From b12f03be2e4c43803af0c8dbd5339078333e23c9 Mon Sep 17 00:00:00 2001 From: Andy Lo-A-Foe Date: Mon, 11 May 2026 09:16:18 +0200 Subject: [PATCH] feat(channels): add slack_webhook channel 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 --- pkg/channels/manager.go | 2 + pkg/channels/manager_channel.go | 13 + pkg/channels/slack_webhook/convert.go | 263 +++++++++++++++ pkg/channels/slack_webhook/convert_test.go | 187 +++++++++++ pkg/channels/slack_webhook/init.go | 32 ++ pkg/channels/slack_webhook/slack_webhook.go | 316 ++++++++++++++++++ .../slack_webhook/slack_webhook_test.go | 281 ++++++++++++++++ pkg/config/config.go | 12 + pkg/config/config_channel.go | 2 + pkg/gateway/gateway.go | 1 + 10 files changed, 1109 insertions(+) create mode 100644 pkg/channels/slack_webhook/convert.go create mode 100644 pkg/channels/slack_webhook/convert_test.go create mode 100644 pkg/channels/slack_webhook/init.go create mode 100644 pkg/channels/slack_webhook/slack_webhook.go create mode 100644 pkg/channels/slack_webhook/slack_webhook_test.go diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 8a8f9dc03..d345a5d0b 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -688,6 +688,8 @@ func (m *Manager) getChannelConfigAndEnabled(channelName string) (*config.Channe return bc, true case *config.TeamsWebhookSettings: return bc, true + case *config.SlackWebhookSettings: + return bc, true case *config.DiscordSettings: return bc, settings.Token.String() != "" case *config.VKSettings: diff --git a/pkg/channels/manager_channel.go b/pkg/channels/manager_channel.go index a5e9a49be..9dbd35cab 100644 --- a/pkg/channels/manager_channel.go +++ b/pkg/channels/manager_channel.go @@ -107,6 +107,19 @@ func hiddenValues(key string, value map[string]any, ch *config.Channel) { value["username"] = settings.Username.String() value["password"] = settings.Password.String() } + case "slack_webhook": + // Expose webhook URLs for hash computation (they contain secrets) + if settings, ok := v.(*config.SlackWebhookSettings); ok { + webhooks := make(map[string]any) + for name, target := range settings.Webhooks { + webhooks[name] = map[string]any{ + "webhook_url": target.WebhookURL.String(), + "username": target.Username, + "icon_emoji": target.IconEmoji, + } + } + value["webhooks"] = webhooks + } } } diff --git a/pkg/channels/slack_webhook/convert.go b/pkg/channels/slack_webhook/convert.go new file mode 100644 index 000000000..6ee2be2f8 --- /dev/null +++ b/pkg/channels/slack_webhook/convert.go @@ -0,0 +1,263 @@ +package slackwebhook + +import ( + "fmt" + "regexp" + "strings" +) + +const maxTableRowWidth = 60 + +var ( + boldRe = regexp.MustCompile(`\*\*([^*]+)\*\*`) + strikeRe = regexp.MustCompile(`~~([^~]+)~~`) + linkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`) + headerRe = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`) + bulletRe = regexp.MustCompile(`(?m)^- (.+)$`) + markdownTableRe = regexp.MustCompile(`(?m)^(\|[^\n]+\|)\n(\|[-:\|\s]+\|)\n((?:\|[^\n]+\|\n?)+)`) + codeBlockRe = regexp.MustCompile("(?s)```.*?```") + inlineCodeRe = regexp.MustCompile("`[^`]+`") + italicRe = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)`) +) + +type contentSegment struct { + content string + isTable bool +} + +func convertMarkdownToMrkdwn(text string) string { + // Protect code blocks from conversion + var codeBlocks []string + text = codeBlockRe.ReplaceAllStringFunc(text, func(match string) string { + codeBlocks = append(codeBlocks, match) + return "\x00CODEBLOCK\x00" + }) + + // Protect inline code + var inlineCode []string + text = inlineCodeRe.ReplaceAllStringFunc(text, func(match string) string { + inlineCode = append(inlineCode, match) + return "\x00INLINE\x00" + }) + + // Convert italic *text* → _text_ BEFORE bold conversion + text = italicRe.ReplaceAllStringFunc(text, func(match string) string { + // Find the asterisk positions + firstAsterisk := strings.Index(match, "*") + lastAsterisk := strings.LastIndex(match, "*") + if firstAsterisk == lastAsterisk { + return match // Only one asterisk, not italic + } + + // Extract content between asterisks + content := match[firstAsterisk+1 : lastAsterisk] + + // Replace with underscores, preserving any prefix/suffix + return match[:firstAsterisk] + "_" + content + "_" + match[lastAsterisk+1:] + }) + + // Convert bold **text** → *text* + text = boldRe.ReplaceAllString(text, "*$1*") + + // Convert strikethrough ~~text~~ → ~text~ + text = strikeRe.ReplaceAllString(text, "~$1~") + + // Convert links [text](url) → + text = linkRe.ReplaceAllString(text, "<$2|$1>") + + // Convert headers # text → *text* + text = headerRe.ReplaceAllString(text, "*$1*") + + // Convert bullet lists - item → • item + text = bulletRe.ReplaceAllString(text, "• $1") + + // Restore inline code + for _, code := range inlineCode { + text = strings.Replace(text, "\x00INLINE\x00", code, 1) + } + + // Restore code blocks + for _, block := range codeBlocks { + text = strings.Replace(text, "\x00CODEBLOCK\x00", block, 1) + } + + return text +} + +func splitContentWithTables(content string) []contentSegment { + var segments []contentSegment + + // Protect code blocks from table detection using unique placeholders + var codeBlocks []string + blockIdx := 0 + protected := codeBlockRe.ReplaceAllStringFunc(content, func(match string) string { + codeBlocks = append(codeBlocks, match) + placeholder := fmt.Sprintf("\x00CODEBLOCK_%d\x00", blockIdx) + blockIdx++ + return placeholder + }) + + matches := markdownTableRe.FindAllStringSubmatchIndex(protected, -1) + if len(matches) == 0 { + return []contentSegment{{content: content, isTable: false}} + } + + // Restore code blocks using indexed placeholders + restoreCodeBlocks := func(s string) string { + result := s + for i, block := range codeBlocks { + placeholder := fmt.Sprintf("\x00CODEBLOCK_%d\x00", i) + result = strings.Replace(result, placeholder, block, 1) + } + return result + } + + lastEnd := 0 + for _, match := range matches { + if match[0] > lastEnd { + segments = append(segments, contentSegment{ + content: restoreCodeBlocks(protected[lastEnd:match[0]]), + isTable: false, + }) + } + segments = append(segments, contentSegment{ + content: restoreCodeBlocks(protected[match[0]:match[1]]), + isTable: true, + }) + lastEnd = match[1] + } + + if lastEnd < len(protected) { + segments = append(segments, contentSegment{ + content: restoreCodeBlocks(protected[lastEnd:]), + isTable: false, + }) + } + + return segments +} + +func renderTable(tableStr string) string { + lines := strings.Split(strings.TrimSpace(tableStr), "\n") + if len(lines) < 2 { + return "```\n" + tableStr + "\n```" + } + + // Parse all rows to get column widths + var allRows [][]string + maxCols := 0 + for i, line := range lines { + if i == 1 && isSeparatorRow(line) { + continue + } + cells := parseTableRow(line) + if len(cells) > 0 { + allRows = append(allRows, cells) + if len(cells) > maxCols { + maxCols = len(cells) + } + } + } + + if len(allRows) == 0 { + return "```\n" + tableStr + "\n```" + } + + // Calculate max width for each column using rune count + colWidths := make([]int, maxCols) + for _, row := range allRows { + for i, cell := range row { + runeLen := len([]rune(cell)) + if runeLen > colWidths[i] { + colWidths[i] = runeLen + } + } + } + + // Check if table is narrow enough for mrkdwn format + totalWidth := 0 + for _, w := range colWidths { + totalWidth += w + } + if len(colWidths) > 1 { + totalWidth += 3 * (len(colWidths) - 1) // " | " separators between columns + } + if totalWidth <= maxTableRowWidth { + // Render as formatted text with bold headers + var result strings.Builder + for i, row := range allRows { + if i == 0 { + var boldCells []string + for _, cell := range row { + boldCells = append(boldCells, "*"+cell+"*") + } + result.WriteString(strings.Join(boldCells, " | ")) + } else { + result.WriteString(strings.Join(row, " | ")) + } + result.WriteString("\n") + } + return strings.TrimSuffix(result.String(), "\n") + } + + // Render as aligned code block + var result strings.Builder + result.WriteString("```\n") + for i, row := range allRows { + var paddedCells []string + for j, cell := range row { + if j < len(colWidths) { + paddedCells = append(paddedCells, padRight(cell, colWidths[j])) + } else { + paddedCells = append(paddedCells, cell) + } + } + result.WriteString("| ") + result.WriteString(strings.Join(paddedCells, " | ")) + result.WriteString(" |\n") + + // Add separator after header + if i == 0 { + var sepParts []string + for _, w := range colWidths { + sepParts = append(sepParts, strings.Repeat("-", w)) + } + result.WriteString("|-") + result.WriteString(strings.Join(sepParts, "-|-")) + result.WriteString("-|\n") + } + } + result.WriteString("```") + return result.String() +} + +func padRight(s string, width int) string { + runeLen := len([]rune(s)) + if runeLen >= width { + return s + } + return s + strings.Repeat(" ", width-runeLen) +} + +func isSeparatorRow(line string) bool { + cleaned := strings.ReplaceAll(line, "|", "") + cleaned = strings.ReplaceAll(cleaned, " ", "") + cleaned = strings.ReplaceAll(cleaned, "-", "") + cleaned = strings.ReplaceAll(cleaned, ":", "") + return cleaned == "" +} + +func parseTableRow(line string) []string { + line = strings.TrimSpace(line) + line = strings.TrimPrefix(line, "|") + line = strings.TrimSuffix(line, "|") + if line == "" { + return nil + } + parts := strings.Split(line, "|") + var cells []string + for _, p := range parts { + cells = append(cells, strings.TrimSpace(p)) + } + return cells +} diff --git a/pkg/channels/slack_webhook/convert_test.go b/pkg/channels/slack_webhook/convert_test.go new file mode 100644 index 000000000..39ff4ac04 --- /dev/null +++ b/pkg/channels/slack_webhook/convert_test.go @@ -0,0 +1,187 @@ +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 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 ", + }, + { + 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") +} diff --git a/pkg/channels/slack_webhook/init.go b/pkg/channels/slack_webhook/init.go new file mode 100644 index 000000000..eed3ae083 --- /dev/null +++ b/pkg/channels/slack_webhook/init.go @@ -0,0 +1,32 @@ +package slackwebhook + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory( + config.ChannelSlackWebHook, + func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + bc := cfg.Channels[channelName] + decoded, err := bc.GetDecoded() + if err != nil { + return nil, err + } + c, ok := decoded.(*config.SlackWebhookSettings) + if !ok { + return nil, channels.ErrSendFailed + } + ch, err := NewSlackWebhookChannel(bc, c, b) + if err != nil { + return nil, err + } + if channelName != config.ChannelSlackWebHook { + ch.SetName(channelName) + } + return ch, nil + }, + ) +} diff --git a/pkg/channels/slack_webhook/slack_webhook.go b/pkg/channels/slack_webhook/slack_webhook.go new file mode 100644 index 000000000..95951de66 --- /dev/null +++ b/pkg/channels/slack_webhook/slack_webhook.go @@ -0,0 +1,316 @@ +package slackwebhook + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "sort" + "strings" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +const maxTextBlockLength = 3000 + +// SlackWebhookChannel is an output-only channel that sends messages +// to Slack via Incoming Webhooks using Block Kit formatting. +type SlackWebhookChannel struct { + *channels.BaseChannel + bc *config.Channel + config *config.SlackWebhookSettings + client *http.Client +} + +// NewSlackWebhookChannel creates a new Slack webhook channel. +func NewSlackWebhookChannel( + bc *config.Channel, + cfg *config.SlackWebhookSettings, + bus *bus.MessageBus, +) (*SlackWebhookChannel, error) { + if len(cfg.Webhooks) == 0 { + return nil, fmt.Errorf("slack_webhook: at least one webhook target is required") + } + + if _, hasDefault := cfg.Webhooks["default"]; !hasDefault { + return nil, fmt.Errorf("slack_webhook: a 'default' webhook target is required") + } + + for name, target := range cfg.Webhooks { + webhookURL := target.WebhookURL.String() + if webhookURL == "" { + return nil, fmt.Errorf("slack_webhook: webhook %q has empty webhook_url", name) + } + parsed, err := url.Parse(webhookURL) + if err != nil { + return nil, fmt.Errorf("slack_webhook: webhook %q has invalid URL format: %w", name, err) + } + if !strings.EqualFold(parsed.Scheme, "https") { + return nil, fmt.Errorf("slack_webhook: webhook %q must use HTTPS (got %q)", name, parsed.Scheme) + } + } + + base := channels.NewBaseChannel( + "slack_webhook", + cfg, + bus, + []string{"*"}, + channels.WithMaxMessageLength(40000), + ) + + return &SlackWebhookChannel{ + BaseChannel: base, + bc: bc, + config: cfg, + client: &http.Client{ + Timeout: 30 * time.Second, + }, + }, nil +} + +// Start initializes the channel. For output-only channels, this is a no-op. +func (c *SlackWebhookChannel) Start(ctx context.Context) error { + targets := make([]string, 0, len(c.config.Webhooks)) + for name := range c.config.Webhooks { + targets = append(targets, name) + } + sort.Strings(targets) + logger.InfoCF("slack_webhook", "Starting Slack webhook channel (output-only)", map[string]any{ + "targets": targets, + }) + c.SetRunning(true) + return nil +} + +// Stop shuts down the channel. +func (c *SlackWebhookChannel) Stop(ctx context.Context) error { + logger.InfoC("slack_webhook", "Stopping Slack webhook channel") + c.SetRunning(false) + return nil +} + +// Send delivers a message to the specified Slack webhook target. +func (c *SlackWebhookChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) { + if !c.IsRunning() { + return nil, channels.ErrNotRunning + } + + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + targetName := msg.ChatID + if targetName == "" { + targetName = "default" + } + + target, ok := c.config.Webhooks[targetName] + if !ok { + logger.WarnCF("slack_webhook", "Unknown target, falling back to default", map[string]any{ + "requested": msg.ChatID, + "using": "default", + }) + target = c.config.Webhooks["default"] + targetName = "default" + } + + payload := c.buildPayload(msg, target) + + jsonData, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("slack_webhook: failed to marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.WebhookURL.String(), bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("slack_webhook: failed to create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + logger.ErrorCF("slack_webhook", "Failed to send message", map[string]any{ + "target": targetName, + }) + // Don't expose raw error - it may contain webhook URL secrets + return nil, fmt.Errorf("slack_webhook: network error: %w", channels.ErrTemporary) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512)) + respText := strings.TrimSpace(string(respBody)) + if respText == "" { + respText = http.StatusText(resp.StatusCode) + if respText == "" { + respText = "unknown error" + } + } + logger.ErrorCF("slack_webhook", "Slack API error", map[string]any{ + "target": targetName, + "status": resp.StatusCode, + "response": respText, + }) + sendErr := fmt.Errorf("status %d: %s", resp.StatusCode, respText) + return nil, fmt.Errorf("slack_webhook: %w", channels.ClassifySendError(resp.StatusCode, sendErr)) + } + + logger.DebugCF("slack_webhook", "Message sent successfully", map[string]any{ + "target": targetName, + }) + + return nil, nil +} + +func (c *SlackWebhookChannel) buildPayload(msg bus.OutboundMessage, target config.SlackWebhookTarget) map[string]any { + payload := make(map[string]any) + + if target.Username != "" { + payload["username"] = target.Username + } + if target.IconEmoji != "" { + payload["icon_emoji"] = target.IconEmoji + } + + content := msg.Content + if content == "" { + content = "(empty message)" + } + + blocks := c.buildBlocks(content) + payload["blocks"] = blocks + + return payload +} + +func (c *SlackWebhookChannel) buildBlocks(content string) []map[string]any { + var blocks []map[string]any + + segments := splitContentWithTables(content) + + for _, seg := range segments { + if seg.isTable { + tableText := renderTable(seg.content) + for _, chunk := range splitText(tableText, maxTextBlockLength) { + blocks = append(blocks, c.textSection(chunk)) + } + } else { + text := strings.TrimSpace(seg.content) + if text == "" { + continue + } + converted := convertMarkdownToMrkdwn(text) + for _, chunk := range splitText(converted, maxTextBlockLength) { + blocks = append(blocks, c.textSection(chunk)) + } + } + } + + if len(blocks) == 0 { + blocks = append(blocks, c.textSection("(empty message)")) + } + + return blocks +} + +func (c *SlackWebhookChannel) textSection(text string) map[string]any { + return map[string]any{ + "type": "section", + "text": map[string]any{ + "type": "mrkdwn", + "text": text, + }, + } +} + +func splitText(text string, maxLen int) []string { + runes := []rune(text) + if len(runes) <= maxLen { + return []string{text} + } + + const fencePrefix = "```\n" + const fenceSuffix = "\n```" + fencePrefixLen := len([]rune(fencePrefix)) + fenceSuffixLen := len([]rune(fenceSuffix)) + + var chunks []string + inFence := false + + for len(runes) > 0 { + // Calculate content budget reserving space for fence markers + prefixLen := 0 + if inFence { + prefixLen = fencePrefixLen + } + contentBudget := maxLen - prefixLen - fenceSuffixLen + if contentBudget <= 0 { + contentBudget = maxLen + } + + splitAt := len(runes) + if splitAt > contentBudget { + splitAt = findSplitPoint(runes, contentBudget) + if splitAt <= 0 || splitAt > contentBudget { + splitAt = contentBudget + } + } + + chunkBody := string(runes[:splitAt]) + chunkEndsInFence := endsInsideFence(chunkBody, inFence) + chunk := wrapFenceChunk(chunkBody, inFence, chunkEndsInFence) + + chunks = append(chunks, chunk) + inFence = chunkEndsInFence + runes = runes[splitAt:] + } + + return chunks +} + +func wrapFenceChunk(text string, wasInFence bool, endsInFence bool) string { + if wasInFence && !strings.HasPrefix(strings.TrimSpace(text), "```") { + text = "```\n" + text + } + if endsInFence { + text = strings.TrimSuffix(text, "\n") + "\n```" + } + return text +} + +func findSplitPoint(runes []rune, maxLen int) int { + if len(runes) <= maxLen { + return len(runes) + } + window := string(runes[:maxLen]) + + // Try splitting on newline + if idx := strings.LastIndex(window, "\n"); idx > 0 { + return len([]rune(window[:idx])) + 1 + } + + // Try splitting on space + if idx := strings.LastIndex(window, " "); idx > 0 { + return len([]rune(window[:idx])) + 1 + } + + // Try to split before a fence marker + if idx := strings.LastIndex(window, "```"); idx > 0 { + return len([]rune(window[:idx])) + } + + return maxLen +} + +func endsInsideFence(text string, wasInFence bool) bool { + return wasInFence != (strings.Count(text, "```")%2 == 1) +} diff --git a/pkg/channels/slack_webhook/slack_webhook_test.go b/pkg/channels/slack_webhook/slack_webhook_test.go new file mode 100644 index 000000000..83a4c0522 --- /dev/null +++ b/pkg/channels/slack_webhook/slack_webhook_test.go @@ -0,0 +1,281 @@ +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]) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index c9d90e0f8..1dab16619 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -527,6 +527,18 @@ type MQTTSettings struct { QoS int `json:"qos,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_QOS"` } +// SlackWebhookSettings configures the output-only Slack webhook channel. +type SlackWebhookSettings struct { + Webhooks map[string]SlackWebhookTarget `json:"webhooks" yaml:"webhooks,omitempty"` +} + +// SlackWebhookTarget represents a single Slack Incoming Webhook destination. +type SlackWebhookTarget struct { + WebhookURL SecureString `json:"webhook_url,omitzero" yaml:"webhook_url,omitempty"` + Username string `json:"username,omitempty" yaml:"-"` + IconEmoji string `json:"icon_emoji,omitempty" yaml:"-"` +} + type HeartbeatConfig struct { Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"` Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5 diff --git a/pkg/config/config_channel.go b/pkg/config/config_channel.go index fe7ee4b98..52d4b4934 100644 --- a/pkg/config/config_channel.go +++ b/pkg/config/config_channel.go @@ -34,6 +34,7 @@ const ( ChannelWhatsAppNative = "whatsapp_native" ChannelTeamsWebHook = "teams_webhook" ChannelMQTT = "mqtt" + ChannelSlackWebHook = "slack_webhook" ) func initChannel() { @@ -642,6 +643,7 @@ var channelSettingsFactory = map[string]any{ ChannelWhatsAppNative: (WhatsAppSettings{}), ChannelTeamsWebHook: (TeamsWebhookSettings{}), ChannelMQTT: (MQTTSettings{}), + ChannelSlackWebHook: (SlackWebhookSettings{}), } // newChannelSettings creates a fresh zero-value pointer for the given channel type. diff --git a/pkg/gateway/gateway.go b/pkg/gateway/gateway.go index 6171fd65f..7d0ab062d 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -30,6 +30,7 @@ import ( _ "github.com/sipeed/picoclaw/pkg/channels/pico" _ "github.com/sipeed/picoclaw/pkg/channels/qq" _ "github.com/sipeed/picoclaw/pkg/channels/slack" + _ "github.com/sipeed/picoclaw/pkg/channels/slack_webhook" _ "github.com/sipeed/picoclaw/pkg/channels/teams_webhook" _ "github.com/sipeed/picoclaw/pkg/channels/telegram" _ "github.com/sipeed/picoclaw/pkg/channels/vk"