From 551a8d56830be5cf6fa53baf72c2de6838f67e4f Mon Sep 17 00:00:00 2001 From: Sash Zats Date: Tue, 6 Jan 2026 20:42:05 -0500 Subject: [PATCH] Add WhatsApp reactions support Summary: Test Plan: --- src/agents/clawdbot-tools.ts | 2 ++ src/agents/pi-tools.ts | 8 +++++ src/agents/tool-display.json | 7 +++++ src/agents/tools/whatsapp-actions.ts | 47 ++++++++++++++++++++++++++++ src/agents/tools/whatsapp-schema.ts | 11 +++++++ src/agents/tools/whatsapp-tool.ts | 18 +++++++++++ src/config/types.ts | 6 ++++ src/config/zod-schema.ts | 5 +++ src/web/active-listener.ts | 7 +++++ src/web/inbound.ts | 24 ++++++++++++++ src/web/outbound.test.ts | 28 +++++++++++++++-- src/web/outbound.ts | 46 +++++++++++++++++++++++++++ 12 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 src/agents/tools/whatsapp-actions.ts create mode 100644 src/agents/tools/whatsapp-schema.ts create mode 100644 src/agents/tools/whatsapp-tool.ts diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 447a098b0..0cab51d14 100644 --- a/src/agents/clawdbot-tools.ts +++ b/src/agents/clawdbot-tools.ts @@ -12,6 +12,7 @@ import { createSessionsListTool } from "./tools/sessions-list-tool.js"; import { createSessionsSendTool } from "./tools/sessions-send-tool.js"; import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; import { createSlackTool } from "./tools/slack-tool.js"; +import { createWhatsAppTool } from "./tools/whatsapp-tool.js"; export function createClawdbotTools(options?: { browserControlUrl?: string; @@ -32,6 +33,7 @@ export function createClawdbotTools(options?: { createCronTool(), createDiscordTool(), createSlackTool(), + createWhatsAppTool(), createGatewayTool(), createSessionsListTool({ agentSessionKey: options?.agentSessionKey, diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 2dbfb6452..8846e9c13 100644 --- a/src/agents/pi-tools.ts +++ b/src/agents/pi-tools.ts @@ -503,6 +503,12 @@ function shouldIncludeSlackTool(messageProvider?: string): boolean { return normalized === "slack" || normalized.startsWith("slack:"); } +function shouldIncludeWhatsAppTool(messageProvider?: string): boolean { + const normalized = normalizeMessageProvider(messageProvider); + if (!normalized) return false; + return normalized === "whatsapp" || normalized.startsWith("whatsapp:"); +} + export function createClawdbotCodingTools(options?: { bash?: BashToolDefaults & ProcessToolDefaults; messageProvider?: string; @@ -562,9 +568,11 @@ export function createClawdbotCodingTools(options?: { ]; const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider); const allowSlack = shouldIncludeSlackTool(options?.messageProvider); + const allowWhatsApp = shouldIncludeWhatsAppTool(options?.messageProvider); const filtered = tools.filter((tool) => { if (tool.name === "discord") return allowDiscord; if (tool.name === "slack") return allowSlack; + if (tool.name === "whatsapp") return allowWhatsApp; return true; }); const globallyFiltered = diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index ce3ba7b66..0e53edbd3 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -231,6 +231,13 @@ "memberInfo": { "label": "member", "detailKeys": ["userId"] }, "emojiList": { "label": "emoji list" } } + }, + "whatsapp": { + "emoji": "💬", + "title": "WhatsApp", + "actions": { + "react": { "label": "react", "detailKeys": ["chatJid", "messageId", "emoji"] } + } } } } diff --git a/src/agents/tools/whatsapp-actions.ts b/src/agents/tools/whatsapp-actions.ts new file mode 100644 index 000000000..52bcf279e --- /dev/null +++ b/src/agents/tools/whatsapp-actions.ts @@ -0,0 +1,47 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; + +import type { + ClawdbotConfig, + WhatsAppActionConfig, +} from "../../config/config.js"; +import { isSelfChatMode } from "../../utils.js"; +import { sendReactionWhatsApp } from "../../web/outbound.js"; +import { readWebSelfId } from "../../web/session.js"; +import { jsonResult, readStringParam } from "./common.js"; + +type ActionGate = ( + key: keyof WhatsAppActionConfig, + defaultValue?: boolean, +) => boolean; + +export async function handleWhatsAppAction( + params: Record, + cfg: ClawdbotConfig, +): Promise> { + const action = readStringParam(params, "action", { required: true }); + const isActionEnabled: ActionGate = (key, defaultValue = true) => { + const value = cfg.whatsapp?.actions?.[key]; + if (value === undefined) return defaultValue; + return value !== false; + }; + + if (action === "react") { + if (!isActionEnabled("reactions")) { + throw new Error("WhatsApp reactions are disabled."); + } + const chatJid = readStringParam(params, "chatJid", { required: true }); + const messageId = readStringParam(params, "messageId", { required: true }); + const emoji = readStringParam(params, "emoji", { required: true }); + const participant = readStringParam(params, "participant"); + const selfE164 = readWebSelfId().e164; + const fromMe = isSelfChatMode(selfE164, cfg.whatsapp?.allowFrom); + await sendReactionWhatsApp(chatJid, messageId, emoji, { + verbose: false, + fromMe, + participant: participant ?? undefined, + }); + return jsonResult({ ok: true }); + } + + throw new Error(`Unsupported WhatsApp action: ${action}`); +} diff --git a/src/agents/tools/whatsapp-schema.ts b/src/agents/tools/whatsapp-schema.ts new file mode 100644 index 000000000..e7111307b --- /dev/null +++ b/src/agents/tools/whatsapp-schema.ts @@ -0,0 +1,11 @@ +import { Type } from "@sinclair/typebox"; + +export const WhatsAppToolSchema = Type.Union([ + Type.Object({ + action: Type.Literal("react"), + chatJid: Type.String(), + messageId: Type.String(), + emoji: Type.String(), + participant: Type.Optional(Type.String()), + }), +]); diff --git a/src/agents/tools/whatsapp-tool.ts b/src/agents/tools/whatsapp-tool.ts new file mode 100644 index 000000000..77ce97d0d --- /dev/null +++ b/src/agents/tools/whatsapp-tool.ts @@ -0,0 +1,18 @@ +import { loadConfig } from "../../config/config.js"; +import type { AnyAgentTool } from "./common.js"; +import { handleWhatsAppAction } from "./whatsapp-actions.js"; +import { WhatsAppToolSchema } from "./whatsapp-schema.js"; + +export function createWhatsAppTool(): AnyAgentTool { + return { + label: "WhatsApp", + name: "whatsapp", + description: "Manage WhatsApp reactions.", + parameters: WhatsAppToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const cfg = loadConfig(); + return await handleWhatsAppAction(params, cfg); + }, + }; +} diff --git a/src/config/types.ts b/src/config/types.ts index 8b02d50ed..707d9481c 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -77,6 +77,10 @@ export type AgentElevatedAllowFromConfig = { webchat?: Array; }; +export type WhatsAppActionConfig = { + reactions?: boolean; +}; + export type WhatsAppConfig = { /** Optional per-account WhatsApp configuration (multi-account). */ accounts?: Record; @@ -95,6 +99,8 @@ export type WhatsAppConfig = { groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; + /** Per-action tool gating (default: true for all). */ + actions?: WhatsAppActionConfig; groups?: Record< string, { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 069a41aad..a9fc0211b 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -737,6 +737,11 @@ export const ClawdbotSchema = z.object({ groupAllowFrom: z.array(z.string()).optional(), groupPolicy: GroupPolicySchema.optional().default("open"), textChunkLimit: z.number().int().positive().optional(), + actions: z + .object({ + reactions: z.boolean().optional(), + }) + .optional(), groups: z .record( z.string(), diff --git a/src/web/active-listener.ts b/src/web/active-listener.ts index 76c7016db..bf1ff4c6f 100644 --- a/src/web/active-listener.ts +++ b/src/web/active-listener.ts @@ -14,6 +14,13 @@ export type ActiveWebListener = { options?: ActiveWebSendOptions, ) => Promise<{ messageId: string }>; sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>; + sendReaction: ( + chatJid: string, + messageId: string, + emoji: string, + fromMe: boolean, + participant?: string, + ) => Promise; sendComposingTo: (to: string) => Promise; close?: () => Promise; }; diff --git a/src/web/inbound.ts b/src/web/inbound.ts index c72ff43d2..b5f33c2fd 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -566,6 +566,30 @@ export async function monitorWebInbox(options: { const jid = toWhatsappJid(to); await sock.sendPresenceUpdate("composing", jid); }, + /** + * Send a reaction (emoji) to a specific message. + * Pass an empty string for emoji to remove the reaction. + */ + sendReaction: async ( + chatJid: string, + messageId: string, + emoji: string, + fromMe: boolean, + participant?: string, + ): Promise => { + const jid = toWhatsappJid(chatJid); + await sock.sendMessage(jid, { + react: { + text: emoji, + key: { + remoteJid: jid, + id: messageId, + fromMe, + participant, + }, + }, + }); + }, } as const; } diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts index e7c3a2ba1..f92c7fea7 100644 --- a/src/web/outbound.test.ts +++ b/src/web/outbound.test.ts @@ -8,16 +8,26 @@ vi.mock("./media.js", () => ({ loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args), })); -import { sendMessageWhatsApp, sendPollWhatsApp } from "./outbound.js"; +import { + sendMessageWhatsApp, + sendPollWhatsApp, + sendReactionWhatsApp, +} from "./outbound.js"; describe("web outbound", () => { const sendComposingTo = vi.fn(async () => {}); const sendMessage = vi.fn(async () => ({ messageId: "msg123" })); const sendPoll = vi.fn(async () => ({ messageId: "poll123" })); + const sendReaction = vi.fn(async () => {}); beforeEach(() => { vi.clearAllMocks(); - setActiveWebListener({ sendComposingTo, sendMessage, sendPoll }); + setActiveWebListener({ + sendComposingTo, + sendMessage, + sendPoll, + sendReaction, + }); }); afterEach(() => { @@ -156,4 +166,18 @@ describe("web outbound", () => { durationHours: undefined, }); }); + + it("sends reactions via active listener", async () => { + await sendReactionWhatsApp("1555@s.whatsapp.net", "msg123", "✅", { + verbose: false, + fromMe: false, + }); + expect(sendReaction).toHaveBeenCalledWith( + "1555@s.whatsapp.net", + "msg123", + "✅", + false, + undefined, + ); + }); }); diff --git a/src/web/outbound.ts b/src/web/outbound.ts index 120184c5f..1666eada6 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -91,6 +91,52 @@ export async function sendMessageWhatsApp( throw err; } } + +export async function sendReactionWhatsApp( + chatJid: string, + messageId: string, + emoji: string, + options: { + verbose: boolean; + fromMe?: boolean; + participant?: string; + }, +): Promise { + const correlationId = randomUUID(); + const active = getActiveWebListener(); + if (!active) { + throw new Error( + "No active gateway listener. Start the gateway before sending WhatsApp reactions.", + ); + } + const logger = getChildLogger({ + module: "web-outbound", + correlationId, + chatJid, + messageId, + }); + try { + const jid = toWhatsappJid(chatJid); + outboundLog.info(`Sending reaction "${emoji}" -> message ${messageId}`); + logger.info({ chatJid: jid, messageId, emoji }, "sending reaction"); + await active.sendReaction( + chatJid, + messageId, + emoji, + options.fromMe ?? false, + options.participant, + ); + outboundLog.info(`Sent reaction "${emoji}" -> message ${messageId}`); + logger.info({ chatJid: jid, messageId, emoji }, "sent reaction"); + } catch (err) { + logger.error( + { err: String(err), chatJid, messageId, emoji }, + "failed to send reaction via web session", + ); + throw err; + } +} + export async function sendPollWhatsApp( to: string, poll: PollInput,