WhatsApp: add QR login result codes and gateway preflight

This commit is contained in:
Marcus Castro
2026-04-16 17:37:01 -03:00
parent aa76cf43f0
commit efabdfc483
9 changed files with 292 additions and 30 deletions

View File

@@ -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<PreflightWebLoginWithQrStart>
): ReturnType<PreflightWebLoginWithQrStart> {
const { preflightWebLoginWithQrStart } = await loadLoginQrModule();
return await preflightWebLoginWithQrStart(...args);
}
export async function startWebLoginWithQr(
...args: Parameters<StartWebLoginWithQr>
): ReturnType<StartWebLoginWithQr> {

View File

@@ -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 },
});
});
});

View File

@@ -52,7 +52,7 @@ export function createWhatsAppLoginTool(): ChannelAgentTool {
text: result.message,
},
],
details: { qr: false },
details: { qr: false, ...(result.code ? { code: result.code } : {}) },
};
}

View File

@@ -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<LoginWeb>): ReturnType<LoginWeb> {
return loginWebImpl(...args);
}
export async function preflightWebLoginWithQrStart(
...args: Parameters<PreflightWebLoginWithQrStart>
): ReturnType<PreflightWebLoginWithQrStart> {
return await preflightWebLoginWithQrStartImpl(...args);
}
export async function startWebLoginWithQr(
...args: Parameters<StartWebLoginWithQr>
): ReturnType<StartWebLoginWithQr> {

View File

@@ -303,6 +303,15 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
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 }) => {

View File

@@ -51,6 +51,14 @@ function waitForNextTask(): Promise<void> {
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<string, ActiveLogin>();
@@ -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<StartWebLoginWithQrResult> {
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<StartWebLoginWithQrResult | null> {
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 }> {

View File

@@ -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<ResolvedAccount = unknown> = {
stopAccount?: (ctx: ChannelGatewayContext<ResolvedAccount>) => Promise<void>;
/** 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<ChannelLoginWithQrStartResult | null>;
loginWithQrStart?: (params: {
accountId?: string;
force?: boolean;

View File

@@ -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["gateway"]>,
): ChannelPlugin<Record<string, never>> {
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<void>;
}): 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,
);
});
});

View File

@@ -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) {