/** * Structured builder for Telegram rich messages we assemble ourselves (Bot API * 10.1) — collapsible code diffs, tables from data, banners — rather than * passing model prose through the Markdown fast-path (`markdown.ts`). * * A rich message is sent as an `InputRichMessage.html` string, so this is a * typed builder over Telegram's Rich HTML dialect. It never emits a raw * `RichBlock[]` tree — `InputRichMessage` has no such field; HTML is the second * (and strictly more expressive) input channel, giving us `
`, * ``, `/`, ``, and ``. * * Escaping is handled by the {@link RichHtml} wrapper: a plain `string` passed * anywhere is treated as text and HTML-escaped exactly once; a `RichHtml` * value is already-built markup and is passed through untouched. This makes * nesting (bold → code → spoiler) safe without double-escaping. */ import type { InputRichMessage } from "@grammyjs/types"; /** The named HTML entities Telegram accepts are limited; we only ever emit these three. */ export function escapeHtml(text: string): string { return text .replace(/&/g, "&") .replace(//g, ">"); } function escapeAttr(value: string): string { return escapeHtml(value).replace(/"/g, """); } /** The entities we emit ({@link escapeHtml}, {@link escapeAttr}), and their characters. */ const NAMED_ENTITIES: Readonly> = { amp: "&", lt: "<", gt: ">", quot: '"', }; /** * The plain text of a Rich HTML fragment: tags dropped, entities decoded — in ONE * left-to-right pass. Used for the classic fallback send, which carries no * `parse_mode`, so the result is read as characters and never parsed as markup. * * It replaces a chain of `.replace()` calls that did the same thing in two passes — * strip the tags, then decode the entities. That shape is a known trap (CodeQL's * `js/incomplete-multi-character-sanitization`): the second pass hands back characters * the first pass was there to remove, so anything downstream that trusted the output as * "markup-free" would be wrong. Nothing here does trust it that way, but the property is * worth having by construction rather than by luck: scanning once, a decoded `<` is * written to the output and never looked at again, so it cannot re-open a tag. * * Rules, all of which match the old chain: a `<` with no closing `>` is the character * itself, not a tag; a tag ends at the first `>`, even one inside an attribute; and an * entity we do not know survives verbatim. */ export function richHtmlToText(html: string): string { let text = ""; let i = 0; while (i < html.length) { const char = html[i]; if (char === "<") { const close = html.indexOf(">", i + 1); if (close === -1) { text += char; i += 1; continue; } i = close + 1; continue; } if (char === "&") { const semicolon = html.indexOf(";", i + 1); const name = semicolon === -1 ? "" : html.slice(i + 1, semicolon); const decoded = NAMED_ENTITIES[name]; if (decoded !== undefined) { text += decoded; i = semicolon + 1; continue; } } text += char; i += 1; } return text; } /** * A piece of already-built, escape-safe Rich HTML. Construct it from trusted * text with {@link RichHtml.text} (escaped) or {@link RichHtml.raw} (verbatim). * `RichHtml.of` accepts either a plain string (escaped as text) or a `RichHtml` * (passed through), which is how every builder below accepts nestable content. */ export class RichHtml { private constructor(readonly html: string) {} /** Escape plain text into safe inline HTML. */ static text(value: string): RichHtml { return new RichHtml(escapeHtml(value)); } /** Wrap an already-valid Rich HTML fragment without escaping it. */ static raw(html: string): RichHtml { return new RichHtml(html); } /** Normalize content: strings are escaped as text, `RichHtml` is passed through. */ static of(value: RichContent): RichHtml { return value instanceof RichHtml ? value : RichHtml.text(value); } /** Concatenate mixed content into one fragment. */ static join(parts: readonly RichContent[]): RichHtml { return RichHtml.raw(parts.map((part) => RichHtml.of(part).html).join("")); } toString(): string { return this.html; } } /** Anything a builder accepts as content: raw text (escaped) or built HTML. */ export type RichContent = RichHtml | string; /** Heading level, largest (1) to smallest (6). */ export type HeadingLevel = 1 | 2 | 3 | 4 | 5 | 6; // --- inline formatting ----------------------------------------------------- function inline(tag: string, content: RichContent): RichHtml { return RichHtml.raw(`<${tag}>${RichHtml.of(content).html}`); } export const bold = (content: RichContent): RichHtml => inline("b", content); export const italic = (content: RichContent): RichHtml => inline("i", content); export const underline = (content: RichContent): RichHtml => inline("u", content); export const strikethrough = (content: RichContent): RichHtml => inline("s", content); export const inlineCode = (content: RichContent): RichHtml => inline("code", content); export const marked = (content: RichContent): RichHtml => inline("mark", content); export const spoiler = (content: RichContent): RichHtml => inline("tg-spoiler", content); export const subscript = (content: RichContent): RichHtml => inline("sub", content); export const superscript = (content: RichContent): RichHtml => inline("sup", content); /** Inline LaTeX (``). The source is raw LaTeX; we only HTML-escape it. */ export const mathInline = (latex: string): RichHtml => RichHtml.raw(`${escapeHtml(latex)}`); /** * An animated "Thinking…" placeholder (``). Telegram animates the * block; the text inside is ours, so we do NOT put model reasoning there (the * SDK never exposes any) — we say what the agent is DOING right now. * * Valid ONLY inside `sendRichMessageDraft`: a real `sendRichMessage` rejects it, * which is also why it can never leak into chat history. */ export const thinking = (content: RichContent): RichHtml => inline("tg-thinking", content); export function link(text: RichContent, url: string): RichHtml { return RichHtml.raw( `${RichHtml.of(text).html}`, ); } // --- block formatting ------------------------------------------------------ export const heading = (text: RichContent, level: HeadingLevel = 2): RichHtml => RichHtml.raw(`${RichHtml.of(text).html}`); export const paragraph = (text: RichContent): RichHtml => RichHtml.raw(`

${RichHtml.of(text).html}

`); export const footer = (text: RichContent): RichHtml => RichHtml.raw(`
${RichHtml.of(text).html}
`); export const divider = (): RichHtml => RichHtml.raw("
"); /** Multi-line LaTeX block (``). */ export const mathBlock = (latex: string): RichHtml => RichHtml.raw(`${escapeHtml(latex)}`); /** A preformatted code block, optionally tagged with a programming language. */ export function preformatted(code: string, language?: string): RichHtml { const body = escapeHtml(code); if (language) { return RichHtml.raw( `
${body}
`, ); } return RichHtml.raw(`
${body}
`); } /** A collapsible disclosure block (`
`); `open` shows it expanded. */ export function details( summary: RichContent, content: readonly RichContent[], open = false, ): RichHtml { const inner = content.map((part) => RichHtml.of(part).html).join(""); const attr = open ? " open" : ""; return RichHtml.raw( `${RichHtml.of(summary).html}${inner}
`, ); } export interface CollapsibleCodeOptions { /** Always-visible label; defaults to `" ( lines)"`. */ summary?: RichContent; language?: string; /** Expand by default. Collapsed (false) is the norm for long diffs. */ open?: boolean; } /** * A collapsible code/diff block: a `
` wrapping a `
`. Long
 * diffs stay folded by default so they don't dominate a message, and expand on
 * tap. This is the collapsible-diff rendering the plan calls for.
 */
export function collapsibleCode(
	code: string,
	options: CollapsibleCodeOptions = {},
): RichHtml {
	const lineCount = code.split("\n").length;
	const summary =
		options.summary ?? `${options.language ?? "code"} (${lineCount} lines)`;
	return details(
		summary,
		[preformatted(code, options.language)],
		options.open ?? false,
	);
}

export function blockquote(
	content: readonly RichContent[],
	credit?: RichContent,
): RichHtml {
	const inner = content.map((part) => RichHtml.of(part).html).join("");
	const cite =
		credit === undefined ? "" : `${RichHtml.of(credit).html}`;
	return RichHtml.raw(`
${inner}${cite}
`); } /** A centered pull quote (`
`; const body = rows .map((row) => { const cells = row.map((cell) => renderCell(cell)).join(""); return `${cells}`; }) .join(""); return RichHtml.raw(`${caption}${body}
${RichHtml.of(options.caption).html}
`); } function renderCell(cell: TableCell): string { const tag = cell.header ? "th" : "td"; const align = cell.align ? ` align="${cell.align}"` : ""; const valign = cell.valign ? ` valign="${cell.valign}"` : ""; const colspan = cell.colspan && cell.colspan > 1 ? ` colspan="${cell.colspan}"` : ""; const rowspan = cell.rowspan && cell.rowspan > 1 ? ` rowspan="${cell.rowspan}"` : ""; const content = cell.text === undefined ? "" : RichHtml.of(cell.text).html; return `<${tag}${align}${valign}${colspan}${rowspan}>${content}`; } // --- document -------------------------------------------------------------- /** * Fluent accumulator for a whole rich message. Each method appends a block and * returns `this`; `build()` produces the `InputRichMessage`. Plain strings * passed to `add`/`paragraph`/… are escaped as text. */ export class RichHtmlDocument { private readonly blocks: string[] = []; add(block: RichContent): this { this.blocks.push(RichHtml.of(block).html); return this; } heading(text: RichContent, level?: HeadingLevel): this { return this.add(heading(text, level)); } paragraph(text: RichContent): this { return this.add(paragraph(text)); } code(code: string, language?: string): this { return this.add(preformatted(code, language)); } collapsibleCode(code: string, options?: CollapsibleCodeOptions): this { return this.add(collapsibleCode(code, options)); } table(rows: readonly (readonly TableCell[])[], options?: TableOptions): this { return this.add(table(rows, options)); } divider(): this { return this.add(divider()); } isEmpty(): boolean { return this.blocks.length === 0; } toHtml(): string { return this.blocks.join("\n"); } build(): InputRichMessage { return { html: this.toHtml() }; } } /** Wrap an already-built Rich HTML string as a rich message. */ export function buildRichHtmlMessage(html: RichContent): InputRichMessage { return { html: RichHtml.of(html).html }; }