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 <noreply@anthropic.com>
This commit is contained in:
parent
6a8552a664
commit
1fc2710999
9 changed files with 1082 additions and 17 deletions
1
go.mod
1
go.mod
|
|
@ -8,6 +8,7 @@ require (
|
||||||
github.com/SevereCloud/vksdk/v3 v3.3.1
|
github.com/SevereCloud/vksdk/v3 v3.3.1
|
||||||
github.com/adhocore/gronx v1.19.6
|
github.com/adhocore/gronx v1.19.6
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0
|
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/atotto/clipboard v0.1.4
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
github.com/aws/aws-sdk-go-v2/config v1.32.12
|
||||||
|
|
|
||||||
2
go.sum
2
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/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 h1:oUTzFaUpAevfuELAP1sjL6CQJ9HHAfT7CoSYSac11PY=
|
||||||
github.com/anthropics/anthropic-sdk-go v1.26.0/go.mod h1:qUKmaW+uuPB64iy1l+4kOSvaLqPXnHTTBKH6RVZ7q5Q=
|
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 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||||
|
|
|
||||||
|
|
@ -430,6 +430,19 @@ func (m *Manager) initChannels(channels *config.ChannelsConfig) error {
|
||||||
m.initChannel("vk", "VK")
|
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{
|
logger.InfoCF("channels", "Channel initialization completed", map[string]any{
|
||||||
"enabled_channels": len(m.channels),
|
"enabled_channels": len(m.channels),
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,13 @@ func hiddenValues(key string, value map[string]any, ch config.ChannelsConfig) {
|
||||||
value["app_secret"] = ch.Feishu.AppSecret.String()
|
value["app_secret"] = ch.Feishu.AppSecret.String()
|
||||||
value["encrypt_key"] = ch.Feishu.EncryptKey.String()
|
value["encrypt_key"] = ch.Feishu.EncryptKey.String()
|
||||||
value["verification_token"] = ch.Feishu.VerificationToken.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.EncryptKey = old.Feishu.EncryptKey
|
||||||
newcfg.Feishu.VerificationToken = old.Feishu.VerificationToken
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
13
pkg/channels/teams_webhook/init.go
Normal file
13
pkg/channels/teams_webhook/init.go
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
422
pkg/channels/teams_webhook/teams_webhook.go
Normal file
422
pkg/channels/teams_webhook/teams_webhook.go
Normal file
|
|
@ -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
|
||||||
|
}
|
||||||
583
pkg/channels/teams_webhook/teams_webhook_test.go
Normal file
583
pkg/channels/teams_webhook/teams_webhook_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -297,6 +297,7 @@ type ChannelsConfig struct {
|
||||||
PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"`
|
PicoClient PicoClientConfig `json:"pico_client" yaml:"pico_client,omitempty"`
|
||||||
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
|
IRC IRCConfig `json:"irc" yaml:"irc,omitempty"`
|
||||||
VK VKConfig `json:"vk" yaml:"vk,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.
|
// GroupTriggerConfig controls when the bot responds in group chats.
|
||||||
|
|
@ -566,6 +567,19 @@ func (c *VKConfig) SetToken(token string) {
|
||||||
c.Token = *NewSecureString(token)
|
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 {
|
type HeartbeatConfig struct {
|
||||||
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
Enabled bool `json:"enabled" env:"PICOCLAW_HEARTBEAT_ENABLED"`
|
||||||
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
Interval int `json:"interval" env:"PICOCLAW_HEARTBEAT_INTERVAL"` // minutes, min 5
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import (
|
||||||
"github.com/sipeed/picoclaw/pkg/channels/pico"
|
"github.com/sipeed/picoclaw/pkg/channels/pico"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
_ "github.com/sipeed/picoclaw/pkg/channels/qq"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/slack"
|
_ "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/telegram"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/vk"
|
_ "github.com/sipeed/picoclaw/pkg/channels/vk"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
_ "github.com/sipeed/picoclaw/pkg/channels/wecom"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue