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>
Extract the pair-handler's close-event decision into a pure helper
decidePairListenerOnClose(warmingUp, restartRequired) returning one of
ignore-leaked-close / post-pair-restart / treat-as-timeout. Refactor
pair-handler to call the helper instead of the inline if-chain.
New tests in pair-state.test.ts:
- warmingUp=true → ignore-leaked-close (regression: prior session's
close racing the new listener)
- warmingUp=true + restartRequired=true → still ignore (defense in
depth — a stale 515 must not hand control to the reconnect path)
- warmingUp=false + restartRequired=true → post-pair-restart
- warmingUp=false → treat-as-timeout
Bot suite goes from 60 → 64 tests, all green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Found from the live bot log: after the user scans the QR, Baileys
receives `pair-success`, logs "pairing configured successfully, expect
to restart the connection...", and then closes the websocket with
status 515 (DisconnectReason.restartRequired) so it can reopen with
the new credentials. The next `open` event finishes the pairing.
The previous code path treated ANY close during pairing as a failure:
it parked the row as `unpaired`, wiped the QR, and emitted
session.timeout to the UI. The user was greeted with "Pairing timed
out — The QR window closed before a device was linked" at the exact
moment they had successfully paired.
Three changes:
- session.ts emits `restartRequired: boolean` on the SessionEvent close
payload (true when reason === DisconnectReason.restartRequired).
- pair-handler treats the restart-required close as a no-op: keeps the
listener attached and the DB row in `pending` so the upcoming `open`
event flips it to `connected`.
- session-manager always reconnects on restart-required (250 ms after
the close — no `lastConnectedAt` gate, no 5 s back-off).
Pure helpers (`pair-state.ts`) updated to model the new branch:
- decideOnPairClose returns null when restartRequired (don't touch DB).
- shouldAutoReconnect returns true on restartRequired regardless of
whether the account has ever connected before.
Tests (+1; 26 bot tests, 104 web tests = 130 green):
- pair-state.test.ts gains explicit cases:
* restart-required close → null
* shouldAutoReconnect always true on restart-required (incl.
first-time pair, where hasEverConnected is false — the exact
case that broke in production).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
bot/src/ipc/pair-state.ts (NEW)
Pure helpers for the pairing-lifecycle decisions, lifted out of
pair-handler so the rules are testable without Baileys / Postgres:
- decideOnPairClose({ current, loggedOut })
- decideOnPairTimeout({ current })
- shouldAutoReconnect({ loggedOut, hasEverConnected })
bot/src/ipc/pair-state.test.ts (NEW, 7 tests)
Locks in the regressions we just fixed:
- Non-loggedOut close from `pending` MUST settle as `unpaired`
(the row used to stay `pending` and disappear from the overview).
- logged_out close → `logged_out`.
- pair-window timeout parks still-`pending` rows; ignores rows
that already moved on.
- Auto-reconnect only kicks in for accounts that have been linked
at least once — guards against the 5-second QR refresh loop on
a fresh pair.
web/src/components/accounts-list-view.test.tsx
+ Test that the overview renders accounts in transient states
(pending, unpaired, disconnected) alongside connected ones — the
`pending` row was being hidden by listAccounts before this fix.
Bot: 24 tests passing (+7).
Web: 99 tests passing (+1).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>