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>
80 lines
3.1 KiB
TypeScript
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 });
|
|
}
|