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