Every dashboard feature is also a command #30
No files matched your search
+1
-1
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user