feat: unify provider reaction tools
This commit is contained in:
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()),
|
||||
}),
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user