Skip to content

Anatomy of a Discord Bot#

So you've done all the setup steps and your bot is running smoothly. Great!

This doc goes into more detail about how discord.py bots do their thing, with an additional focus on the why for some common software patterns.

So, how, um... how does it work?#

To answer that, let's look at the source code for this bot, which isn't much.

In fact, here's the whole thing (at least, the original src/discord_bot_template/__init__.py contents):

import os

import discord
from discord.ext import commands
from dotenv import load_dotenv

load_dotenv()

DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "")
DISCORD_GUILD = int(os.getenv("DISCORD_GUILD", "0"))

intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents)


@bot.event
async def on_ready():
    """Let us know we logged in properly."""
    print(f"Logged in as {bot.user} (ID: {bot.user.id})")


@bot.tree.command(
    description="Replies with Hello!",
    guild=discord.Object(id=DISCORD_GUILD),
)
async def hello(interaction: discord.Interaction):
    """Just say hello."""
    print("Responding to /hello")
    await interaction.response.send_message("Hello, how are you?")


@bot.command(
    description="Replies to !whatsup",
    guild=discord.Object(id=DISCORD_GUILD),
)
async def whatsup(ctx):
    """Tell us what's up."""
    print("Responding to !whatsup")
    await ctx.send("Nothing much")


@bot.command(
    description="Sync the bot tree to update Slash command changes to the server",
    guild=discord.Object(id=DISCORD_GUILD),
)
async def sync(ctx):
    """Sync the bot command tree for Slash commands."""
    print("Syncing the command tree...")
    await bot.tree.sync(guild=discord.Object(id=DISCORD_GUILD))
    await ctx.reply("✅ Command tree synced")
    print("Sync Complete!")


def main():
    """Run the bot."""
    bot.run(DISCORD_TOKEN)
    print("Shutting down.")


if __name__ == "__main__":
    main()

The source code may seem complex, especially for newcomers to Discord bots, Python, or programming in general. Let's look at each part the above code in more detail, one piece at a time.

Environment variable handling#

Per the Twelve-Factor App method, we prefer to store config in the environment. This means all parts of the configuration of the app - including any secrets like our Discord app token and which server/guild to connect to - should be separate from the code of that app. This reduces the possibility of accidentally committing a token to a public repo, as well as making it easier to start the app with a different configuration without needing to make changes to the actual code of the app.

Now, remember our .env file created during the bootstrap step in setup? It may look something like this:

DISCORD_TOKEN=superSecretTokenValue12345 #(1)
DISCORD_GUILD=678910
  1. If this is your real token value, please contact Discord support and tell them something is very, very wrong.

Now have a look at this section of our code:

from dotenv import load_dotenv

load_dotenv()

DISCORD_TOKEN = os.getenv("DISCORD_TOKEN", "") #(1)
DISCORD_GUILD = int(os.getenv("DISCORD_GUILD", "0"))
  1. This second argument is a default value, returned if the environment variable is not present at all. You can read this as "return the value of DISCORD_TOKEN if it is present, otherwise return an empty string".

If this default were missing, it would return None. It may still be useful to use None in some contexts, of course.

Or, maybe you want the whole program to crash if the environment variable is missing (yes, sometimes that's a good thing!). In which case, consider simply calling os.environ["KEY"], which will throw an exception if that environment variable is missing.

The load_dotenv() function (from the python-dotenv package) reads the contents of that .env file. Each line is parsed into an environment variable in the form name=value.

Next, os.getenv() can be used to pull a given value from the environment. In our case, we want to grab our DISCORD_TOKEN and our DISCORD_GUILD values, storing these in variables

One option out of many

load_dotenv() is not required for os.getenv() to function; the former is simply a helper method for loading the .env file's contents. You could just as easily set these environment variables in other ways, such as on the command line when running the program:

# Set an environment variable inline when starting the program:
DISCORD_GUILD=678910 uv run thebot

# Setting a variable ahead of time with `export`:
export DISCORD_GUILD=678910
uv run thebot

Explore your use case and decide how you want these values set in your own environment.

Defining the bot#

Next we come to this bit of code, which creates our bot object:

# Recall where these imports came from:
import discord
from discord.ext import commands

intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix="!", intents=intents)

Intents#

The intents parameter is used to define Gateway Intents for our bot. These allow a bot to subscribe to specific events and ignore others.

The default intents provide most of the events you need, but not those considered "privileged", such as presence and message content. This is why we are explicitly setting intents.message_content = True above, so that the bot is able to read the contents of a message in order to know whether it should respond to a command from one.

Warning

You must also set the Message Content intent within the Discord Developer portal, as shown in the permissions setup.

Command prefix#

The command_prefix, as the name implies, is the prefix expected for legacy commands to the bot. For instance, if we have a command called foo with a prefix of !, the bot will respond when !foo is at the start a user's message.

Handling events#

Next we come to this little bit here:

@bot.event
async def on_ready():
    print(f"Logged in as {bot.user} (ID: {bot.user.id})")

