Merge pull request 'ft/mod' (#8) from ft/mod into dev
Reviewed-on: https://gitea.fernandovideira.com/FernandoJVideira/velox-bot/pulls/8
This commit was merged in pull request #8.
This commit is contained in:
@@ -10,8 +10,12 @@ 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`)
|
||||||
|
- 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
|
## Requirements
|
||||||
|
|
||||||
@@ -96,6 +100,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
|
||||||
@@ -106,6 +118,10 @@ just down # stop postgres
|
|||||||
- `/config setwelcomedm message:"..."` — DM welcome template (supports `{user}`, `{server}`)
|
- `/config setwelcomedm message:"..."` — DM welcome template (supports `{user}`, `{server}`)
|
||||||
- `/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed
|
- `/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed
|
||||||
- `/config setdefaultrole role:@role` — Role automatically granted on join
|
- `/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
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ func NewBot(token, appID, guildID, lavalinkHost, lavalinkPass string, cmds []*di
|
|||||||
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates
|
// - VoiceStateUpdate (meeting lobby): GuildVoiceStates
|
||||||
// - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions
|
// - MessageReactionAdd (scheduling via reactions): GuildMessageReactions + DirectMessageReactions
|
||||||
session.Identify.Intents = discordgo.IntentsGuilds |
|
session.Identify.Intents = discordgo.IntentsGuilds |
|
||||||
|
discordgo.IntentsGuildBans |
|
||||||
discordgo.IntentsGuildMembers |
|
discordgo.IntentsGuildMembers |
|
||||||
discordgo.IntentsGuildMessages |
|
discordgo.IntentsGuildMessages |
|
||||||
|
discordgo.IntentsMessageContent |
|
||||||
discordgo.IntentsGuildVoiceStates |
|
discordgo.IntentsGuildVoiceStates |
|
||||||
discordgo.IntentsGuildMessageReactions |
|
discordgo.IntentsGuildMessageReactions |
|
||||||
discordgo.IntentsDirectMessages |
|
discordgo.IntentsDirectMessages |
|
||||||
@@ -76,7 +78,39 @@ func (b *Bot) Start() error {
|
|||||||
})
|
})
|
||||||
|
|
||||||
b.Session.AddHandler(func(s *discordgo.Session, m *discordgo.GuildMemberAdd) {
|
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.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) {
|
b.Session.AddHandler(func(s *discordgo.Session, vs *discordgo.VoiceStateUpdate) {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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":
|
case "setdefaultrole":
|
||||||
log.Printf("defaultrole: /config setdefaultrole invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
log.Printf("defaultrole: /config setdefaultrole invoked by %s in guild %s", i.Member.User.ID, i.GuildID)
|
||||||
handleSetDefaultRole(s, i)
|
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:
|
default:
|
||||||
respondEphemeral(s, i, "Unknown subcommand.")
|
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()+".")
|
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) {
|
func respondEphemeral(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
|
||||||
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
_ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
|
||||||
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
Type: discordgo.InteractionResponseChannelMessageWithSource,
|
||||||
|
|||||||
@@ -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: "" +
|
||||||
@@ -122,6 +133,13 @@ func HelpHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
|||||||
"`/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed\n" +
|
"`/config setwelcomegif url:<image-url>` — GIF/image used in welcome embed\n" +
|
||||||
"`/config setdefaultrole role:@role` — Role granted to new members",
|
"`/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",
|
Name: "Other",
|
||||||
Value: "`/help` — Show this help in DMs",
|
Value: "`/help` — Show this help in DMs",
|
||||||
|
|||||||
@@ -0,0 +1,578 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -15,6 +15,12 @@ type LevelReward struct {
|
|||||||
RoleID int64
|
RoleID int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogSettings struct {
|
||||||
|
GuildID int64
|
||||||
|
ChannelID int64
|
||||||
|
IsEnabled bool
|
||||||
|
}
|
||||||
|
|
||||||
func NewRepo(db *sql.DB) *Repo {
|
func NewRepo(db *sql.DB) *Repo {
|
||||||
return &Repo{db: db}
|
return &Repo{db: db}
|
||||||
}
|
}
|
||||||
@@ -240,6 +246,80 @@ func (r *Repo) SetLevelUpMessage(ctx context.Context, guildID int64, message str
|
|||||||
return err
|
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) {
|
func (r *Repo) GetLevelUpMessage(ctx context.Context, guildID int64) (string, error) {
|
||||||
const q = `
|
const q = `
|
||||||
SELECT message
|
SELECT message
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"velox-bot/internal/db/services/defaultrole"
|
||||||
"velox-bot/internal/db/services/level"
|
"velox-bot/internal/db/services/level"
|
||||||
"velox-bot/internal/db/services/levelsettings"
|
"velox-bot/internal/db/services/levelsettings"
|
||||||
|
"velox-bot/internal/db/services/logsettings"
|
||||||
"velox-bot/internal/db/services/meeting"
|
"velox-bot/internal/db/services/meeting"
|
||||||
"velox-bot/internal/db/services/projects"
|
"velox-bot/internal/db/services/projects"
|
||||||
"velox-bot/internal/db/services/rps"
|
"velox-bot/internal/db/services/rps"
|
||||||
@@ -10,7 +12,6 @@ import (
|
|||||||
"velox-bot/internal/db/services/twitch"
|
"velox-bot/internal/db/services/twitch"
|
||||||
"velox-bot/internal/db/services/usersettings"
|
"velox-bot/internal/db/services/usersettings"
|
||||||
"velox-bot/internal/db/services/welcome"
|
"velox-bot/internal/db/services/welcome"
|
||||||
"velox-bot/internal/db/services/defaultrole"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Services struct {
|
type Services struct {
|
||||||
@@ -24,11 +25,12 @@ type Services struct {
|
|||||||
Twitch *twitch.Service
|
Twitch *twitch.Service
|
||||||
Welcome *welcome.Service
|
Welcome *welcome.Service
|
||||||
DefaultRole *defaultrole.Service
|
DefaultRole *defaultrole.Service
|
||||||
|
LogSettings *logsettings.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
var Global *Services
|
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{
|
s := &Services{
|
||||||
Level: level,
|
Level: level,
|
||||||
LevelSettings: levelSettings,
|
LevelSettings: levelSettings,
|
||||||
@@ -40,6 +42,7 @@ func NewServices(level *level.Service, levelSettings *levelsettings.Service, mee
|
|||||||
Twitch: twitchSvc,
|
Twitch: twitchSvc,
|
||||||
Welcome: welcomeSvc,
|
Welcome: welcomeSvc,
|
||||||
DefaultRole: defaultRoleSvc,
|
DefaultRole: defaultRoleSvc,
|
||||||
|
LogSettings: logSettingsSvc,
|
||||||
}
|
}
|
||||||
Global = s
|
Global = s
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func HandleMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate, services *services.Services) {
|
func HandleMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate, services *services.Services) {
|
||||||
|
CacheMessageFromCreate(m)
|
||||||
|
|
||||||
if m.Author.Bot || m.GuildID == "" {
|
if m.Author.Bot || m.GuildID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -22,6 +22,7 @@ import (
|
|||||||
"velox-bot/internal/db/services"
|
"velox-bot/internal/db/services"
|
||||||
"velox-bot/internal/db/services/level"
|
"velox-bot/internal/db/services/level"
|
||||||
"velox-bot/internal/db/services/levelsettings"
|
"velox-bot/internal/db/services/levelsettings"
|
||||||
|
"velox-bot/internal/db/services/logsettings"
|
||||||
"velox-bot/internal/db/services/meeting"
|
"velox-bot/internal/db/services/meeting"
|
||||||
"velox-bot/internal/db/services/projects"
|
"velox-bot/internal/db/services/projects"
|
||||||
"velox-bot/internal/db/services/rps"
|
"velox-bot/internal/db/services/rps"
|
||||||
@@ -57,6 +58,7 @@ func main() {
|
|||||||
defaultRoleRepo := defaultrolerepo.NewRepo(db)
|
defaultRoleRepo := defaultrolerepo.NewRepo(db)
|
||||||
levelService := level.New(levelRepo, settingsRepo)
|
levelService := level.New(levelRepo, settingsRepo)
|
||||||
levelSettingsService := levelsettings.New(settingsRepo)
|
levelSettingsService := levelsettings.New(settingsRepo)
|
||||||
|
logSettingsService := logsettings.New(settingsRepo)
|
||||||
meetingService := meeting.New(settingsRepo)
|
meetingService := meeting.New(settingsRepo)
|
||||||
scheduleService := schedule.New(scheduleRepo)
|
scheduleService := schedule.New(scheduleRepo)
|
||||||
userSettingsService := usersettings.New(userSettingsRepo)
|
userSettingsService := usersettings.New(userSettingsRepo)
|
||||||
@@ -65,7 +67,7 @@ func main() {
|
|||||||
twitchService := twitch.New(twitchRepo, config.TwitchClientID)
|
twitchService := twitch.New(twitchRepo, config.TwitchClientID)
|
||||||
welcomeService := welcome.New(welcomeRepo)
|
welcomeService := welcome.New(welcomeRepo)
|
||||||
defaultRoleService := defaultrole.New(defaultRoleRepo)
|
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)
|
bot, err := bot.NewBot(config.BotToken, config.AppID, config.GuildID, config.LavalinkHost, config.LavalinkPass, commands.AllCommands, services)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user