Merge pull request #2719 from loafoe/feat/slack-webhook-channel

feat(channels): add slack_webhook output-only channel
This commit is contained in:
美電球 2026-05-11 16:22:27 +08:00 committed by GitHub
commit 7dc78425d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 1109 additions and 0 deletions

View file

@ -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:

View file

@ -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
}
}
}

View file

@ -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) → <url|text>
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
}

View file

@ -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 <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")
}

View file

@ -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
},
)
}

View file

@ -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)
}

View file

@ -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])
}

View file

@ -648,6 +648,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

View file

@ -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.

View file

@ -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"