Create a Book Club Bot using Discord.js v14

by Lucas Minter

← go back to all posts

This tutorial is based off of my course released on egghead.io.

In this tutorial, I will guide you through creating your first Discord bot. We will begin by setting up our bot in the Discord developer portal, followed by setting up the bot locally. I will teach you how to develop a basic chatbot, and then we will dive into creating slash commands with more detail. Finally, we will deploy the bot to ensure it runs continuously.

I have found the following resources to be helpful while developing:

  • Discord.js' guide on their website is an excellent resource for learning more about each topic covered in this tutorial.
  • If you have any questions you can't figure out, I highly encourage you to check out the Discord.js discord channel. However, please try to solve the problem yourself first. Accurately describing the issue and the attempted solutions is a great way to get a quick answer.
  • The Discord status page is a useful tool to check for any Discord outages. If your bot isn't working and you can't find the issue, verifying that everything is running on Discord's end is a great debugging step.

Create and Configure a Bot using the Discord Developer Portal

To get started, go to https://discord.com/developers/applications. If you do not have an account, you will need to sign up for one. Once you're in, click "New Application" and name your bot.

In the navigation column on the left, click "Bot," then click "Add Bot." This will convert your application into a bot.

Next, click on "OAuth2," then "URL Generator," and select "bot" to set the necessary permissions. The specific permissions required will depend on what you want your bot to do. Since we're creating a book club event bot, we need to grant it the following permissions: manage roles, manage channels, read messages/view channels, moderate members, send messages, manage messages, add reactions, and use slash commands. Copy the generated URL and add it to your Discord channel.

Send your first Discord.js Bot Event

To begin, create a directory and navigate to it using the command line. Then, run yarn init -y to generate the initial files. We will also need to install the following dependencies: discord.js, dotenv, and nodemon.

After that, create a .env file to save some variables. In the Discord developer portal, we will need two variables: application id, which will be saved as CLIENT_ID, and a token that can be found by clicking "Reset Token" in the "Bot" section. Save the token in your .env file as TOKEN.

Next, create a main.js file in the root of your directory and import the necessary packages:

const { Client, Intents } = require('discord.js');
require('dotenv').config();

We will then create a new client instance:

const client = new Client({ intents: [Intents.FLAGS.GUILDS] });

Once we have created the client, it is common practice to log in to make sure the bot is working as intended:

client.once('ready', () => {
  console.log(`Ready! Logged in as ${client.user.tag}`);
});

We can then log in to Discord with our client's token:

client.login(process.env.TOKEN);

Lastly, in package.json, create a development script using nodemon:

"scripts": {
  "dev": "nodemon main.js"
},

Run yarn dev to start your bot!

Create your first Discord.js Bot Message Command

To create your first message command, you need to add the necessary permissions via the GatewayIntentBits. Here is an example of how to set up the client with the required intents:

const client = new Client({ intents: [ GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent ]});

Using client.on, the bot will check the messages sent in the server to see if they match up with the desired command name, and then reply to that command with some text. Here is an example:

client.on('messageCreate', (message) => {
  if(message.content === 'hello'){
    message.reply('world');
  }
});

If you want to create a simple chatbot, you can keep checking the messages in your server with if/else statements and reply accordingly. However, if you want to create a more intricate bot, you'll want to use slash commands.

What are Slash Commands

Using if/else blocks to create a bot can become cluttered and hard to read as it becomes more complex. However, Slash Commands offer a solution by allowing you to separate commands into individual files and providing helpful configurations.

According to the Discord.js tutorial, here are some advantages of using Slash Commands:

  • Integration with the Discord client interface.
  • Automatic command detection and parsing of associated options/arguments.
  • Typed argument inputs for command options, such as "String", "User", or "Role".
  • Validated or dynamic choices for command options.
  • In-channel private responses (ephemeral messages).
  • Pop-up form-style inputs for capturing additional information.

Therefore, if you plan on creating a more complex bot, it is recommended to use Slash Commands to keep your code organized and manageable.

Setup Files for your first Discord.js Slash Command

Thanks to the Discord.js guide, we can use some of their files to set up our project completely for slash commands. While you can have these functions set up in your main.js file, it's much more readable to extract them into their own folder and files.

Here are five files that I grabbed from the Discord.js guide to run slash commands. I won't go into detail here, so you can either go through their documentation or watch my course on egghead.io.

You can manually add the files listed, or you can clone the template I created, fill out the .env file, and be ready for the next section.

Main.js

const fs = require('node:fs')
const path = require('node:path')

const { Client, GatewayIntentBits, Collection } = require('discord.js');
require('dotenv').config()

