fix(whatsapp): keep QR login state in sync

Keep WhatsApp QR login state synced across gateway, macOS, and UI wait flows.

- Preserve the latest QR data URL/version while login polling rotates codes.
- Keep the wait-result protocol bounded to current QR metadata.
- Stabilize QR rendering and media fixture coverage after rebasing on main.

Validation:
- pnpm test extensions/whatsapp/src/login-qr.test.ts extensions/whatsapp/src/media.test.ts extensions/whatsapp/src/agent-tools-login.test.ts src/gateway/protocol/channels.schema.test.ts src/gateway/server-methods/web.start.test.ts ui/src/ui/controllers/channels.test.ts
- pnpm test:extension whatsapp
- cd apps/macos && swift test --filter ChannelsSettingsSmokeTests
- GitHub PR checks: 62 success, 5 skipped
This commit is contained in:
Val Alexander
2026-04-24 15:37:16 -05:00
committed by GitHub
parent 86099ec62a
commit 245451b6a9
20 changed files with 948 additions and 76 deletions

View File

@@ -310,6 +310,7 @@ Docs: https://docs.openclaw.ai
- Codex harness: default app-server runs to unchained local execution, so OpenAI heartbeats can use network and shell tools without stalling behind native Codex approvals or the workspace-write sandbox.
- Codex harness: fail closed for unknown native app-server approval methods instead of routing unsupported future approval shapes through OpenClaw approval grants. (#70356) Thanks @Lucenx9.
- Codex harness: apply the GPT-5 behavior and heartbeat prompt overlay to native Codex app-server runs, so `codex/gpt-5.x` sessions get the same follow-through, tool-use, and proactive heartbeat guidance as OpenAI GPT-5 runs.
- WhatsApp/login QR: propagate refreshed QR images through `web.login.wait` consumers and compare against each caller's current QR instead of shared waiter state, so rotated QR codes stay synchronized across Control UI, macOS, and concurrent waiters. (#70009) Thanks @BunsDev.
- Codex harness: add an explicit Guardian mode for Codex app-server approvals, plus a Docker live probe for approved and ask-back Guardian decisions, while keeping default app-server runs unchained for unattended local heartbeats. The legacy `OPENCLAW_CODEX_APP_SERVER_GUARDIAN` shortcut is removed; use plugin config `appServer.mode: "guardian"` or `OPENCLAW_CODEX_APP_SERVER_MODE=guardian`. Thanks @pashpashpash.
- OpenAI/Responses: keep embedded OpenAI Responses runs on HTTP when `models.providers.openai.baseUrl` points at a local mock or other non-public endpoint, so mocked/custom endpoints no longer drift onto the hardcoded public websocket transport. (#69815) Thanks @vincentkoc.
- Channels/config: require resolved runtime config on channel send/action/client helpers and block runtime helper `loadConfig()` calls, so SecretRefs are resolved at startup/boundaries instead of being re-read during sends.

View File

@@ -1,6 +1,24 @@
import Foundation
import OpenClawProtocol
func whatsappLoginWaitRequestTimeoutMs(
startedAt: Date,
timeoutMs: Int,
didRunFinalWait: inout Bool,
now: Date = Date()) -> Int?
{
let elapsedMs = Int(now.timeIntervalSince(startedAt) * 1000)
let remainingMs = max(timeoutMs - elapsedMs, 0)
if remainingMs > 0 {
return remainingMs
}
if didRunFinalWait {
return nil
}
didRunFinalWait = true
return 1
}
extension ChannelsStore {
func start() {
guard !self.isPreview else { return }
@@ -77,18 +95,28 @@ extension ChannelsStore {
guard !self.whatsappBusy else { return }
self.whatsappBusy = true
defer { self.whatsappBusy = false }
let startedAt = Date()
var didRunFinalWait = false
do {
let params: [String: AnyCodable] = [
"timeoutMs": AnyCodable(timeoutMs),
]
let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded(
method: .webLoginWait,
params: params,
timeoutMs: Double(timeoutMs) + 5000)
self.whatsappLoginMessage = result.message
self.whatsappLoginConnected = result.connected
if result.connected {
self.whatsappLoginQrDataUrl = nil
while let remainingMs = whatsappLoginWaitRequestTimeoutMs(
startedAt: startedAt,
timeoutMs: timeoutMs,
didRunFinalWait: &didRunFinalWait)
{
var params: [String: AnyCodable] = [
"timeoutMs": AnyCodable(remainingMs),
]
if let currentQrDataUrl = self.whatsappLoginQrDataUrl {
params["currentQrDataUrl"] = AnyCodable(currentQrDataUrl)
}
let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded(
method: .webLoginWait,
params: params,
timeoutMs: Double(remainingMs) + 5000)
self.applyWhatsAppLoginWaitResult(result)
if result.connected || result.qrDataUrl == nil || didRunFinalWait {
break
}
}
} catch {
self.whatsappLoginMessage = error.localizedDescription
@@ -151,9 +179,10 @@ private struct WhatsAppLoginStartResult: Codable {
let connected: Bool?
}
private struct WhatsAppLoginWaitResult: Codable {
struct WhatsAppLoginWaitResult: Codable {
let connected: Bool
let message: String
let qrDataUrl: String?
}
private struct ChannelLogoutResult: Codable {

View File

@@ -290,6 +290,16 @@ final class ChannelsStore {
return self.snapshot?.channelOrder ?? []
}
func applyWhatsAppLoginWaitResult(_ result: WhatsAppLoginWaitResult) {
self.whatsappLoginMessage = result.message
self.whatsappLoginConnected = result.connected
if let qrDataUrl = result.qrDataUrl {
self.whatsappLoginQrDataUrl = qrDataUrl
} else if result.connected {
self.whatsappLoginQrDataUrl = nil
}
}
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview
}

View File

@@ -2610,18 +2610,22 @@ public struct WebLoginStartParams: Codable, Sendable {
public struct WebLoginWaitParams: Codable, Sendable {
public let timeoutms: Int?
public let accountid: String?
public let currentqrdataurl: String?
public init(
timeoutms: Int?,
accountid: String?)
accountid: String?,
currentqrdataurl: String?)
{
self.timeoutms = timeoutms
self.accountid = accountid
self.currentqrdataurl = currentqrdataurl
}
private enum CodingKeys: String, CodingKey {
case timeoutms = "timeoutMs"
case accountid = "accountId"
case currentqrdataurl = "currentQrDataUrl"
}
}

View File

@@ -156,4 +156,63 @@ struct ChannelsSettingsSmokeTests {
let view = ChannelsSettings(store: store)
_ = view.body
}
@Test func `whatsapp login wait result keeps latest qr until connected`() {
let store = makeChannelsStore(channels: [:])
store.whatsappLoginQrDataUrl = "data:image/png;base64,initial"
store.applyWhatsAppLoginWaitResult(
WhatsAppLoginWaitResult(
connected: false,
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,rotated"))
#expect(store.whatsappLoginQrDataUrl == "data:image/png;base64,rotated")
#expect(store.whatsappLoginConnected == false)
store.applyWhatsAppLoginWaitResult(
WhatsAppLoginWaitResult(
connected: false,
message: "Still waiting for the QR scan. Let me know when youve scanned it.",
qrDataUrl: nil))
#expect(store.whatsappLoginQrDataUrl == "data:image/png;base64,rotated")
store.applyWhatsAppLoginWaitResult(
WhatsAppLoginWaitResult(
connected: true,
message: "✅ Linked! WhatsApp is ready.",
qrDataUrl: nil))
#expect(store.whatsappLoginQrDataUrl == nil)
#expect(store.whatsappLoginConnected == true)
}
@Test func `whatsapp login wait budget allows one final poll`() {
let startedAt = Date(timeIntervalSince1970: 1_700_000_000)
var didRunFinalWait = false
#expect(
whatsappLoginWaitRequestTimeoutMs(
startedAt: startedAt,
timeoutMs: 1_000,
didRunFinalWait: &didRunFinalWait,
now: Date(timeInterval: 0.25, since: startedAt)) == 750)
#expect(didRunFinalWait == false)
#expect(
whatsappLoginWaitRequestTimeoutMs(
startedAt: startedAt,
timeoutMs: 1_000,
didRunFinalWait: &didRunFinalWait,
now: Date(timeInterval: 1.25, since: startedAt)) == 1)
#expect(didRunFinalWait == true)
#expect(
whatsappLoginWaitRequestTimeoutMs(
startedAt: startedAt,
timeoutMs: 1_000,
didRunFinalWait: &didRunFinalWait,
now: Date(timeInterval: 1.5, since: startedAt)) == nil)
}
}

View File

@@ -2610,18 +2610,22 @@ public struct WebLoginStartParams: Codable, Sendable {
public struct WebLoginWaitParams: Codable, Sendable {
public let timeoutms: Int?
public let accountid: String?
public let currentqrdataurl: String?
public init(
timeoutms: Int?,
accountid: String?)
accountid: String?,
currentqrdataurl: String?)
{
self.timeoutms = timeoutms
self.accountid = accountid
self.currentqrdataurl = currentqrdataurl
}
private enum CodingKeys: String, CodingKey {
case timeoutms = "timeoutMs"
case accountid = "accountId"
case currentqrdataurl = "currentQrDataUrl"
}
}

View File

@@ -0,0 +1,81 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js";
import { createWhatsAppLoginTool } from "./agent-tools-login.js";
vi.mock("../login-qr-api.js", () => ({
startWebLoginWithQr: vi.fn(),
waitForWebLogin: vi.fn(),
}));
const startWebLoginWithQrMock = vi.mocked(startWebLoginWithQr);
const waitForWebLoginMock = vi.mocked(waitForWebLogin);
describe("createWhatsAppLoginTool", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("passes the caller's current QR back into wait actions", async () => {
const accountId = "account-1";
waitForWebLoginMock.mockResolvedValueOnce({
connected: false,
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,next-qr",
});
const tool = createWhatsAppLoginTool();
const result = await tool.execute("tool-call-1", {
action: "wait",
timeoutMs: 5000,
accountId,
currentQrDataUrl: "data:image/png;base64,current-qr",
});
expect(waitForWebLoginMock).toHaveBeenCalledWith({
accountId,
timeoutMs: 5000,
currentQrDataUrl: "data:image/png;base64,current-qr",
});
expect(result).toEqual({
content: [
{
type: "text",
text: [
"QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
"",
"Open WhatsApp → Linked Devices and scan:",
"",
"![whatsapp-qr](data:image/png;base64,next-qr)",
].join("\n"),
},
],
details: {
connected: false,
qr: true,
},
});
});
it("does not retain QR state across tool actions", async () => {
const accountId = "account-2";
startWebLoginWithQrMock.mockResolvedValueOnce({
connected: false,
message: "Scan this QR in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,current-qr",
});
waitForWebLoginMock.mockResolvedValueOnce({
connected: true,
message: "✅ Linked! WhatsApp is ready.",
});
const tool = createWhatsAppLoginTool();
await tool.execute("tool-call-start", { action: "start", accountId });
await tool.execute("tool-call-wait", { action: "wait", timeoutMs: 5000, accountId });
expect(waitForWebLoginMock).toHaveBeenCalledWith({
accountId,
timeoutMs: 5000,
currentQrDataUrl: undefined,
});
});
});

View File

@@ -2,6 +2,12 @@ import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-contract";
import { Type } from "typebox";
import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js";
const QR_DATA_URL_MAX_LENGTH = 16_384;
function readOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value : undefined;
}
export function createWhatsAppLoginTool(): ChannelAgentTool {
return {
label: "WhatsApp Login",
@@ -17,16 +23,56 @@ export function createWhatsAppLoginTool(): ChannelAgentTool {
}),
timeoutMs: Type.Optional(Type.Number()),
force: Type.Optional(Type.Boolean()),
accountId: Type.Optional(Type.String()),
currentQrDataUrl: Type.Optional(
Type.String({
maxLength: QR_DATA_URL_MAX_LENGTH,
pattern: "^data:image/png;base64,",
}),
),
}),
execute: async (_toolCallId, args) => {
const renderQrReply = (params: {
message: string;
qrDataUrl: string;
connected?: boolean;
}) => {
const text = [
params.message,
"",
"Open WhatsApp → Linked Devices and scan:",
"",
`![whatsapp-qr](${params.qrDataUrl})`,
].join("\n");
return {
content: [{ type: "text" as const, text }],
details: {
connected: params.connected ?? false,
qr: true,
},
};
};
const action = (args as { action?: string })?.action ?? "start";
const accountId = readOptionalString((args as { accountId?: unknown }).accountId);
if (action === "wait") {
const result = await waitForWebLogin({
accountId,
timeoutMs:
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
? (args as { timeoutMs?: number }).timeoutMs
: undefined,
currentQrDataUrl: readOptionalString(
(args as { currentQrDataUrl?: unknown }).currentQrDataUrl,
),
});
if (result.qrDataUrl) {
return renderQrReply({
message: result.message,
qrDataUrl: result.qrDataUrl,
connected: result.connected,
});
}
return {
content: [{ type: "text", text: result.message }],
details: { connected: result.connected },
@@ -34,6 +80,7 @@ export function createWhatsAppLoginTool(): ChannelAgentTool {
}
const result = await startWebLoginWithQr({
accountId,
timeoutMs:
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
? (args as { timeoutMs?: number }).timeoutMs
@@ -56,17 +103,11 @@ export function createWhatsAppLoginTool(): ChannelAgentTool {
};
}
const text = [
result.message,
"",
"Open WhatsApp → Linked Devices and scan:",
"",
`![whatsapp-qr](${result.qrDataUrl})`,
].join("\n");
return {
content: [{ type: "text", text }],
details: { qr: true },
};
return renderQrReply({
message: result.message,
qrDataUrl: result.qrDataUrl,
connected: result.connected,
});
},
};
}

View File

@@ -316,8 +316,10 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
timeoutMs,
verbose,
}),
loginWithQrWait: async ({ accountId, timeoutMs }) =>
await (await loadWhatsAppChannelRuntime()).waitForWebLogin({ accountId, timeoutMs }),
loginWithQrWait: async ({ accountId, timeoutMs, currentQrDataUrl }) =>
await (
await loadWhatsAppChannelRuntime()
).waitForWebLogin({ accountId, timeoutMs, currentQrDataUrl }),
logoutAccount: async ({ account, runtime }) => {
const cleared = await (
await loadWhatsAppChannelRuntime()

View File

@@ -160,6 +160,7 @@ export async function waitForWhatsAppLoginResult(params: {
runtime: RuntimeEnv;
waitForConnection?: typeof waitForWaConnection;
createSocket?: typeof createWaSocket;
onQr?: (qr: string) => void;
onSocketReplaced?: (sock: WaSocket) => void;
}): Promise<WhatsAppLoginWaitResult> {
const wait = params.waitForConnection ?? waitForWaConnection;
@@ -184,6 +185,7 @@ export async function waitForWhatsAppLoginResult(params: {
try {
currentSock = await createSocket(false, params.verbose, {
authDir: params.authDir,
onQr: params.onQr,
});
params.onSocketReplaced?.(currentSock);
continue;

View File

@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { startWebLoginWithQr, waitForWebLogin } from "./login-qr.js";
import { renderQrPngDataUrl } from "./qr-image.js";
import {
createWaSocket,
logoutWeb,
@@ -40,7 +41,7 @@ vi.mock("./session.js", async () => {
vi.mock("./qr-image.js", () => ({
renderQrPngBase64: vi.fn(async () => "base64"),
renderQrPngDataUrl: vi.fn(async () => "data:image/png;base64,base64"),
renderQrPngDataUrl: vi.fn(async (input: string) => `data:image/png;base64,encoded:${input}`),
}));
const createWaSocketMock = vi.mocked(createWaSocket);
@@ -48,13 +49,29 @@ const readWebAuthExistsForDecisionMock = vi.mocked(readWebAuthExistsForDecision)
const readWebSelfIdMock = vi.mocked(readWebSelfId);
const waitForWaConnectionMock = vi.mocked(waitForWaConnection);
const logoutWebMock = vi.mocked(logoutWeb);
const renderQrPngDataUrlMock = vi.mocked(renderQrPngDataUrl);
async function flushTasks() {
await Promise.resolve();
await Promise.resolve();
}
async function waitMs(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitForQrRenderCallCount(count: number) {
const deadline = Date.now() + 1000;
while (renderQrPngDataUrlMock.mock.calls.length < count && Date.now() < deadline) {
await waitMs(0);
await flushTasks();
}
}
describe("login-qr", () => {
const rotatingAccountId = "rotating-qr";
const concurrentAccountId = "concurrent-qr";
beforeEach(() => {
vi.clearAllMocks();
createWaSocketMock
@@ -79,6 +96,9 @@ describe("login-qr", () => {
});
readWebSelfIdMock.mockReset().mockReturnValue({ e164: null, jid: null, lid: null });
logoutWebMock.mockReset().mockResolvedValue(true);
renderQrPngDataUrlMock
.mockReset()
.mockImplementation(async (input) => `data:image/png;base64,encoded:${input}`);
});
it("restarts login once on status 515 and completes", async () => {
@@ -87,10 +107,17 @@ describe("login-qr", () => {
.mockRejectedValueOnce({ error: { output: { statusCode: 515 } } })
.mockResolvedValueOnce(undefined);
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(start.qrDataUrl).toBe("data:image/png;base64,base64");
const start = await startWebLoginWithQr({
timeoutMs: 5000,
accountId: rotatingAccountId,
});
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
const resultPromise = waitForWebLogin({ timeoutMs: 5000 });
const resultPromise = waitForWebLogin({
timeoutMs: 5000,
currentQrDataUrl: start.qrDataUrl,
accountId: rotatingAccountId,
});
await flushTasks();
await flushTasks();
@@ -108,9 +135,12 @@ describe("login-qr", () => {
});
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(start.qrDataUrl).toBe("data:image/png;base64,base64");
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
const result = await waitForWebLogin({ timeoutMs: 5000 });
const result = await waitForWebLogin({
timeoutMs: 5000,
currentQrDataUrl: start.qrDataUrl,
});
expect(result).toEqual({
connected: false,
@@ -127,9 +157,12 @@ describe("login-qr", () => {
logoutWebMock.mockRejectedValueOnce(new Error("cleanup failed"));
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(start.qrDataUrl).toBe("data:image/png;base64,base64");
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
const result = await waitForWebLogin({ timeoutMs: 5000 });
const result = await waitForWebLogin({
timeoutMs: 5000,
currentQrDataUrl: start.qrDataUrl,
});
expect(result).toEqual({
connected: false,
@@ -175,4 +208,276 @@ describe("login-qr", () => {
message: "No active WhatsApp login in progress.",
});
});
it("surfaces the latest QR after the socket rotates it", async () => {
createWaSocketMock.mockImplementationOnce(
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
setImmediate(() => opts?.onQr?.("qr-data"));
setTimeout(() => opts?.onQr?.("qr-data-2"), 100);
return sock as never;
},
);
waitForWaConnectionMock.mockImplementation(() => new Promise(() => {}));
const start = await startWebLoginWithQr({ timeoutMs: 5000 });
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
const resultPromise = waitForWebLogin({
timeoutMs: 5000,
currentQrDataUrl: start.qrDataUrl,
});
await flushTasks();
await waitMs(140);
await flushTasks();
await expect(resultPromise).resolves.toEqual({
connected: false,
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,encoded:qr-data-2",
});
});
it("does not short-circuit on an existing QR when the waiter has no current QR image", async () => {
const accountId = "wait-without-current-qr";
waitForWaConnectionMock.mockImplementationOnce(
() => new Promise((resolve) => setTimeout(() => resolve(undefined), 20)),
);
const start = await startWebLoginWithQr({
timeoutMs: 5000,
accountId,
});
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
await expect(
waitForWebLogin({
timeoutMs: 5000,
accountId,
}),
).resolves.toEqual({
connected: true,
message: "✅ Linked! WhatsApp is ready.",
});
});
it("returns a terminal login result before a stale QR refresh", async () => {
const accountId = "connected-before-refresh";
let resolveLogin: () => void = () => {
throw new Error("Expected login wait to be pending");
};
createWaSocketMock.mockImplementationOnce(
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
setImmediate(() => opts?.onQr?.("qr-data"));
setTimeout(() => opts?.onQr?.("qr-data-2"), 20);
return sock as never;
},
);
waitForWaConnectionMock.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveLogin = resolve;
}),
);
const start = await startWebLoginWithQr({
timeoutMs: 5000,
accountId,
});
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
await waitMs(50);
await flushTasks();
resolveLogin();
await flushTasks();
await expect(
waitForWebLogin({
timeoutMs: 5000,
currentQrDataUrl: start.qrDataUrl,
accountId,
}),
).resolves.toEqual({
connected: true,
message: "✅ Linked! WhatsApp is ready.",
});
});
it("returns a terminal result when an older replaced waiter resolves without state", async () => {
const accountId = "replaced-login-waiter";
let resolveFirstConnection: () => void = () => {
throw new Error("Expected first login wait to be pending");
};
waitForWaConnectionMock
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveFirstConnection = resolve;
}),
)
.mockImplementation(() => new Promise(() => {}));
const start = await startWebLoginWithQr({
timeoutMs: 5000,
accountId,
});
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
const waiter = waitForWebLogin({
timeoutMs: 1000,
currentQrDataUrl: start.qrDataUrl,
accountId,
});
await flushTasks();
const now = Date.now();
const dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(now + 3 * 60_000 + 1000);
try {
const replacement = await startWebLoginWithQr({
timeoutMs: 5000,
accountId,
});
expect(replacement.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
resolveFirstConnection();
await expect(waiter).resolves.toEqual({
connected: false,
message: "Login ended without a connection.",
});
} finally {
dateNowSpy.mockRestore();
}
});
it("keeps an active login reusable while a rotated QR image renders", async () => {
const accountId = "reuse-during-qr-render";
let onQr: (qr: string) => void = () => {
throw new Error("Expected QR callback to be registered");
};
createWaSocketMock.mockImplementation(
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
onQr = (qr) => opts?.onQr?.(qr);
setImmediate(() => onQr("qr-data"));
return sock as never;
},
);
waitForWaConnectionMock.mockImplementation(() => new Promise(() => {}));
renderQrPngDataUrlMock.mockImplementation((qr) =>
qr === "qr-data-2"
? new Promise<string>(() => {})
: Promise.resolve(`data:image/png;base64,encoded:${qr}`),
);
const start = await startWebLoginWithQr({
timeoutMs: 5000,
accountId,
});
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
onQr("qr-data-2");
await flushTasks();
const reused = await startWebLoginWithQr({
timeoutMs: 5000,
accountId,
});
expect(createWaSocketMock).toHaveBeenCalledTimes(1);
expect(reused).toEqual({
qrDataUrl: "data:image/png;base64,encoded:qr-data",
message: "QR already active. Scan it in WhatsApp → Linked Devices.",
});
});
it("deduplicates initial QR rendering while the start path awaits the same image", async () => {
const accountId = "single-flight-qr";
let resolveRender: (value: string) => void = () => {
throw new Error("Expected QR render promise to be pending");
};
renderQrPngDataUrlMock.mockImplementationOnce(
() =>
new Promise<string>((resolve) => {
resolveRender = resolve;
}),
);
waitForWaConnectionMock.mockImplementation(() => new Promise(() => {}));
const resultPromise = startWebLoginWithQr({
timeoutMs: 5000,
accountId,
});
await waitForQrRenderCallCount(1);
expect(renderQrPngDataUrlMock).toHaveBeenCalledTimes(1);
resolveRender("data:image/png;base64,encoded:qr-data");
await expect(resultPromise).resolves.toEqual({
qrDataUrl: "data:image/png;base64,encoded:qr-data",
message: "Scan this QR in WhatsApp → Linked Devices.",
});
expect(renderQrPngDataUrlMock).toHaveBeenCalledTimes(1);
});
it("returns the same rotated QR to concurrent waiters that share the same current image", async () => {
createWaSocketMock.mockImplementationOnce(
async (
_printQr: boolean,
_verbose: boolean,
opts?: { authDir?: string; onQr?: (qr: string) => void },
) => {
const sock = { ws: { close: vi.fn() } };
setImmediate(() => opts?.onQr?.("qr-data"));
setTimeout(() => opts?.onQr?.("qr-data-2"), 100);
return sock as never;
},
);
waitForWaConnectionMock.mockImplementation(() => new Promise(() => {}));
const start = await startWebLoginWithQr({
timeoutMs: 5000,
accountId: concurrentAccountId,
});
expect(start.qrDataUrl).toBe("data:image/png;base64,encoded:qr-data");
const waiterA = waitForWebLogin({
timeoutMs: 5000,
currentQrDataUrl: start.qrDataUrl,
accountId: concurrentAccountId,
});
const waiterB = waitForWebLogin({
timeoutMs: 5000,
currentQrDataUrl: start.qrDataUrl,
accountId: concurrentAccountId,
});
await flushTasks();
await waitMs(140);
await flushTasks();
await expect(waiterA).resolves.toEqual({
connected: false,
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,encoded:qr-data-2",
});
await expect(waiterB).resolves.toEqual({
connected: false,
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,encoded:qr-data-2",
});
});
});

View File

@@ -34,10 +34,15 @@ type ActiveLogin = {
startedAt: number;
qr?: string;
qrDataUrl?: string;
qrDataUrlVersion?: number;
qrVersion: number;
connected: boolean;
error?: string;
errorStatus?: number;
waitPromise: Promise<void>;
qrUpdatePromise: Promise<void>;
resolveQrUpdate: (() => void) | null;
qrRenderPromise: Promise<string> | null;
verbose: boolean;
runtime: RuntimeEnv;
};
@@ -52,6 +57,7 @@ function waitForNextTask(): Promise<void> {
}
const ACTIVE_LOGIN_TTL_MS = 3 * 60_000;
const MAX_QR_RENDER_CHASES = 10;
const activeLogins = new Map<string, ActiveLogin>();
function closeSocket(sock: WaSocket) {
@@ -73,6 +79,98 @@ function isLoginFresh(login: ActiveLogin) {
return Date.now() - login.startedAt < ACTIVE_LOGIN_TTL_MS;
}
function resetQrUpdateSignal(login: ActiveLogin) {
login.qrUpdatePromise = new Promise((resolve) => {
login.resolveQrUpdate = resolve;
});
}
function notifyQrUpdate(login: ActiveLogin) {
const resolve = login.resolveQrUpdate;
resetQrUpdateSignal(login);
resolve?.();
}
function updateLoginQrState(login: ActiveLogin, qr: string): number {
login.qr = qr;
login.qrVersion += 1;
return login.qrVersion;
}
async function ensureQrDataUrl(params: {
accountId: string;
loginId: string;
qr: string;
qrVersion: number;
}): Promise<string> {
const current = activeLogins.get(params.accountId);
if (
current?.id !== params.loginId ||
current.qrVersion !== params.qrVersion ||
current.qr !== params.qr
) {
return await renderQrPngDataUrl(params.qr);
}
if (current.qrDataUrl && current.qrDataUrlVersion === params.qrVersion) {
return current.qrDataUrl;
}
if (current.qrRenderPromise) {
return await current.qrRenderPromise;
}
const renderPromise = (async () => {
for (let attempt = 0; attempt < MAX_QR_RENDER_CHASES; attempt += 1) {
const latest = activeLogins.get(params.accountId);
if (!latest || latest.id !== params.loginId || !latest.qr) {
throw new Error("WhatsApp QR is no longer active.");
}
if (latest.qrDataUrl && latest.qrDataUrlVersion === latest.qrVersion) {
return latest.qrDataUrl;
}
const qr = latest.qr;
const qrVersion = latest.qrVersion;
const dataUrl = await renderQrPngDataUrl(qr);
const refreshed = activeLogins.get(params.accountId);
if (!refreshed || refreshed.id !== params.loginId) {
return dataUrl;
}
if (refreshed.qrVersion === qrVersion && refreshed.qr === qr) {
refreshed.qrDataUrl = dataUrl;
refreshed.qrDataUrlVersion = qrVersion;
notifyQrUpdate(refreshed);
return dataUrl;
}
}
throw new Error("WhatsApp QR kept refreshing before the latest image could render.");
})();
current.qrRenderPromise = renderPromise;
try {
return await renderPromise;
} finally {
const latest = activeLogins.get(params.accountId);
if (latest?.id === params.loginId && latest.qrRenderPromise === renderPromise) {
latest.qrRenderPromise = null;
}
}
}
function renderLatestQrDataUrlInBackground(params: {
accountId: string;
loginId: string;
qr: string;
qrVersion: number;
}) {
void ensureQrDataUrl(params).catch(() => {
// Ignore background QR render failures; the caller can still retry or surface
// the login state without clobbering the active session.
});
}
function attachLoginWaiter(accountId: string, login: ActiveLogin) {
login.waitPromise = waitForWhatsAppLoginResult({
sock: login.sock,
@@ -80,6 +178,19 @@ function attachLoginWaiter(accountId: string, login: ActiveLogin) {
isLegacyAuthDir: login.isLegacyAuthDir,
verbose: login.verbose,
runtime: login.runtime,
onQr: (qr) => {
const current = activeLogins.get(accountId);
if (!current || current.id !== login.id) {
return;
}
const qrVersion = updateLoginQrState(current, qr);
renderLatestQrDataUrlInBackground({
accountId,
loginId: login.id,
qr,
qrVersion,
});
},
onSocketReplaced: (sock) => {
const current = activeLogins.get(accountId);
if (current?.id === login.id) {
@@ -212,21 +323,29 @@ export async function startWebLoginWithQr(
let sock: WaSocket;
let pendingQr: string | null = null;
const loginId = randomUUID();
try {
sock = await createWaSocket(false, Boolean(opts.verbose), {
authDir: account.authDir,
onQr: (qr: string) => {
if (pendingQr) {
return;
}
pendingQr = qr;
const current = activeLogins.get(account.accountId);
if (current && !current.qr) {
current.qr = qr;
if (current && current.id === loginId) {
const qrVersion = updateLoginQrState(current, qr);
renderLatestQrDataUrlInBackground({
accountId: account.accountId,
loginId,
qr,
qrVersion,
});
}
if (resolveQr) {
clearTimeout(qrTimer);
resolveQr(qr);
resolveQr = null;
rejectQr = null;
}
clearTimeout(qrTimer);
runtime.log(info("WhatsApp QR received."));
resolveQr?.(qr);
},
});
} catch (err) {
@@ -240,17 +359,28 @@ export async function startWebLoginWithQr(
accountId: account.accountId,
authDir: account.authDir,
isLegacyAuthDir: account.isLegacyAuthDir,
id: randomUUID(),
id: loginId,
sock,
startedAt: Date.now(),
connected: false,
waitPromise: Promise.resolve(),
qrVersion: 0,
qrUpdatePromise: Promise.resolve(),
resolveQrUpdate: null,
qrRenderPromise: null,
verbose: Boolean(opts.verbose),
runtime,
};
resetQrUpdateSignal(login);
activeLogins.set(account.accountId, login);
if (pendingQr && !login.qr) {
login.qr = pendingQr;
if (pendingQr) {
const qrVersion = updateLoginQrState(login, pendingQr);
renderLatestQrDataUrlInBackground({
accountId: account.accountId,
loginId: login.id,
qr: pendingQr,
qrVersion,
});
}
attachLoginWaiter(account.accountId, login);
@@ -278,16 +408,43 @@ export async function startWebLoginWithQr(
};
}
login.qrDataUrl = await renderQrPngDataUrl(loginStartResult.qr);
const qr = login.qr ?? loginStartResult.qr;
const qrVersion = login.qrVersion;
if (qrVersion === 0) {
await resetActiveLogin(account.accountId);
return {
message: "Failed to capture the active WhatsApp QR. Ask me to generate a new one.",
};
}
let qrDataUrl: string;
try {
qrDataUrl = await ensureQrDataUrl({
accountId: account.accountId,
loginId: login.id,
qr,
qrVersion,
});
} catch (err) {
const message =
err instanceof Error ? `Failed to render the WhatsApp QR: ${err.message}` : String(err);
await resetActiveLogin(account.accountId, message);
return { message };
}
return {
qrDataUrl: login.qrDataUrl,
qrDataUrl,
message: "Scan this QR in WhatsApp → Linked Devices.",
};
}
export async function waitForWebLogin(
opts: { timeoutMs?: number; runtime?: RuntimeEnv; accountId?: string } = {},
): Promise<{ connected: boolean; message: string }> {
opts: {
timeoutMs?: number;
runtime?: RuntimeEnv;
accountId?: string;
currentQrDataUrl?: string;
} = {},
): Promise<{ connected: boolean; message: string; qrDataUrl?: string }> {
const runtime = opts.runtime ?? defaultRuntime;
const cfg = loadConfig();
const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId });
@@ -309,27 +466,9 @@ export async function waitForWebLogin(
}
const timeoutMs = Math.max(opts.timeoutMs ?? 120_000, 1000);
const deadline = Date.now() + timeoutMs;
const currentQrDataUrl = opts.currentQrDataUrl;
while (true) {
const remaining = deadline - Date.now();
if (remaining <= 0) {
return {
connected: false,
message: "Still waiting for the QR scan. Let me know when youve scanned it.",
};
}
const timeout = new Promise<"timeout">((resolve) =>
setTimeout(() => resolve("timeout"), remaining),
);
const result = await Promise.race([login.waitPromise.then(() => "done"), timeout]);
if (result === "timeout") {
return {
connected: false,
message: "Still waiting for the QR scan. Let me know when youve scanned it.",
};
}
if (login.error) {
if (login.errorStatus === 401) {
const message = WHATSAPP_LOGGED_OUT_QR_MESSAGE;
@@ -350,6 +489,48 @@ export async function waitForWebLogin(
return { connected: true, message };
}
if (login.qrDataUrl && currentQrDataUrl && login.qrDataUrl !== currentQrDataUrl) {
return {
connected: false,
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
qrDataUrl: login.qrDataUrl,
};
}
const remaining = deadline - Date.now();
if (remaining <= 0) {
return {
connected: false,
message: "Still waiting for the QR scan. Let me know when youve scanned it.",
};
}
const timeout = new Promise<"timeout">((resolve) =>
setTimeout(() => resolve("timeout"), remaining),
);
const result = await Promise.race([
login.waitPromise.then(() => "done" as const),
login.qrUpdatePromise.then(() => "qr-update" as const),
timeout,
]);
if (result === "timeout") {
return {
connected: false,
message: "Still waiting for the QR scan. Let me know when youve scanned it.",
};
}
if (result === "qr-update") {
continue;
}
if (result === "done") {
if (login.connected || login.error) {
continue;
}
return { connected: false, message: "Login ended without a connection." };
}
return { connected: false, message: "Login ended without a connection." };
}
}

View File

@@ -248,10 +248,12 @@ describe("web media loading", () => {
});
it("uses content-disposition filename when available", async () => {
const pdfBytes = Buffer.from("%PDF-1.4");
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({
ok: true,
body: true,
arrayBuffer: async () => Buffer.from("%PDF-1.4").buffer,
arrayBuffer: async () =>
pdfBytes.buffer.slice(pdfBytes.byteOffset, pdfBytes.byteOffset + pdfBytes.byteLength),
headers: {
get: (name: string) => {
if (name === "content-disposition") {

View File

@@ -324,6 +324,7 @@ export type ChannelLoginWithQrStartResult = {
export type ChannelLoginWithQrWaitResult = {
connected: boolean;
message: string;
qrDataUrl?: string;
};
export type ChannelLogoutContext<ResolvedAccount = unknown> = {
@@ -348,6 +349,7 @@ export type ChannelGatewayAdapter<ResolvedAccount = unknown> = {
loginWithQrWait?: (params: {
accountId?: string;
timeoutMs?: number;
currentQrDataUrl?: string;
}) => Promise<ChannelLoginWithQrWaitResult>;
logoutAccount?: (ctx: ChannelLogoutContext<ResolvedAccount>) => Promise<ChannelLogoutResult>;
};

View File

@@ -0,0 +1,27 @@
import AjvPkg from "ajv";
import { describe, expect, it } from "vitest";
import { WebLoginWaitParamsSchema } from "./schema/channels.js";
describe("WebLoginWaitParamsSchema", () => {
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
const validate = new Ajv().compile(WebLoginWaitParamsSchema);
it("bounds caller-provided QR data URLs", () => {
expect(
validate({
currentQrDataUrl: "data:image/png;base64,qr",
}),
).toBe(true);
expect(
validate({
currentQrDataUrl: "x".repeat(16_385),
}),
).toBe(false);
expect(
validate({
currentQrDataUrl: "https://example.com/qr.png",
}),
).toBe(false);
});
});

View File

@@ -225,10 +225,16 @@ export const WebLoginStartParamsSchema = Type.Object(
{ additionalProperties: false },
);
const QrDataUrlSchema = Type.String({
maxLength: 16_384,
pattern: "^data:image/png;base64,",
});
export const WebLoginWaitParamsSchema = Type.Object(
{
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
accountId: Type.Optional(Type.String()),
currentQrDataUrl: Type.Optional(QrDataUrlSchema),
},
{ additionalProperties: false },
);

View File

@@ -148,3 +148,63 @@ describe("webHandlers web.login.start", () => {
expect(startChannel).not.toHaveBeenCalled();
});
});
describe("webHandlers web.login.wait", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("passes refreshed QR payloads back to the client while login is still pending", async () => {
const loginWithQrWait = vi.fn().mockResolvedValue({
connected: false,
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,next-qr",
});
mocks.listChannelPlugins.mockReturnValue([
{
id: "whatsapp",
gatewayMethods: ["web.login.wait"],
gateway: { loginWithQrWait },
},
]);
const respond = vi.fn();
await webHandlers["web.login.wait"](
createOptions(
{
accountId: "default",
timeoutMs: 5000,
currentQrDataUrl: "data:image/png;base64,current-qr",
},
{
req: {
type: "req",
id: "req-2",
method: "web.login.wait",
params: {
accountId: "default",
timeoutMs: 5000,
currentQrDataUrl: "data:image/png;base64,current-qr",
},
} as GatewayRequestHandlerOptions["req"],
respond,
},
),
);
expect(loginWithQrWait).toHaveBeenCalledWith({
accountId: "default",
timeoutMs: 5000,
currentQrDataUrl: "data:image/png;base64,current-qr",
});
expect(respond).toHaveBeenCalledWith(
true,
{
connected: false,
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,next-qr",
},
undefined,
);
});
});

View File

@@ -136,6 +136,10 @@ export const webHandlers: GatewayRequestHandlers = {
? (params as { timeoutMs?: number }).timeoutMs
: undefined,
accountId,
currentQrDataUrl:
typeof (params as { currentQrDataUrl?: unknown }).currentQrDataUrl === "string"
? (params as { currentQrDataUrl?: string }).currentQrDataUrl
: undefined,
});
if (result.connected) {
await context.startChannel(provider.id, accountId);

View File

@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { waitWhatsAppLogin, type ChannelsState } from "./channels.ts";
function createState(): ChannelsState {
return {
client: {
request: vi.fn(),
} as never,
connected: true,
channelsLoading: false,
channelsSnapshot: null,
channelsError: null,
channelsLastSuccess: null,
whatsappLoginMessage: null,
whatsappLoginQrDataUrl: "data:image/png;base64,current-qr",
whatsappLoginConnected: false,
whatsappBusy: false,
};
}
describe("channels controller WhatsApp wait", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("passes the currently displayed QR and replaces it when the login QR rotates", async () => {
const state = createState();
const request = vi.mocked(state.client!.request);
request.mockResolvedValueOnce({
connected: false,
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
qrDataUrl: "data:image/png;base64,next-qr",
});
await waitWhatsAppLogin(state);
expect(request).toHaveBeenCalledWith("web.login.wait", {
timeoutMs: 120000,
currentQrDataUrl: "data:image/png;base64,current-qr",
});
expect(state.whatsappLoginMessage).toBe(
"QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
);
expect(state.whatsappLoginConnected).toBe(false);
expect(state.whatsappLoginQrDataUrl).toBe("data:image/png;base64,next-qr");
expect(state.whatsappBusy).toBe(false);
});
});

View File

@@ -67,15 +67,19 @@ export async function waitWhatsAppLogin(state: ChannelsState) {
}
state.whatsappBusy = true;
try {
const res = await state.client.request<{ message?: string; connected?: boolean }>(
"web.login.wait",
{
timeoutMs: 120000,
},
);
const res = await state.client.request<{
message?: string;
connected?: boolean;
qrDataUrl?: string;
}>("web.login.wait", {
timeoutMs: 120000,
currentQrDataUrl: state.whatsappLoginQrDataUrl ?? undefined,
});
state.whatsappLoginMessage = res.message ?? null;
state.whatsappLoginConnected = res.connected ?? null;
if (res.connected) {
if (res.qrDataUrl) {
state.whatsappLoginQrDataUrl = res.qrDataUrl;
} else if (res.connected) {
state.whatsappLoginQrDataUrl = null;
}
} catch (err) {