Extract the pair-handler's close-event decision into a pure helper decidePairListenerOnClose(warmingUp, restartRequired) returning one of ignore-leaked-close / post-pair-restart / treat-as-timeout. Refactor pair-handler to call the helper instead of the inline if-chain. New tests in pair-state.test.ts: - warmingUp=true → ignore-leaked-close (regression: prior session's close racing the new listener) - warmingUp=true + restartRequired=true → still ignore (defense in depth — a stale 515 must not hand control to the reconnect path) - warmingUp=false + restartRequired=true → post-pair-restart - warmingUp=false → treat-as-timeout Bot suite goes from 60 → 64 tests, all green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
117 lines
4.5 KiB
TypeScript
117 lines
4.5 KiB
TypeScript
/**
|
|
* 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";
|
|
}
|