const client = new Client({ intents: [ GatewayIntentBits.Guilds ]});

client.commands = new Collection();

const commandsPath = path.join(__dirname, 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));

for (const file of commandFiles) {
    const filePath = path.join(commandsPath, file);
    const command = require(filePath);
    if ('data' in command && 'execute' in command) {
        client.commands.set(command.data.name, command);
    } else {
        console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
    }
}

const eventsPath = path.join(__dirname, 'events');
const eventFiles = fs.readdirSync(eventsPath).filter(file => file.endsWith('.js'));

for (const file of eventFiles) {
    const filePath = path.join(eventsPath, file);
    const event = require(filePath);
    if (event.once) {
        client.once(event.name, (...args) => event.execute(...args));
    } else {
        client.on(event.name, (...args) => event.execute(...args));
  }
}

client.login(process.env.TOKEN);

events/interactionCreate.js

const { Events } = require('discord.js');

module.exports = {
    name: Events.InteractionCreate,
    async execute(interaction) {
        if (!interaction.isChatInputCommand()) return;

        const command = interaction.client.commands.get(interaction.commandName);

        if (!command) {
            console.error(`No command matching ${interaction.commandName} was found.`);
            return;
        }

        try {
            await command.execute(interaction);
        } catch (error) {
            console.error(`Error executing ${interaction.commandName}`);
            console.error(error);
        }
    },
};

events/ready.js

const { Events } = require('discord.js');

module.exports = {
    name: Events.ClientReady,
    once: true,
    execute(client) {
        console.log(`Ready! Logged in as ${client.user.tag}`);
    },
};

deploy-commands.js

const { REST, Routes } = require('discord.js');
require('dotenv').config()
const fs = require('node:fs');

const commands = [];
// Grab all the command files from the commands directory you created earlier
const commandFiles = fs.readdirSync('./commands').filter(file => file.endsWith('.js'));

// Grab the SlashCommandBuilder#toJSON() output of each command's data for deployment
for (const file of commandFiles) {
    const command = require(`./commands/${file}`);
    commands.push(command.data.toJSON());
}

// Construct and prepare an instance of the REST module
const rest = new REST({ version: '10' }).setToken(process.env.TOKEN);

// and deploy your commands!
(async () => {
    try {
        console.log(`Started refreshing ${commands.length} application (/) commands.`);

        // The put method is used to fully refresh all commands in the guild with the current set
        const data = await rest.put(
            Routes.applicationGuildCommands(process.env.CLIENT_ID, process.env.GUILD_ID),
            { body: commands },
        );

        console.log(`Successfully reloaded ${data.length} application (/) commands.`);
    } catch (error) {
        // And of course, make sure you catch and log any errors!
        console.error(error);
    }
})();

commands/ping.js


const { SlashCommandBuilder } = require('discord.js');

module.exports = {
    data: new SlashCommandBuilder()
        .setName('ping')
        .setDescription('Replies with Pong'),
    async execute(interaction) {
        await interaction.reply(`Pong!`);
    },
};

Create your first Discord.js Slash Command

If you have already cloned the repository, you should have the ping command set up. You can find the code for the file in the section above.

To create a slash command, you need two things: the data property and the execute method. The data property is the definition you give to your command and requires at least two fields, setName and setDescription. The execute method will contain the functionality that your event handler will run.

Discord.js Slash Commands are promise-based, so we will be using async/await in our execute method. If you ever encounter an error and are unsure why, double-checking that you are using async/await correctly is a good place to start.

After creating the command, I will use the single-guild registration for my bot to register the command to my server using node deploy-commands.js. If your bot scales and is in multiple servers, you can register your commands across all servers, but that won't be necessary for this course. You only need to register your command once.

Receive and Respond to Input From Users with Discord.js Slash Commands

Let's start by creating the book club command using slash commands. One of the main advantages of using slash commands is that we can prompt users for input using input fields. In this case, we'll prompt users to enter a book title using addStringOption, the number of people interested using addIntegerOption, and the deadline for submissions using another addIntegerOption. We can make these fields required by using setRequired.

In the execute method, we can retrieve the data entered by the user using getString and getInteger and store them in separate variables. We can also store the username of the user who executed the command in an author variable, which we can later use in our reply message using the reply method.

bookClub.js

const { SlashCommandBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, PermissionsBitField, ChannelType } = require('discord.js');

