// Login state machine — consumes LoginEvent (one per inbound m.notice from // the bridge bot) and emits a typed UI state. The widget renders forms and // the status pill from this state, never from raw reply strings. // // Multi-reply collapse is implemented here: when the bot emits two notices // for a single transition (2fa instructions + password re-prompt; invalid // code + code re-prompt; wrong password + password re-prompt), the second // notice arrives as `awaiting_password` / `awaiting_code` and the reducer // recognises it as a no-op against the state already set by the first. // // State-gating policy: prompt events (`awaiting_*`) and step-error events // (`twofa_required`, `invalid_code`, `wrong_password`, `submit_failed`, // `invalid_value`) are valid only from a *plausible previous state*. // Without these gates, late prompt-events can resurrect cancelled or // completed flows — e.g. user submits phone, clicks Cancel, bot's pipeline // already started a Telegram API call and emits `Please enter your Code…` // AFTER the cancel reply lands. The reducer here ignores that late prompt // because we're already `disconnected`. import type { LoginEvent, ListedLogin } from './bridge-protocol/types'; export type LoginErrorFlag = | { kind: 'invalid_code' } | { kind: 'wrong_password' } | { kind: 'submit_failed'; reason?: string } | { kind: 'invalid_value'; reason?: string } | { kind: 'prepare_failed'; reason?: string } | { kind: 'start_failed'; reason?: string } | { kind: 'login_in_progress' } | { kind: 'max_logins'; limit?: number } | { kind: 'unknown_command' }; // A live form is open and waiting for user input. M12.5's hydrate path // can produce a phone/code/password form OR a QR-scan state — every other // final state falls through to live `list-logins` reconciliation. // // `awaiting_qr_scan` carries: // tgUrl — `tg://login?token=...` to render as a QR matrix. // qrEventId — current event id of the QR `m.image`. The bridge // rotates the token ~every 30 s and edits the original // event; rotations carry the original id in // `m.relates_to.event_id` and the state machine matches // on this field to decide between «same flow, repaint» // and «something else replaced our QR» (the latter is a // no-op — we keep the current qrEventId until the bridge // redacts or sends a new top-level QR). // firstShownAt — wall-clock ts of the first QR render in this flow. // Drives the UX countdown to the bridge's 10-min server- // side LoginTimeout. NOT a hard kill — when the timer // expires we just show «попробуйте снова». export type PendingFormState = | { kind: 'awaiting_phone'; lastError?: LoginErrorFlag } | { kind: 'awaiting_code'; lastError?: LoginErrorFlag } | { kind: 'awaiting_password'; lastError?: LoginErrorFlag } | { kind: 'awaiting_qr_scan'; tgUrl: string; qrEventId: string; firstShownAt: number; lastError?: LoginErrorFlag; }; export type LoginState = // Pre-handshake / pre-list-logins. Status pill: --faint. | { kind: 'unknown' } // list-logins came back empty, OR logout completed. Status pill: --rose // (disconnected = needs action). | { kind: 'disconnected'; lastError?: LoginErrorFlag } // After "Войти по номеру" — waiting for `Please enter your Phone number`. // After phone submit — waiting for code prompt OR error reply. // After code submit (when the bot decided 2fa is needed) — waiting for // password submission. lastError carries `wrong_password` after a failed // password retry. Status pill: --amber for all three. // `awaiting_qr_scan` is the QR-login analog of `awaiting_phone` — the // bridge has fired its first `m.image` carrying a `tg://login?token=…` // URL and we're waiting for the user to scan it on their phone. | PendingFormState // QR was redacted (i.e. the bridge accepted a scan), but we don't yet // know whether 2FA is required or login succeeded outright. Held as an // intermediate spinner until the next bridge signal arrives. Status // pill: --amber. NOT terminal — `twofa_required` lifts us into // `awaiting_password`, `login_success` into `connected`. | { kind: 'qr_verifying' } // logout in flight — waiting for `Logged out`. Status pill: --amber. | { kind: 'logging_out'; loginId: string } // Live session. login carries the parsed handle/numericId from // `Successfully logged in as ()`, plus the loginId we need // for `!tg logout `. Status pill: --green. | { kind: 'connected'; handle: string; numericId?: string; loginId?: string; }; // States that the hydrate path can restore after a reload. Equals // PendingFormState (live forms waiting for input) plus `qr_verifying` // (the brief interstitial after a successful QR scan but before the bot // emits twofa_required / login_success). Without `qr_verifying` here a // reload during that ~1 s gap reads the bridge's empty list-logins and // routes the user to disconnected, losing the scanned QR. export type HydrateRestoredState = PendingFormState | { kind: 'qr_verifying' }; // Outbound user actions the App dispatches. Form-submit actions clear any // pending lastError; structural transitions (start_login, request_logout, // cancel_pending) 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_login' } // user clicked "Войти по номеру" | { kind: 'start_qr_login' } // user clicked "Войти по QR-коду" | { kind: 'submit_phone' } // user clicked submit on phone form | { kind: 'submit_code' } // user clicked submit on code form | { kind: 'submit_password' } // user clicked submit on 2fa form | { kind: 'request_logout'; loginId: string } // user clicked "Выйти" | { kind: 'cancel_pending' } // user clicked "Отмена" | { kind: 'hydrate'; state: HydrateRestoredState }; // M12.5 timeline-resume seed export const initialLoginState: LoginState = { kind: 'unknown' }; const pickConnected = (logins: ListedLogin[]): LoginState => { if (logins.length === 0) return { kind: 'disconnected' }; // M12 ships single-account UI (max_logins=1 in the operator's bridge // config). If a future deployment runs with multiple logins, we still // surface the first one — multi-account UI is a follow-up phase. The // loginId here is what the widget will pass to `!tg logout `. const [first] = logins; return { kind: 'connected', handle: first.name, loginId: first.id, }; }; // Whether a `awaiting_code` prompt is plausible from the current state. // Plausible: just submitted phone (still in awaiting_phone), or the bot // is re-prompting after invalid_code (we're already in awaiting_code). const acceptsCodePrompt = (s: LoginState): boolean => s.kind === 'awaiting_phone' || s.kind === 'awaiting_code'; // Whether a `awaiting_password` re-prompt is plausible. The TRANSITION to // password (from awaiting_code) is driven by `twofa_required`, not by the // re-prompt itself; the re-prompt only confirms we're still waiting. const acceptsPasswordReprompt = (s: LoginState): boolean => s.kind === 'awaiting_password'; // Whether `twofa_required` is plausible. It can only follow a code submit. const acceptsTwofa = (s: LoginState): boolean => s.kind === 'awaiting_code'; // Whether step-scoped errors (invalid_code, wrong_password, invalid_value, // submit_failed) should land on a form. Form-scoped errors are dropped // when no form is open. Shared by the live reducer and the hydrate path — // the predicate body and the resulting type are identical. const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiting_phone' || s.kind === 'awaiting_code' || s.kind === 'awaiting_password' || s.kind === 'awaiting_qr_scan'; // Whether `twofa_required` is plausible from the current state. After a code // submit, after a successful QR scan (which enters qr_verifying), and as a // late re-entry from awaiting_qr_scan if the bridge skips its redaction // step (shouldn't happen against bridgev2 v0.2604.0, but the path exists). const acceptsQrScanTwofa = (s: LoginState): boolean => s.kind === 'awaiting_qr_scan' || s.kind === 'qr_verifying'; export const loginReducer = (state: LoginState, action: LoginAction): LoginState => { if (action.kind === 'hydrate') { // M12.5: hydrate is a one-shot mount-time seed. It races against live // events that may arrive between `on('ready')` firing and our async // readTimeline resolving — bridgev2 / cinny's BotWidgetEmbed can push // a new bot reply via send_event during that window. If a live event // has 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_code` from the // pre-reload session could overwrite a legitimate `connected` that // arrived during the await. if (state.kind !== 'unknown') return state; return action.state; } if (action.kind === 'start_login') { return { kind: 'awaiting_phone' }; } if (action.kind === 'start_qr_login') { // Optimistic transition into a placeholder QR-scan state. The actual QR // payload arrives as a `qr_displayed` live event and overwrites tgUrl // / qrEventId / firstShownAt then; until then the panel renders a // spinner («Готовим QR-код…»). If the `!tg login qr` send fails, the // App rolls back to `disconnected`. return { kind: 'awaiting_qr_scan', tgUrl: '', qrEventId: '', firstShownAt: Date.now(), }; } if (action.kind === 'submit_phone') { // Stay on the phone form until the bot confirms with `awaiting_code`. // Optimistic transition to awaiting_code would mis-surface a phone-side // error (e.g. `submit_failed: PHONE_NUMBER_BANNED`) on the code form. if (state.kind === 'awaiting_phone') { return { kind: 'awaiting_phone', lastError: undefined }; } return state; } if (action.kind === 'submit_code') { if (state.kind === 'awaiting_code') { return { kind: 'awaiting_code', lastError: undefined }; } return state; } if (action.kind === 'submit_password') { if (state.kind === 'awaiting_password') { return { kind: 'awaiting_password', lastError: undefined }; } return state; } if (action.kind === 'request_logout') { return { kind: 'logging_out', loginId: action.loginId }; } if (action.kind === 'cancel_pending') { // Optimistic: drop straight back to disconnected. The bot's reply will // be `Login cancelled.` (cancel_ok) or `No ongoing command.` // (cancel_no_op) — either way the user has signalled they want out. return { kind: 'disconnected' }; } const event = action.event; switch (event.kind) { case 'logins_listed': // list-logins is the source of truth — accept from any state. return pickConnected(event.logins); case 'not_logged_in': // Same gating idea as the prompt events: a late-arriving // `You're not logged in` from a list-logins fired before the user // started a fresh login flow would otherwise wipe an active form. // Accept only from states where flipping to disconnected is correct. // `qr_verifying` is included because the App fires `list-logins` // as a recovery probe after long QR-verifying stalls — the answer // there means «scan didn't actually take», back to disconnected. if ( state.kind === 'unknown' || state.kind === 'disconnected' || state.kind === 'logging_out' || state.kind === 'qr_verifying' ) { return { kind: 'disconnected' }; } return state; case 'awaiting_phone': // Bot's "Please enter your Phone number". Only meaningful when we // initiated phone-login (state already awaiting_phone). From any // other state — including the late-arriving prompt after a cancel // — drop it on the floor. return state.kind === 'awaiting_phone' ? state : state; case 'awaiting_code': // Plausible after submitting phone, or as a re-prompt within the // code form. Late arrival after cancel/connected/logging_out is // ignored to avoid resurrecting dead flows. if (!acceptsCodePrompt(state)) return state; if (state.kind === 'awaiting_phone') return { kind: 'awaiting_code' }; return state; case 'awaiting_password': // Pure re-prompt arm. The TRANSITION to awaiting_password is driven // by `twofa_required` (or `wrong_password`), not by this event. // Ignored when we're not already on the password form. if (!acceptsPasswordReprompt(state)) return state; return state; case 'twofa_required': // First of the two-reply 2fa transition. Valid after a code submit // (phone-flow path) AND after a successful QR scan (the bridge // skips straight from QR redaction to «You have two-factor // authentication enabled.»). Ignored from disconnected/connected // and from awaiting_phone (where it'd indicate a bridge bug). if (!acceptsTwofa(state) && !acceptsQrScanTwofa(state)) return state; return { kind: 'awaiting_password' }; case 'invalid_code': if (state.kind !== 'awaiting_code') return state; return { kind: 'awaiting_code', lastError: { kind: 'invalid_code' } }; case 'wrong_password': if (state.kind !== 'awaiting_password') return state; return { kind: 'awaiting_password', lastError: { kind: 'wrong_password' } }; case 'login_success': // Always honour — even if state somehow drifted, the bridge says we're in. return { kind: 'connected', handle: event.handle, numericId: event.numericId, // loginId is unknown until the post-success list-logins fires // (App.tsx). Until then, logout is gated. }; case 'logout_ok': // Late `Logged out` from a previous session can arrive while the user // is mid-new-flow (e.g. they cancelled, started login again, and the // old logout's reply finally lands). Only honour from logging_out; // other states keep their flow. if (state.kind !== 'logging_out') return state; return { kind: 'disconnected' }; case 'cancel_ok': case 'cancel_no_op': // The App's `cancel_pending` action ALWAYS optimistically lands us // in `disconnected` before the bot's confirmation arrives. So a // legitimate cancel-reply naturally finds state === 'disconnected' // — accepting it then is a safe idempotent no-op transition. // // From ANY other state (awaiting_*, connected, logging_out, // unknown), the cancel reply is stale: the user has either started // a new flow (state already moved on) or never cancelled in this // widget session at all. Letting it through would clobber an // active flow — exactly the race the reviewer flagged: cancel + // immediate re-login = late cancel_ok kicking awaiting_phone // back to disconnected. // // (Out-of-band manual `!tg cancel` typed in chat-fallback while // the widget shows an active form would also be ignored. That's // accepted scope: we don't run a causality/epoch system, and the // chat-fallback flow is an escape hatch, not a primary surface.) if (state.kind !== 'disconnected') return state; return { kind: 'disconnected' }; case 'login_in_progress': // Surfaces when the user clicked Войти по номеру but the bridge // already has a stale flow open. Form-level warning if a form is // open; otherwise dropped so we don't manufacture a disconnected // banner from nothing. if (isFormState(state)) { return { ...state, lastError: { kind: 'login_in_progress' } }; } return state; case 'max_logins': // Should not fire for max_logins=1 operators when our UI hides // login while connected. If it does fire, the user is in a race; // surface the error on disconnected so they can logout first. return { kind: 'disconnected', lastError: { kind: 'max_logins', limit: event.limit } }; case 'login_not_found': // Logout target id was wrong. Treat as disconnected — bridge clearly // doesn't know that login id any more. return { kind: 'disconnected' }; case 'invalid_value': // Bridge rejected our submitted phone/code/password (e.g. malformed // phone). Keep the form open with an error; if no form is open, // ignore so we don't pollute disconnected state. if (!isFormState(state)) return state; return { ...state, lastError: { kind: 'invalid_value', reason: event.reason } }; case 'submit_failed': // Telegram-side error (FloodWait, banned, etc.) leaked through // bridgev2's commands layer. Hold the current form open so the user // can retry; surface the verbatim Go error tail in the warning. if (!isFormState(state)) return state; return { ...state, lastError: { kind: 'submit_failed', reason: event.reason } }; case 'prepare_failed': return { kind: 'disconnected', lastError: { kind: 'prepare_failed', reason: event.reason } }; case 'start_failed': return { kind: 'disconnected', lastError: { kind: 'start_failed', reason: event.reason } }; case 'login_failed': // bridgev2/commands/login.go:366 sends `Login failed: ` after // the display-and-wait branch's `login.Wait()` returns. The error // string we get here splits cleanly in two: // // 1. `context canceled` — fires whenever a `!tg cancel` tears // down a running login flow. ALWAYS a no-op for our state: // it's an echo of OUR cancel (or of an auto-cancel during a // cancel-race recovery). If we transitioned to disconnected // here, a stale «context canceled» from a previous flow // could clobber a brand-new QR flow the user just started — // observed in prod 2026-05-04 logs as a state-flapping loop. // // 2. anything else (most commonly `login process timed out` // after the 10-min server-side LoginTimeout) — real failure // of the live flow; route to disconnected with the warning. if (event.reason === 'context canceled') return state; if (state.kind === 'disconnected') return state; return { kind: 'disconnected', lastError: { kind: 'start_failed', reason: event.reason } }; case 'flow_required': case 'flow_invalid': // We always send `login phone` so this shouldn't happen. If it does, // the operator-config / bridge mismatch is loud enough to fail // visibly on the disconnected screen. return { kind: 'disconnected', lastError: { kind: 'start_failed', reason: 'flow' } }; case 'unknown_command': // Shouldn't happen — we only send commands the bridge knows. If it // does, the operator-config / bridge image is mismatched; surface it // loudly on the disconnected screen so the misconfig is visible. return { kind: 'disconnected', lastError: { kind: 'unknown_command' } }; case 'qr_displayed': { // `qrEventId` tracks the ORIGINAL bridge event — bridgev2 emits the // QR as a single `m.image`, then on each token rotation (every ~30 s // per Telegram MTProto QR-auth spec) edits the SAME event with // `m.relates_to.rel_type=m.replace` + `m.relates_to.event_id=`. // The eventual redaction also targets the original. So we only ever // bind to the original id and repaint tgUrl on edits. // Defence-in-depth: an inbound `qr_displayed` MUST carry a non-empty // event id (otherwise an adversarial bridge / spoofed event could // land in the placeholder slot and never be dislodged because every // subsequent check would also see empty ids). The parser produces // `eventId: event.event_id` and the host driver rejects events with // empty event_id at the sanitizer; this is a redundant guard. if (event.eventId.length === 0) return state; // Initial QR for this flow — set both anchors. We accept from: // * `unknown` — cold-start before list-logins resolves; // * placeholder `awaiting_qr_scan{qrEventId=''}` set optimistically // by `start_qr_login`; // * `disconnected` — handles bridgev2's startup race. If the user // clicks Cancel while bridge is still in `auth_key generation` // (~2 s), the cancel arrives BEFORE the bridge's CommandState // is registered, so it replies «No ongoing command» (cancel_no_op, // state→disconnected via cancel_pending). Bridge then continues // with the original login as if cancel never happened, and a // few seconds later emits the m.image. Accepting from // `disconnected` re-surfaces that QR so the user can either scan // it or click Cancel again (this time the bridge has a real // CommandState and the cancel will actually take). REJECTING // here causes the user to be stuck on a disconnected screen // while the bridge is happily hosting a 10-min QR-display-and- // wait — bad UX, observed on production 2026-05-04. if ( state.kind === 'unknown' || state.kind === 'disconnected' || (state.kind === 'awaiting_qr_scan' && state.qrEventId === '') ) { return { kind: 'awaiting_qr_scan', tgUrl: event.tgUrl, qrEventId: event.eventId, firstShownAt: state.kind === 'awaiting_qr_scan' && state.firstShownAt ? state.firstShownAt : Date.now(), }; } if (state.kind !== 'awaiting_qr_scan') return state; // Rotation edit pointing at our original — repaint tgUrl, keep id. if (event.replacesEventId === state.qrEventId) { return { ...state, tgUrl: event.tgUrl }; } // A fresh non-edit qr_displayed while we're already tracking one. // Could be the bridge restarting the QR-login internally (rare). // Adopt the new event as the new anchor — the old one will be // either redacted or simply abandoned by the bridge. if (!event.replacesEventId) { return { kind: 'awaiting_qr_scan', tgUrl: event.tgUrl, qrEventId: event.eventId, firstShownAt: Date.now(), }; } // Edit pointing at something we don't track — ignore. Don't let // foreign edits or stale-on-redacted events destabilise the panel. return state; } case 'qr_redacted': { // Bridge cleaned up the QR after a successful scan. Held as // `qr_verifying` until the next signal (twofa_required or // login_success) lands. Only honour from awaiting_qr_scan with a // matching event id — a redaction targeting some unrelated event // (or a redaction arriving while we're already past the QR step) // must not destabilise the current state. if (state.kind !== 'awaiting_qr_scan') return state; if (state.qrEventId !== event.redactsEventId) return state; return { kind: 'qr_verifying' }; } case 'unknown': return state; default: { // Exhaustiveness check — if a new LoginEvent kind is added without a // case, TypeScript will flag this as a compile error. const exhaustive: never = event; return exhaustive; } } }; // --- M12.5 hydrate-from-timeline ----------------------------------------- // // Why a separate reducer: the live `loginReducer` above intentionally rejects // "out-of-thin-air" prompt events to defend against late-arriving replies // from cancelled flows resurrecting forms. Those gates are correct for live // traffic but useless for hydrate — from `unknown` they would drop every // awaiting_* prompt and we'd render nothing. // // The hydrate reducer is permissive: it walks bot replies in chronological // order and lets each one freely transition the state. We trust the timeline // because (a) the sender is filtered to bootstrap.botMxid by the caller and // (b) the events are durable bridge writes, not arbitrary user input. // // Hard scope: hydrate only ever returns one of awaiting_phone / awaiting_code // / awaiting_password (with optional lastError) or null. Terminal-ish final // states (login_success, logout_ok, cancel_*, not_logged_in, max_logins, // login_not_found, prepare_failed, start_failed, flow_*, unknown_command) // always fall back to null so App.tsx fires `list-logins` for authoritative // reconciliation. // UX freshness guard, NOT bridge truth. Bridgev2's CommandState has no // explicit TTL — it lives in User.CommandState until submit/cancel/restart // clears it, so a "stale" prompt could in fact still be active at the bridge. // This window is the UX-side judgement call: a 10-min-old code prompt is // worth resurfacing (Telegram's own SMS code TTL ~5 min, plus margin), a // day-old one is not. Tuned for code/password forms; phone is cheap enough // that the same window applies. const HYDRATE_FRESHNESS_MS = 10 * 60 * 1000; export type HydrateInput = { ev: LoginEvent; // origin_server_ts of the underlying m.notice. Used for the freshness // check on the LAST significant pending prompt only. ts: number; }; type HydrateAccumulator = { state: LoginState; // Timestamp of the most recent event that contributed to a non-unknown, // non-terminal pending state. Drives the freshness gate. pendingTs: number | null; // Once a terminal event lands, we stop honouring later pending prompts in // the same scan — terminal collapses the chain and any subsequent pending // prompt is a fresh flow that the live `list-logins` will reconcile. terminated: boolean; }; // Apply one event with permissive rules. Unlike the live reducer, every // transition is allowed from any predecessor — we're rebuilding past truth, // not protecting against late races. const stepHydrate = ( prevAcc: HydrateAccumulator, input: HydrateInput ): HydrateAccumulator => { const { ev, ts } = input; // After a terminal event (cancel_ok / logout_ok / login_success / …) we // normally stop tracking — anything that follows is by definition a fresh // flow that the live `list-logins` will reconcile. EXCEPT for two cases: // if `awaiting_phone` shows up, that IS the bridgev2 signature of `!tg // login phone` being re-issued; if `qr_displayed` shows up, that's the // same pattern for `!tg login qr`. The user cancelled (or finished) and // is now logging in again; the chain should resume tracking from the new // start. Without this re-entry, sequences like // [awaiting_code, cancel_ok, awaiting_phone, awaiting_code] // (cancel-then-restart, mid-code) would return null and regress the very // M12.5 bug we set out to fix. if (prevAcc.terminated && ev.kind !== 'awaiting_phone' && 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 'awaiting_phone': return { state: { kind: 'awaiting_phone' }, pendingTs: ts, terminated: false }; case 'awaiting_code': return { state: { kind: 'awaiting_code' }, pendingTs: ts, terminated: false }; case 'awaiting_password': return { state: { kind: 'awaiting_password' }, pendingTs: ts, terminated: false }; case 'twofa_required': return { state: { kind: 'awaiting_password' }, pendingTs: ts, terminated: false }; case 'qr_displayed': { // Same anchor logic as the live reducer: qrEventId tracks the // ORIGINAL event, edits repaint tgUrl. In hydrate we always start // from `unknown` and walk past→present, so the original is the // first qr_displayed without a `replacesEventId` we've already // adopted. if (acc.state.kind !== 'awaiting_qr_scan') { return { state: { kind: 'awaiting_qr_scan', tgUrl: ev.tgUrl, qrEventId: ev.eventId, firstShownAt: ts, }, pendingTs: ts, terminated: false, }; } if (ev.replacesEventId === acc.state.qrEventId) { return { state: { ...acc.state, tgUrl: ev.tgUrl }, pendingTs: ts, terminated: false, }; } if (!ev.replacesEventId) { return { state: { kind: 'awaiting_qr_scan', tgUrl: ev.tgUrl, qrEventId: ev.eventId, firstShownAt: ts, }, pendingTs: ts, terminated: false, }; } return acc; } case 'qr_redacted': { // QR was consumed by a successful scan in the past. NOT terminal — // a 2FA prompt or login_success typically follows in the same // scan window, and reload-after-scan-but-before-2FA-submit MUST // restore the password form (otherwise the user reloads, sees // `disconnected`, list-logins replies «You're not logged in» // because the bridge hasn't completed login yet, and the user // has to restart the QR flow from scratch — losing the scan). // Move into `qr_verifying` (interstitial) and keep the chain // open so subsequent twofa_required / awaiting_password can lift // us into the password form. if (acc.state.kind !== 'awaiting_qr_scan') return acc; if (acc.state.qrEventId !== ev.redactsEventId) return acc; return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false }; } case 'invalid_code': return { state: { kind: 'awaiting_code', lastError: { kind: 'invalid_code' } }, pendingTs: ts, terminated: false, }; case 'wrong_password': return { state: { kind: 'awaiting_password', lastError: { kind: 'wrong_password' } }, pendingTs: ts, terminated: false, }; case 'invalid_value': // Form-scoped soft error: only surface if we're already on a form. if (!isFormState(acc.state)) return acc; return { state: { ...acc.state, lastError: { kind: 'invalid_value', reason: ev.reason } }, pendingTs: ts, terminated: false, }; case 'submit_failed': if (!isFormState(acc.state)) return acc; return { state: { ...acc.state, lastError: { kind: 'submit_failed', reason: ev.reason } }, pendingTs: ts, terminated: false, }; case 'login_failed': // `context canceled` is an echo of a previous cancel — never a // terminal signal for the chain we're hydrating, since the chain // can immediately re-enter via a fresh `qr_displayed` / `awaiting_phone` // for a new flow. Treat as a no-op so the chain keeps walking. if (ev.reason === 'context canceled') return acc; return { state: acc.state, pendingTs: null, terminated: true }; // Terminal events — collapse the chain. The state at this point is // whatever-the-bot-confirmed-last; we don't care which, the caller // returns null and lets `list-logins` reconcile. case 'login_success': case 'logout_ok': case 'cancel_ok': case 'cancel_no_op': case 'not_logged_in': case 'max_logins': case 'login_not_found': case 'prepare_failed': case 'start_failed': case 'flow_required': case 'flow_invalid': case 'unknown_command': return { state: acc.state, pendingTs: null, terminated: true }; case 'logins_listed': // A list-logins reply landed in history. Empty list means user wasn't // logged in at that point; non-empty means they were. Both are // terminal-ish for hydrate purposes — fall through to live // reconciliation rather than guess at validity from cached snapshot. return { state: acc.state, pendingTs: null, terminated: true }; case 'login_in_progress': case 'unknown': // Soft no-op for hydrate. login_in_progress is a live-flow warning // that doesn't reflect persistent state; unknown is a wording-drift // catch-all — neither should advance hydrate state. return acc; default: { const exhaustive: never = ev; return exhaustive; } } }; export const hydrateFromTimeline = ( inputs: ReadonlyArray, now: number = Date.now() ): HydrateRestoredState | null => { const acc = inputs.reduce(stepHydrate, { state: { kind: 'unknown' }, pendingTs: null, terminated: false, }); if (acc.terminated) return null; if (acc.pendingTs === null) return null; if (now - acc.pendingTs > HYDRATE_FRESHNESS_MS) return null; if (acc.state.kind === 'qr_verifying') return acc.state; if (!isFormState(acc.state)) return null; return acc.state; }; // --- DEV sanity assertions ------------------------------------------------ // Mirrors the dialect-side runSanityChecks pattern — failure throws, dev // overlay surfaces it on reload, production builds tree-shake the branch. 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; // 1 minute after t0 const cases: Array<{ name: string; inputs: HydrateInput[]; expected: LoginState | null; nowOverride?: number; }> = [ { name: 'empty timeline → null', inputs: [], expected: null, }, { name: 'lone phone prompt → awaiting_phone', inputs: [{ ev: { kind: 'awaiting_phone' }, ts: recent(0) }], expected: { kind: 'awaiting_phone' }, }, { name: 'phone → code → awaiting_code', inputs: [ { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, { ev: { kind: 'awaiting_code' }, ts: recent(1000) }, ], expected: { kind: 'awaiting_code' }, }, { name: '2fa pair → awaiting_password', inputs: [ { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, { ev: { kind: 'awaiting_code' }, ts: recent(1000) }, { ev: { kind: 'twofa_required' }, ts: recent(2000) }, { ev: { kind: 'awaiting_password' }, ts: recent(2100) }, ], expected: { kind: 'awaiting_password' }, }, { name: 'invalid_code retry → awaiting_code with error', inputs: [ { ev: { kind: 'awaiting_code' }, ts: recent(0) }, { ev: { kind: 'invalid_code' }, ts: recent(1000) }, { ev: { kind: 'awaiting_code' }, ts: recent(1100) }, ], expected: { kind: 'awaiting_code' }, // re-prompt clears the error — same as live reducer }, { name: 'invalid_code as final event → awaiting_code with error', inputs: [ { ev: { kind: 'awaiting_code' }, ts: recent(0) }, { ev: { kind: 'invalid_code' }, ts: recent(1000) }, ], expected: { kind: 'awaiting_code', lastError: { kind: 'invalid_code' } }, }, { name: 'wrong_password as final event → awaiting_password with error', inputs: [ { ev: { kind: 'awaiting_password' }, ts: recent(0) }, { ev: { kind: 'wrong_password' }, ts: recent(1000) }, ], expected: { kind: 'awaiting_password', lastError: { kind: 'wrong_password' } }, }, { name: 'login_success after pending → null (let list-logins reconcile)', inputs: [ { ev: { kind: 'awaiting_password' }, ts: recent(0) }, { ev: { kind: 'login_success', handle: '@x', numericId: '1' }, ts: recent(1000), }, ], expected: null, }, { name: 'cancel_ok after pending → null', inputs: [ { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, { ev: { kind: 'cancel_ok' }, ts: recent(1000) }, ], expected: null, }, { name: 'not_logged_in alone → null', inputs: [{ ev: { kind: 'not_logged_in' }, ts: recent(0) }], expected: null, }, { name: 'awaiting_phone after terminal → restart chain (resume tracking)', inputs: [ { ev: { kind: 'awaiting_code' }, ts: recent(0) }, { ev: { kind: 'cancel_ok' }, ts: recent(1000) }, { ev: { kind: 'awaiting_phone' }, ts: recent(2000) }, ], expected: { kind: 'awaiting_phone' }, }, { name: 'cancel-then-restart-mid-code → awaiting_code (the reviewer-#11 regression)', inputs: [ { ev: { kind: 'awaiting_code' }, ts: recent(0) }, { ev: { kind: 'cancel_ok' }, ts: recent(1000) }, { ev: { kind: 'awaiting_phone' }, ts: recent(2000) }, { ev: { kind: 'awaiting_code' }, ts: recent(3000) }, ], expected: { kind: 'awaiting_code' }, }, { name: 'logout-then-relogin-mid-password → awaiting_password', inputs: [ { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, { ev: { kind: 'awaiting_code' }, ts: recent(1000) }, { ev: { kind: 'login_success', handle: '@x', numericId: '1' }, ts: recent(2000) }, { ev: { kind: 'logout_ok' }, ts: recent(3000) }, { ev: { kind: 'awaiting_phone' }, ts: recent(4000) }, { ev: { kind: 'awaiting_code' }, ts: recent(5000) }, { ev: { kind: 'twofa_required' }, ts: recent(6000) }, { ev: { kind: 'awaiting_password' }, ts: recent(6100) }, ], expected: { kind: 'awaiting_password' }, }, { name: 'stale prompt without preceding awaiting_phone → still terminated → null', inputs: [ { ev: { kind: 'awaiting_code' }, ts: recent(0) }, { ev: { kind: 'cancel_ok' }, ts: recent(1000) }, { ev: { kind: 'awaiting_code' }, ts: recent(2000) }, ], expected: null, }, { name: 'pending too old → null (freshness guard)', inputs: [{ ev: { kind: 'awaiting_code' }, ts: t0 - 30 * 60 * 1000 }], expected: null, }, { name: 'pending just inside window → state', inputs: [{ ev: { kind: 'awaiting_code' }, ts: t0 - 9 * 60 * 1000 }], expected: { kind: 'awaiting_code' }, nowOverride: t0, }, { name: 'submit_failed on pending → keeps form with warn', inputs: [ { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, { ev: { kind: 'submit_failed', reason: 'PHONE_NUMBER_BANNED' }, ts: recent(1000) }, ], expected: { kind: 'awaiting_phone', lastError: { kind: 'submit_failed', reason: 'PHONE_NUMBER_BANNED' }, }, }, { name: 'login_in_progress alone → null (soft no-op, no pending state)', inputs: [{ ev: { kind: 'login_in_progress' }, ts: recent(0) }], expected: null, }, { name: 'unknown alone → null', inputs: [{ ev: { kind: 'unknown' }, ts: recent(0) }], expected: null, }, // QR-login hydrate cases (M13) { name: 'lone qr_displayed → awaiting_qr_scan', inputs: [ { ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=A', eventId: '$qrA' }, ts: recent(0), }, ], expected: { kind: 'awaiting_qr_scan', tgUrl: 'tg://login?token=A', qrEventId: '$qrA', firstShownAt: recent(0), }, }, { name: 'qr rotation edits → repaint url, keep original event id', inputs: [ { ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=A', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=B', eventId: '$qrEdit1', replacesEventId: '$qrA', }, ts: recent(30000), }, { ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=C', eventId: '$qrEdit2', replacesEventId: '$qrA', }, ts: recent(60000), }, ], expected: { kind: 'awaiting_qr_scan', tgUrl: 'tg://login?token=C', qrEventId: '$qrA', firstShownAt: recent(0), }, }, { name: 'qr_redacted with mismatched target → ignored', inputs: [ { ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=A', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'qr_redacted', redactsEventId: '$other' }, ts: recent(30000) }, ], expected: { kind: 'awaiting_qr_scan', tgUrl: 'tg://login?token=A', qrEventId: '$qrA', firstShownAt: recent(0), }, }, { name: 'qr scan → twofa pair → awaiting_password (mid-flow reload restores the password form)', inputs: [ { ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=A', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, { ev: { kind: 'twofa_required' }, ts: recent(31000) }, { ev: { kind: 'awaiting_password' }, ts: recent(31100) }, ], // qr_redacted moves into qr_verifying without terminating the // chain — twofa_required + awaiting_password follow and lift the // chain into the password form. Without this the user would // reload mid-2FA and lose the QR scan progress. expected: { kind: 'awaiting_password' }, }, { name: 'qr scan → no follow-up → qr_verifying (reload during the 1 s gap)', inputs: [ { ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=A', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, ], // No twofa_required / login_success in this scan — bridge hasn't // emitted the next signal yet. Restore qr_verifying so the user // sees the «Проверяем вход…» pill on reload instead of a flash // of disconnected. expected: { kind: 'qr_verifying' }, }, { name: 'qr scan → login_success → null (terminal — let list-logins reconcile)', inputs: [ { ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=A', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, { ev: { kind: 'login_success', handle: '@x', numericId: '1' }, ts: recent(31000), }, ], expected: null, }, { name: 'cancel-then-qr-restart → awaiting_qr_scan', inputs: [ { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, { ev: { kind: 'cancel_ok' }, ts: recent(1000) }, { ev: { kind: 'qr_displayed', tgUrl: 'tg://login?token=Z', eventId: '$qrZ' }, ts: recent(2000), }, ], expected: { kind: 'awaiting_qr_scan', tgUrl: 'tg://login?token=Z', qrEventId: '$qrZ', firstShownAt: recent(2000), }, }, ]; 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); }