From a6b540637911461f90da32c39e7367a447f411c6 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 18:48:14 +0200 Subject: [PATCH 01/23] feat(i18n): add i18next infrastructure with locale support - Add i18next dependency for internationalization - Create src/lib/i18n.ts (initI18n, addTranslations, createT) - Create src/core/core.config.ts with locale ENUM (en/fr) - Extend ConfigProvider with locale param and config.t() for key lookup - Add getConfigTypeName() with optional TFunction for localized type names --- package.json | 1 + pnpm-lock.yaml | 15 +++++++++++++++ src/core/core.config.ts | 13 +++++++++++++ src/lib/config.ts | 25 +++++++++++++++++++++---- src/lib/i18n.ts | 33 +++++++++++++++++++++++++++++++++ 5 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 src/core/core.config.ts create mode 100644 src/lib/i18n.ts 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/core.config.ts b/src/core/core.config.ts new file mode 100644 index 0000000..304456c --- /dev/null +++ b/src/core/core.config.ts @@ -0,0 +1,13 @@ +import { ConfigType, type ConfigSchema } from "#lib/config.js"; + +export const coreConfigSchema = { + locale: { + name: "Language", + description: "Bot language (en/fr)", + type: ConfigType.ENUM, + options: ["en", "fr"] as const, + defaultValue: "en", + }, +} satisfies ConfigSchema; + +export type CoreConfig = typeof coreConfigSchema; diff --git a/src/lib/config.ts b/src/lib/config.ts index 450b541..452dbda 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 = { @@ -168,10 +179,16 @@ export type ConfigData = { export class ConfigProvider { private module: Module; private readonly data: ConfigData; + readonly t: TFunction; - constructor(module: Module, data: ConfigData) { + constructor( + module: Module, + data: ConfigData, + locale: string + ) { this.module = module; this.data = data; + this.t = createT(locale, module.id); } get schema() { 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; +} From af638836fccd75c734b4c42312540c32e4cc461b Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 18:48:23 +0200 Subject: [PATCH 02/23] feat(i18n): wire up i18n in module loader, index, and config service - Init i18n before module loading in src/index.ts - Add loadModuleI18n() to auto-discover module i18n/*.json files - Load core i18n explicitly in core.module.ts - Resolve locale per guild via getLocaleForGuild() in config service - Propagate locale through all config service methods --- src/core/core.module.ts | 3 +++ src/core/loaders/module-loader.ts | 39 +++++++++++++++++++++++++++++ src/core/services/config.service.ts | 34 ++++++++++++++++++++----- src/index.ts | 10 +++++++- 4 files changed, 79 insertions(+), 7 deletions(-) diff --git a/src/core/core.module.ts b/src/core/core.module.ts index f7acfb2..621f22c 100644 --- a/src/core/core.module.ts +++ b/src/core/core.module.ts @@ -3,6 +3,8 @@ 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"; @@ -17,6 +19,7 @@ export default defineModule({ "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); 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..c2d343a 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 [locale, currentConfig] = await Promise.all([ + this.getLocaleForGuild(guildId), + this.getOrCreate(guildId), + ]); const defaultConfig = this.createDefaultConfigForModule(module); - const currentConfig = await this.getOrCreate(guildId); const updatedConfig = { ...currentConfig, @@ -101,7 +123,7 @@ class ConfigService { guildId ); - return new ConfigProvider(module, deserializedConfig); + return new ConfigProvider(module, deserializedConfig, locale); } async getFullConfigForGuild( 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); From 7045c0153b047918249a7e3585082d6ebec56ba7 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 18:48:33 +0200 Subject: [PATCH 03/23] feat(i18n): migrate core messages to i18n with EN/FR translations - Add core translations (en.json + fr.json) for config UI, modules list, scalar list editor, modals, placeholders, and error messages - Migrate all hardcoded strings in core-messages.ts to use coreConfig.t() - Replace template literals with i18next named interpolation ({{param}}) - Update require-admin.ts to use localized messages - Update core-messages.test.ts for new i18n-based output --- src/core/i18n/en.json | 79 ++++++++++++++++++++++++++++ src/core/i18n/fr.json | 79 ++++++++++++++++++++++++++++ src/core/utils/core-messages.test.ts | 20 ++++++- src/core/utils/core-messages.ts | 64 +++++++++++++++------- src/core/utils/require-admin.ts | 6 ++- 5 files changed, 226 insertions(+), 22 deletions(-) create mode 100644 src/core/i18n/en.json create mode 100644 src/core/i18n/fr.json diff --git a/src/core/i18n/en.json b/src/core/i18n/en.json new file mode 100644 index 0000000..4e9ee71 --- /dev/null +++ b/src/core/i18n/en.json @@ -0,0 +1,79 @@ +{ + "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.previous": "◀ Previous", + "config.next": "Next ▶", + "config.noModule": "No module with id `{{moduleId}}`", + "config.currentValue": "Current: {{value}}", + "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": "# `{{moduleName}}` settings", + "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": "Core Module", + "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 (en/fr)" +} diff --git a/src/core/i18n/fr.json b/src/core/i18n/fr.json new file mode 100644 index 0000000..e0dccfd --- /dev/null +++ b/src/core/i18n/fr.json @@ -0,0 +1,79 @@ +{ + "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.previous": "◀ Précédent", + "config.next": "Suivant ▶", + "config.noModule": "Aucun module avec l'id `{{moduleId}}`", + "config.currentValue": "Valeur actuelle : {{value}}", + "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 de `{{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": "Module Principal", + "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 (en/fr)" +} diff --git a/src/core/utils/core-messages.test.ts b/src/core/utils/core-messages.test.ts index ad2f198..159637e 100644 --- a/src/core/utils/core-messages.test.ts +++ b/src/core/utils/core-messages.test.ts @@ -1,10 +1,12 @@ -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 { initI18n } from "#lib/i18n.js"; +import { addTranslations } from "#lib/i18n.js"; import type { Module } from "#lib/module.js"; import { CONFIG_FIELDS_PER_PAGE, @@ -12,6 +14,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; @@ -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(); } diff --git a/src/core/utils/core-messages.ts b/src/core/utils/core-messages.ts index 1eb0c04..6221f6d 100644 --- a/src/core/utils/core-messages.ts +++ b/src/core/utils/core-messages.ts @@ -13,30 +13,41 @@ import { 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 +57,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) ) ); @@ -118,12 +131,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 +154,20 @@ 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, + }); 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: renderCurrentValue(option.type, value), + }) ) ); @@ -170,7 +198,7 @@ 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")) ); } @@ -181,12 +209,12 @@ export const configurationMessage = ( new ActionRowBuilder().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) ) 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; From 554a12552955425b509b6ef758c9efb3e0c71fc2 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 18:48:42 +0200 Subject: [PATCH 04/23] feat(i18n): localize config handlers and scalar-list-editor - Migrate all 7 config handlers (string, number, enum, select, user, role, channel, category) to use config.t() with ns fallback - Localize scalar-list-editor with i18n keys - Update integration tests with mock for #core/core.module.js to prevent circular dependency --- src/core/config/category.config-handler.ts | 5 +- src/core/config/channel.config-handler.ts | 5 +- .../config-handlers.integration.test.ts | 43 ++++++- src/core/config/enum.config-handler.ts | 17 +-- src/core/config/number.config-handler.ts | 20 +++- src/core/config/role.config-handler.ts | 5 +- .../scalar-list-editor.integration.test.ts | 24 +++- src/core/config/scalar-list-editor.ts | 113 ++++++++++++++---- src/core/config/select.config-handler.ts | 40 +++++-- src/core/config/string.config-handler.ts | 15 ++- src/core/config/user.config-handler.ts | 5 +- 11 files changed, 227 insertions(+), 65 deletions(-) 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-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..1152539 100644 --- a/src/core/config/enum.config-handler.ts +++ b/src/core/config/enum.config-handler.ts @@ -5,6 +5,7 @@ import { type MessageActionRowComponentBuilder, } from "discord.js"; import { ConfigType, 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,28 +61,30 @@ export default class EnumConfigHandler extends SelectConfigHandler { - const options = optionsFor(module, key).slice(0, MAX_SELECT_VALUES); + 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) diff --git a/src/core/config/number.config-handler.ts b/src/core/config/number.config-handler.ts index 00605e0..1d233d8 100644 --- a/src/core/config/number.config-handler.ts +++ b/src/core/config/number.config-handler.ts @@ -6,6 +6,7 @@ import { TextInputStyle, type ButtonInteraction, } from "discord.js"; +import coreModule from "#core/core.module.js"; import configService from "#core/services/config.service.js"; import { ConfigType, @@ -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,9 +62,14 @@ const handleModalSubmit = declareInteractionHandler({ check: (interaction) => interaction.isModalSubmit(), execute: async (interaction, [moduleId, configKey]) => { const module = resolveConfigurableModule(moduleId); + if (!module || !configService.isConfigKey(module, configKey)) { + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); await interaction.reply({ - content: "Configuration introuvable.", + content: coreConfig.t("interaction.configOptionNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -73,8 +78,12 @@ const handleModalSubmit = declareInteractionHandler({ 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 +96,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..2412c30 100644 --- a/src/core/config/scalar-list-editor.ts +++ b/src/core/config/scalar-list-editor.ts @@ -10,6 +10,7 @@ import { TextInputStyle, type ButtonInteraction, } from "discord.js"; +import coreModule from "#core/core.module.js"; import configService from "#core/services/config.service.js"; import { ConfigType, @@ -17,6 +18,7 @@ import { 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,8 +191,12 @@ const addListItem = declareInteractionHandler({ execute: async (interaction, [moduleId, key, sourceMessageId]) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, key)) { + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); await interaction.reply({ - content: "Configuration introuvable.", + content: coreConfig.t("interaction.configOptionNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -168,7 +205,6 @@ const addListItem = declareInteractionHandler({ 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 +227,8 @@ const addListItem = declareInteractionHandler({ module, key, values, - sourceMessageId! + sourceMessageId!, + provider.t ), flags: MessageFlags.IsComponentsV2, }); @@ -204,14 +241,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,8 +270,12 @@ const toggleListItem = declareInteractionHandler({ execute: async (interaction, [moduleId, key, sourceMessageId, indexRaw]) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, key)) { + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); await interaction.reply({ - content: "Configuration introuvable.", + content: coreConfig.t("interaction.configOptionNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -255,7 +301,8 @@ const toggleListItem = declareInteractionHandler({ module, key, values, - sourceMessageId! + sourceMessageId!, + provider.t ), flags: MessageFlags.IsComponentsV2, }); @@ -270,8 +317,12 @@ const addListItemModal = declareInteractionHandler({ execute: async (interaction, [moduleId, key, sourceMessageId]) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, key)) { + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); await interaction.reply({ - content: "Configuration introuvable.", + content: coreConfig.t("interaction.configOptionNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -280,8 +331,12 @@ const addListItemModal = declareInteractionHandler({ 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 +358,8 @@ const addListItemModal = declareInteractionHandler({ module, key, values, - sourceMessageId! + sourceMessageId!, + provider.t ); if (interaction.isFromMessage()) { await interaction.update({ @@ -327,8 +383,12 @@ const removeListItem = declareInteractionHandler({ execute: async (interaction, [moduleId, key, sourceMessageId, indexRaw]) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, key)) { + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); await interaction.reply({ - content: "Configuration introuvable.", + content: coreConfig.t("interaction.configOptionNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -354,7 +414,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..e442ca9 100644 --- a/src/core/config/select.config-handler.ts +++ b/src/core/config/select.config-handler.ts @@ -6,8 +6,10 @@ import { type ButtonInteraction, type MessageActionRowComponentBuilder, } from "discord.js"; +import coreModule from "#core/core.module.js"; import configService from "#core/services/config.service.js"; import type { ConfigProvider, ConfigSchema, ConfigType } from "#lib/config.js"; +import type { TFunction } from "#lib/i18n.js"; import { declareInteractionHandler, type CompatibleInteraction, @@ -35,6 +37,7 @@ export interface SelectRowContext { current: string[]; minValues: number; maxValues: number; + t: TFunction; } /** @@ -84,7 +87,8 @@ export abstract class SelectConfigHandler< */ protected editorUnavailableReason( _module: Module, - _key: string + _key: string, + _t: TFunction ): string | null { return null; } @@ -94,7 +98,8 @@ export abstract class SelectConfigHandler< module: Module, key: string, current: string[], - sourceMessageId: string + sourceMessageId: string, + t: TFunction ): ContainerBuilder { const customId = `${this.selectCustomId}:${module.id}:${key}:${sourceMessageId}`; const isList = isListEntry(getConfigEntry(module, key)); @@ -103,6 +108,7 @@ export abstract class SelectConfigHandler< key, customId, current, + t, minValues: isList ? 0 : 1, maxValues: isList ? this.maxSelectableValues(module, key) : 1, }); @@ -117,7 +123,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 +136,8 @@ export abstract class SelectConfigHandler< module, String(key), this.currentValues(config.get(key)), - sourceMessageId + sourceMessageId, + config.t ); await interaction.reply({ @@ -158,8 +165,12 @@ export abstract class SelectConfigHandler< ) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, configKey)) { + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); await interaction.reply({ - content: "Configuration introuvable.", + content: coreConfig.t("interaction.configOptionNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -169,8 +180,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 +200,23 @@ 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 + ), ], flags: MessageFlags.IsComponentsV2, }); diff --git a/src/core/config/string.config-handler.ts b/src/core/config/string.config-handler.ts index 80018d3..dc6ee5c 100644 --- a/src/core/config/string.config-handler.ts +++ b/src/core/config/string.config-handler.ts @@ -6,6 +6,7 @@ import { TextInputBuilder, TextInputStyle, } from "discord.js"; +import coreModule from "#core/core.module.js"; import configService from "#core/services/config.service.js"; import { ConfigProvider, ConfigType, type ConfigSchema } from "#lib/config.js"; import { declareInteractionHandler } from "#lib/interaction.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,9 +57,14 @@ const handleModalSubmit = declareInteractionHandler({ check: (interaction) => interaction.isModalSubmit(), execute: async (interaction, [moduleId, configKey]) => { const module = resolveConfigurableModule(moduleId); + if (!module || !configService.isConfigKey(module, configKey)) { + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); await interaction.reply({ - content: "Configuration introuvable.", + content: coreConfig.t("interaction.configOptionNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -75,7 +79,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); From a304dcb0a3fd70eaa582a4fb7996fa5f5766e874 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 18:48:50 +0200 Subject: [PATCH 05/23] feat(i18n): localize slash commands, interactions and listener - Add setDescriptionLocalizations() for /config and /modules commands - Localize autocomplete module names via config.t() - Migrate all button interactions (config-page, configure-module, enable-module, disable-module, toggle-option) to use coreConfig.t() - Update interaction-create.listener.ts for localized responses --- src/core/commands/config.command.ts | 31 ++++++++++++++----- src/core/commands/module.command.ts | 12 ++++--- src/core/interactions/config-page.button.ts | 7 ++++- .../interactions/configure-module.button.ts | 7 ++++- .../interactions/disable-module.button.ts | 15 ++++++--- src/core/interactions/enable-module.button.ts | 15 ++++++--- src/core/interactions/toggle-option.button.ts | 9 ++++-- .../listeners/interaction-create.listener.ts | 19 ++++++++++-- 8 files changed, 88 insertions(+), 27 deletions(-) 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/interactions/config-page.button.ts b/src/core/interactions/config-page.button.ts index 9c23749..4ce4bf1 100644 --- a/src/core/interactions/config-page.button.ts +++ b/src/core/interactions/config-page.button.ts @@ -1,5 +1,6 @@ import { MessageFlags } from "discord.js"; import { resolveConfigurableModule } from "#core/config/config-edit.js"; +import coreModule from "#core/core.module.js"; import configService from "#core/services/config.service.js"; import { configurationMessage } from "#core/utils/core-messages.js"; import { declareInteractionHandler } from "#lib/interaction.js"; @@ -16,8 +17,12 @@ export default declareInteractionHandler({ async execute(interaction, [moduleId, pageRaw]) { const module = resolveConfigurableModule(moduleId); if (!module) { + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); await interaction.reply({ - content: "Configuration introuvable.", + content: coreConfig.t("config.notFound"), flags: MessageFlags.Ephemeral, }); return; diff --git a/src/core/interactions/configure-module.button.ts b/src/core/interactions/configure-module.button.ts index 102fb60..6813e42 100644 --- a/src/core/interactions/configure-module.button.ts +++ b/src/core/interactions/configure-module.button.ts @@ -6,6 +6,7 @@ import { } from "#core/config/config-edit.js"; import configHandlers from "#core/config/config-handler-registry.js"; import { openScalarListEditor } from "#core/config/scalar-list-editor.js"; +import coreModule from "#core/core.module.js"; import configService from "#core/services/config.service.js"; import { ConfigType } from "#lib/config.js"; import { declareInteractionHandler } from "#lib/interaction.js"; @@ -18,8 +19,12 @@ export default declareInteractionHandler({ const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, configKey)) { + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); await interaction.reply({ - content: "Configuration option not found.", + content: coreConfig.t("interaction.configOptionNotFound"), flags: MessageFlags.Ephemeral, }); return; diff --git a/src/core/interactions/disable-module.button.ts b/src/core/interactions/disable-module.button.ts index 871a640..e351a18 100644 --- a/src/core/interactions/disable-module.button.ts +++ b/src/core/interactions/disable-module.button.ts @@ -1,5 +1,7 @@ import { MessageFlags } from "discord.js"; +import coreModule from "#core/core.module.js"; import { uninstallModule } from "#core/loaders/module-installer.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 { modules } from "#index.js"; @@ -11,9 +13,14 @@ export default declareInteractionHandler({ check: (interaction) => interaction.isButton(), async execute(interaction, args) { const moduleId = args[0]; + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); + if (!moduleId) { await interaction.reply({ - content: "The button is malformed. Please try again later.", + content: coreConfig.t("interaction.malformed"), flags: MessageFlags.Ephemeral, }); return; @@ -22,7 +29,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: coreConfig.t("interaction.moduleNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -34,7 +41,7 @@ export default declareInteractionHandler({ await uninstallModule(module, interaction.guild!); } catch { await interaction.followUp({ - content: "Failed to disable the module. Please try again later.", + content: coreConfig.t("interaction.failedDisable"), flags: MessageFlags.Ephemeral, }); return; @@ -45,7 +52,7 @@ export default declareInteractionHandler({ ); await defer.edit({ - components: [modulesMessage(modulesState)], + components: [modulesMessage(modulesState, coreConfig.t)], flags: MessageFlags.IsComponentsV2, }); }, diff --git a/src/core/interactions/enable-module.button.ts b/src/core/interactions/enable-module.button.ts index 8374682..e7e3b1f 100644 --- a/src/core/interactions/enable-module.button.ts +++ b/src/core/interactions/enable-module.button.ts @@ -1,5 +1,7 @@ import { MessageFlags } from "discord.js"; +import coreModule from "#core/core.module.js"; import { installModule } from "#core/loaders/module-installer.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 { modules } from "#index.js"; @@ -11,9 +13,14 @@ export default declareInteractionHandler({ check: (interaction) => interaction.isButton(), async execute(interaction, args) { const moduleId = args[0]; + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); + if (!moduleId) { await interaction.reply({ - content: "The button is malformed. Please try again later.", + content: coreConfig.t("interaction.malformed"), flags: MessageFlags.Ephemeral, }); return; @@ -22,7 +29,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: coreConfig.t("interaction.moduleNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -34,7 +41,7 @@ export default declareInteractionHandler({ await installModule(module, interaction.guild!); } catch { await interaction.followUp({ - content: "Failed to enable the module. Please try again later.", + content: coreConfig.t("interaction.failedEnable"), flags: MessageFlags.Ephemeral, }); return; @@ -45,7 +52,7 @@ export default declareInteractionHandler({ ); await defer.edit({ - components: [modulesMessage(modulesState)], + components: [modulesMessage(modulesState, coreConfig.t)], flags: MessageFlags.IsComponentsV2, }); }, diff --git a/src/core/interactions/toggle-option.button.ts b/src/core/interactions/toggle-option.button.ts index e060920..7302670 100644 --- a/src/core/interactions/toggle-option.button.ts +++ b/src/core/interactions/toggle-option.button.ts @@ -1,5 +1,6 @@ import { MessageFlags } from "discord.js"; import { resolveConfigurableModule } from "#core/config/config-edit.js"; +import coreModule from "#core/core.module.js"; import configService from "#core/services/config.service.js"; import { configPageOfKey, @@ -15,8 +16,12 @@ export default declareInteractionHandler({ const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, configKey)) { + const coreConfig = await configService.getConfigForModuleIn( + coreModule, + interaction.guildId! + ); await interaction.reply({ - content: "Configuration option not found.", + content: coreConfig.t("interaction.configOptionNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -31,7 +36,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 { From ae18d96d72c28c192e3de8894d9686730e9a8565 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 18:48:59 +0200 Subject: [PATCH 06/23] feat(i18n): add module-level i18n for thread-creator and test-config - Migrate thread-creator config name/description to English defaults, add i18n/en.json + i18n/fr.json - Migrate test-config module (name, config, descriptions) to i18n - Both modules use module-level i18n/ directory with EN/FR locale files - Module names in core i18n fallback: modules.thread-creator, modules.test-config --- src/modules/test-config/i18n/en.json | 43 ++++++++++++++ src/modules/test-config/i18n/fr.json | 43 ++++++++++++++ src/modules/test-config/test-config.module.ts | 58 +++++++++---------- src/modules/thread-creator/i18n/en.json | 10 ++++ src/modules/thread-creator/i18n/fr.json | 10 ++++ .../thread-creator/thread-creator.config.ts | 12 ++-- .../thread-creator/thread-creator.module.ts | 2 +- 7 files changed, 142 insertions(+), 36 deletions(-) create mode 100644 src/modules/test-config/i18n/en.json create mode 100644 src/modules/test-config/i18n/fr.json create mode 100644 src/modules/thread-creator/i18n/en.json create mode 100644 src/modules/thread-creator/i18n/fr.json 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..cc3edf8 --- /dev/null +++ b/src/modules/thread-creator/i18n/en.json @@ -0,0 +1,10 @@ +{ + "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.threadNameTemplate.name": "Name template", + "config.threadNameTemplate.description": "Thread name — variables: {messageAuthor}, {messageContent}, {timestamp}." +} diff --git a/src/modules/thread-creator/i18n/fr.json b/src/modules/thread-creator/i18n/fr.json new file mode 100644 index 0000000..5868211 --- /dev/null +++ b/src/modules/thread-creator/i18n/fr.json @@ -0,0 +1,10 @@ +{ + "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.threadNameTemplate.name": "Template de nom", + "config.threadNameTemplate.description": "Nom des fils — variables : {messageAuthor}, {messageContent}, {timestamp}." +} diff --git a/src/modules/thread-creator/thread-creator.config.ts b/src/modules/thread-creator/thread-creator.config.ts index 44c5227..6eb2f8a 100644 --- a/src/modules/thread-creator/thread-creator.config.ts +++ b/src/modules/thread-creator/thread-creator.config.ts @@ -8,21 +8,21 @@ 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 !", }, 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", From 2af8eef712c6e961f84ea914b7da6e70da1d0f64 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 18:55:20 +0200 Subject: [PATCH 07/23] docs(i18n): add localization sections to EN documentation --- docs/site/en/guide/architecture.md | 17 +++++--- docs/site/en/guide/commands.md | 22 ++++++++++ docs/site/en/guide/configuration.md | 44 ++++++++++++++++++++ docs/site/en/guide/creating-a-module.md | 54 +++++++++++++++++++++++++ docs/site/en/guide/getting-started.md | 5 ++- docs/site/en/guide/interactions.md | 15 +++++++ docs/site/en/guide/listeners.md | 13 ++++++ docs/site/en/index.md | 3 ++ 8 files changed, 166 insertions(+), 7 deletions(-) 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..5ba9a3a 100644 --- a/docs/site/en/guide/commands.md +++ b/docs/site/en/guide/commands.md @@ -166,6 +166,28 @@ 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 names and descriptions can be translated per locale using Discord's built-in localization methods: + +```typescript +data: new SlashCommandBuilder() + .setName("hello") + .setDescription("Says hello!") + .setNameLocalizations({ fr: "bonjour" }) + .setDescriptionLocalizations({ fr: "Dit bonjour !" }), +``` + +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..9c84d8a 100644 --- a/docs/site/en/guide/configuration.md +++ b/docs/site/en/guide/configuration.md @@ -177,6 +177,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`, `config.toggle.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..076f6bc 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 @@ -142,6 +145,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`, `config.toggle.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/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. --- From 7a9cad18bd173c4a798951d9b7d0a456d900f3f6 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 18:55:26 +0200 Subject: [PATCH 08/23] docs(i18n): add localization sections to FR documentation - Add i18n feature card to index.md - Mention i18n in getting-started feature description, project structure, and naming conventions - Add i18n init step to architecture boot sequence - Add full Internationalisation section to creating-a-module.md - Add Localisation section to commands.md, configuration.md, interactions.md, listeners.md --- docs/site/fr/guide/architecture.md | 17 +++++--- docs/site/fr/guide/commands.md | 22 ++++++++++ docs/site/fr/guide/configuration.md | 44 ++++++++++++++++++++ docs/site/fr/guide/creating-a-module.md | 54 +++++++++++++++++++++++++ docs/site/fr/guide/getting-started.md | 5 ++- docs/site/fr/guide/interactions.md | 15 +++++++ docs/site/fr/guide/listeners.md | 13 ++++++ docs/site/fr/index.md | 3 ++ 8 files changed, 166 insertions(+), 7 deletions(-) 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..41a69b7 100644 --- a/docs/site/fr/guide/commands.md +++ b/docs/site/fr/guide/commands.md @@ -172,6 +172,28 @@ 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 noms et descriptions des commandes slash peuvent être traduits par locale en utilisant les méthodes de localisation intégrées de Discord : + +```typescript +data: new SlashCommandBuilder() + .setName("hello") + .setDescription("Says hello!") + .setNameLocalizations({ fr: "bonjour" }) + .setDescriptionLocalizations({ fr: "Dit bonjour !" }), +``` + +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..76fb72b 100644 --- a/docs/site/fr/guide/configuration.md +++ b/docs/site/fr/guide/configuration.md @@ -177,6 +177,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`, `config.toggle.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..d4e96ae 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 @@ -142,6 +145,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`, `config.toggle.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/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. --- From 5a9241da64d9c16c7dc64d1d37b91e317ec7c2b9 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 18:58:03 +0200 Subject: [PATCH 09/23] docs(i18n): add tip notes that TypeScript values default to English --- docs/site/en/guide/commands.md | 4 ++++ docs/site/en/guide/configuration.md | 4 ++++ docs/site/en/guide/creating-a-module.md | 4 ++++ docs/site/fr/guide/commands.md | 4 ++++ docs/site/fr/guide/configuration.md | 4 ++++ docs/site/fr/guide/creating-a-module.md | 4 ++++ 6 files changed, 24 insertions(+) diff --git a/docs/site/en/guide/commands.md b/docs/site/en/guide/commands.md index 5ba9a3a..c2622bd 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. Use `setNameLocalizations()` and `setDescriptionLocalizations()` for other locales. +::: + ```typescript // src/modules/greeter/commands/hello.command.ts diff --git a/docs/site/en/guide/configuration.md b/docs/site/en/guide/configuration.md index 9c84d8a..c732b05 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 diff --git a/docs/site/en/guide/creating-a-module.md b/docs/site/en/guide/creating-a-module.md index 076f6bc..6939a61 100644 --- a/docs/site/en/guide/creating-a-module.md +++ b/docs/site/en/guide/creating-a-module.md @@ -25,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 diff --git a/docs/site/fr/guide/commands.md b/docs/site/fr/guide/commands.md index 41a69b7..9fc8f93 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. Utilisez `setNameLocalizations()` et `setDescriptionLocalizations()` pour les autres locales. +::: + ```typescript // src/modules/salut/commands/bonjour.command.ts diff --git a/docs/site/fr/guide/configuration.md b/docs/site/fr/guide/configuration.md index 76fb72b..cf69673 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 diff --git a/docs/site/fr/guide/creating-a-module.md b/docs/site/fr/guide/creating-a-module.md index d4e96ae..732f2b5 100644 --- a/docs/site/fr/guide/creating-a-module.md +++ b/docs/site/fr/guide/creating-a-module.md @@ -25,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 From 69f5009d99b1e1e813b52e476453d1e05d1fe482 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 19:00:15 +0200 Subject: [PATCH 10/23] =?UTF-8?q?docs(i18n):=20fix=20commands=20localizati?= =?UTF-8?q?on=20=E2=80=94=20names=20are=20not=20localizable=20on=20Discord?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/site/en/guide/commands.md | 7 ++++--- docs/site/fr/guide/commands.md | 7 ++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/site/en/guide/commands.md b/docs/site/en/guide/commands.md index c2622bd..b01830b 100644 --- a/docs/site/en/guide/commands.md +++ b/docs/site/en/guide/commands.md @@ -5,7 +5,7 @@ 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. Use `setNameLocalizations()` and `setDescriptionLocalizations()` for other locales. +Set command names and descriptions in English by default — they act as the fallback when no translation is available for the guild's locale. Note that Discord only supports localized descriptions, not command names. Use `setDescriptionLocalizations()` for description translations. ::: ```typescript @@ -172,16 +172,17 @@ OmniBot provides two built-in commands in the **Core** module (always active, no ## Localization -Slash command names and descriptions can be translated per locale using Discord's built-in localization methods: +Slash command descriptions can be translated per locale using Discord's built-in localization methods: ```typescript data: new SlashCommandBuilder() .setName("hello") .setDescription("Says hello!") - .setNameLocalizations({ fr: "bonjour" }) .setDescriptionLocalizations({ fr: "Dit bonjour !" }), ``` +> Command names cannot be localized on Discord. Use the English name as the single source of truth. + For module-specific strings in command responses, use `config.t()` to look up translations from your module's i18n files: ```typescript diff --git a/docs/site/fr/guide/commands.md b/docs/site/fr/guide/commands.md index 9fc8f93..35e2689 100644 --- a/docs/site/fr/guide/commands.md +++ b/docs/site/fr/guide/commands.md @@ -5,7 +5,7 @@ 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. Utilisez `setNameLocalizations()` et `setDescriptionLocalizations()` pour les autres locales. +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. Notez que Discord ne supporte que les descriptions localisées, pas les noms de commandes. Utilisez `setDescriptionLocalizations()` pour les traductions des descriptions. ::: ```typescript @@ -178,16 +178,17 @@ OmniBot fournit deux commandes intégrées dans le module **Cœur** (toujours ac ## Localisation -Les noms et descriptions des commandes slash peuvent être traduits par locale en utilisant les méthodes de localisation intégrées de Discord : +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!") - .setNameLocalizations({ fr: "bonjour" }) .setDescriptionLocalizations({ fr: "Dit bonjour !" }), ``` +> Les noms de commandes ne peuvent pas être localisés sur Discord. Utilisez le nom anglais comme source unique de vérité. + 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 From 0f38cb57e597b3a925a0c0e6543d24ef1b113727 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 19:00:56 +0200 Subject: [PATCH 11/23] =?UTF-8?q?docs(i18n):=20fix=20command=20name=20loca?= =?UTF-8?q?lization=20note=20=E2=80=94=20Discord=20supports=20it,=20bot=20?= =?UTF-8?q?just=20doesn't=20use=20it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/site/en/guide/commands.md | 4 ++-- docs/site/fr/guide/commands.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/site/en/guide/commands.md b/docs/site/en/guide/commands.md index b01830b..41336d7 100644 --- a/docs/site/en/guide/commands.md +++ b/docs/site/en/guide/commands.md @@ -5,7 +5,7 @@ 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. Note that Discord only supports localized descriptions, not command names. Use `setDescriptionLocalizations()` for description translations. +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 @@ -181,7 +181,7 @@ data: new SlashCommandBuilder() .setDescriptionLocalizations({ fr: "Dit bonjour !" }), ``` -> Command names cannot be localized on Discord. Use the English name as the single source of truth. +> 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: diff --git a/docs/site/fr/guide/commands.md b/docs/site/fr/guide/commands.md index 35e2689..20e86de 100644 --- a/docs/site/fr/guide/commands.md +++ b/docs/site/fr/guide/commands.md @@ -5,7 +5,7 @@ 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. Notez que Discord ne supporte que les descriptions localisées, pas les noms de commandes. Utilisez `setDescriptionLocalizations()` pour les traductions des descriptions. +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 @@ -187,7 +187,7 @@ data: new SlashCommandBuilder() .setDescriptionLocalizations({ fr: "Dit bonjour !" }), ``` -> Les noms de commandes ne peuvent pas être localisés sur Discord. Utilisez le nom anglais comme source unique de vérité. +> 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 : From d85a6cb48ccd94a558ad57e0f2e85e851dead5c6 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 19:02:43 +0200 Subject: [PATCH 12/23] docs(i18n): add dedicated Localization page to sidebar --- docs/site/.vitepress/config.ts | 2 + docs/site/en/guide/localization.md | 120 +++++++++++++++++++++++++++++ docs/site/fr/guide/localisation.md | 120 +++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+) create mode 100644 docs/site/en/guide/localization.md create mode 100644 docs/site/fr/guide/localisation.md 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/localization.md b/docs/site/en/guide/localization.md new file mode 100644 index 0000000..ab6ccdb --- /dev/null +++ b/docs/site/en/guide/localization.md @@ -0,0 +1,120 @@ +# 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 | + +## 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); +// → "Short text" (EN) or "Texte court" (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}} | +| `config.toggle.enable` | Enable | Activer | +| `config.toggle.disable` | Disable | Désactiver | +| `type.text` | Short text | Texte court | +| `type.number` | Number | Nombre | +| `type.boolean` | Yes/No | Oui/Non | +| `type.user` | User | Utilisateur | +| `type.role` | Role | Rôle | +| `type.channel` | Channel | Salon | +| `type.category` | Category | Catégorie | +| `type.enum` | 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/fr/guide/localisation.md b/docs/site/fr/guide/localisation.md new file mode 100644 index 0000000..a7a35de --- /dev/null +++ b/docs/site/fr/guide/localisation.md @@ -0,0 +1,120 @@ +# 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 | + +## 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); +// → "Short text" (EN) ou "Texte court" (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}} | +| `config.toggle.enable` | Enable | Activer | +| `config.toggle.disable` | Disable | Désactiver | +| `type.text` | Short text | Texte court | +| `type.number` | Number | Nombre | +| `type.boolean` | Yes/No | Oui/Non | +| `type.user` | User | Utilisateur | +| `type.role` | Role | Rôle | +| `type.channel` | Channel | Salon | +| `type.category` | Category | Catégorie | +| `type.enum` | 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. From 08153439e41b24ab5ddda97e28896526f68c0270 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 19:06:18 +0200 Subject: [PATCH 13/23] docs(i18n): escape Vue interpolation in localization pages --- docs/site/en/guide/localization.md | 36 +++++++++++++++--------------- docs/site/fr/guide/localisation.md | 36 +++++++++++++++--------------- 2 files changed, 36 insertions(+), 36 deletions(-) diff --git a/docs/site/en/guide/localization.md b/docs/site/en/guide/localization.md index ab6ccdb..dd14b7e 100644 --- a/docs/site/en/guide/localization.md +++ b/docs/site/en/guide/localization.md @@ -35,7 +35,7 @@ Translation files are auto-discovered at startup by the module loader — no reg } ``` -Use `{{param}}` syntax for dynamic values — never concatenate strings with `${}` or `+` inside translated text. +Use {{param}} syntax for dynamic values — never concatenate strings with `${}` or `+` inside translated text. ### Module metadata @@ -88,22 +88,22 @@ Only descriptions are localized in this bot — command names stay in English. 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}} | -| `config.toggle.enable` | Enable | Activer | -| `config.toggle.disable` | Disable | Désactiver | -| `type.text` | Short text | Texte court | -| `type.number` | Number | Nombre | -| `type.boolean` | Yes/No | Oui/Non | -| `type.user` | User | Utilisateur | -| `type.role` | Role | Rôle | -| `type.channel` | Channel | Salon | -| `type.category` | Category | Catégorie | -| `type.enum` | Choice | Choix | -| `type.listOf` | List of {{type}} | Liste de {{type}} | +| Key | English value | French value | +| ----------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | +| `config.previous` | ◀ Previous | ◀ Précédent | +| `config.next` | Next ▶ | Suivant ▶ | +| `config.page` | Page {{current}}/{{total}} | Page {{current}}/{{total}} | +| `config.toggle.enable` | Enable | Activer | +| `config.toggle.disable` | Disable | Désactiver | +| `type.text` | Short text | Texte court | +| `type.number` | Number | Nombre | +| `type.boolean` | Yes/No | Oui/Non | +| `type.user` | User | Utilisateur | +| `type.role` | Role | Rôle | +| `type.channel` | Channel | Salon | +| `type.category` | Category | Catégorie | +| `type.enum` | Choice | Choix | +| `type.listOf` | List of {{type}} | Liste de {{type}} | ## Adding a new locale @@ -115,6 +115,6 @@ Common UI strings are provided by the core namespace and are available in every ## 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. +- **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/fr/guide/localisation.md b/docs/site/fr/guide/localisation.md index a7a35de..1634626 100644 --- a/docs/site/fr/guide/localisation.md +++ b/docs/site/fr/guide/localisation.md @@ -35,7 +35,7 @@ Les fichiers de traduction sont découverts automatiquement au démarrage par le } ``` -Utilisez la syntaxe `{{param}}` pour les valeurs dynamiques — ne concaténez jamais avec `${}` ou `+` dans le texte traduit. +Utilisez la syntaxe {{param}} pour les valeurs dynamiques — ne concaténez jamais avec `${}` ou `+` dans le texte traduit. ### Métadonnées du module @@ -88,22 +88,22 @@ Dans ce bot, seules les descriptions sont localisées — les noms de commandes 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}} | -| `config.toggle.enable` | Enable | Activer | -| `config.toggle.disable` | Disable | Désactiver | -| `type.text` | Short text | Texte court | -| `type.number` | Number | Nombre | -| `type.boolean` | Yes/No | Oui/Non | -| `type.user` | User | Utilisateur | -| `type.role` | Role | Rôle | -| `type.channel` | Channel | Salon | -| `type.category` | Category | Catégorie | -| `type.enum` | Choice | Choix | -| `type.listOf` | List of {{type}} | Liste de {{type}} | +| Clé | Valeur anglais | Valeur français | +| ----------------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------ | +| `config.previous` | ◀ Previous | ◀ Précédent | +| `config.next` | Next ▶ | Suivant ▶ | +| `config.page` | Page {{current}}/{{total}} | Page {{current}}/{{total}} | +| `config.toggle.enable` | Enable | Activer | +| `config.toggle.disable` | Disable | Désactiver | +| `type.text` | Short text | Texte court | +| `type.number` | Number | Nombre | +| `type.boolean` | Yes/No | Oui/Non | +| `type.user` | User | Utilisateur | +| `type.role` | Role | Rôle | +| `type.channel` | Channel | Salon | +| `type.category` | Category | Catégorie | +| `type.enum` | Choice | Choix | +| `type.listOf` | List of {{type}} | Liste de {{type}} | ## Ajouter une nouvelle locale @@ -115,6 +115,6 @@ Les chaînes d'interface communes sont fournies par le namespace principal et di ## 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 `${}`. +- **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. From 903b238a4a4a44bf2183efab631902c1fe9ab2e1 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 19:23:05 +0200 Subject: [PATCH 14/23] fix(core): restore missing 'Current:' prefix in config option display Use the config.currentValue translation key in the config.option template so the 'Current: / Valeur actuelle :' label is shown again. Also fix duplicate import in the test file. --- src/core/utils/core-messages.test.ts | 3 +-- src/core/utils/core-messages.ts | 4 +++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/core/utils/core-messages.test.ts b/src/core/utils/core-messages.test.ts index 159637e..00c6a7c 100644 --- a/src/core/utils/core-messages.test.ts +++ b/src/core/utils/core-messages.test.ts @@ -5,8 +5,7 @@ import { type ConfigData, type ConfigSchema, } from "#lib/config.js"; -import { initI18n } from "#lib/i18n.js"; -import { addTranslations } from "#lib/i18n.js"; +import { addTranslations, initI18n } from "#lib/i18n.js"; import type { Module } from "#lib/module.js"; import { CONFIG_FIELDS_PER_PAGE, diff --git a/src/core/utils/core-messages.ts b/src/core/utils/core-messages.ts index 6221f6d..040e065 100644 --- a/src/core/utils/core-messages.ts +++ b/src/core/utils/core-messages.ts @@ -166,7 +166,9 @@ export const configurationMessage = ( typeName: getConfigTypeName(option.type, config.t), optionName: optName, optionDesc: optDesc, - currentValue: renderCurrentValue(option.type, value), + currentValue: config.t("config.currentValue", { + value: renderCurrentValue(option.type, value), + }), }) ) ); From b677a774c7cd00481b522a3e35ee6de12506c4a0 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 19:23:22 +0200 Subject: [PATCH 15/23] refactor(core): extract core config helper to reduce repetition and break circular deps Create src/core/utils/core-config.ts with getCoreT() and replyWithCoreT() helpers that centralize the configService.getConfigForModuleIn(coreModule) pattern. Update all config handlers and button interaction handlers to use these helpers instead of importing coreModule directly, eliminating the circular dependency chain (core.module -> handlers -> core.module) and removing the repeated 4-line boilerplate from 9 files. --- src/core/config/number.config-handler.ts | 11 +----- src/core/config/scalar-list-editor.ts | 38 +++---------------- src/core/config/select.config-handler.ts | 12 ++---- src/core/config/string.config-handler.ts | 11 +----- src/core/interactions/config-page.button.ts | 11 +----- .../interactions/configure-module.button.ts | 12 +----- .../interactions/disable-module.button.ts | 16 +++----- src/core/interactions/enable-module.button.ts | 16 +++----- src/core/interactions/toggle-option.button.ts | 11 +----- src/core/utils/core-config.ts | 24 ++++++++++++ 10 files changed, 55 insertions(+), 107 deletions(-) create mode 100644 src/core/utils/core-config.ts diff --git a/src/core/config/number.config-handler.ts b/src/core/config/number.config-handler.ts index 1d233d8..a87b06e 100644 --- a/src/core/config/number.config-handler.ts +++ b/src/core/config/number.config-handler.ts @@ -6,8 +6,8 @@ import { TextInputStyle, type ButtonInteraction, } from "discord.js"; -import coreModule from "#core/core.module.js"; import configService from "#core/services/config.service.js"; +import { replyWithCoreT } from "#core/utils/core-config.js"; import { ConfigType, ConfigValidator, @@ -64,14 +64,7 @@ const handleModalSubmit = declareInteractionHandler({ const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, configKey)) { - const coreConfig = await configService.getConfigForModuleIn( - coreModule, - interaction.guildId! - ); - await interaction.reply({ - content: coreConfig.t("interaction.configOptionNotFound"), - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } diff --git a/src/core/config/scalar-list-editor.ts b/src/core/config/scalar-list-editor.ts index 2412c30..904e470 100644 --- a/src/core/config/scalar-list-editor.ts +++ b/src/core/config/scalar-list-editor.ts @@ -10,8 +10,8 @@ import { TextInputStyle, type ButtonInteraction, } from "discord.js"; -import coreModule from "#core/core.module.js"; import configService from "#core/services/config.service.js"; +import { replyWithCoreT } from "#core/utils/core-config.js"; import { ConfigType, ConfigValidator, @@ -191,14 +191,7 @@ const addListItem = declareInteractionHandler({ execute: async (interaction, [moduleId, key, sourceMessageId]) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, key)) { - const coreConfig = await configService.getConfigForModuleIn( - coreModule, - interaction.guildId! - ); - await interaction.reply({ - content: coreConfig.t("interaction.configOptionNotFound"), - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } @@ -270,14 +263,7 @@ const toggleListItem = declareInteractionHandler({ execute: async (interaction, [moduleId, key, sourceMessageId, indexRaw]) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, key)) { - const coreConfig = await configService.getConfigForModuleIn( - coreModule, - interaction.guildId! - ); - await interaction.reply({ - content: coreConfig.t("interaction.configOptionNotFound"), - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } @@ -317,14 +303,7 @@ const addListItemModal = declareInteractionHandler({ execute: async (interaction, [moduleId, key, sourceMessageId]) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, key)) { - const coreConfig = await configService.getConfigForModuleIn( - coreModule, - interaction.guildId! - ); - await interaction.reply({ - content: coreConfig.t("interaction.configOptionNotFound"), - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } @@ -383,14 +362,7 @@ const removeListItem = declareInteractionHandler({ execute: async (interaction, [moduleId, key, sourceMessageId, indexRaw]) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, key)) { - const coreConfig = await configService.getConfigForModuleIn( - coreModule, - interaction.guildId! - ); - await interaction.reply({ - content: coreConfig.t("interaction.configOptionNotFound"), - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } diff --git a/src/core/config/select.config-handler.ts b/src/core/config/select.config-handler.ts index e442ca9..f97ce3d 100644 --- a/src/core/config/select.config-handler.ts +++ b/src/core/config/select.config-handler.ts @@ -6,8 +6,8 @@ import { type ButtonInteraction, type MessageActionRowComponentBuilder, } from "discord.js"; -import coreModule from "#core/core.module.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 { @@ -165,14 +165,10 @@ export abstract class SelectConfigHandler< ) => { const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, configKey)) { - const coreConfig = await configService.getConfigForModuleIn( - coreModule, - interaction.guildId! + await replyWithCoreT( + interaction, + "interaction.configOptionNotFound" ); - await interaction.reply({ - content: coreConfig.t("interaction.configOptionNotFound"), - flags: MessageFlags.Ephemeral, - }); return; } diff --git a/src/core/config/string.config-handler.ts b/src/core/config/string.config-handler.ts index dc6ee5c..464032d 100644 --- a/src/core/config/string.config-handler.ts +++ b/src/core/config/string.config-handler.ts @@ -6,8 +6,8 @@ import { TextInputBuilder, TextInputStyle, } from "discord.js"; -import coreModule from "#core/core.module.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"; @@ -59,14 +59,7 @@ const handleModalSubmit = declareInteractionHandler({ const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, configKey)) { - const coreConfig = await configService.getConfigForModuleIn( - coreModule, - interaction.guildId! - ); - await interaction.reply({ - content: coreConfig.t("interaction.configOptionNotFound"), - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } diff --git a/src/core/interactions/config-page.button.ts b/src/core/interactions/config-page.button.ts index 4ce4bf1..5bfb5ad 100644 --- a/src/core/interactions/config-page.button.ts +++ b/src/core/interactions/config-page.button.ts @@ -1,7 +1,7 @@ import { MessageFlags } from "discord.js"; import { resolveConfigurableModule } from "#core/config/config-edit.js"; -import coreModule from "#core/core.module.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"; @@ -17,14 +17,7 @@ export default declareInteractionHandler({ async execute(interaction, [moduleId, pageRaw]) { const module = resolveConfigurableModule(moduleId); if (!module) { - const coreConfig = await configService.getConfigForModuleIn( - coreModule, - interaction.guildId! - ); - await interaction.reply({ - content: coreConfig.t("config.notFound"), - 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 6813e42..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, @@ -6,8 +5,8 @@ import { } from "#core/config/config-edit.js"; import configHandlers from "#core/config/config-handler-registry.js"; import { openScalarListEditor } from "#core/config/scalar-list-editor.js"; -import coreModule from "#core/core.module.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"; @@ -19,14 +18,7 @@ export default declareInteractionHandler({ const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, configKey)) { - const coreConfig = await configService.getConfigForModuleIn( - coreModule, - interaction.guildId! - ); - await interaction.reply({ - content: coreConfig.t("interaction.configOptionNotFound"), - 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 e351a18..7de1b94 100644 --- a/src/core/interactions/disable-module.button.ts +++ b/src/core/interactions/disable-module.button.ts @@ -1,8 +1,7 @@ import { MessageFlags } from "discord.js"; -import coreModule from "#core/core.module.js"; import { uninstallModule } from "#core/loaders/module-installer.js"; -import configService from "#core/services/config.service.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"; @@ -13,14 +12,11 @@ export default declareInteractionHandler({ check: (interaction) => interaction.isButton(), async execute(interaction, args) { const moduleId = args[0]; - const coreConfig = await configService.getConfigForModuleIn( - coreModule, - interaction.guildId! - ); + const coreT = await getCoreT(interaction.guildId!); if (!moduleId) { await interaction.reply({ - content: coreConfig.t("interaction.malformed"), + content: coreT("interaction.malformed"), flags: MessageFlags.Ephemeral, }); return; @@ -29,7 +25,7 @@ export default declareInteractionHandler({ const module = modules.find((mod) => mod.id === moduleId); if (!module) { await interaction.reply({ - content: coreConfig.t("interaction.moduleNotFound"), + content: coreT("interaction.moduleNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -41,7 +37,7 @@ export default declareInteractionHandler({ await uninstallModule(module, interaction.guild!); } catch { await interaction.followUp({ - content: coreConfig.t("interaction.failedDisable"), + content: coreT("interaction.failedDisable"), flags: MessageFlags.Ephemeral, }); return; @@ -52,7 +48,7 @@ export default declareInteractionHandler({ ); await defer.edit({ - components: [modulesMessage(modulesState, coreConfig.t)], + 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 e7e3b1f..b89a885 100644 --- a/src/core/interactions/enable-module.button.ts +++ b/src/core/interactions/enable-module.button.ts @@ -1,8 +1,7 @@ import { MessageFlags } from "discord.js"; -import coreModule from "#core/core.module.js"; import { installModule } from "#core/loaders/module-installer.js"; -import configService from "#core/services/config.service.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"; @@ -13,14 +12,11 @@ export default declareInteractionHandler({ check: (interaction) => interaction.isButton(), async execute(interaction, args) { const moduleId = args[0]; - const coreConfig = await configService.getConfigForModuleIn( - coreModule, - interaction.guildId! - ); + const coreT = await getCoreT(interaction.guildId!); if (!moduleId) { await interaction.reply({ - content: coreConfig.t("interaction.malformed"), + content: coreT("interaction.malformed"), flags: MessageFlags.Ephemeral, }); return; @@ -29,7 +25,7 @@ export default declareInteractionHandler({ const module = modules.find((mod) => mod.id === moduleId); if (!module) { await interaction.reply({ - content: coreConfig.t("interaction.moduleNotFound"), + content: coreT("interaction.moduleNotFound"), flags: MessageFlags.Ephemeral, }); return; @@ -41,7 +37,7 @@ export default declareInteractionHandler({ await installModule(module, interaction.guild!); } catch { await interaction.followUp({ - content: coreConfig.t("interaction.failedEnable"), + content: coreT("interaction.failedEnable"), flags: MessageFlags.Ephemeral, }); return; @@ -52,7 +48,7 @@ export default declareInteractionHandler({ ); await defer.edit({ - components: [modulesMessage(modulesState, coreConfig.t)], + components: [modulesMessage(modulesState, coreT)], flags: MessageFlags.IsComponentsV2, }); }, diff --git a/src/core/interactions/toggle-option.button.ts b/src/core/interactions/toggle-option.button.ts index 7302670..05161eb 100644 --- a/src/core/interactions/toggle-option.button.ts +++ b/src/core/interactions/toggle-option.button.ts @@ -1,7 +1,7 @@ import { MessageFlags } from "discord.js"; import { resolveConfigurableModule } from "#core/config/config-edit.js"; -import coreModule from "#core/core.module.js"; import configService from "#core/services/config.service.js"; +import { replyWithCoreT } from "#core/utils/core-config.js"; import { configPageOfKey, configurationMessage, @@ -16,14 +16,7 @@ export default declareInteractionHandler({ const module = resolveConfigurableModule(moduleId); if (!module || !configService.isConfigKey(module, configKey)) { - const coreConfig = await configService.getConfigForModuleIn( - coreModule, - interaction.guildId! - ); - await interaction.reply({ - content: coreConfig.t("interaction.configOptionNotFound"), - flags: MessageFlags.Ephemeral, - }); + await replyWithCoreT(interaction, "interaction.configOptionNotFound"); return; } 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, + }); +} From 11864422737854fddc332393460588b19df66021 Mon Sep 17 00:00:00 2001 From: RedsTom Date: Wed, 17 Jun 2026 19:23:28 +0200 Subject: [PATCH 16/23] docs: fix localization doc table values to match actual i18n keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - type.text: Short text/Texte court -> Text/Texte - type.boolean: Yes/No/Oui/Non -> Boolean/Booléen - type.enum -> type.choice (actual key used by getConfigTypeName) - config.toggle.enable/disable -> modules.enable/disable (actual keys) - Update getConfigTypeName example to show correct values - Fix stale config.toggle.enable references in configuration and creating-a-module guides --- docs/site/en/guide/configuration.md | 2 +- docs/site/en/guide/creating-a-module.md | 2 +- docs/site/en/guide/localization.md | 34 ++++++++++++------------- docs/site/fr/guide/configuration.md | 2 +- docs/site/fr/guide/creating-a-module.md | 2 +- docs/site/fr/guide/localisation.md | 34 ++++++++++++------------- 6 files changed, 38 insertions(+), 38 deletions(-) diff --git a/docs/site/en/guide/configuration.md b/docs/site/en/guide/configuration.md index c732b05..4d715a6 100644 --- a/docs/site/en/guide/configuration.md +++ b/docs/site/en/guide/configuration.md @@ -223,7 +223,7 @@ 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`, `config.toggle.enable`, and type names (`type.text`, `type.number`, etc.) are provided by the core namespace — you only need to translate module-specific strings. +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 diff --git a/docs/site/en/guide/creating-a-module.md b/docs/site/en/guide/creating-a-module.md index 6939a61..5c8ed1e 100644 --- a/docs/site/en/guide/creating-a-module.md +++ b/docs/site/en/guide/creating-a-module.md @@ -194,7 +194,7 @@ async execute(interaction, config) { } ``` -Keys are looked up in the module's own namespace first, then fall back to core translations. Common UI labels (`config.previous`, `config.next`, `config.toggle.enable`, etc.) are provided by the core namespace — no need to redefine them in every module. +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 diff --git a/docs/site/en/guide/localization.md b/docs/site/en/guide/localization.md index dd14b7e..a2f09a6 100644 --- a/docs/site/en/guide/localization.md +++ b/docs/site/en/guide/localization.md @@ -70,7 +70,7 @@ The `getConfigTypeName()` helper accepts an optional `TFunction` for localized t import { getConfigTypeName } from "#lib/config.js"; const label = getConfigTypeName(ConfigType.STRING, config.t); -// → "Short text" (EN) or "Texte court" (FR) +// → "Text" (EN) or "Texte" (FR) ``` ### In commands @@ -88,22 +88,22 @@ Only descriptions are localized in this bot — command names stay in English. 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}} | -| `config.toggle.enable` | Enable | Activer | -| `config.toggle.disable` | Disable | Désactiver | -| `type.text` | Short text | Texte court | -| `type.number` | Number | Nombre | -| `type.boolean` | Yes/No | Oui/Non | -| `type.user` | User | Utilisateur | -| `type.role` | Role | Rôle | -| `type.channel` | Channel | Salon | -| `type.category` | Category | Catégorie | -| `type.enum` | Choice | Choix | -| `type.listOf` | List of {{type}} | Liste de {{type}} | +| 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 diff --git a/docs/site/fr/guide/configuration.md b/docs/site/fr/guide/configuration.md index cf69673..b968c3b 100644 --- a/docs/site/fr/guide/configuration.md +++ b/docs/site/fr/guide/configuration.md @@ -223,7 +223,7 @@ 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`, `config.toggle.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. +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 diff --git a/docs/site/fr/guide/creating-a-module.md b/docs/site/fr/guide/creating-a-module.md index 732f2b5..b2c4176 100644 --- a/docs/site/fr/guide/creating-a-module.md +++ b/docs/site/fr/guide/creating-a-module.md @@ -194,7 +194,7 @@ async execute(interaction, config) { } ``` -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`, `config.toggle.enable`, etc.) sont fournis par le namespace cœur — pas besoin de les redéfinir dans chaque module. +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 diff --git a/docs/site/fr/guide/localisation.md b/docs/site/fr/guide/localisation.md index 1634626..bcb608d 100644 --- a/docs/site/fr/guide/localisation.md +++ b/docs/site/fr/guide/localisation.md @@ -70,7 +70,7 @@ La fonction utilitaire `getConfigTypeName()` accepte une `TFunction` optionnelle import { getConfigTypeName } from "#lib/config.js"; const label = getConfigTypeName(ConfigType.STRING, config.t); -// → "Short text" (EN) ou "Texte court" (FR) +// → "Text" (EN) ou "Texte" (FR) ``` ### Dans les commandes @@ -88,22 +88,22 @@ Dans ce bot, seules les descriptions sont localisées — les noms de commandes 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}} | -| `config.toggle.enable` | Enable | Activer | -| `config.toggle.disable` | Disable | Désactiver | -| `type.text` | Short text | Texte court | -| `type.number` | Number | Nombre | -| `type.boolean` | Yes/No | Oui/Non | -| `type.user` | User | Utilisateur | -| `type.role` | Role | Rôle | -| `type.channel` | Channel | Salon | -| `type.category` | Category | Catégorie | -| `type.enum` | Choice | Choix | -| `type.listOf` | List of {{type}} | Liste de {{type}} | +| 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 From f0f4cbfd79f366bf48d9681f9ba873d995de9292 Mon Sep 17 00:00:00 2001 From: Antoine James Tournepiche Date: Wed, 17 Jun 2026 20:55:42 +0200 Subject: [PATCH 17/23] feat(config): resolve config defaults lazily with per-locale i18n Stop persisting schema defaults; ConfigProvider.get() resolves them at read time, looking up `config..default` for free-text fields so an unconfigured value follows the guild locale. Enum/number/boolean/list defaults are returned verbatim. thread-creator's welcome message uses this; existing guilds keep their stored default (no migration). Co-Authored-By: Claude Opus 4.8 --- docs/functional.md | 7 ++ docs/site/en/guide/localization.md | 16 ++++ docs/site/fr/guide/localisation.md | 17 ++++ src/core/services/config.service.ts | 44 +++++------ src/lib/config.test.ts | 77 ++++++++++++++++++- src/lib/config.ts | 35 ++++++++- src/modules/thread-creator/i18n/en.json | 4 +- src/modules/thread-creator/i18n/fr.json | 4 +- .../thread-creator/thread-creator.config.ts | 3 +- 9 files changed, 178 insertions(+), 29 deletions(-) diff --git a/docs/functional.md b/docs/functional.md index be1f087..ddd2f52 100644 --- a/docs/functional.md +++ b/docs/functional.md @@ -38,6 +38,13 @@ 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. + **Limites :** - Noms de fils tronqués à 100 caractères (limite Discord) diff --git a/docs/site/en/guide/localization.md b/docs/site/en/guide/localization.md index a2f09a6..412328b 100644 --- a/docs/site/en/guide/localization.md +++ b/docs/site/en/guide/localization.md @@ -51,6 +51,22 @@ Use {{param}} syntax for dynamic values — never concatenate | `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. + +> [!NOTE] +> Guilds whose config predates this mechanism keep whatever default was already +> stored until a `/config` reset — there is no automatic migration. + ## Using translations in code The `ConfigProvider` injected into commands, listeners, and interactions exposes a `t()` method: diff --git a/docs/site/fr/guide/localisation.md b/docs/site/fr/guide/localisation.md index bcb608d..9724a00 100644 --- a/docs/site/fr/guide/localisation.md +++ b/docs/site/fr/guide/localisation.md @@ -51,6 +51,23 @@ Utilisez la syntaxe {{param}} pour les valeurs dynamiques — | `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. + +> [!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. + ## Utiliser les traductions dans le code Le `ConfigProvider` injecté dans les commandes, écouteurs et interactions expose une méthode `t()` : diff --git a/src/core/services/config.service.ts b/src/core/services/config.service.ts index c2d343a..68ff672 100644 --- a/src/core/services/config.service.ts +++ b/src/core/services/config.service.ts @@ -99,7 +99,7 @@ class ConfigService { this.getLocaleForGuild(guildId), this.getOrCreate(guildId), ]); - const defaultConfig = this.createDefaultConfigForModule(module); + const defaultConfig = this.blankConfigForModule(module); const updatedConfig = { ...currentConfig, @@ -168,12 +168,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); } } @@ -185,9 +184,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 @@ -204,23 +204,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/lib/config.test.ts b/src/lib/config.test.ts index 084d6a7..44ed09e 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, expectTypeOf, it } from "vitest"; +import { beforeAll, describe, expect, expectTypeOf, it } from "vitest"; import { ConfigProvider, ConfigType, @@ -9,6 +9,7 @@ import { type ConfigEntry, type ConfigSchema, } from "./config.js"; +import { addTranslations, initI18n } from "./i18n.js"; import type { Module } from "./module.js"; describe("ConfigValidator", () => { @@ -187,6 +188,80 @@ 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("ConfigEntry enum typing", () => { it("requires `options` on an enum entry (regression for the missing-options hole)", () => { const valid: ConfigEntry = { diff --git a/src/lib/config.ts b/src/lib/config.ts index 452dbda..94bf1d6 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -196,6 +196,39 @@ 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); + } + + /** + * 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/modules/thread-creator/i18n/en.json b/src/modules/thread-creator/i18n/en.json index cc3edf8..cabb8d6 100644 --- a/src/modules/thread-creator/i18n/en.json +++ b/src/modules/thread-creator/i18n/en.json @@ -4,7 +4,9 @@ "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.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 index 5868211..5835a83 100644 --- a/src/modules/thread-creator/i18n/fr.json +++ b/src/modules/thread-creator/i18n/fr.json @@ -4,7 +4,9 @@ "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.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 6eb2f8a..b33b20e 100644 --- a/src/modules/thread-creator/thread-creator.config.ts +++ b/src/modules/thread-creator/thread-creator.config.ts @@ -17,7 +17,8 @@ export const threadCreatorConfigSchema = { 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: "Name template", From 2e922ea88718c4e3469cb5ef378cb7773e60117b Mon Sep 17 00:00:00 2001 From: Antoine James Tournepiche Date: Wed, 17 Jun 2026 20:59:23 +0200 Subject: [PATCH 18/23] feat(config): mark default values in the /config panel Show a localized `_(default)_` suffix next to any value still served by its schema default, so admins can tell what they have actually set apart from untouched defaults. Backed by ConfigProvider.isDefault(key), which the lazy-default resolution now makes possible (a stored value, including a `null` clear, counts as explicitly set). Co-Authored-By: Claude Opus 4.8 --- docs/functional.md | 4 +++- docs/site/en/guide/localization.md | 2 ++ docs/site/fr/guide/localisation.md | 2 ++ src/core/i18n/en.json | 1 + src/core/i18n/fr.json | 1 + src/core/utils/core-messages.ts | 7 ++++++- src/lib/config.test.ts | 30 ++++++++++++++++++++++++++++++ src/lib/config.ts | 13 +++++++++++++ 8 files changed, 58 insertions(+), 2 deletions(-) diff --git a/docs/functional.md b/docs/functional.md index ddd2f52..371918f 100644 --- a/docs/functional.md +++ b/docs/functional.md @@ -43,7 +43,9 @@ Dès qu'un message est posté dans le salon configuré : > 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. +> 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 :** diff --git a/docs/site/en/guide/localization.md b/docs/site/en/guide/localization.md index 412328b..02cbcd1 100644 --- a/docs/site/en/guide/localization.md +++ b/docs/site/en/guide/localization.md @@ -63,6 +63,8 @@ The schema's `defaultValue` stays the English fallback. Defaults are **not persi 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, a value still served by its default is flagged with a `config.defaultSuffix` marker (`_(default)_`), so an admin can tell at a glance what has actually been set. `ConfigProvider.isDefault(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. diff --git a/docs/site/fr/guide/localisation.md b/docs/site/fr/guide/localisation.md index 9724a00..2482ea2 100644 --- a/docs/site/fr/guide/localisation.md +++ b/docs/site/fr/guide/localisation.md @@ -63,6 +63,8 @@ Le `defaultValue` du schéma reste le repli anglais. Les défauts ne sont **pas 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`, une valeur encore servie par son défaut est signalée par un marqueur `config.defaultSuffix` (`_(par défaut)_`), pour voir d'un coup d'œil ce qui a réellement été configuré. `ConfigProvider.isDefault(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 diff --git a/src/core/i18n/en.json b/src/core/i18n/en.json index 4e9ee71..fc61aeb 100644 --- a/src/core/i18n/en.json +++ b/src/core/i18n/en.json @@ -9,6 +9,7 @@ "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.", diff --git a/src/core/i18n/fr.json b/src/core/i18n/fr.json index e0dccfd..9e96144 100644 --- a/src/core/i18n/fr.json +++ b/src/core/i18n/fr.json @@ -9,6 +9,7 @@ "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.", diff --git a/src/core/utils/core-messages.ts b/src/core/utils/core-messages.ts index 040e065..c0826dd 100644 --- a/src/core/utils/core-messages.ts +++ b/src/core/utils/core-messages.ts @@ -160,6 +160,11 @@ export const configurationMessage = ( const optDesc = config.t("config." + key + ".description", { defaultValue: option.description, }); + // Flag values still served by their schema default, so an admin can tell + // at a glance what they have actually set versus what is just the default. + const renderedValue = + renderCurrentValue(option.type, value) + + (config.isDefault(key) ? config.t("config.defaultSuffix") : ""); section.addTextDisplayComponents((text) => text.setContent( config.t("config.option", { @@ -167,7 +172,7 @@ export const configurationMessage = ( optionName: optName, optionDesc: optDesc, currentValue: config.t("config.currentValue", { - value: renderCurrentValue(option.type, value), + value: renderedValue, }), }) ) diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 44ed09e..27e8bc5 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -260,6 +260,36 @@ describe("ConfigProvider default resolution", () => { const provider = new ConfigProvider(module, empty, "fr"); expect(provider.get("greeting")).toBeUndefined(); }); + + describe("isDefault", () => { + it("is true for an unset field that declares a default", () => { + const provider = new ConfigProvider(module, empty, "fr"); + expect(provider.isDefault("note")).toBe(true); + }); + + it("is false once a value is stored", () => { + const provider = new ConfigProvider( + module, + { note: "stored" } as ConfigData, + "fr" + ); + expect(provider.isDefault("note")).toBe(false); + }); + + it("is false for an unset field without a default", () => { + const provider = new ConfigProvider(module, empty, "fr"); + expect(provider.isDefault("greeting")).toBe(false); + }); + + it("treats a cleared value (null) as explicitly set, not default", () => { + const provider = new ConfigProvider( + module, + { note: null } as unknown as ConfigData, + "fr" + ); + expect(provider.isDefault("note")).toBe(false); + }); + }); }); describe("ConfigEntry enum typing", () => { diff --git a/src/lib/config.ts b/src/lib/config.ts index 94bf1d6..5bf7a68 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -207,6 +207,19 @@ export class ConfigProvider { return this.resolveDefault(key); } + /** + * Whether `get(key)` is currently serving the schema default rather than a + * stored value — true only when nothing is stored and the entry declares a + * default. A cleared value (`null`) counts as explicitly set, not default. + * Lets the UI flag which values the admin has actually chosen. + */ + isDefault(key: TKey): boolean { + if (this.data[key] !== undefined) { + return false; + } + return this.module.config[key]?.defaultValue !== undefined; + } + /** * The schema default for `key`, resolved against the active locale. * From dbfc71daabe6aef430c914eacbd4620f449b126e Mon Sep 17 00:00:00 2001 From: Antoine James Tournepiche Date: Wed, 17 Jun 2026 21:12:26 +0200 Subject: [PATCH 19/23] feat(config): render enum option labels via Intl language names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional `display: "language"` hint on enum entries; formatConfigValue renders such options through Intl.DisplayNames in the viewer's locale. The core `locale` field now shows Anglais/Français (FR) or English/French (EN) — both in the /config readout and the select menu — with no translation keys to maintain. Other enums and value types are returned verbatim as before. The locale field description also drops its now-redundant "(en/fr)" suffix, since the localized option names already convey the available languages. Co-Authored-By: Claude Opus 4.8 --- docs/site/en/guide/localization.md | 14 +++++++++ docs/site/fr/guide/localisation.md | 14 +++++++++ src/core/config/enum.config-handler.ts | 6 ++-- src/core/config/select.config-handler.ts | 12 ++++++-- src/core/core.config.ts | 3 +- src/core/i18n/en.json | 2 +- src/core/i18n/fr.json | 2 +- src/core/utils/core-messages.ts | 15 ++++++---- src/lib/config.test.ts | 37 ++++++++++++++++++++++++ src/lib/config.ts | 35 ++++++++++++++++++++++ 10 files changed, 127 insertions(+), 13 deletions(-) diff --git a/docs/site/en/guide/localization.md b/docs/site/en/guide/localization.md index 02cbcd1..9927f5e 100644 --- a/docs/site/en/guide/localization.md +++ b/docs/site/en/guide/localization.md @@ -69,6 +69,20 @@ In the `/config` panel, a value still served by its default is flagged with a `c > 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: diff --git a/docs/site/fr/guide/localisation.md b/docs/site/fr/guide/localisation.md index 2482ea2..30098e3 100644 --- a/docs/site/fr/guide/localisation.md +++ b/docs/site/fr/guide/localisation.md @@ -70,6 +70,20 @@ Dans le panneau `/config`, une valeur encore servie par son défaut est signalé > 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()` : diff --git a/src/core/config/enum.config-handler.ts b/src/core/config/enum.config-handler.ts index 1152539..84c67d2 100644 --- a/src/core/config/enum.config-handler.ts +++ b/src/core/config/enum.config-handler.ts @@ -4,7 +4,7 @@ import { type AnySelectMenuInteraction, type MessageActionRowComponentBuilder, } from "discord.js"; -import { ConfigType, isEnumEntry } from "#lib/config.js"; +import { ConfigType, 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"; @@ -77,7 +77,9 @@ export default class EnumConfigHandler extends SelectConfigHandler { + const entry = getConfigEntry(_module, _key); const options = optionsFor(_module, _key).slice(0, MAX_SELECT_VALUES); const menu = new StringSelectMenuBuilder() .setCustomId(customId) @@ -90,7 +92,7 @@ export default class EnumConfigHandler extends SelectConfigHandler ({ - label: option, + label: entry ? formatConfigValue(entry, option, locale) : option, value: option, default: current.includes(option), })) diff --git a/src/core/config/select.config-handler.ts b/src/core/config/select.config-handler.ts index f97ce3d..40d3d3d 100644 --- a/src/core/config/select.config-handler.ts +++ b/src/core/config/select.config-handler.ts @@ -38,6 +38,8 @@ export interface SelectRowContext { minValues: number; maxValues: number; t: TFunction; + /** Viewer locale, for formatting option labels (e.g. language names). */ + locale: string; } /** @@ -99,7 +101,8 @@ export abstract class SelectConfigHandler< key: string, current: string[], sourceMessageId: string, - t: TFunction + t: TFunction, + locale: string ): ContainerBuilder { const customId = `${this.selectCustomId}:${module.id}:${key}:${sourceMessageId}`; const isList = isListEntry(getConfigEntry(module, key)); @@ -109,6 +112,7 @@ export abstract class SelectConfigHandler< customId, current, t, + locale, minValues: isList ? 0 : 1, maxValues: isList ? this.maxSelectableValues(module, key) : 1, }); @@ -137,7 +141,8 @@ export abstract class SelectConfigHandler< String(key), this.currentValues(config.get(key)), sourceMessageId, - config.t + config.t, + config.locale ); await interaction.reply({ @@ -211,7 +216,8 @@ export abstract class SelectConfigHandler< configKey, values, sourceMessageId!, - config.t + config.t, + config.locale ), ], flags: MessageFlags.IsComponentsV2, diff --git a/src/core/core.config.ts b/src/core/core.config.ts index 304456c..490200c 100644 --- a/src/core/core.config.ts +++ b/src/core/core.config.ts @@ -3,10 +3,11 @@ import { ConfigType, type ConfigSchema } from "#lib/config.js"; export const coreConfigSchema = { locale: { name: "Language", - description: "Bot language (en/fr)", + description: "Bot language", type: ConfigType.ENUM, options: ["en", "fr"] as const, defaultValue: "en", + display: "language", }, } satisfies ConfigSchema; diff --git a/src/core/i18n/en.json b/src/core/i18n/en.json index fc61aeb..1342708 100644 --- a/src/core/i18n/en.json +++ b/src/core/i18n/en.json @@ -76,5 +76,5 @@ "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 (en/fr)" + "config.locale.description": "Bot language" } diff --git a/src/core/i18n/fr.json b/src/core/i18n/fr.json index 9e96144..696aac5 100644 --- a/src/core/i18n/fr.json +++ b/src/core/i18n/fr.json @@ -76,5 +76,5 @@ "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 (en/fr)" + "config.locale.description": "Langue du bot" } diff --git a/src/core/utils/core-messages.ts b/src/core/utils/core-messages.ts index c0826dd..fa654ea 100644 --- a/src/core/utils/core-messages.ts +++ b/src/core/utils/core-messages.ts @@ -8,10 +8,11 @@ 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"; @@ -77,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 @@ -87,6 +89,7 @@ function renderCurrentValue( return "—"; } + const type = option.type; const baseType = Array.isArray(type) ? type[0] : type; const isEntity = baseType === ConfigType.USER || @@ -95,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(", "); } @@ -163,7 +168,7 @@ export const configurationMessage = ( // Flag values still served by their schema default, so an admin can tell // at a glance what they have actually set versus what is just the default. const renderedValue = - renderCurrentValue(option.type, value) + + renderCurrentValue(option, value, config.locale) + (config.isDefault(key) ? config.t("config.defaultSuffix") : ""); section.addTextDisplayComponents((text) => text.setContent( diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 27e8bc5..c609333 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -3,6 +3,7 @@ import { ConfigProvider, ConfigType, ConfigValidator, + formatConfigValue, getConfigTypeName, isEnumEntry, type ConfigData, @@ -313,6 +314,42 @@ 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"); + }); +}); + describe("isEnumEntry", () => { it("recognizes single and list enum entries", () => { expect( diff --git a/src/lib/config.ts b/src/lib/config.ts index 5bf7a68..475abe3 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -98,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 @@ -109,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). */ @@ -118,6 +127,7 @@ export interface EnumListConfigEntry { type: ListOf; options: readonly Values[]; defaultValue?: Values[]; + display?: EnumDisplay; } export type ConfigEntry = @@ -145,6 +155,29 @@ 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); +} + /** * Resolved value type of an entry, before factoring in default presence. Enum * entries narrow to the literal union of their `options`; everything else maps @@ -180,6 +213,7 @@ export class ConfigProvider { private module: Module; private readonly data: ConfigData; readonly t: TFunction; + readonly locale: string; constructor( module: Module, @@ -188,6 +222,7 @@ export class ConfigProvider { ) { this.module = module; this.data = data; + this.locale = locale; this.t = createT(locale, module.id); } From 38505fe72a52766c07a90e40d5be0f26af89dc55 Mon Sep 17 00:00:00 2001 From: Antoine James Tournepiche Date: Wed, 17 Jun 2026 21:20:07 +0200 Subject: [PATCH 20/23] feat(config): prefix the language select with flag emojis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each `display: "language"` option now shows its flag in the select menu via configValueEmoji, backed by a small curated code→flag table (en → 🇬🇧, since English has no canonical flag). Codes absent from the table get no emoji. Co-Authored-By: Claude Opus 4.8 --- src/core/config/enum.config-handler.ts | 21 +++++++++++++++------ src/lib/config.test.ts | 17 +++++++++++++++++ src/lib/config.ts | 25 +++++++++++++++++++++++++ 3 files changed, 57 insertions(+), 6 deletions(-) diff --git a/src/core/config/enum.config-handler.ts b/src/core/config/enum.config-handler.ts index 84c67d2..1da3877 100644 --- a/src/core/config/enum.config-handler.ts +++ b/src/core/config/enum.config-handler.ts @@ -4,7 +4,12 @@ import { type AnySelectMenuInteraction, type MessageActionRowComponentBuilder, } from "discord.js"; -import { ConfigType, formatConfigValue, 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"; @@ -91,11 +96,15 @@ export default class EnumConfigHandler extends SelectConfigHandler ({ - label: entry ? formatConfigValue(entry, option, locale) : 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/lib/config.test.ts b/src/lib/config.test.ts index c609333..ac35764 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -3,6 +3,7 @@ import { ConfigProvider, ConfigType, ConfigValidator, + configValueEmoji, formatConfigValue, getConfigTypeName, isEnumEntry, @@ -348,6 +349,22 @@ describe("formatConfigValue", () => { }; 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", () => { diff --git a/src/lib/config.ts b/src/lib/config.ts index 475abe3..af8191b 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -178,6 +178,31 @@ export function formatConfigValue( 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 From 57c8561c293d36e99862bf06385741838fa20990 Mon Sep 17 00:00:00 2001 From: Antoine James Tournepiche Date: Wed, 17 Jun 2026 21:29:36 +0200 Subject: [PATCH 21/23] feat(config): add a field-level reset control to /config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A "Reset…" button at the bottom of the /config panel opens an ephemeral multi-select listing the currently-overridden fields, plus an "All fields" entry; the chosen fields are cleared and fall back to their defaults. The button is disabled when nothing is overridden, so it is never a no-op. Resetting removes the stored value (rather than writing null), letting the lazy per-locale default take over again. Adds ConfigProvider.isSet, ConfigService.resetFieldsForModuleIn, and a page-aware source-panel refresh. Co-Authored-By: Claude Opus 4.8 --- docs/functional.md | 6 ++ src/core/config/config-edit.ts | 24 ++++- src/core/core.module.ts | 7 ++ src/core/i18n/en.json | 5 + src/core/i18n/fr.json | 5 + src/core/interactions/reset-config.ts | 135 ++++++++++++++++++++++++++ src/core/services/config.service.ts | 44 +++++++++ src/core/utils/core-messages.test.ts | 12 ++- src/core/utils/core-messages.ts | 30 ++++-- src/lib/config.test.ts | 26 +++++ src/lib/config.ts | 10 ++ 11 files changed, 289 insertions(+), 15 deletions(-) create mode 100644 src/core/interactions/reset-config.ts diff --git a/docs/functional.md b/docs/functional.md index 371918f..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 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/core.module.ts b/src/core/core.module.ts index 621f22c..d149acd 100644 --- a/src/core/core.module.ts +++ b/src/core/core.module.ts @@ -9,6 +9,10 @@ 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"; @@ -34,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 index 1342708..b0da7b8 100644 --- a/src/core/i18n/en.json +++ b/src/core/i18n/en.json @@ -5,6 +5,11 @@ "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}}`", diff --git a/src/core/i18n/fr.json b/src/core/i18n/fr.json index 696aac5..8dd8bac 100644 --- a/src/core/i18n/fr.json +++ b/src/core/i18n/fr.json @@ -5,6 +5,11 @@ "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}}`", 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/services/config.service.ts b/src/core/services/config.service.ts index 68ff672..0b26802 100644 --- a/src/core/services/config.service.ts +++ b/src/core/services/config.service.ts @@ -126,6 +126,50 @@ class ConfigService { 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( guildId: string ): Promise>> { diff --git a/src/core/utils/core-messages.test.ts b/src/core/utils/core-messages.test.ts index 00c6a7c..8c2a0dd 100644 --- a/src/core/utils/core-messages.test.ts +++ b/src/core/utils/core-messages.test.ts @@ -57,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( @@ -98,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 fa654ea..f5587bf 100644 --- a/src/core/utils/core-messages.ts +++ b/src/core/utils/core-messages.ts @@ -212,13 +212,14 @@ export const configurationMessage = ( container.addTextDisplayComponents((text) => 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(config.t("config.previous")) @@ -229,8 +230,21 @@ export const configurationMessage = ( .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/lib/config.test.ts b/src/lib/config.test.ts index ac35764..654a720 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -292,6 +292,32 @@ describe("ConfigProvider default resolution", () => { expect(provider.isDefault("note")).toBe(false); }); }); + + 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", () => { diff --git a/src/lib/config.ts b/src/lib/config.ts index af8191b..5fcd2bf 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -280,6 +280,16 @@ export class ConfigProvider { return this.module.config[key]?.defaultValue !== undefined; } + /** + * Whether `key` holds an explicitly stored value (including a `null` clear) — + * i.e. something a reset could remove to fall back to the default. Distinct + * from `!isDefault`: a field with neither a stored value nor a default is not + * "set" yet not "default" either. + */ + isSet(key: TKey): boolean { + return this.data[key] !== undefined; + } + /** * The schema default for `key`, resolved against the active locale. * From aba0a8258e75b23711f9be15951eb25f94baa95a Mon Sep 17 00:00:00 2001 From: Antoine James Tournepiche Date: Wed, 17 Jun 2026 21:37:15 +0200 Subject: [PATCH 22/23] refactor(core): rename the core module to "Global Configuration" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Core Module" / "Module Principal" was developer-facing jargon; the module a guild admin actually opens via /config is the bot's global settings. Rename it to "Global Configuration" (EN) / "Configuration Globale" (FR), in the schema fallback and both locale bundles. Also reword the config panel header to "Settings · {{moduleName}}" / "Paramètres · {{moduleName}}", avoiding the redundant "Settings of Global Configuration" the previous "of"-style template produced. Co-Authored-By: Claude Opus 4.8 --- src/core/core.module.ts | 2 +- src/core/i18n/en.json | 4 ++-- src/core/i18n/fr.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/core/core.module.ts b/src/core/core.module.ts index d149acd..efd96d5 100644 --- a/src/core/core.module.ts +++ b/src/core/core.module.ts @@ -18,7 +18,7 @@ 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", diff --git a/src/core/i18n/en.json b/src/core/i18n/en.json index b0da7b8..f413077 100644 --- a/src/core/i18n/en.json +++ b/src/core/i18n/en.json @@ -57,7 +57,7 @@ "select.category.selectMultiple": "Select categories", "select.category.selectSingle": "Select a category", - "config.header": "# `{{moduleName}}` settings", + "config.header": "# Settings · `{{moduleName}}`", "config.page": "-# Page {{current}}/{{total}}", "config.option": "-# {{typeName}}\n**⚙️ {{optionName}}**\n> {{optionDesc}}\n{{currentValue}}\n\n", @@ -71,7 +71,7 @@ "type.choice": "Choice", "type.listOf": "List of {{type}}", - "modules.core.name": "Core Module", + "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", diff --git a/src/core/i18n/fr.json b/src/core/i18n/fr.json index 8dd8bac..3750f18 100644 --- a/src/core/i18n/fr.json +++ b/src/core/i18n/fr.json @@ -57,7 +57,7 @@ "select.category.selectMultiple": "Sélectionnez des catégories", "select.category.selectSingle": "Sélectionnez une catégorie", - "config.header": "# Paramètres de `{{moduleName}}`", + "config.header": "# Paramètres · `{{moduleName}}`", "config.page": "-# Page {{current}}/{{total}}", "config.option": "-# {{typeName}}\n**⚙️ {{optionName}}**\n> {{optionDesc}}\n{{currentValue}}\n\n", @@ -71,7 +71,7 @@ "type.choice": "Choix", "type.listOf": "Liste de {{type}}", - "modules.core.name": "Module Principal", + "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", From 14836011d8fdb78265ed5ab88ac9381e31e77440 Mon Sep 17 00:00:00 2001 From: Antoine James Tournepiche Date: Wed, 17 Jun 2026 22:19:02 +0200 Subject: [PATCH 23/23] fix(config): mark every unset field as default, including empty lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /config "default" marker keyed on isDefault, which was true only for an unset field that *declares* a defaultValue — so an unset list with no default (e.g. thread-creator's channels) showed "—" with no marker. Key it on !isSet instead: any field the admin hasn't set is flagged. This also makes the marker the exact complement of the reset picker (set ⟺ resettable ⟺ no marker). The now-unused isDefault is removed. Co-Authored-By: Claude Opus 4.8 --- docs/site/en/guide/localization.md | 2 +- docs/site/fr/guide/localisation.md | 2 +- src/core/utils/core-messages.ts | 6 +++--- src/lib/config.test.ts | 30 ------------------------------ src/lib/config.ts | 21 ++++----------------- 5 files changed, 9 insertions(+), 52 deletions(-) diff --git a/docs/site/en/guide/localization.md b/docs/site/en/guide/localization.md index 9927f5e..5ecb5d1 100644 --- a/docs/site/en/guide/localization.md +++ b/docs/site/en/guide/localization.md @@ -63,7 +63,7 @@ The schema's `defaultValue` stays the English fallback. Defaults are **not persi 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, a value still served by its default is flagged with a `config.defaultSuffix` marker (`_(default)_`), so an admin can tell at a glance what has actually been set. `ConfigProvider.isDefault(key)` exposes the same distinction in code. +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 diff --git a/docs/site/fr/guide/localisation.md b/docs/site/fr/guide/localisation.md index 30098e3..22646ed 100644 --- a/docs/site/fr/guide/localisation.md +++ b/docs/site/fr/guide/localisation.md @@ -63,7 +63,7 @@ Le `defaultValue` du schéma reste le repli anglais. Les défauts ne sont **pas 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`, une valeur encore servie par son défaut est signalée par un marqueur `config.defaultSuffix` (`_(par défaut)_`), pour voir d'un coup d'œil ce qui a réellement été configuré. `ConfigProvider.isDefault(key)` expose la même distinction côté code. +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 diff --git a/src/core/utils/core-messages.ts b/src/core/utils/core-messages.ts index f5587bf..6258fd0 100644 --- a/src/core/utils/core-messages.ts +++ b/src/core/utils/core-messages.ts @@ -165,11 +165,11 @@ export const configurationMessage = ( const optDesc = config.t("config." + key + ".description", { defaultValue: option.description, }); - // Flag values still served by their schema default, so an admin can tell - // at a glance what they have actually set versus what is just the default. + // 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.isDefault(key) ? config.t("config.defaultSuffix") : ""); + (config.isSet(key) ? "" : config.t("config.defaultSuffix")); section.addTextDisplayComponents((text) => text.setContent( config.t("config.option", { diff --git a/src/lib/config.test.ts b/src/lib/config.test.ts index 654a720..0ff1f5c 100644 --- a/src/lib/config.test.ts +++ b/src/lib/config.test.ts @@ -263,36 +263,6 @@ describe("ConfigProvider default resolution", () => { expect(provider.get("greeting")).toBeUndefined(); }); - describe("isDefault", () => { - it("is true for an unset field that declares a default", () => { - const provider = new ConfigProvider(module, empty, "fr"); - expect(provider.isDefault("note")).toBe(true); - }); - - it("is false once a value is stored", () => { - const provider = new ConfigProvider( - module, - { note: "stored" } as ConfigData, - "fr" - ); - expect(provider.isDefault("note")).toBe(false); - }); - - it("is false for an unset field without a default", () => { - const provider = new ConfigProvider(module, empty, "fr"); - expect(provider.isDefault("greeting")).toBe(false); - }); - - it("treats a cleared value (null) as explicitly set, not default", () => { - const provider = new ConfigProvider( - module, - { note: null } as unknown as ConfigData, - "fr" - ); - expect(provider.isDefault("note")).toBe(false); - }); - }); - describe("isSet", () => { it("is false when nothing is stored (default in use)", () => { const provider = new ConfigProvider(module, empty, "fr"); diff --git a/src/lib/config.ts b/src/lib/config.ts index 5fcd2bf..58862bf 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -268,23 +268,10 @@ export class ConfigProvider { } /** - * Whether `get(key)` is currently serving the schema default rather than a - * stored value — true only when nothing is stored and the entry declares a - * default. A cleared value (`null`) counts as explicitly set, not default. - * Lets the UI flag which values the admin has actually chosen. - */ - isDefault(key: TKey): boolean { - if (this.data[key] !== undefined) { - return false; - } - return this.module.config[key]?.defaultValue !== undefined; - } - - /** - * Whether `key` holds an explicitly stored value (including a `null` clear) — - * i.e. something a reset could remove to fall back to the default. Distinct - * from `!isDefault`: a field with neither a stored value nor a default is not - * "set" yet not "default" either. + * 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;