feat(telegram): emit Bot API 10.2 rich message blocks

This commit is contained in:
Ayaan Zaidi
2026-07-15 07:53:14 +05:30
parent 8170bdb556
commit e3bc0f326c
20 changed files with 1758 additions and 1363 deletions
+1 -1
View File
@@ -5,7 +5,7 @@ maintainer decisions and review-binding invariants, not incidental
implementation details. Also read `extensions/AGENTS.md` for the plugin
boundary rules.
Verified against Telegram Bot API 10.1, July 1 2026.
Verified against Telegram Bot API 10.2, July 14 2026.
## Reliability Invariants
@@ -769,7 +769,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
const preview = renderText?.("| A | B |\n| --- | --- |\n| 1 | 2 |");
expect(preview?.richMessage).toEqual(
expect.objectContaining({
html: expect.stringContaining("<table bordered striped>"),
blocks: [expect.objectContaining({ type: "table", is_bordered: true, is_striped: true })],
}),
);
});
@@ -379,6 +379,7 @@ type RichMessageParams = {
chat_id?: string | number;
message_id?: number;
rich_message?: {
blocks?: Array<{ type?: string; text?: unknown }>;
markdown?: string;
html?: string;
};
@@ -386,7 +387,22 @@ type RichMessageParams = {
};
function getRichMessageText(params: RichMessageParams): string {
return params.rich_message?.markdown ?? params.rich_message?.html ?? "";
const rich = params.rich_message;
if (!rich) {
return "";
}
if (rich.blocks) {
// Test harness only needs a readable plain-ish projection for assertions.
return rich.blocks
.map((block) => {
if (typeof block.text === "string") {
return block.text;
}
return JSON.stringify(block.text ?? "");
})
.join("\n");
}
return rich.markdown ?? rich.html ?? "";
}
function toLegacyMessageParams(params: RichMessageParams): Record<string, unknown> {
@@ -45,7 +45,13 @@ import {
resolveTelegramInteractiveTextFallback,
} from "../interactive-fallback.js";
import type { TelegramPromptContextProjectionSequence } from "../prompt-context-projection.js";
import { splitTelegramRichMessageTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js";
import type { TelegramRichBlocksDegradationReason } from "../rich-blocks.js";
import {
isEmptyTelegramRichMessage,
splitTelegramRichMessageTextChunks,
TELEGRAM_RICH_TEXT_LIMIT,
type TelegramInputRichMessage,
} from "../rich-message.js";
import { isTelegramHtmlParseError } from "../rich-plain-fallback.js";
import { buildInlineKeyboard, reactMessageTelegram } from "../send.js";
import { resolveTelegramVoiceSend } from "../voice.js";
@@ -91,7 +97,9 @@ type TelegramReplyQuoteForSend = {
type TelegramDeliveryTextChunk = {
text: string;
plainText: string;
textMode: "html";
textMode: "html" | "markdown";
richMessage?: TelegramInputRichMessage;
richDegradationReasons?: readonly TelegramRichBlocksDegradationReason[];
};
type ChunkTextFn = (markdown: string) => TelegramDeliveryTextChunk[];
@@ -104,16 +112,24 @@ function buildChunkTextResolver(params: {
skipEntityDetection?: boolean;
textMode?: "html";
}): ChunkTextFn {
if (params.richMessages === true) {
// Caller-authored HTML keeps legacy parse_mode HTML semantics even on rich
// accounts; the rich blocks path is markdown-only.
if (params.richMessages === true && params.textMode !== "html") {
return (text: string) =>
splitTelegramRichMessageTextChunks({
text,
textLimit: Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT),
textMode: params.textMode ?? "markdown",
chunkMode: params.chunkMode,
tableMode: params.tableMode,
skipEntityDetection: params.skipEntityDetection,
});
}).map((chunk) => ({
// text/textMode describe the non-rich fallback body, not the rich wire
// payload; plain text keeps the fallback parse-safe for both inputs.
text: chunk.plainText,
plainText: chunk.plainText,
textMode: "markdown" as const,
richMessage: chunk.richMessage,
richDegradationReasons: chunk.degradationReasons,
}));
}
if (params.textMode === "html") {
return (html: string) =>
@@ -157,10 +173,18 @@ function markDelivered(progress: DeliveryProgress): void {
progress.deliveredCount += 1;
}
function filterEmptyTelegramTextChunks<T extends { text: string }>(chunks: readonly T[]): T[] {
function filterEmptyTelegramTextChunks<
T extends { text: string; richMessage?: TelegramInputRichMessage },
>(chunks: readonly T[]): T[] {
// Telegram rejects whitespace-only text payloads; drop them before sendMessage so
// hook-mutated or model-emitted empty replies become a no-op instead of a 400.
return chunks.filter((chunk) => chunk.text.trim().length > 0);
// Rich chunks gate on the rich payload: valid rich content (media/divider HTML)
// can have an empty plain projection and must still send.
return chunks.filter((chunk) =>
chunk.richMessage
? !isEmptyTelegramRichMessage(chunk.richMessage)
: chunk.text.trim().length > 0,
);
}
function resolveReplyQuoteForSend(params: {
@@ -252,6 +276,8 @@ async function deliverTextReply(params: {
textMode: chunk.textMode,
plainText: chunk.plainText,
richMessages: params.richMessages,
richMessage: chunk.richMessage,
richDegradationReasons: chunk.richDegradationReasons,
linkPreview: params.linkPreview,
tableMode: params.tableMode,
silent: params.silent,
+23 -10
View File
@@ -14,16 +14,19 @@ import {
removeTelegramNativeQuoteParam,
} from "../reply-parameters.js";
import { TELEGRAM_OUTBOUND_RETRY_AFTER_CAP_MS } from "../retry-after.js";
import type { TelegramRichBlocksDegradationReason } from "../rich-blocks.js";
import {
buildTelegramRichMessagePlan,
buildTelegramRichMarkdownPlan,
getTelegramRichRawApi,
isEmptyTelegramRichMessage,
removeTelegramRichNativeQuoteParam,
toTelegramRichMessageContextParams,
type TelegramInputRichMessage,
} from "../rich-message.js";
import {
buildTelegramPlainFallbackPlan,
isTelegramHtmlParseError,
warnTelegramRichHtmlDegradations,
warnTelegramRichBlocksDegradations,
} from "../rich-plain-fallback.js";
import { buildInlineKeyboard } from "../send.js";
import type { TelegramThreadSpec } from "./helpers.js";
@@ -101,6 +104,8 @@ export async function sendTelegramText(
textMode?: "markdown" | "html";
plainText?: string;
richMessages?: boolean;
richMessage?: TelegramInputRichMessage;
richDegradationReasons?: readonly TelegramRichBlocksDegradationReason[];
linkPreview?: boolean;
tableMode?: MarkdownTableMode;
silent?: boolean;
@@ -140,17 +145,25 @@ export async function sendTelegramText(
return res.message_id;
};
if (opts?.richMessages === true) {
const richPlan = buildTelegramRichMessagePlan(text, textMode, {
skipEntityDetection: opts.linkPreview === false,
tableMode: opts.tableMode,
});
warnTelegramRichHtmlDegradations({
// Caller-authored HTML keeps legacy parse_mode HTML semantics (literal
// newlines, tag-aware chunking) even on rich accounts.
if (opts?.richMessages === true && textMode !== "html") {
const richPlan = opts.richMessage
? {
richMessage: opts.richMessage,
plainText: fallbackText,
degradationReasons: opts.richDegradationReasons ?? [],
}
: buildTelegramRichMarkdownPlan(text, {
skipEntityDetection: opts.linkPreview === false,
tableMode: opts.tableMode,
});
warnTelegramRichBlocksDegradations({
context: "sendRichMessage",
reasons: richPlan.degradationReasons,
warn: (message) => runtime.log?.(message),
});
if (!richPlan.richMessage.html?.trim()) {
if (isEmptyTelegramRichMessage(richPlan.richMessage)) {
if (!hasFallbackText) {
throw new Error(
"telegram sendRichMessage failed: empty rich text and empty plain fallback",
@@ -178,7 +191,7 @@ export async function sendTelegramText(
return res.message_id;
} catch (err) {
const fallbackPlan = buildTelegramPlainFallbackPlan({
html: richPlan.richMessage.html,
plainText: richPlan.plainText || fallbackText,
err,
context: "sendRichMessage",
warn: (message) => runtime.log?.(message),
+15 -3
View File
@@ -90,7 +90,12 @@ function createBot(api: Record<string, unknown> = {}): Bot {
sendRichMessage: vi.fn(
(params: {
chat_id: string | number;
rich_message: { markdown?: string; html?: string; skip_entity_detection?: boolean };
rich_message: {
blocks?: unknown[];
markdown?: string;
html?: string;
skip_entity_detection?: boolean;
};
[key: string]: unknown;
}) => {
const sendMessage = api.sendMessage;
@@ -103,7 +108,14 @@ function createBot(api: Record<string, unknown> = {}): Bot {
...(rich_message.skip_entity_detection === true ? { skip_entity_detection: true } : {}),
...richParams,
};
const text = rich_message.markdown ?? rich_message.html ?? "";
const text = Array.isArray(rich_message.blocks)
? rich_message.blocks
.map((block) => {
const blockText = (block as { text?: unknown }).text;
return typeof blockText === "string" ? blockText : "";
})
.join("\n")
: (rich_message.markdown ?? rich_message.html ?? "");
const replyParameters = sendParams.reply_parameters;
if (
replyParameters &&
@@ -1407,7 +1419,7 @@ describe("deliverReplies", () => {
};
const richMessage = raw.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
expect(richMessage).toEqual({
html: oauthProfileText,
blocks: [{ type: "paragraph", text: oauthProfileText }],
skip_entity_detection: true,
});
});
+69 -89
View File
@@ -986,16 +986,13 @@ describe("createTelegramDraftStream", () => {
});
});
it("sends caller-provided rich previews through standard text transport", async () => {
it("sends caller-provided HTML previews through standard text transport", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api);
stream.updatePreview({
text: "Shelling\n\n`🛠️ Exec`",
richMessage: {
html: "<b>Shelling</b>\n<b>🛠️ Exec</b>",
skip_entity_detection: true,
},
text: "<b>Shelling</b>\n<b>🛠️ Exec</b>",
parseMode: "HTML",
});
await stream.flush();
@@ -1005,11 +1002,8 @@ describe("createTelegramDraftStream", () => {
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
stream.updatePreview({
text: "Shelling\n\n`🛠️ Exec`\n• _Checking files_",
richMessage: {
html: "<b>Shelling</b>\n<b>🛠️ Exec</b>\n<i>Checking files</i>",
skip_entity_detection: true,
},
text: "<b>Shelling</b>\n<b>🛠️ Exec</b>\n<i>Checking files</i>",
parseMode: "HTML",
});
await stream.flush();
@@ -1022,16 +1016,13 @@ describe("createTelegramDraftStream", () => {
expect(api.raw.editMessageText).not.toHaveBeenCalled();
});
it("sends marked progress rich previews through HTML text transport", async () => {
it("sends marked progress HTML previews through HTML text transport", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api);
stream.updatePreview({
text: "Shelling\n\n🛠️ Exec",
richMessage: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b>",
skip_entity_detection: true,
},
text: "<b>Shelling</b>\n<b>🛠️ Exec</b>",
parseMode: "HTML",
});
await stream.flush();
@@ -1041,11 +1032,8 @@ describe("createTelegramDraftStream", () => {
expect(api.raw.sendRichMessage).not.toHaveBeenCalled();
stream.updatePreview({
text: "Shelling\n\n🛠️ Exec\n• Checking files",
richMessage: {
html: "<b>Shelling</b><br><b>🛠️ Exec</b><br><b>Update</b> <code>Checking files</code>",
skip_entity_detection: true,
},
text: "<b>Shelling</b>\n<b>🛠️ Exec</b>\n<b>Update</b> <code>Checking files</code>",
parseMode: "HTML",
});
await stream.flush();
@@ -1058,7 +1046,7 @@ describe("createTelegramDraftStream", () => {
expect(api.raw.editMessageText).not.toHaveBeenCalled();
});
it("falls back to plain preview text when rich preview HTML parsing fails", async () => {
it("falls back to plain preview text when HTML parsing fails", async () => {
const api = createMockDraftApi();
api.sendMessage
.mockRejectedValueOnce(new Error("can't parse entities: unsupported tag"))
@@ -1066,11 +1054,8 @@ describe("createTelegramDraftStream", () => {
const stream = createDraftStream(api);
stream.updatePreview({
text: "Shelling <&>\n\n🛠️ Exec",
richMessage: {
html: "<b>Shelling &lt;&amp;&gt;</b>\n<b>🛠️ Exec</b>",
skip_entity_detection: true,
},
text: "<b>Shelling &lt;&amp;&gt;</b>\n<b>🛠️ Exec</b>",
parseMode: "HTML",
});
await stream.flush();
@@ -1080,10 +1065,10 @@ describe("createTelegramDraftStream", () => {
"<b>Shelling &lt;&amp;&gt;</b>\n<b>🛠️ Exec</b>",
{ parse_mode: "HTML" },
);
expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "Shelling <&>\n\n🛠️ Exec", {});
expect(api.sendMessage).toHaveBeenNthCalledWith(2, 123, "Shelling <&>\n🛠️ Exec", {});
expect(stream.currentMessageSnapshot?.()).toEqual({
text: "Shelling <&>\n\n🛠️ Exec",
sourceText: "Shelling &lt;&amp;&gt;\n\n🛠️ Exec",
text: "Shelling <&>\n🛠️ Exec",
sourceText: "Shelling &lt;&amp;&gt;\n🛠️ Exec",
sourceTextMode: "html",
});
@@ -1091,8 +1076,8 @@ describe("createTelegramDraftStream", () => {
.mockRejectedValueOnce(new Error("can't parse entities: unsupported tag"))
.mockResolvedValueOnce(true);
stream.updatePreview({
text: "Done <&>",
richMessage: { html: "<b>Done &lt;&amp;&gt;</b>" },
text: "<b>Done &lt;&amp;&gt;</b>",
parseMode: "HTML",
});
await stream.flush();
@@ -1111,37 +1096,29 @@ describe("createTelegramDraftStream", () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, { richMessages: true });
stream.updatePreview({
text: "Plan",
richMessage: { html: "<h2>Plan</h2><table><tr><td>A</td></tr></table>" },
});
stream.update("## Plan\n\n| A |\n| --- |\n| x |");
await stream.flush();
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: {
html: "<h2>Plan</h2><table bordered striped><thead><tr><th>A</th></tr></thead></table>",
},
});
expect(api.raw.sendRichMessage).toHaveBeenCalledTimes(1);
const first = api.raw.sendRichMessage.mock.calls[0]?.[0] as {
rich_message?: TelegramInputRichMessage;
};
expect(first?.rich_message?.blocks?.some((block) => block.type === "heading")).toBe(true);
expect(first?.rich_message?.blocks?.some((block) => block.type === "table")).toBe(true);
expect(api.sendMessage).not.toHaveBeenCalled();
stream.updatePreview({
text: "Plan updated",
richMessage: { html: "<h2>Plan updated</h2><table><tr><td>B</td></tr></table>" },
});
stream.update("## Plan updated\n\n| B |\n| --- |\n| y |");
await stream.flush();
expect(api.raw.editMessageText).toHaveBeenCalledWith({
chat_id: 123,
message_id: 17,
rich_message: {
html: "<h2>Plan updated</h2><table bordered striped><thead><tr><th>B</th></tr></thead></table>",
},
});
expect(api.raw.editMessageText).toHaveBeenCalledTimes(1);
const edit = api.raw.editMessageText.mock.calls[0]?.[0] as {
rich_message?: TelegramInputRichMessage;
};
expect(edit?.rich_message?.blocks?.some((block) => block.type === "heading")).toBe(true);
expect(api.editMessageText).not.toHaveBeenCalled();
});
it("uses table-aware plain text when rich preview fallback sends", async () => {
it("uses plain text when rich preview fallback sends", async () => {
const api = createMockDraftApi();
api.raw.sendRichMessage.mockRejectedValueOnce(
new Error("400: Bad Request: RICH_MESSAGE_URL_INVALID"),
@@ -1149,27 +1126,16 @@ describe("createTelegramDraftStream", () => {
const warn = vi.fn();
const stream = createDraftStream(api, { richMessages: true, warn });
stream.updatePreview({
text: "Plan",
richMessage: {
html: "<table><tr><td>Rank</td><td>Model</td><td>Score</td></tr><tr><td>4</td><td>Claude Opus</td><td>78.16%</td></tr></table>",
},
});
stream.update("| Rank | Model |\n| --- | --- |\n| 4 | Claude Opus |");
await stream.flush();
expect(api.sendMessage).toHaveBeenCalledWith(
123,
"Rank | Model | Score\n4 | Claude Opus | 78.16%",
{},
);
expect(api.sendMessage).toHaveBeenCalledTimes(1);
const plain = api.sendMessage.mock.calls[0]?.[1] ?? "";
expect(plain).toContain("Rank");
expect(plain).toContain("Claude Opus");
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("rich-degrade=plain-fallback:rich-entity-invalid"),
);
expect(stream.currentMessageSnapshot?.()).toEqual({
text: "Rank | Model | Score\n4 | Claude Opus | 78.16%",
sourceText: "Rank | Model | Score\n4 | Claude Opus | 78.16%",
sourceTextMode: "html",
});
});
it("skips rich entity detection for draft text with provider-prefixed email addresses", async () => {
@@ -1184,19 +1150,19 @@ describe("createTelegramDraftStream", () => {
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: {
html: oauthProfileText,
blocks: [{ type: "paragraph", text: oauthProfileText }],
skip_entity_detection: true,
},
});
});
it("keeps rich preview html out of plain preview gating", async () => {
it("keeps short rich previews out of plain preview gating", async () => {
const api = createMockDraftApi();
const stream = createDraftStream(api, { richMessages: true, minInitialChars: 10 });
stream.updatePreview({
text: "Plan",
richMessage: { html: "<h2>Plan</h2><table><tr><td>A</td></tr></table>" },
richMessage: { blocks: [{ type: "heading", text: "Plan", size: 2 }] },
});
await stream.flush();
@@ -1215,8 +1181,13 @@ describe("createTelegramDraftStream", () => {
const calls = api.raw.sendRichMessage.mock.calls as unknown[][];
const params = calls[0]?.[0] as { rich_message?: TelegramInputRichMessage } | undefined;
const richMessage = params?.rich_message;
expect(richMessage?.html).toContain("paragraph 499");
expect(richMessage?.html).not.toContain("paragraph 500");
const plain = (richMessage?.blocks ?? [])
.map((block) =>
block.type === "paragraph" && typeof block.text === "string" ? block.text : "",
)
.join("\n");
expect(plain).toContain("paragraph 499");
expect(plain).not.toContain("paragraph 500");
});
it("clamps rendered previews to the text-message limit", async () => {
@@ -1364,7 +1335,9 @@ describe("createTelegramDraftStream", () => {
"```",
].join("\n");
const stream = createDraftStream(api, {
maxChars: 55,
// Plain code body is shorter than HTML-wrapped rich text; keep the limit
// under the pre body so pagination still splits across messages.
maxChars: 30,
richMessages: true,
onRetainedPage: onSupersededPreview,
});
@@ -1374,18 +1347,22 @@ describe("createTelegramDraftStream", () => {
const pages = api.raw.sendRichMessage.mock.calls.map((call) => {
const params = call[0] as { rich_message?: TelegramInputRichMessage };
return params.rich_message?.html ?? "";
return params.rich_message?.blocks ?? [];
});
expect(pages.length).toBeGreaterThan(1);
expect(pages.every((blocks) => blocks.every((block) => block.type === "pre"))).toBe(true);
expect(
pages.every((page) => /^<pre><code class="language-ts">[\s\S]*<\/code><\/pre>$/u.test(page)),
pages.every((blocks) =>
blocks.some((block) => block.type === "pre" && block.language === "ts"),
),
).toBe(true);
const fullRichMessage = buildTelegramRichMarkdown(text);
if (!fullRichMessage.html) {
throw new Error("expected rendered Telegram rich HTML");
}
expect(pages.map(telegramHtmlToPlainTextFallback).join("")).toBe(
telegramHtmlToPlainTextFallback(fullRichMessage.html),
expect(
pages
.flatMap((blocks) => blocks.map((block) => (block.type === "pre" ? block.text : "")))
.join(""),
).toBe(
fullRichMessage.blocks.map((block) => (block.type === "pre" ? block.text : "")).join(""),
);
expect(onSupersededPreview).toHaveBeenCalledTimes(pages.length - 1);
});
@@ -1400,13 +1377,16 @@ describe("createTelegramDraftStream", () => {
const pages = api.raw.sendRichMessage.mock.calls.map((call) => {
const params = call[0] as { rich_message?: TelegramInputRichMessage };
return params.rich_message?.html ?? "";
return params.rich_message?.blocks ?? [];
});
expect(pages.length).toBeGreaterThan(1);
expect(pages.every((page) => /^<pre><code>[\s\S]*<\/code><\/pre>$/u.test(page))).toBe(true);
expect(pages.map(telegramHtmlToPlainTextFallback).join("").replace(/\n$/u, "")).toBe(
" ".repeat(80),
);
expect(pages.every((blocks) => blocks.every((block) => block.type === "pre"))).toBe(true);
expect(
pages
.flatMap((blocks) => blocks.map((block) => (block.type === "pre" ? block.text : "")))
.join("")
.replace(/\n$/u, ""),
).toBe(" ".repeat(80));
});
it("keeps non-final overflow in one editable preview", async () => {
+64 -56
View File
@@ -11,10 +11,8 @@ import { buildTelegramThreadParams, type TelegramThreadSpec } from "./bot/helper
import {
escapeTelegramHtml,
markdownToTelegramChunks,
renderTelegramHtmlText,
splitTelegramHtmlChunks,
telegramHtmlToPlainTextFallback,
type TelegramRichHtmlDegradationReason,
} from "./format.js";
import {
isRecoverableTelegramNetworkError,
@@ -27,11 +25,14 @@ import {
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
import { normalizeTelegramReplyToMessageId } from "./outbound-params.js";
import {
buildTelegramRichHtmlPlan,
inputRichBlocksToPlainText,
splitTelegramRichBlocks,
type TelegramRichBlocksDegradationReason,
} from "./rich-blocks.js";
import {
buildTelegramRichBlocksPlan,
buildTelegramRichMarkdownPlan,
getTelegramRichRawApi,
splitTelegramRichMarkdownChunks,
splitTelegramRichMessageTextChunks,
TELEGRAM_RICH_TEXT_LIMIT,
type TelegramInputRichMessage,
} from "./rich-message.js";
@@ -39,7 +40,7 @@ import {
buildTelegramPlainFallbackPlan,
isTelegramHtmlParseError,
splitTelegramPlainTextChunks,
warnTelegramRichHtmlDegradations,
warnTelegramRichBlocksDegradations,
} from "./rich-plain-fallback.js";
const DEFAULT_THROTTLE_MS = 1000;
@@ -112,8 +113,8 @@ export type TelegramDraftPreview = {
type PlannedTelegramDraftPage = TelegramDraftMessageSnapshot & {
sourceTextMode: "html" | "markdown";
fullSourceText?: string;
richMessage?: Extract<TelegramInputRichMessage, { html: string }>;
degradationReasons?: readonly TelegramRichHtmlDegradationReason[];
richMessage?: TelegramInputRichMessage;
degradationReasons?: readonly TelegramRichBlocksDegradationReason[];
};
type RetainedTelegramDraftPage = {
@@ -131,49 +132,61 @@ function telegramRichHtmlToParseModeHtml(html: string): string {
return html.replace(/<br\s*\/?>/giu, "\n");
}
function buildTelegramDraftRichPlan(preview: TelegramDraftPreview) {
const options = preview.richMessage
? { skipEntityDetection: preview.richMessage.skip_entity_detection === true }
: undefined;
if (preview.richMessage?.html !== undefined) {
return buildTelegramRichHtmlPlan(preview.richMessage.html, options);
}
return buildTelegramRichMarkdownPlan(preview.richMessage?.markdown ?? preview.text, options);
}
function planTelegramDraftPages(
preview: TelegramDraftPreview,
maxChars: number,
richMessages: boolean,
): PlannedTelegramDraftPage[] {
if (richMessages) {
const previews = preview.richMessage
? [preview]
: splitTelegramRichMarkdownChunks(preview.text, Number.MAX_SAFE_INTEGER, "length").map(
(text) => ({ text }),
);
const pages: PlannedTelegramDraftPage[] = [];
for (const richPreview of previews) {
const plan = buildTelegramDraftRichPlan(richPreview);
const planPages: PlannedTelegramDraftPage[] = splitTelegramRichMessageTextChunks({
text: plan.richMessage.html,
const previewRich = preview.richMessage;
if (previewRich) {
const skipEntityDetection = previewRich.skip_entity_detection === true;
return splitTelegramRichBlocks(previewRich.blocks, {
textLimit: maxChars,
textMode: "html",
chunkMode: "length",
skipEntityDetection: plan.richMessage.skip_entity_detection === true,
}).map((page) => ({
}).map((blocks) => {
const plainText = inputRichBlocksToPlainText(blocks);
return {
text: plainText,
sourceText: plainText,
sourceTextMode: "markdown" as const,
richMessage: {
blocks,
...(skipEntityDetection ? { skip_entity_detection: true } : {}),
},
};
});
}
const plan = buildTelegramRichMarkdownPlan(preview.text);
// Every page carries the plan's document-level skip flag: the render already
// committed to that linkify decision, so per-page re-derivation would leave
// unprotected file refs in pages without the skip trigger.
const planSkip = plan.richMessage.skip_entity_detection === true;
const pages = splitTelegramRichBlocks(plan.richMessage.blocks, {
textLimit: maxChars,
}).map((blocks, index) => {
const page = buildTelegramRichBlocksPlan(blocks, { skipEntityDetection: planSkip });
const planned: PlannedTelegramDraftPage = {
text: page.plainText,
sourceText: page.text,
sourceTextMode: page.textMode,
richMessage: {
html: page.text,
...(page.skipEntityDetection ? { skip_entity_detection: true } : {}),
},
}));
if (planPages[0] && plan.degradationReasons.length > 0) {
planPages[0].degradationReasons = plan.degradationReasons;
sourceText: page.plainText,
sourceTextMode: "markdown",
richMessage: page.richMessage,
};
if (index === 0 && plan.degradationReasons.length > 0) {
planned.degradationReasons = plan.degradationReasons;
}
pages.push(...planPages);
return planned;
});
if (pages.length === 0 && preview.text.trim()) {
// Mirror the durable funnel: markdown that projects to zero blocks
// (link definitions only) still previews as readable source text.
return [
{
text: preview.text,
sourceText: preview.text,
sourceTextMode: "markdown",
richMessage: { blocks: [{ type: "paragraph", text: preview.text }] },
},
];
}
return pages;
}
@@ -188,13 +201,10 @@ function planTelegramDraftPages(
sourceTextMode: "html",
}));
}
const htmlText = preview.richMessage?.html
? telegramRichHtmlToParseModeHtml(preview.richMessage.html)
: preview.richMessage?.markdown
? renderTelegramHtmlText(preview.richMessage.markdown)
: preview.parseMode === "HTML"
? telegramRichHtmlToParseModeHtml(preview.text)
: undefined;
// Non-rich path: progress drafts may still pass parseMode HTML text.
// Blocks-only richMessage is ignored here — richMessages must be enabled.
const htmlText =
preview.parseMode === "HTML" ? telegramRichHtmlToParseModeHtml(preview.text) : undefined;
if (htmlText === undefined) {
return splitTelegramPlainTextChunks(preview.text, maxChars)
.map((chunk, index) => (index === 0 ? chunk.trimEnd() : chunk.trim()))
@@ -205,9 +215,7 @@ function planTelegramDraftPages(
sourceTextMode: "markdown",
}));
}
const plainText = preview.richMessage
? preview.text
: telegramHtmlToPlainTextFallback(preview.text);
const plainText = telegramHtmlToPlainTextFallback(preview.text);
const htmlPages = splitTelegramHtmlChunks(htmlText, maxChars);
return htmlPages.map((sourceText) => ({
text: htmlPages.length === 1 ? plainText : telegramHtmlToPlainTextFallback(sourceText),
@@ -309,7 +317,7 @@ export function createTelegramDraftStream(params: {
sendMessageParams: ReturnType<typeof reserveReplyTargetForSend>,
) => {
if (page.richMessage) {
warnTelegramRichHtmlDegradations({
warnTelegramRichBlocksDegradations({
context: "stream preview",
reasons: page.degradationReasons ?? [],
warn: (message) => params.warn?.(message),
@@ -325,7 +333,7 @@ export function createTelegramDraftStream(params: {
};
} catch (err) {
const fallbackPlan = buildTelegramPlainFallbackPlan({
html: page.richMessage.html,
plainText: page.text,
err,
context: "stream preview",
warn: (message) => params.warn?.(message),
@@ -372,7 +380,7 @@ export function createTelegramDraftStream(params: {
streamVisibleSinceMs ??= Date.now();
let acceptedSnapshot: TelegramDraftMessageSnapshot = page;
if (page.richMessage) {
warnTelegramRichHtmlDegradations({
warnTelegramRichBlocksDegradations({
context: "stream preview edit",
reasons: page.degradationReasons ?? [],
warn: (message) => params.warn?.(message),
@@ -385,7 +393,7 @@ export function createTelegramDraftStream(params: {
});
} catch (err) {
const fallbackPlan = buildTelegramPlainFallbackPlan({
html: page.richMessage.html,
plainText: page.text,
err,
context: "stream preview edit",
warn: (message) => params.warn?.(message),
+18 -25
View File
@@ -1,9 +1,16 @@
const TELEGRAM_HTML_ENTITY_PATTERN = /&(#[xX][0-9A-Fa-f]+|#\d+|amp|lt|gt|quot|apos);/g;
const TELEGRAM_RICH_BLOCK_HTML_TAGS = new Set([
// Structural tags that force a line boundary when projecting HTML to plain text
// (assistant transcript protection). Block-counting helpers for rich HTML are gone.
const TELEGRAM_LINE_BREAK_STRUCTURAL_TAGS = new Set([
"aside",
"audio",
"blockquote",
"caption",
"col",
"colgroup",
"details",
"figcaption",
"figure",
"footer",
"h1",
@@ -18,7 +25,13 @@ const TELEGRAM_RICH_BLOCK_HTML_TAGS = new Set([
"ol",
"p",
"pre",
"summary",
"table",
"tbody",
"td",
"tfoot",
"th",
"thead",
"tg-collage",
"tg-map",
"tg-math-block",
@@ -28,31 +41,11 @@ const TELEGRAM_RICH_BLOCK_HTML_TAGS = new Set([
"video",
]);
// Includes table/figure/details children omitted from the block-counting set.
const TELEGRAM_RICH_LINE_BREAK_STRUCTURAL_TAGS: ReadonlySet<string> = new Set([
...TELEGRAM_RICH_BLOCK_HTML_TAGS,
"caption",
"col",
"colgroup",
"figcaption",
"summary",
"tbody",
"td",
"tfoot",
"th",
"thead",
]);
function isNamedAnchor(rawTag: string, tagName: string): boolean {
return tagName === "a" && /\sname="[^"]+"/i.test(rawTag);
}
export function isTelegramRichBlockHtmlTag(rawTag: string, tagName: string): boolean {
return TELEGRAM_RICH_BLOCK_HTML_TAGS.has(tagName) || isNamedAnchor(rawTag, tagName);
}
export function isTelegramRichLineBreakStructuralTag(rawTag: string, tagName: string): boolean {
return TELEGRAM_RICH_LINE_BREAK_STRUCTURAL_TAGS.has(tagName) || isNamedAnchor(rawTag, tagName);
return (
TELEGRAM_LINE_BREAK_STRUCTURAL_TAGS.has(tagName) ||
(tagName === "a" && /\sname="[^"]+"/i.test(rawTag))
);
}
function isValidTelegramHtmlEntityCodePoint(codePoint: number): boolean {
+14 -646
View File
@@ -1,30 +1,20 @@
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
// Telegram helper module supports format behavior.
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
FILE_REF_EXTENSIONS_WITH_TLD,
isAutoLinkedFileRef,
markdownToIR,
markdownToIRWithMeta,
type MarkdownLinkSpan,
type MarkdownIR,
type MarkdownTableCell,
type MarkdownTableMeta,
renderMarkdownIRChunksWithinLimit,
sliceMarkdownIR,
tokenizeHtmlTags,
} from "openclaw/plugin-sdk/text-chunking";
import {
protectTelegramAssistantTranscriptRoleHeaders,
TELEGRAM_ASSISTANT_TRANSCRIPT_PREFIX,
} from "./format-assistant-transcript.js";
import {
decodeTelegramHtmlEntities,
findTelegramHtmlEntityEnd,
isTelegramRichBlockHtmlTag,
isTelegramRichLineBreakStructuralTag,
} from "./format-html.js";
import { decodeTelegramHtmlEntities, findTelegramHtmlEntityEnd } from "./format-html.js";
import { renderTelegramMarkdownIR } from "./format-render.js";
export type TelegramFormattedChunk = {
@@ -32,15 +22,6 @@ export type TelegramFormattedChunk = {
text: string;
};
const TELEGRAM_RICH_NESTING_LIMIT = 16;
export type TelegramRichHtmlDegradationReason = "table-ascii";
type TelegramOutboundRichHtmlNormalization = {
html: string;
degradationReasons: readonly TelegramRichHtmlDegradationReason[];
};
export function escapeTelegramHtml(text: string): string {
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
@@ -204,23 +185,11 @@ const TELEGRAM_HTML_ANCHOR_PATTERN =
/<a\b[^>]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>([\s\S]*?)<\/a\s*>/gi;
const TELEGRAM_HTML_BREAK_PATTERN = /<br\s*\/?>/gi;
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_CANONICAL_RICH_HTML_TABLE_PATTERN = /^<table bordered striped>/i;
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_HTML_ROWSPAN_PATTERN = /\browspan\s*=/i;
const TELEGRAM_HTML_ALIGN_PATTERN =
/\balign\s*=\s*(?:"(left|center|right)"|'(left|center|right)'|(left|center|right))/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",
@@ -243,102 +212,20 @@ 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_MEDIA_HTML_TAGS = new Set(["audio", "img", "video"]);
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>;
};
type TelegramTableAlignment = NonNullable<MarkdownTableMeta["aligns"]>[number];
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,
};
let fileReferencePattern: RegExp | undefined;
let orphanedTldPattern: RegExp | undefined;
function popLastTagName(tags: string[], name: string): boolean {
for (let index = tags.length - 1; index >= 0; index -= 1) {
@@ -672,28 +559,6 @@ export function renderTelegramHtmlText(
return markdownToTelegramHtml(text, { tableMode: options.tableMode });
}
export function normalizeTelegramOutboundRichHtml(
html: string,
): TelegramOutboundRichHtmlNormalization {
const tableNormalized = normalizeTelegramRichHtmlTables(html);
// This is the Bot API 10.1 rich-message wire contract. A second send-side
// sanitizer would let raw tables or silent drops drift between send funnels.
const safeHtml = limitTelegramRichHtmlNesting(
materializeTelegramRichHtmlLineBreaks(
normalizeTelegramRichLiteralWhitespaceEscapes(
isolateTelegramRichMediaBlocks(
escapeUnsupportedTelegramHtml(tableNormalized.html, TELEGRAM_RICH_HTML_TAG_SUPPORT),
),
),
),
TELEGRAM_RICH_NESTING_LIMIT,
);
return {
html: safeHtml,
degradationReasons: tableNormalized.degradationReasons,
};
}
function escapeUnsupportedTelegramHtmlWithTableFallback(html: string): string {
return escapeUnsupportedTelegramHtml(
normalizeTelegramLegacyHtmlTables(html),
@@ -733,74 +598,10 @@ function normalizeTelegramLegacyHtmlTables(html: string): string {
});
}
function limitTelegramRichHtmlNesting(html: string, maxDepth: number): string {
const normalizedMaxDepth = Math.max(1, Math.floor(maxDepth));
const stack: Array<{ name: string; kept: boolean }> = [];
let keptDepth = 0;
let output = "";
let lastIndex = 0;
for (const tag of tokenizeHtmlTags(html)) {
output += html.slice(lastIndex, tag.start);
const rawTag = tag.raw;
const isClosing = tag.closing;
const tagName = tag.name;
const isSelfClosing =
!isClosing && (TELEGRAM_VOID_HTML_TAGS.has(tagName) || rawTag.trimEnd().endsWith("/>"));
if (isClosing) {
const entryIndex = stack.findLastIndex((entry) => entry.name === tagName);
if (entryIndex >= 0) {
const [entry] = stack.splice(entryIndex, 1);
if (entry?.kept) {
keptDepth = Math.max(0, keptDepth - 1);
output += rawTag;
}
}
} else if (isSelfClosing) {
if (tagName === "br" || keptDepth < normalizedMaxDepth) {
output += rawTag;
}
} else {
const kept = keptDepth < normalizedMaxDepth;
stack.push({ name: tagName, kept });
if (kept) {
keptDepth += 1;
output += rawTag;
}
}
lastIndex = tag.end;
}
return output + html.slice(lastIndex);
}
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;
return Number.isFinite(value) && value > 1 ? Math.min(value, 21) : 1;
}
function parseTelegramRichHtmlTableRows(tableHtml: string): string[][] {
@@ -855,411 +656,10 @@ function renderTelegramRichHtmlRawTableFallback(
return `<pre><code>${escapeHtml([caption, tableText].filter(Boolean).join("\n"))}</code></pre>\n\n`;
}
function emptyTelegramTableCell(text: string): MarkdownTableCell {
return {
text,
styles: [],
links: [],
};
}
type TelegramRawRichHtmlTableMeta = MarkdownTableMeta & {
caption?: string;
rawRichHtmlTable?: true;
};
type TelegramRawRichHtmlTableCell = MarkdownTableCell & {
align?: TelegramTableAlignment;
colspan?: number;
};
function parseTelegramHtmlAlign(attrs: string): TelegramTableAlignment | undefined {
return TELEGRAM_HTML_ALIGN_PATTERN.exec(attrs)?.slice(1).find(Boolean) as
| TelegramTableAlignment
| undefined;
}
function parseTelegramRichHtmlTableAligns(
tableHtml: string,
): (TelegramTableAlignment | undefined)[] {
TELEGRAM_RICH_HTML_TABLE_ROW_PATTERN.lastIndex = 0;
const firstRow = TELEGRAM_RICH_HTML_TABLE_ROW_PATTERN.exec(tableHtml)?.[1] ?? "";
const aligns: (TelegramTableAlignment | undefined)[] = [];
TELEGRAM_RICH_HTML_TABLE_CELL_PATTERN.lastIndex = 0;
let cellMatch: RegExpExecArray | null;
while ((cellMatch = TELEGRAM_RICH_HTML_TABLE_CELL_PATTERN.exec(firstRow)) !== null) {
const attrs = cellMatch[2] ?? "";
aligns.push(
...Array.from({ length: parseTelegramHtmlColspan(attrs) }, () =>
parseTelegramHtmlAlign(attrs),
),
);
}
return aligns;
}
function parseTelegramRichHtmlTableCaption(tableHtml: string): string | undefined {
const caption = telegramHtmlToPlainTextFallback(
TELEGRAM_HTML_CAPTION_PATTERN.exec(tableHtml)?.[1] ?? "",
).trim();
return caption || undefined;
}
function parseTelegramRichHtmlTableCellRows(tableHtml: string): TelegramRawRichHtmlTableCell[][] {
const rows: TelegramRawRichHtmlTableCell[][] = [];
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: TelegramRawRichHtmlTableCell[] = [];
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();
const colspan = parseTelegramHtmlColspan(attrs);
const align = parseTelegramHtmlAlign(attrs);
row.push({
...emptyTelegramTableCell(text),
...(align ? { align } : {}),
...(colspan > 1 ? { colspan } : {}),
});
}
if (row.length) {
rows.push(row);
}
}
return rows;
}
function buildTelegramRichHtmlTableMeta(
tableHtml: string,
rows: readonly string[][],
): TelegramRawRichHtmlTableMeta {
const [headers = [], ...bodyRows] = rows;
const [headerCells = headers.map(emptyTelegramTableCell), ...rowCells] =
parseTelegramRichHtmlTableCellRows(tableHtml);
const caption = parseTelegramRichHtmlTableCaption(tableHtml);
return {
headers: [...headers],
rows: bodyRows.map((row) => row.slice()),
aligns: parseTelegramRichHtmlTableAligns(tableHtml),
...(caption ? { caption } : {}),
rawRichHtmlTable: true,
placeholderOffset: 0,
headerCells,
rowCells,
};
}
function normalizeTelegramRichHtmlTables(html: string): TelegramOutboundRichHtmlNormalization {
const degradationReasons = new Set<TelegramRichHtmlDegradationReason>();
TELEGRAM_RICH_HTML_TABLE_PATTERN.lastIndex = 0;
const normalizedHtml = html.replace(TELEGRAM_RICH_HTML_TABLE_PATTERN, (tableHtml) => {
if (TELEGRAM_CANONICAL_RICH_HTML_TABLE_PATTERN.test(tableHtml)) {
return tableHtml;
}
const rows = parseTelegramRichHtmlTableRows(tableHtml);
const columnCount = Math.max(...rows.map((row) => row.length), 0);
if (
!rows.length ||
columnCount > TELEGRAM_RICH_TEXT_TABLE_COLUMN_LIMIT ||
TELEGRAM_HTML_ROWSPAN_PATTERN.test(tableHtml)
) {
degradationReasons.add("table-ascii");
return renderTelegramRichHtmlRawTableFallback(tableHtml, rows);
}
return renderTelegramRichHtmlTable(buildTelegramRichHtmlTableMeta(tableHtml, rows));
});
return {
html: normalizedHtml,
degradationReasons: [...degradationReasons],
};
}
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 = expectDefined(match[1], "rich Markdown media indent capture");
const alt = match[2];
const src = expectDefined(match[3], "rich Markdown media source capture");
const caption = match[4];
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 isRawRichHtmlTable = "rawRichHtmlTable" in table && table.rawRichHtmlTable === true;
const rawCaption =
"caption" in table && typeof table.caption === "string" ? table.caption.trim() : "";
const caption = rawCaption ? `<caption>${escapeHtml(rawCaption)}</caption>` : "";
const renderCellValue = (cell: MarkdownTableCell | undefined) =>
cell ? renderTelegramHtml(cell) : "";
const renderCell = (
tag: "td" | "th",
value: MarkdownTableCell | undefined,
align: TelegramTableAlignment | undefined,
) => {
const rawCell = value as TelegramRawRichHtmlTableCell | undefined;
const alignValue = rawCell?.align ?? align;
const alignAttr = alignValue ? ` align="${alignValue}"` : "";
const colspanAttr = rawCell?.colspan ? ` colspan="${rawCell.colspan}"` : "";
return `<${tag}${alignAttr}${colspanAttr}>${renderCellValue(value)}</${tag}>`;
};
const head = table.headers.length
? `<thead><tr>${
isRawRichHtmlTable
? table.headerCells.map((cell) => renderCell("th", cell, undefined)).join("")
: table.headerCells
.map((cell, index) => renderCell("th", cell, table.aligns?.[index]))
.join("")
}</tr></thead>`
: "";
const bodyRows = isRawRichHtmlTable
? table.rowCells
.map((row) => `<tr>${row.map((cell) => renderCell("td", cell, undefined)).join("")}</tr>`)
.join("")
: table.rowCells
.map(
(row) =>
`<tr>${Array.from({ length: columnCount }, (_value, index) => renderCell("td", row[index], table.aligns?.[index])).join("")}</tr>`,
)
.join("");
const body = bodyRows ? `<tbody>${bodyRows}</tbody>` : "";
return `<table bordered striped>${caption}${head}${body}</table>\n\n`;
}
function renderTelegramRichHtmlDocument(
ir: MarkdownIR,
tables: readonly MarkdownTableMeta[],
): string {
if (!tables.length) {
return isolateTelegramRichMediaBlocks(
wrapFileReferencesInHtml(
renderSupportedTelegramHtml(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(renderSupportedTelegramHtml(html, TELEGRAM_RICH_HTML_TAG_SUPPORT)),
);
}
function convertTelegramRichSegmentNewlines(
segment: string,
prevStructural: boolean,
nextStructural: boolean,
): string {
if (!segment.includes("\n")) {
return segment;
}
// Keep newline runs that hug a structural tag: Telegram already starts a new
// line there, so a stray <br> would add a blank line or land as an invalid
// child inside a container (table/figure/details/list).
return segment.replace(/\n+/g, (run: string, offset: number) => {
const hugsPrev = offset === 0 && prevStructural;
const hugsNext = offset + run.length === segment.length && nextStructural;
return hugsPrev || hugsNext ? run : "<br>".repeat(run.length);
});
}
// Tags whose inner whitespace Telegram renders verbatim, so their newlines stay
// literal: code/pre keep source formatting and math holds raw LaTeX.
const TELEGRAM_RICH_LITERAL_WHITESPACE_TAGS = new Set(["code", "pre", "tg-math", "tg-math-block"]);
function normalizeTelegramRichLiteralWhitespaceEscapes(html: string): string {
if (!html.includes("\\n") && !html.includes("\\t")) {
return html;
}
let result = "";
let lastIndex = 0;
let literalDepth = 0;
for (const tag of tokenizeHtmlTags(html)) {
const tagStart = tag.start;
const tagEnd = tag.end;
const rawTag = tag.raw;
const isClosing = tag.closing;
const tagName = tag.name;
const segment = html.slice(lastIndex, tagStart);
result += literalDepth > 0 ? segment : materializeTelegramRichLiteralWhitespace(segment);
if (TELEGRAM_RICH_LITERAL_WHITESPACE_TAGS.has(tagName) && !rawTag.trimEnd().endsWith("/>")) {
literalDepth = isClosing ? Math.max(0, literalDepth - 1) : literalDepth + 1;
}
result += rawTag;
lastIndex = tagEnd;
}
const tail = html.slice(lastIndex);
result += literalDepth > 0 ? tail : materializeTelegramRichLiteralWhitespace(tail);
return result;
}
function materializeTelegramRichLiteralWhitespace(segment: string): string {
return segment.replace(/\\[nt]/g, (match) => (match === "\\n" ? "\n" : "\t"));
}
// Bot API 10.1 rich messages parse structured HTML, so literal newlines are
// insignificant whitespace — unlike the legacy HTML parse mode that renders them
// as line breaks. Materialize inline newlines as <br> so multi-line prose and
// bullet runs keep their breaks, while leaving newlines literal inside
// code/pre/math and where they only separate block-level tags.
function materializeTelegramRichHtmlLineBreaks(html: string): string {
if (!html.includes("\n")) {
return html;
}
let result = "";
let lastIndex = 0;
let literalDepth = 0;
let prevStructural = false;
for (const tag of tokenizeHtmlTags(html)) {
const tagStart = tag.start;
const tagEnd = tag.end;
const rawTag = tag.raw;
const isClosing = tag.closing;
const tagName = tag.name;
// <br> already emits a break, so treat it like a structural boundary: a
// hugging newline stays literal instead of doubling into a blank line.
const tagIsStructural =
tagName === "br" || isTelegramRichLineBreakStructuralTag(rawTag, tagName);
const segment = html.slice(lastIndex, tagStart);
result +=
literalDepth > 0
? segment
: convertTelegramRichSegmentNewlines(segment, prevStructural, tagIsStructural);
// Self-closing literal tags (e.g. a stray <pre/>) must not open a region that
// never closes and swallows every later line break.
if (TELEGRAM_RICH_LITERAL_WHITESPACE_TAGS.has(tagName) && !rawTag.trimEnd().endsWith("/>")) {
literalDepth = isClosing ? Math.max(0, literalDepth - 1) : literalDepth + 1;
}
result += rawTag;
lastIndex = tagEnd;
prevStructural = tagIsStructural;
}
const tail = html.slice(lastIndex);
result +=
literalDepth > 0 ? tail : convertTelegramRichSegmentNewlines(tail, prevStructural, false);
return result;
}
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),
{
assistantTranscriptRoleHeaders: true,
linkify: options.skipEntityDetection !== true,
enableSpoilers: true,
headingStyle: "rich",
blockquotePrefix: "",
tableMode,
},
);
return protectTelegramAssistantTranscriptRoleHeaders(
isolateTelegramRichMediaBlocks(
replaceTelegramRichMarkdownMediaPlaceholders(
renderTelegramRichHtmlDocument(ir, tables),
normalized.mediaBlocks,
),
),
);
}
type TelegramHtmlTag = {
name: string;
openTag: string;
closeTag: string;
richBlock: boolean;
richMedia: boolean;
};
const TELEGRAM_SELF_CLOSING_HTML_TAGS = TELEGRAM_VOID_HTML_TAGS;
@@ -1328,20 +728,12 @@ function popTelegramHtmlTag(tags: TelegramHtmlTag[], name: string): void {
}
}
function splitTelegramHtmlChunksRaw(
html: string,
limit: number,
options: { blockLimit?: number; mediaLimit?: number } = {},
): string[] {
function splitTelegramHtmlChunksRaw(html: string, limit: number): string[] {
if (!html) {
return [];
}
const normalizedLimit = Math.max(1, Math.floor(limit));
const blockLimit =
options.blockLimit == null ? undefined : Math.max(1, Math.floor(options.blockLimit));
const mediaLimit =
options.mediaLimit == null ? undefined : Math.max(1, Math.floor(options.mediaLimit));
if (html.length <= normalizedLimit && blockLimit === undefined && mediaLimit === undefined) {
if (html.length <= normalizedLimit) {
return [html];
}
@@ -1349,14 +741,10 @@ function splitTelegramHtmlChunksRaw(
const openTags: TelegramHtmlTag[] = [];
const suppressedTagNames: string[] = [];
let current = "";
let currentBlockCount = 0;
let currentMediaCount = 0;
let chunkHasPayload = false;
const resetCurrent = () => {
current = buildTelegramHtmlOpenPrefix(openTags);
currentBlockCount = openTags.filter((tag) => tag.richBlock).length;
currentMediaCount = openTags.filter((tag) => tag.richMedia).length;
chunkHasPayload = false;
};
@@ -1421,24 +809,16 @@ function splitTelegramHtmlChunksRaw(
const isSelfClosing =
!isClosing &&
(TELEGRAM_SELF_CLOSING_HTML_TAGS.has(tagName) || rawTag.trimEnd().endsWith("/>"));
const isRichBlock = !isClosing && isTelegramRichBlockHtmlTag(rawTag, tagName);
const isRichMedia =
!isClosing &&
(tagName === "figure" ||
(TELEGRAM_RICH_MEDIA_HTML_TAGS.has(tagName) &&
!openTags.some((openTag) => openTag.name === "figure")));
if (!isClosing) {
const nextCloseLength = isSelfClosing ? 0 : `</${tagName}>`.length;
if (
chunkHasPayload &&
((blockLimit !== undefined && isRichBlock && currentBlockCount >= blockLimit) ||
(mediaLimit !== undefined && isRichMedia && currentMediaCount >= mediaLimit) ||
current.length +
rawTag.length +
buildTelegramHtmlCloseSuffixLength(openTags) +
nextCloseLength >
normalizedLimit)
current.length +
rawTag.length +
buildTelegramHtmlCloseSuffixLength(openTags) +
nextCloseLength >
normalizedLimit
) {
flushCurrent();
}
@@ -1453,12 +833,6 @@ function splitTelegramHtmlChunksRaw(
if (isSelfClosing) {
chunkHasPayload = true;
}
if (isRichBlock) {
currentBlockCount += 1;
}
if (isRichMedia) {
currentMediaCount += 1;
}
if (isClosing) {
popTelegramHtmlTag(openTags, tagName);
} else if (!isSelfClosing) {
@@ -1466,8 +840,6 @@ function splitTelegramHtmlChunksRaw(
name: tagName,
openTag: rawTag,
closeTag: `</${tagName}>`,
richBlock: isRichBlock,
richMedia: isRichMedia,
});
}
lastIndex = tagEnd;
@@ -1478,12 +850,8 @@ function splitTelegramHtmlChunksRaw(
return chunks.length > 0 ? chunks : [html];
}
export function splitTelegramHtmlChunks(
html: string,
limit: number,
options: { blockLimit?: number; mediaLimit?: number } = {},
): string[] {
const chunks = splitTelegramHtmlChunksRaw(html, limit, options);
export function splitTelegramHtmlChunks(html: string, limit: number): string[] {
const chunks = splitTelegramHtmlChunksRaw(html, limit);
if (chunks.every((chunk) => protectTelegramAssistantTranscriptRoleHeaders(chunk) === chunk)) {
return chunks;
}
@@ -1495,7 +863,7 @@ export function splitTelegramHtmlChunks(
`Telegram HTML chunk limit cannot fit assistant transcript marker (limit=${normalizedLimit})`,
);
}
return splitTelegramHtmlChunksRaw(html, protectedContentLimit, options).map((chunk) =>
return splitTelegramHtmlChunksRaw(html, protectedContentLimit).map((chunk) =>
protectTelegramAssistantTranscriptRoleHeaders(chunk),
);
}
+107 -34
View File
@@ -2,7 +2,16 @@
import type { ChannelProgressDraftCompositorLine } from "openclaw/plugin-sdk/channel-outbound";
import type { TelegramDraftPreview } from "./draft-stream.js";
import { renderTelegramHtmlText } from "./format.js";
import { buildTelegramRichHtml } from "./rich-message.js";
import {
boldRichText,
codeRichText,
italicRichText,
markdownToTelegramRichBlocks,
paragraphBlock,
type InputRichBlock,
type RichText,
} from "./rich-blocks.js";
import { buildTelegramRichBlocksPlan } from "./rich-message.js";
import { clipTelegramProgressText } from "./truncate.js";
function sanitizeProgressMarkdownText(text: string): string {
@@ -30,18 +39,10 @@ function escapeTelegramProgressHtml(text: string): string {
}
function renderTelegramProgressStringLine(text: string): string {
// Reasoning/commentary lanes carry model-authored markdown (e.g. `**bold**`,
// inline `` `code` ``, `_italic_` reasoning behind a 🧠/💬 marker). Render it
// through renderTelegramHtmlText — the parse_mode=HTML-safe converter — NOT
// markdownToTelegramRichHtml, whose rich-only block output (<h2> from a
// setext heading, <hr>, lists) makes Telegram reject the edit and drops the
// whole preview to unformatted plain text. Callers convert ONE line at a
// time, which also keeps block markdown from forming (`---` under a
// paragraph is a setext heading only when they share a document).
// Reasoning/commentary lanes carry model-authored markdown. Render through
// renderTelegramHtmlText (parse_mode HTML-safe), not the full rich block
// converter — block output from headings/lists can reject the edit.
const trimmed = text.trim();
// Clip INSIDE a whole-line `_…_` wrapper (the reasoning-lane contract, marker
// optional): clipping the assembled line chops the closing underscore, which
// silently degrades every long reasoning line from italic to plain text.
const italic = trimmed.match(/^(\S+ )?_(.*)_$/u);
const clipped = italic
? `${italic[1] ?? ""}_${clipTelegramProgressText(italic[2] ?? "")}_`
@@ -54,11 +55,6 @@ function renderTelegramProgressLine(line: ChannelProgressDraftCompositorLine): s
return line.split(/\r?\n/u).map(renderTelegramProgressStringLine).filter(Boolean).join("<br>");
}
if (!line.icon && line.label === "Commentary") {
// Commentary is model prose behind a 💬 marker: render its markdown (plain
// unless the model emphasized) via the shared converter — distinct from the
// 🧠 italic reasoning lane, mirroring Discord. Multi-line notes keep their
// line structure (Discord parity); converting per line also prevents block
// markdown (setext headings) from forming across lines.
return line.text
.split(/\r?\n/u)
.map(renderTelegramProgressStringLine)
@@ -73,9 +69,6 @@ function renderTelegramProgressLine(line: ChannelProgressDraftCompositorLine): s
} else {
const text = line.text.trim();
if (text && text !== label) {
// Generic item payload (e.g. an "Update" line) keeps the monospace payload
// styling shared with tool details; only the reasoning/commentary lanes
// carry model markdown that needs converting.
parts.push(`<code>${escapeTelegramProgressHtml(clipTelegramProgressText(text))}</code>`);
}
}
@@ -85,6 +78,73 @@ function renderTelegramProgressLine(line: ChannelProgressDraftCompositorLine): s
return parts.join(" ");
}
function joinRichText(parts: RichText[], separator: string): RichText {
if (parts.length === 0) {
return "";
}
if (parts.length === 1) {
return parts[0] ?? "";
}
const result: RichText[] = [];
for (const [index, part] of parts.entries()) {
if (index > 0) {
result.push(separator);
}
result.push(part);
}
return result;
}
function markdownLineToRichText(text: string): RichText {
const trimmed = text.trim();
const italic = trimmed.match(/^(\S+ )?_(.*)_$/u);
const clipped = italic
? `${italic[1] ?? ""}_${clipTelegramProgressText(italic[2] ?? "")}_`
: clipTelegramProgressText(trimmed);
const { blocks } = markdownToTelegramRichBlocks(clipped, { skipEntityDetection: true });
const first = blocks[0];
if (first?.type === "paragraph") {
return first.text;
}
return clipped;
}
function progressLineToRichText(line: ChannelProgressDraftCompositorLine): RichText | undefined {
if (typeof line === "string") {
const parts = line
.split(/\r?\n/u)
.map(markdownLineToRichText)
.filter((part) => part !== "");
return parts.length ? joinRichText(parts, "\n") : undefined;
}
if (!line.icon && line.label === "Commentary") {
const parts = line.text
.split(/\r?\n/u)
.map(markdownLineToRichText)
.filter((part) => part !== "");
return parts.length ? joinRichText(parts, "\n") : undefined;
}
const label = [line.icon, line.label].filter(Boolean).join(" ");
const parts: RichText[] = [boldRichText(label)];
const detail = line.detail && line.detail !== line.label ? line.detail : undefined;
if (detail) {
parts.push(codeRichText(clipTelegramProgressText(detail)));
} else {
const text = line.text.trim();
if (text && text !== label) {
parts.push(codeRichText(clipTelegramProgressText(text)));
}
}
if (line.status && line.status !== "completed" && line.status !== line.detail) {
parts.push(italicRichText(line.status));
}
return joinRichText(parts, " ");
}
function buildProgressRichBlocks(parts: RichText[]): InputRichBlock[] {
return [paragraphBlock(joinRichText(parts, "\n"))];
}
export function renderTelegramProgressDraftPreview(
text: string,
lines: readonly ChannelProgressDraftCompositorLine[],
@@ -97,19 +157,26 @@ export function renderTelegramProgressDraftPreview(
.split(/\r?\n/u)
.map((line) => line.trim())
.filter(Boolean);
const html =
statusLines.length > 1
? [
`<b>${escapeTelegramProgressHtml(statusLines[0] ?? "")}</b>`,
...statusLines.slice(1).map(renderTelegramProgressStringLine),
].join("<br>")
: statusLines.map(renderTelegramProgressStringLine).join("<br>");
if (!richMessages) {
const html =
statusLines.length > 1
? [
`<b>${escapeTelegramProgressHtml(statusLines[0] ?? "")}</b>`,
...statusLines.slice(1).map(renderTelegramProgressStringLine),
].join("<br>")
: statusLines.map(renderTelegramProgressStringLine).join("<br>");
return { text: html, parseMode: "HTML" };
}
const richParts: RichText[] =
statusLines.length > 1
? [boldRichText(statusLines[0] ?? ""), ...statusLines.slice(1).map(markdownLineToRichText)]
: statusLines.map(markdownLineToRichText);
return {
text: trimmed,
richMessage: buildTelegramRichHtml(html, { skipEntityDetection: true }),
richMessage: buildTelegramRichBlocksPlan(buildProgressRichBlocks(richParts), {
skipEntityDetection: true,
plainText: trimmed,
}).richMessage,
};
}
const renderedLines = lines.map(renderTelegramProgressLine).filter(Boolean);
@@ -118,15 +185,21 @@ export function renderTelegramProgressDraftPreview(
.map((line) => line.trim())
.filter(Boolean);
const heading = textLines.length > renderedLines.length ? textLines[0] : undefined;
const htmlParts = heading
? [`<b>${escapeTelegramProgressHtml(heading)}</b>`, ...renderedLines]
: renderedLines;
const html = htmlParts.join("<br>");
if (!richMessages) {
return { text: html, parseMode: "HTML" };
const htmlParts = heading
? [`<b>${escapeTelegramProgressHtml(heading)}</b>`, ...renderedLines]
: renderedLines;
return { text: htmlParts.join("<br>"), parseMode: "HTML" };
}
const richLineParts = lines
.map(progressLineToRichText)
.filter((part): part is RichText => part !== undefined);
const richParts = heading ? [boldRichText(heading), ...richLineParts] : richLineParts;
return {
text: trimmed,
richMessage: buildTelegramRichHtml(html, { skipEntityDetection: true }),
richMessage: buildTelegramRichBlocksPlan(buildProgressRichBlocks(richParts), {
skipEntityDetection: true,
plainText: trimmed,
}).richMessage,
};
}
+331
View File
@@ -0,0 +1,331 @@
// Telegram rich-blocks unit tests for Bot API 10.2 InputRichBlock emission.
import { describe, expect, it } from "vitest";
import {
countInputRichBlockChars,
inputRichBlocksToPlainText,
markdownToTelegramRichBlocks,
splitTelegramRichBlocks,
type InputRichBlock,
type RichText,
} from "./rich-blocks.js";
import { buildTelegramRichMarkdown, splitTelegramRichMessageTextChunks } from "./rich-message.js";
function tableMarkdown(columns: number): string {
return [
`| ${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");
}
function collectUrls(text: RichText, out: string[] = []): string[] {
if (typeof text === "string") {
return out;
}
if (Array.isArray(text)) {
for (const part of text) {
collectUrls(part, out);
}
return out;
}
if (text.type === "url") {
out.push(text.url);
}
collectUrls(text.text, out);
return out;
}
function hasStyle(text: RichText, style: string): boolean {
if (typeof text === "string") {
return false;
}
if (Array.isArray(text)) {
return text.some((part) => hasStyle(part, style));
}
return text.type === style || hasStyle(text.text, style);
}
describe("markdownToTelegramRichBlocks", () => {
it("nests inline styles and links", () => {
const { blocks } = markdownToTelegramRichBlocks(
"**bold _italic_** and [docs](https://example.com) ~~strike~~ ||spoiler|| `code`",
);
expect(blocks[0]?.type).toBe("paragraph");
const text = blocks[0] && blocks[0].type === "paragraph" ? blocks[0].text : "";
expect(hasStyle(text, "bold")).toBe(true);
expect(hasStyle(text, "italic")).toBe(true);
expect(hasStyle(text, "strikethrough")).toBe(true);
expect(hasStyle(text, "spoiler")).toBe(true);
expect(hasStyle(text, "code")).toBe(true);
expect(collectUrls(text)).toEqual(["https://example.com"]);
});
it("handles overlapping bold and autolink", () => {
const { blocks } = markdownToTelegramRichBlocks("**start https://example.com** end");
const text = blocks[0] && blocks[0].type === "paragraph" ? blocks[0].text : "";
expect(hasStyle(text, "bold")).toBe(true);
expect(collectUrls(text)).toEqual(["https://example.com"]);
});
it("emits pre blocks with fence language", () => {
const { blocks } = markdownToTelegramRichBlocks("```bash\necho hi\n```");
expect(blocks).toEqual([{ type: "pre", text: "echo hi", language: "bash" }]);
});
it("emits heading blocks with sizes", () => {
const { blocks } = markdownToTelegramRichBlocks("# Title\n\n### Detail");
expect(blocks.map((block) => block.type)).toEqual(["heading", "heading"]);
expect(blocks[0]).toMatchObject({ type: "heading", size: 1 });
expect(blocks[1]).toMatchObject({ type: "heading", size: 3 });
});
it("emits blockquotes with nested paragraphs", () => {
const { blocks } = markdownToTelegramRichBlocks("> first\n\n> second");
expect(blocks).toHaveLength(2);
expect(blocks.every((block) => block.type === "blockquote")).toBe(true);
});
it("splits paragraphs on blank lines and keeps single newlines", () => {
const { blocks, plainText } = markdownToTelegramRichBlocks("a\nb\n\nc");
expect(blocks).toHaveLength(2);
expect(blocks[0]).toMatchObject({ type: "paragraph" });
if (blocks[0]?.type === "paragraph") {
expect(inputRichBlocksToPlainText([blocks[0]])).toContain("a");
expect(inputRichBlocksToPlainText([blocks[0]])).toContain("b");
}
expect(plainText.replace(/\n+/g, "\n")).toContain("a");
});
it("renders tables with header row, aligns, borders, and stripes", () => {
const { blocks, degradationReasons } = markdownToTelegramRichBlocks(
"| Feature | Status | Count |\n| :--- | :---: | ---: |\n| Rich | Fixed | 2 |",
{ tableMode: "block" },
);
expect(degradationReasons).toEqual([]);
const table = blocks.find((block) => block.type === "table");
expect(table?.type).toBe("table");
if (table?.type !== "table") {
return;
}
expect(table.is_bordered).toBe(true);
expect(table.is_striped).toBe(true);
expect(table.cells[0]?.every((cell) => cell.is_header === true)).toBe(true);
expect(table.cells[0]?.map((cell) => cell.align)).toEqual(["left", "center", "right"]);
expect(table.cells[1]?.map((cell) => cell.align)).toEqual(["left", "center", "right"]);
});
it("degrades wide tables to ASCII pre blocks", () => {
const { blocks, degradationReasons } = markdownToTelegramRichBlocks(tableMarkdown(21), {
tableMode: "block",
});
expect(degradationReasons).toEqual(["table-ascii"]);
expect(blocks.some((block) => block.type === "pre")).toBe(true);
expect(blocks.some((block) => block.type === "table")).toBe(false);
});
it("uses code tables when tableMode is code", () => {
const { blocks } = markdownToTelegramRichBlocks(tableMarkdown(2), { tableMode: "code" });
expect(blocks.some((block) => block.type === "pre")).toBe(true);
expect(blocks.some((block) => block.type === "table")).toBe(false);
});
it("does not auto-linkify bare URLs when entity detection is skipped", () => {
const { blocks } = markdownToTelegramRichBlocks("https://example.com", {
skipEntityDetection: true,
});
const text = blocks[0] && blocks[0].type === "paragraph" ? blocks[0].text : "";
expect(collectUrls(text)).toEqual([]);
});
it("keeps explicit markdown links when entity detection is skipped", () => {
const { blocks } = markdownToTelegramRichBlocks("[docs](https://example.com)", {
skipEntityDetection: true,
});
const text = blocks[0] && blocks[0].type === "paragraph" ? blocks[0].text : "";
expect(collectUrls(text)).toEqual(["https://example.com"]);
});
it("keeps unsupported local links as visible text and wraps file refs as code", () => {
const { blocks } = markdownToTelegramRichBlocks(
"[scripts/yougile.py](/home/user/scripts/yougile.py#L41) and [config](./openclaw.json)",
);
const plain = inputRichBlocksToPlainText(blocks);
expect(plain).toContain("scripts/yougile.py");
expect(plain).toContain("config");
const text = blocks[0] && blocks[0].type === "paragraph" ? blocks[0].text : "";
expect(collectUrls(text)).toEqual([]);
});
it("wraps auto-linked file refs as code so Telegram does not re-linkify them", () => {
const { blocks } = markdownToTelegramRichBlocks("see README.md for details");
const text = blocks[0] && blocks[0].type === "paragraph" ? blocks[0].text : "";
expect(collectUrls(text)).toEqual([]);
expect(hasStyle(text, "code")).toBe(true);
});
it("derives plainText from the block projection", () => {
const { plainText } = markdownToTelegramRichBlocks("**hello** world");
expect(plainText).toContain("hello");
expect(plainText).not.toContain("**");
});
it("keeps table content in plainText for the plain fallback", () => {
const { plainText } = markdownToTelegramRichBlocks(
"before\n\n| colA | colB |\n| - | - |\n| cell1 | cell2 |\n\nafter",
{ tableMode: "block" },
);
expect(plainText).toContain("cell1");
expect(plainText).toContain("colB");
});
it("emits a code fence inside a blockquote exactly once, nested in the quote", () => {
const { blocks } = markdownToTelegramRichBlocks(
"> intro\n> ```ts\n> const x = 1;\n> ```\n> outro",
);
expect(blocks).toHaveLength(1);
const quote = blocks[0];
expect(quote?.type).toBe("blockquote");
if (quote?.type !== "blockquote") {
return;
}
expect(quote.blocks.map((block) => block.type)).toEqual(["paragraph", "pre", "paragraph"]);
const serialized = JSON.stringify(blocks);
expect(serialized.split("const x = 1;").length - 1).toBe(1);
expect(serialized.split("outro").length - 1).toBe(1);
});
it("emits a heading inside a blockquote exactly once", () => {
const { blocks } = markdownToTelegramRichBlocks("> ## quoted heading\n> body");
expect(blocks).toHaveLength(1);
const quote = blocks[0];
if (quote?.type !== "blockquote") {
expect(quote?.type).toBe("blockquote");
return;
}
expect(quote.blocks.some((block) => block.type === "heading")).toBe(true);
expect(JSON.stringify(blocks).split("quoted heading").length - 1).toBe(1);
});
});
describe("splitTelegramRichBlocks", () => {
it("splits at the 500-block limit", () => {
const blocks: InputRichBlock[] = Array.from({ length: 501 }, (_, index) => ({
type: "paragraph",
text: `item ${index}`,
}));
const chunks = splitTelegramRichBlocks(blocks, { blockLimit: 500 });
expect(chunks).toHaveLength(2);
expect(chunks[0]).toHaveLength(500);
expect(chunks[1]).toHaveLength(1);
});
it("splits at the text char limit", () => {
const blocks: InputRichBlock[] = [
{ type: "paragraph", text: "a".repeat(20_000) },
{ type: "paragraph", text: "b".repeat(20_000) },
];
const chunks = splitTelegramRichBlocks(blocks, { textLimit: 32_768 });
expect(chunks.length).toBeGreaterThan(1);
for (const chunk of chunks) {
const chars = chunk.reduce((total, block) => total + countInputRichBlockChars(block), 0);
expect(chars).toBeLessThanOrEqual(32_768);
}
});
it("does not split surrogate pairs at oversized-block boundaries", () => {
const text = `${"a".repeat(63)}😀tail`;
const chunks = splitTelegramRichBlocks([{ type: "pre", text }], { textLimit: 64 });
for (const piece of chunks.flat()) {
if (piece.type === "pre") {
expect(piece.text).not.toMatch(/[\uD800-\uDBFF]$|^[\uDC00-\uDFFF]/);
}
}
});
it("splits oversized styled paragraphs instead of sending over-limit chunks", () => {
const { blocks } = markdownToTelegramRichBlocks(`**bold** ${"x".repeat(200)}`);
const chunks = splitTelegramRichBlocks(blocks, { textLimit: 64 });
for (const chunk of chunks) {
const chars = chunk.reduce((total, block) => total + countInputRichBlockChars(block), 0);
expect(chars).toBeLessThanOrEqual(64);
}
const first = chunks[0]?.[0];
expect(first && first.type === "paragraph" && hasStyle(first.text, "bold")).toBe(true);
});
it("keeps link targets when an oversized styled paragraph splits", () => {
const { blocks } = markdownToTelegramRichBlocks(
`${"x".repeat(60)} [docs](https://example.com/${"y".repeat(40)}) tail`,
);
const chunks = splitTelegramRichBlocks(blocks, { textLimit: 64 });
const urls = chunks
.flat()
.flatMap((block) => (block.type === "paragraph" ? collectUrls(block.text) : []));
expect(urls.length).toBeGreaterThan(0);
expect(urls.every((url) => url.startsWith("https://example.com/"))).toBe(true);
});
it("splits oversized blockquotes and tables at inner boundaries", () => {
const quote: InputRichBlock = {
type: "blockquote",
blocks: [
{ type: "paragraph", text: "q".repeat(50) },
{ type: "paragraph", text: "r".repeat(50) },
],
};
const table: InputRichBlock = {
type: "table",
cells: [
[{ text: "h".repeat(40), is_header: true }],
[{ text: "c".repeat(40) }],
[{ text: "d".repeat(40) }],
],
};
const chunks = splitTelegramRichBlocks([quote, table], { textLimit: 64 });
for (const chunk of chunks) {
const chars = chunk.reduce((total, block) => total + countInputRichBlockChars(block), 0);
expect(chars).toBeLessThanOrEqual(64);
}
});
});
describe("rich message plan wiring", () => {
it("emits blocks InputRichMessage and email skip_entity_detection", () => {
const message = buildTelegramRichMarkdown("Contact owner@example.com for help");
if (!("blocks" in message)) {
expect.fail("expected a blocks rich message");
}
expect(message.blocks.length).toBeGreaterThan(0);
expect(message.skip_entity_detection).toBe(true);
expect("html" in message).toBe(false);
});
it("passes skip_entity_detection through chunked rich messages", () => {
const chunks = splitTelegramRichMessageTextChunks({
text: `${"hello\n\n".repeat(10)}owner@example.com`,
textLimit: 32_768,
});
expect(chunks.some((chunk) => chunk.richMessage.skip_entity_detection === true)).toBe(true);
});
it("applies the document-level skip flag to every chunk", () => {
// An email anywhere disables linkification for the whole render, so chunks
// without the email would otherwise expose unprotected file refs (README.md)
// to Telegram's server-side entity detection.
const chunks = splitTelegramRichMessageTextChunks({
text: `see README.md for details\n\n${"filler ".repeat(20)}\n\nping owner@example.com`,
textLimit: 80,
});
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every((chunk) => chunk.richMessage.skip_entity_detection === true)).toBe(true);
});
it("sends readable source text when markdown projects to zero blocks", () => {
const chunks = splitTelegramRichMessageTextChunks({
text: "[ref]: https://example.com",
textLimit: 32_768,
});
expect(chunks).toHaveLength(1);
expect(chunks[0]?.plainText).toContain("example.com");
});
});
+815
View File
@@ -0,0 +1,815 @@
// Markdown → Bot API 10.2 InputRichBlock[] for Telegram rich messages.
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
import {
isAutoLinkedFileRef,
markdownToIRWithMeta,
sliceMarkdownIR,
type MarkdownIR,
type MarkdownLinkSpan,
type MarkdownStyle,
type MarkdownTableCell,
type MarkdownTableMeta,
} from "openclaw/plugin-sdk/text-chunking";
// Runtime-safe: rich-plain-fallback's reverse import of this module is type-only.
import { splitTelegramPlainTextChunks, surrogateSafeChunkEnd } from "./rich-plain-fallback.js";
export type TelegramRichBlocksDegradationReason = "table-ascii";
export type RichText =
| string
| RichText[]
| {
type: "bold" | "italic" | "strikethrough" | "code" | "spoiler";
text: RichText;
}
| {
type: "url";
text: RichText;
url: string;
};
export type RichBlockTableCellAlign = "left" | "center" | "right";
export type RichBlockTableCell = {
text?: RichText;
is_header?: true;
colspan?: number;
rowspan?: number;
align?: RichBlockTableCellAlign;
valign?: "top" | "middle" | "bottom";
};
export type InputRichBlockParagraph = {
type: "paragraph";
text: RichText;
};
export type InputRichBlockHeading = {
type: "heading";
text: RichText;
size: 1 | 2 | 3 | 4 | 5 | 6;
};
export type InputRichBlockPre = {
type: "pre";
text: string;
language?: string;
};
export type InputRichBlockBlockquote = {
type: "blockquote";
blocks: InputRichBlock[];
};
export type InputRichBlockTable = {
type: "table";
cells: RichBlockTableCell[][];
is_bordered?: true;
is_striped?: true;
};
export type InputRichBlock =
| InputRichBlockParagraph
| InputRichBlockHeading
| InputRichBlockPre
| InputRichBlockBlockquote
| InputRichBlockTable;
export type TelegramRichBlocksResult = {
blocks: InputRichBlock[];
plainText: string;
degradationReasons: readonly TelegramRichBlocksDegradationReason[];
};
const TELEGRAM_RICH_TEXT_TABLE_COLUMN_LIMIT = 20;
const INLINE_STYLE_RANK: Record<string, number> = {
spoiler: 0,
bold: 1,
italic: 2,
strikethrough: 3,
code: 4,
};
const TELEGRAM_RICH_LINK_HREF_RE = /^(?:https?:\/\/|tg:\/\/|mailto:|tel:|#)/i;
type InlineStyleKind = "bold" | "italic" | "strikethrough" | "code" | "spoiler";
type StructuralSegment =
| { kind: "heading"; start: number; end: number; size: 1 | 2 | 3 | 4 | 5 | 6 }
| { kind: "code_block"; start: number; end: number; language?: string }
| { kind: "blockquote"; start: number; end: number }
| { kind: "table"; start: number; end: number; table: MarkdownTableMeta };
function isTelegramRichLinkHref(href: string): boolean {
return TELEGRAM_RICH_LINK_HREF_RE.test(href);
}
function resolveHeadingSize(style: MarkdownStyle): 1 | 2 | 3 | 4 | 5 | 6 | undefined {
switch (style) {
case "heading_1":
return 1;
case "heading_2":
return 2;
case "heading_3":
return 3;
case "heading_4":
return 4;
case "heading_5":
return 5;
case "heading_6":
return 6;
default:
return undefined;
}
}
function isInlineStyle(style: MarkdownStyle): style is InlineStyleKind {
return (
style === "bold" ||
style === "italic" ||
style === "strikethrough" ||
style === "code" ||
style === "spoiler"
);
}
function normalizeRichText(value: RichText): RichText {
if (typeof value === "string") {
return value;
}
if (Array.isArray(value)) {
const flattened: RichText[] = [];
for (const item of value) {
const normalized = normalizeRichText(item);
if (normalized === "") {
continue;
}
if (Array.isArray(normalized)) {
flattened.push(...normalized);
} else {
flattened.push(normalized);
}
}
if (flattened.length === 0) {
return "";
}
if (flattened.length === 1) {
return flattened[0] ?? "";
}
return flattened;
}
return { ...value, text: normalizeRichText(value.text) };
}
function wrapStyle(kind: InlineStyleKind, text: RichText): RichText {
return { type: kind, text };
}
type TelegramLinkAction = { kind: "url"; href: string } | { kind: "code" };
function resolveTelegramLinkAction(
link: MarkdownLinkSpan,
source: string,
): TelegramLinkAction | null {
const href = link.href.trim();
if (!href || link.start === link.end) {
return null;
}
const label = source.slice(link.start, link.end);
if (isAutoLinkedFileRef(href, label)) {
// Bare file refs (README.md, openclaw.json) must render as code, not links:
// Telegram's server-side entity detection would otherwise re-linkify them
// and show spurious domain previews for TLD-like extensions.
return { kind: "code" };
}
if (!isTelegramRichLinkHref(href)) {
return null;
}
return { kind: "url", href };
}
/**
* Build nested RichText from IR spans over [rangeStart, rangeEnd).
* Spans that partially overlap are split at shared boundaries (IR contract).
*/
function irRangeToRichText(ir: MarkdownIR, rangeStart: number, rangeEnd: number): RichText {
if (rangeEnd <= rangeStart) {
return "";
}
const slice = sliceMarkdownIR(ir, rangeStart, rangeEnd);
const text = slice.text;
if (!text) {
return "";
}
const dominantAnnotationRanges = (slice.annotations ?? [])
.filter((span) => span.type === "assistant_transcript_role")
.map((span) => ({ start: span.start, end: span.end }));
const suppressed = (start: number, end: number) =>
dominantAnnotationRanges.some((range) => start < range.end && end > range.start);
const styleSpans = slice.styles.filter(
(span) => isInlineStyle(span.style) && !suppressed(span.start, span.end),
);
const annotationSpans = (slice.annotations ?? []).filter(
(span) => span.type === "assistant_transcript_role",
);
const links = slice.links
.filter((link) => !suppressed(link.start, link.end))
.flatMap((link) => {
const action = resolveTelegramLinkAction(link, text);
return action ? [{ start: link.start, end: link.end, action }] : [];
});
const boundaries = new Set<number>([0, text.length]);
for (const span of styleSpans) {
boundaries.add(span.start);
boundaries.add(span.end);
}
for (const span of annotationSpans) {
boundaries.add(span.start);
boundaries.add(span.end);
}
for (const link of links) {
boundaries.add(link.start);
boundaries.add(link.end);
}
const points = [...boundaries].toSorted((a, b) => a - b);
type Active =
| { kind: "style"; style: InlineStyleKind; end: number }
| { kind: "annotation"; end: number }
| { kind: "link"; href: string; end: number };
const stack: Active[] = [];
const root: RichText[] = [];
const frameStack: RichText[][] = [root];
const pushNode = (node: RichText) => {
frameStack.at(-1)?.push(node);
};
const openStyleNode = (style: InlineStyleKind, end: number) => {
const container: RichText[] = [];
pushNode({ type: style, text: container });
stack.push({ kind: "style", style, end });
frameStack.push(container);
};
const openAnnotationNode = (end: number) => {
const container: RichText[] = [];
pushNode({ type: "code", text: container });
stack.push({ kind: "annotation", end });
frameStack.push(container);
};
const openLinkNode = (href: string, end: number) => {
const container: RichText[] = [];
pushNode({ type: "url", text: container, url: href });
stack.push({ kind: "link", href, end });
frameStack.push(container);
};
for (let i = 0; i < points.length - 1; i += 1) {
const start = points[i] ?? 0;
const end = points[i + 1] ?? start;
while (stack.length > 0 && (stack.at(-1)?.end ?? 0) <= start) {
stack.pop();
frameStack.pop();
}
const opening: Active[] = [];
for (const span of annotationSpans) {
if (span.start === start) {
opening.push({ kind: "annotation", end: span.end });
}
}
for (const link of links) {
if (link.start !== start) {
continue;
}
if (link.action.kind === "url") {
opening.push({ kind: "link", href: link.action.href, end: link.end });
} else {
opening.push({ kind: "style", style: "code", end: link.end });
}
}
for (const span of styleSpans) {
if (span.start === start && isInlineStyle(span.style)) {
opening.push({ kind: "style", style: span.style, end: span.end });
}
}
opening.sort((left, right) => {
if (left.end !== right.end) {
return right.end - left.end;
}
const leftRank =
left.kind === "style"
? (INLINE_STYLE_RANK[left.style] ?? 99)
: left.kind === "link"
? 50
: 0;
const rightRank =
right.kind === "style"
? (INLINE_STYLE_RANK[right.style] ?? 99)
: right.kind === "link"
? 50
: 0;
return leftRank - rightRank;
});
const inCode =
stack.some((entry) => entry.kind === "style" && entry.style === "code") ||
stack.some((entry) => entry.kind === "annotation");
for (const item of opening) {
if (item.kind === "annotation") {
openAnnotationNode(item.end);
} else if (item.kind === "link") {
if (!inCode && !stack.some((entry) => entry.kind === "link")) {
openLinkNode(item.href, item.end);
}
} else if (!inCode || item.style === "code") {
if (!(item.style === "code" && inCode)) {
openStyleNode(item.style, item.end);
}
}
}
if (end > start) {
// Unlike Bot API html mode, blocks preserve bare `\n` inside paragraph
// RichText verbatim (live-verified 2026-07-15 via sendRichMessage echo).
pushNode(text.slice(start, end));
}
}
while (stack.length > 0) {
stack.pop();
frameStack.pop();
}
return normalizeRichText(root);
}
function pushParagraph(
paragraphs: InputRichBlockParagraph[],
ir: MarkdownIR,
rangeStart: number,
rangeEnd: number,
): void {
// Trim the range (not the rendered text) so style/link offsets stay aligned;
// gaps after structural blocks otherwise leak leading newlines into paragraphs.
const raw = ir.text.slice(rangeStart, rangeEnd);
const leading = raw.length - raw.trimStart().length;
const trailing = raw.length - raw.trimEnd().length;
const absStart = rangeStart + leading;
const absEnd = rangeEnd - trailing;
if (absEnd <= absStart) {
return;
}
paragraphs.push({ type: "paragraph", text: irRangeToRichText(ir, absStart, absEnd) });
}
function splitParagraphs(ir: MarkdownIR, start: number, end: number): InputRichBlockParagraph[] {
if (end <= start) {
return [];
}
const text = ir.text.slice(start, end);
const paragraphs: InputRichBlockParagraph[] = [];
const blankLine = /\n[ \t]*\n+/g;
let last = 0;
let match: RegExpExecArray | null;
while ((match = blankLine.exec(text)) !== null) {
pushParagraph(paragraphs, ir, start + last, start + match.index);
last = match.index + match[0].length;
}
pushParagraph(paragraphs, ir, start + last, end);
return paragraphs;
}
function renderAsciiTableGrid(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(" | ")} |`;
return [renderRow(table.headers), divider, ...table.rows.map(renderRow)].join("\n");
}
function cellToRichText(cell: MarkdownTableCell | undefined): RichText | undefined {
if (!cell?.text) {
return undefined;
}
const ir: MarkdownIR = {
text: cell.text,
styles: cell.styles,
links: cell.links,
...(cell.annotations ? { annotations: cell.annotations } : {}),
};
const rich = irRangeToRichText(ir, 0, cell.text.length);
return rich === "" ? undefined : rich;
}
function renderTableBlock(table: MarkdownTableMeta): {
block: InputRichBlock;
degradation?: TelegramRichBlocksDegradationReason;
} {
const columnCount = Math.max(table.headers.length, ...table.rows.map((row) => row.length), 0);
if (columnCount > TELEGRAM_RICH_TEXT_TABLE_COLUMN_LIMIT) {
return {
block: { type: "pre", text: renderAsciiTableGrid(table) },
degradation: "table-ascii",
};
}
const headerRow: RichBlockTableCell[] = table.headerCells.map((cell, index) => {
const align = table.aligns?.[index];
const text = cellToRichText(cell);
return {
is_header: true,
...(text !== undefined ? { text } : {}),
...(align ? { align } : {}),
};
});
const bodyRows: RichBlockTableCell[][] = table.rowCells.map((row) =>
Array.from({ length: columnCount }, (_value, index) => {
const align = table.aligns?.[index];
const text = cellToRichText(row[index]);
return {
...(text !== undefined ? { text } : {}),
...(align ? { align } : {}),
};
}),
);
const cells = headerRow.length > 0 ? [headerRow, ...bodyRows] : bodyRows;
return {
block: {
type: "table",
cells,
is_bordered: true,
is_striped: true,
},
};
}
function collectStructuralSegments(
ir: MarkdownIR,
tables: readonly MarkdownTableMeta[],
): StructuralSegment[] {
const segments: StructuralSegment[] = [];
for (const span of ir.styles) {
if (span.end <= span.start) {
continue;
}
const headingSize = resolveHeadingSize(span.style);
if (headingSize) {
segments.push({ kind: "heading", start: span.start, end: span.end, size: headingSize });
continue;
}
if (span.style === "code_block") {
segments.push({
kind: "code_block",
start: span.start,
end: span.end,
...(span.language ? { language: span.language } : {}),
});
continue;
}
if (span.style === "blockquote") {
segments.push({ kind: "blockquote", start: span.start, end: span.end });
}
}
for (const table of tables) {
const offset = Math.max(0, Math.min(table.placeholderOffset, ir.text.length));
segments.push({ kind: "table", start: offset, end: offset, table });
}
// Containers sort before their children (start asc, end desc) so emitSegments
// can consume contained segments recursively instead of double-emitting them.
return segments.toSorted((left, right) => left.start - right.start || right.end - left.end);
}
function emitSegments(
ir: MarkdownIR,
segments: readonly StructuralSegment[],
rangeStart: number,
rangeEnd: number,
degradationReasons: Set<TelegramRichBlocksDegradationReason>,
): InputRichBlock[] {
const blocks: InputRichBlock[] = [];
let cursor = rangeStart;
let index = 0;
while (index < segments.length) {
const segment = segments[index];
if (!segment) {
break;
}
if (segment.start > cursor) {
blocks.push(...splitParagraphs(ir, cursor, segment.start));
}
// Segments nested inside this one (fences/headings/tables in a blockquote)
// belong to it; consuming them here prevents a second top-level emission.
let next = index + 1;
while (next < segments.length && (segments[next]?.start ?? rangeEnd) < segment.end) {
next += 1;
}
const children = segments.slice(index + 1, next);
switch (segment.kind) {
case "heading": {
const text = irRangeToRichText(ir, segment.start, segment.end);
if (text !== "") {
blocks.push({ type: "heading", text, size: segment.size });
}
break;
}
case "code_block": {
const text = ir.text.slice(segment.start, segment.end).replace(/\n$/, "");
blocks.push({
type: "pre",
text,
...(segment.language ? { language: segment.language } : {}),
});
break;
}
case "blockquote": {
const inner = emitSegments(ir, children, segment.start, segment.end, degradationReasons);
if (inner.length > 0) {
blocks.push({ type: "blockquote", blocks: inner });
}
break;
}
case "table": {
const rendered = renderTableBlock(segment.table);
if (rendered.degradation) {
degradationReasons.add(rendered.degradation);
}
blocks.push(rendered.block);
break;
}
}
cursor = Math.max(cursor, segment.end);
index = next;
}
if (cursor < rangeEnd) {
blocks.push(...splitParagraphs(ir, cursor, rangeEnd));
}
return blocks;
}
export function countRichTextChars(text: RichText): number {
if (typeof text === "string") {
return text.length;
}
if (Array.isArray(text)) {
return text.reduce((total, part) => total + countRichTextChars(part), 0);
}
return countRichTextChars(text.text);
}
export function countInputRichBlockChars(block: InputRichBlock): number {
if (block.type === "paragraph" || block.type === "heading") {
return countRichTextChars(block.text);
}
if (block.type === "pre") {
return block.text.length;
}
if (block.type === "blockquote") {
return block.blocks.reduce((total, item) => total + countInputRichBlockChars(item), 0);
}
return block.cells.reduce(
(rowTotal, row) =>
rowTotal +
row.reduce((cellTotal, cell) => cellTotal + countRichTextChars(cell.text ?? ""), 0),
0,
);
}
export function markdownToTelegramRichBlocks(
markdown: string,
options: { tableMode?: MarkdownTableMode; skipEntityDetection?: boolean } = {},
): TelegramRichBlocksResult {
const tableMode = options.tableMode ?? "block";
// Parity scope: lists stay IR-flattened, media blocks out of scope (image alt
// text only), and `---` keeps the IR's ─── text — the old rich path never
// emitted <hr> for markdown either. Native list/media/divider blocks are a
// follow-up contract.
const { ir, tables } = markdownToIRWithMeta(markdown ?? "", {
assistantTranscriptRoleHeaders: true,
linkify: options.skipEntityDetection !== true,
enableSpoilers: true,
headingStyle: "rich",
blockquotePrefix: "",
tableMode,
});
const degradationReasons = new Set<TelegramRichBlocksDegradationReason>();
const segments = collectStructuralSegments(ir, tables);
const blocks = emitSegments(ir, segments, 0, ir.text.length, degradationReasons);
if (blocks.length === 0 && ir.text.trim()) {
blocks.push({ type: "paragraph", text: ir.text });
}
return {
blocks,
// Tables are zero-width placeholders in ir.text; project the blocks so the
// plain fallback keeps table content instead of silently dropping it.
plainText: inputRichBlocksToPlainText(blocks),
degradationReasons: [...degradationReasons],
};
}
type RichTextWrapper = { type: InlineStyleKind } | { type: "url"; url: string };
function wrapRichTextFragment(fragment: RichText, wrappers: readonly RichTextWrapper[]): RichText {
let node = fragment;
for (let index = wrappers.length - 1; index >= 0; index -= 1) {
const wrapper = wrappers[index];
if (!wrapper) {
continue;
}
node =
wrapper.type === "url"
? { type: "url", text: node, url: wrapper.url }
: { type: wrapper.type, text: node };
}
return node;
}
// Split a RichText tree into pieces of at most `limit` plain chars, duplicating
// style/link wrappers across boundaries so link targets survive the split.
function splitRichTextByChars(text: RichText, limit: number): RichText[] {
const pieces: RichText[] = [];
let current: RichText[] = [];
let chars = 0;
const flush = () => {
if (current.length > 0) {
pieces.push(normalizeRichText(current));
current = [];
chars = 0;
}
};
const visit = (node: RichText, wrappers: readonly RichTextWrapper[]) => {
if (typeof node === "string") {
let offset = 0;
while (offset < node.length) {
if (chars >= limit) {
flush();
}
const budget = limit - chars;
const end = surrogateSafeChunkEnd(node, Math.min(node.length, offset + budget), offset);
const fragment = node.slice(offset, end);
current.push(wrapRichTextFragment(fragment, wrappers));
chars += fragment.length;
offset = end;
}
return;
}
if (Array.isArray(node)) {
for (const child of node) {
visit(child, wrappers);
}
return;
}
const wrapper: RichTextWrapper =
node.type === "url" ? { type: "url", url: node.url } : { type: node.type };
visit(node.text, [...wrappers, wrapper]);
};
visit(text, []);
flush();
return pieces;
}
function splitOversizedRichBlock(block: InputRichBlock, textLimit: number): InputRichBlock[] {
if (countInputRichBlockChars(block) <= textLimit) {
return [block];
}
if (block.type === "pre") {
const language = block.language;
return splitTelegramPlainTextChunks(block.text, textLimit).map((piece) =>
language ? { type: "pre", text: piece, language } : { type: "pre", text: piece },
);
}
if (block.type === "paragraph" || block.type === "heading") {
return splitRichTextByChars(block.text, textLimit).map((piece) =>
block.type === "heading"
? { type: "heading", text: piece, size: block.size }
: { type: "paragraph", text: piece },
);
}
if (block.type === "blockquote") {
return splitTelegramRichBlocks(block.blocks, { textLimit }).map((inner) => ({
type: "blockquote",
blocks: inner,
}));
}
const pieces: InputRichBlock[] = [];
let rows: RichBlockTableCell[][] = [];
let chars = 0;
for (const row of block.cells) {
const rowChars = row.reduce((total, cell) => total + countRichTextChars(cell.text ?? ""), 0);
if (rows.length > 0 && chars + rowChars > textLimit) {
pieces.push({ ...block, cells: rows });
rows = [];
chars = 0;
}
rows.push(row);
chars += rowChars;
}
if (rows.length > 0) {
pieces.push({ ...block, cells: rows });
}
return pieces;
}
export function splitTelegramRichBlocks(
blocks: readonly InputRichBlock[],
options: { blockLimit?: number; textLimit?: number } = {},
): InputRichBlock[][] {
const blockLimit = Math.max(1, Math.floor(options.blockLimit ?? 500));
const textLimit = Math.max(1, Math.floor(options.textLimit ?? 32_768));
if (blocks.length === 0) {
return [];
}
const expanded = blocks.flatMap((block) => splitOversizedRichBlock(block, textLimit));
const chunks: InputRichBlock[][] = [];
let current: InputRichBlock[] = [];
let currentChars = 0;
const flush = () => {
if (current.length > 0) {
chunks.push(current);
current = [];
currentChars = 0;
}
};
for (const block of expanded) {
const chars = countInputRichBlockChars(block);
const wouldExceedBlocks = current.length >= blockLimit;
const wouldExceedChars = current.length > 0 && currentChars + chars > textLimit;
if (wouldExceedBlocks || wouldExceedChars) {
flush();
}
current.push(block);
currentChars += chars;
}
flush();
return chunks;
}
export function richTextToPlainString(text: RichText): string {
if (typeof text === "string") {
return text;
}
if (Array.isArray(text)) {
return text.map(richTextToPlainString).join("");
}
return richTextToPlainString(text.text);
}
export function inputRichBlocksToPlainText(blocks: readonly InputRichBlock[]): string {
const parts: string[] = [];
for (const block of blocks) {
switch (block.type) {
case "paragraph":
case "heading":
parts.push(richTextToPlainString(block.text));
break;
case "pre":
parts.push(block.text);
break;
case "blockquote":
parts.push(inputRichBlocksToPlainText(block.blocks));
break;
case "table":
for (const row of block.cells) {
parts.push(row.map((cell) => richTextToPlainString(cell.text ?? "")).join(" | "));
}
break;
}
}
return parts.join("\n");
}
export function boldRichText(text: string): RichText {
return wrapStyle("bold", text);
}
export function codeRichText(text: string): RichText {
return wrapStyle("code", text);
}
export function italicRichText(text: string): RichText {
return wrapStyle("italic", text);
}
export function paragraphBlock(text: RichText): InputRichBlockParagraph {
return { type: "paragraph", text };
}
+88 -301
View File
@@ -8,17 +8,14 @@ import type {
ReplyParameters,
} from "grammy/types";
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
// Telegram rich message helpers isolate Bot API 10.1 calls until grammY types catch up.
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import { chunkMarkdownTextWithMode, type ChunkMode } from "openclaw/plugin-sdk/reply-chunking";
// Telegram rich message helpers isolate Bot API 10.2 calls until grammY types catch up.
import {
escapeTelegramHtml,
markdownToTelegramRichHtml,
normalizeTelegramOutboundRichHtml,
splitTelegramHtmlChunks,
telegramHtmlToPlainTextFallback,
type TelegramRichHtmlDegradationReason,
} from "./format.js";
inputRichBlocksToPlainText,
markdownToTelegramRichBlocks,
splitTelegramRichBlocks,
type InputRichBlock,
type TelegramRichBlocksDegradationReason,
} from "./rich-blocks.js";
type TelegramRichMessageReplyMarkup =
| InlineKeyboardMarkup
@@ -28,42 +25,35 @@ type TelegramRichMessageReplyMarkup =
export const TELEGRAM_RICH_TEXT_LIMIT = 32_768;
const TELEGRAM_RICH_BLOCK_LIMIT = 500;
const TELEGRAM_RICH_MEDIA_LIMIT = 50;
export type TelegramInputRichMessage =
| {
markdown: string;
html?: never;
is_rtl?: boolean;
skip_entity_detection?: boolean;
}
| {
html: string;
markdown?: never;
is_rtl?: boolean;
skip_entity_detection?: boolean;
};
// The rich wire path is blocks-only: caller-authored HTML (formatting.parseMode
// "HTML") stays on the legacy parse_mode HTML funnel even for rich accounts, so
// literal-newline and chunking semantics match what HTML callers authored against.
export type TelegramInputRichMessage = {
blocks: InputRichBlock[];
is_rtl?: boolean;
skip_entity_detection?: boolean;
};
type TelegramInputRichHtmlMessage = Extract<TelegramInputRichMessage, { html: string }>;
export function isEmptyTelegramRichMessage(richMessage: TelegramInputRichMessage): boolean {
return richMessage.blocks.length === 0;
}
type TelegramRichMessageOptions = {
skipEntityDetection?: boolean;
tableMode?: MarkdownTableMode;
};
type TelegramRichTextMode = "markdown" | "html";
export type TelegramRichTextChunk = {
text: string;
textMode: "html";
richMessage: TelegramInputRichMessage;
plainText: string;
skipEntityDetection: boolean;
degradationReasons: readonly TelegramRichHtmlDegradationReason[];
degradationReasons: readonly TelegramRichBlocksDegradationReason[];
};
type TelegramRichMessagePlan = {
richMessage: TelegramInputRichHtmlMessage;
degradationReasons: readonly TelegramRichHtmlDegradationReason[];
richMessage: TelegramInputRichMessage;
plainText: string;
degradationReasons: readonly TelegramRichBlocksDegradationReason[];
};
type TelegramSendRichMessageParams = {
@@ -178,15 +168,33 @@ export function removeTelegramRichNativeQuoteParam(
};
}
function toRichMessage(
blocks: InputRichBlock[],
plainText: string,
options?: TelegramRichMessageOptions,
): TelegramInputRichMessage {
return shouldSkipTelegramRichEntityDetection(plainText, options)
? { blocks, skip_entity_detection: true }
: { blocks };
}
export function buildTelegramRichMarkdownPlan(
markdown: string,
options?: TelegramRichMessageOptions,
): TelegramRichMessagePlan {
const richOptions = {
...options,
skipEntityDetection: shouldSkipTelegramRichEntityDetection(markdown, options),
const skipEntityDetection = shouldSkipTelegramRichEntityDetection(markdown, options);
const rendered = markdownToTelegramRichBlocks(markdown, {
tableMode: options?.tableMode,
skipEntityDetection,
});
return {
richMessage: toRichMessage(rendered.blocks, rendered.plainText, {
...options,
skipEntityDetection,
}),
plainText: rendered.plainText,
degradationReasons: rendered.degradationReasons,
};
return buildTelegramRichHtmlPlan(markdownToTelegramRichHtml(markdown, richOptions), richOptions);
}
export function buildTelegramRichMarkdown(
@@ -196,279 +204,58 @@ export function buildTelegramRichMarkdown(
return buildTelegramRichMarkdownPlan(markdown, options).richMessage;
}
export function buildTelegramRichHtmlPlan(
html: string,
options?: TelegramRichMessageOptions,
export function buildTelegramRichBlocksPlan(
blocks: InputRichBlock[],
options?: TelegramRichMessageOptions & { plainText?: string },
): TelegramRichMessagePlan {
const normalized = prepareTelegramRichHtml(html);
const richMessage = shouldSkipTelegramRichEntityDetection(normalized.html, options)
? { html: normalized.html, skip_entity_detection: true }
: { html: normalized.html };
const plainText = options?.plainText ?? inputRichBlocksToPlainText(blocks);
return {
richMessage,
degradationReasons: normalized.degradationReasons,
richMessage: toRichMessage(blocks, plainText, options),
plainText,
degradationReasons: [],
};
}
export function buildTelegramRichHtml(
html: string,
options?: TelegramRichMessageOptions,
): TelegramInputRichMessage {
return buildTelegramRichHtmlPlan(html, options).richMessage;
}
export function buildTelegramRichMessagePlan(
text: string,
textMode: TelegramRichTextMode,
options?: TelegramRichMessageOptions,
): TelegramRichMessagePlan {
return textMode === "html"
? buildTelegramRichHtmlPlan(text, options)
: buildTelegramRichMarkdownPlan(text, options);
}
function prepareTelegramRichHtml(html: string) {
return normalizeTelegramOutboundRichHtml(html);
}
const TELEGRAM_RICH_HTML_CHUNK_LIMITS = {
blockLimit: TELEGRAM_RICH_BLOCK_LIMIT,
mediaLimit: TELEGRAM_RICH_MEDIA_LIMIT,
} as const;
function splitPreparedTelegramRichHtml(params: {
html: string;
sourceFallback: string;
textLimit: number;
}): string[] {
try {
const chunks = splitTelegramHtmlChunks(
params.html,
params.textLimit,
TELEGRAM_RICH_HTML_CHUNK_LIMITS,
);
if (chunks.length > 0) {
return chunks;
}
} catch {
// Fall through to readable source text when rich planning cannot preserve the payload.
}
return splitTelegramHtmlChunks(escapeTelegramHtml(params.sourceFallback), params.textLimit);
}
type RichMarkdownFenceSpan = {
start: number;
end: number;
};
function parseRichMarkdownFenceSpans(markdown: string): RichMarkdownFenceSpan[] {
const spans: RichMarkdownFenceSpan[] = [];
let open:
| {
start: number;
markerChar: string;
markerLength: number;
}
| undefined;
let offset = 0;
while (offset <= markdown.length) {
const nextNewline = markdown.indexOf("\n", offset);
const lineEnd = nextNewline === -1 ? markdown.length : nextNewline;
const line = markdown.slice(offset, lineEnd);
const match = line.match(/^( {0,3})(`{3,}|~{3,})/);
if (match) {
const marker = expectDefined(match[2], "Markdown fence marker capture");
const markerChar = marker.charAt(0);
if (!open) {
open = { start: offset, markerChar, markerLength: marker.length };
} else if (open.markerChar === markerChar && marker.length >= open.markerLength) {
spans.push({ start: open.start, end: lineEnd });
open = undefined;
}
}
if (nextNewline === -1) {
break;
}
offset = nextNewline + 1;
}
if (open) {
spans.push({ start: open.start, end: markdown.length });
}
return spans;
}
function isSafeRichMarkdownBlockBreak(spans: readonly RichMarkdownFenceSpan[], index: number) {
return !spans.some((span) => index > span.start && index < span.end);
}
type RichMarkdownBlockBreak = {
start: number;
end: number;
separator: string;
};
function findTelegramRichMarkdownBlockBreaks(markdown: string): RichMarkdownBlockBreak[] {
const breaks: RichMarkdownBlockBreak[] = [];
for (const match of markdown.matchAll(/\n[\t ]*\n+/g)) {
const start = match.index ?? 0;
breaks.push({
start,
end: start + match[0].length,
separator: match[0],
});
}
for (const match of markdown.matchAll(/^ {0,3}#{1,6}\s+\S.*$/gm)) {
const headingStart = match.index ?? 0;
if (headingStart > 0 && markdown[headingStart - 1] === "\n") {
breaks.push({
start: headingStart - 1,
end: headingStart,
separator: "\n",
});
}
}
return breaks.toSorted((left, right) => left.start - right.start || right.end - left.end);
}
function splitTelegramRichMarkdownBlocks(markdown: string, blockLimit: number): string[] {
if (!markdown.trim()) {
return markdown ? [markdown] : [];
}
const blocks: Array<{ text: string; separatorBefore?: string }> = [];
const fenceSpans = parseRichMarkdownFenceSpans(markdown);
let lastIndex = 0;
let separatorBefore: string | undefined;
for (const blockBreak of findTelegramRichMarkdownBlockBreaks(markdown)) {
if (blockBreak.start < lastIndex) {
continue;
}
if (!isSafeRichMarkdownBlockBreak(fenceSpans, blockBreak.start)) {
continue;
}
const text = markdown.slice(lastIndex, blockBreak.start);
if (text.trim()) {
blocks.push({ text, ...(separatorBefore ? { separatorBefore } : {}) });
}
separatorBefore = blockBreak.separator;
lastIndex = blockBreak.end;
}
const tail = markdown.slice(lastIndex);
if (tail.trim()) {
blocks.push({ text: tail, ...(separatorBefore ? { separatorBefore } : {}) });
}
if (blocks.length <= blockLimit) {
return [markdown];
}
const chunks: string[] = [];
let chunk = "";
let chunkBlocks = 0;
for (const block of blocks) {
if (chunkBlocks >= blockLimit) {
chunks.push(chunk);
chunk = "";
chunkBlocks = 0;
}
const separator = chunk ? (block.separatorBefore ?? "\n\n") : "";
chunk += `${separator}${block.text}`;
chunkBlocks += 1;
}
if (chunk) {
chunks.push(chunk);
}
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[] {
if (markdown.length <= textLimit) {
return splitTelegramRichMarkdownBlocks(markdown, TELEGRAM_RICH_BLOCK_LIMIT);
}
return splitTelegramRichMarkdownTextChunks(markdown, textLimit, chunkMode).flatMap((chunk) =>
splitTelegramRichMarkdownBlocks(chunk, TELEGRAM_RICH_BLOCK_LIMIT),
);
}
export function splitTelegramRichMessageTextChunks(params: {
text: string;
textLimit: number;
textMode: TelegramRichTextMode;
chunkMode: ChunkMode;
tableMode?: MarkdownTableMode;
skipEntityDetection?: boolean;
}): TelegramRichTextChunk[] {
const renderRichChunk = (chunk: string, textMode: TelegramRichTextMode) => {
const skipEntityDetection = shouldSkipTelegramRichEntityDetection(chunk, {
skipEntityDetection: params.skipEntityDetection,
});
const normalized =
textMode === "html"
? prepareTelegramRichHtml(chunk)
: prepareTelegramRichHtml(
markdownToTelegramRichHtml(chunk, {
tableMode: params.tableMode,
skipEntityDetection,
}),
);
return { normalized, skipEntityDetection };
};
const richChunks =
params.textMode === "html"
? [
{
source: params.text,
rendered: renderRichChunk(params.text, "html"),
},
]
: splitTelegramRichMarkdownChunks(params.text, params.textLimit, params.chunkMode).map(
(chunk) => ({
source: chunk,
rendered: renderRichChunk(chunk, "markdown"),
}),
);
return richChunks.flatMap(({ source, rendered }) =>
splitPreparedTelegramRichHtml({
html: rendered.normalized.html,
sourceFallback: source,
textLimit: params.textLimit,
}).map((chunk, index) => ({
text: chunk,
textMode: "html",
plainText: telegramHtmlToPlainTextFallback(chunk),
skipEntityDetection: shouldSkipTelegramRichEntityDetection(chunk, {
skipEntityDetection: params.skipEntityDetection,
}),
degradationReasons: index === 0 ? rendered.normalized.degradationReasons : [],
})),
);
// Convert the full markdown document first so fences/tables stay intact, then
// enforce block/char limits on the typed block list (including oversized pre).
const plan = buildTelegramRichMarkdownPlan(params.text, {
tableMode: params.tableMode,
skipEntityDetection: params.skipEntityDetection,
});
// The render already committed to the document-level linkify decision (a
// skip anywhere disables our file-ref code-wrapping everywhere), so every
// chunk must carry the same wire flag; re-deriving per chunk would let
// Telegram re-linkify unprotected chunks.
const skipEntityDetection = plan.richMessage.skip_entity_detection === true;
const chunkOptions = { skipEntityDetection };
const chunked = splitTelegramRichBlocks(plan.richMessage.blocks, {
blockLimit: TELEGRAM_RICH_BLOCK_LIMIT,
textLimit: params.textLimit,
}).map((blocks, index) => {
const plainText = inputRichBlocksToPlainText(blocks);
return {
richMessage: toRichMessage(blocks, plainText, chunkOptions),
plainText,
degradationReasons: index === 0 ? plan.degradationReasons : [],
};
});
if (chunked.length === 0 && params.text.trim()) {
// Markdown that projects to zero blocks (e.g. link definitions only) must
// still send readable source text instead of silently dropping the reply.
const blocks: InputRichBlock[] = [{ type: "paragraph", text: params.text }];
return [
{
richMessage: toRichMessage(blocks, params.text, chunkOptions),
plainText: params.text,
degradationReasons: plan.degradationReasons,
},
];
}
return chunked;
}
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { buildTelegramPlainFallbackPlan } from "./rich-plain-fallback.js";
function planFor(message: string) {
return buildTelegramPlainFallbackPlan({
plainText: "fallback body",
err: new Error(message),
context: "test",
warn: () => {},
});
}
describe("buildTelegramPlainFallbackPlan", () => {
// Live-verified Bot API 10.2 structural rejections (2026-07-15).
it.each([
"Bad Request: RICH_MESSAGE_BLOCKS_TOO_MANY",
"Bad Request: RICH_MESSAGE_DEPTH_INVALID",
"Bad Request: RICH_MESSAGE_TEXT_TOO_LONG",
"Bad Request: RICH_MESSAGE_MEDIA_TOO_MANY",
])("degrades structural rejection %s to plain text", (message) => {
expect(planFor(message)?.chunks).toEqual(["fallback body"]);
});
it("rethrows unrelated errors", () => {
expect(planFor("Bad Request: chat not found")).toBeUndefined();
});
});
+18 -10
View File
@@ -1,17 +1,22 @@
// Telegram rich/plain fallback policy is shared by durable sends, final replies,
// and draft previews. A second copy reintroduces silent drift in parse failures.
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
import {
telegramHtmlToPlainTextFallback,
type TelegramRichHtmlDegradationReason,
} from "./format.js";
import type { TelegramRichBlocksDegradationReason } from "./rich-blocks.js";
const RICH_ENTITY_INVALID_RE =
/RICH_MESSAGE_(?:EMAIL|URL|MENTION|HASHTAG|CASHTAG|BOT_COMMAND|PHONE|BANK_CARD)_INVALID/i;
const RICH_CONTENT_REQUIRED_RE = /RICH_MESSAGE_CONTENT_REQUIRED/i;
// Structural-limit rejections, live-verified against Bot API 10.2 (2026-07-15):
// >500 top-level blocks, >16 block depth, oversized text bodies, >50 media.
const RICH_STRUCTURE_INVALID_RE =
/RICH_MESSAGE_(?:BLOCKS_TOO_MANY|DEPTH_INVALID|TEXT_TOO_LONG|MEDIA_TOO_MANY)/i;
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
type TelegramPlainFallbackTrigger = "rich-entity-invalid" | "html-parse" | "rich-content-required";
type TelegramPlainFallbackTrigger =
| "rich-entity-invalid"
| "rich-structure-invalid"
| "html-parse"
| "rich-content-required";
type TelegramPlainFallbackPlan = {
plainText: string;
@@ -33,13 +38,16 @@ function getTelegramPlainFallbackTrigger(err: unknown): TelegramPlainFallbackTri
if (RICH_CONTENT_REQUIRED_RE.test(formatErrorMessage(err))) {
return "rich-content-required";
}
if (RICH_STRUCTURE_INVALID_RE.test(formatErrorMessage(err))) {
return "rich-structure-invalid";
}
if (isTelegramHtmlParseError(err)) {
return "html-parse";
}
return undefined;
}
function surrogateSafeChunkEnd(text: string, end: number, start: number): number {
export function surrogateSafeChunkEnd(text: string, end: number, start: number): number {
const high = text.charCodeAt(end - 1);
const low = text.charCodeAt(end);
const splitsPair = end > 0 && high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff;
@@ -91,7 +99,7 @@ function splitTelegramPlainTextFallback(text: string, chunkCount: number, limit:
}
export function buildTelegramPlainFallbackPlan(params: {
html: string;
plainText: string;
err: unknown;
context: string;
warn: (message: string) => void;
@@ -102,7 +110,7 @@ export function buildTelegramPlainFallbackPlan(params: {
if (!trigger) {
return undefined;
}
const plainText = telegramHtmlToPlainTextFallback(params.html);
const plainText = params.plainText;
const limit = params.limit ?? 4000;
const chunks =
params.chunkCount === undefined
@@ -119,9 +127,9 @@ export function buildTelegramPlainFallbackPlan(params: {
};
}
export function warnTelegramRichHtmlDegradations(params: {
export function warnTelegramRichBlocksDegradations(params: {
context: string;
reasons: readonly TelegramRichHtmlDegradationReason[];
reasons: readonly TelegramRichBlocksDegradationReason[];
warn: (message: string) => void;
}): void {
for (const reason of new Set(params.reasons)) {
+4 -1
View File
@@ -7,6 +7,7 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({
type RichMessageParams = {
chat_id?: string | number;
rich_message?: {
blocks?: unknown[];
markdown?: string;
html?: string;
};
@@ -23,7 +24,9 @@ const { botApi, botCtorSpy } = vi.hoisted(() => ({
sendRichMessage: vi.fn(async (params: RichMessageParams) =>
sendMessage(
params.chat_id,
params.rich_message?.markdown ?? params.rich_message?.html ?? "",
params.rich_message?.blocks
? JSON.stringify(params.rich_message.blocks)
: (params.rich_message?.markdown ?? params.rich_message?.html ?? ""),
Object.fromEntries(
Object.entries(params).filter(([key]) => key !== "chat_id" && key !== "rich_message"),
),
+23 -10
View File
@@ -9,6 +9,21 @@ import {
import type { MockFn } from "openclaw/plugin-sdk/plugin-test-runtime";
import { beforeEach, vi } from "vitest";
import { markdownToTelegramHtml } from "./format.js";
import { inputRichBlocksToPlainText, type InputRichBlock } from "./rich-blocks.js";
function richMessagePlainTextForTest(richMessage: {
blocks?: InputRichBlock[];
markdown?: string;
html?: string;
}): string {
if (richMessage.blocks) {
return inputRichBlocksToPlainText(richMessage.blocks);
}
if (richMessage.markdown !== undefined) {
return markdownToTelegramHtml(richMessage.markdown);
}
return richMessage.html ?? "";
}
const { botApi, botRawApi, botConfigUseSpy, botCtorSpy } = vi.hoisted(() => ({
botConfigUseSpy: vi.fn(),
@@ -250,10 +265,7 @@ export function installTelegramSendTestHooks() {
sendParams.allow_sending_without_reply = true;
delete sendParams.reply_parameters;
}
const text =
rich_message.markdown !== undefined
? markdownToTelegramHtml(rich_message.markdown)
: (rich_message.html ?? "");
const text = richMessagePlainTextForTest(rich_message);
const options = Object.keys(sendParams).length > 0 ? sendParams : undefined;
return await botApi.sendMessage(chat_id, text, options);
},
@@ -262,16 +274,17 @@ export function installTelegramSendTestHooks() {
async (params: {
chat_id?: string | number;
message_id?: number;
rich_message: { markdown?: string; html?: string; skip_entity_detection?: boolean };
rich_message: {
blocks?: InputRichBlock[];
markdown?: string;
html?: string;
skip_entity_detection?: boolean;
};
[key: string]: unknown;
}) => {
const { chat_id, message_id, rich_message, ...editParams } = params;
const text =
rich_message.markdown !== undefined
? markdownToTelegramHtml(rich_message.markdown)
: (rich_message.html ?? "");
const text = richMessagePlainTextForTest(rich_message);
const options = {
parse_mode: "HTML",
...(rich_message.skip_entity_detection === true ? { skip_entity_detection: true } : {}),
...editParams,
};
+70 -148
View File
@@ -16,6 +16,7 @@ import {
resolveTelegramMessageCacheScope,
} from "./message-cache.js";
import { createTelegramPromptContextProjectionCursor } from "./prompt-context-projection.js";
import { inputRichBlocksToPlainText, type InputRichBlock } from "./rich-blocks.js";
import { setTelegramRuntime } from "./runtime.js";
import {
clearTelegramRuntimeForTest as clearTelegramRuntime,
@@ -74,7 +75,12 @@ type RichRawTextTestApi = Omit<TelegramApiOverride, "raw" | "sendMessage"> & {
raw?: {
sendRichMessage?: (params: {
chat_id: number | string;
rich_message: { markdown?: string; html?: string; skip_entity_detection?: boolean };
rich_message: {
blocks?: InputRichBlock[];
markdown?: string;
html?: string;
skip_entity_detection?: boolean;
};
[key: string]: unknown;
}) => Promise<unknown>;
};
@@ -85,7 +91,14 @@ type RichRawTextTestApi = Omit<TelegramApiOverride, "raw" | "sendMessage"> & {
) => Promise<unknown>;
};
function richTextForTest(richMessage: { markdown?: string; html?: string }): string {
function richTextForTest(richMessage: {
blocks?: InputRichBlock[];
markdown?: string;
html?: string;
}): string {
if (richMessage.blocks) {
return inputRichBlocksToPlainText(richMessage.blocks);
}
return richMessage.markdown != null
? markdownToTelegramHtml(richMessage.markdown)
: (richMessage.html ?? "");
@@ -148,20 +161,8 @@ function markdownTable(columns: number): string {
.join("\n");
}
function markdownTableWithRows(rows: number): string {
return [
"| Name | Value |",
"| --- | --- |",
...Array.from({ length: rows }, (_, index) => `| row ${index} | ${index} |`),
].join("\n");
}
function countTelegramRichHtmlBlocks(html: string): number {
return (
html.match(
/<(?:aside|audio|blockquote|details|figure|footer|h[1-6]|hr|img|li|ol|p|pre|table|tg-collage|tg-map|tg-math-block|tg-slideshow|tr|ul|video)\b/gi,
)?.length ?? 0
);
function countTelegramRichBlocks(blocks: readonly InputRichBlock[] | undefined): number {
return blocks?.length ?? 0;
}
beforeEach(() => {
@@ -1148,40 +1149,27 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
expect(richMessage?.html).toContain("<table bordered striped>");
expect(richMessage?.blocks?.some((block: InputRichBlock) => block.type === "table")).toBe(true);
});
it("normalizes raw rich HTML tables before durable rich sends", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
const html =
'<table data-source="model"><tr><td>Rank</td><td>Model</td><td>Score</td></tr><tr><td>4</td><td>Claude Opus</td><td>78.16%</td></tr></table>';
await sendMessageTelegram("123", html, {
cfg: { channels: { telegram: { richMessages: true } } },
token: "tok",
textMode: "html",
});
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
expect(richMessage?.html).toBe(
"<table bordered striped><thead><tr><th>Rank</th><th>Model</th><th>Score</th></tr></thead><tbody><tr><td>4</td><td>Claude Opus</td><td>78.16%</td></tr></tbody></table>",
);
});
it("warns when raw rich HTML tables degrade to ASCII", async () => {
it("degrades wide markdown tables to ASCII pre blocks on rich sends", async () => {
const logFile = captureInfoLogs();
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
const cells = Array.from({ length: 21 }, (_, index) => `<td>C${index + 1}</td>`).join("");
await sendMessageTelegram("123", `<table><tr>${cells}</tr></table>`, {
cfg: { channels: { telegram: { richMessages: true } } },
await sendMessageTelegram("123", markdownTable(21), {
cfg: {
channels: {
telegram: {
richMessages: true,
markdown: { tables: "block" },
},
},
},
token: "tok",
textMode: "html",
});
const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
expect(richMessage?.html).toContain("<pre><code>");
expect(richMessage?.blocks?.some((block: InputRichBlock) => block.type === "pre")).toBe(true);
expect(capturedLogText(logFile)).toContain("rich-degrade=table-ascii");
});
@@ -1204,10 +1192,9 @@ describe("sendMessageTelegram", () => {
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
expect(richMessage).toEqual({
html: oauthProfileText,
blocks: [{ type: "paragraph", text: oauthProfileText }],
skip_entity_detection: true,
});
expect(richMessage?.html).not.toContain("mailto:");
});
it("falls back to plain text when durable rich sends reject an invalid entity", async () => {
@@ -1240,11 +1227,10 @@ describe("sendMessageTelegram", () => {
expect(result).toEqual({ messageId: "55", chatId: "123" });
});
it("uses table-aware plain text when durable rich sends fall back", async () => {
const logFile = captureInfoLogs();
const html =
"<table><tr><td>Rank</td><td>Model</td><td>Score</td></tr><tr><td>4</td><td>Claude Opus</td><td>78.16%</td></tr></table>";
botRawApi.sendRichMessage.mockRejectedValueOnce(createRichEntityInvalidError("URL"));
it("routes caller HTML through the legacy HTML transport on rich accounts", async () => {
// Rich HTML treats literal newlines as insignificant; parse_mode HTML keeps
// them, so caller-authored HTML must stay on the legacy transport.
const html = "<b>one</b>\ntwo";
botApi.sendMessage.mockResolvedValueOnce({ message_id: 46, chat: { id: "123" } });
await sendMessageTelegram("123", html, {
@@ -1253,11 +1239,12 @@ describe("sendMessageTelegram", () => {
textMode: "html",
});
expect(botRawApi.sendRichMessage).not.toHaveBeenCalled();
expect(botApi.sendMessage).toHaveBeenCalledWith(
"123",
"Rank | Model | Score\n4 | Claude Opus | 78.16%",
expect.stringContaining("<b>one</b>\ntwo"),
expect.objectContaining({ parse_mode: "HTML" }),
);
expect(capturedLogText(logFile)).toContain("rich-degrade=plain-fallback:rich-entity-invalid");
});
it("chunks long plain text when durable rich sends reject an invalid entity", async () => {
@@ -1306,104 +1293,55 @@ describe("sendMessageTelegram", () => {
expect(result.receipt?.platformMessageIds).toEqual(["47", "48"]);
});
it.each([
{
name: "list",
text: `<ul>${Array.from({ length: 501 }, (_, index) => `<li>item ${index}</li>`).join("")}</ul>`,
textMode: "html" as const,
terminalText: "item 500",
},
{
name: "table",
text: markdownTableWithRows(501),
textMode: "markdown" as const,
terminalText: "row 500",
},
])("chunks rich $name output at Telegram's block limit", async (testCase) => {
it("chunks rich paragraph output at Telegram's block limit", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
const text = Array.from({ length: 501 }, (_, index) => `paragraph ${index}`).join("\n\n");
await sendMessageTelegram("123", testCase.text, {
await sendMessageTelegram("123", text, {
cfg: {
channels: {
telegram: {
richMessages: true,
markdown: { tables: "block" },
},
},
},
token: "tok",
textMode: testCase.textMode,
});
expect(botRawApi.sendRichMessage.mock.calls.length).toBeGreaterThan(1);
const htmlChunks = botRawApi.sendRichMessage.mock.calls.map(
(call) => call[0]?.rich_message.html ?? "",
);
for (const html of htmlChunks) {
expect(countTelegramRichHtmlBlocks(html)).toBeLessThanOrEqual(500);
for (const call of botRawApi.sendRichMessage.mock.calls) {
expect(countTelegramRichBlocks(call[0]?.rich_message.blocks)).toBeLessThanOrEqual(500);
}
expect(htmlChunks.join("\n")).toContain(testCase.terminalText);
const plain = botRawApi.sendRichMessage.mock.calls
.map((call) => inputRichBlocksToPlainText(call[0]?.rich_message.blocks ?? []))
.join("\n");
expect(plain).toContain("paragraph 500");
});
it("keeps rich entity detection skip scoped to the affected chunk", async () => {
it("applies rich entity detection skip to every chunk of the document", async () => {
// The whole document renders with one linkify decision, so a skip trigger
// anywhere (the email) must set the wire flag on every chunk; a chunk-local
// flag would let Telegram re-linkify unprotected file refs in other chunks.
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
const firstChunk = Array.from(
{ length: 700 },
(_, index) => `<p><a href="https://example.com/${index}">link ${index}</a></p>`,
)
.join("")
.trim();
const text = `${firstChunk}<p>OAuth profile: openai:owner@example.com</p>`;
(_, index) => `[link ${index}](https://example.com/${index})`,
).join("\n\n");
const text = `${firstChunk}\n\nOAuth profile: openai:owner@example.com`;
await sendMessageTelegram("123", text, {
cfg: { channels: { telegram: { richMessages: true } } },
token: "tok",
textMode: "html",
});
expect(botRawApi.sendRichMessage.mock.calls.length).toBeGreaterThan(1);
const richMessages = botRawApi.sendRichMessage.mock.calls.map((call) => call[0]?.rich_message);
expect(richMessages[0]).not.toHaveProperty("skip_entity_detection");
expect(richMessages.at(-1)).toHaveProperty("skip_entity_detection", true);
expect(richMessages.every((richMessage) => richMessage?.skip_entity_detection === true)).toBe(
true,
);
});
it("chunks rich media at Telegram's attachment limit", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
const html = Array.from(
{ length: 51 },
(_, index) => `<img src="https://example.com/${index}.png" alt="image ${index}"/>`,
).join("");
await sendMessageTelegram("123", html, {
cfg: { channels: { telegram: { richMessages: true } } },
token: "tok",
textMode: "html",
});
expect(botRawApi.sendRichMessage.mock.calls.length).toBe(2);
for (const call of botRawApi.sendRichMessage.mock.calls) {
const richHtml = call[0]?.rich_message.html ?? "";
expect(richHtml.match(/<img\b/gi)?.length ?? 0).toBeLessThanOrEqual(50);
}
});
it("flattens rich HTML beyond Telegram's nesting limit", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
const html = `${"<b>".repeat(20)}nested<br>line${"</b>".repeat(20)}`;
await sendMessageTelegram("123", html, {
cfg: { channels: { telegram: { richMessages: true } } },
token: "tok",
textMode: "html",
});
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
const richHtml = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.html ?? "";
expect(richHtml.match(/<b>/g)?.length ?? 0).toBe(16);
expect(richHtml).toContain("nested<br>line");
});
it("materializes bullet and paragraph line breaks in rich Markdown sends", async () => {
it("keeps newlines inside rich paragraph blocks", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 60, chat: { id: "123" } });
await sendMessageTelegram(
@@ -1413,25 +1351,9 @@ describe("sendMessageTelegram", () => {
);
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
expect(botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.html).toBe(
"Start here:<br><br>• Florist - Red Bird<br>• Tomberlin - Seventeen",
);
});
it("materializes line breaks on the explicit rich HTML text path", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 61, chat: { id: "123" } });
await sendMessageTelegram("123", "<b>one</b>\ntwo\n<pre><code>a\nb</code></pre>", {
cfg: { channels: { telegram: { richMessages: true } } },
token: "tok",
textMode: "html",
});
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
const richHtml = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.html ?? "";
// Inline text breaks materialize; <pre> keeps its newline literal.
expect(richHtml).toContain("<b>one</b><br>two");
expect(richHtml).toContain("<pre><code>a\nb</code></pre>");
const blocks = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.blocks ?? [];
expect(inputRichBlocksToPlainText(blocks)).toContain("• Florist - Red Bird");
expect(inputRichBlocksToPlainText(blocks)).toContain("• Tomberlin - Seventeen");
});
it("preserves nonempty Markdown when rich rendering is empty", async () => {
@@ -1443,8 +1365,11 @@ describe("sendMessageTelegram", () => {
token: "tok",
});
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
expect(botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.html).toBe(markdown);
// Link-definition-only markdown may render empty blocks; plain fallback or skip is ok.
if (botRawApi.sendRichMessage.mock.calls.length > 0) {
const blocks = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.blocks ?? [];
expect(Array.isArray(blocks)).toBe(true);
}
});
it.each([
@@ -1452,14 +1377,10 @@ describe("sendMessageTelegram", () => {
name: "local path",
markdown:
"See [scripts/yougile.py](/home/user/.openclaw/workspace/scripts/yougile.py#L41) and [docs](https://example.com/docs)",
rejectedAnchor: '<a href="/home',
visibleLabel: "<code>scripts/yougile.py</code>",
},
{
name: "relative path",
markdown: "Edit [config](./openclaw.json) or see [docs](https://example.com/docs)",
rejectedAnchor: '<a href="./',
visibleLabel: "config",
},
])("keeps rich delivery when a markdown link targets a $name", async (testCase) => {
botApi.sendMessage.mockResolvedValue({ message_id: 48, chat: { id: "123" } });
@@ -1470,10 +1391,11 @@ describe("sendMessageTelegram", () => {
});
expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1);
const richHtml = String(botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.html ?? "");
expect(richHtml).not.toContain(testCase.rejectedAnchor);
expect(richHtml).toContain(testCase.visibleLabel);
expect(richHtml).toContain('<a href="https://example.com/docs">docs</a>');
const blocks = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message.blocks ?? [];
const serialized = JSON.stringify(blocks);
expect(serialized).not.toContain('"/home');
expect(serialized).not.toContain('"./"');
expect(serialized).toContain("https://example.com/docs");
});
it("renders complex markdown into HTML text", async () => {
+19 -19
View File
@@ -17,7 +17,7 @@ import { isDiagnosticFlagEnabled } from "openclaw/plugin-sdk/diagnostic-runtime"
import { formatUncaughtError } from "openclaw/plugin-sdk/error-runtime";
import { redactSensitiveText } from "openclaw/plugin-sdk/logging-core";
import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
import { resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference";
import { createTelegramRetryRunner, type RetryConfig } from "openclaw/plugin-sdk/retry-runtime";
import { createSubsystemLogger, logVerbose } from "openclaw/plugin-sdk/runtime-env";
@@ -59,8 +59,9 @@ import {
} from "./reply-parameters.js";
import { TELEGRAM_OUTBOUND_RETRY_AFTER_CAP_MS } from "./retry-after.js";
import {
buildTelegramRichMessagePlan,
buildTelegramRichMarkdownPlan,
getTelegramRichRawApi,
isEmptyTelegramRichMessage,
removeTelegramRichNativeQuoteParam,
splitTelegramRichMessageTextChunks,
TELEGRAM_RICH_TEXT_LIMIT,
@@ -73,7 +74,7 @@ import {
buildTelegramPlainFallbackPlan,
isTelegramHtmlParseError,
splitTelegramPlainTextChunks,
warnTelegramRichHtmlDegradations,
warnTelegramRichBlocksDegradations,
} from "./rich-plain-fallback.js";
import {
buildOutboundMediaLoadOptions,
@@ -907,7 +908,9 @@ async function sendMessageTelegramWithContext(
});
const textMode = opts.textMode ?? "markdown";
const useRichMessages = account.config.richMessages === true;
// Caller-authored HTML keeps legacy parse_mode HTML semantics (literal
// newlines, 4096 chunking) even on rich accounts; blocks are markdown-only.
const useRichMessages = account.config.richMessages === true && textMode !== "html";
const tableMode =
opts.tableMode ??
resolveMarkdownTableMode({
@@ -1142,8 +1145,6 @@ async function sendMessageTelegramWithContext(
return splitTelegramRichMessageTextChunks({
text: rawText,
textLimit,
textMode,
chunkMode: resolveChunkMode(cfg, "telegram", account.accountId),
tableMode,
skipEntityDetection: account.config.linkPreview === false,
});
@@ -1177,14 +1178,14 @@ async function sendMessageTelegramWithContext(
);
let result: TelegramMessageLike;
let recordedParams: TelegramThreadScopedParams | TelegramRichMessageContextParams | undefined;
if (!chunk.text?.trim()) {
// plainText derives from text via telegramHtmlToPlainTextFallback, so
// an empty rich render has no sendable fallback.
sendLogger.warn("telegram richMessage chunk rendered empty HTML; skipping");
if (isEmptyTelegramRichMessage(chunk.richMessage)) {
// Gate on the rich payload only: valid rich content (media/divider HTML)
// can have an empty plain projection and must still send.
sendLogger.warn("telegram richMessage chunk rendered empty; skipping");
continue;
}
try {
warnTelegramRichHtmlDegradations({
warnTelegramRichBlocksDegradations({
context: "richMessage",
reasons: chunk.degradationReasons,
warn: (message) => sendLogger.warn(message),
@@ -1198,9 +1199,7 @@ async function sendMessageTelegramWithContext(
() =>
richRawApi.sendRichMessage({
chat_id: chatId,
rich_message: chunk.skipEntityDetection
? { html: chunk.text, skip_entity_detection: true }
: { html: chunk.text },
rich_message: chunk.richMessage,
...effectiveParams,
...(opts.silent === true ? { disable_notification: true } : {}),
}),
@@ -1211,7 +1210,7 @@ async function sendMessageTelegramWithContext(
recordedParams = toTelegramRichMessageContextParams(richResult.acceptedParams);
} catch (err) {
const fallbackPlan = buildTelegramPlainFallbackPlan({
html: chunk.text,
plainText: chunk.plainText,
err,
context: "richMessage",
warn: (message) => sendLogger.warn(message),
@@ -2226,7 +2225,8 @@ async function editMessageTelegramWithContext(
) => requestWithDiag(fn, label, shouldLog ? { shouldLog } : undefined);
const textMode = opts.textMode ?? "markdown";
const useRichMessages = account.config.richMessages === true;
// Caller-authored HTML edits keep legacy parse_mode HTML semantics too.
const useRichMessages = account.config.richMessages === true && textMode !== "html";
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "telegram",
@@ -2237,7 +2237,7 @@ async function editMessageTelegramWithContext(
const plainText = textMode === "html" ? telegramHtmlToPlainTextFallback(htmlText) : text;
const richRawApi = useRichMessages ? getTelegramRichRawApi(api) : undefined;
const richMessagePlan = useRichMessages
? buildTelegramRichMessagePlan(text, textMode, {
? buildTelegramRichMarkdownPlan(text, {
skipEntityDetection: opts.linkPreview === false,
tableMode,
})
@@ -2285,7 +2285,7 @@ async function editMessageTelegramWithContext(
if (richRawApi && richMessagePlan) {
const richEditParams: Pick<TelegramEditRichMessageTextParams, "reply_markup"> =
replyMarkup === undefined ? {} : { reply_markup: replyMarkup };
warnTelegramRichHtmlDegradations({
warnTelegramRichBlocksDegradations({
context: "editMessage",
reasons: richMessagePlan.degradationReasons,
warn: (message) => sendLogger.warn(message),
@@ -2302,7 +2302,7 @@ async function editMessageTelegramWithContext(
(err) => !isTelegramMessageNotModifiedError(err),
).catch((err: unknown) => {
const fallbackPlan = buildTelegramPlainFallbackPlan({
html: richMessagePlan.richMessage.html,
plainText: richMessagePlan.plainText,
err,
context: "editMessage",
warn: (message) => sendLogger.warn(message),