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

1026 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 +<phone>`: 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 +<phone>`, plus the loginId we need for
// `!wa logout <id>`.
| {
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 = "+<phone>"
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 "+<phone-number>"; 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: <err>` 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=<original>`.
//
// 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<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;
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);
}