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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,4 @@ Copy `.env.example` to `.env`:

- `DISCORD_TOKEN` — bot token from Discord Developer Portal
- `DATABASE_URL` — PostgreSQL connection string (default matches `compose.yaml`)
- `LOG_LEVEL` — optional log level (`fatal`/`error`/`warn`/`info`/`debug`/`trace`/`silent`, default `debug`); invalid values fall back to `debug`
16 changes: 10 additions & 6 deletions bootstrap/validate-env-vars.mjs → bootstrap/validate-env-vars.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@
// missing env vars in the application code.
// Let's just crash before starting if missing
// values.
const REQUIRED_ENV_VARS = {
//
// Runs under tsx (see the `dev`/`start` scripts), so the single dev-mode
// definition in `#lib/env.js` can be imported instead of duplicating the
// "development" literal here.
import { isDevMode } from "#lib/env.js";

const REQUIRED_ENV_VARS: Record<string, { sensitive: boolean }> = {
DISCORD_TOKEN: {
sensitive: true,
},
Expand All @@ -11,22 +17,20 @@ const REQUIRED_ENV_VARS = {
},
// Required only in development: the guild where core commands are registered
// instantly instead of globally (see command-loader).
...(process.env.NODE_ENV === "development"
? { DEV_GUILD_ID: { sensitive: false } }
: {}),
...(isDevMode() ? { DEV_GUILD_ID: { sensitive: false } } : {}),
};

// --- Functions ---

function exitIfMissing(envVarName) {
function exitIfMissing(envVarName: string): void {
const envVar = process.env[envVarName];
if (envVar === undefined || envVar.length === 0) {
console.error(`Missing required environment variable '${envVarName}'`);
process.exit(1);
}
}

function displayEnvironmentVariables() {
function displayEnvironmentVariables(): void {
console.log(
"Starting the application with the following environment variables:"
);
Expand Down
6 changes: 6 additions & 0 deletions docs/functional.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ Documentation du comportement visible par les utilisateurs et administrateurs de

---

## Arrivée et départ du bot

Quand le bot est invité sur un serveur, ses données sont initialisées et un message de bienvenue est posté dans le salon système (ou envoyé en MP au propriétaire si le salon système est indisponible). Le message pointe vers `/modules` et `/config`. Rien d'autre n'est activé automatiquement : seuls les administrateurs décident quels modules activer.

Quand le bot quitte un serveur, rien n'est effacé : si le bot est réinvité plus tard, la configuration et les modules activés sont restaurés automatiquement (y compris leurs commandes).

## Module Core

Toujours actif, non désinstallable. Fournit la gestion des modules pour les administrateurs.
Expand Down
3 changes: 1 addition & 2 deletions docs/site/en/guide/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,7 @@ interface Command {

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.

> [!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.
In `complete()` (autocomplete) handlers, the injected `config` is the command's own module config — the same provider `execute()` receives.

```typescript
async execute(interaction, config) {
Expand Down
1 change: 1 addition & 0 deletions docs/site/en/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ model GuildConfiguration {
```

- Entity IDs (user, role, channel) are stored as strings and **deserialized** to Discord objects at read time
- Entities deleted since (removed channel/role/…) are silently dropped from lists at read time, so `config.get()` never contains `null`
- An in-memory cache (`configCache`) avoids database reads on every interaction
- The cache is invalidated on every write

Expand Down
26 changes: 26 additions & 0 deletions docs/site/en/guide/creating-a-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@

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):

```bash
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("#index.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

```
Expand Down Expand Up @@ -199,6 +211,20 @@ Keys are looked up in the module's own namespace first, then fall back to core t

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 `version`** when 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)
- **Set `DEV_GUILD_ID`** in `.env` — without it, dev mode registers no commands at all
- **Prefix command names and `customId`s** with your module id — both are matched first-come-first-served across all modules, and a `:` inside an argument shifts `customId` parsing
- **Guard listeners with `if (!config) return`** — listeners run with `undefined` config outside guilds (DMs)
- **Put `requiresAdmin: true`** on every sensitive command and interaction handler — fail-open otherwise
- **Never put an object in an entity `defaultValue`** (`USER`/`ROLE`/`CHANNEL`/`CATEGORY` defaults must stay unset; ids are stored, objects are hydrated)
- **Declare `ENUM` `options` `as const`** for literal-union typing of `config.get()`
- **Prisma order**: write `models/*.prisma`, then `pnpm prisma:generate`, then `pnpm prisma:migrate` — and keep model names unique across all modules
- **No top-level side effects** — `devOnly` modules are still imported in production; only their registration is skipped

## Best Practices

- **Keep `onLoad` lean** — register artifacts and log, move logic to services
Expand Down
2 changes: 1 addition & 1 deletion docs/site/en/legal/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Data is stored in a PostgreSQL database managed by the bot instance operator. Th

## Data Retention

Configuration and activation data is retained for as long as the bot is active on a guild. When a module is uninstalled, its configuration data may be retained in the guild's configuration blob (configurable by instance operators). To request data deletion, contact the instance operator.
Configuration and activation data is retained for as long as the bot is active on a guild. When the bot leaves a guild (kick or removal), the data is deliberately kept so everything is restored automatically if the bot is re-invited. When a module is uninstalled, its configuration data may be retained in the guild's configuration blob (configurable by instance operators). To request data deletion, contact the instance operator.

## Data Sharing

Expand Down
1 change: 1 addition & 0 deletions docs/site/fr/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ model GuildConfiguration {
```

- Les IDs d'entités (utilisateur, rôle, salon) sont stockées sous forme de chaînes et **désérialisées** en objets Discord à la lecture
- Les entités supprimées depuis (salon/rôle/… retiré) sont écartées des listes à la lecture, donc `config.get()` ne contient jamais `null`
- Un cache en mémoire (`configCache`) évite les lectures base de données à chaque interaction
- Le cache est invalidé à chaque écriture

Expand Down
26 changes: 25 additions & 1 deletion docs/site/fr/guide/creating-a-module.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,17 @@

Un module dans OmniBot est une unité fonctionnelle autonome qui peut être installée et désinstallée par serveur Discord. Chaque module peut contenir des commandes, des écouteurs d'événements, des gestionnaires d'interactions, des services, une configuration et des modèles de base de données.

## Structure d'un module
## Générer un module

Ne partez pas de zéro. Générez un squelette fonctionnel (définition, schéma config, une commande, un listener, `i18n/en+fr.json`, un modèle Prisma commenté, et un test) :

```bash
pnpm new-module mon-module "Mon Module"
```

Le module généré compile et son test passe sans retouche. Remplissez la description `TODO`, puis activez-le via `/modules` sur votre serveur dev. Aucune étape d'enregistrement : les modules sont auto-découverts depuis `src/modules/` au démarrage.

Pour les tests, utilisez les helpers partagés de `#lib/testing.js` (`makeTestConfig`, `fakeGuild`, `fakeMessage`, `initTestI18n`) plutôt que des mocks maison. Notez que `vi.mock("#index.js")` et `vi.mock("#lib/database.js")` doivent toujours être déclarés par fichier de test — Vitest les hisse, aucun helper ne peut les masquer.

```
src/modules/mon-module/
Expand Down Expand Up @@ -199,6 +209,20 @@ Les clés sont d'abord cherchées dans le namespace du module, puis dans celui d

La locale du serveur est configurée via les paramètres du module Cœur (`/config core > locale`). Quand un fichier de locale n'existe pas pour la langue sélectionnée, le système utilise l'anglais par défaut.

## Liste de vérification avant publication

Avant que votre module n'atteigne des serveurs de production :

- **Incrémentez `version`** quand vous ajoutez, renommez ou modifiez une commande — la production ne ré-enregistre les commandes que lors d'un changement de version (le dev resynchronise à chaque démarrage, facile à rater en local)
- **Renseignez `DEV_GUILD_ID`** dans `.env` — sans lui, le mode dev n'enregistre aucune commande
- **Préfixez noms de commandes et `customId`** avec votre id de module — les deux sont résolus premier-arrivé-premier-servi entre tous les modules, et un `:` dans un argument décale le parsing des `customId`
- **Gardez `if (!config) return`** dans les listeners — hors serveurs (MP), ils tournent avec une config `undefined`
- **Mettez `requiresAdmin: true`** sur chaque commande et handler sensible — sinon exposé à tout le monde
- **Jamais d'objet en `defaultValue` d'entité** (`USER`/`ROLE`/`CHANNEL`/`CATEGORY` restent non renseignés ; les ids sont stockés, les objets sont réhydratés)
- **Déclarez les `options` des `ENUM` avec `as const`** pour typer `config.get()` en union littérale
- **Ordre Prisma** : écrire `models/*.prisma`, puis `pnpm prisma:generate`, puis `pnpm prisma:migrate` — et gardez des noms de modèles uniques entre modules
- **Pas de side-effect au top-level** — les modules `devOnly` sont quand même importés en production ; seul leur enregistrement est sauté

## Bonnes pratiques

- **Gardez `onLoad` léger** — enregistrez les artefacts et loggez, déplacez la logique dans les services
Expand Down
2 changes: 1 addition & 1 deletion docs/site/fr/legal/privacy.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ Les données sont stockées dans une base de données PostgreSQL gérée par l'o

## Conservation des données

Les données de configuration et d'activation sont conservées tant que le bot est actif sur un serveur. Quand un module est désinstallé, ses données de configuration peuvent être conservées dans le blob de configuration du serveur (configurable par les opérateurs d'instance). Pour demander la suppression des données, contactez l'opérateur de l'instance.
Les données de configuration et d'activation sont conservées tant que le bot est actif sur un serveur. Quand le bot quitte un serveur (kick ou retrait), les données sont volontairement conservées afin que tout soit restauré automatiquement si le bot est réinvité. Quand un module est désinstallé, ses données de configuration peuvent être conservées dans le blob de configuration du serveur (configurable par les opérateurs d'instance). Pour demander la suppression des données, contactez l'opérateur de l'instance.

## Partage des données

Expand Down
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,14 @@
},
"scripts": {
"build": "pnpm prisma:generate && tsc",
"dev": "NODE_ENV=development node --env-file=.env ./bootstrap/validate-env-vars.mjs && NODE_ENV=development node --conditions=development --env-file=.env --import tsx src/index.ts",
"start": "node ./bootstrap/validate-env-vars.mjs && node ./dist/index.js",
"dev": "NODE_ENV=development node --conditions=development --env-file=.env --import tsx ./bootstrap/validate-env-vars.ts && NODE_ENV=development node --conditions=development --env-file=.env --import tsx src/index.ts",
"start": "node --import tsx ./bootstrap/validate-env-vars.ts && node ./dist/index.js",
"test": "vitest run",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration",
"prepare": "lefthook install -f || true",
"format": "oxfmt --write && oxlint --fix --fix-suggestions",
"new-module": "tsx scripts/new-module.ts",
"prisma:consolidate": "tsx scripts/consolidate-schema.ts",
"prisma:generate": "pnpm prisma:consolidate && prisma generate --no-hints",
"prisma:migrate": "pnpm prisma:consolidate && prisma migrate dev",
Expand Down
34 changes: 34 additions & 0 deletions scripts/consolidate-schema.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { findDuplicatePrismaModel } from "./consolidate-schema.js";

describe("findDuplicatePrismaModel", () => {
it("returns null when every model name is unique", () => {
expect(
findDuplicatePrismaModel([
{ path: "a.prisma", content: "model Alpha {\n id String @id\n}" },
{ path: "b.prisma", content: "model Beta {\n id String @id\n}" },
])
).toBeNull();
});

it("names the model and both files on collision", () => {
expect(
findDuplicatePrismaModel([
{ path: "a.prisma", content: "model Alpha {\n id String @id\n}" },
{
path: "b.prisma",
content: "// comment\nmodel Alpha {\n id String @id\n}",
},
])
).toEqual({ model: "Alpha", first: "a.prisma", second: "b.prisma" });
});

it("ignores commented-out models", () => {
expect(
findDuplicatePrismaModel([
{ path: "a.prisma", content: "// model Alpha {\n// }" },
{ path: "b.prisma", content: "model Alpha {\n id String @id\n}" },
])
).toBeNull();
});
});
52 changes: 50 additions & 2 deletions scripts/consolidate-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,40 @@ async function findPrismaFiles(dir: string): Promise<string[]> {
return files;
}

export interface PrismaSource {
path: string;
content: string;
}

export interface DuplicateModel {
model: string;
first: string;
second: string;
}

/**
* Everything is merged into a single schema, so a model name must be unique
* across ALL module files. Prisma itself only reports this late at
* `generate` time with a cryptic error — fail here with both file paths.
*/
export function findDuplicatePrismaModel(
files: PrismaSource[]
): DuplicateModel | null {
const origins = new Map<string, string>();
for (const file of files) {
for (const line of file.content.split("\n")) {
const match = line.match(/^\s*model\s+(\w+)/);
if (!match?.[1]) continue;
const first = origins.get(match[1]);
if (first) {
return { model: match[1], first, second: file.path };
}
origins.set(match[1], file.path);
}
}
return null;
}

async function consolidateSchema() {
const srcDir = path.join(__dirname, "..", "src");
const schemaPath = path.join(srcDir, "prisma", "schema.prisma");
Expand All @@ -53,6 +87,7 @@ async function consolidateSchema() {

// Lire le contenu du header.prisma
let consolidatedContent = "";
const sources: PrismaSource[] = [];

// Ajouter le contenu de tous les fichiers prisma trouvés
for (const file of prismaFiles) {
Expand All @@ -73,11 +108,19 @@ async function consolidateSchema() {
.trim();

consolidatedContent += cleanContent + "\n";
sources.push({ path: relativePath, content: cleanContent });
} catch (error) {
console.warn(`Erreur lors de la lecture de ${file}:`, error);
}
}

const duplicate = findDuplicatePrismaModel(sources);
if (duplicate) {
throw new Error(
`Duplicate Prisma model "${duplicate.model}" in "${duplicate.first}" and "${duplicate.second}": model names must be unique across all modules.`
);
}

// Écrire le schéma consolidé
await fs.writeFile(schemaPath, consolidatedContent);

Expand All @@ -90,5 +133,10 @@ async function consolidateSchema() {
});
}

// Exécuter la consolidation
consolidateSchema().catch(console.error);
// Exécuter la consolidation (seulement en run direct, pas à l'import en test)
if (process.argv[1]?.endsWith("consolidate-schema.ts")) {
consolidateSchema().catch((error) => {
console.error(error);
process.exitCode = 1;
});
}
Loading
Loading