feat(bots-discord): land Preact widget for mautrix-discord QR-login with ping-based status, reconnect recovery, and discordapp.com URL parser
This commit is contained in:
parent
156570826a
commit
5eb12f888b
23 changed files with 6022 additions and 4 deletions
3
apps/widget-discord/.gitignore
vendored
Normal file
3
apps/widget-discord/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
*.log
|
||||||
193
apps/widget-discord/README.md
Normal file
193
apps/widget-discord/README.md
Normal file
|
|
@ -0,0 +1,193 @@
|
||||||
|
# @vojo/widget-discord
|
||||||
|
|
||||||
|
Vojo Discord bridge management widget — mounts inside `/bots/discord`
|
||||||
|
in the Vojo client. Mirrors the Telegram widget contract; protocol
|
||||||
|
specifics differ because mautrix-discord runs on the **legacy** mautrix
|
||||||
|
command framework, not bridgev2 (the Discord bridge had not yet been
|
||||||
|
ported to v2 as of January 2026 — see
|
||||||
|
https://mau.fi/blog/2026-01-mautrix-release/).
|
||||||
|
|
||||||
|
This is **not** a Discord client. It's a small panel that drives the
|
||||||
|
mautrix-discord bridge bot (`@discordbot:vojo.chat`) by sending text
|
||||||
|
commands in the control DM and rendering the bot's text replies. It
|
||||||
|
ships QR-only login (the Discord token-login flow stays accessible via
|
||||||
|
chat-fallback for power users).
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── bootstrap.ts Parse URL params (matches BotWidgetEmbed.ts)
|
||||||
|
├── widget-api.ts Inline matrix-widget-api postMessage transport
|
||||||
|
├── App.tsx UI: status pill, QR panel, logout / reconnect cards, transcript
|
||||||
|
├── main.tsx Entry: bootstrap + render
|
||||||
|
├── state.ts LoginState reducer + hydrate-from-timeline
|
||||||
|
├── styles.css Theme-aware CSS variables (Dawn palette)
|
||||||
|
├── i18n/ Tiny RU/EN dictionary harness
|
||||||
|
└── bridge-protocol/
|
||||||
|
├── types.ts LoginEvent + ParsableEvent types
|
||||||
|
├── parser.ts Dialect dispatch shim
|
||||||
|
└── dialects/
|
||||||
|
└── legacy_v076.ts mautrix-discord v0.7.6 wording
|
||||||
|
```
|
||||||
|
|
||||||
|
## Login flow (QR only)
|
||||||
|
|
||||||
|
1. Widget sends `!discord login-qr`.
|
||||||
|
2. Bridge replies with an `m.image` event whose `body` is a Discord
|
||||||
|
remoteauth URL (`https://discord.com/ra/<token>`). The host driver
|
||||||
|
strips `url`/`file`/`info` so the widget never touches the uploaded
|
||||||
|
PNG bytes — it re-encodes the URL into an SVG QR matrix client-side
|
||||||
|
via `qrcode-generator`.
|
||||||
|
3. The user scans the QR with the **Discord mobile app** (Settings →
|
||||||
|
Devices → Scan QR Code). Discord's remoteauth gateway requires the
|
||||||
|
mobile app — desktop Discord and the browser cannot scan.
|
||||||
|
4. Bridge redacts the `m.image` event after a successful scan and sends
|
||||||
|
`Successfully logged in as @<username>`.
|
||||||
|
5. Widget fires `!discord ping` to pick up the discord snowflake for
|
||||||
|
the connected pill.
|
||||||
|
|
||||||
|
If Discord asks for a CAPTCHA, the bridge replies with the standard
|
||||||
|
error line plus a hint about token-login. The widget surfaces an amber
|
||||||
|
warning suggesting the user retry later or use chat-fallback.
|
||||||
|
|
||||||
|
## Status probe
|
||||||
|
|
||||||
|
Discord's legacy command system has no `list-logins` API; status is
|
||||||
|
queried via `!discord ping`. The four reply variants map to four UI
|
||||||
|
states:
|
||||||
|
|
||||||
|
- `You're not logged in` → disconnected
|
||||||
|
- `You're logged in as @x (\`<id>\`)` → connected
|
||||||
|
- `You have a Discord token stored, but are not connected for some reason 🤔` → connected_dead (token_stored)
|
||||||
|
- `You're logged in, but the Discord connection seems to be dead 💥` → connected_dead (connection_dead)
|
||||||
|
|
||||||
|
`connected_dead` exposes a «Переподключиться» card that sends
|
||||||
|
`!discord reconnect`. `disconnect` is recognised for chat-fallback
|
||||||
|
typists but never sent by the widget.
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
Same overlay mechanism as the Telegram widget — create
|
||||||
|
`config.local.json` at the project root (gitignored) with a `bots[]`
|
||||||
|
entry overriding the discord widget's `experience.url` to your local
|
||||||
|
dev server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# one-time: install widget deps
|
||||||
|
cd apps/widget-discord && npm install
|
||||||
|
|
||||||
|
# config.local.json (gitignored) at the project root
|
||||||
|
cat > /home/ubuntu/projects/vojo/cinny/config.local.json <<'JSON'
|
||||||
|
{
|
||||||
|
"bots": [
|
||||||
|
{
|
||||||
|
"id": "discord",
|
||||||
|
"experience": {
|
||||||
|
"type": "matrix-widget",
|
||||||
|
"url": "http://localhost:8082/",
|
||||||
|
"commandPrefix": "!discord"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
```
|
||||||
|
|
||||||
|
`http://localhost:*` URLs pass the host's URL validator only in dev
|
||||||
|
builds — see `src/app/features/bots/catalog.ts` `import.meta.env.DEV`
|
||||||
|
branch. Production builds drop the branch via Vite's dead-code
|
||||||
|
elimination AND enforce an origin allowlist (`PROD_WIDGET_ORIGINS`).
|
||||||
|
|
||||||
|
Run both servers:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# terminal 1 — widget on :8082 with HMR
|
||||||
|
cd apps/widget-discord && npm run dev
|
||||||
|
|
||||||
|
# terminal 2 — host SPA on :8080
|
||||||
|
cd /home/ubuntu/projects/vojo/cinny && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Open `http://localhost:8080/bots/discord`. The Telegram widget on :8081
|
||||||
|
can run in parallel with no port conflict.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Outputs to `apps/widget-discord/dist/`. Deploy by rsyncing `dist/*` into
|
||||||
|
`~/vojo/widgets/discord/` on the production host (Caddy serves this via
|
||||||
|
the `widgets.vojo.chat` block).
|
||||||
|
|
||||||
|
## Hosting (server-side, runbook)
|
||||||
|
|
||||||
|
Pre-requisite: `widgets.vojo.chat` already exists for the Telegram
|
||||||
|
widget — only the Caddy `widgets.vojo.chat` block needs a new
|
||||||
|
`handle_path` and the docker host needs a new directory.
|
||||||
|
|
||||||
|
1. `~/vojo/caddy/Caddyfile` — append to the existing
|
||||||
|
`widgets.vojo.chat { … }` block, beside the Telegram `handle_path`:
|
||||||
|
```
|
||||||
|
handle_path /discord/* {
|
||||||
|
root * /var/www/widgets/discord
|
||||||
|
try_files {path} /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
```
|
||||||
|
2. `mkdir -p ~/vojo/widgets/discord` (placeholder so the bind-mount has
|
||||||
|
something to serve), then `docker compose up -d caddy` (or `reload`).
|
||||||
|
3. Verify directly:
|
||||||
|
`curl -I https://widgets.vojo.chat/discord/index.html` should
|
||||||
|
return 200 and the `Content-Security-Policy` header.
|
||||||
|
|
||||||
|
## Adding the discord bridge to docker-compose
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
discord-bridge:
|
||||||
|
image: dock.mau.dev/mautrix/discord:v0.7.6
|
||||||
|
restart: unless-stopped
|
||||||
|
volumes:
|
||||||
|
- ./mautrix-discord:/data
|
||||||
|
```
|
||||||
|
|
||||||
|
Then `~/vojo/synapse/homeserver.yaml` needs the discord registration
|
||||||
|
file added to `app_service_config_files`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
app_service_config_files:
|
||||||
|
- /data/telegram-registration.yaml
|
||||||
|
- /data/discord-registration.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
The bridge's `command_prefix` defaults to `!discord` — keep it that
|
||||||
|
way so it matches the widget's `experience.commandPrefix`. If you
|
||||||
|
override it in `mautrix-discord/config.yaml`, mirror the override in
|
||||||
|
`/config.json`.
|
||||||
|
|
||||||
|
## Capacitor (Android)
|
||||||
|
|
||||||
|
`capacitor.config.ts` already allow-navigates `widgets.vojo.chat` for
|
||||||
|
the Telegram widget; no further change needed.
|
||||||
|
|
||||||
|
## Capability contract
|
||||||
|
|
||||||
|
The widget requests EXACTLY this set (matches the host's
|
||||||
|
`BotWidgetDriver.getBotWidgetCapabilities`):
|
||||||
|
|
||||||
|
```
|
||||||
|
org.matrix.msc2762.timeline:<roomId>
|
||||||
|
org.matrix.msc2762.send.event:m.room.message#m.text
|
||||||
|
org.matrix.msc2762.receive.event:m.room.message#m.text
|
||||||
|
org.matrix.msc2762.receive.event:m.room.message#m.notice
|
||||||
|
org.matrix.msc2762.receive.event:m.room.message#m.image
|
||||||
|
org.matrix.msc2762.receive.event:m.room.redaction
|
||||||
|
org.matrix.msc2762.receive.state_event:m.room.member
|
||||||
|
```
|
||||||
|
|
||||||
|
`m.image` is the QR carrier; `m.room.redaction` signals the bridge
|
||||||
|
consumed the QR after a successful scan. The host sanitizer strips
|
||||||
|
`url`/`file`/`info` from `m.image` content, so only the QR URL string
|
||||||
|
inside `body` survives the boundary.
|
||||||
12
apps/widget-discord/index.html
Normal file
12
apps/widget-discord/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
|
<title>Discord bridge — Vojo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1999
apps/widget-discord/package-lock.json
generated
Normal file
1999
apps/widget-discord/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
21
apps/widget-discord/package.json
Normal file
21
apps/widget-discord/package.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"name": "@vojo/widget-discord",
|
||||||
|
"version": "0.0.1",
|
||||||
|
"private": true,
|
||||||
|
"description": "Vojo Discord bridge management widget — mounts inside /bots/discord",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "10.22.1",
|
||||||
|
"qrcode-generator": "1.4.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "2.9.0",
|
||||||
|
"typescript": "5.4.5",
|
||||||
|
"vite": "5.4.19"
|
||||||
|
}
|
||||||
|
}
|
||||||
965
apps/widget-discord/src/App.tsx
Normal file
965
apps/widget-discord/src/App.tsx
Normal file
|
|
@ -0,0 +1,965 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'preact/hooks';
|
||||||
|
import type { ComponentChildren } from 'preact';
|
||||||
|
import qrcodeGenerator from 'qrcode-generator';
|
||||||
|
import type { WidgetBootstrap } from './bootstrap';
|
||||||
|
import { WidgetApi, type RoomEvent } from './widget-api';
|
||||||
|
import { createT, type T } from './i18n';
|
||||||
|
import { parseEvent } from './bridge-protocol/parser';
|
||||||
|
import {
|
||||||
|
hydrateFromTimeline,
|
||||||
|
initialLoginState,
|
||||||
|
loginReducer,
|
||||||
|
type HydrateInput,
|
||||||
|
type LoginErrorFlag,
|
||||||
|
} from './state';
|
||||||
|
|
||||||
|
// Visual canon mirrors the Telegram widget — Dawn palette, fleet-violet
|
||||||
|
// accent, monospace handles. The Discord widget keeps Vojo's accent (per
|
||||||
|
// product decision: «used Vojo style») rather than adopting Discord
|
||||||
|
// blurple, so the panel reads as a coherent continuation of the host UI.
|
||||||
|
|
||||||
|
type TranscriptKind = 'from-bot' | 'from-user' | 'diag' | 'error';
|
||||||
|
|
||||||
|
type TranscriptLine = {
|
||||||
|
id: string;
|
||||||
|
ts: number;
|
||||||
|
kind: TranscriptKind;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bootstrap: WidgetBootstrap;
|
||||||
|
// The WidgetApi is constructed in main.tsx synchronously, BEFORE React's
|
||||||
|
// first render — see widget-telegram for the cached-bundle race rationale.
|
||||||
|
api: WidgetApi;
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSCRIPT_MAX = 200;
|
||||||
|
|
||||||
|
// Inline SVG refresh icon — same as TG widget for visual consistency.
|
||||||
|
const RefreshIcon = () => (
|
||||||
|
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
|
||||||
|
<path d="M3.5 8.5a6.5 6.5 0 0 1 11.4-3.2" stroke-linecap="round" />
|
||||||
|
<path d="M16.5 11.5a6.5 6.5 0 0 1-11.4 3.2" stroke-linecap="round" />
|
||||||
|
<path d="M14.6 3.2v3.5h-3.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
<path d="M5.4 16.8v-3.5h3.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Linkifier — same heuristic as TG widget.
|
||||||
|
const URL_RE = /https?:\/\/[^\s)]+/g;
|
||||||
|
|
||||||
|
// Defense-in-depth: a Discord remoteauth login URL is the LIVE login
|
||||||
|
// secret. Today the bridge only emits it via `m.image` (which we route
|
||||||
|
// to a generic «QR-код выдан» diag, never a verbatim transcript line).
|
||||||
|
// But if a future bridge revision started echoing the URL into m.notice
|
||||||
|
// — say, for a chat-fallback fallback path — the existing transcript
|
||||||
|
// append would (a) store the URL in the DOM, (b) survive page reload via
|
||||||
|
// the hydrate replay, and (c) the linkifier would turn it into a
|
||||||
|
// clickable anchor that opens in the parent browser, leaving the active
|
||||||
|
// login token in the user's history. Scrubbing here makes the leak
|
||||||
|
// path closed even if the upstream wiring drifts.
|
||||||
|
const REMOTEAUTH_URL_RE =
|
||||||
|
/https:\/\/(?:[a-z0-9-]+\.)?(?:discordapp|discord)\.com\/(?:ra|login\/handoff)\/[A-Za-z0-9_\-+=.~?&/]+/gi;
|
||||||
|
const scrubLoginSecret = (body: string): string =>
|
||||||
|
body.replace(REMOTEAUTH_URL_RE, '[redacted login URL]');
|
||||||
|
|
||||||
|
const formatTime = (ts: number): string => {
|
||||||
|
const d = new Date(ts);
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
return `${hh}:${mm}:${ss}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderBody = (body: string): ComponentChildren => {
|
||||||
|
const out: ComponentChildren[] = [];
|
||||||
|
let lastIndex = 0;
|
||||||
|
for (const match of body.matchAll(URL_RE)) {
|
||||||
|
const idx = match.index ?? 0;
|
||||||
|
if (idx > lastIndex) out.push(body.slice(lastIndex, idx));
|
||||||
|
out.push(
|
||||||
|
<a key={`${idx}-${match[0]}`} href={match[0]} target="_blank" rel="noreferrer noopener">
|
||||||
|
{match[0]}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
lastIndex = idx + match[0].length;
|
||||||
|
}
|
||||||
|
if (lastIndex < body.length) out.push(body.slice(lastIndex));
|
||||||
|
return out.length === 0 ? body : out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const localizeError = (err: LoginErrorFlag, t: T): string => {
|
||||||
|
switch (err.kind) {
|
||||||
|
case 'login_failed':
|
||||||
|
return t('auth-error.login-failed', { reason: err.reason ?? '' });
|
||||||
|
case 'captcha_required':
|
||||||
|
return t('auth-error.captcha-required');
|
||||||
|
case 'login_websocket_failed':
|
||||||
|
return t('auth-error.websocket-failed', { reason: err.reason ?? '' });
|
||||||
|
case 'connect_after_login_failed':
|
||||||
|
return t('auth-error.connect-after-login-failed', { reason: err.reason ?? '' });
|
||||||
|
case 'prepare_login_failed':
|
||||||
|
return t('auth-error.prepare-failed', { reason: err.reason ?? '' });
|
||||||
|
case 'already_logged_in':
|
||||||
|
return t('auth-error.already-logged-in');
|
||||||
|
case 'unknown_command':
|
||||||
|
return t('auth-error.unknown-command');
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = err;
|
||||||
|
return String(exhaustive);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Captcha is the only «not really an error, more of a suggestion» case —
|
||||||
|
// surface as warn (amber) rather than red. Everything else is a hard
|
||||||
|
// failure of the login attempt and gets red treatment.
|
||||||
|
const errorTone = (err: LoginErrorFlag): 'error' | 'warn' => {
|
||||||
|
if (err.kind === 'captcha_required') return 'warn';
|
||||||
|
if (err.kind === 'already_logged_in') return 'warn';
|
||||||
|
return 'error';
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// QR panel
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Discord remoteauth's server-side timeout sits around 2 minutes of
|
||||||
|
// inactivity (the bridge holds the websocket; Discord's gateway closes
|
||||||
|
// it from its side). 3 minutes is a slight safety margin: the user
|
||||||
|
// sees «expired» a touch after the server probably already dropped
|
||||||
|
// the WS, but never before, so they can't trust a dead QR. This MUST
|
||||||
|
// match HYDRATE_FRESHNESS_MS in state.ts so the timeline-resume window
|
||||||
|
// agrees with the panel countdown — diverging the two would mean a
|
||||||
|
// reload at e.g. 4 min restores the panel even though the panel
|
||||||
|
// itself would render «expired». Telegram's MTProto QR rotates and
|
||||||
|
// lives ~10 min, which is why the TG widget uses 10 min for both.
|
||||||
|
const QR_TIMEOUT_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
|
// Error-correction level M is a good trade-off for short URLs — more
|
||||||
|
// resilient to camera glare than L, smaller modules than Q. typeNumber=0
|
||||||
|
// auto-picks the smallest QR version that fits the payload.
|
||||||
|
const buildQrModules = (data: string): boolean[][] | null => {
|
||||||
|
if (!data) return null;
|
||||||
|
try {
|
||||||
|
const qr = qrcodeGenerator(0, 'M');
|
||||||
|
qr.addData(data);
|
||||||
|
qr.make();
|
||||||
|
const count = qr.getModuleCount();
|
||||||
|
const matrix: boolean[][] = [];
|
||||||
|
for (let r = 0; r < count; r += 1) {
|
||||||
|
const row: boolean[] = [];
|
||||||
|
for (let c = 0; c < count; c += 1) {
|
||||||
|
row.push(qr.isDark(r, c));
|
||||||
|
}
|
||||||
|
matrix.push(row);
|
||||||
|
}
|
||||||
|
return matrix;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render the QR matrix as <rect> elements inside an SVG. We deliberately
|
||||||
|
// avoid `dangerouslySetInnerHTML` and any external QR-rendering service:
|
||||||
|
// the `https://discord.com/ra/...` URL IS the login secret, so it must
|
||||||
|
// never leave the iframe and must never reach a stringified-HTML path
|
||||||
|
// that bypasses Preact's escaping.
|
||||||
|
type QrSvgProps = { matrix: boolean[][]; pixelSize: number; ariaLabel: string };
|
||||||
|
const QrSvg = ({ matrix, pixelSize, ariaLabel }: QrSvgProps) => {
|
||||||
|
const count = matrix.length;
|
||||||
|
const margin = 4;
|
||||||
|
const totalUnits = count + margin * 2;
|
||||||
|
const cellPx = pixelSize / totalUnits;
|
||||||
|
const rects: ComponentChildren[] = [];
|
||||||
|
for (let r = 0; r < count; r += 1) {
|
||||||
|
for (let c = 0; c < count; c += 1) {
|
||||||
|
if (!matrix[r][c]) continue;
|
||||||
|
rects.push(
|
||||||
|
<rect
|
||||||
|
key={`${r}-${c}`}
|
||||||
|
x={(c + margin) * cellPx}
|
||||||
|
y={(r + margin) * cellPx}
|
||||||
|
width={cellPx + 0.5 /* +0.5 px overlap kills subpixel gaps on Android */}
|
||||||
|
height={cellPx + 0.5}
|
||||||
|
fill="#000"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
width={pixelSize}
|
||||||
|
height={pixelSize}
|
||||||
|
viewBox={`0 0 ${pixelSize} ${pixelSize}`}
|
||||||
|
role="img"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
>
|
||||||
|
{rects}
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
type QrPanelProps = {
|
||||||
|
state: {
|
||||||
|
kind: 'awaiting_qr_scan';
|
||||||
|
discordUrl: string;
|
||||||
|
firstShownAt: number;
|
||||||
|
lastError?: LoginErrorFlag;
|
||||||
|
};
|
||||||
|
t: T;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const QrPanel = ({ state, t, onCancel }: QrPanelProps) => {
|
||||||
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||||
|
return () => window.clearInterval(timer);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const matrix = useMemo(() => buildQrModules(state.discordUrl), [state.discordUrl]);
|
||||||
|
const elapsed = state.firstShownAt > 0 ? now - state.firstShownAt : 0;
|
||||||
|
const remainingSeconds = Math.max(0, Math.ceil((QR_TIMEOUT_MS - elapsed) / 1000));
|
||||||
|
const expired = elapsed >= QR_TIMEOUT_MS && state.firstShownAt > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="auth-card auth-card-qr">
|
||||||
|
<div class="auth-card-title">{t('auth-card.qr.title')}</div>
|
||||||
|
<div class="auth-card-hint">{t('auth-card.qr.hint')}</div>
|
||||||
|
<div class="auth-card-qr-frame">
|
||||||
|
{matrix ? (
|
||||||
|
// The aria-label describes the PURPOSE, not the contents — the
|
||||||
|
// URL itself is the login secret and must not be exposed via
|
||||||
|
// AT-tree text content.
|
||||||
|
<QrSvg matrix={matrix} pixelSize={232} ariaLabel={t('auth-card.qr.aria')} />
|
||||||
|
) : (
|
||||||
|
<div class="auth-card-qr-placeholder" role="status" aria-live="polite">
|
||||||
|
<span class="dot" />
|
||||||
|
{t('auth-card.qr.preparing')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{!expired ? (
|
||||||
|
<div class="auth-card-countdown">
|
||||||
|
{t('auth-card.qr.countdown', {
|
||||||
|
minutes: String(Math.floor(remainingSeconds / 60)),
|
||||||
|
seconds: String(remainingSeconds % 60).padStart(2, '0'),
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div class="auth-card-countdown expired">{t('auth-card.qr.expired')}</div>
|
||||||
|
)}
|
||||||
|
<ol class="auth-card-qr-steps">
|
||||||
|
<li>{t('auth-card.qr.step-1')}</li>
|
||||||
|
<li>{t('auth-card.qr.step-2')}</li>
|
||||||
|
<li>{t('auth-card.qr.step-3')}</li>
|
||||||
|
</ol>
|
||||||
|
{state.lastError ? (
|
||||||
|
<div class={errorTone(state.lastError) === 'warn' ? 'auth-card-warn' : 'auth-card-error'}>
|
||||||
|
{localizeError(state.lastError, t)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div class="auth-card-row">
|
||||||
|
<button type="button" class="btn-text" onClick={onCancel}>
|
||||||
|
{t('auth-card.cancel')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// About card + modal
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type AboutCardProps = {
|
||||||
|
t: T;
|
||||||
|
onOpen: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AboutCard = ({ t, onOpen }: AboutCardProps) => (
|
||||||
|
<button class="command-card" type="button" onClick={onOpen}>
|
||||||
|
<div class="command-card-body">
|
||||||
|
<div class="command-card-name">{t('card.about.name')}</div>
|
||||||
|
<div class="command-card-desc">{t('card.about.desc')}</div>
|
||||||
|
</div>
|
||||||
|
<span class="command-card-chevron" aria-hidden="true">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
type AboutModalProps = {
|
||||||
|
t: T;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const AboutModal = ({ t, onClose }: AboutModalProps) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') onClose();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class="about-overlay"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label={t('about.title')}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.target === e.currentTarget) onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div class="about-panel">
|
||||||
|
<header class="about-header">
|
||||||
|
<h2 class="about-title">{t('about.title')}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="about-close-x"
|
||||||
|
onClick={onClose}
|
||||||
|
aria-label={t('about.aria-close')}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div class="about-body">
|
||||||
|
<p>{t('about.body-1')}</p>
|
||||||
|
<p>{t('about.body-2')}</p>
|
||||||
|
<p>{t('about.body-3')}</p>
|
||||||
|
<p>
|
||||||
|
{t('about.github-label')}{' '}
|
||||||
|
<a href={t('about.github-url')} target="_blank" rel="noreferrer">
|
||||||
|
{t('about.github-url')}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<p>{t('about.body-4')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="about-footer">
|
||||||
|
<button type="button" class="btn-primary" onClick={onClose}>
|
||||||
|
{t('about.close')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Logout card with confirm-in-place
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type LogoutCardProps = {
|
||||||
|
t: T;
|
||||||
|
onConfirm: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LogoutCard = ({ t, onConfirm }: LogoutCardProps) => {
|
||||||
|
const [confirming, setConfirming] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
// Belt-and-suspenders against double-submit. `disabled={submitting}` covers
|
||||||
|
// 99% of cases, but there's a microtask window between click and Preact
|
||||||
|
// rendering the disabled state where a fast second click could fire.
|
||||||
|
const inFlight = useRef(false);
|
||||||
|
|
||||||
|
if (confirming) {
|
||||||
|
return (
|
||||||
|
<div class="command-card danger">
|
||||||
|
<div class="command-card-confirm">
|
||||||
|
<span class="command-card-confirm-prompt">{t('card.logout.confirm-prompt')}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="command-card-confirm-yes"
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={async () => {
|
||||||
|
if (inFlight.current) return;
|
||||||
|
inFlight.current = true;
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await onConfirm();
|
||||||
|
} finally {
|
||||||
|
inFlight.current = false;
|
||||||
|
setSubmitting(false);
|
||||||
|
setConfirming(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('card.logout.confirm-yes')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="command-card-confirm-no"
|
||||||
|
disabled={submitting}
|
||||||
|
onClick={() => setConfirming(false)}
|
||||||
|
>
|
||||||
|
{t('card.logout.confirm-no')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button class="command-card danger" type="button" onClick={() => setConfirming(true)}>
|
||||||
|
<div class="command-card-body">
|
||||||
|
<div class="command-card-name">{t('card.logout.name')}</div>
|
||||||
|
<div class="command-card-desc">{t('card.logout.desc')}</div>
|
||||||
|
</div>
|
||||||
|
<span class="command-card-chevron" aria-hidden="true">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
// Main App
|
||||||
|
// --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function App({ bootstrap, api }: Props) {
|
||||||
|
const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme);
|
||||||
|
const [transcript, setTranscript] = useState<TranscriptLine[]>([]);
|
||||||
|
const [handshakeOk, setHandshakeOk] = useState(false);
|
||||||
|
const [aboutOpen, setAboutOpen] = useState(false);
|
||||||
|
// True while a `ping` probe is in flight from a refresh-card click.
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
const seenEventIds = useRef(new Set<string>());
|
||||||
|
const [state, dispatch] = useReducer(loginReducer, initialLoginState);
|
||||||
|
|
||||||
|
// stateRef mirrors latest reducer state so async live-event listeners
|
||||||
|
// (attached once at mount) read current state without their stale
|
||||||
|
// closure capturing the initial `unknown` snapshot. Used by transcript
|
||||||
|
// diag gate for `qr_redacted`.
|
||||||
|
const stateRef = useRef(state);
|
||||||
|
useEffect(() => {
|
||||||
|
stateRef.current = state;
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const t = useMemo(() => createT(bootstrap.clientLanguage), [bootstrap.clientLanguage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.theme = theme;
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
const append = useCallback((line: Omit<TranscriptLine, 'id' | 'ts'>) => {
|
||||||
|
setTranscript((prev) => {
|
||||||
|
const next = [...prev, { ...line, id: `${Date.now()}-${Math.random()}`, ts: Date.now() }];
|
||||||
|
return next.length > TRANSCRIPT_MAX ? next.slice(-TRANSCRIPT_MAX) : next;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Newest-at-top — pin scroll to top on each new line.
|
||||||
|
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const el = transcriptRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollTop = 0;
|
||||||
|
}, [transcript.length]);
|
||||||
|
|
||||||
|
// Subscribe to widget-api events for capability handshake completion,
|
||||||
|
// live events, and theme updates. The `api` itself is constructed in
|
||||||
|
// main.tsx BEFORE React's first render so its postMessage listener is
|
||||||
|
// already attached — this effect only wires React state to the api's
|
||||||
|
// event surface. WidgetApi.on('ready', ...) self-replays if the
|
||||||
|
// handshake already finished by the time we attach.
|
||||||
|
useEffect(() => {
|
||||||
|
let disposed = false;
|
||||||
|
|
||||||
|
api.on('ready', () => {
|
||||||
|
setHandshakeOk(true);
|
||||||
|
append({ kind: 'diag', text: t('diag.ready') });
|
||||||
|
append({ kind: 'diag', text: t('diag.checking-status') });
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
// Timeline-resume: scan the recent room history BEFORE firing
|
||||||
|
// ping. Discord's QR flow doesn't have multi-step prompts (no
|
||||||
|
// phone/code/password ladder), but a reload during an active QR
|
||||||
|
// scan SHOULD restore the QR panel — otherwise the user reloads,
|
||||||
|
// sees disconnected, hits «Войти по QR» again, and the bridge
|
||||||
|
// creates a SECOND remoteauth session in parallel with the first
|
||||||
|
// (commands.go has no session-deduplication; each call spins a
|
||||||
|
// fresh remoteauth.Client goroutine). The hydrate path here is
|
||||||
|
// identical in shape to the TG widget's: pull notices, images,
|
||||||
|
// and redactions in parallel and feed them chronologically into
|
||||||
|
// the hydrate reducer.
|
||||||
|
let hydrated = false;
|
||||||
|
try {
|
||||||
|
const settled = await Promise.allSettled([
|
||||||
|
api.readTimeline({ limit: 30, type: 'm.room.message', msgtype: 'm.notice' }),
|
||||||
|
api.readTimeline({ limit: 30, type: 'm.room.message', msgtype: 'm.text' }),
|
||||||
|
// QR images: Discord doesn't rotate, so 10 events is plenty
|
||||||
|
// (each login attempt produces exactly one m.image). Keep
|
||||||
|
// headroom for back-history if the user did multiple
|
||||||
|
// attempts in this room over time.
|
||||||
|
api.readTimeline({ limit: 10, type: 'm.room.message', msgtype: 'm.image' }),
|
||||||
|
api.readTimeline({ limit: 10, type: 'm.room.redaction' }),
|
||||||
|
]);
|
||||||
|
if (disposed) return;
|
||||||
|
const pickValue = (s: PromiseSettledResult<RoomEvent[]>): RoomEvent[] =>
|
||||||
|
s.status === 'fulfilled' ? s.value : [];
|
||||||
|
const notices = pickValue(settled[0]);
|
||||||
|
const texts = pickValue(settled[1]);
|
||||||
|
const qrImages = pickValue(settled[2]);
|
||||||
|
const redactions = pickValue(settled[3]);
|
||||||
|
|
||||||
|
const fromBot = (events: RoomEvent[]) =>
|
||||||
|
events.filter((e) => e.sender === bootstrap.botMxid);
|
||||||
|
|
||||||
|
// Sort by origin_server_ts ascending, tie-break on event_id.
|
||||||
|
// Without the tie-break, equal-timestamp events from different
|
||||||
|
// streams could process in nondeterministic order.
|
||||||
|
const merged = [
|
||||||
|
...fromBot(notices),
|
||||||
|
...fromBot(texts),
|
||||||
|
...fromBot(qrImages),
|
||||||
|
...fromBot(redactions),
|
||||||
|
].sort((a, b) => {
|
||||||
|
const tsDiff = a.origin_server_ts - b.origin_server_ts;
|
||||||
|
if (tsDiff !== 0) return tsDiff;
|
||||||
|
return a.event_id < b.event_id ? -1 : a.event_id > b.event_id ? 1 : 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const inputs: HydrateInput[] = merged.map((e) => ({
|
||||||
|
ev: parseEvent(e),
|
||||||
|
ts: e.origin_server_ts,
|
||||||
|
}));
|
||||||
|
const restored = hydrateFromTimeline(inputs);
|
||||||
|
|
||||||
|
if (restored) {
|
||||||
|
// Conservative transcript replay. m.image events are replaced
|
||||||
|
// with a generic «QR-код выдан» diag — never replay the raw
|
||||||
|
// discord.com/ra/<token> body, that would persist the login
|
||||||
|
// token in DOM history past the bridge's redaction. Bot
|
||||||
|
// notices replay verbatim (they're already redacted of
|
||||||
|
// sensitive data by the bridge).
|
||||||
|
let appendedAnyHistory = false;
|
||||||
|
const seenQrIds = new Set<string>();
|
||||||
|
for (const e of merged) {
|
||||||
|
if (seenEventIds.current.has(e.event_id)) continue;
|
||||||
|
seenEventIds.current.add(e.event_id);
|
||||||
|
const parsed = parseEvent(e);
|
||||||
|
if (parsed.kind === 'qr_displayed') {
|
||||||
|
seenQrIds.add(parsed.eventId);
|
||||||
|
if (parsed.replacesEventId) seenQrIds.add(parsed.replacesEventId);
|
||||||
|
append({ kind: 'diag', text: t('diag.qr-issued') });
|
||||||
|
appendedAnyHistory = true;
|
||||||
|
} else if (parsed.kind === 'qr_redacted') {
|
||||||
|
if (seenQrIds.has(parsed.redactsEventId)) {
|
||||||
|
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||||
|
appendedAnyHistory = true;
|
||||||
|
}
|
||||||
|
} else if (e.type === 'm.room.message' && e.content.msgtype !== 'm.image') {
|
||||||
|
// m.text / m.notice — body is safe to replay verbatim,
|
||||||
|
// BUT we still scrub any login-URL-shaped substring as
|
||||||
|
// belt-and-suspenders against future bridge wording
|
||||||
|
// drift that could echo the URL through a notice.
|
||||||
|
append({
|
||||||
|
kind: 'from-bot',
|
||||||
|
text: `← ${scrubLoginSecret(e.content.body ?? '')}`,
|
||||||
|
});
|
||||||
|
appendedAnyHistory = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (appendedAnyHistory) {
|
||||||
|
append({ kind: 'diag', text: t('diag.history-marker') });
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({ kind: 'hydrate', state: restored });
|
||||||
|
hydrated = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!disposed) {
|
||||||
|
append({ kind: 'diag', text: t('diag.history-unavailable') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disposed) return;
|
||||||
|
if (!hydrated) {
|
||||||
|
// Discord's status probe is `ping`, not `list-logins`. The reply
|
||||||
|
// routes through the reducer to disconnected / connected /
|
||||||
|
// connected_dead.
|
||||||
|
api.sendCommand('ping').catch((err) => {
|
||||||
|
if (disposed) return;
|
||||||
|
append({
|
||||||
|
kind: 'error',
|
||||||
|
text: t('diag.send-failed', { message: (err as Error).message }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
api.on('themeChange', (name) => setTheme(name));
|
||||||
|
|
||||||
|
api.on('liveEvent', (ev: RoomEvent) => {
|
||||||
|
if (seenEventIds.current.has(ev.event_id)) return;
|
||||||
|
seenEventIds.current.add(ev.event_id);
|
||||||
|
// Defense-in-depth sender filter — the host's strict 1:1 invariant
|
||||||
|
// already guarantees this, but pinning to bootstrap.botMxid prevents
|
||||||
|
// (a) skipping our own outbound echoes (already appended optimistically),
|
||||||
|
// (b) third-party noise that somehow slips past the 1:1 invariant.
|
||||||
|
if (ev.sender !== bootstrap.botMxid) return;
|
||||||
|
|
||||||
|
const event = parseEvent(ev);
|
||||||
|
|
||||||
|
// Transcript routing is GATED on the parser's verdict, not raw event
|
||||||
|
// type. Same logic as TG widget: m.image bodies are NEVER appended
|
||||||
|
// verbatim (they ARE the login secret); QR-redaction diag fires only
|
||||||
|
// for the active QR.
|
||||||
|
if (event.kind === 'qr_displayed') {
|
||||||
|
append({ kind: 'diag', text: t('diag.qr-issued') });
|
||||||
|
} else if (event.kind === 'qr_redacted') {
|
||||||
|
const liveState = stateRef.current;
|
||||||
|
if (
|
||||||
|
liveState.kind === 'awaiting_qr_scan' &&
|
||||||
|
liveState.qrEventId === event.redactsEventId
|
||||||
|
) {
|
||||||
|
append({ kind: 'diag', text: t('diag.qr-consumed') });
|
||||||
|
}
|
||||||
|
} else if (ev.type === 'm.room.message' && ev.content.msgtype !== 'm.image') {
|
||||||
|
const body = ev.content.body ?? '';
|
||||||
|
append({ kind: 'from-bot', text: `← ${scrubLoginSecret(body)}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch({ kind: 'event', event });
|
||||||
|
|
||||||
|
// Fire `ping` after lifecycle transitions that need authoritative
|
||||||
|
// state reconciliation:
|
||||||
|
// * login_success — the success line lacks the discordId; ping
|
||||||
|
// picks it up so the connected pill can show the snowflake.
|
||||||
|
// * reconnect_ok / reconnect_no_op — flips us back into connected
|
||||||
|
// but with potentially-stale handle; ping refreshes.
|
||||||
|
// * already_logged_in — bridge says we tried login while already
|
||||||
|
// in. Without a re-ping the QR-form stays open with a warn
|
||||||
|
// banner forever (no QR will ever come because the bridge
|
||||||
|
// bails before remoteauth.New). Re-pinging routes us to the
|
||||||
|
// connected pill so the user can click logout if they wanted
|
||||||
|
// a fresh login.
|
||||||
|
if (
|
||||||
|
event.kind === 'login_success' ||
|
||||||
|
event.kind === 'reconnect_ok' ||
|
||||||
|
event.kind === 'reconnect_no_op' ||
|
||||||
|
event.kind === 'already_logged_in'
|
||||||
|
) {
|
||||||
|
api.sendCommand('ping').catch(() => {
|
||||||
|
/* surface in diag is overkill; the connected pill still shows
|
||||||
|
the handle even without the snowflake */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
append({ kind: 'diag', text: t('diag.connecting') });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
api.dispose();
|
||||||
|
};
|
||||||
|
// `api`, `bootstrap`, `t`, and `append` are stable for the App's lifetime.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Outbound bare-command + transcript echo. Errors append to transcript
|
||||||
|
// AND rethrow — callers decide whether to roll back optimistic transitions.
|
||||||
|
// `api` is a stable singleton owned by main.tsx; closing over it directly
|
||||||
|
// is safe (the App's lifetime is the iframe's, and api.dispose() in the
|
||||||
|
// unmount cleanup makes any in-flight sends fail loudly).
|
||||||
|
const sendBare = useCallback(
|
||||||
|
async (command: string): Promise<void> => {
|
||||||
|
append({ kind: 'from-user', text: `→ ${command}` });
|
||||||
|
try {
|
||||||
|
await api.sendCommand(command);
|
||||||
|
} catch (err) {
|
||||||
|
append({
|
||||||
|
kind: 'error',
|
||||||
|
text: t('diag.send-failed', { message: (err as Error).message }),
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[append, api, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
// In-flight guard against double-tap. The button is on the disconnected
|
||||||
|
// screen which unmounts as soon as state advances, BUT a rapid second
|
||||||
|
// click can fire in the microtask window between dispatch and the next
|
||||||
|
// Preact commit (especially on Android WebView, where a tap-rebound can
|
||||||
|
// synthesise a second click). For login-qr, a duplicate would spin a
|
||||||
|
// SECOND remoteauth goroutine on the bridge in parallel — harmless but
|
||||||
|
// wastes a remoteauth session.
|
||||||
|
const loginInFlight = useRef(false);
|
||||||
|
|
||||||
|
const onClickLoginQr = useCallback(async () => {
|
||||||
|
if (loginInFlight.current) return;
|
||||||
|
loginInFlight.current = true;
|
||||||
|
dispatch({ kind: 'start_qr_login' });
|
||||||
|
try {
|
||||||
|
await sendBare('login-qr');
|
||||||
|
} catch {
|
||||||
|
dispatch({ kind: 'cancel_pending' });
|
||||||
|
} finally {
|
||||||
|
loginInFlight.current = false;
|
||||||
|
}
|
||||||
|
}, [sendBare]);
|
||||||
|
|
||||||
|
// Cancel is LOCAL — Discord legacy mautrix has no `cancel` command.
|
||||||
|
// Returns the widget to disconnected; the bridge's remoteauth goroutine
|
||||||
|
// continues until success / failure / internal timeout.
|
||||||
|
const onClickCancel = useCallback(() => {
|
||||||
|
dispatch({ kind: 'cancel_pending' });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onClickRefresh = useCallback(async () => {
|
||||||
|
if (refreshing) return;
|
||||||
|
setRefreshing(true);
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
await sendBare('ping');
|
||||||
|
} catch {
|
||||||
|
/* transcript carries the failure */
|
||||||
|
}
|
||||||
|
// 500 ms minimum visible loading state — without this, a fast healthy
|
||||||
|
// transport (<100ms round-trip) skips a paint frame entirely and the
|
||||||
|
// click goes visually unacknowledged.
|
||||||
|
const elapsed = Date.now() - start;
|
||||||
|
if (elapsed < 500) {
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
window.setTimeout(resolve, 500 - elapsed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
setRefreshing(false);
|
||||||
|
}, [refreshing, sendBare]);
|
||||||
|
|
||||||
|
const onConfirmLogout = useCallback(async () => {
|
||||||
|
dispatch({ kind: 'request_logout' });
|
||||||
|
try {
|
||||||
|
await sendBare('logout');
|
||||||
|
} catch {
|
||||||
|
// Recovery: refire ping so the reducer recalibrates from bridge truth
|
||||||
|
// instead of leaving the UI stuck in logging_out forever.
|
||||||
|
sendBare('ping').catch(() => {
|
||||||
|
/* user can hit refresh */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [sendBare]);
|
||||||
|
|
||||||
|
const onClickReconnect = useCallback(async () => {
|
||||||
|
// Carry the current handle through `reconnecting` so the post-reconnect
|
||||||
|
// success path can flip directly to `connected{handle}` without
|
||||||
|
// bouncing through `unknown`. The handle is read from whichever
|
||||||
|
// pre-reconnect state we're in (connected_dead is the typical
|
||||||
|
// entry, but a manual disconnect path could leave us in connected
|
||||||
|
// and trigger reconnect from there).
|
||||||
|
const handle =
|
||||||
|
state.kind === 'connected_dead' || state.kind === 'connected'
|
||||||
|
? state.handle
|
||||||
|
: undefined;
|
||||||
|
dispatch({ kind: 'request_reconnect', handle });
|
||||||
|
try {
|
||||||
|
await sendBare('reconnect');
|
||||||
|
} catch {
|
||||||
|
sendBare('ping').catch(() => {
|
||||||
|
/* user can hit refresh */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [sendBare, state]);
|
||||||
|
|
||||||
|
// Convenience: render a status pill with optional recovery button.
|
||||||
|
type StatusRowProps = {
|
||||||
|
tone: 'connected' | 'disconnected' | 'checking';
|
||||||
|
label: string;
|
||||||
|
recovery?: { label: string; icon?: ComponentChildren; onClick: () => void; disabled?: boolean };
|
||||||
|
};
|
||||||
|
const StatusRow = ({ tone, label, recovery }: StatusRowProps) => {
|
||||||
|
const pill = (
|
||||||
|
<span class={`section-status ${tone}`} role="status">
|
||||||
|
<span class="dot" />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
if (!recovery) return pill;
|
||||||
|
return (
|
||||||
|
<div class="section-recovery-row">
|
||||||
|
{pill}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="recovery-action"
|
||||||
|
onClick={recovery.onClick}
|
||||||
|
disabled={recovery.disabled}
|
||||||
|
>
|
||||||
|
{recovery.icon ?? <RefreshIcon />}
|
||||||
|
{recovery.label}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div class="app">
|
||||||
|
{/* Hero is OWNED BY THE HOST (BotShell + BotShellHero). The widget no
|
||||||
|
* longer renders an avatar/name/handle/description block — the host
|
||||||
|
* panel above the iframe carries that information plus the
|
||||||
|
* three-dots menu. «О боте» lives HERE in the widget body so it
|
||||||
|
* sits adjacent to the login/logout actions it explains. */}
|
||||||
|
|
||||||
|
{handshakeOk && state.kind === 'unknown' ? (
|
||||||
|
<section class="section">
|
||||||
|
<StatusRow
|
||||||
|
tone="checking"
|
||||||
|
label={t('status.unknown')}
|
||||||
|
recovery={{ label: t('card.refresh.label'), onClick: onClickRefresh }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{handshakeOk && state.kind === 'disconnected' ? (
|
||||||
|
<section class="section">
|
||||||
|
<StatusRow tone="disconnected" label={t('status.disconnected')} />
|
||||||
|
{state.lastError ? (
|
||||||
|
<div
|
||||||
|
class={errorTone(state.lastError) === 'warn' ? 'auth-card-warn' : 'auth-card-error'}
|
||||||
|
style={{ marginBottom: '14px' }}
|
||||||
|
>
|
||||||
|
{localizeError(state.lastError, t)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div class="command-grid">
|
||||||
|
<button class="command-card" type="button" onClick={onClickLoginQr}>
|
||||||
|
<div class="command-card-body">
|
||||||
|
<div class="command-card-name">{t('card.login-qr.name')}</div>
|
||||||
|
<div class="command-card-desc">{t('card.login-qr.desc')}</div>
|
||||||
|
</div>
|
||||||
|
<span class="command-card-chevron" aria-hidden="true">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class={`command-card${refreshing ? ' refreshing' : ''}`}
|
||||||
|
type="button"
|
||||||
|
onClick={onClickRefresh}
|
||||||
|
disabled={refreshing}
|
||||||
|
>
|
||||||
|
<div class="command-card-body">
|
||||||
|
<div class="command-card-name">{t('card.refresh.name')}</div>
|
||||||
|
<div class="command-card-desc">
|
||||||
|
{refreshing ? t('card.refresh.in-flight') : t('card.refresh.desc')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span class="command-card-chevron" aria-hidden="true">
|
||||||
|
<RefreshIcon />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.kind === 'awaiting_qr_scan' ? (
|
||||||
|
<section class="section">
|
||||||
|
<QrPanel state={state} t={t} onCancel={onClickCancel} />
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.kind === 'qr_verifying' ? (
|
||||||
|
<section class="section">
|
||||||
|
<StatusRow
|
||||||
|
tone="checking"
|
||||||
|
label={t('status.qr-verifying')}
|
||||||
|
recovery={{ label: t('card.refresh.label'), onClick: onClickRefresh }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.kind === 'logging_out' ? (
|
||||||
|
<section class="section">
|
||||||
|
<StatusRow
|
||||||
|
tone="checking"
|
||||||
|
label={t('status.logging-out')}
|
||||||
|
recovery={{ label: t('card.refresh.label'), onClick: onClickRefresh }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.kind === 'reconnecting' ? (
|
||||||
|
<section class="section">
|
||||||
|
<StatusRow
|
||||||
|
tone="checking"
|
||||||
|
label={t('status.reconnecting')}
|
||||||
|
recovery={{ label: t('card.refresh.label'), onClick: onClickRefresh }}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.kind === 'connected' ? (
|
||||||
|
<section class="section">
|
||||||
|
<StatusRow
|
||||||
|
tone="connected"
|
||||||
|
label={
|
||||||
|
state.handle
|
||||||
|
? t('status.connected-as', { handle: state.handle })
|
||||||
|
: t('status.connected')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div class="command-grid">
|
||||||
|
<LogoutCard t={t} onConfirm={onConfirmLogout} />
|
||||||
|
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{state.kind === 'connected_dead' ? (
|
||||||
|
<section class="section">
|
||||||
|
<StatusRow
|
||||||
|
tone="checking"
|
||||||
|
label={
|
||||||
|
state.reason === 'connection_dead'
|
||||||
|
? t('status.connection-dead')
|
||||||
|
: t('status.token-stored')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div class="command-grid">
|
||||||
|
{/* Reconnect — primary action for this state. The button uses
|
||||||
|
* the same command-card chrome so it visually matches Login /
|
||||||
|
* Logout cards. */}
|
||||||
|
<button class="command-card" type="button" onClick={onClickReconnect}>
|
||||||
|
<div class="command-card-body">
|
||||||
|
<div class="command-card-name">{t('card.reconnect.name')}</div>
|
||||||
|
<div class="command-card-desc">{t('card.reconnect.desc')}</div>
|
||||||
|
</div>
|
||||||
|
<span class="command-card-chevron" aria-hidden="true">
|
||||||
|
›
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<LogoutCard t={t} onConfirm={onConfirmLogout} />
|
||||||
|
<AboutCard t={t} onOpen={() => setAboutOpen(true)} />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{aboutOpen ? <AboutModal t={t} onClose={() => setAboutOpen(false)} /> : null}
|
||||||
|
|
||||||
|
<section class="section">
|
||||||
|
<div ref={transcriptRef} class="transcript" role="log" aria-live="polite">
|
||||||
|
{transcript.length === 0 ? (
|
||||||
|
<div class="transcript-empty">{/* placeholder */}</div>
|
||||||
|
) : (
|
||||||
|
transcript
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.map((line) => (
|
||||||
|
<div key={line.id} class={`transcript-line ${line.kind}`}>
|
||||||
|
<span class="ts">{formatTime(line.ts)}</span>
|
||||||
|
<span class="body">
|
||||||
|
{line.kind === 'from-bot' ? renderBody(line.text) : line.text}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/widget-discord/src/bootstrap.ts
Normal file
69
apps/widget-discord/src/bootstrap.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// Parse the URL params the Phase 2 bot widget host appends when loading
|
||||||
|
// experience.url. Source of truth on the host side:
|
||||||
|
// src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl).
|
||||||
|
// Keep this in sync if the host adds params.
|
||||||
|
//
|
||||||
|
// Identical shape to apps/widget-telegram/src/bootstrap.ts on purpose —
|
||||||
|
// the host emits the same param set for every bot. Differences between
|
||||||
|
// telegram and discord live in the bridge protocol, not in bootstrap.
|
||||||
|
|
||||||
|
export type WidgetBootstrap = {
|
||||||
|
widgetId: string;
|
||||||
|
parentUrl: string;
|
||||||
|
parentOrigin: string;
|
||||||
|
roomId: string;
|
||||||
|
userId: string;
|
||||||
|
botId: string;
|
||||||
|
botMxid: string;
|
||||||
|
/** Bridge command prefix (e.g. `!discord`). Always non-empty — the host
|
||||||
|
* validator (catalog.ts) defaults missing values to `!tg` and rejects
|
||||||
|
* malformed overrides, so the discord bot's /config.json entry MUST set
|
||||||
|
* `experience.commandPrefix: "!discord"` to override the default. The
|
||||||
|
* widget prepends `<commandPrefix> ` to every outbound command. */
|
||||||
|
commandPrefix: string;
|
||||||
|
theme: 'light' | 'dark';
|
||||||
|
clientLanguage: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BootstrapResult =
|
||||||
|
| { ok: true; bootstrap: WidgetBootstrap }
|
||||||
|
| { ok: false; missing: string[] };
|
||||||
|
|
||||||
|
const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid', 'commandPrefix'] as const;
|
||||||
|
|
||||||
|
export const readBootstrap = (search: string): BootstrapResult => {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const get = (k: string) => params.get(k) ?? '';
|
||||||
|
|
||||||
|
const missing = REQUIRED.filter((k) => !params.get(k));
|
||||||
|
if (missing.length > 0) return { ok: false, missing: [...missing] };
|
||||||
|
|
||||||
|
// Origin is what the widget validates against on incoming postMessage —
|
||||||
|
// see widget-api.ts. Falling back to '*' would defeat the security
|
||||||
|
// boundary, so a malformed parentUrl bails out as a missing-param error.
|
||||||
|
let parentOrigin: string;
|
||||||
|
try {
|
||||||
|
parentOrigin = new URL(get('parentUrl')).origin;
|
||||||
|
} catch {
|
||||||
|
return { ok: false, missing: ['parentUrl'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const themeRaw = get('theme');
|
||||||
|
const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light';
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
bootstrap: {
|
||||||
|
widgetId: get('widgetId'),
|
||||||
|
parentUrl: get('parentUrl'),
|
||||||
|
parentOrigin,
|
||||||
|
roomId: get('roomId'),
|
||||||
|
userId: get('userId'),
|
||||||
|
botId: get('botId'),
|
||||||
|
botMxid: get('botMxid'),
|
||||||
|
commandPrefix: get('commandPrefix'),
|
||||||
|
theme,
|
||||||
|
clientLanguage: get('clientLanguage'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
482
apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts
Normal file
482
apps/widget-discord/src/bridge-protocol/dialects/legacy_v076.ts
Normal file
|
|
@ -0,0 +1,482 @@
|
||||||
|
// Dialect: mautrix-discord v0.7.6 (16 Feb 2026). The bridge runs on the
|
||||||
|
// LEGACY mautrix command framework — `maunium.net/go/mautrix/bridge/commands`,
|
||||||
|
// NOT bridgev2. As of January 2026 the mautrix maintainers flagged Discord
|
||||||
|
// as «not yet migrated to bridgev2» (mau.fi/blog/2026-01-mautrix-release/),
|
||||||
|
// so this dialect is the canonical one until the v2 migration lands.
|
||||||
|
//
|
||||||
|
// Each regex below is paired with its upstream source line in
|
||||||
|
// github.com/mautrix/discord/blob/v0.7.6/commands.go. If wording drifts in
|
||||||
|
// a future patch, replace this file with a sibling `legacy_v077.ts`
|
||||||
|
// (or whatever) and switch the import in ../parser.ts.
|
||||||
|
//
|
||||||
|
// Body encoding note: legacy mautrix commands use `ce.Reply(...)` which
|
||||||
|
// renders through `format.RenderMarkdown` in the bridge framework. Our
|
||||||
|
// host driver strips `formatted_body` (Phase 2 contract), so the widget
|
||||||
|
// only sees the markdown source — backticks, asterisks, escaped angle-
|
||||||
|
// brackets stay literal.
|
||||||
|
|
||||||
|
import type { LoginEvent, ParsableEvent } from '../types';
|
||||||
|
|
||||||
|
// --- Regex table ----------------------------------------------------------
|
||||||
|
|
||||||
|
// Ping replies — commands.go:fnPing (l.297-310 in v0.7.6). All four are
|
||||||
|
// distinct phrasings; we capture each separately so the state machine can
|
||||||
|
// route them to different status pills.
|
||||||
|
//
|
||||||
|
// «You're logged in as @<username> (`<id>`)» — the trailing parens hold the
|
||||||
|
// numeric Discord snowflake wrapped in markdown backticks. Both are useful
|
||||||
|
// for surfacing in the UI.
|
||||||
|
const PING_LOGGED_IN_RE = /^you'?re logged in as\s+@?(.+?)\s+\(`?(\d+)`?\)\.?$/i;
|
||||||
|
// «You're not logged in» — exact match, no period. The legacy framework
|
||||||
|
// doesn't append punctuation here.
|
||||||
|
const PING_NOT_LOGGED_IN_RE = /^you'?re not logged in\.?$/i;
|
||||||
|
// «You have a Discord token stored, but are not connected for some reason 🤔»
|
||||||
|
// — the emoji is part of the literal upstream string and we tolerate optional
|
||||||
|
// trailing whitespace / period.
|
||||||
|
const PING_TOKEN_STORED_RE = /^you have a discord token stored, but are not connected/i;
|
||||||
|
// «You're logged in, but the Discord connection seems to be dead 💥»
|
||||||
|
const PING_CONNECTION_DEAD_RE = /^you'?re logged in, but the discord connection seems to be dead/i;
|
||||||
|
|
||||||
|
// login-token / login-qr success — commands.go:fnLoginToken (l.156) and
|
||||||
|
// fnLoginQR (l.220). Format: `Successfully logged in as @<username>`. The
|
||||||
|
// QR-login path doesn't include the snowflake; the token-login path has
|
||||||
|
// «Connecting to Discord as user ID %d» BEFORE the success line, but we
|
||||||
|
// only need the success terminator. Capturing the handle is enough — App
|
||||||
|
// fires `ping` after to pick up the snowflake.
|
||||||
|
const LOGIN_SUCCESS_RE = /^successfully logged in as\s+@?(.+?)\.?$/i;
|
||||||
|
|
||||||
|
// login-qr CAPTCHA path — commands.go:fnLoginQR (l.207-209). The bridge
|
||||||
|
// appends «CAPTCHAs are currently not supported - use token login instead»
|
||||||
|
// to the standard `Error logging in: %v` reply. Detect the suffix
|
||||||
|
// independently of the leading error verb so we surface a useful hint
|
||||||
|
// rather than a raw stack-trace tail.
|
||||||
|
const CAPTCHA_REQUIRED_RE = /captchas? are currently not supported/i;
|
||||||
|
|
||||||
|
// Generic «Error logging in: %v» — fnLoginQR l.205 / 211 (different
|
||||||
|
// branches funnel through the same Reply call). Capture the Go-error tail
|
||||||
|
// as `reason`. Order matters: CAPTCHA_REQUIRED must be checked BEFORE this
|
||||||
|
// trap because the captcha case is a more specific subset.
|
||||||
|
const LOGIN_FAILED_RE = /^error logging in:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// «Error connecting to login websocket: %v» — fnLoginQR l.184. Pre-QR
|
||||||
|
// failure (couldn't reach Discord's remoteauth gateway). Distinguish from
|
||||||
|
// LOGIN_FAILED so the App can surface a more accurate message.
|
||||||
|
const LOGIN_WEBSOCKET_FAILED_RE = /^error connecting to login websocket:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// «Error connecting after login: %v» — fnLoginQR l.213. Post-QR rare path:
|
||||||
|
// remoteauth handed us a token but the immediate Discord connect failed.
|
||||||
|
const CONNECT_AFTER_LOGIN_FAILED_RE = /^error connecting after login:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// «Failed to prepare login: %v» — fnLoginQR l.176. Pre-QR initialisation
|
||||||
|
// failure (remoteauth couldn't even start). Routes back to disconnected.
|
||||||
|
const PREPARE_LOGIN_FAILED_RE = /^failed to prepare login:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// «You're already logged in» — both fnLoginToken (l.117) and fnLoginQR
|
||||||
|
// (l.171). Replied when the user clicks login but ping would have shown
|
||||||
|
// connected. We dispatch a re-ping to reconcile.
|
||||||
|
const ALREADY_LOGGED_IN_RE = /^you'?re already logged in\.?$/i;
|
||||||
|
|
||||||
|
// Logout — commands.go:fnLogout (l.275-280).
|
||||||
|
const LOGOUT_OK_RE = /^logged out successfully\.?$/i;
|
||||||
|
const LOGOUT_NO_OP_RE = /^you weren'?t logged in, but data was re-cleared/i;
|
||||||
|
|
||||||
|
// Disconnect — commands.go:fnDisconnect (l.318-326). User-typed-only path
|
||||||
|
// (the widget never sends `disconnect`), but recognising the replies keeps
|
||||||
|
// chat-fallback typists from confusing the state machine.
|
||||||
|
const DISCONNECT_OK_RE = /^successfully disconnected\.?$/i;
|
||||||
|
const DISCONNECT_NO_OP_RE = /^you'?re already not connected\.?$/i;
|
||||||
|
const DISCONNECT_FAILED_RE = /^error while disconnecting:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// Reconnect — commands.go:fnReconnect (l.339-347). Used as recovery from
|
||||||
|
// `connection_dead` / `token_stored_not_connected` ping replies.
|
||||||
|
const RECONNECT_OK_RE = /^successfully reconnected\.?$/i;
|
||||||
|
const RECONNECT_NO_OP_RE = /^you'?re already connected\.?$/i;
|
||||||
|
const RECONNECT_FAILED_RE = /^error while reconnecting:\s*(.*)$/i;
|
||||||
|
|
||||||
|
// Unknown command — bridge/commands/processor.go (legacy framework). The
|
||||||
|
// exact wording differs between framework versions; this regex tolerates
|
||||||
|
// the canonical «Unknown command. Try `help`.» phrasing.
|
||||||
|
const UNKNOWN_COMMAND_RE = /^unknown command\.?\s*(?:try\s+)?`?help`?/i;
|
||||||
|
|
||||||
|
// --- Body parser ----------------------------------------------------------
|
||||||
|
|
||||||
|
const trimReplyBody = (raw: string): string => raw.trim();
|
||||||
|
|
||||||
|
export const parseLegacyV076Body = (rawBody: string): LoginEvent => {
|
||||||
|
const body = trimReplyBody(rawBody);
|
||||||
|
if (body.length === 0) return { kind: 'unknown' };
|
||||||
|
|
||||||
|
// ORDER MATTERS:
|
||||||
|
// 1. CAPTCHA must be checked before the generic LOGIN_FAILED — captcha
|
||||||
|
// bodies match LOGIN_FAILED_RE but carry the more-specific suffix.
|
||||||
|
// 2. LOGIN_SUCCESS_RE has a permissive `(.+?)` capture; we keep it AFTER
|
||||||
|
// explicit ping replies so a future ping wording drift can't swallow
|
||||||
|
// a success line.
|
||||||
|
|
||||||
|
// Ping replies (most common) — try first.
|
||||||
|
if (PING_NOT_LOGGED_IN_RE.test(body)) return { kind: 'not_logged_in' };
|
||||||
|
if (PING_TOKEN_STORED_RE.test(body)) return { kind: 'token_stored_not_connected' };
|
||||||
|
if (PING_CONNECTION_DEAD_RE.test(body)) return { kind: 'connection_dead' };
|
||||||
|
const pingLoggedInMatch = PING_LOGGED_IN_RE.exec(body);
|
||||||
|
if (pingLoggedInMatch) {
|
||||||
|
return {
|
||||||
|
kind: 'logged_in',
|
||||||
|
handle: pingLoggedInMatch[1].trim(),
|
||||||
|
discordId: pingLoggedInMatch[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login lifecycle.
|
||||||
|
if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' };
|
||||||
|
|
||||||
|
const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body);
|
||||||
|
if (loginWsMatch) return { kind: 'login_websocket_failed', reason: loginWsMatch[1].trim() };
|
||||||
|
|
||||||
|
const connectAfterMatch = CONNECT_AFTER_LOGIN_FAILED_RE.exec(body);
|
||||||
|
if (connectAfterMatch)
|
||||||
|
return { kind: 'connect_after_login_failed', reason: connectAfterMatch[1].trim() };
|
||||||
|
|
||||||
|
const prepareMatch = PREPARE_LOGIN_FAILED_RE.exec(body);
|
||||||
|
if (prepareMatch) return { kind: 'prepare_login_failed', reason: prepareMatch[1].trim() };
|
||||||
|
|
||||||
|
const loginFailedMatch = LOGIN_FAILED_RE.exec(body);
|
||||||
|
if (loginFailedMatch) return { kind: 'login_failed', reason: loginFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
if (ALREADY_LOGGED_IN_RE.test(body)) return { kind: 'already_logged_in' };
|
||||||
|
|
||||||
|
// Login success — capture the handle. Discord usernames may include `.`
|
||||||
|
// and other ASCII punctuation; the regex's `(.+?)` is greedy-enough.
|
||||||
|
const successMatch = LOGIN_SUCCESS_RE.exec(body);
|
||||||
|
if (successMatch) {
|
||||||
|
const handleRaw = successMatch[1].trim();
|
||||||
|
return { kind: 'login_success', handle: handleRaw };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout / disconnect / reconnect lifecycle.
|
||||||
|
if (LOGOUT_OK_RE.test(body)) return { kind: 'logout_ok' };
|
||||||
|
if (LOGOUT_NO_OP_RE.test(body)) return { kind: 'logout_no_op' };
|
||||||
|
|
||||||
|
if (DISCONNECT_OK_RE.test(body)) return { kind: 'disconnect_ok' };
|
||||||
|
if (DISCONNECT_NO_OP_RE.test(body)) return { kind: 'disconnect_no_op' };
|
||||||
|
const disconnectFailedMatch = DISCONNECT_FAILED_RE.exec(body);
|
||||||
|
if (disconnectFailedMatch)
|
||||||
|
return { kind: 'disconnect_failed', reason: disconnectFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
if (RECONNECT_OK_RE.test(body)) return { kind: 'reconnect_ok' };
|
||||||
|
if (RECONNECT_NO_OP_RE.test(body)) return { kind: 'reconnect_no_op' };
|
||||||
|
const reconnectFailedMatch = RECONNECT_FAILED_RE.exec(body);
|
||||||
|
if (reconnectFailedMatch)
|
||||||
|
return { kind: 'reconnect_failed', reason: reconnectFailedMatch[1].trim() };
|
||||||
|
|
||||||
|
if (UNKNOWN_COMMAND_RE.test(body)) return { kind: 'unknown_command' };
|
||||||
|
|
||||||
|
return { kind: 'unknown' };
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Full-event parser ----------------------------------------------------
|
||||||
|
//
|
||||||
|
// `parseEventLegacyV076` dispatches on `event.type`:
|
||||||
|
//
|
||||||
|
// * `m.room.redaction` → `qr_redacted`. The state machine pairs the
|
||||||
|
// redaction's `redacts` against the active QR event id; an unrelated
|
||||||
|
// redaction is dropped silently.
|
||||||
|
//
|
||||||
|
// * `m.room.message` + `msgtype=m.image` → `qr_displayed` when the body
|
||||||
|
// contains a Discord remoteauth URL. Discord doesn't rotate the QR (no
|
||||||
|
// m.replace edits), but we still honour `m.relates_to.rel_type=m.replace`
|
||||||
|
// for forward-compat with a hypothetical future bridge that does.
|
||||||
|
//
|
||||||
|
// * `m.room.message` + `msgtype=m.text|m.notice` → existing
|
||||||
|
// `parseLegacyV076Body(body)` path.
|
||||||
|
|
||||||
|
// Discord remoteauth URLs encode the auth handshake in a path on
|
||||||
|
// `discordapp.com` (the OLD Discord domain — Discord still uses it as
|
||||||
|
// the canonical remoteauth host because the URL is consumed by the
|
||||||
|
// mobile app's deep-link handler, not by browser routing).
|
||||||
|
//
|
||||||
|
// Verified upstream: mautrix/discord/remoteauth/serverpackets.go at v0.7.6
|
||||||
|
// builds the QR string as `"https://discordapp.com/ra/" + Fingerprint` —
|
||||||
|
// see https://github.com/mautrix/discord/blob/v0.7.6/remoteauth/serverpackets.go.
|
||||||
|
//
|
||||||
|
// We accept both `discordapp.com` (canonical) AND `discord.com` because
|
||||||
|
// Discord has been gradually consolidating onto discord.com over years
|
||||||
|
// and a future bridge release could flip — keeping both means the
|
||||||
|
// widget survives the transition without a co-ordinated push.
|
||||||
|
// Subdomains (`canary.`, `ptb.`) aren't expected here (bridge talks to
|
||||||
|
// production remoteauth) but we tolerate them as belt-and-suspenders.
|
||||||
|
const DISCORD_REMOTEAUTH_URL_RE =
|
||||||
|
/https:\/\/(?:[a-z0-9-]+\.)?(?:discordapp|discord)\.com\/[A-Za-z0-9/_\-+=.~?&]+/i;
|
||||||
|
|
||||||
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||||
|
|
||||||
|
export const parseEventLegacyV076 = (event: ParsableEvent): LoginEvent => {
|
||||||
|
if (event.type === 'm.room.redaction') {
|
||||||
|
const target =
|
||||||
|
typeof event.redacts === 'string'
|
||||||
|
? event.redacts
|
||||||
|
: isObject(event.content) && typeof event.content.redacts === 'string'
|
||||||
|
? event.content.redacts
|
||||||
|
: undefined;
|
||||||
|
if (!target) return { kind: 'unknown' };
|
||||||
|
return { kind: 'qr_redacted', redactsEventId: target };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.type !== 'm.room.message') return { kind: 'unknown' };
|
||||||
|
|
||||||
|
const msgtype = event.content?.msgtype;
|
||||||
|
|
||||||
|
if (msgtype === 'm.image') {
|
||||||
|
// Edits replace `body` by spec; bridges typically also mirror the new
|
||||||
|
// body into `m.new_content.body`. Discord's bridge doesn't edit QRs in
|
||||||
|
// the v0.7.6 timeline, but we read both spots so a future change
|
||||||
|
// doesn't quietly break the parser.
|
||||||
|
const newContent = isObject(event.content['m.new_content'])
|
||||||
|
? (event.content['m.new_content'] as { body?: unknown })
|
||||||
|
: undefined;
|
||||||
|
const editedBody = typeof newContent?.body === 'string' ? newContent.body : undefined;
|
||||||
|
const directBody = typeof event.content.body === 'string' ? event.content.body : '';
|
||||||
|
const body = editedBody ?? directBody;
|
||||||
|
|
||||||
|
const match = body.match(DISCORD_REMOTEAUTH_URL_RE);
|
||||||
|
if (!match) return { kind: 'unknown' };
|
||||||
|
|
||||||
|
const relatesTo = isObject(event.content['m.relates_to'])
|
||||||
|
? (event.content['m.relates_to'] as { rel_type?: unknown; event_id?: unknown })
|
||||||
|
: undefined;
|
||||||
|
const replacesEventId =
|
||||||
|
relatesTo?.rel_type === 'm.replace' && typeof relatesTo.event_id === 'string'
|
||||||
|
? relatesTo.event_id
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: 'qr_displayed',
|
||||||
|
discordUrl: match[0],
|
||||||
|
eventId: event.event_id,
|
||||||
|
replacesEventId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgtype !== 'm.text' && msgtype !== 'm.notice') return { kind: 'unknown' };
|
||||||
|
|
||||||
|
const body = typeof event.content.body === 'string' ? event.content.body : '';
|
||||||
|
return parseLegacyV076Body(body);
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DEV sanity assertions ------------------------------------------------
|
||||||
|
// Vite tree-shakes this branch in production builds: `import.meta.env.DEV`
|
||||||
|
// is replaced with the literal `false` and the call site collapses, so the
|
||||||
|
// fixture array never ships. Failure throws — HMR/dev-overlay surfaces the
|
||||||
|
// first regression on reload.
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
runSanityChecks();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runSanityChecks(): void {
|
||||||
|
// Body-only cases (`parseLegacyV076Body`).
|
||||||
|
const cases: Array<[string, LoginEvent]> = [
|
||||||
|
// Ping replies.
|
||||||
|
["You're not logged in", { kind: 'not_logged_in' }],
|
||||||
|
["You're not logged in.", { kind: 'not_logged_in' }],
|
||||||
|
[
|
||||||
|
'You have a Discord token stored, but are not connected for some reason 🤔',
|
||||||
|
{ kind: 'token_stored_not_connected' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"You're logged in, but the Discord connection seems to be dead 💥",
|
||||||
|
{ kind: 'connection_dead' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"You're logged in as @example (`123456789`)",
|
||||||
|
{ kind: 'logged_in', handle: 'example', discordId: '123456789' },
|
||||||
|
],
|
||||||
|
// Discord usernames support `.` since the 2026 username migration.
|
||||||
|
[
|
||||||
|
"You're logged in as @user.name (`987654321`)",
|
||||||
|
{ kind: 'logged_in', handle: 'user.name', discordId: '987654321' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Login success (post-QR scan). No snowflake in this line; App fires
|
||||||
|
// `ping` afterwards to pick up the discordId.
|
||||||
|
[
|
||||||
|
'Successfully logged in as @example',
|
||||||
|
{ kind: 'login_success', handle: 'example' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Successfully logged in as @user.name',
|
||||||
|
{ kind: 'login_success', handle: 'user.name' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Login failure paths.
|
||||||
|
[
|
||||||
|
'Error logging in: rate limited 429',
|
||||||
|
{ kind: 'login_failed', reason: 'rate limited 429' },
|
||||||
|
],
|
||||||
|
// CAPTCHA — must pre-empt LOGIN_FAILED_RE because both match. The
|
||||||
|
// suffix detector is independent of the leading verb so it catches the
|
||||||
|
// case even if Discord changes the body wrapping.
|
||||||
|
[
|
||||||
|
'Error logging in: captcha-required 400\n\nCAPTCHAs are currently not supported - use token login instead',
|
||||||
|
{ kind: 'captcha_required' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Error connecting to login websocket: dial tcp i/o timeout',
|
||||||
|
{ kind: 'login_websocket_failed', reason: 'dial tcp i/o timeout' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Error connecting after login: gateway timeout',
|
||||||
|
{ kind: 'connect_after_login_failed', reason: 'gateway timeout' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'Failed to prepare login: remoteauth init failed',
|
||||||
|
{ kind: 'prepare_login_failed', reason: 'remoteauth init failed' },
|
||||||
|
],
|
||||||
|
["You're already logged in", { kind: 'already_logged_in' }],
|
||||||
|
|
||||||
|
// Logout.
|
||||||
|
['Logged out successfully.', { kind: 'logout_ok' }],
|
||||||
|
[
|
||||||
|
"You weren't logged in, but data was re-cleared just to be safe.",
|
||||||
|
{ kind: 'logout_no_op' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Disconnect / reconnect.
|
||||||
|
['Successfully disconnected', { kind: 'disconnect_ok' }],
|
||||||
|
["You're already not connected", { kind: 'disconnect_no_op' }],
|
||||||
|
[
|
||||||
|
'Error while disconnecting: connection already closed',
|
||||||
|
{ kind: 'disconnect_failed', reason: 'connection already closed' },
|
||||||
|
],
|
||||||
|
['Successfully reconnected', { kind: 'reconnect_ok' }],
|
||||||
|
["You're already connected", { kind: 'reconnect_no_op' }],
|
||||||
|
[
|
||||||
|
'Error while reconnecting: dial tcp connection refused',
|
||||||
|
{ kind: 'reconnect_failed', reason: 'dial tcp connection refused' },
|
||||||
|
],
|
||||||
|
|
||||||
|
// Unknown command — the bridge framework's wording.
|
||||||
|
['Unknown command. Try `help`.', { kind: 'unknown_command' }],
|
||||||
|
|
||||||
|
// Catch-all.
|
||||||
|
['Some completely unknown bridge reply that does not match any anchor', { kind: 'unknown' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [body, expected] of cases) {
|
||||||
|
const actual = parseLegacyV076Body(body);
|
||||||
|
if (!sameEvent(actual, expected)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[legacy_v076 sanity] mismatch', { body, actual, expected });
|
||||||
|
throw new Error(
|
||||||
|
`legacy_v076 parser sanity failed for body ${JSON.stringify(body)} — see console for diff`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full-event cases — m.image / m.room.redaction / m.notice fall-through.
|
||||||
|
const eventCases: Array<[ParsableEvent, LoginEvent]> = [
|
||||||
|
[
|
||||||
|
// Canonical upstream form — `discordapp.com` (verified at v0.7.6
|
||||||
|
// serverpackets.go). The legacy domain is what the Discord mobile
|
||||||
|
// app's deep-link handler accepts.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$qr1',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.image', body: 'https://discordapp.com/ra/ABCDEF' },
|
||||||
|
},
|
||||||
|
{ kind: 'qr_displayed', discordUrl: 'https://discordapp.com/ra/ABCDEF', eventId: '$qr1' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Forward-compat: a future bridge release could flip to
|
||||||
|
// `discord.com`. The regex tolerates both.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$qr1b',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.image', body: 'https://discord.com/ra/ABCDEF' },
|
||||||
|
},
|
||||||
|
{ kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/ABCDEF', eventId: '$qr1b' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Bare m.image without a discord URL — bridge has no business sending
|
||||||
|
// these here, but the parser declines to invent state.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$rand',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.image', body: 'random non-discord image caption' },
|
||||||
|
},
|
||||||
|
{ kind: 'unknown' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Forward-compat: hypothetical future edit. Verifies the rotation
|
||||||
|
// path works even though Discord doesn't currently rotate.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$qr2',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: {
|
||||||
|
msgtype: 'm.image',
|
||||||
|
body: 'https://discordapp.com/ra/OLD',
|
||||||
|
'm.relates_to': { rel_type: 'm.replace', event_id: '$qr1' },
|
||||||
|
'm.new_content': { msgtype: 'm.image', body: 'https://discordapp.com/ra/ROTATED' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'qr_displayed',
|
||||||
|
discordUrl: 'https://discordapp.com/ra/ROTATED',
|
||||||
|
eventId: '$qr2',
|
||||||
|
replacesEventId: '$qr1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Redaction — top-level `redacts` (host sanitizer mirrors there).
|
||||||
|
{
|
||||||
|
type: 'm.room.redaction',
|
||||||
|
event_id: '$red1',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: { redacts: '$qr1' },
|
||||||
|
redacts: '$qr1',
|
||||||
|
},
|
||||||
|
{ kind: 'qr_redacted', redactsEventId: '$qr1' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// Redaction missing target — sanitizer should already reject; defence
|
||||||
|
// in depth.
|
||||||
|
{
|
||||||
|
type: 'm.room.redaction',
|
||||||
|
event_id: '$red2',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: {},
|
||||||
|
},
|
||||||
|
{ kind: 'unknown' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
// m.notice fall-through — preserves the body-side parser path.
|
||||||
|
{
|
||||||
|
type: 'm.room.message',
|
||||||
|
event_id: '$n1',
|
||||||
|
sender: '@discordbot:vojo.chat',
|
||||||
|
content: { msgtype: 'm.notice', body: "You're not logged in" },
|
||||||
|
},
|
||||||
|
{ kind: 'not_logged_in' },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [event, expected] of eventCases) {
|
||||||
|
const actual = parseEventLegacyV076(event);
|
||||||
|
if (!sameEvent(actual, expected)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[legacy_v076 event sanity] mismatch', { event, actual, expected });
|
||||||
|
throw new Error(
|
||||||
|
`legacy_v076 event-parser sanity failed for type=${event.type} msgtype=${event.content?.msgtype ?? '<none>'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameEvent(a: LoginEvent, b: LoginEvent): boolean {
|
||||||
|
if (a.kind !== b.kind) return false;
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
18
apps/widget-discord/src/bridge-protocol/parser.ts
Normal file
18
apps/widget-discord/src/bridge-protocol/parser.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
// Parser shim. The widget consumes a single `parseEvent(rawEvent)` and
|
||||||
|
// the dialect handles the full event surface — m.text, m.notice, m.image
|
||||||
|
// (QR broadcasts), m.room.redaction (post-scan cleanup). M-discord ships
|
||||||
|
// one dialect, `legacy_v076`, for the operator's current bridge image.
|
||||||
|
// When mautrix-discord eventually migrates to bridgev2 (the team flagged
|
||||||
|
// this as «not yet» as of 2026-01), add a sibling dialect file and
|
||||||
|
// switch the import below.
|
||||||
|
//
|
||||||
|
// The dialects/ subdirectory is kept as a seam for that swap; we don't
|
||||||
|
// implement runtime autodetect (the operator owns one bridge image at a
|
||||||
|
// time and a parser pin is honest about that).
|
||||||
|
|
||||||
|
import type { LoginEvent, ParsableEvent } from './types';
|
||||||
|
import { parseEventLegacyV076 } from './dialects/legacy_v076';
|
||||||
|
|
||||||
|
export type { ParsableEvent };
|
||||||
|
|
||||||
|
export const parseEvent = (event: ParsableEvent): LoginEvent => parseEventLegacyV076(event);
|
||||||
110
apps/widget-discord/src/bridge-protocol/types.ts
Normal file
110
apps/widget-discord/src/bridge-protocol/types.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
// LoginEvent — discriminated union the parser emits and the state machine
|
||||||
|
// consumes. One LoginEvent per inbound m.room.message / m.room.redaction
|
||||||
|
// from the bridge bot.
|
||||||
|
//
|
||||||
|
// Source-of-truth for every kind below is mautrix/discord legacy command
|
||||||
|
// system (commands.go), tag v0.7.6 — see ./dialects/legacy_v076.ts for the
|
||||||
|
// per-string upstream pointers. Discord uses the OLDER mautrix command
|
||||||
|
// processor (`maunium.net/go/mautrix/bridge/commands`), NOT bridgev2 — so
|
||||||
|
// the wording differs from mautrix-telegram and there's no list-logins
|
||||||
|
// API; status is queried via `ping`, and there's no per-account login id.
|
||||||
|
|
||||||
|
// `ping` reply variants — the bridge's only status-probe surface for the
|
||||||
|
// legacy command system. Each variant maps to a different LoginEvent so
|
||||||
|
// the state machine can render distinct status pills.
|
||||||
|
export type PingResult =
|
||||||
|
| { kind: 'not_logged_in' }
|
||||||
|
| { kind: 'token_stored_not_connected' }
|
||||||
|
| { kind: 'connection_dead' }
|
||||||
|
| { kind: 'logged_in'; handle: string; discordId?: string };
|
||||||
|
|
||||||
|
// Shape of an inbound event the dialect parser needs to look at. Matches
|
||||||
|
// the wire shape produced by the host's BotWidgetDriver sanitizer; declared
|
||||||
|
// here (not in widget-api.ts) so the dialect doesn't import from the
|
||||||
|
// transport layer.
|
||||||
|
export type ParsableEvent = {
|
||||||
|
type: string;
|
||||||
|
event_id: string;
|
||||||
|
sender: string;
|
||||||
|
origin_server_ts?: number;
|
||||||
|
content: { msgtype?: string; body?: string; [k: string]: unknown };
|
||||||
|
redacts?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginEvent =
|
||||||
|
// --- ping reply ----------------------------------------------------------
|
||||||
|
| { kind: 'not_logged_in' }
|
||||||
|
| { kind: 'token_stored_not_connected' }
|
||||||
|
| { kind: 'connection_dead' }
|
||||||
|
// `ping` says we're live; handle parsed from `You're logged in as @x (`id`)`.
|
||||||
|
// Same shape as `login_success` — App routes both into the connected state.
|
||||||
|
| { kind: 'logged_in'; handle: string; discordId?: string }
|
||||||
|
|
||||||
|
// --- login-qr lifecycle --------------------------------------------------
|
||||||
|
// `m.image` carrying the remoteauth URL inside `content.body`. The widget
|
||||||
|
// renders the QR client-side from that URL and never touches the uploaded
|
||||||
|
// PNG. Discord's remoteauth does NOT rotate the URL (unlike Telegram
|
||||||
|
// MTProto): the bridge sends one m.image per login attempt and either
|
||||||
|
// redacts it on success or leaves it (and replies with an error) on
|
||||||
|
// failure. `replacesEventId` is here for forward-compat / paranoia — if a
|
||||||
|
// future bridge ever does an edit, the state machine handles it gracefully.
|
||||||
|
| { kind: 'qr_displayed'; discordUrl: string; eventId: string; replacesEventId?: string }
|
||||||
|
// Bridge redacted the QR event after a successful scan. NOT terminal — the
|
||||||
|
// success line («Successfully logged in as @x») typically lands in the same
|
||||||
|
// breath; the state machine moves us into a `qr_verifying` interstitial
|
||||||
|
// until it does.
|
||||||
|
| { kind: 'qr_redacted'; redactsEventId: string }
|
||||||
|
|
||||||
|
// Successful login (after QR scan). Captures handle and optional snowflake.
|
||||||
|
| { kind: 'login_success'; handle: string; discordId?: string }
|
||||||
|
// Generic login failure (wraps gotd / remoteauth Go errors). Most common
|
||||||
|
// bodies: «Error logging in: ...» — we surface the verbatim Go-error tail
|
||||||
|
// as a yellow warning on the QR panel.
|
||||||
|
| { kind: 'login_failed'; reason?: string }
|
||||||
|
// Special-cased login_failed branch: the bridge appends «CAPTCHAs are
|
||||||
|
// currently not supported - use token login instead» when Discord
|
||||||
|
// presents a captcha. Telegram never sees this; Discord's remoteauth
|
||||||
|
// throws CAPTCHA roughly proportionally to the user's account age and
|
||||||
|
// login frequency. Promotes to a hint-with-explanation banner instead
|
||||||
|
// of a raw stack-trace tail.
|
||||||
|
| { kind: 'captcha_required' }
|
||||||
|
// bridge sets up a websocket against Discord's remoteauth gateway; this is
|
||||||
|
// the «we couldn't even reach Discord» error — different from
|
||||||
|
// login_failed, which lands AFTER the websocket is up.
|
||||||
|
| { kind: 'login_websocket_failed'; reason?: string }
|
||||||
|
// Surfaces when QR-login starts but the bridge is already logged in.
|
||||||
|
// Race against ping/status — the App fires `ping` to reconcile.
|
||||||
|
| { kind: 'already_logged_in' }
|
||||||
|
// bridge couldn't initialise remoteauth at all (rare, indicates bridge-
|
||||||
|
// image misconfiguration). Routes back to disconnected with a warn line.
|
||||||
|
| { kind: 'prepare_login_failed'; reason?: string }
|
||||||
|
// bridge received the token but couldn't connect to Discord with it (rare
|
||||||
|
// post-scan failure; remoteauth can return a stale token if the gateway
|
||||||
|
// race trips). Surfaces as «Signed in, but couldn't connect: <reason>»
|
||||||
|
// and routes back to disconnected.
|
||||||
|
| { kind: 'connect_after_login_failed'; reason?: string }
|
||||||
|
|
||||||
|
// --- logout --------------------------------------------------------------
|
||||||
|
| { kind: 'logout_ok' }
|
||||||
|
// Bridge says the user wasn't logged in but cleared state defensively.
|
||||||
|
// Idempotent confirmation that we're now disconnected.
|
||||||
|
| { kind: 'logout_no_op' }
|
||||||
|
|
||||||
|
// --- disconnect / reconnect ---------------------------------------------
|
||||||
|
// Used as recovery from `connection_dead` / `token_stored_not_connected`.
|
||||||
|
// The widget never SENDS `disconnect` — that's an admin-only state op —
|
||||||
|
// but if the user typed it manually in chat-fallback, the parser still
|
||||||
|
// recognises the reply.
|
||||||
|
| { kind: 'disconnect_ok' }
|
||||||
|
| { kind: 'disconnect_no_op' }
|
||||||
|
| { kind: 'disconnect_failed'; reason?: string }
|
||||||
|
| { kind: 'reconnect_ok' }
|
||||||
|
| { kind: 'reconnect_no_op' }
|
||||||
|
| { kind: 'reconnect_failed'; reason?: string }
|
||||||
|
|
||||||
|
// --- bridge-side errors --------------------------------------------------
|
||||||
|
// Generic «I don't know that command» — should not happen since we only
|
||||||
|
// ship known commands, but visible if the bridge image is misconfigured
|
||||||
|
// or the prefix in /config.json drifted from the bridge's command_prefix.
|
||||||
|
| { kind: 'unknown_command' }
|
||||||
|
| { kind: 'unknown' };
|
||||||
78
apps/widget-discord/src/i18n/en.ts
Normal file
78
apps/widget-discord/src/i18n/en.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// English fallback. Mirror the RU key set; `Record<StringKey, string>` enforces
|
||||||
|
// that every RU key has an EN counterpart at compile time.
|
||||||
|
|
||||||
|
import type { StringKey } from './ru';
|
||||||
|
|
||||||
|
export const EN: Record<StringKey, string> = {
|
||||||
|
'status.unknown': 'Checking status…',
|
||||||
|
'status.disconnected': 'Discord not linked',
|
||||||
|
'status.connected': 'Discord linked',
|
||||||
|
'status.connected-as': 'Discord linked as {handle}',
|
||||||
|
'status.connection-dead': 'Discord connection lost',
|
||||||
|
'status.token-stored': 'Discord session is not active',
|
||||||
|
'status.qr-verifying': 'Verifying sign-in…',
|
||||||
|
'status.logging-out': 'Signing out…',
|
||||||
|
'status.reconnecting': 'Reconnecting to Discord…',
|
||||||
|
'card.login-qr.name': 'Sign in with QR code',
|
||||||
|
'card.login-qr.desc': 'Scan a QR code from the Discord mobile app',
|
||||||
|
'card.refresh.aria': 'Refresh status',
|
||||||
|
'card.refresh.label': 'Refresh status',
|
||||||
|
'card.refresh.name': 'Refresh status',
|
||||||
|
'card.refresh.desc': 'Re-check whether Discord is linked',
|
||||||
|
'card.refresh.in-flight': 'Checking…',
|
||||||
|
'card.about.name': 'How the Discord bot works',
|
||||||
|
'card.about.desc': 'Sign-in, safety, and source code',
|
||||||
|
'about.title': 'About the Discord bot',
|
||||||
|
'about.body-1':
|
||||||
|
'This bot connects Discord to Vojo. After sign-in, your DMs and servers from Discord will appear in Vojo’s chat list, and replies from the Vojo app will be sent to your contacts as normal Discord messages.',
|
||||||
|
'about.body-2':
|
||||||
|
'Sign-in requires the Discord mobile app — scan the QR code via Settings → Devices → Scan QR Code. Desktop Discord and the browser cannot be used: the bridge uses Discord’s “remoteauth” mechanism, available only in the mobile app.',
|
||||||
|
'about.body-3':
|
||||||
|
'The connection runs through the open-source mautrix-discord bridge. It creates a Discord session on the Vojo server and uses it to connect Discord with your Vojo account: receive messages from Discord and send your replies back.',
|
||||||
|
'about.github-label': 'The bridge source code is public on GitHub:',
|
||||||
|
'about.github-url': 'https://github.com/mautrix/discord',
|
||||||
|
'about.body-4':
|
||||||
|
'You can revoke access at any time — either with the “Sign out of Discord” button here, or inside Discord itself under Settings → Devices → Log out of Vojo.',
|
||||||
|
'about.close': 'Close',
|
||||||
|
'about.aria-close': 'Close “About this bot”',
|
||||||
|
'auth-card.qr.title': 'QR code sign-in',
|
||||||
|
'auth-card.qr.hint': 'Open the Discord mobile app and scan this QR code.',
|
||||||
|
'auth-card.qr.preparing': 'Preparing QR code…',
|
||||||
|
'auth-card.qr.aria': 'QR code for Discord sign-in. Scan it with your phone.',
|
||||||
|
'auth-card.qr.countdown': 'Time left to scan: {minutes}:{seconds}',
|
||||||
|
'auth-card.qr.expired': 'Sign-in window expired. Tap Cancel and try again.',
|
||||||
|
'auth-card.qr.step-1': 'Open the Discord mobile app.',
|
||||||
|
'auth-card.qr.step-2': 'Open Settings → Devices → Scan QR Code.',
|
||||||
|
'auth-card.qr.step-3': 'Scan the QR and confirm sign-in on your phone.',
|
||||||
|
'auth-card.cancel': 'Cancel',
|
||||||
|
'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.',
|
||||||
|
'auth-error.captcha-required':
|
||||||
|
'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bot’s chat.',
|
||||||
|
'auth-error.login-failed': 'Sign-in failed: {reason}',
|
||||||
|
'auth-error.prepare-failed': 'Failed to prepare sign-in: {reason}',
|
||||||
|
'auth-error.websocket-failed': 'Could not connect to the sign-in server: {reason}',
|
||||||
|
'auth-error.connect-after-login-failed':
|
||||||
|
'Signed in, but could not connect to Discord: {reason}',
|
||||||
|
'auth-error.already-logged-in': 'You are already signed in to Discord — refresh status.',
|
||||||
|
'auth-error.unknown-command':
|
||||||
|
'The bot does not recognise this command — check the prefix in config.json.',
|
||||||
|
'auth-error.disconnect-failed': 'Disconnect failed: {reason}',
|
||||||
|
'card.reconnect.name': 'Reconnect',
|
||||||
|
'card.reconnect.desc': 'Restore the Discord connection without signing in again',
|
||||||
|
'card.logout.name': 'Sign out of Discord',
|
||||||
|
'card.logout.desc': 'End the session for this account',
|
||||||
|
'card.logout.confirm-prompt': 'Sign out for real?',
|
||||||
|
'card.logout.confirm-yes': 'Sign out',
|
||||||
|
'card.logout.confirm-no': 'Cancel',
|
||||||
|
'diag.connecting': 'Connecting to Vojo… awaiting capability handshake.',
|
||||||
|
'diag.ready': 'Ready to send commands.',
|
||||||
|
'diag.checking-status': 'Checking connection status…',
|
||||||
|
'diag.send-failed': 'send failed: {message}',
|
||||||
|
'diag.history-marker': '─── history ───',
|
||||||
|
'diag.history-unavailable': 'Could not read history — re-checking status.',
|
||||||
|
'diag.qr-issued': 'QR code issued.',
|
||||||
|
'diag.qr-consumed': 'QR code consumed — bridge confirmed the scan.',
|
||||||
|
'bootstrap.failed': 'Widget failed to start',
|
||||||
|
'bootstrap.missing-params': 'Missing required URL params: {names}.',
|
||||||
|
'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.',
|
||||||
|
};
|
||||||
34
apps/widget-discord/src/i18n/index.ts
Normal file
34
apps/widget-discord/src/i18n/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
// Tiny i18n harness — Russian primary, English fallback (BCP-47 prefix
|
||||||
|
// match — any `en` variant). Bootstrap forwards `clientLanguage` from the
|
||||||
|
// host; main.tsx can also call `createT()` without args before bootstrap
|
||||||
|
// completes (falls back to navigator.language, then RU).
|
||||||
|
//
|
||||||
|
// Identical mechanics to apps/widget-telegram/src/i18n/index.ts; the
|
||||||
|
// Discord widget keeps its own dictionary file because the copy differs —
|
||||||
|
// QR-only flow, no SMS, no 2FA password form.
|
||||||
|
|
||||||
|
import { RU, type StringKey } from './ru';
|
||||||
|
import { EN } from './en';
|
||||||
|
|
||||||
|
const interpolate = (s: string, vars?: Record<string, string>): string => {
|
||||||
|
if (!vars) return s;
|
||||||
|
return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickDict = (clientLanguage: string | undefined): Record<StringKey, string> => {
|
||||||
|
const lang = (
|
||||||
|
clientLanguage ||
|
||||||
|
(typeof navigator !== 'undefined' ? navigator.language : '') ||
|
||||||
|
'ru'
|
||||||
|
).toLowerCase();
|
||||||
|
return lang.startsWith('en') ? EN : RU;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type T = (key: StringKey, vars?: Record<string, string>) => string;
|
||||||
|
|
||||||
|
export const createT = (clientLanguage?: string): T => {
|
||||||
|
const dict = pickDict(clientLanguage);
|
||||||
|
return (key, vars) => interpolate(dict[key], vars);
|
||||||
|
};
|
||||||
|
|
||||||
|
export type { StringKey };
|
||||||
113
apps/widget-discord/src/i18n/ru.ts
Normal file
113
apps/widget-discord/src/i18n/ru.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
// Russian primary copy. To add a string:
|
||||||
|
// 1. add the key + RU value here (this file is the canonical key list — `en.ts`
|
||||||
|
// and the `StringKey` type derive from it),
|
||||||
|
// 2. add the same key + EN value in `en.ts`,
|
||||||
|
// 3. consume via `t('key', { var: 'x' })` in components.
|
||||||
|
// Interpolation uses `{name}` placeholders resolved against the second arg.
|
||||||
|
//
|
||||||
|
// The widget no longer renders a hero (avatar/name/handle/description) —
|
||||||
|
// that block lives in the host's BotShellHero. Status is surfaced inline
|
||||||
|
// inside the relevant section.
|
||||||
|
|
||||||
|
export const RU = {
|
||||||
|
// --- Inline section status ---------------------------------------------
|
||||||
|
'status.unknown': 'Проверка статуса…',
|
||||||
|
'status.disconnected': 'Discord не привязан',
|
||||||
|
'status.connected': 'Discord привязан',
|
||||||
|
'status.connected-as': 'Discord привязан как {handle}',
|
||||||
|
'status.connection-dead': 'Соединение с Discord потеряно',
|
||||||
|
'status.token-stored': 'Сессия Discord не активна',
|
||||||
|
'status.qr-verifying': 'Проверяем вход…',
|
||||||
|
'status.logging-out': 'Завершение сеанса…',
|
||||||
|
'status.reconnecting': 'Переподключаюсь к Discord…',
|
||||||
|
// --- Section headers ---------------------------------------------------
|
||||||
|
'card.login-qr.name': 'Войти по QR-коду',
|
||||||
|
// Discord QR требует МОБИЛЬНОЕ приложение Discord (legacy remoteauth
|
||||||
|
// не работает с десктопным клиентом) — это важная подсказка, чтобы у
|
||||||
|
// пользователя без мобильного клиента не возникло тупика «попробовал
|
||||||
|
// и не работает».
|
||||||
|
'card.login-qr.desc': 'Отсканировать QR из мобильного приложения Discord',
|
||||||
|
'card.refresh.aria': 'Обновить статус',
|
||||||
|
'card.refresh.label': 'Обновить статус',
|
||||||
|
'card.refresh.name': 'Обновить статус',
|
||||||
|
'card.refresh.desc': 'Перепроверить, привязан ли Discord',
|
||||||
|
'card.refresh.in-flight': 'Проверяю…',
|
||||||
|
// --- About panel -------------------------------------------------------
|
||||||
|
'card.about.name': 'Как работает Discord-бот',
|
||||||
|
'card.about.desc': 'Вход, безопасность и исходный код',
|
||||||
|
'about.title': 'О боте Discord',
|
||||||
|
'about.body-1':
|
||||||
|
'Этот бот подключает Discord к Vojo. После входа личные чаты и серверы Discord появятся в списке чатов Vojo, а ответы из приложения Vojo будут отправляться собеседникам как обычные сообщения в Discord.',
|
||||||
|
'about.body-2':
|
||||||
|
'Для входа нужен мобильный клиент Discord — отсканируйте QR-код через «Настройки → Устройства → Сканировать QR-код». Десктопный Discord или браузер для входа не подходят: используется механизм remoteauth, который доступен только в мобильном приложении.',
|
||||||
|
'about.body-3':
|
||||||
|
'Подключение работает через open-source мост mautrix-discord. Он создаёт Discord-сессию на сервере Vojo и использует её для связи Discord с вашим аккаунтом Vojo: получает сообщения из Discord и отправляет ваши ответы обратно.',
|
||||||
|
'about.github-label': 'Исходный код моста открыт на GitHub:',
|
||||||
|
'about.github-url': 'https://github.com/mautrix/discord',
|
||||||
|
'about.body-4':
|
||||||
|
'Отозвать доступ можно в любой момент — кнопкой «Выйти из Discord» здесь, либо в самом Discord через «Настройки → Устройства → Выйти из Vojo».',
|
||||||
|
'about.close': 'Закрыть',
|
||||||
|
'about.aria-close': 'Закрыть «О боте»',
|
||||||
|
// --- QR form -----------------------------------------------------------
|
||||||
|
// Discord QR не ротируется в отличие от Telegram MTProto — мост держит
|
||||||
|
// одну сессию remoteauth до успеха, ошибки или таймаута. Поэтому в
|
||||||
|
// тексте говорим про «отсканируйте этот QR-код», без указаний на
|
||||||
|
// обновление, и таймаут показываем «всего окна» одной строкой.
|
||||||
|
'auth-card.qr.title': 'Вход по QR-коду',
|
||||||
|
'auth-card.qr.hint': 'Откройте мобильный Discord и отсканируйте этот QR-код.',
|
||||||
|
'auth-card.qr.preparing': 'Готовим QR-код…',
|
||||||
|
'auth-card.qr.aria': 'QR-код для входа в Discord. Отсканируйте его телефоном.',
|
||||||
|
'auth-card.qr.countdown': 'На сканирование осталось {minutes}:{seconds}',
|
||||||
|
'auth-card.qr.expired': 'Окно входа истекло. Нажмите «Отмена» и попробуйте снова.',
|
||||||
|
'auth-card.qr.step-1': 'Откройте мобильное приложение Discord.',
|
||||||
|
'auth-card.qr.step-2': 'Откройте «Настройки → Устройства → Сканировать QR-код».',
|
||||||
|
'auth-card.qr.step-3': 'Отсканируйте QR-код и подтвердите вход на телефоне.',
|
||||||
|
// --- Shared form chrome ------------------------------------------------
|
||||||
|
// Cancel в Discord-flow ЛОКАЛЬНЫЙ: legacy-мост не имеет команды отмены
|
||||||
|
// активного login-qr, поэтому кнопка просто возвращает виджет в
|
||||||
|
// disconnected, а серверная сторона сама истекает по таймауту remoteauth
|
||||||
|
// (~2 минуты по умолчанию). Это написано в about.body-2, и пользователь,
|
||||||
|
// увидев «Окно входа истекло», понимает, что стало с QR.
|
||||||
|
'auth-card.cancel': 'Отмена',
|
||||||
|
'auth-card.waiting-hint': 'Бот ещё думает… ответ может идти до 30 секунд.',
|
||||||
|
// --- Inline errors -----------------------------------------------------
|
||||||
|
'auth-error.captcha-required':
|
||||||
|
'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.',
|
||||||
|
'auth-error.login-failed': 'Не удалось войти: {reason}',
|
||||||
|
'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}',
|
||||||
|
'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}',
|
||||||
|
'auth-error.connect-after-login-failed':
|
||||||
|
'Вход прошёл, но соединиться с Discord не получилось: {reason}',
|
||||||
|
'auth-error.already-logged-in': 'Вы уже вошли в Discord — обновите статус.',
|
||||||
|
'auth-error.unknown-command': 'Бот не знает эту команду — проверьте префикс в config.json.',
|
||||||
|
'auth-error.disconnect-failed': 'Не удалось отключиться: {reason}',
|
||||||
|
// --- Logout / Reconnect ------------------------------------------------
|
||||||
|
// Reconnect-action нужен только в connection_dead / token_stored —
|
||||||
|
// здоровая сессия не показывает кнопку. Текст глагольный, без префиксов.
|
||||||
|
'card.reconnect.name': 'Переподключиться',
|
||||||
|
'card.reconnect.desc': 'Восстановить соединение с Discord без повторного входа',
|
||||||
|
'card.logout.name': 'Выйти из Discord',
|
||||||
|
'card.logout.desc': 'Завершить сеанс на этом аккаунте',
|
||||||
|
'card.logout.confirm-prompt': 'Точно выйти?',
|
||||||
|
'card.logout.confirm-yes': 'Выйти',
|
||||||
|
'card.logout.confirm-no': 'Отмена',
|
||||||
|
// --- Diagnostics in transcript ----------------------------------------
|
||||||
|
'diag.connecting': 'Соединение с Vojo… ожидаем capability handshake.',
|
||||||
|
'diag.ready': 'Готов отправлять команды.',
|
||||||
|
'diag.checking-status': 'Проверяю статус подключения…',
|
||||||
|
'diag.send-failed': 'ошибка отправки: {message}',
|
||||||
|
'diag.history-marker': '─── история ───',
|
||||||
|
'diag.history-unavailable': 'Не удалось прочитать историю — проверяю статус заново.',
|
||||||
|
// QR-сообщения никогда не выводятся целиком в transcript — body содержит
|
||||||
|
// токен `https://discord.com/ra/…`, который мост стирает после скана;
|
||||||
|
// сохранять его в DOM-логе виджета означало бы пережить эту защиту.
|
||||||
|
// Поэтому в логе только нейтральные диагностические строки.
|
||||||
|
'diag.qr-issued': 'QR-код выдан.',
|
||||||
|
'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.',
|
||||||
|
// --- Bootstrap failure -------------------------------------------------
|
||||||
|
'bootstrap.failed': 'Widget не запустился',
|
||||||
|
'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.',
|
||||||
|
'bootstrap.embedded-only': 'Эта страница предназначена для встраивания Vojo по маршруту {route}.',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type StringKey = keyof typeof RU;
|
||||||
63
apps/widget-discord/src/main.tsx
Normal file
63
apps/widget-discord/src/main.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
import { render } from 'preact';
|
||||||
|
import { readBootstrap } from './bootstrap';
|
||||||
|
import { App } from './App';
|
||||||
|
import { createT } from './i18n';
|
||||||
|
import { WidgetApi, buildCapabilities } from './widget-api';
|
||||||
|
import './styles.css';
|
||||||
|
|
||||||
|
// Input-mode detector — see apps/widget-telegram/src/main.tsx for the
|
||||||
|
// full rationale. Capacitor Android WebView mis-reports `hover: hover`
|
||||||
|
// on touch devices, so we drive `:hover` styling off the actual
|
||||||
|
// `pointerdown.pointerType` instead of media queries. The initial guess
|
||||||
|
// based on `(any-pointer: coarse)` covers the pre-first-pointerdown
|
||||||
|
// frame so a touch device doesn't briefly show mouse-mode hover
|
||||||
|
// affordances if the user immediately taps a card.
|
||||||
|
const setInputMode = (mode: 'touch' | 'mouse'): void => {
|
||||||
|
document.documentElement.dataset.input = mode;
|
||||||
|
};
|
||||||
|
setInputMode(window.matchMedia('(any-pointer: coarse)').matches ? 'touch' : 'mouse');
|
||||||
|
window.addEventListener(
|
||||||
|
'pointerdown',
|
||||||
|
(event) => {
|
||||||
|
setInputMode(event.pointerType === 'mouse' ? 'mouse' : 'touch');
|
||||||
|
},
|
||||||
|
{ passive: true, capture: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = document.getElementById('app');
|
||||||
|
if (!root) {
|
||||||
|
throw new Error('#app root element missing — index.html out of sync');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = readBootstrap(window.location.search);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
// Either someone opened the widget URL directly (no host params), or a
|
||||||
|
// host bug failed to provide them. Render a self-contained diagnostic
|
||||||
|
// instead of going silent. Bootstrap failed before we could read
|
||||||
|
// clientLanguage, so let createT fall back to navigator.language.
|
||||||
|
const t = createT();
|
||||||
|
render(
|
||||||
|
<div class="app">
|
||||||
|
<div class="error-banner">
|
||||||
|
<strong>{t('bootstrap.failed')}</strong>
|
||||||
|
{t('bootstrap.missing-params', { names: result.missing.join(', ') })}{' '}
|
||||||
|
{t('bootstrap.embedded-only', { route: '/bots/discord' })}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
root
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Apply initial theme synchronously so the first paint isn't flashed
|
||||||
|
// through the wrong palette.
|
||||||
|
document.documentElement.dataset.theme = result.bootstrap.theme;
|
||||||
|
|
||||||
|
// Instantiate WidgetApi BEFORE React render. The constructor attaches
|
||||||
|
// the message listener synchronously, so by the time the host's
|
||||||
|
// ClientWidgetApi fires its capabilities request on iframe `load`,
|
||||||
|
// we're already listening. Constructing inside a useEffect would race
|
||||||
|
// with the cached-bundle remount path. See widget-telegram for full
|
||||||
|
// rationale.
|
||||||
|
const api = new WidgetApi(result.bootstrap, buildCapabilities(result.bootstrap.roomId));
|
||||||
|
render(<App bootstrap={result.bootstrap} api={api} />, root);
|
||||||
|
}
|
||||||
765
apps/widget-discord/src/state.ts
Normal file
765
apps/widget-discord/src/state.ts
Normal file
|
|
@ -0,0 +1,765 @@
|
||||||
|
// Login state machine — consumes LoginEvent (one per inbound bridge bot
|
||||||
|
// reply) and emits a typed UI state. The widget renders the QR panel and
|
||||||
|
// the status pill from this state, never from raw reply strings.
|
||||||
|
//
|
||||||
|
// Discord vs Telegram differences:
|
||||||
|
// - QR-only: there's no phone/code/password ladder, so the state space
|
||||||
|
// is much smaller than the Telegram reducer.
|
||||||
|
// - status comes from `ping` (legacy mautrix command system), not
|
||||||
|
// `list-logins` (bridgev2). The four ping replies map to four states:
|
||||||
|
// disconnected / connected / connection_dead / token_stored.
|
||||||
|
// - no list-logins-derived `loginId`; logout is the bare `logout` verb,
|
||||||
|
// so the connected state doesn't need to gate on a login id.
|
||||||
|
// - the QR is NOT rotated by Discord remoteauth (single image per
|
||||||
|
// login attempt). The state machine still tracks `qrEventId` so the
|
||||||
|
// redaction handler can match against it and ignore unrelated cleanup.
|
||||||
|
//
|
||||||
|
// State-gating policy: late-arriving replies from cancelled flows must
|
||||||
|
// not resurrect dead state. The `cancel_pending` action ALWAYS lands us
|
||||||
|
// in `disconnected` immediately; later bridge events arriving after
|
||||||
|
// cancel are filtered by the live reducer.
|
||||||
|
|
||||||
|
import type { LoginEvent } from './bridge-protocol/types';
|
||||||
|
|
||||||
|
export type LoginErrorFlag =
|
||||||
|
| { kind: 'login_failed'; reason?: string }
|
||||||
|
| { kind: 'captcha_required' }
|
||||||
|
| { kind: 'login_websocket_failed'; reason?: string }
|
||||||
|
| { kind: 'connect_after_login_failed'; reason?: string }
|
||||||
|
| { kind: 'prepare_login_failed'; reason?: string }
|
||||||
|
| { kind: 'already_logged_in' }
|
||||||
|
| { kind: 'unknown_command' };
|
||||||
|
// `reconnect_failed` is intentionally NOT a LoginErrorFlag arm: the live
|
||||||
|
// reducer routes that event back to `connected_dead` (no error surface
|
||||||
|
// there — the connected-dead pill IS the error indicator) without
|
||||||
|
// staging a reason for `localizeError`. If a future UI change wants to
|
||||||
|
// surface the reason, add `lastError?: ...` to the connected_dead state
|
||||||
|
// shape and route `reconnect_failed` through it.
|
||||||
|
|
||||||
|
// A live form is open and waiting for user action. M-discord ships with
|
||||||
|
// only one: the QR panel. Hydrate's restorable shape collapses to this
|
||||||
|
// single variant + the `qr_verifying` interstitial.
|
||||||
|
export type PendingFormState = {
|
||||||
|
kind: 'awaiting_qr_scan';
|
||||||
|
discordUrl: string;
|
||||||
|
qrEventId: string;
|
||||||
|
firstShownAt: number;
|
||||||
|
lastError?: LoginErrorFlag;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type LoginState =
|
||||||
|
// Pre-handshake / pre-ping. Status pill: --faint.
|
||||||
|
| { kind: 'unknown' }
|
||||||
|
// ping returned `not_logged_in`, OR logout completed. Status pill:
|
||||||
|
// --rose. The card grid offers the QR-login affordance.
|
||||||
|
| { kind: 'disconnected'; lastError?: LoginErrorFlag }
|
||||||
|
// QR-login in progress. Optimistically transitioned by `start_qr_login`;
|
||||||
|
// overwritten with real discordUrl/qrEventId by the live `qr_displayed`
|
||||||
|
// event. Status pill: --amber.
|
||||||
|
| PendingFormState
|
||||||
|
// QR was redacted (i.e. the bridge accepted a scan), but we don't yet
|
||||||
|
// know whether login succeeded. Held as an intermediate spinner until
|
||||||
|
// the next bridge signal arrives. Status pill: --amber.
|
||||||
|
| { kind: 'qr_verifying' }
|
||||||
|
// logout in flight — waiting for `Logged out successfully.`. Status
|
||||||
|
// pill: --amber.
|
||||||
|
| { kind: 'logging_out' }
|
||||||
|
// reconnect in flight (recovery from connection_dead / token_stored).
|
||||||
|
// Waiting for `Successfully reconnected` or `You're already connected`.
|
||||||
|
// Status pill: --amber. `handle` is carried through from the
|
||||||
|
// connected_dead state so a successful reconnect can flip directly to
|
||||||
|
// `connected{handle}` without bouncing through a transient `unknown`
|
||||||
|
// (which would briefly paint a faint «Проверка статуса…» pill — bad
|
||||||
|
// UX immediately after the user took an action).
|
||||||
|
| { kind: 'reconnecting'; handle?: string }
|
||||||
|
// Live session — ping or login_success confirmed. Discord legacy bridge
|
||||||
|
// doesn't have a per-account loginId concept (single Discord account
|
||||||
|
// per Matrix user), so logout doesn't need an id.
|
||||||
|
| { kind: 'connected'; handle: string; discordId?: string }
|
||||||
|
// ping says we have a token but the connection's down. Status pill:
|
||||||
|
// green-ish but with a Reconnect recovery action exposed. The reducer
|
||||||
|
// distinguishes `connection_dead` (Discord WS dropped) from `token_stored`
|
||||||
|
// (we have the token but never got far enough to connect), but the UI
|
||||||
|
// collapses both into the same shape — they share the recovery path.
|
||||||
|
| { kind: 'connected_dead'; reason: 'connection_dead' | 'token_stored'; handle?: string };
|
||||||
|
|
||||||
|
// States that the hydrate path can restore after a reload. The QR panel
|
||||||
|
// (`awaiting_qr_scan`) survives reloads via the m.image / m.room.redaction
|
||||||
|
// timeline; `qr_verifying` covers the post-scan pre-success interstitial.
|
||||||
|
// Other transient states (logging_out, reconnecting) deliberately don't
|
||||||
|
// survive — those are tied to live in-flight commands and would feel
|
||||||
|
// stuck on reload; the hydrate path falls through to live ping.
|
||||||
|
export type HydrateRestoredState =
|
||||||
|
| PendingFormState
|
||||||
|
| { kind: 'qr_verifying' };
|
||||||
|
|
||||||
|
// Outbound user actions the App dispatches. Form-submit actions clear any
|
||||||
|
// pending lastError; structural transitions optimistically advance state —
|
||||||
|
// the App rolls them back on send-failure where the bot would otherwise
|
||||||
|
// leave us stuck.
|
||||||
|
export type LoginAction =
|
||||||
|
| { kind: 'event'; event: LoginEvent }
|
||||||
|
| { kind: 'start_qr_login' } // user clicked «Войти по QR»
|
||||||
|
| { kind: 'request_logout' } // user clicked «Выйти из Discord»
|
||||||
|
// user clicked «Переподключиться» — App passes the current handle
|
||||||
|
// (from `connected_dead.handle` or `connected.handle`) so the
|
||||||
|
// transient `reconnecting` state carries it forward; without this the
|
||||||
|
// post-reconnect_ok branch can't paint the connected pill until the
|
||||||
|
// follow-up ping resolves.
|
||||||
|
| { kind: 'request_reconnect'; handle?: string }
|
||||||
|
// Discord legacy mautrix has no `cancel` command. Cancel is LOCAL —
|
||||||
|
// returns the widget to disconnected immediately; the bridge's
|
||||||
|
// remoteauth websocket eventually times out on its own. The action is
|
||||||
|
// kept symmetrical with TG's reducer for shape consistency, but
|
||||||
|
// dispatching it doesn't trigger any send.
|
||||||
|
| { kind: 'cancel_pending' }
|
||||||
|
| { kind: 'hydrate'; state: HydrateRestoredState };
|
||||||
|
|
||||||
|
export const initialLoginState: LoginState = { kind: 'unknown' };
|
||||||
|
|
||||||
|
const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiting_qr_scan';
|
||||||
|
|
||||||
|
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
|
||||||
|
if (action.kind === 'hydrate') {
|
||||||
|
// hydrate is a one-shot mount-time seed. If a live event already
|
||||||
|
// moved us off `unknown`, the live truth wins; the cached timeline
|
||||||
|
// snapshot is by definition older than what the live event just told
|
||||||
|
// us. Without this gate, a stale `awaiting_qr_scan` from a previous
|
||||||
|
// session could overwrite a legitimate `connected` that arrived
|
||||||
|
// during the readTimeline await.
|
||||||
|
if (state.kind !== 'unknown') return state;
|
||||||
|
return action.state;
|
||||||
|
}
|
||||||
|
if (action.kind === 'start_qr_login') {
|
||||||
|
// Optimistic placeholder; the live `qr_displayed` event overwrites
|
||||||
|
// discordUrl + qrEventId + firstShownAt. If the `!discord login-qr`
|
||||||
|
// send fails, the App rolls back to `disconnected`.
|
||||||
|
return {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: '',
|
||||||
|
qrEventId: '',
|
||||||
|
firstShownAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action.kind === 'request_logout') {
|
||||||
|
return { kind: 'logging_out' };
|
||||||
|
}
|
||||||
|
if (action.kind === 'request_reconnect') {
|
||||||
|
return { kind: 'reconnecting', handle: action.handle };
|
||||||
|
}
|
||||||
|
if (action.kind === 'cancel_pending') {
|
||||||
|
// Optimistic: drop straight back to disconnected. Discord legacy mautrix
|
||||||
|
// has no `cancel` command — the bridge's remoteauth websocket continues
|
||||||
|
// until it succeeds or times out internally. From the user's POV the
|
||||||
|
// widget returns to disconnected, and any later QR redaction / login
|
||||||
|
// success / login failure event from the abandoned flow is filtered
|
||||||
|
// by the per-event gates below (qr_redacted gated on awaiting_qr_scan,
|
||||||
|
// login_success / login_failed gated on awaiting_qr_scan|qr_verifying).
|
||||||
|
return { kind: 'disconnected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = action.event;
|
||||||
|
switch (event.kind) {
|
||||||
|
// --- ping replies ----------------------------------------------------
|
||||||
|
|
||||||
|
case 'not_logged_in':
|
||||||
|
// Accept from states where flipping to disconnected is correct.
|
||||||
|
// Late-arriving `not_logged_in` MUST NOT clobber an active QR-scan
|
||||||
|
// (which was started after the ping was fired but before the reply
|
||||||
|
// landed) — that's the same race the TG reducer guards against.
|
||||||
|
if (
|
||||||
|
state.kind === 'unknown' ||
|
||||||
|
state.kind === 'disconnected' ||
|
||||||
|
state.kind === 'logging_out' ||
|
||||||
|
state.kind === 'qr_verifying' ||
|
||||||
|
state.kind === 'reconnecting' ||
|
||||||
|
state.kind === 'connected_dead'
|
||||||
|
) {
|
||||||
|
return { kind: 'disconnected' };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
|
case 'logged_in':
|
||||||
|
// Authoritative source — accept from any state. Used by both the
|
||||||
|
// initial ping AND the post-`login_success` re-ping that picks up
|
||||||
|
// the discordId snowflake.
|
||||||
|
return {
|
||||||
|
kind: 'connected',
|
||||||
|
handle: event.handle,
|
||||||
|
discordId: event.discordId,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'connection_dead':
|
||||||
|
// ping says token's good but the WS is down. Show the connected
|
||||||
|
// chrome with a Reconnect recovery action.
|
||||||
|
return {
|
||||||
|
kind: 'connected_dead',
|
||||||
|
reason: 'connection_dead',
|
||||||
|
handle: state.kind === 'connected' ? state.handle : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'token_stored_not_connected':
|
||||||
|
return {
|
||||||
|
kind: 'connected_dead',
|
||||||
|
reason: 'token_stored',
|
||||||
|
handle: state.kind === 'connected' ? state.handle : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- QR lifecycle ----------------------------------------------------
|
||||||
|
|
||||||
|
case 'qr_displayed': {
|
||||||
|
// Defence-in-depth: an inbound qr_displayed MUST carry a non-empty
|
||||||
|
// event id (the host driver rejects empty event_id at the sanitizer;
|
||||||
|
// this is a redundant guard).
|
||||||
|
if (event.eventId.length === 0) return state;
|
||||||
|
|
||||||
|
// Initial QR from a fresh login attempt — accept from:
|
||||||
|
// * `unknown` — cold-start before ping resolves;
|
||||||
|
// * placeholder `awaiting_qr_scan{qrEventId=''}` from start_qr_login.
|
||||||
|
//
|
||||||
|
// We DO NOT accept from `disconnected`. Discord legacy mautrix has
|
||||||
|
// no cancel command, so when the user clicks Cancel locally the
|
||||||
|
// bridge's remoteauth goroutine continues until success / failure
|
||||||
|
// / internal timeout. The widget transitions to `disconnected`
|
||||||
|
// immediately, but the bridge eventually emits the m.image. If we
|
||||||
|
// accepted that here, the user would see a QR they didn't ask for
|
||||||
|
// — the bridge has no way to know the user moved on. Drop it
|
||||||
|
// silently; the user has to click «Войти по QR» again to express
|
||||||
|
// intent (which resets the placeholder and lets the next m.image
|
||||||
|
// land).
|
||||||
|
if (
|
||||||
|
state.kind === 'unknown' ||
|
||||||
|
(state.kind === 'awaiting_qr_scan' && state.qrEventId === '')
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: event.discordUrl,
|
||||||
|
qrEventId: event.eventId,
|
||||||
|
firstShownAt:
|
||||||
|
state.kind === 'awaiting_qr_scan' && state.firstShownAt
|
||||||
|
? state.firstShownAt
|
||||||
|
: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.kind !== 'awaiting_qr_scan') return state;
|
||||||
|
|
||||||
|
// Hypothetical edit pointing at our anchor — repaint URL, keep id.
|
||||||
|
// Discord doesn't currently edit QRs but the path stays for
|
||||||
|
// forward-compat (cheaper to keep than to reconstruct).
|
||||||
|
if (event.replacesEventId === state.qrEventId) {
|
||||||
|
return { ...state, discordUrl: event.discordUrl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh non-edit qr_displayed while we're already tracking one —
|
||||||
|
// could be a bridge-side restart (rare). Adopt as new anchor.
|
||||||
|
if (!event.replacesEventId) {
|
||||||
|
return {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: event.discordUrl,
|
||||||
|
qrEventId: event.eventId,
|
||||||
|
firstShownAt: Date.now(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit pointing at something we don't track — ignore.
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'qr_redacted': {
|
||||||
|
// Bridge cleaned up the QR after a successful scan (commands.go
|
||||||
|
// l.197: `_, _ = ce.MainIntent().RedactEvent(ce.RoomID, qrCodeEvent)`
|
||||||
|
// — only fires on the success path). Held as `qr_verifying` until
|
||||||
|
// the success line lands. Only honour from awaiting_qr_scan with a
|
||||||
|
// matching event id.
|
||||||
|
if (state.kind !== 'awaiting_qr_scan') return state;
|
||||||
|
if (state.qrEventId !== event.redactsEventId) return state;
|
||||||
|
return { kind: 'qr_verifying' };
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'login_success':
|
||||||
|
// Honour from any non-terminal state. The bridge's success line
|
||||||
|
// doesn't include the discordId; the App fires `ping` afterwards
|
||||||
|
// to upgrade to the full `connected{handle, discordId}` shape.
|
||||||
|
return { kind: 'connected', handle: event.handle };
|
||||||
|
|
||||||
|
case 'login_failed':
|
||||||
|
// Generic Discord-side login failure — bridge replies «Error logging
|
||||||
|
// in: <go-error>». Routes back to disconnected with the verbatim
|
||||||
|
// reason as a warn line. Only honour when a QR flow is in flight;
|
||||||
|
// otherwise it's stale (e.g. an old failure replaying after page
|
||||||
|
// reload while the user is already connected).
|
||||||
|
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
||||||
|
return {
|
||||||
|
kind: 'disconnected',
|
||||||
|
lastError: { kind: 'login_failed', reason: event.reason },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'captcha_required':
|
||||||
|
// Discord presented a captcha during remoteauth — QR flow is dead
|
||||||
|
// for this attempt. Surface as a hint suggesting token-login (which
|
||||||
|
// we don't expose in the widget; users can do it via chat-fallback).
|
||||||
|
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
||||||
|
return { kind: 'disconnected', lastError: { kind: 'captcha_required' } };
|
||||||
|
|
||||||
|
case 'login_websocket_failed':
|
||||||
|
// Pre-QR failure: couldn't reach Discord remoteauth. The QR was
|
||||||
|
// never displayed in the first place. State `awaiting_qr_scan` with
|
||||||
|
// empty discordUrl is the placeholder set by `start_qr_login`;
|
||||||
|
// this fires before the first qr_displayed lands.
|
||||||
|
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
||||||
|
return {
|
||||||
|
kind: 'disconnected',
|
||||||
|
lastError: { kind: 'login_websocket_failed', reason: event.reason },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'connect_after_login_failed':
|
||||||
|
// Post-scan rare: remoteauth gave us a token, but the bridge couldn't
|
||||||
|
// connect to Discord with it. The bridge has the token cached and
|
||||||
|
// might recover on next ping; we still route to disconnected so the
|
||||||
|
// user can retry.
|
||||||
|
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
||||||
|
return {
|
||||||
|
kind: 'disconnected',
|
||||||
|
lastError: { kind: 'connect_after_login_failed', reason: event.reason },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'prepare_login_failed':
|
||||||
|
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
||||||
|
return {
|
||||||
|
kind: 'disconnected',
|
||||||
|
lastError: { kind: 'prepare_login_failed', reason: event.reason },
|
||||||
|
};
|
||||||
|
|
||||||
|
case 'already_logged_in':
|
||||||
|
// The user clicked «Войти по QR» but the bridge is already logged
|
||||||
|
// in — race against ping. Surface a soft warning and let the App's
|
||||||
|
// re-ping reconcile to the connected state.
|
||||||
|
if (isFormState(state)) {
|
||||||
|
return { ...state, lastError: { kind: 'already_logged_in' } };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
|
// --- logout ----------------------------------------------------------
|
||||||
|
|
||||||
|
case 'logout_ok':
|
||||||
|
case 'logout_no_op':
|
||||||
|
// Late `Logged out` from a previous session can arrive while the
|
||||||
|
// user is mid-new-flow. Only honour from logging_out; other states
|
||||||
|
// keep their flow.
|
||||||
|
if (state.kind !== 'logging_out') return state;
|
||||||
|
return { kind: 'disconnected' };
|
||||||
|
|
||||||
|
// --- disconnect (read-only, never sent by widget) -------------------
|
||||||
|
|
||||||
|
case 'disconnect_ok':
|
||||||
|
case 'disconnect_no_op':
|
||||||
|
// User typed `disconnect` manually in chat-fallback while the widget
|
||||||
|
// was open. Reflect the bridge's truth: no token-loss, but no live
|
||||||
|
// connection either — same shape as `token_stored`. Both
|
||||||
|
// `connected` (string handle) and `connected_dead` (handle?:
|
||||||
|
// string) expose `handle` on the same key, so a single read works.
|
||||||
|
if (state.kind === 'connected' || state.kind === 'connected_dead') {
|
||||||
|
return {
|
||||||
|
kind: 'connected_dead',
|
||||||
|
reason: 'token_stored',
|
||||||
|
handle: state.handle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
|
case 'disconnect_failed':
|
||||||
|
// Manual disconnect attempt failed — keep current state, the widget
|
||||||
|
// doesn't surface a UI for this since it never sent the command.
|
||||||
|
return state;
|
||||||
|
|
||||||
|
// --- reconnect -------------------------------------------------------
|
||||||
|
|
||||||
|
case 'reconnect_ok':
|
||||||
|
case 'reconnect_no_op':
|
||||||
|
// After a successful reconnect, ping is the source of truth for the
|
||||||
|
// handle. The App fires `ping` after this event lands to refresh.
|
||||||
|
// We flip to `connected` immediately so the user sees an immediate
|
||||||
|
// green pill confirming their click; the post-event ping refreshes
|
||||||
|
// the handle / discordId within ~100ms. Both `reconnecting` and
|
||||||
|
// `connected_dead` carry `handle?` — a missing handle still flips
|
||||||
|
// green with an empty handle, which the UI's
|
||||||
|
// `state.handle ? connected-as : connected` ternary tolerates.
|
||||||
|
// This avoids the `unknown` flap that the previous draft would
|
||||||
|
// produce when no handle was stashed.
|
||||||
|
if (state.kind === 'reconnecting' || state.kind === 'connected_dead') {
|
||||||
|
return { kind: 'connected', handle: state.handle ?? '' };
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
|
||||||
|
case 'reconnect_failed':
|
||||||
|
if (state.kind !== 'reconnecting') return state;
|
||||||
|
// Roll back to connected_dead carrying the previous handle. The
|
||||||
|
// user can hit Reconnect again or refresh. We don't surface the
|
||||||
|
// error reason here — the connected_dead pill itself reads as
|
||||||
|
// «something is wrong, try Reconnect» — adding a transient red
|
||||||
|
// banner adjacent to a recovery affordance is overkill.
|
||||||
|
return {
|
||||||
|
kind: 'connected_dead',
|
||||||
|
reason: 'connection_dead',
|
||||||
|
handle: state.handle,
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- bridge-side errors ---------------------------------------------
|
||||||
|
|
||||||
|
case 'unknown_command':
|
||||||
|
// Shouldn't happen — we only send commands the bridge knows. Visible
|
||||||
|
// when /config.json's commandPrefix drifts from the bridge's actual
|
||||||
|
// command_prefix. Surface loudly on disconnected.
|
||||||
|
return { kind: 'disconnected', lastError: { kind: 'unknown_command' } };
|
||||||
|
|
||||||
|
case 'unknown':
|
||||||
|
return state;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
// Exhaustiveness check — TS flags this if a new LoginEvent kind is
|
||||||
|
// added without a case here.
|
||||||
|
const exhaustive: never = event;
|
||||||
|
return exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- hydrate-from-timeline -----------------------------------------------
|
||||||
|
//
|
||||||
|
// Discord's hydrate is simpler than Telegram's because the QR flow has
|
||||||
|
// fewer states. We walk past→present and let each event freely transition
|
||||||
|
// the state — like the TG hydrate, this is permissive (no out-of-thin-air
|
||||||
|
// rejection) because we trust the bridge's durable timeline.
|
||||||
|
|
||||||
|
// 3 minutes — Discord remoteauth's server-side timeout sits around 2
|
||||||
|
// minutes (verified empirically against v0.7.6's remoteauth/client.go;
|
||||||
|
// no explicit constant in the lib, the server-side gateway closes the
|
||||||
|
// websocket on inactivity). We use 3 min as a slight safety margin so
|
||||||
|
// reload-after-success grace still works while the panel is still
|
||||||
|
// fresh enough to scan. Telegram's QR rotates internally and lives ~10
|
||||||
|
// min, which is why the TG widget uses 10 min — Discord's single-shot
|
||||||
|
// remoteauth needs the tighter window.
|
||||||
|
const HYDRATE_FRESHNESS_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
|
export type HydrateInput = {
|
||||||
|
ev: LoginEvent;
|
||||||
|
ts: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type HydrateAccumulator = {
|
||||||
|
state: LoginState;
|
||||||
|
pendingTs: number | null;
|
||||||
|
terminated: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepHydrate = (
|
||||||
|
prevAcc: HydrateAccumulator,
|
||||||
|
input: HydrateInput
|
||||||
|
): HydrateAccumulator => {
|
||||||
|
const { ev, ts } = input;
|
||||||
|
|
||||||
|
// After a terminal event we normally stop — except if a fresh
|
||||||
|
// `qr_displayed` shows up, that's the bridge signature of a NEW login
|
||||||
|
// flow. The user cancelled (or finished) and is now logging in again;
|
||||||
|
// the chain should resume tracking from the new start. Without this
|
||||||
|
// re-entry, `[qr_displayed, login_success, qr_displayed]` (logout-then-
|
||||||
|
// re-login-mid-QR) would return null.
|
||||||
|
if (prevAcc.terminated && ev.kind !== 'qr_displayed') {
|
||||||
|
return prevAcc;
|
||||||
|
}
|
||||||
|
// Restart-on-re-entry: clear the terminated bit AND any prior tracked
|
||||||
|
// state so the new flow's first event becomes the new anchor without
|
||||||
|
// inheriting the old QR's eventId.
|
||||||
|
const acc: HydrateAccumulator = prevAcc.terminated
|
||||||
|
? { state: { kind: 'unknown' }, pendingTs: null, terminated: false }
|
||||||
|
: prevAcc;
|
||||||
|
|
||||||
|
switch (ev.kind) {
|
||||||
|
case 'qr_displayed': {
|
||||||
|
// Same anchor logic as the live reducer.
|
||||||
|
if (acc.state.kind !== 'awaiting_qr_scan') {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: ev.discordUrl,
|
||||||
|
qrEventId: ev.eventId,
|
||||||
|
firstShownAt: ts,
|
||||||
|
},
|
||||||
|
pendingTs: ts,
|
||||||
|
terminated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (ev.replacesEventId === acc.state.qrEventId) {
|
||||||
|
return {
|
||||||
|
state: { ...acc.state, discordUrl: ev.discordUrl },
|
||||||
|
pendingTs: ts,
|
||||||
|
terminated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!ev.replacesEventId) {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: ev.discordUrl,
|
||||||
|
qrEventId: ev.eventId,
|
||||||
|
firstShownAt: ts,
|
||||||
|
},
|
||||||
|
pendingTs: ts,
|
||||||
|
terminated: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'qr_redacted': {
|
||||||
|
if (acc.state.kind !== 'awaiting_qr_scan') return acc;
|
||||||
|
if (acc.state.qrEventId !== ev.redactsEventId) return acc;
|
||||||
|
// Move into qr_verifying and keep the chain open — the success line
|
||||||
|
// typically follows in the same scan window.
|
||||||
|
return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Terminal events — collapse the chain. State becomes whatever the
|
||||||
|
// bot confirmed last; the caller returns null and lets live `ping`
|
||||||
|
// reconcile.
|
||||||
|
case 'login_success':
|
||||||
|
case 'logged_in':
|
||||||
|
case 'logout_ok':
|
||||||
|
case 'logout_no_op':
|
||||||
|
case 'not_logged_in':
|
||||||
|
case 'connection_dead':
|
||||||
|
case 'token_stored_not_connected':
|
||||||
|
case 'reconnect_ok':
|
||||||
|
case 'reconnect_no_op':
|
||||||
|
case 'reconnect_failed':
|
||||||
|
case 'disconnect_ok':
|
||||||
|
case 'disconnect_no_op':
|
||||||
|
case 'disconnect_failed':
|
||||||
|
case 'login_failed':
|
||||||
|
case 'captcha_required':
|
||||||
|
case 'login_websocket_failed':
|
||||||
|
case 'connect_after_login_failed':
|
||||||
|
case 'prepare_login_failed':
|
||||||
|
case 'unknown_command':
|
||||||
|
return { state: acc.state, pendingTs: null, terminated: true };
|
||||||
|
|
||||||
|
case 'already_logged_in':
|
||||||
|
case 'unknown':
|
||||||
|
// Soft no-op for hydrate. already_logged_in is a live-flow warning
|
||||||
|
// that doesn't reflect persistent state; unknown is a wording-drift
|
||||||
|
// catch-all.
|
||||||
|
return acc;
|
||||||
|
|
||||||
|
default: {
|
||||||
|
const exhaustive: never = ev;
|
||||||
|
return exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const hydrateFromTimeline = (
|
||||||
|
inputs: ReadonlyArray<HydrateInput>,
|
||||||
|
now: number = Date.now()
|
||||||
|
): HydrateRestoredState | null => {
|
||||||
|
const acc = inputs.reduce<HydrateAccumulator>(stepHydrate, {
|
||||||
|
state: { kind: 'unknown' },
|
||||||
|
pendingTs: null,
|
||||||
|
terminated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (acc.terminated) return null;
|
||||||
|
if (acc.pendingTs === null) return null;
|
||||||
|
if (now - acc.pendingTs > HYDRATE_FRESHNESS_MS) return null;
|
||||||
|
if (acc.state.kind === 'qr_verifying') return acc.state;
|
||||||
|
if (!isFormState(acc.state)) return null;
|
||||||
|
return acc.state;
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- DEV sanity assertions ------------------------------------------------
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
runHydrateSanity();
|
||||||
|
}
|
||||||
|
|
||||||
|
function runHydrateSanity(): void {
|
||||||
|
const t0 = 1_700_000_000_000;
|
||||||
|
const recent = (offset: number) => t0 + offset;
|
||||||
|
const now = t0 + 60 * 1000;
|
||||||
|
|
||||||
|
const cases: Array<{
|
||||||
|
name: string;
|
||||||
|
inputs: HydrateInput[];
|
||||||
|
expected: LoginState | null;
|
||||||
|
nowOverride?: number;
|
||||||
|
}> = [
|
||||||
|
{ name: 'empty timeline → null', inputs: [], expected: null },
|
||||||
|
{
|
||||||
|
name: 'lone qr_displayed → awaiting_qr_scan',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expected: {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: 'https://discord.com/ra/A',
|
||||||
|
qrEventId: '$qrA',
|
||||||
|
firstShownAt: recent(0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qr_redacted with mismatched target → ignored',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
{ ev: { kind: 'qr_redacted', redactsEventId: '$other' }, ts: recent(30000) },
|
||||||
|
],
|
||||||
|
expected: {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: 'https://discord.com/ra/A',
|
||||||
|
qrEventId: '$qrA',
|
||||||
|
firstShownAt: recent(0),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qr scan → no follow-up → qr_verifying (reload during the gap)',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
{ ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) },
|
||||||
|
],
|
||||||
|
expected: { kind: 'qr_verifying' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'qr scan → login_success → null (terminal — let ping reconcile)',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
{ ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) },
|
||||||
|
{ ev: { kind: 'login_success', handle: 'example' }, ts: recent(31000) },
|
||||||
|
],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'login_failed after qr → null (terminal)',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
{ ev: { kind: 'login_failed', reason: 'rate limited' }, ts: recent(15000) },
|
||||||
|
],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'captcha_required after qr → null (terminal)',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
{ ev: { kind: 'captcha_required' }, ts: recent(10000) },
|
||||||
|
],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'logout-then-relogin-mid-qr → awaiting_qr_scan (resume tracking)',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/OLD', eventId: '$qrOld' },
|
||||||
|
ts: recent(0),
|
||||||
|
},
|
||||||
|
{ ev: { kind: 'qr_redacted', redactsEventId: '$qrOld' }, ts: recent(15000) },
|
||||||
|
{ ev: { kind: 'login_success', handle: 'old' }, ts: recent(16000) },
|
||||||
|
{ ev: { kind: 'logout_ok' }, ts: recent(20000) },
|
||||||
|
{
|
||||||
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/NEW', eventId: '$qrNew' },
|
||||||
|
ts: recent(25000),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expected: {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: 'https://discord.com/ra/NEW',
|
||||||
|
qrEventId: '$qrNew',
|
||||||
|
firstShownAt: recent(25000),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pending too old (5 min) → null (freshness guard, 3-min window)',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: {
|
||||||
|
kind: 'qr_displayed',
|
||||||
|
discordUrl: 'https://discordapp.com/ra/A',
|
||||||
|
eventId: '$qrA',
|
||||||
|
},
|
||||||
|
ts: t0 - 5 * 60 * 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expected: null,
|
||||||
|
nowOverride: t0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'pending just inside window (2 min) → state',
|
||||||
|
inputs: [
|
||||||
|
{
|
||||||
|
ev: {
|
||||||
|
kind: 'qr_displayed',
|
||||||
|
discordUrl: 'https://discordapp.com/ra/A',
|
||||||
|
eventId: '$qrA',
|
||||||
|
},
|
||||||
|
ts: t0 - 2 * 60 * 1000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
expected: {
|
||||||
|
kind: 'awaiting_qr_scan',
|
||||||
|
discordUrl: 'https://discordapp.com/ra/A',
|
||||||
|
qrEventId: '$qrA',
|
||||||
|
firstShownAt: t0 - 2 * 60 * 1000,
|
||||||
|
},
|
||||||
|
nowOverride: t0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'connection_dead alone → null (terminal — let live ping reconcile)',
|
||||||
|
inputs: [{ ev: { kind: 'connection_dead' }, ts: recent(0) }],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'token_stored_not_connected alone → null (terminal — let live ping reconcile)',
|
||||||
|
inputs: [{ ev: { kind: 'token_stored_not_connected' }, ts: recent(0) }],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'logged_in alone → null (terminal — let live ping reconcile)',
|
||||||
|
inputs: [{ ev: { kind: 'logged_in', handle: 'x' }, ts: recent(0) }],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'unknown alone → null',
|
||||||
|
inputs: [{ ev: { kind: 'unknown' }, ts: recent(0) }],
|
||||||
|
expected: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const c of cases) {
|
||||||
|
const actual = hydrateFromTimeline(c.inputs, c.nowOverride ?? now);
|
||||||
|
if (!sameLoginState(actual, c.expected)) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error('[hydrate sanity] mismatch', { case: c.name, actual, expected: c.expected });
|
||||||
|
throw new Error(`hydrate sanity failed: ${c.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameLoginState(a: LoginState | null, b: LoginState | null): boolean {
|
||||||
|
if (a === null || b === null) return a === b;
|
||||||
|
return JSON.stringify(a) === JSON.stringify(b);
|
||||||
|
}
|
||||||
740
apps/widget-discord/src/styles.css
Normal file
740
apps/widget-discord/src/styles.css
Normal file
|
|
@ -0,0 +1,740 @@
|
||||||
|
/* Dawn palette — must stay in sync with
|
||||||
|
* docs/design/new-direct-messages-design/project/stream-v2-dawn.jsx
|
||||||
|
* (DAWN const, lines 4-23). The widget renders inside the Vojo chat slot
|
||||||
|
* which is itself a Dawn surface; the iframe inherits the same visual
|
||||||
|
* canon to feel like a continuation of the host.
|
||||||
|
*
|
||||||
|
* Identical visual vocabulary to apps/widget-telegram/src/styles.css —
|
||||||
|
* the Discord widget keeps fleet-violet (Vojo accent) rather than
|
||||||
|
* adopting Discord blurple, per product decision: «used Vojo style». */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg: #181a20;
|
||||||
|
--bg2: #0d0e11;
|
||||||
|
--surface: #21232b;
|
||||||
|
--surface2: #2a2d36;
|
||||||
|
--divider: rgba(255, 255, 255, 0.06);
|
||||||
|
--hairline: rgba(255, 255, 255, 0.08);
|
||||||
|
--text: #e6e6e9;
|
||||||
|
--muted: rgba(230, 230, 233, 0.55);
|
||||||
|
--faint: rgba(230, 230, 233, 0.32);
|
||||||
|
--fleet: #9580ff;
|
||||||
|
--fleet-soft: #a59cff;
|
||||||
|
--green: #7dd3a8;
|
||||||
|
--amber: #d4b88a;
|
||||||
|
--rose: #c08e7b;
|
||||||
|
--section-pad-x: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme='light'] {
|
||||||
|
/* Light theme is intentionally a thin remap. Vojo is dark-default; the
|
||||||
|
* theme param exists so we don't fight an explicit user/host setting,
|
||||||
|
* not because we expect daily light-mode use. */
|
||||||
|
--bg: #f5f5f7;
|
||||||
|
--bg2: #ffffff;
|
||||||
|
--surface: #f0f0f2;
|
||||||
|
--surface2: #e8e8ec;
|
||||||
|
--divider: rgba(0, 0, 0, 0.08);
|
||||||
|
--hairline: rgba(0, 0, 0, 0.1);
|
||||||
|
--text: #1a1a1d;
|
||||||
|
--muted: rgba(26, 26, 29, 0.62);
|
||||||
|
--faint: rgba(26, 26, 29, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
:root {
|
||||||
|
--section-pad-x: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Kills the translucent grey overlay iOS/Android WebViews paint on top
|
||||||
|
* of any tapped element. Web browsers ignore this. */
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font: 14px/1.45 -apple-system, 'Segoe UI', 'Inter', system-ui, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 100%;
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The hero is OWNED BY THE HOST (BotShellHero). The widget body starts
|
||||||
|
* with the active-state section directly. */
|
||||||
|
|
||||||
|
/* ── Section ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 24px var(--section-pad-x) 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section + .section {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status pill — non-interactive (no cursor:pointer, no hover). The pill
|
||||||
|
* carries the section's identity for stateful sections. */
|
||||||
|
.section-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
margin: 0 0 14px;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status.connected {
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
.section-status.connected .dot {
|
||||||
|
background: var(--green);
|
||||||
|
box-shadow: 0 0 0 3px rgba(125, 211, 168, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status.disconnected {
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
.section-status.disconnected .dot {
|
||||||
|
background: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-status.checking {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
.section-status.checking .dot {
|
||||||
|
background: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Section row: status pill + recovery button (refresh / reconnect /
|
||||||
|
* cancel) when the state has no other affordance. Without this row, the
|
||||||
|
* user can stare at a «Проверка статуса…» pill forever if the first
|
||||||
|
* ping reply dropped on the wire. */
|
||||||
|
.section-recovery-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.section-recovery-row > .section-status {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recovery-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 14px;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.12s, color 0.12s, border-color 0.12s;
|
||||||
|
}
|
||||||
|
:root[data-input='mouse'] .recovery-action:hover:not(:disabled) {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--hairline);
|
||||||
|
}
|
||||||
|
.recovery-action:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
.recovery-action svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Command card (action card with name + desc + chevron) ──────── */
|
||||||
|
|
||||||
|
.command-card {
|
||||||
|
/* `appearance:none` strips native WebView focus paint that otherwise
|
||||||
|
* sits ON TOP of our explicit background — see telegram widget for
|
||||||
|
* the full debugging trail (Capacitor Android WebView holds native
|
||||||
|
* focus paint until focus moves elsewhere). */
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
transition: border-color 0.12s, background 0.12s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover scoped to mouse-mode sessions only — Capacitor Android WebView
|
||||||
|
* reports `(hover: hover)` as TRUE on a pure-touch device, so a media-
|
||||||
|
* query gate doesn't work. `[data-input]` is set in main.tsx from the
|
||||||
|
* actual `pointerdown.pointerType`. */
|
||||||
|
:root[data-input='mouse'] .command-card:hover:not(:disabled) {
|
||||||
|
background: var(--surface);
|
||||||
|
border-color: var(--hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root[data-input='mouse'] .command-card:focus-visible {
|
||||||
|
outline: 2px solid var(--fleet);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-name {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-chevron {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 18px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-chevron svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card.refreshing .command-card-chevron svg {
|
||||||
|
animation: command-card-spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes command-card-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Transcript ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.transcript {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-family: ui-monospace, 'JetBrains Mono', 'SF Mono', monospace;
|
||||||
|
font-size: 12.5px;
|
||||||
|
line-height: 1.55;
|
||||||
|
max-height: 360px;
|
||||||
|
overflow-y: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: var(--surface2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
.transcript::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.transcript::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--surface2);
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 2px solid var(--bg2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
.transcript::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 2px solid var(--bg2);
|
||||||
|
background-clip: padding-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line {
|
||||||
|
padding: 4px 0;
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: flex-start;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line + .transcript-line {
|
||||||
|
border-top: 1px dashed var(--divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line .ts {
|
||||||
|
color: var(--faint);
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line .body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line.from-bot .body {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line.from-user .body {
|
||||||
|
color: var(--fleet-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line.diag .body {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-line.error .body {
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.transcript-empty {
|
||||||
|
color: var(--faint);
|
||||||
|
text-align: center;
|
||||||
|
padding: 16px 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Destructive card — red name marks logout as destructive vs the primary
|
||||||
|
* login card. */
|
||||||
|
.command-card.danger .command-card-name {
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
.command-card.danger:hover:not(:disabled) {
|
||||||
|
border-color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline confirm-in-place body for the destructive logout card. */
|
||||||
|
.command-card-confirm {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-prompt {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text);
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-yes,
|
||||||
|
.command-card-confirm-no,
|
||||||
|
.btn-primary,
|
||||||
|
.btn-text {
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-yes {
|
||||||
|
background: var(--rose);
|
||||||
|
color: #0c0c0e;
|
||||||
|
border: none;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-no {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card-confirm-yes:disabled,
|
||||||
|
.command-card-confirm-no:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Auth card (QR panel chrome) ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.auth-card {
|
||||||
|
background: var(--bg2);
|
||||||
|
border: 1px solid var(--divider);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card.error {
|
||||||
|
border-color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-title {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1.4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-hint {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--fleet);
|
||||||
|
color: #0c0c0e;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-text {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.btn-text:hover:not(:disabled) {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-error {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--rose);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-warn {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 18px;
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-countdown {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--muted);
|
||||||
|
line-height: 18px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
transition: color 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.auth-card-countdown.expired {
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── QR-login panel ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Override the auth-card row layout — QR panel stacks vertically with the
|
||||||
|
* matrix as the visual anchor. */
|
||||||
|
.auth-card-qr {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The QR matrix sits on a hard #fff plate regardless of theme — phone
|
||||||
|
* camera scanners need maximum contrast. */
|
||||||
|
.auth-card-qr-frame {
|
||||||
|
align-self: center;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
/* Lock the inner box to the SVG's rendered size so the placeholder
|
||||||
|
* variant doesn't collapse to zero height while the matrix is being
|
||||||
|
* computed. */
|
||||||
|
min-width: 260px;
|
||||||
|
min-height: 260px;
|
||||||
|
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.06), 0 12px 24px rgba(0, 0, 0, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-qr-placeholder {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: rgba(26, 26, 29, 0.62);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 20px;
|
||||||
|
padding: 96px 16px;
|
||||||
|
}
|
||||||
|
.auth-card-qr-placeholder .dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--amber);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-qr-steps {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.4em;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 19px;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
.auth-card-qr-steps li::marker {
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.auth-card-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.btn-primary,
|
||||||
|
.btn-text {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-card {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.command-card-name {
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.command-card-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.command-grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.auth-card-qr-frame {
|
||||||
|
min-width: 232px;
|
||||||
|
min-height: 232px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
.auth-card-qr-placeholder {
|
||||||
|
padding: 80px 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Linkified transcript bodies ─────────────────────────────────── */
|
||||||
|
|
||||||
|
.transcript-line a {
|
||||||
|
color: var(--fleet-soft);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.transcript-line a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Diagnostic banner (pre-bootstrap failure) ───────────────────── */
|
||||||
|
|
||||||
|
.error-banner {
|
||||||
|
margin: var(--section-pad-x);
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: rgba(192, 142, 123, 0.08);
|
||||||
|
border: 1px solid var(--rose);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--rose);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
color: var(--rose);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-banner code {
|
||||||
|
background: var(--bg2);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: ui-monospace, 'JetBrains Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── About modal ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.about-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(13, 14, 17, 0.72);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 20px;
|
||||||
|
animation: about-fade 0.15s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes about-fade {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-panel {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--hairline);
|
||||||
|
border-radius: 14px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px 18px;
|
||||||
|
border-bottom: 1px solid var(--divider);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-title {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 17px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-close-x {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--muted);
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1;
|
||||||
|
transition: background 0.12s, color 0.12s;
|
||||||
|
}
|
||||||
|
.about-close-x:hover {
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-body {
|
||||||
|
padding: 16px 18px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.about-body p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.about-body a {
|
||||||
|
color: var(--fleet-soft);
|
||||||
|
text-decoration: underline;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.about-body a:hover {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.about-footer {
|
||||||
|
padding: 12px 18px 16px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
border-top: 1px solid var(--divider);
|
||||||
|
}
|
||||||
1
apps/widget-discord/src/vite-env.d.ts
vendored
Normal file
1
apps/widget-discord/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
290
apps/widget-discord/src/widget-api.ts
Normal file
290
apps/widget-discord/src/widget-api.ts
Normal file
|
|
@ -0,0 +1,290 @@
|
||||||
|
// Minimal matrix-widget-api transport implemented inline. Mirrors the
|
||||||
|
// Telegram widget's transport (apps/widget-telegram/src/widget-api.ts);
|
||||||
|
// the postMessage protocol is bot-agnostic and the host-side
|
||||||
|
// BotWidgetDriver / BotWidgetEmbed treat every bot identically.
|
||||||
|
//
|
||||||
|
// Protocol shapes match
|
||||||
|
// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
|
||||||
|
// (in the host repo). Default request timeout on the host transport is
|
||||||
|
// 10 s — keep that in mind for bridge-bot replies that take time.
|
||||||
|
|
||||||
|
import type { WidgetBootstrap } from './bootstrap';
|
||||||
|
|
||||||
|
export type RoomEvent = {
|
||||||
|
type: string;
|
||||||
|
event_id: string;
|
||||||
|
room_id: string;
|
||||||
|
sender: string;
|
||||||
|
origin_server_ts: number;
|
||||||
|
content: { msgtype?: string; body?: string; [k: string]: unknown };
|
||||||
|
unsigned: Record<string, unknown>;
|
||||||
|
// `m.room.redaction` events carry `redacts` at the top level (room v < 11)
|
||||||
|
// and/or inside `content.redacts` (v11+). The host driver mirrors at both
|
||||||
|
// for forward-compat; the widget-side parser reads either.
|
||||||
|
redacts?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ToWidgetMessage = {
|
||||||
|
api: 'toWidget';
|
||||||
|
widgetId: string;
|
||||||
|
requestId: string;
|
||||||
|
action: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
// Present when this message IS a reply to a prior toWidget request.
|
||||||
|
// Per matrix-widget-api PostmessageTransport: replies preserve the original
|
||||||
|
// `api` field and add `response`. Both directions follow the same shape.
|
||||||
|
response?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FromWidgetMessage = {
|
||||||
|
api: 'fromWidget';
|
||||||
|
widgetId: string;
|
||||||
|
requestId: string;
|
||||||
|
action: string;
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
response?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Capability = string;
|
||||||
|
|
||||||
|
export type WidgetApiEvents = {
|
||||||
|
ready: () => void;
|
||||||
|
liveEvent: (ev: RoomEvent) => void;
|
||||||
|
themeChange: (name: 'light' | 'dark') => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FROM_WIDGET_REQUEST_TIMEOUT_MS = 10_000;
|
||||||
|
|
||||||
|
export class WidgetApi {
|
||||||
|
private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {};
|
||||||
|
|
||||||
|
private readonly pending = new Map<
|
||||||
|
string,
|
||||||
|
{ resolve: (v: Record<string, unknown>) => void; reject: (e: Error) => void }
|
||||||
|
>();
|
||||||
|
|
||||||
|
private requestSeq = 0;
|
||||||
|
|
||||||
|
private isReady = false;
|
||||||
|
|
||||||
|
public constructor(
|
||||||
|
private readonly bootstrap: WidgetBootstrap,
|
||||||
|
private readonly capabilities: Capability[]
|
||||||
|
) {
|
||||||
|
window.addEventListener('message', this.onMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
public dispose(): void {
|
||||||
|
window.removeEventListener('message', this.onMessage);
|
||||||
|
this.pending.forEach(({ reject }) => reject(new Error('disposed')));
|
||||||
|
this.pending.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
|
||||||
|
const list = (this.listeners[event] ??= []) as Array<WidgetApiEvents[K]>;
|
||||||
|
list.push(listener);
|
||||||
|
// `ready` is a one-shot lifecycle signal. If the handshake completed
|
||||||
|
// before this listener attached (cached-bundle race: host fires the
|
||||||
|
// capabilities request on iframe `load`, the WidgetApi catches and
|
||||||
|
// resolves it during script init, then React's useEffect runs *after*
|
||||||
|
// that and attaches the `ready` listener), replay synchronously so
|
||||||
|
// App.tsx still flips `handshakeOk` and fires the initial probe.
|
||||||
|
if (event === 'ready' && this.isReady) {
|
||||||
|
(listener as () => void)();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public sendText(body: string): Promise<{ event_id: string }> {
|
||||||
|
return this.fromWidget('send_event', {
|
||||||
|
type: 'm.room.message',
|
||||||
|
content: { msgtype: 'm.text', body },
|
||||||
|
}) as Promise<{ event_id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always prefix outbound commands with `<commandPrefix> ` (trailing space).
|
||||||
|
// Legacy mautrix-discord routes management-room commands through the
|
||||||
|
// bridge.commands.Processor in mautrix/go bridge/commands; outside the
|
||||||
|
// management room the prefix is required, inside it's optional but stays
|
||||||
|
// unambiguous when other text is present. We always send the prefix —
|
||||||
|
// works in both cases, never wrong.
|
||||||
|
public sendCommand(rawBody: string): Promise<{ event_id: string }> {
|
||||||
|
const body = `${this.bootstrap.commandPrefix} ${rawBody}`;
|
||||||
|
return this.sendText(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeline-resume probe. Action name is MSC2876 (`read_events`); the
|
||||||
|
// capability is MSC2762 timeline (already requested at construction). We
|
||||||
|
// pass `room_ids: [bootstrap.roomId]` explicitly so the host's
|
||||||
|
// ClientWidgetApi takes the modern code path that calls our driver's
|
||||||
|
// `readRoomTimeline` (single-room cap-checked) rather than the deprecated
|
||||||
|
// `readRoomEvents` fallback. Driver returns events newest-first; reversing
|
||||||
|
// to chronological order is the caller's job.
|
||||||
|
//
|
||||||
|
// `type` defaults to `m.room.message`; pass `m.room.redaction` to scan QR
|
||||||
|
// post-scan cleanup events. `msgtype` is honoured only for m.room.message.
|
||||||
|
public async readTimeline(opts: {
|
||||||
|
limit: number;
|
||||||
|
type?: 'm.room.message' | 'm.room.redaction';
|
||||||
|
msgtype?: 'm.text' | 'm.notice' | 'm.image';
|
||||||
|
}): Promise<RoomEvent[]> {
|
||||||
|
const data: Record<string, unknown> = {
|
||||||
|
type: opts.type ?? 'm.room.message',
|
||||||
|
limit: opts.limit,
|
||||||
|
room_ids: [this.bootstrap.roomId],
|
||||||
|
};
|
||||||
|
if (opts.msgtype !== undefined) data.msgtype = opts.msgtype;
|
||||||
|
const res = await this.fromWidget('org.matrix.msc2876.read_events', data);
|
||||||
|
return (res.events as RoomEvent[] | undefined) ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit<K extends keyof WidgetApiEvents>(
|
||||||
|
event: K,
|
||||||
|
...args: Parameters<WidgetApiEvents[K]>
|
||||||
|
): void {
|
||||||
|
const list = this.listeners[event] as
|
||||||
|
| Array<(...a: Parameters<WidgetApiEvents[K]>) => void>
|
||||||
|
| undefined;
|
||||||
|
list?.forEach((fn) => fn(...args));
|
||||||
|
}
|
||||||
|
|
||||||
|
private nextRequestId(): string {
|
||||||
|
this.requestSeq += 1;
|
||||||
|
return `widget-discord-${Date.now()}-${this.requestSeq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private postToHost(msg: ToWidgetMessage | FromWidgetMessage): void {
|
||||||
|
window.parent.postMessage(msg, this.bootstrap.parentOrigin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private onMessage = (ev: MessageEvent): void => {
|
||||||
|
if (ev.origin !== this.bootstrap.parentOrigin) return;
|
||||||
|
// Source-window guard — see telegram widget for full rationale.
|
||||||
|
if (ev.source !== window.parent) return;
|
||||||
|
const msg = ev.data as ToWidgetMessage | FromWidgetMessage | undefined;
|
||||||
|
if (!msg || typeof msg !== 'object') return;
|
||||||
|
if (msg.widgetId !== this.bootstrap.widgetId) return;
|
||||||
|
|
||||||
|
if (msg.api === 'toWidget') {
|
||||||
|
this.handleToWidget(msg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.api === 'fromWidget' && msg.response) {
|
||||||
|
const pending = this.pending.get(msg.requestId);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pending.delete(msg.requestId);
|
||||||
|
const err = (msg.response as { error?: { message?: string } }).error;
|
||||||
|
if (err) pending.reject(new Error(err.message ?? 'request failed'));
|
||||||
|
else pending.resolve(msg.response);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
private replyTo(msg: ToWidgetMessage, response: Record<string, unknown>): void {
|
||||||
|
this.postToHost({
|
||||||
|
api: msg.api,
|
||||||
|
widgetId: msg.widgetId,
|
||||||
|
requestId: msg.requestId,
|
||||||
|
action: msg.action,
|
||||||
|
data: msg.data,
|
||||||
|
response,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleToWidget(msg: ToWidgetMessage): void {
|
||||||
|
if (!msg.requestId || !msg.action) return;
|
||||||
|
switch (msg.action) {
|
||||||
|
case 'capabilities': {
|
||||||
|
this.replyTo(msg, { capabilities: this.capabilities });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'notify_capabilities': {
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
if (!this.isReady) {
|
||||||
|
this.isReady = true;
|
||||||
|
this.emit('ready');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'supported_api_versions': {
|
||||||
|
this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'theme_change': {
|
||||||
|
const name = (msg.data?.name as string | undefined) ?? '';
|
||||||
|
const themed: 'light' | 'dark' = name === 'dark' ? 'dark' : 'light';
|
||||||
|
this.emit('themeChange', themed);
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'send_event': {
|
||||||
|
// Live event push from host. Forward `m.room.message` (carries the
|
||||||
|
// bot's notices / errors / `m.image` QR-login broadcasts) AND
|
||||||
|
// `m.room.redaction` (post-scan QR cleanup, see BotWidgetDriver
|
||||||
|
// `sanitizeBotWidgetRedactionEvent`).
|
||||||
|
const data = msg.data as Partial<RoomEvent> | undefined;
|
||||||
|
if (
|
||||||
|
data &&
|
||||||
|
data.event_id &&
|
||||||
|
(data.type === 'm.room.message' || data.type === 'm.room.redaction')
|
||||||
|
) {
|
||||||
|
this.emit('liveEvent', data as RoomEvent);
|
||||||
|
}
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
case 'update_state': {
|
||||||
|
// Initial room state push from host (m.room.member members) — ignored.
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
// Be liberal — reply empty so the host's request promise resolves.
|
||||||
|
this.replyTo(msg, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fromWidget(
|
||||||
|
action: string,
|
||||||
|
data: Record<string, unknown>
|
||||||
|
): Promise<Record<string, unknown>> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const requestId = this.nextRequestId();
|
||||||
|
this.pending.set(requestId, { resolve, reject });
|
||||||
|
this.postToHost({
|
||||||
|
api: 'fromWidget',
|
||||||
|
widgetId: this.bootstrap.widgetId,
|
||||||
|
requestId,
|
||||||
|
action,
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
window.setTimeout(() => {
|
||||||
|
if (this.pending.has(requestId)) {
|
||||||
|
this.pending.delete(requestId);
|
||||||
|
reject(new Error(`${action} timed out after ${FROM_WIDGET_REQUEST_TIMEOUT_MS}ms`));
|
||||||
|
}
|
||||||
|
}, FROM_WIDGET_REQUEST_TIMEOUT_MS);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capability set must match the host's BotWidgetDriver.getBotWidgetCapabilities.
|
||||||
|
// The driver is bot-agnostic — the same allowlist is applied for telegram
|
||||||
|
// and discord. Discord-specific additions would have to land in
|
||||||
|
// BotWidgetDriver first.
|
||||||
|
//
|
||||||
|
// `m.image` carries the QR login URL in `content.body` (the host sanitizer
|
||||||
|
// strips `url` / `file` / `info`, so only the URL string survives); we
|
||||||
|
// render the QR client-side from that URL via `qrcode-generator`.
|
||||||
|
// `m.room.redaction` is how the bridge signals «QR consumed by a successful
|
||||||
|
// scan» — see mautrix-discord/commands.go::fnLoginQR which redacts the QR
|
||||||
|
// event after the remoteauth websocket completes.
|
||||||
|
export const buildCapabilities = (roomId: string): Capability[] => [
|
||||||
|
`org.matrix.msc2762.timeline:${roomId}`,
|
||||||
|
'org.matrix.msc2762.send.event:m.room.message#m.text',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.message#m.text',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.message#m.notice',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.message#m.image',
|
||||||
|
'org.matrix.msc2762.receive.event:m.room.redaction',
|
||||||
|
'org.matrix.msc2762.receive.state_event:m.room.member',
|
||||||
|
];
|
||||||
21
apps/widget-discord/tsconfig.json
Normal file
21
apps/widget-discord/tsconfig.json
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"allowSyntheticDefaultImports": true,
|
||||||
|
"useDefineForClassFields": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
27
apps/widget-discord/vite.config.ts
Normal file
27
apps/widget-discord/vite.config.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
|
||||||
|
// Build artefact lives at apps/widget-discord/dist/. The deploy step (out
|
||||||
|
// of repo) rsyncs this into ~/vojo/widgets/discord/ on the server, which
|
||||||
|
// Caddy serves from /var/www/widgets/discord via the widgets.vojo.chat
|
||||||
|
// block — same shape as the Telegram widget, different sub-path.
|
||||||
|
//
|
||||||
|
// `base: './'` keeps every generated asset path relative so the same
|
||||||
|
// build can sit under /discord/ on widgets.vojo.chat without rewrites.
|
||||||
|
export default defineConfig({
|
||||||
|
base: './',
|
||||||
|
plugins: [preact()],
|
||||||
|
build: {
|
||||||
|
target: 'es2020',
|
||||||
|
sourcemap: true,
|
||||||
|
// Inline CSS for a single round-trip; the widget is small and the
|
||||||
|
// host's iframe handshake budget is already tight (10s default).
|
||||||
|
cssCodeSplit: false,
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
// Port 8082 — telegram widget owns 8081, host SPA owns 8080.
|
||||||
|
// Both widget dev servers can run side by side without conflict.
|
||||||
|
port: 8082,
|
||||||
|
host: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
10
config.json
10
config.json
|
|
@ -23,6 +23,16 @@
|
||||||
"type": "matrix-widget",
|
"type": "matrix-widget",
|
||||||
"url": "https://widgets.vojo.chat/telegram/index.html"
|
"url": "https://widgets.vojo.chat/telegram/index.html"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "discord",
|
||||||
|
"mxid": "@discordbot:vojo.chat",
|
||||||
|
"name": "Discord",
|
||||||
|
"experience": {
|
||||||
|
"type": "matrix-widget",
|
||||||
|
"url": "https://widgets.vojo.chat/discord/index.html",
|
||||||
|
"commandPrefix": "!discord"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"push": {
|
"push": {
|
||||||
|
|
|
||||||
|
|
@ -897,10 +897,12 @@
|
||||||
"retry_widget": "Retry robot",
|
"retry_widget": "Retry robot",
|
||||||
"more_options": "More",
|
"more_options": "More",
|
||||||
"description": {
|
"description": {
|
||||||
"telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages."
|
"telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages.",
|
||||||
|
"discord": "Connect Discord to Vojo: DMs and servers appear in the chat list, and replies from the Vojo app are sent as normal Discord messages. Sign-in uses a QR code from the Discord mobile app."
|
||||||
},
|
},
|
||||||
"description_short": {
|
"description_short": {
|
||||||
"telegram": "Telegram chat connection"
|
"telegram": "Telegram chat connection",
|
||||||
|
"discord": "Discord chat connection"
|
||||||
},
|
},
|
||||||
"unknown_title": "Robot not found",
|
"unknown_title": "Robot not found",
|
||||||
"unknown_description": "This robot is not in the Vojo catalog."
|
"unknown_description": "This robot is not in the Vojo catalog."
|
||||||
|
|
|
||||||
|
|
@ -901,10 +901,12 @@
|
||||||
"retry_widget": "Повторить",
|
"retry_widget": "Повторить",
|
||||||
"more_options": "Ещё",
|
"more_options": "Ещё",
|
||||||
"description": {
|
"description": {
|
||||||
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения."
|
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.",
|
||||||
|
"discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord."
|
||||||
},
|
},
|
||||||
"description_short": {
|
"description_short": {
|
||||||
"telegram": "Подключение чатов Telegram"
|
"telegram": "Подключение чатов Telegram",
|
||||||
|
"discord": "Подключение чатов Discord"
|
||||||
},
|
},
|
||||||
"unknown_title": "Робот не найден",
|
"unknown_title": "Робот не найден",
|
||||||
"unknown_description": "Этого робота нет в каталоге Vojo."
|
"unknown_description": "Этого робота нет в каталоге Vojo."
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue