/** * Telegram rich-message rendering: canonical mdast → Telegram rich HTML. * * The channel-neutral parse (markdown → mdast) lives in `messaging/content`; * this module is Telegram's "how". Telegram Bot API 10.1 rich messages accept * an `html` field whose text content is literal — so `$100`, `==x==`, `||y||`, * and `<` render exactly as the agent wrote them. The sibling `markdown` field * is a GFM *superset* that would instead reinterpret those as math, highlight, * spoiler, and inline HTML, silently changing content. * * Rendering goes through the standard unified pipeline — `mdast-util-to-hast` * then `hast-util-to-html` — so HTML escaping and serialization are owned by the * maintained serializer rather than hand-written. `telegramizeHast` then applies * the few documented places where Telegram's vocabulary differs from generic * HTML: * - tables use `
`; a language is carried
* on a nested ``;
* - images are standalone media blocks, so an image-only paragraph is
* unwrapped (`![]()
` → `
`) rather than nested inside ``.
*
* `useNamedReferences: false` emits numeric character references. Telegram
* supports every numeric reference but only a fixed set of 13 named entities,
* and its docs are ambiguous about whether `"` is among them, so numeric
* references are the unambiguously-safe choice.
*
* Verified against the official Bot API docs:
* https://core.telegram.org/bots/api#rich-html-style
* https://core.telegram.org/bots/api#inputrichmessage
*/
import type { Element, ElementContent, Root, RootContent } from "hast";
import { toHtml } from "hast-util-to-html";
import { toHast } from "mdast-util-to-hast";
import { parseMarkdown } from "../../content/parse.js";
/**
* Render markdown / plain text into a Telegram rich-message HTML string, or
* `undefined` when there is no renderable content so callers can skip the rich
* path entirely.
*/
export function renderTelegramHtml(text: string): string | undefined {
if (!text || text.trim().length === 0) {
return undefined;
}
const hast = toHast(parseMarkdown(text), {
handlers: {
// remark-gfm parses literal `` markup the agent typed as `html`
// nodes. Agent-authored HTML is not executed as Telegram markup; emitting
// it as text makes the serializer escape it so it renders verbatim — the
// same outcome as the web client, which parses with remark-gfm and no
// `rehype-raw`.
html: (_state, node: { value: string }) => ({
type: "text",
value: node.value,
}),
},
});
// `toHast` is typed to return any hast node, but a root input always yields a
// root; the guard narrows the type without changing behaviour.
if (hast.type !== "root") {
return undefined;
}
telegramizeHast(hast);
const html = toHtml(hast, {
characterReferences: { useNamedReferences: false },
});
return html.length > 0 ? html : undefined;
}
// ---------------------------------------------------------------------------
// Telegram-specific hast adjustments
// ---------------------------------------------------------------------------
/**
* Block-level tags in the generic HTML the serializer produces. Whitespace that
* only separates block-level siblings is layout-insignificant; whitespace
* between inline content (text, ``, ``, ``, …) is meaningful and
* must be preserved.
*/
const BLOCK_TAGS = new Set([
"blockquote",
"details",
"figure",
"figcaption",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"li",
"ol",
"p",
"pre",
"summary",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"ul",
]);
/** Tags whose text content is preformatted, so whitespace is always significant. */
const PREFORMATTED_TAGS = new Set(["pre", "code"]);
function telegramizeHast(root: Root): void {
processNode(root);
}
function processNode(node: Root | Element): void {
for (const child of node.children) {
if (child.type === "element") {
processNode(child);
}
}
if (node.type === "element") {
switch (node.tagName) {
case "table":
unwrapTableSections(node);
break;
case "pre":
normalizeCodeBlock(node);
break;
case "input":
if (node.properties) {
delete node.properties.disabled;
}
break;
case "ul":
case "ol":
stripTaskListClasses(node);
break;
case "li":
stripTaskListClasses(node);
normalizeTaskItemSpacing(node);
break;
}
}
unwrapImageParagraphs(node);
stripStructuralWhitespace(node);
}
/** Lift `` rows out of ``/` `, which Telegram does not support. */
function unwrapTableSections(table: Element): void {
const rows: ElementContent[] = [];
for (const child of table.children) {
if (
child.type === "element" &&
(child.tagName === "thead" || child.tagName === "tbody")
) {
rows.push(...child.children);
} else {
rows.push(child);
}
}
table.children = rows;
}
function normalizeCodeBlock(pre: Element): void {
const code = pre.children.find(
(child): child is Element =>
child.type === "element" && child.tagName === "code",
);
if (!code) {
return;
}
const last = code.children[code.children.length - 1];
if (last?.type === "text" && last.value.endsWith("\n")) {
last.value = last.value.slice(0, -1);
}
const className = code.properties?.className;
const hasLanguage =
Array.isArray(className) &&
className.some(
(name) => typeof name === "string" && name.startsWith("language-"),
);
// A bare code block has no language to carry, so the `` wrapper is
// dropped — Telegram renders fixed-width text from `` alone and only
// honours a language on a nested ``.
if (!hasLanguage) {
pre.children = code.children;
}
}
function stripTaskListClasses(element: Element): void {
const className = element.properties?.className;
if (!Array.isArray(className) || !element.properties) {
return;
}
const kept = className.filter(
(name) => name !== "contains-task-list" && name !== "task-list-item",
);
if (kept.length > 0) {
element.properties.className = kept;
} else {
delete element.properties.className;
}
}
/**
* Drop the whitespace that GFM inserts between a task-list checkbox and its
* label. Telegram's documented checklist form places the label directly after
* the input (`Checked checkbox`), so the leading
* space is removed to match.
*/
function normalizeTaskItemSpacing(li: Element): void {
const [first, second] = li.children;
if (
first?.type === "element" &&
first.tagName === "input" &&
first.properties?.type === "checkbox" &&
second?.type === "text" &&
second.value.trim().length === 0
) {
li.children.splice(1, 1);
}
}
/**
* Lift the `
` out of an image-only paragraph. Telegram's rich HTML treats
* images as standalone media blocks and does not accept `
` nested inside a
* ``; the generic serializer wraps a standalone Markdown image in `
`, so
* an `
![]()
` is flattened to a top-level `
`. A paragraph that mixes
* an image with text is left intact — its surrounding prose still needs ``.
*/
function unwrapImageParagraphs(node: Root | Element): void {
const lifted: Array = [];
for (const child of node.children) {
if (
child.type === "element" &&
child.tagName === "p" &&
isImageOnlyParagraph(child)
) {
for (const grandchild of child.children) {
if (grandchild.type === "element" && grandchild.tagName === "img") {
lifted.push(grandchild);
}
}
} else {
lifted.push(child);
}
}
node.children = lifted as Element["children"];
}
function isImageOnlyParagraph(paragraph: Element): boolean {
let hasImage = false;
for (const child of paragraph.children) {
if (child.type === "text") {
if (child.value.trim().length > 0) {
return false;
}
} else if (child.type === "element" && child.tagName === "img") {
hasImage = true;
} else {
return false;
}
}
return hasImage;
}
/**
* Remove whitespace-only text nodes that merely separate block-level siblings —
* the generic serializer inserts newlines between block elements, but Telegram
* renders those source newlines as literal text. Whitespace flanked by inline
* content on both sides (e.g. the space in `x y`) is
* meaningful and preserved, and preformatted (``/``) whitespace is
* always left untouched.
*/
function stripStructuralWhitespace(node: Root | Element): void {
if (node.type === "element" && PREFORMATTED_TAGS.has(node.tagName)) {
return;
}
const children = node.children;
node.children = children.filter((child, index) => {
if (child.type !== "text" || child.value.trim().length > 0) {
return true;
}
const prev = children[index - 1];
const next = children[index + 1];
return (
prev !== undefined &&
next !== undefined &&
isInlineContent(prev) &&
isInlineContent(next)
);
}) as Element["children"];
}
function isInlineContent(node: RootContent | ElementContent): boolean {
if (node.type === "text") {
return node.value.trim().length > 0;
}
if (node.type === "element") {
return !BLOCK_TAGS.has(node.tagName);
}
return false;
}