refactor: rewrite Telegram formatting pipeline with gomarkdown AST

Replace regex-chain markdown conversion with single-parser + dual-renderer
architecture using gomarkdown/markdown. This adds support for tables
(monospace + list fallback), ordered lists, code block language tags, and
proper nested formatting while eliminating inconsistencies between HTML
and MarkdownV2 paths.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-03-19 15:15:16 +09:00
parent 8cae962d2a
commit 4ecd54eaab
10 changed files with 1155 additions and 396 deletions

View file

@ -1,197 +0,0 @@
package telegram
import (
"regexp"
"strings"
)
// mdV2SpecialChars are all characters that must be escaped in Telegram MarkdownV2
var mdV2SpecialChars = map[rune]bool{
'*': true,
'_': true,
'[': true,
']': true,
'(': true,
')': true,
'~': true,
'`': true,
'>': true,
'<': true,
'#': true,
'+': true,
'-': true,
'=': true,
'|': true,
'{': true,
'}': true,
'.': true,
'!': true,
'\\': true,
}
// entityPattern describes one Telegram MarkdownV2 inline entity type.
type entityPattern struct {
re *regexp.Regexp
open string
close string
}
// allEntityPatterns lists every recognized entity in priority order
// (longer / more-specific delimiters first so they win over shorter ones).
// Each entry's regex is anchored to find the first occurrence in a string.
var allEntityPatterns = []entityPattern{
// fenced code block — content is completely verbatim
{re: regexp.MustCompile("(?s)```(?:[\\w]*\\n)?[\\s\\S]*?```"), open: "```", close: "```"},
// inline code — content is completely verbatim
{re: regexp.MustCompile("`(?:[^`\\\n]|\\\\.)*`"), open: "`", close: "`"},
// expandable block-quote opener **>…
{re: regexp.MustCompile(`(?m)\*\*>(?:[^\n]*)`), open: "**>", close: ""},
// block-quote line >…
{re: regexp.MustCompile(`(?m)^>(?:[^\n]*)`), open: ">", close: ""},
// custom emoji / timestamp ![…](…) — must come before plain link
{re: regexp.MustCompile(`!\[[^\]]*\]\([^)]*\)`), open: "!", close: ""},
// inline URL / user mention […](…)
{re: regexp.MustCompile(`\[[^\]]*\]\([^)]*\)`), open: "[", close: ""},
// spoiler ||…|| — before single | so it wins
{re: regexp.MustCompile(`\|\|(?:[^|\\\n]|\\.)*\|\|`), open: "||", close: "||"},
// underline __…__ — before single _ so it wins
{re: regexp.MustCompile(`__(?:[^_\\\n]|\\.)*__`), open: "__", close: "__"},
// bold *…*
{re: regexp.MustCompile(`\*(?:[^*\\\n]|\\.)*\*`), open: "*", close: "*"},
// italic _…_
{re: regexp.MustCompile(`_(?:[^_\\\n]|\\.)*_`), open: "_", close: "_"},
// strikethrough ~…~
{re: regexp.MustCompile(`~(?:[^~\\\n]|\\.)*~`), open: "~", close: "~"},
}
// verbatimEntities are entity types whose inner content must never be
// touched (code blocks, URLs, quotes, custom emoji).
// Their content is passed through completely unchanged.
var verbatimEntities = map[string]bool{
"```": true,
"`": true,
"**>": true,
">": true,
"!": true,
"[": true,
}
// markdownToTelegramMarkdownV2 converts a Markdown string into a string safe
// for sending with Telegram's MarkdownV2 parse mode.
//
// Rules:
// - Markdown headings (# … ######) are converted to *bold*.
// - **bold** Markdown syntax is converted to *bold*.
// - Recognized Telegram MarkdownV2 entity spans are preserved; their inner
// content is processed recursively so that nested valid entities are kept
// intact while stray special characters are escaped.
// - All plain-text segments have their MarkdownV2 special characters escaped.
//
// Reference: https://core.telegram.org/bots/api#formatting-options
func markdownToTelegramMarkdownV2(text string) string {
// 1. Convert Markdown headings → *escaped heading text*
text = reHeading.ReplaceAllStringFunc(text, func(match string) string {
sub := reHeading.FindStringSubmatch(match)
if len(sub) < 2 {
return match
}
// The heading content is fresh plain text — escape everything
// including * so the resulting *…* bold span stays valid.
return "*" + escapeMarkdownV2(sub[1]) + "*"
})
// 2. Convert **bold** → *bold*
text = reBoldStar.ReplaceAllString(text, "*$1*")
// 3. Recursively escape the full string.
return processText(text)
}
// processText walks `text`, finds the leftmost / longest matching entity,
// escapes the gap before it, processes the entity (recursing into its inner
// content when appropriate), then continues with the remainder.
func processText(text string) string {
if text == "" {
return ""
}
// Find the leftmost match among all entity patterns.
bestStart := -1
bestEnd := -1
var bestPat *entityPattern
for i := range allEntityPatterns {
p := &allEntityPatterns[i]
loc := p.re.FindStringIndex(text)
if loc == nil {
continue
}
if bestStart == -1 || loc[0] < bestStart ||
(loc[0] == bestStart && (loc[1]-loc[0]) > (bestEnd-bestStart)) {
bestStart = loc[0]
bestEnd = loc[1]
bestPat = p
}
}
if bestPat == nil {
// No entity found — escape everything.
return escapeMarkdownV2(text)
}
var b strings.Builder
// Plain text before the entity.
if bestStart > 0 {
b.WriteString(escapeMarkdownV2(text[:bestStart]))
}
// The matched entity span.
matched := text[bestStart:bestEnd]
if verbatimEntities[bestPat.open] {
// Code blocks, URLs, quotes: pass through completely untouched.
b.WriteString(matched)
} else {
// Inline formatting (bold, italic, underline, strikethrough, spoiler):
// keep the delimiters and recursively process the inner content so that
// nested entities survive but stray specials get escaped.
openLen := len(bestPat.open)
closeLen := len(bestPat.close)
inner := matched[openLen : len(matched)-closeLen]
b.WriteString(bestPat.open)
b.WriteString(processText(inner))
b.WriteString(bestPat.close)
}
// Continue with the remainder of the string.
b.WriteString(processText(text[bestEnd:]))
return b.String()
}
// escapeMarkdownV2 escapes every MarkdownV2 special character in a plain-text
// segment (i.e. a segment that is not part of any recognized entity).
// Already-escaped sequences (backslash + char) are forwarded verbatim to avoid
// double-escaping.
func escapeMarkdownV2(s string) string {
var b strings.Builder
b.Grow(len(s) + 8)
runes := []rune(s)
for i := 0; i < len(runes); i++ {
ch := runes[i]
// Forward an existing escape sequence verbatim.
if ch == '\\' && i+1 < len(runes) {
b.WriteRune(ch)
b.WriteRune(runes[i+1])
i++
continue
}
if mdV2SpecialChars[ch] {
b.WriteByte('\\')
}
b.WriteRune(ch)
}
return b.String()
}

