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) +} +