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

@@ -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