mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-20 21:02:10 +08:00
WhatsApp: add QR login result codes and gateway preflight
This commit is contained in:
@@ -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> {
|
||||
|
||||
75
extensions/whatsapp/src/agent-tools-login.test.ts
Normal file
75
extensions/whatsapp/src/agent-tools-login.test.ts
Normal 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:",
|
||||
"",
|
||||
"",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
details: { qr: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -52,7 +52,7 @@ export function createWhatsAppLoginTool(): ChannelAgentTool {
|
||||
text: result.message,
|
||||
},
|
||||
],
|
||||
details: { qr: false },
|
||||
details: { qr: false, ...(result.code ? { code: result.code } : {}) },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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 }> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
135
src/gateway/server-methods/web.test.ts
Normal file
135
src/gateway/server-methods/web.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user