cm_whatsapp_bot_v1/apps/bot/src/ipc/pair-handler.ts
yiekheng 2fe8459d25 feat: duplicate-pair detection + logout-before-delete + ordering tests
Three connected bits of paired-account hygiene:

1. Duplicate-pair guard (apps/bot/src/ipc/pair-handler.ts)

   Operator scans the QR with a phone that's already linked to
   another account row → both rows would fight over the same
   WhatsApp device and sends become a coin flip. After Baileys'
   `open` event the bot now queries siblings of the same operator,
   passes them through findDuplicateExistingAccount() (a pure
   helper extracted to pair-state.ts), and on a hit:
     - stops the new session (intentional; keeps the original's
       session intact)
     - scrubs the partial auth blob from disk
     - resets the row's status to unpaired and clears phone_number
     - emits a new session.duplicate event with the existing row's
       label so PairLive can render a clear message
   New PairLive 'duplicate' phase: amber icon + "Phone already
   linked, unpair the existing account first or scan with a
   different phone".

2. Logout-before-delete (apps/bot/src/ipc/unpair-handler.ts +
   apps/bot/src/whatsapp/session-manager.ts)

   Delete used to call account.unpair which only closes the local
   socket — the operator's phone kept showing a phantom "linked
   device" pointing at a row that no longer exists. Added:
     - new account.delete command type (web side and bot side)
     - sessionManager.logoutAndStop(): calls socket.logout() so
       WhatsApp drops the device on the server side, THEN closes
       the local socket. Best-effort; logout RPC failure doesn't
       strand the delete.
     - new handleDelete() handler that calls logoutAndStop, removes
       session files, audits, and notifies.
     - deleteAccountAction now sends account.delete instead of
       account.unpair.
   Unpair stays unchanged — re-pair-friendly, no logout.

3. Tests (bot 77 → 88, web 477 → 480)

   - findDuplicateExistingAccount: 6 cases covering match, no-match,
     self-exclusion, null/empty/whitespace handling, whitespace
     normalisation, deterministic-pick when (defensively) two
     siblings share a phone.
   - handleUnpair / handleDelete: handleDelete calls logoutAndStop
     BEFORE rm; handleUnpair never touches logoutAndStop (regression
     guard for a refactor that swaps them); audit log payload
     includes the row's label; audit lookup throwing doesn't strand
     the delete.
   - listAccounts ordering: static guard against the rename-
     reshuffles-list regression. Pins `asc(a.createdAt)` + `asc(a.id)`
     and rejects `asc(a.label)` in the function body.

Bot restarted with the new flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 21:26:58 +08:00

