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"
|
robotevents "github.com/yaoapp/yao/agent/robot/events"
|
||||||
"github.com/yaoapp/yao/agent/robot/events/integrations"
|
"github.com/yaoapp/yao/agent/robot/events/integrations"
|
||||||
dtadapter "github.com/yaoapp/yao/agent/robot/events/integrations/dingtalk"
|
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"
|
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/events/integrations/telegram"
|
||||||
"github.com/yaoapp/yao/agent/robot/logger"
|
"github.com/yaoapp/yao/agent/robot/logger"
|
||||||
|
|
@ -63,6 +64,7 @@ func Start() error {
|
||||||
"telegram": telegram.NewAdapter(),
|
"telegram": telegram.NewAdapter(),
|
||||||
"feishu": fsadapter.NewAdapter(),
|
"feishu": fsadapter.NewAdapter(),
|
||||||
"dingtalk": dtadapter.NewAdapter(),
|
"dingtalk": dtadapter.NewAdapter(),
|
||||||
|
"discord": dcadapter.NewAdapter(),
|
||||||
}
|
}
|
||||||
globalDispatcher = integrations.NewDispatcher(globalManager.Cache(), adapters)
|
globalDispatcher = integrations.NewDispatcher(globalManager.Cache(), adapters)
|
||||||
if err := globalDispatcher.Start(context.Background()); err != nil {
|
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 {
|
if intg.DingTalk != nil {
|
||||||
keys = append(keys, "dingtalk")
|
keys = append(keys, "dingtalk")
|
||||||
}
|
}
|
||||||
|
if intg.Discord != nil {
|
||||||
|
keys = append(keys, "discord")
|
||||||
|
}
|
||||||
return keys
|
return keys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ type Integrations struct {
|
||||||
Telegram *TelegramConfig `json:"telegram,omitempty"`
|
Telegram *TelegramConfig `json:"telegram,omitempty"`
|
||||||
Feishu *FeishuConfig `json:"feishu,omitempty"`
|
Feishu *FeishuConfig `json:"feishu,omitempty"`
|
||||||
DingTalk *DingTalkConfig `json:"dingtalk,omitempty"`
|
DingTalk *DingTalkConfig `json:"dingtalk,omitempty"`
|
||||||
|
Discord *DiscordConfig `json:"discord,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TelegramConfig holds Telegram Bot integration settings.
|
// TelegramConfig holds Telegram Bot integration settings.
|
||||||
|
|
@ -53,6 +54,13 @@ type DingTalkConfig struct {
|
||||||
ClientSecret string `json:"client_secret"`
|
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
|
// ExecutorConfig - executor settings
|
||||||
type ExecutorConfig struct {
|
type ExecutorConfig struct {
|
||||||
Mode ExecutorMode `json:"mode,omitempty"` // standard | dryrun | sandbox
|
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/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/blang/semver/v4 v4.0.0 // indirect
|
github.com/blang/semver/v4 v4.0.0 // indirect
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // 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 v1.13.2 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // 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/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 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
|
||||||
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
|
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 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||||
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
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=
|
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/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-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||||
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/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.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 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
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-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-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-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.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.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
|
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