refactor(telegram): split rich block modules under lint budgets

This commit is contained in:
Ayaan Zaidi
2026-07-15 13:45:20 +05:30
parent 2b4c03cae8
commit 02a5ce6306
18 changed files with 1378 additions and 1343 deletions
@@ -45,7 +45,7 @@ import {
resolveTelegramInteractiveTextFallback,
} from "../interactive-fallback.js";
import type { TelegramPromptContextProjectionSequence } from "../prompt-context-projection.js";
import type { TelegramRichBlocksDegradationReason } from "../rich-blocks.js";
import type { TelegramRichBlocksDegradationReason } from "../rich-block-model.js";
import {
isEmptyTelegramRichMessage,
splitTelegramRichMessageTextChunks,
+1 -1
View File
@@ -14,7 +14,7 @@ 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 type { TelegramRichBlocksDegradationReason } from "../rich-block-model.js";
import {
buildTelegramRichMarkdownPlan,
getTelegramRichRawApi,
+2 -2
View File
@@ -26,9 +26,9 @@ import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
import { normalizeTelegramReplyToMessageId } from "./outbound-params.js";
import {
inputRichBlocksToPlainText,
splitTelegramRichBlocks,
type TelegramRichBlocksDegradationReason,
} from "./rich-blocks.js";
} from "./rich-block-model.js";
import { splitTelegramRichBlocks } from "./rich-block-split.js";
import {
buildTelegramRichBlocksPlan,
buildTelegramRichMarkdownPlan,
+12 -1
View File
@@ -9,7 +9,10 @@ import {
resolveTelegramMessageCachePersistentScopeKey,
TELEGRAM_MESSAGE_CACHE_PERSISTENT_MAX_MESSAGES,
} from "./message-cache.js";
import { resetTelegramMessageCacheForTest as resetTelegramMessageCacheBucketsForTest } from "./runtime.test-support.js";
import {
clearTelegramRuntimeForTest,
resetTelegramMessageCacheForTest as resetTelegramMessageCacheBucketsForTest,
} from "./runtime.test-support.js";
type TelegramMessageCachePersistentStore = NonNullable<
NonNullable<Parameters<typeof createTelegramMessageCache>[0]>["persistentStore"]
@@ -939,6 +942,10 @@ describe("telegram message cache", () => {
});
it("preserves rich-message placeholders in subsequent conversation context", async () => {
// A runtime leaked by earlier suite files binds new caches to the
// persistent keyed store; clear it so this cache stays instance-local.
clearTelegramRuntimeForTest();
resetTelegramMessageCacheBucketsForTest();
const cache = createTelegramMessageCache();
const chat = { id: 7, type: "private", first_name: "Nora" } as const;
await cache.record({
@@ -982,6 +989,10 @@ describe("telegram message cache", () => {
});
it("preserves rich-message text in subsequent conversation context", async () => {
// A runtime leaked by earlier suite files binds new caches to the
// persistent keyed store; clear it so this cache stays instance-local.
clearTelegramRuntimeForTest();
resetTelegramMessageCacheBucketsForTest();
const cache = createTelegramMessageCache();
const chat = { id: 7, type: "private", first_name: "Nora" } as const;
await cache.record({
@@ -0,0 +1,66 @@
// Telegram outbound sanitize gating: rich accounts keep the HTML island
// contract; non-rich accounts keep the legacy plain conversion.
import { describe, expect, it, vi } from "vitest";
vi.mock("./send.js", () => ({
pinMessageTelegram: vi.fn(),
reactMessageTelegram: vi.fn(),
sendPollTelegram: vi.fn(),
sendLocationTelegram: vi.fn(),
sendMessageTelegram: vi.fn(),
}));
import { telegramOutbound } from "./outbound-adapter.js";
describe("telegramOutbound.sanitizeText", () => {
const islandText =
'before <details><summary>More</summary>body</details> <tg-math-block>x^2</tg-math-block> <ul><li><input type="checkbox" checked/>done</li></ul>';
it("keeps the rich HTML island contract intact for rich accounts", () => {
const sanitized = telegramOutbound.sanitizeText?.({
text: islandText,
payload: { text: islandText },
cfg: { channels: { telegram: { richMessages: true } } } as never,
accountId: "default",
});
expect(sanitized).toContain("<details><summary>More</summary>");
expect(sanitized).toContain("<tg-math-block>x^2</tg-math-block>");
expect(sanitized).toContain('<input type="checkbox" checked/>');
});
it("converts HTML to plain markers for non-rich accounts", () => {
const sanitized = telegramOutbound.sanitizeText?.({
text: islandText,
payload: { text: islandText },
cfg: { channels: { telegram: {} } } as never,
accountId: "default",
});
expect(sanitized).not.toContain("<details>");
expect(sanitized).toContain("• done");
});
it("resolves the effective named default account when accountId is omitted", () => {
const cfg = {
channels: {
telegram: {
defaultAccount: "rich-bot",
accounts: { "rich-bot": { richMessages: true } },
},
},
} as never;
const sanitized = telegramOutbound.sanitizeText?.({
text: islandText,
payload: { text: islandText },
cfg,
});
expect(sanitized).toContain("<details><summary>More</summary>");
});
it("stays on the plain path when config is unavailable", () => {
const sanitized = telegramOutbound.sanitizeText?.({
text: islandText,
payload: { text: islandText },
});
expect(sanitized).not.toContain("<details>");
});
});
@@ -1074,56 +1074,3 @@ describe("telegramOutbound", () => {
expect(options.notify).toBe(false);
});
});
describe("telegramOutbound.sanitizeText", () => {
const islandText =
'before <details><summary>More</summary>body</details> <tg-math-block>x^2</tg-math-block> <ul><li><input type="checkbox" checked/>done</li></ul>';
it("keeps the rich HTML island contract intact for rich accounts", () => {
const sanitized = telegramOutbound.sanitizeText?.({
text: islandText,
payload: { text: islandText },
cfg: { channels: { telegram: { richMessages: true } } } as never,
accountId: "default",
});
expect(sanitized).toContain("<details><summary>More</summary>");
expect(sanitized).toContain("<tg-math-block>x^2</tg-math-block>");
expect(sanitized).toContain('<input type="checkbox" checked/>');
});
it("converts HTML to plain markers for non-rich accounts", () => {
const sanitized = telegramOutbound.sanitizeText?.({
text: islandText,
payload: { text: islandText },
cfg: { channels: { telegram: {} } } as never,
accountId: "default",
});
expect(sanitized).not.toContain("<details>");
expect(sanitized).toContain("• done");
});
it("resolves the effective named default account when accountId is omitted", () => {
const cfg = {
channels: {
telegram: {
defaultAccount: "rich-bot",
accounts: { "rich-bot": { richMessages: true } },
},
},
} as never;
const sanitized = telegramOutbound.sanitizeText?.({
text: islandText,
payload: { text: islandText },
cfg,
});
expect(sanitized).toContain("<details><summary>More</summary>");
});
it("stays on the plain path when config is unavailable", () => {
const sanitized = telegramOutbound.sanitizeText?.({
text: islandText,
payload: { text: islandText },
});
expect(sanitized).not.toContain("<details>");
});
});
@@ -6,11 +6,11 @@ import {
boldRichText,
codeRichText,
italicRichText,
markdownToTelegramRichBlocks,
paragraphBlock,
type InputRichBlock,
type RichText,
} from "./rich-blocks.js";
} from "./rich-block-model.js";
import { markdownToTelegramRichBlocks } from "./rich-blocks.js";
import { buildTelegramRichBlocksPlan } from "./rich-message.js";
import { clipTelegramProgressText } from "./truncate.js";
+395
View File
@@ -0,0 +1,395 @@
// Bot API 10.2 rich block/RichText model: types, size accounting, and the
// plain-text projection shared by the emitter, splitter, and fallback paths.
export type TelegramRichBlocksDegradationReason = "table-ascii";
export type RichText =
| string
| RichText[]
| {
type:
| "bold"
| "italic"
| "underline"
| "strikethrough"
| "code"
| "spoiler"
| "marked"
| "subscript"
| "superscript";
text: RichText;
}
| {
type: "url";
text: RichText;
url: string;
}
| {
type: "anchor_link";
text: RichText;
anchor_name: string;
}
| {
type: "mathematical_expression";
expression: string;
}
| {
type: "custom_emoji";
custom_emoji_id: string;
alternative_text: string;
};
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;
};
type InputRichBlockHeading = {
type: "heading";
text: RichText;
size: 1 | 2 | 3 | 4 | 5 | 6;
};
type InputRichBlockPre = {
type: "pre";
text: string;
language?: string;
};
type InputRichBlockBlockquote = {
type: "blockquote";
blocks: InputRichBlock[];
credit?: RichText;
};
type InputRichBlockTable = {
type: "table";
cells: RichBlockTableCell[][];
is_bordered?: true;
is_striped?: true;
caption?: RichText;
};
export type RichBlockCaption = {
text: RichText;
credit?: RichText;
};
export type InputRichBlockListItem = {
blocks: InputRichBlock[];
has_checkbox?: true;
is_checked?: true;
value?: number;
type?: "a" | "A" | "i" | "I" | "1";
};
type InputMediaUrl<K extends string> = { type: K; media: string };
export type InputRichBlock =
| InputRichBlockParagraph
| InputRichBlockHeading
| InputRichBlockPre
| InputRichBlockBlockquote
| InputRichBlockTable
| { type: "divider" }
| { type: "anchor"; name: string }
| { type: "footer"; text: RichText }
| { type: "pullquote"; text: RichText; credit?: RichText }
| { type: "mathematical_expression"; expression: string }
| { type: "details"; summary: RichText; blocks: InputRichBlock[]; is_open?: true }
| { type: "list"; items: InputRichBlockListItem[] }
| { type: "photo"; photo: InputMediaUrl<"photo">; caption?: RichBlockCaption }
| { type: "video"; video: InputMediaUrl<"video">; caption?: RichBlockCaption }
| { type: "audio"; audio: InputMediaUrl<"audio">; caption?: RichBlockCaption }
| { type: "animation"; animation: InputMediaUrl<"animation">; caption?: RichBlockCaption }
| { type: "voice_note"; voice_note: InputMediaUrl<"voice_note">; caption?: RichBlockCaption }
| { type: "collage"; blocks: InputRichBlock[]; caption?: RichBlockCaption }
| { type: "slideshow"; blocks: InputRichBlock[]; caption?: RichBlockCaption }
| {
type: "map";
location: { latitude: number; longitude: number };
zoom: number;
width: number;
height: number;
caption?: RichBlockCaption;
};
export 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;
}
if (value.type === "mathematical_expression" || value.type === "custom_emoji") {
return value;
}
return { ...value, text: normalizeRichText(value.text) };
}
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);
}
if (text.type === "mathematical_expression") {
return text.expression.length;
}
if (text.type === "custom_emoji") {
return text.alternative_text.length;
}
return countRichTextChars(text.text);
}
function countCaptionChars(caption: RichBlockCaption | undefined): number {
if (!caption) {
return 0;
}
return countRichTextChars(caption.text) + countRichTextChars(caption.credit ?? "");
}
export function countInputRichBlockChars(block: InputRichBlock): number {
switch (block.type) {
case "paragraph":
case "heading":
case "footer":
return countRichTextChars(block.text);
case "pre":
return block.text.length;
case "mathematical_expression":
return block.expression.length;
case "pullquote":
return countRichTextChars(block.text) + countRichTextChars(block.credit ?? "");
case "blockquote":
return (
block.blocks.reduce((total, item) => total + countInputRichBlockChars(item), 0) +
countRichTextChars(block.credit ?? "")
);
case "collage":
case "slideshow":
return (
block.blocks.reduce((total, item) => total + countInputRichBlockChars(item), 0) +
countCaptionChars(block.caption)
);
case "details":
return (
countRichTextChars(block.summary) +
block.blocks.reduce((total, item) => total + countInputRichBlockChars(item), 0)
);
case "list":
return block.items.reduce(
(total, item) =>
total + item.blocks.reduce((inner, child) => inner + countInputRichBlockChars(child), 0),
0,
);
case "table":
return (
countRichTextChars(block.caption ?? "") +
block.cells.reduce(
(rowTotal, row) =>
rowTotal +
row.reduce((cellTotal, cell) => cellTotal + countRichTextChars(cell.text ?? ""), 0),
0,
)
);
case "photo":
case "video":
case "audio":
case "animation":
case "voice_note":
case "map":
return countCaptionChars(block.caption);
// divider and anchor carry no text.
default:
return 0;
}
}
/** Media elements per block, for the wire's 50-media message cap. */
export function countInputRichBlockMedia(block: InputRichBlock): number {
switch (block.type) {
// Maps are excluded: 51 maps in one message were accepted live, so they
// do not consume the 50-attachment budget.
case "photo":
case "video":
case "audio":
case "animation":
case "voice_note":
return 1;
case "collage":
case "slideshow":
case "blockquote":
case "details":
return block.blocks.reduce((total, item) => total + countInputRichBlockMedia(item), 0);
case "list":
return block.items.reduce(
(total, item) =>
total + item.blocks.reduce((inner, child) => inner + countInputRichBlockMedia(child), 0),
0,
);
default:
return 0;
}
}
export function richTextToPlainString(text: RichText): string {
if (typeof text === "string") {
return text;
}
if (Array.isArray(text)) {
return text.map(richTextToPlainString).join("");
}
if (text.type === "mathematical_expression") {
return text.expression;
}
if (text.type === "custom_emoji") {
return text.alternative_text;
}
return richTextToPlainString(text.text);
}
function captionToPlainText(caption: RichBlockCaption | undefined): string {
if (!caption) {
return "";
}
const credit = caption.credit ? `${richTextToPlainString(caption.credit)}` : "";
return `${richTextToPlainString(caption.text)}${credit}`.trim();
}
export function inputRichBlocksToPlainText(blocks: readonly InputRichBlock[]): string {
const parts: string[] = [];
const push = (value: string) => {
if (value) {
parts.push(value);
}
};
for (const block of blocks) {
switch (block.type) {
case "paragraph":
case "heading":
case "footer":
push(richTextToPlainString(block.text));
break;
case "pre":
push(block.text);
break;
case "mathematical_expression":
push(block.expression);
break;
case "pullquote":
push(
block.credit
? `${richTextToPlainString(block.text)}${richTextToPlainString(block.credit)}`
: richTextToPlainString(block.text),
);
break;
case "blockquote":
push(inputRichBlocksToPlainText(block.blocks));
if (block.credit) {
push(`${richTextToPlainString(block.credit)}`);
}
break;
case "collage":
case "slideshow":
push(inputRichBlocksToPlainText(block.blocks));
push(captionToPlainText(block.caption));
break;
case "details":
push(richTextToPlainString(block.summary));
push(inputRichBlocksToPlainText(block.blocks));
break;
case "list":
for (const item of block.items) {
const marker = item.has_checkbox
? item.is_checked
? "[x] "
: "[ ] "
: item.value !== undefined
? `${item.value}. `
: "• ";
push(`${marker}${inputRichBlocksToPlainText(item.blocks)}`);
}
break;
case "table":
if (block.caption !== undefined) {
push(richTextToPlainString(block.caption));
}
for (const row of block.cells) {
push(row.map((cell) => richTextToPlainString(cell.text ?? "")).join(" | "));
}
break;
// Fallback text keeps BOTH caption and source so a degraded delivery
// still lets the user reach the media.
case "photo":
push(`${captionToPlainText(block.caption)} ${block.photo.media}`.trim());
break;
case "video":
push(`${captionToPlainText(block.caption)} ${block.video.media}`.trim());
break;
case "audio":
push(`${captionToPlainText(block.caption)} ${block.audio.media}`.trim());
break;
case "animation":
push(`${captionToPlainText(block.caption)} ${block.animation.media}`.trim());
break;
case "voice_note":
push(`${captionToPlainText(block.caption)} ${block.voice_note.media}`.trim());
break;
case "map":
push(
`${captionToPlainText(block.caption)} ${block.location.latitude},${block.location.longitude}`.trim(),
);
break;
case "divider":
case "anchor":
break;
}
}
return parts.join("\n");
}
export function boldRichText(text: string): RichText {
return { type: "bold", text };
}
export function codeRichText(text: string): RichText {
return { type: "code", text };
}
export function italicRichText(text: string): RichText {
return { type: "italic", text };
}
export function paragraphBlock(text: RichText): InputRichBlockParagraph {
return { type: "paragraph", text };
}
+237
View File
@@ -0,0 +1,237 @@
// Chunk-limit enforcement for typed rich blocks: surrogate-safe, wrapper- and
// caption-preserving splitting against the live-verified Bot API limits.
import {
countInputRichBlockChars,
countInputRichBlockMedia,
countRichTextChars,
normalizeRichText,
type InputRichBlock,
type InputRichBlockListItem,
type RichBlockTableCell,
type RichText,
} from "./rich-block-model.js";
import { splitTelegramPlainTextChunks, surrogateSafeChunkEnd } from "./rich-plain-fallback.js";
type RichTextStyleWrap =
| "bold"
| "italic"
| "underline"
| "strikethrough"
| "code"
| "spoiler"
| "marked"
| "subscript"
| "superscript";
type RichTextWrapper =
| { type: RichTextStyleWrap }
| { type: "url"; url: string }
| { type: "anchor_link"; anchor_name: 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 }
: wrapper.type === "anchor_link"
? { type: "anchor_link", text: node, anchor_name: wrapper.anchor_name }
: { 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;
}
if (node.type === "mathematical_expression" || node.type === "custom_emoji") {
// Atomic leaves: never sliced, only placed whole into the current piece.
const atomicChars = countRichTextChars(node);
if (chars > 0 && chars + atomicChars > limit) {
flush();
}
current.push(wrapRichTextFragment(node, wrappers));
chars += atomicChars;
return;
}
const wrapper: RichTextWrapper =
node.type === "url"
? { type: "url", url: node.url }
: node.type === "anchor_link"
? { type: "anchor_link", anchor_name: node.anchor_name }
: { 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") {
// Reserve the credit's chars while splitting the body, then attach the
// credit to the final piece only (attribution belongs at the quote's end).
const creditChars = countRichTextChars(block.credit ?? "");
const innerLimit = Math.max(1, textLimit - creditChars);
const pieces = splitTelegramRichBlocks(block.blocks, { textLimit: innerLimit });
return pieces.map((inner, index) =>
index === pieces.length - 1 && block.credit !== undefined
? { type: "blockquote", blocks: inner, credit: block.credit }
: { type: "blockquote", blocks: inner },
);
}
if (block.type === "table") {
// Row-splitting a table with rowspans would strand spans across messages;
// such tables stay atomic and degrade via the TEXT_TOO_LONG fallback.
if (block.cells.some((row) => row.some((cell) => (cell.rowspan ?? 1) > 1))) {
return [block];
}
const { caption, ...tableRest } = block;
const pieces: InputRichBlock[] = [];
const pushPiece = (pieceRows: RichBlockTableCell[][]) => {
// The caption rides only the first piece.
pieces.push(
pieces.length === 0 && caption !== undefined
? { ...tableRest, cells: pieceRows, caption }
: { ...tableRest, cells: pieceRows },
);
};
let rows: RichBlockTableCell[][] = [];
let chars = countRichTextChars(caption ?? "");
for (const row of block.cells) {
const rowChars = row.reduce((total, cell) => total + countRichTextChars(cell.text ?? ""), 0);
if (rows.length > 0 && chars + rowChars > textLimit) {
pushPiece(rows);
rows = [];
chars = 0;
}
rows.push(row);
chars += rowChars;
}
if (rows.length > 0) {
pushPiece(rows);
}
return pieces;
}
if (block.type === "list") {
const pieces: InputRichBlock[] = [];
let items: InputRichBlockListItem[] = [];
let chars = 0;
for (const item of block.items) {
const itemChars = item.blocks.reduce(
(total, child) => total + countInputRichBlockChars(child),
0,
);
if (items.length > 0 && chars + itemChars > textLimit) {
pieces.push({ type: "list", items });
items = [];
chars = 0;
}
items.push(item);
chars += itemChars;
}
if (items.length > 0) {
pieces.push({ type: "list", items });
}
return pieces;
}
// Details, media, and remaining container blocks stay atomic; a genuinely
// oversized one degrades via the RICH_MESSAGE_TEXT_TOO_LONG plain fallback.
return [block];
}
// Chunking is locality-blind for anchors: an anchor_link whose target lands in
// an earlier chunk renders as an inert link. Accepted trade-off — it needs a
// >32k message with cross-chunk fragment links, and delivery is unaffected.
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;
// Live-verified message cap: >50 media elements → RICH_MESSAGE_MEDIA_TOO_MANY.
const mediaLimit = 50;
let currentMedia = 0;
const flush = () => {
if (current.length > 0) {
chunks.push(current);
current = [];
currentChars = 0;
currentMedia = 0;
}
};
for (const block of expanded) {
const chars = countInputRichBlockChars(block);
const media = countInputRichBlockMedia(block);
const wouldExceedBlocks = current.length >= blockLimit;
const wouldExceedChars = current.length > 0 && currentChars + chars > textLimit;
const wouldExceedMedia = current.length > 0 && currentMedia + media > mediaLimit;
if (wouldExceedBlocks || wouldExceedChars || wouldExceedMedia) {
flush();
}
current.push(block);
currentChars += chars;
currentMedia += media;
}
flush();
return chunks;
}
@@ -0,0 +1,623 @@
// Block-level HTML-island mapping: figures/lists/tables/media/maps/collages
// and island discovery, on top of the fragment parser in rich-blocks-html.ts.
import { tokenizeHtmlTags } from "openclaw/plugin-sdk/text-chunking";
import {
richTextToPlainString,
type InputRichBlock,
type InputRichBlockListItem,
type RichBlockCaption,
type RichBlockTableCell,
type RichText,
} from "./rich-block-model.js";
import {
htmlNodesToRichText,
nodeText,
parseHtmlAttrs,
parseHtmlFragment,
VOID_TAGS,
type HtmlNode,
} from "./rich-blocks-html.js";
// Block-level islands the agent contract documents. A supported open tag with a
// matching close (or a void tag) becomes a typed block; anything else stays text.
const BLOCK_ISLAND_TAGS = new Set([
"details",
"table",
"ul",
"ol",
"figure",
"img",
"video",
"audio",
"blockquote",
"aside",
"footer",
"hr",
"tg-math-block",
"tg-map",
"tg-collage",
"tg-slideshow",
// Only an empty <a name> becomes an anchor block; hrefs fall through to the
// inline path because elementToBlock returns undefined for them.
"a",
]);
const MEDIA_SRC_RE = /^https:\/\//i;
// True when a container holds meaningful content outside its allowed children;
// such islands stay literal instead of silently dropping the stray content.
function hasStrayContent(nodes: readonly HtmlNode[], allowed: ReadonlySet<string>): boolean {
return nodes.some((node) =>
node.kind === "text" ? node.text.trim() !== "" : !allowed.has(node.name),
);
}
function mediaBlockFromElement(
node: Extract<HtmlNode, { kind: "element" }>,
caption?: RichBlockCaption,
): InputRichBlock | undefined {
const attrs = parseHtmlAttrs(node.raw);
const src = attrs.get("src") ?? "";
// Media islands are content-free (src only); any authored body — text or
// nested elements — would be silently lost from rich output and fallback.
const hasBody = node.children.some((child) =>
child.kind === "text" ? child.text.trim() !== "" : true,
);
if (!MEDIA_SRC_RE.test(src) || hasBody) {
return undefined;
}
const withCaption = caption ? { caption } : {};
// GIF sources render as looping animations, matching the old rich HTML
// pipeline where Telegram inferred the media kind from the URL.
const isGif = /\.gif(?:[?#]|$)/i.test(src);
if (node.name === "img" || node.name === "video") {
if (isGif) {
return { type: "animation", animation: { type: "animation", media: src }, ...withCaption };
}
return node.name === "img"
? { type: "photo", photo: { type: "photo", media: src }, ...withCaption }
: { type: "video", video: { type: "video", media: src }, ...withCaption };
}
if (node.name === "audio") {
// OGG/Opus is Telegram's voice-note family; the music `audio` type rejects
// it (live-verified RICH_MESSAGE_AUDIO_INVALID), and a Vorbis ogg fails
// under both types, so voice_note strictly dominates for these extensions.
if (/\.(?:ogg|opus|oga)(?:[?#]|$)/i.test(src)) {
return {
type: "voice_note",
voice_note: { type: "voice_note", media: src },
...withCaption,
};
}
return { type: "audio", audio: { type: "audio", media: src }, ...withCaption };
}
return undefined;
}
function countChildren(nodes: readonly HtmlNode[], name: string): number {
return nodes.filter((node) => node.kind === "element" && node.name === name).length;
}
function captionFromFigcaption(nodes: readonly HtmlNode[]): RichBlockCaption | undefined {
const figcaption = nodes.find(
(node): node is Extract<HtmlNode, { kind: "element" }> =>
node.kind === "element" && node.name === "figcaption",
);
if (!figcaption) {
return undefined;
}
const cite = figcaption.children.find(
(node): node is Extract<HtmlNode, { kind: "element" }> =>
node.kind === "element" && node.name === "cite",
);
const textNodes = figcaption.children.filter((node) => node !== cite);
const text = htmlNodesToRichText(textNodes);
if (text === "" && !cite) {
return undefined;
}
return {
text,
...(cite ? { credit: htmlNodesToRichText(cite.children) } : {}),
};
}
const FIGURE_CHILDREN = new Set(["img", "video", "audio", "tg-map", "figcaption"]);
function figureToBlock(node: Extract<HtmlNode, { kind: "element" }>): InputRichBlock | undefined {
if (hasStrayContent(node.children, FIGURE_CHILDREN)) {
return undefined;
}
// A figure carries exactly one media element and at most one caption;
// multiples would silently drop authored content.
const mediaChildren = node.children.filter(
(child) => child.kind === "element" && child.name !== "figcaption",
);
if (mediaChildren.length > 1 || countChildren(node.children, "figcaption") > 1) {
return undefined;
}
const media = node.children.find(
(child): child is Extract<HtmlNode, { kind: "element" }> =>
child.kind === "element" &&
(child.name === "img" ||
child.name === "video" ||
child.name === "audio" ||
child.name === "tg-map"),
);
if (!media) {
return undefined;
}
const caption = captionFromFigcaption(node.children);
if (media.name === "tg-map") {
const map = mapToBlock(media);
if (map?.type === "map" && caption) {
return { ...map, caption };
}
return map;
}
return mediaBlockFromElement(media, caption);
}
const LIST_CHILDREN = new Set(["li"]);
function listToBlock(node: Extract<HtmlNode, { kind: "element" }>): InputRichBlock | undefined {
if (hasStrayContent(node.children, LIST_CHILDREN)) {
return undefined;
}
const items: InputRichBlockListItem[] = [];
for (const child of node.children) {
if (child.kind !== "element" || child.name !== "li") {
continue;
}
const checkbox = child.children.find(
(grandchild): grandchild is Extract<HtmlNode, { kind: "element" }> =>
grandchild.kind === "element" &&
grandchild.name === "input" &&
parseHtmlAttrs(grandchild.raw).get("type") === "checkbox",
);
const contentNodes = child.children.filter((grandchild) => grandchild !== checkbox);
const blocks = htmlNodesToBlocks(contentNodes);
const item: InputRichBlockListItem = {
blocks: blocks.length > 0 ? blocks : [{ type: "paragraph", text: "" }],
};
if (checkbox) {
item.has_checkbox = true;
if (parseHtmlAttrs(checkbox.raw).has("checked")) {
item.is_checked = true;
}
}
items.push(item);
}
if (items.length === 0) {
return undefined;
}
return {
type: "list",
items: node.name === "ol" ? items.map((item, index) => ({ ...item, value: index + 1 })) : items,
};
}
const CELL_ALIGN_VALUES = new Set(["left", "center", "right"]);
function tableCellFromElement(
node: Extract<HtmlNode, { kind: "element" }>,
inHeader: boolean,
): RichBlockTableCell {
const attrs = parseHtmlAttrs(node.raw);
const text = htmlNodesToRichText(node.children);
const colspan = Number.parseInt(attrs.get("colspan") ?? "", 10);
const rowspan = Number.parseInt(attrs.get("rowspan") ?? "", 10);
const align = attrs.get("align")?.toLowerCase();
return {
...(text !== "" ? { text } : {}),
...(node.name === "th" || inHeader ? { is_header: true as const } : {}),
...(Number.isFinite(colspan) && colspan > 1 ? { colspan } : {}),
...(Number.isFinite(rowspan) && rowspan > 1 ? { rowspan } : {}),
...(align && CELL_ALIGN_VALUES.has(align)
? { align: align as RichBlockTableCell["align"] }
: {}),
};
}
// Live-verified: >20 effective columns → RICH_MESSAGE_TABLE_COLS_TOO_MANY.
const TABLE_COLUMN_LIMIT = 20;
function tableColumnCount(cells: readonly RichBlockTableCell[][]): number {
// Rowspans occupy width in later rows too; ignoring the carryover would
// under-count and emit tables Telegram rejects with TABLE_COLS_TOO_MANY.
let carryover: Array<{ span: number; rows: number }> = [];
let max = 0;
for (const row of cells) {
const carried = carryover.reduce((total, cell) => total + cell.span, 0);
const own = row.reduce((total, cell) => total + (cell.colspan ?? 1), 0);
max = Math.max(max, carried + own);
carryover = [
...carryover
.map((cell) => ({ span: cell.span, rows: cell.rows - 1 }))
.filter((cell) => cell.rows > 0),
...row
.filter((cell) => (cell.rowspan ?? 1) > 1)
.map((cell) => ({ span: cell.colspan ?? 1, rows: (cell.rowspan ?? 1) - 1 })),
];
}
return max;
}
const TABLE_CHILDREN = new Set(["caption", "thead", "tbody", "tfoot", "tr"]);
const TABLE_ROW_CHILDREN = new Set(["td", "th"]);
function tableToBlock(node: Extract<HtmlNode, { kind: "element" }>): InputRichBlock | undefined {
if (hasStrayContent(node.children, TABLE_CHILDREN)) {
return undefined;
}
const cells: RichBlockTableCell[][] = [];
let caption: RichText | undefined;
// Stray non-whitespace content anywhere in the table structure rejects the
// island: silently dropping it would lose agent content from the fallback too.
let stray = false;
const visitRows = (parent: Extract<HtmlNode, { kind: "element" }>, inHeader: boolean) => {
for (const child of parent.children) {
if (child.kind !== "element") {
stray ||= child.text.trim() !== "";
continue;
}
if (child.name === "caption") {
const text = htmlNodesToRichText(child.children);
if (text !== "") {
// A second caption would overwrite authored content; reject instead.
stray ||= caption !== undefined;
caption = text;
}
continue;
}
if (child.name === "thead" || child.name === "tbody" || child.name === "tfoot") {
visitRows(child, child.name === "thead");
continue;
}
if (child.name === "tr") {
if (hasStrayContent(child.children, TABLE_ROW_CHILDREN)) {
stray = true;
continue;
}
const row = child.children
.filter(
(cell): cell is Extract<HtmlNode, { kind: "element" }> =>
cell.kind === "element" && (cell.name === "td" || cell.name === "th"),
)
.map((cell) => tableCellFromElement(cell, inHeader));
if (row.length > 0) {
cells.push(row);
}
continue;
}
stray = true;
}
};
visitRows(node, false);
if (stray || cells.length === 0) {
return undefined;
}
if (tableColumnCount(cells) > TABLE_COLUMN_LIMIT) {
// Mirror the markdown table path: over-wide tables degrade to a readable
// monospace grid instead of an API-rejected table block.
const grid = cells
.map((row) => `| ${row.map((cell) => richTextToPlainString(cell.text ?? "")).join(" | ")} |`)
.join("\n");
return {
type: "pre",
text: caption !== undefined ? `${richTextToPlainString(caption)}\n${grid}` : grid,
};
}
return {
type: "table",
cells,
is_bordered: true,
is_striped: true,
...(caption !== undefined ? { caption } : {}),
};
}
// Full-string numeric parse: prefix-tolerant parseFloat would silently map
// malformed coordinates like "48.8north" to an unintended location.
function strictNumber(value: string | undefined): number | undefined {
if (value === undefined || !/^-?\d+(?:\.\d+)?$/.test(value.trim())) {
return undefined;
}
return Number.parseFloat(value);
}
function mapToBlock(node: Extract<HtmlNode, { kind: "element" }>): InputRichBlock | undefined {
const attrs = parseHtmlAttrs(node.raw);
const latitude = strictNumber(attrs.get("lat"));
const longitude = strictNumber(attrs.get("long"));
const inRange =
latitude !== undefined &&
longitude !== undefined &&
Math.abs(latitude) <= 90 &&
Math.abs(longitude) <= 180;
if (!inRange) {
return undefined;
}
const zoom = strictNumber(attrs.get("zoom")) ?? Number.NaN;
return {
type: "map",
location: { latitude, longitude },
zoom: Number.isFinite(zoom) ? Math.min(24, Math.max(0, Math.round(zoom))) : 14,
// The documented <tg-map> island carries no size; a 16:9 default satisfies
// the API's total<=10000 and ratio<=20 constraints.
width: 800,
height: 450,
};
}
const COLLAGE_CHILDREN = new Set(["figure", "img", "video", "audio", "figcaption"]);
function collageToBlock(node: Extract<HtmlNode, { kind: "element" }>): InputRichBlock | undefined {
if (
hasStrayContent(node.children, COLLAGE_CHILDREN) ||
countChildren(node.children, "figcaption") > 1
) {
return undefined;
}
const blocks: InputRichBlock[] = [];
for (const child of node.children) {
if (child.kind !== "element" || child.name === "figcaption") {
continue;
}
const media = child.name === "figure" ? figureToBlock(child) : mediaBlockFromElement(child);
if (!media) {
// A child that fails conversion (bad scheme, unsupported tag) rejects the
// whole island: partial collages would silently drop agent content.
return undefined;
}
blocks.push(media);
}
if (blocks.length === 0) {
return undefined;
}
const caption = captionFromFigcaption(node.children);
return {
type: node.name === "tg-slideshow" ? "slideshow" : "collage",
blocks,
...(caption ? { caption } : {}),
};
}
function richTextIsBlank(text: RichText): boolean {
if (typeof text === "string") {
return text.trim() === "";
}
if (Array.isArray(text)) {
return text.every(richTextIsBlank);
}
if (text.type === "mathematical_expression") {
return text.expression.trim() === "";
}
if (text.type === "custom_emoji") {
return false;
}
return richTextIsBlank(text.text);
}
/** Map island element nodes plus loose text into typed blocks. */
function htmlNodesToBlocks(nodes: readonly HtmlNode[]): InputRichBlock[] {
const blocks: InputRichBlock[] = [];
let pendingInline: HtmlNode[] = [];
const flushInline = () => {
if (pendingInline.length === 0) {
return;
}
const text = htmlNodesToRichText(pendingInline);
pendingInline = [];
// Indentation between child tags collapses to spaces; a whitespace-only
// run is layout, not content, and must not mint blank paragraphs.
if (!richTextIsBlank(text)) {
blocks.push({ type: "paragraph", text });
}
};
for (const node of nodes) {
const block = node.kind === "element" ? elementToBlock(node) : undefined;
if (block) {
flushInline();
blocks.push(block);
continue;
}
if (node.kind === "element" && node.name === "p") {
flushInline();
const text = htmlNodesToRichText(node.children);
if (text !== "") {
blocks.push({ type: "paragraph", text });
}
continue;
}
pendingInline.push(node);
}
flushInline();
return blocks;
}
function elementToBlock(node: Extract<HtmlNode, { kind: "element" }>): InputRichBlock | undefined {
switch (node.name) {
case "hr":
return { type: "divider" };
case "details": {
const summary = node.children.find(
(child): child is Extract<HtmlNode, { kind: "element" }> =>
child.kind === "element" && child.name === "summary",
);
const bodyNodes = node.children.filter((child) => child !== summary);
const blocks = htmlNodesToBlocks(bodyNodes);
return {
type: "details",
summary: summary ? htmlNodesToRichText(summary.children) : "Details",
blocks: blocks.length > 0 ? blocks : [{ type: "paragraph", text: "" }],
...(parseHtmlAttrs(node.raw).has("open") ? { is_open: true } : {}),
};
}
case "ul":
case "ol":
return listToBlock(node);
case "table":
return tableToBlock(node);
case "figure":
return figureToBlock(node);
case "img":
case "video":
case "audio":
return mediaBlockFromElement(node);
case "blockquote": {
const cite = node.children.find(
(child): child is Extract<HtmlNode, { kind: "element" }> =>
child.kind === "element" && child.name === "cite",
);
const blocks = htmlNodesToBlocks(node.children.filter((child) => child !== cite));
if (blocks.length === 0) {
return undefined;
}
const credit = cite ? htmlNodesToRichText(cite.children) : "";
return credit !== ""
? { type: "blockquote", blocks, credit }
: { type: "blockquote", blocks };
}
case "aside": {
const cite = node.children.find(
(child): child is Extract<HtmlNode, { kind: "element" }> =>
child.kind === "element" && child.name === "cite",
);
const text = htmlNodesToRichText(node.children.filter((child) => child !== cite));
if (text === "") {
return undefined;
}
return {
type: "pullquote",
text,
...(cite ? { credit: htmlNodesToRichText(cite.children) } : {}),
};
}
case "footer": {
const text = htmlNodesToRichText(node.children);
return text === "" ? undefined : { type: "footer", text };
}
case "tg-math-block": {
const expression = nodeText(node.children).trim();
return expression ? { type: "mathematical_expression", expression } : undefined;
}
case "tg-map":
return mapToBlock(node);
case "tg-collage":
case "tg-slideshow":
return collageToBlock(node);
case "a": {
const attrs = parseHtmlAttrs(node.raw);
const name = attrs.get("name");
// Only an empty named <a> is an anchor block; hrefs are inline islands.
if (name && !attrs.get("href") && nodeText(node.children).trim() === "") {
return { type: "anchor", name };
}
return undefined;
}
default:
return undefined;
}
}
type TelegramHtmlIsland = {
start: number;
end: number;
blocks: InputRichBlock[];
};
/**
* Find supported block islands inside a text range. Returns non-overlapping
* spans in order; text outside spans stays on the markdown paragraph path.
*/
export function findTelegramHtmlIslands(text: string): TelegramHtmlIsland[] {
if (!text.includes("<")) {
return [];
}
const islands: TelegramHtmlIsland[] = [];
const tags = [...tokenizeHtmlTags(text)];
// Open non-island containers seen at scan level; a supported tag nested in an
// unsupported wrapper (<custom><hr/></custom>) must stay literal with it.
const openContainers: string[] = [];
let index = 0;
while (index < tags.length) {
const tag = tags[index];
if (!tag) {
index += 1;
continue;
}
const startsIsland =
!tag.closing && BLOCK_ISLAND_TAGS.has(tag.name) && openContainers.length === 0;
if (!startsIsland) {
if (tag.closing) {
const openIndex = openContainers.lastIndexOf(tag.name);
if (openIndex >= 0) {
openContainers.length = openIndex;
}
} else if (!tag.selfClosing && !VOID_TAGS.has(tag.name)) {
openContainers.push(tag.name);
}
index += 1;
continue;
}
let end = tag.end;
const contentStart = tag.end;
let contentEnd = tag.end;
let matched = tag.selfClosing || VOID_TAGS.has(tag.name);
if (!matched) {
let depth = 1;
// Tag names quoted in prose (<code><details></code>) must not count
// toward matching; models routinely mention tags inside code spans.
let codeDepth = 0;
let scan = index + 1;
while (scan < tags.length) {
const candidate = tags[scan];
if (candidate && (candidate.name === "code" || candidate.name === "pre")) {
if (candidate.closing) {
codeDepth = Math.max(0, codeDepth - 1);
} else if (!candidate.selfClosing) {
codeDepth += 1;
}
scan += 1;
continue;
}
if (candidate && candidate.name === tag.name && codeDepth === 0) {
depth += candidate.closing ? -1 : candidate.selfClosing ? 0 : 1;
if (depth === 0) {
end = candidate.end;
contentEnd = candidate.start;
matched = true;
index = scan;
break;
}
}
scan += 1;
}
}
if (!matched) {
// An unclosed supported opener wraps everything after it; treating later
// tags as islands would extract blocks out of a malformed fragment.
openContainers.push(tag.name);
index += 1;
continue;
}
if (tag.name === "a") {
// Only an empty named anchor is a block; href/labelled links stay inline
// so a mid-sentence link never breaks its paragraph apart.
const attrs = parseHtmlAttrs(tag.raw);
const isEmptyNamedAnchor =
attrs.get("name") !== undefined &&
attrs.get("href") === undefined &&
text.slice(contentStart, contentEnd).trim() === "";
if (!isEmptyNamedAnchor) {
index += 1;
continue;
}
}
const blocks = htmlNodesToBlocks(parseHtmlFragment(text.slice(tag.start, end)));
if (blocks.length > 0) {
islands.push({ start: tag.start, end, blocks });
}
index += 1;
}
return islands;
}
@@ -1,12 +1,9 @@
// HTML-island → typed block mapping tests: this is the agent authoring contract
// the core system prompt advertises for rich-enabled Telegram accounts.
import { describe, expect, it } from "vitest";
import {
countInputRichBlockChars,
markdownToTelegramRichBlocks,
splitTelegramRichBlocks,
type InputRichBlock,
} from "./rich-blocks.js";
import { countInputRichBlockChars, type InputRichBlock } from "./rich-block-model.js";
import { splitTelegramRichBlocks } from "./rich-block-split.js";
import { markdownToTelegramRichBlocks } from "./rich-blocks.js";
function blocksFor(markdown: string): InputRichBlock[] {
return markdownToTelegramRichBlocks(markdown).blocks;
+11 -636
View File
@@ -1,46 +1,17 @@
// HTML-island layer for the Telegram rich blocks emitter. Agents author rich
// Telegram content as markdown plus a documented set of HTML islands (see the
// core system prompt's "Telegram rich ON" contract); this module parses those
// islands and maps them to typed Bot API 10.2 blocks / RichText nodes.
// HTML-fragment parsing and inline-island conversion for the Telegram rich
// blocks emitter. Agents author rich content as markdown plus a documented set
// of HTML islands (see the core system prompt's "Telegram rich ON" contract);
// this module owns the tolerant parser and inline (RichText-level) mapping,
// while rich-blocks-html-map.ts owns block-level island mapping.
import { tokenizeHtmlTags } from "openclaw/plugin-sdk/text-chunking";
import { decodeTelegramHtmlEntities } from "./format-html.js";
import type {
InputRichBlock,
InputRichBlockListItem,
RichBlockCaption,
RichBlockTableCell,
RichText,
} from "./rich-blocks.js";
import type { RichText } from "./rich-block-model.js";
type HtmlNode =
export type HtmlNode =
| { kind: "text"; text: string }
| { kind: "element"; name: string; raw: string; children: HtmlNode[]; closed: boolean };
const VOID_TAGS = new Set(["br", "hr", "img", "input", "tg-map"]);
// Block-level islands the agent contract documents. A supported open tag with a
// matching close (or a void tag) becomes a typed block; anything else stays text.
const BLOCK_ISLAND_TAGS = new Set([
"details",
"table",
"ul",
"ol",
"figure",
"img",
"video",
"audio",
"blockquote",
"aside",
"footer",
"hr",
"tg-math-block",
"tg-map",
"tg-collage",
"tg-slideshow",
// Only an empty <a name> becomes an anchor block; hrefs fall through to the
// inline path because elementToBlock returns undefined for them.
"a",
]);
export const VOID_TAGS = new Set(["br", "hr", "img", "input", "tg-map"]);
const INLINE_STYLE_TAGS: Record<
string,
@@ -72,7 +43,7 @@ const INLINE_STYLE_TAGS: Record<
const HTML_ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
function parseHtmlAttrs(raw: string): Map<string, string> {
export function parseHtmlAttrs(raw: string): Map<string, string> {
const attrs = new Map<string, string>();
const inner = raw.replace(/^<\/?[a-zA-Z][a-zA-Z0-9-]*/, "").replace(/\/?>$/, "");
for (const match of inner.matchAll(HTML_ATTR_RE)) {
@@ -85,7 +56,7 @@ function parseHtmlAttrs(raw: string): Map<string, string> {
}
/** Parse an HTML fragment into a light node tree; unmatched tags stay text. */
function parseHtmlFragment(text: string): HtmlNode[] {
export function parseHtmlFragment(text: string): HtmlNode[] {
const root: HtmlNode[] = [];
const stack: Array<{ name: string; node: Extract<HtmlNode, { kind: "element" }> }> = [];
const childrenOf = () => (stack.length > 0 ? stack[stack.length - 1]!.node.children : root);
@@ -146,7 +117,7 @@ function unwrapUnclosed(nodes: HtmlNode[]): HtmlNode[] {
return result;
}
function nodeText(nodes: readonly HtmlNode[]): string {
export function nodeText(nodes: readonly HtmlNode[]): string {
return nodes
.map((node) =>
node.kind === "text" ? decodeTelegramHtmlEntities(node.text) : nodeText(node.children),
@@ -263,599 +234,3 @@ export function parseInlineHtmlIslands(leaf: string): RichText {
}
// Prompt contract: media islands are https-only.
const MEDIA_SRC_RE = /^https:\/\//i;
// True when a container holds meaningful content outside its allowed children;
// such islands stay literal instead of silently dropping the stray content.
function hasStrayContent(nodes: readonly HtmlNode[], allowed: ReadonlySet<string>): boolean {
return nodes.some((node) =>
node.kind === "text" ? node.text.trim() !== "" : !allowed.has(node.name),
);
}
function mediaBlockFromElement(
node: Extract<HtmlNode, { kind: "element" }>,
caption?: RichBlockCaption,
): InputRichBlock | undefined {
const attrs = parseHtmlAttrs(node.raw);
const src = attrs.get("src") ?? "";
// Media islands are content-free (src only); any authored body — text or
// nested elements — would be silently lost from rich output and fallback.
const hasBody = node.children.some((child) =>
child.kind === "text" ? child.text.trim() !== "" : true,
);
if (!MEDIA_SRC_RE.test(src) || hasBody) {
return undefined;
}
const withCaption = caption ? { caption } : {};
// GIF sources render as looping animations, matching the old rich HTML
// pipeline where Telegram inferred the media kind from the URL.
const isGif = /\.gif(?:[?#]|$)/i.test(src);
if (node.name === "img" || node.name === "video") {
if (isGif) {
return { type: "animation", animation: { type: "animation", media: src }, ...withCaption };
}
return node.name === "img"
? { type: "photo", photo: { type: "photo", media: src }, ...withCaption }
: { type: "video", video: { type: "video", media: src }, ...withCaption };
}
if (node.name === "audio") {
// OGG/Opus is Telegram's voice-note family; the music `audio` type rejects
// it (live-verified RICH_MESSAGE_AUDIO_INVALID), and a Vorbis ogg fails
// under both types, so voice_note strictly dominates for these extensions.
if (/\.(?:ogg|opus|oga)(?:[?#]|$)/i.test(src)) {
return {
type: "voice_note",
voice_note: { type: "voice_note", media: src },
...withCaption,
};
}
return { type: "audio", audio: { type: "audio", media: src }, ...withCaption };
}
return undefined;
}
function countChildren(nodes: readonly HtmlNode[], name: string): number {
return nodes.filter((node) => node.kind === "element" && node.name === name).length;
}
function captionFromFigcaption(nodes: readonly HtmlNode[]): RichBlockCaption | undefined {
const figcaption = nodes.find(
(node): node is Extract<HtmlNode, { kind: "element" }> =>
node.kind === "element" && node.name === "figcaption",
);
if (!figcaption) {
return undefined;
}
const cite = figcaption.children.find(
(node): node is Extract<HtmlNode, { kind: "element" }> =>
node.kind === "element" && node.name === "cite",
);
const textNodes = figcaption.children.filter((node) => node !== cite);
const text = htmlNodesToRichText(textNodes);
if (text === "" && !cite) {
return undefined;
}
return {
text,
...(cite ? { credit: htmlNodesToRichText(cite.children) } : {}),
};
}
const FIGURE_CHILDREN = new Set(["img", "video", "audio", "tg-map", "figcaption"]);
function figureToBlock(node: Extract<HtmlNode, { kind: "element" }>): InputRichBlock | undefined {
if (hasStrayContent(node.children, FIGURE_CHILDREN)) {
return undefined;
}
// A figure carries exactly one media element and at most one caption;
// multiples would silently drop authored content.
const mediaChildren = node.children.filter(
(child) => child.kind === "element" && child.name !== "figcaption",
);
if (mediaChildren.length > 1 || countChildren(node.children, "figcaption") > 1) {
return undefined;
}
const media = node.children.find(
(child): child is Extract<HtmlNode, { kind: "element" }> =>
child.kind === "element" &&
(child.name === "img" ||
child.name === "video" ||
child.name === "audio" ||
child.name === "tg-map"),
);
if (!media) {
return undefined;
}
const caption = captionFromFigcaption(node.children);
if (media.name === "tg-map") {
const map = mapToBlock(media);
if (map?.type === "map" && caption) {
return { ...map, caption };
}
return map;
}
return mediaBlockFromElement(media, caption);
}
const LIST_CHILDREN = new Set(["li"]);
function listToBlock(node: Extract<HtmlNode, { kind: "element" }>): InputRichBlock | undefined {
if (hasStrayContent(node.children, LIST_CHILDREN)) {
return undefined;
}
const items: InputRichBlockListItem[] = [];
for (const child of node.children) {
if (child.kind !== "element" || child.name !== "li") {
continue;
}
const checkbox = child.children.find(
(grandchild): grandchild is Extract<HtmlNode, { kind: "element" }> =>
grandchild.kind === "element" &&
grandchild.name === "input" &&
parseHtmlAttrs(grandchild.raw).get("type") === "checkbox",
);
const contentNodes = child.children.filter((grandchild) => grandchild !== checkbox);
const blocks = htmlNodesToBlocks(contentNodes);
const item: InputRichBlockListItem = {
blocks: blocks.length > 0 ? blocks : [{ type: "paragraph", text: "" }],
};
if (checkbox) {
item.has_checkbox = true;
if (parseHtmlAttrs(checkbox.raw).has("checked")) {
item.is_checked = true;
}
}
items.push(item);
}
if (items.length === 0) {
return undefined;
}
return {
type: "list",
items: node.name === "ol" ? items.map((item, index) => ({ ...item, value: index + 1 })) : items,
};
}
const CELL_ALIGN_VALUES = new Set(["left", "center", "right"]);
function tableCellFromElement(
node: Extract<HtmlNode, { kind: "element" }>,
inHeader: boolean,
): RichBlockTableCell {
const attrs = parseHtmlAttrs(node.raw);
const text = htmlNodesToRichText(node.children);
const colspan = Number.parseInt(attrs.get("colspan") ?? "", 10);
const rowspan = Number.parseInt(attrs.get("rowspan") ?? "", 10);
const align = attrs.get("align")?.toLowerCase();
return {
...(text !== "" ? { text } : {}),
...(node.name === "th" || inHeader ? { is_header: true as const } : {}),
...(Number.isFinite(colspan) && colspan > 1 ? { colspan } : {}),
...(Number.isFinite(rowspan) && rowspan > 1 ? { rowspan } : {}),
...(align && CELL_ALIGN_VALUES.has(align)
? { align: align as RichBlockTableCell["align"] }
: {}),
};
}
function richTextPlain(text: RichText): string {
if (typeof text === "string") {
return text;
}
if (Array.isArray(text)) {
return text.map(richTextPlain).join("");
}
if (text.type === "mathematical_expression") {
return text.expression;
}
if (text.type === "custom_emoji") {
return text.alternative_text;
}
return richTextPlain(text.text);
}
// Live-verified: >20 effective columns → RICH_MESSAGE_TABLE_COLS_TOO_MANY.
const TABLE_COLUMN_LIMIT = 20;
function tableColumnCount(cells: readonly RichBlockTableCell[][]): number {
// Rowspans occupy width in later rows too; ignoring the carryover would
// under-count and emit tables Telegram rejects with TABLE_COLS_TOO_MANY.
let carryover: Array<{ span: number; rows: number }> = [];
let max = 0;
for (const row of cells) {
const carried = carryover.reduce((total, cell) => total + cell.span, 0);
const own = row.reduce((total, cell) => total + (cell.colspan ?? 1), 0);
max = Math.max(max, carried + own);
carryover = [
...carryover
.map((cell) => ({ span: cell.span, rows: cell.rows - 1 }))
.filter((cell) => cell.rows > 0),
...row
.filter((cell) => (cell.rowspan ?? 1) > 1)
.map((cell) => ({ span: cell.colspan ?? 1, rows: (cell.rowspan ?? 1) - 1 })),
];
}
return max;
}
const TABLE_CHILDREN = new Set(["caption", "thead", "tbody", "tfoot", "tr"]);
const TABLE_ROW_CHILDREN = new Set(["td", "th"]);
function tableToBlock(node: Extract<HtmlNode, { kind: "element" }>): InputRichBlock | undefined {
if (hasStrayContent(node.children, TABLE_CHILDREN)) {
return undefined;
}
const cells: RichBlockTableCell[][] = [];
let caption: RichText | undefined;
// Stray non-whitespace content anywhere in the table structure rejects the
// island: silently dropping it would lose agent content from the fallback too.
let stray = false;
const visitRows = (parent: Extract<HtmlNode, { kind: "element" }>, inHeader: boolean) => {
for (const child of parent.children) {
if (child.kind !== "element") {
stray ||= child.text.trim() !== "";
continue;
}
if (child.name === "caption") {
const text = htmlNodesToRichText(child.children);
if (text !== "") {
// A second caption would overwrite authored content; reject instead.
stray ||= caption !== undefined;
caption = text;
}
continue;
}
if (child.name === "thead" || child.name === "tbody" || child.name === "tfoot") {
visitRows(child, child.name === "thead");
continue;
}
if (child.name === "tr") {
if (hasStrayContent(child.children, TABLE_ROW_CHILDREN)) {
stray = true;
continue;
}
const row = child.children
.filter(
(cell): cell is Extract<HtmlNode, { kind: "element" }> =>
cell.kind === "element" && (cell.name === "td" || cell.name === "th"),
)
.map((cell) => tableCellFromElement(cell, inHeader));
if (row.length > 0) {
cells.push(row);
}
continue;
}
stray = true;
}
};
visitRows(node, false);
if (stray || cells.length === 0) {
return undefined;
}
if (tableColumnCount(cells) > TABLE_COLUMN_LIMIT) {
// Mirror the markdown table path: over-wide tables degrade to a readable
// monospace grid instead of an API-rejected table block.
const grid = cells
.map((row) => `| ${row.map((cell) => richTextPlain(cell.text ?? "")).join(" | ")} |`)
.join("\n");
return {
type: "pre",
text: caption !== undefined ? `${richTextPlain(caption)}\n${grid}` : grid,
};
}
return {
type: "table",
cells,
is_bordered: true,
is_striped: true,
...(caption !== undefined ? { caption } : {}),
};
}
// Full-string numeric parse: prefix-tolerant parseFloat would silently map
// malformed coordinates like "48.8north" to an unintended location.
function strictNumber(value: string | undefined): number | undefined {
if (value === undefined || !/^-?\d+(?:\.\d+)?$/.test(value.trim())) {
return undefined;
}
return Number.parseFloat(value);
}
function mapToBlock(node: Extract<HtmlNode, { kind: "element" }>): InputRichBlock | undefined {
const attrs = parseHtmlAttrs(node.raw);
const latitude = strictNumber(attrs.get("lat"));
const longitude = strictNumber(attrs.get("long"));
const inRange =
latitude !== undefined &&
longitude !== undefined &&
Math.abs(latitude) <= 90 &&
Math.abs(longitude) <= 180;
if (!inRange) {
return undefined;
}
const zoom = strictNumber(attrs.get("zoom")) ?? Number.NaN;
return {
type: "map",
location: { latitude, longitude },
zoom: Number.isFinite(zoom) ? Math.min(24, Math.max(0, Math.round(zoom))) : 14,
// The documented <tg-map> island carries no size; a 16:9 default satisfies
// the API's total<=10000 and ratio<=20 constraints.
width: 800,
height: 450,
};
}
const COLLAGE_CHILDREN = new Set(["figure", "img", "video", "audio", "figcaption"]);
function collageToBlock(node: Extract<HtmlNode, { kind: "element" }>): InputRichBlock | undefined {
if (
hasStrayContent(node.children, COLLAGE_CHILDREN) ||
countChildren(node.children, "figcaption") > 1
) {
return undefined;
}
const blocks: InputRichBlock[] = [];
for (const child of node.children) {
if (child.kind !== "element" || child.name === "figcaption") {
continue;
}
const media = child.name === "figure" ? figureToBlock(child) : mediaBlockFromElement(child);
if (!media) {
// A child that fails conversion (bad scheme, unsupported tag) rejects the
// whole island: partial collages would silently drop agent content.
return undefined;
}
blocks.push(media);
}
if (blocks.length === 0) {
return undefined;
}
const caption = captionFromFigcaption(node.children);
return {
type: node.name === "tg-slideshow" ? "slideshow" : "collage",
blocks,
...(caption ? { caption } : {}),
};
}
function richTextIsBlank(text: RichText): boolean {
if (typeof text === "string") {
return text.trim() === "";
}
if (Array.isArray(text)) {
return text.every(richTextIsBlank);
}
if (text.type === "mathematical_expression") {
return text.expression.trim() === "";
}
if (text.type === "custom_emoji") {
return false;
}
return richTextIsBlank(text.text);
}
/** Map island element nodes plus loose text into typed blocks. */
export function htmlNodesToBlocks(nodes: readonly HtmlNode[]): InputRichBlock[] {
const blocks: InputRichBlock[] = [];
let pendingInline: HtmlNode[] = [];
const flushInline = () => {
if (pendingInline.length === 0) {
return;
}
const text = htmlNodesToRichText(pendingInline);
pendingInline = [];
// Indentation between child tags collapses to spaces; a whitespace-only
// run is layout, not content, and must not mint blank paragraphs.
if (!richTextIsBlank(text)) {
blocks.push({ type: "paragraph", text });
}
};
for (const node of nodes) {
const block = node.kind === "element" ? elementToBlock(node) : undefined;
if (block) {
flushInline();
blocks.push(block);
continue;
}
if (node.kind === "element" && node.name === "p") {
flushInline();
const text = htmlNodesToRichText(node.children);
if (text !== "") {
blocks.push({ type: "paragraph", text });
}
continue;
}
pendingInline.push(node);
}
flushInline();
return blocks;
}
function elementToBlock(node: Extract<HtmlNode, { kind: "element" }>): InputRichBlock | undefined {
switch (node.name) {
case "hr":
return { type: "divider" };
case "details": {
const summary = node.children.find(
(child): child is Extract<HtmlNode, { kind: "element" }> =>
child.kind === "element" && child.name === "summary",
);
const bodyNodes = node.children.filter((child) => child !== summary);
const blocks = htmlNodesToBlocks(bodyNodes);
return {
type: "details",
summary: summary ? htmlNodesToRichText(summary.children) : "Details",
blocks: blocks.length > 0 ? blocks : [{ type: "paragraph", text: "" }],
...(parseHtmlAttrs(node.raw).has("open") ? { is_open: true } : {}),
};
}
case "ul":
case "ol":
return listToBlock(node);
case "table":
return tableToBlock(node);
case "figure":
return figureToBlock(node);
case "img":
case "video":
case "audio":
return mediaBlockFromElement(node);
case "blockquote": {
const cite = node.children.find(
(child): child is Extract<HtmlNode, { kind: "element" }> =>
child.kind === "element" && child.name === "cite",
);
const blocks = htmlNodesToBlocks(node.children.filter((child) => child !== cite));
if (blocks.length === 0) {
return undefined;
}
const credit = cite ? htmlNodesToRichText(cite.children) : "";
return credit !== ""
? { type: "blockquote", blocks, credit }
: { type: "blockquote", blocks };
}
case "aside": {
const cite = node.children.find(
(child): child is Extract<HtmlNode, { kind: "element" }> =>
child.kind === "element" && child.name === "cite",
);
const text = htmlNodesToRichText(node.children.filter((child) => child !== cite));
if (text === "") {
return undefined;
}
return {
type: "pullquote",
text,
...(cite ? { credit: htmlNodesToRichText(cite.children) } : {}),
};
}
case "footer": {
const text = htmlNodesToRichText(node.children);
return text === "" ? undefined : { type: "footer", text };
}
case "tg-math-block": {
const expression = nodeText(node.children).trim();
return expression ? { type: "mathematical_expression", expression } : undefined;
}
case "tg-map":
return mapToBlock(node);
case "tg-collage":
case "tg-slideshow":
return collageToBlock(node);
case "a": {
const attrs = parseHtmlAttrs(node.raw);
const name = attrs.get("name");
// Only an empty named <a> is an anchor block; hrefs are inline islands.
if (name && !attrs.get("href") && nodeText(node.children).trim() === "") {
return { type: "anchor", name };
}
return undefined;
}
default:
return undefined;
}
}
export type TelegramHtmlIsland = {
start: number;
end: number;
blocks: InputRichBlock[];
};
/**
* Find supported block islands inside a text range. Returns non-overlapping
* spans in order; text outside spans stays on the markdown paragraph path.
*/
export function findTelegramHtmlIslands(text: string): TelegramHtmlIsland[] {
if (!text.includes("<")) {
return [];
}
const islands: TelegramHtmlIsland[] = [];
const tags = [...tokenizeHtmlTags(text)];
// Open non-island containers seen at scan level; a supported tag nested in an
// unsupported wrapper (<custom><hr/></custom>) must stay literal with it.
const openContainers: string[] = [];
let index = 0;
while (index < tags.length) {
const tag = tags[index];
if (!tag) {
index += 1;
continue;
}
const startsIsland =
!tag.closing && BLOCK_ISLAND_TAGS.has(tag.name) && openContainers.length === 0;
if (!startsIsland) {
if (tag.closing) {
const openIndex = openContainers.lastIndexOf(tag.name);
if (openIndex >= 0) {
openContainers.length = openIndex;
}
} else if (!tag.selfClosing && !VOID_TAGS.has(tag.name)) {
openContainers.push(tag.name);
}
index += 1;
continue;
}
let end = tag.end;
const contentStart = tag.end;
let contentEnd = tag.end;
let matched = tag.selfClosing || VOID_TAGS.has(tag.name);
if (!matched) {
let depth = 1;
// Tag names quoted in prose (<code><details></code>) must not count
// toward matching; models routinely mention tags inside code spans.
let codeDepth = 0;
let scan = index + 1;
while (scan < tags.length) {
const candidate = tags[scan];
if (candidate && (candidate.name === "code" || candidate.name === "pre")) {
if (candidate.closing) {
codeDepth = Math.max(0, codeDepth - 1);
} else if (!candidate.selfClosing) {
codeDepth += 1;
}
scan += 1;
continue;
}
if (candidate && candidate.name === tag.name && codeDepth === 0) {
depth += candidate.closing ? -1 : candidate.selfClosing ? 0 : 1;
if (depth === 0) {
end = candidate.end;
contentEnd = candidate.start;
matched = true;
index = scan;
break;
}
}
scan += 1;
}
}
if (!matched) {
// An unclosed supported opener wraps everything after it; treating later
// tags as islands would extract blocks out of a malformed fragment.
openContainers.push(tag.name);
index += 1;
continue;
}
if (tag.name === "a") {
// Only an empty named anchor is a block; href/labelled links stay inline
// so a mid-sentence link never breaks its paragraph apart.
const attrs = parseHtmlAttrs(tag.raw);
const isEmptyNamedAnchor =
attrs.get("name") !== undefined &&
attrs.get("href") === undefined &&
text.slice(contentStart, contentEnd).trim() === "";
if (!isEmptyNamedAnchor) {
index += 1;
continue;
}
}
const blocks = htmlNodesToBlocks(parseHtmlFragment(text.slice(tag.start, end)));
if (blocks.length > 0) {
islands.push({ start: tag.start, end, blocks });
}
index += 1;
}
return islands;
}
+3 -3
View File
@@ -3,11 +3,11 @@ import { describe, expect, it } from "vitest";
import {
countInputRichBlockChars,
inputRichBlocksToPlainText,
markdownToTelegramRichBlocks,
splitTelegramRichBlocks,
type InputRichBlock,
type RichText,
} from "./rich-blocks.js";
} from "./rich-block-model.js";
import { splitTelegramRichBlocks } from "./rich-block-split.js";
import { markdownToTelegramRichBlocks } from "./rich-blocks.js";
import { buildTelegramRichMarkdown, splitTelegramRichMessageTextChunks } from "./rich-message.js";
function tableMarkdown(columns: number): string {
+16 -632
View File
@@ -10,139 +10,17 @@ import {
type MarkdownTableCell,
type MarkdownTableMeta,
} from "openclaw/plugin-sdk/text-chunking";
// Runtime-safe: rich-blocks-html and rich-plain-fallback import only types back.
import { findTelegramHtmlIslands, parseInlineHtmlIslands } from "./rich-blocks-html.js";
import { splitTelegramPlainTextChunks, surrogateSafeChunkEnd } from "./rich-plain-fallback.js";
export type TelegramRichBlocksDegradationReason = "table-ascii";
export type RichText =
| string
| RichText[]
| {
type:
| "bold"
| "italic"
| "underline"
| "strikethrough"
| "code"
| "spoiler"
| "marked"
| "subscript"
| "superscript";
text: RichText;
}
| {
type: "url";
text: RichText;
url: string;
}
| {
type: "anchor_link";
text: RichText;
anchor_name: string;
}
| {
type: "mathematical_expression";
expression: string;
}
| {
type: "custom_emoji";
custom_emoji_id: string;
alternative_text: 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[];
credit?: RichText;
};
export type InputRichBlockTable = {
type: "table";
cells: RichBlockTableCell[][];
is_bordered?: true;
is_striped?: true;
caption?: RichText;
};
export type RichBlockCaption = {
text: RichText;
credit?: RichText;
};
export type InputRichBlockListItem = {
blocks: InputRichBlock[];
has_checkbox?: true;
is_checked?: true;
value?: number;
type?: "a" | "A" | "i" | "I" | "1";
};
type InputMediaUrl<K extends string> = { type: K; media: string };
export type InputRichBlock =
| InputRichBlockParagraph
| InputRichBlockHeading
| InputRichBlockPre
| InputRichBlockBlockquote
| InputRichBlockTable
| { type: "divider" }
| { type: "anchor"; name: string }
| { type: "footer"; text: RichText }
| { type: "pullquote"; text: RichText; credit?: RichText }
| { type: "mathematical_expression"; expression: string }
| { type: "details"; summary: RichText; blocks: InputRichBlock[]; is_open?: true }
| { type: "list"; items: InputRichBlockListItem[] }
| { type: "photo"; photo: InputMediaUrl<"photo">; caption?: RichBlockCaption }
| { type: "video"; video: InputMediaUrl<"video">; caption?: RichBlockCaption }
| { type: "audio"; audio: InputMediaUrl<"audio">; caption?: RichBlockCaption }
| { type: "animation"; animation: InputMediaUrl<"animation">; caption?: RichBlockCaption }
| { type: "voice_note"; voice_note: InputMediaUrl<"voice_note">; caption?: RichBlockCaption }
| { type: "collage"; blocks: InputRichBlock[]; caption?: RichBlockCaption }
| { type: "slideshow"; blocks: InputRichBlock[]; caption?: RichBlockCaption }
| {
type: "map";
location: { latitude: number; longitude: number };
zoom: number;
width: number;
height: number;
caption?: RichBlockCaption;
};
export type TelegramRichBlocksResult = {
blocks: InputRichBlock[];
plainText: string;
degradationReasons: readonly TelegramRichBlocksDegradationReason[];
};
import {
inputRichBlocksToPlainText,
normalizeRichText,
type InputRichBlock,
type InputRichBlockParagraph,
type RichBlockTableCell,
type RichText,
type TelegramRichBlocksDegradationReason,
} from "./rich-block-model.js";
import { findTelegramHtmlIslands } from "./rich-blocks-html-map.js";
import { parseInlineHtmlIslands } from "./rich-blocks-html.js";
const TELEGRAM_RICH_TEXT_TABLE_COLUMN_LIMIT = 20;
@@ -197,41 +75,6 @@ function isInlineStyle(style: MarkdownStyle): style is InlineStyleKind {
);
}
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;
}
if (value.type === "mathematical_expression" || value.type === "custom_emoji") {
return value;
}
return { ...value, text: normalizeRichText(value.text) };
}
function wrapStyle(kind: InlineStyleKind, text: RichText): RichText {
return { type: kind, text };
}
type TelegramLinkAction =
| { kind: "url"; href: string }
| { kind: "anchor"; name: string }
@@ -705,117 +548,14 @@ function emitSegments(
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);
}
if (text.type === "mathematical_expression") {
return text.expression.length;
}
if (text.type === "custom_emoji") {
return text.alternative_text.length;
}
return countRichTextChars(text.text);
}
function countCaptionChars(caption: RichBlockCaption | undefined): number {
if (!caption) {
return 0;
}
return countRichTextChars(caption.text) + countRichTextChars(caption.credit ?? "");
}
export function countInputRichBlockChars(block: InputRichBlock): number {
switch (block.type) {
case "paragraph":
case "heading":
case "footer":
return countRichTextChars(block.text);
case "pre":
return block.text.length;
case "mathematical_expression":
return block.expression.length;
case "pullquote":
return countRichTextChars(block.text) + countRichTextChars(block.credit ?? "");
case "blockquote":
return (
block.blocks.reduce((total, item) => total + countInputRichBlockChars(item), 0) +
countRichTextChars(block.credit ?? "")
);
case "collage":
case "slideshow":
return (
block.blocks.reduce((total, item) => total + countInputRichBlockChars(item), 0) +
countCaptionChars(block.caption)
);
case "details":
return (
countRichTextChars(block.summary) +
block.blocks.reduce((total, item) => total + countInputRichBlockChars(item), 0)
);
case "list":
return block.items.reduce(
(total, item) =>
total + item.blocks.reduce((inner, child) => inner + countInputRichBlockChars(child), 0),
0,
);
case "table":
return (
countRichTextChars(block.caption ?? "") +
block.cells.reduce(
(rowTotal, row) =>
rowTotal +
row.reduce((cellTotal, cell) => cellTotal + countRichTextChars(cell.text ?? ""), 0),
0,
)
);
case "photo":
case "video":
case "audio":
case "animation":
case "voice_note":
case "map":
return countCaptionChars(block.caption);
// divider and anchor carry no text.
default:
return 0;
}
}
/** Media elements per block, for the wire's 50-media message cap. */
export function countInputRichBlockMedia(block: InputRichBlock): number {
switch (block.type) {
// Maps are excluded: 51 maps in one message were accepted live, so they
// do not consume the 50-attachment budget.
case "photo":
case "video":
case "audio":
case "animation":
case "voice_note":
return 1;
case "collage":
case "slideshow":
case "blockquote":
case "details":
return block.blocks.reduce((total, item) => total + countInputRichBlockMedia(item), 0);
case "list":
return block.items.reduce(
(total, item) =>
total + item.blocks.reduce((inner, child) => inner + countInputRichBlockMedia(child), 0),
0,
);
default:
return 0;
}
}
export function markdownToTelegramRichBlocks(
markdown: string,
options: { tableMode?: MarkdownTableMode; skipEntityDetection?: boolean } = {},
): TelegramRichBlocksResult {
): {
blocks: InputRichBlock[];
plainText: string;
degradationReasons: readonly TelegramRichBlocksDegradationReason[];
} {
const tableMode = options.tableMode ?? "block";
// Markdown-native lists stay IR-flattened and `---` keeps the IR's ─── text
// (the old rich path did the same); native list/media/details/math blocks
@@ -846,359 +586,3 @@ export function markdownToTelegramRichBlocks(
degradationReasons: [...degradationReasons],
};
}
type RichTextStyleWrap =
| "bold"
| "italic"
| "underline"
| "strikethrough"
| "code"
| "spoiler"
| "marked"
| "subscript"
| "superscript";
type RichTextWrapper =
| { type: RichTextStyleWrap }
| { type: "url"; url: string }
| { type: "anchor_link"; anchor_name: 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 }
: wrapper.type === "anchor_link"
? { type: "anchor_link", text: node, anchor_name: wrapper.anchor_name }
: { 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;
}
if (node.type === "mathematical_expression" || node.type === "custom_emoji") {
// Atomic leaves: never sliced, only placed whole into the current piece.
const atomicChars = countRichTextChars(node);
if (chars > 0 && chars + atomicChars > limit) {
flush();
}
current.push(wrapRichTextFragment(node, wrappers));
chars += atomicChars;
return;
}
const wrapper: RichTextWrapper =
node.type === "url"
? { type: "url", url: node.url }
: node.type === "anchor_link"
? { type: "anchor_link", anchor_name: node.anchor_name }
: { 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") {
// Reserve the credit's chars while splitting the body, then attach the
// credit to the final piece only (attribution belongs at the quote's end).
const creditChars = countRichTextChars(block.credit ?? "");
const innerLimit = Math.max(1, textLimit - creditChars);
const pieces = splitTelegramRichBlocks(block.blocks, { textLimit: innerLimit });
return pieces.map((inner, index) =>
index === pieces.length - 1 && block.credit !== undefined
? { type: "blockquote", blocks: inner, credit: block.credit }
: { type: "blockquote", blocks: inner },
);
}
if (block.type === "table") {
// Row-splitting a table with rowspans would strand spans across messages;
// such tables stay atomic and degrade via the TEXT_TOO_LONG fallback.
if (block.cells.some((row) => row.some((cell) => (cell.rowspan ?? 1) > 1))) {
return [block];
}
const { caption, ...tableRest } = block;
const pieces: InputRichBlock[] = [];
const pushPiece = (pieceRows: RichBlockTableCell[][]) => {
// The caption rides only the first piece.
pieces.push(
pieces.length === 0 && caption !== undefined
? { ...tableRest, cells: pieceRows, caption }
: { ...tableRest, cells: pieceRows },
);
};
let rows: RichBlockTableCell[][] = [];
let chars = countRichTextChars(caption ?? "");
for (const row of block.cells) {
const rowChars = row.reduce((total, cell) => total + countRichTextChars(cell.text ?? ""), 0);
if (rows.length > 0 && chars + rowChars > textLimit) {
pushPiece(rows);
rows = [];
chars = 0;
}
rows.push(row);
chars += rowChars;
}
if (rows.length > 0) {
pushPiece(rows);
}
return pieces;
}
if (block.type === "list") {
const pieces: InputRichBlock[] = [];
let items: InputRichBlockListItem[] = [];
let chars = 0;
for (const item of block.items) {
const itemChars = item.blocks.reduce(
(total, child) => total + countInputRichBlockChars(child),
0,
);
if (items.length > 0 && chars + itemChars > textLimit) {
pieces.push({ type: "list", items });
items = [];
chars = 0;
}
items.push(item);
chars += itemChars;
}
if (items.length > 0) {
pieces.push({ type: "list", items });
}
return pieces;
}
// Details, media, and remaining container blocks stay atomic; a genuinely
// oversized one degrades via the RICH_MESSAGE_TEXT_TOO_LONG plain fallback.
return [block];
}
// Chunking is locality-blind for anchors: an anchor_link whose target lands in
// an earlier chunk renders as an inert link. Accepted trade-off — it needs a
// >32k message with cross-chunk fragment links, and delivery is unaffected.
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;
// Live-verified message cap: >50 media elements → RICH_MESSAGE_MEDIA_TOO_MANY.
const mediaLimit = 50;
let currentMedia = 0;
const flush = () => {
if (current.length > 0) {
chunks.push(current);
current = [];
currentChars = 0;
currentMedia = 0;
}
};
for (const block of expanded) {
const chars = countInputRichBlockChars(block);
const media = countInputRichBlockMedia(block);
const wouldExceedBlocks = current.length >= blockLimit;
const wouldExceedChars = current.length > 0 && currentChars + chars > textLimit;
const wouldExceedMedia = current.length > 0 && currentMedia + media > mediaLimit;
if (wouldExceedBlocks || wouldExceedChars || wouldExceedMedia) {
flush();
}
current.push(block);
currentChars += chars;
currentMedia += media;
}
flush();
return chunks;
}
export function richTextToPlainString(text: RichText): string {
if (typeof text === "string") {
return text;
}
if (Array.isArray(text)) {
return text.map(richTextToPlainString).join("");
}
if (text.type === "mathematical_expression") {
return text.expression;
}
if (text.type === "custom_emoji") {
return text.alternative_text;
}
return richTextToPlainString(text.text);
}
function captionToPlainText(caption: RichBlockCaption | undefined): string {
if (!caption) {
return "";
}
const credit = caption.credit ? `${richTextToPlainString(caption.credit)}` : "";
return `${richTextToPlainString(caption.text)}${credit}`.trim();
}
export function inputRichBlocksToPlainText(blocks: readonly InputRichBlock[]): string {
const parts: string[] = [];
const push = (value: string) => {
if (value) {
parts.push(value);
}
};
for (const block of blocks) {
switch (block.type) {
case "paragraph":
case "heading":
case "footer":
push(richTextToPlainString(block.text));
break;
case "pre":
push(block.text);
break;
case "mathematical_expression":
push(block.expression);
break;
case "pullquote":
push(
block.credit
? `${richTextToPlainString(block.text)}${richTextToPlainString(block.credit)}`
: richTextToPlainString(block.text),
);
break;
case "blockquote":
push(inputRichBlocksToPlainText(block.blocks));
if (block.credit) {
push(`${richTextToPlainString(block.credit)}`);
}
break;
case "collage":
case "slideshow":
push(inputRichBlocksToPlainText(block.blocks));
push(captionToPlainText(block.caption));
break;
case "details":
push(richTextToPlainString(block.summary));
push(inputRichBlocksToPlainText(block.blocks));
break;
case "list":
for (const item of block.items) {
const marker = item.has_checkbox
? item.is_checked
? "[x] "
: "[ ] "
: item.value !== undefined
? `${item.value}. `
: "• ";
push(`${marker}${inputRichBlocksToPlainText(item.blocks)}`);
}
break;
case "table":
if (block.caption !== undefined) {
push(richTextToPlainString(block.caption));
}
for (const row of block.cells) {
push(row.map((cell) => richTextToPlainString(cell.text ?? "")).join(" | "));
}
break;
// Fallback text keeps BOTH caption and source so a degraded delivery
// still lets the user reach the media.
case "photo":
push(`${captionToPlainText(block.caption)} ${block.photo.media}`.trim());
break;
case "video":
push(`${captionToPlainText(block.caption)} ${block.video.media}`.trim());
break;
case "audio":
push(`${captionToPlainText(block.caption)} ${block.audio.media}`.trim());
break;
case "animation":
push(`${captionToPlainText(block.caption)} ${block.animation.media}`.trim());
break;
case "voice_note":
push(`${captionToPlainText(block.caption)} ${block.voice_note.media}`.trim());
break;
case "map":
push(
`${captionToPlainText(block.caption)} ${block.location.latitude},${block.location.longitude}`.trim(),
);
break;
case "divider":
case "anchor":
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 };
}
+3 -3
View File
@@ -11,11 +11,11 @@ import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
// Telegram rich message helpers isolate Bot API 10.2 calls until grammY types catch up.
import {
inputRichBlocksToPlainText,
markdownToTelegramRichBlocks,
splitTelegramRichBlocks,
type InputRichBlock,
type TelegramRichBlocksDegradationReason,
} from "./rich-blocks.js";
} from "./rich-block-model.js";
import { splitTelegramRichBlocks } from "./rich-block-split.js";
import { markdownToTelegramRichBlocks } from "./rich-blocks.js";
type TelegramRichMessageReplyMarkup =
| InlineKeyboardMarkup
@@ -1,7 +1,7 @@
// 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 type { TelegramRichBlocksDegradationReason } from "./rich-blocks.js";
import type { TelegramRichBlocksDegradationReason } from "./rich-block-model.js";
// Any RICH_MESSAGE_*_INVALID rejection (entities, media, depth) degrades to
// plain text; media content validity (e.g. AUDIO_INVALID for a non-decodable
+1 -1
View File
@@ -9,7 +9,7 @@ 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";
import { inputRichBlocksToPlainText, type InputRichBlock } from "./rich-block-model.js";
function richMessagePlainTextForTest(richMessage: {
blocks?: InputRichBlock[];
+1 -1
View File
@@ -16,7 +16,7 @@ import {
resolveTelegramMessageCacheScope,
} from "./message-cache.js";
import { createTelegramPromptContextProjectionCursor } from "./prompt-context-projection.js";
import { inputRichBlocksToPlainText, type InputRichBlock } from "./rich-blocks.js";
import { inputRichBlocksToPlainText, type InputRichBlock } from "./rich-block-model.js";
import { setTelegramRuntime } from "./runtime.js";
import {
clearTelegramRuntimeForTest as clearTelegramRuntime,