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:
v.lagerev 2026-05-03 02:36:17 +03:00
parent 691eb8530a
commit 9576e7d879
5 changed files with 517 additions and 37 deletions

View file

@ -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>

View file

@ -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}.',

View file

@ -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}.',

View file

@ -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);
}

View file

@ -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]>