module.exports = {
  data: new SlashCommandBuilder()
    .setName('bookclub')
    .setDescription('Creates a new book club!')
    .addStringOption(op =>
      op.setName('book')
        .setDescription('The book the book club is on')
        .setRequired(true)
    )
    .addIntegerOption(op =>
      op.setName('people')
        .setDescription('How many people you want for the club')
        .setRequired(true)
    )
    .addIntegerOption( op =>
      op.setName('hours')
        .setDescription('When, in hours, you want submissions closed')
        .setRequired(true)
    )
    ,

  execute(interaction) {
        const book = interaction.options.getString('book')
    const numOfPeople = interaction.options.getInteger('people')
    const hours = interaction.options.getInteger('hours')

        const author = interaction.member.user.username

        interaction.reply(`${author} is looking for ${numOfPeople} people to run a book club on the book ${book}. Submissions end in ${hours} hours.`)
    }
}

Convert a Discord Slash Command Bot Response to an Embedded Message

To streamline bot responses, embedded messages are an effective tool. Firstly, I will generate an appropriate title for the book club by utilizing the user-provided book input. To distinguish between similar book clubs, appending the date to the title is a useful strategy.

const bookClub = book + ' Book Club ' + new Date().toDateString()

Next, I will create an object, which will contain the essential components for an embed message - the title and description of the book club.

const embedMessage = {
    title: bookClub,
    description: `${author} is going to run a book club on the book ${book}.`,
}

Following this, a field can be added to display the required number of participants and the expiration of submissions. Although the inline format can be used for numerous fields, I will stick to regular fields, as there are only a few.

const embedMessage = {
    title: bookClub,
    description: `${author} is going to run a book club on the book ${book}.`,
    fields: [
        {
            name: 'Amount of people wanted',
            value: `${numOfPeople}`
        },
        {
            name: `Submissions close in ${hours} hours`,
            value: `Time runs out in: ${hours} hours`
        }
    ]
}

Finally, I will respond to the interaction and include the embed message.

interaction.reply({ embeds: [embedMessage] })

Display relative time in a Discord.js Embedded Message

To make it easier for our users, Discord provides us with a useful function based on the UNIX timestamp. With this, we can display the time relative to the user's location in Discord. I'll convert the number of hours given by the user into a UNIX timestamp and then use <t:${hours}:R> to show the relative time to the user.

const hours = interaction.options.getInteger('hours')
const waitTimeInSeconds = (hours * 60) * 60
const timeExpiresAt = Math.floor(new Date().getTime() / 1000) + waitTimeInSeconds

...

{
    name: `Submissions close at <t:${timeExpiresAt}:f>`,
    value: `Time runs out in: <t:${timeExpiresAt}:R>`
}

There are other ways to display time to our users, and you can find them in the Discord timestamps table on GitHub, as well as more information on the UNIX timestamp.

Resources:

Discord timestamps table on GitHub

UNIX timestamp converter and definition

Create Custom Discord.js Buttons with ActionRowBuilder and ButtonBuilder

To allow users to interact with the embedded message and join the book club, a button needs to be created. The following code imports necessary components and sets up a button with three fields:

const { 
    SlashCommandBuilder, 
    ActionRowBuilder, 
    ButtonBuilder, 
    ButtonStyle, 
    PermissionsBitField,
    ChannelType
} = require('discord.js');

const row = new ActionRowBuilder()
    .addComponents(
        new ButtonBuilder()
            .setCustomId('primary')
            .setStyle(ButtonStyle.Primary)
            .setEmoji('👍')
    );

To display the button, it needs to be added as a component to the embedded message object:

interaction.reply({ embeds: [embedMessage], components: [row]})

Resources:

Discord.js Button Guide

Button Styles

Create Dynamic Channels and Roles

In order to ensure that only book club members can view the club channel, we have to create a new channel and a role that will be applied to that channel.

We begin by creating the role for the book club with a name of our choosing and setting the required permissions to view channels and send messages using the following code snippet:

interaction.guild.roles.create({ 
    name: `${bookClub}`,
    permissions: [
        PermissionsBitField.Flags.ViewChannel,
        PermissionsBitField.Flags.SendMessages
    ]
})

Then, we get the role ID using interaction.guild.roles.cache.find() method, as shown below:

const bookClubRoleId = interaction.guild.roles.cache.find(r => r.name === bookClub).id

Next, we create the text channel and set permission overwrites to deny access to everyone except members of the book club. This is achieved using the following code snippet:

interaction.guild.channels.create({
    name: `${bookClub}`,
    type: ChannelType.GuildText,
    permissionOverwrites: [
        {
            id: interaction.guild.id,
            deny: [PermissionsBitField.Flags.ViewChannel]
        },
        {
            id: bookClubRoleId,
            allow:[
                PermissionsBitField.Flags.SendMessages,
                PermissionsBitField.Flags.ViewChannel
            ]
        }
    ]
})

