diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d13fbcc7d..584fe9241cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/.generated/config-baseline.json b/docs/.generated/config-baseline.json index 1c7e2d5eb64..fd780220af9 100644 --- a/docs/.generated/config-baseline.json +++ b/docs/.generated/config-baseline.json @@ -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", diff --git a/docs/.generated/config-baseline.jsonl b/docs/.generated/config-baseline.jsonl index 8ca67f22976..abd4c7f1b69 100644 --- a/docs/.generated/config-baseline.jsonl +++ b/docs/.generated/config-baseline.jsonl @@ -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} diff --git a/extensions/whatsapp/src/accounts.ts b/extensions/whatsapp/src/accounts.ts index 9de02f09ea2..a85108ac43c 100644 --- a/extensions/whatsapp/src/accounts.ts +++ b/extensions/whatsapp/src/accounts.ts @@ -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, }; diff --git a/extensions/whatsapp/src/action-runtime.test.ts b/extensions/whatsapp/src/action-runtime.test.ts index e118480476b..0e1fe4db12d 100644 --- a/extensions/whatsapp/src/action-runtime.test.ts +++ b/extensions/whatsapp/src/action-runtime.test.ts @@ -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: { diff --git a/extensions/whatsapp/src/action-runtime.ts b/extensions/whatsapp/src/action-runtime.ts index 661b3a495dd..42494b7a632 100644 --- a/extensions/whatsapp/src/action-runtime.ts +++ b/extensions/whatsapp/src/action-runtime.ts @@ -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> { 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; diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts new file mode 100644 index 00000000000..594d6a4df2b --- /dev/null +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.test.ts @@ -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 { + 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["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", + }, + ); + }); +}); diff --git a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts index 6d060af362b..cb9cf8ad27f 100644 --- a/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts +++ b/extensions/whatsapp/src/auto-reply/monitor/ack-reaction.ts @@ -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; diff --git a/extensions/whatsapp/src/channel.test.ts b/extensions/whatsapp/src/channel.test.ts index 5069bf7640f..e1525870992 100644 --- a/extensions/whatsapp/src/channel.test.ts +++ b/extensions/whatsapp/src/channel.test.ts @@ -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: ["*"] } }, diff --git a/extensions/whatsapp/src/channel.ts b/extensions/whatsapp/src/channel.ts index 59501dc5473..7409cd643e6 100644 --- a/extensions/whatsapp/src/channel.ts +++ b/extensions/whatsapp/src/channel.ts @@ -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 = createChatChannelPlugin({ pairing: { @@ -111,6 +165,15 @@ export const whatsappPlugin: ChannelPlugin = 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 = 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(); - if (gate("reactions")) { + const canReact = + accountId != null + ? areWhatsAppAgentReactionsEnabled({ + cfg, + accountId: accountId ?? undefined, + }) + : hasAnyWhatsAppAccountWithAgentReactionsEnabled(cfg); + if (canReact) { actions.add("react"); } if (gate("polls")) { diff --git a/extensions/whatsapp/src/reaction-level.test.ts b/extensions/whatsapp/src/reaction-level.test.ts new file mode 100644 index 00000000000..79a7ab59a28 --- /dev/null +++ b/extensions/whatsapp/src/reaction-level.test.ts @@ -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; + +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", + }); + }); +}); diff --git a/extensions/whatsapp/src/reaction-level.ts b/extensions/whatsapp/src/reaction-level.ts new file mode 100644 index 00000000000..e9bcaf51425 --- /dev/null +++ b/extensions/whatsapp/src/reaction-level.ts @@ -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", + }); +} diff --git a/extensions/whatsapp/src/runtime-api.ts b/extensions/whatsapp/src/runtime-api.ts index d586877aeec..10de17d5d76 100644 --- a/extensions/whatsapp/src/runtime-api.ts +++ b/extensions/whatsapp/src/runtime-api.ts @@ -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 | null = null; diff --git a/src/config/bundled-channel-config-metadata.generated.ts b/src/config/bundled-channel-config-metadata.generated.ts index 6a236618eb4..add92fb0088 100644 --- a/src/config/bundled-channel-config-metadata.generated.ts +++ b/src/config/bundled-channel-config-metadata.generated.ts @@ -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", diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 0bb3146af0c..987c6ee5cee 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -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; /** 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. */ diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 26b7c476c53..80c98eb586b 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -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,