/** * Pure helpers for pairing-lifecycle status transitions. Extracted so * the rules are unit-testable without spinning up Baileys / Postgres. * * Key invariant the tests guard: * - A failed or abandoned pair MUST NOT leave the row stuck in * `pending`. It transitions to `unpaired` so the operator can see * the account on the list with a Re-pair affordance. * - Successful pairing transitions to `connected` (set by the * session-manager on the `open` event — not this helper's job). * - Auto-reconnect for transient drops only applies to accounts * that have been linked at least once (`lastConnectedAt` set). */ export type AccountStatus = | "pending" | "unpaired" | "connected" | "disconnected" | "logged_out" | "banned"; export interface PairCloseInput { /** Status of the account row at the moment the close event fires. */ current: AccountStatus; /** Did Baileys signal a logged-out close (vs an ephemeral close)? */ loggedOut: boolean; /** Was it the post-pair "restart required" close (status 515)? */ restartRequired?: boolean; } export type StatusUpdate = { next: AccountStatus; /** Wipe the cached QR PNG when the pair window closes. */ clearQrPng: boolean; } | null; /** * Decide the status transition when the Baileys session closes during * a pairing attempt. * * - logged_out close → terminal: `logged_out`. * - restart-required close → null (this is a SUCCESS signal that triggers * a reconnect; the row stays in its current state until `open` fires). * - ephemeral close (refs exhausted, network blip, etc.) → park as * `unpaired` so the row stays visible and the user can retry. */ export function decideOnPairClose({ current, loggedOut, restartRequired }: PairCloseInput): StatusUpdate { if (loggedOut) { return { next: "logged_out", clearQrPng: true }; } if (restartRequired) { // Post-pair-success reconnect — the next `open` event finishes the // job. Don't touch DB state and don't tear the listener down. return null; } // Whatever transient state we were in (most often `pending`), park // the row as `unpaired` — anything else hides it from the operator. return { next: "unpaired", clearQrPng: true }; } /** Whether the session-manager should auto-reconnect after a non-loggedOut close. */ export function shouldAutoReconnect(args: { loggedOut: boolean; restartRequired?: boolean; /** True if the account row has `last_connected_at` set (has been linked before). */ hasEverConnected: boolean; }): boolean { if (args.loggedOut) return false; // Status 515 is the post-pair-success reconnect — always do it, // regardless of whether the account has ever connected before. if (args.restartRequired) return true; return args.hasEverConnected; } /** Decide what happens when the 5-min pair-window timeout fires. */ export function decideOnPairTimeout({ current }: { current: AccountStatus }): StatusUpdate | null { // Only the still-pending rows need cleanup. Anything else has already // moved on (connected, unpaired by an earlier close, etc.). if (current !== "pending") return null; return { next: "unpaired", clearQrPng: true }; } /** * Decide how the pair-handler should react to a `close` event delivered * to its listener. Three outcomes: * * - "ignore-leaked-close": the new attempt is still warming up and * we're seeing the OLD session's tail close. Do nothing — don't * emit timeout to the UI, don't touch the DB row. * - "post-pair-restart": status-515 close from a successful scan. * The session-manager will reconnect; we keep the listener alive * and wait for the subsequent `open` event. * - "treat-as-timeout": a real ephemeral close on a live attempt * (refs exhausted, etc.). Park the row as `unpaired` and push * `session.timeout` to the UI. * * Captures the regression where, after the user pulled up a QR and * navigated back, clicking Pair again would instantly flash "Pairing * timed out" because the await on stop() returned before * sessionManager.handleEvent finished broadcasting the old session's * close — and the new listener was already attached. */ export type PairListenerCloseDecision = | "ignore-leaked-close" | "post-pair-restart" | "treat-as-timeout"; export function decidePairListenerOnClose(input: { warmingUp: boolean; restartRequired?: boolean; }): PairListenerCloseDecision { if (input.warmingUp) return "ignore-leaked-close"; if (input.restartRequired) return "post-pair-restart"; return "treat-as-timeout"; }