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]>
This commit is contained in:
FernandoJVideiraandClaude Sonnet 5.5 committed 2026-10-23 02:03:03 +01:00
1 parent 5657d0789a
commit d95ebf473e
10 files changed
+1020 -4

No files matched your search

+347
View File
@@ -0,0 +1,347 @@
package public
import (
"context"
"fmt"
"sort"
"strings"
"velox-bot/internal/db/repos/securityrepo"
"velox-bot/internal/security"
"github.com/bwmarrin/discordgo"
)
// The commands that tune the Join Gate, raid mode, anti-nuke and heat. They
// reach the same settings a dashboard would, so a server never needs one.
// Changing them needs the server owner or someone on the trusted list; that is
// enforced by the security engine, not by Discord's command permissions.
func intOption(name, desc string, min, max int) *discordgo.ApplicationCommandOption {
lo := float64(min)
return &discordgo.ApplicationCommandOption{
Type: discordgo.ApplicationCommandOptionInteger, Name: name, Description: desc,
MinValue: &lo, MaxValue: float64(max),
}
}
func boolOption(name, desc string) *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionBoolean, Name: name, Description: desc}
}
func choiceOption(name, desc string, required bool, choices ...string) *discordgo.ApplicationCommandOption {
o := &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionString, Name: name, Description: desc, Required: required}
for _, c := range choices {
o.Choices = append(o.Choices, &discordgo.ApplicationCommandOptionChoice{Name: c, Value: c})
}
return o
}
func sub(name, desc string, options ...*discordgo.ApplicationCommandOption) *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionSubCommand, Name: name, Description: desc, Options: options}
}
func group(name, desc string, subs ...*discordgo.ApplicationCommandOption) *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionSubCommandGroup, Name: name, Description: desc, Options: subs}
}
func required(o *discordgo.ApplicationCommandOption) *discordgo.ApplicationCommandOption {
o.Required = true
return o
}
var nukeLimitChoices = []string{
securityrepo.LimitChannelDelete, securityrepo.LimitChannelCreate, securityrepo.LimitRoleDelete, securityrepo.LimitRoleCreate,
securityrepo.LimitBan, securityrepo.LimitKick, securityrepo.LimitWebhookCreate,
}
// Which Join Gate check an option name stands for.
var joinGateChecks = []string{"no-avatar", "new-account", "invite-in-name", "blocked-names", "unverified-bot", "unauthorized-bot"}
// tuneOptions are the subcommands added to /security.
func tuneOptions() []*discordgo.ApplicationCommandOption {
acts := []string{securityrepo.ActionOff, securityrepo.ActionTimeout, securityrepo.ActionKick, securityrepo.ActionBan}
lists := []string{string(security.UsernameWords), string(security.ChatWords)}
listOpt := func() *discordgo.ApplicationCommandOption {
return choiceOption("list", "username: new members' names (Join Gate). chat: messages (heat)", true, lists...)
}
wordOpt := func() *discordgo.ApplicationCommandOption {
return &discordgo.ApplicationCommandOption{Type: discordgo.ApplicationCommandOptionString, Name: "word", Description: "A word or phrase, matched as plain text", Required: true, MaxLength: 50}
}
return []*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "settings", Description: "Show every security setting"},
group("joingate", "Tune the Join Gate (owner or trusted)",
sub("check", "Choose what happens when a check catches a new member",
choiceOption("check", "Which check", true, joinGateChecks...),
choiceOption("action", "What to do. Bot checks accept off, kick or ban", true, acts...)),
sub("account-age", "How old an account must be to pass the new-account check",
required(intOption("days", "Days, 1 to 365", 1, 365)))),
group("words", "Blocked words (owner or trusted)",
sub("add", "Block a word", listOpt(), wordOpt()),
sub("remove", "Stop blocking a word", listOpt(), wordOpt()),
sub("list", "Show the blocked words", listOpt())),
sub("raid", "Tune raid mode (owner or trusted). Leave out what you don't change",
intOption("join-count", "Joins that start a raid, 3 to 100", 3, 100),
intOption("join-seconds", "Within this many seconds, 5 to 300", 5, 300),
choiceOption("action", "What happens to raiders", false, securityrepo.ActionAlert, securityrepo.ActionTimeout, securityrepo.ActionKick, securityrepo.ActionBan),
boolOption("pause-invites", "Pause server invites during a raid"),
intOption("quiet-minutes", "Minutes without a join before the raid ends, 1 to 120", 1, 120)),
group("antinuke", "Tune anti-nuke (owner or trusted)",
sub("set", "Leave out what you don't change",
intOption("window-seconds", "Actions are counted within this many seconds, 5 to 300", 5, 300),
boolOption("lock-server", "Also lock every channel when a nuke is stopped"),
boolOption("auto-restore", "Restore deleted roles and channels after a stopped nuke")),
sub("limit", "How many times someone may do an action within the window (0 means never)",
choiceOption("action", "Which action", true, nukeLimitChoices...),
required(intOption("count", "0 to 100", 0, 100)))),
sub("heat", "Tune the spam heat system (owner or trusted). Leave out what you don't change",
choiceOption("strictness", "How fast heat builds", false, "low", "medium", "high"),
choiceOption("action", "What happens at maximum heat", false, securityrepo.ActionTimeout, securityrepo.ActionKick, securityrepo.ActionBan),
intOption("timeout-minutes", "Timeout length, 1 to 10080", 1, 10080),
boolOption("escalate", "Double the timeout for repeat offences"),
intOption("panic-members", "Members maxing out in a minute that start heat panic, 0 (off) to 50", 0, 50),
intOption("mention-lockdown", "Mentions in 30 seconds that lock the server, 0 (off) or 5 to 1000", 0, 1000)),
sub("alert-dms", "Send critical alerts to the owner and trusted users too (owner or trusted)",
required(boolOption("on", "On or off"))),
}
}
func intArg(args map[string]*discordgo.ApplicationCommandInteractionDataOption, name string) *int {
if a, ok := args[name]; ok {
v := int(a.IntValue())
return &v
}
return nil
}
func boolArg(args map[string]*discordgo.ApplicationCommandInteractionDataOption, name string) *bool {
if a, ok := args[name]; ok {
v := a.BoolValue()
return &v
}
return nil
}
func strArg(args map[string]*discordgo.ApplicationCommandInteractionDataOption, name string) *string {
if a, ok := args[name]; ok {
v := a.StringValue()
return &v
}
return nil
}
// handleTune runs the tuning subcommands. It reports whether it handled name.
func handleTune(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine, subcmd *discordgo.ApplicationCommandInteractionDataOption) bool {
args := opts(subcmd.Options)
switch subcmd.Name {
case "settings":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to see the security settings.")
return true
}
st, _, err := e.Settings(ctx, i.GuildID)
if err != nil {
reply(s, i, message("settings", err))
return true
}
reply(s, i, describeSettings(st))
case "joingate":
if len(subcmd.Options) == 0 {
return true
}
inner := subcmd.Options[0]
innerArgs := opts(inner.Options)
t := securityrepo.Tuning{}
var summary, done string
switch inner.Name {
case "check":
check, action := innerArgs["check"].StringValue(), innerArgs["action"].StringValue()
switch check {
case "no-avatar":
t.NoAvatar = &action
case "new-account":
t.NewAccount = &action
case "invite-in-name":
t.AdvertisingName = &action
case "blocked-names":
t.UsernameFilter = &action
case "unverified-bot":
t.UnverifiedBot = &action
case "unauthorized-bot":
t.UnauthorizedBot = &action
}
summary = "joingate " + check + " " + action
done = fmt.Sprintf("The **%s** check is now **%s**. The Join Gate itself is switched with `/security toggle`.", check, action)
case "account-age":
days := int(innerArgs["days"].IntValue())
t.MinAccountAgeDays = &days
summary = fmt.Sprintf("joingate minimum account age %d days", days)
done = fmt.Sprintf("Accounts younger than **%d days** are caught by the new-account check.", days)
default:
return true
}
finishTune(ctx, s, i, e, t, summary, done)
case "words":
if len(subcmd.Options) == 0 {
return true
}
handleWords(ctx, s, i, e, subcmd.Options[0])
case "raid":
t := securityrepo.Tuning{
RaidJoinCount: intArg(args, "join-count"), RaidJoinSeconds: intArg(args, "join-seconds"), RaidAction: strArg(args, "action"),
RaidPauseInvites: boolArg(args, "pause-invites"), RaidQuietMinutes: intArg(args, "quiet-minutes"),
}
if len(args) == 0 {
reply(s, i, "Say what to change: join-count, join-seconds, action, pause-invites or quiet-minutes.")
return true
}
finishTune(ctx, s, i, e, t, "raid settings changed", "Raid settings saved.\n"+raidLine(ctx, e, i.GuildID, t))
case "antinuke":
if len(subcmd.Options) == 0 {
return true
}
inner := subcmd.Options[0]
innerArgs := opts(inner.Options)
t := securityrepo.Tuning{}
var summary, done string
switch inner.Name {
case "set":
if len(innerArgs) == 0 {
reply(s, i, "Say what to change: window-seconds, lock-server or auto-restore.")
return true
}
t.AntiNukeWindowSeconds, t.AntiNukeLockServer, t.AntiNukeAutoRestore = intArg(innerArgs, "window-seconds"), boolArg(innerArgs, "lock-server"), boolArg(innerArgs, "auto-restore")
summary, done = "antinuke settings changed", "Anti-nuke settings saved. See them with `/security settings`."
case "limit":
action, count := innerArgs["action"].StringValue(), int(innerArgs["count"].IntValue())
t.AntiNukeLimits = map[string]int{action: count}
summary = fmt.Sprintf("antinuke limit %s = %d", action, count)
if count == 0 {
done = fmt.Sprintf("**%s** is never allowed for anyone who isn't the owner or trusted.", action)
} else {
done = fmt.Sprintf("**%s** is limited to **%d** per window.", action, count)
}
default:
return true
}
finishTune(ctx, s, i, e, t, summary, done)
case "heat":
if len(args) == 0 {
reply(s, i, "Say what to change: strictness, action, timeout-minutes, escalate, panic-members or mention-lockdown.")
return true
}
t := securityrepo.Tuning{
HeatStrictness: strArg(args, "strictness"), HeatAction: strArg(args, "action"), HeatTimeoutMinutes: intArg(args, "timeout-minutes"),
HeatEscalate: boolArg(args, "escalate"), HeatPanicMembers: intArg(args, "panic-members"), HeatLockdownMentions: intArg(args, "mention-lockdown"),
}
finishTune(ctx, s, i, e, t, "heat settings changed", "Heat settings saved. See them with `/security settings`.")
case "alert-dms":
on := args["on"].BoolValue()
state := "off"
if on {
state = "on"
}
finishTune(ctx, s, i, e, securityrepo.Tuning{AlertDM: &on}, "alert DMs "+state, "Critical alerts by DM are now **"+state+"**.")
default:
return false
}
return true
}
func finishTune(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine, t securityrepo.Tuning, summary, done string) {
if err := e.Tune(ctx, i.GuildID, i.Member, t, summary); err != nil {
reply(s, i, message("tune", err))
return
}
reply(s, i, done)
}
func raidLine(ctx context.Context, e *security.Engine, guildID string, _ securityrepo.Tuning) string {
st, _, err := e.Settings(ctx, guildID)
if err != nil {
return ""
}
return fmt.Sprintf("%d joins within %ds start a raid, raiders get **%s**, it ends after %d quiet minutes (invites %s).",
st.RaidJoinCount, st.RaidJoinSeconds, st.RaidAction, st.RaidQuietMinutes, pausedText(st.RaidPauseInvites))
}
func pausedText(on bool) string {
if on {
return "are paused"
}
return "stay open"
}
func handleWords(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate, e *security.Engine, inner *discordgo.ApplicationCommandInteractionDataOption) {
args := opts(inner.Options)
list := security.WordList(args["list"].StringValue())
what := map[security.WordList]string{security.UsernameWords: "new members' names", security.ChatWords: "messages"}[list]
switch inner.Name {
case "list":
st, _, err := e.Settings(ctx, i.GuildID)
if err != nil {
reply(s, i, message("words", err))
return
}
words := st.UsernamePatterns
if list == security.ChatWords {
words = st.HeatBlockedWords
}
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
reply(s, i, "You need Manage Server to see the blocked words.")
return
}
if len(words) == 0 {
reply(s, i, "No words are blocked in "+what+".")
return
}
reply(s, i, fmt.Sprintf("Blocked in %s (%d): %s", what, len(words), clip("`"+strings.Join(words, "`, `")+"`", 1700)))
case "add", "remove":
word := args["word"].StringValue()
words, err := e.EditWords(ctx, i.GuildID, i.Member, list, inner.Name == "add", word)
if err != nil {
reply(s, i, message("words", err))
return
}
verb := "is now blocked in"
if inner.Name == "remove" {
verb = "is no longer blocked in"
}
reply(s, i, fmt.Sprintf("`%s` %s %s. %d word(s) on the list.", strings.ToLower(strings.TrimSpace(word)), verb, what, len(words)))
}
}
// describeSettings lists every setting, for /security settings.
func describeSettings(st *securityrepo.Settings) string {
var b strings.Builder
fmt.Fprintf(&b, "**Join Gate** %s\n", onOff(st.JoinGateEnabled))
fmt.Fprintf(&b, "No avatar: %s · New account (under %d days): %s · Invite in name: %s · Blocked names: %s (%d words)\n",
st.NoAvatar, st.MinAccountAgeDays, st.NewAccount, st.AdvertisingName, st.UsernameFilter, len(st.UsernamePatterns))
fmt.Fprintf(&b, "Unverified bots: %s · Bots added by someone untrusted: %s\n\n", st.UnverifiedBot, st.UnauthorizedBot)
fmt.Fprintf(&b, "**Raid mode** %s\n%d joins in %ds → %s · ends after %d quiet minutes · invites %s\n\n",
onOff(st.AntiRaidEnabled), st.RaidJoinCount, st.RaidJoinSeconds, st.RaidAction, st.RaidQuietMinutes, pausedText(st.RaidPauseInvites))
fmt.Fprintf(&b, "**Anti-nuke** %s\nWindow %ds · lock server on a nuke: %s · auto restore: %s\n",
onOff(st.AntiNukeEnabled), st.AntiNukeWindowSeconds, onOff(st.AntiNukeLockServer), onOff(st.AntiNukeAutoRestore))
keys := make([]string, 0, len(st.AntiNukeLimits))
for k := range st.AntiNukeLimits {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s %d", k, st.AntiNukeLimits[k]))
}
fmt.Fprintf(&b, "Limits: %s\n\n", strings.Join(parts, " · "))
mention := "off"
if st.HeatLockdownMentions > 0 {
mention = fmt.Sprintf("%d mentions", st.HeatLockdownMentions)
}
panicMembers := "off"
if st.HeatPanicMembers > 0 {
panicMembers = fmt.Sprintf("%d members", st.HeatPanicMembers)
}
fmt.Fprintf(&b, "**Heat** %s\nStrictness %s · action %s (%d min timeout, escalating %s) · panic at %s · mention flood lockdown at %s · %d blocked words\n\n",
onOff(st.HeatEnabled), st.HeatStrictness, st.HeatAction, st.HeatTimeoutMinutes, onOff(st.HeatEscalate), panicMembers, mention, len(st.HeatBlockedWords))
fmt.Fprintf(&b, "**Alerts** by DM to the owner and trusted: %s", onOff(st.AlertDM))
return clip(b.String(), 1990)
}