feat: implement server logging functionality

- 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.
This commit is contained in:
2026-03-18 13:18:12 +00:00
parent 2b14079e50
commit 035e383db2
13 changed files with 883 additions and 3 deletions
+7
View File
@@ -13,6 +13,9 @@ It currently includes:
- Twitch live notifications (per-guild `/config` setup)
- Welcome messages, DMs, and default role assignment (per-guild `/config` setup)
- Focused moderation tools (`/moderation purge|timeout|untimeout|kick|ban|unban|slowmode`)
- Server logging system (edits/deletes/joins/leaves/roles/ban/kick/mod actions)
When the bot is added to a new server for the first time, toggleable systems are defaulted to **disabled** and a setup DM is sent to the inviter (or server owner fallback) with enable instructions.
## Requirements
@@ -115,6 +118,10 @@ just down # stop postgres
- `/config setwelcomedm message:"..."` — DM welcome template (supports `{user}`, `{server}`)
- `/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed
- `/config setdefaultrole role:@role` — Role automatically granted on join
- **Logging** (**Manage Server**)
- `/config setlogchannel channel:#channel` — Set where logs are sent
- `/config enablelogging` — Enable event/moderation logs
- `/config disablelogging` — Disable event/moderation logs
## Notes
+34
View File
@@ -34,8 +34,10 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates
// - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions
session.Identify.Intents = discordgo.IntentsGuilds |
discordgo.IntentsGuildBans |
discordgo.IntentsGuildMembers |
discordgo.IntentsGuildMessages |
discordgo.IntentsMessageContent |
discordgo.IntentsGuildVoiceStates |
discordgo.IntentsGuildMessageReactions |
discordgo.IntentsDirectMessages |
@@ -76,7 +78,39 @@ func (b *Bot) Start() error {
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) {
events.HandleBotAddedOnGuild(s, m, b.Services)
// Ignore bot members for welcome/log onboarding of normal users.
if m != nil && m.Member != nil && m.User != nil && m.User.Bot {
return
}
events.HandleGuildMemberAdd(s, m, b.Services)
events.HandleGuildMemberAddLog(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberRemove) {
events.HandleGuildMemberRemoveLog(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberUpdate) {
events.HandleGuildMemberUpdateLog(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageUpdate) {
events.HandleMessageUpdateLog(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageDelete) {
events.HandleMessageDeleteLog(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildBanAdd) {
events.HandleGuildBanAddLog(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildBanRemove) {
events.HandleGuildBanRemoveLog(s, m, b.Services)
})
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
+106
View File
@@ -121,6 +121,30 @@ var Config = &discordgo.ApplicationCommand{
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "setlogchannel",
Description: "Set the moderation log channel",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionChannel,
Name: "channel",
Description: "Text channel for logs",
Required: true,
ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews},
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "enablelogging",
Description: "Enable server logging events",
},
{
Type: discordgo.ApplicationCommandOptionSubCommand,
Name: "disablelogging",
Description: "Disable server logging events",
},
},
}
@@ -169,6 +193,15 @@ func ConfigHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
case "setdefaultrole":
log.Printf("defaultrole: /config setdefaultrole invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetDefaultRole(s, i)
case "setlogchannel":
log.Printf("log: /config setlogchannel invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetLogChannel(s, i)
case "enablelogging":
log.Printf("log: /config enablelogging invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetLoggingEnabled(s, i, true)
case "disablelogging":
log.Printf("log: /config disablelogging invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
handleSetLoggingEnabled(s, i, false)
default:
respondEphemeral(s, i, "Unknown subcommand.")
}
@@ -460,6 +493,79 @@ func handleSetDefaultRole(s *discordgo.Session, i *discordgo.InteractionCreate)
respondEphemeral(s, i, "Default role set to "+role.Mention()+".")
}
func handleSetLogChannel(s *discordgo.Session, i *discordgo.InteractionCreate) {
if services.Global.LogSettings == nil {
respondEphemeral(s, i, "Log settings service is not available.")
return
}
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
if err != nil {
respondEphemeral(s, i, "Invalid guild ID.")
return
}
opt := i.ApplicationCommandData().Options[0]
var chOpt *discordgo.ApplicationCommandInteractionDataOption
for _, o := range opt.Options {
if o.Name == "channel" {
chOpt = o
break
}
}
if chOpt == nil {
respondEphemeral(s, i, "Missing channel option.")
return
}
ch := chOpt.ChannelValue(s)
if ch == nil {
respondEphemeral(s, i, "Invalid channel.")
return
}
channelID, err := strconv.ParseInt(ch.ID, 10, 64)
if err != nil {
respondEphemeral(s, i, "Invalid channel ID.")
return
}
if err := services.Global.LogSettings.SetChannel(context.Background(), guildID, channelID); err != nil {
respondEphemeral(s, i, "Failed to set log channel.")
return
}
if err := services.Global.LogSettings.SetEnabled(context.Background(), guildID, true); err != nil {
respondEphemeral(s, i, "Log channel set, but failed to enable logging.")
return
}
respondEphemeral(s, i, "Logging enabled in "+ch.Mention()+".")
}
func handleSetLoggingEnabled(s *discordgo.Session, i *discordgo.InteractionCreate, enabled bool) {
if services.Global.LogSettings == nil {
respondEphemeral(s, i, "Log settings service is not available.")
return
}
guildID, err := strconv.ParseInt(i.GuildID, 10, 64)
if err != nil {
respondEphemeral(s, i, "Invalid guild ID.")
return
}
if err := services.Global.LogSettings.SetEnabled(context.Background(), guildID, enabled); err != nil {
respondEphemeral(s, i, "Failed to update logging state.")
return
}
if enabled {
respondEphemeral(s, i, "Logging is now enabled.")
return
}
respondEphemeral(s, i, "Logging is now disabled.")
}
func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
+7
View File
@@ -133,6 +133,13 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
"`/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed\n" +
"`/config setdefaultrole role:@role` — Role granted to new members",
},
{
Name: "Logging (**Manage Server**)",
Value: "" +
"`/config setlogchannel channel:#channel` — Set log output channel\n" +
"`/config enablelogging` — Enable event/moderation logging\n" +
"`/config disablelogging` — Disable event/moderation logging",
},
{
Name: "Other",
Value: "`/help` — Show this help in DMs",
@@ -5,6 +5,9 @@ import (
"strings"
"time"
"velox-bot/internal/db/services"
"velox-bot/internal/modlog"
"github.com/bwmarrin/discordgo"
)
@@ -238,6 +241,7 @@ func handlePurge(s *discordgo.Session, i *discordgo.InteractionCreate) {
return
}
respondEphemeral(s, i, "Deleted 1 message.")
sendModerationActionLog(s, i, "Purge", fmt.Sprintf("Deleted 1 message in <#%s>.", i.ChannelID), "")
return
}
@@ -247,6 +251,7 @@ func handlePurge(s *discordgo.Session, i *discordgo.InteractionCreate) {
}
respondEphemeral(s, i, fmt.Sprintf("Deleted %d messages.", len(ids)))
sendModerationActionLog(s, i, "Purge", fmt.Sprintf("Deleted %d messages in <#%s>.", len(ids), i.ChannelID), "")
}
func handleTimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -298,9 +303,11 @@ func handleTimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
if reason == "" {
respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`.", target.Mention(), dur.String()))
sendModerationActionLog(s, i, "Timeout", fmt.Sprintf("Target: %s\nDuration: `%s`", target.Mention(), dur.String()), "")
return
}
respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`. Reason: %s", target.Mention(), dur.String(), reason))
sendModerationActionLog(s, i, "Timeout", fmt.Sprintf("Target: %s\nDuration: `%s`", target.Mention(), dur.String()), reason)
}
func handleKick(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -345,9 +352,11 @@ func handleKick(s *discordgo.Session, i *discordgo.InteractionCreate) {
if reason == "" {
respondEphemeral(s, i, "Kicked "+target.Mention()+".")
sendModerationActionLog(s, i, "Kick", "Target: "+target.Mention(), "")
return
}
respondEphemeral(s, i, "Kicked "+target.Mention()+". Reason: "+reason)
sendModerationActionLog(s, i, "Kick", "Target: "+target.Mention(), reason)
}
func handleBan(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -399,9 +408,11 @@ func handleBan(s *discordgo.Session, i *discordgo.InteractionCreate) {
if reason == "" {
respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages).", target.Mention(), deleteDays))
sendModerationActionLog(s, i, "Ban", fmt.Sprintf("Target: %s\nDeleted message days: %d", target.Mention(), deleteDays), "")
return
}
respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages). Reason: %s", target.Mention(), deleteDays, reason))
sendModerationActionLog(s, i, "Ban", fmt.Sprintf("Target: %s\nDeleted message days: %d", target.Mention(), deleteDays), reason)
}
func handleUnban(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -433,9 +444,11 @@ func handleUnban(s *discordgo.Session, i *discordgo.InteractionCreate) {
if reason == "" {
respondEphemeral(s, i, "Unbanned user ID `"+userID+"`.")
sendModerationActionLog(s, i, "Unban", "Target user ID: `"+userID+"`", "")
return
}
respondEphemeral(s, i, "Unbanned user ID `"+userID+"`. Reason: "+reason)
sendModerationActionLog(s, i, "Unban", "Target user ID: `"+userID+"`", reason)
}
func handleUntimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -472,9 +485,11 @@ func handleUntimeout(s *discordgo.Session, i *discordgo.InteractionCreate) {
if reason == "" {
respondEphemeral(s, i, "Removed timeout from "+target.Mention()+".")
sendModerationActionLog(s, i, "Timeout Removed", "Target: "+target.Mention(), "")
return
}
respondEphemeral(s, i, "Removed timeout from "+target.Mention()+". Reason: "+reason)
sendModerationActionLog(s, i, "Timeout Removed", "Target: "+target.Mention(), reason)
}
func handleSlowmode(s *discordgo.Session, i *discordgo.InteractionCreate) {
@@ -519,9 +534,11 @@ func handleSlowmode(s *discordgo.Session, i *discordgo.InteractionCreate) {
if seconds == 0 {
respondEphemeral(s, i, "Disabled slowmode in "+ch.Mention()+".")
sendModerationActionLog(s, i, "Slowmode", "Channel: "+ch.Mention()+"\nSet to: `0s`", "")
return
}
respondEphemeral(s, i, fmt.Sprintf("Set slowmode in %s to %d seconds.", ch.Mention(), seconds))
sendModerationActionLog(s, i, "Slowmode", fmt.Sprintf("Channel: %s\nSet to: `%ds`", ch.Mention(), seconds), "")
}
func hasPerm(i *discordgo.InteractionCreate, perm int64) bool {
@@ -538,3 +555,24 @@ func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg
})
}
func sendModerationActionLog(s *discordgo.Session, i *discordgo.InteractionCreate, action, details, reason string) {
if services.Global == nil {
return
}
moderator := "Unknown"
if i != nil && i.Member != nil && i.Member.User != nil {
moderator = i.Member.User.Mention()
}
desc := "Moderator: " + moderator + "\n" + details
if strings.TrimSpace(reason) != "" {
desc += "\nReason: " + reason
}
embed := &discordgo.MessageEmbed{
Title: "Moderation Action: " + action,
Color: 0x5865F2,
Description: desc,
Timestamp: time.Now().Format(time.RFC3339),
}
_ = modlog.Send(s, services.Global, i.GuildID, embed)
}
+80
View File
@@ -15,6 +15,12 @@ type LevelReward struct {
RoleID int64
}
type LogSettings struct {
GuildID int64
ChannelID int64
IsEnabled bool
}
func NewRepo(db *sql.DB) *Repo {
return &Repo{db: db}
}
@@ -240,6 +246,80 @@ func (r *Repo) SetLevelUpMessage(ctx context.Context, guildID int64, message str
return err
}
func (r *Repo) SetLogChannel(ctx context.Context, guildID int64, channelID int64) error {
const q = `
INSERT INTO logsettings (guild_id, log_channel_id, is_enabled)
VALUES ($1, $2, TRUE)
ON CONFLICT (guild_id)
DO UPDATE SET log_channel_id = EXCLUDED.log_channel_id
`
_, err := r.db.ExecContext(ctx, q, guildID, channelID)
return err
}
func (r *Repo) SetLogEnabled(ctx context.Context, guildID int64, enabled bool) error {
const q = `
INSERT INTO logsettings (guild_id, is_enabled)
VALUES ($1, $2)
ON CONFLICT (guild_id)
DO UPDATE SET is_enabled = EXCLUDED.is_enabled
`
_, err := r.db.ExecContext(ctx, q, guildID, enabled)
return err
}
func (r *Repo) GetLogSettings(ctx context.Context, guildID int64) (*LogSettings, error) {
const q = `
SELECT log_channel_id, is_enabled
FROM logsettings
WHERE guild_id = $1
`
row := r.db.QueryRowContext(ctx, q, guildID)
var channelID sql.NullInt64
var enabled sql.NullBool
switch err := row.Scan(&channelID, &enabled); err {
case nil:
out := &LogSettings{
GuildID: guildID,
ChannelID: 0,
IsEnabled: false,
}
if channelID.Valid {
out.ChannelID = channelID.Int64
}
if enabled.Valid {
out.IsEnabled = enabled.Bool
}
return out, nil
case sql.ErrNoRows:
return &LogSettings{
GuildID: guildID,
ChannelID: 0,
IsEnabled: false,
}, nil
default:
return nil, err
}
}
func (r *Repo) HasLogSettings(ctx context.Context, guildID int64) (bool, error) {
const q = `
SELECT 1
FROM logsettings
WHERE guild_id = $1
`
row := r.db.QueryRowContext(ctx, q, guildID)
var one int
switch err := row.Scan(&one); err {
case nil:
return true, nil
case sql.ErrNoRows:
return false, nil
default:
return false, err
}
}
func (r *Repo) GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) {
const q = `
SELECT message
@@ -0,0 +1,32 @@
package logsettings
import (
"context"
"velox-bot/internal/db/repos/settingsrepo"
)
type Service struct {
settings *settingsrepo.Repo
}
func New(settings *settingsrepo.Repo) *Service {
return &Service{settings: settings}
}
func (s *Service) SetChannel(ctx context.Context, guildID, channelID int64) error {
return s.settings.SetLogChannel(ctx, guildID, channelID)
}
func (s *Service) SetEnabled(ctx context.Context, guildID int64, enabled bool) error {
return s.settings.SetLogEnabled(ctx, guildID, enabled)
}
func (s *Service) GetSettings(ctx context.Context, guildID int64) (*settingsrepo.LogSettings, error) {
return s.settings.GetLogSettings(ctx, guildID)
}
func (s *Service) HasSettings(ctx context.Context, guildID int64) (bool, error) {
return s.settings.HasLogSettings(ctx, guildID)
}
+5 -2
View File
@@ -1,8 +1,10 @@
package services
import (
"velox-bot/internal/db/services/defaultrole"
"velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings"
"velox-bot/internal/db/services/logsettings"
"velox-bot/internal/db/services/meeting"
"velox-bot/internal/db/services/projects"
"velox-bot/internal/db/services/rps"
@@ -10,7 +12,6 @@ import (
"velox-bot/internal/db/services/twitch"
"velox-bot/internal/db/services/usersettings"
"velox-bot/internal/db/services/welcome"
"velox-bot/internal/db/services/defaultrole"
)
type Services struct {
@@ -24,11 +25,12 @@ type Services struct {
Twitch *twitch.Service
Welcome *welcome.Service
DefaultRole *defaultrole.Service
LogSettings *logsettings.Service
}
var Global *Services
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service) *Services {
func NewServices(level *level.Service, levelSettings *levelsettings.Service, meeting *meeting.Service, schedule *schedule.Service, userSettings *usersettings.Service, projects *projects.Service, rps *rps.Service, twitchSvc *twitch.Service, welcomeSvc *welcome.Service, defaultRoleSvc *defaultrole.Service, logSettingsSvc *logsettings.Service) *Services {
s := &Services{
Level: level,
LevelSettings: levelSettings,
@@ -40,6 +42,7 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee
Twitch: twitchSvc,
Welcome: welcomeSvc,
DefaultRole: defaultRoleSvc,
LogSettings: logSettingsSvc,
}
Global = s
return s
+2
View File
@@ -11,6 +11,8 @@ import (
)
func HandleMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate, services *services.Services) {
CacheMessageFromCreate(m)
if m.Author.Bot || m.GuildID == "" {
return
}
+376
View File
@@ -0,0 +1,376 @@
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
}
+163
View File
@@ -0,0 +1,163 @@
package events
import (
"context"
"fmt"
"log"
"strconv"
"strings"
"time"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
// HandleBotAddedOnGuild applies safe defaults when this bot is added to a guild
// and DMs setup instructions to the inviter (or guild owner fallback).
func HandleBotAddedOnGuild(s *discordgo.Session, m *discordgo.GuildMemberAdd, svc *services.Services) {
if s == nil || m == nil || m.Member == nil || m.User == nil || svc == nil {
return
}
if s.State == nil || s.State.User == nil {
return
}
if m.User.ID != s.State.User.ID {
return
}
guildID64, err := strconv.ParseInt(m.GuildID, 10, 64)
if err != nil {
return
}
// Treat an existing logsettings row as "already onboarded once".
alreadyOnboarded := false
if svc.LogSettings != nil {
exists, err := svc.LogSettings.HasSettings(context.Background(), guildID64)
if err == nil {
alreadyOnboarded = exists
}
}
if alreadyOnboarded {
return
}
// Explicitly disable toggleable systems by default.
if svc.LevelSettings != nil {
if err := svc.LevelSettings.SetLevelSystemEnabled(context.Background(), guildID64, false); err != nil {
log.Printf("onboarding: failed to set default level state for guild %s: %v", m.GuildID, err)
}
}
if svc.LogSettings != nil {
if err := svc.LogSettings.SetEnabled(context.Background(), guildID64, false); err != nil {
log.Printf("onboarding: failed to set default log state for guild %s: %v", m.GuildID, err)
}
}
recipientID := findRecentBotAdderID(s, m.GuildID)
if recipientID == "" {
recipientID = guildOwnerID(s, m.GuildID)
}
if recipientID == "" {
log.Printf("onboarding: unable to resolve DM recipient for guild %s", m.GuildID)
return
}
dmCh, err := s.UserChannelCreate(recipientID)
if err != nil {
log.Printf("onboarding: failed creating DM for user %s: %v", recipientID, err)
return
}
guildName := m.GuildID
if g, err := s.State.Guild(m.GuildID); err == nil && g != nil && strings.TrimSpace(g.Name) != "" {
guildName = g.Name
} else if g, err := s.Guild(m.GuildID); err == nil && g != nil && strings.TrimSpace(g.Name) != "" {
guildName = g.Name
}
embed := &discordgo.MessageEmbed{
Title: "Thanks for adding Velox Bot!",
Color: 0x5865F2,
Description: fmt.Sprintf(
"Server: **%s**\n\nFor safety, toggleable systems start **disabled by default**.\nUse these commands in your server to enable features:",
guildName,
),
Fields: []*discordgo.MessageEmbedField{
{
Name: "Level System",
Value: "`/slvl toggle`",
Inline: false,
},
{
Name: "Logging System",
Value: "" +
"`/config setlogchannel channel:#channel`\n" +
"`/config enablelogging`",
Inline: false,
},
{
Name: "Welcome System",
Value: "" +
"`/config setwelcomechannel channel:#channel`\n" +
"`/config setwelcomemessage message:\"...\"`\n" +
"`/config setwelcomedm message:\"...\"`",
Inline: false,
},
},
Footer: &discordgo.MessageEmbedFooter{
Text: "Tip: run /help in any server channel for the full command reference.",
},
Timestamp: time.Now().Format(time.RFC3339),
}
if _, err := s.ChannelMessageSendEmbed(dmCh.ID, embed); err != nil {
log.Printf("onboarding: failed sending setup DM to user %s: %v", recipientID, err)
}
}
func findRecentBotAdderID(s *discordgo.Session, guildID string) string {
if s == nil || s.State == nil || s.State.User == nil {
return ""
}
logs, err := s.GuildAuditLog(guildID, "", "", int(discordgo.AuditLogActionBotAdd), 10)
if err != nil || logs == nil {
return ""
}
botID := s.State.User.ID
now := time.Now().UTC()
for _, entry := range logs.AuditLogEntries {
if entry == nil || entry.TargetID != botID || entry.ActionType == nil || *entry.ActionType != discordgo.AuditLogActionBotAdd {
continue
}
ts, err := discordgo.SnowflakeTimestamp(entry.ID)
if err != nil {
continue
}
if now.Sub(ts) > 10*time.Minute {
continue
}
if entry.UserID != "" {
return entry.UserID
}
}
return ""
}
func guildOwnerID(s *discordgo.Session, guildID string) string {
if s == nil || guildID == "" {
return ""
}
if s.State != nil {
if g, err := s.State.Guild(guildID); err == nil && g != nil && g.OwnerID != "" {
return g.OwnerID
}
}
if g, err := s.Guild(guildID); err == nil && g != nil {
return g.OwnerID
}
return ""
}
+30
View File
@@ -0,0 +1,30 @@
package modlog
import (
"context"
"strconv"
"velox-bot/internal/db/services"
"github.com/bwmarrin/discordgo"
)
func Send(s *discordgo.Session, svc *services.Services, guildID string, embed *discordgo.MessageEmbed) error {
if s == nil || svc == nil || svc.LogSettings == nil || embed == nil || guildID == "" {
return nil
}
gid, err := strconv.ParseInt(guildID, 10, 64)
if err != nil {
return nil
}
cfg, err := svc.LogSettings.GetSettings(context.Background(), gid)
if err != nil || cfg == nil || !cfg.IsEnabled || cfg.ChannelID == 0 {
return err
}
_, err = s.ChannelMessageSendEmbed(strconv.FormatInt(cfg.ChannelID, 10), embed)
return err
}
+3 -1
View File
@@ -22,6 +22,7 @@ import (
"velox-bot/internal/db/services"
"velox-bot/internal/db/services/level"
"velox-bot/internal/db/services/levelsettings"
"velox-bot/internal/db/services/logsettings"
"velox-bot/internal/db/services/meeting"
"velox-bot/internal/db/services/projects"
"velox-bot/internal/db/services/rps"
@@ -57,6 +58,7 @@ func main() {
defaultRoleRepo := defaultrolerepo.NewRepo(db)
levelService := level.New(levelRepo, settingsRepo)
levelSettingsService := levelsettings.New(settingsRepo)
logSettingsService := logsettings.New(settingsRepo)
meetingService := meeting.New(settingsRepo)
scheduleService := schedule.New(scheduleRepo)
userSettingsService := usersettings.New(userSettingsRepo)
@@ -65,7 +67,7 @@ func main() {
twitchService := twitch.New(twitchRepo, config.TwitchClientID)
welcomeService := welcome.New(welcomeRepo)
defaultRoleService := defaultrole.New(defaultRoleRepo)
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService)
services := services.NewServices(levelService, levelSettingsService, meetingService, scheduleService, userSettingsService, projectsService, rpsService, twitchService, welcomeService, defaultRoleService, logSettingsService)
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services)
if err != nil {