feat(whatsapp): add reaction guidance levels (#58622)

* WhatsApp: add reaction guidance policy

* WhatsApp: expose reaction guidance to agents
This commit is contained in:
Marcus Castro 2026-04-01 01:42:10 -03:00 committed by GitHub
parent 21403a3898
commit ac6db066d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 732 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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: {

View File

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

View File

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

View File

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

View File

@ -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: ["*"] } },

View File

@ -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")) {

View File

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

View File

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

View File

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

View File

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

View File

@ -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. */

View File

@ -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,