From 6686ef0b3a90ae82b4ffd43b8771ccca44743077 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 3 Apr 2026 17:26:12 +0100 Subject: [PATCH] test: split whatsapp channel coverage --- .../whatsapp/src/channel-actions.runtime.ts | 7 + .../whatsapp/src/channel-actions.test.ts | 216 +++++++ extensions/whatsapp/src/channel-actions.ts | 84 +++ extensions/whatsapp/src/channel-outbound.ts | 23 + .../src/channel-react-action.runtime.ts | 4 + .../whatsapp/src/channel-react-action.test.ts | 188 ++++++ .../whatsapp/src/channel-react-action.ts | 69 ++ extensions/whatsapp/src/channel.setup.test.ts | 247 ++++++++ extensions/whatsapp/src/channel.test.ts | 587 ------------------ extensions/whatsapp/src/channel.ts | 179 +----- .../whatsapp/src/directory-config.test.ts | 60 ++ extensions/whatsapp/src/group-policy.test.ts | 36 ++ .../src/heartbeat-recipients.runtime.ts | 9 + .../whatsapp/src/heartbeat-recipients.test.ts | 51 +- .../whatsapp/src/heartbeat-recipients.ts | 10 +- extensions/whatsapp/src/outbound-base.test.ts | 88 +++ extensions/whatsapp/src/setup-finalize.ts | 390 ++++++++++++ extensions/whatsapp/src/setup-surface.ts | 379 +---------- 18 files changed, 1462 insertions(+), 1165 deletions(-) create mode 100644 extensions/whatsapp/src/channel-actions.runtime.ts create mode 100644 extensions/whatsapp/src/channel-actions.test.ts create mode 100644 extensions/whatsapp/src/channel-actions.ts create mode 100644 extensions/whatsapp/src/channel-outbound.ts create mode 100644 extensions/whatsapp/src/channel-react-action.runtime.ts create mode 100644 extensions/whatsapp/src/channel-react-action.test.ts create mode 100644 extensions/whatsapp/src/channel-react-action.ts create mode 100644 extensions/whatsapp/src/channel.setup.test.ts delete mode 100644 extensions/whatsapp/src/channel.test.ts create mode 100644 extensions/whatsapp/src/directory-config.test.ts create mode 100644 extensions/whatsapp/src/group-policy.test.ts create mode 100644 extensions/whatsapp/src/heartbeat-recipients.runtime.ts create mode 100644 extensions/whatsapp/src/outbound-base.test.ts create mode 100644 extensions/whatsapp/src/setup-finalize.ts diff --git a/extensions/whatsapp/src/channel-actions.runtime.ts b/extensions/whatsapp/src/channel-actions.runtime.ts new file mode 100644 index 00000000000..e0f8b363024 --- /dev/null +++ b/extensions/whatsapp/src/channel-actions.runtime.ts @@ -0,0 +1,7 @@ +export { listWhatsAppAccountIds, resolveWhatsAppAccount } from "./accounts.js"; +export { + createActionGate, + type ChannelMessageActionName, + type OpenClawConfig, +} from "./runtime-api.js"; +export { resolveWhatsAppReactionLevel } from "./reaction-level.js"; diff --git a/extensions/whatsapp/src/channel-actions.test.ts b/extensions/whatsapp/src/channel-actions.test.ts new file mode 100644 index 00000000000..f726fadfaca --- /dev/null +++ b/extensions/whatsapp/src/channel-actions.test.ts @@ -0,0 +1,216 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + describeWhatsAppMessageActions, + resolveWhatsAppAgentReactionGuidance, +} from "./channel-actions.js"; +import type { OpenClawConfig } from "./runtime-api.js"; + +const hoisted = vi.hoisted(() => ({ + listWhatsAppAccountIds: vi.fn((cfg: OpenClawConfig) => { + const accountIds = Object.keys(cfg.channels?.whatsapp?.accounts ?? {}); + return accountIds.length > 0 ? accountIds : ["default"]; + }), + resolveWhatsAppAccount: vi.fn( + ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => ({ + enabled: + accountId == null ? true : cfg.channels?.whatsapp?.accounts?.[accountId]?.enabled !== false, + }), + ), +})); + +vi.mock("./channel-actions.runtime.js", async () => { + return { + listWhatsAppAccountIds: hoisted.listWhatsAppAccountIds, + resolveWhatsAppAccount: hoisted.resolveWhatsAppAccount, + createActionGate: (actions?: { reactions?: boolean; polls?: boolean }) => (name: string) => { + if (name === "reactions") { + return actions?.reactions !== false; + } + if (name === "polls") { + return actions?.polls !== false; + } + return true; + }, + resolveWhatsAppReactionLevel: ({ + cfg, + accountId, + }: { + cfg: OpenClawConfig; + accountId?: string; + }) => { + const accountLevel = + accountId == null + ? undefined + : cfg.channels?.whatsapp?.accounts?.[accountId]?.reactionLevel; + const level = accountLevel ?? cfg.channels?.whatsapp?.reactionLevel ?? "minimal"; + return { + level, + agentReactionsEnabled: level === "minimal" || level === "extensive", + agentReactionGuidance: level === "minimal" || level === "extensive" ? level : undefined, + }; + }, + }; +}); + +describe("whatsapp channel action helpers", () => { + beforeEach(() => { + hoisted.listWhatsAppAccountIds.mockClear(); + hoisted.resolveWhatsAppAccount.mockClear(); + }); + + it("defaults to minimal reaction guidance when reactions are available", () => { + const cfg = { + channels: { + whatsapp: { + allowFrom: ["*"], + }, + }, + } as OpenClawConfig; + + expect(resolveWhatsAppAgentReactionGuidance({ cfg, accountId: "default" })).toBe("minimal"); + }); + + it("omits reaction guidance when WhatsApp is not configured", () => { + expect( + resolveWhatsAppAgentReactionGuidance({ + cfg: {} as OpenClawConfig, + accountId: "default", + }), + ).toBeUndefined(); + }); + + it("returns minimal reaction guidance when configured", () => { + const cfg = { + channels: { + whatsapp: { + reactionLevel: "minimal", + allowFrom: ["*"], + }, + }, + } as OpenClawConfig; + + expect(resolveWhatsAppAgentReactionGuidance({ cfg, accountId: "default" })).toBe("minimal"); + }); + + it("omits reaction guidance when WhatsApp reactions are disabled", () => { + const cfg = { + channels: { + whatsapp: { + actions: { reactions: false }, + allowFrom: ["*"], + }, + }, + } as OpenClawConfig; + + expect(resolveWhatsAppAgentReactionGuidance({ cfg, accountId: "default" })).toBeUndefined(); + }); + + it("omits reaction guidance when reactionLevel disables agent reactions", () => { + const cfg = { + channels: { + whatsapp: { + reactionLevel: "ack", + allowFrom: ["*"], + }, + }, + } as OpenClawConfig; + + expect(resolveWhatsAppAgentReactionGuidance({ cfg, accountId: "default" })).toBeUndefined(); + }); + + it("advertises react when agent reactions are enabled", () => { + const cfg = { + channels: { + whatsapp: { + allowFrom: ["*"], + }, + }, + } as OpenClawConfig; + + expect(describeWhatsAppMessageActions({ cfg, accountId: "default" })?.actions).toEqual([ + "react", + "poll", + ]); + }); + + it("returns null when WhatsApp is not configured", () => { + expect( + describeWhatsAppMessageActions({ cfg: {} as OpenClawConfig, accountId: "default" }), + ).toBeNull(); + }); + + it("omits react when reactionLevel disables agent reactions", () => { + const cfg = { + channels: { + whatsapp: { + reactionLevel: "ack", + allowFrom: ["*"], + }, + }, + } as OpenClawConfig; + + expect(describeWhatsAppMessageActions({ cfg, accountId: "default" })?.actions).toEqual([ + "poll", + ]); + }); + + it("uses the active account reactionLevel for discovery", () => { + const cfg = { + channels: { + whatsapp: { + reactionLevel: "ack", + allowFrom: ["*"], + accounts: { + work: { + reactionLevel: "minimal", + }, + }, + }, + }, + } as OpenClawConfig; + + expect(describeWhatsAppMessageActions({ cfg, accountId: "work" })?.actions).toEqual([ + "react", + "poll", + ]); + }); + + it("keeps react in global discovery when any account enables agent reactions", () => { + const cfg = { + channels: { + whatsapp: { + reactionLevel: "ack", + allowFrom: ["*"], + accounts: { + work: { + reactionLevel: "minimal", + }, + }, + }, + }, + } as OpenClawConfig; + hoisted.listWhatsAppAccountIds.mockReturnValue(["default", "work"]); + + expect(describeWhatsAppMessageActions({ cfg })?.actions).toEqual(["react", "poll"]); + }); + + it("omits react in global discovery when only disabled accounts enable agent reactions", () => { + const cfg = { + channels: { + whatsapp: { + reactionLevel: "ack", + allowFrom: ["*"], + accounts: { + work: { + enabled: false, + reactionLevel: "minimal", + }, + }, + }, + }, + } as OpenClawConfig; + hoisted.listWhatsAppAccountIds.mockReturnValue(["default", "work"]); + + expect(describeWhatsAppMessageActions({ cfg })?.actions).toEqual(["poll"]); + }); +}); diff --git a/extensions/whatsapp/src/channel-actions.ts b/extensions/whatsapp/src/channel-actions.ts new file mode 100644 index 00000000000..6d63d2b95e4 --- /dev/null +++ b/extensions/whatsapp/src/channel-actions.ts @@ -0,0 +1,84 @@ +import { + listWhatsAppAccountIds, + resolveWhatsAppAccount, + createActionGate, + type ChannelMessageActionName, + type OpenClawConfig, + resolveWhatsAppReactionLevel, +} from "./channel-actions.runtime.js"; + +function areWhatsAppAgentReactionsEnabled(params: { cfg: OpenClawConfig; accountId?: string }) { + if (!params.cfg.channels?.whatsapp) { + return false; + } + const gate = createActionGate(params.cfg.channels.whatsapp.actions); + if (!gate("reactions")) { + return false; + } + return resolveWhatsAppReactionLevel({ + cfg: params.cfg, + accountId: params.accountId, + }).agentReactionsEnabled; +} + +function hasAnyWhatsAppAccountWithAgentReactionsEnabled(cfg: OpenClawConfig) { + if (!cfg.channels?.whatsapp) { + return false; + } + return listWhatsAppAccountIds(cfg).some((accountId) => { + const account = resolveWhatsAppAccount({ cfg, accountId }); + if (!account.enabled) { + return false; + } + return areWhatsAppAgentReactionsEnabled({ + cfg, + accountId, + }); + }); +} + +export function resolveWhatsAppAgentReactionGuidance(params: { + cfg: OpenClawConfig; + accountId?: string; +}) { + if (!params.cfg.channels?.whatsapp) { + return undefined; + } + const gate = createActionGate(params.cfg.channels.whatsapp.actions); + if (!gate("reactions")) { + return undefined; + } + const resolved = resolveWhatsAppReactionLevel({ + cfg: params.cfg, + accountId: params.accountId, + }); + if (!resolved.agentReactionsEnabled) { + return undefined; + } + return resolved.agentReactionGuidance; +} + +export function describeWhatsAppMessageActions(params: { + cfg: OpenClawConfig; + accountId?: string | null; +}): { actions: ChannelMessageActionName[] } | null { + if (!params.cfg.channels?.whatsapp) { + return null; + } + const gate = createActionGate(params.cfg.channels.whatsapp.actions); + const actions = new Set(); + const canReact = + params.accountId != null + ? areWhatsAppAgentReactionsEnabled({ + cfg: params.cfg, + accountId: params.accountId ?? undefined, + }) + : hasAnyWhatsAppAccountWithAgentReactionsEnabled(params.cfg); + if (canReact) { + actions.add("react"); + } + if (gate("polls")) { + actions.add("poll"); + } + return { actions: Array.from(actions) }; +} diff --git a/extensions/whatsapp/src/channel-outbound.ts b/extensions/whatsapp/src/channel-outbound.ts new file mode 100644 index 00000000000..6d6085d6a58 --- /dev/null +++ b/extensions/whatsapp/src/channel-outbound.ts @@ -0,0 +1,23 @@ +import { chunkText } from "openclaw/plugin-sdk/reply-runtime"; +import { createWhatsAppOutboundBase, resolveWhatsAppOutboundTarget } from "./runtime-api.js"; +import { getWhatsAppRuntime } from "./runtime.js"; +import { sendMessageWhatsApp, sendPollWhatsApp } from "./send.js"; + +export function normalizeWhatsAppPayloadText(text: string | undefined): string { + return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); +} + +export const whatsappChannelOutbound = { + ...createWhatsAppOutboundBase({ + chunker: chunkText, + sendMessageWhatsApp, + sendPollWhatsApp, + shouldLogVerbose: () => getWhatsAppRuntime().logging.shouldLogVerbose(), + resolveTarget: ({ to, allowFrom, mode }) => + resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), + }), + normalizePayload: ({ payload }: { payload: { text?: string } }) => ({ + ...payload, + text: normalizeWhatsAppPayloadText(payload.text), + }), +}; diff --git a/extensions/whatsapp/src/channel-react-action.runtime.ts b/extensions/whatsapp/src/channel-react-action.runtime.ts new file mode 100644 index 00000000000..35c0f106ad0 --- /dev/null +++ b/extensions/whatsapp/src/channel-react-action.runtime.ts @@ -0,0 +1,4 @@ +export { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions"; +export { handleWhatsAppAction } from "./action-runtime.js"; +export { normalizeWhatsAppTarget } from "./normalize.js"; +export { readStringParam, type OpenClawConfig } from "./runtime-api.js"; diff --git a/extensions/whatsapp/src/channel-react-action.test.ts b/extensions/whatsapp/src/channel-react-action.test.ts new file mode 100644 index 00000000000..aff2e787057 --- /dev/null +++ b/extensions/whatsapp/src/channel-react-action.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { handleWhatsAppReactAction } from "./channel-react-action.js"; +import type { OpenClawConfig } from "./runtime-api.js"; + +const hoisted = vi.hoisted(() => ({ + handleWhatsAppAction: vi.fn(async () => ({ content: [{ type: "text", text: '{"ok":true}' }] })), +})); + +vi.mock("./channel-react-action.runtime.js", async () => { + return { + handleWhatsAppAction: hoisted.handleWhatsAppAction, + resolveReactionMessageId: ({ + args, + toolContext, + }: { + args: Record; + toolContext?: { currentMessageId?: string | number | null }; + }) => args.messageId ?? toolContext?.currentMessageId ?? null, + normalizeWhatsAppTarget: (value?: string | null) => { + const raw = `${value ?? ""}`.trim(); + if (!raw) { + return null; + } + const stripped = raw.replace(/^whatsapp:/, ""); + return stripped.startsWith("+") ? stripped : `+${stripped.replace(/^\+/, "")}`; + }, + readStringParam: ( + params: Record, + key: string, + options?: { required?: boolean; allowEmpty?: boolean }, + ) => { + const value = params[key]; + if (value == null) { + if (options?.required) { + const err = new Error(`${key} required`); + err.name = "ToolInputError"; + throw err; + } + return undefined; + } + const text = String(value); + if (!options?.allowEmpty && !text.trim()) { + if (options?.required) { + const err = new Error(`${key} required`); + err.name = "ToolInputError"; + throw err; + } + return undefined; + } + return text; + }, + }; +}); + +describe("whatsapp react action messageId resolution", () => { + const baseCfg = { + channels: { whatsapp: { actions: { reactions: true }, allowFrom: ["*"] } }, + } as OpenClawConfig; + + beforeEach(() => { + hoisted.handleWhatsAppAction.mockClear(); + }); + + it("uses explicit messageId when provided", async () => { + await handleWhatsAppReactAction({ + action: "react", + params: { messageId: "explicit-id", emoji: "👍", to: "+1555" }, + cfg: baseCfg, + accountId: "default", + }); + expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( + expect.objectContaining({ messageId: "explicit-id" }), + baseCfg, + ); + }); + + it("falls back to toolContext.currentMessageId when messageId omitted", async () => { + await handleWhatsAppReactAction({ + action: "react", + params: { emoji: "❤️", to: "+1555" }, + cfg: baseCfg, + accountId: "default", + toolContext: { + currentChannelId: "whatsapp:+1555", + currentChannelProvider: "whatsapp", + currentMessageId: "ctx-msg-42", + }, + }); + expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( + expect.objectContaining({ messageId: "ctx-msg-42" }), + baseCfg, + ); + }); + + it("converts numeric toolContext messageId to string", async () => { + await handleWhatsAppReactAction({ + action: "react", + params: { emoji: "🎉", to: "+1555" }, + cfg: baseCfg, + accountId: "default", + toolContext: { + currentChannelId: "whatsapp:+1555", + currentChannelProvider: "whatsapp", + currentMessageId: 12345, + }, + }); + expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( + expect.objectContaining({ messageId: "12345" }), + baseCfg, + ); + }); + + it("throws ToolInputError when messageId missing and no toolContext", async () => { + const err = await handleWhatsAppReactAction({ + action: "react", + params: { emoji: "👍", to: "+1555" }, + cfg: baseCfg, + accountId: "default", + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).name).toBe("ToolInputError"); + }); + + it("skips context fallback when targeting a different chat", async () => { + const err = await handleWhatsAppReactAction({ + action: "react", + params: { emoji: "👍", to: "+9999" }, + cfg: baseCfg, + accountId: "default", + toolContext: { + currentChannelId: "whatsapp:+1555", + currentChannelProvider: "whatsapp", + currentMessageId: "ctx-msg-42", + }, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).name).toBe("ToolInputError"); + }); + + it("uses context fallback when target matches current chat", async () => { + await handleWhatsAppReactAction({ + action: "react", + params: { emoji: "👍", to: "+1555" }, + cfg: baseCfg, + accountId: "default", + toolContext: { + currentChannelId: "whatsapp:+1555", + currentChannelProvider: "whatsapp", + currentMessageId: "ctx-msg-42", + }, + }); + expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( + expect.objectContaining({ messageId: "ctx-msg-42" }), + baseCfg, + ); + }); + + it("skips context fallback when source is another provider", async () => { + const err = await handleWhatsAppReactAction({ + action: "react", + params: { emoji: "👍", to: "+1555" }, + cfg: baseCfg, + accountId: "default", + toolContext: { + currentChannelId: "telegram:-1003841603622", + currentChannelProvider: "telegram", + currentMessageId: "tg-msg-99", + }, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).name).toBe("ToolInputError"); + }); + + it("skips context fallback when currentChannelId is missing with explicit target", async () => { + const err = await handleWhatsAppReactAction({ + action: "react", + params: { emoji: "👍", to: "+1555" }, + cfg: baseCfg, + accountId: "default", + toolContext: { + currentChannelProvider: "whatsapp", + currentMessageId: "ctx-msg-42", + }, + }).catch((e: unknown) => e); + expect(err).toBeInstanceOf(Error); + expect((err as Error).name).toBe("ToolInputError"); + }); +}); diff --git a/extensions/whatsapp/src/channel-react-action.ts b/extensions/whatsapp/src/channel-react-action.ts new file mode 100644 index 00000000000..81bad7038f5 --- /dev/null +++ b/extensions/whatsapp/src/channel-react-action.ts @@ -0,0 +1,69 @@ +import { + resolveReactionMessageId, + handleWhatsAppAction, + normalizeWhatsAppTarget, + readStringParam, + type OpenClawConfig, +} from "./channel-react-action.runtime.js"; + +const WHATSAPP_CHANNEL = "whatsapp" as const; + +export async function handleWhatsAppReactAction(params: { + action: string; + params: Record; + cfg: OpenClawConfig; + accountId?: string | null; + toolContext?: { + currentChannelId?: string | null; + currentChannelProvider?: string | null; + currentMessageId?: string | number | null; + }; +}) { + if (params.action !== "react") { + throw new Error(`Action ${params.action} is not supported for provider ${WHATSAPP_CHANNEL}.`); + } + const isWhatsAppSource = params.toolContext?.currentChannelProvider === WHATSAPP_CHANNEL; + const explicitTarget = + readStringParam(params.params, "chatJid") ?? readStringParam(params.params, "to"); + const normalizedTarget = explicitTarget ? normalizeWhatsAppTarget(explicitTarget) : null; + const normalizedCurrent = + isWhatsAppSource && params.toolContext?.currentChannelId + ? normalizeWhatsAppTarget(params.toolContext.currentChannelId) + : null; + const isCrossChat = + normalizedTarget != null && + (normalizedCurrent == null || normalizedTarget !== normalizedCurrent); + const scopedContext = + !isWhatsAppSource || isCrossChat || !params.toolContext + ? undefined + : { + currentChannelId: params.toolContext.currentChannelId ?? undefined, + currentChannelProvider: params.toolContext.currentChannelProvider ?? undefined, + currentMessageId: params.toolContext.currentMessageId ?? undefined, + }; + const messageIdRaw = resolveReactionMessageId({ + args: params.params, + toolContext: scopedContext, + }); + if (messageIdRaw == null) { + readStringParam(params.params, "messageId", { required: true }); + } + const messageId = String(messageIdRaw); + const emoji = readStringParam(params.params, "emoji", { allowEmpty: true }); + const remove = typeof params.params.remove === "boolean" ? params.params.remove : undefined; + return await handleWhatsAppAction( + { + action: "react", + chatJid: + readStringParam(params.params, "chatJid") ?? + readStringParam(params.params, "to", { required: true }), + messageId, + emoji, + remove, + participant: readStringParam(params.params, "participant"), + accountId: params.accountId ?? undefined, + fromMe: typeof params.params.fromMe === "boolean" ? params.params.fromMe : undefined, + }, + params.cfg, + ); +} diff --git a/extensions/whatsapp/src/channel.setup.test.ts b/extensions/whatsapp/src/channel.setup.test.ts new file mode 100644 index 00000000000..84c755a66af --- /dev/null +++ b/extensions/whatsapp/src/channel.setup.test.ts @@ -0,0 +1,247 @@ +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; +import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createQueuedWizardPrompter } from "../../../test/helpers/plugins/setup-wizard.js"; +import type { OpenClawConfig } from "./runtime-api.js"; +import { finalizeWhatsAppSetup } from "./setup-finalize.js"; + +const hoisted = vi.hoisted(() => ({ + loginWeb: vi.fn(async () => {}), + pathExists: vi.fn(async () => false), + resolveWhatsAppAuthDir: vi.fn(() => ({ + authDir: "/tmp/openclaw-whatsapp-test", + })), +})); + +vi.mock("./login.js", () => ({ + loginWeb: hoisted.loginWeb, +})); + +vi.mock("openclaw/plugin-sdk/setup", () => { + const normalizeE164 = (value?: string | null) => { + const raw = `${value ?? ""}`.trim(); + if (!raw) { + return ""; + } + const digits = raw.replace(/[^\d+]/g, ""); + return digits.startsWith("+") ? digits : `+${digits}`; + }; + return { + DEFAULT_ACCOUNT_ID, + normalizeAccountId: (value?: string | null) => value?.trim() || DEFAULT_ACCOUNT_ID, + normalizeAllowFromEntries: (entries: string[], normalize: (value: string) => string) => [ + ...new Set(entries.map((entry) => (entry === "*" ? "*" : normalize(entry))).filter(Boolean)), + ], + normalizeE164, + pathExists: hoisted.pathExists, + splitSetupEntries: (raw: string) => + raw + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean), + setSetupChannelEnabled: (cfg: OpenClawConfig, channel: string, enabled: boolean) => ({ + ...cfg, + channels: { + ...cfg.channels, + [channel]: { + ...(cfg.channels?.[channel as keyof NonNullable] as object), + enabled, + }, + }, + }), + }; +}); + +vi.mock("./accounts.js", () => ({ + resolveWhatsAppAuthDir: hoisted.resolveWhatsAppAuthDir, +})); + +function createRuntime(): RuntimeEnv { + return { + error: vi.fn(), + } as unknown as RuntimeEnv; +} + +async function runConfigureWithHarness(params: { + harness: ReturnType; + cfg?: OpenClawConfig; + runtime?: RuntimeEnv; + forceAllowFrom?: boolean; +}) { + const result = await finalizeWhatsAppSetup({ + cfg: params.cfg ?? ({} as OpenClawConfig), + accountId: DEFAULT_ACCOUNT_ID, + forceAllowFrom: params.forceAllowFrom ?? false, + prompter: params.harness.prompter, + runtime: params.runtime ?? createRuntime(), + }); + return { + accountId: DEFAULT_ACCOUNT_ID, + cfg: result.cfg, + }; +} + +function createSeparatePhoneHarness(params: { selectValues: string[]; textValues?: string[] }) { + return createQueuedWizardPrompter({ + confirmValues: [false], + selectValues: params.selectValues, + textValues: params.textValues, + }); +} + +async function runSeparatePhoneFlow(params: { selectValues: string[]; textValues?: string[] }) { + hoisted.pathExists.mockResolvedValue(true); + const harness = createSeparatePhoneHarness({ + selectValues: params.selectValues, + textValues: params.textValues, + }); + const result = await runConfigureWithHarness({ + harness, + }); + return { harness, result }; +} + +describe("whatsapp setup wizard", () => { + beforeEach(() => { + hoisted.loginWeb.mockReset(); + hoisted.pathExists.mockReset(); + hoisted.pathExists.mockResolvedValue(false); + hoisted.resolveWhatsAppAuthDir.mockReset(); + hoisted.resolveWhatsAppAuthDir.mockReturnValue({ authDir: "/tmp/openclaw-whatsapp-test" }); + }); + + it("applies owner allowlist when forceAllowFrom is enabled", async () => { + const harness = createQueuedWizardPrompter({ + confirmValues: [false], + textValues: ["+1 (555) 555-0123"], + }); + + const result = await runConfigureWithHarness({ + harness, + forceAllowFrom: true, + }); + + expect(result.accountId).toBe(DEFAULT_ACCOUNT_ID); + expect(hoisted.loginWeb).not.toHaveBeenCalled(); + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(true); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]); + expect(harness.text).toHaveBeenCalledWith( + expect.objectContaining({ + message: "Your personal WhatsApp number (the phone you will message from)", + }), + ); + }); + + it("supports disabled DM policy for separate-phone setup", async () => { + const { harness, result } = await runSeparatePhoneFlow({ + selectValues: ["separate", "disabled"], + }); + + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("disabled"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toBeUndefined(); + expect(harness.text).not.toHaveBeenCalled(); + }); + + it("normalizes allowFrom entries when list mode is selected", async () => { + const { result } = await runSeparatePhoneFlow({ + selectValues: ["separate", "allowlist", "list"], + textValues: ["+1 (555) 555-0123, +15555550123, *"], + }); + + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15555550123", "*"]); + }); + + it("enables allowlist self-chat mode for personal-phone setup", async () => { + hoisted.pathExists.mockResolvedValue(true); + const harness = createQueuedWizardPrompter({ + confirmValues: [false], + selectValues: ["personal"], + textValues: ["+1 (555) 111-2222"], + }); + + const result = await runConfigureWithHarness({ + harness, + }); + + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(true); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["+15551112222"]); + }); + + it("forces wildcard allowFrom for open policy without allowFrom follow-up prompts", async () => { + hoisted.pathExists.mockResolvedValue(true); + const harness = createSeparatePhoneHarness({ + selectValues: ["separate", "open"], + }); + + const result = await runConfigureWithHarness({ + harness, + cfg: { + channels: { + whatsapp: { + allowFrom: ["+15555550123"], + }, + }, + }, + }); + + expect(result.cfg.channels?.whatsapp?.selfChatMode).toBe(false); + expect(result.cfg.channels?.whatsapp?.dmPolicy).toBe("open"); + expect(result.cfg.channels?.whatsapp?.allowFrom).toEqual(["*", "+15555550123"]); + expect(harness.select).toHaveBeenCalledTimes(2); + expect(harness.text).not.toHaveBeenCalled(); + }); + + it("runs WhatsApp login when not linked and user confirms linking", async () => { + hoisted.pathExists.mockResolvedValue(false); + const harness = createQueuedWizardPrompter({ + confirmValues: [true], + selectValues: ["separate", "disabled"], + }); + const runtime = createRuntime(); + + await runConfigureWithHarness({ + harness, + runtime, + }); + + expect(hoisted.loginWeb).toHaveBeenCalledWith(false, undefined, runtime, DEFAULT_ACCOUNT_ID); + }); + + it("skips relink note when already linked and relink is declined", async () => { + hoisted.pathExists.mockResolvedValue(true); + const harness = createSeparatePhoneHarness({ + selectValues: ["separate", "disabled"], + }); + + await runConfigureWithHarness({ + harness, + }); + + expect(hoisted.loginWeb).not.toHaveBeenCalled(); + expect(harness.note).not.toHaveBeenCalledWith( + expect.stringContaining("openclaw channels login"), + "WhatsApp", + ); + }); + + it("shows follow-up login command note when not linked and linking is skipped", async () => { + hoisted.pathExists.mockResolvedValue(false); + const harness = createSeparatePhoneHarness({ + selectValues: ["separate", "disabled"], + }); + + await runConfigureWithHarness({ + harness, + }); + + expect(harness.note).toHaveBeenCalledWith( + expect.stringContaining("openclaw channels login"), + "WhatsApp", + ); + }); +}); diff --git a/extensions/whatsapp/src/channel.test.ts b/extensions/whatsapp/src/channel.test.ts deleted file mode 100644 index 5dc22c7191f..00000000000 --- a/extensions/whatsapp/src/channel.test.ts +++ /dev/null @@ -1,587 +0,0 @@ -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - createWhatsAppPollFixture, - expectWhatsAppPollSent, -} from "../../../src/test-helpers/whatsapp-outbound.js"; -import { - createDirectoryTestRuntime, - expectDirectorySurface, -} from "../../../test/helpers/plugins/directory.ts"; -import { whatsappPlugin } from "./channel.js"; -import { - resolveWhatsAppGroupRequireMention, - resolveWhatsAppGroupToolPolicy, -} from "./group-policy.js"; -import type { OpenClawConfig } from "./runtime-api.js"; - -const hoisted = vi.hoisted(() => ({ - sendPollWhatsApp: vi.fn(async () => ({ messageId: "wa-poll-1", toJid: "1555@s.whatsapp.net" })), - sendReactionWhatsApp: vi.fn(async () => undefined), - handleWhatsAppAction: vi.fn(async () => ({ content: [{ type: "text", text: '{"ok":true}' }] })), - listWhatsAppAccountIds: vi.fn((cfg: OpenClawConfig) => { - const accountIds = Object.keys(cfg.channels?.whatsapp?.accounts ?? {}); - return accountIds.length > 0 ? accountIds : [DEFAULT_ACCOUNT_ID]; - }), -})); - -vi.mock("./runtime.js", () => ({ - getWhatsAppRuntime: () => ({ - logging: { - shouldLogVerbose: () => false, - }, - channel: { - whatsapp: { - sendPollWhatsApp: hoisted.sendPollWhatsApp, - handleWhatsAppAction: hoisted.handleWhatsAppAction, - }, - }, - }), -})); - -vi.mock("./send.js", async () => { - const actual = await vi.importActual("./send.js"); - return { - ...actual, - sendPollWhatsApp: hoisted.sendPollWhatsApp, - sendReactionWhatsApp: hoisted.sendReactionWhatsApp, - }; -}); - -vi.mock("./action-runtime.js", () => ({ - handleWhatsAppAction: hoisted.handleWhatsAppAction, -})); - -vi.mock("./accounts.js", async () => { - const actual = await vi.importActual("./accounts.js"); - return { - ...actual, - listWhatsAppAccountIds: hoisted.listWhatsAppAccountIds, - }; -}); - -describe("whatsappPlugin outbound sendMedia", () => { - it("chunks outbound text without requiring WhatsApp runtime initialization", () => { - const chunker = whatsappPlugin.outbound?.chunker; - if (!chunker) { - throw new Error("whatsapp outbound chunker is unavailable"); - } - - expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]); - }); - - it("forwards mediaLocalRoots to sendMessageWhatsApp", async () => { - const sendWhatsApp = vi.fn(async () => ({ - messageId: "msg-1", - toJid: "15551234567@s.whatsapp.net", - })); - const mediaLocalRoots = ["/tmp/workspace"]; - - const outbound = whatsappPlugin.outbound; - if (!outbound?.sendMedia) { - throw new Error("whatsapp outbound sendMedia is unavailable"); - } - - const result = await outbound.sendMedia({ - cfg: {} as never, - to: "whatsapp:+15551234567", - text: "photo", - mediaUrl: "/tmp/workspace/photo.png", - mediaLocalRoots, - accountId: "default", - deps: { sendWhatsApp }, - gifPlayback: false, - }); - - expect(sendWhatsApp).toHaveBeenCalledWith( - "whatsapp:+15551234567", - "photo", - expect.objectContaining({ - verbose: false, - mediaUrl: "/tmp/workspace/photo.png", - mediaLocalRoots, - accountId: "default", - gifPlayback: false, - }), - ); - expect(result).toMatchObject({ channel: "whatsapp", messageId: "msg-1" }); - }); -}); - -describe("whatsappPlugin outbound resolveTarget", () => { - it("delegates direct target normalization to the outbound resolver", () => { - const outbound = whatsappPlugin.outbound; - if (!outbound?.resolveTarget) { - throw new Error("whatsapp outbound resolveTarget is unavailable"); - } - - expect( - outbound.resolveTarget({ - to: "whatsapp:+15551234567", - allowFrom: [], - mode: "explicit", - }), - ).toEqual({ ok: true, to: "+15551234567" }); - }); -}); - -describe("whatsappPlugin outbound sendPoll", () => { - beforeEach(async () => { - vi.resetModules(); - hoisted.sendPollWhatsApp.mockClear(); - }); - - it("threads cfg into runtime sendPollWhatsApp call", async () => { - const { cfg, poll, to, accountId } = createWhatsAppPollFixture(); - - const result = await whatsappPlugin.outbound!.sendPoll!({ - cfg, - to, - poll, - accountId, - }); - - expectWhatsAppPollSent(hoisted.sendPollWhatsApp, { cfg, poll, to, accountId }); - expect(result).toEqual({ - channel: "whatsapp", - messageId: "wa-poll-1", - toJid: "1555@s.whatsapp.net", - }); - }); -}); - -describe("whatsapp directory", () => { - const runtimeEnv = createDirectoryTestRuntime() as never; - - it("lists peers and groups from config", async () => { - const cfg = { - channels: { - whatsapp: { - authDir: "/tmp/wa-auth", - allowFrom: [ - "whatsapp:+15551230001", - "15551230002@s.whatsapp.net", - "120363999999999999@g.us", - ], - groups: { - "120363111111111111@g.us": {}, - "120363222222222222@g.us": {}, - }, - }, - }, - } as unknown as OpenClawConfig; - - const directory = expectDirectorySurface(whatsappPlugin.directory); - - await expect( - directory.listPeers({ - cfg, - accountId: undefined, - query: undefined, - limit: undefined, - runtime: runtimeEnv, - }), - ).resolves.toEqual( - expect.arrayContaining([ - { kind: "user", id: "+15551230001" }, - { kind: "user", id: "+15551230002" }, - ]), - ); - - await expect( - directory.listGroups({ - cfg, - accountId: undefined, - query: undefined, - limit: undefined, - runtime: runtimeEnv, - }), - ).resolves.toEqual( - expect.arrayContaining([ - { kind: "group", id: "120363111111111111@g.us" }, - { kind: "group", id: "120363222222222222@g.us" }, - ]), - ); - }); -}); - -describe("whatsapp group policy", () => { - it("uses generic channel group policy helpers", () => { - const cfg = { - channels: { - whatsapp: { - groups: { - "1203630@g.us": { - requireMention: false, - tools: { deny: ["exec"] }, - }, - "*": { - requireMention: true, - tools: { allow: ["message.send"] }, - }, - }, - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - expect(resolveWhatsAppGroupRequireMention({ cfg, groupId: "1203630@g.us" })).toBe(false); - expect(resolveWhatsAppGroupRequireMention({ cfg, groupId: "other@g.us" })).toBe(true); - expect(resolveWhatsAppGroupToolPolicy({ cfg, groupId: "1203630@g.us" })).toEqual({ - deny: ["exec"], - }); - expect(resolveWhatsAppGroupToolPolicy({ cfg, groupId: "other@g.us" })).toEqual({ - allow: ["message.send"], - }); - }); -}); - -describe("whatsapp agent prompt", () => { - it("defaults to minimal reaction guidance when reactions are available", () => { - const cfg = { - channels: { - whatsapp: { - allowFrom: ["*"], - }, - }, - } as OpenClawConfig; - - expect( - whatsappPlugin.agentPrompt?.reactionGuidance?.({ - cfg, - accountId: DEFAULT_ACCOUNT_ID, - }), - ).toEqual({ - level: "minimal", - channelLabel: "WhatsApp", - }); - }); - - it("omits reaction guidance when WhatsApp is not configured", () => { - expect( - whatsappPlugin.agentPrompt?.reactionGuidance?.({ - cfg: {} as OpenClawConfig, - accountId: DEFAULT_ACCOUNT_ID, - }), - ).toBeUndefined(); - }); - - it("returns minimal reaction guidance when configured", () => { - const cfg = { - channels: { - whatsapp: { - reactionLevel: "minimal", - allowFrom: ["*"], - }, - }, - } as OpenClawConfig; - - expect( - whatsappPlugin.agentPrompt?.reactionGuidance?.({ - cfg, - accountId: DEFAULT_ACCOUNT_ID, - }), - ).toEqual({ - level: "minimal", - channelLabel: "WhatsApp", - }); - }); - - it("omits reaction guidance when WhatsApp reactions are disabled", () => { - const cfg = { - channels: { - whatsapp: { - actions: { reactions: false }, - allowFrom: ["*"], - }, - }, - } as OpenClawConfig; - - expect( - whatsappPlugin.agentPrompt?.reactionGuidance?.({ - cfg, - accountId: DEFAULT_ACCOUNT_ID, - }), - ).toBeUndefined(); - }); - - it("omits reaction guidance when reactionLevel disables agent reactions", () => { - const cfg = { - channels: { - whatsapp: { - reactionLevel: "ack", - allowFrom: ["*"], - }, - }, - } as OpenClawConfig; - - expect( - whatsappPlugin.agentPrompt?.reactionGuidance?.({ - cfg, - accountId: DEFAULT_ACCOUNT_ID, - }), - ).toBeUndefined(); - }); -}); - -describe("whatsapp action discovery", () => { - it("advertises react when agent reactions are enabled", () => { - const cfg = { - channels: { - whatsapp: { - allowFrom: ["*"], - }, - }, - } as OpenClawConfig; - - expect( - whatsappPlugin.actions?.describeMessageTool?.({ - cfg, - accountId: DEFAULT_ACCOUNT_ID, - })?.actions, - ).toEqual(["react", "poll"]); - }); - - it("returns null when WhatsApp is not configured", () => { - expect( - whatsappPlugin.actions?.describeMessageTool?.({ - cfg: {} as OpenClawConfig, - accountId: DEFAULT_ACCOUNT_ID, - }), - ).toBeNull(); - }); - - it("omits react when reactionLevel disables agent reactions", () => { - const cfg = { - channels: { - whatsapp: { - reactionLevel: "ack", - allowFrom: ["*"], - }, - }, - } as OpenClawConfig; - - expect( - whatsappPlugin.actions?.describeMessageTool?.({ - cfg, - accountId: DEFAULT_ACCOUNT_ID, - })?.actions, - ).toEqual(["poll"]); - }); - - it("uses the active account reactionLevel for discovery", () => { - const cfg = { - channels: { - whatsapp: { - reactionLevel: "ack", - allowFrom: ["*"], - accounts: { - work: { - reactionLevel: "minimal", - }, - }, - }, - }, - } as OpenClawConfig; - - expect( - whatsappPlugin.actions?.describeMessageTool?.({ - cfg, - accountId: "work", - })?.actions, - ).toEqual(["react", "poll"]); - }); - - it("keeps react in global discovery when any account enables agent reactions", () => { - const cfg = { - channels: { - whatsapp: { - reactionLevel: "ack", - allowFrom: ["*"], - accounts: { - work: { - reactionLevel: "minimal", - }, - }, - }, - }, - } as OpenClawConfig; - hoisted.listWhatsAppAccountIds.mockReturnValue(["default", "work"]); - - expect( - whatsappPlugin.actions?.describeMessageTool?.({ - cfg, - })?.actions, - ).toEqual(["react", "poll"]); - }); - - it("omits react in global discovery when only disabled accounts enable agent reactions", () => { - const cfg = { - channels: { - whatsapp: { - reactionLevel: "ack", - allowFrom: ["*"], - accounts: { - work: { - enabled: false, - reactionLevel: "minimal", - }, - }, - }, - }, - } as OpenClawConfig; - hoisted.listWhatsAppAccountIds.mockReturnValue(["default", "work"]); - - expect( - whatsappPlugin.actions?.describeMessageTool?.({ - cfg, - })?.actions, - ).toEqual(["poll"]); - }); -}); - -describe("whatsappPlugin actions.handleAction react messageId resolution", () => { - const baseCfg = { - channels: { whatsapp: { actions: { reactions: true }, allowFrom: ["*"] } }, - } as OpenClawConfig; - - beforeEach(() => { - hoisted.handleWhatsAppAction.mockClear(); - hoisted.sendReactionWhatsApp.mockClear(); - }); - - it("uses explicit messageId when provided", async () => { - await whatsappPlugin.actions!.handleAction!({ - channel: "whatsapp", - action: "react", - params: { messageId: "explicit-id", emoji: "👍", to: "+1555" }, - cfg: baseCfg, - accountId: DEFAULT_ACCOUNT_ID, - }); - expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( - expect.objectContaining({ messageId: "explicit-id" }), - baseCfg, - ); - }); - - it("falls back to toolContext.currentMessageId when messageId omitted", async () => { - await whatsappPlugin.actions!.handleAction!({ - channel: "whatsapp", - action: "react", - params: { emoji: "❤️", to: "+1555" }, - cfg: baseCfg, - accountId: DEFAULT_ACCOUNT_ID, - toolContext: { - currentChannelId: "whatsapp:+1555", - currentChannelProvider: "whatsapp", - currentMessageId: "ctx-msg-42", - }, - }); - expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( - expect.objectContaining({ messageId: "ctx-msg-42" }), - baseCfg, - ); - }); - - it("converts numeric toolContext messageId to string", async () => { - await whatsappPlugin.actions!.handleAction!({ - channel: "whatsapp", - action: "react", - params: { emoji: "🎉", to: "+1555" }, - cfg: baseCfg, - accountId: DEFAULT_ACCOUNT_ID, - toolContext: { - currentChannelId: "whatsapp:+1555", - currentChannelProvider: "whatsapp", - currentMessageId: 12345, - }, - }); - expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( - expect.objectContaining({ messageId: "12345" }), - baseCfg, - ); - }); - - it("throws ToolInputError when messageId missing and no toolContext", async () => { - const err = await whatsappPlugin.actions!.handleAction!({ - channel: "whatsapp", - action: "react", - params: { emoji: "👍", to: "+1555" }, - cfg: baseCfg, - accountId: DEFAULT_ACCOUNT_ID, - }).catch((e: unknown) => e); - expect(err).toBeInstanceOf(Error); - expect((err as Error).name).toBe("ToolInputError"); - }); - - it("skips context fallback when targeting a different chat", async () => { - const err = await whatsappPlugin.actions!.handleAction!({ - channel: "whatsapp", - action: "react", - params: { emoji: "👍", to: "+9999" }, - cfg: baseCfg, - accountId: DEFAULT_ACCOUNT_ID, - toolContext: { - currentChannelId: "whatsapp:+1555", - currentChannelProvider: "whatsapp", - currentMessageId: "ctx-msg-42", - }, - }).catch((e: unknown) => e); - // Different target chat → context fallback suppressed → ToolInputError - expect(err).toBeInstanceOf(Error); - expect((err as Error).name).toBe("ToolInputError"); - }); - - it("uses context fallback when target matches current chat (prefixed)", async () => { - await whatsappPlugin.actions!.handleAction!({ - channel: "whatsapp", - action: "react", - params: { emoji: "👍", to: "+1555" }, - cfg: baseCfg, - accountId: DEFAULT_ACCOUNT_ID, - toolContext: { - currentChannelId: "whatsapp:+1555", - currentChannelProvider: "whatsapp", - currentMessageId: "ctx-msg-42", - }, - }); - expect(hoisted.handleWhatsAppAction).toHaveBeenCalledWith( - expect.objectContaining({ messageId: "ctx-msg-42" }), - baseCfg, - ); - }); - - it("skips context fallback when source is another provider", async () => { - const err = await whatsappPlugin.actions!.handleAction!({ - channel: "whatsapp", - action: "react", - params: { emoji: "👍", to: "+1555" }, - cfg: baseCfg, - accountId: DEFAULT_ACCOUNT_ID, - toolContext: { - currentChannelId: "telegram:-1003841603622", - currentChannelProvider: "telegram", - currentMessageId: "tg-msg-99", - }, - }).catch((e: unknown) => e); - expect(err).toBeInstanceOf(Error); - expect((err as Error).name).toBe("ToolInputError"); - }); - - it("skips context fallback when currentChannelId is missing with explicit target", async () => { - const err = await whatsappPlugin.actions!.handleAction!({ - channel: "whatsapp", - action: "react", - params: { emoji: "👍", to: "+1555" }, - cfg: baseCfg, - accountId: DEFAULT_ACCOUNT_ID, - toolContext: { - currentChannelProvider: "whatsapp", - currentMessageId: "ctx-msg-42", - }, - }).catch((e: unknown) => e); - // WhatsApp source but no currentChannelId to compare → fallback suppressed - expect(err).toBeInstanceOf(Error); - expect((err as Error).name).toBe("ToolInputError"); - }); -}); diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index c94e267c84c..d1a73fe6445 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -1,13 +1,10 @@ import { createRequire } from "node:module"; import { buildDmGroupAccountAllowlistAdapter } from "openclaw/plugin-sdk/allowlist-config-edit"; -import { resolveReactionMessageId } from "openclaw/plugin-sdk/channel-actions"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/core"; -import { chunkText } from "openclaw/plugin-sdk/reply-chunking"; import { createAsyncComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; -// WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/) import { listWhatsAppAccountIds, resolveWhatsAppAccount, @@ -15,6 +12,12 @@ import { } from "./accounts.js"; import { whatsappApprovalAuth } from "./approval-auth.js"; import type { WebChannelStatus } from "./auto-reply/types.js"; +import { + describeWhatsAppMessageActions, + resolveWhatsAppAgentReactionGuidance, +} from "./channel-actions.js"; +import { whatsappChannelOutbound } from "./channel-outbound.js"; +import { handleWhatsAppReactAction } from "./channel-react-action.js"; import { listWhatsAppDirectoryGroupsFromConfig, listWhatsAppDirectoryPeersFromConfig, @@ -24,20 +27,13 @@ import { resolveWhatsAppGroupToolPolicy, } from "./group-policy.js"; import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js"; -import { resolveWhatsAppReactionLevel } from "./reaction-level.js"; import { - createActionGate, - createWhatsAppOutboundBase, DEFAULT_ACCOUNT_ID, formatWhatsAppConfigAllowFromEntries, - readStringParam, resolveWhatsAppGroupIntroHint, - resolveWhatsAppOutboundTarget, resolveWhatsAppHeartbeatRecipients, resolveWhatsAppMentionStripRegexes, - type ChannelMessageActionName, type ChannelPlugin, - type OpenClawConfig, isWhatsAppGroupJid, normalizeWhatsAppTarget, } from "./runtime-api.js"; @@ -48,39 +44,19 @@ import { createWhatsAppPluginBase, loadWhatsAppChannelRuntime, whatsappSetupWizardProxy, - WHATSAPP_CHANNEL, } from "./shared.js"; import { collectWhatsAppStatusIssues } from "./status-issues.js"; -type WhatsAppSendModule = typeof import("./send.js"); -type WhatsAppActionRuntimeModule = typeof import("./action-runtime.js"); - -let whatsAppSendModulePromise: Promise | undefined; -let whatsAppActionRuntimeModulePromise: Promise | undefined; let whatsAppAgentToolsModuleCache: typeof import("./agent-tools-login.js") | null = null; const require = createRequire(import.meta.url); -async function loadWhatsAppSendModule() { - whatsAppSendModulePromise ??= import("./send.js"); - return await whatsAppSendModulePromise; -} - -async function loadWhatsAppActionRuntimeModule() { - whatsAppActionRuntimeModulePromise ??= import("./action-runtime.js"); - return await whatsAppActionRuntimeModulePromise; -} - function loadWhatsAppAgentToolsModule() { whatsAppAgentToolsModuleCache ??= require("./agent-tools-login.js") as typeof import("./agent-tools-login.js"); return whatsAppAgentToolsModuleCache; } -function normalizeWhatsAppPayloadText(text: string | undefined): string { - return (text ?? "").replace(/^(?:[ \t]*\r?\n)+/, ""); -} - function parseWhatsAppExplicitTarget(raw: string) { const normalized = normalizeWhatsAppTarget(raw); if (!normalized) { @@ -92,75 +68,12 @@ function parseWhatsAppExplicitTarget(raw: string) { }; } -function areWhatsAppAgentReactionsEnabled(params: { cfg: OpenClawConfig; accountId?: string }) { - if (!params.cfg.channels?.whatsapp) { - return false; - } - const gate = createActionGate(params.cfg.channels.whatsapp.actions); - if (!gate("reactions")) { - return false; - } - return resolveWhatsAppReactionLevel({ - cfg: params.cfg, - accountId: params.accountId, - }).agentReactionsEnabled; -} - -function hasAnyWhatsAppAccountWithAgentReactionsEnabled(cfg: OpenClawConfig) { - if (!cfg.channels?.whatsapp) { - return false; - } - return listWhatsAppAccountIds(cfg).some((accountId) => { - const account = resolveWhatsAppAccount({ cfg, accountId }); - if (!account.enabled) { - return false; - } - return areWhatsAppAgentReactionsEnabled({ - cfg, - accountId, - }); - }); -} - -function resolveWhatsAppAgentReactionGuidance(params: { cfg: OpenClawConfig; accountId?: string }) { - if (!params.cfg.channels?.whatsapp) { - return undefined; - } - const gate = createActionGate(params.cfg.channels.whatsapp.actions); - if (!gate("reactions")) { - return undefined; - } - const resolved = resolveWhatsAppReactionLevel({ - cfg: params.cfg, - accountId: params.accountId, - }); - if (!resolved.agentReactionsEnabled) { - return undefined; - } - return resolved.agentReactionGuidance; -} - export const whatsappPlugin: ChannelPlugin = createChatChannelPlugin({ pairing: { idLabel: "whatsappSenderId", }, - outbound: { - ...createWhatsAppOutboundBase({ - chunker: chunkText, - sendMessageWhatsApp: async (...args) => - await (await loadWhatsAppSendModule()).sendMessageWhatsApp(...args), - sendPollWhatsApp: async (...args) => - await (await loadWhatsAppSendModule()).sendPollWhatsApp(...args), - shouldLogVerbose: () => getWhatsAppRuntime().logging.shouldLogVerbose(), - resolveTarget: ({ to, allowFrom, mode }) => - resolveWhatsAppOutboundTarget({ to, allowFrom, mode }), - }), - normalizePayload: ({ payload }) => ({ - ...payload, - text: normalizeWhatsAppPayloadText(payload.text), - }), - }, + outbound: whatsappChannelOutbound, base: { ...createWhatsAppPluginBase({ groups: { @@ -228,81 +141,11 @@ export const whatsappPlugin: ChannelPlugin = listGroups: async (params) => listWhatsAppDirectoryGroupsFromConfig(params), }, actions: { - describeMessageTool: ({ cfg, accountId }) => { - if (!cfg.channels?.whatsapp) { - return null; - } - const gate = createActionGate(cfg.channels.whatsapp.actions); - const actions = new Set(); - const canReact = - accountId != null - ? areWhatsAppAgentReactionsEnabled({ - cfg, - accountId: accountId ?? undefined, - }) - : hasAnyWhatsAppAccountWithAgentReactionsEnabled(cfg); - if (canReact) { - actions.add("react"); - } - if (gate("polls")) { - actions.add("poll"); - } - return { actions: Array.from(actions) }; - }, + describeMessageTool: ({ cfg, accountId }) => + describeWhatsAppMessageActions({ cfg, accountId }), supportsAction: ({ action }) => action === "react", - handleAction: async ({ action, params, cfg, accountId, toolContext }) => { - if (action !== "react") { - throw new Error(`Action ${action} is not supported for provider ${WHATSAPP_CHANNEL}.`); - } - // Only fall back to the inbound message id when the current turn - // originates from WhatsApp and targets the same chat. Skip the - // fallback when the source is another provider (the message id - // would be meaningless) or when the caller routes to a different - // WhatsApp chat (the id would belong to the wrong conversation). - const isWhatsAppSource = toolContext?.currentChannelProvider === WHATSAPP_CHANNEL; - const explicitTarget = - readStringParam(params, "chatJid") ?? readStringParam(params, "to"); - const normalizedTarget = explicitTarget ? normalizeWhatsAppTarget(explicitTarget) : null; - const normalizedCurrent = - isWhatsAppSource && toolContext?.currentChannelId - ? normalizeWhatsAppTarget(toolContext.currentChannelId) - : null; - // When an explicit target is provided, require a known current chat - // to compare against. If currentChannelId is missing/unparseable, - // treat it as ineligible for fallback to avoid cross-chat leaks. - const isCrossChat = - normalizedTarget != null && - (normalizedCurrent == null || normalizedTarget !== normalizedCurrent); - const scopedContext = !isWhatsAppSource || isCrossChat ? undefined : toolContext; - const messageIdRaw = resolveReactionMessageId({ - args: params, - toolContext: scopedContext, - }); - if (messageIdRaw == null) { - // Delegate to readStringParam so the gateway maps the error to 400. - readStringParam(params, "messageId", { required: true }); - } - const messageId = String(messageIdRaw); - const emoji = readStringParam(params, "emoji", { allowEmpty: true }); - const remove = typeof params.remove === "boolean" ? params.remove : undefined; - return await ( - await loadWhatsAppActionRuntimeModule() - ).handleWhatsAppAction( - { - action: "react", - chatJid: - readStringParam(params, "chatJid") ?? - readStringParam(params, "to", { required: true }), - messageId, - emoji, - remove, - participant: readStringParam(params, "participant"), - accountId: accountId ?? undefined, - fromMe: typeof params.fromMe === "boolean" ? params.fromMe : undefined, - }, - cfg, - ); - }, + handleAction: async ({ action, params, cfg, accountId, toolContext }) => + await handleWhatsAppReactAction({ action, params, cfg, accountId, toolContext }), }, auth: { ...whatsappApprovalAuth, diff --git a/extensions/whatsapp/src/directory-config.test.ts b/extensions/whatsapp/src/directory-config.test.ts new file mode 100644 index 00000000000..f54a9b425d8 --- /dev/null +++ b/extensions/whatsapp/src/directory-config.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest"; +import { createDirectoryTestRuntime } from "../../../test/helpers/plugins/directory.ts"; +import { + listWhatsAppDirectoryGroupsFromConfig, + listWhatsAppDirectoryPeersFromConfig, +} from "./directory-config.js"; +import type { OpenClawConfig } from "./runtime-api.js"; + +describe("whatsapp directory", () => { + const runtimeEnv = createDirectoryTestRuntime() as never; + + it("lists peers and groups from config", async () => { + const cfg = { + channels: { + whatsapp: { + authDir: "/tmp/wa-auth", + allowFrom: [ + "whatsapp:+15551230001", + "15551230002@s.whatsapp.net", + "120363999999999999@g.us", + ], + groups: { + "120363111111111111@g.us": {}, + "120363222222222222@g.us": {}, + }, + }, + }, + } as unknown as OpenClawConfig; + + await expect( + listWhatsAppDirectoryPeersFromConfig({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + } as never), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "user", id: "+15551230001" }, + { kind: "user", id: "+15551230002" }, + ]), + ); + + await expect( + listWhatsAppDirectoryGroupsFromConfig({ + cfg, + accountId: undefined, + query: undefined, + limit: undefined, + runtime: runtimeEnv, + } as never), + ).resolves.toEqual( + expect.arrayContaining([ + { kind: "group", id: "120363111111111111@g.us" }, + { kind: "group", id: "120363222222222222@g.us" }, + ]), + ); + }); +}); diff --git a/extensions/whatsapp/src/group-policy.test.ts b/extensions/whatsapp/src/group-policy.test.ts new file mode 100644 index 00000000000..afe9a338782 --- /dev/null +++ b/extensions/whatsapp/src/group-policy.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + resolveWhatsAppGroupRequireMention, + resolveWhatsAppGroupToolPolicy, +} from "./group-policy.js"; +import type { OpenClawConfig } from "./runtime-api.js"; + +describe("whatsapp group policy", () => { + it("uses generic channel group policy helpers", () => { + const cfg = { + channels: { + whatsapp: { + groups: { + "1203630@g.us": { + requireMention: false, + tools: { deny: ["exec"] }, + }, + "*": { + requireMention: true, + tools: { allow: ["message.send"] }, + }, + }, + }, + }, + } as OpenClawConfig; + + expect(resolveWhatsAppGroupRequireMention({ cfg, groupId: "1203630@g.us" })).toBe(false); + expect(resolveWhatsAppGroupRequireMention({ cfg, groupId: "other@g.us" })).toBe(true); + expect(resolveWhatsAppGroupToolPolicy({ cfg, groupId: "1203630@g.us" })).toEqual({ + deny: ["exec"], + }); + expect(resolveWhatsAppGroupToolPolicy({ cfg, groupId: "other@g.us" })).toEqual({ + allow: ["message.send"], + }); + }); +}); diff --git a/extensions/whatsapp/src/heartbeat-recipients.runtime.ts b/extensions/whatsapp/src/heartbeat-recipients.runtime.ts new file mode 100644 index 00000000000..a87879802e1 --- /dev/null +++ b/extensions/whatsapp/src/heartbeat-recipients.runtime.ts @@ -0,0 +1,9 @@ +export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; +export { normalizeE164 } from "openclaw/plugin-sdk/account-resolution"; +export { readChannelAllowFromStoreSync } from "openclaw/plugin-sdk/channel-pairing"; +export { normalizeChannelId } from "openclaw/plugin-sdk/channel-targets"; +export { + loadSessionStore, + resolveStorePath, + type OpenClawConfig, +} from "openclaw/plugin-sdk/config-runtime"; diff --git a/extensions/whatsapp/src/heartbeat-recipients.test.ts b/extensions/whatsapp/src/heartbeat-recipients.test.ts index 5efd21fa2e2..e8d17ab6484 100644 --- a/extensions/whatsapp/src/heartbeat-recipients.test.ts +++ b/extensions/whatsapp/src/heartbeat-recipients.test.ts @@ -1,12 +1,27 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { resolveWhatsAppHeartbeatRecipients } from "./heartbeat-recipients.js"; import type { OpenClawConfig } from "./runtime-api.js"; const loadSessionStoreMock = vi.hoisted(() => vi.fn()); const readChannelAllowFromStoreSyncMock = vi.hoisted(() => vi.fn<() => string[]>(() => [])); -type WhatsAppRuntimeApiModule = typeof import("./runtime-api.js"); - -let resolveWhatsAppHeartbeatRecipients: WhatsAppRuntimeApiModule["resolveWhatsAppHeartbeatRecipients"]; +vi.mock("./heartbeat-recipients.runtime.js", () => ({ + DEFAULT_ACCOUNT_ID: "default", + loadSessionStore: loadSessionStoreMock, + readChannelAllowFromStoreSync: readChannelAllowFromStoreSyncMock, + resolveStorePath: vi.fn(() => "/tmp/test-sessions.json"), + normalizeChannelId: (value?: string | null) => { + const trimmed = value?.trim().toLowerCase(); + return trimmed ? (trimmed as "whatsapp") : null; + }, + normalizeE164: (value?: string | null) => { + const digits = `${value ?? ""}`.replace(/[^\d+]/g, ""); + if (!digits) { + return ""; + } + return digits.startsWith("+") ? digits : `+${digits}`; + }, +})); function makeCfg(overrides?: Partial): OpenClawConfig { return { @@ -39,36 +54,10 @@ describe("resolveWhatsAppHeartbeatRecipients", () => { setAllowFromStore(["+15550000001"]); } - beforeEach(async () => { - vi.resetModules(); + beforeEach(() => { loadSessionStoreMock.mockReset(); readChannelAllowFromStoreSyncMock.mockReset(); - vi.doMock("openclaw/plugin-sdk/config-runtime", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadSessionStore: loadSessionStoreMock, - resolveStorePath: vi.fn(() => "/tmp/test-sessions.json"), - }; - }); - vi.doMock("openclaw/plugin-sdk/channel-pairing", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - readChannelAllowFromStoreSync: readChannelAllowFromStoreSyncMock, - }; - }); - vi.doMock("openclaw/plugin-sdk/channel-targets", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - normalizeChannelId: (value?: string | null) => { - const trimmed = value?.trim().toLowerCase(); - return trimmed ? (trimmed as "whatsapp") : null; - }, - }; - }); - ({ resolveWhatsAppHeartbeatRecipients } = await import("./runtime-api.js")); + loadSessionStoreMock.mockReturnValue({}); setAllowFromStore([]); }); diff --git a/extensions/whatsapp/src/heartbeat-recipients.ts b/extensions/whatsapp/src/heartbeat-recipients.ts index f81c662eb0e..3ebc7f2441b 100644 --- a/extensions/whatsapp/src/heartbeat-recipients.ts +++ b/extensions/whatsapp/src/heartbeat-recipients.ts @@ -1,12 +1,12 @@ -import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; -import { normalizeE164 } from "openclaw/plugin-sdk/account-resolution"; -import { readChannelAllowFromStoreSync } from "openclaw/plugin-sdk/channel-pairing"; -import { normalizeChannelId } from "openclaw/plugin-sdk/channel-targets"; import { + DEFAULT_ACCOUNT_ID, loadSessionStore, + normalizeChannelId, + normalizeE164, + readChannelAllowFromStoreSync, resolveStorePath, type OpenClawConfig, -} from "openclaw/plugin-sdk/config-runtime"; +} from "./heartbeat-recipients.runtime.js"; import { resolveWhatsAppAccount } from "./accounts.js"; type HeartbeatRecipientsResult = { recipients: string[]; source: string }; diff --git a/extensions/whatsapp/src/outbound-base.test.ts b/extensions/whatsapp/src/outbound-base.test.ts new file mode 100644 index 00000000000..f04106c16db --- /dev/null +++ b/extensions/whatsapp/src/outbound-base.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createWhatsAppPollFixture, + expectWhatsAppPollSent, +} from "../../../src/test-helpers/whatsapp-outbound.js"; +import { createWhatsAppOutboundBase } from "./outbound-base.js"; + +describe("createWhatsAppOutboundBase", () => { + it("exposes the provided chunker", () => { + const outbound = createWhatsAppOutboundBase({ + chunker: (text, limit) => [text.slice(0, limit)], + sendMessageWhatsApp: vi.fn(), + sendPollWhatsApp: vi.fn(), + shouldLogVerbose: () => false, + resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }), + }); + + expect(outbound.chunker?.("alpha beta", 5)).toEqual(["alpha"]); + }); + + it("forwards mediaLocalRoots to sendMessageWhatsApp", async () => { + const sendMessageWhatsApp = vi.fn(async () => ({ + messageId: "msg-1", + toJid: "15551234567@s.whatsapp.net", + })); + const outbound = createWhatsAppOutboundBase({ + chunker: (text) => [text], + sendMessageWhatsApp, + sendPollWhatsApp: vi.fn(), + shouldLogVerbose: () => false, + resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }), + }); + const mediaLocalRoots = ["/tmp/workspace"]; + + const result = await outbound.sendMedia!({ + cfg: {} as never, + to: "whatsapp:+15551234567", + text: "photo", + mediaUrl: "/tmp/workspace/photo.png", + mediaLocalRoots, + accountId: "default", + deps: { sendWhatsApp: sendMessageWhatsApp }, + gifPlayback: false, + }); + + expect(sendMessageWhatsApp).toHaveBeenCalledWith( + "whatsapp:+15551234567", + "photo", + expect.objectContaining({ + verbose: false, + mediaUrl: "/tmp/workspace/photo.png", + mediaLocalRoots, + accountId: "default", + gifPlayback: false, + }), + ); + expect(result).toMatchObject({ channel: "whatsapp", messageId: "msg-1" }); + }); + + it("threads cfg into sendPollWhatsApp call", async () => { + const sendPollWhatsApp = vi.fn(async () => ({ + messageId: "wa-poll-1", + toJid: "1555@s.whatsapp.net", + })); + const outbound = createWhatsAppOutboundBase({ + chunker: (text) => [text], + sendMessageWhatsApp: vi.fn(), + sendPollWhatsApp, + shouldLogVerbose: () => false, + resolveTarget: ({ to }) => ({ ok: true as const, to: to ?? "" }), + }); + const { cfg, poll, to, accountId } = createWhatsAppPollFixture(); + + const result = await outbound.sendPoll!({ + cfg, + to, + poll, + accountId, + }); + + expectWhatsAppPollSent(sendPollWhatsApp, { cfg, poll, to, accountId }); + expect(result).toEqual({ + channel: "whatsapp", + messageId: "wa-poll-1", + toJid: "1555@s.whatsapp.net", + }); + }); +}); diff --git a/extensions/whatsapp/src/setup-finalize.ts b/extensions/whatsapp/src/setup-finalize.ts new file mode 100644 index 00000000000..05314877e9b --- /dev/null +++ b/extensions/whatsapp/src/setup-finalize.ts @@ -0,0 +1,390 @@ +import path from "node:path"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAllowFromEntries, + normalizeE164, + pathExists, + splitSetupEntries, + type DmPolicy, + type OpenClawConfig, +} from "openclaw/plugin-sdk/setup"; +import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; +import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; +import { resolveWhatsAppAccount, resolveWhatsAppAuthDir } from "./accounts.js"; +import { loginWeb } from "./login.js"; +import { whatsappSetupAdapter } from "./setup-core.js"; + +type SetupPrompter = Parameters>[0]["prompter"]; +type SetupRuntime = Parameters>[0]["runtime"]; +type WhatsAppConfig = NonNullable["whatsapp"]>; +type WhatsAppAccountConfig = NonNullable[string]>; + +function mergeWhatsAppConfig( + cfg: OpenClawConfig, + accountId: string, + patch: Partial, + options?: { unsetOnUndefined?: string[] }, +): OpenClawConfig { + const channelConfig: WhatsAppConfig = { ...(cfg.channels?.whatsapp ?? {}) }; + const mutableChannelConfig = channelConfig as Record; + if (accountId === DEFAULT_ACCOUNT_ID) { + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + if (options?.unsetOnUndefined?.includes(key)) { + delete mutableChannelConfig[key]; + } + continue; + } + mutableChannelConfig[key] = value; + } + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: channelConfig, + }, + }; + } + const accounts = { + ...((channelConfig.accounts as Record | undefined) ?? {}), + }; + const nextAccount: WhatsAppAccountConfig = { ...(accounts[accountId] ?? {}) }; + const mutableNextAccount = nextAccount as Record; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) { + if (options?.unsetOnUndefined?.includes(key)) { + delete mutableNextAccount[key]; + } + continue; + } + mutableNextAccount[key] = value; + } + accounts[accountId] = nextAccount as WhatsAppAccountConfig; + return { + ...cfg, + channels: { + ...cfg.channels, + whatsapp: { + ...channelConfig, + accounts, + }, + }, + }; +} + +function setWhatsAppDmPolicy( + cfg: OpenClawConfig, + accountId: string, + dmPolicy: DmPolicy, +): OpenClawConfig { + return mergeWhatsAppConfig(cfg, accountId, { dmPolicy }); +} + +function setWhatsAppAllowFrom( + cfg: OpenClawConfig, + accountId: string, + allowFrom?: string[], +): OpenClawConfig { + return mergeWhatsAppConfig(cfg, accountId, { allowFrom }, { unsetOnUndefined: ["allowFrom"] }); +} + +function setWhatsAppSelfChatMode( + cfg: OpenClawConfig, + accountId: string, + selfChatMode: boolean, +): OpenClawConfig { + return mergeWhatsAppConfig(cfg, accountId, { selfChatMode }); +} + +export async function detectWhatsAppLinked( + cfg: OpenClawConfig, + accountId: string, +): Promise { + const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); + const credsPath = path.join(authDir, "creds.json"); + return await pathExists(credsPath); +} + +async function promptWhatsAppOwnerAllowFrom(params: { + existingAllowFrom: string[]; + prompter: SetupPrompter; +}): Promise<{ normalized: string; allowFrom: string[] }> { + const { prompter, existingAllowFrom } = params; + + await prompter.note( + "We need the sender/owner number so OpenClaw can allowlist you.", + "WhatsApp number", + ); + const entry = await prompter.text({ + message: "Your personal WhatsApp number (the phone you will message from)", + placeholder: "+15555550123", + initialValue: existingAllowFrom[0], + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + const normalized = normalizeE164(raw); + if (!normalized) { + return `Invalid number: ${raw}`; + } + return undefined; + }, + }); + + const normalized = normalizeE164(String(entry).trim()); + if (!normalized) { + throw new Error("Invalid WhatsApp owner number (expected E.164 after validation)."); + } + const allowFrom = normalizeAllowFromEntries( + [...existingAllowFrom.filter((item) => item !== "*"), normalized], + normalizeE164, + ); + return { normalized, allowFrom }; +} + +async function applyWhatsAppOwnerAllowlist(params: { + cfg: OpenClawConfig; + accountId: string; + existingAllowFrom: string[]; + messageLines: string[]; + prompter: SetupPrompter; + title: string; +}): Promise { + const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({ + prompter: params.prompter, + existingAllowFrom: params.existingAllowFrom, + }); + let next = setWhatsAppSelfChatMode(params.cfg, params.accountId, true); + next = setWhatsAppDmPolicy(next, params.accountId, "allowlist"); + next = setWhatsAppAllowFrom(next, params.accountId, allowFrom); + await params.prompter.note( + [...params.messageLines, `- allowFrom includes ${normalized}`].join("\n"), + params.title, + ); + return next; +} + +function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invalidEntry?: string } { + const parts = splitSetupEntries(raw); + if (parts.length === 0) { + return { entries: [] }; + } + const entries: string[] = []; + for (const part of parts) { + if (part === "*") { + entries.push("*"); + continue; + } + const normalized = normalizeE164(part); + if (!normalized) { + return { entries: [], invalidEntry: part }; + } + entries.push(normalized); + } + return { entries: normalizeAllowFromEntries(entries, normalizeE164) }; +} + +async function promptWhatsAppDmAccess(params: { + cfg: OpenClawConfig; + accountId: string; + forceAllowFrom: boolean; + prompter: SetupPrompter; +}): Promise { + const accountId = params.accountId.trim() || DEFAULT_ACCOUNT_ID; + const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId }); + const existingPolicy = account.dmPolicy ?? "pairing"; + const existingAllowFrom = account.allowFrom ?? []; + const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; + const policyKey = + accountId === DEFAULT_ACCOUNT_ID + ? "channels.whatsapp.dmPolicy" + : `channels.whatsapp.accounts.${accountId}.dmPolicy`; + const allowFromKey = + accountId === DEFAULT_ACCOUNT_ID + ? "channels.whatsapp.allowFrom" + : `channels.whatsapp.accounts.${accountId}.allowFrom`; + + if (params.forceAllowFrom) { + return await applyWhatsAppOwnerAllowlist({ + cfg: params.cfg, + accountId, + prompter: params.prompter, + existingAllowFrom, + title: "WhatsApp allowlist", + messageLines: ["Allowlist mode enabled."], + }); + } + + await params.prompter.note( + [ + `WhatsApp direct chats are gated by \`${policyKey}\` + \`${allowFromKey}\`.`, + "- pairing (default): unknown senders get a pairing code; owner approves", + "- allowlist: unknown senders are blocked", + '- open: public inbound DMs (requires allowFrom to include "*")', + "- disabled: ignore WhatsApp DMs", + "", + `Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`, + `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, + ].join("\n"), + "WhatsApp DM access", + ); + + const phoneMode = await params.prompter.select({ + message: "WhatsApp phone setup", + options: [ + { value: "personal", label: "This is my personal phone number" }, + { value: "separate", label: "Separate phone just for OpenClaw" }, + ], + }); + + if (phoneMode === "personal") { + return await applyWhatsAppOwnerAllowlist({ + cfg: params.cfg, + accountId, + prompter: params.prompter, + existingAllowFrom, + title: "WhatsApp personal phone", + messageLines: [ + "Personal phone mode enabled.", + "- dmPolicy set to allowlist (pairing skipped)", + ], + }); + } + + const policy = (await params.prompter.select({ + message: "WhatsApp DM policy", + options: [ + { value: "pairing", label: "Pairing (recommended)" }, + { value: "allowlist", label: "Allowlist only (block unknown senders)" }, + { value: "open", label: "Open (public inbound DMs)" }, + { value: "disabled", label: "Disabled (ignore WhatsApp DMs)" }, + ], + })) as DmPolicy; + + let next = setWhatsAppSelfChatMode(params.cfg, accountId, false); + next = setWhatsAppDmPolicy(next, accountId, policy); + if (policy === "open") { + const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164); + next = setWhatsAppAllowFrom(next, accountId, allowFrom.length > 0 ? allowFrom : ["*"]); + return next; + } + if (policy === "disabled") { + return next; + } + + const allowOptions = + existingAllowFrom.length > 0 + ? ([ + { value: "keep", label: "Keep current allowFrom" }, + { + value: "unset", + label: "Unset allowFrom (use pairing approvals only)", + }, + { value: "list", label: "Set allowFrom to specific numbers" }, + ] as const) + : ([ + { value: "unset", label: "Unset allowFrom (default)" }, + { value: "list", label: "Set allowFrom to specific numbers" }, + ] as const); + + const mode = await params.prompter.select({ + message: "WhatsApp allowFrom (optional pre-allowlist)", + options: allowOptions.map((opt) => ({ + value: opt.value, + label: opt.label, + })), + }); + + if (mode === "keep") { + return next; + } + if (mode === "unset") { + return setWhatsAppAllowFrom(next, accountId, undefined); + } + + const allowRaw = await params.prompter.text({ + message: "Allowed sender numbers (comma-separated, E.164)", + placeholder: "+15555550123, +447700900123", + validate: (value) => { + const raw = String(value ?? "").trim(); + if (!raw) { + return "Required"; + } + const parsed = parseWhatsAppAllowFromEntries(raw); + if (parsed.entries.length === 0 && !parsed.invalidEntry) { + return "Required"; + } + if (parsed.invalidEntry) { + return `Invalid number: ${parsed.invalidEntry}`; + } + return undefined; + }, + }); + + const parsed = parseWhatsAppAllowFromEntries(String(allowRaw)); + return setWhatsAppAllowFrom(next, accountId, parsed.entries); +} + +export async function finalizeWhatsAppSetup(params: { + cfg: OpenClawConfig; + accountId: string; + forceAllowFrom: boolean; + prompter: SetupPrompter; + runtime: SetupRuntime; +}) { + let next = + params.accountId === DEFAULT_ACCOUNT_ID + ? params.cfg + : whatsappSetupAdapter.applyAccountConfig({ + cfg: params.cfg, + accountId: params.accountId, + input: {}, + }); + + const linked = await detectWhatsAppLinked(next, params.accountId); + const { authDir } = resolveWhatsAppAuthDir({ + cfg: next, + accountId: params.accountId, + }); + + if (!linked) { + await params.prompter.note( + [ + "Scan the QR with WhatsApp on your phone.", + `Credentials are stored under ${authDir}/ for future runs.`, + `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, + ].join("\n"), + "WhatsApp linking", + ); + } + + const wantsLink = await params.prompter.confirm({ + message: linked ? "WhatsApp already linked. Re-link now?" : "Link WhatsApp now (QR)?", + initialValue: !linked, + }); + if (wantsLink) { + try { + await loginWeb(false, undefined, params.runtime, params.accountId); + } catch (error) { + params.runtime.error(`WhatsApp login failed: ${String(error)}`); + await params.prompter.note( + `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, + "WhatsApp help", + ); + } + } else if (!linked) { + await params.prompter.note( + `Run \`${formatCliCommand("openclaw channels login")}\` later to link WhatsApp.`, + "WhatsApp", + ); + } + + next = await promptWhatsAppDmAccess({ + cfg: next, + accountId: params.accountId, + forceAllowFrom: params.forceAllowFrom, + prompter: params.prompter, + }); + return { cfg: next }; +} diff --git a/extensions/whatsapp/src/setup-surface.ts b/extensions/whatsapp/src/setup-surface.ts index 142d88819a7..4687b179d63 100644 --- a/extensions/whatsapp/src/setup-surface.ts +++ b/extensions/whatsapp/src/setup-surface.ts @@ -1,332 +1,14 @@ -import path from "node:path"; import { DEFAULT_ACCOUNT_ID, - normalizeAccountId, - normalizeAllowFromEntries, - normalizeE164, - pathExists, - splitSetupEntries, setSetupChannelEnabled, - type DmPolicy, type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; -import { formatCliCommand, formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; -import { - listWhatsAppAccountIds, - resolveWhatsAppAccount, - resolveWhatsAppAuthDir, -} from "./accounts.js"; -import { loginWeb } from "./login.js"; -import { whatsappSetupAdapter } from "./setup-core.js"; +import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; +import { listWhatsAppAccountIds } from "./accounts.js"; +import { detectWhatsAppLinked, finalizeWhatsAppSetup } from "./setup-finalize.js"; const channel = "whatsapp" as const; -type WhatsAppConfig = NonNullable["whatsapp"]>; -type WhatsAppAccountConfig = NonNullable[string]>; - -function mergeWhatsAppConfig( - cfg: OpenClawConfig, - accountId: string, - patch: Partial, - options?: { unsetOnUndefined?: string[] }, -): OpenClawConfig { - const channelConfig: WhatsAppConfig = { ...(cfg.channels?.whatsapp ?? {}) }; - const mutableChannelConfig = channelConfig as Record; - if (accountId === DEFAULT_ACCOUNT_ID) { - for (const [key, value] of Object.entries(patch)) { - if (value === undefined) { - if (options?.unsetOnUndefined?.includes(key)) { - delete mutableChannelConfig[key]; - } - continue; - } - mutableChannelConfig[key] = value; - } - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: channelConfig, - }, - }; - } - const accounts = { - ...((channelConfig.accounts as Record | undefined) ?? {}), - }; - const nextAccount: WhatsAppAccountConfig = { ...(accounts[accountId] ?? {}) }; - const mutableNextAccount = nextAccount as Record; - for (const [key, value] of Object.entries(patch)) { - if (value === undefined) { - if (options?.unsetOnUndefined?.includes(key)) { - delete mutableNextAccount[key]; - } - continue; - } - mutableNextAccount[key] = value; - } - accounts[accountId] = nextAccount as WhatsAppAccountConfig; - return { - ...cfg, - channels: { - ...cfg.channels, - whatsapp: { - ...channelConfig, - accounts, - }, - }, - }; -} - -function setWhatsAppDmPolicy( - cfg: OpenClawConfig, - accountId: string, - dmPolicy: DmPolicy, -): OpenClawConfig { - return mergeWhatsAppConfig(cfg, accountId, { dmPolicy }); -} - -function setWhatsAppAllowFrom( - cfg: OpenClawConfig, - accountId: string, - allowFrom?: string[], -): OpenClawConfig { - return mergeWhatsAppConfig(cfg, accountId, { allowFrom }, { unsetOnUndefined: ["allowFrom"] }); -} - -function setWhatsAppSelfChatMode( - cfg: OpenClawConfig, - accountId: string, - selfChatMode: boolean, -): OpenClawConfig { - return mergeWhatsAppConfig(cfg, accountId, { selfChatMode }); -} - -async function detectWhatsAppLinked(cfg: OpenClawConfig, accountId: string): Promise { - const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId }); - const credsPath = path.join(authDir, "creds.json"); - return await pathExists(credsPath); -} - -async function promptWhatsAppOwnerAllowFrom(params: { - existingAllowFrom: string[]; - prompter: Parameters>[0]["prompter"]; -}): Promise<{ normalized: string; allowFrom: string[] }> { - const { prompter, existingAllowFrom } = params; - - await prompter.note( - "We need the sender/owner number so OpenClaw can allowlist you.", - "WhatsApp number", - ); - const entry = await prompter.text({ - message: "Your personal WhatsApp number (the phone you will message from)", - placeholder: "+15555550123", - initialValue: existingAllowFrom[0], - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - const normalized = normalizeE164(raw); - if (!normalized) { - return `Invalid number: ${raw}`; - } - return undefined; - }, - }); - - const normalized = normalizeE164(String(entry).trim()); - if (!normalized) { - throw new Error("Invalid WhatsApp owner number (expected E.164 after validation)."); - } - const allowFrom = normalizeAllowFromEntries( - [...existingAllowFrom.filter((item) => item !== "*"), normalized], - normalizeE164, - ); - return { normalized, allowFrom }; -} - -async function applyWhatsAppOwnerAllowlist(params: { - cfg: OpenClawConfig; - accountId: string; - existingAllowFrom: string[]; - messageLines: string[]; - prompter: Parameters>[0]["prompter"]; - title: string; -}): Promise { - const { normalized, allowFrom } = await promptWhatsAppOwnerAllowFrom({ - prompter: params.prompter, - existingAllowFrom: params.existingAllowFrom, - }); - let next = setWhatsAppSelfChatMode(params.cfg, params.accountId, true); - next = setWhatsAppDmPolicy(next, params.accountId, "allowlist"); - next = setWhatsAppAllowFrom(next, params.accountId, allowFrom); - await params.prompter.note( - [...params.messageLines, `- allowFrom includes ${normalized}`].join("\n"), - params.title, - ); - return next; -} - -function parseWhatsAppAllowFromEntries(raw: string): { entries: string[]; invalidEntry?: string } { - const parts = splitSetupEntries(raw); - if (parts.length === 0) { - return { entries: [] }; - } - const entries: string[] = []; - for (const part of parts) { - if (part === "*") { - entries.push("*"); - continue; - } - const normalized = normalizeE164(part); - if (!normalized) { - return { entries: [], invalidEntry: part }; - } - entries.push(normalized); - } - return { entries: normalizeAllowFromEntries(entries, normalizeE164) }; -} - -async function promptWhatsAppDmAccess(params: { - cfg: OpenClawConfig; - accountId: string; - forceAllowFrom: boolean; - prompter: Parameters>[0]["prompter"]; -}): Promise { - const accountId = normalizeAccountId(params.accountId); - const account = resolveWhatsAppAccount({ cfg: params.cfg, accountId }); - const existingPolicy = account.dmPolicy ?? "pairing"; - const existingAllowFrom = account.allowFrom ?? []; - const existingLabel = existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset"; - const policyKey = - accountId === DEFAULT_ACCOUNT_ID - ? "channels.whatsapp.dmPolicy" - : `channels.whatsapp.accounts.${accountId}.dmPolicy`; - const allowFromKey = - accountId === DEFAULT_ACCOUNT_ID - ? "channels.whatsapp.allowFrom" - : `channels.whatsapp.accounts.${accountId}.allowFrom`; - - if (params.forceAllowFrom) { - return await applyWhatsAppOwnerAllowlist({ - cfg: params.cfg, - accountId, - prompter: params.prompter, - existingAllowFrom, - title: "WhatsApp allowlist", - messageLines: ["Allowlist mode enabled."], - }); - } - - await params.prompter.note( - [ - `WhatsApp direct chats are gated by \`${policyKey}\` + \`${allowFromKey}\`.`, - "- pairing (default): unknown senders get a pairing code; owner approves", - "- allowlist: unknown senders are blocked", - '- open: public inbound DMs (requires allowFrom to include "*")', - "- disabled: ignore WhatsApp DMs", - "", - `Current: dmPolicy=${existingPolicy}, allowFrom=${existingLabel}`, - `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, - ].join("\n"), - "WhatsApp DM access", - ); - - const phoneMode = await params.prompter.select({ - message: "WhatsApp phone setup", - options: [ - { value: "personal", label: "This is my personal phone number" }, - { value: "separate", label: "Separate phone just for OpenClaw" }, - ], - }); - - if (phoneMode === "personal") { - return await applyWhatsAppOwnerAllowlist({ - cfg: params.cfg, - accountId, - prompter: params.prompter, - existingAllowFrom, - title: "WhatsApp personal phone", - messageLines: [ - "Personal phone mode enabled.", - "- dmPolicy set to allowlist (pairing skipped)", - ], - }); - } - - const policy = (await params.prompter.select({ - message: "WhatsApp DM policy", - options: [ - { value: "pairing", label: "Pairing (recommended)" }, - { value: "allowlist", label: "Allowlist only (block unknown senders)" }, - { value: "open", label: "Open (public inbound DMs)" }, - { value: "disabled", label: "Disabled (ignore WhatsApp DMs)" }, - ], - })) as DmPolicy; - - let next = setWhatsAppSelfChatMode(params.cfg, accountId, false); - next = setWhatsAppDmPolicy(next, accountId, policy); - if (policy === "open") { - const allowFrom = normalizeAllowFromEntries(["*", ...existingAllowFrom], normalizeE164); - next = setWhatsAppAllowFrom(next, accountId, allowFrom.length > 0 ? allowFrom : ["*"]); - return next; - } - if (policy === "disabled") { - return next; - } - - const allowOptions = - existingAllowFrom.length > 0 - ? ([ - { value: "keep", label: "Keep current allowFrom" }, - { - value: "unset", - label: "Unset allowFrom (use pairing approvals only)", - }, - { value: "list", label: "Set allowFrom to specific numbers" }, - ] as const) - : ([ - { value: "unset", label: "Unset allowFrom (default)" }, - { value: "list", label: "Set allowFrom to specific numbers" }, - ] as const); - - const mode = await params.prompter.select({ - message: "WhatsApp allowFrom (optional pre-allowlist)", - options: allowOptions.map((opt) => ({ - value: opt.value, - label: opt.label, - })), - }); - - if (mode === "keep") { - return next; - } - if (mode === "unset") { - return setWhatsAppAllowFrom(next, accountId, undefined); - } - - const allowRaw = await params.prompter.text({ - message: "Allowed sender numbers (comma-separated, E.164)", - placeholder: "+15555550123, +447700900123", - validate: (value) => { - const raw = String(value ?? "").trim(); - if (!raw) { - return "Required"; - } - const parsed = parseWhatsAppAllowFromEntries(raw); - if (parsed.entries.length === 0 && !parsed.invalidEntry) { - return "Required"; - } - if (parsed.invalidEntry) { - return `Invalid number: ${parsed.invalidEntry}`; - } - return undefined; - }, - }); - - const parsed = parseWhatsAppAllowFromEntries(String(allowRaw)); - return setWhatsAppAllowFrom(next, accountId, parsed.entries); -} export const whatsappSetupWizard: ChannelSetupWizard = { channel, @@ -363,59 +45,8 @@ export const whatsappSetupWizard: ChannelSetupWizard = { resolveShouldPromptAccountIds: ({ options, shouldPromptAccountIds }) => Boolean(shouldPromptAccountIds || options?.promptWhatsAppAccountId), credentials: [], - finalize: async ({ cfg, accountId, forceAllowFrom, prompter, runtime }) => { - let next = - accountId === DEFAULT_ACCOUNT_ID - ? cfg - : whatsappSetupAdapter.applyAccountConfig({ - cfg, - accountId, - input: {}, - }); - - const linked = await detectWhatsAppLinked(next, accountId); - const { authDir } = resolveWhatsAppAuthDir({ - cfg: next, - accountId, - }); - - if (!linked) { - await prompter.note( - [ - "Scan the QR with WhatsApp on your phone.", - `Credentials are stored under ${authDir}/ for future runs.`, - `Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, - ].join("\n"), - "WhatsApp linking", - ); - } - - const wantsLink = await prompter.confirm({ - message: linked ? "WhatsApp already linked. Re-link now?" : "Link WhatsApp now (QR)?", - initialValue: !linked, - }); - if (wantsLink) { - try { - await loginWeb(false, undefined, runtime, accountId); - } catch (error) { - runtime.error(`WhatsApp login failed: ${String(error)}`); - await prompter.note(`Docs: ${formatDocsLink("/whatsapp", "whatsapp")}`, "WhatsApp help"); - } - } else if (!linked) { - await prompter.note( - `Run \`${formatCliCommand("openclaw channels login")}\` later to link WhatsApp.`, - "WhatsApp", - ); - } - - next = await promptWhatsAppDmAccess({ - cfg: next, - accountId, - forceAllowFrom, - prompter, - }); - return { cfg: next }; - }, + finalize: async ({ cfg, accountId, forceAllowFrom, prompter, runtime }) => + await finalizeWhatsAppSetup({ cfg, accountId, forceAllowFrom, prompter, runtime }), disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), onAccountRecorded: (accountId, options) => { options?.onWhatsAppAccountId?.(accountId);