From 4d10c725511a6f625ceb20e94cf227072f0be9cc Mon Sep 17 00:00:00 2001 From: yiekheng Date: Sun, 10 May 2026 08:45:47 +0800 Subject: [PATCH] =?UTF-8?q?fix(bot):=20stop=20reconnect=20loop=20during=20?= =?UTF-8?q?fresh=20pairing=20=E2=80=94=20root=20cause=20of=20QR=20rotation?= =?UTF-8?q?=20every=205s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The session-manager's auto-reconnect (5 s after a non-logged-out close) was firing during initial pairing. Baileys closes the socket whenever it exhausts its QR refs (or transient handshake errors); the auto-reconnect then opened a brand-new socket → new QR pool → another close 5 s later. The web saw a fresh QR every ~5 s and the user could never link, because WhatsApp invalidates each QR as soon as Baileys cycles to the next. Fix: only auto-reconnect for accounts that have been linked before (`whatsapp_accounts.last_connected_at IS NOT NULL`). For brand-new pairing attempts the pair-handler's 5-minute window is now the single authority; on close we just stop the session and let the operator retry. With auto-reconnect off, Baileys uses its default QR cadence: 60 s for the first QR, 20 s for each subsequent rotation, ~6 refs total (~3 minutes of valid scanning) — plenty of time to scan. Pair-handler now also surfaces ANY close as `session.timeout` to the web (was only emitting on `loggedOut`). Without this the user would be left staring at the last QR after Baileys gives up, with no way to know pairing failed. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/bot/src/ipc/pair-handler.ts | 7 +++++- apps/bot/src/whatsapp/session-manager.ts | 30 ++++++++++++++++++------ 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/bot/src/ipc/pair-handler.ts b/apps/bot/src/ipc/pair-handler.ts index c8b84fa..ad4d31f 100644 --- a/apps/bot/src/ipc/pair-handler.ts +++ b/apps/bot/src/ipc/pair-handler.ts @@ -128,7 +128,12 @@ export async function handleStartPairing(accountId: string): Promise { count: synced, }); off(); - } else if (event.type === "close" && event.loggedOut) { + } else if (event.type === "close") { + // During the pairing window, ANY close means the QR window + // ended without a successful link — Baileys' default is to + // close after exhausting QR refs (~2.5 min). Surface this to + // the UI so the user gets a "pairing timed out" screen and a + // chance to retry, instead of staring at a stale QR forever. const t = pairTimeouts.get(id); if (t) { clearTimeout(t); diff --git a/apps/bot/src/whatsapp/session-manager.ts b/apps/bot/src/whatsapp/session-manager.ts index 50ff71e..246f50b 100644 --- a/apps/bot/src/whatsapp/session-manager.ts +++ b/apps/bot/src/whatsapp/session-manager.ts @@ -141,14 +141,30 @@ class SessionManager { .set({ status: event.loggedOut ? "logged_out" : "disconnected" }) .where(eq(whatsappAccounts.id, accountId)); - if (!event.loggedOut) { - const timer = setTimeout(() => { - this.reconnectTimers.delete(accountId); - void this.stop(accountId).then(() => this.start(accountId)); - }, 5000); - this.reconnectTimers.set(accountId, timer); - } else { + if (event.loggedOut) { await this.stop(accountId); + } else { + // Only auto-reconnect for accounts that have been linked at least + // once — `lastConnectedAt` is set on `open`. During an initial + // pairing attempt the close event fires every time Baileys + // exhausts QR refs (~every 30s). Reconnecting would restart the + // pair dance and rotate the QR every few seconds — pair-handler + // already manages the pairing window via its own 5-min timeout. + const account = await db.query.whatsappAccounts.findFirst({ + where: (a, { eq }) => eq(a.id, accountId), + columns: { lastConnectedAt: true }, + }); + if (account?.lastConnectedAt) { + const timer = setTimeout(() => { + this.reconnectTimers.delete(accountId); + void this.stop(accountId).then(() => this.start(accountId)); + }, 5000); + this.reconnectTimers.set(accountId, timer); + } else { + // Brand-new account that hasn't authenticated yet — let the + // pair-handler clean up via its timeout. + await this.stop(accountId); + } } } else if (event.type === "qr") { await db