Lark adapter was already implemented in Go (lark.go — outbound Custom Bot webhook + inbound Event Subscriptions with constant-time token verify), but the Canvas connect-form hardcoded a Telegram-shaped pair of inputs (bot_token + chat_id). Selecting "Lark / Feishu" from the dropdown silently sent the wrong field names — there was no way to enter a webhook URL. Fix: move form shape to the server. - Add `ConfigField` struct + `ConfigSchema()` method to the `ChannelAdapter` interface. Each adapter declares its own fields with label/type/required/sensitive/placeholder/help. - Implement per-adapter schemas: - Lark: webhook_url (required+sensitive) + verify_token (optional+sensitive) - Slack: bot_token/channel_id/webhook_url/username/icon_emoji - Discord: webhook_url + optional public_key - Telegram: bot_token + chat_id (unchanged UX, keeps Detect Chats) - Change `ListAdapters()` to return `[]AdapterInfo` with config_schema inline. Sorted deterministically by display name so UI ordering is stable across Go's random map iteration. - Update the 3 existing `ListAdapters` test sites to struct access. Canvas (`ChannelsTab.tsx`): - Replace the two hardcoded bot_token/chat_id inputs with a single schema-driven `SchemaField` component. Renders one input per field in the order the adapter returns them. - Form state becomes `formValues: Record<string,string>` keyed by `ConfigField.key`. Values reset on platform-switch so stale Telegram credentials can't leak into a new Lark channel. - "Detect Chats" stays but only renders for platforms in `SUPPORTS_DETECT_CHATS` (Telegram only — the only provider with getUpdates). - Only schema-known keys are posted in `config`, scrubbing any stale values from previous platform selections. Regression tests: - `TestLark_ConfigSchema` locks in the 2-field Lark contract with the required/sensitive flags correctly set. - `TestListAdapters_IncludesLark` confirms registry wiring + schema survives round-trip through ListAdapters. Known pre-existing `TestStripPluginMarkers_AwkScript` failure in internal/handlers is unrelated to this change (verified via stash+test on clean staging). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
248 lines
8.2 KiB
Go
248 lines
8.2 KiB
Go
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
|
|
}
|