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:
parent
dbdb156a09
commit
fe8e14b7a0
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user