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" } // ConfigSchema — Discord only needs a webhook URL for outbound. // public_key is the Ed25519 pubkey used to verify inbound Interactions // signatures (stored hex-encoded); not required if you only do outbound. func (d *DiscordAdapter) ConfigSchema() []ConfigField { return []ConfigField{ { Key: "webhook_url", Label: "Webhook URL", Type: "password", Required: true, Sensitive: true, Placeholder: "https://discord.com/api/webhooks/{id}/{token}", Help: "From Server Settings → Integrations → Webhooks → Copy URL.", }, { Key: "public_key", Label: "Interactions Public Key (hex)", Type: "password", Required: false, Sensitive: true, Placeholder: "optional — for inbound slash commands", Help: "Ed25519 public key from the Discord Developer Portal → General Information. Only needed to receive slash commands.", }, } } // 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 { // Do NOT wrap err — the *url.Error from http.Client.Do includes the // full request URL, which contains the Discord webhook token // (https://discord.com/api/webhooks/{id}/{token}). Wrapping with %w // would propagate that token into logs and error responses (#659). return fmt.Errorf("discord: HTTP request failed") } body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) _ = 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) { // Cap incoming webhook bodies at 1 MiB. Discord's Interactions API // payloads are well under 10 KiB in practice; the cap is a DoS // guard, not a functional limit. const maxDiscordWebhook = 1 << 20 body, err := io.ReadAll(io.LimitReader(c.Request.Body, maxDiscordWebhook)) 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 }