Skip to content

Commit 8489492

Browse files
Copilotalexec
andauthored
Add Markdown to Mrkdwn conversion for Slack messages (#2)
* Initial plan * Initial plan for Markdown to Mrkdwn conversion Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Add Markdown to Mrkdwn conversion and feature suggestions Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Address code review feedback - fix italic conversion and language specifier patterns Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Fix code consistency - use ${N} syntax and reuse compiled regex patterns Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> * Remove FEATURES.md and suggested features section from README Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: alexec <1142830+alexec@users.noreply.github.com> Co-authored-by: Alex Collins <alexec@users.noreply.github.com>
1 parent e34253b commit 8489492

5 files changed

Lines changed: 198 additions & 1 deletion

File tree

.gitignore

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
# Binaries for programs and plugins
2+
*.exe
3+
*.exe~
4+
*.dll
5+
*.so
6+
*.dylib
7+
8+
# Test binary, built with `go test -c`
9+
*.test
10+
11+
# Output of the go coverage tool
12+
*.out
13+
14+
# Build artifacts
15+
slack-cli
16+
slack_*
17+
18+
# Dependency directories
19+
vendor/
20+
21+
# Go workspace file
22+
go.work

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Add this to your prompt (e.g. `AGENTS.md`):
2020

2121
```markdown
2222
- You can send messages to a Slack user by using the `slack send-message <channel|email> "<message>"` command.
23+
- The message supports Markdown formatting which will be automatically converted to Slack's Mrkdwn format.
2324
```
2425

2526
## Usage

main.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,10 @@ func sendMessage(ctx context.Context, identifier, body string) error {
7979
channel = identifier
8080
}
8181

82-
if _, _, err := api.PostMessageContext(ctx, channel, slack.MsgOptionText(body, false)); err != nil {
82+
// Convert Markdown to Mrkdwn format
83+
mrkdwnBody := convertMarkdownToMrkdwn(body)
84+
85+
if _, _, err := api.PostMessageContext(ctx, channel, slack.MsgOptionText(mrkdwnBody, false)); err != nil {
8386
return fmt.Errorf("failed to send message: %w", err)
8487
}
8588

markdown.go

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package main
2+
3+
import (
4+
"regexp"
5+
"strings"
6+
)
7+
8+
// convertMarkdownToMrkdwn converts Markdown format to Slack's Mrkdwn format
9+
// See: https://api.slack.com/reference/surfaces/formatting
10+
func convertMarkdownToMrkdwn(markdown string) string {
11+
text := markdown
12+
13+
// Placeholder to protect already-converted bold text
14+
const boldPlaceholder = "\x00BOLD\x00"
15+
16+
// Store bold conversions with placeholders
17+
boldMatches := []string{}
18+
19+
// Convert bold: **text** or __text__ -> placeholder
20+
boldPattern := regexp.MustCompile(`\*\*(.+?)\*\*`)
21+
text = boldPattern.ReplaceAllStringFunc(text, func(match string) string {
22+
content := boldPattern.FindStringSubmatch(match)
23+
if len(content) > 1 {
24+
replacement := "*" + content[1] + "*"
25+
boldMatches = append(boldMatches, replacement)
26+
return boldPlaceholder
27+
}
28+
return match
29+
})
30+
31+
boldUnderscorePattern := regexp.MustCompile(`__(.+?)__`)
32+
text = boldUnderscorePattern.ReplaceAllStringFunc(text, func(match string) string {
33+
content := boldUnderscorePattern.FindStringSubmatch(match)
34+
if len(content) > 1 {
35+
replacement := "*" + content[1] + "*"
36+
boldMatches = append(boldMatches, replacement)
37+
return boldPlaceholder
38+
}
39+
return match
40+
})
41+
42+
// Convert italic: single *text* -> _text_ (only single asterisks)
43+
italicAsteriskPattern := regexp.MustCompile(`\*([^*\n]+?)\*`)
44+
text = italicAsteriskPattern.ReplaceAllString(text, `_${1}_`)
45+
46+
// Convert italic: _text_ -> _text_ (already in Mrkdwn format, no change needed)
47+
48+
// Restore bold text from placeholders
49+
for _, boldText := range boldMatches {
50+
text = strings.Replace(text, boldPlaceholder, boldText, 1)
51+
}
52+
53+
// Convert strikethrough: ~~text~~ -> ~text~
54+
strikethroughPattern := regexp.MustCompile(`~~(.+?)~~`)
55+
text = strikethroughPattern.ReplaceAllString(text, `~${1}~`)
56+
57+
// Convert links: [text](url) -> <url|text>
58+
linkPattern := regexp.MustCompile(`\[([^\]]+)\]\(([^\)]+)\)`)
59+
text = linkPattern.ReplaceAllString(text, `<${2}|${1}>`)
60+
61+
// Convert code blocks: ```lang\ncode\n``` -> ```code```
62+
// Remove language specifier from code blocks (supports alphanumeric, hyphens, plus, etc.)
63+
codeBlockPattern := regexp.MustCompile("```[a-zA-Z0-9+#\\-]*\n")
64+
text = codeBlockPattern.ReplaceAllString(text, "```\n")
65+
66+
// Convert unordered lists: * item or - item -> • item
67+
listPattern := regexp.MustCompile(`(?m)^[\*\-]\s+`)
68+
text = listPattern.ReplaceAllString(text, "• ")
69+
70+
// Convert ordered lists: 1. item -> 1. item (no change needed)
71+
72+
return text
73+
}

markdown_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestConvertMarkdownToMrkdwn(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
markdown string
11+
expected string
12+
}{
13+
{
14+
name: "bold with double asterisks",
15+
markdown: "This is **bold** text",
16+
expected: "This is *bold* text",
17+
},
18+
{
19+
name: "bold with double underscores",
20+
markdown: "This is __bold__ text",
21+
expected: "This is *bold* text",
22+
},
23+
{
24+
name: "strikethrough",
25+
markdown: "This is ~~strikethrough~~ text",
26+
expected: "This is ~strikethrough~ text",
27+
},
28+
{
29+
name: "inline code",
30+
markdown: "This is `code` text",
31+
expected: "This is `code` text",
32+
},
33+
{
34+
name: "link",
35+
markdown: "Check out [Google](https://google.com)",
36+
expected: "Check out <https://google.com|Google>",
37+
},
38+
{
39+
name: "code block with language",
40+
markdown: "```python\nprint('hello')\n```",
41+
expected: "```\nprint('hello')\n```",
42+
},
43+
{
44+
name: "code block without language",
45+
markdown: "```\ncode here\n```",
46+
expected: "```\ncode here\n```",
47+
},
48+
{
49+
name: "unordered list with asterisk",
50+
markdown: "* Item 1\n* Item 2",
51+
expected: "• Item 1\n• Item 2",
52+
},
53+
{
54+
name: "unordered list with dash",
55+
markdown: "- Item 1\n- Item 2",
56+
expected: "• Item 1\n• Item 2",
57+
},
58+
{
59+
name: "ordered list",
60+
markdown: "1. First\n2. Second",
61+
expected: "1. First\n2. Second",
62+
},
63+
{
64+
name: "mixed formatting",
65+
markdown: "This is **bold** and ~~strike~~ with a [link](https://example.com)",
66+
expected: "This is *bold* and ~strike~ with a <https://example.com|link>",
67+
},
68+
{
69+
name: "italic with single asterisk",
70+
markdown: "This is *italic* text",
71+
expected: "This is _italic_ text",
72+
},
73+
{
74+
name: "code block with complex language specifier",
75+
markdown: "```c++\nint main() {}\n```",
76+
expected: "```\nint main() {}\n```",
77+
},
78+
{
79+
name: "code block with c# language",
80+
markdown: "```c#\npublic void Main() {}\n```",
81+
expected: "```\npublic void Main() {}\n```",
82+
},
83+
{
84+
name: "code block with hyphenated language",
85+
markdown: "```objective-c\n@interface MyClass\n```",
86+
expected: "```\n@interface MyClass\n```",
87+
},
88+
}
89+
90+
for _, tt := range tests {
91+
t.Run(tt.name, func(t *testing.T) {
92+
result := convertMarkdownToMrkdwn(tt.markdown)
93+
if result != tt.expected {
94+
t.Errorf("convertMarkdownToMrkdwn() = %q, want %q", result, tt.expected)
95+
}
96+
})
97+
}
98+
}

0 commit comments

Comments
 (0)