Skip to content

Commit 551a8d5

Browse files
zatssteipete
authored andcommitted
Add WhatsApp reactions support
Summary: Test Plan:
1 parent aa87d6c commit 551a8d5

File tree

12 files changed

+207
-2
lines changed

12 files changed

+207
-2
lines changed

src/agents/clawdbot-tools.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { createSessionsListTool } from "./tools/sessions-list-tool.js";
1212
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
1313
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
1414
import { createSlackTool } from "./tools/slack-tool.js";
15+
import { createWhatsAppTool } from "./tools/whatsapp-tool.js";
1516

1617
export function createClawdbotTools(options?: {
1718
browserControlUrl?: string;
@@ -32,6 +33,7 @@ export function createClawdbotTools(options?: {
3233
createCronTool(),
3334
createDiscordTool(),
3435
createSlackTool(),
36+
createWhatsAppTool(),
3537
createGatewayTool(),
3638
createSessionsListTool({
3739
agentSessionKey: options?.agentSessionKey,

src/agents/pi-tools.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,12 @@ function shouldIncludeSlackTool(messageProvider?: string): boolean {
503503
return normalized === "slack" || normalized.startsWith("slack:");
504504
}
505505

506+
function shouldIncludeWhatsAppTool(messageProvider?: string): boolean {
507+
const normalized = normalizeMessageProvider(messageProvider);
508+
if (!normalized) return false;
509+
return normalized === "whatsapp" || normalized.startsWith("whatsapp:");
510+
}
511+
506512
export function createClawdbotCodingTools(options?: {
507513
bash?: BashToolDefaults & ProcessToolDefaults;
508514
messageProvider?: string;
@@ -562,9 +568,11 @@ export function createClawdbotCodingTools(options?: {
562568
];
563569
const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider);
564570
const allowSlack = shouldIncludeSlackTool(options?.messageProvider);
571+
const allowWhatsApp = shouldIncludeWhatsAppTool(options?.messageProvider);
565572
const filtered = tools.filter((tool) => {
566573
if (tool.name === "discord") return allowDiscord;
567574
if (tool.name === "slack") return allowSlack;
575+
if (tool.name === "whatsapp") return allowWhatsApp;
568576
return true;
569577
});
570578
const globallyFiltered =

src/agents/tool-display.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,13 @@
231231
"memberInfo": { "label": "member", "detailKeys": ["userId"] },
232232
"emojiList": { "label": "emoji list" }
233233
}
234+
},
235+
"whatsapp": {
236+
"emoji": "💬",
237+
"title": "WhatsApp",
238+
"actions": {
239+
"react": { "label": "react", "detailKeys": ["chatJid", "messageId", "emoji"] }
240+
}
234241
}
235242
}
236243
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
2+
3+
import type {
4+
ClawdbotConfig,
5+
WhatsAppActionConfig,
6+
} from "../../config/config.js";
7+
import { isSelfChatMode } from "../../utils.js";
8+
import { sendReactionWhatsApp } from "../../web/outbound.js";
9+
import { readWebSelfId } from "../../web/session.js";
10+
import { jsonResult, readStringParam } from "./common.js";
11+
12+
type ActionGate = (
13+
key: keyof WhatsAppActionConfig,
14+
defaultValue?: boolean,
15+
) => boolean;
16+
17+
export async function handleWhatsAppAction(
18+
params: Record<string, unknown>,
19+
cfg: ClawdbotConfig,
20+
): Promise<AgentToolResult<unknown>> {
21+
const action = readStringParam(params, "action", { required: true });
22+
const isActionEnabled: ActionGate = (key, defaultValue = true) => {
23+
const value = cfg.whatsapp?.actions?.[key];
24+
if (value === undefined) return defaultValue;
25+
return value !== false;
26+
};
27+
28+
if (action === "react") {
29+
if (!isActionEnabled("reactions")) {
30+
throw new Error("WhatsApp reactions are disabled.");
31+
}
32+
const chatJid = readStringParam(params, "chatJid", { required: true });
33+
const messageId = readStringParam(params, "messageId", { required: true });
34+
const emoji = readStringParam(params, "emoji", { required: true });
35+
const participant = readStringParam(params, "participant");
36+
const selfE164 = readWebSelfId().e164;
37+
const fromMe = isSelfChatMode(selfE164, cfg.whatsapp?.allowFrom);
38+
await sendReactionWhatsApp(chatJid, messageId, emoji, {
39+
verbose: false,
40+
fromMe,
41+
participant: participant ?? undefined,
42+
});
43+
return jsonResult({ ok: true });
44+
}
45+
46+
throw new Error(`Unsupported WhatsApp action: ${action}`);
47+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { Type } from "@sinclair/typebox";
2+
3+
export const WhatsAppToolSchema = Type.Union([
4+
Type.Object({
5+
action: Type.Literal("react"),
6+
chatJid: Type.String(),
7+
messageId: Type.String(),
8+
emoji: Type.String(),
9+
participant: Type.Optional(Type.String()),
10+
}),
11+
]);

src/agents/tools/whatsapp-tool.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { loadConfig } from "../../config/config.js";
2+
import type { AnyAgentTool } from "./common.js";
3+
import { handleWhatsAppAction } from "./whatsapp-actions.js";
4+
import { WhatsAppToolSchema } from "./whatsapp-schema.js";
5+
6+
export function createWhatsAppTool(): AnyAgentTool {
7+
return {
8+
label: "WhatsApp",
9+
name: "whatsapp",
10+
description: "Manage WhatsApp reactions.",
11+
parameters: WhatsAppToolSchema,
12+
execute: async (_toolCallId, args) => {
13+
const params = args as Record<string, unknown>;
14+
const cfg = loadConfig();
15+
return await handleWhatsAppAction(params, cfg);
16+
},
17+
};
18+
}

src/config/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,10 @@ export type AgentElevatedAllowFromConfig = {
7777
webchat?: Array<string | number>;
7878
};
7979

80+
export type WhatsAppActionConfig = {
81+
reactions?: boolean;
82+
};
83+
8084
export type WhatsAppConfig = {
8185
/** Optional per-account WhatsApp configuration (multi-account). */
8286
accounts?: Record<string, WhatsAppAccountConfig>;
@@ -95,6 +99,8 @@ export type WhatsAppConfig = {
9599
groupPolicy?: GroupPolicy;
96100
/** Outbound text chunk size (chars). Default: 4000. */
97101
textChunkLimit?: number;
102+
/** Per-action tool gating (default: true for all). */
103+
actions?: WhatsAppActionConfig;
98104
groups?: Record<
99105
string,
100106
{

src/config/zod-schema.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -737,6 +737,11 @@ export const ClawdbotSchema = z.object({
737737
groupAllowFrom: z.array(z.string()).optional(),
738738
groupPolicy: GroupPolicySchema.optional().default("open"),
739739
textChunkLimit: z.number().int().positive().optional(),
740+
actions: z
741+
.object({
742+
reactions: z.boolean().optional(),
743+
})
744+
.optional(),
740745
groups: z
741746
.record(
742747
z.string(),

src/web/active-listener.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ export type ActiveWebListener = {
1414
options?: ActiveWebSendOptions,
1515
) => Promise<{ messageId: string }>;
1616
sendPoll: (to: string, poll: PollInput) => Promise<{ messageId: string }>;
17+
sendReaction: (
18+
chatJid: string,
19+
messageId: string,
20+
emoji: string,
21+
fromMe: boolean,
22+
participant?: string,
23+
) => Promise<void>;
1724
sendComposingTo: (to: string) => Promise<void>;
1825
close?: () => Promise<void>;
1926
};

src/web/inbound.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -566,6 +566,30 @@ export async function monitorWebInbox(options: {
566566
const jid = toWhatsappJid(to);
567567
await sock.sendPresenceUpdate("composing", jid);
568568
},
569+
/**
570+
* Send a reaction (emoji) to a specific message.
571+
* Pass an empty string for emoji to remove the reaction.
572+
*/
573+
sendReaction: async (
574+
chatJid: string,
575+
messageId: string,
576+
emoji: string,
577+
fromMe: boolean,
578+
participant?: string,
579+
): Promise<void> => {
580+
const jid = toWhatsappJid(chatJid);
581+
await sock.sendMessage(jid, {
582+
react: {
583+
text: emoji,
584+
key: {
585+
remoteJid: jid,
586+
id: messageId,
587+
fromMe,
588+
participant,
589+
},
590+
},
591+
});
592+
},
569593
} as const;
570594
}
571595

0 commit comments

Comments
 (0)