From 3b63d1cb776770676088498d810fe2112a2b47fd Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 21 Dec 2025 13:36:26 +0100 Subject: [PATCH] fix: auto-restart WhatsApp QR login --- docs/health.md | 2 +- docs/refactor/web-gateway-troubleshooting.md | 1 + src/web/login-qr.test.ts | 66 +++++++++ src/web/login-qr.ts | 137 +++++++++++++------ 4 files changed, 161 insertions(+), 45 deletions(-) create mode 100644 src/web/login-qr.test.ts diff --git a/docs/health.md b/docs/health.md index d57a3bf6e..86e7ed82a 100644 --- a/docs/health.md +++ b/docs/health.md @@ -17,7 +17,7 @@ Short guide to verify the WhatsApp Web / Baileys stack without guessing. ## Deep diagnostics - Creds on disk: `ls -l ~/.clawdis/credentials/creds.json` (mtime should be recent). - Session store: `ls -l ~/.clawdis/sessions/sessions.json` (legacy: `~/.clawdis/sessions.json`; path can be overridden in config). Count and recent recipients are surfaced via `status`. -- Relink flow: `clawdis logout && clawdis login --verbose` when status codes 409–515 or `loggedOut` appear in logs. +- Relink flow: `clawdis logout && clawdis login --verbose` when status codes 409–515 or `loggedOut` appear in logs. (Note: the QR login flow auto-restarts once for status 515 after pairing.) ## When something fails - `logged out` or status 409–515 → relink with `clawdis logout` then `clawdis login`. diff --git a/docs/refactor/web-gateway-troubleshooting.md b/docs/refactor/web-gateway-troubleshooting.md index 0998da62a..06bf74147 100644 --- a/docs/refactor/web-gateway-troubleshooting.md +++ b/docs/refactor/web-gateway-troubleshooting.md @@ -10,6 +10,7 @@ read_when: - **Logged out:** Console prints “session logged out”; re-link with `clawdis login`. - **Repeated retries then exit:** Tune reconnect behavior via config `web.reconnect` and restart the Gateway. - **No inbound messages:** Ensure the QR-linked account is online in WhatsApp, and check logs for `web-heartbeat` to confirm auth age/connection. +- **Status 515 right after pairing:** The QR login flow now auto-restarts once; you should not need a manual gateway restart after scanning. - **Fast nuke:** From an allowed WhatsApp sender you can send `/restart` to request a supervised restart (launchd/mac app setups); wait a few seconds for it to come back. ## Helpful commands diff --git a/src/web/login-qr.test.ts b/src/web/login-qr.test.ts new file mode 100644 index 000000000..0d2e82c02 --- /dev/null +++ b/src/web/login-qr.test.ts @@ -0,0 +1,66 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("./session.js", () => { + const createWaSocket = vi.fn( + async ( + _printQr: boolean, + _verbose: boolean, + opts?: { onQr?: (qr: string) => void }, + ) => { + const sock = { ws: { close: vi.fn() } }; + if (opts?.onQr) { + setImmediate(() => opts.onQr?.("qr-data")); + } + return sock; + }, + ); + const waitForWaConnection = vi.fn(); + const formatError = vi.fn((err: unknown) => `formatted:${String(err)}`); + const getStatusCode = vi.fn( + (err: unknown) => + (err as { output?: { statusCode?: number } })?.output?.statusCode ?? + (err as { status?: number })?.status, + ); + const webAuthExists = vi.fn(async () => false); + const readWebSelfId = vi.fn(() => ({ e164: null, jid: null })); + const logoutWeb = vi.fn(async () => true); + return { + createWaSocket, + waitForWaConnection, + formatError, + getStatusCode, + webAuthExists, + readWebSelfId, + logoutWeb, + }; +}); + +vi.mock("./qr-image.js", () => ({ + renderQrPngBase64: vi.fn(async () => "base64"), +})); + +const { startWebLoginWithQr, waitForWebLogin } = await import("./login-qr.js"); +const { createWaSocket, waitForWaConnection, logoutWeb } = await import( + "./session.js" +); + +describe("login-qr", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("restarts login once on status 515 and completes", async () => { + waitForWaConnection + .mockRejectedValueOnce({ output: { statusCode: 515 } }) + .mockResolvedValueOnce(undefined); + + const start = await startWebLoginWithQr({ timeoutMs: 5000 }); + expect(start.qrDataUrl).toBe("data:image/png;base64,base64"); + + const result = await waitForWebLogin({ timeoutMs: 5000 }); + + expect(result.connected).toBe(true); + expect(createWaSocket).toHaveBeenCalledTimes(2); + expect(logoutWeb).not.toHaveBeenCalled(); + }); +}); diff --git a/src/web/login-qr.ts b/src/web/login-qr.ts index f12466d57..5c70b79e2 100644 --- a/src/web/login-qr.ts +++ b/src/web/login-qr.ts @@ -28,6 +28,8 @@ type ActiveLogin = { error?: string; errorStatus?: number; waitPromise: Promise; + restartAttempted: boolean; + verbose: boolean; }; const ACTIVE_LOGIN_TTL_MS = 3 * 60_000; @@ -55,6 +57,45 @@ function isLoginFresh(login: ActiveLogin) { return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS; } +function attachLoginWaiter(login: ActiveLogin) { + login.waitPromise = waitForWaConnection(login.sock) + .then(() => { + if (activeLogin?.id === login.id) { + activeLogin.connected = true; + } + }) + .catch((err) => { + if (activeLogin?.id === login.id) { + activeLogin.error = formatError(err); + activeLogin.errorStatus = getStatusCode(err); + } + }); +} + +async function restartLoginSocket(login: ActiveLogin, runtime: RuntimeEnv) { + if (login.restartAttempted) return false; + login.restartAttempted = true; + runtime.log( + info( + "WhatsApp asked for a restart after pairing (code 515); retrying connection once…", + ), + ); + closeSocket(login.sock); + try { + const sock = await createWaSocket(false, login.verbose); + login.sock = sock; + login.connected = false; + login.error = undefined; + login.errorStatus = undefined; + attachLoginWaiter(login); + return true; + } catch (err) { + login.error = formatError(err); + login.errorStatus = getStatusCode(err); + return false; + } +} + export async function startWebLoginWithQr( opts: { verbose?: boolean; @@ -120,21 +161,11 @@ export async function startWebLoginWithQr( startedAt: Date.now(), connected: false, waitPromise: Promise.resolve(), + restartAttempted: false, + verbose: Boolean(opts.verbose), }; activeLogin = login; - - login.waitPromise = waitForWaConnection(sock) - .then(() => { - if (activeLogin?.id === login.id) { - activeLogin.connected = true; - } - }) - .catch((err) => { - if (activeLogin?.id === login.id) { - activeLogin.error = formatError(err); - activeLogin.errorStatus = getStatusCode(err); - } - }); + attachLoginWaiter(login); let qr: string; try { @@ -175,43 +206,61 @@ export async function waitForWebLogin( }; } const timeoutMs = Math.max(opts.timeoutMs ?? 120_000, 1000); - const timeout = new Promise<"timeout">((resolve) => - setTimeout(() => resolve("timeout"), timeoutMs), - ); - const result = await Promise.race([ - login.waitPromise.then(() => "done"), - timeout, - ]); + const deadline = Date.now() + timeoutMs; - if (result === "timeout") { - return { - connected: false, - message: - "Still waiting for the QR scan. Let me know when you’ve scanned it.", - }; - } + while (true) { + const remaining = deadline - Date.now(); + if (remaining <= 0) { + return { + connected: false, + message: + "Still waiting for the QR scan. Let me know when you’ve scanned it.", + }; + } + const timeout = new Promise<"timeout">((resolve) => + setTimeout(() => resolve("timeout"), remaining), + ); + const result = await Promise.race([ + login.waitPromise.then(() => "done"), + timeout, + ]); - if (login.error) { - if (login.errorStatus === DisconnectReason.loggedOut) { - await logoutWeb(runtime); - const message = - "WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR."; + if (result === "timeout") { + return { + connected: false, + message: + "Still waiting for the QR scan. Let me know when you’ve scanned it.", + }; + } + + if (login.error) { + if (login.errorStatus === DisconnectReason.loggedOut) { + await logoutWeb(runtime); + const message = + "WhatsApp reported the session is logged out. Cleared cached web session; please scan a new QR."; + await resetActiveLogin(message); + runtime.log(danger(message)); + return { connected: false, message }; + } + if (login.errorStatus === 515) { + const restarted = await restartLoginSocket(login, runtime); + if (restarted && isLoginFresh(login)) { + continue; + } + } + const message = `WhatsApp login failed: ${login.error}`; await resetActiveLogin(message); runtime.log(danger(message)); return { connected: false, message }; } - const message = `WhatsApp login failed: ${login.error}`; - await resetActiveLogin(message); - runtime.log(danger(message)); - return { connected: false, message }; - } - if (login.connected) { - const message = "✅ Linked! WhatsApp is ready."; - runtime.log(success(message)); - await resetActiveLogin(); - return { connected: true, message }; - } + if (login.connected) { + const message = "✅ Linked! WhatsApp is ready."; + runtime.log(success(message)); + await resetActiveLogin(); + return { connected: true, message }; + } - return { connected: false, message: "Login ended without a connection." }; + return { connected: false, message: "Login ended without a connection." }; + } }