Skip to content

Commit

Permalink
feat: add checks and improve error messages (#19)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
risu729 authored Jan 2, 2024
1 parent 4f7b7bb commit 9f46512
Show file tree
Hide file tree
Showing 8 changed files with 164 additions and 63 deletions.
4 changes: 4 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
DISCORD_BOT_TOKEN=
DISCORD_GUILD_ID=
GOOGLE_SERVICE_ACCOUNT_EMAIL=
GOOGLE_SERVICE_ACCOUNT_KEY=
Binary file modified bun.lockb
Binary file not shown.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
132 changes: 132 additions & 0 deletions src/checks.ts
Original file line number Diff line number Diff line change
@@ -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<true>) => {
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}).`);
}
}
};
14 changes: 8 additions & 6 deletions src/commands.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { env } from "bun";
import { consola } from "consola";
import {
ApplicationCommandType,
type ChatInputCommandInteraction,
Expand Down Expand Up @@ -58,27 +59,28 @@ export const commands: ExecutableCommand[] = [
* @param client client used to register commands
*/
export const registerCommands = async (client: Client<true>) => {
console.info("Registering application commands...");
consola.start("Registering application commands...");
try {
const body: RESTPutAPIApplicationGuildCommandsJSONBody = commands.map(
(command) => command.data,
);
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);
Expand All @@ -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"
}.`,
Expand Down Expand Up @@ -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.`);
}
};
1 change: 0 additions & 1 deletion src/embeds.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,6 @@ const createEmbedsMessage = async (
) {
return undefined;
}
console.error(error);
throw error;
}),
),
Expand Down
8 changes: 1 addition & 7 deletions src/env.d.ts
Original file line number Diff line number Diff line change
@@ -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;

/**
Expand Down
67 changes: 18 additions & 49 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { env } from "bun";
import { consola } from "consola";
import {
ActivityType,
Client,
Expand All @@ -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
Expand All @@ -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);
Expand All @@ -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"
}.`,
Expand Down

0 comments on commit 9f46512

Please sign in to comment.