mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
b12f03be2e
Add an output-only channel that sends messages to Slack via Incoming
Webhooks using Block Kit formatting.
Features:
- Multiple webhook targets with named routing (requires "default" target)
- Markdown to Slack mrkdwn conversion (bold, italic, strikethrough, links, lists)
- Code block handling with proper fence preservation across chunk splits
- Table rendering with aligned columns in code blocks
- Automatic text chunking at 3000 chars (Slack's text block limit)
- HTTPS-only webhook URL validation
Configuration example:
channels:
slack_webhook:
webhooks:
default:
webhook_url: "https://hooks.slack.com/services/..."
username: "PicoClaw"
icon_emoji: ":robot_face:"
Co-Authored-By: Claude <noreply@anthropic.com>
188 lines
4.5 KiB
Go
188 lines
4.5 KiB
Go
package slackwebhook
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
)
|
|
|
|
func TestConvertMarkdownToMrkdwn(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected string
|
|
}{
|
|
{
|
|
name: "bold double asterisk",
|
|
input: "This is **bold** text",
|
|
expected: "This is *bold* text",
|
|
},
|
|
{
|
|
name: "italic single asterisk",
|
|
input: "This is *italic* text",
|
|
expected: "This is _italic_ text",
|
|
},
|
|
{
|
|
name: "italic underscore",
|
|
input: "This is _italic_ text",
|
|
expected: "This is _italic_ text",
|
|
},
|
|
{
|
|
name: "strikethrough",
|
|
input: "This is ~~struck~~ text",
|
|
expected: "This is ~struck~ text",
|
|
},
|
|
{
|
|
name: "inline code unchanged",
|
|
input: "Use `code` here",
|
|
expected: "Use `code` here",
|
|
},
|
|
{
|
|
name: "link conversion",
|
|
input: "Click [here](https://example.com) now",
|
|
expected: "Click <https://example.com|here> now",
|
|
},
|
|
{
|
|
name: "header to bold",
|
|
input: "# Header One",
|
|
expected: "*Header One*",
|
|
},
|
|
{
|
|
name: "header level 2",
|
|
input: "## Header Two",
|
|
expected: "*Header Two*",
|
|
},
|
|
{
|
|
name: "bullet list",
|
|
input: "- item one\n- item two",
|
|
expected: "• item one\n• item two",
|
|
},
|
|
{
|
|
name: "mixed formatting",
|
|
input: "**bold** and *italic* and [link](http://x.com)",
|
|
expected: "*bold* and _italic_ and <http://x.com|link>",
|
|
},
|
|
{
|
|
name: "code block unchanged",
|
|
input: "```\ncode here\n```",
|
|
expected: "```\ncode here\n```",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := convertMarkdownToMrkdwn(tt.input)
|
|
assert.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSplitContentWithTables(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expectedCount int
|
|
expectedTables int
|
|
}{
|
|
{
|
|
name: "no table",
|
|
input: "Just some text",
|
|
expectedCount: 1,
|
|
expectedTables: 0,
|
|
},
|
|
{
|
|
name: "simple table",
|
|
input: "| A | B |\n|---|---|\n| 1 | 2 |",
|
|
expectedCount: 1,
|
|
expectedTables: 1,
|
|
},
|
|
{
|
|
name: "text before table",
|
|
input: "Intro text\n\n| A | B |\n|---|---|\n| 1 | 2 |",
|
|
expectedCount: 2,
|
|
expectedTables: 1,
|
|
},
|
|
{
|
|
name: "text before and after table",
|
|
input: "Before\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAfter",
|
|
expectedCount: 3,
|
|
expectedTables: 1,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
segments := splitContentWithTables(tt.input)
|
|
assert.Equal(t, tt.expectedCount, len(segments))
|
|
tableCount := 0
|
|
for _, seg := range segments {
|
|
if seg.isTable {
|
|
tableCount++
|
|
}
|
|
}
|
|
assert.Equal(t, tt.expectedTables, tableCount)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenderTable(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expectCode bool
|
|
}{
|
|
{
|
|
name: "narrow table renders as text",
|
|
input: "| A | B |\n|---|---|\n| 1 | 2 |",
|
|
expectCode: false,
|
|
},
|
|
{
|
|
name: "wide table renders as code block",
|
|
input: "| This is a very long column header | Another extremely long column header here |\n|---|---|\n| Some long value content here | More long value content |",
|
|
expectCode: true,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := renderTable(tt.input)
|
|
if tt.expectCode {
|
|
assert.Contains(t, result, "```")
|
|
} else {
|
|
assert.NotContains(t, result, "```")
|
|
assert.Contains(t, result, "*") // Bold headers
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRenderTable_Alignment(t *testing.T) {
|
|
input := "| Name | Status | Count |\n|---|---|---|\n| foo | OK | 1 |\n| barbaz | PENDING | 123 |"
|
|
result := renderTable(input)
|
|
|
|
// Should be mrkdwn (narrow table)
|
|
assert.NotContains(t, result, "```")
|
|
assert.Contains(t, result, "*Name*")
|
|
|
|
// Test wide table alignment
|
|
wideInput := "| This is a very long column header | Another extremely long column header here |\n|---|---|\n| Short | Longer value here |"
|
|
wideResult := renderTable(wideInput)
|
|
|
|
assert.Contains(t, wideResult, "```")
|
|
// Check that columns are padded - header and value should have same column width
|
|
lines := strings.Split(wideResult, "\n")
|
|
// Find the header line and a data line
|
|
var headerLine, dataLine string
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "This is a very long") {
|
|
headerLine = line
|
|
}
|
|
if strings.Contains(line, "Short") {
|
|
dataLine = line
|
|
}
|
|
}
|
|
// Both lines should have same length (aligned columns)
|
|
assert.Equal(t, len(headerLine), len(dataLine), "columns should be aligned")
|
|
}
|