mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-07 07:58:36 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
81
extensions/whatsapp/src/agent-tools-login.test.ts
Normal file
81
extensions/whatsapp/src/agent-tools-login.test.ts
Normal 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:",
|
||||
"",
|
||||
"",
|
||||
].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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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:",
|
||||
"",
|
||||
``,
|
||||
].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:",
|
||||
"",
|
||||
``,
|
||||
].join("\n");
|
||||
return {
|
||||
content: [{ type: "text", text }],
|
||||
details: { qr: true },
|
||||
};
|
||||
return renderQrReply({
|
||||
message: result.message,
|
||||
qrDataUrl: result.qrDataUrl,
|
||||
connected: result.connected,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 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." };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
27
src/gateway/protocol/channels.schema.test.ts
Normal file
27
src/gateway/protocol/channels.schema.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
48
ui/src/ui/controllers/channels.test.ts
Normal file
48
ui/src/ui/controllers/channels.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user