Module Development Documentation

How to make a cog show up on the dashboard as a module, and how to give it settings. This page is for contributors. The examples come from Melvin's own cogs, mostly the thanks cog, since it uses every part of this.

How modules are loaded

Each module is a single cog file. The cog declares the module with a MODULE at the top of the file, and the bot and the website read it from there.

File What it does
cogs/<key>.py The cog, and its MODULE, with the label, description, and settings.
module_registry.py Finds every MODULE in cogs/, and lists the files the bot loads.
module_settings.py Stores whether each module is on or off per guild.
module_config.py Checks setting values and stores them per guild.
ui/config.py The settings card every /<module> config command replies with.
dashboard.py The website dashboard, built from the files above.

Every file in cogs/ is loaded when the bot starts, except files whose name starts with an underscore, like _template.py. Files without a setup function are also skipped.

The bot and the website both read and write the same database at data/modules.db. The bot caches it and re-reads it every 10 seconds, so a change made on the dashboard reaches the bot within that time.

Using the template

cogs/_template.py is a blank module that includes everything below: the MODULE, the GroupCog, the server only decorators, a config command, a listener that checks the module, and cleanup for when Melvin leaves a guild. It isn't loaded, since its name starts with an underscore. To start a new module:

  1. Copy it to cogs/<key>.py, without the underscore.
  2. Replace template with the new key.
  3. Fill in MODULE.
  4. Restart the bot.

The same steps are listed at the top of the file. New modules should also have a docs page in docs/.

Making a cog a module

A module is a cog that can be turned on or off per guild, from /modules or the dashboard.

1. Use a GroupCog The module's key is the cog's command group name, so the cog has to be a commands.GroupCog.

class ThanksCog(
    commands.GroupCog,
    name="thanks",
    description="'Thank you' count tracking.",
):

2. Declare MODULE Under the imports in the cog's file, add a MODULE with the group name as the key, a label, and a short description. The label and description are shown on /modules and on the dashboard card. Modules are listed alphabetically by label.

from module_registry import Module

MODULE = Module(
    "thanks",
    "Thanks",
    "Counts thanks between members.",
)

The module then has a switch on /modules and the dashboard. When it's turned off, the bot automatically blocks every command in the group, including subcommands and any message options the cog adds, and leaves the group out of /help in that guild.

3. Check it in listeners Listeners aren't commands, so they aren't blocked automatically. Every listener that does something in a guild should check the module first, like the thanks cog does before counting a thank.

@commands.Cog.listener()
async def on_message(self, message: discord.Message) -> None:
    if message.author.bot:
        return

    if message.guild is not None and not await module_settings.is_enabled(
        message.guild.id,
        "thanks",
    ):
        return

If every listener goes through the same lookup, put the check there once instead. The audit cog checks inside get_log_channel, since all 15 of its listeners call it.

Listeners that clean up after the bot leaves a guild, like on_guild_remove, should run whether the module is on or not.

A module with no commands, like Server Stats, uses a plain commands.Cog instead, named after its module key. A GroupCog with no commands would show up in Discord as a command that does nothing. Its listeners still check is_enabled like any other module.

Where commands can be used Every command works in servers, in DMs, and when Melvin is added to someone's account as a user app, unless it says otherwise. That's set once for the whole bot in main.py. Commands that only make sense in a server, like moderation or welcome messages, should say so with these two decorators on the cog class, or on the command if it isn't in a group. Discord only reads them on top level commands and groups, so they do nothing on subcommands.

@app_commands.allowed_installs(guilds=True, users=False)
@app_commands.allowed_contexts(guilds=True, dms=False, private_channels=False)
class ModCog(
    commands.GroupCog,
    name="mod",
    description="Guild moderation commands.",
):

Don't use app_commands.guild_only for this. The bot wide setting in main.py overrides it, so the commands would still show up in DMs.

