- Added a logging service to manage server event logs including message edits, deletions, member joins/leaves, and moderation actions. - Introduced commands to configure logging settings, including setting the log channel and enabling/disabling logging. - Updated the README to document the new logging features and commands. - Enhanced moderation commands to log actions taken on members.
377 lines
10 KiB
Go
377 lines
10 KiB
Go
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
|
|
}
|
|
|