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

@@ -5,6 +5,8 @@ import {
buildBootstrapContextFiles,
formatAssistantErrorText,
isContextOverflowError,
isMessagingToolDuplicate,
normalizeTextForComparison,
sanitizeGoogleTurnOrdering,
validateGeminiTurns,
} from "./pi-embedded-helpers.js";
@@ -247,3 +249,86 @@ describe("sanitizeGoogleTurnOrdering", () => {
expect(out).toBe(input);
});
});
describe("normalizeTextForComparison", () => {
it("lowercases text", () => {
expect(normalizeTextForComparison("Hello World")).toBe("hello world");
});
it("trims whitespace", () => {
expect(normalizeTextForComparison(" hello ")).toBe("hello");
});
it("collapses multiple spaces", () => {
expect(normalizeTextForComparison("hello world")).toBe("hello world");
});
it("strips emoji", () => {
expect(normalizeTextForComparison("Hello 👋 World 🌍")).toBe("hello world");
});
it("handles mixed normalization", () => {
expect(normalizeTextForComparison(" Hello 👋 WORLD 🌍 ")).toBe(
"hello world",
);
});
});
describe("isMessagingToolDuplicate", () => {
it("returns false for empty sentTexts", () => {
expect(isMessagingToolDuplicate("hello world", [])).toBe(false);
});
it("returns false for short texts", () => {
expect(isMessagingToolDuplicate("short", ["short"])).toBe(false);
});
it("detects exact duplicates", () => {
expect(
isMessagingToolDuplicate("Hello, this is a test message!", [
"Hello, this is a test message!",
]),
).toBe(true);
});
it("detects duplicates with different casing", () => {
expect(
isMessagingToolDuplicate("HELLO, THIS IS A TEST MESSAGE!", [
"hello, this is a test message!",
]),
).toBe(true);
});
it("detects duplicates with emoji variations", () => {
expect(
isMessagingToolDuplicate("Hello! 👋 This is a test message!", [
"Hello! This is a test message!",
]),
).toBe(true);
});
it("detects substring duplicates (LLM elaboration)", () => {
expect(
isMessagingToolDuplicate(
'I sent the message: "Hello, this is a test message!"',
["Hello, this is a test message!"],
),
).toBe(true);
});
it("detects when sent text contains block reply (reverse substring)", () => {
expect(
isMessagingToolDuplicate("Hello, this is a test message!", [
'I sent the message: "Hello, this is a test message!"',
]),
).toBe(true);
});
it("returns false for non-matching texts", () => {
expect(
isMessagingToolDuplicate("This is completely different content.", [
"Hello, this is a test message!",
]),
).toBe(false);
});
});

View File

@@ -346,3 +346,50 @@ export function validateGeminiTurns(messages: AgentMessage[]): AgentMessage[] {
return result;
}
// ── Messaging tool duplicate detection ──────────────────────────────────────
// When the agent uses a messaging tool (telegram, discord, slack, sessions_send)
// to send a message, we track the text so we can suppress duplicate block replies.
// The LLM sometimes elaborates or wraps the same content, so we use substring matching.
const MIN_DUPLICATE_TEXT_LENGTH = 10;
/**
* Normalize text for duplicate comparison.
* - Trims whitespace
* - Lowercases
* - Strips emoji (Emoji_Presentation and Extended_Pictographic)
* - Collapses multiple spaces to single space
*/
export function normalizeTextForComparison(text: string): string {
return text
.trim()
.toLowerCase()
.replace(/\p{Emoji_Presentation}|\p{Extended_Pictographic}/gu, "")
.replace(/\s+/g, " ")
.trim();
}
/**
* Check if a text is a duplicate of any previously sent messaging tool text.
* Uses substring matching to handle LLM elaboration (e.g., wrapping in quotes,
* adding context, or slight rephrasing that includes the original).
*/
export function isMessagingToolDuplicate(
text: string,
sentTexts: string[],
): boolean {
if (sentTexts.length === 0) return false;
const normalized = normalizeTextForComparison(text);
if (!normalized || normalized.length < MIN_DUPLICATE_TEXT_LENGTH)
return false;
return sentTexts.some((sent) => {
const normalizedSent = normalizeTextForComparison(sent);
if (!normalizedSent || normalizedSent.length < MIN_DUPLICATE_TEXT_LENGTH)
return false;
// Substring match: either text contains the other
return (
normalized.includes(normalizedSent) || normalizedSent.includes(normalized)
);
});
}

