feat(slack): use Block Kit TableBlock for native table rendering

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) <noreply@anthropic.com>
This commit is contained in:
Andrei Simion 2026-03-29 23:37:30 +07:00
parent 0ae7c4445c
commit f17817bde0
5 changed files with 306 additions and 131 deletions

4
go.mod
View file

@ -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

4
go.sum
View file

@ -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=

View file

@ -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 <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 {
// 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) → <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.
// 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) → <url|text>.
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 {

View file

@ -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 <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.",
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 ""
}

View file

@ -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,