From 3afef2d504ec46bf00e3a65024fd28174317b624 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 7 Jan 2026 04:10:13 +0100 Subject: [PATCH] feat: unify provider reaction tools --- CHANGELOG.md | 1 + docs/gateway/configuration.md | 1 + docs/providers/discord.md | 2 + docs/providers/slack.md | 1 + docs/providers/telegram.md | 7 + docs/providers/whatsapp.md | 8 ++ docs/tools/index.md | 26 ++++ src/agents/clawdbot-tools.ts | 2 + src/agents/pi-tools.test.ts | 16 +++ src/agents/pi-tools.ts | 8 ++ src/agents/system-prompt.ts | 4 + src/agents/tool-display.json | 16 ++- src/agents/tools/common.test.ts | 92 +++++++++++++ src/agents/tools/common.ts | 95 ++++++++++++- src/agents/tools/discord-actions-guild.ts | 14 +- src/agents/tools/discord-actions-messaging.ts | 31 +++-- .../tools/discord-actions-moderation.ts | 9 +- src/agents/tools/discord-actions.test.ts | 119 ++++++++++++++++ src/agents/tools/discord-actions.ts | 18 +-- src/agents/tools/discord-schema.ts | 1 + src/agents/tools/slack-actions.test.ts | 113 +++++++++++++++ src/agents/tools/slack-actions.ts | 36 +++-- src/agents/tools/slack-schema.ts | 1 + src/agents/tools/telegram-actions.test.ts | 95 +++++++++++++ src/agents/tools/telegram-actions.ts | 53 +++++++ src/agents/tools/telegram-schema.ts | 11 ++ src/agents/tools/telegram-tool.ts | 18 +++ src/agents/tools/whatsapp-actions.test.ts | 129 ++++++++++++++++++ src/agents/tools/whatsapp-actions.ts | 43 +++--- src/agents/tools/whatsapp-schema.ts | 3 + src/config/types.ts | 6 + src/config/zod-schema.ts | 5 + src/discord/send.test.ts | 43 ++++++ src/discord/send.ts | 49 +++++++ src/slack/actions.ts | 52 +++++++ src/slack/index.ts | 2 + src/telegram/index.ts | 2 +- src/telegram/send.test.ts | 49 ++++++- src/telegram/send.ts | 43 ++++++ src/web/inbound.ts | 24 ++++ src/web/outbound.ts | 3 +- 41 files changed, 1169 insertions(+), 82 deletions(-) create mode 100644 src/agents/tools/common.test.ts create mode 100644 src/agents/tools/discord-actions.test.ts create mode 100644 src/agents/tools/slack-actions.test.ts create mode 100644 src/agents/tools/telegram-actions.test.ts create mode 100644 src/agents/tools/telegram-actions.ts create mode 100644 src/agents/tools/telegram-schema.ts create mode 100644 src/agents/tools/telegram-tool.ts create mode 100644 src/agents/tools/whatsapp-actions.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fe99525ba..09681b132 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior. ### Fixes +- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating), unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp, and allow WhatsApp reaction routing across accounts. - Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect. - CLI: add `clawdbot docs` live docs search with pretty output. - Agent: treat compaction retry AbortError as a fallback trigger without swallowing non-abort errors. Thanks @erikpr1994 for PR #341. diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 074accc65..0fb9f769d 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -464,6 +464,7 @@ Set `telegram.enabled: false` to disable automatic startup. dmPolicy: "pairing", // pairing | allowlist | open | disabled allowFrom: ["tg:123456789"], // optional; "open" requires ["*"] groups: { "*": { requireMention: true } }, + actions: { reactions: true }, // tool action gates (false disables) mediaMaxMb: 5, proxy: "socks5://localhost:9050", webhookUrl: "https://example.com/telegram-webhook", diff --git a/docs/providers/discord.md b/docs/providers/discord.md index c218713bd..d0d91c074 100644 --- a/docs/providers/discord.md +++ b/docs/providers/discord.md @@ -33,6 +33,8 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa - Full command list + config: [Slash commands](/tools/slash-commands) 11. Optional guild context history: set `discord.historyLimit` (default 20) to include the last N guild messages as context when replying to a mention. Set `0` to disable. 12. Reactions: the agent can trigger reactions via the `discord` tool (gated by `discord.actions.*`). + - `emoji=""` removes the bot's reaction(s) on the message. + - `remove: true` removes the specific emoji reaction. - The `discord` tool is only exposed when the current provider is Discord. 13. Native commands use isolated session keys (`discord:slash:${userId}`) rather than the shared `main` session. diff --git a/docs/providers/slack.md b/docs/providers/slack.md index f0bf2a5e9..4c7ae1fa2 100644 --- a/docs/providers/slack.md +++ b/docs/providers/slack.md @@ -222,4 +222,5 @@ Slack tool actions can be gated with `slack.actions.*`: ## Notes - Mention gating is controlled via `slack.channels` (set `requireMention` to `true`); `routing.groupChat.mentionPatterns` also count as mentions. - Reaction notifications follow `slack.reactionNotifications` (use `reactionAllowlist` with mode `allowlist`). +- For the Slack tool, `emoji=""` removes the bot's reaction(s) on the message; `remove: true` removes a specific emoji reaction. - Attachments are downloaded to the media store when permitted and under the size limit. diff --git a/docs/providers/telegram.md b/docs/providers/telegram.md index 93f6203bf..6d18ff123 100644 --- a/docs/providers/telegram.md +++ b/docs/providers/telegram.md @@ -69,6 +69,12 @@ Telegram supports optional threaded replies via tags: Controlled by `telegram.replyToMode`: - `off` (default), `first`, `all`. +## Agent tool (reactions) +- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`). +- `emoji=""` removes the bot's reaction(s) on the message. +- `remove: true` removes the reaction (Telegram only supports removing your own reaction). +- Tool gating: `telegram.actions.reactions` (default: enabled). + ## Delivery targets (CLI/cron) - Use a chat id (`123456789`) or a username (`@name`) as the target. - Example: `clawdbot send --provider telegram --to 123456789 "hi"`. @@ -92,6 +98,7 @@ Provider options: - `telegram.webhookUrl`: enable webhook mode. - `telegram.webhookSecret`: webhook secret (optional). - `telegram.webhookPath`: local webhook path (default `/telegram-webhook`). +- `telegram.actions.reactions`: gate Telegram tool reactions. Related global options: - `routing.groupChat.mentionPatterns` (mention gating patterns). diff --git a/docs/providers/whatsapp.md b/docs/providers/whatsapp.md index e674125b1..53e9f880d 100644 --- a/docs/providers/whatsapp.md +++ b/docs/providers/whatsapp.md @@ -94,6 +94,13 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number - WhatsApp Web sends standard messages (no quoted reply threading in the current gateway). - Reply tags are ignored on this provider. +## Agent tool (reactions) +- Tool: `whatsapp` with `react` action (`chatJid`, `messageId`, `emoji`, optional `remove`). +- Optional: `participant` (group sender), `fromMe` (reacting to your own message), `accountId` (multi-account). +- `emoji=""` removes the bot's reaction(s) on the message. +- `remove: true` removes the bot's reaction (same effect as empty emoji). +- Tool gating: `whatsapp.actions.reactions` (default: enabled). + ## Outbound send (text + media) - Uses active web listener; error if gateway not running. - Text chunking: 4k max per message. @@ -131,6 +138,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number - `whatsapp.groupAllowFrom` (group sender allowlist). - `whatsapp.groupPolicy` (group policy). - `whatsapp.groups` (group allowlist + mention gating defaults; use `"*"` to allow all) +- `whatsapp.actions.reactions` (gate WhatsApp tool reactions). - `routing.groupChat.mentionPatterns` - `routing.groupChat.historyLimit` - `messages.messagePrefix` (inbound prefix) diff --git a/docs/tools/index.md b/docs/tools/index.md index f19c4d028..f6f6d2b94 100644 --- a/docs/tools/index.md +++ b/docs/tools/index.md @@ -201,10 +201,36 @@ Notes: - `to` accepts `channel:` or `user:`. - Polls require 2–10 answers and default to 24 hours. - `reactions` returns per-emoji user lists (limited to 100 per reaction). +- `emoji=""` on `react` removes the bot's reaction(s) on the message. +- `remove: true` on `react` removes just that emoji. - `discord.actions.*` gates Discord tool actions; `roles` + `moderation` default to `false`. - `searchMessages` follows the Discord preview spec (limit max 25, channel/author filters accept arrays). - The tool is only exposed when the current provider is Discord. +### `whatsapp` +Send WhatsApp reactions. + +Core actions: +- `react` (`chatJid`, `messageId`, `emoji`, optional `remove`, `participant`, `fromMe`, `accountId`) + +Notes: +- `emoji=""` removes the bot's reaction(s) on the message. +- `remove: true` removes the bot's reaction (same effect as empty emoji). +- `whatsapp.actions.*` gates WhatsApp tool actions. +- The tool is only exposed when the current provider is WhatsApp. + +### `telegram` +Send Telegram reactions. + +Core actions: +- `react` (`chatId`, `messageId`, `emoji`, optional `remove`) + +Notes: +- `emoji=""` removes the bot's reaction(s) on the message. +- `remove: true` removes the reaction (Telegram only supports removing your own reaction). +- `telegram.actions.*` gates Telegram tool actions. +- The tool is only exposed when the current provider is Telegram. + ## Parameters (common) Gateway-backed tools (`canvas`, `nodes`, `cron`): diff --git a/src/agents/clawdbot-tools.ts b/src/agents/clawdbot-tools.ts index 0cab51d14..ecc866a3d 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 { createTelegramTool } from "./tools/telegram-tool.js"; import { createWhatsAppTool } from "./tools/whatsapp-tool.js"; export function createClawdbotTools(options?: { @@ -33,6 +34,7 @@ export function createClawdbotTools(options?: { createCronTool(), createDiscordTool(), createSlackTool(), + createTelegramTool(), createWhatsAppTool(), createGatewayTool(), createSessionsListTool({ diff --git a/src/agents/pi-tools.test.ts b/src/agents/pi-tools.test.ts index 4dd83fdbd..3c14b7519 100644 --- a/src/agents/pi-tools.test.ts +++ b/src/agents/pi-tools.test.ts @@ -116,6 +116,22 @@ describe("createClawdbotCodingTools", () => { expect(slack.some((tool) => tool.name === "slack")).toBe(true); }); + it("scopes telegram tool to telegram provider", () => { + const other = createClawdbotCodingTools({ messageProvider: "whatsapp" }); + expect(other.some((tool) => tool.name === "telegram")).toBe(false); + + const telegram = createClawdbotCodingTools({ messageProvider: "telegram" }); + expect(telegram.some((tool) => tool.name === "telegram")).toBe(true); + }); + + it("scopes whatsapp tool to whatsapp provider", () => { + const other = createClawdbotCodingTools({ messageProvider: "slack" }); + expect(other.some((tool) => tool.name === "whatsapp")).toBe(false); + + const whatsapp = createClawdbotCodingTools({ messageProvider: "whatsapp" }); + expect(whatsapp.some((tool) => tool.name === "whatsapp")).toBe(true); + }); + it("filters session tools for sub-agent sessions by default", () => { const tools = createClawdbotCodingTools({ sessionKey: "agent:main:subagent:test", diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts index 8846e9c13..4dd201caa 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 shouldIncludeTelegramTool(messageProvider?: string): boolean { + const normalized = normalizeMessageProvider(messageProvider); + if (!normalized) return false; + return normalized === "telegram" || normalized.startsWith("telegram:"); +} + function shouldIncludeWhatsAppTool(messageProvider?: string): boolean { const normalized = normalizeMessageProvider(messageProvider); if (!normalized) return false; @@ -568,10 +574,12 @@ export function createClawdbotCodingTools(options?: { ]; const allowDiscord = shouldIncludeDiscordTool(options?.messageProvider); const allowSlack = shouldIncludeSlackTool(options?.messageProvider); + const allowTelegram = shouldIncludeTelegramTool(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 === "telegram") return allowTelegram; if (tool.name === "whatsapp") return allowWhatsApp; return true; }); diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index d5c40f51b..9d1dc5c18 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -45,6 +45,8 @@ export function buildAgentSystemPromptAppend(params: { image: "Analyze an image with the configured image model", discord: "Send Discord reactions/messages and manage threads", slack: "Send Slack messages and manage channels", + telegram: "Send Telegram reactions", + whatsapp: "Send WhatsApp reactions", }; const toolOrder = [ @@ -68,6 +70,8 @@ export function buildAgentSystemPromptAppend(params: { "image", "discord", "slack", + "telegram", + "whatsapp", ]; const normalizedTools = (params.toolNames ?? []) diff --git a/src/agents/tool-display.json b/src/agents/tool-display.json index 0e53edbd3..fb02b91f4 100644 --- a/src/agents/tool-display.json +++ b/src/agents/tool-display.json @@ -182,7 +182,7 @@ "emoji": "💬", "title": "Discord", "actions": { - "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] }, + "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] }, "reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] }, "sticker": { "label": "sticker", "detailKeys": ["to", "stickerIds"] }, "poll": { "label": "poll", "detailKeys": ["question", "to"] }, @@ -219,7 +219,7 @@ "emoji": "💬", "title": "Slack", "actions": { - "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji"] }, + "react": { "label": "react", "detailKeys": ["channelId", "messageId", "emoji", "remove"] }, "reactions": { "label": "reactions", "detailKeys": ["channelId", "messageId"] }, "sendMessage": { "label": "send", "detailKeys": ["to", "content"] }, "editMessage": { "label": "edit", "detailKeys": ["channelId", "messageId"] }, @@ -232,11 +232,21 @@ "emojiList": { "label": "emoji list" } } }, + "telegram": { + "emoji": "✈️", + "title": "Telegram", + "actions": { + "react": { "label": "react", "detailKeys": ["chatId", "messageId", "emoji", "remove"] } + } + }, "whatsapp": { "emoji": "💬", "title": "WhatsApp", "actions": { - "react": { "label": "react", "detailKeys": ["chatJid", "messageId", "emoji"] } + "react": { + "label": "react", + "detailKeys": ["chatJid", "messageId", "emoji", "remove", "participant", "accountId", "fromMe"] + } } } } diff --git a/src/agents/tools/common.test.ts b/src/agents/tools/common.test.ts new file mode 100644 index 000000000..86d2a5ea8 --- /dev/null +++ b/src/agents/tools/common.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "vitest"; + +import { + createActionGate, + readNumberParam, + readReactionParams, + readStringOrNumberParam, +} from "./common.js"; + +type TestActions = { + reactions?: boolean; + messages?: boolean; +}; + +describe("createActionGate", () => { + it("defaults to enabled when unset", () => { + const gate = createActionGate(undefined); + expect(gate("reactions")).toBe(true); + expect(gate("messages", false)).toBe(false); + }); + + it("respects explicit false", () => { + const gate = createActionGate({ reactions: false }); + expect(gate("reactions")).toBe(false); + expect(gate("messages")).toBe(true); + }); +}); + +describe("readStringOrNumberParam", () => { + it("returns numeric strings for numbers", () => { + const params = { chatId: 123 }; + expect(readStringOrNumberParam(params, "chatId")).toBe("123"); + }); + + it("trims strings", () => { + const params = { chatId: " abc " }; + expect(readStringOrNumberParam(params, "chatId")).toBe("abc"); + }); + + it("throws when required and missing", () => { + expect(() => + readStringOrNumberParam({}, "chatId", { required: true }), + ).toThrow(/chatId required/); + }); +}); + +describe("readNumberParam", () => { + it("parses numeric strings", () => { + const params = { messageId: "42" }; + expect(readNumberParam(params, "messageId")).toBe(42); + }); + + it("truncates when integer is true", () => { + const params = { messageId: "42.9" }; + expect(readNumberParam(params, "messageId", { integer: true })).toBe(42); + }); + + it("throws when required and missing", () => { + expect(() => readNumberParam({}, "messageId", { required: true })).toThrow( + /messageId required/, + ); + }); +}); + +describe("readReactionParams", () => { + it("allows empty emoji for removal semantics", () => { + const params = { emoji: "" }; + const result = readReactionParams(params, { + removeErrorMessage: "Emoji is required", + }); + expect(result.isEmpty).toBe(true); + expect(result.remove).toBe(false); + }); + + it("throws when remove true but emoji empty", () => { + const params = { emoji: "", remove: true }; + expect(() => + readReactionParams(params, { + removeErrorMessage: "Emoji is required", + }), + ).toThrow(/Emoji is required/); + }); + + it("passes through remove flag", () => { + const params = { emoji: "✅", remove: true }; + const result = readReactionParams(params, { + removeErrorMessage: "Emoji is required", + }); + expect(result.remove).toBe(true); + expect(result.emoji).toBe("✅"); + }); +}); diff --git a/src/agents/tools/common.ts b/src/agents/tools/common.ts index b7aa04d02..c5dfade13 100644 --- a/src/agents/tools/common.ts +++ b/src/agents/tools/common.ts @@ -12,8 +12,24 @@ export type StringParamOptions = { required?: boolean; trim?: boolean; label?: string; + allowEmpty?: boolean; }; +export type ActionGate> = ( + key: keyof T, + defaultValue?: boolean, +) => boolean; + +export function createActionGate>( + actions: T | undefined, +): ActionGate { + return (key, defaultValue = true) => { + const value = actions?.[key]; + if (value === undefined) return defaultValue; + return value !== false; + }; +} + export function readStringParam( params: Record, key: string, @@ -29,20 +45,67 @@ export function readStringParam( key: string, options: StringParamOptions = {}, ) { - const { required = false, trim = true, label = key } = options; + const { + required = false, + trim = true, + label = key, + allowEmpty = false, + } = options; const raw = params[key]; if (typeof raw !== "string") { if (required) throw new Error(`${label} required`); return undefined; } const value = trim ? raw.trim() : raw; - if (!value) { + if (!value && !allowEmpty) { if (required) throw new Error(`${label} required`); return undefined; } return value; } +export function readStringOrNumberParam( + params: Record, + key: string, + options: { required?: boolean; label?: string } = {}, +): string | undefined { + const { required = false, label = key } = options; + const raw = params[key]; + if (typeof raw === "number" && Number.isFinite(raw)) { + return String(raw); + } + if (typeof raw === "string") { + const value = raw.trim(); + if (value) return value; + } + if (required) throw new Error(`${label} required`); + return undefined; +} + +export function readNumberParam( + params: Record, + key: string, + options: { required?: boolean; label?: string; integer?: boolean } = {}, +): number | undefined { + const { required = false, label = key, integer = false } = options; + const raw = params[key]; + let value: number | undefined; + if (typeof raw === "number" && Number.isFinite(raw)) { + value = raw; + } else if (typeof raw === "string") { + const trimmed = raw.trim(); + if (trimmed) { + const parsed = Number.parseFloat(trimmed); + if (Number.isFinite(parsed)) value = parsed; + } + } + if (value === undefined) { + if (required) throw new Error(`${label} required`); + return undefined; + } + return integer ? Math.trunc(value) : value; +} + export function readStringArrayParam( params: Record, key: string, @@ -83,6 +146,34 @@ export function readStringArrayParam( return undefined; } +export type ReactionParams = { + emoji: string; + remove: boolean; + isEmpty: boolean; +}; + +export function readReactionParams( + params: Record, + options: { + emojiKey?: string; + removeKey?: string; + removeErrorMessage: string; + }, +): ReactionParams { + const emojiKey = options.emojiKey ?? "emoji"; + const removeKey = options.removeKey ?? "remove"; + const remove = + typeof params[removeKey] === "boolean" ? params[removeKey] : false; + const emoji = readStringParam(params, emojiKey, { + required: true, + allowEmpty: true, + }); + if (remove && !emoji) { + throw new Error(options.removeErrorMessage); + } + return { emoji, remove, isEmpty: !emoji }; +} + export function jsonResult(payload: unknown): AgentToolResult { return { content: [ diff --git a/src/agents/tools/discord-actions-guild.ts b/src/agents/tools/discord-actions-guild.ts index 9be9d3992..536c820ce 100644 --- a/src/agents/tools/discord-actions-guild.ts +++ b/src/agents/tools/discord-actions-guild.ts @@ -14,17 +14,17 @@ import { uploadEmojiDiscord, uploadStickerDiscord, } from "../../discord/send.js"; -import { jsonResult, readStringArrayParam, readStringParam } from "./common.js"; - -type ActionGate = ( - key: keyof DiscordActionConfig, - defaultValue?: boolean, -) => boolean; +import { + type ActionGate, + jsonResult, + readStringArrayParam, + readStringParam, +} from "./common.js"; export async function handleDiscordGuildAction( action: string, params: Record, - isActionEnabled: ActionGate, + isActionEnabled: ActionGate, ): Promise> { switch (action) { case "memberInfo": { diff --git a/src/agents/tools/discord-actions-messaging.ts b/src/agents/tools/discord-actions-messaging.ts index 855a72d8f..d45d8f4a0 100644 --- a/src/agents/tools/discord-actions-messaging.ts +++ b/src/agents/tools/discord-actions-messaging.ts @@ -11,18 +11,21 @@ import { pinMessageDiscord, reactMessageDiscord, readMessagesDiscord, + removeOwnReactionsDiscord, + removeReactionDiscord, searchMessagesDiscord, sendMessageDiscord, sendPollDiscord, sendStickerDiscord, unpinMessageDiscord, } from "../../discord/send.js"; -import { jsonResult, readStringArrayParam, readStringParam } from "./common.js"; - -type ActionGate = ( - key: keyof DiscordActionConfig, - defaultValue?: boolean, -) => boolean; +import { + type ActionGate, + jsonResult, + readReactionParams, + readStringArrayParam, + readStringParam, +} from "./common.js"; function formatDiscordTimestamp(ts?: string | null): string | undefined { if (!ts) return undefined; @@ -53,7 +56,7 @@ function formatDiscordTimestamp(ts?: string | null): string | undefined { export async function handleDiscordMessagingAction( action: string, params: Record, - isActionEnabled: ActionGate, + isActionEnabled: ActionGate, ): Promise> { switch (action) { case "react": { @@ -66,9 +69,19 @@ export async function handleDiscordMessagingAction( const messageId = readStringParam(params, "messageId", { required: true, }); - const emoji = readStringParam(params, "emoji", { required: true }); + const { emoji, remove, isEmpty } = readReactionParams(params, { + removeErrorMessage: "Emoji is required to remove a Discord reaction.", + }); + if (remove) { + await removeReactionDiscord(channelId, messageId, emoji); + return jsonResult({ ok: true, removed: emoji }); + } + if (isEmpty) { + const removed = await removeOwnReactionsDiscord(channelId, messageId); + return jsonResult({ ok: true, removed: removed.removed }); + } await reactMessageDiscord(channelId, messageId, emoji); - return jsonResult({ ok: true }); + return jsonResult({ ok: true, added: emoji }); } case "reactions": { if (!isActionEnabled("reactions")) { diff --git a/src/agents/tools/discord-actions-moderation.ts b/src/agents/tools/discord-actions-moderation.ts index ccbf2105c..7b2803f2c 100644 --- a/src/agents/tools/discord-actions-moderation.ts +++ b/src/agents/tools/discord-actions-moderation.ts @@ -5,17 +5,12 @@ import { kickMemberDiscord, timeoutMemberDiscord, } from "../../discord/send.js"; -import { jsonResult, readStringParam } from "./common.js"; - -type ActionGate = ( - key: keyof DiscordActionConfig, - defaultValue?: boolean, -) => boolean; +import { type ActionGate, jsonResult, readStringParam } from "./common.js"; export async function handleDiscordModerationAction( action: string, params: Record, - isActionEnabled: ActionGate, + isActionEnabled: ActionGate, ): Promise> { switch (action) { case "timeout": { diff --git a/src/agents/tools/discord-actions.test.ts b/src/agents/tools/discord-actions.test.ts new file mode 100644 index 000000000..ec8371b08 --- /dev/null +++ b/src/agents/tools/discord-actions.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { DiscordActionConfig } from "../../config/config.js"; +import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; + +const createThreadDiscord = vi.fn(async () => ({})); +const deleteMessageDiscord = vi.fn(async () => ({})); +const editMessageDiscord = vi.fn(async () => ({})); +const fetchChannelPermissionsDiscord = vi.fn(async () => ({})); +const fetchReactionsDiscord = vi.fn(async () => ({})); +const listPinsDiscord = vi.fn(async () => ({})); +const listThreadsDiscord = vi.fn(async () => ({})); +const pinMessageDiscord = vi.fn(async () => ({})); +const reactMessageDiscord = vi.fn(async () => ({})); +const readMessagesDiscord = vi.fn(async () => []); +const removeOwnReactionsDiscord = vi.fn(async () => ({ removed: ["👍"] })); +const removeReactionDiscord = vi.fn(async () => ({})); +const searchMessagesDiscord = vi.fn(async () => ({})); +const sendMessageDiscord = vi.fn(async () => ({})); +const sendPollDiscord = vi.fn(async () => ({})); +const sendStickerDiscord = vi.fn(async () => ({})); +const unpinMessageDiscord = vi.fn(async () => ({})); + +vi.mock("../../discord/send.js", () => ({ + createThreadDiscord: (...args: unknown[]) => createThreadDiscord(...args), + deleteMessageDiscord: (...args: unknown[]) => deleteMessageDiscord(...args), + editMessageDiscord: (...args: unknown[]) => editMessageDiscord(...args), + fetchChannelPermissionsDiscord: (...args: unknown[]) => + fetchChannelPermissionsDiscord(...args), + fetchReactionsDiscord: (...args: unknown[]) => fetchReactionsDiscord(...args), + listPinsDiscord: (...args: unknown[]) => listPinsDiscord(...args), + listThreadsDiscord: (...args: unknown[]) => listThreadsDiscord(...args), + pinMessageDiscord: (...args: unknown[]) => pinMessageDiscord(...args), + reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args), + readMessagesDiscord: (...args: unknown[]) => readMessagesDiscord(...args), + removeOwnReactionsDiscord: (...args: unknown[]) => + removeOwnReactionsDiscord(...args), + removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args), + searchMessagesDiscord: (...args: unknown[]) => searchMessagesDiscord(...args), + sendMessageDiscord: (...args: unknown[]) => sendMessageDiscord(...args), + sendPollDiscord: (...args: unknown[]) => sendPollDiscord(...args), + sendStickerDiscord: (...args: unknown[]) => sendStickerDiscord(...args), + unpinMessageDiscord: (...args: unknown[]) => unpinMessageDiscord(...args), +})); + +const enableAllActions = () => true; + +const disabledActions = (key: keyof DiscordActionConfig) => key !== "reactions"; + +describe("handleDiscordMessagingAction", () => { + it("adds reactions", async () => { + await handleDiscordMessagingAction( + "react", + { + channelId: "C1", + messageId: "M1", + emoji: "✅", + }, + enableAllActions, + ); + expect(reactMessageDiscord).toHaveBeenCalledWith("C1", "M1", "✅"); + }); + + it("removes reactions on empty emoji", async () => { + await handleDiscordMessagingAction( + "react", + { + channelId: "C1", + messageId: "M1", + emoji: "", + }, + enableAllActions, + ); + expect(removeOwnReactionsDiscord).toHaveBeenCalledWith("C1", "M1"); + }); + + it("removes reactions when remove flag set", async () => { + await handleDiscordMessagingAction( + "react", + { + channelId: "C1", + messageId: "M1", + emoji: "✅", + remove: true, + }, + enableAllActions, + ); + expect(removeReactionDiscord).toHaveBeenCalledWith("C1", "M1", "✅"); + }); + + it("rejects removes without emoji", async () => { + await expect( + handleDiscordMessagingAction( + "react", + { + channelId: "C1", + messageId: "M1", + emoji: "", + remove: true, + }, + enableAllActions, + ), + ).rejects.toThrow(/Emoji is required/); + }); + + it("respects reaction gating", async () => { + await expect( + handleDiscordMessagingAction( + "react", + { + channelId: "C1", + messageId: "M1", + emoji: "✅", + }, + disabledActions, + ), + ).rejects.toThrow(/Discord reactions are disabled/); + }); +}); diff --git a/src/agents/tools/discord-actions.ts b/src/agents/tools/discord-actions.ts index 3c5bad678..9cb2ec10e 100644 --- a/src/agents/tools/discord-actions.ts +++ b/src/agents/tools/discord-actions.ts @@ -1,9 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { - ClawdbotConfig, - DiscordActionConfig, -} from "../../config/config.js"; -import { readStringParam } from "./common.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { createActionGate, readStringParam } from "./common.js"; import { handleDiscordGuildAction } from "./discord-actions-guild.js"; import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; import { handleDiscordModerationAction } from "./discord-actions-moderation.js"; @@ -44,21 +41,12 @@ const guildActions = new Set([ const moderationActions = new Set(["timeout", "kick", "ban"]); -type ActionGate = ( - key: keyof DiscordActionConfig, - defaultValue?: boolean, -) => boolean; - export async function handleDiscordAction( params: Record, cfg: ClawdbotConfig, ): Promise> { const action = readStringParam(params, "action", { required: true }); - const isActionEnabled: ActionGate = (key, defaultValue = true) => { - const value = cfg.discord?.actions?.[key]; - if (value === undefined) return defaultValue; - return value !== false; - }; + const isActionEnabled = createActionGate(cfg.discord?.actions); if (messagingActions.has(action)) { return await handleDiscordMessagingAction(action, params, isActionEnabled); diff --git a/src/agents/tools/discord-schema.ts b/src/agents/tools/discord-schema.ts index 25089f783..05292f375 100644 --- a/src/agents/tools/discord-schema.ts +++ b/src/agents/tools/discord-schema.ts @@ -6,6 +6,7 @@ export const DiscordToolSchema = Type.Union([ channelId: Type.String(), messageId: Type.String(), emoji: Type.String(), + remove: Type.Optional(Type.Boolean()), }), Type.Object({ action: Type.Literal("reactions"), diff --git a/src/agents/tools/slack-actions.test.ts b/src/agents/tools/slack-actions.test.ts new file mode 100644 index 000000000..8f2c3e9f8 --- /dev/null +++ b/src/agents/tools/slack-actions.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import { handleSlackAction } from "./slack-actions.js"; + +const deleteSlackMessage = vi.fn(async () => ({})); +const editSlackMessage = vi.fn(async () => ({})); +const getSlackMemberInfo = vi.fn(async () => ({})); +const listSlackEmojis = vi.fn(async () => ({})); +const listSlackPins = vi.fn(async () => ({})); +const listSlackReactions = vi.fn(async () => ({})); +const pinSlackMessage = vi.fn(async () => ({})); +const reactSlackMessage = vi.fn(async () => ({})); +const readSlackMessages = vi.fn(async () => ({})); +const removeOwnSlackReactions = vi.fn(async () => ["thumbsup"]); +const removeSlackReaction = vi.fn(async () => ({})); +const sendSlackMessage = vi.fn(async () => ({})); +const unpinSlackMessage = vi.fn(async () => ({})); + +vi.mock("../../slack/actions.js", () => ({ + deleteSlackMessage: (...args: unknown[]) => deleteSlackMessage(...args), + editSlackMessage: (...args: unknown[]) => editSlackMessage(...args), + getSlackMemberInfo: (...args: unknown[]) => getSlackMemberInfo(...args), + listSlackEmojis: (...args: unknown[]) => listSlackEmojis(...args), + listSlackPins: (...args: unknown[]) => listSlackPins(...args), + listSlackReactions: (...args: unknown[]) => listSlackReactions(...args), + pinSlackMessage: (...args: unknown[]) => pinSlackMessage(...args), + reactSlackMessage: (...args: unknown[]) => reactSlackMessage(...args), + readSlackMessages: (...args: unknown[]) => readSlackMessages(...args), + removeOwnSlackReactions: (...args: unknown[]) => + removeOwnSlackReactions(...args), + removeSlackReaction: (...args: unknown[]) => removeSlackReaction(...args), + sendSlackMessage: (...args: unknown[]) => sendSlackMessage(...args), + unpinSlackMessage: (...args: unknown[]) => unpinSlackMessage(...args), +})); + +describe("handleSlackAction", () => { + it("adds reactions", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + await handleSlackAction( + { + action: "react", + channelId: "C1", + messageId: "123.456", + emoji: "✅", + }, + cfg, + ); + expect(reactSlackMessage).toHaveBeenCalledWith("C1", "123.456", "✅"); + }); + + it("removes reactions on empty emoji", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + await handleSlackAction( + { + action: "react", + channelId: "C1", + messageId: "123.456", + emoji: "", + }, + cfg, + ); + expect(removeOwnSlackReactions).toHaveBeenCalledWith("C1", "123.456"); + }); + + it("removes reactions when remove flag set", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + await handleSlackAction( + { + action: "react", + channelId: "C1", + messageId: "123.456", + emoji: "✅", + remove: true, + }, + cfg, + ); + expect(removeSlackReaction).toHaveBeenCalledWith("C1", "123.456", "✅"); + }); + + it("rejects removes without emoji", async () => { + const cfg = { slack: { botToken: "tok" } } as ClawdbotConfig; + await expect( + handleSlackAction( + { + action: "react", + channelId: "C1", + messageId: "123.456", + emoji: "", + remove: true, + }, + cfg, + ), + ).rejects.toThrow(/Emoji is required/); + }); + + it("respects reaction gating", async () => { + const cfg = { + slack: { botToken: "tok", actions: { reactions: false } }, + } as ClawdbotConfig; + await expect( + handleSlackAction( + { + action: "react", + channelId: "C1", + messageId: "123.456", + emoji: "✅", + }, + cfg, + ), + ).rejects.toThrow(/Slack reactions are disabled/); + }); +}); diff --git a/src/agents/tools/slack-actions.ts b/src/agents/tools/slack-actions.ts index 7009c0602..48b5b9a18 100644 --- a/src/agents/tools/slack-actions.ts +++ b/src/agents/tools/slack-actions.ts @@ -1,6 +1,6 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { ClawdbotConfig, SlackActionConfig } from "../../config/config.js"; +import type { ClawdbotConfig } from "../../config/config.js"; import { deleteSlackMessage, editSlackMessage, @@ -11,10 +11,17 @@ import { pinSlackMessage, reactSlackMessage, readSlackMessages, + removeOwnSlackReactions, + removeSlackReaction, sendSlackMessage, unpinSlackMessage, } from "../../slack/actions.js"; -import { jsonResult, readStringParam } from "./common.js"; +import { + createActionGate, + jsonResult, + readReactionParams, + readStringParam, +} from "./common.js"; const messagingActions = new Set([ "sendMessage", @@ -26,21 +33,12 @@ const messagingActions = new Set([ const reactionsActions = new Set(["react", "reactions"]); const pinActions = new Set(["pinMessage", "unpinMessage", "listPins"]); -type ActionGate = ( - key: keyof SlackActionConfig, - defaultValue?: boolean, -) => boolean; - export async function handleSlackAction( params: Record, cfg: ClawdbotConfig, ): Promise> { const action = readStringParam(params, "action", { required: true }); - const isActionEnabled: ActionGate = (key, defaultValue = true) => { - const value = cfg.slack?.actions?.[key]; - if (value === undefined) return defaultValue; - return value !== false; - }; + const isActionEnabled = createActionGate(cfg.slack?.actions); if (reactionsActions.has(action)) { if (!isActionEnabled("reactions")) { @@ -49,9 +47,19 @@ export async function handleSlackAction( const channelId = readStringParam(params, "channelId", { required: true }); const messageId = readStringParam(params, "messageId", { required: true }); if (action === "react") { - const emoji = readStringParam(params, "emoji", { required: true }); + const { emoji, remove, isEmpty } = readReactionParams(params, { + removeErrorMessage: "Emoji is required to remove a Slack reaction.", + }); + if (remove) { + await removeSlackReaction(channelId, messageId, emoji); + return jsonResult({ ok: true, removed: emoji }); + } + if (isEmpty) { + const removed = await removeOwnSlackReactions(channelId, messageId); + return jsonResult({ ok: true, removed }); + } await reactSlackMessage(channelId, messageId, emoji); - return jsonResult({ ok: true }); + return jsonResult({ ok: true, added: emoji }); } const reactions = await listSlackReactions(channelId, messageId); return jsonResult({ ok: true, reactions }); diff --git a/src/agents/tools/slack-schema.ts b/src/agents/tools/slack-schema.ts index 84b3a715c..b94ce7c47 100644 --- a/src/agents/tools/slack-schema.ts +++ b/src/agents/tools/slack-schema.ts @@ -6,6 +6,7 @@ export const SlackToolSchema = Type.Union([ channelId: Type.String(), messageId: Type.String(), emoji: Type.String(), + remove: Type.Optional(Type.Boolean()), }), Type.Object({ action: Type.Literal("reactions"), diff --git a/src/agents/tools/telegram-actions.test.ts b/src/agents/tools/telegram-actions.test.ts new file mode 100644 index 000000000..0863ed2e5 --- /dev/null +++ b/src/agents/tools/telegram-actions.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import { handleTelegramAction } from "./telegram-actions.js"; + +const reactMessageTelegram = vi.fn(async () => ({ ok: true })); +const originalToken = process.env.TELEGRAM_BOT_TOKEN; + +vi.mock("../../telegram/send.js", () => ({ + reactMessageTelegram: (...args: unknown[]) => reactMessageTelegram(...args), +})); + +describe("handleTelegramAction", () => { + beforeEach(() => { + reactMessageTelegram.mockClear(); + process.env.TELEGRAM_BOT_TOKEN = "tok"; + }); + + afterEach(() => { + if (originalToken === undefined) { + delete process.env.TELEGRAM_BOT_TOKEN; + } else { + process.env.TELEGRAM_BOT_TOKEN = originalToken; + } + }); + + it("adds reactions", async () => { + const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig; + await handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: "456", + emoji: "✅", + }, + cfg, + ); + expect(reactMessageTelegram).toHaveBeenCalledWith("123", 456, "✅", { + token: "tok", + remove: false, + }); + }); + + it("removes reactions on empty emoji", async () => { + const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig; + await handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: "456", + emoji: "", + }, + cfg, + ); + expect(reactMessageTelegram).toHaveBeenCalledWith("123", 456, "", { + token: "tok", + remove: false, + }); + }); + + it("removes reactions when remove flag set", async () => { + const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig; + await handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: "456", + emoji: "✅", + remove: true, + }, + cfg, + ); + expect(reactMessageTelegram).toHaveBeenCalledWith("123", 456, "✅", { + token: "tok", + remove: true, + }); + }); + + it("respects reaction gating", async () => { + const cfg = { + telegram: { botToken: "tok", actions: { reactions: false } }, + } as ClawdbotConfig; + await expect( + handleTelegramAction( + { + action: "react", + chatId: "123", + messageId: "456", + emoji: "✅", + }, + cfg, + ), + ).rejects.toThrow(/Telegram reactions are disabled/); + }); +}); diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts new file mode 100644 index 000000000..11fbf8a84 --- /dev/null +++ b/src/agents/tools/telegram-actions.ts @@ -0,0 +1,53 @@ +import type { AgentToolResult } from "@mariozechner/pi-agent-core"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import { reactMessageTelegram } from "../../telegram/send.js"; +import { resolveTelegramToken } from "../../telegram/token.js"; +import { + createActionGate, + jsonResult, + readNumberParam, + readReactionParams, + readStringOrNumberParam, + readStringParam, +} from "./common.js"; + +export async function handleTelegramAction( + params: Record, + cfg: ClawdbotConfig, +): Promise> { + const action = readStringParam(params, "action", { required: true }); + const isActionEnabled = createActionGate(cfg.telegram?.actions); + + if (action === "react") { + if (!isActionEnabled("reactions")) { + throw new Error("Telegram reactions are disabled."); + } + const chatId = readStringOrNumberParam(params, "chatId", { + required: true, + }); + const messageId = readNumberParam(params, "messageId", { + required: true, + integer: true, + }); + const { emoji, remove, isEmpty } = readReactionParams(params, { + removeErrorMessage: "Emoji is required to remove a Telegram reaction.", + }); + const token = resolveTelegramToken(cfg).token; + if (!token) { + throw new Error( + "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or telegram.botToken.", + ); + } + await reactMessageTelegram(chatId ?? "", messageId ?? 0, emoji ?? "", { + token, + remove, + }); + if (!remove && !isEmpty) { + return jsonResult({ ok: true, added: emoji }); + } + return jsonResult({ ok: true, removed: true }); + } + + throw new Error(`Unsupported Telegram action: ${action}`); +} diff --git a/src/agents/tools/telegram-schema.ts b/src/agents/tools/telegram-schema.ts new file mode 100644 index 000000000..d967649f5 --- /dev/null +++ b/src/agents/tools/telegram-schema.ts @@ -0,0 +1,11 @@ +import { Type } from "@sinclair/typebox"; + +export const TelegramToolSchema = Type.Union([ + Type.Object({ + action: Type.Literal("react"), + chatId: Type.Union([Type.String(), Type.Number()]), + messageId: Type.Union([Type.String(), Type.Number()]), + emoji: Type.String(), + remove: Type.Optional(Type.Boolean()), + }), +]); diff --git a/src/agents/tools/telegram-tool.ts b/src/agents/tools/telegram-tool.ts new file mode 100644 index 000000000..4506c00f1 --- /dev/null +++ b/src/agents/tools/telegram-tool.ts @@ -0,0 +1,18 @@ +import { loadConfig } from "../../config/config.js"; +import type { AnyAgentTool } from "./common.js"; +import { handleTelegramAction } from "./telegram-actions.js"; +import { TelegramToolSchema } from "./telegram-schema.js"; + +export function createTelegramTool(): AnyAgentTool { + return { + label: "Telegram", + name: "telegram", + description: "Manage Telegram reactions.", + parameters: TelegramToolSchema, + execute: async (_toolCallId, args) => { + const params = args as Record; + const cfg = loadConfig(); + return await handleTelegramAction(params, cfg); + }, + }; +} diff --git a/src/agents/tools/whatsapp-actions.test.ts b/src/agents/tools/whatsapp-actions.test.ts new file mode 100644 index 000000000..a0a7af13e --- /dev/null +++ b/src/agents/tools/whatsapp-actions.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { ClawdbotConfig } from "../../config/config.js"; +import { handleWhatsAppAction } from "./whatsapp-actions.js"; + +const sendReactionWhatsApp = vi.fn(async () => undefined); + +vi.mock("../../web/outbound.js", () => ({ + sendReactionWhatsApp: (...args: unknown[]) => sendReactionWhatsApp(...args), +})); + +const enabledConfig = { + whatsapp: { actions: { reactions: true } }, +} as ClawdbotConfig; + +describe("handleWhatsAppAction", () => { + it("adds reactions", async () => { + await handleWhatsAppAction( + { + action: "react", + chatJid: "123@s.whatsapp.net", + messageId: "msg1", + emoji: "✅", + }, + enabledConfig, + ); + expect(sendReactionWhatsApp).toHaveBeenCalledWith( + "123@s.whatsapp.net", + "msg1", + "✅", + { + verbose: false, + fromMe: undefined, + participant: undefined, + accountId: undefined, + }, + ); + }); + + it("removes reactions on empty emoji", async () => { + await handleWhatsAppAction( + { + action: "react", + chatJid: "123@s.whatsapp.net", + messageId: "msg1", + emoji: "", + }, + enabledConfig, + ); + expect(sendReactionWhatsApp).toHaveBeenCalledWith( + "123@s.whatsapp.net", + "msg1", + "", + { + verbose: false, + fromMe: undefined, + participant: undefined, + accountId: undefined, + }, + ); + }); + + it("removes reactions when remove flag set", async () => { + await handleWhatsAppAction( + { + action: "react", + chatJid: "123@s.whatsapp.net", + messageId: "msg1", + emoji: "✅", + remove: true, + }, + enabledConfig, + ); + expect(sendReactionWhatsApp).toHaveBeenCalledWith( + "123@s.whatsapp.net", + "msg1", + "", + { + verbose: false, + fromMe: undefined, + participant: undefined, + accountId: undefined, + }, + ); + }); + + it("passes account scope and sender flags", async () => { + await handleWhatsAppAction( + { + action: "react", + chatJid: "123@s.whatsapp.net", + messageId: "msg1", + emoji: "🎉", + accountId: "work", + fromMe: true, + participant: "999@s.whatsapp.net", + }, + enabledConfig, + ); + expect(sendReactionWhatsApp).toHaveBeenCalledWith( + "123@s.whatsapp.net", + "msg1", + "🎉", + { + verbose: false, + fromMe: true, + participant: "999@s.whatsapp.net", + accountId: "work", + }, + ); + }); + + it("respects reaction gating", async () => { + const cfg = { + whatsapp: { actions: { reactions: false } }, + } as ClawdbotConfig; + await expect( + handleWhatsAppAction( + { + action: "react", + chatJid: "123@s.whatsapp.net", + messageId: "msg1", + emoji: "✅", + }, + cfg, + ), + ).rejects.toThrow(/WhatsApp reactions are disabled/); + }); +}); diff --git a/src/agents/tools/whatsapp-actions.ts b/src/agents/tools/whatsapp-actions.ts index 52bcf279e..23e2833ae 100644 --- a/src/agents/tools/whatsapp-actions.ts +++ b/src/agents/tools/whatsapp-actions.ts @@ -1,29 +1,20 @@ import type { AgentToolResult } from "@mariozechner/pi-agent-core"; -import type { - ClawdbotConfig, - WhatsAppActionConfig, -} from "../../config/config.js"; -import { isSelfChatMode } from "../../utils.js"; +import type { ClawdbotConfig } from "../../config/config.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; +import { + createActionGate, + jsonResult, + readReactionParams, + readStringParam, +} from "./common.js"; 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; - }; + const isActionEnabled = createActionGate(cfg.whatsapp?.actions); if (action === "react") { if (!isActionEnabled("reactions")) { @@ -31,16 +22,24 @@ export async function handleWhatsAppAction( } const chatJid = readStringParam(params, "chatJid", { required: true }); const messageId = readStringParam(params, "messageId", { required: true }); - const emoji = readStringParam(params, "emoji", { required: true }); + const { emoji, remove, isEmpty } = readReactionParams(params, { + removeErrorMessage: "Emoji is required to remove a WhatsApp reaction.", + }); const participant = readStringParam(params, "participant"); - const selfE164 = readWebSelfId().e164; - const fromMe = isSelfChatMode(selfE164, cfg.whatsapp?.allowFrom); - await sendReactionWhatsApp(chatJid, messageId, emoji, { + const accountId = readStringParam(params, "accountId"); + const fromMeRaw = params.fromMe; + const fromMe = typeof fromMeRaw === "boolean" ? fromMeRaw : undefined; + const resolvedEmoji = remove ? "" : emoji; + await sendReactionWhatsApp(chatJid, messageId, resolvedEmoji, { verbose: false, fromMe, participant: participant ?? undefined, + accountId: accountId ?? undefined, }); - return jsonResult({ ok: true }); + if (!remove && !isEmpty) { + return jsonResult({ ok: true, added: emoji }); + } + return jsonResult({ ok: true, removed: true }); } throw new Error(`Unsupported WhatsApp action: ${action}`); diff --git a/src/agents/tools/whatsapp-schema.ts b/src/agents/tools/whatsapp-schema.ts index e7111307b..95a000cbb 100644 --- a/src/agents/tools/whatsapp-schema.ts +++ b/src/agents/tools/whatsapp-schema.ts @@ -6,6 +6,9 @@ export const WhatsAppToolSchema = Type.Union([ chatJid: Type.String(), messageId: Type.String(), emoji: Type.String(), + remove: Type.Optional(Type.Boolean()), participant: Type.Optional(Type.String()), + accountId: Type.Optional(Type.String()), + fromMe: Type.Optional(Type.Boolean()), }), ]); diff --git a/src/config/types.ts b/src/config/types.ts index 707d9481c..ac2fc0275 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -232,6 +232,10 @@ export type HooksConfig = { gmail?: HooksGmailConfig; }; +export type TelegramActionConfig = { + reactions?: boolean; +}; + export type TelegramConfig = { /** * Controls how Telegram direct chats (DMs) are handled: @@ -271,6 +275,8 @@ export type TelegramConfig = { webhookUrl?: string; webhookSecret?: string; webhookPath?: string; + /** Per-action tool gating (default: true for all). */ + actions?: TelegramActionConfig; }; export type DiscordDmConfig = { diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index a9fc0211b..56997dde9 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -793,6 +793,11 @@ export const ClawdbotSchema = z.object({ webhookUrl: z.string().optional(), webhookSecret: z.string().optional(), webhookPath: z.string().optional(), + actions: z + .object({ + reactions: z.boolean().optional(), + }) + .optional(), }) .superRefine((value, ctx) => { if (value.dmPolicy !== "open") return; diff --git a/src/discord/send.test.ts b/src/discord/send.test.ts index 62b3a17f5..c5b67f3e2 100644 --- a/src/discord/send.test.ts +++ b/src/discord/send.test.ts @@ -14,6 +14,8 @@ import { pinMessageDiscord, reactMessageDiscord, readMessagesDiscord, + removeOwnReactionsDiscord, + removeReactionDiscord, removeRoleDiscord, searchMessagesDiscord, sendMessageDiscord, @@ -224,6 +226,47 @@ describe("reactMessageDiscord", () => { }); }); +describe("removeReactionDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("removes a unicode emoji reaction", async () => { + const { rest, deleteMock } = makeRest(); + await removeReactionDiscord("chan1", "msg1", "✅", { rest, token: "t" }); + expect(deleteMock).toHaveBeenCalledWith( + Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"), + ); + }); +}); + +describe("removeOwnReactionsDiscord", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("removes all own reactions on a message", async () => { + const { rest, getMock, deleteMock } = makeRest(); + getMock.mockResolvedValue({ + reactions: [ + { emoji: { name: "✅", id: null } }, + { emoji: { name: "party_blob", id: "123" } }, + ], + }); + const res = await removeOwnReactionsDiscord("chan1", "msg1", { + rest, + token: "t", + }); + expect(res).toEqual({ ok: true, removed: ["✅", "party_blob:123"] }); + expect(deleteMock).toHaveBeenCalledWith( + Routes.channelMessageOwnReaction("chan1", "msg1", "%E2%9C%85"), + ); + expect(deleteMock).toHaveBeenCalledWith( + Routes.channelMessageOwnReaction("chan1", "msg1", "party_blob%3A123"), + ); + }); +}); + describe("fetchReactionsDiscord", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/discord/send.ts b/src/discord/send.ts index fe4f60f92..35de62e2f 100644 --- a/src/discord/send.ts +++ b/src/discord/send.ts @@ -566,6 +566,55 @@ export async function reactMessageDiscord( return { ok: true }; } +export async function removeReactionDiscord( + channelId: string, + messageId: string, + emoji: string, + opts: DiscordReactOpts = {}, +) { + const token = resolveToken(opts.token); + const rest = resolveRest(token, opts.rest); + const encoded = normalizeReactionEmoji(emoji); + await rest.delete( + Routes.channelMessageOwnReaction(channelId, messageId, encoded), + ); + return { ok: true }; +} + +export async function removeOwnReactionsDiscord( + channelId: string, + messageId: string, + opts: DiscordReactOpts = {}, +): Promise<{ ok: true; removed: string[] }> { + const token = resolveToken(opts.token); + const rest = resolveRest(token, opts.rest); + const message = (await rest.get( + Routes.channelMessage(channelId, messageId), + )) as { + reactions?: Array<{ emoji: { id?: string | null; name?: string | null } }>; + }; + const identifiers = new Set(); + for (const reaction of message.reactions ?? []) { + const identifier = buildReactionIdentifier(reaction.emoji); + if (identifier) identifiers.add(identifier); + } + if (identifiers.size === 0) return { ok: true, removed: [] }; + const removed: string[] = []; + await Promise.allSettled( + Array.from(identifiers, (identifier) => { + removed.push(identifier); + return rest.delete( + Routes.channelMessageOwnReaction( + channelId, + messageId, + normalizeReactionEmoji(identifier), + ), + ); + }), + ); + return { ok: true, removed }; +} + export async function fetchReactionsDiscord( channelId: string, messageId: string, diff --git a/src/slack/actions.ts b/src/slack/actions.ts index e53d94dbb..0c1bd8193 100644 --- a/src/slack/actions.ts +++ b/src/slack/actions.ts @@ -54,6 +54,14 @@ async function getClient(opts: SlackActionClientOpts = {}) { return opts.client ?? new WebClient(token); } +async function resolveBotUserId(client: WebClient) { + const auth = await client.auth.test(); + if (!auth?.user_id) { + throw new Error("Failed to resolve Slack bot user id"); + } + return auth.user_id; +} + export async function reactSlackMessage( channelId: string, messageId: string, @@ -68,6 +76,50 @@ export async function reactSlackMessage( }); } +export async function removeSlackReaction( + channelId: string, + messageId: string, + emoji: string, + opts: SlackActionClientOpts = {}, +) { + const client = await getClient(opts); + await client.reactions.remove({ + channel: channelId, + timestamp: messageId, + name: normalizeEmoji(emoji), + }); +} + +export async function removeOwnSlackReactions( + channelId: string, + messageId: string, + opts: SlackActionClientOpts = {}, +): Promise { + const client = await getClient(opts); + const userId = await resolveBotUserId(client); + const reactions = await listSlackReactions(channelId, messageId, { client }); + const toRemove = new Set(); + for (const reaction of reactions ?? []) { + const name = reaction?.name; + if (!name) continue; + const users = reaction?.users ?? []; + if (users.includes(userId)) { + toRemove.add(name); + } + } + if (toRemove.size === 0) return []; + await Promise.all( + Array.from(toRemove, (name) => + client.reactions.remove({ + channel: channelId, + timestamp: messageId, + name, + }), + ), + ); + return Array.from(toRemove); +} + export async function listSlackReactions( channelId: string, messageId: string, diff --git a/src/slack/index.ts b/src/slack/index.ts index 817ef4ee2..2cc9bbdd9 100644 --- a/src/slack/index.ts +++ b/src/slack/index.ts @@ -8,6 +8,8 @@ export { pinSlackMessage, reactSlackMessage, readSlackMessages, + removeOwnSlackReactions, + removeSlackReaction, sendSlackMessage, unpinSlackMessage, } from "./actions.js"; diff --git a/src/telegram/index.ts b/src/telegram/index.ts index 7effa5e31..a74d21821 100644 --- a/src/telegram/index.ts +++ b/src/telegram/index.ts @@ -1,4 +1,4 @@ export { createTelegramBot, createTelegramWebhookCallback } from "./bot.js"; export { monitorTelegramProvider } from "./monitor.js"; -export { sendMessageTelegram } from "./send.js"; +export { reactMessageTelegram, sendMessageTelegram } from "./send.js"; export { startTelegramWebhook } from "./webhook.js"; diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index a302c9f5a..d3a9aad27 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -8,7 +8,7 @@ vi.mock("../web/media.js", () => ({ loadWebMedia, })); -import { sendMessageTelegram } from "./send.js"; +import { reactMessageTelegram, sendMessageTelegram } from "./send.js"; describe("sendMessageTelegram", () => { beforeEach(() => { @@ -108,3 +108,50 @@ describe("sendMessageTelegram", () => { expect(res.messageId).toBe("9"); }); }); + +describe("reactMessageTelegram", () => { + it("sends emoji reactions", async () => { + const setMessageReaction = vi.fn().mockResolvedValue(undefined); + const api = { setMessageReaction } as unknown as { + setMessageReaction: typeof setMessageReaction; + }; + + await reactMessageTelegram("telegram:123", "456", "✅", { + token: "tok", + api, + }); + + expect(setMessageReaction).toHaveBeenCalledWith("123", 456, [ + { type: "emoji", emoji: "✅" }, + ]); + }); + + it("removes reactions when emoji is empty", async () => { + const setMessageReaction = vi.fn().mockResolvedValue(undefined); + const api = { setMessageReaction } as unknown as { + setMessageReaction: typeof setMessageReaction; + }; + + await reactMessageTelegram("123", 456, "", { + token: "tok", + api, + }); + + expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []); + }); + + it("removes reactions when remove flag is set", async () => { + const setMessageReaction = vi.fn().mockResolvedValue(undefined); + const api = { setMessageReaction } as unknown as { + setMessageReaction: typeof setMessageReaction; + }; + + await reactMessageTelegram("123", 456, "✅", { + token: "tok", + api, + remove: true, + }); + + expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []); + }); +}); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 39c063cc7..afaa2ceb9 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -18,6 +18,12 @@ type TelegramSendResult = { chatId: string; }; +type TelegramReactionOpts = { + token?: string; + api?: Bot["api"]; + remove?: boolean; +}; + const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; @@ -57,6 +63,21 @@ function normalizeChatId(to: string): string { return normalized; } +function normalizeMessageId(raw: string | number): number { + if (typeof raw === "number" && Number.isFinite(raw)) { + return Math.trunc(raw); + } + if (typeof raw === "string") { + const value = raw.trim(); + if (!value) { + throw new Error("Message id is required for Telegram reactions"); + } + const parsed = Number.parseInt(value, 10); + if (Number.isFinite(parsed)) return parsed; + } + throw new Error("Message id is required for Telegram reactions"); +} + export async function sendMessageTelegram( to: string, text: string, @@ -196,6 +217,28 @@ export async function sendMessageTelegram( return { messageId, chatId: String(res?.chat?.id ?? chatId) }; } +export async function reactMessageTelegram( + chatIdInput: string | number, + messageIdInput: string | number, + emoji: string, + opts: TelegramReactionOpts = {}, +): Promise<{ ok: true }> { + const token = resolveToken(opts.token); + const chatId = normalizeChatId(String(chatIdInput)); + const messageId = normalizeMessageId(messageIdInput); + const bot = opts.api ? null : new Bot(token); + const api = opts.api ?? bot?.api; + const remove = opts.remove === true; + const trimmedEmoji = emoji.trim(); + const reactions = + remove || !trimmedEmoji ? [] : [{ type: "emoji", emoji: trimmedEmoji }]; + if (typeof api.setMessageReaction !== "function") { + throw new Error("Telegram reactions are unavailable in this bot API."); + } + await api.setMessageReaction(chatId, messageId, reactions); + return { ok: true }; +} + function inferFilename(kind: ReturnType) { switch (kind) { case "image": diff --git a/src/web/inbound.ts b/src/web/inbound.ts index b5f33c2fd..f461d63d7 100644 --- a/src/web/inbound.ts +++ b/src/web/inbound.ts @@ -558,6 +558,30 @@ export async function monitorWebInbox(options: { }); return { messageId: result?.key?.id ?? "unknown" }; }, + /** + * 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, + }, + }, + }); + }, /** * Send typing indicator ("composing") to a chat. * Used after IPC send to show more messages are coming. diff --git a/src/web/outbound.ts b/src/web/outbound.ts index 1666eada6..c8d99f7a3 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -100,10 +100,11 @@ export async function sendReactionWhatsApp( verbose: boolean; fromMe?: boolean; participant?: string; + accountId?: string; }, ): Promise { const correlationId = randomUUID(); - const active = getActiveWebListener(); + const active = getActiveWebListener(options.accountId); if (!active) { throw new Error( "No active gateway listener. Start the gateway before sending WhatsApp reactions.",