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