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) } } }