@bot.event registers an event listener, with the event matching the name of the function. In this case, the on_ready event, which fires sometime at the start of the bot application, though this timing is not always guaranteed. More details on which events are available can be found here.

Here we're simply printing that the bot user logged in successfully. This may seem trivial, but having that feedback can be handy, confirming that the right app account was used for this program.

How should I handle logging?

As you may have noticed, we're just using simple print calls in this template, though a proper logging config would be ideal. By default, not much activity is logged to the console from a discord.py app, so you're pretty much on your own here.

Well, not entirely on your own. discord.py has some advice on logging; Python's logging module has some interesting recipes, as well. And there are many great third-party libraries such as loguru and structlog to choose from.

Which logging strategy you choose is outside the scope of this document. As a general rule, though, I would advise following Twelve Factor's advice on Logs: have the app send logs, unbuffered, to stdout.

For now, print() will suffice 🙂.

Adding a slash command#

Now let's get to the good part: an actual command!

We'll start with a Slash Command. Typing / into the chat shows us Slash Commands available in the server, and we can either type it or click on it in the pop-up menu:

typing /hello for our test bot and seeing its help text

Defining one is a simple matter of adding a new method to the bot's "command tree":

@bot.tree.command(
    description="Replies with Hello!",
    guild=discord.Object(id=DISCORD_GUILD),
)
async def hello(interaction: discord.Interaction):
    """Just say hello."""
    print("Responding to /hello")
    await interaction.response.send_message("Hello, how are you?")
  • The name of the command, hello, is taken from the name of the coroutine we are decorating. Alternatively, we could set a name="blah" parameter in the @bot.tree.command decorator to change the name to something else.

  • Our description, as we see above, is shown to the user when they are selecting our command from the menu.

  • guild is optional, but in this case we use the DISCORD_GUILD environment variable from earlier to only permit the command to work on that server.

The coroutine itself (async def hello()) takes at least one argument, interaction, which we can use to respond back to the user who send this command. This object also contains information about the channel the sent the command in, the server, the roles of the user, and so on.

We use this interaction to send a response, in this case a simple send_message call with the text "Hello, how are you?":

The test bot responding to /hello slash command

Way more than just a text message

That's a lengthy explanation for a pretty simple interaction, but those details open the door to many possibilities:

  • responding with a message that can contain one or more embeds, files, or even a poll.
  • editing a previous message that was sent
  • adding a reaction to a post
  • checking the roles of a user to see if they are even permitted to use this command

I invite you to explore the Interaction object and its associated attributes in more detail to discover what you might want to do with your Slash command.

Adding arguments#

Having a simple /hello command is all well and good, but what if we want it to announce "Hello" to a friend of ours?

We can simply add a new argument to our command and update the logic a bit, like so:

@bot.tree.command(
    description="Replies to /hello",
    guild=discord.Object(id=DISCORD_GUILD),
)
-async def hello(interaction: discord.Interaction):
+async def hello(interaction: discord.Interaction, name: str):
    """Just say hello."""
    print("Responding to /hello")
-   await interaction.response.send_message(f"Hello, how are you?")
+   await interaction.response.send_message(f"Hello {name}, how are you?")

Now, simply shut down the running bot with Ctrl-C and restart it (uv run thebot or just up again). This restarts the process using our updated code and reconnects to the Discord API.

Running the command now, however, causes an error:

/hello command showing an error message

This is because the bot needs to exchange some information about its commands with the server, in order for the server to know the correct signature for these commands. This process is known as "syncing" the bot's "command tree", and is explained in more detail below.

Suffice to say, after running our built-in !sync command (a prefixed command):

!sync command responding successfully

...we can then start using our updated /hello command with the name argument:

/hello command with name parameter waiting for input

Each argument presented in a command gets its own prompt area, providing the user with visual feedback about their command input, even before sending it.

We can type a name within this prompt in Discord:

/hello command with name parameter and Stanley typed in

...and our command receives the string "Stanley" as the name argument, which it then uses in the response:

result of /hello command with name parameter Stanley

Basic workflow for developing Slash commands

Each time you change the signature of a command - the arguments that the command accepts - you should start up your bot and call its !sync command in order to use the latest code version within Discord.

Remember this basic flow as you develop Slash commands:

  1. Save your code! Unsaved files do not run!
  2. Stop and restart the bot process.
  3. Call !sync in a channel in your Discord server (you may want to choose a private channel reserved for such maintenance commands).
  4. Test your updated Slash command in your Discord server.

Adding a "legacy" prefixed command#

Bots can also read message content from a chat channel, interpreting the text of the entire message as a "command". While Slash Commands are a relatively newer addition to Discord, these types of "legacy" commands have been around for a long time. And, while they are less discoverable than Slash Commands, they do not require syncing a tree with the server before the bot can respond. Instead, the bot just watches all messages to see if one is a "command" that it should act on.

In discord.py, writing one of these commands is about as simple as writing a Slash Command:

@bot.command(
    description="Replies to !whatsup",
    guild=discord.Object(id=DISCORD_GUILD),
)
async def whatsup(ctx):
    """Tell us what's up."""
    print("Responding to !whatsup")
    await ctx.send("Nothing much")

