import type { Bot } from "grammy"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { getTelegramSendTestMocks, importTelegramSendModule, installTelegramSendTestHooks, } from "./send.test-harness.js"; import { clearSentMessageCache, recordSentMessage, wasSentByBot } from "./sent-message-cache.js"; installTelegramSendTestHooks(); const { botApi, botCtorSpy, loadConfig, loadWebMedia } = getTelegramSendTestMocks(); const { buildInlineKeyboard, createForumTopicTelegram, editMessageTelegram, reactMessageTelegram, sendMessageTelegram, sendPollTelegram, sendStickerTelegram, } = await importTelegramSendModule(); describe("sent-message-cache", () => { afterEach(() => { clearSentMessageCache(); }); it("records and retrieves sent messages", () => { recordSentMessage(123, 1); recordSentMessage(123, 2); recordSentMessage(456, 10); expect(wasSentByBot(123, 1)).toBe(true); expect(wasSentByBot(123, 2)).toBe(true); expect(wasSentByBot(456, 10)).toBe(true); expect(wasSentByBot(123, 3)).toBe(false); expect(wasSentByBot(789, 1)).toBe(false); }); it("handles string chat IDs", () => { recordSentMessage("123", 1); expect(wasSentByBot("123", 1)).toBe(true); expect(wasSentByBot(123, 1)).toBe(true); }); it("clears cache", () => { recordSentMessage(123, 1); expect(wasSentByBot(123, 1)).toBe(true); clearSentMessageCache(); expect(wasSentByBot(123, 1)).toBe(false); }); }); describe("buildInlineKeyboard", () => { it("returns undefined for empty input", () => { expect(buildInlineKeyboard()).toBeUndefined(); expect(buildInlineKeyboard([])).toBeUndefined(); }); it("builds inline keyboards for valid input", () => { const result = buildInlineKeyboard([ [{ text: "Option A", callback_data: "cmd:a" }], [ { text: "Option B", callback_data: "cmd:b" }, { text: "Option C", callback_data: "cmd:c" }, ], ]); expect(result).toEqual({ inline_keyboard: [ [{ text: "Option A", callback_data: "cmd:a" }], [ { text: "Option B", callback_data: "cmd:b" }, { text: "Option C", callback_data: "cmd:c" }, ], ], }); }); it("passes through button style", () => { const result = buildInlineKeyboard([ [ { text: "Option A", callback_data: "cmd:a", style: "primary", }, ], ]); expect(result).toEqual({ inline_keyboard: [ [ { text: "Option A", callback_data: "cmd:a", style: "primary", }, ], ], }); }); it("filters invalid buttons and empty rows", () => { const result = buildInlineKeyboard([ [ { text: "", callback_data: "cmd:skip" }, { text: "Ok", callback_data: "cmd:ok" }, ], [{ text: "Missing data", callback_data: "" }], [], ]); expect(result).toEqual({ inline_keyboard: [[{ text: "Ok", callback_data: "cmd:ok" }]], }); }); }); describe("sendMessageTelegram", () => { it("passes timeoutSeconds to grammY client when configured", async () => { loadConfig.mockReturnValue({ channels: { telegram: { timeoutSeconds: 60 } }, }); await sendMessageTelegram("123", "hi", { token: "tok" }); expect(botCtorSpy).toHaveBeenCalledWith( "tok", expect.objectContaining({ client: expect.objectContaining({ timeoutSeconds: 60 }), }), ); }); it("prefers per-account timeoutSeconds overrides", async () => { loadConfig.mockReturnValue({ channels: { telegram: { timeoutSeconds: 60, accounts: { foo: { timeoutSeconds: 61 } }, }, }, }); await sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" }); expect(botCtorSpy).toHaveBeenCalledWith( "tok", expect.objectContaining({ client: expect.objectContaining({ timeoutSeconds: 61 }), }), ); }); it("falls back to plain text when Telegram rejects HTML", async () => { const chatId = "123"; const parseErr = new Error( "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9", ); const sendMessage = vi .fn() .mockRejectedValueOnce(parseErr) .mockResolvedValueOnce({ message_id: 42, chat: { id: chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; const res = await sendMessageTelegram(chatId, "_oops_", { token: "tok", api, verbose: true, }); expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "oops", { parse_mode: "HTML", }); expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_oops_"); expect(res.chatId).toBe(chatId); expect(res.messageId).toBe("42"); }); it("adds link_preview_options when previews are disabled in config", async () => { const chatId = "123"; const sendMessage = vi.fn().mockResolvedValue({ message_id: 7, chat: { id: chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; loadConfig.mockReturnValue({ channels: { telegram: { linkPreview: false } }, }); await sendMessageTelegram(chatId, "hi", { token: "tok", api }); expect(sendMessage).toHaveBeenCalledWith(chatId, "hi", { parse_mode: "HTML", link_preview_options: { is_disabled: true }, }); }); it("keeps link_preview_options on plain-text fallback when disabled", async () => { const chatId = "123"; const parseErr = new Error( "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9", ); const sendMessage = vi .fn() .mockRejectedValueOnce(parseErr) .mockResolvedValueOnce({ message_id: 42, chat: { id: chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; loadConfig.mockReturnValue({ channels: { telegram: { linkPreview: false } }, }); await sendMessageTelegram(chatId, "_oops_", { token: "tok", api, }); expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "oops", { parse_mode: "HTML", link_preview_options: { is_disabled: true }, }); expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_oops_", { link_preview_options: { is_disabled: true }, }); }); it("uses native fetch for BAN compatibility when api is omitted", async () => { const originalFetch = globalThis.fetch; const originalBun = (globalThis as { Bun?: unknown }).Bun; const fetchSpy = vi.fn() as unknown as typeof fetch; globalThis.fetch = fetchSpy; (globalThis as { Bun?: unknown }).Bun = {}; botApi.sendMessage.mockResolvedValue({ message_id: 1, chat: { id: "123" }, }); try { await sendMessageTelegram("123", "hi", { token: "tok" }); const clientFetch = (botCtorSpy.mock.calls[0]?.[1] as { client?: { fetch?: unknown } }) ?.client?.fetch; expect(clientFetch).toBeTypeOf("function"); expect(clientFetch).not.toBe(fetchSpy); } finally { globalThis.fetch = originalFetch; if (originalBun === undefined) { delete (globalThis as { Bun?: unknown }).Bun; } else { (globalThis as { Bun?: unknown }).Bun = originalBun; } } }); it("normalizes chat ids with internal prefixes", async () => { const sendMessage = vi.fn().mockResolvedValue({ message_id: 1, chat: { id: "123" }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await sendMessageTelegram("telegram:123", "hi", { token: "tok", api, }); expect(sendMessage).toHaveBeenCalledWith("123", "hi", { parse_mode: "HTML", }); }); it("wraps chat-not-found with actionable context", async () => { const chatId = "123"; const err = new Error("400: Bad Request: chat not found"); const sendMessage = vi.fn().mockRejectedValue(err); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await expect(sendMessageTelegram(chatId, "hi", { token: "tok", api })).rejects.toThrow( /chat not found/i, ); await expect(sendMessageTelegram(chatId, "hi", { token: "tok", api })).rejects.toThrow( /chat_id=123/, ); }); it("preserves thread params in plain text fallback", async () => { const chatId = "-1001234567890"; const parseErr = new Error( "400: Bad Request: can't parse entities: Can't find end of the entity", ); const sendMessage = vi .fn() .mockRejectedValueOnce(parseErr) .mockResolvedValueOnce({ message_id: 60, chat: { id: chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; const res = await sendMessageTelegram(chatId, "_bad markdown_", { token: "tok", api, messageThreadId: 271, replyToMessageId: 100, }); expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "bad markdown", { parse_mode: "HTML", message_thread_id: 271, reply_to_message_id: 100, }); expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_bad markdown_", { message_thread_id: 271, reply_to_message_id: 100, }); expect(res.messageId).toBe("60"); }); it("includes thread params in media messages", async () => { const chatId = "-1001234567890"; const sendPhoto = vi.fn().mockResolvedValue({ message_id: 58, chat: { id: chatId }, }); const api = { sendPhoto } as unknown as { sendPhoto: typeof sendPhoto; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("fake-image"), contentType: "image/jpeg", fileName: "photo.jpg", }); await sendMessageTelegram(chatId, "photo in topic", { token: "tok", api, mediaUrl: "https://example.com/photo.jpg", messageThreadId: 99, }); expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { caption: "photo in topic", parse_mode: "HTML", message_thread_id: 99, }); }); it("splits long captions into media + text messages when text exceeds 1024 chars", async () => { const chatId = "123"; const longText = "A".repeat(1100); const sendPhoto = vi.fn().mockResolvedValue({ message_id: 70, chat: { id: chatId }, }); const sendMessage = vi.fn().mockResolvedValue({ message_id: 71, chat: { id: chatId }, }); const api = { sendPhoto, sendMessage } as unknown as { sendPhoto: typeof sendPhoto; sendMessage: typeof sendMessage; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("fake-image"), contentType: "image/jpeg", fileName: "photo.jpg", }); const res = await sendMessageTelegram(chatId, longText, { token: "tok", api, mediaUrl: "https://example.com/photo.jpg", }); expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { caption: undefined, }); expect(sendMessage).toHaveBeenCalledWith(chatId, longText, { parse_mode: "HTML", }); expect(res.messageId).toBe("71"); }); it("uses caption when text is within 1024 char limit", async () => { const chatId = "123"; const shortText = "B".repeat(1024); const sendPhoto = vi.fn().mockResolvedValue({ message_id: 72, chat: { id: chatId }, }); const sendMessage = vi.fn(); const api = { sendPhoto, sendMessage } as unknown as { sendPhoto: typeof sendPhoto; sendMessage: typeof sendMessage; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("fake-image"), contentType: "image/jpeg", fileName: "photo.jpg", }); const res = await sendMessageTelegram(chatId, shortText, { token: "tok", api, mediaUrl: "https://example.com/photo.jpg", }); expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { caption: shortText, parse_mode: "HTML", }); expect(sendMessage).not.toHaveBeenCalled(); expect(res.messageId).toBe("72"); }); it("renders markdown in media captions", async () => { const chatId = "123"; const caption = "hi **boss**"; const sendPhoto = vi.fn().mockResolvedValue({ message_id: 90, chat: { id: chatId }, }); const api = { sendPhoto } as unknown as { sendPhoto: typeof sendPhoto; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("fake-image"), contentType: "image/jpeg", fileName: "photo.jpg", }); await sendMessageTelegram(chatId, caption, { token: "tok", api, mediaUrl: "https://example.com/photo.jpg", }); expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), { caption: "hi boss", parse_mode: "HTML", }); }); it("sends video as video note when asVideoNote is true", async () => { const chatId = "123"; const text = "ignored caption context"; const sendVideoNote = vi.fn().mockResolvedValue({ message_id: 101, chat: { id: chatId }, }); const sendMessage = vi.fn().mockResolvedValue({ message_id: 102, chat: { id: chatId }, }); const api = { sendVideoNote, sendMessage } as unknown as { sendVideoNote: typeof sendVideoNote; sendMessage: typeof sendMessage; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("fake-video"), contentType: "video/mp4", fileName: "video.mp4", }); const res = await sendMessageTelegram(chatId, text, { token: "tok", api, mediaUrl: "https://example.com/video.mp4", asVideoNote: true, }); expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {}); expect(sendMessage).toHaveBeenCalledWith(chatId, text, { parse_mode: "HTML", }); expect(res.messageId).toBe("102"); }); it("sends regular video when asVideoNote is false", async () => { const chatId = "123"; const text = "my caption"; const sendVideo = vi.fn().mockResolvedValue({ message_id: 201, chat: { id: chatId }, }); const api = { sendVideo } as unknown as { sendVideo: typeof sendVideo; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("fake-video"), contentType: "video/mp4", fileName: "video.mp4", }); const res = await sendMessageTelegram(chatId, text, { token: "tok", api, mediaUrl: "https://example.com/video.mp4", asVideoNote: false, }); expect(sendVideo).toHaveBeenCalledWith(chatId, expect.anything(), { caption: expect.any(String), parse_mode: "HTML", }); expect(res.messageId).toBe("201"); }); it("adds reply_markup to separate text message for video notes", async () => { const chatId = "123"; const text = "Check this out"; const sendVideoNote = vi.fn().mockResolvedValue({ message_id: 301, chat: { id: chatId }, }); const sendMessage = vi.fn().mockResolvedValue({ message_id: 302, chat: { id: chatId }, }); const api = { sendVideoNote, sendMessage } as unknown as { sendVideoNote: typeof sendVideoNote; sendMessage: typeof sendMessage; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("fake-video"), contentType: "video/mp4", fileName: "video.mp4", }); await sendMessageTelegram(chatId, text, { token: "tok", api, mediaUrl: "https://example.com/video.mp4", asVideoNote: true, buttons: [[{ text: "Btn", callback_data: "dat" }]], }); expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {}); expect(sendMessage).toHaveBeenCalledWith(chatId, text, { parse_mode: "HTML", reply_markup: { inline_keyboard: [[{ text: "Btn", callback_data: "dat" }]], }, }); }); it("threads video note and text message correctly", async () => { const chatId = "123"; const text = "Threaded reply"; const sendVideoNote = vi.fn().mockResolvedValue({ message_id: 401, chat: { id: chatId }, }); const sendMessage = vi.fn().mockResolvedValue({ message_id: 402, chat: { id: chatId }, }); const api = { sendVideoNote, sendMessage } as unknown as { sendVideoNote: typeof sendVideoNote; sendMessage: typeof sendMessage; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("fake-video"), contentType: "video/mp4", fileName: "video.mp4", }); await sendMessageTelegram(chatId, text, { token: "tok", api, mediaUrl: "https://example.com/video.mp4", asVideoNote: true, replyToMessageId: 999, }); expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), { reply_to_message_id: 999, }); expect(sendMessage).toHaveBeenCalledWith(chatId, text, { parse_mode: "HTML", reply_to_message_id: 999, }); }); it("retries on transient errors with retry_after", async () => { vi.useFakeTimers(); const chatId = "123"; const err = Object.assign(new Error("429"), { parameters: { retry_after: 0.5 }, }); const sendMessage = vi .fn() .mockRejectedValueOnce(err) .mockResolvedValueOnce({ message_id: 1, chat: { id: chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; const setTimeoutSpy = vi.spyOn(global, "setTimeout"); const promise = sendMessageTelegram(chatId, "hi", { token: "tok", api, retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 }, }); await vi.runAllTimersAsync(); await expect(promise).resolves.toEqual({ messageId: "1", chatId }); expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(500); setTimeoutSpy.mockRestore(); vi.useRealTimers(); }); it("does not retry on non-transient errors", async () => { const chatId = "123"; const sendMessage = vi.fn().mockRejectedValue(new Error("400: Bad Request")); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await expect( sendMessageTelegram(chatId, "hi", { token: "tok", api, retry: { attempts: 3, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, }), ).rejects.toThrow(/Bad Request/); expect(sendMessage).toHaveBeenCalledTimes(1); }); it("sends GIF media as animation", async () => { const chatId = "123"; const sendAnimation = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: chatId }, }); const api = { sendAnimation } as unknown as { sendAnimation: typeof sendAnimation; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("GIF89a"), fileName: "fun.gif", }); const res = await sendMessageTelegram(chatId, "caption", { token: "tok", api, mediaUrl: "https://example.com/fun", }); expect(sendAnimation).toHaveBeenCalledTimes(1); expect(sendAnimation).toHaveBeenCalledWith(chatId, expect.anything(), { caption: "caption", parse_mode: "HTML", }); expect(res.messageId).toBe("9"); }); it("sends audio media as files by default", async () => { const chatId = "123"; const sendAudio = vi.fn().mockResolvedValue({ message_id: 10, chat: { id: chatId }, }); const sendVoice = vi.fn().mockResolvedValue({ message_id: 11, chat: { id: chatId }, }); const api = { sendAudio, sendVoice } as unknown as { sendAudio: typeof sendAudio; sendVoice: typeof sendVoice; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("audio"), contentType: "audio/mpeg", fileName: "clip.mp3", }); await sendMessageTelegram(chatId, "caption", { token: "tok", api, mediaUrl: "https://example.com/clip.mp3", }); expect(sendAudio).toHaveBeenCalledWith(chatId, expect.anything(), { caption: "caption", parse_mode: "HTML", }); expect(sendVoice).not.toHaveBeenCalled(); }); it("sends voice messages when asVoice is true and preserves thread params", async () => { const chatId = "-1001234567890"; const sendAudio = vi.fn().mockResolvedValue({ message_id: 12, chat: { id: chatId }, }); const sendVoice = vi.fn().mockResolvedValue({ message_id: 13, chat: { id: chatId }, }); const api = { sendAudio, sendVoice } as unknown as { sendAudio: typeof sendAudio; sendVoice: typeof sendVoice; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("voice"), contentType: "audio/ogg", fileName: "note.ogg", }); await sendMessageTelegram(chatId, "voice note", { token: "tok", api, mediaUrl: "https://example.com/note.ogg", asVoice: true, messageThreadId: 271, replyToMessageId: 500, }); expect(sendVoice).toHaveBeenCalledWith(chatId, expect.anything(), { caption: "voice note", parse_mode: "HTML", message_thread_id: 271, reply_to_message_id: 500, }); expect(sendAudio).not.toHaveBeenCalled(); }); it("falls back to audio when asVoice is true but media is not voice compatible", async () => { const chatId = "123"; const sendAudio = vi.fn().mockResolvedValue({ message_id: 14, chat: { id: chatId }, }); const sendVoice = vi.fn().mockResolvedValue({ message_id: 15, chat: { id: chatId }, }); const api = { sendAudio, sendVoice } as unknown as { sendAudio: typeof sendAudio; sendVoice: typeof sendVoice; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("audio"), contentType: "audio/wav", fileName: "clip.wav", }); await sendMessageTelegram(chatId, "caption", { token: "tok", api, mediaUrl: "https://example.com/clip.wav", asVoice: true, }); expect(sendAudio).toHaveBeenCalledWith(chatId, expect.anything(), { caption: "caption", parse_mode: "HTML", }); expect(sendVoice).not.toHaveBeenCalled(); }); it("sends MP3 as voice when asVoice is true", async () => { const chatId = "123"; const sendAudio = vi.fn().mockResolvedValue({ message_id: 16, chat: { id: chatId }, }); const sendVoice = vi.fn().mockResolvedValue({ message_id: 17, chat: { id: chatId }, }); const api = { sendAudio, sendVoice } as unknown as { sendAudio: typeof sendAudio; sendVoice: typeof sendVoice; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("audio"), contentType: "audio/mpeg", fileName: "clip.mp3", }); await sendMessageTelegram(chatId, "caption", { token: "tok", api, mediaUrl: "https://example.com/clip.mp3", asVoice: true, }); expect(sendVoice).toHaveBeenCalledWith(chatId, expect.anything(), { caption: "caption", parse_mode: "HTML", }); expect(sendAudio).not.toHaveBeenCalled(); }); it("includes message_thread_id for forum topic messages", async () => { const chatId = "-1001234567890"; const sendMessage = vi.fn().mockResolvedValue({ message_id: 55, chat: { id: chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await sendMessageTelegram(chatId, "hello forum", { token: "tok", api, messageThreadId: 271, }); expect(sendMessage).toHaveBeenCalledWith(chatId, "hello forum", { parse_mode: "HTML", message_thread_id: 271, }); }); it("keeps message_thread_id for private chat topic sends (#18974)", async () => { const chatId = "123456789"; const sendMessage = vi.fn().mockResolvedValue({ message_id: 56, chat: { id: chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await sendMessageTelegram(chatId, "hello private", { token: "tok", api, messageThreadId: 271, }); expect(sendMessage).toHaveBeenCalledWith(chatId, "hello private", { parse_mode: "HTML", message_thread_id: 271, }); }); it("keeps message_thread_id for group chat sends (#17242)", async () => { // Group/supergroup chats have negative IDs. const chatId = "-1001234567890"; const sendMessage = vi.fn().mockResolvedValue({ message_id: 57, chat: { id: chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await sendMessageTelegram(chatId, "hello group", { token: "tok", api, messageThreadId: 271, }); expect(sendMessage).toHaveBeenCalledWith(chatId, "hello group", { parse_mode: "HTML", message_thread_id: 271, }); }); it("retries without message_thread_id when Telegram reports missing thread", async () => { const chatId = "-100123"; const threadErr = new Error("400: Bad Request: message thread not found"); const sendMessage = vi .fn() .mockRejectedValueOnce(threadErr) .mockResolvedValueOnce({ message_id: 58, chat: { id: chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; const res = await sendMessageTelegram(chatId, "hello forum", { token: "tok", api, messageThreadId: 271, }); expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "hello forum", { parse_mode: "HTML", message_thread_id: 271, }); expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "hello forum", { parse_mode: "HTML", }); expect(res.messageId).toBe("58"); }); it("retries private chat sends without message_thread_id on thread-not-found", async () => { const chatId = "123456789"; const threadErr = new Error("400: Bad Request: message thread not found"); const sendMessage = vi .fn() .mockRejectedValueOnce(threadErr) .mockResolvedValueOnce({ message_id: 59, chat: { id: chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; const res = await sendMessageTelegram(chatId, "hello private", { token: "tok", api, messageThreadId: 271, }); expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "hello private", { parse_mode: "HTML", message_thread_id: 271, }); expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "hello private", { parse_mode: "HTML", }); expect(res.messageId).toBe("59"); }); it("does not retry thread-not-found when no message_thread_id was provided", async () => { const chatId = "123"; const threadErr = new Error("400: Bad Request: message thread not found"); const sendMessage = vi.fn().mockRejectedValueOnce(threadErr); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await expect( sendMessageTelegram(chatId, "hello forum", { token: "tok", api, }), ).rejects.toThrow("message thread not found"); expect(sendMessage).toHaveBeenCalledTimes(1); }); it("does not retry without message_thread_id on chat-not-found", async () => { const chatId = "123456789"; const chatErr = new Error("400: Bad Request: chat not found"); const sendMessage = vi.fn().mockRejectedValueOnce(chatErr); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await expect( sendMessageTelegram(chatId, "hello private", { token: "tok", api, messageThreadId: 271, }), ).rejects.toThrow(/chat not found/i); expect(sendMessage).toHaveBeenCalledTimes(1); expect(sendMessage).toHaveBeenCalledWith(chatId, "hello private", { parse_mode: "HTML", message_thread_id: 271, }); }); it("sets disable_notification when silent is true", async () => { const chatId = "123"; const sendMessage = vi.fn().mockResolvedValue({ message_id: 1, chat: { id: chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await sendMessageTelegram(chatId, "hi", { token: "tok", api, silent: true, }); expect(sendMessage).toHaveBeenCalledWith(chatId, "hi", { parse_mode: "HTML", disable_notification: true, }); }); it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => { const chatId = "-1001234567890"; const sendMessage = vi.fn().mockResolvedValue({ message_id: 55, chat: { id: chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await sendMessageTelegram(`telegram:group:${chatId}:topic:271`, "hello forum", { token: "tok", api, }); expect(sendMessage).toHaveBeenCalledWith(chatId, "hello forum", { parse_mode: "HTML", message_thread_id: 271, }); }); it("includes reply_to_message_id for threaded replies", async () => { const chatId = "123"; const sendMessage = vi.fn().mockResolvedValue({ message_id: 56, chat: { id: chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await sendMessageTelegram(chatId, "reply text", { token: "tok", api, replyToMessageId: 100, }); expect(sendMessage).toHaveBeenCalledWith(chatId, "reply text", { parse_mode: "HTML", reply_to_message_id: 100, }); }); it("retries media sends without message_thread_id when thread is missing", async () => { const chatId = "-100123"; const threadErr = new Error("400: Bad Request: message thread not found"); const sendPhoto = vi .fn() .mockRejectedValueOnce(threadErr) .mockResolvedValueOnce({ message_id: 59, chat: { id: chatId }, }); const api = { sendPhoto } as unknown as { sendPhoto: typeof sendPhoto; }; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("fake-image"), contentType: "image/jpeg", fileName: "photo.jpg", }); const res = await sendMessageTelegram(chatId, "photo", { token: "tok", api, mediaUrl: "https://example.com/photo.jpg", messageThreadId: 271, }); expect(sendPhoto).toHaveBeenNthCalledWith(1, chatId, expect.anything(), { caption: "photo", parse_mode: "HTML", message_thread_id: 271, }); expect(sendPhoto).toHaveBeenNthCalledWith(2, chatId, expect.anything(), { caption: "photo", parse_mode: "HTML", }); expect(res.messageId).toBe("59"); }); }); describe("reactMessageTelegram", () => { it("sends emoji reactions", async () => { const setMessageReaction = vi.fn().mockResolvedValue(undefined); const api = { setMessageReaction } as unknown as { setMessageReaction: typeof setMessageReaction; }; await reactMessageTelegram("telegram:123", "456", "✅", { token: "tok", api, }); expect(setMessageReaction).toHaveBeenCalledWith("123", 456, [{ type: "emoji", emoji: "✅" }]); }); it("removes reactions when emoji is empty", async () => { const setMessageReaction = vi.fn().mockResolvedValue(undefined); const api = { setMessageReaction } as unknown as { setMessageReaction: typeof setMessageReaction; }; await reactMessageTelegram("123", 456, "", { token: "tok", api, }); expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []); }); it("removes reactions when remove flag is set", async () => { const setMessageReaction = vi.fn().mockResolvedValue(undefined); const api = { setMessageReaction } as unknown as { setMessageReaction: typeof setMessageReaction; }; await reactMessageTelegram("123", 456, "✅", { token: "tok", api, remove: true, }); expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []); }); }); describe("sendStickerTelegram", () => { beforeEach(() => { loadConfig.mockReturnValue({}); botApi.sendSticker.mockReset(); botCtorSpy.mockReset(); }); it("sends a sticker by file_id", async () => { const chatId = "123"; const fileId = "CAACAgIAAxkBAAI...sticker_file_id"; const sendSticker = vi.fn().mockResolvedValue({ message_id: 100, chat: { id: chatId }, }); const api = { sendSticker } as unknown as { sendSticker: typeof sendSticker; }; const res = await sendStickerTelegram(chatId, fileId, { token: "tok", api, }); expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, undefined); expect(res.messageId).toBe("100"); expect(res.chatId).toBe(chatId); }); it("throws error when fileId is blank", async () => { for (const fileId of ["", " "]) { await expect(sendStickerTelegram("123", fileId, { token: "tok" })).rejects.toThrow( /file_id is required/i, ); } }); it("retries sticker sends without message_thread_id when thread is missing", async () => { const chatId = "-100123"; const threadErr = new Error("400: Bad Request: message thread not found"); const sendSticker = vi .fn() .mockRejectedValueOnce(threadErr) .mockResolvedValueOnce({ message_id: 109, chat: { id: chatId }, }); const api = { sendSticker } as unknown as { sendSticker: typeof sendSticker; }; const res = await sendStickerTelegram(chatId, "fileId123", { token: "tok", api, messageThreadId: 271, }); expect(sendSticker).toHaveBeenNthCalledWith(1, chatId, "fileId123", { message_thread_id: 271, }); expect(sendSticker).toHaveBeenNthCalledWith(2, chatId, "fileId123", undefined); expect(res.messageId).toBe("109"); }); it("includes reply_to_message_id for threaded replies", async () => { const chatId = "123"; const fileId = "CAACAgIAAxkBAAI...sticker_file_id"; const sendSticker = vi.fn().mockResolvedValue({ message_id: 102, chat: { id: chatId }, }); const api = { sendSticker } as unknown as { sendSticker: typeof sendSticker; }; await sendStickerTelegram(chatId, fileId, { token: "tok", api, replyToMessageId: 500, }); expect(sendSticker).toHaveBeenCalledWith(chatId, fileId, { reply_to_message_id: 500, }); }); it("wraps chat-not-found with actionable context", async () => { const chatId = "123"; const err = new Error("400: Bad Request: chat not found"); const sendSticker = vi.fn().mockRejectedValue(err); const api = { sendSticker } as unknown as { sendSticker: typeof sendSticker; }; await expect(sendStickerTelegram(chatId, "fileId123", { token: "tok", api })).rejects.toThrow( /chat not found/i, ); await expect(sendStickerTelegram(chatId, "fileId123", { token: "tok", api })).rejects.toThrow( /chat_id=123/, ); }); it("trims whitespace from fileId", async () => { const chatId = "123"; const sendSticker = vi.fn().mockResolvedValue({ message_id: 106, chat: { id: chatId }, }); const api = { sendSticker } as unknown as { sendSticker: typeof sendSticker; }; await sendStickerTelegram(chatId, " fileId123 ", { token: "tok", api, }); expect(sendSticker).toHaveBeenCalledWith(chatId, "fileId123", undefined); }); }); describe("editMessageTelegram", () => { beforeEach(() => { botApi.editMessageText.mockReset(); botCtorSpy.mockReset(); }); it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => { botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); await editMessageTelegram("123", 1, "hi", { token: "tok", cfg: {}, }); expect(botCtorSpy).toHaveBeenCalledTimes(1); expect(botCtorSpy.mock.calls[0]?.[0]).toBe("tok"); expect(botApi.editMessageText).toHaveBeenCalledTimes(1); const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; expect(params).toEqual(expect.objectContaining({ parse_mode: "HTML" })); expect(params).not.toHaveProperty("reply_markup"); }); it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => { botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); await editMessageTelegram("123", 1, "hi", { token: "tok", cfg: {}, buttons: [], }); expect(botApi.editMessageText).toHaveBeenCalledTimes(1); const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; expect(params).toEqual( expect.objectContaining({ parse_mode: "HTML", reply_markup: { inline_keyboard: [] }, }), ); }); it("falls back to plain text when Telegram HTML parse fails (and preserves reply_markup)", async () => { botApi.editMessageText .mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities")) .mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } }); await editMessageTelegram("123", 1, " html", { token: "tok", cfg: {}, buttons: [], }); expect(botApi.editMessageText).toHaveBeenCalledTimes(2); const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; expect(firstParams).toEqual( expect.objectContaining({ parse_mode: "HTML", reply_markup: { inline_keyboard: [] }, }), ); const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record; expect(secondParams).toEqual( expect.objectContaining({ reply_markup: { inline_keyboard: [] }, }), ); }); it("treats 'message is not modified' as success", async () => { botApi.editMessageText.mockRejectedValueOnce( new Error( "400: Bad Request: message is not modified: specified new message content and reply markup are exactly the same as a current content and reply markup of the message", ), ); await expect( editMessageTelegram("123", 1, "hi", { token: "tok", cfg: {}, }), ).resolves.toEqual({ ok: true, messageId: "1", chatId: "123" }); expect(botApi.editMessageText).toHaveBeenCalledTimes(1); }); it("disables link previews when linkPreview is false", async () => { botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); await editMessageTelegram("123", 1, "https://example.com", { token: "tok", cfg: {}, linkPreview: false, }); expect(botApi.editMessageText).toHaveBeenCalledTimes(1); const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; expect(params).toEqual( expect.objectContaining({ parse_mode: "HTML", link_preview_options: { is_disabled: true }, }), ); }); }); describe("sendPollTelegram", () => { it("maps durationSeconds to open_period", async () => { const api = { sendPoll: vi.fn(async () => ({ message_id: 123, chat: { id: 555 }, poll: { id: "p1" } })), }; const res = await sendPollTelegram( "123", { question: " Q ", options: [" A ", "B "], durationSeconds: 60 }, { token: "t", api: api as unknown as Bot["api"] }, ); expect(res).toEqual({ messageId: "123", chatId: "555", pollId: "p1" }); expect(api.sendPoll).toHaveBeenCalledTimes(1); const sendPollMock = api.sendPoll as ReturnType; expect(sendPollMock.mock.calls[0]?.[0]).toBe("123"); expect(sendPollMock.mock.calls[0]?.[1]).toBe("Q"); expect(sendPollMock.mock.calls[0]?.[2]).toEqual(["A", "B"]); expect(sendPollMock.mock.calls[0]?.[3]).toMatchObject({ open_period: 60 }); }); it("retries without message_thread_id on thread-not-found", async () => { const api = { sendPoll: vi.fn( async (_chatId: string, _question: string, _options: string[], params: unknown) => { const p = params as { message_thread_id?: unknown } | undefined; if (p?.message_thread_id) { throw new Error("400: Bad Request: message thread not found"); } return { message_id: 1, chat: { id: 2 }, poll: { id: "p2" } }; }, ), }; const res = await sendPollTelegram( "-100123", { question: "Q", options: ["A", "B"] }, { token: "t", api: api as unknown as Bot["api"], messageThreadId: 99 }, ); expect(res).toEqual({ messageId: "1", chatId: "2", pollId: "p2" }); expect(api.sendPoll).toHaveBeenCalledTimes(2); expect(api.sendPoll.mock.calls[0]?.[3]).toMatchObject({ message_thread_id: 99 }); expect( (api.sendPoll.mock.calls[1]?.[3] as { message_thread_id?: unknown } | undefined) ?.message_thread_id, ).toBeUndefined(); }); it("rejects durationHours for Telegram polls", async () => { const api = { sendPoll: vi.fn() }; await expect( sendPollTelegram( "123", { question: "Q", options: ["A", "B"], durationHours: 1 }, { token: "t", api: api as unknown as Bot["api"] }, ), ).rejects.toThrow(/durationHours is not supported/i); expect(api.sendPoll).not.toHaveBeenCalled(); }); }); describe("createForumTopicTelegram", () => { it("uses base chat id when target includes topic suffix", async () => { const createForumTopic = vi.fn().mockResolvedValue({ message_thread_id: 272, name: "Build Updates", }); const api = { createForumTopic } as unknown as Bot["api"]; const result = await createForumTopicTelegram("telegram:group:-1001234567890:topic:271", "x", { token: "tok", api, }); expect(createForumTopic).toHaveBeenCalledWith("-1001234567890", "x", undefined); expect(result).toEqual({ topicId: 272, name: "Build Updates", chatId: "-1001234567890", }); }); it("forwards optional icon fields", async () => { const createForumTopic = vi.fn().mockResolvedValue({ message_thread_id: 300, name: "Roadmap", }); const api = { createForumTopic } as unknown as Bot["api"]; await createForumTopicTelegram("-1001234567890", "Roadmap", { token: "tok", api, iconColor: 0x6fb9f0, iconCustomEmojiId: " 1234567890 ", }); expect(createForumTopic).toHaveBeenCalledWith("-1001234567890", "Roadmap", { icon_color: 0x6fb9f0, icon_custom_emoji_id: "1234567890", }); }); });