diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index fa7e42fc9..cb6356061 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -1,5 +1,6 @@ import { type Bot, InputFile } from "grammy"; import { markdownToTelegramChunks, markdownToTelegramHtml } from "../format.js"; +import { splitTelegramCaption } from "../caption.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyToMode } from "../../config/config.js"; import { danger, logVerbose } from "../../globals.js"; @@ -16,10 +17,6 @@ import type { TelegramContext } from "./types.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; -// Telegram limits media captions to 1024 characters. -// Text beyond this must be sent as a separate follow-up message. -const TELEGRAM_MAX_CAPTION_LENGTH = 1024; - export async function deliverReplies(params: { replies: ReplyPayload[]; chatId: string; @@ -73,6 +70,7 @@ export async function deliverReplies(params: { // (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); const kind = mediaKindFromMime(media.contentType ?? undefined); const isGif = isGifMedia({ @@ -82,11 +80,11 @@ export async function deliverReplies(params: { 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 rawCaption = first ? (reply.text ?? undefined) : undefined; - const captionTooLong = rawCaption != null && rawCaption.length > TELEGRAM_MAX_CAPTION_LENGTH; - const caption = captionTooLong ? undefined : rawCaption; - if (captionTooLong && rawCaption) { - pendingFollowUpText = rawCaption; + const { caption, followUpText } = splitTelegramCaption( + isFirstMedia ? (reply.text ?? undefined) : undefined, + ); + if (followUpText) { + pendingFollowUpText = followUpText; } first = false; const replyToMessageId = @@ -138,22 +136,26 @@ export async function deliverReplies(params: { if (replyToId && !hasReplied) { hasReplied = true; } - } - // Send deferred follow-up text when caption was too long for media. - // Chunk it in case it's extremely long (same logic as text-only replies). - if (pendingFollowUpText) { - const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit); - for (const chunk of chunks) { - await sendTelegramText(bot, chatId, chunk.html, runtime, { - replyToMessageId: - replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined, - messageThreadId, - textMode: "html", - plainText: chunk.text, - }); - 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 = markdownToTelegramChunks(pendingFollowUpText, textLimit); + for (const chunk of chunks) { + const replyToMessageIdFollowup = + replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; + await bot.api.sendMessage( + chatId, + chunk.text, + buildTelegramSendParams({ + replyToMessageId: replyToMessageIdFollowup, + messageThreadId, + }), + ); + if (replyToId && !hasReplied) { + hasReplied = true; + } } + pendingFollowUpText = undefined; } } } @@ -191,6 +193,21 @@ export async function resolveMedia( return { path: saved.path, contentType: saved.contentType, placeholder }; } +function buildTelegramSendParams(opts?: { + replyToMessageId?: number; + messageThreadId?: number; +}): Record { + const threadParams = buildTelegramThreadParams(opts?.messageThreadId); + 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, @@ -203,13 +220,10 @@ async function sendTelegramText( plainText?: string; }, ): Promise { - const threadParams = buildTelegramThreadParams(opts?.messageThreadId); - const baseParams: Record = { - reply_to_message_id: opts?.replyToMessageId, - }; - if (threadParams) { - baseParams.message_thread_id = threadParams.message_thread_id; - } + const baseParams = buildTelegramSendParams({ + replyToMessageId: opts?.replyToMessageId, + messageThreadId: opts?.messageThreadId, + }); const textMode = opts?.textMode ?? "markdown"; const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); try { diff --git a/src/telegram/caption.ts b/src/telegram/caption.ts new file mode 100644 index 000000000..e9981c8c4 --- /dev/null +++ b/src/telegram/caption.ts @@ -0,0 +1,15 @@ +export const TELEGRAM_MAX_CAPTION_LENGTH = 1024; + +export function splitTelegramCaption(text?: string): { + caption?: string; + followUpText?: string; +} { + const trimmed = text?.trim() ?? ""; + if (!trimmed) { + return { caption: undefined, followUpText: undefined }; + } + if (trimmed.length > TELEGRAM_MAX_CAPTION_LENGTH) { + return { caption: undefined, followUpText: trimmed }; + } + return { caption: trimmed, followUpText: undefined }; +} diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 615872c83..253db203e 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -17,6 +17,7 @@ import { loadWebMedia } from "../web/media.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramFetch } from "./fetch.js"; import { markdownToTelegramHtml } from "./format.js"; +import { splitTelegramCaption } from "./caption.js"; import { recordSentMessage } from "./sent-message-cache.js"; import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; import { resolveTelegramVoiceSend } from "./voice.js"; @@ -58,10 +59,6 @@ type TelegramReactionOpts = { const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; -// Telegram limits media captions to 1024 characters. -// Text beyond this must be sent as a separate follow-up message. -const TELEGRAM_MAX_CAPTION_LENGTH = 1024; - function resolveToken(explicit: string | undefined, params: { accountId: string; token: string }) { if (explicit?.trim()) return explicit.trim(); if (!params.token) { @@ -201,11 +198,10 @@ export async function sendMessageTelegram( }); const fileName = media.fileName ?? (isGif ? "animation.gif" : inferFilename(kind)) ?? "file"; const file = new InputFile(media.buffer, fileName); - const trimmedText = text?.trim() || ""; + const { caption, followUpText } = splitTelegramCaption(text); // If text exceeds Telegram's caption limit, send media without caption // then send text as a separate follow-up message. - const needsSeparateText = trimmedText.length > TELEGRAM_MAX_CAPTION_LENGTH; - const caption = needsSeparateText ? undefined : trimmedText || undefined; + const needsSeparateText = Boolean(followUpText); // When splitting, put reply_markup only on the follow-up text (the "main" content), // not on the media message. const mediaParams = hasThreadParams @@ -283,7 +279,7 @@ export async function sendMessageTelegram( // If text was too long for a caption, send it as a separate follow-up message. // Use plain text to match caption behavior (captions don't use HTML conversion). - if (needsSeparateText && trimmedText) { + if (needsSeparateText && followUpText) { const textParams = hasThreadParams || replyMarkup ? { @@ -294,8 +290,8 @@ export async function sendMessageTelegram( const textRes = await request( () => textParams - ? api.sendMessage(chatId, trimmedText, textParams) - : api.sendMessage(chatId, trimmedText), + ? api.sendMessage(chatId, followUpText, textParams) + : api.sendMessage(chatId, followUpText), "message", ).catch((err) => { throw wrapChatNotFound(err);