Files
picoclaw/pkg/channels/slack_webhook/convert.go
T
Andy Lo-A-Foe b12f03be2e feat(channels): add slack_webhook channel
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>
2026-05-11 09:54:04 +02:00

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
}