View file

@ -1,68 +0,0 @@
package telegram
import (
_ "embed"
"testing"
"github.com/stretchr/testify/require"
)
//go:embed testdata/md2_all_formats.txt
var md2AllFormats string
func Test_markdownToTelegramMarkdownV2(t *testing.T) {
cases := []struct {
name string
input string
expected string
}{
{
name: "heading -> bolding",
input: `## HeadingH2 #`,
expected: "*HeadingH2 \\#*",
},
{
name: "strikethrough",
input: "~strikethroughMD~",
expected: "~strikethroughMD~",
},
{
name: "inline URL",
input: "[inline URL](http://www.example.com/)",
expected: "[inline URL](http://www.example.com/)",
},
{
name: "all telegram formats",
input: md2AllFormats,
expected: md2AllFormats,
},
{
name: "empty",
input: "",
expected: "",
},
{
name: "one letter",
input: "o",
expected: "o",
},
{
name: "",
input: "*Last update: ~10 24h*",
expected: "*Last update: \\~10 24h*",
},
{
name: "",
input: "<Market Capitalization>",
expected: "\\<Market Capitalization\\>",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
actual := markdownToTelegramMarkdownV2(tc.input)
require.EqualValues(t, tc.expected, actual)
})
}
}

View file

@ -1,111 +0,0 @@
package telegram
import (
"fmt"
"strings"
)
func markdownToTelegramHTML(text string) string {
if text == "" {
return ""
}
codeBlocks := extractCodeBlocks(text)
text = codeBlocks.text
inlineCodes := extractInlineCodes(text)
text = inlineCodes.text
text = reHeading.ReplaceAllString(text, "$1")
text = reBlockquote.ReplaceAllString(text, "$1")
text = escapeHTML(text)
text = reLink.ReplaceAllString(text, `<a href="$2">$1</a>`)
text = reBoldStar.ReplaceAllString(text, "<b>$1</b>")
text = reBoldUnder.ReplaceAllString(text, "<b>$1</b>")
text = reItalic.ReplaceAllStringFunc(text, func(s string) string {
match := reItalic.FindStringSubmatch(s)
if len(match) < 2 {
return s
}
return "<i>" + match[1] + "</i>"
})
text = reStrike.ReplaceAllString(text, "<s>$1</s>")
text = reListItem.ReplaceAllString(text, "• ")
for i, code := range inlineCodes.codes {
escaped := escapeHTML(code)
text = strings.ReplaceAll(text, fmt.Sprintf("\x00IC%d\x00", i), fmt.Sprintf("<code>%s</code>", escaped))
}
for i, code := range codeBlocks.codes {
escaped := escapeHTML(code)
text = strings.ReplaceAll(
text,
fmt.Sprintf("\x00CB%d\x00", i),
fmt.Sprintf("<pre><code>%s</code></pre>", escaped),
)
}
return text
}
type codeBlockMatch struct {
text string
codes []string
}
func extractCodeBlocks(text string) codeBlockMatch {
matches := reCodeBlock.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches))
for _, match := range matches {
codes = append(codes, match[1])
}
i := 0
text = reCodeBlock.ReplaceAllStringFunc(text, func(m string) string {
placeholder := fmt.Sprintf("\x00CB%d\x00", i)
i++
return placeholder
})
return codeBlockMatch{text: text, codes: codes}
}
type inlineCodeMatch struct {
text string
codes []string
}
func extractInlineCodes(text string) inlineCodeMatch {
matches := reInlineCode.FindAllStringSubmatch(text, -1)
codes := make([]string, 0, len(matches))
for _, match := range matches {
codes = append(codes, match[1])
}
i := 0
text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
placeholder := fmt.Sprintf("\x00IC%d\x00", i)
i++
return placeholder
})
return inlineCodeMatch{text: text, codes: codes}
}
func escapeHTML(text string) string {
text = strings.ReplaceAll(text, "&", "&amp;")
text = strings.ReplaceAll(text, "<", "&lt;")
text = strings.ReplaceAll(text, ">", "&gt;")
return text
}

View file

