diff --git a/agent/robot/api/lifecycle.go b/agent/robot/api/lifecycle.go index 0ca15c4f..62343f44 100644 --- a/agent/robot/api/lifecycle.go +++ b/agent/robot/api/lifecycle.go @@ -8,6 +8,7 @@ import ( robotevents "github.com/yaoapp/yao/agent/robot/events" "github.com/yaoapp/yao/agent/robot/events/integrations" dtadapter "github.com/yaoapp/yao/agent/robot/events/integrations/dingtalk" + dcadapter "github.com/yaoapp/yao/agent/robot/events/integrations/discord" fsadapter "github.com/yaoapp/yao/agent/robot/events/integrations/feishu" "github.com/yaoapp/yao/agent/robot/events/integrations/telegram" "github.com/yaoapp/yao/agent/robot/logger" @@ -63,6 +64,7 @@ func Start() error { "telegram": telegram.NewAdapter(), "feishu": fsadapter.NewAdapter(), "dingtalk": dtadapter.NewAdapter(), + "discord": dcadapter.NewAdapter(), } globalDispatcher = integrations.NewDispatcher(globalManager.Cache(), adapters) if err := globalDispatcher.Start(context.Background()); err != nil { diff --git a/agent/robot/events/integrations/discord/dedup.go b/agent/robot/events/integrations/discord/dedup.go new file mode 100644 index 00000000..7895c726 --- /dev/null +++ b/agent/robot/events/integrations/discord/dedup.go @@ -0,0 +1,44 @@ +package discord + +import ( + "sync" + "time" +) + +const ( + dedupTTL = 24 * time.Hour + dedupCleanInterval = time.Hour +) + +type dedupStore struct { + m sync.Map +} + +func newDedupStore() *dedupStore { + return &dedupStore{} +} + +func (d *dedupStore) markSeen(key string) bool { + now := time.Now().Unix() + _, loaded := d.m.LoadOrStore(key, now) + return !loaded +} + +func (d *dedupStore) cleaner(stopCh <-chan struct{}) { + ticker := time.NewTicker(dedupCleanInterval) + defer ticker.Stop() + for { + select { + case <-stopCh: + return + case <-ticker.C: + cutoff := time.Now().Add(-dedupTTL).Unix() + d.m.Range(func(key, value any) bool { + if ts, ok := value.(int64); ok && ts < cutoff { + d.m.Delete(key) + } + return true + }) + } + } +} diff --git a/agent/robot/events/integrations/discord/discord.go b/agent/robot/events/integrations/discord/discord.go new file mode 100644 index 00000000..0d1eaaec --- /dev/null +++ b/agent/robot/events/integrations/discord/discord.go @@ -0,0 +1,151 @@ +package discord + +import ( + "context" + "sync" + + "github.com/yaoapp/yao/agent/robot/logger" + robottypes "github.com/yaoapp/yao/agent/robot/types" + dcapi "github.com/yaoapp/yao/integrations/discord" +) + +var log = logger.New("discord") + +// Adapter implements the integrations.Adapter interface for Discord. +// +// Architecture: +// - One WebSocket Gateway connection per registered bot via discordgo +// - One dedup cleaner goroutine removes expired keys every hour +type Adapter struct { + mu sync.RWMutex + bots map[string]*botEntry // robotID -> *botEntry + appIdx map[string]string // appID -> robotID + dedup *dedupStore + stopCh chan struct{} +} + +// botEntry holds the state for one robot's Discord integration. +type botEntry struct { + robotID string + appID string + bot *dcapi.Bot + cancelFn context.CancelFunc +} + +// NewAdapter creates a new Discord adapter. +func NewAdapter() *Adapter { + a := &Adapter{ + bots: make(map[string]*botEntry), + appIdx: make(map[string]string), + dedup: newDedupStore(), + stopCh: make(chan struct{}), + } + go a.dedup.cleaner(a.stopCh) + return a +} + +// Apply is called by the Dispatcher when a robot config is created or updated. +func (a *Adapter) Apply(ctx context.Context, robot *robottypes.Robot) { + dcConf := extractConfig(robot) + log.Debug("Apply robot=%s dcConf=%v", robot.MemberID, dcConf != nil) + + if dcConf == nil || !dcConf.Enabled || dcConf.BotToken == "" { + a.removeBot(robot.MemberID) + return + } + + a.mu.Lock() + defer a.mu.Unlock() + + if existing, ok := a.bots[robot.MemberID]; ok { + if existing.bot.Token() == dcConf.BotToken { + return + } + a.removeBotLocked(robot.MemberID) + } + + bot, err := dcapi.NewBot(dcConf.BotToken, dcConf.AppID) + if err != nil { + log.Error("discord adapter: create bot failed robot=%s: %v", robot.MemberID, err) + return + } + + gwCtx, gwCancel := context.WithCancel(context.Background()) + entry := &botEntry{ + robotID: robot.MemberID, + appID: dcConf.AppID, + bot: bot, + cancelFn: gwCancel, + } + a.bots[robot.MemberID] = entry + if dcConf.AppID != "" { + a.appIdx[dcConf.AppID] = robot.MemberID + } + + go a.gatewayLoop(gwCtx, entry) + + log.Info("discord adapter: registered robot=%s app=%s", robot.MemberID, dcConf.AppID) +} + +// Remove is called by the Dispatcher when a robot is deleted. +func (a *Adapter) Remove(ctx context.Context, robotID string) { + a.removeBot(robotID) +} + +// Shutdown stops all gateway connections and dedup cleaner. +func (a *Adapter) Shutdown() { + close(a.stopCh) + a.mu.Lock() + for _, entry := range a.bots { + if entry.cancelFn != nil { + entry.cancelFn() + } + if entry.bot != nil && entry.bot.Session() != nil { + entry.bot.Session().Close() + } + } + a.mu.Unlock() + log.Info("discord adapter: shutdown complete") +} + +func (a *Adapter) removeBot(robotID string) { + a.mu.Lock() + defer a.mu.Unlock() + a.removeBotLocked(robotID) +} + +func (a *Adapter) removeBotLocked(robotID string) { + entry, ok := a.bots[robotID] + if !ok { + return + } + if entry.cancelFn != nil { + entry.cancelFn() + } + if entry.bot != nil && entry.bot.Session() != nil { + entry.bot.Session().Close() + } + if entry.appID != "" { + delete(a.appIdx, entry.appID) + } + delete(a.bots, robotID) + log.Info("discord adapter: unregistered robot=%s", robotID) +} + +func (a *Adapter) resolveByAppID(appID string) (*botEntry, bool) { + a.mu.RLock() + defer a.mu.RUnlock() + robotID, ok := a.appIdx[appID] + if !ok { + return nil, false + } + entry, ok := a.bots[robotID] + return entry, ok +} + +func extractConfig(robot *robottypes.Robot) *robottypes.DiscordConfig { + if robot.Config == nil || robot.Config.Integrations == nil { + return nil + } + return robot.Config.Integrations.Discord +} diff --git a/agent/robot/events/integrations/discord/e2e_test.go b/agent/robot/events/integrations/discord/e2e_test.go new file mode 100644 index 00000000..5ed320a2 --- /dev/null +++ b/agent/robot/events/integrations/discord/e2e_test.go @@ -0,0 +1,218 @@ +package discord + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + robottypes "github.com/yaoapp/yao/agent/robot/types" + dcapi "github.com/yaoapp/yao/integrations/discord" +) + +var ( + dcBotToken string + dcAppID string +) + +func TestMain(m *testing.M) { + dcBotToken = os.Getenv("DISCORD_TEST_BOT_TOKEN") + dcAppID = os.Getenv("DISCORD_TEST_APP_ID") + os.Exit(m.Run()) +} + +func skipIfNoToken(t *testing.T) { + t.Helper() + if dcBotToken == "" { + t.Skip("DISCORD_TEST_BOT_TOKEN not set") + } +} + +// TestE2E_Adapter_Apply verifies that Apply correctly registers a bot. +func TestE2E_Adapter_Apply(t *testing.T) { + skipIfNoToken(t) + + a := &Adapter{ + bots: make(map[string]*botEntry), + appIdx: make(map[string]string), + dedup: newDedupStore(), + stopCh: make(chan struct{}), + } + defer close(a.stopCh) + + robot := &robottypes.Robot{ + MemberID: "robot_e2e_dc_adapter", + TeamID: "team_e2e_dc", + Config: &robottypes.Config{ + Integrations: &robottypes.Integrations{ + Discord: &robottypes.DiscordConfig{ + Enabled: true, + BotToken: dcBotToken, + AppID: dcAppID, + }, + }, + }, + } + + a.Apply(context.Background(), robot) + + a.mu.RLock() + entry, ok := a.bots["robot_e2e_dc_adapter"] + a.mu.RUnlock() + + require.True(t, ok, "bot should be registered") + assert.Equal(t, dcBotToken, entry.bot.Token()) + assert.Equal(t, dcAppID, entry.appID) + + t.Logf("OK Apply: discord bot registered robot=%s app=%s", robot.MemberID, entry.appID) +} + +// TestE2E_Adapter_Apply_Update verifies re-Apply with same token is a no-op. +func TestE2E_Adapter_Apply_Update(t *testing.T) { + skipIfNoToken(t) + + a := &Adapter{ + bots: make(map[string]*botEntry), + appIdx: make(map[string]string), + dedup: newDedupStore(), + stopCh: make(chan struct{}), + } + defer close(a.stopCh) + + robot := &robottypes.Robot{ + MemberID: "robot_e2e_dc_update", + TeamID: "team_e2e_dc", + Config: &robottypes.Config{ + Integrations: &robottypes.Integrations{ + Discord: &robottypes.DiscordConfig{ + Enabled: true, + BotToken: dcBotToken, + AppID: dcAppID, + }, + }, + }, + } + + a.Apply(context.Background(), robot) + a.mu.RLock() + _, ok := a.bots["robot_e2e_dc_update"] + a.mu.RUnlock() + require.True(t, ok) + + a.Apply(context.Background(), robot) + a.mu.RLock() + assert.Len(t, a.bots, 1) + a.mu.RUnlock() + + a.Remove(context.Background(), "robot_e2e_dc_update") + a.mu.RLock() + _, ok = a.bots["robot_e2e_dc_update"] + a.mu.RUnlock() + assert.False(t, ok, "bot should be removed") + t.Log("OK Apply/Remove lifecycle verified") +} + +// TestE2E_Adapter_Dedup verifies deduplication works. +func TestE2E_Adapter_Dedup(t *testing.T) { + a := &Adapter{ + bots: make(map[string]*botEntry), + appIdx: make(map[string]string), + dedup: newDedupStore(), + stopCh: make(chan struct{}), + } + defer close(a.stopCh) + + key := "dc:test-robot:msg-12345" + assert.True(t, a.dedup.markSeen(key), "first time should return true") + assert.False(t, a.dedup.markSeen(key), "second time should return false (dedup)") + t.Log("OK dedup working correctly") +} + +// TestE2E_Adapter_HandleMessages verifies message handling. +func TestE2E_Adapter_HandleMessages(t *testing.T) { + skipIfNoToken(t) + + bot, err := dcapi.NewBot(dcBotToken, dcAppID) + require.NoError(t, err) + + a := &Adapter{ + bots: make(map[string]*botEntry), + appIdx: make(map[string]string), + dedup: newDedupStore(), + stopCh: make(chan struct{}), + } + defer close(a.stopCh) + + entry := &botEntry{ + robotID: "robot_e2e_dc_handle", + appID: dcAppID, + bot: bot, + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + cms := []*dcapi.ConvertedMessage{ + { + MessageID: "test_msg_1", + ChannelID: "test_ch_1", + AuthorID: "test_user_1", + AuthorName: "TestUser", + Text: "Hello from E2E test", + }, + } + + a.handleMessages(ctx, entry, cms) + + assert.False(t, a.dedup.markSeen("dc:robot_e2e_dc_handle:test_msg_1"), + "message should be marked as seen after handleMessages") + t.Log("OK handleMessages processed 1 message") +} + +// TestE2E_Adapter_ApplyDisabled verifies Apply removes bot when disabled. +func TestE2E_Adapter_ApplyDisabled(t *testing.T) { + a := &Adapter{ + bots: make(map[string]*botEntry), + appIdx: make(map[string]string), + dedup: newDedupStore(), + stopCh: make(chan struct{}), + } + defer close(a.stopCh) + + robot := &robottypes.Robot{ + MemberID: "robot_e2e_dc_disabled", + TeamID: "team_e2e_dc", + Config: &robottypes.Config{ + Integrations: &robottypes.Integrations{ + Discord: &robottypes.DiscordConfig{ + Enabled: false, + BotToken: "some_token", + }, + }, + }, + } + + a.Apply(context.Background(), robot) + a.mu.RLock() + _, ok := a.bots["robot_e2e_dc_disabled"] + a.mu.RUnlock() + assert.False(t, ok, "disabled bot should not be registered") + t.Log("OK disabled config not registered") +} + +// TestE2E_BotUser verifies real Discord credentials. +func TestE2E_BotUser(t *testing.T) { + skipIfNoToken(t) + + bot, err := dcapi.NewBot(dcBotToken, dcAppID) + require.NoError(t, err) + + user, err := bot.BotUser() + require.NoError(t, err) + assert.NotEmpty(t, user.ID) + assert.NotEmpty(t, user.Username) + assert.True(t, user.Bot) + t.Logf("OK Discord bot verified: id=%s username=%s", user.ID, user.Username) +} diff --git a/agent/robot/events/integrations/discord/gateway.go b/agent/robot/events/integrations/discord/gateway.go new file mode 100644 index 00000000..ada33a8a --- /dev/null +++ b/agent/robot/events/integrations/discord/gateway.go @@ -0,0 +1,84 @@ +package discord + +import ( + "context" + "time" + + "github.com/bwmarrin/discordgo" + dcapi "github.com/yaoapp/yao/integrations/discord" +) + +const reconnectDelay = 5 * time.Second + +// gatewayLoop starts the Discord WebSocket Gateway for a single bot. +// It automatically reconnects on failure. +func (a *Adapter) gatewayLoop(ctx context.Context, entry *botEntry) { + log.Info("discord gatewayLoop started robot=%s app=%s", entry.robotID, entry.appID) + + for { + select { + case <-ctx.Done(): + log.Info("discord gatewayLoop stopped robot=%s", entry.robotID) + return + case <-a.stopCh: + return + default: + } + + err := a.runGateway(ctx, entry) + if err != nil { + log.Error("discord gateway disconnected robot=%s: %v, reconnecting in %s", entry.robotID, err, reconnectDelay) + } + + select { + case <-ctx.Done(): + return + case <-a.stopCh: + return + case <-time.After(reconnectDelay): + } + } +} + +func (a *Adapter) runGateway(ctx context.Context, entry *botEntry) error { + session := entry.bot.Session() + + session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { + a.onMessageCreate(ctx, entry, m) + }) + + if err := session.Open(); err != nil { + return err + } + + // Block until context is cancelled or stop signal + select { + case <-ctx.Done(): + case <-a.stopCh: + } + + return session.Close() +} + +func (a *Adapter) onMessageCreate(ctx context.Context, entry *botEntry, m *discordgo.MessageCreate) { + if m == nil || m.Author == nil { + return + } + + // Ignore bot's own messages + if m.Author.Bot { + return + } + + cm := dcapi.ConvertMessageCreate(m) + if cm == nil { + return + } + + if cm.HasMedia() { + groups := []string{"discord", entry.robotID} + dcapi.ResolveMedia(ctx, cm, groups) + } + + a.handleMessages(ctx, entry, []*dcapi.ConvertedMessage{cm}) +} diff --git a/agent/robot/events/integrations/discord/message.go b/agent/robot/events/integrations/discord/message.go new file mode 100644 index 00000000..4218d541 --- /dev/null +++ b/agent/robot/events/integrations/discord/message.go @@ -0,0 +1,130 @@ +package discord + +import ( + "context" + "fmt" + "strings" + + agentcontext "github.com/yaoapp/yao/agent/context" + events "github.com/yaoapp/yao/agent/robot/events" + "github.com/yaoapp/yao/event" + dcapi "github.com/yaoapp/yao/integrations/discord" +) + +// handleMessages processes a batch of Discord messages. +func (a *Adapter) handleMessages(ctx context.Context, entry *botEntry, cms []*dcapi.ConvertedMessage) { + if len(cms) == 0 { + return + } + + var allParts []interface{} + var lastCM *dcapi.ConvertedMessage + + for _, cm := range cms { + if cm == nil { + continue + } + + // Skip bot commands (messages starting with /) + if strings.HasPrefix(strings.TrimSpace(cm.Text), "/") && !cm.HasMedia() { + continue + } + + dedupKey := fmt.Sprintf("dc:%s:%s", entry.robotID, cm.MessageID) + if !a.dedup.markSeen(dedupKey) { + continue + } + + parts := buildContentParts(cm) + if len(parts) == 0 { + continue + } + + allParts = append(allParts, parts...) + lastCM = cm + } + + if len(allParts) == 0 || lastCM == nil { + return + } + + content := mergeContentParts(allParts) + + msgPayload := events.MessagePayload{ + RobotID: entry.robotID, + Messages: []agentcontext.Message{ + {Role: agentcontext.RoleUser, Content: content}, + }, + Metadata: &events.MessageMetadata{ + Channel: "discord", + MessageID: lastCM.MessageID, + AppID: entry.appID, + ChatID: lastCM.ChannelID, + SenderID: lastCM.AuthorID, + SenderName: lastCM.AuthorName, + Extra: map[string]any{ + "discord_message_id": lastCM.MessageID, + "guild_id": lastCM.GuildID, + "is_dm": lastCM.IsDM, + }, + }, + } + + if _, err := event.Push(ctx, events.Message, msgPayload); err != nil { + log.Error("discord adapter: event.Push robot.message failed robot=%s: %v", entry.robotID, err) + } +} + +func buildContentParts(cm *dcapi.ConvertedMessage) []interface{} { + var parts []interface{} + + if cm.HasText() { + parts = append(parts, map[string]interface{}{ + "type": "text", + "text": cm.Text, + }) + } + + for _, mi := range cm.MediaItems { + url := mi.Wrapper + if url == "" { + url = mi.URL + } + if url == "" { + continue + } + parts = append(parts, map[string]interface{}{ + "type": "file", + "file_url": url, + "mime_type": mi.ContentType, + "file_name": mi.FileName, + }) + } + + return parts +} + +func mergeContentParts(parts []interface{}) interface{} { + allText := true + for _, p := range parts { + m, ok := p.(map[string]interface{}) + if !ok || m["type"] != "text" { + allText = false + break + } + } + + if allText { + var buf strings.Builder + for i, p := range parts { + if i > 0 { + buf.WriteString("\n") + } + m := p.(map[string]interface{}) + buf.WriteString(m["text"].(string)) + } + return buf.String() + } + + return parts +} diff --git a/agent/robot/events/integrations/discord/reply.go b/agent/robot/events/integrations/discord/reply.go new file mode 100644 index 00000000..048e2470 --- /dev/null +++ b/agent/robot/events/integrations/discord/reply.go @@ -0,0 +1,163 @@ +package discord + +import ( + "context" + "fmt" + "strings" + + agentcontext "github.com/yaoapp/yao/agent/context" + events "github.com/yaoapp/yao/agent/robot/events" +) + +// Reply sends the assistant message back to the originating Discord channel. +func (a *Adapter) Reply(ctx context.Context, msg *agentcontext.Message, metadata *events.MessageMetadata) error { + if msg == nil || metadata == nil { + return fmt.Errorf("nil message or metadata") + } + + entry := a.resolveByChat(metadata) + if entry == nil { + return fmt.Errorf("no bot registered for discord metadata (appID=%s)", metadata.AppID) + } + + var replyToID string + if metadata.Extra != nil { + if v, ok := metadata.Extra["discord_message_id"]; ok { + if s, ok := v.(string); ok { + replyToID = s + } + } + } + + return a.sendContent(ctx, entry, metadata.ChatID, replyToID, msg.Content) +} + +func (a *Adapter) sendContent(ctx context.Context, entry *botEntry, channelID, replyToID string, content interface{}) error { + switch c := content.(type) { + case string: + if strings.TrimSpace(c) == "" { + return nil + } + if replyToID != "" { + _, err := entry.bot.SendMessageReply(channelID, c, replyToID) + return err + } + _, err := entry.bot.SendMessage(channelID, c) + return err + + case []interface{}: + return a.sendParts(ctx, entry, channelID, replyToID, c) + + default: + parts, ok := toContentParts(content) + if ok { + return a.sendPartsTyped(ctx, entry, channelID, replyToID, parts) + } + _, err := entry.bot.SendMessage(channelID, fmt.Sprintf("%v", content)) + return err + } +} + +func (a *Adapter) sendParts(ctx context.Context, entry *botEntry, channelID, replyToID string, parts []interface{}) error { + var textBuf strings.Builder + for _, part := range parts { + m, ok := part.(map[string]interface{}) + if !ok { + continue + } + partType, _ := m["type"].(string) + switch partType { + case "text": + if text, ok := m["text"].(string); ok { + textBuf.WriteString(text) + } + case "image_url": + if err := a.flushText(entry, channelID, replyToID, &textBuf); err != nil { + return err + } + if imgMap, ok := m["image_url"].(map[string]interface{}); ok { + if url, ok := imgMap["url"].(string); ok { + if err := sendFileOrWrapper(entry, channelID, url, ""); err != nil { + log.Error("discord reply: send image: %v", err) + } + } + } + case "file": + if err := a.flushText(entry, channelID, replyToID, &textBuf); err != nil { + return err + } + fileURL, _ := m["file_url"].(string) + if fileURL == "" { + if fileMap, ok := m["file"].(map[string]interface{}); ok { + fileURL, _ = fileMap["url"].(string) + } + } + if fileURL != "" { + if err := sendFileOrWrapper(entry, channelID, fileURL, ""); err != nil { + log.Error("discord reply: send file: %v", err) + } + } + } + } + return a.flushText(entry, channelID, replyToID, &textBuf) +} + +func (a *Adapter) sendPartsTyped(ctx context.Context, entry *botEntry, channelID, replyToID string, parts []agentcontext.ContentPart) error { + var textBuf strings.Builder + for _, part := range parts { + switch part.Type { + case agentcontext.ContentText: + textBuf.WriteString(part.Text) + case agentcontext.ContentImageURL, agentcontext.ContentFile: + if err := a.flushText(entry, channelID, replyToID, &textBuf); err != nil { + return err + } + } + } + return a.flushText(entry, channelID, replyToID, &textBuf) +} + +func (a *Adapter) flushText(entry *botEntry, channelID, replyToID string, buf *strings.Builder) error { + if buf.Len() == 0 { + return nil + } + text := buf.String() + buf.Reset() + + if replyToID != "" { + _, err := entry.bot.SendMessageReply(channelID, text, replyToID) + return err + } + _, err := entry.bot.SendMessage(channelID, text) + return err +} + +func sendFileOrWrapper(entry *botEntry, channelID, url, caption string) error { + if strings.Contains(url, "://") && !strings.HasPrefix(url, "http") { + return entry.bot.SendMediaFromWrapper(channelID, url, caption) + } + if strings.HasPrefix(url, "http") { + _, err := entry.bot.SendMessage(channelID, url) + return err + } + return fmt.Errorf("unsupported file URL scheme: %s", url) +} + +func toContentParts(content interface{}) ([]agentcontext.ContentPart, bool) { + parts, ok := content.([]agentcontext.ContentPart) + return parts, ok +} + +func (a *Adapter) resolveByChat(metadata *events.MessageMetadata) *botEntry { + if metadata.AppID != "" { + if entry, ok := a.resolveByAppID(metadata.AppID); ok { + return entry + } + } + a.mu.RLock() + defer a.mu.RUnlock() + for _, entry := range a.bots { + return entry + } + return nil +} diff --git a/agent/robot/events/integrations/dispatcher.go b/agent/robot/events/integrations/dispatcher.go index a78f72f4..a277b024 100644 --- a/agent/robot/events/integrations/dispatcher.go +++ b/agent/robot/events/integrations/dispatcher.go @@ -184,6 +184,9 @@ func parseIntegrations(intg *robottypes.Integrations) []string { if intg.DingTalk != nil { keys = append(keys, "dingtalk") } + if intg.Discord != nil { + keys = append(keys, "discord") + } return keys } diff --git a/agent/robot/types/config.go b/agent/robot/types/config.go index d09a07ef..9ad5b52f 100644 --- a/agent/robot/types/config.go +++ b/agent/robot/types/config.go @@ -27,6 +27,7 @@ type Integrations struct { Telegram *TelegramConfig `json:"telegram,omitempty"` Feishu *FeishuConfig `json:"feishu,omitempty"` DingTalk *DingTalkConfig `json:"dingtalk,omitempty"` + Discord *DiscordConfig `json:"discord,omitempty"` } // TelegramConfig holds Telegram Bot integration settings. @@ -53,6 +54,13 @@ type DingTalkConfig struct { ClientSecret string `json:"client_secret"` } +// DiscordConfig holds Discord Bot integration settings. +type DiscordConfig struct { + Enabled bool `json:"enabled"` + BotToken string `json:"bot_token"` + AppID string `json:"app_id,omitempty"` +} + // ExecutorConfig - executor settings type ExecutorConfig struct { Mode ExecutorMode `json:"mode,omitempty"` // standard | dryrun | sandbox diff --git a/go.mod b/go.mod index 5d71bd0c..10d504ff 100644 --- a/go.mod +++ b/go.mod @@ -72,6 +72,7 @@ require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/blang/semver/v4 v4.0.0 // indirect github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect + github.com/bwmarrin/discordgo v0.29.0 // indirect github.com/bytedance/sonic v1.13.2 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect diff --git a/go.sum b/go.sum index 3bc8e6cf..95ef8c9d 100644 --- a/go.sum +++ b/go.sum @@ -99,6 +99,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA= github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8= +github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= +github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= @@ -268,6 +270,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= @@ -581,6 +584,7 @@ golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= diff --git a/integrations/discord/bot.go b/integrations/discord/bot.go new file mode 100644 index 00000000..c97b6f6e --- /dev/null +++ b/integrations/discord/bot.go @@ -0,0 +1,44 @@ +package discord + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +// Bot represents a single Discord bot instance bound to a token. +type Bot struct { + token string + appID string + session *discordgo.Session +} + +// NewBot creates a Bot bound to the given Discord bot token. +func NewBot(token, appID string) (*Bot, error) { + session, err := discordgo.New("Bot " + token) + if err != nil { + return nil, fmt.Errorf("create discord session: %w", err) + } + session.Identify.Intents = discordgo.IntentsGuildMessages | + discordgo.IntentsDirectMessages | + discordgo.IntentMessageContent + return &Bot{ + token: token, + appID: appID, + session: session, + }, nil +} + +// Token returns the raw bot token. +func (b *Bot) Token() string { return b.token } + +// AppID returns the application ID. +func (b *Bot) AppID() string { return b.appID } + +// Session returns the underlying discordgo session. +func (b *Bot) Session() *discordgo.Session { return b.session } + +// BotUser returns the bot's own user information (verifies token). +func (b *Bot) BotUser() (*discordgo.User, error) { + return b.session.User("@me") +} diff --git a/integrations/discord/bot_test.go b/integrations/discord/bot_test.go new file mode 100644 index 00000000..1f9915f1 --- /dev/null +++ b/integrations/discord/bot_test.go @@ -0,0 +1,21 @@ +package discord + +import ( + "testing" +) + +func TestNewBot(t *testing.T) { + bot, err := NewBot("test-token", "test-app-id") + if err != nil { + t.Fatalf("NewBot: %v", err) + } + if bot.Token() != "test-token" { + t.Fatalf("expected token test-token, got %s", bot.Token()) + } + if bot.AppID() != "test-app-id" { + t.Fatalf("expected appID test-app-id, got %s", bot.AppID()) + } + if bot.Session() == nil { + t.Fatal("Session() should not be nil") + } +} diff --git a/integrations/discord/convert.go b/integrations/discord/convert.go new file mode 100644 index 00000000..b406413e --- /dev/null +++ b/integrations/discord/convert.go @@ -0,0 +1,108 @@ +package discord + +import ( + "github.com/bwmarrin/discordgo" +) + +// ConvertedMessage is the unified output after parsing a Discord message event. +type ConvertedMessage struct { + MessageID string `json:"message_id"` + ChannelID string `json:"channel_id"` + GuildID string `json:"guild_id,omitempty"` + AuthorID string `json:"author_id"` + AuthorName string `json:"author_name,omitempty"` + IsBot bool `json:"is_bot"` + Text string `json:"text,omitempty"` + MediaItems []MediaItem `json:"media,omitempty"` + ReplyTo string `json:"reply_to,omitempty"` + IsDM bool `json:"is_dm"` +} + +// MediaItem describes a single attachment from a Discord message. +type MediaItem struct { + Type MediaType `json:"type"` + URL string `json:"url"` + ProxyURL string `json:"proxy_url,omitempty"` + FileName string `json:"file_name"` + ContentType string `json:"content_type,omitempty"` + Size int `json:"size,omitempty"` + Wrapper string `json:"wrapper,omitempty"` +} + +// MediaType indicates the attachment type. +type MediaType string + +const ( + MediaImage MediaType = "image" + MediaVideo MediaType = "video" + MediaAudio MediaType = "audio" + MediaDocument MediaType = "document" +) + +// HasMedia returns true if the message contains media. +func (cm *ConvertedMessage) HasMedia() bool { return len(cm.MediaItems) > 0 } + +// HasText returns true if the message contains text. +func (cm *ConvertedMessage) HasText() bool { return cm.Text != "" } + +// ConvertMessageCreate transforms a discordgo MessageCreate event into a ConvertedMessage. +func ConvertMessageCreate(m *discordgo.MessageCreate) *ConvertedMessage { + if m == nil || m.Message == nil { + return nil + } + return ConvertMessage(m.Message) +} + +// ConvertMessage transforms a discordgo Message into a ConvertedMessage. +func ConvertMessage(m *discordgo.Message) *ConvertedMessage { + if m == nil { + return nil + } + + cm := &ConvertedMessage{ + MessageID: m.ID, + ChannelID: m.ChannelID, + GuildID: m.GuildID, + Text: m.Content, + IsDM: m.GuildID == "", + } + + if m.Author != nil { + cm.AuthorID = m.Author.ID + cm.AuthorName = m.Author.Username + cm.IsBot = m.Author.Bot + } + + if m.MessageReference != nil { + cm.ReplyTo = m.MessageReference.MessageID + } + + for _, att := range m.Attachments { + cm.MediaItems = append(cm.MediaItems, MediaItem{ + Type: detectMediaType(att.ContentType), + URL: att.URL, + ProxyURL: att.ProxyURL, + FileName: att.Filename, + ContentType: att.ContentType, + Size: att.Size, + }) + } + + return cm +} + +func detectMediaType(contentType string) MediaType { + if contentType == "" { + return MediaDocument + } + switch { + case len(contentType) > 6 && contentType[:6] == "image/": + return MediaImage + case len(contentType) > 6 && contentType[:6] == "video/": + return MediaVideo + case len(contentType) > 6 && contentType[:6] == "audio/": + return MediaAudio + default: + return MediaDocument + } +} diff --git a/integrations/discord/convert_test.go b/integrations/discord/convert_test.go new file mode 100644 index 00000000..83791965 --- /dev/null +++ b/integrations/discord/convert_test.go @@ -0,0 +1,173 @@ +package discord + +import ( + "testing" + + "github.com/bwmarrin/discordgo" +) + +func TestConvertMessage_Text(t *testing.T) { + m := &discordgo.Message{ + ID: "msg_001", + ChannelID: "ch_001", + GuildID: "guild_001", + Content: "Hello World", + Author: &discordgo.User{ + ID: "user_001", + Username: "TestUser", + Bot: false, + }, + } + cm := ConvertMessage(m) + if cm == nil { + t.Fatal("expected non-nil ConvertedMessage") + } + if cm.MessageID != "msg_001" { + t.Errorf("expected msg_001, got %s", cm.MessageID) + } + if cm.Text != "Hello World" { + t.Errorf("expected 'Hello World', got %q", cm.Text) + } + if cm.AuthorID != "user_001" { + t.Errorf("expected user_001, got %s", cm.AuthorID) + } + if cm.AuthorName != "TestUser" { + t.Errorf("expected TestUser, got %s", cm.AuthorName) + } + if cm.IsBot { + t.Error("expected IsBot=false") + } + if cm.IsDM { + t.Error("expected IsDM=false for guild message") + } + if !cm.HasText() { + t.Error("expected HasText=true") + } + if cm.HasMedia() { + t.Error("expected HasMedia=false") + } +} + +func TestConvertMessage_DM(t *testing.T) { + m := &discordgo.Message{ + ID: "msg_002", + ChannelID: "ch_dm", + Content: "DM message", + Author: &discordgo.User{ + ID: "user_002", + Username: "DMUser", + }, + } + cm := ConvertMessage(m) + if cm == nil { + t.Fatal("expected non-nil") + } + if !cm.IsDM { + t.Error("expected IsDM=true for message without GuildID") + } +} + +func TestConvertMessage_WithAttachments(t *testing.T) { + m := &discordgo.Message{ + ID: "msg_003", + ChannelID: "ch_003", + Content: "Check this out", + Author: &discordgo.User{ + ID: "user_003", + Username: "FileUser", + }, + Attachments: []*discordgo.MessageAttachment{ + { + ID: "att_001", + URL: "https://cdn.discordapp.com/attachments/test.png", + ProxyURL: "https://media.discordapp.net/attachments/test.png", + Filename: "test.png", + ContentType: "image/png", + Size: 1024, + }, + { + ID: "att_002", + URL: "https://cdn.discordapp.com/attachments/report.pdf", + Filename: "report.pdf", + ContentType: "application/pdf", + Size: 2048, + }, + }, + } + cm := ConvertMessage(m) + if cm == nil { + t.Fatal("expected non-nil") + } + if !cm.HasText() { + t.Error("expected HasText=true") + } + if !cm.HasMedia() { + t.Error("expected HasMedia=true") + } + if len(cm.MediaItems) != 2 { + t.Fatalf("expected 2 media items, got %d", len(cm.MediaItems)) + } + if cm.MediaItems[0].Type != MediaImage { + t.Errorf("expected image type, got %s", cm.MediaItems[0].Type) + } + if cm.MediaItems[0].FileName != "test.png" { + t.Errorf("expected test.png, got %s", cm.MediaItems[0].FileName) + } + if cm.MediaItems[1].Type != MediaDocument { + t.Errorf("expected document type, got %s", cm.MediaItems[1].Type) + } +} + +func TestConvertMessage_WithReply(t *testing.T) { + m := &discordgo.Message{ + ID: "msg_004", + ChannelID: "ch_004", + Content: "Replying", + Author: &discordgo.User{ID: "user_004"}, + MessageReference: &discordgo.MessageReference{ + MessageID: "msg_original", + ChannelID: "ch_004", + }, + } + cm := ConvertMessage(m) + if cm == nil { + t.Fatal("expected non-nil") + } + if cm.ReplyTo != "msg_original" { + t.Errorf("expected ReplyTo=msg_original, got %s", cm.ReplyTo) + } +} + +func TestConvertMessage_Nil(t *testing.T) { + cm := ConvertMessage(nil) + if cm != nil { + t.Error("nil input should return nil") + } +} + +func TestConvertMessageCreate_Nil(t *testing.T) { + cm := ConvertMessageCreate(nil) + if cm != nil { + t.Error("nil input should return nil") + } +} + +func TestDetectMediaType(t *testing.T) { + cases := []struct { + input string + expected MediaType + }{ + {"image/png", MediaImage}, + {"image/jpeg", MediaImage}, + {"video/mp4", MediaVideo}, + {"audio/mpeg", MediaAudio}, + {"application/pdf", MediaDocument}, + {"", MediaDocument}, + } + for _, tc := range cases { + got := detectMediaType(tc.input) + if got != tc.expected { + t.Errorf("detectMediaType(%q) = %q, want %q", tc.input, got, tc.expected) + } + } +} diff --git a/integrations/discord/discord_test.go b/integrations/discord/discord_test.go new file mode 100644 index 00000000..ab54c1c8 --- /dev/null +++ b/integrations/discord/discord_test.go @@ -0,0 +1,33 @@ +package discord + +import ( + "os" + "testing" +) + +var ( + testBotToken string + testAppID string +) + +func TestMain(m *testing.M) { + testBotToken = os.Getenv("DISCORD_TEST_BOT_TOKEN") + testAppID = os.Getenv("DISCORD_TEST_APP_ID") + os.Exit(m.Run()) +} + +func skipIfNoToken(t *testing.T) { + t.Helper() + if testBotToken == "" { + t.Skip("DISCORD_TEST_BOT_TOKEN not set") + } +} + +func testBot(t *testing.T) *Bot { + t.Helper() + bot, err := NewBot(testBotToken, testAppID) + if err != nil { + t.Fatalf("NewBot: %v", err) + } + return bot +} diff --git a/integrations/discord/e2e_test.go b/integrations/discord/e2e_test.go new file mode 100644 index 00000000..b63aa249 --- /dev/null +++ b/integrations/discord/e2e_test.go @@ -0,0 +1,27 @@ +package discord + +import ( + "testing" +) + +// TestE2E_01_BotUser verifies the Discord bot token by fetching bot user info. +func TestE2E_01_BotUser(t *testing.T) { + skipIfNoToken(t) + bot := testBot(t) + + user, err := bot.BotUser() + if err != nil { + t.Fatalf("BotUser: %v", err) + } + if user.ID == "" { + t.Error("user.ID should not be empty") + } + if user.Username == "" { + t.Error("user.Username should not be empty") + } + if !user.Bot { + t.Error("user.Bot should be true") + } + t.Logf("OK id=%s username=%s discriminator=%s bot=%v", + user.ID, user.Username, user.Discriminator, user.Bot) +} diff --git a/integrations/discord/file.go b/integrations/discord/file.go new file mode 100644 index 00000000..ca04e0d0 --- /dev/null +++ b/integrations/discord/file.go @@ -0,0 +1,122 @@ +package discord + +import ( + "bytes" + "context" + "crypto/md5" + "encoding/hex" + "fmt" + "io" + "mime/multipart" + "net/http" + "net/textproto" + "strings" + + "github.com/yaoapp/kun/log" + "github.com/yaoapp/yao/attachment" +) + +const defaultUploader = "__yao.attachment" + +// FileResult holds attachment wrapper and metadata. +type FileResult struct { + Wrapper string + MimeType string + FileName string +} + +// DownloadAndStoreURL downloads a file from URL and stores it through +// the attachment manager. Uses the URL as fingerprint for dedup. +func DownloadAndStoreURL(ctx context.Context, url, contentType, fileName string, groups []string) (*FileResult, error) { + manager, exists := attachment.Managers[defaultUploader] + if !exists { + return nil, fmt.Errorf("attachment manager %s not found", defaultUploader) + } + + probeID := fingerprintKey(url, groups) + if manager.Exists(ctx, probeID) { + wrapper := fmt.Sprintf("%s://%s", defaultUploader, probeID) + return &FileResult{Wrapper: wrapper, MimeType: contentType, FileName: fileName}, nil + } + + req, err := http.NewRequestWithContext(ctx, "GET", url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("download: %w", err) + } + defer resp.Body.Close() + + data, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read body: %w", err) + } + + if contentType == "" { + contentType = resp.Header.Get("Content-Type") + if contentType == "" { + contentType = "application/octet-stream" + } + } + if fileName == "" { + fileName = "file" + } + + header := &attachment.FileHeader{ + FileHeader: &multipart.FileHeader{ + Filename: fileName, + Size: int64(len(data)), + Header: make(textproto.MIMEHeader), + }, + } + header.Header.Set("Content-Type", contentType) + header.Header.Set("Content-Fingerprint", url) + + option := attachment.UploadOption{ + OriginalFilename: fileName, + Groups: groups, + } + + uploaded, err := manager.Upload(ctx, header, bytes.NewReader(data), option) + if err != nil { + return nil, fmt.Errorf("attachment upload: %w", err) + } + + wrapper := fmt.Sprintf("%s://%s", defaultUploader, uploaded.ID) + return &FileResult{Wrapper: wrapper, MimeType: contentType, FileName: fileName}, nil +} + +// ResolveMedia downloads and stores all media items in a ConvertedMessage. +func ResolveMedia(ctx context.Context, cm *ConvertedMessage, groups []string) { + if cm == nil { + return + } + for i := range cm.MediaItems { + mi := &cm.MediaItems[i] + if mi.URL == "" { + continue + } + result, err := DownloadAndStoreURL(ctx, mi.URL, mi.ContentType, mi.FileName, groups) + if err != nil { + log.Error("discord ResolveMedia: %s %s: %v", mi.Type, mi.URL, err) + continue + } + mi.Wrapper = result.Wrapper + if result.MimeType != "" { + mi.ContentType = result.MimeType + } + } +} + +func fingerprintKey(key string, groups []string) string { + parts := make([]string, 0, len(groups)+1) + parts = append(parts, groups...) + parts = append(parts, key) + storagePath := strings.Join(parts, "/") + hash := md5.Sum([]byte(storagePath)) + return hex.EncodeToString(hash[:]) +} diff --git a/integrations/discord/message.go b/integrations/discord/message.go new file mode 100644 index 00000000..1db1f49f --- /dev/null +++ b/integrations/discord/message.go @@ -0,0 +1,78 @@ +package discord + +import ( + "fmt" + "io" + + "github.com/bwmarrin/discordgo" + "github.com/yaoapp/yao/attachment" +) + +// SendMessage sends a text message to a channel. +func (b *Bot) SendMessage(channelID, text string) (*discordgo.Message, error) { + return b.session.ChannelMessageSend(channelID, text) +} + +// SendMessageReply sends a text message as a reply to another message. +func (b *Bot) SendMessageReply(channelID, text, replyToID string) (*discordgo.Message, error) { + return b.session.ChannelMessageSendReply(channelID, text, &discordgo.MessageReference{ + MessageID: replyToID, + ChannelID: channelID, + }) +} + +// SendComplex sends a complex message with embeds, files, etc. +func (b *Bot) SendComplex(channelID string, data *discordgo.MessageSend) (*discordgo.Message, error) { + return b.session.ChannelMessageSendComplex(channelID, data) +} + +// SendFile sends a file to a channel. +func (b *Bot) SendFile(channelID, filename string, reader io.Reader) (*discordgo.Message, error) { + return b.session.ChannelFileSend(channelID, filename, reader) +} + +// SendFileWithMessage sends a file with an accompanying text message. +func (b *Bot) SendFileWithMessage(channelID, text, filename string, reader io.Reader) (*discordgo.Message, error) { + return b.session.ChannelFileSendWithMessage(channelID, text, filename, reader) +} + +// SendMediaFromWrapper sends a media file from a Yao attachment wrapper. +func (b *Bot) SendMediaFromWrapper(channelID, wrapper, caption string) error { + managerName, fileID, err := parseWrapper(wrapper) + if err != nil { + return err + } + + manager, exists := attachment.Managers[managerName] + if !exists { + return fmt.Errorf("attachment manager %s not found", managerName) + } + + resp, err := manager.Download(nil, fileID) + if err != nil { + return fmt.Errorf("attachment download %s: %w", fileID, err) + } + defer resp.Reader.Close() + + filename := fileID + resp.Extension + if caption != "" { + _, err = b.SendFileWithMessage(channelID, caption, filename, resp.Reader) + } else { + _, err = b.SendFile(channelID, filename, resp.Reader) + } + return err +} + +func parseWrapper(wrapper string) (managerName string, fileID string, err error) { + idx := 0 + for i := range wrapper { + if wrapper[i] == ':' && i+2 < len(wrapper) && wrapper[i+1] == '/' && wrapper[i+2] == '/' { + idx = i + break + } + } + if idx == 0 { + return "", "", fmt.Errorf("invalid attachment wrapper: %s", wrapper) + } + return wrapper[:idx], wrapper[idx+3:], nil +} diff --git a/integrations/discord/message_test.go b/integrations/discord/message_test.go new file mode 100644 index 00000000..9e853f81 --- /dev/null +++ b/integrations/discord/message_test.go @@ -0,0 +1,34 @@ +package discord + +import ( + "testing" +) + +func TestParseWrapper(t *testing.T) { + cases := []struct { + input string + manager string + fileID string + wantErr bool + }{ + {"__yao.attachment://abc123", "__yao.attachment", "abc123", false}, + {"__custom.uploader://xyz", "__custom.uploader", "xyz", false}, + {"no-separator", "", "", true}, + } + for _, tc := range cases { + manager, fileID, err := parseWrapper(tc.input) + if tc.wantErr { + if err == nil { + t.Errorf("parseWrapper(%q) expected error, got nil", tc.input) + } + continue + } + if err != nil { + t.Errorf("parseWrapper(%q) unexpected error: %v", tc.input, err) + continue + } + if manager != tc.manager || fileID != tc.fileID { + t.Errorf("parseWrapper(%q) = (%q, %q), want (%q, %q)", tc.input, manager, fileID, tc.manager, tc.fileID) + } + } +}