Files
2026-07-10 22:25:45 +07:00

604 lines
17 KiB
Go

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
}