package public import ( "fmt" "strings" "time" "velox-bot/internal/db/services" "velox-bot/internal/modlog" "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.") sendModerationActionLog(s, i, "Purge", fmt.Sprintf("Deleted 1 message in <#%s>.", i.ChannelID), "") 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))) sendModerationActionLog(s, i, "Purge", fmt.Sprintf("Deleted %d messages in <#%s>.", len(ids), i.ChannelID), "") } 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())) 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) { 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()+".") 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) { 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)) 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) { 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+"`.") 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) { 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()+".") 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) { 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()+".") 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 { 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, }, }) } 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) }