@ -0,0 +1,492 @@
package telegram
import (
"strings"
"github.com/gomarkdown/markdown"
"github.com/gomarkdown/markdown/ast"
"github.com/gomarkdown/markdown/parser"
)
// parseMarkdownAST parses standard Markdown text into a gomarkdown AST.
func parseMarkdownAST(text string) ast.Node {
extensions := parser.CommonExtensions | parser.AutoHeadingIDs |
parser.Strikethrough | parser.Tables | parser.FencedCode
p := parser.NewWithExtensions(extensions)
return markdown.Parse([]byte(text), p)
}
// telegramRenderer walks a gomarkdown AST and produces Telegram-compatible
// HTML or MarkdownV2 output.
type telegramRenderer struct {
buf strings.Builder
mdv2 bool // true → MarkdownV2, false → HTML
}
// render produces the final formatted string from an AST root node.
func (r *telegramRenderer) render(doc ast.Node) string {
r.buf.Reset()
r.walkChildren(doc)
return strings.TrimRight(r.buf.String(), "\n")
}
// walkChildren iterates over direct children of a node.
func (r *telegramRenderer) walkChildren(node ast.Node) {
for _, child := range node.GetChildren() {
r.renderNode(child)
}
}
// renderNode dispatches rendering for a single AST node.
func (r *telegramRenderer) renderNode(node ast.Node) {
switch n := node.(type) {
case *ast.Document:
r.walkChildren(n)
case *ast.Paragraph:
r.renderParagraph(n)
case *ast.Heading:
r.renderHeading(n)
case *ast.CodeBlock:
r.renderCodeBlock(n)
case *ast.BlockQuote:
r.renderBlockQuote(n)
case *ast.List:
r.renderList(n)
case *ast.ListItem:
r.walkChildren(n)
case *ast.Table:
r.renderTable(n)
case *ast.HorizontalRule:
r.buf.WriteString("———\n")
case *ast.Text:
r.renderText(n)
case *ast.Strong:
r.renderStrong(n)
case *ast.Emph:
r.renderEmph(n)
case *ast.Del:
r.renderDel(n)
case *ast.Code:
r.renderInlineCode(n)
case *ast.Link:
r.renderLink(n)
case *ast.Image:
r.renderImage(n)
case *ast.Softbreak:
r.buf.WriteByte('\n')
case *ast.Hardbreak:
r.buf.WriteByte('\n')
case *ast.HTMLSpan:
// Pass through raw HTML spans (e.g. <br>)
r.writeEscaped(string(n.Literal))
case *ast.HTMLBlock:
r.writeEscaped(string(n.Literal))
default:
// Fallback: render children if any
r.walkChildren(node)
}
}
// --- Block-level renderers ---
func (r *telegramRenderer) renderParagraph(n *ast.Paragraph) {
// Don't add newline before the very first output.
if r.buf.Len() > 0 {
r.buf.WriteByte('\n')
}
r.walkChildren(n)
r.buf.WriteByte('\n')
}
func (r *telegramRenderer) renderHeading(n *ast.Heading) {
if r.buf.Len() > 0 {
r.buf.WriteByte('\n')
}
if r.mdv2 {
r.buf.WriteString("*")
r.walkChildren(n)
r.buf.WriteString("*")
} else {
r.buf.WriteString("<b>")
r.walkChildren(n)
r.buf.WriteString("</b>")
}
r.buf.WriteByte('\n')
}
func (r *telegramRenderer) renderCodeBlock(n *ast.CodeBlock) {
if r.buf.Len() > 0 {
r.buf.WriteByte('\n')
}
lang := strings.TrimSpace(string(n.Info))
content := string(n.Literal)
// Remove trailing newline from code content (gomarkdown includes it).
content = strings.TrimRight(content, "\n")
if r.mdv2 {
r.buf.WriteString("```")
if lang != "" {
r.buf.WriteString(lang)
}
r.buf.WriteByte('\n')
r.buf.WriteString(content)
r.buf.WriteString("\n```\n")
} else {
if lang != "" {
r.buf.WriteString("<pre><code class=\"language-")
r.buf.WriteString(escapeHTML(lang))
r.buf.WriteString("\">")
} else {
r.buf.WriteString("<pre><code>")
}
r.buf.WriteString(escapeHTML(content))
r.buf.WriteString("</code></pre>\n")
}
}
func (r *telegramRenderer) renderBlockQuote(n *ast.BlockQuote) {
if r.buf.Len() > 0 {
r.buf.WriteByte('\n')
}
// Render children to a sub-renderer to get the text, then prefix lines.
sub := &telegramRenderer{mdv2: r.mdv2}
sub.walkChildren(n)
text := strings.TrimRight(sub.buf.String(), "\n")
if r.mdv2 {
for i, line := range strings.Split(text, "\n") {
if i > 0 {
r.buf.WriteByte('\n')
}
r.buf.WriteString(">")
r.buf.WriteString(line)
}
} else {
r.buf.WriteString("<blockquote>")
r.buf.WriteString(text)
r.buf.WriteString("</blockquote>")
}
r.buf.WriteByte('\n')
}
func (r *telegramRenderer) renderList(n *ast.List) {
if r.buf.Len() > 0 {
r.buf.WriteByte('\n')
}
ordered := (n.ListFlags & ast.ListTypeOrdered) != 0
counter := 1
for _, item := range n.GetChildren() {
li, ok := item.(*ast.ListItem)
if !ok {
continue
}
if ordered {
num := itoa(counter)
if r.mdv2 {
r.buf.WriteString(escapeMarkdownV2(num))
r.buf.WriteString("\\. ")
} else {
r.buf.WriteString(num)
r.buf.WriteString(". ")
}
counter++
} else {
r.buf.WriteString("• ")
}
// Render list item children inline (strip paragraph wrapping).
r.renderListItemChildren(li)
r.buf.WriteByte('\n')
}
}
// renderListItemChildren renders the children of a list item, flattening
// single-paragraph items to avoid extra newlines.
func (r *telegramRenderer) renderListItemChildren(li *ast.ListItem) {
children := li.GetChildren()
if len(children) == 1 {
if p, ok := children[0].(*ast.Paragraph); ok {
// Single paragraph — render its children directly.
r.walkChildren(p)
return
}
}
// Multiple children or non-paragraph: render normally.
for _, child := range children {
if p, ok := child.(*ast.Paragraph); ok {
r.walkChildren(p)
} else {
r.renderNode(child)
}
}
}
func (r *telegramRenderer) renderTable(n *ast.Table) {
if r.buf.Len() > 0 {
r.buf.WriteByte('\n')
}
td := r.extractTableData(n)
if tableWidth(td) <= tableMaxMonoWidth {
mono := renderTableMono(td)
if r.mdv2 {
r.buf.WriteString("```\n")
r.buf.WriteString(mono)
r.buf.WriteString("\n```\n")
} else {
r.buf.WriteString("<pre>")
r.buf.WriteString(escapeHTML(mono))
r.buf.WriteString("</pre>\n")
}
} else {
if r.mdv2 {
r.buf.WriteString(renderTableAsListMDV2(td))
} else {
r.buf.WriteString(renderTableAsListHTML(td))
}
r.buf.WriteByte('\n')
}
}
// extractTableData converts a gomarkdown ast.Table into our tableData struct.
func (r *telegramRenderer) extractTableData(n *ast.Table) tableData {
var td tableData
for _, child := range n.GetChildren() {
switch section := child.(type) {
case *ast.TableHeader:
for _, row := range section.GetChildren() {
if tr, ok := row.(*ast.TableRow); ok {
for _, cell := range tr.GetChildren() {
if tc, ok := cell.(*ast.TableCell); ok {
td.headers = append(td.headers, r.cellText(tc))
}
}
}
}
case *ast.TableBody:
for _, row := range section.GetChildren() {
if tr, ok := row.(*ast.TableRow); ok {
var rowCells []string
for _, cell := range tr.GetChildren() {
if tc, ok := cell.(*ast.TableCell); ok {
rowCells = append(rowCells, r.cellText(tc))
}
}
td.rows = append(td.rows, rowCells)
}
}
}
}
return td
}
// cellText extracts plain text from a table cell, stripping inline markup.
func (r *telegramRenderer) cellText(cell *ast.TableCell) string {
var b strings.Builder
plainTextWalk(&b, cell)
return strings.TrimSpace(b.String())
}
// plainTextWalk recursively extracts plain text from an AST subtree.
func plainTextWalk(b *strings.Builder, node ast.Node) {
if t, ok := node.(*ast.Text); ok {
b.Write(t.Literal)
return
}
if c, ok := node.(*ast.Code); ok {
b.Write(c.Literal)
return
}
for _, child := range node.GetChildren() {
plainTextWalk(b, child)
}
}
// --- Inline renderers ---
func (r *telegramRenderer) renderText(n *ast.Text) {
text := string(n.Literal)
r.writeEscaped(text)
}
func (r *telegramRenderer) renderStrong(n *ast.Strong) {
if r.mdv2 {
r.buf.WriteString("*")
r.walkChildren(n)
r.buf.WriteString("*")
} else {
r.buf.WriteString("<b>")
r.walkChildren(n)
r.buf.WriteString("</b>")
}
}
func (r *telegramRenderer) renderEmph(n *ast.Emph) {
if r.mdv2 {
r.buf.WriteString("_")
r.walkChildren(n)
r.buf.WriteString("_")
} else {
r.buf.WriteString("<i>")
r.walkChildren(n)
r.buf.WriteString("</i>")
}
}
func (r *telegramRenderer) renderDel(n *ast.Del) {
if r.mdv2 {
r.buf.WriteString("~")
r.walkChildren(n)
r.buf.WriteString("~")
} else {
r.buf.WriteString("<s>")
r.walkChildren(n)
r.buf.WriteString("</s>")
}
}
func (r *telegramRenderer) renderInlineCode(n *ast.Code) {
content := string(n.Literal)
if r.mdv2 {
r.buf.WriteString("`")
r.buf.WriteString(content)
r.buf.WriteString("`")
} else {
r.buf.WriteString("<code>")
r.buf.WriteString(escapeHTML(content))
r.buf.WriteString("</code>")
}
}
func (r *telegramRenderer) renderLink(n *ast.Link) {
url := string(n.Destination)
if r.mdv2 {
r.buf.WriteString("[")
r.walkChildren(n)
r.buf.WriteString("](")
r.buf.WriteString(url)
r.buf.WriteString(")")
} else {
r.buf.WriteString(`<a href="`)
r.buf.WriteString(escapeHTML(url))
r.buf.WriteString(`">`)
r.walkChildren(n)
r.buf.WriteString("</a>")
}
}
func (r *telegramRenderer) renderImage(n *ast.Image) {
// Telegram doesn't support inline images.
// For tg:// URLs, pass through as-is for MarkdownV2 compatibility.
url := string(n.Destination)
if r.mdv2 && strings.HasPrefix(url, "tg://") {
r.buf.WriteString("![")
// Render alt text children
for _, child := range n.GetChildren() {
if t, ok := child.(*ast.Text); ok {
r.buf.Write(t.Literal)
}
}
r.buf.WriteString("](")
r.buf.WriteString(url)
r.buf.WriteString(")")
} else {
// For non-Telegram images, just output the alt text.
for _, child := range n.GetChildren() {
if t, ok := child.(*ast.Text); ok {
r.writeEscaped(string(t.Literal))
}
}
}
}
// writeEscaped writes text with the appropriate escaping for the current mode.
func (r *telegramRenderer) writeEscaped(text string) {
if r.mdv2 {
r.buf.WriteString(escapeMarkdownV2(text))
} else {
r.buf.WriteString(escapeHTML(text))
}
}
// mdV2SpecialChars are all characters that must be escaped in Telegram MarkdownV2.
var mdV2SpecialChars = map[rune]bool{
'*': true,
'_': true,
'[': true,
']': true,
'(': true,
')': true,
'~': true,
'`': true,
'>': true,
'<': true,
'#': true,
'+': true,
'-': true,
'=': true,
'|': true,
'{': true,
'}': true,
'.': true,
'!': true,
'\\': true,
}
// escapeMarkdownV2 escapes every MarkdownV2 special character in a plain-text
// segment. Already-escaped sequences (backslash + char) are forwarded verbatim.
func escapeMarkdownV2(s string) string {
var b strings.Builder
b.Grow(len(s) + 8)
runes := []rune(s)
for i := 0; i < len(runes); i++ {
ch := runes[i]
if ch == '\\' && i+1 < len(runes) {
b.WriteRune(ch)
b.WriteRune(runes[i+1])
i++
continue
}
if mdV2SpecialChars[ch] {
b.WriteByte('\\')
}
b.WriteRune(ch)
}
return b.String()
}
// escapeHTML escapes &, <, > for Telegram HTML mode.
func escapeHTML(text string) string {
text = strings.ReplaceAll(text, "&", "&amp;")
text = strings.ReplaceAll(text, "<", "&lt;")
text = strings.ReplaceAll(text, ">", "&gt;")
text = strings.ReplaceAll(text, "'", "&#39;")
return text
}