Recall that our bot is configured with the command_prefix value of "!". This means the bot will look for messages that start with the phrase !whatsup (matching the name of the coroutine above) and respond by sending a message to the channel:

The test bot responding to !whatsup command

Notice how this is a simple message sent to the channel (using ctx.send), not a reply to the user sending the command. To do that, we can use ctx.reply instead:

@bot.command(description="Replies to !whatsup")
async def whatsup(ctx):
    """Tell us what's up."""
    print("Responding to !whatsup")
-   await ctx.send("Nothing much")
+   await ctx.reply("Nothing much")

The test bot responding to !whatsup command with a reply

As with Slash Commands, these commands can also take arguments:

@bot.command(description="Replies to !whatsup")
-async def whatsup(ctx):
+async def whatsup(ctx, name: str):
    """Tell us what's up."""
    print("Responding to !whatsup")
-   await ctx.reply("Nothing much")
+   await ctx.reply(f"Nothing much, {name}!")

However, where the Slash Command arguments create a container for the user to enter the arg value, legacy commands will split the message on spaces and pass individual words to different args:

The test bot responding to !whatsup command with a reply and name arg

Check the discord.py Command Parameters doc for more details on this behavior, how to use a variable number of parameters, and so on.

Tip of the iceberg

As with Slash Commands, there are many more possibilities for what a command can do. The ctx parameter (a Context instance) contains loads of information about the user sending the command, the channel where it was sent, the contents of their message, whether they were replying to someone else, and so on.

Syncing the command tree#

Remember that Slash command from before? Specifically, how it produced an error when we updated it initially:

/hello command showing an error message

As noted above, we must sync the command tree between the bot and the Discord server in order to update the server with information about our bot's Slash commands. Otherwise, the server will use outdated information when it prompts the user and will send an incorrect command signature to your bot, resulting in the error shown above.

Syncing the tree is a simple matter of calling the bot tree's sync command. This seems like a trivial task that should really be performed one time only, when the the bot is starting up.

However, there are some considerations to keep in mind:

  • The bot needs to be logged in first before it can attempt to sync the tree. This means bot.run needs to be running its event loop, so we cannot call this command before calling bot.run.
  • Again, the bot needs to be logged in, and that process is not immediate: we should wait until we are certain the bot successfully logged into Discord.

    Note

    We might think the on_ready event handler is a good indicator for this, and generally it is. However, this event may be sent many times throughout the lifetime of the same bot process. So, calling sync here may result in it being called more than once, which involves extra API calls to Discord that we do not need to send (and that Discord may start rejecting or rate-limiting if we send too many).

  • It is possible to run the same bot on many parallel machines, such as in a Kubernetes cluster. This is useful for running very popular bots used by many different Discord communities, so that each copy of the bot can share some of the load of those requests (just like with any other web-facing application).

If every instance of the bot were to sync its command tree during the startup phase, this, too, would result in several unnecessary API calls to Discord.

With all these issues facing any sort of automated solution, it turns out the best solution is a more manual one: our own !sync command:

@bot.command(
    description="Sync the bot tree to update Slash command changes to the server",
    guild=discord.Object(id=DISCORD_GUILD),
)
async def sync(ctx):
    """Sync the bot command tree for Slash commands."""
    print("Syncing the command tree...")
    await bot.tree.sync(guild=discord.Object(id=DISCORD_GUILD))
    await ctx.reply("✅ Command tree synced")
    print("Sync Complete!")

This is written as a "legacy" command, not a Slash command, so as to avoid the issue having to sync commands in order for a sync command to be available for us to sync commands (yo dawg...).

The bot simply watches for message content and responds to the command !sync:

!sync command responding successfully

We can also see our print() messages in the console where the bot is running, indicating when the command started and when it successfully completed:

Syncing the command tree...
Sync Complete!

Running the bot#

Finally, we have the part that actually starts our bot. Without it, the program would define the bot and these commands just fine, but then it would immediately shut down, doing nothing!

Starting the bot is a simple matter of calling bot.run with our DISCORD_TOKEN passed in to authenticate it. In our template, we have a little extra wrapped around that call:

def main():
    """Run the bot."""
    bot.run(DISCORD_TOKEN)
    print("Shutting down.")


if __name__ == "__main__":
    main()
  • The main() function - not a coroutine! - is our entrypoint for the program. It's not required for any Python to have a function by this name (some other languages do require a specific main method defined as the entrypoint), but it's a solid pattern.
  • if __name__ == "__main__":, if you're unfamiliar with it, is a guard statement that prevents some code from running automatically if this module is ever imported. There's a great explanation for the use of this guard in this SO answer. We use it here as the point where main() is actually being called, in order to start the program.
  • bot.run() starts the event loop in discord.py, connecting to the Discord API and handling sessions, authentication, etc. Within our main() function, the control flow mostly stays inside this line of code.
  • Whenever the bot shuts down (such as when you press Ctrl+C in the terminal to stop it), the event loop in bot.run exits, and the last line, print("Shutting down"), will finally run. After this, the program exits naturally.