Add Discord integration support in robot lifecycle

- Introduce Discord adapter in the robot lifecycle to enable integration with Discord events.
- Update the integration dispatcher to recognize and handle events from Discord.
- Modify the configuration structure to include settings for Discord integration.
- Enhance the integration parsing logic to support Discord configurations.
This commit is contained in:
Max 2026-03-02 07:53:49 +08:00
parent 33efd3e890
commit 5333302055
20 changed files with 1448 additions and 0 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

1
go.mod
View file

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

4
go.sum
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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[:])
}

View file

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

View file

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