vojo/apps/widget-discord/src/state.ts

920 lines
37 KiB
TypeScript

// Login state machine — consumes LoginEvent (one per inbound bridge bot
// reply) and emits a typed UI state. The widget renders the QR panel and
// the status pill from this state, never from raw reply strings.
//
// Discord vs Telegram differences:
// - QR-only: there's no phone/code/password ladder, so the state space
// is much smaller than the Telegram reducer.
// - status comes from `ping` (legacy mautrix command system), not
// `list-logins` (bridgev2). The four ping replies map to four states:
// disconnected / connected / connection_dead / token_stored.
// - no list-logins-derived `loginId`; logout is the bare `logout` verb,
// so the connected state doesn't need to gate on a login id.
// - the QR is NOT rotated by Discord remoteauth (single image per
// login attempt). The state machine still tracks `qrEventId` so the
// redaction handler can match against it and ignore unrelated cleanup.
//
// State-gating policy: late-arriving replies from cancelled flows must
// not resurrect dead state. The `cancel_pending` action ALWAYS lands us
// in `disconnected` immediately; later bridge events arriving after
// cancel are filtered by the live reducer.
import type { LoginEvent } from './bridge-protocol/types';
export type LoginErrorFlag =
| { kind: 'login_failed'; reason?: string }
| { kind: 'captcha_required' }
// 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 }
| { kind: 'already_logged_in' }
| { kind: 'unknown_command' };
// `reconnect_failed` is intentionally NOT a LoginErrorFlag arm: the live
// reducer routes that event back to `connected_dead` (no error surface
// there — the connected-dead pill IS the error indicator) without
// staging a reason for `localizeError`. If a future UI change wants to
// surface the reason, add `lastError?: ...` to the connected_dead state
// shape and route `reconnect_failed` through it.
// A live form is open and waiting for user action. M-discord ships with
// only one: the QR panel. Hydrate's restorable shape collapses to this
// single variant + the `qr_verifying` interstitial.
export type PendingFormState = {
kind: 'awaiting_qr_scan';
discordUrl: string;
qrEventId: string;
firstShownAt: number;
lastError?: LoginErrorFlag;
};
// 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' }
// ping returned `not_logged_in`, OR logout completed. Status pill:
// --rose. The card grid offers the QR-login affordance.
| { kind: 'disconnected'; lastError?: LoginErrorFlag }
// QR-login in progress. Optimistically transitioned by `start_qr_login`;
// overwritten with real discordUrl/qrEventId by the live `qr_displayed`
// event. Status pill: --amber.
| PendingFormState
// 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.
| { kind: 'qr_verifying' }
// logout in flight — waiting for `Logged out successfully.`. Status
// pill: --amber.
| { kind: 'logging_out' }
// reconnect in flight (recovery from connection_dead / token_stored).
// Waiting for `Successfully reconnected` or `You're already connected`.
// Status pill: --amber. `handle` is carried through from the
// connected_dead state so a successful reconnect can flip directly to
// `connected{handle}` without bouncing through a transient `unknown`
// (which would briefly paint a faint «Проверка статуса…» pill — bad
// UX immediately after the user took an action).
| { kind: 'reconnecting'; handle?: string }
// Live session — ping or login_success confirmed. Discord legacy bridge
// doesn't have a per-account loginId concept (single Discord account
// per Matrix user), so logout doesn't need an id.
| { kind: 'connected'; handle: string; discordId?: string }
// ping says we have a token but the connection's down. Status pill:
// green-ish but with a Reconnect recovery action exposed. The reducer
// distinguishes `connection_dead` (Discord WS dropped) from `token_stored`
// (we have the token but never got far enough to connect), but the UI
// collapses both into the same shape — they share the recovery path.
| { kind: 'connected_dead'; reason: 'connection_dead' | 'token_stored'; handle?: string };
// States that the hydrate path can restore after a reload. The QR panel
// (`awaiting_qr_scan`) survives reloads via the m.image / m.room.redaction
// timeline; `qr_verifying` covers the post-scan pre-success interstitial;
// `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
// pending lastError; structural transitions optimistically advance state —
// the App rolls them back on send-failure where the bot would otherwise
// leave us stuck.
export type LoginAction =
| { kind: 'event'; event: LoginEvent }
| { kind: 'start_qr_login' } // user clicked «Войти по QR»
| { kind: 'request_logout' } // user clicked «Выйти из Discord»
// user clicked «Переподключиться» — App passes the current handle
// (from `connected_dead.handle` or `connected.handle`) so the
// transient `reconnecting` state carries it forward; without this the
// post-reconnect_ok branch can't paint the connected pill until the
// follow-up ping resolves.
| { kind: 'request_reconnect'; handle?: string }
// Discord legacy mautrix has no `cancel` command. Cancel is LOCAL —
// returns the widget to disconnected immediately; the bridge's
// remoteauth websocket eventually times out on its own. The action is
// kept symmetrical with TG's reducer for shape consistency, but
// dispatching it doesn't trigger any send.
| { kind: 'cancel_pending' }
// 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
// moved us off `unknown`, the live truth wins; the cached timeline
// snapshot is by definition older than what the live event just told
// us. Without this gate, a stale `awaiting_qr_scan` from a previous
// session could overwrite a legitimate `connected` that arrived
// during the readTimeline await.
if (state.kind !== 'unknown') return state;
return action.state;
}
if (action.kind === 'start_qr_login') {
// Optimistic placeholder; the live `qr_displayed` event overwrites
// discordUrl + qrEventId + firstShownAt. If the `!discord login-qr`
// send fails, the App rolls back to `disconnected`.
return {
kind: 'awaiting_qr_scan',
discordUrl: '',
qrEventId: '',
firstShownAt: Date.now(),
};
}
if (action.kind === 'request_logout') {
return { kind: 'logging_out' };
}
if (action.kind === 'request_reconnect') {
return { kind: 'reconnecting', handle: action.handle };
}
if (action.kind === 'cancel_pending') {
// Optimistic: drop straight back to disconnected. Discord legacy mautrix
// has no `cancel` command — the bridge's remoteauth websocket continues
// until it succeeds or times out internally. From the user's POV the
// widget returns to disconnected, and any later QR redaction / login
// success / login failure event from the abandoned flow is filtered
// by the per-event gates below (qr_redacted gated on awaiting_qr_scan,
// login_success / login_failed gated on awaiting_qr_scan|qr_verifying).
return { kind: 'disconnected' };
}
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) {
// --- ping replies ----------------------------------------------------
case 'not_logged_in':
// Accept from states where flipping to disconnected is correct.
// Late-arriving `not_logged_in` MUST NOT clobber an active QR-scan
// (which was started after the ping was fired but before the reply
// landed) — that's the same race the TG reducer guards against.
if (
state.kind === 'unknown' ||
state.kind === 'disconnected' ||
state.kind === 'logging_out' ||
state.kind === 'qr_verifying' ||
state.kind === 'reconnecting' ||
state.kind === 'connected_dead'
) {
return { kind: 'disconnected' };
}
return state;
case 'logged_in':
// Authoritative source — accept from any state. Used by both the
// initial ping AND the post-`login_success` re-ping that picks up
// the discordId snowflake.
return {
kind: 'connected',
handle: event.handle,
discordId: event.discordId,
};
case 'connection_dead':
// ping says token's good but the WS is down. Show the connected
// chrome with a Reconnect recovery action.
return {
kind: 'connected_dead',
reason: 'connection_dead',
handle: state.kind === 'connected' ? state.handle : undefined,
};
case 'token_stored_not_connected':
return {
kind: 'connected_dead',
reason: 'token_stored',
handle: state.kind === 'connected' ? state.handle : undefined,
};
// --- QR lifecycle ----------------------------------------------------
case 'qr_displayed': {
// Defence-in-depth: an inbound qr_displayed MUST carry a non-empty
// event id (the host driver rejects empty event_id at the sanitizer;
// this is a redundant guard).
if (event.eventId.length === 0) return state;
// Initial QR from a fresh login attempt — accept from:
// * `unknown` — cold-start before ping resolves;
// * placeholder `awaiting_qr_scan{qrEventId=''}` from start_qr_login.
//
// We DO NOT accept from `disconnected`. Discord legacy mautrix has
// no cancel command, so when the user clicks Cancel locally the
// bridge's remoteauth goroutine continues until success / failure
// / internal timeout. The widget transitions to `disconnected`
// immediately, but the bridge eventually emits the m.image. If we
// accepted that here, the user would see a QR they didn't ask for
// — the bridge has no way to know the user moved on. Drop it
// silently; the user has to click «Войти по QR» again to express
// intent (which resets the placeholder and lets the next m.image
// land).
if (
state.kind === 'unknown' ||
(state.kind === 'awaiting_qr_scan' && state.qrEventId === '')
) {
return {
kind: 'awaiting_qr_scan',
discordUrl: event.discordUrl,
qrEventId: event.eventId,
firstShownAt:
state.kind === 'awaiting_qr_scan' && state.firstShownAt
? state.firstShownAt
: Date.now(),
};
}
if (state.kind !== 'awaiting_qr_scan') return state;
// Hypothetical edit pointing at our anchor — repaint URL, keep id.
// Discord doesn't currently edit QRs but the path stays for
// forward-compat (cheaper to keep than to reconstruct).
if (event.replacesEventId === state.qrEventId) {
return { ...state, discordUrl: event.discordUrl };
}
// Fresh non-edit qr_displayed while we're already tracking one —
// could be a bridge-side restart (rare). Adopt as new anchor.
if (!event.replacesEventId) {
return {
kind: 'awaiting_qr_scan',
discordUrl: event.discordUrl,
qrEventId: event.eventId,
firstShownAt: Date.now(),
};
}
// Edit pointing at something we don't track — ignore.
return state;
}
case 'qr_redacted': {
// Bridge cleaned up the QR after a successful scan (commands.go
// l.197: `_, _ = ce.MainIntent().RedactEvent(ce.RoomID, qrCodeEvent)`
// — only fires on the success path). Held as `qr_verifying` until
// the success line lands. Only honour from awaiting_qr_scan with a
// matching event id.
if (state.kind !== 'awaiting_qr_scan') return state;
if (state.qrEventId !== event.redactsEventId) return state;
return { kind: 'qr_verifying' };
}
case 'login_success':
// Honour from any non-terminal state. The bridge's success line
// doesn't include the discordId; the App fires `ping` afterwards
// to upgrade to the full `connected{handle, discordId}` shape.
return { kind: 'connected', handle: event.handle };
case 'login_failed':
// Generic Discord-side login failure — bridge replies «Error logging
// in: <go-error>». Routes back to disconnected with the verbatim
// reason as a warn line. Only honour when a QR flow is in flight,
// 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':
// 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 (!isCaptchaAcceptingState(state)) return state;
return {
kind: 'disconnected',
lastError: { kind: 'login_websocket_failed', reason: event.reason },
};
case 'connect_after_login_failed':
// Post-scan rare: remoteauth gave us a token, but the bridge couldn't
// connect to Discord with it. The bridge has the token cached and
// might recover on next ping; we still route to disconnected so the
// user can retry.
if (!isCaptchaAcceptingState(state)) return state;
return {
kind: 'disconnected',
lastError: { kind: 'connect_after_login_failed', reason: event.reason },
};
case 'prepare_login_failed':
if (!isCaptchaAcceptingState(state)) return state;
return {
kind: 'disconnected',
lastError: { kind: 'prepare_login_failed', reason: event.reason },
};
case 'already_logged_in':
// The user clicked «Войти по QR» but the bridge is already logged
// in — race against ping. Surface a soft warning and let the App's
// re-ping reconcile to the connected state.
if (isFormState(state)) {
return { ...state, lastError: { kind: 'already_logged_in' } };
}
return state;
// --- logout ----------------------------------------------------------
case 'logout_ok':
case 'logout_no_op':
// Late `Logged out` from a previous session can arrive while the
// user is mid-new-flow. Only honour from logging_out; other states
// keep their flow.
if (state.kind !== 'logging_out') return state;
return { kind: 'disconnected' };
// --- disconnect (read-only, never sent by widget) -------------------
case 'disconnect_ok':
case 'disconnect_no_op':
// User typed `disconnect` manually in chat-fallback while the widget
// was open. Reflect the bridge's truth: no token-loss, but no live
// connection either — same shape as `token_stored`. Both
// `connected` (string handle) and `connected_dead` (handle?:
// string) expose `handle` on the same key, so a single read works.
if (state.kind === 'connected' || state.kind === 'connected_dead') {
return {
kind: 'connected_dead',
reason: 'token_stored',
handle: state.handle,
};
}
return state;
case 'disconnect_failed':
// Manual disconnect attempt failed — keep current state, the widget
// doesn't surface a UI for this since it never sent the command.
return state;
// --- reconnect -------------------------------------------------------
case 'reconnect_ok':
case 'reconnect_no_op':
// After a successful reconnect, ping is the source of truth for the
// handle. The App fires `ping` after this event lands to refresh.
// We flip to `connected` immediately so the user sees an immediate
// green pill confirming their click; the post-event ping refreshes
// the handle / discordId within ~100ms. Both `reconnecting` and
// `connected_dead` carry `handle?` — a missing handle still flips
// green with an empty handle, which the UI's
// `state.handle ? connected-as : connected` ternary tolerates.
// This avoids the `unknown` flap that the previous draft would
// produce when no handle was stashed.
if (state.kind === 'reconnecting' || state.kind === 'connected_dead') {
return { kind: 'connected', handle: state.handle ?? '' };
}
return state;
case 'reconnect_failed':
if (state.kind !== 'reconnecting') return state;
// Roll back to connected_dead carrying the previous handle. The
// user can hit Reconnect again or refresh. We don't surface the
// error reason here — the connected_dead pill itself reads as
// «something is wrong, try Reconnect» — adding a transient red
// banner adjacent to a recovery affordance is overkill.
return {
kind: 'connected_dead',
reason: 'connection_dead',
handle: state.handle,
};
// --- bridge-side errors ---------------------------------------------
case 'unknown_command':
// Shouldn't happen — we only send commands the bridge knows. Visible
// when /config.json's commandPrefix drifts from the bridge's actual
// command_prefix. Surface loudly on disconnected.
return { kind: 'disconnected', lastError: { kind: 'unknown_command' } };
case 'unknown':
return state;
default: {
// Exhaustiveness check — TS flags this if a new LoginEvent kind is
// added without a case here.
const exhaustive: never = event;
return exhaustive;
}
}
};
// --- hydrate-from-timeline -----------------------------------------------
//
// Discord's hydrate is simpler than Telegram's because the QR flow has
// fewer states. We walk past→present and let each event freely transition
// the state — like the TG hydrate, this is permissive (no out-of-thin-air
// rejection) because we trust the bridge's durable timeline.
// 3 minutes — Discord remoteauth's server-side timeout sits around 2
// minutes (verified empirically against v0.7.6's remoteauth/client.go;
// no explicit constant in the lib, the server-side gateway closes the
// websocket on inactivity). We use 3 min as a slight safety margin so
// reload-after-success grace still works while the panel is still
// fresh enough to scan. Telegram's QR rotates internally and lives ~10
// min, which is why the TG widget uses 10 min — Discord's single-shot
// remoteauth needs the tighter window.
const HYDRATE_FRESHNESS_MS = 3 * 60 * 1000;
// 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;
ts: number;
};
type HydrateAccumulator = {
state: LoginState;
pendingTs: number | null;
terminated: boolean;
};
const stepHydrate = (
prevAcc: HydrateAccumulator,
input: HydrateInput
): HydrateAccumulator => {
const { ev, ts } = input;
// After a terminal event we normally stop — except if a fresh
// `qr_displayed` shows up, that's the bridge signature of a NEW login
// flow. The user cancelled (or finished) and is now logging in again;
// the chain should resume tracking from the new start. Without this
// re-entry, `[qr_displayed, login_success, qr_displayed]` (logout-then-
// re-login-mid-QR) would return null.
if (prevAcc.terminated && ev.kind !== 'qr_displayed') {
return prevAcc;
}
// Restart-on-re-entry: clear the terminated bit AND any prior tracked
// state so the new flow's first event becomes the new anchor without
// inheriting the old QR's eventId.
const acc: HydrateAccumulator = prevAcc.terminated
? { state: { kind: 'unknown' }, pendingTs: null, terminated: false }
: prevAcc;
switch (ev.kind) {
case 'qr_displayed': {
// Same anchor logic as the live reducer.
if (acc.state.kind !== 'awaiting_qr_scan') {
return {
state: {
kind: 'awaiting_qr_scan',
discordUrl: ev.discordUrl,
qrEventId: ev.eventId,
firstShownAt: ts,
},
pendingTs: ts,
terminated: false,
};
}
if (ev.replacesEventId === acc.state.qrEventId) {
return {
state: { ...acc.state, discordUrl: ev.discordUrl },
pendingTs: ts,
terminated: false,
};
}
if (!ev.replacesEventId) {
return {
state: {
kind: 'awaiting_qr_scan',
discordUrl: ev.discordUrl,
qrEventId: ev.eventId,
firstShownAt: ts,
},
pendingTs: ts,
terminated: false,
};
}
return acc;
}
case 'qr_redacted': {
if (acc.state.kind !== 'awaiting_qr_scan') return acc;
if (acc.state.qrEventId !== ev.redactsEventId) return acc;
// Move into qr_verifying and keep the chain open — the success line
// typically follows in the same scan window.
return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false };
}
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.
case 'login_success':
case 'logged_in':
case 'logout_ok':
case 'logout_no_op':
case 'not_logged_in':
case 'connection_dead':
case 'token_stored_not_connected':
case 'reconnect_ok':
case 'reconnect_no_op':
case 'reconnect_failed':
case 'disconnect_ok':
case 'disconnect_no_op':
case 'disconnect_failed':
case 'login_failed':
case 'captcha_required':
case 'login_websocket_failed':
case 'connect_after_login_failed':
case 'prepare_login_failed':
case 'unknown_command':
return { state: acc.state, pendingTs: null, terminated: true };
case 'already_logged_in':
case 'unknown':
// Soft no-op for hydrate. already_logged_in is a live-flow warning
// that doesn't reflect persistent state; unknown is a wording-drift
// catch-all.
return acc;
default: {
const exhaustive: never = ev;
return exhaustive;
}
}
};
export const hydrateFromTimeline = (
inputs: ReadonlyArray<HydrateInput>,
now: number = Date.now()
): HydrateRestoredState | null => {
const acc = inputs.reduce<HydrateAccumulator>(stepHydrate, {
state: { kind: 'unknown' },
pendingTs: null,
terminated: false,
});
if (acc.terminated) return null;
if (acc.pendingTs === null) return null;
// 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;
};
// --- DEV sanity assertions ------------------------------------------------
if (import.meta.env.DEV) {
runHydrateSanity();
}
function runHydrateSanity(): void {
const t0 = 1_700_000_000_000;
const recent = (offset: number) => t0 + offset;
const now = t0 + 60 * 1000;
const cases: Array<{
name: string;
inputs: HydrateInput[];
expected: LoginState | null;
nowOverride?: number;
}> = [
{ name: 'empty timeline → null', inputs: [], expected: null },
{
name: 'lone qr_displayed → awaiting_qr_scan',
inputs: [
{
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
ts: recent(0),
},
],
expected: {
kind: 'awaiting_qr_scan',
discordUrl: 'https://discord.com/ra/A',
qrEventId: '$qrA',
firstShownAt: recent(0),
},
},
{
name: 'qr_redacted with mismatched target → ignored',
inputs: [
{
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
ts: recent(0),
},
{ ev: { kind: 'qr_redacted', redactsEventId: '$other' }, ts: recent(30000) },
],
expected: {
kind: 'awaiting_qr_scan',
discordUrl: 'https://discord.com/ra/A',
qrEventId: '$qrA',
firstShownAt: recent(0),
},
},
{
name: 'qr scan → no follow-up → qr_verifying (reload during the gap)',
inputs: [
{
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
ts: recent(0),
},
{ ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) },
],
expected: { kind: 'qr_verifying' },
},
{
name: 'qr scan → login_success → null (terminal — let ping reconcile)',
inputs: [
{
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
ts: recent(0),
},
{ ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) },
{ ev: { kind: 'login_success', handle: 'example' }, ts: recent(31000) },
],
expected: null,
},
{
name: 'login_failed after qr → null (terminal)',
inputs: [
{
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
ts: recent(0),
},
{ ev: { kind: 'login_failed', reason: 'rate limited' }, ts: recent(15000) },
],
expected: null,
},
{
name: 'captcha_required after qr → null (terminal)',
inputs: [
{
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
ts: recent(0),
},
{ ev: { kind: 'captcha_required' }, ts: recent(10000) },
],
expected: null,
},
{
name: 'logout-then-relogin-mid-qr → awaiting_qr_scan (resume tracking)',
inputs: [
{
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/OLD', eventId: '$qrOld' },
ts: recent(0),
},
{ ev: { kind: 'qr_redacted', redactsEventId: '$qrOld' }, ts: recent(15000) },
{ ev: { kind: 'login_success', handle: 'old' }, ts: recent(16000) },
{ ev: { kind: 'logout_ok' }, ts: recent(20000) },
{
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/NEW', eventId: '$qrNew' },
ts: recent(25000),
},
],
expected: {
kind: 'awaiting_qr_scan',
discordUrl: 'https://discord.com/ra/NEW',
qrEventId: '$qrNew',
firstShownAt: recent(25000),
},
},
{
name: 'pending too old (5 min) → null (freshness guard, 3-min window)',
inputs: [
{
ev: {
kind: 'qr_displayed',
discordUrl: 'https://discordapp.com/ra/A',
eventId: '$qrA',
},
ts: t0 - 5 * 60 * 1000,
},
],
expected: null,
nowOverride: t0,
},
{
name: 'pending just inside window (2 min) → state',
inputs: [
{
ev: {
kind: 'qr_displayed',
discordUrl: 'https://discordapp.com/ra/A',
eventId: '$qrA',
},
ts: t0 - 2 * 60 * 1000,
},
],
expected: {
kind: 'awaiting_qr_scan',
discordUrl: 'https://discordapp.com/ra/A',
qrEventId: '$qrA',
firstShownAt: t0 - 2 * 60 * 1000,
},
nowOverride: t0,
},
{
name: 'connection_dead alone → null (terminal — let live ping reconcile)',
inputs: [{ ev: { kind: 'connection_dead' }, ts: recent(0) }],
expected: null,
},
{
name: 'token_stored_not_connected alone → null (terminal — let live ping reconcile)',
inputs: [{ ev: { kind: 'token_stored_not_connected' }, ts: recent(0) }],
expected: null,
},
{
name: 'logged_in alone → null (terminal — let live ping reconcile)',
inputs: [{ ev: { kind: 'logged_in', handle: 'x' }, ts: recent(0) }],
expected: null,
},
{
name: 'unknown alone → null',
inputs: [{ ev: { kind: 'unknown' }, ts: recent(0) }],
expected: null,
},
];
for (const c of cases) {
const actual = hydrateFromTimeline(c.inputs, c.nowOverride ?? now);
if (!sameLoginState(actual, c.expected)) {
// eslint-disable-next-line no-console
console.error('[hydrate sanity] mismatch', { case: c.name, actual, expected: c.expected });
throw new Error(`hydrate sanity failed: ${c.name}`);
}
}
}
function sameLoginState(a: LoginState | null, b: LoginState | null): boolean {
if (a === null || b === null) return a === b;
return JSON.stringify(a) === JSON.stringify(b);
}