diff --git a/docs/functional.md b/docs/functional.md index be1f087..3f50bed 100644 --- a/docs/functional.md +++ b/docs/functional.md @@ -14,6 +14,12 @@ Toujours actif, non désinstallable. Fournit la gestion des modules pour les adm Affiche la liste de tous les modules disponibles avec leur statut sur le serveur (activé/désactivé), leur version activée, et leur description. Chaque module dispose d'un bouton **Activer** (vert) ou **Désactiver** (rouge) qui prend effet immédiatement. +### `/config` — Réinitialiser des champs + +En bas du panneau `/config` d'un module, un bouton **Réinitialiser…** ouvre un sélecteur (éphémère, visible de l'administrateur seul) listant les champs actuellement personnalisés, plus une entrée **Tous les champs**. Les champs choisis sont remis à leur valeur par défaut. + +Le bouton est désactivé tant qu'aucun champ n'a été personnalisé (rien à réinitialiser). Réinitialiser un champ retire simplement la valeur enregistrée : il repasse sur le défaut (qui suit alors de nouveau la langue du serveur, cf. plus bas) et son marqueur _(par défaut)_ réapparaît. + --- ## Module Thread Creator @@ -38,6 +44,15 @@ Dès qu'un message est posté dans le salon configuré : **Template par défaut :** `Discussion - {messageAuthor}` **Message de bienvenue par défaut :** `💬 Utilisez ce fil pour discuter de ce sujet !` +> [!NOTE] +> Les valeurs par défaut suivent la langue du serveur (`/config core`) : tant +> qu'un administrateur n'a pas saisi sa propre valeur, le message de bienvenue +> par défaut s'affiche dans la langue configurée et change si on bascule la +> langue. Dès qu'une valeur est définie manuellement, elle est figée et +> n'est plus affectée par la langue. Dans `/config`, une valeur encore par +> défaut est signalée par le marqueur _(par défaut)_, ce qui permet de voir +> d'un coup d'œil ce qui a réellement été configuré. + **Limites :** - Noms de fils tronqués à 100 caractères (limite Discord) diff --git a/docs/site/.vitepress/config.ts b/docs/site/.vitepress/config.ts index 964a663..6c2effe 100644 --- a/docs/site/.vitepress/config.ts +++ b/docs/site/.vitepress/config.ts @@ -49,6 +49,7 @@ export default defineConfig({ { text: "Écouteurs", link: "/fr/guide/listeners" }, { text: "Interactions", link: "/fr/guide/interactions" }, { text: "Configuration", link: "/fr/guide/configuration" }, + { text: "Localisation", link: "/fr/guide/localisation" }, { text: "Services", link: "/fr/guide/services" }, { text: "Base de données", link: "/fr/guide/database" }, { text: "Contribuer", link: "/fr/guide/contributing" }, @@ -103,6 +104,7 @@ export default defineConfig({ { text: "Listeners", link: "/en/guide/listeners" }, { text: "Interactions", link: "/en/guide/interactions" }, { text: "Configuration", link: "/en/guide/configuration" }, + { text: "Localization", link: "/en/guide/localization" }, { text: "Services", link: "/en/guide/services" }, { text: "Database", link: "/en/guide/database" }, { text: "Contributing", link: "/en/guide/contributing" }, diff --git a/docs/site/en/guide/architecture.md b/docs/site/en/guide/architecture.md index 9e16bc5..7c5066b 100644 --- a/docs/site/en/guide/architecture.md +++ b/docs/site/en/guide/architecture.md @@ -11,17 +11,21 @@ When the bot starts (`src/index.ts`), it follows this sequence: └─ prisma.$queryRaw`SELECT 1` └─ exits with error if DB is unavailable -2. Module discovery +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 -3. Intent aggregation +4. Intent aggregation └─ collects GatewayIntentBits from all modules └─ creates the Discord Client with the union of all intents -4. ClientReady handler (async) +5. ClientReady handler (async) ├─ For each module: │ ├─ module.onLoad(client, registry) │ └─ loadModuleEvents(client, module) @@ -31,10 +35,10 @@ When the bot starts (`src/index.ts`), it follows this sequence: │ └─ Prod mode: version-gated guild commands + global core commands └─ loadGlobalEvents(client) -5. Login +6. Login └─ client.login(token) -6. Shutdown handlers +7. Shutdown handlers └─ SIGTERM / SIGINT → client.destroy() + prisma.$disconnect() ``` @@ -122,7 +126,8 @@ The configuration system has several layers: 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 ` renders an interactive panel with per-field edit controls +5. **Localization** — Field names and descriptions are resolved from the module's i18n files (falling back to English defaults in the TypeScript schema) +6. **Admin UI** — `/config ` renders an interactive panel with per-field edit controls ## Service Layer diff --git a/docs/site/en/guide/commands.md b/docs/site/en/guide/commands.md index 7a67ebd..41336d7 100644 --- a/docs/site/en/guide/commands.md +++ b/docs/site/en/guide/commands.md @@ -4,6 +4,10 @@ This guide explains how to create Discord slash commands in your modules. Comman ## Creating a Command +::: tip +Set command names and descriptions in English by default — they act as the fallback when no translation is available for the guild's locale. In this bot, only descriptions are localized via `setDescriptionLocalizations()`; command names stay in English. +::: + ```typescript // src/modules/greeter/commands/hello.command.ts @@ -166,6 +170,29 @@ OmniBot provides two built-in commands in the **Core** module (always active, no | `/modules` | Administrator | Lists all modules with enable/disable buttons | | `/config ` | Administrator | Opens the interactive configuration panel for a module | +## Localization + +Slash command descriptions can be translated per locale using Discord's built-in localization methods: + +```typescript +data: new SlashCommandBuilder() + .setName("hello") + .setDescription("Says hello!") + .setDescriptionLocalizations({ fr: "Dit bonjour !" }), +``` + +> In this bot, only descriptions are localized. Command names stay in English. + +For module-specific strings in command responses, use `config.t()` to look up translations from your module's i18n files: + +```typescript +async execute(interaction, config) { + await interaction.reply(config.t("greeting", { user: interaction.user.username })); +} +``` + +The guild's locale is configured via the core module's `/config core > locale` setting. See [Configuration → Localization](./configuration#localization) for details on setting up translation files. + ## Best Practices - **Use reply, deferReply, or editReply** appropriately — defer for operations that take longer than 3 seconds diff --git a/docs/site/en/guide/configuration.md b/docs/site/en/guide/configuration.md index f4eea9f..4d715a6 100644 --- a/docs/site/en/guide/configuration.md +++ b/docs/site/en/guide/configuration.md @@ -6,6 +6,10 @@ OmniBot provides a typed configuration system for modules. Each module can decla A module declares its schema in `defineModule()` via the `config` property: +::: tip +Config field `name` and `description` must be in English in the TypeScript schema — they serve as the fallback when no translation file matches the guild's locale. No need to duplicate them in `en.json`. Add translations in `i18n/` files for other locales. +::: + ```typescript // src/modules/my-module/my-module.module.ts @@ -177,6 +181,50 @@ model GuildConfiguration { - An in-memory cache (`configCache`) avoids database reads on every interaction - The cache is invalidated on every write +## Localization + +Config field names and descriptions can be translated per guild locale via i18n files. The system resolves localized values automatically based on the guild's configured locale (`/config core > locale`). + +### Module-level translations + +Create an `i18n/` directory in your module with one JSON file per locale: + +```json +// i18n/en.json +{ + "config.myField.name": "My Field", + "config.myField.description": "Description of my field." +} + +// i18n/fr.json +{ + "config.myField.name": "Mon champ", + "config.myField.description": "Description de mon champ." +} +``` + +The field's `name` and `description` in the TypeScript schema serve as English defaults — there's no need to duplicate them in `en.json`. + +### Using config.t() for messages + +The `ConfigProvider` injected into commands, listeners, and interactions exposes a `t()` method for localized strings anywhere in your module: + +```typescript +async execute(interaction, config) { + const message = config.t("welcomeMessage", { user: interaction.user.username }); + await interaction.reply(message); +} +``` + +### Namespace fallback + +Keys are resolved in this order: + +1. Your module's namespace (e.g. `thread-creator:welcomeMessage`) +2. The core namespace (`core:welcomeMessage`) + +This means common UI strings like `config.previous`, `config.next`, `modules.enable`, and type names (`type.text`, `type.number`, etc.) are provided by the core namespace — you only need to translate module-specific strings. + ## Best Practices - **Use camelCase for keys**, human-readable `name` and clear `description` diff --git a/docs/site/en/guide/creating-a-module.md b/docs/site/en/guide/creating-a-module.md index 0c64c89..5c8ed1e 100644 --- a/docs/site/en/guide/creating-a-module.md +++ b/docs/site/en/guide/creating-a-module.md @@ -7,6 +7,9 @@ A module in OmniBot is a self-contained functional unit that can be installed an ``` 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 @@ -22,6 +25,10 @@ src/modules/my-module/ ## Step-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 ```typescript @@ -142,6 +149,57 @@ The module loader (`src/core/loaders/module-loader.ts`) scans `src/modules/` at **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. The module's `name`, `description`, and config field `name`/`description` are automatically resolved from these files, overriding the TypeScript defaults when a matching locale is active: + +```json +{ + "module.name": "My Module", + "module.description": "Does awesome things.", + "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: + +```typescript +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. + ## Best Practices - **Keep `onLoad` lean** — register artifacts and log, move logic to services diff --git a/docs/site/en/guide/getting-started.md b/docs/site/en/guide/getting-started.md index f3b954b..43ef3de 100644 --- a/docs/site/en/guide/getting-started.md +++ b/docs/site/en/guide/getting-started.md @@ -4,7 +4,7 @@ Welcome to OmniBot! This guide will help you set up the bot for development and ## What is OmniBot? -OmniBot is a **modular Discord bot** developed for the [Graven - Développement](https://discord.gg/graven) community. Each feature is a self-contained **module** that is auto-discovered at startup and can be installed or uninstalled **per Discord server** via `/modules`. Modules declare their own slash commands, event listeners, interaction handlers, and a typed configuration schema edited live with `/config `. +OmniBot is a **modular Discord bot** developed for the [Graven - Développement](https://discord.gg/graven) community. Each feature is a self-contained **module** that is auto-discovered at startup and can be installed or uninstalled **per Discord server** via `/modules`. Modules declare their own slash commands, event listeners, interaction handlers, and a typed configuration schema edited live with `/config `. The entire interface is translatable — each module can bundle its own locale files. The core system wires everything together — adding a feature means creating a module, never touching the bootstrap. @@ -98,7 +98,9 @@ src/ │ └── utils/ # Permission guard, version parser, messages ├── modules/ # Feature modules (one folder per module) │ ├── thread-creator/ # Example module: automatic thread creation +│ │ └── i18n/ # Module translations (en.json, fr.json) │ └── test-config/ # Dev-only: exercises every config type +│ └── i18n/ # Module translations (en.json, fr.json) ├── lib/ # Shared contracts exposed to modules │ ├── module.ts # defineModule() │ ├── command.ts # declareCommand() @@ -134,6 +136,7 @@ Files use suffixes to identify their role: | `*.select.ts` | Select menu handler | | `*.service.ts` | Business service (e.g. `thread-creation-queue.service.ts`) | | `*.prisma` | Prisma model definition | +| `i18n/*.json` | Locale translation files (e.g. `en.json`, `fr.json`) | --- diff --git a/docs/site/en/guide/interactions.md b/docs/site/en/guide/interactions.md index 937672a..6473f59 100644 --- a/docs/site/en/guide/interactions.md +++ b/docs/site/en/guide/interactions.md @@ -195,6 +195,21 @@ Interactions are automatically tied to module activation. When a module is disab When editing configuration via ephemeral select menus, the source config message (public) needs to be updated. The system uses `refreshSourceConfigMessage()` which threads the source message ID through `customId` arguments and re-edits the public message after each change. +## Localization + +Interaction responses can use `config.t()` for localized strings. Provide translations in your module's `i18n/` files: + +```typescript +async execute(interaction, [actionId, userId]) { + await interaction.reply({ + content: config.t("actionConfirmed", { action: actionId, user: userId }), + flags: MessageFlags.Ephemeral, + }); +} +``` + +The guild's locale is configured via `/config core > locale`. See [Configuration → Localization](./configuration#localization) for details. + ## Best Practices ### Response Types diff --git a/docs/site/en/guide/listeners.md b/docs/site/en/guide/listeners.md index 36d5d44..f1f766a 100644 --- a/docs/site/en/guide/listeners.md +++ b/docs/site/en/guide/listeners.md @@ -85,6 +85,19 @@ export default defineModule({ Additionally, **privileged intents** (`GuildMembers`, `GuildPresences`, `MessageContent`) must be enabled in the Discord Developer Portal under Bot > Privileged Gateway Intents. +## Localization + +Listener responses can use `config.t()` for localized strings. Provide translations in your module's `i18n/` files: + +```typescript +async execute(message, config) { + if (!config) return; + await message.reply(config.t("welcomeMessage", { user: message.author.username })); +} +``` + +The guild's locale is configured via `/config core > locale`. See [Configuration → Localization](./configuration#localization) for details. + ## Best Practices - **Filter early** — check for bots, DMs, or irrelevant channels at the top of `execute()` diff --git a/docs/site/en/guide/localization.md b/docs/site/en/guide/localization.md new file mode 100644 index 0000000..5ecb5d1 --- /dev/null +++ b/docs/site/en/guide/localization.md @@ -0,0 +1,152 @@ +# Localization + +OmniBot supports translating module interfaces per guild via an i18n system built on i18next. Each module can provide its own translation files, with automatic fallback to the core namespace. + +## How it works + +- The guild's locale is configured via `/config core > locale` (currently `en` or `fr`). +- Each module can bundle an `i18n/` directory with one JSON file per locale. +- Translation keys are resolved in order: module namespace → core namespace. +- When a locale file is missing, English is used as the fallback. + +## Module structure + +``` +src/modules/my-module/ +├── my-module.module.ts +├── i18n/ +│ ├── en.json +│ └── fr.json +└── commands/ + └── ... +``` + +Translation files are auto-discovered at startup by the module loader — no registration needed. + +## Translation file format + +```json +{ + "module.name": "My Module", + "module.description": "Does awesome things.", + "config.myField.name": "My Field", + "config.myField.description": "Description of my field.", + "greeting": "Hello {{name}}!" +} +``` + +Use {{param}} syntax for dynamic values — never concatenate strings with `${}` or `+` inside translated text. + +### Module metadata + +| Key | Overrides | +| -------------------- | ---------------------------------------- | +| `module.name` | Module `name` in `defineModule()` | +| `module.description` | Module `description` in `defineModule()` | + +### Config field labels + +| Key pattern | Overrides | +| ------------------------------- | ---------------------------------------- | +| `config..name` | Field `name` in the config schema | +| `config..description` | Field `description` in the config schema | + +### Default values + +A free-text (`STRING`) field's default can be localized so an unconfigured value renders in the guild's language: + +| Key pattern | Overrides | +| --------------------------- | -------------------------------------------------- | +| `config..default` | The field's `defaultValue` (free-text fields only) | + +The schema's `defaultValue` stays the English fallback. Defaults are **not persisted**: `ConfigProvider.get()` resolves them lazily, so the value tracks the guild locale and changes live when the locale changes — until an admin sets the field explicitly, after which the stored value wins and no longer follows the locale. + +Only `STRING` fields are localized this way. Enum defaults are stored identifiers (not display text) and are returned verbatim, as are numbers, booleans and lists. + +In the `/config` panel, any field the admin hasn't set — showing a default, or nothing for a field without one — is flagged with a `config.defaultSuffix` marker (`_(default)_`), so they can tell at a glance what has actually been changed. `ConfigProvider.isSet(key)` exposes the same distinction in code. + +> [!NOTE] +> Guilds whose config predates this mechanism keep whatever default was already +> stored until a `/config` reset — there is no automatic migration. + +### Localized enum option labels + +An enum field can render its option values as localized names instead of raw codes with a `display` hint: + +```typescript +locale: { + type: ConfigType.ENUM, + options: ["en", "fr"] as const, + display: "language", +} +``` + +With `display: "language"`, each option is shown via `Intl.DisplayNames` in the viewer's locale — `fr` renders as "Français" (FR) or "French" (EN), in both the `/config` readout and the select menu. No translation keys are needed; the names come from the runtime. + +## Using translations in code + +The `ConfigProvider` injected into commands, listeners, and interactions exposes a `t()` method: + +```typescript +async execute(interaction, config) { + const greeting = config.t("greeting", { name: interaction.user.username }); + await interaction.reply(greeting); +} +``` + +### Type name localization + +The `getConfigTypeName()` helper accepts an optional `TFunction` for localized type names: + +```typescript +import { getConfigTypeName } from "#lib/config.js"; + +const label = getConfigTypeName(ConfigType.STRING, config.t); +// → "Text" (EN) or "Texte" (FR) +``` + +### In commands + +```typescript +data: new SlashCommandBuilder() + .setName("hello") + .setDescription("Says hello!") + .setDescriptionLocalizations({ fr: "Dit bonjour !" }), +``` + +Only descriptions are localized in this bot — command names stay in English. + +## Core namespace fallback + +Common UI strings are provided by the core namespace and are available in every module without redefining them: + +| Key | English value | French value | +| ----------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | +| `config.previous` | ◀ Previous | ◀ Précédent | +| `config.next` | Next ▶ | Suivant ▶ | +| `config.page` | Page {{current}}/{{total}} | Page {{current}}/{{total}} | +| `modules.enable` | Enable | Activer | +| `modules.disable` | Disable | Désactiver | +| `type.text` | Text | Texte | +| `type.number` | Number | Nombre | +| `type.boolean` | Boolean | Booléen | +| `type.user` | User | Utilisateur | +| `type.role` | Role | Rôle | +| `type.channel` | Channel | Salon | +| `type.category` | Category | Catégorie | +| `type.choice` | Choice | Choix | +| `type.listOf` | List of {{type}} | Liste de {{type}} | + +## Adding a new locale + +1. Add the locale code to the `locale` ENUM in `src/core/core.config.ts`. +2. Create `i18n/.json` in the core module with translations for all core keys. +3. Create `i18n/.json` in each module you want to translate. +4. Guild admins can then select the new locale via `/config core > locale`. + +## Best practices + +- **Write TypeScript values in English** — they serve as the fallback when no translation matches. +- **Always use named parameters** ({{param}}) in i18n values, never `${}` template literals. +- **Only translate module-specific strings** — common UI labels come from the core namespace. +- **Keep translation files complete** — missing keys fall back to English, but this may produce mixed-language output. diff --git a/docs/site/en/index.md b/docs/site/en/index.md index bad17a1..56d6fbe 100644 --- a/docs/site/en/index.md +++ b/docs/site/en/index.md @@ -26,4 +26,7 @@ features: - icon: 🎛️ title: Typed configuration details: Declare a config schema and get an interactive editing UI. + - icon: 🌐 + title: Internationalization + details: Localize your module's UI in multiple languages with per-module i18n files. --- diff --git a/docs/site/fr/guide/architecture.md b/docs/site/fr/guide/architecture.md index f90cf37..51a655c 100644 --- a/docs/site/fr/guide/architecture.md +++ b/docs/site/fr/guide/architecture.md @@ -11,17 +11,21 @@ Au démarrage du bot (`src/index.ts`), la séquence suivante est exécutée : └─ prisma.$queryRaw`SELECT 1` └─ sort avec une erreur si la DB est indisponible -2. Découverte des modules +2. Initialisation i18n + └─ initI18n() — initialise le moteur i18next avec support de la locale de repli + └─ loadModuleI18n("core") — charge les fichiers de traduction du cœur (en.json, fr.json) + +3. Découverte des modules └─ loadModules("./modules") └─ parcourt chaque sous-dossier de src/modules/ └─ importe le fichier *.module.ts └─ ignore les modules devOnly en production -3. Agrégation des intentions +4. Agrégation des intentions └─ collecte les GatewayIntentBits de tous les modules └─ crée le client Discord avec l'union de toutes les intentions -4. Gestionnaire ClientReady (asynchrone) +5. Gestionnaire ClientReady (asynchrone) ├─ Pour chaque module : │ ├─ module.onLoad(client, registry) │ └─ loadModuleEvents(client, module) @@ -31,10 +35,10 @@ Au démarrage du bot (`src/index.ts`), la séquence suivante est exécutée : │ └─ Mode prod : commandes guild versionnées + commandes globales du cœur └─ loadGlobalEvents(client) -5. Connexion +6. Connexion └─ client.login(token) -6. Gestionnaires d'arrêt +7. Gestionnaires d'arrêt └─ SIGTERM / SIGINT → client.destroy() + prisma.$disconnect() ``` @@ -122,7 +126,8 @@ Le système de configuration comporte plusieurs couches : 2. **Stockage** — Toutes les configurations sont stockées dans un blob JSON par serveur dans la table `GuildConfiguration` 3. **Cache** — `ConfigService` maintient un cache en mémoire (`configCache`) pour éviter les lectures base de données à chaque interaction 4. **Désérialisation** — Les IDs d'entités (utilisateur, rôle, salon) sont stockés sous forme de chaînes et résolus en objets Discord à la lecture -5. **Interface admin** — `/config ` affiche un panneau interactif avec des contrôles d'édition par champ +5. **Localisation** — Les noms et descriptions des champs sont résolus depuis les fichiers i18n du module (avec repli sur les valeurs par défaut en anglais dans le schéma TypeScript) +6. **Interface admin** — `/config ` affiche un panneau interactif avec des contrôles d'édition par champ ## Couche service diff --git a/docs/site/fr/guide/commands.md b/docs/site/fr/guide/commands.md index f3124bf..20e86de 100644 --- a/docs/site/fr/guide/commands.md +++ b/docs/site/fr/guide/commands.md @@ -4,6 +4,10 @@ Ce guide explique comment créer des commandes slash Discord dans vos modules. L ## Créer une commande +::: tip +Définissez les noms et descriptions des commandes en anglais par défaut — ils servent de valeur de repli quand aucune traduction n'est disponible pour la locale du serveur. Dans ce bot, seules les descriptions sont localisées via `setDescriptionLocalizations()` ; les noms de commandes restent en anglais. +::: + ```typescript // src/modules/salut/commands/bonjour.command.ts @@ -172,6 +176,29 @@ OmniBot fournit deux commandes intégrées dans le module **Cœur** (toujours ac | `/modules` | Administrateur | Liste tous les modules avec boutons d'activation/désactivation | | `/config ` | Administrateur | Ouvre le panneau de configuration interactif d'un module | +## Localisation + +Les descriptions des commandes slash peuvent être traduites par locale en utilisant les méthodes de localisation intégrées de Discord : + +```typescript +data: new SlashCommandBuilder() + .setName("hello") + .setDescription("Says hello!") + .setDescriptionLocalizations({ fr: "Dit bonjour !" }), +``` + +> Dans ce bot, seules les descriptions sont localisées. Les noms de commandes restent en anglais. + +Pour les chaînes spécifiques au module dans les réponses de commandes, utilisez `config.t()` pour chercher les traductions dans vos fichiers i18n : + +```typescript +async execute(interaction, config) { + await interaction.reply(config.t("salutation", { user: interaction.user.username })); +} +``` + +La locale du serveur est configurée via `/config core > locale`. Voir [Configuration → Localisation](./configuration#localisation) pour les détails sur la mise en place des fichiers de traduction. + ## Bonnes pratiques - **Utilisez reply, deferReply ou editReply** de manière appropriée — différée pour les opérations de plus de 3 secondes diff --git a/docs/site/fr/guide/configuration.md b/docs/site/fr/guide/configuration.md index 8c6b081..b968c3b 100644 --- a/docs/site/fr/guide/configuration.md +++ b/docs/site/fr/guide/configuration.md @@ -6,6 +6,10 @@ OmniBot fournit un système de configuration typé pour les modules. Chaque modu Un module déclare son schéma dans `defineModule()` via la propriété `config` : +::: tip +Les `name` et `description` des champs de configuration doivent être en anglais dans le schéma TypeScript — ils servent de valeur de repli quand aucun fichier de traduction ne correspond à la locale du serveur. Inutile de les dupliquer dans `en.json`. Ajoutez les traductions dans les fichiers `i18n/` pour les autres locales. +::: + ```typescript // src/modules/mon-module/mon-module.module.ts @@ -177,6 +181,50 @@ model GuildConfiguration { - Un cache en mémoire (`configCache`) évite les lectures base de données à chaque interaction - Le cache est invalidé à chaque écriture +## Localisation + +Les noms et descriptions des champs de configuration peuvent être traduits par locale via des fichiers i18n. Le système résout automatiquement les valeurs localisées en fonction de la locale configurée du serveur (`/config core > locale`). + +### Traductions au niveau du module + +Créez un dossier `i18n/` dans votre module avec un fichier JSON par locale : + +```json +// i18n/fr.json +{ + "config.monChamp.name": "Mon Champ", + "config.monChamp.description": "Description de mon champ." +} + +// i18n/en.json +{ + "config.monChamp.name": "My Field", + "config.monChamp.description": "Description of my field." +} +``` + +Les valeurs `name` et `description` dans le schéma TypeScript servent de valeurs par défaut en anglais — pas besoin de les dupliquer dans `en.json`. + +### Utiliser config.t() pour les messages + +Le `ConfigProvider` injecté dans les commandes, écouteurs et interactions expose une méthode `t()` pour les chaînes localisées : + +```typescript +async execute(interaction, config) { + const message = config.t("messageBienvenue", { user: interaction.user.username }); + await interaction.reply(message); +} +``` + +### Repli de namespace + +Les clés sont résolues dans cet ordre : + +1. Le namespace du module (ex. `thread-creator:messageBienvenue`) +2. Le namespace cœur (`core:messageBienvenue`) + +Cela signifie que les chaînes d'interface communes comme `config.previous`, `config.next`, `modules.enable` et les noms de types (`type.text`, `type.number`, etc.) sont fournies par le namespace cœur — vous n'avez à traduire que les chaînes spécifiques à votre module. + ## Bonnes pratiques - **Utilisez le camelCase pour les clés**, un `name` lisible et une `description` claire diff --git a/docs/site/fr/guide/creating-a-module.md b/docs/site/fr/guide/creating-a-module.md index 1c09dd4..b2c4176 100644 --- a/docs/site/fr/guide/creating-a-module.md +++ b/docs/site/fr/guide/creating-a-module.md @@ -7,6 +7,9 @@ Un module dans OmniBot est une unité fonctionnelle autonome qui peut être inst ``` src/modules/mon-module/ ├── mon-module.module.ts # Définition principale du module +├── i18n/ # Fichiers de traduction +│ ├── en.json +│ └── fr.json ├── commands/ # Commandes slash │ └── saluer.command.ts ├── listeners/ # Écouteurs d'événements @@ -22,6 +25,10 @@ src/modules/mon-module/ ## Guide pas à pas : Créer un module « Salut » +::: tip +Le `name` et `description` du module doivent être en anglais par défaut — ils servent de valeur de repli quand aucun fichier de traduction ne correspond à la locale du serveur. Ajoutez les traductions dans les fichiers `i18n/` pour les autres locales. +::: + ### 1. Créer le dossier et le fichier principal ```typescript @@ -142,6 +149,57 @@ Le chargeur de modules (`src/core/loaders/module-loader.ts`) parcourt `src/modul **Modules devOnly** (avec `devOnly: true`) sont ignorés quand `NODE_ENV` n'est pas `"development"`. Utilisez ceci pour les modules de test ou de débogage. +## Internationalisation + +Les modules peuvent fournir des traductions pour leurs chaînes d'interface (nom, description, champs de configuration et messages du bot) via des fichiers de locale par module. + +### Ajouter des traductions + +Créez un dossier `i18n/` dans votre module avec un fichier JSON par locale : + +``` +src/modules/mon-module/ +├── mon-module.module.ts +├── i18n/ +│ ├── en.json +│ └── fr.json +└── commands/ + └── ... +``` + +Le chargeur de modules découvre automatiquement ces fichiers au démarrage et les enregistre dans i18next. + +### Format des fichiers de traduction + +Chaque fichier contient des paires clé-valeur. Le `name`, `description` et les champs de configuration du module sont automatiquement résolus depuis ces fichiers, remplaçant les valeurs par défaut TypeScript quand la locale correspondante est active : + +```json +{ + "module.name": "Mon Module", + "module.description": "Fait des choses géniales.", + "config.monChamp.name": "Mon Champ", + "config.monChamp.description": "Description de mon champ.", + "salutation": "Bonjour {{name}} !" +} +``` + +### Utiliser les traductions dans le code + +Le `ConfigProvider` injecté dans les commandes, écouteurs et interactions expose une méthode `t()` : + +```typescript +async execute(interaction, config) { + const salutation = config.t("salutation", { name: interaction.user.username }); + await interaction.reply(salutation); +} +``` + +Les clés sont d'abord cherchées dans le namespace du module, puis dans celui du cœur. Les libellés communs (`config.previous`, `config.next`, `modules.enable`, etc.) sont fournis par le namespace cœur — pas besoin de les redéfinir dans chaque module. + +### Sélection de la locale + +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. + ## Bonnes pratiques - **Gardez `onLoad` léger** — enregistrez les artefacts et loggez, déplacez la logique dans les services diff --git a/docs/site/fr/guide/getting-started.md b/docs/site/fr/guide/getting-started.md index 6584877..27444e2 100644 --- a/docs/site/fr/guide/getting-started.md +++ b/docs/site/fr/guide/getting-started.md @@ -4,7 +4,7 @@ Bienvenue sur OmniBot ! Ce guide vous aidera à configurer le bot pour le dével ## Qu'est-ce qu'OmniBot ? -OmniBot est un **bot Discord modulaire** développé pour la communauté [Graven - Développement](https://discord.gg/graven). Chaque fonctionnalité est un **module** autonome qui est auto-découvert au démarrage et peut être installé ou désinstallé **par serveur Discord** via `/modules`. Les modules déclarent leurs propres commandes slash, écouteurs d'événements, gestionnaires d'interactions et un schéma de configuration typé éditable en direct avec `/config `. +OmniBot est un **bot Discord modulaire** développé pour la communauté [Graven - Développement](https://discord.gg/graven). Chaque fonctionnalité est un **module** autonome qui est auto-découvert au démarrage et peut être installé ou désinstallé **par serveur Discord** via `/modules`. Les modules déclarent leurs propres commandes slash, écouteurs d'événements, gestionnaires d'interactions et un schéma de configuration typé éditable en direct avec `/config `. L'interface entière est traduisible — chaque module peut inclure ses propres fichiers de locale. Le système central relie le tout — ajouter une fonctionnalité signifie créer un module, sans jamais toucher au bootstrap. @@ -98,7 +98,9 @@ src/ │ └── utils/ # Garde de permission, parseur de version, messages ├── modules/ # Modules fonctionnels (un dossier par module) │ ├── thread-creator/ # Module exemple : création automatique de fils +│ │ └── i18n/ # Traductions du module (en.json, fr.json) │ └── test-config/ # Dev uniquement : teste tous les types de config +│ └── i18n/ # Traductions du module (en.json, fr.json) ├── lib/ # Contrats partagés exposés aux modules │ ├── module.ts # defineModule() │ ├── command.ts # declareCommand() @@ -134,6 +136,7 @@ Les fichiers utilisent des suffixes pour identifier leur rôle : | `*.select.ts` | Gestionnaire de menu de sélection | | `*.service.ts` | Service métier (ex. `file-attente.service.ts`) | | `*.prisma` | Définition de modèle Prisma | +| `i18n/*.json` | Fichiers de traduction (ex. `en.json`, `fr.json`) | --- diff --git a/docs/site/fr/guide/interactions.md b/docs/site/fr/guide/interactions.md index 52813ef..bf4c050 100644 --- a/docs/site/fr/guide/interactions.md +++ b/docs/site/fr/guide/interactions.md @@ -195,6 +195,21 @@ Les interactions sont automatiquement liées à l'activation du module. Quand un Lors de l'édition de la configuration via des menus de sélection éphémères, le message de configuration source (public) doit être mis à jour. Le système utilise `refreshSourceConfigMessage()` qui transmet l'ID du message source dans les arguments `customId` et réédite le message public après chaque modification. +## Localisation + +Les réponses d'interactions peuvent utiliser `config.t()` pour des chaînes localisées. Fournissez les traductions dans les fichiers `i18n/` de votre module : + +```typescript +async execute(interaction, [actionId, userId]) { + await interaction.reply({ + content: config.t("actionConfirmee", { action: actionId, user: userId }), + flags: MessageFlags.Ephemeral, + }); +} +``` + +La locale du serveur est configurée via `/config core > locale`. Voir [Configuration → Localisation](./configuration#localisation) pour les détails. + ## Bonnes pratiques ### Types de réponse diff --git a/docs/site/fr/guide/listeners.md b/docs/site/fr/guide/listeners.md index 7e9b9c3..de252f7 100644 --- a/docs/site/fr/guide/listeners.md +++ b/docs/site/fr/guide/listeners.md @@ -85,6 +85,19 @@ export default defineModule({ De plus, les **intentions privilégiées** (`GuildMembers`, `GuildPresences`, `MessageContent`) doivent être activées dans le Discord Developer Portal sous Bot > Privileged Gateway Intents. +## Localisation + +Les réponses des écouteurs peuvent utiliser `config.t()` pour des chaînes localisées. Fournissez les traductions dans les fichiers `i18n/` de votre module : + +```typescript +async execute(message, config) { + if (!config) return; + await message.reply(config.t("messageBienvenue", { user: message.author.username })); +} +``` + +La locale du serveur est configurée via `/config core > locale`. Voir [Configuration → Localisation](./configuration#localisation) pour les détails. + ## Bonnes pratiques - **Filtrez tôt** — vérifiez les bots, MPs ou salons non pertinents au début de `execute()` diff --git a/docs/site/fr/guide/localisation.md b/docs/site/fr/guide/localisation.md new file mode 100644 index 0000000..22646ed --- /dev/null +++ b/docs/site/fr/guide/localisation.md @@ -0,0 +1,153 @@ +# Localisation + +OmniBot permet de traduire les interfaces des modules par serveur grâce à un système d'i18n basé sur i18next. Chaque module peut fournir ses propres fichiers de traduction, avec un repli automatique vers le namespace principal. + +## Fonctionnement + +- La locale du serveur est configurée via `/config core > locale` (`en` ou `fr` actuellement). +- Chaque module peut inclure un dossier `i18n/` avec un fichier JSON par locale. +- Les clés sont résolues dans l'ordre : namespace du module → namespace principal. +- Quand un fichier de locale est manquant, l'anglais est utilisé comme valeur de repli. + +## Structure d'un module + +``` +src/modules/mon-module/ +├── mon-module.module.ts +├── i18n/ +│ ├── en.json +│ └── fr.json +└── commands/ + └── ... +``` + +Les fichiers de traduction sont découverts automatiquement au démarrage par le chargeur de modules — aucun enregistrement n'est nécessaire. + +## Format des fichiers de traduction + +```json +{ + "module.name": "Mon Module", + "module.description": "Fait des choses géniales.", + "config.myField.name": "Mon champ", + "config.myField.description": "Description de mon champ.", + "greeting": "Bonjour {{name}} !" +} +``` + +Utilisez la syntaxe {{param}} pour les valeurs dynamiques — ne concaténez jamais avec `${}` ou `+` dans le texte traduit. + +### Métadonnées du module + +| Clé | Remplace | +| -------------------- | ----------------------------------- | +| `module.name` | `name` dans `defineModule()` | +| `module.description` | `description` dans `defineModule()` | + +### Étiquettes des champs de configuration + +| Motif de clé | Remplace | +| ------------------------------- | ------------------------------------- | +| `config..name` | `name` du champ dans le schéma | +| `config..description` | `description` du champ dans le schéma | + +### Valeurs par défaut + +La valeur par défaut d'un champ texte libre (`STRING`) peut être localisée, afin qu'une valeur non configurée s'affiche dans la langue du serveur : + +| Motif de clé | Remplace | +| --------------------------- | ---------------------------------------------------- | +| `config..default` | Le `defaultValue` du champ (champs texte uniquement) | + +Le `defaultValue` du schéma reste le repli anglais. Les défauts ne sont **pas persistés** : `ConfigProvider.get()` les résout à la lecture, donc la valeur suit la langue du serveur et change en direct si la langue change — jusqu'à ce qu'un admin définisse explicitement le champ, après quoi la valeur stockée prime et ne suit plus la langue. + +Seuls les champs `STRING` sont localisés ainsi. Les défauts d'enum sont des identifiants stockés (pas du texte affichable) et sont renvoyés tels quels, comme les nombres, booléens et listes. + +Dans le panneau `/config`, tout champ que l'admin n'a pas défini — affichant un défaut, ou rien pour un champ qui n'en a pas — est signalé par un marqueur `config.defaultSuffix` (`_(par défaut)_`), pour voir d'un coup d'œil ce qui a réellement été modifié. `ConfigProvider.isSet(key)` expose la même distinction côté code. + +> [!NOTE] +> Les serveurs dont la config est antérieure à ce mécanisme conservent le défaut +> déjà stocké jusqu'à un reset via `/config` — il n'y a pas de migration +> automatique. + +### Libellés d'options d'enum localisés + +Un champ enum peut afficher ses options sous forme de noms localisés plutôt que de codes bruts grâce à l'indication `display` : + +```typescript +locale: { + type: ConfigType.ENUM, + options: ["en", "fr"] as const, + display: "language", +} +``` + +Avec `display: "language"`, chaque option est affichée via `Intl.DisplayNames` dans la langue du lecteur — `fr` devient « Français » (FR) ou « French » (EN), aussi bien dans le récapitulatif `/config` que dans le menu de sélection. Aucune clé de traduction n'est nécessaire ; les noms viennent du runtime. + +## Utiliser les traductions dans le code + +Le `ConfigProvider` injecté dans les commandes, écouteurs et interactions expose une méthode `t()` : + +```typescript +async execute(interaction, config) { + const greeting = config.t("greeting", { name: interaction.user.username }); + await interaction.reply(greeting); +} +``` + +### Localisation des noms de types + +La fonction utilitaire `getConfigTypeName()` accepte une `TFunction` optionnelle pour les noms de types localisés : + +```typescript +import { getConfigTypeName } from "#lib/config.js"; + +const label = getConfigTypeName(ConfigType.STRING, config.t); +// → "Text" (EN) ou "Texte" (FR) +``` + +### Dans les commandes + +```typescript +data: new SlashCommandBuilder() + .setName("hello") + .setDescription("Says hello!") + .setDescriptionLocalizations({ fr: "Dit bonjour !" }), +``` + +Dans ce bot, seules les descriptions sont localisées — les noms de commandes restent en anglais. + +## Repli vers le namespace principal + +Les chaînes d'interface communes sont fournies par le namespace principal et disponibles dans tous les modules sans être redéfinies : + +| Clé | Valeur anglais | Valeur français | +| ----------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | +| `config.previous` | ◀ Previous | ◀ Précédent | +| `config.next` | Next ▶ | Suivant ▶ | +| `config.page` | Page {{current}}/{{total}} | Page {{current}}/{{total}} | +| `modules.enable` | Enable | Activer | +| `modules.disable` | Disable | Désactiver | +| `type.text` | Text | Texte | +| `type.number` | Number | Nombre | +| `type.boolean` | Boolean | Booléen | +| `type.user` | User | Utilisateur | +| `type.role` | Role | Rôle | +| `type.channel` | Channel | Salon | +| `type.category` | Category | Catégorie | +| `type.choice` | Choice | Choix | +| `type.listOf` | List of {{type}} | Liste de {{type}} | + +## Ajouter une nouvelle locale + +1. Ajoutez le code de locale dans l'ENUM `locale` de `src/core/core.config.ts`. +2. Créez `i18n/.json` dans le module principal avec les traductions de toutes les clés principales. +3. Créez `i18n/.json` dans chaque module que vous souhaitez traduire. +4. Les admins du serveur peuvent ensuite sélectionner la nouvelle locale via `/config core > locale`. + +## Bonnes pratiques + +- **Écrivez les valeurs TypeScript en anglais** — elles servent de valeur de repli quand aucune traduction ne correspond. +- **Utilisez toujours des paramètres nommés** ({{param}}) dans les valeurs i18n, jamais les templates littéraux `${}`. +- **Ne traduisez que les chaînes spécifiques au module** — les étiquettes d'interface communes viennent du namespace principal. +- **Gardez les fichiers de traduction complets** — les clés manquantes tombent en anglais, ce qui peut produire un affichage multilingue. diff --git a/docs/site/fr/index.md b/docs/site/fr/index.md index 816b540..397ebfb 100644 --- a/docs/site/fr/index.md +++ b/docs/site/fr/index.md @@ -26,4 +26,7 @@ features: - icon: 🎛️ title: Configuration typée details: Déclarez un schéma de configuration et obtenez une UI interactive. + - icon: 🌐 + title: Internationalisation + details: Traduisez l'interface de votre module en plusieurs langues avec des fichiers i18n par module. --- diff --git a/package.json b/package.json index 1f68f03..d815893 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "dependencies": { "@prisma/client": "^6.19.3", "discord.js": "^14.21.0", + "i18next": "^26.3.1", "pino": "^10.3.1", "pino-pretty": "^13.1.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 021b367..dc68a51 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: discord.js: specifier: ^14.21.0 version: 14.26.4 + i18next: + specifier: ^26.3.1 + version: 26.3.1(typescript@5.9.3) pino: specifier: ^10.3.1 version: 10.3.1 @@ -1639,6 +1642,14 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + i18next@26.3.1: + resolution: {integrity: sha512-txQqd5EULsqEh9OJqRH15aCaOuy/nLJyhw5EHCSKLKJE1aBbb3Zve2+uQIxgWhPm1QqUQoWyQBm2kfmmIrzkcQ==} + peerDependencies: + typescript: ^5 || ^6 + peerDependenciesMeta: + typescript: + optional: true + import-fresh@3.3.1: resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} engines: {node: '>=6'} @@ -3718,6 +3729,10 @@ snapshots: html-void-elements@3.0.0: {} + i18next@26.3.1(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + import-fresh@3.3.1: dependencies: parent-module: 1.0.1 diff --git a/src/core/commands/config.command.ts b/src/core/commands/config.command.ts index 84e2c5a..4692c70 100644 --- a/src/core/commands/config.command.ts +++ b/src/core/commands/config.command.ts @@ -15,6 +15,7 @@ export default declareCommand({ data: new SlashCommandBuilder() .setName("config") .setDescription("Configure the modules of the bot") + .setDescriptionLocalizations({ fr: "Configurer les modules du bot" }) .setDefaultMemberPermissions(0x8) .addStringOption((option) => option @@ -28,12 +29,17 @@ export default declareCommand({ const module = [...modules, coreModule].find((m) => m.id === moduleId); if (!module) { + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); + await interaction.reply({ components: [ new ContainerBuilder() .setAccentColor(Colors.Red) .addTextDisplayComponents((text) => - text.setContent(`No module with id \`${moduleId}\``) + text.setContent(coreConfig.t("config.noModule", { moduleId })) ), ], flags: MessageFlags.IsComponentsV2, @@ -55,18 +61,27 @@ export default declareCommand({ }); }, async complete(interaction) { - const modules = await moduleService.getAllModulesStateIn( - interaction.guildId! - ); + const [modules, coreConfig] = await Promise.all([ + moduleService.getAllModulesStateIn(interaction.guildId!), + configService.getConfigForModuleIn(coreModule, interaction.guildId!), + ]); - const moduleNames = modules + const t = coreConfig.t; + const moduleEntries = modules .filter((m) => m.enabled) - .map((m) => ({ name: m.module.name, value: m.module.id })); + .map((m) => ({ + name: t("modules." + m.module.id + ".name", { + defaultValue: m.module.name, + }), + value: m.module.id, + })); await interaction.respond([ - ...moduleNames, + ...moduleEntries, { - name: coreModule.name, + name: t("modules." + coreModule.id + ".name", { + defaultValue: coreModule.name, + }), value: coreModule.id, }, ]); diff --git a/src/core/commands/module.command.ts b/src/core/commands/module.command.ts index 879219f..c7fc928 100644 --- a/src/core/commands/module.command.ts +++ b/src/core/commands/module.command.ts @@ -3,6 +3,8 @@ import { MessageFlags, SlashCommandBuilder, } from "discord.js"; +import coreModule from "#core/core.module.js"; +import configService from "#core/services/config.service.js"; import moduleService from "#core/services/module.service.js"; import { modulesMessage } from "#core/utils/core-messages.js"; import { declareCommand } from "#lib/command.js"; @@ -13,6 +15,7 @@ export default declareCommand({ data: new SlashCommandBuilder() .setName("modules") .setDescription("Manage server modules") + .setDescriptionLocalizations({ fr: "Gérer les modules du serveur" }) .setDefaultMemberPermissions(PERMISSION_ADMINISTRATOR) .setContexts([InteractionContextType.Guild]), @@ -21,12 +24,13 @@ export default declareCommand({ flags: MessageFlags.Ephemeral, }); - const modulesState = await moduleService.getAllModulesStateIn( - interaction.guildId! - ); + const [modulesState, coreConfig] = await Promise.all([ + moduleService.getAllModulesStateIn(interaction.guildId!), + configService.getConfigForModuleIn(coreModule, interaction.guildId!), + ]); await defer.edit({ - components: [modulesMessage(modulesState)], + components: [modulesMessage(modulesState, coreConfig.t)], flags: MessageFlags.IsComponentsV2, }); }, diff --git a/src/core/config/category.config-handler.ts b/src/core/config/category.config-handler.ts index 39477a8..5305923 100644 --- a/src/core/config/category.config-handler.ts +++ b/src/core/config/category.config-handler.ts @@ -22,13 +22,14 @@ export default class CategoryConfigHandler extends EntitySelectConfigHandler { const menu = new ChannelSelectMenuBuilder() .setCustomId(customId) .setPlaceholder( maxValues > 1 - ? "Sélectionnez des catégories" - : "Sélectionnez une catégorie" + ? t("select.category.selectMultiple") + : t("select.category.selectSingle") ) .setChannelTypes(ChannelType.GuildCategory) .setMinValues(minValues) diff --git a/src/core/config/channel.config-handler.ts b/src/core/config/channel.config-handler.ts index f16f4f8..d67507f 100644 --- a/src/core/config/channel.config-handler.ts +++ b/src/core/config/channel.config-handler.ts @@ -22,11 +22,14 @@ export default class ChannelConfigHandler extends EntitySelectConfigHandler { const menu = new ChannelSelectMenuBuilder() .setCustomId(customId) .setPlaceholder( - maxValues > 1 ? "Sélectionnez des salons" : "Sélectionnez un salon" + maxValues > 1 + ? t("select.channel.selectMultiple") + : t("select.channel.selectSingle") ) .setChannelTypes( ChannelType.GuildText, diff --git a/src/core/config/config-edit.ts b/src/core/config/config-edit.ts index 974a128..752ebda 100644 --- a/src/core/config/config-edit.ts +++ b/src/core/config/config-edit.ts @@ -84,6 +84,24 @@ export async function refreshSourceConfigMessage( module: Module, sourceMessageId: string | undefined, key: string +): Promise { + await refreshSourceConfigMessageAtPage( + interaction, + module, + sourceMessageId, + configPageOfKey(module, key) + ); +} + +/** + * Like {@link refreshSourceConfigMessage} but re-renders an explicit page, + * for edits that don't map to a single key (e.g. resetting several fields). + */ +export async function refreshSourceConfigMessageAtPage( + interaction: CompatibleInteraction, + module: Module, + sourceMessageId: string | undefined, + page: number ): Promise { if (!sourceMessageId || !interaction.channelId) { return; @@ -106,11 +124,7 @@ export async function refreshSourceConfigMessage( const message = await channel.messages.fetch(sourceMessageId); await message.edit({ - components: configurationMessage( - module, - provider, - configPageOfKey(module, key) - ), + components: configurationMessage(module, provider, page), flags: MessageFlags.IsComponentsV2, }); } catch (err) { diff --git a/src/core/config/config-handlers.integration.test.ts b/src/core/config/config-handlers.integration.test.ts index f91affa..414f1bf 100644 --- a/src/core/config/config-handlers.integration.test.ts +++ b/src/core/config/config-handlers.integration.test.ts @@ -16,8 +16,25 @@ import type { Registry } from "#lib/registry.js"; // Mock the persistence boundary so importing the handlers does not boot the bot // (config.service.js and config-edit.js both pull in ../../index.js). +vi.mock("#core/core.module.js", () => ({ + default: { + id: "core", + name: "Core Module", + description: "", + version: "1.0.0", + config: {}, + registry: { commands: [], interactionHandlers: [] }, + onLoad: vi.fn(), + onInstall: vi.fn(), + onUninstall: vi.fn(), + }, +})); vi.mock("#core/services/config.service.js", () => ({ - default: { isConfigKey: vi.fn(), updateConfigForModuleIn: vi.fn() }, + default: { + isConfigKey: vi.fn(), + updateConfigForModuleIn: vi.fn(), + getConfigForModuleIn: vi.fn(), + }, })); vi.mock("./config-edit.js", () => ({ resolveConfigurableModule: vi.fn(), @@ -42,6 +59,7 @@ const { default: EnumConfigHandler } = await import("./enum.config-handler.js"); const isConfigKey = vi.mocked(configService.isConfigKey); const updateConfig = vi.mocked(configService.updateConfigForModuleIn); +const getConfig = vi.mocked(configService.getConfigForModuleIn); const resolveModule = vi.mocked(resolveConfigurableModule); const save = vi.mocked(saveConfigValue); const isList = vi.mocked(isListEntry); @@ -49,6 +67,15 @@ const configEntry = vi.mocked(getConfigEntry); const fakeModule = { id: "mod" } as unknown as Module; +function mockConfigProvider( + t?: (key: string, opts?: Record) => string +): ConfigProvider { + return { + get: vi.fn(), + t: t ?? ((key: string) => key), + } as unknown as ConfigProvider; +} + /** * Drives a handler's `registerEditionInteractionHandlers` with a capturing * registry and returns the single interaction handler it registers. @@ -70,7 +97,8 @@ beforeEach(() => { resolveModule.mockReturnValue(fakeModule); isConfigKey.mockReturnValue(true); save.mockResolvedValue([]); - updateConfig.mockResolvedValue({} as never); + updateConfig.mockResolvedValue(mockConfigProvider()); + getConfig.mockResolvedValue(mockConfigProvider()); isList.mockReturnValue(false); }); @@ -98,7 +126,9 @@ describe("NumberConfigHandler modal submit", () => { expect(save).not.toHaveBeenCalled(); expect(interaction.reply).toHaveBeenCalledWith( - expect.objectContaining({ content: expect.stringContaining("nombre") }) + expect.objectContaining({ + content: expect.stringContaining("config.number.invalid"), + }) ); }); @@ -132,7 +162,7 @@ describe("NumberConfigHandler modal submit", () => { expect(save).not.toHaveBeenCalled(); expect(interaction.reply).toHaveBeenCalledWith( expect.objectContaining({ - content: expect.stringContaining("introuvable"), + content: expect.stringContaining("configOptionNotFound"), }) ); }); @@ -341,6 +371,7 @@ describe("EnumConfigHandler select submit", () => { } as unknown as ButtonInteraction; const config = { get: () => undefined, + t: (key: string) => key, } as unknown as ConfigProvider; await handler.replyToEditRequest( @@ -352,7 +383,9 @@ describe("EnumConfigHandler select submit", () => { ); expect(interaction.reply).toHaveBeenCalledWith( - expect.objectContaining({ content: expect.stringContaining("option") }) + expect.objectContaining({ + content: expect.stringContaining("config.enum.noOptions"), + }) ); }); }); diff --git a/src/core/config/enum.config-handler.ts b/src/core/config/enum.config-handler.ts index 97402fd..1da3877 100644 --- a/src/core/config/enum.config-handler.ts +++ b/src/core/config/enum.config-handler.ts @@ -4,7 +4,13 @@ import { type AnySelectMenuInteraction, type MessageActionRowComponentBuilder, } from "discord.js"; -import { ConfigType, isEnumEntry } from "#lib/config.js"; +import { + ConfigType, + configValueEmoji, + formatConfigValue, + isEnumEntry, +} from "#lib/config.js"; +import type { TFunction } from "#lib/i18n.js"; import type { CompatibleInteraction } from "#lib/interaction.js"; import type { Module } from "#lib/module.js"; import { getConfigEntry } from "./config-edit.js"; @@ -60,37 +66,45 @@ export default class EnumConfigHandler extends SelectConfigHandler { - const options = optionsFor(module, key).slice(0, MAX_SELECT_VALUES); + const entry = getConfigEntry(_module, _key); + const options = optionsFor(_module, _key).slice(0, MAX_SELECT_VALUES); const menu = new StringSelectMenuBuilder() .setCustomId(customId) .setPlaceholder( maxValues > 1 - ? "Sélectionnez une ou plusieurs valeurs" - : "Sélectionnez une valeur" + ? t("config.enum.selectMultiple") + : t("config.enum.selectSingle") ) .setMinValues(minValues) .setMaxValues(maxValues) .addOptions( - options.map((option) => ({ - label: option, - value: option, - default: current.includes(option), - })) + options.map((option) => { + const emoji = entry ? configValueEmoji(entry, option) : undefined; + return { + label: entry ? formatConfigValue(entry, option, locale) : option, + value: option, + default: current.includes(option), + ...(emoji ? { emoji: { name: emoji } } : {}), + }; + }) ); return new ActionRowBuilder().addComponents( diff --git a/src/core/config/number.config-handler.ts b/src/core/config/number.config-handler.ts index 00605e0..a87b06e 100644 --- a/src/core/config/number.config-handler.ts +++ b/src/core/config/number.config-handler.ts @@ -7,6 +7,7 @@ import { type ButtonInteraction, } from "discord.js"; import configService from "#core/services/config.service.js"; +import { replyWithCoreT } from "#core/utils/core-config.js"; import { ConfigType, ConfigValidator, @@ -29,17 +30,16 @@ export default class NumberConfigHandler extends ConfigTypeHandler, config: ConfigProvider, key: keyof TSchema, - // The modal updates the public config message in place via isFromMessage. _sourceMessageId: string ): Promise { const modal = new ModalBuilder() .setCustomId(`set-config-number-modal:${module.id}:${key.toString()}`) - .setTitle(`Set ${key.toString()}`) + .setTitle(config.t("setConfig.title", { key: key.toString() })) .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId("value") - .setLabel(`Enter a value (number):`) + .setLabel(config.t("setConfig.label", { type: "number" })) .setValue((config.get(key) ?? "").toString()) .setStyle(TextInputStyle.Short) .setRequired(true) @@ -62,19 +62,21 @@ const handleModalSubmit = declareInteractionHandler({ check: (interaction) => interaction.isModalSubmit(), execute: async (interaction, [moduleId, configKey]) => { const module = resolveConfigurableModule(moduleId); + if (!module || !configService.isConfigKey(module, configKey)) { - await interaction.reply({ - content: "Configuration introuvable.", - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } const raw = interaction.fields.getTextInputValue("value"); if (!ConfigValidator[ConfigType.NUMBER](raw)) { + const config = await configService.getConfigForModuleIn( + module, + interaction.guildId! + ); await interaction.reply({ - content: `❌ \`${raw}\` n'est pas un nombre valide.`, + content: config.t("config.number.invalid", { value: raw }), flags: MessageFlags.Ephemeral, }); return; @@ -87,7 +89,6 @@ const handleModalSubmit = declareInteractionHandler({ Number(raw) ); - // Edit the config message in place rather than posting a new embed. if (interaction.isFromMessage()) { await interaction.update({ components, diff --git a/src/core/config/role.config-handler.ts b/src/core/config/role.config-handler.ts index 3f55f03..1975c6d 100644 --- a/src/core/config/role.config-handler.ts +++ b/src/core/config/role.config-handler.ts @@ -21,11 +21,14 @@ export default class RoleConfigHandler extends EntitySelectConfigHandler { const menu = new RoleSelectMenuBuilder() .setCustomId(customId) .setPlaceholder( - maxValues > 1 ? "Sélectionnez des rôles" : "Sélectionnez un rôle" + maxValues > 1 + ? t("select.role.selectMultiple") + : t("select.role.selectSingle") ) .setMinValues(minValues) .setMaxValues(maxValues); diff --git a/src/core/config/scalar-list-editor.integration.test.ts b/src/core/config/scalar-list-editor.integration.test.ts index 9bbd094..d5c03c9 100644 --- a/src/core/config/scalar-list-editor.integration.test.ts +++ b/src/core/config/scalar-list-editor.integration.test.ts @@ -6,6 +6,19 @@ import type { Module } from "#lib/module.js"; import type { Registry } from "#lib/registry.js"; // Mock the persistence boundary so importing the editor does not boot the bot. +vi.mock("#core/core.module.js", () => ({ + default: { + id: "core", + name: "Core Module", + description: "", + version: "1.0.0", + config: {}, + registry: { commands: [], interactionHandlers: [] }, + onLoad: vi.fn(), + onInstall: vi.fn(), + onUninstall: vi.fn(), + }, +})); vi.mock("#core/services/config.service.js", () => ({ default: { isConfigKey: vi.fn(), @@ -43,15 +56,22 @@ function handlers(): Record>> { return Object.fromEntries(registered.map((h) => [h.customId, h])); } +function mockProvider(values: unknown[]) { + return { + get: () => values, + t: (key: string) => key, + } as never; +} + function currentValues(values: unknown[]) { - getConfig.mockResolvedValue({ get: () => values } as never); + getConfig.mockResolvedValue(mockProvider(values)); } beforeEach(() => { vi.clearAllMocks(); resolveModule.mockReturnValue(fakeModule); isConfigKey.mockReturnValue(true); - updateConfig.mockResolvedValue({} as never); + updateConfig.mockResolvedValue({ t: (key: string) => key } as never); entry.mockReturnValue({ name: "Count", description: "", diff --git a/src/core/config/scalar-list-editor.ts b/src/core/config/scalar-list-editor.ts index 22748d7..904e470 100644 --- a/src/core/config/scalar-list-editor.ts +++ b/src/core/config/scalar-list-editor.ts @@ -11,12 +11,14 @@ import { type ButtonInteraction, } from "discord.js"; import configService from "#core/services/config.service.js"; +import { replyWithCoreT } from "#core/utils/core-config.js"; import { ConfigType, ConfigValidator, getConfigTypeName, type ListOf, } from "#lib/config.js"; +import type { TFunction } from "#lib/i18n.js"; import { declareInteractionHandler } from "#lib/interaction.js"; import type { Module } from "#lib/module.js"; import type { Registry } from "#lib/registry.js"; @@ -42,14 +44,14 @@ function baseType(entry: { return (entry.type as ListOf)[0]; } -function inputLabel(type: ConfigType): string { +function inputLabel(type: ConfigType, t: TFunction): string { switch (type) { case ConfigType.NUMBER: - return "Valeur (nombre)"; + return t("scalarList.valueNumber"); case ConfigType.BOOLEAN: - return "Valeur (true / false)"; + return t("scalarList.valueBoolean"); default: - return "Valeur (texte)"; + return t("scalarList.valueText"); } } @@ -75,15 +77,31 @@ export function scalarListEditorMessage( module: Module, key: string, values: unknown[], - sourceMessageId: string + sourceMessageId: string, + t: TFunction ): ContainerBuilder[] { const entry = getConfigEntry(module, key); const container = new ContainerBuilder().setAccentColor(Colors.Turquoise); + const modName = t("modules." + module.id + ".name", { + defaultValue: module.name, + }); + const entryName = entry + ? t("config." + key + ".name", { defaultValue: entry.name }) + : key; + const entryDesc = entry + ? t("config." + key + ".description", { defaultValue: entry.description }) + : ""; + const typeName = entry ? getConfigTypeName(entry.type, t) : ""; + container.addTextDisplayComponents((text) => text.setContent( - `# \`${module.name}\` — ${entry?.name ?? key}\n` + - `-# ${entry ? getConfigTypeName(entry.type) : ""}\n> ${entry?.description ?? ""}` + t("scalarList.header", { + moduleName: modName, + entryName, + typeName, + entryDesc, + }) ) ); container.addSeparatorComponents((separator) => separator.setDivider(true)); @@ -93,19 +111,23 @@ export function scalarListEditorMessage( if (values.length === 0) { container.addTextDisplayComponents((text) => - text.setContent("-# Liste vide.") + text.setContent(t("scalarList.empty")) ); } else if (type === ConfigType.BOOLEAN) { - // Booleans are flipped in place via a toggle; a separate remove button drops them. values.forEach((value, index) => { const row = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`toggle-list-item:${idSuffix}:${index}`) - .setLabel(`Élément ${index + 1} : ${value ? "vrai" : "faux"}`) + .setLabel( + t("scalarList.booleanItem", { + index: String(index + 1), + value: value ? "true" : "false", + }) + ) .setStyle(value ? ButtonStyle.Success : ButtonStyle.Danger), new ButtonBuilder() .setCustomId(`remove-list-item:${idSuffix}:${index}`) - .setLabel("Supprimer") + .setLabel(t("scalarList.remove")) .setStyle(ButtonStyle.Secondary) ); container.addActionRowComponents(row); @@ -119,7 +141,7 @@ export function scalarListEditorMessage( .setButtonAccessory((button) => button .setCustomId(`remove-list-item:${idSuffix}:${index}`) - .setLabel("Supprimer") + .setLabel(t("scalarList.remove")) .setStyle(ButtonStyle.Danger) ); container.addSectionComponents(section); @@ -129,7 +151,7 @@ export function scalarListEditorMessage( const addRow = new ActionRowBuilder().addComponents( new ButtonBuilder() .setCustomId(`add-list-item:${idSuffix}`) - .setLabel("Ajouter") + .setLabel(t("scalarList.add")) .setStyle(ButtonStyle.Success) ); container.addActionRowComponents(addRow); @@ -145,8 +167,19 @@ export async function openScalarListEditor( values: unknown[], sourceMessageId: string ): Promise { + const config = await configService.getConfigForModuleIn( + module, + interaction.guildId! + ); + await interaction.reply({ - components: scalarListEditorMessage(module, key, values, sourceMessageId), + components: scalarListEditorMessage( + module, + key, + values, + sourceMessageId, + config.t + ), flags: MessageFlags.Ephemeral + MessageFlags.IsComponentsV2, }); } @@ -158,17 +191,13 @@ const addListItem = declareInteractionHandler({ execute: async (interaction, [moduleId, key, sourceMessageId]) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, key)) { - await interaction.reply({ - content: "Configuration introuvable.", - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } const entry = getConfigEntry(module, key)!; const type = baseType(entry); - // Booleans are added directly (then toggled in place) — no modal to type. if (type === ConfigType.BOOLEAN) { const provider = await configService.getConfigForModuleIn( module, @@ -191,7 +220,8 @@ const addListItem = declareInteractionHandler({ module, key, values, - sourceMessageId! + sourceMessageId!, + provider.t ), flags: MessageFlags.IsComponentsV2, }); @@ -204,14 +234,19 @@ const addListItem = declareInteractionHandler({ return; } + const config = await configService.getConfigForModuleIn( + module, + interaction.guildId! + ); + const modal = new ModalBuilder() .setCustomId(`add-list-item-modal:${module.id}:${key}:${sourceMessageId}`) - .setTitle(`Ajouter — ${entry.name}`) + .setTitle(config.t("scalarList.addTitle", { name: entry.name })) .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId("value") - .setLabel(inputLabel(type)) + .setLabel(inputLabel(type, config.t)) .setStyle(TextInputStyle.Short) .setRequired(true) ) @@ -228,10 +263,7 @@ const toggleListItem = declareInteractionHandler({ execute: async (interaction, [moduleId, key, sourceMessageId, indexRaw]) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, key)) { - await interaction.reply({ - content: "Configuration introuvable.", - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } @@ -255,7 +287,8 @@ const toggleListItem = declareInteractionHandler({ module, key, values, - sourceMessageId! + sourceMessageId!, + provider.t ), flags: MessageFlags.IsComponentsV2, }); @@ -270,18 +303,19 @@ const addListItemModal = declareInteractionHandler({ execute: async (interaction, [moduleId, key, sourceMessageId]) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, key)) { - await interaction.reply({ - content: "Configuration introuvable.", - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } const type = baseType(getConfigEntry(module, key)!); const raw = interaction.fields.getTextInputValue("value").trim(); if (!ConfigValidator[type](raw)) { + const config = await configService.getConfigForModuleIn( + module, + interaction.guildId! + ); await interaction.reply({ - content: `❌ \`${raw}\` n'est pas une valeur valide.`, + content: config.t("scalarList.invalidValue", { value: raw }), flags: MessageFlags.Ephemeral, }); return; @@ -303,7 +337,8 @@ const addListItemModal = declareInteractionHandler({ module, key, values, - sourceMessageId! + sourceMessageId!, + provider.t ); if (interaction.isFromMessage()) { await interaction.update({ @@ -327,10 +362,7 @@ const removeListItem = declareInteractionHandler({ execute: async (interaction, [moduleId, key, sourceMessageId, indexRaw]) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, key)) { - await interaction.reply({ - content: "Configuration introuvable.", - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } @@ -354,7 +386,8 @@ const removeListItem = declareInteractionHandler({ module, key, values, - sourceMessageId! + sourceMessageId!, + provider.t ), flags: MessageFlags.IsComponentsV2, }); diff --git a/src/core/config/select.config-handler.ts b/src/core/config/select.config-handler.ts index bbed72f..40d3d3d 100644 --- a/src/core/config/select.config-handler.ts +++ b/src/core/config/select.config-handler.ts @@ -7,7 +7,9 @@ import { type MessageActionRowComponentBuilder, } from "discord.js"; import configService from "#core/services/config.service.js"; +import { replyWithCoreT } from "#core/utils/core-config.js"; import type { ConfigProvider, ConfigSchema, ConfigType } from "#lib/config.js"; +import type { TFunction } from "#lib/i18n.js"; import { declareInteractionHandler, type CompatibleInteraction, @@ -35,6 +37,9 @@ export interface SelectRowContext { current: string[]; minValues: number; maxValues: number; + t: TFunction; + /** Viewer locale, for formatting option labels (e.g. language names). */ + locale: string; } /** @@ -84,7 +89,8 @@ export abstract class SelectConfigHandler< */ protected editorUnavailableReason( _module: Module, - _key: string + _key: string, + _t: TFunction ): string | null { return null; } @@ -94,7 +100,9 @@ export abstract class SelectConfigHandler< module: Module, key: string, current: string[], - sourceMessageId: string + sourceMessageId: string, + t: TFunction, + locale: string ): ContainerBuilder { const customId = `${this.selectCustomId}:${module.id}:${key}:${sourceMessageId}`; const isList = isListEntry(getConfigEntry(module, key)); @@ -103,6 +111,8 @@ export abstract class SelectConfigHandler< key, customId, current, + t, + locale, minValues: isList ? 0 : 1, maxValues: isList ? this.maxSelectableValues(module, key) : 1, }); @@ -117,7 +127,7 @@ export abstract class SelectConfigHandler< key: keyof TSchema, sourceMessageId: string ): Promise { - const reason = this.editorUnavailableReason(module, String(key)); + const reason = this.editorUnavailableReason(module, String(key), config.t); if (reason) { await interaction.reply({ content: reason, @@ -130,7 +140,9 @@ export abstract class SelectConfigHandler< module, String(key), this.currentValues(config.get(key)), - sourceMessageId + sourceMessageId, + config.t, + config.locale ); await interaction.reply({ @@ -158,10 +170,10 @@ export abstract class SelectConfigHandler< ) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, configKey)) { - await interaction.reply({ - content: "Configuration introuvable.", - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT( + interaction, + "interaction.configOptionNotFound" + ); return; } @@ -169,8 +181,12 @@ export abstract class SelectConfigHandler< if ( !values.every((value) => isValidValue(value, module, configKey)) ) { + const config = await configService.getConfigForModuleIn( + module, + interaction.guildId! + ); await interaction.reply({ - content: "Valeur sélectionnée invalide.", + content: config.t("config.invalidSelection"), flags: MessageFlags.Ephemeral, }); return; @@ -185,12 +201,24 @@ export abstract class SelectConfigHandler< { [configKey]: value } ); + const config = await configService.getConfigForModuleIn( + module, + interaction.guildId! + ); + // Keep the (ephemeral) select open with the new selection, then refresh // the public config message — never render a config view here, so its // edit buttons can't point at an ephemeral (uneditable) message. await interaction.update({ components: [ - buildEditorContainer(module, configKey, values, sourceMessageId!), + buildEditorContainer( + module, + configKey, + values, + sourceMessageId!, + config.t, + config.locale + ), ], flags: MessageFlags.IsComponentsV2, }); diff --git a/src/core/config/string.config-handler.ts b/src/core/config/string.config-handler.ts index 80018d3..464032d 100644 --- a/src/core/config/string.config-handler.ts +++ b/src/core/config/string.config-handler.ts @@ -7,6 +7,7 @@ import { TextInputStyle, } from "discord.js"; import configService from "#core/services/config.service.js"; +import { replyWithCoreT } from "#core/utils/core-config.js"; import { ConfigProvider, ConfigType, type ConfigSchema } from "#lib/config.js"; import { declareInteractionHandler } from "#lib/interaction.js"; import type { Module } from "#lib/module.js"; @@ -24,18 +25,16 @@ export default class StringConfigHandler extends ConfigTypeHandler, config: ConfigProvider, key: string, - // The modal updates the public config message in place via isFromMessage, - // so it does not need the source message id. _sourceMessageId: string ): Promise { const modal = new ModalBuilder() .setCustomId(`set-string-config-modal:${module.id}:${key}`) - .setTitle(`Set ${key}`) + .setTitle(config.t("setConfig.title", { key })) .addComponents( new ActionRowBuilder().addComponents( new TextInputBuilder() .setCustomId("value") - .setLabel(`Enter a value (text):`) + .setLabel(config.t("setConfig.label", { type: "text" })) .setStyle(TextInputStyle.Short) .setValue((config.get(key) ?? "").toString()) .setRequired(true) @@ -58,11 +57,9 @@ const handleModalSubmit = declareInteractionHandler({ check: (interaction) => interaction.isModalSubmit(), execute: async (interaction, [moduleId, configKey]) => { const module = resolveConfigurableModule(moduleId); + if (!module || !configService.isConfigKey(module, configKey)) { - await interaction.reply({ - content: "Configuration introuvable.", - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } @@ -75,7 +72,6 @@ const handleModalSubmit = declareInteractionHandler({ value ); - // Edit the config message in place rather than posting a new embed. if (interaction.isFromMessage()) { await interaction.update({ components, diff --git a/src/core/config/user.config-handler.ts b/src/core/config/user.config-handler.ts index f8e4b34..f867c96 100644 --- a/src/core/config/user.config-handler.ts +++ b/src/core/config/user.config-handler.ts @@ -21,13 +21,14 @@ export default class UserConfigHandler extends EntitySelectConfigHandler { const menu = new UserSelectMenuBuilder() .setCustomId(customId) .setPlaceholder( maxValues > 1 - ? "Sélectionnez des utilisateurs" - : "Sélectionnez un utilisateur" + ? t("select.user.selectMultiple") + : t("select.user.selectSingle") ) .setMinValues(minValues) .setMaxValues(maxValues); diff --git a/src/core/core.config.ts b/src/core/core.config.ts new file mode 100644 index 0000000..490200c --- /dev/null +++ b/src/core/core.config.ts @@ -0,0 +1,14 @@ +import { ConfigType, type ConfigSchema } from "#lib/config.js"; + +export const coreConfigSchema = { + locale: { + name: "Language", + description: "Bot language", + type: ConfigType.ENUM, + options: ["en", "fr"] as const, + defaultValue: "en", + display: "language", + }, +} satisfies ConfigSchema; + +export type CoreConfig = typeof coreConfigSchema; diff --git a/src/core/core.module.ts b/src/core/core.module.ts index f7acfb2..efd96d5 100644 --- a/src/core/core.module.ts +++ b/src/core/core.module.ts @@ -3,20 +3,27 @@ import configCommand from "./commands/config.command.js"; import moduleCommand from "./commands/module.command.js"; import configTypeHandlers from "./config/config-handler-registry.js"; import { registerScalarListEditorHandlers } from "./config/scalar-list-editor.js"; +import type { CoreConfig } from "./core.config.js"; +import { coreConfigSchema } from "./core.config.js"; import configPageButton from "./interactions/config-page.button.js"; import configureModuleButton from "./interactions/configure-module.button.js"; import disableModuleButton from "./interactions/disable-module.button.js"; import enableModuleButton from "./interactions/enable-module.button.js"; +import { + resetConfigButton, + resetConfigSelect, +} from "./interactions/reset-config.js"; import toggleOptionButton from "./interactions/toggle-option.button.js"; import commandListener from "./listeners/interaction-create.listener.js"; export default defineModule({ id: "core", - name: "Core Module", + name: "Global Configuration", description: "The core module of the application, managing core commands and events. It is always loaded.", version: "1.1.1", intents: [], + config: coreConfigSchema satisfies CoreConfig, onLoad(_, registry) { // Register the core module's commands and events in the provided registry registry.register(moduleCommand); @@ -31,6 +38,9 @@ export default defineModule({ registry.register(toggleOptionButton); registry.register(configPageButton); + registry.register(resetConfigButton); + registry.register(resetConfigSelect); + for (const [, handler] of Object.entries(configTypeHandlers)) { handler?.registerEditionInteractionHandlers(registry); } diff --git a/src/core/i18n/en.json b/src/core/i18n/en.json new file mode 100644 index 0000000..f413077 --- /dev/null +++ b/src/core/i18n/en.json @@ -0,0 +1,85 @@ +{ + "modules.title": "# Modules", + "modules.description": "The modules marked with ✅ are enabled, while those marked with ❌ are disabled.", + "modules.enable": "Enable", + "modules.disable": "Disable", + "modules.item": "{{emoji}} `{{moduleName}}`{{version}}\n> {{description}}", + "config.noConfig": "This module has no configuration available.", + "config.reset.button": "Reset…", + "config.reset.placeholder": "Select the fields to reset", + "config.reset.all": "↩️ All fields", + "config.reset.done": "✅ The selected fields were reset to their default.", + "config.reset.nothing": "Nothing to reset — every field is already on its default.", + "config.previous": "◀ Previous", + "config.next": "Next ▶", + "config.noModule": "No module with id `{{moduleId}}`", + "config.currentValue": "Current: {{value}}", + "config.defaultSuffix": " _(default)_", + "config.notFound": "Configuration not found.", + "config.invalidSelection": "Invalid selected value.", + "config.number.invalid": "❌ `{{value}}` is not a valid number.", + + "config.enum.noOptions": "This field declares no options.", + "config.enum.selectMultiple": "Select one or more values", + "config.enum.selectSingle": "Select a value", + + "admin.noPermission": "You do not have permission to manage modules.", + + "setConfig.title": "Set {{key}}", + "setConfig.label": "Enter a value ({{type}}):", + + "command.notEnabled": "The command `{{commandName}}` is not enabled in this guild.", + + "interaction.malformed": "The button is malformed. Please try again later.", + "interaction.moduleNotFound": "Module not found. Please try again later.", + "interaction.failedEnable": "Failed to enable the module. Please try again later.", + "interaction.failedDisable": "Failed to disable the module. Please try again later.", + "interaction.configOptionNotFound": "Configuration option not found.", + "interaction.notBoolean": "This option is not a boolean toggle.", + + "scalarList.header": "# `{{moduleName}}` — {{entryName}}\n-# {{typeName}}\n> {{entryDesc}}", + "scalarList.valueText": "Value (text)", + "scalarList.valueNumber": "Value (number)", + "scalarList.valueBoolean": "Value (true / false)", + "scalarList.empty": "-# Empty list.", + "scalarList.booleanItem": "Item {{index}}: {{value}}", + "scalarList.remove": "Remove", + "scalarList.add": "Add", + "scalarList.addTitle": "Add — {{name}}", + "scalarList.invalidValue": "❌ `{{value}}` is not a valid value.", + + "select.user.selectMultiple": "Select users", + "select.user.selectSingle": "Select a user", + "select.role.selectMultiple": "Select roles", + "select.role.selectSingle": "Select a role", + "select.channel.selectMultiple": "Select channels", + "select.channel.selectSingle": "Select a channel", + "select.category.selectMultiple": "Select categories", + "select.category.selectSingle": "Select a category", + + "config.header": "# Settings · `{{moduleName}}`", + "config.page": "-# Page {{current}}/{{total}}", + "config.option": "-# {{typeName}}\n**⚙️ {{optionName}}**\n> {{optionDesc}}\n{{currentValue}}\n\n", + + "type.text": "Text", + "type.number": "Number", + "type.boolean": "Boolean", + "type.user": "User", + "type.role": "Role", + "type.channel": "Channel", + "type.category": "Category", + "type.choice": "Choice", + "type.listOf": "List of {{type}}", + + "modules.core.name": "Global Configuration", + "modules.core.description": "The core module of the application, managing core commands and events. It is always loaded.", + + "modules.thread-creator.name": "Thread Creator", + "modules.thread-creator.description": "Automatically creates discussion threads under each new message in configured channels. Replaces Needle bot.", + + "modules.test-config.name": "Test Config", + "modules.test-config.description": "Development module declaring all configuration types, used to test the configuration UI.", + + "config.locale.name": "Language", + "config.locale.description": "Bot language" +} diff --git a/src/core/i18n/fr.json b/src/core/i18n/fr.json new file mode 100644 index 0000000..3750f18 --- /dev/null +++ b/src/core/i18n/fr.json @@ -0,0 +1,85 @@ +{ + "modules.title": "# Modules", + "modules.description": "Les modules marqués avec ✅ sont activés, ceux marqués avec ❌ sont désactivés.", + "modules.enable": "Activer", + "modules.disable": "Désactiver", + "modules.item": "{{emoji}} `{{moduleName}}`{{version}}\n> {{description}}", + "config.noConfig": "Ce module n'a aucune configuration disponible.", + "config.reset.button": "Réinitialiser…", + "config.reset.placeholder": "Choisissez les champs à réinitialiser", + "config.reset.all": "↩️ Tous les champs", + "config.reset.done": "✅ Les champs sélectionnés ont été réinitialisés à leur valeur par défaut.", + "config.reset.nothing": "Rien à réinitialiser — tous les champs sont déjà à leur valeur par défaut.", + "config.previous": "◀ Précédent", + "config.next": "Suivant ▶", + "config.noModule": "Aucun module avec l'id `{{moduleId}}`", + "config.currentValue": "Valeur actuelle : {{value}}", + "config.defaultSuffix": " _(par défaut)_", + "config.notFound": "Configuration introuvable.", + "config.invalidSelection": "Valeur sélectionnée invalide.", + "config.number.invalid": "❌ `{{value}}` n'est pas un nombre valide.", + + "config.enum.noOptions": "Ce champ ne déclare aucune option.", + "config.enum.selectMultiple": "Sélectionnez une ou plusieurs valeurs", + "config.enum.selectSingle": "Sélectionnez une valeur", + + "admin.noPermission": "Vous n'avez pas la permission de gérer les modules.", + + "setConfig.title": "Définir {{key}}", + "setConfig.label": "Entrez une valeur ({{type}}) :", + + "command.notEnabled": "La commande `{{commandName}}` n'est pas activée sur ce serveur.", + + "interaction.malformed": "Le bouton est mal formé. Veuillez réessayer plus tard.", + "interaction.moduleNotFound": "Module introuvable. Veuillez réessayer plus tard.", + "interaction.failedEnable": "Échec de l'activation du module. Veuillez réessayer plus tard.", + "interaction.failedDisable": "Échec de la désactivation du module. Veuillez réessayer plus tard.", + "interaction.configOptionNotFound": "Option de configuration introuvable.", + "interaction.notBoolean": "Cette option n'est pas un booléen.", + + "scalarList.header": "# `{{moduleName}}` — {{entryName}}\n-# {{typeName}}\n> {{entryDesc}}", + "scalarList.valueText": "Valeur (texte)", + "scalarList.valueNumber": "Valeur (nombre)", + "scalarList.valueBoolean": "Valeur (true / false)", + "scalarList.empty": "-# Liste vide.", + "scalarList.booleanItem": "Élément {{index}} : {{value}}", + "scalarList.remove": "Supprimer", + "scalarList.add": "Ajouter", + "scalarList.addTitle": "Ajouter — {{name}}", + "scalarList.invalidValue": "❌ `{{value}}` n'est pas une valeur valide.", + + "select.user.selectMultiple": "Sélectionnez des utilisateurs", + "select.user.selectSingle": "Sélectionnez un utilisateur", + "select.role.selectMultiple": "Sélectionnez des rôles", + "select.role.selectSingle": "Sélectionnez un rôle", + "select.channel.selectMultiple": "Sélectionnez des salons", + "select.channel.selectSingle": "Sélectionnez un salon", + "select.category.selectMultiple": "Sélectionnez des catégories", + "select.category.selectSingle": "Sélectionnez une catégorie", + + "config.header": "# Paramètres · `{{moduleName}}`", + "config.page": "-# Page {{current}}/{{total}}", + "config.option": "-# {{typeName}}\n**⚙️ {{optionName}}**\n> {{optionDesc}}\n{{currentValue}}\n\n", + + "type.text": "Texte", + "type.number": "Nombre", + "type.boolean": "Booléen", + "type.user": "Utilisateur", + "type.role": "Rôle", + "type.channel": "Salon", + "type.category": "Catégorie", + "type.choice": "Choix", + "type.listOf": "Liste de {{type}}", + + "modules.core.name": "Configuration Globale", + "modules.core.description": "Le module principal de l'application, gérant les commandes et évènements principaux. Il est toujours chargé.", + + "modules.thread-creator.name": "Thread Creator", + "modules.thread-creator.description": "Crée automatiquement des fils de discussion sous chaque nouveau message dans les canaux configurés. Remplace le bot Needle.", + + "modules.test-config.name": "Test Config", + "modules.test-config.description": "Module de développement déclarant tous les types de configuration, pour tester l'UI de configuration.", + + "config.locale.name": "Langue", + "config.locale.description": "Langue du bot" +} diff --git a/src/core/interactions/config-page.button.ts b/src/core/interactions/config-page.button.ts index 9c23749..5bfb5ad 100644 --- a/src/core/interactions/config-page.button.ts +++ b/src/core/interactions/config-page.button.ts @@ -1,6 +1,7 @@ import { MessageFlags } from "discord.js"; import { resolveConfigurableModule } from "#core/config/config-edit.js"; import configService from "#core/services/config.service.js"; +import { replyWithCoreT } from "#core/utils/core-config.js"; import { configurationMessage } from "#core/utils/core-messages.js"; import { declareInteractionHandler } from "#lib/interaction.js"; @@ -16,10 +17,7 @@ export default declareInteractionHandler({ async execute(interaction, [moduleId, pageRaw]) { const module = resolveConfigurableModule(moduleId); if (!module) { - await interaction.reply({ - content: "Configuration introuvable.", - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "config.notFound"); return; } diff --git a/src/core/interactions/configure-module.button.ts b/src/core/interactions/configure-module.button.ts index 102fb60..a009ee5 100644 --- a/src/core/interactions/configure-module.button.ts +++ b/src/core/interactions/configure-module.button.ts @@ -1,4 +1,3 @@ -import { MessageFlags } from "discord.js"; import { getConfigEntry, isScalarType, @@ -7,6 +6,7 @@ import { import configHandlers from "#core/config/config-handler-registry.js"; import { openScalarListEditor } from "#core/config/scalar-list-editor.js"; import configService from "#core/services/config.service.js"; +import { replyWithCoreT } from "#core/utils/core-config.js"; import { ConfigType } from "#lib/config.js"; import { declareInteractionHandler } from "#lib/interaction.js"; @@ -18,10 +18,7 @@ export default declareInteractionHandler({ const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, configKey)) { - await interaction.reply({ - content: "Configuration option not found.", - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } diff --git a/src/core/interactions/disable-module.button.ts b/src/core/interactions/disable-module.button.ts index 871a640..7de1b94 100644 --- a/src/core/interactions/disable-module.button.ts +++ b/src/core/interactions/disable-module.button.ts @@ -1,6 +1,7 @@ import { MessageFlags } from "discord.js"; import { uninstallModule } from "#core/loaders/module-installer.js"; import moduleService from "#core/services/module.service.js"; +import { getCoreT } from "#core/utils/core-config.js"; import { modulesMessage } from "#core/utils/core-messages.js"; import { modules } from "#index.js"; import { declareInteractionHandler } from "#lib/interaction.js"; @@ -11,9 +12,11 @@ export default declareInteractionHandler({ check: (interaction) => interaction.isButton(), async execute(interaction, args) { const moduleId = args[0]; + const coreT = await getCoreT(interaction.guildId!); + if (!moduleId) { await interaction.reply({ - content: "The button is malformed. Please try again later.", + content: coreT("interaction.malformed"), flags: MessageFlags.Ephemeral, }); return; @@ -22,7 +25,7 @@ export default declareInteractionHandler({ const module = modules.find((mod) => mod.id === moduleId); if (!module) { await interaction.reply({ - content: "Module not found. Please try again later.", + content: coreT("interaction.moduleNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -34,7 +37,7 @@ export default declareInteractionHandler({ await uninstallModule(module, interaction.guild!); } catch { await interaction.followUp({ - content: "Failed to disable the module. Please try again later.", + content: coreT("interaction.failedDisable"), flags: MessageFlags.Ephemeral, }); return; @@ -45,7 +48,7 @@ export default declareInteractionHandler({ ); await defer.edit({ - components: [modulesMessage(modulesState)], + components: [modulesMessage(modulesState, coreT)], flags: MessageFlags.IsComponentsV2, }); }, diff --git a/src/core/interactions/enable-module.button.ts b/src/core/interactions/enable-module.button.ts index 8374682..b89a885 100644 --- a/src/core/interactions/enable-module.button.ts +++ b/src/core/interactions/enable-module.button.ts @@ -1,6 +1,7 @@ import { MessageFlags } from "discord.js"; import { installModule } from "#core/loaders/module-installer.js"; import moduleService from "#core/services/module.service.js"; +import { getCoreT } from "#core/utils/core-config.js"; import { modulesMessage } from "#core/utils/core-messages.js"; import { modules } from "#index.js"; import { declareInteractionHandler } from "#lib/interaction.js"; @@ -11,9 +12,11 @@ export default declareInteractionHandler({ check: (interaction) => interaction.isButton(), async execute(interaction, args) { const moduleId = args[0]; + const coreT = await getCoreT(interaction.guildId!); + if (!moduleId) { await interaction.reply({ - content: "The button is malformed. Please try again later.", + content: coreT("interaction.malformed"), flags: MessageFlags.Ephemeral, }); return; @@ -22,7 +25,7 @@ export default declareInteractionHandler({ const module = modules.find((mod) => mod.id === moduleId); if (!module) { await interaction.reply({ - content: "Module not found. Please try again later.", + content: coreT("interaction.moduleNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -34,7 +37,7 @@ export default declareInteractionHandler({ await installModule(module, interaction.guild!); } catch { await interaction.followUp({ - content: "Failed to enable the module. Please try again later.", + content: coreT("interaction.failedEnable"), flags: MessageFlags.Ephemeral, }); return; @@ -45,7 +48,7 @@ export default declareInteractionHandler({ ); await defer.edit({ - components: [modulesMessage(modulesState)], + components: [modulesMessage(modulesState, coreT)], flags: MessageFlags.IsComponentsV2, }); }, diff --git a/src/core/interactions/reset-config.ts b/src/core/interactions/reset-config.ts new file mode 100644 index 0000000..e4529cf --- /dev/null +++ b/src/core/interactions/reset-config.ts @@ -0,0 +1,135 @@ +import { + ActionRowBuilder, + ContainerBuilder, + MessageFlags, + StringSelectMenuBuilder, + type MessageActionRowComponentBuilder, + type StringSelectMenuInteraction, +} from "discord.js"; +import { + getConfigEntry, + refreshSourceConfigMessageAtPage, + resolveConfigurableModule, +} from "#core/config/config-edit.js"; +import configService from "#core/services/config.service.js"; +import { getCoreT, replyWithCoreT } from "#core/utils/core-config.js"; +import type { ConfigProvider, ConfigSchema } from "#lib/config.js"; +import { declareInteractionHandler } from "#lib/interaction.js"; + +/** Sentinel select value meaning "reset every field of the module". */ +const RESET_ALL = "*all*"; + +/** + * Opens, from the public `/config` panel, an ephemeral picker listing the + * fields currently overridden (plus an "all fields" entry) to reset to default. + * A reset just clears the stored value; the lazy default takes over on read. + */ +export const resetConfigButton = declareInteractionHandler({ + customId: "reset-config", + requiresAdmin: true, + check: (interaction) => interaction.isButton(), + async execute(interaction, [moduleId, page]) { + const module = resolveConfigurableModule(moduleId); + if (!module) { + await replyWithCoreT(interaction, "interaction.moduleNotFound"); + return; + } + + // Typed as a generic schema provider so `isSet(key)` accepts string keys + // (the resolved module is non-generic, i.e. ConfigProvider<{}>). + const config = (await configService.getConfigForModuleIn( + module, + interaction.guildId! + )) as unknown as ConfigProvider; + const t = config.t; + + const schema = module.config as ConfigSchema; + const overridden = Object.keys(schema).filter((key) => config.isSet(key)); + if (overridden.length === 0) { + await interaction.reply({ + content: t("config.reset.nothing"), + flags: MessageFlags.Ephemeral, + }); + return; + } + + const customId = `reset-config-select:${module.id}:${page}:${interaction.message.id}`; + const options = [ + { label: t("config.reset.all"), value: RESET_ALL }, + ...overridden.map((key) => ({ + label: t("config." + key + ".name", { + defaultValue: getConfigEntry(module, key)?.name ?? key, + }), + value: key, + })), + ]; + + const menu = new StringSelectMenuBuilder() + .setCustomId(customId) + .setPlaceholder(t("config.reset.placeholder")) + .setMinValues(1) + .setMaxValues(options.length) + .addOptions(options); + + await interaction.reply({ + components: [ + new ContainerBuilder().addActionRowComponents( + new ActionRowBuilder().addComponents( + menu + ) + ), + ], + flags: MessageFlags.Ephemeral + MessageFlags.IsComponentsV2, + }); + }, +}); + +/** + * Applies the field picker's selection: resets the chosen fields (or all), + * acknowledges in the ephemeral message, then refreshes the public panel. + */ +export const resetConfigSelect = + declareInteractionHandler({ + customId: "reset-config-select", + requiresAdmin: true, + check: (interaction) => interaction.isStringSelectMenu(), + async execute(interaction, [moduleId, pageStr, sourceMessageId]) { + const module = resolveConfigurableModule(moduleId); + if (!module) { + await replyWithCoreT(interaction, "interaction.moduleNotFound"); + return; + } + + const selected = interaction.values; + if (selected.includes(RESET_ALL)) { + await configService.resetConfigForModuleIn( + module, + interaction.guildId! + ); + } else { + await configService.resetFieldsForModuleIn( + module, + interaction.guildId!, + selected + ); + } + + const t = await getCoreT(interaction.guildId!); + await interaction.update({ + components: [ + new ContainerBuilder().addTextDisplayComponents((text) => + text.setContent(t("config.reset.done")) + ), + ], + flags: MessageFlags.IsComponentsV2, + }); + + const page = Number(pageStr); + await refreshSourceConfigMessageAtPage( + interaction, + module, + sourceMessageId, + Number.isFinite(page) ? page : 0 + ); + }, + }); diff --git a/src/core/interactions/toggle-option.button.ts b/src/core/interactions/toggle-option.button.ts index e060920..05161eb 100644 --- a/src/core/interactions/toggle-option.button.ts +++ b/src/core/interactions/toggle-option.button.ts @@ -1,6 +1,7 @@ import { MessageFlags } from "discord.js"; import { resolveConfigurableModule } from "#core/config/config-edit.js"; import configService from "#core/services/config.service.js"; +import { replyWithCoreT } from "#core/utils/core-config.js"; import { configPageOfKey, configurationMessage, @@ -15,10 +16,7 @@ export default declareInteractionHandler({ const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, configKey)) { - await interaction.reply({ - content: "Configuration option not found.", - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } @@ -31,7 +29,7 @@ export default declareInteractionHandler({ if (typeof currentValue !== "boolean") { await interaction.reply({ - content: "This option is not a boolean toggle.", + content: config.t("interaction.notBoolean"), flags: MessageFlags.Ephemeral, }); return; diff --git a/src/core/listeners/interaction-create.listener.ts b/src/core/listeners/interaction-create.listener.ts index de74f77..46694b6 100644 --- a/src/core/listeners/interaction-create.listener.ts +++ b/src/core/listeners/interaction-create.listener.ts @@ -59,11 +59,18 @@ async function handleCommand(interaction: ChatInputCommandInteraction) { logger.debug(`Executing command | name = ${interaction.commandName}`); await command.command.execute(interaction, config); } else { + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); + logger.warn( `Command not enabled | name = ${interaction.commandName} | module = ${command.module.id}` ); await interaction.reply({ - content: `The command \`${interaction.commandName}\` is not enabled in this guild.`, + content: coreConfig.t("command.notEnabled", { + commandName: interaction.commandName, + }), flags: MessageFlags.Ephemeral, }); } @@ -157,8 +164,14 @@ async function handleInteraction(interaction: CompatibleInteraction) { if (!handler.handler.check(interaction, config)) return; - if (handler.handler.requiresAdmin && !(await requireAdmin(interaction))) { - return; + if (handler.handler.requiresAdmin) { + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); + if (!(await requireAdmin(interaction, coreConfig.t))) { + return; + } } try { diff --git a/src/core/loaders/module-loader.ts b/src/core/loaders/module-loader.ts index 38ec249..0ef0a4a 100644 --- a/src/core/loaders/module-loader.ts +++ b/src/core/loaders/module-loader.ts @@ -3,6 +3,7 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import path from "path"; import { DeclarationType, type Declared } from "#lib/declared.js"; import { isDevMode } from "#lib/env.js"; +import { addTranslations } from "#lib/i18n.js"; import { loggerMaker } from "#lib/logger.js"; import type { Module } from "#lib/module.js"; @@ -15,6 +16,41 @@ const __dirname = path.resolve( ".." ); +/** + * Scan a module directory for an `i18n/` sub-folder and register every JSON + * file found there as a translation bundle under the module's namespace. + */ +export async function loadModuleI18n( + moduleId: string, + modulePath: string +): Promise { + const i18nDir = path.resolve(modulePath, "i18n"); + + let files: string[]; + try { + files = await fs.readdir(i18nDir); + } catch { + // No i18n directory – nothing to load. + return; + } + + for (const file of files) { + const match = file.match(/^(.+)\.json$/); + if (!match) continue; + + const lng = match[1]; + if (!lng) continue; + + const filePath = path.resolve(i18nDir, file); + const content = await import(pathToFileURL(filePath).href, { + with: { type: "json" }, + }); + + addTranslations(lng, moduleId, content.default ?? content); + logger.debug(`Loaded i18n | lng = ${lng} | ns = ${moduleId}`); + } +} + export async function loadModules(basePath: string): Promise { logger.info(`Finding modules | path = ${basePath}`); @@ -84,6 +120,9 @@ export async function loadModule(modulePath: string): Promise { return null; } + // Load translation bundles for this module + await loadModuleI18n(module.id, modulePath); + logger.info(`\tModule resolved successfully | id = ${module.id}`); return module; diff --git a/src/core/services/config.service.ts b/src/core/services/config.service.ts index b01b969..0b26802 100644 --- a/src/core/services/config.service.ts +++ b/src/core/services/config.service.ts @@ -14,11 +14,27 @@ import { declareService } from "#lib/service.js"; const configCache = new Map>>(); class ConfigService { + /** + * Resolve the locale for a guild. Falls back to "en" when the value is + * missing or invalid. Reads directly from the raw cache / database entry + * to avoid the circular dependency that would arise from going through + * getConfigForModuleIn for core. + */ + async getLocaleForGuild(guildId: string): Promise { + const config = await this.getOrCreate(guildId); + const coreData = config["core"] as Record | undefined; + const locale = coreData?.["locale"]; + return typeof locale === "string" && locale.length > 0 ? locale : "en"; + } + async getConfigForModuleIn( module: Module, guildId: string ): Promise> { - const config = await this.getOrCreate(guildId); + const [locale, config] = await Promise.all([ + this.getLocaleForGuild(guildId), + this.getOrCreate(guildId), + ]); const configData = config[module.id]; // Deserialize the config data before creating the provider @@ -28,7 +44,7 @@ class ConfigService { guildId ); - return new ConfigProvider(module, deserializedConfig); + return new ConfigProvider(module, deserializedConfig, locale); } async updateConfigForModuleIn( @@ -36,7 +52,10 @@ class ConfigService { guildId: string, newConfig: Partial> ): Promise> { - const currentConfig = await this.getOrCreate(guildId); + const [locale, currentConfig] = await Promise.all([ + this.getLocaleForGuild(guildId), + this.getOrCreate(guildId), + ]); const moduleConfig = currentConfig[module.id] as ConfigData; if (!moduleConfig) { @@ -69,15 +88,18 @@ class ConfigService { guildId ); - return new ConfigProvider(module, deserializedConfig); + return new ConfigProvider(module, deserializedConfig, locale); } async resetConfigForModuleIn( module: Module, guildId: string ): Promise> { - const defaultConfig = this.createDefaultConfigForModule(module); - const currentConfig = await this.getOrCreate(guildId); + const [locale, currentConfig] = await Promise.all([ + this.getLocaleForGuild(guildId), + this.getOrCreate(guildId), + ]); + const defaultConfig = this.blankConfigForModule(module); const updatedConfig = { ...currentConfig, @@ -101,7 +123,51 @@ class ConfigService { guildId ); - return new ConfigProvider(module, deserializedConfig); + return new ConfigProvider(module, deserializedConfig, locale); + } + + /** + * Clears the stored value of specific fields, so each falls back to its + * schema default on the next read. Unlike setting a value to `null` (an + * explicit "empty"), this removes the key entirely. Fields without a stored + * value are left untouched. + */ + async resetFieldsForModuleIn( + module: Module, + guildId: string, + keys: string[] + ): Promise> { + const [locale, currentConfig] = await Promise.all([ + this.getLocaleForGuild(guildId), + this.getOrCreate(guildId), + ]); + + const moduleConfig = (currentConfig[module.id] ?? + {}) as ConfigData; + const updatedModuleConfig: Record = { ...moduleConfig }; + for (const key of keys) { + delete updatedModuleConfig[key]; + } + + const updatedConfig = { + ...currentConfig, + [module.id]: updatedModuleConfig as ConfigData, + }; + + await database.guildConfiguration.upsert({ + where: { guildId }, + create: { guildId, data: updatedConfig }, + update: { data: updatedConfig }, + }); + configCache.set(guildId, updatedConfig); + + const deserializedConfig = await this.deserializeConfigData( + module, + updatedModuleConfig as ConfigData, + guildId + ); + + return new ConfigProvider(module, deserializedConfig, locale); } async getFullConfigForGuild( @@ -146,12 +212,11 @@ class ConfigService { const moduleConfig = (config as Record)[module.id]; if (moduleConfig) { - // Validate that the module config has all required fields - const defaultConfig = this.createDefaultConfigForModule(module); - validatedConfig[module.id] = { ...defaultConfig, ...moduleConfig }; + validatedConfig[module.id] = { ...moduleConfig }; } else { - // If module config is missing, create default - validatedConfig[module.id] = this.createDefaultConfigForModule(module); + // No stored config for this module yet — start from a blank slate; + // defaults are filled in lazily at read time. + validatedConfig[module.id] = this.blankConfigForModule(module); } } @@ -163,9 +228,10 @@ class ConfigService { ): Promise>> { const config: Record> = {}; - // Create default configuration for all modules + // Start every module with a blank stored config; defaults are resolved + // lazily at read time rather than baked in here. for (const module of [...modules, coreModule]) { - config[module.id] = this.createDefaultConfigForModule(module); + config[module.id] = this.blankConfigForModule(module); } // Save to database @@ -182,23 +248,19 @@ class ConfigService { return config; } - private createDefaultConfigForModule( - module: Module + /** + * The initial stored config for a module: empty. + * + * Defaults are deliberately NOT persisted — {@link ConfigProvider.get} + * resolves them lazily at read time, so an unconfigured value tracks the + * guild locale. Persisting a default would freeze its value (and its + * language) at creation time, and would make it indistinguishable from a + * value the admin set explicitly. + */ + private blankConfigForModule( + _module: Module ): ConfigData { - const defaultConfig: any = {}; - - if (module.config) { - for (const [key, configEntry] of Object.entries(module.config)) { - // Only materialize a value when the entry declares a default. Entries - // without one stay absent (undefined) — never a fabricated "" / 0 / - // false / null — so reads reflect the real "not set" state. - if (configEntry.defaultValue !== undefined) { - defaultConfig[key] = configEntry.defaultValue; - } - } - } - - return defaultConfig as ConfigData; + return {} as ConfigData; } private async deserializeConfigData( diff --git a/src/core/utils/core-config.ts b/src/core/utils/core-config.ts new file mode 100644 index 0000000..76c2c5a --- /dev/null +++ b/src/core/utils/core-config.ts @@ -0,0 +1,24 @@ +import { MessageFlags, type InteractionReplyOptions } from "discord.js"; +import coreModule from "#core/core.module.js"; +import configService from "#core/services/config.service.js"; +import type { TFunction } from "#lib/i18n.js"; + +export async function getCoreT(guildId: string): Promise { + const config = await configService.getConfigForModuleIn(coreModule, guildId); + return config.t; +} + +export async function replyWithCoreT( + interaction: { + guildId: string | null; + reply(opts: InteractionReplyOptions): unknown; + }, + key: string, + options?: Record +): Promise { + const t = await getCoreT(interaction.guildId!); + await interaction.reply({ + content: t(key, options), + flags: MessageFlags.Ephemeral, + }); +} diff --git a/src/core/utils/core-messages.test.ts b/src/core/utils/core-messages.test.ts index ad2f198..8c2a0dd 100644 --- a/src/core/utils/core-messages.test.ts +++ b/src/core/utils/core-messages.test.ts @@ -1,10 +1,11 @@ -import { describe, expect, it } from "vitest"; +import { beforeAll, describe, expect, it } from "vitest"; import { ConfigProvider, ConfigType, type ConfigData, type ConfigSchema, } from "#lib/config.js"; +import { addTranslations, initI18n } from "#lib/i18n.js"; import type { Module } from "#lib/module.js"; import { CONFIG_FIELDS_PER_PAGE, @@ -12,6 +13,16 @@ import { configurationMessage, } from "./core-messages.js"; +beforeAll(async () => { + await initI18n(); + addTranslations("en", "core", { + "config.previous": "◀ Previous", + "config.next": "Next ▶", + "config.noConfig": "This module has no configuration available.", + "config.currentValue": "Current: {{value}}", + }); +}); + /** Recursively counts every component node (matching Discord's message-wide cap). */ function countComponents(node: unknown): number { if (!node || typeof node !== "object") return 0; @@ -46,6 +57,7 @@ function countByType(node: unknown, type: number): number { const SECTION = 9; const ACTION_ROW = 1; +const BUTTON = 2; function moduleWithFields(count: number): Module { const schema = Object.fromEntries( @@ -58,7 +70,11 @@ function moduleWithFields(count: number): Module { } function panel(module: Module, page = 0) { - const config = new ConfigProvider(module, {} as ConfigData); + const config = new ConfigProvider( + module, + {} as ConfigData, + "en" + ); return configurationMessage(module, config, page)[0]!.toJSON(); } @@ -83,12 +99,19 @@ describe("configurationMessage pagination", () => { ); }); - it("adds a navigation row only when there is more than one page", () => { + it("uses one bottom row; pagination buttons appear only when multipage", () => { const single = moduleWithFields(CONFIG_FIELDS_PER_PAGE); const multi = moduleWithFields(CONFIG_FIELDS_PER_PAGE + 1); - expect(countByType(panel(single), ACTION_ROW)).toBe(0); + // A single combined bottom row in both cases (reset lives there). + expect(countByType(panel(single), ACTION_ROW)).toBe(1); expect(countByType(panel(multi), ACTION_ROW)).toBe(1); + + // Both show 10 field rows on page 0, so the two extra buttons on the + // multipage panel are exactly the prev/next pagination controls. + expect( + countByType(panel(multi), BUTTON) - countByType(panel(single), BUTTON) + ).toBe(2); }); it("clamps an out-of-range page to the last page", () => { diff --git a/src/core/utils/core-messages.ts b/src/core/utils/core-messages.ts index 1eb0c04..6258fd0 100644 --- a/src/core/utils/core-messages.ts +++ b/src/core/utils/core-messages.ts @@ -8,35 +8,47 @@ import { import type moduleService from "#core/services/module.service.js"; import { ConfigType, + formatConfigValue, getConfigTypeName, + type ConfigEntry, type ConfigProvider, type ConfigSchema, - type ListOf, } from "#lib/config.js"; +import type { TFunction } from "#lib/i18n.js"; import type { Module } from "#lib/module.js"; import { Colors } from "#utils/colors.js"; export const modulesMessage = ( - modulesState: Awaited> + modulesState: Awaited>, + t: TFunction ) => { const container = new ContainerBuilder().setAccentColor(Colors.Turquoise); container.addTextDisplayComponents( - (text) => text.setContent("# Modules"), - (text) => - text.setContent( - "The modules marked with ✅ are enabled, while those marked with ❌ are disabled." - ) + (text) => text.setContent(t("modules.title")), + (text) => text.setContent(t("modules.description")) ); container.addSeparatorComponents((separator) => separator.setDivider(true)); for (let state of modulesState) { const emoji = state.enabled ? "✅" : "❌"; + const modName = t("modules." + state.module.id + ".name", { + defaultValue: state.module.name, + }); + const modDesc = t("modules." + state.module.id + ".description", { + defaultValue: state.module.description, + }); + const versionSuffix = state.enabled ? ` (${state.enabledVersion})` : ""; container.addSectionComponents((section) => section .addTextDisplayComponents((text) => text.setContent( - `${emoji} \`${state.module.name}\`${state.enabled ? ` (${state.enabledVersion})` : ""}\n> ${state.module.description}` + t("modules.item", { + emoji, + moduleName: modName, + version: versionSuffix, + description: modDesc, + }) ) ) .setButtonAccessory((button) => @@ -46,7 +58,9 @@ export const modulesMessage = ( ? "disable-module:" + state.module.id : "enable-module:" + state.module.id ) - .setLabel(state.enabled ? "Disable" : "Enable") + .setLabel( + state.enabled ? t("modules.disable") : t("modules.enable") + ) .setStyle(state.enabled ? ButtonStyle.Danger : ButtonStyle.Success) ) ); @@ -64,8 +78,9 @@ export const modulesMessage = ( * joined; unset values show `—`. */ function renderCurrentValue( - type: ConfigType | ListOf, - value: unknown + option: ConfigEntry, + value: unknown, + locale: string ): string { const items = (Array.isArray(value) ? value : [value]).filter( (item) => item !== null && item !== undefined @@ -74,6 +89,7 @@ function renderCurrentValue( return "—"; } + const type = option.type; const baseType = Array.isArray(type) ? type[0] : type; const isEntity = baseType === ConfigType.USER || @@ -82,7 +98,9 @@ function renderCurrentValue( baseType === ConfigType.CATEGORY; return items - .map((item) => (isEntity ? String(item) : `\`${String(item)}\``)) + .map((item) => + isEntity ? String(item) : `\`${formatConfigValue(option, item, locale)}\`` + ) .join(", "); } @@ -118,12 +136,16 @@ export const configurationMessage = ( ); const currentPage = Math.min(Math.max(page, 0), pageCount - 1); - container.addTextDisplayComponents((text) => - text.setContent( - `# \`${module.name}\` settings` + - (pageCount > 1 ? `\n-# Page ${currentPage + 1}/${pageCount}` : "") - ) - ); + const modName = config.t("modules." + module.id + ".name", { + defaultValue: module.name, + }); + let header = config.t("config.header", { moduleName: modName }); + if (pageCount > 1) { + header += + "\n" + + config.t("config.page", { current: currentPage + 1, total: pageCount }); + } + container.addTextDisplayComponents((text) => text.setContent(header)); container.addSeparatorComponents((separator) => separator.setDivider(true)); const pageKeys = keys.slice( @@ -137,9 +159,27 @@ export const configurationMessage = ( const value = config.get(key); const section = new SectionBuilder(); + const optName = config.t("config." + key + ".name", { + defaultValue: option.name, + }); + const optDesc = config.t("config." + key + ".description", { + defaultValue: option.description, + }); + // Flag any field the admin hasn't set (showing a default — or nothing, for + // a field without one), so they can tell at a glance what they've changed. + const renderedValue = + renderCurrentValue(option, value, config.locale) + + (config.isSet(key) ? "" : config.t("config.defaultSuffix")); section.addTextDisplayComponents((text) => text.setContent( - `-# ${getConfigTypeName(option.type)}\n**⚙️ ${option.name}**\n> ${option.description}\nCurrent: ${renderCurrentValue(option.type, value)}\n\n` + config.t("config.option", { + typeName: getConfigTypeName(option.type, config.t), + optionName: optName, + optionDesc: optDesc, + currentValue: config.t("config.currentValue", { + value: renderedValue, + }), + }) ) ); @@ -170,27 +210,41 @@ export const configurationMessage = ( if (keys.length === 0) { container.addTextDisplayComponents((text) => - text.setContent("-# Ce module n'a aucune configuration disponible.") + text.setContent(config.t("config.noConfig")) ); - } - - // Pagination controls — shown only when the schema spans more than one page. - // The target page is encoded in the customId so the handler can re-render. - if (pageCount > 1) { - container.addActionRowComponents( - new ActionRowBuilder().addComponents( + } else { + // Single bottom row: pagination (when multipage) on the left, then the + // reset control last so it sits on the right. The target page is encoded in + // each customId so the handler can re-render in place. + const row = new ActionRowBuilder(); + + if (pageCount > 1) { + row.addComponents( new ButtonBuilder() .setCustomId(`config-page:${module.id}:${currentPage - 1}`) - .setLabel("◀ Précédent") + .setLabel(config.t("config.previous")) .setStyle(ButtonStyle.Secondary) .setDisabled(currentPage === 0), new ButtonBuilder() .setCustomId(`config-page:${module.id}:${currentPage + 1}`) - .setLabel("Suivant ▶") + .setLabel(config.t("config.next")) .setStyle(ButtonStyle.Secondary) .setDisabled(currentPage === pageCount - 1) - ) + ); + } + + // Reset opens an ephemeral field picker; disabled when nothing is + // overridden (every field already on its default), so it is never a no-op. + const hasOverrides = keys.some((key) => config.isSet(key)); + row.addComponents( + new ButtonBuilder() + .setCustomId(`reset-config:${module.id}:${currentPage}`) + .setLabel(config.t("config.reset.button")) + .setStyle(ButtonStyle.Secondary) + .setDisabled(!hasOverrides) ); + + container.addActionRowComponents(row); } return [container]; diff --git a/src/core/utils/require-admin.ts b/src/core/utils/require-admin.ts index 572d3d0..be2bd98 100644 --- a/src/core/utils/require-admin.ts +++ b/src/core/utils/require-admin.ts @@ -1,12 +1,14 @@ import { MessageFlags, PermissionFlagsBits } from "discord.js"; +import type { TFunction } from "#lib/i18n.js"; import type { CompatibleInteraction } from "#lib/interaction.js"; export async function requireAdmin( - interaction: CompatibleInteraction + interaction: CompatibleInteraction, + t: TFunction ): Promise { if (!interaction.memberPermissions?.has(PermissionFlagsBits.Administrator)) { await interaction.reply({ - content: "You do not have permission to manage modules.", + content: t("admin.noPermission"), flags: MessageFlags.Ephemeral, }); return false; diff --git a/src/index.ts b/src/index.ts index 5d9df07..d98c24a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import { fileURLToPath } from "node:url"; +import path from "path"; import { Client, Events } from "discord.js"; import coreModule from "./core/core.module.js"; import { syncCommands } from "./core/loaders/command-loader.js"; @@ -5,8 +7,9 @@ import { loadGlobalEvents, loadModuleEvents, } from "./core/loaders/listener-loader.js"; -import { loadModules } from "./core/loaders/module-loader.js"; +import { loadModuleI18n, loadModules } from "./core/loaders/module-loader.js"; import prisma, { Prisma } from "./lib/database.js"; +import { initI18n } from "./lib/i18n.js"; import logger from "./lib/logger.js"; import sql = Prisma.sql; @@ -21,6 +24,11 @@ try { const token = process.env["DISCORD_TOKEN"]; +await initI18n(); + +const corePath = path.resolve(fileURLToPath(import.meta.url), "..", "core"); +await loadModuleI18n("core", corePath); + export const modules = await loadModules("./modules"); const intents = modules.flatMap((module) => module.intents).filter((a) => !!a); diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 084d6a7..0ff1f5c 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -1,14 +1,17 @@ -import { describe, expect, expectTypeOf, it } from "vitest"; +import { beforeAll, describe, expect, expectTypeOf, it } from "vitest"; import { ConfigProvider, ConfigType, ConfigValidator, + configValueEmoji, + formatConfigValue, getConfigTypeName, isEnumEntry, type ConfigData, type ConfigEntry, type ConfigSchema, } from "./config.js"; +import { addTranslations, initI18n } from "./i18n.js"; import type { Module } from "./module.js"; describe("ConfigValidator", () => { @@ -187,6 +190,106 @@ describe("ConfigProvider", () => { }); }); +describe("ConfigProvider default resolution", () => { + beforeAll(async () => { + await initI18n(); + addTranslations("fr", "test-mod", { + "config.note.default": "Bonjour", + }); + }); + + const schema = { + note: { + name: "Note", + description: "A note", + type: ConfigType.STRING, + defaultValue: "Hello", + }, + count: { + name: "Count", + description: "A count", + type: ConfigType.NUMBER, + defaultValue: 5, + }, + mode: { + name: "Mode", + description: "A choice", + type: ConfigType.ENUM, + options: ["light", "dark"] as const, + defaultValue: "light", + }, + greeting: { + name: "Greeting", + description: "No default", + type: ConfigType.STRING, + }, + } satisfies ConfigSchema; + + const module = { + id: "test-mod", + config: schema, + } as unknown as Module; + + const empty = {} as ConfigData; + + it("resolves an unset string default through the locale", () => { + const provider = new ConfigProvider(module, empty, "fr"); + expect(provider.get("note")).toBe("Bonjour"); + }); + + it("falls back to the schema default when no translation matches", () => { + const provider = new ConfigProvider(module, empty, "en"); + expect(provider.get("note")).toBe("Hello"); + }); + + it("returns non-string defaults verbatim (numbers, enums)", () => { + const provider = new ConfigProvider(module, empty, "fr"); + expect(provider.get("count")).toBe(5); + // Enum defaults are stored identifiers, never translated. + expect(provider.get("mode")).toBe("light"); + }); + + it("prefers a stored value over the default", () => { + const provider = new ConfigProvider( + module, + { note: "stored" } as ConfigData, + "fr" + ); + expect(provider.get("note")).toBe("stored"); + }); + + it("returns undefined for an unset field without a default", () => { + const provider = new ConfigProvider(module, empty, "fr"); + expect(provider.get("greeting")).toBeUndefined(); + }); + + describe("isSet", () => { + it("is false when nothing is stored (default in use)", () => { + const provider = new ConfigProvider(module, empty, "fr"); + expect(provider.isSet("note")).toBe(false); + expect(provider.isSet("greeting")).toBe(false); + }); + + it("is true for a stored value", () => { + const provider = new ConfigProvider( + module, + { note: "x" } as ConfigData, + "fr" + ); + expect(provider.isSet("note")).toBe(true); + }); + + it("is true for a cleared value (null counts as set)", () => { + const provider = new ConfigProvider( + module, + { note: null } as unknown as ConfigData, + "fr" + ); + expect(provider.isSet("note")).toBe(true); + }); + }); +}); + describe("ConfigEntry enum typing", () => { it("requires `options` on an enum entry (regression for the missing-options hole)", () => { const valid: ConfigEntry = { @@ -208,6 +311,58 @@ describe("ConfigEntry enum typing", () => { }); }); +describe("formatConfigValue", () => { + const langEntry: ConfigEntry = { + name: "Language", + description: "", + type: ConfigType.ENUM, + options: ["en", "fr"], + display: "language", + }; + + it("renders a language enum value as its name in the viewer locale", () => { + expect(formatConfigValue(langEntry, "fr", "fr")).toBe("Français"); + expect(formatConfigValue(langEntry, "en", "fr")).toBe("Anglais"); + expect(formatConfigValue(langEntry, "fr", "en")).toBe("French"); + expect(formatConfigValue(langEntry, "en", "en")).toBe("English"); + }); + + it("returns the raw value for an enum without a display hint", () => { + const plain: ConfigEntry = { + name: "Mode", + description: "", + type: ConfigType.ENUM, + options: ["a", "b"], + }; + expect(formatConfigValue(plain, "a", "fr")).toBe("a"); + }); + + it("stringifies a non-enum value", () => { + const str: ConfigEntry = { + name: "Note", + description: "", + type: ConfigType.STRING, + }; + expect(formatConfigValue(str, "hello", "fr")).toBe("hello"); + }); + + it("returns a flag emoji for a known language code", () => { + expect(configValueEmoji(langEntry, "fr")).toBe("🇫🇷"); + expect(configValueEmoji(langEntry, "en")).toBe("🇬🇧"); + }); + + it("returns no emoji for an unknown code or a plain enum", () => { + expect(configValueEmoji(langEntry, "de")).toBeUndefined(); + const plain: ConfigEntry = { + name: "Mode", + description: "", + type: ConfigType.ENUM, + options: ["a", "b"], + }; + expect(configValueEmoji(plain, "a")).toBeUndefined(); + }); +}); + describe("isEnumEntry", () => { it("recognizes single and list enum entries", () => { expect( diff --git a/src/lib/config.ts b/src/lib/config.ts index 450b541..58862bf 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,6 +1,11 @@ import { CategoryChannel, type Channel, Role, type User } from "discord.js"; +import { createT, type TFunction } from "./i18n.js"; import type { Module } from "./module.js"; +function ucfirst(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + export enum ConfigType { STRING = "STRING", NUMBER = "NUMBER", @@ -30,14 +35,20 @@ export const ConfigValidator: Record boolean> = { }; export function getConfigTypeName( - type: ConfigType | ListOf + type: ConfigType | ListOf, + t?: TFunction ): string { if (Array.isArray(type)) { - return `List of ${configTypeNames[type[0]]}`; + const inner = configTypeNames[type[0]]; + return t + ? t("type.listOf", { + type: t("type." + inner, { defaultValue: ucfirst(inner) }), + }) + : `List of ${inner}`; } const name = configTypeNames[type]; - return name.charAt(0).toUpperCase() + name.slice(1); + return t ? t("type." + name, { defaultValue: ucfirst(name) }) : ucfirst(name); } export const configTypeNames: Record = { @@ -87,6 +98,14 @@ interface ListConfigEntry { defaultValue?: ResolveType>; } +/** + * Optional rendering hint for an enum's option values. `"language"` formats a + * BCP-47 code (e.g. `en`, `fr`) as its language name in the viewer's locale via + * `Intl.DisplayNames` — so `fr` shows as "Français" / "French". Without a hint + * the raw option value is displayed. + */ +export type EnumDisplay = "language"; + /** * Config entry whose value is one of a fixed set of string `options`, edited * through a single-choice select menu. Declare `options` `as const` to have @@ -98,6 +117,7 @@ export interface EnumConfigEntry { type: ConfigType.ENUM; options: readonly Values[]; defaultValue?: Values; + display?: EnumDisplay; } /** List variant of {@link EnumConfigEntry}: any subset of `options` (multi-select). */ @@ -107,6 +127,7 @@ export interface EnumListConfigEntry { type: ListOf; options: readonly Values[]; defaultValue?: Values[]; + display?: EnumDisplay; } export type ConfigEntry = @@ -134,6 +155,54 @@ export function isEnumEntry( return base === ConfigType.ENUM; } +/** + * Display label for a single enum/scalar value in the viewer's `locale`. + * + * Honors an enum entry's {@link EnumDisplay} hint (e.g. a `language` enum shows + * `fr` as "Français"); any value without an applicable hint is returned + * stringified. Used for both the current-value readout and select-menu labels. + */ +export function formatConfigValue( + entry: ConfigEntry, + value: unknown, + locale: string +): string { + if (isEnumEntry(entry) && entry.display === "language") { + const name = new Intl.DisplayNames([locale], { type: "language" }).of( + String(value) + ); + if (name) { + return ucfirst(name); + } + } + return String(value); +} + +/** + * Flag emojis for known `display: "language"` codes, shown as a select prefix. + * There is no canonical language→flag mapping (English has no single flag — GB + * is used here), so this is a small curated table; extend it when adding a + * locale. Codes absent here simply get no emoji. + */ +const LANGUAGE_FLAGS: Record = { + en: "🇬🇧", + fr: "🇫🇷", +}; + +/** + * Optional emoji shown before an enum option in a select menu — currently the + * flag for a `language` enum value. Returns undefined when none applies. + */ +export function configValueEmoji( + entry: ConfigEntry, + value: unknown +): string | undefined { + if (isEnumEntry(entry) && entry.display === "language") { + return LANGUAGE_FLAGS[String(value)]; + } + return undefined; +} + /** * Resolved value type of an entry, before factoring in default presence. Enum * entries narrow to the literal union of their `options`; everything else maps @@ -168,10 +237,18 @@ export type ConfigData = { export class ConfigProvider { private module: Module; private readonly data: ConfigData; + readonly t: TFunction; + readonly locale: string; - constructor(module: Module, data: ConfigData) { + constructor( + module: Module, + data: ConfigData, + locale: string + ) { this.module = module; this.data = data; + this.locale = locale; + this.t = createT(locale, module.id); } get schema() { @@ -179,6 +256,49 @@ export class ConfigProvider { } get(key: TKey): ConfigEntryValue { - return this.data[key]; + const stored = this.data[key]; + // A stored value — including `null`, an intentional clear — wins. Only a + // truly absent (`undefined`) entry falls back to the schema default. + // Defaults are not persisted (see ConfigService), so an unconfigured entry + // reaches this branch and is resolved fresh against the current locale. + if (stored !== undefined) { + return stored; + } + return this.resolveDefault(key); + } + + /** + * Whether `key` holds an explicitly stored value (including a `null` clear). + * Its negation means the admin has not set the field: `get(key)` is serving a + * default — or simply nothing, for a field without one. The UI flags such + * values as "default" and a reset only ever touches set fields. + */ + isSet(key: TKey): boolean { + return this.data[key] !== undefined; + } + + /** + * The schema default for `key`, resolved against the active locale. + * + * Free-text string defaults are looked up as `config..default`, so a + * module can translate them per locale; the schema's `defaultValue` is the + * fallback when no translation matches. Every other type is returned verbatim + * — enum defaults in particular are stored identifiers, never display text, + * and must not be translated. + */ + private resolveDefault( + key: TKey + ): ConfigEntryValue { + const entry = this.module.config[key]; + type Value = ConfigEntryValue; + if (!entry || entry.defaultValue === undefined) { + return undefined as Value; + } + if (entry.type === ConfigType.STRING) { + return this.t("config." + String(key) + ".default", { + defaultValue: entry.defaultValue as string, + }) as Value; + } + return entry.defaultValue as Value; } } diff --git a/src/lib/i18n.ts b/src/lib/i18n.ts new file mode 100644 index 0000000..1e64dba --- /dev/null +++ b/src/lib/i18n.ts @@ -0,0 +1,33 @@ +import i18next from "i18next"; + +export async function initI18n(): Promise { + await i18next.init({ + fallbackLng: "en", + interpolation: { + escapeValue: false, + }, + resources: {}, + }); +} + +export function addTranslations( + lng: string, + namespace: string, + resources: Record +): void { + i18next.addResourceBundle(lng, namespace, resources, true, true); +} + +export type TFunction = ( + key: string, + options?: Record +) => string; + +export function createT(locale: string, namespace: string): TFunction { + return (key, options) => + i18next.t(key, { + ns: [namespace, "core"], + lng: locale, + ...options, + }) as string; +} diff --git a/src/modules/test-config/i18n/en.json b/src/modules/test-config/i18n/en.json new file mode 100644 index 0000000..8689fd1 --- /dev/null +++ b/src/modules/test-config/i18n/en.json @@ -0,0 +1,43 @@ +{ + "config.text.name": "Text", + "config.text.description": "A free text field.", + + "config.number.name": "Number", + "config.number.description": "A numeric field.", + + "config.toggle.name": "Boolean", + "config.toggle.description": "An on/off toggle.", + + "config.user.name": "User", + "config.user.description": "A Discord user.", + + "config.role.name": "Role", + "config.role.description": "A Discord role.", + + "config.channel.name": "Channel", + "config.channel.description": "A Discord channel.", + + "config.category.name": "Category", + "config.category.description": "A Discord category.", + + "config.textList.name": "Text list", + "config.textList.description": "A list of strings (add/remove editor).", + + "config.numberList.name": "Number list", + "config.numberList.description": "A list of numbers (add/remove editor).", + + "config.boolList.name": "Boolean list", + "config.boolList.description": "A list of booleans (add/remove editor).", + + "config.roleList.name": "Role list", + "config.roleList.description": "Multiple Discord roles (multi-select).", + + "config.channelList.name": "Channel list", + "config.channelList.description": "Multiple Discord channels (multi-select).", + + "config.choice.name": "Choice", + "config.choice.description": "A value from a fixed set (single-choice select).", + + "config.choiceList.name": "Multiple choice", + "config.choiceList.description": "Multiple values from a fixed set (multi-select)." +} diff --git a/src/modules/test-config/i18n/fr.json b/src/modules/test-config/i18n/fr.json new file mode 100644 index 0000000..c42894c --- /dev/null +++ b/src/modules/test-config/i18n/fr.json @@ -0,0 +1,43 @@ +{ + "config.text.name": "Texte", + "config.text.description": "Un champ texte libre.", + + "config.number.name": "Nombre", + "config.number.description": "Un champ numérique.", + + "config.toggle.name": "Booléen", + "config.toggle.description": "Un interrupteur on/off.", + + "config.user.name": "Utilisateur", + "config.user.description": "Un utilisateur Discord.", + + "config.role.name": "Rôle", + "config.role.description": "Un rôle Discord.", + + "config.channel.name": "Salon", + "config.channel.description": "Un salon Discord.", + + "config.category.name": "Catégorie", + "config.category.description": "Une catégorie Discord.", + + "config.textList.name": "Liste de textes", + "config.textList.description": "Une liste de chaînes (éditeur ajouter/supprimer).", + + "config.numberList.name": "Liste de nombres", + "config.numberList.description": "Une liste de nombres (éditeur ajouter/supprimer).", + + "config.boolList.name": "Liste de booléens", + "config.boolList.description": "Une liste de booléens (éditeur ajouter/supprimer).", + + "config.roleList.name": "Liste de rôles", + "config.roleList.description": "Plusieurs rôles Discord (multi-select).", + + "config.channelList.name": "Liste de salons", + "config.channelList.description": "Plusieurs salons Discord (multi-select).", + + "config.choice.name": "Choix", + "config.choice.description": "Une valeur parmi un ensemble fixe (select mono-choix).", + + "config.choiceList.name": "Choix multiples", + "config.choiceList.description": "Plusieurs valeurs parmi un ensemble fixe (multi-select)." +} diff --git a/src/modules/test-config/test-config.module.ts b/src/modules/test-config/test-config.module.ts index b24a5aa..35590b2 100644 --- a/src/modules/test-config/test-config.module.ts +++ b/src/modules/test-config/test-config.module.ts @@ -15,87 +15,87 @@ export default defineModule({ id: "test-config", name: "Test Config", description: - "Module de développement déclarant tous les types de configuration, pour tester l'UI de configuration.", + "Development module declaring all configuration types, used to test the configuration UI.", version: "1.0.0", author: "OmniBot", devOnly: true, config: { text: { - name: "Texte", - description: "Un champ texte libre.", + name: "Text", + description: "A free text field.", type: ConfigType.STRING, defaultValue: "valeur par défaut", }, number: { - name: "Nombre", - description: "Un champ numérique.", + name: "Number", + description: "A numeric field.", type: ConfigType.NUMBER, defaultValue: 42, }, toggle: { - name: "Booléen", - description: "Un interrupteur on/off.", + name: "Boolean", + description: "An on/off toggle.", type: ConfigType.BOOLEAN, defaultValue: false, }, user: { - name: "Utilisateur", - description: "Un utilisateur Discord.", + name: "User", + description: "A Discord user.", type: ConfigType.USER, }, role: { - name: "Rôle", - description: "Un rôle Discord.", + name: "Role", + description: "A Discord role.", type: ConfigType.ROLE, }, channel: { - name: "Salon", - description: "Un salon Discord.", + name: "Channel", + description: "A Discord channel.", type: ConfigType.CHANNEL, }, category: { - name: "Catégorie", - description: "Une catégorie Discord.", + name: "Category", + description: "A Discord category.", type: ConfigType.CATEGORY, }, textList: { - name: "Liste de textes", - description: "Une liste de chaînes (éditeur ajouter/supprimer).", + name: "Text list", + description: "A list of strings (add/remove editor).", type: [ConfigType.STRING], defaultValue: ["alpha", "beta"], }, numberList: { - name: "Liste de nombres", - description: "Une liste de nombres (éditeur ajouter/supprimer).", + name: "Number list", + description: "A list of numbers (add/remove editor).", type: [ConfigType.NUMBER], }, boolList: { - name: "Liste de booléens", - description: "Une liste de booléens (éditeur ajouter/supprimer).", + name: "Boolean list", + description: "A list of booleans (add/remove editor).", type: [ConfigType.BOOLEAN], defaultValue: [true, false], }, roleList: { - name: "Liste de rôles", - description: "Plusieurs rôles Discord (multi-select).", + name: "Role list", + description: "Multiple Discord roles (multi-select).", type: [ConfigType.ROLE], }, channelList: { - name: "Liste de salons", - description: "Plusieurs salons Discord (multi-select).", + name: "Channel list", + description: "Multiple Discord channels (multi-select).", type: [ConfigType.CHANNEL], }, choice: { - name: "Choix", - description: "Une valeur parmi un ensemble fixe (select mono-choix).", + name: "Choice", + description: "A value from a fixed set (single-choice select).", type: ConfigType.ENUM, options: ["faible", "moyen", "élevé"] as const, defaultValue: "moyen", }, choiceList: { - name: "Choix multiples", - description: "Plusieurs valeurs parmi un ensemble fixe (multi-select).", + name: "Multiple choice", + description: "Multiple values from a fixed set (multi-select).", type: [ConfigType.ENUM], options: ["nord", "sud", "est", "ouest"] as const, }, diff --git a/src/modules/thread-creator/i18n/en.json b/src/modules/thread-creator/i18n/en.json new file mode 100644 index 0000000..cabb8d6 --- /dev/null +++ b/src/modules/thread-creator/i18n/en.json @@ -0,0 +1,12 @@ +{ + "config.channels.name": "Monitored channels", + "config.channels.description": "Channels where new messages are monitored (a thread is created under each message).", + + "config.welcomeMessage.name": "Welcome message", + "config.welcomeMessage.description": "Message automatically posted in each created thread.", + "config.welcomeMessage.default": "💬 Use this thread to discuss this topic!", + + "config.threadNameTemplate.name": "Name template", + "config.threadNameTemplate.description": "Thread name — variables: {messageAuthor}, {messageContent}, {timestamp}.", + "config.threadNameTemplate.default": "Discussion - {messageAuthor}" +} diff --git a/src/modules/thread-creator/i18n/fr.json b/src/modules/thread-creator/i18n/fr.json new file mode 100644 index 0000000..5835a83 --- /dev/null +++ b/src/modules/thread-creator/i18n/fr.json @@ -0,0 +1,12 @@ +{ + "config.channels.name": "Salons surveillés", + "config.channels.description": "Salons où surveiller les nouveaux messages (un fil est créé sous chaque message).", + + "config.welcomeMessage.name": "Message de bienvenue", + "config.welcomeMessage.description": "Message posté automatiquement dans chaque fil créé.", + "config.welcomeMessage.default": "💬 Utilisez ce fil pour discuter de ce sujet !", + + "config.threadNameTemplate.name": "Template de nom", + "config.threadNameTemplate.description": "Nom des fils — variables : {messageAuthor}, {messageContent}, {timestamp}.", + "config.threadNameTemplate.default": "Discussion - {messageAuthor}" +} diff --git a/src/modules/thread-creator/thread-creator.config.ts b/src/modules/thread-creator/thread-creator.config.ts index 44c5227..b33b20e 100644 --- a/src/modules/thread-creator/thread-creator.config.ts +++ b/src/modules/thread-creator/thread-creator.config.ts @@ -8,21 +8,22 @@ import { ConfigType, type ConfigSchema } from "#lib/config.js"; */ export const threadCreatorConfigSchema = { channels: { - name: "Salons surveillés", + name: "Monitored channels", description: - "Salons où surveiller les nouveaux messages (un fil est créé sous chaque message).", + "Channels where new messages are monitored (a thread is created under each message).", type: [ConfigType.CHANNEL], }, welcomeMessage: { - name: "Message de bienvenue", - description: "Message posté automatiquement dans chaque fil créé.", + name: "Welcome message", + description: "Message automatically posted in each created thread.", type: ConfigType.STRING, - defaultValue: "💬 Utilisez ce fil pour discuter de ce sujet !", + // English fallback; localized per locale via `config.welcomeMessage.default`. + defaultValue: "💬 Use this thread to discuss this topic!", }, threadNameTemplate: { - name: "Template de nom", + name: "Name template", description: - "Nom des fils — variables : {messageAuthor}, {messageContent}, {timestamp}.", + "Thread name — variables: {messageAuthor}, {messageContent}, {timestamp}.", type: ConfigType.STRING, defaultValue: "Discussion - {messageAuthor}", }, diff --git a/src/modules/thread-creator/thread-creator.module.ts b/src/modules/thread-creator/thread-creator.module.ts index 2cbc5be..3873031 100644 --- a/src/modules/thread-creator/thread-creator.module.ts +++ b/src/modules/thread-creator/thread-creator.module.ts @@ -8,7 +8,7 @@ export default defineModule({ id: "thread-creator", name: "Thread Creator", description: - "Crée automatiquement des fils de discussion sous chaque nouveau message dans les canaux configurés. Remplace le bot Needle.", + "Automatically creates discussion threads under each new message in configured channels. Replaces Needle bot.", version: "2.1.0", author: "AsyncMod Team",