feat(bots-telegram): land M12.5 timeline-resume hydrate to recover pending login forms after widget reload via read_events scan
This commit is contained in:
parent
691eb8530a
commit
9576e7d879
5 changed files with 517 additions and 37 deletions
|
|
@ -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<HTMLDivElement>(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}
|
||||
|
||||
<section class="section">
|
||||
<h2 class="section-label">{t('section.transcript')}</h2>
|
||||
<div ref={transcriptRef} class="transcript" role="log" aria-live="polite">
|
||||
{transcript.length === 0 ? (
|
||||
<div class="transcript-empty">{/* placeholder kept blank intentionally */}</div>
|
||||
) : (
|
||||
transcript.map((line) => (
|
||||
<div key={line.id} class={`transcript-line ${line.kind}`}>
|
||||
<span class="ts">{formatTime(line.ts)}</span>
|
||||
<span class="body">
|
||||
{line.kind === 'from-bot' ? renderBody(line.text) : line.text}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
// 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) => (
|
||||
<div key={line.id} class={`transcript-line ${line.kind}`}>
|
||||
<span class="ts">{formatTime(line.ts)}</span>
|
||||
<span class="body">
|
||||
{line.kind === 'from-bot' ? renderBody(line.text) : line.text}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export const EN: Record<StringKey, string> = {
|
|||
'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<StringKey, string> = {
|
|||
'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}.',
|
||||
|
|
|
|||
|
|
@ -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}.',
|
||||
|
|
|
|||
|
|
@ -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<HydrateInput>,
|
||||
now: number = Date.now()
|
||||
): PendingFormState | null => {
|
||||
const acc = inputs.reduce<HydrateAccumulator>(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RoomEvent[]> {
|
||||
const data: Record<string, unknown> = {
|
||||
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<K extends keyof WidgetApiEvents>(
|
||||
event: K,
|
||||
...args: Parameters<WidgetApiEvents[K]>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue