Found from the live bot log: after the user scans the QR, Baileys
receives `pair-success`, logs "pairing configured successfully, expect
to restart the connection...", and then closes the websocket with
status 515 (DisconnectReason.restartRequired) so it can reopen with
the new credentials. The next `open` event finishes the pairing.
The previous code path treated ANY close during pairing as a failure:
it parked the row as `unpaired`, wiped the QR, and emitted
session.timeout to the UI. The user was greeted with "Pairing timed
out — The QR window closed before a device was linked" at the exact
moment they had successfully paired.
Three changes:
- session.ts emits `restartRequired: boolean` on the SessionEvent close
payload (true when reason === DisconnectReason.restartRequired).
- pair-handler treats the restart-required close as a no-op: keeps the
listener attached and the DB row in `pending` so the upcoming `open`
event flips it to `connected`.
- session-manager always reconnects on restart-required (250 ms after
the close — no `lastConnectedAt` gate, no 5 s back-off).
Pure helpers (`pair-state.ts`) updated to model the new branch:
- decideOnPairClose returns null when restartRequired (don't touch DB).
- shouldAutoReconnect returns true on restartRequired regardless of
whether the account has ever connected before.
Tests (+1; 26 bot tests, 104 web tests = 130 green):
- pair-state.test.ts gains explicit cases:
* restart-required close → null
* shouldAutoReconnect always true on restart-required (incl.
first-time pair, where hasEverConnected is false — the exact
case that broke in production).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
83 lines
3.1 KiB
TypeScript
83 lines
3.1 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 };
|
|
}
|