What isn't a module Commands outside a group, like /help, /latency, and /melvin, can't be turned off. Neither can the stats and private cogs, since they don't declare a MODULE.

Importing cog files The website imports each cog file to read its MODULE, without loading the cog or connecting to Discord. Anything that needs the bot, a token, or an API key should be in __init__ or cog_load, not at the top level of the file. Constants and imports at the top level are fine.

Making a module configurable

A configurable module gets a /<module> config command and a Config button on its dashboard card. Both are built from the same list of settings, so each setting is only defined once.

1. Add settings to MODULE Add a settings tuple to MODULE, in the order the settings should appear.

from module_registry import Module, Setting

MODULE = Module(
    "thanks",
    "Thanks",
    "Counts thanks between members.",
    settings=(
        Setting(
            "announce",
            "Thank messages",
            "Whether Melvin posts a message when someone is thanked. Thanks are counted either way.",
            "choice",
            default="on",
            choices=(("on", "On"), ("off", "Off")),
        ),
        Setting(
            "cooldown",
            "Cooldown",
            "How long each member waits between giving thanks.",
            "choice",
            default="60",
            choices=(
                ("30", "30 seconds"),
                ("60", "1 minute"),
                ("300", "5 minutes"),
                ("600", "10 minutes"),
            ),
        ),
    ),
)

A setting that needs more than Manage Server says so with permission, like the moderation auto-role.

(
    Setting(
        "auto_role",
        "Auto-role",
        "Given to members when they join. Leave empty for none.",
        "role",
        permission="manage_roles",
    ),
)

These are the fields a setting can have.

Field Description
key The name it's stored under. Keep it short and never rename it, since saved values use it.
label The name shown on the card and the dashboard.
description One or two sentences explaining what it does.
kind What type of value it is, see the table below.
default The value used until a guild changes it. Leave it out for no value.
choices For choice settings, pairs of the stored value and the label shown.
max_length For text settings, the longest value allowed. Over 100 gets a larger text box.
placeholder Example text shown in empty boxes.
permission A Discord permission needed on top of Manage Server, like "manage_roles".
channel_type For channel settings, which channels are offered. "text" by default, or "any" for text and announcement channels.

Each kind of setting gets its own controls on both sides, and is stored as text.

Kind In Discord On the dashboard Stored as
channel Channel select menu Dropdown of channels The channel id
channels Channel select menu that takes up to 25 A checkbox for each channel The channel ids, joined by commas
role Role select menu Dropdown of roles Melvin and the user can give out The role id
choice Select menu Dropdown The chosen value
text Edit button and form Text box The text
url Edit button and form Link box The link, which must start with http:// or https://
color Edit button and form Text box with color swatches Hex codes joined by a dash, like F4A261-FFFFFF
image Edit button and form with an upload and a remove checkbox File upload with a preview The file's path in data/config_images

Values are checked the same way on both sides by clean_value, so a value that's rejected in Discord is also rejected on the dashboard. If a setting needs a kind that isn't listed, add it to clean_value, ui/config.py, and the field markup in templates/dashboard_config.html.

2. Add the config command Every config command looks the same. Keep the name config, keep the Manage Server check, and reply through open_config.

from ui import open_config


@app_commands.command(
    name="config",
    description="Change thanks settings for this server.",
)
@app_commands.checks.has_permissions(manage_guild=True)
async def config(self, interaction: discord.Interaction) -> None:
    await open_config(interaction, "thanks")

Errors are handled globally in main.py (MelvinTree.on_error), so the cog doesn't need its own handler. A missing permission replies with You do not have permission to do this., and a missing bot permission names what Melvin needs.

3. Read the settings Read settings wherever the cog needs them. Values come back as text, or None when a setting has no value and no default, so turn ids and numbers into the right type first. The thanks cog reads both of its settings before crediting a thank.

