mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 22:44:14 +00:00
Move Telegram plugin-local state from JSON sidecars into plugin-state SQLite. Keep legacy JSON handling in startup and doctor migration plans, with runtime state now reading and writing SQLite directly. Stabilize the channel Vitest lane by cleaning up typing timers and isolating that lane.
3349 lines
102 KiB
TypeScript
3349 lines
102 KiB
TypeScript
import fs from "node:fs";
|
|
import type { Bot } from "grammy";
|
|
import {
|
|
createPluginStateSyncKeyedStoreForTests,
|
|
resetPluginStateStoreForTests,
|
|
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
|
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
buildTelegramConversationContext,
|
|
createTelegramMessageCache,
|
|
resolveTelegramMessageCachePath,
|
|
resetTelegramMessageCacheBucketsForTest,
|
|
} from "./message-cache.js";
|
|
import {
|
|
getTelegramSendTestMocks,
|
|
importTelegramSendModule,
|
|
installTelegramSendTestHooks,
|
|
} from "./send.test-harness.js";
|
|
import {
|
|
TELEGRAM_SENT_MESSAGE_CACHE_MAX_ENTRIES,
|
|
TELEGRAM_SENT_MESSAGE_CACHE_NAMESPACE,
|
|
clearSentMessageCache,
|
|
recordSentMessage,
|
|
resetSentMessageCacheForTest,
|
|
setTelegramSentMessageStoreForTest,
|
|
wasSentByBot,
|
|
} from "./sent-message-cache.js";
|
|
|
|
installTelegramSendTestHooks();
|
|
|
|
const {
|
|
botApi,
|
|
botConfigUseSpy,
|
|
botCtorSpy,
|
|
imageMetadata,
|
|
loadConfig,
|
|
loadWebMedia,
|
|
maybePersistResolvedTelegramTarget,
|
|
probeVideoDimensions,
|
|
} = getTelegramSendTestMocks();
|
|
const telegramSendModule = await importTelegramSendModule();
|
|
const { resetLogger, setLoggerOverride } = await import("openclaw/plugin-sdk/runtime-env");
|
|
const {
|
|
buildInlineKeyboard,
|
|
createForumTopicTelegram,
|
|
deleteMessageTelegram,
|
|
editForumTopicTelegram,
|
|
editMessageTelegram,
|
|
pinMessageTelegram,
|
|
reactMessageTelegram,
|
|
renameForumTopicTelegram,
|
|
sendMessageTelegram,
|
|
sendTypingTelegram,
|
|
sendPollTelegram,
|
|
sendStickerTelegram,
|
|
unpinMessageTelegram,
|
|
} = telegramSendModule;
|
|
|
|
const TELEGRAM_TEST_CFG = {};
|
|
let sentMessageStore: NonNullable<Parameters<typeof setTelegramSentMessageStoreForTest>[0]>;
|
|
|
|
beforeEach(() => {
|
|
resetPluginStateStoreForTests({ closeDatabase: false });
|
|
sentMessageStore = createPluginStateSyncKeyedStoreForTests("telegram", {
|
|
namespace: TELEGRAM_SENT_MESSAGE_CACHE_NAMESPACE,
|
|
maxEntries: TELEGRAM_SENT_MESSAGE_CACHE_MAX_ENTRIES,
|
|
});
|
|
sentMessageStore.clear();
|
|
setTelegramSentMessageStoreForTest(sentMessageStore);
|
|
});
|
|
|
|
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 } : {}),
|
|
});
|
|
}
|
|
|
|
function requireMockCall<T extends unknown[]>(call: T | undefined, label: string): T {
|
|
if (!call) {
|
|
throw new Error(`expected ${label}`);
|
|
}
|
|
return call;
|
|
}
|
|
|
|
function mockCall(mock: ReturnType<typeof vi.fn>, index: number, label: string): unknown[] {
|
|
const calls = mock.mock.calls;
|
|
const resolvedIndex = index < 0 ? calls.length + index : index;
|
|
return requireMockCall(calls[resolvedIndex], label);
|
|
}
|
|
|
|
function firstMockCall(mock: ReturnType<typeof vi.fn>, label: string): unknown[] {
|
|
return mockCall(mock, 0, label);
|
|
}
|
|
|
|
function requireString(value: unknown, label: string): string {
|
|
if (typeof value !== "string") {
|
|
throw new Error(`expected ${label} to be a string`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function mockCallText(call: unknown[], label: string): string {
|
|
return requireString(call[1], label);
|
|
}
|
|
|
|
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null) {
|
|
throw new Error(`expected ${label}`);
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function expectMediaSendCall(
|
|
call: unknown[] | undefined,
|
|
label: string,
|
|
chatId: string,
|
|
expectedParams: Record<string, unknown>,
|
|
): void {
|
|
const [actualChatId, media, actualParams] = requireMockCall(call, label);
|
|
expect(actualChatId).toBe(chatId);
|
|
if (media === undefined) {
|
|
throw new Error(`expected ${label} media`);
|
|
}
|
|
expect(actualParams).toEqual(expectedParams);
|
|
}
|
|
|
|
function expectPersistedTarget(fields: Record<string, unknown>): void {
|
|
const [target] = requireMockCall(
|
|
mockCall(maybePersistResolvedTelegramTarget, -1, "persisted Telegram target"),
|
|
"persisted Telegram target",
|
|
);
|
|
const record = requireRecord(target, "persisted Telegram target");
|
|
for (const [key, value] of Object.entries(fields)) {
|
|
expect(record[key]).toEqual(value);
|
|
}
|
|
}
|
|
|
|
let logCaptureCounter = 0;
|
|
|
|
function captureInfoLogs(): string {
|
|
logCaptureCounter += 1;
|
|
const logFile = `/tmp/openclaw-telegram-send-log-${process.pid}-${logCaptureCounter}.jsonl`;
|
|
fs.rmSync(logFile, { force: true });
|
|
setLoggerOverride({ level: "info", consoleLevel: "silent", file: logFile });
|
|
return logFile;
|
|
}
|
|
|
|
function capturedLogText(logFile: string): string {
|
|
return fs.existsSync(logFile) ? fs.readFileSync(logFile, "utf8") : "";
|
|
}
|
|
|
|
afterEach(() => {
|
|
clearSentMessageCache();
|
|
setTelegramSentMessageStoreForTest(undefined);
|
|
resetPluginStateStoreForTests();
|
|
setLoggerOverride(null);
|
|
resetLogger();
|
|
resetTelegramMessageCacheBucketsForTest();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("sent-message-cache", () => {
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
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("keeps sent-message cache storage failures best-effort", () => {
|
|
setTelegramSentMessageStoreForTest({
|
|
...sentMessageStore,
|
|
entries() {
|
|
throw new Error("read boom");
|
|
},
|
|
register() {
|
|
throw new Error("write boom");
|
|
},
|
|
});
|
|
|
|
expect(() => recordSentMessage(123, 1)).not.toThrow();
|
|
expect(wasSentByBot(123, 1)).toBe(true);
|
|
});
|
|
|
|
it("persists sent-message rows with their remaining logical ttl", () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date("2026-01-26T12:00:00.000Z"));
|
|
const ttlByMessageId = new Map<string, number>();
|
|
setTelegramSentMessageStoreForTest({
|
|
...sentMessageStore,
|
|
register(key, value, options) {
|
|
sentMessageStore.register(key, value, options);
|
|
ttlByMessageId.set(value.messageId, options?.ttlMs ?? 0);
|
|
},
|
|
});
|
|
|
|
recordSentMessage(123, 1);
|
|
vi.advanceTimersByTime(60 * 60 * 1000);
|
|
recordSentMessage(123, 2);
|
|
|
|
expect(ttlByMessageId.get("1")).toBe(23 * 60 * 60 * 1000);
|
|
expect(ttlByMessageId.get("2")).toBe(24 * 60 * 60 * 1000);
|
|
});
|
|
|
|
it("keeps sent-message ownership across restart", async () => {
|
|
const persistedStorePath = `/tmp/openclaw-telegram-send-tests-${process.pid}-restart.json`;
|
|
const sentMessageCfg = { session: { store: persistedStorePath } };
|
|
|
|
recordSentMessage(123, 1, sentMessageCfg);
|
|
expect(wasSentByBot(123, 1, sentMessageCfg)).toBe(true);
|
|
|
|
resetSentMessageCacheForTest();
|
|
|
|
const restartedCache = await importFreshModule<typeof import("./sent-message-cache.js")>(
|
|
import.meta.url,
|
|
"./sent-message-cache.js?scope=restart",
|
|
);
|
|
restartedCache.setTelegramSentMessageStoreForTest(sentMessageStore);
|
|
|
|
try {
|
|
expect(restartedCache.wasSentByBot(123, 1, sentMessageCfg)).toBe(true);
|
|
} finally {
|
|
restartedCache.clearSentMessageCache();
|
|
restartedCache.setTelegramSentMessageStoreForTest(undefined);
|
|
}
|
|
});
|
|
|
|
it("keeps expired custom-store cleanup away from the default store", () => {
|
|
const customStorePath = `/tmp/openclaw-telegram-send-tests-${process.pid}-custom-cleanup.json`;
|
|
const customCfg = { session: { store: customStorePath } };
|
|
const startedAt = new Date("2026-01-01T00:00:00.000Z");
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(startedAt);
|
|
|
|
try {
|
|
recordSentMessage(123, 2, customCfg);
|
|
|
|
vi.setSystemTime(startedAt.getTime() + 24 * 60 * 60 * 1000 + 1);
|
|
recordSentMessage(123, 1);
|
|
|
|
expect(wasSentByBot(123, 2, customCfg)).toBe(false);
|
|
expect(wasSentByBot(123, 1)).toBe(true);
|
|
} finally {
|
|
fs.rmSync(customStorePath, { force: true });
|
|
fs.rmSync(`${customStorePath}.telegram-sent-messages.json`, { force: true });
|
|
}
|
|
});
|
|
|
|
it("keeps default and custom stores isolated while both are loaded", () => {
|
|
const customStorePath = `/tmp/openclaw-telegram-send-tests-${process.pid}-custom-isolated.json`;
|
|
const customCfg = { session: { store: customStorePath } };
|
|
|
|
try {
|
|
recordSentMessage(123, 1);
|
|
recordSentMessage(123, 2, customCfg);
|
|
|
|
expect(wasSentByBot(123, 1)).toBe(true);
|
|
expect(wasSentByBot(123, 2)).toBe(false);
|
|
expect(wasSentByBot(123, 1, customCfg)).toBe(false);
|
|
expect(wasSentByBot(123, 2, customCfg)).toBe(true);
|
|
} finally {
|
|
fs.rmSync(customStorePath, { force: true });
|
|
fs.rmSync(`${customStorePath}.telegram-sent-messages.json`, { force: true });
|
|
}
|
|
});
|
|
|
|
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.setTelegramSentMessageStoreForTest(sentMessageStore);
|
|
cacheB.setTelegramSentMessageStoreForTest(sentMessageStore);
|
|
|
|
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();
|
|
cacheA.setTelegramSentMessageStoreForTest(undefined);
|
|
cacheB.setTelegramSentMessageStoreForTest(undefined);
|
|
}
|
|
});
|
|
});
|
|
|
|
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: "keeps url buttons",
|
|
input: [[{ text: "Open", url: "https://example.com" }]],
|
|
expected: {
|
|
inline_keyboard: [[{ text: "Open", url: "https://example.com" }]],
|
|
},
|
|
},
|
|
{
|
|
name: "keeps web app buttons",
|
|
input: [[{ text: "Launch", web_app: { url: "https://example.com/app" } }]],
|
|
expected: {
|
|
inline_keyboard: [[{ text: "Launch", web_app: { url: "https://example.com/app" } }]],
|
|
},
|
|
},
|
|
{
|
|
name: "prefers url over callback data when both are present",
|
|
input: [[{ text: "Open", callback_data: "cmd:open", url: "https://example.com" }]],
|
|
expected: {
|
|
inline_keyboard: [[{ text: "Open", url: "https://example.com" }]],
|
|
},
|
|
},
|
|
{
|
|
name: "filters invalid buttons and empty rows",
|
|
input: [
|
|
[
|
|
{ text: "", callback_data: "cmd:skip" },
|
|
{ text: "Ok", callback_data: "cmd:ok" },
|
|
],
|
|
[{ text: "Missing data", callback_data: "" }],
|
|
[{ text: "Missing action" }],
|
|
[],
|
|
],
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
accountId: "default",
|
|
});
|
|
await unpinMessageTelegram("-1001234567890", 101, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
accountId: "default",
|
|
});
|
|
|
|
expect(botApi.pinChatMessage).toHaveBeenCalledWith("-1001234567890", 101, {
|
|
disable_notification: true,
|
|
});
|
|
expect(botApi.unpinChatMessage).toHaveBeenCalledWith("-1001234567890", 101);
|
|
});
|
|
|
|
it("honors Telegram pin notification requests", async () => {
|
|
loadConfig.mockReturnValue({
|
|
channels: {
|
|
telegram: {
|
|
botToken: "tok",
|
|
},
|
|
},
|
|
});
|
|
botApi.pinChatMessage.mockResolvedValue(true);
|
|
|
|
await pinMessageTelegram("-1001234567890", 101, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
accountId: "default",
|
|
notify: true,
|
|
});
|
|
|
|
expect(botApi.pinChatMessage).toHaveBeenCalledWith("-1001234567890", 101, {
|
|
disable_notification: false,
|
|
});
|
|
});
|
|
|
|
it("renames a Telegram forum topic", async () => {
|
|
loadConfig.mockReturnValue({
|
|
channels: {
|
|
telegram: {
|
|
botToken: "tok",
|
|
},
|
|
},
|
|
});
|
|
botApi.editForumTopic.mockResolvedValue(true);
|
|
|
|
await renameForumTopicTelegram("-1001234567890", 271, "Codex Thread", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
accountId: "default",
|
|
}),
|
|
).rejects.toThrow("Telegram forum topic update requires a name or iconCustomEmojiId");
|
|
await expect(
|
|
editForumTopicTelegram("-1001234567890", 271, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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: { cfg: TELEGRAM_TEST_CFG, token: "tok" },
|
|
expectedTimeout: 60,
|
|
},
|
|
{
|
|
name: "per-account timeout override",
|
|
cfg: {
|
|
channels: {
|
|
telegram: {
|
|
timeoutSeconds: 60,
|
|
accounts: { foo: { timeoutSeconds: 61 } },
|
|
},
|
|
},
|
|
},
|
|
opts: { cfg: TELEGRAM_TEST_CFG, 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, cfg: testCase.cfg });
|
|
const [token, options] = firstMockCall(botCtorSpy, "bot constructor call");
|
|
expect(token, testCase.name).toBe("tok");
|
|
const client = requireRecord(requireRecord(options, "bot options").client, "bot client");
|
|
expect(client.timeoutSeconds, testCase.name).toBe(testCase.expectedTimeout);
|
|
}
|
|
});
|
|
|
|
it("normalizes full Telegram bot endpoint apiRoot before send clients reach grammY", async () => {
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
accounts: {
|
|
foo: {
|
|
apiRoot: "https://api.telegram.org/bot123456:ABC/",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
loadConfig.mockReturnValue(cfg);
|
|
botApi.sendMessage.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
|
|
|
await sendMessageTelegram("123", "hi", { cfg, token: "tok", accountId: "foo" });
|
|
|
|
const [token, options] = firstMockCall(botCtorSpy, "bot constructor call");
|
|
expect(token).toBe("tok");
|
|
const client = requireRecord(requireRecord(options, "bot options").client, "bot client");
|
|
expect(client.apiRoot).toBe("https://api.telegram.org");
|
|
});
|
|
|
|
it("installs the shared grammY throttler on send clients", async () => {
|
|
botApi.sendMessage.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
|
|
|
|
await sendMessageTelegram("123", "hi", { cfg: TELEGRAM_TEST_CFG, token: "tok" });
|
|
|
|
const [middleware] = firstMockCall(botConfigUseSpy, "bot config use call");
|
|
expect(middleware).toBeTypeOf("function");
|
|
});
|
|
|
|
it("records sent text messages into the Telegram prompt context cache", async () => {
|
|
const storePath = `/tmp/openclaw-telegram-send-context-${process.pid}-${Date.now()}.json`;
|
|
const persistedPath = resolveTelegramMessageCachePath(storePath);
|
|
const cfg = { session: { store: storePath } };
|
|
try {
|
|
botApi.sendMessage.mockResolvedValueOnce({
|
|
message_id: 1497,
|
|
date: 1_779_394_740,
|
|
chat: {
|
|
id: "-1003966283270",
|
|
type: "supergroup",
|
|
title: "Keshav and Kelaw - Keshav's Bot",
|
|
},
|
|
from: { id: 42, is_bot: true, first_name: "Kelaw", username: "keshavbotagent" },
|
|
text: "Done already: timeoutSeconds is now 7200s.",
|
|
message_thread_id: 1154,
|
|
});
|
|
|
|
await sendMessageTelegram("-1003966283270", "Done already: timeoutSeconds is now 7200s.", {
|
|
cfg,
|
|
token: "tok",
|
|
messageThreadId: 1154,
|
|
});
|
|
|
|
const cache = createTelegramMessageCache({ persistedPath });
|
|
await cache.record({
|
|
accountId: "default",
|
|
chatId: "-1003966283270",
|
|
threadId: 1154,
|
|
msg: {
|
|
chat: {
|
|
id: -1003966283270,
|
|
type: "supergroup",
|
|
title: "Keshav and Kelaw - Keshav's Bot",
|
|
},
|
|
message_thread_id: 1154,
|
|
message_id: 1521,
|
|
date: 1_779_425_460,
|
|
text: "Did all Amazon crons run fine",
|
|
from: { id: 5185575566, is_bot: false, first_name: "Keshav" },
|
|
},
|
|
});
|
|
|
|
const context = await buildTelegramConversationContext({
|
|
cache,
|
|
accountId: "default",
|
|
chatId: "-1003966283270",
|
|
threadId: 1154,
|
|
messageId: "1521",
|
|
replyChainNodes: [],
|
|
recentLimit: 10,
|
|
replyTargetWindowSize: 2,
|
|
});
|
|
|
|
expect(context.map((entry) => entry.node.messageId)).toContain("1497");
|
|
expect(context.map((entry) => entry.node.body)).toContain(
|
|
"Done already: timeoutSeconds is now 7200s.",
|
|
);
|
|
} finally {
|
|
fs.rmSync(persistedPath, { force: true });
|
|
fs.rmSync(`${storePath}.telegram-sent-messages.json`, { force: true });
|
|
}
|
|
});
|
|
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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("derives plain-text fallback from html-mode anchors when Telegram rejects HTML", async () => {
|
|
const chatId = "123";
|
|
const htmlText =
|
|
'Created: <a href="https://example.com/a?x=1&y=2">Task & One</a> <code>file.md</code>';
|
|
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: 43, chat: { id: chatId } });
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
|
|
const res = await sendMessageTelegram(chatId, htmlText, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
textMode: "html",
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, htmlText, { parse_mode: "HTML" });
|
|
expect(sendMessage).toHaveBeenNthCalledWith(
|
|
2,
|
|
chatId,
|
|
"Created: Task & One (https://example.com/a?x=1&y=2) file.md",
|
|
);
|
|
expect(res.chatId).toBe(chatId);
|
|
expect(res.messageId).toBe("43");
|
|
});
|
|
|
|
it("normalizes raw code language HTML before sending", async () => {
|
|
const chatId = "123";
|
|
const text = [
|
|
"Yep. Send these in order:",
|
|
"",
|
|
'<code class="language-text">/queue followup debounce:0',
|
|
"</code>",
|
|
].join("\n");
|
|
const sendMessage = vi.fn().mockResolvedValue({ message_id: 44, chat: { id: chatId } });
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
|
|
const res = await sendMessageTelegram(chatId, text, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith(
|
|
chatId,
|
|
["Yep. Send these in order:", "", "<code>/queue followup debounce:0", "</code>"].join("\n"),
|
|
{ parse_mode: "HTML" },
|
|
);
|
|
expect(res.chatId).toBe(chatId);
|
|
expect(res.messageId).toBe("44");
|
|
});
|
|
|
|
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) {
|
|
const cfg = {
|
|
channels: { telegram: { linkPreview: false } },
|
|
};
|
|
loadConfig.mockReturnValue(cfg);
|
|
const api = { sendMessage: testCase.sendMessage } as unknown as {
|
|
sendMessage: typeof testCase.sendMessage;
|
|
};
|
|
await sendMessageTelegram("123", testCase.text, {
|
|
cfg,
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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", { cfg: TELEGRAM_TEST_CFG, token: "tok" });
|
|
const clientFetch = (
|
|
firstMockCall(botCtorSpy, "bot constructor call")[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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
gatewayClientScopes: ["operator.write"],
|
|
});
|
|
|
|
expect(getChat).toHaveBeenCalledWith("@mychannel");
|
|
expect(sendMessage).toHaveBeenCalledWith("-100123", "hi", {
|
|
parse_mode: "HTML",
|
|
});
|
|
expectPersistedTarget({
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/photo.jpg",
|
|
messageThreadId: 99,
|
|
});
|
|
|
|
expectMediaSendCall(firstMockCall(sendPhoto, "send photo call"), "send photo call", chatId, {
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/photo.jpg",
|
|
});
|
|
|
|
expectMediaSendCall(firstMockCall(sendPhoto, "send photo call"), "send photo call", chatId, {
|
|
caption: undefined,
|
|
});
|
|
expect(sendMessage).toHaveBeenCalledWith(chatId, longText, {
|
|
parse_mode: "HTML",
|
|
});
|
|
expect(res.messageId).toBe("71");
|
|
});
|
|
|
|
it("chunks long default markdown media follow-up text", async () => {
|
|
const chatId = "123";
|
|
const longText = `**${"A".repeat(5000)}**`;
|
|
|
|
const sendPhoto = vi.fn().mockResolvedValue({
|
|
message_id: 72,
|
|
chat: { id: chatId },
|
|
});
|
|
const sendMessage = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ message_id: 73, chat: { id: chatId } })
|
|
.mockResolvedValueOnce({ message_id: 74, 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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/photo.jpg",
|
|
});
|
|
|
|
expectMediaSendCall(firstMockCall(sendPhoto, "send photo call"), "send photo call", chatId, {
|
|
caption: undefined,
|
|
});
|
|
expect(sendMessage).toHaveBeenCalledTimes(2);
|
|
expect(sendMessage.mock.calls.every((call) => call[2]?.parse_mode === "HTML")).toBe(true);
|
|
expect(sendMessage.mock.calls.every((call) => String(call[1] ?? "").length <= 4000)).toBe(true);
|
|
expect(sendMessage.mock.calls.map((call) => String(call[1] ?? "")).join("")).toContain("<b>");
|
|
expect(res.messageId).toBe("74");
|
|
});
|
|
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/photo.jpg",
|
|
});
|
|
|
|
expectMediaSendCall(firstMockCall(sendPhoto, "send photo call"), "send photo call", chatId, {
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/photo.jpg",
|
|
});
|
|
|
|
expectMediaSendCall(firstMockCall(sendPhoto, "send photo call"), "send photo call", chatId, {
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/video.mp4",
|
|
asVideoNote: true,
|
|
});
|
|
|
|
expectMediaSendCall(
|
|
firstMockCall(sendVideoNote, "send video note call"),
|
|
"send video note call",
|
|
chatId,
|
|
{},
|
|
);
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/video.mp4",
|
|
asVideoNote: false,
|
|
});
|
|
|
|
const [actualChatId, media, videoParams] = firstMockCall(sendVideo, "send video call");
|
|
expect(actualChatId).toBe(chatId);
|
|
if (media === undefined) {
|
|
throw new Error("expected send video media");
|
|
}
|
|
const params = requireRecord(videoParams, "send video params");
|
|
expect(typeof params.caption).toBe("string");
|
|
expect(params.parse_mode).toBe("HTML");
|
|
expect(Object.keys(params).toSorted()).toEqual(["caption", "parse_mode"]);
|
|
expect(res.messageId).toBe("201");
|
|
}
|
|
});
|
|
|
|
it("passes probed dimensions to regular video sends", async () => {
|
|
const chatId = "123";
|
|
const videoBuffer = Buffer.from("fake-video");
|
|
const sendVideo = vi.fn().mockResolvedValue({
|
|
message_id: 201,
|
|
chat: { id: chatId },
|
|
});
|
|
const api = { sendVideo } as unknown as {
|
|
sendVideo: typeof sendVideo;
|
|
};
|
|
probeVideoDimensions.mockResolvedValueOnce({ width: 720, height: 1280 });
|
|
|
|
mockLoadedMedia({
|
|
buffer: videoBuffer,
|
|
contentType: "video/mp4",
|
|
fileName: "video.mp4",
|
|
});
|
|
|
|
await sendMessageTelegram(chatId, "my caption", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/video.mp4",
|
|
});
|
|
|
|
expect(probeVideoDimensions).toHaveBeenCalledWith(videoBuffer);
|
|
expectMediaSendCall(firstMockCall(sendVideo, "send video call"), "send video call", chatId, {
|
|
caption: "my caption",
|
|
parse_mode: "HTML",
|
|
width: 720,
|
|
height: 1280,
|
|
});
|
|
});
|
|
|
|
it("does not probe video dimensions for video notes", async () => {
|
|
const chatId = "123";
|
|
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",
|
|
});
|
|
|
|
await sendMessageTelegram(chatId, "ignored caption context", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/video.mp4",
|
|
asVideoNote: true,
|
|
});
|
|
|
|
expect(probeVideoDimensions).not.toHaveBeenCalled();
|
|
expectMediaSendCall(
|
|
firstMockCall(sendVideoNote, "send video note call"),
|
|
"send video note call",
|
|
chatId,
|
|
{},
|
|
);
|
|
});
|
|
|
|
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]> = {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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);
|
|
|
|
expectMediaSendCall(
|
|
firstMockCall(sendVideoNote, "send video note call"),
|
|
"send video note call",
|
|
chatId,
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
|
|
});
|
|
|
|
await vi.runAllTimersAsync();
|
|
await expect(promise).resolves.toEqual({ messageId: "1", chatId });
|
|
expect(firstMockCall(setTimeoutSpy, "setTimeout call")[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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/fun",
|
|
});
|
|
|
|
expect(sendAnimation).toHaveBeenCalledTimes(1);
|
|
expectMediaSendCall(
|
|
firstMockCall(sendAnimation, "send animation call"),
|
|
"send animation call",
|
|
chatId,
|
|
{
|
|
caption: "caption",
|
|
parse_mode: "HTML",
|
|
},
|
|
);
|
|
expect(probeVideoDimensions).not.toHaveBeenCalled();
|
|
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",
|
|
},
|
|
{
|
|
name: "videos",
|
|
buffer: Buffer.from("fake-video"),
|
|
contentType: "video/mp4",
|
|
fileName: "clip.mp4",
|
|
mediaUrl: "https://example.com/clip.mp4",
|
|
},
|
|
])("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 sendVideo = vi.fn();
|
|
const api = { sendAnimation, sendDocument, sendPhoto, sendVideo } as unknown as {
|
|
sendAnimation: typeof sendAnimation;
|
|
sendDocument: typeof sendDocument;
|
|
sendPhoto: typeof sendPhoto;
|
|
sendVideo: typeof sendVideo;
|
|
};
|
|
|
|
mockLoadedMedia({
|
|
buffer: testCase.buffer,
|
|
contentType: testCase.contentType,
|
|
fileName: testCase.fileName,
|
|
});
|
|
|
|
const res = await sendMessageTelegram(chatId, "caption", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: testCase.mediaUrl,
|
|
forceDocument: true,
|
|
});
|
|
|
|
expectMediaSendCall(
|
|
firstMockCall(sendDocument, "send document call"),
|
|
`send document call ${testCase.name}`,
|
|
chatId,
|
|
{
|
|
caption: "caption",
|
|
parse_mode: "HTML",
|
|
disable_content_type_detection: true,
|
|
},
|
|
);
|
|
expect(sendPhoto, testCase.name).not.toHaveBeenCalled();
|
|
expect(sendAnimation, testCase.name).not.toHaveBeenCalled();
|
|
expect(sendVideo, testCase.name).not.toHaveBeenCalled();
|
|
expect(probeVideoDimensions, 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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/photo.png",
|
|
});
|
|
|
|
expectMediaSendCall(
|
|
firstMockCall(sendDocument, "send document call"),
|
|
"send document call",
|
|
chatId,
|
|
{
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/photo.png",
|
|
});
|
|
|
|
expectMediaSendCall(
|
|
firstMockCall(sendDocument, "send document call"),
|
|
"send document call",
|
|
chatId,
|
|
{
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/report.pdf",
|
|
});
|
|
|
|
expectMediaSendCall(
|
|
firstMockCall(sendDocument, "send document call"),
|
|
"send document call",
|
|
chatId,
|
|
{
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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;
|
|
expectMediaSendCall(
|
|
firstMockCall(called, "called mock call"),
|
|
`${testCase.expectedMethod} call ${testCase.name}`,
|
|
testCase.chatId,
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
messageThreadId: 271,
|
|
});
|
|
|
|
expect(sendMessage, testCase.name).toHaveBeenCalledWith(testCase.chatId, testCase.text, {
|
|
parse_mode: "HTML",
|
|
message_thread_id: 271,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("fails topic sends instead of retrying without message_thread_id", async () => {
|
|
const cases = [{ name: "forum", chatId: "-100123", text: "hello forum" }] as const;
|
|
const threadErr = new Error("400: Bad Request: message thread not found");
|
|
|
|
for (const testCase of cases) {
|
|
const sendMessage = vi.fn().mockRejectedValueOnce(threadErr);
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
|
|
await expect(
|
|
sendMessageTelegram(testCase.chatId, testCase.text, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
messageThreadId: 271,
|
|
}),
|
|
).rejects.toThrow("message thread not found");
|
|
|
|
expect(sendMessage, testCase.name).toHaveBeenCalledTimes(1);
|
|
expect(sendMessage, testCase.name).toHaveBeenCalledWith(testCase.chatId, testCase.text, {
|
|
parse_mode: "HTML",
|
|
message_thread_id: 271,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("does not retry private DM topic sends without the topic id", async () => {
|
|
const threadErr = new Error("400: Bad Request: message thread not found");
|
|
const sendMessage = vi.fn().mockRejectedValueOnce(threadErr);
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
|
|
await expect(
|
|
sendMessageTelegram("123456789", "hello private", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
messageThreadId: 271,
|
|
}),
|
|
).rejects.toThrow("message thread not found");
|
|
|
|
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
expect(sendMessage).toHaveBeenCalledWith("123456789", "hello private", {
|
|
parse_mode: "HTML",
|
|
message_thread_id: 271,
|
|
});
|
|
});
|
|
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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_", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello forum", {
|
|
parse_mode: "HTML",
|
|
message_thread_id: 271,
|
|
});
|
|
});
|
|
|
|
it("logs successful outbound text delivery without the message body", async () => {
|
|
const logFile = captureInfoLogs();
|
|
const chatId = "-1001234567890";
|
|
const body = "incident reply body should stay private";
|
|
const sendMessage = vi.fn().mockResolvedValue({
|
|
message_id: 321,
|
|
chat: { id: chatId },
|
|
});
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
|
|
await sendMessageTelegram(`telegram:group:${chatId}:topic:271`, body, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
accountId: "ops",
|
|
api,
|
|
replyToMessageId: 123,
|
|
silent: true,
|
|
});
|
|
|
|
const logs = capturedLogText(logFile);
|
|
expect(logs).toContain("outbound send ok");
|
|
expect(logs).toContain("accountId=ops");
|
|
expect(logs).toContain(`chatId=${chatId}`);
|
|
expect(logs).toContain("messageId=321");
|
|
expect(logs).toContain("operation=sendMessage");
|
|
expect(logs).toContain("threadId=271");
|
|
expect(logs).toContain("replyToMessageId=123");
|
|
expect(logs).toContain("silent=true");
|
|
expect(logs).toContain("chunkCount=1");
|
|
expect(logs).not.toContain(body);
|
|
});
|
|
|
|
it("does not log outbound success when topic text send fails thread lookup", async () => {
|
|
const logFile = captureInfoLogs();
|
|
const chatId = "-1001234567890";
|
|
const body = "topic reply body should stay private";
|
|
const threadErr = new Error("400: Bad Request: message thread not found");
|
|
const sendMessage = vi.fn().mockRejectedValueOnce(threadErr);
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
|
|
await expect(
|
|
sendMessageTelegram(`telegram:group:${chatId}:topic:271`, body, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
accountId: "ops",
|
|
api,
|
|
}),
|
|
).rejects.toThrow("message thread not found");
|
|
|
|
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
expect(sendMessage).toHaveBeenCalledWith(chatId, body, {
|
|
parse_mode: "HTML",
|
|
message_thread_id: 271,
|
|
});
|
|
const logs = capturedLogText(logFile);
|
|
expect(logs).not.toContain("outbound send ok");
|
|
expect(logs).not.toContain(body);
|
|
});
|
|
|
|
it("logs successful outbound media delivery without caption or media location", async () => {
|
|
const logFile = captureInfoLogs();
|
|
const chatId = "123";
|
|
const caption = "private media caption";
|
|
const mediaUrl = "https://example.com/private-photo.jpg";
|
|
const fileName = "private-photo.jpg";
|
|
const sendPhoto = vi.fn().mockResolvedValue({
|
|
message_id: 654,
|
|
chat: { id: chatId },
|
|
});
|
|
const api = { sendPhoto } as unknown as {
|
|
sendPhoto: typeof sendPhoto;
|
|
};
|
|
|
|
mockLoadedMedia({
|
|
buffer: Buffer.from("fake-image"),
|
|
contentType: "image/jpeg",
|
|
fileName,
|
|
});
|
|
|
|
await sendMessageTelegram(chatId, caption, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
accountId: "ops",
|
|
api,
|
|
mediaUrl,
|
|
messageThreadId: 45,
|
|
});
|
|
|
|
const logs = capturedLogText(logFile);
|
|
expect(logs).toContain("outbound send ok");
|
|
expect(logs).toContain("accountId=ops");
|
|
expect(logs).toContain(`chatId=${chatId}`);
|
|
expect(logs).toContain("messageId=654");
|
|
expect(logs).toContain("operation=sendPhoto");
|
|
expect(logs).toContain("deliveryKind=photo");
|
|
expect(logs).toContain("threadId=45");
|
|
expect(logs).not.toContain(caption);
|
|
expect(logs).not.toContain(mediaUrl);
|
|
expect(logs).not.toContain(fileName);
|
|
});
|
|
|
|
it("does not log outbound success when a chunked text delivery fails mid-send", async () => {
|
|
const logFile = captureInfoLogs();
|
|
const chatId = "123";
|
|
const body = "private chunked body ".repeat(260);
|
|
const sendMessage = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({
|
|
message_id: 700,
|
|
chat: { id: chatId },
|
|
})
|
|
.mockRejectedValueOnce(new Error("telegram send failed"));
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
|
|
await expect(
|
|
sendMessageTelegram(chatId, body, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
accountId: "ops",
|
|
api,
|
|
}),
|
|
).rejects.toThrow(/telegram send failed/);
|
|
|
|
expect(sendMessage).toHaveBeenCalledTimes(2);
|
|
const logs = capturedLogText(logFile);
|
|
expect(logs).not.toContain("outbound send ok");
|
|
expect(logs).not.toContain(body);
|
|
});
|
|
|
|
it("fails media sends instead of retrying without message_thread_id", async () => {
|
|
const logFile = captureInfoLogs();
|
|
const chatId = "-100123";
|
|
const threadErr = new Error("400: Bad Request: message thread not found");
|
|
const sendPhoto = vi.fn().mockRejectedValueOnce(threadErr);
|
|
const api = { sendPhoto } as unknown as {
|
|
sendPhoto: typeof sendPhoto;
|
|
};
|
|
|
|
mockLoadedMedia({
|
|
buffer: Buffer.from("fake-image"),
|
|
contentType: "image/jpeg",
|
|
fileName: "photo.jpg",
|
|
});
|
|
|
|
await expect(
|
|
sendMessageTelegram(chatId, "photo", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/photo.jpg",
|
|
messageThreadId: 271,
|
|
}),
|
|
).rejects.toThrow("message thread not found");
|
|
|
|
expect(sendPhoto).toHaveBeenCalledTimes(1);
|
|
expectMediaSendCall(
|
|
firstMockCall(sendPhoto, "first send photo call"),
|
|
"first send photo call",
|
|
chatId,
|
|
{
|
|
caption: "photo",
|
|
parse_mode: "HTML",
|
|
message_thread_id: 271,
|
|
},
|
|
);
|
|
const logs = capturedLogText(logFile);
|
|
expect(logs).not.toContain("outbound send ok");
|
|
});
|
|
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/photo.jpg",
|
|
});
|
|
|
|
const [mediaUrl, options] = requireMockCall(
|
|
firstMockCall(loadWebMedia, "loadWebMedia call"),
|
|
"load web media call",
|
|
);
|
|
expect(mediaUrl).toBe("https://example.com/photo.jpg");
|
|
expect(requireRecord(options, "load web media options").maxBytes).toBe(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;
|
|
};
|
|
const cfg = {
|
|
channels: {
|
|
telegram: {
|
|
mediaMaxMb: 42,
|
|
},
|
|
},
|
|
};
|
|
loadConfig.mockReturnValue(cfg);
|
|
|
|
mockLoadedMedia({
|
|
buffer: Buffer.from("fake-image"),
|
|
contentType: "image/jpeg",
|
|
fileName: "photo.jpg",
|
|
});
|
|
|
|
await sendMessageTelegram(chatId, "photo", {
|
|
cfg,
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/photo.jpg",
|
|
});
|
|
|
|
const [mediaUrl, options] = requireMockCall(
|
|
firstMockCall(loadWebMedia, "loadWebMedia call"),
|
|
"load web media call",
|
|
);
|
|
expect(mediaUrl).toBe("https://example.com/photo.jpg");
|
|
expect(requireRecord(options, "load web media options").maxBytes).toBe(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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
textMode: "html",
|
|
buttons: [[{ text: "OK", callback_data: "ok" }]],
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledTimes(2);
|
|
const firstCall = firstMockCall(sendMessage, "first sendMessage call");
|
|
const secondCall = mockCall(sendMessage, 1, "second sendMessage call");
|
|
const firstParams = requireRecord(firstCall[2], "first sendMessage params");
|
|
const secondParams = requireRecord(secondCall[2], "second sendMessage params");
|
|
expect((firstCall[1] as string).length).toBeLessThanOrEqual(4000);
|
|
expect((secondCall[1] as string).length).toBeLessThanOrEqual(4000);
|
|
expect(firstParams.reply_markup).toBeUndefined();
|
|
expect(secondParams.reply_markup).toEqual({
|
|
inline_keyboard: [[{ text: "OK", callback_data: "ok" }]],
|
|
});
|
|
expect(res.messageId).toBe("91");
|
|
});
|
|
|
|
it("chunks long default markdown text and keeps buttons on the last chunk only", async () => {
|
|
const chatId = "123";
|
|
const markdownText = `**${"A".repeat(5000)}**`;
|
|
|
|
const sendMessage = vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ message_id: 90, chat: { id: chatId } })
|
|
.mockResolvedValueOnce({ message_id: 91, chat: { id: chatId } });
|
|
const api = { sendMessage } as unknown as { sendMessage: typeof sendMessage };
|
|
|
|
const res = await sendMessageTelegram(chatId, markdownText, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
buttons: [[{ text: "OK", callback_data: "ok" }]],
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledTimes(2);
|
|
const firstCall = firstMockCall(sendMessage, "first sendMessage call");
|
|
const secondCall = mockCall(sendMessage, 1, "second sendMessage call");
|
|
const firstParams = requireRecord(firstCall[2], "first sendMessage params");
|
|
const secondParams = requireRecord(secondCall[2], "second sendMessage params");
|
|
const firstText = requireString(firstCall[1], "first sendMessage text");
|
|
const secondText = requireString(secondCall[1], "second sendMessage text");
|
|
expect(firstText.length).toBeLessThanOrEqual(4000);
|
|
expect(secondText.length).toBeLessThanOrEqual(4000);
|
|
expect(firstParams.parse_mode).toBe("HTML");
|
|
expect(secondParams.parse_mode).toBe("HTML");
|
|
expect(firstText).toMatch(/^<b>[\s\S]*<\/b>$/);
|
|
expect(secondText).toMatch(/^<b>[\s\S]*<\/b>$/);
|
|
expect(firstParams.reply_markup).toBeUndefined();
|
|
expect(secondParams.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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
textMode: "html",
|
|
plainText,
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledTimes(4);
|
|
const plainFallbackCalls = [
|
|
mockCall(sendMessage, 1, "first plain fallback sendMessage call"),
|
|
mockCall(sendMessage, 3, "second plain fallback sendMessage call"),
|
|
];
|
|
const plainFallbackText = plainFallbackCalls
|
|
.map((call) => mockCallText(call, "plain fallback text"))
|
|
.join("");
|
|
expect(plainFallbackText).toBe(plainText);
|
|
expect(plainFallbackText).not.toContain("<");
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
textMode: "html",
|
|
plainText,
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledTimes(4);
|
|
expect(
|
|
requireString(firstMockCall(sendMessage, "sendMessage call")[1], "sendMessage text"),
|
|
).toMatch(/^&/);
|
|
const plainFallbackCalls = [
|
|
mockCall(sendMessage, 1, "first plain fallback sendMessage call"),
|
|
mockCall(sendMessage, 3, "second plain fallback sendMessage call"),
|
|
];
|
|
const plainFallbackTexts = plainFallbackCalls.map((call) =>
|
|
mockCallText(call, "plain fallback text"),
|
|
);
|
|
expect(plainFallbackTexts.join("")).toBe(plainText);
|
|
expect(plainFallbackTexts.filter((text) => text === "")).toEqual([]);
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
textMode: "html",
|
|
plainText,
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledTimes(3);
|
|
expect(sendMessage.mock.calls.map((call) => call[2]?.parse_mode)).toEqual([
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
]);
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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, "✅", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
});
|
|
|
|
expect(getChat).toHaveBeenCalledWith("@mychannel");
|
|
expect(setMessageReaction).toHaveBeenCalledWith("-100123", 456, [
|
|
{ type: "emoji", emoji: "✅" },
|
|
]);
|
|
expectPersistedTarget({
|
|
rawTarget: "@mychannel",
|
|
resolvedChatId: "-100123",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("deleteMessageTelegram", () => {
|
|
it.each([
|
|
"400: Bad Request: message to delete not found",
|
|
"400: Bad Request: message can't be deleted",
|
|
"MESSAGE_ID_INVALID",
|
|
"MESSAGE_DELETE_FORBIDDEN",
|
|
] as const)("returns a warning for benign delete no-op error: %s", async (message) => {
|
|
const deleteMessage = vi.fn().mockRejectedValue(new Error(message));
|
|
const api = { deleteMessage } as unknown as { deleteMessage: typeof deleteMessage };
|
|
|
|
const result = await deleteMessageTelegram("123", 456, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
});
|
|
|
|
expect(deleteMessage).toHaveBeenCalledWith("123", 456);
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) {
|
|
throw new Error("expected delete warning result");
|
|
}
|
|
expect(result.warning).toContain(message);
|
|
});
|
|
|
|
it("throws non-benign delete errors", async () => {
|
|
const deleteMessage = vi.fn().mockRejectedValue(new Error("500: Internal Server Error"));
|
|
const api = { deleteMessage } as unknown as { deleteMessage: typeof deleteMessage };
|
|
|
|
await expect(
|
|
deleteMessageTelegram("123", 456, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
}),
|
|
).rejects.toThrow(/Internal Server Error/);
|
|
});
|
|
|
|
it("rejects partial message id strings", async () => {
|
|
const deleteMessage = vi.fn();
|
|
const api = { deleteMessage } as unknown as { deleteMessage: typeof deleteMessage };
|
|
|
|
await expect(
|
|
deleteMessageTelegram("123", "456abc", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
}),
|
|
).rejects.toThrow(/Message id is required/);
|
|
expect(deleteMessage).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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, { cfg: TELEGRAM_TEST_CFG, token: "tok" }),
|
|
).rejects.toThrow(/file_id is required/i);
|
|
}
|
|
});
|
|
|
|
it("fails sticker sends instead of retrying without message_thread_id", async () => {
|
|
const chatId = "-100123";
|
|
const threadErr = new Error("400: Bad Request: message thread not found");
|
|
const sendSticker = vi.fn().mockRejectedValueOnce(threadErr);
|
|
const api = { sendSticker } as unknown as {
|
|
sendSticker: typeof sendSticker;
|
|
};
|
|
|
|
await expect(
|
|
sendStickerTelegram(chatId, "fileId123", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
messageThreadId: 271,
|
|
}),
|
|
).rejects.toThrow("message thread not found");
|
|
|
|
expect(sendSticker).toHaveBeenCalledTimes(1);
|
|
expect(sendSticker).toHaveBeenCalledWith(chatId, "fileId123", {
|
|
message_thread_id: 271,
|
|
});
|
|
});
|
|
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
|
|
});
|
|
|
|
await vi.runAllTimersAsync();
|
|
await expect(promise).resolves.toEqual({ messageId: "109", chatId });
|
|
expect(firstMockCall(setTimeoutSpy, "setTimeout call")[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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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("uses native reply parameters for direct quote sends without trimming the quote", 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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
replyToMessageId: 100,
|
|
quoteText: " quoted text\n",
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith(chatId, "reply text", {
|
|
parse_mode: "HTML",
|
|
reply_parameters: {
|
|
message_id: 100,
|
|
quote: " quoted text\n",
|
|
allow_sending_without_reply: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
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", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
replyToMessageId: invalidReplyToMessageId as unknown as number,
|
|
});
|
|
await sendStickerTelegram(chatId, "CAACAgIAAxkBAAI...sticker_file_id", {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
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", { cfg: TELEGRAM_TEST_CFG, 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", { cfg: TELEGRAM_TEST_CFG, 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", { cfg: TELEGRAM_TEST_CFG, 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", { cfg: TELEGRAM_TEST_CFG, 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(firstMockCall(botCtorSpy, "bot constructor call")[0], testCase.name).toBe("tok");
|
|
expect(botApi.editMessageText, testCase.name).toHaveBeenCalledTimes(testCase.expectedCalls);
|
|
|
|
const firstParams = requireRecord(
|
|
firstMockCall(botApi.editMessageText, "editMessageText call")[3],
|
|
"first edit params",
|
|
);
|
|
expect(firstParams.parse_mode, testCase.name).toBe("HTML");
|
|
if ("firstExpectNoReplyMarkup" in testCase && testCase.firstExpectNoReplyMarkup) {
|
|
expect(firstParams, testCase.name).not.toHaveProperty("reply_markup");
|
|
}
|
|
if ("firstExpectReplyMarkup" in testCase && testCase.firstExpectReplyMarkup) {
|
|
expect(firstParams.reply_markup, testCase.name).toEqual(testCase.firstExpectReplyMarkup);
|
|
}
|
|
|
|
if ("secondExpectReplyMarkup" in testCase && testCase.secondExpectReplyMarkup) {
|
|
const secondParams = requireRecord(
|
|
mockCall(botApi.editMessageText, 1, "second editMessageText call")[3],
|
|
"second edit params",
|
|
);
|
|
expect(secondParams.reply_markup, testCase.name).toEqual(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("derives readable plain text when Telegram rejects edited HTML", async () => {
|
|
botApi.editMessageText
|
|
.mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities"))
|
|
.mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } });
|
|
|
|
await editMessageTelegram(
|
|
"123",
|
|
1,
|
|
'Created: <a href="https://example.com/a?x=1&y=2">Task <id></a>',
|
|
{
|
|
token: "tok",
|
|
cfg: {},
|
|
textMode: "html",
|
|
},
|
|
);
|
|
|
|
expect(botApi.editMessageText).toHaveBeenCalledTimes(2);
|
|
expect(mockCall(botApi.editMessageText, 1, "plain edit fallback")[2]).toBe(
|
|
"Created: Task <id> (https://example.com/a?x=1&y=2)",
|
|
);
|
|
});
|
|
|
|
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 = requireRecord(
|
|
firstMockCall(botApi.editMessageText, "editMessageText call")[3],
|
|
"edit params",
|
|
);
|
|
expect(params.parse_mode).toBe("HTML");
|
|
expect(params.link_preview_options).toEqual({ 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 "] },
|
|
{
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "t",
|
|
api: api as unknown as Bot["api"],
|
|
gatewayClientScopes: ["operator.admin"],
|
|
},
|
|
);
|
|
|
|
expect(api.getChat).toHaveBeenCalledWith("@mychannel");
|
|
expectPersistedTarget({
|
|
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 },
|
|
{ cfg: TELEGRAM_TEST_CFG, 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>;
|
|
const sendPollCall = firstMockCall(sendPollMock, "send poll call");
|
|
expect(sendPollCall[0]).toBe("123");
|
|
expect(sendPollCall[1]).toBe("Q");
|
|
expect(sendPollCall[2]).toEqual(["A", "B"]);
|
|
expect(requireRecord(sendPollCall[3], "send poll params").open_period).toBe(60);
|
|
});
|
|
|
|
it("fails poll sends instead of retrying without message_thread_id", async () => {
|
|
const api = {
|
|
sendPoll: vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("400: Bad Request: message thread not found")),
|
|
};
|
|
|
|
await expect(
|
|
sendPollTelegram(
|
|
"-100123",
|
|
{ question: "Q", options: ["A", "B"] },
|
|
{
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "t",
|
|
api: api as unknown as Bot["api"],
|
|
messageThreadId: 99,
|
|
},
|
|
),
|
|
).rejects.toThrow("message thread not found");
|
|
|
|
expect(api.sendPoll).toHaveBeenCalledTimes(1);
|
|
expect(
|
|
requireRecord(firstMockCall(api.sendPoll, "send poll call")[3], "send poll params")
|
|
.message_thread_id,
|
|
).toBe(99);
|
|
});
|
|
|
|
it("rejects durationHours for Telegram polls", async () => {
|
|
const api = { sendPoll: vi.fn() };
|
|
|
|
await expect(
|
|
sendPollTelegram(
|
|
"123",
|
|
{ question: "Q", options: ["A", "B"], durationHours: 1 },
|
|
{ cfg: TELEGRAM_TEST_CFG, 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"] },
|
|
{ cfg: TELEGRAM_TEST_CFG, 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, {
|
|
cfg: TELEGRAM_TEST_CFG,
|
|
token: "tok",
|
|
api,
|
|
...("options" in testCase ? testCase.options : {}),
|
|
});
|
|
|
|
expect(createForumTopic).toHaveBeenCalledWith(...testCase.expectedCall);
|
|
expect(result).toEqual(testCase.expectedResult);
|
|
});
|
|
}
|
|
});
|