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 <noreply@anthropic.com>
This commit is contained in:
parent
c9b8c26d5f
commit
afd9c3b5bb
@ -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
|
||||
|
||||
213
platform/internal/channels/discord.go
Normal file
213
platform/internal/channels/discord.go
Normal 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
|
||||
}
|
||||
304
platform/internal/channels/discord_test.go
Normal file
304
platform/internal/channels/discord_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@ -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.
|
||||
|
||||
Loading…
Reference in New Issue
Block a user