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 // `/` to drive auto-bind, not ``. 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 `/`, // 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 }