View file

@ -0,0 +1,303 @@
package telegram
import (
"testing"
"github.com/stretchr/testify/assert"
)
func renderHTML(input string) string {
doc := parseMarkdownAST(input)
r := &telegramRenderer{mdv2: false}
return r.render(doc)
}
func renderMDV2(input string) string {
doc := parseMarkdownAST(input)
r := &telegramRenderer{mdv2: true}
return r.render(doc)
}
// --- HTML mode tests ---
func TestRenderHTML_PlainText(t *testing.T) {
assert.Equal(t, "Hello, world!", renderHTML("Hello, world!"))
}
func TestRenderHTML_Bold(t *testing.T) {
assert.Equal(t, "<b>bold</b>", renderHTML("**bold**"))
}
func TestRenderHTML_Italic(t *testing.T) {
assert.Equal(t, "<i>italic</i>", renderHTML("*italic*"))
}
func TestRenderHTML_BoldItalic(t *testing.T) {
got := renderHTML("***bold italic***")
assert.Contains(t, got, "<b>")
assert.Contains(t, got, "<i>")
}
func TestRenderHTML_Strikethrough(t *testing.T) {
assert.Equal(t, "<s>strike</s>", renderHTML("~~strike~~"))
}
func TestRenderHTML_InlineCode(t *testing.T) {
assert.Equal(t, "<code>code</code>", renderHTML("`code`"))
}
func TestRenderHTML_InlineCode_HTMLEscape(t *testing.T) {
assert.Equal(t, "<code>&lt;div&gt;</code>", renderHTML("`<div>`"))
}
func TestRenderHTML_CodeBlock(t *testing.T) {
input := "```\nfoo\nbar\n```"
got := renderHTML(input)
assert.Contains(t, got, "<pre><code>")
assert.Contains(t, got, "foo\nbar")
assert.Contains(t, got, "</code></pre>")
}
func TestRenderHTML_CodeBlock_WithLanguage(t *testing.T) {
input := "```python\nprint('hello')\n```"
got := renderHTML(input)
assert.Contains(t, got, `class="language-python"`)
assert.Contains(t, got, "print(&#39;hello&#39;)")
}
func TestRenderHTML_Heading(t *testing.T) {
assert.Equal(t, "<b>Hello</b>", renderHTML("# Hello"))
}
func TestRenderHTML_HeadingH2(t *testing.T) {
assert.Equal(t, "<b>Sub heading</b>", renderHTML("## Sub heading"))
}
func TestRenderHTML_Link(t *testing.T) {
got := renderHTML("[click here](https://example.com)")
assert.Equal(t, `<a href="https://example.com">click here</a>`, got)
}
func TestRenderHTML_UnorderedList(t *testing.T) {
input := "- item one\n- item two\n- item three"
got := renderHTML(input)
assert.Contains(t, got, "• item one")
assert.Contains(t, got, "• item two")
assert.Contains(t, got, "• item three")
}
func TestRenderHTML_OrderedList(t *testing.T) {
input := "1. first\n2. second\n3. third"
got := renderHTML(input)
assert.Contains(t, got, "1. first")
assert.Contains(t, got, "2. second")
assert.Contains(t, got, "3. third")
}
func TestRenderHTML_Blockquote(t *testing.T) {
input := "> quoted text"
got := renderHTML(input)
assert.Contains(t, got, "<blockquote>")
assert.Contains(t, got, "quoted text")
assert.Contains(t, got, "</blockquote>")
}
func TestRenderHTML_HorizontalRule(t *testing.T) {
input := "before\n\n---\n\nafter"
got := renderHTML(input)
assert.Contains(t, got, "———")
}
func TestRenderHTML_HTMLEscaping(t *testing.T) {
got := renderHTML("a < b & c > d")
assert.Contains(t, got, "&lt;")
assert.Contains(t, got, "&amp;")
assert.Contains(t, got, "&gt;")
}
func TestRenderHTML_Table_Narrow(t *testing.T) {
input := "| A | B |\n|---|---|\n| 1 | 2 |"
got := renderHTML(input)
assert.Contains(t, got, "<pre>")
assert.Contains(t, got, "A")
assert.Contains(t, got, "B")
assert.Contains(t, got, "1")
assert.Contains(t, got, "2")
}
func TestRenderHTML_Table_Wide(t *testing.T) {
input := "| Very Long Header Name | Another Long Header |\n|---|---|\n| Some long cell value here | Another long value here too |"
got := renderHTML(input)
// Wide table should fall back to list format
assert.Contains(t, got, "<b>Row 1:</b>")
assert.Contains(t, got, "• Very Long Header Name:")
}
func TestRenderHTML_Image_AltText(t *testing.T) {
got := renderHTML("![alt text](https://example.com/img.png)")
assert.Contains(t, got, "alt text")
assert.NotContains(t, got, "<img")
}
func TestRenderHTML_Empty(t *testing.T) {
assert.Equal(t, "", renderHTML(""))
}
func TestRenderHTML_MultipleParagraphs(t *testing.T) {
input := "First paragraph.\n\nSecond paragraph."
got := renderHTML(input)
assert.Contains(t, got, "First paragraph.")
assert.Contains(t, got, "Second paragraph.")
}
// --- MarkdownV2 mode tests ---
func TestRenderMDV2_PlainText(t *testing.T) {
assert.Equal(t, "Hello, world\\!", renderMDV2("Hello, world!"))
}
func TestRenderMDV2_Bold(t *testing.T) {
assert.Equal(t, "*bold*", renderMDV2("**bold**"))
}
func TestRenderMDV2_Italic(t *testing.T) {
assert.Equal(t, "_italic_", renderMDV2("*italic*"))
}
func TestRenderMDV2_Strikethrough(t *testing.T) {
assert.Equal(t, "~strike~", renderMDV2("~~strike~~"))
}
func TestRenderMDV2_InlineCode(t *testing.T) {
assert.Equal(t, "`code`", renderMDV2("`code`"))
}
func TestRenderMDV2_CodeBlock(t *testing.T) {
input := "```\nfoo\n```"
got := renderMDV2(input)
assert.Contains(t, got, "```\nfoo\n```")
}
func TestRenderMDV2_CodeBlock_WithLanguage(t *testing.T) {
input := "```python\nprint('hello')\n```"
got := renderMDV2(input)
assert.Contains(t, got, "```python\n")
assert.Contains(t, got, "print('hello')")
}
func TestRenderMDV2_Heading(t *testing.T) {
assert.Equal(t, "*Hello*", renderMDV2("# Hello"))
}
func TestRenderMDV2_Link(t *testing.T) {
got := renderMDV2("[click](https://example.com)")
assert.Equal(t, "[click](https://example.com)", got)
}
func TestRenderMDV2_UnorderedList(t *testing.T) {
input := "- item one\n- item two"
got := renderMDV2(input)
assert.Contains(t, got, "• item one")
assert.Contains(t, got, "• item two")
}
func TestRenderMDV2_OrderedList(t *testing.T) {
input := "1. first\n2. second"
got := renderMDV2(input)
assert.Contains(t, got, "1\\. first")
assert.Contains(t, got, "2\\. second")
}
func TestRenderMDV2_Blockquote(t *testing.T) {
input := "> quoted text"
got := renderMDV2(input)
assert.Contains(t, got, ">")
assert.Contains(t, got, "quoted text")
}
func TestRenderMDV2_Escaping(t *testing.T) {
got := renderMDV2("price is 10.99!")
assert.Contains(t, got, "10\\.99\\!")
}
func TestRenderMDV2_Table_Narrow(t *testing.T) {
input := "| A | B |\n|---|---|\n| 1 | 2 |"
got := renderMDV2(input)
assert.Contains(t, got, "```\n")
}
func TestRenderMDV2_Table_Wide(t *testing.T) {
input := "| Very Long Header Name | Another Long Header |\n|---|---|\n| Some long cell value here | Another long value here too |"
got := renderMDV2(input)
assert.Contains(t, got, "*Row 1:*")
}
func TestRenderMDV2_Image_TgEmoji(t *testing.T) {
// tg:// URLs should be passed through verbatim in MDV2
input := "![👍](tg://emoji?id=5368324170671202286)"
got := renderMDV2(input)
assert.Equal(t, "![👍](tg://emoji?id=5368324170671202286)", got)
}
func TestRenderMDV2_HorizontalRule(t *testing.T) {
input := "before\n\n---\n\nafter"
got := renderMDV2(input)
assert.Contains(t, got, "———")
}
// --- Integration / edge cases ---
func TestRender_NestedBoldItalic(t *testing.T) {
input := "***bold and italic***"
htmlGot := renderHTML(input)
assert.Contains(t, htmlGot, "<b>")
assert.Contains(t, htmlGot, "<i>")
mdv2Got := renderMDV2(input)
assert.Contains(t, mdv2Got, "*")
assert.Contains(t, mdv2Got, "_")
}
func TestRender_CodeBlockPreservesContent(t *testing.T) {
input := "```\n**not bold** <html>\n```"
htmlGot := renderHTML(input)
assert.Contains(t, htmlGot, "**not bold**")
assert.Contains(t, htmlGot, "&lt;html&gt;")
}
func TestRender_Table_WithInlineMarkup(t *testing.T) {
input := "| **Bold** | `code` |\n|---|---|\n| [link](url) | ~~strike~~ |"
htmlGot := renderHTML(input)
// Table cells should be plain text
assert.Contains(t, htmlGot, "Bold")
assert.Contains(t, htmlGot, "code")
}
func TestRender_Table_EmptyCells(t *testing.T) {
input := "| A | B |\n|---|---|\n| | 2 |"
htmlGot := renderHTML(input)
assert.Contains(t, htmlGot, "2")
}
func TestRender_Table_CJK(t *testing.T) {
input := "| 名前 | バージョン |\n|---|---|\n| Go | 1.22 |"
htmlGot := renderHTML(input)
assert.Contains(t, htmlGot, "名前")
assert.Contains(t, htmlGot, "Go")
}
func TestParseContent_HTML(t *testing.T) {
got := parseContent("**hello** world", false)
assert.Contains(t, got, "<b>hello</b>")
assert.Contains(t, got, "world")
}
func TestParseContent_MDV2(t *testing.T) {
got := parseContent("**hello** world", true)
assert.Contains(t, got, "*hello*")
}
func TestParseContent_Empty(t *testing.T) {
assert.Equal(t, "", parseContent("", false))
assert.Equal(t, "", parseContent("", true))
}

