Merge pull request #467 from Molecule-AI/feat/slack-webhook-validation

[Backend Engineer] feat(channels): Slack adapter with webhook URL validation (#384)
This commit is contained in:
Hongming Wang 2026-04-16 05:22:47 -07:00 committed by GitHub
commit cdc1caf6f9
3 changed files with 280 additions and 0 deletions

View File

@ -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

View File

@ -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.

View File

@ -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
}