Skip to content

Commit 245451b

Browse files
authored
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
1 parent 86099ec commit 245451b

20 files changed

Lines changed: 946 additions & 74 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,6 +310,7 @@ Docs: https://docs.openclaw.ai
310310
- 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.
311311
- 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.
312312
- 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.
313+
- 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.
313314
- 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.
314315
- 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.
315316
- 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.

apps/macos/Sources/OpenClaw/ChannelsStore+Lifecycle.swift

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
11
import Foundation
22
import OpenClawProtocol
33

4+
func whatsappLoginWaitRequestTimeoutMs(
5+
startedAt: Date,
6+
timeoutMs: Int,
7+
didRunFinalWait: inout Bool,
8+
now: Date = Date()) -> Int?
9+
{
10+
let elapsedMs = Int(now.timeIntervalSince(startedAt) * 1000)
11+
let remainingMs = max(timeoutMs - elapsedMs, 0)
12+
if remainingMs > 0 {
13+
return remainingMs
14+
}
15+
if didRunFinalWait {
16+
return nil
17+
}
18+
didRunFinalWait = true
19+
return 1
20+
}
21+
422
extension ChannelsStore {
523
func start() {
624
guard !self.isPreview else { return }
@@ -77,18 +95,28 @@ extension ChannelsStore {
7795
guard !self.whatsappBusy else { return }
7896
self.whatsappBusy = true
7997
defer { self.whatsappBusy = false }
98+
let startedAt = Date()
99+
var didRunFinalWait = false
80100
do {
81-
let params: [String: AnyCodable] = [
82-
"timeoutMs": AnyCodable(timeoutMs),
83-
]
84-
let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded(
85-
method: .webLoginWait,
86-
params: params,
87-
timeoutMs: Double(timeoutMs) + 5000)
88-
self.whatsappLoginMessage = result.message
89-
self.whatsappLoginConnected = result.connected
90-
if result.connected {
91-
self.whatsappLoginQrDataUrl = nil
101+
while let remainingMs = whatsappLoginWaitRequestTimeoutMs(
102+
startedAt: startedAt,
103+
timeoutMs: timeoutMs,
104+
didRunFinalWait: &didRunFinalWait)
105+
{
106+
var params: [String: AnyCodable] = [
107+
"timeoutMs": AnyCodable(remainingMs),
108+
]
109+
if let currentQrDataUrl = self.whatsappLoginQrDataUrl {
110+
params["currentQrDataUrl"] = AnyCodable(currentQrDataUrl)
111+
}
112+
let result: WhatsAppLoginWaitResult = try await GatewayConnection.shared.requestDecoded(
113+
method: .webLoginWait,
114+
params: params,
115+
timeoutMs: Double(remainingMs) + 5000)
116+
self.applyWhatsAppLoginWaitResult(result)
117+
if result.connected || result.qrDataUrl == nil || didRunFinalWait {
118+
break
119+
}
92120
}
93121
} catch {
94122
self.whatsappLoginMessage = error.localizedDescription
@@ -151,9 +179,10 @@ private struct WhatsAppLoginStartResult: Codable {
151179
let connected: Bool?
152180
}
153181

154-
private struct WhatsAppLoginWaitResult: Codable {
182+
struct WhatsAppLoginWaitResult: Codable {
155183
let connected: Bool
156184
let message: String
185+
let qrDataUrl: String?
157186
}
158187

159188
private struct ChannelLogoutResult: Codable {

apps/macos/Sources/OpenClaw/ChannelsStore.swift

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,16 @@ final class ChannelsStore {
290290
return self.snapshot?.channelOrder ?? []
291291
}
292292

293+
func applyWhatsAppLoginWaitResult(_ result: WhatsAppLoginWaitResult) {
294+
self.whatsappLoginMessage = result.message
295+
self.whatsappLoginConnected = result.connected
296+
if let qrDataUrl = result.qrDataUrl {
297+
self.whatsappLoginQrDataUrl = qrDataUrl
298+
} else if result.connected {
299+
self.whatsappLoginQrDataUrl = nil
300+
}
301+
}
302+
293303
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
294304
self.isPreview = isPreview
295305
}

apps/macos/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2610,18 +2610,22 @@ public struct WebLoginStartParams: Codable, Sendable {
26102610
public struct WebLoginWaitParams: Codable, Sendable {
26112611
public let timeoutms: Int?
26122612
public let accountid: String?
2613+
public let currentqrdataurl: String?
26132614

26142615
public init(
26152616
timeoutms: Int?,
2616-
accountid: String?)
2617+
accountid: String?,
2618+
currentqrdataurl: String?)
26172619
{
26182620
self.timeoutms = timeoutms
26192621
self.accountid = accountid
2622+
self.currentqrdataurl = currentqrdataurl
26202623
}
26212624

26222625
private enum CodingKeys: String, CodingKey {
26232626
case timeoutms = "timeoutMs"
26242627
case accountid = "accountId"
2628+
case currentqrdataurl = "currentQrDataUrl"
26252629
}
26262630
}
26272631

apps/macos/Tests/OpenClawIPCTests/ChannelsSettingsSmokeTests.swift

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,4 +156,63 @@ struct ChannelsSettingsSmokeTests {
156156
let view = ChannelsSettings(store: store)
157157
_ = view.body
158158
}
159+
160+
@Test func `whatsapp login wait result keeps latest qr until connected`() {
161+
let store = makeChannelsStore(channels: [:])
162+
store.whatsappLoginQrDataUrl = "data:image/png;base64,initial"
163+
164+
store.applyWhatsAppLoginWaitResult(
165+
WhatsAppLoginWaitResult(
166+
connected: false,
167+
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
168+
qrDataUrl: "data:image/png;base64,rotated"))
169+
170+
#expect(store.whatsappLoginQrDataUrl == "data:image/png;base64,rotated")
171+
#expect(store.whatsappLoginConnected == false)
172+
173+
store.applyWhatsAppLoginWaitResult(
174+
WhatsAppLoginWaitResult(
175+
connected: false,
176+
message: "Still waiting for the QR scan. Let me know when you’ve scanned it.",
177+
qrDataUrl: nil))
178+
179+
#expect(store.whatsappLoginQrDataUrl == "data:image/png;base64,rotated")
180+
181+
store.applyWhatsAppLoginWaitResult(
182+
WhatsAppLoginWaitResult(
183+
connected: true,
184+
message: "✅ Linked! WhatsApp is ready.",
185+
qrDataUrl: nil))
186+
187+
#expect(store.whatsappLoginQrDataUrl == nil)
188+
#expect(store.whatsappLoginConnected == true)
189+
}
190+
191+
@Test func `whatsapp login wait budget allows one final poll`() {
192+
let startedAt = Date(timeIntervalSince1970: 1_700_000_000)
193+
var didRunFinalWait = false
194+
195+
#expect(
196+
whatsappLoginWaitRequestTimeoutMs(
197+
startedAt: startedAt,
198+
timeoutMs: 1_000,
199+
didRunFinalWait: &didRunFinalWait,
200+
now: Date(timeInterval: 0.25, since: startedAt)) == 750)
201+
#expect(didRunFinalWait == false)
202+
203+
#expect(
204+
whatsappLoginWaitRequestTimeoutMs(
205+
startedAt: startedAt,
206+
timeoutMs: 1_000,
207+
didRunFinalWait: &didRunFinalWait,
208+
now: Date(timeInterval: 1.25, since: startedAt)) == 1)
209+
#expect(didRunFinalWait == true)
210+
211+
#expect(
212+
whatsappLoginWaitRequestTimeoutMs(
213+
startedAt: startedAt,
214+
timeoutMs: 1_000,
215+
didRunFinalWait: &didRunFinalWait,
216+
now: Date(timeInterval: 1.5, since: startedAt)) == nil)
217+
}
159218
}

apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2610,18 +2610,22 @@ public struct WebLoginStartParams: Codable, Sendable {
26102610
public struct WebLoginWaitParams: Codable, Sendable {
26112611
public let timeoutms: Int?
26122612
public let accountid: String?
2613+
public let currentqrdataurl: String?
26132614

26142615
public init(
26152616
timeoutms: Int?,
2616-
accountid: String?)
2617+
accountid: String?,
2618+
currentqrdataurl: String?)
26172619
{
26182620
self.timeoutms = timeoutms
26192621
self.accountid = accountid
2622+
self.currentqrdataurl = currentqrdataurl
26202623
}
26212624

26222625
private enum CodingKeys: String, CodingKey {
26232626
case timeoutms = "timeoutMs"
26242627
case accountid = "accountId"
2628+
case currentqrdataurl = "currentQrDataUrl"
26252629
}
26262630
}
26272631

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js";
3+
import { createWhatsAppLoginTool } from "./agent-tools-login.js";
4+
5+
vi.mock("../login-qr-api.js", () => ({
6+
startWebLoginWithQr: vi.fn(),
7+
waitForWebLogin: vi.fn(),
8+
}));
9+
10+
const startWebLoginWithQrMock = vi.mocked(startWebLoginWithQr);
11+
const waitForWebLoginMock = vi.mocked(waitForWebLogin);
12+
13+
describe("createWhatsAppLoginTool", () => {
14+
beforeEach(() => {
15+
vi.clearAllMocks();
16+
});
17+
18+
it("passes the caller's current QR back into wait actions", async () => {
19+
const accountId = "account-1";
20+
waitForWebLoginMock.mockResolvedValueOnce({
21+
connected: false,
22+
message: "QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
23+
qrDataUrl: "data:image/png;base64,next-qr",
24+
});
25+
26+
const tool = createWhatsAppLoginTool();
27+
const result = await tool.execute("tool-call-1", {
28+
action: "wait",
29+
timeoutMs: 5000,
30+
accountId,
31+
currentQrDataUrl: "data:image/png;base64,current-qr",
32+
});
33+
34+
expect(waitForWebLoginMock).toHaveBeenCalledWith({
35+
accountId,
36+
timeoutMs: 5000,
37+
currentQrDataUrl: "data:image/png;base64,current-qr",
38+
});
39+
expect(result).toEqual({
40+
content: [
41+
{
42+
type: "text",
43+
text: [
44+
"QR refreshed. Scan the latest code in WhatsApp → Linked Devices.",
45+
"",
46+
"Open WhatsApp → Linked Devices and scan:",
47+
"",
48+
"![whatsapp-qr](data:image/png;base64,next-qr)",
49+
].join("\n"),
50+
},
51+
],
52+
details: {
53+
connected: false,
54+
qr: true,
55+
},
56+
});
57+
});
58+
59+
it("does not retain QR state across tool actions", async () => {
60+
const accountId = "account-2";
61+
startWebLoginWithQrMock.mockResolvedValueOnce({
62+
connected: false,
63+
message: "Scan this QR in WhatsApp → Linked Devices.",
64+
qrDataUrl: "data:image/png;base64,current-qr",
65+
});
66+
waitForWebLoginMock.mockResolvedValueOnce({
67+
connected: true,
68+
message: "✅ Linked! WhatsApp is ready.",
69+
});
70+
71+
const tool = createWhatsAppLoginTool();
72+
await tool.execute("tool-call-start", { action: "start", accountId });
73+
await tool.execute("tool-call-wait", { action: "wait", timeoutMs: 5000, accountId });
74+
75+
expect(waitForWebLoginMock).toHaveBeenCalledWith({
76+
accountId,
77+
timeoutMs: 5000,
78+
currentQrDataUrl: undefined,
79+
});
80+
});
81+
});

extensions/whatsapp/src/agent-tools-login.ts

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ import type { ChannelAgentTool } from "openclaw/plugin-sdk/channel-contract";
22
import { Type } from "typebox";
33
import { startWebLoginWithQr, waitForWebLogin } from "../login-qr-api.js";
44

5+
const QR_DATA_URL_MAX_LENGTH = 16_384;
6+
7+
function readOptionalString(value: unknown): string | undefined {
8+
return typeof value === "string" && value.trim() ? value : undefined;
9+
}
10+
511
export function createWhatsAppLoginTool(): ChannelAgentTool {
612
return {
713
label: "WhatsApp Login",
@@ -17,23 +23,64 @@ export function createWhatsAppLoginTool(): ChannelAgentTool {
1723
}),
1824
timeoutMs: Type.Optional(Type.Number()),
1925
force: Type.Optional(Type.Boolean()),
26+
accountId: Type.Optional(Type.String()),
27+
currentQrDataUrl: Type.Optional(
28+
Type.String({
29+
maxLength: QR_DATA_URL_MAX_LENGTH,
30+
pattern: "^data:image/png;base64,",
31+
}),
32+
),
2033
}),
2134
execute: async (_toolCallId, args) => {
35+
const renderQrReply = (params: {
36+
message: string;
37+
qrDataUrl: string;
38+
connected?: boolean;
39+
}) => {
40+
const text = [
41+
params.message,
42+
"",
43+
"Open WhatsApp → Linked Devices and scan:",
44+
"",
45+
`![whatsapp-qr](${params.qrDataUrl})`,
46+
].join("\n");
47+
return {
48+
content: [{ type: "text" as const, text }],
49+
details: {
50+
connected: params.connected ?? false,
51+
qr: true,
52+
},
53+
};
54+
};
55+
2256
const action = (args as { action?: string })?.action ?? "start";
57+
const accountId = readOptionalString((args as { accountId?: unknown }).accountId);
2358
if (action === "wait") {
2459
const result = await waitForWebLogin({
60+
accountId,
2561
timeoutMs:
2662
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
2763
? (args as { timeoutMs?: number }).timeoutMs
2864
: undefined,
65+
currentQrDataUrl: readOptionalString(
66+
(args as { currentQrDataUrl?: unknown }).currentQrDataUrl,
67+
),
2968
});
69+
if (result.qrDataUrl) {
70+
return renderQrReply({
71+
message: result.message,
72+
qrDataUrl: result.qrDataUrl,
73+
connected: result.connected,
74+
});
75+
}
3076
return {
3177
content: [{ type: "text", text: result.message }],
3278
details: { connected: result.connected },
3379
};
3480
}
3581

3682
const result = await startWebLoginWithQr({
83+
accountId,
3784
timeoutMs:
3885
typeof (args as { timeoutMs?: unknown }).timeoutMs === "number"
3986
? (args as { timeoutMs?: number }).timeoutMs
@@ -56,17 +103,11 @@ export function createWhatsAppLoginTool(): ChannelAgentTool {
56103
};
57104
}
58105

59-
const text = [
60-
result.message,
61-
"",
62-
"Open WhatsApp → Linked Devices and scan:",
63-
"",
64-
`![whatsapp-qr](${result.qrDataUrl})`,
65-
].join("\n");
66-
return {
67-
content: [{ type: "text", text }],
68-
details: { qr: true },
69-
};
106+
return renderQrReply({
107+
message: result.message,
108+
qrDataUrl: result.qrDataUrl,
109+
connected: result.connected,
110+
});
70111
},
71112
};
72113
}

0 commit comments

Comments
 (0)