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>
108 lines
3.8 KiB
TypeScript
108 lines
3.8 KiB
TypeScript
import { mkdir } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
import {
|
|
makeWASocket,
|
|
useMultiFileAuthState,
|
|
fetchLatestBaileysVersion,
|
|
type WASocket,
|
|
type ConnectionState,
|
|
DisconnectReason,
|
|
Browsers,
|
|
} from "@whiskeysockets/baileys";
|
|
import { logger } from "../logger.js";
|
|
import { env } from "../env.js";
|
|
import { syncGroupsForAccount } from "./group-sync.js";
|
|
|
|
export type SessionEvent =
|
|
| { type: "qr"; payload: string }
|
|
| { type: "open"; phoneNumber: string | undefined }
|
|
// `restartRequired` is set when Baileys closes the socket with status
|
|
// 515 — the normal post-pair handshake reconnect, NOT a failure. Both
|
|
// pair-handler and session-manager use it to skip the "pairing failed"
|
|
// path and re-open the socket so the account finishes linking.
|
|
| { type: "close"; reason: number; loggedOut: boolean; restartRequired: boolean };
|
|
|
|
export type SessionEventHandler = (event: SessionEvent) => void | Promise<void>;
|
|
|
|
export type Session = {
|
|
accountId: string;
|
|
socket: WASocket;
|
|
close: () => Promise<void>;
|
|
};
|
|
|
|
export async function startSession(params: {
|
|
accountId: string;
|
|
onEvent: SessionEventHandler;
|
|
}): Promise<Session> {
|
|
const { accountId, onEvent } = params;
|
|
const sessionDir = join(env.SESSIONS_DIR, accountId);
|
|
await mkdir(sessionDir, { recursive: true });
|
|
|
|
const { state, saveCreds } = await useMultiFileAuthState(sessionDir);
|
|
|
|
// Fetch the WhatsApp Web version Baileys should announce. Without this,
|
|
// the noise handshake fails because WA's server-side rejects stale versions.
|
|
const { version, isLatest } = await fetchLatestBaileysVersion();
|
|
logger.info({ accountId, version, isLatest }, "session: using WA Web version");
|
|
|
|
const socket = makeWASocket({
|
|
version,
|
|
auth: state,
|
|
browser: Browsers.macOS("Safari"),
|
|
syncFullHistory: false,
|
|
// Use Baileys' default QR cadence (60 s for the first ref, ~20 s for
|
|
// each subsequent ref) — that's the native WhatsApp Web cadence and
|
|
// each rotation just refreshes the displayed QR. The earlier "QR
|
|
// refresh every 5 s" bug was the session-manager reconnect loop,
|
|
// not the cadence.
|
|
logger: logger.child({ accountId, component: "baileys" }) as never,
|
|
});
|
|
|
|
socket.ev.on("creds.update", () => void saveCreds());
|
|
|
|
// Keep `whatsapp_groups` in sync as Baileys discovers new groups or updates.
|
|
// Debounced so a flurry of upsert events from the initial sync collapses
|
|
// into a single DB write.
|
|
let groupsSyncTimer: NodeJS.Timeout | null = null;
|
|
const scheduleGroupsSync = (): void => {
|
|
if (groupsSyncTimer) return;
|
|
groupsSyncTimer = setTimeout(() => {
|
|
groupsSyncTimer = null;
|
|
void syncGroupsForAccount(accountId, socket).catch((err) =>
|
|
logger.warn({ err, accountId }, "auto group sync failed"),
|
|
);
|
|
}, 1500);
|
|
};
|
|
socket.ev.on("groups.upsert", scheduleGroupsSync);
|
|
socket.ev.on("groups.update", scheduleGroupsSync);
|
|
|
|
socket.ev.on("connection.update", (update: Partial<ConnectionState>) => {
|
|
if (update.qr) {
|
|
void onEvent({ type: "qr", payload: update.qr });
|
|
}
|
|
if (update.connection === "open") {
|
|
const phoneNumber = socket.user?.id?.split(":")[0];
|
|
void onEvent({ type: "open", phoneNumber });
|
|
}
|
|
if (update.connection === "close") {
|
|
const reason =
|
|
(update.lastDisconnect?.error as { output?: { statusCode?: number } } | undefined)?.output?.statusCode ?? 0;
|
|
const loggedOut = reason === DisconnectReason.loggedOut;
|
|
const restartRequired = reason === DisconnectReason.restartRequired;
|
|
void onEvent({ type: "close", reason, loggedOut, restartRequired });
|
|
}
|
|
});
|
|
|
|
return {
|
|
accountId,
|
|
socket,
|
|
close: async () => {
|
|
try {
|
|
socket.end(undefined);
|
|
} catch (err) {
|
|
logger.warn({ err, accountId }, "session.close: error closing socket");
|
|
}
|
|
},
|
|
};
|
|
}
|