Merge pull request #50 from dj-oyu/feature/telegram-format-rewrite
refactor: rewrite Telegram formatting with gomarkdown AST
This commit is contained in:
commit
d27fe8ec32
10 changed files with 1153 additions and 396 deletions
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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, "&", "&")
|
||||
text = strings.ReplaceAll(text, "<", "<")
|
||||
text = strings.ReplaceAll(text, ">", ">")
|
||||
return text
|
||||
}
|
||||
492
pkg/channels/telegram/render_telegram.go
Normal file
492
pkg/channels/telegram/render_telegram.go
Normal 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("
|
||||
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
|
||||
}
|
||||
303
pkg/channels/telegram/render_telegram_test.go
Normal file
303
pkg/channels/telegram/render_telegram_test.go
Normal 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><div></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('hello')")
|
||||
}
|
||||
|
||||
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, "<")
|
||||
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, "<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("")
|
||||
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 := ""
|
||||
got := renderMDV2(input)
|
||||
assert.Equal(t, "", 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, "<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 |" //nolint:gosmopolitan
|
||||
htmlGot := renderHTML(input)
|
||||
assert.Contains(t, htmlGot, "名前") //nolint:gosmopolitan
|
||||
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))
|
||||
}
|
||||
193
pkg/channels/telegram/table_render.go
Normal file
193
pkg/channels/telegram/table_render.go
Normal 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))
|
||||
}
|
||||
157
pkg/channels/telegram/table_render_test.go
Normal file
157
pkg/channels/telegram/table_render_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
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("名前")) //nolint:gosmopolitan // CJK width test
|
||||
assert.Equal(t, 4, stringWidth("Go言")) //nolint:gosmopolitan // CJK width test
|
||||
}
|
||||
|
||||
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{"名前", "バージョン"}, //nolint:gosmopolitan
|
||||
rows: [][]string{
|
||||
{"Go", "1.22"},
|
||||
},
|
||||
}
|
||||
|
||||
got := renderTableMono(td)
|
||||
expected := "名前 | バージョン\n" + //nolint:gosmopolitan
|
||||
"-----+-----------\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&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{"名前", "値"}, //nolint:gosmopolitan
|
||||
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))
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue