diff --git a/pkg/channels/telegram/parse_markdown_to_md_v2.go b/pkg/channels/telegram/parse_markdown_to_md_v2.go deleted file mode 100644 index 8cae312c5..000000000 --- a/pkg/channels/telegram/parse_markdown_to_md_v2.go +++ /dev/null @@ -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() -} diff --git a/pkg/channels/telegram/parse_markdown_to_md_v2_test.go b/pkg/channels/telegram/parse_markdown_to_md_v2_test.go deleted file mode 100644 index fd68a9b83..000000000 --- a/pkg/channels/telegram/parse_markdown_to_md_v2_test.go +++ /dev/null @@ -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: "", - expected: "\\", - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - actual := markdownToTelegramMarkdownV2(tc.input) - - require.EqualValues(t, tc.expected, actual) - }) - } -} diff --git a/pkg/channels/telegram/parser_markdown_to_html.go b/pkg/channels/telegram/parser_markdown_to_html.go deleted file mode 100644 index bdaa51807..000000000 --- a/pkg/channels/telegram/parser_markdown_to_html.go +++ /dev/null @@ -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, `$1`) - - text = reBoldStar.ReplaceAllString(text, "$1") - - text = reBoldUnder.ReplaceAllString(text, "$1") - - text = reItalic.ReplaceAllStringFunc(text, func(s string) string { - match := reItalic.FindStringSubmatch(s) - if len(match) < 2 { - return s - } - return "" + match[1] + "" - }) - - text = reStrike.ReplaceAllString(text, "$1") - - 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("%s", escaped)) - } - - for i, code := range codeBlocks.codes { - escaped := escapeHTML(code) - text = strings.ReplaceAll( - text, - fmt.Sprintf("\x00CB%d\x00", i), - fmt.Sprintf("
%s
", 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, "&", "&") - text = strings.ReplaceAll(text, "<", "<") - text = strings.ReplaceAll(text, ">", ">") - return text -} diff --git a/pkg/channels/telegram/render_telegram.go b/pkg/channels/telegram/render_telegram.go new file mode 100644 index 000000000..7763e3bc7 --- /dev/null +++ b/pkg/channels/telegram/render_telegram.go @@ -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.
) + 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("") + r.walkChildren(n) + r.buf.WriteString("") + } + 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("
")
+		} else {
+			r.buf.WriteString("
")
+		}
+		r.buf.WriteString(escapeHTML(content))
+		r.buf.WriteString("
\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("
") + r.buf.WriteString(text) + r.buf.WriteString("
") + } + 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("
")
+			r.buf.WriteString(escapeHTML(mono))
+			r.buf.WriteString("
\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("") + r.walkChildren(n) + r.buf.WriteString("") + } +} + +func (r *telegramRenderer) renderEmph(n *ast.Emph) { + if r.mdv2 { + r.buf.WriteString("_") + r.walkChildren(n) + r.buf.WriteString("_") + } else { + r.buf.WriteString("") + r.walkChildren(n) + r.buf.WriteString("") + } +} + +func (r *telegramRenderer) renderDel(n *ast.Del) { + if r.mdv2 { + r.buf.WriteString("~") + r.walkChildren(n) + r.buf.WriteString("~") + } else { + r.buf.WriteString("") + r.walkChildren(n) + r.buf.WriteString("") + } +} + +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("") + r.buf.WriteString(escapeHTML(content)) + r.buf.WriteString("") + } +} + +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(``) + r.walkChildren(n) + r.buf.WriteString("") + } +} + +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, "&", "&") + text = strings.ReplaceAll(text, "<", "<") + text = strings.ReplaceAll(text, ">", ">") + text = strings.ReplaceAll(text, "'", "'") + return text +} diff --git a/pkg/channels/telegram/render_telegram_test.go b/pkg/channels/telegram/render_telegram_test.go new file mode 100644 index 000000000..7cccc6dad --- /dev/null +++ b/pkg/channels/telegram/render_telegram_test.go @@ -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, "bold", renderHTML("**bold**")) +} + +func TestRenderHTML_Italic(t *testing.T) { + assert.Equal(t, "italic", renderHTML("*italic*")) +} + +func TestRenderHTML_BoldItalic(t *testing.T) { + got := renderHTML("***bold italic***") + assert.Contains(t, got, "") + assert.Contains(t, got, "") +} + +func TestRenderHTML_Strikethrough(t *testing.T) { + assert.Equal(t, "strike", renderHTML("~~strike~~")) +} + +func TestRenderHTML_InlineCode(t *testing.T) { + assert.Equal(t, "code", renderHTML("`code`")) +} + +func TestRenderHTML_InlineCode_HTMLEscape(t *testing.T) { + assert.Equal(t, "<div>", renderHTML("`
`")) +} + +func TestRenderHTML_CodeBlock(t *testing.T) { + input := "```\nfoo\nbar\n```" + got := renderHTML(input) + assert.Contains(t, got, "
")
+	assert.Contains(t, got, "foo\nbar")
+	assert.Contains(t, got, "
") +} + +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('hello')") +} + +func TestRenderHTML_Heading(t *testing.T) { + assert.Equal(t, "Hello", renderHTML("# Hello")) +} + +func TestRenderHTML_HeadingH2(t *testing.T) { + assert.Equal(t, "Sub heading", renderHTML("## Sub heading")) +} + +func TestRenderHTML_Link(t *testing.T) { + got := renderHTML("[click here](https://example.com)") + assert.Equal(t, `click here`, 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, "
") + assert.Contains(t, got, "quoted text") + assert.Contains(t, got, "
") +} + +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, "<") + assert.Contains(t, got, "&") + assert.Contains(t, got, ">") +} + +func TestRenderHTML_Table_Narrow(t *testing.T) { + input := "| A | B |\n|---|---|\n| 1 | 2 |" + got := renderHTML(input) + assert.Contains(t, got, "
")
+	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, "Row 1:")
+	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, "")
+	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, "")
+	assert.Contains(t, htmlGot, "")
+
+	mdv2Got := renderMDV2(input)
+	assert.Contains(t, mdv2Got, "*")
+	assert.Contains(t, mdv2Got, "_")
+}
+
+func TestRender_CodeBlockPreservesContent(t *testing.T) {
+	input := "```\n**not bold** \n```"
+	htmlGot := renderHTML(input)
+	assert.Contains(t, htmlGot, "**not bold**")
+	assert.Contains(t, htmlGot, "<html>")
+}
+
+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, "hello")
+	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))
+}
diff --git a/pkg/channels/telegram/table_render.go b/pkg/channels/telegram/table_render.go
new file mode 100644
index 000000000..b7b649608
--- /dev/null
+++ b/pkg/channels/telegram/table_render.go
@@ -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("Row ")
+		b.WriteString(itoa(i + 1))
+		b.WriteString(":\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))
+}
diff --git a/pkg/channels/telegram/table_render_test.go b/pkg/channels/telegram/table_render_test.go
new file mode 100644
index 000000000..79046f473
--- /dev/null
+++ b/pkg/channels/telegram/table_render_test.go
@@ -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 := "Row 1:\n• Name: Go\n• Version: 1.22\n\n" +
+		"Row 2:\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{
+			{""},
+		},
+	}
+
+	got := renderTableAsListHTML(td)
+	assert.Contains(t, got, "A&B")
+	assert.Contains(t, got, "<val>")
+}
+
+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))
+}
diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go
index acd549012..6ce7e4a97 100644
--- a/pkg/channels/telegram/telegram.go
+++ b/pkg/channels/telegram/telegram.go
@@ -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.
diff --git a/pkg/channels/telegram/telegram_ext.go b/pkg/channels/telegram/telegram_ext.go
index 76ed9afdf..29ec48179 100644
--- a/pkg/channels/telegram/telegram_ext.go
+++ b/pkg/channels/telegram/telegram_ext.go
@@ -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,
diff --git a/pkg/channels/telegram/telegram_test.go b/pkg/channels/telegram/telegram_test.go
index 6bf1077af..4b596f45f 100644
--- a/pkg/channels/telegram/telegram_test.go
+++ b/pkg/channels/telegram/telegram_test.go
@@ -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",