diff --git a/src/command-abstractions/text-based-command-builder.ts b/src/command-abstractions/text-based-command-builder.ts index a8aef89..dab18b9 100644 --- a/src/command-abstractions/text-based-command-builder.ts +++ b/src/command-abstractions/text-based-command-builder.ts @@ -21,6 +21,17 @@ export type TextBasedCommandParameterOptions = { autocomplete?: (partial: string, command_name: string) => { name: string; value: string }[]; }; +export type TextBasedCommandParameterOptionsWithChoices = TextBasedCommandParameterOptions & + ( + | { + choices: { name: string; value: T }[]; + autocomplete?: never; + } + | { + choices?: never; + } + ); + export type CommandCategory = | "Wiki Articles" | "References" @@ -77,7 +88,7 @@ export class TextBasedCommandBuilder< return this as unknown as TextBasedCommandBuilder; } - add_string_option( + add_string_option>( option: O, ): TextBasedCommandBuilder< Append>, @@ -98,7 +109,7 @@ export class TextBasedCommandBuilder< >; } - add_number_option( + add_number_option>( option: O, ): TextBasedCommandBuilder< Append>, diff --git a/src/command-abstractions/text-based-command-descriptor.ts b/src/command-abstractions/text-based-command-descriptor.ts index de39f18..6d333e6 100644 --- a/src/command-abstractions/text-based-command-descriptor.ts +++ b/src/command-abstractions/text-based-command-descriptor.ts @@ -8,6 +8,7 @@ import { zip } from "../utils/iterables.js"; import { M } from "../utils/debugging-and-logging.js"; import { TextBasedCommandParameterOptions, + TextBasedCommandParameterOptionsWithChoices, TextBasedCommandOptionType, TextBasedCommandBuilder, EarlyReplyMode, @@ -18,6 +19,12 @@ import { BaseBotInteraction } from "./interaction-base.js"; import { Wheatley, create_basic_embed } from "../wheatley.js"; import { colors } from "../common.js"; +class ParseError extends Error { + constructor(readonly promise: Promise) { + super(); + } +} + export class BotTextBasedCommand extends BaseBotInteraction<[TextBasedCommand, ...Args]> { public readonly options = new Discord.Collection< string, @@ -97,11 +104,17 @@ export class BotTextBasedCommand extends BaseBotInt .setDescription(option.description) .setRequired(!!option.required); if (option.type == "string") { - djs_command.addStringOption(slash_option => - apply_options(slash_option).setAutocomplete(!!option.autocomplete), - ); + djs_command.addStringOption(slash_option => { + const opt = option as TextBasedCommandParameterOptionsWithChoices; + return apply_options(slash_option) + .setChoices(opt.choices ?? []) + .setAutocomplete(!!opt.autocomplete); + }); } else if (option.type == "number") { - djs_command.addNumberOption(slash_option => apply_options(slash_option)); + djs_command.addNumberOption(slash_option => { + const opt = option as TextBasedCommandParameterOptionsWithChoices; + return apply_options(slash_option).setChoices(opt.choices ?? []); + }); } else if (option.type == "boolean") { djs_command.addBooleanOption(slash_option => apply_options(slash_option)); } else if (option.type == "user") { @@ -136,161 +149,170 @@ export class BotTextBasedCommand extends BaseBotInt }; const command_options: unknown[] = []; for (const [i, option] of [...this.options.values()].entries()) { - const required_arg_error = async () => { - if (i === 0) { - await command_obj.reply({ embeds: [this.command_info_and_description_embed()] }); - } else { - await reply_with_error(`Required argument "${option.title}" not found`); - } - }; - if (option.type == "string") { - if (option.regex) { - const match = command_body.match(option.regex); - if (match) { - command_options.push(match[0]); - command_body = command_body.slice(match[0].length).trim(); - } else if (!option.required) { - command_options.push(null); + try { + const required_arg_error = async () => { + if (i === 0) { + return new ParseError( + command_obj.reply({ embeds: [this.command_info_and_description_embed()] }), + ); + } + return new ParseError(reply_with_error(`Required argument "${option.title}" not found`)); + }; + const validate_choices = (v: T) => { + const opt = option as TextBasedCommandParameterOptionsWithChoices; + if (opt.choices && !opt.choices.find(({ name, value }) => v == value)) { + throw new ParseError(reply_with_error(`Invalid argument choice ${v}`)); + } + return v; + }; + if (option.type == "string") { + if (option.regex) { + const match = command_body.match(option.regex); + if (match) { + command_options.push(validate_choices(match[0])); + command_body = command_body.slice(match[0].length).trim(); + } else if (!option.required) { + command_options.push(null); + } else { + throw required_arg_error(); + } + } else if (i == this.options.size - 1) { + if (command_body !== "") { + command_options.push(validate_choices(command_body)); + command_body = ""; + } else if (!option.required) { + command_options.push(null); + } else { + throw required_arg_error(); + } } else { - await required_arg_error(); - return; + const re = /^\S+/; + const match = command_body.match(re); + if (match) { + command_options.push(validate_choices(match[0])); + command_body = command_body.slice(match[0].length).trim(); + } else if (!option.required) { + command_options.push(null); + } else { + throw required_arg_error(); + } } - } else if (i == this.options.size - 1) { - if (command_body !== "") { - command_options.push(command_body); - command_body = ""; + } else if (option.type == "number") { + // TODO: Handle optional number... + const re = /^\d+/; + const match = command_body.match(re); + if (match) { + command_options.push(validate_choices(parseInt(match[0]))); + command_body = command_body.slice(match[0].length).trim(); } else if (!option.required) { command_options.push(null); } else { - await required_arg_error(); - return; + throw required_arg_error(); } - } else { - const re = /^\S+/; + } else if (option.type == "boolean") { + const re = /^(?:true|false)/i; const match = command_body.match(re); if (match) { - command_options.push(match[0]); + command_options.push(match[0].toLowerCase() === "true"); command_body = command_body.slice(match[0].length).trim(); } else if (!option.required) { command_options.push(null); } else { - await required_arg_error(); - return; - } - } - } else if (option.type == "number") { - // TODO: Handle optional number... - const re = /^\d+/; - const match = command_body.match(re); - if (match) { - command_options.push(parseInt(match[0])); - command_body = command_body.slice(match[0].length).trim(); - } else if (!option.required) { - command_options.push(null); - } else { - await required_arg_error(); - return; - } - } else if (option.type == "boolean") { - const re = /^(?:true|false)/i; - const match = command_body.match(re); - if (match) { - command_options.push(match[0].toLowerCase() === "true"); - command_body = command_body.slice(match[0].length).trim(); - } else if (!option.required) { - command_options.push(null); - } else { - await required_arg_error(); - return; - } - } else if (option.type == "user") { - const re = /^(?:<@(\d{10,})>|(\d{10,}))/; - const match = command_body.match(re); - if (match) { - const userid = match[1] || match[2]; - try { - const user = await this.wheatley.client.users.fetch(userid); - command_options.push(user); - command_body = command_body.slice(match[0].length).trim(); - } catch (e) { - M.debug(e); - await reply_with_error(`Unable to find user`, true); - return; - } - } else if (message.type === Discord.MessageType.Reply) { - // Handle reply as an argument, only if no text argument is provided - // NOTE: If there's ever a command like !x this won't quite work - try { - const reply_message = await this.wheatley.fetch_message_reply(message); - command_options.push(reply_message.author); - } catch (e) { - await reply_with_error(`Error fetching reply`, true); - this.wheatley.critical_error(e); - return; + throw required_arg_error(); } - } else if (!option.required) { - command_options.push(null); - } else { - await required_arg_error(); - return; - } - } else if (option.type == "users") { - const users: Discord.User[] = []; - while (true) { - const re = /^(?:<@(\d{10,})>|(\d{10,}))+/; + } else if (option.type == "user") { + const re = /^(?:<@(\d{10,})>|(\d{10,}))/; const match = command_body.match(re); if (match) { const userid = match[1] || match[2]; try { const user = await this.wheatley.client.users.fetch(userid); - users.push(user); + command_options.push(user); command_body = command_body.slice(match[0].length).trim(); } catch (e) { M.debug(e); await reply_with_error(`Unable to find user`, true); return; } + } else if (message.type === Discord.MessageType.Reply) { + // Handle reply as an argument, only if no text argument is provided + // NOTE: If there's ever a command like !x this won't quite work + try { + const reply_message = await this.wheatley.fetch_message_reply(message); + command_options.push(reply_message.author); + } catch (e) { + await reply_with_error(`Error fetching reply`, true); + this.wheatley.critical_error(e); + return; + } + } else if (!option.required) { + command_options.push(null); } else { - break; + throw required_arg_error(); } - } - if (users.length > 0) { - command_options.push(users); - } else { - if (!option.required) { - command_options.push(null); + } else if (option.type == "users") { + const users: Discord.User[] = []; + while (true) { + const re = /^(?:<@(\d{10,})>|(\d{10,}))+/; + const match = command_body.match(re); + if (match) { + const userid = match[1] || match[2]; + try { + const user = await this.wheatley.client.users.fetch(userid); + users.push(user); + command_body = command_body.slice(match[0].length).trim(); + } catch (e) { + M.debug(e); + await reply_with_error(`Unable to find user`, true); + return; + } + } else { + break; + } + } + if (users.length > 0) { + command_options.push(users); } else { - await required_arg_error(); - return; + if (!option.required) { + command_options.push(null); + } else { + throw required_arg_error(); + } } - } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - } else if (option.type == "role") { - const re = new RegExp( - this.wheatley.guild.roles.cache - .map(role => escape_regex(role.name)) - .filter(name => name !== "@everyone") - .join("|"), - "i", - ); - const match = command_body.match(re); - if (match) { - command_options.push( - unwrap( - this.wheatley.guild.roles.cache.find( - role => role.name.toLowerCase() === match[0].toLowerCase(), - ), - ), + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (option.type == "role") { + const re = new RegExp( + this.wheatley.guild.roles.cache + .map(role => escape_regex(role.name)) + .filter(name => name !== "@everyone") + .join("|"), + "i", ); - command_body = command_body.slice(match[0].length).trim(); - } else if (!option.required) { - command_options.push(null); + const match = command_body.match(re); + if (match) { + command_options.push( + unwrap( + this.wheatley.guild.roles.cache.find( + role => role.name.toLowerCase() === match[0].toLowerCase(), + ), + ), + ); + command_body = command_body.slice(match[0].length).trim(); + } else if (!option.required) { + command_options.push(null); + } else { + throw required_arg_error(); + } } else { - await required_arg_error(); + assert(false, "unhandled option type"); + } + } catch (e) { + if (e instanceof ParseError) { + await e.promise; return; } - } else { - assert(false, "unhandled option type"); + this.wheatley.critical_error(e); + return; } } if (command_body != "" && !this.allow_trailing_junk) {