Files
velox-bot/internal/commands/moderation/public/moderation.go
T
FernandoJVideira 035e383db2 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.
2026-03-18 13:18:12 +00:00

579 lines
16 KiB
Go

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