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:
commit
cdc1caf6f9
@ -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
|
||||
|
||||
@ -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.
|
||||
|
||||
162
platform/internal/channels/slack.go
Normal file
162
platform/internal/channels/slack.go
Normal 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
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user