mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 16:14:57 +00:00
604 lines
17 KiB
Go
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
|
|
}
|