From ee2b5a9cc121a75a5ebbd2d5c24360d3efcf04d3 Mon Sep 17 00:00:00 2001 From: Pkmmte Xeleon Date: Sat, 29 Apr 2023 23:51:11 -0700 Subject: [PATCH] feat: automatic promise rejection handling Robo.js will now automatically prevent unhandled promise rejections from crashing the process when: - Running on production - Running on dev with debug channel You must set a debug channel for crashes to be prevented. I understand why Node behaves this way and I agree in that developers should not merely ignore such errors. That said, sometimes these errors are out of the developers' control and thus this is relevant. --- .changeset/thin-peaches-impress.md | 5 + packages/discord/src/core/env.ts | 1 + packages/discord/src/core/handlers.ts | 275 +++++++++++++++++--------- packages/discord/src/core/robo.ts | 24 ++- 4 files changed, 207 insertions(+), 98 deletions(-) create mode 100644 .changeset/thin-peaches-impress.md diff --git a/.changeset/thin-peaches-impress.md b/.changeset/thin-peaches-impress.md new file mode 100644 index 00000000..b100a242 --- /dev/null +++ b/.changeset/thin-peaches-impress.md @@ -0,0 +1,5 @@ +--- +'@roboplay/robo.js': minor +--- + +feat: automatic promise rejection handling diff --git a/packages/discord/src/core/env.ts b/packages/discord/src/core/env.ts index b07560b3..934990d2 100644 --- a/packages/discord/src/core/env.ts +++ b/packages/discord/src/core/env.ts @@ -4,6 +4,7 @@ dotenv.config() export const env = { discord: { clientId: process.env.DISCORD_CLIENT_ID, + debugChannelId: process.env.DISCORD_DEBUG_CHANNEL_ID, guildId: process.env.DISCORD_GUILD_ID, token: process.env.DISCORD_TOKEN }, diff --git a/packages/discord/src/core/handlers.ts b/packages/discord/src/core/handlers.ts index 7d4c9af2..db0fe406 100644 --- a/packages/discord/src/core/handlers.ts +++ b/packages/discord/src/core/handlers.ts @@ -1,15 +1,32 @@ import chalk from 'chalk' -import { commands, events } from './robo.js' -import { ActionRowBuilder, ButtonBuilder, ButtonStyle, Colors, CommandInteraction, Message } from 'discord.js' +import { client, commands, events } from './robo.js' +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChannelType, + Colors, + CommandInteraction, + Message +} from 'discord.js' import path from 'node:path' import { getSage, timeout } from '../cli/utils/utils.js' import { getConfig } from '../cli/utils/config.js' import { logger } from './logger.js' import { BUFFER, DEFAULT_CONFIG, TIMEOUT } from './constants.js' import fs from 'node:fs/promises' -import type { APIEmbed, APIEmbedField, APIMessage, AutocompleteInteraction, InteractionResponse, MessageComponentInteraction, BaseMessageOptions } from 'discord.js' +import type { + APIEmbed, + APIEmbedField, + APIMessage, + AutocompleteInteraction, + InteractionResponse, + MessageComponentInteraction, + BaseMessageOptions +} from 'discord.js' import type { CommandConfig, EventRecord, PluginData } from '../types/index.js' import type { Collection } from 'discord.js' +import { env } from './env.js' export async function executeAutocompleteHandler(interaction: AutocompleteInteraction) { const command = commands.get(interaction.commandName) @@ -108,7 +125,11 @@ export async function executeCommandHandler(interaction: CommandInteraction) { } } -export async function executeEventHandler(plugins: Collection, eventName: string, ...eventData: unknown[]) { +export async function executeEventHandler( + plugins: Collection, + eventName: string, + ...eventData: unknown[] +) { const callbacks = events.get(eventName) if (!callbacks?.length) { return Promise.resolve() @@ -169,110 +190,57 @@ async function printErrorResponse(error: unknown, interaction: unknown, details? } try { - // Extract readable error message or assign default - let message = 'There was an error while executing this command!' - if (error instanceof Error) { - message = error.message - } else if (typeof error === 'string') { - message = error - } - message += '\n\u200b' - - // Try to get code at fault from stack trace - const stack = error instanceof Error ? error.stack : null - const source = error instanceof Error ? await getCodeCodeAtFault(error) : null - - // Assemble error response using fanceh embeds - const fields: APIEmbedField[] = [] - - // Include additional details available - if (interaction instanceof CommandInteraction) { - fields.push({ - name: 'Command', - value: '`/' + interaction.commandName + '`' - }) - } - if (details) { - fields.push({ - name: 'Details', - value: details - }) - } - if (event) { - fields.push({ - name: 'Event', - value: '`' + event.path + '`' - }) - } - if (source) { - fields.push({ - name: 'Source', - value: `\`${source.file.replace(process.cwd(), '')}\`\n` + '```' + `${source.type}\n` + source.code + '\n```' - }) - } - - // Assemble response as an embed - const response: APIEmbed = { - color: Colors.Red, - fields: fields - } - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder({ - label: 'Show stack trace', - style: ButtonStyle.Danger, - customId: 'stack_trace' - }) - ) + const { message, stack } = await formatError({ error, interaction, details, event }) // Send response as follow-up if the command has already been replied to let reply: Message | APIMessage | InteractionResponse - const content: BaseMessageOptions = { - content: message, - embeds: [response], - components: [row] - } - if (interaction instanceof CommandInteraction) { if (interaction.replied || interaction.deferred) { - reply = await interaction.followUp(content) + reply = await interaction.followUp(message) } else { - reply = await interaction.reply(content) + reply = await interaction.reply(message) } } else if (interaction instanceof Message) { - reply = await interaction.channel.send(content) + reply = await interaction.channel.send(message) } - // Wait for user to click on the "Stack trace" button - (reply as Message).awaitMessageComponent({ - filter: (i: MessageComponentInteraction) => i.customId === 'stack_trace' - }).then(async (i) => { - try { - // Make button disabled - await i.update({ - components: [ - new ActionRowBuilder().addComponents( - new ButtonBuilder({ - label: 'Show stack trace', - style: ButtonStyle.Danger, - customId: 'stack_trace', - disabled: true - }) - ) - ] - }) - const stackTrace = stack.replace('/.robo/build/commands', '').replace('/.robo/build/events', '').replaceAll('\n', '\n> ') - await i.followUp('> ```js\n> ' + stackTrace + '\n> ```') - } catch (error) { - // Error-ception!! T-T - logger.debug('Error sending stack trace:', error) - } - }) + handleErrorStack(reply as Message, stack) } catch (error) { // This had one job... and it failed logger.debug('Error printing error response:', error) } } + +export async function sendDebugError(error: unknown) { + try { + // Find the guild by its ID + const guild = client.guilds.cache.get(env.discord.guildId) + const channel = guild?.channels?.cache?.get(env.discord.debugChannelId) + if (!guild || !channel) { + logger.info( + `Fix the error or set DISCORD_GUILD_ID and DISCORD_DEBUG_CHANNEL_ID to prevent your Robo from stopping.` + ) + return false + } + + // Ensure the channel is a text-based channel + if (channel.type !== ChannelType.GuildText) { + logger.warn(`Debug channel specified is not a text-based channel.`) + return false + } + + // Send the message to the channel + const { message, stack } = await formatError({ error }) + const reply = await channel.send(message) + handleErrorStack(reply, stack) + logger.debug(`Message sent to channel ${env.discord.debugChannelId} in guild ${env.discord.guildId}.`) + return true + } catch (error) { + logger.error('Error sending message:', error) + return false + } +} + async function getCodeCodeAtFault(err: Error) { try { const stackLines = err.stack?.split('\n') @@ -289,10 +257,7 @@ async function getCodeCodeAtFault(err: Error) { // Read file contents const file = filePath.replaceAll('/.robo/build/commands', '').replaceAll('/.robo/build/events', '') - const fileContent = await fs.readFile( - path.resolve(file), - 'utf-8' - ) + const fileContent = await fs.readFile(path.resolve(file), 'utf-8') const lines = fileContent.split('\n') const lineNumber = parseInt(line, 10) const columnNumber = parseInt(column, 10) @@ -318,3 +283,119 @@ async function getCodeCodeAtFault(err: Error) { return null } } + +interface FormatErrorOptions { + details?: string + error: unknown + event?: EventRecord + interaction?: unknown +} + +interface FormatErrorResult { + message: BaseMessageOptions + stack?: string +} + +async function formatError(options: FormatErrorOptions): Promise { + const { details, error, event, interaction } = options + + // Extract readable error message or assign default + let message = 'There was an error while executing this command!' + if (error instanceof Error) { + message = error.message + } else if (typeof error === 'string') { + message = error + } + message += '\n\u200b' + + // Try to get code at fault from stack trace + const stack = error instanceof Error ? error.stack : null + const source = error instanceof Error ? await getCodeCodeAtFault(error) : null + + // Assemble error response using fanceh embeds + const fields: APIEmbedField[] = [] + + // Include additional details available + if (interaction instanceof CommandInteraction) { + fields.push({ + name: 'Command', + value: '`/' + interaction.commandName + '`' + }) + } + if (details) { + fields.push({ + name: 'Details', + value: details + }) + } + if (event) { + fields.push({ + name: 'Event', + value: '`' + event.path + '`' + }) + } + if (source) { + fields.push({ + name: 'Source', + value: `\`${source.file.replace(process.cwd(), '')}\`\n` + '```' + `${source.type}\n` + source.code + '\n```' + }) + } + + // Assemble response as an embed + const response: APIEmbed = { + color: Colors.Red, + fields: fields + } + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder({ + label: 'Show stack trace', + style: ButtonStyle.Danger, + customId: 'stack_trace' + }) + ) + + return { + message: { + content: message, + embeds: [response], + components: [row] + }, + stack: stack + } +} + +/** + * Wait for user to click on the "Show stack trace" button + */ +function handleErrorStack(reply: Message, stack: string) { + reply + .awaitMessageComponent({ + filter: (i: MessageComponentInteraction) => i.customId === 'stack_trace' + }) + .then(async (i) => { + try { + // Make button disabled + await i.update({ + components: [ + new ActionRowBuilder().addComponents( + new ButtonBuilder({ + label: 'Show stack trace', + style: ButtonStyle.Danger, + customId: 'stack_trace', + disabled: true + }) + ) + ] + }) + const stackTrace = stack + .replace('/.robo/build/commands', '') + .replace('/.robo/build/events', '') + .replaceAll('\n', '\n> ') + await i.followUp('> ```js\n> ' + stackTrace + '\n> ```') + } catch (error) { + // Error-ception!! T-T + logger.debug('Error sending stack trace:', error) + } + }) +} diff --git a/packages/discord/src/core/robo.ts b/packages/discord/src/core/robo.ts index 4327473a..c19efc45 100644 --- a/packages/discord/src/core/robo.ts +++ b/packages/discord/src/core/robo.ts @@ -6,7 +6,7 @@ import { logger } from './logger.js' import { getManifest, loadManifest } from '../cli/utils/manifest.js' import { env } from './env.js' import { pathToFileURL } from 'node:url' -import { executeAutocompleteHandler, executeCommandHandler, executeEventHandler } from './handlers.js' +import { executeAutocompleteHandler, executeCommandHandler, executeEventHandler, sendDebugError } from './handlers.js' import type { CommandRecord, EventRecord, Handler, PluginData, RoboMessage } from '../types/index.js' export const Robo = { restart, start, stop } @@ -108,6 +108,28 @@ process.on('message', (message: RoboMessage) => { } }) +process.on('unhandledRejection', async (reason) => { + // Exit right away if the client isn't ready yet + // We don't want to send a message to Discord nor notify handlers if we can't + if (!client?.isReady()) { + logger.error(reason) + process.exit(1) + } + + // Log error and ignore it in production + logger.error(reason) + if (env.nodeEnv === 'production') { + return + } + + // Development mode works a bit differently because we don't want developers to ignore errors + // Errors will stop the process unless there's a special channel to send them to + const handledError = await sendDebugError(reason) + if (!handledError) { + stop(1) + } +}) + async function loadHandlerModules(type: 'commands' | 'events') { const collection = new Collection() const manifest = getManifest()