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/BurntSushi/toml v1.6.0
|
||||||
github.com/adhocore/gronx v1.19.6
|
github.com/adhocore/gronx v1.19.6
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
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 v1.41.4
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
||||||
github.com/aws/aws-sdk-go-v2/service/bedrockruntime v1.50.2
|
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/openai/openai-go/v3 v3.22.0
|
||||||
github.com/rivo/tview v0.42.0
|
github.com/rivo/tview v0.42.0
|
||||||
github.com/rs/zerolog v1.34.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/spf13/cobra v1.10.2
|
||||||
github.com/stretchr/testify v1.11.1
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/tencent-connect/botgo v0.2.1
|
github.com/tencent-connect/botgo v0.2.1
|
||||||
|
|
@ -46,7 +47,6 @@ require (
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
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/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/credentials v1.19.12 // indirect
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // 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/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 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
|
||||||
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
|
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.20.0 h1:gbDdbee8+Z2o+DWx05Spq3GzbrLLleiRwHUKs+hZLSU=
|
||||||
github.com/slack-go/slack v0.17.3/go.mod h1:X+UqOufi3LYQHDnMG1vxf0J8asC6+WllXrVrhl8/Prk=
|
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 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"regexp"
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
goslack "github.com/slack-go/slack"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
|
|
@ -24,23 +26,20 @@ var (
|
||||||
reMrkdwnTableSep = regexp.MustCompile(`(?m)^\|[\s|:-]+\|$`)
|
reMrkdwnTableSep = regexp.MustCompile(`(?m)^\|[\s|:-]+\|$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
// markdownToSlackMrkdwn converts standard Markdown (as produced by LLMs) into
|
// slackMessage holds the converted content, optionally including a Block Kit
|
||||||
// Slack's mrkdwn format.
|
// table for native Slack table rendering.
|
||||||
//
|
type slackMessage struct {
|
||||||
// Conversion rules:
|
text string // mrkdwn-formatted text (without the first table)
|
||||||
// - Fenced code blocks and inline code are preserved unchanged.
|
table *goslack.TableBlock // nil when the message has no table
|
||||||
// - Markdown tables are converted to preformatted code blocks.
|
}
|
||||||
// - Headings (# … ######) become *bold* text.
|
|
||||||
// - **bold** and __bold__ become *bold*.
|
// convertMessage converts standard Markdown into Slack's mrkdwn format and
|
||||||
// - ~~strikethrough~~ becomes ~strikethrough~.
|
// extracts the first Markdown table as a Block Kit TableBlock. Slack allows
|
||||||
// - [text](url) becomes <url|text>.
|
// only one table per message, so any additional tables fall back to
|
||||||
// -  becomes <url|alt>.
|
// preformatted code blocks.
|
||||||
// - Horizontal rules (---) are removed.
|
func convertMessage(text string) slackMessage {
|
||||||
// - Unordered list markers (-, *, +) become bullet characters (•).
|
|
||||||
// - Italic (_text_), blockquotes (> text), and code pass through unchanged.
|
|
||||||
func markdownToSlackMrkdwn(text string) string {
|
|
||||||
if text == "" {
|
if text == "" {
|
||||||
return ""
|
return slackMessage{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Extract code blocks and inline code to protect them from conversion.
|
// 1. Extract code blocks and inline code to protect them from conversion.
|
||||||
|
|
@ -58,49 +57,15 @@ func markdownToSlackMrkdwn(text string) string {
|
||||||
return inlineCodePlaceholder(idx)
|
return inlineCodePlaceholder(idx)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 2. Convert tables to preformatted code blocks.
|
// 2. Extract tables — first table becomes a Block Kit TableBlock,
|
||||||
text = convertTables(text)
|
// 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
|
// 3. Apply inline mrkdwn conversions.
|
||||||
// since the heading itself is rendered as bold.
|
text = convertInlineMarkdown(text)
|
||||||
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*.
|
// 4. Restore inline code, then code blocks.
|
||||||
// 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.
|
|
||||||
for i, code := range inlineCodes {
|
for i, code := range inlineCodes {
|
||||||
text = strings.Replace(text, inlineCodePlaceholder(i), code, 1)
|
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")
|
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
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertTables detects Markdown tables (consecutive lines matching |...|) and
|
// extractTables scans the text for Markdown tables. The first table found is
|
||||||
// wraps them in fenced code blocks so Slack renders them as preformatted text.
|
// converted to a Block Kit TableBlock and replaced with a placeholder that is
|
||||||
func convertTables(text string) string {
|
// later removed. Any additional tables are converted to preformatted code blocks.
|
||||||
|
func extractTables(text string) (string, *goslack.TableBlock) {
|
||||||
lines := strings.Split(text, "\n")
|
lines := strings.Split(text, "\n")
|
||||||
var result []string
|
var result []string
|
||||||
|
var tableLines []string
|
||||||
|
var firstTable *goslack.TableBlock
|
||||||
inTable := false
|
inTable := false
|
||||||
|
tableCount := 0
|
||||||
|
|
||||||
for i := 0; i < len(lines); i++ {
|
flush := func() {
|
||||||
line := lines[i]
|
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 reMrkdwnTableRow.MatchString(line) {
|
||||||
if !inTable {
|
if !inTable {
|
||||||
inTable = true
|
inTable = true
|
||||||
result = append(result, "```")
|
|
||||||
}
|
}
|
||||||
// Skip separator rows (| --- | --- |) — they add no value in plain text.
|
tableLines = append(tableLines, line)
|
||||||
if reMrkdwnTableSep.MatchString(line) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// Strip leading/trailing pipes and clean up cell content.
|
|
||||||
result = append(result, formatTableRow(line))
|
|
||||||
} else {
|
} else {
|
||||||
if inTable {
|
if inTable {
|
||||||
inTable = false
|
inTable = false
|
||||||
result = append(result, "```")
|
flush()
|
||||||
}
|
}
|
||||||
result = append(result, line)
|
result = append(result, line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close any trailing table.
|
|
||||||
if inTable {
|
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.
|
// parseTableRows extracts cell contents from Markdown table lines, skipping
|
||||||
func formatTableRow(line string) string {
|
// separator rows and stripping inline markdown formatting.
|
||||||
// Remove leading and trailing pipe.
|
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.TrimSpace(line)
|
||||||
line = strings.TrimPrefix(line, "|")
|
line = strings.TrimPrefix(line, "|")
|
||||||
line = strings.TrimSuffix(line, "|")
|
line = strings.TrimSuffix(line, "|")
|
||||||
|
|
||||||
cells := strings.Split(line, "|")
|
cells := strings.Split(line, "|")
|
||||||
for i, cell := range cells {
|
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 {
|
func codePlaceholder(idx int) string {
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,18 @@
|
||||||
package slack
|
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 {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
expected string
|
expected string
|
||||||
|
hasTable bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "empty string",
|
name: "empty string",
|
||||||
|
|
@ -141,23 +147,18 @@ func Test_markdownToSlackMrkdwn(t *testing.T) {
|
||||||
expected: "1. first\n2. second",
|
expected: "1. first\n2. second",
|
||||||
},
|
},
|
||||||
|
|
||||||
// Tables
|
// Tables → extracted as Block Kit table, removed from text
|
||||||
{
|
{
|
||||||
name: "simple table",
|
name: "table extracted from text",
|
||||||
input: "| Name | Age |\n| --- | --- |\n| Alice | 30 |\n| Bob | 25 |",
|
input: "| A | B |\n| --- | --- |\n| 1 | 2 |",
|
||||||
expected: "```\nName | Age\nAlice | 30\nBob | 25\n```",
|
expected: "",
|
||||||
|
hasTable: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "table with surrounding text",
|
name: "table with surrounding text",
|
||||||
input: "Here is a table:\n\n| A | B |\n| --- | --- |\n| 1 | 2 |\n\nEnd.",
|
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.",
|
expected: "Here is a table:\n\nEnd.",
|
||||||
},
|
hasTable: true,
|
||||||
|
|
||||||
// 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.",
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// Edge cases
|
// Edge cases
|
||||||
|
|
@ -180,68 +181,151 @@ func Test_markdownToSlackMrkdwn(t *testing.T) {
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
actual := markdownToSlackMrkdwn(tc.input)
|
msg := convertMessage(tc.input)
|
||||||
if actual != tc.expected {
|
if msg.text != tc.expected {
|
||||||
t.Errorf("\ninput: %q\nexpected: %q\nactual: %q", tc.input, tc.expected, actual)
|
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 {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
input string
|
||||||
expected string
|
wantRows int
|
||||||
|
wantCols int
|
||||||
|
wantHeader []string
|
||||||
|
wantDataRow []string
|
||||||
}{
|
}{
|
||||||
{
|
|
||||||
name: "no table",
|
|
||||||
input: "just some text",
|
|
||||||
expected: "just some text",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "simple two column",
|
name: "simple two column",
|
||||||
input: "| A | B |\n| --- | --- |\n| 1 | 2 |",
|
input: "| Name | Age |\n| --- | --- |\n| Alice | 30 |",
|
||||||
expected: "```\nA | B\n1 | 2\n```",
|
wantRows: 2,
|
||||||
|
wantCols: 2,
|
||||||
|
wantHeader: []string{"Name", "Age"},
|
||||||
|
wantDataRow: []string{"Alice", "30"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "three columns with alignment markers",
|
name: "three columns",
|
||||||
input: "| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |",
|
input: "| A | B | C |\n| --- | --- | --- |\n| 1 | 2 | 3 |",
|
||||||
expected: "```\nLeft | Center | Right\na | b | c\n```",
|
wantRows: 2,
|
||||||
|
wantCols: 3,
|
||||||
|
wantHeader: []string{"A", "B", "C"},
|
||||||
|
wantDataRow: []string{"1", "2", "3"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "multiple tables separated by text",
|
name: "bold stripped from cells",
|
||||||
input: "| A | B |\n| - | - |\n| 1 | 2 |\n\ntext\n\n| C | D |\n| - | - |\n| 3 | 4 |",
|
input: "| Feature | **Status** |\n| --- | --- |\n| **Auth** | Done |",
|
||||||
expected: "```\nA | B\n1 | 2\n```\n\ntext\n\n```\nC | D\n3 | 4\n```",
|
wantRows: 2,
|
||||||
|
wantCols: 2,
|
||||||
|
wantHeader: []string{"Feature", "Status"},
|
||||||
|
wantDataRow: []string{"Auth", "Done"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
actual := convertTables(tc.input)
|
msg := convertMessage(tc.input)
|
||||||
if actual != tc.expected {
|
if msg.table == nil {
|
||||||
t.Errorf("\ninput: %q\nexpected: %q\nactual: %q", tc.input, tc.expected, actual)
|
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 {
|
cases := []struct {
|
||||||
input string
|
input string
|
||||||
expected string
|
expected []string
|
||||||
}{
|
}{
|
||||||
{"| A | B |", "A | B"},
|
{"| A | B |", []string{"A", "B"}},
|
||||||
{"| one | two | three |", "one | two | three"},
|
{"| one | two | three |", []string{"one", "two", "three"}},
|
||||||
{"| spaced | cells |", "spaced | cells"},
|
{"| spaced | cells |", []string{"spaced", "cells"}},
|
||||||
|
{"| **Bold** | ~~strike~~ |", []string{"Bold", "strike"}},
|
||||||
|
{"| `code` | __under__ |", []string{"code", "under"}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.input, func(t *testing.T) {
|
t.Run(tc.input, func(t *testing.T) {
|
||||||
actual := formatTableRow(tc.input)
|
actual := splitTableRow(tc.input)
|
||||||
if actual != tc.expected {
|
if len(actual) != len(tc.expected) {
|
||||||
t.Errorf("formatTableRow(%q) = %q, want %q", tc.input, actual, 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)
|
return fmt.Errorf("invalid slack chat ID: %s", msg.ChatID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
converted := convertMessage(msg.Content)
|
||||||
|
|
||||||
opts := []slack.MsgOption{
|
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 == "" {
|
if msg.ReplyToMessageID != "" && threadTS == "" {
|
||||||
|
|
@ -187,7 +202,7 @@ func (c *SlackChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessa
|
||||||
title = filename
|
title = filename
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.api.UploadFileV2Context(ctx, slack.UploadFileV2Parameters{
|
_, err = c.api.UploadFileContext(ctx, slack.UploadFileParameters{
|
||||||
Channel: channelID,
|
Channel: channelID,
|
||||||
File: localPath,
|
File: localPath,
|
||||||
Filename: filename,
|
Filename: filename,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue