From 2b14079e5075569c3dc167a46e2e7e32afae5faa Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Wed, 18 Mar 2026 13:00:30 +0000 Subject: [PATCH 1/2] feat: add moderation commands and update README - Introduced a new set of moderation commands including `/moderation purge`, `/moderation timeout`, `/moderation kick`, `/moderation ban`, `/moderation unban`, `/moderation untimeout`, and `/moderation slowmode`. - Updated README to include details about the new moderation features and commands. - Enhanced help command to display moderation tools and their usage. --- README.md | 13 +- internal/commands/commands.go | 3 + internal/commands/help/help.go | 11 + .../commands/moderation/public/moderation.go | 540 ++++++++++++++++++ internal/commands/moderation/registry.go | 16 + 5 files changed, 581 insertions(+), 2 deletions(-) create mode 100644 internal/commands/moderation/public/moderation.go create mode 100644 internal/commands/moderation/registry.go diff --git a/README.md b/README.md index 74832fc..228f782 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,9 @@ It currently includes: - Scheduling (1:1 sessions with DM invites + reaction-based accept/decline/reschedule) - Projects (list/create projects + helpers) - Music (Lavalink-powered `/play`, `/queue`, `/volume`) - - Twitch live notifications (per-guild `/config` setup) - - Welcome messages, DMs, and default role assignment (per-guild `/config` setup) +- 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`) ## Requirements @@ -96,6 +97,14 @@ just down # stop postgres - `/projects list`, `/projects create`, `/projects add-helper` - **Music** (requires **DJ** role) - `/play`, `/queue`, `/volume` +- **Moderation** (focused + practical) + - `/moderation purge amount:<1-100>` — Fast recent message cleanup (**Manage Messages**) + - `/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Temporary member timeout (**Moderate Members**) + - `/moderation kick user:@someone [reason]` — Kick member (**Kick Members**) + - `/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**) + - `/moderation unban user_id: [reason]` — Unban by user ID (**Ban Members**) + - `/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**) + - `/moderation slowmode channel:#channel seconds:<0-21600>` — Control channel flood (**Manage Channels**) - **Twitch live notifications** (**Manage Server**) - `/config addstreamer username:` — Add a streamer for notifications - `/config removestreamer username:` — Remove a streamer diff --git a/internal/commands/commands.go b/internal/commands/commands.go index ee8f9fe..16a972d 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -6,6 +6,7 @@ import ( "velox-bot/internal/commands/help" cmdlevel "velox-bot/internal/commands/level" "velox-bot/internal/commands/meeting" + "velox-bot/internal/commands/moderation" cmdmusic "velox-bot/internal/commands/music" "velox-bot/internal/commands/projects" "velox-bot/internal/commands/schedule" @@ -32,6 +33,7 @@ var AllCommands = []*discordgo.ApplicationCommand{ cmdmusic.Queue, cmdmusic.Volume, meeting.Meeting, + moderation.Moderation, timezone.Timezone, schedule.Schedule, projects.Projects, @@ -52,6 +54,7 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre "leaderboard": cmdlevel.LeaderboardHandler, "rewards": cmdlevel.RewardsHandler, "meeting": meeting.MeetingHandler, + "moderation": moderation.ModerationHandler, "timezone": timezone.TimezoneHandler, "schedule": schedule.ScheduleHandler, "projects": projects.ProjectsHandler, diff --git a/internal/commands/help/help.go b/internal/commands/help/help.go index a630d10..0dff722 100644 --- a/internal/commands/help/help.go +++ b/internal/commands/help/help.go @@ -94,6 +94,17 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { "`/meeting invite user:@someone` — Allow a user to join\n" + "`/meeting uninvite user:@someone` — Remove a user's permission", }, + { + Name: "Moderation (focused tools)", + Value: "" + + "`/moderation purge amount:<1-100>` — Delete recent messages (**Manage Messages**)\n" + + "`/moderation timeout user:@someone duration:<10m|1h|...> [reason]` — Timeout member (**Moderate Members**)\n" + + "`/moderation kick user:@someone [reason]` — Kick member (**Kick Members**)\n" + + "`/moderation ban user:@someone [delete_days] [reason]` — Ban member (**Ban Members**)\n" + + "`/moderation unban user_id: [reason]` — Unban by user ID (**Ban Members**)\n" + + "`/moderation untimeout user:@someone [reason]` — Remove timeout (**Moderate Members**)\n" + + "`/moderation slowmode channel:#channel seconds:<0-21600>` — Set slowmode (**Manage Channels**)", + }, { Name: "Leveling", Value: "" + diff --git a/internal/commands/moderation/public/moderation.go b/internal/commands/moderation/public/moderation.go new file mode 100644 index 0000000..95294aa --- /dev/null +++ b/internal/commands/moderation/public/moderation.go @@ -0,0 +1,540 @@ +package public + +import ( + "fmt" + "strings" + "time" + + "github.com/bwmarrin/discordgo" +) + +var Moderation = &discordgo.ApplicationCommand{ + Name: "moderation", + Description: "High-value moderation tools", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "purge", + Description: "Delete recent messages in this channel (max 100)", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "amount", + Description: "How many recent messages to delete (1-100)", + Required: true, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "timeout", + Description: "Temporarily timeout a member", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to timeout", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "duration", + Description: "Duration like 10m, 1h, 2h30m (max 28d)", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "reason", + Description: "Optional reason", + Required: false, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "kick", + Description: "Kick a member from the server", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to kick", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "reason", + Description: "Optional reason", + Required: false, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "ban", + Description: "Ban a member from the server", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to ban", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "delete_days", + Description: "Delete message history (0-7 days)", + Required: false, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "reason", + Description: "Optional reason", + Required: false, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "unban", + Description: "Unban a user by ID", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "user_id", + Description: "User ID to unban", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "reason", + Description: "Optional reason", + Required: false, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "untimeout", + Description: "Remove timeout from a member", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionUser, + Name: "user", + Description: "User to untimeout", + Required: true, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "reason", + Description: "Optional reason", + Required: false, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "slowmode", + Description: "Set channel slowmode (0 disables)", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionChannel, + Name: "channel", + Description: "Text channel to update", + Required: true, + ChannelTypes: []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews}, + }, + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "seconds", + Description: "Slowmode delay in seconds (0-21600)", + Required: true, + }, + }, + }, + }, +} + +func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + if i.GuildID == "" { + respondEphemeral(s, i, "This command can only be used in a server.") + return + } + + data := i.ApplicationCommandData() + if len(data.Options) == 0 { + respondEphemeral(s, i, "Missing subcommand.") + return + } + + switch data.Options[0].Name { + case "purge": + handlePurge(s, i) + case "timeout": + handleTimeout(s, i) + case "kick": + handleKick(s, i) + case "ban": + handleBan(s, i) + case "unban": + handleUnban(s, i) + case "untimeout": + handleUntimeout(s, i) + case "slowmode": + handleSlowmode(s, i) + default: + respondEphemeral(s, i, "Unknown subcommand.") + } +} + +func handlePurge(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionManageMessages) { + respondEphemeral(s, i, "You need **Manage Messages** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var amount int64 + for _, o := range opt.Options { + if o.Name == "amount" { + amount = o.IntValue() + break + } + } + if amount < 1 || amount > 100 { + respondEphemeral(s, i, "Amount must be between 1 and 100.") + return + } + + msgs, err := s.ChannelMessages(i.ChannelID, int(amount), "", "", "") + if err != nil { + respondEphemeral(s, i, "Failed to fetch messages.") + return + } + if len(msgs) == 0 { + respondEphemeral(s, i, "No messages found to delete.") + return + } + + tooOldCutoff := time.Now().Add(-14 * 24 * time.Hour) + ids := make([]string, 0, len(msgs)) + for _, m := range msgs { + if m == nil { + continue + } + if m.Timestamp.Before(tooOldCutoff) { + continue + } + ids = append(ids, m.ID) + } + if len(ids) == 0 { + respondEphemeral(s, i, "No messages can be bulk deleted (older than 14 days).") + return + } + + if len(ids) == 1 { + if err := s.ChannelMessageDelete(i.ChannelID, ids[0]); err != nil { + respondEphemeral(s, i, "Failed to delete message.") + return + } + respondEphemeral(s, i, "Deleted 1 message.") + return + } + + if err := s.ChannelMessagesBulkDelete(i.ChannelID, ids); err != nil { + respondEphemeral(s, i, "Failed to bulk delete messages.") + return + } + + respondEphemeral(s, i, fmt.Sprintf("Deleted %d messages.", len(ids))) +} + +func handleTimeout(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionModerateMembers) { + respondEphemeral(s, i, "You need **Moderate Members** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var userOpt *discordgo.ApplicationCommandInteractionDataOption + var durationInput string + var reason string + for _, o := range opt.Options { + switch o.Name { + case "user": + userOpt = o + case "duration": + durationInput = strings.TrimSpace(o.StringValue()) + case "reason": + reason = strings.TrimSpace(o.StringValue()) + } + } + if userOpt == nil { + respondEphemeral(s, i, "Missing user option.") + return + } + target := userOpt.UserValue(s) + if target == nil { + respondEphemeral(s, i, "Invalid user.") + return + } + + dur, err := time.ParseDuration(durationInput) + if err != nil || dur <= 0 { + respondEphemeral(s, i, "Invalid duration. Examples: `10m`, `1h`, `2h30m`.") + return + } + maxTimeout := 28 * 24 * time.Hour + if dur > maxTimeout { + respondEphemeral(s, i, "Duration cannot exceed 28 days.") + return + } + + until := time.Now().UTC().Add(dur) + if err := s.GuildMemberTimeout(i.GuildID, target.ID, &until); err != nil { + respondEphemeral(s, i, "Failed to timeout user. Check role hierarchy and permissions.") + return + } + + if reason == "" { + respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`.", target.Mention(), dur.String())) + return + } + respondEphemeral(s, i, fmt.Sprintf("Timed out %s for `%s`. Reason: %s", target.Mention(), dur.String(), reason)) +} + +func handleKick(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionKickMembers) { + respondEphemeral(s, i, "You need **Kick Members** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var userOpt *discordgo.ApplicationCommandInteractionDataOption + var reason string + for _, o := range opt.Options { + switch o.Name { + case "user": + userOpt = o + case "reason": + reason = strings.TrimSpace(o.StringValue()) + } + } + if userOpt == nil { + respondEphemeral(s, i, "Missing user option.") + return + } + target := userOpt.UserValue(s) + if target == nil { + respondEphemeral(s, i, "Invalid user.") + return + } + if i.Member != nil && i.Member.User != nil && target.ID == i.Member.User.ID { + respondEphemeral(s, i, "You cannot kick yourself.") + return + } + if s != nil && s.State != nil && s.State.User != nil && target.ID == s.State.User.ID { + respondEphemeral(s, i, "I can't kick myself.") + return + } + + if err := s.GuildMemberDeleteWithReason(i.GuildID, target.ID, reason); err != nil { + respondEphemeral(s, i, "Failed to kick user. Check role hierarchy and permissions.") + return + } + + if reason == "" { + respondEphemeral(s, i, "Kicked "+target.Mention()+".") + return + } + respondEphemeral(s, i, "Kicked "+target.Mention()+". Reason: "+reason) +} + +func handleBan(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionBanMembers) { + respondEphemeral(s, i, "You need **Ban Members** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var userOpt *discordgo.ApplicationCommandInteractionDataOption + var deleteDays int64 + var reason string + for _, o := range opt.Options { + switch o.Name { + case "user": + userOpt = o + case "delete_days": + deleteDays = o.IntValue() + case "reason": + reason = strings.TrimSpace(o.StringValue()) + } + } + if userOpt == nil { + respondEphemeral(s, i, "Missing user option.") + return + } + target := userOpt.UserValue(s) + if target == nil { + respondEphemeral(s, i, "Invalid user.") + return + } + if deleteDays < 0 || deleteDays > 7 { + respondEphemeral(s, i, "delete_days must be between 0 and 7.") + return + } + if i.Member != nil && i.Member.User != nil && target.ID == i.Member.User.ID { + respondEphemeral(s, i, "You cannot ban yourself.") + return + } + if s != nil && s.State != nil && s.State.User != nil && target.ID == s.State.User.ID { + respondEphemeral(s, i, "I can't ban myself.") + return + } + + if err := s.GuildBanCreateWithReason(i.GuildID, target.ID, reason, int(deleteDays)); err != nil { + respondEphemeral(s, i, "Failed to ban user. Check role hierarchy and permissions.") + return + } + + if reason == "" { + respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages).", target.Mention(), deleteDays)) + return + } + respondEphemeral(s, i, fmt.Sprintf("Banned %s (deleted %d days of messages). Reason: %s", target.Mention(), deleteDays, reason)) +} + +func handleUnban(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionBanMembers) { + respondEphemeral(s, i, "You need **Ban Members** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var userID string + var reason string + for _, o := range opt.Options { + switch o.Name { + case "user_id": + userID = strings.TrimSpace(o.StringValue()) + case "reason": + reason = strings.TrimSpace(o.StringValue()) + } + } + if userID == "" { + respondEphemeral(s, i, "Missing user ID.") + return + } + + if err := s.GuildBanDelete(i.GuildID, userID); err != nil { + respondEphemeral(s, i, "Failed to unban user. Make sure the user ID is valid and banned.") + return + } + + if reason == "" { + respondEphemeral(s, i, "Unbanned user ID `"+userID+"`.") + return + } + respondEphemeral(s, i, "Unbanned user ID `"+userID+"`. Reason: "+reason) +} + +func handleUntimeout(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionModerateMembers) { + respondEphemeral(s, i, "You need **Moderate Members** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var userOpt *discordgo.ApplicationCommandInteractionDataOption + var reason string + for _, o := range opt.Options { + switch o.Name { + case "user": + userOpt = o + case "reason": + reason = strings.TrimSpace(o.StringValue()) + } + } + if userOpt == nil { + respondEphemeral(s, i, "Missing user option.") + return + } + target := userOpt.UserValue(s) + if target == nil { + respondEphemeral(s, i, "Invalid user.") + return + } + + if err := s.GuildMemberTimeout(i.GuildID, target.ID, nil); err != nil { + respondEphemeral(s, i, "Failed to remove timeout. Check role hierarchy and permissions.") + return + } + + if reason == "" { + respondEphemeral(s, i, "Removed timeout from "+target.Mention()+".") + return + } + respondEphemeral(s, i, "Removed timeout from "+target.Mention()+". Reason: "+reason) +} + +func handleSlowmode(s *discordgo.Session, i *discordgo.InteractionCreate) { + if !hasPerm(i, discordgo.PermissionManageChannels) { + respondEphemeral(s, i, "You need **Manage Channels** for this command.") + return + } + + opt := i.ApplicationCommandData().Options[0] + var chOpt *discordgo.ApplicationCommandInteractionDataOption + var seconds int64 + for _, o := range opt.Options { + switch o.Name { + case "channel": + chOpt = o + case "seconds": + seconds = o.IntValue() + } + } + if chOpt == nil { + respondEphemeral(s, i, "Missing channel option.") + return + } + if seconds < 0 || seconds > 21600 { + respondEphemeral(s, i, "Seconds must be between 0 and 21600.") + return + } + ch := chOpt.ChannelValue(s) + if ch == nil { + respondEphemeral(s, i, "Invalid channel.") + return + } + + secondsInt := int(seconds) + _, err := s.ChannelEditComplex(ch.ID, &discordgo.ChannelEdit{ + RateLimitPerUser: &secondsInt, + }) + if err != nil { + respondEphemeral(s, i, "Failed to update slowmode.") + return + } + + if seconds == 0 { + respondEphemeral(s, i, "Disabled slowmode in "+ch.Mention()+".") + return + } + respondEphemeral(s, i, fmt.Sprintf("Set slowmode in %s to %d seconds.", ch.Mention(), seconds)) +} + +func hasPerm(i *discordgo.InteractionCreate, perm int64) bool { + return i.Member != nil && (i.Member.Permissions&perm) == perm +} + +func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + diff --git a/internal/commands/moderation/registry.go b/internal/commands/moderation/registry.go new file mode 100644 index 0000000..57794b4 --- /dev/null +++ b/internal/commands/moderation/registry.go @@ -0,0 +1,16 @@ +package moderation + +import ( + "velox-bot/internal/commands/moderation/public" + + "github.com/bwmarrin/discordgo" +) + +var ( + Moderation *discordgo.ApplicationCommand = public.Moderation +) + +func ModerationHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + public.ModerationHandler(s, i) +} + From 035e383db22d575a58441d622680fa61c0e8ae18 Mon Sep 17 00:00:00 2001 From: FernandoJVideira <03.pleaser-minster@icloud.com> Date: Wed, 18 Mar 2026 13:18:12 +0000 Subject: [PATCH 2/2] 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. --- README.md | 7 + internal/bot/bot.go | 34 ++ internal/commands/config/public/config.go | 106 +++++ internal/commands/help/help.go | 7 + .../commands/moderation/public/moderation.go | 38 ++ internal/db/repos/settingsrepo/repo.go | 80 ++++ internal/db/services/logsettings/service.go | 32 ++ internal/db/services/services.go | 7 +- internal/events/event_handlers.go | 2 + internal/events/logging.go | 376 ++++++++++++++++++ internal/events/onboarding.go | 163 ++++++++ internal/modlog/modlog.go | 30 ++ main.go | 4 +- 13 files changed, 883 insertions(+), 3 deletions(-) create mode 100644 internal/db/services/logsettings/service.go create mode 100644 internal/events/logging.go create mode 100644 internal/events/onboarding.go create mode 100644 internal/modlog/modlog.go diff --git a/README.md b/README.md index 228f782..880e267 100644 --- a/README.md +++ b/README.md @@ -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:` — 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 diff --git a/internal/bot/bot.go b/internal/bot/bot.go index 6288d90..bedea5d 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -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) { diff --git a/internal/commands/config/public/config.go b/internal/commands/config/public/config.go index 225df4e..9adbd9e 100644 --- a/internal/commands/config/public/config.go +++ b/internal/commands/config/public/config.go @@ -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, diff --git a/internal/commands/help/help.go b/internal/commands/help/help.go index 0dff722..9450cdd 100644 --- a/internal/commands/help/help.go +++ b/internal/commands/help/help.go @@ -133,6 +133,13 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { "`/config setwelcomegif 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", diff --git a/internal/commands/moderation/public/moderation.go b/internal/commands/moderation/public/moderation.go index 95294aa..3565a97 100644 --- a/internal/commands/moderation/public/moderation.go +++ b/internal/commands/moderation/public/moderation.go @@ -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) +} + diff --git a/internal/db/repos/settingsrepo/repo.go b/internal/db/repos/settingsrepo/repo.go index 82035ba..e895604 100644 --- a/internal/db/repos/settingsrepo/repo.go +++ b/internal/db/repos/settingsrepo/repo.go @@ -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 diff --git a/internal/db/services/logsettings/service.go b/internal/db/services/logsettings/service.go new file mode 100644 index 0000000..14046e4 --- /dev/null +++ b/internal/db/services/logsettings/service.go @@ -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) +} + diff --git a/internal/db/services/services.go b/internal/db/services/services.go index 0c0c96e..d4b21b7 100644 --- a/internal/db/services/services.go +++ b/internal/db/services/services.go @@ -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 diff --git a/internal/events/event_handlers.go b/internal/events/event_handlers.go index 5ea1373..ea12615 100644 --- a/internal/events/event_handlers.go +++ b/internal/events/event_handlers.go @@ -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 } diff --git a/internal/events/logging.go b/internal/events/logging.go new file mode 100644 index 0000000..40057c2 --- /dev/null +++ b/internal/events/logging.go @@ -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 +} + diff --git a/internal/events/onboarding.go b/internal/events/onboarding.go new file mode 100644 index 0000000..46ffebf --- /dev/null +++ b/internal/events/onboarding.go @@ -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 "" +} + diff --git a/internal/modlog/modlog.go b/internal/modlog/modlog.go new file mode 100644 index 0000000..1acf830 --- /dev/null +++ b/internal/modlog/modlog.go @@ -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 +} + diff --git a/main.go b/main.go index a78799e..c009b14 100644 --- a/main.go +++ b/main.go @@ -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 {