Skip to content

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.py for 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:

python
@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.

python
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:

python
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:

python
if not self.cfg.get("channel"):
    return

Shared 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:

python
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 _id when 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:

    python
    async 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:

python
@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:

python
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.")
    return

Background 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:

python
async def cog_load(self) -> None:
    self.announce.start()

async def cog_unload(self) -> None:
    self.announce.cancel()

Logging from a cog ​

python
import logging

await self.bot.log(logging.INFO, f"{member} found a secret code")

See Logging.