forked from molecule-ai/molecule-core
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>
560 lines
16 KiB
Go
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
|
|
}
|