diff --git a/extensions/telegram/AGENTS.md b/extensions/telegram/AGENTS.md index eea9dae7507d..a1943c41327b 100644 --- a/extensions/telegram/AGENTS.md +++ b/extensions/telegram/AGENTS.md @@ -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 diff --git a/extensions/telegram/src/bot-message-dispatch.test.ts b/extensions/telegram/src/bot-message-dispatch.test.ts index d3cc8cc1627c..7e5285def7de 100644 --- a/extensions/telegram/src/bot-message-dispatch.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.test.ts @@ -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(""), + blocks: [expect.objectContaining({ type: "table", is_bordered: true, is_striped: true })], }), ); }); diff --git a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts index b71d90d201ad..d8819b1418b5 100644 --- a/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts +++ b/extensions/telegram/src/bot.create-telegram-bot.test-harness.ts @@ -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 { diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 5f48a07158ad..59cced38f465 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -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(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, diff --git a/extensions/telegram/src/bot/delivery.send.ts b/extensions/telegram/src/bot/delivery.send.ts index 2e7766c5e404..26f2da573298 100644 --- a/extensions/telegram/src/bot/delivery.send.ts +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -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), diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index 7ece619d6e4f..fd7fa935f1c1 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -90,7 +90,12 @@ function createBot(api: Record = {}): 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 = {}): 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, }); }); diff --git a/extensions/telegram/src/draft-stream.test.ts b/extensions/telegram/src/draft-stream.test.ts index aab94c9a362f..f7842be55daf 100644 --- a/extensions/telegram/src/draft-stream.test.ts +++ b/extensions/telegram/src/draft-stream.test.ts @@ -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: "Shelling\n🛠️ Exec", - skip_entity_detection: true, - }, + text: "Shelling\n🛠️ Exec", + 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: "Shelling\n🛠️ Exec\nChecking files", - skip_entity_detection: true, - }, + text: "Shelling\n🛠️ Exec\nChecking files", + 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: "Shelling
🛠️ Exec", - skip_entity_detection: true, - }, + text: "Shelling\n🛠️ Exec", + 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: "Shelling
🛠️ Exec
Update Checking files", - skip_entity_detection: true, - }, + text: "Shelling\n🛠️ Exec\nUpdate Checking files", + 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: "Shelling <&>\n🛠️ Exec", - skip_entity_detection: true, - }, + text: "Shelling <&>\n🛠️ Exec", + parseMode: "HTML", }); await stream.flush(); @@ -1080,10 +1065,10 @@ describe("createTelegramDraftStream", () => { "Shelling <&>\n🛠️ Exec", { 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 <&>\n\n🛠️ Exec", + text: "Shelling <&>\n🛠️ Exec", + sourceText: "Shelling <&>\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: "Done <&>" }, + text: "Done <&>", + 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: "

Plan

A
" }, - }); + stream.update("## Plan\n\n| A |\n| --- |\n| x |"); await stream.flush(); - expect(api.raw.sendRichMessage).toHaveBeenCalledWith({ - chat_id: 123, - rich_message: { - html: "

Plan

A
", - }, - }); + 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: "

Plan updated

B
" }, - }); + 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: "

Plan updated

B
", - }, - }); + 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: "
RankModelScore
4Claude Opus78.16%
", - }, - }); + 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: "

Plan

A
" }, + 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) => /^
[\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) => /^
[\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 () => {
diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts
index ca4f609e3268..91e3cd95ab3c 100644
--- a/extensions/telegram/src/draft-stream.ts
+++ b/extensions/telegram/src/draft-stream.ts
@@ -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;
-  degradationReasons?: readonly TelegramRichHtmlDegradationReason[];
+  richMessage?: TelegramInputRichMessage;
+  degradationReasons?: readonly TelegramRichBlocksDegradationReason[];
 };
 
 type RetainedTelegramDraftPage = {
@@ -131,49 +132,61 @@ function telegramRichHtmlToParseModeHtml(html: string): string {
   return html.replace(//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,
   ) => {
     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),
diff --git a/extensions/telegram/src/format-html.ts b/extensions/telegram/src/format-html.ts
index a8abb79badaf..a4764c2906ae 100644
--- a/extensions/telegram/src/format-html.ts
+++ b/extensions/telegram/src/format-html.ts
@@ -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 = 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 {
diff --git a/extensions/telegram/src/format.ts b/extensions/telegram/src/format.ts
index c39293f3de89..d036d9c6c806 100644
--- a/extensions/telegram/src/format.ts
+++ b/extensions/telegram/src/format.ts
@@ -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, "&").replace(//g, ">");
 }
@@ -204,23 +185,11 @@ const TELEGRAM_HTML_ANCHOR_PATTERN =
   /]*\bhref\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))[^>]*>([\s\S]*?)<\/a\s*>/gi;
 const TELEGRAM_HTML_BREAK_PATTERN = //gi;
 const TELEGRAM_HTML_TAG_PATTERN = /<[^>]*>/g;
