// Login state machine — consumes LoginEvent (one per inbound bridge bot // reply) and emits a typed UI state. The widget renders forms / QR panel / // pairing-code panel / status pill from this state, never from raw reply // strings. // // WhatsApp vs Telegram differences (both bridgev2): // - TWO login flows: `qr` and `phone` (pairing code). The widget always // sends the full `login qr` / `login phone` command — never bare // `login` (which would trigger a flow_required reply). // - NO 2FA cloud password — multidevice handshake is single-factor. // The reducer has no `awaiting_password` / `twofa_required` arms. // - QR data is a raw whatsmeow handshake (not a URL) — handled by // parser, reducer just carries the opaque string. // - QR rotation: 60 s for first QR, 5 more × 20 s. Total active window // 2 min 40 s (vs Telegram's 10 min). Hydrate freshness window // correspondingly tightened to 3 min. // - Pairing code: NEW intermediate states (`awaiting_pairing_code`, // `pairing_code_shown`). The bridge replies in two notices — // instructions then code — so the reducer flips through both. // - Login success format `Successfully logged in as +`: handle // IS the phone number, no separate numericId. // - Async session events: `external_logout` flips disconnected with a // warn flag; `connection_warning` is transcript-only (state untouched). import type { LoginEvent, ListedLogin, ExternalLogoutReason } from './bridge-protocol/types'; export type LoginErrorFlag = // login_failed reasons (connector-side errors all funnel through here). // We don't sub-classify by reason text — upstream wording is structured // enough that the user can read the reason verbatim. | { kind: 'login_failed'; reason?: string } | { kind: 'invalid_value'; reason?: string } | { kind: 'submit_failed'; reason?: string } | { kind: 'prepare_failed'; reason?: string } | { kind: 'start_failed'; reason?: string } | { kind: 'login_in_progress' } | { kind: 'max_logins'; limit?: number } | { kind: 'unknown_command' } | { kind: 'external_logout'; reason: ExternalLogoutReason }; // A live form is open and waiting for user input. WhatsApp ships THREE: // - phone-number form (pairing-code flow only) // - QR-scan panel (qr flow) // - pairing-code shown (phone flow, after the bridge generated a code) // Plus an `awaiting_pairing_code` interstitial — we know the user submitted // a phone, the bridge accepted it, and we're waiting for the code to land. export type PendingFormState = | { kind: 'awaiting_phone'; lastError?: LoginErrorFlag } | { kind: 'awaiting_pairing_code'; lastError?: LoginErrorFlag } | { kind: 'pairing_code_shown'; code: string; firstShownAt: number; lastError?: LoginErrorFlag; } | { kind: 'awaiting_qr_scan'; qrData: 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, OR external_logout // landed. Status pill: --rose. lastError carries the most recent // structured error (including external_logout reason). | { kind: 'disconnected'; lastError?: LoginErrorFlag } | PendingFormState // QR was redacted (i.e. the bridge accepted a scan), but we don't yet // know whether the phone-side handshake completed. Held as a spinner // until the next bridge signal arrives. NOT terminal — `login_success` // flips to `connected`. | { kind: 'qr_verifying' } // Pairing-code accepted by phone, waiting for login_success. WhatsApp // doesn't redact the code message (no analog to QR redaction), so this // state is reached optimistically by the App when the code-shown panel // sees its own success wait window run out OR when the user explicitly // confirms. M-discord uses `qr_verifying` for a similar gap. Reserved // here in case future versions of mautrix-whatsapp redact the code on // success — the live reducer would still need somewhere to land. | { kind: 'pairing_verifying' } // logout in flight — waiting for `Logged out`. Status pill: --amber. | { kind: 'logging_out'; loginId: string } // Live session. login carries the phone-number handle parsed from // `Successfully logged in as +`, plus the loginId we need for // `!wa logout `. | { kind: 'connected'; handle: string; loginId?: string; }; // States that the hydrate path can restore after a reload. Equals // PendingFormState (live forms waiting for input) plus interstitials // (`qr_verifying`, `pairing_verifying`) for the brief gap between // scan-accept and the next bridge signal. Other transient states // (logging_out) 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 `list-logins`. export type HydrateRestoredState = | PendingFormState | { kind: 'qr_verifying' } | { kind: 'pairing_verifying' }; // Outbound user actions the App dispatches. export type LoginAction = | { kind: 'event'; event: LoginEvent } | { kind: 'start_qr_login' } // user clicked «Войти по QR-коду» | { kind: 'start_phone_login' } // user clicked «Войти по коду из приложения» | { kind: 'submit_phone' } // user clicked submit on phone form | { kind: 'request_logout'; loginId: string } // user clicked «Выйти» | { kind: 'cancel_pending' } // user clicked «Отмена» | { kind: 'hydrate'; state: HydrateRestoredState }; export const initialLoginState: LoginState = { kind: 'unknown' }; const pickConnected = (logins: ListedLogin[]): LoginState => { if (logins.length === 0) return { kind: 'disconnected' }; // M-WA 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. const [first] = logins; return { kind: 'connected', handle: first.name, // RemoteName = "+" loginId: first.id, }; }; // Whether step-scoped errors (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. const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiting_phone' || s.kind === 'awaiting_pairing_code' || s.kind === 'pairing_code_shown' || s.kind === 'awaiting_qr_scan'; export const loginReducer = (state: LoginState, action: LoginAction): LoginState => { if (action.kind === 'hydrate') { // 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. If a live event has already moved us off // `unknown`, the live truth wins; the cached timeline snapshot is by // definition older. if (state.kind !== 'unknown') return state; return action.state; } if (action.kind === 'start_qr_login') { // Optimistic placeholder QR-scan state. The actual qr_displayed event // overwrites qrData / qrEventId / firstShownAt. If the // `!wa login qr` send fails, the App rolls back to disconnected. // // `firstShownAt: 0` here (not Date.now()) so the QR-window countdown // starts when the bridge actually ships the FIRST QR — not when the // user clicked. Bridge takes 1-3 s to connect to whatsmeow + emit // the first code; using the click time eats that off the user's // visible 3-min window. QrPanel reads `firstShownAt > 0 ? ... : 0` // and renders the countdown only once a real QR has landed. return { kind: 'awaiting_qr_scan', qrData: '', qrEventId: '', firstShownAt: 0, }; } if (action.kind === 'start_phone_login') { return { kind: 'awaiting_phone' }; } if (action.kind === 'submit_phone') { // Stay on the phone form until the bot confirms with the pairing-code // instructions. Optimistic transition to awaiting_pairing_code would // mis-surface a phone-side error (e.g. `Phone number too short`) // on the wrong panel. if (state.kind === 'awaiting_phone') { return { kind: 'awaiting_phone', 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': // 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. if ( state.kind === 'unknown' || state.kind === 'disconnected' || state.kind === 'logging_out' || state.kind === 'qr_verifying' || state.kind === 'pairing_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 a late-arriving prompt after a cancel // — drop it on the floor. return state; case 'pairing_code_instructions': // First of two notices after a phone submit. Plausible only when // we're on the phone form OR already in the pairing-code // interstitial (re-prompt scenario, defensive). Late arrival from // a cancelled flow (user cancel + bridge already submitted phone) // is dropped — the reducer doesn't resurrect dead flows. if (state.kind === 'awaiting_phone') { return { kind: 'awaiting_pairing_code' }; } if (state.kind === 'awaiting_pairing_code') { return state; } return state; case 'pairing_code_displayed': { // Second of the two notices — the actual XXXX-XXXX. Plausible // from awaiting_pairing_code (the normal post-submit flow) OR // from awaiting_phone (defensive — if the instructions notice // was missed/dropped on the wire, the code itself is the // operative signal). Also accept from pairing_code_shown to // tolerate the bridge re-emitting the code (rare). const accepts = state.kind === 'awaiting_phone' || state.kind === 'awaiting_pairing_code' || state.kind === 'pairing_code_shown'; if (!accepts) return state; return { kind: 'pairing_code_shown', code: event.code, firstShownAt: state.kind === 'pairing_code_shown' && state.firstShownAt > 0 ? state.firstShownAt : Date.now(), }; } case 'login_success': // Always honour — even if state somehow drifted, the bridge says // we're in. handle is "+"; loginId is unknown until // the post-success list-logins fires (App.tsx). return { kind: 'connected', handle: event.handle, }; case 'logout_ok': // Late `Logged out` from a previous session can arrive while the // user is mid-new-flow. Only honour from logging_out. 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. // // 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. if (state.kind !== 'disconnected') return state; return { kind: 'disconnected' }; case 'login_in_progress': 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 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 (e.g. malformed). Keep the // form open with an error; if no form is open, ignore. if (!isFormState(state)) return state; return { ...state, lastError: { kind: 'invalid_value', reason: event.reason } }; case 'submit_failed': // WhatsApp-side error (Phone number too short, rate limited, etc.) // leaked through bridgev2's commands layer. Hold the current form // open so the user can retry; surface the verbatim Go error tail. 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 sends `Login failed: ` after the // display-and-wait branch's `login.Wait()` returns. For WhatsApp // every connector RespError funnels through here. // // `context canceled` is an echo of OUR cancel — always a no-op. // Anything else is a real failure (most commonly `Entering code or // scanning QR timed out. Please try again.` after the 2 min 40 s // window expires) — route to disconnected with the warning. We // gate on form/QR/pairing states so a stale `login_failed` from a // previous flow can't clobber a fresh one. if (event.reason === 'context canceled') return state; if (state.kind === 'disconnected') return state; if ( state.kind === 'connected' || state.kind === 'logging_out' || state.kind === 'unknown' ) { return state; } return { kind: 'disconnected', lastError: { kind: 'login_failed', reason: event.reason }, }; case 'flow_required': case 'flow_invalid': // We always send `login qr` / `login phone` so this shouldn't // happen. Visible if /config.json's commandPrefix drifted from // the bridge's actual command_prefix or if a chat-fallback typist // sent bare `!wa login`. Surface on disconnected — but only if // we're not already connected. From `connected` the live session // is intact and a chat-fallback typist sending bare `login` // shouldn't clobber it (functional review #15). if (state.kind === 'connected') return state; 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 is mismatched. return { kind: 'disconnected', lastError: { kind: 'unknown_command' } }; case 'qr_displayed': { // Same anchor logic as the Telegram widget: `qrEventId` tracks the // ORIGINAL bridge event. bridgev2 emits the QR as a single // `m.image`, then on each rotation (per whatsmeow `qrIntervals`: // 60 s + 5 × 20 s) edits the SAME event with // `m.relates_to.rel_type=m.replace` + `event_id=`. // // Defence-in-depth: an inbound qr_displayed MUST carry a non-empty // event id (otherwise an adversarial event could land in the // placeholder slot and never be dislodged). The host driver // sanitizer rejects empty event_id; this is redundant. if (event.eventId.length === 0) return state; // Initial QR for this flow — 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 connecting to // whatsmeow, the cancel arrives BEFORE CommandState is // registered, replying cancel_no_op, and the bridge emits // the QR anyway. We accept ONLY a fresh non-edit QR from // `disconnected` — a `replacesEventId` here means a stale // rotation from a flow we already cancelled (race functional // review #5: edit with replaces=$qrA arrives after Cancel, // we'd otherwise adopt the EDIT's event_id as a new anchor // and the subsequent redaction targeting $qrA would be // ignored). Drop edits in that situation. if (event.replacesEventId && state.kind === 'disconnected') return state; if ( state.kind === 'unknown' || state.kind === 'disconnected' || (state.kind === 'awaiting_qr_scan' && state.qrEventId === '') ) { return { kind: 'awaiting_qr_scan', qrData: event.qrData, 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 anchor — repaint qrData, keep id. if (event.replacesEventId === state.qrEventId) { return { ...state, qrData: event.qrData }; } // Fresh non-edit qr_displayed while we're already tracking one — // could be a bridge restart of QR-login internally (rare; e.g. // the bridge dropped the original event due to AS retry path). // Adopt as new anchor BUT preserve the existing firstShownAt so // the user-facing countdown doesn't reset (functional review #1: // some edit-encoder paths can drop `m.relates_to`, which would // otherwise pin firstShownAt to Date.now() every 20 s and the // panel would never expire visibly). if (!event.replacesEventId) { return { kind: 'awaiting_qr_scan', qrData: event.qrData, qrEventId: event.eventId, firstShownAt: state.firstShownAt > 0 ? state.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. Held as // `qr_verifying` until the next signal lands. if (state.kind !== 'awaiting_qr_scan') return state; if (state.qrEventId !== event.redactsEventId) return state; return { kind: 'qr_verifying' }; } case 'external_logout': // WhatsApp lost its session externally (phone unlinked, another // device kicked us, or the bridge lost auth on startup). Hard // route to disconnected with the structured reason — the App // surfaces a louder warn banner than ordinary form-side errors. // Honour from any state because the bridge is authoritative // about its own session loss. return { kind: 'disconnected', lastError: { kind: 'external_logout', reason: event.reason }, }; case 'connection_warning': // Soft warning — surface in transcript only (App-level append), // state untouched. The bridge is still operational. return state; 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 ----------------------------------------------- // // Same shape as the Telegram widget: walks bot replies in chronological // order, permissively transitions state (no out-of-thin-air rejection // because we trust durable timeline writes from a known sender). // // Hard scope: hydrate returns one of awaiting_phone / // awaiting_pairing_code / pairing_code_shown / awaiting_qr_scan with // optional lastError, OR qr_verifying / pairing_verifying interstitial, // OR null. Terminal-ish events (login_success, logout_ok, cancel_*, // not_logged_in, max_logins, login_not_found, prepare/start_failed, // flow_*, unknown_command, external_logout) collapse the chain to null // so App.tsx fires `list-logins` for authoritative reconciliation. // 3 minutes — covers the 2 min 40 s active QR window from whatsmeow's // qrIntervals (60 s + 5 × 20 s) plus a small safety margin. Pairing-code // server-side validity at WhatsApp's gateway is similar (~3 min); we // share the same window. A reload past this point falls through to // live list-logins. const HYDRATE_FRESHNESS_MS = 3 * 60 * 1000; export type HydrateInput = { ev: LoginEvent; // origin_server_ts of the underlying bridge event. 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` reconciles. terminated: boolean; }; const stepHydrate = ( prevAcc: HydrateAccumulator, input: HydrateInput ): HydrateAccumulator => { const { ev, ts } = input; // After a terminal event we normally stop tracking. Re-entry exception // for `awaiting_phone` (re-issued `!wa login phone`) and FRESH // `qr_displayed` (re-issued `!wa 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 // [pairing_code_shown, cancel_ok, qr_displayed] // would return null and regress an active flow. // // ROTATION-EDIT GUARD: a `qr_displayed` carrying `replacesEventId` // is by definition an edit of an EARLIER QR — never a fresh flow's // first QR. Mirrors the live reducer's guard against late rotations // landing after Cancel: without this, a stale edit arriving 30 s // post-cancel would resurrect a phantom QR panel that survives a // page reload (until the freshness window expires). const isFreshQrEntry = ev.kind === 'qr_displayed' && !ev.replacesEventId; if ( prevAcc.terminated && ev.kind !== 'awaiting_phone' && !isFreshQrEntry ) { 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 'pairing_code_instructions': return { state: { kind: 'awaiting_pairing_code' }, pendingTs: ts, terminated: false }; case 'pairing_code_displayed': { // Anchor on the first appearance — keep firstShownAt stable across // re-emissions in the same scan window (the bridge shouldn't // re-emit the same code, but if it does, we don't want to reset // the countdown). const firstShownAt = acc.state.kind === 'pairing_code_shown' && acc.state.firstShownAt > 0 ? acc.state.firstShownAt : ts; return { state: { kind: 'pairing_code_shown', code: ev.code, firstShownAt, }, pendingTs: ts, terminated: false, }; } case 'qr_displayed': { // Same anchor logic as the live reducer. if (acc.state.kind !== 'awaiting_qr_scan') { return { state: { kind: 'awaiting_qr_scan', qrData: ev.qrData, qrEventId: ev.eventId, firstShownAt: ts, }, pendingTs: ts, terminated: false, }; } if (ev.replacesEventId === acc.state.qrEventId) { return { state: { ...acc.state, qrData: ev.qrData }, pendingTs: ts, terminated: false, }; } if (!ev.replacesEventId) { return { state: { kind: 'awaiting_qr_scan', qrData: ev.qrData, 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 — login_success // typically follows in the same scan window. return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false }; } case 'invalid_value': 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. State becomes whatever the // bot confirmed last; 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': case 'external_logout': return { state: acc.state, pendingTs: null, terminated: true }; case 'logins_listed': // A list-logins reply landed in history — terminal-ish for hydrate. return { state: acc.state, pendingTs: null, terminated: true }; case 'login_in_progress': case 'connection_warning': case 'unknown': // Soft no-ops for hydrate. login_in_progress is a live-flow // warning that doesn't reflect persistent state; connection_warning // is a transcript-only signal; unknown is a wording-drift catch-all. 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 (acc.state.kind === 'pairing_verifying') 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 phone prompt → awaiting_phone', inputs: [{ ev: { kind: 'awaiting_phone' }, ts: recent(0) }], expected: { kind: 'awaiting_phone' }, }, { name: 'phone + pairing-code instructions → awaiting_pairing_code', inputs: [ { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, { ev: { kind: 'pairing_code_instructions' }, ts: recent(1000) }, ], expected: { kind: 'awaiting_pairing_code' }, }, { name: 'phone + instructions + code → pairing_code_shown', inputs: [ { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, { ev: { kind: 'pairing_code_instructions' }, ts: recent(1000) }, { ev: { kind: 'pairing_code_displayed', code: 'ABCD-1234' }, ts: recent(1100), }, ], expected: { kind: 'pairing_code_shown', code: 'ABCD-1234', firstShownAt: recent(1100), }, }, { name: 'phone + code only (instructions notice missed) → pairing_code_shown', inputs: [ { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, { ev: { kind: 'pairing_code_displayed', code: 'WXYZ-9876' }, ts: recent(1000), }, ], expected: { kind: 'pairing_code_shown', code: 'WXYZ-9876', firstShownAt: recent(1000), }, }, { name: 'lone qr_displayed → awaiting_qr_scan', inputs: [ { ev: { kind: 'qr_displayed', qrData: '2@A,b,c,d', eventId: '$qrA' }, ts: recent(0), }, ], expected: { kind: 'awaiting_qr_scan', qrData: '2@A,b,c,d', qrEventId: '$qrA', firstShownAt: recent(0), }, }, { name: 'qr rotation edits → repaint payload, keep original event id', inputs: [ { ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'qr_displayed', qrData: '2@B,a,b,c', eventId: '$qrEdit1', replacesEventId: '$qrA', }, ts: recent(60_000), }, { ev: { kind: 'qr_displayed', qrData: '2@C,a,b,c', eventId: '$qrEdit2', replacesEventId: '$qrA', }, ts: recent(80_000), }, ], expected: { kind: 'awaiting_qr_scan', qrData: '2@C,a,b,c', qrEventId: '$qrA', firstShownAt: recent(0), }, }, { name: 'qr_redacted with mismatched target → ignored', inputs: [ { ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'qr_redacted', redactsEventId: '$other' }, ts: recent(30000) }, ], expected: { kind: 'awaiting_qr_scan', qrData: '2@A,a,b,c', qrEventId: '$qrA', firstShownAt: recent(0), }, }, { name: 'qr scan → no follow-up → qr_verifying', inputs: [ { ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, ], expected: { kind: 'qr_verifying' }, }, { name: 'qr scan → login_success → null (let list-logins reconcile)', inputs: [ { ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) }, { ev: { kind: 'login_success', handle: '+12345678901' }, ts: recent(31000), }, ], 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: 'cancel-then-restart-mid-pairing → awaiting_pairing_code', inputs: [ { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, { ev: { kind: 'pairing_code_instructions' }, ts: recent(1000) }, { ev: { kind: 'cancel_ok' }, ts: recent(2000) }, { ev: { kind: 'awaiting_phone' }, ts: recent(3000) }, { ev: { kind: 'pairing_code_instructions' }, ts: recent(4000) }, ], expected: { kind: 'awaiting_pairing_code' }, }, // Hydrate-side analog of live reducer's «late rotation after Cancel» // guard: a rotation edit with replacesEventId in the chain after // a cancel_ok (terminal) must NOT resurrect tracking. The terminal // gate already handles this — only `qr_displayed` and // `awaiting_phone` re-enter, but we explicitly only re-enter on a // FRESH (non-edit) qr_displayed. Cover with a sanity case. { name: 'cancel + late rotation edit → null (no resurrect from edit)', inputs: [ { ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, ts: recent(0), }, { ev: { kind: 'cancel_ok' }, ts: recent(30_000) }, // Late rotation edit pointing at the cancelled flow's QR. The // hydrate accumulator is `terminated`, and re-entry only fires // for fresh (no replacesEventId) qr_displayed. With // replacesEventId set, the entry is ignored and we stay // terminated → null. { ev: { kind: 'qr_displayed', qrData: '2@B,a,b,c', eventId: '$qrEdit', replacesEventId: '$qrA', }, ts: recent(31_000), }, ], expected: null, }, { name: 'logout-then-relogin-mid-qr → awaiting_qr_scan', inputs: [ { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, { ev: { kind: 'login_success', handle: '+12345678901' }, ts: recent(2000), }, { ev: { kind: 'logout_ok' }, ts: recent(3000) }, { ev: { kind: 'qr_displayed', qrData: '2@Z,a,b,c', eventId: '$qrZ' }, ts: recent(4000), }, ], expected: { kind: 'awaiting_qr_scan', qrData: '2@Z,a,b,c', qrEventId: '$qrZ', firstShownAt: recent(4000), }, }, { name: 'pending too old (5 min) → null (3-min freshness window)', inputs: [ { ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, ts: t0 - 5 * 60 * 1000, }, ], expected: null, nowOverride: t0, }, { name: 'pending just inside window (2 min) → state', inputs: [ { ev: { kind: 'qr_displayed', qrData: '2@A,a,b,c', eventId: '$qrA' }, ts: t0 - 2 * 60 * 1000, }, ], expected: { kind: 'awaiting_qr_scan', qrData: '2@A,a,b,c', qrEventId: '$qrA', firstShownAt: t0 - 2 * 60 * 1000, }, nowOverride: t0, }, { name: 'submit_failed on phone form → keeps form with warn', inputs: [ { ev: { kind: 'awaiting_phone' }, ts: recent(0) }, { ev: { kind: 'submit_failed', reason: 'Phone number too short' }, ts: recent(1000), }, ], expected: { kind: 'awaiting_phone', lastError: { kind: 'submit_failed', reason: 'Phone number too short' }, }, }, { name: 'login_in_progress alone → null (soft no-op)', inputs: [{ ev: { kind: 'login_in_progress' }, ts: recent(0) }], expected: null, }, { name: 'connection_warning alone → null (transcript-only)', inputs: [ { ev: { kind: 'connection_warning', text: 'Reconnecting to WhatsApp...' }, ts: recent(0), }, ], expected: null, }, { name: 'external_logout alone → null (terminal — let list-logins reconcile)', inputs: [ { ev: { kind: 'external_logout', reason: 'phone_logged_out' }, 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); }