refactor: share telegram caption splitting

This commit is contained in:
Peter Steinberger
2026-01-17 03:50:05 +00:00
parent 7f1f9473a0
commit 4b749f1b8f
3 changed files with 66 additions and 41 deletions

View File

@@ -1,5 +1,6 @@
import { type Bot, InputFile } from "grammy"; import { type Bot, InputFile } from "grammy";
import { markdownToTelegramChunks, markdownToTelegramHtml } from "../format.js"; import { markdownToTelegramChunks, markdownToTelegramHtml } from "../format.js";
import { splitTelegramCaption } from "../caption.js";
import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyPayload } from "../../auto-reply/types.js";
import type { ReplyToMode } from "../../config/config.js"; import type { ReplyToMode } from "../../config/config.js";
import { danger, logVerbose } from "../../globals.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; 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: { export async function deliverReplies(params: {
replies: ReplyPayload[]; replies: ReplyPayload[];
chatId: string; chatId: string;
@@ -73,6 +70,7 @@ export async function deliverReplies(params: {
// (when caption exceeds Telegram's 1024-char limit) // (when caption exceeds Telegram's 1024-char limit)
let pendingFollowUpText: string | undefined; let pendingFollowUpText: string | undefined;
for (const mediaUrl of mediaList) { for (const mediaUrl of mediaList) {
const isFirstMedia = first;
const media = await loadWebMedia(mediaUrl); const media = await loadWebMedia(mediaUrl);
const kind = mediaKindFromMime(media.contentType ?? undefined); const kind = mediaKindFromMime(media.contentType ?? undefined);
const isGif = isGifMedia({ const isGif = isGifMedia({
@@ -82,11 +80,11 @@ export async function deliverReplies(params: {
const fileName = media.fileName ?? (isGif ? "animation.gif" : "file"); const fileName = media.fileName ?? (isGif ? "animation.gif" : "file");
const file = new InputFile(media.buffer, fileName); const file = new InputFile(media.buffer, fileName);
// Caption only on first item; if text exceeds limit, defer to follow-up message. // Caption only on first item; if text exceeds limit, defer to follow-up message.
const rawCaption = first ? (reply.text ?? undefined) : undefined; const { caption, followUpText } = splitTelegramCaption(
const captionTooLong = rawCaption != null && rawCaption.length > TELEGRAM_MAX_CAPTION_LENGTH; isFirstMedia ? (reply.text ?? undefined) : undefined,
const caption = captionTooLong ? undefined : rawCaption; );
if (captionTooLong && rawCaption) { if (followUpText) {
pendingFollowUpText = rawCaption; pendingFollowUpText = followUpText;
} }
first = false; first = false;
const replyToMessageId = const replyToMessageId =
@@ -138,22 +136,26 @@ export async function deliverReplies(params: {
if (replyToId && !hasReplied) { if (replyToId && !hasReplied) {
hasReplied = true; hasReplied = true;
} }
} // Send deferred follow-up text right after the first media item.
// 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).
// Chunk it in case it's extremely long (same logic as text-only replies). if (pendingFollowUpText && isFirstMedia) {
if (pendingFollowUpText) { const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit);
const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit); for (const chunk of chunks) {
for (const chunk of chunks) { const replyToMessageIdFollowup =
await sendTelegramText(bot, chatId, chunk.html, runtime, { replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined;
replyToMessageId: await bot.api.sendMessage(
replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined, chatId,
messageThreadId, chunk.text,
textMode: "html", buildTelegramSendParams({
plainText: chunk.text, replyToMessageId: replyToMessageIdFollowup,
}); messageThreadId,
if (replyToId && !hasReplied) { }),
hasReplied = true; );
if (replyToId && !hasReplied) {
hasReplied = true;
}
} }
pendingFollowUpText = undefined;
} }
} }
} }
@@ -191,6 +193,21 @@ export async function resolveMedia(
return { path: saved.path, contentType: saved.contentType, placeholder }; return { path: saved.path, contentType: saved.contentType, placeholder };
} }
function buildTelegramSendParams(opts?: {
replyToMessageId?: number;
messageThreadId?: number;
}): Record<string, unknown> {
const threadParams = buildTelegramThreadParams(opts?.messageThreadId);
const params: Record<string, unknown> = {};
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( async function sendTelegramText(
bot: Bot, bot: Bot,
chatId: string, chatId: string,
@@ -203,13 +220,10 @@ async function sendTelegramText(
plainText?: string; plainText?: string;
}, },
): Promise<number | undefined> { ): Promise<number | undefined> {
const threadParams = buildTelegramThreadParams(opts?.messageThreadId); const baseParams = buildTelegramSendParams({
const baseParams: Record<string, unknown> = { replyToMessageId: opts?.replyToMessageId,
reply_to_message_id: opts?.replyToMessageId, messageThreadId: opts?.messageThreadId,
}; });
if (threadParams) {
baseParams.message_thread_id = threadParams.message_thread_id;
}
const textMode = opts?.textMode ?? "markdown"; const textMode = opts?.textMode ?? "markdown";
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
try { try {

15
src/telegram/caption.ts Normal file
View File

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

View File

@@ -17,6 +17,7 @@ import { loadWebMedia } from "../web/media.js";
import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAccount } from "./accounts.js";
import { resolveTelegramFetch } from "./fetch.js"; import { resolveTelegramFetch } from "./fetch.js";
import { markdownToTelegramHtml } from "./format.js"; import { markdownToTelegramHtml } from "./format.js";
import { splitTelegramCaption } from "./caption.js";
import { recordSentMessage } from "./sent-message-cache.js"; import { recordSentMessage } from "./sent-message-cache.js";
import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js";
import { resolveTelegramVoiceSend } from "./voice.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; 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 }) { function resolveToken(explicit: string | undefined, params: { accountId: string; token: string }) {
if (explicit?.trim()) return explicit.trim(); if (explicit?.trim()) return explicit.trim();
if (!params.token) { if (!params.token) {
@@ -201,11 +198,10 @@ export async function sendMessageTelegram(
}); });
const fileName = media.fileName ?? (isGif ? "animation.gif" : inferFilename(kind)) ?? "file"; const fileName = media.fileName ?? (isGif ? "animation.gif" : inferFilename(kind)) ?? "file";
const file = new InputFile(media.buffer, fileName); 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 // If text exceeds Telegram's caption limit, send media without caption
// then send text as a separate follow-up message. // then send text as a separate follow-up message.
const needsSeparateText = trimmedText.length > TELEGRAM_MAX_CAPTION_LENGTH; const needsSeparateText = Boolean(followUpText);
const caption = needsSeparateText ? undefined : trimmedText || undefined;
// When splitting, put reply_markup only on the follow-up text (the "main" content), // When splitting, put reply_markup only on the follow-up text (the "main" content),
// not on the media message. // not on the media message.
const mediaParams = hasThreadParams 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. // 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). // Use plain text to match caption behavior (captions don't use HTML conversion).
if (needsSeparateText && trimmedText) { if (needsSeparateText && followUpText) {
const textParams = const textParams =
hasThreadParams || replyMarkup hasThreadParams || replyMarkup
? { ? {
@@ -294,8 +290,8 @@ export async function sendMessageTelegram(
const textRes = await request( const textRes = await request(
() => () =>
textParams textParams
? api.sendMessage(chatId, trimmedText, textParams) ? api.sendMessage(chatId, followUpText, textParams)
: api.sendMessage(chatId, trimmedText), : api.sendMessage(chatId, followUpText),
"message", "message",
).catch((err) => { ).catch((err) => {
throw wrapChatNotFound(err); throw wrapChatNotFound(err);