From 1d64275bae3c75bad078cd335f9af9454675e387 Mon Sep 17 00:00:00 2001 From: heaven Date: Wed, 6 May 2026 14:19:45 +0300 Subject: [PATCH] feat(bots-discord): land hCaptcha challenge handling for QR-login with sentinel-prefixed bridge protocol and Dawn-themed widget UI --- apps/widget-discord/src/App.tsx | 367 ++++++++++++++++++ .../bridge-protocol/dialects/legacy_v076.ts | 67 +++- .../src/bridge-protocol/types.ts | 23 +- apps/widget-discord/src/i18n/en.ts | 10 + apps/widget-discord/src/i18n/ru.ts | 16 + apps/widget-discord/src/state.ts | 187 ++++++++- apps/widget-discord/src/styles.css | 28 ++ capacitor.config.ts | 16 +- 8 files changed, 680 insertions(+), 34 deletions(-) diff --git a/apps/widget-discord/src/App.tsx b/apps/widget-discord/src/App.tsx index 68924b16..55ee2258 100644 --- a/apps/widget-discord/src/App.tsx +++ b/apps/widget-discord/src/App.tsx @@ -144,6 +144,10 @@ const localizeError = (err: LoginErrorFlag, t: T): string => { 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': @@ -166,6 +170,7 @@ const localizeError = (err: LoginErrorFlag, t: T): string => { // 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'; }; @@ -319,6 +324,294 @@ const QrPanel = ({ state, t, onCancel }: QrPanelProps) => { ); }; +// -------------------------------------------------------------------------- +// 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 // -------------------------------------------------------------------------- @@ -628,6 +921,12 @@ export function App({ bootstrap, api }: Props) { 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 @@ -696,6 +995,12 @@ export function App({ bootstrap, api }: Props) { ) { 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)}` }); @@ -759,6 +1064,27 @@ export function App({ bootstrap, api }: Props) { [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 @@ -788,6 +1114,35 @@ export function App({ bootstrap, api }: Props) { 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); @@ -945,6 +1300,18 @@ export function App({ bootstrap, api }: Props) { ) : null} + {state.kind === 'awaiting_captcha_solve' ? ( +
+ +
+ ) : null} + {state.kind === 'qr_verifying' ? (
{ } // Login lifecycle. + // Vojo-patched bridge: structured hCaptcha challenge wins over every + // legacy regex — checked first so the JSON payload survives. + if (body.startsWith(CAPTCHA_CHALLENGE_PREFIX)) { + const match = CAPTCHA_CHALLENGE_RE.exec(body); + if (match) { + try { + const payload = JSON.parse(match[1]) as Record; + const service = typeof payload.service === 'string' ? payload.service : ''; + const sitekey = typeof payload.sitekey === 'string' ? payload.sitekey : ''; + const sessionId = typeof payload.session_id === 'string' ? payload.session_id : ''; + const rqdata = typeof payload.rqdata === 'string' ? payload.rqdata : ''; + const rqtoken = typeof payload.rqtoken === 'string' ? payload.rqtoken : ''; + if (sitekey && rqtoken) { + return { kind: 'captcha_challenge', service, sitekey, sessionId, rqdata, rqtoken }; + } + } catch { + // fall through — malformed payload is treated as unknown + } + } + return { kind: 'unknown' }; + } if (CAPTCHA_REQUIRED_RE.test(body)) return { kind: 'captcha_required' }; const loginWsMatch = LOGIN_WEBSOCKET_FAILED_RE.exec(body); @@ -313,13 +344,33 @@ function runSanityChecks(): void { '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. + // CAPTCHA legacy fallback — pre-empts LOGIN_FAILED_RE. Fires only on + // unpatched upstream v0.7.6. [ 'Error logging in: captcha-required 400\n\nCAPTCHAs are currently not supported - use token login instead', { kind: 'captcha_required' }, ], + // Vojo-patched bridge — structured hCaptcha challenge. Sentinel prefix + // is checked before any regex so the JSON body is never misclassified. + // Use a realistic-shape rqtoken (JWT-style segments separated by `.`, + // base64url payload with `_`/`-`/`=`) so a regression where the regex + // accidentally trips on those characters is caught in CI. + [ + 'VOJO-CAPTCHA-CHALLENGE-V1 {"service":"hcaptcha","sitekey":"a9b5fb07-92ff-493f-86fe-352a2803b3df","session_id":"e971514e-4a6e-4a45-a869-01e61421327c","rqdata":"fgpS6hRTe96TX5qXD7QZgLQwgbQal50jmYsSPyZHqdY+UdfpECcH9gAZESHuGwi0k3n2aCUDs/32bITzKBYGSYjRUbKJqsN0gSo3JHr7SzyPB4bMLcwkOT15yro6f2ax","rqtoken":"IlRGQ3BWQVdGLzJhZUxHMDUrWkV3OE9TNjF6MjNCOS9zOWx2Nk1idzBOdlVvTy9abmZqUnZoZDNnZ2lBUm80Ull1NVRPL0E9PXhFTVRpOW5QeXFmaGF1MFEi.afpL4w.vi8MtSRKgUhesyHDNy4uWwpft1A"}', + { + kind: 'captcha_challenge', + service: 'hcaptcha', + sitekey: 'a9b5fb07-92ff-493f-86fe-352a2803b3df', + sessionId: 'e971514e-4a6e-4a45-a869-01e61421327c', + rqdata: + 'fgpS6hRTe96TX5qXD7QZgLQwgbQal50jmYsSPyZHqdY+UdfpECcH9gAZESHuGwi0k3n2aCUDs/32bITzKBYGSYjRUbKJqsN0gSo3JHr7SzyPB4bMLcwkOT15yro6f2ax', + rqtoken: + 'IlRGQ3BWQVdGLzJhZUxHMDUrWkV3OE9TNjF6MjNCOS9zOWx2Nk1idzBOdlVvTy9abmZqUnZoZDNnZ2lBUm80Ull1NVRPL0E9PXhFTVRpOW5QeXFmaGF1MFEi.afpL4w.vi8MtSRKgUhesyHDNy4uWwpft1A', + }, + ], + // Malformed challenge JSON falls through to `unknown` so a corrupted + // bridge build doesn't crash the parser. + ['VOJO-CAPTCHA-CHALLENGE-V1 {not-json', { kind: 'unknown' }], [ 'Error connecting to login websocket: dial tcp i/o timeout', { kind: 'login_websocket_failed', reason: 'dial tcp i/o timeout' }, diff --git a/apps/widget-discord/src/bridge-protocol/types.ts b/apps/widget-discord/src/bridge-protocol/types.ts index 2b7ffbc0..afa780a9 100644 --- a/apps/widget-discord/src/bridge-protocol/types.ts +++ b/apps/widget-discord/src/bridge-protocol/types.ts @@ -61,12 +61,23 @@ export type LoginEvent = // 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. + // Vojo-patched bridge replies with a sentinel-prefixed JSON line + // («VOJO-CAPTCHA-CHALLENGE-V1 {...}») when Discord demands an hCaptcha + // before completing remote-auth. The widget renders a hCaptcha iframe + // with the supplied sitekey + rqdata, then sends the solved token back + // via ` login-captcha ` to retry the login. + | { + kind: 'captcha_challenge'; + service: string; + sitekey: string; + sessionId: string; + rqdata: string; + rqtoken: string; + } + // Legacy «CAPTCHAs are currently not supported - use token login instead» + // path — only fires against an UNPATCHED upstream v0.7.6 bridge. Kept so + // a deployment that forgets to ship the Vojo bridge image still surfaces + // a useful hint instead of a generic Go-error 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 diff --git a/apps/widget-discord/src/i18n/en.ts b/apps/widget-discord/src/i18n/en.ts index ac402b9f..5e742925 100644 --- a/apps/widget-discord/src/i18n/en.ts +++ b/apps/widget-discord/src/i18n/en.ts @@ -44,10 +44,19 @@ export const EN: Record = { 'auth-card.qr.step-1': 'Open the Discord mobile app.', 'auth-card.qr.step-2': 'Open Settings → Scan QR Code.', 'auth-card.qr.step-3': 'Scan the QR and confirm sign-in on your phone.', + 'auth-card.captcha.title': 'Confirm you’re not a robot', + 'auth-card.captcha.hint': + 'Discord asked for a CAPTCHA. Solve it below — sign-in will continue automatically once you’re done.', + 'auth-card.captcha.load-error': + 'Could not load the CAPTCHA. Check your network, tap Cancel and try signing in again.', 'auth-card.cancel': 'Cancel', 'auth-card.waiting-hint': 'The bot is still thinking… replies may take up to 30 seconds.', 'auth-error.captcha-required': 'Discord requested a CAPTCHA — QR sign-in is temporarily unavailable. Try again later, or sign in with a token via the bot’s chat.', + 'auth-error.captcha-send-failed': + 'Could not deliver your CAPTCHA solution. Check your network and try signing in again.', + 'auth-error.captcha-expired': + 'CAPTCHA expired — tap «Sign in with QR code» and solve it again.', '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}', @@ -72,6 +81,7 @@ export const EN: Record = { '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.', + 'diag.captcha-issued': 'Discord requested a CAPTCHA — solve it in the form above.', '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}.', diff --git a/apps/widget-discord/src/i18n/ru.ts b/apps/widget-discord/src/i18n/ru.ts index 2e4a4467..7e770c21 100644 --- a/apps/widget-discord/src/i18n/ru.ts +++ b/apps/widget-discord/src/i18n/ru.ts @@ -62,6 +62,17 @@ export const RU = { 'auth-card.qr.step-1': 'Откройте мобильное приложение Discord.', 'auth-card.qr.step-2': 'Откройте «Настройки → Сканировать QR-код».', 'auth-card.qr.step-3': 'Отсканируйте QR-код и подтвердите вход на телефоне.', + // --- hCaptcha challenge ----------------------------------------------- + // Discord иногда требует решить hCaptcha перед завершением remoteauth — + // anti-abuse-сигнал, не зависящий от конкретного юзера. Vojo-патч + // отдаёт челлендж сюда в виджет; пользователь решает, токен уходит + // обратно на мост и логин завершается. Текст не упоминает «бан»: это + // обычный механизм Discord, а не санкция. + 'auth-card.captcha.title': 'Подтвердите, что вы не робот', + 'auth-card.captcha.hint': + 'Discord попросил решить капчу. Решите её ниже — после этого вход продолжится автоматически.', + 'auth-card.captcha.load-error': + 'Не удалось загрузить капчу. Проверьте сеть и нажмите «Отмена», затем войдите снова.', // --- Shared form chrome ------------------------------------------------ // Cancel в Discord-flow ЛОКАЛЬНЫЙ: legacy-мост не имеет команды отмены // активного login-qr, поэтому кнопка просто возвращает виджет в @@ -73,6 +84,10 @@ export const RU = { // --- Inline errors ----------------------------------------------------- 'auth-error.captcha-required': 'Discord потребовал CAPTCHA — вход через QR временно недоступен. Попробуйте позже или войдите через токен в чате с ботом.', + 'auth-error.captcha-send-failed': + 'Не удалось отправить ответ на CAPTCHA. Проверьте сеть и попробуйте войти заново.', + 'auth-error.captcha-expired': + 'CAPTCHA устарела — нажмите «Войти по QR-коду» и решите её заново.', 'auth-error.login-failed': 'Не удалось войти: {reason}', 'auth-error.prepare-failed': 'Не удалось подготовить вход: {reason}', 'auth-error.websocket-failed': 'Не удалось подключиться к серверу входа: {reason}', @@ -104,6 +119,7 @@ export const RU = { // Поэтому в логе только нейтральные диагностические строки. 'diag.qr-issued': 'QR-код выдан.', 'diag.qr-consumed': 'QR-код использован — мост подтверждает скан.', + 'diag.captcha-issued': 'Discord прислал CAPTCHA — решите её на форме выше.', // --- Bootstrap failure ------------------------------------------------- 'bootstrap.failed': 'Widget не запустился', 'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.', diff --git a/apps/widget-discord/src/state.ts b/apps/widget-discord/src/state.ts index ecf3ea64..a1c6344d 100644 --- a/apps/widget-discord/src/state.ts +++ b/apps/widget-discord/src/state.ts @@ -24,6 +24,17 @@ import type { LoginEvent } from './bridge-protocol/types'; export type LoginErrorFlag = | { kind: 'login_failed'; reason?: string } | { kind: 'captcha_required' } + // Local rollback flag — fired when the widget couldn't deliver + // `login-captcha ` to the bridge (transport / Matrix-API failure + // before the bridge even saw the command). Distinct from + // `login_failed` (which IS a bridge reply) so the UX can read «sign-in + // didn't reach the bot, retry» instead of hinting at a Discord-side + // problem the user can't act on. + | { kind: 'captcha_send_failed' } + // The captcha challenge timed out (rqtoken expiry on Discord's side) + // before the user solved it. Surfaced as a soft warning to retry + // login-qr from scratch. + | { kind: 'captcha_expired' } | { kind: 'login_websocket_failed'; reason?: string } | { kind: 'connect_after_login_failed'; reason?: string } | { kind: 'prepare_login_failed'; reason?: string } @@ -47,6 +58,20 @@ export type PendingFormState = { lastError?: LoginErrorFlag; }; +// hCaptcha challenge surfaced by the Vojo-patched bridge after Discord +// returned 400+captcha-required. The widget renders the hCaptcha iframe +// from `sitekey` + `rqdata`; on solve, the App sends `login-captcha +// ` and we transition to `qr_verifying` until the bridge replies. +export type CaptchaSolveState = { + kind: 'awaiting_captcha_solve'; + service: string; + sitekey: string; + sessionId: string; + rqdata: string; + rqtoken: string; + firstShownAt: number; +}; + export type LoginState = // Pre-handshake / pre-ping. Status pill: --faint. | { kind: 'unknown' } @@ -57,6 +82,11 @@ export type LoginState = // overwritten with real discordUrl/qrEventId by the live `qr_displayed` // event. Status pill: --amber. | PendingFormState + // hCaptcha challenge from Discord — Vojo-patched bridge surfaced the + // sitekey + rqdata via `VOJO-CAPTCHA-CHALLENGE-V1` notice. The widget + // renders the hCaptcha iframe; on solve we send `login-captcha ` + // and transition to `qr_verifying`. Status pill: --amber. + | CaptchaSolveState // 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. @@ -85,12 +115,14 @@ export type LoginState = // 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. +// timeline; `qr_verifying` covers the post-scan pre-success interstitial; +// `awaiting_captcha_solve` covers the case where the user reloads while +// staring at an hCaptcha challenge (rqdata/rqtoken are short-lived but +// often valid for a couple of minutes — fresh enough to reuse). Other +// transient states (logging_out, reconnecting) deliberately don't survive. export type HydrateRestoredState = | PendingFormState + | CaptchaSolveState | { kind: 'qr_verifying' }; // Outbound user actions the App dispatches. Form-submit actions clear any @@ -113,12 +145,34 @@ export type LoginAction = // kept symmetrical with TG's reducer for shape consistency, but // dispatching it doesn't trigger any send. | { kind: 'cancel_pending' } + // User finished an hCaptcha challenge — token is non-empty. Optimistic + // transition to `qr_verifying`; the App fires `login-captcha ` + // and the bridge's reply (`login_success` / chained `captcha_challenge` + // / `login_failed`) lands via the live event stream. + | { kind: 'submit_captcha_token' } + // Rollback for `submit_captcha_token` — the App couldn't deliver the + // command to the bridge. Routes back to disconnected with a localized + // error so the user sees what happened. + | { kind: 'captcha_send_failed' } + // hCaptcha challenge expired (server rqtoken TTL or local 90s timer). + // Routes to disconnected with a localized warn. + | { kind: 'captcha_expired' } | { kind: 'hydrate'; state: HydrateRestoredState }; export const initialLoginState: LoginState = { kind: 'unknown' }; const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiting_qr_scan'; +// States that a fresh captcha challenge can clobber: the QR scan has +// landed (or is mid-flight), or we're already showing a previous +// challenge that Discord chained on top. +const isCaptchaAcceptingState = ( + s: LoginState +): s is PendingFormState | { kind: 'qr_verifying' } | CaptchaSolveState => + s.kind === 'awaiting_qr_scan' || + s.kind === 'qr_verifying' || + s.kind === 'awaiting_captcha_solve'; + 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 @@ -157,6 +211,36 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState // login_success / login_failed gated on awaiting_qr_scan|qr_verifying). return { kind: 'disconnected' }; } + if (action.kind === 'submit_captcha_token') { + // User solved hCaptcha and we're about to fire login-captcha. Hold + // `qr_verifying` until the bridge replies (success / chained challenge + // / generic login_failed). Only honour from the captcha state — this + // action is App-emitted right after the hCaptcha callback, so it + // shouldn't ever fire from anywhere else, but defensive: a stale send + // shouldn't suddenly paint a verifying spinner over a connected pill. + if (state.kind !== 'awaiting_captcha_solve') return state; + return { kind: 'qr_verifying' }; + } + if (action.kind === 'captcha_send_failed') { + // App couldn't ship the `login-captcha` command (transport / Matrix + // API failure before the bridge saw it). Roll the optimistic + // `qr_verifying` back to disconnected with a localized error. + // + // Honour ONLY from `qr_verifying` — narrowed from also accepting + // `awaiting_captcha_solve` to avoid clobbering a fresh chained + // captcha that may have arrived between the optimistic dispatch and + // the failed-send rollback. If the live state already moved to a + // newer challenge, the stale send-failure should be silently dropped. + if (state.kind !== 'qr_verifying') return state; + return { kind: 'disconnected', lastError: { kind: 'captcha_send_failed' } }; + } + if (action.kind === 'captcha_expired') { + // Local 90s timer or hCaptcha's expired-callback fired — the rqtoken + // is dead, the user has nothing to solve. Route to disconnected with + // a localized warn so they retry login-qr from scratch. + if (state.kind !== 'awaiting_captcha_solve') return state; + return { kind: 'disconnected', lastError: { kind: 'captcha_expired' } }; + } const event = action.event; switch (event.kind) { @@ -286,28 +370,50 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState case 'login_failed': // Generic Discord-side login failure — bridge replies «Error logging // in: ». 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; + // reason as a warn line. Only honour when a QR flow is in flight, + // OR while the user is solving a captcha (the Vojo-patched bridge + // can also reply «Error logging in: …» AFTER `login-captcha` if + // Discord rejects the post-solve replay). Otherwise it's stale + // (e.g. an old failure replaying after page reload while the user + // is already connected). + if (!isCaptchaAcceptingState(state)) 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; + // UNPATCHED bridge fallback: «CAPTCHAs are currently not supported». + // Surface as a hint suggesting token-login (chat-fallback only). On + // a Vojo-patched bridge this branch never fires — see captcha_challenge. + if (!isCaptchaAcceptingState(state)) return state; return { kind: 'disconnected', lastError: { kind: 'captcha_required' } }; + case 'captcha_challenge': + // Vojo-patched bridge surfaced an hCaptcha challenge — pivot the + // widget to the captcha screen. Accept from awaiting_qr_scan + // (challenge landed before the QR was redacted), qr_verifying + // (challenge landed while we were still in the post-redact spinner) + // or awaiting_captcha_solve (Discord chained another challenge after + // the previous solve). Other states drop the event silently — a + // stale challenge from an abandoned flow shouldn't repaint UI. + if (!isCaptchaAcceptingState(state)) return state; + return { + kind: 'awaiting_captcha_solve', + service: event.service, + sitekey: event.sitekey, + sessionId: event.sessionId, + rqdata: event.rqdata, + rqtoken: event.rqtoken, + firstShownAt: Date.now(), + }; + 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; + if (!isCaptchaAcceptingState(state)) return state; return { kind: 'disconnected', lastError: { kind: 'login_websocket_failed', reason: event.reason }, @@ -318,14 +424,14 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState // 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; + if (!isCaptchaAcceptingState(state)) 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; + if (!isCaptchaAcceptingState(state)) return state; return { kind: 'disconnected', lastError: { kind: 'prepare_login_failed', reason: event.reason }, @@ -441,6 +547,12 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState // 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; +// hCaptcha rqtoken is even more short-lived than the QR ticket — Discord +// invalidates after ~90s in practice. If the user reloads while staring +// at a captcha challenge older than this, restoring the captcha screen +// only sets them up for a server-side rejection on solve. Drop the state +// instead and let live ping reconcile. +const CAPTCHA_HYDRATE_FRESHNESS_MS = 90 * 1000; export type HydrateInput = { ev: LoginEvent; @@ -520,6 +632,41 @@ const stepHydrate = ( return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false }; } + case 'captcha_challenge': { + // SECURITY: only accept a captcha challenge if the same chain has + // already seen a `qr_displayed` (i.e. WE initiated the login). + // Otherwise a malicious / compromised homeserver could craft an + // m.notice with the sentinel JSON pointing at an attacker-controlled + // sitekey, the user solves it on reload, and the resulting hCaptcha + // token is sent verbatim to the bridge — useful free captcha-solving + // labour for the attacker, and a phishing surface. The live reducer + // already gates on `isCaptchaAcceptingState` (which requires we're + // mid-flow), but the hydrate path replays raw timeline events + // without the live state — drop unsolicited challenges here. + if (acc.state.kind !== 'awaiting_qr_scan' && acc.state.kind !== 'qr_verifying') { + return acc; + } + // Vojo-patched bridge surfaced an hCaptcha — keep the chain open so a + // later `login_success` / `login_failed` still lands as terminal. + // The rqdata/rqtoken are short-lived on Discord's side (~2 min); + // the captcha-specific freshness gate in `hydrateFromTimeline` + // (CAPTCHA_HYDRATE_FRESHNESS_MS) drops stale states before they + // surface to the user. + return { + state: { + kind: 'awaiting_captcha_solve', + service: ev.service, + sitekey: ev.sitekey, + sessionId: ev.sessionId, + rqdata: ev.rqdata, + rqtoken: ev.rqtoken, + firstShownAt: ts, + }, + 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. @@ -570,8 +717,16 @@ export const hydrateFromTimeline = ( if (acc.terminated) return null; if (acc.pendingTs === null) return null; - if (now - acc.pendingTs > HYDRATE_FRESHNESS_MS) return null; + // Tighter freshness gate for captcha state — rqtoken expires faster + // than the QR ticket. This protects the user from a "solved captcha, + // bridge rejects, user confused" UX after a slow reload. + const freshness = + acc.state.kind === 'awaiting_captcha_solve' + ? CAPTCHA_HYDRATE_FRESHNESS_MS + : HYDRATE_FRESHNESS_MS; + if (now - acc.pendingTs > freshness) return null; if (acc.state.kind === 'qr_verifying') return acc.state; + if (acc.state.kind === 'awaiting_captcha_solve') return acc.state; if (!isFormState(acc.state)) return null; return acc.state; }; diff --git a/apps/widget-discord/src/styles.css b/apps/widget-discord/src/styles.css index e538c50b..5aec8e3c 100644 --- a/apps/widget-discord/src/styles.css +++ b/apps/widget-discord/src/styles.css @@ -582,6 +582,34 @@ body { color: var(--faint); } +/* ── hCaptcha challenge panel ───────────────────────────────────── */ + +.auth-card-captcha { + align-items: stretch; +} + +/* hCaptcha renders its own iframe inside this host. We just provide a + * minimum frame height so the layout doesn't collapse during script load, + * and centre the iframe horizontally. The hCaptcha widget is a fixed- + * width 304px element by default; on narrow viewports we let it overflow + * its container's flex line rather than try to scale the iframe (the + * upstream SDK handles its own responsive variants). */ +.auth-card-captcha-frame { + align-self: center; + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + min-height: 88px; +} +.auth-card-captcha-host { + /* hCaptcha injects a 304x78 (normal) or 156x144 (compact) iframe. The + * default rendering is 'normal' — we size accordingly and let the SDK + * place its own iframe inside. */ + display: flex; + justify-content: center; +} + @media (max-width: 600px) { .auth-card-row { flex-direction: column; diff --git a/capacitor.config.ts b/capacitor.config.ts index 7c5f1942..429e7e10 100644 --- a/capacitor.config.ts +++ b/capacitor.config.ts @@ -9,11 +9,19 @@ const config: CapacitorConfig = { // request.isForMainFrame(), so any cross-origin iframe URL not in // appAllowNavigationMask is hijacked into an external Intent.ACTION_VIEW // and the iframe stays blank. Same-origin /widgets/... is safe (resolves - // to https://localhost under Capacitor). For HTTPS-hosted bot widgets, - // uncomment and edit the host list below BEFORE shipping a config.json - // that points at them: + // to https://localhost under Capacitor). // - // server: { allowNavigation: ['app.vojo.chat', '*.vojo.chat'] }, + // The Discord widget renders a nested hCaptcha iframe inside the + // widgets.vojo.chat frame; without `*.hcaptcha.com` in the allowlist + // the captcha challenge stays blank on Android and login is dead-ended. + server: { + allowNavigation: [ + 'widgets.vojo.chat', + 'js.hcaptcha.com', + 'newassets.hcaptcha.com', + '*.hcaptcha.com', + ], + }, android: { // Keep default: resolveServiceWorkerRequests = true // SW is critical for authenticated Matrix media (MSC3916 / spec v1.11+)