Skip to content

Commit a0e2bb8

Browse files
Alexey Panfilovclaude
andcommitted
feat(telegram): richer Markdown rendering and safer long-message splits
Handles tables, blockquotes (with expandable variant), horizontal rules, spoilers, backslash escapes, header hierarchy (H1 underlined, H3+ italic), and footnote refs/defs. splitMessage is now fence-aware — it closes and reopens ``` fences when a split lands inside one, so each chunk stays syntactically valid. On HTML send failure, fallback first tries tag-stripped text (preserving unicode bullets/box-drawing) before raw Markdown. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7430348 commit a0e2bb8

3 files changed

Lines changed: 455 additions & 38 deletions

File tree

internal/telegram/handler.go

100644100755
Lines changed: 66 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,12 @@ import (
44
"context"
55
"encoding/base64"
66
"fmt"
7+
"html"
78
"io"
89
"log/slog"
910
"math"
1011
"net/http"
12+
"regexp"
1113
"sort"
1214
"strings"
1315
"sync"
@@ -923,18 +925,35 @@ type htmlChunk struct {
923925
html string
924926
}
925927

926-
// sendHTMLWithFallback sends a single HTML message, falling back to plain text.
928+
// sendHTMLWithFallback sends an HTML message with graceful degradation.
929+
// On HTML failure (malformed tag, unsupported entity, etc.) it first retries
930+
// with HTML tags stripped — the unicode structure produced by our converter
931+
// (bullets, checkboxes, box-drawing for tables) is preserved, which is far
932+
// more readable than the raw Markdown source. Only if that also fails do we
933+
// fall back to the raw text.
927934
func (h *Handler) sendHTMLWithFallback(chatID int64, rawText, htmlText string) {
928935
if h.sendHTMLMsg(chatID, htmlText) {
929936
return
930937
}
931-
h.logger.Warn("html send failed, retrying as plain text")
938+
h.logger.Warn("html send failed, retrying as stripped text")
939+
if h.sendPlainMsg(chatID, stripHTMLTags(htmlText)) {
940+
return
941+
}
942+
h.logger.Warn("stripped send failed, retrying as raw markdown")
932943
msg := tgbotapi.NewMessage(chatID, rawText)
933944
if _, err := h.bot.Send(msg); err != nil {
934945
h.logger.Error("failed to send response", "chat_id", chatID, "err", err)
935946
}
936947
}
937948

949+
var reHTMLTag = regexp.MustCompile(`<[^>]+>`)
950+
951+
// stripHTMLTags removes HTML markup but keeps the text content and any
952+
// non-HTML unicode structure (•, ☐, │, ─). Entities like &amp; are decoded.
953+
func stripHTMLTags(s string) string {
954+
return html.UnescapeString(reHTMLTag.ReplaceAllString(s, ""))
955+
}
956+
938957
// sendHTMLMsg sends an HTML message. Returns true on success.
939958
func (h *Handler) sendHTMLMsg(chatID int64, htmlText string) bool {
940959
msg := tgbotapi.NewMessage(chatID, htmlText)
@@ -1047,6 +1066,9 @@ func (h *Handler) sendAsFile(chatID int64, text string) {
10471066

10481067
// splitMessage splits text into chunks of at most maxLen bytes,
10491068
// breaking at paragraph boundaries (\n\n), then line boundaries (\n).
1069+
// If a split lands inside an open ``` fence, the fence is closed in the
1070+
// current chunk and reopened (with the same language tag) in the next one
1071+
// so each chunk remains a standalone, syntactically valid Markdown document.
10501072
func splitMessage(text string, maxLen int) []string {
10511073
if len(text) <= maxLen {
10521074
return []string{text}
@@ -1060,28 +1082,57 @@ func splitMessage(text string, maxLen int) []string {
10601082
}
10611083

10621084
chunk := text[:maxLen]
1085+
var part, rest string
10631086

10641087
// Try to break at a paragraph boundary.
10651088
if idx := strings.LastIndex(chunk, "\n\n"); idx > maxLen/4 {
1066-
parts = append(parts, text[:idx])
1067-
text = strings.TrimLeft(text[idx:], "\n")
1068-
continue
1069-
}
1070-
1071-
// Try to break at a line boundary.
1072-
if idx := strings.LastIndex(chunk, "\n"); idx > maxLen/4 {
1073-
parts = append(parts, text[:idx])
1074-
text = text[idx+1:]
1075-
continue
1089+
part = text[:idx]
1090+
rest = strings.TrimLeft(text[idx:], "\n")
1091+
} else if idx := strings.LastIndex(chunk, "\n"); idx > maxLen/4 {
1092+
// Line boundary.
1093+
part = text[:idx]
1094+
rest = text[idx+1:]
1095+
} else {
1096+
// Hard break at maxLen.
1097+
part = chunk
1098+
rest = text[maxLen:]
10761099
}
10771100

1078-
// Hard break at maxLen.
1079-
parts = append(parts, chunk)
1080-
text = text[maxLen:]
1101+
part, rest = balanceFence(part, rest)
1102+
parts = append(parts, part)
1103+
text = rest
10811104
}
10821105
return parts
10831106
}
10841107

