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 <steipete@gmail.com>
This commit is contained in:
ZOOWH
2026-07-10 08:05:51 +08:00
committed by GitHub
parent 8dd45e864e
commit e327a3dca1
3 changed files with 56 additions and 7 deletions

View File

@@ -635,8 +635,19 @@ describe("markdownToTelegramHtml", () => {
expect(containsLoneSurrogate(output)).toBe(false);
});
it("fails loudly when tag overhead leaves no room for text", () => {
expect(() => splitTelegramHtmlChunks("<b><i><u>x</u></i></b>", 10)).toThrow(/tag overhead/i);
it("delivers content as plain text when tag overhead fills the chunk", () => {
const chunks = splitTelegramHtmlChunks("<b><i><u>x</u></i></b>", 10);
expect(chunks).toHaveLength(1);
expect(chunks[0]).toBe("x");
});
it("keeps later formatting balanced after dropping an oversized tag scope", () => {
const oversizedLink = `<a href="https://example.com/${"x".repeat(40)}">first</a>`;
const chunks = splitTelegramHtmlChunks(`${oversizedLink}<b>second</b>`, 20);
expect(chunks).toEqual(["first<b>second</b>"]);
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", () => {

View File

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

View File

@@ -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 = `<a href="https://example.com/${"x".repeat(4_000)}">first</a>`;
const text = `${oversizedLink}<b>second</b>`;
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",
"first<b>second</b>",
expect.objectContaining({ textMode: "html" }),
);
});
it("keeps astral characters whole at positive configured chunk limits", () => {
clearTelegramRuntime();