feat(bots-discord): land hCaptcha challenge handling for QR-login with sentinel-prefixed bridge protocol and Dawn-themed widget UI

This commit is contained in:
v.lagerev 2026-05-06 14:19:45 +03:00
parent a0537a56a2
commit d1bf95f541
8 changed files with 680 additions and 34 deletions

View file

@ -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 <token>` 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<HCaptchaApi> | null = null;
const loadHCaptcha = (): Promise<HCaptchaApi> => {
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<HCaptchaApi>((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<HTMLDivElement | null>(null);
const widgetIdRef = useRef<string | null>(null);
const [loadError, setLoadError] = useState<boolean>(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<boolean>(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<boolean>(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
// <html> 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 (
<div class="auth-card auth-card-captcha">
<div class="auth-card-title">{t('auth-card.captcha.title')}</div>
<div class="auth-card-hint">{t('auth-card.captcha.hint')}</div>
<div class="auth-card-captcha-frame">
<div ref={containerRef} class="auth-card-captcha-host" />
{loadError ? (
<div class="auth-card-error">{t('auth-card.captcha.load-error')}</div>
) : null}
</div>
<div class="auth-card-row">
<button type="button" class="btn-text" onClick={onCancel}>
{t('auth-card.cancel')}
</button>
</div>
</div>
);
};
// --------------------------------------------------------------------------
// 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<void> => {
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 <token>` 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) {
</section>
) : null}
{state.kind === 'awaiting_captcha_solve' ? (
<section class="section">
<CaptchaPanel
state={state}
t={t}
onSolved={onCaptchaSolved}
onCancel={onClickCancel}
onExpired={onCaptchaExpired}
/>
</section>
) : null}
{state.kind === 'qr_verifying' ? (
<section class="section">
<StatusRow

View file

@ -45,11 +45,21 @@ const PING_CONNECTION_DEAD_RE = /^you'?re logged in, but the discord connection
// 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.
// login-qr CAPTCHA path — Vojo-patched bridge sends a single-line
// `VOJO-CAPTCHA-CHALLENGE-V1 {json}` m.notice carrying the hCaptcha
// sitekey, session_id, rqdata, rqtoken. The sentinel is markdown-inert by
// design (no `_`, `*`, `` ` ``, `[`, `<` characters): even if a future
// patch routes the bridge reply through goldmark again, the prefix
// survives intact. The bridge currently sends the notice via
// SendMessageEvent directly to bypass the framework's markdown round-trip.
// See bridge `commands_captcha.go` for the producer side.
const CAPTCHA_CHALLENGE_PREFIX = 'VOJO-CAPTCHA-CHALLENGE-V1';
const CAPTCHA_CHALLENGE_RE = /^VOJO-CAPTCHA-CHALLENGE-V1\s+(\{[\s\S]*\})\s*$/;
// Legacy CAPTCHA fallback — commands.go:fnLoginQR (l.207-209) on UNPATCHED
// upstream v0.7.6: «CAPTCHAs are currently not supported - use token login
// instead». Kept so a deployment running unpatched bridge still produces a
// useful hint rather than a generic Go-error tail.
const CAPTCHA_REQUIRED_RE = /captchas? are currently not supported/i;
// Generic «Error logging in: %v» — fnLoginQR l.205 / 211 (different
@ -127,6 +137,27 @@ export const parseLegacyV076Body = (rawBody: string): LoginEvent => {
}
// 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<string, unknown>;
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' },

View file

@ -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 `<commandPrefix> login-captcha <token>` 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

View file

@ -44,10 +44,19 @@ export const EN: Record<StringKey, string> = {
'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 youre not a robot',
'auth-card.captcha.hint':
'Discord asked for a CAPTCHA. Solve it below — sign-in will continue automatically once youre 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 bots 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<StringKey, string> = {
'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}.',

View file

@ -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}.',

View file

@ -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 <token>` 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
// <token>` 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 <token>`
// 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 <token>`
// 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: <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;
// 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;
};

View file

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

View file

@ -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+)