Add WhatsApp reactions support
Summary: Test Plan:
This commit is contained in:
committed by
Peter Steinberger
parent
aa87d6cee8
commit
551a8d5683
@@ -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,
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"] }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
47
src/agents/tools/whatsapp-actions.ts
Normal file
47
src/agents/tools/whatsapp-actions.ts
Normal file
@@ -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<string, unknown>,
|
||||
cfg: ClawdbotConfig,
|
||||
): Promise<AgentToolResult<unknown>> {
|
||||
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}`);
|
||||
}
|
||||
11
src/agents/tools/whatsapp-schema.ts
Normal file
11
src/agents/tools/whatsapp-schema.ts
Normal file
@@ -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()),
|
||||
}),
|
||||
]);
|
||||
18
src/agents/tools/whatsapp-tool.ts
Normal file
18
src/agents/tools/whatsapp-tool.ts
Normal file
@@ -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<string, unknown>;
|
||||
const cfg = loadConfig();
|
||||
return await handleWhatsAppAction(params, cfg);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -77,6 +77,10 @@ export type AgentElevatedAllowFromConfig = {
|
||||
webchat?: Array<string | number>;
|
||||
};
|
||||
|
||||
export type WhatsAppActionConfig = {
|
||||
reactions?: boolean;
|
||||
};
|
||||
|
||||
export type WhatsAppConfig = {
|
||||
/** Optional per-account WhatsApp configuration (multi-account). */
|
||||
accounts?: Record<string, WhatsAppAccountConfig>;
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<void>;
|
||||
sendComposingTo: (to: string) => Promise<void>;
|
||||
close?: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -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<void> => {
|
||||
const jid = toWhatsappJid(chatJid);
|
||||
await sock.sendMessage(jid, {
|
||||
react: {
|
||||
text: emoji,
|
||||
key: {
|
||||
remoteJid: jid,
|
||||
id: messageId,
|
||||
fromMe,
|
||||
participant,
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user