From 1fc2710999b1f02a21eec889db25321921957e4a Mon Sep 17 00:00:00 2001 From: Andy Lo-A-Foe Date: Tue, 7 Apr 2026 13:24:27 +0200 Subject: [PATCH] feat(channels): add teams_webhook output-only channel (#2244) Add Microsoft Teams webhook integration via Power Automate workflows. Features: - Output-only channel for sending notifications to Teams - Multiple webhook targets with named configuration - Required "default" target with automatic fallback - Rich Adaptive Card formatting with full-width rendering - Markdown table conversion to native Adaptive Card Tables - Column widths based on header content length - HTTPS-only webhook URL validation - Proper error classification for retry behavior Configuration: - channels.teams_webhook.enabled: bool - channels.teams_webhook.webhooks: map of named targets - Each target has webhook_url (SecureString) and optional title Co-authored-by: Claude Opus 4.5 --- go.mod | 1 + go.sum | 2 + pkg/channels/manager.go | 13 + pkg/channels/manager_channel.go | 16 + pkg/channels/teams_webhook/init.go | 13 + pkg/channels/teams_webhook/teams_webhook.go | 422 +++++++++++++ .../teams_webhook/teams_webhook_test.go | 583 ++++++++++++++++++ pkg/config/config.go | 48 +- pkg/gateway/gateway.go | 1 + 9 files changed, 1082 insertions(+), 17 deletions(-) create mode 100644 pkg/channels/teams_webhook/init.go create mode 100644 pkg/channels/teams_webhook/teams_webhook.go create mode 100644 pkg/channels/teams_webhook/teams_webhook_test.go diff --git a/go.mod b/go.mod index cc5385f7d..a9f4bb7cb 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/SevereCloud/vksdk/v3 v3.3.1 github.com/adhocore/gronx v1.19.6 github.com/anthropics/anthropic-sdk-go v1.26.0 + github.com/atc0005/go-teams-notify/v2 v2.14.0 github.com/atotto/clipboard v0.1.4 github.com/aws/aws-sdk-go-v2 v1.41.5 github.com/aws/aws-sdk-go-v2/config v1.32.12 diff --git a/go.sum b/go.sum index 275184b8a..765a3211a 100644 --- a/go.sum +++ b/go.sum @@ -21,6 +21,8 @@ github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwTo github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/anthropics/anthropic-sdk-go v1.26.0 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY= github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q= +github.com/atc0005/go-teams-notify/v2 v2.14.0 h1:7N+xw+COnYANLREaAveQ65rsNQ12nIZJED9nMLyscCo= +github.com/atc0005/go-teams-notify/v2 v2.14.0/go.mod h1:EECsWM2b0Hvoz7O+QdlsvyN2KCUOFQCGj8bUBXv3A3Q= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY= diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 6d9f5eda8..c4326fda0 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -430,6 +430,19 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error { m.initChannel("vk", "VK") } + if channels.TeamsWebhook.Enabled && len(channels.TeamsWebhook.Webhooks) > 0 { + hasValidTarget := false + for _, target := range channels.TeamsWebhook.Webhooks { + if target.WebhookURL.String() != "" { + hasValidTarget = true + break + } + } + if hasValidTarget { + m.initChannel("teams_webhook", "Teams Webhook") + } + } + logger.InfoCF("channels", "Channel initialization completed", map[string]any{ "enabled_channels": len(m.channels), }) diff --git a/pkg/channels/manager_channel.go b/pkg/channels/manager_channel.go index b1c8c25e0..b54facda4 100644 --- a/pkg/channels/manager_channel.go +++ b/pkg/channels/manager_channel.go @@ -62,6 +62,13 @@ func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) { value["app_secret"] = ch.Feishu.AppSecret.String() value["encrypt_key"] = ch.Feishu.EncryptKey.String() value["verification_token"] = ch.Feishu.VerificationToken.String() + case "teams_webhook": + // Expose webhook URLs for hash computation (they contain secrets) + webhooks := make(map[string]string) + for name, target := range ch.TeamsWebhook.Webhooks { + webhooks[name] = target.WebhookURL.String() + } + value["webhooks"] = webhooks } } @@ -166,4 +173,13 @@ func updateKeys(newcfg, old *config.ChannelsConfig) { newcfg.Feishu.EncryptKey = old.Feishu.EncryptKey newcfg.Feishu.VerificationToken = old.Feishu.VerificationToken } + if newcfg.TeamsWebhook.Enabled { + // Copy SecureString webhook URLs from old config + for name, oldTarget := range old.TeamsWebhook.Webhooks { + if newTarget, ok := newcfg.TeamsWebhook.Webhooks[name]; ok { + newTarget.WebhookURL = oldTarget.WebhookURL + newcfg.TeamsWebhook.Webhooks[name] = newTarget + } + } + } } diff --git a/pkg/channels/teams_webhook/init.go b/pkg/channels/teams_webhook/init.go new file mode 100644 index 000000000..fca960039 --- /dev/null +++ b/pkg/channels/teams_webhook/init.go @@ -0,0 +1,13 @@ +package teamswebhook + +import ( + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" +) + +func init() { + channels.RegisterFactory("teams_webhook", func(cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) { + return NewTeamsWebhookChannel(cfg.Channels.TeamsWebhook, b) + }) +} diff --git a/pkg/channels/teams_webhook/teams_webhook.go b/pkg/channels/teams_webhook/teams_webhook.go new file mode 100644 index 000000000..fa7762a3e --- /dev/null +++ b/pkg/channels/teams_webhook/teams_webhook.go @@ -0,0 +1,422 @@ +package teamswebhook + +import ( + "context" + "fmt" + "net/url" + "regexp" + "sort" + "strconv" + "strings" + + goteamsnotify "github.com/atc0005/go-teams-notify/v2" + "github.com/atc0005/go-teams-notify/v2/adaptivecard" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/channels" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/logger" +) + +// statusCodeRe extracts HTTP status codes from error messages like "401 Unauthorized". +var statusCodeRe = regexp.MustCompile(`\b([45]\d{2})\b`) + +// markdownTableRe matches a markdown table block (header + separator + rows). +// It captures the entire table including all rows. +var markdownTableRe = regexp.MustCompile(`(?m)^(\|[^\n]+\|)\n(\|[-:\|\s]+\|)\n((?:\|[^\n]+\|\n?)+)`) + +// teamsMessageSender abstracts the Teams client for testability. +type teamsMessageSender interface { + SendWithContext(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error +} + +// classifyTeamsError extracts HTTP status code from error message and classifies it. +// The go-teams-notify library returns errors like "error on notification: 401 Unauthorized, ...". +// This allows proper retry behavior: 4xx errors are permanent, 5xx are temporary. +func classifyTeamsError(err error) error { + if err == nil { + return nil + } + errMsg := err.Error() + if matches := statusCodeRe.FindStringSubmatch(errMsg); len(matches) > 1 { + if statusCode, parseErr := strconv.Atoi(matches[1]); parseErr == nil { + return channels.ClassifySendError(statusCode, err) + } + } + // Fallback: treat as temporary network error (retryable) + return channels.ClassifyNetError(err) +} + +// TeamsWebhookChannel is an output-only channel that sends messages +// to Microsoft Teams via Power Automate workflow webhooks. +// Multiple webhook targets can be configured and selected via ChatID. +type TeamsWebhookChannel struct { + *channels.BaseChannel + config config.TeamsWebhookConfig + client teamsMessageSender +} + +// NewTeamsWebhookChannel creates a new Teams webhook channel. +func NewTeamsWebhookChannel( + cfg config.TeamsWebhookConfig, + bus *bus.MessageBus, +) (*TeamsWebhookChannel, error) { + if len(cfg.Webhooks) == 0 { + return nil, fmt.Errorf("teams_webhook: at least one webhook target is required") + } + + // Require "default" webhook target + if _, hasDefault := cfg.Webhooks["default"]; !hasDefault { + return nil, fmt.Errorf("teams_webhook: a 'default' webhook target is required") + } + + // Validate all webhook targets have valid HTTPS URLs + for name, target := range cfg.Webhooks { + webhookURL := target.WebhookURL.String() + if webhookURL == "" { + return nil, fmt.Errorf("teams_webhook: webhook %q has empty webhook_url", name) + } + parsed, err := url.Parse(webhookURL) + if err != nil { + return nil, fmt.Errorf("teams_webhook: webhook %q has invalid URL: %w", name, err) + } + if !strings.EqualFold(parsed.Scheme, "https") { + return nil, fmt.Errorf("teams_webhook: webhook %q must use HTTPS (got %q)", name, parsed.Scheme) + } + } + + base := channels.NewBaseChannel( + "teams_webhook", + cfg, + bus, + []string{ + "*", + }, // Output-only channel; "*" suppresses misleading "allows EVERYONE" audit warning + channels.WithMaxMessageLength(24000), // Power Automate webhook payload limit is 28KB + ) + + client := goteamsnotify.NewTeamsClient() + + return &TeamsWebhookChannel{ + BaseChannel: base, + config: cfg, + client: client, + }, nil +} + +// Start initializes the channel. For output-only channels, this is a no-op. +func (c *TeamsWebhookChannel) 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("teams_webhook", "Starting Teams webhook channel (output-only)", map[string]any{ + "targets": targets, + }) + c.SetRunning(true) + return nil +} + +// Stop shuts down the channel. +func (c *TeamsWebhookChannel) Stop(ctx context.Context) error { + logger.InfoC("teams_webhook", "Stopping Teams webhook channel") + c.SetRunning(false) + return nil +} + +// Send delivers a message to the specified Teams webhook target. +// The target is selected by msg.ChatID which must match a key in the webhooks map. +func (c *TeamsWebhookChannel) 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: + } + + // Look up webhook target by ChatID, fall back to "default" if empty or unknown + targetName := msg.ChatID + if targetName == "" { + targetName = "default" + } + + target, ok := c.config.Webhooks[targetName] + if !ok { + // Log warning and fall back to default target + logger.WarnCF("teams_webhook", "Unknown target, falling back to default", map[string]any{ + "requested": msg.ChatID, + "using": "default", + }) + target = c.config.Webhooks["default"] + } + + // Build an Adaptive Card for rich formatting + card, err := c.buildAdaptiveCard(msg, target) + if err != nil { + return nil, fmt.Errorf("teams_webhook: failed to build card: %w", err) + } + + // Create the message with the card + teamsMsg, err := adaptivecard.NewMessageFromCard(card) + if err != nil { + return nil, fmt.Errorf("teams_webhook: failed to create message: %w", err) + } + + // Send to Teams + if err := c.client.SendWithContext(ctx, target.WebhookURL.String(), teamsMsg); err != nil { + // Log without raw error to avoid leaking webhook URL (embedded in net/http errors) + logger.ErrorCF("teams_webhook", "Failed to send message to Teams webhook", map[string]any{ + "target": msg.ChatID, + }) + // Classify error based on status code extracted from error message. + // The go-teams-notify library includes status in errors like "401 Unauthorized". + // Use ClassifySendError for proper retry behavior (4xx = permanent, 5xx = temporary). + classifiedErr := classifyTeamsError(err) + return nil, fmt.Errorf("teams_webhook: send failed: %w", classifiedErr) + } + + logger.DebugCF("teams_webhook", "Message sent successfully", map[string]any{ + "target": msg.ChatID, + }) + + return nil, nil +} + +// buildAdaptiveCard creates a formatted Adaptive Card from the outbound message. +// It detects markdown tables and converts them to native Adaptive Card Table elements, +// since TextBlocks only support a limited markdown subset (no tables). +func (c *TeamsWebhookChannel) buildAdaptiveCard( + msg bus.OutboundMessage, + target config.TeamsWebhookTarget, +) (adaptivecard.Card, error) { + card := adaptivecard.NewCard() + card.Type = adaptivecard.TypeAdaptiveCard + + // Set full width for Teams rendering + card.MSTeams.Width = "Full" + + // Add title if configured on the target + title := target.Title + if title == "" { + title = "PicoClaw Notification" + } + + titleBlock := adaptivecard.NewTextBlock(title, true) + titleBlock.Size = adaptivecard.SizeLarge + titleBlock.Weight = adaptivecard.WeightBolder + titleBlock.Style = adaptivecard.TextBlockStyleHeading + + if err := card.AddElement(false, titleBlock); err != nil { + return card, err + } + + content := msg.Content + if content == "" { + content = "(empty message)" + } + + // Split content into text segments and tables + // TextBlocks support: bold, italic, bullet/numbered lists, links + // TextBlocks do NOT support: headers, tables, images + segments := splitContentWithTables(content) + + for _, seg := range segments { + if seg.isTable { + // Convert markdown table to Adaptive Card Table element + tableElement, err := parseMarkdownTable(seg.content) + if err != nil { + // Fallback: render as preformatted text if parsing fails + logger.WarnCF("teams_webhook", "Failed to parse markdown table, using fallback", map[string]any{ + "error": err.Error(), + }) + block := adaptivecard.NewTextBlock("```\n"+seg.content+"\n```", true) + block.Wrap = true + if err := card.AddElement(false, block); err != nil { + return card, err + } + continue + } + if err := card.AddElement(false, tableElement); err != nil { + return card, err + } + } else { + // Regular text content + text := strings.TrimSpace(seg.content) + if text == "" { + continue + } + block := adaptivecard.NewTextBlock(text, true) + block.Wrap = true + if err := card.AddElement(false, block); err != nil { + return card, err + } + } + } + + return card, nil +} + +// contentSegment represents either a text block or a table in the message content. +type contentSegment struct { + content string + isTable bool +} + +// splitContentWithTables splits content into alternating text and table segments. +func splitContentWithTables(content string) []contentSegment { + var segments []contentSegment + + matches := markdownTableRe.FindAllStringSubmatchIndex(content, -1) + if len(matches) == 0 { + // No tables found, return entire content as text + return []contentSegment{{content: content, isTable: false}} + } + + lastEnd := 0 + for _, match := range matches { + // Text before this table + if match[0] > lastEnd { + segments = append(segments, contentSegment{ + content: content[lastEnd:match[0]], + isTable: false, + }) + } + // The table itself + segments = append(segments, contentSegment{ + content: content[match[0]:match[1]], + isTable: true, + }) + lastEnd = match[1] + } + + // Text after the last table + if lastEnd < len(content) { + segments = append(segments, contentSegment{ + content: content[lastEnd:], + isTable: false, + }) + } + + return segments +} + +// parseMarkdownTable converts a markdown table string to an Adaptive Card Table element. +func parseMarkdownTable(tableStr string) (adaptivecard.Element, error) { + lines := strings.Split(strings.TrimSpace(tableStr), "\n") + if len(lines) < 2 { + return adaptivecard.Element{}, fmt.Errorf("table must have at least header and separator rows") + } + + // Track header content length per column for width calculation + var headerLengths []int + + // Parse all rows (header + data rows, skip separator) + var allRows [][]adaptivecard.TableCell + for i, line := range lines { + // Skip separator row (contains only |, -, :, and spaces) + if i == 1 && isSeparatorRow(line) { + continue + } + + cells := parseTableRow(line) + if len(cells) == 0 { + continue + } + + var tableCells []adaptivecard.TableCell + for _, cellText := range cells { + trimmedText := strings.TrimSpace(cellText) + + // Use header row (first row) to determine column widths + if i == 0 { + headerLengths = append(headerLengths, len(trimmedText)) + } + + textBlock := adaptivecard.Element{ + Type: adaptivecard.TypeElementTextBlock, + Text: trimmedText, + Wrap: true, + } + cell := adaptivecard.TableCell{ + Type: adaptivecard.TypeTableCell, + Items: []*adaptivecard.Element{&textBlock}, + } + tableCells = append(tableCells, cell) + } + allRows = append(allRows, tableCells) + } + + if len(allRows) == 0 { + return adaptivecard.Element{}, fmt.Errorf("no valid rows found in table") + } + + // Create table with first row as headers + firstRowAsHeaders := true + showGridLines := true + + table, err := adaptivecard.NewTableFromTableCells(allRows, 0, firstRowAsHeaders, showGridLines) + if err != nil { + return adaptivecard.Element{}, fmt.Errorf("failed to create table: %w", err) + } + + // Set column widths based on header content length + table.Columns = calculateColumnWidths(headerLengths) + + return table, nil +} + +// calculateColumnWidths creates TableColumnDefinition entries with widths +// proportional to the max content length of each column. +func calculateColumnWidths(maxLengths []int) []adaptivecard.Column { + if len(maxLengths) == 0 { + return nil + } + + // Use content length as relative weight, with a minimum of 1 + columns := make([]adaptivecard.Column, len(maxLengths)) + for i, length := range maxLengths { + weight := length + if weight < 1 { + weight = 1 + } + columns[i] = adaptivecard.Column{ + Type: "TableColumnDefinition", + Width: weight, + } + } + + return columns +} + +// isSeparatorRow checks if a line is a markdown table separator (e.g., |---|---|). +func isSeparatorRow(line string) bool { + // Remove pipes and spaces, check if only dashes and colons remain + cleaned := strings.ReplaceAll(line, "|", "") + cleaned = strings.ReplaceAll(cleaned, " ", "") + cleaned = strings.ReplaceAll(cleaned, "-", "") + cleaned = strings.ReplaceAll(cleaned, ":", "") + return cleaned == "" +} + +// parseTableRow extracts cell values from a markdown table row. +func parseTableRow(line string) []string { + // Trim leading/trailing pipes and split by | + 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/teams_webhook/teams_webhook_test.go b/pkg/channels/teams_webhook/teams_webhook_test.go new file mode 100644 index 000000000..451ba9d18 --- /dev/null +++ b/pkg/channels/teams_webhook/teams_webhook_test.go @@ -0,0 +1,583 @@ +package teamswebhook + +import ( + "context" + "errors" + "testing" + + goteamsnotify "github.com/atc0005/go-teams-notify/v2" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" +) + +// mockTeamsClient implements teamsMessageSender for testing. +type mockTeamsClient struct { + sendFunc func(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error +} + +func (m *mockTeamsClient) SendWithContext( + ctx context.Context, + webhookURL string, + message goteamsnotify.TeamsMessage, +) error { + if m.sendFunc != nil { + return m.sendFunc(ctx, webhookURL, message) + } + return nil +} + +func TestNewTeamsWebhookChannel(t *testing.T) { + msgBus := bus.NewMessageBus() + + // Test missing webhooks + _, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{ + Enabled: true, + Webhooks: nil, + }, msgBus) + if err == nil { + t.Error("expected error for missing webhooks") + } + + // Test missing "default" webhook + _, err = NewTeamsWebhookChannel(config.TeamsWebhookConfig{ + Enabled: true, + Webhooks: map[string]config.TeamsWebhookTarget{ + "alerts": { + WebhookURL: *config.NewSecureString("https://example.com/webhook"), + Title: "Alerts", + }, + }, + }, msgBus) + if err == nil { + t.Error("expected error for missing 'default' webhook") + } + + // Test empty webhook URL + _, err = NewTeamsWebhookChannel(config.TeamsWebhookConfig{ + Enabled: true, + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": {Title: "Default"}, + }, + }, msgBus) + if err == nil { + t.Error("expected error for empty webhook_url") + } + + // Test HTTP URL (should fail, must be HTTPS) + _, err = NewTeamsWebhookChannel(config.TeamsWebhookConfig{ + Enabled: true, + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("http://example.com/webhook"), + Title: "Default", + }, + }, + }, msgBus) + if err == nil { + t.Error("expected error for HTTP webhook URL (must be HTTPS)") + } + + // Test valid config with HTTPS (must include "default") + ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{ + Enabled: true, + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-default"), + Title: "Default", + }, + "alerts": { + WebhookURL: *config.NewSecureString("https://example.com/webhook1"), + Title: "Alerts", + }, + }, + }, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if ch.Name() != "teams_webhook" { + t.Errorf("expected name 'teams_webhook', got %q", ch.Name()) + } +} + +func TestTeamsWebhookChannel_StartStop(t *testing.T) { + msgBus := bus.NewMessageBus() + ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{ + Enabled: true, + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook"), + }, + }, + }, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ctx := context.Background() + + if ch.IsRunning() { + t.Error("channel should not be running before Start") + } + + if err := ch.Start(ctx); err != nil { + t.Fatalf("Start failed: %v", err) + } + + if !ch.IsRunning() { + t.Error("channel should be running after Start") + } + + if err := ch.Stop(ctx); err != nil { + t.Fatalf("Stop failed: %v", err) + } + + if ch.IsRunning() { + t.Error("channel should not be running after Stop") + } +} + +func TestTeamsWebhookChannel_BuildAdaptiveCard(t *testing.T) { + msgBus := bus.NewMessageBus() + ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{ + Enabled: true, + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-default"), + Title: "Default", + }, + "alerts": { + WebhookURL: *config.NewSecureString("https://example.com/webhook"), + Title: "Custom Title", + }, + }, + }, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + target := ch.config.Webhooks["alerts"] + msg := bus.OutboundMessage{ + Content: "Test message content", + ChatID: "alerts", + } + + card, err := ch.buildAdaptiveCard(msg, target) + if err != nil { + t.Fatalf("buildAdaptiveCard failed: %v", err) + } + + if card.Type != "AdaptiveCard" { + t.Errorf("expected card type 'AdaptiveCard', got %q", card.Type) + } +} + +func TestTeamsWebhookChannel_SendNotRunning(t *testing.T) { + msgBus := bus.NewMessageBus() + ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{ + Enabled: true, + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook"), + }, + }, + }, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + ctx := context.Background() + msg := bus.OutboundMessage{Content: "test", ChatID: "default"} + + _, err = ch.Send(ctx, msg) + if err == nil { + t.Error("expected error when sending while not running") + } +} + +func TestTeamsWebhookChannel_SendDefaultTargetFallback(t *testing.T) { + tests := []struct { + name string + chatID string + }{ + {"unknown target falls back to default", "unknown"}, + {"empty ChatID uses default", ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + msgBus := bus.NewMessageBus() + ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{ + Enabled: true, + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-default"), + }, + "alerts": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-alerts"), + }, + }, + }, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var sentURL string + ch.client = &mockTeamsClient{ + sendFunc: func(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error { + sentURL = webhookURL + return nil + }, + } + + ctx := context.Background() + _ = ch.Start(ctx) + defer ch.Stop(ctx) + + msg := bus.OutboundMessage{Content: "test", ChatID: tt.chatID} + _, err = ch.Send(ctx, msg) + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + + if sentURL != "https://example.com/webhook-default" { + t.Errorf("expected default webhook URL, got %q", sentURL) + } + }) + } +} + +func TestTeamsWebhookChannel_SendSuccess(t *testing.T) { + msgBus := bus.NewMessageBus() + ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{ + Enabled: true, + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-default"), + Title: "Default", + }, + "alerts": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-alerts"), + Title: "Test Alerts", + }, + }, + }, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Inject mock client + var sentURL string + ch.client = &mockTeamsClient{ + sendFunc: func(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error { + sentURL = webhookURL + return nil + }, + } + + ctx := context.Background() + _ = ch.Start(ctx) + defer ch.Stop(ctx) + + msg := bus.OutboundMessage{Content: "Hello Teams!", ChatID: "alerts"} + + _, err = ch.Send(ctx, msg) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if sentURL != "https://example.com/webhook-alerts" { + t.Errorf("expected webhook URL 'https://example.com/webhook-alerts', got %q", sentURL) + } +} + +func TestTeamsWebhookChannel_SendError(t *testing.T) { + msgBus := bus.NewMessageBus() + ch, err := NewTeamsWebhookChannel(config.TeamsWebhookConfig{ + Enabled: true, + Webhooks: map[string]config.TeamsWebhookTarget{ + "default": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-default"), + }, + "alerts": { + WebhookURL: *config.NewSecureString("https://example.com/webhook-alerts"), + }, + }, + }, msgBus) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Inject mock client that returns an error + ch.client = &mockTeamsClient{ + sendFunc: func(ctx context.Context, webhookURL string, message goteamsnotify.TeamsMessage) error { + return errors.New("error on notification: 401 Unauthorized, forbidden") + }, + } + + ctx := context.Background() + _ = ch.Start(ctx) + defer ch.Stop(ctx) + + msg := bus.OutboundMessage{Content: "test", ChatID: "alerts"} + + _, err = ch.Send(ctx, msg) + if err == nil { + t.Error("expected error from failed send") + } +} + +func TestSplitContentWithTables(t *testing.T) { + tests := []struct { + name string + content string + wantSegs int + wantTbl int // number of table segments + }{ + { + name: "no tables", + content: "Just some text\nwith multiple lines", + wantSegs: 1, + wantTbl: 0, + }, + { + name: "single table", + content: `| Col1 | Col2 | +|------|------| +| A | B | +| C | D |`, + wantSegs: 1, + wantTbl: 1, + }, + { + name: "text before table", + content: `Here is some text. + +| Col1 | Col2 | +|------|------| +| A | B |`, + wantSegs: 2, + wantTbl: 1, + }, + { + name: "text before and after table", + content: `Before table. + +| Col1 | Col2 | +|------|------| +| A | B | + +After table.`, + wantSegs: 3, + wantTbl: 1, + }, + { + name: "multiple tables", + content: `First table: + +| A | B | +|---|---| +| 1 | 2 | + +Second table: + +| X | Y | +|---|---| +| 3 | 4 |`, + wantSegs: 4, + wantTbl: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + segs := splitContentWithTables(tt.content) + if len(segs) != tt.wantSegs { + t.Errorf("got %d segments, want %d", len(segs), tt.wantSegs) + } + tableCount := 0 + for _, s := range segs { + if s.isTable { + tableCount++ + } + } + if tableCount != tt.wantTbl { + t.Errorf("got %d tables, want %d", tableCount, tt.wantTbl) + } + }) + } +} + +func TestParseMarkdownTable(t *testing.T) { + tableStr := `| Name | Value | +|------|-------| +| foo | 123 | +| bar | 456 |` + + elem, err := parseMarkdownTable(tableStr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if elem.Type != "Table" { + t.Errorf("expected type 'Table', got %q", elem.Type) + } + + // Should have 3 rows (header + 2 data rows) + if len(elem.Rows) != 3 { + t.Errorf("expected 3 rows, got %d", len(elem.Rows)) + } + + // Should have 2 columns with widths based on content length + if len(elem.Columns) != 2 { + t.Errorf("expected 2 columns, got %d", len(elem.Columns)) + } +} + +func TestParseMarkdownTableColumnWidths(t *testing.T) { + // Column widths are based on HEADER row only: + // Col1: "Description" (11 chars) + // Col2: "X" (1 char) + // Col3: "Amount" (6 chars) + tableStr := `| Description | X | Amount | +|-------------|---|--------| +| Short | Y | 100 | +| Longer text | Z | 50 |` + + elem, err := parseMarkdownTable(tableStr) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(elem.Columns) != 3 { + t.Fatalf("expected 3 columns, got %d", len(elem.Columns)) + } + + // Verify column widths are based on header content length + w1, ok1 := elem.Columns[0].Width.(int) + w2, ok2 := elem.Columns[1].Width.(int) + w3, ok3 := elem.Columns[2].Width.(int) + + if !ok1 || !ok2 || !ok3 { + t.Fatalf("expected int widths, got types: %T, %T, %T", + elem.Columns[0].Width, elem.Columns[1].Width, elem.Columns[2].Width) + } + + // Header lengths: "Description" = 11, "X" = 1, "Amount" = 6 + if w1 != 11 { + t.Errorf("expected col1 width 11 (from 'Description'), got %d", w1) + } + if w2 != 1 { + t.Errorf("expected col2 width 1 (from 'X'), got %d", w2) + } + if w3 != 6 { + t.Errorf("expected col3 width 6 (from 'Amount'), got %d", w3) + } +} + +func TestCalculateColumnWidths(t *testing.T) { + tests := []struct { + name string + maxLengths []int + wantWidths []int + }{ + { + name: "equal lengths", + maxLengths: []int{10, 10, 10}, + wantWidths: []int{10, 10, 10}, + }, + { + name: "varying lengths", + maxLengths: []int{5, 20, 10}, + wantWidths: []int{5, 20, 10}, + }, + { + name: "zero length gets minimum of 1", + maxLengths: []int{0, 5, 0}, + wantWidths: []int{1, 5, 1}, + }, + { + name: "empty input", + maxLengths: []int{}, + wantWidths: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cols := calculateColumnWidths(tt.maxLengths) + + if tt.wantWidths == nil { + if cols != nil { + t.Errorf("expected nil, got %v", cols) + } + return + } + + if len(cols) != len(tt.wantWidths) { + t.Fatalf("expected %d columns, got %d", len(tt.wantWidths), len(cols)) + } + + for i, col := range cols { + width, ok := col.Width.(int) + if !ok { + t.Errorf("column %d: expected int width, got %T", i, col.Width) + continue + } + if width != tt.wantWidths[i] { + t.Errorf("column %d: expected width %d, got %d", i, tt.wantWidths[i], width) + } + if col.Type != "TableColumnDefinition" { + t.Errorf("column %d: expected type 'TableColumnDefinition', got %q", i, col.Type) + } + } + }) + } +} + +func TestParseTableRow(t *testing.T) { + tests := []struct { + line string + want []string + }{ + {"| A | B | C |", []string{"A", "B", "C"}}, + {"|A|B|C|", []string{"A", "B", "C"}}, + {"| foo | bar |", []string{"foo", "bar"}}, + {"", nil}, + } + + for _, tt := range tests { + got := parseTableRow(tt.line) + if len(got) != len(tt.want) { + t.Errorf("parseTableRow(%q): got %v, want %v", tt.line, got, tt.want) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("parseTableRow(%q)[%d]: got %q, want %q", tt.line, i, got[i], tt.want[i]) + } + } + } +} + +func TestIsSeparatorRow(t *testing.T) { + tests := []struct { + line string + want bool + }{ + {"|---|---|", true}, + {"| --- | --- |", true}, + {"|:---|---:|", true}, + {"| :---: | :---: |", true}, + {"| A | B |", false}, + {"| foo | bar |", false}, + } + + for _, tt := range tests { + got := isSeparatorRow(tt.line) + if got != tt.want { + t.Errorf("isSeparatorRow(%q): got %v, want %v", tt.line, got, tt.want) + } + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 1d98aa334..606f7a095 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -280,23 +280,24 @@ func (d *AgentDefaults) GetModelName() string { } type ChannelsConfig struct { - WhatsApp WhatsAppConfig `json:"whatsapp" yaml:"-"` - Telegram TelegramConfig `json:"telegram" yaml:"telegram,omitempty"` - Feishu FeishuConfig `json:"feishu" yaml:"feishu,omitempty"` - Discord DiscordConfig `json:"discord" yaml:"discord,omitempty"` - MaixCam MaixCamConfig `json:"maixcam" yaml:"-"` - QQ QQConfig `json:"qq" yaml:"qq,omitempty"` - DingTalk DingTalkConfig `json:"dingtalk" yaml:"dingtalk,omitempty"` - Slack SlackConfig `json:"slack" yaml:"slack,omitempty"` - Matrix MatrixConfig `json:"matrix" yaml:"matrix,omitempty"` - LINE LINEConfig `json:"line" yaml:"line,omitempty"` - OneBot OneBotConfig `json:"onebot" yaml:"onebot,omitempty"` - WeCom WeComConfig `json:"wecom" yaml:"wecom,omitempty" envPrefix:"PICOCLAW_CHANNELS_WECOM_"` - Weixin WeixinConfig `json:"weixin" yaml:"weixin,omitempty"` - Pico PicoConfig `json:"pico" yaml:"pico,omitempty"` - PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"` - IRC IRCConfig `json:"irc" yaml:"irc,omitempty"` - VK VKConfig `json:"vk" yaml:"vk,omitempty"` + WhatsApp WhatsAppConfig `json:"whatsapp" yaml:"-"` + Telegram TelegramConfig `json:"telegram" yaml:"telegram,omitempty"` + Feishu FeishuConfig `json:"feishu" yaml:"feishu,omitempty"` + Discord DiscordConfig `json:"discord" yaml:"discord,omitempty"` + MaixCam MaixCamConfig `json:"maixcam" yaml:"-"` + QQ QQConfig `json:"qq" yaml:"qq,omitempty"` + DingTalk DingTalkConfig `json:"dingtalk" yaml:"dingtalk,omitempty"` + Slack SlackConfig `json:"slack" yaml:"slack,omitempty"` + Matrix MatrixConfig `json:"matrix" yaml:"matrix,omitempty"` + LINE LINEConfig `json:"line" yaml:"line,omitempty"` + OneBot OneBotConfig `json:"onebot" yaml:"onebot,omitempty"` + WeCom WeComConfig `json:"wecom" yaml:"wecom,omitempty" envPrefix:"PICOCLAW_CHANNELS_WECOM_"` + Weixin WeixinConfig `json:"weixin" yaml:"weixin,omitempty"` + Pico PicoConfig `json:"pico" yaml:"pico,omitempty"` + PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"` + IRC IRCConfig `json:"irc" yaml:"irc,omitempty"` + VK VKConfig `json:"vk" yaml:"vk,omitempty"` + TeamsWebhook TeamsWebhookConfig `json:"teams_webhook" yaml:"teams_webhook,omitempty"` } // GroupTriggerConfig controls when the bot responds in group chats. @@ -566,6 +567,19 @@ func (c *VKConfig) SetToken(token string) { c.Token = *NewSecureString(token) } +// TeamsWebhookConfig configures the output-only Microsoft Teams webhook channel. +// Multiple webhook targets can be configured and selected via ChatID at send time. +type TeamsWebhookConfig struct { + Enabled bool `json:"enabled" yaml:"-" env:"PICOCLAW_CHANNELS_TEAMS_WEBHOOK_ENABLED"` + Webhooks map[string]TeamsWebhookTarget `json:"webhooks" yaml:"webhooks,omitempty"` +} + +// TeamsWebhookTarget represents a single Teams webhook destination. +type TeamsWebhookTarget struct { + WebhookURL SecureString `json:"webhook_url,omitzero" yaml:"webhook_url,omitempty"` + Title string `json:"title,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/gateway/gateway.go b/pkg/gateway/gateway.go index 509b5d37e..8be84bdf6 100644 --- a/pkg/gateway/gateway.go +++ b/pkg/gateway/gateway.go @@ -28,6 +28,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/teams_webhook" _ "github.com/sipeed/picoclaw/pkg/channels/telegram" _ "github.com/sipeed/picoclaw/pkg/channels/vk" _ "github.com/sipeed/picoclaw/pkg/channels/wecom"