Merge pull request #2719 from loafoe/feat/slack-webhook-channel
feat(channels): add slack_webhook output-only channel
This commit is contained in:
commit
7dc78425d1
10 changed files with 1109 additions and 0 deletions
|
|
@ -688,6 +688,8 @@ func (m *Manager) getChannelConfigAndEnabled(channelName string) (*config.Channe
|
||||||
return bc, true
|
return bc, true
|
||||||
case *config.TeamsWebhookSettings:
|
case *config.TeamsWebhookSettings:
|
||||||
return bc, true
|
return bc, true
|
||||||
|
case *config.SlackWebhookSettings:
|
||||||
|
return bc, true
|
||||||
case *config.DiscordSettings:
|
case *config.DiscordSettings:
|
||||||
return bc, settings.Token.String() != ""
|
return bc, settings.Token.String() != ""
|
||||||
case *config.VKSettings:
|
case *config.VKSettings:
|
||||||
|
|
|
||||||
|
|
@ -107,6 +107,19 @@ func hiddenValues(key string, value map[string]any, ch *config.Channel) {
|
||||||
value["username"] = settings.Username.String()
|
value["username"] = settings.Username.String()
|
||||||
value["password"] = settings.Password.String()
|
value["password"] = settings.Password.String()
|
||||||
}
|
}
|
||||||
|
case "slack_webhook":
|
||||||
|
// Expose webhook URLs for hash computation (they contain secrets)
|
||||||
|
if settings, ok := v.(*config.SlackWebhookSettings); ok {
|
||||||
|
webhooks := make(map[string]any)
|
||||||
|
for name, target := range settings.Webhooks {
|
||||||
|
webhooks[name] = map[string]any{
|
||||||
|
"webhook_url": target.WebhookURL.String(),
|
||||||
|
"username": target.Username,
|
||||||
|
"icon_emoji": target.IconEmoji,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value["webhooks"] = webhooks
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
263
pkg/channels/slack_webhook/convert.go
Normal file
263
pkg/channels/slack_webhook/convert.go
Normal file
|
|
@ -0,0 +1,263 @@
|
||||||
|
package slackwebhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxTableRowWidth = 60
|
||||||
|
|
||||||
|
var (
|
||||||
|
boldRe = regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
||||||
|
strikeRe = regexp.MustCompile(`~~([^~]+)~~`)
|
||||||
|
linkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||||
|
headerRe = regexp.MustCompile(`(?m)^#{1,6}\s+(.+)$`)
|
||||||
|
bulletRe = regexp.MustCompile(`(?m)^- (.+)$`)
|
||||||
|
markdownTableRe = regexp.MustCompile(`(?m)^(\|[^\n]+\|)\n(\|[-:\|\s]+\|)\n((?:\|[^\n]+\|\n?)+)`)
|
||||||
|
codeBlockRe = regexp.MustCompile("(?s)```.*?```")
|
||||||
|
inlineCodeRe = regexp.MustCompile("`[^`]+`")
|
||||||
|
italicRe = regexp.MustCompile(`(?:^|[^*])\*([^*]+)\*(?:[^*]|$)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
type contentSegment struct {
|
||||||
|
content string
|
||||||
|
isTable bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func convertMarkdownToMrkdwn(text string) string {
|
||||||
|
// Protect code blocks from conversion
|
||||||
|
var codeBlocks []string
|
||||||
|
text = codeBlockRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
codeBlocks = append(codeBlocks, match)
|
||||||
|
return "\x00CODEBLOCK\x00"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Protect inline code
|
||||||
|
var inlineCode []string
|
||||||
|
text = inlineCodeRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
inlineCode = append(inlineCode, match)
|
||||||
|
return "\x00INLINE\x00"
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert italic *text* → _text_ BEFORE bold conversion
|
||||||
|
text = italicRe.ReplaceAllStringFunc(text, func(match string) string {
|
||||||
|
// Find the asterisk positions
|
||||||
|
firstAsterisk := strings.Index(match, "*")
|
||||||
|
lastAsterisk := strings.LastIndex(match, "*")
|
||||||
|
if firstAsterisk == lastAsterisk {
|
||||||
|
return match // Only one asterisk, not italic
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract content between asterisks
|
||||||
|
content := match[firstAsterisk+1 : lastAsterisk]
|
||||||
|
|
||||||
|
// Replace with underscores, preserving any prefix/suffix
|
||||||
|
return match[:firstAsterisk] + "_" + content + "_" + match[lastAsterisk+1:]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Convert bold **text** → *text*
|
||||||
|
text = boldRe.ReplaceAllString(text, "*$1*")
|
||||||
|
|
||||||
|
// Convert strikethrough ~~text~~ → ~text~
|
||||||
|
text = strikeRe.ReplaceAllString(text, "~$1~")
|
||||||
|
|
||||||
|
// Convert links [text](url) → <url|text>
|
||||||
|
text = linkRe.ReplaceAllString(text, "<$2|$1>")
|
||||||
|
|
||||||
|
// Convert headers # text → *text*
|
||||||
|
text = headerRe.ReplaceAllString(text, "*$1*")
|
||||||
|
|
||||||
|
// Convert bullet lists - item → • item
|
||||||
|
text = bulletRe.ReplaceAllString(text, "• $1")
|
||||||
|
|
||||||
|
// Restore inline code
|
||||||
|
for _, code := range inlineCode {
|
||||||
|
text = strings.Replace(text, "\x00INLINE\x00", code, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore code blocks
|
||||||
|
for _, block := range codeBlocks {
|
||||||
|
text = strings.Replace(text, "\x00CODEBLOCK\x00", block, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitContentWithTables(content string) []contentSegment {
|
||||||
|
var segments []contentSegment
|
||||||
|
|
||||||
|
// Protect code blocks from table detection using unique placeholders
|
||||||
|
var codeBlocks []string
|
||||||
|
blockIdx := 0
|
||||||
|
protected := codeBlockRe.ReplaceAllStringFunc(content, func(match string) string {
|
||||||
|
codeBlocks = append(codeBlocks, match)
|
||||||
|
placeholder := fmt.Sprintf("\x00CODEBLOCK_%d\x00", blockIdx)
|
||||||
|
blockIdx++
|
||||||
|
return placeholder
|
||||||
|
})
|
||||||
|
|
||||||
|
matches := markdownTableRe.FindAllStringSubmatchIndex(protected, -1)
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return []contentSegment{{content: content, isTable: false}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore code blocks using indexed placeholders
|
||||||
|
restoreCodeBlocks := func(s string) string {
|
||||||
|
result := s
|
||||||
|
for i, block := range codeBlocks {
|
||||||
|
placeholder := fmt.Sprintf("\x00CODEBLOCK_%d\x00", i)
|
||||||
|
result = strings.Replace(result, placeholder, block, 1)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
lastEnd := 0
|
||||||
|
for _, match := range matches {
|
||||||
|
if match[0] > lastEnd {
|
||||||
|
segments = append(segments, contentSegment{
|
||||||
|
content: restoreCodeBlocks(protected[lastEnd:match[0]]),
|
||||||
|
isTable: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
segments = append(segments, contentSegment{
|
||||||
|
content: restoreCodeBlocks(protected[match[0]:match[1]]),
|
||||||
|
isTable: true,
|
||||||
|
})
|
||||||
|
lastEnd = match[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastEnd < len(protected) {
|
||||||
|
segments = append(segments, contentSegment{
|
||||||
|
content: restoreCodeBlocks(protected[lastEnd:]),
|
||||||
|
isTable: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return segments
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTable(tableStr string) string {
|
||||||
|
lines := strings.Split(strings.TrimSpace(tableStr), "\n")
|
||||||
|
if len(lines) < 2 {
|
||||||
|
return "```\n" + tableStr + "\n```"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse all rows to get column widths
|
||||||
|
var allRows [][]string
|
||||||
|
maxCols := 0
|
||||||
|
for i, line := range lines {
|
||||||
|
if i == 1 && isSeparatorRow(line) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cells := parseTableRow(line)
|
||||||
|
if len(cells) > 0 {
|
||||||
|
allRows = append(allRows, cells)
|
||||||
|
if len(cells) > maxCols {
|
||||||
|
maxCols = len(cells)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(allRows) == 0 {
|
||||||
|
return "```\n" + tableStr + "\n```"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate max width for each column using rune count
|
||||||
|
colWidths := make([]int, maxCols)
|
||||||
|
for _, row := range allRows {
|
||||||
|
for i, cell := range row {
|
||||||
|
runeLen := len([]rune(cell))
|
||||||
|
if runeLen > colWidths[i] {
|
||||||
|
colWidths[i] = runeLen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if table is narrow enough for mrkdwn format
|
||||||
|
totalWidth := 0
|
||||||
|
for _, w := range colWidths {
|
||||||
|
totalWidth += w
|
||||||
|
}
|
||||||
|
if len(colWidths) > 1 {
|
||||||
|
totalWidth += 3 * (len(colWidths) - 1) // " | " separators between columns
|
||||||
|
}
|
||||||
|
if totalWidth <= maxTableRowWidth {
|
||||||
|
// Render as formatted text with bold headers
|
||||||
|
var result strings.Builder
|
||||||
|
for i, row := range allRows {
|
||||||
|
if i == 0 {
|
||||||
|
var boldCells []string
|
||||||
|
for _, cell := range row {
|
||||||
|
boldCells = append(boldCells, "*"+cell+"*")
|
||||||
|
}
|
||||||
|
result.WriteString(strings.Join(boldCells, " | "))
|
||||||
|
} else {
|
||||||
|
result.WriteString(strings.Join(row, " | "))
|
||||||
|
}
|
||||||
|
result.WriteString("\n")
|
||||||
|
}
|
||||||
|
return strings.TrimSuffix(result.String(), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render as aligned code block
|
||||||
|
var result strings.Builder
|
||||||
|
result.WriteString("```\n")
|
||||||
|
for i, row := range allRows {
|
||||||
|
var paddedCells []string
|
||||||
|
for j, cell := range row {
|
||||||
|
if j < len(colWidths) {
|
||||||
|
paddedCells = append(paddedCells, padRight(cell, colWidths[j]))
|
||||||
|
} else {
|
||||||
|
paddedCells = append(paddedCells, cell)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.WriteString("| ")
|
||||||
|
result.WriteString(strings.Join(paddedCells, " | "))
|
||||||
|
result.WriteString(" |\n")
|
||||||
|
|
||||||
|
// Add separator after header
|
||||||
|
if i == 0 {
|
||||||
|
var sepParts []string
|
||||||
|
for _, w := range colWidths {
|
||||||
|
sepParts = append(sepParts, strings.Repeat("-", w))
|
||||||
|
}
|
||||||
|
result.WriteString("|-")
|
||||||
|
result.WriteString(strings.Join(sepParts, "-|-"))
|
||||||
|
result.WriteString("-|\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.WriteString("```")
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func padRight(s string, width int) string {
|
||||||
|
runeLen := len([]rune(s))
|
||||||
|
if runeLen >= width {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s + strings.Repeat(" ", width-runeLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSeparatorRow(line string) bool {
|
||||||
|
cleaned := strings.ReplaceAll(line, "|", "")
|
||||||
|
cleaned = strings.ReplaceAll(cleaned, " ", "")
|
||||||
|
cleaned = strings.ReplaceAll(cleaned, "-", "")
|
||||||
|
cleaned = strings.ReplaceAll(cleaned, ":", "")
|
||||||
|
return cleaned == ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTableRow(line string) []string {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
line = strings.TrimPrefix(line, "|")
|
||||||
|
line = strings.TrimSuffix(line, "|")
|
||||||
|
if line == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(line, "|")
|
||||||
|
var cells []string
|
||||||
|
for _, p := range parts {
|
||||||
|
cells = append(cells, strings.TrimSpace(p))
|
||||||
|
}
|
||||||
|
return cells
|
||||||
|
}
|
||||||
187
pkg/channels/slack_webhook/convert_test.go
Normal file
187
pkg/channels/slack_webhook/convert_test.go
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
package slackwebhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConvertMarkdownToMrkdwn(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "bold double asterisk",
|
||||||
|
input: "This is **bold** text",
|
||||||
|
expected: "This is *bold* text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "italic single asterisk",
|
||||||
|
input: "This is *italic* text",
|
||||||
|
expected: "This is _italic_ text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "italic underscore",
|
||||||
|
input: "This is _italic_ text",
|
||||||
|
expected: "This is _italic_ text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "strikethrough",
|
||||||
|
input: "This is ~~struck~~ text",
|
||||||
|
expected: "This is ~struck~ text",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inline code unchanged",
|
||||||
|
input: "Use `code` here",
|
||||||
|
expected: "Use `code` here",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "link conversion",
|
||||||
|
input: "Click [here](https://example.com) now",
|
||||||
|
expected: "Click <https://example.com|here> now",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "header to bold",
|
||||||
|
input: "# Header One",
|
||||||
|
expected: "*Header One*",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "header level 2",
|
||||||
|
input: "## Header Two",
|
||||||
|
expected: "*Header Two*",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "bullet list",
|
||||||
|
input: "- item one\n- item two",
|
||||||
|
expected: "• item one\n• item two",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed formatting",
|
||||||
|
input: "**bold** and *italic* and [link](http://x.com)",
|
||||||
|
expected: "*bold* and _italic_ and <http://x.com|link>",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "code block unchanged",
|
||||||
|
input: "```\ncode here\n```",
|
||||||
|
expected: "```\ncode here\n```",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := convertMarkdownToMrkdwn(tt.input)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitContentWithTables(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expectedCount int
|
||||||
|
expectedTables int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no table",
|
||||||
|
input: "Just some text",
|
||||||
|
expectedCount: 1,
|
||||||
|
expectedTables: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "simple table",
|
||||||
|
input: "| A | B |\n|---|---|\n| 1 | 2 |",
|
||||||
|
expectedCount: 1,
|
||||||
|
expectedTables: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text before table",
|
||||||
|
input: "Intro text\n\n| A | B |\n|---|---|\n| 1 | 2 |",
|
||||||
|
expectedCount: 2,
|
||||||
|
expectedTables: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text before and after table",
|
||||||
|
input: "Before\n\n| A | B |\n|---|---|\n| 1 | 2 |\n\nAfter",
|
||||||
|
expectedCount: 3,
|
||||||
|
expectedTables: 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
segments := splitContentWithTables(tt.input)
|
||||||
|
assert.Equal(t, tt.expectedCount, len(segments))
|
||||||
|
tableCount := 0
|
||||||
|
for _, seg := range segments {
|
||||||
|
if seg.isTable {
|
||||||
|
tableCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, tt.expectedTables, tableCount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderTable(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expectCode bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "narrow table renders as text",
|
||||||
|
input: "| A | B |\n|---|---|\n| 1 | 2 |",
|
||||||
|
expectCode: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wide table renders as code block",
|
||||||
|
input: "| This is a very long column header | Another extremely long column header here |\n|---|---|\n| Some long value content here | More long value content |",
|
||||||
|
expectCode: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := renderTable(tt.input)
|
||||||
|
if tt.expectCode {
|
||||||
|
assert.Contains(t, result, "```")
|
||||||
|
} else {
|
||||||
|
assert.NotContains(t, result, "```")
|
||||||
|
assert.Contains(t, result, "*") // Bold headers
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderTable_Alignment(t *testing.T) {
|
||||||
|
input := "| Name | Status | Count |\n|---|---|---|\n| foo | OK | 1 |\n| barbaz | PENDING | 123 |"
|
||||||
|
result := renderTable(input)
|
||||||
|
|
||||||
|
// Should be mrkdwn (narrow table)
|
||||||
|
assert.NotContains(t, result, "```")
|
||||||
|
assert.Contains(t, result, "*Name*")
|
||||||
|
|
||||||
|
// Test wide table alignment
|
||||||
|
wideInput := "| This is a very long column header | Another extremely long column header here |\n|---|---|\n| Short | Longer value here |"
|
||||||
|
wideResult := renderTable(wideInput)
|
||||||
|
|
||||||
|
assert.Contains(t, wideResult, "```")
|
||||||
|
// Check that columns are padded - header and value should have same column width
|
||||||
|
lines := strings.Split(wideResult, "\n")
|
||||||
|
// Find the header line and a data line
|
||||||
|
var headerLine, dataLine string
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.Contains(line, "This is a very long") {
|
||||||
|
headerLine = line
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "Short") {
|
||||||
|
dataLine = line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Both lines should have same length (aligned columns)
|
||||||
|
assert.Equal(t, len(headerLine), len(dataLine), "columns should be aligned")
|
||||||
|
}
|
||||||
32
pkg/channels/slack_webhook/init.go
Normal file
32
pkg/channels/slack_webhook/init.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
package slackwebhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
channels.RegisterFactory(
|
||||||
|
config.ChannelSlackWebHook,
|
||||||
|
func(channelName, channelType string, cfg *config.Config, b *bus.MessageBus) (channels.Channel, error) {
|
||||||
|
bc := cfg.Channels[channelName]
|
||||||
|
decoded, err := bc.GetDecoded()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
c, ok := decoded.(*config.SlackWebhookSettings)
|
||||||
|
if !ok {
|
||||||
|
return nil, channels.ErrSendFailed
|
||||||
|
}
|
||||||
|
ch, err := NewSlackWebhookChannel(bc, c, b)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if channelName != config.ChannelSlackWebHook {
|
||||||
|
ch.SetName(channelName)
|
||||||
|
}
|
||||||
|
return ch, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
316
pkg/channels/slack_webhook/slack_webhook.go
Normal file
316
pkg/channels/slack_webhook/slack_webhook.go
Normal file
|
|
@ -0,0 +1,316 @@
|
||||||
|
package slackwebhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const maxTextBlockLength = 3000
|
||||||
|
|
||||||
|
// SlackWebhookChannel is an output-only channel that sends messages
|
||||||
|
// to Slack via Incoming Webhooks using Block Kit formatting.
|
||||||
|
type SlackWebhookChannel struct {
|
||||||
|
*channels.BaseChannel
|
||||||
|
bc *config.Channel
|
||||||
|
config *config.SlackWebhookSettings
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSlackWebhookChannel creates a new Slack webhook channel.
|
||||||
|
func NewSlackWebhookChannel(
|
||||||
|
bc *config.Channel,
|
||||||
|
cfg *config.SlackWebhookSettings,
|
||||||
|
bus *bus.MessageBus,
|
||||||
|
) (*SlackWebhookChannel, error) {
|
||||||
|
if len(cfg.Webhooks) == 0 {
|
||||||
|
return nil, fmt.Errorf("slack_webhook: at least one webhook target is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, hasDefault := cfg.Webhooks["default"]; !hasDefault {
|
||||||
|
return nil, fmt.Errorf("slack_webhook: a 'default' webhook target is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, target := range cfg.Webhooks {
|
||||||
|
webhookURL := target.WebhookURL.String()
|
||||||
|
if webhookURL == "" {
|
||||||
|
return nil, fmt.Errorf("slack_webhook: webhook %q has empty webhook_url", name)
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(webhookURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("slack_webhook: webhook %q has invalid URL format: %w", name, err)
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(parsed.Scheme, "https") {
|
||||||
|
return nil, fmt.Errorf("slack_webhook: webhook %q must use HTTPS (got %q)", name, parsed.Scheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base := channels.NewBaseChannel(
|
||||||
|
"slack_webhook",
|
||||||
|
cfg,
|
||||||
|
bus,
|
||||||
|
[]string{"*"},
|
||||||
|
channels.WithMaxMessageLength(40000),
|
||||||
|
)
|
||||||
|
|
||||||
|
return &SlackWebhookChannel{
|
||||||
|
BaseChannel: base,
|
||||||
|
bc: bc,
|
||||||
|
config: cfg,
|
||||||
|
client: &http.Client{
|
||||||
|
Timeout: 30 * time.Second,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start initializes the channel. For output-only channels, this is a no-op.
|
||||||
|
func (c *SlackWebhookChannel) Start(ctx context.Context) error {
|
||||||
|
targets := make([]string, 0, len(c.config.Webhooks))
|
||||||
|
for name := range c.config.Webhooks {
|
||||||
|
targets = append(targets, name)
|
||||||
|
}
|
||||||
|
sort.Strings(targets)
|
||||||
|
logger.InfoCF("slack_webhook", "Starting Slack webhook channel (output-only)", map[string]any{
|
||||||
|
"targets": targets,
|
||||||
|
})
|
||||||
|
c.SetRunning(true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop shuts down the channel.
|
||||||
|
func (c *SlackWebhookChannel) Stop(ctx context.Context) error {
|
||||||
|
logger.InfoC("slack_webhook", "Stopping Slack webhook channel")
|
||||||
|
c.SetRunning(false)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send delivers a message to the specified Slack webhook target.
|
||||||
|
func (c *SlackWebhookChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
||||||
|
if !c.IsRunning() {
|
||||||
|
return nil, channels.ErrNotRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
targetName := msg.ChatID
|
||||||
|
if targetName == "" {
|
||||||
|
targetName = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
target, ok := c.config.Webhooks[targetName]
|
||||||
|
if !ok {
|
||||||
|
logger.WarnCF("slack_webhook", "Unknown target, falling back to default", map[string]any{
|
||||||
|
"requested": msg.ChatID,
|
||||||
|
"using": "default",
|
||||||
|
})
|
||||||
|
target = c.config.Webhooks["default"]
|
||||||
|
targetName = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := c.buildPayload(msg, target)
|
||||||
|
|
||||||
|
jsonData, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("slack_webhook: failed to marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target.WebhookURL.String(), bytes.NewReader(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("slack_webhook: failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := c.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
logger.ErrorCF("slack_webhook", "Failed to send message", map[string]any{
|
||||||
|
"target": targetName,
|
||||||
|
})
|
||||||
|
// Don't expose raw error - it may contain webhook URL secrets
|
||||||
|
return nil, fmt.Errorf("slack_webhook: network error: %w", channels.ErrTemporary)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
respText := strings.TrimSpace(string(respBody))
|
||||||
|
if respText == "" {
|
||||||
|
respText = http.StatusText(resp.StatusCode)
|
||||||
|
if respText == "" {
|
||||||
|
respText = "unknown error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.ErrorCF("slack_webhook", "Slack API error", map[string]any{
|
||||||
|
"target": targetName,
|
||||||
|
"status": resp.StatusCode,
|
||||||
|
"response": respText,
|
||||||
|
})
|
||||||
|
sendErr := fmt.Errorf("status %d: %s", resp.StatusCode, respText)
|
||||||
|
return nil, fmt.Errorf("slack_webhook: %w", channels.ClassifySendError(resp.StatusCode, sendErr))
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.DebugCF("slack_webhook", "Message sent successfully", map[string]any{
|
||||||
|
"target": targetName,
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SlackWebhookChannel) buildPayload(msg bus.OutboundMessage, target config.SlackWebhookTarget) map[string]any {
|
||||||
|
payload := make(map[string]any)
|
||||||
|
|
||||||
|
if target.Username != "" {
|
||||||
|
payload["username"] = target.Username
|
||||||
|
}
|
||||||
|
if target.IconEmoji != "" {
|
||||||
|
payload["icon_emoji"] = target.IconEmoji
|
||||||
|
}
|
||||||
|
|
||||||
|
content := msg.Content
|
||||||
|
if content == "" {
|
||||||
|
content = "(empty message)"
|
||||||
|
}
|
||||||
|
|
||||||
|
blocks := c.buildBlocks(content)
|
||||||
|
payload["blocks"] = blocks
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SlackWebhookChannel) buildBlocks(content string) []map[string]any {
|
||||||
|
var blocks []map[string]any
|
||||||
|
|
||||||
|
segments := splitContentWithTables(content)
|
||||||
|
|
||||||
|
for _, seg := range segments {
|
||||||
|
if seg.isTable {
|
||||||
|
tableText := renderTable(seg.content)
|
||||||
|
for _, chunk := range splitText(tableText, maxTextBlockLength) {
|
||||||
|
blocks = append(blocks, c.textSection(chunk))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
text := strings.TrimSpace(seg.content)
|
||||||
|
if text == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
converted := convertMarkdownToMrkdwn(text)
|
||||||
|
for _, chunk := range splitText(converted, maxTextBlockLength) {
|
||||||
|
blocks = append(blocks, c.textSection(chunk))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(blocks) == 0 {
|
||||||
|
blocks = append(blocks, c.textSection("(empty message)"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SlackWebhookChannel) textSection(text string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"type": "section",
|
||||||
|
"text": map[string]any{
|
||||||
|
"type": "mrkdwn",
|
||||||
|
"text": text,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitText(text string, maxLen int) []string {
|
||||||
|
runes := []rune(text)
|
||||||
|
if len(runes) <= maxLen {
|
||||||
|
return []string{text}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fencePrefix = "```\n"
|
||||||
|
const fenceSuffix = "\n```"
|
||||||
|
fencePrefixLen := len([]rune(fencePrefix))
|
||||||
|
fenceSuffixLen := len([]rune(fenceSuffix))
|
||||||
|
|
||||||
|
var chunks []string
|
||||||
|
inFence := false
|
||||||
|
|
||||||
|
for len(runes) > 0 {
|
||||||
|
// Calculate content budget reserving space for fence markers
|
||||||
|
prefixLen := 0
|
||||||
|
if inFence {
|
||||||
|
prefixLen = fencePrefixLen
|
||||||
|
}
|
||||||
|
contentBudget := maxLen - prefixLen - fenceSuffixLen
|
||||||
|
if contentBudget <= 0 {
|
||||||
|
contentBudget = maxLen
|
||||||
|
}
|
||||||
|
|
||||||
|
splitAt := len(runes)
|
||||||
|
if splitAt > contentBudget {
|
||||||
|
splitAt = findSplitPoint(runes, contentBudget)
|
||||||
|
if splitAt <= 0 || splitAt > contentBudget {
|
||||||
|
splitAt = contentBudget
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkBody := string(runes[:splitAt])
|
||||||
|
chunkEndsInFence := endsInsideFence(chunkBody, inFence)
|
||||||
|
chunk := wrapFenceChunk(chunkBody, inFence, chunkEndsInFence)
|
||||||
|
|
||||||
|
chunks = append(chunks, chunk)
|
||||||
|
inFence = chunkEndsInFence
|
||||||
|
runes = runes[splitAt:]
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
func wrapFenceChunk(text string, wasInFence bool, endsInFence bool) string {
|
||||||
|
if wasInFence && !strings.HasPrefix(strings.TrimSpace(text), "```") {
|
||||||
|
text = "```\n" + text
|
||||||
|
}
|
||||||
|
if endsInFence {
|
||||||
|
text = strings.TrimSuffix(text, "\n") + "\n```"
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
func findSplitPoint(runes []rune, maxLen int) int {
|
||||||
|
if len(runes) <= maxLen {
|
||||||
|
return len(runes)
|
||||||
|
}
|
||||||
|
window := string(runes[:maxLen])
|
||||||
|
|
||||||
|
// Try splitting on newline
|
||||||
|
if idx := strings.LastIndex(window, "\n"); idx > 0 {
|
||||||
|
return len([]rune(window[:idx])) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try splitting on space
|
||||||
|
if idx := strings.LastIndex(window, " "); idx > 0 {
|
||||||
|
return len([]rune(window[:idx])) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to split before a fence marker
|
||||||
|
if idx := strings.LastIndex(window, "```"); idx > 0 {
|
||||||
|
return len([]rune(window[:idx]))
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxLen
|
||||||
|
}
|
||||||
|
|
||||||
|
func endsInsideFence(text string, wasInFence bool) bool {
|
||||||
|
return wasInFence != (strings.Count(text, "```")%2 == 1)
|
||||||
|
}
|
||||||
281
pkg/channels/slack_webhook/slack_webhook_test.go
Normal file
281
pkg/channels/slack_webhook/slack_webhook_test.go
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
package slackwebhook
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/sipeed/picoclaw/pkg/bus"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/channels"
|
||||||
|
"github.com/sipeed/picoclaw/pkg/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewSlackWebhookChannel_Validation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
webhooks map[string]config.SlackWebhookTarget
|
||||||
|
expectErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty webhooks",
|
||||||
|
webhooks: map[string]config.SlackWebhookTarget{},
|
||||||
|
expectErr: "at least one webhook target is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing default",
|
||||||
|
webhooks: map[string]config.SlackWebhookTarget{
|
||||||
|
"alerts": {
|
||||||
|
WebhookURL: *config.NewSecureString("https://hooks.slack.com/services/T/B/x"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErr: "a 'default' webhook target is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty webhook URL",
|
||||||
|
webhooks: map[string]config.SlackWebhookTarget{
|
||||||
|
"default": {WebhookURL: *config.NewSecureString("")},
|
||||||
|
},
|
||||||
|
expectErr: "has empty webhook_url",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-HTTPS URL",
|
||||||
|
webhooks: map[string]config.SlackWebhookTarget{
|
||||||
|
"default": {
|
||||||
|
WebhookURL: *config.NewSecureString("http://hooks.slack.com/services/T/B/x"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErr: "must use HTTPS",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid config",
|
||||||
|
webhooks: map[string]config.SlackWebhookTarget{
|
||||||
|
"default": {
|
||||||
|
WebhookURL: *config.NewSecureString("https://hooks.slack.com/services/T/B/x"),
|
||||||
|
Username: "TestBot",
|
||||||
|
IconEmoji: ":robot_face:",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectErr: "",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
cfg := &config.SlackWebhookSettings{Webhooks: tt.webhooks}
|
||||||
|
bc := &config.Channel{Enabled: true}
|
||||||
|
mb := bus.NewMessageBus()
|
||||||
|
|
||||||
|
ch, err := NewSlackWebhookChannel(bc, cfg, mb)
|
||||||
|
if tt.expectErr != "" {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.expectErr)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.NotNil(t, ch)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlackWebhookChannel_Send(t *testing.T) {
|
||||||
|
payloadCh := make(chan map[string]any, 1)
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var payload map[string]any
|
||||||
|
json.Unmarshal(body, &payload)
|
||||||
|
payloadCh <- payload
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &config.SlackWebhookSettings{
|
||||||
|
Webhooks: map[string]config.SlackWebhookTarget{
|
||||||
|
"default": {
|
||||||
|
WebhookURL: *config.NewSecureString(server.URL),
|
||||||
|
Username: "TestBot",
|
||||||
|
IconEmoji: ":test:",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
bc := &config.Channel{Enabled: true}
|
||||||
|
mb := bus.NewMessageBus()
|
||||||
|
|
||||||
|
ch, err := NewSlackWebhookChannel(bc, cfg, mb)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Use the test server's client to skip TLS verification
|
||||||
|
ch.client = server.Client()
|
||||||
|
|
||||||
|
err = ch.Start(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
Content: "Hello **world**",
|
||||||
|
ChatID: "default",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify payload structure
|
||||||
|
receivedPayload := <-payloadCh
|
||||||
|
assert.Equal(t, "TestBot", receivedPayload["username"])
|
||||||
|
assert.Equal(t, ":test:", receivedPayload["icon_emoji"])
|
||||||
|
blocks, ok := receivedPayload["blocks"].([]any)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Len(t, blocks, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlackWebhookChannel_FallbackToDefault(t *testing.T) {
|
||||||
|
var requestCount atomic.Int32
|
||||||
|
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
requestCount.Add(1)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &config.SlackWebhookSettings{
|
||||||
|
Webhooks: map[string]config.SlackWebhookTarget{
|
||||||
|
"default": {WebhookURL: *config.NewSecureString(server.URL)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
bc := &config.Channel{Enabled: true}
|
||||||
|
mb := bus.NewMessageBus()
|
||||||
|
|
||||||
|
ch, err := NewSlackWebhookChannel(bc, cfg, mb)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ch.client = server.Client()
|
||||||
|
err = ch.Start(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Send to unknown target - should fall back to default
|
||||||
|
_, err = ch.Send(context.Background(), bus.OutboundMessage{
|
||||||
|
Content: "Test",
|
||||||
|
ChatID: "unknown_target",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int32(1), requestCount.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlackWebhookChannel_ErrorClassification(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
statusCode int
|
||||||
|
expectTemp bool
|
||||||
|
}{
|
||||||
|
{"400 Bad Request", 400, false},
|
||||||
|
{"401 Unauthorized", 401, false},
|
||||||
|
{"403 Forbidden", 403, false},
|
||||||
|
{"404 Not Found", 404, false},
|
||||||
|
{"500 Internal Error", 500, true},
|
||||||
|
{"502 Bad Gateway", 502, true},
|
||||||
|
{"503 Service Unavailable", 503, true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
server := httptest.NewTLSServer(
|
||||||
|
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(tt.statusCode)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
cfg := &config.SlackWebhookSettings{
|
||||||
|
Webhooks: map[string]config.SlackWebhookTarget{
|
||||||
|
"default": {WebhookURL: *config.NewSecureString(server.URL)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
bc := &config.Channel{Enabled: true}
|
||||||
|
mb := bus.NewMessageBus()
|
||||||
|
|
||||||
|
ch, err := NewSlackWebhookChannel(bc, cfg, mb)
|
||||||
|
require.NoError(t, err)
|
||||||
|
ch.client = server.Client()
|
||||||
|
err = ch.Start(context.Background())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = ch.Send(context.Background(), bus.OutboundMessage{Content: "Test"})
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
if tt.expectTemp {
|
||||||
|
assert.True(
|
||||||
|
t,
|
||||||
|
errors.Is(err, channels.ErrTemporary),
|
||||||
|
"expected temporary error for %d",
|
||||||
|
tt.statusCode,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
assert.True(t, errors.Is(err, channels.ErrSendFailed), "expected permanent error for %d", tt.statusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitText_ChunkSizeLimit(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
maxLen int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "plain text",
|
||||||
|
input: strings.Repeat("a", 5000),
|
||||||
|
maxLen: 3000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "text with code block",
|
||||||
|
input: "```\n" + strings.Repeat("x", 5000) + "\n```",
|
||||||
|
maxLen: 3000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple code blocks",
|
||||||
|
input: "text\n```\n" + strings.Repeat(
|
||||||
|
"code ",
|
||||||
|
800,
|
||||||
|
) + "\n```\nmore text\n```\n" + strings.Repeat(
|
||||||
|
"more ",
|
||||||
|
800,
|
||||||
|
) + "\n```",
|
||||||
|
maxLen: 3000,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
chunks := splitText(tt.input, tt.maxLen)
|
||||||
|
for i, chunk := range chunks {
|
||||||
|
runeLen := len([]rune(chunk))
|
||||||
|
assert.LessOrEqual(t, runeLen, tt.maxLen,
|
||||||
|
"chunk %d has %d runes, exceeds max %d", i, runeLen, tt.maxLen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitText_FenceIntegrity(t *testing.T) {
|
||||||
|
input := "```\n" + strings.Repeat("line of code\n", 300) + "```"
|
||||||
|
|
||||||
|
chunks := splitText(input, 3000)
|
||||||
|
require.Greater(t, len(chunks), 1, "expected multiple chunks")
|
||||||
|
|
||||||
|
for i, chunk := range chunks {
|
||||||
|
openCount := strings.Count(chunk, "```")
|
||||||
|
assert.Equal(t, 0, openCount%2,
|
||||||
|
"chunk %d has unbalanced fence markers (count=%d)", i, openCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitText_ShortText(t *testing.T) {
|
||||||
|
input := "short text"
|
||||||
|
chunks := splitText(input, 3000)
|
||||||
|
require.Len(t, chunks, 1)
|
||||||
|
assert.Equal(t, input, chunks[0])
|
||||||
|
}
|
||||||
|
|
@ -648,6 +648,18 @@ type MQTTSettings struct {
|
||||||
QoS int `json:"qos,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_QOS"`
|
QoS int `json:"qos,omitempty" yaml:"-" env:"PICOCLAW_CHANNELS_MQTT_QOS"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SlackWebhookSettings configures the output-only Slack webhook channel.
|
||||||
|
type SlackWebhookSettings struct {
|
||||||
|
Webhooks map[string]SlackWebhookTarget `json:"webhooks" yaml:"webhooks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlackWebhookTarget represents a single Slack Incoming Webhook destination.
|
||||||
|
type SlackWebhookTarget struct {
|
||||||
|
WebhookURL SecureString `json:"webhook_url,omitzero" yaml:"webhook_url,omitempty"`
|
||||||
|
Username string `json:"username,omitempty" yaml:"-"`
|
||||||
|
IconEmoji string `json:"icon_emoji,omitempty" yaml:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
type HeartbeatConfig struct {
|
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
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const (
|
||||||
ChannelWhatsAppNative = "whatsapp_native"
|
ChannelWhatsAppNative = "whatsapp_native"
|
||||||
ChannelTeamsWebHook = "teams_webhook"
|
ChannelTeamsWebHook = "teams_webhook"
|
||||||
ChannelMQTT = "mqtt"
|
ChannelMQTT = "mqtt"
|
||||||
|
ChannelSlackWebHook = "slack_webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
func initChannel() {
|
func initChannel() {
|
||||||
|
|
@ -642,6 +643,7 @@ var channelSettingsFactory = map[string]any{
|
||||||
ChannelWhatsAppNative: (WhatsAppSettings{}),
|
ChannelWhatsAppNative: (WhatsAppSettings{}),
|
||||||
ChannelTeamsWebHook: (TeamsWebhookSettings{}),
|
ChannelTeamsWebHook: (TeamsWebhookSettings{}),
|
||||||
ChannelMQTT: (MQTTSettings{}),
|
ChannelMQTT: (MQTTSettings{}),
|
||||||
|
ChannelSlackWebHook: (SlackWebhookSettings{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
// newChannelSettings creates a fresh zero-value pointer for the given channel type.
|
// newChannelSettings creates a fresh zero-value pointer for the given channel type.
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,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/slack_webhook"
|
||||||
_ "github.com/sipeed/picoclaw/pkg/channels/teams_webhook"
|
_ "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"
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue