Files
openclaw/extensions/telegram/src/send.test.ts
Chinar Amrutkar 3f67581e50 fix: retry safe wrapped Telegram send failures (#51895) (thanks @chinar-amrutkar)
* fix(telegram): traverse error .cause chain in formatErrorMessage and match grammY HttpError

grammY wraps network failures in HttpError with message
'Network request for ... failed!' and the original error in .cause.
formatErrorMessage only checked err.message, so shouldRetry never
fired for the most common transient failure class.

Changes:
- formatErrorMessage now traverses .cause chain, appending nested
  error messages (with cycle protection)
- Added 'Network request' to TELEGRAM_RETRY_RE as belt-and-suspenders
- Added tests for .cause traversal, circular references, and grammY
  HttpError retry behavior

Fixes #51525

* style: fix oxfmt formatting in retry-policy.ts

* fix: add braces to satisfy oxlint requirement

* fix(telegram): keep send retries strict

* test(telegram): cover wrapped retry paths

* fix(telegram): retry rate-limited sends safely

* fix: retry safe wrapped Telegram send failures (#51895) (thanks @chinar-amrutkar)

* fix: preserve wrapped Telegram rate-limit retries (#51895) (thanks @chinar-amrutkar)

---------

Co-authored-by: chinar-amrutkar <chinar-amrutkar@users.noreply.github.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-01 15:54:29 +05:30

2406 lines
72 KiB
TypeScript

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,
imageMetadata,
loadConfig,
loadWebMedia,
maybePersistResolvedTelegramTarget,
} = getTelegramSendTestMocks();
const {
buildInlineKeyboard,
createForumTopicTelegram,
editForumTopicTelegram,
editMessageTelegram,
pinMessageTelegram,
reactMessageTelegram,
renameForumTopicTelegram,
sendMessageTelegram,
sendTypingTelegram,
sendPollTelegram,
sendStickerTelegram,
unpinMessageTelegram,
} = await importTelegramSendModule();
async function expectChatNotFoundWithChatId(
action: Promise<unknown>,
expectedChatId: string,
): Promise<void> {
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}`));
}
}
async function expectTelegramMembershipErrorWithChatId(
action: Promise<unknown>,
expectedChatId: string,
expectedDetail: RegExp,
): Promise<void> {
try {
await action;
throw new Error("Expected action to reject with membership error context");
} catch (error) {
if (
error instanceof Error &&
error.message === "Expected action to reject with membership error context"
) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
expect(message).toMatch(/not a member of the chat, was blocked, or was kicked/i);
expect(message).toMatch(expectedDetail);
expect(message).toMatch(/Fix: Add the bot to the channel\/group/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<typeof import("./sent-message-cache.js")>(
import.meta.url,
"./sent-message-cache.js?scope=shared-a",
);
const cacheB = await importFreshModule<typeof import("./sent-message-cache.js")>(
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<typeof buildInlineKeyboard>[0];
expected: ReturnType<typeof buildInlineKeyboard>;
}> = [
{
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: "<i>oops</i>",
messageId: 42,
options: { verbose: true } as const,
firstCall: { parse_mode: "HTML" },
secondCall: undefined,
},
{
name: "threaded reply send",
chatId: "-1001234567890",
text: "_bad markdown_",
htmlText: "<i>bad markdown</i>",
messageId: 60,
options: { messageThreadId: 271, replyToMessageId: 100 } as const,
firstCall: {
parse_mode: "HTML",
message_thread_id: 271,
reply_to_message_id: 100,
allow_sending_without_reply: true,
},
secondCall: {
message_thread_id: 271,
reply_to_message_id: 100,
allow_sending_without_reply: true,
},
},
] 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",
"<i>oops</i>",
{ 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,
gatewayClientScopes: ["operator.write"],
});
expect(getChat).toHaveBeenCalledWith("@mychannel");
expect(sendMessage).toHaveBeenCalledWith("-100123", "hi", {
parse_mode: "HTML",
});
expect(maybePersistResolvedTelegramTarget).toHaveBeenCalledWith(
expect.objectContaining({
rawTarget: "https://t.me/mychannel",
resolvedChatId: "-100123",
gatewayClientScopes: ["operator.write"],
}),
);
});
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 <b>boss</b>",
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<NonNullable<Parameters<typeof sendMessageTelegram>[2]>>;
expectedVideoNote: Record<string, unknown>;
expectedMessage: Record<string, unknown>;
}> = [
{
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, allow_sending_without_reply: true },
expectedMessage: {
parse_mode: "HTML",
reply_to_message_id: 999,
allow_sending_without_reply: true,
},
},
];
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<Parameters<typeof sendMessageTelegram>[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("retries wrapped pre-connect HttpError sends", async () => {
vi.useFakeTimers();
const chatId = "123";
const root = Object.assign(new Error("connect ECONNREFUSED api.telegram.org"), {
code: "ECONNREFUSED",
});
const fetchError = Object.assign(new TypeError("fetch failed"), { cause: root });
const err = Object.assign(new Error("Network request for 'sendMessage' failed!"), {
name: "HttpError",
error: fetchError,
});
const sendMessage = vi
.fn()
.mockRejectedValueOnce(err)
.mockResolvedValueOnce({
message_id: 1,
chat: { id: chatId },
});
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
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(sendMessage).toHaveBeenCalledTimes(2);
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.each([
{ name: "oversized dimensions", width: 6000, height: 5001 },
{ name: "oversized aspect ratio", width: 4000, height: 100 },
])("sends images as documents when Telegram rejects $name", async ({ width, height }) => {
const chatId = "123";
const sendDocument = vi.fn().mockResolvedValue({
message_id: 10,
chat: { id: chatId },
});
const sendPhoto = vi.fn();
const api = { sendDocument, sendPhoto } as unknown as {
sendDocument: typeof sendDocument;
sendPhoto: typeof sendPhoto;
};
imageMetadata.width = width;
imageMetadata.height = height;
mockLoadedMedia({
buffer: Buffer.from("fake-image"),
contentType: "image/png",
fileName: "photo.png",
});
const res = await sendMessageTelegram(chatId, "caption", {
token: "tok",
api,
mediaUrl: "https://example.com/photo.png",
});
expect(sendDocument).toHaveBeenCalledWith(chatId, expect.anything(), {
caption: "caption",
parse_mode: "HTML",
});
expect(sendPhoto).not.toHaveBeenCalled();
expect(res.messageId).toBe("10");
});
it("sends images as documents when metadata dimensions are unavailable", async () => {
const chatId = "123";
const sendDocument = vi.fn().mockResolvedValue({
message_id: 10,
chat: { id: chatId },
});
const sendPhoto = vi.fn();
const api = { sendDocument, sendPhoto } as unknown as {
sendDocument: typeof sendDocument;
sendPhoto: typeof sendPhoto;
};
imageMetadata.width = undefined;
imageMetadata.height = undefined;
mockLoadedMedia({
buffer: Buffer.from("fake-image"),
contentType: "image/png",
fileName: "photo.png",
});
const res = await sendMessageTelegram(chatId, "caption", {
token: "tok",
api,
mediaUrl: "https://example.com/photo.png",
});
expect(sendDocument).toHaveBeenCalledWith(chatId, expect.anything(), {
caption: "caption",
parse_mode: "HTML",
});
expect(sendPhoto).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<string, unknown>;
}> = [
{
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,
allow_sending_without_reply: true,
},
},
{
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, "<i>oops</i>", { 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 = `<b>${"A".repeat(5000)}</b>`;
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 = `<b>${"A".repeat(5000)}</b>`;
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 = `<b>${"A".repeat(5000)}</b>`;
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);
});
it("does not retry generic grammY failed envelopes for sticker sends", async () => {
const chatId = "123";
const sendSticker = vi
.fn()
.mockRejectedValueOnce(new Error("Network request for 'sendSticker' failed!"));
const api = { sendSticker } as unknown as {
sendSticker: typeof sendSticker;
};
await expect(
sendStickerTelegram(chatId, "fileId123", {
token: "tok",
api,
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
}),
).rejects.toThrow(/Network request for 'sendSticker' failed!/i);
expect(sendSticker).toHaveBeenCalledTimes(1);
});
it("retries rate-limited sticker sends and honors retry_after", async () => {
vi.useFakeTimers();
const chatId = "123";
const sendSticker = vi
.fn()
.mockRejectedValueOnce({
message: "429 Too Many Requests",
response: { parameters: { retry_after: 1 } },
})
.mockResolvedValueOnce({
message_id: 109,
chat: { id: chatId },
});
const api = { sendSticker } as unknown as {
sendSticker: typeof sendSticker;
};
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
const promise = sendStickerTelegram(chatId, "fileId123", {
token: "tok",
api,
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
});
await vi.runAllTimersAsync();
await expect(promise).resolves.toEqual({ messageId: "109", chatId });
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(1000);
expect(sendSticker).toHaveBeenCalledTimes(2);
setTimeoutSpy.mockRestore();
vi.useRealTimers();
});
});
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,
allow_sending_without_reply: true,
});
},
},
{
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,
allow_sending_without_reply: true,
});
},
},
] as const;
for (const testCase of cases) {
await testCase.run();
}
});
it("omits invalid reply_to_message_id values before calling Telegram", async () => {
const invalidReplyToMessageIds = ["session-meta-id", "123abc", Number.NaN] as const;
for (const invalidReplyToMessageId of invalidReplyToMessageIds) {
const chatId = "123";
const sendMessage = vi.fn().mockResolvedValue({
message_id: 56,
chat: { id: chatId },
});
const sendSticker = vi.fn().mockResolvedValue({
message_id: 102,
chat: { id: chatId },
});
const api = { sendMessage, sendSticker } as unknown as {
sendMessage: typeof sendMessage;
sendSticker: typeof sendSticker;
};
await sendMessageTelegram(chatId, "reply text", {
token: "tok",
api,
replyToMessageId: invalidReplyToMessageId as unknown as number,
});
await sendStickerTelegram(chatId, "CAACAgIAAxkBAAI...sticker_file_id", {
token: "tok",
api,
replyToMessageId: invalidReplyToMessageId as unknown as number,
});
expect(sendMessage, String(invalidReplyToMessageId)).toHaveBeenCalledWith(
chatId,
"reply text",
{
parse_mode: "HTML",
},
);
expect(sendSticker, String(invalidReplyToMessageId)).toHaveBeenCalledWith(
chatId,
"CAACAgIAAxkBAAI...sticker_file_id",
undefined,
);
}
});
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();
}
});
it("wraps membership-related 403 errors with actionable context and original detail", async () => {
const cases = [
{
name: "message send",
errorText: "403: Forbidden: bot is not a member of the channel chat",
run: async (chatId: string, err: Error) => {
const sendMessage = vi.fn().mockRejectedValue(err);
const api = { sendMessage } as unknown as {
sendMessage: typeof sendMessage;
};
await expectTelegramMembershipErrorWithChatId(
sendMessageTelegram(chatId, "hi", { token: "tok", api }),
chatId,
/bot is not a member of the channel chat/i,
);
},
},
{
name: "sticker send",
errorText: "403: Forbidden: bot was kicked from the group chat",
run: async (chatId: string, err: Error) => {
const sendSticker = vi.fn().mockRejectedValue(err);
const api = { sendSticker } as unknown as {
sendSticker: typeof sendSticker;
};
await expectTelegramMembershipErrorWithChatId(
sendStickerTelegram(chatId, "fileId123", { token: "tok", api }),
chatId,
/bot was kicked from the group chat/i,
);
},
},
] as const;
for (const testCase of cases) {
await testCase.run("123", new Error(testCase.errorText));
}
});
});
describe("editMessageTelegram", () => {
it.each([
{
name: "buttons undefined keeps existing keyboard",
text: "hi",
buttons: undefined as Parameters<typeof buildInlineKeyboard>[0],
expectedCalls: 1,
firstExpectNoReplyMarkup: true,
parseFallback: false,
},
{
name: "buttons empty clears keyboard",
text: "hi",
buttons: [] as Parameters<typeof buildInlineKeyboard>[0],
expectedCalls: 1,
firstExpectReplyMarkup: { inline_keyboard: [] } as Record<string, unknown>,
parseFallback: false,
},
{
name: "parse error fallback preserves cleared keyboard",
text: "<bad> html",
buttons: [] as Parameters<typeof buildInlineKeyboard>[0],
expectedCalls: 2,
firstExpectReplyMarkup: { inline_keyboard: [] } as Record<string, unknown>,
secondExpectReplyMarkup: { inline_keyboard: [] } as Record<string, unknown>,
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<string, unknown>;
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<string, unknown>;
expect(params).toEqual(
expect.objectContaining({
parse_mode: "HTML",
link_preview_options: { is_disabled: true },
}),
);
});
});
describe("sendPollTelegram", () => {
it("propagates gateway client scopes when resolving legacy poll targets", async () => {
const api = {
getChat: vi.fn(async () => ({ id: -100321 })),
sendPoll: vi.fn(async () => ({ message_id: 123, chat: { id: 555 }, poll: { id: "p1" } })),
};
await sendPollTelegram(
"https://t.me/mychannel",
{ question: " Q ", options: [" A ", "B "] },
{
token: "t",
api: api as unknown as Bot["api"],
gatewayClientScopes: ["operator.admin"],
},
);
expect(api.getChat).toHaveBeenCalledWith("@mychannel");
expect(maybePersistResolvedTelegramTarget).toHaveBeenCalledWith(
expect.objectContaining({
rawTarget: "https://t.me/mychannel",
resolvedChatId: "-100321",
gatewayClientScopes: ["operator.admin"],
}),
);
});
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<typeof vi.fn>;
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);
});
}
});