1108+
// balanceFence ensures `part` does not end inside an open ``` fence.
1109+
// If a fence is open at the end of `part`, it is closed there and a fresh
1110+
// opening fence (with the same language tag) is prepended to `rest`.
1111+
func balanceFence(part, rest string) (string, string) {
1112+
inFence := false
1113+
lang := ""
1114+
for _, line := range strings.Split(part, "\n") {
1115+
if !inFence {
1116+
if m := reFenceOpen.FindStringSubmatch(line); m != nil {
1117+
inFence = true
1118+
lang = m[1]
1119+
}
1120+
} else if line == "```" {
1121+
inFence = false
1122+
lang = ""
1123+
}
1124+
}
1125+
if inFence {
1126+
if !strings.HasSuffix(part, "\n") {
1127+
part += "\n"
1128+
}
1129+
part += "```"
1130+
prefix := "```" + lang + "\n"
1131+
rest = prefix + rest
1132+
}
1133+
return part, rest
1134+
}
1135+
10851136
// send sends a bot-generated message with MarkdownV2 (text must be pre-escaped).
10861137
func (h *Handler) send(chatID int64, text string) {
10871138
msg := tgbotapi.NewMessage(chatID, text)

internal/telegram/markdown.go

Lines changed: 187 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -8,22 +8,37 @@ import (
88
)
99

1010
var (
11-
reFenceOpen = regexp.MustCompile("^```(\\w*)")
12-
reInlineCode = regexp.MustCompile("`([^`\n]+)`")
13-
reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
14-
reBoldUnder = regexp.MustCompile(`__(.+?)__`)
15-
reItalicStar = regexp.MustCompile(`\*([^*\n]+)\*`)
16-
reItalicUnder = regexp.MustCompile(`_([^_\n]+)_`)
17-
reStrike = regexp.MustCompile(`~~(.+?)~~`)
18-
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)\s]+)\)`)
19-
reHeader = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
20-
reBulletItem = regexp.MustCompile(`^(\s*)[-*]\s+(.+)$`)
21-
reNumItem = regexp.MustCompile(`^(\s*\d+\.)\s+(.+)$`)
22-
reCheckbox = regexp.MustCompile(`^\[[ ]\]\s*`)
11+
reFenceOpen = regexp.MustCompile("^```(\\w*)")
12+
reInlineCode = regexp.MustCompile("`([^`\n]+)`")
13+
reBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
14+
reBoldUnder = regexp.MustCompile(`__(.+?)__`)
15+
reItalicStar = regexp.MustCompile(`\*([^*\n]+)\*`)
16+
reItalicUnder = regexp.MustCompile(`_([^_\n]+)_`)
17+
reStrike = regexp.MustCompile(`~~(.+?)~~`)
18+
reSpoiler = regexp.MustCompile(`\|\|(.+?)\|\|`)
19+
reLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)\s]+)\)`)
20+
reHeader = regexp.MustCompile(`^(#{1,6})\s+(.+)$`)
21+
reBulletItem = regexp.MustCompile(`^(\s*)[-*]\s+(.+)$`)
22+
reNumItem = regexp.MustCompile(`^(\s*\d+\.)\s+(.+)$`)
23+
reCheckbox = regexp.MustCompile(`^\[[ ]\]\s*`)
2324
reCheckboxDone = regexp.MustCompile(`^\[[xX]\]\s*`)
24-
rePlaceholder = regexp.MustCompile(`\x00(\d+)\x00`)
25+
reHR = regexp.MustCompile(`^\s*(?:(?:-\s*){3,}|(?:\*\s*){3,}|(?:_\s*){3,})$`)
26+
reBlockquote = regexp.MustCompile(`^>\s?(.*)$`)
27+
reTableSep = regexp.MustCompile(`^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)+\|?\s*$`)
28+
reTableRow = regexp.MustCompile(`^\s*\|.*\|\s*$`)
29+
reFootnoteRef = regexp.MustCompile(`\[\^([\w-]+)\]`)
30+
reFootnoteDef = regexp.MustCompile(`^\[\^([\w-]+)\]:\s*(.+)$`)
31+
reEscape = regexp.MustCompile("\\\\([\\\\*_~`\\[\\]|])")
32+
rePlaceholder = regexp.MustCompile(`\x00(\d+)\x00`)
2533
)
2634

