package events import ( "fmt" "log" "slices" "strings" "sync" "time" "velox-bot/internal/db/services" "velox-bot/internal/modlog" "github.com/bwmarrin/discordgo" ) type cachedMessage struct { Content string AuthorID string } var messageCache sync.Map // map[messageID]cachedMessage func HandleMessageUpdateLog(s *discordgo.Session, m *discordgo.MessageUpdate, svc *services.Services) { if s == nil || m == nil || m.Message == nil || m.GuildID == "" || svc == nil { return } if m.Author != nil && m.Author.Bot { return } before := "" if m.BeforeUpdate != nil { before = strings.TrimSpace(m.BeforeUpdate.Content) } if before == "" { if prev, ok := getCachedMessage(m.ID); ok { before = strings.TrimSpace(prev.Content) } } after := strings.TrimSpace(m.Content) if before == after { return } jumpURL := fmt.Sprintf("https://discord.com/channels/%s/%s/%s", m.GuildID, m.ChannelID, m.ID) authorMention := "Unknown" if m.Author != nil { authorMention = m.Author.Mention() } embed := &discordgo.MessageEmbed{ Title: "Message Edited", Color: 0xF1C40F, Description: fmt.Sprintf("Author: %s\nChannel: <#%s>\n[Jump to message](%s)", authorMention, m.ChannelID, jumpURL), Fields: []*discordgo.MessageEmbedField{ {Name: "Before", Value: truncateOrPlaceholder(before), Inline: false}, {Name: "After", Value: truncateOrPlaceholder(after), Inline: false}, }, Timestamp: time.Now().Format(time.RFC3339), } if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { log.Printf("log: failed to send message edit log: %v", err) } cacheMessage(m.ID, after, authorIDOrEmpty(m.Author)) } func HandleMessageDeleteLog(s *discordgo.Session, m *discordgo.MessageDelete, svc *services.Services) { if s == nil || m == nil || m.Message == nil || m.GuildID == "" || svc == nil { return } authorID := "" authorLabel := "Unknown" content := "" if m.BeforeDelete != nil { if m.BeforeDelete.Author != nil { authorID = m.BeforeDelete.Author.ID authorLabel = m.BeforeDelete.Author.Mention() } content = strings.TrimSpace(m.BeforeDelete.Content) } if content == "" { if prev, ok := getCachedMessage(m.ID); ok { content = strings.TrimSpace(prev.Content) if authorID == "" && prev.AuthorID != "" { authorID = prev.AuthorID authorLabel = "<@" + prev.AuthorID + ">" } } } if authorID == "" && m.Author != nil { authorID = m.Author.ID authorLabel = m.Author.Mention() } if authorID == "" { authorLabel = "Unknown" } embed := &discordgo.MessageEmbed{ Title: "Message Deleted", Color: 0xE74C3C, Description: fmt.Sprintf("Author: %s\nChannel: <#%s>\nMessage ID: `%s`", authorLabel, m.ChannelID, m.ID), Fields: []*discordgo.MessageEmbedField{ {Name: "Content", Value: truncateOrPlaceholder(content), Inline: false}, }, Timestamp: time.Now().Format(time.RFC3339), } if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { log.Printf("log: failed to send message delete log: %v", err) } deleteCachedMessage(m.ID) } func HandleGuildMemberAddLog(s *discordgo.Session, m *discordgo.GuildMemberAdd, svc *services.Services) { if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil { return } embed := &discordgo.MessageEmbed{ Title: "Member Joined", Color: 0x2ECC71, Description: fmt.Sprintf("%s joined the server.\nUser ID: `%s`", m.User.Mention(), m.User.ID), Timestamp: time.Now().Format(time.RFC3339), } if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { log.Printf("log: failed to send member join log: %v", err) } } func HandleGuildMemberRemoveLog(s *discordgo.Session, m *discordgo.GuildMemberRemove, svc *services.Services) { if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil { return } // Try to classify this leave as a kick via recent audit log. kickEntry, kickMod := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberKick) title := "Member Left" color := 0x95A5A6 description := fmt.Sprintf("%s left the server.\nUser ID: `%s`", m.User.Mention(), m.User.ID) if kickEntry != nil { title = "Member Kicked" color = 0xE67E22 description = fmt.Sprintf("%s was kicked.\nUser ID: `%s`", m.User.Mention(), m.User.ID) if kickMod != nil { description += "\nModerator: " + kickMod.Mention() } if strings.TrimSpace(kickEntry.Reason) != "" { description += "\nReason: " + kickEntry.Reason } } embed := &discordgo.MessageEmbed{ Title: title, Color: color, Description: description, Timestamp: time.Now().Format(time.RFC3339), } if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { log.Printf("log: failed to send member remove log: %v", err) } } func HandleGuildMemberUpdateLog(s *discordgo.Session, m *discordgo.GuildMemberUpdate, svc *services.Services) { if s == nil || m == nil || m.Member == nil || m.User == nil || m.GuildID == "" || svc == nil || m.BeforeUpdate == nil { return } added := diffRoleIDs(m.BeforeUpdate.Roles, m.Roles) removed := diffRoleIDs(m.Roles, m.BeforeUpdate.Roles) if len(added) == 0 && len(removed) == 0 { return } fields := make([]*discordgo.MessageEmbedField, 0, 2) if len(added) > 0 { fields = append(fields, &discordgo.MessageEmbedField{ Name: "Roles Added", Value: joinRoleMentions(added), Inline: false, }) } if len(removed) > 0 { fields = append(fields, &discordgo.MessageEmbedField{ Name: "Roles Removed", Value: joinRoleMentions(removed), Inline: false, }) } embed := &discordgo.MessageEmbed{ Title: "Member Roles Updated", Color: 0x3498DB, Description: fmt.Sprintf("Member: %s (`%s`)", m.User.Mention(), m.User.ID), Fields: fields, Timestamp: time.Now().Format(time.RFC3339), } if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { log.Printf("log: failed to send member update log: %v", err) } } func HandleGuildBanAddLog(s *discordgo.Session, m *discordgo.GuildBanAdd, svc *services.Services) { if s == nil || m == nil || m.User == nil || m.GuildID == "" || svc == nil { return } entry, moderator := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberBanAdd) desc := fmt.Sprintf("%s was banned.\nUser ID: `%s`", m.User.Mention(), m.User.ID) if moderator != nil { desc += "\nModerator: " + moderator.Mention() } if entry != nil && strings.TrimSpace(entry.Reason) != "" { desc += "\nReason: " + entry.Reason } embed := &discordgo.MessageEmbed{ Title: "Member Banned", Color: 0xC0392B, Description: desc, Timestamp: time.Now().Format(time.RFC3339), } if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { log.Printf("log: failed to send ban add log: %v", err) } } func HandleGuildBanRemoveLog(s *discordgo.Session, m *discordgo.GuildBanRemove, svc *services.Services) { if s == nil || m == nil || m.User == nil || m.GuildID == "" || svc == nil { return } entry, moderator := findRecentAuditEntryForTarget(s, m.GuildID, m.User.ID, discordgo.AuditLogActionMemberBanRemove) desc := fmt.Sprintf("%s was unbanned.\nUser ID: `%s`", m.User.Mention(), m.User.ID) if moderator != nil { desc += "\nModerator: " + moderator.Mention() } if entry != nil && strings.TrimSpace(entry.Reason) != "" { desc += "\nReason: " + entry.Reason } embed := &discordgo.MessageEmbed{ Title: "Member Unbanned", Color: 0x27AE60, Description: desc, Timestamp: time.Now().Format(time.RFC3339), } if err := modlog.Send(s, svc, m.GuildID, embed); err != nil { log.Printf("log: failed to send ban remove log: %v", err) } } func findRecentAuditEntryForTarget(s *discordgo.Session, guildID, targetID string, action discordgo.AuditLogAction) (*discordgo.AuditLogEntry, *discordgo.User) { if s == nil || guildID == "" || targetID == "" { return nil, nil } logs, err := s.GuildAuditLog(guildID, "", "", int(action), 10) if err != nil || logs == nil { return nil, nil } var chosen *discordgo.AuditLogEntry now := time.Now().UTC() for _, e := range logs.AuditLogEntries { if e == nil || e.TargetID != targetID || e.ActionType == nil || *e.ActionType != action { continue } ts, err := discordgo.SnowflakeTimestamp(e.ID) if err != nil { continue } // Keep only very recent entries to reduce false positives. if now.Sub(ts) > 2*time.Minute { continue } chosen = e break } if chosen == nil { return nil, nil } for _, u := range logs.Users { if u != nil && u.ID == chosen.UserID { return chosen, u } } return chosen, nil } func truncateOrPlaceholder(s string) string { v := strings.TrimSpace(s) if v == "" { return "*empty*" } if len(v) <= 1000 { return v } return v[:997] + "..." } func diffRoleIDs(base, compare []string) []string { out := make([]string, 0) for _, id := range base { if id == "" { continue } if !slices.Contains(compare, id) { out = append(out, id) } } return out } func joinRoleMentions(roleIDs []string) string { if len(roleIDs) == 0 { return "*none*" } const max = 12 parts := make([]string, 0, len(roleIDs)) for idx, id := range roleIDs { if idx >= max { parts = append(parts, fmt.Sprintf("... and %d more", len(roleIDs)-max)) break } parts = append(parts, "<@&"+id+">") } return strings.Join(parts, ", ") } func CacheMessageFromCreate(m *discordgo.MessageCreate) { if m == nil || m.Message == nil || m.ID == "" { return } cacheMessage(m.ID, strings.TrimSpace(m.Content), authorIDOrEmpty(m.Author)) } func cacheMessage(messageID, content, authorID string) { if messageID == "" { return } messageCache.Store(messageID, cachedMessage{ Content: content, AuthorID: authorID, }) } func getCachedMessage(messageID string) (cachedMessage, bool) { if messageID == "" { return cachedMessage{}, false } raw, ok := messageCache.Load(messageID) if !ok { return cachedMessage{}, false } msg, ok := raw.(cachedMessage) if !ok { return cachedMessage{}, false } return msg, true } func deleteCachedMessage(messageID string) { if messageID == "" { return } messageCache.Delete(messageID) } func authorIDOrEmpty(u *discordgo.User) string { if u == nil { return "" } return u.ID }