feat: unify provider reaction tools

This commit is contained in:
Peter Steinberger
2026-01-07 04:10:13 +01:00
parent 551a8d5683
commit 3afef2d504
41 changed files with 1169 additions and 82 deletions

View File

@@ -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.

View File

@@ -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",

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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)

View File

@@ -201,10 +201,36 @@ Notes:
- `to` accepts `channel:<id>` or `user:<id>`.
- Polls require 210 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`):

View File

@@ -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({

View File

@@ -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",

View File

@@ -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;
});

View File

@@ -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 ?? [])

View File

@@ -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"]
}
}
}
}

View File

@@ -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<TestActions>(undefined);
expect(gate("reactions")).toBe(true);
expect(gate("messages", false)).toBe(false);
});
it("respects explicit false", () => {
const gate = createActionGate<TestActions>({ 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("✅");
});
});

View File

@@ -12,8 +12,24 @@ export type StringParamOptions = {
required?: boolean;
trim?: boolean;
label?: string;
allowEmpty?: boolean;
};
export type ActionGate<T extends Record<string, boolean | undefined>> = (
key: keyof T,
defaultValue?: boolean,
) => boolean;
export function createActionGate<T extends Record<string, boolean | undefined>>(
actions: T | undefined,
): ActionGate<T> {
return (key, defaultValue = true) => {
const value = actions?.[key];
if (value === undefined) return defaultValue;
return value !== false;
};
}
export function readStringParam(
params: Record<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<string, unknown>,
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<unknown> {
return {
content: [

View File

@@ -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<string, unknown>,
isActionEnabled: ActionGate,
isActionEnabled: ActionGate<DiscordActionConfig>,
): Promise<AgentToolResult<unknown>> {
switch (action) {
case "memberInfo": {

View File

@@ -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<string, unknown>,
isActionEnabled: ActionGate,
isActionEnabled: ActionGate<DiscordActionConfig>,
): Promise<AgentToolResult<unknown>> {
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")) {

View File

@@ -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<string, unknown>,
isActionEnabled: ActionGate,
isActionEnabled: ActionGate<DiscordActionConfig>,
): Promise<AgentToolResult<unknown>> {
switch (action) {
case "timeout": {

View File

@@ -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/);
});
});

View File

@@ -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<string, unknown>,
cfg: ClawdbotConfig,
): Promise<AgentToolResult<unknown>> {
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);

View File

@@ -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"),

View File

@@ -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/);
});
});

View File

@@ -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<string, unknown>,
cfg: ClawdbotConfig,
): Promise<AgentToolResult<unknown>> {
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 });

View File

@@ -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"),

View File

@@ -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/);
});
});

View File

@@ -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<string, unknown>,
cfg: ClawdbotConfig,
): Promise<AgentToolResult<unknown>> {
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}`);
}

View File

@@ -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()),
}),
]);

View File

@@ -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<string, unknown>;
const cfg = loadConfig();
return await handleTelegramAction(params, cfg);
},
};
}

View File

@@ -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/);
});
});

View File

@@ -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<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;
};
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}`);

View File

@@ -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()),
}),
]);

View File

@@ -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 = {

View File

@@ -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;

View File

@@ -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();

View File

@@ -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<string>();
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,

View File

@@ -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<string[]> {
const client = await getClient(opts);
const userId = await resolveBotUserId(client);
const reactions = await listSlackReactions(channelId, messageId, { client });
const toRemove = new Set<string>();
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,

View File

@@ -8,6 +8,8 @@ export {
pinSlackMessage,
reactSlackMessage,
readSlackMessages,
removeOwnSlackReactions,
removeSlackReaction,
sendSlackMessage,
unpinSlackMessage,
} from "./actions.js";

View File

@@ -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";

View File

@@ -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, []);
});
});

View File

@@ -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<typeof mediaKindFromMime>) {
switch (kind) {
case "image":

View File

@@ -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<void> => {
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.

View File

@@ -100,10 +100,11 @@ export async function sendReactionWhatsApp(
verbose: boolean;
fromMe?: boolean;
participant?: string;
accountId?: string;
},
): Promise<void> {
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.",