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 offByAccount = new Map<string, () => void>();
|
||||||
const lastQrPayload = new Map<string, string>();
|
const lastQrPayload = new Map<string, string>();
|
||||||
const pairTimeouts = new Map<string, NodeJS.Timeout>();
|
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 }> {
|
async function abandonPair(accountId: string): Promise<{ existed: boolean; label: string | null }> {
|
||||||
const account = await db.query.whatsappAccounts.findFirst({
|
const account = await db.query.whatsappAccounts.findFirst({
|
||||||
@ -34,6 +41,7 @@ async function abandonPair(accountId: string): Promise<{ existed: boolean; label
|
|||||||
pairTimeouts.delete(accountId);
|
pairTimeouts.delete(accountId);
|
||||||
}
|
}
|
||||||
lastQrPayload.delete(accountId);
|
lastQrPayload.delete(accountId);
|
||||||
|
pairingWarmingUp.delete(accountId);
|
||||||
if (sessionManager.hasSession(accountId)) {
|
if (sessionManager.hasSession(accountId)) {
|
||||||
await sessionManager.stop(accountId);
|
await sessionManager.stop(accountId);
|
||||||
}
|
}
|
||||||
@ -80,10 +88,17 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
.set({ lastQrPng: null })
|
.set({ lastQrPng: null })
|
||||||
.where(eq(whatsappAccounts.id, accountId));
|
.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) => {
|
const off = sessionManager.on(async (id, _state, event) => {
|
||||||
if (id !== accountId) return;
|
if (id !== accountId) return;
|
||||||
try {
|
try {
|
||||||
if (event.type === "qr") {
|
if (event.type === "qr") {
|
||||||
|
pairingWarmingUp.delete(id);
|
||||||
// Dedupe by payload — Baileys can re-emit the same QR string in a
|
// Dedupe by payload — Baileys can re-emit the same QR string in a
|
||||||
// burst. Different strings (a fresh QR) always pass through, so
|
// burst. Different strings (a fresh QR) always pass through, so
|
||||||
// the user gets a new QR as soon as Baileys generates one.
|
// 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(),
|
ts: Date.now(),
|
||||||
});
|
});
|
||||||
} else if (event.type === "open") {
|
} else if (event.type === "open") {
|
||||||
|
pairingWarmingUp.delete(id);
|
||||||
const t = pairTimeouts.get(id);
|
const t = pairTimeouts.get(id);
|
||||||
if (t) {
|
if (t) {
|
||||||
clearTimeout(t);
|
clearTimeout(t);
|
||||||
@ -149,6 +165,19 @@ export async function handleStartPairing(accountId: string): Promise<void> {
|
|||||||
// The session-manager handles the actual reconnect; nothing to
|
// The session-manager handles the actual reconnect; nothing to
|
||||||
// do here other than NOT tear our listener / DB state down.
|
// do here other than NOT tear our listener / DB state down.
|
||||||
} else if (event.type === "close") {
|
} 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
|
// During the pairing window, any other 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
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import Link from "next/link";
|
|
||||||
import { ShieldCheckIcon, ChevronRightIcon } from "lucide-react";
|
|
||||||
import { getSeededOperator } from "@/lib/operator";
|
import { getSeededOperator } from "@/lib/operator";
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@ -29,31 +27,6 @@ export default async function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Notifications</CardTitle>
|
<CardTitle>Notifications</CardTitle>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user