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:
- Copy it to
cogs/<key>.py, without the underscore. - Replace
templatewith the new key. - Fill in
MODULE. - 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:
- Add the page's endpoint and label to
GUILD_SECTIONSindashboard.py. Modules is listed as("dashboard.modules", "Modules"). - Add a route under
/<int:guild_id>/. Load the user's servers withmanageable_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. - Pass
sections=_sidebar(guild_id, ...)with the page's endpoint to the template, and include_dashboard_sidebar.htmlin it, likedashboard_guild.htmldoes.
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
MODULEuses 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
configcommand that replies throughopen_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.