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>
317 lines
7.8 KiB
Go
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)
|
|
}
|