fix(whatsapp): route login qr through runtime

This commit is contained in:
Peter Steinberger
2026-05-04 09:07:42 +01:00
parent 03ad3c0684
commit 9efbae7acd
3 changed files with 52 additions and 2 deletions

View File

@@ -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.

View File

@@ -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 },

View File

@@ -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;
},