diff --git a/src/telegram/send.caption-split.test.ts b/src/telegram/send.caption-split.test.ts
deleted file mode 100644
index 564f31388..000000000
--- a/src/telegram/send.caption-split.test.ts
+++ /dev/null
@@ -1,357 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import {
- getTelegramSendTestMocks,
- importTelegramSendModule,
- installTelegramSendTestHooks,
-} from "./send.test-harness.js";
-
-installTelegramSendTestHooks();
-
-const { loadWebMedia } = getTelegramSendTestMocks();
-const { sendMessageTelegram } = await importTelegramSendModule();
-
-describe("sendMessageTelegram caption splitting", () => {
- it("splits long captions into media + text messages when text exceeds 1024 chars", async () => {
- const chatId = "123";
- // Generate text longer than 1024 characters
- const longText = "A".repeat(1100);
-
- const sendPhoto = vi.fn().mockResolvedValue({
- message_id: 70,
- chat: { id: chatId },
- });
- const sendMessage = vi.fn().mockResolvedValue({
- message_id: 71,
- chat: { id: chatId },
- });
- const api = { sendPhoto, sendMessage } as unknown as {
- sendPhoto: typeof sendPhoto;
- sendMessage: typeof sendMessage;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-image"),
- contentType: "image/jpeg",
- fileName: "photo.jpg",
- });
-
- const res = await sendMessageTelegram(chatId, longText, {
- token: "tok",
- api,
- mediaUrl: "https://example.com/photo.jpg",
- });
-
- // Media should be sent first without caption
- expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
- caption: undefined,
- });
- // Then text sent as separate message (HTML formatting)
- expect(sendMessage).toHaveBeenCalledWith(chatId, longText, {
- parse_mode: "HTML",
- });
- // Returns the text message ID (the "main" content)
- expect(res.messageId).toBe("71");
- });
-
- it("uses caption when text is within 1024 char limit", async () => {
- const chatId = "123";
- // Text exactly at 1024 characters should still use caption
- const shortText = "B".repeat(1024);
-
- const sendPhoto = vi.fn().mockResolvedValue({
- message_id: 72,
- chat: { id: chatId },
- });
- const sendMessage = vi.fn();
- const api = { sendPhoto, sendMessage } as unknown as {
- sendPhoto: typeof sendPhoto;
- sendMessage: typeof sendMessage;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-image"),
- contentType: "image/jpeg",
- fileName: "photo.jpg",
- });
-
- const res = await sendMessageTelegram(chatId, shortText, {
- token: "tok",
- api,
- mediaUrl: "https://example.com/photo.jpg",
- });
-
- // Caption should be included with media
- expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
- caption: shortText,
- parse_mode: "HTML",
- });
- // No separate text message needed
- expect(sendMessage).not.toHaveBeenCalled();
- expect(res.messageId).toBe("72");
- });
-
- it("renders markdown in media captions", async () => {
- const chatId = "123";
- const caption = "hi **boss**";
-
- const sendPhoto = vi.fn().mockResolvedValue({
- message_id: 90,
- chat: { id: chatId },
- });
- const api = { sendPhoto } as unknown as {
- sendPhoto: typeof sendPhoto;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-image"),
- contentType: "image/jpeg",
- fileName: "photo.jpg",
- });
-
- await sendMessageTelegram(chatId, caption, {
- token: "tok",
- api,
- mediaUrl: "https://example.com/photo.jpg",
- });
-
- expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
- caption: "hi boss",
- parse_mode: "HTML",
- });
- });
-
- it("preserves thread params when splitting long captions", async () => {
- const chatId = "-1001234567890";
- const longText = "C".repeat(1100);
-
- const sendPhoto = vi.fn().mockResolvedValue({
- message_id: 73,
- chat: { id: chatId },
- });
- const sendMessage = vi.fn().mockResolvedValue({
- message_id: 74,
- chat: { id: chatId },
- });
- const api = { sendPhoto, sendMessage } as unknown as {
- sendPhoto: typeof sendPhoto;
- sendMessage: typeof sendMessage;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-image"),
- contentType: "image/jpeg",
- fileName: "photo.jpg",
- });
-
- await sendMessageTelegram(chatId, longText, {
- token: "tok",
- api,
- mediaUrl: "https://example.com/photo.jpg",
- messageThreadId: 271,
- replyToMessageId: 500,
- });
-
- // Media sent with thread params but no caption
- expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
- caption: undefined,
- message_thread_id: 271,
- reply_to_message_id: 500,
- });
- // Text message also includes thread params (HTML formatting)
- expect(sendMessage).toHaveBeenCalledWith(chatId, longText, {
- parse_mode: "HTML",
- message_thread_id: 271,
- reply_to_message_id: 500,
- });
- });
-
- it("puts reply_markup only on follow-up text when splitting", async () => {
- const chatId = "123";
- const longText = "D".repeat(1100);
-
- const sendPhoto = vi.fn().mockResolvedValue({
- message_id: 75,
- chat: { id: chatId },
- });
- const sendMessage = vi.fn().mockResolvedValue({
- message_id: 76,
- chat: { id: chatId },
- });
- const api = { sendPhoto, sendMessage } as unknown as {
- sendPhoto: typeof sendPhoto;
- sendMessage: typeof sendMessage;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-image"),
- contentType: "image/jpeg",
- fileName: "photo.jpg",
- });
-
- await sendMessageTelegram(chatId, longText, {
- token: "tok",
- api,
- mediaUrl: "https://example.com/photo.jpg",
- buttons: [[{ text: "Click me", callback_data: "action:click" }]],
- });
-
- // Media sent WITHOUT reply_markup
- expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
- caption: undefined,
- });
- // Follow-up text has the reply_markup
- expect(sendMessage).toHaveBeenCalledWith(chatId, longText, {
- parse_mode: "HTML",
- reply_markup: {
- inline_keyboard: [[{ text: "Click me", callback_data: "action:click" }]],
- },
- });
- });
-
- it("includes thread params and reply_markup on follow-up text when splitting", async () => {
- const chatId = "-1001234567890";
- const longText = "F".repeat(1100);
-
- const sendPhoto = vi.fn().mockResolvedValue({
- message_id: 78,
- chat: { id: chatId },
- });
- const sendMessage = vi.fn().mockResolvedValue({
- message_id: 79,
- chat: { id: chatId },
- });
- const api = { sendPhoto, sendMessage } as unknown as {
- sendPhoto: typeof sendPhoto;
- sendMessage: typeof sendMessage;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-image"),
- contentType: "image/jpeg",
- fileName: "photo.jpg",
- });
-
- await sendMessageTelegram(chatId, longText, {
- token: "tok",
- api,
- mediaUrl: "https://example.com/photo.jpg",
- messageThreadId: 271,
- replyToMessageId: 500,
- buttons: [[{ text: "Click me", callback_data: "action:click" }]],
- });
-
- expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
- caption: undefined,
- message_thread_id: 271,
- reply_to_message_id: 500,
- });
- expect(sendMessage).toHaveBeenCalledWith(chatId, longText, {
- parse_mode: "HTML",
- message_thread_id: 271,
- reply_to_message_id: 500,
- reply_markup: {
- inline_keyboard: [[{ text: "Click me", callback_data: "action:click" }]],
- },
- });
- });
-
- it("wraps chat-not-found errors from follow-up message", async () => {
- const chatId = "123";
- const longText = "G".repeat(1100);
-
- const sendPhoto = vi.fn().mockResolvedValue({
- message_id: 80,
- chat: { id: chatId },
- });
- const sendMessage = vi.fn().mockRejectedValue(new Error("400: Bad Request: chat not found"));
- const api = { sendPhoto, sendMessage } as unknown as {
- sendPhoto: typeof sendPhoto;
- sendMessage: typeof sendMessage;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-image"),
- contentType: "image/jpeg",
- fileName: "photo.jpg",
- });
-
- await expect(
- sendMessageTelegram(chatId, longText, {
- token: "tok",
- api,
- mediaUrl: "https://example.com/photo.jpg",
- }),
- ).rejects.toThrow(/Telegram send failed: chat not found \(chat_id=123\)\./);
- });
-
- it("does not send follow-up text when caption is empty", async () => {
- const chatId = "123";
- const emptyText = " ";
-
- const sendPhoto = vi.fn().mockResolvedValue({
- message_id: 81,
- chat: { id: chatId },
- });
- const sendMessage = vi.fn();
- const api = { sendPhoto, sendMessage } as unknown as {
- sendPhoto: typeof sendPhoto;
- sendMessage: typeof sendMessage;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-image"),
- contentType: "image/jpeg",
- fileName: "photo.jpg",
- });
-
- const res = await sendMessageTelegram(chatId, emptyText, {
- token: "tok",
- api,
- mediaUrl: "https://example.com/photo.jpg",
- });
-
- expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
- caption: undefined,
- });
- expect(sendMessage).not.toHaveBeenCalled();
- expect(res.messageId).toBe("81");
- });
-
- it("keeps reply_markup on media when not splitting", async () => {
- const chatId = "123";
- const shortText = "E".repeat(100);
-
- const sendPhoto = vi.fn().mockResolvedValue({
- message_id: 77,
- chat: { id: chatId },
- });
- const sendMessage = vi.fn();
- const api = { sendPhoto, sendMessage } as unknown as {
- sendPhoto: typeof sendPhoto;
- sendMessage: typeof sendMessage;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-image"),
- contentType: "image/jpeg",
- fileName: "photo.jpg",
- });
-
- await sendMessageTelegram(chatId, shortText, {
- token: "tok",
- api,
- mediaUrl: "https://example.com/photo.jpg",
- buttons: [[{ text: "Click me", callback_data: "action:click" }]],
- });
-
- // Media sent WITH reply_markup when not splitting
- expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
- caption: shortText,
- parse_mode: "HTML",
- reply_markup: {
- inline_keyboard: [[{ text: "Click me", callback_data: "action:click" }]],
- },
- });
- expect(sendMessage).not.toHaveBeenCalled();
- });
-});
diff --git a/src/telegram/send.edit-message.test.ts b/src/telegram/send.edit-message.test.ts
deleted file mode 100644
index 8ee5a3e56..000000000
--- a/src/telegram/send.edit-message.test.ts
+++ /dev/null
@@ -1,110 +0,0 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
-
-const { botApi, botCtorSpy } = vi.hoisted(() => ({
- botApi: {
- editMessageText: vi.fn(),
- },
- botCtorSpy: vi.fn(),
-}));
-
-vi.mock("grammy", () => ({
- Bot: class {
- api = botApi;
- constructor(public token: string) {
- botCtorSpy(token);
- }
- },
- InputFile: class {},
-}));
-
-import { editMessageTelegram } from "./send.js";
-
-describe("editMessageTelegram", () => {
- beforeEach(() => {
- botApi.editMessageText.mockReset();
- botCtorSpy.mockReset();
- });
-
- it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => {
- botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
-
- await editMessageTelegram("123", 1, "hi", {
- token: "tok",
- cfg: {},
- });
-
- expect(botCtorSpy).toHaveBeenCalledWith("tok");
- expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
- const call = botApi.editMessageText.mock.calls[0] ?? [];
- const params = call[3] as Record;
- expect(params).toEqual(expect.objectContaining({ parse_mode: "HTML" }));
- expect(params).not.toHaveProperty("reply_markup");
- });
-
- it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => {
- botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
-
- await editMessageTelegram("123", 1, "hi", {
- token: "tok",
- cfg: {},
- buttons: [],
- });
-
- expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
- const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record;
- expect(params).toEqual(
- expect.objectContaining({
- parse_mode: "HTML",
- reply_markup: { inline_keyboard: [] },
- }),
- );
- });
-
- it("falls back to plain text when Telegram HTML parse fails (and preserves reply_markup)", async () => {
- botApi.editMessageText
- .mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities"))
- .mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } });
-
- await editMessageTelegram("123", 1, " html", {
- token: "tok",
- cfg: {},
- buttons: [],
- });
-
- expect(botApi.editMessageText).toHaveBeenCalledTimes(2);
-
- const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record;
- expect(firstParams).toEqual(
- expect.objectContaining({
- parse_mode: "HTML",
- reply_markup: { inline_keyboard: [] },
- }),
- );
-
- const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record;
- expect(secondParams).toEqual(
- expect.objectContaining({
- reply_markup: { inline_keyboard: [] },
- }),
- );
- });
-
- it("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 },
- }),
- );
- });
-});
diff --git a/src/telegram/send.poll.test.ts b/src/telegram/send.poll.test.ts
deleted file mode 100644
index 31bc1dc90..000000000
--- a/src/telegram/send.poll.test.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import type { Bot } from "grammy";
-import { describe, expect, it, vi } from "vitest";
-import { sendPollTelegram } from "./send.js";
-
-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);
- expect(api.sendPoll.mock.calls[0]?.[0]).toBe("123");
- expect(api.sendPoll.mock.calls[0]?.[1]).toBe("Q");
- expect(api.sendPoll.mock.calls[0]?.[2]).toEqual(["A", "B"]);
- expect(api.sendPoll.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(
- "123",
- { 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]?.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();
- });
-});
diff --git a/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts b/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts
deleted file mode 100644
index 2f9e7d057..000000000
--- a/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-
-const { botApi, botCtorSpy } = vi.hoisted(() => ({
- botApi: {
- sendMessage: vi.fn(),
- setMessageReaction: vi.fn(),
- },
- botCtorSpy: vi.fn(),
-}));
-
-const { loadWebMedia } = vi.hoisted(() => ({
- loadWebMedia: vi.fn(),
-}));
-
-vi.mock("../web/media.js", () => ({
- loadWebMedia,
-}));
-
-vi.mock("grammy", () => ({
- Bot: class {
- api = botApi;
- catch = vi.fn();
- constructor(
- public token: string,
- public options?: { client?: { fetch?: typeof fetch } },
- ) {
- botCtorSpy(token, options);
- }
- },
- InputFile: class {},
-}));
-
-import { reactMessageTelegram, sendMessageTelegram } from "./send.js";
-
-describe("buildInlineKeyboard", () => {
- it("preserves thread params in plain text fallback", async () => {
- const chatId = "-1001234567890";
- const parseErr = new Error(
- "400: Bad Request: can't parse entities: Can't find end of the entity",
- );
- const sendMessage = vi
- .fn()
- .mockRejectedValueOnce(parseErr)
- .mockResolvedValueOnce({
- message_id: 60,
- chat: { id: chatId },
- });
- const api = { sendMessage } as unknown as {
- sendMessage: typeof sendMessage;
- };
-
- const res = await sendMessageTelegram(chatId, "_bad markdown_", {
- token: "tok",
- api,
- messageThreadId: 271,
- replyToMessageId: 100,
- });
-
- // First call: with HTML + thread params
- expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "bad markdown", {
- parse_mode: "HTML",
- message_thread_id: 271,
- reply_to_message_id: 100,
- });
- // Second call: plain text BUT still with thread params (critical!)
- expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_bad markdown_", {
- message_thread_id: 271,
- reply_to_message_id: 100,
- });
- expect(res.messageId).toBe("60");
- });
-
- it("includes thread params in media messages", async () => {
- const chatId = "-1001234567890";
- const sendPhoto = vi.fn().mockResolvedValue({
- message_id: 58,
- chat: { id: chatId },
- });
- const api = { sendPhoto } as unknown as {
- sendPhoto: typeof sendPhoto;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-image"),
- contentType: "image/jpeg",
- fileName: "photo.jpg",
- });
-
- await sendMessageTelegram(chatId, "photo in topic", {
- token: "tok",
- api,
- mediaUrl: "https://example.com/photo.jpg",
- messageThreadId: 99,
- });
-
- expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
- caption: "photo in topic",
- parse_mode: "HTML",
- message_thread_id: 99,
- });
- });
-});
-
-describe("reactMessageTelegram", () => {
- it("sends emoji reactions", async () => {
- const setMessageReaction = vi.fn().mockResolvedValue(undefined);
- const api = { setMessageReaction } as unknown as {
- setMessageReaction: typeof setMessageReaction;
- };
-
- await reactMessageTelegram("telegram:123", "456", "✅", {
- token: "tok",
- api,
- });
-
- expect(setMessageReaction).toHaveBeenCalledWith("123", 456, [{ type: "emoji", emoji: "✅" }]);
- });
-
- it("removes reactions when emoji is empty", async () => {
- const setMessageReaction = vi.fn().mockResolvedValue(undefined);
- const api = { setMessageReaction } as unknown as {
- setMessageReaction: typeof setMessageReaction;
- };
-
- await reactMessageTelegram("123", 456, "", {
- token: "tok",
- api,
- });
-
- expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []);
- });
-
- it("removes reactions when remove flag is set", async () => {
- const setMessageReaction = vi.fn().mockResolvedValue(undefined);
- const api = { setMessageReaction } as unknown as {
- setMessageReaction: typeof setMessageReaction;
- };
-
- await reactMessageTelegram("123", 456, "✅", {
- token: "tok",
- api,
- remove: true,
- });
-
- expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []);
- });
-});
diff --git a/src/telegram/send.test-harness.ts b/src/telegram/send.test-harness.ts
index 528ec2fb5..f211d3936 100644
--- a/src/telegram/send.test-harness.ts
+++ b/src/telegram/send.test-harness.ts
@@ -3,10 +3,16 @@ import type { MockFn } from "../test-utils/vitest-mock-fn.js";
const { botApi, botCtorSpy } = vi.hoisted(() => ({
botApi: {
+ deleteMessage: vi.fn(),
+ editMessageText: vi.fn(),
sendMessage: vi.fn(),
+ sendPoll: vi.fn(),
sendPhoto: vi.fn(),
+ sendVoice: vi.fn(),
+ sendAudio: vi.fn(),
sendVideo: vi.fn(),
sendVideoNote: vi.fn(),
+ sendAnimation: vi.fn(),
setMessageReaction: vi.fn(),
sendSticker: vi.fn(),
},
diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.test.ts
similarity index 61%
rename from src/telegram/send.returns-undefined-empty-input.test.ts
rename to src/telegram/send.test.ts
index d0d9eb7cd..23b87433a 100644
--- a/src/telegram/send.returns-undefined-empty-input.test.ts
+++ b/src/telegram/send.test.ts
@@ -1,3 +1,4 @@
+import type { Bot } from "grammy";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
getTelegramSendTestMocks,
@@ -8,8 +9,14 @@ import {
installTelegramSendTestHooks();
const { botApi, botCtorSpy, loadConfig, loadWebMedia } = getTelegramSendTestMocks();
-const { buildInlineKeyboard, sendMessageTelegram, sendStickerTelegram } =
- await importTelegramSendModule();
+const {
+ buildInlineKeyboard,
+ editMessageTelegram,
+ reactMessageTelegram,
+ sendMessageTelegram,
+ sendPollTelegram,
+ sendStickerTelegram,
+} = await importTelegramSendModule();
describe("buildInlineKeyboard", () => {
it("returns undefined for empty input", () => {
@@ -229,6 +236,322 @@ describe("sendMessageTelegram", () => {
);
});
+ it("preserves thread params in plain text fallback", async () => {
+ const chatId = "-1001234567890";
+ const parseErr = new Error(
+ "400: Bad Request: can't parse entities: Can't find end of the entity",
+ );
+ const sendMessage = vi
+ .fn()
+ .mockRejectedValueOnce(parseErr)
+ .mockResolvedValueOnce({
+ message_id: 60,
+ chat: { id: chatId },
+ });
+ const api = { sendMessage } as unknown as {
+ sendMessage: typeof sendMessage;
+ };
+
+ const res = await sendMessageTelegram(chatId, "_bad markdown_", {
+ token: "tok",
+ api,
+ messageThreadId: 271,
+ replyToMessageId: 100,
+ });
+
+ expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "bad markdown", {
+ parse_mode: "HTML",
+ message_thread_id: 271,
+ reply_to_message_id: 100,
+ });
+ expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_bad markdown_", {
+ message_thread_id: 271,
+ reply_to_message_id: 100,
+ });
+ expect(res.messageId).toBe("60");
+ });
+
+ it("includes thread params in media messages", async () => {
+ const chatId = "-1001234567890";
+ const sendPhoto = vi.fn().mockResolvedValue({
+ message_id: 58,
+ chat: { id: chatId },
+ });
+ const api = { sendPhoto } as unknown as {
+ sendPhoto: typeof sendPhoto;
+ };
+
+ loadWebMedia.mockResolvedValueOnce({
+ buffer: Buffer.from("fake-image"),
+ contentType: "image/jpeg",
+ fileName: "photo.jpg",
+ });
+
+ await sendMessageTelegram(chatId, "photo in topic", {
+ token: "tok",
+ api,
+ mediaUrl: "https://example.com/photo.jpg",
+ messageThreadId: 99,
+ });
+
+ expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
+ caption: "photo in topic",
+ parse_mode: "HTML",
+ message_thread_id: 99,
+ });
+ });
+
+ it("splits long captions into media + text messages when text exceeds 1024 chars", async () => {
+ const chatId = "123";
+ const longText = "A".repeat(1100);
+
+ const sendPhoto = vi.fn().mockResolvedValue({
+ message_id: 70,
+ chat: { id: chatId },
+ });
+ const sendMessage = vi.fn().mockResolvedValue({
+ message_id: 71,
+ chat: { id: chatId },
+ });
+ const api = { sendPhoto, sendMessage } as unknown as {
+ sendPhoto: typeof sendPhoto;
+ sendMessage: typeof sendMessage;
+ };
+
+ loadWebMedia.mockResolvedValueOnce({
+ buffer: Buffer.from("fake-image"),
+ contentType: "image/jpeg",
+ fileName: "photo.jpg",
+ });
+
+ const res = await sendMessageTelegram(chatId, longText, {
+ token: "tok",
+ api,
+ mediaUrl: "https://example.com/photo.jpg",
+ });
+
+ expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
+ caption: undefined,
+ });
+ expect(sendMessage).toHaveBeenCalledWith(chatId, longText, {
+ parse_mode: "HTML",
+ });
+ expect(res.messageId).toBe("71");
+ });
+
+ it("uses caption when text is within 1024 char limit", async () => {
+ const chatId = "123";
+ const shortText = "B".repeat(1024);
+
+ const sendPhoto = vi.fn().mockResolvedValue({
+ message_id: 72,
+ chat: { id: chatId },
+ });
+ const sendMessage = vi.fn();
+ const api = { sendPhoto, sendMessage } as unknown as {
+ sendPhoto: typeof sendPhoto;
+ sendMessage: typeof sendMessage;
+ };
+
+ loadWebMedia.mockResolvedValueOnce({
+ buffer: Buffer.from("fake-image"),
+ contentType: "image/jpeg",
+ fileName: "photo.jpg",
+ });
+
+ const res = await sendMessageTelegram(chatId, shortText, {
+ token: "tok",
+ api,
+ mediaUrl: "https://example.com/photo.jpg",
+ });
+
+ expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
+ caption: shortText,
+ parse_mode: "HTML",
+ });
+ expect(sendMessage).not.toHaveBeenCalled();
+ expect(res.messageId).toBe("72");
+ });
+
+ it("renders markdown in media captions", async () => {
+ const chatId = "123";
+ const caption = "hi **boss**";
+
+ const sendPhoto = vi.fn().mockResolvedValue({
+ message_id: 90,
+ chat: { id: chatId },
+ });
+ const api = { sendPhoto } as unknown as {
+ sendPhoto: typeof sendPhoto;
+ };
+
+ loadWebMedia.mockResolvedValueOnce({
+ buffer: Buffer.from("fake-image"),
+ contentType: "image/jpeg",
+ fileName: "photo.jpg",
+ });
+
+ await sendMessageTelegram(chatId, caption, {
+ token: "tok",
+ api,
+ mediaUrl: "https://example.com/photo.jpg",
+ });
+
+ expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
+ caption: "hi boss",
+ parse_mode: "HTML",
+ });
+ });
+
+ it("sends video as video note when asVideoNote is true", async () => {
+ const chatId = "123";
+ const text = "ignored caption context";
+
+ const sendVideoNote = vi.fn().mockResolvedValue({
+ message_id: 101,
+ chat: { id: chatId },
+ });
+ const sendMessage = vi.fn().mockResolvedValue({
+ message_id: 102,
+ chat: { id: chatId },
+ });
+ const api = { sendVideoNote, sendMessage } as unknown as {
+ sendVideoNote: typeof sendVideoNote;
+ sendMessage: typeof sendMessage;
+ };
+
+ loadWebMedia.mockResolvedValueOnce({
+ buffer: Buffer.from("fake-video"),
+ contentType: "video/mp4",
+ fileName: "video.mp4",
+ });
+
+ const res = await sendMessageTelegram(chatId, text, {
+ token: "tok",
+ api,
+ mediaUrl: "https://example.com/video.mp4",
+ asVideoNote: true,
+ });
+
+ expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {});
+ expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
+ parse_mode: "HTML",
+ });
+ expect(res.messageId).toBe("102");
+ });
+
+ it("sends regular video when asVideoNote is false", async () => {
+ const chatId = "123";
+ const text = "my caption";
+
+ const sendVideo = vi.fn().mockResolvedValue({
+ message_id: 201,
+ chat: { id: chatId },
+ });
+ const api = { sendVideo } as unknown as {
+ sendVideo: typeof sendVideo;
+ };
+
+ loadWebMedia.mockResolvedValueOnce({
+ buffer: Buffer.from("fake-video"),
+ contentType: "video/mp4",
+ fileName: "video.mp4",
+ });
+
+ const res = await sendMessageTelegram(chatId, text, {
+ token: "tok",
+ api,
+ mediaUrl: "https://example.com/video.mp4",
+ asVideoNote: false,
+ });
+
+ expect(sendVideo).toHaveBeenCalledWith(chatId, expect.anything(), {
+ caption: expect.any(String),
+ parse_mode: "HTML",
+ });
+ expect(res.messageId).toBe("201");
+ });
+
+ it("adds reply_markup to separate text message for video notes", async () => {
+ const chatId = "123";
+ const text = "Check this out";
+
+ const sendVideoNote = vi.fn().mockResolvedValue({
+ message_id: 301,
+ chat: { id: chatId },
+ });
+ const sendMessage = vi.fn().mockResolvedValue({
+ message_id: 302,
+ chat: { id: chatId },
+ });
+ const api = { sendVideoNote, sendMessage } as unknown as {
+ sendVideoNote: typeof sendVideoNote;
+ sendMessage: typeof sendMessage;
+ };
+
+ loadWebMedia.mockResolvedValueOnce({
+ buffer: Buffer.from("fake-video"),
+ contentType: "video/mp4",
+ fileName: "video.mp4",
+ });
+
+ await sendMessageTelegram(chatId, text, {
+ token: "tok",
+ api,
+ mediaUrl: "https://example.com/video.mp4",
+ asVideoNote: true,
+ buttons: [[{ text: "Btn", callback_data: "dat" }]],
+ });
+
+ expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {});
+ expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
+ parse_mode: "HTML",
+ reply_markup: {
+ inline_keyboard: [[{ text: "Btn", callback_data: "dat" }]],
+ },
+ });
+ });
+
+ it("threads video note and text message correctly", async () => {
+ const chatId = "123";
+ const text = "Threaded reply";
+
+ const sendVideoNote = vi.fn().mockResolvedValue({
+ message_id: 401,
+ chat: { id: chatId },
+ });
+ const sendMessage = vi.fn().mockResolvedValue({
+ message_id: 402,
+ chat: { id: chatId },
+ });
+ const api = { sendVideoNote, sendMessage } as unknown as {
+ sendVideoNote: typeof sendVideoNote;
+ sendMessage: typeof sendMessage;
+ };
+
+ loadWebMedia.mockResolvedValueOnce({
+ buffer: Buffer.from("fake-video"),
+ contentType: "video/mp4",
+ fileName: "video.mp4",
+ });
+
+ await sendMessageTelegram(chatId, text, {
+ token: "tok",
+ api,
+ mediaUrl: "https://example.com/video.mp4",
+ asVideoNote: true,
+ replyToMessageId: 999,
+ });
+
+ expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {
+ reply_to_message_id: 999,
+ });
+ expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
+ parse_mode: "HTML",
+ reply_to_message_id: 999,
+ });
+ });
+
it("retries on transient errors with retry_after", async () => {
vi.useFakeTimers();
const chatId = "123";
@@ -647,6 +970,51 @@ describe("sendMessageTelegram", () => {
});
});
+describe("reactMessageTelegram", () => {
+ it("sends emoji reactions", async () => {
+ const setMessageReaction = vi.fn().mockResolvedValue(undefined);
+ const api = { setMessageReaction } as unknown as {
+ setMessageReaction: typeof setMessageReaction;
+ };
+
+ await reactMessageTelegram("telegram:123", "456", "✅", {
+ token: "tok",
+ api,
+ });
+
+ expect(setMessageReaction).toHaveBeenCalledWith("123", 456, [{ type: "emoji", emoji: "✅" }]);
+ });
+
+ it("removes reactions when emoji is empty", async () => {
+ const setMessageReaction = vi.fn().mockResolvedValue(undefined);
+ const api = { setMessageReaction } as unknown as {
+ setMessageReaction: typeof setMessageReaction;
+ };
+
+ await reactMessageTelegram("123", 456, "", {
+ token: "tok",
+ api,
+ });
+
+ expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []);
+ });
+
+ it("removes reactions when remove flag is set", async () => {
+ const setMessageReaction = vi.fn().mockResolvedValue(undefined);
+ const api = { setMessageReaction } as unknown as {
+ setMessageReaction: typeof setMessageReaction;
+ };
+
+ await reactMessageTelegram("123", 456, "✅", {
+ token: "tok",
+ api,
+ remove: true,
+ });
+
+ expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []);
+ });
+});
+
describe("sendStickerTelegram", () => {
beforeEach(() => {
loadConfig.mockReturnValue({});
@@ -849,3 +1217,156 @@ describe("sendStickerTelegram", () => {
expect(sendSticker).toHaveBeenCalledWith(chatId, "fileId123", undefined);
});
});
+
+describe("editMessageTelegram", () => {
+ beforeEach(() => {
+ botApi.editMessageText.mockReset();
+ botCtorSpy.mockReset();
+ });
+
+ it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => {
+ botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
+
+ await editMessageTelegram("123", 1, "hi", {
+ token: "tok",
+ cfg: {},
+ });
+
+ expect(botCtorSpy).toHaveBeenCalledTimes(1);
+ expect(botCtorSpy.mock.calls[0]?.[0]).toBe("tok");
+ expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
+ const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record;
+ expect(params).toEqual(expect.objectContaining({ parse_mode: "HTML" }));
+ expect(params).not.toHaveProperty("reply_markup");
+ });
+
+ it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => {
+ botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
+
+ await editMessageTelegram("123", 1, "hi", {
+ token: "tok",
+ cfg: {},
+ buttons: [],
+ });
+
+ expect(botApi.editMessageText).toHaveBeenCalledTimes(1);
+ const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record;
+ expect(params).toEqual(
+ expect.objectContaining({
+ parse_mode: "HTML",
+ reply_markup: { inline_keyboard: [] },
+ }),
+ );
+ });
+
+ it("falls back to plain text when Telegram HTML parse fails (and preserves reply_markup)", async () => {
+ botApi.editMessageText
+ .mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities"))
+ .mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } });
+
+ await editMessageTelegram("123", 1, " html", {
+ token: "tok",
+ cfg: {},
+ buttons: [],
+ });
+
+ expect(botApi.editMessageText).toHaveBeenCalledTimes(2);
+
+ const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record;
+ expect(firstParams).toEqual(
+ expect.objectContaining({
+ parse_mode: "HTML",
+ reply_markup: { inline_keyboard: [] },
+ }),
+ );
+
+ const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record;
+ expect(secondParams).toEqual(
+ expect.objectContaining({
+ reply_markup: { inline_keyboard: [] },
+ }),
+ );
+ });
+
+ it("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);
+ expect(api.sendPoll.mock.calls[0]?.[0]).toBe("123");
+ expect(api.sendPoll.mock.calls[0]?.[1]).toBe("Q");
+ expect(api.sendPoll.mock.calls[0]?.[2]).toEqual(["A", "B"]);
+ expect(api.sendPoll.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(
+ "123",
+ { 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();
+ });
+});
diff --git a/src/telegram/send.video-note.test.ts b/src/telegram/send.video-note.test.ts
deleted file mode 100644
index 6924bad21..000000000
--- a/src/telegram/send.video-note.test.ts
+++ /dev/null
@@ -1,174 +0,0 @@
-import { describe, expect, it, vi } from "vitest";
-import {
- getTelegramSendTestMocks,
- importTelegramSendModule,
- installTelegramSendTestHooks,
-} from "./send.test-harness.js";
-
-installTelegramSendTestHooks();
-
-const { loadWebMedia } = getTelegramSendTestMocks();
-const { sendMessageTelegram } = await importTelegramSendModule();
-
-describe("sendMessageTelegram video notes", () => {
- it("sends video as video note when asVideoNote is true", async () => {
- const chatId = "123";
- const text = "ignored caption context"; // Should be sent separately
-
- const sendVideoNote = vi.fn().mockResolvedValue({
- message_id: 101,
- chat: { id: chatId },
- });
- const sendMessage = vi.fn().mockResolvedValue({
- message_id: 102,
- chat: { id: chatId },
- });
- const api = { sendVideoNote, sendMessage } as unknown as {
- sendVideoNote: typeof sendVideoNote;
- sendMessage: typeof sendMessage;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-video"),
- contentType: "video/mp4",
- fileName: "video.mp4",
- });
-
- const res = await sendMessageTelegram(chatId, text, {
- token: "tok",
- api,
- mediaUrl: "https://example.com/video.mp4",
- asVideoNote: true,
- });
-
- // Video note sent WITHOUT caption (video notes cannot have captions)
- expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {});
-
- // Text sent as separate message
- expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
- parse_mode: "HTML",
- });
-
- // Returns the text message ID as it is the "main" content with text
- expect(res.messageId).toBe("102");
- });
-
- it("sends regular video when asVideoNote is false", async () => {
- const chatId = "123";
- const text = "my caption";
-
- const sendVideo = vi.fn().mockResolvedValue({
- message_id: 201,
- chat: { id: chatId },
- });
- const api = { sendVideo } as unknown as {
- sendVideo: typeof sendVideo;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-video"),
- contentType: "video/mp4",
- fileName: "video.mp4",
- });
-
- const res = await sendMessageTelegram(chatId, text, {
- token: "tok",
- api,
- mediaUrl: "https://example.com/video.mp4",
- asVideoNote: false,
- });
-
- // Regular video sent WITH caption
- expect(sendVideo).toHaveBeenCalledWith(chatId, expect.anything(), {
- caption: expect.any(String),
- parse_mode: "HTML",
- });
- expect(res.messageId).toBe("201");
- });
-
- it("adds reply_markup to separate text message for video notes", async () => {
- const chatId = "123";
- const text = "Check this out";
-
- const sendVideoNote = vi.fn().mockResolvedValue({
- message_id: 301,
- chat: { id: chatId },
- });
- const sendMessage = vi.fn().mockResolvedValue({
- message_id: 302,
- chat: { id: chatId },
- });
- const api = { sendVideoNote, sendMessage } as unknown as {
- sendVideoNote: typeof sendVideoNote;
- sendMessage: typeof sendMessage;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-video"),
- contentType: "video/mp4",
- fileName: "video.mp4",
- });
-
- await sendMessageTelegram(chatId, text, {
- token: "tok",
- api,
- mediaUrl: "https://example.com/video.mp4",
- asVideoNote: true,
- buttons: [[{ text: "Btn", callback_data: "dat" }]],
- });
-
- // Video note sent WITHOUT reply_markup (it goes to text)
- expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {});
-
- // Text message gets reply markup
- expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
- parse_mode: "HTML",
- reply_markup: {
- inline_keyboard: [[{ text: "Btn", callback_data: "dat" }]],
- },
- });
- });
-
- it("threads video note and text message correctly", async () => {
- const chatId = "123";
- const text = "Threaded reply";
-
- const sendVideoNote = vi.fn().mockResolvedValue({
- message_id: 401,
- chat: { id: chatId },
- });
- const sendMessage = vi.fn().mockResolvedValue({
- message_id: 402,
- chat: { id: chatId },
- });
- const api = { sendVideoNote, sendMessage } as unknown as {
- sendVideoNote: typeof sendVideoNote;
- sendMessage: typeof sendMessage;
- };
-
- loadWebMedia.mockResolvedValueOnce({
- buffer: Buffer.from("fake-video"),
- contentType: "video/mp4",
- fileName: "video.mp4",
- });
-
- await sendMessageTelegram(chatId, text, {
- token: "tok",
- api,
- mediaUrl: "https://example.com/video.mp4",
- asVideoNote: true,
- replyToMessageId: 999,
- });
-
- // Video note threaded
- expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {
- reply_to_message_id: 999,
- });
-
- // Text threaded
- expect(sendMessage).toHaveBeenCalledWith(chatId, text, {
- parse_mode: "HTML",
- reply_to_message_id: 999,
- });
- });
-});