307 lines
12 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";
import {
decidePairListenerOnClose,
findDuplicateExistingAccount,
nextWarmingUpAfterEvent,
} from "./pair-state.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>();
// "Warming" set: while present, the just-attached listener will ignore
// close events. Cleared the moment a qr/open arrives. This prevents the
// old session's close (broadcast asynchronously by sessionManager after
// our await sessionManager.stop() returns) from being mis-read as the
// NEW session timing out — which manifested as: get QR → go back →
// click Pair again → instantly see "Pairing timed out".
const pairingWarmingUp = new Set<string>();
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);
pairingWarmingUp.delete(accountId);
if (sessionManager.hasSession(accountId)) {
await sessionManager.stop(accountId);
}
// Throw away the partial Baileys session files so the next pair
// attempt starts clean — but KEEP the account row so the operator
// sees it on the list with a "Re-pair" affordance.
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
await db
.update(whatsappAccounts)
.set({ status: "unpaired", lastQrPng: null })
.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));
// Mark the new attempt as warming up. Cleared by the first qr/open we
// observe; while set, any close event is treated as the leaked tail of
// the previous session being torn down (see comment near
// `pairingWarmingUp` declaration).
pairingWarmingUp.add(accountId);
const off = sessionManager.on(async (id, _state, event) => {
if (id !== accountId) return;
try {
if (event.type === "qr") {
pairingWarmingUp.delete(id);
// 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") {
pairingWarmingUp.delete(id);
const t = pairTimeouts.get(id);
if (t) {
clearTimeout(t);
pairTimeouts.delete(id);
}
lastQrPayload.delete(id);
offByAccount.delete(id);
// Duplicate-pair guard. Operator scanned the QR with a phone
// that's already linked to another account row. Letting both
// rows claim the same WhatsApp device confuses Baileys and
// turns sends into a coin flip — abandon this pairing and
// surface a clear message to the UI.
const siblings = await db.query.whatsappAccounts.findMany({
where: (a, { eq: dEq }) => dEq(a.operatorId, account.operatorId),
columns: { id: true, phoneNumber: true, label: true },
});
const dup = findDuplicateExistingAccount({
currentAccountId: id,
currentPhoneNumber: event.phoneNumber,
siblings,
});
if (dup) {
logger.warn(
{
accountId: id,
phoneNumber: event.phoneNumber,
existingAccountId: dup.existingAccountId,
existingLabel: dup.existingLabel,
},
"pair: duplicate phone — abandoning new pairing",
);
// Stop the duplicate session, scrub the partial auth blob,
// and reset the row's status. We DO NOT logout() here — the
// original account's session remains valid and the operator
// hasn't actually added a new linked device on the phone yet
// (it'd just be the freshly-completed scan, which Baileys
// hasn't yet committed to the WhatsApp side).
await sessionManager.stop(id, { intentional: true });
await rm(join(env.SESSIONS_DIR, id), { recursive: true, force: true });
await db
.update(whatsappAccounts)
.set({ status: "unpaired", lastQrPng: null, phoneNumber: null })
.where(eq(whatsappAccounts.id, id));
await pgNotifyWeb({
type: "session.duplicate",
accountId: id,
phoneNumber: event.phoneNumber!,
existingLabel: dup.existingLabel,
});
off();
return;
}
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") {
const decision = decidePairListenerOnClose({
warmingUp: pairingWarmingUp.has(id),
restartRequired: event.restartRequired,
});
if (decision === "ignore-leaked-close") {
logger.info(
{ accountId: id },
"pair: ignoring close from previous attempt while warming up",
);
return;
}
if (decision === "post-pair-restart") {
// After the user scans, WhatsApp tells Baileys to "restart"
// the connection. The socket closes with status 515 and the
// session-manager will reopen it with the new credentials —
// the next `open` event finishes the pairing. Keep the
// listener attached and don't surface a timeout to the UI.
//
// Re-arm the warming-up flag: the session-manager schedules a
// cleanup `stop().then(start())` to kick off the reconnect.
// That stop emits another close event that lands on this
// listener BEFORE the new open arrives — without warming-up,
// we'd treat it as a timeout and detach right when the user
// actually paired successfully. Cleared again on the next
// qr / open from the freshly-reopened session.
pairingWarmingUp.add(id);
logger.info(
{ accountId: id },
"pair: restart-required close (post-pair reconnect) — keeping listener alive",
);
return;
}
// decision === "treat-as-timeout": ephemeral close on a live
// attempt. Park the row as `unpaired` and push session.timeout
// so the operator sees the "Re-pair" affordance.
const t = pairTimeouts.get(id);
if (t) {
clearTimeout(t);
pairTimeouts.delete(id);
}
lastQrPayload.delete(id);
offByAccount.delete(id);
await db
.update(whatsappAccounts)
.set({ status: "unpaired", lastQrPng: null })
.where(eq(whatsappAccounts.id, 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. The bot was probably
* restarted mid-pair (or the operator never finished scanning) — the
* row is parked as `unpaired` so the operator sees it on the list and
* can hit Re-pair, instead of silently disappearing.
*/
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
.update(whatsappAccounts)
.set({ status: "unpaired", lastQrPng: null })
.where(eq(whatsappAccounts.id, row.id));
logger.info({ accountId: row.id, label: row.label }, "sweep: parked stale pending account as unpaired");
}
}