-const TELEGRAM_RICH_MEDIA_BLOCK_PATTERN =
-  /[^\S\r\n]*(?:]*>[\s\S]*?<\/figure>|]*>[\s\S]*?<\/tg-collage>|]*>[\s\S]*?<\/tg-slideshow>|]*\bsrc="https?:\/\/[^"]+"[^>]*\/?>|]*\bsrc="https?:\/\/[^"]+"[^>]*(?:\/>|>[\s\S]*?<\/video>)|]*\bsrc="https?:\/\/[^"]+"[^>]*(?:\/>|>[\s\S]*?<\/audio>)|]*\/?>)[^\S\r\n]*/gi;
 const TELEGRAM_RICH_HTML_TABLE_PATTERN = /]*>[\s\S]*?<\/table>/gi;
-const TELEGRAM_CANONICAL_RICH_HTML_TABLE_PATTERN = /^/i;
 const TELEGRAM_RICH_HTML_TABLE_ROW_PATTERN = /]*>([\s\S]*?)<\/tr>/gi;
 const TELEGRAM_RICH_HTML_TABLE_CELL_PATTERN = /<(td|th)\b([^>]*)>([\s\S]*?)<\/\1>/gi;
 const TELEGRAM_HTML_CAPTION_PATTERN = /]*>([\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;
   attrPatterns: ReadonlyMap;
 };
 
-type TelegramTableAlignment = NonNullable[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(/]*?)(\s*)>/gi, (_match, attrs: string, trailing: string) =>
-      attrs.trimEnd().endsWith("/") ? `` : ``,
-    );
-  return /^<(?:img|video|audio)\b/i.test(normalized)
-    ? `
${normalized}
` - : 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 `
${escapeHtml([caption, tableText].filter(Boolean).join("\n"))}
\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(); - 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 = `${escapeHtmlAttr(alt)}`; - const figcaption = caption ? `
${escapeHtml(caption)}
` : ""; - const placeholder = buildTelegramRichMarkdownMediaPlaceholder(mediaBlocks.length); - mediaBlocks.push(`
${img}${figcaption}
`); - 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 `
${escapeHtml(tableText)}
\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 ? `
` : ""; - 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)}`; - }; - const head = table.headers.length - ? `${ - isRawRichHtmlTable - ? table.headerCells.map((cell) => renderCell("th", cell, undefined)).join("") - : table.headerCells - .map((cell, index) => renderCell("th", cell, table.aligns?.[index])) - .join("") - }` - : ""; - const bodyRows = isRawRichHtmlTable - ? table.rowCells - .map((row) => `${row.map((cell) => renderCell("td", cell, undefined)).join("")}`) - .join("") - : table.rowCells - .map( - (row) => - `${Array.from({ length: columnCount }, (_value, index) => renderCell("td", row[index], table.aligns?.[index])).join("")}`, - ) - .join(""); - const body = bodyRows ? `${bodyRows}` : ""; - return `
${escapeHtml(rawCaption)}
${caption}${head}${body}
\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
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 : "
".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
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; - //
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
) 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 : ``.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: ``,
-        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),
   );
 }
