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',
'
',
] 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 () => {