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

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