// Telegram tests cover format plugin behavior. import { describe, expect, it } from "vitest"; import { markdownToTelegramChunks, markdownToTelegramHtml, markdownToTelegramRichHtml, materializeTelegramRichHtmlLineBreaks, normalizeTelegramOutboundRichHtml, renderTelegramHtmlText, splitTelegramHtmlChunks, telegramHtmlToPlainTextFallback, } from "./format.js"; describe("markdownToTelegramHtml", () => { it("handles core markdown-to-telegram conversions", () => { const cases = [ [ "renders basic inline formatting", "hi _there_ **boss** `code`", "hi there boss code", ], [ "renders links as Telegram-safe HTML", "see [docs](https://example.com)", 'see docs', ], ["preserves Telegram HTML", "yes", "yes"], [ "escapes unsupported raw HTML", "", "<script>nope</script>", ], [ "escapes literal reasoning-looking tags", "Before literal tag text after", "Before <think>literal tag text after", ], ["escapes unsafe characters", "a & b < c", "a & b < c"], ["renders paragraphs with blank lines", "first\n\nsecond", "first\n\nsecond"], ["renders lists without block HTML", "- one\n- two", "• one\n• two"], ["renders ordered lists with numbering", "2. two\n3. three", "2. two\n3. three"], ["flattens headings", "# Title", "Title"], ] as const; for (const [name, input, expected] of cases) { expect(markdownToTelegramHtml(input), name).toBe(expected); } }); it("preserves supported Telegram HTML in stream markdown rendering", () => { const input = [ "✉️ Morning Email Rollup", "", "
✅ No important emails in the last 24 hours.
", "", "
oauth2: invalid_grant
", ].join("\n"); expect(markdownToTelegramHtml(input)).toBe(input); expect( markdownToTelegramChunks(input, 4096) .map((chunk) => chunk.html) .join(""), ).toBe(input); }); it("preserves Telegram expandable blockquote HTML", () => { const input = "
hidden details
"; expect(markdownToTelegramHtml(input)).toBe(input); expect(renderTelegramHtmlText(input, { textMode: "html" })).toBe(input); }); it("does not promote Telegram HTML tags inside code", () => { expect(markdownToTelegramHtml("`literal`")).toBe( "<b>literal</b>", ); expect(markdownToTelegramHtml("```\n
literal
\n```")).toBe( "
<blockquote>literal</blockquote>\n
", ); }); it("keeps unsupported Telegram HTML variants escaped", () => { expect(markdownToTelegramHtml('bad')).toBe('<b class="x">bad</b>'); expect(markdownToTelegramHtml('
bad
')).toBe( '<blockquote cite="x">bad</blockquote>', ); expect(markdownToTelegramHtml("1")).toBe("<sup>1</sup>"); expect(renderTelegramHtmlText('bad', { textMode: "html" })).toBe( '<b class="x">bad</b>', ); }); it("preserves rich-only Telegram HTML tags on the rich path", () => { expect(markdownToTelegramRichHtml("1")).toBe("1"); }); it("renders bold spans that close before CJK punctuation in rich markdown", () => { expect(markdownToTelegramRichHtml("边界:**社区显示:**Fable 与 **有效**。")).toBe( "边界:社区显示:Fable 与 有效。", ); }); it("materializes inline and paragraph newlines as
for rich messages", () => { // The exact reported symptom: literal "• " bullets (not Markdown list markers) // joined by soft breaks, which Bot API 10.1 rich messages collapse without
. expect( materializeTelegramRichHtmlLineBreaks( "Start here:\n\n• Florist - Red Bird\n• Tomberlin - Seventeen", ), ).toBe("Start here:

• Florist - Red Bird
• Tomberlin - Seventeen"); expect(materializeTelegramRichHtmlLineBreaks("Line one\nLine two")).toBe( "Line one
Line two", ); // Soft breaks inside an inline-styled block (blockquote) also collapse. expect(materializeTelegramRichHtmlLineBreaks("
one\ntwo
")).toBe( "
one
two
", ); expect( materializeTelegramRichHtmlLineBreaks('one\ntwo'), ).toBe('one
two'); }); it("keeps newlines literal inside code, pre, and math", () => { expect(materializeTelegramRichHtmlLineBreaks("
first\nsecond\n
")).toBe( "
first\nsecond\n
", ); expect(materializeTelegramRichHtmlLineBreaks("a\nb")).toBe("a\nb"); expect(materializeTelegramRichHtmlLineBreaks("x\ny")).toBe( "x\ny", ); }); it("preserves structural newlines that only separate block tags", () => { // Block tags already break; a stray
would add a blank line or land as an // invalid container child. Mixed text hugging a block keeps its boundary \n too. const blocks = "

Plan

\n
A
"; expect(materializeTelegramRichHtmlLineBreaks(blocks)).toBe(blocks); expect( materializeTelegramRichHtmlLineBreaks( 'A\n\n
\n\nB', ), ).toBe('A\n\n
\n\nB'); }); it("does not let a self-closing literal tag swallow later line breaks", () => { expect(materializeTelegramRichHtmlLineBreaks("\na\nb")).toBe("
a
b"); }); it("does not inject
into pretty-printed rich containers", () => { // Explicit rich HTML can arrive pretty-printed; newlines between or inside // table/figure/details container children are layout, not prose, and the // block-counting set omits thead/tbody/td/th/caption/figcaption/summary. const table = "\n\n\n\n\n\n\n
H
A
"; expect(materializeTelegramRichHtmlLineBreaks(table)).toBe(table); const figure = '
\n\n
\nCap\n
\n
'; expect(materializeTelegramRichHtmlLineBreaks(figure)).toBe(figure); const details = "
\n\nMore\n\nBody\n
"; expect(materializeTelegramRichHtmlLineBreaks(details)).toBe(details); }); it("keeps existing
tags intact without doubling adjacent newlines", () => { expect(materializeTelegramRichHtmlLineBreaks("a
b\nc")).toBe("a
b
c"); // A newline hugging an existing
stays literal — the break already exists. expect(materializeTelegramRichHtmlLineBreaks("line1
\nline2")).toBe("line1
\nline2"); }); it("preserves rich table, details, quote, checklist, anchor, and math HTML", () => { const input = [ '', "

Plan

", '
Scores
NameTotal
A12
', "
More

Hidden

", "", '
  • Done
  • Todo
', '

Back H2O E=mc2 note secret E=mc^2

', "\\int_0^1 x^2 dx", ].join("\n"); expect(markdownToTelegramRichHtml(input)).toBe(input); }); it("converts raw HTML tables to code fallbacks in legacy HTML mode", () => { const input = [ "", "", "", "
NameAge
Ada37
", ].join(""); const html = renderTelegramHtmlText(input, { textMode: "html" }); expect(html).toBe("
| Name | Age |\n| Ada  | 37  |
\n\n"); expect(html).not.toContain("<table"); }); it("keeps raw HTML tables escaped inside legacy HTML code blocks", () => { expect( renderTelegramHtmlText("
A
", { textMode: "html", }), ).toBe( "
<table><tr><td>A</td></tr></table>
", ); }); it("normalizes supported raw rich HTML tables during sanitization", () => { const input = '
RankModelScore
4Claude Opus78.16%
'; expect(normalizeTelegramOutboundRichHtml(input).html).toBe( "
RankModelScore
4Claude Opus78.16%
", ); }); it("preserves raw rich HTML table captions and alignment during normalization", () => { const input = '
Scores
RankModel
4Claude Opus
'; expect(normalizeTelegramOutboundRichHtml(input).html).toBe( '
Scores
RankModel
4Claude Opus
', ); }); it("preserves raw rich HTML table colspans during normalization", () => { const input = '
NameTotal
A12
'; expect(normalizeTelegramOutboundRichHtml(input).html).toBe( '
NameTotal
A12
', ); }); it("keeps canonical rich tables idempotent during outbound normalization", () => { const html = markdownToTelegramRichHtml( "| Feature | Link |\n| --- | --- |\n| **API** | [docs](https://example.com) |", ); expect(normalizeTelegramOutboundRichHtml(html).html).toBe(html.trim()); }); it("materializes literal rich HTML newline escapes outside code", () => { expect(normalizeTelegramOutboundRichHtml("Alpha\\tBeta\\nGamma").html).toBe( "Alpha\tBeta
Gamma", ); }); it("keeps literal rich HTML newline escapes inside code and pre", () => { const html = "Alpha\\nBeta\\tGamma
One\\nTwo\\tThree
"; expect(normalizeTelegramOutboundRichHtml(html).html).toBe(html); }); it("isolates rich media tags as blocks", () => { const html = markdownToTelegramRichHtml( 'One A two https://example.com/page', ); expect(html).toContain( '\n\n
A
\n\n', ); expect(html).toContain('https://example.com/page'); expect(html).not.toContain("<img"); expect(html).not.toContain(''); }); it("escapes rich media tags without supported http sources", () => { expect(markdownToTelegramRichHtml('Logo')).toBe( '<img src="logo.png" alt="Logo">', ); expect(markdownToTelegramRichHtml('')).toBe( '<audio src="data:audio/wav;base64,x"></audio>', ); expect(markdownToTelegramRichHtml('')).toBe( '
', ); }); it("renders Markdown media blocks on the rich HTML fallback path", () => { expect(markdownToTelegramRichHtml('![Diagram](https://example.com/a.jpg "Caption")')).toBe( '
Diagram
Caption
', ); expect( markdownToTelegramRichHtml('![A "quote"](https://cdn.example/img.png?token=a&expires=b)'), ).toBe( '
A "quote"
', ); expect(markdownToTelegramRichHtml("![A > B](https://example.com/a.png)")).toBe( '
A > B
', ); expect(markdownToTelegramRichHtml("See ![Diagram](https://example.com/a.jpg).")).toBe( 'See
Diagram.', ); expect(markdownToTelegramRichHtml("```\n![](https://example.com/a.jpg)\n```")).toBe( "
![](https://example.com/a.jpg)\n
", ); }); it("renders rich tables and falls back when they exceed Telegram's column limit", () => { const table = (columns: number) => [ `| ${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"); expect(markdownToTelegramRichHtml(table(20))).toContain(""); expect(markdownToTelegramRichHtml(table(21))).toContain("
");
    expect(markdownToTelegramRichHtml(table(2), { tableMode: "code" })).toContain("
");
    expect(markdownToTelegramRichHtml(table(2), { tableMode: "code" })).not.toContain("
"); }); it("falls back over-wide raw rich HTML tables", () => { const cells = Array.from({ length: 21 }, (_, index) => ``).join(""); const html = `
C${index + 1}
${cells}
Wide
`; const sanitized = normalizeTelegramOutboundRichHtml(html); expect(sanitized.html).toContain("
Wide");
    expect(sanitized.html).toContain("C21");
    expect(sanitized.html).not.toContain("");
    expect(sanitized.degradationReasons).toEqual(["table-ascii"]);
  });

  it("falls back malformed raw rich HTML tables", () => {
    const sanitized = normalizeTelegramOutboundRichHtml("
Broken
"); expect(sanitized.html).toContain("
Broken
"); expect(sanitized.degradationReasons).toEqual(["table-ascii"]); }); it("clamps raw rich HTML table colspans before fallback", () => { const html = '
x
'; const sanitized = normalizeTelegramOutboundRichHtml(html); expect(sanitized.html).toContain("
");
    expect(sanitized.html.length).toBeLessThan(300);
    expect(sanitized.degradationReasons).toEqual(["table-ascii"]);
  });

  it("renders block-mode tables as code in legacy Telegram HTML", () => {
    const table = "| A | B |\n| --- | --- |\n| 1 | 2 |";

    expect(markdownToTelegramHtml(table, { tableMode: "block" })).toBe(
      "
| A | B |\n| --- | --- |\n| 1 | 2 |\n
", ); }); it("preserves inline markdown inside rich table cells", () => { const html = markdownToTelegramRichHtml( "| Name | Link |\n| --- | --- |\n| **API** | [docs](https://example.com) |", ); expect(html).toContain("API"); expect(html).toContain('docs'); }); it("preserves markdown table column alignment in rich tables", () => { const html = markdownToTelegramRichHtml( "| Feature | Status | Count |\n| :--- | :---: | ---: |\n| Rich tables | Fixed | 2 |", ); expect(html).toContain('Feature'); expect(html).toContain('Status'); expect(html).toContain('Count'); expect(html).toContain('Rich tables'); expect(html).toContain('Fixed'); expect(html).toContain('2'); }); it("does not auto-linkify bare URLs when entity detection is skipped", () => { expect(markdownToTelegramRichHtml("https://example.com", { skipEntityDetection: true })).toBe( "https://example.com", ); expect( markdownToTelegramRichHtml("[docs](https://example.com)", { skipEntityDetection: true }), ).toBe('docs'); }); it("keeps unsupported markdown link hrefs as visible text in rich HTML", () => { expect( markdownToTelegramRichHtml( "[scripts/yougile.py](/home/dankar/.openclaw/workspace-yougile/scripts/yougile.py#L41)", ), ).toBe("scripts/yougile.py"); expect(markdownToTelegramRichHtml("[config](./openclaw.json)")).toBe("config"); expect(markdownToTelegramRichHtml("[docs](https://example.com/docs)")).toBe( 'docs', ); expect(markdownToTelegramRichHtml("[user](tg://user?id=123)")).toBe( 'user', ); expect(markdownToTelegramRichHtml("[support](mailto:user@example.com)")).toBe( 'support', ); expect(markdownToTelegramRichHtml("[call](tel:+123456789)")).toBe( 'call', ); expect(markdownToTelegramRichHtml("[back](#top)")).toBe('back'); }); it("preserves Markdown heading levels in rich HTML", () => { expect(markdownToTelegramRichHtml("# Title\n\n### Detail")).toBe( "

Title

\n\n

Detail

", ); }); it("normalizes raw code language HTML without leaking tags", () => { const commandBlock = '/queue followup debounce:0\n'; expect(markdownToTelegramHtml(commandBlock)).toBe("/queue followup debounce:0\n"); expect( markdownToTelegramHtml('
print(1)\n
'), ).toBe('
print(1)\n
'); }); it("renders blockquotes as native Telegram blockquote tags", () => { const res = markdownToTelegramHtml("> Quote"); expect(res).toContain("
"); expect(res).toContain("Quote"); expect(res).toContain("
"); }); it("renders blockquotes with inline formatting", () => { const res = markdownToTelegramHtml("> **bold** quote"); expect(res).toContain("
"); expect(res).toContain("bold"); expect(res).toContain("
"); }); it("renders multiline blockquotes as a single Telegram blockquote", () => { const res = markdownToTelegramHtml("> first\n> second"); expect(res).toBe("
first\nsecond
"); }); it("renders separated quoted paragraphs as distinct blockquotes", () => { const res = markdownToTelegramHtml("> first\n\n> second"); expect(res).toContain("
first"); expect(res).toContain("
second
"); expect(res.match(/
/g)).toHaveLength(2); }); it("renders fenced code block languages for Telegram native copy buttons", () => { const res = markdownToTelegramHtml('```bash\necho "hello"\n```'); expect(res).toBe('
echo "hello"\n
'); }); it("properly nests overlapping bold and autolink (#4071)", () => { const res = markdownToTelegramHtml("**start https://example.com** end"); expect(res).toMatch( /start https:\/\/example\.com<\/a><\/b> end/, ); }); it("properly nests link inside bold", () => { const res = markdownToTelegramHtml("**bold [link](https://example.com) text**"); expect(res).toBe('bold link text'); }); it("properly nests bold wrapping a link with trailing text", () => { const res = markdownToTelegramHtml("**[link](https://example.com) rest**"); expect(res).toBe('link rest'); }); it("properly nests bold inside a link", () => { const res = markdownToTelegramHtml("[**bold**](https://example.com)"); expect(res).toBe('bold'); }); it("wraps punctuated file references in code tags", () => { const res = markdownToTelegramHtml("See README.md. Also (backup.sh)."); expect(res).toContain("README.md."); expect(res).toContain("(backup.sh)."); }); it("renders spoiler tags", () => { const res = markdownToTelegramHtml("the answer is ||42||"); expect(res).toBe("the answer is 42"); }); it("renders spoiler with nested formatting", () => { const res = markdownToTelegramHtml("||**secret** text||"); expect(res).toBe("secret text"); }); it("preserves spacing between Telegram bullet blocks and following numbered sections", () => { const input = [ "2. Main invariants:", "", " • Raw Log is source of truth.", " • Autonomy starts only with report/draft.", "3. Cognee is a candidate:", "", " • bake-off first;", " • decide keep/adopt/hybrid later.", "4. Project Flow slices:", ].join("\n"); const res = markdownToTelegramHtml(input, { wrapFileRefs: false }); expect(res).toContain("report/draft.\n\n3. Cognee"); expect(res).toContain("keep/adopt/hybrid later.\n\n4. Project"); }); it("preserves Telegram list boundary spacing in chunked rendering", () => { const input = [ "2. Main invariants:", "", " • Raw Log is source of truth.", " • Autonomy starts only with report/draft.", "3. Cognee is a candidate:", ].join("\n"); const res = markdownToTelegramChunks(input, 4096) .map((chunk) => chunk.html) .join(""); expect(res).toContain("report/draft.\n\n3. Cognee"); }); it("does not insert Telegram list boundary spacing inside fenced code", () => { const input = ["```", " • literal bullet", "3. literal number", "```"].join("\n"); const res = markdownToTelegramHtml(input, { wrapFileRefs: false }); expect(res).toBe("
  • literal bullet\n3. literal number\n
"); }); it("does not insert Telegram list boundary spacing inside indented code", () => { const input = [" • literal bullet", " 3. literal number"].join("\n"); const res = markdownToTelegramHtml(input, { wrapFileRefs: false }); const chunks = markdownToTelegramChunks(input, 4096) .map((chunk) => chunk.html) .join(""); expect(res).toBe("
• literal bullet\n3. literal number\n
"); expect(chunks).toBe(res); }); it("does not treat single pipe as spoiler", () => { const res = markdownToTelegramHtml("( ̄_ ̄|) face"); expect(res).not.toContain("tg-spoiler"); expect(res).toContain("|"); }); it("does not treat unpaired || as spoiler", () => { const res = markdownToTelegramHtml("before || after"); expect(res).not.toContain("tg-spoiler"); expect(res).toContain("||"); }); it("keeps valid spoiler pairs when a trailing || is unmatched", () => { const res = markdownToTelegramHtml("||secret|| trailing ||"); expect(res).toContain("secret"); expect(res).toContain("trailing ||"); }); it("splits long multiline html text without breaking balanced tags", () => { const chunks = splitTelegramHtmlChunks(`${"A\n".repeat(2500)}`, 4000); expect(chunks.length).toBeGreaterThan(1); expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true); expect(chunks[0]).toMatch(/^[\s\S]*<\/b>$/); expect(chunks[1]).toMatch(/^[\s\S]*<\/b>$/); }); it("does not synthesize closing tags for rich void tags when chunking html", () => { const chunks = splitTelegramHtmlChunks( `
  • ${"A".repeat(80)}
`, 64, ); expect(chunks.join("")).not.toContain(""); expect(chunks.join("")).not.toContain(""); }); it("fails loudly when a leading entity cannot fit inside a chunk", () => { expect(() => splitTelegramHtmlChunks(`A&${"B".repeat(20)}`, 4)).toThrow(/leading entity/i); }); it("treats malformed leading ampersands as plain text when chunking html", () => { const chunks = splitTelegramHtmlChunks(`&${"A".repeat(5000)}`, 4000); expect(chunks.length).toBeGreaterThan(1); expect(chunks.every((chunk) => chunk.length <= 4000)).toBe(true); }); it("derives readable plain text from Telegram HTML fallback markup", () => { const html = [ 'Created: Task & One', "file.md", "
", 'https://example.com/same', "done", ].join(" "); expect(telegramHtmlToPlainTextFallback(html)).toBe( "Created: Task & One (https://example.com/a?x=1&y=2) file.md \n https://example.com/same done", ); }); it("preserves escaped angle-bracket text in Telegram HTML fallback links", () => { expect( telegramHtmlToPlainTextFallback( 'Task <id>', ), ).toBe("Task (https://example.com/task?id=1&kind=bug)"); }); it("preserves table cell boundaries in Telegram HTML fallback text", () => { expect( telegramHtmlToPlainTextFallback( "
NameAge
Alice30
", ), ).toBe("Name | Age\nAlice | 30"); }); it("does not decode surrogate numeric entities into Telegram HTML fallback text", () => { const cases = [ ["hex high surrogate", "x � y", "x � y"], ["decimal high surrogate", "x � y", "x � y"], ["hex low surrogate", "x � y", "x � y"], ] as const; for (const [name, input, expected] of cases) { const output = telegramHtmlToPlainTextFallback(input); expect(output, name).toBe(expected); expect(containsLoneSurrogate(output), name).toBe(false); } }); it("continues to decode valid astral numeric entities in Telegram HTML fallback text", () => { const output = telegramHtmlToPlainTextFallback("x 😀 😀 y"); expect(output).toBe("x 😀 😀 y"); expect(containsLoneSurrogate(output)).toBe(false); }); it("delivers content as plain text when tag overhead fills the chunk", () => { const chunks = splitTelegramHtmlChunks("x", 10); expect(chunks).toHaveLength(1); expect(chunks[0]).toBe("x"); }); it("keeps later formatting balanced after dropping an oversized tag scope", () => { const oversizedLink = `first`; const chunks = splitTelegramHtmlChunks(`${oversizedLink}second`, 20); expect(chunks).toEqual(["firstsecond"]); expect(chunks.every((chunk) => chunk.length <= 20)).toBe(true); expect(telegramHtmlToPlainTextFallback(chunks.join(""))).toBe("firstsecond"); }); it("does not split an astral char across the chunk boundary", () => { // Emoji surrogate pair straddles index 10 (limit): high at 9, low at 10. const input = `${"A".repeat(9)}😀${"B".repeat(20)}`; const chunks = splitTelegramHtmlChunks(input, 10); expect(chunks.length).toBeGreaterThan(1); expect(chunks.join("")).toBe(input); for (const chunk of chunks) { expect(containsLoneSurrogate(chunk)).toBe(false); } }); it("keeps an astral char whole when a positive limit starts on its pair", () => { expect(splitTelegramHtmlChunks("A😀B", 1)).toEqual(["A", "😀", "B"]); }); it("keeps astral chars whole in rendered Markdown chunks", () => { const chunks = markdownToTelegramChunks("A😀B", 1); expect(chunks.map((chunk) => chunk.text)).toEqual(["A", "😀", "B"]); for (const chunk of chunks) { expect(containsLoneSurrogate(chunk.html)).toBe(false); expect(containsLoneSurrogate(chunk.text)).toBe(false); } }); }); function containsLoneSurrogate(text: string): boolean { for (let index = 0; index < text.length; index += 1) { const code = text.charCodeAt(index); const isHigh = code >= 0xd800 && code <= 0xdbff; const isLow = code >= 0xdc00 && code <= 0xdfff; if (isHigh) { const next = text.charCodeAt(index + 1); if (!(next >= 0xdc00 && next <= 0xdfff)) { return true; } index += 1; } else if (isLow) { return true; } } return false; }