From 316c3eb9fd26522cd4523f146a4e9724ed9bf472 Mon Sep 17 00:00:00 2001 From: heaven Date: Sun, 3 May 2026 02:36:17 +0300 Subject: [PATCH] feat(bots-telegram): land M12.5 timeline-resume hydrate to recover pending login forms after widget reload via read_events scan --- apps/widget-telegram/src/App.tsx | 132 +++++++-- apps/widget-telegram/src/i18n/en.ts | 3 +- apps/widget-telegram/src/i18n/ru.ts | 3 +- apps/widget-telegram/src/state.ts | 395 ++++++++++++++++++++++++- apps/widget-telegram/src/widget-api.ts | 21 ++ 5 files changed, 517 insertions(+), 37 deletions(-) diff --git a/apps/widget-telegram/src/App.tsx b/apps/widget-telegram/src/App.tsx index 7c94acae..66c87658 100644 --- a/apps/widget-telegram/src/App.tsx +++ b/apps/widget-telegram/src/App.tsx @@ -6,8 +6,10 @@ import { WidgetApi, type RoomEvent } from './widget-api'; import { createT, type T } from './i18n'; import { parseReply } from './bridge-protocol/parser'; import { + hydrateFromTimeline, initialLoginState, loginReducer, + type HydrateInput, type LoginAction, type LoginErrorFlag, type LoginState, @@ -586,16 +588,18 @@ export function App({ bootstrap, api }: Props) { }); }, []); - // Pin the transcript to the bottom whenever a new line lands. Bot replies - // are append-only so this is the right default; users who scroll up will - // be snapped back on the next message — acceptable here because the - // transcript is a passive log, not a primary reading surface (forms and - // status pills are above it). + // Newest-at-top ordering: pin the scroll to the TOP whenever a new line + // lands. The data structure stays chronologically appended (oldest first + // in the array — required by the slice(-TRANSCRIPT_MAX) cap and by the + // hydrate replay's seenEventIds dedupe); only the render reverses it. + // Users who scroll down to read older context get snapped back to top on + // the next message — acceptable here because the transcript is a passive + // log, not a primary reading surface (forms and status pills are above). const transcriptRef = useRef(null); useEffect(() => { const el = transcriptRef.current; if (!el) return; - el.scrollTop = el.scrollHeight; + el.scrollTop = 0; }, [transcript.length]); // App-level cooldown state for phone-form Send Code button. Lifted out of @@ -629,16 +633,96 @@ export function App({ bootstrap, api }: Props) { // remount: bundle parses near-instantly and the host's capabilities // request can resolve before this useEffect runs). useEffect(() => { + // Guard for the async hydrate IIFE inside the ready handler — flipped + // by the cleanup function. We don't return a Promise from the effect + // (Preact's useEffect expects a void/cleanup return), so the async + // work runs in a self-invoking wrapper that bails on unmount. + let disposed = false; + api.on('ready', () => { setHandshakeOk(true); append({ kind: 'diag', text: t('diag.ready') }); append({ kind: 'diag', text: t('diag.checking-status') }); - api.sendCommand('list-logins').catch((err) => { - append({ - kind: 'error', - text: t('diag.send-failed', { message: (err as Error).message }), - }); - }); + + void (async () => { + // M12.5 timeline-resume: scan the recent room history BEFORE firing + // list-logins. bridgev2's list-logins doesn't surface pending + // CommandState — if the user reloads mid-«Please enter your Code», + // a list-logins probe replies «You're not logged in» and the UI + // helpfully offers a fresh login button, losing the SMS code. + // Reading the timeline lets us restore the form the user actually + // had open. Hydrate is read-only and never sends commands; only + // bot m.notice events drive state, never user m.text. + let hydrated = false; + try { + const events = await api.readTimeline({ limit: 30, msgtype: 'm.notice' }); + if (disposed) return; + + // Driver returns events newest-first; reverse to chronological so + // the hydrate reducer can walk past→present. + const fromBot = events.filter((e) => e.sender === bootstrap.botMxid).reverse(); + const inputs: HydrateInput[] = fromBot.map((e) => ({ + ev: parseReply(e.content.body ?? ''), + ts: e.origin_server_ts, + })); + const restored = hydrateFromTimeline(inputs); + + if (restored) { + // Conservative transcript replay: ONLY bot m.notice lines, plus + // a trailing marker. User m.text is intentionally NOT replayed — + // bridgev2 does not redact 2FA codes server-side, and replaying + // user echoes would surface a code from history that the + // original submit had masked locally. Bot prompts alone give + // enough context for the user to know what step they're on. + // + // Dedupe via seenEventIds: a live event for the same notice may + // already have arrived during the readTimeline await (the host + // pushes new bot replies via send_event as soon as they hit the + // room). Skipping seen ids in this loop avoids the duplicate + // line, AND the .add side-effect simultaneously pre-seeds the + // set so any post-hydrate live replay of these same notices + // (matrix-js-sdk timeline forward-push) is suppressed too. + // + // Append order: bot lines first, THEN the marker. With the + // newest-at-top render reversal, the marker visually sits ABOVE + // the historical bot block (acting as a divider between live + // activity and replayed history). The marker is gated on at + // least one bot line actually being appended — otherwise it + // would float on its own labelling nothing. + let appendedAnyHistory = false; + for (const e of fromBot) { + if (seenEventIds.current.has(e.event_id)) continue; + seenEventIds.current.add(e.event_id); + append({ kind: 'from-bot', text: `← ${e.content.body ?? ''}` }); + appendedAnyHistory = true; + } + if (appendedAnyHistory) { + append({ kind: 'diag', text: t('diag.history-marker') }); + } + + dispatch({ kind: 'hydrate', state: restored }); + hydrated = true; + } + } catch { + // Driver / capability / network failure — fall through to live + // list-logins probe. We surface a soft diag line so a persistent + // failure is visible in logs without blocking the user. + if (!disposed) { + append({ kind: 'diag', text: t('diag.history-unavailable') }); + } + } + + if (disposed) return; + if (!hydrated) { + api.sendCommand('list-logins').catch((err) => { + if (disposed) return; + append({ + kind: 'error', + text: t('diag.send-failed', { message: (err as Error).message }), + }); + }); + } + })(); }); api.on('themeChange', (name) => setTheme(name)); @@ -679,6 +763,7 @@ export function App({ bootstrap, api }: Props) { append({ kind: 'diag', text: t('diag.connecting') }); return () => { + disposed = true; // App-level unmount tears down the iframe window entirely (host // detaches the iframe DOM node), so dispose just clears pending // request promises. Don't null `apiRef.current` — `api` is a @@ -921,19 +1006,24 @@ export function App({ bootstrap, api }: Props) { ) : null}
-
{transcript.length === 0 ? (
{/* placeholder kept blank intentionally */}
) : ( - transcript.map((line) => ( -
- {formatTime(line.ts)} - - {line.kind === 'from-bot' ? renderBody(line.text) : line.text} - -
- )) + // Render newest-first by reversing a shallow copy. The source + // array is kept chronological — see the comment on the + // scroll-to-top effect above. + transcript + .slice() + .reverse() + .map((line) => ( +
+ {formatTime(line.ts)} + + {line.kind === 'from-bot' ? renderBody(line.text) : line.text} + +
+ )) )}
diff --git a/apps/widget-telegram/src/i18n/en.ts b/apps/widget-telegram/src/i18n/en.ts index e910271e..aa259049 100644 --- a/apps/widget-telegram/src/i18n/en.ts +++ b/apps/widget-telegram/src/i18n/en.ts @@ -9,7 +9,6 @@ export const EN: Record = { 'status.connected': 'Telegram linked', 'status.connected-as': 'Telegram linked as {handle}', 'status.logging-out': 'Signing out…', - 'section.transcript': 'Logs', 'card.login.name': '/login', 'card.login.desc': 'By phone number', 'card.refresh.aria': 'Refresh status', @@ -61,6 +60,8 @@ export const EN: Record = { 'diag.ready': 'Ready to send commands.', 'diag.checking-status': 'Checking connection status…', 'diag.send-failed': 'send failed: {message}', + 'diag.history-marker': '─── history ───', + 'diag.history-unavailable': 'Could not read history — re-checking status.', 'bootstrap.failed': 'Widget failed to start', 'bootstrap.missing-params': 'Missing required URL params: {names}.', 'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.', diff --git a/apps/widget-telegram/src/i18n/ru.ts b/apps/widget-telegram/src/i18n/ru.ts index 08d49ea5..82d860fd 100644 --- a/apps/widget-telegram/src/i18n/ru.ts +++ b/apps/widget-telegram/src/i18n/ru.ts @@ -19,7 +19,6 @@ export const RU = { 'status.connected-as': 'Telegram привязан как {handle}', 'status.logging-out': 'Завершение сеанса…', // --- Section headers --------------------------------------------------- - 'section.transcript': 'Логи', 'card.login.name': '/login', // Card desc is descriptive (noun-style), not a third call-to-action — the // section status «Войдите в Telegram» already carries the imperative. @@ -79,6 +78,8 @@ export const RU = { 'diag.ready': 'Готов отправлять команды.', 'diag.checking-status': 'Проверяю статус подключения…', 'diag.send-failed': 'ошибка отправки: {message}', + 'diag.history-marker': '─── история ───', + 'diag.history-unavailable': 'Не удалось прочитать историю — проверяю статус заново.', // --- Bootstrap failure ------------------------------------------------- 'bootstrap.failed': 'Widget не запустился', 'bootstrap.missing-params': 'Отсутствуют обязательные параметры URL: {names}.', diff --git a/apps/widget-telegram/src/state.ts b/apps/widget-telegram/src/state.ts index 6a93c560..9fd012e3 100644 --- a/apps/widget-telegram/src/state.ts +++ b/apps/widget-telegram/src/state.ts @@ -30,6 +30,14 @@ export type LoginErrorFlag = | { kind: 'max_logins'; limit?: number } | { kind: 'unknown_command' }; +// A live form is open and waiting for user input. M12.5's hydrate path +// can ONLY ever produce one of these — every other final state falls +// through to live `list-logins` reconciliation. +export type PendingFormState = + | { kind: 'awaiting_phone'; lastError?: LoginErrorFlag } + | { kind: 'awaiting_code'; lastError?: LoginErrorFlag } + | { kind: 'awaiting_password'; lastError?: LoginErrorFlag }; + export type LoginState = // Pre-handshake / pre-list-logins. Status pill: --faint. | { kind: 'unknown' } @@ -37,15 +45,11 @@ export type LoginState = // (disconnected = needs action). | { kind: 'disconnected'; lastError?: LoginErrorFlag } // After "Войти по номеру" — waiting for `Please enter your Phone number`. - // Status pill: --amber (in flight). - | { kind: 'awaiting_phone'; lastError?: LoginErrorFlag } // After phone submit — waiting for code prompt OR error reply. - // Status pill: --amber. - | { kind: 'awaiting_code'; lastError?: LoginErrorFlag } // 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. - | { kind: 'awaiting_password'; lastError?: LoginErrorFlag } + // password retry. Status pill: --amber for all three. + | PendingFormState // logout in flight — waiting for `Logged out`. Status pill: --amber. | { kind: 'logging_out'; loginId: string } // Live session. login carries the parsed handle/numericId from @@ -69,7 +73,8 @@ export type LoginAction = | { 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: 'cancel_pending' } // user clicked "Отмена" + | { kind: 'hydrate'; state: PendingFormState }; // M12.5 timeline-resume seed export const initialLoginState: LoginState = { kind: 'unknown' }; @@ -103,16 +108,25 @@ 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. -const isFormState = ( - s: LoginState -): s is - | { kind: 'awaiting_phone'; lastError?: LoginErrorFlag } - | { kind: 'awaiting_code'; lastError?: LoginErrorFlag } - | { kind: 'awaiting_password'; lastError?: LoginErrorFlag } => +// 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'; 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' }; } @@ -308,3 +322,356 @@ export const loginReducer = (state: LoginState, action: LoginAction): LoginState } } }; + +// --- 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 = (acc: 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 one case: + // if `awaiting_phone` shows up, that IS the bridgev2 signature of `!tg + // login phone` being re-issued. 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 (acc.terminated && ev.kind !== 'awaiting_phone') { + return acc; + } + + 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 '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, + }; + + // Terminal events — collapse the chain. The state at this point is + // whatever-the-bot-confirmed-last; we don't care which, the caller + // returns null and lets `list-logins` reconcile. + case 'login_success': + case 'logout_ok': + case 'cancel_ok': + case 'cancel_no_op': + case 'not_logged_in': + case 'max_logins': + case 'login_not_found': + case 'prepare_failed': + case 'start_failed': + case 'flow_required': + case 'flow_invalid': + case 'unknown_command': + return { state: acc.state, pendingTs: null, terminated: true }; + + case 'logins_listed': + // A list-logins reply landed in history. Empty list means user wasn't + // logged in at that point; non-empty means they were. Both are + // terminal-ish for hydrate purposes — fall through to live + // reconciliation rather than guess at validity from cached snapshot. + return { state: acc.state, pendingTs: null, terminated: true }; + + case 'login_in_progress': + case 'unknown': + // Soft no-op for hydrate. login_in_progress is a live-flow warning + // that doesn't reflect persistent state; unknown is a wording-drift + // catch-all — neither should advance hydrate state. + return acc; + + default: { + const exhaustive: never = ev; + return exhaustive; + } + } +}; + +export const hydrateFromTimeline = ( + inputs: ReadonlyArray, + now: number = Date.now() +): PendingFormState | null => { + const acc = inputs.reduce(stepHydrate, { + state: { kind: 'unknown' }, + pendingTs: null, + terminated: false, + }); + + if (acc.terminated) return null; + if (!isFormState(acc.state)) return null; + if (acc.pendingTs === null) return null; + if (now - acc.pendingTs > HYDRATE_FRESHNESS_MS) 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, + }, + ]; + + 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); +} diff --git a/apps/widget-telegram/src/widget-api.ts b/apps/widget-telegram/src/widget-api.ts index 5314601b..80f433a5 100644 --- a/apps/widget-telegram/src/widget-api.ts +++ b/apps/widget-telegram/src/widget-api.ts @@ -113,6 +113,27 @@ export class WidgetApi { return this.sendText(body); } + // M12.5 timeline-resume probe. Action name is MSC2876 (`read_events`); the + // capability is MSC2762 timeline (already requested at construction). We + // pass `room_ids: [bootstrap.roomId]` explicitly so the host's + // ClientWidgetApi takes the modern code path that calls our driver's + // `readRoomTimeline` (single-room cap-checked) rather than the deprecated + // `readRoomEvents` fallback. Driver returns events newest-first; reversing + // to chronological order is the caller's job. + public async readTimeline(opts: { + limit: number; + msgtype?: 'm.text' | 'm.notice'; + }): Promise { + const data: Record = { + type: 'm.room.message', + limit: opts.limit, + room_ids: [this.bootstrap.roomId], + }; + if (opts.msgtype !== undefined) data.msgtype = opts.msgtype; + const res = await this.fromWidget('org.matrix.msc2876.read_events', data); + return (res.events as RoomEvent[] | undefined) ?? []; + } + private emit( event: K, ...args: Parameters