From e327a3dca1929f3ea4e2fad347ad9166b0c1694f Mon Sep 17 00:00:00 2001 From: ZOOWH Date: Fri, 10 Jul 2026 08:05:51 +0800 Subject: [PATCH] fix(telegram): deliver content instead of throwing when tag overhead fills chunk (#102999) * fix(telegram): deliver content instead of throwing when tag overhead fills chunk When HTML tag overhead (open + close tags) exceeds the chunk limit, appendText threw an error causing complete message delivery failure. Instead, force the text into the chunk so the message is still delivered even if the chunk slightly exceeds the limit. Closes #102910 * fix(telegram): strip rich formatting when tag overhead fills chunk When HTML tag overhead fills an empty chunk with no room for text, strip rich formatting and retry as plain text instead of delivering an oversized chunk or throwing. Orphan close tags from the stripped formatting are skipped to keep the output well-formed and within the chunk limit. This preserves the existing plain-text fallback paths in send.ts and rich-message.ts. Closes #102910 * fix(telegram): preserve delivery when HTML tag overhead overflows --------- Co-authored-by: Peter Steinberger --- extensions/telegram/src/format.test.ts | 15 ++++++++-- extensions/telegram/src/format.ts | 18 ++++++++--- .../telegram/src/telegram-outbound.test.ts | 30 ++++++++++++++++++- 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/extensions/telegram/src/format.test.ts b/extensions/telegram/src/format.test.ts index edf9ded2113b..e4fdc9a86479 100644 --- a/extensions/telegram/src/format.test.ts +++ b/extensions/telegram/src/format.test.ts @@ -635,8 +635,19 @@ describe("markdownToTelegramHtml", () => { expect(containsLoneSurrogate(output)).toBe(false); }); - it("fails loudly when tag overhead leaves no room for text", () => { - expect(() => splitTelegramHtmlChunks("x", 10)).toThrow(/tag overhead/i); + it("delivers content as plain text when tag overhead fills the chunk", () => { + const chunks = splitTelegramHtmlChunks("x", 10); + expect(chunks).toHaveLength(1); + expect(chunks[0]).toBe("x"); + }); + + it("keeps later formatting balanced after dropping an oversized tag scope", () => { + const oversizedLink = `first`; + const chunks = splitTelegramHtmlChunks(`${oversizedLink}second`, 20); + + expect(chunks).toEqual(["firstsecond"]); + expect(chunks.every((chunk) => chunk.length <= 20)).toBe(true); + expect(telegramHtmlToPlainTextFallback(chunks.join(""))).toBe("firstsecond"); }); it("does not split an astral char across the chunk boundary", () => { diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts index 814c971ac0cc..5ff4dce0432f 100644 --- a/extensions/telegram/src/format.ts +++ b/extensions/telegram/src/format.ts @@ -1496,6 +1496,7 @@ export function splitTelegramHtmlChunks( const chunks: string[] = []; const openTags: TelegramHtmlTag[] = []; + const suppressedTagNames: string[] = []; let current = ""; let currentBlockCount = 0; let currentMediaCount = 0; @@ -1523,9 +1524,13 @@ export function splitTelegramHtmlChunks( normalizedLimit - current.length - buildTelegramHtmlCloseSuffixLength(openTags); if (available <= 0) { if (!chunkHasPayload) { - throw new Error( - `Telegram HTML chunk limit exceeded by tag overhead (limit=${normalizedLimit})`, - ); + // Preserve the matching closes separately when tag overhead alone + // fills a chunk. Dropping only this active scope keeps later tags + // balanced while the affected text degrades to plain HTML content. + suppressedTagNames.push(...openTags.map((tag) => tag.name)); + openTags.length = 0; + resetCurrent(); + continue; } flushCurrent(); continue; @@ -1590,7 +1595,12 @@ export function splitTelegramHtmlChunks( } } - current += rawTag; + const closesOpenTag = isClosing && openTags.some((tag) => tag.name === tagName); + const closesSuppressedTag = + isClosing && !closesOpenTag && popLastTagName(suppressedTagNames, tagName); + if (!closesSuppressedTag) { + current += rawTag; + } if (isSelfClosing) { chunkHasPayload = true; } diff --git a/extensions/telegram/src/telegram-outbound.test.ts b/extensions/telegram/src/telegram-outbound.test.ts index dc726325e565..dafd980db88b 100644 --- a/extensions/telegram/src/telegram-outbound.test.ts +++ b/extensions/telegram/src/telegram-outbound.test.ts @@ -1,6 +1,7 @@ import { chunkMarkdownTextWithMode } from "openclaw/plugin-sdk/reply-chunking"; +import { sendTextMediaPayload } from "openclaw/plugin-sdk/reply-payload"; // Telegram tests cover telegram outbound plugin behavior. -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { splitTelegramHtmlChunks } from "./format.js"; import { telegramOutbound } from "./outbound-adapter.js"; import { clearTelegramRuntime } from "./runtime.js"; @@ -56,6 +57,33 @@ describe("telegramPlugin outbound", () => { expect(telegramOutbound.chunker?.(text, 4000)).toEqual([text]); }); + it("delivers bounded HTML through the shared payload path when tag overhead overflows", async () => { + clearTelegramRuntime(); + const oversizedLink = `first`; + const text = `${oversizedLink}second`; + const sendTelegram = vi.fn().mockResolvedValue({ messageId: "tg-1", chatId: "12345" }); + + await sendTextMediaPayload({ + channel: "telegram", + ctx: { + cfg: {}, + to: "12345", + text: "", + payload: { text }, + formatting: { parseMode: "HTML" }, + deps: { sendTelegram }, + }, + adapter: telegramOutbound, + }); + + expect(sendTelegram).toHaveBeenCalledTimes(1); + expect(sendTelegram).toHaveBeenCalledWith( + "12345", + "firstsecond", + expect.objectContaining({ textMode: "html" }), + ); + }); + it("keeps astral characters whole at positive configured chunk limits", () => { clearTelegramRuntime();