Merge pull request #656 from Molecule-AI/feat/issue-625-discord-adapter-clean

feat(channels): add Discord adapter (#625)
This commit is contained in:
molecule-ai[bot] 2026-04-17 07:30:39 +00:00 committed by GitHub
commit a2a26c6cce
4 changed files with 519 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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