diff --git a/apps/bot/src/ipc/pair-handler.ts b/apps/bot/src/ipc/pair-handler.ts index 3681ab0..1f08bb8 100644 --- a/apps/bot/src/ipc/pair-handler.ts +++ b/apps/bot/src/ipc/pair-handler.ts @@ -15,6 +15,13 @@ const PAIR_TIMEOUT_MS = 5 * 60 * 1000; const offByAccount = new Map void>(); const lastQrPayload = new Map(); const pairTimeouts = new Map(); +// "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(); 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 { .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 { 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 { // 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 diff --git a/apps/web/src/app/settings/page.tsx b/apps/web/src/app/settings/page.tsx index f448776..fe2bad0 100644 --- a/apps/web/src/app/settings/page.tsx +++ b/apps/web/src/app/settings/page.tsx @@ -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() { - {isAdmin && ( - - - - - Admin - - - Manage which usernames can sign in and what role each - one has. Visible to admins only. - - - - - Users - - - - - )} - Notifications