765 lines
30 KiB
TypeScript
765 lines
30 KiB
TypeScript
// Login state machine — consumes LoginEvent (one per inbound bridge bot
|
|
// reply) and emits a typed UI state. The widget renders the QR panel and
|
|
// the status pill from this state, never from raw reply strings.
|
|
//
|
|
// Discord vs Telegram differences:
|
|
// - QR-only: there's no phone/code/password ladder, so the state space
|
|
// is much smaller than the Telegram reducer.
|
|
// - status comes from `ping` (legacy mautrix command system), not
|
|
// `list-logins` (bridgev2). The four ping replies map to four states:
|
|
// disconnected / connected / connection_dead / token_stored.
|
|
// - no list-logins-derived `loginId`; logout is the bare `logout` verb,
|
|
// so the connected state doesn't need to gate on a login id.
|
|
// - the QR is NOT rotated by Discord remoteauth (single image per
|
|
// login attempt). The state machine still tracks `qrEventId` so the
|
|
// redaction handler can match against it and ignore unrelated cleanup.
|
|
//
|
|
// State-gating policy: late-arriving replies from cancelled flows must
|
|
// not resurrect dead state. The `cancel_pending` action ALWAYS lands us
|
|
// in `disconnected` immediately; later bridge events arriving after
|
|
// cancel are filtered by the live reducer.
|
|
|
|
import type { LoginEvent } from './bridge-protocol/types';
|
|
|
|
export type LoginErrorFlag =
|
|
| { kind: 'login_failed'; reason?: string }
|
|
| { kind: 'captcha_required' }
|
|
| { kind: 'login_websocket_failed'; reason?: string }
|
|
| { kind: 'connect_after_login_failed'; reason?: string }
|
|
| { kind: 'prepare_login_failed'; reason?: string }
|
|
| { kind: 'already_logged_in' }
|
|
| { kind: 'unknown_command' };
|
|
// `reconnect_failed` is intentionally NOT a LoginErrorFlag arm: the live
|
|
// reducer routes that event back to `connected_dead` (no error surface
|
|
// there — the connected-dead pill IS the error indicator) without
|
|
// staging a reason for `localizeError`. If a future UI change wants to
|
|
// surface the reason, add `lastError?: ...` to the connected_dead state
|
|
// shape and route `reconnect_failed` through it.
|
|
|
|
// A live form is open and waiting for user action. M-discord ships with
|
|
// only one: the QR panel. Hydrate's restorable shape collapses to this
|
|
// single variant + the `qr_verifying` interstitial.
|
|
export type PendingFormState = {
|
|
kind: 'awaiting_qr_scan';
|
|
discordUrl: string;
|
|
qrEventId: string;
|
|
firstShownAt: number;
|
|
lastError?: LoginErrorFlag;
|
|
};
|
|
|
|
export type LoginState =
|
|
// Pre-handshake / pre-ping. Status pill: --faint.
|
|
| { kind: 'unknown' }
|
|
// ping returned `not_logged_in`, OR logout completed. Status pill:
|
|
// --rose. The card grid offers the QR-login affordance.
|
|
| { kind: 'disconnected'; lastError?: LoginErrorFlag }
|
|
// QR-login in progress. Optimistically transitioned by `start_qr_login`;
|
|
// overwritten with real discordUrl/qrEventId by the live `qr_displayed`
|
|
// event. Status pill: --amber.
|
|
| PendingFormState
|
|
// QR was redacted (i.e. the bridge accepted a scan), but we don't yet
|
|
// know whether login succeeded. Held as an intermediate spinner until
|
|
// the next bridge signal arrives. Status pill: --amber.
|
|
| { kind: 'qr_verifying' }
|
|
// logout in flight — waiting for `Logged out successfully.`. Status
|
|
// pill: --amber.
|
|
| { kind: 'logging_out' }
|
|
// reconnect in flight (recovery from connection_dead / token_stored).
|
|
// Waiting for `Successfully reconnected` or `You're already connected`.
|
|
// Status pill: --amber. `handle` is carried through from the
|
|
// connected_dead state so a successful reconnect can flip directly to
|
|
// `connected{handle}` without bouncing through a transient `unknown`
|
|
// (which would briefly paint a faint «Проверка статуса…» pill — bad
|
|
// UX immediately after the user took an action).
|
|
| { kind: 'reconnecting'; handle?: string }
|
|
// Live session — ping or login_success confirmed. Discord legacy bridge
|
|
// doesn't have a per-account loginId concept (single Discord account
|
|
// per Matrix user), so logout doesn't need an id.
|
|
| { kind: 'connected'; handle: string; discordId?: string }
|
|
// ping says we have a token but the connection's down. Status pill:
|
|
// green-ish but with a Reconnect recovery action exposed. The reducer
|
|
// distinguishes `connection_dead` (Discord WS dropped) from `token_stored`
|
|
// (we have the token but never got far enough to connect), but the UI
|
|
// collapses both into the same shape — they share the recovery path.
|
|
| { kind: 'connected_dead'; reason: 'connection_dead' | 'token_stored'; handle?: string };
|
|
|
|
// States that the hydrate path can restore after a reload. The QR panel
|
|
// (`awaiting_qr_scan`) survives reloads via the m.image / m.room.redaction
|
|
// timeline; `qr_verifying` covers the post-scan pre-success interstitial.
|
|
// Other transient states (logging_out, reconnecting) deliberately don't
|
|
// survive — those are tied to live in-flight commands and would feel
|
|
// stuck on reload; the hydrate path falls through to live ping.
|
|
export type HydrateRestoredState =
|
|
| PendingFormState
|
|
| { kind: 'qr_verifying' };
|
|
|
|
// Outbound user actions the App dispatches. Form-submit actions clear any
|
|
// pending lastError; structural transitions 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_qr_login' } // user clicked «Войти по QR»
|
|
| { kind: 'request_logout' } // user clicked «Выйти из Discord»
|
|
// user clicked «Переподключиться» — App passes the current handle
|
|
// (from `connected_dead.handle` or `connected.handle`) so the
|
|
// transient `reconnecting` state carries it forward; without this the
|
|
// post-reconnect_ok branch can't paint the connected pill until the
|
|
// follow-up ping resolves.
|
|
| { kind: 'request_reconnect'; handle?: string }
|
|
// Discord legacy mautrix has no `cancel` command. Cancel is LOCAL —
|
|
// returns the widget to disconnected immediately; the bridge's
|
|
// remoteauth websocket eventually times out on its own. The action is
|
|
// kept symmetrical with TG's reducer for shape consistency, but
|
|
// dispatching it doesn't trigger any send.
|
|
| { kind: 'cancel_pending' }
|
|
| { kind: 'hydrate'; state: HydrateRestoredState };
|
|
|
|
export const initialLoginState: LoginState = { kind: 'unknown' };
|
|
|
|
const isFormState = (s: LoginState): s is PendingFormState => s.kind === 'awaiting_qr_scan';
|
|
|
|
export const loginReducer = (state: LoginState, action: LoginAction): LoginState => {
|
|
if (action.kind === 'hydrate') {
|
|
// hydrate is a one-shot mount-time seed. If a live event 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_qr_scan` from a previous
|
|
// session could overwrite a legitimate `connected` that arrived
|
|
// during the readTimeline await.
|
|
if (state.kind !== 'unknown') return state;
|
|
return action.state;
|
|
}
|
|
if (action.kind === 'start_qr_login') {
|
|
// Optimistic placeholder; the live `qr_displayed` event overwrites
|
|
// discordUrl + qrEventId + firstShownAt. If the `!discord login-qr`
|
|
// send fails, the App rolls back to `disconnected`.
|
|
return {
|
|
kind: 'awaiting_qr_scan',
|
|
discordUrl: '',
|
|
qrEventId: '',
|
|
firstShownAt: Date.now(),
|
|
};
|
|
}
|
|
if (action.kind === 'request_logout') {
|
|
return { kind: 'logging_out' };
|
|
}
|
|
if (action.kind === 'request_reconnect') {
|
|
return { kind: 'reconnecting', handle: action.handle };
|
|
}
|
|
if (action.kind === 'cancel_pending') {
|
|
// Optimistic: drop straight back to disconnected. Discord legacy mautrix
|
|
// has no `cancel` command — the bridge's remoteauth websocket continues
|
|
// until it succeeds or times out internally. From the user's POV the
|
|
// widget returns to disconnected, and any later QR redaction / login
|
|
// success / login failure event from the abandoned flow is filtered
|
|
// by the per-event gates below (qr_redacted gated on awaiting_qr_scan,
|
|
// login_success / login_failed gated on awaiting_qr_scan|qr_verifying).
|
|
return { kind: 'disconnected' };
|
|
}
|
|
|
|
const event = action.event;
|
|
switch (event.kind) {
|
|
// --- ping replies ----------------------------------------------------
|
|
|
|
case 'not_logged_in':
|
|
// Accept from states where flipping to disconnected is correct.
|
|
// Late-arriving `not_logged_in` MUST NOT clobber an active QR-scan
|
|
// (which was started after the ping was fired but before the reply
|
|
// landed) — that's the same race the TG reducer guards against.
|
|
if (
|
|
state.kind === 'unknown' ||
|
|
state.kind === 'disconnected' ||
|
|
state.kind === 'logging_out' ||
|
|
state.kind === 'qr_verifying' ||
|
|
state.kind === 'reconnecting' ||
|
|
state.kind === 'connected_dead'
|
|
) {
|
|
return { kind: 'disconnected' };
|
|
}
|
|
return state;
|
|
|
|
case 'logged_in':
|
|
// Authoritative source — accept from any state. Used by both the
|
|
// initial ping AND the post-`login_success` re-ping that picks up
|
|
// the discordId snowflake.
|
|
return {
|
|
kind: 'connected',
|
|
handle: event.handle,
|
|
discordId: event.discordId,
|
|
};
|
|
|
|
case 'connection_dead':
|
|
// ping says token's good but the WS is down. Show the connected
|
|
// chrome with a Reconnect recovery action.
|
|
return {
|
|
kind: 'connected_dead',
|
|
reason: 'connection_dead',
|
|
handle: state.kind === 'connected' ? state.handle : undefined,
|
|
};
|
|
|
|
case 'token_stored_not_connected':
|
|
return {
|
|
kind: 'connected_dead',
|
|
reason: 'token_stored',
|
|
handle: state.kind === 'connected' ? state.handle : undefined,
|
|
};
|
|
|
|
// --- QR lifecycle ----------------------------------------------------
|
|
|
|
case 'qr_displayed': {
|
|
// Defence-in-depth: an inbound qr_displayed MUST carry a non-empty
|
|
// event id (the host driver rejects empty event_id at the sanitizer;
|
|
// this is a redundant guard).
|
|
if (event.eventId.length === 0) return state;
|
|
|
|
// Initial QR from a fresh login attempt — accept from:
|
|
// * `unknown` — cold-start before ping resolves;
|
|
// * placeholder `awaiting_qr_scan{qrEventId=''}` from start_qr_login.
|
|
//
|
|
// We DO NOT accept from `disconnected`. Discord legacy mautrix has
|
|
// no cancel command, so when the user clicks Cancel locally the
|
|
// bridge's remoteauth goroutine continues until success / failure
|
|
// / internal timeout. The widget transitions to `disconnected`
|
|
// immediately, but the bridge eventually emits the m.image. If we
|
|
// accepted that here, the user would see a QR they didn't ask for
|
|
// — the bridge has no way to know the user moved on. Drop it
|
|
// silently; the user has to click «Войти по QR» again to express
|
|
// intent (which resets the placeholder and lets the next m.image
|
|
// land).
|
|
if (
|
|
state.kind === 'unknown' ||
|
|
(state.kind === 'awaiting_qr_scan' && state.qrEventId === '')
|
|
) {
|
|
return {
|
|
kind: 'awaiting_qr_scan',
|
|
discordUrl: event.discordUrl,
|
|
qrEventId: event.eventId,
|
|
firstShownAt:
|
|
state.kind === 'awaiting_qr_scan' && state.firstShownAt
|
|
? state.firstShownAt
|
|
: Date.now(),
|
|
};
|
|
}
|
|
|
|
if (state.kind !== 'awaiting_qr_scan') return state;
|
|
|
|
// Hypothetical edit pointing at our anchor — repaint URL, keep id.
|
|
// Discord doesn't currently edit QRs but the path stays for
|
|
// forward-compat (cheaper to keep than to reconstruct).
|
|
if (event.replacesEventId === state.qrEventId) {
|
|
return { ...state, discordUrl: event.discordUrl };
|
|
}
|
|
|
|
// Fresh non-edit qr_displayed while we're already tracking one —
|
|
// could be a bridge-side restart (rare). Adopt as new anchor.
|
|
if (!event.replacesEventId) {
|
|
return {
|
|
kind: 'awaiting_qr_scan',
|
|
discordUrl: event.discordUrl,
|
|
qrEventId: event.eventId,
|
|
firstShownAt: Date.now(),
|
|
};
|
|
}
|
|
|
|
// Edit pointing at something we don't track — ignore.
|
|
return state;
|
|
}
|
|
|
|
case 'qr_redacted': {
|
|
// Bridge cleaned up the QR after a successful scan (commands.go
|
|
// l.197: `_, _ = ce.MainIntent().RedactEvent(ce.RoomID, qrCodeEvent)`
|
|
// — only fires on the success path). Held as `qr_verifying` until
|
|
// the success line lands. Only honour from awaiting_qr_scan with a
|
|
// matching event id.
|
|
if (state.kind !== 'awaiting_qr_scan') return state;
|
|
if (state.qrEventId !== event.redactsEventId) return state;
|
|
return { kind: 'qr_verifying' };
|
|
}
|
|
|
|
case 'login_success':
|
|
// Honour from any non-terminal state. The bridge's success line
|
|
// doesn't include the discordId; the App fires `ping` afterwards
|
|
// to upgrade to the full `connected{handle, discordId}` shape.
|
|
return { kind: 'connected', handle: event.handle };
|
|
|
|
case 'login_failed':
|
|
// Generic Discord-side login failure — bridge replies «Error logging
|
|
// in: <go-error>». Routes back to disconnected with the verbatim
|
|
// reason as a warn line. Only honour when a QR flow is in flight;
|
|
// otherwise it's stale (e.g. an old failure replaying after page
|
|
// reload while the user is already connected).
|
|
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
|
return {
|
|
kind: 'disconnected',
|
|
lastError: { kind: 'login_failed', reason: event.reason },
|
|
};
|
|
|
|
case 'captcha_required':
|
|
// Discord presented a captcha during remoteauth — QR flow is dead
|
|
// for this attempt. Surface as a hint suggesting token-login (which
|
|
// we don't expose in the widget; users can do it via chat-fallback).
|
|
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
|
return { kind: 'disconnected', lastError: { kind: 'captcha_required' } };
|
|
|
|
case 'login_websocket_failed':
|
|
// Pre-QR failure: couldn't reach Discord remoteauth. The QR was
|
|
// never displayed in the first place. State `awaiting_qr_scan` with
|
|
// empty discordUrl is the placeholder set by `start_qr_login`;
|
|
// this fires before the first qr_displayed lands.
|
|
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
|
return {
|
|
kind: 'disconnected',
|
|
lastError: { kind: 'login_websocket_failed', reason: event.reason },
|
|
};
|
|
|
|
case 'connect_after_login_failed':
|
|
// Post-scan rare: remoteauth gave us a token, but the bridge couldn't
|
|
// connect to Discord with it. The bridge has the token cached and
|
|
// might recover on next ping; we still route to disconnected so the
|
|
// user can retry.
|
|
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
|
return {
|
|
kind: 'disconnected',
|
|
lastError: { kind: 'connect_after_login_failed', reason: event.reason },
|
|
};
|
|
|
|
case 'prepare_login_failed':
|
|
if (state.kind !== 'awaiting_qr_scan' && state.kind !== 'qr_verifying') return state;
|
|
return {
|
|
kind: 'disconnected',
|
|
lastError: { kind: 'prepare_login_failed', reason: event.reason },
|
|
};
|
|
|
|
case 'already_logged_in':
|
|
// The user clicked «Войти по QR» but the bridge is already logged
|
|
// in — race against ping. Surface a soft warning and let the App's
|
|
// re-ping reconcile to the connected state.
|
|
if (isFormState(state)) {
|
|
return { ...state, lastError: { kind: 'already_logged_in' } };
|
|
}
|
|
return state;
|
|
|
|
// --- logout ----------------------------------------------------------
|
|
|
|
case 'logout_ok':
|
|
case 'logout_no_op':
|
|
// Late `Logged out` from a previous session can arrive while the
|
|
// user is mid-new-flow. Only honour from logging_out; other states
|
|
// keep their flow.
|
|
if (state.kind !== 'logging_out') return state;
|
|
return { kind: 'disconnected' };
|
|
|
|
// --- disconnect (read-only, never sent by widget) -------------------
|
|
|
|
case 'disconnect_ok':
|
|
case 'disconnect_no_op':
|
|
// User typed `disconnect` manually in chat-fallback while the widget
|
|
// was open. Reflect the bridge's truth: no token-loss, but no live
|
|
// connection either — same shape as `token_stored`. Both
|
|
// `connected` (string handle) and `connected_dead` (handle?:
|
|
// string) expose `handle` on the same key, so a single read works.
|
|
if (state.kind === 'connected' || state.kind === 'connected_dead') {
|
|
return {
|
|
kind: 'connected_dead',
|
|
reason: 'token_stored',
|
|
handle: state.handle,
|
|
};
|
|
}
|
|
return state;
|
|
|
|
case 'disconnect_failed':
|
|
// Manual disconnect attempt failed — keep current state, the widget
|
|
// doesn't surface a UI for this since it never sent the command.
|
|
return state;
|
|
|
|
// --- reconnect -------------------------------------------------------
|
|
|
|
case 'reconnect_ok':
|
|
case 'reconnect_no_op':
|
|
// After a successful reconnect, ping is the source of truth for the
|
|
// handle. The App fires `ping` after this event lands to refresh.
|
|
// We flip to `connected` immediately so the user sees an immediate
|
|
// green pill confirming their click; the post-event ping refreshes
|
|
// the handle / discordId within ~100ms. Both `reconnecting` and
|
|
// `connected_dead` carry `handle?` — a missing handle still flips
|
|
// green with an empty handle, which the UI's
|
|
// `state.handle ? connected-as : connected` ternary tolerates.
|
|
// This avoids the `unknown` flap that the previous draft would
|
|
// produce when no handle was stashed.
|
|
if (state.kind === 'reconnecting' || state.kind === 'connected_dead') {
|
|
return { kind: 'connected', handle: state.handle ?? '' };
|
|
}
|
|
return state;
|
|
|
|
case 'reconnect_failed':
|
|
if (state.kind !== 'reconnecting') return state;
|
|
// Roll back to connected_dead carrying the previous handle. The
|
|
// user can hit Reconnect again or refresh. We don't surface the
|
|
// error reason here — the connected_dead pill itself reads as
|
|
// «something is wrong, try Reconnect» — adding a transient red
|
|
// banner adjacent to a recovery affordance is overkill.
|
|
return {
|
|
kind: 'connected_dead',
|
|
reason: 'connection_dead',
|
|
handle: state.handle,
|
|
};
|
|
|
|
// --- bridge-side errors ---------------------------------------------
|
|
|
|
case 'unknown_command':
|
|
// Shouldn't happen — we only send commands the bridge knows. Visible
|
|
// when /config.json's commandPrefix drifts from the bridge's actual
|
|
// command_prefix. Surface loudly on disconnected.
|
|
return { kind: 'disconnected', lastError: { kind: 'unknown_command' } };
|
|
|
|
case 'unknown':
|
|
return state;
|
|
|
|
default: {
|
|
// Exhaustiveness check — TS flags this if a new LoginEvent kind is
|
|
// added without a case here.
|
|
const exhaustive: never = event;
|
|
return exhaustive;
|
|
}
|
|
}
|
|
};
|
|
|
|
// --- hydrate-from-timeline -----------------------------------------------
|
|
//
|
|
// Discord's hydrate is simpler than Telegram's because the QR flow has
|
|
// fewer states. We walk past→present and let each event freely transition
|
|
// the state — like the TG hydrate, this is permissive (no out-of-thin-air
|
|
// rejection) because we trust the bridge's durable timeline.
|
|
|
|
// 3 minutes — Discord remoteauth's server-side timeout sits around 2
|
|
// minutes (verified empirically against v0.7.6's remoteauth/client.go;
|
|
// no explicit constant in the lib, the server-side gateway closes the
|
|
// websocket on inactivity). We use 3 min as a slight safety margin so
|
|
// reload-after-success grace still works while the panel is still
|
|
// fresh enough to scan. Telegram's QR rotates internally and lives ~10
|
|
// min, which is why the TG widget uses 10 min — Discord's single-shot
|
|
// remoteauth needs the tighter window.
|
|
const HYDRATE_FRESHNESS_MS = 3 * 60 * 1000;
|
|
|
|
export type HydrateInput = {
|
|
ev: LoginEvent;
|
|
ts: number;
|
|
};
|
|
|
|
type HydrateAccumulator = {
|
|
state: LoginState;
|
|
pendingTs: number | null;
|
|
terminated: boolean;
|
|
};
|
|
|
|
const stepHydrate = (
|
|
prevAcc: HydrateAccumulator,
|
|
input: HydrateInput
|
|
): HydrateAccumulator => {
|
|
const { ev, ts } = input;
|
|
|
|
// After a terminal event we normally stop — except if a fresh
|
|
// `qr_displayed` shows up, that's the bridge signature of a NEW login
|
|
// flow. The user cancelled (or finished) and is now logging in again;
|
|
// the chain should resume tracking from the new start. Without this
|
|
// re-entry, `[qr_displayed, login_success, qr_displayed]` (logout-then-
|
|
// re-login-mid-QR) would return null.
|
|
if (prevAcc.terminated && 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 'qr_displayed': {
|
|
// Same anchor logic as the live reducer.
|
|
if (acc.state.kind !== 'awaiting_qr_scan') {
|
|
return {
|
|
state: {
|
|
kind: 'awaiting_qr_scan',
|
|
discordUrl: ev.discordUrl,
|
|
qrEventId: ev.eventId,
|
|
firstShownAt: ts,
|
|
},
|
|
pendingTs: ts,
|
|
terminated: false,
|
|
};
|
|
}
|
|
if (ev.replacesEventId === acc.state.qrEventId) {
|
|
return {
|
|
state: { ...acc.state, discordUrl: ev.discordUrl },
|
|
pendingTs: ts,
|
|
terminated: false,
|
|
};
|
|
}
|
|
if (!ev.replacesEventId) {
|
|
return {
|
|
state: {
|
|
kind: 'awaiting_qr_scan',
|
|
discordUrl: ev.discordUrl,
|
|
qrEventId: ev.eventId,
|
|
firstShownAt: ts,
|
|
},
|
|
pendingTs: ts,
|
|
terminated: false,
|
|
};
|
|
}
|
|
return acc;
|
|
}
|
|
|
|
case 'qr_redacted': {
|
|
if (acc.state.kind !== 'awaiting_qr_scan') return acc;
|
|
if (acc.state.qrEventId !== ev.redactsEventId) return acc;
|
|
// Move into qr_verifying and keep the chain open — the success line
|
|
// typically follows in the same scan window.
|
|
return { state: { kind: 'qr_verifying' }, pendingTs: ts, terminated: false };
|
|
}
|
|
|
|
// Terminal events — collapse the chain. State becomes whatever the
|
|
// bot confirmed last; the caller returns null and lets live `ping`
|
|
// reconcile.
|
|
case 'login_success':
|
|
case 'logged_in':
|
|
case 'logout_ok':
|
|
case 'logout_no_op':
|
|
case 'not_logged_in':
|
|
case 'connection_dead':
|
|
case 'token_stored_not_connected':
|
|
case 'reconnect_ok':
|
|
case 'reconnect_no_op':
|
|
case 'reconnect_failed':
|
|
case 'disconnect_ok':
|
|
case 'disconnect_no_op':
|
|
case 'disconnect_failed':
|
|
case 'login_failed':
|
|
case 'captcha_required':
|
|
case 'login_websocket_failed':
|
|
case 'connect_after_login_failed':
|
|
case 'prepare_login_failed':
|
|
case 'unknown_command':
|
|
return { state: acc.state, pendingTs: null, terminated: true };
|
|
|
|
case 'already_logged_in':
|
|
case 'unknown':
|
|
// Soft no-op for hydrate. already_logged_in is a live-flow warning
|
|
// that doesn't reflect persistent state; unknown is a wording-drift
|
|
// catch-all.
|
|
return acc;
|
|
|
|
default: {
|
|
const exhaustive: never = ev;
|
|
return exhaustive;
|
|
}
|
|
}
|
|
};
|
|
|
|
export const hydrateFromTimeline = (
|
|
inputs: ReadonlyArray<HydrateInput>,
|
|
now: number = Date.now()
|
|
): HydrateRestoredState | null => {
|
|
const acc = inputs.reduce<HydrateAccumulator>(stepHydrate, {
|
|
state: { kind: 'unknown' },
|
|
pendingTs: null,
|
|
terminated: false,
|
|
});
|
|
|
|
if (acc.terminated) return null;
|
|
if (acc.pendingTs === null) return null;
|
|
if (now - acc.pendingTs > HYDRATE_FRESHNESS_MS) return null;
|
|
if (acc.state.kind === 'qr_verifying') return acc.state;
|
|
if (!isFormState(acc.state)) return null;
|
|
return acc.state;
|
|
};
|
|
|
|
// --- DEV sanity assertions ------------------------------------------------
|
|
|
|
if (import.meta.env.DEV) {
|
|
runHydrateSanity();
|
|
}
|
|
|
|
function runHydrateSanity(): void {
|
|
const t0 = 1_700_000_000_000;
|
|
const recent = (offset: number) => t0 + offset;
|
|
const now = t0 + 60 * 1000;
|
|
|
|
const cases: Array<{
|
|
name: string;
|
|
inputs: HydrateInput[];
|
|
expected: LoginState | null;
|
|
nowOverride?: number;
|
|
}> = [
|
|
{ name: 'empty timeline → null', inputs: [], expected: null },
|
|
{
|
|
name: 'lone qr_displayed → awaiting_qr_scan',
|
|
inputs: [
|
|
{
|
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
|
ts: recent(0),
|
|
},
|
|
],
|
|
expected: {
|
|
kind: 'awaiting_qr_scan',
|
|
discordUrl: 'https://discord.com/ra/A',
|
|
qrEventId: '$qrA',
|
|
firstShownAt: recent(0),
|
|
},
|
|
},
|
|
{
|
|
name: 'qr_redacted with mismatched target → ignored',
|
|
inputs: [
|
|
{
|
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
|
ts: recent(0),
|
|
},
|
|
{ ev: { kind: 'qr_redacted', redactsEventId: '$other' }, ts: recent(30000) },
|
|
],
|
|
expected: {
|
|
kind: 'awaiting_qr_scan',
|
|
discordUrl: 'https://discord.com/ra/A',
|
|
qrEventId: '$qrA',
|
|
firstShownAt: recent(0),
|
|
},
|
|
},
|
|
{
|
|
name: 'qr scan → no follow-up → qr_verifying (reload during the gap)',
|
|
inputs: [
|
|
{
|
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
|
ts: recent(0),
|
|
},
|
|
{ ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) },
|
|
],
|
|
expected: { kind: 'qr_verifying' },
|
|
},
|
|
{
|
|
name: 'qr scan → login_success → null (terminal — let ping reconcile)',
|
|
inputs: [
|
|
{
|
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
|
ts: recent(0),
|
|
},
|
|
{ ev: { kind: 'qr_redacted', redactsEventId: '$qrA' }, ts: recent(30000) },
|
|
{ ev: { kind: 'login_success', handle: 'example' }, ts: recent(31000) },
|
|
],
|
|
expected: null,
|
|
},
|
|
{
|
|
name: 'login_failed after qr → null (terminal)',
|
|
inputs: [
|
|
{
|
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
|
ts: recent(0),
|
|
},
|
|
{ ev: { kind: 'login_failed', reason: 'rate limited' }, ts: recent(15000) },
|
|
],
|
|
expected: null,
|
|
},
|
|
{
|
|
name: 'captcha_required after qr → null (terminal)',
|
|
inputs: [
|
|
{
|
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/A', eventId: '$qrA' },
|
|
ts: recent(0),
|
|
},
|
|
{ ev: { kind: 'captcha_required' }, ts: recent(10000) },
|
|
],
|
|
expected: null,
|
|
},
|
|
{
|
|
name: 'logout-then-relogin-mid-qr → awaiting_qr_scan (resume tracking)',
|
|
inputs: [
|
|
{
|
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/OLD', eventId: '$qrOld' },
|
|
ts: recent(0),
|
|
},
|
|
{ ev: { kind: 'qr_redacted', redactsEventId: '$qrOld' }, ts: recent(15000) },
|
|
{ ev: { kind: 'login_success', handle: 'old' }, ts: recent(16000) },
|
|
{ ev: { kind: 'logout_ok' }, ts: recent(20000) },
|
|
{
|
|
ev: { kind: 'qr_displayed', discordUrl: 'https://discord.com/ra/NEW', eventId: '$qrNew' },
|
|
ts: recent(25000),
|
|
},
|
|
],
|
|
expected: {
|
|
kind: 'awaiting_qr_scan',
|
|
discordUrl: 'https://discord.com/ra/NEW',
|
|
qrEventId: '$qrNew',
|
|
firstShownAt: recent(25000),
|
|
},
|
|
},
|
|
{
|
|
name: 'pending too old (5 min) → null (freshness guard, 3-min window)',
|
|
inputs: [
|
|
{
|
|
ev: {
|
|
kind: 'qr_displayed',
|
|
discordUrl: 'https://discordapp.com/ra/A',
|
|
eventId: '$qrA',
|
|
},
|
|
ts: t0 - 5 * 60 * 1000,
|
|
},
|
|
],
|
|
expected: null,
|
|
nowOverride: t0,
|
|
},
|
|
{
|
|
name: 'pending just inside window (2 min) → state',
|
|
inputs: [
|
|
{
|
|
ev: {
|
|
kind: 'qr_displayed',
|
|
discordUrl: 'https://discordapp.com/ra/A',
|
|
eventId: '$qrA',
|
|
},
|
|
ts: t0 - 2 * 60 * 1000,
|
|
},
|
|
],
|
|
expected: {
|
|
kind: 'awaiting_qr_scan',
|
|
discordUrl: 'https://discordapp.com/ra/A',
|
|
qrEventId: '$qrA',
|
|
firstShownAt: t0 - 2 * 60 * 1000,
|
|
},
|
|
nowOverride: t0,
|
|
},
|
|
{
|
|
name: 'connection_dead alone → null (terminal — let live ping reconcile)',
|
|
inputs: [{ ev: { kind: 'connection_dead' }, ts: recent(0) }],
|
|
expected: null,
|
|
},
|
|
{
|
|
name: 'token_stored_not_connected alone → null (terminal — let live ping reconcile)',
|
|
inputs: [{ ev: { kind: 'token_stored_not_connected' }, ts: recent(0) }],
|
|
expected: null,
|
|
},
|
|
{
|
|
name: 'logged_in alone → null (terminal — let live ping reconcile)',
|
|
inputs: [{ ev: { kind: 'logged_in', handle: 'x' }, 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);
|
|
}
|