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>
282 lines
7.0 KiB
Go
282 lines
7.0 KiB
Go
package slackwebhook
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
|
)
|
|
|
|
func TestNewSlackWebhookChannel_Validation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
webhooks map[string]config.SlackWebhookTarget
|
|
expectErr string
|
|
}{
|
|
{
|
|
name: "empty webhooks",
|
|
webhooks: map[string]config.SlackWebhookTarget{},
|
|
expectErr: "at least one webhook target is required",
|
|
},
|
|
{
|
|
name: "missing default",
|
|
webhooks: map[string]config.SlackWebhookTarget{
|
|
"alerts": {
|
|
WebhookURL: *config.NewSecureString("https://hooks.slack.com/services/T/B/x"),
|
|
},
|
|
},
|
|
expectErr: "a 'default' webhook target is required",
|
|
},
|
|
{
|
|
name: "empty webhook URL",
|
|
webhooks: map[string]config.SlackWebhookTarget{
|
|
"default": {WebhookURL: *config.NewSecureString("")},
|
|
},
|
|
expectErr: "has empty webhook_url",
|
|
},
|
|
{
|
|
name: "non-HTTPS URL",
|
|
webhooks: map[string]config.SlackWebhookTarget{
|
|
"default": {
|
|
WebhookURL: *config.NewSecureString("http://hooks.slack.com/services/T/B/x"),
|
|
},
|
|
},
|
|
expectErr: "must use HTTPS",
|
|
},
|
|
{
|
|
name: "valid config",
|
|
webhooks: map[string]config.SlackWebhookTarget{
|
|
"default": {
|
|
WebhookURL: *config.NewSecureString("https://hooks.slack.com/services/T/B/x"),
|
|
Username: "TestBot",
|
|
IconEmoji: ":robot_face:",
|
|
},
|
|
},
|
|
expectErr: "",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
cfg := &config.SlackWebhookSettings{Webhooks: tt.webhooks}
|
|
bc := &config.Channel{Enabled: true}
|
|
mb := bus.NewMessageBus()
|
|
|
|
ch, err := NewSlackWebhookChannel(bc, cfg, mb)
|
|
if tt.expectErr != "" {
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), tt.expectErr)
|
|
} else {
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, ch)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSlackWebhookChannel_Send(t *testing.T) {
|
|
payloadCh := make(chan map[string]any, 1)
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
body, _ := io.ReadAll(r.Body)
|
|
var payload map[string]any
|
|
json.Unmarshal(body, &payload)
|
|
payloadCh <- payload
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &config.SlackWebhookSettings{
|
|
Webhooks: map[string]config.SlackWebhookTarget{
|
|
"default": {
|
|
WebhookURL: *config.NewSecureString(server.URL),
|
|
Username: "TestBot",
|
|
IconEmoji: ":test:",
|
|
},
|
|
},
|
|
}
|
|
bc := &config.Channel{Enabled: true}
|
|
mb := bus.NewMessageBus()
|
|
|
|
ch, err := NewSlackWebhookChannel(bc, cfg, mb)
|
|
require.NoError(t, err)
|
|
|
|
// Use the test server's client to skip TLS verification
|
|
ch.client = server.Client()
|
|
|
|
err = ch.Start(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
_, err = ch.Send(context.Background(), bus.OutboundMessage{
|
|
Content: "Hello **world**",
|
|
ChatID: "default",
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Verify payload structure
|
|
receivedPayload := <-payloadCh
|
|
assert.Equal(t, "TestBot", receivedPayload["username"])
|
|
assert.Equal(t, ":test:", receivedPayload["icon_emoji"])
|
|
blocks, ok := receivedPayload["blocks"].([]any)
|
|
require.True(t, ok)
|
|
require.Len(t, blocks, 1)
|
|
}
|
|
|
|
func TestSlackWebhookChannel_FallbackToDefault(t *testing.T) {
|
|
var requestCount atomic.Int32
|
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requestCount.Add(1)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := &config.SlackWebhookSettings{
|
|
Webhooks: map[string]config.SlackWebhookTarget{
|
|
"default": {WebhookURL: *config.NewSecureString(server.URL)},
|
|
},
|
|
}
|
|
bc := &config.Channel{Enabled: true}
|
|
mb := bus.NewMessageBus()
|
|
|
|
ch, err := NewSlackWebhookChannel(bc, cfg, mb)
|
|
require.NoError(t, err)
|
|
ch.client = server.Client()
|
|
err = ch.Start(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
// Send to unknown target - should fall back to default
|
|
_, err = ch.Send(context.Background(), bus.OutboundMessage{
|
|
Content: "Test",
|
|
ChatID: "unknown_target",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, int32(1), requestCount.Load())
|
|
}
|
|
|
|
func TestSlackWebhookChannel_ErrorClassification(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
statusCode int
|
|
expectTemp bool
|
|
}{
|
|
{"400 Bad Request", 400, false},
|
|
{"401 Unauthorized", 401, false},
|
|
{"403 Forbidden", 403, false},
|
|
{"404 Not Found", 404, false},
|
|
{"500 Internal Error", 500, true},
|
|
{"502 Bad Gateway", 502, true},
|
|
{"503 Service Unavailable", 503, true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
server := httptest.NewTLSServer(
|
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(tt.statusCode)
|
|
}),
|
|
)
|
|
defer server.Close()
|
|
|
|
cfg := &config.SlackWebhookSettings{
|
|
Webhooks: map[string]config.SlackWebhookTarget{
|
|
"default": {WebhookURL: *config.NewSecureString(server.URL)},
|
|
},
|
|
}
|
|
bc := &config.Channel{Enabled: true}
|
|
mb := bus.NewMessageBus()
|
|
|
|
ch, err := NewSlackWebhookChannel(bc, cfg, mb)
|
|
require.NoError(t, err)
|
|
ch.client = server.Client()
|
|
err = ch.Start(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
_, err = ch.Send(context.Background(), bus.OutboundMessage{Content: "Test"})
|
|
require.Error(t, err)
|
|
|
|
if tt.expectTemp {
|
|
assert.True(
|
|
t,
|
|
errors.Is(err, channels.ErrTemporary),
|
|
"expected temporary error for %d",
|
|
tt.statusCode,
|
|
)
|
|
} else {
|
|
assert.True(t, errors.Is(err, channels.ErrSendFailed), "expected permanent error for %d", tt.statusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSplitText_ChunkSizeLimit(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
maxLen int
|
|
}{
|
|
{
|
|
name: "plain text",
|
|
input: strings.Repeat("a", 5000),
|
|
maxLen: 3000,
|
|
},
|
|
{
|
|
name: "text with code block",
|
|
input: "```\n" + strings.Repeat("x", 5000) + "\n```",
|
|
maxLen: 3000,
|
|
},
|
|
{
|
|
name: "multiple code blocks",
|
|
input: "text\n```\n" + strings.Repeat(
|
|
"code ",
|
|
800,
|
|
) + "\n```\nmore text\n```\n" + strings.Repeat(
|
|
"more ",
|
|
800,
|
|
) + "\n```",
|
|
maxLen: 3000,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
chunks := splitText(tt.input, tt.maxLen)
|
|
for i, chunk := range chunks {
|
|
runeLen := len([]rune(chunk))
|
|
assert.LessOrEqual(t, runeLen, tt.maxLen,
|
|
"chunk %d has %d runes, exceeds max %d", i, runeLen, tt.maxLen)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSplitText_FenceIntegrity(t *testing.T) {
|
|
input := "```\n" + strings.Repeat("line of code\n", 300) + "```"
|
|
|
|
chunks := splitText(input, 3000)
|
|
require.Greater(t, len(chunks), 1, "expected multiple chunks")
|
|
|
|
for i, chunk := range chunks {
|
|
openCount := strings.Count(chunk, "```")
|
|
assert.Equal(t, 0, openCount%2,
|
|
"chunk %d has unbalanced fence markers (count=%d)", i, openCount)
|
|
}
|
|
}
|
|
|
|
func TestSplitText_ShortText(t *testing.T) {
|
|
input := "short text"
|
|
chunks := splitText(input, 3000)
|
|
require.Len(t, chunks, 1)
|
|
assert.Equal(t, input, chunks[0])
|
|
}
|