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

+1 -1
View File
@@ -62,7 +62,7 @@ Every listener **validates the request again**. The dashboard should check that
## Changing the schema ## Changing the schema
1. Update `schema.sql`. Use `CREATE TABLE IF NOT EXISTS`, and avoid destructive changes: a bot that is a version behind (or a tool reading the same tables) must keep working against the new schema for a moment. Add a column or table first, and remove old ones in a later release. 1. Update `schema.sql`. Use `CREATE TABLE IF NOT EXISTS`, and for a new column on an existing table also add `ALTER TABLE ... ADD COLUMN IF NOT EXISTS ...` right after it, so servers set up earlier get the column when they run the file again. Avoid destructive changes: a bot that is a version behind (or a tool reading the same tables) must keep working against the new schema for a moment. Add a column or table first, and remove old ones in a later release.
2. Add or change the repo method in `internal/db/repos/<area>repo`. 2. Add or change the repo method in `internal/db/repos/<area>repo`.
3. If something outside the bot must make it do something, add a `command_type` to the `CHECK` in the right `*_commands` table and handle it in the listener. 3. If something outside the bot must make it do something, add a `command_type` to the `CHECK` in the right `*_commands` table and handle it in the listener.
4. In your pull request, say that the schema changed and whether an existing table is affected, so the maintainer can port the change to the dashboard's migrations. 4. In your pull request, say that the schema changed and whether an existing table is affected, so the maintainer can port the change to the dashboard's migrations.
+106
View File
@@ -0,0 +1,106 @@
package commands
import (
"regexp"
"testing"
"unicode/utf8"
"github.com/bwmarrin/discordgo"
)
// Discord refuses a command that is over its limits, and then the whole
// command silently doesn't exist. These checks catch that in a test instead
// of after a deploy. See
// https://discord.com/developers/docs/interactions/application-commands
var commandName = regexp.MustCompile(`^[-_\p{L}\p{N}]{1,32}$`)
func checkOptions(t *testing.T, path string, options []*discordgo.ApplicationCommandOption, size *int) {
t.Helper()
if len(options) > 25 {
t.Errorf("%s: %d options, at most 25 allowed", path, len(options))
}
seen := map[string]bool{}
sawOptional := false
for _, o := range options {
where := path + " " + o.Name
if !commandName.MatchString(o.Name) || o.Name != lower(o.Name) {
t.Errorf("%s: option names are 1 to 32 lowercase letters, digits, - or _", where)
}
if seen[o.Name] {
t.Errorf("%s: duplicate option name", where)
}
seen[o.Name] = true
if n := utf8.RuneCountInString(o.Description); n < 1 || n > 100 {
t.Errorf("%s: description is %d characters, must be 1 to 100", where, n)
}
*size += utf8.RuneCountInString(o.Name) + utf8.RuneCountInString(o.Description)
if len(o.Choices) > 25 {
t.Errorf("%s: %d choices, at most 25", where, len(o.Choices))
}
for _, c := range o.Choices {
*size += utf8.RuneCountInString(c.Name)
if s, ok := c.Value.(string); ok {
*size += utf8.RuneCountInString(s)
if utf8.RuneCountInString(s) > 100 || utf8.RuneCountInString(c.Name) > 100 {
t.Errorf("%s: a choice is over 100 characters", where)
}
}
}
switch o.Type {
case discordgo.ApplicationCommandOptionSubCommand, discordgo.ApplicationCommandOptionSubCommandGroup:
if len(o.Options) == 0 && o.Type == discordgo.ApplicationCommandOptionSubCommandGroup {
t.Errorf("%s: a group needs subcommands", where)
}
checkOptions(t, where, o.Options, size)
default:
// Required options come before optional ones.
if o.Required && sawOptional {
t.Errorf("%s: a required option after an optional one", where)
}
if !o.Required {
sawOptional = true
}
}
}
}
func lower(s string) string {
out := []rune(s)
for i, r := range out {
if r >= 'A' && r <= 'Z' {
out[i] = r + 32
}
}
return string(out)
}
func TestCommandsStayWithinDiscordLimits(t *testing.T) {
all := append(append([]*discordgo.ApplicationCommand{}, AllCommands...), BetaCommands...)
names := map[string]bool{}
for _, c := range all {
if names[c.Name] {
t.Errorf("/%s is defined twice", c.Name)
}
names[c.Name] = true
if !commandName.MatchString(c.Name) || c.Name != lower(c.Name) {
t.Errorf("/%s: names are 1 to 32 lowercase letters, digits, - or _", c.Name)
}
if n := utf8.RuneCountInString(c.Description); n < 1 || n > 100 {
t.Errorf("/%s: description is %d characters, must be 1 to 100", c.Name, n)
}
size := utf8.RuneCountInString(c.Name) + utf8.RuneCountInString(c.Description)
checkOptions(t, "/"+c.Name, c.Options, &size)
if size > 8000 {
t.Errorf("/%s: %d characters in total, Discord allows 8000", c.Name, size)
}
if _, ok := handlers[c.Name]; !ok {
t.Errorf("/%s has no handler", c.Name)
}
}
for name := range handlers {
if !names[name] {
t.Errorf("a handler for /%s exists but the command is never registered", name)
}
}
}
@@ -43,7 +43,7 @@ var Security = &discordgo.ApplicationCommand{
Description: "Anti-raid and anti-nuke protection", Description: "Anti-raid and anti-nuke protection",
DefaultMemberPermissions: &manageGuild, DefaultMemberPermissions: &manageGuild,
Contexts: guildOnly, Contexts: guildOnly,
Options: []*discordgo.ApplicationCommandOption{ Options: append([]*discordgo.ApplicationCommandOption{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "status", Description: "Show what's on and who is trusted"}, {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", Type: discordgo.ApplicationCommandOptionSubCommand, Name: "toggle", Description: "Turn a protection on or off",
@@ -94,7 +94,7 @@ var Security = &discordgo.ApplicationCommand{
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "end-raid", Description: "End raid mode now (owner or trusted)"}, {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: "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)"}, {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 { func lockOptions(unlock bool) []*discordgo.ApplicationCommandOption {
@@ -211,6 +211,10 @@ func SecurityHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx, cancel := context.WithTimeout(context.Background(), opTimeout) ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
defer cancel() defer cancel()
if handleTune(ctx, s, i, e, sub) {
return
}
switch sub.Name { switch sub.Name {
case "status": case "status":
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) { if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
@@ -434,7 +438,7 @@ func status(ctx context.Context, e *security.Engine, guildID string) string {
fmt.Fprintf(&b, ", <@%d>", t.TargetID) fmt.Fprintf(&b, ", <@%d>", t.TargetID)
} }
} }
b.WriteString("\nFine-tune filters, limits and actions on the dashboard.") b.WriteString("\nSee every setting with `/security settings`, and change them with `/security joingate`, `words`, `raid`, `antinuke` and `heat`.")
return b.String() return b.String()
} }
+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)
}
+134
View File
@@ -8,6 +8,8 @@ import (
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"strconv"
"strings"
"time" "time"
) )
@@ -197,6 +199,138 @@ func (r *Repo) SetJoinLock(ctx context.Context, guildID int64, mode string) erro
return r.update(ctx, guildID, `join_lock = $2`, mode) return r.update(ctx, guildID, `join_lock = $2`, mode)
} }
// Tuning is a partial update of a server's thresholds, filters and actions.
// A nil field is left as it is. The security package validates it before it
// gets here; the column constraints are the last line of defence.
type Tuning struct {
AlertDM *bool
NoAvatar *string
NewAccount *string
MinAccountAgeDays *int
AdvertisingName *string
UsernameFilter *string
UsernamePatterns *[]string
UnverifiedBot *string
UnauthorizedBot *string
RaidJoinCount *int
RaidJoinSeconds *int
RaidAction *string
RaidPauseInvites *bool
RaidQuietMinutes *int
AntiNukeWindowSeconds *int
AntiNukeLimits map[string]int // only the keys present change
AntiNukeLockServer *bool
AntiNukeAutoRestore *bool
HeatStrictness *string
HeatAction *string
HeatTimeoutMinutes *int
HeatEscalate *bool
HeatBlockedWords *[]string
HeatPanicMembers *int
HeatLockdownMentions *int
}
// UpdateTuning applies the fields that are set, in one statement.
func (r *Repo) UpdateTuning(ctx context.Context, guildID int64, t Tuning) error {
var sets []string
args := []any{}
add := func(col string, v any) {
args = append(args, v)
sets = append(sets, col+` = $`+strconv.Itoa(len(args)+1)) // $1 is the guild
}
addList := func(col string, v []string) {
b, _ := json.Marshal(v)
args = append(args, string(b))
sets = append(sets, col+` = ARRAY(SELECT jsonb_array_elements_text($`+strconv.Itoa(len(args)+1)+`::jsonb))`)
}
if t.AlertDM != nil {
add("alert_dm", *t.AlertDM)
}
if t.NoAvatar != nil {
add("joingate_no_avatar", *t.NoAvatar)
}
if t.NewAccount != nil {
add("joingate_new_account", *t.NewAccount)
}
if t.MinAccountAgeDays != nil {
add("joingate_min_account_age_days", *t.MinAccountAgeDays)
}
if t.AdvertisingName != nil {
add("joingate_advertising_name", *t.AdvertisingName)
}
if t.UsernameFilter != nil {
add("joingate_username_filter", *t.UsernameFilter)
}
if t.UsernamePatterns != nil {
addList("joingate_username_patterns", *t.UsernamePatterns)
}
if t.UnverifiedBot != nil {
add("joingate_unverified_bot", *t.UnverifiedBot)
}
if t.UnauthorizedBot != nil {
add("joingate_unauthorized_bot", *t.UnauthorizedBot)
}
if t.RaidJoinCount != nil {
add("raid_join_count", *t.RaidJoinCount)
}
if t.RaidJoinSeconds != nil {
add("raid_join_seconds", *t.RaidJoinSeconds)
}
if t.RaidAction != nil {
add("raid_action", *t.RaidAction)
}
if t.RaidPauseInvites != nil {
add("raid_pause_invites", *t.RaidPauseInvites)
}
if t.RaidQuietMinutes != nil {
add("raid_quiet_minutes", *t.RaidQuietMinutes)
}
if t.AntiNukeWindowSeconds != nil {
add("antinuke_window_seconds", *t.AntiNukeWindowSeconds)
}
if len(t.AntiNukeLimits) > 0 {
b, _ := json.Marshal(t.AntiNukeLimits)
args = append(args, string(b))
// Merge into what is saved, so setting one limit keeps the others.
sets = append(sets, `antinuke_limits = antinuke_limits || $`+strconv.Itoa(len(args)+1)+`::jsonb`)
}
if t.AntiNukeLockServer != nil {
add("antinuke_lock_server", *t.AntiNukeLockServer)
}
if t.AntiNukeAutoRestore != nil {
add("antinuke_auto_restore", *t.AntiNukeAutoRestore)
}
if t.HeatStrictness != nil {
add("heat_strictness", *t.HeatStrictness)
}
if t.HeatAction != nil {
add("heat_action", *t.HeatAction)
}
if t.HeatTimeoutMinutes != nil {
add("heat_timeout_minutes", *t.HeatTimeoutMinutes)
}
if t.HeatEscalate != nil {
add("heat_escalate", *t.HeatEscalate)
}
if t.HeatBlockedWords != nil {
addList("heat_blocked_words", *t.HeatBlockedWords)
}
if t.HeatPanicMembers != nil {
add("heat_panic_members", *t.HeatPanicMembers)
}
if t.HeatLockdownMentions != nil {
add("heat_lockdown_mentions", *t.HeatLockdownMentions)
}
if len(sets) == 0 {
return nil
}
return r.update(ctx, guildID, strings.Join(sets, ", "), args...)
}
// ---- Trusted ---- // ---- Trusted ----
type Trusted struct { type Trusted struct {
+55
View File
@@ -153,6 +153,61 @@ func (f *fakeStore) SetJoinLock(_ context.Context, g int64, mode string) error {
f.set(g).JoinLock = mode f.set(g).JoinLock = mode
return nil return nil
} }
func (f *fakeStore) UpdateTuning(_ context.Context, g int64, t securityrepo.Tuning) error {
s := f.set(g)
str := func(dst *string, v *string) {
if v != nil {
*dst = *v
}
}
num := func(dst *int, v *int) {
if v != nil {
*dst = *v
}
}
flag := func(dst *bool, v *bool) {
if v != nil {
*dst = *v
}
}
if t.AlertDM != nil {
s.AlertDM = *t.AlertDM
}
str(&s.NoAvatar, t.NoAvatar)
str(&s.NewAccount, t.NewAccount)
num(&s.MinAccountAgeDays, t.MinAccountAgeDays)
str(&s.AdvertisingName, t.AdvertisingName)
str(&s.UsernameFilter, t.UsernameFilter)
if t.UsernamePatterns != nil {
s.UsernamePatterns = *t.UsernamePatterns
}
str(&s.UnverifiedBot, t.UnverifiedBot)
str(&s.UnauthorizedBot, t.UnauthorizedBot)
num(&s.RaidJoinCount, t.RaidJoinCount)
num(&s.RaidJoinSeconds, t.RaidJoinSeconds)
str(&s.RaidAction, t.RaidAction)
flag(&s.RaidPauseInvites, t.RaidPauseInvites)
num(&s.RaidQuietMinutes, t.RaidQuietMinutes)
num(&s.AntiNukeWindowSeconds, t.AntiNukeWindowSeconds)
if s.AntiNukeLimits == nil {
s.AntiNukeLimits = securityrepo.DefaultLimits()
}
for k, v := range t.AntiNukeLimits {
s.AntiNukeLimits[k] = v
}
flag(&s.AntiNukeLockServer, t.AntiNukeLockServer)
flag(&s.AntiNukeAutoRestore, t.AntiNukeAutoRestore)
str(&s.HeatStrictness, t.HeatStrictness)
str(&s.HeatAction, t.HeatAction)
num(&s.HeatTimeoutMinutes, t.HeatTimeoutMinutes)
flag(&s.HeatEscalate, t.HeatEscalate)
if t.HeatBlockedWords != nil {
s.HeatBlockedWords = *t.HeatBlockedWords
}
num(&s.HeatPanicMembers, t.HeatPanicMembers)
num(&s.HeatLockdownMentions, t.HeatLockdownMentions)
return nil
}
func (f *fakeStore) ListTrusted(_ context.Context, g int64) ([]securityrepo.Trusted, error) { func (f *fakeStore) ListTrusted(_ context.Context, g int64) ([]securityrepo.Trusted, error) {
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
+1
View File
@@ -48,6 +48,7 @@ type Store interface {
SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error SetAlerts(ctx context.Context, guildID, channelID, roleID int64) error
SetQuarantineRole(ctx context.Context, guildID, roleID int64) error SetQuarantineRole(ctx context.Context, guildID, roleID int64) error
SetJoinLock(ctx context.Context, guildID int64, mode string) error SetJoinLock(ctx context.Context, guildID int64, mode string) error
UpdateTuning(ctx context.Context, guildID int64, t securityrepo.Tuning) error
ListTrusted(ctx context.Context, guildID int64) ([]securityrepo.Trusted, error) ListTrusted(ctx context.Context, guildID int64) ([]securityrepo.Trusted, error)
AddTrusted(ctx context.Context, guildID int64, t securityrepo.Trusted) error AddTrusted(ctx context.Context, guildID int64, t securityrepo.Trusted) error
RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error) RemoveTrusted(ctx context.Context, guildID, targetID int64) (bool, error)
+217
View File
@@ -0,0 +1,217 @@
package security
import (
"context"
"fmt"
"sort"
"strings"
"unicode"
"unicode/utf8"
"velox-bot/internal/db/repos/securityrepo"
"github.com/bwmarrin/discordgo"
)
// Tuning the thresholds, filters and actions. Everything the Join Gate, raid
// mode, anti-nuke and heat do can be adjusted from Discord with these calls;
// a dashboard is never needed. They follow the same rules as the rest of the
// configuration: the server owner or someone on the trusted list, checked
// here and not left to Discord's command permissions.
const (
maxWords = 50 // blocked words per list
maxWordLength = 50
)
var (
filterActions = map[string]bool{securityrepo.ActionOff: true, securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
botActions = map[string]bool{securityrepo.ActionOff: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
raidActions = map[string]bool{securityrepo.ActionAlert: true, securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
heatActions = map[string]bool{securityrepo.ActionTimeout: true, securityrepo.ActionKick: true, securityrepo.ActionBan: true}
strictnessSet = map[string]bool{"low": true, "medium": true, "high": true}
nukeLimitKeys = map[string]bool{
securityrepo.LimitChannelDelete: true, securityrepo.LimitChannelCreate: true, securityrepo.LimitRoleDelete: true,
securityrepo.LimitRoleCreate: true, securityrepo.LimitBan: true, securityrepo.LimitKick: true, securityrepo.LimitWebhookCreate: true,
}
)
// CleanWords trims, lowercases and de-duplicates a list of blocked words and
// rejects anything oversized or containing control characters. They are
// matched as plain text, never as regular expressions.
func CleanWords(in []string) ([]string, error) {
seen := map[string]bool{}
out := []string{}
for _, w := range in {
w = strings.ToLower(strings.TrimSpace(w))
if w == "" || seen[w] {
continue
}
if utf8.RuneCountInString(w) > maxWordLength {
return nil, UserError("Each blocked word can be at most 50 characters.")
}
for _, r := range w {
if unicode.IsControl(r) {
return nil, UserError("Blocked words can't contain control characters.")
}
}
seen[w] = true
out = append(out, w)
}
if len(out) > maxWords {
return nil, UserError("You can block at most 50 words.")
}
sort.Strings(out)
return out, nil
}
func inRange(v *int, lo, hi int) bool { return v == nil || (*v >= lo && *v <= hi) }
func oneOf(v *string, allowed map[string]bool) bool { return v == nil || allowed[*v] }
// ValidateTuning returns a message for the first invalid field, or "". It
// also cleans the word lists in place.
func ValidateTuning(t *securityrepo.Tuning) error {
for name, v := range map[string]*string{
"No avatar": t.NoAvatar, "New account": t.NewAccount, "Invite in name": t.AdvertisingName, "Blocked words": t.UsernameFilter,
} {
if !oneOf(v, filterActions) {
return UserError(name + " can be off, timeout, kick or ban.")
}
}
if !oneOf(t.UnverifiedBot, botActions) || !oneOf(t.UnauthorizedBot, botActions) {
return UserError("Bot filters can only be off, kick or ban.")
}
if !oneOf(t.RaidAction, raidActions) {
return UserError("The raid action can be alert, timeout, kick or ban.")
}
if !oneOf(t.HeatStrictness, strictnessSet) {
return UserError("Heat strictness must be low, medium or high.")
}
if !oneOf(t.HeatAction, heatActions) {
return UserError("The heat action can be timeout, kick or ban.")
}
switch {
case !inRange(t.MinAccountAgeDays, 1, 365):
return UserError("Minimum account age must be between 1 and 365 days.")
case !inRange(t.RaidJoinCount, 3, 100):
return UserError("The raid join count must be between 3 and 100.")
case !inRange(t.RaidJoinSeconds, 5, 300):
return UserError("The raid window must be between 5 and 300 seconds.")
case !inRange(t.RaidQuietMinutes, 1, 120):
return UserError("The raid quiet period must be between 1 and 120 minutes.")
case !inRange(t.AntiNukeWindowSeconds, 5, 300):
return UserError("The anti-nuke window must be between 5 and 300 seconds.")
case !inRange(t.HeatTimeoutMinutes, 1, 10080):
return UserError("The heat timeout must be between 1 minute and 7 days (10080 minutes).")
case !inRange(t.HeatPanicMembers, 0, 50):
return UserError("Heat panic must be between 0 (off) and 50 members.")
case t.HeatLockdownMentions != nil && *t.HeatLockdownMentions != 0 && (*t.HeatLockdownMentions < 5 || *t.HeatLockdownMentions > 1000):
return UserError("The mention flood lockdown must be 0 (off) or between 5 and 1000 mentions.")
}
for k, v := range t.AntiNukeLimits {
if !nukeLimitKeys[k] {
return UserError("Unknown anti-nuke limit " + fmt.Sprintf("%q", k) + ".")
}
if v < 0 || v > 100 {
return UserError("Anti-nuke limits must be between 0 and 100 (0 means never allowed).")
}
}
if t.UsernamePatterns != nil {
words, err := CleanWords(*t.UsernamePatterns)
if err != nil {
return err
}
t.UsernamePatterns = &words
}
if t.HeatBlockedWords != nil {
words, err := CleanWords(*t.HeatBlockedWords)
if err != nil {
return err
}
t.HeatBlockedWords = &words
}
return nil
}
// Tune changes thresholds, filters and actions. Only the server owner or
// someone on the trusted list may.
func (e *Engine) Tune(ctx context.Context, guildID string, actor *discordgo.Member, t securityrepo.Tuning, summary string) error {
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
return err
}
if err := ValidateTuning(&t); err != nil {
return err
}
if err := e.st.UpdateTuning(ctx, parseID(guildID), t); err != nil {
return err
}
e.Invalidate(guildID)
e.event(ctx, guildID, "settings", actor.User.ID, "", summary)
return nil
}
// WordList names one of the two blocked word lists.
type WordList string
const (
UsernameWords WordList = "username" // checked against new members' names by the Join Gate
ChatWords WordList = "chat" // checked against messages by heat
)
// EditWords adds or removes one blocked word and returns the list as saved.
func (e *Engine) EditWords(ctx context.Context, guildID string, actor *discordgo.Member, list WordList, add bool, word string) ([]string, error) {
if err := e.RequireTrusted(ctx, guildID, actor); err != nil {
return nil, err
}
e.Invalidate(guildID) // read what is saved now, not what was cached
cur, _, err := e.load(ctx, guildID)
if err != nil {
return nil, err
}
var words []string
switch list {
case UsernameWords:
words = append(words, cur.UsernamePatterns...)
case ChatWords:
words = append(words, cur.HeatBlockedWords...)
default:
return nil, UserError("Unknown list.")
}
word = strings.ToLower(strings.TrimSpace(word))
if word == "" {
return nil, UserError("Give a word to block.")
}
if add {
words = append(words, word)
} else {
kept := words[:0]
for _, w := range words {
if w != word {
kept = append(kept, w)
}
}
if len(kept) == len(words) {
return nil, UserError("That word isn't on the list.")
}
words = kept
}
clean, err := CleanWords(words)
if err != nil {
return nil, err
}
t := securityrepo.Tuning{}
if list == UsernameWords {
t.UsernamePatterns = &clean
} else {
t.HeatBlockedWords = &clean
}
verb := "removed"
if add {
verb = "added"
}
if err := e.Tune(ctx, guildID, actor, t, fmt.Sprintf("%s word list: %s %q", list, verb, word)); err != nil {
return nil, err
}
return clean, nil
}
+147
View File
@@ -0,0 +1,147 @@
package security
import (
"context"
"errors"
"strings"
"testing"
"velox-bot/internal/db/repos/securityrepo"
)
func ptr[T any](v T) *T { return &v }
func TestValidateTuning(t *testing.T) {
cases := []struct {
name string
t securityrepo.Tuning
ok bool
}{
{"nothing to change is fine", securityrepo.Tuning{}, true},
{"a filter action", securityrepo.Tuning{NoAvatar: ptr("timeout")}, true},
{"an unknown filter action", securityrepo.Tuning{NoAvatar: ptr("nuke")}, false},
{"bots can't be timed out", securityrepo.Tuning{UnverifiedBot: ptr("timeout")}, false},
{"bots can be kicked", securityrepo.Tuning{UnauthorizedBot: ptr("kick")}, true},
{"raid alert only", securityrepo.Tuning{RaidAction: ptr("alert")}, true},
{"heat can't just alert", securityrepo.Tuning{HeatAction: ptr("alert")}, false},
{"strictness", securityrepo.Tuning{HeatStrictness: ptr("extreme")}, false},
{"account age low", securityrepo.Tuning{MinAccountAgeDays: ptr(0)}, false},
{"account age high", securityrepo.Tuning{MinAccountAgeDays: ptr(366)}, false},
{"raid join count edges", securityrepo.Tuning{RaidJoinCount: ptr(3)}, true},
{"raid join count too low", securityrepo.Tuning{RaidJoinCount: ptr(2)}, false},
{"raid window too long", securityrepo.Tuning{RaidJoinSeconds: ptr(301)}, false},
{"quiet minutes", securityrepo.Tuning{RaidQuietMinutes: ptr(121)}, false},
{"nuke window", securityrepo.Tuning{AntiNukeWindowSeconds: ptr(4)}, false},
{"heat timeout max", securityrepo.Tuning{HeatTimeoutMinutes: ptr(10080)}, true},
{"heat timeout over", securityrepo.Tuning{HeatTimeoutMinutes: ptr(10081)}, false},
{"panic off", securityrepo.Tuning{HeatPanicMembers: ptr(0)}, true},
{"panic too many", securityrepo.Tuning{HeatPanicMembers: ptr(51)}, false},
{"mention flood off", securityrepo.Tuning{HeatLockdownMentions: ptr(0)}, true},
{"mention flood too small", securityrepo.Tuning{HeatLockdownMentions: ptr(4)}, false},
{"mention flood max", securityrepo.Tuning{HeatLockdownMentions: ptr(1000)}, true},
{"a known limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": 0}}, true},
{"an unknown limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban_everyone": 1}}, false},
{"a negative limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": -1}}, false},
{"a huge limit", securityrepo.Tuning{AntiNukeLimits: map[string]int{"ban": 101}}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := ValidateTuning(&tc.t)
if (err == nil) != tc.ok {
t.Errorf("ValidateTuning = %v, want ok=%v", err, tc.ok)
}
var ue UserError
if err != nil && !errors.As(err, &ue) {
t.Errorf("a refusal must be a UserError the command can show: %T", err)
}
})
}
}
func TestCleanWords(t *testing.T) {
got, err := CleanWords([]string{" Spam ", "spam", "", "ADS", "ads "})
if err != nil || len(got) != 2 || got[0] != "ads" || got[1] != "spam" {
t.Errorf("trimmed, lowercased, de-duplicated and sorted: %v, %v", got, err)
}
if _, err := CleanWords([]string{strings.Repeat("x", 51)}); err == nil {
t.Error("an oversized word is refused")
}
if _, err := CleanWords([]string{"bad\x00word"}); err == nil {
t.Error("control characters are refused")
}
many := make([]string, 51)
for i := range many {
many[i] = strings.Repeat("a", i+1)
}
if _, err := CleanWords(many); err == nil {
t.Error("more than 50 words is refused")
}
}
func TestTuneNeedsTheOwnerOrTrusted(t *testing.T) {
e, d, st, _ := testEngine(t)
ctx := context.Background()
owner := d.members["1"]
admin := d.addMember("50")
trustedUser := d.addMember("60")
st.trusted[100] = []securityrepo.Trusted{{TargetID: 60, Kind: "user"}}
refresh(e)
tun := securityrepo.Tuning{RaidJoinCount: ptr(20)}
if err := e.Tune(ctx, guild, admin, tun, "x"); !errors.Is(err, ErrNotTrusted) {
t.Errorf("an admin who isn't trusted must not tune: %v", err)
}
if err := e.Tune(ctx, guild, nil, tun, "x"); err == nil {
t.Error("no actor, no change")
}
if st.set(100).RaidJoinCount == 20 {
t.Fatal("nothing may be saved for a refused change")
}
if err := e.Tune(ctx, guild, owner, tun, "raid join count 20"); err != nil {
t.Fatalf("the owner may: %v", err)
}
if st.set(100).RaidJoinCount != 20 {
t.Error("the owner's change is saved")
}
if err := e.Tune(ctx, guild, trustedUser, securityrepo.Tuning{RaidJoinCount: ptr(2)}, "x"); err == nil {
t.Error("an invalid value is refused even for the owner or trusted")
}
if st.set(100).RaidJoinCount != 20 {
t.Error("an invalid change must leave the old value")
}
if len(st.events) == 0 || st.events[len(st.events)-1].Kind != "settings" {
t.Errorf("the change is written to the history: %v", st.events)
}
}
func TestEditWords(t *testing.T) {
e, d, st, _ := testEngine(t)
ctx := context.Background()
owner := d.members["1"]
admin := d.addMember("50")
if _, err := e.EditWords(ctx, guild, admin, UsernameWords, true, "scam"); !errors.Is(err, ErrNotTrusted) {
t.Errorf("untrusted: %v", err)
}
words, err := e.EditWords(ctx, guild, owner, UsernameWords, true, " SCAM ")
if err != nil || len(words) != 1 || words[0] != "scam" {
t.Fatalf("add: %v, %v", words, err)
}
if _, err := e.EditWords(ctx, guild, owner, ChatWords, true, "free nitro"); err != nil {
t.Fatalf("the chat list is separate: %v", err)
}
s := st.set(100)
if len(s.UsernamePatterns) != 1 || len(s.HeatBlockedWords) != 1 {
t.Errorf("each list keeps its own words: %v %v", s.UsernamePatterns, s.HeatBlockedWords)
}
if _, err := e.EditWords(ctx, guild, owner, UsernameWords, false, "nothing"); err == nil {
t.Error("removing a word that isn't there says so")
}
words, err = e.EditWords(ctx, guild, owner, UsernameWords, false, "scam")
if err != nil || len(words) != 0 {
t.Errorf("remove: %v, %v", words, err)
}
if _, err := e.EditWords(ctx, guild, owner, UsernameWords, true, " "); err == nil {
t.Error("a blank word is refused")
}
}
+5
View File
@@ -288,6 +288,8 @@ CREATE TABLE IF NOT EXISTS security_settings (
-- Where alerts go, and who gets pinged for raids and nukes. -- Where alerts go, and who gets pinged for raids and nukes.
alert_channel_id BIGINT, alert_channel_id BIGINT,
alert_role_id BIGINT, alert_role_id BIGINT,
-- Also DM critical alerts to the owner and trusted users.
alert_dm BOOLEAN NOT NULL DEFAULT TRUE,
-- Join Gate: checks run on every join, each with its own action. -- Join Gate: checks run on every join, each with its own action.
joingate_enabled BOOLEAN NOT NULL DEFAULT FALSE, joingate_enabled BOOLEAN NOT NULL DEFAULT FALSE,
@@ -348,6 +350,9 @@ CREATE TABLE IF NOT EXISTS security_settings (
-- Join lockdown (/lockdown joins): every new member is kicked or banned. -- Join lockdown (/lockdown joins): every new member is kicked or banned.
join_lock TEXT NOT NULL DEFAULT 'off' CHECK (join_lock IN ('off', 'kick', 'ban')) join_lock TEXT NOT NULL DEFAULT 'off' CHECK (join_lock IN ('off', 'kick', 'ban'))
); );
-- Servers set up before alert_dm existed get it here (CREATE TABLE IF NOT EXISTS
-- doesn't change an existing table).
ALTER TABLE security_settings ADD COLUMN IF NOT EXISTS alert_dm BOOLEAN NOT NULL DEFAULT TRUE;
-- Users and roles exempt from anti-nuke, who may also manage its settings. -- Users and roles exempt from anti-nuke, who may also manage its settings.
-- The server owner is always trusted and never listed here. -- The server owner is always trusted and never listed here.