fix(bot): swallow leaked close from previous pairing attempt

Repro: scan QR window once → click Back → click Pair again → instantly
see 'Pairing timed out' (sometimes for several attempts in a row).

Root cause: when handleStartPairing hits a still-running session it
calls await sessionManager.stop(accountId) and immediately attaches a
fresh listener. session.close() resolves before sessionManager broadcasts
the close event to listeners (handleEvent has several awaits between
close arriving and the listener fan-out). The new listener was already
attached by then and saw the OLD session's close as if it were the new
session timing out — flipped the row to unpaired and pushed
session.timeout to the UI.

Fix: track a per-account 'pairingWarmingUp' Set. The new attempt enters
warming-up the moment its listener attaches; clears on the first qr or
open (those events can only come from the freshly-started session). A
close that arrives while still warming is logged and ignored. abandonPair
also clears the flag for safety.

Also drop the redundant Admin card from /settings — the Admin nav entry
on the sidebar/drawer already routes admins to /settings/users, the
extra card was duplicate UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
yiekheng 2026-05-10 19:02:10 +08:00
parent dbdb156a09
commit fe8e14b7a0
2 changed files with 29 additions and 27 deletions

View File

@ -15,6 +15,13 @@ const PAIR_TIMEOUT_MS = 5 * 60 * 1000;
const offByAccount = new Map<string, () => void>();
const lastQrPayload = new Map<string, string>();
const pairTimeouts = new Map<string, NodeJS.Timeout>();
// "Warming" set: while present, the just-attached listener will ignore
// close events. Cleared the moment a qr/open arrives. This prevents the
// old session's close (broadcast asynchronously by sessionManager after
// our await sessionManager.stop() returns) from being mis-read as the
// NEW session timing out — which manifested as: get QR → go back →
// click Pair again → instantly see "Pairing timed out".
const pairingWarmingUp = new Set<string>();
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
const account = await db.query.whatsappAccounts.findFirst({
@ -34,6 +41,7 @@ async function abandonPair(accountId: string): Promise<{ existed: boolean; label
pairTimeouts.delete(accountId);
}
lastQrPayload.delete(accountId);
pairingWarmingUp.delete(accountId);
if (sessionManager.hasSession(accountId)) {
await sessionManager.stop(accountId);
}
@ -80,10 +88,17 @@ export async function handleStartPairing(accountId: string): Promise<void> {
.set({ lastQrPng: null })
.where(eq(whatsappAccounts.id, accountId));
// Mark the new attempt as warming up. Cleared by the first qr/open we
// observe; while set, any close event is treated as the leaked tail of
// the previous session being torn down (see comment near
// `pairingWarmingUp` declaration).
pairingWarmingUp.add(accountId);
const off = sessionManager.on(async (id, _state, event) => {
if (id !== accountId) return;
try {
if (event.type === "qr") {
pairingWarmingUp.delete(id);
// Dedupe by payload — Baileys can re-emit the same QR string in a
// burst. Different strings (a fresh QR) always pass through, so
// the user gets a new QR as soon as Baileys generates one.
@ -102,6 +117,7 @@ export async function handleStartPairing(accountId: string): Promise<void> {
ts: Date.now(),
});
} else if (event.type === "open") {
pairingWarmingUp.delete(id);
const t = pairTimeouts.get(id);
if (t) {
clearTimeout(t);
@ -149,6 +165,19 @@ export async function handleStartPairing(accountId: string): Promise<void> {
// The session-manager handles the actual reconnect; nothing to
// do here other than NOT tear our listener / DB state down.
} else if (event.type === "close") {
// Swallow the leaked close from the *previous* pairing attempt
// being torn down inside this handler's own
// `await sessionManager.stop()`. The old session's close is
// broadcast asynchronously and lands here AFTER we've attached
// the new listener; treating it as a real timeout would flash
// "Pairing timed out" before any QR even arrives.
if (pairingWarmingUp.has(id)) {
logger.info(
{ accountId: id },
"pair: ignoring close from previous attempt while warming up",
);
return;
}
// During the pairing window, any other close means the QR window
// ended without a successful link — Baileys' default is to
// close after exhausting QR refs (~2.5 min). Surface this to

View File

@ -1,5 +1,3 @@
import Link from "next/link";
import { ShieldCheckIcon, ChevronRightIcon } from "lucide-react";
import { getSeededOperator } from "@/lib/operator";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
@ -29,31 +27,6 @@ export default async function SettingsPage() {
</CardContent>
</Card>
{isAdmin && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ShieldCheckIcon className="size-4" />
Admin
</CardTitle>
<CardDescription>
Manage which usernames can sign in and what role each
one has. Visible to admins only.
</CardDescription>
</CardHeader>
<CardContent className="p-0">
<Link
// eslint-disable-next-line @typescript-eslint/no-explicit-any
href={"/settings/users" as any}
className="flex items-center justify-between gap-3 px-6 py-3 text-sm font-medium hover:bg-muted focus-visible:bg-muted rounded-b-xl"
>
<span>Users</span>
<ChevronRightIcon className="size-4 text-muted-foreground" />
</Link>
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>