View File

@@ -223,6 +223,9 @@ export type EmbeddedPiRunResult = {
isError?: boolean;
}>;
meta: EmbeddedPiRunMeta;
// True if a messaging tool (telegram, whatsapp, discord, slack, sessions_send)
// successfully sent a message. Used to suppress agent's confirmation text.
didSendViaMessagingTool?: boolean;
};
export type EmbeddedPiCompactResult = {
@@ -1250,6 +1253,7 @@ export async function runEmbeddedPiAgent(params: {
toolMetas,
unsubscribe,
waitForCompactionRetry,
didSendViaMessagingTool,
} = subscription;
const queueHandle: EmbeddedPiQueueHandle = {
@@ -1531,6 +1535,7 @@ export async function runEmbeddedPiAgent(params: {
agentMeta,
aborted,
},
didSendViaMessagingTool: didSendViaMessagingTool(),
};
} finally {
restoreSkillEnv?.();

View File

@@ -8,6 +8,7 @@ import { createSubsystemLogger } from "../logging.js";
import { splitMediaFromOutput } from "../media/parse.js";
import type { BlockReplyChunking } from "./pi-embedded-block-chunker.js";
import { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js";
import { isMessagingToolDuplicate } from "./pi-embedded-helpers.js";
import {
extractAssistantText,
extractAssistantThinking,
@@ -137,6 +138,21 @@ export function subscribeEmbeddedPiSession(params: {
let compactionRetryPromise: Promise<void> | null = null;
let lastReasoningSent: string | undefined;
// ── Messaging tool duplicate detection ──────────────────────────────────────
// Track texts sent via messaging tools to suppress duplicate block replies.
// Only committed (successful) texts are checked - pending texts are tracked
// to support commit logic but not used for suppression (avoiding lost messages on tool failure).
// These tools can send messages via sendMessage/threadReply actions (or sessions_send with message).
const MESSAGING_TOOLS = new Set([
"telegram",
"whatsapp",
"discord",
"slack",
"sessions_send",
]);
const messagingToolSentTexts: string[] = [];
const pendingMessagingTexts = new Map<string, string>();
const ensureCompactionPromise = () => {
if (!compactionRetryPromise) {
compactionRetryPromise = new Promise((resolve) => {
@@ -221,6 +237,16 @@ export function subscribeEmbeddedPiSession(params: {
const chunk = strippedText.trimEnd();
if (!chunk) return;
if (chunk === lastBlockReplyText) return;
// Only check committed (successful) messaging tool texts - checking pending texts
// is risky because if the tool fails after suppression, the user gets no response
if (isMessagingToolDuplicate(chunk, messagingToolSentTexts)) {
log.debug(
`Skipping block reply - already sent via messaging tool: ${chunk.slice(0, 50)}...`,
);
return;
}
lastBlockReplyText = chunk;
assistantTexts.push(chunk);
if (!params.onBlockReply) return;
@@ -288,6 +314,8 @@ export function subscribeEmbeddedPiSession(params: {
toolMetas.length = 0;
toolMetaById.clear();
toolSummaryById.clear();
messagingToolSentTexts.length = 0;
pendingMessagingTexts.clear();
deltaBuffer = "";
blockBuffer = "";
blockChunker?.reset();
@@ -355,6 +383,32 @@ export function subscribeEmbeddedPiSession(params: {
toolSummaryById.add(toolCallId);
emitToolSummary(toolName, meta);
}
// Track messaging tool sends (pending until confirmed in tool_execution_end)
if (MESSAGING_TOOLS.has(toolName)) {
const argsRecord =
args && typeof args === "object"
? (args as Record<string, unknown>)
: {};
const action =
typeof argsRecord.action === "string" ? argsRecord.action : "";
// Track send actions: sendMessage/threadReply for Discord/Slack, or sessions_send (no action field)
if (
action === "sendMessage" ||
action === "threadReply" ||
toolName === "sessions_send"
) {
// Field names vary by tool: Discord/Slack use "content", sessions_send uses "message"
const text =
(argsRecord.content as string) ?? (argsRecord.message as string);
if (text && typeof text === "string") {
pendingMessagingTexts.set(toolCallId, text);
log.debug(
`Tracking pending messaging text: tool=${toolName} action=${action} len=${text.length}`,
);
}
}
}
}
if (evt.type === "tool_execution_update") {
@@ -404,6 +458,18 @@ export function subscribeEmbeddedPiSession(params: {
toolMetaById.delete(toolCallId);
toolSummaryById.delete(toolCallId);
// Commit messaging tool text on success, discard on error
const pendingText = pendingMessagingTexts.get(toolCallId);
if (pendingText) {
pendingMessagingTexts.delete(toolCallId);
if (!isError) {
messagingToolSentTexts.push(pendingText);
log.debug(
`Committed messaging text: tool=${toolName} len=${pendingText.length}`,
);
}
}
emitAgentEvent({
runId: params.runId,
stream: "tool",
@@ -591,14 +657,21 @@ export function subscribeEmbeddedPiSession(params: {
blockChunker.drain({ force: true, emit: emitBlockChunk });
blockChunker.reset();
} else if (text !== lastBlockReplyText) {
lastBlockReplyText = text;
const { text: cleanedText, mediaUrls } =
splitMediaFromOutput(text);
if (cleanedText || (mediaUrls && mediaUrls.length > 0)) {
void params.onBlockReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
});
// Check for duplicates before emitting (same logic as emitBlockChunk)
if (isMessagingToolDuplicate(text, messagingToolSentTexts)) {
log.debug(
`Skipping message_end block reply - already sent via messaging tool: ${text.slice(0, 50)}...`,
);
} else {
lastBlockReplyText = text;
const { text: cleanedText, mediaUrls } =
splitMediaFromOutput(text);
if (cleanedText || (mediaUrls && mediaUrls.length > 0)) {
void params.onBlockReply({
text: cleanedText,
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
});
}
}
}
}
@@ -705,6 +778,10 @@ export function subscribeEmbeddedPiSession(params: {
toolMetas,
unsubscribe,
isCompacting: () => compactionInFlight || pendingCompactionRetry > 0,
// Returns true if any messaging tool successfully sent a message.
// Used to suppress agent's confirmation text (e.g., "Respondi no Telegram!")
// which is generated AFTER the tool sends the actual answer.
didSendViaMessagingTool: () => messagingToolSentTexts.length > 0,
waitForCompactionRetry: () => {
if (compactionInFlight || pendingCompactionRetry > 0) {
ensureCompactionPromise();

View File

@@ -4,15 +4,21 @@ import type { ClawdbotConfig } from "../../config/config.js";
import { handleTelegramAction } from "./telegram-actions.js";
const reactMessageTelegram = vi.fn(async () => ({ ok: true }));
const sendMessageTelegram = vi.fn(async () => ({
messageId: "789",
chatId: "123",
}));
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
vi.mock("../../telegram/send.js", () => ({
reactMessageTelegram: (...args: unknown[]) => reactMessageTelegram(...args),
sendMessageTelegram: (...args: unknown[]) => sendMessageTelegram(...args),
}));
describe("handleTelegramAction", () => {
beforeEach(() => {
reactMessageTelegram.mockClear();
sendMessageTelegram.mockClear();
process.env.TELEGRAM_BOT_TOKEN = "tok";
});
@@ -92,4 +98,74 @@ describe("handleTelegramAction", () => {
),
).rejects.toThrow(/Telegram reactions are disabled/);
});
it("sends a text message", async () => {
const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig;
const result = await handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Hello, Telegram!",
},
cfg,
);
expect(sendMessageTelegram).toHaveBeenCalledWith(
"@testchannel",
"Hello, Telegram!",
{ token: "tok", mediaUrl: undefined },
);
expect(result.content).toContainEqual({
type: "text",
text: expect.stringContaining('"ok": true'),
});
});
it("sends a message with media", async () => {
const cfg = { telegram: { botToken: "tok" } } as ClawdbotConfig;
await handleTelegramAction(
{
action: "sendMessage",
to: "123456",
content: "Check this image!",
mediaUrl: "https://example.com/image.jpg",
},
cfg,
);
expect(sendMessageTelegram).toHaveBeenCalledWith(
"123456",
"Check this image!",
{ token: "tok", mediaUrl: "https://example.com/image.jpg" },
);
});
it("respects sendMessage gating", async () => {
const cfg = {
telegram: { botToken: "tok", actions: { sendMessage: false } },
} as ClawdbotConfig;
await expect(
handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Hello!",
},
cfg,
),
).rejects.toThrow(/Telegram sendMessage is disabled/);
});
it("throws on missing bot token for sendMessage", async () => {
delete process.env.TELEGRAM_BOT_TOKEN;
const cfg = {} as ClawdbotConfig;
await expect(
handleTelegramAction(
{
action: "sendMessage",
to: "@testchannel",
content: "Hello!",
},
cfg,
),
).rejects.toThrow(/Telegram bot token missing/);
});
});

View File

@@ -1,7 +1,10 @@
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
import type { ClawdbotConfig } from "../../config/config.js";
import { reactMessageTelegram } from "../../telegram/send.js";
import {
reactMessageTelegram,
sendMessageTelegram,
} from "../../telegram/send.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import {
createActionGate,
@@ -49,5 +52,38 @@ export async function handleTelegramAction(
return jsonResult({ ok: true, removed: true });
}
if (action === "sendMessage") {
if (!isActionEnabled("sendMessage")) {
throw new Error("Telegram sendMessage is disabled.");
}
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "content", { required: true });
const mediaUrl = readStringParam(params, "mediaUrl");
// Optional threading parameters for forum topics and reply chains
const replyToMessageId = readNumberParam(params, "replyToMessageId", {
integer: true,
});
const messageThreadId = readNumberParam(params, "messageThreadId", {
integer: true,
});
const token = resolveTelegramToken(cfg).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or telegram.botToken.",
);
}
const result = await sendMessageTelegram(to, content, {
token,
mediaUrl: mediaUrl || undefined,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
});
return jsonResult({
ok: true,
messageId: result.messageId,
chatId: result.chatId,
});
}
throw new Error(`Unsupported Telegram action: ${action}`);
}

View File

@@ -13,4 +13,22 @@ export const TelegramToolSchema = Type.Union([
},
includeRemove: true,
}),
Type.Object({
action: Type.Literal("sendMessage"),
to: Type.String({ description: "Chat ID, @username, or t.me/username" }),
content: Type.String({ description: "Message text to send" }),
mediaUrl: Type.Optional(
Type.String({ description: "URL of image/video/audio to attach" }),
),
replyToMessageId: Type.Optional(
Type.Union([Type.String(), Type.Number()], {
description: "Message ID to reply to (for threading)",
}),
),
messageThreadId: Type.Optional(
Type.Union([Type.String(), Type.Number()], {
description: "Forum topic thread ID (for forum supergroups)",
}),
),
}),
]);

View File

@@ -7,7 +7,7 @@ export function createTelegramTool(): AnyAgentTool {
return {
label: "Telegram",
name: "telegram",
description: "Manage Telegram reactions.",
description: "Send messages and manage reactions on Telegram.",
parameters: TelegramToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;

View File

@@ -319,7 +319,8 @@ export async function runReplyAgent(params: {
text: cleaned,
mediaUrls: payload.mediaUrls,
mediaUrl: payload.mediaUrls?.[0],
replyToId: tagResult.replyToId,
// Default to incoming message ID for threading support (replyToMode: "first"|"all")
replyToId: tagResult.replyToId ?? sessionCtx.MessageSid,
};
const payloadKey = buildPayloadKey(blockPayload);
if (
@@ -501,7 +502,8 @@ export async function runReplyAgent(params: {
return {
...payload,
text: cleaned ? cleaned : undefined,
replyToId: replyToId ?? payload.replyToId,
// Default to incoming message ID for threading support (replyToMode: "first"|"all")
replyToId: replyToId ?? payload.replyToId ?? sessionCtx.MessageSid,
};
})
.filter(
@@ -511,8 +513,14 @@ export async function runReplyAgent(params: {
(payload.mediaUrls && payload.mediaUrls.length > 0),
);
// Drop final payloads if:
// 1. Block streaming is enabled and we already streamed block replies, OR
// 2. A messaging tool (telegram, whatsapp, etc.) successfully sent the response.
// The agent often generates confirmation text (e.g., "Respondi no Telegram!")
// AFTER using the messaging tool - we must suppress this confirmation text.
const shouldDropFinalPayloads =
blockStreamingEnabled && didStreamBlockReply;
(blockStreamingEnabled && didStreamBlockReply) ||
runResult.didSendViaMessagingTool === true;
const filteredPayloads = shouldDropFinalPayloads
? []
: blockStreamingEnabled

View File

@@ -92,6 +92,7 @@ export type AgentElevatedAllowFromConfig = {
export type WhatsAppActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
};
export type WhatsAppConfig = {
@@ -254,6 +255,7 @@ export type HooksConfig = {
export type TelegramActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
};
export type TelegramTopicConfig = {

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