Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/site/.vitepress/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export default defineConfig({
text: "Guide",
items: [
{ text: "Pour commencer", link: "/fr/guide/getting-started" },
{ text: "Architecture", link: "/fr/guide/architecture" },
{
text: "Créer un module",
link: "/fr/guide/creating-a-module",
Expand All @@ -50,6 +51,14 @@ export default defineConfig({
{ text: "Configuration", link: "/fr/guide/configuration" },
{ text: "Services", link: "/fr/guide/services" },
{ text: "Base de données", link: "/fr/guide/database" },
{ text: "Contribuer", link: "/fr/guide/contributing" },
],
},
{
text: "Juridique",
items: [
{ text: "Confidentialité", link: "/fr/legal/privacy" },
{ text: "CGU", link: "/fr/legal/terms" },
],
},
],
Expand Down Expand Up @@ -85,6 +94,7 @@ export default defineConfig({
text: "Guide",
items: [
{ text: "Getting Started", link: "/en/guide/getting-started" },
{ text: "Architecture", link: "/en/guide/architecture" },
{
text: "Creating a Module",
link: "/en/guide/creating-a-module",
Expand All @@ -95,6 +105,14 @@ export default defineConfig({
{ text: "Configuration", link: "/en/guide/configuration" },
{ text: "Services", link: "/en/guide/services" },
{ text: "Database", link: "/en/guide/database" },
{ text: "Contributing", link: "/en/guide/contributing" },
],
},
{
text: "Legal",
items: [
{ text: "Privacy", link: "/en/legal/privacy" },
{ text: "Terms", link: "/en/legal/terms" },
],
},
],
Expand Down
146 changes: 146 additions & 0 deletions docs/site/en/guide/architecture.md
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.
158 changes: 133 additions & 25 deletions docs/site/en/guide/commands.md
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>;
}
Comment thread
RedsTom marked this conversation as resolved.
```

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.
Comment thread
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.)
Loading