diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 59cced38f465..ffffb428d2a4 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -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, diff --git a/extensions/telegram/src/bot/delivery.send.ts b/extensions/telegram/src/bot/delivery.send.ts index 26f2da573298..8786f80138df 100644 --- a/extensions/telegram/src/bot/delivery.send.ts +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -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, diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index 91e3cd95ab3c..1da352d2b3dc 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -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, diff --git a/extensions/telegram/src/message-cache.test.ts b/extensions/telegram/src/message-cache.test.ts index 715de3a0a863..9ca0f3187678 100644 --- a/extensions/telegram/src/message-cache.test.ts +++ b/extensions/telegram/src/message-cache.test.ts @@ -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[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({ diff --git a/extensions/telegram/src/outbound-adapter.sanitize.test.ts b/extensions/telegram/src/outbound-adapter.sanitize.test.ts new file mode 100644 index 000000000000..fb7b26eb77ce --- /dev/null +++ b/extensions/telegram/src/outbound-adapter.sanitize.test.ts @@ -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
Morebody
x^2 '; + + 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("
More"); + expect(sanitized).toContain("x^2"); + expect(sanitized).toContain(''); + }); + + 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("
"); + 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("
More"); + }); + + it("stays on the plain path when config is unavailable", () => { + const sanitized = telegramOutbound.sanitizeText?.({ + text: islandText, + payload: { text: islandText }, + }); + expect(sanitized).not.toContain("
"); + }); +}); diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index f3d7279f56c1..ab0616f5592d 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -1074,56 +1074,3 @@ describe("telegramOutbound", () => { expect(options.notify).toBe(false); }); }); - -describe("telegramOutbound.sanitizeText", () => { - const islandText = - 'before
Morebody
x^2
  • done
'; - - 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("
More"); - expect(sanitized).toContain("x^2"); - expect(sanitized).toContain(''); - }); - - 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("
"); - 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("
More"); - }); - - it("stays on the plain path when config is unavailable", () => { - const sanitized = telegramOutbound.sanitizeText?.({ - text: islandText, - payload: { text: islandText }, - }); - expect(sanitized).not.toContain("
"); - }); -}); diff --git a/extensions/telegram/src/progress-draft-preview.ts b/extensions/telegram/src/progress-draft-preview.ts index 65abed665011..284e6a93de6f 100644 --- a/extensions/telegram/src/progress-draft-preview.ts +++ b/extensions/telegram/src/progress-draft-preview.ts @@ -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"; diff --git a/extensions/telegram/src/rich-block-model.ts b/extensions/telegram/src/rich-block-model.ts new file mode 100644 index 000000000000..b28810e29807 --- /dev/null +++ b/extensions/telegram/src/rich-block-model.ts @@ -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 = { 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 }; +} diff --git a/extensions/telegram/src/rich-block-split.ts b/extensions/telegram/src/rich-block-split.ts new file mode 100644 index 000000000000..c42bb3818473 --- /dev/null +++ b/extensions/telegram/src/rich-block-split.ts @@ -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; +} diff --git a/extensions/telegram/src/rich-blocks-html-map.ts b/extensions/telegram/src/rich-blocks-html-map.ts new file mode 100644 index 000000000000..6e00cf61fcbc --- /dev/null +++ b/extensions/telegram/src/rich-blocks-html-map.ts @@ -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 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): boolean { + return nodes.some((node) => + node.kind === "text" ? node.text.trim() !== "" : !allowed.has(node.name), + ); +} + +function mediaBlockFromElement( + node: Extract, + 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 => + node.kind === "element" && node.name === "figcaption", + ); + if (!figcaption) { + return undefined; + } + const cite = figcaption.children.find( + (node): node is Extract => + 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): 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 => + 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): 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 => + 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, + 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): 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, 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 => + 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): 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 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): 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): InputRichBlock | undefined { + switch (node.name) { + case "hr": + return { type: "divider" }; + case "details": { + const summary = node.children.find( + (child): child is Extract => + 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 => + 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 => + 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 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 (
) 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 (
) 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; +} diff --git a/extensions/telegram/src/rich-blocks-html.test.ts b/extensions/telegram/src/rich-blocks-html.test.ts index 0ecc7fcf8856..141aa287f9bf 100644 --- a/extensions/telegram/src/rich-blocks-html.test.ts +++ b/extensions/telegram/src/rich-blocks-html.test.ts @@ -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; diff --git a/extensions/telegram/src/rich-blocks-html.ts b/extensions/telegram/src/rich-blocks-html.ts index 58ce5a80bd35..5f4fed42bcb8 100644 --- a/extensions/telegram/src/rich-blocks-html.ts +++ b/extensions/telegram/src/rich-blocks-html.ts @@ -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 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 { +export function parseHtmlAttrs(raw: string): Map { const attrs = new Map(); 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 { } /** 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 }> = []; 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): boolean { - return nodes.some((node) => - node.kind === "text" ? node.text.trim() !== "" : !allowed.has(node.name), - ); -} - -function mediaBlockFromElement( - node: Extract, - 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 => - node.kind === "element" && node.name === "figcaption", - ); - if (!figcaption) { - return undefined; - } - const cite = figcaption.children.find( - (node): node is Extract => - 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): 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 => - 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): 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 => - 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, - 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): 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, 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 => - 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): 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 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): 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): InputRichBlock | undefined { - switch (node.name) { - case "hr": - return { type: "divider" }; - case "details": { - const summary = node.children.find( - (child): child is Extract => - 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 => - 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 => - 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 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 (
) 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 (
) 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; -} diff --git a/extensions/telegram/src/rich-blocks.test.ts b/extensions/telegram/src/rich-blocks.test.ts index fa64eef12b98..16fa36055441 100644 --- a/extensions/telegram/src/rich-blocks.test.ts +++ b/extensions/telegram/src/rich-blocks.test.ts @@ -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 { diff --git a/extensions/telegram/src/rich-blocks.ts b/extensions/telegram/src/rich-blocks.ts index 20b2bcb8536a..6f1eeabc7704 100644 --- a/extensions/telegram/src/rich-blocks.ts +++ b/extensions/telegram/src/rich-blocks.ts @@ -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 = { 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 }; -} diff --git a/extensions/telegram/src/rich-message.ts b/extensions/telegram/src/rich-message.ts index 5f9f34504e6b..f3f149ecb84a 100644 --- a/extensions/telegram/src/rich-message.ts +++ b/extensions/telegram/src/rich-message.ts @@ -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 diff --git a/extensions/telegram/src/rich-plain-fallback.ts b/extensions/telegram/src/rich-plain-fallback.ts index 46c8440d6730..c793275f2a2e 100644 --- a/extensions/telegram/src/rich-plain-fallback.ts +++ b/extensions/telegram/src/rich-plain-fallback.ts @@ -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 diff --git a/extensions/telegram/src/send.test-harness.ts b/extensions/telegram/src/send.test-harness.ts index eaf2d4c3c210..47ed08665ee8 100644 --- a/extensions/telegram/src/send.test-harness.ts +++ b/extensions/telegram/src/send.test-harness.ts @@ -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[]; diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 7baf9d148532..6160e4775ab8 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -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,