/** * 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"; } /** * Step the pair-listener's warming-up flag forward through one Baileys * event. Captures three rules in one place so they're test-locked: * * - First `qr` / `open` from the live session clears warming-up * (we've seen real session activity, future closes are real). * - `close + restartRequired` (post-pair-success / status 515) * RE-ARMS warming-up. The session-manager will schedule a * `stop().then(start())` reconnect; that stop emits a second close * before the new socket reopens. Without re-arming, the leaked * close from the cleanup-stop reaches us with warming-up=false and * resolves to `treat-as-timeout` — detaching the listener right at * the moment the user actually paired successfully (regression). * - Any other `close` keeps warming-up unchanged (the listener * either ignored it because we're warming, or processed it as a * real timeout / restart and is leaving the loop anyway). */ export function nextWarmingUpAfterEvent(input: { warmingUp: boolean; event: "qr" | "open" | "close"; restartRequired?: boolean; }): boolean { if (input.event === "qr" || input.event === "open") return false; if (input.event === "close" && input.restartRequired) return true; return input.warmingUp; } /** * Decide whether a freshly-paired account is a duplicate of an * existing account row owned by the same operator. The operator * cannot legitimately link the same WhatsApp number to two account * rows — Baileys keeps one auth blob per phone and the second row * would just hijack the first's session. * * Inputs: * - `currentAccountId` the row that just received the open event * - `currentPhoneNumber` the JID-derived phone string (or null) * - `siblings` every other operator-owned account row * * Returns `null` if the phone is unique (proceed normally), or a * descriptor with the existing-row's id+label so the caller can park * the duplicate row and surface a clear "already linked" message to * the UI. A null/empty phone never reports a duplicate (we'd be * comparing apples and we'd block legitimate first pairs that * haven't received the WID yet). */ export interface DuplicatePairInput { currentAccountId: string; currentPhoneNumber: string | null | undefined; siblings: Array<{ id: string; phoneNumber: string | null; label: string }>; } export interface DuplicatePairFinding { existingAccountId: string; existingLabel: string; } export function findDuplicateExistingAccount( input: DuplicatePairInput, ): DuplicatePairFinding | null { const phone = (input.currentPhoneNumber ?? "").trim(); if (!phone) return null; for (const s of input.siblings) { if (s.id === input.currentAccountId) continue; if ((s.phoneNumber ?? "").trim() === phone) { return { existingAccountId: s.id, existingLabel: s.label }; } } return null; }