From 9f4651215eded6bf4fafdb652ef54f62e9cddb62 Mon Sep 17 00:00:00 2001 From: Risu <79110363+risu729@users.noreply.github.com> Date: Tue, 2 Jan 2024 14:43:47 +0900 Subject: [PATCH] feat: add checks and improve error messages (#19) * fix: get application id from client instead of env * fix: check environment variables are set at initialization * feat: add checks and improve error messages * style: format with biome --- .env.sample | 4 ++ bun.lockb | Bin 42176 -> 42518 bytes package.json | 1 + src/checks.ts | 132 ++++++++++++++++++++++++++++++++++++++++++++++++ src/commands.ts | 14 ++--- src/embeds.ts | 1 - src/env.d.ts | 8 +-- src/main.ts | 67 +++++++----------------- 8 files changed, 164 insertions(+), 63 deletions(-) create mode 100644 .env.sample create mode 100644 src/checks.ts diff --git a/.env.sample b/.env.sample new file mode 100644 index 0000000..72d6262 --- /dev/null +++ b/.env.sample @@ -0,0 +1,4 @@ +DISCORD_BOT_TOKEN= +DISCORD_GUILD_ID= +GOOGLE_SERVICE_ACCOUNT_EMAIL= +GOOGLE_SERVICE_ACCOUNT_KEY= diff --git a/bun.lockb b/bun.lockb index a40283c66ca364811ff31e17d01de0fe6bc6f25b..94e8c0fbf31c8bb6fab1dfadab203250ba68737a 100755 GIT binary patch delta 5954 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 { + // need to sync with env.d.ts + const requiredEnvs = [ + "DISCORD_BOT_TOKEN", + "DISCORD_GUILD_ID", + "GOOGLE_SERVICE_ACCOUNT_EMAIL", + "GOOGLE_SERVICE_ACCOUNT_KEY", + ]; + const missingEnv = requiredEnvs.filter((name) => !env[name]); + if (!missingEnv.length) { + return; + } + consola.error( + `Environment variables ${missingEnv.join( + ", ", + )} are not set. Follow the instructions in README.md and set them in .env.`, + ); + process.exit(1); +}; + +/** + * Check the status of the bot are valid. + * @param client client after ready event + */ + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ignore for now +export const checkBotStatus = async (client: Client) => { + const requiredPermissions = [ + PermissionFlagsBits.ViewChannel, + PermissionFlagsBits.SendMessages, + PermissionFlagsBits.SendMessagesInThreads, + // required to send embeds + PermissionFlagsBits.EmbedLinks, + PermissionFlagsBits.ReadMessageHistory, + // required to suppress embeds of original messages + PermissionFlagsBits.ManageMessages, + ]; + + const guilds = client.guilds.cache; + const isInTargetGuild = guilds.has(env.DISCORD_GUILD_ID); + + const bot = await guilds.get(env.DISCORD_GUILD_ID)?.members.fetchMe(); + const missingPermissions = bot?.permissions.missing(requiredPermissions); + + const application = await client.application.fetch(); + const botSettingsUrl = `https://discord.com/developers/applications/${application.id}/bot`; + if (application.botPublic) { + consola.warn( + `Bot is public (can be added by anyone). Consider making it private from ${botSettingsUrl}.`, + ); + } + if (application.botRequireCodeGrant) { + if (!(isInTargetGuild && missingPermissions) || missingPermissions.length) { + if (isInTargetGuild) { + consola.error( + `Bot is missing the following required permissions: ${ + !missingPermissions || missingPermissions.join(", ") + }.`, + ); + } else { + consola.error( + `Bot is not in the target guild ${env.DISCORD_GUILD_ID}.`, + ); + } + consola.error( + `The bot authorization URL cannot be generated because the bot requires OAuth2 code grant. Disable it from ${botSettingsUrl} and try again.`, + ); + process.exit(1); + } + consola.warn( + `Bot requires OAuth2 code grant. It is unnecessary for this bot. Consider disabling it from ${botSettingsUrl}.`, + ); + } + + const oauth2Scopes = [OAuth2Scopes.Bot, OAuth2Scopes.ApplicationsCommands]; + const authorizationUrl = application.botRequireCodeGrant + ? undefined + : new URL("https://discord.com/api/oauth2/authorize"); + if (authorizationUrl) { + authorizationUrl.searchParams.append("client_id", client.user.id); + authorizationUrl.searchParams.append("scope", oauth2Scopes.join(" ")); + authorizationUrl.searchParams.append( + "permissions", + PermissionsBitField.resolve(requiredPermissions).toString(), + ); + } + + if (!isInTargetGuild) { + // exit if the bot is not in the target guild + consola.error( + `Bot is not in the target guild ${env.DISCORD_GUILD_ID}. ${ + authorizationUrl + ? `Follow this link to add the bot to the guild: ${authorizationUrl}` + : `Bot requires OAuth2 code grant. It is unnecessary for this bot. Consider disabling it from ${botSettingsUrl}.` + }`, + ); + consola.error( + `Bot requires OAuth2 code grant. It is unnecessary for this bot. Consider disabling it from ${botSettingsUrl}.`, + ); + process.exit(1); + } + + // exit if the bot is missing some required permissions + if (!missingPermissions || missingPermissions.length) { + consola.error( + `Bot is missing the following required permissions: ${ + !missingPermissions || missingPermissions.join(", ") + }. Follow this link to update the permissions: ${authorizationUrl}`, + ); + process.exit(1); + } + + // leave unauthorized guilds + for (const [id, guild] of guilds) { + if (id !== env.DISCORD_GUILD_ID) { + await guild.leave(); + consola.warn(`Left unauthorized guild ${guild.name} (${id}).`); + } + } +}; diff --git a/src/commands.ts b/src/commands.ts index a9550a8..071e371 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -1,4 +1,5 @@ import { env } from "bun"; +import { consola } from "consola"; import { ApplicationCommandType, type ChatInputCommandInteraction, @@ -58,7 +59,7 @@ export const commands: ExecutableCommand[] = [ * @param client client used to register commands */ export const registerCommands = async (client: Client) => { - console.info("Registering application commands..."); + consola.start("Registering application commands..."); try { const body: RESTPutAPIApplicationGuildCommandsJSONBody = commands.map( (command) => command.data, @@ -66,19 +67,20 @@ export const registerCommands = async (client: Client) => { await client.rest.put( // register as guild commands to avoid accessing data from DMs or other guilds Routes.applicationGuildCommands( - env.DISCORD_BOT_APPLICATION_ID, + client.application.id, env.DISCORD_GUILD_ID, ), { body }, ); - console.info( + consola.success( `Successfully registered application commands: ${commands .map((command) => command.data.name) .join(", ")}`, ); } catch (error) { - console.error("Failed to register application commands."); + consola.error("Failed to register application commands."); + // do not use consola#error to throw Error since it cannot handle line numbers correctly console.error(error); // bun does not exit with a thrown error in listener process.exit(1); @@ -96,7 +98,7 @@ export const commandsListener = async (interaction: Interaction) => { // ignore commands from unauthorized guilds or DMs if (interaction.guildId !== env.DISCORD_GUILD_ID) { - console.warn( + consola.warn( `Command ${interaction.commandName} was triggered in ${ interaction.inGuild() ? "an unauthorized guild" : "DM" }.`, @@ -132,6 +134,6 @@ export const commandsListener = async (interaction: Interaction) => { return; } - console.error(`Command ${command.data.name} not found.`); + consola.error(`Command ${command.data.name} not found.`); } }; diff --git a/src/embeds.ts b/src/embeds.ts index 5834e5b..a5bc098 100644 --- a/src/embeds.ts +++ b/src/embeds.ts @@ -104,7 +104,6 @@ const createEmbedsMessage = async ( ) { return undefined; } - console.error(error); throw error; }), ), diff --git a/src/env.d.ts b/src/env.d.ts index a74cee7..3a00878 100644 --- a/src/env.d.ts +++ b/src/env.d.ts @@ -1,15 +1,9 @@ declare module "bun" { interface Env { - /** - * Application ID of the Discord bot. - */ - // biome-ignore lint/style/useNamingConvention: should be SCREAMING_SNAKE_CASE - DISCORD_BOT_APPLICATION_ID: string; - /** * Token of the Discord bot. */ - // biome-ignore lint/style/useNamingConvention: + // biome-ignore lint/style/useNamingConvention: should be SCREAMING_SNAKE_CASE DISCORD_BOT_TOKEN: string; /** diff --git a/src/main.ts b/src/main.ts index 4552a2f..acbedfa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,5 @@ import { env } from "bun"; +import { consola } from "consola"; import { ActivityType, Client, @@ -8,25 +9,29 @@ import { MessageFlags, PartialMessage, Partials, - PermissionFlagsBits, } from "discord.js"; +import { checkBotStatus, checkEnvs } from "./checks"; import { commandsListener, registerCommands } from "./commands"; import { deleteEmbedsMessage, updateEmbedsMessage } from "./embeds"; import { driveClient } from "./gdrive"; -console.info("Starting Google Drive API client..."); -console.info(`Service account email: ${env.GOOGLE_SERVICE_ACCOUNT_EMAIL}`); +consola.start("gdrive4d is starting..."); + +checkEnvs(); + +consola.start("Starting Google Drive API client..."); +consola.info(`Service account email: ${env.GOOGLE_SERVICE_ACCOUNT_EMAIL}`); // test if the client is working, fail fast const files = await driveClient.files.list(); // exit if the service account has access to no files if (!files.data.files?.length) { - throw new Error( - "No files are shared to the service account in Google Drive.", + consola.warn( + "No files are shared to the service account in Google Drive. Share some files to the service account and try again.", ); } -console.info("Google Drive API client is now ready!"); +consola.ready("Google Drive API client is now ready!"); -console.info("Starting Discord bot..."); +consola.start("Starting Discord bot..."); const discordClient = new Client({ intents: [ // required to receive messages @@ -39,52 +44,16 @@ const discordClient = new Client({ }); discordClient.once(Events.ClientReady, async (client) => { - console.info("Discord bot is now ready!"); - console.info(`Logged in as ${client.user.tag}.`); - - await registerCommands(client); + consola.ready("Discord bot is now ready!"); + consola.info(`Logged in as ${client.user.tag}.`); client.user.setActivity("Google Drive", { type: ActivityType.Watching }); - const guilds = client.guilds.cache; - if (!guilds.has(env.DISCORD_GUILD_ID)) { - // exit if the bot is not in the target guild - console.error(`Bot is not in the target guild ${env.DISCORD_GUILD_ID}.`); - // bun does not exit with a thrown error in listener - process.exit(1); - } + await checkBotStatus(client); - // exit if the bot is missing some required permissions - const requiredPermissions = [ - PermissionFlagsBits.ViewChannel, - PermissionFlagsBits.SendMessages, - PermissionFlagsBits.SendMessagesInThreads, - // required to send embeds - PermissionFlagsBits.EmbedLinks, - PermissionFlagsBits.ReadMessageHistory, - // required to suppress embeds of original messages - PermissionFlagsBits.ManageMessages, - ]; - // biome-ignore lint/style/noNonNullAssertion: already ensured that the bot is in the target guild - const bot = await guilds.get(env.DISCORD_GUILD_ID)!.members.fetchMe(); - const missingPermissions = bot.permissions.missing(requiredPermissions); - if (missingPermissions.length) { - console.error( - `Bot is missing the following required permissions: ${missingPermissions.join( - ", ", - )}.`, - ); - // bun does not exit with a thrown error in listener - process.exit(1); - } + await registerCommands(client); - // leave unauthorized guilds - for (const [id, guild] of guilds) { - if (id !== env.DISCORD_GUILD_ID) { - await guild.leave(); - console.warn(`Left unauthorized guild ${guild.name} (${id}).`); - } - } + consola.ready("gdrive4d is successfully started!"); }); discordClient.on(Events.InteractionCreate, commandsListener); @@ -97,7 +66,7 @@ const isValidRequest = (message: Message | PartialMessage): boolean => { } // ignore commands from unauthorized guilds or DMs if (message.guildId !== env.DISCORD_GUILD_ID) { - console.warn( + consola.warn( `Message event was sent in ${ message.inGuild() ? "an unauthorized guild" : "DM" }.`,