Files
cc-connect/core/markdown_html.go
T
cg33andClaude 1beffe3f25 fix(telegram): UTF-8-safe command menu descriptions (fixes #119) (#301)
* fix(codex): avoid closing events channel before readLoop exits

Close() previously closed cs.events after an 8s wait even when wg.Wait
had not finished, while readLoop could still send in flushPendingAsText
(turn.completed), causing "panic: send on closed channel" after /stop
then /new. Defer channel close until readLoop completes.

Fixes https://github.com/chenhg5/cc-connect/issues/281

* fix(telegram): UTF-8-safe bot command descriptions and table rows (#119)

- Truncate BotCommand.Description by runes (Telegram 256-char limit); byte
  slicing broke CJK and caused API errors.
- Make truncateForLog rune-aware for debug prefixes.
- Parse markdown table rows without byte-slicing the outer pipes.

Fixes #119

Made-with: Cursor

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-03-24 14:16:04 +08:00

388 lines
10 KiB
Go

package core
import (
"regexp"
"strings"
)
// MarkdownToSimpleHTML converts common Markdown to a simplified HTML subset.
// Supported tags: <b>, <i>, <s>, <code>, <pre>, <a href="">, <blockquote>.
// Useful for platforms that accept a limited set of HTML (e.g. Telegram).
func MarkdownToSimpleHTML(md string) string {
var b strings.Builder
b.Grow(len(md) + len(md)/4)
lines := strings.Split(md, "\n")
inCodeBlock := false
codeLang := ""
var codeLines []string
inBlockquote := false
var bqLines []string
inTable := false
var tblLines []string
// flushBlockquote merges buffered blockquote lines into a single <blockquote>.
// Supports Obsidian-style callouts: > [!type] Title
flushBlockquote := func() {
if len(bqLines) == 0 {
return
}
b.WriteString("<blockquote>")
startIdx := 0
// Check for callout syntax in the first line
if len(bqLines) > 0 {
if m := reCallout.FindStringSubmatch(bqLines[0]); m != nil {
calloutType := m[1]
calloutTitle := m[2]
if calloutTitle != "" {
b.WriteString("<b>" + escapeHTML(calloutType) + ": " + escapeHTML(calloutTitle) + "</b>")
} else {
b.WriteString("<b>" + escapeHTML(calloutType) + "</b>")
}
startIdx = 1
if startIdx < len(bqLines) {
b.WriteByte('\n')
}
}
}
for j := startIdx; j < len(bqLines); j++ {
if j > startIdx {
b.WriteByte('\n')
}
b.WriteString(convertInlineHTML(bqLines[j]))
}
b.WriteString("</blockquote>")
bqLines = bqLines[:0]
inBlockquote = false
}
// flushTable renders buffered table rows as readable text.
flushTable := func() {
if len(tblLines) == 0 {
return
}
for j, tl := range tblLines {
if j > 0 {
b.WriteByte('\n')
}
tl = strings.TrimSpace(tl)
if reTableSep.MatchString(tl) {
b.WriteString("——————————")
} else {
tlTrim := strings.TrimSpace(tl)
inner := tlTrim
if strings.HasPrefix(tlTrim, "|") && strings.HasSuffix(tlTrim, "|") && len(tlTrim) >= 2 {
inner = strings.TrimSpace(strings.TrimSuffix(strings.TrimPrefix(tlTrim, "|"), "|"))
}
cells := strings.Split(inner, "|")
for k := range cells {
cells[k] = strings.TrimSpace(cells[k])
}
row := strings.Join(cells, " | ")
b.WriteString(convertInlineHTML(row))
}
}
tblLines = tblLines[:0]
inTable = false
}
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
if !inCodeBlock {
if inBlockquote {
flushBlockquote()
b.WriteByte('\n')
}
if inTable {
flushTable()
b.WriteByte('\n')
}
inCodeBlock = true
codeLang = strings.TrimPrefix(trimmed, "```")
codeLines = nil
} else {
inCodeBlock = false
if codeLang != "" {
b.WriteString("<pre><code class=\"language-" + escapeHTML(codeLang) + "\">")
} else {
b.WriteString("<pre><code>")
}
b.WriteString(escapeHTML(strings.Join(codeLines, "\n")))
b.WriteString("</code></pre>")
if i < len(lines)-1 {
b.WriteByte('\n')
}
}
continue
}
if inCodeBlock {
codeLines = append(codeLines, line)
continue
}
// Determine line type for blockquote/table buffering
isQuote := strings.HasPrefix(trimmed, "> ") || trimmed == ">"
isTable := len(trimmed) > 2 && trimmed[0] == '|' && trimmed[len(trimmed)-1] == '|'
// Flush blockquote when leaving
if !isQuote && inBlockquote {
flushBlockquote()
b.WriteByte('\n')
}
// Flush table when leaving
if !isTable && inTable {
flushTable()
b.WriteByte('\n')
}
// Buffer blockquote lines into a single block
if isQuote {
quoteContent := strings.TrimPrefix(trimmed, "> ")
if trimmed == ">" {
quoteContent = ""
}
bqLines = append(bqLines, quoteContent)
inBlockquote = true
continue
}
// Buffer table lines
if isTable {
tblLines = append(tblLines, trimmed)
inTable = true
continue
}
// Headings → bold
if heading := reHeading.FindString(line); heading != "" {
rest := strings.TrimPrefix(line, heading)
b.WriteString("<b>")
b.WriteString(convertInlineHTML(rest))
b.WriteString("</b>")
} else if reHorizontal.MatchString(trimmed) {
b.WriteString("——————————")
} else if m := reUnorderedList.FindStringSubmatch(line); m != nil {
indent := strings.Repeat(" ", len(m[1])/2)
b.WriteString(indent + "• " + convertInlineHTML(m[2]))
} else if m := reOrderedList.FindStringSubmatch(line); m != nil {
indent := strings.Repeat(" ", len(m[1])/2)
numDot := strings.TrimSpace(line[:len(line)-len(m[2])])
b.WriteString(indent + escapeHTML(numDot) + " " + convertInlineHTML(m[2]))
} else {
b.WriteString(convertInlineHTML(line))
}
if i < len(lines)-1 {
b.WriteByte('\n')
}
}
// Flush any remaining buffered state
if inBlockquote {
flushBlockquote()
}
if inTable {
flushTable()
}
if inCodeBlock && len(codeLines) > 0 {
b.WriteString("<pre><code>")
b.WriteString(escapeHTML(strings.Join(codeLines, "\n")))
b.WriteString("</code></pre>")
}
return b.String()
}
var (
reInlineCodeHTML = regexp.MustCompile("`([^`]+)`")
reBoldItalicHTML = regexp.MustCompile(`\*\*\*(.+?)\*\*\*`)
reBoldAstHTML = regexp.MustCompile(`\*\*(.+?)\*\*`)
reBoldUndHTML = regexp.MustCompile(`__(.+?)__`)
reItalicAstHTML = regexp.MustCompile(`(?:^|[^*])\*([^*]+?)\*(?:[^*]|$)`)
reStrikeHTML = regexp.MustCompile(`~~(.+?)~~`)
reLinkHTML = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
reWikilinkHTML = regexp.MustCompile(`\[\[([^\]|]+)\|([^\]]+)\]\]|\[\[([^\]]+)\]\]`)
reUnorderedList = regexp.MustCompile(`^(\s*)[-*]\s+(.*)$`)
reOrderedList = regexp.MustCompile(`^(\s*)\d+\.\s+(.*)$`)
reTableSep = regexp.MustCompile(`^\|[\s:|-]+\|$`)
reCallout = regexp.MustCompile(`^\[!(\w+)\]\s*(.*)$`)
)
// convertInlineHTML converts inline Markdown formatting to Telegram-compatible HTML.
//
// Each formatting pass (bold, strikethrough) protects its output as placeholders
// so that subsequent passes (italic) cannot match across HTML tag boundaries.
func convertInlineHTML(s string) string {
type placeholder struct {
key string
html string
}
var phs []placeholder
phIdx := 0
nextPH := func(html string) string {
key := "\x00PH" + string(rune('0'+phIdx)) + "\x00"
phs = append(phs, placeholder{key: key, html: html})
phIdx++
return key
}
// 1. Extract inline code → placeholder (content escaped)
s = reInlineCodeHTML.ReplaceAllStringFunc(s, func(m string) string {
inner := m[1 : len(m)-1]
return nextPH("<code>" + escapeHTML(inner) + "</code>")
})
// 2. Extract links → placeholder (text & URL escaped)
s = reLinkHTML.ReplaceAllStringFunc(s, func(m string) string {
sm := reLinkHTML.FindStringSubmatch(m)
if len(sm) < 3 {
return m
}
return nextPH(`<a href="` + escapeHTML(sm[2]) + `">` + escapeHTML(sm[1]) + `</a>`)
})
// 2b. Wikilinks: [[Link|Text]] → Text, [[Link]] → Link
// Don't escape here — step 3 will HTML-escape the whole remaining text.
s = reWikilinkHTML.ReplaceAllStringFunc(s, func(m string) string {
sm := reWikilinkHTML.FindStringSubmatch(m)
if sm[1] != "" && sm[2] != "" {
return sm[2]
}
if sm[3] != "" {
return sm[3]
}
return m
})
// 3. HTML-escape the entire remaining text.
s = escapeHTML(s)
// 4. Bold-italic (***text***) → placeholder (must be before bold)
s = reBoldItalicHTML.ReplaceAllStringFunc(s, func(m string) string {
inner := m[3 : len(m)-3]
return nextPH("<b><i>" + inner + "</i></b>")
})
// 5. Bold → placeholder (so italic regex can't cross bold boundaries)
s = reBoldAstHTML.ReplaceAllStringFunc(s, func(m string) string {
inner := m[2 : len(m)-2]
return nextPH("<b>" + inner + "</b>")
})
s = reBoldUndHTML.ReplaceAllStringFunc(s, func(m string) string {
inner := m[2 : len(m)-2]
return nextPH("<b>" + inner + "</b>")
})
// 6. Strikethrough → placeholder
s = reStrikeHTML.ReplaceAllStringFunc(s, func(m string) string {
inner := m[2 : len(m)-2]
return nextPH("<s>" + inner + "</s>")
})
// 7. Italic (applied last, on text with bold/strike already protected)
s = reItalicAstHTML.ReplaceAllStringFunc(s, func(m string) string {
idx := strings.Index(m, "*")
if idx < 0 {
return m
}
lastIdx := strings.LastIndex(m, "*")
if lastIdx <= idx {
return m
}
return m[:idx] + "<i>" + m[idx+1:lastIdx] + "</i>" + m[lastIdx+1:]
})
// 8. Restore all placeholders (may be nested, so iterate until stable).
for i := 0; i <= len(phs); i++ {
changed := false
for _, ph := range phs {
if strings.Contains(s, ph.key) {
s = strings.Replace(s, ph.key, ph.html, 1)
changed = true
}
}
if !changed {
break
}
}
return s
}
func escapeHTML(s string) string {
s = strings.ReplaceAll(s, "&", "&amp;")
s = strings.ReplaceAll(s, "<", "&lt;")
s = strings.ReplaceAll(s, ">", "&gt;")
s = strings.ReplaceAll(s, "\"", "&quot;")
return s
}
// SplitMessageCodeFenceAware splits text into chunks respecting code fence boundaries.
// When a chunk boundary falls inside a code block, the fence is closed at the end of
// the chunk and re-opened at the start of the next chunk.
func SplitMessageCodeFenceAware(text string, maxLen int) []string {
if len(text) <= maxLen {
return []string{text}
}
const closingFence = "\n```" // 4 bytes appended when splitting inside a code block
lines := strings.Split(text, "\n")
var chunks []string
var current []string
currentLen := 0
openFence := "" // the ``` opening line, or "" if outside code block
for _, line := range lines {
lineLen := len(line) + 1 // +1 for newline
// Reserve space for the closing fence when inside a code block,
// so the final chunk length stays within maxLen.
limit := maxLen
if openFence != "" {
limit -= len(closingFence)
}
if currentLen+lineLen > limit && len(current) > 0 {
chunk := strings.Join(current, "\n")
if openFence != "" {
chunk += closingFence
}
chunks = append(chunks, chunk)
current = nil
currentLen = 0
if openFence != "" {
current = append(current, openFence)
currentLen = len(openFence) + 1
}
}
current = append(current, line)
currentLen += lineLen
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "```") {
if openFence != "" {
openFence = ""
} else {
openFence = trimmed
}
}
}
if len(current) > 0 {
chunk := strings.Join(current, "\n")
if openFence != "" {
chunk += "\n```"
}
chunks = append(chunks, chunk)
}
return chunks
}