Found from the live bot log: after the user scans the QR, Baileys
receives `pair-success`, logs "pairing configured successfully, expect
to restart the connection...", and then closes the websocket with
status 515 (DisconnectReason.restartRequired) so it can reopen with
the new credentials. The next `open` event finishes the pairing.
The previous code path treated ANY close during pairing as a failure:
it parked the row as `unpaired`, wiped the QR, and emitted
session.timeout to the UI. The user was greeted with "Pairing timed
out — The QR window closed before a device was linked" at the exact
moment they had successfully paired.
Three changes:
- session.ts emits `restartRequired: boolean` on the SessionEvent close
payload (true when reason === DisconnectReason.restartRequired).
- pair-handler treats the restart-required close as a no-op: keeps the
listener attached and the DB row in `pending` so the upcoming `open`
event flips it to `connected`.
- session-manager always reconnects on restart-required (250 ms after
the close — no `lastConnectedAt` gate, no 5 s back-off).
Pure helpers (`pair-state.ts`) updated to model the new branch:
- decideOnPairClose returns null when restartRequired (don't touch DB).
- shouldAutoReconnect returns true on restartRequired regardless of
whether the account has ever connected before.
Tests (+1; 26 bot tests, 104 web tests = 130 green):
- pair-state.test.ts gains explicit cases:
* restart-required close → null
* shouldAutoReconnect always true on restart-required (incl.
first-time pair, where hasEverConnected is false — the exact
case that broke in production).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
204 lines
6.5 KiB
TypeScript
204 lines
6.5 KiB
TypeScript
import { eq } from "drizzle-orm";
|
|
import { whatsappAccounts } from "@cmbot/db";
|
|
import { db } from "../db.js";
|
|
import { logger } from "../logger.js";
|
|
import { startSession, type Session, type SessionEvent } from "./session.js";
|
|
|
|
export type SessionState =
|
|
| "pending"
|
|
| "connecting"
|
|
| "connected"
|
|
| "disconnected"
|
|
| "logged_out"
|
|
| "banned";
|
|
|
|
export type StateEvent =
|
|
| { kind: "starting" }
|
|
| { kind: "open" }
|
|
| { kind: "close"; loggedOut: boolean };
|
|
|
|
export function reduceState(current: SessionState, event: StateEvent): SessionState {
|
|
if (event.kind === "starting" && current === "pending") return "connecting";
|
|
if (event.kind === "open" && (current === "connecting" || current === "disconnected")) {
|
|
return "connected";
|
|
}
|
|
if (event.kind === "close") {
|
|
if (event.loggedOut) return "logged_out";
|
|
return "disconnected";
|
|
}
|
|
return current;
|
|
}
|
|
|
|
export type SessionListener = (
|
|
accountId: string,
|
|
state: SessionState,
|
|
event: SessionEvent,
|
|
) => void | Promise<void>;
|
|
|
|
class SessionManager {
|
|
private sessions = new Map<string, Session>();
|
|
private states = new Map<string, SessionState>();
|
|
private listeners = new Set<SessionListener>();
|
|
private reconnectTimers = new Map<string, NodeJS.Timeout>();
|
|
|
|
on(listener: SessionListener): () => void {
|
|
this.listeners.add(listener);
|
|
return () => {
|
|
this.listeners.delete(listener);
|
|
};
|
|
}
|
|
|
|
getState(accountId: string): SessionState {
|
|
return this.states.get(accountId) ?? "pending";
|
|
}
|
|
|
|
getCounts(): Record<SessionState, number> {
|
|
const counts: Record<SessionState, number> = {
|
|
pending: 0,
|
|
connecting: 0,
|
|
connected: 0,
|
|
disconnected: 0,
|
|
logged_out: 0,
|
|
banned: 0,
|
|
};
|
|
for (const state of this.states.values()) counts[state]++;
|
|
return counts;
|
|
}
|
|
|
|
hasSession(accountId: string): boolean {
|
|
return this.sessions.has(accountId);
|
|
}
|
|
|
|
getSession(accountId: string): Session | undefined {
|
|
return this.sessions.get(accountId);
|
|
}
|
|
|
|
async start(accountId: string): Promise<void> {
|
|
if (this.sessions.has(accountId)) {
|
|
logger.debug({ accountId }, "session-manager: already running, ignoring start");
|
|
return;
|
|
}
|
|
const existingTimer = this.reconnectTimers.get(accountId);
|
|
if (existingTimer) {
|
|
clearTimeout(existingTimer);
|
|
this.reconnectTimers.delete(accountId);
|
|
}
|
|
this.transition(accountId, { kind: "starting" });
|
|
|
|
const session = await startSession({
|
|
accountId,
|
|
onEvent: (event) => this.handleEvent(accountId, event),
|
|
});
|
|
this.sessions.set(accountId, session);
|
|
}
|
|
|
|
async stop(accountId: string): Promise<void> {
|
|
const timer = this.reconnectTimers.get(accountId);
|
|
if (timer) {
|
|
clearTimeout(timer);
|
|
this.reconnectTimers.delete(accountId);
|
|
}
|
|
const session = this.sessions.get(accountId);
|
|
if (!session) return;
|
|
await session.close();
|
|
this.sessions.delete(accountId);
|
|
}
|
|
|
|
async stopAll(): Promise<void> {
|
|
await Promise.all([...this.sessions.keys()].map((id) => this.stop(id)));
|
|
}
|
|
|
|
async resumeFromDb(): Promise<void> {
|
|
const rows = await db
|
|
.select({ id: whatsappAccounts.id, status: whatsappAccounts.status })
|
|
.from(whatsappAccounts);
|
|
for (const row of rows) {
|
|
if (row.status === "connected" || row.status === "disconnected") {
|
|
try {
|
|
await this.start(row.id);
|
|
} catch (err) {
|
|
logger.warn({ err, accountId: row.id }, "resumeFromDb: failed to start");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private async handleEvent(accountId: string, event: SessionEvent): Promise<void> {
|
|
if (event.type === "open") {
|
|
this.transition(accountId, { kind: "open" });
|
|
await db
|
|
.update(whatsappAccounts)
|
|
.set({
|
|
status: "connected",
|
|
phoneNumber: event.phoneNumber ?? null,
|
|
lastConnectedAt: new Date(),
|
|
})
|
|
.where(eq(whatsappAccounts.id, accountId));
|
|
} else if (event.type === "close") {
|
|
this.transition(accountId, { kind: "close", loggedOut: event.loggedOut });
|
|
await db
|
|
.update(whatsappAccounts)
|
|
.set({ status: event.loggedOut ? "logged_out" : "disconnected" })
|
|
.where(eq(whatsappAccounts.id, accountId));
|
|
|
|
if (event.loggedOut) {
|
|
await this.stop(accountId);
|
|
} else if (event.restartRequired) {
|
|
// Status 515 — the post-pair-success reconnect. Always re-open
|
|
// immediately (no 5 s back-off, no `lastConnectedAt` gate). If
|
|
// we don't, the auth handshake never completes and the user
|
|
// sees a spurious "Pairing timed out".
|
|
const timer = setTimeout(() => {
|
|
this.reconnectTimers.delete(accountId);
|
|
void this.stop(accountId).then(() => this.start(accountId));
|
|
}, 250);
|
|
this.reconnectTimers.set(accountId, timer);
|
|
} else {
|
|
// Other ephemeral closes (refs exhausted, network blip): only
|
|
// auto-reconnect for accounts that have been linked at least
|
|
// once. During an initial pair attempt this would otherwise
|
|
// restart the pair dance and rotate the QR every few seconds.
|
|
const account = await db.query.whatsappAccounts.findFirst({
|
|
where: (a, { eq }) => eq(a.id, accountId),
|
|
columns: { lastConnectedAt: true },
|
|
});
|
|
if (account?.lastConnectedAt) {
|
|
const timer = setTimeout(() => {
|
|
this.reconnectTimers.delete(accountId);
|
|
void this.stop(accountId).then(() => this.start(accountId));
|
|
}, 5000);
|
|
this.reconnectTimers.set(accountId, timer);
|
|
} else {
|
|
// Brand-new account that hasn't authenticated yet — let the
|
|
// pair-handler clean up via its timeout.
|
|
await this.stop(accountId);
|
|
}
|
|
}
|
|
} else if (event.type === "qr") {
|
|
await db
|
|
.update(whatsappAccounts)
|
|
.set({ lastQrAt: new Date() })
|
|
.where(eq(whatsappAccounts.id, accountId));
|
|
}
|
|
|
|
for (const listener of this.listeners) {
|
|
try {
|
|
await listener(accountId, this.getState(accountId), event);
|
|
} catch (err) {
|
|
logger.warn({ err, accountId }, "session-manager: listener error");
|
|
}
|
|
}
|
|
}
|
|
|
|
private transition(accountId: string, event: StateEvent): void {
|
|
const current = this.states.get(accountId) ?? "pending";
|
|
const next = reduceState(current, event);
|
|
if (current !== next) {
|
|
logger.info({ accountId, from: current, to: next }, "session-manager: state change");
|
|
}
|
|
this.states.set(accountId, next);
|
|
}
|
|
}
|
|
|
|
export const sessionManager = new SessionManager();
|