35+
const hrLine = "──────────"
36+
37+
var supDigits = map[rune]rune{
38+
'0': '⁰', '1': '¹', '2': '²', '3': '³', '4': '⁴',
39+
'5': '⁵', '6': '⁶', '7': '⁷', '8': '⁸', '9': '⁹',
40+
}
41+
2742
// markdownToTelegramHTML converts LLM Markdown output to Telegram-compatible HTML.
2843
func markdownToTelegramHTML(src string) string {
2944
var sb strings.Builder
@@ -53,6 +68,52 @@ func markdownToTelegramHTML(src string) string {
5368
continue
5469
}
5570

71+
// Horizontal rule
72+
if reHR.MatchString(line) {
73+
sb.WriteString(hrLine + "\n")
74+
i++
75+
continue
76+
}
77+
78+
// Markdown table: row line followed by separator line
79+
if reTableRow.MatchString(line) && i+1 < len(lines) && reTableSep.MatchString(lines[i+1]) {
80+
var rows [][]string
81+
rows = append(rows, splitTableRow(line))
82+
i += 2 // skip header + separator
83+
for i < len(lines) && reTableRow.MatchString(lines[i]) {
84+
rows = append(rows, splitTableRow(lines[i]))
85+
i++
86+
}
87+
sb.WriteString(renderTable(rows))
88+
sb.WriteByte('\n')
89+
continue
90+
}
91+
92+
// Blockquote: one or more consecutive > lines
93+
if reBlockquote.MatchString(line) {
94+
var quoteLines []string
95+
for i < len(lines) && reBlockquote.MatchString(lines[i]) {
96+
m := reBlockquote.FindStringSubmatch(lines[i])
97+
quoteLines = append(quoteLines, processInline(m[1]))
98+
i++
99+
}
100+
content := strings.Join(quoteLines, "\n")
101+
openTag := "<blockquote>"
102+
if len(content) > 300 || len(quoteLines) > 5 {
103+
openTag = "<blockquote expandable>"
104+
}
105+
sb.WriteString(openTag + content + "</blockquote>\n")
106+
continue
107+
}
108+
109+
// Footnote definition: [^id]: text
110+
if m := reFootnoteDef.FindStringSubmatch(line); m != nil {
111+
label := toSuper(m[1])
112+
sb.WriteString("<i>" + label + " " + processInline(m[2]) + "</i>\n")
113+
i++
114+
continue
115+
}
116+
56117
sb.WriteString(processLine(line))
57118
sb.WriteByte('\n')
58119
i++
@@ -62,7 +123,16 @@ func markdownToTelegramHTML(src string) string {
62123

63124
func processLine(line string) string {
64125
if m := reHeader.FindStringSubmatch(line); m != nil {
65-
return "<b>" + processInline(m[1]) + "</b>"
126+
level := len(m[1])
127+
inner := processInline(m[2])
128+
switch level {
129+
case 1:
130+
return "<b><u>" + inner + "</u></b>"
131+
case 2:
132+
return "<b>" + inner + "</b>"
133+
default:
134+
return "<b><i>" + inner + "</i></b>"
135+
}
66136
}
67137
if m := reBulletItem.FindStringSubmatch(line); m != nil {
68138
indent := strings.Repeat(" ", len(m[1])/2)
@@ -84,19 +154,32 @@ func processLine(line string) string {
84154
}
85155

86156
func processInline(text string) string {
87-
// Extract inline code spans first (protect their content)
88157
var spans []string
158+
push := func(s string) string {
159+
spans = append(spans, s)
160+
return fmt.Sprintf("\x00%d\x00", len(spans)-1)
161+
}
162+
163+
// 1) Backslash escapes → placeholder with the literal char.
164+
// Done first so \*, \_, etc. are not consumed by later regexes.
165+
text = reEscape.ReplaceAllStringFunc(text, func(m string) string {
166+
return push(html.EscapeString(m[1:]))
167+
})
168+
169+
// 2) Inline code → placeholder (content is HTML-escaped).
89170
text = reInlineCode.ReplaceAllStringFunc(text, func(m string) string {
90171
inner := html.EscapeString(m[1 : len(m)-1])
91-
spans = append(spans, "<code>"+inner+"</code>")
92-
return fmt.Sprintf("\x00%d\x00", len(spans)-1)
172+
return push("<code>" + inner + "</code>")
93173
})
94174

95-
// HTML-escape remaining text (*, _, ~, [, ], (, ) are not HTML special chars)
175+
// 3) HTML-escape the remaining raw text without touching placeholders.
96176
text = escapeNonPlaceholders(text)
97177

98-
// Apply formatting (order matters)
178+
// 4) Inline formatting. Order: links → spoiler → strike → bold → italic.
99179
text = reLink.ReplaceAllString(text, `<a href="$2">$1</a>`)
180+
text = reSpoiler.ReplaceAllStringFunc(text, func(m string) string {
181+
return "<tg-spoiler>" + m[2:len(m)-2] + "</tg-spoiler>"
182+
})
100183
text = reStrike.ReplaceAllStringFunc(text, func(m string) string {
101184
return "<s>" + m[2:len(m)-2] + "</s>"
102185
})
@@ -113,7 +196,12 @@ func processInline(text string) string {
113196
return "<i>" + m[1:len(m)-1] + "</i>"
114197
})
115198

116-
// Restore code spans
199+
// 5) Footnote references [^N] → superscript.
200+
text = reFootnoteRef.ReplaceAllStringFunc(text, func(m string) string {
201+
return toSuper(m[2 : len(m)-1])
202+
})
203+
204+
// 6) Restore placeholders (code spans + escaped literals).
117205
text = rePlaceholder.ReplaceAllStringFunc(text, func(m string) string {
118206
var idx int
119207
fmt.Sscanf(m[1:len(m)-1], "%d", &idx)
@@ -141,3 +229,82 @@ func escapeNonPlaceholders(text string) string {
141229
sb.WriteString(html.EscapeString(text[last:]))
142230
return sb.String()
143231
}
232+
233+
func toSuper(s string) string {
234+
var sb strings.Builder
235+
for _, r := range s {
236+
if sup, ok := supDigits[r]; ok {
237+
sb.WriteRune(sup)
238+
} else {
239+
sb.WriteRune(r)
240+
}
241+
}
242+
return sb.String()
243+
}
244+
245+
func splitTableRow(line string) []string {
246+
line = strings.TrimSpace(line)
247+
line = strings.Trim(line, "|")
248+
cells := strings.Split(line, "|")
249+
for i, c := range cells {
250+
cells[i] = strings.TrimSpace(c)
251+
}
252+
return cells
253+
}
254+
255+
// renderTable emits a Telegram <pre> block with space-padded columns and
256+
// box-drawing separators. Telegram renders <pre> in a monospace font so
257+
// single-character widths align correctly.
258+
func renderTable(rows [][]string) string {
259+
if len(rows) == 0 {
260+
return ""
261+
}
262+
ncols := 0
263+
for _, r := range rows {
264+
if len(r) > ncols {
265+
ncols = len(r)
266+
}
267+
}
268+
widths := make([]int, ncols)
269+
for _, r := range rows {
270+
for i, c := range r {
271+
if w := runeCount(c); w > widths[i] {
272+
widths[i] = w
273+
}
274+
}
275+
}
276+
var sb strings.Builder
277+
sb.WriteString("<pre>")
278+
for rowIdx, r := range rows {
279+
for i := 0; i < ncols; i++ {
280+
var cell string
281+
if i < len(r) {
282+
cell = r[i]
283+
}
284+
if i > 0 {
285+
sb.WriteString(" │ ")
286+
}
287+
sb.WriteString(html.EscapeString(cell))
288+
sb.WriteString(strings.Repeat(" ", widths[i]-runeCount(cell)))
289+
}
290+
sb.WriteByte('\n')
291+
if rowIdx == 0 {
292+
for i := 0; i < ncols; i++ {
293+
if i > 0 {
294+
sb.WriteString("─┼─")
295+
}
296+
sb.WriteString(strings.Repeat("─", widths[i]))
297+
}
298+
sb.WriteByte('\n')
299+
}
300+
}
301+
return strings.TrimRight(sb.String(), "\n") + "</pre>"
302+
}
303+
304+
func runeCount(s string) int {
305+
n := 0
306+
for range s {
307+
n++
308+
}
309+
return n
310+
}

0 commit comments

Comments
 (0)