import type { Bot } from "grammy"; import { afterEach, describe, expect, it, vi } from "vitest"; import { importFreshModule } from "../../../test/helpers/import-fresh.js"; import { getTelegramSendTestMocks, importTelegramSendModule, installTelegramSendTestHooks, } from "./send.test-harness.js"; import { clearSentMessageCache, recordSentMessage, wasSentByBot } from "./sent-message-cache.js"; installTelegramSendTestHooks(); const { botApi, botCtorSpy, loadConfig, loadWebMedia, maybePersistResolvedTelegramTarget } = getTelegramSendTestMocks(); const { buildInlineKeyboard, createForumTopicTelegram, editForumTopicTelegram, editMessageTelegram, pinMessageTelegram, reactMessageTelegram, renameForumTopicTelegram, sendMessageTelegram, sendTypingTelegram, sendPollTelegram, sendStickerTelegram, unpinMessageTelegram, } = await importTelegramSendModule(); async function expectChatNotFoundWithChatId( action: Promise, expectedChatId: string, ): Promise { try { await action; throw new Error("Expected action to reject with chat-not-found context"); } catch (error) { if ( error instanceof Error && error.message === "Expected action to reject with chat-not-found context" ) { throw error; } const message = error instanceof Error ? error.message : String(error); expect(message).toMatch(/chat not found/i); expect(message).toMatch(new RegExp(`chat_id=${expectedChatId}`)); } } function mockLoadedMedia({ buffer = Buffer.from("media"), contentType, fileName, }: { buffer?: Buffer; contentType?: string; fileName?: string; }): void { loadWebMedia.mockResolvedValueOnce({ buffer, ...(contentType ? { contentType } : {}), ...(fileName ? { fileName } : {}), }); } 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); }); it("shares sent-message state across distinct module instances", async () => { const cacheA = await importFreshModule( import.meta.url, "./sent-message-cache.js?scope=shared-a", ); const cacheB = await importFreshModule( import.meta.url, "./sent-message-cache.js?scope=shared-b", ); cacheA.clearSentMessageCache(); try { cacheA.recordSentMessage(123, 1); expect(cacheB.wasSentByBot(123, 1)).toBe(true); cacheB.clearSentMessageCache(); expect(cacheA.wasSentByBot(123, 1)).toBe(false); } finally { cacheA.clearSentMessageCache(); } }); }); describe("buildInlineKeyboard", () => { it("normalizes keyboard inputs", () => { const cases: Array<{ name: string; input: Parameters[0]; expected: ReturnType; }> = [ { name: "empty input", input: undefined, expected: undefined, }, { name: "empty rows", input: [], expected: undefined, }, { name: "valid rows", input: [ [{ text: "Option A", callback_data: "cmd:a" }], [ { text: "Option B", callback_data: "cmd:b" }, { text: "Option C", callback_data: "cmd:c" }, ], ], expected: { inline_keyboard: [ [{ text: "Option A", callback_data: "cmd:a" }], [ { text: "Option B", callback_data: "cmd:b" }, { text: "Option C", callback_data: "cmd:c" }, ], ], }, }, { name: "keeps button style fields", input: [ [ { text: "Option A", callback_data: "cmd:a", style: "primary", }, ], ], expected: { inline_keyboard: [ [ { text: "Option A", callback_data: "cmd:a", style: "primary", }, ], ], }, }, { name: "filters invalid buttons and empty rows", input: [ [ { text: "", callback_data: "cmd:skip" }, { text: "Ok", callback_data: "cmd:ok" }, ], [{ text: "Missing data", callback_data: "" }], [], ], expected: { inline_keyboard: [[{ text: "Ok", callback_data: "cmd:ok" }]], }, }, ]; for (const testCase of cases) { const input = testCase.input?.map((row) => row.map((button) => ({ ...button }))); expect(buildInlineKeyboard(input), testCase.name).toEqual(testCase.expected); } }); }); describe("sendMessageTelegram", () => { it("sends typing to the resolved chat and topic", async () => { loadConfig.mockReturnValue({ channels: { telegram: { botToken: "tok", }, }, }); botApi.sendChatAction.mockResolvedValue(true); await sendTypingTelegram("telegram:group:-1001234567890:topic:271", { accountId: "default", }); expect(botApi.sendChatAction).toHaveBeenCalledWith("-1001234567890", "typing", { message_thread_id: 271, }); }); it("pins and unpins Telegram messages", async () => { loadConfig.mockReturnValue({ channels: { telegram: { botToken: "tok", }, }, }); botApi.pinChatMessage.mockResolvedValue(true); botApi.unpinChatMessage.mockResolvedValue(true); await pinMessageTelegram("-1001234567890", 101, { accountId: "default" }); await unpinMessageTelegram("-1001234567890", 101, { accountId: "default" }); expect(botApi.pinChatMessage).toHaveBeenCalledWith("-1001234567890", 101, { disable_notification: true, }); expect(botApi.unpinChatMessage).toHaveBeenCalledWith("-1001234567890", 101); }); it("renames a Telegram forum topic", async () => { loadConfig.mockReturnValue({ channels: { telegram: { botToken: "tok", }, }, }); botApi.editForumTopic.mockResolvedValue(true); await renameForumTopicTelegram("-1001234567890", 271, "Codex Thread", { accountId: "default", }); expect(botApi.editForumTopic).toHaveBeenCalledWith("-1001234567890", 271, { name: "Codex Thread", }); }); it("edits a Telegram forum topic name and icon via the shared helper", async () => { loadConfig.mockReturnValue({ channels: { telegram: { botToken: "tok", }, }, }); botApi.editForumTopic.mockResolvedValue(true); await editForumTopicTelegram("-1001234567890", 271, { accountId: "default", name: "Codex Thread", iconCustomEmojiId: "emoji-123", }); expect(botApi.editForumTopic).toHaveBeenCalledWith("-1001234567890", 271, { name: "Codex Thread", icon_custom_emoji_id: "emoji-123", }); }); it("strips topic suffixes before editing a Telegram forum topic", async () => { loadConfig.mockReturnValue({ channels: { telegram: { botToken: "tok", }, }, }); botApi.editForumTopic.mockResolvedValue(true); await editForumTopicTelegram("telegram:group:-1001234567890:topic:271", 271, { accountId: "default", name: "Codex Thread", }); expect(botApi.editForumTopic).toHaveBeenCalledWith("-1001234567890", 271, { name: "Codex Thread", }); }); it("rejects empty topic edits", async () => { await expect( editForumTopicTelegram("-1001234567890", 271, { accountId: "default", }), ).rejects.toThrow("Telegram forum topic update requires a name or iconCustomEmojiId"); await expect( editForumTopicTelegram("-1001234567890", 271, { accountId: "default", iconCustomEmojiId: " ", }), ).rejects.toThrow("Telegram forum topic icon custom emoji ID is required"); }); it("applies timeoutSeconds config precedence", async () => { const cases = [ { name: "global telegram timeout", cfg: { channels: { telegram: { timeoutSeconds: 60 } } }, opts: { token: "tok" }, expectedTimeout: 60, }, { name: "per-account timeout override", cfg: { channels: { telegram: { timeoutSeconds: 60, accounts: { foo: { timeoutSeconds: 61 } }, }, }, }, opts: { token: "tok", accountId: "foo" }, expectedTimeout: 61, }, ] as const; for (const testCase of cases) { botCtorSpy.mockClear(); loadConfig.mockReturnValue(testCase.cfg); botApi.sendMessage.mockResolvedValue({ message_id: 1, chat: { id: "123" }, }); await sendMessageTelegram("123", "hi", testCase.opts); expect(botCtorSpy, testCase.name).toHaveBeenCalledWith( "tok", expect.objectContaining({ client: expect.objectContaining({ timeoutSeconds: testCase.expectedTimeout }), }), ); } }); it("falls back to plain text when Telegram rejects HTML and preserves send params", async () => { const parseErr = new Error( "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9", ); const cases = [ { name: "plain text send", chatId: "123", text: "_oops_", htmlText: "oops", messageId: 42, options: { verbose: true } as const, firstCall: { parse_mode: "HTML" }, secondCall: undefined, }, { name: "threaded reply send", chatId: "-1001234567890", text: "_bad markdown_", htmlText: "bad markdown", messageId: 60, options: { messageThreadId: 271, replyToMessageId: 100 } as const, firstCall: { parse_mode: "HTML", message_thread_id: 271, reply_to_message_id: 100, }, secondCall: { message_thread_id: 271, reply_to_message_id: 100, }, }, ] as const; for (const testCase of cases) { const sendMessage = vi .fn() .mockRejectedValueOnce(parseErr) .mockResolvedValueOnce({ message_id: testCase.messageId, chat: { id: testCase.chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; const res = await sendMessageTelegram(testCase.chatId, testCase.text, { token: "tok", api, ...testCase.options, }); expect(sendMessage, testCase.name).toHaveBeenNthCalledWith( 1, testCase.chatId, testCase.htmlText, testCase.firstCall, ); if (testCase.secondCall) { expect(sendMessage, testCase.name).toHaveBeenNthCalledWith( 2, testCase.chatId, testCase.text, testCase.secondCall, ); } else { expect(sendMessage, testCase.name).toHaveBeenNthCalledWith( 2, testCase.chatId, testCase.text, ); } expect(res.chatId, testCase.name).toBe(testCase.chatId); expect(res.messageId, testCase.name).toBe(String(testCase.messageId)); } }); it("keeps link_preview_options disabled for both html and plain-text fallback", async () => { const parseErr = new Error( "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 9", ); const cases = [ { name: "html send succeeds", text: "hi", sendMessage: vi.fn().mockResolvedValue({ message_id: 7, chat: { id: "123" } }), expectedCalls: [ ["123", "hi", { parse_mode: "HTML", link_preview_options: { is_disabled: true } }], ], }, { name: "html parse fails then plain-text fallback", text: "_oops_", sendMessage: vi .fn() .mockRejectedValueOnce(parseErr) .mockResolvedValueOnce({ message_id: 42, chat: { id: "123" } }), expectedCalls: [ [ "123", "oops", { parse_mode: "HTML", link_preview_options: { is_disabled: true } }, ], ["123", "_oops_", { link_preview_options: { is_disabled: true } }], ], }, ] as const; for (const testCase of cases) { loadConfig.mockReturnValue({ channels: { telegram: { linkPreview: false } }, }); const api = { sendMessage: testCase.sendMessage } as unknown as { sendMessage: typeof testCase.sendMessage; }; await sendMessageTelegram("123", testCase.text, { token: "tok", api }); expect(testCase.sendMessage.mock.calls, testCase.name).toEqual(testCase.expectedCalls); } }); it("fails when Telegram text send returns no message_id", async () => { const sendMessage = vi.fn().mockResolvedValue({ chat: { id: "123" }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await expect( sendMessageTelegram("123", "hi", { token: "tok", api, }), ).rejects.toThrow(/returned no message_id/i); }); it("fails when Telegram media send returns no message_id", async () => { mockLoadedMedia({ contentType: "image/png", fileName: "photo.png" }); const sendPhoto = vi.fn().mockResolvedValue({ chat: { id: "123" }, }); const api = { sendPhoto } as unknown as { sendPhoto: typeof sendPhoto; }; await expect( sendMessageTelegram("123", "caption", { token: "tok", api, mediaUrl: "https://example.com/photo.png", }), ).rejects.toThrow(/returned no message_id/i); }); 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("resolves t.me targets to numeric chat ids via getChat", async () => { const sendMessage = vi.fn().mockResolvedValue({ message_id: 1, chat: { id: "-100123" }, }); const getChat = vi.fn().mockResolvedValue({ id: -100123 }); const api = { sendMessage, getChat } as unknown as { sendMessage: typeof sendMessage; getChat: typeof getChat; }; await sendMessageTelegram("https://t.me/mychannel", "hi", { token: "tok", api, }); expect(getChat).toHaveBeenCalledWith("@mychannel"); expect(sendMessage).toHaveBeenCalledWith("-100123", "hi", { parse_mode: "HTML", }); expect(maybePersistResolvedTelegramTarget).toHaveBeenCalledWith( expect.objectContaining({ rawTarget: "https://t.me/mychannel", resolvedChatId: "-100123", }), ); }); it("fails clearly when a legacy target cannot be resolved", async () => { const getChat = vi.fn().mockRejectedValue(new Error("400: Bad Request: chat not found")); const api = { getChat } as unknown as { getChat: typeof getChat; }; await expect( sendMessageTelegram("@missingchannel", "hi", { token: "tok", api, }), ).rejects.toThrow(/could not be resolved to a numeric chat ID/i); }); 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; }; mockLoadedMedia({ 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; }; mockLoadedMedia({ 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; }; mockLoadedMedia({ 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; }; mockLoadedMedia({ 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 notes when requested and regular videos otherwise", 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; }; mockLoadedMedia({ 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"); } { const text = "my caption"; const sendVideo = vi.fn().mockResolvedValue({ message_id: 201, chat: { id: chatId }, }); const api = { sendVideo } as unknown as { sendVideo: typeof sendVideo; }; mockLoadedMedia({ 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("applies reply markup and thread options to split video-note sends", async () => { const chatId = "123"; const cases: Array<{ text: string; options: Partial[2]>>; expectedVideoNote: Record; expectedMessage: Record; }> = [ { text: "Check this out", options: { buttons: [[{ text: "Btn", callback_data: "dat" }]], }, expectedVideoNote: {}, expectedMessage: { parse_mode: "HTML", reply_markup: { inline_keyboard: [[{ text: "Btn", callback_data: "dat" }]], }, }, }, { text: "Threaded reply", options: { replyToMessageId: 999, }, expectedVideoNote: { reply_to_message_id: 999 }, expectedMessage: { parse_mode: "HTML", reply_to_message_id: 999, }, }, ]; for (const testCase of cases) { 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; }; mockLoadedMedia({ buffer: Buffer.from("fake-video"), contentType: "video/mp4", fileName: "video.mp4", }); const sendOptions: NonNullable[2]> = { token: "tok", api, mediaUrl: "https://example.com/video.mp4", asVideoNote: true, }; if ( "replyToMessageId" in testCase.options && testCase.options.replyToMessageId !== undefined ) { sendOptions.replyToMessageId = testCase.options.replyToMessageId; } if ("buttons" in testCase.options && testCase.options.buttons) { sendOptions.buttons = testCase.options.buttons; } await sendMessageTelegram(chatId, testCase.text, sendOptions); expect(sendVideoNote).toHaveBeenCalledWith( chatId, expect.anything(), testCase.expectedVideoNote, ); expect(sendMessage).toHaveBeenCalledWith(chatId, testCase.text, testCase.expectedMessage); } }); it("retries pre-connect send errors and honors retry_after when present", async () => { vi.useFakeTimers(); const chatId = "123"; const err = Object.assign(new Error("getaddrinfo ENOTFOUND api.telegram.org"), { code: "ENOTFOUND", 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("does not retry generic grammY failed-after envelopes for non-idempotent sends", async () => { const chatId = "123"; const sendMessage = vi .fn() .mockRejectedValueOnce( new Error("Network request for 'sendMessage' failed after 1 attempts."), ); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await expect( sendMessageTelegram(chatId, "hi", { token: "tok", api, retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, }), ).rejects.toThrow(/failed after 1 attempts/i); 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; }; mockLoadedMedia({ 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.each([ { name: "images", buffer: Buffer.from("fake-image"), contentType: "image/png", fileName: "photo.png", mediaUrl: "https://example.com/photo.png", }, { name: "GIFs", buffer: Buffer.from("GIF89a"), contentType: "image/gif", fileName: "fun.gif", mediaUrl: "https://example.com/fun.gif", }, ])("sends $name as documents when forceDocument is true", async (testCase) => { const chatId = "123"; const sendAnimation = vi.fn(); const sendDocument = vi.fn().mockResolvedValue({ message_id: 10, chat: { id: chatId }, }); const sendPhoto = vi.fn(); const api = { sendAnimation, sendDocument, sendPhoto } as unknown as { sendAnimation: typeof sendAnimation; sendDocument: typeof sendDocument; sendPhoto: typeof sendPhoto; }; mockLoadedMedia({ buffer: testCase.buffer, contentType: testCase.contentType, fileName: testCase.fileName, }); const res = await sendMessageTelegram(chatId, "caption", { token: "tok", api, mediaUrl: testCase.mediaUrl, forceDocument: true, }); expect(sendDocument, testCase.name).toHaveBeenCalledWith(chatId, expect.anything(), { caption: "caption", parse_mode: "HTML", disable_content_type_detection: true, }); expect(sendPhoto, testCase.name).not.toHaveBeenCalled(); expect(sendAnimation, testCase.name).not.toHaveBeenCalled(); expect(res.messageId).toBe("10"); }); it("keeps regular document sends on the default Telegram params", async () => { const chatId = "123"; const sendDocument = vi.fn().mockResolvedValue({ message_id: 11, chat: { id: chatId }, }); const api = { sendDocument } as unknown as { sendDocument: typeof sendDocument; }; mockLoadedMedia({ buffer: Buffer.from("%PDF-1.7"), contentType: "application/pdf", fileName: "report.pdf", }); const res = await sendMessageTelegram(chatId, "caption", { token: "tok", api, mediaUrl: "https://example.com/report.pdf", }); expect(sendDocument).toHaveBeenCalledWith(chatId, expect.anything(), { caption: "caption", parse_mode: "HTML", }); expect(res.messageId).toBe("11"); }); it("routes audio media to sendAudio/sendVoice based on voice compatibility", async () => { const cases: Array<{ name: string; chatId: string; text: string; mediaUrl: string; contentType: string; fileName: string; asVoice?: boolean; messageThreadId?: number; replyToMessageId?: number; expectedMethod: "sendAudio" | "sendVoice"; expectedOptions: Record; }> = [ { name: "default audio send", chatId: "123", text: "caption", mediaUrl: "https://example.com/clip.mp3", contentType: "audio/mpeg", fileName: "clip.mp3", expectedMethod: "sendAudio" as const, expectedOptions: { caption: "caption", parse_mode: "HTML" }, }, { name: "voice-compatible media with thread params", chatId: "-1001234567890", text: "voice note", mediaUrl: "https://example.com/note.ogg", contentType: "audio/ogg", fileName: "note.ogg", asVoice: true, messageThreadId: 271, replyToMessageId: 500, expectedMethod: "sendVoice" as const, expectedOptions: { caption: "voice note", parse_mode: "HTML", message_thread_id: 271, reply_to_message_id: 500, }, }, { name: "asVoice fallback for non-voice media", chatId: "123", text: "caption", mediaUrl: "https://example.com/clip.wav", contentType: "audio/wav", fileName: "clip.wav", asVoice: true, expectedMethod: "sendAudio" as const, expectedOptions: { caption: "caption", parse_mode: "HTML" }, }, { name: "asVoice accepts mp3", chatId: "123", text: "caption", mediaUrl: "https://example.com/clip.mp3", contentType: "audio/mpeg", fileName: "clip.mp3", asVoice: true, expectedMethod: "sendVoice" as const, expectedOptions: { caption: "caption", parse_mode: "HTML" }, }, { name: "normalizes parameterized audio MIME with mixed casing", chatId: "123", text: "caption", mediaUrl: "https://example.com/note", contentType: " Audio/Ogg; codecs=opus ", fileName: "note.ogg", expectedMethod: "sendAudio" as const, expectedOptions: { caption: "caption", parse_mode: "HTML" }, }, ]; for (const testCase of cases) { const sendAudio = vi.fn().mockResolvedValue({ message_id: 10, chat: { id: testCase.chatId }, }); const sendVoice = vi.fn().mockResolvedValue({ message_id: 11, chat: { id: testCase.chatId }, }); const api = { sendAudio, sendVoice } as unknown as { sendAudio: typeof sendAudio; sendVoice: typeof sendVoice; }; mockLoadedMedia({ buffer: Buffer.from("audio"), contentType: testCase.contentType, fileName: testCase.fileName, }); await sendMessageTelegram(testCase.chatId, testCase.text, { token: "tok", api, mediaUrl: testCase.mediaUrl, ...("asVoice" in testCase && testCase.asVoice ? { asVoice: true } : {}), ...("messageThreadId" in testCase && testCase.messageThreadId !== undefined ? { messageThreadId: testCase.messageThreadId } : {}), ...("replyToMessageId" in testCase && testCase.replyToMessageId !== undefined ? { replyToMessageId: testCase.replyToMessageId } : {}), }); const called = testCase.expectedMethod === "sendVoice" ? sendVoice : sendAudio; const notCalled = testCase.expectedMethod === "sendVoice" ? sendAudio : sendVoice; expect(called, testCase.name).toHaveBeenCalledWith( testCase.chatId, expect.anything(), testCase.expectedOptions, ); expect(notCalled, testCase.name).not.toHaveBeenCalled(); } }); it("keeps message_thread_id for forum/private/group sends", async () => { const cases = [ { name: "forum topic", chatId: "-1001234567890", text: "hello forum", messageId: 55, }, { name: "private chat topic (#18974)", chatId: "123456789", text: "hello private", messageId: 56, }, { // Group/supergroup chats have negative IDs. name: "group chat (#17242)", chatId: "-1001234567890", text: "hello group", messageId: 57, }, ] as const; for (const testCase of cases) { const sendMessage = vi.fn().mockResolvedValue({ message_id: testCase.messageId, chat: { id: testCase.chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await sendMessageTelegram(testCase.chatId, testCase.text, { token: "tok", api, messageThreadId: 271, }); expect(sendMessage, testCase.name).toHaveBeenCalledWith(testCase.chatId, testCase.text, { parse_mode: "HTML", message_thread_id: 271, }); } }); it("retries sends without message_thread_id on thread-not-found", async () => { const cases = [ { name: "forum", chatId: "-100123", text: "hello forum", messageId: 58 }, { name: "private", chatId: "123456789", text: "hello private", messageId: 59 }, ] as const; const threadErr = new Error("400: Bad Request: message thread not found"); for (const testCase of cases) { const sendMessage = vi .fn() .mockRejectedValueOnce(threadErr) .mockResolvedValueOnce({ message_id: testCase.messageId, chat: { id: testCase.chatId }, }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; const res = await sendMessageTelegram(testCase.chatId, testCase.text, { token: "tok", api, messageThreadId: 271, }); expect(sendMessage, testCase.name).toHaveBeenNthCalledWith( 1, testCase.chatId, testCase.text, { parse_mode: "HTML", message_thread_id: 271, }, ); expect(sendMessage, testCase.name).toHaveBeenNthCalledWith( 2, testCase.chatId, testCase.text, { parse_mode: "HTML", }, ); expect(res.messageId, testCase.name).toBe(String(testCase.messageId)); } }); it("does not retry on non-retriable thread/chat errors", async () => { const cases: Array<{ chatId: string; text: string; error: Error; opts?: { messageThreadId?: number }; expectedError: RegExp | string; expectedCallArgs: [string, string, { parse_mode: "HTML"; message_thread_id?: number }]; }> = [ { chatId: "123", text: "hello forum", error: new Error("400: Bad Request: message thread not found"), expectedError: "message thread not found", expectedCallArgs: ["123", "hello forum", { parse_mode: "HTML" }], }, { chatId: "123456789", text: "hello private", error: new Error("400: Bad Request: chat not found"), opts: { messageThreadId: 271 }, expectedError: /chat not found/i, expectedCallArgs: [ "123456789", "hello private", { parse_mode: "HTML", message_thread_id: 271 }, ], }, ]; for (const testCase of cases) { const sendMessage = vi.fn().mockRejectedValueOnce(testCase.error); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await expect( sendMessageTelegram(testCase.chatId, testCase.text, { token: "tok", api, ...testCase.opts, }), ).rejects.toThrow(testCase.expectedError); expect(sendMessage).toHaveBeenCalledTimes(1); expect(sendMessage).toHaveBeenCalledWith(...testCase.expectedCallArgs); } }); 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("keeps disable_notification on plain-text fallback when silent is true", 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: 2, chat: { id: chatId } }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage; }; await sendMessageTelegram(chatId, "_oops_", { token: "tok", api, silent: true, }); expect(sendMessage.mock.calls).toEqual([ [chatId, "oops", { parse_mode: "HTML", disable_notification: true }], [chatId, "_oops_", { 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("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; }; mockLoadedMedia({ 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"); }); it("defaults outbound media uploads to 100MB", async () => { const chatId = "123"; const sendPhoto = vi.fn().mockResolvedValue({ message_id: 60, chat: { id: chatId }, }); const api = { sendPhoto } as unknown as { sendPhoto: typeof sendPhoto; }; mockLoadedMedia({ buffer: Buffer.from("fake-image"), contentType: "image/jpeg", fileName: "photo.jpg", }); await sendMessageTelegram(chatId, "photo", { token: "tok", api, mediaUrl: "https://example.com/photo.jpg", }); expect(loadWebMedia).toHaveBeenCalledWith( "https://example.com/photo.jpg", expect.objectContaining({ maxBytes: 100 * 1024 * 1024 }), ); }); it("uses configured telegram mediaMaxMb for outbound uploads", async () => { const chatId = "123"; const sendPhoto = vi.fn().mockResolvedValue({ message_id: 61, chat: { id: chatId }, }); const api = { sendPhoto } as unknown as { sendPhoto: typeof sendPhoto; }; loadConfig.mockReturnValue({ channels: { telegram: { mediaMaxMb: 42, }, }, }); mockLoadedMedia({ buffer: Buffer.from("fake-image"), contentType: "image/jpeg", fileName: "photo.jpg", }); await sendMessageTelegram(chatId, "photo", { token: "tok", api, mediaUrl: "https://example.com/photo.jpg", }); expect(loadWebMedia).toHaveBeenCalledWith( "https://example.com/photo.jpg", expect.objectContaining({ maxBytes: 42 * 1024 * 1024 }), ); }); it("chunks long html-mode text and keeps buttons on the last chunk only", async () => { const chatId = "123"; const htmlText = `${"A".repeat(5000)}`; const sendMessage = vi .fn() .mockResolvedValueOnce({ message_id: 90, chat: { id: chatId } }) .mockResolvedValueOnce({ message_id: 91, chat: { id: chatId } }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; const res = await sendMessageTelegram(chatId, htmlText, { token: "tok", api, textMode: "html", buttons: [[{ text: "OK", callback_data: "ok" }]], }); expect(sendMessage).toHaveBeenCalledTimes(2); const firstCall = sendMessage.mock.calls[0]; const secondCall = sendMessage.mock.calls[1]; expect(firstCall).toBeDefined(); expect(secondCall).toBeDefined(); expect((firstCall[1] as string).length).toBeLessThanOrEqual(4000); expect((secondCall[1] as string).length).toBeLessThanOrEqual(4000); expect(firstCall[2]?.reply_markup).toBeUndefined(); expect(secondCall[2]?.reply_markup).toEqual({ inline_keyboard: [[{ text: "OK", callback_data: "ok" }]], }); expect(res.messageId).toBe("91"); }); it("preserves caller plain-text fallback across chunked html parse retries", async () => { const chatId = "123"; const htmlText = `${"A".repeat(5000)}`; const plainText = `${"P".repeat(2500)}${"Q".repeat(2500)}`; 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: 90, chat: { id: chatId } }) .mockRejectedValueOnce(parseErr) .mockResolvedValueOnce({ message_id: 91, chat: { id: chatId } }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; const res = await sendMessageTelegram(chatId, htmlText, { token: "tok", api, textMode: "html", plainText, }); expect(sendMessage).toHaveBeenCalledTimes(4); const plainFallbackCalls = [sendMessage.mock.calls[1], sendMessage.mock.calls[3]]; expect(plainFallbackCalls.map((call) => String(call?.[1] ?? "")).join("")).toBe(plainText); expect(plainFallbackCalls.every((call) => !String(call?.[1] ?? "").includes("<"))).toBe(true); expect(res.messageId).toBe("91"); }); it("keeps malformed leading ampersands on the chunked plain-text fallback path", async () => { const chatId = "123"; const htmlText = `&${"A".repeat(5000)}`; const plainText = "fallback!!"; const parseErr = new Error( "400: Bad Request: can't parse entities: Can't find end of the entity starting at byte offset 0", ); const sendMessage = vi .fn() .mockRejectedValueOnce(parseErr) .mockResolvedValueOnce({ message_id: 92, chat: { id: chatId } }) .mockRejectedValueOnce(parseErr) .mockResolvedValueOnce({ message_id: 93, chat: { id: chatId } }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; const res = await sendMessageTelegram(chatId, htmlText, { token: "tok", api, textMode: "html", plainText, }); expect(sendMessage).toHaveBeenCalledTimes(4); expect(String(sendMessage.mock.calls[0]?.[1] ?? "")).toMatch(/^&/); const plainFallbackCalls = [sendMessage.mock.calls[1], sendMessage.mock.calls[3]]; expect(plainFallbackCalls.map((call) => String(call?.[1] ?? "")).join("")).toBe(plainText); expect(plainFallbackCalls.every((call) => String(call?.[1] ?? "").length > 0)).toBe(true); expect(res.messageId).toBe("93"); }); it("cuts over to plain text when fallback text needs more chunks than html", async () => { const chatId = "123"; const htmlText = `${"A".repeat(5000)}`; const plainText = "P".repeat(9000); const sendMessage = vi .fn() .mockResolvedValueOnce({ message_id: 94, chat: { id: chatId } }) .mockResolvedValueOnce({ message_id: 95, chat: { id: chatId } }) .mockResolvedValueOnce({ message_id: 96, chat: { id: chatId } }); const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage }; const res = await sendMessageTelegram(chatId, htmlText, { token: "tok", api, textMode: "html", plainText, }); expect(sendMessage).toHaveBeenCalledTimes(3); expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === undefined)).toBe(true); expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toBe(plainText); expect(res.messageId).toBe("96"); }); }); describe("reactMessageTelegram", () => { it.each([ { testName: "sends emoji reactions", target: "telegram:123", messageId: "456", emoji: "✅", remove: false, expected: [{ type: "emoji", emoji: "✅" }], }, { testName: "removes reactions when emoji is empty", target: "123", messageId: 456, emoji: "", remove: false, expected: [], }, { testName: "removes reactions when remove flag is set", target: "123", messageId: 456, emoji: "✅", remove: true, expected: [], }, ] as const)("$testName", async (testCase) => { const setMessageReaction = vi.fn().mockResolvedValue(undefined); const api = { setMessageReaction } as unknown as { setMessageReaction: typeof setMessageReaction; }; await reactMessageTelegram(testCase.target, testCase.messageId, testCase.emoji, { token: "tok", api, ...(testCase.remove ? { remove: true } : {}), }); expect(setMessageReaction).toHaveBeenCalledWith("123", 456, testCase.expected); }); it("resolves legacy telegram targets before reacting", async () => { const setMessageReaction = vi.fn().mockResolvedValue(undefined); const getChat = vi.fn().mockResolvedValue({ id: -100123 }); const api = { setMessageReaction, getChat } as unknown as { setMessageReaction: typeof setMessageReaction; getChat: typeof getChat; }; await reactMessageTelegram("@mychannel", 456, "✅", { token: "tok", api, }); expect(getChat).toHaveBeenCalledWith("@mychannel"); expect(setMessageReaction).toHaveBeenCalledWith("-100123", 456, [ { type: "emoji", emoji: "✅" }, ]); expect(maybePersistResolvedTelegramTarget).toHaveBeenCalledWith( expect.objectContaining({ rawTarget: "@mychannel", resolvedChatId: "-100123", }), ); }); }); describe("sendStickerTelegram", () => { const positiveSendCases = [ { name: "sends a sticker by file_id", fileId: "CAACAgIAAxkBAAI...sticker_file_id", expectedFileId: "CAACAgIAAxkBAAI...sticker_file_id", expectedMessageId: 100, }, { name: "trims whitespace from fileId", fileId: " fileId123 ", expectedFileId: "fileId123", expectedMessageId: 106, }, ] as const; for (const testCase of positiveSendCases) { it(testCase.name, async () => { const chatId = "123"; const sendSticker = vi.fn().mockResolvedValue({ message_id: testCase.expectedMessageId, chat: { id: chatId }, }); const api = { sendSticker } as unknown as { sendSticker: typeof sendSticker; }; const res = await sendStickerTelegram(chatId, testCase.fileId, { token: "tok", api, }); expect(sendSticker).toHaveBeenCalledWith(chatId, testCase.expectedFileId, undefined); expect(res.messageId).toBe(String(testCase.expectedMessageId)); 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("fails when sticker send returns no message_id", async () => { const chatId = "123"; const sendSticker = vi.fn().mockResolvedValue({ chat: { id: chatId }, }); const api = { sendSticker } as unknown as { sendSticker: typeof sendSticker; }; await expect( sendStickerTelegram(chatId, "fileId123", { token: "tok", api, }), ).rejects.toThrow(/returned no message_id/i); }); }); describe("shared send behaviors", () => { it("includes reply_to_message_id for threaded replies", async () => { const cases = [ { name: "message send", run: 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, }); }, }, { name: "sticker send", run: 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, }); }, }, ] as const; for (const testCase of cases) { await testCase.run(); } }); it("wraps chat-not-found with actionable context", async () => { const cases = [ { name: "message send", run: 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 expectChatNotFoundWithChatId( sendMessageTelegram(chatId, "hi", { token: "tok", api }), chatId, ); }, }, { name: "sticker send", run: 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 expectChatNotFoundWithChatId( sendStickerTelegram(chatId, "fileId123", { token: "tok", api }), chatId, ); }, }, ] as const; for (const testCase of cases) { await testCase.run(); } }); }); describe("editMessageTelegram", () => { it.each([ { name: "buttons undefined keeps existing keyboard", text: "hi", buttons: undefined as Parameters[0], expectedCalls: 1, firstExpectNoReplyMarkup: true, parseFallback: false, }, { name: "buttons empty clears keyboard", text: "hi", buttons: [] as Parameters[0], expectedCalls: 1, firstExpectReplyMarkup: { inline_keyboard: [] } as Record, parseFallback: false, }, { name: "parse error fallback preserves cleared keyboard", text: " html", buttons: [] as Parameters[0], expectedCalls: 2, firstExpectReplyMarkup: { inline_keyboard: [] } as Record, secondExpectReplyMarkup: { inline_keyboard: [] } as Record, parseFallback: true, }, ])("$name", async (testCase) => { if (testCase.parseFallback) { botApi.editMessageText .mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities")) .mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } }); } else { botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); } await editMessageTelegram("123", 1, testCase.text, { token: "tok", cfg: {}, buttons: testCase.buttons ? testCase.buttons.map((row) => [...row]) : testCase.buttons, }); expect(botCtorSpy, testCase.name).toHaveBeenCalledTimes(1); expect(botCtorSpy.mock.calls[0]?.[0], testCase.name).toBe("tok"); expect(botApi.editMessageText, testCase.name).toHaveBeenCalledTimes(testCase.expectedCalls); const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; expect(firstParams, testCase.name).toEqual(expect.objectContaining({ parse_mode: "HTML" })); if ("firstExpectNoReplyMarkup" in testCase && testCase.firstExpectNoReplyMarkup) { expect(firstParams, testCase.name).not.toHaveProperty("reply_markup"); } if ("firstExpectReplyMarkup" in testCase && testCase.firstExpectReplyMarkup) { expect(firstParams, testCase.name).toEqual( expect.objectContaining({ reply_markup: testCase.firstExpectReplyMarkup }), ); } if ("secondExpectReplyMarkup" in testCase && testCase.secondExpectReplyMarkup) { const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record< string, unknown >; expect(secondParams, testCase.name).toEqual( expect.objectContaining({ reply_markup: testCase.secondExpectReplyMarkup }), ); } }); 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("retries editMessageTelegram on Telegram 5xx errors", async () => { botApi.editMessageText .mockRejectedValueOnce(Object.assign(new Error("502: Bad Gateway"), { error_code: 502 })) .mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } }); await expect( editMessageTelegram("123", 1, "hi", { token: "tok", cfg: {}, retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, }), ).resolves.toEqual({ ok: true, messageId: "1", chatId: "123" }); expect(botApi.editMessageText).toHaveBeenCalledTimes(2); }); 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(); }); it("fails when poll send returns no message_id", async () => { const api = { sendPoll: vi.fn(async () => ({ chat: { id: 555 }, poll: { id: "p1" } })), }; await expect( sendPollTelegram( "123", { question: "Q", options: ["A", "B"] }, { token: "t", api: api as unknown as Bot["api"] }, ), ).rejects.toThrow(/returned no message_id/i); }); }); describe("createForumTopicTelegram", () => { const cases = [ { name: "uses base chat id when target includes topic suffix", target: "telegram:group:-1001234567890:topic:271", title: "x", response: { message_thread_id: 272, name: "Build Updates" }, expectedCall: ["-1001234567890", "x", undefined] as const, expectedResult: { topicId: 272, name: "Build Updates", chatId: "-1001234567890", }, }, { name: "forwards optional icon fields", target: "-1001234567890", title: "Roadmap", response: { message_thread_id: 300, name: "Roadmap" }, options: { iconColor: 0x6fb9f0, iconCustomEmojiId: " 1234567890 ", }, expectedCall: [ "-1001234567890", "Roadmap", { icon_color: 0x6fb9f0, icon_custom_emoji_id: "1234567890" }, ] as const, expectedResult: { topicId: 300, name: "Roadmap", chatId: "-1001234567890", }, }, ] as const; for (const testCase of cases) { it(testCase.name, async () => { const createForumTopic = vi.fn().mockResolvedValue(testCase.response); const api = { createForumTopic } as unknown as Bot["api"]; const result = await createForumTopicTelegram(testCase.target, testCase.title, { token: "tok", api, ...("options" in testCase ? testCase.options : {}), }); expect(createForumTopic).toHaveBeenCalledWith(...testCase.expectedCall); expect(result).toEqual(testCase.expectedResult); }); } });