1026 lines
38 KiB
TypeScript
1026 lines
38 KiB
TypeScript
// 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);
|
||
}
|