feat(telegram): send rich messages as rich html (#93286)

* feat(telegram): render rich messages through rich html

* docs(telegram): teach agents rich formatting

* fix(telegram): bound rich draft payloads (#93286)

* fix(telegram): narrow rich draft payload type (#93286)

* fix(telegram): preserve rich table cell formatting (#93286)

* fix(telegram): honor rich table mode config (#93286)

* fix(telegram): default rich markdown tables (#93286)

* fix(telegram): gate rich table block mode (#93286)

* fix(telegram): normalize raw rich html limits (#93286)

* fix(telegram): preserve link preview suppression (#93286)

* fix(telegram): preserve rich markdown headings (#93286)

* fix(telegram): reject unsupported rich media sources (#93286)

* fix(telegram): honor link preview off in rich chunks (#93286)

* fix(telegram): avoid double escaping markdown media (#93286)

* fix(telegram): render markdown media via placeholders (#93286)

* fix(telegram): preserve table text in prompt context (#93286)
This commit is contained in:
Ayaan Zaidi
2026-06-15 20:38:41 +05:30
committed by GitHub
parent 767e8280ac
commit da92615816
25 changed files with 1029 additions and 396 deletions

View File

@@ -403,11 +403,11 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
<Accordion title="Rich message formatting">
Outbound text uses Telegram rich messages.
- Markdown text is sent as rich Markdown without converting it to HTML.
- Explicit HTML payloads are sent as rich HTML.
- Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML.
- Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas.
- Media captions still use Telegram HTML captions because rich messages do not replace captions.
Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
This keeps model text away from Telegram Rich Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.

View File

@@ -882,10 +882,14 @@ export const dispatchTelegramMessage = async ({
cfg,
channel: "telegram",
accountId: route.accountId,
supportsBlockTables: true,
});
const renderStreamText = (text: string) => ({
text,
richMessage: buildTelegramRichMarkdown(text),
richMessage: buildTelegramRichMarkdown(text, {
tableMode,
skipEntityDetection: telegramCfg.linkPreview === false,
}),
});
const accountBlockStreamingEnabled =
resolveChannelStreamingBlockEnabled(telegramCfg) ??

View File

@@ -951,6 +951,7 @@ export const registerTelegramNativeCommands = ({
cfg: runtimeCfg,
channel: "telegram",
accountId: route.accountId,
supportsBlockTables: true,
});
const chunkMode = nativeCommandRuntime.resolveChunkMode(
runtimeCfg,

View File

@@ -33,7 +33,11 @@ import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "../but
import { splitTelegramCaption } from "../caption.js";
import { renderTelegramHtmlText } from "../format.js";
import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js";
import { splitTelegramRichMarkdownChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js";
import {
splitTelegramRichMessageTextChunks,
TELEGRAM_RICH_TEXT_LIMIT,
type TelegramRichTextChunk,
} from "../rich-message.js";
import { buildInlineKeyboard } from "../send.js";
import { resolveTelegramVoiceSend } from "../voice.js";
import {
@@ -71,23 +75,23 @@ type TelegramReplyQuoteForSend = {
entities?: unknown[];
};
type TelegramTextChunk = {
text: string;
};
type ChunkTextFn = (markdown: string) => TelegramTextChunk[];
type ChunkTextFn = (markdown: string) => TelegramRichTextChunk[];
function buildChunkTextResolver(params: {
textLimit: number;
chunkMode: ChunkMode;
tableMode?: MarkdownTableMode;
skipEntityDetection?: boolean;
}): ChunkTextFn {
return (markdown: string) => {
return splitTelegramRichMarkdownChunks(markdown, params.textLimit, params.chunkMode).map(
(text) => ({
text,
}),
);
return splitTelegramRichMessageTextChunks({
text: markdown,
textLimit: params.textLimit,
textMode: "markdown",
chunkMode: params.chunkMode,
tableMode: params.tableMode,
skipEntityDetection: params.skipEntityDetection,
});
};
}
@@ -156,6 +160,7 @@ async function deliverTextReply(params: {
replyQuoteEntities?: unknown[];
linkPreview?: boolean;
silent?: boolean;
tableMode?: MarkdownTableMode;
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
@@ -183,8 +188,9 @@ async function deliverTextReply(params: {
replyQuotePosition: params.replyQuotePosition,
replyQuoteEntities: params.replyQuoteEntities,
thread: params.thread,
textMode: "markdown",
textMode: chunk.textMode,
linkPreview: params.linkPreview,
tableMode: params.tableMode,
silent: params.silent,
replyMarkup,
},
@@ -207,6 +213,7 @@ async function sendPendingFollowUpText(params: {
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
linkPreview?: boolean;
silent?: boolean;
tableMode?: MarkdownTableMode;
replyToId?: number;
replyToMode: ReplyToMode;
progress: DeliveryProgress;
@@ -223,8 +230,9 @@ async function sendPendingFollowUpText(params: {
await sendTelegramText(params.bot, params.chatId, chunk.text, params.runtime, {
replyToMessageId,
thread: params.thread,
textMode: "markdown",
textMode: chunk.textMode,
linkPreview: params.linkPreview,
tableMode: params.tableMode,
silent: params.silent,
replyMarkup,
});
@@ -261,7 +269,7 @@ async function sendTelegramVoiceFallbackText(opts: {
chatId: string;
runtime: RuntimeEnv;
text: string;
chunkText: (markdown: string) => TelegramTextChunk[];
chunkText: ChunkTextFn;
replyToId?: number;
replyQuoteMessageId?: number;
replyQuotePosition?: number;
@@ -269,6 +277,7 @@ async function sendTelegramVoiceFallbackText(opts: {
thread?: TelegramThreadSpec | null;
linkPreview?: boolean;
silent?: boolean;
tableMode?: MarkdownTableMode;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
replyQuoteText?: string;
}): Promise<number | undefined> {
@@ -286,8 +295,9 @@ async function sendTelegramVoiceFallbackText(opts: {
replyQuotePosition: applyQuoteForChunk ? opts.replyQuotePosition : undefined,
replyQuoteEntities: applyQuoteForChunk ? opts.replyQuoteEntities : undefined,
thread: opts.thread,
textMode: "markdown",
textMode: chunk.textMode,
linkPreview: opts.linkPreview,
tableMode: opts.tableMode,
silent: opts.silent,
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
});
@@ -552,6 +562,7 @@ async function deliverMediaReply(params: {
replyMarkup: params.replyMarkup,
linkPreview: params.linkPreview,
silent: params.silent,
tableMode: params.tableMode,
replyToId: params.replyToId,
replyToMode: params.replyToMode,
progress: params.progress,
@@ -717,6 +728,7 @@ export async function deliverReplies(params: {
textLimit: Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT),
chunkMode: params.chunkMode ?? "length",
tableMode: params.tableMode,
skipEntityDetection: params.linkPreview === false,
});
const candidateReplies: ReplyPayload[] = [];
for (const reply of params.replies) {
@@ -837,6 +849,7 @@ export async function deliverReplies(params: {
replyQuoteEntities: replyQuote.entities,
linkPreview: params.linkPreview,
silent: params.silent,
tableMode: params.tableMode,
replyToId,
replyToMode: params.replyToMode,
progress,

View File

@@ -1,5 +1,6 @@
// Telegram plugin module implements delivery.send behavior.
import { type Bot, GrammyError } from "grammy";
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import { createTelegramRetryRunner } from "openclaw/plugin-sdk/retry-runtime";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -100,6 +101,7 @@ export async function sendTelegramText(
thread?: TelegramThreadSpec | null;
textMode?: "markdown" | "html";
linkPreview?: boolean;
tableMode?: MarkdownTableMode;
silent?: boolean;
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
},
@@ -117,6 +119,7 @@ export async function sendTelegramText(
const textMode = opts?.textMode ?? "markdown";
const richMessage = buildTelegramRichMessage(text, textMode, {
skipEntityDetection: opts?.linkPreview === false,
tableMode: opts?.tableMode,
});
const richRawApi = getTelegramRichRawApi(bot.api);

View File

@@ -790,6 +790,7 @@ export const telegramPlugin = createChatChannelPlugin({
},
},
messaging: {
defaultMarkdownTableMode: "block",
targetPrefixes: ["telegram", "tg"],
normalizeTarget: normalizeTelegramMessagingTarget,
resolveInboundConversation: ({ to, conversationId, threadId }) =>

View File

@@ -2,6 +2,7 @@
import type { Bot } from "grammy";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTelegramDraftStream } from "./draft-stream.js";
import { markdownToTelegramRichHtml } from "./format.js";
type TelegramDraftStreamParams = Parameters<typeof createTelegramDraftStream>[0];
@@ -48,7 +49,7 @@ async function expectInitialForumSend(
await vi.waitFor(() =>
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: { markdown: text },
rich_message: { html: markdownToTelegramRichHtml(text) },
message_thread_id: 99,
}),
);
@@ -61,7 +62,7 @@ function expectRichSend(
) {
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: { markdown: text },
rich_message: { html: markdownToTelegramRichHtml(text) },
...params,
});
}
@@ -74,7 +75,7 @@ function expectNthRichSend(
) {
expect(api.raw.sendRichMessage).toHaveBeenNthCalledWith(call, {
chat_id: 123,
rich_message: { markdown: text },
rich_message: { html: markdownToTelegramRichHtml(text) },
...params,
});
}
@@ -83,7 +84,7 @@ function expectRichEdit(api: ReturnType<typeof createMockDraftApi>, text: string
expect(api.raw.editMessageText).toHaveBeenCalledWith({
chat_id: 123,
message_id: 17,
rich_message: { markdown: text },
rich_message: { html: markdownToTelegramRichHtml(text) },
});
}
@@ -396,7 +397,7 @@ describe("createTelegramDraftStream", () => {
expect(api.raw.editMessageText).not.toHaveBeenCalledWith({
chat_id: 123,
message_id: 17,
rich_message: { markdown: "Message B partial" },
rich_message: { html: markdownToTelegramRichHtml("Message B partial") },
});
});
@@ -471,7 +472,7 @@ describe("createTelegramDraftStream", () => {
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
chat_id: 123,
message_id: 17,
rich_message: { markdown: "Hello more" },
rich_message: { html: markdownToTelegramRichHtml("Hello more") },
});
expect(warn).not.toHaveBeenCalled();
});
@@ -498,7 +499,7 @@ describe("createTelegramDraftStream", () => {
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
chat_id: 123,
message_id: 17,
rich_message: { markdown: "Hello again" },
rich_message: { html: markdownToTelegramRichHtml("Hello again") },
});
expect(stream.lastDeliveredText?.()).toBe("Hello again");
});
@@ -530,7 +531,7 @@ describe("createTelegramDraftStream", () => {
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
chat_id: 123,
message_id: 17,
rich_message: { markdown: "Hello more" },
rich_message: { html: markdownToTelegramRichHtml("Hello more") },
});
} finally {
vi.useRealTimers();
@@ -637,7 +638,7 @@ describe("createTelegramDraftStream", () => {
chatId: 123,
renderText: (value) => ({
text: value,
richMessage: { markdown: value },
richMessage: { html: markdownToTelegramRichHtml(value) },
}),
});
@@ -646,7 +647,7 @@ describe("createTelegramDraftStream", () => {
expect(richApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: { markdown: text.trimEnd() },
rich_message: { html: markdownToTelegramRichHtml(text.trimEnd()) },
});
expect(api.sendMessage).not.toHaveBeenCalled();
});
@@ -797,7 +798,7 @@ describe("createTelegramDraftStream", () => {
chatId: 123,
maxChars: 100,
renderText: () => ({
text: `<b>${"<".repeat(120)}</b>`,
text: "short raw text",
richMessage: { html: `<b>${"<".repeat(120)}</b>` },
}),
warn,

View File

@@ -79,6 +79,11 @@ function telegramDraftPreviewKey(preview: TelegramDraftPreview): string {
return JSON.stringify(preview.richMessage);
}
function telegramDraftPreviewPayloadLength(preview: TelegramDraftPreview): number {
const richMessage = preview.richMessage;
return richMessage.html !== undefined ? richMessage.html.length : richMessage.markdown.length;
}
function findTelegramDraftChunkLength(
text: string,
maxChars: number,
@@ -89,8 +94,8 @@ function findTelegramDraftChunkLength(
let high = text.length;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const renderedText = renderTelegramDraftPreview(text.slice(0, mid), renderText).text.trimEnd();
if (renderedText && renderedText.length <= maxChars) {
const preview = renderTelegramDraftPreview(text.slice(0, mid), renderText);
if (preview.text.trimEnd() && telegramDraftPreviewPayloadLength(preview) <= maxChars) {
best = mid;
low = mid + 1;
} else {
@@ -206,11 +211,9 @@ export function createTelegramDraftStream(params: {
streamVisibleSinceMs = visibleSinceMs;
return true;
};
const stopOversizedPreview = (renderedText: string): false => {
const stopOversizedPreview = (payloadLength: number): false => {
streamState.stopped = true;
params.warn?.(
`telegram stream preview stopped (text length ${renderedText.length} > ${maxChars})`,
);
params.warn?.(`telegram stream preview stopped (text length ${payloadLength} > ${maxChars})`);
return false;
};
@@ -239,10 +242,11 @@ export function createTelegramDraftStream(params: {
const renderedText = rendered.text.trimEnd();
const renderedPreview = { ...rendered, text: renderedText };
const renderedPreviewKey = telegramDraftPreviewKey(renderedPreview);
const renderedPayloadLength = telegramDraftPreviewPayloadLength(renderedPreview);
if (!renderedText) {
return false;
}
if (renderedText.length > maxChars) {
if (renderedPayloadLength > maxChars) {
const chunkLength = findTelegramDraftChunkLength(currentText, maxChars, params.renderText);
if (!streamState.final) {
if (chunkLength > 0) {
@@ -250,7 +254,7 @@ export function createTelegramDraftStream(params: {
trimmed.slice(0, deliveredTextOffset) + currentText.slice(0, chunkLength),
);
}
return stopOversizedPreview(renderedText);
return stopOversizedPreview(renderedPayloadLength);
}
if (lastDeliveredText.length > deliveredTextOffset) {
const supersededMessageId = streamMessageId;
@@ -277,7 +281,7 @@ export function createTelegramDraftStream(params: {
}
return await sendOrEditStreamMessage(trimmed);
}
return stopOversizedPreview(renderedText);
return stopOversizedPreview(renderedPayloadLength);
}
if (renderedPreviewKey === lastSentPreviewKey) {
return true;

View File

@@ -3,7 +3,9 @@ import { describe, expect, it } from "vitest";
import {
markdownToTelegramChunks,
markdownToTelegramHtml,
markdownToTelegramRichHtml,
renderTelegramHtmlText,
sanitizeTelegramRichHtml,
splitTelegramHtmlChunks,
telegramHtmlToPlainTextFallback,
} from "./format.js";
@@ -76,11 +78,140 @@ describe("markdownToTelegramHtml", () => {
expect(markdownToTelegramHtml('<blockquote cite="x">bad</blockquote>')).toBe(
'&lt;blockquote cite="x"&gt;bad&lt;/blockquote&gt;',
);
expect(markdownToTelegramHtml("<sup>1</sup>")).toBe("&lt;sup&gt;1&lt;/sup&gt;");
expect(renderTelegramHtmlText('<b class="x">bad</b>', { textMode: "html" })).toBe(
'&lt;b class="x"&gt;bad&lt;/b&gt;',
);
});
it("preserves rich-only Telegram HTML tags on the rich path", () => {
expect(markdownToTelegramRichHtml("<sup>1</sup>")).toBe("<sup>1</sup>");
});
it("preserves rich table, details, quote, checklist, anchor, and math HTML", () => {
const input = [
'<a name="top"></a>',
"<h2>Plan</h2>",
'<table bordered striped><caption>Scores</caption><thead><tr><th align="left">Name</th><th align="right" colspan="2">Total</th></tr></thead><tbody><tr><td>A</td><td align="right">1</td><td>2</td></tr></tbody></table>',
"<details><summary>More</summary><p>Hidden</p></details>",
"<aside>Pull quote<cite>Source</cite></aside>",
'<ul><li><input type="checkbox" checked/>Done</li><li><input type="checkbox"/>Todo</li></ul>',
'<p><a href="#top">Back</a> H<sub>2</sub>O E=mc<sup>2</sup> <mark>note</mark> <tg-spoiler>secret</tg-spoiler> <tg-math>E=mc^2</tg-math></p>',
"<tg-math-block>\\int_0^1 x^2 dx</tg-math-block>",
].join("\n");
expect(markdownToTelegramRichHtml(input)).toBe(input);
});
it("isolates rich media tags as blocks", () => {
const html = markdownToTelegramRichHtml(
'One <img src="https://example.com/a.jpg" alt="A"> two https://example.com/page',
);
expect(html).toContain(
'\n\n<figure><img src="https://example.com/a.jpg" alt="A"/></figure>\n\n',
);
expect(html).toContain('<a href="https://example.com/page">https://example.com/page</a>');
expect(html).not.toContain("&lt;img");
expect(html).not.toContain('<a href="https://example.com/a.jpg">');
});
it("escapes rich media tags without supported http sources", () => {
expect(markdownToTelegramRichHtml('<img src="logo.png" alt="Logo">')).toBe(
'&lt;img src="logo.png" alt="Logo"&gt;',
);
expect(markdownToTelegramRichHtml('<audio src="data:audio/wav;base64,x"></audio>')).toBe(
'&lt;audio src="data:audio/wav;base64,x"&gt;&lt;/audio&gt;',
);
expect(markdownToTelegramRichHtml('<video src="https://example.com/a.mp4"></video>')).toBe(
'<figure><video src="https://example.com/a.mp4"></video></figure>',
);
});
it("renders Markdown media blocks on the rich HTML fallback path", () => {
expect(markdownToTelegramRichHtml('![Diagram](https://example.com/a.jpg "Caption")')).toBe(
'<figure><img src="https://example.com/a.jpg" alt="Diagram"/><figcaption>Caption</figcaption></figure>',
);
expect(
markdownToTelegramRichHtml('![A "quote"](https://cdn.example/img.png?token=a&expires=b)'),
).toBe(
'<figure><img src="https://cdn.example/img.png?token=a&amp;expires=b" alt="A &quot;quote&quot;"/></figure>',
);
expect(markdownToTelegramRichHtml("![A > B](https://example.com/a.png)")).toBe(
'<figure><img src="https://example.com/a.png" alt="A &gt; B"/></figure>',
);
expect(markdownToTelegramRichHtml("See ![Diagram](https://example.com/a.jpg).")).toBe(
'See <a href="https://example.com/a.jpg">Diagram</a>.',
);
expect(markdownToTelegramRichHtml("```\n![](https://example.com/a.jpg)\n```")).toBe(
"<pre><code>![](https://example.com/a.jpg)\n</code></pre>",
);
});
it("renders rich tables and falls back when they exceed Telegram's column limit", () => {
const table = (columns: number) =>
[
`| ${Array.from({ length: columns }, (_, index) => `H${index + 1}`).join(" | ")} |`,
`| ${Array.from({ length: columns }, () => "---").join(" | ")} |`,
`| ${Array.from({ length: columns }, (_, index) => String(index + 1)).join(" | ")} |`,
].join("\n");
expect(markdownToTelegramRichHtml(table(20))).toContain("<table>");
expect(markdownToTelegramRichHtml(table(21))).toContain("<pre><code>");
expect(markdownToTelegramRichHtml(table(2), { tableMode: "code" })).toContain("<pre><code>");
expect(markdownToTelegramRichHtml(table(2), { tableMode: "code" })).not.toContain("<table>");
});
it("falls back over-wide raw rich HTML tables", () => {
const cells = Array.from({ length: 21 }, (_, index) => `<td>C${index + 1}</td>`).join("");
const html = `<table><caption>Wide</caption><tbody><tr>${cells}</tr></tbody></table>`;
const sanitized = sanitizeTelegramRichHtml(html);
expect(sanitized).toContain("<pre><code>Wide");
expect(sanitized).toContain("C21");
expect(sanitized).not.toContain("<table>");
});
it("clamps raw rich HTML table colspans before fallback", () => {
const html = '<table><tbody><tr><td colspan="1000000000">x</td></tr></tbody></table>';
const sanitized = sanitizeTelegramRichHtml(html);
expect(sanitized).toContain("<pre><code>");
expect(sanitized.length).toBeLessThan(300);
});
it("renders block-mode tables as code in legacy Telegram HTML", () => {
const table = "| A | B |\n| --- | --- |\n| 1 | 2 |";
expect(markdownToTelegramHtml(table, { tableMode: "block" })).toBe(
"<pre><code>| A | B |\n| --- | --- |\n| 1 | 2 |\n</code></pre>",
);
});
it("preserves inline markdown inside rich table cells", () => {
const html = markdownToTelegramRichHtml(
"| Name | Link |\n| --- | --- |\n| **API** | [docs](https://example.com) |",
);
expect(html).toContain("<td><b>API</b></td>");
expect(html).toContain('<td><a href="https://example.com">docs</a></td>');
});
it("does not auto-linkify bare URLs when entity detection is skipped", () => {
expect(markdownToTelegramRichHtml("https://example.com", { skipEntityDetection: true })).toBe(
"https://example.com",
);
expect(
markdownToTelegramRichHtml("[docs](https://example.com)", { skipEntityDetection: true }),
).toBe('<a href="https://example.com">docs</a>');
});
it("preserves Markdown heading levels in rich HTML", () => {
expect(markdownToTelegramRichHtml("# Title\n\n### Detail")).toBe(
"<h1>Title</h1>\n\n<h3>Detail</h3>",
);
});
it("normalizes raw code language HTML without leaking tags", () => {
const commandBlock = '<code class="language-text">/queue followup debounce:0\n</code>';
@@ -240,6 +371,16 @@ describe("markdownToTelegramHtml", () => {
expect(chunks[1]).toMatch(/^<b>[\s\S]*<\/b>$/);
});
it("does not synthesize closing tags for rich void tags when chunking html", () => {
const chunks = splitTelegramHtmlChunks(
`<figure><img src="https://example.com/a.jpg"></figure><ul><li><input type="checkbox" checked>${"A".repeat(80)}</li></ul>`,
64,
);
expect(chunks.join("")).not.toContain("</img>");
expect(chunks.join("")).not.toContain("</input>");
});
it("fails loudly when a leading entity cannot fit inside a chunk", () => {
expect(() => splitTelegramHtmlChunks(`A&amp;${"B".repeat(20)}`, 4)).toThrow(/leading entity/i);
});
@@ -272,6 +413,14 @@ describe("markdownToTelegramHtml", () => {
).toBe("Task <id> (https://example.com/task?id=1&kind=bug)");
});
it("preserves table cell boundaries in Telegram HTML fallback text", () => {
expect(
telegramHtmlToPlainTextFallback(
"<table><thead><tr><th>Name</th><th>Age</th></tr></thead><tbody><tr><td>Alice</td><td>30</td></tr></tbody></table>",
),
).toBe("Name | Age\nAlice | 30");
});
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);
});

View File

@@ -5,11 +5,15 @@ import {
FILE_REF_EXTENSIONS_WITH_TLD,
isAutoLinkedFileRef,
markdownToIR,
markdownToIRWithMeta,
type MarkdownLinkSpan,
type MarkdownIR,
type MarkdownTableCell,
type MarkdownTableMeta,
renderMarkdownIRChunksWithinLimit,
renderMarkdownWithMarkers,
sliceMarkdownIR,
} from "openclaw/plugin-sdk/text-chunking";
import { renderMarkdownWithMarkers } from "openclaw/plugin-sdk/text-chunking";
export type TelegramFormattedChunk = {
html: string;
@@ -78,6 +82,12 @@ function renderTelegramHtml(ir: MarkdownIR): string {
code_block: { open: buildTelegramCodeBlockOpen, close: "</code></pre>" },
spoiler: { open: "<tg-spoiler>", close: "</tg-spoiler>" },
blockquote: { open: "<blockquote>", close: "</blockquote>" },
heading_1: { open: "<h1>", close: "</h1>" },
heading_2: { open: "<h2>", close: "</h2>" },
heading_3: { open: "<h3>", close: "</h3>" },
heading_4: { open: "<h4>", close: "</h4>" },
heading_5: { open: "<h5>", close: "</h5>" },
heading_6: { open: "<h6>", close: "</h6>" },
},
escapeText: escapeHtml,
buildLink: buildTelegramLink,
@@ -140,12 +150,13 @@ export function markdownToTelegramHtml(
markdown: string,
options: { tableMode?: MarkdownTableMode; wrapFileRefs?: boolean } = {},
): string {
const tableMode = options.tableMode === "block" ? "code" : options.tableMode;
const ir = markdownToIR(preserveTelegramListBoundarySpacing(markdown ?? ""), {
linkify: true,
enableSpoilers: true,
headingStyle: "none",
blockquotePrefix: "",
tableMode: options.tableMode,
tableMode,
});
const html = renderTelegramHtml(ir);
const telegramHtml = preserveSupportedTelegramHtmlTags(html);
@@ -178,6 +189,19 @@ const TELEGRAM_HTML_ANCHOR_PATTERN =
const TELEGRAM_HTML_BREAK_PATTERN = /<br\s*\/?>/gi;
const TELEGRAM_HTML_ENTITY_PATTERN = /&(#x[0-9A-Fa-f]+|#\d+|amp|lt|gt|quot|apos);/g;
const TELEGRAM_HTML_TAG_PATTERN = /<[^>]*>/g;
const TELEGRAM_RICH_MEDIA_BLOCK_PATTERN =
/[^\S\r\n]*(?:<figure\b[^>]*>[\s\S]*?<\/figure>|<tg-collage\b[^>]*>[\s\S]*?<\/tg-collage>|<tg-slideshow\b[^>]*>[\s\S]*?<\/tg-slideshow>|<img\b[^>]*\bsrc="https?:\/\/[^"]+"[^>]*\/?>|<video\b[^>]*\bsrc="https?:\/\/[^"]+"[^>]*(?:\/>|>[\s\S]*?<\/video>)|<audio\b[^>]*\bsrc="https?:\/\/[^"]+"[^>]*(?:\/>|>[\s\S]*?<\/audio>)|<tg-map\b[^>]*\/?>)[^\S\r\n]*/gi;
const TELEGRAM_RICH_HTML_TABLE_PATTERN = /<table\b[^>]*>[\s\S]*?<\/table>/gi;
const TELEGRAM_RICH_HTML_TABLE_ROW_PATTERN = /<tr\b[^>]*>([\s\S]*?)<\/tr>/gi;
const TELEGRAM_RICH_HTML_TABLE_CELL_PATTERN = /<(td|th)\b([^>]*)>([\s\S]*?)<\/\1>/gi;
const TELEGRAM_HTML_CAPTION_PATTERN = /<caption\b[^>]*>([\s\S]*?)<\/caption>/i;
const TELEGRAM_HTML_COLSPAN_PATTERN = /\bcolspan\s*=\s*(?:"(\d+)"|'(\d+)'|(\d+))/i;
const TELEGRAM_MARKDOWN_MEDIA_BLOCK_PATTERN =
/^([ \t]*)!\[([^\]\n]*)\]\((https?:\/\/[^\s)"]+)(?:\s+"([^"\n]*)")?\)[ \t]*$/;
const TELEGRAM_MARKDOWN_INLINE_IMAGE_PATTERN = /!\[([^\]\n]*)\]\(([^)\n]+)\)/g;
const TELEGRAM_MARKDOWN_REFERENCE_IMAGE_PATTERN = /!\[([^\]\n]*)\]\[([^\]\n]+)\]/g;
const TELEGRAM_MARKDOWN_MEDIA_PLACEHOLDER_PREFIX = "\uE000telegram-media:";
const TELEGRAM_MARKDOWN_MEDIA_PLACEHOLDER_SUFFIX = "\uE001";
const TELEGRAM_SIMPLE_HTML_TAGS = new Set([
"b",
"strong",
@@ -200,9 +224,100 @@ const TELEGRAM_ATTR_HTML_TAG_PATTERNS = new Map([
["blockquote", /^(\s+expandable)?\s*$/],
]);
const TELEGRAM_CODE_LANGUAGE_ATTR_PATTERN = /^\s+class="language-[^"]+"\s*$/;
const TELEGRAM_RICH_TEXT_TABLE_COLUMN_LIMIT = 20;
const TELEGRAM_VOID_HTML_TAGS = new Set(["br", "hr", "img", "input", "tg-map"]);
const TELEGRAM_RICH_SIMPLE_HTML_TAGS = new Set([
...TELEGRAM_SIMPLE_HTML_TAGS,
"a",
"aside",
"audio",
"blockquote",
"br",
"caption",
"cite",
"details",
"figcaption",
"figure",
"footer",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"li",
"mark",
"ol",
"p",
"sub",
"summary",
"sup",
"table",
"tbody",
"td",
"tg-collage",
"tg-math",
"tg-math-block",
"tg-slideshow",
"th",
"thead",
"tr",
"ul",
"video",
]);
const TELEGRAM_RICH_ATTR_HTML_TAG_PATTERNS = new Map([
...TELEGRAM_ATTR_HTML_TAG_PATTERNS,
["a", /^\s+(?:href|name)="[^"]+"\s*$/],
[
"audio",
/^(?=.*\ssrc="https?:\/\/[^"]+")(?:\s+src="https?:\/\/[^"]+"|\s+title="[^"]*")*\s*\/?\s*$/,
],
["details", /^\s+open\s*$/],
["figure", /^\s+tg-spoiler\s*$/],
[
"img",
/^(?=.*\ssrc="https?:\/\/[^"]+")(?:\s+src="https?:\/\/[^"]+"|\s+(?:alt|title)="[^"]*"|\s+tg-spoiler)*\s*\/?\s*$/,
],
["input", /^\s+type="checkbox"(?:\s+checked)?\s*\/?\s*$/],
["li", /^(?:\s+(?:value|type)="[^"]*")*\s*$/],
["ol", /^(?:\s+(?:start|type)="[^"]*"|\s+reversed)*\s*$/],
["table", /^(?:\s+(?:bordered|striped))*\s*$/],
[
"td",
/^(?:\s+(?:colspan|rowspan)="[1-9]\d*"|\s+align="(?:left|center|right)"|\s+valign="(?:top|middle|bottom)")*\s*$/,
],
["tg-emoji", /^\s+emoji-id="[^"]+"\s*$/],
["tg-map", /^\s+lat="[^"]+"\s+long="[^"]+"(?:\s+zoom="[^"]+")?\s*\/?\s*$/],
["tg-reference", /^\s+name="[^"]+"\s*$/],
["tg-time", /^\s+unix="[^"]+"(?:\s+format="[^"]+")?\s*$/],
[
"th",
/^(?:\s+(?:colspan|rowspan)="[1-9]\d*"|\s+align="(?:left|center|right)"|\s+valign="(?:top|middle|bottom)")*\s*$/,
],
[
"video",
/^(?=.*\ssrc="https?:\/\/[^"]+")(?:\s+src="https?:\/\/[^"]+"|\s+title="[^"]*"|\s+tg-spoiler)*\s*\/?\s*$/,
],
]);
let fileReferencePattern: RegExp | undefined;
let orphanedTldPattern: RegExp | undefined;
type TelegramHtmlTagSupport = {
simpleTags: ReadonlySet<string>;
attrPatterns: ReadonlyMap<string, RegExp>;
};
const TELEGRAM_LEGACY_HTML_TAG_SUPPORT: TelegramHtmlTagSupport = {
simpleTags: TELEGRAM_SIMPLE_HTML_TAGS,
attrPatterns: TELEGRAM_ATTR_HTML_TAG_PATTERNS,
};
const TELEGRAM_RICH_HTML_TAG_SUPPORT: TelegramHtmlTagSupport = {
simpleTags: TELEGRAM_RICH_SIMPLE_HTML_TAGS,
attrPatterns: TELEGRAM_RICH_ATTR_HTML_TAG_PATTERNS,
};
function popLastTagName(tags: string[], name: string): boolean {
for (let index = tags.length - 1; index >= 0; index -= 1) {
if (tags[index] === name) {
@@ -213,7 +328,7 @@ function popLastTagName(tags: string[], name: string): boolean {
return false;
}
function isSupportedTelegramHtmlTag(rawTag: string): boolean {
function isSupportedTelegramHtmlTag(rawTag: string, support: TelegramHtmlTagSupport): boolean {
const match = HTML_MODE_TAG_PATTERN.exec(rawTag);
if (!match) {
return false;
@@ -221,13 +336,16 @@ function isSupportedTelegramHtmlTag(rawTag: string): boolean {
const closing = match[1] === "/";
const name = normalizeLowercaseStringOrEmpty(match[2]);
const attrs = match[3] ?? "";
if (TELEGRAM_SIMPLE_HTML_TAGS.has(name)) {
return attrs.trim() === "";
}
if (closing) {
return attrs.trim() === "";
return attrs.trim() === "" && (support.simpleTags.has(name) || support.attrPatterns.has(name));
}
return TELEGRAM_ATTR_HTML_TAG_PATTERNS.get(name)?.test(attrs) ?? false;
if (name === "code" && TELEGRAM_CODE_LANGUAGE_ATTR_PATTERN.test(attrs)) {
return true;
}
if (support.attrPatterns.get(name)?.test(attrs)) {
return true;
}
return support.simpleTags.has(name) && attrs.trim() === "";
}
function hasOpenTelegramHtmlTag(tags: readonly string[], name: string): boolean {
@@ -238,6 +356,7 @@ function preserveTelegramHtmlTag(
rawTag: string,
openTags: string[],
escapeTag: (rawTag: string) => string,
support: TelegramHtmlTagSupport = TELEGRAM_LEGACY_HTML_TAG_SUPPORT,
): string {
const match = HTML_MODE_TAG_PATTERN.exec(rawTag);
if (!match) {
@@ -253,17 +372,23 @@ function preserveTelegramHtmlTag(
}
return "<code>";
}
if (!isSupportedTelegramHtmlTag(rawTag)) {
if (!isSupportedTelegramHtmlTag(rawTag, support)) {
return escapeTag(rawTag);
}
if (closing) {
return popLastTagName(openTags, tagName) ? rawTag : escapeTag(rawTag);
}
if (TELEGRAM_VOID_HTML_TAGS.has(tagName) || rawTag.trimEnd().endsWith("/>")) {
return rawTag;
}
openTags.push(tagName);
return rawTag;
}
function escapeUnsupportedTelegramHtml(text: string): string {
function escapeUnsupportedTelegramHtml(
text: string,
support: TelegramHtmlTagSupport = TELEGRAM_LEGACY_HTML_TAG_SUPPORT,
): string {
let result = "";
let index = 0;
const openTags: string[] = [];
@@ -284,7 +409,7 @@ function escapeUnsupportedTelegramHtml(text: string): string {
const end = text.indexOf(">", index + 1);
if (end !== -1) {
const rawTag = text.slice(index, end + 1);
result += preserveTelegramHtmlTag(rawTag, openTags, escapeHtml);
result += preserveTelegramHtmlTag(rawTag, openTags, escapeHtml, support);
index = end + 1;
} else {
result += "&lt;";
@@ -360,8 +485,12 @@ function encodePlainTextForTelegramHtmlStrip(text: string): string {
}
export function telegramHtmlToPlainTextFallback(html: string): string {
const withPlainTables = html.replace(TELEGRAM_RICH_HTML_TABLE_PATTERN, (tableHtml) => {
const rows = parseTelegramRichHtmlTableRows(tableHtml);
return rows.map((row) => row.join(" | ")).join("\n");
});
TELEGRAM_HTML_ANCHOR_PATTERN.lastIndex = 0;
const withPlainLinks = html.replace(
const withPlainLinks = withPlainTables.replace(
TELEGRAM_HTML_ANCHOR_PATTERN,
(
_match: string,
@@ -385,16 +514,23 @@ export function telegramHtmlToPlainTextFallback(html: string): string {
return stripTelegramHtmlForPlainText(withPlainLinks);
}
function promoteEscapedSupportedTelegramTags(text: string, openTags: string[]): string {
function promoteEscapedSupportedTelegramTags(
text: string,
openTags: string[],
support: TelegramHtmlTagSupport,
): string {
ESCAPED_HTML_TAG_PATTERN.lastIndex = 0;
return text.replace(
ESCAPED_HTML_TAG_PATTERN,
(match, closing: string, name: string, attrs: string) =>
preserveTelegramHtmlTag(`<${closing}${name}${attrs}>`, openTags, () => match),
preserveTelegramHtmlTag(`<${closing}${name}${attrs}>`, openTags, () => match, support),
);
}
function preserveSupportedTelegramHtmlTags(html: string): string {
function preserveSupportedTelegramHtmlTags(
html: string,
support: TelegramHtmlTagSupport = TELEGRAM_LEGACY_HTML_TAG_SUPPORT,
): string {
let codeDepth = 0;
let preDepth = 0;
let result = "";
@@ -412,7 +548,7 @@ function preserveSupportedTelegramHtmlTags(html: string): string {
result +=
codeDepth > 0 || preDepth > 0
? textBefore
: promoteEscapedSupportedTelegramTags(textBefore, openEscapedTags);
: promoteEscapedSupportedTelegramTags(textBefore, openEscapedTags, support);
if (tagName === "code") {
codeDepth = isClosing ? Math.max(0, codeDepth - 1) : codeDepth + 1;
@@ -428,7 +564,7 @@ function preserveSupportedTelegramHtmlTags(html: string): string {
result +=
codeDepth > 0 || preDepth > 0
? remainingText
: promoteEscapedSupportedTelegramTags(remainingText, openEscapedTags);
: promoteEscapedSupportedTelegramTags(remainingText, openEscapedTags, support);
return result;
}
@@ -545,13 +681,253 @@ export function renderTelegramHtmlText(
return markdownToTelegramHtml(text, { tableMode: options.tableMode });
}
export function sanitizeTelegramRichHtml(html: string): string {
return isolateTelegramRichMediaBlocks(
normalizeWideTelegramRichHtmlTables(
escapeUnsupportedTelegramHtml(html, TELEGRAM_RICH_HTML_TAG_SUPPORT),
),
);
}
function normalizeTelegramRichMediaBlock(block: string): string {
const normalized = block
.trim()
.replace(/<img\b([^>]*?)(\s*)>/gi, (_match, attrs: string, trailing: string) =>
attrs.trimEnd().endsWith("/") ? `<img${attrs}${trailing}>` : `<img${attrs}${trailing}/>`,
);
return /^<(?:img|video|audio)\b/i.test(normalized)
? `<figure>${normalized}</figure>`
: normalized;
}
function isolateTelegramRichMediaBlocks(html: string): string {
return html
.replace(
TELEGRAM_RICH_MEDIA_BLOCK_PATTERN,
(match) => `\n\n${normalizeTelegramRichMediaBlock(match)}\n\n`,
)
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function parseTelegramHtmlColspan(attrs: string): number {
const raw = TELEGRAM_HTML_COLSPAN_PATTERN.exec(attrs)?.slice(1).find(Boolean);
const value = raw ? Number.parseInt(raw, 10) : 1;
return Number.isFinite(value) && value > 1
? Math.min(value, TELEGRAM_RICH_TEXT_TABLE_COLUMN_LIMIT + 1)
: 1;
}
function parseTelegramRichHtmlTableRows(tableHtml: string): string[][] {
const rows: string[][] = [];
TELEGRAM_RICH_HTML_TABLE_ROW_PATTERN.lastIndex = 0;
let rowMatch: RegExpExecArray | null;
while ((rowMatch = TELEGRAM_RICH_HTML_TABLE_ROW_PATTERN.exec(tableHtml)) !== null) {
const rowHtml = rowMatch[1] ?? "";
const row: string[] = [];
TELEGRAM_RICH_HTML_TABLE_CELL_PATTERN.lastIndex = 0;
let cellMatch: RegExpExecArray | null;
while ((cellMatch = TELEGRAM_RICH_HTML_TABLE_CELL_PATTERN.exec(rowHtml)) !== null) {
const attrs = cellMatch[2] ?? "";
const text = telegramHtmlToPlainTextFallback(cellMatch[3] ?? "")
.replace(/\s+/g, " ")
.trim();
row.push(text, ...Array.from({ length: parseTelegramHtmlColspan(attrs) - 1 }, () => ""));
}
if (row.length) {
rows.push(row);
}
}
return rows;
}
function renderTelegramRichHtmlRawTableFallback(
tableHtml: string,
rows: readonly string[][],
): string {
const columnCount = Math.max(...rows.map((row) => row.length), 0);
const widths = Array.from({ length: columnCount }, () => 3);
for (const row of rows) {
for (let index = 0; index < columnCount; index += 1) {
widths[index] = Math.max(widths[index] ?? 3, row[index]?.length ?? 0);
}
}
const caption = telegramHtmlToPlainTextFallback(
TELEGRAM_HTML_CAPTION_PATTERN.exec(tableHtml)?.[1] ?? "",
).trim();
const tableText = rows
.map(
(row) => `| ${widths.map((width, index) => (row[index] ?? "").padEnd(width)).join(" | ")} |`,
)
.join("\n");
return `<pre><code>${escapeHtml([caption, tableText].filter(Boolean).join("\n"))}</code></pre>\n\n`;
}
function normalizeWideTelegramRichHtmlTables(html: string): string {
TELEGRAM_RICH_HTML_TABLE_PATTERN.lastIndex = 0;
return html.replace(TELEGRAM_RICH_HTML_TABLE_PATTERN, (tableHtml) => {
const rows = parseTelegramRichHtmlTableRows(tableHtml);
const columnCount = Math.max(...rows.map((row) => row.length), 0);
return columnCount > TELEGRAM_RICH_TEXT_TABLE_COLUMN_LIMIT
? renderTelegramRichHtmlRawTableFallback(tableHtml, rows)
: tableHtml;
});
}
type TelegramRichMarkdownMediaNormalization = {
markdown: string;
mediaBlocks: string[];
};
function buildTelegramRichMarkdownMediaPlaceholder(index: number): string {
return `${TELEGRAM_MARKDOWN_MEDIA_PLACEHOLDER_PREFIX}${index}${TELEGRAM_MARKDOWN_MEDIA_PLACEHOLDER_SUFFIX}`;
}
function replaceTelegramRichMarkdownMediaPlaceholders(
html: string,
mediaBlocks: readonly string[],
): string {
let result = html;
for (const [index, block] of mediaBlocks.entries()) {
result = result.replaceAll(buildTelegramRichMarkdownMediaPlaceholder(index), block);
}
return result;
}
function normalizeTelegramRichMarkdownMedia(
markdown: string,
): TelegramRichMarkdownMediaNormalization {
const lines = markdown.split("\n");
const out: string[] = [];
const mediaBlocks: string[] = [];
let inFence = false;
for (const line of lines) {
if (/^[ \t]*(?:```|~~~)/.test(line)) {
inFence = !inFence;
out.push(line);
continue;
}
const match = inFence ? null : TELEGRAM_MARKDOWN_MEDIA_BLOCK_PATTERN.exec(line);
if (inFence) {
out.push(line);
continue;
}
if (!match) {
out.push(
line
.replace(TELEGRAM_MARKDOWN_INLINE_IMAGE_PATTERN, "[$1]($2)")
.replace(TELEGRAM_MARKDOWN_REFERENCE_IMAGE_PATTERN, "[$1][$2]"),
);
continue;
}
const [, indent, alt, src, caption] = match;
const img = `<img src="${escapeHtmlAttr(src)}"${alt ? ` alt="${escapeHtmlAttr(alt)}"` : ""}/>`;
const figcaption = caption ? `<figcaption>${escapeHtml(caption)}</figcaption>` : "";
const placeholder = buildTelegramRichMarkdownMediaPlaceholder(mediaBlocks.length);
mediaBlocks.push(`<figure>${img}${figcaption}</figure>`);
out.push(`${indent}${placeholder}`);
}
return { markdown: out.join("\n"), mediaBlocks };
}
function renderTelegramRichHtmlTableFallback(table: MarkdownTableMeta): string {
const rows = [table.headers, ...table.rows];
const columnCount = Math.max(...rows.map((row) => row.length), 0);
const widths = Array.from({ length: columnCount }, () => 3);
for (const row of rows) {
for (let index = 0; index < columnCount; index += 1) {
widths[index] = Math.max(widths[index] ?? 3, row[index]?.length ?? 0);
}
}
const renderRow = (row: readonly string[]) =>
`| ${widths.map((width, index) => (row[index] ?? "").padEnd(width)).join(" | ")} |`;
const divider = `| ${widths.map((width) => "-".repeat(width)).join(" | ")} |`;
const tableText = [renderRow(table.headers), divider, ...table.rows.map(renderRow)].join("\n");
return `<pre><code>${escapeHtml(tableText)}</code></pre>\n\n`;
}
function renderTelegramRichHtmlTable(table: MarkdownTableMeta): string {
const columnCount = Math.max(table.headers.length, ...table.rows.map((row) => row.length), 0);
if (columnCount > TELEGRAM_RICH_TEXT_TABLE_COLUMN_LIMIT) {
return renderTelegramRichHtmlTableFallback(table);
}
const renderCellValue = (cell: MarkdownTableCell | undefined) =>
cell ? renderTelegramHtml(cell) : "";
const renderCell = (tag: "td" | "th", value: MarkdownTableCell | undefined) =>
`<${tag}>${renderCellValue(value)}</${tag}>`;
const head = table.headers.length
? `<thead><tr>${table.headerCells.map((cell) => renderCell("th", cell)).join("")}</tr></thead>`
: "";
const bodyRows = table.rowCells
.map(
(row) =>
`<tr>${Array.from({ length: columnCount }, (_value, index) => renderCell("td", row[index])).join("")}</tr>`,
)
.join("");
const body = bodyRows ? `<tbody>${bodyRows}</tbody>` : "";
return `<table>${head}${body}</table>\n\n`;
}
function renderTelegramRichHtmlDocument(
ir: MarkdownIR,
tables: readonly MarkdownTableMeta[],
): string {
if (!tables.length) {
return isolateTelegramRichMediaBlocks(
wrapFileReferencesInHtml(
preserveSupportedTelegramHtmlTags(renderTelegramHtml(ir), TELEGRAM_RICH_HTML_TAG_SUPPORT),
),
);
}
let cursor = 0;
let html = "";
for (const table of [...tables].toSorted(
(left, right) => left.placeholderOffset - right.placeholderOffset,
)) {
const offset = Math.max(cursor, Math.min(table.placeholderOffset, ir.text.length));
html += renderTelegramHtml(sliceMarkdownIR(ir, cursor, offset));
html += renderTelegramRichHtmlTable(table);
cursor = offset;
}
html += renderTelegramHtml(sliceMarkdownIR(ir, cursor, ir.text.length));
return isolateTelegramRichMediaBlocks(
wrapFileReferencesInHtml(
preserveSupportedTelegramHtmlTags(html, TELEGRAM_RICH_HTML_TAG_SUPPORT),
),
);
}
export function markdownToTelegramRichHtml(
markdown: string,
options: { tableMode?: MarkdownTableMode; skipEntityDetection?: boolean } = {},
): string {
const tableMode = options.tableMode ?? "block";
const normalized = normalizeTelegramRichMarkdownMedia(markdown ?? "");
const { ir, tables } = markdownToIRWithMeta(
preserveTelegramListBoundarySpacing(normalized.markdown),
{
linkify: options.skipEntityDetection !== true,
enableSpoilers: true,
headingStyle: "rich",
blockquotePrefix: "",
tableMode,
},
);
return isolateTelegramRichMediaBlocks(
replaceTelegramRichMarkdownMediaPlaceholders(
renderTelegramRichHtmlDocument(ir, tables),
normalized.mediaBlocks,
),
);
}
type TelegramHtmlTag = {
name: string;
openTag: string;
closeTag: string;
};
const TELEGRAM_SELF_CLOSING_HTML_TAGS = new Set(["br"]);
const TELEGRAM_SELF_CLOSING_HTML_TAGS = TELEGRAM_VOID_HTML_TAGS;
function buildTelegramHtmlOpenPrefix(tags: TelegramHtmlTag[]): string {
return tags.map((tag) => tag.openTag).join("");

View File

@@ -8,8 +8,14 @@ import type {
ReplyKeyboardRemove,
ReplyParameters,
} from "grammy/types";
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
import { splitTelegramHtmlChunks } from "./format.js";
import {
markdownToTelegramRichHtml,
sanitizeTelegramRichHtml,
splitTelegramHtmlChunks,
telegramHtmlToPlainTextFallback,
} from "./format.js";
type TelegramRichMessageReplyMarkup =
| InlineKeyboardMarkup
@@ -19,7 +25,6 @@ type TelegramRichMessageReplyMarkup =
export const TELEGRAM_RICH_TEXT_LIMIT = 32_768;
export const TELEGRAM_RICH_BLOCK_LIMIT = 500;
export const TELEGRAM_RICH_TABLE_COLUMN_LIMIT = 20;
export type TelegramInputRichMessage =
| {
@@ -37,10 +42,17 @@ export type TelegramInputRichMessage =
type TelegramRichMessageOptions = {
skipEntityDetection?: boolean;
tableMode?: MarkdownTableMode;
};
export type TelegramRichTextMode = "markdown" | "html";
export type TelegramRichTextChunk = {
text: string;
textMode: TelegramRichTextMode;
plainText: string;
};
export type TelegramSendRichMessageParams = {
business_connection_id?: string;
chat_id: number | string;
@@ -147,17 +159,14 @@ export function buildTelegramRichMarkdown(
markdown: string,
options?: TelegramRichMessageOptions,
): TelegramInputRichMessage {
const normalizedMarkdown = normalizeTelegramRichMarkdown(sanitizeTelegramRichMarkdown(markdown));
return options?.skipEntityDetection === true
? { markdown: normalizedMarkdown, skip_entity_detection: true }
: { markdown: normalizedMarkdown };
return buildTelegramRichHtml(markdownToTelegramRichHtml(markdown, options), options);
}
export function buildTelegramRichHtml(
html: string,
options?: TelegramRichMessageOptions,
): TelegramInputRichMessage {
const safeHtml = escapeTelegramRichHtmlMediaTags(html);
const safeHtml = sanitizeTelegramRichHtml(html);
return options?.skipEntityDetection === true
? { html: safeHtml, skip_entity_detection: true }
: { html: safeHtml };
@@ -178,27 +187,6 @@ type RichMarkdownFenceSpan = {
end: number;
};
function escapeTelegramRichHtmlTag(tag: string): string {
return tag
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeTelegramRichHtmlMediaTags(html: string): string {
return html.replace(
/<\/?(?:img|picture|source|video|audio|track|iframe|embed|object)\b[^<>]*>/gi,
(tag) => escapeTelegramRichHtmlTag(tag),
);
}
function sanitizeTelegramRichMarkdown(markdown: string): string {
return escapeTelegramRichHtmlMediaTags(markdown)
.replace(/!\[([^\]\n]*)\]\(([^)\n]+)\)/g, "[$1]($2)")
.replace(/!\[([^\]\n]*)\]\[([^\]\n]+)\]/g, "[$1][$2]");
}
function parseRichMarkdownFenceSpans(markdown: string): RichMarkdownFenceSpan[] {
const spans: RichMarkdownFenceSpan[] = [];
let open:
@@ -239,172 +227,6 @@ function isSafeRichMarkdownBlockBreak(spans: readonly RichMarkdownFenceSpan[], i
return !spans.some((span) => index > span.start && index < span.end);
}
function isRichMarkdownFenceMarker(line: string): boolean {
return /^( {0,3})(`{3,}|~{3,})/.test(line);
}
function isRichMarkdownBlockLine(line: string, isTableLine: boolean): boolean {
const trimmed = line.trimStart();
return (
isTableLine ||
isRichMarkdownFenceMarker(line) ||
/^#{1,6}\s+\S/.test(trimmed) ||
trimmed.startsWith(">") ||
/^(?:[-+*]|\d+[.)])\s+\S/.test(trimmed) ||
/^[-*_][\s-*_-]{2,}$/.test(trimmed)
);
}
function splitMarkdownTableRow(row: string): string[] {
const trimmed = row.trim();
const body = trimmed.startsWith("|") && trimmed.endsWith("|") ? trimmed.slice(1, -1) : trimmed;
const cells: string[] = [];
let cell = "";
let escaped = false;
for (const char of body) {
if (escaped) {
cell += char;
escaped = false;
continue;
}
if (char === "\\") {
cell += char;
escaped = true;
continue;
}
if (char === "|") {
cells.push(cell.trim());
cell = "";
continue;
}
cell += char;
}
cells.push(cell.trim());
return cells;
}
function isMarkdownTableSeparator(row: string): boolean {
const cells = splitMarkdownTableRow(row);
return cells.length > 1 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
}
function isMarkdownTableRow(row: string): boolean {
return splitMarkdownTableRow(row).length > 1;
}
function markdownTableColumnCount(row: string): number {
return splitMarkdownTableRow(row).length;
}
function findRichMarkdownTableLineIndexes(
lines: readonly string[],
fenceSpans: readonly RichMarkdownFenceSpan[],
): Set<number> {
const tableLineIndexes = new Set<number>();
let offset = 0;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
const nextLine = lines[index + 1];
if (
nextLine !== undefined &&
isSafeRichMarkdownBlockBreak(fenceSpans, offset) &&
isMarkdownTableRow(line) &&
isMarkdownTableSeparator(nextLine)
) {
tableLineIndexes.add(index);
tableLineIndexes.add(index + 1);
offset += line.length + 1 + nextLine.length + 1;
index += 2;
while (index < lines.length && isMarkdownTableRow(lines[index] ?? "")) {
tableLineIndexes.add(index);
offset += (lines[index] ?? "").length + 1;
index += 1;
}
index -= 1;
continue;
}
offset += line.length + 1;
}
return tableLineIndexes;
}
function preserveTelegramRichMarkdownLineBreaks(markdown: string): string {
if (!markdown.includes("\n")) {
return markdown;
}
const fenceSpans = parseRichMarkdownFenceSpans(markdown);
const lines = markdown.split("\n");
const tableLineIndexes = findRichMarkdownTableLineIndexes(lines, fenceSpans);
const out: string[] = [];
let offset = 0;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
const nextLine = lines[index + 1];
if (nextLine === undefined) {
out.push(line);
break;
}
const newlineIndex = offset + line.length;
const shouldPreserveBreak =
line.length > 0 &&
nextLine.length > 0 &&
!line.endsWith(" ") &&
!line.endsWith("\\") &&
!isRichMarkdownBlockLine(line, tableLineIndexes.has(index)) &&
!isRichMarkdownBlockLine(nextLine, tableLineIndexes.has(index + 1)) &&
isSafeRichMarkdownBlockBreak(fenceSpans, newlineIndex);
out.push(`${line}${shouldPreserveBreak ? " " : ""}\n`);
offset = newlineIndex + 1;
}
return out.join("");
}
function normalizeTelegramRichMarkdownTables(markdown: string): string {
if (!markdown.includes("|")) {
return markdown;
}
const fenceSpans = parseRichMarkdownFenceSpans(markdown);
const lines = markdown.split("\n");
const out: string[] = [];
let offset = 0;
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index] ?? "";
const nextLine = lines[index + 1];
if (
nextLine !== undefined &&
isSafeRichMarkdownBlockBreak(fenceSpans, offset) &&
isMarkdownTableRow(line) &&
isMarkdownTableSeparator(nextLine) &&
Math.max(markdownTableColumnCount(line), markdownTableColumnCount(nextLine)) >
TELEGRAM_RICH_TABLE_COLUMN_LIMIT
) {
const tableLines = [line, nextLine];
let consumed = line.length + 1 + nextLine.length + 1;
index += 2;
while (index < lines.length && isMarkdownTableRow(lines[index] ?? "")) {
const tableLine = lines[index] ?? "";
tableLines.push(tableLine);
consumed += tableLine.length + 1;
index += 1;
}
index -= 1;
out.push("```", ...tableLines, "```");
offset += consumed;
continue;
}
out.push(line);
offset += line.length + 1;
}
return out.join("\n");
}
function normalizeTelegramRichMarkdown(markdown: string): string {
return preserveTelegramRichMarkdownLineBreaks(normalizeTelegramRichMarkdownTables(markdown));
}
type RichMarkdownBlockBreak = {
start: number;
end: number;
@@ -485,13 +307,40 @@ function splitTelegramRichMarkdownBlocks(markdown: string, blockLimit: number):
return chunks;
}
function splitTelegramRichMarkdownTextChunks(
markdown: string,
textLimit: number,
chunkMode: ChunkMode,
): string[] {
const chunks: string[] = [];
const queue = chunkMarkdownTextWithMode(markdown, textLimit, chunkMode);
for (let index = 0; index < queue.length; index += 1) {
const chunk = queue[index] ?? "";
if (chunk.length <= textLimit) {
chunks.push(chunk);
continue;
}
const reducedLimit = Math.max(1, Math.min(chunk.length - 1, textLimit - 16));
const nextChunks = chunkMarkdownTextWithMode(chunk, reducedLimit, chunkMode);
if (nextChunks.length <= 1) {
chunks.push(chunk);
continue;
}
queue.splice(index, 1, ...nextChunks);
index -= 1;
}
return chunks;
}
export function splitTelegramRichMarkdownChunks(
markdown: string,
textLimit: number,
chunkMode: ChunkMode,
): string[] {
const normalizedMarkdown = normalizeTelegramRichMarkdown(markdown);
return chunkMarkdownTextWithMode(normalizedMarkdown, textLimit, chunkMode).flatMap((chunk) =>
if (markdown.length <= textLimit) {
return splitTelegramRichMarkdownBlocks(markdown, TELEGRAM_RICH_BLOCK_LIMIT);
}
return splitTelegramRichMarkdownTextChunks(markdown, textLimit, chunkMode).flatMap((chunk) =>
splitTelegramRichMarkdownBlocks(chunk, TELEGRAM_RICH_BLOCK_LIMIT),
);
}
@@ -503,6 +352,32 @@ export function splitTelegramRichTextChunks(params: {
chunkMode: ChunkMode;
}): string[] {
return params.textMode === "html"
? splitTelegramHtmlChunks(params.text, params.textLimit)
? splitTelegramHtmlChunks(sanitizeTelegramRichHtml(params.text), params.textLimit)
: splitTelegramRichMarkdownChunks(params.text, params.textLimit, params.chunkMode);
}
export function splitTelegramRichMessageTextChunks(params: {
text: string;
textLimit: number;
textMode: TelegramRichTextMode;
chunkMode: ChunkMode;
tableMode?: MarkdownTableMode;
skipEntityDetection?: boolean;
}): TelegramRichTextChunk[] {
const renderMarkdownChunk = (chunk: string) =>
markdownToTelegramRichHtml(chunk, {
tableMode: params.tableMode,
skipEntityDetection: params.skipEntityDetection,
});
const htmlChunks =
params.textMode === "html"
? splitTelegramHtmlChunks(sanitizeTelegramRichHtml(params.text), params.textLimit)
: splitTelegramRichMarkdownChunks(params.text, params.textLimit, params.chunkMode).flatMap(
(chunk) => splitTelegramHtmlChunks(renderMarkdownChunk(chunk), params.textLimit),
);
return htmlChunks.map((chunk) => ({
text: chunk,
textMode: "html",
plainText: telegramHtmlToPlainTextFallback(chunk),
}));
}

View File

@@ -8,7 +8,7 @@ import {
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import { importFreshModule } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { markdownToTelegramHtml } from "./format.js";
import { markdownToTelegramHtml, markdownToTelegramRichHtml } from "./format.js";
import {
buildTelegramConversationContext,
createTelegramMessageCache,
@@ -129,7 +129,7 @@ const sendMessageTelegram: typeof sendMessageTelegramImpl = async (to, text, opt
: opts,
);
const TELEGRAM_TEST_CFG = {};
const TELEGRAM_TEST_CFG = { channels: { telegram: { markdown: { tables: "block" as const } } } };
let sentMessageStore: NonNullable<Parameters<typeof setTelegramSentMessageStoreForTest>[0]>;
function markdownTable(columns: number): string {
@@ -908,7 +908,7 @@ describe("sendMessageTelegram", () => {
}
});
it("sends raw rich markdown for durable text", async () => {
it("sends Markdown durable text as Telegram rich HTML", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
await sendMessageTelegram("123", "**hi**", {
@@ -918,11 +918,11 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown: "**hi**" },
rich_message: { html: "<b>hi</b>" },
});
});
it("keeps complex markdown raw for rich message text", async () => {
it("sends complex Markdown through Telegram rich HTML", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 46, chat: { id: "123" } });
const markdown = [
"# Heading",
@@ -943,11 +943,33 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown },
rich_message: { html: markdownToTelegramRichHtml(markdown) },
});
});
it("preserves rich markdown line breaks outside fenced code", async () => {
it("does not pass currency through Telegram Rich Markdown math parsing", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 47, chat: { id: "123" } });
const text =
"10Y realistic strong outcome: ~$400-600K TC, top end ($800K+) gated on frontier lab equity.";
await sendMessageTelegram("123", text, {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
});
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: {
html: markdownToTelegramRichHtml(text),
},
});
const richMessage = richSendCallParams()[0]?.rich_message;
expect(richMessage?.html).toContain("$400-600K");
expect(richMessage?.html).toContain("($800K+)");
expect(richMessage?.markdown).toBeUndefined();
});
it("preserves line breaks outside fenced code through rich HTML", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 47, chat: { id: "123" } });
const markdown = [
"Status: ok | mode",
@@ -959,16 +981,6 @@ describe("sendMessageTelegram", () => {
"```",
"Tail",
].join("\n");
const expectedMarkdown = [
"Status: ok | mode ",
"Models: ready",
"",
"```",
"a",
"b",
"```",
"Tail",
].join("\n");
await sendMessageTelegram("123", markdown, {
cfg: TELEGRAM_TEST_CFG,
@@ -977,28 +989,17 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown: expectedMarkdown },
rich_message: { html: markdownToTelegramRichHtml(markdown) },
});
});
it("keeps markdown media syntax on the text-only rich path", async () => {
it("isolates supported rich HTML media tags as blocks", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 48, chat: { id: "123" } });
const html = '<b>See</b><img src="https://example.com/diagram.png">';
const expectedHtml =
'<b>See</b>\n\n<figure><img src="https://example.com/diagram.png"/></figure>';
await sendMessageTelegram("123", "See ![diagram](https://example.com/diagram.png)", {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
});
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown: "See [diagram](https://example.com/diagram.png)" },
});
});
it("escapes HTML media tags on the text-only rich path", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 49, chat: { id: "123" } });
await sendMessageTelegram("123", '<b>See</b><img src="https://example.com/diagram.png">', {
await sendMessageTelegram("123", html, {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
textMode: "html",
@@ -1006,13 +1007,56 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: {
html: "<b>See</b>&lt;img src=&quot;https://example.com/diagram.png&quot;&gt;",
},
rich_message: { html: expectedHtml },
});
});
it("keeps native rich markdown tables within Telegram's column limit", async () => {
it("preserves supported Telegram rich HTML structures", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 49, chat: { id: "123" } });
const html = [
"<h2>Plan</h2>",
"<details><summary>More</summary><p>Hidden</p></details>",
"<table><thead><tr><th>A</th></tr></thead><tbody><tr><td>B</td></tr></tbody></table>",
'<figure tg-spoiler><img src="https://example.com/diagram.png" alt="diagram"></figure>',
"<tg-math>x^2 + y^2</tg-math>",
].join("");
await sendMessageTelegram("123", html, {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
textMode: "html",
});
const renderedHtml = richSendCallParams()[0]?.rich_message?.html ?? "";
expect(renderedHtml).toContain("<h2>Plan</h2>");
expect(renderedHtml).toContain("<details><summary>More</summary><p>Hidden</p></details>");
expect(renderedHtml).toContain("<table>");
expect(renderedHtml).toContain(
'\n\n<figure tg-spoiler><img src="https://example.com/diagram.png" alt="diagram"/></figure>\n\n',
);
expect(renderedHtml).toContain("<tg-math>x^2 + y^2</tg-math>");
});
it("sends raw rich HTML tags through Telegram rich HTML", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 49, chat: { id: "123" } });
const markdown = [
'<img src="https://example.com/diagram.png" alt="Diagram">',
"<details><summary>More</summary>Hidden</details>",
"<sup>1</sup>",
'<input type="checkbox" checked>',
].join(" ");
await sendMessageTelegram("123", markdown, {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
});
expect(richSendCallParams()[0]?.rich_message).toEqual({
html: markdownToTelegramRichHtml(markdown),
});
});
it("sends Markdown tables within Telegram's column limit as rich HTML tables", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 50, chat: { id: "123" } });
const markdown = markdownTable(20);
@@ -1023,11 +1067,34 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown },
rich_message: { html: markdownToTelegramRichHtml(markdown) },
});
expect(richSendCallParams()[0]?.rich_message?.html).toContain("<table>");
});
it("does not auto-linkify Markdown URLs when link previews are disabled", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 50, chat: { id: "123" } });
const cfg = {
channels: {
telegram: {
markdown: { tables: "block" as const },
linkPreview: false,
},
},
};
await sendMessageTelegram("123", "https://example.com", {
cfg,
token: "tok",
});
expect(richSendCallParams()[0]?.rich_message).toEqual({
html: "https://example.com",
skip_entity_detection: true,
});
});
it("wraps wide rich markdown tables that exceed Telegram's column limit", async () => {
it("renders wide Markdown tables as code blocks when they exceed Telegram's column limit", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 51, chat: { id: "123" } });
const markdown = markdownTable(21);
@@ -1038,11 +1105,12 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown: `\`\`\`\n${markdown}\n\`\`\`` },
rich_message: { html: markdownToTelegramRichHtml(markdown) },
});
expect(richSendCallParams()[0]?.rich_message?.html).toContain("<pre><code>");
});
it("leaves wide rich markdown tables alone inside fences", async () => {
it("renders fenced wide Markdown tables as code in rich HTML", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 52, chat: { id: "123" } });
const markdown = `~~~\n${markdownTable(25)}\n~~~`;
@@ -1053,11 +1121,11 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown },
rich_message: { html: markdownToTelegramRichHtml(markdown) },
});
});
it("wraps only wide rich markdown tables outside fences", async () => {
it("falls back only wide Markdown tables outside fences", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 52, chat: { id: "123" } });
const fencedTable = markdownTable(25);
const outsideTable = markdownTable(21);
@@ -1070,33 +1138,27 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: {
markdown: ["Before", "~~~", fencedTable, "~~~", "After", "```", outsideTable, "```"].join(
"\n",
),
},
rich_message: { html: markdownToTelegramRichHtml(markdown) },
});
});
it("sends long rich markdown as one message", async () => {
it("chunks long rich HTML when source text exceeds the message limit", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 53, chat: { id: "123" } });
const line = "**section** with _style_ and `code`";
const markdown = `# Long\n\n${`${line}\n`.repeat(800)}`;
const expectedMarkdown = `# Long\n\n${`${line} \n`.repeat(799)}${line}\n`;
const markdown = `# Long\n\n${`${line}\n`.repeat(2000)}`;
await sendMessageTelegram("123", markdown, {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
});
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown: expectedMarkdown },
});
const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.length <= 32_768)).toBe(true);
expect(chunks.join("").match(/<b>section<\/b>/g)).toHaveLength(2000);
});
it("chunks rich markdown above the Bot API rich message limit", async () => {
it("chunks rich HTML above the Bot API rich message limit", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 54, chat: { id: "123" } });
const markdown = `# Long\n\n${"**section** with _style_ and `code`\n".repeat(3000)}`;
@@ -1106,14 +1168,13 @@ describe("sendMessageTelegram", () => {
});
expect(botRawApi.sendRichMessage.mock.calls.length).toBeGreaterThan(1);
const chunks = richSendCallParams().map((params) => params.rich_message?.markdown ?? "");
const joinedChunks = chunks.join("\n");
expect(joinedChunks.startsWith("# Long")).toBe(true);
expect(joinedChunks.match(/\*\*section\*\* with _style_ and `code`/g)?.length).toBe(3000);
const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
expect(chunks.at(0)).toContain("Long");
expect(chunks.join("").match(/<b>section<\/b>/g)).toHaveLength(3000);
expect(chunks.every((chunk) => chunk.length <= 32_768)).toBe(true);
});
it("chunks long inline rich markdown without converting to HTML", async () => {
it("chunks long inline Markdown as bounded rich HTML", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 52, chat: { id: "123" } });
const markdown = `**${"A".repeat(70_000)}**`;
@@ -1124,9 +1185,9 @@ describe("sendMessageTelegram", () => {
const chunks = richSendCallParams().map((params) => params.rich_message);
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk?.html === undefined)).toBe(true);
expect(chunks.every((chunk) => (chunk?.markdown ?? "").length <= 32_768)).toBe(true);
expect(chunks.map((chunk) => chunk?.markdown ?? "").join("")).toBe(markdown);
expect(chunks.every((chunk) => chunk?.markdown === undefined)).toBe(true);
expect(chunks.every((chunk) => (chunk?.html ?? "").length <= 32_768)).toBe(true);
expect(chunks.map((chunk) => chunk?.html ?? "").join("")).toContain("A".repeat(100));
});
it("chunks rich markdown above Telegram's rich block limit", async () => {
@@ -1140,14 +1201,10 @@ describe("sendMessageTelegram", () => {
token: "tok",
});
const chunks = richSendCallParams().map((params) => params.rich_message?.markdown ?? "");
const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
expect(chunks).toHaveLength(2);
expect(
chunks.every(
(chunk) => chunk.split(/\n[\t ]*\n+/).filter((block) => block.trim()).length <= 500,
),
).toBe(true);
expect(chunks.join("\n\n")).toBe(markdown);
expect(chunks.every((chunk) => (chunk.match(/Paragraph \d+/g)?.length ?? 0) <= 500)).toBe(true);
expect(chunks.join("").match(/Paragraph \d+/g)).toHaveLength(900);
});
it("chunks rich markdown headings above Telegram's rich block limit", async () => {
@@ -1159,11 +1216,10 @@ describe("sendMessageTelegram", () => {
token: "tok",
});
const chunks = richSendCallParams().map((params) => params.rich_message?.markdown ?? "");
const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
expect(chunks).toHaveLength(2);
expect(chunks.at(0)?.match(/^# /gm)).toHaveLength(500);
expect(chunks.at(1)?.match(/^# /gm)).toHaveLength(100);
expect(chunks.join("\n")).toBe(markdown);
expect(chunks.at(0)?.match(/Heading \d+/g)).toHaveLength(500);
expect(chunks.at(1)?.match(/Heading \d+/g)).toHaveLength(100);
});
it("keeps long rich markdown lists intact", async () => {
@@ -1178,7 +1234,7 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown },
rich_message: { html: markdownToTelegramRichHtml(markdown) },
});
});
@@ -1198,7 +1254,7 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown },
rich_message: { html: markdownToTelegramRichHtml(markdown) },
});
});
@@ -1216,7 +1272,7 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown },
rich_message: { html: markdownToTelegramRichHtml(markdown) },
});
});
@@ -1235,11 +1291,11 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
expect(botRawApi.sendRichMessage).toHaveBeenCalledWith({
chat_id: "123",
rich_message: { markdown },
rich_message: { html: markdownToTelegramRichHtml(markdown) },
});
});
it("chunks long rich markdown fences into bounded markdown chunks", async () => {
it("chunks long rich markdown fences into bounded rich HTML chunks", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 59, chat: { id: "123" } });
const markdown = `~~~ts\n${"const value = 1;\n".repeat(5000)}~~~`;
@@ -1248,11 +1304,10 @@ describe("sendMessageTelegram", () => {
token: "tok",
});
const chunks = richSendCallParams().map((params) => params.rich_message?.markdown ?? "");
const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.length <= 32_768)).toBe(true);
expect(chunks.every((chunk) => chunk.startsWith("~~~ts\n"))).toBe(true);
expect(chunks.every((chunk) => chunk.endsWith("\n~~~") || chunk.endsWith("~~~"))).toBe(true);
expect(chunks.join("")).toContain("const value = 1;");
});
it("chunks explicit rich HTML above the Bot API rich message limit", async () => {
@@ -1277,6 +1332,31 @@ describe("sendMessageTelegram", () => {
});
});
it("chunks explicit rich HTML after media normalization", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 61, chat: { id: "123" } });
const img = '<img src="https://example.com/a.png">';
const html = img.repeat(3);
const cfg = {
channels: {
telegram: {
markdown: { tables: "block" as const },
textChunkLimit: html.length + 5,
},
},
};
await sendMessageTelegram("123", html, {
cfg,
token: "tok",
textMode: "html",
});
const chunks = richSendCallParams().map((params) => params.rich_message?.html ?? "");
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.length <= html.length + 5)).toBe(true);
expect(chunks.join("")).toContain("<figure>");
});
it("fails when Telegram text send returns no message_id", async () => {
const sendMessage = vi.fn().mockResolvedValue({
chat: { id: "123" },
@@ -3370,7 +3450,7 @@ describe("editMessageTelegram", () => {
expect(botApi.editMessageText).toHaveBeenCalledTimes(2);
});
it("edits text with raw rich markdown", async () => {
it("edits Markdown text as Telegram rich HTML", async () => {
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
await editMessageTelegram("123", 1, "**edited**", {
@@ -3381,11 +3461,11 @@ describe("editMessageTelegram", () => {
expect(botRawApi.editMessageText).toHaveBeenCalledWith({
chat_id: "123",
message_id: 1,
rich_message: { markdown: "**edited**" },
rich_message: { html: "<b>edited</b>" },
});
});
it("edits complex text as raw rich markdown", async () => {
it("edits complex Markdown text as Telegram rich HTML", async () => {
botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } });
const markdown = ["## Updated", "", "- **bold**", "- _italic_", "", "`code`"].join("\n");
@@ -3397,7 +3477,7 @@ describe("editMessageTelegram", () => {
expect(botRawApi.editMessageText).toHaveBeenCalledWith({
chat_id: "123",
message_id: 1,
rich_message: { markdown },
rich_message: { html: markdownToTelegramRichHtml(markdown) },
});
});
@@ -3415,7 +3495,7 @@ describe("editMessageTelegram", () => {
chat_id: "123",
message_id: 1,
rich_message: {
markdown: "https://example.com",
html: "https://example.com",
skip_entity_detection: true,
},
});

View File

@@ -40,11 +40,12 @@ import {
import {
buildTelegramRichMessage,
getTelegramRichRawApi,
splitTelegramRichTextChunks,
splitTelegramRichMessageTextChunks,
TELEGRAM_RICH_TEXT_LIMIT,
toTelegramRichMessageContextParams,
type TelegramEditRichMessageTextParams,
type TelegramRichMessageContextParams,
type TelegramRichTextChunk,
} from "./rich-message.js";
import {
buildOutboundMediaLoadOptions,
@@ -600,16 +601,16 @@ export async function sendMessageTelegram(
});
const textMode = opts.textMode ?? "markdown";
const richMessageOptions = {
skipEntityDetection: account.config.linkPreview === false,
};
const buildRichMessage = (value: string) =>
buildTelegramRichMessage(value, textMode, richMessageOptions);
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
accountId: account.accountId,
supportsBlockTables: true,
});
const richMessageOptions = {
skipEntityDetection: account.config.linkPreview === false,
tableMode,
};
const renderHtmlText = (value: string) => renderTelegramHtmlText(value, { textMode, tableMode });
const textLimit = Math.min(
resolveTextChunkLimit(cfg, "telegram", account.accountId, {
@@ -619,12 +620,8 @@ export async function sendMessageTelegram(
);
const chunkMode = resolveChunkMode(cfg, "telegram", account.accountId);
type TelegramTextChunk = {
text: string;
};
const sendTelegramTextChunk = async (
chunk: TelegramTextChunk,
chunk: TelegramRichTextChunk,
params?: TelegramRichMessageContextParams,
) => {
const richRawApi = getTelegramRichRawApi(api);
@@ -636,7 +633,7 @@ export async function sendMessageTelegram(
() =>
richRawApi.sendRichMessage({
chat_id: chatId,
rich_message: buildRichMessage(chunk.text),
rich_message: buildTelegramRichMessage(chunk.text, chunk.textMode, richMessageOptions),
...richParams,
}),
"richMessage",
@@ -653,7 +650,7 @@ export async function sendMessageTelegram(
: undefined;
const sendTelegramTextChunks = async (
chunks: TelegramTextChunk[],
chunks: TelegramRichTextChunk[],
context: string,
): Promise<{ messageId: string; chatId: string }> => {
let lastMessageId = "";
@@ -677,7 +674,7 @@ export async function sendMessageTelegram(
chatId,
message: res,
messageId,
text: chunk.text,
text: chunk.plainText,
...(acceptedParams?.message_thread_id !== undefined
? { messageThreadId: acceptedParams.message_thread_id }
: {}),
@@ -703,13 +700,15 @@ export async function sendMessageTelegram(
return { messageId: lastMessageId, chatId: lastChatId };
};
const buildChunkedTextPlan = (rawText: string): TelegramTextChunk[] => {
return splitTelegramRichTextChunks({
const buildChunkedTextPlan = (rawText: string): TelegramRichTextChunk[] => {
return splitTelegramRichMessageTextChunks({
text: rawText,
textLimit,
textMode,
chunkMode,
}).map((chunk) => ({ text: chunk }));
tableMode,
skipEntityDetection: richMessageOptions.skipEntityDetection,
});
};
const sendChunkedText = async (rawText: string, context: string) =>
@@ -1355,12 +1354,14 @@ export async function editMessageTelegram(
cfg,
channel: "telegram",
accountId: account.accountId,
supportsBlockTables: true,
});
const htmlText = renderTelegramHtmlText(text, { textMode, tableMode });
const plainText = textMode === "html" ? telegramHtmlToPlainTextFallback(htmlText) : text;
const richRawApi = getTelegramRichRawApi(api);
const richMessage = buildTelegramRichMessage(text, textMode, {
skipEntityDetection: opts.linkPreview === false,
tableMode,
});
// Reply markup semantics:

View File

@@ -19,7 +19,7 @@ describe("telegramPlugin outbound", () => {
it("uses static outbound contract when Telegram runtime is uninitialized", () => {
clearTelegramRuntime();
const text = `${"hello\n".repeat(1200)}tail`;
const expected = chunkMarkdownTextWithMode(`${"hello \n".repeat(1200)}tail`, 32_768, "length");
const expected = chunkMarkdownTextWithMode(text, 32_768, "length");
expect(telegramOutbound.chunker?.(text, 32_768)).toEqual(expected);
expect(telegramOutbound.deliveryMode).toBe("direct");
@@ -54,16 +54,16 @@ describe("telegramPlugin outbound", () => {
expect(chunks).toEqual([text]);
});
it("wraps wide markdown tables before rich message parsing", () => {
it("keeps wide markdown tables for rich HTML rendering", () => {
clearTelegramRuntime();
const text = markdownTable(21);
const chunks = telegramOutbound.chunker?.(text, 32_768);
expect(chunks).toEqual(["```\n" + text + "\n```"]);
expect(chunks).toEqual([text]);
});
it("wraps only wide markdown tables outside fences", () => {
it("keeps fenced and unfenced wide markdown tables for rich HTML rendering", () => {
clearTelegramRuntime();
const fencedTable = markdownTable(25);
const outsideTable = markdownTable(21);
@@ -71,9 +71,7 @@ describe("telegramPlugin outbound", () => {
const chunks = telegramOutbound.chunker?.(text, 32_768);
expect(chunks).toEqual([
["Before", "~~~", fencedTable, "~~~", "After", "```", outsideTable, "```"].join("\n"),
]);
expect(chunks).toEqual([text]);
});
it("chunks rich markdown by Telegram's block limit", () => {

View File

@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { markdownToIR } from "./ir.js";
describe("markdownToIR raw HTML", () => {
it("does not linkify URLs inside raw HTML tag attributes", () => {
const ir = markdownToIR(
'<img src="https://example.com/diagram.png" alt="Diagram"> https://example.com/page',
);
expect(ir.text).toBe(
'<img src="https://example.com/diagram.png" alt="Diagram"> https://example.com/page',
);
expect(ir.links.map((link) => ir.text.slice(link.start, link.end))).toEqual([
"https://example.com/page",
]);
});
it("does not treat comparison text as a raw HTML tag", () => {
const ir = markdownToIR("x < y https://example.com/page");
expect(ir.links.map((link) => ir.text.slice(link.start, link.end))).toEqual([
"https://example.com/page",
]);
});
});

View File

@@ -14,6 +14,16 @@ describe("markdownToIRWithMeta tableMode block", () => {
{
headers: ["Name", "Age"],
rows: [["Alice", "30"]],
headerCells: [
{ text: "Name", styles: [], links: [] },
{ text: "Age", styles: [], links: [] },
],
rowCells: [
[
{ text: "Alice", styles: [], links: [] },
{ text: "30", styles: [], links: [] },
],
],
placeholderOffset: ir.text.indexOf("After"),
},
]);

View File

@@ -14,12 +14,15 @@ type LinkState = {
labelStart: number;
};
const OPEN_MARKDOWN_HTML_TAG_PATTERN = /<\/?[a-zA-Z][a-zA-Z0-9-]*\b[^<>]*$/;
type RenderEnv = {
listStack: ListState[];
};
type MarkdownToken = {
type: string;
tag?: string;
content?: string;
info?: string;
children?: MarkdownToken[];
@@ -36,7 +39,13 @@ export type MarkdownStyle =
| "code"
| "code_block"
| "spoiler"
| "blockquote";
| "blockquote"
| "heading_1"
| "heading_2"
| "heading_3"
| "heading_4"
| "heading_5"
| "heading_6";
export type MarkdownStyleSpan = {
start: number;
@@ -74,8 +83,16 @@ export type MarkdownTableData = {
rows: string[][];
};
export type MarkdownTableCell = {
text: string;
styles: MarkdownStyleSpan[];
links: MarkdownLinkSpan[];
};
export type MarkdownTableMeta = MarkdownTableData & {
placeholderOffset: number;
headerCells: MarkdownTableCell[];
rowCells: MarkdownTableCell[][];
};
type OpenStyle = {
@@ -91,11 +108,7 @@ type RenderTarget = {
linkStack: LinkState[];
};
type TableCell = {
text: string;
styles: MarkdownStyleSpan[];
links: MarkdownLinkSpan[];
};
type TableCell = MarkdownTableCell;
type TableState = {
headers: TableCell[];
@@ -107,7 +120,7 @@ type TableState = {
type RenderState = RenderTarget & {
env: RenderEnv;
headingStyle: "none" | "bold";
headingStyle: "none" | "bold" | "rich";
blockquotePrefix: string;
enableSpoilers: boolean;
tableMode: MarkdownTableMode;
@@ -119,7 +132,7 @@ type RenderState = RenderTarget & {
export type MarkdownParseOptions = {
linkify?: boolean;
enableSpoilers?: boolean;
headingStyle?: "none" | "bold";
headingStyle?: "none" | "bold" | "rich";
blockquotePrefix?: string;
autolink?: boolean;
/** How to render tables (off|bullets|code|block). Default: off. */
@@ -385,6 +398,36 @@ function handleLinkClose(state: RenderState) {
target.links.push({ start, end, href });
}
function headingStyleFromToken(token: MarkdownToken): MarkdownStyle | null {
switch (token.tag) {
case "h1":
return "heading_1";
case "h2":
return "heading_2";
case "h3":
return "heading_3";
case "h4":
return "heading_4";
case "h5":
return "heading_5";
case "h6":
return "heading_6";
default:
return null;
}
}
function isInsideMarkdownHtmlTag(text: string): boolean {
const openTagStart = text.lastIndexOf("<");
if (openTagStart === -1) {
return false;
}
return (
text.lastIndexOf(">") < openTagStart &&
OPEN_MARKDOWN_HTML_TAG_PATTERN.test(text.slice(openTagStart))
);
}
function initTableState(): TableState {
return {
headers: [],
@@ -472,9 +515,13 @@ function collectTableBlock(state: RenderState) {
if (!state.table) {
return;
}
const headerCells = state.table.headers.map(trimCell);
const rowCells = state.table.rows.map((row) => row.map(trimCell));
state.collectedTables.push({
headers: state.table.headers.map((cell) => trimCell(cell).text),
rows: state.table.rows.map((row) => row.map((cell) => trimCell(cell).text)),
headers: headerCells.map((cell) => cell.text),
rows: rowCells.map((row) => row.map((cell) => cell.text)),
headerCells,
rowCells,
placeholderOffset: state.text.length,
});
}
@@ -678,8 +725,8 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
}
break;
case "link_open": {
const href = getAttr(token, "href") ?? "";
const target = resolveRenderTarget(state);
const href = isInsideMarkdownHtmlTag(target.text) ? "" : (getAttr(token, "href") ?? "");
target.linkStack.push({ href, labelStart: target.text.length });
break;
}
@@ -699,11 +746,21 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void {
case "heading_open":
if (state.headingStyle === "bold") {
openStyle(state, "bold");
} else if (state.headingStyle === "rich") {
const style = headingStyleFromToken(token);
if (style) {
openStyle(state, style);
}
}
break;
case "heading_close":
if (state.headingStyle === "bold") {
closeStyle(state, "bold");
} else if (state.headingStyle === "rich") {
const style = headingStyleFromToken(token);
if (style) {
closeStyle(state, style);
}
}
appendParagraphSeparator(state);
break;

View File

@@ -29,6 +29,12 @@ const STYLE_ORDER: MarkdownStyle[] = [
"blockquote",
"code_block",
"code",
"heading_1",
"heading_2",
"heading_3",
"heading_4",
"heading_5",
"heading_6",
"bold",
"italic",
"strikethrough",

View File

@@ -119,6 +119,7 @@ describe("buildCliAgentSystemPrompt", () => {
expect(prompt).toContain("Telegram rich text is available");
expect(prompt).toContain("headings, tables");
expect(prompt).toContain("Media tags are blocks, not inline prose");
expect(prompt).toContain("This is not legacy MarkdownV2/parse_mode");
expect(prompt).toContain("channel=telegram");
expect(prompt).not.toContain("### message tool");

View File

@@ -992,8 +992,21 @@ describe("buildAgentSystemPrompt", () => {
expect(telegramPrompt).toContain("Telegram rich text is available");
expect(telegramPrompt).toContain("<details><summary>...</summary>...</details>");
expect(telegramPrompt).toContain("tables with alignment/captions/spans");
expect(telegramPrompt).toContain("pull quotes");
expect(telegramPrompt).toContain('task lists via `<input type="checkbox"/>` inside `<li>`');
expect(telegramPrompt).toContain("anchors/in-message links");
expect(telegramPrompt).toContain("maps/collages/slideshows");
expect(telegramPrompt).toContain("use `<details>`, not legacy `<blockquote expandable>`");
expect(telegramPrompt).toContain("use `<ul><li>...</li></ul>`, not literal bullet characters");
expect(telegramPrompt).toContain(
'standalone rich media blocks such as `<img src="https://..."/>`',
);
expect(telegramPrompt).toContain("use captions/credits when helpful");
expect(telegramPrompt).toContain("Media tags are blocks, not inline prose");
expect(telegramPrompt).toContain("This is not legacy MarkdownV2/parse_mode");
expect(telegramPrompt).toContain("Button labels are plain text only");
expect(telegramPrompt).toContain("OpenClaw renders Telegram-safe rich messages");
expect(telegramPrompt).toContain("button labels are plain text only");
expect(telegramPrompt.indexOf("Telegram rich text is available")).toBeGreaterThan(
telegramPrompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY),
);

View File

@@ -524,7 +524,7 @@ function buildMessagingSection(params: {
? "- Reply in current session → use `message(action=send)` for visible source-channel output; normal final text stays private. Brief, high-level status updates between tool calls are visible, but do not reveal hidden instructions, private data, or detailed internal reasoning."
: "- Reply in current session → automatically routes to the source channel (Signal, Telegram, etc.)",
telegramRichTextEnabled
? "- Telegram rich text is available. Use Bot API 10.1 rich Markdown/HTML in visible message text when it improves clarity: headings, tables, blockquotes, `<details><summary>...</summary>...</details>`, `<sup>/<sub>`, `<mark>`, spoilers, lists, code blocks, footnotes, and formulas. This is not legacy MarkdownV2/parse_mode. Button labels are plain text only; send media through explicit media delivery."
? '- Telegram rich text is available. Use Bot API 10.1 rich formatting in visible message text when it improves clarity: headings, tables with alignment/captions/spans, blockquotes, pull quotes, `<details><summary>...</summary>...</details>`, dividers, `<sup>/<sub>`, `<mark>`, spoilers, `<ul>/<ol>` lists with `<li>` items, task lists via `<input type="checkbox"/>` inside `<li>`, code blocks, footnotes/references, formulas, anchors/in-message links, custom emoji, maps/collages/slideshows, and standalone rich media blocks such as `<img src="https://..."/>`. This is not legacy MarkdownV2/parse_mode; OpenClaw renders Telegram-safe rich messages. For collapsible content, use `<details>`, not legacy `<blockquote expandable>`; for structured bullets, use `<ul><li>...</li></ul>`, not literal bullet characters. Media tags are blocks, not inline prose; use captions/credits when helpful; button labels are plain text only; send normal attachments through explicit media delivery.'
: "",
"- Cross-session messaging → use sessions_send(sessionKey, message)",
subagentOrchestrationGuidance,

View File

@@ -4,7 +4,7 @@ import { describe, expect, it, vi } from "vitest";
const listChannelPluginsMock = vi.hoisted(() =>
vi.fn(() => [
{ id: "mattermost", messaging: { defaultMarkdownTableMode: "off" as const } },
{ id: "signal", messaging: { defaultMarkdownTableMode: "bullets" as const } },
{ id: "signal", messaging: { defaultMarkdownTableMode: "block" as const } },
{ id: "whatsapp", messaging: { defaultMarkdownTableMode: "bullets" as const } },
]),
);
@@ -17,6 +17,7 @@ vi.mock("../channels/plugins/registry.js", async () => {
return {
...actual,
listChannelPlugins: () => listChannelPluginsMock(),
normalizeChannelId: (raw?: string | null) => raw ?? null,
};
});
@@ -36,8 +37,8 @@ describe("DEFAULT_TABLE_MODES", () => {
expect(DEFAULT_TABLE_MODES.get("mattermost")).toBe("off");
});
it("signal mode is bullets", () => {
expect(DEFAULT_TABLE_MODES.get("signal")).toBe("bullets");
it("signal mode is block", () => {
expect(DEFAULT_TABLE_MODES.get("signal")).toBe("block");
});
it("whatsapp mode is bullets", () => {
@@ -59,8 +60,22 @@ describe("resolveMarkdownTableMode", () => {
expect(resolveMarkdownTableMode({ cfg, channel: "slack" })).toBe("code");
});
it("coerces explicit block mode to code for non-slack channels", () => {
it("keeps block mode behind renderer capability", () => {
expect(resolveMarkdownTableMode({ channel: "signal" })).toBe("code");
expect(resolveMarkdownTableMode({ channel: "signal", supportsBlockTables: true })).toBe(
"block",
);
const cfg = { channels: { signal: { markdown: { tables: "code" as const } } } };
expect(resolveMarkdownTableMode({ cfg, channel: "signal", supportsBlockTables: true })).toBe(
"code",
);
});
it("allows explicit block mode only for block-aware renderers", () => {
const cfg = { channels: { telegram: { markdown: { tables: "block" as const } } } };
expect(resolveMarkdownTableMode({ cfg, channel: "telegram" })).toBe("code");
expect(resolveMarkdownTableMode({ cfg, channel: "telegram", supportsBlockTables: true })).toBe(
"block",
);
});
});

View File

@@ -91,16 +91,14 @@ export function resolveMarkdownTableMode(
): MarkdownTableMode {
const channel = normalizeChannelId(params.channel);
const defaultMode = channel ? (getDefaultTableModes().get(channel) ?? "code") : "code";
if (!channel || !params.cfg) {
return defaultMode;
let resolved = defaultMode;
if (channel && params.cfg) {
const channelsConfig = params.cfg.channels as Record<string, unknown> | undefined;
const rootConfig = params.cfg as Record<string, unknown>;
const section = (channelsConfig?.[channel] ?? rootConfig[channel]) as
| MarkdownConfigSection
| undefined;
resolved = resolveMarkdownModeFromSection(section, params.accountId) ?? defaultMode;
}
const channelsConfig = params.cfg.channels as Record<string, unknown> | undefined;
const section = (channelsConfig?.[channel] ??
(params.cfg as Record<string, unknown> | undefined)?.[channel]) as
| MarkdownConfigSection
| undefined;
const resolved = resolveMarkdownModeFromSection(section, params.accountId) ?? defaultMode;
// "block" stays schema-valid for the shared markdown seam, but this PR
// keeps runtime delivery on safe text rendering until Slack send support lands.
return resolved === "block" ? "code" : resolved;
return resolved === "block" && !params.supportsBlockTables ? "code" : resolved;
}

View File

@@ -7,6 +7,7 @@ export type ResolveMarkdownTableModeParams = {
cfg?: Partial<OpenClawConfig>;
channel?: string | null;
accountId?: string | null;
supportsBlockTables?: boolean;
};
export type ResolveMarkdownTableMode = (

View File

@@ -25,6 +25,7 @@ export {
type MarkdownParseOptions,
type MarkdownStyle,
type MarkdownStyleSpan,
type MarkdownTableCell,
type MarkdownTableMeta,
} from "../../packages/markdown-core/src/ir.js";
/** Render-size-aware Markdown chunking for channel payload limits. */