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

186 lines
7.3 KiB
TypeScript

/**
* Pure helpers for pairing-lifecycle status transitions. Extracted so
* the rules are unit-testable without spinning up Baileys / Postgres.
*
* Key invariant the tests guard:
* - A failed or abandoned pair MUST NOT leave the row stuck in
* `pending`. It transitions to `unpaired` so the operator can see
* the account on the list with a Re-pair affordance.
* - Successful pairing transitions to `connected` (set by the
* session-manager on the `open` event — not this helper's job).
* - Auto-reconnect for transient drops only applies to accounts
* that have been linked at least once (`lastConnectedAt` set).
*/
export type AccountStatus =
| "pending"
| "unpaired"
| "connected"
| "disconnected"
| "logged_out"
| "banned";
export interface PairCloseInput {
/** Status of the account row at the moment the close event fires. */
current: AccountStatus;
/** Did Baileys signal a logged-out close (vs an ephemeral close)? */
loggedOut: boolean;
/** Was it the post-pair "restart required" close (status 515)? */
restartRequired?: boolean;
}
export type StatusUpdate = {
next: AccountStatus;
/** Wipe the cached QR PNG when the pair window closes. */
clearQrPng: boolean;
} | null;
/**
* Decide the status transition when the Baileys session closes during
* a pairing attempt.
*
* - logged_out close → terminal: `logged_out`.
* - restart-required close → null (this is a SUCCESS signal that triggers
* a reconnect; the row stays in its current state until `open` fires).
* - ephemeral close (refs exhausted, network blip, etc.) → park as
* `unpaired` so the row stays visible and the user can retry.
*/
export function decideOnPairClose({ current, loggedOut, restartRequired }: PairCloseInput): StatusUpdate {
if (loggedOut) {
return { next: "logged_out", clearQrPng: true };
}
if (restartRequired) {
// Post-pair-success reconnect — the next `open` event finishes the
// job. Don't touch DB state and don't tear the listener down.
return null;
}
// Whatever transient state we were in (most often `pending`), park
// the row as `unpaired` — anything else hides it from the operator.
return { next: "unpaired", clearQrPng: true };
}
/** Whether the session-manager should auto-reconnect after a non-loggedOut close. */
export function shouldAutoReconnect(args: {
loggedOut: boolean;
restartRequired?: boolean;
/** True if the account row has `last_connected_at` set (has been linked before). */
hasEverConnected: boolean;
}): boolean {
if (args.loggedOut) return false;
// Status 515 is the post-pair-success reconnect — always do it,
// regardless of whether the account has ever connected before.
if (args.restartRequired) return true;
return args.hasEverConnected;
}
/** Decide what happens when the 5-min pair-window timeout fires. */
export function decideOnPairTimeout({ current }: { current: AccountStatus }): StatusUpdate | null {
// Only the still-pending rows need cleanup. Anything else has already
// moved on (connected, unpaired by an earlier close, etc.).
if (current !== "pending") return null;
return { next: "unpaired", clearQrPng: true };
}
/**
* Decide how the pair-handler should react to a `close` event delivered
* to its listener. Three outcomes:
*
* - "ignore-leaked-close": the new attempt is still warming up and
* we're seeing the OLD session's tail close. Do nothing — don't
* emit timeout to the UI, don't touch the DB row.
* - "post-pair-restart": status-515 close from a successful scan.
* The session-manager will reconnect; we keep the listener alive
* and wait for the subsequent `open` event.
* - "treat-as-timeout": a real ephemeral close on a live attempt
* (refs exhausted, etc.). Park the row as `unpaired` and push
* `session.timeout` to the UI.
*
* Captures the regression where, after the user pulled up a QR and
* navigated back, clicking Pair again would instantly flash "Pairing
* timed out" because the await on stop() returned before
* sessionManager.handleEvent finished broadcasting the old session's
* close — and the new listener was already attached.
*/
export type PairListenerCloseDecision =
| "ignore-leaked-close"
| "post-pair-restart"
| "treat-as-timeout";
export function decidePairListenerOnClose(input: {
warmingUp: boolean;
restartRequired?: boolean;
}): PairListenerCloseDecision {
if (input.warmingUp) return "ignore-leaked-close";
if (input.restartRequired) return "post-pair-restart";
return "treat-as-timeout";
}
/**
* Step the pair-listener's warming-up flag forward through one Baileys
* event. Captures three rules in one place so they're test-locked:
*
* - First `qr` / `open` from the live session clears warming-up
* (we've seen real session activity, future closes are real).
* - `close + restartRequired` (post-pair-success / status 515)
* RE-ARMS warming-up. The session-manager will schedule a
* `stop().then(start())` reconnect; that stop emits a second close
* before the new socket reopens. Without re-arming, the leaked
* close from the cleanup-stop reaches us with warming-up=false and
* resolves to `treat-as-timeout` — detaching the listener right at
* the moment the user actually paired successfully (regression).
* - Any other `close` keeps warming-up unchanged (the listener
* either ignored it because we're warming, or processed it as a
* real timeout / restart and is leaving the loop anyway).
*/
export function nextWarmingUpAfterEvent(input: {
warmingUp: boolean;
event: "qr" | "open" | "close";
restartRequired?: boolean;
}): boolean {
if (input.event === "qr" || input.event === "open") return false;
if (input.event === "close" && input.restartRequired) return true;
return input.warmingUp;
}
/**
* Decide whether a freshly-paired account is a duplicate of an
* existing account row owned by the same operator. The operator
* cannot legitimately link the same WhatsApp number to two account
* rows — Baileys keeps one auth blob per phone and the second row
* would just hijack the first's session.
*
* Inputs:
* - `currentAccountId` the row that just received the open event
* - `currentPhoneNumber` the JID-derived phone string (or null)
* - `siblings` every other operator-owned account row
*
* Returns `null` if the phone is unique (proceed normally), or a
* descriptor with the existing-row's id+label so the caller can park
* the duplicate row and surface a clear "already linked" message to
* the UI. A null/empty phone never reports a duplicate (we'd be
* comparing apples and we'd block legitimate first pairs that
* haven't received the WID yet).
*/
export interface DuplicatePairInput {
currentAccountId: string;
currentPhoneNumber: string | null | undefined;
siblings: Array<{ id: string; phoneNumber: string | null; label: string }>;
}
export interface DuplicatePairFinding {
existingAccountId: string;
existingLabel: string;
}
export function findDuplicateExistingAccount(
input: DuplicatePairInput,
): DuplicatePairFinding | null {
const phone = (input.currentPhoneNumber ?? "").trim();
if (!phone) return null;
for (const s of input.siblings) {
if (s.id === input.currentAccountId) continue;
if ((s.phoneNumber ?? "").trim() === phone) {
return { existingAccountId: s.id, existingLabel: s.label };
}
}
return null;
}