diff --git a/adapters/whatsapp/__tests__/protocol.test.ts b/adapters/whatsapp/__tests__/protocol.test.ts new file mode 100644 index 00000000..e381c261 --- /dev/null +++ b/adapters/whatsapp/__tests__/protocol.test.ts @@ -0,0 +1,190 @@ +import { describe, it, expect } from 'bun:test' +import * as fs from 'node:fs' +import * as path from 'node:path' +import * as os from 'node:os' +import { pollWhatsAppLoginWithQr, startWhatsAppLoginWithQr } from '../protocol.js' +import type { WhatsAppSocket } from '../session.js' + +type ConnectionUpdate = { + qr?: string + connection?: 'open' | 'close' + lastDisconnect?: { error: unknown } +} + +type FakeSocket = { + sock: WhatsAppSocket + emit: (update: ConnectionUpdate) => Promise +} + +function makeFakeSocket(): FakeSocket { + const handlers: Array<(update: ConnectionUpdate) => void | Promise> = [] + const sock = { + user: { id: '8613800000000:1@s.whatsapp.net' }, + ev: { + on: (event: string, handler: (update: ConnectionUpdate) => void | Promise) => { + if (event === 'connection.update') handlers.push(handler) + }, + }, + end: () => {}, + } + return { + sock: sock as unknown as WhatsAppSocket, + emit: async (update) => { + for (const handler of handlers) await handler(update) + }, + } +} + +function makeFakeSocketFactory() { + const sockets: FakeSocket[] = [] + const createSocket = async () => { + const fake = makeFakeSocket() + sockets.push(fake) + return fake.sock + } + return { sockets, createSocket } +} + +async function waitFor(predicate: () => boolean, timeoutMs = 5000): Promise { + const started = Date.now() + while (!predicate()) { + if (Date.now() - started > timeoutMs) throw new Error('waitFor timed out') + await new Promise((resolve) => setTimeout(resolve, 10)) + } +} + +function makeTempAuthDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'wa-protocol-test-')) +} + +const restartError = (statusCode: number) => ({ output: { statusCode } }) + +describe('whatsapp login protocol', () => { + it('recreates the socket after post-pairing restart (515) and completes login', async () => { + const { sockets, createSocket } = makeFakeSocketFactory() + const authDir = makeTempAuthDir() + + const startPromise = startWhatsAppLoginWithQr({ authDir, createSocket }) + await waitFor(() => sockets.length === 1) + await sockets[0].emit({ qr: 'qr-1' }) + const start = await startPromise + expect(start.qr).toBe('qr-1') + + // Scan succeeds -> Baileys closes the socket with DisconnectReason.restartRequired (515). + await sockets[0].emit({ + connection: 'close', + lastDisconnect: { error: restartError(515) }, + }) + + // The login flow must recreate the socket instead of failing. + await waitFor(() => sockets.length === 2) + await sockets[1].emit({ connection: 'open' }) + + const poll = await pollWhatsAppLoginWithQr({ sessionKey: start.sessionKey }) + expect(poll.connected).toBe(true) + if (poll.connected) { + expect(poll.accountJid).toBe('8613800000000:1@s.whatsapp.net') + expect(poll.authDir).toBe(path.resolve(authDir)) + } + }) + + it('recreates the socket after QR timeout (408) and serves a fresh QR', async () => { + const { sockets, createSocket } = makeFakeSocketFactory() + const authDir = makeTempAuthDir() + + const startPromise = startWhatsAppLoginWithQr({ authDir, createSocket }) + await waitFor(() => sockets.length === 1) + await sockets[0].emit({ qr: 'qr-1' }) + const start = await startPromise + + await sockets[0].emit({ + connection: 'close', + lastDisconnect: { error: restartError(408) }, + }) + + await waitFor(() => sockets.length === 2) + await sockets[1].emit({ qr: 'qr-2' }) + + const poll = await pollWhatsAppLoginWithQr({ sessionKey: start.sessionKey }) + expect(poll.connected).toBe(false) + if (!poll.connected) { + expect(poll.status).toBe('waiting') + expect(poll.qr).toBe('qr-2') + } + }) + + it('reports an error when the connection closes with a non-restart status', async () => { + const { sockets, createSocket } = makeFakeSocketFactory() + const authDir = makeTempAuthDir() + + const startPromise = startWhatsAppLoginWithQr({ authDir, createSocket }) + await waitFor(() => sockets.length === 1) + await sockets[0].emit({ qr: 'qr-1' }) + const start = await startPromise + + await sockets[0].emit({ + connection: 'close', + lastDisconnect: { error: restartError(500) }, + }) + + const poll = await pollWhatsAppLoginWithQr({ sessionKey: start.sessionKey }) + expect(poll.connected).toBe(false) + if (!poll.connected) { + expect(poll.status).toBe('error') + expect(poll.message).toContain('connection closed') + } + expect(sockets.length).toBe(1) + }) + + it('reports logged out instead of restarting', async () => { + const { sockets, createSocket } = makeFakeSocketFactory() + const authDir = makeTempAuthDir() + + const startPromise = startWhatsAppLoginWithQr({ authDir, createSocket }) + await waitFor(() => sockets.length === 1) + await sockets[0].emit({ qr: 'qr-1' }) + const start = await startPromise + + await sockets[0].emit({ + connection: 'close', + lastDisconnect: { error: restartError(401) }, + }) + + const poll = await pollWhatsAppLoginWithQr({ sessionKey: start.sessionKey }) + expect(poll.connected).toBe(false) + if (!poll.connected) { + expect(poll.status).toBe('error') + expect(poll.message).toContain('logged out') + } + expect(sockets.length).toBe(1) + }) + + it('stops restarting after the restart limit and reports an error', async () => { + const { sockets, createSocket } = makeFakeSocketFactory() + const authDir = makeTempAuthDir() + + const startPromise = startWhatsAppLoginWithQr({ authDir, createSocket }) + await waitFor(() => sockets.length === 1) + await sockets[0].emit({ qr: 'qr-1' }) + const start = await startPromise + + for (let i = 0; i < 5; i++) { + await sockets[i].emit({ + connection: 'close', + lastDisconnect: { error: restartError(408) }, + }) + await waitFor(() => sockets.length === i + 2) + } + await sockets[5].emit({ + connection: 'close', + lastDisconnect: { error: restartError(408) }, + }) + + const poll = await pollWhatsAppLoginWithQr({ sessionKey: start.sessionKey }) + expect(poll.connected).toBe(false) + if (!poll.connected) { + expect(poll.status).toBe('error') + } + expect(sockets.length).toBe(6) + }) +}) diff --git a/adapters/whatsapp/protocol.ts b/adapters/whatsapp/protocol.ts index 090dab61..5c40688d 100644 --- a/adapters/whatsapp/protocol.ts +++ b/adapters/whatsapp/protocol.ts @@ -4,6 +4,7 @@ import { clearWhatsAppAuth, closeWhatsAppSocket, createWhatsAppSocket, + getWhatsAppDisconnectStatus, isWhatsAppLoggedOut, waitForWhatsAppCredsSave, type WhatsAppSocket, @@ -29,23 +30,35 @@ export type WhatsAppLoginPollResult = message: string } +type CreateLoginSocket = typeof createWhatsAppSocket + type LoginSession = { authDir: string sock: WhatsAppSocket + createSocket: CreateLoginSocket qr?: string connected: boolean accountJid?: string error?: string + restarts: number createdAt: number } const LOGIN_TTL_MS = 2 * 60 * 1000 const WAIT_FOR_QR_MS = 20_000 +// After a successful QR scan WhatsApp always closes the socket with +// DisconnectReason.restartRequired; login must reconnect to finish pairing. +const POST_PAIRING_RESTART_STATUS = 515 +// QR rotation exhausts after ~1min and closes with DisconnectReason.timedOut; +// reconnecting serves a fresh QR instead of failing the login session. +const TIMED_OUT_STATUS = 408 +const MAX_LOGIN_RESTARTS = 5 const sessions = new Map() export async function startWhatsAppLoginWithQr(options: { authDir: string force?: boolean + createSocket?: CreateLoginSocket }): Promise { cleanupExpiredSessions() const authDir = path.resolve(options.authDir) @@ -58,36 +71,19 @@ export async function startWhatsAppLoginWithQr(options: { const session: LoginSession = { authDir, sock: undefined as unknown as WhatsAppSocket, + createSocket: options.createSocket ?? createWhatsAppSocket, connected: false, + restarts: 0, createdAt: Date.now(), } - const qrPromise = waitForQr(session) - const sock = await createWhatsAppSocket({ - authDir, - onQr: (qr) => { - session.qr = qr - }, - }) - session.sock = sock sessions.set(sessionKey, session) - - sock.ev.on('connection.update', async (update) => { - if (update.qr) { - session.qr = update.qr - } - if (update.connection === 'open') { - await waitForWhatsAppCredsSave(authDir) - session.connected = true - session.accountJid = sock.user?.id ?? '' - } - if (update.connection === 'close') { - if (isWhatsAppLoggedOut(update.lastDisconnect?.error)) { - session.error = 'WhatsApp session logged out. Please scan again.' - } else if (!session.connected) { - session.error = 'WhatsApp login connection closed. Please retry.' - } - } - }) + const qrPromise = waitForQr(session) + try { + await connectLoginSocket(sessionKey, session) + } catch (err) { + sessions.delete(sessionKey) + throw err + } await qrPromise return { @@ -99,6 +95,51 @@ export async function startWhatsAppLoginWithQr(options: { } } +async function connectLoginSocket(sessionKey: string, session: LoginSession): Promise { + const sock = await session.createSocket({ + authDir: session.authDir, + onQr: (qr) => { + session.qr = qr + }, + }) + session.sock = sock + + sock.ev.on('connection.update', async (update) => { + if (sessions.get(sessionKey) !== session || session.sock !== sock) return + if (update.qr) { + session.qr = update.qr + } + if (update.connection === 'open') { + await waitForWhatsAppCredsSave(session.authDir) + session.connected = true + session.accountJid = sock.user?.id ?? '' + } + if (update.connection === 'close' && !session.connected) { + if (isWhatsAppLoggedOut(update.lastDisconnect?.error)) { + session.error = 'WhatsApp session logged out. Please scan again.' + return + } + const status = getWhatsAppDisconnectStatus(update.lastDisconnect?.error) + const shouldRestart = (status === POST_PAIRING_RESTART_STATUS || status === TIMED_OUT_STATUS) + && session.restarts < MAX_LOGIN_RESTARTS + if (!shouldRestart) { + session.error = 'WhatsApp login connection closed. Please retry.' + return + } + session.restarts += 1 + closeWhatsAppSocket(sock, 'WhatsApp login restart') + try { + await waitForWhatsAppCredsSave(session.authDir) + await connectLoginSocket(sessionKey, session) + } catch (err) { + if (sessions.get(sessionKey) !== session) return + console.warn('[WhatsApp] Login socket restart failed:', err instanceof Error ? err.message : err) + session.error = 'WhatsApp login connection closed. Please retry.' + } + } + }) +} + export async function pollWhatsAppLoginWithQr(options: { sessionKey: string }): Promise {