View file

@ -0,0 +1,193 @@
package telegram
import (
"strings"
"unicode"
)
const tableMaxMonoWidth = 40
// runeWidth returns the display width of a rune (CJK = 2, others = 1).
func runeWidth(r rune) int {
if unicode.Is(unicode.Han, r) ||
unicode.Is(unicode.Hangul, r) ||
// Hiragana, Katakana, CJK symbols & punctuation (includes ー U+30FC)
(r >= 0x3000 && r <= 0x30FF) ||
// Fullwidth forms
(r >= 0xFF01 && r <= 0xFF60) ||
(r >= 0xFFE0 && r <= 0xFFE6) {
return 2
}
return 1
}
// stringWidth returns the total display width of s in half-width units.
func stringWidth(s string) int {
w := 0
for _, r := range s {
w += runeWidth(r)
}
return w
}
// padRight pads s with spaces to reach targetWidth display units.
func padRight(s string, targetWidth int) string {
gap := targetWidth - stringWidth(s)
if gap <= 0 {
return s
}
return s + strings.Repeat(" ", gap)
}
// tableData holds parsed table information for rendering.
type tableData struct {
headers []string
rows [][]string
}
// renderTableMono renders a table in monospace format.
// Output is plain text (no HTML/MDV2 escaping); the caller wraps it.
func renderTableMono(td tableData) string {
numCols := len(td.headers)
if numCols == 0 {
return ""
}
// Compute column widths.
colWidths := make([]int, numCols)
for i, h := range td.headers {
colWidths[i] = stringWidth(h)
}
for _, row := range td.rows {
for i := 0; i < numCols && i < len(row); i++ {
w := stringWidth(row[i])
if w > colWidths[i] {
colWidths[i] = w
}
}
}
var b strings.Builder
// Header row.
for i, h := range td.headers {
if i > 0 {
b.WriteString(" | ")
}
b.WriteString(padRight(h, colWidths[i]))
}
b.WriteByte('\n')
// Separator row.
for i, w := range colWidths {
if i > 0 {
b.WriteString("-+-")
}
b.WriteString(strings.Repeat("-", w))
}
// Data rows.
for _, row := range td.rows {
b.WriteByte('\n')
for i := 0; i < numCols; i++ {
if i > 0 {
b.WriteString(" | ")
}
cell := ""
if i < len(row) {
cell = row[i]
}
b.WriteString(padRight(cell, colWidths[i]))
}
}
return b.String()
}
// renderTableAsListHTML renders a wide table as a bulleted list in HTML.
func renderTableAsListHTML(td tableData) string {
var b strings.Builder
for i, row := range td.rows {
if i > 0 {
b.WriteByte('\n')
}
b.WriteString("<b>Row ")
b.WriteString(itoa(i + 1))
b.WriteString(":</b>\n")
for j, h := range td.headers {
cell := ""
if j < len(row) {
cell = row[j]
}
b.WriteString("• ")
b.WriteString(escapeHTML(h))
b.WriteString(": ")
b.WriteString(escapeHTML(cell))
b.WriteByte('\n')
}
}
return strings.TrimRight(b.String(), "\n")
}
// renderTableAsListMDV2 renders a wide table as a bulleted list in MarkdownV2.
func renderTableAsListMDV2(td tableData) string {
var b strings.Builder
for i, row := range td.rows {
if i > 0 {
b.WriteByte('\n')
}
b.WriteString("*Row ")
b.WriteString(escapeMarkdownV2(itoa(i + 1)))
b.WriteString(":*\n")
for j, h := range td.headers {
cell := ""
if j < len(row) {
cell = row[j]
}
b.WriteString("• ")
b.WriteString(escapeMarkdownV2(h))
b.WriteString(": ")
b.WriteString(escapeMarkdownV2(cell))
b.WriteByte('\n')
}
}
return strings.TrimRight(b.String(), "\n")
}
// tableWidth returns the total display width of a mono-rendered table.
func tableWidth(td tableData) int {
numCols := len(td.headers)
if numCols == 0 {
return 0
}
colWidths := make([]int, numCols)
for i, h := range td.headers {
colWidths[i] = stringWidth(h)
}
for _, row := range td.rows {
for i := 0; i < numCols && i < len(row); i++ {
w := stringWidth(row[i])
if w > colWidths[i] {
colWidths[i] = w
}
}
}
total := 0
for _, w := range colWidths {
total += w
}
// Add separators: " | " = 3 chars per gap
total += 3 * (numCols - 1)
return total
}
// itoa is a small helper to avoid importing strconv for int→string.
func itoa(n int) string {
if n < 10 {
return string(rune('0' + n))
}
// Simple recursive for small numbers (row counts are always small).
return itoa(n/10) + string(rune('0'+n%10))
}

