mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 11:02:26 +00:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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) ??
|
||||
|
||||
@@ -951,6 +951,7 @@ export const registerTelegramNativeCommands = ({
|
||||
cfg: runtimeCfg,
|
||||
channel: "telegram",
|
||||
accountId: route.accountId,
|
||||
supportsBlockTables: true,
|
||||
});
|
||||
const chunkMode = nativeCommandRuntime.resolveChunkMode(
|
||||
runtimeCfg,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -790,6 +790,7 @@ export const telegramPlugin = createChatChannelPlugin({
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
defaultMarkdownTableMode: "block",
|
||||
targetPrefixes: ["telegram", "tg"],
|
||||
normalizeTarget: normalizeTelegramMessagingTarget,
|
||||
resolveInboundConversation: ({ to, conversationId, threadId }) =>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
'<blockquote cite="x">bad</blockquote>',
|
||||
);
|
||||
expect(markdownToTelegramHtml("<sup>1</sup>")).toBe("<sup>1</sup>");
|
||||
expect(renderTelegramHtmlText('<b class="x">bad</b>', { textMode: "html" })).toBe(
|
||||
'<b class="x">bad</b>',
|
||||
);
|
||||
});
|
||||
|
||||
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("<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(
|
||||
'<img src="logo.png" alt="Logo">',
|
||||
);
|
||||
expect(markdownToTelegramRichHtml('<audio src="data:audio/wav;base64,x"></audio>')).toBe(
|
||||
'<audio src="data:audio/wav;base64,x"></audio>',
|
||||
);
|
||||
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('')).toBe(
|
||||
'<figure><img src="https://example.com/a.jpg" alt="Diagram"/><figcaption>Caption</figcaption></figure>',
|
||||
);
|
||||
expect(
|
||||
markdownToTelegramRichHtml(''),
|
||||
).toBe(
|
||||
'<figure><img src="https://cdn.example/img.png?token=a&expires=b" alt="A "quote""/></figure>',
|
||||
);
|
||||
expect(markdownToTelegramRichHtml("")).toBe(
|
||||
'<figure><img src="https://example.com/a.png" alt="A > B"/></figure>',
|
||||
);
|
||||
expect(markdownToTelegramRichHtml("See .")).toBe(
|
||||
'See <a href="https://example.com/a.jpg">Diagram</a>.',
|
||||
);
|
||||
expect(markdownToTelegramRichHtml("```\n\n```")).toBe(
|
||||
"<pre><code>\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&${"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);
|
||||
});
|
||||
|
||||
@@ -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 += "<";
|
||||
@@ -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("");
|
||||
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
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),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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 ", {
|
||||
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><img src="https://example.com/diagram.png">",
|
||||
},
|
||||
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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
25
packages/markdown-core/src/ir.raw-html.test.ts
Normal file
25
packages/markdown-core/src/ir.raw-html.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export type ResolveMarkdownTableModeParams = {
|
||||
cfg?: Partial<OpenClawConfig>;
|
||||
channel?: string | null;
|
||||
accountId?: string | null;
|
||||
supportsBlockTables?: boolean;
|
||||
};
|
||||
|
||||
export type ResolveMarkdownTableMode = (
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user