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:
committed by
Peter Steinberger
parent
6cd32ec7f6
commit
33e2d53be3
@@ -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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user