mirror of https://github.com/openclaw/openclaw.git
test: split whatsapp channel coverage
This commit is contained in:
parent
d21ae7173f
commit
6686ef0b3a
|
|
@ -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";
|
||||
|
|
@ -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"]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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) };
|
||||
}
|
||||
|
|
@ -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),
|
||||
}),
|
||||
};
|
||||
|
|
@ -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";
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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";
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue