Files
chenhg5-cc-connect/core/markdown_slack.go
cg33 33976574a6 fix(slack): convert Markdown to Slack mrkdwn format (#667) (#680)
Add MarkdownToSlackMrkdwn() converter that transforms standard Markdown
to Slack's mrkdwn format before sending messages:
- **bold** → *bold*
- ***bold italic*** → *_bold italic_*
- ~~strike~~ → ~strike~
- [text](url) → <url|text>
- # Heading → *Heading*
- Code blocks and inline code are preserved untouched.

Applied in both Reply() and Send() methods of the Slack platform.

Closes #667

Co-authored-by: Claude <noreply@anthropic.com>
2026-04-24 06:49:32 +08:00

132 lines
3.4 KiB
Go

package core
import (
"regexp"
"strings"
)
// Slack mrkdwn regex patterns (compiled once).
var (
reSlackCodeBlock = regexp.MustCompile("(?s)```[a-zA-Z]*\n?(.*?)```")
reSlackInlineCode = regexp.MustCompile("`([^`]+)`")
reSlackLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
reSlackBoldItalic = regexp.MustCompile(`\*\*\*(.+?)\*\*\*`)
reSlackBold = regexp.MustCompile(`\*\*(.+?)\*\*`)
reSlackStrike = regexp.MustCompile(`~~(.+?)~~`)
reSlackHeading = regexp.MustCompile(`^#{1,6}\s+(.+)$`)
reSlackImgTag = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
)
// MarkdownToSlackMrkdwn converts standard Markdown to Slack mrkdwn format.
//
// Key conversions:
// - **bold** → *bold*
// - *italic* → _italic_ (single asterisk → underscore)
// - ~~strike~~ → ~strike~
// - [text](url) → <url|text>
// - # Heading → *Heading*
// - Code blocks and inline code are preserved as-is.
func MarkdownToSlackMrkdwn(md string) string {
// Split into code blocks vs non-code segments so we don't
// accidentally convert syntax inside code.
type segment struct {
text string
isCode bool
}
var segments []segment
rest := md
for {
loc := reSlackCodeBlock.FindStringIndex(rest)
if loc == nil {
segments = append(segments, segment{text: rest})
break
}
if loc[0] > 0 {
segments = append(segments, segment{text: rest[:loc[0]]})
}
segments = append(segments, segment{text: rest[loc[0]:loc[1]], isCode: true})
rest = rest[loc[1]:]
}
var b strings.Builder
b.Grow(len(md) + len(md)/8)
for _, seg := range segments {
if seg.isCode {
b.WriteString(seg.text)
continue
}
b.WriteString(convertSlackInline(seg.text))
}
return b.String()
}
// convertSlackInline converts inline Markdown formatting to Slack mrkdwn.
// Must NOT be called on code block content.
func convertSlackInline(s string) string {
// Protect inline code from further processing.
type placeholder struct {
key string
content string
}
var phs []placeholder
phIdx := 0
nextPH := func(content string) string {
key := "\x00SL" + string(rune('0'+phIdx)) + "\x00"
phs = append(phs, placeholder{key: key, content: content})
phIdx++
return key
}
// 1. Protect inline code spans.
s = reSlackInlineCode.ReplaceAllStringFunc(s, func(m string) string {
return nextPH(m) // keep as-is
})
// 2. Image tags → just the alt text or URL (Slack can't render inline images).
s = reSlackImgTag.ReplaceAllStringFunc(s, func(m string) string {
sm := reSlackImgTag.FindStringSubmatch(m)
if sm[1] != "" {
return sm[1]
}
return sm[2]
})
// 3. Links: [text](url) → <url|text>
s = reSlackLink.ReplaceAllStringFunc(s, func(m string) string {
sm := reSlackLink.FindStringSubmatch(m)
if len(sm) < 3 {
return m
}
return nextPH("<" + sm[2] + "|" + sm[1] + ">")
})
// 4. Bold-italic: ***text*** → *_text_* (must precede bold)
s = reSlackBoldItalic.ReplaceAllString(s, "*_${1}_*")
// 5. Bold: **text** → *text*
s = reSlackBold.ReplaceAllString(s, "*${1}*")
// 6. Strikethrough: ~~text~~ → ~text~
s = reSlackStrike.ReplaceAllString(s, "~${1}~")
// 7. Headings: # Heading → *Heading* (line-by-line)
lines := strings.Split(s, "\n")
for i, line := range lines {
if m := reSlackHeading.FindStringSubmatch(line); m != nil {
lines[i] = "*" + m[1] + "*"
}
}
s = strings.Join(lines, "\n")
// Restore placeholders.
for _, ph := range phs {
s = strings.Replace(s, ph.key, ph.content, 1)
}
return s
}