Writing cogs
A cog is one feature of your bot: a file in cogs/ with commands, listeners and background tasks. Ruby cogs are ordinary discord.py cogs. Ruby adds config, a database, string tables and a few helpers.
How cogs are loaded
When the bot starts (in setup_hook, before it connects), Ruby loads every Python file in ./cogs:
- Files starting with
_are skipped. Use_helpers.pyfor shared code that isn't a cog. - Each file needs an
async def setup(bot)that adds the cog. - If a cog fails to load (syntax error, missing config, an exception in
__init__), the error is logged and the bot carries on without it.
Because cogs load before the connection, on_ready listeners in cogs do run. But the bot's cache (channels, members, roles) is empty in __init__ and cog_load. Look things up in commands, or wait first:
@tasks.loop(minutes=5)
async def announce(self) -> None:
...
@announce.before_loop
async def before_announce(self) -> None:
await self.bot.wait_until_ready()A complete example
This cog uses everything on this page: settings, the database, strings and checks.
from typing import TYPE_CHECKING, Any
import discord
import ruby.utils
from discord.ext import commands
from discord.ext.commands import Context
from ruby.utils import error, get_string, require_guild, success
if TYPE_CHECKING:
from ruby import RubyBot
# fmt: off
DEFAULT_STRINGS = ruby.utils.DEFAULT_STRINGS | {
"WAVE_DONE": "{member} waved! That's wave number **{count}**.",
"WAVE_TOO_MANY": "You've waved enough for one day.",
}
# fmt: on
def get(string: str) -> str:
return get_string(string, default_table=DEFAULT_STRINGS)
class Waves(commands.Cog, name="waves"):
"""config.yaml:
cogs:
waves:
limit: 10 # waves per person
"""
def __init__(self, bot: "RubyBot") -> None:
self.bot = bot
self.col = bot.db["waves"]
cfg = bot.cfg.cogs.get("waves")
self.cfg: dict[str, Any] = cfg if isinstance(cfg, dict) else {}
@commands.hybrid_command(name="wave", description="Wave at the server.")
@commands.cooldown(1, 10, commands.BucketType.user)
async def wave(self, ctx: Context) -> None:
if await require_guild(ctx): return
doc = await self.col.find_one_and_update(
{"_id": ctx.author.id},
{"$inc": {"count": 1}},
upsert=True,
return_document=True,
)
if doc["count"] > self.cfg.get("limit", 10):
await error(ctx, get("WAVE_TOO_MANY"))
return
await success(ctx, get("WAVE_DONE").format(member=ctx.author.mention, count=doc["count"]))
async def setup(bot: "RubyBot") -> None:
await bot.add_cog(Waves(bot))Settings
Each cog gets its own section under cogs: in config.yaml. Ruby doesn't check what's inside, so read it defensively. The section might be missing, or not a mapping at all:
cfg = bot.cfg.cogs.get("waves")
self.cfg: dict[str, Any] = cfg if isinstance(cfg, dict) else {}
limit = self.cfg.get("limit", 10)Put the settings your cog understands in its docstring, so people know what to add to their config.
If the cog can't work without a setting, let it skip quietly rather than crash. For example, the birthdays cog only announces when channel is set:
if not self.cfg.get("channel"):
returnShared settings live elsewhere:
- Currency name:
self.bot.cfg.currency.currency - Server channels:
self.bot.server.channels["spam"] - Owners:
self.bot.cfg.owners, or better,await self.bot.is_owner(user)
Database
self.bot.db is an async PyMongo database named after name in your config. Use one collection per feature and await everything:
self.col = bot.db["waves"]
await self.col.insert_one({"_id": user.id, "count": 0})
doc = await self.col.find_one({"_id": user.id})
async for doc in self.col.find().sort("count", -1).limit(10):
...Some habits that keep data correct:
Use the user ID as
_idwhen there's one document per person. Lookups are fast and duplicates can't happen.Use atomic updates (
$inc,find_one_and_update) instead of read-modify-write, so two commands at once can't lose an update.Create indexes in
cog_load, not__init__, because it's async:pythonasync def cog_load(self) -> None: await self.col.create_index([("month", 1), ("day", 1)])
Points are shared by convention: a points collection with {"_id": user_id, "points": n}, so any cog can pay people.
Strings
Give each cog its own string table: Ruby's defaults plus your own messages. That way server owners can reword everything from config.yaml. See Strings for how it works.
Checks
discord.py's checks all work. Ruby replies with a friendly message when they fail:
@commands.has_permissions(manage_guild=True) # "You are missing the permission `manage_guild`..."
@commands.bot_has_permissions(manage_roles=True)
@commands.cooldown(1, 30, commands.BucketType.user) # "Please slow down - You can use this command again in 30 seconds"
@commands.guild_only()
@commands.is_owner()Ruby also has helpers for checks inside a command. They reply with an error and return 1 when the check fails, so the pattern is if await ...: return:
if await require_guild(ctx): # not in a server
return
if await require_normal(ctx): # not a normal server channel (thread, DM, group DM)
return
role = await get_role(ctx, 123456789012345678)
if role == 1: # not in a server (already replied)
return
if role is None: # no such role
await error(ctx, "That role doesn't exist any more.")
returnBackground tasks
Use discord.ext.tasks. Start loops in cog_load and stop them in cog_unload, so reloading the cog doesn't leave a second copy running:
async def cog_load(self) -> None:
self.announce.start()
async def cog_unload(self) -> None:
self.announce.cancel()Logging from a cog
import logging
await self.bot.log(logging.INFO, f"{member} found a secret code")See Logging.