feat: unify provider reaction tools
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -201,10 +201,36 @@ Notes:
|
||||
- `to` accepts `channel:<id>` or `user:<id>`.
|
||||
- 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`):
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -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 ?? [])
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
92
src/agents/tools/common.test.ts
Normal file
92
src/agents/tools/common.test.ts
Normal 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("✅");
|
||||
});
|
||||
});
|
||||
@@ -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: [
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
119
src/agents/tools/discord-actions.test.ts
Normal file
119
src/agents/tools/discord-actions.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
113
src/agents/tools/slack-actions.test.ts
Normal file
113
src/agents/tools/slack-actions.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
|
||||
@@ -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"),
|
||||
|
||||
95
src/agents/tools/telegram-actions.test.ts
Normal file
95
src/agents/tools/telegram-actions.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
53
src/agents/tools/telegram-actions.ts
Normal file
53
src/agents/tools/telegram-actions.ts
Normal 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}`);
|
||||
}
|
||||
11
src/agents/tools/telegram-schema.ts
Normal file
11
src/agents/tools/telegram-schema.ts
Normal 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()),
|
||||
}),
|
||||
]);
|
||||
18
src/agents/tools/telegram-tool.ts
Normal file
18
src/agents/tools/telegram-tool.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
129
src/agents/tools/whatsapp-actions.test.ts
Normal file
129
src/agents/tools/whatsapp-actions.test.ts
Normal 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/);
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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()),
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -8,6 +8,8 @@ export {
|
||||
pinSlackMessage,
|
||||
reactSlackMessage,
|
||||
readSlackMessages,
|
||||
removeOwnSlackReactions,
|
||||
removeSlackReaction,
|
||||
sendSlackMessage,
|
||||
unpinSlackMessage,
|
||||
} from "./actions.js";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user