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 a40283c..94e8c0f 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 2f244d3..005e7f7 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "dependencies": { "@googleapis/drive": "8.4.0", "compare-urls": "4.0.0", + "consola": "3.2.3", "discord.js": "14.14.1" }, "devDependencies": { diff --git a/src/checks.ts b/src/checks.ts new file mode 100644 index 0000000..709de1b --- /dev/null +++ b/src/checks.ts @@ -0,0 +1,132 @@ +import { env } from "bun"; +import { consola } from "consola"; +import { + Client, + OAuth2Scopes, + PermissionFlagsBits, + PermissionsBitField, +} from "discord.js"; + +/** + * Check if all required environment variables are set. + */ +export const checkEnvs = () => { + // 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" }.`,