cm_whatsapp_bot_v1/apps/bot/src/ipc/unpair-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

80 lines
3.1 KiB
TypeScript

import { rm } from "node:fs/promises";
import { join } from "node:path";
import { db } from "../db.js";
import { env } from "../env.js";
import { sessionManager } from "../whatsapp/session-manager.js";
import { writeAuditLog } from "../audit.js";
import { pgNotifyWeb } from "./notify.js";
import { logger } from "../logger.js";
/**
* Unpair handler: stop the live Baileys session and remove the on-disk
* session files. The web action keeps the account row alive (status =
* 'unpaired') so the operator can re-pair without retyping the label;
* the {intentional: true} stop tells the session manager not to race
* the web's status write with its own "disconnected" update or
* schedule a reconnect for a session we just chose to tear down.
*
* For the delete-account flow the row IS gone by the time we run;
* the audit log lookup tolerates that.
*/
export async function handleUnpair(accountId: string): Promise<void> {
await sessionManager.stop(accountId, { intentional: true });
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
try {
const row = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId),
columns: { operatorId: true },
});
await writeAuditLog(db, {
operatorId: row?.operatorId ?? null,
source: "web",
action: "account.unpaired",
targetType: "whatsapp_account",
targetId: accountId,
payload: {},
});
} catch (err) {
logger.warn({ err, accountId }, "unpair: audit log failed (non-fatal)");
}
await pgNotifyWeb({ type: "session.disconnected", accountId });
}
/**
* Delete-account flow on the bot side. Distinct from unpair because
* we want WhatsApp to drop this device from the user's linked-devices
* list — otherwise the phone keeps showing a phantom entry that has
* to be manually removed from WhatsApp's UI.
*
* Order is important:
* 1. socket.logout() over the still-connected socket → WhatsApp
* removes the linked device on the server side.
* 2. close() the local Baileys session.
* 3. rm() the on-disk auth blob so the next pairing starts clean.
*
* Step 1 is best-effort — if the socket is already torn down or the
* RPC fails the delete still proceeds. The web action then deletes
* the row (cascade FKs handle groups/reminders/runs).
*/
export async function handleDelete(accountId: string): Promise<void> {
await sessionManager.logoutAndStop(accountId);
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
try {
const row = await db.query.whatsappAccounts.findFirst({
where: (a, { eq }) => eq(a.id, accountId),
columns: { operatorId: true, label: true },
});
await writeAuditLog(db, {
operatorId: row?.operatorId ?? null,
source: "web",
action: "account.deleted",
targetType: "whatsapp_account",
targetId: accountId,
payload: { label: row?.label ?? null },
});
} catch (err) {
logger.warn({ err, accountId }, "delete: audit log failed (non-fatal)");
}
await pgNotifyWeb({ type: "session.disconnected", accountId });
}