diff --git a/.gitignore b/.gitignore index de3c7e5..4cd5879 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ # Node things node_modules .nyc_output +lib # My dev things todo diff --git a/.mocharc.json b/.mocharc.json new file mode 100644 index 0000000..65c0154 --- /dev/null +++ b/.mocharc.json @@ -0,0 +1,4 @@ +{ + "loader": "ts-node/esm", + "experimental-specifier-resolution": "node" +} \ No newline at end of file diff --git a/README.md b/README.md index 26b2d93..ebde991 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ src="https://www.gnu.org/graphics/lgplv3-with-text-154x68.png"> +_Now with 100% more TypeScript!_ + _**NOTE: version 2.x of this library supports discord.js v14. If you still need v13 support, use an older 1.x version of this library.**_ @@ -40,7 +42,7 @@ It also supports some additional "option" types This library adds a new builder `SlashCommandRegistry` that serves as the entry point for defining all of your commands. Existing builders from -[@discordjs/builders](https://www.npmjs.com/package/@discordjs/builders) +[`@discordjs/builders`](https://www.npmjs.com/package/@discordjs/builders) still work as you expect, but there's a new function added to all of them: `.setHandler(handler)`. The handler is a callback function that expects a Discord.js `Interaction` instance. The `SlashCommandRegistry` will figure out @@ -120,6 +122,32 @@ npm exec register src/commands.js -- --app 1234 --token path/to/token_file npm exec register src/commands.js -- --app 1234 --token "my token text" ``` +### Using the script with TypeScript + +If you're using TypeScript and aren't transpiling to JavaScript (e.g. running +your bot with `ts-node`), you can still use this script. This library ships +with a separate, stand-alone TypeScript version of this register script. + +Then you can run the register script from `node_modules` like this: +```sh +npx ts-node --esm --skipIgnore --logError --compilerOptions '{"esModuleInterop":true}' node_modules/discord-command-registry/src/register.ts +``` + +If you'd rather remove all the `ts-node` flags, you can also specify these +options in your `tsconfig.json`, like this: +```json +{ + "ts-node": { + "esm": true, + "skipIgnore": true, + "logError": true, + "compilerOptions": { + "esModuleInterop": true, + } + } +} +``` + ### Using `SlashCommandRegistry.registerCommands()` If you want more control over registration, the `SlashCommandRegistry` can @@ -210,14 +238,19 @@ some flexibility if you have many commands that all use similar code. ## Additional option types Discord (and Discord.js) does not currently support command options for things -like Applications. This library provides functions to approximate these -additional option types: +like Applications. This library provides builders and functions to approximate +these additional option types: + +(Note: these are registered as string options under the hood) +- `SlashCommandCustomOption` +- `addApplicationOption(custom_option)` +- `addEmojiOption(custom_option)` - `getApplication(interaction, option_name, required=false)` - `getEmoji(interaction, option_name, required=false)` -To use these, register the option as a string option, then use the -`Options.getX(...)` helpers to retrieve the value. +To use these, register the option using the appropriate custom builder, then +use the `Options.getX(...)` helpers to retrieve the value. For example, this is a functional example of an Application option: @@ -232,7 +265,7 @@ const commands = new SlashCommandRegistry() .addName('mycmd') .addDescription('Example command that has an application option') // Add your application option as a string option - .addStringOption(option => option + .addApplicationOption(option => option .setName('app') .setDescription('An application ID') ) diff --git a/index.js b/index.js deleted file mode 100644 index 5f34cda..0000000 --- a/index.js +++ /dev/null @@ -1,437 +0,0 @@ -/******************************************************************************* - * This file is part of discord-command-registry, a Discord.js slash command - * library for Node.js - * Copyright (C) 2021 Mimickal (Mia Moretti). - * - * discord-command-registry is free software under the GNU Lesser General Public - * License v3.0. See LICENSE.md or - * for more information. - ******************************************************************************/ - -/** - * The function called during command execution. - * - * @callback Handler - * @param {CommandInteraction} interaction A Discord.js CommandInteraction object. - * @return {any} - */ - -const { - Application, - BaseInteraction, - ContextMenuCommandBuilder, - Snowflake, - CommandInteraction, - REST, - Routes, - SlashCommandBuilder, - SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, -} = require('discord.js'); - -const API_VERSION = '10'; - -/** - * Sets a handler function called when this command is executed. - * - * @param {Handler} handler The handler function - * @returns {any} instance so we can chain calls. - */ -function setHandler(handler) { - if (typeof handler !== 'function') { - throw new Error(`handler was '${typeof handler}', expected 'function'`); - } - - this.handler = handler; - return this; -} -// Inject setHandler into the builder classes. Doing this instead of extending -// the builder classes allows us to set command handlers with the standard -// builder objects without needing to re-implement the addSubcommand(Group) -// functions. -ContextMenuCommandBuilder.prototype.setHandler = setHandler; -SlashCommandBuilder.prototype.setHandler = setHandler; -SlashCommandSubcommandBuilder.prototype.setHandler = setHandler; -SlashCommandSubcommandGroupBuilder.prototype.setHandler = setHandler; - -/** - * A collection of Discord.js commands that registers itself with Discord's API - * and routes Discord.js {@link BaseInteraction} events to the appropriate - * command handlers. - */ -class SlashCommandRegistry { - - #command_map = new Map(); - #rest = null; - - /** - * The bot's Discord application ID. - */ - application_id = null; - - /** - * The handler run for unrecognized commands. - */ - default_handler = null; - - /** - * A Discord guild ID used to restrict command registration to one guild. - */ - guild_id = null; - - /** - * The bot token used to register commands with Discord's API. - */ - token = null; - - /** - * Accessor for the list of {@link SlashCommandBuilder} objects. - */ - get commands() { - return Array.from(this.#command_map.values()); - } - - /** - * Creates a new {@link SlashCommandRegistry}. - */ - constructor() { - this.#rest = new REST({ version: API_VERSION }); - } - - /** - * Defines a new slash command from a builder. - * Commands defined here can also be registered with Discord's API. - * - * @param {SlashCommandBuilder|Function} input - * Either a SlashCommandBuilder or a function that returns a - * SlashCommandBuilder. - * @throws {Error} if builder is not an instance of SlashCommandBuilder or - * a function that returns a SlashCommandBuilder. - * @return {SlashCommandRegistry} instance so we can chain calls. - */ - addCommand(input) { - const builder = (typeof input === 'function') - ? input(new SlashCommandBuilder()) - : input; - - if (!(builder instanceof SlashCommandBuilder)) { - throw new Error( - `input did not resolve to a SlashCommandBuilder. Got ${builder}` - ); - } - - this.#command_map.set(builder.name, builder); - return this; - } - - /** - * Defines a new context menu command from a builder. - * Commands defined here can also be registered with Discord's API. - * - * @param {ContextMenuCommandBuilder|Function} input - * Either a ContextMenuCommandBuilder or a function that returns a - * ContextMenuCommandBuilder. - * @throws {Error} if builder is not an instance of ContextMenuCommandBuilder - * or a function that returns a ContextMenuCommandBuilder. - * @returns {SlashCommandRegistry} instance so we can chain calls. - */ - addContextMenuCommand(input) { - const builder = (typeof input === 'function') - ? input(new ContextMenuCommandBuilder()) - : input; - - if (!(builder instanceof ContextMenuCommandBuilder)) { - throw new Error( - `input did not resolve to a ContextMenuCommandBuilder. Got ${builder}` - ); - } - - this.#command_map.set(builder.name, builder); - return this; - } - - /** - * Sets the Discord application ID. This is the ID for the Discord - * application to register commands for. - * - * @param {Snowflake} id The Discord application ID to register commands for. - * @return {SlashCommandRegistry} instance so we can chain calls. - */ - setApplicationId(id) { - this.application_id = id; - return this; - } - - /** - * Sets up a function to run for unrecognized commands. - * - * @param {Handler} handler The function to execute for unrecognized commands. - * @throws {Error} if handler is not a function. - * @return {CommandRegistry} instance so we can chain calls. - */ - setDefaultHandler(handler) { - if (typeof handler !== 'function') { - throw new Error(`handler was '${typeof handler}', expected 'function'`); - } - - this.default_handler = handler; - return this; - } - - /** - * Sets the Discord guild ID. This restricts command registration to the - * given guild, rather than registering globally. - * - * @param {Snowflake} id The Discord guild ID. - * @returns {SlashCommandRegistry} instance so we can chain calls. - */ - setGuildId(id) { - this.guild_id = id; - return this; - } - - /** - * Sets the Discord bot token for this command registry. - * - * @param {String} token A Discord bot token, used to register commands. - * @throws {Error} if token is not a string. - * @return {SlashCommandRegistry} instance so we can chain calls. - */ - setToken(token) { - // setToken handles validation for us - this.token = token; - this.#rest.setToken(token); - return this; - } - - /** - * Returns an array of command builder JSON that can be sent to Discord's API. - * - * @param {String[]} commands Optional array of command names. If provided, - * only a subset of the command builders will be serialized. - * @return {JSON[]} Array of command builder JSON. - */ - toJSON(commands) { - const should_add_cmd = commands - ? new Map(commands.map(name => [name, true])) - : this.#command_map; // Also a map of name -> truthy value - - return this.commands - .filter(cmd => should_add_cmd.get(cmd.name)) - .map(cmd => cmd.toJSON()); - } - - /** - * Attempts to execute the given Discord.js {@link BaseInteraction} using - * the most specific handler provided. For example, if an individual - * subcommand does not have a handler but the parent command does, the - * parent's handler will be called. If no builder matches the interaction, - * the default handler is called (if provided). - * - * This function is a no-op if: - * - The interaction is not a supported {@link BaseInteraction} type. We - * currently support: - * - {@link CommandInteraction} - * - {@link ContextMenuInteraction} - * - No builder matches the interaction and no default handler is set. - * - * This function is set up so it can be directly used as the handler for - * Discord.js' `interactionCreate` event (but you may consider a thin - * wrapper for logging). - * - * @param {BaseInteraction} interaction A Discord.js interaction object. - * @return {Promise<*>} Fulfills based on command execution. - * @resolve The value returned from the {@link Handler}. - * @reject - * - Received interaction does not match a command builder. This will - * usually happen if a bot's command definitions are changed without - * updating the bot application with Discord's API. - * - Any Error that occurs during handler execution. - */ - async execute(interaction) { - if (!(typeof interaction?.isCommand === 'function')) { - throw new Error(`given value was not a Discord.js command`); - } - - if (!interaction.isCommand?.() && !interaction.isContextMenuCommand?.()) { - return; - } - - const cmd_name = interaction.commandName; - const cmd_group = interaction.options.getSubcommandGroup(false); - const cmd_sub = interaction.options.getSubcommand(false); - - // Find the most specific command handler for this CommandInteraction. - // Drill down matching valid structures here: - // https://canary.discord.com/developers/docs/interactions/slash-commands#nested-subcommands-and-groups - const builder_cmd = this.#command_map.get(cmd_name); - if (!builder_cmd) { - throw builderErr(interaction, 'command'); - } - - let builder_group; - if (cmd_group) { - builder_group = builder_cmd.options.find(b => - b instanceof SlashCommandSubcommandGroupBuilder && - b.name === cmd_group - ); - if (!builder_group) { - throw builderErr(interaction, 'group'); - } - } - - let builder_sub; - if (cmd_sub) { - // See above linked Discord docs on valid command structure. - builder_sub = (builder_group || builder_cmd).options.find(b => - b instanceof SlashCommandSubcommandBuilder && - b.name === cmd_sub - ); - if (!builder_sub) { - throw builderErr(interaction, 'subcommand'); - } - } - - const handler = - builder_sub?.handler ?? - builder_group?.handler ?? - builder_cmd.handler ?? - this.default_handler; - - return handler?.(interaction); - } - - /** - * Registers known commands with Discord's API via an HTTP call. - * - * @param {Object} options Optional parameters for this function. - * - {@link Snowflake} `application_id` - A Discord application ID. If - * specified, this ID will override the one specified via - * {@link SlashCommandRegistry.setAppId} for this call. - * - {@link String[]} `commands` - An array of command names. When specified, - * only these commands will be registered with the API. This can be - * useful for only registering new commands. If omitted, all commands - * are registered. - * - {@link Snowflake} `guild` - A Discord Guild ID. If specified, this ID - * will override the one specified via - * {@link SlashCommandRegistry.setGuildId} for this call. - * - {@link String} `token` - A Discord bot token. If specified, this token - * will override the one specified via - * {@link SlashCommandRegistry.setToken} for this call. - * @return {Promise} Fulfills based on the Discord API call. - * @resolve {@link JSON} Response body returned from Discord's API. - * @reject {@link DiscordAPIError} containing the Discord API error. - * **NOTE**: This is the `DiscordAPIError` from the `@discordjs/rest` - * package, *not* the `discord.js` package. - */ - async registerCommands(options) { - options = options || {}; - - if (options.token) { - this.#rest.setToken(options.token); - } - - try { - const app_id = options.application_id || this.application_id; - const guild_id = options.guild || this.guild_id; - return await this.#rest.put( - guild_id - ? Routes.applicationGuildCommands(app_id, guild_id) - : Routes.applicationCommands(app_id), - { body: this.toJSON(options.commands) }, - ); - } finally { - // So we only use provided token for one request - this.#rest.setToken(this.token); - } - } -} - -/** - * Resolves a string interaction option into an Application object. - * Neither Discord.js nor Discord's own API support application options in - * commands, so we need to use a builder's `.addStringOption(...)` function - * instead. - * - * **NOTE**: This depends on an undocumented API endpoint. This could break if - * this endpoint changes. - * `/applications/{application.id}/rpc` - * - * @param {CommandInteraction} interaction A Discord.js interaction containing a - * string option containing an application's ID. - * @param {String} opt_name The option containing the application's ID. - * @param {Boolean} required Whether to throw an error if the option is not - * found (default false). - * @return {Promise} Fulfills based on the Discord API call. - * @resolve {@link Application} The resolved Application object. - * @reject Any error from the Discord API, e.g. invalid application ID. - */ -async function getApplication(interaction, opt_name, required=false) { - const app_id = interaction.options.getString(opt_name, required); - return new REST({ version: API_VERSION }) - .setToken('ignored') - .get(`/applications/${app_id}/rpc`) // NOTE: undocumented endpoint! - .then(data => new Application(interaction.client, data)) -} - -const DEFAULT_EMOJI_PATTERN = /^\p{Emoji}+/u; -const CUSTOM_EMOJI_PATTERN = /^$/; -const DISCORD_ID_PATTERN = /^\d{17,22}$/; - -/** - * Resolves a string interaction option into a single emoji. Built-in emojis are - * just unicode strings, thus they return as unicode strings. Custom emojis have - * a little more going on, so they are returned as Discord.js GuildEmoji objects. - * - * @param {CommandInteraction} interaction A Discord.js interaction containing a - * string option that contains an emoji. - * @param {String} opt_name The option containing the Emoji. - * @param {Boolean} required Whether to throw an error if the option is not - * found (default false). - * @return {GuildEmoji|String|null} The resolved emoji as a Discord.js - * GuildEmoji object for custom emojis, as a String for built-in emojis, or - * null if not found. - */ -function getEmoji(interaction, opt_name, required=false) { - const emoji_str = interaction.options.getString(opt_name, required) || ''; - - // This matches built-in emojis AND Discord IDs, so we need a another check. - if (emoji_str.match(DEFAULT_EMOJI_PATTERN)) { - if (emoji_str.match(DISCORD_ID_PATTERN)) { - return interaction.client.emojis.resolve(emoji_str); - } - - return emoji_str; - } - - const match = emoji_str.match(CUSTOM_EMOJI_PATTERN); - if (match) { - const emoji_id = match[1]; - return interaction.client.emojis.resolve(emoji_id); - } - - return null; -} - -// Makes an Error describing a mismatched Discord.js CommandInteraction. -function builderErr(interaction, part) { - return new Error( - `No known command matches the following (mismatch starts at '${part}')\n` + - `\tcommand: ${interaction.commandName}\n` + - `\tgroup: ${interaction.options.getSubcommandGroup(false) ?? ''}\n` + - `\tsubcommand: ${interaction.options.getSubcommand(false) ?? ''}\n` + - 'You may need to update your commands with the Discord API.' - ); -} - -module.exports = { - Options: Object.freeze({ - getApplication, - getEmoji, - }), - ...require('@discordjs/builders'), // Forward utils and stuff - SlashCommandRegistry, - SlashCommandBuilder, - SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, -}; diff --git a/package-lock.json b/package-lock.json index 9faba29..118296c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,24 +1,34 @@ { "name": "discord-command-registry", - "version": "2.1.0", + "version": "2.2.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "discord-command-registry", - "version": "2.1.0", + "version": "2.2.0", "license": "LGPL-3.0", "dependencies": { "commander": "^10.0.1" }, "bin": { - "register": "register.js" + "register": "lib/register.js" }, "devDependencies": { + "@sapphire/shapeshift": "^3.9.0", + "@types/chai": "^4.3.5", + "@types/chai-as-promised": "^7.1.5", + "@types/mocha": "^10.0.1", "chai": "^4.3.7", "chai-as-promised": "^7.1.1", "mocha": "^9.2.2", - "nyc": "^15.1.0" + "nyc": "^15.1.0", + "ts-mixer": "^6.0.3", + "ts-node": "^10.9.1", + "typescript": "^5.0.4" + }, + "engines": { + "node": ">=16.9.0" }, "peerDependencies": { "discord.js": "^14" @@ -415,6 +425,18 @@ "node": ">=6.9.0" } }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@discordjs/builders": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.6.1.tgz", @@ -581,6 +603,31 @@ "node": ">=8" } }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "node_modules/@sapphire/async-queue": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.0.tgz", @@ -592,10 +639,9 @@ } }, "node_modules/@sapphire/shapeshift": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.8.2.tgz", - "integrity": "sha512-NXpnJAsxN3/h9TqQPntOeVWZrpIuucqXI3IWF6tj2fWCoRLCuVK5wx7Dtg7pRrtkYfsMUbDqgKoX26vrC5iYfA==", - "peer": true, + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.0.tgz", + "integrity": "sha512-iJpHmjAdwX9aSL6MvFpVyo+tkokDtInmSjoJHbz/k4VJfnim3DjvG0hgGEKWtWZgCu45RaLgcoNgR1fCPdIz3w==", "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" @@ -621,6 +667,51 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "peer": true }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", + "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", + "dev": true + }, + "node_modules/@types/chai": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz", + "integrity": "sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==", + "dev": true + }, + "node_modules/@types/chai-as-promised": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-7.1.5.tgz", + "integrity": "sha512-jStwss93SITGBwt/niYrkf2C+/1KTeZCZl1LaeezTlqppAKeoQC7jxyqYuP72sxBGKCIbw7oHgbYssIRzT5FCQ==", + "dev": true, + "dependencies": { + "@types/chai": "*" + } + }, + "node_modules/@types/mocha": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz", + "integrity": "sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q==", + "dev": true + }, "node_modules/@types/node": { "version": "18.15.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.13.tgz", @@ -642,6 +733,27 @@ "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", "dev": true }, + "node_modules/acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -719,6 +831,12 @@ "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", "dev": true }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -1007,6 +1125,12 @@ "safe-buffer": "~5.1.1" } }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -1171,8 +1295,7 @@ "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "peer": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/file-type": { "version": "18.2.1", @@ -1764,8 +1887,7 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "peer": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "node_modules/lodash.flattendeep": { "version": "4.4.0", @@ -1819,6 +1941,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -2671,8 +2799,59 @@ "node_modules/ts-mixer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.3.tgz", - "integrity": "sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==", - "peer": true + "integrity": "sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==" + }, + "node_modules/ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node/node_modules/diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } }, "node_modules/tslib": { "version": "2.5.0", @@ -2707,6 +2886,19 @@ "is-typedarray": "^1.0.0" } }, + "node_modules/typescript": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=12.20" + } + }, "node_modules/undici": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.0.tgz", @@ -2735,6 +2927,12 @@ "uuid": "bin/uuid" } }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2893,6 +3091,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3206,6 +3413,15 @@ "to-fast-properties": "^2.0.0" } }, + "@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "requires": { + "@jridgewell/trace-mapping": "0.3.9" + } + }, "@discordjs/builders": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.6.1.tgz", @@ -3335,6 +3551,28 @@ "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true }, + "@jridgewell/resolve-uri": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.1.tgz", + "integrity": "sha512-dSYZh7HhCDtCKm4QakX0xFpsRDqjjtZf/kjI/v3T3Nwt5r8/qz/M19F9ySyOqU94SXBmeG9ttTul+YnR4LOxFA==", + "dev": true + }, + "@jridgewell/sourcemap-codec": { + "version": "1.4.15", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", + "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", + "dev": true + }, + "@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "requires": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, "@sapphire/async-queue": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.0.tgz", @@ -3342,10 +3580,9 @@ "peer": true }, "@sapphire/shapeshift": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.8.2.tgz", - "integrity": "sha512-NXpnJAsxN3/h9TqQPntOeVWZrpIuucqXI3IWF6tj2fWCoRLCuVK5wx7Dtg7pRrtkYfsMUbDqgKoX26vrC5iYfA==", - "peer": true, + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-3.9.0.tgz", + "integrity": "sha512-iJpHmjAdwX9aSL6MvFpVyo+tkokDtInmSjoJHbz/k4VJfnim3DjvG0hgGEKWtWZgCu45RaLgcoNgR1fCPdIz3w==", "requires": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" @@ -3363,6 +3600,51 @@ "integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==", "peer": true }, + "@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "@tsconfig/node16": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.3.tgz", + "integrity": "sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ==", + "dev": true + }, + "@types/chai": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.5.tgz", + "integrity": "sha512-mEo1sAde+UCE6b2hxn332f1g1E8WfYRu6p5SvTKr2ZKC1f7gFJXk4h5PyGP9Dt6gCaG8y8XhwnXWC6Iy2cmBng==", + "dev": true + }, + "@types/chai-as-promised": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/@types/chai-as-promised/-/chai-as-promised-7.1.5.tgz", + "integrity": "sha512-jStwss93SITGBwt/niYrkf2C+/1KTeZCZl1LaeezTlqppAKeoQC7jxyqYuP72sxBGKCIbw7oHgbYssIRzT5FCQ==", + "dev": true, + "requires": { + "@types/chai": "*" + } + }, + "@types/mocha": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.1.tgz", + "integrity": "sha512-/fvYntiO1GeICvqbQ3doGDIP97vWmvFt83GKguJ6prmQM2iXZfFcq6YE8KteFyRtX2/h5Hf91BYvPodJKFYv5Q==", + "dev": true + }, "@types/node": { "version": "18.15.13", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.15.13.tgz", @@ -3384,6 +3666,18 @@ "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==", "dev": true }, + "acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true + }, + "acorn-walk": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.2.0.tgz", + "integrity": "sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==", + "dev": true + }, "aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -3440,6 +3734,12 @@ "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", "dev": true }, + "arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, "argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3660,6 +3960,12 @@ "safe-buffer": "~5.1.1" } }, + "create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, "cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -3784,8 +4090,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "peer": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "file-type": { "version": "18.2.1", @@ -4193,8 +4498,7 @@ "lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "peer": true + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, "lodash.flattendeep": { "version": "4.4.0", @@ -4236,6 +4540,12 @@ "semver": "^6.0.0" } }, + "make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, "minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -4866,8 +5176,36 @@ "ts-mixer": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.3.tgz", - "integrity": "sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==", - "peer": true + "integrity": "sha512-k43M7uCG1AkTyxgnmI5MPwKoUvS/bRvLvUb7+Pgpdlmok8AoqmUaZxUUw8zKM5B1lqZrt41GjYgnvAi0fppqgQ==" + }, + "ts-node": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", + "integrity": "sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw==", + "dev": true, + "requires": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "dependencies": { + "diff": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", + "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "dev": true + } + } }, "tslib": { "version": "2.5.0", @@ -4896,6 +5234,12 @@ "is-typedarray": "^1.0.0" } }, + "typescript": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", + "dev": true + }, "undici": { "version": "5.22.0", "resolved": "https://registry.npmjs.org/undici/-/undici-5.22.0.tgz", @@ -4917,6 +5261,12 @@ "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", "dev": true }, + "v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -5027,6 +5377,12 @@ } } }, + "yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true + }, "yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index fe48ace..02cead0 100644 --- a/package.json +++ b/package.json @@ -1,14 +1,23 @@ { "name": "discord-command-registry", - "version": "2.1.0", + "version": "2.2.0", "description": "A structure for Discord.js slash commands that allow you to define, register, and execute slash commands all in one place.", - "main": "index.js", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "engines": { + "node": ">=16.9.0" + }, "bin": { - "register": "./register.js" + "register": "./lib/register.js" }, "scripts": { - "test": "mocha test.js", - "test:coverage": "nyc --reporter=text mocha test.js" + "build": "tsc", + "clean": "rm lib/*", + "prepack": "npm run clean && npm run build && npm test", + "prepare": "npm run build", + "prepublishOnly": "npm test", + "test": "mocha test/*.test.ts", + "test:coverage": "nyc --reporter=text mocha test/*.test.ts" }, "homepage": "https://www.npmjs.com/package/discord-command-registry", "repository": { @@ -27,9 +36,8 @@ "library" ], "files": [ - "index.js", - "register.js", - "test.js" + "lib/*", + "src/register.ts" ], "author": "Mimickal", "license": "LGPL-3.0", @@ -37,10 +45,17 @@ "discord.js": "^14" }, "devDependencies": { + "@sapphire/shapeshift": "^3.9.0", + "@types/chai": "^4.3.5", + "@types/chai-as-promised": "^7.1.5", + "@types/mocha": "^10.0.1", "chai": "^4.3.7", "chai-as-promised": "^7.1.1", "mocha": "^9.2.2", - "nyc": "^15.1.0" + "nyc": "^15.1.0", + "ts-mixer": "^6.0.3", + "ts-node": "^10.9.1", + "typescript": "^5.0.4" }, "dependencies": { "commander": "^10.0.1" diff --git a/src/builders.ts b/src/builders.ts new file mode 100644 index 0000000..3e2ec0e --- /dev/null +++ b/src/builders.ts @@ -0,0 +1,222 @@ +/******************************************************************************* + * This file is part of discord-command-registry, a Discord.js slash command + * library for Node.js + * Copyright (C) 2021 Mimickal (Mia Moretti). + * + * discord-command-registry is free software under the GNU Lesser General Public + * License v3.0. See LICENSE.md or + * for more information. + ******************************************************************************/ + +// Ok this is a bit of a nightmare. Let me explain. +// We want the command builders to all support an additional field and setter +// for a handler function. We also want that change to be a drop-in replacement. +// +// In JavaScript, we can hack it onto the existing builders' prototypes. +// It works, but it is indeed a hack, so it breaks any sort of type hinting. +// +// In TypeScript, we want type safety, so we need to do it the "right" way. +// This means extending discord.js' builders, and duplicating some functionality +// to get type safety/checking with our classes. +// We also want this to be a drop-in replacement though, so the class names all +// need to stay the same. So what do we do? Import all discord.js' things into a +// "Discord" namespace, duplicate some logic (addCommand, etc...) to return our +// versions of the classes, and do a wee bit of (safe) @ts-ignore-ing to change +// the parameter type of some inherited methods. +// Most of what follows is satisfying TypeScript's type checker. +// +// We use the same "mixin" pattern discord.js uses in `@discordjs/builders` +// https://github.com/discordjs/discord.js/blob/14.9.0/packages/builders/src/interactions/slashCommands/SlashCommandBuilder.ts + +import * as Discord from 'discord.js'; +import { Mixin } from 'ts-mixer'; +import { s } from '@sapphire/shapeshift'; + +const { name: pack_name } = require('../package.json'); + +/** Either a Builder or a function that returns a Builder. */ +export type BuilderInput = T | ((thing: T) => T); +/** The function called during command execution. */ +export type Handler = (interaction: Discord.CommandInteraction) => unknown; +/** A string option with the length set internally. */ +export type SlashCommandCustomOption = Omit; + +/** Mixin that adds builder support for our additional custom options. */ +class MoreOptionsMixin extends Discord.SharedSlashCommandOptions { + /** + * Adds an Application option. + * + * @param input Either a string builder or a function that returns a builder. + */ + addApplicationOption(input: BuilderInput): this { + const result = resolveBuilder(input, Discord.SlashCommandStringOption); + + // Discord Application ID length + (result as Discord.SlashCommandStringOption) + .setMinLength(18) + .setMaxLength(20); + + return addThing(this, result, Discord.SlashCommandStringOption); + } + + /** + * Adds an Emoji option. This appears as a string option that will accept + * a string containing an emoji ID, emoji name, or emoji literal. + * + * @param input Either a string builder or a function that returns a builder. + */ + addEmojiOption(input: BuilderInput): this { + const result = resolveBuilder(input, Discord.SlashCommandStringOption); + + // Emoji literals are 1 or more characters. + // Emoji names are 1 to 32 characters. + // Emoji IDs are somewhere in between, like 18 to 20. + (result as Discord.SlashCommandStringOption) + .setMinLength(1) + .setMaxLength(32); + + return addThing(this, result, Discord.SlashCommandStringOption); + } +} + +/** Mixin that adds the ability to set and store a command handler function. */ +class CommandHandlerMixin { + /** The function called when this command is executed. */ + public readonly handler: Handler | undefined; + + /** Sets the function called when this command is executed. */ + setHandler(handler: Handler): this { + if (typeof handler !== 'function') { + throw new Error(`handler was '${typeof handler}', expected 'function'`); + } + + Reflect.set(this, 'handler', handler); + return this; + } +} + +/** + * Discord.js builders are not designed to be grouped together in a collection. + * This union represents any possible end value for an individual command's + * builder. + */ +export type SlashCommandBuilderReturn = + | SlashCommandBuilder + | Omit + | SlashCommandSubcommandsOnlyBuilder; + +type SlashCommandSubcommandsOnlyBuilder = Omit + | keyof MoreOptionsMixin +>; + +// NOTE: it's important that Discord's built-ins are the last Mixin in the list! +// Otherwise, we run the risk of stepping on field initialization. + +export class ContextMenuCommandBuilder extends Mixin( + CommandHandlerMixin, + Discord.ContextMenuCommandBuilder, +) {} + +export class SlashCommandBuilder extends Mixin( + CommandHandlerMixin, + MoreOptionsMixin, + Discord.SlashCommandBuilder, +) { + // @ts-ignore We want to force this to only accept our version of the + // builder with .setHandler. + addSubcommand( + input: BuilderInput + ): SlashCommandSubcommandsOnlyBuilder { + return addThing(this, input, + SlashCommandSubcommandBuilder, + Discord.SlashCommandSubcommandBuilder + ); + } + + // @ts-ignore We want to force this to only accept our version of the + // builder with .setHandler. + addSubcommandGroup( + input: BuilderInput + ): SlashCommandSubcommandsOnlyBuilder { + return addThing(this, input, + SlashCommandSubcommandGroupBuilder, + Discord.SlashCommandSubcommandGroupBuilder, + ); + } +} + +export class SlashCommandSubcommandGroupBuilder extends Mixin( + CommandHandlerMixin, + Discord.SlashCommandSubcommandGroupBuilder, +) { + // @ts-ignore We want to force this to only accept our version of the + // builder with .setHandler. + addSubcommand(input: BuilderInput): this { + return addThing(this, input, + SlashCommandSubcommandBuilder, + Discord.SlashCommandSubcommandBuilder + ); + } +} + +export class SlashCommandSubcommandBuilder extends Mixin( + MoreOptionsMixin, + CommandHandlerMixin, + Discord.SlashCommandSubcommandBuilder, +) {} + +/** + * Some magic to de-duplicate our overridden addWhatever methods. + * Couldn't bind `this` with a Mixin, so this will have to do. + */ +function addThing< + S extends { options: Discord.ToAPIApplicationCommandOptions[] }, + T extends Discord.ToAPIApplicationCommandOptions, + P, +>( + self: S, + input: BuilderInput, + Class: new () => T, + Parent?: new () => P, +): S { + validateMaxOptionsLength(self.options); + const result = resolveBuilder(input, Class); + assertReturnOfBuilder(result, Class, Parent); + self.options.push(result); + return self; +} + +/** + * Adapted from + * https://github.com/discordjs/discord.js/blob/14.9.0/packages/builders/src/interactions/slashCommands/Assertions.ts#L68 + */ +export function assertReturnOfBuilder( + input: unknown, + ExpectedInstanceOf: new () => T, + ParentInstanceOf?: new () => P, +): asserts input is T { + if (!(input instanceof ExpectedInstanceOf)) { + throw new Error(ParentInstanceOf && input instanceof ParentInstanceOf + ? `Use ${ExpectedInstanceOf.name} from ${pack_name}, not discord.js` + : `input did not resolve to a ${ExpectedInstanceOf.name}. Got ${input}` + ); + } +} + +/** Resolves {@link BuilderInput} values to their final form. */ +export function resolveBuilder(input: BuilderInput, Class: new() => T): T { + return input instanceof Function ? input(new Class()) : input; +} + +/** + * Stolen directly from + * https://github.com/discordjs/discord.js/blob/14.9.0/packages/builders/src/interactions/slashCommands/Assertions.ts#L33 + */ +function validateMaxOptionsLength( + options: unknown +): asserts options is Discord.ToAPIApplicationCommandOptions[] { + s.unknown.array.lengthLessThanOrEqual(25).parse(options); +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..cefc03d --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,10 @@ +/******************************************************************************* + * This file is part of discord-command-registry, a Discord.js slash command + * library for Node.js + * Copyright (C) 2021 Mimickal (Mia Moretti). + * + * discord-command-registry is free software under the GNU Lesser General Public + * License v3.0. See LICENSE.md or + * for more information. + ******************************************************************************/ +export const API_VERSION = '10' as const; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..be3e8a9 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,24 @@ +/******************************************************************************* + * This file is part of discord-command-registry, a Discord.js slash command + * library for Node.js + * Copyright (C) 2021 Mimickal (Mia Moretti). + * + * discord-command-registry is free software under the GNU Lesser General Public + * License v3.0. See LICENSE.md or + * for more information. + ******************************************************************************/ +export * from '@discordjs/builders'; // Forward utils and stuff +export { + ContextMenuCommandBuilder, + SlashCommandBuilder, + SlashCommandCustomOption, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, +} from './builders'; + +import * as Options from './options'; +import SlashCommandRegistry from './registry'; +export { + Options, + SlashCommandRegistry, +}; diff --git a/src/options.ts b/src/options.ts new file mode 100644 index 0000000..ba4b973 --- /dev/null +++ b/src/options.ts @@ -0,0 +1,88 @@ +/******************************************************************************* + * This file is part of discord-command-registry, a Discord.js slash command + * library for Node.js + * Copyright (C) 2021 Mimickal (Mia Moretti). + * + * discord-command-registry is free software under the GNU Lesser General Public + * License v3.0. See LICENSE.md or + * for more information. + ******************************************************************************/ +import { + Application, + ChatInputCommandInteraction, + FormattingPatterns, + GuildEmoji, + REST, +} from 'discord.js'; + +import { API_VERSION } from './constants'; + +const DEFAULT_EMOJI_PATTERN = /^\p{Emoji}+/u; +const DISCORD_ID_PATTERN = /^\d{17,22}$/; + +/** + * Resolves a string interaction option into an Application object. + * Neither Discord.js nor Discord's own API support application options in + * commands, so we need to use a builder's `.addStringOption(...)` function + * instead. + * + * **NOTE**: This depends on an undocumented API endpoint. + * This could break if this endpoint changes: + * `/applications/{application.id}/rpc` + * + * @param interaction A Discord.js interaction containing a string option that + * contains an Application's ID. + * @param opt_name The string option containing the application's ID. + * @param required Whether to throw an error if the option is not found. + * @reject Any error from the Discord API, e.g. invalid application ID. + */ +export async function getApplication( + interaction: ChatInputCommandInteraction, + opt_name: string, + required=false, +): Promise { + const app_id = interaction.options.getString(opt_name, required); + return new REST({ version: API_VERSION }) + .setToken('ignored') + .get(`/applications/${app_id}/rpc`) // NOTE: undocumented endpoint! + // @ts-ignore This constructor is private, but discord.js doesn't + // offer any other way to instantiate or look up an Application. + .then(data => new Application(interaction.client, data)) +} + +/** + * Resolves a string interaction option into a single emoji. Built-in emojis are + * just unicode strings, thus they return as unicode strings. Custom emojis have + * a little more going on, so they are returned as Discord.js GuildEmoji objects. + * + * @param interaction A Discord.js interaction containing a string option that + * contains an emoji. + * @param opt_name The string option containing the Emoji. + * @param required Whether to throw an error if the option is not found. + * @return The resolved emoji as a Discord.js `GuildEmoji` object for custom + * emojis, as a String for built-in emojis, or null if not found. + */ +export function getEmoji( + interaction: ChatInputCommandInteraction, + opt_name: string, + required=false, +): GuildEmoji | string | null { + const emoji_str = interaction.options.getString(opt_name, required) || ''; + + // This matches built-in emojis AND Discord IDs, so we need a another check. + if (emoji_str.match(DEFAULT_EMOJI_PATTERN)) { + if (emoji_str.match(DISCORD_ID_PATTERN)) { + return interaction.client.emojis.resolve(emoji_str); + } + + return emoji_str; + } + + const match = emoji_str.match(FormattingPatterns.Emoji); + if (match?.groups) { + const emoji_id = match.groups.id; + return interaction.client.emojis.resolve(emoji_id); + } + + return null; +} diff --git a/register.js b/src/register.ts similarity index 85% rename from register.js rename to src/register.ts index 7829648..96c0d38 100755 --- a/register.js +++ b/src/register.ts @@ -8,19 +8,19 @@ * License v3.0. See LICENSE.md or * for more information. ******************************************************************************/ -const { lstatSync, readFileSync } = require('fs'); -const { resolve } = require('path'); -const { Command } = require('commander'); +import { lstatSync, readFileSync } from 'fs'; +import { resolve } from 'path'; +import { Command } from 'commander'; const cliArgs = new Command() .description([ - 'Registers a SlashCommandRegistry\'s commands with Discord\'s API.', + "Registers a SlashCommandRegistry's commands with Discord's API.", 'This only needs to be done once after commands are updated. Updating', 'commands globally can take some time to propagate! For testing, use', - 'guild-specific commands (specify \'guild\').', + 'guild-specific commands (specify "guild").', ].join(' ')) .argument('', - 'Path to a JavaScript file whose default export is a SlashCommandRegistry.', + 'Path to a JS (or TS) file whose default export is a SlashCommandRegistry.', (path) => require(resolve(path)), ) .option('-a, --app ', 'The Discord bot application ID.') @@ -76,7 +76,8 @@ console.info([ '...', ].join('')); -registry.registerCommands({ +// A hack to deal with how "require" imports TypeScript modules with default exports. +(registry.default ?? registry).registerCommands({ application_id, guild, token: ( @@ -84,7 +85,7 @@ registry.registerCommands({ cliArgs.getOptionValue('config')?.token ), commands: cliArgs.getOptionValue('names'), -}).then(data => { +}).then((data: unknown) => { console.debug(data); console.debug(); console.info('Registration successful!', guild @@ -92,7 +93,7 @@ registry.registerCommands({ : 'Commands may take some time to globally propagate.' ); -}).catch(err => { +}).catch((err: Error) => { console.error('Error registering commands!\n\n', err); process.exit(1); }); diff --git a/src/registry.ts b/src/registry.ts new file mode 100644 index 0000000..0ce00c5 --- /dev/null +++ b/src/registry.ts @@ -0,0 +1,349 @@ +/******************************************************************************* + * This file is part of discord-command-registry, a Discord.js slash command + * library for Node.js + * Copyright (C) 2021 Mimickal (Mia Moretti). + * + * discord-command-registry is free software under the GNU Lesser General Public + * License v3.0. See LICENSE.md or + * for more information. + ******************************************************************************/ +import { + BaseInteraction, + ChatInputCommandInteraction, + ContextMenuCommandInteraction, + DiscordAPIError, + ContextMenuCommandBuilder as DiscordContextMenuCommandBuilder, + SlashCommandBuilder as DiscordSlashCommandBuilder, + REST, + Routes, + Snowflake, + RESTPostAPIContextMenuApplicationCommandsJSONBody, + RESTPostAPIChatInputApplicationCommandsJSONBody, + CommandInteraction, +} from 'discord.js'; + +import { + assertReturnOfBuilder, + BuilderInput, + ContextMenuCommandBuilder, + Handler, + resolveBuilder, + SlashCommandBuilder, + SlashCommandBuilderReturn, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, +} from './builders'; +import { API_VERSION } from './constants'; + +/** A top-level command builder. */ +type TopLevelBuilder = SlashCommandBuilderReturn | ContextMenuCommandBuilder; + +/** Optional parameters for registering commands. */ +interface RegisterOpts { + /** + * A Discord application ID. If specified, this ID will override the one + * specified via {@link SlashCommandRegistry.setAppId} for this call. + */ + application_id?: Snowflake; + /** + * An array of command names. When specified, only these commands will be + * registered with Discord's API. This can be useful for only registering + * new commands. If omitted, all commands are registered. + */ + commands?: string[]; + /** + * A Discord Guild ID. If specified, this ID will override the one + * specified via {@link SlashCommandRegistry.setGuildId} for this call. + */ + guild?: Snowflake; + /** + * A Discord bot token. If specified, this token will override the one + * specified via {@link SlashCommandRegistry.setToken} for this call. + */ + token?: string; +} + +/** + * A collection of Discord.js commands that registers itself with Discord's API + * and routes Discord.js {@link BaseInteraction} events to the appropriate + * command handlers. + */ +export default class SlashCommandRegistry { + + #command_map = new Map(); + #rest: REST; + + /** The bot's Discord application ID. */ + application_id: Snowflake | null = null; + + /** The handler run for unrecognized commands. */ + default_handler: Handler | null = null; + + /** A Discord guild ID used to restrict command registration to one guild. */ + guild_id: Snowflake | null = null; + + /** The bot token used to register commands with Discord's API. */ + token: string | null = null; + + /** Accessor for the list of Builder objects. */ + get commands(): TopLevelBuilder[] { + return Array.from(this.#command_map.values()); + } + + /** Creates a new {@link SlashCommandRegistry}. */ + constructor() { + this.#rest = new REST({ version: API_VERSION }); + } + + /** + * Defines a new slash command from a builder. + * Commands defined here can also be registered with Discord's API. + * + * @param input Either a SlashCommandBuilder or a function that returns a + * SlashCommandBuilder. + * @throws If input does not resolve to a SlashCommandBuilder. + * @return Instance so we can chain calls. + */ + addCommand(input: BuilderInput): this { + const builder = resolveBuilder(input, SlashCommandBuilder); + assertReturnOfBuilder(builder, + SlashCommandBuilder, + DiscordSlashCommandBuilder + ); + + this.#command_map.set(builder.name, builder); + return this; + } + + /** + * Defines a new context menu command from a builder. + * Commands defined here can also be registered with Discord's API. + * + * @param input Either a ContextMenuCommandBuilder or a function that + * returns a ContextMenuCommandBuilder. + * @throws If input does not resolve to a ContextMenuCommandBuilder. + * @returns Instance so we can chain calls. + */ + addContextMenuCommand(input: BuilderInput): this { + const builder = resolveBuilder(input, ContextMenuCommandBuilder); + assertReturnOfBuilder(builder, + ContextMenuCommandBuilder, + DiscordContextMenuCommandBuilder, + ); + + this.#command_map.set(builder.name, builder); + return this; + } + + /** + * Sets the Discord application ID. This is the ID for the Discord + * application to register commands for. + * + * @param id The Discord application ID to register commands for. + * @return Instance so we can chain calls. + */ + setApplicationId(id: Snowflake): this { + this.application_id = id; + return this; + } + + /** + * Sets up a function to run for unrecognized commands. + * + * @param handler The function to execute for unrecognized commands. + * @throws If handler is not a function. + * @return Instance so we can chain calls. + */ + setDefaultHandler(handler: Handler): this { + if (typeof handler !== 'function') { + throw new Error(`handler was '${typeof handler}', expected 'function'`); + } + + this.default_handler = handler; + return this; + } + + /** + * Sets the Discord guild ID. This restricts command registration to the + * given guild, rather than registering globally. + * + * @param id The Discord guild ID. + * @returns Instance so we can chain calls. + */ + setGuildId(id: Snowflake | null): this { + this.guild_id = id; + return this; + } + + /** + * Sets the Discord bot token for this command registry. + * + * @param token A Discord bot token, used to register commands. + * @throws If token is not a string. + * @return {SlashCommandRegistry} instance so we can chain calls. + */ + setToken(token: string): this { + // setToken handles validation for us. + this.token = token; + this.#rest.setToken(token); + return this; + } + + /** + * Returns an array of command builder JSON that can be sent to Discord's API. + * + * @param commands Optional array of command names. + * If provided, only a subset of the command builders will be serialized. + * @return Array of command builder JSON. + */ + toJSON(commands?: string[]): ( + RESTPostAPIContextMenuApplicationCommandsJSONBody | + RESTPostAPIChatInputApplicationCommandsJSONBody + )[] { + const should_add_cmd = commands + ? new Map(commands.map(name => [name, true])) + : this.#command_map; // Doubles as a map of name -> truthy value + + return this.commands + .filter(cmd => should_add_cmd.get(cmd.name)) + .map(cmd => cmd.toJSON()); + } + + /** + * Attempts to execute the given Discord.js Interaction using the most + * specific handler provided. For example, if an individual subcommand does + * not have a handler but the parent command does, the parent's handler will + * be called. If no builder matches the interaction, the default handler is + * called (if provided). + * + * This function is a no-op if: + * - The interaction is not a supported Interaction type. + * - {@link ContextMenuCommandInteraction} + * - {@link ChatInputCommandInteraction} + * - No builder matches the interaction and no default handler is set. + * + * This function is set up so it can be directly used as the handler for + * Discord.js' `interactionCreate` event (but you may consider a thin + * wrapper for logging). + * + * @param interaction A Discord.js interaction object. + * @param T An optional type for the data returned by the handler. + * Defaults to `unknown`. + * @resolve The value returned from the {@link Handler}. + * @reject + * - Received interaction does not match a command builder. This will + * usually happen if a bot's command definitions are changed without + * updating the bot application with Discord's API. + * - Any Error that occurs during handler execution. + */ + execute(interaction: BaseInteraction): T | undefined { + if (!(typeof interaction?.isCommand === 'function')) { + throw new Error(`given value was not a Discord.js command`); + } + + if (!interaction.isChatInputCommand?.() && + !interaction.isContextMenuCommand?.() + ) { + return; + } + + // Find the most specific command handler for this CommandInteraction. + // Drill down matching valid structures here: + // https://canary.discord.com/developers/docs/interactions/slash-commands#nested-subcommands-and-groups + const builder_top = this.#command_map.get(interaction.commandName); + if (!builder_top) { + throw builderErr(interaction, 'command'); + } + + let builder_group: SlashCommandSubcommandGroupBuilder | undefined; + let builder_sub: SlashCommandSubcommandBuilder | undefined; + + if (interaction.isChatInputCommand()) { + const cmd_group = interaction.options.getSubcommandGroup?.(false); + const cmd_sub = interaction.options.getSubcommand?.(false); + const builder_cmd = builder_top as SlashCommandBuilder; + + if (cmd_group) { + // Discord.js' typing narrows all option objects down to a + // single toJSON() function. The other option data is still + // there in the underlying object though, and we want it. + // The instanceof check here should make this a safe cast. + builder_group = builder_cmd.options.find(b => + b instanceof SlashCommandSubcommandGroupBuilder && + b.name === cmd_group + ) as SlashCommandSubcommandGroupBuilder; + + if (!builder_group) { + throw builderErr(interaction, 'group'); + } + } + + if (cmd_sub) { + // Same story here. + builder_sub = (builder_group || builder_cmd).options.find(b => + b instanceof SlashCommandSubcommandBuilder && + b.name === cmd_sub + ) as SlashCommandSubcommandBuilder; + + if (!builder_sub) { + throw builderErr(interaction, 'subcommand'); + } + } + } + + const handler = + builder_sub?.handler ?? + builder_group?.handler ?? + builder_top.handler ?? + this.default_handler; + + return handler ? handler(interaction) as T : undefined; + } + + /** + * Registers known commands with Discord's API via an HTTP call. + * + * @param options Optional parameters for this function. + * @return Fulfills based on the Discord API call. + * @reject {@link DiscordAPIError} containing the Discord API error. + * **NOTE**: This is the `DiscordAPIError` from the `@discordjs/rest` + * package, *not* the `discord.js` package. + */ + async registerCommands(options?: RegisterOpts): Promise { + options = options ?? {}; + + if (options.token) { + this.#rest.setToken(options.token); + } + + try { + // app_id might not actually be defined, but Discord's API will + // return an error if not. + const app_id = (options.application_id || this.application_id)!; + const guild_id = options.guild || this.guild_id; + return await this.#rest.put( + guild_id + ? Routes.applicationGuildCommands(app_id, guild_id) + : Routes.applicationCommands(app_id), + { body: this.toJSON(options.commands) }, + ); + } finally { + // So we only use provided token for one request. + // this.token + this.#rest.setToken(this.token!); + } + } +} + +/** Makes an Error describing a mismatched Discord.js CommandInteraction. */ +function builderErr(interaction: CommandInteraction, part: string): Error { + return new Error([ + `No known command matches the following (mismatch starts at '${part}')`, + `\tcommand: ${interaction.commandName}`, + ...(interaction.isChatInputCommand() ? [ + `\tgroup: ${interaction.options.getSubcommandGroup(false) ?? ''}`, + `\tsubcommand: ${interaction.options.getSubcommand(false) ?? ''}`, + ] : []), + 'You may need to update your commands with the Discord API.', + ].join('\n')); +} diff --git a/test.js b/test.js deleted file mode 100644 index 95987b5..0000000 --- a/test.js +++ /dev/null @@ -1,707 +0,0 @@ -/******************************************************************************* - * This file is part of discord-command-registry, a Discord.js slash command - * library for Node.js - * Copyright (C) 2021 Mimickal (Mia Moretti). - * - * discord-command-registry is free software under the GNU Lesser General Public - * License v3.0. See LICENSE.md or - * for more information. - ******************************************************************************/ -const chai = require('chai'); -chai.use(require('chai-as-promised')); -const expect = chai.expect; - -const { MockAgent, setGlobalDispatcher } = require('undici'); // From discord.js -const { ApplicationCommandOptionType } = require('discord-api-types/v10'); -const { DiscordAPIError } = require('@discordjs/rest'); -const { - Application, - Client, - CommandInteraction, - CommandInteractionOptionResolver, - Guild, - GuildEmoji, -} = require('discord.js'); -const { - ContextMenuCommandBuilder, - Options, - SlashCommandRegistry, - SlashCommandBuilder, - SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, - spoiler, - hyperlink, - inlineCode, - time, -} = require('.'); - -// discord.js uses undici for HTTP requests, so we piggyback off of that -// transitive dependency to mock those requests in testing. -const mockAgent = new MockAgent(); -mockAgent.disableNetConnect(); -setGlobalDispatcher(mockAgent); - -describe('Builders have setHandler() functions injected', function() { - Array.of( - SlashCommandBuilder, - SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, - ).forEach(klass => { - - it(`${klass.name} function injected`, function() { - const builder = new klass(); - expect(builder).to.respondTo('setHandler'); - - const handler = () => {}; - builder.setHandler(handler); - expect(builder).to.have.property('handler', handler); - }); - - it(`${klass.name} error thrown for non-functions`, function() { - const builder = new klass(); - expect(() => { builder.setHandler('') }).to.throw( - Error, "handler was 'string', expected 'function'" - ); - }); - }); -}); - -describe('SlashCommandRegistry addCommand()', function() { - - beforeEach(function() { - this.registry = new SlashCommandRegistry(); - }); - - it('Add builder instance', function() { - const builder = new SlashCommandBuilder(); - expect(() => this.registry.addCommand(builder)).to.not.throw(); - expect(this.registry.commands).to.include(builder); - }); - - it('value must be a builder', function() { - expect(() => this.registry.addCommand('thing')).to.throw( - Error, 'input did not resolve to a SlashCommandBuilder. Got thing' - ); - }); - - it('Function returns builder', function() { - expect(this.registry.commands).to.be.empty; - expect(() => this.registry.addCommand(builder => builder)).to.not.throw(); - expect(this.registry.commands).to.have.lengthOf(1); - }); - - it('Function must return a builder', function() { - expect(() => this.registry.addCommand(builder => 'thing')).to.throw( - Error, 'input did not resolve to a SlashCommandBuilder. Got thing' - ); - }); -}); - -describe('SlashCommandRegistry addContextMenuCommand()', function() { - - beforeEach(function() { - this.registry = new SlashCommandRegistry(); - }); - - it('Add builder instance', function() { - const builder = new ContextMenuCommandBuilder(); - expect(() => this.registry.addContextMenuCommand(builder)).to.not.throw(); - expect(this.registry.commands).to.include(builder); - }); - - it('value must be a builder', function() { - expect(() => this.registry.addContextMenuCommand('thing')).to.throw( - Error, 'input did not resolve to a ContextMenuCommandBuilder. Got thing' - ); - }); - - it('Function returns builder', function() { - expect(this.registry.commands).to.be.empty; - expect( - () => this.registry.addContextMenuCommand(builder => builder) - ).to.not.throw(); - expect(this.registry.commands).to.have.lengthOf(1); - }); - - it('Function must return a builder', function() { - expect( - () => this.registry.addContextMenuCommand(builder => 'thing') - ).to.throw( - Error, 'input did not resolve to a ContextMenuCommandBuilder. Got thing' - ) - }); -}); - -describe('SlashCommandRegistry toJSON()', function() { - - before(function() { - this.registry = new SlashCommandRegistry() - .addCommand(builder => builder - .setName('test1') - .setNameLocalizations({ - 'en-US': 'test1', - 'ru': 'тест1', - }) - .setDescription('test description 1') - .setDescriptionLocalizations({ - 'en-US': 'test description 1', - 'ru': 'Описание теста 1', - }) - ) - .addCommand(builder => builder - .setName('test2') - .setDescription('test description 2') - ) - .addCommand(builder => builder - .setName('test3') - .setDescription('test description 3') - ); - this.expected = [ - { - name: 'test1', - name_localizations: { - 'en-US': 'test1', - 'ru': 'тест1', - }, - description: 'test description 1', - description_localizations: { - 'en-US': 'test description 1', - 'ru': 'Описание теста 1', - }, - options: [], - nsfw: undefined, - default_permission: undefined, - default_member_permissions: undefined, - dm_permission: undefined, - }, - { - name: 'test2', - description: 'test description 2', - options: [], - default_permission: undefined, - default_member_permissions: undefined, - description_localizations: undefined, - dm_permission: undefined, - name_localizations: undefined, - nsfw: undefined, - }, - { - name: 'test3', - description: 'test description 3', - options: [], - default_permission: undefined, - default_member_permissions: undefined, - description_localizations: undefined, - dm_permission: undefined, - name_localizations: undefined, - nsfw: undefined, - }, - ]; - }); - - it('Serialize all', function() { - expect(this.registry.toJSON()).to.deep.equal(this.expected); - }); - - it('Serialize subset', function() { - expect(this.registry.toJSON(['test1', 'test3'])) - .to.deep.equal([ this.expected[0], this.expected[2] ]); - }); -}); - -describe('SlashCommandRegistry registerCommands()', function() { - let captured_path; - let captured_headers; - let captured_body; - - function makeMockApiWithCode(code) { - return mockAgent.get('https://discord.com') - .intercept({ - method: 'PUT', - path: (path) => { - captured_path = path; - return true; - }, - headers: (headers) => { - captured_headers = headers; - return true; - }, - body: (body) => { - captured_body = JSON.parse(body); - return true; - }, - }) - .reply(code, 'Mocked Discord response'); - } - - beforeEach(function() { - this.app_id = 'test_app_id'; - this.token = 'test_token'; - this.registry = new SlashCommandRegistry() - .setApplicationId(this.app_id) - .setGuildId(null) - .setToken(this.token); - - captured_path = null; - captured_headers = null; - captured_body = null; - }); - - it('Uses set application ID and token', async function() { - makeMockApiWithCode(200); - await this.registry.registerCommands(); - expect(captured_path) - .to.equal(`/api/v10/applications/${this.app_id}/commands`); - expect(captured_headers.authorization) - .to.equal(`Bot ${this.token}`); - }); - - it('Providing guild registers as guild commands', async function() { - const guild = 'test_guild_id'; - makeMockApiWithCode(200); - await this.registry.setGuildId(guild).registerCommands(); - expect(captured_path).to.equal( - `/api/v10/applications/${this.app_id}/guilds/${guild}/commands` - ); - }); - - it('Uses application ID override', async function() { - const new_app_id = 'override'; - makeMockApiWithCode(200); - await this.registry.registerCommands({ application_id: new_app_id }); - expect(captured_path) - .to.equal(`/api/v10/applications/${new_app_id}/commands`); - }); - - it('Uses token override and resets after', async function() { - const newtoken = 'override'; - makeMockApiWithCode(200); - await this.registry.registerCommands({ token: newtoken }); - expect(captured_headers.authorization) - .to.equal(`Bot ${newtoken}`); - - // Token is reset - makeMockApiWithCode(200); - await this.registry.registerCommands(); - expect(captured_headers.authorization) - .to.equal(`Bot ${this.token}`); - }); - - it('Uses guild ID override', async function() { - const newGuild = 'override'; - makeMockApiWithCode(200); - await this.registry - .setGuildId('original_guild') - .registerCommands({ guild: newGuild }); - - expect(captured_path).to.equal( - `/api/v10/applications/${this.app_id}/guilds/${newGuild}/commands` - ); - }); - - it('Providing command name array registers a subset', async function() { - this.registry - .addCommand(builder => builder.setName('test1').setDescription('test desc 1')) - .addCommand(builder => builder.setName('test2').setDescription('test desc 2')); - - makeMockApiWithCode(200); - await this.registry.registerCommands({ commands: ['test1'] }); - expect(captured_body).to.deep.equal( - [{ name: 'test1', description: 'test desc 1', options: [] }] - ); - }); - - it('Handles errors from the Discord API', async function() { - makeMockApiWithCode(400); - return expect(this.registry.registerCommands()) - .to.be.rejectedWith(DiscordAPIError, 'No Description'); - }); -}); - -// A crappy mock interaction for testing that satisfies an instanceof check -// without any of the actual safety checks. -class MockCommandInteraction extends CommandInteraction { - constructor(args) { - const client = new Client({ intents: [] }); - super(client, { - data: { - id: 1, - }, - type: 1, - user: {} - }); - - this.is_command = args.is_command ?? true; - this.commandName = args.name; - - // Closely depends on private implementations here: - // https://github.com/discordjs/discord.js/blob/13.3.1/src/structures/CommandInteractionOptionResolver.js - this.options = new CommandInteractionOptionResolver( - client, - Object.entries(args.string_opts || {}).map(([name, value]) => ({ - type: ApplicationCommandOptionType.String, - name: name, - value: value, - })) - ); - this.options._group = args.group; - this.options._subcommand = args.subcommand; - } - isCommand() { - return this.is_command; - } -} - -describe('SlashCommandRegistry execute()', function() { - - beforeEach(function() { - this.registry = new SlashCommandRegistry() - .addCommand(command => command - .setName('cmd1') - .setDescription('Command with direct subcommands') - .addSubcommand(sub => sub - .setName('subcmd1') - .setDescription('Direct subcommand 1') - ) - .addSubcommand(sub => sub - .setName('subcmd2') - .setDescription('Direct subcommand 2') - ) - ) - .addCommand(command => command - .setName('cmd2') - .setDescription('Command with subcommand group') - .addSubcommandGroup(group => group - .setName('group1') - .setDescription('Subcommand group 1') - .addSubcommand(sub => sub - .setName('subcmd1') - .setDescription('subcommand in group 1') - ) - .addSubcommand(sub => sub - .setName('subcmd2') - .setDescription('subcommand in group 2') - ) - ) - ) - .addCommand(command => command - .setName('cmd3') - .setDescription('Top-level command only') - ); - - this.interaction = new MockCommandInteraction({ - name: 'cmd2', - group: 'group1', - subcommand: 'subcmd1', - }); - }); - - it('Error for setDefaultHandler() non-function values', function() { - expect(() => this.registry.setDefaultHandler({})).to.throw( - Error, "handler was 'object', expected 'function'" - ); - }); - - it('Handler priority 5: no-op if no handler set anywhere', function() { - return this.registry.execute(this.interaction) - .then(val => expect(val).to.be.undefined); - }); - - it('Handler priority 4: default handler', function() { - const expected = 'Should see this'; - this.registry.setDefaultHandler(() => expected); - - return this.registry.execute(this.interaction) - .then(val => expect(val).to.equal(expected)); - }); - - it('Handler priority 3: top-level command handler', function() { - const expected = 'Should see this'; - this.registry.setDefaultHandler(() => 'default handler called'); - this.registry.commands[1].setHandler(() => expected); - - return this.registry.execute(this.interaction) - .then(val => expect(val).to.equal(expected)); - }); - - it('Handler priority 3: top-level command handler (only command)', function() { - const expected = 'Should see this'; - this.registry.setDefaultHandler(() => 'default handler called'); - this.registry.commands[2].setHandler(() => expected); - - return this.registry.execute(new MockCommandInteraction({ - name: 'cmd3', - })) - .then(val => expect(val).to.equal(expected)); - }); - - it('Handler priority 2: subcommand group handler', function() { - const expected = 'Should see this'; - this.registry.setDefaultHandler(() => 'default handler called'); - this.registry.commands[1].setHandler(() => 'top-level handler'); - this.registry.commands[1].options[0].setHandler(() => expected); - - return this.registry.execute(this.interaction) - .then(val => expect(val).to.equal(expected)); - }); - - it('Handler priority 1: subcommand handler', function() { - const expected = 'Should see this'; - this.registry.setDefaultHandler(() => 'default handler called'); - this.registry.commands[1].setHandler(() => 'top-level handler'); - this.registry.commands[1].options[0].setHandler(() => 'group handler'); - this.registry.commands[1].options[0].options[0].setHandler(() => expected); - - return this.registry.execute(this.interaction) - .then(val => expect(val).to.equal(expected)); - }); - - it('Handler priority 1: subcommand handler (direct subcommand)', function() { - const expected = 'Should see this'; - this.registry.setDefaultHandler(() => 'default handler called'); - this.registry.commands[0].setHandler(() => 'top-level handler'); - this.registry.commands[0].options[0].setHandler(() => expected); - - return this.registry.execute(new MockCommandInteraction({ - name: 'cmd1', - subcommand: 'subcmd1', - })) - .then(val => expect(val).to.equal(expected)); - }); - - it('Error on non-interaction value', function() { - return this.registry.execute({}) - .then(() => expect.fail('Expected exception but got none')) - .catch(err => { - expect(err).to.be.instanceOf(Error); - expect(err.message).to.equal( - `given value was not a Discord.js command` - ); - }); - }); - - it('No-op on non-CommandInteraction value', function() { - this.registry.setDefaultHandler(() => 'default handler called'); - return this.registry.execute(new MockCommandInteraction({ - is_command: false, - })).then(val => expect(val).to.be.undefined); - }); - - it('Error on missing command', function() { - return this.registry.execute(new MockCommandInteraction({ - name: 'bad', - })) - .then(() => expect.fail('Expected exception but got none')) - .catch(err => { - expect(err).to.be.instanceOf(Error); - expect(err.message).to.contain( - "No known command matches the following (mismatch starts at 'command')\n" + - " command: bad\n" + - " group: \n" + - " subcommand: \n" - ); - }); - }); - - it('Error on missing group', function() { - return this.registry.execute(new MockCommandInteraction({ - name: 'cmd1', - group: 'bad', - })) - .then(() => expect.fail('Expected exception but got none')) - .catch(err => { - expect(err).to.be.instanceOf(Error); - expect(err.message).to.contain( - "No known command matches the following (mismatch starts at 'group')\n" + - " command: cmd1\n" + - " group: bad\n" + - " subcommand: \n" - ); - }); - }); - - it('Error on missing subcommand', function() { - return this.registry.execute(new MockCommandInteraction({ - name: 'cmd2', - group: 'group1', - subcommand: 'bad', - })) - .then(() => expect.fail('Expected exception but got none')) - .catch(err => { - expect(err).to.be.instanceOf(Error); - expect(err.message).to.contain( - "No known command matches the following (mismatch starts at 'subcommand')\n" + - " command: cmd2\n" + - " group: group1\n" + - " subcommand: bad\n" - ); - }); - }); - - it('Gracefully handles error thrown in handler', function() { - const expected = 'I was thrown from the handler'; - this.registry.setDefaultHandler(() => { throw new Error(expected) }); - - return this.registry.execute(this.interaction) - .then(() => expect.fail('Expected exception but got none')) - .catch(err => { - expect(err).to.be.instanceOf(Error); - expect(err.message).to.equal(expected); - }); - }); -}); - -describe('Option resolvers', function() { - const test_opt_name = 'test_opt'; - /** - * Makes a {@link MockCommandInteraction} object with a single string - * option containing the given value. - */ - function makeInteractionWithOpt(value) { - return new MockCommandInteraction({ - name: 'test', - string_opts: { [test_opt_name]: value }, - }); - } - - describe('getApplication()', function() { - - it('Required but not provided', function() { - const interaction = makeInteractionWithOpt(undefined); - return Options.getApplication(interaction, test_opt_name, true) - .then(() => expect.fail('Expected exception but got none')) - .catch(err => { - expect(err).to.be.instanceOf(TypeError); - expect(err.message).to.match(/expected a non-empty value/); - }); - }); - - it('Returns a good application', function() { - const test_app_id = '12345'; - const test_app_name = 'cool thing'; - - mockAgent.get('https://discord.com') - .intercept({ - method: 'GET', - path: `/api/v10/applications/${test_app_id}/rpc`, - }).reply(200, { - id: test_app_id, - name: test_app_name, - icon: 'testhashthinghere', - }, { - headers: { 'content-type': 'application/json' } - }); - - const interaction = makeInteractionWithOpt(test_app_id); - - return Options.getApplication(interaction, test_opt_name).then(app => { - expect(app).to.be.instanceOf(Application); - expect(app.id).to.equal(test_app_id); - expect(app.name).to.equal(test_app_name); - }); - }); - }); - - describe('getEmoji()', function() { - - it('Required but not provided', function() { - const interaction = makeInteractionWithOpt(undefined); - expect( - () => Options.getEmoji(interaction, test_opt_name, true) - ).to.throw(TypeError, /expected a non-empty value/); - }); - - it('Optional and not provided', function() { - const interaction = makeInteractionWithOpt(undefined); - const emoji = Options.getEmoji(interaction, test_opt_name); - expect(emoji).to.be.null; - }); - - it('Not an emoji string', function() { - const interaction = makeInteractionWithOpt('not an emoji'); - const emoji = Options.getEmoji(interaction, test_opt_name); - expect(emoji).to.be.null; - }); - - it('Built-in emoji string', function() { - const test_str = '🦊'; - const interaction = makeInteractionWithOpt(test_str); - const emoji = Options.getEmoji(interaction, test_opt_name); - expect(emoji).to.be.a.string; - expect(emoji).to.equal(test_str); - }); - - it('Complex emoji string', function() { - // All emojis that were demonstrated to trip up the regex - Array.of('1️⃣', '🕴️', '🎞️', '🖼️').forEach(emoji_str => { - const interaction = makeInteractionWithOpt(emoji_str); - const got_emoji = Options.getEmoji(interaction, test_opt_name); - expect(got_emoji).to.be.a.string; - expect(got_emoji).to.equal(emoji_str); - }); - }); - - describe('Custom emojis', function() { - const test_id = '884481185005326377'; - const test_name = 'fennec_fox'; - const test_str = `<:${test_name}:${test_id}>`; - - // Need to populate the test client's cache with our test emoji. - // Discord.js internally aggregates the emojis of individual guilds - // on the fly, so we need to make a fake guild and set up all those - // links, too. - // https://github.com/discordjs/discord.js/blob/13.3.1/src/client/Client.js#L194 - function addTestEmojiToClient(interaction) { - const test_guild = new Guild(interaction.client, { - channels: [true], // Dumb hack. - }); - const test_emoji = new GuildEmoji(interaction.client, - { id: test_id, name: test_name }, - test_guild - ); - test_guild.emojis.cache.set(test_id, test_emoji); - interaction.client.guilds.cache.set(test_guild.id, test_guild); - } - - it('Custom emoji by raw Discord ID', function() { - const interaction = makeInteractionWithOpt(test_id); - addTestEmojiToClient(interaction) - - const emoji = Options.getEmoji(interaction, test_opt_name); - expect(emoji).to.be.instanceOf(GuildEmoji); - expect(emoji.toString()).to.equal(test_str); - }); - - it('Custom emoji string', function() { - const interaction = makeInteractionWithOpt(test_str); - addTestEmojiToClient(interaction); - - const emoji = Options.getEmoji(interaction, test_opt_name); - expect(emoji).to.be.instanceOf(GuildEmoji) - expect(emoji.toString()).to.equal(test_str); - }); - }); - }); -}); - -describe('Utils forwarded', function() { - - it('spoiler', function() { - expect(spoiler('this thing')).to.equal('||this thing||'); - }); - - it('hyperlink', function() { - expect(hyperlink('this thing', 'www.test.com')).to.equal( - '[this thing](www.test.com)' - ); - }); - - it('inlineCode', function() { - expect(inlineCode('this thing')).to.equal('`this thing`'); - }); - - it('time', function() { - const now = Date.now(); - expect(time(now)).to.equal(``); - }); -}); diff --git a/test/builders.test.ts b/test/builders.test.ts new file mode 100644 index 0000000..1d85d96 --- /dev/null +++ b/test/builders.test.ts @@ -0,0 +1,129 @@ +/******************************************************************************* + * This file is part of discord-command-registry, a Discord.js slash command + * library for Node.js + * Copyright (C) 2021 Mimickal (Mia Moretti). + * + * discord-command-registry is free software under the GNU Lesser General Public + * License v3.0. See LICENSE.md or + * for more information. + ******************************************************************************/ +import { expect } from 'chai'; +import * as Discord from 'discord.js'; + +import { + ContextMenuCommandBuilder, + SlashCommandCustomOption, + SlashCommandBuilder, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, +} from '../src'; +import { BuilderInput, Handler } from '../src/builders'; + +type CanSetHandler = new () => { + setHandler: (handler: Handler) => unknown; +} +type CanAddSubCommand = new () => { + addSubcommand: (input: BuilderInput) => unknown; +}; + +describe('Builders have setHandler() functions injected', function() { + Array.of( + ContextMenuCommandBuilder, + SlashCommandBuilder, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, + ).forEach(Class => { + + it(`${Class.name} function injected`, function() { + const builder = new Class(); + expect(builder).to.respondTo('setHandler'); + + const handler = () => {}; + builder.setHandler(handler); + expect(builder).to.have.property('handler', handler); + }); + + it(`${Class.name} error thrown for non-functions`, function() { + const builder = new Class(); + // @ts-expect-error This is a test of a runtime safety check. + expect(() => { builder.setHandler('') }).to.throw( + Error, "handler was 'string', expected 'function'" + ); + }); + }); +}); + +describe('Builders require our overridden classes', function() { + Array.of( + SlashCommandBuilder, + SlashCommandSubcommandGroupBuilder, + ).forEach(Class => { + it(`${Class.name} wants ${SlashCommandSubcommandBuilder.name}`, function() { + const builder = new Class(); + const subcommand = new Discord.SlashCommandSubcommandBuilder(); + + // @ts-expect-error This is a test of a runtime safety check. + expect(() => builder.addSubcommand(subcommand)).to.throw( + Error, + 'Use SlashCommandSubcommandBuilder from discord-command-registry, not discord.js' + ); + }); + }); + + it(`${SlashCommandBuilder.name} wants ${SlashCommandSubcommandGroupBuilder.name}`, function() { + const builder = new SlashCommandBuilder(); + const group = new Discord.SlashCommandSubcommandGroupBuilder(); + + // @ts-expect-error This is a test of a runtime safety check. + expect(() => builder.addSubcommandGroup(group)).to.throw( + Error, + 'Use SlashCommandSubcommandGroupBuilder from discord-command-registry, not discord.js' + ) + }); +}); + +describe('Builders support our custom option resolvers', function() { + const TEST_NAME = 'test_name'; + const TEST_DESC = 'test command description'; + function makeOpt(opt: SlashCommandCustomOption): SlashCommandCustomOption { + return opt + .setName(TEST_NAME) + .setDescription(TEST_DESC) + .setRequired(true); + } + + Array.from([ + SlashCommandBuilder, + SlashCommandSubcommandBuilder, + ]).forEach(Class => { + function makeBuilder() { + return new Class() + .setName('ignored') + .setDescription('ignored'); + } + + it(`${Class.name}.${new Class().addApplicationOption.name}`, function() { + const builder = makeBuilder().addApplicationOption(makeOpt); + expect(builder.options[0].toJSON()).to.contain({ + name: TEST_NAME, + description: TEST_DESC, + min_length: 18, + max_length: 20, + required: true, + type: Discord.ApplicationCommandOptionType.String, + }); + }); + + it(`${Class.name}.${new Class().addEmojiOption.name}`, function() { + const builder = makeBuilder().addEmojiOption(makeOpt); + expect(builder.options[0].toJSON()).to.contain({ + name: TEST_NAME, + description: TEST_DESC, + min_length: 1, + max_length: 32, + required: true, + type: Discord.ApplicationCommandOptionType.String, + }); + }); + }); +}); diff --git a/test/mock.ts b/test/mock.ts new file mode 100644 index 0000000..c33f21c --- /dev/null +++ b/test/mock.ts @@ -0,0 +1,95 @@ +/******************************************************************************* + * This file is part of discord-command-registry, a Discord.js slash command + * library for Node.js + * Copyright (C) 2021 Mimickal (Mia Moretti). + * + * discord-command-registry is free software under the GNU Lesser General Public + * License v3.0. See LICENSE.md or + * for more information. + ******************************************************************************/ +import { + ApplicationCommandOptionType, + ApplicationCommandType, + ChannelType, + ChatInputCommandInteraction, + Client, + CommandInteractionOption, + CommandInteractionOptionResolver, + InteractionType, +} from 'discord.js'; +import { MockAgent, setGlobalDispatcher } from 'undici'; // From discord.js + +// discord.js uses undici for HTTP requests, so we piggyback off of that +// transitive dependency to mock those requests in testing. +const mockAgent = new MockAgent(); +mockAgent.disableNetConnect(); +setGlobalDispatcher(mockAgent); + +export { mockAgent }; + +/** + * A crappy mock interaction for testing that satisfies an instanceof check + * without any of the actual safety checks. + */ +export class MockCommandInteraction extends ChatInputCommandInteraction { + private is_command: boolean; + + constructor(args: { + name: string; + is_command?: boolean; + command_group?: string; + subcommand?: string; + opt?: { + name: string; + value: string; + }; + }) { + const client = new Client({ intents: [] }); + + // This is like 90% fake garbage to satisfy TypeScript + // and 10% fake garbage to avoid undefined read errors. + super(client, { + id: 'fake_int_id', + application_id: 'fake_test_id', + type: InteractionType.ApplicationCommand, + token: 'fake_token', + locale: 'en-US', + version: 1, + channel_id: 'fake_channel_id', + app_permissions: '0', + channel: { + id: 'fake_channel_id', + type: ChannelType.DM, + }, + data: { + id: 'fake_data_id', + name: 'fake_data_name', + type: ApplicationCommandType.ChatInput, + }, + user: { + id: 'fake_user_id', + username: 'fake_test_user', + discriminator: '1234', + avatar: null, + }, + }); + + this.is_command = args.is_command ?? true; + this.commandName = args.name; + + // Pull this up to get some type safety where we can. + const opts: CommandInteractionOption[] = args.opt ? [{ + name: args.opt.name, + type: ApplicationCommandOptionType.String, + value: args.opt.value, + }] : []; + // @ts-ignore This constructor is private, but fu. + this.options = new CommandInteractionOptionResolver(client, opts); + Reflect.set(this.options, '_group', args.command_group); + Reflect.set(this.options, '_subcommand', args.subcommand); + } + + isChatInputCommand(): boolean { + return this.is_command; + } +} diff --git a/test/options.test.ts b/test/options.test.ts new file mode 100644 index 0000000..0c294b5 --- /dev/null +++ b/test/options.test.ts @@ -0,0 +1,155 @@ +/******************************************************************************* + * This file is part of discord-command-registry, a Discord.js slash command + * library for Node.js + * Copyright (C) 2021 Mimickal (Mia Moretti). + * + * discord-command-registry is free software under the GNU Lesser General Public + * License v3.0. See LICENSE.md or + * for more information. + ******************************************************************************/ +import { expect } from 'chai'; +import { Application, Guild, GuildEmoji } from 'discord.js'; + +import { Options } from '../src'; +import { mockAgent, MockCommandInteraction } from './mock'; + +describe('Option resolvers', function() { + const TEST_OPT_NAME = 'test_opt'; + + /** + * Makes a {@link MockCommandInteraction} object with a single string + * option containing the given value. + */ + function makeInteractionWithOpt(value: string | undefined): MockCommandInteraction { + return new MockCommandInteraction({ + name: 'test', + ...(value ? { opt: { + name: TEST_OPT_NAME, + value: value, + }} : {}), + }); + } + + describe('getApplication()', function() { + + it('Required but not provided', async function() { + const interaction = makeInteractionWithOpt(undefined); + try { + await Options.getApplication(interaction, TEST_OPT_NAME, true); + return expect.fail('Expected exception but got none'); + } catch (err) { + expect(err).to.be.instanceOf(TypeError); + expect((err as TypeError).message).to.equal( + `Required option "${TEST_OPT_NAME}" not found.` + ); + } + }); + + it('Returns a good application', async function() { + const test_app_id = '12345'; + const test_app_name = 'cool thing'; + + mockAgent.get('https://discord.com') + .intercept({ + method: 'GET', + path: `/api/v10/applications/${test_app_id}/rpc`, + }).reply(200, { + id: test_app_id, + name: test_app_name, + icon: 'testhashthinghere', + }, { + headers: { 'content-type': 'application/json' } + }); + + const interaction = makeInteractionWithOpt(test_app_id); + + const app = await Options.getApplication(interaction, TEST_OPT_NAME); + expect(app).to.be.instanceOf(Application); + expect(app.id).to.equal(test_app_id); + expect(app.name).to.equal(test_app_name); + }); + }); + + describe('getEmoji()', function() { + + it('Required but not provided', function() { + const interaction = makeInteractionWithOpt(undefined); + expect( + () => Options.getEmoji(interaction, TEST_OPT_NAME, true) + ).to.throw(TypeError, `Required option "${TEST_OPT_NAME}" not found.`); + }); + + it('Optional and not provided', function() { + const interaction = makeInteractionWithOpt(undefined); + const emoji = Options.getEmoji(interaction, TEST_OPT_NAME); + expect(emoji).to.be.null; + }); + + it('Not an emoji string', function() { + const interaction = makeInteractionWithOpt('not an emoji'); + const emoji = Options.getEmoji(interaction, TEST_OPT_NAME); + expect(emoji).to.be.null; + }); + + it('Built-in emoji string', function() { + const test_str = '🦊'; + const interaction = makeInteractionWithOpt(test_str); + const emoji = Options.getEmoji(interaction, TEST_OPT_NAME); + expect(emoji).to.be.a.string; + expect(emoji).to.equal(test_str); + }); + + it('Complex emoji string', function() { + // These are all emojis that were demonstrated to trip up the regex + Array.of('1️⃣', '🕴️', '🎞️', '🖼️').forEach(emoji_str => { + const interaction = makeInteractionWithOpt(emoji_str); + const got_emoji = Options.getEmoji(interaction, TEST_OPT_NAME); + expect(got_emoji).to.be.a.string; + expect(got_emoji).to.equal(emoji_str); + }); + }); + + describe('Custom emojis', function() { + const TEST_EMOJI_ID = '884481185005326377'; + const TEST_EMOJI_NAME = 'fennec_fox'; + const TEST_EMOJI_STR = `<:${TEST_EMOJI_NAME}:${TEST_EMOJI_ID}>`; + + // Need to populate the test client's cache with our test emoji. + // Discord.js internally aggregates the emojis of individual guilds + // on the fly, so we need to make a fake guild and set up all those + // links, too. + // https://github.com/discordjs/discord.js/blob/14.9.0/packages/discord.js/src/client/Client.js#L179 + function addTestEmojiToClient(interaction: MockCommandInteraction): void { + //@ts-ignore Private constructor + const test_guild = new Guild(interaction.client, { + channels: [true], // Dumb hack. + }); + // @ts-ignore Private constructor + const test_emoji = new GuildEmoji(interaction.client, + { id: TEST_EMOJI_ID, name: TEST_EMOJI_NAME }, + test_guild + ); + test_guild.emojis.cache.set(TEST_EMOJI_ID, test_emoji); + interaction.client.guilds.cache.set(test_guild.id, test_guild); + } + + it('Custom emoji by raw Discord ID', function() { + const interaction = makeInteractionWithOpt(TEST_EMOJI_ID); + addTestEmojiToClient(interaction) + + const emoji = Options.getEmoji(interaction, TEST_OPT_NAME); + expect(emoji).to.be.instanceOf(GuildEmoji); + expect(emoji?.toString()).to.equal(TEST_EMOJI_STR); + }); + + it('Custom emoji string', function() { + const interaction = makeInteractionWithOpt(TEST_EMOJI_STR); + addTestEmojiToClient(interaction); + + const emoji = Options.getEmoji(interaction, TEST_OPT_NAME); + expect(emoji).to.be.instanceOf(GuildEmoji) + expect(emoji?.toString()).to.equal(TEST_EMOJI_STR); + }); + }); + }); +}); diff --git a/test/registry.test.ts b/test/registry.test.ts new file mode 100644 index 0000000..1675fb6 --- /dev/null +++ b/test/registry.test.ts @@ -0,0 +1,501 @@ +/******************************************************************************* + * This file is part of discord-command-registry, a Discord.js slash command + * library for Node.js + * Copyright (C) 2021 Mimickal (Mia Moretti). + * + * discord-command-registry is free software under the GNU Lesser General Public + * License v3.0. See LICENSE.md or + * for more information. + ******************************************************************************/ +import chai, { expect } from 'chai'; +import promised from 'chai-as-promised'; +chai.use(promised); +import { + DiscordAPIError, + ContextMenuCommandBuilder as DiscordContextMenuCommandBuilder, + SlashCommandBuilder as DiscordSlashCommandBuilder, +} from 'discord.js'; + +import { + ContextMenuCommandBuilder, + SlashCommandBuilder, + SlashCommandRegistry, + SlashCommandSubcommandBuilder, + SlashCommandSubcommandGroupBuilder, +} from '../src'; + +import { MockCommandInteraction, mockAgent } from './mock'; + + +describe(SlashCommandRegistry.name, function() { +describe(new SlashCommandRegistry().addCommand.name, function() { + let registry: SlashCommandRegistry; + + beforeEach(function() { + registry = new SlashCommandRegistry(); + }); + + it('Add builder instance', function() { + const builder = new SlashCommandBuilder(); + expect(() => registry.addCommand(builder)).to.not.throw(); + expect(registry.commands).to.include(builder); + }); + + it('value must be a builder', function() { + // @ts-expect-error Testing underlying JS safeties + expect(() => registry.addCommand('thing')).to.throw( + Error, 'input did not resolve to a SlashCommandBuilder. Got thing' + ); + }); + + it('value must be OUR builder', function() { + const builder = new DiscordSlashCommandBuilder(); + // @ts-expect-error Testing underlying JS safeties + expect(() => registry.addCommand(builder)).to.throw( + Error, + 'Use SlashCommandBuilder from discord-command-registry, not discord.js' + ); + }); + + it('Function returns builder', function() { + expect(registry.commands).to.be.empty; + expect(() => registry.addCommand(builder => builder)).to.not.throw(); + expect(registry.commands).to.have.lengthOf(1); + }); + + it('Function must return a builder', function() { + // @ts-expect-error Testing underlying JS safeties + expect(() => registry.addCommand(builder => 'thing')).to.throw( + Error, 'input did not resolve to a SlashCommandBuilder. Got thing' + ); + }); +}); + +describe(new SlashCommandRegistry().addContextMenuCommand.name, function() { + let registry: SlashCommandRegistry; + + beforeEach(function() { + registry = new SlashCommandRegistry(); + }); + + it('Add builder instance', function() { + const builder = new ContextMenuCommandBuilder(); + expect(() => registry.addContextMenuCommand(builder)).to.not.throw(); + expect(registry.commands).to.include(builder); + }); + + it('value must be a builder', function() { + // @ts-expect-error Testing underlying JS safeties + expect(() => registry.addContextMenuCommand('thing')).to.throw( + Error, 'input did not resolve to a ContextMenuCommandBuilder. Got thing' + ); + }); + + it('value must be OUR builder', function() { + const builder = new DiscordContextMenuCommandBuilder(); + // @ts-expect-error Testing underlying JS safeties + expect(() => registry.addContextMenuCommand(builder)).to.throw( + Error, + 'Use ContextMenuCommandBuilder from discord-command-registry, not discord.js' + ); + }); + + it('Function returns builder', function() { + expect(registry.commands).to.be.empty; + expect( + () => registry.addContextMenuCommand(builder => builder) + ).to.not.throw(); + expect(registry.commands).to.have.lengthOf(1); + }); + + it('Function must return a builder', function() { + expect( + // @ts-expect-error Testing underlying JS safeties + () => registry.addContextMenuCommand(builder => 'thing') + ).to.throw( + Error, 'input did not resolve to a ContextMenuCommandBuilder. Got thing' + ) + }); +}); + +describe(new SlashCommandRegistry().toJSON.name, function() { + let expected: unknown[]; + let registry: SlashCommandRegistry; + + before(function() { + registry = new SlashCommandRegistry() + .addCommand(builder => builder + .setName('test1') + .setNameLocalizations({ + 'en-US': 'test1', + 'ru': 'тест1', + }) + .setDescription('test description 1') + .setDescriptionLocalizations({ + 'en-US': 'test description 1', + 'ru': 'Описание теста 1', + }) + ) + .addCommand(builder => builder + .setName('test2') + .setDescription('test description 2') + ) + .addCommand(builder => builder + .setName('test3') + .setDescription('test description 3') + ); + + expected = [ + { + name: 'test1', + name_localizations: { + 'en-US': 'test1', + 'ru': 'тест1', + }, + description: 'test description 1', + description_localizations: { + 'en-US': 'test description 1', + 'ru': 'Описание теста 1', + }, + options: [], + nsfw: undefined, + default_permission: undefined, + default_member_permissions: undefined, + dm_permission: undefined, + }, + { + name: 'test2', + description: 'test description 2', + options: [], + default_permission: undefined, + default_member_permissions: undefined, + description_localizations: undefined, + dm_permission: undefined, + name_localizations: undefined, + nsfw: undefined, + }, + { + name: 'test3', + description: 'test description 3', + options: [], + default_permission: undefined, + default_member_permissions: undefined, + description_localizations: undefined, + dm_permission: undefined, + name_localizations: undefined, + nsfw: undefined, + }, + ]; + }); + + it('Serialize all', function() { + expect(registry.toJSON()).to.deep.equal(expected); + }); + + it('Serialize subset', function() { + expect(registry.toJSON(['test1', 'test3'])) + .to.deep.equal([ expected[0], expected[2] ]); + }); +}); + +describe(new SlashCommandRegistry().registerCommands.name, function() { + interface CapturedMockData { + body?: unknown | null + headers?: Record | null; + path?: string | null; + } + + const app_id = 'test_app_id'; + const token = 'test_token'; + + let captured: CapturedMockData; + let registry: SlashCommandRegistry; + + /** Sets up a mock for one request. */ + function makeMockApiWithCode(code: number): void { + mockAgent.get('https://discord.com') + .intercept({ + method: 'PUT', + path: (path: string) => { + captured.path = path; + return true; + }, + headers: (headers: Record) => { + captured.headers = headers; + return true; + }, + body: (body: string) => { + captured.body = JSON.parse(body); + return true; + }, + }) + .reply(code, 'Mocked Discord response'); + } + + beforeEach(function() { + captured = {}; + registry = new SlashCommandRegistry() + .setApplicationId(app_id) + .setGuildId(null) + .setToken(token); + }); + + it('Uses set application ID and token', async function() { + makeMockApiWithCode(200); + await registry.registerCommands(); + expect(captured.path) + .to.equal(`/api/v10/applications/${app_id}/commands`); + expect(captured.headers?.authorization) + .to.equal(`Bot ${token}`); + }); + + it('Providing guild registers as guild commands', async function() { + const guild = 'test_guild_id'; + makeMockApiWithCode(200); + await registry.setGuildId(guild).registerCommands(); + expect(captured.path).to.equal( + `/api/v10/applications/${app_id}/guilds/${guild}/commands` + ); + }); + + it('Uses application ID override', async function() { + const new_app_id = 'override'; + makeMockApiWithCode(200); + await registry.registerCommands({ application_id: new_app_id }); + expect(captured.path) + .to.equal(`/api/v10/applications/${new_app_id}/commands`); + }); + + it('Uses token override and resets after', async function() { + const newtoken = 'override'; + makeMockApiWithCode(200); + await registry.registerCommands({ token: newtoken }); + expect(captured.headers?.authorization).to.equal(`Bot ${newtoken}`); + + // Token is reset + makeMockApiWithCode(200); + await registry.registerCommands(); + expect(captured.headers?.authorization).to.equal(`Bot ${token}`); + }); + + it('Uses guild ID override', async function() { + const newGuild = 'override'; + makeMockApiWithCode(200); + await registry + .setGuildId('original_guild') + .registerCommands({ guild: newGuild }); + + expect(captured.path).to.equal( + `/api/v10/applications/${app_id}/guilds/${newGuild}/commands` + ); + }); + + it('Providing command name array registers a subset', async function() { + registry + .addCommand(builder => builder.setName('test1').setDescription('test desc 1')) + .addCommand(builder => builder.setName('test2').setDescription('test desc 2')); + + makeMockApiWithCode(200); + await registry.registerCommands({ commands: ['test1'] }); + expect(captured.body).to.deep.equal( + [{ name: 'test1', description: 'test desc 1', options: [] }] + ); + }); + + it('Handles errors from the Discord API', async function() { + makeMockApiWithCode(400); + return expect(registry.registerCommands()) + .to.be.rejectedWith(DiscordAPIError, 'No Description'); + }); +}); + +describe(new SlashCommandRegistry().execute.name, function() { + let interaction: MockCommandInteraction; + let registry: SlashCommandRegistry; + + beforeEach(function() { + registry = new SlashCommandRegistry() + .addCommand(command => command + .setName('cmd1') + .setDescription('Command with direct subcommands') + .setHandler(() => {}) + .addSubcommand(sub => sub + .setName('subcmd1') + .setDescription('Direct subcommand 1') + ) + .addSubcommand(sub => sub + .setName('subcmd2') + .setDescription('Direct subcommand 2') + ) + ) + .addCommand(command => command + .setName('cmd2') + .setDescription('Command with subcommand group') + .addSubcommandGroup(group => group + .setName('group1') + .setDescription('Subcommand group 1') + .addSubcommand(sub => sub + .setName('subcmd1') + .setDescription('subcommand in group 1') + ) + .addSubcommand(sub => sub + .setName('subcmd2') + .setDescription('subcommand in group 2') + ) + ) + ) + .addCommand(command => command + .setName('cmd3') + .setDescription('Top-level command only') + ) + .addCommand(command => command + .setName('cmd4') + .setDescription('top-level command with options') + .addBooleanOption(option => option + .setName('opt') + .setDescription('test option') + ) + ); + + interaction = new MockCommandInteraction({ + name: 'cmd2', + command_group: 'group1', + subcommand: 'subcmd1', + }); + }); + + it('Error for setDefaultHandler() non-function values', function() { + // @ts-expect-error Testing underlying JS safeties + expect(() => registry.setDefaultHandler({})).to.throw( + Error, "handler was 'object', expected 'function'" + ); + }); + + it('Handler priority 5: no-op if no handler set anywhere', function() { + expect(registry.execute(interaction)).to.be.undefined; + }); + + it('Handler priority 4: default handler', function() { + const expected = 'Should see this'; + registry.setDefaultHandler(() => expected); + expect(registry.execute(interaction)).to.equal(expected); + }); + + it('Handler priority 3: top-level command handler', function() { + const expected = 'Should see this'; + registry.setDefaultHandler(() => 'default handler called'); + registry.commands[1].setHandler(() => expected); + expect(registry.execute(interaction)).to.equal(expected); + }); + + it('Handler priority 3: top-level command handler (only command)', function() { + const expected = 'Should see this'; + registry.setDefaultHandler(() => 'default handler called'); + registry.commands[2].setHandler(() => expected); + + expect(registry.execute(new MockCommandInteraction({ + name: 'cmd3', + }))).to.equal(expected); + }); + + it('Handler priority 2: subcommand group handler', function() { + const expected = 'Should see this'; + const cmd1 = registry.commands[1] as SlashCommandBuilder; + const grp0 = cmd1.options[0] as SlashCommandSubcommandGroupBuilder; + + registry.setDefaultHandler(() => 'default handler called'); + cmd1.setHandler(() => 'top-level handler'); + grp0.setHandler(() => expected); + + expect(registry.execute(interaction)).to.equal(expected); + }); + + it('Handler priority 1: subcommand handler', function() { + const expected = 'Should see this'; + const cmd1 = registry.commands[1] as SlashCommandBuilder; + const grp0 = cmd1.options[0] as SlashCommandSubcommandGroupBuilder; + const sub0 = grp0.options[0] as SlashCommandSubcommandBuilder; + + registry.setDefaultHandler(() => 'default handler called'); + cmd1.setHandler(() => 'top-level handler'); + grp0.setHandler(() => 'group handler'); + sub0.setHandler(() => expected); + + expect(registry.execute(interaction)).to.equal(expected); + }); + + it('Handler priority 1: subcommand handler (direct subcommand)', function() { + const expected = 'Should see this'; + const cmd0 = registry.commands[0] as SlashCommandBuilder; + const sub0 = cmd0.options[0] as SlashCommandSubcommandBuilder; + + registry.setDefaultHandler(() => 'default handler called'); + cmd0.setHandler(() => 'top-level handler'); + sub0.setHandler(() => expected); + + expect(registry.execute(new MockCommandInteraction({ + name: 'cmd1', + subcommand: 'subcmd1', + }))).to.equal(expected); + }); + + it('Error on non-interaction value', function() { + // @ts-expect-error Testing underlying JS safeties + expect(() => registry.execute({})).to.throw( + Error, 'given value was not a Discord.js command' + ) + }); + + it('No-op on non-CommandInteraction value', function() { + registry.setDefaultHandler(() => 'default handler called'); + expect(registry.execute(new MockCommandInteraction({ + name: 'test', + is_command: false, + }))).to.be.undefined; + }); + + it('Error on missing command', function() { + expect(() => registry.execute(new MockCommandInteraction({ + name: 'bad', + }))).to.throw(Error, [ + "No known command matches the following (mismatch starts at 'command')", + " command: bad", + " group: ", + " subcommand: ", + ].join('\n')); + }); + + it('Error on missing group', function() { + expect(() => registry.execute(new MockCommandInteraction({ + name: 'cmd1', + command_group: 'bad', + }))).to.throw(Error, [ + "No known command matches the following (mismatch starts at 'group')", + " command: cmd1", + " group: bad", + " subcommand: ", + ].join('\n')); + }); + + it('Error on missing subcommand', function() { + expect(() => registry.execute(new MockCommandInteraction({ + name: 'cmd2', + command_group: 'group1', + subcommand: 'bad', + }))).to.throw(Error, [ + "No known command matches the following (mismatch starts at 'subcommand')", + " command: cmd2", + " group: group1", + " subcommand: bad", + ].join('\n')); + }); + + it('Gracefully handles error thrown in handler', function() { + const expected = 'I was thrown from the handler'; + registry.setDefaultHandler(() => { throw new Error(expected) }); + + expect(() => registry.execute(interaction)).to.throw(Error, expected); + }); +}); + +}); diff --git a/test/utils.test.ts b/test/utils.test.ts new file mode 100644 index 0000000..b2761be --- /dev/null +++ b/test/utils.test.ts @@ -0,0 +1,34 @@ +/******************************************************************************* + * This file is part of discord-command-registry, a Discord.js slash command + * library for Node.js + * Copyright (C) 2021 Mimickal (Mia Moretti). + * + * discord-command-registry is free software under the GNU Lesser General Public + * License v3.0. See LICENSE.md or + * for more information. + ******************************************************************************/ +import { expect } from 'chai'; + +import { hyperlink, inlineCode, spoiler, time } from '../src'; + +describe('Utils forwarded', function() { + + it('spoiler', function() { + expect(spoiler('this thing')).to.equal('||this thing||'); + }); + + it('hyperlink', function() { + expect(hyperlink('this thing', 'www.test.com')).to.equal( + '[this thing](www.test.com)' + ); + }); + + it('inlineCode', function() { + expect(inlineCode('this thing')).to.equal('`this thing`'); + }); + + it('time', function() { + const now = Date.now(); + expect(time(now)).to.equal(``); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d9c0c04 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "lib": ["ES2020"], + "module": "CommonJS", + "target": "ES2020", + + "moduleResolution": "node", + "esModuleInterop": true, + "resolveJsonModule": true, + "strict": true, + + "outDir": "lib", + "declaration": true + }, + "include": ["src"], + "exclude": ["node_modules", "tests/*"], + "ts-node": { + "esm": true + } +} \ No newline at end of file