mirror of https://github.com/openclaw/openclaw.git
feat(whatsapp): add reaction guidance levels (#58622)
* WhatsApp: add reaction guidance policy * WhatsApp: expose reaction guidance to agents
This commit is contained in:
parent
21403a3898
commit
ac6db066d3
|
|
@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
|
|||
|
||||
- Agents/compaction: resolve `agents.defaults.compaction.model` consistently for manual `/compact` and other context-engine compaction paths, so engine-owned compaction uses the configured override model across runtime entrypoints. (#56710) Thanks @oliviareid-svg
|
||||
- Channels/session routing: move provider-specific session conversation grammar into plugin-owned session-key surfaces, preserving Telegram topic routing and Feishu scoped inheritance across bootstrap, model override, restart, and tool-policy paths.
|
||||
- WhatsApp/reactions: add `reactionLevel` guidance for agent reactions. Thanks @mcaxtr.
|
||||
|
||||
### Fixes
|
||||
|
||||
|
|
|
|||
|
|
@ -37810,6 +37810,22 @@
|
|||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.whatsapp.accounts.*.reactionLevel",
|
||||
"kind": "channel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"enumValues": [
|
||||
"off",
|
||||
"ack",
|
||||
"minimal",
|
||||
"extensive"
|
||||
],
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.whatsapp.accounts.*.responsePrefix",
|
||||
"kind": "channel",
|
||||
|
|
@ -38499,6 +38515,22 @@
|
|||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.whatsapp.reactionLevel",
|
||||
"kind": "channel",
|
||||
"type": "string",
|
||||
"required": false,
|
||||
"enumValues": [
|
||||
"off",
|
||||
"ack",
|
||||
"minimal",
|
||||
"extensive"
|
||||
],
|
||||
"deprecated": false,
|
||||
"sensitive": false,
|
||||
"tags": [],
|
||||
"hasChildren": false
|
||||
},
|
||||
{
|
||||
"path": "channels.whatsapp.responsePrefix",
|
||||
"kind": "channel",
|
||||
|
|
|
|||
|
|
@ -3394,6 +3394,7 @@
|
|||
{"recordType":"path","path":"channels.whatsapp.accounts.*.mediaMaxMb","kind":"channel","type":"integer","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.messagePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.name","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.reactionLevel","kind":"channel","type":"string","required":false,"enumValues":["off","ack","minimal","extensive"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.selfChatMode","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.accounts.*.sendReadReceipts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
|
|
@ -3458,6 +3459,7 @@
|
|||
{"recordType":"path","path":"channels.whatsapp.markdown.tables","kind":"channel","type":"string","required":false,"enumValues":["off","bullets","code","block"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.mediaMaxMb","kind":"channel","type":"integer","required":true,"defaultValue":50,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.messagePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.reactionLevel","kind":"channel","type":"string","required":false,"enumValues":["off","ack","minimal","extensive"],"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.responsePrefix","kind":"channel","type":"string","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.selfChatMode","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":["channels","network"],"label":"WhatsApp Self-Phone Mode","help":"Same-phone setup (bot uses your personal WhatsApp number).","hasChildren":false}
|
||||
{"recordType":"path","path":"channels.whatsapp.sendReadReceipts","kind":"channel","type":"boolean","required":false,"deprecated":false,"sensitive":false,"tags":[],"hasChildren":false}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export type ResolvedWhatsAppAccount = {
|
|||
mediaMaxMb?: number;
|
||||
blockStreaming?: boolean;
|
||||
ackReaction?: WhatsAppAccountConfig["ackReaction"];
|
||||
reactionLevel?: WhatsAppAccountConfig["reactionLevel"];
|
||||
groups?: WhatsAppAccountConfig["groups"];
|
||||
debounceMs?: number;
|
||||
};
|
||||
|
|
@ -154,6 +155,7 @@ export function resolveWhatsAppAccount(params: {
|
|||
mediaMaxMb: merged.mediaMaxMb,
|
||||
blockStreaming: merged.blockStreaming,
|
||||
ackReaction: merged.ackReaction,
|
||||
reactionLevel: merged.reactionLevel,
|
||||
groups: merged.groups,
|
||||
debounceMs: merged.debounceMs,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,12 @@ const enabledConfig = {
|
|||
} as OpenClawConfig;
|
||||
|
||||
describe("handleWhatsAppAction", () => {
|
||||
function reactionConfig(reactionLevel: "minimal" | "extensive" | "off" | "ack"): OpenClawConfig {
|
||||
return {
|
||||
channels: { whatsapp: { actions: { reactions: true }, reactionLevel } },
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
Object.assign(whatsAppActionRuntime, originalWhatsAppActionRuntime, {
|
||||
|
|
@ -36,6 +42,42 @@ describe("handleWhatsAppAction", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it("adds reactions when reactionLevel is minimal", async () => {
|
||||
await handleWhatsAppAction(
|
||||
{
|
||||
action: "react",
|
||||
chatJid: "123@s.whatsapp.net",
|
||||
messageId: "msg1",
|
||||
emoji: "✅",
|
||||
},
|
||||
reactionConfig("minimal"),
|
||||
);
|
||||
expect(sendReactionWhatsApp).toHaveBeenLastCalledWith("+123", "msg1", "✅", {
|
||||
verbose: false,
|
||||
fromMe: undefined,
|
||||
participant: undefined,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("adds reactions when reactionLevel is extensive", async () => {
|
||||
await handleWhatsAppAction(
|
||||
{
|
||||
action: "react",
|
||||
chatJid: "123@s.whatsapp.net",
|
||||
messageId: "msg1",
|
||||
emoji: "✅",
|
||||
},
|
||||
reactionConfig("extensive"),
|
||||
);
|
||||
expect(sendReactionWhatsApp).toHaveBeenLastCalledWith("+123", "msg1", "✅", {
|
||||
verbose: false,
|
||||
fromMe: undefined,
|
||||
participant: undefined,
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("removes reactions on empty emoji", async () => {
|
||||
await handleWhatsAppAction(
|
||||
{
|
||||
|
|
@ -111,6 +153,59 @@ describe("handleWhatsAppAction", () => {
|
|||
).rejects.toThrow(/WhatsApp reactions are disabled/);
|
||||
});
|
||||
|
||||
it("disables reactions when WhatsApp is not configured", async () => {
|
||||
await expect(
|
||||
handleWhatsAppAction(
|
||||
{
|
||||
action: "react",
|
||||
chatJid: "123@s.whatsapp.net",
|
||||
messageId: "msg1",
|
||||
emoji: "✅",
|
||||
},
|
||||
{} as OpenClawConfig,
|
||||
),
|
||||
).rejects.toThrow(/WhatsApp reactions are disabled/);
|
||||
});
|
||||
|
||||
it("prefers the action gate error when both actions.reactions and reactionLevel disable reactions", async () => {
|
||||
const cfg = {
|
||||
channels: { whatsapp: { actions: { reactions: false }, reactionLevel: "ack" } },
|
||||
} as OpenClawConfig;
|
||||
|
||||
await expect(
|
||||
handleWhatsAppAction(
|
||||
{
|
||||
action: "react",
|
||||
chatJid: "123@s.whatsapp.net",
|
||||
messageId: "msg1",
|
||||
emoji: "✅",
|
||||
},
|
||||
cfg,
|
||||
),
|
||||
).rejects.toThrow(/WhatsApp reactions are disabled/);
|
||||
expect(sendReactionWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["off", "ack"] as const)(
|
||||
"blocks agent reactions when reactionLevel is %s",
|
||||
async (reactionLevel) => {
|
||||
await expect(
|
||||
handleWhatsAppAction(
|
||||
{
|
||||
action: "react",
|
||||
chatJid: "123@s.whatsapp.net",
|
||||
messageId: "msg1",
|
||||
emoji: "✅",
|
||||
},
|
||||
reactionConfig(reactionLevel),
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new RegExp(`WhatsApp agent reactions disabled \\(reactionLevel=\"${reactionLevel}\"\\)`),
|
||||
);
|
||||
expect(sendReactionWhatsApp).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("applies default account allowFrom when accountId is omitted", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
|
||||
import { resolveAuthorizedWhatsAppOutboundTarget } from "./action-runtime-target-auth.js";
|
||||
import { resolveWhatsAppReactionLevel } from "./reaction-level.js";
|
||||
import {
|
||||
createActionGate,
|
||||
jsonResult,
|
||||
|
|
@ -19,19 +20,33 @@ export async function handleWhatsAppAction(
|
|||
cfg: OpenClawConfig,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const isActionEnabled = createActionGate(cfg.channels?.whatsapp?.actions);
|
||||
const whatsAppConfig = cfg.channels?.whatsapp;
|
||||
const isActionEnabled = createActionGate(whatsAppConfig?.actions);
|
||||
|
||||
if (action === "react") {
|
||||
const accountId = readStringParam(params, "accountId");
|
||||
if (!whatsAppConfig) {
|
||||
throw new Error("WhatsApp reactions are disabled.");
|
||||
}
|
||||
if (!isActionEnabled("reactions")) {
|
||||
throw new Error("WhatsApp reactions are disabled.");
|
||||
}
|
||||
const reactionLevelInfo = resolveWhatsAppReactionLevel({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
if (!reactionLevelInfo.agentReactionsEnabled) {
|
||||
throw new Error(
|
||||
`WhatsApp agent reactions disabled (reactionLevel="${reactionLevelInfo.level}"). ` +
|
||||
`Set channels.whatsapp.reactionLevel to "minimal" or "extensive" to enable.`,
|
||||
);
|
||||
}
|
||||
const chatJid = readStringParam(params, "chatJid", { required: true });
|
||||
const messageId = readStringParam(params, "messageId", { required: true });
|
||||
const { emoji, remove, isEmpty } = readReactionParams(params, {
|
||||
removeErrorMessage: "Emoji is required to remove a WhatsApp reaction.",
|
||||
});
|
||||
const participant = readStringParam(params, "participant");
|
||||
const accountId = readStringParam(params, "accountId");
|
||||
const fromMeRaw = params.fromMe;
|
||||
const fromMe = typeof fromMeRaw === "boolean" ? fromMeRaw : undefined;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WebInboundMessage } from "../../inbound/types.js";
|
||||
import { maybeSendAckReaction } from "./ack-reaction.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
sendReactionWhatsApp: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../../send.js", () => ({
|
||||
sendReactionWhatsApp: hoisted.sendReactionWhatsApp,
|
||||
}));
|
||||
|
||||
function createMessage(overrides: Partial<WebInboundMessage> = {}): WebInboundMessage {
|
||||
return {
|
||||
id: "msg-1",
|
||||
from: "15551234567",
|
||||
conversationId: "15551234567",
|
||||
to: "15559876543",
|
||||
accountId: "default",
|
||||
body: "hello",
|
||||
chatType: "direct",
|
||||
chatId: "15551234567@s.whatsapp.net",
|
||||
sendComposing: async () => {},
|
||||
reply: async () => {},
|
||||
sendMedia: async () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createConfig(
|
||||
reactionLevel: "off" | "ack" | "minimal" | "extensive",
|
||||
extras?: Partial<NonNullable<OpenClawConfig["channels"]>["whatsapp"]>,
|
||||
): OpenClawConfig {
|
||||
return {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
reactionLevel,
|
||||
ackReaction: {
|
||||
emoji: "👀",
|
||||
direct: true,
|
||||
group: "mentions",
|
||||
},
|
||||
...extras,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
}
|
||||
|
||||
describe("maybeSendAckReaction", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each(["ack", "minimal", "extensive"] as const)(
|
||||
"sends ack reactions when reactionLevel is %s",
|
||||
(reactionLevel) => {
|
||||
maybeSendAckReaction({
|
||||
cfg: createConfig(reactionLevel),
|
||||
msg: createMessage(),
|
||||
agentId: "agent",
|
||||
sessionKey: "whatsapp:default:15551234567",
|
||||
conversationId: "15551234567",
|
||||
verbose: false,
|
||||
accountId: "default",
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
});
|
||||
|
||||
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
|
||||
"15551234567@s.whatsapp.net",
|
||||
"msg-1",
|
||||
"👀",
|
||||
{
|
||||
verbose: false,
|
||||
fromMe: false,
|
||||
participant: undefined,
|
||||
accountId: "default",
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("suppresses ack reactions when reactionLevel is off", () => {
|
||||
maybeSendAckReaction({
|
||||
cfg: createConfig("off"),
|
||||
msg: createMessage(),
|
||||
agentId: "agent",
|
||||
sessionKey: "whatsapp:default:15551234567",
|
||||
conversationId: "15551234567",
|
||||
verbose: false,
|
||||
accountId: "default",
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
});
|
||||
|
||||
expect(hoisted.sendReactionWhatsApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the active account reactionLevel override for ack gating", () => {
|
||||
maybeSendAckReaction({
|
||||
cfg: createConfig("off", {
|
||||
accounts: {
|
||||
work: {
|
||||
reactionLevel: "ack",
|
||||
},
|
||||
},
|
||||
}),
|
||||
msg: createMessage({
|
||||
accountId: "work",
|
||||
}),
|
||||
agentId: "agent",
|
||||
sessionKey: "whatsapp:work:15551234567",
|
||||
conversationId: "15551234567",
|
||||
verbose: false,
|
||||
accountId: "work",
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
});
|
||||
|
||||
expect(hoisted.sendReactionWhatsApp).toHaveBeenCalledWith(
|
||||
"15551234567@s.whatsapp.net",
|
||||
"msg-1",
|
||||
"👀",
|
||||
{
|
||||
verbose: false,
|
||||
fromMe: false,
|
||||
participant: undefined,
|
||||
accountId: "work",
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { shouldAckReactionForWhatsApp } from "openclaw/plugin-sdk/channel-feedba
|
|||
import type { loadConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { getSenderIdentity } from "../../identity.js";
|
||||
import { resolveWhatsAppReactionLevel } from "../../reaction-level.js";
|
||||
import { sendReactionWhatsApp } from "../../send.js";
|
||||
import { formatError } from "../../session.js";
|
||||
import type { WebInboundMsg } from "../types.js";
|
||||
|
|
@ -22,6 +23,16 @@ export function maybeSendAckReaction(params: {
|
|||
return;
|
||||
}
|
||||
|
||||
// Keep ackReaction as the emoji/scope control, while letting reactionLevel
|
||||
// suppress all automatic reactions when it is explicitly set to "off".
|
||||
const reactionLevel = resolveWhatsAppReactionLevel({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
if (reactionLevel.level === "off") {
|
||||
return;
|
||||
}
|
||||
|
||||
const ackConfig = params.cfg.channels?.whatsapp?.ackReaction;
|
||||
const emoji = (ackConfig?.emoji ?? "").trim();
|
||||
const directEnabled = ackConfig?.direct ?? true;
|
||||
|
|
|
|||
|
|
@ -24,7 +24,10 @@ const hoisted = vi.hoisted(() => ({
|
|||
handleWhatsAppAction: vi.fn(async () => ({ content: [{ type: "text", text: '{"ok":true}' }] })),
|
||||
loginWeb: vi.fn(async () => {}),
|
||||
pathExists: vi.fn(async () => false),
|
||||
listWhatsAppAccountIds: vi.fn(() => [] as string[]),
|
||||
listWhatsAppAccountIds: vi.fn((cfg: OpenClawConfig) => {
|
||||
const accountIds = Object.keys(cfg.channels?.whatsapp?.accounts ?? {});
|
||||
return accountIds.length > 0 ? accountIds : [DEFAULT_ACCOUNT_ID];
|
||||
}),
|
||||
resolveDefaultWhatsAppAccountId: vi.fn(() => DEFAULT_ACCOUNT_ID),
|
||||
resolveWhatsAppAuthDir: vi.fn(() => ({
|
||||
authDir: "/tmp/openclaw-whatsapp-test",
|
||||
|
|
@ -443,6 +446,210 @@ describe("whatsapp group policy", () => {
|
|||
});
|
||||
});
|
||||
|
||||
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: ["*"] } },
|
||||
|
|
|
|||
|
|
@ -7,7 +7,11 @@ import {
|
|||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
// WhatsApp-specific imports from local extension code (moved from src/web/ and src/channels/plugins/)
|
||||
import { resolveWhatsAppAccount, type ResolvedWhatsAppAccount } from "./accounts.js";
|
||||
import {
|
||||
listWhatsAppAccountIds,
|
||||
resolveWhatsAppAccount,
|
||||
type ResolvedWhatsAppAccount,
|
||||
} from "./accounts.js";
|
||||
import { handleWhatsAppAction } from "./action-runtime.js";
|
||||
import { createWhatsAppLoginTool } from "./agent-tools-login.js";
|
||||
import { whatsappApprovalAuth } from "./approval-auth.js";
|
||||
|
|
@ -21,6 +25,7 @@ import {
|
|||
resolveWhatsAppGroupToolPolicy,
|
||||
} from "./group-policy.js";
|
||||
import { looksLikeWhatsAppTargetId, normalizeWhatsAppMessagingTarget } from "./normalize.js";
|
||||
import { resolveWhatsAppReactionLevel } from "./reaction-level.js";
|
||||
import {
|
||||
createActionGate,
|
||||
createWhatsAppOutboundBase,
|
||||
|
|
@ -33,6 +38,7 @@ import {
|
|||
resolveWhatsAppMentionStripRegexes,
|
||||
type ChannelMessageActionName,
|
||||
type ChannelPlugin,
|
||||
type OpenClawConfig,
|
||||
isWhatsAppGroupJid,
|
||||
normalizeWhatsAppTarget,
|
||||
} from "./runtime-api.js";
|
||||
|
|
@ -63,6 +69,54 @@ 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: {
|
||||
|
|
@ -111,6 +165,15 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
|
|||
enforceOwnerForCommands: true,
|
||||
skipWhenConfigEmpty: true,
|
||||
},
|
||||
agentPrompt: {
|
||||
reactionGuidance: ({ cfg, accountId }) => {
|
||||
const level = resolveWhatsAppAgentReactionGuidance({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
});
|
||||
return level ? { level, channelLabel: "WhatsApp" } : undefined;
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeWhatsAppMessagingTarget,
|
||||
resolveOutboundSessionRoute: (params) => resolveWhatsAppOutboundSessionRoute(params),
|
||||
|
|
@ -140,13 +203,20 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> =
|
|||
listGroups: async (params) => listWhatsAppDirectoryGroupsFromConfig(params),
|
||||
},
|
||||
actions: {
|
||||
describeMessageTool: ({ cfg }) => {
|
||||
describeMessageTool: ({ cfg, accountId }) => {
|
||||
if (!cfg.channels?.whatsapp) {
|
||||
return null;
|
||||
}
|
||||
const gate = createActionGate(cfg.channels.whatsapp.actions);
|
||||
const actions = new Set<ChannelMessageActionName>();
|
||||
if (gate("reactions")) {
|
||||
const canReact =
|
||||
accountId != null
|
||||
? areWhatsAppAgentReactionsEnabled({
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
})
|
||||
: hasAnyWhatsAppAccountWithAgentReactionsEnabled(cfg);
|
||||
if (canReact) {
|
||||
actions.add("react");
|
||||
}
|
||||
if (gate("polls")) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveWhatsAppReactionLevel } from "./reaction-level.js";
|
||||
|
||||
type ReactionResolution = ReturnType<typeof resolveWhatsAppReactionLevel>;
|
||||
|
||||
describe("resolveWhatsAppReactionLevel", () => {
|
||||
const expectReactionFlags = (
|
||||
result: ReactionResolution,
|
||||
expected: {
|
||||
level: "off" | "ack" | "minimal" | "extensive";
|
||||
ackEnabled: boolean;
|
||||
agentReactionsEnabled: boolean;
|
||||
agentReactionGuidance?: "minimal" | "extensive";
|
||||
},
|
||||
) => {
|
||||
expect(result.level).toBe(expected.level);
|
||||
expect(result.ackEnabled).toBe(expected.ackEnabled);
|
||||
expect(result.agentReactionsEnabled).toBe(expected.agentReactionsEnabled);
|
||||
expect(result.agentReactionGuidance).toBe(expected.agentReactionGuidance);
|
||||
};
|
||||
|
||||
it("defaults to minimal level when reactionLevel is not set", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: {} },
|
||||
};
|
||||
|
||||
const result = resolveWhatsAppReactionLevel({ cfg });
|
||||
expectReactionFlags(result, {
|
||||
level: "minimal",
|
||||
ackEnabled: false,
|
||||
agentReactionsEnabled: true,
|
||||
agentReactionGuidance: "minimal",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns off level with no reactions enabled", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { reactionLevel: "off" } },
|
||||
};
|
||||
|
||||
const result = resolveWhatsAppReactionLevel({ cfg });
|
||||
expectReactionFlags(result, {
|
||||
level: "off",
|
||||
ackEnabled: false,
|
||||
agentReactionsEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns ack level with only ackEnabled", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { reactionLevel: "ack" } },
|
||||
};
|
||||
|
||||
const result = resolveWhatsAppReactionLevel({ cfg });
|
||||
expectReactionFlags(result, {
|
||||
level: "ack",
|
||||
ackEnabled: true,
|
||||
agentReactionsEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns minimal level with agent reactions enabled and minimal guidance", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { reactionLevel: "minimal" } },
|
||||
};
|
||||
|
||||
const result = resolveWhatsAppReactionLevel({ cfg });
|
||||
expectReactionFlags(result, {
|
||||
level: "minimal",
|
||||
ackEnabled: false,
|
||||
agentReactionsEnabled: true,
|
||||
agentReactionGuidance: "minimal",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns extensive level with agent reactions enabled and extensive guidance", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: { whatsapp: { reactionLevel: "extensive" } },
|
||||
};
|
||||
|
||||
const result = resolveWhatsAppReactionLevel({ cfg });
|
||||
expectReactionFlags(result, {
|
||||
level: "extensive",
|
||||
ackEnabled: false,
|
||||
agentReactionsEnabled: true,
|
||||
agentReactionGuidance: "extensive",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves reaction level from a specific account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
reactionLevel: "minimal",
|
||||
accounts: {
|
||||
work: { reactionLevel: "extensive" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = resolveWhatsAppReactionLevel({ cfg, accountId: "work" });
|
||||
expectReactionFlags(result, {
|
||||
level: "extensive",
|
||||
ackEnabled: false,
|
||||
agentReactionsEnabled: true,
|
||||
agentReactionGuidance: "extensive",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
resolveReactionLevel,
|
||||
type ReactionLevel,
|
||||
type ResolvedReactionLevel,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { resolveWhatsAppAccount } from "./accounts.js";
|
||||
|
||||
export type WhatsAppReactionLevel = ReactionLevel;
|
||||
export type ResolvedWhatsAppReactionLevel = ResolvedReactionLevel;
|
||||
|
||||
/** Resolve the effective reaction level and its implications for WhatsApp. */
|
||||
export function resolveWhatsAppReactionLevel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string;
|
||||
}): ResolvedWhatsAppReactionLevel {
|
||||
const account = resolveWhatsAppAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return resolveReactionLevel({
|
||||
value: account.reactionLevel,
|
||||
defaultLevel: "minimal",
|
||||
invalidFallback: "minimal",
|
||||
});
|
||||
}
|
||||
|
|
@ -36,6 +36,7 @@ export {
|
|||
normalizeWhatsAppTarget,
|
||||
} from "./normalize-target.js";
|
||||
export { resolveWhatsAppOutboundTarget } from "./resolve-outbound-target.js";
|
||||
export { resolveWhatsAppReactionLevel } from "./reaction-level.js";
|
||||
type MonitorWebChannel = typeof import("./channel.runtime.js").monitorWebChannel;
|
||||
|
||||
let channelRuntimePromise: Promise<typeof import("./channel.runtime.js")> | null = null;
|
||||
|
|
|
|||
|
|
@ -14357,6 +14357,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
required: ["direct", "group"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
reactionLevel: {
|
||||
type: "string",
|
||||
enum: ["off", "ack", "minimal", "extensive"],
|
||||
},
|
||||
debounceMs: {
|
||||
default: 0,
|
||||
type: "integer",
|
||||
|
|
@ -14602,6 +14606,10 @@ export const GENERATED_BUNDLED_CHANNEL_CONFIG_METADATA = [
|
|||
required: ["direct", "group"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
reactionLevel: {
|
||||
type: "string",
|
||||
enum: ["off", "ack", "minimal", "extensive"],
|
||||
},
|
||||
debounceMs: {
|
||||
default: 0,
|
||||
type: "integer",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import type { ReactionLevel } from "../utils/reaction-level.js";
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmPolicy,
|
||||
|
|
@ -17,6 +18,8 @@ export type WhatsAppActionConfig = {
|
|||
polls?: boolean;
|
||||
};
|
||||
|
||||
export type WhatsAppReactionLevel = ReactionLevel;
|
||||
|
||||
export type WhatsAppGroupConfig = {
|
||||
requireMention?: boolean;
|
||||
tools?: GroupToolPolicyConfig;
|
||||
|
|
@ -77,6 +80,14 @@ type WhatsAppSharedConfig = {
|
|||
groups?: Record<string, WhatsAppGroupConfig>;
|
||||
/** Acknowledgment reaction sent immediately upon message receipt. */
|
||||
ackReaction?: WhatsAppAckReactionConfig;
|
||||
/**
|
||||
* Controls agent reaction behavior:
|
||||
* - "off": No reactions
|
||||
* - "ack": Only automatic ack reactions
|
||||
* - "minimal" (default): Agent can react sparingly
|
||||
* - "extensive": Agent can react liberally
|
||||
*/
|
||||
reactionLevel?: WhatsAppReactionLevel;
|
||||
/** Debounce window (ms) for batching rapid consecutive messages from the same sender (0 to disable). */
|
||||
debounceMs?: number;
|
||||
/** Heartbeat visibility settings. */
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ const WhatsAppSharedSchema = z.object({
|
|||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
groups: WhatsAppGroupsSchema,
|
||||
ackReaction: WhatsAppAckReactionSchema,
|
||||
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
|
||||
debounceMs: z.number().int().nonnegative().optional().default(0),
|
||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
||||
healthMonitor: ChannelHealthMonitorSchema,
|
||||
|
|
|
|||
Loading…
Reference in New Issue