diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts
index b9c59f0e3..71acf883b 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 76ea0e787..9002245ab 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 f919a917f..acefd8f75 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 d059f950c..8d003eba3 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", () => {