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:
parent
33efd3e890
commit
5333302055
20 changed files with 1448 additions and 0 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
44
agent/robot/events/integrations/discord/dedup.go
Normal file
44
agent/robot/events/integrations/discord/dedup.go
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
151
agent/robot/events/integrations/discord/discord.go
Normal file
151
agent/robot/events/integrations/discord/discord.go
Normal 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
|
||||
}
|
||||
218
agent/robot/events/integrations/discord/e2e_test.go
Normal file
218
agent/robot/events/integrations/discord/e2e_test.go
Normal 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)
|
||||
}
|
||||
84
agent/robot/events/integrations/discord/gateway.go
Normal file
84
agent/robot/events/integrations/discord/gateway.go
Normal 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})
|
||||
}
|
||||
130
agent/robot/events/integrations/discord/message.go
Normal file
130
agent/robot/events/integrations/discord/message.go
Normal 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
|
||||
}
|
||||
163
agent/robot/events/integrations/discord/reply.go
Normal file
163
agent/robot/events/integrations/discord/reply.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
44
integrations/discord/bot.go
Normal file
44
integrations/discord/bot.go
Normal 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")
|
||||
}
|
||||
21
integrations/discord/bot_test.go
Normal file
21
integrations/discord/bot_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
108
integrations/discord/convert.go
Normal file
108
integrations/discord/convert.go
Normal 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
|
||||
}
|
||||
}
|
||||
173
integrations/discord/convert_test.go
Normal file
173
integrations/discord/convert_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
33
integrations/discord/discord_test.go
Normal file
33
integrations/discord/discord_test.go
Normal 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
|
||||
}
|
||||
27
integrations/discord/e2e_test.go
Normal file
27
integrations/discord/e2e_test.go
Normal 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)
|
||||
}
|
||||
122
integrations/discord/file.go
Normal file
122
integrations/discord/file.go
Normal 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[:])
|
||||
}
|
||||
78
integrations/discord/message.go
Normal file
78
integrations/discord/message.go
Normal 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
|
||||
}
|
||||
34
integrations/discord/message_test.go
Normal file
34
integrations/discord/message_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue