-
Notifications
You must be signed in to change notification settings - Fork 0
docs: rewrite documentation with setup instructions, architecture, contribution guide, and legal pages #150
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
095dc73
docs: rewrite guide pages with setup instructions, architecture detai…
RedsTom 9a93e78
docs: add architecture overview and contributing guide
RedsTom 3ad7e64
docs: add privacy policy and terms of service
RedsTom 0477640
docs: add new pages to sidebar navigation
RedsTom ca4816c
docs: fix dead links in contributing pages (use absolute GitHub URLs)
RedsTom 1ae8118
docs: fix copilot PR review comments
RedsTom 3112835
docs: address remaining PR review comments
RedsTom File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| # 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. Module discovery | ||
| └─ loadModules("./modules") | ||
| └─ scans each subdirectory in src/modules/ | ||
| └─ imports the *.module.ts file | ||
| └─ skips devOnly modules in production | ||
|
|
||
| 3. Intent aggregation | ||
| └─ collects GatewayIntentBits from all modules | ||
| └─ creates the Discord Client with the union of all intents | ||
|
|
||
| 4. 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) | ||
|
|
||
| 5. Login | ||
| └─ client.login(token) | ||
|
|
||
| 6. 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: | ||
|
|
||
| 1. Lists all subdirectories in `src/modules/` | ||
| 2. For each directory, finds a file matching `*.module.ts` (or `*.module.js`) | ||
| 3. Dynamically imports the module | ||
| 4. Validates that it has `type: DeclarationType.Module` | ||
| 5. Returns a `Module` object with its `Registry` | ||
|
|
||
| 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. | ||
|
|
||
| ```typescript | ||
| // 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 requiresAdmin → check Administrator permission | ||
| │ ├─ run type guard (check()) | ||
| │ └─ execute(interaction, args, config) | ||
| ``` | ||
|
|
||
| This centralised approach means: | ||
|
|
||
| - **Permissions** are checked in one place (the `requiresAdmin` flag on `InteractionHandler`) | ||
| - **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 `activatedVersion` stored in the database. Commands are only re-registered on guilds where the version differs. | ||
|
|
||
| ## Config System Overview | ||
|
|
||
| The configuration system has several layers: | ||
|
|
||
| 1. **Schema declaration** — Modules declare typed schemas in `defineModule({ config: {...} })` | ||
| 2. **Storage** — All configs are stored as a single JSON blob per guild in the `GuildConfiguration` table | ||
| 3. **Cache** — `ConfigService` holds an in-memory cache (`configCache`) to avoid DB reads on every interaction | ||
| 4. **Deserialization** — Entity IDs (user, role, channel) are stored as strings and resolved to Discord objects at read time | ||
| 5. **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: | ||
|
|
||
| ```typescript | ||
| 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: | ||
|
|
||
| 1. Extracts the `guildId` from the event arguments | ||
| 2. Looks up the module's activation state in the database | ||
| 3. If disabled → silently returns (no-op) | ||
| 4. 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,66 +1,174 @@ | ||
| # Commands | ||
|
|
||
| This guide explains how to create Discord slash commands in your modules. Commands are only available when the module is enabled on the guild. | ||
| This guide explains how to create Discord slash commands in your modules. Commands are only available when the module is enabled on the server where they're used. | ||
|
|
||
| ## Creating a command | ||
| ## Creating a Command | ||
|
|
||
| ```typescript | ||
| // src/modules/my-module/commands/test.command.ts | ||
| // 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("test") | ||
| .setDescription("A test command"), | ||
| .setName("hello") | ||
| .setDescription("Says hello!"), | ||
|
|
||
| async execute(interaction) { | ||
| await interaction.reply("Test!"); | ||
| await interaction.reply(`Hello, ${interaction.user.username}!`); | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ## Command interface | ||
| ### Command Interface | ||
|
|
||
| ```typescript | ||
| interface Command { | ||
| data: SlashCommandBuilder; // Command configuration | ||
| data: | ||
| | SlashCommandBuilder | ||
| | SlashCommandSubcommandBuilder | ||
| | SlashCommandSubcommandGroupBuilder | ||
| | SlashCommandSubcommandsOnlyBuilder | ||
| | SlashCommandOptionsOnlyBuilder; | ||
| execute: ( | ||
| // Execution function (required) | ||
| interaction, | ||
| config | ||
| interaction: ChatInputCommandInteraction, | ||
| config: ConfigProvider | ||
| ) => Promise<void>; | ||
| complete?: ( | ||
| // Autocomplete (optional) | ||
| interaction, | ||
| config | ||
| interaction: AutocompleteInteraction, | ||
| config: ConfigProvider | ||
| ) => Promise<void>; | ||
| } | ||
| ``` | ||
|
|
||
| The `config` parameter is a `ConfigProvider` giving access to the module's configuration (see [Configuration](./configuration)). | ||
| | Field | Required | Description | | ||
| | ---------- | -------- | --------------------------------------------------------- | | ||
| | `data` | Yes | The slash command definition (name, description, options) | | ||
| | `execute` | Yes | Called when a user runs the command | | ||
| | `complete` | No | Called when a user types in an autocomplete option | | ||
|
|
||
| ## Registering in the module | ||
| ### Config Access | ||
|
|
||
| The `config` parameter is a `ConfigProvider` that gives access to the module's configuration (see [Configuration](./configuration)). It's always injected — even for modules without a config schema. | ||
|
RedsTom marked this conversation as resolved.
|
||
|
|
||
| > [!NOTE] | ||
| > In `complete()` (autocomplete) handlers, the injected `config` currently comes from the **Core** module rather than the command's module. This means module-specific config values are not available during autocomplete — only the Core module's config is accessible. | ||
|
|
||
| ```typescript | ||
| async execute(interaction, config) { | ||
| const channel = config.get("logChannel"); | ||
| const max = config.get("maxWarnings"); | ||
| // ... | ||
| } | ||
| ``` | ||
|
|
||
| ## Options & Subcommands | ||
|
|
||
| ### Basic Options | ||
|
|
||
| ```typescript | ||
| data: new SlashCommandBuilder() | ||
| .setName("greet") | ||
| .setDescription("Greet someone") | ||
| .addUserOption((option) => | ||
| option.setName("target").setDescription("Who to greet").setRequired(true) | ||
| ) | ||
| .addStringOption((option) => | ||
| option.setName("message").setDescription("Custom message").setMaxLength(200) | ||
| ); | ||
| ``` | ||
|
|
||
| ### Autocomplete | ||
|
|
||
| ```typescript | ||
| export default declareCommand({ | ||
| data: new SlashCommandBuilder() | ||
| .setName("color") | ||
| .setDescription("Pick a color") | ||
| .addStringOption((option) => | ||
| option | ||
| .setName("color") | ||
| .setDescription("Choose a color") | ||
| .setAutocomplete(true) | ||
| .setRequired(true) | ||
| ), | ||
|
|
||
| async execute(interaction, config) { | ||
| const color = interaction.options.getString("color", true); | ||
| await interaction.reply(`You picked ${color}!`); | ||
| }, | ||
|
|
||
| async complete(interaction, config) { | ||
| const colors = ["red", "green", "blue", "yellow", "purple"]; | ||
| const input = interaction.options.getFocused().toLowerCase(); | ||
| const filtered = colors.filter((c) => c.startsWith(input)); | ||
| await interaction.respond(filtered.map((c) => ({ name: c, value: c }))); | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ### Subcommands | ||
|
|
||
| ```typescript | ||
| const builder = new SlashCommandBuilder() | ||
| .setName("config") | ||
| .setDescription("Configuration commands") | ||
| .addSubcommand((sub) => | ||
| sub.setName("view").setDescription("View configuration") | ||
| ) | ||
| .addSubcommand((sub) => | ||
| sub | ||
| .setName("set") | ||
| .setDescription("Set a value") | ||
| .addStringOption((opt) => | ||
| opt.setName("key").setDescription("Config key").setRequired(true) | ||
| ) | ||
| .addStringOption((opt) => | ||
| opt.setName("value").setDescription("Config value").setRequired(true) | ||
| ) | ||
| ); | ||
| ``` | ||
|
|
||
| ## Registration & Propagation | ||
|
|
||
| ### In the Module | ||
|
|
||
| ```typescript | ||
| // src/modules/my-module/my-module.module.ts | ||
| import testCommand from "./commands/test.command.js"; | ||
| // src/modules/greeter/greeter.module.ts | ||
| import helloCommand from "./commands/hello.command.js"; | ||
|
|
||
| export default defineModule({ | ||
| onLoad(_client, registry) { | ||
| registry.register(testCommand); | ||
| registry.register(helloCommand); | ||
| }, | ||
| }); | ||
| ``` | ||
|
|
||
| ## Registration & propagation | ||
| ### Dev vs Production | ||
|
|
||
| | Commands | Production | Development (`NODE_ENV=development`) | | ||
| | -------------------------------- | ------------------------------------------------- | -------------------------------------------------------------------- | | ||
| | **core** (`/config`, `/modules`) | Global (~1 h propagation) | Registered on `DEV_GUILD_ID` — **instant** | | ||
| | **module** | Per guild, (re)installed on module `version` bump | Re-synced **at every startup** on `DEV_GUILD_ID` for enabled modules | | ||
| | Aspect | Development (`NODE_ENV=development`) | Production | | ||
| | ------------------- | ------------------------------------------------------------- | -------------------------------------------- | | ||
| | **Core commands** | Registered on `DEV_GUILD_ID` (instant) | Global (~1h propagation) | | ||
| | **Module commands** | Re-synced every startup on `DEV_GUILD_ID` for enabled modules | Registered per guild, only on version change | | ||
| | **Propagation** | Instant (single PUT) | Delayed (global) or on-demand (per guild) | | ||
|
|
||
| In production, version gating prevents re-pushing module commands to all guilds on every startup. In development, all commands are registered in a single PUT on the dev guild for instant updates. | ||
| The version gating system in production uses the `activatedVersion` field in the `ModuleActivation` database record. Commands are re-registered on a guild only when the module's declared version differs from the stored version. This avoids unnecessary API calls on every restart. | ||
|
|
||
| > `DEV_GUILD_ID` is **required** in development mode. See `.env.example`. | ||
|
|
||
| ## Core Commands | ||
|
|
||
| OmniBot provides two built-in commands in the **Core** module (always active, non-uninstallable): | ||
|
|
||
| | Command | Permission | Description | | ||
| | ------------------ | ------------- | ------------------------------------------------------ | | ||
| | `/modules` | Administrator | Lists all modules with enable/disable buttons | | ||
| | `/config <module>` | Administrator | Opens the interactive configuration panel for a module | | ||
|
|
||
| ## Best Practices | ||
|
|
||
| - **Use reply, deferReply, or editReply** appropriately — defer for operations that take longer than 3 seconds | ||
| - **Use ephemeral replies** for user-specific responses (`flags: MessageFlags.Ephemeral`) | ||
| - **Handle errors** — wrap risky operations in try/catch and reply with a user-friendly message | ||
| - **Validate option values** — use the builder's built-in validation (min/max length, min/max value, etc.) | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.