diff --git a/extensions/whatsapp/login-qr-runtime.ts b/extensions/whatsapp/login-qr-runtime.ts index 89c85332a8f..d3e4ba89fc9 100644 --- a/extensions/whatsapp/login-qr-runtime.ts +++ b/extensions/whatsapp/login-qr-runtime.ts @@ -1,3 +1,4 @@ +type PreflightWebLoginWithQrStart = typeof import("./src/login-qr.js").preflightWebLoginWithQrStart; type StartWebLoginWithQr = typeof import("./src/login-qr.js").startWebLoginWithQr; type WaitForWebLogin = typeof import("./src/login-qr.js").waitForWebLogin; @@ -8,6 +9,13 @@ function loadLoginQrModule() { return loginQrModulePromise; } +export async function preflightWebLoginWithQrStart( + ...args: Parameters +): ReturnType { + const { preflightWebLoginWithQrStart } = await loadLoginQrModule(); + return await preflightWebLoginWithQrStart(...args); +} + export async function startWebLoginWithQr( ...args: Parameters ): ReturnType { 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..449f33a807a --- /dev/null +++ b/extensions/whatsapp/src/agent-tools-login.test.ts @@ -0,0 +1,75 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createWhatsAppLoginTool } from "./agent-tools-login.js"; + +const hoisted = vi.hoisted(() => ({ + startWebLoginWithQr: vi.fn(), + waitForWebLogin: vi.fn(), +})); + +vi.mock("../login-qr-api.js", () => ({ + startWebLoginWithQr: hoisted.startWebLoginWithQr, + waitForWebLogin: hoisted.waitForWebLogin, +})); + +describe("createWhatsAppLoginTool", () => { + beforeEach(() => { + hoisted.startWebLoginWithQr.mockReset(); + hoisted.waitForWebLogin.mockReset(); + }); + + it("preserves unstable-auth code on non-QR start results", async () => { + hoisted.startWebLoginWithQr.mockResolvedValueOnce({ + code: "whatsapp-auth-unstable", + message: "WhatsApp auth state is still stabilizing. Retry login in a moment.", + }); + + const tool = createWhatsAppLoginTool(); + const result = await tool.execute("tool-call", { + action: "start", + timeoutMs: 5_000, + }); + + expect(hoisted.startWebLoginWithQr).toHaveBeenCalledWith({ + timeoutMs: 5_000, + force: false, + }); + expect(result).toEqual({ + content: [ + { + type: "text", + text: "WhatsApp auth state is still stabilizing. Retry login in a moment.", + }, + ], + details: { qr: false, code: "whatsapp-auth-unstable" }, + }); + }); + + it("returns QR details without adding an unstable code", async () => { + hoisted.startWebLoginWithQr.mockResolvedValueOnce({ + qrDataUrl: "data:image/png;base64,abc123", + message: "QR already active. Scan it in WhatsApp -> Linked Devices.", + }); + + const tool = createWhatsAppLoginTool(); + const result = await tool.execute("tool-call", { + action: "start", + force: true, + }); + + expect(result).toEqual({ + content: [ + { + type: "text", + text: [ + "QR already active. Scan it in WhatsApp -> Linked Devices.", + "", + "Open WhatsApp → Linked Devices and scan:", + "", + "![whatsapp-qr](data:image/png;base64,abc123)", + ].join("\n"), + }, + ], + details: { qr: true }, + }); + }); +}); diff --git a/extensions/whatsapp/src/agent-tools-login.ts b/extensions/whatsapp/src/agent-tools-login.ts index 653f4c5ef6b..d18538bc23b 100644 --- a/extensions/whatsapp/src/agent-tools-login.ts +++ b/extensions/whatsapp/src/agent-tools-login.ts @@ -52,7 +52,7 @@ export function createWhatsAppLoginTool(): ChannelAgentTool { text: result.message, }, ], - details: { qr: false }, + details: { qr: false, ...(result.code ? { code: result.code } : {}) }, }; } diff --git a/extensions/whatsapp/src/channel.runtime.ts b/extensions/whatsapp/src/channel.runtime.ts index 13978d8c652..040babe52a7 100644 --- a/extensions/whatsapp/src/channel.runtime.ts +++ b/extensions/whatsapp/src/channel.runtime.ts @@ -1,4 +1,5 @@ import { + preflightWebLoginWithQrStart as preflightWebLoginWithQrStartImpl, startWebLoginWithQr as startWebLoginWithQrImpl, waitForWebLogin as waitForWebLoginImpl, } from "../login-qr-runtime.js"; @@ -31,6 +32,8 @@ type ReadWebAuthSnapshotBestEffort = typeof import("./auth-store.js").readWebAut type ReadWebSelfId = typeof import("./auth-store.js").readWebSelfId; type WebAuthExists = typeof import("./auth-store.js").webAuthExists; type LoginWeb = typeof import("./login.js").loginWeb; +type PreflightWebLoginWithQrStart = + typeof import("../login-qr-runtime.js").preflightWebLoginWithQrStart; type StartWebLoginWithQr = typeof import("../login-qr-runtime.js").startWebLoginWithQr; type WaitForWebLogin = typeof import("../login-qr-runtime.js").waitForWebLogin; type WhatsAppSetupWizard = typeof import("./setup-surface.js").whatsappSetupWizard; @@ -96,6 +99,12 @@ export function loginWeb(...args: Parameters): ReturnType { return loginWebImpl(...args); } +export async function preflightWebLoginWithQrStart( + ...args: Parameters +): ReturnType { + return await preflightWebLoginWithQrStartImpl(...args); +} + export async function startWebLoginWithQr( ...args: Parameters ): ReturnType { diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 9e0af3b535e..edef9e84b56 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -303,6 +303,15 @@ export const whatsappPlugin: ChannelPlugin = timeoutMs, verbose, }), + loginWithQrStartPreflight: async ({ accountId, force, timeoutMs, verbose }) => + await ( + await loadWhatsAppChannelRuntime() + ).preflightWebLoginWithQrStart({ + accountId, + force, + timeoutMs, + verbose, + }), loginWithQrWait: async ({ accountId, timeoutMs }) => await (await loadWhatsAppChannelRuntime()).waitForWebLogin({ accountId, timeoutMs }), logoutAccount: async ({ account, runtime }) => { diff --git a/extensions/whatsapp/src/login-qr.ts b/extensions/whatsapp/src/login-qr.ts index ff1d1354212..81110e3441b 100644 --- a/extensions/whatsapp/src/login-qr.ts +++ b/extensions/whatsapp/src/login-qr.ts @@ -51,6 +51,14 @@ function waitForNextTask(): Promise { return new Promise((resolve) => setImmediate(resolve)); } +type WebLoginStartParams = { + verbose?: boolean; + timeoutMs?: number; + force?: boolean; + accountId?: string; + runtime?: RuntimeEnv; +}; + const ACTIVE_LOGIN_TTL_MS = 3 * 60_000; const activeLogins = new Map(); @@ -160,32 +168,15 @@ async function waitForQrOrRecoveredLogin(params: { } export async function startWebLoginWithQr( - opts: { - verbose?: boolean; - timeoutMs?: number; - force?: boolean; - accountId?: string; - runtime?: RuntimeEnv; - } = {}, + opts: WebLoginStartParams = {}, ): Promise { + const preflight = await preflightWebLoginWithQrStart(opts); + if (preflight) { + return preflight; + } const runtime = opts.runtime ?? defaultRuntime; const cfg = loadConfig(); const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId }); - const authState = await readWebAuthExistsForDecision(account.authDir); - if (authState.outcome === "unstable") { - return { - code: WHATSAPP_AUTH_UNSTABLE_CODE, - message: "WhatsApp auth state is still stabilizing. Retry login in a moment.", - }; - } - if (authState.exists && !opts.force) { - const selfId = readWebSelfId(account.authDir); - const who = selfId.e164 ?? selfId.jid ?? "unknown"; - return { - message: `WhatsApp is already linked (${who}). Say “relink” if you want a fresh QR.`, - }; - } - const existing = activeLogins.get(account.accountId); if (existing && isLoginFresh(existing) && existing.qrDataUrl) { return { @@ -286,6 +277,28 @@ export async function startWebLoginWithQr( }; } +export async function preflightWebLoginWithQrStart( + opts: WebLoginStartParams = {}, +): Promise { + const cfg = loadConfig(); + const account = resolveWhatsAppAccount({ cfg, accountId: opts.accountId }); + const authState = await readWebAuthExistsForDecision(account.authDir); + if (authState.outcome === "unstable") { + return { + code: WHATSAPP_AUTH_UNSTABLE_CODE, + message: "WhatsApp auth state is still stabilizing. Retry login in a moment.", + }; + } + if (authState.exists && !opts.force) { + const selfId = readWebSelfId(account.authDir); + const who = selfId.e164 ?? selfId.jid ?? "unknown"; + return { + message: `WhatsApp is already linked (${who}). Say “relink” if you want a fresh QR.`, + }; + } + return null; +} + export async function waitForWebLogin( opts: { timeoutMs?: number; runtime?: RuntimeEnv; accountId?: string } = {}, ): Promise<{ connected: boolean; message: string }> { diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index 792d39f02a2..40a15808b74 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -318,6 +318,7 @@ export type ChannelLoginWithQrStartResult = { qrDataUrl?: string; message: string; connected?: boolean; + code?: string; }; export type ChannelLoginWithQrWaitResult = { @@ -338,6 +339,12 @@ export type ChannelGatewayAdapter = { stopAccount?: (ctx: ChannelGatewayContext) => Promise; /** Keep gateway auth bypass resolution mirrored through a lightweight top-level `gateway-auth-api.ts` artifact. */ resolveGatewayAuthBypassPaths?: (params: { cfg: OpenClawConfig }) => string[]; + loginWithQrStartPreflight?: (params: { + accountId?: string; + force?: boolean; + timeoutMs?: number; + verbose?: boolean; + }) => Promise; loginWithQrStart?: (params: { accountId?: string; force?: boolean; diff --git a/src/gateway/server-methods/web.test.ts b/src/gateway/server-methods/web.test.ts new file mode 100644 index 00000000000..2430e2a0062 --- /dev/null +++ b/src/gateway/server-methods/web.test.ts @@ -0,0 +1,135 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ChannelPlugin } from "../../channels/plugins/types.plugin.js"; +import type { GatewayRequestHandlerOptions } from "./shared-types.js"; + +const hoisted = vi.hoisted(() => ({ + listChannelPlugins: vi.fn<() => ChannelPlugin[]>(() => []), +})); + +vi.mock("../../channels/plugins/index.js", () => ({ + listChannelPlugins: hoisted.listChannelPlugins, +})); + +import { webHandlers } from "./web.js"; + +function createWebLoginPlugin( + gateway: NonNullable, +): ChannelPlugin> { + return { + id: "whatsapp", + meta: { + id: "whatsapp", + label: "WhatsApp", + selectionLabel: "WhatsApp", + docsPath: "/whatsapp", + blurb: "WhatsApp", + }, + capabilities: { + chatTypes: ["direct"], + }, + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({}), + }, + gatewayMethods: ["web.login.start", "web.login.wait"], + gateway, + }; +} + +function createHandlerOptions(params: { + respond: GatewayRequestHandlerOptions["respond"]; + stopChannel: (channelId: string, accountId?: string) => Promise; +}): GatewayRequestHandlerOptions { + return { + req: { + id: "req_1", + method: "web.login.start", + } as never, + params: {}, + client: null, + isWebchatConnect: () => false, + respond: params.respond, + context: { + stopChannel: params.stopChannel, + } as never, + }; +} + +describe("webHandlers", () => { + beforeEach(() => { + hoisted.listChannelPlugins.mockReset().mockReturnValue([]); + }); + + it("does not stop the channel when QR login preflight returns unstable auth", async () => { + const stopChannel = vi.fn(async () => undefined); + const respond = vi.fn(); + const loginWithQrStart = vi.fn(async () => ({ + qrDataUrl: "data:image/png;base64,qr", + message: "Scan this QR in WhatsApp -> Linked Devices.", + })); + const loginWithQrStartPreflight = vi.fn(async () => ({ + code: "whatsapp-auth-unstable", + message: "WhatsApp auth state is still stabilizing. Retry login in a moment.", + })); + hoisted.listChannelPlugins.mockReturnValue([ + createWebLoginPlugin({ + loginWithQrStart, + loginWithQrStartPreflight, + }), + ]); + + await webHandlers["web.login.start"]( + createHandlerOptions({ + respond, + stopChannel, + }), + ); + + expect(loginWithQrStartPreflight).toHaveBeenCalledOnce(); + expect(stopChannel).not.toHaveBeenCalled(); + expect(loginWithQrStart).not.toHaveBeenCalled(); + expect(respond).toHaveBeenCalledWith( + true, + { + code: "whatsapp-auth-unstable", + message: "WhatsApp auth state is still stabilizing. Retry login in a moment.", + }, + undefined, + ); + }); + + it("stops the channel before starting QR login when preflight allows it", async () => { + const stopChannel = vi.fn(async () => undefined); + const respond = vi.fn(); + const loginWithQrStart = vi.fn(async () => ({ + qrDataUrl: "data:image/png;base64,qr", + message: "Scan this QR in WhatsApp -> Linked Devices.", + })); + const loginWithQrStartPreflight = vi.fn(async () => null); + hoisted.listChannelPlugins.mockReturnValue([ + createWebLoginPlugin({ + loginWithQrStart, + loginWithQrStartPreflight, + }), + ]); + + await webHandlers["web.login.start"]( + createHandlerOptions({ + respond, + stopChannel, + }), + ); + + expect(loginWithQrStartPreflight).toHaveBeenCalledOnce(); + expect(stopChannel).toHaveBeenCalledWith("whatsapp", undefined); + expect(loginWithQrStart).toHaveBeenCalledOnce(); + expect(respond).toHaveBeenCalledWith( + true, + { + qrDataUrl: "data:image/png;base64,qr", + message: "Scan this QR in WhatsApp -> Linked Devices.", + }, + undefined, + ); + }); +}); diff --git a/src/gateway/server-methods/web.ts b/src/gateway/server-methods/web.ts index d9128b4bddc..b948c39bb39 100644 --- a/src/gateway/server-methods/web.ts +++ b/src/gateway/server-methods/web.ts @@ -82,13 +82,7 @@ export const webHandlers: GatewayRequestHandlers = { respondProviderUnsupported(respond, provider.id); return; } - const wasRunning = wasChannelRunning({ - context, - channelId: provider.id, - accountId, - }); - await context.stopChannel(provider.id, accountId); - const result = await provider.gateway.loginWithQrStart({ + const loginParams = { force: Boolean((params as { force?: boolean }).force), timeoutMs: typeof (params as { timeoutMs?: unknown }).timeoutMs === "number" @@ -96,7 +90,19 @@ export const webHandlers: GatewayRequestHandlers = { : undefined, verbose: Boolean((params as { verbose?: boolean }).verbose), accountId, + }; + const preflightResult = await provider.gateway.loginWithQrStartPreflight?.(loginParams); + if (preflightResult) { + respond(true, preflightResult, undefined); + return; + } + const wasRunning = wasChannelRunning({ + context, + channelId: provider.id, + accountId, }); + await context.stopChannel(provider.id, accountId); + const result = await provider.gateway.loginWithQrStart(loginParams); if (result.connected) { await context.startChannel(provider.id, accountId); } else if (wasRunning && !result.qrDataUrl) {