/** * Telegram message formatting and splitting utilities. */ import { escapeHtml as escapeNative } from "../../native/htmlents.js"; import { splitMessage as splitNative } from "../../native/textops.js"; /** * Split a message into chunks that fit Telegram's 4096 char limit. * Delegates to the shared Zig core (native/textops-wasm): fence-aware * — splits never strand an open ``` block — and surrogate-safe, unlike * the JS loop this replaces. */ export function splitMessage(text: string, max: number): string[] { return splitNative(text, max); } /** * Escape HTML special characters for Telegram HTML parse mode. * Must be applied to all text that is NOT inside an HTML tag. * Delegates to the Rust core (native/htmlents-wasm): one pass over the * bytes instead of the five chained regex passes this replaces. */ export function escapeHtml(text: string): string { return escapeNative(text); } /** * True when every tag in `html` is closed in the order it was opened. * * The inline formatters below are independent regex passes with no * knowledge of each other's spans, so interleaved delimiters * (`**a _b** c_`) emit crossed tags like `x`. Telegram * refuses to parse those and 400s the whole message, so the caller * checks before committing to the formatted rendering. * * Only tags this module generates are possible here — everything else * was entity-escaped in step 3 — so a plain stack is sufficient. */ function isWellFormedHtml(html: string): boolean { const stack: string[] = []; const tag = /<(\/?)([a-zA-Z]+)(?:\s[^>]*)?>/g; let match: RegExpExecArray | null; while ((match = tag.exec(html)) !== null) { const [, closing, name] = match; if (closing) { if (stack.pop() !== name) return false; } else { stack.push(name!); } } return stack.length === 0; } /** `\x00URLn\x00` → the stashed URL, or undefined when it isn't a placeholder. */ function resolveUrlPlaceholder( target: string, urls: string[], ): string | undefined { // oxlint-disable-next-line no-control-regex const match = /^\x00URL(\d+)\x00$/.exec(target); return match ? urls[Number(match[1])] : undefined; } /** Apply the inline delimiter passes to already-escaped text. */ function applyInlineFormatting(input: string, urls: string[]): string { let out = input; // Bold+italic: ***text*** — must run before the ** and * passes, which // would otherwise split it into the crossed pair `x`. out = out.replace(/\*\*\*(.+?)\*\*\*/g, "$1"); // Bold: **text** out = out.replace(/\*\*(.+?)\*\*/g, "$1"); // Italic: *text* (not preceded by another *) out = out.replace(/(?$1"); // Italic: _text_ (surrounded by non-word or start/end) out = out.replace(/(?$1"); // Links: [text](url) — only safe URL schemes become anchors. The target // arrives as a `\x00URLn\x00` placeholder (step 2b), so the passes above // never saw the URL's own punctuation; resolve it back here. Both text and // url were already HTML-escaped (quotes included, so the href attribute // can't be broken out of); escaping again here corrupted every & in a // query string into &amp;. out = out.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, text, target) => { const url = resolveUrlPlaceholder(target, urls) ?? target; return /^https?:\/\//i.test(url) ? `${text}` : text; }); // Strikethrough: ~~text~~ out = out.replace(/~~(.+?)~~/g, "$1"); return out; } /** * Convert Markdown output to Telegram-safe HTML. * * Handles: bold, italic, inline code, fenced code blocks, links. * Escapes HTML entities in non-formatted text. * * Guarantees well-formed output: when the inline passes would produce * crossed tags, the formatting is dropped rather than emitted, because * Telegram rejects the entire message on a parse error and the caller's * only recourse is a second round-trip with no formatting at all. */ export function markdownToTelegramHtml(text: string): string { // Step 1: Extract fenced code blocks to avoid processing their contents. // We replace them with placeholders and restore after all inline processing. const codeBlocks: string[] = []; let processed = text.replace( /```(\w*)\n([\s\S]*?)```/g, (_match, lang: string, code: string) => { const escaped = escapeHtml(code.replace(/\n$/, "")); const langAttr = lang ? ` class="language-${escapeHtml(lang)}"` : ""; const placeholder = `\x00CODEBLOCK${codeBlocks.length}\x00`; codeBlocks.push(`
${escaped}
`); return placeholder; }, ); // Step 2: Extract inline code spans to protect them from further processing. const inlineCode: string[] = []; processed = processed.replace(/`([^`\n]+)`/g, (_match, code: string) => { const placeholder = `\x00INLINECODE${inlineCode.length}\x00`; inlineCode.push(`${escapeHtml(code)}`); return placeholder; }); // Step 2b: Stash link targets. The emphasis passes run before the link // pass and have no idea what a URL is, so punctuation inside the target // used to be consumed as a delimiter: a Chomikuj path like // `Jak+Si*c4*99+Bawi*c4*85+Ludzie.mp3` reads as two italic runs, the // resulting tags cross, and the well-formedness guard then discards the // formatting for the *whole* message. Placeholders carry no delimiters, // so the URL is invisible to every pass but the link one. const urls: string[] = []; processed = processed.replace( /(\[[^\]\n]*\])\(([^()\s]+)\)/g, (_match, label: string, url: string) => { const placeholder = `\x00URL${urls.length}\x00`; urls.push(escapeHtml(url)); return `${label}(${placeholder})`; }, ); // Step 3: Escape HTML in remaining plain text (before applying formatting). // Escape HTML in plain text segments (skip placeholders marked with \x00) // oxlint-disable-next-line no-control-regex processed = processed.replace(/[^`\x00]+/g, (segment) => escapeHtml(segment)); // Step 4: Apply inline formatting, but only keep it if the result is // actually parseable. `processed` at this point holds escaped text plus // tag-free placeholders, so it is a safe unformatted fallback. const unformatted = processed; processed = applyInlineFormatting(processed, urls); if (!isWellFormedHtml(processed)) { processed = unformatted; } // Step 4b: Restore any link target that never became an anchor — a // non-http scheme, or the unformatted fallback above. Anchors already // resolved their placeholder inline. for (let i = 0; i < urls.length; i++) { processed = processed.replace(`\x00URL${i}\x00`, () => urls[i]!); } // Steps 5+6: Restore code spans and fenced blocks. The replacement MUST // go through a function: with a string, String.replace interprets $- // substitution patterns in the *code content* ($& re-inserts the // placeholder, $\` splices the whole preceding message), which is how // \`$&\` in a code span used to leak a stranded INLINECODEn into chat. for (let i = 0; i < inlineCode.length; i++) { processed = processed.replace( `\x00INLINECODE${i}\x00`, () => inlineCode[i], ); } for (let i = 0; i < codeBlocks.length; i++) { processed = processed.replace(`\x00CODEBLOCK${i}\x00`, () => codeBlocks[i]); } return processed; }