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>
186 lines
7.3 KiB
TypeScript
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;
|
|
}
|