diff --git a/extensions/telegram/src/progress-draft-preview.ts b/extensions/telegram/src/progress-draft-preview.ts
index b8e4a4f96bce..65abed665011 100644
--- a/extensions/telegram/src/progress-draft-preview.ts
+++ b/extensions/telegram/src/progress-draft-preview.ts
@@ -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 (

from a - // setext heading,
, 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("
"); } 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(`${escapeTelegramProgressHtml(clipTelegramProgressText(text))}`); } } @@ -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 - ? [ - `${escapeTelegramProgressHtml(statusLines[0] ?? "")}`, - ...statusLines.slice(1).map(renderTelegramProgressStringLine), - ].join("
") - : statusLines.map(renderTelegramProgressStringLine).join("
"); if (!richMessages) { + const html = + statusLines.length > 1 + ? [ + `${escapeTelegramProgressHtml(statusLines[0] ?? "")}`, + ...statusLines.slice(1).map(renderTelegramProgressStringLine), + ].join("
") + : statusLines.map(renderTelegramProgressStringLine).join("
"); 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 - ? [`${escapeTelegramProgressHtml(heading)}`, ...renderedLines] - : renderedLines; - const html = htmlParts.join("
"); if (!richMessages) { - return { text: html, parseMode: "HTML" }; + const htmlParts = heading + ? [`${escapeTelegramProgressHtml(heading)}`, ...renderedLines] + : renderedLines; + return { text: htmlParts.join("
"), 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, }; } diff --git a/extensions/telegram/src/rich-blocks.test.ts b/extensions/telegram/src/rich-blocks.test.ts new file mode 100644 index 000000000000..50290985b12d --- /dev/null +++ b/extensions/telegram/src/rich-blocks.test.ts @@ -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"); + }); +}); diff --git a/extensions/telegram/src/rich-blocks.ts b/extensions/telegram/src/rich-blocks.ts new file mode 100644 index 000000000000..0a2f39af949c --- /dev/null +++ b/extensions/telegram/src/rich-blocks.ts @@ -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 = { + 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([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, +): 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
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(); + 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 }; +} diff --git a/extensions/telegram/src/rich-message.ts b/extensions/telegram/src/rich-message.ts index 1b12b5a61f46..5f9f34504e6b 100644 --- a/extensions/telegram/src/rich-message.ts +++ b/extensions/telegram/src/rich-message.ts @@ -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; +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; } diff --git a/extensions/telegram/src/rich-plain-fallback.test.ts b/extensions/telegram/src/rich-plain-fallback.test.ts new file mode 100644 index 000000000000..24222b9920dd --- /dev/null +++ b/extensions/telegram/src/rich-plain-fallback.test.ts @@ -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(); + }); +}); diff --git a/extensions/telegram/src/rich-plain-fallback.ts b/extensions/telegram/src/rich-plain-fallback.ts index 4c1acb76cabe..d7b38db70e3e 100644 --- a/extensions/telegram/src/rich-plain-fallback.ts +++ b/extensions/telegram/src/rich-plain-fallback.ts @@ -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)) { diff --git a/extensions/telegram/src/send.proxy.test.ts b/extensions/telegram/src/send.proxy.test.ts index a6a8957ec4db..d2a08e72fcc9 100644 --- a/extensions/telegram/src/send.proxy.test.ts +++ b/extensions/telegram/src/send.proxy.test.ts @@ -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"), ), diff --git a/extensions/telegram/src/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts index 0d11d228177e..eaf2d4c3c210 100644 --- a/extensions/telegram/src/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -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, }; diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 6fa3d346298b..7baf9d148532 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -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 & { 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; }; @@ -85,7 +91,14 @@ type RichRawTextTestApi = Omit & { ) => Promise; }; -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(""); + 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 = - '
RankModelScore
4Claude Opus78.16%
'; - - 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( - "
RankModelScore
4Claude Opus78.16%
", - ); - }); - - 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) => `C${index + 1}`).join(""); - await sendMessageTelegram("123", `${cells}
`, { - 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("
");
+    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 =
-      "
RankModelScore
4Claude Opus78.16%
"; - 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 = "one\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("one\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: `
    ${Array.from({ length: 501 }, (_, index) => `
  • item ${index}
  • `).join("")}
`, - 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) => `

link ${index}

`, - ) - .join("") - .trim(); - const text = `${firstChunk}

OAuth profile: openai:owner@example.com

`; + (_, 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) => `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(/ { - botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); - const html = `${"".repeat(20)}nested
line${"
".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(//g)?.length ?? 0).toBe(16); - expect(richHtml).toContain("nested
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:

• Florist - Red Bird
• 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", "one\ntwo\n
a\nb
", { - 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;
 keeps its newline literal.
-    expect(richHtml).toContain("one
two"); - expect(richHtml).toContain("
a\nb
"); + 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: 'scripts/yougile.py
", }, { name: "relative path", markdown: "Edit [config](./openclaw.json) or see [docs](https://example.com/docs)", - rejectedAnchor: ' { 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('docs'); + 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 () => { diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index c9eb0fed4461..5bbbaec5a964 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -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 = 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),