Organising a discord.py bot with cogs
A single bot.py is fine until it is 900 lines long. Cogs are discord.py's way of splitting it into files, and the only tricky part is the order things load in.
8 minute read
For discord.py 2.x. Two words that get used loosely: a cog is a class that groups commands and listeners; an extension is a Python file that discord.py can load, which usually adds one cog.
The layout
my-bot/
bot.py
requirements.txt
cogs/
__init__.py (empty)
fun.py
moderation.py
A cog
# cogs/fun.py
import random
import discord
from discord import app_commands
from discord.ext import commands
class Fun(commands.Cog):
def __init__(self, bot: commands.Bot):
self.bot = bot
@app_commands.command(description="Roll a die")
async def roll(self, interaction: discord.Interaction, sides: int = 6):
await interaction.response.send_message(f"You rolled {random.randint(1, sides)}")
@commands.command()
async def hello(self, ctx: commands.Context):
await ctx.send(f"Hello {ctx.author.mention}")
@commands.Cog.listener()
async def on_member_join(self, member: discord.Member):
channel = member.guild.system_channel
if channel:
await channel.send(f"Welcome, {member.mention}!")
async def setup(bot: commands.Bot):
await bot.add_cog(Fun(bot))
- Every command and listener takes
selffirst. Forgetting it gives confusing errors about the wrong number of arguments. - Slash commands in a cog use
@app_commands.command, not@bot.tree.command. Prefix commands use@commands.command. - Events use
@commands.Cog.listener(), and the method name is the event name. - The
setupfunction at the bottom is what makes the file an extension. It must beasyncin discord.py 2. on_member_joinneeds the Server Members intent in your code and in the Developer Portal.
Loading them
# bot.py
import os
import discord
from discord.ext import commands
EXTENSIONS = ["cogs.fun", "cogs.moderation"]
class MyBot(commands.Bot):
def __init__(self):
intents = discord.Intents.default()
intents.message_content = True # for prefix commands
intents.members = True # for on_member_join
super().__init__(command_prefix="!", intents=intents)
async def setup_hook(self):
for ext in EXTENSIONS:
await self.load_extension(ext)
await self.tree.sync() # after loading, or the cogs' slash commands are missing
bot = MyBot()
bot.run(os.environ["BOT_TOKEN"])
The order matters. tree.sync() uploads whatever commands exist at that moment. Sync before loading the extensions and Discord gets a list without them.
Extension names are module paths with dots (cogs.fun), not file paths (cogs/fun.py). They are resolved from the folder the bot is started in, which is why a bot that works when run from its own folder fails when started from somewhere else.
Loading every file in the folder
import pathlib
async def setup_hook(self):
for path in sorted(pathlib.Path(__file__).parent.joinpath("cogs").glob("*.py")):
if path.stem != "__init__":
await self.load_extension(f"cogs.{path.stem}")
await self.tree.sync()
Reloading without restarting
An owner-only command that reloads a cog after you edit it:
@commands.command()
@commands.is_owner()
async def reload(self, ctx: commands.Context, name: str):
await self.bot.reload_extension(f"cogs.{name}")
await ctx.send(f"Reloaded {name}")
Reloading picks up changed Python code. It does not re-sync slash commands: if you added, renamed or removed one, run a sync as well (see slash commands in discord.py). If the bot's hosting pulls from Git on restart, a plain restart does both.
Shared things: a database, an HTTP session
Create shared objects once on the bot, and reach them from any cog through self.bot:
import aiohttp
class MyBot(commands.Bot):
async def setup_hook(self):
self.http_session = aiohttp.ClientSession()
for ext in EXTENSIONS:
await self.load_extension(ext)
await self.tree.sync()
async def close(self):
await self.http_session.close()
await super().close()
Then in a cog: async with self.bot.http_session.get(url) as r: .... Opening a new session for every command leaks connections and eventually memory.
Errors you will meet
ExtensionNotFound: Extension 'cogs.fun' could not be loaded: the path is wrong, or the bot was started from another folder. Check the folder has__init__.pyand that you runpython bot.pyfrommy-bot.NoEntryPointError: the file has nosetupfunction, or it is notasync.ExtensionAlreadyLoaded:load_extensionran twice, often because it was put inon_ready, which can fire more than once. Usesetup_hook.- RuntimeWarning: coroutine 'Bot.add_cog' was never awaited: old discord.py 1.x code. In 2.x both
add_cogandload_extensionneedawait. - Slash commands from a cog missing: synced before loading, or the cog raised an error while loading that was scrolled past. Read the console from the top of the start.