2026-02-20 15:25:44 +00:00
|
|
|
package discord
|
2026-02-04 11:06:13 +00:00
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"fmt"
|
2026-04-01 04:21:21 +00:00
|
|
|
"io"
|
2026-02-27 06:35:23 +00:00
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
2026-02-10 09:10:41 +00:00
|
|
|
"os"
|
2026-03-04 01:14:18 +00:00
|
|
|
"regexp"
|
2026-02-20 11:18:37 +00:00
|
|
|
"strings"
|
2026-02-19 12:28:58 +00:00
|
|
|
"sync"
|
2026-02-10 09:10:41 +00:00
|
|
|
"time"
|
2026-02-04 11:06:13 +00:00
|
|
|
|
|
|
|
|
"github.com/bwmarrin/discordgo"
|
2026-02-27 06:35:23 +00:00
|
|
|
"github.com/gorilla/websocket"
|
2026-02-19 20:05:15 +00:00
|
|
|
|
2026-04-01 04:21:21 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/audio"
|
|
|
|
|
"github.com/sipeed/picoclaw/pkg/audio/tts"
|
2026-02-04 11:06:13 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/bus"
|
2026-02-20 15:25:44 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/channels"
|
2026-02-04 11:06:13 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/config"
|
2026-02-22 22:56:48 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/identity"
|
2026-02-04 11:06:13 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/logger"
|
2026-02-22 15:27:55 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/media"
|
2026-02-11 16:46:48 +00:00
|
|
|
"github.com/sipeed/picoclaw/pkg/utils"
|
2026-02-04 11:06:13 +00:00
|
|
|
)
|
|
|
|
|
|
2026-02-12 04:46:28 +00:00
|
|
|
const (
|
2026-02-22 19:47:12 +00:00
|
|
|
sendTimeout = 10 * time.Second
|
2026-02-12 04:46:28 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-04 01:30:52 +00:00
|
|
|
var (
|
|
|
|
|
// Pre-compiled regexes for resolveDiscordRefs (avoid re-compiling per call)
|
|
|
|
|
channelRefRe = regexp.MustCompile(`<#(\d+)>`)
|
|
|
|
|
msgLinkRe = regexp.MustCompile(`https://(?:discord\.com|discordapp\.com)/channels/(\d+)/(\d+)/(\d+)`)
|
|
|
|
|
)
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
type DiscordChannel struct {
|
2026-02-20 15:25:44 +00:00
|
|
|
*channels.BaseChannel
|
2026-04-11 16:57:26 +00:00
|
|
|
bc *config.Channel
|
2026-02-22 19:47:12 +00:00
|
|
|
session *discordgo.Session
|
2026-04-11 16:57:26 +00:00
|
|
|
config *config.DiscordSettings
|
2026-02-22 19:47:12 +00:00
|
|
|
ctx context.Context
|
|
|
|
|
cancel context.CancelFunc
|
|
|
|
|
typingMu sync.Mutex
|
|
|
|
|
typingStop map[string]chan struct{} // chatID → stop signal
|
2026-04-23 02:35:50 +00:00
|
|
|
progress *channels.ToolFeedbackAnimator
|
|
|
|
|
botUserID string // stored for mention checking
|
2026-04-01 04:21:21 +00:00
|
|
|
bus *bus.MessageBus
|
|
|
|
|
tts tts.TTSProvider
|
2026-04-23 02:35:50 +00:00
|
|
|
playTTSFn func(context.Context, *discordgo.VoiceConnection, string, uint64)
|
|
|
|
|
ttsVoiceFn func(string) (*discordgo.VoiceConnection, bool)
|
2026-04-01 04:21:21 +00:00
|
|
|
voiceMu sync.RWMutex
|
|
|
|
|
voiceSSRC map[string]map[uint32]string // guildID -> ssrc -> userID
|
|
|
|
|
|
|
|
|
|
// TTS interruption: cancel active playback when user speaks
|
|
|
|
|
ttsMu sync.Mutex
|
|
|
|
|
cancelTTS context.CancelFunc
|
|
|
|
|
ttsPlayID uint64
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
func NewDiscordChannel(
|
|
|
|
|
bc *config.Channel,
|
|
|
|
|
cfg *config.DiscordSettings,
|
|
|
|
|
bus *bus.MessageBus,
|
|
|
|
|
) (*DiscordChannel, error) {
|
2026-03-11 08:33:01 +00:00
|
|
|
discordgo.Logger = logger.NewLogger("discord").
|
|
|
|
|
WithLevels(map[int]logger.LogLevel{
|
|
|
|
|
discordgo.LogError: logger.ERROR,
|
|
|
|
|
discordgo.LogWarning: logger.WARN,
|
|
|
|
|
discordgo.LogInformational: logger.INFO,
|
|
|
|
|
discordgo.LogDebug: logger.DEBUG,
|
|
|
|
|
}).Log
|
|
|
|
|
|
2026-03-27 16:03:34 +00:00
|
|
|
session, err := discordgo.New("Bot " + cfg.Token.String())
|
2026-02-04 11:06:13 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, fmt.Errorf("failed to create discord session: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-27 06:35:23 +00:00
|
|
|
if err := applyDiscordProxy(session, cfg.Proxy); err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-04-11 16:57:26 +00:00
|
|
|
base := channels.NewBaseChannel("discord", cfg, bus, bc.AllowFrom,
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
channels.WithMaxMessageLength(2000),
|
2026-04-11 16:57:26 +00:00
|
|
|
channels.WithGroupTrigger(bc.GroupTrigger),
|
|
|
|
|
channels.WithReasoningChannelID(bc.ReasoningChannelID),
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
)
|
2026-02-04 11:06:13 +00:00
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
ch := &DiscordChannel{
|
2026-02-04 11:06:13 +00:00
|
|
|
BaseChannel: base,
|
2026-04-11 16:57:26 +00:00
|
|
|
bc: bc,
|
2026-02-04 11:06:13 +00:00
|
|
|
session: session,
|
|
|
|
|
config: cfg,
|
2026-02-12 04:46:28 +00:00
|
|
|
ctx: context.Background(),
|
2026-02-19 12:28:58 +00:00
|
|
|
typingStop: make(map[string]chan struct{}),
|
2026-04-01 04:21:21 +00:00
|
|
|
bus: bus,
|
|
|
|
|
voiceSSRC: make(map[string]map[uint32]string),
|
2026-04-23 02:35:50 +00:00
|
|
|
}
|
|
|
|
|
ch.playTTSFn = ch.playTTS
|
|
|
|
|
ch.ttsVoiceFn = ch.voiceConnectionForTTS
|
|
|
|
|
ch.progress = channels.NewToolFeedbackAnimator(ch.EditMessage)
|
|
|
|
|
return ch, nil
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) Start(ctx context.Context) error {
|
|
|
|
|
logger.InfoC("discord", "Starting Discord bot")
|
|
|
|
|
|
2026-02-22 14:25:07 +00:00
|
|
|
c.ctx, c.cancel = context.WithCancel(ctx)
|
2026-02-20 11:18:37 +00:00
|
|
|
|
|
|
|
|
// Get bot user ID before opening session to avoid race condition
|
|
|
|
|
botUser, err := c.session.User("@me")
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("failed to get bot user: %w", err)
|
|
|
|
|
}
|
|
|
|
|
c.botUserID = botUser.ID
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
c.session.AddHandler(c.handleMessage)
|
|
|
|
|
|
2026-04-01 04:21:21 +00:00
|
|
|
go c.listenVoiceControl(c.ctx)
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
if err := c.session.Open(); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to open discord session: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 15:25:44 +00:00
|
|
|
c.SetRunning(true)
|
2026-02-04 11:06:13 +00:00
|
|
|
|
2026-02-12 04:46:28 +00:00
|
|
|
logger.InfoCF("discord", "Discord bot connected", map[string]any{
|
2026-02-04 11:06:13 +00:00
|
|
|
"username": botUser.Username,
|
|
|
|
|
"user_id": botUser.ID,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) Stop(ctx context.Context) error {
|
|
|
|
|
logger.InfoC("discord", "Stopping Discord bot")
|
2026-02-20 15:25:44 +00:00
|
|
|
c.SetRunning(false)
|
2026-02-04 11:06:13 +00:00
|
|
|
|
2026-02-19 12:28:58 +00:00
|
|
|
// Stop all typing goroutines before closing session
|
|
|
|
|
c.typingMu.Lock()
|
|
|
|
|
for chatID, stop := range c.typingStop {
|
|
|
|
|
close(stop)
|
|
|
|
|
delete(c.typingStop, chatID)
|
|
|
|
|
}
|
|
|
|
|
c.typingMu.Unlock()
|
|
|
|
|
|
2026-02-22 14:25:07 +00:00
|
|
|
// Cancel our context so typing goroutines using c.ctx.Done() exit
|
|
|
|
|
if c.cancel != nil {
|
|
|
|
|
c.cancel()
|
|
|
|
|
}
|
2026-04-23 02:35:50 +00:00
|
|
|
if c.progress != nil {
|
|
|
|
|
c.progress.StopAll()
|
|
|
|
|
}
|
2026-02-22 14:25:07 +00:00
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
if err := c.session.Close(); err != nil {
|
|
|
|
|
return fmt.Errorf("failed to close discord session: %w", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
func (c *DiscordChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]string, error) {
|
2026-02-20 15:25:44 +00:00
|
|
|
if !c.IsRunning() {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, channels.ErrNotRunning
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
channelID := msg.ChatID
|
|
|
|
|
if channelID == "" {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("channel ID is empty")
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
2026-02-19 12:28:58 +00:00
|
|
|
|
2026-02-22 14:46:29 +00:00
|
|
|
if len([]rune(msg.Content)) == 0 {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, nil
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
isToolFeedback := outboundMessageIsToolFeedback(msg)
|
|
|
|
|
if isToolFeedback {
|
|
|
|
|
if msgID, handled, err := c.progress.Update(ctx, channelID, msg.Content); handled {
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
2026-04-01 04:21:21 +00:00
|
|
|
}
|
2026-04-23 02:35:50 +00:00
|
|
|
return []string{msgID}, nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID)
|
|
|
|
|
c.maybeStartTTS(channelID, msg.Content, isToolFeedback)
|
|
|
|
|
if !isToolFeedback {
|
|
|
|
|
if msgIDs, handled := c.FinalizeToolFeedbackMessage(ctx, msg); handled {
|
|
|
|
|
return msgIDs, nil
|
2026-04-01 04:21:21 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
content := msg.Content
|
|
|
|
|
if isToolFeedback {
|
|
|
|
|
content = channels.InitialAnimatedToolFeedbackContent(msg.Content)
|
|
|
|
|
}
|
|
|
|
|
msgID, err := c.sendChunk(ctx, channelID, content, msg.ReplyToMessageID)
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
2026-04-23 02:35:50 +00:00
|
|
|
if isToolFeedback {
|
|
|
|
|
c.RecordToolFeedbackMessage(channelID, msgID, msg.Content)
|
|
|
|
|
} else if hasTrackedMsg {
|
|
|
|
|
c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID)
|
|
|
|
|
}
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return []string{msgID}, nil
|
2026-02-20 15:25:44 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
func (c *DiscordChannel) maybeStartTTS(channelID, content string, isToolFeedback bool) {
|
|
|
|
|
if c.tts == nil || isToolFeedback {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
voiceFn := c.ttsVoiceFn
|
|
|
|
|
if voiceFn == nil {
|
|
|
|
|
voiceFn = c.voiceConnectionForTTS
|
|
|
|
|
}
|
|
|
|
|
vc, ok := voiceFn(channelID)
|
|
|
|
|
if !ok || vc == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cancel any previous TTS playback.
|
|
|
|
|
c.ttsMu.Lock()
|
|
|
|
|
if c.cancelTTS != nil {
|
|
|
|
|
c.cancelTTS()
|
|
|
|
|
}
|
|
|
|
|
ttsCtx, ttsCancel := context.WithCancel(c.ctx)
|
|
|
|
|
c.ttsPlayID++
|
|
|
|
|
playID := c.ttsPlayID
|
|
|
|
|
c.cancelTTS = ttsCancel
|
|
|
|
|
playFn := c.playTTSFn
|
|
|
|
|
c.ttsMu.Unlock()
|
|
|
|
|
|
|
|
|
|
if playFn == nil {
|
|
|
|
|
playFn = c.playTTS
|
|
|
|
|
}
|
|
|
|
|
go playFn(ttsCtx, vc, content, playID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) voiceConnectionForTTS(channelID string) (*discordgo.VoiceConnection, bool) {
|
|
|
|
|
if c.session == nil || c.session.State == nil {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
ch, err := c.session.State.Channel(channelID)
|
|
|
|
|
if err != nil || ch == nil || ch.GuildID == "" {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
vc, ok := c.session.VoiceConnections[ch.GuildID]
|
|
|
|
|
if !ok || vc == nil {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return vc, true
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:10:57 +00:00
|
|
|
// SendMedia implements the channels.MediaSender interface.
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
func (c *DiscordChannel) SendMedia(ctx context.Context, msg bus.OutboundMediaMessage) ([]string, error) {
|
2026-02-04 11:06:13 +00:00
|
|
|
if !c.IsRunning() {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, channels.ErrNotRunning
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
channelID := msg.ChatID
|
|
|
|
|
if channelID == "" {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("channel ID is empty")
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
2026-04-23 02:35:50 +00:00
|
|
|
trackedMsgID, hasTrackedMsg := c.currentToolFeedbackMessage(channelID)
|
2026-02-04 11:06:13 +00:00
|
|
|
|
2026-02-22 19:10:57 +00:00
|
|
|
store := c.GetMediaStore()
|
|
|
|
|
if store == nil {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, fmt.Errorf("no media store available: %w", channels.ErrSendFailed)
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Collect all files into a single ChannelMessageSendComplex call
|
|
|
|
|
files := make([]*discordgo.File, 0, len(msg.Parts))
|
|
|
|
|
var caption string
|
|
|
|
|
|
|
|
|
|
for _, part := range msg.Parts {
|
|
|
|
|
localPath, err := store.Resolve(part.Ref)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.ErrorCF("discord", "Failed to resolve media ref", map[string]any{
|
|
|
|
|
"ref": part.Ref,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
file, err := os.Open(localPath)
|
|
|
|
|
if err != nil {
|
|
|
|
|
logger.ErrorCF("discord", "Failed to open media file", map[string]any{
|
|
|
|
|
"path": localPath,
|
|
|
|
|
"error": err.Error(),
|
|
|
|
|
})
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
// Note: discordgo reads from the Reader and we can't close it before send
|
|
|
|
|
|
|
|
|
|
filename := part.Filename
|
|
|
|
|
if filename == "" {
|
|
|
|
|
filename = "file"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
files = append(files, &discordgo.File{
|
|
|
|
|
Name: filename,
|
|
|
|
|
ContentType: part.ContentType,
|
|
|
|
|
Reader: file,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if part.Caption != "" && caption == "" {
|
|
|
|
|
caption = part.Caption
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if len(files) == 0 {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, nil
|
2026-02-16 05:39:26 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:10:57 +00:00
|
|
|
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
type mediaResult struct {
|
|
|
|
|
id string
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
done := make(chan mediaResult, 1)
|
2026-02-22 19:10:57 +00:00
|
|
|
go func() {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
sentMsg, err := c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
|
2026-02-22 19:10:57 +00:00
|
|
|
Content: caption,
|
|
|
|
|
Files: files,
|
|
|
|
|
})
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
if err != nil {
|
|
|
|
|
done <- mediaResult{err: err}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
done <- mediaResult{id: sentMsg.ID}
|
2026-02-22 19:10:57 +00:00
|
|
|
}()
|
2026-02-16 05:39:26 +00:00
|
|
|
|
2026-02-22 19:10:57 +00:00
|
|
|
select {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
case r := <-done:
|
2026-02-22 19:10:57 +00:00
|
|
|
// Close all file readers
|
|
|
|
|
for _, f := range files {
|
|
|
|
|
if closer, ok := f.Reader.(*os.File); ok {
|
|
|
|
|
closer.Close()
|
|
|
|
|
}
|
|
|
|
|
}
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
if r.err != nil {
|
|
|
|
|
return nil, fmt.Errorf("discord send media: %w", channels.ErrTemporary)
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
2026-04-23 02:35:50 +00:00
|
|
|
if hasTrackedMsg {
|
|
|
|
|
c.dismissTrackedToolFeedbackMessage(ctx, channelID, trackedMsgID)
|
|
|
|
|
}
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return []string{r.id}, nil
|
2026-02-22 19:10:57 +00:00
|
|
|
case <-sendCtx.Done():
|
|
|
|
|
// Close all file readers
|
|
|
|
|
for _, f := range files {
|
|
|
|
|
if closer, ok := f.Reader.(*os.File); ok {
|
|
|
|
|
closer.Close()
|
|
|
|
|
}
|
2026-02-16 05:39:26 +00:00
|
|
|
}
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return nil, sendCtx.Err()
|
2026-02-16 05:39:26 +00:00
|
|
|
}
|
2026-02-22 19:10:57 +00:00
|
|
|
}
|
2026-02-16 05:39:26 +00:00
|
|
|
|
2026-02-22 20:55:15 +00:00
|
|
|
// EditMessage implements channels.MessageEditor.
|
|
|
|
|
func (c *DiscordChannel) EditMessage(ctx context.Context, chatID string, messageID string, content string) error {
|
2026-04-23 02:35:50 +00:00
|
|
|
_, err := c.session.ChannelMessageEdit(chatID, messageID, content, discordgo.WithContext(ctx))
|
2026-02-22 20:55:15 +00:00
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
// DeleteMessage implements channels.MessageDeleter.
|
|
|
|
|
func (c *DiscordChannel) DeleteMessage(ctx context.Context, chatID string, messageID string) error {
|
|
|
|
|
return c.session.ChannelMessageDelete(chatID, messageID, discordgo.WithContext(ctx))
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 19:02:40 +00:00
|
|
|
// SendPlaceholder implements channels.PlaceholderCapable.
|
|
|
|
|
// It sends a placeholder message that will later be edited to the actual
|
|
|
|
|
// response via EditMessage (channels.MessageEditor).
|
|
|
|
|
func (c *DiscordChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) {
|
2026-04-11 16:57:26 +00:00
|
|
|
if !c.bc.Placeholder.Enabled {
|
2026-02-26 19:02:40 +00:00
|
|
|
return "", nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-11 16:57:26 +00:00
|
|
|
text := c.bc.Placeholder.GetRandomText()
|
2026-02-26 19:02:40 +00:00
|
|
|
|
|
|
|
|
msg, err := c.session.ChannelMessageSend(chatID, text)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return "", err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return msg.ID, nil
|
2026-02-16 05:39:26 +00:00
|
|
|
}
|
|
|
|
|
|
2026-04-23 02:35:50 +00:00
|
|
|
func outboundMessageIsToolFeedback(msg bus.OutboundMessage) bool {
|
|
|
|
|
if len(msg.Context.Raw) == 0 {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
return strings.EqualFold(strings.TrimSpace(msg.Context.Raw["message_kind"]), "tool_feedback")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) currentToolFeedbackMessage(chatID string) (string, bool) {
|
|
|
|
|
if c.progress == nil {
|
|
|
|
|
return "", false
|
|
|
|
|
}
|
|
|
|
|
return c.progress.Current(chatID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) takeToolFeedbackMessage(chatID string) (string, string, bool) {
|
|
|
|
|
if c.progress == nil {
|
|
|
|
|
return "", "", false
|
|
|
|
|
}
|
|
|
|
|
return c.progress.Take(chatID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) RecordToolFeedbackMessage(chatID, messageID, content string) {
|
|
|
|
|
if c.progress == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
c.progress.Record(chatID, messageID, content)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) ClearToolFeedbackMessage(chatID string) {
|
|
|
|
|
if c.progress == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
c.progress.Clear(chatID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) DismissToolFeedbackMessage(ctx context.Context, chatID string) {
|
|
|
|
|
msgID, ok := c.currentToolFeedbackMessage(chatID)
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
c.dismissTrackedToolFeedbackMessage(ctx, chatID, msgID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) dismissTrackedToolFeedbackMessage(ctx context.Context, chatID, messageID string) {
|
|
|
|
|
if strings.TrimSpace(chatID) == "" || strings.TrimSpace(messageID) == "" {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
c.ClearToolFeedbackMessage(chatID)
|
|
|
|
|
_ = c.DeleteMessage(ctx, chatID, messageID)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) finalizeTrackedToolFeedbackMessage(
|
|
|
|
|
ctx context.Context,
|
|
|
|
|
chatID string,
|
|
|
|
|
content string,
|
|
|
|
|
editFn func(context.Context, string, string, string) error,
|
|
|
|
|
) ([]string, bool) {
|
|
|
|
|
msgID, baseContent, ok := c.takeToolFeedbackMessage(chatID)
|
|
|
|
|
if !ok || editFn == nil {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
if err := editFn(ctx, chatID, msgID, content); err != nil {
|
|
|
|
|
c.RecordToolFeedbackMessage(chatID, msgID, baseContent)
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return []string{msgID}, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) FinalizeToolFeedbackMessage(ctx context.Context, msg bus.OutboundMessage) ([]string, bool) {
|
|
|
|
|
if outboundMessageIsToolFeedback(msg) {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return c.finalizeTrackedToolFeedbackMessage(ctx, msg.ChatID, msg.Content, c.EditMessage)
|
|
|
|
|
}
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
func (c *DiscordChannel) sendChunk(ctx context.Context, channelID, content, replyToID string) (string, error) {
|
2026-02-20 11:18:37 +00:00
|
|
|
// Use the passed ctx for timeout control
|
2026-02-12 04:46:28 +00:00
|
|
|
sendCtx, cancel := context.WithTimeout(ctx, sendTimeout)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
type result struct {
|
|
|
|
|
id string
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
done := make(chan result, 1)
|
2026-02-12 04:46:28 +00:00
|
|
|
go func() {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
var (
|
|
|
|
|
msg *discordgo.Message
|
|
|
|
|
err error
|
|
|
|
|
)
|
2026-03-11 08:33:01 +00:00
|
|
|
|
|
|
|
|
// If we have an ID, we send the message as "Reply"
|
|
|
|
|
if replyToID != "" {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
msg, err = c.session.ChannelMessageSendComplex(channelID, &discordgo.MessageSend{
|
2026-03-11 08:33:01 +00:00
|
|
|
Content: content,
|
|
|
|
|
Reference: &discordgo.MessageReference{
|
|
|
|
|
MessageID: replyToID,
|
|
|
|
|
ChannelID: channelID,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
// Otherwise, we send a normal message
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
msg, err = c.session.ChannelMessageSend(channelID, content)
|
2026-03-11 08:33:01 +00:00
|
|
|
}
|
|
|
|
|
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
if err != nil {
|
|
|
|
|
done <- result{err: fmt.Errorf("discord send: %w", channels.ErrTemporary)}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
done <- result{id: msg.ID}
|
2026-02-12 04:46:28 +00:00
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
select {
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
case r := <-done:
|
|
|
|
|
return r.id, r.err
|
2026-02-12 04:46:28 +00:00
|
|
|
case <-sendCtx.Done():
|
feat(channels): make Channel.Send return delivered message IDs (#2190)
* feat(channels): Channel.Send and MediaSender.SendMedia return delivered message IDs
Change Channel.Send signature from (ctx, msg) error to (ctx, msg) ([]string, error)
and MediaSender.SendMedia similarly, so callers can capture platform message IDs
for threading, reactions, and history annotation.
Adapters that return real IDs: Telegram (per-chunk MessageID), Discord (Message.ID),
Slack Send (ts), QQ (sentMsg.ID), Matrix (EventID). Slack SendMedia returns nil
because UploadFileV2 does not expose the posted message timestamp in its response.
All other adapters return nil IDs.
preSend and sendWithRetry in manager.go updated to propagate ([]string, bool).
README examples updated for both English and Chinese docs.
* style: apply golangci-lint fixes (golines)
* docs: fix Send migration guide — restore old error-only signature in before/after example
2026-03-31 03:07:32 +00:00
|
|
|
return "", sendCtx.Err()
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
2026-02-12 04:46:28 +00:00
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
|
2026-02-20 11:18:37 +00:00
|
|
|
// appendContent safely appends content to existing text
|
2026-02-12 04:46:28 +00:00
|
|
|
func appendContent(content, suffix string) string {
|
|
|
|
|
if content == "" {
|
|
|
|
|
return suffix
|
|
|
|
|
}
|
|
|
|
|
return content + "\n" + suffix
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) handleMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
|
|
|
|
|
if m == nil || m.Author == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if m.Author.ID == s.State.User.ID {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-22 19:47:12 +00:00
|
|
|
// Check allowlist first to avoid downloading attachments for rejected users
|
2026-02-22 22:56:48 +00:00
|
|
|
sender := bus.SenderInfo{
|
|
|
|
|
Platform: "discord",
|
|
|
|
|
PlatformID: m.Author.ID,
|
|
|
|
|
CanonicalID: identity.BuildCanonicalID("discord", m.Author.ID),
|
|
|
|
|
Username: m.Author.Username,
|
|
|
|
|
}
|
|
|
|
|
// Build display name
|
|
|
|
|
displayName := m.Author.Username
|
|
|
|
|
if m.Author.Discriminator != "" && m.Author.Discriminator != "0" {
|
|
|
|
|
displayName += "#" + m.Author.Discriminator
|
|
|
|
|
}
|
|
|
|
|
sender.DisplayName = displayName
|
|
|
|
|
|
|
|
|
|
if !c.IsAllowedSender(sender) {
|
2026-02-12 04:46:28 +00:00
|
|
|
logger.DebugCF("discord", "Message rejected by allowlist", map[string]any{
|
|
|
|
|
"user_id": m.Author.ID,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-01 04:21:21 +00:00
|
|
|
if c.handleVoiceCommand(s, m) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
content := m.Content
|
|
|
|
|
|
|
|
|
|
// In guild (group) channels, apply unified group trigger filtering
|
|
|
|
|
// DMs (GuildID is empty) always get a response
|
2026-04-01 05:50:24 +00:00
|
|
|
isMentioned := false
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
if m.GuildID != "" {
|
2026-02-20 11:18:37 +00:00
|
|
|
for _, mention := range m.Mentions {
|
|
|
|
|
if mention.ID == c.botUserID {
|
|
|
|
|
isMentioned = true
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
content = c.stripBotMention(content)
|
|
|
|
|
respond, cleaned := c.ShouldRespondInGroup(isMentioned, content)
|
|
|
|
|
if !respond {
|
|
|
|
|
logger.DebugCF("discord", "Group message ignored by group trigger", map[string]any{
|
2026-02-20 11:18:37 +00:00
|
|
|
"user_id": m.Author.ID,
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
refactor(channels): standardize group chat trigger filtering (Phase 8)
Add unified ShouldRespondInGroup to BaseChannel, replacing scattered
per-channel group filtering logic. Introduce GroupTriggerConfig (with
mention_only + prefixes), TypingConfig, and PlaceholderConfig types.
Migrate Discord MentionOnly, OneBot checkGroupTrigger, and LINE
hardcoded mention-only to the shared mechanism. Add group trigger
entry points for Slack, Telegram, QQ, Feishu, DingTalk, and WeCom.
Legacy config fields are preserved with automatic migration.
2026-02-22 20:11:11 +00:00
|
|
|
content = cleaned
|
|
|
|
|
} else {
|
|
|
|
|
// DMs: just strip bot mention without filtering
|
|
|
|
|
content = c.stripBotMention(content)
|
2026-02-20 11:18:37 +00:00
|
|
|
}
|
|
|
|
|
|
2026-03-04 07:10:10 +00:00
|
|
|
// Resolve Discord refs in main content before concatenation to avoid
|
|
|
|
|
// double-expanding links that appear in the referenced message.
|
|
|
|
|
content = c.resolveDiscordRefs(s, content, m.GuildID)
|
|
|
|
|
|
2026-03-04 01:13:20 +00:00
|
|
|
// Prepend referenced (quoted) message content if this is a reply
|
|
|
|
|
if m.MessageReference != nil && m.ReferencedMessage != nil {
|
|
|
|
|
refContent := m.ReferencedMessage.Content
|
|
|
|
|
if refContent != "" {
|
2026-03-04 01:30:52 +00:00
|
|
|
refAuthor := "unknown"
|
|
|
|
|
if m.ReferencedMessage.Author != nil {
|
|
|
|
|
refAuthor = m.ReferencedMessage.Author.Username
|
|
|
|
|
}
|
2026-03-04 02:08:13 +00:00
|
|
|
refContent = c.resolveDiscordRefs(s, refContent, m.GuildID)
|
2026-03-04 01:13:20 +00:00
|
|
|
content = fmt.Sprintf("[quoted message from %s]: %s\n\n%s",
|
2026-03-04 01:30:52 +00:00
|
|
|
refAuthor, refContent, content)
|
2026-03-04 01:13:20 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
senderID := m.Author.ID
|
|
|
|
|
|
2026-02-12 04:46:28 +00:00
|
|
|
mediaPaths := make([]string, 0, len(m.Attachments))
|
2026-02-22 15:27:55 +00:00
|
|
|
|
|
|
|
|
scope := channels.BuildMediaScope("discord", m.ChannelID, m.ID)
|
|
|
|
|
|
|
|
|
|
// Helper to register a local file with the media store
|
|
|
|
|
storeMedia := func(localPath, filename string) string {
|
|
|
|
|
if store := c.GetMediaStore(); store != nil {
|
|
|
|
|
ref, err := store.Store(localPath, media.MediaMeta{
|
2026-03-23 04:13:59 +00:00
|
|
|
Filename: filename,
|
|
|
|
|
Source: "discord",
|
|
|
|
|
CleanupPolicy: media.CleanupPolicyDeleteOnCleanup,
|
2026-02-22 15:27:55 +00:00
|
|
|
}, scope)
|
|
|
|
|
if err == nil {
|
|
|
|
|
return ref
|
2026-02-12 04:46:28 +00:00
|
|
|
}
|
|
|
|
|
}
|
2026-02-22 15:27:55 +00:00
|
|
|
return localPath // fallback
|
|
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
|
|
|
|
|
for _, attachment := range m.Attachments {
|
2026-02-12 04:46:28 +00:00
|
|
|
isAudio := utils.IsAudioFile(attachment.Filename, attachment.ContentType)
|
2026-02-10 09:10:41 +00:00
|
|
|
|
|
|
|
|
if isAudio {
|
|
|
|
|
localPath := c.downloadAttachment(attachment.URL, attachment.Filename)
|
|
|
|
|
if localPath != "" {
|
2026-02-22 15:27:55 +00:00
|
|
|
mediaPaths = append(mediaPaths, storeMedia(localPath, attachment.Filename))
|
2026-02-22 19:47:12 +00:00
|
|
|
content = appendContent(content, fmt.Sprintf("[audio: %s]", attachment.Filename))
|
2026-02-10 09:10:41 +00:00
|
|
|
} else {
|
2026-02-12 04:46:28 +00:00
|
|
|
logger.WarnCF("discord", "Failed to download audio attachment", map[string]any{
|
|
|
|
|
"url": attachment.URL,
|
|
|
|
|
"filename": attachment.Filename,
|
|
|
|
|
})
|
2026-02-10 09:10:41 +00:00
|
|
|
mediaPaths = append(mediaPaths, attachment.URL)
|
2026-02-12 04:46:28 +00:00
|
|
|
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
2026-02-10 09:10:41 +00:00
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
mediaPaths = append(mediaPaths, attachment.URL)
|
2026-02-12 04:46:28 +00:00
|
|
|
content = appendContent(content, fmt.Sprintf("[attachment: %s]", attachment.URL))
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if content == "" && len(mediaPaths) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if content == "" {
|
|
|
|
|
content = "[media only]"
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-12 04:46:28 +00:00
|
|
|
logger.DebugCF("discord", "Received message", map[string]any{
|
2026-02-22 22:56:48 +00:00
|
|
|
"sender_name": sender.DisplayName,
|
2026-02-04 11:06:13 +00:00
|
|
|
"sender_id": senderID,
|
2026-02-11 16:46:48 +00:00
|
|
|
"preview": utils.Truncate(content, 50),
|
2026-02-04 11:06:13 +00:00
|
|
|
})
|
|
|
|
|
|
feat: add multi-agent routing with declarative bindings
Implement per-agent workspace/model/session isolation with 7-level
priority routing cascade (peer > parent_peer > guild > team > account >
channel > default). Backward compatible - empty agents.list creates
implicit "main" agent from defaults.
Core components:
- routing/agent_id.go: ID normalization with pre-compiled regex
- routing/session_key.go: 4 DM scope modes with identity links
- routing/route.go: RouteResolver with priority-based binding matcher
- agent/instance.go: Per-agent state (workspace, sessions, tools, model)
- agent/registry.go: Agent lifecycle, route resolution, subagent ACL
Integration:
- config.go: AgentModelConfig (flexible JSON), bindings, session config
- loop.go: Complete rewrite for multi-agent dispatch
- Channel adapters: peer_kind/peer_id metadata (telegram, discord, slack)
- spawn.go: Subagent allowlist enforcement per agent
Validated end-to-end with Discord channel-based bindings, default
fallback routing, and per-agent session persistence.
2026-02-13 15:12:33 +00:00
|
|
|
peerKind := "channel"
|
|
|
|
|
if m.GuildID == "" {
|
|
|
|
|
peerKind = "direct"
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-04 11:06:13 +00:00
|
|
|
metadata := map[string]string{
|
|
|
|
|
"user_id": senderID,
|
|
|
|
|
"username": m.Author.Username,
|
2026-02-22 22:56:48 +00:00
|
|
|
"display_name": sender.DisplayName,
|
2026-02-04 11:06:13 +00:00
|
|
|
"guild_id": m.GuildID,
|
|
|
|
|
"channel_id": m.ChannelID,
|
|
|
|
|
"is_dm": fmt.Sprintf("%t", m.GuildID == ""),
|
|
|
|
|
}
|
2026-04-01 05:50:24 +00:00
|
|
|
inboundCtx := bus.InboundContext{
|
|
|
|
|
Channel: c.Name(),
|
|
|
|
|
ChatID: m.ChannelID,
|
|
|
|
|
ChatType: peerKind,
|
|
|
|
|
SenderID: senderID,
|
|
|
|
|
MessageID: m.ID,
|
|
|
|
|
Mentioned: isMentioned,
|
|
|
|
|
Raw: metadata,
|
|
|
|
|
}
|
|
|
|
|
if m.GuildID != "" {
|
|
|
|
|
inboundCtx.SpaceID = m.GuildID
|
|
|
|
|
inboundCtx.SpaceType = "guild"
|
|
|
|
|
}
|
|
|
|
|
if m.MessageReference != nil {
|
|
|
|
|
inboundCtx.ReplyToMessageID = m.MessageReference.MessageID
|
|
|
|
|
}
|
2026-02-04 11:06:13 +00:00
|
|
|
|
2026-04-01 12:56:48 +00:00
|
|
|
c.HandleInboundContext(c.ctx, m.ChannelID, content, mediaPaths, inboundCtx, sender)
|
2026-02-04 11:06:13 +00:00
|
|
|
}
|
2026-02-10 09:10:41 +00:00
|
|
|
|
2026-02-19 12:28:58 +00:00
|
|
|
// startTyping starts a continuous typing indicator loop for the given chatID.
|
|
|
|
|
// It stops any existing typing loop for that chatID before starting a new one.
|
|
|
|
|
func (c *DiscordChannel) startTyping(chatID string) {
|
|
|
|
|
c.typingMu.Lock()
|
|
|
|
|
// Stop existing loop for this chatID if any
|
|
|
|
|
if stop, ok := c.typingStop[chatID]; ok {
|
|
|
|
|
close(stop)
|
|
|
|
|
}
|
|
|
|
|
stop := make(chan struct{})
|
|
|
|
|
c.typingStop[chatID] = stop
|
|
|
|
|
c.typingMu.Unlock()
|
|
|
|
|
|
|
|
|
|
go func() {
|
|
|
|
|
if err := c.session.ChannelTyping(chatID); err != nil {
|
2026-02-19 20:05:15 +00:00
|
|
|
logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
|
2026-02-19 12:28:58 +00:00
|
|
|
}
|
|
|
|
|
ticker := time.NewTicker(8 * time.Second)
|
|
|
|
|
defer ticker.Stop()
|
|
|
|
|
timeout := time.After(5 * time.Minute)
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-stop:
|
|
|
|
|
return
|
|
|
|
|
case <-timeout:
|
|
|
|
|
return
|
|
|
|
|
case <-c.ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case <-ticker.C:
|
|
|
|
|
if err := c.session.ChannelTyping(chatID); err != nil {
|
2026-02-19 20:05:15 +00:00
|
|
|
logger.DebugCF("discord", "ChannelTyping error", map[string]any{"chatID": chatID, "err": err})
|
2026-02-19 12:28:58 +00:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// stopTyping stops the typing indicator loop for the given chatID.
|
|
|
|
|
func (c *DiscordChannel) stopTyping(chatID string) {
|
|
|
|
|
c.typingMu.Lock()
|
|
|
|
|
defer c.typingMu.Unlock()
|
|
|
|
|
if stop, ok := c.typingStop[chatID]; ok {
|
|
|
|
|
close(stop)
|
|
|
|
|
delete(c.typingStop, chatID)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 19:02:40 +00:00
|
|
|
// StartTyping implements channels.TypingCapable.
|
|
|
|
|
// It starts a continuous typing indicator and returns an idempotent stop function.
|
|
|
|
|
func (c *DiscordChannel) StartTyping(ctx context.Context, chatID string) (func(), error) {
|
|
|
|
|
c.startTyping(chatID)
|
|
|
|
|
return func() { c.stopTyping(chatID) }, nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-10 09:10:41 +00:00
|
|
|
func (c *DiscordChannel) downloadAttachment(url, filename string) string {
|
2026-02-12 04:46:28 +00:00
|
|
|
return utils.DownloadFile(url, filename, utils.DownloadOptions{
|
|
|
|
|
LoggerPrefix: "discord",
|
2026-02-27 06:35:23 +00:00
|
|
|
ProxyURL: c.config.Proxy,
|
2026-02-12 04:46:28 +00:00
|
|
|
})
|
2026-02-10 09:10:41 +00:00
|
|
|
}
|
2026-02-20 11:18:37 +00:00
|
|
|
|
2026-02-27 06:35:23 +00:00
|
|
|
func applyDiscordProxy(session *discordgo.Session, proxyAddr string) error {
|
|
|
|
|
var proxyFunc func(*http.Request) (*url.URL, error)
|
|
|
|
|
if proxyAddr != "" {
|
|
|
|
|
proxyURL, err := url.Parse(proxyAddr)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return fmt.Errorf("invalid discord proxy URL %q: %w", proxyAddr, err)
|
|
|
|
|
}
|
|
|
|
|
proxyFunc = http.ProxyURL(proxyURL)
|
|
|
|
|
} else if os.Getenv("HTTP_PROXY") != "" || os.Getenv("HTTPS_PROXY") != "" {
|
|
|
|
|
proxyFunc = http.ProxyFromEnvironment
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if proxyFunc == nil {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
transport := &http.Transport{Proxy: proxyFunc}
|
|
|
|
|
session.Client = &http.Client{
|
2026-03-02 10:17:51 +00:00
|
|
|
Timeout: sendTimeout,
|
2026-02-27 06:35:23 +00:00
|
|
|
Transport: transport,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if session.Dialer != nil {
|
|
|
|
|
dialerCopy := *session.Dialer
|
|
|
|
|
dialerCopy.Proxy = proxyFunc
|
|
|
|
|
session.Dialer = &dialerCopy
|
|
|
|
|
} else {
|
|
|
|
|
session.Dialer = &websocket.Dialer{Proxy: proxyFunc}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return nil
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-04 01:14:18 +00:00
|
|
|
// resolveDiscordRefs resolves channel references (<#id> → #channel-name) and
|
|
|
|
|
// expands Discord message links to show the linked message content.
|
2026-03-04 02:08:13 +00:00
|
|
|
// Only links pointing to the same guild are expanded to prevent cross-guild leakage.
|
|
|
|
|
func (c *DiscordChannel) resolveDiscordRefs(s *discordgo.Session, text string, guildID string) string {
|
2026-03-04 01:14:18 +00:00
|
|
|
// 1. Resolve channel references: <#id> → #channel-name
|
2026-03-04 01:30:52 +00:00
|
|
|
text = channelRefRe.ReplaceAllStringFunc(text, func(match string) string {
|
|
|
|
|
parts := channelRefRe.FindStringSubmatch(match)
|
2026-03-04 01:14:18 +00:00
|
|
|
if len(parts) < 2 {
|
|
|
|
|
return match
|
|
|
|
|
}
|
2026-03-04 02:08:13 +00:00
|
|
|
// Prefer session state cache to avoid API calls
|
|
|
|
|
if ch, err := s.State.Channel(parts[1]); err == nil {
|
|
|
|
|
return "#" + ch.Name
|
|
|
|
|
}
|
|
|
|
|
if ch, err := s.Channel(parts[1]); err == nil {
|
|
|
|
|
return "#" + ch.Name
|
2026-03-04 01:14:18 +00:00
|
|
|
}
|
2026-03-04 02:08:13 +00:00
|
|
|
return match
|
2026-03-04 01:14:18 +00:00
|
|
|
})
|
|
|
|
|
|
2026-03-04 02:08:13 +00:00
|
|
|
// 2. Expand Discord message links (max 3, same guild only)
|
2026-03-04 01:14:18 +00:00
|
|
|
matches := msgLinkRe.FindAllStringSubmatch(text, 3)
|
|
|
|
|
for _, m := range matches {
|
|
|
|
|
if len(m) < 4 {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-03-04 02:08:13 +00:00
|
|
|
linkGuildID, channelID, messageID := m[1], m[2], m[3]
|
|
|
|
|
// Security: only expand links from the same guild
|
|
|
|
|
if linkGuildID != guildID {
|
|
|
|
|
continue
|
|
|
|
|
}
|
2026-03-04 01:14:18 +00:00
|
|
|
msg, err := s.ChannelMessage(channelID, messageID)
|
|
|
|
|
if err != nil || msg == nil || msg.Content == "" {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
author := "unknown"
|
|
|
|
|
if msg.Author != nil {
|
|
|
|
|
author = msg.Author.Username
|
|
|
|
|
}
|
|
|
|
|
text += fmt.Sprintf("\n[linked message from %s]: %s", author, msg.Content)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 11:18:37 +00:00
|
|
|
// stripBotMention removes the bot mention from the message content.
|
|
|
|
|
// Discord mentions have the format <@USER_ID> or <@!USER_ID> (with nickname).
|
|
|
|
|
func (c *DiscordChannel) stripBotMention(text string) string {
|
|
|
|
|
if c.botUserID == "" {
|
|
|
|
|
return text
|
|
|
|
|
}
|
|
|
|
|
// Remove both regular mention <@USER_ID> and nickname mention <@!USER_ID>
|
|
|
|
|
text = strings.ReplaceAll(text, fmt.Sprintf("<@%s>", c.botUserID), "")
|
|
|
|
|
text = strings.ReplaceAll(text, fmt.Sprintf("<@!%s>", c.botUserID), "")
|
|
|
|
|
return strings.TrimSpace(text)
|
|
|
|
|
}
|
2026-04-01 04:21:21 +00:00
|
|
|
|
|
|
|
|
func (c *DiscordChannel) listenVoiceControl(ctx context.Context) {
|
|
|
|
|
for {
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
return
|
|
|
|
|
case ctrl, ok := <-c.bus.VoiceControlsChan():
|
|
|
|
|
if !ok {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if ctrl.Type == "command" && ctrl.Action == "leave" {
|
|
|
|
|
if strings.HasPrefix(ctrl.SessionID, "discord_vc_") {
|
|
|
|
|
guildID := strings.TrimPrefix(ctrl.SessionID, "discord_vc_")
|
|
|
|
|
vc, exists := c.session.VoiceConnections[guildID]
|
|
|
|
|
if exists && vc != nil {
|
|
|
|
|
vc.Disconnect(ctx)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (c *DiscordChannel) playTTS(ctx context.Context, vc *discordgo.VoiceConnection, text string, playID uint64) {
|
|
|
|
|
// Capture the cancel func associated with this playback (if any).
|
|
|
|
|
// Clear cancelTTS when playback finishes (normal or interrupted),
|
|
|
|
|
// but only if it still refers to this playback's cancel func.
|
|
|
|
|
defer func() {
|
|
|
|
|
c.ttsMu.Lock()
|
|
|
|
|
if c.ttsPlayID == playID {
|
|
|
|
|
c.cancelTTS = nil
|
|
|
|
|
}
|
|
|
|
|
c.ttsMu.Unlock()
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
sentences := audio.SplitSentences(text)
|
|
|
|
|
if len(sentences) == 0 {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
logger.InfoCF("discord", "Starting streamed TTS", map[string]any{"sentences": len(sentences)})
|
|
|
|
|
|
|
|
|
|
// Pipeline: prefetch next sentence's audio while playing current
|
|
|
|
|
type ttResult struct {
|
|
|
|
|
stream io.ReadCloser
|
|
|
|
|
err error
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var prefetch chan ttResult
|
|
|
|
|
|
|
|
|
|
// Ensure any in-flight prefetch is drained on exit to prevent stream leaks,
|
|
|
|
|
// but avoid blocking indefinitely if the prefetch goroutine is stuck or never sends.
|
|
|
|
|
defer func() {
|
|
|
|
|
if prefetch != nil {
|
|
|
|
|
select {
|
|
|
|
|
case result := <-prefetch:
|
|
|
|
|
if result.stream != nil {
|
|
|
|
|
result.stream.Close()
|
|
|
|
|
}
|
|
|
|
|
case <-time.After(100 * time.Millisecond):
|
|
|
|
|
// Timed out waiting for a prefetched result; avoid blocking on exit.
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}()
|
|
|
|
|
|
|
|
|
|
for i, sentence := range sentences {
|
|
|
|
|
// Check for cancellation (interruption)
|
|
|
|
|
select {
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
logger.InfoCF("discord", "TTS interrupted", map[string]any{"at_sentence": i})
|
|
|
|
|
return
|
|
|
|
|
default:
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Start prefetching the NEXT sentence while we process the current one
|
|
|
|
|
var nextPrefetch chan ttResult
|
|
|
|
|
if i+1 < len(sentences) {
|
|
|
|
|
nextPrefetch = make(chan ttResult, 1)
|
|
|
|
|
nextSentence := sentences[i+1]
|
|
|
|
|
go func() {
|
|
|
|
|
s, e := c.tts.Synthesize(ctx, nextSentence)
|
|
|
|
|
nextPrefetch <- ttResult{s, e}
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Get the current sentence's audio
|
|
|
|
|
var stream io.ReadCloser
|
|
|
|
|
var err error
|
|
|
|
|
|
|
|
|
|
if prefetch != nil {
|
|
|
|
|
// Use prefetched result from previous iteration, but be responsive to cancellation.
|
|
|
|
|
var result ttResult
|
|
|
|
|
select {
|
|
|
|
|
case result = <-prefetch:
|
|
|
|
|
stream, err = result.stream, result.err
|
|
|
|
|
case <-ctx.Done():
|
|
|
|
|
// Context canceled while waiting for prefetched audio; abort playback.
|
|
|
|
|
logger.InfoCF(
|
|
|
|
|
"discord",
|
|
|
|
|
"TTS interrupted while waiting for prefetched audio",
|
|
|
|
|
map[string]any{"at_sentence": i},
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// First sentence: synthesize directly
|
|
|
|
|
stream, err = c.tts.Synthesize(ctx, sentence)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
if stream != nil {
|
|
|
|
|
stream.Close()
|
|
|
|
|
}
|
|
|
|
|
logger.ErrorCF("discord", "TTS synthesize failed", map[string]any{"error": err.Error(), "sentence": i})
|
|
|
|
|
prefetch = nextPrefetch
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if err := streamOggOpusToDiscord(ctx, vc, stream); err != nil {
|
|
|
|
|
logger.ErrorCF("discord", "TTS playback failed", map[string]any{"error": err.Error(), "sentence": i})
|
|
|
|
|
}
|
|
|
|
|
stream.Close()
|
|
|
|
|
|
|
|
|
|
prefetch = nextPrefetch
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// VoiceCapabilities returns the voice capabilities of the channel.
|
|
|
|
|
func (c *DiscordChannel) VoiceCapabilities() channels.VoiceCapabilities {
|
|
|
|
|
return channels.VoiceCapabilities{ASR: true, TTS: true}
|
|
|
|
|
}
|