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 { createSessionsSendTool } from "./tools/sessions-send-tool.js";
|
||||||
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
|
||||||
import { createSlackTool } from "./tools/slack-tool.js";
|
import { createSlackTool } from "./tools/slack-tool.js";
|
||||||
|
import { createWhatsAppTool } from "./tools/whatsapp-tool.js";
|
||||||
|
|
||||||
export function createClawdbotTools(options?: {
|
export function createClawdbotTools(options?: {
|
||||||
browserControlUrl?: string;
|
browserControlUrl?: string;
|
||||||
@@ -32,6 +33,7 @@ export function createClawdbotTools(options?: {
|
|||||||
createCronTool(),
|
createCronTool(),
|
||||||
createDiscordTool(),
|
createDiscordTool(),
|
||||||
createSlackTool(),
|
createSlackTool(),
|
||||||
|
createWhatsAppTool(),
|
||||||
createGatewayTool(),
|
createGatewayTool(),
|
||||||
createSessionsListTool({
|
createSessionsListTool({
|
||||||
agentSessionKey: options?.agentSessionKey,
|
agentSessionKey: options?.agentSessionKey,
|
||||||
|
|||||||
@@ -503,6 +503,12 @@ function shouldIncludeSlackTool(messageProvider?: string): boolean {
|
|||||||
return normalized === "slack" || normalized.startsWith("slack:");
|
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?: {
|
export function createClawdbotCodingTools(options?: {
|
||||||
bash?: BashToolDefaults & ProcessToolDefaults;
|
bash?: BashToolDefaults & ProcessToolDefaults;
|
||||||
messageProvider?: string;
|
messageProvider?: string;
|
||||||
@@ -562,9 +568,11 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
];
|
];
|
||||||
const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider);
|
const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider);
|
||||||
const allowSlack = shouldIncludeSlackTool(options?.messageProvider);
|
const allowSlack = shouldIncludeSlackTool(options?.messageProvider);
|
||||||
|
const allowWhatsApp = shouldIncludeWhatsAppTool(options?.messageProvider);
|
||||||
const filtered = tools.filter((tool) => {
|
const filtered = tools.filter((tool) => {
|
||||||
if (tool.name === "discord") return allowDiscord;
|
if (tool.name === "discord") return allowDiscord;
|
||||||
if (tool.name === "slack") return allowSlack;
|
if (tool.name === "slack") return allowSlack;
|
||||||
|
if (tool.name === "whatsapp") return allowWhatsApp;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
const globallyFiltered =
|
const globallyFiltered =
|
||||||
|
|||||||
@@ -231,6 +231,13 @@
|
|||||||
"memberInfo": { "label": "member", "detailKeys": ["userId"] },
|
"memberInfo": { "label": "member", "detailKeys": ["userId"] },
|
||||||
"emojiList": { "label": "emoji list" }
|
"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>;
|
webchat?: Array<string | number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type WhatsAppActionConfig = {
|
||||||
|
reactions?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type WhatsAppConfig = {
|
export type WhatsAppConfig = {
|
||||||
/** Optional per-account WhatsApp configuration (multi-account). */
|
/** Optional per-account WhatsApp configuration (multi-account). */
|
||||||
accounts?: Record<string, WhatsAppAccountConfig>;
|
accounts?: Record<string, WhatsAppAccountConfig>;
|
||||||
@@ -95,6 +99,8 @@ export type WhatsAppConfig = {
|
|||||||
groupPolicy?: GroupPolicy;
|
groupPolicy?: GroupPolicy;
|
||||||
/** Outbound text chunk size (chars). Default: 4000. */
|
/** Outbound text chunk size (chars). Default: 4000. */
|
||||||
textChunkLimit?: number;
|
textChunkLimit?: number;
|
||||||
|
/** Per-action tool gating (default: true for all). */
|
||||||
|
actions?: WhatsAppActionConfig;
|
||||||
groups?: Record<
|
groups?: Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -737,6 +737,11 @@ export const ClawdbotSchema = z.object({
|
|||||||
groupAllowFrom: z.array(z.string()).optional(),
|
groupAllowFrom: z.array(z.string()).optional(),
|
||||||
groupPolicy: GroupPolicySchema.optional().default("open"),
|
groupPolicy: GroupPolicySchema.optional().default("open"),
|
||||||
textChunkLimit: z.number().int().positive().optional(),
|
textChunkLimit: z.number().int().positive().optional(),
|
||||||
|
actions: z
|
||||||
|
.object({
|
||||||
|
reactions: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
groups: z
|
groups: z
|
||||||
.record(
|
.record(
|
||||||
z.string(),
|
z.string(),
|
||||||
|
|||||||
@@ -14,6 +14,13 @@ export type ActiveWebListener = {
|
|||||||
options?: ActiveWebSendOptions,
|
options?: ActiveWebSendOptions,
|
||||||
) => Promise<{ messageId: string }>;
|
) => Promise<{ messageId: string }>;
|
||||||
sendPoll: (to: string, poll: PollInput) => 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>;
|
sendComposingTo: (to: string) => Promise<void>;
|
||||||
close?: () => Promise<void>;
|
close?: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -566,6 +566,30 @@ export async function monitorWebInbox(options: {
|
|||||||
const jid = toWhatsappJid(to);
|
const jid = toWhatsappJid(to);
|
||||||
await sock.sendPresenceUpdate("composing", jid);
|
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;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,16 +8,26 @@ vi.mock("./media.js", () => ({
|
|||||||
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
loadWebMedia: (...args: unknown[]) => loadWebMediaMock(...args),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { sendMessageWhatsApp, sendPollWhatsApp } from "./outbound.js";
|
import {
|
||||||
|
sendMessageWhatsApp,
|
||||||
|
sendPollWhatsApp,
|
||||||
|
sendReactionWhatsApp,
|
||||||
|
} from "./outbound.js";
|
||||||
|
|
||||||
describe("web outbound", () => {
|
describe("web outbound", () => {
|
||||||
const sendComposingTo = vi.fn(async () => {});
|
const sendComposingTo = vi.fn(async () => {});
|
||||||
const sendMessage = vi.fn(async () => ({ messageId: "msg123" }));
|
const sendMessage = vi.fn(async () => ({ messageId: "msg123" }));
|
||||||
const sendPoll = vi.fn(async () => ({ messageId: "poll123" }));
|
const sendPoll = vi.fn(async () => ({ messageId: "poll123" }));
|
||||||
|
const sendReaction = vi.fn(async () => {});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
setActiveWebListener({ sendComposingTo, sendMessage, sendPoll });
|
setActiveWebListener({
|
||||||
|
sendComposingTo,
|
||||||
|
sendMessage,
|
||||||
|
sendPoll,
|
||||||
|
sendReaction,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -156,4 +166,18 @@ describe("web outbound", () => {
|
|||||||
durationHours: undefined,
|
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;
|
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(
|
export async function sendPollWhatsApp(
|
||||||
to: string,
|
to: string,
|
||||||
poll: PollInput,
|
poll: PollInput,
|
||||||
|
|||||||
Reference in New Issue
Block a user