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