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();