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.
This commit is contained in:
2026-03-18 13:00:30 +00:00
parent a92c4131db
commit 2b14079e50
5 changed files with 581 additions and 2 deletions
+11 -2
View File
@@ -10,8 +10,9 @@ It currently includes:
- Scheduling (1:1 sessions with DM invites + reaction-based accept/decline/reschedule) - Scheduling (1:1 sessions with DM invites + reaction-based accept/decline/reschedule)
- Projects (list/create projects + helpers) - Projects (list/create projects + helpers)
- Music (Lavalink-powered `/play`, `/queue`, `/volume`) - Music (Lavalink-powered `/play`, `/queue`, `/volume`)
- Twitch live notifications (per-guild `/config` setup) - Twitch live notifications (per-guild `/config` setup)
- Welcome messages, DMs, and default role assignment (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 ## Requirements
@@ -96,6 +97,14 @@ just down # stop postgres
- `/projects list`, `/projects create`, `/projects add-helper` - `/projects list`, `/projects create`, `/projects add-helper`
- **Music** (requires **DJ** role) - **Music** (requires **DJ** role)
- `/play`, `/queue`, `/volume` - `/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:<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**) - **Twitch live notifications** (**Manage Server**)
- `/config addstreamer username:<twitch-name>` — Add a streamer for notifications - `/config addstreamer username:<twitch-name>` — Add a streamer for notifications
- `/config removestreamer username:<twitch-name>` — Remove a streamer - `/config removestreamer username:<twitch-name>` — Remove a streamer
+3
View File
@@ -6,6 +6,7 @@ import (
"velox-bot/internal/commands/help" "velox-bot/internal/commands/help"
cmdlevel "velox-bot/internal/commands/level" cmdlevel "velox-bot/internal/commands/level"
"velox-bot/internal/commands/meeting" "velox-bot/internal/commands/meeting"
"velox-bot/internal/commands/moderation"
cmdmusic "velox-bot/internal/commands/music" cmdmusic "velox-bot/internal/commands/music"
"velox-bot/internal/commands/projects" "velox-bot/internal/commands/projects"
"velox-bot/internal/commands/schedule" "velox-bot/internal/commands/schedule"
@@ -32,6 +33,7 @@ var AllCommands = []*discordgo.ApplicationCommand{
cmdmusic.Queue, cmdmusic.Queue,
cmdmusic.Volume, cmdmusic.Volume,
meeting.Meeting, meeting.Meeting,
moderation.Moderation,
timezone.Timezone, timezone.Timezone,
schedule.Schedule, schedule.Schedule,
projects.Projects, projects.Projects,
@@ -52,6 +54,7 @@ var handlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCre
"leaderboard": cmdlevel.LeaderboardHandler, "leaderboard": cmdlevel.LeaderboardHandler,
"rewards": cmdlevel.RewardsHandler, "rewards": cmdlevel.RewardsHandler,
"meeting": meeting.MeetingHandler, "meeting": meeting.MeetingHandler,
"moderation": moderation.ModerationHandler,
"timezone": timezone.TimezoneHandler, "timezone": timezone.TimezoneHandler,
"schedule": schedule.ScheduleHandler, "schedule": schedule.ScheduleHandler,
"projects": projects.ProjectsHandler, "projects": projects.ProjectsHandler,
+11
View File
@@ -94,6 +94,17 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
"`/meeting invite user:@someone` — Allow a user to join\n" + "`/meeting invite user:@someone` — Allow a user to join\n" +
"`/meeting uninvite user:@someone` — Remove a user's permission", "`/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:<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", Name: "Leveling",
Value: "" + Value: "" +
@@ -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,
},
})
}
+16
View File
@@ -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)
}