fix: don't hide accounts in 'pending' state; park failed pairs as 'unpaired'
Accounts list was hiding any row in the transient `pending` status (originally meant only for an active QR scan). When a pair attempt failed (timeout, transient connection error, page closed mid-scan) the row was left in `pending` and silently disappeared from the overview — the operator's "I created an account but it's gone" bug. Two-part fix: - listAccounts no longer filters by status. The status badge tells the operator what state each row is in; hiding rows just hides bugs. - Pairing lifecycle no longer leaves rows in `pending` after failure. When the pair-handler sees a close (Baileys exhausting QR refs, or the pair-window timeout firing), it now sets `status='unpaired'` and clears `last_qr_png`. The row settles into a state that the detail page can act on (Re-pair / Delete) and remains visible on the list. - The bot startup sweep used to DELETE stale pending rows older than 1 hour. It now parks them as `unpaired` instead, keeping them visible so the operator notices and can retry. Stuck `haha` row in the live DB also flipped to `unpaired` so it reappears on the list immediately. 98 tests passing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8ca7ebdd5b
commit
fe135cdef5
@ -37,8 +37,14 @@ async function abandonPair(accountId: string): Promise<{ existed: boolean; label
|
|||||||
if (sessionManager.hasSession(accountId)) {
|
if (sessionManager.hasSession(accountId)) {
|
||||||
await sessionManager.stop(accountId);
|
await sessionManager.stop(accountId);
|
||||||
}
|
}
|
||||||
|
// Throw away the partial Baileys session files so the next pair
|
||||||
|
// attempt starts clean — but KEEP the account row so the operator
|
||||||
|
// sees it on the list with a "Re-pair" affordance.
|
||||||
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
|
await rm(join(env.SESSIONS_DIR, accountId), { recursive: true, force: true });
|
||||||
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, accountId));
|
await db
|
||||||
|
.update(whatsappAccounts)
|
||||||
|
.set({ status: "unpaired", lastQrPng: null })
|
||||||
|
.where(eq(whatsappAccounts.id, accountId));
|
||||||
return { existed: true, label: account.label };
|
return { existed: true, label: account.label };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -132,8 +138,9 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
// During the pairing window, ANY close means the QR window
|
// During the pairing window, ANY close means the QR window
|
||||||
// ended without a successful link — Baileys' default is to
|
// ended without a successful link — Baileys' default is to
|
||||||
// close after exhausting QR refs (~2.5 min). Surface this to
|
// close after exhausting QR refs (~2.5 min). Surface this to
|
||||||
// the UI so the user gets a "pairing timed out" screen and a
|
// the UI so the user gets a "pairing timed out" screen, and
|
||||||
// chance to retry, instead of staring at a stale QR forever.
|
// park the row in a stable state so it shows up cleanly on
|
||||||
|
// the accounts list with a "Re-pair" affordance.
|
||||||
const t = pairTimeouts.get(id);
|
const t = pairTimeouts.get(id);
|
||||||
if (t) {
|
if (t) {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
@ -141,6 +148,10 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
lastQrPayload.delete(id);
|
lastQrPayload.delete(id);
|
||||||
offByAccount.delete(id);
|
offByAccount.delete(id);
|
||||||
|
await db
|
||||||
|
.update(whatsappAccounts)
|
||||||
|
.set({ status: "unpaired", lastQrPng: null })
|
||||||
|
.where(eq(whatsappAccounts.id, id));
|
||||||
await pgNotifyWeb({ type: "session.timeout", accountId: id });
|
await pgNotifyWeb({ type: "session.timeout", accountId: id });
|
||||||
off();
|
off();
|
||||||
}
|
}
|
||||||
@ -175,7 +186,12 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
pairTimeouts.set(accountId, timeoutId);
|
pairTimeouts.set(accountId, timeoutId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sweep stale pending accounts on bot startup. */
|
/**
|
||||||
|
* Sweep stale `pending` accounts on bot startup. The bot was probably
|
||||||
|
* restarted mid-pair (or the operator never finished scanning) — the
|
||||||
|
* row is parked as `unpaired` so the operator sees it on the list and
|
||||||
|
* can hit Re-pair, instead of silently disappearing.
|
||||||
|
*/
|
||||||
export async function sweepStalePendingAccounts(): Promise<void> {
|
export async function sweepStalePendingAccounts(): Promise<void> {
|
||||||
const cutoff = new Date(Date.now() - 60 * 60 * 1000);
|
const cutoff = new Date(Date.now() - 60 * 60 * 1000);
|
||||||
const stale = await db
|
const stale = await db
|
||||||
@ -184,7 +200,10 @@ export async function sweepStalePendingAccounts(): Promise<void> {
|
|||||||
.where(and(eq(whatsappAccounts.status, "pending"), lt(whatsappAccounts.createdAt, cutoff)));
|
.where(and(eq(whatsappAccounts.status, "pending"), lt(whatsappAccounts.createdAt, cutoff)));
|
||||||
for (const row of stale) {
|
for (const row of stale) {
|
||||||
await rm(join(env.SESSIONS_DIR, row.id), { recursive: true, force: true });
|
await rm(join(env.SESSIONS_DIR, row.id), { recursive: true, force: true });
|
||||||
await db.delete(whatsappAccounts).where(eq(whatsappAccounts.id, row.id));
|
await db
|
||||||
logger.info({ accountId: row.id, label: row.label }, "sweep: removed stale pending account");
|
.update(whatsappAccounts)
|
||||||
|
.set({ status: "unpaired", lastQrPng: null })
|
||||||
|
.where(eq(whatsappAccounts.id, row.id));
|
||||||
|
logger.info({ accountId: row.id, label: row.label }, "sweep: parked stale pending account as unpaired");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,13 +44,14 @@ export async function getDashboardStats(operatorId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function listAccounts(operatorId: string) {
|
export async function listAccounts(operatorId: string) {
|
||||||
// Show all accounts except those in the transient `pending` state
|
// Show every account the operator owns, regardless of status. The
|
||||||
// (active QR scan in progress — they're surfaced via the pairing page,
|
// status badge tells the user what state each row is in (Pending /
|
||||||
// and abandoned ones are swept by the bot). Operators see unpaired,
|
// Unpaired / Connected / Disconnected / etc), and the detail page
|
||||||
// connected, disconnected, and banned accounts so they can manage them.
|
// exposes Pair / Re-pair / Delete actions accordingly. Hiding rows
|
||||||
|
// by status produced phantom "I created an account but it's gone"
|
||||||
|
// bug reports.
|
||||||
return db.query.whatsappAccounts.findMany({
|
return db.query.whatsappAccounts.findMany({
|
||||||
where: (a, { eq, and, ne }) =>
|
where: (a, { eq }) => eq(a.operatorId, operatorId),
|
||||||
and(eq(a.operatorId, operatorId), ne(a.status, "pending")),
|
|
||||||
orderBy: (a, { asc }) => [asc(a.label)],
|
orderBy: (a, { asc }) => [asc(a.label)],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user