diff --git a/CHANGELOG.md b/CHANGELOG.md index d965f768..661f826e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add automatic command registration + ### Changed - Update dependencies @@ -24,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed - **Breaking:** remove `pm2` from the `npm start` script +- Remove `commands:deploy` and `commands:revoke` scripts ### Fixed diff --git a/README.md b/README.md index 587ba51e..011e64d0 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ The docker script will take care of every part of the docker development process ### Build the bot -Be sure to install dependencies, generate necessary files, initialize the database, and deploy commands. Here's a handy command to do all of that: +Be sure to install dependencies, generate necessary files, and initialize the database. Here's a handy command to do all of that: ```sh $ npm run setup @@ -245,16 +245,6 @@ Migrations can be run on the database with the following command: $ npm run db:migrate ``` -### Register Slash Commands - -If you want support for Discord [Slash Commands](https://support.discord.com/hc/en-us/articles/1500000368501-Slash-Commands-FAQ), you'll need to deploy the commands directly. To avoid rate limits, use a command-line tool, rather than deploying on startup. - -Once you have your bot's account token in the .env file, run the following command to tell Discord about our commands: - -```sh -$ npm run commands:deploy -``` - ### Test the bot Whenever you make changes, you should make sure to run all unit tests before submitting. diff --git a/package.json b/package.json index 3e3f2adc..e98c1383 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,6 @@ "type": "module", "main": "src/main.ts", "scripts": { - "commands:deploy": "node --env-file=.env . --deploy # TODO: Replace these with automatic command deployment", - "commands:revoke": "node --env-file=.env . --revoke", "db:generate": "./node_modules/.bin/prisma generate --no-hints --schema ./prisma/schema.prisma", "db:init": "npm run db:migrate:initial || ./node_modules/.bin/prisma db push && npm run db:migrate:initial", "db:migrate:initial": "./node_modules/.bin/prisma migrate resolve --applied 20240905171155_initial_state", @@ -38,7 +36,7 @@ "lint": "./node_modules/.bin/eslint --flag unstable_native_nodejs_ts_config", "lint:fix": "npm run lint -- --fix", "release": "node ./scripts/release.ts", - "setup": "npm ci && npm run export-version && npm run db:migrate && npm run db:generate && npm run commands:deploy", + "setup": "npm ci && npm run export-version && npm run db:migrate && npm run db:generate", "start": "node --env-file=.env .", "test": "./node_modules/.bin/vitest", "type-check": "./node_modules/.bin/tsc --noEmit" diff --git a/scripts/launch_in_docker.sh b/scripts/launch_in_docker.sh index 90b40ba2..9dbbbba5 100755 --- a/scripts/launch_in_docker.sh +++ b/scripts/launch_in_docker.sh @@ -13,8 +13,5 @@ else npm run db:init fi -# Deploy commands -node . --deploy - # Launch node . diff --git a/src/@types/Command.d.ts b/src/@types/Command.d.ts index 48c22777..76e8e5a8 100644 --- a/src/@types/Command.d.ts +++ b/src/@types/Command.d.ts @@ -19,7 +19,7 @@ declare global { | Omit; /** The type of the command. */ - type?: ApplicationCommandType.ChatInput; + type: ApplicationCommandType.ChatInput; /** * A handler for autocomplete requests. diff --git a/src/commands/contextMenu/altText.ts b/src/commands/contextMenu/altText.ts index 4be51429..8e3871d6 100644 --- a/src/commands/contextMenu/altText.ts +++ b/src/commands/contextMenu/altText.ts @@ -4,7 +4,9 @@ import { ApplicationCommandType, ContextMenuCommandBuilder } from 'discord.js'; import { isNonEmptyArray } from '../../helpers/guards/isNonEmptyArray.ts'; export const altText: MessageContextMenuCommand = { - info: new ContextMenuCommandBuilder().setName('Get Alt Text'), + info: new ContextMenuCommandBuilder() + .setName('Get Alt Text') + .setType(ApplicationCommandType.Message), type: ApplicationCommandType.Message, requiresGuild: false, async execute({ targetMessage, replyPrivately }) { diff --git a/src/commands/contextMenu/fxtwitter.ts b/src/commands/contextMenu/fxtwitter.ts index e14592cc..71e93958 100644 --- a/src/commands/contextMenu/fxtwitter.ts +++ b/src/commands/contextMenu/fxtwitter.ts @@ -10,7 +10,9 @@ const x = 'x.com'; const xPermutations = new Set([x, `www.${x}`]); export const fxtwitter: MessageContextMenuCommand = { - info: new ContextMenuCommandBuilder().setName('Fix Twitter/X Links'), + info: new ContextMenuCommandBuilder() + .setName('Fix Twitter/X Links') + .setType(ApplicationCommandType.Message), type: ApplicationCommandType.Message, requiresGuild: false, async execute({ targetMessage, replyPrivately }) { diff --git a/src/commands/contextMenu/talk.ts b/src/commands/contextMenu/talk.ts index f23f340e..9cde6a05 100644 --- a/src/commands/contextMenu/talk.ts +++ b/src/commands/contextMenu/talk.ts @@ -2,7 +2,9 @@ import { ApplicationCommandType, ContextMenuCommandBuilder } from 'discord.js'; import { speak } from '../talk.ts'; -const builder = new ContextMenuCommandBuilder().setName('Talk'); +const builder = new ContextMenuCommandBuilder() + .setName('Talk') + .setType(ApplicationCommandType.Message); export const talk: MessageContextMenuCommand = { info: builder, diff --git a/src/commands/emoji.ts b/src/commands/emoji.ts index 442b6692..0501467b 100644 --- a/src/commands/emoji.ts +++ b/src/commands/emoji.ts @@ -1,4 +1,4 @@ -import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { ApplicationCommandType, EmbedBuilder, SlashCommandBuilder } from 'discord.js'; import { UserMessageError } from '../helpers/UserMessageError.ts'; @@ -21,6 +21,7 @@ const builder = new SlashCommandBuilder() export const emoji: GlobalCommand = { info: builder, + type: ApplicationCommandType.ChatInput, requiresGuild: false, async execute({ reply, client, options }): Promise { const emojiName = options.getString(EmojiName, true); diff --git a/src/commands/findRoom.ts b/src/commands/findRoom.ts index 08287e18..84bbcab7 100644 --- a/src/commands/findRoom.ts +++ b/src/commands/findRoom.ts @@ -1,5 +1,5 @@ import { array, boolean, string, tuple, type as schema } from 'superstruct'; -import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { ApplicationCommandType, EmbedBuilder, SlashCommandBuilder } from 'discord.js'; import { URL } from 'node:url'; import { fetchJson } from '../helpers/fetch.ts'; @@ -213,6 +213,7 @@ const builder = new SlashCommandBuilder() export const findRoom: GlobalCommand = { info: builder, + type: ApplicationCommandType.ChatInput, requiresGuild: false, async execute({ replyPrivately, options }): Promise { const input_bldg = options.getString('building'); diff --git a/src/commands/help.ts b/src/commands/help.ts index 140c6c6f..c0d440bb 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -1,4 +1,4 @@ -import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { ApplicationCommandType, EmbedBuilder, SlashCommandBuilder } from 'discord.js'; import { appVersion, repo } from '../constants/meta.ts'; @@ -8,6 +8,7 @@ const builder = new SlashCommandBuilder() export const help: GlobalCommand = { info: builder, + type: ApplicationCommandType.ChatInput, requiresGuild: false, async execute({ reply }) { const embed = new EmbedBuilder() diff --git a/src/commands/isCasDown.ts b/src/commands/isCasDown.ts index f49c9ca5..d5820eb0 100644 --- a/src/commands/isCasDown.ts +++ b/src/commands/isCasDown.ts @@ -1,4 +1,4 @@ -import { EmbedBuilder, SlashCommandBuilder, Colors } from 'discord.js'; +import { EmbedBuilder, SlashCommandBuilder, Colors, ApplicationCommandType } from 'discord.js'; const statusURI = 'https://cas.byu.edu/cas/serviceValidate'; @@ -18,6 +18,7 @@ const builder = new SlashCommandBuilder() export const isCasDown: GlobalCommand = { info: builder, + type: ApplicationCommandType.ChatInput, requiresGuild: false, async execute({ reply }) { const res = await fetch(statusURI); diff --git a/src/commands/profile.ts b/src/commands/profile.ts index 3488a71e..5007166b 100644 --- a/src/commands/profile.ts +++ b/src/commands/profile.ts @@ -1,5 +1,11 @@ import type { User } from 'discord.js'; -import { DiscordAPIError, EmbedBuilder, SlashCommandBuilder, userMention } from 'discord.js'; +import { + ApplicationCommandType, + DiscordAPIError, + EmbedBuilder, + SlashCommandBuilder, + userMention, +} from 'discord.js'; import { DiscordErrorCode } from '../helpers/DiscordErrorCode.ts'; @@ -14,6 +20,7 @@ const builder = new SlashCommandBuilder() export const profile: GlobalCommand = { info: builder, + type: ApplicationCommandType.ChatInput, requiresGuild: false, async execute({ client, user, options, reply, guild, source }) { const otherUser = options.getUser(UserParamName); diff --git a/src/commands/sendtag.ts b/src/commands/sendtag.ts index cadb7a45..c0e4b655 100644 --- a/src/commands/sendtag.ts +++ b/src/commands/sendtag.ts @@ -1,4 +1,4 @@ -import { SlashCommandBuilder } from 'discord.js'; +import { ApplicationCommandType, SlashCommandBuilder } from 'discord.js'; const NameOption = 'name'; @@ -15,6 +15,7 @@ const info = new SlashCommandBuilder() export const sendtag: GuildedCommand = { info, + type: ApplicationCommandType.ChatInput, requiresGuild: true, autocomplete(interaction) { // Get the user-provided intermediate value diff --git a/src/commands/setReactboard.ts b/src/commands/setReactboard.ts index aee2c546..49364675 100644 --- a/src/commands/setReactboard.ts +++ b/src/commands/setReactboard.ts @@ -1,4 +1,5 @@ import { + ApplicationCommandType, Guild, GuildEmoji, SlashCommandBuilder, @@ -44,6 +45,7 @@ interface ReactboardReactInfo { export const setReactboard: GuildedCommand = { info: builder, + type: ApplicationCommandType.ChatInput, requiresGuild: true, async execute({ guild, options, replyPrivately }) { const channel = options.getChannel(channelOption, true); diff --git a/src/commands/stats.ts b/src/commands/stats.ts index a5ea1080..e00ff540 100644 --- a/src/commands/stats.ts +++ b/src/commands/stats.ts @@ -1,5 +1,5 @@ import type { ChatInputCommandInteraction } from 'discord.js'; -import { EmbedBuilder, SlashCommandBuilder, userMention } from 'discord.js'; +import { ApplicationCommandType, EmbedBuilder, SlashCommandBuilder, userMention } from 'discord.js'; import { db } from '../database/index.ts'; import { sanitize } from '../helpers/sanitize.ts'; @@ -73,8 +73,8 @@ const builder = new SlashCommandBuilder() export const stats: GuildedCommand = { info: builder, + type: ApplicationCommandType.ChatInput, requiresGuild: true, - async execute({ reply, replyPrivately, interaction, guild }): Promise { const subcommand = interaction.options.getSubcommand(); diff --git a/src/commands/talk.ts b/src/commands/talk.ts index 0932d689..9a2234b9 100644 --- a/src/commands/talk.ts +++ b/src/commands/talk.ts @@ -1,5 +1,10 @@ import type { AttachmentPayload } from 'discord.js'; -import { SlashCommandBuilder, ChannelType, PermissionFlagsBits } from 'discord.js'; +import { + SlashCommandBuilder, + ChannelType, + PermissionFlagsBits, + ApplicationCommandType, +} from 'discord.js'; import type { AudioResource } from '@discordjs/voice'; import { createAudioResource, @@ -46,6 +51,7 @@ const builder = new SlashCommandBuilder() export const talk: GlobalCommand = { info: builder, + type: ApplicationCommandType.ChatInput, requiresGuild: false, async execute(context) { const options = context.options; diff --git a/src/commands/toTheGallows.ts b/src/commands/toTheGallows.ts index 02066251..4662acd0 100644 --- a/src/commands/toTheGallows.ts +++ b/src/commands/toTheGallows.ts @@ -1,4 +1,4 @@ -import { SlashCommandBuilder } from 'discord.js'; +import { ApplicationCommandType, SlashCommandBuilder } from 'discord.js'; import { EvilHangmanGame } from '../evilHangman/evilHangmanGame.ts'; import { buildEvilHangmanMessage } from '../evilHangman/evilHangmanMessage.ts'; @@ -25,6 +25,7 @@ const builder = new SlashCommandBuilder() export const toTheGallows: GlobalCommand = { info: builder, + type: ApplicationCommandType.ChatInput, requiresGuild: false, async execute({ reply, options }): Promise { const wordLength = options.getInteger(LengthOption); diff --git a/src/commands/xkcd.ts b/src/commands/xkcd.ts index c4119246..b68d7176 100644 --- a/src/commands/xkcd.ts +++ b/src/commands/xkcd.ts @@ -1,4 +1,4 @@ -import { EmbedBuilder, SlashCommandBuilder } from 'discord.js'; +import { ApplicationCommandType, EmbedBuilder, SlashCommandBuilder } from 'discord.js'; import { URL } from 'node:url'; import { number, string, type as schema } from 'superstruct'; @@ -75,10 +75,9 @@ const builder = new SlashCommandBuilder() ); export const xkcd: GlobalCommand = { - requiresGuild: false, info: builder, - - // entry point for command execution + type: ApplicationCommandType.ChatInput, + requiresGuild: false, async execute({ options, reply, sendTyping }) { let comic = ''; // not making this nullable, instead filling with dummy data to be later filled. diff --git a/src/events/clientReady.test.ts b/src/events/clientReady.test.ts index 634100dc..73009099 100644 --- a/src/events/clientReady.test.ts +++ b/src/events/clientReady.test.ts @@ -1,98 +1,44 @@ -import type { Mock } from 'vitest'; -import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { afterEach, describe, expect, test, vi } from 'vitest'; -import type { Client } from 'discord.js'; - -// Mock parseArgs so we can control what the args are -import type { parseArgs } from '../helpers/parseArgs.ts'; -const mockParseArgs = vi.hoisted(() => vi.fn()); -vi.mock('../helpers/parseArgs', () => ({ parseArgs: mockParseArgs })); +import type { Worker } from 'node:worker_threads'; -// Mock deployCommands so we can track it -vi.mock('../helpers/actions/deployCommands.ts'); -import { deployCommands } from '../helpers/actions/deployCommands.ts'; -const mockDeployCommands = deployCommands as Mock; +import type { Client } from 'discord.js'; -// Mock revokeCommands so we can track it -vi.mock('../helpers/actions/revokeCommands.ts'); -import { revokeCommands } from '../helpers/actions/revokeCommands.ts'; -const mockRevokeCommands = revokeCommands as Mock; +import { registerCommands } from '../helpers/actions/registerCommands.ts'; +import { clientReady } from './clientReady.ts'; -// Mock verifyCommandDeployments so we can track it -vi.mock('../helpers/actions/verifyCommandDeployments.ts'); -import { verifyCommandDeployments } from '../helpers/actions/verifyCommandDeployments.ts'; -const mockVerifyCommandDeployments = verifyCommandDeployments as Mock< - typeof verifyCommandDeployments ->; +vi.mock(import('../helpers/actions/registerCommands.ts')); -// Mock the logger so nothing is printed vi.mock('../logger.ts'); -// Import the code to test -import { clientReady } from './clientReady.ts'; +const mockWorkerConstructor = vi.fn(); +vi.mock(import('node:worker_threads'), () => ({ + // eslint-disable-next-line @typescript-eslint/no-extraneous-class + Worker: class { + public constructor() { + mockWorkerConstructor(); + } + } as unknown as typeof Worker, +})); describe('once(clientReady)', () => { const client = { - user: { username: 'Ze Kaiser Jr.' }, - destroy() { - // nop - }, + user: { username: 'mock_user' }, } as Client; - beforeEach(() => { - // Default is no deploy, no revoke, no method behavior - mockParseArgs.mockReturnValue({ - deploy: false, - revoke: false, - }); - }); - afterEach(() => { - vi.restoreAllMocks(); - }); - - test("doesn't touch commands if the `deploy` and `revoke` flags are not set", async () => { - mockParseArgs.mockReturnValue({ - deploy: false, - revoke: false, - }); - await clientReady.execute(client); - expect(mockDeployCommands).not.toHaveBeenCalled(); - expect(mockRevokeCommands).not.toHaveBeenCalled(); - }); - - test('deploys commands if the `deploy` flag is set', async () => { - mockParseArgs.mockReturnValue({ - deploy: true, - revoke: false, - }); - await clientReady.execute(client); - expect(mockDeployCommands).toHaveBeenCalledWith(client); - expect(mockRevokeCommands).not.toHaveBeenCalled(); - }); - - test('revokes commands if the `revoke` flag is set', async () => { - mockParseArgs.mockReturnValue({ - deploy: false, - revoke: true, - }); - await clientReady.execute(client); - expect(mockDeployCommands).not.toHaveBeenCalled(); - expect(mockRevokeCommands).toHaveBeenCalledWith(client); + vi.unstubAllEnvs(); }); - test('deploys commands if both the `revoke` and `deploy` flags are set', async () => { - mockParseArgs.mockReturnValue({ - deploy: true, - revoke: true, - }); + test('syncs commands', async () => { await clientReady.execute(client); - expect(mockDeployCommands).toHaveBeenCalledWith(client); - expect(mockRevokeCommands).not.toHaveBeenCalled(); + expect(vi.mocked(registerCommands)).toHaveBeenCalled(); }); - test('verifies command deployments', async () => { + test('starts uptime ping worker if UPTIME_URL is set', async () => { + vi.stubEnv('UPTIME_URL', 'https://example.com'); await clientReady.execute(client); - expect(mockVerifyCommandDeployments).toHaveBeenCalledWith(client); + expect(mockWorkerConstructor).toHaveBeenCalled(); + mockWorkerConstructor.mockClear(); }); }); diff --git a/src/events/clientReady.ts b/src/events/clientReady.ts index 5f7dd29b..19ada5a0 100644 --- a/src/events/clientReady.ts +++ b/src/events/clientReady.ts @@ -2,11 +2,8 @@ import { Events } from 'discord.js'; import { Worker } from 'node:worker_threads'; import { appVersion } from '../constants/meta.ts'; -import { deployCommands } from '../helpers/actions/deployCommands.ts'; -import { revokeCommands } from '../helpers/actions/revokeCommands.ts'; import { onEvent } from '../helpers/onEvent.ts'; -import { parseArgs } from '../helpers/parseArgs.ts'; -import { verifyCommandDeployments } from '../helpers/actions/verifyCommandDeployments.ts'; +import { registerCommands } from '../helpers/actions/registerCommands.ts'; import { info } from '../logger.ts'; /** @@ -17,25 +14,7 @@ export const clientReady = onEvent(Events.ClientReady, { async execute(client) { info(`Starting ${client.user.username} v${appVersion}...`); - const args = parseArgs(); - - // If we're only here to deploy commands, do that and then exit - if (args.deploy) { - await deployCommands(client); - await client.destroy(); - return; - } - - // If we're only here to revoke commands, do that and then exit - if (args.revoke) { - await revokeCommands(client); - await client.destroy(); - return; - } - - // Sanity check for commands - info('Verifying command deployments...'); - await verifyCommandDeployments(client); + await registerCommands(client); // Start uptime ping const UPTIME_URL = process.env['UPTIME_URL']; diff --git a/src/events/interactionCreate.test.ts b/src/events/interactionCreate.test.ts index e46f4811..8b5c8977 100644 --- a/src/events/interactionCreate.test.ts +++ b/src/events/interactionCreate.test.ts @@ -31,6 +31,7 @@ const mockGlobalCommand: ChatInputCommand = { info: new SlashCommandBuilder() // .setName('global-test') .setDescription('lolcat'), + type: ApplicationCommandType.ChatInput, requiresGuild: false, execute: mockGlobalExecute, }; @@ -41,6 +42,7 @@ const mockGlobalAutocompleteCommand: ChatInputCommand = { info: new SlashCommandBuilder() // .setName('global-autocomplete-test') .setDescription('lolcat'), + type: ApplicationCommandType.ChatInput, requiresGuild: false, execute: mockGlobalExecute, autocomplete: mockGlobalAutocomplete, @@ -99,6 +101,7 @@ const mockGuildedCommand: ChatInputCommand = { info: new SlashCommandBuilder() // .setName('guilded-test') .setDescription('lolcat'), + type: ApplicationCommandType.ChatInput, requiresGuild: true, execute: mockGuildedExecute, }; @@ -108,6 +111,7 @@ const mockErrorGlobalCommand: Command = { info: new SlashCommandBuilder() // .setName('global-error-test') .setDescription('whoops'), + type: ApplicationCommandType.ChatInput, requiresGuild: false, execute: () => { throw new Error('Command error, this is a test'); @@ -119,6 +123,7 @@ const mockErrorGuildedCommand: Command = { info: new SlashCommandBuilder() // .setName('guilded-error-test') .setDescription('whoops'), + type: ApplicationCommandType.ChatInput, requiresGuild: true, execute: () => { throw new Error('Command error, this is a test'); @@ -131,6 +136,7 @@ const mockUserMessageErrorGlobalCommand: Command = { info: new SlashCommandBuilder() // .setName('global-error-test') .setDescription('whoops'), + type: ApplicationCommandType.ChatInput, requiresGuild: false, execute: () => { throw new UserMessageError(userErrorMessage); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts index 84586972..0e378647 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -75,7 +75,7 @@ async function handleCommandInteraction( // Fixes weird hangs when the command list is out of date: await sendErrorMessage( interaction, - `Unknown command name '${interaction.commandName}'. Contact the bot operator and make sure they deployed the latest set of commands.` + `Unknown command name '${interaction.commandName}'. Contact the bot operator and make sure they registered the latest set of commands.` ); return; } @@ -209,7 +209,7 @@ async function handleAutocompleteInteraction(interaction: AutocompleteInteractio } // Command must be a chat-input command - if (command.type !== ApplicationCommandType.ChatInput && command.type !== undefined) { + if (command.type !== ApplicationCommandType.ChatInput) { warn( `Received an autocomplete request for command '${command.info.name}'. This command must be of type 'ChatInput', but was found instead to be of a different type (${command.type}).` ); @@ -316,7 +316,7 @@ async function handleButtonInteraction( warn(`Received request to execute unknown button with id '${interaction.customId}'`); await sendErrorMessage( interaction, - `Unknown button '${interaction.customId}'. Contact the bot operator and make sure they deployed the latest set of commands.` + `Unknown button '${interaction.customId}'. Contact the bot operator and make sure they registered the latest set of commands.` ); return; } diff --git a/src/helpers/actions/areCommandsRegistered.test.ts b/src/helpers/actions/areCommandsRegistered.test.ts new file mode 100644 index 00000000..a09b86fb --- /dev/null +++ b/src/helpers/actions/areCommandsRegistered.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, test, vi } from 'vitest'; + +import type { ApplicationCommand, Guild } from 'discord.js'; +import { + ApplicationCommandOptionType, + ApplicationCommandType, + SlashCommandBuilder, +} from 'discord.js'; + +import { areCommandsRegistered } from './areCommandsRegistered.ts'; + +describe('Check if commands are registered', () => { + const mockGuild = { + id: 'test-guild1', + } as Guild; + + // Covers description, options + const actualGlobalCommand1 = { + name: 'zaphod', + description: 'description', + type: ApplicationCommandType.ChatInput, + nsfw: false, + options: [ + { + name: 'mock_option', + description: 'option_description', + type: ApplicationCommandOptionType.String, + required: false, + }, + ], + } as ApplicationCommand; + + const expectedGlobalCommand1 = { + info: new SlashCommandBuilder() + .setName('zaphod') + .setDescription('description') + .addStringOption(option => + option.setName('mock_option').setDescription('option_description') + ), + type: ApplicationCommandType.ChatInput, + requiresGuild: false, + execute: vi.fn(), + }; + + // Covers unset description, nsfw, unset options + const actualGlobalCommand2 = { + name: 'beeblebrox', + description: '', + type: ApplicationCommandType.ChatInput, + nsfw: true, + options: [] as Array, + } as ApplicationCommand; + + const expectedGlobalCommand2 = { + info: new SlashCommandBuilder().setName('beeblebrox').setNSFW(true), + type: ApplicationCommandType.ChatInput, + requiresGuild: false, + execute: vi.fn(), + }; + + // Covers subcommands, subcommand options + const actualGuildedCommand1 = { + name: 'arthur', + description: '', + type: ApplicationCommandType.ChatInput, + nsfw: false, + options: [ + { + name: 'sub1', + description: 'sub1 description', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'sub1_option', + description: 'sub1_option description', + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + ] as Array, + }, + ] as ApplicationCommand['options'], + } as ApplicationCommand; + + const expectedGuildedCommand1 = { + info: new SlashCommandBuilder().setName('arthur').addSubcommand(subcommand => + subcommand + .setName('sub1') + .setDescription('sub1 description') + .addBooleanOption(option => + option.setName('sub1_option').setDescription('sub1_option description') + ) + ), + type: ApplicationCommandType.ChatInput, + requiresGuild: true, + execute: vi.fn(), + }; + + // Covers subcommand groups + const actualGuildedCommand2 = { + name: 'dent', + description: 'dent description', + type: ApplicationCommandType.ChatInput, + nsfw: false, + options: [ + { + name: 'group1', + description: 'group1 description', + type: ApplicationCommandOptionType.SubcommandGroup, + options: [ + { + name: 'sub1', + description: 'sub1 description', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'sub1_option', + description: 'sub1_option description', + type: ApplicationCommandOptionType.String, + required: false, + }, + ], + }, + { + name: 'sub2', + description: 'sub2 description', + type: ApplicationCommandOptionType.Subcommand, + options: [ + { + name: 'sub2_option', + description: 'sub2_option description', + type: ApplicationCommandOptionType.Number, + required: false, + }, + ], + }, + ], + }, + ] as ApplicationCommand['options'], + } as ApplicationCommand; + + const expectedGuildedCommand2 = { + info: new SlashCommandBuilder() + .setName('dent') + .setDescription('dent description') + .addSubcommandGroup(subcommandGroup => + subcommandGroup + .setName('group1') + .setDescription('group1 description') + .addSubcommand(subcommand => + subcommand + .setName('sub1') + .setDescription('sub1 description') + .addStringOption(option => + option.setName('sub1_option').setDescription('sub1_option description') + ) + ) + .addSubcommand(subcommand => + subcommand + .setName('sub2') + .setDescription('sub2 description') + .addNumberOption(option => + option.setName('sub2_option').setDescription('sub2_option description') + ) + ) + ), + type: ApplicationCommandType.ChatInput, + requiresGuild: true, + execute: vi.fn(), + }; + + describe('Global commands', () => { + test('returns true if the actual commands match expectations', () => { + const result = areCommandsRegistered( + { + global: [actualGlobalCommand1, actualGlobalCommand2], + guilded: new Map(), + }, + { + global: [expectedGlobalCommand1, expectedGlobalCommand2], + guilded: [], + } + ); + expect(result).toBe(true); + }); + + test('returns false if the number of commands differs', () => { + const result = areCommandsRegistered( + { + global: [actualGlobalCommand1], + guilded: new Map(), + }, + { + global: [expectedGlobalCommand1, expectedGlobalCommand2], + guilded: [], + } + ); + expect(result).toBe(false); + }); + + test('returns false if the command lists differ', () => { + // Name differs + let modActualGlobalCommand2 = { + ...actualGlobalCommand2, + name: 'not_beeblebrox', + } as ApplicationCommand; + let result = areCommandsRegistered( + { + global: [actualGlobalCommand1, modActualGlobalCommand2], + guilded: new Map(), + }, + { + global: [expectedGlobalCommand1, expectedGlobalCommand2], + guilded: [], + } + ); + expect(result).toBe(false); + + // Options differ + modActualGlobalCommand2 = { + ...actualGlobalCommand2, + options: [{ name: 'new_option' }], + } as ApplicationCommand; + result = areCommandsRegistered( + { + global: [actualGlobalCommand1, modActualGlobalCommand2], + guilded: new Map(), + }, + { + global: [expectedGlobalCommand1, expectedGlobalCommand2], + guilded: [], + } + ); + expect(result).toBe(false); + }); + }); + + describe('Guild commands', () => { + test('returns true if the actual commands match expectations', () => { + const result = areCommandsRegistered( + { + global: [], + guilded: new Map([[mockGuild, [actualGuildedCommand1, actualGuildedCommand2]]]), + }, + { + global: [], + guilded: [expectedGuildedCommand1, expectedGuildedCommand2], + } + ); + expect(result).toBe(true); + }); + + test('returns false if the number of commands differs', () => { + const result = areCommandsRegistered( + { + global: [], + guilded: new Map([[mockGuild, [actualGuildedCommand1]]]), + }, + { + global: [], + guilded: [expectedGuildedCommand1, expectedGuildedCommand2], + } + ); + expect(result).toBe(false); + }); + + test('returns false if the command lists differ', () => { + // Name diffs + let modActualGuildedCommand2 = { + ...actualGuildedCommand2, + name: 'not_dent', + } as ApplicationCommand; + let result = areCommandsRegistered( + { + global: [], + guilded: new Map([[mockGuild, [actualGuildedCommand1, modActualGuildedCommand2]]]), + }, + { + global: [], + guilded: [expectedGuildedCommand1, expectedGuildedCommand2], + } + ); + expect(result).toBe(false); + + // Options differ + modActualGuildedCommand2 = { + ...actualGuildedCommand2, + options: [{ name: 'new_option' }], + } as ApplicationCommand; + result = areCommandsRegistered( + { + global: [], + guilded: new Map([[mockGuild, [actualGuildedCommand1, modActualGuildedCommand2]]]), + }, + { + global: [], + guilded: [expectedGuildedCommand1, expectedGuildedCommand2], + } + ); + expect(result).toBe(false); + }); + }); +}); diff --git a/src/helpers/actions/areCommandsRegistered.ts b/src/helpers/actions/areCommandsRegistered.ts new file mode 100644 index 00000000..75627907 --- /dev/null +++ b/src/helpers/actions/areCommandsRegistered.ts @@ -0,0 +1,138 @@ +import { isDeepStrictEqual } from 'node:util'; + +import type { + ApplicationCommandType, + ApplicationCommand, + Guild, + ApplicationCommandOptionType, +} from 'discord.js'; + +/** + * @param actualCommands Commands currently registered, pulled from the Discord API + * @param expectedCommands Command handlers from local definitions + * @returns Whether the registered and local command definitions agree + */ +export function areCommandsRegistered( + actualCommands: { + global: Array; + guilded: Map>; + }, + expectedCommands: { + global: Array; + guilded: Array; + } +): boolean { + return ( + areGlobalCommandRegistered(actualCommands.global, expectedCommands.global) && + areGuildedCommandsRegistered(actualCommands.guilded, expectedCommands.guilded) + ); +} + +function areGlobalCommandRegistered( + actualCommands: Array, + expectedCommands: Array +): boolean { + if (actualCommands.length !== expectedCommands.length) { + return false; + } + const actualCommandsComparable = actualCommands + .map(getCommandComparableValues) + .toSorted((a, b) => a.name.localeCompare(b.name)); + const expectedCommandsComparable = expectedCommands + .map(getCommandComparableValues) + .toSorted((a, b) => a.name.localeCompare(b.name)); + return isDeepStrictEqual(actualCommandsComparable, expectedCommandsComparable); +} + +function areGuildedCommandsRegistered( + actualCommands: Map>, + expectedCommands: Array +): boolean { + const expectedCommandsComparable = expectedCommands + .map(getCommandComparableValues) + .toSorted((a, b) => a.name.localeCompare(b.name)); + for (const actualCommandsOfGuild of actualCommands.values()) { + if (actualCommandsOfGuild.length !== expectedCommands.length) { + return false; + } + const actualCommandsComparable = actualCommandsOfGuild + .map(getCommandComparableValues) + .toSorted((a, b) => a.name.localeCompare(b.name)); + if (!isDeepStrictEqual(actualCommandsComparable, expectedCommandsComparable)) { + return false; + } + } + return true; +} + +/** The comparable values of {@link ApplicationCommand} and {@link Command} */ +interface CommandComparableValues { + name: string; + description: string; + type: ApplicationCommandType; + nsfw: boolean; + options: Array; +} + +interface OptionComparableValues { + name: string; + description: string; + type: ApplicationCommandOptionType; + required?: boolean; + options: Array; // Subcommands +} + +/** + * @returns An object of the comparable values between {@link ApplicationCommand} and {@link Command} + * + * Currently only considers name, description, type, nfsw, and options + * (including subcommand group and subcommands) for comparison, + * but can be extended as needed. + */ +function getCommandComparableValues( + command: ApplicationCommand | Command +): CommandComparableValues { + const name = 'info' in command ? command.info.name : command.name; + const description = + 'info' in command + ? 'description' in command.info + ? // Types are wrong - `SlashCommandBuilder#description` is undefined if not set + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (command.info.description ?? '') + : '' + : command.description; + const type = command.type; + const nsfw = + 'info' in command + ? 'nsfw' in command.info + ? (command.info.nsfw ?? false) + : false + : command.nsfw; + const options = + 'info' in command + ? 'options' in command.info + ? command.info.options.map(opt => opt.toJSON()) + : [] + : command.options; + + /** Recursive to handle options and subcommands with options */ + function getOptionComparableValues(opt: (typeof options)[number]): OptionComparableValues { + const subOptions = 'options' in opt ? (opt.options ?? []) : []; + const required = 'required' in opt ? opt.required : undefined; + return { + name: opt.name, + description: opt.description, + type: opt.type, + required, + options: subOptions.map(getOptionComparableValues), + }; + } + + return { + name, + description, + type, + nsfw, + options: options.map(getOptionComparableValues), + }; +} diff --git a/src/helpers/actions/deployCommands.test.ts b/src/helpers/actions/deployCommands.test.ts deleted file mode 100644 index cad2f22a..00000000 --- a/src/helpers/actions/deployCommands.test.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { Mock } from 'vitest'; -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import type { Client, Guild, OAuth2Guild } from 'discord.js'; -import { Collection, InteractionContextType, SlashCommandBuilder } from 'discord.js'; - -// Mock the logger so nothing is printed -vi.mock('../../logger.ts'); - -const mockAllCommands = vi.hoisted(() => new Map()); -vi.mock('../../commands/index.ts', () => ({ - allCommands: mockAllCommands, -})); - -vi.mock('./revokeCommands.ts'); -import { revokeCommands } from './revokeCommands.ts'; -const mockRevokeCommands = revokeCommands as Mock; - -import { deployCommands } from './deployCommands.ts'; - -describe('Command deployments', () => { - const mockApplicationCommandsSet = vi.fn['commands']['set']>(); - const mockGuildCommandsSet = vi.fn(); - const mockFetchOauthGuilds = vi.fn(); - - const mockClient = { - application: { - commands: { - set: mockApplicationCommandsSet, - }, - }, - guilds: { - fetch: mockFetchOauthGuilds, - }, - } as unknown as Client; - - beforeEach(() => { - mockGuildCommandsSet.mockImplementation(() => Promise.resolve(new Collection())); - mockFetchOauthGuilds.mockResolvedValue( - new Collection().set('test-guild1', { - fetch: (): Promise => - Promise.resolve({ - id: 'test-guild1', - commands: { - set: mockGuildCommandsSet, - }, - } as unknown as Guild), - } as OAuth2Guild) - ); - const mockCommands: NonEmptyArray = [ - { - info: new SlashCommandBuilder().setName('test1').setDescription(' '), - requiresGuild: false, - execute: vi.fn(), - }, - { - info: new SlashCommandBuilder() - .setName('test2') - .setDescription(' ') - .setNameLocalizations({}), - requiresGuild: true, - execute: vi.fn(), - }, - { - info: new SlashCommandBuilder() - .setName('test3') - .setDescription(' ') - .setNameLocalizations({}) - .setDescriptionLocalizations({}), - requiresGuild: true, - execute: vi.fn(), - }, - { - info: new SlashCommandBuilder() - .setName('test4') - .setDescription(' ') - .setNameLocalizations({}) - .setDescriptionLocalizations({}) - .addStringOption(option => option.setName('c').setDescription(' ')), - requiresGuild: true, - execute: vi.fn(), - }, - { - info: new SlashCommandBuilder() - .setName('test5') - .setDescription(' ') - .setNameLocalizations({}) - .setDescriptionLocalizations({}) - .setDefaultMemberPermissions(null) - .addStringOption(option => option.setName('c').setDescription(' ')), - requiresGuild: true, - execute: vi.fn(), - }, - { - info: new SlashCommandBuilder() - .setName('test6') - .setDescription(' ') - .setNameLocalizations({}) - .setDescriptionLocalizations({}) - .setContexts(InteractionContextType.Guild) - .addStringOption(option => option.setName('c').setDescription(' ')), - requiresGuild: true, - execute: vi.fn(), - }, - ]; - for (const cmd of mockCommands) { - mockAllCommands.set(cmd.info.name, cmd); - } - }); - - test('does no deployments if there are no commands to deploy', async () => { - mockAllCommands.clear(); - await deployCommands(mockClient); - expect(mockRevokeCommands).toHaveBeenCalledOnce(); - expect(mockApplicationCommandsSet).not.toHaveBeenCalled(); - expect(mockGuildCommandsSet).not.toHaveBeenCalled(); - expect(mockFetchOauthGuilds).not.toHaveBeenCalled(); - }); - - test('revokes commands before deploying', async () => { - mockRevokeCommands.mockRejectedValue(new Error('This is a test')); - await expect(deployCommands(mockClient)).rejects.toThrow(); - expect(mockRevokeCommands).toHaveBeenCalledOnce(); - expect(mockApplicationCommandsSet).not.toHaveBeenCalled(); - expect(mockGuildCommandsSet).not.toHaveBeenCalled(); - expect(mockFetchOauthGuilds).not.toHaveBeenCalled(); - }); - - test('continues deployments if global commands fail to deploy', async () => { - mockApplicationCommandsSet.mockRejectedValueOnce(new Error('This is a test')); - await deployCommands(mockClient); - expect(mockApplicationCommandsSet).toHaveBeenCalledOnce(); - expect(mockGuildCommandsSet).toHaveBeenCalledOnce(); - }); - - test('continues deployments if guild-bound commands fail to deploy', async () => { - mockGuildCommandsSet.mockRejectedValueOnce(new Error('This is a test')); - await deployCommands(mockClient); - expect(mockApplicationCommandsSet).toHaveBeenCalledOnce(); - expect(mockGuildCommandsSet).toHaveBeenCalledOnce(); - }); -}); diff --git a/src/helpers/actions/deployCommands.ts b/src/helpers/actions/deployCommands.ts deleted file mode 100644 index 8c046ba1..00000000 --- a/src/helpers/actions/deployCommands.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { Client, Guild, RESTPostAPIApplicationCommandsJSONBody } from 'discord.js'; -import { ApplicationCommandType } from 'discord.js'; - -import { allCommands } from '../../commands/index.ts'; -import { debug, error, info } from '../../logger.ts'; -import { isNonEmptyArray } from '../guards/isNonEmptyArray.ts'; -import { revokeCommands } from './revokeCommands.ts'; - -export async function deployCommands(client: Client): Promise { - await revokeCommands(client); // fresh start! - - info('Deploying commands...'); - const commands: ReadonlyArray = Array.from(allCommands.values()); - if (commands.length === 0) return; - info(`Syncing ${commands.length} command(s)...`); - - const guildCommands: Array = []; - const globalCommands: Array = []; - for (const cmd of commands) { - if (isContextMenuCommand(cmd) || !cmd.requiresGuild) { - globalCommands.push(cmd); - } else { - // else if (cmd.requiresGuild) - guildCommands.push(cmd); - } - } - - if (isNonEmptyArray(globalCommands)) { - await prepareGlobalCommands(globalCommands, client); - } - if (isNonEmptyArray(guildCommands)) { - await prepareGuildedCommands(guildCommands, client); - } - - info( - `All ${commands.length} command(s) prepared. Discord may take some time to sync commands to clients.` - ); -} - -async function prepareGlobalCommands( - globalCommands: NonEmptyArray, - client: Client -): Promise { - const commandBuilders = globalCommands.map(deployableCommand); - info( - `${globalCommands.length} command(s) will be set globally: ${JSON.stringify( - commandBuilders.map(cmd => cmd.name) - )}` - ); - debug(`Deploying all ${globalCommands.length} global command(s)...`); - try { - await client.application.commands.set(commandBuilders); - info(`Set ${globalCommands.length} global command(s).`); - } catch (error_) { - error('Failed to set global commands:', error_); - } -} - -async function prepareGuildedCommands( - guildCommands: NonEmptyArray, - client: Client -): Promise { - const commandBuilders = guildCommands.map(deployableCommand); - info( - `${guildCommands.length} command(s) require a guild: ${JSON.stringify( - commandBuilders.map(cmd => cmd.name) - )}` - ); - const oAuthGuilds = await client.guilds.fetch(); - const guilds = await Promise.all(oAuthGuilds.map(g => g.fetch())); - await Promise.all(guilds.map(guild => prepareCommandsForGuild(guild, guildCommands))); -} - -async function prepareCommandsForGuild( - guild: Guild, - guildCommands: ReadonlyArray -): Promise { - const commandBuilders = guildCommands.map(deployableCommand); - info( - `Deploying ${guildCommands.length} guild-bound command(s): ${JSON.stringify( - commandBuilders.map(cmd => cmd.name) - )}` - ); - try { - const result = await guild.commands.set(commandBuilders); - info(`Set ${result.size} command(s) on guild ${guild.id}`); - } catch (error_) { - error(`Failed to set commands on guild ${guild.id}:`, error_); - } -} - -/** - * Creates a deployable JSON payload from the given command. - */ -export function deployableCommand(cmd: Command): RESTPostAPIApplicationCommandsJSONBody { - if (isContextMenuCommand(cmd)) { - return cmd.info.setType(cmd.type).toJSON(); - } - - // Slash commands are simpler: - return cmd.info.toJSON(); -} - -function isContextMenuCommand(cmd: Command): cmd is ContextMenuCommand { - return cmd.type === ApplicationCommandType.Message || cmd.type === ApplicationCommandType.User; -} diff --git a/src/helpers/actions/registerCommands.test.ts b/src/helpers/actions/registerCommands.test.ts new file mode 100644 index 00000000..4b8474a5 --- /dev/null +++ b/src/helpers/actions/registerCommands.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, test, vi } from 'vitest'; + +import type { Client, Guild, OAuth2Guild } from 'discord.js'; +import { + ApplicationCommandType, + Collection, + ContextMenuCommandBuilder, + InteractionContextType, + SlashCommandBuilder, +} from 'discord.js'; + +import { areCommandsRegistered } from './areCommandsRegistered.ts'; +import { registerCommands } from './registerCommands.ts'; + +const mockAllCommands = vi.hoisted(() => new Map()); +vi.mock(import('../../commands/index.ts'), () => ({ + allCommands: mockAllCommands, +})); + +vi.mock('../../logger.ts'); + +vi.mock(import('./areCommandsRegistered.ts'), () => ({ + areCommandsRegistered: vi.fn().mockReturnValue(false), +})); + +describe('Command registration', () => { + const mockCommands: NonEmptyArray = [ + { + info: new SlashCommandBuilder().setName('test1').setDescription(' '), + requiresGuild: false, + execute: vi.fn(), + }, + { + info: new SlashCommandBuilder().setName('test2').setDescription(' ').setNameLocalizations({}), + requiresGuild: true, + execute: vi.fn(), + }, + { + info: new SlashCommandBuilder() + .setName('test3') + .setDescription(' ') + .setNameLocalizations({}) + .setDescriptionLocalizations({}), + requiresGuild: true, + execute: vi.fn(), + }, + { + info: new SlashCommandBuilder() + .setName('test4') + .setDescription(' ') + .setNameLocalizations({}) + .setDescriptionLocalizations({}) + .addStringOption(option => option.setName('c').setDescription(' ')), + requiresGuild: true, + execute: vi.fn(), + }, + { + info: new SlashCommandBuilder() + .setName('test5') + .setDescription(' ') + .setNameLocalizations({}) + .setDescriptionLocalizations({}) + .setDefaultMemberPermissions(null) + .addStringOption(option => option.setName('c').setDescription(' ')), + requiresGuild: true, + execute: vi.fn(), + }, + { + info: new SlashCommandBuilder() + .setName('test6') + .setDescription(' ') + .setNameLocalizations({}) + .setDescriptionLocalizations({}) + .setContexts(InteractionContextType.Guild) + .addStringOption(option => option.setName('c').setDescription(' ')), + requiresGuild: true, + execute: vi.fn(), + }, + { + info: new ContextMenuCommandBuilder().setName('test6'), + type: ApplicationCommandType.Message, + requiresGuild: false, + execute: vi.fn(), + }, + ]; + for (const cmd of mockCommands) { + mockAllCommands.set(cmd.info.name, cmd); + } + + const mockApplicationCommandsFetch = vi + .fn['application']['commands']['fetch']>() + .mockResolvedValue(new Collection()); + + const mockApplicationCommandsSet = vi + .fn['application']['commands']['set']>() + .mockResolvedValue(new Collection()); + + const mockFetchOauthGuilds = vi.fn().mockResolvedValue( + new Collection().set('test-guild1', { + fetch: (): Promise => + Promise.resolve({ + id: 'test-guild1', + commands: { + fetch: mockGuildCommandsFetch, + set: mockGuildCommandsSet, + }, + } as unknown as Guild), + } as OAuth2Guild) + ); + + const mockGuildCommandsFetch = vi + .fn() + .mockResolvedValue(new Collection()); + + const mockGuildCommandsSet = vi + .fn() + .mockResolvedValue(new Collection()); + + const mockClient = { + application: { + commands: { + fetch: mockApplicationCommandsFetch, + set: mockApplicationCommandsSet, + }, + }, + guilds: { + fetch: mockFetchOauthGuilds, + }, + } as unknown as Client; + + test('should not register if commands are already in sync', async () => { + vi.mocked(areCommandsRegistered).mockReturnValueOnce(true); + await registerCommands(mockClient); + expect(mockApplicationCommandsSet).not.toHaveBeenCalled(); + expect(mockGuildCommandsSet).not.toHaveBeenCalled(); + }); + + test('continues registration if global commands fail to register', async () => { + vi.mocked(areCommandsRegistered).mockReturnValueOnce(false).mockReturnValueOnce(true); + mockApplicationCommandsSet.mockRejectedValueOnce(new Error('This is a test')); + await registerCommands(mockClient); + expect(mockApplicationCommandsSet).toHaveBeenCalledOnce(); + expect(mockGuildCommandsSet).toHaveBeenCalledOnce(); + }); + + test('continues registration if guild-bound commands fail to register', async () => { + vi.mocked(areCommandsRegistered).mockReturnValueOnce(false).mockReturnValueOnce(true); + mockGuildCommandsSet.mockRejectedValueOnce(new Error('This is a test')); + await registerCommands(mockClient); + expect(mockApplicationCommandsSet).toHaveBeenCalledOnce(); + expect(mockGuildCommandsSet).toHaveBeenCalledOnce(); + }); + + test('should log an error if commands are still not registered after registration', async () => { + vi.mocked(areCommandsRegistered).mockReturnValueOnce(false).mockReturnValueOnce(false); + await registerCommands(mockClient); + expect(mockApplicationCommandsSet).toHaveBeenCalledOnce(); + expect(mockGuildCommandsSet).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/helpers/actions/registerCommands.ts b/src/helpers/actions/registerCommands.ts new file mode 100644 index 00000000..29574d9c --- /dev/null +++ b/src/helpers/actions/registerCommands.ts @@ -0,0 +1,150 @@ +import type { + ApplicationCommand, + Client, + Guild, + RESTPostAPIApplicationCommandsJSONBody, +} from 'discord.js'; +import { ApplicationCommandType } from 'discord.js'; + +import { allCommands } from '../../commands/index.ts'; +import { debug, error, info } from '../../logger.ts'; +import { areCommandsRegistered } from './areCommandsRegistered.ts'; + +/** + * Fetches current commands from the client application, checks if they match + * the commands stored locally, and re-registers commands if necessary + * + * @param client The current logged-in client + */ +export async function registerCommands(client: Client): Promise { + // Pre-fetch guilds to avoid repeating work + const oAuthGuilds = await client.guilds.fetch(); + const guilds = await Promise.all(oAuthGuilds.map(g => g.fetch())); + + const actualCommands = await getActualCommands(client, guilds); + const expectedCommands = getExpectedCommands(); + + // Skip registration if the commands are already registered + if (areCommandsRegistered(actualCommands, expectedCommands)) { + return; + } + + const totalCommands = expectedCommands.global.length + expectedCommands.guilded.length; + info(`Registering ${totalCommands} commands...`); + + await registerGlobalCommands(expectedCommands.global, client); + await registerGuildedCommands(expectedCommands.guilded, guilds); + + // Sanity check + const newActualCommands = await getActualCommands(client, guilds); + if (!areCommandsRegistered(newActualCommands, expectedCommands)) { + error('Command registration did not succeed. Please restart.'); + } + + info( + `All ${totalCommands} command(s) prepared. Discord may take some time to sync commands to clients.` + ); +} + +async function getActualCommands( + client: Client, + guilds: ReadonlyArray +): Promise<{ + global: Array; + guilded: Map>; +}> { + const collection = await client.application.commands.fetch(); + const global = collection.values().toArray(); + + const guilded = new Map( + await Promise.all( + guilds.map(guild => + (async function (): Promise<[Guild, Array]> { + const guildCollection = await guild.commands.fetch(); + const commands = guildCollection.values().toArray(); + return [guild, commands] as const; + })() + ) + ) + ); + + return { global, guilded }; +} + +function getExpectedCommands(): { + global: Array; + guilded: Array; +} { + const commands = Array.from(allCommands.values()); + const global = commands.filter(c => !c.requiresGuild); + const guilded = commands.filter(c => c.requiresGuild); + return { global, guilded }; +} + +async function registerGlobalCommands( + commands: Array, + client: Client +): Promise { + const commandBuilders = commands.map(registerableCommand); + info( + `${commands.length} command(s) will be set globally: ${JSON.stringify( + commandBuilders.map(cmd => cmd.name) + )}` + ); + debug(`Registering all ${commands.length} global command(s)...`); + try { + await client.application.commands.set(commandBuilders); + info(`Registered ${commands.length} global command(s).`); + } catch (error_) { + error('Failed to register global commands:', error_); + } +} + +async function registerGuildedCommands( + commands: Array, + guilds: Array +): Promise { + const commandBuilders = commands.map(registerableCommand); + info( + `${commands.length} command(s) require a guild: ${JSON.stringify( + commandBuilders.map(cmd => cmd.name) + )}` + ); + await Promise.all(guilds.map(guild => registerGuildedCommandsToGuild(commands, guild))); +} + +async function registerGuildedCommandsToGuild( + commands: ReadonlyArray, + guild: Guild +): Promise { + const commandBuilders = commands.map(registerableCommand); + info( + `Registering ${commands.length} guild-bound command(s): ${JSON.stringify( + commandBuilders.map(cmd => cmd.name) + )}` + ); + try { + const result = await guild.commands.set(commandBuilders); + info(`Registered ${result.size} command(s) on guild ${guild.id}`); + } catch (error_) { + error(`Failed to register commands on guild ${guild.id}:`, error_); + } +} + +/** + * Creates a registerable JSON payload from the given command. + */ +function registerableCommand(command: Command): RESTPostAPIApplicationCommandsJSONBody { + if (isContextMenuCommand(command)) { + return command.info.setType(command.type).toJSON(); + } + + // Slash commands are simpler: + return command.info.toJSON(); +} + +function isContextMenuCommand(command: Command): command is ContextMenuCommand { + return ( + command.type === ApplicationCommandType.Message || command.type === ApplicationCommandType.User + ); +} diff --git a/src/helpers/actions/revokeCommands.test.ts b/src/helpers/actions/revokeCommands.test.ts deleted file mode 100644 index 18aa58a4..00000000 --- a/src/helpers/actions/revokeCommands.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import type { Client, Guild, OAuth2Guild } from 'discord.js'; -import { Collection } from 'discord.js'; - -// Mock the logger so nothing is printed -vi.mock('../../logger.ts'); - -import { revokeCommands } from './revokeCommands.ts'; - -describe('Command revocations', () => { - const mockApplicationCommandsSet = vi.fn['commands']['set']>(); - const mockGuildCommandsSet = vi.fn(); - const mockFetchOauthGuilds = vi.fn(); - - const mockClient = { - application: { - commands: { - set: mockApplicationCommandsSet, - }, - }, - guilds: { - fetch: mockFetchOauthGuilds, - }, - } as unknown as Client; - - beforeEach(() => { - mockGuildCommandsSet.mockImplementation(() => Promise.resolve(new Collection())); - mockFetchOauthGuilds.mockResolvedValue( - new Collection() - .set('test-guild1', { - fetch: () => - Promise.resolve({ - id: 'test-guild1', - commands: { - set: mockGuildCommandsSet, - }, - } as unknown as Guild), - } as OAuth2Guild) - .set('test-guild2', { - fetch: () => - Promise.resolve({ - id: 'test-guild2', - commands: { - set: mockGuildCommandsSet, - }, - } as unknown as Guild), - } as OAuth2Guild) - ); - }); - - test('clears global commands', async () => { - await revokeCommands(mockClient); - expect(mockApplicationCommandsSet).toHaveBeenCalledOnce(); - }); - - test('clears commands for each guild', async () => { - await revokeCommands(mockClient); - expect(mockGuildCommandsSet).toHaveBeenCalledTimes(2); - }); -}); diff --git a/src/helpers/actions/revokeCommands.ts b/src/helpers/actions/revokeCommands.ts deleted file mode 100644 index 5b13e4ec..00000000 --- a/src/helpers/actions/revokeCommands.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Client } from 'discord.js'; - -import { info } from '../../logger.ts'; - -/** - * Unregisters all command interactions globally and in each guild for this account. - */ -export async function revokeCommands(client: Client): Promise { - info('Revoking global commands...'); - await client.application.commands.set([]); - info('Revoked global commands'); - - const oAuthGuilds = await client.guilds.fetch(); - const guilds = await Promise.all(oAuthGuilds.map(g => g.fetch())); - - info(`Revoking commands in ${guilds.length} guild(s)...`); - for (const guild of guilds) { - await guild.commands.set([]); - info(`Revoked commands in guild ${guild.id}`); - } -} diff --git a/src/helpers/actions/verifyCommandDeployments.test.ts b/src/helpers/actions/verifyCommandDeployments.test.ts deleted file mode 100644 index 46f71000..00000000 --- a/src/helpers/actions/verifyCommandDeployments.test.ts +++ /dev/null @@ -1,166 +0,0 @@ -import { beforeEach, describe, expect, test, vi } from 'vitest'; - -import type { Client, Guild } from 'discord.js'; -import { Collection, SlashCommandBuilder } from 'discord.js'; - -const mockAllCommands = vi.hoisted(() => new Map()); -vi.mock('../../commands', () => ({ allCommands: mockAllCommands })); - -// Mock the logger to track output -vi.mock('../../logger.ts'); -import { warn as mockLoggerWarn } from '../../logger.ts'; - -import { deployableCommand } from './deployCommands.ts'; -import { verifyCommandDeployments } from './verifyCommandDeployments.ts'; - -describe('Verify command deployments', () => { - const commands: Array = [ - // Global Commands - { - info: new SlashCommandBuilder().setName('zaphod').setDescription(' '), - requiresGuild: false, - execute: vi.fn(), - }, - { - info: new SlashCommandBuilder().setName('beeblebrox').setDescription(' '), - requiresGuild: false, - execute: vi.fn(), - }, - - // Guild-bound Commands - { - info: new SlashCommandBuilder().setName('arthur').setDescription(' '), - requiresGuild: true, - execute: vi.fn(), - }, - { - info: new SlashCommandBuilder().setName('dent').setDescription(' '), - requiresGuild: true, - execute: vi.fn(), - }, - ]; - - const mockFetchApplicationCommands = - vi.fn['commands']['fetch']>(); - const mockFetchGuildCommands = vi.fn(); - - const mockClient = { - application: { - commands: { - fetch: mockFetchApplicationCommands, - }, - }, - guilds: { - fetch: () => - Promise.resolve( - new Collection([ - [ - 'guild1', - { - fetch: (): unknown => - Promise.resolve({ - id: 'guild1', - commands: { - fetch: mockFetchGuildCommands, - }, - }), - }, - ], - ]) - ), - }, - } as unknown as Client; - - beforeEach(() => { - mockAllCommands.clear(); - const deployedGlobal = new Collection(); - const deployedGuild = new Collection(); - mockFetchApplicationCommands.mockImplementation(() => - // @ts-expect-error Really complicated errors with discord.js types that I don't want to deal with - Promise.resolve(deployedGlobal.map(deployableCommand)) - ); - mockFetchGuildCommands.mockImplementation(() => - // @ts-expect-error Really complicated errors with discord.js types that I don't want to deal with - Promise.resolve(deployedGuild.map(deployableCommand)) - ); - - for (const cmd of commands) { - mockAllCommands.set(cmd.info.name, cmd); - if (cmd.requiresGuild) { - deployedGuild.set(cmd.info.name, cmd); - } else { - deployedGlobal.set(cmd.info.name, cmd); - } - } - }); - - describe('Guild commands', () => { - test('does nothing if the actual commands match expectations', async () => { - await verifyCommandDeployments(mockClient); - expect(mockFetchGuildCommands).toHaveBeenCalledOnce(); - expect(mockLoggerWarn).not.toHaveBeenCalled(); - }); - - test('logs a warning if the number of commands differs', async () => { - mockAllCommands.delete('arthur'); - - await verifyCommandDeployments(mockClient); - expect(mockFetchGuildCommands).toHaveBeenCalledOnce(); - expect(mockLoggerWarn).toHaveBeenCalledWith( - expect.stringContaining("commands in guild 'guild1' differ") - ); - expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining('Expected 1')); - }); - - test('logs a warning if the command lists differ', async () => { - mockAllCommands.delete('arthur'); - mockAllCommands.set('ford', { - info: new SlashCommandBuilder().setName('ford').setDescription(' '), - requiresGuild: true, - execute: vi.fn(), - }); - - await verifyCommandDeployments(mockClient); - expect(mockFetchGuildCommands).toHaveBeenCalledOnce(); - expect(mockLoggerWarn).toHaveBeenCalledWith( - expect.stringContaining("commands in guild 'guild1' differ") - ); - expect(mockLoggerWarn).toHaveBeenCalledWith( - expect.stringContaining("Expected a command named 'dent'") - ); - }); - }); - - describe('Global commands', () => { - test('does nothing if the actual commands match expectations', async () => { - await verifyCommandDeployments(mockClient); - expect(mockFetchApplicationCommands).toHaveBeenCalledOnce(); - expect(mockLoggerWarn).not.toHaveBeenCalled(); - }); - - test('logs a warning if the number of commands differs', async () => { - mockAllCommands.delete('zaphod'); - - await verifyCommandDeployments(mockClient); - expect(mockFetchApplicationCommands).toHaveBeenCalledOnce(); - expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining('commands differ')); - expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining('Expected 1')); - }); - - test('logs a warning if the command lists differ', async () => { - mockAllCommands.delete('zaphod'); - mockAllCommands.set('marvin', { - info: new SlashCommandBuilder().setName('marvin').setDescription(' '), - requiresGuild: false, - execute: vi.fn(), - }); - - await verifyCommandDeployments(mockClient); - expect(mockFetchApplicationCommands).toHaveBeenCalledOnce(); - expect(mockLoggerWarn).toHaveBeenCalledWith(expect.stringContaining('commands differ')); - expect(mockLoggerWarn).toHaveBeenCalledWith( - expect.stringContaining("Expected a command named 'marvin'") - ); - }); - }); -}); diff --git a/src/helpers/actions/verifyCommandDeployments.ts b/src/helpers/actions/verifyCommandDeployments.ts deleted file mode 100644 index 3780f72e..00000000 --- a/src/helpers/actions/verifyCommandDeployments.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { Client, Guild } from 'discord.js'; - -import { allCommands } from '../../commands/index.ts'; -import { warn } from '../../logger.ts'; - -/** - * Verify that the deployed command list is up-to-date, and yell in the console if it's not. - * - * @param client The Discord.js client whose commands to validate. - * @param logger The place to send error messages - */ -export async function verifyCommandDeployments(client: Client): Promise { - const globalDiff = await diffGlobalCommandDeployments(client); - if (globalDiff) { - const issue = globalDiff.issue; - const expected = globalDiff.expected; - const actual = globalDiff.actual; - switch (issue) { - case 'content': { - warn( - `The deployed commands differ from the expected command list: Expected a command named '${expected}', but found '${actual}'. Please redeploy.` - ); - break; - } - case 'length': { - warn( - `The deployed commands differ from the expected command list: Expected ${expected} global command(s), but Discord returned ${actual}. Please redeploy.` - ); - break; - } - default: { - /* istanbul ignore next */ - assertUnreachable(issue); - } - } - } - - const guildedDiff = await diffGuildCommandDeployments(client); - if (guildedDiff) { - const issue = guildedDiff.issue; - const expected = guildedDiff.expected; - const actual = guildedDiff.actual; - const guildId = guildedDiff.guild.id; - switch (issue) { - case 'content': { - warn( - `The deployed commands in guild '${guildId}' differ from the expected command list: Expected a command named '${expected}', but found '${actual}'. Please redeploy.` - ); - break; - } - case 'length': { - warn( - `The deployed commands in guild '${guildId}' differ from the expected command list: Expected ${expected} command(s), but Discord returned ${actual}. Please redeploy.` - ); - break; - } - default: { - /* istanbul ignore next */ - assertUnreachable(issue); - } - } - } -} - -async function diffGuildCommandDeployments( - client: Client -): Promise<(Diff & { guild: Guild }) | null> { - const oAuthGuilds = await client.guilds.fetch(); - const guilds = await Promise.all(oAuthGuilds.map(g => g.fetch())); - - const expectedCommandNames = Array.from(allCommands.values()) - .filter(c => c.requiresGuild) - .map(c => c.info.name) - .toSorted(sortAlphabetically); - - for (const guild of guilds) { - const commands = await guild.commands.fetch(); - const actualCommandNames = commands.map(c => c.name).toSorted(sortAlphabetically); - - const diff = diffArrays(expectedCommandNames, actualCommandNames); - if (diff) return { ...diff, guild }; - } - - return null; // all clear! -} - -async function diffGlobalCommandDeployments(client: Client): Promise { - const expectedCommandNames = Array.from(allCommands.values()) - .filter(c => !c.requiresGuild) - .map(c => c.info.name) - .toSorted(sortAlphabetically); - - const commands = await client.application.commands.fetch(); - const actualCommandNames = commands.map(c => c.name).toSorted(sortAlphabetically); - - return diffArrays(expectedCommandNames, actualCommandNames); -} - -// MARK: - Difference between arrays - -interface Diff { - readonly issue: 'length' | 'content'; - readonly expected: string | number; - readonly actual: string | number; -} - -function diffArrays(expected: ReadonlyArray, actual: ReadonlyArray): Diff | null { - if (actual.length !== expected.length) { - return { - issue: 'length', - expected: expected.length, - actual: actual.length, - }; - } - - for (const [idx, element] of actual.entries()) { - const deployedName = element; - const expectedName = expected[idx] ?? ''; - if (deployedName !== expectedName) { - return { - issue: 'content', - expected: expectedName, - actual: deployedName, - }; - } - } - - return null; // all clear! -} - -function sortAlphabetically(a: string, b: string): number { - return a.localeCompare(b); -} - -/* istanbul ignore next */ -function assertUnreachable(value: never): never { - throw new EvalError(`Unreachable case: ${JSON.stringify(value)}`); -} diff --git a/src/helpers/parseArgs.test.ts b/src/helpers/parseArgs.test.ts deleted file mode 100644 index 7b50bc15..00000000 --- a/src/helpers/parseArgs.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { parseArgs } from './parseArgs.ts'; - -describe('Args parser', () => { - test('defaults both flags to `false`', () => { - expect(parseArgs()).toMatchObject({ - deploy: false, - revoke: false, - }); - }); -}); diff --git a/src/helpers/parseArgs.ts b/src/helpers/parseArgs.ts deleted file mode 100644 index a4fa623a..00000000 --- a/src/helpers/parseArgs.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { parseArgs as _parseArgs } from 'node:util'; - -const { values } = _parseArgs({ - options: { - // Upload Discord commands, then exit - deploy: { short: 'c', type: 'boolean', default: false }, - - // Revoke Discord commands, then exit - revoke: { short: 'C', type: 'boolean', default: false }, - }, - strict: true, -}); - -export type Args = typeof values; - -/** - * Returns the command-line arguments, or their default values if none were set. - */ -export function parseArgs(): Args { - return values; -} diff --git a/vitest.config.ts b/vitest.config.ts index 9287de81..e30ad1e4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { - mockReset: true, + clearMocks: true, // Can be removed in vitest v5 typecheck: { checker: 'tsc', tsconfig: './tsconfig.json',