diff --git a/extensions/whatsapp/src/connection-controller.test.ts b/extensions/whatsapp/src/connection-controller.test.ts index e24c01aacbd..d136dc61aef 100644 --- a/extensions/whatsapp/src/connection-controller.test.ts +++ b/extensions/whatsapp/src/connection-controller.test.ts @@ -1,7 +1,12 @@ import { EventEmitter } from "node:events"; +import { DisconnectReason } from "baileys"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getRegisteredWhatsAppConnectionController } from "./connection-controller-registry.js"; -import { closeWaSocket, WhatsAppConnectionController } from "./connection-controller.js"; +import { + closeWaSocket, + waitForWhatsAppLoginResult, + WhatsAppConnectionController, +} from "./connection-controller.js"; import type { WhatsAppSendKind, WhatsAppSendResult } from "./inbound/send-result.js"; import { createWaSocket, waitForWaConnection } from "./session.js"; @@ -127,6 +132,120 @@ describe("WhatsAppConnectionController", () => { expect(callOrder).toEqual(["create", "wait-for-connection"]); }); + it("restarts login once on status 408 and preserves replacement socket options", async () => { + const initialSock = createSocketWithTransportEmitter(); + const replacementSock = createSocketWithTransportEmitter(); + const waitForConnection = vi + .fn() + .mockRejectedValueOnce({ output: { statusCode: DisconnectReason.timedOut } }) + .mockResolvedValueOnce(undefined); + const onQr = vi.fn(); + const onSocketReplaced = vi.fn(); + const createSocket = vi.fn( + async (_printQr: boolean, _verbose: boolean, opts?: { onQr?: (qr: string) => void }) => { + opts?.onQr?.("qr-after-timeout"); + return replacementSock; + }, + ); + + const result = await waitForWhatsAppLoginResult({ + sock: initialSock as never, + authDir: "/tmp/wa-auth", + isLegacyAuthDir: false, + verbose: true, + runtime: { log: vi.fn() } as never, + waitForConnection: waitForConnection as never, + createSocket: createSocket as never, + socketTiming: { + connectTimeoutMs: 10_000, + defaultQueryTimeoutMs: 20_000, + keepAliveIntervalMs: 30_000, + }, + onQr, + onSocketReplaced, + }); + + expect(result).toEqual({ + outcome: "connected", + restarted: true, + sock: replacementSock, + }); + expect(initialSock.end).toHaveBeenCalledOnce(); + expect(createSocket).toHaveBeenCalledWith(false, true, { + authDir: "/tmp/wa-auth", + connectTimeoutMs: 10_000, + defaultQueryTimeoutMs: 20_000, + keepAliveIntervalMs: 30_000, + onQr, + }); + expect(onQr).toHaveBeenCalledWith("qr-after-timeout"); + expect(onSocketReplaced).toHaveBeenCalledWith(replacementSock); + }); + + it("still honors the post-pairing 515 restart after a status 408 recovery", async () => { + const initialSock = createSocketWithTransportEmitter(); + const afterTimeoutSock = createSocketWithTransportEmitter(); + const afterPairingRestartSock = createSocketWithTransportEmitter(); + const waitForConnection = vi + .fn() + .mockRejectedValueOnce({ output: { statusCode: DisconnectReason.timedOut } }) + .mockRejectedValueOnce({ output: { statusCode: 515 } }) + .mockResolvedValueOnce(undefined); + const createSocket = vi + .fn() + .mockResolvedValueOnce(afterTimeoutSock) + .mockResolvedValueOnce(afterPairingRestartSock); + + const result = await waitForWhatsAppLoginResult({ + sock: initialSock as never, + authDir: "/tmp/wa-auth", + isLegacyAuthDir: false, + verbose: false, + runtime: { log: vi.fn() } as never, + waitForConnection: waitForConnection as never, + createSocket: createSocket as never, + }); + + expect(result).toEqual({ + outcome: "connected", + restarted: true, + sock: afterPairingRestartSock, + }); + expect(createSocket).toHaveBeenCalledTimes(2); + expect(waitForConnection).toHaveBeenCalledTimes(3); + expect(initialSock.end).toHaveBeenCalledOnce(); + expect(afterTimeoutSock.end).toHaveBeenCalledOnce(); + }); + + it("does not keep recreating sockets when login status 408 persists", async () => { + const initialSock = createSocketWithTransportEmitter(); + const replacementSock = createSocketWithTransportEmitter(); + const timeoutError = { output: { statusCode: DisconnectReason.timedOut } }; + const waitForConnection = vi + .fn() + .mockRejectedValueOnce(timeoutError) + .mockRejectedValueOnce(timeoutError); + const createSocket = vi.fn(async () => replacementSock); + + const result = await waitForWhatsAppLoginResult({ + sock: initialSock as never, + authDir: "/tmp/wa-auth", + isLegacyAuthDir: false, + verbose: false, + runtime: { log: vi.fn() } as never, + waitForConnection: waitForConnection as never, + createSocket: createSocket as never, + }); + + expect(result).toMatchObject({ + outcome: "failed", + statusCode: DisconnectReason.timedOut, + error: timeoutError, + }); + expect(createSocket).toHaveBeenCalledOnce(); + expect(waitForConnection).toHaveBeenCalledTimes(2); + }); + it("keeps the previous registered controller until a replacement listener is ready", async () => { const liveController = new WhatsAppConnectionController({ accountId: "work", diff --git a/extensions/whatsapp/src/connection-controller.ts b/extensions/whatsapp/src/connection-controller.ts index 06764feb24e..c02bdcef735 100644 --- a/extensions/whatsapp/src/connection-controller.ts +++ b/extensions/whatsapp/src/connection-controller.ts @@ -17,8 +17,12 @@ import { import type { WhatsAppSocketTimingOptions } from "./socket-timing.js"; const LOGGED_OUT_STATUS = DisconnectReason?.loggedOut ?? 401; +const POST_PAIRING_RESTART_STATUS = 515; +const TIMED_OUT_STATUS = DisconnectReason?.timedOut ?? 408; const WHATSAPP_LOGIN_RESTART_MESSAGE = "WhatsApp asked for a restart after pairing (code 515); waiting for creds to save…"; +const WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE = + "WhatsApp connection timed out before login; retrying with a fresh socket…"; const WHATSAPP_LOGGED_OUT_RELINK_MESSAGE = "WhatsApp reported the session is logged out. Cleared cached web session; please rerun openclaw channels login and scan the QR again."; export const WHATSAPP_LOGGED_OUT_QR_MESSAGE = @@ -85,10 +89,28 @@ type WhatsAppReconnectAttemptDecision = { healthState: "stopped" | "reconnecting"; }; +type LoginSocketRestartKind = "post-pairing" | "timeout"; + function createNeverResolvePromise(): Promise { return new Promise(() => {}); } +function getLoginSocketRestartKind(statusCode: number | undefined): LoginSocketRestartKind | null { + if (statusCode === POST_PAIRING_RESTART_STATUS) { + return "post-pairing"; + } + if (statusCode === TIMED_OUT_STATUS) { + return "timeout"; + } + return null; +} + +function getLoginSocketRestartMessage(kind: LoginSocketRestartKind): string { + return kind === "timeout" + ? WHATSAPP_LOGIN_TIMEOUT_RESTART_MESSAGE + : WHATSAPP_LOGIN_RESTART_MESSAGE; +} + type SocketActivityEmitter = { on?: (event: string, listener: (...args: unknown[]) => void) => void; off?: (event: string, listener: (...args: unknown[]) => void) => void; @@ -201,21 +223,30 @@ export async function waitForWhatsAppLoginResult(params: { const wait = params.waitForConnection ?? waitForWaConnection; const createSocket = params.createSocket ?? createWaSocket; let currentSock = params.sock; - let restarted = false; + let postPairingRestarted = false; + let timeoutRestarted = false; while (true) { try { await wait(currentSock); return { outcome: "connected", - restarted, + restarted: postPairingRestarted || timeoutRestarted, sock: currentSock, }; } catch (err) { const statusCode = getStatusCode(err); - if (statusCode === 515 && !restarted) { - restarted = true; - params.runtime.log(info(WHATSAPP_LOGIN_RESTART_MESSAGE)); + const restartKind = getLoginSocketRestartKind(statusCode); + const canRestart = + (restartKind === "post-pairing" && !postPairingRestarted) || + (restartKind === "timeout" && !timeoutRestarted); + if (restartKind && canRestart) { + if (restartKind === "post-pairing") { + postPairingRestarted = true; + } else { + timeoutRestarted = true; + } + params.runtime.log(info(getLoginSocketRestartMessage(restartKind))); closeWaSocket(currentSock); try { currentSock = await createSocket(false, params.verbose, { diff --git a/extensions/whatsapp/src/login-qr.test.ts b/extensions/whatsapp/src/login-qr.test.ts index ac6319cadc9..93135ef74db 100644 --- a/extensions/whatsapp/src/login-qr.test.ts +++ b/extensions/whatsapp/src/login-qr.test.ts @@ -129,6 +129,37 @@ describe("login-qr", () => { expect(logoutWebMock).not.toHaveBeenCalled(); }); + it("returns a replacement QR when status 408 happens before the first QR", async () => { + const accountId = "timeout-before-first-qr"; + createWaSocketMock + .mockImplementationOnce(async () => ({ ws: { close: vi.fn() } }) as never) + .mockImplementationOnce( + async ( + _printQr: boolean, + _verbose: boolean, + opts?: { authDir?: string; onQr?: (qr: string) => void }, + ) => { + const sock = { ws: { close: vi.fn() } }; + setImmediate(() => opts?.onQr?.("qr-after-timeout")); + return sock as never; + }, + ); + waitForWaConnectionMock + .mockRejectedValueOnce({ output: { statusCode: 408 } }) + .mockImplementation(() => new Promise(() => {})); + + const start = await startWebLoginWithQr({ + timeoutMs: 5000, + accountId, + }); + + expect(start).toEqual({ + qrDataUrl: "data:image/png;base64,encoded:qr-after-timeout", + message: "Scan this QR in WhatsApp → Linked Devices.", + }); + expect(createWaSocketMock).toHaveBeenCalledTimes(2); + }); + it("clears auth and reports a relink message when WhatsApp is logged out", async () => { waitForWaConnectionMock.mockRejectedValueOnce({ output: { statusCode: 401 }, diff --git a/extensions/whatsapp/src/login-qr.ts b/extensions/whatsapp/src/login-qr.ts index da9fbd9ae15..311a6b253b1 100644 --- a/extensions/whatsapp/src/login-qr.ts +++ b/extensions/whatsapp/src/login-qr.ts @@ -187,6 +187,7 @@ function attachLoginWaiter(accountId: string, login: ActiveLogin) { return; } const qrVersion = updateLoginQrState(current, qr); + notifyQrUpdate(current); renderLatestQrDataUrlInBackground({ accountId, loginId: login.id, @@ -269,8 +270,29 @@ async function waitForQrOrRecoveredLogin(params: { message: latest.error ? `WhatsApp login failed: ${latest.error}` : "WhatsApp login failed.", } as const; }); + const qrUpdateResult = params.login.qrUpdatePromise.then(() => { + const current = activeLogins.get(params.accountId); + if (current?.id !== params.login.id) { + return { + outcome: "failed", + message: "WhatsApp login was replaced by a newer request.", + } as const; + } + if (current.qr) { + return { outcome: "qr", qr: current.qr } as const; + } + if (current.connected) { + return { outcome: "connected" } as const; + } + return { + outcome: "failed", + message: current.error + ? `WhatsApp login failed: ${current.error}` + : "WhatsApp QR update ended without an active QR.", + } as const; + }); - return await Promise.race([qrResult, loginResult]); + return await Promise.race([qrResult, loginResult, qrUpdateResult]); } export async function startWebLoginWithQr(