package discord import ( "context" "fmt" "log/slog" "path/filepath" "strings" "time" "github.com/bwmarrin/discordgo" "github.com/nextlevelbuilder/goclaw/internal/audio" "github.com/nextlevelbuilder/goclaw/internal/bus" "github.com/nextlevelbuilder/goclaw/internal/channels" "github.com/nextlevelbuilder/goclaw/internal/channels/media" "github.com/nextlevelbuilder/goclaw/internal/channels/typing" "github.com/nextlevelbuilder/goclaw/internal/store" "github.com/nextlevelbuilder/goclaw/internal/systemmessages" "github.com/nextlevelbuilder/goclaw/internal/tools" ) // handleMessage processes incoming Discord messages. func (c *Channel) handleMessage(_ *discordgo.Session, m *discordgo.MessageCreate) { ctx := context.Background() ctx = store.WithTenantID(ctx, c.TenantID()) // Ignore bot's own messages if m.Author == nil || m.Author.ID == c.botUserID { return } // Ignore bot messages if m.Author.Bot { return } senderID := m.Author.ID displayName := resolveDisplayName(m) authorLabel := discordAuthorLabel(m.Author, m.Member) channelID := m.ChannelID isDM := m.GuildID == "" // DM/Group policy check peerKind := "group" if isDM { peerKind = "direct" } // Pre-compute mention flag for groups so policy gating can suppress // pairing replies when the bot was not addressed. mentioned := false if !isDM { for _, u := range m.Mentions { if u.ID == c.botUserID { mentioned = true break } } if !mentioned && m.ReferencedMessage != nil && m.ReferencedMessage.Author != nil && m.ReferencedMessage.Author.ID == c.botUserID { mentioned = true } } if isDM { if !c.checkDMPolicy(ctx, senderID, channelID) { return } } else { if !c.checkGroupPolicy(ctx, senderID, channelID, mentioned) { slog.Debug("discord group message rejected by policy", "user_id", senderID, "username", displayName, ) return } } // Handle bot commands (writer management, etc.) before further processing. if c.tryHandleCommand(m) { return } // Build content content := m.Content // Build reply context if replying to another message. if m.ReferencedMessage != nil { author := "unknown" if m.ReferencedMessage.Author != nil { author = discordAuthorLabel(m.ReferencedMessage.Author, m.ReferencedMessage.Member) } body := channels.Truncate(m.ReferencedMessage.Content, 500) replyCtx := fmt.Sprintf("[Replying to %s]\n%s\n[/Replying]", author, body) if content != "" { content = replyCtx + "\n\n" + content } else { content = replyCtx } } // Resolve media attachments (download files, classify types) maxBytes := c.config.MediaMaxBytes if maxBytes <= 0 { maxBytes = defaultMediaMaxBytes } mediaList := resolveMedia(m.Attachments, maxBytes) // Download media from replied-to message and merge (reply first, current second). if m.ReferencedMessage != nil && len(m.ReferencedMessage.Attachments) > 0 { replyMedia := resolveMedia(m.ReferencedMessage.Attachments, maxBytes) for i := range replyMedia { replyMedia[i].FromReply = true } mediaList = append(replyMedia, mediaList...) } // Process media: STT, document extraction, build tags var mediaFiles []bus.MediaFile if len(mediaList) > 0 { var extraContent strings.Builder for i := range mediaList { mi := &mediaList[i] switch mi.Type { case media.TypeAudio, media.TypeVoice: var transcript string var sttErr error if c.audioMgr != nil { sttCtx, cancel := context.WithTimeout(ctx, 10*time.Second) res, err := c.audioMgr.Transcribe(sttCtx, audio.STTInput{FilePath: mi.FilePath, MimeType: "audio/ogg"}, audio.STTOptions{}) cancel() if err == nil && res != nil { transcript = res.Text } else { sttErr = err } } if sttErr != nil { slog.Warn("discord: STT transcription failed", "type", mi.Type, "error", sttErr, ) } else { mi.Transcript = transcript } case media.TypeDocument: if mi.FileName != "" && mi.FilePath != "" { docContent, err := media.ExtractDocumentContent(mi.FilePath, mi.FileName) if err != nil { slog.Warn("discord: document extraction failed", "file", mi.FileName, "error", err) } else if docContent != "" { extraContent.WriteString("\n\n" + docContent) } } } if mi.FilePath != "" { mediaFiles = append(mediaFiles, bus.MediaFile{ Path: mi.FilePath, MimeType: mi.ContentType, Filename: mi.FileName, }) } } // Build media tags AFTER processing so transcript fields are populated. mediaTags := media.BuildMediaTags(mediaList) if mediaTags != "" { if content != "" { content = mediaTags + "\n\n" + content } else { content = mediaTags } } if extraContent.String() != "" { content += extraContent.String() } } if content == "" { content = "[empty message]" } // Mention gating: in groups, only respond when bot is @mentioned (default true). // When not mentioned, record message to pending history for later context. // `mentioned` was pre-computed above for policy gating. if peerKind == "group" && c.RequireMention() { if !mentioned { // Collect media file paths for group history context. var mediaPaths []string for _, mf := range mediaFiles { if mf.Path != "" { mediaPaths = append(mediaPaths, mf.Path) } } parentHistoryKey := c.parentHistoryKeyForChannel(ctx, channelID) c.GroupHistory().Record(channelID, channels.HistoryEntry{ Sender: authorLabel, SenderID: senderID, Body: content, ParentHistoryKey: parentHistoryKey, Media: mediaPaths, Timestamp: m.Timestamp, MessageID: m.ID, }, c.HistoryLimit()) // Collect contact even when bot is not mentioned (cache prevents DB spam). if cc := c.ContactCollector(); cc != nil { cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, displayName, m.Author.Username, "group", "user", "", "") cc.EnsureContactWithMetadata(ctx, c.Type(), c.Name(), channelID, "", c.resolveCachedChannelTitle(channelID), "", "group", "group", "", "", c.cachedContactMetadata(channelID)) } slog.Debug("discord group message recorded (no mention)", "channel_id", channelID, "user_id", senderID, "username", displayName, ) return } } slog.Debug("discord message received", "sender_id", senderID, "channel_id", channelID, "is_dm", isDM, "preview", channels.Truncate(content, 50), ) // Send typing indicator with keepalive + TTL safety net. // Discord typing expires after 10s, so keepalive every 9s. // TTL auto-stops after 60s to prevent stuck indicators. typingCtrl := typing.New(typing.Options{ MaxDuration: 60 * time.Second, KeepaliveInterval: 9 * time.Second, StartFn: func() error { return c.session.ChannelTyping(channelID) }, }) // Stop previous typing controller for this channel (if any) if prev, ok := c.typingCtrls.Load(channelID); ok { prev.(*typing.Controller).Stop() } c.typingCtrls.Store(channelID, typingCtrl) typingCtrl.Start() // Send placeholder "Thinking..." message. // Key by inbound message ID (not channel ID) to avoid race conditions // when multiple messages arrive in the same channel concurrently. placeholder, err := c.session.ChannelMessageSend(channelID, "Thinking...") if err == nil { c.placeholders.Store(m.ID, placeholder.ID) } // Strip bot @mention from content — it's just the trigger, not meaningful. content = strings.ReplaceAll(content, "<@"+c.botUserID+">", "") content = strings.TrimSpace(content) threadBackfill := threadBackfillResult{} if peerKind == "group" && mentioned { threadBackfill = c.backfillThreadHistory(ctx, m, maxBytes) if len(threadBackfill.Media) > 0 { mediaFiles = append(threadBackfill.Media, mediaFiles...) } } hasThreadBackfill := threadBackfill.Context != "" || len(threadBackfill.Media) > 0 // Build final content with group context. finalContent := content if peerKind == "group" { annotated := fmt.Sprintf("[From: %s (<@%s>)]\n%s", displayName, senderID, content) if threadBackfill.Context != "" { annotated = threadBackfill.Context + "\n\n" + annotated } if c.HistoryLimit() > 0 && !hasThreadBackfill { finalContent = c.GroupHistory().BuildContext(channelID, annotated, c.HistoryLimit()) } else { finalContent = annotated } // Collect media from pending history entries (sent before this @mention). // Original filename not retained by CollectMedia; use disk basename so // persistMedia's sanitizer gets a meaningful stem instead of UUID fallback. if !hasThreadBackfill { histMediaPaths := c.GroupHistory().CollectMedia(channelID) for _, p := range histMediaPaths { mediaFiles = append(mediaFiles, bus.MediaFile{Path: p, Filename: filepath.Base(p)}) } } } metadata := map[string]string{ "message_id": m.ID, "user_id": senderID, "username": m.Author.Username, "display_name": channels.SanitizeDisplayName(displayName), "guild_id": m.GuildID, "channel_id": channelID, "is_dm": fmt.Sprintf("%t", isDM), "placeholder_key": m.ID, // keyed by inbound message ID for placeholder lookup } if !isDM { if title := c.resolveCachedGroupDisplayTitle(channelID); title != "" { metadata[tools.MetaChatTitle] = title } } targetAgentID := c.targetAgentID(mediaList) // Collect contact for processed messages (DM + group-mentioned). if cc := c.ContactCollector(); cc != nil { cc.EnsureContact(ctx, c.Type(), c.Name(), senderID, senderID, displayName, m.Author.Username, peerKind, "user", "", "") if peerKind == "group" { cc.EnsureContactWithMetadata(ctx, c.Type(), c.Name(), channelID, "", c.resolveCachedChannelTitle(channelID), "", "group", "group", "", "", c.cachedContactMetadata(channelID)) } } // Publish directly to bus (to preserve MediaFile MIME types) c.Bus().PublishInbound(bus.InboundMessage{ Channel: c.Name(), SenderID: senderID, ChatID: channelID, Content: finalContent, Media: mediaFiles, PeerKind: peerKind, UserID: senderID, AgentID: targetAgentID, TenantID: c.TenantID(), Metadata: metadata, }) // Clear pending history after sending to agent. if peerKind == "group" { c.GroupHistory().Clear(channelID) } } func (c *Channel) targetAgentID(mediaList []media.MediaInfo) string { targetAgentID := c.AgentID() if c.config.VoiceAgentID == "" { return targetAgentID } for _, mi := range mediaList { if mi.Type == media.TypeAudio || mi.Type == media.TypeVoice { slog.Debug("discord: routing voice inbound to speaking agent", "agent_id", c.config.VoiceAgentID, "media_type", mi.Type, ) return c.config.VoiceAgentID } } return targetAgentID } // checkGroupPolicy evaluates the group policy for a sender, with pairing support. // When RequireMention is enabled, pairing replies only fire if the bot was // explicitly addressed — otherwise the bot stays silent in the channel. func (c *Channel) checkGroupPolicy(ctx context.Context, senderID, channelID string, mentioned bool) bool { result := c.CheckGroupPolicy(ctx, senderID, channelID, c.config.GroupPolicy) switch result { case channels.PolicyAllow: return true case channels.PolicyNeedsPairing: if c.RequireMention() && !mentioned { return false } groupSenderID := fmt.Sprintf("group:%s", channelID) c.sendPairingReply(ctx, groupSenderID, channelID) return false default: return false } } // checkDMPolicy evaluates the DM policy for a sender, handling pairing flow. func (c *Channel) checkDMPolicy(ctx context.Context, senderID, channelID string) bool { result := c.CheckDMPolicy(ctx, senderID, c.config.DMPolicy) switch result { case channels.PolicyAllow: return true case channels.PolicyNeedsPairing: c.sendPairingReply(ctx, senderID, channelID) return false default: slog.Debug("discord DM rejected by policy", "sender_id", senderID, "policy", c.config.DMPolicy) return false } } // sendPairingReply sends a pairing code to the user via DM. func (c *Channel) sendPairingReply(ctx context.Context, senderID, channelID string) { ps := c.PairingService() if ps == nil { return } if !c.CanSendPairingNotif(senderID, pairingDebounceTime) { return } code, err := ps.RequestPairing(ctx, senderID, c.Name(), channelID, "default", nil) if err != nil { slog.Debug("discord pairing request failed", "sender_id", senderID, "error", err) return } replyText := c.SystemMessage("", systemmessages.KeyPairingAccountRequired, systemmessages.Vars{ "platform": "Discord", "sender_id": senderID, "code": code, }) if _, err := c.session.ChannelMessageSend(channelID, replyText); err != nil { slog.Warn("failed to send discord pairing reply", "error", err) } else { c.MarkPairingNotifSent(senderID) slog.Info("discord pairing reply sent", "sender_id", senderID, "code", code) } } // resolveDisplayName returns the best available display name for a Discord message author. // Priority: server nickname > global display name > username. func resolveDisplayName(m *discordgo.MessageCreate) string { if m == nil { return "" } return discordDisplayName(m.Author, m.Member) } func discordAuthorLabel(user *discordgo.User, member *discordgo.Member) string { display := discordDisplayName(user, member) if display == "" { display = "unknown" } handle := discordHandle(user) if handle == "" && user != nil { handle = user.Username } if user == nil || user.ID == "" { if handle == "" { return display } return fmt.Sprintf("%s (@%s)", display, handle) } if handle == "" { return fmt.Sprintf("%s (id: %s)", display, user.ID) } return fmt.Sprintf("%s (@%s, id: %s)", display, handle, user.ID) } func discordDisplayName(user *discordgo.User, member *discordgo.Member) string { if member != nil { if member.Nick != "" { return member.Nick } if member.User != nil { if member.User.GlobalName != "" { return member.User.GlobalName } if member.User.Username != "" { return member.User.Username } } } if user == nil { return "" } if user.GlobalName != "" { return user.GlobalName } return user.Username } func discordHandle(user *discordgo.User) string { if user == nil { return "" } handle := strings.TrimSpace(user.Username) if handle == "" { return "" } if user.Discriminator != "" && user.Discriminator != "0" { return handle + "#" + user.Discriminator } return handle } func (c *Channel) resolveCachedChannelTitle(channelID string) string { if c == nil || c.session == nil || c.session.State == nil || channelID == "" { return "" } ch, err := c.session.State.Channel(channelID) if err != nil || ch == nil { return "" } return channels.SanitizeDisplayName(ch.Name) } func (c *Channel) cachedContactMetadata(channelID string) map[string]string { if c == nil || c.session == nil || c.session.State == nil || channelID == "" { return nil } ch, err := c.session.State.Channel(channelID) if err != nil || ch == nil { return nil } return discordContactMetadata(ch, func(id string) *discordgo.Channel { parent, _ := c.session.State.Channel(id) return parent }) } func (c *Channel) ResolveGroupTitle(_ context.Context, channelID string) (string, error) { if title := c.resolveCachedChannelTitle(channelID); title != "" { return title, nil } return "", fmt.Errorf("discord channel title not cached") } func (c *Channel) ResolveGroupTitles(ctx context.Context, channelIDs []string) (map[string]string, error) { if c == nil || c.session == nil || len(channelIDs) == 0 { return map[string]string{}, nil } remaining := make(map[string]struct{}, len(channelIDs)) titles := make(map[string]string, len(channelIDs)) for _, id := range channelIDs { if id == "" { continue } if title := c.resolveCachedChannelTitle(id); title != "" { titles[id] = title continue } remaining[id] = struct{}{} } if len(remaining) == 0 { return titles, nil } var guildIDs []string if c.session.State != nil { c.session.State.RLock() guildIDs = make([]string, 0, len(c.session.State.Guilds)) for _, guild := range c.session.State.Guilds { if guild != nil && guild.ID != "" { guildIDs = append(guildIDs, guild.ID) } } c.session.State.RUnlock() } for _, guildID := range guildIDs { select { case <-ctx.Done(): return titles, nil default: } lookupCtx, cancel := context.WithTimeout(ctx, 750*time.Millisecond) guildChannels, err := c.session.GuildChannels(guildID, discordgo.WithContext(lookupCtx)) cancel() if err != nil { slog.Debug("discord channel title batch lookup failed", "guild_id", guildID, "error", err) continue } for _, ch := range guildChannels { if ch == nil { continue } if _, ok := remaining[ch.ID]; !ok { continue } if title := channels.SanitizeDisplayName(ch.Name); title != "" { titles[ch.ID] = title delete(remaining, ch.ID) } } if len(remaining) == 0 { break } } for id := range remaining { select { case <-ctx.Done(): return titles, nil default: } lookupCtx, cancel := context.WithTimeout(ctx, 750*time.Millisecond) ch, err := c.session.Channel(id, discordgo.WithContext(lookupCtx)) cancel() if err != nil { slog.Debug("discord channel title direct lookup failed", "channel_id", id, "error", err) continue } if ch == nil { continue } if title := channels.SanitizeDisplayName(ch.Name); title != "" { titles[id] = title } } return titles, nil }