import { type Bot, GrammyError, InputFile } from "grammy"; import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyToMode } from "../../config/config.js"; import type { MarkdownTableMode } from "../../config/types.base.js"; import { danger, logVerbose, warn } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { retryAsync } from "../../infra/retry.js"; import { mediaKindFromMime } from "../../media/constants.js"; import { fetchRemoteMedia } from "../../media/fetch.js"; import { isGifMedia } from "../../media/mime.js"; import { saveMediaBuffer } from "../../media/store.js"; import type { RuntimeEnv } from "../../runtime.js"; import { loadWebMedia } from "../../web/media.js"; import { withTelegramApiErrorLogging } from "../api-logging.js"; import type { TelegramInlineButtons } from "../button-types.js"; import { splitTelegramCaption } from "../caption.js"; import { markdownToTelegramChunks, markdownToTelegramHtml, renderTelegramHtmlText, wrapFileReferencesInHtml, } from "../format.js"; import { buildInlineKeyboard } from "../send.js"; import { cacheSticker, getCachedSticker } from "../sticker-cache.js"; import { resolveTelegramVoiceSend } from "../voice.js"; import { buildTelegramThreadParams, resolveTelegramMediaPlaceholder, resolveTelegramReplyId, type TelegramThreadSpec, } from "./helpers.js"; import type { StickerMetadata, TelegramContext } from "./types.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; const FILE_TOO_BIG_RE = /file is too big/i; export async function deliverReplies(params: { replies: ReplyPayload[]; chatId: string; token: string; runtime: RuntimeEnv; bot: Bot; mediaLocalRoots?: readonly string[]; replyToMode: ReplyToMode; textLimit: number; thread?: TelegramThreadSpec | null; tableMode?: MarkdownTableMode; chunkMode?: ChunkMode; /** Callback invoked before sending a voice message to switch typing indicator. */ onVoiceRecording?: () => Promise | void; /** Controls whether link previews are shown. Default: true (previews enabled). */ linkPreview?: boolean; /** Optional quote text for Telegram reply_parameters. */ replyQuoteText?: string; }): Promise<{ delivered: boolean }> { const { replies, chatId, runtime, bot, replyToMode, textLimit, thread, linkPreview, replyQuoteText, } = params; const chunkMode = params.chunkMode ?? "length"; let hasReplied = false; let hasDelivered = false; const markDelivered = () => { hasDelivered = true; }; const chunkText = (markdown: string) => { const markdownChunks = chunkMode === "newline" ? chunkMarkdownTextWithMode(markdown, textLimit, chunkMode) : [markdown]; const chunks: ReturnType = []; for (const chunk of markdownChunks) { const nested = markdownToTelegramChunks(chunk, textLimit, { tableMode: params.tableMode }); if (!nested.length && chunk) { chunks.push({ html: wrapFileReferencesInHtml( markdownToTelegramHtml(chunk, { tableMode: params.tableMode, wrapFileRefs: false }), ), text: chunk, }); continue; } chunks.push(...nested); } return chunks; }; for (const reply of replies) { const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0; if (!reply?.text && !hasMedia) { if (reply?.audioAsVoice) { logVerbose("telegram reply has audioAsVoice without media/text; skipping"); continue; } runtime.error?.(danger("reply missing text/media")); continue; } const replyToId = replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId); const mediaList = reply.mediaUrls?.length ? reply.mediaUrls : reply.mediaUrl ? [reply.mediaUrl] : []; const telegramData = reply.channelData?.telegram as | { buttons?: TelegramInlineButtons } | undefined; const replyMarkup = buildInlineKeyboard(telegramData?.buttons); if (mediaList.length === 0) { const chunks = chunkText(reply.text || ""); for (let i = 0; i < chunks.length; i += 1) { const chunk = chunks[i]; if (!chunk) { continue; } // Only attach buttons to the first chunk. const shouldAttachButtons = i === 0 && replyMarkup; await sendTelegramText(bot, chatId, chunk.html, runtime, { replyToMessageId: replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined, replyQuoteText, thread, textMode: "html", plainText: chunk.text, linkPreview, replyMarkup: shouldAttachButtons ? replyMarkup : undefined, }); markDelivered(); if (replyToId && !hasReplied) { hasReplied = true; } } continue; } // media with optional caption on first item let first = true; // Track if we need to send a follow-up text message after media // (when caption exceeds Telegram's 1024-char limit) let pendingFollowUpText: string | undefined; for (const mediaUrl of mediaList) { const isFirstMedia = first; const media = await loadWebMedia(mediaUrl, { localRoots: params.mediaLocalRoots, }); const kind = mediaKindFromMime(media.contentType ?? undefined); const isGif = isGifMedia({ contentType: media.contentType, fileName: media.fileName, }); const fileName = media.fileName ?? (isGif ? "animation.gif" : "file"); const file = new InputFile(media.buffer, fileName); // Caption only on first item; if text exceeds limit, defer to follow-up message. const { caption, followUpText } = splitTelegramCaption( isFirstMedia ? (reply.text ?? undefined) : undefined, ); const htmlCaption = caption ? renderTelegramHtmlText(caption, { tableMode: params.tableMode }) : undefined; if (followUpText) { pendingFollowUpText = followUpText; } first = false; const replyToMessageId = replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; const shouldAttachButtonsToMedia = isFirstMedia && replyMarkup && !followUpText; const mediaParams: Record = { caption: htmlCaption, ...(htmlCaption ? { parse_mode: "HTML" } : {}), ...(shouldAttachButtonsToMedia ? { reply_markup: replyMarkup } : {}), ...buildTelegramSendParams({ replyToMessageId, thread, }), }; if (isGif) { await withTelegramApiErrorLogging({ operation: "sendAnimation", runtime, fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }), }); markDelivered(); } else if (kind === "image") { await withTelegramApiErrorLogging({ operation: "sendPhoto", runtime, fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }), }); markDelivered(); } else if (kind === "video") { await withTelegramApiErrorLogging({ operation: "sendVideo", runtime, fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }), }); markDelivered(); } else if (kind === "audio") { const { useVoice } = resolveTelegramVoiceSend({ wantsVoice: reply.audioAsVoice === true, // default false (backward compatible) contentType: media.contentType, fileName, logFallback: logVerbose, }); if (useVoice) { // Voice message - displays as round playable bubble (opt-in via [[audio_as_voice]]) // Switch typing indicator to record_voice before sending. await params.onVoiceRecording?.(); try { await withTelegramApiErrorLogging({ operation: "sendVoice", runtime, shouldLog: (err) => !isVoiceMessagesForbidden(err), fn: () => bot.api.sendVoice(chatId, file, { ...mediaParams }), }); markDelivered(); } catch (voiceErr) { // Fall back to text if voice messages are forbidden in this chat. // This happens when the recipient has Telegram Premium privacy settings // that block voice messages (Settings > Privacy > Voice Messages). if (isVoiceMessagesForbidden(voiceErr)) { const fallbackText = reply.text; if (!fallbackText || !fallbackText.trim()) { throw voiceErr; } logVerbose( "telegram sendVoice forbidden (recipient has voice messages blocked in privacy settings); falling back to text", ); hasReplied = await sendTelegramVoiceFallbackText({ bot, chatId, runtime, text: fallbackText, chunkText, replyToId, replyToMode, hasReplied, thread, linkPreview, replyMarkup, replyQuoteText, }); markDelivered(); // Skip this media item; continue with next. continue; } throw voiceErr; } } else { // Audio file - displays with metadata (title, duration) - DEFAULT await withTelegramApiErrorLogging({ operation: "sendAudio", runtime, fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }), }); markDelivered(); } } else { await withTelegramApiErrorLogging({ operation: "sendDocument", runtime, fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }), }); markDelivered(); } if (replyToId && !hasReplied) { hasReplied = true; } // Send deferred follow-up text right after the first media item. // Chunk it in case it's extremely long (same logic as text-only replies). if (pendingFollowUpText && isFirstMedia) { const chunks = chunkText(pendingFollowUpText); for (let i = 0; i < chunks.length; i += 1) { const chunk = chunks[i]; const replyToMessageIdFollowup = replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; await sendTelegramText(bot, chatId, chunk.html, runtime, { replyToMessageId: replyToMessageIdFollowup, thread, textMode: "html", plainText: chunk.text, linkPreview, replyMarkup: i === 0 ? replyMarkup : undefined, }); markDelivered(); if (replyToId && !hasReplied) { hasReplied = true; } } pendingFollowUpText = undefined; } } } return { delivered: hasDelivered }; } export async function resolveMedia( ctx: TelegramContext, maxBytes: number, token: string, proxyFetch?: typeof fetch, ): Promise<{ path: string; contentType?: string; placeholder: string; stickerMetadata?: StickerMetadata; } | null> { const msg = ctx.message; const downloadAndSaveTelegramFile = async (filePath: string, fetchImpl: typeof fetch) => { const url = `https://api.telegram.org/file/bot${token}/${filePath}`; const fetched = await fetchRemoteMedia({ url, fetchImpl, filePathHint: filePath, }); const originalName = fetched.fileName ?? filePath; return saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes, originalName); }; // Handle stickers separately - only static stickers (WEBP) are supported if (msg.sticker) { const sticker = msg.sticker; // Skip animated (TGS) and video (WEBM) stickers - only static WEBP supported if (sticker.is_animated || sticker.is_video) { logVerbose("telegram: skipping animated/video sticker (only static stickers supported)"); return null; } if (!sticker.file_id) { return null; } try { const file = await ctx.getFile(); if (!file.file_path) { logVerbose("telegram: getFile returned no file_path for sticker"); return null; } const fetchImpl = proxyFetch ?? globalThis.fetch; if (!fetchImpl) { logVerbose("telegram: fetch not available for sticker download"); return null; } const saved = await downloadAndSaveTelegramFile(file.file_path, fetchImpl); // Check sticker cache for existing description const cached = sticker.file_unique_id ? getCachedSticker(sticker.file_unique_id) : null; if (cached) { logVerbose(`telegram: sticker cache hit for ${sticker.file_unique_id}`); const fileId = sticker.file_id ?? cached.fileId; const emoji = sticker.emoji ?? cached.emoji; const setName = sticker.set_name ?? cached.setName; if (fileId !== cached.fileId || emoji !== cached.emoji || setName !== cached.setName) { // Refresh cached sticker metadata on hits so sends/searches use latest file_id. cacheSticker({ ...cached, fileId, emoji, setName, }); } return { path: saved.path, contentType: saved.contentType, placeholder: "", stickerMetadata: { emoji, setName, fileId, fileUniqueId: sticker.file_unique_id, cachedDescription: cached.description, }, }; } // Cache miss - return metadata for vision processing return { path: saved.path, contentType: saved.contentType, placeholder: "", stickerMetadata: { emoji: sticker.emoji ?? undefined, setName: sticker.set_name ?? undefined, fileId: sticker.file_id, fileUniqueId: sticker.file_unique_id, }, }; } catch (err) { logVerbose(`telegram: failed to process sticker: ${String(err)}`); return null; } } const m = msg.photo?.[msg.photo.length - 1] ?? msg.video ?? msg.video_note ?? msg.document ?? msg.audio ?? msg.voice; if (!m?.file_id) { return null; } let file: { file_path?: string }; try { file = await retryAsync(() => ctx.getFile(), { attempts: 3, minDelayMs: 1000, maxDelayMs: 4000, jitter: 0.2, label: "telegram:getFile", shouldRetry: isRetryableGetFileError, onRetry: ({ attempt, maxAttempts }) => logVerbose(`telegram: getFile retry ${attempt}/${maxAttempts}`), }); } catch (err) { // Handle "file is too big" separately - Telegram Bot API has a 20MB download limit if (isFileTooBigError(err)) { logVerbose( warn( "telegram: getFile failed - file exceeds Telegram Bot API 20MB limit; skipping attachment", ), ); return null; } // All retries exhausted — return null so the message still reaches the agent // with a type-based placeholder (e.g. ) instead of being dropped. logVerbose(`telegram: getFile failed after retries: ${String(err)}`); return null; } if (!file.file_path) { throw new Error("Telegram getFile returned no file_path"); } const fetchImpl = proxyFetch ?? globalThis.fetch; if (!fetchImpl) { throw new Error("fetch is not available; set channels.telegram.proxy in config"); } const saved = await downloadAndSaveTelegramFile(file.file_path, fetchImpl); const placeholder = resolveTelegramMediaPlaceholder(msg) ?? ""; return { path: saved.path, contentType: saved.contentType, placeholder }; } function isVoiceMessagesForbidden(err: unknown): boolean { if (err instanceof GrammyError) { return VOICE_FORBIDDEN_RE.test(err.description); } return VOICE_FORBIDDEN_RE.test(formatErrorMessage(err)); } /** * Returns true if the error is Telegram's "file is too big" error. * This happens when trying to download files >20MB via the Bot API. * Unlike network errors, this is a permanent error and should not be retried. */ function isFileTooBigError(err: unknown): boolean { if (err instanceof GrammyError) { return FILE_TOO_BIG_RE.test(err.description); } return FILE_TOO_BIG_RE.test(formatErrorMessage(err)); } /** * Returns true if the error is a transient network error that should be retried. * Returns false for permanent errors like "file is too big" (400 Bad Request). */ function isRetryableGetFileError(err: unknown): boolean { // Don't retry "file is too big" - it's a permanent 400 error if (isFileTooBigError(err)) { return false; } // Retry all other errors (network issues, timeouts, etc.) return true; } async function sendTelegramVoiceFallbackText(opts: { bot: Bot; chatId: string; runtime: RuntimeEnv; text: string; chunkText: (markdown: string) => ReturnType; replyToId?: number; replyToMode: ReplyToMode; hasReplied: boolean; thread?: TelegramThreadSpec | null; linkPreview?: boolean; replyMarkup?: ReturnType; replyQuoteText?: string; }): Promise { const chunks = opts.chunkText(opts.text); let hasReplied = opts.hasReplied; for (let i = 0; i < chunks.length; i += 1) { const chunk = chunks[i]; await sendTelegramText(opts.bot, opts.chatId, chunk.html, opts.runtime, { replyToMessageId: opts.replyToId && (opts.replyToMode === "all" || !hasReplied) ? opts.replyToId : undefined, replyQuoteText: opts.replyQuoteText, thread: opts.thread, textMode: "html", plainText: chunk.text, linkPreview: opts.linkPreview, replyMarkup: i === 0 ? opts.replyMarkup : undefined, }); if (opts.replyToId && !hasReplied) { hasReplied = true; } } return hasReplied; } function buildTelegramSendParams(opts?: { replyToMessageId?: number; thread?: TelegramThreadSpec | null; }): Record { const threadParams = buildTelegramThreadParams(opts?.thread); const params: Record = {}; if (opts?.replyToMessageId) { params.reply_to_message_id = opts.replyToMessageId; } if (threadParams) { params.message_thread_id = threadParams.message_thread_id; } return params; } async function sendTelegramText( bot: Bot, chatId: string, text: string, runtime: RuntimeEnv, opts?: { replyToMessageId?: number; replyQuoteText?: string; thread?: TelegramThreadSpec | null; textMode?: "markdown" | "html"; plainText?: string; linkPreview?: boolean; replyMarkup?: ReturnType; }, ): Promise { const baseParams = buildTelegramSendParams({ replyToMessageId: opts?.replyToMessageId, thread: opts?.thread, }); // Add link_preview_options when link preview is disabled. const linkPreviewEnabled = opts?.linkPreview ?? true; const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; const textMode = opts?.textMode ?? "markdown"; const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); try { const res = await withTelegramApiErrorLogging({ operation: "sendMessage", runtime, shouldLog: (err) => !PARSE_ERR_RE.test(formatErrorMessage(err)), fn: () => bot.api.sendMessage(chatId, htmlText, { parse_mode: "HTML", ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), ...baseParams, }), }); return res.message_id; } catch (err) { const errText = formatErrorMessage(err); if (PARSE_ERR_RE.test(errText)) { runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`); const fallbackText = opts?.plainText ?? text; const res = await withTelegramApiErrorLogging({ operation: "sendMessage", runtime, fn: () => bot.api.sendMessage(chatId, fallbackText, { ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}), ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), ...baseParams, }), }); return res.message_id; } throw err; } }