molecule-core/workspace-server/internal/channels/slack.go
rabbitblood 00265d7028 feat(channels): first-class Lark/Feishu support via schema-driven config
Lark adapter was already implemented in Go (lark.go — outbound Custom Bot
webhook + inbound Event Subscriptions with constant-time token verify),
but the Canvas connect-form hardcoded a Telegram-shaped pair of inputs
(bot_token + chat_id). Selecting "Lark / Feishu" from the dropdown
silently sent the wrong field names — there was no way to enter a
webhook URL.

Fix: move form shape to the server.

- Add `ConfigField` struct + `ConfigSchema()` method to the
  `ChannelAdapter` interface. Each adapter declares its own fields with
  label/type/required/sensitive/placeholder/help.
- Implement per-adapter schemas:
  - Lark: webhook_url (required+sensitive) + verify_token (optional+sensitive)
  - Slack: bot_token/channel_id/webhook_url/username/icon_emoji
  - Discord: webhook_url + optional public_key
  - Telegram: bot_token + chat_id (unchanged UX, keeps Detect Chats)
- Change `ListAdapters()` to return `[]AdapterInfo` with config_schema
  inline. Sorted deterministically by display name so UI ordering is
  stable across Go's random map iteration.
- Update the 3 existing `ListAdapters` test sites to struct access.

Canvas (`ChannelsTab.tsx`):
- Replace the two hardcoded bot_token/chat_id inputs with a single
  schema-driven `SchemaField` component. Renders one input per field in
  the order the adapter returns them.
- Form state becomes `formValues: Record<string,string>` keyed by
  `ConfigField.key`. Values reset on platform-switch so stale
  Telegram credentials can't leak into a new Lark channel.
- "Detect Chats" stays but only renders for platforms in
  `SUPPORTS_DETECT_CHATS` (Telegram only — the only provider with
  getUpdates).
- Only schema-known keys are posted in `config`, scrubbing any stale
  values from previous platform selections.

Regression tests:
- `TestLark_ConfigSchema` locks in the 2-field Lark contract with the
  required/sensitive flags correctly set.
- `TestListAdapters_IncludesLark` confirms registry wiring + schema
  survives round-trip through ListAdapters.

Known pre-existing `TestStripPluginMarkers_AwkScript` failure in
internal/handlers is unrelated to this change (verified via stash+test
on clean staging).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 11:51:15 -07:00

560 lines
16 KiB
Go

