Files
picoclaw/pkg/channels/slack_webhook/slack_webhook.go
T
Andy Lo-A-Foe b12f03be2e 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 <noreply@anthropic.com>
2026-05-11 09:54:04 +02:00

317 lines
7.8 KiB
Go

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