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 sendStickerTelegram = vi.fn(async () => ({ messageId: "456", chatId: "123", })); const deleteMessageTelegram = vi.fn(async () => ({ ok: true })); let envSnapshot: ReturnType; vi.mock("../../telegram/send.js", () => ({ reactMessageTelegram: (...args: Parameters) => reactMessageTelegram(...args), sendMessageTelegram: (...args: Parameters) => sendMessageTelegram(...args), sendStickerTelegram: (...args: Parameters) => sendStickerTelegram(...args), deleteMessageTelegram: (...args: Parameters) => deleteMessageTelegram(...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): 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"]); reactMessageTelegram.mockClear(); sendMessageTelegram.mockClear(); sendStickerTelegram.mockClear(); deleteMessageTelegram.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("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("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: "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("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" }), ); }); });