Creating a Module
A module in OmniBot is a self-contained functional unit that can be installed and uninstalled per Discord server. Each module can contain commands, event listeners, interaction handlers, services, configuration, and database models.
Scaffolding a Module
Don't start from scratch. Generate a working module skeleton (definition, config schema, one command, one listener, i18n/en+fr.json, a commented Prisma model, and a test):
pnpm new-module my-module "My Module"The generated module compiles and its test passes unmodified. Fill the TODO description, then enable it via /modules on your dev guild. No registration step exists: modules are auto-discovered from src/modules/ at startup.
For tests, use the shared helpers from #lib/testing.js (makeTestConfig, fakeGuild, fakeMessage, initTestI18n) instead of hand-rolled mocks. Note that vi.mock("#core/runtime.js") and vi.mock("#lib/database.js") must still be declared per test file — Vitest hoists them, so no helper can hide them.
Module Structure
src/modules/my-module/
├── my-module.module.ts # Main module definition
├── i18n/ # Translation files
│ ├── en.json
│ └── fr.json
├── commands/ # Slash commands
│ └── greet.command.ts
├── listeners/ # Event listeners
│ └── message-create.listener.ts
├── interactions/ # Button, modal, select handlers
│ ├── confirm.button.ts
│ └── feedback.modal.ts
├── services/ # Business logic
│ └── my-service.service.ts
└── models/ # Prisma schemas (optional)
└── my-model.prismaStep-by-Step: Creating a "Greeter" Module
TIP
The module's name and description should be in English by default. They serve as the fallback when no translation file matches the guild's locale. Add translations in i18n/ files for other locales.
1. Create the directory and main file
// src/modules/greeter/greeter.module.ts
import logger from "#lib/logger.js";
import { defineModule } from "#lib/module.js";
export default defineModule({
id: "greeter",
name: "Greeter",
description: "Welcomes new members and says hello.",
version: "1.0.0",
author: "You",
onLoad(_client, registry) {
logger.info("Greeter module loaded");
},
onInstall(_client, guild) {
logger.info(`Greeter installed on ${guild.name}`);
},
onUninstall(_client, guild) {
logger.info(`Greeter uninstalled from ${guild.name}`);
},
});2. Add a slash command
// src/modules/greeter/commands/hello.command.ts
import { SlashCommandBuilder } from "discord.js";
import { declareCommand } from "#lib/command.js";
export default declareCommand({
data: new SlashCommandBuilder()
.setName("hello")
.setDescription("Says hello!"),
async execute(interaction) {
await interaction.reply(`Hello, ${interaction.user.username}!`);
},
});3. Register the command in the module
// src/modules/greeter/greeter.module.ts
import helloCommand from "./commands/hello.command.js";
export default defineModule({
// ... id, name, etc.
onLoad(_client, registry) {
registry.register(helloCommand);
logger.info("Greeter module loaded");
},
// ...
});4. Run and test
Start the bot (see Getting Started), install the Greeter module via /modules, then run /hello.
ModuleDeclaration API
interface ModuleDeclaration {
id: string; // Unique kebab-case identifier
name: string; // Display name shown in UI
description: string; // Description shown in UI
version: Version; // Semver "x.y.z"
author?: string; // Optional author name
intents?: GatewayIntentBits[]; // Discord gateway intents
partials?: Partials[]; // discord.js partials, e.g. events on uncached messages
devOnly?: boolean; // Only loaded in dev mode
config?: ConfigSchema; // Configuration schema
onLoad: (client: Client, registry: Registry) => void;
onInstall: (client: Client, guild: Guild, registry: Registry) => void;
onUninstall: (client: Client, guild: Guild, registry: Registry) => void;
maintenanceTasks?: () => ScheduledTask[];
deleteMemberData?: (guildId: string, userId: string) => Promise<void>;
}Lifecycle Hooks
| Hook | When | Typical Use |
|---|---|---|
onLoad | Bot startup | Register commands, listeners, interactions via registry.register() |
onInstall | Module enabled on a guild | Create roles, send welcome messages, initialize data |
onUninstall | Module disabled on a guild | Clean up data, remove roles, delete configurations |
deleteMemberData | A member runs /privacy delete on a guild | Delete every row the module keeps about that member on that guild |
A module that stores anything tied to a member's ID must implement deleteMemberData, and say what it stores in the privacy policy (docs/site/*/legal/privacy.md). It runs whether or not the module is enabled on the guild, and also for a module skipped because Discord has not granted its privileged intent.
Purges of stored data belong in maintenanceTasks, not in tasks registered from onLoad: they are scheduled for every discovered module, including one skipped because Discord has not granted its privileged intent, so the retention periods of the privacy policy hold in every case.
Intent Management
Only declare the intents your module actually needs. Required intents must also be enabled in the Discord Developer Portal under Bot > Privileged Gateway Intents.
At startup the bot reads which privileged intents (GuildMembers, GuildPresences, MessageContent) the application has been granted, in full or limited form. A module that declares one the application lacks is skipped with a warning instead of making Discord reject the connection, and /modules shows it as unavailable, naming the missing intent; it loads again on the first restart after the intent is granted. If the flags cannot be read, every module is loaded as before. The core cannot be skipped: if it ever declares a privileged intent that is not granted, the bot exits at startup with an error naming the intent.
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMessages,
GatewayIntentBits.MessageContent,
],How Auto-Discovery Works
The module loader (src/core/loaders/module-loader.ts) scans src/modules/ at startup:
- Lists each subdirectory
- Finds a
*.module.tsfile inside - Imports it dynamically
- Checks the export has
type: DeclarationType.Module - Returns the
Moduleobject
Dev-only modules (with devOnly: true) are skipped when NODE_ENV is not "development". Use this for test or debug modules.
Internationalization
Modules can provide translations for their interface strings (name, description, config fields, and bot messages) using per-module locale files.
Adding translations
Create an i18n/ directory in your module with one JSON file per locale:
src/modules/my-module/
├── my-module.module.ts
├── i18n/
│ ├── en.json
│ └── fr.json
└── commands/
└── ...The module loader auto-discovers these files at startup and registers them with i18next.
Translation file format
Each file contains key-value pairs. Config field name/description are automatically resolved from these files, overriding the TypeScript defaults when a matching locale is active. The module's own name and description are translated in the core files instead, under modules.<id>.name and modules.<id>.description (see the localization guide):
{
"config.myField.name": "My Field",
"config.myField.description": "Description of my field.",
"greeting": "Hello {{name}}!"
}Using translations in code
The ConfigProvider exposed to commands, listeners, and interactions provides a t() method:
async execute(interaction, config) {
const greeting = config.t("greeting", { name: interaction.user.username });
await interaction.reply(greeting);
}Keys are looked up in the module's own namespace first, then fall back to core translations. Common UI labels (config.previous, config.next, modules.enable, etc.) are provided by the core namespace — no need to redefine them in every module.
Locale selection
The guild's locale is configured via the core module's settings (/config core > locale). When a locale file doesn't exist for the selected language, the system falls back to English.
Publishing Checklist
Before your module reaches production guilds:
- Bump
versionwhen you add, rename, or change a command — production re-registers guild commands only on a version change (dev re-syncs every boot, so this is easy to miss locally) - List removed or renamed commands in
retiredCommands(old names) along with the version bump — the production update only deletes the guild commands the module still names, so without it the old command stays on every server - Set
DEV_GUILD_IDin.env— without it, dev mode registers no commands at all - Prefix command names and
customIds with your module id — both are matched first-come-first-served across all modules, and a:inside an argument shiftscustomIdparsing - Guard listeners with
if (!config) return— listeners run withundefinedconfig outside guilds (DMs) - Put
requiresAdmin: trueon every sensitive command and interaction handler — fail-open otherwise - Never put an object in an entity
defaultValue(USER/ROLE/CHANNEL/CATEGORYdefaults must stay unset; ids are stored, objects are hydrated) - Declare
ENUMoptionsas constfor literal-union typing ofconfig.get() - Prisma order: write
models/*.prisma, thenpnpm prisma:generate, thenpnpm prisma:migrate— and keep model names unique across all modules - No top-level side effects —
devOnlymodules are still imported in production; only their registration is skipped
Best Practices
- Keep
onLoadlean — register artifacts and log, move logic to services - Use kebab-case for module IDs and folder names
- Handle errors in lifecycle hooks — use try/catch in
onInstall/onUninstall - Keep files organized — one artifact per file, use subdirectories
- Declare only needed intents — minimize privileged intent usage
Next Steps
- Commands — Add slash commands with options and autocomplete
- Listeners — React to Discord events
- Interactions — Buttons, select menus, and modals
- Configuration — Declare a typed configuration schema
- Services — Organize business logic
- Database — Persist data with Prisma