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:
1 parent
5657d0789a
commit
d95ebf473e
10 files changed
+1020
-4
No files matched your search
@@ -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",
|
||||
DefaultMemberPermissions: &manageGuild,
|
||||
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: "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: "snapshot", Description: "Save a snapshot of roles and channels to restore from"},
|
||||
{Type: discordgo.ApplicationCommandOptionSubCommand, Name: "restore", Description: "Recreate deleted roles and channels from the latest snapshot (owner or trusted)"},
|
||||
},
|
||||
}, tuneOptions()...),
|
||||
}
|
||||
|
||||
func lockOptions(unlock bool) []*discordgo.ApplicationCommandOption {
|
||||
@@ -211,6 +211,10 @@ func SecurityHandler(s *discordgo.Session, i *discordgo.InteractionCreate) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), opTimeout)
|
||||
defer cancel()
|
||||
|
||||
if handleTune(ctx, s, i, e, sub) {
|
||||
return
|
||||
}
|
||||
|
||||
switch sub.Name {
|
||||
case "status":
|
||||
if !allowedOrTrusted(ctx, e, i, discordgo.PermissionManageGuild) {
|
||||
@@ -434,7 +438,7 @@ func status(ctx context.Context, e *security.Engine, guildID string) string {
|
||||
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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user