Files
cc-connect/platform/discord/discord.go
T
2026-06-02 23:14:41 +08:00

1394 lines
44 KiB
Go

package discord
import (
"bytes"
"context"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"github.com/chenhg5/cc-connect/core"
"github.com/bwmarrin/discordgo"
"github.com/gorilla/websocket"
)
func init() {
core.RegisterPlatform("discord", New)
}
const maxDiscordLen = 1900
type replyContext struct {
channelID string
messageID string
threadID string
}
// interactionReplyCtx handles Discord slash command (Application Command)
// responses. The first reply edits the deferred interaction response;
// subsequent replies use followup messages.
type interactionReplyCtx struct {
interaction *discordgo.Interaction
channelID string
mu sync.Mutex
firstDone bool
}
type progressPlatform struct {
*Platform
}
type Platform struct {
token string
allowFrom string
guildID string // optional: per-guild registration (instant) vs global (up to 1h propagation)
progressStyle string
groupReplyAllGuilds []string // guild IDs where groupReplyAll is active; "*" = all guilds
shareSessionInChannel bool
threadIsolation bool
respondToAtEveryoneAndHere bool
proxyURL *url.URL
session *discordgo.Session
handler core.MessageHandler
botID string
appID string
channelNameCache sync.Map // channelID -> name
botRoleIDs sync.Map // guildID -> bot managed role ID
readyCh chan struct{}
seenMsgs sync.Map // message ID dedup: prevents duplicate MessageCreate events
seenInteractions sync.Map // interaction ID dedup: prevents duplicate slash/button events
self core.Platform
}
func New(opts map[string]any) (core.Platform, error) {
token, _ := opts["token"].(string)
if token == "" {
return nil, fmt.Errorf("discord: token is required")
}
allowFrom, _ := opts["allow_from"].(string)
core.CheckAllowFrom("discord", allowFrom)
guildID, _ := opts["guild_id"].(string)
var groupReplyAllGuilds []string
if guilds, _ := opts["group_reply_all_guilds"].(string); guilds != "" {
for _, g := range strings.Split(guilds, ",") {
if g = strings.TrimSpace(g); g != "" {
groupReplyAllGuilds = append(groupReplyAllGuilds, g)
}
}
} else if all, _ := opts["group_reply_all"].(bool); all {
groupReplyAllGuilds = []string{"*"}
}
shareSessionInChannel, _ := opts["share_session_in_channel"].(bool)
threadIsolation, _ := opts["thread_isolation"].(bool)
respondToAtEveryoneAndHere, _ := opts["respond_to_at_everyone_and_here"].(bool)
progressStyle := "legacy"
if v, ok := opts["progress_style"].(string); ok {
switch strings.ToLower(strings.TrimSpace(v)) {
case "", "legacy":
progressStyle = "legacy"
case "compact", "card":
progressStyle = strings.ToLower(strings.TrimSpace(v))
default:
return nil, fmt.Errorf("discord: invalid progress_style %q (want legacy, compact, or card)", v)
}
}
var proxyU *url.URL
if proxyStr, _ := opts["proxy"].(string); proxyStr != "" {
u, err := url.Parse(proxyStr)
if err != nil {
return nil, fmt.Errorf("discord: invalid proxy URL %q: %w", proxyStr, err)
}
if user, _ := opts["proxy_username"].(string); user != "" {
pass, _ := opts["proxy_password"].(string)
u.User = url.UserPassword(user, pass)
}
proxyU = u
}
base := &Platform{
token: token,
allowFrom: allowFrom,
guildID: guildID,
progressStyle: progressStyle,
groupReplyAllGuilds: groupReplyAllGuilds,
shareSessionInChannel: shareSessionInChannel,
readyCh: make(chan struct{}),
threadIsolation: threadIsolation,
respondToAtEveryoneAndHere: respondToAtEveryoneAndHere,
proxyURL: proxyU,
}
if progressStyle == "compact" || progressStyle == "card" {
wrapped := &progressPlatform{Platform: base}
base.self = wrapped
return wrapped, nil
}
base.self = base
return base, nil
}
func (p *Platform) Name() string { return "discord" }
func (p *Platform) selfPlatform() core.Platform {
if p != nil && p.self != nil {
return p.self
}
return p
}
func (p *Platform) dispatchMessage(msg *core.Message) {
if p == nil || p.handler == nil {
return
}
p.handler(p.selfPlatform(), msg)
}
func (p *progressPlatform) ProgressStyle() string {
switch strings.ToLower(strings.TrimSpace(p.progressStyle)) {
case "", "legacy":
return "legacy"
case "compact":
return "compact"
case "card":
return "card"
default:
return "legacy"
}
}
func (p *progressPlatform) SupportsProgressCardPayload() bool {
return p.ProgressStyle() == "card"
}
func (p *Platform) isGroupReplyAllGuild(guildID string) bool {
for _, g := range p.groupReplyAllGuilds {
if g == "*" || g == guildID {
return true
}
}
return false
}
func (p *Platform) makeSessionKey(channelID string, userID string) string {
return buildSessionKey(channelID, userID, p.shareSessionInChannel)
}
func rememberDedupID(store *sync.Map, id string) bool {
if id == "" {
return true
}
if _, loaded := store.LoadOrStore(id, struct{}{}); loaded {
return false
}
time.AfterFunc(2*time.Minute, func() { store.Delete(id) })
return true
}
func buildSessionKey(channelID string, userID string, shareSessionInChannel bool) string {
if shareSessionInChannel {
return fmt.Sprintf("discord:%s", channelID)
}
return fmt.Sprintf("discord:%s:%s", channelID, userID)
}
// TODO: thread_isolation currently keys each Discord thread as one shared session, so share_session_in_channel=false does not further isolate users within the same thread.
func buildThreadSessionKey(threadID string) string {
return fmt.Sprintf("discord:%s", threadID)
}
func (rc replyContext) targetChannelID() string {
if rc.threadID != "" {
return rc.threadID
}
return rc.channelID
}
func (rc replyContext) useThreadChannel() bool {
return rc.threadID != "" && rc.threadID == rc.channelID
}
type discordThreadOps interface {
ResolveChannel(channelID string) (*discordgo.Channel, error)
StartThread(channelID, messageID, name string, archiveDuration int) (*discordgo.Channel, error)
StartStandaloneThread(channelID, name string, typ discordgo.ChannelType, archiveDuration int) (*discordgo.Channel, error)
JoinThread(threadID string) error
}
type sessionThreadOps struct {
session *discordgo.Session
}
func (o sessionThreadOps) ResolveChannel(channelID string) (*discordgo.Channel, error) {
if o.session == nil {
return nil, fmt.Errorf("discord: session not initialized")
}
if ch, err := o.session.State.Channel(channelID); err == nil && ch != nil {
return ch, nil
}
return o.session.Channel(channelID)
}
func (o sessionThreadOps) StartThread(channelID, messageID, name string, archiveDuration int) (*discordgo.Channel, error) {
if o.session == nil {
return nil, fmt.Errorf("discord: session not initialized")
}
return o.session.MessageThreadStart(channelID, messageID, name, archiveDuration)
}
func (o sessionThreadOps) StartStandaloneThread(channelID, name string, typ discordgo.ChannelType, archiveDuration int) (*discordgo.Channel, error) {
if o.session == nil {
return nil, fmt.Errorf("discord: session not initialized")
}
return o.session.ThreadStart(channelID, name, typ, archiveDuration)
}
func (o sessionThreadOps) JoinThread(threadID string) error {
if o.session == nil {
return fmt.Errorf("discord: session not initialized")
}
return o.session.ThreadJoin(threadID)
}
func isThreadChannelType(t discordgo.ChannelType) bool {
switch t {
case discordgo.ChannelTypeGuildNewsThread,
discordgo.ChannelTypeGuildPublicThread,
discordgo.ChannelTypeGuildPrivateThread:
return true
default:
return false
}
}
func truncateDiscordThreadName(s string, maxRunes int) string {
runes := []rune(s)
if len(runes) <= maxRunes {
return s
}
return string(runes[:maxRunes])
}
func threadNameForMessage(m *discordgo.MessageCreate, botID string) string {
name := stripDiscordMention(m.Content, botID)
name = strings.Join(strings.Fields(strings.ReplaceAll(name, "\n", " ")), " ")
if name == "" && m.Author != nil {
name = "cc " + m.Author.Username
}
if name == "" {
name = "cc session"
}
return truncateDiscordThreadName(name, 90)
}
func freshThreadName(title string) string {
name := strings.Join(strings.Fields(strings.ReplaceAll(title, "\n", " ")), " ")
if name == "" {
name = "cc cron"
}
return truncateDiscordThreadName(name, 90)
}
func standaloneThreadType(parentType discordgo.ChannelType) (discordgo.ChannelType, bool) {
switch parentType {
case discordgo.ChannelTypeGuildText:
return discordgo.ChannelTypeGuildPublicThread, true
case discordgo.ChannelTypeGuildNews:
return discordgo.ChannelTypeGuildNewsThread, true
default:
return 0, false
}
}
func parseDiscordSessionKeyChannelID(sessionKey string) (string, error) {
parts := strings.SplitN(sessionKey, ":", 3)
if len(parts) < 2 || parts[0] != "discord" || parts[1] == "" {
return "", fmt.Errorf("discord: invalid session key %q", sessionKey)
}
return parts[1], nil
}
func resolveSessionKeyForChannel(channelID, userID string, shareSessionInChannel bool, threadIsolation bool, ops discordThreadOps) string {
if !threadIsolation {
return buildSessionKey(channelID, userID, shareSessionInChannel)
}
ch, err := ops.ResolveChannel(channelID)
if err != nil {
slog.Warn("discord: resolve channel for session key failed, falling back", "channel", channelID, "error", err)
return buildSessionKey(channelID, userID, shareSessionInChannel)
}
if isThreadChannelType(ch.Type) {
return buildThreadSessionKey(channelID)
}
return buildSessionKey(channelID, userID, shareSessionInChannel)
}
// resolveParentChannelID returns the ID of the parent guild channel a message
// or interaction belongs to, looking through threads. Returns channelID
// unchanged when it isn't a thread or when ParentID is unavailable.
//
// This is the workspace-binding identity for thread-isolation mode: we want
// `<base_dir>/<parent-channel-name>` to drive auto-bind, not `<thread-name>`.
func resolveParentChannelID(channelID string, ops discordThreadOps) string {
ch, err := ops.ResolveChannel(channelID)
if err != nil {
slog.Debug("discord: resolve channel for parent lookup failed", "channel", channelID, "error", err)
return channelID
}
if isThreadChannelType(ch.Type) && ch.ParentID != "" {
return ch.ParentID
}
return channelID
}
// resolveThreadReplyContext routes a guild message into a Discord thread for
// thread_isolation mode and returns the per-thread session key, the reply
// context, and the parent channel ID.
//
// parentChannelID is the channel the thread lives under (or, for messages
// posted directly into an existing thread, the thread's ParentID). It is
// distinct from the thread itself: it's what callers should stamp onto
// Message.ChannelKey so multi-workspace auto-bind keys by channel name
// rather than thread name. Without this distinction, threads break the
// "channel name → workspace folder" convention because Discord threads
// have their own names that rarely match a workspace directory.
func resolveThreadReplyContext(m *discordgo.MessageCreate, botID string, ops discordThreadOps) (string, replyContext, string, error) {
ch, err := ops.ResolveChannel(m.ChannelID)
if err != nil {
return "", replyContext{}, "", fmt.Errorf("resolve channel %s: %w", m.ChannelID, err)
}
if isThreadChannelType(ch.Type) {
// Message posted directly inside an existing thread. The parent
// channel comes from ch.ParentID; fall back to m.ChannelID only
// if Discord didn't populate it (defensive — discordgo always
// sets ParentID for thread channels).
parentChannelID := ch.ParentID
if parentChannelID == "" {
parentChannelID = m.ChannelID
}
if err := ops.JoinThread(m.ChannelID); err != nil {
slog.Debug("discord: join existing thread failed", "thread", m.ChannelID, "error", err)
}
rc := replyContext{channelID: m.ChannelID, messageID: m.ID, threadID: m.ChannelID}
return buildThreadSessionKey(m.ChannelID), rc, parentChannelID, nil
}
if m.Message != nil && m.Message.Thread != nil && m.Message.Thread.ID != "" {
threadID := m.Message.Thread.ID
if err := ops.JoinThread(threadID); err != nil {
slog.Debug("discord: join attached thread failed", "thread", threadID, "error", err)
}
rc := replyContext{channelID: threadID, messageID: m.ID, threadID: threadID}
return buildThreadSessionKey(threadID), rc, m.ChannelID, nil
}
if m.Flags&discordgo.MessageFlagsHasThread != 0 {
threadID := m.ID
if err := ops.JoinThread(threadID); err != nil {
slog.Debug("discord: join message thread failed", "thread", threadID, "error", err)
}
rc := replyContext{channelID: threadID, messageID: m.ID, threadID: threadID}
return buildThreadSessionKey(threadID), rc, m.ChannelID, nil
}
thread, err := ops.StartThread(m.ChannelID, m.ID, threadNameForMessage(m, botID), 1440)
if err != nil {
return "", replyContext{}, "", fmt.Errorf("start thread for message %s: %w", m.ID, err)
}
if err := ops.JoinThread(thread.ID); err != nil {
slog.Debug("discord: join new thread failed", "thread", thread.ID, "error", err)
}
rc := replyContext{channelID: thread.ID, messageID: m.ID, threadID: thread.ID}
return buildThreadSessionKey(thread.ID), rc, m.ChannelID, nil
}
func resolveCronReplyTarget(sessionKey, title string, ops discordThreadOps) (string, replyContext, error) {
channelID, err := parseDiscordSessionKeyChannelID(sessionKey)
if err != nil {
return "", replyContext{}, err
}
ch, err := ops.ResolveChannel(channelID)
if err != nil {
return "", replyContext{}, fmt.Errorf("resolve channel %s: %w", channelID, err)
}
parentChannelID := channelID
parentType := ch.Type
if isThreadChannelType(ch.Type) {
if ch.ParentID == "" {
return "", replyContext{}, core.ErrNotSupported
}
parent, err := ops.ResolveChannel(ch.ParentID)
if err != nil {
return "", replyContext{}, fmt.Errorf("resolve parent channel %s: %w", ch.ParentID, err)
}
parentChannelID = ch.ParentID
parentType = parent.Type
}
threadType, ok := standaloneThreadType(parentType)
if !ok {
return "", replyContext{}, core.ErrNotSupported
}
thread, err := ops.StartStandaloneThread(parentChannelID, freshThreadName(title), threadType, 1440)
if err != nil {
return "", replyContext{}, fmt.Errorf("start thread in channel %s: %w", parentChannelID, err)
}
if err := ops.JoinThread(thread.ID); err != nil {
slog.Debug("discord: join fresh thread failed", "thread", thread.ID, "error", err)
}
rc := replyContext{channelID: thread.ID, threadID: thread.ID}
return buildThreadSessionKey(thread.ID), rc, nil
}
// RegisterCommands registers bot commands with Discord for the slash command menu.
func (p *Platform) RegisterCommands(commands []core.BotCommandInfo) error {
// Wait for Ready event to ensure appID is populated
select {
case <-p.readyCh:
case <-time.After(15 * time.Second):
return fmt.Errorf("discord: timed out waiting for Ready event")
}
var cmds []*discordgo.ApplicationCommand
for _, c := range commands {
if len(c.Command) > 32 {
slog.Warn("discord: command name > 32 skip " + c.Command)
continue
}
desc := c.Description
if runes := []rune(desc); len(runes) > 100 {
desc = string(runes[:97]) + "..."
}
cmds = append(cmds, &discordgo.ApplicationCommand{
Name: c.Command,
Description: desc,
// A trick to be able to input any args
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Description: "optional args",
Name: "args",
Required: false,
},
},
})
}
// Discord allows max 100 commands per bulk overwrite (guild or global).
if len(cmds) > 100 {
slog.Warn("discord: truncating commands to Discord limit of 100", "total", len(cmds), "dropped", len(cmds)-100)
cmds = cmds[:100]
}
if len(cmds) == 0 {
slog.Debug("discord: no commands to register")
return nil
}
registered, err := p.session.ApplicationCommandBulkOverwrite(p.appID, p.guildID, cmds)
if err != nil {
slog.Error("discord: failed to register slash commands — "+
"make sure the bot was invited with BOTH 'bot' AND 'applications.commands' OAuth2 scopes. "+
"Re-invite URL: https://discord.com/oauth2/authorize?client_id="+p.appID+
"&scope=bot+applications.commands&permissions=2147485696",
"error", err, "guild_id", p.guildID)
return err
}
scope := "global (may take up to 1h to appear — set guild_id for instant)"
if p.guildID != "" {
scope = "guild:" + p.guildID
}
slog.Info("discord: registered slash commands", "count", len(registered), "scope", scope)
return nil
}
func (p *Platform) Start(handler core.MessageHandler) error {
p.handler = handler
session, err := discordgo.New("Bot " + p.token)
if err != nil {
return fmt.Errorf("discord: create session: %w", err)
}
if p.proxyURL != nil {
transport := &http.Transport{Proxy: http.ProxyURL(p.proxyURL)}
session.Client = &http.Client{Transport: transport, Timeout: 60 * time.Second}
session.Dialer = &websocket.Dialer{Proxy: http.ProxyURL(p.proxyURL)}
slog.Info("discord: using proxy", "proxy", p.proxyURL.Host)
}
p.session = session
session.Identify.Intents = discordgo.IntentsGuilds | discordgo.IntentsGuildMessages | discordgo.IntentsDirectMessages | discordgo.IntentMessageContent
session.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) {
p.botID = r.User.ID
p.appID = r.User.ID
slog.Info("discord: connected", "bot", r.User.Username+"#"+r.User.Discriminator)
// Signal readiness before guild role lookups so RegisterCommands
// is not blocked by slow API calls when there are many guilds.
select {
case <-p.readyCh:
default:
close(p.readyCh)
}
for _, g := range r.Guilds {
if g == nil || g.ID == "" || g.Unavailable {
continue
}
p.cacheBotRoleIDForGuild(s, g.ID, g.Roles)
}
})
session.AddHandler(func(s *discordgo.Session, g *discordgo.GuildCreate) {
if g == nil || g.Guild == nil || g.ID == "" || g.Unavailable {
return
}
p.cacheBotRoleIDForGuild(s, g.ID, g.Roles)
})
session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) {
// Deduplicate: Discord gateway may deliver the same event twice
if !rememberDedupID(&p.seenMsgs, m.ID) {
slog.Debug("discord: ignoring duplicate message", "msg_id", m.ID)
return
}
if m.Author.Bot || m.Author.ID == p.botID {
return
}
if core.IsOldMessage(m.Timestamp) {
slog.Debug("discord: ignoring old message after restart", "timestamp", m.Timestamp)
return
}
if !core.AllowList(p.allowFrom, m.Author.ID) {
slog.Debug("discord: message from unauthorized user", "user", m.Author.ID)
return
}
// In guild channels, only respond when the bot is @mentioned (unless group_reply_all).
// Check both user mentions and role mentions (Discord auto-creates a managed role
// for each bot; users may @ the role instead of the user).
botRoleID := p.botRoleIDForGuild(m.GuildID)
if botRoleID == "" && m.GuildID != "" {
p.cacheBotRoleIDForGuild(s, m.GuildID, nil)
botRoleID = p.botRoleIDForGuild(m.GuildID)
}
if m.GuildID != "" && !p.isGroupReplyAllGuild(m.GuildID) {
if !isDiscordBotMention(m, p.botID, botRoleID, p.respondToAtEveryoneAndHere) {
slog.Debug("discord: ignoring guild message without bot mention", "channel", m.ChannelID)
return
}
m.Content = stripDiscordMentionWithRole(m.Content, p.botID, botRoleID)
if m.MentionEveryone {
m.Content = stripEveryoneHere(m.Content)
}
}
slog.Debug("discord: message received", "user", m.Author.Username, "channel", m.ChannelID)
sessionKey := p.makeSessionKey(m.ChannelID, m.Author.ID)
rctx := replyContext{channelID: m.ChannelID, messageID: m.ID}
// channelKey pins workspace binding to the parent channel even when
// thread_isolation rewrites SessionKey to a thread ID. Without it,
// effectiveChannelID() would extract the thread ID from SessionKey
// and multi-workspace auto-bind would try to match `<base_dir>/<thread-name>`,
// which never exists. Empty value falls back to SessionKey extraction
// (the historical, non-isolated behavior).
channelKey := ""
if p.threadIsolation && m.GuildID != "" {
threadSessionKey, threadCtx, parentChannelID, err := resolveThreadReplyContext(m, p.botID, sessionThreadOps{session: p.session})
if err != nil {
slog.Warn("discord: thread isolation setup failed, falling back", "message", m.ID, "channel", m.ChannelID, "error", err)
} else {
sessionKey = threadSessionKey
rctx = threadCtx
channelKey = parentChannelID
}
}
images, files, audio := classifyAttachments(m.Attachments, downloadURL)
if m.Content == "" && len(images) == 0 && len(files) == 0 && audio == nil && m.ReferencedMessage == nil {
return
}
// Prepend the replied-to message's content and images so the agent has context.
if m.ReferencedMessage != nil {
m.Content, images = applyReferencedMessage(m.ReferencedMessage, m.Content, images, downloadURL)
}
msg := &core.Message{
SessionKey: sessionKey, ChannelKey: channelKey, Platform: "discord",
ChannelID: m.ChannelID,
MessageID: m.ID,
UserID: m.Author.ID, UserName: m.Author.Username,
Content: m.Content, Images: images, Files: files, Audio: audio, ReplyCtx: rctx,
}
msg.ChatName, _ = p.ResolveChannelName(m.ChannelID)
p.dispatchMessage(msg)
})
session.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) {
p.handleInteraction(s, i)
})
if err := session.Open(); err != nil {
return fmt.Errorf("discord: open gateway: %w", err)
}
return nil
}
// handleInteraction processes incoming Discord command and button interactions.
func (p *Platform) handleInteraction(s *discordgo.Session, i *discordgo.InteractionCreate) {
if !rememberDedupID(&p.seenInteractions, i.ID) {
slog.Debug("discord: ignoring duplicate interaction", "interaction_id", i.ID, "type", i.Type)
return
}
userID, userName := "", ""
if i.Member != nil && i.Member.User != nil {
userID = i.Member.User.ID
userName = i.Member.User.Username
} else if i.User != nil {
userID = i.User.ID
userName = i.User.Username
}
if !core.AllowList(p.allowFrom, userID) {
slog.Debug("discord: interaction from unauthorized user", "user", userID)
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{
Content: "You are not authorized to use this bot.",
Flags: discordgo.MessageFlagsEphemeral,
},
})
return
}
switch i.Type {
case discordgo.InteractionMessageComponent:
p.handleComponentInteraction(s, i, userID, userName)
return
case discordgo.InteractionApplicationCommand:
default:
return
}
var rctx any
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
}); err != nil {
// Defer must usually happen within ~3s; if it fails (e.g. "Unknown interaction"),
// aborting here drops the command entirely (#258). Fall back to normal channel
// messages — sendInteraction already falls back similarly on edit failures.
slog.Warn("discord: defer interaction failed, continuing with channel replies", "error", err)
channelID := i.ChannelID
var rc replyContext
if ch, chErr := s.Channel(channelID); chErr != nil {
slog.Debug("discord: channel lookup for slash fallback failed", "channel", channelID, "error", chErr)
rc = replyContext{channelID: channelID}
} else {
rc = replyContextForDeferredInteractionFallback(ch, channelID)
}
rctx = rc
} else {
rctx = &interactionReplyCtx{
interaction: i.Interaction,
channelID: i.ChannelID,
}
}
data := i.ApplicationCommandData()
cmdText := reconstructCommand(data)
channelID := i.ChannelID
slog.Debug("discord: slash command", "user", userName, "command", cmdText, "channel", channelID)
ops := sessionThreadOps{session: p.session}
sessionKey := resolveSessionKeyForChannel(channelID, userID, p.shareSessionInChannel, p.threadIsolation, ops)
channelKey := ""
if p.threadIsolation {
channelKey = resolveParentChannelID(channelID, ops)
}
msg := &core.Message{
SessionKey: sessionKey, ChannelKey: channelKey, Platform: "discord",
MessageID: i.ID,
ChannelID: i.ChannelID,
UserID: userID, UserName: userName,
Content: cmdText, ReplyCtx: rctx,
}
msg.ChatName, _ = p.ResolveChannelName(channelID)
p.dispatchMessage(msg)
}
// replyContextForDeferredInteractionFallback builds a replyContext for slash commands
// when InteractionRespond(defer) failed. Thread channels must set threadID so
// sendChannelReply uses ChannelMessageSend instead of ChannelMessageSendReply with an empty ref.
func replyContextForDeferredInteractionFallback(ch *discordgo.Channel, channelID string) replyContext {
if ch == nil {
return replyContext{channelID: channelID}
}
switch ch.Type {
case discordgo.ChannelTypeGuildPublicThread, discordgo.ChannelTypeGuildPrivateThread:
return replyContext{channelID: channelID, threadID: channelID}
default:
return replyContext{channelID: channelID}
}
}
// reconstructCommand converts a Discord interaction back to a text command string
// (e.g. "/config thinking_max_len 200") that the engine can parse.
func reconstructCommand(data discordgo.ApplicationCommandInteractionData) string {
name := data.Name
var parts []string
parts = append(parts, "/"+name)
for _, opt := range data.Options {
switch opt.Type {
case discordgo.ApplicationCommandOptionInteger:
parts = append(parts, fmt.Sprintf("%d", opt.IntValue()))
default:
parts = append(parts, opt.StringValue())
}
}
return strings.Join(parts, " ")
}
func (p *Platform) handleComponentInteraction(s *discordgo.Session, i *discordgo.InteractionCreate, userID, userName string) {
data := i.MessageComponentData()
if !strings.HasPrefix(data.CustomID, "cmd:") {
slog.Debug("discord: unknown component interaction", "custom_id", data.CustomID)
return
}
command := strings.TrimPrefix(data.CustomID, "cmd:")
origText := ""
if i.Message != nil {
origText = i.Message.Content
}
emptyComponents := []discordgo.MessageComponent{}
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseUpdateMessage,
Data: &discordgo.InteractionResponseData{
Content: origText + "\n\n> " + command,
Components: emptyComponents,
},
}); err != nil {
slog.Debug("discord: command component update failed", "error", err)
}
channelID := i.ChannelID
ops := sessionThreadOps{session: p.session}
sessionKey := resolveSessionKeyForChannel(channelID, userID, p.shareSessionInChannel, p.threadIsolation, ops)
channelKey := ""
if p.threadIsolation {
channelKey = resolveParentChannelID(channelID, ops)
}
rc := replyContext{channelID: channelID}
if i.Message != nil {
rc.messageID = i.Message.ID
}
chatName, _ := p.ResolveChannelName(channelID)
p.dispatchMessage(&core.Message{
SessionKey: sessionKey,
ChannelKey: channelKey,
Platform: "discord",
MessageID: i.ID,
UserID: userID,
UserName: userName,
Content: command,
ChatName: chatName,
ReplyCtx: rc,
})
}
func (p *Platform) Reply(ctx context.Context, rctx any, content string) error {
switch rc := rctx.(type) {
case *interactionReplyCtx:
return p.sendInteraction(rc, content)
case replyContext:
return p.sendChannelReply(rc, content)
default:
return fmt.Errorf("discord: invalid reply context type %T", rctx)
}
}
// Send sends a new message (not a reply).
func (p *Platform) Send(ctx context.Context, rctx any, content string) error {
switch rc := rctx.(type) {
case *interactionReplyCtx:
return p.sendInteraction(rc, content)
case replyContext:
return p.sendChannel(rc, content)
default:
return fmt.Errorf("discord: invalid reply context type %T", rctx)
}
}
// sendInteraction delivers a message through the Discord interaction response
// mechanism. The first call edits the deferred "thinking" response; subsequent
// calls create followup messages.
func (p *Platform) sendInteraction(ictx *interactionReplyCtx, content string) error {
chunks := core.SplitMessageCodeFenceAware(wrapTablesInCodeBlocks(content), maxDiscordLen)
for _, chunk := range chunks {
ictx.mu.Lock()
first := !ictx.firstDone
if first {
ictx.firstDone = true
}
ictx.mu.Unlock()
var err error
if first {
c := chunk
_, err = p.session.InteractionResponseEdit(ictx.interaction, &discordgo.WebhookEdit{Content: &c})
} else {
_, err = p.session.FollowupMessageCreate(ictx.interaction, true, &discordgo.WebhookParams{Content: chunk})
}
if err != nil {
slog.Warn("discord: interaction response failed, falling back to channel message", "error", err)
_, err = p.session.ChannelMessageSend(ictx.channelID, chunk)
if err != nil {
return fmt.Errorf("discord: send fallback: %w", err)
}
}
}
return nil
}
func (p *Platform) sendChannelReply(rc replyContext, content string) error {
chunks := core.SplitMessageCodeFenceAware(wrapTablesInCodeBlocks(content), maxDiscordLen)
for _, chunk := range chunks {
var err error
if rc.useThreadChannel() || rc.messageID == "" {
_, err = p.session.ChannelMessageSend(rc.targetChannelID(), chunk)
} else {
ref := &discordgo.MessageReference{MessageID: rc.messageID}
_, err = p.session.ChannelMessageSendReply(rc.channelID, chunk, ref)
}
if err != nil {
return fmt.Errorf("discord: send: %w", err)
}
}
return nil
}
func (p *Platform) sendChannel(rc replyContext, content string) error {
chunks := core.SplitMessageCodeFenceAware(wrapTablesInCodeBlocks(content), maxDiscordLen)
for _, chunk := range chunks {
_, err := p.session.ChannelMessageSend(rc.targetChannelID(), chunk)
if err != nil {
return fmt.Errorf("discord: send: %w", err)
}
}
return nil
}
// SendImage sends an image to the channel or interaction.
// Implements core.ImageSender.
func (p *Platform) SendImage(ctx context.Context, rctx any, img core.ImageAttachment) error {
name := img.FileName
if name == "" {
name = "image.png"
}
newFile := func() *discordgo.File {
return &discordgo.File{
Name: name,
ContentType: img.MimeType,
Reader: bytes.NewReader(img.Data),
}
}
switch rc := rctx.(type) {
case *interactionReplyCtx:
rc.mu.Lock()
first := !rc.firstDone
if first {
rc.firstDone = true
}
rc.mu.Unlock()
var err error
if first {
_, err = p.session.InteractionResponseEdit(rc.interaction, &discordgo.WebhookEdit{
Files: []*discordgo.File{newFile()},
})
} else {
_, err = p.session.FollowupMessageCreate(rc.interaction, true, &discordgo.WebhookParams{
Files: []*discordgo.File{newFile()},
})
}
if err != nil {
slog.Warn("discord: interaction image failed, falling back to channel message", "error", err)
_, err = p.session.ChannelMessageSendComplex(rc.channelID, &discordgo.MessageSend{
Files: []*discordgo.File{newFile()},
})
if err != nil {
return fmt.Errorf("discord: send image fallback: %w", err)
}
}
return nil
case replyContext:
_, err := p.session.ChannelMessageSendComplex(rc.targetChannelID(), &discordgo.MessageSend{
Files: []*discordgo.File{newFile()},
})
if err != nil {
return fmt.Errorf("discord: send image: %w", err)
}
return nil
default:
return fmt.Errorf("discord: SendImage: invalid reply context type %T", rctx)
}
}
func (p *Platform) SendFile(ctx context.Context, rctx any, file core.FileAttachment) error {
name := file.FileName
if name == "" {
name = "attachment"
}
newFile := func() *discordgo.File {
return &discordgo.File{
Name: name,
ContentType: file.MimeType,
Reader: bytes.NewReader(file.Data),
}
}
switch rc := rctx.(type) {
case *interactionReplyCtx:
rc.mu.Lock()
first := !rc.firstDone
if first {
rc.firstDone = true
}
rc.mu.Unlock()
var err error
if first {
_, err = p.session.InteractionResponseEdit(rc.interaction, &discordgo.WebhookEdit{
Files: []*discordgo.File{newFile()},
})
} else {
_, err = p.session.FollowupMessageCreate(rc.interaction, true, &discordgo.WebhookParams{
Files: []*discordgo.File{newFile()},
})
}
if err != nil {
slog.Warn("discord: interaction file failed, falling back to channel message", "error", err)
_, err = p.session.ChannelMessageSendComplex(rc.channelID, &discordgo.MessageSend{
Files: []*discordgo.File{newFile()},
})
if err != nil {
return fmt.Errorf("discord: send file fallback: %w", err)
}
}
return nil
case replyContext:
_, err := p.session.ChannelMessageSendComplex(rc.targetChannelID(), &discordgo.MessageSend{
Files: []*discordgo.File{newFile()},
})
if err != nil {
return fmt.Errorf("discord: send file: %w", err)
}
return nil
default:
return fmt.Errorf("discord: SendFile: invalid reply context type %T", rctx)
}
}
func buildDiscordActionRows(rows [][]core.ButtonOption) []discordgo.MessageComponent {
components := make([]discordgo.MessageComponent, 0, len(rows))
for _, row := range rows {
if len(row) == 0 {
continue
}
buttons := make([]discordgo.MessageComponent, 0, len(row))
for idx, btn := range row {
style := discordgo.SecondaryButton
switch idx {
case 0:
style = discordgo.SuccessButton
case 1:
style = discordgo.DangerButton
case 2:
style = discordgo.PrimaryButton
}
buttons = append(buttons, discordgo.Button{
Label: btn.Text,
Style: style,
CustomID: btn.Data,
})
}
components = append(components, discordgo.ActionsRow{Components: buttons})
}
return components
}
func (p *Platform) SendWithButtons(ctx context.Context, rctx any, content string, buttons [][]core.ButtonOption) error {
rc, ok := rctx.(*interactionReplyCtx)
if !ok {
return core.ErrNotSupported
}
if len(buttons) == 0 {
return fmt.Errorf("discord: no buttons provided")
}
components := buildDiscordActionRows(buttons)
if len(components) == 0 {
return fmt.Errorf("discord: no buttons provided")
}
if err := p.sendInteraction(rc, content); err != nil {
return err
}
_, err := p.session.FollowupMessageCreate(rc.interaction, true, &discordgo.WebhookParams{
Content: content,
Components: components,
})
if err != nil {
return fmt.Errorf("discord: send button followup: %w", err)
}
return nil
}
func (p *progressPlatform) ProgressUpdateInterval() time.Duration {
return 2 * time.Second
}
var _ core.ImageSender = (*Platform)(nil)
var _ core.FileSender = (*Platform)(nil)
var _ core.InlineButtonSender = (*Platform)(nil)
var _ core.ProgressStyleProvider = (*progressPlatform)(nil)
var _ core.ProgressCardPayloadSupport = (*progressPlatform)(nil)
var _ core.ProgressUpdateThrottler = (*progressPlatform)(nil)
func (p *Platform) ReconstructReplyCtx(sessionKey string) (any, error) {
// discord:{channelID}:{userID} or discord:{threadID}
parts := strings.SplitN(sessionKey, ":", 3)
if len(parts) < 2 || parts[0] != "discord" {
return nil, fmt.Errorf("discord: invalid session key %q", sessionKey)
}
rc := replyContext{channelID: parts[1]}
if len(parts) == 2 {
rc.threadID = parts[1]
}
return rc, nil
}
func (p *Platform) ResolveCronReplyTarget(sessionKey string, title string) (string, any, error) {
if !p.threadIsolation {
return "", nil, core.ErrNotSupported
}
resolvedSessionKey, rc, err := resolveCronReplyTarget(sessionKey, title, sessionThreadOps{session: p.session})
if err != nil {
return "", nil, err
}
return resolvedSessionKey, rc, nil
}
// discordPreviewHandle stores the IDs needed to edit or delete a preview message.
type discordPreviewHandle struct {
channelID string
messageID string
}
// SendPreviewStart sends a new message and returns a handle for subsequent edits.
func (p *Platform) SendPreviewStart(ctx context.Context, rctx any, content string) (any, error) {
var channelID string
switch rc := rctx.(type) {
case replyContext:
channelID = rc.targetChannelID()
case *interactionReplyCtx:
channelID = rc.channelID
default:
return nil, fmt.Errorf("discord: invalid reply context type %T", rctx)
}
msg := buildDiscordPreviewMessage(content)
sent, err := p.session.ChannelMessageSendComplex(channelID, msg)
if err != nil {
return nil, fmt.Errorf("discord: send preview: %w", err)
}
return &discordPreviewHandle{channelID: channelID, messageID: sent.ID}, nil
}
// UpdateMessage edits an existing message identified by previewHandle.
func (p *Platform) UpdateMessage(ctx context.Context, previewHandle any, content string) error {
h, ok := previewHandle.(*discordPreviewHandle)
if !ok {
return fmt.Errorf("discord: invalid preview handle type %T", previewHandle)
}
_, err := p.session.ChannelMessageEditComplex(buildDiscordPreviewEdit(h.channelID, h.messageID, content))
if err != nil {
return fmt.Errorf("discord: edit message: %w", err)
}
return nil
}
// DeletePreviewMessage removes the preview message so the final response can
// be sent as a fresh message (avoids notification confusion).
func (p *Platform) DeletePreviewMessage(ctx context.Context, previewHandle any) error {
h, ok := previewHandle.(*discordPreviewHandle)
if !ok {
return fmt.Errorf("discord: invalid preview handle type %T", previewHandle)
}
return p.session.ChannelMessageDelete(h.channelID, h.messageID)
}
// StartTyping sends a typing indicator and repeats every 8 seconds
// (Discord typing status lasts ~10s) until the returned stop function is called.
func (p *Platform) StartTyping(ctx context.Context, rctx any) (stop func()) {
rc, ok := rctx.(replyContext)
if !ok {
return func() {}
}
channelID := rc.channelID
if rc.targetChannelID() != "" {
channelID = rc.targetChannelID()
}
if channelID == "" {
return func() {}
}
_ = p.session.ChannelTyping(channelID)
done := make(chan struct{})
go func() {
ticker := time.NewTicker(8 * time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ctx.Done():
return
case <-ticker.C:
_ = p.session.ChannelTyping(channelID)
}
}
}()
return func() { close(done) }
}
func (p *Platform) ResolveChannelName(channelID string) (string, error) {
if cached, ok := p.channelNameCache.Load(channelID); ok {
return cached.(string), nil
}
ch, err := p.session.Channel(channelID)
if err != nil {
slog.Debug("discord: resolve channel name failed", "channel", channelID, "error", err)
return channelID, err
}
name := ch.Name
slog.Debug("discord: resolve channel name", "channel", channelID, "name", ch.Name)
if name == "" {
return channelID, nil
}
p.channelNameCache.Store(channelID, name)
return name, nil
}
func (p *Platform) Stop() error {
if p.session != nil {
return p.session.Close()
}
return nil
}
// stripDiscordMention removes <@botID> and <@!botID> (nick mention) from text.
func stripDiscordMention(text, botID string) string {
return stripDiscordMentionWithRole(text, botID, "")
}
func stripDiscordMentionWithRole(text, botID string, botRoleID string) string {
text = strings.ReplaceAll(text, "<@!"+botID+">", "")
text = strings.ReplaceAll(text, "<@"+botID+">", "")
if botRoleID != "" {
text = strings.ReplaceAll(text, "<@&"+botRoleID+">", "")
}
return strings.TrimSpace(text)
}
// stripEveryoneHere removes @everyone and @here from text.
func stripEveryoneHere(text string) string {
text = strings.ReplaceAll(text, "@everyone", "")
text = strings.ReplaceAll(text, "@here", "")
return strings.TrimSpace(text)
}
// isDiscordBotMention checks if the message mentions the bot by user ID or managed role ID.
func isDiscordBotMention(m *discordgo.MessageCreate, botID string, botRoleID string, respondToAtEveryoneAndHere bool) bool {
if respondToAtEveryoneAndHere && m.MentionEveryone {
return true
}
for _, u := range m.Mentions {
if u != nil && u.ID == botID {
return true
}
}
if strings.Contains(m.Content, "<@"+botID+">") || strings.Contains(m.Content, "<@!"+botID+">") {
return true
}
for _, roleID := range m.MentionRoles {
if roleID == botRoleID && roleID != "" {
return true
}
}
return botRoleID != "" && strings.Contains(m.Content, "<@&"+botRoleID+">")
}
func (p *Platform) botRoleIDForGuild(guildID string) string {
if guildID == "" {
return ""
}
v, ok := p.botRoleIDs.Load(guildID)
if !ok {
return ""
}
roleID, _ := v.(string)
return roleID
}
func (p *Platform) cacheBotRoleIDForGuild(s *discordgo.Session, guildID string, guildRoles []*discordgo.Role) {
if s == nil || guildID == "" || p.botID == "" {
return
}
roleID, err := p.resolveBotRoleIDForGuild(s, guildID, guildRoles)
if err != nil {
slog.Debug("discord: resolve bot managed role failed", "guild", guildID, "error", err)
return
}
if roleID == "" {
return
}
p.botRoleIDs.Store(guildID, roleID)
}
func (p *Platform) resolveBotRoleIDForGuild(s *discordgo.Session, guildID string, guildRoles []*discordgo.Role) (string, error) {
member, err := s.GuildMember(guildID, p.botID)
if err != nil {
return "", fmt.Errorf("fetch bot member: %w", err)
}
if member == nil || len(member.Roles) == 0 {
return "", nil
}
memberRoleSet := make(map[string]struct{}, len(member.Roles))
for _, roleID := range member.Roles {
memberRoleSet[roleID] = struct{}{}
}
roles := guildRoles
if len(roles) == 0 {
roles, err = s.GuildRoles(guildID)
if err != nil {
return "", fmt.Errorf("fetch guild roles: %w", err)
}
}
for _, role := range roles {
if role == nil {
continue
}
if _, ok := memberRoleSet[role.ID]; !ok {
continue
}
if role.Managed {
return role.ID, nil
}
}
return "", nil
}
// classifyAttachments downloads and sorts Discord message attachments into
// images, files, and a single voice/audio attachment based on ContentType.
// Attachments whose ContentType is empty fall back to width/height for
// image detection; anything unrecognized is treated as a generic file so
// PDFs, documents, archives, etc. are not silently dropped. If multiple
// audio attachments appear only the last successful one is kept.
func classifyAttachments(atts []*discordgo.MessageAttachment, download func(string) ([]byte, error)) (images []core.ImageAttachment, files []core.FileAttachment, audio *core.AudioAttachment) {
for _, att := range atts {
if att == nil {
continue
}
ct := strings.ToLower(att.ContentType)
switch {
case strings.HasPrefix(ct, "audio/"):
data, err := download(att.URL)
if err != nil {
slog.Error("discord: download audio failed", "url", att.URL, "error", err)
continue
}
format := "ogg"
if parts := strings.SplitN(ct, "/", 2); len(parts) == 2 {
format = parts[1]
}
audio = &core.AudioAttachment{MimeType: ct, Data: data, Format: format}
case strings.HasPrefix(ct, "image/"), ct == "" && att.Width > 0 && att.Height > 0:
data, err := download(att.URL)
if err != nil {
slog.Error("discord: download image failed", "url", att.URL, "file_name", att.Filename, "error", err)
continue
}
images = append(images, core.ImageAttachment{MimeType: att.ContentType, Data: data, FileName: att.Filename})
default:
data, err := download(att.URL)
if err != nil {
slog.Error("discord: download file failed", "url", att.URL, "file_name", att.Filename, "error", err)
continue
}
files = append(files, core.FileAttachment{MimeType: att.ContentType, Data: data, FileName: att.Filename})
}
}
return images, files, audio
}
const maxDownloadBytes = 50 << 20 // 50 MiB
func downloadURL(u string) ([]byte, error) {
resp, err := core.HTTPClient.Get(u)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("download %s: status %d", u, resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, maxDownloadBytes+1))
}
// applyReferencedMessage prepends the replied-to message's author and content
// to content and prepends any images from the referenced message's attachments.
// Image attachments (width > 0) are downloaded via download and prepended so the
// agent sees them before the current message's own images.
func applyReferencedMessage(ref *discordgo.Message, content string, images []core.ImageAttachment, download func(string) ([]byte, error)) (string, []core.ImageAttachment) {
author := ""
if ref.Author != nil {
author = ref.Author.Username
}
content = "[replying to " + author + ": " + ref.Content + "]\n" + content
for _, att := range ref.Attachments {
if att.Width > 0 && att.Height > 0 {
data, err := download(att.URL)
if err != nil {
slog.Error("discord: download referenced attachment failed", "url", att.URL, "error", err)
continue
}
images = append([]core.ImageAttachment{{
MimeType: att.ContentType, Data: data, FileName: att.Filename,
}}, images...)
}
}
return content, images
}