Skip to content

🤖 Adding a Cog

This page documents how to add a new Discord.py cog to the Magikal bot safely.

It is based on the current live bot structure.

Source files checked

The current live bot loads cogs from:

/home/magikalbot/magikal-bot/bot/main.py

Cogs live in:

/home/magikalbot/magikal-bot/bot/cogs/

A recent current-pattern cog used for reference:

/home/magikalbot/magikal-bot/bot/cogs/tempvc_v2.py

Current cog loading list

The bot startup extension list is in bot/main.py.

Current loaded extensions include:

  • bot.cogs.moderation
  • bot.cogs.autoroles
  • bot.cogs.reaction_roles
  • bot.cogs.tempvc_v2
  • bot.cogs.basic
  • bot.cogs.admin_config
  • bot.cogs.diag_storage
  • bot.cogs.tickets
  • bot.cogs.shipinfo_uex
  • bot.cogs.market_uex
  • bot.cogs.embed_tools
  • bot.cogs.rsi_profile
  • bot.cogs.welcome
  • bot.cogs.diag_commands
  • bot.cogs.help_auditor
  • bot.cogs.help_panel
  • bot.cogs.event_modal
  • bot.cogs.automod
  • bot.cogs.modlog_events
  • bot.cogs.voice_events
  • bot.cogs.banking
  • bot.cogs.recruit_config
  • bot.cogs.events
  • bot.cogs.xp
  • bot.cogs.command_dump
  • bot.cogs.roles_config
  • bot.cogs.rsi_status
  • bot.cogs.ai_assistant

Keep the load list intentional

Do not add a cog to initial_extensions unless it should load automatically on the live bot.

Experimental or unfinished cogs should not be silently added to startup.

Basic cog pattern

A normal cog should use the Discord.py extension pattern:

class ExampleCog(commands.Cog):
    def __init__(self, bot: commands.Bot):
        self.bot = bot
        self.config_storage = bot.config_storage

async def setup(bot: commands.Bot):
    await bot.add_cog(ExampleCog(bot))

A typical cog starts with:

import discord
import structlog
from discord.ext import commands
from discord import app_commands

from ..utils.hasher import make_pid
from ..utils.embeds import MagikalEmbeds

logger = structlog.get_logger(__name__)

Only import what the cog actually needs.

Store bot references clearly

In __init__, cache the bot and shared adapters:

def __init__(self, bot: commands.Bot):
    self.bot = bot
    self.config_storage = bot.config_storage

If the cog needs runtime state, use the bot state adapter:

self.state_storage = bot.state_storage

State storage is an adapter

Do not hardcode assumptions about whether state is Postgres, Redis, or in-memory.

Use the adapter exposed by bot.state_storage.

Config access pattern

Normal cogs should use:

self.config_storage

or:

self.bot.config_storage

Avoid direct database sessions in normal cogs.

Good:

settings = await self.config_storage.get_tempvc_afk_settings(guild_id)

Avoid in new cogs unless explicitly approved:

self.db = bot.database_manager
async with self.db.session_factory() as session:
    ...

Optional config helper pattern

For cogs that need to support slightly different storage method shapes, a helper can be used.

Pattern seen in current TempVC:

async def _cfg(self, method: str, *args, **kwargs):
    fn = getattr(self.config_storage, method, None)
    if fn is None:
        raise AttributeError(f"config_storage missing {method}")
    res = fn(*args, **kwargs)
    return await res if inspect.isawaitable(res) else res

Keep this simple.

Do not use a fallback to raw database manager unless there is a clear reason.

State helper pattern

For cogs that use runtime state:

async def _state(self, method: str, *args, **kwargs):
    fn = getattr(self.state_storage, method, None)
    if fn is None:
        return None
    res = fn(*args, **kwargs)
    return await res if inspect.isawaitable(res) else res

Use this only where the cog genuinely needs state storage.

Slash commands

Use slash commands for modern user-facing interactions.

Example shape:

@app_commands.command(name="example", description="Short clear description.")
@app_commands.guild_only()
async def example(self, interaction: discord.Interaction):
    await interaction.response.send_message("Done.", ephemeral=True)

For admin-only slash commands, use suitable permissions:

@app_commands.default_permissions(manage_guild=True)
@app_commands.guild_only()

Text commands

Text commands still exist in the bot.

Example shape:

@commands.command(name="example")
@commands.guild_only()
async def example(self, ctx: commands.Context):
    await ctx.reply("Done.")

Use text commands where the existing feature area already uses text commands or where prefix command behaviour is required.

Persistent views

If a cog registers a persistent Discord view, create the cog first, then register the view.

Pattern seen in TempVC:

async def setup(bot: commands.Bot):
    cog = TempVCV2Cog(bot)
    await bot.add_cog(cog)
    bot.add_view(ControlPanelView(cog))

Persistent view rule

Persistent views must be registered during setup so buttons keep working after bot restart.

Make sure the view does not depend on stale local-only state.

Logging

Use structlog.

Pattern:

import structlog

logger = structlog.get_logger(__name__)

Use stable event names:

logger.info(event="example.started")
logger.warning(event="example.failed", error=str(e))

Do not log raw Discord IDs or Discord objects.

Privacy-safe IDs

Use:

make_pid(guild_id, user_id)

Good:

logger.info(
    event="example.action",
    guild_pid=make_pid(guild.id, guild.id),
    user_pid=make_pid(guild.id, member.id),
    actor_pid=make_pid(guild.id, interaction.user.id),
)

Avoid:

logger.info(event="example.action", user_id=member.id)

Embeds

Prefer the existing embed helper where possible:

MagikalEmbeds.success(...)
MagikalEmbeds.warning(...)
MagikalEmbeds.error(...)

This keeps bot messages consistent.

Error handling

Handle expected Discord errors cleanly.

Good:

try:
    await interaction.followup.send(...)
except discord.HTTPException as e:
    logger.warning(event="example.followup_failed", error=str(e))

Do not expose raw tracebacks to users.

Adding the cog to startup

After creating the new cog file, add it to initial_extensions in:

bot/main.py

Example:

"bot.cogs.example",

Keep the list tidy.

Do not add a half-finished cog to live startup.

Restart and check

After adding a cog, restart the bot:

sudo systemctl restart magikal-bot

Check status:

sudo systemctl status magikal-bot --no-pager

Check logs:

sudo journalctl -u magikal-bot -n 120 --no-pager

Look for:

cog.loaded

or:

cog.load_failed

Slash command sync

If the cog adds slash commands, sync may be required.

The bot has existing command sync helpers.

Common check:

sudo journalctl -u magikal-bot -n 200 --no-pager | grep -i slash

Admin command sync may also be available from Discord depending on current bot command setup.

Testing checklist

Before calling the cog done:

  • bot starts cleanly
  • cog logs as loaded
  • no traceback in logs
  • commands appear where expected
  • command permissions are correct
  • guild-only commands do not work in DMs
  • embeds look consistent
  • logs do not contain raw Discord IDs
  • database writes go through the approved storage layer
  • any new config has docs or panel support planned

Red flags

Stop and review if the cog:

  • needs a new database table
  • changes bot/database.py
  • needs Alembic
  • uses direct DB sessions
  • stores raw Discord IDs
  • introduces a new storage backend
  • adds persistent views
  • creates background tasks
  • touches Temp VC, tickets, moderation, or panel config
  • changes permission gates
  • logs user data

Rule

A new cog should be small, load cleanly, use config_storage, log safely, and avoid new architecture unless approved.