View file

@ -0,0 +1,159 @@
package telegram
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestRuneWidth(t *testing.T) {
assert.Equal(t, 1, runeWidth('a'))
assert.Equal(t, 1, runeWidth('1'))
assert.Equal(t, 2, runeWidth('名'))
assert.Equal(t, 2, runeWidth('ア'))
assert.Equal(t, 2, runeWidth('あ'))
}
func TestStringWidth(t *testing.T) {
assert.Equal(t, 5, stringWidth("hello"))
assert.Equal(t, 4, stringWidth("名前")) // 2+2
assert.Equal(t, 4, stringWidth("Go言")) // 1+1+2
}
func TestRenderTableMono(t *testing.T) {
td := tableData{
headers: []string{"Name", "Version", "Status"},
rows: [][]string{
{"Go", "1.22", "Active"},
{"Python", "3.12", "Active"},
},
}
got := renderTableMono(td)
expected := "Name | Version | Status\n" +
"-------+---------+-------\n" +
"Go | 1.22 | Active\n" +
"Python | 3.12 | Active"
assert.Equal(t, expected, got)
}
func TestRenderTableMono_SingleColumn(t *testing.T) {
td := tableData{
headers: []string{"Item"},
rows: [][]string{
{"Apple"},
{"Banana"},
},
}
got := renderTableMono(td)
expected := "Item \n" +
"------\n" +
"Apple \n" +
"Banana"
assert.Equal(t, expected, got)
}
func TestRenderTableMono_CJK(t *testing.T) {
td := tableData{
headers: []string{"名前", "バージョン"},
rows: [][]string{
{"Go", "1.22"},
},
}
got := renderTableMono(td)
// "名前" width=4, "バージョン" width=10
// "Go" width=2, "1.22" width=4
expected := "名前 | バージョン\n" +
"-----+-----------\n" +
"Go | 1.22 "
assert.Equal(t, expected, got)
}
func TestRenderTableMono_EmptyCells(t *testing.T) {
td := tableData{
headers: []string{"A", "B"},
rows: [][]string{
{"", "2"},
{"1", ""},
},
}
got := renderTableMono(td)
expected := "A | B\n" +
"--+--\n" +
" | 2\n" +
"1 | "
assert.Equal(t, expected, got)
}
func TestRenderTableAsListHTML(t *testing.T) {
td := tableData{
headers: []string{"Name", "Version"},
rows: [][]string{
{"Go", "1.22"},
{"Python", "3.12"},
},
}
got := renderTableAsListHTML(td)
expected := "<b>Row 1:</b>\n• Name: Go\n• Version: 1.22\n\n" +
"<b>Row 2:</b>\n• Name: Python\n• Version: 3.12"
assert.Equal(t, expected, got)
}
func TestRenderTableAsListHTML_Escaping(t *testing.T) {
td := tableData{
headers: []string{"A&B"},
rows: [][]string{
{"<val>"},
},
}
got := renderTableAsListHTML(td)
assert.Contains(t, got, "A&amp;B")
assert.Contains(t, got, "&lt;val&gt;")
}
func TestRenderTableAsListMDV2(t *testing.T) {
td := tableData{
headers: []string{"Name", "Version"},
rows: [][]string{
{"Go", "1.22"},
},
}
got := renderTableAsListMDV2(td)
expected := "*Row 1:*\n• Name: Go\n• Version: 1\\.22"
assert.Equal(t, expected, got)
}
func TestTableWidth(t *testing.T) {
td := tableData{
headers: []string{"A", "BB", "CCC"},
rows: [][]string{
{"x", "yy", "zzz"},
},
}
// col widths: 1, 2, 3; separators: 3*2=6; total=12
assert.Equal(t, 12, tableWidth(td))
}
func TestTableWidth_CJK(t *testing.T) {
td := tableData{
headers: []string{"名前", "値"},
rows: [][]string{{"Go", "ok"}},
}
// "名前"=4, "値"=2 → max col widths: 4, 2 → total=4+2+3=9
assert.Equal(t, 9, tableWidth(td))
}
func TestItoa(t *testing.T) {
assert.Equal(t, "0", itoa(0))
assert.Equal(t, "1", itoa(1))
assert.Equal(t, "9", itoa(9))
assert.Equal(t, "10", itoa(10))
assert.Equal(t, "42", itoa(42))
assert.Equal(t, "100", itoa(100))
}

