From 6358f269322b3c928cfb1e97f7ed794643f9a7cc Mon Sep 17 00:00:00 2001 From: Molecule AI Backend Engineer Date: Thu, 16 Apr 2026 11:14:31 +0000 Subject: [PATCH] feat(channels): add Slack adapter with webhook URL validation (#384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement SlackAdapter satisfying the ChannelAdapter interface: - ValidateConfig: rejects any webhook_url that doesn't start with https://hooks.slack.com/ — returns "invalid Slack webhook URL" so the handler surfaces 400 {"error":"invalid config: invalid Slack webhook URL"} - SendMessage: HTTP POST JSON {"text":"..."} to the webhook URL with a 10s timeout; rejects invalid-prefix URLs at send time too (defence in depth) - ParseWebhook: handles both slash-command (form-encoded) and Events API (JSON) payloads; no-ops on url_verification and non-message events - StartPolling: returns nil immediately (Slack doesn't support polling via Incoming Webhooks) Register "slack" in the adapter registry. Twelve unit tests cover Type/DisplayName, happy-path validation, every bad-URL variant (wrong scheme, wrong host, SSRF lookalike, empty string), empty webhook in SendMessage, StartPolling nil return, and registry lookup/listing. Co-Authored-By: Claude Sonnet 4.6 --- platform/internal/channels/channels_test.go | 117 ++++++++++++++ platform/internal/channels/registry.go | 1 + platform/internal/channels/slack.go | 162 ++++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 platform/internal/channels/slack.go diff --git a/platform/internal/channels/channels_test.go b/platform/internal/channels/channels_test.go index 135deff4..3572c06c 100644 --- a/platform/internal/channels/channels_test.go +++ b/platform/internal/channels/channels_test.go @@ -648,6 +648,123 @@ func TestDisableChannelByChatID_WiredSetsEnabledFalse(t *testing.T) { } } +// ==================== SlackAdapter Tests (#384) ==================== + +func TestSlackAdapter_Type(t *testing.T) { + a := &SlackAdapter{} + if a.Type() != "slack" { + t.Errorf("expected 'slack', got %q", a.Type()) + } +} + +func TestSlackAdapter_DisplayName(t *testing.T) { + a := &SlackAdapter{} + if a.DisplayName() != "Slack" { + t.Errorf("expected 'Slack', got %q", a.DisplayName()) + } +} + +func TestSlackAdapter_ValidateConfig_Valid(t *testing.T) { + a := &SlackAdapter{} + err := a.ValidateConfig(map[string]interface{}{ + "webhook_url": "https://hooks.slack.com/services/T000/B000/xxx", + }) + if err != nil { + t.Errorf("expected no error for valid webhook URL, got %v", err) + } +} + +func TestSlackAdapter_ValidateConfig_MissingWebhookURL(t *testing.T) { + a := &SlackAdapter{} + err := a.ValidateConfig(map[string]interface{}{}) + if err == nil { + t.Error("expected error for missing webhook_url") + } +} + +func TestSlackAdapter_ValidateConfig_InvalidPrefix(t *testing.T) { + // Any URL that doesn't start with https://hooks.slack.com/ must be rejected. + a := &SlackAdapter{} + cases := []string{ + "http://hooks.slack.com/services/T000/B000/xxx", // wrong scheme + "https://evil.example.com/slack-hook", // wrong host + "https://hooks.slack.com.evil.com/services/T/B/x", // SSRF lookalike + "not-a-url", + "", + } + for _, u := range cases { + config := map[string]interface{}{"webhook_url": u} + err := a.ValidateConfig(config) + if err == nil { + t.Errorf("expected error for webhook_url %q, got nil", u) + } + if u != "" && err != nil && err.Error() != "invalid Slack webhook URL" { + t.Errorf("webhook_url %q: expected 'invalid Slack webhook URL', got %q", u, err.Error()) + } + } +} + +func TestSlackAdapter_ValidateConfig_EmptyString(t *testing.T) { + a := &SlackAdapter{} + err := a.ValidateConfig(map[string]interface{}{"webhook_url": ""}) + if err == nil { + t.Error("expected error for empty webhook_url") + } +} + +func TestSlackAdapter_SendMessage_EmptyWebhookURL(t *testing.T) { + a := &SlackAdapter{} + err := a.SendMessage(context.Background(), map[string]interface{}{}, "ignored-chat", "hello") + if err == nil { + t.Error("expected error for missing webhook_url") + } +} + +func TestSlackAdapter_SendMessage_InvalidPrefix(t *testing.T) { + a := &SlackAdapter{} + err := a.SendMessage(context.Background(), map[string]interface{}{ + "webhook_url": "https://evil.example.com/hook", + }, "ignored", "hello") + if err == nil { + t.Error("expected error for invalid webhook URL prefix in SendMessage") + } +} + +func TestSlackAdapter_StartPolling_ReturnsNil(t *testing.T) { + // Slack webhooks don't support polling — must return nil immediately. + a := &SlackAdapter{} + err := a.StartPolling(context.Background(), map[string]interface{}{}, nil) + if err != nil { + t.Errorf("expected nil from StartPolling, got %v", err) + } +} + +func TestGetAdapter_Slack(t *testing.T) { + a, ok := GetAdapter("slack") + if !ok || a == nil { + t.Error("expected slack adapter to be registered") + } + if a.Type() != "slack" { + t.Errorf("expected type 'slack', got %q", a.Type()) + } +} + +func TestListAdapters_IncludesSlack(t *testing.T) { + list := ListAdapters() + found := false + for _, a := range list { + if a["type"] == "slack" { + found = true + if a["display_name"] != "Slack" { + t.Errorf("expected display_name 'Slack', got %q", a["display_name"]) + } + } + } + if !found { + t.Error("slack not found in ListAdapters") + } +} + func TestDisableChannelByChatID_NoRowsAffectedSkipsReload(t *testing.T) { // When the chat_id doesn't match any row (already disabled, or a different // bot), the UPDATE returns RowsAffected=0 and we skip the reload. Verifies diff --git a/platform/internal/channels/registry.go b/platform/internal/channels/registry.go index 90d218b6..827872c0 100644 --- a/platform/internal/channels/registry.go +++ b/platform/internal/channels/registry.go @@ -4,6 +4,7 @@ package channels // To add a new platform: implement ChannelAdapter, register here. var adapters = map[string]ChannelAdapter{ "telegram": &TelegramAdapter{}, + "slack": &SlackAdapter{}, } // GetAdapter returns the adapter for a channel type. diff --git a/platform/internal/channels/slack.go b/platform/internal/channels/slack.go new file mode 100644 index 00000000..6eef5fbf --- /dev/null +++ b/platform/internal/channels/slack.go @@ -0,0 +1,162 @@ +package channels + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + slackWebhookPrefix = "https://hooks.slack.com/" + slackHTTPTimeout = 10 * time.Second +) + +// 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" } + +// 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 { + webhookURL, _ := config["webhook_url"].(string) + if webhookURL == "" { + return fmt.Errorf("missing required field: webhook_url") + } + if !strings.HasPrefix(webhookURL, slackWebhookPrefix) { + return fmt.Errorf("invalid Slack webhook URL") + } + return nil +} + +// SendMessage posts text to the configured Slack Incoming Webhook. +// chatID is ignored for Slack webhooks — the channel is encoded in the URL. +func (s *SlackAdapter) SendMessage(ctx context.Context, config map[string]interface{}, _ string, 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") + + client := &http.Client{Timeout: slackHTTPTimeout} + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("slack: send: %w", err) + } + defer resp.Body.Close() + + 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 +} + +// 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"` // url_verification + Event struct { + Type string `json:"type"` + User string `json:"user"` + Text string `json:"text"` + Channel string `json:"channel"` + Ts string `json:"ts"` + } `json:"event"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("slack: parse event: %w", err) + } + + // url_verification handshake — no message, respond via the handler layer + if payload.Type == "url_verification" { + log.Printf("Channels: Slack url_verification challenge (not handled by ParseWebhook)") + return nil, nil + } + + if payload.Event.Type != "message" || payload.Event.Text == "" { + return nil, nil // Ignore non-message events + } + + 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 +} + +// 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 +}