Changes: - Default replyToMode from "off" to "first" for better threading UX - Add messageThreadId and replyToMessageId params for forum topic support - Add messaging tool duplicate detection to suppress redundant block replies - Add sendMessage action to telegram tool schema - Add @grammyjs/types devDependency for proper TypeScript typing - Remove @ts-nocheck and fix all type errors in send.ts - Add comprehensive docs/telegram.md documentation - Add PR-326-REVIEW.md with John Carmack-level code review Test coverage: - normalizeTextForComparison: 5 cases - isMessagingToolDuplicate: 7 cases - sendMessageTelegram thread params: 5 cases - handleTelegramAction sendMessage: 4 cases - Forum topic isolation: 4 cases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
342 lines
9.4 KiB
TypeScript
342 lines
9.4 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const { loadWebMedia } = vi.hoisted(() => ({
|
|
loadWebMedia: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("../web/media.js", () => ({
|
|
loadWebMedia,
|
|
}));
|
|
|
|
import { reactMessageTelegram, sendMessageTelegram } from "./send.js";
|
|
|
|
describe("sendMessageTelegram", () => {
|
|
beforeEach(() => {
|
|
loadWebMedia.mockReset();
|
|
});
|
|
|
|
it("falls back to plain text when Telegram rejects Markdown", 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: 42,
|
|
chat: { id: chatId },
|
|
});
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
|
|
const res = await sendMessageTelegram(chatId, "_oops_", {
|
|
token: "tok",
|
|
api,
|
|
verbose: true,
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "_oops_", {
|
|
parse_mode: "Markdown",
|
|
});
|
|
expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_oops_");
|
|
expect(res.chatId).toBe(chatId);
|
|
expect(res.messageId).toBe("42");
|
|
});
|
|
|
|
it("normalizes chat ids with internal prefixes", async () => {
|
|
const sendMessage = vi.fn().mockResolvedValue({
|
|
message_id: 1,
|
|
chat: { id: "123" },
|
|
});
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
|
|
await sendMessageTelegram("telegram:123", "hi", {
|
|
token: "tok",
|
|
api,
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith("123", "hi", {
|
|
parse_mode: "Markdown",
|
|
});
|
|
});
|
|
|
|
it("wraps chat-not-found with actionable context", 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 expect(
|
|
sendMessageTelegram(chatId, "hi", { token: "tok", api }),
|
|
).rejects.toThrow(/chat not found/i);
|
|
await expect(
|
|
sendMessageTelegram(chatId, "hi", { token: "tok", api }),
|
|
).rejects.toThrow(/chat_id=123/);
|
|
});
|
|
|
|
it("retries on transient errors with retry_after", async () => {
|
|
vi.useFakeTimers();
|
|
const chatId = "123";
|
|
const err = Object.assign(new Error("429"), {
|
|
parameters: { retry_after: 0.5 },
|
|
});
|
|
const sendMessage = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(err)
|
|
.mockResolvedValueOnce({
|
|
message_id: 1,
|
|
chat: { id: chatId },
|
|
});
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
const setTimeoutSpy = vi.spyOn(global, "setTimeout");
|
|
|
|
const promise = sendMessageTelegram(chatId, "hi", {
|
|
token: "tok",
|
|
api,
|
|
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
|
|
});
|
|
|
|
await vi.runAllTimersAsync();
|
|
await expect(promise).resolves.toEqual({ messageId: "1", chatId });
|
|
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(500);
|
|
setTimeoutSpy.mockRestore();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("does not retry on non-transient errors", async () => {
|
|
const chatId = "123";
|
|
const sendMessage = vi
|
|
.fn()
|
|
.mockRejectedValue(new Error("400: Bad Request"));
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
|
|
await expect(
|
|
sendMessageTelegram(chatId, "hi", {
|
|
token: "tok",
|
|
api,
|
|
retry: { attempts: 3, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
|
}),
|
|
).rejects.toThrow(/Bad Request/);
|
|
expect(sendMessage).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("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;
|
|
};
|
|
|
|
loadWebMedia.mockResolvedValueOnce({
|
|
buffer: Buffer.from("GIF89a"),
|
|
fileName: "fun.gif",
|
|
});
|
|
|
|
const res = await sendMessageTelegram(chatId, "caption", {
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/fun",
|
|
});
|
|
|
|
expect(sendAnimation).toHaveBeenCalledTimes(1);
|
|
expect(sendAnimation).toHaveBeenCalledWith(chatId, expect.anything(), {
|
|
caption: "caption",
|
|
});
|
|
expect(res.messageId).toBe("9");
|
|
});
|
|
|
|
it("includes message_thread_id for forum topic messages", 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(chatId, "hello forum", {
|
|
token: "tok",
|
|
api,
|
|
messageThreadId: 271,
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith(chatId, "hello forum", {
|
|
parse_mode: "Markdown",
|
|
message_thread_id: 271,
|
|
});
|
|
});
|
|
|
|
it("includes reply_to_message_id for threaded replies", async () => {
|
|
const chatId = "123";
|
|
const sendMessage = vi.fn().mockResolvedValue({
|
|
message_id: 56,
|
|
chat: { id: chatId },
|
|
});
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
|
|
await sendMessageTelegram(chatId, "reply text", {
|
|
token: "tok",
|
|
api,
|
|
replyToMessageId: 100,
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith(chatId, "reply text", {
|
|
parse_mode: "Markdown",
|
|
reply_to_message_id: 100,
|
|
});
|
|
});
|
|
|
|
it("includes both thread and reply params for forum topic replies", async () => {
|
|
const chatId = "-1001234567890";
|
|
const sendMessage = vi.fn().mockResolvedValue({
|
|
message_id: 57,
|
|
chat: { id: chatId },
|
|
});
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
|
|
await sendMessageTelegram(chatId, "forum reply", {
|
|
token: "tok",
|
|
api,
|
|
messageThreadId: 271,
|
|
replyToMessageId: 500,
|
|
});
|
|
|
|
expect(sendMessage).toHaveBeenCalledWith(chatId, "forum reply", {
|
|
parse_mode: "Markdown",
|
|
message_thread_id: 271,
|
|
reply_to_message_id: 500,
|
|
});
|
|
});
|
|
|
|
it("preserves thread params in plain text fallback", async () => {
|
|
const chatId = "-1001234567890";
|
|
const parseErr = new Error(
|
|
"400: Bad Request: can't parse entities: Can't find end of the entity",
|
|
);
|
|
const sendMessage = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(parseErr)
|
|
.mockResolvedValueOnce({
|
|
message_id: 60,
|
|
chat: { id: chatId },
|
|
});
|
|
const api = { sendMessage } as unknown as {
|
|
sendMessage: typeof sendMessage;
|
|
};
|
|
|
|
const res = await sendMessageTelegram(chatId, "_bad markdown_", {
|
|
token: "tok",
|
|
api,
|
|
messageThreadId: 271,
|
|
replyToMessageId: 100,
|
|
});
|
|
|
|
// First call: with Markdown + thread params
|
|
expect(sendMessage).toHaveBeenNthCalledWith(1, chatId, "_bad markdown_", {
|
|
parse_mode: "Markdown",
|
|
message_thread_id: 271,
|
|
reply_to_message_id: 100,
|
|
});
|
|
// Second call: plain text BUT still with thread params (critical!)
|
|
expect(sendMessage).toHaveBeenNthCalledWith(2, chatId, "_bad markdown_", {
|
|
message_thread_id: 271,
|
|
reply_to_message_id: 100,
|
|
});
|
|
expect(res.messageId).toBe("60");
|
|
});
|
|
|
|
it("includes thread params in media messages", async () => {
|
|
const chatId = "-1001234567890";
|
|
const sendPhoto = vi.fn().mockResolvedValue({
|
|
message_id: 58,
|
|
chat: { id: chatId },
|
|
});
|
|
const api = { sendPhoto } as unknown as {
|
|
sendPhoto: typeof sendPhoto;
|
|
};
|
|
|
|
loadWebMedia.mockResolvedValueOnce({
|
|
buffer: Buffer.from("fake-image"),
|
|
contentType: "image/jpeg",
|
|
fileName: "photo.jpg",
|
|
});
|
|
|
|
await sendMessageTelegram(chatId, "photo in topic", {
|
|
token: "tok",
|
|
api,
|
|
mediaUrl: "https://example.com/photo.jpg",
|
|
messageThreadId: 99,
|
|
});
|
|
|
|
expect(sendPhoto).toHaveBeenCalledWith(chatId, expect.anything(), {
|
|
caption: "photo in topic",
|
|
message_thread_id: 99,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("reactMessageTelegram", () => {
|
|
it("sends emoji reactions", async () => {
|
|
const setMessageReaction = vi.fn().mockResolvedValue(undefined);
|
|
const api = { setMessageReaction } as unknown as {
|
|
setMessageReaction: typeof setMessageReaction;
|
|
};
|
|
|
|
await reactMessageTelegram("telegram:123", "456", "✅", {
|
|
token: "tok",
|
|
api,
|
|
});
|
|
|
|
expect(setMessageReaction).toHaveBeenCalledWith("123", 456, [
|
|
{ type: "emoji", emoji: "✅" },
|
|
]);
|
|
});
|
|
|
|
it("removes reactions when emoji is empty", async () => {
|
|
const setMessageReaction = vi.fn().mockResolvedValue(undefined);
|
|
const api = { setMessageReaction } as unknown as {
|
|
setMessageReaction: typeof setMessageReaction;
|
|
};
|
|
|
|
await reactMessageTelegram("123", 456, "", {
|
|
token: "tok",
|
|
api,
|
|
});
|
|
|
|
expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []);
|
|
});
|
|
|
|
it("removes reactions when remove flag is set", async () => {
|
|
const setMessageReaction = vi.fn().mockResolvedValue(undefined);
|
|
const api = { setMessageReaction } as unknown as {
|
|
setMessageReaction: typeof setMessageReaction;
|
|
};
|
|
|
|
await reactMessageTelegram("123", 456, "✅", {
|
|
token: "tok",
|
|
api,
|
|
remove: true,
|
|
});
|
|
|
|
expect(setMessageReaction).toHaveBeenCalledWith("123", 456, []);
|
|
});
|
|
});
|