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:
parent
1fc5345857
commit
0ae7c4445c
3 changed files with 425 additions and 1 deletions
177
pkg/channels/slack/parse_markdown_to_mrkdwn.go
Normal file
177
pkg/channels/slack/parse_markdown_to_mrkdwn.go
Normal 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>.
|
||||||
|
// -  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)
|
||||||
|
}
|
||||||
247
pkg/channels/slack/parse_markdown_to_mrkdwn_test.go
Normal file
247
pkg/channels/slack/parse_markdown_to_mrkdwn_test.go
Normal 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: "",
|
||||||
|
expected: "<https://example.com/img.png|screenshot>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "image before link",
|
||||||
|
input: " 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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -119,7 +119,7 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error
|
||||||
}
|
}
|
||||||
|
|
||||||
opts := []slack.MsgOption{
|
opts := []slack.MsgOption{
|
||||||
slack.MsgOptionText(msg.Content, false),
|
slack.MsgOptionText(markdownToSlackMrkdwn(msg.Content), false),
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.ReplyToMessageID != "" && threadTS == "" {
|
if msg.ReplyToMessageID != "" && threadTS == "" {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue