diff --git a/pkg/channels/slack/parse_markdown_to_mrkdwn.go b/pkg/channels/slack/parse_markdown_to_mrkdwn.go new file mode 100644 index 000000000..514cf597d --- /dev/null +++ b/pkg/channels/slack/parse_markdown_to_mrkdwn.go @@ -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 . +// - ![alt](url) becomes . +// - 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) → . + 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) +} diff --git a/pkg/channels/slack/parse_markdown_to_mrkdwn_test.go b/pkg/channels/slack/parse_markdown_to_mrkdwn_test.go new file mode 100644 index 000000000..046bc10c3 --- /dev/null +++ b/pkg/channels/slack/parse_markdown_to_mrkdwn_test.go @@ -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 today", + }, + { + name: "link with special chars in text", + input: "[click & go](https://example.com/path?a=1&b=2)", + expected: "", + }, + + // Images + { + name: "image to link", + input: "![screenshot](https://example.com/img.png)", + expected: "", + }, + { + name: "image before link", + input: "![img](https://a.com/1.png) and [link](https://b.com)", + expected: " and ", + }, + + // 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 \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) + } + }) + } +} diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index acd857a06..99a50b5fc 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -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 == "" {