mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
b12f03be2e
Add an output-only channel that sends messages to Slack via Incoming
Webhooks using Block Kit formatting.
Features:
- Multiple webhook targets with named routing (requires "default" target)
- Markdown to Slack mrkdwn conversion (bold, italic, strikethrough, links, lists)
- Code block handling with proper fence preservation across chunk splits
- Table rendering with aligned columns in code blocks
- Automatic text chunking at 3000 chars (Slack's text block limit)
- HTTPS-only webhook URL validation
Configuration example:
channels:
slack_webhook:
webhooks:
default:
webhook_url: "https://hooks.slack.com/services/..."
username: "PicoClaw"
icon_emoji: ":robot_face:"
Co-Authored-By: Claude <noreply@anthropic.com>
264 lines
6.8 KiB
Go
264 lines
6.8 KiB
Go
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
|
|
}
|