Merge pull request #851 from Molecule-AI/fix/slack-mrkdwn-formatting

fix(slack): convert Markdown → mrkdwn before posting
This commit is contained in:
Hongming Wang 2026-04-17 14:27:17 -07:00 committed by GitHub
commit 2d9083b155
2 changed files with 123 additions and 0 deletions

View File

@ -81,6 +81,9 @@ func (s *SlackAdapter) sendBotMessage(ctx context.Context, config map[string]int
username, _ := config["username"].(string)
iconEmoji, _ := config["icon_emoji"].(string)
// Convert Markdown → Slack mrkdwn before sending
text = markdownToMrkdwn(text)
// Split long messages at newline boundaries
chunks := slackSplitMessage(text, 3000)
for _, chunk := range chunks {
@ -152,6 +155,73 @@ func (s *SlackAdapter) sendWebhookMessage(ctx context.Context, config map[string
return nil
}
// markdownToMrkdwn converts standard Markdown to Slack's mrkdwn format.
// Agents output standard MD (Claude Code default); Slack renders mrkdwn.
//
// MD **bold** → mrkdwn *bold*
// MD __italic__ or *italic* (standalone) → mrkdwn _italic_
// MD ### heading → mrkdwn *heading* (bold, no heading syntax in Slack)
// MD [text](url) → mrkdwn <url|text>
// MD --- → mrkdwn ———
// MD > quote → mrkdwn > quote (same, works as-is)
// MD `code` → mrkdwn `code` (same)
// MD ```block``` → mrkdwn ```block``` (same)
func markdownToMrkdwn(text string) string {
lines := strings.Split(text, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// Headings: ### Text → *Text*
if strings.HasPrefix(trimmed, "#") {
heading := strings.TrimLeft(trimmed, "# ")
if heading != "" {
lines[i] = "*" + heading + "*"
continue
}
}
// Horizontal rules
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
lines[i] = "———"
continue
}
// Links: [text](url) → <url|text>
for {
start := strings.Index(lines[i], "[")
if start < 0 {
break
}
mid := strings.Index(lines[i][start:], "](")
if mid < 0 {
break
}
mid += start
end := strings.Index(lines[i][mid+2:], ")")
if end < 0 {
break
}
end += mid + 2
linkText := lines[i][start+1 : mid]
url := lines[i][mid+2 : end]
lines[i] = lines[i][:start] + "<" + url + "|" + linkText + ">" + lines[i][end+1:]
}
// Bold: **text** → *text* (Slack bold is single asterisk)
for strings.Contains(lines[i], "**") {
first := strings.Index(lines[i], "**")
second := strings.Index(lines[i][first+2:], "**")
if second < 0 {
break
}
second += first + 2
inner := lines[i][first+2 : second]
lines[i] = lines[i][:first] + "*" + inner + "*" + lines[i][second+2:]
}
}
return strings.Join(lines, "\n")
}
func slackSplitMessage(text string, maxLen int) []string {
if len(text) <= maxLen {
return []string{text}

View File

@ -113,3 +113,56 @@ func TestSlackAdapter_DisplayName(t *testing.T) {
t.Errorf("expected 'Slack', got %q", a.DisplayName())
}
}
func TestMarkdownToMrkdwn_Bold(t *testing.T) {
got := markdownToMrkdwn("This is **bold** text")
if got != "This is *bold* text" {
t.Errorf("expected *bold*, got %q", got)
}
}
func TestMarkdownToMrkdwn_Heading(t *testing.T) {
got := markdownToMrkdwn("### Security Findings")
if got != "*Security Findings*" {
t.Errorf("expected *Security Findings*, got %q", got)
}
}
func TestMarkdownToMrkdwn_Link(t *testing.T) {
got := markdownToMrkdwn("See [PR #800](https://github.com/org/repo/pull/800)")
if got != "See <https://github.com/org/repo/pull/800|PR #800>" {
t.Errorf("expected Slack link, got %q", got)
}
}
func TestMarkdownToMrkdwn_HorizontalRule(t *testing.T) {
got := markdownToMrkdwn("above\n---\nbelow")
if got != "above\n———\nbelow" {
t.Errorf("expected ———, got %q", got)
}
}
func TestMarkdownToMrkdwn_CodeBlockUntouched(t *testing.T) {
input := "```go\nfunc main() {}\n```"
got := markdownToMrkdwn(input)
if got != input {
t.Errorf("code block should be untouched, got %q", got)
}
}
func TestMarkdownToMrkdwn_Mixed(t *testing.T) {
input := "## Summary\n\n**3 PRs** merged. See [details](https://example.com).\n\n---\n\nDone."
got := markdownToMrkdwn(input)
if !strings.Contains(got, "*Summary*") {
t.Error("heading not converted")
}
if !strings.Contains(got, "*3 PRs*") {
t.Error("bold not converted")
}
if !strings.Contains(got, "<https://example.com|details>") {
t.Error("link not converted")
}
if !strings.Contains(got, "———") {
t.Error("hr not converted")
}
}