View file

@ -26,19 +26,6 @@ import (
"github.com/sipeed/picoclaw/pkg/utils"
)
var (
reHeading = regexp.MustCompile(`(?m)^#{1,6}\s+([^\n]+)`)
reBlockquote = regexp.MustCompile(`^>\s*(.*)$`)
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
reBoldUnder = regexp.MustCompile(`__(.+?)__`)
reItalic = regexp.MustCompile(`_([^_]+)_`)
reStrike = regexp.MustCompile(`~~(.+?)~~`)
reListItem = regexp.MustCompile(`^[-*]\s+`)
reCodeBlock = regexp.MustCompile("```[\\w]*\\n?([\\s\\S]*?)```")
reInlineCode = regexp.MustCompile("`([^`]+)`")
)
type TelegramChannel struct {
*channels.BaseChannel
bot *telego.Bot
@ -713,11 +700,12 @@ func (c *TelegramChannel) downloadFile(ctx context.Context, fileID, ext string)
}
func parseContent(text string, useMarkdownV2 bool) string {
if useMarkdownV2 {
return markdownToTelegramMarkdownV2(text)
if text == "" {
return ""
}
return markdownToTelegramHTML(text)
doc := parseMarkdownAST(text)
r := &telegramRenderer{mdv2: useMarkdownV2}
return r.render(doc)
}
// parseTelegramChatID splits "chatID/threadID" into its components.

View file

@ -22,7 +22,7 @@ func (c *TelegramChannel) SendWithID(ctx context.Context, chatID string, content
return "", fmt.Errorf("invalid chat ID %s: %w", chatID, channels.ErrSendFailed)
}
htmlContent := markdownToTelegramHTML(content)
htmlContent := parseContent(content, false)
tgMsg := tu.Message(tu.ID(cid), htmlContent)
tgMsg.ParseMode = telego.ModeHTML
tgMsg.MessageThreadID = tid
@ -54,7 +54,7 @@ func (c *TelegramChannel) SendDraft(ctx context.Context, chatID string, draftID
if !isLikelyPrivateChatID(cid) && tid == 0 {
return fmt.Errorf("telegram draft unsupported for non-threaded group chat: %w", channels.ErrSendFailed)
}
htmlContent := markdownToTelegramHTML(content)
htmlContent := parseContent(content, false)
params := &telego.SendMessageDraftParams{
ChatID: cid,
MessageThreadID: tid,

View file

@ -358,7 +358,7 @@ func TestSend_MarkdownShortButHTMLLong_MultipleCalls(t *testing.T) {
markdownContent := strings.Repeat("**a** ", 600) // 3600 chars markdown, HTML ~5400+ chars
assert.LessOrEqual(t, len([]rune(markdownContent)), 4000, "markdown content must not exceed chunk size")
htmlExpanded := markdownToTelegramHTML(markdownContent)
htmlExpanded := parseContent(markdownContent, false)
assert.Greater(
t, len([]rune(htmlExpanded)), 4096,
"HTML expansion must exceed Telegram limit for this test to be meaningful",