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>
85 lines
3.4 KiB
TypeScript
85 lines
3.4 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import {
|
|
decideOnPairClose,
|
|
decideOnPairTimeout,
|
|
shouldAutoReconnect,
|
|
} from "./pair-state.js";
|
|
|
|
describe("decideOnPairClose", () => {
|
|
it("logged-out close → terminal `logged_out` and wipes QR", () => {
|
|
const r = decideOnPairClose({ current: "pending", loggedOut: true });
|
|
expect(r).toEqual({ next: "logged_out", clearQrPng: true });
|
|
});
|
|
|
|
it("restart-required close → null (it's a SUCCESS — reconnect, don't touch DB)", () => {
|
|
// Regression we just fixed: after the user scans, Baileys closes
|
|
// the socket with status 515 ("restart required") so it can
|
|
// reopen with the new credentials. Treating that close as a
|
|
// failure produced a spurious "Pairing timed out" right at the
|
|
// moment the user actually paired successfully.
|
|
expect(
|
|
decideOnPairClose({ current: "pending", loggedOut: false, restartRequired: true }),
|
|
).toBe(null);
|
|
});
|
|
|
|
it("non-loggedOut close from `pending` parks the row as `unpaired`", () => {
|
|
const r = decideOnPairClose({ current: "pending", loggedOut: false });
|
|
expect(r).toEqual({ next: "unpaired", clearQrPng: true });
|
|
});
|
|
|
|
it("non-loggedOut close from any transient state parks as `unpaired`", () => {
|
|
for (const current of ["disconnected", "unpaired", "connected"] as const) {
|
|
const r = decideOnPairClose({ current, loggedOut: false });
|
|
expect(r).not.toBe(null);
|
|
expect(r!.next).toBe("unpaired");
|
|
expect(r!.clearQrPng).toBe(true);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("decideOnPairTimeout (5-min pair-window expiry)", () => {
|
|
it("parks a still-`pending` row as `unpaired`", () => {
|
|
expect(decideOnPairTimeout({ current: "pending" })).toEqual({
|
|
next: "unpaired",
|
|
clearQrPng: true,
|
|
});
|
|
});
|
|
|
|
it("does nothing if the row already moved on", () => {
|
|
// Don't clobber a successfully-paired account that just happened
|
|
// to fire after the timeout for any reason.
|
|
for (const current of ["connected", "unpaired", "logged_out", "banned"] as const) {
|
|
expect(decideOnPairTimeout({ current })).toBe(null);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("shouldAutoReconnect", () => {
|
|
it("never reconnects after a logged-out close", () => {
|
|
expect(shouldAutoReconnect({ loggedOut: true, hasEverConnected: true })).toBe(false);
|
|
expect(shouldAutoReconnect({ loggedOut: true, hasEverConnected: false })).toBe(false);
|
|
// Even if Baileys also flagged restartRequired (it shouldn't, but
|
|
// be defensive), loggedOut wins.
|
|
expect(
|
|
shouldAutoReconnect({ loggedOut: true, restartRequired: true, hasEverConnected: true }),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("ALWAYS reconnects on restart-required (post-pair-success), even for first-time accounts", () => {
|
|
// The regression: brand-new pair attempts have hasEverConnected=false,
|
|
// so the old logic refused to reconnect after status 515 — and the
|
|
// user got "Pairing timed out" the moment they actually paired.
|
|
expect(
|
|
shouldAutoReconnect({ loggedOut: false, restartRequired: true, hasEverConnected: false }),
|
|
).toBe(true);
|
|
expect(
|
|
shouldAutoReconnect({ loggedOut: false, restartRequired: true, hasEverConnected: true }),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("reconnects only for accounts that have been linked at least once for non-restartRequired drops", () => {
|
|
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: true })).toBe(true);
|
|
expect(shouldAutoReconnect({ loggedOut: false, hasEverConnected: false })).toBe(false);
|
|
});
|
|
});
|