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:
parent
0ae7c4445c
commit
f17817bde0
5 changed files with 306 additions and 131 deletions
4
go.mod
4
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -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>.
|
||||
// -  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 {
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue