From f17817bde0880db213bda1501a9906054f52f59d Mon Sep 17 00:00:00 2001 From: Andrei Simion Date: Sun, 29 Mar 2026 23:37:30 +0700 Subject: [PATCH] feat(slack): use Block Kit TableBlock for native table rendering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace preformatted code block tables with Slack's native Block Kit TableBlock (available since slack-go v0.18.0). The first Markdown table in a message is rendered as a native table; any additional tables fall back to preformatted code blocks (Slack allows one table per message). Also upgrades slack-go from v0.17.3 to v0.20.0 for TableBlock support and adapts UploadFileV2Context → UploadFileContext for the new API. Co-Authored-By: Claude Opus 4.6 (1M context) --- go.mod | 4 +- go.sum | 4 +- .../slack/parse_markdown_to_mrkdwn.go | 234 ++++++++++++------ .../slack/parse_markdown_to_mrkdwn_test.go | 176 +++++++++---- pkg/channels/slack/slack.go | 19 +- 5 files changed, 306 insertions(+), 131 deletions(-) diff --git a/go.mod b/go.mod index 202839c29..1e453290a 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 + github.com/atotto/clipboard v0.1.4 github.com/aws/aws-sdk-go-v2 v1.41.4 github.com/aws/aws-sdk-go-v2/config v1.32.12 github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2 @@ -28,7 +29,7 @@ require ( github.com/openai/openai-go/v3 v3.22.0 github.com/rivo/tview v0.42.0 github.com/rs/zerolog v1.34.0 - github.com/slack-go/slack v0.17.3 + github.com/slack-go/slack v0.20.0 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 github.com/tencent-connect/botgo v0.2.1 @@ -46,7 +47,6 @@ require ( require ( filippo.io/edwards25519 v1.2.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.7 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect diff --git a/go.sum b/go.sum index c64f3593d..fdff08a34 100644 --- a/go.sum +++ b/go.sum @@ -221,8 +221,8 @@ github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfv github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= -github.com/slack-go/slack v0.17.3 h1:zV5qO3Q+WJAQ/XwbGfNFrRMaJ5T/naqaonyPV/1TP4g= -github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk= +github.com/slack-go/slack v0.20.0 h1:gbDdbee8+Z2o+DWx05Spq3GzbrLLleiRwHUKs+hZLSU= +github.com/slack-go/slack v0.20.0/go.mod h1:K81UmCivcYd/5Jmz8vLBfuyoZ3B4rQC2GHVXHteXiAE= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= diff --git a/pkg/channels/slack/parse_markdown_to_mrkdwn.go b/pkg/channels/slack/parse_markdown_to_mrkdwn.go index 514cf597d..53a6349b3 100644 --- a/pkg/channels/slack/parse_markdown_to_mrkdwn.go +++ b/pkg/channels/slack/parse_markdown_to_mrkdwn.go @@ -4,6 +4,8 @@ import ( "fmt" "regexp" "strings" + + goslack "github.com/slack-go/slack" ) var ( @@ -24,23 +26,20 @@ var ( 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 { +// slackMessage holds the converted content, optionally including a Block Kit +// table for native Slack table rendering. +type slackMessage struct { + text string // mrkdwn-formatted text (without the first table) + table *goslack.TableBlock // nil when the message has no table +} + +// convertMessage converts standard Markdown into Slack's mrkdwn format and +// extracts the first Markdown table as a Block Kit TableBlock. Slack allows +// only one table per message, so any additional tables fall back to +// preformatted code blocks. +func convertMessage(text string) slackMessage { if text == "" { - return "" + return slackMessage{} } // 1. Extract code blocks and inline code to protect them from conversion. @@ -58,49 +57,15 @@ func markdownToSlackMrkdwn(text string) string { return inlineCodePlaceholder(idx) }) - // 2. Convert tables to preformatted code blocks. - text = convertTables(text) + // 2. Extract tables — first table becomes a Block Kit TableBlock, + // any subsequent tables become preformatted code blocks. + var table *goslack.TableBlock + text, table = extractTables(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 + "*" - }) + // 3. Apply inline mrkdwn conversions. + text = convertInlineMarkdown(text) - // 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. + // 4. Restore inline code, then code blocks. for i, code := range inlineCodes { text = strings.Replace(text, inlineCodePlaceholder(i), code, 1) } @@ -113,59 +78,170 @@ func markdownToSlackMrkdwn(text string) string { text = strings.ReplaceAll(text, "\n\n\n", "\n\n") } + return slackMessage{text: text, table: table} +} + +// convertInlineMarkdown applies all non-table mrkdwn transformations. +func convertInlineMarkdown(text string) string { + // 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 + "*" + }) + + // Bold: **text** and __text__ → *text*. + text = reMrkdwnBoldStar.ReplaceAllString(text, "*$1*") + text = reMrkdwnBoldUnder.ReplaceAllString(text, "*$1*") + + // Strikethrough: ~~text~~ → ~text~. + text = reMrkdwnStrike.ReplaceAllString(text, "~$1~") + + // Images before links (images have the ! prefix). + text = reMrkdwnImage.ReplaceAllString(text, "<$2|$1>") + + // Links: [text](url) → . + text = reMrkdwnLink.ReplaceAllString(text, "<$2|$1>") + + // Horizontal rules → remove. + text = reMrkdwnHR.ReplaceAllString(text, "") + + // 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 " + }) + 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 { +// extractTables scans the text for Markdown tables. The first table found is +// converted to a Block Kit TableBlock and replaced with a placeholder that is +// later removed. Any additional tables are converted to preformatted code blocks. +func extractTables(text string) (string, *goslack.TableBlock) { lines := strings.Split(text, "\n") var result []string + var tableLines []string + var firstTable *goslack.TableBlock inTable := false + tableCount := 0 - for i := 0; i < len(lines); i++ { - line := lines[i] + flush := func() { + if len(tableLines) == 0 { + return + } + rows := parseTableRows(tableLines) + tableCount++ + if tableCount == 1 && len(rows) > 0 { + // First table → Block Kit TableBlock. + firstTable = buildTableBlock(rows) + // Leave a blank line where the table was (table renders at bottom). + } else { + // Subsequent tables → preformatted code block fallback. + result = append(result, "```") + for _, row := range rows { + result = append(result, strings.Join(row, " | ")) + } + result = append(result, "```") + } + tableLines = nil + } + + for _, line := range lines { 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)) + tableLines = append(tableLines, line) } else { if inTable { inTable = false - result = append(result, "```") + flush() } result = append(result, line) } } - - // Close any trailing table. if inTable { - result = append(result, "```") + flush() } - return strings.Join(result, "\n") + return strings.Join(result, "\n"), firstTable } -// formatTableRow strips the outer pipes from a table row and trims each cell. -func formatTableRow(line string) string { - // Remove leading and trailing pipe. +// parseTableRows extracts cell contents from Markdown table lines, skipping +// separator rows and stripping inline markdown formatting. +func parseTableRows(lines []string) [][]string { + var rows [][]string + for _, line := range lines { + if reMrkdwnTableSep.MatchString(line) { + continue + } + row := splitTableRow(line) + if len(row) > 0 { + rows = append(rows, row) + } + } + return rows +} + +// splitTableRow parses a single | ... | line into trimmed, markdown-stripped cells. +func splitTableRow(line string) []string { 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) + cell = strings.TrimSpace(cell) + cell = reMrkdwnBoldStar.ReplaceAllString(cell, "$1") + cell = reMrkdwnBoldUnder.ReplaceAllString(cell, "$1") + cell = reMrkdwnStrike.ReplaceAllString(cell, "$1") + cell = reMrkdwnInlineCode.ReplaceAllString(cell, "$1") + cells[i] = cell } - return strings.Join(cells, " | ") + return cells +} + +// buildTableBlock constructs a Slack Block Kit TableBlock from parsed rows. +// The first row is automatically rendered as a header by Slack. +func buildTableBlock(rows [][]string) *goslack.TableBlock { + table := goslack.NewTableBlock("") + + // Set all columns to left-aligned and wrapped. + if len(rows) > 0 { + settings := make([]goslack.ColumnSetting, len(rows[0])) + for i := range settings { + settings[i] = goslack.ColumnSetting{ + Align: goslack.ColumnAlignmentLeft, + IsWrapped: true, + } + } + table.WithColumnSettings(settings...) + } + + for _, row := range rows { + cells := make([]*goslack.RichTextBlock, len(row)) + for i, cellText := range row { + cells[i] = goslack.NewRichTextBlock("", + goslack.NewRichTextSection( + goslack.NewRichTextSectionTextElement(cellText, nil), + ), + ) + } + table.AddRow(cells...) + } + + return table } func codePlaceholder(idx int) string { diff --git a/pkg/channels/slack/parse_markdown_to_mrkdwn_test.go b/pkg/channels/slack/parse_markdown_to_mrkdwn_test.go index 046bc10c3..20d837065 100644 --- a/pkg/channels/slack/parse_markdown_to_mrkdwn_test.go +++ b/pkg/channels/slack/parse_markdown_to_mrkdwn_test.go @@ -1,12 +1,18 @@ package slack -import "testing" +import ( + "strings" + "testing" -func Test_markdownToSlackMrkdwn(t *testing.T) { + goslack "github.com/slack-go/slack" +) + +func Test_convertMessage_text(t *testing.T) { cases := []struct { name string input string expected string + hasTable bool }{ { name: "empty string", @@ -141,23 +147,18 @@ func Test_markdownToSlackMrkdwn(t *testing.T) { expected: "1. first\n2. second", }, - // Tables + // Tables → extracted as Block Kit table, removed from text { - name: "simple table", - input: "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |", - expected: "```\nName | Age\nAlice | 30\nBob | 25\n```", + name: "table extracted from text", + input: "| A | B |\n| --- | --- |\n| 1 | 2 |", + expected: "", + hasTable: true, }, { - 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.", + 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\nEnd.", + hasTable: true, }, // Edge cases @@ -180,68 +181,151 @@ func Test_markdownToSlackMrkdwn(t *testing.T) { 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) + msg := convertMessage(tc.input) + if msg.text != tc.expected { + t.Errorf("\ninput: %q\nexpected: %q\nactual: %q", tc.input, tc.expected, msg.text) + } + if tc.hasTable && msg.table == nil { + t.Error("expected table block, got nil") + } + if !tc.hasTable && msg.table != nil { + t.Error("expected no table block, got one") } }) } } -func Test_convertTables(t *testing.T) { +func Test_convertMessage_table(t *testing.T) { cases := []struct { - name string - input string - expected string + name string + input string + wantRows int + wantCols int + wantHeader []string + wantDataRow []string }{ { - name: "no table", - input: "just some text", - expected: "just some text", + name: "simple two column", + input: "| Name | Age |\n| --- | --- |\n| Alice | 30 |", + wantRows: 2, + wantCols: 2, + wantHeader: []string{"Name", "Age"}, + wantDataRow: []string{"Alice", "30"}, }, { - name: "simple two column", - input: "| A | B |\n| --- | --- |\n| 1 | 2 |", - expected: "```\nA | B\n1 | 2\n```", + name: "three columns", + input: "| A | B | C |\n| --- | --- | --- |\n| 1 | 2 | 3 |", + wantRows: 2, + wantCols: 3, + wantHeader: []string{"A", "B", "C"}, + wantDataRow: []string{"1", "2", "3"}, }, { - name: "three columns with alignment markers", - input: "| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |", - expected: "```\nLeft | Center | Right\na | b | c\n```", + name: "bold stripped from cells", + input: "| Feature | **Status** |\n| --- | --- |\n| **Auth** | Done |", + wantRows: 2, + wantCols: 2, + wantHeader: []string{"Feature", "Status"}, + wantDataRow: []string{"Auth", "Done"}, }, { - 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```", + name: "strikethrough stripped from cells", + input: "| Item | Status |\n| --- | --- |\n| ~~old~~ | removed |", + wantRows: 2, + wantCols: 2, + wantHeader: []string{"Item", "Status"}, + wantDataRow: []string{"old", "removed"}, }, } 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) + msg := convertMessage(tc.input) + if msg.table == nil { + t.Fatal("expected table block, got nil") + } + + if len(msg.table.Rows) != tc.wantRows { + t.Fatalf("rows: got %d, want %d", len(msg.table.Rows), tc.wantRows) + } + + if len(msg.table.Rows[0]) != tc.wantCols { + t.Fatalf("cols: got %d, want %d", len(msg.table.Rows[0]), tc.wantCols) + } + + for i, want := range tc.wantHeader { + got := cellText(msg.table.Rows[0][i]) + if got != want { + t.Errorf("header[%d]: got %q, want %q", i, got, want) + } + } + + if tc.wantDataRow != nil && len(msg.table.Rows) > 1 { + for i, want := range tc.wantDataRow { + got := cellText(msg.table.Rows[1][i]) + if got != want { + t.Errorf("data[0][%d]: got %q, want %q", i, got, want) + } + } } }) } } -func Test_formatTableRow(t *testing.T) { +func Test_convertMessage_multipleTablesSecondFallsBack(t *testing.T) { + input := "| A | B |\n| - | - |\n| 1 | 2 |\n\ntext\n\n| C | D |\n| - | - |\n| 3 | 4 |" + msg := convertMessage(input) + + if msg.table == nil { + t.Fatal("expected first table as Block Kit table") + } + + if !strings.Contains(msg.text, "```\nC | D") { + t.Errorf("expected second table as code block in text, got: %q", msg.text) + } +} + +func Test_splitTableRow(t *testing.T) { cases := []struct { input string - expected string + expected []string }{ - {"| A | B |", "A | B"}, - {"| one | two | three |", "one | two | three"}, - {"| spaced | cells |", "spaced | cells"}, + {"| A | B |", []string{"A", "B"}}, + {"| one | two | three |", []string{"one", "two", "three"}}, + {"| spaced | cells |", []string{"spaced", "cells"}}, + {"| **Bold** | ~~strike~~ |", []string{"Bold", "strike"}}, + {"| `code` | __under__ |", []string{"code", "under"}}, } 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) + actual := splitTableRow(tc.input) + if len(actual) != len(tc.expected) { + t.Fatalf("len: got %d, want %d", len(actual), len(tc.expected)) + } + for i := range tc.expected { + if actual[i] != tc.expected[i] { + t.Errorf("[%d]: got %q, want %q", i, actual[i], tc.expected[i]) + } } }) } } + +// cellText extracts the text string from a RichTextBlock table cell. +func cellText(cell *goslack.RichTextBlock) string { + for _, elem := range cell.Elements { + section, ok := elem.(*goslack.RichTextSection) + if !ok { + continue + } + for _, se := range section.Elements { + textElem, ok := se.(*goslack.RichTextSectionTextElement) + if !ok { + continue + } + return textElem.Text + } + } + return "" +} diff --git a/pkg/channels/slack/slack.go b/pkg/channels/slack/slack.go index 99a50b5fc..b67324a2a 100644 --- a/pkg/channels/slack/slack.go +++ b/pkg/channels/slack/slack.go @@ -118,8 +118,23 @@ func (c *SlackChannel) Send(ctx context.Context, msg bus.OutboundMessage) error return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID) } + converted := convertMessage(msg.Content) + opts := []slack.MsgOption{ - slack.MsgOptionText(markdownToSlackMrkdwn(msg.Content), false), + slack.MsgOptionText(converted.text, false), + } + + if converted.table != nil { + // Build blocks: a section block for the mrkdwn text, plus the table. + // Slack renders the table at the bottom regardless of block order. + blocks := []slack.Block{ + slack.NewSectionBlock( + slack.NewTextBlockObject("mrkdwn", converted.text, false, false), + nil, nil, + ), + converted.table, + } + opts = append(opts, slack.MsgOptionBlocks(blocks...)) } if msg.ReplyToMessageID != "" && threadTS == "" { @@ -187,7 +202,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa title = filename } - _, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{ + _, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{ Channel: channelID, File: localPath, Filename: filename,