cm_whatsapp_bot_v1/apps/bot/src/ipc/pair-handler.ts
yiekheng 4d10c72551 fix(bot): stop reconnect loop during fresh pairing — root cause of QR rotation every 5s
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) <noreply@anthropic.com>
2026-05-10 08:45:47 +08:00

191 lines
6.8 KiB
TypeScript

import { eq, and, lt } from "drizzle-orm";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { whatsappAccounts } from "@cmbot/db";
import { db } from "../db.js";
import { env } from "../env.js";
import { logger } from "../logger.js";
import { sessionManager } from "../whatsapp/session-manager.js";
import { renderQrPng } from "../whatsapp/qr-renderer.js";
import { syncGroupsForAccount } from "../whatsapp/group-sync.js";
import { writeAuditLog } from "../audit.js";
import { pgNotifyWeb } from "./notify.js";
const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
const offByAccount = new Map<string, () => void>();
const lastQrPayload = new Map<string, string>();
const pairTimeouts = new Map<string, NodeJS.Timeout>();
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId),
});
if (!account || account.status !== "pending") {
return { existed: false, label: account?.label ?? null };
}
const off = offByAccount.get(accountId);
if (off) {
off();
offByAccount.delete(accountId);
}
const t = pairTimeouts.get(accountId);
if (t) {
clearTimeout(t);
pairTimeouts.delete(accountId);
}
lastQrPayload.delete(accountId);
if (sessionManager.hasSession(accountId)) {
await sessionManager.stop(accountId);
}
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
return { existed: true, label: account.label };
}
export async function handleStartPairing(accountId: string): Promise<void> {
const account = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId),
});
if (!account) {
logger.warn({ accountId }, "pair: account row missing");
return;
}
// Detach any listener still subscribed from a prior pairing attempt for
// this account. Without this, repeated Re-pair clicks accumulate
// listeners and each one writes a fresh QR to the DB on every Baileys
// event — the UI then flashes through new QRs constantly.
const prevOff = offByAccount.get(accountId);
if (prevOff) {
prevOff();
offByAccount.delete(accountId);
}
// For Re-pair, an old session may still be alive. Stop it so
// sessionManager.start() actually opens a fresh socket and Baileys emits
// a new QR. (start() is a no-op when a session is already registered.)
if (sessionManager.hasSession(accountId)) {
await sessionManager.stop(accountId);
}
// Clear any stale QR lingering from a prior attempt.
lastQrPayload.delete(accountId);
await db
.update(whatsappAccounts)
.set({ lastQrPng: null })
.where(eq(whatsappAccounts.id, accountId));
const off = sessionManager.on(async (id, _state, event) => {
if (id !== accountId) return;
try {
if (event.type === "qr") {
// Dedupe by payload — Baileys can re-emit the same QR string in a
// burst. Different strings (a fresh QR) always pass through, so
// the user gets a new QR as soon as Baileys generates one.
if (lastQrPayload.get(id) === event.payload) return;
lastQrPayload.set(id, event.payload);
const png = await renderQrPng(event.payload);
// PNG is too large (~5-10KB) for pg_notify (8000 byte limit).
// Persist on the account row; web fetches via /api/qr/[id].
await db
.update(whatsappAccounts)
.set({ lastQrPng: png.toString("base64"), lastQrAt: new Date() })
.where(eq(whatsappAccounts.id, id));
await pgNotifyWeb({
type: "session.qr",
accountId: id,
ts: Date.now(),
});
} else if (event.type === "open") {
const t = pairTimeouts.get(id);
if (t) {
clearTimeout(t);
pairTimeouts.delete(id);
}
lastQrPayload.delete(id);
offByAccount.delete(id);
const session = sessionManager.getSession(id);
let synced = 0;
if (session) {
const r = await syncGroupsForAccount(id, session.socket);
synced = r.synced;
}
await writeAuditLog(db, {
operatorId: account.operatorId,
source: "web",
action: "account.paired",
targetType: "whatsapp_account",
targetId: id,
payload: { label: account.label },
});
await pgNotifyWeb({
type: "session.connected",
accountId: id,
phoneNumber: event.phoneNumber ?? null,
});
await pgNotifyWeb({
type: "groups.synced",
accountId: id,
count: synced,
});
off();
} 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);
pairTimeouts.delete(id);
}
lastQrPayload.delete(id);
offByAccount.delete(id);
await pgNotifyWeb({ type: "session.timeout", accountId: id });
off();
}
} catch (err) {
logger.error({ err, accountId: id }, "pair: handler error");
}
});
offByAccount.set(accountId, off);
try {
await sessionManager.start(accountId);
} catch (err) {
logger.error({ err, accountId }, "pair: start failed");
off();
offByAccount.delete(accountId);
await pgNotifyWeb({ type: "session.timeout", accountId });
return;
}
const timeoutId = setTimeout(() => {
void (async () => {
try {
const r = await abandonPair(accountId);
if (r.existed) {
await pgNotifyWeb({ type: "session.timeout", accountId });
}
} catch (err) {
logger.error({ err, accountId }, "pair: timeout cleanup failed");
}
})();
}, PAIR_TIMEOUT_MS);
pairTimeouts.set(accountId, timeoutId);
}
/** Sweep stale pending accounts on bot startup. */
export async function sweepStalePendingAccounts(): Promise<void> {
const cutoff = new Date(Date.now() - 60 * 60 * 1000);
const stale = await db
.select({ id: whatsappAccounts.id, label: whatsappAccounts.label })
.from(whatsappAccounts)
.where(and(eq(whatsappAccounts.status, "pending"), lt(whatsappAccounts.createdAt, cutoff)));
for (const row of stale) {
await rm(join(env.SESSIONS_DIR, row.id), { recursive: true, force: true });
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, row.id));
logger.info({ accountId: row.id, label: row.label }, "sweep: removed stale pending account");
}
}