test: split whatsapp channel coverage

This commit is contained in:
Peter Steinberger 2026-04-03 17:26:12 +01:00
parent d21ae7173f
commit 6686ef0b3a
No known key found for this signature in database
18 changed files with 1462 additions and 1165 deletions

View File

@ -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";

View File

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

View File

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

View File

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

View File

@ -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";

View File

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

View File

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

View File

@ -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<OpenClawConfig["channels"]>] 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<typeof createQueuedWizardPrompter>;
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",
);
});
});

View File

@ -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<typeof import("./send.js")>("./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<typeof import("./accounts.js")>("./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");
});
});

View File

@ -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<WhatsAppSendModule> | undefined;
let whatsAppActionRuntimeModulePromise: Promise<WhatsAppActionRuntimeModule> | 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<ResolvedWhatsAppAccount> =
createChatChannelPlugin<ResolvedWhatsAppAccount>({
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<ResolvedWhatsAppAccount> =
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<ChannelMessageActionName>();
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,

View File

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

View File

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

View File

@ -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";

View File

@ -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>): 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<typeof import("openclaw/plugin-sdk/config-runtime")>();
return {
...actual,
loadSessionStore: loadSessionStoreMock,
resolveStorePath: vi.fn(() => "/tmp/test-sessions.json"),
};
});
vi.doMock("openclaw/plugin-sdk/channel-pairing", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-pairing")>();
return {
...actual,
readChannelAllowFromStoreSync: readChannelAllowFromStoreSyncMock,
};
});
vi.doMock("openclaw/plugin-sdk/channel-targets", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/channel-targets")>();
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([]);
});

View File

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

View File

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

View File

@ -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<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
type SetupRuntime = Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[0]["runtime"];
type WhatsAppConfig = NonNullable<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>;
type WhatsAppAccountConfig = NonNullable<NonNullable<WhatsAppConfig["accounts"]>[string]>;
function mergeWhatsAppConfig(
cfg: OpenClawConfig,
accountId: string,
patch: Partial<WhatsAppAccountConfig>,
options?: { unsetOnUndefined?: string[] },
): OpenClawConfig {
const channelConfig: WhatsAppConfig = { ...(cfg.channels?.whatsapp ?? {}) };
const mutableChannelConfig = channelConfig as Record<string, unknown>;
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<string, WhatsAppAccountConfig> | undefined) ?? {}),
};
const nextAccount: WhatsAppAccountConfig = { ...(accounts[accountId] ?? {}) };
const mutableNextAccount = nextAccount as Record<string, unknown>;
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<boolean> {
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<OpenClawConfig> {
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<OpenClawConfig> {
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 };
}

View File

@ -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<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>;
type WhatsAppAccountConfig = NonNullable<NonNullable<WhatsAppConfig["accounts"]>[string]>;
function mergeWhatsAppConfig(
cfg: OpenClawConfig,
accountId: string,
patch: Partial<WhatsAppAccountConfig>,
options?: { unsetOnUndefined?: string[] },
): OpenClawConfig {
const channelConfig: WhatsAppConfig = { ...(cfg.channels?.whatsapp ?? {}) };
const mutableChannelConfig = channelConfig as Record<string, unknown>;
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<string, WhatsAppAccountConfig> | undefined) ?? {}),
};
const nextAccount: WhatsAppAccountConfig = { ...(accounts[accountId] ?? {}) };
const mutableNextAccount = nextAccount as Record<string, unknown>;
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<boolean> {
const { authDir } = resolveWhatsAppAuthDir({ cfg, accountId });
const credsPath = path.join(authDir, "creds.json");
return await pathExists(credsPath);
}
async function promptWhatsAppOwnerAllowFrom(params: {
existingAllowFrom: string[];
prompter: Parameters<NonNullable<ChannelSetupWizard["finalize"]>>[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<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
title: string;
}): Promise<OpenClawConfig> {
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<NonNullable<ChannelSetupWizard["finalize"]>>[0]["prompter"];
}): Promise<OpenClawConfig> {
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);