if message.guild is not None:
    settings = await module_config.get_all(message.guild.id, "thanks")
    cooldown = float(settings["cooldown"] or DEFAULT_COOLDOWN)
    announce = settings["announce"] != "off"
else:
    cooldown, announce = DEFAULT_COOLDOWN, True

Use module_config.get(guild_id, module, key) to read a single setting, like the audit cog does for its log channel.

channel_id = await module_config.get(guild_id, "audit", "log_channel")
return self.bot.get_channel(int(channel_id)) if channel_id else None
``` Reads are cached, so it's fine to read settings in busy listeners like `on_message`.

**4. Document it**
Add a `## /<module> config` section to the cog's docs page with a table of its settings, following `docs/thanks.md`.

## Reacting to changes
Most cogs just read their settings when they need them, so changes apply on their own. Some settings have to change something on Discord the moment they're saved, like the style cog setting Melvin's name.

For changes made in Discord, pass a function to `open_config`. It's called with the guild after every change.

```python
await open_config(interaction, "style", on_change=self.apply_style)

The dashboard can't reach the bot directly, so for changes made there, check for recently changed guilds in a loop. changed_since returns the guilds with a setting changed after a time.time() timestamp. The style cog does this every 15 seconds in sync_styles, and remembers what it last sent so unchanged styles aren't sent again.

Moving old settings

If a cog already stored settings in its own table, move them over once in cog_load with migrate_legacy. It copies each row into the shared settings, then renames the old table to <table>_migrated instead of deleting it. The audit cog used to keep its log channels in a log_channels table.

async def cog_load(self) -> None:
    await module_config.migrate_legacy(
        self.db_path,
        "log_channels",
        "audit",
        lambda row: (int(row[0]), {"log_channel": row[1]}),
    )

The welcome cog does the same with its welcome_channels table, mapping each column to one of its settings.

The function receives each row of the old table, and returns the guild id with a dictionary of setting keys and values. Empty values are skipped.

Documenting commands

Every cog has a page in docs/ named after its command group, like docs/thanks.md for /thanks. Commands that aren't in a group, like /help, go in docs/melvin.md. The website lists every page in docs/ on its own.

Headings Each command's heading is its full path, the same as it's typed in Discord. The website turns each heading into a link to that part of the page, and /help links every command to its heading, so a heading that doesn't match its command leaves a broken link.

Command Heading Link
/thanks config ## /thanks config /docs/thanks#thanks-config
/mod role add ### /mod role add /docs/mod#mod-role-add
/latency ## /latency /docs/melvin#latency

Commands in a subgroup go under a heading for the subgroup, like ## /mod role, followed by a sentence on what it covers. Anything that isn't a command, like Storage or the thanks trigger phrases, gets a plain heading instead.

The links are built by docs_url in ui/views.py, from the website address in globals.py. Cogs whose commands are only for developers, like private, are listed in HIDDEN_COGS there, so /help leaves them out.

Under each heading Start with the command's description, then add these sections in order, leaving out any that don't apply.

Section What it covers
**Parameters** A table of each parameter's name, type, whether it's required, and a description, or None.
**Permissions** What the user needs, and whether it's guild only.
**Behavior** What the command does, including its checks and replies.
**Settings** For config commands, a table of each setting and its default.
**Example** An input and the output it gives.
**Error handling** What happens when something goes wrong.

docs/info.md uses all of these except Settings, and docs/thanks.md has a config command.

Adding a dashboard section

Server pages have a sidebar of sections, which only has Modules for now. To add one:

  1. Add the page's endpoint and label to GUILD_SECTIONS in dashboard.py. Modules is listed as ("dashboard.modules", "Modules").
  2. Add a route under /<int:guild_id>/. Load the user's servers with manageable_guilds(), find the server with _find_guild, and reply with a 404 if it isn't there, so the page never reveals servers the user can't manage.
  3. Pass sections=_sidebar(guild_id, ...) with the page's endpoint to the template, and include _dashboard_sidebar.html in it, like dashboard_guild.html does.

Anything that saves must check the CSRF token with _check_csrf(), and re-check the user's permissions with manageable_guilds(max_age=WRITE_CHECK_SECONDS, strict=True) before saving. That way someone who has lost Manage Server can't keep changing settings with an old page.

Saving The dashboard saves in one of two ways, depending on what's being changed. A single on or off choice saves the moment it's clicked, like the switches on the Modules page. Each switch is its own small form, and the page's script sends it in the background, so it still works without the script. The server replies with JSON when the request has an X-Requested-With: fetch header, and redirects back to the page otherwise. Anything else is a form with a Save changes button, like the config pages. Every field is checked before anything is saved, and if any of them are wrong, nothing is saved and each problem is shown under its field.

Banners Status messages use the banner macro in templates/_banner.html, so they look the same everywhere. Import it at the top of the template, then call it with a kind and a message.

{% from "_banner.html" import banner %}

{{ banner("success", "Saved. Changes apply in Discord within a few seconds.") }}
Kind Color Use it for
success Secondary, the green in globals.py Something worked, like a save.
error Tertiary, the red in globals.py Something failed or needs fixing.
misc Primary, the orange in globals.py Anything else worth pointing out, like a module being turned off.

Banners are only for what just happened or needs attention. Descriptions of how a page works use the plain dashboard-notice style instead. To fill in a banner from a script, render it hidden with an id, like banner("error", "", id="dashboard-error", hidden=True), then set its text and unhide it.

Charts

The status page and the dashboard's Server Stats page share their charts. The shapes are worked out in Python by charts.py, and the tooltips are added by static/js/charts.js, so a new chart only needs its data and some markup.

Drawing a chart Charts are SVG polygons drawn in a fixed size, which the page stretches to fit its card. scale turns values into heights, then area_points gives the filled shape. Server Stats scales its counts from zero, while the status page fits the scale to its values so small changes still show.

member_ys = charts.scale(members, min(members), max(members), height)
points = charts.area_points(member_ys, width, height)

For a chart with two series, scale both with the same range and draw each with area_points, so they're layered over each other rather than stacked. Put the series-2 polygon first in the SVG, so the series-1 polygon sits on top of it. The status page's gateway and API chart and the messages and voice minutes chart are both drawn this way. For the tooltip heights, pass whichever series is taller at each point, so the tooltip sits on the top edge.

Tooltips charts.tooltip_points builds each point's tooltip, from a label for when it was, the height it sits at, and its values. Each series is a label, its values, a unit, and a key, where series-1 is the main orange and series-2 the lighter one.

charts.tooltip_points(whens, member_ys, height, [("Members", members, "", "series-1")])

In the template, give the chart's element the status-chart class and pass the tooltip points as data-points, then load charts.js once on the page. Hovering, tapping, and keyboard stepping all work from there. Pages that refresh themselves can check window.melvinCharts.isActive() first, so they don't replace a chart while someone is reading its tooltip.

<div class="status-chart" tabindex="0" role="img" aria-label="..." data-points='{{ members.tooltip | tojson }}'>
    <svg viewBox="0 0 {{ width }} {{ height }}" preserveAspectRatio="none" aria-hidden="true">
        <polygon points="{{ members.points }}" class="status-chart-series-1"></polygon>
    </svg>
</div>

Checklist

  • The cog is a GroupCog, and its MODULE uses the group name as its key.
  • Nothing at the top level of the file needs the bot, a token, or an API key.
  • Every listener that acts in a guild checks module_settings.is_enabled.
  • Its settings are in MODULE, with a description and default for each.
  • It has a config command that replies through open_config, with the Manage Server check.
  • It reads settings with module_config, never from its own table.
  • Old settings are moved with migrate_legacy, if there were any.
  • Its docs page has a heading for every command, written as its full path, like ## /thanks config.