import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../src/config/config.js"; import { captureEnv } from "../../../test/helpers/extensions/env.js"; import { handleTelegramAction, readTelegramButtons, telegramActionRuntime, } from "./action-runtime.js"; const originalTelegramActionRuntime = { ...telegramActionRuntime }; 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 editForumTopicTelegram = vi.fn(async () => ({ ok: true, chatId: "123", messageThreadId: 42, name: "Renamed", })); const createForumTopicTelegram = vi.fn(async () => ({ topicId: 99, name: "Topic", chatId: "123", })); let envSnapshot: ReturnType; 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): OpenClawConfig { return { channels: { telegram: { botToken: "tok", ...overrides, }, }, } as OpenClawConfig; } async function sendInlineButtonsMessage(params: { to: string; buttons: Array>; 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"]); Object.assign(telegramActionRuntime, originalTelegramActionRuntime, { reactMessageTelegram, sendMessageTelegram, sendPollTelegram, sendStickerTelegram, deleteMessageTelegram, editMessageTelegram, editForumTopicTelegram, createForumTopicTelegram, }); reactMessageTelegram.mockClear(); sendMessageTelegram.mockClear(); sendPollTelegram.mockClear(); sendStickerTelegram.mockClear(); deleteMessageTelegram.mockClear(); editMessageTelegram.mockClear(); editForumTopicTelegram.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>); 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("accepts shared sticker action aliases", async () => { const cfg = { channels: { telegram: { botToken: "tok", actions: { sticker: true } } }, } as OpenClawConfig; await handleTelegramAction( { action: "sticker", target: "123", stickerId: ["sticker"], replyTo: 9, threadId: 11, }, cfg, ); expect(sendStickerTelegram).toHaveBeenCalledWith( "123", "sticker", expect.objectContaining({ token: "tok", replyToMessageId: 9, messageThreadId: 11, }), ); }); 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("accepts shared send action aliases", async () => { await handleTelegramAction( { action: "send", to: "@testchannel", message: "Hello from alias", media: "https://example.com/image.jpg", }, telegramConfig(), ); expect(sendMessageTelegram).toHaveBeenCalledWith( "@testchannel", "Hello from alias", expect.objectContaining({ token: "tok", mediaUrl: "https://example.com/image.jpg", }), ); }); 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("accepts shared poll action aliases", async () => { await handleTelegramAction( { action: "poll", to: "@testchannel", pollQuestion: "Ready?", pollOption: ["Yes", "No"], pollMulti: "true", pollPublic: "true", pollDurationSeconds: 60, replyTo: 55, threadId: 77, silent: "true", }, telegramConfig(), ); expect(sendPollTelegram).toHaveBeenCalledWith( "@testchannel", { question: "Ready?", options: ["Yes", "No"], maxSelections: 2, durationSeconds: 60, durationHours: undefined, }, expect.objectContaining({ token: "tok", isAnonymous: false, replyToMessageId: 55, messageThreadId: 77, silent: true, }), ); }); 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, ) => 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, ) => 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, ) => 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, ) => 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, ) => 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, ) => 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, ) => readCallOpts(createForumTopicTelegram.mock.calls as unknown[][], 2), }, { name: "editForumTopic", params: { action: "editForumTopic", chatId: "123", messageThreadId: 42, name: "New" }, cfg: telegramConfig({ actions: { editForumTopic: true } }), assertCall: ( readCallOpts: (calls: unknown[][], argIndex: number) => Record, ) => readCallOpts(editForumTopicTelegram.mock.calls as unknown[][], 2), }, ])("forwards resolved cfg for $name action", async ({ params, cfg, assertCall }) => { const readCallOpts = (calls: unknown[][], argIndex: number): Record => { 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; }; await handleTelegramAction(params as Record, 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" }), ); }); });