From 69c39368ec9de325e4819bb7ab0861fa9ea6a285 Mon Sep 17 00:00:00 2001 From: Ayaan Zaidi Date: Sat, 28 Feb 2026 07:58:46 +0530 Subject: [PATCH] fix: enforce telegram shared outbound chunking --- src/infra/outbound/deliver.test.ts | 24 ++++++++++++ src/infra/outbound/deliver.ts | 7 +++- src/telegram/format.ts | 58 ++++++++++++++++++++++++++--- src/telegram/format.wrap-md.test.ts | 8 ++++ 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index b9c59f0e391..71acf883b23 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -160,6 +160,30 @@ describe("deliverOutboundPayloads", () => { }); }); + it("clamps telegram text chunk size to protocol max even with higher config", async () => { + const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); + const cfg: OpenClawConfig = { + channels: { telegram: { botToken: "tok-1", textChunkLimit: 10_000 } }, + }; + const text = "<".repeat(3_000); + await withEnvAsync({ TELEGRAM_BOT_TOKEN: "" }, async () => { + await deliverOutboundPayloads({ + cfg, + channel: "telegram", + to: "123", + payloads: [{ text }], + deps: { sendTelegram }, + }); + }); + + expect(sendTelegram.mock.calls.length).toBeGreaterThan(1); + const sentHtmlChunks = sendTelegram.mock.calls + .map((call) => call[1]) + .filter((message): message is string => typeof message === "string"); + expect(sentHtmlChunks.length).toBeGreaterThan(1); + expect(sentHtmlChunks.every((message) => message.length <= 4096)).toBe(true); + }); + it("keeps payload replyToId across all chunked telegram sends", async () => { const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); await withEnvAsync({ TELEGRAM_BOT_TOKEN: "" }, async () => { diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 76ea0e78736..9002245ab3c 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -40,6 +40,7 @@ export type { NormalizedOutboundPayload } from "./payloads.js"; export { normalizeOutboundPayloads } from "./payloads.js"; const log = createSubsystemLogger("outbound/deliver"); +const TELEGRAM_TEXT_LIMIT = 4096; type SendMatrixMessage = ( to: string, @@ -314,11 +315,15 @@ async function deliverOutboundPayloadsCore( silent: params.silent, mediaLocalRoots, }); - const textLimit = handler.chunker + const configuredTextLimit = handler.chunker ? resolveTextChunkLimit(cfg, channel, accountId, { fallbackLimit: handler.textChunkLimit, }) : undefined; + const textLimit = + channel === "telegram" && typeof configuredTextLimit === "number" + ? Math.min(configuredTextLimit, TELEGRAM_TEXT_LIMIT) + : configuredTextLimit; const chunkMode = handler.chunker ? resolveChunkMode(cfg, channel, accountId) : "length"; const isSignalChannel = channel === "signal"; const signalTableMode = isSignalChannel diff --git a/src/telegram/format.ts b/src/telegram/format.ts index f919a917f9f..acefd8f75d9 100644 --- a/src/telegram/format.ts +++ b/src/telegram/format.ts @@ -241,6 +241,58 @@ export function renderTelegramHtmlText( return markdownToTelegramHtml(text, { tableMode: options.tableMode }); } +function splitTelegramChunkByHtmlLimit( + chunk: MarkdownIR, + htmlLimit: number, + renderedHtmlLength: number, +): MarkdownIR[] { + const currentTextLength = chunk.text.length; + if (currentTextLength <= 1) { + return [chunk]; + } + const proportionalLimit = Math.floor( + (currentTextLength * htmlLimit) / Math.max(renderedHtmlLength, 1), + ); + const candidateLimit = Math.min(currentTextLength - 1, proportionalLimit); + const splitLimit = + Number.isFinite(candidateLimit) && candidateLimit > 0 + ? candidateLimit + : Math.max(1, Math.floor(currentTextLength / 2)); + const split = chunkMarkdownIR(chunk, splitLimit); + if (split.length > 1) { + return split; + } + return chunkMarkdownIR(chunk, Math.max(1, Math.floor(currentTextLength / 2))); +} + +function renderTelegramChunksWithinHtmlLimit( + ir: MarkdownIR, + limit: number, +): TelegramFormattedChunk[] { + const normalizedLimit = Math.max(1, Math.floor(limit)); + const pending = chunkMarkdownIR(ir, normalizedLimit); + const rendered: TelegramFormattedChunk[] = []; + while (pending.length > 0) { + const chunk = pending.shift(); + if (!chunk) { + continue; + } + const html = wrapFileReferencesInHtml(renderTelegramHtml(chunk)); + if (html.length <= normalizedLimit || chunk.text.length <= 1) { + rendered.push({ html, text: chunk.text }); + continue; + } + const split = splitTelegramChunkByHtmlLimit(chunk, normalizedLimit, html.length); + if (split.length <= 1) { + // Worst-case safety: avoid retry loops, deliver the chunk as-is. + rendered.push({ html, text: chunk.text }); + continue; + } + pending.unshift(...split); + } + return rendered; +} + export function markdownToTelegramChunks( markdown: string, limit: number, @@ -253,11 +305,7 @@ export function markdownToTelegramChunks( blockquotePrefix: "", tableMode: options.tableMode, }); - const chunks = chunkMarkdownIR(ir, limit); - return chunks.map((chunk) => ({ - html: wrapFileReferencesInHtml(renderTelegramHtml(chunk)), - text: chunk.text, - })); + return renderTelegramChunksWithinHtmlLimit(ir, limit); } export function markdownToTelegramHtmlChunks(markdown: string, limit: number): string[] { diff --git a/src/telegram/format.wrap-md.test.ts b/src/telegram/format.wrap-md.test.ts index d059f950cae..8d003eba320 100644 --- a/src/telegram/format.wrap-md.test.ts +++ b/src/telegram/format.wrap-md.test.ts @@ -158,6 +158,14 @@ describe("markdownToTelegramChunks - file reference wrapping", () => { expect(chunks[0].html).toContain("README.md"); expect(chunks[0].html).toContain("backup.sh"); }); + + it("keeps rendered html chunks within the provided limit", () => { + const input = "<".repeat(1500); + const chunks = markdownToTelegramChunks(input, 512); + expect(chunks.length).toBeGreaterThan(1); + expect(chunks.map((chunk) => chunk.text).join("")).toBe(input); + expect(chunks.every((chunk) => chunk.html.length <= 512)).toBe(true); + }); }); describe("edge cases", () => {