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:
v.lagerev 2026-05-05 02:17:30 +03:00
parent aaae635bf2
commit bd6bcd7d1c
23 changed files with 6022 additions and 4 deletions

3
apps/widget-discord/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules
dist
*.log

View 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.

View 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

File diff suppressed because it is too large Load diff

View 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"
}
}

View 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>
);
}

View 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'),
},
};
};

View 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);
}

View 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);

View 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' };

View 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 Vojos 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 Discords “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 bots 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}.',
};

View 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 };

View 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;

View 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);
}

View 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);
}

View 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
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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',
];

View 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"]
}

View 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,
},
});

View file

@ -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": {

View file

@ -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."

View file

@ -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."