diff --git a/src/telegram/format.wrap-md.test.ts b/src/telegram/format.wrap-md.test.ts index d77ab792c55..84749d3f993 100644 --- a/src/telegram/format.wrap-md.test.ts +++ b/src/telegram/format.wrap-md.test.ts @@ -201,80 +201,106 @@ describe("edge cases", () => { expect(result).toBe("README.md"); }); - it("wraps supported TLD extensions (.am, .at, .be, .cc)", () => { - const result = markdownToTelegramHtml("Makefile.am and code.at and app.be and main.cc"); - expect(result).toContain("Makefile.am"); - expect(result).toContain("code.at"); - expect(result).toContain("app.be"); - expect(result).toContain("main.cc"); - }); - - it("does not wrap popular domain TLDs (.ai, .io, .tv, .fm)", () => { - // These are commonly used as real domains (x.ai, vercel.io, github.io) - const result = markdownToTelegramHtml("Check x.ai and vercel.io and app.tv and radio.fm"); - // Should be links, not code - expect(result).toContain(''); - expect(result).toContain(''); - expect(result).toContain(''); - expect(result).toContain(''); - }); - - it("keeps .co domains as links", () => { - const result = markdownToTelegramHtml("Visit t.co and openclaw.co"); - expect(result).toContain(''); - expect(result).toContain(''); - expect(result).not.toContain("t.co"); - expect(result).not.toContain("openclaw.co"); - }); - - it("does not wrap non-TLD extensions", () => { - const result = markdownToTelegramHtml("image.png and style.css and script.js"); - expect(result).not.toContain("image.png"); - expect(result).not.toContain("style.css"); - expect(result).not.toContain("script.js"); - }); - - it("handles file refs at message boundaries", () => { + it("classifies extension-like tokens as file refs or domains", () => { const cases = [ - ["README.md is important", "README.md is important"], - ["Check the README.md", "Check the README.md"], + { + name: "supported file-style extensions", + input: "Makefile.am and code.at and app.be and main.cc", + contains: [ + "Makefile.am", + "code.at", + "app.be", + "main.cc", + ], + }, + { + name: "popular domain TLDs stay links", + input: "Check x.ai and vercel.io and app.tv and radio.fm", + contains: [ + '', + '', + '', + '', + ], + }, + { + name: ".co stays links", + input: "Visit t.co and openclaw.co", + contains: ['', ''], + notContains: ["t.co", "openclaw.co"], + }, + { + name: "non-target extensions stay plain text", + input: "image.png and style.css and script.js", + notContains: ["image.png", "style.css", "script.js"], + }, ] as const; - for (const [input, expected] of cases) { - expect(markdownToTelegramHtml(input), input).toBe(expected); + for (const testCase of cases) { + const result = markdownToTelegramHtml(testCase.input); + for (const expected of testCase.contains ?? []) { + expect(result, testCase.name).toContain(expected); + } + for (const unexpected of testCase.notContains ?? []) { + expect(result, testCase.name).not.toContain(unexpected); + } } }); - it("handles multiple file refs in sequence", () => { - const result = markdownToTelegramHtml("README.md CHANGELOG.md LICENSE.md"); - expect(result).toContain("README.md"); - expect(result).toContain("CHANGELOG.md"); - expect(result).toContain("LICENSE.md"); - }); - - it("handles nested path without domain-like segments", () => { - const result = markdownToTelegramHtml("src/utils/helpers/format.go"); - expect(result).toContain("src/utils/helpers/format.go"); - }); - - it("wraps path with version-like segment (not a domain)", () => { - // v1.0/README.md is not linkified by markdown-it (no TLD), so it's wrapped - const result = markdownToTelegramHtml("v1.0/README.md"); - expect(result).toContain("v1.0/README.md"); - }); - - it("preserves domain path with version segment", () => { - // example.com/v1.0/README.md IS linkified (has domain), preserved as link - const result = markdownToTelegramHtml("example.com/v1.0/README.md"); - expect(result).toContain(''); - }); - - it("wraps hyphen/underscore filenames and uppercase extensions", () => { - const first = markdownToTelegramHtml("my-file_name.md"); - expect(first).toContain("my-file_name.md"); - - const second = markdownToTelegramHtml("README.MD and SCRIPT.PY"); - expect(second).toContain("README.MD"); - expect(second).toContain("SCRIPT.PY"); + it("wraps file refs across boundaries, sequences, and path variants", () => { + const cases = [ + { + name: "message start boundary", + input: "README.md is important", + expectedExact: "README.md is important", + }, + { + name: "message end boundary", + input: "Check the README.md", + expectedExact: "Check the README.md", + }, + { + name: "multiple file refs", + input: "README.md CHANGELOG.md LICENSE.md", + contains: [ + "README.md", + "CHANGELOG.md", + "LICENSE.md", + ], + }, + { + name: "nested path", + input: "src/utils/helpers/format.go", + contains: ["src/utils/helpers/format.go"], + }, + { + name: "version-like non-domain path", + input: "v1.0/README.md", + contains: ["v1.0/README.md"], + }, + { + name: "domain with version path", + input: "example.com/v1.0/README.md", + contains: [''], + }, + { + name: "hyphen underscore and uppercase extensions", + input: "my-file_name.md README.MD and SCRIPT.PY", + contains: [ + "my-file_name.md", + "README.MD", + "SCRIPT.PY", + ], + }, + ] as const; + for (const testCase of cases) { + const result = markdownToTelegramHtml(testCase.input); + if ("expectedExact" in testCase) { + expect(result, testCase.name).toBe(testCase.expectedExact); + } + for (const expected of testCase.contains ?? []) { + expect(result, testCase.name).toContain(expected); + } + } }); it("handles nested code tags (depth tracking)", () => { @@ -325,24 +351,6 @@ describe("edge cases", () => { expect(result).toBe("x.md bold"); }); - it("does not wrap orphaned TLD inside existing code tags", () => { - // R&D.md is already inside , orphaned pass should NOT wrap D.md again - const input = "R&D.md"; - const result = wrapFileReferencesInHtml(input); - // Should remain unchanged - no nested code tags - expect(result).toBe(input); - expect(result).not.toContain(""); - expect(result).not.toContain(""); - }); - - it("does not wrap orphaned TLD inside anchor link text", () => { - // R&D.md inside anchor text should NOT have D.md wrapped - const input = 'R&D.md'; - const result = wrapFileReferencesInHtml(input); - expect(result).toBe(input); - expect(result).not.toContain("D.md"); - }); - it("handles malformed HTML with stray closing tags (negative depth)", () => { // Stray before content shouldn't break protection logic // (depth should clamp at 0, not go negative) @@ -356,15 +364,19 @@ describe("edge cases", () => { expect(result).not.toContain(""); }); - it("does not wrap orphaned TLD fragments inside HTML attributes", () => { + it("does not wrap orphaned TLD fragments inside protected HTML contexts", () => { const cases = [ + "R&D.md", + 'R&D.md', 'link', 'R&D.md', ] as const; for (const input of cases) { const result = wrapFileReferencesInHtml(input); - expect(result).toBe(input); - expect(result).not.toContain("D.md"); + expect(result, input).toBe(input); + expect(result, input).not.toContain("D.md"); + expect(result, input).not.toContain(""); + expect(result, input).not.toContain(""); } }); diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 8e4ac35e0d4..6eb8d8feea4 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -73,97 +73,115 @@ describe("sent-message-cache", () => { }); 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("normalizes keyboard inputs", () => { + const cases = [ + { + 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" }, + ], ], - ], - }); - }); - - it("passes through button style", () => { - const result = buildInlineKeyboard([ - [ - { - text: "Option A", - callback_data: "cmd:a", - style: "primary", + expected: { + inline_keyboard: [ + [{ 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", - style: "primary", - }, + }, + { + name: "keeps button style fields", + input: [ + [ + { + 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" }]], - }); + 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" }]], + }, + }, + ] as const; + for (const testCase of cases) { + expect(buildInlineKeyboard(testCase.input), testCase.name).toEqual(testCase.expected); + } }); }); 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 } }, - }, + it("applies timeoutSeconds config precedence", async () => { + const cases = [ + { + name: "global telegram timeout", + cfg: { channels: { telegram: { timeoutSeconds: 60 } } }, + opts: { token: "tok" }, + expectedTimeout: 60, }, - }); - await sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" }); - expect(botCtorSpy).toHaveBeenCalledWith( - "tok", - expect.objectContaining({ - client: expect.objectContaining({ timeoutSeconds: 61 }), - }), - ); + { + 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); + 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", async () => { @@ -196,60 +214,46 @@ describe("sendMessageTelegram", () => { 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"; + 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 sendMessage = vi - .fn() - .mockRejectedValueOnce(parseErr) - .mockResolvedValueOnce({ - message_id: 42, - chat: { id: chatId }, + 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 } 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 }, - }); + 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("uses native fetch for BAN compatibility when api is omitted", async () => { @@ -676,147 +680,102 @@ describe("sendMessageTelegram", () => { 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; - }; + it("routes audio media to sendAudio/sendVoice based on voice compatibility", async () => { + const cases = [ + { + 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" }, + }, + ] as const; - loadWebMedia.mockResolvedValueOnce({ - buffer: Buffer.from("audio"), - contentType: "audio/mpeg", - fileName: "clip.mp3", - }); + 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; + }; - await sendMessageTelegram(chatId, "caption", { - token: "tok", - api, - mediaUrl: "https://example.com/clip.mp3", - }); + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("audio"), + contentType: testCase.contentType, + fileName: testCase.fileName, + }); - expect(sendAudio).toHaveBeenCalledWith(chatId, expect.anything(), { - caption: "caption", - parse_mode: "HTML", - }); - expect(sendVoice).not.toHaveBeenCalled(); - }); + await sendMessageTelegram(testCase.chatId, testCase.text, { + token: "tok", + api, + mediaUrl: testCase.mediaUrl, + ...(testCase.asVoice ? { asVoice: true } : {}), + ...(testCase.messageThreadId !== undefined + ? { messageThreadId: testCase.messageThreadId } + : {}), + ...(testCase.replyToMessageId !== undefined + ? { replyToMessageId: testCase.replyToMessageId } + : {}), + }); - 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(); + 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 () => { @@ -1250,68 +1209,79 @@ describe("editMessageTelegram", () => { botCtorSpy.mockReset(); }); - it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => { - botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + it("handles button payload + parse fallback behavior", async () => { + const cases = [ + { + name: "buttons undefined keeps existing keyboard", + setup: () => { + botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + return { text: "hi", buttons: undefined as [] | undefined }; + }, + expectedCalls: 1, + firstExpectNoReplyMarkup: true, + }, + { + name: "buttons empty clears keyboard", + setup: () => { + botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + return { text: "hi", buttons: [] as [] }; + }, + expectedCalls: 1, + firstExpectReplyMarkup: { inline_keyboard: [] }, + }, + { + name: "parse error fallback preserves cleared keyboard", + setup: () => { + botApi.editMessageText + .mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities")) + .mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } }); + return { text: " html", buttons: [] as [] }; + }, + expectedCalls: 2, + firstExpectReplyMarkup: { inline_keyboard: [] }, + secondExpectReplyMarkup: { inline_keyboard: [] }, + }, + ] as const; - await editMessageTelegram("123", 1, "hi", { - token: "tok", - cfg: {}, - }); + for (const testCase of cases) { + botApi.editMessageText.mockReset(); + botCtorSpy.mockReset(); + const input = testCase.setup(); - 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"); - }); + await editMessageTelegram("123", 1, input.text, { + token: "tok", + cfg: {}, + buttons: input.buttons, + }); - it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => { - botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + expect(botCtorSpy, testCase.name).toHaveBeenCalledTimes(1); + expect(botCtorSpy.mock.calls[0]?.[0], testCase.name).toBe("tok"); + expect(botApi.editMessageText, testCase.name).toHaveBeenCalledTimes(testCase.expectedCalls); - await editMessageTelegram("123", 1, "hi", { - token: "tok", - cfg: {}, - buttons: [], - }); + const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record< + string, + unknown + >; + expect(firstParams, testCase.name).toEqual(expect.objectContaining({ parse_mode: "HTML" })); + if (testCase.firstExpectNoReplyMarkup) { + expect(firstParams, testCase.name).not.toHaveProperty("reply_markup"); + } + if (testCase.firstExpectReplyMarkup) { + expect(firstParams, testCase.name).toEqual( + expect.objectContaining({ reply_markup: testCase.firstExpectReplyMarkup }), + ); + } - 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: [] }, - }), - ); + if (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 () => {