Finally, we need to add async to the execute function and use await for the interactions, as Discord.js is promise-based. The modified code looks like this:

async execute(interaction) {

await interaction.reply({ embeds: [embedMessage], components: [row]})

await interaction.guild.roles.create({

const bookClubRoleId = await interaction.guild.roles.cache.find(r => r.name === bookClub).id

await interaction.guild.channels.create({

We do this because Discord.js is promise-based, and without adding async/await, the calls might work occasionally, but they will not work properly all of the time.

Add Roles to Users using a Discord.js Collector

Collectors are essential to gather information from users, and there are various types of collectors you can utilize. Before we get started, we must save our embedded message to a variable so we can attach a collector to it later on.

const finishedEmbedMessage = await interaction.reply({ embeds: [embedMessage], components: [row]})

Since we are using a button component on a message, we will use the createMessageComponentCollector collector.

The collector requires three fields: a filter to verify if users do not have the book club role.

const filter = i => {
    return interaction.guild.members.cache.get(i.user.id).roles.cache.has(bookClubRoleId) === false
}

max to specify the maximum number of items we want our collector to collect, and time to turn off the collector after a certain amount of time if we do not reach the maximum. We will need to convert the hours into milliseconds for the time variable.

const waitTimeInMilliseconds = waitTimeInSeconds * 1000

const collector = finishedEmbedMessage.createMessageComponentCollector({ filter, max: numOfPeople, time: waitTimeInMilliseconds})

Now, let's enable our collector. Inside this function is where we will add the role to our users. First, let us retrieve the ID of our user and store it in a variable: interaction.guild.members.cache.get(i.user.id). With that ID, we can then add the book club role using the book club role ID.

Once that is complete, we want to send a message to ensure our users are aware that they were added to the book club. We will use a simple reply to display username has been added to the book club!.

collector.on('collect', i => {
    const userById = interaction.guild.members.cache.get(i.user.id)
    userById.roles.add(bookClubRoleId)
    i.reply({content: `${i.user.username} has been added to the book club!`})
})

Lastly, let's turn off our collector. We will use console.log to output collected.

collector.on('end', c => {
    console.log('collected'
})

Resources:

Discord.js Collectors Guide

Conditionally Disable a Custom Discord.js Button

There are two issues that require solving. Presently, when a user with the book club role clicks the button, they receive an error message. Additionally, even if submissions are closed, users can still click the button. To improve the user experience, I will add code to display a more appropriate message when someone clicks the button but already has the book club role. I will also disable the button once submissions are closed.

To handle the first issue, I will add an if statement to the filter function to check the user's roles. If the user already has the book club role, a hidden message will be sent to them using ephemeral to let them know they have already been added.

const filter = i => {
    if (interaction.guild.members.cache.get(i.user.id).roles.cache.has(bookClubRoleId) === true) {
        i.reply({content: `You've already been added to the book club.`, ephemeral: true})
    }
    return interaction.guild.members.cache.get(i.user.id).roles.cache.has(bookClubRoleId) === false
}

To disable the button, I will create a new button using ActionRowBuilder and set the setDisabled(true) property to make it unclickable. Finally, I will use interaction.editReply() to update the original reply and add the new button.

const disabledButton = new ActionRowBuilder()
    .addComponents(
        new ButtonBuilder()
            .setCustomId('primary')
            .setStyle(ButtonStyle.Primary)
            .setLabel('Submissions Closed')
            .setDisabled(true)
    );

...

collector.on('end', c => {
    interaction.editReply({embeds: [embedMessage], components: [disabledButton]})
})

Lastly, for additional information, I will log out the size of our collection.

collector.on('end', c => {
    interaction.editReply({embeds: [embedMessage], components: [disabledButton]})
    console.log(`collected ${c.size}`)
})

Deploy a Discord.js Bot for Production

To deploy the bot in its finished state, I will create a new GitHub repository and push the code to it, making sure to ignore the node_modules directory and the .env file by adding them to the .gitignore file.

For deployment, I will use railway.app. If necessary, I will create an account and select start a new project, then choose my repository from the deploy from GitHub option. After adding the environment variables, I can deploy the bot!

Conclusion

Building a Book Club Bot with Discord.js is a fun and challenging project that can help you hone your programming skills while providing a valuable tool for managing your book club. By following the steps outlined in this tutorial, you should now have a solid understanding of how to create a Discord bot, interact with the Discord API, and utilize various libraries such as Discord.js and Dotenv. From creating custom commands and buttons to handling interactions and deploying your bot, this tutorial has covered all the essential aspects of building a book club bot. So what are you waiting for? Grab your favorite programming language and start building your own book club bot today!

Resources List