import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; import { listNativeCommandSpecs, listNativeCommandSpecsForConfig, } from "../auto-reply/commands-registry.js"; import { normalizeTelegramCommandName } from "../config/telegram-custom-commands.js"; import { answerCallbackQuerySpy, commandSpy, editMessageTextSpy, enqueueSystemEventSpy, getLoadConfigMock, getReadChannelAllowFromStoreMock, getOnHandler, listSkillCommandsForAgents, onSpy, replySpy, sendMessageSpy, setMyCommandsSpy, wasSentByBot, } from "./bot.create-telegram-bot.test-harness.js"; import { createTelegramBot } from "./bot.js"; const loadConfig = getLoadConfigMock(); const readChannelAllowFromStore = getReadChannelAllowFromStoreMock(); function resolveSkillCommands(config: Parameters[0]) { void config; return listSkillCommandsForAgents() as NonNullable< Parameters[1] >["skillCommands"]; } const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { beforeEach(() => { process.env.TZ = "UTC"; loadConfig.mockReturnValue({ agents: { defaults: { envelopeTimezone: "utc", }, }, channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] }, }, }); }); afterEach(() => { process.env.TZ = ORIGINAL_TZ; }); it("merges custom commands with native commands", () => { const config = { channels: { telegram: { customCommands: [ { command: "custom_backup", description: "Git backup" }, { command: "/Custom_Generate", description: "Create an image" }, ], }, }, }; loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok" }); const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{ command: string; description: string; }>; const skillCommands = resolveSkillCommands(config); const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({ command: normalizeTelegramCommandName(command.name), description: command.description, })); expect(registered.slice(0, native.length)).toEqual(native); expect(registered.slice(native.length)).toEqual([ { command: "custom_backup", description: "Git backup" }, { command: "custom_generate", description: "Create an image" }, ]); }); it("ignores custom commands that collide with native commands", () => { const errorSpy = vi.fn(); const config = { channels: { telegram: { customCommands: [ { command: "status", description: "Custom status" }, { command: "custom_backup", description: "Git backup" }, ], }, }, }; loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok", runtime: { log: vi.fn(), error: errorSpy, exit: ((code: number) => { throw new Error(`exit ${code}`); }) as (code: number) => never, }, }); const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{ command: string; description: string; }>; const skillCommands = resolveSkillCommands(config); const native = listNativeCommandSpecsForConfig(config, { skillCommands }).map((command) => ({ command: normalizeTelegramCommandName(command.name), description: command.description, })); const nativeStatus = native.find((command) => command.command === "status"); expect(nativeStatus).toBeDefined(); expect(registered).toContainEqual({ command: "custom_backup", description: "Git backup" }); expect(registered).not.toContainEqual({ command: "status", description: "Custom status" }); expect(registered.filter((command) => command.command === "status")).toEqual([nativeStatus]); expect(errorSpy).toHaveBeenCalled(); }); it("registers custom commands when native commands are disabled", () => { const config = { commands: { native: false }, channels: { telegram: { customCommands: [ { command: "custom_backup", description: "Git backup" }, { command: "custom_generate", description: "Create an image" }, ], }, }, }; loadConfig.mockReturnValue(config); createTelegramBot({ token: "tok" }); const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{ command: string; description: string; }>; expect(registered).toEqual([ { command: "custom_backup", description: "Git backup" }, { command: "custom_generate", description: "Create an image" }, ]); const reserved = new Set(listNativeCommandSpecs().map((command) => command.name)); expect(registered.some((command) => reserved.has(command.command))).toBe(false); }); it("blocks callback_query when inline buttons are allowlist-only and sender not authorized", async () => { onSpy.mockReset(); replySpy.mockReset(); createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "pairing", capabilities: { inlineButtons: "allowlist" }, allowFrom: [], }, }, }, }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; expect(callbackHandler).toBeDefined(); await callbackHandler({ callbackQuery: { id: "cbq-2", data: "cmd:option_b", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 11, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).not.toHaveBeenCalled(); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-2"); }); it("edits commands list for pagination callbacks", async () => { onSpy.mockReset(); listSkillCommandsForAgents.mockReset(); createTelegramBot({ token: "tok" }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; expect(callbackHandler).toBeDefined(); await callbackHandler({ callbackQuery: { id: "cbq-3", data: "commands_page_2:main", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 12, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(listSkillCommandsForAgents).toHaveBeenCalledWith({ cfg: expect.any(Object), agentIds: ["main"], }); expect(editMessageTextSpy).toHaveBeenCalledTimes(1); const [chatId, messageId, text, params] = editMessageTextSpy.mock.calls[0] ?? []; expect(chatId).toBe(1234); expect(messageId).toBe(12); expect(String(text)).toContain("â„šī¸ Commands"); expect(params).toEqual( expect.objectContaining({ reply_markup: expect.any(Object), }), ); }); it("blocks pagination callbacks when allowlist rejects sender", async () => { onSpy.mockReset(); editMessageTextSpy.mockReset(); createTelegramBot({ token: "tok", config: { channels: { telegram: { dmPolicy: "pairing", capabilities: { inlineButtons: "allowlist" }, allowFrom: [], }, }, }, }); const callbackHandler = onSpy.mock.calls.find((call) => call[0] === "callback_query")?.[1] as ( ctx: Record, ) => Promise; expect(callbackHandler).toBeDefined(); await callbackHandler({ callbackQuery: { id: "cbq-4", data: "commands_page_2", from: { id: 9, first_name: "Ada", username: "ada_bot" }, message: { chat: { id: 1234, type: "private" }, date: 1736380800, message_id: 13, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(editMessageTextSpy).not.toHaveBeenCalled(); expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-4"); }); it("includes sender identity in group envelope headers", async () => { onSpy.mockReset(); replySpy.mockReset(); loadConfig.mockReturnValue({ agents: { defaults: { envelopeTimezone: "utc", }, }, channels: { telegram: { groupPolicy: "open", groups: { "*": { requireMention: false } }, }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 42, type: "group", title: "Ops" }, text: "hello", date: 1736380800, message_id: 2, from: { id: 99, first_name: "Ada", last_name: "Lovelace", username: "ada", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expectInboundContextContract(payload); const expectedTimestamp = formatEnvelopeTimestamp(new Date("2025-01-09T00:00:00Z")); const timestampPattern = escapeRegExp(expectedTimestamp); expect(payload.Body).toMatch( new RegExp(`^\\[Telegram Ops id:42 (\\+\\d+[smhd] )?${timestampPattern}\\]`), ); expect(payload.SenderName).toBe("Ada Lovelace"); expect(payload.SenderId).toBe("99"); expect(payload.SenderUsername).toBe("ada"); }); it("uses quote text when a Telegram partial reply is received", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); replySpy.mockReset(); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 7, type: "private" }, text: "Sure, see below", date: 1736380800, reply_to_message: { message_id: 9001, text: "Can you summarize this?", from: { first_name: "Ada" }, }, quote: { text: "summarize this", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.Body).toContain("[Quoting Ada id:9001]"); expect(payload.Body).toContain('"summarize this"'); expect(payload.ReplyToId).toBe("9001"); expect(payload.ReplyToBody).toBe("summarize this"); expect(payload.ReplyToSender).toBe("Ada"); }); it("handles quote-only replies without reply metadata", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); replySpy.mockReset(); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 7, type: "private" }, text: "Sure, see below", date: 1736380800, quote: { text: "summarize this", }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.Body).toContain("[Quoting unknown sender]"); expect(payload.Body).toContain('"summarize this"'); expect(payload.ReplyToId).toBeUndefined(); expect(payload.ReplyToBody).toBe("summarize this"); expect(payload.ReplyToSender).toBe("unknown sender"); }); it("uses external_reply quote text for partial replies", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); replySpy.mockReset(); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 7, type: "private" }, text: "Sure, see below", date: 1736380800, external_reply: { message_id: 9002, text: "Can you summarize this?", from: { first_name: "Ada" }, quote: { text: "summarize this", }, }, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.Body).toContain("[Quoting Ada id:9002]"); expect(payload.Body).toContain('"summarize this"'); expect(payload.ReplyToId).toBe("9002"); expect(payload.ReplyToBody).toBe("summarize this"); expect(payload.ReplyToSender).toBe("Ada"); }); it("accepts group replies to the bot without explicit mention when requireMention is enabled", async () => { onSpy.mockReset(); replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { groups: { "*": { requireMention: true } } }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: 456, type: "group", title: "Ops Chat" }, text: "following up", date: 1736380800, reply_to_message: { message_id: 42, text: "original reply", from: { id: 999, first_name: "OpenClaw" }, }, }, me: { id: 999, username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.WasMentioned).toBe(true); }); it("inherits group allowlist + requireMention in topics", async () => { onSpy.mockReset(); replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { groupPolicy: "allowlist", groups: { "-1001234567890": { requireMention: false, allowFrom: ["123456789"], topics: { "99": {}, }, }, }, }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: -1001234567890, type: "supergroup", title: "Forum Group", is_forum: true, }, from: { id: 123456789, username: "testuser" }, text: "hello", date: 1736380800, message_thread_id: 99, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); }); it("prefers topic allowFrom over group allowFrom", async () => { onSpy.mockReset(); replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { groupPolicy: "allowlist", groups: { "-1001234567890": { allowFrom: ["123456789"], topics: { "99": { allowFrom: ["999999999"] }, }, }, }, }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: -1001234567890, type: "supergroup", title: "Forum Group", is_forum: true, }, from: { id: 123456789, username: "testuser" }, text: "hello", date: 1736380800, message_thread_id: 99, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(0); }); it("allows group messages for per-group groupPolicy open override (global groupPolicy allowlist)", async () => { onSpy.mockReset(); replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { groupPolicy: "allowlist", groups: { "-100123456789": { groupPolicy: "open", requireMention: false, }, }, }, }, }); readChannelAllowFromStore.mockResolvedValueOnce(["123456789"]); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: -100123456789, type: "group", title: "Test Group" }, from: { id: 999999, username: "random" }, text: "hello", date: 1736380800, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).toHaveBeenCalledTimes(1); }); it("blocks control commands from unauthorized senders in per-group open groups", async () => { onSpy.mockReset(); replySpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { groupPolicy: "allowlist", groups: { "-100123456789": { groupPolicy: "open", requireMention: false, }, }, }, }, }); readChannelAllowFromStore.mockResolvedValueOnce(["123456789"]); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message") as (ctx: Record) => Promise; await handler({ message: { chat: { id: -100123456789, type: "group", title: "Test Group" }, from: { id: 999999, username: "random" }, text: "/status", date: 1736380800, }, me: { username: "openclaw_bot" }, getFile: async () => ({ download: async () => new Uint8Array() }), }); expect(replySpy).not.toHaveBeenCalled(); }); it("sets command target session key for dm topic commands", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); commandSpy.mockReset(); replySpy.mockReset(); replySpy.mockResolvedValue({ text: "response" }); loadConfig.mockReturnValue({ commands: { native: true }, channels: { telegram: { dmPolicy: "pairing", }, }, }); readChannelAllowFromStore.mockResolvedValueOnce(["12345"]); createTelegramBot({ token: "tok" }); const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as | ((ctx: Record) => Promise) | undefined; if (!handler) { throw new Error("status command handler missing"); } await handler({ message: { chat: { id: 12345, type: "private" }, from: { id: 12345, username: "testuser" }, text: "/status", date: 1736380800, message_id: 42, message_thread_id: 99, }, match: "", }); expect(replySpy).toHaveBeenCalledTimes(1); const payload = replySpy.mock.calls[0][0]; expect(payload.CommandTargetSessionKey).toBe("agent:main:main:thread:99"); }); it("allows native DM commands for paired users", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); commandSpy.mockReset(); replySpy.mockReset(); replySpy.mockResolvedValue({ text: "response" }); loadConfig.mockReturnValue({ commands: { native: true }, channels: { telegram: { dmPolicy: "pairing", }, }, }); readChannelAllowFromStore.mockResolvedValueOnce(["12345"]); createTelegramBot({ token: "tok" }); const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as | ((ctx: Record) => Promise) | undefined; if (!handler) { throw new Error("status command handler missing"); } await handler({ message: { chat: { id: 12345, type: "private" }, from: { id: 12345, username: "testuser" }, text: "/status", date: 1736380800, message_id: 42, }, match: "", }); expect(replySpy).toHaveBeenCalledTimes(1); expect( sendMessageSpy.mock.calls.some( (call) => call[1] === "You are not authorized to use this command.", ), ).toBe(false); }); it("blocks native DM commands for unpaired users", async () => { onSpy.mockReset(); sendMessageSpy.mockReset(); commandSpy.mockReset(); replySpy.mockReset(); loadConfig.mockReturnValue({ commands: { native: true }, channels: { telegram: { dmPolicy: "pairing", }, }, }); readChannelAllowFromStore.mockResolvedValueOnce([]); createTelegramBot({ token: "tok" }); const handler = commandSpy.mock.calls.find((call) => call[0] === "status")?.[1] as | ((ctx: Record) => Promise) | undefined; if (!handler) { throw new Error("status command handler missing"); } await handler({ message: { chat: { id: 12345, type: "private" }, from: { id: 12345, username: "testuser" }, text: "/status", date: 1736380800, message_id: 42, }, match: "", }); expect(replySpy).not.toHaveBeenCalled(); expect(sendMessageSpy).toHaveBeenCalledWith( 12345, "You are not authorized to use this command.", ); }); it("registers message_reaction handler", () => { onSpy.mockReset(); createTelegramBot({ token: "tok" }); const reactionHandler = onSpy.mock.calls.find((call) => call[0] === "message_reaction"); expect(reactionHandler).toBeDefined(); }); it("enqueues system event for reaction", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", reactionNotifications: "all" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: 500 }, messageReaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada", username: "ada_bot" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: "👍" }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); expect(enqueueSystemEventSpy).toHaveBeenCalledWith( "Telegram reaction added: 👍 by Ada (@ada_bot) on msg 42", expect.objectContaining({ contextKey: expect.stringContaining("telegram:reaction:add:1234:42:9"), }), ); }); it("skips reaction when reactionNotifications is off", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset(); wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", reactionNotifications: "off" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: 501 }, messageReaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: "👍" }], }, }); expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); }); it("defaults reactionNotifications to own", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset(); wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: 502 }, messageReaction: { chat: { id: 1234, type: "private" }, message_id: 43, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: "👍" }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); }); it("allows reaction in all mode regardless of message sender", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset(); wasSentByBot.mockReturnValue(false); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", reactionNotifications: "all" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: 503 }, messageReaction: { chat: { id: 1234, type: "private" }, message_id: 99, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: "🎉" }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); expect(enqueueSystemEventSpy).toHaveBeenCalledWith( "Telegram reaction added: 🎉 by Ada on msg 99", expect.any(Object), ); }); it("skips reaction in own mode when message is not sent by bot", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset(); wasSentByBot.mockReturnValue(false); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", reactionNotifications: "own" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: 503 }, messageReaction: { chat: { id: 1234, type: "private" }, message_id: 99, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: "🎉" }], }, }); expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); }); it("allows reaction in own mode when message is sent by bot", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset(); wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", reactionNotifications: "own" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: 503 }, messageReaction: { chat: { id: 1234, type: "private" }, message_id: 99, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: "🎉" }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); }); it("skips reaction from bot users", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset(); wasSentByBot.mockReturnValue(true); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", reactionNotifications: "all" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: 503 }, messageReaction: { chat: { id: 1234, type: "private" }, message_id: 99, user: { id: 9, first_name: "Bot", is_bot: true }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: "🎉" }], }, }); expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); }); it("skips reaction removal (only processes added reactions)", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", reactionNotifications: "all" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: 504 }, messageReaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [{ type: "emoji", emoji: "👍" }], new_reaction: [], }, }); expect(enqueueSystemEventSpy).not.toHaveBeenCalled(); }); it("enqueues one event per added emoji reaction", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", reactionNotifications: "all" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: 505 }, messageReaction: { chat: { id: 1234, type: "private" }, message_id: 42, user: { id: 9, first_name: "Ada" }, date: 1736380800, old_reaction: [{ type: "emoji", emoji: "👍" }], new_reaction: [ { type: "emoji", emoji: "👍" }, { type: "emoji", emoji: "đŸ”Ĩ" }, { type: "emoji", emoji: "🎉" }, ], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(2); expect(enqueueSystemEventSpy.mock.calls.map((call) => call[0])).toEqual([ "Telegram reaction added: đŸ”Ĩ by Ada on msg 42", "Telegram reaction added: 🎉 by Ada on msg 42", ]); }); it("routes forum group reactions to the general topic (thread id not available on reactions)", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", reactionNotifications: "all" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; // MessageReactionUpdated does not include message_thread_id in the Bot API, // so forum reactions always route to the general topic (1). await handler({ update: { update_id: 505 }, messageReaction: { chat: { id: 5678, type: "supergroup", is_forum: true }, message_id: 100, user: { id: 10, first_name: "Bob", username: "bob_user" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: "đŸ”Ĩ" }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); expect(enqueueSystemEventSpy).toHaveBeenCalledWith( "Telegram reaction added: đŸ”Ĩ by Bob (@bob_user) on msg 100", expect.objectContaining({ sessionKey: expect.stringContaining("telegram:group:5678:topic:1"), contextKey: expect.stringContaining("telegram:reaction:add:5678:100:10"), }), ); }); it("uses correct session key for forum group reactions in general topic", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", reactionNotifications: "all" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: 506 }, messageReaction: { chat: { id: 5678, type: "supergroup", is_forum: true }, message_id: 101, // No message_thread_id - should default to general topic (1) user: { id: 10, first_name: "Bob" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: "👀" }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); expect(enqueueSystemEventSpy).toHaveBeenCalledWith( "Telegram reaction added: 👀 by Bob on msg 101", expect.objectContaining({ sessionKey: expect.stringContaining("telegram:group:5678:topic:1"), contextKey: expect.stringContaining("telegram:reaction:add:5678:101:10"), }), ); }); it("uses correct session key for regular group reactions without topic", async () => { onSpy.mockReset(); enqueueSystemEventSpy.mockReset(); loadConfig.mockReturnValue({ channels: { telegram: { dmPolicy: "open", reactionNotifications: "all" }, }, }); createTelegramBot({ token: "tok" }); const handler = getOnHandler("message_reaction") as ( ctx: Record, ) => Promise; await handler({ update: { update_id: 507 }, messageReaction: { chat: { id: 9999, type: "group" }, message_id: 200, user: { id: 11, first_name: "Charlie" }, date: 1736380800, old_reaction: [], new_reaction: [{ type: "emoji", emoji: "â¤ī¸" }], }, }); expect(enqueueSystemEventSpy).toHaveBeenCalledTimes(1); expect(enqueueSystemEventSpy).toHaveBeenCalledWith( "Telegram reaction added: â¤ī¸ by Charlie on msg 200", expect.objectContaining({ sessionKey: expect.stringContaining("telegram:group:9999"), contextKey: expect.stringContaining("telegram:reaction:add:9999:200:11"), }), ); // Verify session key does NOT contain :topic: const eventOptions = enqueueSystemEventSpy.mock.calls[0]?.[1] as { sessionKey?: string; }; const sessionKey = eventOptions.sessionKey ?? ""; expect(sessionKey).not.toContain(":topic:"); }); });