From 780e20d4195a1dbd011931ee49d762a6301e797d Mon Sep 17 00:00:00 2001 From: Marius Begby Date: Mon, 28 Aug 2023 17:21:52 +0200 Subject: [PATCH] feat!: Extracted handling of interactions into separate files --- package.json | 4 +- src/events/interactions/interactionCreate.ts | 144 +++--------------- .../interactionAutocompleteHandler.ts | 16 ++ src/handlers/interactionCommandHandler.ts | 32 ++++ src/handlers/interactionComponentHandler.ts | 24 +++ src/handlers/interactionErrorHandler.ts | 69 +++++++++ src/interactions/commands/player/play.ts | 30 +--- 7 files changed, 166 insertions(+), 153 deletions(-) create mode 100644 src/handlers/interactionAutocompleteHandler.ts create mode 100644 src/handlers/interactionCommandHandler.ts create mode 100644 src/handlers/interactionComponentHandler.ts create mode 100644 src/handlers/interactionErrorHandler.ts diff --git a/package.json b/package.json index 98773e57..d34fa4e0 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,10 @@ "scripts": { "test": "echo \"Error: no test specified\" && exit 1", "start": "node ./dist/index.js", - "start-pretty": "node ./dist/index.js | pino-pretty --ignore environment,source,module,action,name,context,executionId,shardId,guildId", + "start-pretty": "node ./dist/index.js | pino-pretty --ignore environment,source,module,action,name,context,executionId,executionTime,shardId,guildId,interactionType", "start-pretty-no-ignore": "node ./dist/index.js | pino-pretty", "deploy": "node ./dist/utils/deploySlashCommands.js", - "deploy-pretty": "node ./dist/utils/deploySlashCommands.js | pino-pretty --ignore environment,source,module,action,name,context,executionId,shardId,guildId", + "deploy-pretty": "node ./dist/utils/deploySlashCommands.js | pino-pretty --ignore environment,source,module,action,name,context,executionId,executionTime,shardId,guildId,interactionType", "eslint": "eslint ./src", "build": "tsc" }, diff --git a/src/events/interactions/interactionCreate.ts b/src/events/interactions/interactionCreate.ts index f4755bc2..f141997c 100644 --- a/src/events/interactions/interactionCreate.ts +++ b/src/events/interactions/interactionCreate.ts @@ -1,8 +1,6 @@ -import config from 'config'; import { AutocompleteInteraction, ChatInputCommandInteraction, - EmbedBuilder, Events, Interaction, InteractionType, @@ -10,14 +8,14 @@ import { } from 'discord.js'; import { v4 as uuidv4 } from 'uuid'; +import { handleAutocomplete } from '../../handlers/interactionAutocompleteHandler'; +import { handleCommand } from '../../handlers/interactionCommandHandler'; +import { handleComponent } from '../../handlers/interactionComponentHandler'; +import { handleError } from '../../handlers/interactionErrorHandler'; import loggerModule from '../../services/logger'; -import { Command, ExtendedClient } from '../../types/clientTypes'; -import { BotOptions, EmbedOptions } from '../../types/configTypes'; -import { cannotSendMessageInChannel } from '../../utils/validation/permissionValidator'; +import { ExtendedClient } from '../../types/clientTypes'; import { CustomError } from '../../types/interactionTypes'; -const embedOptions: EmbedOptions = config.get('embedOptions'); -const botOptions: BotOptions = config.get('botOptions'); module.exports = { name: Events.InteractionCreate, isDebug: false, @@ -46,131 +44,19 @@ module.exports = { interactionIdentifier = (interaction as MessageComponentInteraction).customId; } - // Todo: Extract to own file - const handleComponent = async (interaction: MessageComponentInteraction) => { - await interaction.deferReply(); - logger.debug('Interaction deferred.'); - - const componentId = interaction.customId.split('_')[0]; - const referenceId = interaction.customId.split('_')[1]; - - logger.debug(`Parsed componentId: ${componentId}`); - - const componentModule = await import(`../../interactions/components/${componentId}.js`); - const { default: component } = componentModule; - - logger.debug('Executing component interaction.'); - await component.execute({ interaction, referenceId, executionId }); - }; - - const handleAutocomplete = async (interaction: AutocompleteInteraction) => { - const autocompleteModule = await import(`../../interactions/autocomplete/${interaction.commandName}.js`); - const { default: autocomplete } = autocompleteModule; - - logger.debug('Executing autocomplete interaction.'); - await autocomplete.execute({ interaction, executionId }); - }; - - const handleCommand = async (interaction: ChatInputCommandInteraction) => { - await interaction.deferReply(); - logger.debug('Interaction deferred.'); - - const command = client.commands?.get(interaction.commandName) as Command; - if (!command) { - logger.warn(`Interaction created but command '${interaction.commandName}' was not found.`); - return; - } - - logger.debug(`Chat input command interaction created for '${interaction.commandName}'.`); - - if (await cannotSendMessageInChannel({ interaction, executionId })) { - return; - } - - logger.debug('Executing command interaction.'); - await command.execute({ interaction, client, executionId }); - }; - - // TODO: Extract the text to a config file? - const errorReply = { - embeds: [ - new EmbedBuilder() - .setDescription( - `**${embedOptions.icons.error} Uh-oh... _Something_ went wrong!**\nThere was an unexpected error while trying to perform this action. You can try again.\n\n_If this problem persists, please submit a bug report in the **[support server](${botOptions.serverInviteUrl})**._` - ) - .setColor(embedOptions.colors.error) - .setFooter({ text: `Execution ID: ${executionId}` }) - ] - }; - - // TODO: extract handlError to own file (create errorHandler and export handleInteractionError?) - const handleError = async (interaction: Interaction, error: CustomError) => { - logger.error(error, `Error handling interaction '${interactionIdentifier}'`); - - if (interaction instanceof ChatInputCommandInteraction && interaction.deferred) { - switch (interaction.replied) { - case true: - // This means interaction has received a reply already. - // Most likely command executed successfully or error is already handled. - logger.warn( - error, - `Interaction '${interaction.id}' threw an error but has already been replied to.` - ); - return; - case false: - // If the interaction has not been replied to, most likely command execution failed. - logger.debug('Responding with error embed'); - return await interaction.editReply(errorReply); - } - } else if (interaction instanceof MessageComponentInteraction && interaction.deferred) { - switch (interaction.replied) { - case true: - // This means interaction has received a reply already. - // Most likely command executed successfully or error is already handled. - logger.warn( - error, - `Interaction '${interaction.id}' threw an error but has already been replied to.` - ); - return; - case false: - // If the interaction has not been replied to, most likely command execution failed. - logger.debug('Responding with error embed'); - return await interaction.editReply(errorReply); - } - } else { - logger.warn( - 'Interaction threw an error but was not deferred or replied to, or was an autocomplete interaction. Cannot send error reply.' - ); - - if ( - error.code === 'InteractionCollectorError' || - error.message === 'Collector received no interactions before ending with reason: time' - ) { - logger.debug('Interaction response timed out.'); - return; - } - - // If this code is reached there is an error that is not handled at all. - // This should not happen, but if it does, we log it with 'fatal' level to investigate. - // This is to prevent the bot from crashing or doing something very unexpected. - logger.fatal(error, 'Unhandled error while awaiting or handling component interaction.'); - return; - } - }; - try { logger.debug('Started handling interaction.'); - switch (interaction.type as InteractionType) { + switch (interaction.type) { case InteractionType.ApplicationCommand: - await handleCommand(interaction as ChatInputCommandInteraction); + await handleCommand(interaction as ChatInputCommandInteraction, client, executionId); break; case InteractionType.ApplicationCommandAutocomplete: - await handleAutocomplete(interaction as AutocompleteInteraction); + await handleAutocomplete(interaction as AutocompleteInteraction, executionId); break; case InteractionType.MessageComponent: - await handleComponent(interaction as MessageComponentInteraction); + await handleComponent(interaction as MessageComponentInteraction, executionId); break; default: @@ -182,21 +68,25 @@ module.exports = { } } catch (error) { logger.debug('Error while handling received interaction.'); - await handleError(interaction, error as CustomError); + await handleError(interaction, error as CustomError, interactionIdentifier, executionId); } const outputTime: number = new Date().getTime(); const executionTime: number = outputTime - inputTime; + const interactionType = InteractionType[interaction.type]; logger.info( { - executionTime: executionTime + executionTime: executionTime, + interactionType: interactionType }, - `Interaction '${interactionIdentifier}' successfully handled in ${executionTime} ms.` + `${interactionType} interaction '${interactionIdentifier}' successfully handled in ${executionTime} ms.` ); if (executionTime > 10000) { - logger.warn(`Interaction '${interactionIdentifier}' took ${executionTime} ms to execute.`); + logger.warn( + `${interactionType} interaction '${interactionIdentifier}' took ${executionTime} ms to execute.` + ); } return; diff --git a/src/handlers/interactionAutocompleteHandler.ts b/src/handlers/interactionAutocompleteHandler.ts new file mode 100644 index 00000000..1d70551b --- /dev/null +++ b/src/handlers/interactionAutocompleteHandler.ts @@ -0,0 +1,16 @@ +import { AutocompleteInteraction } from 'discord.js'; +import loggerModule from '../services/logger'; + +const logger = loggerModule.child({ + source: 'interactionCutocompleteHandler.ts', + module: 'handler', + name: 'interactionAutocompleteHandler' +}); + +export const handleAutocomplete = async (interaction: AutocompleteInteraction, executionId: string) => { + const autocompleteModule = await import(`../interactions/autocomplete/${interaction.commandName}.js`); + const { default: autocomplete } = autocompleteModule; + + logger.debug('Executing autocomplete interaction.'); + await autocomplete.execute({ interaction, executionId }); +}; diff --git a/src/handlers/interactionCommandHandler.ts b/src/handlers/interactionCommandHandler.ts new file mode 100644 index 00000000..e345f7a5 --- /dev/null +++ b/src/handlers/interactionCommandHandler.ts @@ -0,0 +1,32 @@ +import { ChatInputCommandInteraction } from 'discord.js'; +import { Command, ExtendedClient } from '../types/clientTypes'; +import { cannotSendMessageInChannel } from '../utils/validation/permissionValidator'; +import loggerModule from '../services/logger'; + +const logger = loggerModule.child({ + source: 'interactionCommandHandler.ts', + module: 'handler', + name: 'interactionCommandHandler' +}); + +export const handleCommand = async ( + interaction: ChatInputCommandInteraction, + client: ExtendedClient, + executionId: string +) => { + await interaction.deferReply(); + logger.debug('Interaction deferred.'); + + const command = client.commands?.get(interaction.commandName) as Command; + if (!command) { + logger.warn(`Interaction created but command '${interaction.commandName}' was not found.`); + return; + } + + if (await cannotSendMessageInChannel({ interaction, executionId })) { + return Promise.resolve(); + } + + logger.debug('Executing command interaction.'); + await command.execute({ interaction, client, executionId }); +}; diff --git a/src/handlers/interactionComponentHandler.ts b/src/handlers/interactionComponentHandler.ts new file mode 100644 index 00000000..8a117115 --- /dev/null +++ b/src/handlers/interactionComponentHandler.ts @@ -0,0 +1,24 @@ +import { MessageComponentInteraction } from 'discord.js'; +import loggerModule from '../services/logger'; + +const logger = loggerModule.child({ + source: 'interactionComponentHandler.js', + module: 'handler', + name: 'interactionComponentHandler' +}); + +export const handleComponent = async (interaction: MessageComponentInteraction, executionId: string) => { + await interaction.deferReply(); + logger.debug('Interaction deferred.'); + + const componentId = interaction.customId.split('_')[0]; + const referenceId = interaction.customId.split('_')[1]; + + logger.debug(`Parsed componentId: ${componentId}`); + + const componentModule = await import(`../interactions/components/${componentId}.js`); + const { default: component } = componentModule; + + logger.debug('Executing component interaction.'); + await component.execute({ interaction, referenceId, executionId }); +}; diff --git a/src/handlers/interactionErrorHandler.ts b/src/handlers/interactionErrorHandler.ts new file mode 100644 index 00000000..b7dd3447 --- /dev/null +++ b/src/handlers/interactionErrorHandler.ts @@ -0,0 +1,69 @@ +import config from 'config'; +import { ChatInputCommandInteraction, EmbedBuilder, Interaction, MessageComponentInteraction } from 'discord.js'; +import { BotOptions, EmbedOptions } from '../types/configTypes'; +import loggerModule from '../services/logger'; +import { CustomError } from '../types/interactionTypes'; + +const embedOptions: EmbedOptions = config.get('embedOptions'); +const botOptions: BotOptions = config.get('botOptions'); + +const logger = loggerModule.child({ + source: 'interactionErrorHandler.ts', + module: 'handler', + name: 'interactionErrorHandler' +}); + +export const handleError = async ( + interaction: Interaction, + error: CustomError, + interactionIdentifier: string, + executionId: string +) => { + const errorReply = { + embeds: [ + new EmbedBuilder() + .setDescription( + `**${embedOptions.icons.error} Uh-oh... _Something_ went wrong!**\nThere was an unexpected error while trying to perform this action. You can try again.\n\n_If this problem persists, please submit a bug report in the **[support server](${botOptions.serverInviteUrl})**._` + ) + .setColor(embedOptions.colors.error) + .setFooter({ text: `Execution ID: ${executionId}` }) + ] + }; + + logger.error(error, `Error handling interaction '${interactionIdentifier}'`); + + if (interaction instanceof ChatInputCommandInteraction && interaction.deferred) { + switch (interaction.replied) { + case true: + logger.warn(error, `Interaction '${interaction.id}' threw an error but has already been replied to.`); + return; + case false: + logger.debug('Responding with error embed'); + return await interaction.editReply(errorReply); + } + } else if (interaction instanceof MessageComponentInteraction && interaction.deferred) { + switch (interaction.replied) { + case true: + logger.warn(error, `Interaction '${interaction.id}' threw an error but has already been replied to.`); + return; + case false: + logger.debug('Responding with error embed'); + return await interaction.editReply(errorReply); + } + } else { + logger.warn( + 'Interaction threw an error but was not deferred or replied to, or was an autocomplete interaction. Cannot send error reply.' + ); + + if ( + error.code === 'InteractionCollectorError' || + error.message === 'Collector received no interactions before ending with reason: time' + ) { + logger.debug('Interaction response timed out.'); + return; + } + + logger.fatal(error, 'Unhandled error while awaiting or handling component interaction.'); + return; + } +}; diff --git a/src/interactions/commands/player/play.ts b/src/interactions/commands/player/play.ts index 87a7458f..bfcdb482 100644 --- a/src/interactions/commands/player/play.ts +++ b/src/interactions/commands/player/play.ts @@ -3,7 +3,7 @@ import { useMainPlayer, useQueue } from 'discord-player'; import { EmbedBuilder, GuildMember, SlashCommandBuilder } from 'discord.js'; import loggerModule from '../../../services/logger'; -import { CustomSlashCommandInteraction } from '../../../types/interactionTypes'; +import { CustomError, CustomSlashCommandInteraction } from '../../../types/interactionTypes'; import { BotOptions, EmbedOptions, PlayerOptions } from '../../../types/configTypes'; import { cannotJoinVoiceOrTalk } from '../../../utils/validation/permissionValidator'; import { transformQuery } from '../../../utils/validation/searchQueryValidator'; @@ -132,7 +132,7 @@ const command: CustomSlashCommandInteraction = { } })); } catch (error) { - if (error instanceof Error) { + if (error instanceof CustomError) { if (error.message.includes('Sign in to confirm your age')) { logger.debug('Found track but failed to retrieve audio due to age confirmation warning.'); @@ -166,12 +166,14 @@ const command: CustomSlashCommandInteraction = { } if ( - error.message.includes("Cannot read properties of null (reading 'createStream')") || + error.message === "Cannot read properties of null (reading 'createStream')" || error.message.includes('Failed to fetch resources for ytdl streaming') || error.message.includes('Could not extract stream for this track') ) { logger.debug(error, `Found track but failed to retrieve audio. Query: ${query}.`); + // note: reading 'createStream' error can happen if queue is destroyed before track starts playing, e.g. /leave quickly after /play + logger.debug('Responding with error embed.'); return await interaction.editReply({ embeds: [ @@ -201,26 +203,6 @@ const command: CustomSlashCommandInteraction = { }); } - if (error.message === "Cannot read properties of null (reading 'createStream')") { - // Can happen if /play then /leave before track starts playing - logger.warn( - error, - 'Found track but failed to play back audio. Voice connection might be unavailable.' - ); - - logger.debug('Responding with error embed.'); - return await interaction.editReply({ - embeds: [ - new EmbedBuilder() - .setDescription( - `**${embedOptions.icons.error} Uh-oh... Failed to add track!**\nSomething unexpected happened and it was not possible to start playing the track. This could happen if the voice connection is lost or queue is destroyed while adding the track.\n\nYou can try to perform the command again.\n\n_If you think this message is incorrect, please submit a bug report in the **[support server](${botOptions.serverInviteUrl})**._` - ) - .setColor(embedOptions.colors.error) - .setFooter({ text: `Execution ID: ${executionId}` }) - ] - }); - } - logger.error(error, 'Failed to play track with player.play(), unhandled error.'); } else { throw error; @@ -299,7 +281,7 @@ const command: CustomSlashCommandInteraction = { }); } - if (queue.currentTrack === track && queue.tracks.data.length === 0) { + if (queue && queue.currentTrack === track && queue.tracks.data.length === 0) { logger.debug(`Track found and added with player.play(), started playing. Query: '${query}'.`); let authorName: string;