- /music skip, pause, resume, stop, repeat, remove, move (DJ role) - /config view, clear, toggletwitch, streamers, beta-features (server owner) - /slvl status, remove-reward; /moderation bans, timeouts; /security history - /beta is now the bot owner's DM-only command: set, off, servers, remove-server, status - betasettings service and repo methods, with integration tests Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
107 lines
3.3 KiB
Go
107 lines
3.3 KiB
Go
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(append([]*discordgo.ApplicationCommand{}, AllCommands...), BetaCommands...), OwnerCommands...)
|
|
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)
|
|
}
|
|
}
|
|
}
|