From 9efbae7acda42f0767af5bd1966c9992983d5379 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 4 May 2026 09:07:42 +0100 Subject: [PATCH] fix(whatsapp): route login qr through runtime --- CHANGELOG.md | 1 + .../whatsapp/src/login.coverage.test.ts | 38 ++++++++++++++++++- extensions/whatsapp/src/login.ts | 15 +++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b595e180b1..34ae3b34527 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -213,6 +213,7 @@ Docs: https://docs.openclaw.ai - Gateway/update: avoid `launchctl kickstart -k` immediately after fresh macOS update bootstraps, and unlink dangling global plugin-runtime symlinks during packaged postinstall and `doctor --fix` so upgrades no longer SIGTERM the newly booted Gateway or leave bundled plugin imports pointed at pruned `plugin-runtime-deps` trees. Completes #76261 and fixes #76466. (#76929) - Google Chat: normalize custom Google auth transport headers before google-auth/gaxios interceptors run, restoring webhook token verification when certificate retrieval expects Fetch `Headers`. Fixes #76742. Thanks @donbowman. - Google Chat: normalize Google auth certificate response headers before google-auth-library reads cache-control, so inbound webhook auth no longer rejects with `res?.headers.get is not a function`. Fixes #76880. Thanks @donbowman. +- WhatsApp: route terminal login QR output through the active runtime for initial and restart sockets, so `openclaw channels login --channel whatsapp` does not lose the QR behind direct stdout writes. Fixes #76213. Thanks @dougvk. - Doctor/plugins: reset stale `plugins.slots.memory` and `plugins.slots.contextEngine` references during `doctor --fix`, so cleanup of missing plugin config does not leave unrecoverable slot owners behind. Fixes #76550 and #76551. Thanks @vincentkoc. - Docs/WhatsApp: merge the duplicate top-level `web` objects in the gateway channel config example so copy-pasted WhatsApp config keeps both `web.whatsapp` and reconnect settings. Fixes #76619. Thanks @WadydX. - Plugins/Anthropic: expose Claude thinking profiles from the bundled provider-policy artifact so non-runtime callers keep Opus 4.7 `adaptive`, `xhigh`, and `max` instead of downgrading to `high`. Fixes #76779. Thanks @tomascupr and @iAbhi001. diff --git a/extensions/whatsapp/src/login.coverage.test.ts b/extensions/whatsapp/src/login.coverage.test.ts index 9c0e1b9b43b..90f07d53208 100644 --- a/extensions/whatsapp/src/login.coverage.test.ts +++ b/extensions/whatsapp/src/login.coverage.test.ts @@ -1,7 +1,9 @@ import { rmSync } from "node:fs"; import fs from "node:fs/promises"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { loginWeb } from "./login.js"; +import { renderQrTerminal } from "./qr-terminal.js"; import { createWaSocket, formatError, waitForWaConnection } from "./session.js"; const rmMock = vi.spyOn(fs, "rm"); @@ -63,9 +65,14 @@ vi.mock("./session.js", async () => { }; }); +vi.mock("./qr-terminal.js", () => ({ + renderQrTerminal: vi.fn(async (qr: string) => `terminal:${qr}\n`), +})); + const createWaSocketMock = vi.mocked(createWaSocket); const waitForWaConnectionMock = vi.mocked(waitForWaConnection); const formatErrorMock = vi.mocked(formatError); +const renderQrTerminalMock = vi.mocked(renderQrTerminal); async function flushTasks() { await Promise.resolve(); @@ -94,7 +101,7 @@ describe("loginWeb coverage", () => { .mockRejectedValueOnce({ error: { output: { statusCode: 515 } } }) .mockResolvedValueOnce(undefined); - const runtime = { log: vi.fn(), error: vi.fn() } as never; + const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; const pendingLogin = loginWeb(false, waitForWaConnectionMock as never, runtime); await flushTasks(); @@ -109,6 +116,35 @@ describe("loginWeb coverage", () => { expect(secondSock.ws.close).toHaveBeenCalled(); }); + it("routes QR output through runtime for initial and restart sockets", async () => { + waitForWaConnectionMock + .mockRejectedValueOnce({ error: { output: { statusCode: 515 } } }) + .mockResolvedValueOnce(undefined); + + const runtime: RuntimeEnv = { log: vi.fn(), error: vi.fn(), exit: vi.fn() }; + await loginWeb(false, waitForWaConnectionMock as never, runtime); + + expect(createWaSocketMock).toHaveBeenCalledTimes(2); + expect(createWaSocketMock.mock.calls[0]?.[0]).toBe(false); + const initialOpts = createWaSocketMock.mock.calls[0]?.[2] as + | { onQr?: (qr: string) => void } + | undefined; + const restartOpts = createWaSocketMock.mock.calls[1]?.[2] as + | { onQr?: (qr: string) => void } + | undefined; + expect(initialOpts?.onQr).toBe(restartOpts?.onQr); + + initialOpts?.onQr?.("initial-qr"); + restartOpts?.onQr?.("restart-qr"); + await flushTasks(); + + expect(runtime.log).toHaveBeenCalledWith("Scan this QR in WhatsApp (Linked Devices):"); + expect(runtime.log).toHaveBeenCalledWith("terminal:initial-qr"); + expect(runtime.log).toHaveBeenCalledWith("terminal:restart-qr"); + expect(renderQrTerminalMock).toHaveBeenCalledWith("initial-qr", { small: true }); + expect(renderQrTerminalMock).toHaveBeenCalledWith("restart-qr", { small: true }); + }); + it("clears creds and throws when logged out", async () => { waitForWaConnectionMock.mockRejectedValueOnce({ output: { statusCode: 401 }, diff --git a/extensions/whatsapp/src/login.ts b/extensions/whatsapp/src/login.ts index 5c7e8da61b3..497078a47e1 100644 --- a/extensions/whatsapp/src/login.ts +++ b/extensions/whatsapp/src/login.ts @@ -6,6 +6,7 @@ import { logInfo } from "openclaw/plugin-sdk/text-runtime"; import { resolveWhatsAppAccount } from "./accounts.js"; import { restoreCredsFromBackupIfNeeded } from "./auth-store.js"; import { closeWaSocketSoon, waitForWhatsAppLoginResult } from "./connection-controller.js"; +import { renderQrTerminal } from "./qr-terminal.js"; import { createWaSocket, waitForWaConnection } from "./session.js"; import { resolveWhatsAppSocketTiming } from "./socket-timing.js"; @@ -19,9 +20,20 @@ export async function loginWeb( const account = resolveWhatsAppAccount({ cfg, accountId }); const socketTiming = resolveWhatsAppSocketTiming(cfg); const restoredFromBackup = await restoreCredsFromBackupIfNeeded(account.authDir); - let sock = await createWaSocket(true, verbose, { + const onQr = (qr: string) => { + runtime.log("Scan this QR in WhatsApp (Linked Devices):"); + void renderQrTerminal(qr, { small: true }) + .then((output) => { + runtime.log(output.endsWith("\n") ? output.slice(0, -1) : output); + }) + .catch((err) => { + runtime.error(`failed rendering WhatsApp QR: ${String(err)}`); + }); + }; + let sock = await createWaSocket(false, verbose, { authDir: account.authDir, ...socketTiming, + onQr, }); logInfo("Waiting for WhatsApp connection...", runtime); try { @@ -33,6 +45,7 @@ export async function loginWeb( runtime, waitForConnection, socketTiming, + onQr, onSocketReplaced: (replacementSock) => { sock = replacementSock; },