Files
openclaw/src/agents/tools/telegram-actions.test.ts

900 lines
24 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { captureEnv } from "../../test-utils/env.js";
import { handleTelegramAction, readTelegramButtons } from "./telegram-actions.js";
const reactMessageTelegram = vi.fn(async () => ({ ok: true }));
const sendMessageTelegram = vi.fn(async () => ({
messageId: "789",
chatId: "123",
}));
const sendPollTelegram = vi.fn(async () => ({
messageId: "790",
chatId: "123",
pollId: "poll-1",
}));
const sendStickerTelegram = vi.fn(async () => ({
messageId: "456",
chatId: "123",
}));
const deleteMessageTelegram = vi.fn(async () => ({ ok: true }));
const editMessageTelegram = vi.fn(async () => ({
ok: true,
messageId: "456",
chatId: "123",
}));
const createForumTopicTelegram = vi.fn(async () => ({
topicId: 99,
name: "Topic",
chatId: "123",
}));
let envSnapshot: ReturnType<typeof captureEnv>;
vi.mock("../../telegram/send.js", () => ({
reactMessageTelegram: (...args: Parameters<typeof reactMessageTelegram>) =>
reactMessageTelegram(...args),
sendMessageTelegram: (...args: Parameters<typeof sendMessageTelegram>) =>
sendMessageTelegram(...args),
sendPollTelegram: (...args: Parameters<typeof sendPollTelegram>) => sendPollTelegram(...args),
sendStickerTelegram: (...args: Parameters<typeof sendStickerTelegram>) =>
sendStickerTelegram(...args),
deleteMessageTelegram: (...args: Parameters<typeof deleteMessageTelegram>) =>
deleteMessageTelegram(...args),
editMessageTelegram: (...args: Parameters<typeof editMessageTelegram>) =>
editMessageTelegram(...args),
createForumTopicTelegram: (...args: Parameters<typeof createForumTopicTelegram>) =>
createForumTopicTelegram(...args),
}));
describe("handleTelegramAction", () => {
const defaultReactionAction = {
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
} as const;
function reactionConfig(reactionLevel: "minimal" | "extensive" | "off" | "ack"): OpenClawConfig {
return {
channels: { telegram: { botToken: "tok", reactionLevel } },
} as OpenClawConfig;
}
function telegramConfig(overrides?: Record<string, unknown>): OpenClawConfig {
return {
channels: {
telegram: {
botToken: "tok",
...overrides,
},
},
} as OpenClawConfig;
}
async function sendInlineButtonsMessage(params: {
to: string;
buttons: Array<Array<{ text: string; callback_data: string; style?: string }>>;
inlineButtons: "dm" | "group" | "all";
}) {
await handleTelegramAction(
{
action: "sendMessage",
to: params.to,
content: "Choose",
buttons: params.buttons,
},
telegramConfig({ capabilities: { inlineButtons: params.inlineButtons } }),
);
}
async function expectReactionAdded(reactionLevel: "minimal" | "extensive") {
await handleTelegramAction(defaultReactionAction, reactionConfig(reactionLevel));
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: false }),
);
}
beforeEach(() => {
envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN"]);
reactMessageTelegram.mockClear();
sendMessageTelegram.mockClear();
sendPollTelegram.mockClear();
sendStickerTelegram.mockClear();
deleteMessageTelegram.mockClear();
editMessageTelegram.mockClear();
createForumTopicTelegram.mockClear();
process.env.TELEGRAM_BOT_TOKEN = "tok";
});
afterEach(() => {
envSnapshot.restore();
});
it("adds reactions when reactionLevel is minimal", async () => {
await expectReactionAdded("minimal");
});
it("surfaces non-fatal reaction warnings", async () => {
reactMessageTelegram.mockResolvedValueOnce({
ok: false,
warning: "Reaction unavailable: ✅",
} as unknown as Awaited<ReturnType<typeof reactMessageTelegram>>);
const result = await handleTelegramAction(defaultReactionAction, reactionConfig("minimal"));
const textPayload = result.content.find((item) => item.type === "text");
expect(textPayload?.type).toBe("text");
const parsed = JSON.parse((textPayload as { type: "text"; text: string }).text) as {
ok: boolean;
warning?: string;
added?: string;
};
expect(parsed).toMatchObject({
ok: false,
warning: "Reaction unavailable: ✅",
added: "✅",
});
});
it("adds reactions when reactionLevel is extensive", async () => {
await expectReactionAdded("extensive");
});
it("accepts snake_case message_id for reactions", async () => {
await handleTelegramAction(
{
action: "react",
chatId: "123",
message_id: "456",
emoji: "✅",
},
reactionConfig("minimal"),
);
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: false }),
);
});
it("soft-fails when messageId is missing", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
} as OpenClawConfig;
const result = await handleTelegramAction(
{
action: "react",
chatId: "123",
emoji: "✅",
},
cfg,
);
expect(result.details).toMatchObject({
ok: false,
reason: "missing_message_id",
});
expect(reactMessageTelegram).not.toHaveBeenCalled();
});
it("removes reactions on empty emoji", async () => {
await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "",
},
reactionConfig("minimal"),
);
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"",
expect.objectContaining({ token: "tok", remove: false }),
);
});
it("rejects sticker actions when disabled by default", async () => {
const cfg = { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig;
await expect(
handleTelegramAction(
{
action: "sendSticker",
to: "123",
fileId: "sticker",
},
cfg,
),
).rejects.toThrow(/sticker actions are disabled/i);
expect(sendStickerTelegram).not.toHaveBeenCalled();
});
it("sends stickers when enabled", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", actions: { sticker: true } } },
} as OpenClawConfig;
await handleTelegramAction(
{
action: "sendSticker",
to: "123",
fileId: "sticker",
},
cfg,
);
expect(sendStickerTelegram).toHaveBeenCalledWith(
"123",
"sticker",
expect.objectContaining({ token: "tok" }),
);
});
it("removes reactions when remove flag set", async () => {
const cfg = reactionConfig("extensive");
await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
remove: true,
},
cfg,
);
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: true }),
);
});
it.each(["off", "ack"] as const)(
"soft-fails reactions when reactionLevel is %s",
async (level) => {
const result = await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
reactionConfig(level),
);
expect(result.details).toMatchObject({
ok: false,
reason: "disabled",
});
},
);
it("soft-fails when reactions are disabled via actions.reactions", async () => {
const cfg = {
channels: {
telegram: {
botToken: "tok",
reactionLevel: "minimal",
actions: { reactions: false },
},
},
} as OpenClawConfig;
const result = await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
);
expect(result.details).toMatchObject({
ok: false,
reason: "disabled",
});
});
it("sends a text message", async () => {
const result = await handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Hello, Telegram!",
},
telegramConfig(),
);
expect(sendMessageTelegram).toHaveBeenCalledWith(
"@testchannel",
"Hello, Telegram!",
expect.objectContaining({ token: "tok", mediaUrl: undefined }),
);
expect(result.content).toContainEqual({
type: "text",
text: expect.stringContaining('"ok": true'),
});
});
it("sends a poll", async () => {
const result = await handleTelegramAction(
{
action: "poll",
to: "@testchannel",
question: "Ready?",
answers: ["Yes", "No"],
allowMultiselect: true,
durationSeconds: 60,
isAnonymous: false,
silent: true,
},
telegramConfig(),
);
expect(sendPollTelegram).toHaveBeenCalledWith(
"@testchannel",
{
question: "Ready?",
options: ["Yes", "No"],
maxSelections: 2,
durationSeconds: 60,
durationHours: undefined,
},
expect.objectContaining({
token: "tok",
isAnonymous: false,
silent: true,
}),
);
expect(result.details).toMatchObject({
ok: true,
messageId: "790",
chatId: "123",
pollId: "poll-1",
});
});
it("parses string booleans for poll flags", async () => {
await handleTelegramAction(
{
action: "poll",
to: "@testchannel",
question: "Ready?",
answers: ["Yes", "No"],
allowMultiselect: "true",
isAnonymous: "false",
silent: "true",
},
telegramConfig(),
);
expect(sendPollTelegram).toHaveBeenCalledWith(
"@testchannel",
expect.objectContaining({
question: "Ready?",
options: ["Yes", "No"],
maxSelections: 2,
}),
expect.objectContaining({
isAnonymous: false,
silent: true,
}),
);
});
it("forwards trusted mediaLocalRoots into sendMessageTelegram", async () => {
await handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Hello with local media",
},
telegramConfig(),
{ mediaLocalRoots: ["/tmp/agent-root"] },
);
expect(sendMessageTelegram).toHaveBeenCalledWith(
"@testchannel",
"Hello with local media",
expect.objectContaining({ mediaLocalRoots: ["/tmp/agent-root"] }),
);
});
it.each([
{
name: "react",
params: { action: "react", chatId: "123", messageId: 456, emoji: "✅" },
cfg: reactionConfig("minimal"),
assertCall: (
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
) => readCallOpts(reactMessageTelegram.mock.calls as unknown[][], 3),
},
{
name: "sendMessage",
params: { action: "sendMessage", to: "123", content: "hello" },
cfg: telegramConfig(),
assertCall: (
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
) => readCallOpts(sendMessageTelegram.mock.calls as unknown[][], 2),
},
{
name: "poll",
params: {
action: "poll",
to: "123",
question: "Q?",
answers: ["A", "B"],
},
cfg: telegramConfig(),
assertCall: (
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
) => readCallOpts(sendPollTelegram.mock.calls as unknown[][], 2),
},
{
name: "deleteMessage",
params: { action: "deleteMessage", chatId: "123", messageId: 1 },
cfg: telegramConfig(),
assertCall: (
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
) => readCallOpts(deleteMessageTelegram.mock.calls as unknown[][], 2),
},
{
name: "editMessage",
params: { action: "editMessage", chatId: "123", messageId: 1, content: "updated" },
cfg: telegramConfig(),
assertCall: (
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
) => readCallOpts(editMessageTelegram.mock.calls as unknown[][], 3),
},
{
name: "sendSticker",
params: { action: "sendSticker", to: "123", fileId: "sticker-1" },
cfg: telegramConfig({ actions: { sticker: true } }),
assertCall: (
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
) => readCallOpts(sendStickerTelegram.mock.calls as unknown[][], 2),
},
{
name: "createForumTopic",
params: { action: "createForumTopic", chatId: "123", name: "Topic" },
cfg: telegramConfig({ actions: { createForumTopic: true } }),
assertCall: (
readCallOpts: (calls: unknown[][], argIndex: number) => Record<string, unknown>,
) => readCallOpts(createForumTopicTelegram.mock.calls as unknown[][], 2),
},
])("forwards resolved cfg for $name action", async ({ params, cfg, assertCall }) => {
const readCallOpts = (calls: unknown[][], argIndex: number): Record<string, unknown> => {
const args = calls[0];
if (!Array.isArray(args)) {
throw new Error("Expected Telegram action call args");
}
const opts = args[argIndex];
if (!opts || typeof opts !== "object") {
throw new Error("Expected Telegram action options object");
}
return opts as Record<string, unknown>;
};
await handleTelegramAction(params as Record<string, unknown>, cfg);
const opts = assertCall(readCallOpts);
expect(opts.cfg).toBe(cfg);
});
it.each([
{
name: "media",
params: {
action: "sendMessage",
to: "123456",
content: "Check this image!",
mediaUrl: "https://example.com/image.jpg",
},
expectedTo: "123456",
expectedContent: "Check this image!",
expectedOptions: { mediaUrl: "https://example.com/image.jpg" },
},
{
name: "quoteText",
params: {
action: "sendMessage",
to: "123456",
content: "Replying now",
replyToMessageId: 144,
quoteText: "The text you want to quote",
},
expectedTo: "123456",
expectedContent: "Replying now",
expectedOptions: {
replyToMessageId: 144,
quoteText: "The text you want to quote",
},
},
{
name: "media-only",
params: {
action: "sendMessage",
to: "123456",
mediaUrl: "https://example.com/note.ogg",
},
expectedTo: "123456",
expectedContent: "",
expectedOptions: { mediaUrl: "https://example.com/note.ogg" },
},
] as const)("maps sendMessage params for $name", async (testCase) => {
await handleTelegramAction(testCase.params, telegramConfig());
expect(sendMessageTelegram).toHaveBeenCalledWith(
testCase.expectedTo,
testCase.expectedContent,
expect.objectContaining({
token: "tok",
...testCase.expectedOptions,
}),
);
});
it("requires content when no mediaUrl is provided", async () => {
await expect(
handleTelegramAction(
{
action: "sendMessage",
to: "123456",
},
telegramConfig(),
),
).rejects.toThrow(/content required/i);
});
it("respects sendMessage gating", async () => {
const cfg = {
channels: {
telegram: { botToken: "tok", actions: { sendMessage: false } },
},
} as OpenClawConfig;
await expect(
handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Hello!",
},
cfg,
),
).rejects.toThrow(/Telegram sendMessage is disabled/);
});
it("respects poll gating", async () => {
const cfg = {
channels: {
telegram: { botToken: "tok", actions: { poll: false } },
},
} as OpenClawConfig;
await expect(
handleTelegramAction(
{
action: "poll",
to: "@testchannel",
question: "Lunch?",
answers: ["Pizza", "Sushi"],
},
cfg,
),
).rejects.toThrow(/Telegram polls are disabled/);
});
it("deletes a message", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as OpenClawConfig;
await handleTelegramAction(
{
action: "deleteMessage",
chatId: "123",
messageId: 456,
},
cfg,
);
expect(deleteMessageTelegram).toHaveBeenCalledWith(
"123",
456,
expect.objectContaining({ token: "tok" }),
);
});
it("respects deleteMessage gating", async () => {
const cfg = {
channels: {
telegram: { botToken: "tok", actions: { deleteMessage: false } },
},
} as OpenClawConfig;
await expect(
handleTelegramAction(
{
action: "deleteMessage",
chatId: "123",
messageId: 456,
},
cfg,
),
).rejects.toThrow(/Telegram deleteMessage is disabled/);
});
it("throws on missing bot token for sendMessage", async () => {
delete process.env.TELEGRAM_BOT_TOKEN;
const cfg = {} as OpenClawConfig;
await expect(
handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Hello!",
},
cfg,
),
).rejects.toThrow(/Telegram bot token missing/);
});
it("allows inline buttons by default (allowlist)", async () => {
const cfg = {
channels: { telegram: { botToken: "tok" } },
} as OpenClawConfig;
await handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Choose",
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
},
cfg,
);
expect(sendMessageTelegram).toHaveBeenCalled();
});
it.each([
{
name: "scope is off",
to: "@testchannel",
inlineButtons: "off" as const,
expectedMessage: /inline buttons are disabled/i,
},
{
name: "scope is dm and target is group",
to: "-100123456",
inlineButtons: "dm" as const,
expectedMessage: /inline buttons are limited to DMs/i,
},
])("blocks inline buttons when $name", async ({ to, inlineButtons, expectedMessage }) => {
await expect(
handleTelegramAction(
{
action: "sendMessage",
to,
content: "Choose",
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
},
telegramConfig({ capabilities: { inlineButtons } }),
),
).rejects.toThrow(expectedMessage);
});
it("allows inline buttons in DMs with tg: prefixed targets", async () => {
await sendInlineButtonsMessage({
to: "tg:5232990709",
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
inlineButtons: "dm",
});
expect(sendMessageTelegram).toHaveBeenCalled();
});
it("allows inline buttons in groups with topic targets", async () => {
await sendInlineButtonsMessage({
to: "telegram:group:-1001234567890:topic:456",
buttons: [[{ text: "Ok", callback_data: "cmd:ok" }]],
inlineButtons: "group",
});
expect(sendMessageTelegram).toHaveBeenCalled();
});
it("sends messages with inline keyboard buttons when enabled", async () => {
await sendInlineButtonsMessage({
to: "@testchannel",
buttons: [[{ text: " Option A ", callback_data: " cmd:a " }]],
inlineButtons: "all",
});
expect(sendMessageTelegram).toHaveBeenCalledWith(
"@testchannel",
"Choose",
expect.objectContaining({
buttons: [[{ text: "Option A", callback_data: "cmd:a" }]],
}),
);
});
it("forwards optional button style", async () => {
await sendInlineButtonsMessage({
to: "@testchannel",
inlineButtons: "all",
buttons: [
[
{
text: "Option A",
callback_data: "cmd:a",
style: "primary",
},
],
],
});
expect(sendMessageTelegram).toHaveBeenCalledWith(
"@testchannel",
"Choose",
expect.objectContaining({
buttons: [
[
{
text: "Option A",
callback_data: "cmd:a",
style: "primary",
},
],
],
}),
);
});
});
describe("readTelegramButtons", () => {
it("returns trimmed button rows for valid input", () => {
const result = readTelegramButtons({
buttons: [[{ text: " Option A ", callback_data: " cmd:a " }]],
});
expect(result).toEqual([[{ text: "Option A", callback_data: "cmd:a" }]]);
});
it("normalizes optional style", () => {
const result = readTelegramButtons({
buttons: [
[
{
text: "Option A",
callback_data: "cmd:a",
style: " PRIMARY ",
},
],
],
});
expect(result).toEqual([
[
{
text: "Option A",
callback_data: "cmd:a",
style: "primary",
},
],
]);
});
it("rejects unsupported button style", () => {
expect(() =>
readTelegramButtons({
buttons: [[{ text: "Option A", callback_data: "cmd:a", style: "secondary" }]],
}),
).toThrow(/style must be one of danger, success, primary/i);
});
});
describe("handleTelegramAction per-account gating", () => {
function accountTelegramConfig(params: {
accounts: Record<
string,
{ botToken: string; actions?: { sticker?: boolean; reactions?: boolean } }
>;
topLevelBotToken?: string;
topLevelActions?: { reactions?: boolean };
}): OpenClawConfig {
return {
channels: {
telegram: {
...(params.topLevelBotToken ? { botToken: params.topLevelBotToken } : {}),
...(params.topLevelActions ? { actions: params.topLevelActions } : {}),
accounts: params.accounts,
},
},
} as OpenClawConfig;
}
async function expectAccountStickerSend(cfg: OpenClawConfig, accountId = "media") {
await handleTelegramAction(
{ action: "sendSticker", to: "123", fileId: "sticker-id", accountId },
cfg,
);
expect(sendStickerTelegram).toHaveBeenCalledWith(
"123",
"sticker-id",
expect.objectContaining({ token: "tok-media" }),
);
}
it("allows sticker when account config enables it", async () => {
const cfg = accountTelegramConfig({
accounts: {
media: { botToken: "tok-media", actions: { sticker: true } },
},
});
await expectAccountStickerSend(cfg);
});
it("blocks sticker when account omits it", async () => {
const cfg = {
channels: {
telegram: {
accounts: {
chat: { botToken: "tok-chat" },
},
},
},
} as OpenClawConfig;
await expect(
handleTelegramAction(
{ action: "sendSticker", to: "123", fileId: "sticker-id", accountId: "chat" },
cfg,
),
).rejects.toThrow(/sticker actions are disabled/i);
});
it("uses account-merged config, not top-level config", async () => {
// Top-level has no sticker enabled, but the account does
const cfg = accountTelegramConfig({
topLevelBotToken: "tok-base",
accounts: {
media: { botToken: "tok-media", actions: { sticker: true } },
},
});
await expectAccountStickerSend(cfg);
});
it("inherits top-level reaction gate when account overrides sticker only", async () => {
const cfg = accountTelegramConfig({
topLevelActions: { reactions: false },
accounts: {
media: { botToken: "tok-media", actions: { sticker: true } },
},
});
const result = await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: 1,
emoji: "👀",
accountId: "media",
},
cfg,
);
expect(result.details).toMatchObject({
ok: false,
reason: "disabled",
});
});
it("allows account to explicitly re-enable top-level disabled reaction gate", async () => {
const cfg = accountTelegramConfig({
topLevelActions: { reactions: false },
accounts: {
media: { botToken: "tok-media", actions: { sticker: true, reactions: true } },
},
});
await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: 1,
emoji: "👀",
accountId: "media",
},
cfg,
);
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
1,
"👀",
expect.objectContaining({ token: "tok-media", accountId: "media" }),
);
});
});