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 = () => ( ); // Inline SVG info icon — leads the AboutCard. Shared shape with the // Telegram widget. WhatsApp uses a triangle warning glyph instead // because its About modal also carries a Meta-ToS risk disclosure. const InfoIcon = () => ( ); // Three QR finder squares + a few module dots — leads the QR-login // card. Same shape across all three bot widgets. const QrIcon = () => ( ); // Sign-out arrow leaving an open box — leads the destructive logout // card. Open right side conveys «out of the session». Stays muted // inside `.command-card.danger` so the rose accent remains a single // accent on the title. const LogoutIcon = () => ( ); // Two chain links joined by a horizontal bar — leads the Discord- // only Reconnect card. Visually distinct from RefreshIcon's circular // arrows so the user can tell «re-establish bridge link» from // «re-fetch status» at a glance. const LinkIcon = () => ( ); // 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( {match[0]} ); 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 'captcha_send_failed': return t('auth-error.captcha-send-failed'); case 'captcha_expired': return t('auth-error.captcha-expired'); 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 === 'captcha_expired') 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 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( ); } } return ( {rects} ); }; 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 (
{t('auth-card.qr.title')}
{t('auth-card.qr.hint')}
{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. ) : (
{t('auth-card.qr.preparing')}
)}
{!expired ? (
{t('auth-card.qr.countdown', { minutes: String(Math.floor(remainingSeconds / 60)), seconds: String(remainingSeconds % 60).padStart(2, '0'), })}
) : (
{t('auth-card.qr.expired')}
)}
  1. {t('auth-card.qr.step-1')}
  2. {t('auth-card.qr.step-2')}
  3. {t('auth-card.qr.step-3')}
{state.lastError ? (
{localizeError(state.lastError, t)}
) : null}
); }; // -------------------------------------------------------------------------- // Captcha panel — Vojo-patched bridge surfaces an hCaptcha challenge via // the `VOJO-CAPTCHA-CHALLENGE-V1` notice. The panel lazy-loads hCaptcha's // own JS, renders the challenge widget into our container with the supplied // sitekey + rqdata (the latter is REQUIRED for hCaptcha-enterprise — Discord // uses the enterprise tier), and on solve hands the token back to the App // which sends `login-captcha ` to the bridge. // -------------------------------------------------------------------------- // Canonical hCaptcha bootstrapping for explicit render: combine // `?render=explicit` (don't auto-render any data-sitekey div) with // `?onload=fnname` (call our resolver when the SDK is fully initialized). // docs.hcaptcha.com/configuration recommends this over polling // `window.hcaptcha` because (a) the global appears before its methods are // safe to call in some SDK versions, (b) onload fires once and is cheaper // than a 50ms tick. We still keep a wall-clock timeout as a backstop — // covers ad-block / CSP / third-party-cookie blocks where the script // itself fails to load. const HCAPTCHA_ONLOAD_FN = '__vojoHCaptchaOnload'; const HCAPTCHA_API_URL = `https://js.hcaptcha.com/1/api.js?render=explicit&onload=${HCAPTCHA_ONLOAD_FN}`; const HCAPTCHA_LOAD_TIMEOUT_MS = 15_000; // hCaptcha render() does NOT accept rqdata as a config field — for // hCaptcha-enterprise (which Discord uses) rqdata must be applied via // `setData(widgetId, { rqdata })` AFTER `render()`. Passing rqdata to // render is silently ignored, the resulting token isn't bound to the // challenge, and Discord rejects with `invalid-response` / // `sitekey-secret-mismatch`. Sources: // https://github.com/hCaptcha/vue-hcaptcha (search `setData(`) // https://github.com/hCaptcha/react-hcaptcha/issues/190 type HCaptchaConfig = { sitekey: string; callback?: (token: string) => void; 'expired-callback'?: () => void; 'error-callback'?: () => void; size?: 'normal' | 'compact' | 'invisible'; theme?: 'light' | 'dark'; }; type HCaptchaApi = { render: (container: HTMLElement, config: HCaptchaConfig) => string; setData: (widgetId: string, data: { rqdata: string }) => void; reset: (id?: string) => void; remove: (id: string) => void; }; declare global { interface Window { hcaptcha?: HCaptchaApi; [HCAPTCHA_ONLOAD_FN]?: () => void; } } let hcaptchaScriptPromise: Promise | null = null; const loadHCaptcha = (): Promise => { if (typeof window === 'undefined') return Promise.reject(new Error('no window')); // Already initialised (e.g. earlier mount of the captcha panel) — skip // the script dance entirely. if (window.hcaptcha) return Promise.resolve(window.hcaptcha); if (hcaptchaScriptPromise) return hcaptchaScriptPromise; hcaptchaScriptPromise = new Promise((resolve, reject) => { const existing = document.querySelector( `script[src^="https://js.hcaptcha.com/1/api.js"]` ) as HTMLScriptElement | null; let timeoutHandle: number | undefined; let settled = false; const settle = (action: () => void) => { if (settled) return; settled = true; if (timeoutHandle !== undefined) window.clearTimeout(timeoutHandle); // Drop the global — leaves `window` clean if no other consumer // is hooked. Future `loadHCaptcha()` calls hit the early-return // `if (window.hcaptcha)` and skip this whole path. delete window[HCAPTCHA_ONLOAD_FN]; action(); }; // `?onload=` calls our global once the SDK finishes initialising. // We register BEFORE appending the script so the listener is in // place if the script is already cached and runs synchronously. window[HCAPTCHA_ONLOAD_FN] = () => { if (window.hcaptcha) { settle(() => resolve(window.hcaptcha as HCaptchaApi)); } else { // Defence-in-depth: onload fired but global missing. Treat as // a load failure and let the panel show the error UI. settle(() => { hcaptchaScriptPromise = null; reject(new Error('hcaptcha onload fired without window.hcaptcha')); }); } }; if (!existing) { const script = document.createElement('script'); script.src = HCAPTCHA_API_URL; script.async = true; script.defer = true; script.addEventListener('error', () => settle(() => { hcaptchaScriptPromise = null; reject(new Error('hcaptcha script failed to load')); }) ); document.head.appendChild(script); } else if (window.hcaptcha) { // A previous mount already loaded the SDK fully; resolve immediately. settle(() => resolve(window.hcaptcha as HCaptchaApi)); return; } // Otherwise: existing script is mid-load — our onload hook will fire // when it finishes. timeoutHandle = window.setTimeout(() => { settle(() => { hcaptchaScriptPromise = null; reject(new Error('hcaptcha script load timed out')); }); }, HCAPTCHA_LOAD_TIMEOUT_MS); }); return hcaptchaScriptPromise; }; type CaptchaPanelProps = { state: { kind: 'awaiting_captcha_solve'; service: string; sitekey: string; rqdata: string; rqtoken: string; }; t: T; onSolved: (token: string) => void; onCancel: () => void; // Fired when the hCaptcha challenge expires (either Discord's // server-side rqtoken TTL or our local 90s timer beats the user). The // App rolls back to disconnected with a localized warn so the user // retries login-qr instead of trying to solve a dead challenge. onExpired: () => void; }; // Match the bridge's CAPTCHA_HYDRATE_FRESHNESS_MS — Discord invalidates // rqtoken around the 2-minute mark; we expire the UI a little earlier so // the user retries fresh instead of getting a server-side rejection. const CAPTCHA_EXPIRY_MS = 90 * 1000; const CaptchaPanel = ({ state, t, onSolved, onCancel, onExpired }: CaptchaPanelProps) => { const containerRef = useRef(null); const widgetIdRef = useRef(null); const [loadError, setLoadError] = useState(false); // Set true once we've finished render + setData — gate the solve // callback on this so a hCaptcha auto-pass (low-friction users) or a // fast click before setData lands can't ship a token unbound to rqdata. // Discord rejects unbound tokens with `invalid-response`, burning the // rqtoken and forcing a fresh challenge. const dataReadyRef = useRef(false); // Idempotency latch on the solve callback — guards against hCaptcha // firing the callback twice (observed when users double-click "I am // human" or when low-risk users get the silent auto-pass that some // SDK versions deliver via two events). Without this, two // login-captcha commands ship to the bridge with the same single-use // token and the second one tears down the session via // `response-already-used`. const solvedRef = useRef(false); useEffect(() => { let cancelled = false; dataReadyRef.current = false; solvedRef.current = false; setLoadError(false); // 90-second client-side expiry timer. Drives the localized // "captcha_expired" error rather than letting the user solve a // dead rqtoken and watch the bridge return a generic Discord // failure. const expiryTimer = window.setTimeout(() => { if (!cancelled && !solvedRef.current) onExpired(); }, CAPTCHA_EXPIRY_MS); // Pass the host theme into hCaptcha so the chrome inside the iframe // matches Vojo's surface — the host writes `data-theme` on // at boot from the `?theme=` URL param. Without this the // hCaptcha widget paints its default light variant inside our // Dawn-dark card and reads as a foreign white box. const hcaptchaTheme: 'light' | 'dark' = document.documentElement.dataset.theme === 'light' ? 'light' : 'dark'; loadHCaptcha() .then((api) => { if (cancelled || !containerRef.current) return; const id = api.render(containerRef.current, { sitekey: state.sitekey, theme: hcaptchaTheme, size: 'normal', callback: (token) => { // Gate on dataReadyRef AND solvedRef. dataReadyRef rejects a // token before setData bound rqdata; solvedRef rejects // duplicate callbacks for the same challenge. if (cancelled || !dataReadyRef.current || solvedRef.current) return; solvedRef.current = true; onSolved(token); }, 'expired-callback': () => { if (cancelled || solvedRef.current) return; onExpired(); }, 'error-callback': () => { if (!cancelled) setLoadError(true); }, }); // Race guard: cancel may have flipped between the await and // render. If so, the iframe is mounted into a detached node — clean // up immediately rather than leaking the widget id. if (cancelled) { try { api.remove(id); } catch { /* ignore */ } return; } widgetIdRef.current = id; // Bind the solve to Discord's challenge nonce. hCaptcha-enterprise // (which Discord uses) requires setData AFTER render — passing // rqdata into render() is silently ignored. try { api.setData(id, { rqdata: state.rqdata }); dataReadyRef.current = true; } catch (err) { // setData failed (unknown widget id, SDK bug, etc.) — tear the // widget down so a stray callback can't ship an unbound token. // Surface the load-error UI; the user can retry login-qr. try { api.remove(id); } catch { /* ignore */ } widgetIdRef.current = null; if (!cancelled) setLoadError(true); // eslint-disable-next-line no-console console.warn('[captcha] hcaptcha.setData failed', err); } }) .catch(() => { if (!cancelled) setLoadError(true); }); return () => { cancelled = true; window.clearTimeout(expiryTimer); // Best-effort cleanup. hCaptcha's own remove() is idempotent and // tolerates missing widget ids; if the iframe was never rendered // (load failure) the catch is harmless. try { if (window.hcaptcha && widgetIdRef.current) { window.hcaptcha.remove(widgetIdRef.current); } } catch { /* ignore */ } widgetIdRef.current = null; }; // rqtoken is the per-challenge nonce — chained captchas reuse the same // sitekey/rqdata-shape but bring a new rqtoken; we want a remount on // any of these for safety. The whole CaptchaSolveState normally // replaces wholesale, but listing all three guards against future // partial mutations. // eslint-disable-next-line react-hooks/exhaustive-deps }, [state.sitekey, state.rqdata, state.rqtoken]); return (
{t('auth-card.captcha.title')}
{t('auth-card.captcha.hint')}
{loadError ? (
{t('auth-card.captcha.load-error')}
) : null}
); }; // -------------------------------------------------------------------------- // About card + modal // -------------------------------------------------------------------------- type AboutCardProps = { t: T; onOpen: () => void; }; const AboutCard = ({ t, onOpen }: AboutCardProps) => ( ); 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 ( ); }; // -------------------------------------------------------------------------- // Logout card with confirm-in-place // -------------------------------------------------------------------------- type LogoutCardProps = { t: T; onConfirm: () => Promise; }; 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 (
{t('card.logout.confirm-prompt')}
); } return ( ); }; // -------------------------------------------------------------------------- // Main App // -------------------------------------------------------------------------- export function App({ bootstrap, api }: Props) { const [theme, setTheme] = useState<'light' | 'dark'>(bootstrap.theme); const [transcript, setTranscript] = useState([]); 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()); 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]); // Capture-phase click interceptor for `` — // inside Capacitor's Android WebView, cross-origin iframes silently // drop those clicks (the WebView has no multi-window concept and // the host's setupExternalLinkHandler can't see them across the // origin boundary). We preventDefault and ask the host to open the // URL via Browser.open / window.open. Modifier-clicks pass through // untouched so users can still Ctrl/Cmd-click for new-tab on web. useEffect(() => { const onClick = (e: MouseEvent) => { const anchor = (e.target as HTMLElement | null)?.closest?.( 'a[target="_blank"]' ) as HTMLAnchorElement | null; if (!anchor?.href) return; if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; e.preventDefault(); api.openExternalUrl(anchor.href); }; document.addEventListener('click', onClick, true); return () => document.removeEventListener('click', onClick, true); }, [api]); const append = useCallback((line: Omit) => { 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(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[] => 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/ 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(); 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 (parsed.kind === 'captcha_challenge') { // Captcha challenge body carries hCaptcha rqdata/rqtoken // — short-lived but credential-bearing. Diag-only on // hydrate too; the live path treats it identically. append({ kind: 'diag', text: t('diag.captcha-issued') }); 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 (event.kind === 'captcha_challenge') { // The raw body carries Discord's hCaptcha sitekey + rqdata + rqtoken // — short-lived but credential-bearing. Don't echo into the // transcript DOM (where screenshots / accessibility tools could // leak them). Diag-only display. append({ kind: 'diag', text: t('diag.captcha-issued') }); } 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 => { 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] ); // Specialised send for hCaptcha tokens. The token is a single-use // credential — we send it to the bridge but redact the body in the // user-visible transcript so it doesn't sit in DOM (and screenshots / // accessibility tools can't see it). The error path keeps the same // shape as `sendBare` so the caller can `try { await … } catch { roll back }`. const sendCaptchaToken = useCallback( async (token: string): Promise => { append({ kind: 'from-user', text: '→ login-captcha [redacted]' }); try { await api.sendCommand(`login-captcha ${token}`); } 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' }); }, []); // Captcha solve callback — fires from hCaptcha's own iframe. We dispatch // `submit_captcha_token` first (transition to qr_verifying immediately so // the user sees a spinner), then send `login-captcha ` to the // bridge. The bridge re-fires Discord's RemoteAuthLogin with the // X-Captcha-Key header attached and replies via the live event stream. const onCaptchaSolved = useCallback( async (token: string) => { dispatch({ kind: 'submit_captcha_token' }); try { await sendCaptchaToken(token); } catch { // Couldn't deliver to the bridge — roll the optimistic // qr_verifying back to disconnected with a localized error. // Don't auto-fall-through to ping: the failure is local // (transport), not a bridge state mismatch, and a stale ping // would mask it. dispatch({ kind: 'captcha_send_failed' }); } }, [sendCaptchaToken] ); // hCaptcha challenge expired (server-side rqtoken TTL or 90s local // timer). Tell the user with a localized warn rather than letting them // solve a dead challenge. const onCaptchaExpired = useCallback(() => { dispatch({ kind: 'captcha_expired' }); }, []); 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((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 = ( {label} ); if (!recovery) return pill; return (
{pill}
); }; return (
{/* 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' ? (
) : null} {handshakeOk && state.kind === 'disconnected' ? (
{state.lastError ? (
{localizeError(state.lastError, t)}
) : null}
setAboutOpen(true)} />
) : null} {state.kind === 'awaiting_qr_scan' ? (
) : null} {state.kind === 'awaiting_captcha_solve' ? (
) : null} {state.kind === 'qr_verifying' ? (
) : null} {state.kind === 'logging_out' ? (
) : null} {state.kind === 'reconnecting' ? (
) : null} {state.kind === 'connected' ? (
setAboutOpen(true)} />
) : null} {state.kind === 'connected_dead' ? (
{/* Reconnect — primary action for this state. The button uses * the same command-card chrome so it visually matches Login / * Logout cards. */} setAboutOpen(true)} />
) : null} {aboutOpen ? setAboutOpen(false)} /> : null}
{transcript.length === 0 ? (
{/* placeholder */}
) : ( transcript .slice() .reverse() .map((line) => (
{formatTime(line.ts)} {line.kind === 'from-bot' ? renderBody(line.text) : line.text}
)) )}
); }