feat(telegram): wire replyToMode config, add forum topic support, fix messaging tool duplicates

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>
This commit is contained in:
mneves75
2026-01-07 03:24:56 -03:00
committed by Peter Steinberger
parent 6cd32ec7f6
commit 33e2d53be3
18 changed files with 872 additions and 38 deletions

View File

@@ -205,7 +205,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
(entry) => entry === username || entry === `@${username}`,
);
};
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "off";
const replyToMode = opts.replyToMode ?? cfg.telegram?.replyToMode ?? "first";
const streamMode = resolveTelegramStreamMode(cfg);
const nativeEnabled = cfg.commands?.native === true;
const nativeDisabledExplicit = cfg.commands?.native === false;

View File

@@ -157,6 +157,140 @@ describe("sendMessageTelegram", () => {
});
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", () => {

View File

@@ -1,4 +1,4 @@
// @ts-nocheck
import type { ReactionType, ReactionTypeEmoji } from "@grammyjs/types";
import { Bot, InputFile } from "grammy";
import { loadConfig } from "../config/config.js";
import type { ClawdbotConfig } from "../config/types.js";
@@ -15,9 +15,12 @@ type TelegramSendOpts = {
verbose?: boolean;
mediaUrl?: string;
maxBytes?: number;
messageThreadId?: number;
api?: Bot["api"];
retry?: RetryConfig;
/** Message ID to reply to (for threading) */
replyToMessageId?: number;
/** Forum topic thread ID (for forum supergroups) */
messageThreadId?: number;
};
type TelegramSendResult = {
@@ -96,13 +99,21 @@ export async function sendMessageTelegram(
const cfg = loadConfig();
const token = resolveToken(opts.token, cfg);
const chatId = normalizeChatId(to);
const bot = opts.api ? null : new Bot(token);
const api = opts.api ?? bot?.api;
// Use provided api or create a new Bot instance. The nullish coalescing
// operator ensures api is always defined (Bot.api is always non-null).
const api = opts.api ?? new Bot(token).api;
const mediaUrl = opts.mediaUrl?.trim();
const threadParams =
typeof opts.messageThreadId === "number"
? { message_thread_id: Math.trunc(opts.messageThreadId) }
: undefined;
// Build optional params for forum topics and reply threading.
// Only include these if actually provided to keep API calls clean.
const threadParams: Record<string, number> = {};
if (opts.messageThreadId != null) {
threadParams.message_thread_id = Math.trunc(opts.messageThreadId);
}
if (opts.replyToMessageId != null) {
threadParams.reply_to_message_id = Math.trunc(opts.replyToMessageId);
}
const hasThreadParams = Object.keys(threadParams).length > 0;
const request = createTelegramRetryRunner({
retry: opts.retry,
configRetry: cfg.telegram?.retry,
@@ -134,6 +145,9 @@ export async function sendMessageTelegram(
"file";
const file = new InputFile(media.buffer, fileName);
const caption = text?.trim() || undefined;
const mediaParams = hasThreadParams
? { caption, ...threadParams }
: { caption };
let result:
| Awaited<ReturnType<typeof api.sendPhoto>>
| Awaited<ReturnType<typeof api.sendVideo>>
@@ -142,35 +156,35 @@ export async function sendMessageTelegram(
| Awaited<ReturnType<typeof api.sendDocument>>;
if (isGif) {
result = await request(
() => api.sendAnimation(chatId, file, { caption, ...threadParams }),
() => api.sendAnimation(chatId, file, mediaParams),
"animation",
).catch((err) => {
throw wrapChatNotFound(err);
});
} else if (kind === "image") {
result = await request(
() => api.sendPhoto(chatId, file, { caption, ...threadParams }),
() => api.sendPhoto(chatId, file, mediaParams),
"photo",
).catch((err) => {
throw wrapChatNotFound(err);
});
} else if (kind === "video") {
result = await request(
() => api.sendVideo(chatId, file, { caption, ...threadParams }),
() => api.sendVideo(chatId, file, mediaParams),
"video",
).catch((err) => {
throw wrapChatNotFound(err);
});
} else if (kind === "audio") {
result = await request(
() => api.sendAudio(chatId, file, { caption, ...threadParams }),
() => api.sendAudio(chatId, file, mediaParams),
"audio",
).catch((err) => {
throw wrapChatNotFound(err);
});
} else {
result = await request(
() => api.sendDocument(chatId, file, { caption, ...threadParams }),
() => api.sendDocument(chatId, file, mediaParams),
"document",
).catch((err) => {
throw wrapChatNotFound(err);
@@ -183,12 +197,11 @@ export async function sendMessageTelegram(
if (!text || !text.trim()) {
throw new Error("Message must be non-empty for Telegram sends");
}
const textParams = hasThreadParams
? { parse_mode: "Markdown" as const, ...threadParams }
: { parse_mode: "Markdown" as const };
const res = await request(
() =>
api.sendMessage(chatId, text, {
parse_mode: "Markdown",
...threadParams,
}),
() => api.sendMessage(chatId, text, textParams),
"message",
).catch(async (err) => {
// Telegram rejects malformed Markdown (e.g., unbalanced '_' or '*').
@@ -202,7 +215,7 @@ export async function sendMessageTelegram(
}
return await request(
() =>
threadParams
hasThreadParams
? api.sendMessage(chatId, text, threadParams)
: api.sendMessage(chatId, text),
"message-plain",
@@ -226,8 +239,7 @@ export async function reactMessageTelegram(
const token = resolveToken(opts.token, cfg);
const chatId = normalizeChatId(String(chatIdInput));
const messageId = normalizeMessageId(messageIdInput);
const bot = opts.api ? null : new Bot(token);
const api = opts.api ?? bot?.api;
const api = opts.api ?? new Bot(token).api;
const request = createTelegramRetryRunner({
retry: opts.retry,
configRetry: cfg.telegram?.retry,
@@ -235,8 +247,12 @@ export async function reactMessageTelegram(
});
const remove = opts.remove === true;
const trimmedEmoji = emoji.trim();
const reactions =
remove || !trimmedEmoji ? [] : [{ type: "emoji", emoji: trimmedEmoji }];
// Build the reaction array. We cast emoji to the grammY union type since
// Telegram validates emoji server-side; invalid emojis fail gracefully.
const reactions: ReactionType[] =
remove || !trimmedEmoji
? []
: [{ type: "emoji", emoji: trimmedEmoji as ReactionTypeEmoji["emoji"] }];
if (typeof api.setMessageReaction !== "function") {
throw new Error("Telegram reactions are unavailable in this bot API.");
}
@@ -259,4 +275,3 @@ function inferFilename(kind: ReturnType<typeof mediaKindFromMime>) {
return "file.bin";
}
}
// @ts-nocheck