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
aaae635bf2
commit
bd6bcd7d1c
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",
|
||||
"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": {
|
||||
|
|
|
|||
|
|
@ -897,10 +897,12 @@
|
|||
"retry_widget": "Retry robot",
|
||||
"more_options": "More",
|
||||
"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": {
|
||||
"telegram": "Telegram chat connection"
|
||||
"telegram": "Telegram chat connection",
|
||||
"discord": "Discord chat connection"
|
||||
},
|
||||
"unknown_title": "Robot not found",
|
||||
"unknown_description": "This robot is not in the Vojo catalog."
|
||||
|
|
|
|||
|
|
@ -901,10 +901,12 @@
|
|||
"retry_widget": "Повторить",
|
||||
"more_options": "Ещё",
|
||||
"description": {
|
||||
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения."
|
||||
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.",
|
||||
"discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord."
|
||||
},
|
||||
"description_short": {
|
||||
"telegram": "Подключение чатов Telegram"
|
||||
"telegram": "Подключение чатов Telegram",
|
||||
"discord": "Подключение чатов Discord"
|
||||
},
|
||||
"unknown_title": "Робот не найден",
|
||||
"unknown_description": "Этого робота нет в каталоге Vojo."
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue