feat(slack): add markdown to mrkdwn converter

LLM responses use standard Markdown (e.g. **bold**, ~~strike~~,
[text](url), tables) which renders incorrectly in Slack. This adds a
converter that transforms Markdown into Slack's mrkdwn format before
sending, following the same pattern used by the Telegram adapter.

Conversions: **bold**/\_\_bold\_\_ → *bold*, ~~strike~~ → ~strike~,
[text](url) → <url|text>, headings → bold, tables → preformatted
code blocks, list markers → bullet chars. Code blocks and inline
code are preserved unchanged.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Andrei Simion 2026-03-29 22:29:42 +07:00
parent 1fc5345857
commit 0ae7c4445c
3 changed files with 425 additions and 1 deletions

View file

@ -0,0 +1,177 @@
package slack
import (
"fmt"
"regexp"
"strings"
)
var (
// Patterns are applied in order; code blocks extracted first to protect verbatim content.
reMrkdwnCodeBlock = regexp.MustCompile("(?s)```[\\w]*\\n?([\\s\\S]*?)```")
reMrkdwnInlineCode = regexp.MustCompile("`([^`\n]+)`")
reMrkdwnHeading = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`)
reMrkdwnBoldStar = regexp.MustCompile(`\*\*(.+?)\*\*`)
reMrkdwnBoldUnder = regexp.MustCompile(`__(.+?)__`)
reMrkdwnStrike = regexp.MustCompile(`~~(.+?)~~`)
reMrkdwnImage = regexp.MustCompile(`!\[([^\]]*)\]\(([^)]+)\)`)
reMrkdwnLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
reMrkdwnHR = regexp.MustCompile(`(?m)^[-*_]{3,}\s*$`)
reMrkdwnULItem = regexp.MustCompile(`(?m)^(\s*)[-*+]\s+`)
// Table: line starting with optional whitespace, pipe, content, pipe.
reMrkdwnTableRow = regexp.MustCompile(`(?m)^\|(.+)\|$`)
// Separator row: | --- | --- | or | :---: | etc.
reMrkdwnTableSep = regexp.MustCompile(`(?m)^\|[\s|:-]+\|$`)
)
// markdownToSlackMrkdwn converts standard Markdown (as produced by LLMs) into
// Slack's mrkdwn format.
//
// Conversion rules:
// - Fenced code blocks and inline code are preserved unchanged.
// - Markdown tables are converted to preformatted code blocks.
// - Headings (# … ######) become *bold* text.
// - **bold** and __bold__ become *bold*.
// - ~~strikethrough~~ becomes ~strikethrough~.
// - [text](url) becomes <url|text>.
// - ![alt](url) becomes <url|alt>.
// - Horizontal rules (---) are removed.
// - Unordered list markers (-, *, +) become bullet characters (•).
// - Italic (_text_), blockquotes (> text), and code pass through unchanged.
func markdownToSlackMrkdwn(text string) string {
if text == "" {
return ""
}
// 1. Extract code blocks and inline code to protect them from conversion.
var codeBlocks []string
text = reMrkdwnCodeBlock.ReplaceAllStringFunc(text, func(match string) string {
idx := len(codeBlocks)
codeBlocks = append(codeBlocks, match)
return codePlaceholder(idx)
})
var inlineCodes []string
text = reMrkdwnInlineCode.ReplaceAllStringFunc(text, func(match string) string {
idx := len(inlineCodes)
inlineCodes = append(inlineCodes, match)
return inlineCodePlaceholder(idx)
})
// 2. Convert tables to preformatted code blocks.
text = convertTables(text)
// 3. Headings → bold. Strip any bold markers from heading content first
// since the heading itself is rendered as bold.
text = reMrkdwnHeading.ReplaceAllStringFunc(text, func(match string) string {
sub := reMrkdwnHeading.FindStringSubmatch(match)
if len(sub) < 2 {
return match
}
content := reMrkdwnBoldStar.ReplaceAllString(sub[1], "$1")
content = reMrkdwnBoldUnder.ReplaceAllString(content, "$1")
return "*" + content + "*"
})
// 4. Bold: **text** and __text__ → *text*.
// Process **bold** before __bold__ so nested cases like **__text__** work.
text = reMrkdwnBoldStar.ReplaceAllString(text, "*$1*")
text = reMrkdwnBoldUnder.ReplaceAllString(text, "*$1*")
// 5. Strikethrough: ~~text~~ → ~text~.
text = reMrkdwnStrike.ReplaceAllString(text, "~$1~")
// 6. Images before links (images have the ! prefix).
text = reMrkdwnImage.ReplaceAllString(text, "<$2|$1>")
// 7. Links: [text](url) → <url|text>.
text = reMrkdwnLink.ReplaceAllString(text, "<$2|$1>")
// 8. Horizontal rules → remove.
text = reMrkdwnHR.ReplaceAllString(text, "")
// 9. Unordered list markers → bullet character.
text = reMrkdwnULItem.ReplaceAllStringFunc(text, func(match string) string {
sub := reMrkdwnULItem.FindStringSubmatch(match)
indent := ""
if len(sub) > 1 {
indent = sub[1]
}
return indent + "\u2022 "
})
// 10. Restore inline code, then code blocks.
for i, code := range inlineCodes {
text = strings.Replace(text, inlineCodePlaceholder(i), code, 1)
}
for i, block := range codeBlocks {
text = strings.Replace(text, codePlaceholder(i), block, 1)
}
// Clean up excessive blank lines left by removed elements.
for strings.Contains(text, "\n\n\n") {
text = strings.ReplaceAll(text, "\n\n\n", "\n\n")
}
return text
}
// convertTables detects Markdown tables (consecutive lines matching |...|) and
// wraps them in fenced code blocks so Slack renders them as preformatted text.
func convertTables(text string) string {
lines := strings.Split(text, "\n")
var result []string
inTable := false
for i := 0; i < len(lines); i++ {
line := lines[i]
if reMrkdwnTableRow.MatchString(line) {
if !inTable {
inTable = true
result = append(result, "```")
}
// Skip separator rows (| --- | --- |) — they add no value in plain text.
if reMrkdwnTableSep.MatchString(line) {
continue
}
// Strip leading/trailing pipes and clean up cell content.
result = append(result, formatTableRow(line))
} else {
if inTable {
inTable = false
result = append(result, "```")
}
result = append(result, line)
}
}
// Close any trailing table.
if inTable {
result = append(result, "```")
}
return strings.Join(result, "\n")
}
// formatTableRow strips the outer pipes from a table row and trims each cell.
func formatTableRow(line string) string {
// Remove leading and trailing pipe.
line = strings.TrimSpace(line)
line = strings.TrimPrefix(line, "|")
line = strings.TrimSuffix(line, "|")
cells := strings.Split(line, "|")
for i, cell := range cells {
cells[i] = strings.TrimSpace(cell)
}
return strings.Join(cells, " | ")
}
func codePlaceholder(idx int) string {
return fmt.Sprintf("\x00CODEBLOCK_%d\x00", idx)
}
func inlineCodePlaceholder(idx int) string {
return fmt.Sprintf("\x00INLINECODE_%d\x00", idx)
}

View file

@ -0,0 +1,247 @@
package slack
import "testing"
func Test_markdownToSlackMrkdwn(t *testing.T) {
cases := []struct {
name string
input string
expected string
}{
{
name: "empty string",
input: "",
expected: "",
},
// Bold
{
name: "bold double asterisk",
input: "this is **bold** text",
expected: "this is *bold* text",
},
{
name: "bold double underscore",
input: "this is __bold__ text",
expected: "this is *bold* text",
},
{
name: "multiple bold segments",
input: "**one** and **two**",
expected: "*one* and *two*",
},
// Italic (passthrough)
{
name: "italic unchanged",
input: "this is _italic_ text",
expected: "this is _italic_ text",
},
// Strikethrough
{
name: "strikethrough",
input: "this is ~~deleted~~ text",
expected: "this is ~deleted~ text",
},
// Headings
{
name: "h1 heading",
input: "# Main Title",
expected: "*Main Title*",
},
{
name: "h3 heading",
input: "### Sub Heading",
expected: "*Sub Heading*",
},
{
name: "heading with inline bold collapses",
input: "## **Bold Heading**",
expected: "*Bold Heading*",
},
// Links
{
name: "markdown link",
input: "visit [Google](https://google.com) today",
expected: "visit <https://google.com|Google> today",
},
{
name: "link with special chars in text",
input: "[click & go](https://example.com/path?a=1&b=2)",
expected: "<https://example.com/path?a=1&b=2|click & go>",
},
// Images
{
name: "image to link",
input: "![screenshot](https://example.com/img.png)",
expected: "<https://example.com/img.png|screenshot>",
},
{
name: "image before link",
input: "![img](https://a.com/1.png) and [link](https://b.com)",
expected: "<https://a.com/1.png|img> and <https://b.com|link>",
},
// Code blocks (passthrough)
{
name: "fenced code block preserved",
input: "```go\nfmt.Println(\"**not bold**\")\n```",
expected: "```go\nfmt.Println(\"**not bold**\")\n```",
},
{
name: "inline code preserved",
input: "use `**bold**` syntax",
expected: "use `**bold**` syntax",
},
// Blockquotes (passthrough)
{
name: "blockquote unchanged",
input: "> this is a quote",
expected: "> this is a quote",
},
// Horizontal rules
{
name: "horizontal rule removed",
input: "above\n---\nbelow",
expected: "above\n\nbelow",
},
{
name: "asterisk hr removed",
input: "above\n***\nbelow",
expected: "above\n\nbelow",
},
// Unordered lists
{
name: "dash list to bullet",
input: "- item one\n- item two",
expected: "\u2022 item one\n\u2022 item two",
},
{
name: "asterisk list to bullet",
input: "* item one\n* item two",
expected: "\u2022 item one\n\u2022 item two",
},
{
name: "nested list indentation",
input: "- top\n - nested",
expected: "\u2022 top\n \u2022 nested",
},
// Ordered lists (passthrough)
{
name: "ordered list unchanged",
input: "1. first\n2. second",
expected: "1. first\n2. second",
},
// Tables
{
name: "simple table",
input: "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |",
expected: "```\nName | Age\nAlice | 30\nBob | 25\n```",
},
{
name: "table with surrounding text",
input: "Here is a table:\n\n| A | B |\n| --- | --- |\n| 1 | 2 |\n\nEnd.",
expected: "Here is a table:\n\n```\nA | B\n1 | 2\n```\n\nEnd.",
},
// Mixed content
{
name: "realistic LLM output",
input: "## Summary\n\nHere are the **key points**:\n\n- First item with `code`\n- Second item with [a link](https://example.com)\n- ~~Removed~~ item\n\n### Details\n\n| Feature | Status |\n| --- | --- |\n| Auth | Done |\n| API | WIP |\n\n> Note: check the docs.",
expected: "*Summary*\n\nHere are the *key points*:\n\n\u2022 First item with `code`\n\u2022 Second item with <https://example.com|a link>\n\u2022 ~Removed~ item\n\n*Details*\n\n```\nFeature | Status\nAuth | Done\nAPI | WIP\n```\n\n> Note: check the docs.",
},
// Edge cases
{
name: "single asterisk not converted",
input: "this * is not * bold",
expected: "this * is not * bold",
},
{
name: "bold inside code block not converted",
input: "```\n**bold** inside code\n```",
expected: "```\n**bold** inside code\n```",
},
{
name: "link inside inline code not converted",
input: "use `[text](url)` for links",
expected: "use `[text](url)` for links",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
actual := markdownToSlackMrkdwn(tc.input)
if actual != tc.expected {
t.Errorf("\ninput: %q\nexpected: %q\nactual: %q", tc.input, tc.expected, actual)
}
})
}
}
func Test_convertTables(t *testing.T) {
cases := []struct {
name string
input string
expected string
}{
{
name: "no table",
input: "just some text",
expected: "just some text",
},
{
name: "simple two column",
input: "| A | B |\n| --- | --- |\n| 1 | 2 |",
expected: "```\nA | B\n1 | 2\n```",
},
{
name: "three columns with alignment markers",
input: "| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |",
expected: "```\nLeft | Center | Right\na | b | c\n```",
},
{
name: "multiple tables separated by text",
input: "| A | B |\n| - | - |\n| 1 | 2 |\n\ntext\n\n| C | D |\n| - | - |\n| 3 | 4 |",
expected: "```\nA | B\n1 | 2\n```\n\ntext\n\n```\nC | D\n3 | 4\n```",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
actual := convertTables(tc.input)
if actual != tc.expected {
t.Errorf("\ninput: %q\nexpected: %q\nactual: %q", tc.input, tc.expected, actual)
}
})
}
}
func Test_formatTableRow(t *testing.T) {
cases := []struct {
input string
expected string
}{
{"| A | B |", "A | B"},
{"| one | two | three |", "one | two | three"},
{"| spaced | cells |", "spaced | cells"},
}
for _, tc := range cases {
t.Run(tc.input, func(t *testing.T) {
actual := formatTableRow(tc.input)
if actual != tc.expected {
t.Errorf("formatTableRow(%q) = %q, want %q", tc.input, actual, tc.expected)
}
})
}
}

View file

@ -119,7 +119,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
}
opts := []slack.MsgOption{
slack.MsgOptionText(msg.Content, false),
slack.MsgOptionText(markdownToSlackMrkdwn(msg.Content), false),
}
if msg.ReplyToMessageID != "" && threadTS == "" {