From afd9c3b5bb25b34ddb9c4983791214be8daa1f51 Mon Sep 17 00:00:00 2001 From: Molecule AI DevOps Engineer Date: Fri, 17 Apr 2026 07:02:13 +0000 Subject: [PATCH] feat(channels): add Discord adapter (#625) Implements DiscordAdapter conforming to the ChannelAdapter interface, using Discord Incoming Webhooks for outbound messages and the Interactions endpoint for inbound slash commands. Changes: - platform/internal/channels/discord.go: DiscordAdapter + splitMessage helper (Discord enforces 2000-char limit; long messages are split at newline/space boundaries). ParseWebhook handles type-1 PING (returns nil so the router layer can respond), type-2 APPLICATION_COMMAND, and type-3 MESSAGE_COMPONENT payloads. ValidateConfig rejects non-discord webhook URLs (SSRF guard matches Slack pattern). - platform/internal/channels/discord_test.go: 20 unit tests covering Type/DisplayName, ValidateConfig (valid + 5 invalid cases), SendMessage error paths, ParseWebhook (PING / slash command / DM user / unknown type / invalid JSON), StartPolling, GetAdapter registry lookup, ListAdapters inclusion, and splitMessage edge cases. - platform/internal/channels/registry.go: register "discord" adapter. - .env.example: document DISCORD_WEBHOOK_URL. Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 1 + platform/internal/channels/discord.go | 213 +++++++++++++++ platform/internal/channels/discord_test.go | 304 +++++++++++++++++++++ platform/internal/channels/registry.go | 1 + 4 files changed, 519 insertions(+) create mode 100644 platform/internal/channels/discord.go create mode 100644 platform/internal/channels/discord_test.go diff --git a/.env.example b/.env.example index 3a8b39c9..05d7dde6 100644 --- a/.env.example +++ b/.env.example @@ -87,6 +87,7 @@ TIER4_CPU_SHARES=4096 # Full-host tier CPU (default 4096 = 4 CPU; previ # Social Channels (optional — configure per-workspace via API or Canvas) TELEGRAM_BOT_TOKEN= # Telegram Bot API token (talk to @BotFather). Used as default for new Telegram channels. +DISCORD_WEBHOOK_URL= # Discord Incoming Webhook URL (Server → Channel → Integrations → Webhooks). Used by Community Manager workspace. # Langfuse (optional observability) LANGFUSE_HOST=http://langfuse-web:3000 diff --git a/platform/internal/channels/discord.go b/platform/internal/channels/discord.go new file mode 100644 index 00000000..b7807724 --- /dev/null +++ b/platform/internal/channels/discord.go @@ -0,0 +1,213 @@ +package channels + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" +) + +const ( + discordWebhookPrefix = "https://discord.com/api/webhooks/" + discordHTTPTimeout = 10 * time.Second +) + +// DiscordAdapter implements ChannelAdapter for Discord. +// +// Outbound messages are sent via Discord Incoming Webhooks. The webhook URL +// (https://discord.com/api/webhooks/{id}/{token}) is the only required config +// field — it encodes the channel and bot-token so no separate bot setup is +// needed for outbound-only use. +// +// Inbound messages are received via Discord's Interactions endpoint (slash +// commands and message components). Discord POSTs a signed JSON payload to the +// configured Interactions URL; ParseWebhook extracts the text and returns a +// standardized InboundMessage. Signature verification must be performed at +// the router layer before calling ParseWebhook. +// +// StartPolling returns nil immediately — Discord does not support long-polling; +// use the Interactions webhook route instead. +type DiscordAdapter struct{} + +func (d *DiscordAdapter) Type() string { return "discord" } +func (d *DiscordAdapter) DisplayName() string { return "Discord" } + +// ValidateConfig checks that the channel config contains a valid Discord +// Incoming Webhook URL. Returns a human-readable error for the Canvas UI. +func (d *DiscordAdapter) 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, discordWebhookPrefix) { + return fmt.Errorf("invalid Discord webhook URL (must start with %s)", discordWebhookPrefix) + } + return nil +} + +// SendMessage posts a text message to the configured Discord webhook. +// chatID is ignored — the destination channel is encoded in the webhook URL. +// Messages longer than 2000 characters are split into 2000-char chunks because +// Discord enforces a hard 2000-character limit per message. +func (d *DiscordAdapter) SendMessage(ctx context.Context, config map[string]interface{}, _ string, text string) error { + webhookURL, _ := config["webhook_url"].(string) + if webhookURL == "" { + return fmt.Errorf("discord: webhook_url not configured") + } + if !strings.HasPrefix(webhookURL, discordWebhookPrefix) { + return fmt.Errorf("discord: invalid webhook URL") + } + + const maxLen = 2000 + + // Split long messages into chunks at word boundaries where possible. + chunks := splitMessage(text, maxLen) + + client := &http.Client{Timeout: discordHTTPTimeout} + for _, chunk := range chunks { + payload, err := json.Marshal(map[string]string{"content": chunk}) + if err != nil { + return fmt.Errorf("discord: marshal payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, bytes.NewReader(payload)) + if err != nil { + return fmt.Errorf("discord: create request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("discord: send: %w", err) + } + body, _ := io.ReadAll(resp.Body) + resp.Body.Close() + + // Discord returns 204 No Content on success. + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + return fmt.Errorf("discord: webhook returned %d: %s", resp.StatusCode, strings.TrimSpace(string(body))) + } + } + return nil +} + +// ParseWebhook handles a Discord Interactions POST. +// Discord sends two types of payloads: type 1 (PING) and type 2 (APPLICATION_COMMAND / slash command). +// Returns nil, nil for PING payloads — the handler layer must respond with `{"type":1}` to pass +// Discord's endpoint verification. Returns an InboundMessage for APPLICATION_COMMAND payloads. +func (d *DiscordAdapter) ParseWebhook(c *gin.Context, _ map[string]interface{}) (*InboundMessage, error) { + body, err := io.ReadAll(c.Request.Body) + if err != nil { + return nil, fmt.Errorf("discord: read body: %w", err) + } + + var payload struct { + Type int `json:"type"` // 1=PING, 2=APPLICATION_COMMAND, 3=MESSAGE_COMPONENT + ID string `json:"id"` + Data struct { + Name string `json:"name"` // slash command name + Options []struct { + Name string `json:"name"` + Value interface{} `json:"value"` + } `json:"options"` + } `json:"data"` + Member struct { + User struct { + ID string `json:"id"` + Username string `json:"username"` + } `json:"user"` + } `json:"member"` + User struct { + ID string `json:"id"` + Username string `json:"username"` + } `json:"user"` + ChannelID string `json:"channel_id"` + Token string `json:"token"` + } + + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("discord: parse interaction: %w", err) + } + + // Type 1: PING from Discord during endpoint verification — let the handler layer respond. + if payload.Type == 1 { + return nil, nil + } + + // Type 2 or 3: extract text from slash command name + options. + if payload.Type != 2 && payload.Type != 3 { + return nil, nil + } + + // Reconstruct the invocation as text: "/command option1 option2" + var parts []string + if payload.Data.Name != "" { + parts = append(parts, "/"+payload.Data.Name) + } + for _, opt := range payload.Data.Options { + parts = append(parts, fmt.Sprintf("%v", opt.Value)) + } + text := strings.TrimSpace(strings.Join(parts, " ")) + if text == "" { + return nil, nil + } + + // Prefer member.user (in guilds) over user (in DMs). + userID := payload.Member.User.ID + username := payload.Member.User.Username + if userID == "" { + userID = payload.User.ID + username = payload.User.Username + } + + return &InboundMessage{ + ChatID: payload.ChannelID, + UserID: userID, + Username: username, + Text: text, + MessageID: payload.ID, + Metadata: map[string]string{ + "platform": "discord", + "interaction_token": payload.Token, + }, + }, nil +} + +// StartPolling returns nil immediately. Discord uses the Interactions endpoint +// (webhook-based) rather than long-polling for inbound messages. +func (d *DiscordAdapter) StartPolling(_ context.Context, _ map[string]interface{}, _ MessageHandler) error { + return nil +} + +// splitMessage splits text into chunks of at most maxLen characters. +// It tries to break at the last newline or space within the window to avoid +// cutting words in the middle, but hard-splits if no boundary is found. +func splitMessage(text string, maxLen int) []string { + if len(text) <= maxLen { + return []string{text} + } + var chunks []string + for len(text) > 0 { + if len(text) <= maxLen { + chunks = append(chunks, text) + break + } + cut := maxLen + // Walk back from cut looking for a newline or space. + for i := cut - 1; i > maxLen/2; i-- { + if text[i] == '\n' || text[i] == ' ' { + cut = i + 1 + break + } + } + chunks = append(chunks, text[:cut]) + text = text[cut:] + } + return chunks +} diff --git a/platform/internal/channels/discord_test.go b/platform/internal/channels/discord_test.go new file mode 100644 index 00000000..cd184d17 --- /dev/null +++ b/platform/internal/channels/discord_test.go @@ -0,0 +1,304 @@ +package channels + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +// ==================== DiscordAdapter unit tests ==================== + +func TestDiscordAdapter_Type(t *testing.T) { + a := &DiscordAdapter{} + if a.Type() != "discord" { + t.Errorf("expected 'discord', got %q", a.Type()) + } +} + +func TestDiscordAdapter_DisplayName(t *testing.T) { + a := &DiscordAdapter{} + if a.DisplayName() != "Discord" { + t.Errorf("expected 'Discord', got %q", a.DisplayName()) + } +} + +func TestDiscordAdapter_ValidateConfig_Valid(t *testing.T) { + a := &DiscordAdapter{} + err := a.ValidateConfig(map[string]interface{}{ + "webhook_url": "https://discord.com/api/webhooks/1234567890/abcdefghijk", + }) + if err != nil { + t.Errorf("expected no error for valid webhook URL, got %v", err) + } +} + +func TestDiscordAdapter_ValidateConfig_MissingWebhookURL(t *testing.T) { + a := &DiscordAdapter{} + err := a.ValidateConfig(map[string]interface{}{}) + if err == nil { + t.Error("expected error for missing webhook_url") + } +} + +func TestDiscordAdapter_ValidateConfig_EmptyWebhookURL(t *testing.T) { + a := &DiscordAdapter{} + err := a.ValidateConfig(map[string]interface{}{"webhook_url": ""}) + if err == nil { + t.Error("expected error for empty webhook_url") + } +} + +func TestDiscordAdapter_ValidateConfig_InvalidPrefix(t *testing.T) { + a := &DiscordAdapter{} + cases := []string{ + "http://discord.com/api/webhooks/1/abc", // wrong scheme + "https://evil.example.com/discord-hook", // wrong host + "https://discord.com.evil.com/api/webhooks/1/abc", // 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) + } + } +} + +func TestDiscordAdapter_SendMessage_EmptyWebhookURL(t *testing.T) { + a := &DiscordAdapter{} + err := a.SendMessage(context.Background(), map[string]interface{}{}, "ignored-chat", "hello") + if err == nil { + t.Error("expected error for missing webhook_url") + } +} + +func TestDiscordAdapter_SendMessage_InvalidPrefix(t *testing.T) { + a := &DiscordAdapter{} + 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 TestDiscordAdapter_ParseWebhook_Ping(t *testing.T) { + a := &DiscordAdapter{} + body := `{"type":1,"id":"ping-id"}` + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) + + msg, err := a.ParseWebhook(c, nil) + if err != nil { + t.Errorf("expected no error for PING, got %v", err) + } + if msg != nil { + t.Errorf("expected nil message for PING (type 1), got %+v", msg) + } +} + +func TestDiscordAdapter_ParseWebhook_SlashCommand(t *testing.T) { + a := &DiscordAdapter{} + payload := map[string]interface{}{ + "type": 2, + "id": "interaction-id", + "channel_id": "chan-123", + "token": "interaction-token", + "member": map[string]interface{}{ + "user": map[string]interface{}{ + "id": "user-456", + "username": "testuser", + }, + }, + "data": map[string]interface{}{ + "name": "ask", + "options": []interface{}{ + map[string]interface{}{"name": "query", "value": "what is the status?"}, + }, + }, + } + bodyBytes, _ := json.Marshal(payload) + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(string(bodyBytes))) + + msg, err := a.ParseWebhook(c, nil) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if msg == nil { + t.Fatal("expected non-nil message for slash command") + } + if msg.UserID != "user-456" { + t.Errorf("expected UserID 'user-456', got %q", msg.UserID) + } + if msg.Username != "testuser" { + t.Errorf("expected Username 'testuser', got %q", msg.Username) + } + if msg.ChatID != "chan-123" { + t.Errorf("expected ChatID 'chan-123', got %q", msg.ChatID) + } + if !strings.Contains(msg.Text, "/ask") { + t.Errorf("expected text to contain '/ask', got %q", msg.Text) + } + if !strings.Contains(msg.Text, "what is the status?") { + t.Errorf("expected text to contain option value, got %q", msg.Text) + } + if msg.Metadata["platform"] != "discord" { + t.Errorf("expected platform metadata 'discord', got %q", msg.Metadata["platform"]) + } +} + +func TestDiscordAdapter_ParseWebhook_SlashCommand_DMUser(t *testing.T) { + // In DMs, "user" field is set instead of "member.user". + a := &DiscordAdapter{} + payload := map[string]interface{}{ + "type": 2, + "id": "dm-interaction-id", + "channel_id": "dm-chan", + "token": "dm-token", + "user": map[string]interface{}{ + "id": "dm-user-789", + "username": "dmuser", + }, + "data": map[string]interface{}{ + "name": "help", + "options": []interface{}{}, + }, + } + bodyBytes, _ := json.Marshal(payload) + + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(string(bodyBytes))) + + msg, err := a.ParseWebhook(c, nil) + if err != nil { + t.Errorf("expected no error, got %v", err) + } + if msg == nil { + t.Fatal("expected non-nil message for DM slash command") + } + if msg.UserID != "dm-user-789" { + t.Errorf("expected UserID 'dm-user-789', got %q", msg.UserID) + } + if msg.Username != "dmuser" { + t.Errorf("expected Username 'dmuser', got %q", msg.Username) + } +} + +func TestDiscordAdapter_ParseWebhook_UnknownType(t *testing.T) { + a := &DiscordAdapter{} + body := `{"type":99}` + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader(body)) + + msg, err := a.ParseWebhook(c, nil) + if err != nil { + t.Errorf("expected no error for unknown type, got %v", err) + } + if msg != nil { + t.Errorf("expected nil message for unknown type, got %+v", msg) + } +} + +func TestDiscordAdapter_ParseWebhook_InvalidJSON(t *testing.T) { + a := &DiscordAdapter{} + c, _ := gin.CreateTestContext(httptest.NewRecorder()) + c.Request = httptest.NewRequest(http.MethodPost, "/webhook", strings.NewReader("{bad json")) + + _, err := a.ParseWebhook(c, nil) + if err == nil { + t.Error("expected error for invalid JSON") + } +} + +func TestDiscordAdapter_StartPolling_ReturnsNil(t *testing.T) { + a := &DiscordAdapter{} + err := a.StartPolling(context.Background(), map[string]interface{}{}, nil) + if err != nil { + t.Errorf("expected nil from StartPolling, got %v", err) + } +} + +func TestGetAdapter_Discord(t *testing.T) { + a, ok := GetAdapter("discord") + if !ok || a == nil { + t.Error("expected discord adapter to be registered") + } + if a.Type() != "discord" { + t.Errorf("expected type 'discord', got %q", a.Type()) + } +} + +func TestListAdapters_IncludesDiscord(t *testing.T) { + list := ListAdapters() + found := false + for _, a := range list { + if a["type"] == "discord" { + found = true + if a["display_name"] != "Discord" { + t.Errorf("expected display_name 'Discord', got %q", a["display_name"]) + } + } + } + if !found { + t.Error("discord not found in ListAdapters") + } +} + +// ==================== splitMessage helper tests ==================== + +func TestSplitMessage_Short(t *testing.T) { + chunks := splitMessage("hello world", 2000) + if len(chunks) != 1 { + t.Errorf("expected 1 chunk for short message, got %d", len(chunks)) + } + if chunks[0] != "hello world" { + t.Errorf("expected 'hello world', got %q", chunks[0]) + } +} + +func TestSplitMessage_ExactlyMaxLen(t *testing.T) { + text := strings.Repeat("a", 2000) + chunks := splitMessage(text, 2000) + if len(chunks) != 1 { + t.Errorf("expected 1 chunk, got %d", len(chunks)) + } +} + +func TestSplitMessage_LongMessage(t *testing.T) { + // Build a 4100-character message — should split into at least 2 chunks. + text := strings.Repeat("x", 4100) + chunks := splitMessage(text, 2000) + if len(chunks) < 2 { + t.Errorf("expected at least 2 chunks for 4100-char message, got %d", len(chunks)) + } + // Reassembled content must equal original. + reassembled := strings.Join(chunks, "") + if reassembled != text { + t.Error("reassembled chunks do not match original text") + } +} + +func TestSplitMessage_SplitsAtNewline(t *testing.T) { + // Build a message where a newline falls within the split window. + line1 := strings.Repeat("a", 1500) + "\n" + line2 := strings.Repeat("b", 1500) + text := line1 + line2 + chunks := splitMessage(text, 2000) + if len(chunks) < 2 { + t.Errorf("expected at least 2 chunks, got %d", len(chunks)) + } + // Reassembled content must equal original. + reassembled := strings.Join(chunks, "") + if reassembled != text { + t.Error("reassembled chunks do not match original text") + } +} diff --git a/platform/internal/channels/registry.go b/platform/internal/channels/registry.go index f36fb985..11d29cc6 100644 --- a/platform/internal/channels/registry.go +++ b/platform/internal/channels/registry.go @@ -6,6 +6,7 @@ var adapters = map[string]ChannelAdapter{ "telegram": &TelegramAdapter{}, "slack": &SlackAdapter{}, "lark": &LarkAdapter{}, + "discord": &DiscordAdapter{}, } // GetAdapter returns the adapter for a channel type.