package channels
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
)
const (
slackWebhookPrefix = "https://hooks.slack.com/"
slackHTTPTimeout = 10 * time.Second
)
var slackHTTPClient = &http.Client{Timeout: slackHTTPTimeout}
// SlackAdapter implements ChannelAdapter for Slack Incoming Webhooks.
//
// Outbound messages are sent via Slack Incoming Webhooks (the simple,
// no-OAuth path). Inbound messages require Slack Event API / slash command
// configuration on the Slack App side; ParseWebhook handles the JSON payload
// that Slack POSTs to the registered webhook URL.
type SlackAdapter struct{}
func (s *SlackAdapter) Type() string { return "slack" }
func (s *SlackAdapter) DisplayName() string { return "Slack" }
// ConfigSchema — Slack supports two mutually-exclusive outbound modes:
// Bot API (bot_token + channel_id, supports per-message identity override)
// and Incoming Webhook (webhook_url, legacy, no identity override). The
// form exposes both; ValidateConfig enforces "one or the other".
func (s *SlackAdapter) ConfigSchema() []ConfigField {
return []ConfigField{
{
Key: "bot_token",
Label: "Bot Token (xoxb-…)",
Type: "password",
Required: false,
Sensitive: true,
Placeholder: "xoxb-1234-5678-abc...",
Help: "Bot API mode — supports per-agent identity override. Required scopes: chat:write, chat:write.customize. Leave empty to use Incoming Webhook mode instead.",
},
{
Key: "channel_id",
Label: "Channel ID",
Type: "text",
Required: false,
Placeholder: "C01234ABCDE",
Help: "Required when using Bot Token mode. From the channel's \"View channel details\" dialog.",
},
{
Key: "webhook_url",
Label: "Incoming Webhook URL (legacy)",
Type: "password",
Required: false,
Sensitive: true,
Placeholder: "https://hooks.slack.com/services/T.../B.../...",
Help: "Simpler mode — no per-agent identity. Either Bot Token OR Webhook URL is required.",
},
{
Key: "username",
Label: "Override Username",
Type: "text",
Required: false,
Placeholder: "optional, Bot Token mode only",
Help: "Display name to use on outbound messages. Ignored in Webhook mode.",
},
{
Key: "icon_emoji",
Label: "Override Icon Emoji",
Type: "text",
Required: false,
Placeholder: ":robot_face:",
Help: "Emoji shortcode for per-message avatar. Ignored in Webhook mode.",
},
}
}
// ValidateConfig checks that the channel config contains a valid Slack
// Incoming Webhook URL (must start with https://hooks.slack.com/).
// Returns an error whose message becomes part of the 400 response body so
// keep it human-readable for the canvas UI.
func (s *SlackAdapter) ValidateConfig(config map[string]interface{}) error {
botToken, _ := config["bot_token"].(string)
webhookURL, _ := config["webhook_url"].(string)
if botToken == "" && webhookURL == "" {
return fmt.Errorf("missing required field: bot_token or webhook_url")
}
if botToken != "" {
if cid, _ := config["channel_id"].(string); cid == "" {
return fmt.Errorf("bot_token mode requires channel_id")
}
}
if webhookURL != "" && !strings.HasPrefix(webhookURL, slackWebhookPrefix) {
return fmt.Errorf("invalid Slack webhook URL")
}
return nil
}
// SendMessage posts text to Slack. Supports two modes:
//
// - Bot API (bot_token set): uses chat.postMessage with per-agent identity
// via chat:write.customize scope. Supports username + icon_emoji overrides.
// - Webhook (webhook_url set, legacy): simple POST, no identity override.
//
// chatID overrides channel_id from config if non-empty (for multi-channel routing).
func (s *SlackAdapter) SendMessage(ctx context.Context, config map[string]interface{}, chatID string, text string) error {
botToken, _ := config["bot_token"].(string)
if botToken != "" {
return s.sendBotMessage(ctx, config, chatID, text)
}
return s.sendWebhookMessage(ctx, config, text)
}
func (s *SlackAdapter) sendBotMessage(ctx context.Context, config map[string]interface{}, chatID, text string) error {
botToken, _ := config["bot_token"].(string)
channelID := chatID
if channelID == "" {
channelID, _ = config["channel_id"].(string)
}
if channelID == "" {
return fmt.Errorf("slack: no channel_id")
}
username, _ := config["username"].(string)
iconEmoji, _ := config["icon_emoji"].(string)
// Convert Markdown → Slack mrkdwn before sending
text = markdownToMrkdwn(text)
// Split long messages at newline boundaries
chunks := slackSplitMessage(text, 3000)
for _, chunk := range chunks {
payload := map[string]interface{}{
"channel": channelID,
"text": chunk,
// Use blocks with mrkdwn type for rich formatting.
// The "text" field is the fallback for notifications/previews.
"blocks": []map[string]interface{}{
{
"type": "section",
"text": map[string]interface{}{
"type": "mrkdwn",
"text": chunk,
},
},
},
}
if username != "" {
payload["username"] = username
}
if iconEmoji != "" {
payload["icon_emoji"] = iconEmoji
}
body, _ := json.Marshal(payload)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://slack.com/api/chat.postMessage", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("slack: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
req.Header.Set("Authorization", "Bearer "+botToken)
resp, err := slackHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("slack: send: %w", err)
}
respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
_ = resp.Body.Close()
var result struct {
OK bool `json:"ok"`
Error string `json:"error"`
}
if json.Unmarshal(respBody, &result) == nil && !result.OK {
return fmt.Errorf("slack: API error: %s", result.Error)
}
}
return nil
}
func (s *SlackAdapter) sendWebhookMessage(ctx context.Context, config map[string]interface{}, text string) error {
webhookURL, _ := config["webhook_url"].(string)
if webhookURL == "" {
return fmt.Errorf("webhook_url not configured")
}
if !strings.HasPrefix(webhookURL, slackWebhookPrefix) {
return fmt.Errorf("invalid Slack webhook URL")
}
payload, err := json.Marshal(map[string]string{"text": text})
if err != nil {
return fmt.Errorf("slack: marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(payload))
if err != nil {
return fmt.Errorf("slack: create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := slackHTTPClient.Do(req)
if err != nil {
return fmt.Errorf("slack: send: %w", err)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("slack: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
}
// markdownToMrkdwn converts standard Markdown to Slack's mrkdwn format.
// Agents output standard MD (Claude Code default); Slack renders mrkdwn.
//
// MD **bold** → mrkdwn *bold*
// MD __italic__ or *italic* (standalone) → mrkdwn _italic_
// MD ### heading → mrkdwn *heading* (bold, no heading syntax in Slack)
// MD [text](url) → mrkdwn <url|text>
// MD --- → mrkdwn ———
// MD > quote → mrkdwn > quote (same, works as-is)
// MD `code` → mrkdwn `code` (same)
// MD ```block``` → mrkdwn ```block``` (same)
func markdownToMrkdwn(text string) string {
// First pass: convert markdown tables to aligned plain text.
// Slack has no table support — render as monospace columns.
text = convertTables(text)
lines := strings.Split(text, "\n")
for i, line := range lines {
trimmed := strings.TrimSpace(line)
// Headings: ### Text → *Text*
if strings.HasPrefix(trimmed, "#") {
heading := strings.TrimLeft(trimmed, "# ")
if heading != "" {
lines[i] = "*" + heading + "*"
continue
}
}
// Horizontal rules → simple dashes (no unicode em-dash)
if trimmed == "---" || trimmed == "***" || trimmed == "___" {
lines[i] = "----------"
continue
}
// Strikethrough: ~~text~~ → ~text~ (Slack uses single tilde)
for strings.Contains(lines[i], "~~") {
first := strings.Index(lines[i], "~~")
second := strings.Index(lines[i][first+2:], "~~")
if second < 0 {
break
}
second += first + 2
inner := lines[i][first+2 : second]
lines[i] = lines[i][:first] + "~" + inner + "~" + lines[i][second+2:]
}
// Links: [text](url) → <url|text>
for {
start := strings.Index(lines[i], "[")
if start < 0 {
break
}
mid := strings.Index(lines[i][start:], "](")
if mid < 0 {
break
}
mid += start
end := strings.Index(lines[i][mid+2:], ")")
if end < 0 {
break
}
end += mid + 2
linkText := lines[i][start+1 : mid]
url := lines[i][mid+2 : end]
lines[i] = lines[i][:start] + "<" + url + "|" + linkText + ">" + lines[i][end+1:]
}
// Bold: **text** → *text* (Slack bold is single asterisk)
for strings.Contains(lines[i], "**") {
first := strings.Index(lines[i], "**")
second := strings.Index(lines[i][first+2:], "**")
if second < 0 {
break
}
second += first + 2
inner := lines[i][first+2 : second]
lines[i] = lines[i][:first] + "*" + inner + "*" + lines[i][second+2:]
}
}
return strings.Join(lines, "\n")
}
// convertTables finds markdown tables and renders them as monospace blocks.
// Input: | Col A | Col B |
// |-------|-------|
// | val1 | val2 |
// Output: ```
// Col A Col B
// val1 val2
// ```
func convertTables(text string) string {
lines := strings.Split(text, "\n")
var result []string
i := 0
for i < len(lines) {
// Detect table start: line with | and next line is separator |---|
if strings.Contains(lines[i], "|") && i+1 < len(lines) && isTableSeparator(lines[i+1]) {
// Collect all table rows
var headers []string
var rows [][]string
headers = parseTableRow(lines[i])
i += 2 // skip header + separator
for i < len(lines) && strings.Contains(lines[i], "|") && !isTableSeparator(lines[i]) {
rows = append(rows, parseTableRow(lines[i]))
i++
}
// Calculate column widths
colWidths := make([]int, len(headers))
for j, h := range headers {
if len(h) > colWidths[j] {
colWidths[j] = len(h)
}
}
for _, row := range rows {
for j, cell := range row {
if j < len(colWidths) && len(cell) > colWidths[j] {
colWidths[j] = len(cell)
}
}
}
// Render as monospace block
result = append(result, "```")
headerLine := ""
for j, h := range headers {
headerLine += padRight(h, colWidths[j]) + " "
}
result = append(result, strings.TrimRight(headerLine, " "))
// Separator
sepLine := ""
for j := range headers {
sepLine += strings.Repeat("-", colWidths[j]) + " "
}
result = append(result, strings.TrimRight(sepLine, " "))
for _, row := range rows {
rowLine := ""
for j, cell := range row {
if j < len(colWidths) {
rowLine += padRight(cell, colWidths[j]) + " "
}
}
result = append(result, strings.TrimRight(rowLine, " "))
}
result = append(result, "```")
} else {
result = append(result, lines[i])
i++
}
}
return strings.Join(result, "\n")
}
func isTableSeparator(line string) bool {
trimmed := strings.TrimSpace(line)
return strings.Contains(trimmed, "|") && strings.Contains(trimmed, "---")
}
func parseTableRow(line string) []string {
line = strings.TrimSpace(line)
line = strings.Trim(line, "|")
parts := strings.Split(line, "|")
var cells []string
for _, p := range parts {
cells = append(cells, strings.TrimSpace(p))
}
return cells
}
func padRight(s string, width int) string {
if len(s) >= width {
return s
}
return s + strings.Repeat(" ", width-len(s))
}
func slackSplitMessage(text string, maxLen int) []string {
if len(text) <= maxLen {
return []string{text}
}
var chunks []string
for len(text) > 0 {
end := maxLen
if end > len(text) {
end = len(text)
}
if end < len(text) {
if idx := strings.LastIndex(text[:end], "\n"); idx > 0 {
end = idx + 1
}
}
chunks = append(chunks, text[:end])
text = text[end:]
}
return chunks
}
// ParseWebhook handles a Slack slash command or event API POST.
// The payload is either URL-encoded (slash commands) or JSON (Events API).
// Returns nil, nil for non-message events (e.g. url_verification challenge).
func (s *SlackAdapter) ParseWebhook(c *gin.Context, _ map[string]interface{}) (*InboundMessage, error) {
contentType := c.GetHeader("Content-Type")
var text, userID, username, channelID, msgID string
if strings.Contains(contentType, "application/x-www-form-urlencoded") {
// Slack slash command payload
if err := c.Request.ParseForm(); err != nil {
return nil, fmt.Errorf("slack: parse form: %w", err)
}
text = c.Request.FormValue("text")
userID = c.Request.FormValue("user_id")
username = c.Request.FormValue("user_name")
channelID = c.Request.FormValue("channel_id")
msgID = c.Request.FormValue("trigger_id")
// Slash command: prepend the command itself so agent sees the full invocation
if cmd := c.Request.FormValue("command"); cmd != "" {
text = cmd + " " + text
}
} else {
// Slack Events API JSON payload
body, err := io.ReadAll(c.Request.Body)
if err != nil {
return nil, fmt.Errorf("slack: read body: %w", err)
}
var payload struct {
Type string `json:"type"`
Challenge string `json:"challenge"`
Event struct {
Type string `json:"type"`
User string `json:"user"`
Text string `json:"text"`
Channel string `json:"channel"`
Ts string `json:"ts"`
BotID string `json:"bot_id"`
Subtype string `json:"subtype"`
} `json:"event"`
}
if err := json.Unmarshal(body, &payload); err != nil {
return nil, fmt.Errorf("slack: parse event: %w", err)
}
// url_verification handshake — respond with challenge directly
if payload.Type == "url_verification" {
c.JSON(200, gin.H{"challenge": payload.Challenge})
return nil, nil
}
// Ignore bot messages to prevent echo loops. Our own auto-posts
// via chat.postMessage fire Events API callbacks with bot_id set.
if payload.Event.BotID != "" || payload.Event.Subtype == "bot_message" {
return nil, nil
}
if payload.Event.Type != "message" || payload.Event.Text == "" {
return nil, nil
}
text = payload.Event.Text
userID = payload.Event.User
channelID = payload.Event.Channel
msgID = payload.Event.Ts
}
if text == "" {
return nil, nil
}
return &InboundMessage{
ChatID: channelID,
UserID: userID,
Username: username,
Text: text,
MessageID: msgID,
Metadata: map[string]string{"platform": "slack"},
}, nil
}
// SlackHistoryMessage represents a single message from conversations.history.
type SlackHistoryMessage struct {
User string `json:"user"`
Username string `json:"username"`
Text string `json:"text"`
Ts string `json:"ts"`
BotID string `json:"bot_id"`
}
// FetchChannelHistory calls Slack conversations.history and returns the
// last N messages from the channel, filtering out raw bot messages.
func FetchChannelHistory(ctx context.Context, botToken, channelID string, limit int) ([]SlackHistoryMessage, error) {
if botToken == "" || channelID == "" {
return nil, nil
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("https://slack.com/api/conversations.history?channel=%s&limit=%d", channelID, limit*2),
nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+botToken)
resp, err := slackHTTPClient.Do(req)
if err != nil {
return nil, err
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 65536))
_ = resp.Body.Close()
var result struct {
OK bool `json:"ok"`
Messages []SlackHistoryMessage `json:"messages"`
}
if json.Unmarshal(body, &result) != nil || !result.OK {
return nil, fmt.Errorf("slack history API error")
}
var filtered []SlackHistoryMessage
for _, m := range result.Messages {
if m.BotID != "" && m.Username == "" {
continue
}
filtered = append(filtered, m)
if len(filtered) >= limit {
break
}
}
// Reverse: oldest first
for i, j := 0, len(filtered)-1; i < j; i, j = i+1, j-1 {
filtered[i], filtered[j] = filtered[j], filtered[i]
}
return filtered, nil
}
// StartPolling returns nil immediately. Slack does not support long-polling
// for Incoming Webhooks — use the Slack Events API + webhook route instead.
func (s *SlackAdapter) StartPolling(_ context.Context, _ map[string]interface{}, _ MessageHandler) error {
return nil
}