From 72cb636692682b88bb31c2242147a023ff743f35 Mon Sep 17 00:00:00 2001 From: rabbitblood Date: Fri, 17 Apr 2026 14:26:41 -0700 Subject: [PATCH] fix(slack): convert Markdown to mrkdwn before posting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agents output standard Markdown (Claude Code default) but Slack uses its own mrkdwn format. Without conversion: **bold** shows as literal **bold** ### heading shows as literal ### [text](url) shows as raw markdown link Converter handles: **bold** → *bold* (Slack bold is single asterisk) ### heading → *heading* (bold text, no headings in Slack) [text](url) → (Slack link format) --- → ——— (visual separator) `code` and ```blocks``` pass through unchanged 6 new tests: bold, heading, link, hr, code block, mixed. Co-Authored-By: Claude Opus 4.6 (1M context) --- platform/internal/channels/slack.go | 70 ++++++++++++++++++++++++ platform/internal/channels/slack_test.go | 53 ++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/platform/internal/channels/slack.go b/platform/internal/channels/slack.go index 2ecfd086..02fb1260 100644 --- a/platform/internal/channels/slack.go +++ b/platform/internal/channels/slack.go @@ -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 +// 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) → + 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} diff --git a/platform/internal/channels/slack_test.go b/platform/internal/channels/slack_test.go index f326972f..58448223 100644 --- a/platform/internal/channels/slack_test.go +++ b/platform/internal/channels/slack_test.go @@ -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 " { + 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, "") { + t.Error("link not converted") + } + if !strings.Contains(got, "———") { + t.Error("hr not converted") + } +}