diff --git a/CHANGELOG.md b/CHANGELOG.md index 76ec823b3cc..6f2b707badc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift b/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift index 27deb1db907..2789ebb3878 100644 --- a/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift +++ b/apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift @@ -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 { diff --git a/apps/macos/Sources/OpenClaw/ChannelsStore.swift b/apps/macos/Sources/OpenClaw/ChannelsStore.swift index 09b9b75a532..e8d1ce0e075 100644 --- a/apps/macos/Sources/OpenClaw/ChannelsStore.swift +++ b/apps/macos/Sources/OpenClaw/ChannelsStore.swift @@ -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 } diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index cb6e5588db8..009266b2ecc 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift b/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift index 4d455835351..085e83ee9c6 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift @@ -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 you’ve 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) + } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index cb6e5588db8..009266b2ecc 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -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" } } diff --git a/extensions/whatsapp/src/agent-tools-login.test.ts b/extensions/whatsapp/src/agent-tools-login.test.ts new file mode 100644 index 00000000000..1ee31e26b05 --- /dev/null +++ b/extensions/whatsapp/src/agent-tools-login.test.ts @@ -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, + }); + }); +}); diff --git a/extensions/whatsapp/src/agent-tools-login.ts b/extensions/whatsapp/src/agent-tools-login.ts index 714502fdd7a..f9f6699e087 100644 --- a/extensions/whatsapp/src/agent-tools-login.ts +++ b/extensions/whatsapp/src/agent-tools-login.ts @@ -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, + }); }, }; } diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 42cd2059d19..01e2c08d29a 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -316,8 +316,10 @@ export const whatsappPlugin: ChannelPlugin = 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() diff --git a/extensions/whatsapp/src/connection-controller.ts b/extensions/whatsapp/src/connection-controller.ts index 86523565c38..6142dacfc6e 100644 --- a/extensions/whatsapp/src/connection-controller.ts +++ b/extensions/whatsapp/src/connection-controller.ts @@ -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 { 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; diff --git a/extensions/whatsapp/src/login-qr.test.ts b/extensions/whatsapp/src/login-qr.test.ts index cdc8af48303..ac6319cadc9 100644 --- a/extensions/whatsapp/src/login-qr.test.ts +++ b/extensions/whatsapp/src/login-qr.test.ts @@ -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((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((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(() => {}) + : 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((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", + }); + }); }); diff --git a/extensions/whatsapp/src/login-qr.ts b/extensions/whatsapp/src/login-qr.ts index 38510a7fb3b..0c5ee0d9f49 100644 --- a/extensions/whatsapp/src/login-qr.ts +++ b/extensions/whatsapp/src/login-qr.ts @@ -34,10 +34,15 @@ type ActiveLogin = { startedAt: number; qr?: string; qrDataUrl?: string; + qrDataUrlVersion?: number; + qrVersion: number; connected: boolean; error?: string; errorStatus?: number; waitPromise: Promise; + qrUpdatePromise: Promise; + resolveQrUpdate: (() => void) | null; + qrRenderPromise: Promise | null; verbose: boolean; runtime: RuntimeEnv; }; @@ -52,6 +57,7 @@ function waitForNextTask(): Promise { } const ACTIVE_LOGIN_TTL_MS = 3 * 60_000; +const MAX_QR_RENDER_CHASES = 10; const activeLogins = new Map(); 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 { + 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 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 (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 === 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 you’ve 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 you’ve 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." }; } } diff --git a/extensions/whatsapp/src/media.test.ts b/extensions/whatsapp/src/media.test.ts index f55f17c622b..91c3fcc41fe 100644 --- a/extensions/whatsapp/src/media.test.ts +++ b/extensions/whatsapp/src/media.test.ts @@ -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") { diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 0004b065f7d..d057c538665 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -324,6 +324,7 @@ export type ChannelLoginWithQrStartResult = { export type ChannelLoginWithQrWaitResult = { connected: boolean; message: string; + qrDataUrl?: string; }; export type ChannelLogoutContext = { @@ -348,6 +349,7 @@ export type ChannelGatewayAdapter = { loginWithQrWait?: (params: { accountId?: string; timeoutMs?: number; + currentQrDataUrl?: string; }) => Promise; logoutAccount?: (ctx: ChannelLogoutContext) => Promise; }; diff --git a/src/gateway/protocol/channels.schema.test.ts b/src/gateway/protocol/channels.schema.test.ts new file mode 100644 index 00000000000..bffbdc4c57b --- /dev/null +++ b/src/gateway/protocol/channels.schema.test.ts @@ -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); + }); +}); diff --git a/src/gateway/protocol/schema/channels.ts b/src/gateway/protocol/schema/channels.ts index 89fe4ee5ef0..62c62dcb726 100644 --- a/src/gateway/protocol/schema/channels.ts +++ b/src/gateway/protocol/schema/channels.ts @@ -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 }, ); diff --git a/src/gateway/server-methods/web.start.test.ts b/src/gateway/server-methods/web.start.test.ts index 1c45c472cbb..1389c69cc23 100644 --- a/src/gateway/server-methods/web.start.test.ts +++ b/src/gateway/server-methods/web.start.test.ts @@ -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, + ); + }); +}); diff --git a/src/gateway/server-methods/web.ts b/src/gateway/server-methods/web.ts index d9128b4bddc..8f8df9582d1 100644 --- a/src/gateway/server-methods/web.ts +++ b/src/gateway/server-methods/web.ts @@ -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); diff --git a/ui/src/ui/controllers/channels.test.ts b/ui/src/ui/controllers/channels.test.ts new file mode 100644 index 00000000000..857320f6e22 --- /dev/null +++ b/ui/src/ui/controllers/channels.test.ts @@ -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); + }); +}); diff --git a/ui/src/ui/controllers/channels.ts b/ui/src/ui/controllers/channels.ts index 650f1389f0f..2c5b2d0b887 100644 --- a/ui/src/ui/controllers/channels.ts +++ b/ui/src/ui/controllers/channels.ts @@ -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) {