Files
FernandoJVideiraandClaude Sonnet 5.5 d95ebf473e Security: tune the Join Gate, raid mode, anti-nuke and heat with commands
- /security settings, joingate check|account-age, words add|remove|list, raid, antinuke set|limit, heat, alert-dms
- Same rule as the rest of the configuration: owner or trusted, validated in the engine
- schema.sql was missing security_settings.alert_dm, so the bot couldn't read security settings on a database built from it. Fixed, with an ALTER for existing installs
- A test checks every command against Discord's limits

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-23 02:03:03 +01:00

580 lines
19 KiB
Go

package public
import (
"context"
"errors"
"fmt"
"log"
"strings"
"time"
"velox-bot/internal/db/repos/securityrepo"
"velox-bot/internal/security"
"github.com/bwmarrin/discordgo"
)
// Every handler re-checks permissions through the security engine. The
// DefaultMemberPermissions below only hide commands from the menu; server
// admins can change them, so they are never what keeps anyone out.
var (
manageGuild = int64(discordgo.PermissionManageGuild)
manageChannel = int64(discordgo.PermissionManageChannels)
guildOnly = &[]discordgo.InteractionContextType{discordgo.InteractionContextGuild}
textChannels = []discordgo.ChannelType{discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews}
chatChannels = []discordgo.ChannelType{
discordgo.ChannelTypeGuildText, discordgo.ChannelTypeGuildNews, discordgo.ChannelTypeGuildForum,
discordgo.ChannelTypeGuildVoice, discordgo.ChannelTypeGuildStageVoice,
}
opTimeout = 3 * time.Minute
genericFailed = "Something went wrong. Check that Velox's role is high enough and has the permissions it needs, then try again."
)
var featureChoices = []*discordgo.ApplicationCommandOptionChoice{
{Name: "Join Gate", Value: securityrepo.FeatureJoinGate},
{Name: "Raid mode", Value: securityrepo.FeatureAntiRaid},
{Name: "Anti-nuke", Value: securityrepo.FeatureAntiNuke},
{Name: "Heat (anti-spam)", Value: securityrepo.FeatureHeat},
}
var Security = &discordgo.ApplicationCommand{
Name: "security",
Description: "Anti-raid and anti-nuke protection",
DefaultMemberPermissions: &manageGuild,
Contexts: guildOnly,
Options: append([]*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show what's on and who is trusted"},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "toggle", Description: "Turn a protection on or off",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionString, Name: "feature", Description: "Which protection", Required: true, Choices: featureChoices},
{Type: discordgo.ApplicationCommandOptionBoolean, Name: "on", Description: "On or off", Required: true},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "alerts", Description: "Where alerts go and which role they ping (owner or trusted)",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Alert channel", Required: true, ChannelTypes: textChannels},
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "Role to ping for raids and nukes"},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "quarantine-role", Description: "Role given to quarantined members (owner or trusted)",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "Leave empty to use none"},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommandGroup, Name: "trust", Description: "Who is exempt from anti-nuke (owner only)",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "add", Description: "Trust a user or a role",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "A user"},
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "A role"},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "remove", Description: "Stop trusting a user or a role",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "A user"},
{Type: discordgo.ApplicationCommandOptionRole, Name: "role", Description: "A role"},
},
},
},
},
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "release", Description: "Release a quarantined member (owner or trusted)",
Options: []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionUser, Name: "user", Description: "Who to release", Required: true},
},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "quarantined", Description: "List quarantined members"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "end-raid", Description: "End raid mode now (owner or trusted)"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "snapshot", Description: "Save a snapshot of roles and channels to restore from"},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "restore", Description: "Recreate deleted roles and channels from the latest snapshot (owner or trusted)"},
}, tuneOptions()...),
}
func lockOptions(unlock bool) []*discordgo.ApplicationCommandOption {
verb := "Lock"
if unlock {
verb = "Unlock"
}
joins := &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "joins", Description: verb + " joins"}
if !unlock {
joins.Description = "Kick or ban everyone who joins until unlocked"
joins.Options = []*discordgo.ApplicationCommandOption{{
Type: discordgo.ApplicationCommandOptionString, Name: "action", Description: "What happens to new members", Required: true,
Choices: []*discordgo.ApplicationCommandOptionChoice{{Name: "Kick", Value: securityrepo.ActionKick}, {Name: "Ban", Value: securityrepo.ActionBan}},
}}
}
return []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionSubCommand, Name: "channel", Description: verb + " one channel",
Options: []*discordgo.ApplicationCommandOption{{Type: discordgo.ApplicationCommandOptionChannel, Name: "channel", Description: "Defaults to this channel", ChannelTypes: chatChannels}},
},
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "server", Description: verb + " every channel"},
joins,
}
}
var Lockdown = &discordgo.ApplicationCommand{
Name: "lockdown", Description: "Stop members from talking, or from joining", DefaultMemberPermissions: &manageChannel,
Contexts: guildOnly, Options: lockOptions(false),
}
var Unlock = &discordgo.ApplicationCommand{
Name: "unlock", Description: "Undo a lockdown (owner or trusted)", DefaultMemberPermissions: &manageChannel,
Contexts: guildOnly, Options: lockOptions(true),
}
func reply(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, AllowedMentions: &discordgo.MessageAllowedMentions{}},
})
}
func deferReply(s *discordgo.Session, i *discordgo.InteractionCreate) bool {
return s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseDeferredChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Flags: discordgo.MessageFlagsEphemeral},
}) == nil
}
func edit(s *discordgo.Session, i *discordgo.InteractionCreate, msg string) {
_, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{Content: &msg, AllowedMentions: &discordgo.MessageAllowedMentions{}})
}
func message(where string, err error) string {
var ue security.UserError
if errors.As(err, &ue) {
return string(ue)
}
log.Printf("security command %s: %v", where, err)
return genericFailed
}
func opts(o []*discordgo.ApplicationCommandInteractionDataOption) map[string]*discordgo.ApplicationCommandInteractionDataOption {
m := map[string]*discordgo.ApplicationCommandInteractionDataOption{}
for _, x := range o {
m[x.Name] = x
}
return m
}
func perms(i *discordgo.InteractionCreate) int64 {
if i.Member == nil {
return 0
}
return i.Member.Permissions
}
func userID(i *discordgo.InteractionCreate) string {
if i.Member == nil || i.Member.User == nil {
return ""
}
return i.Member.User.ID
}
func engine(s *discordgo.Session, i *discordgo.InteractionCreate) *security.Engine {
e := security.Default()
if e == nil || i.GuildID == "" || i.Member == nil {
reply(s, i, "Security isn't available right now.")
return nil
}
return e
}
// allowedOrTrusted lets an action through when the member has one of the
// permissions, or is the owner or trusted.
func allowedOrTrusted(ctx context.Context, e *security.Engine, i *discordgo.InteractionCreate, need int64) bool {
if perms(i)&(need|discordgo.PermissionAdministrator) != 0 {
return true
}
return e.RequireTrusted(ctx, i.GuildID, i.Member) == nil
}
func SecurityHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
e := engine(s, i)
if e == nil {
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
sub := data.Options[0]
args := opts(sub.Options)
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if handleTune(ctx, s, i, e, sub) {
return
}
switch sub.Name {
case "status":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to see the security settings.")
return
}
reply(s, i, status(ctx, e, i.GuildID))
case "toggle":
feature, on := args["feature"].StringValue(), args["on"].BoolValue()
if err := e.SetFeature(ctx, i.GuildID, i.Member, perms(i), feature, on); err != nil {
reply(s, i, message("toggle", err))
return
}
state := "off"
if on {
state = "on"
}
reply(s, i, fmt.Sprintf("%s is now **%s**.", featureName(feature), state))
case "alerts":
ch := args["channel"].ChannelValue(nil).ID
role := ""
if r, ok := args["role"]; ok {
role = r.RoleValue(nil, i.GuildID).ID
}
if err := e.SetAlerts(ctx, i.GuildID, i.Member, ch, role); err != nil {
reply(s, i, message("alerts", err))
return
}
reply(s, i, fmt.Sprintf("Alerts will go to <#%s>.", ch))
case "quarantine-role":
role := ""
if r, ok := args["role"]; ok {
role = r.RoleValue(nil, i.GuildID).ID
}
if err := e.SetQuarantineRole(ctx, i.GuildID, i.Member, role); err != nil {
reply(s, i, message("quarantine-role", err))
return
}
if role == "" {
reply(s, i, "Quarantined members won't get a role; they still lose their roles and are timed out.")
} else {
reply(s, i, fmt.Sprintf("Quarantined members will get <@&%s>.", role))
}
case "trust":
if len(sub.Options) == 0 {
return
}
handleTrust(ctx, s, i, e, sub.Options[0])
case "release":
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
reply(s, i, message("release", err))
return
}
target := args["user"].UserValue(nil)
if !deferReply(s, i) {
return
}
if err := e.Release(ctx, i.GuildID, target.ID, userID(i)); err != nil {
edit(s, i, message("release", err))
return
}
edit(s, i, fmt.Sprintf("<@%s> was released and got their roles back.", target.ID))
case "quarantined":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to see this.")
return
}
qs, err := e.Quarantines(ctx, i.GuildID)
if err != nil {
reply(s, i, message("quarantined", err))
return
}
if len(qs) == 0 {
reply(s, i, "Nobody is quarantined.")
return
}
var b strings.Builder
for _, q := range qs {
fmt.Fprintf(&b, "• <@%d>, <t:%d:R>: %s\n", q.UserID, q.CreatedAt.Unix(), q.Reason)
}
reply(s, i, clip(b.String(), 1900))
case "end-raid":
endRaid(ctx, s, i, e)
case "snapshot":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to take a snapshot.")
return
}
if !deferReply(s, i) {
return
}
roles, channels, err := e.TakeSnapshot(ctx, i.GuildID, userID(i))
if err != nil {
edit(s, i, message("snapshot", err))
return
}
edit(s, i, fmt.Sprintf("Snapshot saved: %d roles and %d channels. The last three are kept.", roles, channels))
case "restore":
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
reply(s, i, message("restore", err))
return
}
if !deferReply(s, i) {
return
}
// Recreating many channels takes a while under Discord's rate limits.
res, err := e.Restore(ctx, i.GuildID, userID(i))
if err != nil {
edit(s, i, message("restore", err))
return
}
edit(s, i, fmt.Sprintf("Restored from the snapshot of <t:%d:f>: %d roles and %d channels recreated, %d roles had dangerous permissions reverted, %d steps failed.",
res.SnapshotAt.Unix(), res.RolesCreated, res.ChannelsCreated, res.RolesReverted, res.Failed))
}
}
func handleTrust(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine, sub *discordgo.ApplicationCommandInteractionDataOption) {
args := opts(sub.Options)
kind, target := "", ""
if u, ok := args["user"]; ok {
kind, target = "user", u.UserValue(nil).ID
}
if r, ok := args["role"]; ok {
if target != "" {
reply(s, i, "Pick a user or a role, not both.")
return
}
kind, target = "role", r.RoleValue(nil, i.GuildID).ID
}
if target == "" {
reply(s, i, "Pick a user or a role.")
return
}
mention := "<@" + target + ">"
if kind == "role" {
mention = "<@&" + target + ">"
}
switch sub.Name {
case "add":
if err := e.Trust(ctx, i.GuildID, i.Member, kind, target); err != nil {
reply(s, i, message("trust add", err))
return
}
reply(s, i, mention+" is now trusted: exempt from anti-nuke and able to change security settings.")
case "remove":
if err := e.Untrust(ctx, i.GuildID, i.Member, target); err != nil {
reply(s, i, message("trust remove", err))
return
}
reply(s, i, mention+" is no longer trusted.")
}
}
func endRaid(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine) {
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
reply(s, i, message("end raid", err))
return
}
if !deferReply(s, i) {
return
}
if err := e.EndRaid(ctx, i.GuildID, userID(i)); err != nil {
edit(s, i, message("end raid", err))
return
}
edit(s, i, "Raid mode is off.")
}
func featureName(f string) string {
switch f {
case securityrepo.FeatureJoinGate:
return "Join Gate"
case securityrepo.FeatureAntiRaid:
return "Raid mode"
case securityrepo.FeatureAntiNuke:
return "Anti-nuke"
case securityrepo.FeatureHeat:
return "Heat"
}
return f
}
func onOff(b bool) string {
if b {
return "✅ on"
}
return "⬜ off"
}
func status(ctx context.Context, e *security.Engine, guildID string) string {
st, trusted, err := e.Settings(ctx, guildID)
if err != nil {
return message("status", err)
}
var b strings.Builder
fmt.Fprintf(&b, "**Join Gate:** %s\n**Raid mode:** %s (%d joins in %ds → %s)\n**Anti-nuke:** %s (auto restore %s)\n**Heat:** %s (%s)\n",
onOff(st.JoinGateEnabled), onOff(st.AntiRaidEnabled), st.RaidJoinCount, st.RaidJoinSeconds, st.RaidAction,
onOff(st.AntiNukeEnabled), onOff(st.AntiNukeAutoRestore), onOff(st.HeatEnabled), st.HeatStrictness)
if e.RaidActive(guildID) {
b.WriteString("🚨 **A raid is in progress.**\n")
}
if st.JoinLock != securityrepo.ActionOff {
fmt.Fprintf(&b, "🔒 Joins are locked (%s).\n", st.JoinLock)
}
if st.AlertChannelID != 0 {
fmt.Fprintf(&b, "**Alerts:** <#%d>", st.AlertChannelID)
if st.AlertRoleID != 0 {
fmt.Fprintf(&b, ", pinging <@&%d>", st.AlertRoleID)
}
b.WriteString("\n")
} else {
b.WriteString("**Alerts:** not set. Use `/security alerts`.\n")
}
if owner, err := e.OwnerID(guildID); err == nil {
fmt.Fprintf(&b, "**Trusted:** <@%s> (owner)", owner)
}
for _, t := range trusted {
if t.Kind == "role" {
fmt.Fprintf(&b, ", <@&%d>", t.TargetID)
} else {
fmt.Fprintf(&b, ", <@%d>", t.TargetID)
}
}
b.WriteString("\nSee every setting with `/security settings`, and change them with `/security joingate`, `words`, `raid`, `antinuke` and `heat`.")
return b.String()
}
func clip(s string, n int) string {
if r := []rune(s); len(r) > n {
return string(r[:n-1]) + "…"
}
return s
}
func LockdownHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
e := engine(s, i)
if e == nil {
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
sub := data.Options[0]
args := opts(sub.Options)
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
switch sub.Name {
case "channel":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageChannels) {
reply(s, i, "You need Manage Channels to lock a channel.")
return
}
ch := i.ChannelID
if c, ok := args["channel"]; ok {
ch = c.ChannelValue(nil).ID
}
if err := e.LockChannel(ctx, i.GuildID, ch, userID(i), false); err != nil {
reply(s, i, message("lock channel", err))
return
}
reply(s, i, fmt.Sprintf("🔒 <#%s> is locked. `/unlock channel` puts it back as it was.", ch))
case "server":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to lock the server.")
return
}
if !deferReply(s, i) {
return
}
locked, failed, err := e.LockServer(ctx, i.GuildID, userID(i))
if err != nil {
edit(s, i, message("lock server", err))
return
}
msg := fmt.Sprintf("🔒 Locked %d channels.", locked)
if failed > 0 {
msg += fmt.Sprintf(" %d couldn't be locked (Velox needs Manage Channels there).", failed)
}
edit(s, i, msg+" `/unlock server` puts them back as they were.")
case "joins":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to lock joins.")
return
}
mode := args["action"].StringValue()
if err := e.SetJoinLock(ctx, i.GuildID, mode, userID(i)); err != nil {
reply(s, i, message("lock joins", err))
return
}
reply(s, i, fmt.Sprintf("🔒 Everyone who joins will be %s until `/unlock joins`.", map[string]string{"kick": "kicked", "ban": "banned"}[mode]))
}
}
// UnlockHandler relaxes protection, so it is owner or trusted only.
func UnlockHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
e := engine(s, i)
if e == nil {
return
}
data := i.ApplicationCommandData()
if len(data.Options) == 0 {
return
}
sub := data.Options[0]
args := opts(sub.Options)
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
if err := e.RequireTrusted(ctx, i.GuildID, i.Member); err != nil {
reply(s, i, message("unlock", err))
return
}
switch sub.Name {
case "channel":
ch := i.ChannelID
if c, ok := args["channel"]; ok {
ch = c.ChannelValue(nil).ID
}
if err := e.UnlockChannel(ctx, i.GuildID, ch, userID(i)); err != nil {
reply(s, i, message("unlock channel", err))
return
}
reply(s, i, fmt.Sprintf("🔓 <#%s> is unlocked.", ch))
case "server":
if !deferReply(s, i) {
return
}
unlocked, failed, err := e.UnlockServer(ctx, i.GuildID, userID(i))
if err != nil {
edit(s, i, message("unlock server", err))
return
}
msg := fmt.Sprintf("🔓 Unlocked %d channels.", unlocked)
if failed > 0 {
msg += fmt.Sprintf(" %d failed; try `/unlock server` again.", failed)
}
edit(s, i, msg)
case "joins":
if err := e.SetJoinLock(ctx, i.GuildID, securityrepo.ActionOff, userID(i)); err != nil {
reply(s, i, message("unlock joins", err))
return
}
reply(s, i, "🔓 Joins are open again.")
}
}
// HandleComponent handles the alert's "End raid" button. Anyone in the alert
// channel can press it, so it checks trust like the command.
func HandleComponent(s *discordgo.Session, i *discordgo.InteractionCreate) {
e := engine(s, i)
if e == nil {
return
}
if i.MessageComponentData().CustomID != security.CustomIDEndRaid {
return
}
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel()
endRaid(ctx, s, i, e)
}