Architecture
This page explains how OmniBot works under the hood. Understanding this will help you create better modules and debug issues.
Boot Sequence
When the bot starts (src/index.ts), it follows this sequence:
1. Database health check
└─ prisma.$queryRaw`SELECT 1`
└─ exits with error if DB is unavailable
2. i18n initialization
└─ initI18n() — initializes the i18next engine with fallback locale support
└─ loadModuleI18n("core") — loads core translation files (en.json, fr.json)
3. Module discovery
└─ loadModules("./modules")
└─ scans each subdirectory in src/modules/
└─ imports the *.module.ts file
└─ skips devOnly modules in production
4. Intent aggregation
└─ fetchApplicationFlags() — reads the privileged intents granted to the application
└─ skips (with a warning) every module needing a privileged intent that is not granted
└─ collects GatewayIntentBits from the remaining modules
└─ creates the Discord Client with the union of all intents and partials
5. ClientReady handler (async)
├─ For each module:
│ ├─ module.onLoad(client, registry)
│ └─ loadModuleEvents(client, module)
├─ coreModule.onLoad(client, coreModule.registry)
├─ syncCommands(client, modules)
│ ├─ Dev mode: bulk PUT on DEV_GUILD_ID
│ └─ Prod mode: version-gated guild commands + global core commands
└─ loadGlobalEvents(client)
6. Login
└─ client.login(token)
7. Shutdown handlers
└─ SIGTERM / SIGINT → client.destroy() + prisma.$disconnect()Module Auto-Discovery
Modules are not registered manually. The loader (src/core/loaders/module-loader.ts) discovers them automatically:
- Lists all subdirectories in
src/modules/ - For each directory, finds a file matching
*.module.ts(or*.module.js) - Dynamically imports the module
- Validates that it has
type: DeclarationType.Module - Returns a
Moduleobject with itsRegistry
This means adding a new module is as simple as creating a folder with a *.module.ts file. No configuration file to edit, no imports to add.
Registry & Declared Pattern
Each module has its own Registry instance (src/lib/registry.ts) that collects three kinds of artifacts:
commands: Declared<Command>[]listeners: Declared<EventListener>[]interactionHandlers: Declared<InteractionHandler>[]
The register() method uses a discriminated union — it checks handler.type (from the DeclarationType enum) and pushes into the correct array.
Every dynamically imported file (command, listener, interaction) is wrapped with a declare*() function that tags it with its DeclarationType. This allows the loaders to identify what kind of artifact they're dealing with without naming conventions or configuration.
// The declared pattern
export enum DeclarationType {
Module = "module",
Command = "command",
Listener = "listener",
Interaction = "interaction",
Service = "service",
}Centralized Interaction Dispatch
A single interactionCreate listener (src/core/listeners/interaction-create.listener.ts) handles all user interactions:
interactionCreate
├─ isChatInputCommand()
│ ├─ find command by name across all modules
│ ├─ check module activation state (DB)
│ ├─ load config (ConfigProvider)
│ └─ execute(interaction, config)
├─ isAutocomplete()
│ ├─ find command
│ ├─ check activation
│ └─ complete(interaction, config)
├─ isMessageComponent() or isModalSubmit()
│ ├─ split customId on ":" → prefix + args
│ ├─ find handler by prefix
│ ├─ check module activation
│ ├─ if access === "admin" → check Administrator permission
│ ├─ run type guard (check())
│ └─ execute(interaction, args, config)This centralised approach means:
- Permissions are checked in one place (the required
accessfield onInteractionHandler) - Module activation is verified automatically
- Config injection happens transparently
Command Propagation
Development Mode
When NODE_ENV=development, all commands (core + enabled modules) are registered in a single PUT on DEV_GUILD_ID. This is instant — no propagation delay. Every bot restart re-syncs all commands.
Production Mode
- Core commands (
/modules,/config): registered globally (~1 hour propagation). - Module commands: registered per guild. To avoid re-registering on every startup, the system uses version gating: it compares the module's declared version with the
activatedVersionstored in the database. Commands are only re-registered on guilds where the version differs.
Config System Overview
The configuration system has several layers:
- Schema declaration — Modules declare typed schemas in
defineModule({ config: {...} }) - Storage — All configs are stored as a single JSON blob per guild in the
GuildConfigurationtable - Cache —
ConfigServiceholds an in-memory cache (configCache) to avoid DB reads on every interaction - Deserialization — Entity IDs (user, role, channel) are stored as strings and resolved to Discord objects at read time
- Localization — Field names and descriptions are resolved from the module's i18n files (falling back to English defaults in the TypeScript schema)
- Admin UI —
/config <module>renders an interactive panel with per-field edit controls
Service Layer
Unlike modules, services are not auto-discovered. They are plain TypeScript classes or objects tagged with declareService(). You import them directly where needed:
import queue from "../services/thread-creation-queue.js";This keeps the service layer simple and dependency-free.
Listener Auto-Activation
When a module listener is registered, the loader wraps it with logic that:
- Extracts the
guildIdfrom the event arguments - Looks up the module's activation state in the database
- If disabled → silently returns (no-op)
- If enabled → fetches the config and calls
execute()with the config as the last argument
For events that can occur outside a guild (e.g., DMs), the listener must handle activation checks manually — there's no guild context to look up.