From 72831abfe724eefd3412ac2516b2fab4a361564e Mon Sep 17 00:00:00 2001 From: Just1diaxx <157634021+Just1diaxx@users.noreply.github.com> Date: Mon, 2 Feb 2026 19:24:56 +0100 Subject: [PATCH 1/8] Rewrite of the module system and rename /strucures in /lib --- README.md | 2 +- index.js | 10 +- {structures => lib}/Command.js | 2 +- {structures => lib}/ConfigurationManager.js | 0 {structures => lib}/Database.js | 2 +- {structures => lib}/LocalizationManager.js | 0 {structures => lib}/Logger.js | 0 {structures => lib}/Module.js | 6 +- {structures => lib}/ModuleManager.js | 18 +-- {structures => lib}/ModulePriorities.js | 0 {structures => lib}/PowerLevels.js | 0 {structures => lib}/SettingsManager.js | 2 +- {structures => lib}/Utils.js | 0 modules/Example/commands/example.js | 21 +++ modules/Example/index.js | 32 +++++ modules/Example/lib/ImportantFile.js | 7 + .../index.js} | 10 +- modules/{ReadyLog.js => ReadyLog/index.js} | 4 +- modules/System/{ => commands}/eval.js | 6 +- modules/System/{ => commands}/exec.js | 6 +- modules/System/{ => commands}/modman.js | 8 +- modules/System/{ => commands}/reboot.js | 6 +- modules/System/{ => commands}/setlevel.js | 6 +- modules/System/{ => commands}/update.js | 6 +- modules/{System.js => System/index.js} | 14 +- modules/Utility/{ => commands}/perms.js | 6 +- modules/Utility/{ => commands}/ping.js | 4 +- modules/Utility/{ => commands}/setlang.js | 4 +- modules/Utility/{ => commands}/settings.js | 6 +- modules/Utility/{ => commands}/stats.js | 4 +- modules/{Utility.js => Utility/index.js} | 6 +- package-lock.json | 133 +++++++++--------- package.json | 2 +- 33 files changed, 201 insertions(+), 132 deletions(-) rename {structures => lib}/Command.js (98%) rename {structures => lib}/ConfigurationManager.js (100%) rename {structures => lib}/Database.js (98%) rename {structures => lib}/LocalizationManager.js (100%) rename {structures => lib}/Logger.js (100%) rename {structures => lib}/Module.js (95%) rename {structures => lib}/ModuleManager.js (91%) rename {structures => lib}/ModulePriorities.js (100%) rename {structures => lib}/PowerLevels.js (100%) rename {structures => lib}/SettingsManager.js (97%) rename {structures => lib}/Utils.js (100%) create mode 100644 modules/Example/commands/example.js create mode 100644 modules/Example/index.js create mode 100644 modules/Example/lib/ImportantFile.js rename modules/{InteractionCommandHandler.js => InteractionCommandHandler/index.js} (87%) rename modules/{ReadyLog.js => ReadyLog/index.js} (94%) rename modules/System/{ => commands}/eval.js (93%) rename modules/System/{ => commands}/exec.js (93%) rename modules/System/{ => commands}/modman.js (97%) rename modules/System/{ => commands}/reboot.js (84%) rename modules/System/{ => commands}/setlevel.js (92%) rename modules/System/{ => commands}/update.js (87%) rename modules/{System.js => System/index.js} (82%) rename modules/Utility/{ => commands}/perms.js (90%) rename modules/Utility/{ => commands}/ping.js (86%) rename modules/Utility/{ => commands}/setlang.js (93%) rename modules/Utility/{ => commands}/settings.js (98%) rename modules/Utility/{ => commands}/stats.js (95%) rename modules/{Utility.js => Utility/index.js} (93%) diff --git a/README.md b/README.md index 70115a3..d5e07ae 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ If you have troubles just open an issue or join my Discord server https://discor ## Making a Module Modules are stored in modules/ directory and are loaded into the bot on startup. Enabled modules are executed when they get triggered by respective events. ```js -const Module = require("../structures/Module.js"); // Import the base module +const Module = require("../lib/Module.js"); // Import the base module class Example extends Module { constructor(client) { diff --git a/index.js b/index.js index c72a54d..1d49d01 100644 --- a/index.js +++ b/index.js @@ -2,11 +2,11 @@ require('dotenv').config(); const { Client, Collection, GatewayIntentBits, Partials } = require('discord.js'); -const ModuleManager = require('./structures/ModuleManager.js'); -const Database = require('./structures/Database.js'); -const ConfigurationManager = require('./structures/ConfigurationManager.js'); -const Utils = require('./structures/Utils.js'); -const LocalizationManager = require('./structures/LocalizationManager.js'); +const ModuleManager = require('./lib/ModuleManager.js'); +const Database = require('./lib/Database.js'); +const ConfigurationManager = require('./lib/ConfigurationManager.js'); +const Utils = require('./lib/Utils.js'); +const LocalizationManager = require('./lib/LocalizationManager.js'); BigInt.prototype.toJSON = function () { return this.toString() } // MDN https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt#use_within_json // Discord diff --git a/structures/Command.js b/lib/Command.js similarity index 98% rename from structures/Command.js rename to lib/Command.js index c41ba5f..ecfcc10 100644 --- a/structures/Command.js +++ b/lib/Command.js @@ -14,7 +14,7 @@ module.exports = class Command { guildOnly = false, moduleName = 'Unspecified' }) { - /** @type {import('..')} */ + /** @type {import('../index.js')} */ this.client = client this.module = module this.config = { name, description, cooldown, minLevel, defaultMemberPermissions, guildOnly, moduleName }; diff --git a/structures/ConfigurationManager.js b/lib/ConfigurationManager.js similarity index 100% rename from structures/ConfigurationManager.js rename to lib/ConfigurationManager.js diff --git a/structures/Database.js b/lib/Database.js similarity index 98% rename from structures/Database.js rename to lib/Database.js index 241575f..4af6811 100644 --- a/structures/Database.js +++ b/lib/Database.js @@ -1,5 +1,5 @@ const Loki = require('lokijs'); -const BotClient = require('..'); +const BotClient = require('../index.js'); const Logger = require('./Logger.js'); const PowerLevels = require('./PowerLevels.js'); const cache = new Map(); diff --git a/structures/LocalizationManager.js b/lib/LocalizationManager.js similarity index 100% rename from structures/LocalizationManager.js rename to lib/LocalizationManager.js diff --git a/structures/Logger.js b/lib/Logger.js similarity index 100% rename from structures/Logger.js rename to lib/Logger.js diff --git a/structures/Module.js b/lib/Module.js similarity index 95% rename from structures/Module.js rename to lib/Module.js index 4eb02b5..0bfd2f0 100644 --- a/structures/Module.js +++ b/lib/Module.js @@ -75,15 +75,15 @@ module.exports = class Module { } async loadCommands() { - const commands = fs.existsSync(`./modules/${this.options.name}`) ? fs.readdirSync(`./modules/${this.options.name}`).filter(file => file.endsWith(".js")) : []; + const commands = fs.existsSync(`./modules/${this.options.name}/commands`) ? fs.readdirSync(`./modules/${this.options.name}/commands`).filter(file => file.endsWith(".js")) : []; commands.forEach(file => { try { /** * @type {import('./Command')} */ - const command = require(`../modules/${this.options.name}/${file}`); - delete require.cache[require.resolve(`../modules/${this.options.name}/${file}`)]; + const command = require(`../modules/${this.options.name}/commands/${file}`); + delete require.cache[require.resolve(`../modules/${this.options.name}/commands/${file}`)]; const _command = new command(this.client, this); this.commands.set(file.split(".")[0], _command); diff --git a/structures/ModuleManager.js b/lib/ModuleManager.js similarity index 91% rename from structures/ModuleManager.js rename to lib/ModuleManager.js index cbb1188..86c6f56 100644 --- a/structures/ModuleManager.js +++ b/lib/ModuleManager.js @@ -6,7 +6,7 @@ const SettingsManager = require('./SettingsManager.js'); module.exports = class ModuleManager { /** - * @param {import('..')} client + * @param {import('../index.js')} client */ constructor(client) { this.client = client; @@ -19,13 +19,15 @@ module.exports = class ModuleManager { init() { this.logger.info(`Loading modules...`) - const modules = fs.readdirSync("./modules").filter(file => file.endsWith(".js")); - modules.forEach(file => { - if (!this.isLoaded(file.split(".")[0])) { - this.load(file) + const modules = fs.readdirSync("./modules", { withFileTypes: true }) + .filter(dirent => dirent.isDirectory()) + .map(dirent => dirent.name); + modules.forEach(moduleName => { + if (!this.isLoaded(moduleName)) { + this.load(moduleName); } }); - this.client.database.reconfigure(); + this.client.database.reconfigure(); this.logger.success(`Successfully Loaded ${this.modules.size} modules`) } @@ -34,8 +36,8 @@ module.exports = class ModuleManager { */ load(moduleName) { try { - const module = require(`../modules/${moduleName}`); - delete require.cache[require.resolve(`../modules/${moduleName}`)]; + const module = require(`../modules/${moduleName}/index.js`); + delete require.cache[require.resolve(`../modules/${moduleName}/index.js`)]; const _module = new module(this.client); if (_module.options.dependencies.length > 0) { let dependencies = _module.options.dependencies; diff --git a/structures/ModulePriorities.js b/lib/ModulePriorities.js similarity index 100% rename from structures/ModulePriorities.js rename to lib/ModulePriorities.js diff --git a/structures/PowerLevels.js b/lib/PowerLevels.js similarity index 100% rename from structures/PowerLevels.js rename to lib/PowerLevels.js diff --git a/structures/SettingsManager.js b/lib/SettingsManager.js similarity index 97% rename from structures/SettingsManager.js rename to lib/SettingsManager.js index bfb53aa..4587c30 100644 --- a/structures/SettingsManager.js +++ b/lib/SettingsManager.js @@ -1,7 +1,7 @@ module.exports = class SettingsManager { /** * Instantiates a settings manager for a specific module - * @param {import('../')} client The client to use for the settings manager + * @param {import('..')} client The client to use for the settings manager * @param {import('./Module')} module The module to instantiate the settings manager for * @param {object} defaultSettings The default settings for the module */ diff --git a/structures/Utils.js b/lib/Utils.js similarity index 100% rename from structures/Utils.js rename to lib/Utils.js diff --git a/modules/Example/commands/example.js b/modules/Example/commands/example.js new file mode 100644 index 0000000..0d5b692 --- /dev/null +++ b/modules/Example/commands/example.js @@ -0,0 +1,21 @@ +const Command = require('../../../lib/Command.js'); + +module.exports = class ExampleCommand extends Command { + constructor(client, module) { + super(client, module, { + name: 'example', + description: 'See the example config.', + cooldown: 3 + }); + this.module = module; + } + + /** + * + * @param {import('../../../index.js')} client + * @param {import('discord.js').CommandInteraction} interaction + */ + async run(client, interaction) { + await interaction.reply(`Example String: ${this.module.config.get('exampleString')}\nExample Number: ${this.module.config.get('exampleNumber')}\nExample Array: ${this.module.config.get('exampleArray').join(', ')}\nExample Boolean: ${this.module.config.get('exampleBoolean')}`); + } +} diff --git a/modules/Example/index.js b/modules/Example/index.js new file mode 100644 index 0000000..7af08fe --- /dev/null +++ b/modules/Example/index.js @@ -0,0 +1,32 @@ +const Module = require("../../lib/Module.js"); +const ImportantFile = require("./lib/importantFile.js"); + +module.exports = class Example extends Module { + constructor(client) { + super(client, { + name: "Example", + info: "Very important module", + enabled: true, + events: ["ready"], + config: { + exampleString: 'Hello, world!', + exampleNumber: 67, + exampleArray: [1, 2, 3], + exampleBoolean: true + } + }) + } + + /** + * @param {import('../../index.js')} client + */ + async ready(client) { + this.logger.info('This module is doing very important things.'); + this.logger.info(`Example string: ${this.config.get('exampleString')}`); + this.logger.info(`Example number: ${this.config.get('exampleNumber')}`); + this.logger.info(`Example array: ${this.config.get('exampleArray').join(', ')}`); + this.logger.info(`Example boolean: ${this.config.get('exampleBoolean')}`); + const importantFile = new ImportantFile(); + this.logger.info(`The important function returned: ${importantFile.importantFunction()}`); + } +} diff --git a/modules/Example/lib/ImportantFile.js b/modules/Example/lib/ImportantFile.js new file mode 100644 index 0000000..2dac635 --- /dev/null +++ b/modules/Example/lib/ImportantFile.js @@ -0,0 +1,7 @@ +module.exports = class ImportantFile { + + importantFunction() { + return 'hello'; + } + +} diff --git a/modules/InteractionCommandHandler.js b/modules/InteractionCommandHandler/index.js similarity index 87% rename from modules/InteractionCommandHandler.js rename to modules/InteractionCommandHandler/index.js index 844f47a..655911e 100644 --- a/modules/InteractionCommandHandler.js +++ b/modules/InteractionCommandHandler/index.js @@ -1,9 +1,9 @@ const Discord = require('discord.js'); -const Module = require("../structures/Module.js"); -const Command = require("../structures/Command.js"); -const BotClient = require('../index.js'); -const PowerLevels = require('../structures/PowerLevels.js'); -const ModulePriorities = require('../structures/ModulePriorities.js'); +const Module = require("../../lib/Module.js"); +const Command = require("../../lib/Command.js"); +const BotClient = require('../../index.js'); +const PowerLevels = require('../../lib/PowerLevels.js'); +const ModulePriorities = require('../../lib/ModulePriorities.js'); module.exports = class InteractionCommandHandler extends Module { constructor(client) { diff --git a/modules/ReadyLog.js b/modules/ReadyLog/index.js similarity index 94% rename from modules/ReadyLog.js rename to modules/ReadyLog/index.js index ed43bc4..883beb7 100644 --- a/modules/ReadyLog.js +++ b/modules/ReadyLog/index.js @@ -1,4 +1,4 @@ -const Module = require("../structures/Module.js"); +const Module = require("../../lib/Module.js"); module.exports = class ReadyLog extends Module { constructor(client) { @@ -11,7 +11,7 @@ module.exports = class ReadyLog extends Module { } /** - * @param {import('../index.js')} client + * @param {import('../../index.js')} client * @param {...any} _args */ async run(client, ..._args) { diff --git a/modules/System/eval.js b/modules/System/commands/eval.js similarity index 93% rename from modules/System/eval.js rename to modules/System/commands/eval.js index 9677a55..218e63d 100644 --- a/modules/System/eval.js +++ b/modules/System/commands/eval.js @@ -1,7 +1,7 @@ -const Command = require("../../structures/Command.js"); +const Command = require("../../../lib/Command.js"); const { inspect } = require("util"); const { EmbedBuilder, ApplicationCommandOptionType } = require("discord.js"); -const PowerLevels = require("../../structures/PowerLevels.js"); +const PowerLevels = require("../../../lib/PowerLevels.js"); module.exports = class EvalCommand extends Command { constructor(client, module) { @@ -23,7 +23,7 @@ module.exports = class EvalCommand extends Command { /** * - * @param {import('../../index.js')} client + * @param {import('../../../index.js')} client * @param {import('discord.js').ChatInputCommandInteraction} interaction */ async run(client, interaction) { diff --git a/modules/System/exec.js b/modules/System/commands/exec.js similarity index 93% rename from modules/System/exec.js rename to modules/System/commands/exec.js index b270f0e..2283cf1 100644 --- a/modules/System/exec.js +++ b/modules/System/commands/exec.js @@ -1,7 +1,7 @@ -const Command = require("../../structures/Command.js"); +const Command = require("../../../lib/Command.js"); const { AttachmentBuilder, ApplicationCommandOptionType } = require("discord.js"); const exec = require("util").promisify(require("child_process").exec); -const PowerLevels = require("../../structures/PowerLevels.js"); +const PowerLevels = require("../../../lib/PowerLevels.js"); module.exports = class ExecCommand extends Command { constructor(client, module) { @@ -23,7 +23,7 @@ module.exports = class ExecCommand extends Command { /** * - * @param {import('../../index.js')} client + * @param {import('../../../index.js')} client * @param {import('discord.js').ChatInputCommandInteraction} interaction */ async run(client, interaction) { diff --git a/modules/System/modman.js b/modules/System/commands/modman.js similarity index 97% rename from modules/System/modman.js rename to modules/System/commands/modman.js index f6fd259..dbef1a4 100644 --- a/modules/System/modman.js +++ b/modules/System/commands/modman.js @@ -1,6 +1,6 @@ -const Command = require('../../structures/Command.js'); +const Command = require('../../../lib/Command.js'); let { EmbedBuilder, ApplicationCommandOptionType} = require('discord.js'); -const PowerLevels = require("../../structures/PowerLevels.js"); +const PowerLevels = require("../../../lib/PowerLevels.js"); module.exports = class ModManCommand extends Command { constructor(client, module) { @@ -105,9 +105,9 @@ module.exports = class ModManCommand extends Command { /** * - * @param {import('../../index.js')} client + * @param {import('../../../index.js')} client * @param {import('discord.js').ChatInputCommandInteraction} interaction - * @param {import('../../structures/Module.js')} module + * @param {import('../../../lib/Module.js')} module */ async run(client, interaction, module) { let embed = new EmbedBuilder() diff --git a/modules/System/reboot.js b/modules/System/commands/reboot.js similarity index 84% rename from modules/System/reboot.js rename to modules/System/commands/reboot.js index de1d981..192109d 100644 --- a/modules/System/reboot.js +++ b/modules/System/commands/reboot.js @@ -1,6 +1,6 @@ const { MessageFlags } = require('discord.js'); -const Command = require('../../structures/Command.js'); -const PowerLevels = require("../../structures/PowerLevels.js"); +const Command = require('../../../lib/Command.js'); +const PowerLevels = require("../../../lib/PowerLevels.js"); module.exports = class RebootCommand extends Command { constructor(client, module) { @@ -13,7 +13,7 @@ module.exports = class RebootCommand extends Command { /** * - * @param {import('../../index.js')} client + * @param {import('../../../index.js')} client * @param {import('discord.js').ChatInputCommandInteraction} interaction */ async run(client, interaction) { diff --git a/modules/System/setlevel.js b/modules/System/commands/setlevel.js similarity index 92% rename from modules/System/setlevel.js rename to modules/System/commands/setlevel.js index 51194fa..1406a0c 100644 --- a/modules/System/setlevel.js +++ b/modules/System/commands/setlevel.js @@ -1,6 +1,6 @@ -const Command = require('../../structures/Command.js'); +const Command = require('../../../lib/Command.js'); const { ApplicationCommandOptionType, EmbedBuilder, userMention, User, UserContextMenuCommandInteraction } = require('discord.js'); -const PowerLevels = require('../../structures/PowerLevels.js'); +const PowerLevels = require('../../../lib/PowerLevels.js'); module.exports = class SetLevelCommand extends Command { constructor(client, module) { @@ -28,7 +28,7 @@ module.exports = class SetLevelCommand extends Command { /** * - * @param {import('../../index.js')} client + * @param {import('../../../index.js')} client * @param {import('discord.js').ChatInputCommandInteraction} interaction */ async run(client, interaction) { diff --git a/modules/System/update.js b/modules/System/commands/update.js similarity index 87% rename from modules/System/update.js rename to modules/System/commands/update.js index 6d6fd1c..ce7dd3e 100644 --- a/modules/System/update.js +++ b/modules/System/commands/update.js @@ -1,5 +1,5 @@ -const Command = require("../../structures/Command.js"); -const PowerLevels = require("../../structures/PowerLevels.js"); +const Command = require("../../../lib/Command.js"); +const PowerLevels = require("../../../lib/PowerLevels.js"); const exec = require("util").promisify(require("child_process").exec); module.exports = class UpdateCommand extends Command { @@ -14,7 +14,7 @@ module.exports = class UpdateCommand extends Command { /** * - * @param {import('../../index.js')} client + * @param {import('../../../index.js')} client * @param {import('discord.js').ChatInputCommandInteraction} interaction */ async run(client, interaction) { diff --git a/modules/System.js b/modules/System/index.js similarity index 82% rename from modules/System.js rename to modules/System/index.js index 77fbeae..ebc5308 100644 --- a/modules/System.js +++ b/modules/System/index.js @@ -1,7 +1,7 @@ -const Module = require("../structures/Module.js"); +const Module = require("../../lib/Module.js"); const Discord = require('discord.js'); const fs = require('fs'); -const ModulePriorities = require("../structures/ModulePriorities.js"); +const ModulePriorities = require("../../lib/ModulePriorities.js"); module.exports = class System extends Module { constructor(client) { @@ -15,7 +15,7 @@ module.exports = class System extends Module { } /** - * @param {import('../index.js')} client + * @param {import('../../index.js')} client */ async ready(client) { let serverIds = this.client.config.get('systemServer'); @@ -44,7 +44,7 @@ module.exports = class System extends Module { } /** - * @param {import('../index.js')} client + * @param {import('../../index.js')} client * @param {Discord.Interaction} interaction */ async interactionCreate(client, interaction) { @@ -62,15 +62,15 @@ module.exports = class System extends Module { // Override async loadCommands() { this.systemCommands = new Discord.Collection(); - const commands = fs.existsSync(`./modules/${this.options.name}`) ? fs.readdirSync(`./modules/${this.options.name}`).filter(file => file.endsWith(".js")) : []; + const commands = fs.existsSync(`./modules/${this.options.name}/commands`) ? fs.readdirSync(`./modules/${this.options.name}/commands`).filter(file => file.endsWith(".js")) : []; commands.forEach(file => { try { /** * @type {import('./InteractionCommand')} */ - const command = require(`../modules/${this.options.name}/${file}`); - delete require.cache[require.resolve(`../modules/${this.options.name}/${file}`)]; + const command = require(`../../modules/${this.options.name}/commands/${file}`); + delete require.cache[require.resolve(`../../modules/${this.options.name}/commands/${file}`)]; const _command = new command(this.client, this); this.systemCommands.set(file.split(".")[0], _command); diff --git a/modules/Utility/perms.js b/modules/Utility/commands/perms.js similarity index 90% rename from modules/Utility/perms.js rename to modules/Utility/commands/perms.js index d6424b6..c2e7b94 100644 --- a/modules/Utility/perms.js +++ b/modules/Utility/commands/perms.js @@ -1,6 +1,6 @@ let { EmbedBuilder, ApplicationCommandOptionType } = require('discord.js') -const Command = require('../../structures/Command.js'); -const PowerLevels = require('../../structures/PowerLevels.js'); +const Command = require('../../../lib/Command.js'); +const PowerLevels = require('../../../lib/PowerLevels.js'); module.exports = class PermsCommand extends Command { constructor(client, module) { @@ -21,7 +21,7 @@ module.exports = class PermsCommand extends Command { /** * - * @param {import('../../index.js')} client + * @param {import('../../../index.js')} client * @param {import('discord.js').ChatInputCommandInteraction} interaction */ async run(client, interaction) { diff --git a/modules/Utility/ping.js b/modules/Utility/commands/ping.js similarity index 86% rename from modules/Utility/ping.js rename to modules/Utility/commands/ping.js index 7a81281..9258cb0 100644 --- a/modules/Utility/ping.js +++ b/modules/Utility/commands/ping.js @@ -1,4 +1,4 @@ -const Command = require('../../structures/Command.js'); +const Command = require('../../../lib/Command.js'); module.exports = class PingCommand extends Command { constructor(client, module) { @@ -11,7 +11,7 @@ module.exports = class PingCommand extends Command { /** * - * @param {import('../../index.js')} client + * @param {import('../../../index.js')} client * @param {import('discord.js').CommandInteraction} interaction */ async run(client, interaction) { diff --git a/modules/Utility/setlang.js b/modules/Utility/commands/setlang.js similarity index 93% rename from modules/Utility/setlang.js rename to modules/Utility/commands/setlang.js index fbde91c..179b735 100644 --- a/modules/Utility/setlang.js +++ b/modules/Utility/commands/setlang.js @@ -1,4 +1,4 @@ -const Command = require('../../structures/Command.js'); +const Command = require('../../../lib/Command.js'); const { ApplicationCommandOptionType, MessageFlags } = require('discord.js'); module.exports = class SetLangCommand extends Command { @@ -21,7 +21,7 @@ module.exports = class SetLangCommand extends Command { /** * - * @param {import('../../index.js')} client + * @param {import('../../../index.js')} client * @param {import('discord.js').CommandInteraction} interaction */ async run(client, interaction) { diff --git a/modules/Utility/settings.js b/modules/Utility/commands/settings.js similarity index 98% rename from modules/Utility/settings.js rename to modules/Utility/commands/settings.js index 5a137e9..556bc5d 100644 --- a/modules/Utility/settings.js +++ b/modules/Utility/commands/settings.js @@ -1,7 +1,7 @@ -const Command = require('../../structures/Command.js'); +const Command = require('../../../lib/Command.js'); const { ApplicationCommandOptionType, EmbedBuilder, userMention, User, UserContextMenuCommandInteraction, PermissionsBitField } = require('discord.js'); const { Pagination } = require('pagination.djs'); -const Module = require('../../structures/Module.js'); +const Module = require('../../../lib/Module.js'); module.exports = class Settings extends Command { constructor(client, module) { @@ -137,7 +137,7 @@ module.exports = class Settings extends Command { /** * - * @param {import('../../index.js')} client + * @param {import('../../../index.js')} client * @param {import('discord.js').ChatInputCommandInteraction} interaction */ async run(client, interaction) { diff --git a/modules/Utility/stats.js b/modules/Utility/commands/stats.js similarity index 95% rename from modules/Utility/stats.js rename to modules/Utility/commands/stats.js index b4446bb..202097d 100644 --- a/modules/Utility/stats.js +++ b/modules/Utility/commands/stats.js @@ -1,4 +1,4 @@ -const Command = require('../../structures/Command.js'); +const Command = require('../../../lib/Command.js'); const { version } = require("discord.js"); const moment = require("moment"); @@ -16,7 +16,7 @@ module.exports = class StatsCommand extends Command { /** * - * @param {import('../../index.js')} client + * @param {import('../../../index.js')} client * @param {import('discord.js').CommandInteraction} interaction */ async run(client, interaction) { diff --git a/modules/Utility.js b/modules/Utility/index.js similarity index 93% rename from modules/Utility.js rename to modules/Utility/index.js index 045ec01..8e9a8cb 100644 --- a/modules/Utility.js +++ b/modules/Utility/index.js @@ -1,5 +1,5 @@ -const Module = require("../structures/Module.js"); -const ModulePriorities = require("../structures/ModulePriorities.js"); +const Module = require("../../lib/Module.js"); +const ModulePriorities = require("../../lib/ModulePriorities.js"); module.exports = class Utility extends Module { constructor(client) { @@ -16,7 +16,7 @@ module.exports = class Utility extends Module { /** * - * @param {import('../index.js')} client + * @param {import('../../index.js')} client * @param {import('discord.js').Interaction} interaction */ async interactionCreate(client, interaction) { diff --git a/package-lock.json b/package-lock.json index 502fd20..2c09b7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "chalk": "^4.1.2", "common-tags": "^1.8.2", - "discord.js": "^14.18.0", + "discord.js": "^14.25.1", "dotenv": "^16.4.7", "fs": "^0.0.1-security", "lokijs": "^1.5.12", @@ -25,15 +25,15 @@ } }, "node_modules/@discordjs/builders": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.10.1.tgz", - "integrity": "sha512-OWo1fY4ztL1/M/DUyRPShB4d/EzVfuUvPTRRHRIt/YxBrUYSz0a+JicD5F5zHFoNs2oTuWavxCOVFV1UljHTng==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", + "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", "license": "Apache-2.0", "dependencies": { - "@discordjs/formatters": "^0.6.0", - "@discordjs/util": "^1.1.1", + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.37.119", + "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -55,12 +55,12 @@ } }, "node_modules/@discordjs/formatters": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.0.tgz", - "integrity": "sha512-YIruKw4UILt/ivO4uISmrGq2GdMY6EkoTtD0oS0GvkJFRZbTSdPhzYiUILbJ/QslsvC9H9nTgGgnarnIl4jMfw==", + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.2.tgz", + "integrity": "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ==", "license": "Apache-2.0", "dependencies": { - "discord-api-types": "^0.37.114" + "discord-api-types": "^0.38.33" }, "engines": { "node": ">=16.11.0" @@ -70,9 +70,9 @@ } }, "node_modules/@discordjs/rest": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.4.3.tgz", - "integrity": "sha512-+SO4RKvWsM+y8uFHgYQrcTl/3+cY02uQOH7/7bKbVZsTfrfpoE62o5p+mmV+s7FVhTX82/kQUGGbu4YlV60RtA==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", + "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.1", @@ -80,10 +80,10 @@ "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.37.119", + "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", - "undici": "6.21.1" + "undici": "6.21.3" }, "engines": { "node": ">=18" @@ -105,10 +105,13 @@ } }, "node_modules/@discordjs/util": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz", - "integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, "engines": { "node": ">=18" }, @@ -117,18 +120,18 @@ } }, "node_modules/@discordjs/ws": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.1.tgz", - "integrity": "sha512-PBvenhZG56a6tMWF/f4P6f4GxZKJTBG95n7aiGSPTnodmz4N5g60t79rSIAq7ywMbv8A4jFtexMruH+oe51aQQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.3.tgz", + "integrity": "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.0", - "@discordjs/rest": "^2.4.3", + "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", - "discord-api-types": "^0.37.119", + "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" }, @@ -185,27 +188,27 @@ } }, "node_modules/@types/node": { - "version": "22.13.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.9.tgz", - "integrity": "sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw==", + "version": "25.2.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.0.tgz", + "integrity": "sha512-DZ8VwRFUNzuqJ5khrvwMXHmvPe+zGayJhr2CDNiKB1WBE1ST8Djl00D0IC4vvNmHMdj6DlbYRIaFE7WHjlDl5w==", "license": "MIT", "dependencies": { - "undici-types": "~6.20.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8svvI3hMyvN0kKCJMvTJP/x6Y/EoQbepff882wL+Sn5QsXb3etnamgrJq4isrBxSJj5L2AuXcI0+bgkoAXGUJw==", + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "license": "MIT", "dependencies": { "@types/node": "*" } }, "node_modules/@vladfrangu/async_event_emitter": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz", - "integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==", + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.7.tgz", + "integrity": "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g==", "license": "MIT", "engines": { "node": ">=v14.0.0", @@ -271,29 +274,33 @@ } }, "node_modules/discord-api-types": { - "version": "0.37.119", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.119.tgz", - "integrity": "sha512-WasbGFXEB+VQWXlo6IpW3oUv73Yuau1Ig4AZF/m13tXcTKnMpc/mHjpztIlz4+BM9FG9BHQkEXiPto3bKduQUg==", - "license": "MIT" + "version": "0.38.38", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.38.tgz", + "integrity": "sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] }, "node_modules/discord.js": { - "version": "14.18.0", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.18.0.tgz", - "integrity": "sha512-SvU5kVUvwunQhN2/+0t55QW/1EHfB1lp0TtLZUSXVHDmyHTrdOj5LRKdR0zLcybaA15F+NtdWuWmGOX9lE+CAw==", + "version": "14.25.1", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", + "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.10.1", + "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", - "@discordjs/formatters": "^0.6.0", - "@discordjs/rest": "^2.4.3", - "@discordjs/util": "^1.1.1", - "@discordjs/ws": "^1.2.1", + "@discordjs/formatters": "^0.6.2", + "@discordjs/rest": "^2.6.0", + "@discordjs/util": "^1.2.0", + "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.37.119", + "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", - "undici": "6.21.1" + "undici": "6.21.3" }, "engines": { "node": ">=18" @@ -336,9 +343,9 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", "license": "MIT" }, "node_modules/lodash.snakecase": { @@ -354,9 +361,9 @@ "license": "MIT" }, "node_modules/magic-bytes.js": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.10.0.tgz", - "integrity": "sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ==", + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.13.0.tgz", + "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, "node_modules/moment": { @@ -408,24 +415,24 @@ "license": "0BSD" }, "node_modules/undici": { - "version": "6.21.1", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.1.tgz", - "integrity": "sha512-q/1rj5D0/zayJB2FraXdaWxbhWiNKDvu8naDT2dl1yTlvJp4BLtOcp2a5BvgGNQpYYJzau7tf1WgKv3b+7mqpQ==", + "version": "6.21.3", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", + "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", "license": "MIT", "engines": { "node": ">=18.17" } }, "node_modules/undici-types": { - "version": "6.20.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.20.0.tgz", - "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-RKW2aJZMXeMxVpnZ6bck+RswznaxmzdULiBr6KY7XkTnW8uvt0iT9H5DkHUChXrc+uurzwa0rVI16n/Xzjdz1w==", + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 98d5889..ffbced9 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "dependencies": { "chalk": "^4.1.2", "common-tags": "^1.8.2", - "discord.js": "^14.18.0", + "discord.js": "^14.25.1", "dotenv": "^16.4.7", "fs": "^0.0.1-security", "lokijs": "^1.5.12", From 7193fffad93e5602b9c96ed98ae4ee1da945bba1 Mon Sep 17 00:00:00 2001 From: Miky88 <44903677+Miky88@users.noreply.github.com> Date: Thu, 5 Feb 2026 18:02:08 +0100 Subject: [PATCH 2/8] refactor: code cleanup --- {lib => core}/Command.js | 0 {lib => core}/ConfigurationManager.js | 1 - {lib => core}/Database.js | 0 {lib => core}/LocalizationManager.js | 0 {lib => core}/Logger.js | 0 {lib => core}/Module.js | 1 - {lib => core}/ModuleManager.js | 0 {lib => core}/ModulePriorities.js | 0 {lib => core}/PowerLevels.js | 0 {lib => core}/SettingsManager.js | 0 {lib => core}/Utils.js | 0 index.js | 10 +++++----- 12 files changed, 5 insertions(+), 7 deletions(-) rename {lib => core}/Command.js (100%) rename {lib => core}/ConfigurationManager.js (98%) rename {lib => core}/Database.js (100%) rename {lib => core}/LocalizationManager.js (100%) rename {lib => core}/Logger.js (100%) rename {lib => core}/Module.js (99%) rename {lib => core}/ModuleManager.js (100%) rename {lib => core}/ModulePriorities.js (100%) rename {lib => core}/PowerLevels.js (100%) rename {lib => core}/SettingsManager.js (100%) rename {lib => core}/Utils.js (100%) diff --git a/lib/Command.js b/core/Command.js similarity index 100% rename from lib/Command.js rename to core/Command.js diff --git a/lib/ConfigurationManager.js b/core/ConfigurationManager.js similarity index 98% rename from lib/ConfigurationManager.js rename to core/ConfigurationManager.js index 30434ef..29486b2 100644 --- a/lib/ConfigurationManager.js +++ b/core/ConfigurationManager.js @@ -1,7 +1,6 @@ const { parse, stringify } = require('yaml') const Module = require('./Module.js') const fs = require('fs') -const { Client } = require('discord.js') module.exports = class ConfigurationManager { /** diff --git a/lib/Database.js b/core/Database.js similarity index 100% rename from lib/Database.js rename to core/Database.js diff --git a/lib/LocalizationManager.js b/core/LocalizationManager.js similarity index 100% rename from lib/LocalizationManager.js rename to core/LocalizationManager.js diff --git a/lib/Logger.js b/core/Logger.js similarity index 100% rename from lib/Logger.js rename to core/Logger.js diff --git a/lib/Module.js b/core/Module.js similarity index 99% rename from lib/Module.js rename to core/Module.js index 0bfd2f0..58ec8e3 100644 --- a/lib/Module.js +++ b/core/Module.js @@ -6,7 +6,6 @@ const ModulePriorities = require('./ModulePriorities'); const ConfigurationManager = require('./ConfigurationManager'); const SettingsManager = require('./SettingsManager'); const Logger = require('./Logger'); -const Database = require('./Database') module.exports = class Module { /** diff --git a/lib/ModuleManager.js b/core/ModuleManager.js similarity index 100% rename from lib/ModuleManager.js rename to core/ModuleManager.js diff --git a/lib/ModulePriorities.js b/core/ModulePriorities.js similarity index 100% rename from lib/ModulePriorities.js rename to core/ModulePriorities.js diff --git a/lib/PowerLevels.js b/core/PowerLevels.js similarity index 100% rename from lib/PowerLevels.js rename to core/PowerLevels.js diff --git a/lib/SettingsManager.js b/core/SettingsManager.js similarity index 100% rename from lib/SettingsManager.js rename to core/SettingsManager.js diff --git a/lib/Utils.js b/core/Utils.js similarity index 100% rename from lib/Utils.js rename to core/Utils.js diff --git a/index.js b/index.js index 1d49d01..de3c9ef 100644 --- a/index.js +++ b/index.js @@ -2,11 +2,11 @@ require('dotenv').config(); const { Client, Collection, GatewayIntentBits, Partials } = require('discord.js'); -const ModuleManager = require('./lib/ModuleManager.js'); -const Database = require('./lib/Database.js'); -const ConfigurationManager = require('./lib/ConfigurationManager.js'); -const Utils = require('./lib/Utils.js'); -const LocalizationManager = require('./lib/LocalizationManager.js'); +const ModuleManager = require('./core/ModuleManager.js'); +const Database = require('./core/Database.js'); +const ConfigurationManager = require('./core/ConfigurationManager.js'); +const Utils = require('./core/Utils.js'); +const LocalizationManager = require('./core/LocalizationManager.js'); BigInt.prototype.toJSON = function () { return this.toString() } // MDN https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt#use_within_json // Discord From bf58dbfc29801df679f4ac02275ac0f632673288 Mon Sep 17 00:00:00 2001 From: Miky88 <44903677+Miky88@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:09:28 +0100 Subject: [PATCH 3/8] refactor: code cleanup --- README.md | 4 ++-- config.yml | 1 + core/ModuleManager.js | 4 ++-- index.js | 1 + modules/Example/{index.js => Example.js} | 6 +++--- modules/Example/commands/example.js | 2 +- modules/Example/locales/en-GB.yaml | 1 + modules/Example/locales/it.yaml | 1 + .../{index.js => InteractionCommandHandler.js} | 12 ++++++------ modules/ReadyLog/{index.js => ReadyLog.js} | 6 +++--- modules/System/{index.js => System.js} | 8 ++++---- modules/System/commands/eval.js | 4 ++-- modules/System/commands/exec.js | 4 ++-- modules/System/commands/modman.js | 6 +++--- modules/System/commands/reboot.js | 4 ++-- modules/System/commands/setlevel.js | 4 ++-- modules/System/commands/update.js | 4 ++-- modules/Utility/{index.js => Utility.js} | 4 ++-- modules/Utility/commands/perms.js | 4 ++-- modules/Utility/commands/ping.js | 2 +- modules/Utility/commands/setlang.js | 2 +- modules/Utility/commands/settings.js | 4 ++-- modules/Utility/commands/stats.js | 2 +- package-lock.json | 7 +++++++ package.json | 9 +++++++-- 25 files changed, 61 insertions(+), 45 deletions(-) rename modules/Example/{index.js => Example.js} (90%) create mode 100644 modules/Example/locales/en-GB.yaml create mode 100644 modules/Example/locales/it.yaml rename modules/InteractionCommandHandler/{index.js => InteractionCommandHandler.js} (87%) rename modules/ReadyLog/{index.js => ReadyLog.js} (92%) rename modules/System/{index.js => System.js} (93%) rename modules/Utility/{index.js => Utility.js} (95%) diff --git a/README.md b/README.md index d5e07ae..3e75534 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ class Example extends Module { name: "Example", // Name of the module info: "Description", // Description of the module enabled: true, // Defines if this module should be enabled on startup - events: ["ready"], // Event that triggeres the module (can be more than one) + events: ["clientReady"], // Event that triggeres the module (can be more than one) config: { // Default module configuration, it will be stored in a config.yml inside module directory myOptions: { configurableString: "Hey!", @@ -35,7 +35,7 @@ class Example extends Module { }) } - async ready(client, ...args) { // args are the arguments of Discord.js Events (es. for presenceUpdate you would have [oldPresence, newPresence] + async clientReady(client, ...args) { // args are the arguments of Discord.js Events (es. for presenceUpdate you would have [oldPresence, newPresence] this.logger.log("Hi!") } } diff --git a/config.yml b/config.yml index b41c9b5..be5d2f9 100644 --- a/config.yml +++ b/config.yml @@ -9,6 +9,7 @@ intents: - GuildMembers - GuildModeration - GuildBans + - GuildExpressions - GuildEmojisAndStickers - GuildIntegrations - GuildWebhooks diff --git a/core/ModuleManager.js b/core/ModuleManager.js index 86c6f56..1658fa4 100644 --- a/core/ModuleManager.js +++ b/core/ModuleManager.js @@ -36,8 +36,8 @@ module.exports = class ModuleManager { */ load(moduleName) { try { - const module = require(`../modules/${moduleName}/index.js`); - delete require.cache[require.resolve(`../modules/${moduleName}/index.js`)]; + const module = require(`@modules/${moduleName}/${moduleName}.js`); + delete require.cache[require.resolve(`@modules/${moduleName}/${moduleName}.js`)]; const _module = new module(this.client); if (_module.options.dependencies.length > 0) { let dependencies = _module.options.dependencies; diff --git a/index.js b/index.js index de3c9ef..d9f03c0 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,6 @@ // Imports require('dotenv').config(); +require("module-alias/register"); const { Client, Collection, GatewayIntentBits, Partials } = require('discord.js'); const ModuleManager = require('./core/ModuleManager.js'); diff --git a/modules/Example/index.js b/modules/Example/Example.js similarity index 90% rename from modules/Example/index.js rename to modules/Example/Example.js index 7af08fe..96d099e 100644 --- a/modules/Example/index.js +++ b/modules/Example/Example.js @@ -1,4 +1,4 @@ -const Module = require("../../lib/Module.js"); +const Module = require("@core/Module.js"); const ImportantFile = require("./lib/importantFile.js"); module.exports = class Example extends Module { @@ -7,7 +7,7 @@ module.exports = class Example extends Module { name: "Example", info: "Very important module", enabled: true, - events: ["ready"], + events: ["clientReady"], config: { exampleString: 'Hello, world!', exampleNumber: 67, @@ -20,7 +20,7 @@ module.exports = class Example extends Module { /** * @param {import('../../index.js')} client */ - async ready(client) { + async clientReady(client) { this.logger.info('This module is doing very important things.'); this.logger.info(`Example string: ${this.config.get('exampleString')}`); this.logger.info(`Example number: ${this.config.get('exampleNumber')}`); diff --git a/modules/Example/commands/example.js b/modules/Example/commands/example.js index 0d5b692..09c0c45 100644 --- a/modules/Example/commands/example.js +++ b/modules/Example/commands/example.js @@ -1,4 +1,4 @@ -const Command = require('../../../lib/Command.js'); +const Command = require('@core/Command.js'); module.exports = class ExampleCommand extends Command { constructor(client, module) { diff --git a/modules/Example/locales/en-GB.yaml b/modules/Example/locales/en-GB.yaml new file mode 100644 index 0000000..c6bf7c8 --- /dev/null +++ b/modules/Example/locales/en-GB.yaml @@ -0,0 +1 @@ +example: "This is an example module. You can edit this file to add your own translations for the en-GB locale." \ No newline at end of file diff --git a/modules/Example/locales/it.yaml b/modules/Example/locales/it.yaml new file mode 100644 index 0000000..e5fab92 --- /dev/null +++ b/modules/Example/locales/it.yaml @@ -0,0 +1 @@ +example: "Questo è un modulo di esempio. Puoi modificare questo file per aggiungere le tue traduzioni per la lingua italiana." \ No newline at end of file diff --git a/modules/InteractionCommandHandler/index.js b/modules/InteractionCommandHandler/InteractionCommandHandler.js similarity index 87% rename from modules/InteractionCommandHandler/index.js rename to modules/InteractionCommandHandler/InteractionCommandHandler.js index 655911e..d4d43c7 100644 --- a/modules/InteractionCommandHandler/index.js +++ b/modules/InteractionCommandHandler/InteractionCommandHandler.js @@ -1,9 +1,9 @@ const Discord = require('discord.js'); -const Module = require("../../lib/Module.js"); -const Command = require("../../lib/Command.js"); +const Module = require("@core/Module.js"); +const Command = require("@core/Command.js"); const BotClient = require('../../index.js'); -const PowerLevels = require('../../lib/PowerLevels.js'); -const ModulePriorities = require('../../lib/ModulePriorities.js'); +const PowerLevels = require('@core/PowerLevels.js'); +const ModulePriorities = require('@core/ModulePriorities.js'); module.exports = class InteractionCommandHandler extends Module { constructor(client) { @@ -11,7 +11,7 @@ module.exports = class InteractionCommandHandler extends Module { name: "InteractionCommandHandler", info: "Adds interaction commands support.", enabled: true, - events: ["ready", "interactionCreate"], + events: ["clientReady", "interactionCreate"], priority: ModulePriorities.HIGH }); } @@ -19,7 +19,7 @@ module.exports = class InteractionCommandHandler extends Module { /** * @param {BotClient} client */ - async ready(client) { + async clientReady(client) { client.application.commands .set(client.moduleManager.commands.filter(c => c.module.options.name !== "System").map(c => c.toJson())) } diff --git a/modules/ReadyLog/index.js b/modules/ReadyLog/ReadyLog.js similarity index 92% rename from modules/ReadyLog/index.js rename to modules/ReadyLog/ReadyLog.js index 883beb7..58372da 100644 --- a/modules/ReadyLog/index.js +++ b/modules/ReadyLog/ReadyLog.js @@ -1,4 +1,4 @@ -const Module = require("../../lib/Module.js"); +const Module = require("@core/Module.js"); module.exports = class ReadyLog extends Module { constructor(client) { @@ -6,7 +6,7 @@ module.exports = class ReadyLog extends Module { name: "ReadyLog", info: "Logs informations once ready and sets the custom status", enabled: true, - events: ["ready"] + events: ["clientReady"] }) } @@ -14,7 +14,7 @@ module.exports = class ReadyLog extends Module { * @param {import('../../index.js')} client * @param {...any} _args */ - async run(client, ..._args) { + async clientReady(client, ..._args) { // Log some useful variables when online this.logger.success("I am ready!"); this.logger.info(`I am logged in as ${client.user.tag}`); diff --git a/modules/System/index.js b/modules/System/System.js similarity index 93% rename from modules/System/index.js rename to modules/System/System.js index ebc5308..10ee29d 100644 --- a/modules/System/index.js +++ b/modules/System/System.js @@ -1,7 +1,7 @@ -const Module = require("../../lib/Module.js"); +const Module = require("@core/Module.js"); const Discord = require('discord.js'); const fs = require('fs'); -const ModulePriorities = require("../../lib/ModulePriorities.js"); +const ModulePriorities = require("@core/ModulePriorities.js"); module.exports = class System extends Module { constructor(client) { @@ -9,7 +9,7 @@ module.exports = class System extends Module { name: "System", info: "Loads the system commands", enabled: true, - events: ["ready", "interactionCreate"], + events: ["clientReady", "interactionCreate"], priority: ModulePriorities.HIGHEST }) } @@ -17,7 +17,7 @@ module.exports = class System extends Module { /** * @param {import('../../index.js')} client */ - async ready(client) { + async clientReady(client) { let serverIds = this.client.config.get('systemServer'); if (!serverIds) { diff --git a/modules/System/commands/eval.js b/modules/System/commands/eval.js index 218e63d..8aab36e 100644 --- a/modules/System/commands/eval.js +++ b/modules/System/commands/eval.js @@ -1,7 +1,7 @@ -const Command = require("../../../lib/Command.js"); +const Command = require("@core/Command.js"); const { inspect } = require("util"); const { EmbedBuilder, ApplicationCommandOptionType } = require("discord.js"); -const PowerLevels = require("../../../lib/PowerLevels.js"); +const PowerLevels = require("@core/PowerLevels.js"); module.exports = class EvalCommand extends Command { constructor(client, module) { diff --git a/modules/System/commands/exec.js b/modules/System/commands/exec.js index 2283cf1..f98541c 100644 --- a/modules/System/commands/exec.js +++ b/modules/System/commands/exec.js @@ -1,7 +1,7 @@ -const Command = require("../../../lib/Command.js"); +const Command = require("@core/Command.js"); const { AttachmentBuilder, ApplicationCommandOptionType } = require("discord.js"); const exec = require("util").promisify(require("child_process").exec); -const PowerLevels = require("../../../lib/PowerLevels.js"); +const PowerLevels = require("@core/PowerLevels.js"); module.exports = class ExecCommand extends Command { constructor(client, module) { diff --git a/modules/System/commands/modman.js b/modules/System/commands/modman.js index dbef1a4..6db57d3 100644 --- a/modules/System/commands/modman.js +++ b/modules/System/commands/modman.js @@ -1,6 +1,6 @@ -const Command = require('../../../lib/Command.js'); +const Command = require('@core/Command.js'); let { EmbedBuilder, ApplicationCommandOptionType} = require('discord.js'); -const PowerLevels = require("../../../lib/PowerLevels.js"); +const PowerLevels = require("@core/PowerLevels.js"); module.exports = class ModManCommand extends Command { constructor(client, module) { @@ -107,7 +107,7 @@ module.exports = class ModManCommand extends Command { * * @param {import('../../../index.js')} client * @param {import('discord.js').ChatInputCommandInteraction} interaction - * @param {import('../../../lib/Module.js')} module + * @param {import('@core/Module.js')} module */ async run(client, interaction, module) { let embed = new EmbedBuilder() diff --git a/modules/System/commands/reboot.js b/modules/System/commands/reboot.js index 192109d..dce4103 100644 --- a/modules/System/commands/reboot.js +++ b/modules/System/commands/reboot.js @@ -1,6 +1,6 @@ const { MessageFlags } = require('discord.js'); -const Command = require('../../../lib/Command.js'); -const PowerLevels = require("../../../lib/PowerLevels.js"); +const Command = require('@core/Command.js'); +const PowerLevels = require("@core/PowerLevels.js"); module.exports = class RebootCommand extends Command { constructor(client, module) { diff --git a/modules/System/commands/setlevel.js b/modules/System/commands/setlevel.js index 1406a0c..098bffe 100644 --- a/modules/System/commands/setlevel.js +++ b/modules/System/commands/setlevel.js @@ -1,6 +1,6 @@ -const Command = require('../../../lib/Command.js'); +const Command = require('@core/Command.js'); const { ApplicationCommandOptionType, EmbedBuilder, userMention, User, UserContextMenuCommandInteraction } = require('discord.js'); -const PowerLevels = require('../../../lib/PowerLevels.js'); +const PowerLevels = require('@core/PowerLevels.js'); module.exports = class SetLevelCommand extends Command { constructor(client, module) { diff --git a/modules/System/commands/update.js b/modules/System/commands/update.js index ce7dd3e..22e7ab0 100644 --- a/modules/System/commands/update.js +++ b/modules/System/commands/update.js @@ -1,5 +1,5 @@ -const Command = require("../../../lib/Command.js"); -const PowerLevels = require("../../../lib/PowerLevels.js"); +const Command = require("@core/Command.js"); +const PowerLevels = require("@core/PowerLevels.js"); const exec = require("util").promisify(require("child_process").exec); module.exports = class UpdateCommand extends Command { diff --git a/modules/Utility/index.js b/modules/Utility/Utility.js similarity index 95% rename from modules/Utility/index.js rename to modules/Utility/Utility.js index 8e9a8cb..8a47e79 100644 --- a/modules/Utility/index.js +++ b/modules/Utility/Utility.js @@ -1,5 +1,5 @@ -const Module = require("../../lib/Module.js"); -const ModulePriorities = require("../../lib/ModulePriorities.js"); +const Module = require("@core/Module.js"); +const ModulePriorities = require("@core/ModulePriorities.js"); module.exports = class Utility extends Module { constructor(client) { diff --git a/modules/Utility/commands/perms.js b/modules/Utility/commands/perms.js index c2e7b94..c5883bb 100644 --- a/modules/Utility/commands/perms.js +++ b/modules/Utility/commands/perms.js @@ -1,6 +1,6 @@ let { EmbedBuilder, ApplicationCommandOptionType } = require('discord.js') -const Command = require('../../../lib/Command.js'); -const PowerLevels = require('../../../lib/PowerLevels.js'); +const Command = require('@core/Command.js'); +const PowerLevels = require('@core/PowerLevels.js'); module.exports = class PermsCommand extends Command { constructor(client, module) { diff --git a/modules/Utility/commands/ping.js b/modules/Utility/commands/ping.js index 9258cb0..8458ede 100644 --- a/modules/Utility/commands/ping.js +++ b/modules/Utility/commands/ping.js @@ -1,4 +1,4 @@ -const Command = require('../../../lib/Command.js'); +const Command = require('@core/Command.js'); module.exports = class PingCommand extends Command { constructor(client, module) { diff --git a/modules/Utility/commands/setlang.js b/modules/Utility/commands/setlang.js index 179b735..ebf0632 100644 --- a/modules/Utility/commands/setlang.js +++ b/modules/Utility/commands/setlang.js @@ -1,4 +1,4 @@ -const Command = require('../../../lib/Command.js'); +const Command = require('@core/Command.js'); const { ApplicationCommandOptionType, MessageFlags } = require('discord.js'); module.exports = class SetLangCommand extends Command { diff --git a/modules/Utility/commands/settings.js b/modules/Utility/commands/settings.js index 556bc5d..9859eac 100644 --- a/modules/Utility/commands/settings.js +++ b/modules/Utility/commands/settings.js @@ -1,7 +1,7 @@ -const Command = require('../../../lib/Command.js'); +const Command = require('@core/Command.js'); const { ApplicationCommandOptionType, EmbedBuilder, userMention, User, UserContextMenuCommandInteraction, PermissionsBitField } = require('discord.js'); const { Pagination } = require('pagination.djs'); -const Module = require('../../../lib/Module.js'); +const Module = require('@core/Module.js'); module.exports = class Settings extends Command { constructor(client, module) { diff --git a/modules/Utility/commands/stats.js b/modules/Utility/commands/stats.js index 202097d..b8b77c9 100644 --- a/modules/Utility/commands/stats.js +++ b/modules/Utility/commands/stats.js @@ -1,4 +1,4 @@ -const Command = require('../../../lib/Command.js'); +const Command = require('@core/Command.js'); const { version } = require("discord.js"); const moment = require("moment"); diff --git a/package-lock.json b/package-lock.json index 2c09b7a..57df469 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "dotenv": "^16.4.7", "fs": "^0.0.1-security", "lokijs": "^1.5.12", + "module-alias": "^2.3.4", "moment": "^2.30.1", "moment-duration-format": "^2.3.2", "pagination.djs": "^4.0.16", @@ -366,6 +367,12 @@ "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, + "node_modules/module-alias": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/module-alias/-/module-alias-2.3.4.tgz", + "integrity": "sha512-bOclZt8hkpuGgSSoG07PKmvzTizROilUTvLNyrMqvlC9snhs7y7GzjNWAVbISIOlhCP1T14rH1PDAV9iNyBq/w==", + "license": "MIT" + }, "node_modules/moment": { "version": "2.30.1", "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", diff --git a/package.json b/package.json index ffbced9..d5275a1 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "bot", "discord" ], - "author": "Miky88", - "license": "ISC", + "author": "Miky88, Just1diaxx, GalaxyVinci05", + "license": "GNU", "dependencies": { "chalk": "^4.1.2", "common-tags": "^1.8.2", @@ -22,9 +22,14 @@ "dotenv": "^16.4.7", "fs": "^0.0.1-security", "lokijs": "^1.5.12", + "module-alias": "^2.3.4", "moment": "^2.30.1", "moment-duration-format": "^2.3.2", "pagination.djs": "^4.0.16", "yaml": "^2.3.1" + }, + "_moduleAliases": { + "@core": "core", + "@modules": "modules" } } From da54a494a2eff55b5307c43793eba07d6959be0c Mon Sep 17 00:00:00 2001 From: Just1diaxx <157634021+Just1diaxx@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:41:54 +0100 Subject: [PATCH 4/8] fix system commands loading --- modules/System/System.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/System/System.js b/modules/System/System.js index 10ee29d..8530639 100644 --- a/modules/System/System.js +++ b/modules/System/System.js @@ -62,14 +62,14 @@ module.exports = class System extends Module { // Override async loadCommands() { this.systemCommands = new Discord.Collection(); - const commands = fs.existsSync(`./modules/${this.options.name}/commands`) ? fs.readdirSync(`./modules/${this.options.name}/commands`).filter(file => file.endsWith(".js")) : []; + const commands = fs.existsSync(`@modules/${this.options.name}/commands`) ? fs.readdirSync(`@modules/${this.options.name}/commands`).filter(file => file.endsWith(".js")) : []; commands.forEach(file => { try { /** * @type {import('./InteractionCommand')} */ - const command = require(`../../modules/${this.options.name}/commands/${file}`); + const command = require(`@modules/${this.options.name}/commands/${file}`); delete require.cache[require.resolve(`../../modules/${this.options.name}/commands/${file}`)]; const _command = new command(this.client, this); From ac6e616c711940294020c77d26387fadc169b239 Mon Sep 17 00:00:00 2001 From: Miky88 <44903677+Miky88@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:38:21 +0200 Subject: [PATCH 5/8] Refactor database, errors, and i18n; fix ping; tighten config - Database: split into a registry (core/Database.js) of DatabaseHandle instances, one Loki file per module under data/.db. Adds a one-shot migrate() helper that copies legacy module_X / settings_X collections out of database.db into the new per-module layout. - ErrorHandler: new core/ErrorHandler.js. Hooks process and discord.js error/warning events, dedup-windowed, writes JSONL + log to logs/, optional Discord embed reporting via config.errorReporting. Wraps module event dispatch and command execution so a single failure no longer breaks the chain. exitOnUncaught = true for Docker restarts. - LocalizationManager: rewritten to load module-owned locales from modules//locales/.yaml and merge under modules.. Drops the global /locales/ dir entirely. Auto-syncs missing keys by inserting the dotted path as the value. Discord-style locale fallback (en-US -> en-GB). Adds Intl.DisplayNames-based languageName(). All module strings migrated out of the old global locale files. - Module/SettingsManager/ModuleManager: usesDB removed in favor of the databases option; SettingsManager and ModuleManager go through the registry instead of poking client.database.db directly. - ConfigurationManager: track a mutated flag so newly-added default keys actually persist on rewrite (the key-count check was a no-op for adds). - modules/Utility/commands/ping.js: withResponse: true returns an InteractionCallbackResponse; pull the message off response.resource. - gitignore: add data/ and logs/. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 4 +- config.yml | 10 + core/ConfigurationManager.js | 7 +- core/Database.js | 237 ++++++++---- core/DatabaseHandle.js | 102 ++++++ core/ErrorHandler.js | 212 +++++++++++ core/LocalizationManager.js | 337 ++++++++++++++---- core/Module.js | 90 +++-- core/ModuleManager.js | 23 +- core/SettingsManager.js | 40 ++- index.js | 18 +- locales/en-GB.yaml | 170 --------- locales/it.yaml | 170 --------- .../InteractionCommandHandler.js | 60 +++- .../locales/en-GB.yaml | 4 + .../InteractionCommandHandler/locales/it.yaml | 4 + modules/ReadyLog/ReadyLog.js | 12 - modules/System/System.js | 13 +- modules/System/locales/en-GB.yaml | 77 ++++ modules/System/locales/it.yaml | 77 ++++ modules/Utility/Utility.js | 5 +- modules/Utility/commands/ping.js | 5 +- modules/Utility/locales/en-GB.yaml | 83 +++++ modules/Utility/locales/it.yaml | 83 +++++ package-lock.json | 103 +++--- package.json | 8 +- 26 files changed, 1351 insertions(+), 603 deletions(-) create mode 100644 core/DatabaseHandle.js create mode 100644 core/ErrorHandler.js delete mode 100644 locales/en-GB.yaml delete mode 100644 locales/it.yaml create mode 100644 modules/InteractionCommandHandler/locales/en-GB.yaml create mode 100644 modules/InteractionCommandHandler/locales/it.yaml create mode 100644 modules/System/locales/en-GB.yaml create mode 100644 modules/System/locales/it.yaml create mode 100644 modules/Utility/locales/en-GB.yaml create mode 100644 modules/Utility/locales/it.yaml diff --git a/.gitignore b/.gitignore index de18ecf..7b471e9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,6 @@ reboot.json database.db config.yml out.txt -err.txt \ No newline at end of file +err.txt +data/ +logs/ \ No newline at end of file diff --git a/config.yml b/config.yml index be5d2f9..ee39b08 100644 --- a/config.yml +++ b/config.yml @@ -31,3 +31,13 @@ intents: partials: - Reaction - Message +errorReporting: + channelId: null + notifyOwners: false + dedupWindowMs: 60000 + exitOnUncaught: true +i18n: + defaultLang: en-GB + referenceLanguage: en-GB + autoSync: true + hotReload: false diff --git a/core/ConfigurationManager.js b/core/ConfigurationManager.js index 29486b2..ca457ae 100644 --- a/core/ConfigurationManager.js +++ b/core/ConfigurationManager.js @@ -25,14 +25,15 @@ module.exports = class ConfigurationManager { this.file = parse(fs.readFileSync(this.path, 'utf8')) // Check if config file has all the required fields + let mutated = false; for (const key in this.defaultConfig) { - if (!this.file[key]) { + if (this.file[key] === undefined || this.file[key] === null) { this.file[key] = this.defaultConfig[key] + mutated = true; } } - // Re-write config file if it doesn't have all the required fields - if (Object.keys(this.file).length !== Object.keys(this.defaultConfig).length) { + if (mutated) { fs.writeFileSync(this.path, stringify(this.file)) } } diff --git a/core/Database.js b/core/Database.js index 4af6811..774dd5e 100644 --- a/core/Database.js +++ b/core/Database.js @@ -1,102 +1,136 @@ const Loki = require('lokijs'); +const path = require('path'); +const fs = require('fs'); const BotClient = require('../index.js'); const Logger = require('./Logger.js'); const PowerLevels = require('./PowerLevels.js'); -const cache = new Map(); +const DatabaseHandle = require('./DatabaseHandle.js'); + +const userCache = new Map(); + +const DEFAULT_DATA_DIR = 'data'; +const CORE_FILE = 'database.db'; module.exports = class Database { /** - * The bot's main database - * @param {BotClient} client + * Registry of database handles. The bot ships a single "core" handle backed + * by `database.db` (housing the built-in `users` collection); each module + * that opts in gets its own handle backed by `data/.db`. + * @param {BotClient} client */ constructor(client) { this.client = client; - this.collections = ['users'] - - this.db = new Loki('database.db', { - autoload: true, - autosave: true, - autoloadCallback: () => this.collections.forEach((collection) => this.db[collection] = this.db.addCollection(collection)), - autosaveInterval: 1000, - }); this.logger = new Logger('DB'); + /** @type {Map} */ + this.handles = new Map(); - // console.log(this.db.listCollections()) - this.logger.verbose('Database loaded') + this.core = this.register('core', { + file: CORE_FILE, + collections: ['users'] + }); } - - reconfigure() { - this.db.configureOptions({ - autoload: true, - autosave: true, - autoloadCallback: () => this.collections.forEach((collection) => this.db[collection] = this.db.addCollection(collection)), - autosaveInterval: 1000, - }) + + /** + * Register (or fetch) a named database handle. Idempotent: calling twice + * with the same name returns the existing handle and ensures any newly + * requested collections exist. + * @param {string} name + * @param {object} [opts] + * @param {string} [opts.file] Custom file path. Defaults to `data/.db`. + * @param {string[]} [opts.collections] Collections to ensure on load. + * @param {object} [opts.collectionOptions] Per-collection Loki options. + * @param {object} [opts.lokiOptions] Extra Loki constructor options. + * @returns {DatabaseHandle} + */ + register(name, opts = {}) { + const existing = this.handles.get(name); + if (existing) { + for (const c of opts.collections || []) existing.collection(c); + return existing; + } + + const file = opts.file || path.join(DEFAULT_DATA_DIR, `${name}.db`); + const handle = new DatabaseHandle(name, { ...opts, file }); + handle.ready().catch(err => { + this.client.errorHandler?.capture(err, { source: 'lokiAutoload', module: name }); + }); + this.handles.set(name, handle); + return handle; } - + /** - * @param {String} userID + * @param {string} name + * @returns {DatabaseHandle | undefined} + */ + get(name) { return this.handles.get(name); } + + /** Convenience accessor for the core users collection. */ + get users() { return this.core.collection('users'); } + + /** + * @param {String} userID */ addUser(userID) { - const user = this.db.users.insert({ + const user = this.users.insert({ id: userID, powerlevel: PowerLevels.USER, language: null - }) - this.cacheUser(user) + }); + this.cacheUser(user); return user; } /** - * @param {import('discord.js').User} user + * @param {import('discord.js').User | { id: string }} user */ cacheUser(user) { - cache.set(user.id, user) + userCache.set(user.id, user); } /** - * @param {String} userID + * @param {String} userID */ getUser(userID) { - const data = cache.get(userID) || this.db.users.findOne({ id: userID }) - if (data) this.cacheUser(data) - return data + const data = userCache.get(userID) || this.users.findOne({ id: userID }); + if (data) this.cacheUser(data); + return data; } - + /** - * @param {String} userID + * @param {String} userID */ forceUser(userID) { - let user = this.getUser(userID) + let user = this.getUser(userID); if (user) { - if(user.powerlevel !== PowerLevels.OWNER && this.client.config.get('owners').includes(userID)) { - user.powerlevel = PowerLevels.OWNER - this.updateUser(user) - } else if (user.powerlevel === PowerLevels.OWNER && !this.client.config.get('owners').includes(userID)) { - user.powerlevel = PowerLevels.USER - this.updateUser(user) + const isOwner = this.client.config.get('owners').includes(userID); + if (user.powerlevel !== PowerLevels.OWNER && isOwner) { + user.powerlevel = PowerLevels.OWNER; + this.updateUser(user); + } else if (user.powerlevel === PowerLevels.OWNER && !isOwner) { + user.powerlevel = PowerLevels.USER; + this.updateUser(user); } return user; } - return this.addUser(userID) + return this.addUser(userID); } /** - * @param {*} data + * @param {*} data */ async updateUser(data) { - delete data.user - this.db.users.update(data) + delete data.user; + this.users.update(data); - const update = await this.db.users.findOne({ id: data.id }) - this.cacheUser(update) - return update + const update = this.users.findOne({ id: data.id }); + this.cacheUser(update); + return update; } /** - * @param {import('lokijs').Collection} collection + * @param {import('lokijs').Collection} collection */ - fixCollection (collection) { + fixCollection(collection) { this.logger.debug('Fixing collection', collection.name); const deduplicateSet = new Set(); const data = collection.data @@ -104,18 +138,13 @@ module.exports = class Database { .filter((x) => { const duplicated = deduplicateSet.has(x.$loki); deduplicateSet.add(x.$loki); - - if (duplicated) { - this.logger.warn('Detected duplicated key, will remove it'); - } + if (duplicated) this.logger.warn('Detected duplicated key, will remove it'); return !duplicated; }) .sort((a, b) => a.$loki - b.$loki); const index = new Array(data.length); - for (let i = 0; i < data.length; i += 1) { - index[i] = data[i].$loki; - } + for (let i = 0; i < data.length; i += 1) index[i] = data[i].$loki; collection.data = data; collection.idIndex = index; @@ -123,10 +152,94 @@ module.exports = class Database { ? Math.max(...collection.data.map((x) => x.$loki)) : 0; collection.dirty = true; - collection.checkAllIndexes({ - randomSampling: true, - repair: true, - }); + collection.checkAllIndexes({ randomSampling: true, repair: true }); this.logger.success('Done!'); } -} + + /** + * Migrate a legacy monolithic Loki file (everything in `database.db`) into + * the new per-module file layout. Detects collections named `module_` + * and `settings_` and copies their rows into the matching per-module + * handle (collections `default` and `settings` respectively). The `users` + * collection is left alone since the core handle already points at the + * legacy file. + * + * @param {object} [opts] + * @param {string} [opts.source='database.db'] Path to the legacy file. + * @param {boolean} [opts.dryRun=false] Report what would happen, do nothing. + * @param {boolean} [opts.removeOriginal=false] Drop the migrated collections from the legacy file after copy. + * @returns {Promise<{migrated: Array<{module: string, collection: string, rows: number}>, skipped: string[]}>} + */ + async migrate({ source = CORE_FILE, dryRun = false, removeOriginal = false } = {}) { + const report = { migrated: [], skipped: [] }; + + if (!fs.existsSync(source)) { + this.logger.warn(`Migration source ${source} does not exist — nothing to do.`); + return report; + } + + // If the source is the file the core handle already has open, reuse + // that Loki instance — opening a second Loki on the same file would + // race with core's autosave. + const reuseCore = path.resolve(source) === path.resolve(this.core.file); + const legacy = reuseCore ? this.core.db : await this._loadStandalone(source); + const legacyCollections = legacy.listCollections().map(c => c.name); + this.logger.info(`Found ${legacyCollections.length} collection(s) in ${source}: ${legacyCollections.join(', ') || '(none)'}`); + + for (const colName of legacyCollections) { + const moduleMatch = colName.match(/^module_(.+)$/); + const settingsMatch = colName.match(/^settings_(.+)$/); + if (!moduleMatch && !settingsMatch) { + report.skipped.push(colName); + continue; + } + + const moduleName = (moduleMatch || settingsMatch)[1]; + const targetCollection = moduleMatch ? 'default' : 'settings'; + const sourceCol = legacy.getCollection(colName); + const rows = sourceCol.find(); + + this.logger.info(`${dryRun ? '[dry-run] ' : ''}Migrating ${colName} (${rows.length} row${rows.length === 1 ? '' : 's'}) → ${moduleName}/${targetCollection}`); + report.migrated.push({ module: moduleName, collection: targetCollection, rows: rows.length }); + + if (dryRun) continue; + + const handle = this.register(moduleName, { collections: [targetCollection] }); + await handle.ready(); + const targetCol = handle.collection(targetCollection); + + for (const row of rows) { + const { $loki, meta, ...clean } = row; + targetCol.insert(clean); + } + + if (removeOriginal) legacy.removeCollection(colName); + } + + if (!dryRun) { + // Persist legacy changes (only matters if removeOriginal stripped collections). + // For the reused core handle, the next autosave tick will flush; we still save explicitly to make the cleanup observable immediately. + await new Promise((resolve, reject) => legacy.saveDatabase(err => err ? reject(err) : resolve())); + if (!reuseCore) await new Promise((resolve) => legacy.close(resolve)); + } + + this.logger.success(`Migration ${dryRun ? 'dry-run ' : ''}complete: ${report.migrated.length} collection(s) migrated, ${report.skipped.length} skipped.`); + return report; + } + + /** + * Load a Loki file in isolation (not registered with this Database). + * @private + * @param {string} file + * @returns {Promise} + */ + _loadStandalone(file) { + return new Promise((resolve, reject) => { + const db = new Loki(file, { + autoload: true, + autosave: false, + autoloadCallback: (err) => err ? reject(err) : resolve(db) + }); + }); + } +}; diff --git a/core/DatabaseHandle.js b/core/DatabaseHandle.js new file mode 100644 index 0000000..3720499 --- /dev/null +++ b/core/DatabaseHandle.js @@ -0,0 +1,102 @@ +const Loki = require('lokijs'); +const path = require('path'); +const fs = require('fs'); +const Logger = require('./Logger.js'); + +/** + * Wraps a single Loki database file and the named collections inside it. + * Created via Database.register() — modules should not instantiate directly. + */ +module.exports = class DatabaseHandle { + /** + * @param {string} name Logical name (usually the module name, or 'core') + * @param {object} opts + * @param {string} opts.file Absolute or relative path to the Loki file + * @param {string[]} [opts.collections] Collections to ensure on load + * @param {object} [opts.collectionOptions] Map of collectionName -> Loki addCollection options + * @param {object} [opts.lokiOptions] Extra options forwarded to the Loki constructor + */ + constructor(name, { file, collections = [], collectionOptions = {}, lokiOptions = {} } = {}) { + this.name = name; + this.file = file; + this.logger = new Logger(`DB:${name}`); + this._declared = new Set(collections); + this._collectionOptions = collectionOptions; + + const dir = path.dirname(file); + if (dir && dir !== '.' && !fs.existsSync(dir)) + fs.mkdirSync(dir, { recursive: true }); + + this._ready = new Promise((resolve, reject) => { + this.db = new Loki(file, { + autoload: true, + autosave: true, + autosaveInterval: 1000, + ...lokiOptions, + autoloadCallback: (err) => { + if (err) return reject(err); + for (const c of this._declared) this._ensure(c); + this.logger.verbose(`Loaded (${this._declared.size} collection${this._declared.size === 1 ? '' : 's'})`); + resolve(this); + } + }); + }); + } + + _ensure(collectionName) { + let col = this.db.getCollection(collectionName); + if (!col) col = this.db.addCollection(collectionName, this._collectionOptions[collectionName]); + return col; + } + + /** + * Resolves once the underlying Loki file has finished autoloading. + * @returns {Promise} + */ + ready() { return this._ready; } + + /** + * Get a named collection, creating it on demand if it wasn't declared upfront. + * @param {string} name + * @returns {import('lokijs').Collection} + */ + collection(name) { + if (!this._declared.has(name)) this._declared.add(name); + return this._ensure(name); + } + + /** + * Add (or expose) a collection with explicit options. + * @param {string} name + * @param {object} [options] + */ + addCollection(name, options) { + this._declared.add(name); + if (options) this._collectionOptions[name] = options; + let col = this.db.getCollection(name); + if (!col) col = this.db.addCollection(name, options); + return col; + } + + /** + * Names of every collection currently registered with this handle. + */ + listCollections() { + return this.db.listCollections().map(c => c.name); + } + + /** + * Proxy access (e.g. handle.users) — returns the named collection if it exists. + * Provided for convenience; prefer collection(name) for clarity. + */ + static proxy(handle) { + return new Proxy(handle, { + get(target, prop) { + if (prop in target) return target[prop]; + if (typeof prop === 'string' && target._declared.has(prop)) + return target._ensure(prop); + return undefined; + } + }); + } +}; diff --git a/core/ErrorHandler.js b/core/ErrorHandler.js new file mode 100644 index 0000000..449bea1 --- /dev/null +++ b/core/ErrorHandler.js @@ -0,0 +1,212 @@ +const fs = require('fs'); +const path = require('path'); +const { EmbedBuilder } = require('discord.js'); +const Logger = require('./Logger.js'); + +const DEFAULTS = { + channelId: null, + notifyOwners: false, + dedupWindowMs: 60000, + exitOnUncaught: true, + logsDir: 'logs' +}; + +/** + * Centralized error sink. Hooks process- and discord.js-level error events, + * exposes `capture(err, context)` for in-app error reporting, writes both a + * structured JSONL stream and a human-readable log file (date-rotated), + * deduplicates floods, and optionally posts a formatted embed to a Discord + * channel and/or DMs the bot's owners. + */ +module.exports = class ErrorHandler { + /** + * @param {import('..')} client + * @param {Partial} [opts] + */ + constructor(client, opts = {}) { + this.client = client; + this.config = { ...DEFAULTS, ...opts }; + this.logger = new Logger('ErrorHandler', false); + this._dedup = new Map(); + this._exiting = false; + + this._logsDir = path.resolve(process.cwd(), this.config.logsDir); + if (!fs.existsSync(this._logsDir)) fs.mkdirSync(this._logsDir, { recursive: true }); + + this._streams = { json: null, text: null, date: null }; + + this._installProcessHooks(); + this._installClientHooks(); + } + + _installProcessHooks() { + process.on('uncaughtException', (err, origin) => { + this.capture(err, { fatal: true, origin, source: 'uncaughtException' }); + if (this.config.exitOnUncaught) this._gracefulExit(1); + }); + process.on('unhandledRejection', (reason) => { + const err = reason instanceof Error ? reason : new Error(String(reason)); + this.capture(err, { fatal: true, source: 'unhandledRejection' }); + if (this.config.exitOnUncaught) this._gracefulExit(1); + }); + process.on('warning', (warning) => { + this.capture(warning, { source: 'nodeWarning', severity: 'warn' }); + }); + process.on('exit', () => { + try { this._streams.json?.end(); } catch {} + try { this._streams.text?.end(); } catch {} + }); + } + + _installClientHooks() { + this.client.on('error', (err) => this.capture(err, { source: 'discord.js' })); + this.client.on('shardError', (err, shardId) => this.capture(err, { source: 'shard', shardId })); + this.client.on('warn', (msg) => this.capture(new Error(msg), { source: 'discord.js', severity: 'warn' })); + } + + _streamsForToday() { + const date = new Date().toISOString().slice(0, 10); + if (this._streams.date !== date) { + try { this._streams.json?.end(); } catch {} + try { this._streams.text?.end(); } catch {} + this._streams = { + date, + json: fs.createWriteStream(path.join(this._logsDir, `errors-${date}.jsonl`), { flags: 'a' }), + text: fs.createWriteStream(path.join(this._logsDir, `errors-${date}.log`), { flags: 'a' }) + }; + } + return this._streams; + } + + /** + * Report an error. Returns synchronously after writing to console + files; + * the optional Discord report is fired-and-forgotten. + * @param {Error | unknown} err + * @param {object} [context] + * @param {string} [context.module] + * @param {string} [context.event] + * @param {string} [context.command] + * @param {string} [context.userId] + * @param {string} [context.guildId] + * @param {string} [context.source] + * @param {boolean} [context.fatal] + * @param {'error' | 'warn' | 'fatal'} [context.severity] + */ + capture(err, context = {}) { + const error = err instanceof Error ? err : new Error(String(err)); + const severity = context.severity || (context.fatal ? 'fatal' : 'error'); + const firstFrame = (error.stack || '').split('\n')[1] || ''; + const dedupKey = `${error.name}|${error.message}|${firstFrame}`; + const now = Date.now(); + const previous = this._dedup.get(dedupKey); + + if (previous && now - previous.timestamp < this.config.dedupWindowMs) { + previous.count = (previous.count || 1) + 1; + return; + } + const suppressedCount = previous?.count; + this._dedup.set(dedupKey, { timestamp: now }); + + const record = { + timestamp: new Date().toISOString(), + severity, + name: error.name, + message: error.message, + stack: error.stack, + ...context + }; + + const headline = this._headline(record, suppressedCount); + if (severity === 'warn') this.logger.warn(headline); + else this.logger.error(headline); + if (error.stack && severity !== 'warn') console.error(error.stack); + + try { + const { json, text } = this._streamsForToday(); + json.write(JSON.stringify(record) + '\n'); + text.write(this._formatText(record, suppressedCount) + '\n'); + } catch (writeErr) { + console.error('[ErrorHandler] Failed to write log:', writeErr); + } + + if (severity !== 'warn' && (this.config.channelId || this.config.notifyOwners)) + this._reportDiscord(record).catch(() => { /* already on disk */ }); + } + + _headline(r, suppressed) { + const parts = [r.message]; + if (r.module) parts.push(`module=${r.module}`); + if (r.command) parts.push(`command=${r.command}`); + if (r.event) parts.push(`event=${r.event}`); + if (suppressed) parts.push(`(${suppressed} prior duplicates suppressed)`); + return parts.join(' | '); + } + + _formatText(r, suppressed) { + let out = `[${r.timestamp}] [${r.severity}] ${r.name}: ${r.message}`; + const meta = []; + if (r.source) meta.push(`source=${r.source}`); + if (r.module) meta.push(`module=${r.module}`); + if (r.event) meta.push(`event=${r.event}`); + if (r.command) meta.push(`command=${r.command}`); + if (r.userId) meta.push(`user=${r.userId}`); + if (r.guildId) meta.push(`guild=${r.guildId}`); + if (suppressed) meta.push(`suppressed=${suppressed}`); + if (meta.length) out += `\n ${meta.join(' ')}`; + if (r.stack) out += `\n${r.stack}`; + return out; + } + + async _reportDiscord(record) { + if (!this.client.isReady?.()) return; + + const embed = new EmbedBuilder() + .setTitle(`${record.severity === 'fatal' ? '\u{1F525} Fatal' : '\u{26A0} Error'}: ${record.name || 'Error'}`) + .setDescription('```\n' + this._truncate(record.message || '(no message)', 1000) + '\n```') + .setColor(record.severity === 'fatal' ? 0xff3b3b : 0xe67e22) + .setTimestamp(new Date(record.timestamp)); + + const fields = []; + if (record.source) fields.push({ name: 'Source', value: String(record.source), inline: true }); + if (record.module) fields.push({ name: 'Module', value: record.module, inline: true }); + if (record.event) fields.push({ name: 'Event', value: record.event, inline: true }); + if (record.command) fields.push({ name: 'Command', value: record.command, inline: true }); + if (record.userId) fields.push({ name: 'User', value: `<@${record.userId}>`, inline: true }); + if (record.guildId) fields.push({ name: 'Guild', value: record.guildId, inline: true }); + if (fields.length) embed.addFields(fields); + + if (record.stack) + embed.addFields({ name: 'Stack', value: '```\n' + this._truncate(record.stack, 1000) + '\n```' }); + + if (this.config.channelId) { + try { + const channel = await this.client.channels.fetch(this.config.channelId); + if (channel?.isTextBased()) await channel.send({ embeds: [embed] }); + } catch { /* ignore — channel missing or no permissions */ } + } + + if (this.config.notifyOwners) { + const owners = this.client.config?.get('owners') || []; + for (const ownerId of owners) { + try { + const user = await this.client.users.fetch(ownerId); + await user.send({ embeds: [embed] }); + } catch { /* DMs closed or user unreachable */ } + } + } + } + + _truncate(s, max) { + if (typeof s !== 'string') s = String(s); + return s.length <= max ? s : s.slice(0, max - 16) + '\n…[truncated]'; + } + + _gracefulExit(code) { + if (this._exiting) return; + this._exiting = true; + this.logger.warn('Exiting due to uncaught error — process supervisor should restart the bot.'); + try { this._streams.json?.end(); } catch {} + try { this._streams.text?.end(); } catch {} + setTimeout(() => process.exit(code), 250); + } +}; diff --git a/core/LocalizationManager.js b/core/LocalizationManager.js index 45d2867..c2bb4ab 100644 --- a/core/LocalizationManager.js +++ b/core/LocalizationManager.js @@ -1,112 +1,327 @@ -const { parse } = require('yaml') -const fs = require("fs"); -const path = require("path"); +const { parse, stringify } = require('yaml'); +const fs = require('fs'); +const path = require('path'); const Logger = require('./Logger'); +const YAML_EXTS = ['.yml', '.yaml']; + +/** + * Loads module-level strings from `/modules//locales/.`. + * Per-module strings are merged under `modules.` in the language + * tree, so modules ship their translations alongside their code. + * + * Auto-sync: on boot, missing keys are added to non-reference locale files + * with the dotted path of the key as their value (so untranslated entries + * are visible to translators in the file and to end-users at runtime). + */ module.exports = class LocalizationManager { /** - * @param {{ defaultLang?: string, localesDir?: string }} options + * @param {object} [options] + * @param {string} [options.defaultLang] + * @param {string} [options.referenceLanguage] Language used as the source of truth for auto-sync. Defaults to `defaultLang`. + * @param {string} [options.modulesDir] Where to look for `/locales/`. + * @param {boolean} [options.autoSync] + * @param {boolean} [options.hotReload] */ constructor(options = {}) { - this.defaultLang = options.defaultLang || "en-GB"; - this.localesDir = options.localesDir || path.join(__dirname, "..", "locales"); + this.defaultLang = options.defaultLang || 'en-GB'; + this.referenceLanguage = options.referenceLanguage || this.defaultLang; + this.modulesDir = options.modulesDir || path.join(__dirname, '..', 'modules'); + this.autoSync = options.autoSync !== false; + this.hotReload = options.hotReload === true; + this.languages = {}; + /** Per-language file index: `{ [lang]: { [Module]: filePath } }`. */ + this._files = {}; this.logger = new Logger('i18n'); + this._watchers = []; + + this.load(); + if (this.autoSync) this.syncMissingKeys(); + this.reportCoverage(); + if (this.hotReload) this._installWatchers(); + } + + // ───────────────────────────────────────────── loading + + load() { + this.languages = {}; + this._files = {}; + + if (!fs.existsSync(this.modulesDir)) return; + const moduleDirs = fs.readdirSync(this.modulesDir, { withFileTypes: true }) + .filter(d => d.isDirectory()).map(d => d.name); + + for (const moduleName of moduleDirs) { + const dir = path.join(this.modulesDir, moduleName, 'locales'); + if (!fs.existsSync(dir)) continue; + + const files = fs.readdirSync(dir).filter(f => YAML_EXTS.includes(path.extname(f))); + for (const file of files) { + const lang = path.basename(file, path.extname(file)); + const filePath = path.join(dir, file); + const content = this._readYaml(filePath); + + if (!this.languages[lang]) this.languages[lang] = { modules: {} }; + if (!this.languages[lang].modules) this.languages[lang].modules = {}; + if (!this._files[lang]) this._files[lang] = {}; + + this._files[lang][moduleName] = filePath; + this.languages[lang].modules[moduleName] = this._deepMerge( + this.languages[lang].modules[moduleName] || {}, + content + ); + } + } + } - this.loadLocales(); + _readYaml(filePath) { + try { + const text = fs.readFileSync(filePath, 'utf8'); + return parse(text) || {}; + } catch (err) { + this.logger.error(`Failed to load ${filePath}: ${err.message}`); + return {}; + } } - loadLocales() { - if (!fs.existsSync(this.localesDir)) { - this.logger.warn(`Locales directory not found at ${this.localesDir}`); + // ───────────────────────────────────────────── auto-sync + + /** + * For each module locale file, ensure every key present in the reference + * language exists. Missing keys are inserted with their dotted path as + * the value. Existing translations are never modified. + */ + syncMissingKeys() { + const refFiles = this._files[this.referenceLanguage]; + if (!refFiles) { + this.logger.warn(`Reference language "${this.referenceLanguage}" not loaded — skipping auto-sync.`); return; } - const files = fs.readdirSync(this.localesDir).filter(f => f.endsWith(".yml") || f.endsWith(".yaml")); - - for (const file of files) { - const langCode = path.basename(file, path.extname(file)); - try { - const content = parse( - fs.readFileSync(path.join(this.localesDir, file), "utf8") + for (const lang of Object.keys(this.languages)) { + if (lang === this.referenceLanguage) continue; + this._syncLanguage(lang); + } + } + + _syncLanguage(lang) { + const refFiles = this._files[this.referenceLanguage]; + const targetFiles = this._files[lang] || (this._files[lang] = {}); + + for (const [moduleName, refPath] of Object.entries(refFiles)) { + const refTree = this._readYaml(refPath); + const targetPath = targetFiles[moduleName] + || path.join(this.modulesDir, moduleName, 'locales', `${lang}.yaml`); + const targetTree = fs.existsSync(targetPath) ? this._readYaml(targetPath) : {}; + const { merged, added } = this._fillMissing(refTree, targetTree, ''); + + if (added > 0) { + fs.writeFileSync(targetPath, stringify(merged)); + targetFiles[moduleName] = targetPath; + this.logger.info(`[${lang}] ${moduleName}: stubbed ${added} missing key(s) at ${path.relative(process.cwd(), targetPath)}`); + + if (!this.languages[lang]) this.languages[lang] = { modules: {} }; + if (!this.languages[lang].modules) this.languages[lang].modules = {}; + this.languages[lang].modules[moduleName] = this._deepMerge( + this.languages[lang].modules[moduleName] || {}, + merged ); - // parse() can return null for empty files — ensure an object - this.languages[langCode] = content || {}; - this.logger.verbose(`Loaded language ${langCode}`); - } catch (err) { - this.logger.error(`Failed to load ${file}:`, err); } } } /** - * Resolve `a.b.c` into nested object. + * Recursive walk: any leaf in `ref` not present in `target` is inserted + * with its dotted path (relative to the file root) as the value. Returns + * the resulting target tree and a count of insertions. */ + _fillMissing(ref, target, prefix) { + let added = 0; + const out = (target && typeof target === 'object' && !Array.isArray(target)) ? { ...target } : {}; + + for (const [key, refVal] of Object.entries(ref || {})) { + const dotted = prefix ? `${prefix}.${key}` : key; + if (refVal && typeof refVal === 'object' && !Array.isArray(refVal)) { + const childTarget = (out[key] && typeof out[key] === 'object' && !Array.isArray(out[key])) ? out[key] : {}; + const { merged: childMerged, added: childAdded } = this._fillMissing(refVal, childTarget, dotted); + out[key] = childMerged; + added += childAdded; + } else { + if (!(target && key in target)) { + out[key] = dotted; + added++; + } + } + } + return { merged: out, added }; + } + + // ───────────────────────────────────────────── lookup + _getKey(obj, key) { - return key.split(".").reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj); + return key.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj); } _interpolate(str, vars = {}) { - if (str === undefined || str === null) return ""; - const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + if (str === undefined || str === null) return ''; + const escapeRegExp = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return Object.entries(vars).reduce((out, [k, v]) => { - const pattern = new RegExp(`{{\\s*${escapeRegExp(k)}\\s*}}`, "g"); - // use a function replacement to avoid interpretation of `$` in replacement strings + const pattern = new RegExp(`{{\\s*${escapeRegExp(k)}\\s*}}`, 'g'); return out.replace(pattern, () => String(v)); }, String(str)); } /** - * Translate a key into the specified language, interpolating variables as needed. - * @param {string} key The translation key, e.g., "ping.reply" - * @param {string} lang The language code (e.g., "en-GB") - * @param {Object} [vars] The variables object for interpolation - * @returns {string} The translated and interpolated string + * Resolve a Discord-style locale (`en-US`, `it`, `pt-BR`, …) against the + * loaded languages. Tries exact → same base (`en-US` → first `en-*`) → + * default. Returns the resolved language code (or null if nothing fits). */ - t(key, lang, maybeVars) { - let langCode = lang || this.defaultLang; - let vars = maybeVars || {}; + resolveLanguage(requested) { + if (!requested) return this.languages[this.defaultLang] ? this.defaultLang : null; + if (this.languages[requested]) return requested; + + const base = requested.split('-')[0]; + const sibling = Object.keys(this.languages).find(l => l === base || l.split('-')[0] === base); + if (sibling) return sibling; + + return this.languages[this.defaultLang] ? this.defaultLang : null; + } + /** + * Translate a key. Returns the raw value (string or object/array — not + * JSON-stringified). For string values, `{{var}}` placeholders are + * interpolated from `vars`. For non-string values, `vars` is ignored. + * @param {string} key + * @param {string} [lang] + * @param {object} [vars] + */ + t(key, lang, vars) { + const langCode = this.resolveLanguage(lang) || this.defaultLang; const langObj = this.languages[langCode] || {}; let value = this._getKey(langObj, key); - // fallback to default language - if (value === undefined && langCode !== this.defaultLang) { - const defObj = this.languages[this.defaultLang] || {}; - value = this._getKey(defObj, key); - } + if (value === undefined && langCode !== this.defaultLang) + value = this._getKey(this.languages[this.defaultLang] || {}, key); - // last fallback: show the key if (value === undefined) { this.logger.warn(`Missing localization for key "${key}" in language "${langCode}"`); - value = key; + return key; } - if (typeof value === "string") { - return this._interpolate(value, vars); + if (typeof value === 'string') return this._interpolate(value, vars); + return value; + } + + /** + * Build a Discord-friendly localization map: `{ "en-US": "...", "it": "..." }` + * for every loaded language that defines `key`. Skips auto-sync stubs so + * Discord never receives a dotted path as a localized command name. + */ + getLocalizationObject(key) { + const out = {}; + for (const [lang, tree] of Object.entries(this.languages)) { + const value = this._getKey(tree, key); + if (value === undefined) continue; + if (typeof value !== 'string') continue; + // Skip auto-sync stubs (value is a dotted suffix of the lookup key) + if (value.includes('.') && !/\s/.test(value) && key.endsWith(value)) continue; + out[lang] = value; } + return out; + } - // If the value is an object/array, return a JSON string representation - if (typeof value === "object" && value !== null) { - try { - return JSON.stringify(value, null, 2); - } catch (e) { - this.logger.error(`Error stringifying value for key "${key}" in language "${langCode}":`, e); - return String(value); + /** + * Human-readable name for a language code, via Intl.DisplayNames. + * @param {string} code Language code (e.g., 'en-GB', 'it'). + * @param {string} [displayIn] Locale to render the name in. Defaults to the language itself (so 'it' renders as 'italiano'). + * @returns {string} + */ + languageName(code, displayIn) { + try { + const dn = new Intl.DisplayNames([displayIn || code], { type: 'language' }); + const name = dn.of(code); + if (!name || name === code) return code; + return name.charAt(0).toUpperCase() + name.slice(1); + } catch { + return code; + } + } + + // ───────────────────────────────────────────── coverage + + reportCoverage() { + const ref = this.languages[this.referenceLanguage]; + if (!ref) { + this.logger.warn(`No reference language loaded.`); + return; + } + const refLeaves = this._countLeaves(ref); + const summary = Object.keys(this.languages).map(lang => { + if (lang === this.referenceLanguage) return `${lang} (ref): ${refLeaves}`; + const tree = this.languages[lang]; + const present = this._countLeaves(tree, ref); + return `${lang}: ${present}/${refLeaves}`; + }); + this.logger.success(`Loaded ${Object.keys(this.languages).length} language(s) — ${summary.join(' | ')}`); + } + + /** Count leaf keys in `tree` that are present (any value) AND, if `ref` is provided, that exist in ref. */ + _countLeaves(tree, ref) { + const walk = (node, refNode) => { + let n = 0; + for (const [k, v] of Object.entries(node || {})) { + const refV = refNode ? refNode[k] : undefined; + if (v && typeof v === 'object' && !Array.isArray(v)) { + n += walk(v, refV && typeof refV === 'object' ? refV : undefined); + } else if (refNode === undefined || refV !== undefined) { + n++; + } } + return n; + }; + return walk(tree, ref); + } + + // ───────────────────────────────────────────── hot reload + + _installWatchers() { + if (!fs.existsSync(this.modulesDir)) return; + const watch = (dir) => { + if (!fs.existsSync(dir)) return; + try { + const w = fs.watch(dir, { persistent: false }, () => this._scheduleReload()); + this._watchers.push(w); + } catch { /* fs.watch unsupported, ignore */ } + }; + for (const d of fs.readdirSync(this.modulesDir, { withFileTypes: true })) { + if (d.isDirectory()) watch(path.join(this.modulesDir, d.name, 'locales')); } + } - return String(value); + _scheduleReload() { + clearTimeout(this._reloadTimer); + this._reloadTimer = setTimeout(() => { + this.logger.info('Locale file change detected — reloading.'); + this.load(); + if (this.autoSync) this.syncMissingKeys(); + }, 500); } - getLocalizationObject(key) { - const localizationObj = {}; - for (const [langCode, langObj] of Object.entries(this.languages)) { - const value = this._getKey(langObj, key); - if (value !== undefined) { - localizationObj[langCode] = value; + // ───────────────────────────────────────────── helpers + + _deepMerge(base, override) { + if (!base || typeof base !== 'object') return override; + if (!override || typeof override !== 'object') return base; + if (Array.isArray(override)) return override; + const out = { ...base }; + for (const [k, v] of Object.entries(override)) { + if (v && typeof v === 'object' && !Array.isArray(v) && out[k] && typeof out[k] === 'object' && !Array.isArray(out[k])) { + out[k] = this._deepMerge(out[k], v); + } else { + out[k] = v; } } - return localizationObj; + return out; } -} \ No newline at end of file +}; diff --git a/core/Module.js b/core/Module.js index 58ec8e3..56b636e 100644 --- a/core/Module.js +++ b/core/Module.js @@ -1,6 +1,5 @@ const { Collection, BaseInteraction } = require('discord.js'); const fs = require('fs'); -const { Collection: LokiCollection } = require('lokijs'); const BotClient = require('..'); const ModulePriorities = require('./ModulePriorities'); const ConfigurationManager = require('./ConfigurationManager'); @@ -9,30 +8,50 @@ const Logger = require('./Logger'); module.exports = class Module { /** - * @param {BotClient} client - * @param {} options + * @param {BotClient} client + * @param {object} options + * @param {string} [options.name] + * @param {string} [options.info] + * @param {boolean} [options.enabled] * @param {string[]} [options.events] + * @param {boolean | string[]} [options.databases] Either `true` for a single + * `default` collection in `data/.db`, or an array of collection + * names (e.g. `['guilds', 'logs']`) to declare upfront. + * @param {number} [options.priority] + * @param {string[]} [options.dependencies] + * @param {object} [options.config] + * @param {object} [options.settings] */ constructor(client, { name = this.constructor.name, info = "No description provided.", enabled = false, events = [], - usesDB = false, + databases = false, priority = ModulePriorities.NORMAL, dependencies = [], config = null, settings = null }) { this.client = client; - this.options = { name, info, enabled, events, priority, usesDB, dependencies, settings}; - + + const declaredCollections = Array.isArray(databases) + ? [...databases] + : (databases ? ['default'] : []); + + this.options = { + name, info, enabled, events, priority, + databases, + collections: declaredCollections, + dependencies, settings + }; + this.commands = new Collection(); this.logger = new Logger(this.options.name); - if(config) + if (config) this.config = new ConfigurationManager(this, config); - if(settings) + if (settings) this.settings = new SettingsManager(client, this, settings); } @@ -46,21 +65,23 @@ module.exports = class Module { if (interactionOrLang instanceof BaseInteraction) { const interaction = interactionOrLang; - - const utility = this.client.moduleManager.modules.get("Utility")?.settings; - const guildLang = interaction.guild ? utility?.get(interaction.guild.id)?.settings?.defaultServerLanguage : null; - let lang = this.client.i18n.defaultLang; + const i18n = this.client.i18n; + const utility = this.client.moduleManager.modules.get("Utility")?.settings; + const guildLang = interaction.guild + ? utility?.get(interaction.guild.id)?.settings?.defaultServerLanguage + : null; const userData = this.client.database.forceUser(interaction.user.id); - if (userData && userData.language && this.client.i18n.languages[userData.language]) - lang = userData.language; - else if (interaction.locale && this.client.i18n.languages[interaction.locale]) - lang = interaction.locale; - else if (guildLang && this.client.i18n.languages[guildLang]) - lang = guildLang; + // Resolution order: user-forced → guild default → Discord interaction locale → bot default. + let lang = i18n.defaultLang; + const candidates = [userData?.language, guildLang, interaction.locale]; + for (const candidate of candidates) { + const resolved = candidate && i18n.resolveLanguage(candidate); + if (resolved && i18n.languages[resolved]) { lang = resolved; break; } + } - return this.client.i18n.t(key, lang, vars); + return i18n.t(key, lang, vars); } else { const lang = interactionOrLang || this.client.i18n.defaultLang; return this.client.i18n.t(key, lang, vars); @@ -102,23 +123,30 @@ module.exports = class Module { } /** - * @type {LokiCollection} + * The module's database handle. `null` if the module didn't opt in via + * the `databases` option. Use `this.db.collection(name)` to access a + * specific collection, or the convenience proxy (e.g. `this.db.guilds`) + * for collections declared at construction time. + * @type {import('./DatabaseHandle') | null} */ get db() { - if (!this.options.usesDB) - return null; - - return this.client.database.db[`module_${this.options.name}`]; + if (this.options.collections.length === 0) return null; + return this.client.database.get(this.options.name) || null; } - saveData(data) { + /** + * Insert-or-update a row into one of the module's collections. + * @param {string} collectionName + * @param {object} data + */ + saveData(collectionName, data) { if (!this.db) - throw new Error("You must use usesDB: true to use this method.") + throw new Error("You must declare `databases` in module options to use this method."); if (!data) - throw new Error("You must pass a valid argument to data.") - - if (data.$loki) - this.db.update(data); - else this.db.add(data); + throw new Error("You must pass a valid argument to data."); + + const collection = this.db.collection(collectionName); + if (data.$loki) collection.update(data); + else collection.insert(data); } } diff --git a/core/ModuleManager.js b/core/ModuleManager.js index 1658fa4..426ac52 100644 --- a/core/ModuleManager.js +++ b/core/ModuleManager.js @@ -27,7 +27,6 @@ module.exports = class ModuleManager { this.load(moduleName); } }); - this.client.database.reconfigure(); this.logger.success(`Successfully Loaded ${this.modules.size} modules`) } @@ -50,14 +49,14 @@ module.exports = class ModuleManager { } if (_module.options.enabled) _module.loadCommands() - if (_module.options.usesDB) { - this.client.database.db[`module_${_module.options.name}`] = this.client.database.db.addCollection(`module_${_module.options.name}`); - this.client.database.collections.push(`module_${_module.options.name}`); - } - if (_module.options.settings) { + + const collections = [..._module.options.collections]; + if (_module.options.settings) collections.push('settings'); + if (collections.length > 0) + this.client.database.register(_module.options.name, { collections }); + + if (_module.options.settings) _module.settings = new SettingsManager(this.client, _module, _module.options.settings); - this.client.database.collections.push(`settings_${_module.options.name}`); - } this.add(_module) } catch (error) { @@ -78,8 +77,12 @@ module.exports = class ModuleManager { for (let [_name, module] of new Map([...this.modules.entries()].sort((a, b) => b[1].options.priority - a[1].options.priority))) { if (module.options.enabled && module.options.events.includes(event)) { let execution; - - execution = await module.run(this.client, event, ...args); + try { + execution = await module.run(this.client, event, ...args); + } catch (err) { + this.client.errorHandler?.capture(err, { module: _name, event }); + continue; + } if (execution?.cancelEvent) break; diff --git a/core/SettingsManager.js b/core/SettingsManager.js index 4587c30..2eb3c04 100644 --- a/core/SettingsManager.js +++ b/core/SettingsManager.js @@ -1,20 +1,29 @@ module.exports = class SettingsManager { /** - * Instantiates a settings manager for a specific module + * Instantiates a settings manager for a specific module. Settings live in + * the `settings` collection of the module's own database handle. * @param {import('..')} client The client to use for the settings manager * @param {import('./Module')} module The module to instantiate the settings manager for * @param {object} defaultSettings The default settings for the module */ constructor(client, module, defaultSettings = {}) { this.client = client; - this.dbName = `settings_${module.options.name}`; this.module = module; this.defaultSettings = defaultSettings; this._cache = new Map(); - + client.settings.set(module.options.name, this); } + /** + * @returns {import('lokijs').Collection} + */ + get collection() { + const handle = this.client.database.get(this.module.options.name) + || this.client.database.register(this.module.options.name, { collections: ['settings'] }); + return handle.collection('settings'); + } + /** * Internal method to cache settings for a guild * @param {String} guildID The ID of the guild to cache the settings for @@ -32,16 +41,15 @@ module.exports = class SettingsManager { const cached = this._cache.get(guildID); if (cached) return cached; - let guildData = this.client.database.db[this.dbName].findOne({ id: guildID }); - if (!guildData) - guildData = this.client.database.db[this.dbName].insert({ id: guildID, settings: this.defaultSettings }); + let guildData = this.collection.findOne({ id: guildID }); + if (!guildData) + guildData = this.collection.insert({ id: guildID, settings: this.defaultSettings }); this._cache.set(guildID, guildData); return guildData; } /** - * * @param {String} guildID The ID of the guild to set the settings for * @param {String} key The key to set the value for * @param {String} value The value to set to the key @@ -49,35 +57,33 @@ module.exports = class SettingsManager { set(guildID, key, value) { const guildData = this.get(guildID); guildData.settings[key] = value; - this.client.database.db[this.dbName].update(guildData); + this.collection.update(guildData); this._cache.set(guildID, guildData); } /** - * * @param {String} guildID The guild ID to add the settings for * @param {String} key Key should represent an array * @param {String} value The value to add from the array */ add(guildID, key, value) { const guildData = this.get(guildID); - if(!Array.isArray(guildData.settings[key])) throw new Error("Not an array."); + if (!Array.isArray(guildData.settings[key])) throw new Error("Not an array."); guildData.settings[key].push(value); - this.client.database.db[this.dbName].update(guildData); + this.collection.update(guildData); this._cache.set(guildID, guildData); } /** - * * @param {String} guildID The guild ID to remove the settings for - * @param {String} key Key should represent an array + * @param {String} key Key should represent an array * @param {String} value The value to remove from the array */ remove(guildID, key, value) { const guildData = this.get(guildID); - if(!Array.isArray(guildData.settings[key])) throw new Error("Not an array."); + if (!Array.isArray(guildData.settings[key])) throw new Error("Not an array."); guildData.settings[key] = guildData.settings[key].filter(item => item !== value); - this.client.database.db[this.dbName].update(guildData); + this.collection.update(guildData); this._cache.set(guildID, guildData); } @@ -89,7 +95,7 @@ module.exports = class SettingsManager { reset(guildID, key) { const guildData = this.get(guildID); guildData.settings[key] = this.defaultSettings[key]; - this.client.database.db[this.dbName].update(guildData); + this.collection.update(guildData); this._cache.set(guildID, guildData); } @@ -100,7 +106,7 @@ module.exports = class SettingsManager { factoryReset(guildID) { const guildData = this.get(guildID); guildData.settings = this.defaultSettings; - this.client.database.db[this.dbName].update(guildData); + this.collection.update(guildData); this._cache.set(guildID, guildData); } } \ No newline at end of file diff --git a/index.js b/index.js index d9f03c0..f22f4f3 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,7 @@ const Database = require('./core/Database.js'); const ConfigurationManager = require('./core/ConfigurationManager.js'); const Utils = require('./core/Utils.js'); const LocalizationManager = require('./core/LocalizationManager.js'); +const ErrorHandler = require('./core/ErrorHandler.js'); BigInt.prototype.toJSON = function () { return this.toString() } // MDN https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt#use_within_json // Discord @@ -18,7 +19,19 @@ class BotClient extends Client { owners: ["311929179186790400", "422418878459674624"], systemServer: ["1313550337474429001"], intents: Object.keys(GatewayIntentBits).filter(i => isNaN(i)), - partials: ['Reaction', 'Message'] + partials: ['Reaction', 'Message'], + errorReporting: { + channelId: null, + notifyOwners: false, + dedupWindowMs: 60000, + exitOnUncaught: true + }, + i18n: { + defaultLang: 'en-GB', + referenceLanguage: 'en-GB', + autoSync: true, + hotReload: false + } }); super({ intents: config.get('intents').map(i => GatewayIntentBits[i]), @@ -30,8 +43,9 @@ class BotClient extends Client { this.utils = new Utils(); this.moduleManager = new ModuleManager(this); this.config = config; - this.i18n = new LocalizationManager(); + this.i18n = new LocalizationManager(config.get('i18n')); + this.errorHandler = new ErrorHandler(this, config.get('errorReporting')); this.database = new Database(this); this.moduleManager.init(); } diff --git a/locales/en-GB.yaml b/locales/en-GB.yaml deleted file mode 100644 index 98d6cae..0000000 --- a/locales/en-GB.yaml +++ /dev/null @@ -1,170 +0,0 @@ -name: 'English' -modules: - System: - commands: - eval: - name: eval - description: Execute arbitrary JavaScript code. - options: - code: The JavaScript code to execute. - exec: - name: exec - description: Execute a system command. - options: - command: The system command to execute. - modman: - name: modman - description: Manage modules (load, unload, reload). - options: - load: - name: load - description: Loads a module - options: - module: - name: module - description: Module to perform the action on - unload: - name: unload - description: Unloads a module - options: - module: - name: module - description: Module to perform the action on - reload: - name: reload - description: Reloads a module - options: - module: - name: module - description: Module to perform the action on - enable: - name: enable - description: Enable a module - options: - module: - name: module - description: Module to perform the action on - disable: - name: disable - description: Disable a module - options: - module: - name: module - description: Module to perform the action on - info: - name: info - description: Get information about a module - options: - module: - name: module - description: Module to perform the action on - list: - name: list - description: Get a list of loaded modules - reboot: - name: reboot - description: Reboots the bot if running under PM2 or a process manager. - messages: - rebooting: ":hourglass_flowing_sand: Rebooting..." - setlevel: - name: setlevel - description: Set the power level of a user. - messages: - nopermission: ":x: You can't set this user's powerlevel." - success: ":white_check_mark: Successfully set powerlevel for {{user}} to: {{level}}" - error: ":x: An internal error occurred" - update: - name: update - description: Update the bot to the latest version from the Git repository, and reboots the bot - messages: - noreboot: "Unknown command `reboot`, aborting." - Utility: - commands: - perms: - name: perms - description: Check the permissions of a user or role in the current channel. - options: - user: The user to check permissions for. - messages: - nouserindb: ":warning: There's no user in database matching your query" - ping: - name: ping - description: Check the bot's latency and responsiveness. - messages: - pinging: "Pinging..." - pong: ":ping_pong: Pong! Latency is **{{latency}}ms**. API Latency is **{{apiLatency}}ms**" - settings: - name: settings - description: Manage the current server settings - options: - view: - name: view - description: View current settings - set: - name: set - description: Set a setting - options: - module: - name: module - description: The module to view settings for - key: - name: key - description: The setting to view - value: - name: value - description: The value to filter settings by - reset: - name: reset - description: Reset a setting to default - options: - module: - name: module - description: The module to view settings for - key: - name: key - description: The setting to view - value: - name: value - description: The value to filter settings by - embeds: - set: - title: "Setting Updated" - add: - title: "Setting Added" - remove: - title: "Setting Removed" - view: - settings: "Current Settings" - nosettings: - title: "No Settings Found" - description: "There are no settings configured for this module." - page: "Page {{page}} of {{totalPages}}" - reset: - title: "Setting Reset" - description: "The setting has been reset to its default value." - stats: - name: stats - description: View bot statistics and information. - embed: - title: "STATISTICS" - memusage: "⚙️ Memory Usage" - uptime: "⏱️ Uptime" - versions: "🔢 Versions" - creationdate: "🎂 Creation date" - setlang: - name: setlang - description: Set your preferred language for the bot - options: - language: - name: language - description: The language you want to set - messages: - invalidLang: ":warning: The specified language is not supported." - success: ":flag_gb: Your preferred language has been set to **English**!" - reset: ":globe_with_meridians: From now on your language will be synchronized to the default language!" - - InteractionCommandHandler: - errors: - command-not-found: ":no_entry: Command not found" - insufficient-powerlevel: ":no_entry: You don't have permission to use this command. The required permission level is" - execution-error: ":no_entry: Uh-oh, there was an error trying to execute the command, please contact bot developers." \ No newline at end of file diff --git a/locales/it.yaml b/locales/it.yaml deleted file mode 100644 index 4d5d95c..0000000 --- a/locales/it.yaml +++ /dev/null @@ -1,170 +0,0 @@ -name: 'Italiano' -modules: - System: - commands: - eval: - name: eval - description: Esegue codice JavaScript arbitrario. - options: - code: Il codice JavaScript da eseguire. - exec: - name: exec - description: Esegue un comando di sistema. - options: - command: Il comando di sistema da eseguire. - modman: - name: modman - description: Gestisci i moduli (carica, scarica, ricarica). - options: - load: - name: load - description: Carica un modulo - options: - module: - name: module - description: Modulo su cui eseguire l’azione - unload: - name: unload - description: Scarica un modulo - options: - module: - name: module - description: Modulo su cui eseguire l’azione - reload: - name: reload - description: Ricarica un modulo - options: - module: - name: module - description: Modulo su cui eseguire l’azione - enable: - name: enable - description: Abilita un modulo - options: - module: - name: module - description: Modulo su cui eseguire l’azione - disable: - name: disable - description: Disabilita un modulo - options: - module: - name: module - description: Modulo su cui eseguire l’azione - info: - name: info - description: Ottieni informazioni su un modulo - options: - module: - name: module - description: Modulo su cui eseguire l’azione - list: - name: list - description: Ottieni la lista dei moduli caricati - reboot: - name: reboot - description: Riavvia il bot se in esecuzione tramite PM2 o un process manager. - messages: - rebooting: ":hourglass_flowing_sand: Riavvio in corso..." - setlevel: - name: setlevel - description: Imposta il power level di un utente. - messages: - nopermission: ":x: Non puoi impostare il power level di questo utente." - success: ":white_check_mark: Power level di {{user}} impostato a: {{level}}" - error: ":x: Si è verificato un errore interno" - update: - name: update - description: Aggiorna il bot all’ultima versione dal repository Git e riavvia il bot - messages: - noreboot: "Comando `reboot` sconosciuto, annullo l'operazione." - - Utility: - commands: - perms: - name: perms - description: Controlla i permessi di un utente o ruolo nel canale corrente. - options: - user: L’utente di cui controllare i permessi. - messages: - nouserindb: ":warning: Nessun utente nel database corrisponde alla tua ricerca" - ping: - name: ping - description: Controlla la latenza e responsività del bot. - messages: - pinging: "Pinging..." - pong: ":ping_pong: Pong! La latenza è **{{latency}}ms**. La latenza API è **{{apiLatency}}ms**" - settings: - name: settings - description: Gestisci le impostazioni del server corrente - options: - view: - name: view - description: Visualizza le impostazioni correnti - set: - name: set - description: Imposta un valore - options: - module: - name: module - description: Il modulo di cui visualizzare le impostazioni - key: - name: key - description: L’impostazione da visualizzare - value: - name: value - description: Il valore per filtrare le impostazioni - reset: - name: reset - description: Ripristina un’impostazione al valore predefinito - options: - module: - name: module - description: Il modulo di cui visualizzare le impostazioni - key: - name: key - description: L’impostazione da ripristinare - value: - name: value - description: Il valore per filtrare le impostazioni - embeds: - set: - title: "Impostazione Aggiornata" - add: - title: "Impostazione Aggiunta" - remove: - title: "Impostazione Rimossa" - view: - settings: "Impostazioni Correnti" - nosettings: - title: "Nessuna Impostazione Trovata" - description: "Non ci sono impostazioni configurate per questo modulo." - page: "Pagina {{page}} di {{totalPages}}" - reset: - title: "Impostazione Ripristinata" - description: "L’impostazione è stata ripristinata al valore predefinito." - stats: - name: stats - description: Visualizza statistiche e informazioni sul bot. - embed: - title: "STATISTICHE" - memusage: "⚙️ Utilizzo Memoria" - uptime: "⏱️ Tempo di attività" - versions: "🔢 Versioni" - creationdate: "🎂 Data di creazione" - setlang: - name: setlang - description: Imposta la tua lingua preferita per il bot - options: - language: - name: language - description: La lingua che vuoi impostare - messages: - invalidLang: ":warning: La lingua specificata non è supportata." - success: ":flag_it: La tua lingua preferita è stata impostata su **Italiano**!" - reset: ":globe_with_meridians: D'ora in poi la tua lingua sarà sincronizzata con la lingua predefinita!" - InteractionCommandHandler: - errors: - command-not-found: ":no_entry: Comando non trovato" - insufficient-powerlevel: ":no_entry: Non hai i permessi per usare questo comando. Il livello richiesto è" - execution-error: ":no_entry: Ops, si è verificato un errore durante l'esecuzione del comando. Contatta gli sviluppatori del bot." \ No newline at end of file diff --git a/modules/InteractionCommandHandler/InteractionCommandHandler.js b/modules/InteractionCommandHandler/InteractionCommandHandler.js index d4d43c7..b6a580c 100644 --- a/modules/InteractionCommandHandler/InteractionCommandHandler.js +++ b/modules/InteractionCommandHandler/InteractionCommandHandler.js @@ -20,8 +20,12 @@ module.exports = class InteractionCommandHandler extends Module { * @param {BotClient} client */ async clientReady(client) { - client.application.commands - .set(client.moduleManager.commands.filter(c => c.module.options.name !== "System").map(c => c.toJson())) + try { + await client.application.commands + .set(client.moduleManager.commands.filter(c => c.module.options.name !== "System").map(c => c.toJson())); + } catch (err) { + client.errorHandler?.capture(err, { source: 'commandRegistration', module: this.options.name }); + } } /** @@ -33,23 +37,57 @@ module.exports = class InteractionCommandHandler extends Module { if (!interaction.isCommand() && !interaction.isContextMenuCommand()) return; interaction.user.data = await client.database.forceUser(interaction.user.id); + let cmd, cmdModule; try { - /** @type {[Command, Module]} */ - let [cmd, module] = this.client.moduleManager.getCommand(interaction.commandName); - if (!cmd) return interaction.reply({ content: ":no_entry: Command not found", flags: [Discord.MessageFlags.Ephemeral] }); + [cmd, cmdModule] = this.client.moduleManager.getCommand(interaction.commandName); + if (!cmd) { + await this._safeReply(interaction, { content: ":no_entry: Command not found", flags: [Discord.MessageFlags.Ephemeral] }); + return { cancelEvent: true }; + } - if (interaction.user.data.powerlevel < cmd.config.minLevel) - return interaction.reply({ content: `:no_entry: You don't have permission to use this command. The required permission level is ${Object.keys(PowerLevels).find(k => PowerLevels[k] == cmd.config.minLevel)}`, flags: [Discord.MessageFlags.Ephemeral] }); + if (interaction.user.data.powerlevel < cmd.config.minLevel) { + await this._safeReply(interaction, { + content: `:no_entry: You don't have permission to use this command. The required permission level is ${Object.keys(PowerLevels).find(k => PowerLevels[k] == cmd.config.minLevel)}`, + flags: [Discord.MessageFlags.Ephemeral] + }); + return { cancelEvent: true }; + } - await cmd.run(client, interaction, module); + await cmd.run(client, interaction, cmdModule); } catch (e) { - interaction.reply({ + client.errorHandler?.capture(e, { + module: cmdModule?.options?.name, + command: cmd?.config?.name || interaction.commandName, + userId: interaction.user?.id, + guildId: interaction.guildId || undefined + }); + await this._safeReply(interaction, { content: ":no_entry: Uh-oh, there was an error trying to execute the command, please contact bot developers.", flags: [Discord.MessageFlags.Ephemeral] - }) - this.logger.error(e.stack || e) + }); } return { cancelEvent: true }; } + + /** + * Best-effort interaction reply that won't throw if the interaction was + * already replied/deferred or has expired. + * @param {Discord.Interaction} interaction + * @param {Discord.InteractionReplyOptions} payload + */ + async _safeReply(interaction, payload) { + try { + if (interaction.deferred || interaction.replied) + return await interaction.followUp(payload); + return await interaction.reply(payload); + } catch (replyErr) { + this.client.errorHandler?.capture(replyErr, { + source: 'safeReply', + command: interaction.commandName, + userId: interaction.user?.id, + severity: 'warn' + }); + } + } } diff --git a/modules/InteractionCommandHandler/locales/en-GB.yaml b/modules/InteractionCommandHandler/locales/en-GB.yaml new file mode 100644 index 0000000..1a41f99 --- /dev/null +++ b/modules/InteractionCommandHandler/locales/en-GB.yaml @@ -0,0 +1,4 @@ +errors: + command-not-found: ":no_entry: Command not found" + insufficient-powerlevel: ":no_entry: You don't have permission to use this command. The required permission level is" + execution-error: ":no_entry: Uh-oh, there was an error trying to execute the command, please contact bot developers." diff --git a/modules/InteractionCommandHandler/locales/it.yaml b/modules/InteractionCommandHandler/locales/it.yaml new file mode 100644 index 0000000..6435df3 --- /dev/null +++ b/modules/InteractionCommandHandler/locales/it.yaml @@ -0,0 +1,4 @@ +errors: + command-not-found: ":no_entry: Comando non trovato" + insufficient-powerlevel: ":no_entry: Non hai i permessi per usare questo comando. Il livello richiesto è" + execution-error: ":no_entry: Ops, si è verificato un errore durante l'esecuzione del comando. Contatta gli sviluppatori del bot." diff --git a/modules/ReadyLog/ReadyLog.js b/modules/ReadyLog/ReadyLog.js index 58372da..d59ad8b 100644 --- a/modules/ReadyLog/ReadyLog.js +++ b/modules/ReadyLog/ReadyLog.js @@ -24,17 +24,5 @@ module.exports = class ReadyLog extends Module { this.logger.info(`System Server: ${client.config.get('systemServer').join(", ")}`); this.logger.info(`Owners: ${client.config.get('owners').join(", ")}`); this.logger.info(`===========================`); - - // If the bot got rebooted with reboot command, this will edit the message once ready - try { - const { id, channel } = require("./../reboot.json"); - let c = client.channels.cache.get(channel); - await c.messages.fetch(); - let m = c.messages.cache.get(id); - await m.edit(":white_check_mark: Rebooted. It took " + ((Date.now() - m.createdTimestamp) / 1000).toFixed(1) + "ms"); - fs.unlink("./reboot.json", () => { }); - } catch (e) { - // pass - } } } diff --git a/modules/System/System.js b/modules/System/System.js index 8530639..9a42236 100644 --- a/modules/System/System.js +++ b/modules/System/System.js @@ -39,8 +39,19 @@ module.exports = class System extends Module { } } - this.commands = this.systemCommands; + + // If the bot got rebooted with reboot command, this will edit the message once ready + try { + const { id, channel } = require("./../reboot.json"); + let c = client.channels.cache.get(channel); + await c.messages.fetch(); + let m = c.messages.cache.get(id); + await m.edit(":white_check_mark: Rebooted. It took " + ((Date.now() - m.createdTimestamp) / 1000).toFixed(1) + "ms"); + fs.unlink("./reboot.json", () => { }); + } catch (e) { + // pass + } } /** diff --git a/modules/System/locales/en-GB.yaml b/modules/System/locales/en-GB.yaml new file mode 100644 index 0000000..9c80d88 --- /dev/null +++ b/modules/System/locales/en-GB.yaml @@ -0,0 +1,77 @@ +commands: + eval: + name: eval + description: Execute arbitrary JavaScript code. + options: + code: The JavaScript code to execute. + exec: + name: exec + description: Execute a system command. + options: + command: The system command to execute. + modman: + name: modman + description: Manage modules (load, unload, reload). + options: + load: + name: load + description: Loads a module + options: + module: + name: module + description: Module to perform the action on + unload: + name: unload + description: Unloads a module + options: + module: + name: module + description: Module to perform the action on + reload: + name: reload + description: Reloads a module + options: + module: + name: module + description: Module to perform the action on + enable: + name: enable + description: Enable a module + options: + module: + name: module + description: Module to perform the action on + disable: + name: disable + description: Disable a module + options: + module: + name: module + description: Module to perform the action on + info: + name: info + description: Get information about a module + options: + module: + name: module + description: Module to perform the action on + list: + name: list + description: Get a list of loaded modules + reboot: + name: reboot + description: Reboots the bot if running under PM2 or a process manager. + messages: + rebooting: ":hourglass_flowing_sand: Rebooting..." + setlevel: + name: setlevel + description: Set the power level of a user. + messages: + nopermission: ":x: You can't set this user's powerlevel." + success: ":white_check_mark: Successfully set powerlevel for {{user}} to: {{level}}" + error: ":x: An internal error occurred" + update: + name: update + description: Update the bot to the latest version from the Git repository, and reboots the bot + messages: + noreboot: "Unknown command `reboot`, aborting." diff --git a/modules/System/locales/it.yaml b/modules/System/locales/it.yaml new file mode 100644 index 0000000..84f652c --- /dev/null +++ b/modules/System/locales/it.yaml @@ -0,0 +1,77 @@ +commands: + eval: + name: eval + description: Esegue codice JavaScript arbitrario. + options: + code: Il codice JavaScript da eseguire. + exec: + name: exec + description: Esegue un comando di sistema. + options: + command: Il comando di sistema da eseguire. + modman: + name: modman + description: Gestisci i moduli (carica, scarica, ricarica). + options: + load: + name: load + description: Carica un modulo + options: + module: + name: module + description: Modulo su cui eseguire l’azione + unload: + name: unload + description: Scarica un modulo + options: + module: + name: module + description: Modulo su cui eseguire l’azione + reload: + name: reload + description: Ricarica un modulo + options: + module: + name: module + description: Modulo su cui eseguire l’azione + enable: + name: enable + description: Abilita un modulo + options: + module: + name: module + description: Modulo su cui eseguire l’azione + disable: + name: disable + description: Disabilita un modulo + options: + module: + name: module + description: Modulo su cui eseguire l’azione + info: + name: info + description: Ottieni informazioni su un modulo + options: + module: + name: module + description: Modulo su cui eseguire l’azione + list: + name: list + description: Ottieni la lista dei moduli caricati + reboot: + name: reboot + description: Riavvia il bot se in esecuzione tramite PM2 o un process manager. + messages: + rebooting: ":hourglass_flowing_sand: Riavvio in corso..." + setlevel: + name: setlevel + description: Imposta il power level di un utente. + messages: + nopermission: ":x: Non puoi impostare il power level di questo utente." + success: ":white_check_mark: Power level di {{user}} impostato a: {{level}}" + error: ":x: Si è verificato un errore interno" + update: + name: update + description: Aggiorna il bot all’ultima versione dal repository Git e riavvia il bot + messages: + noreboot: "Comando `reboot` sconosciuto, annullo l'operazione." diff --git a/modules/Utility/Utility.js b/modules/Utility/Utility.js index 8a47e79..81f9183 100644 --- a/modules/Utility/Utility.js +++ b/modules/Utility/Utility.js @@ -25,7 +25,10 @@ module.exports = class Utility extends Module { if (!command) return; if (interaction.commandName == "setlang") { - return interaction.respond(Object.keys(client.i18n.languages || {}).concat(['default']).map(lang => ({ name: client.i18n.languages[lang]?.name || 'Default', value: lang }))); + return interaction.respond(Object.keys(client.i18n.languages || {}).concat(['default']).map(lang => ({ + name: lang === 'default' ? 'Default' : client.i18n.languageName(lang), + value: lang + }))); } if (interaction.commandName == "settings") { switch (interaction.options.getFocused(true).name) { diff --git a/modules/Utility/commands/ping.js b/modules/Utility/commands/ping.js index 8458ede..5644586 100644 --- a/modules/Utility/commands/ping.js +++ b/modules/Utility/commands/ping.js @@ -15,7 +15,8 @@ module.exports = class PingCommand extends Command { * @param {import('discord.js').CommandInteraction} interaction */ async run(client, interaction) { - let m = await interaction.reply({ content: this.t('messages.pinging', interaction), withResponse: true }); - interaction.editReply(this.t('messages.pong', interaction, { latency: m.createdTimestamp - interaction.createdTimestamp, apiLatency: Math.round(client.ws.ping) })); + const response = await interaction.reply({ content: this.t('messages.pinging', interaction), withResponse: true }); + const message = response.resource.message; + interaction.editReply(this.t('messages.pong', interaction, { latency: message.createdTimestamp - interaction.createdTimestamp, apiLatency: Math.round(client.ws.ping) })); } } diff --git a/modules/Utility/locales/en-GB.yaml b/modules/Utility/locales/en-GB.yaml new file mode 100644 index 0000000..83f16df --- /dev/null +++ b/modules/Utility/locales/en-GB.yaml @@ -0,0 +1,83 @@ +commands: + perms: + name: perms + description: Check the permissions of a user or role in the current channel. + options: + user: The user to check permissions for. + messages: + nouserindb: ":warning: There's no user in database matching your query" + ping: + name: ping + description: Check the bot's latency and responsiveness. + messages: + pinging: "Pinging..." + pong: ":ping_pong: Pong! Latency is **{{latency}}ms**. API Latency is **{{apiLatency}}ms**" + settings: + name: settings + description: Manage the current server settings + options: + view: + name: view + description: View current settings + set: + name: set + description: Set a setting + options: + module: + name: module + description: The module to view settings for + key: + name: key + description: The setting to view + value: + name: value + description: The value to filter settings by + reset: + name: reset + description: Reset a setting to default + options: + module: + name: module + description: The module to view settings for + key: + name: key + description: The setting to view + value: + name: value + description: The value to filter settings by + embeds: + set: + title: "Setting Updated" + add: + title: "Setting Added" + remove: + title: "Setting Removed" + view: + settings: "Current Settings" + nosettings: + title: "No Settings Found" + description: "There are no settings configured for this module." + page: "Page {{page}} of {{totalPages}}" + reset: + title: "Setting Reset" + description: "The setting has been reset to its default value." + stats: + name: stats + description: View bot statistics and information. + embed: + title: "STATISTICS" + memusage: "⚙️ Memory Usage" + uptime: "⏱️ Uptime" + versions: "🔢 Versions" + creationdate: "🎂 Creation date" + setlang: + name: setlang + description: Set your preferred language for the bot + options: + language: + name: language + description: The language you want to set + messages: + invalidLang: ":warning: The specified language is not supported." + success: ":flag_gb: Your preferred language has been set to **English**!" + reset: ":globe_with_meridians: From now on your language will be synchronized to the default language!" diff --git a/modules/Utility/locales/it.yaml b/modules/Utility/locales/it.yaml new file mode 100644 index 0000000..62a5264 --- /dev/null +++ b/modules/Utility/locales/it.yaml @@ -0,0 +1,83 @@ +commands: + perms: + name: perms + description: Controlla i permessi di un utente o ruolo nel canale corrente. + options: + user: L’utente di cui controllare i permessi. + messages: + nouserindb: ":warning: Nessun utente nel database corrisponde alla tua ricerca" + ping: + name: ping + description: Controlla la latenza e responsività del bot. + messages: + pinging: "Pinging..." + pong: ":ping_pong: Pong! La latenza è **{{latency}}ms**. La latenza API è **{{apiLatency}}ms**" + settings: + name: settings + description: Gestisci le impostazioni del server corrente + options: + view: + name: view + description: Visualizza le impostazioni correnti + set: + name: set + description: Imposta un valore + options: + module: + name: module + description: Il modulo di cui visualizzare le impostazioni + key: + name: key + description: L’impostazione da visualizzare + value: + name: value + description: Il valore per filtrare le impostazioni + reset: + name: reset + description: Ripristina un’impostazione al valore predefinito + options: + module: + name: module + description: Il modulo di cui visualizzare le impostazioni + key: + name: key + description: L’impostazione da ripristinare + value: + name: value + description: Il valore per filtrare le impostazioni + embeds: + set: + title: "Impostazione Aggiornata" + add: + title: "Impostazione Aggiunta" + remove: + title: "Impostazione Rimossa" + view: + settings: "Impostazioni Correnti" + nosettings: + title: "Nessuna Impostazione Trovata" + description: "Non ci sono impostazioni configurate per questo modulo." + page: "Pagina {{page}} di {{totalPages}}" + reset: + title: "Impostazione Ripristinata" + description: "L’impostazione è stata ripristinata al valore predefinito." + stats: + name: stats + description: Visualizza statistiche e informazioni sul bot. + embed: + title: "STATISTICHE" + memusage: "⚙️ Utilizzo Memoria" + uptime: "⏱️ Tempo di attività" + versions: "🔢 Versioni" + creationdate: "🎂 Data di creazione" + setlang: + name: setlang + description: Imposta la tua lingua preferita per il bot + options: + language: + name: language + description: La lingua che vuoi impostare + messages: + invalidLang: ":warning: La lingua specificata non è supportata." + success: ":flag_it: La tua lingua preferita è stata impostata su **Italiano**!" + reset: ":globe_with_meridians: D'ora in poi la tua lingua sarà sincronizzata con la lingua predefinita!" diff --git a/package-lock.json b/package-lock.json index 57df469..960c069 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,34 +7,34 @@ "": { "name": "modulardiscordbot", "version": "2.0", - "license": "ISC", + "license": "GNU", "dependencies": { "chalk": "^4.1.2", "common-tags": "^1.8.2", - "discord.js": "^14.25.1", - "dotenv": "^16.4.7", + "discord.js": "^14.26.3", + "dotenv": "^16.6.1", "fs": "^0.0.1-security", "lokijs": "^1.5.12", "module-alias": "^2.3.4", "moment": "^2.30.1", "moment-duration-format": "^2.3.2", - "pagination.djs": "^4.0.16", - "yaml": "^2.3.1" + "pagination.djs": "^4.0.18", + "yaml": "^2.8.3" }, "engines": { "node": ">=18" } }, "node_modules/@discordjs/builders": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.13.1.tgz", - "integrity": "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", "license": "Apache-2.0", "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", - "discord-api-types": "^0.38.33", + "discord-api-types": "^0.38.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -71,20 +71,20 @@ } }, "node_modules/@discordjs/rest": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.0.tgz", - "integrity": "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.6.1.tgz", + "integrity": "sha512-wwQdgjeaoYFiaG+atbqx6aJDpqW7JHAo0HrQkBTbYzM3/PJ3GweQIpgElNcGZ26DCUOXMyawYd0YF7vtr+fZXg==", "license": "Apache-2.0", "dependencies": { "@discordjs/collection": "^2.1.1", - "@discordjs/util": "^1.1.1", + "@discordjs/util": "^1.2.0", "@sapphire/async-queue": "^1.5.3", - "@sapphire/snowflake": "^3.5.3", + "@sapphire/snowflake": "^3.5.5", "@vladfrangu/async_event_emitter": "^2.4.6", - "discord-api-types": "^0.38.16", - "magic-bytes.js": "^1.10.0", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -105,6 +105,16 @@ "url": "https://github.com/discordjs/discord.js?sponsor" } }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, "node_modules/@discordjs/util": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", @@ -275,33 +285,33 @@ } }, "node_modules/discord-api-types": { - "version": "0.38.38", - "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.38.tgz", - "integrity": "sha512-7qcM5IeZrfb+LXW07HvoI5L+j4PQeMZXEkSm1htHAHh4Y9JSMXBWjy/r7zmUCOj4F7zNjMcm7IMWr131MT2h0Q==", + "version": "0.38.47", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.47.tgz", + "integrity": "sha512-XgXQodHQBAE6kfD7kMvVo30863iHX1LHSqNq6MGUTDwIFCCvHva13+rwxyxVXDqudyApMNAd32PGjgVETi5rjA==", "license": "MIT", "workspaces": [ "scripts/actions/documentation" ] }, "node_modules/discord.js": { - "version": "14.25.1", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.25.1.tgz", - "integrity": "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g==", + "version": "14.26.3", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.3.tgz", + "integrity": "sha512-XEKtYn28YFsiJ5l4fLRyikdbo6RD5oFyqfVHQlvXz2104JhH/E8slN28dbky05w3DCrJcNVWvhVvcJCTSl/KIg==", "license": "Apache-2.0", "dependencies": { - "@discordjs/builders": "^1.13.0", + "@discordjs/builders": "^1.14.1", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", - "@discordjs/rest": "^2.6.0", + "@discordjs/rest": "^2.6.1", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", - "discord-api-types": "^0.38.33", + "discord-api-types": "^0.38.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", - "magic-bytes.js": "^1.10.0", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.3" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -311,9 +321,9 @@ } }, "node_modules/dotenv": { - "version": "16.4.7", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", - "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -344,9 +354,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.snakecase": { @@ -389,12 +399,12 @@ "license": "MIT" }, "node_modules/pagination.djs": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/pagination.djs/-/pagination.djs-4.0.17.tgz", - "integrity": "sha512-8P6BfnBupUKe4fqXYW/bwL+gpIAM2+rPymlgQoNzJuY6RNx+OSEvLmo1b25yUZW2AJhxuxXTeOANREsFOdhOQQ==", + "version": "4.0.18", + "resolved": "https://registry.npmjs.org/pagination.djs/-/pagination.djs-4.0.18.tgz", + "integrity": "sha512-FlKjdUFEA/UKne5Lt4ohcM2F59d5+AYkFdvPI+zpSAPl8n2ujeCNWwQGzOvpY45tMv+GQDc6w2d1Rl7O/UIHzg==", "license": "MIT", "peerDependencies": { - "discord.js": "^14.1.2" + "discord.js": "^14.18.0" } }, "node_modules/supports-color": { @@ -422,9 +432,9 @@ "license": "0BSD" }, "node_modules/undici": { - "version": "6.21.3", - "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.3.tgz", - "integrity": "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw==", + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.24.1.tgz", + "integrity": "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA==", "license": "MIT", "engines": { "node": ">=18.17" @@ -458,15 +468,18 @@ } }, "node_modules/yaml": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.7.0.tgz", - "integrity": "sha512-+hSoy/QHluxmC9kCIJyL/uyFmLmc+e5CFR5Wa+bpIhIj85LVb9ZH2nVnqrHoSvKogwODv0ClqZkmiSSaIH5LTA==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", "license": "ISC", "bin": { "yaml": "bin.mjs" }, "engines": { - "node": ">= 14" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } } } diff --git a/package.json b/package.json index d5275a1..cc1a567 100644 --- a/package.json +++ b/package.json @@ -18,15 +18,15 @@ "dependencies": { "chalk": "^4.1.2", "common-tags": "^1.8.2", - "discord.js": "^14.25.1", - "dotenv": "^16.4.7", + "discord.js": "^14.26.3", + "dotenv": "^16.6.1", "fs": "^0.0.1-security", "lokijs": "^1.5.12", "module-alias": "^2.3.4", "moment": "^2.30.1", "moment-duration-format": "^2.3.2", - "pagination.djs": "^4.0.16", - "yaml": "^2.3.1" + "pagination.djs": "^4.0.18", + "yaml": "^2.8.3" }, "_moduleAliases": { "@core": "core", From af4c02aab0391481e80495fe7b3663fcf3a35e97 Mon Sep 17 00:00:00 2001 From: Miky88 <44903677+Miky88@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:46:20 +0200 Subject: [PATCH 6/8] Add per-guild permissions; rewrite SettingsManager schema-driven MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Permissions: new core/Permissions.js — per-guild custom level ladder (member/helper/moderator/admin seeded by default; admins can rename, reweight, add custom levels, and delete any of them). Roles bind to levels; a member's effective weight is the max across overlapping bindings. User-, command-, and setting-level overrides supported. Resolver short-circuits on bot OWNER, guild owner, and Discord Administrator. Override-driven by design: modules don't ship gates, the resolver only enforces when an admin has set an override. - SettingsManager rewrite: schema-driven, no backward compat. Each key declares { type, default, description?, validate? }. Built-in types: string/boolean/number/integer/snowflake (channel/role/user)/array /enum:a|b|c. String inputs from slash commands are coerced before validation. Auth via Permissions.check on each mutation when an actor is supplied. - /permissions command replaced with an in-Discord GUI panel (modules/Utility/lib/PermissionsUI.js): single ephemeral message with StringSelect navigation + RoleSelect/UserSelect pickers + modals for forms. Custom-id convention `perms:[:]` carries state. Each section has inline documentation explaining what it manages and how the resolver chain composes. - /settings rewritten to use the schema, validates per-key, defers to Permissions for setting overrides. - Command.requires field removed — was a module-side default that duplicated Discord's defaultMemberPermissions; admins delegate via /permissions overrides instead. - Drop circular `require('../index.js')` in core/Module.js and core/Database.js (was JSDoc-only but executed index.js side effects, blocking standalone loading of core files). - Strip box-drawing section divider comments across core/ and modules/Utility/lib (44 lines removed). - CHANGELOG.md: document this change set + the prior ac6e616 commit. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 93 +++ core/Command.js | 2 +- core/Database.js | 3 +- core/LocalizationManager.js | 12 - core/Module.js | 3 +- core/Permissions.js | 268 ++++++++ core/SettingsManager.js | 345 ++++++++--- index.js | 2 + .../InteractionCommandHandler.js | 14 + modules/Utility/Utility.js | 50 +- modules/Utility/commands/permissions.js | 26 + modules/Utility/commands/settings.js | 252 ++++---- modules/Utility/lib/PermissionsUI.js | 576 ++++++++++++++++++ modules/Utility/locales/en-GB.yaml | 7 +- modules/Utility/locales/it.yaml | 39 +- 15 files changed, 1415 insertions(+), 277 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 core/Permissions.js create mode 100644 modules/Utility/commands/permissions.js create mode 100644 modules/Utility/lib/PermissionsUI.js diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8c3f39a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,93 @@ +# Changelog + +All notable changes to this project are documented here. +Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); +this file starts at the point where the database, error handling and i18n +were refactored — earlier history lives in `git log`. + +## [Unreleased] + +### Added +- **Per-guild custom permission level system** ([`core/Permissions.js`](core/Permissions.js)). + - Built-in level IDs (`member`, `helper`, `moderator`, `admin`) seeded into every guild on first read. Built-ins are immutable (cannot be deleted) so module gates referencing them never dangle. + - Admins can rename and reweight any level, and add fully custom levels (any ID, any weight) — letting them insert e.g. `senior_mod` between `moderator` and `admin`. + - Roles bind to levels at the level (`level.roles: [roleId, …]`); a role can belong to multiple levels and the resolver takes max weight. + - User overrides (`userOverrides[userId] = levelId`) and per-guild gate overrides (`commandOverrides[name]`, `settingOverrides['Module.key']`) for fine-grained admin control. + - Resolver short-circuits on bot OWNER PowerLevel, guild owner, and Discord `Administrator` perm. + - Storage: dedicated `Permissions` Loki handle with a single `guilds` collection. +- **Schema-driven `SettingsManager`** ([`core/SettingsManager.js`](core/SettingsManager.js)). + - Each setting key declares `{ type, default, description?, validate? }`. Built-in types: `string`, `boolean`, `number`, `integer`, `channel` / `role` / `user` / `snowflake`, `array`, and `enum:a|b|c`. + - String inputs from slash commands are coerced (`"true"` → `true`, mention syntax stripped from snowflakes, etc.) before validation. + - Custom per-key `validate` functions can return `true` / `false` / a string error message. + - Per-guild settings record auto-backfills any new keys added in later schema versions. +- **`/permissions` slash command** ([`modules/Utility/commands/permissions.js`](modules/Utility/commands/permissions.js)) for guild admins: + - `view` — show the level ladder with bound roles and active overrides. + - `level create | edit | delete` — manage the level ladder (built-ins refuse deletion). + - `role bind | unbind` — attach roles to levels. + - `user` — set or clear a user-specific level override. + - `override command | setting` — re-gate a specific command or `Module.key` to a different level in this guild. + - Autocomplete for level IDs, command names, and `Module.key` setting paths. + +### Changed +- **`Permissions.check()` is now override-driven, not requirement-driven.** Modules don't ship custom-level defaults; the resolver only enforces a gate when an admin has set an override (`commandOverrides[]` / `settingOverrides[]`). Discord's `defaultMemberPermissions` remains the baseline for command visibility/usage. Signature: `check(member, { commandName?, settingKey? })`. +- **`/settings` command rewritten** ([`modules/Utility/commands/settings.js`](modules/Utility/commands/settings.js)): + - Goes through the new schema-driven API. + - Passes `actor: interaction.member` so per-guild setting overrides are enforced. + - View embed shows each key's value and declared type. + - Restored `defaultMemberPermissions: [ManageGuild]` as the baseline visibility gate. +- **`InteractionCommandHandler`** ([`modules/InteractionCommandHandler/InteractionCommandHandler.js`](modules/InteractionCommandHandler/InteractionCommandHandler.js)) checks `client.permissions.check(member, { commandName })` for every guild command — only enforces when an admin has set an override. +- **Utility module's `defaultServerLanguage`** declared in the new schema shape. + +### Removed +- **`Command.requires` field.** Previously let modules declare a guild-level requirement (e.g. `requires: 'moderator'`); replaced by Discord-native default + admin-applied per-guild overrides. +- **Per-key `requires` in setting schemas.** Same reasoning — modules don't ship gates of their own. +- Stale `BotClient` runtime-`require` references in `core/Database.js` and `core/Module.js` (they were JSDoc-only but executed `require('../index.js')`, blocking standalone loading of those files outside `node index.js`). + +--- + +## [2026-04-30] — Database, error handling, and i18n refactor + +Commit [`ac6e616`](https://github.com/) (`Refactor database, errors, and i18n; fix ping; tighten config`). + +### Added +- **`core/DatabaseHandle.js`** — wraps a single Loki file plus the named collections inside it. Async `ready()` for autoload, `collection(name)` / `addCollection(name, opts)` for access, mkdir-recursive on the data dir. +- **`core/ErrorHandler.js`** — central error sink: + - Hooks `process.on('uncaughtException')`, `unhandledRejection`, `warning`, plus discord.js `error` / `shardError` / `warn`. + - `capture(err, context)` API for explicit reporting from anywhere in the bot. + - Writes `logs/errors-YYYY-MM-DD.log` (human) and `logs/errors-YYYY-MM-DD.jsonl` (machine) via async write streams with date rotation. + - 60s deduplication window — repeated identical errors collapse with a suppressed-count tag rather than flooding logs. + - Optional Discord-side reporting via `config.errorReporting.channelId` and/or `notifyOwners`. Posts a properly formatted, color-coded embed; silently no-ops if neither is configured. + - `exitOnUncaught: true` by default — flushes logs and exits with code 1 so a process supervisor (Docker `restart: always`, pm2, systemd) can bring the bot back to a clean state. +- **`Database.migrate({ source, dryRun, removeOriginal })`** — one-shot helper that pulls legacy `module_` and `settings_` collections out of the monolithic `database.db` and into the new per-module files. Reuses the open core handle when source overlaps to avoid double-open races. +- **`languageName(code, displayIn?)`** on the i18n manager — uses `Intl.DisplayNames` to render any locale code in any locale. Replaces the prior reliance on a `name:` field at the top of each locale file. +- **i18n auto-sync.** On every boot, missing keys in non-reference locale files are stubbed with their dotted path as the value, so translators can grep for untranslated entries and end users see a clear placeholder until a translation lands. +- **i18n locale fallback chain.** `resolveLanguage()` does exact match → same-base match (`en-US` → first `en-*`) → default. Discord's `en-US` interaction locale now resolves to `en-GB` translations cleanly. +- **i18n optional hot reload** via `fs.watch` on per-module `locales/` dirs (off by default; enable with `i18n.hotReload: true`). +- **i18n boot-time coverage report** (`Loaded 2 language(s) — en-GB (ref): 100 | it: 100/100`). +- **`config.yml` blocks** for `errorReporting` and `i18n`. ConfigurationManager now persists newly-added defaults to disk on startup so existing installations pick them up. + +### Changed +- **`core/Database.js` is now a registry** of `DatabaseHandle`s. The bot ships a built-in `core` handle (still backed by `database.db`, housing the `users` collection) plus one handle per module that opts in. Modules declare their per-module file via the `databases` option (`true` for a single `default` collection or `['guilds', 'logs', …]` for named collections). +- **`Module.js` `databases` option** replaces the old `usesDB`. `module.db` returns the `DatabaseHandle`, so `this.db.collection('guilds').insert(…)` (or the convenience proxy `this.db.guilds`) is the new shape. `saveData(collection, data)` updated accordingly. +- **`ModuleManager`** stops poking into `client.database.db` directly — registers each module's handle through `database.register(name, …)`. +- **`SettingsManager`** routes through the module's `DatabaseHandle` (collection `settings`) rather than reaching into `client.database.db[settings_]`. +- **`LocalizationManager` rewritten.** Loads strictly from `modules//locales/.`. Per-module strings merge into the language tree under `modules.`. The global `/locales/` directory was deleted entirely — there's no bot-level string file anymore. +- **All previously-global module strings migrated** into their module's `locales/` directory: + - `modules/System/locales/` + - `modules/Utility/locales/` + - `modules/InteractionCommandHandler/locales/` +- **`ConfigurationManager`** tracks an explicit `mutated` flag so newly-added defaults actually persist when present in the schema but missing from the user's `config.yml`. Previously a key-count comparison made the rewrite a no-op for additions. +- **i18n object values are no longer JSON-stringified.** `t()` returns objects/arrays as-is for callers that want them; `getLocalizationObject()` continues to return string maps for Discord command localizations and now skips auto-sync stubs (so Discord never receives a dotted path as a localized command name). +- **`Module.t()` language resolution** order matches its own comment: user-forced → guild default → Discord interaction locale → bot default. + +### Fixed +- **`/ping`** broke under discord.js v14.26+ because `interaction.reply({ withResponse: true })` returns an `InteractionCallbackResponse`, not the message — pulled the message off `response.resource.message` instead. +- **Pre-existing `ConfigurationManager` bug** where missing-but-defaulted keys were merged into memory but never written back to disk. + +### Removed +- **`Module.usesDB`** option — replaced by `databases`. +- **The global `/locales/` directory** (and its `en-GB.yaml` / `it.yaml`). +- **`Database.reconfigure()`** and the dual-tracked `Database.collections` array — both became dead code in the registry model. + +### Infrastructure +- `.gitignore`: added `data/` (per-module Loki files) and `logs/` (ErrorHandler output). diff --git a/core/Command.js b/core/Command.js index ecfcc10..730f44c 100644 --- a/core/Command.js +++ b/core/Command.js @@ -10,7 +10,7 @@ module.exports = class Command { type = ApplicationCommandType.ChatInput, cooldown = 0, minLevel = PowerLevels.USER, - defaultMemberPermissions = null, // Array + defaultMemberPermissions = null, // Array — Discord-native default visibility/usage gate guildOnly = false, moduleName = 'Unspecified' }) { diff --git a/core/Database.js b/core/Database.js index 774dd5e..0053f42 100644 --- a/core/Database.js +++ b/core/Database.js @@ -1,7 +1,6 @@ const Loki = require('lokijs'); const path = require('path'); const fs = require('fs'); -const BotClient = require('../index.js'); const Logger = require('./Logger.js'); const PowerLevels = require('./PowerLevels.js'); const DatabaseHandle = require('./DatabaseHandle.js'); @@ -16,7 +15,7 @@ module.exports = class Database { * Registry of database handles. The bot ships a single "core" handle backed * by `database.db` (housing the built-in `users` collection); each module * that opts in gets its own handle backed by `data/.db`. - * @param {BotClient} client + * @param {import('..')} client */ constructor(client) { this.client = client; diff --git a/core/LocalizationManager.js b/core/LocalizationManager.js index c2bb4ab..89d7eb4 100644 --- a/core/LocalizationManager.js +++ b/core/LocalizationManager.js @@ -42,8 +42,6 @@ module.exports = class LocalizationManager { if (this.hotReload) this._installWatchers(); } - // ───────────────────────────────────────────── loading - load() { this.languages = {}; this._files = {}; @@ -85,8 +83,6 @@ module.exports = class LocalizationManager { } } - // ───────────────────────────────────────────── auto-sync - /** * For each module locale file, ensure every key present in the reference * language exists. Missing keys are inserted with their dotted path as @@ -157,8 +153,6 @@ module.exports = class LocalizationManager { return { merged: out, added }; } - // ───────────────────────────────────────────── lookup - _getKey(obj, key) { return key.split('.').reduce((o, k) => (o && o[k] !== undefined ? o[k] : undefined), obj); } @@ -248,8 +242,6 @@ module.exports = class LocalizationManager { } } - // ───────────────────────────────────────────── coverage - reportCoverage() { const ref = this.languages[this.referenceLanguage]; if (!ref) { @@ -283,8 +275,6 @@ module.exports = class LocalizationManager { return walk(tree, ref); } - // ───────────────────────────────────────────── hot reload - _installWatchers() { if (!fs.existsSync(this.modulesDir)) return; const watch = (dir) => { @@ -308,8 +298,6 @@ module.exports = class LocalizationManager { }, 500); } - // ───────────────────────────────────────────── helpers - _deepMerge(base, override) { if (!base || typeof base !== 'object') return override; if (!override || typeof override !== 'object') return base; diff --git a/core/Module.js b/core/Module.js index 56b636e..329990b 100644 --- a/core/Module.js +++ b/core/Module.js @@ -1,6 +1,5 @@ const { Collection, BaseInteraction } = require('discord.js'); const fs = require('fs'); -const BotClient = require('..'); const ModulePriorities = require('./ModulePriorities'); const ConfigurationManager = require('./ConfigurationManager'); const SettingsManager = require('./SettingsManager'); @@ -8,7 +7,7 @@ const Logger = require('./Logger'); module.exports = class Module { /** - * @param {BotClient} client + * @param {import('..')} client * @param {object} options * @param {string} [options.name] * @param {string} [options.info] diff --git a/core/Permissions.js b/core/Permissions.js new file mode 100644 index 0000000..3db2606 --- /dev/null +++ b/core/Permissions.js @@ -0,0 +1,268 @@ +const Logger = require('./Logger.js'); +const PowerLevels = require('./PowerLevels.js'); +const { PermissionsBitField } = require('discord.js'); + +/** + * Default level ladder seeded into every guild on first read. The `builtin` + * tag is informational only (the UI marks which levels shipped by default vs + * which were admin-created); admins can rename, reweight, and delete any + * level they want. There is no auto-backfill once a guild record exists. + */ +const BUILTIN_LEVELS = Object.freeze([ + { id: 'member', name: 'Member', weight: 0, builtin: true, roles: [] }, + { id: 'helper', name: 'Helper', weight: 1, builtin: true, roles: [] }, + { id: 'moderator', name: 'Moderator', weight: 5, builtin: true, roles: [] }, + { id: 'admin', name: 'Admin', weight: 10, builtin: true, roles: [] } +]); + +/** + * Per-guild custom level / role-permission system. + * + * Levels are admin-editable: rename, reweight, add custom levels, bind roles. + * Built-in level IDs (member/helper/moderator/admin) are immutable so module + * gates referencing them never dangle. Modules declare `requires: 'moderator'` + * (a level ID) on commands and per setting keys; this manager evaluates the + * requirement at runtime against the member's roles in the current guild. + * + * Resolver order (granted on first hit): + * 1. Bot OWNER PowerLevel (global escape hatch from /setlevel) + * 2. Guild owner + * 3. Discord Administrator native permission + * 4. Member's effective weight (max over bound role weights and userOverride) + * ≥ required level's weight + */ +module.exports = class PermissionsManager { + /** + * @param {import('..')} client + */ + constructor(client) { + this.client = client; + this.logger = new Logger('Permissions'); + this.handle = client.database.register('Permissions', { collections: ['guilds'] }); + } + + static get BUILTIN_LEVELS() { return BUILTIN_LEVELS; } + + _collection() { return this.handle.collection('guilds'); } + + /** + * Get the full permission config for a guild, seeding builtin defaults the + * first time it's accessed. + * @param {string} guildId + * @returns {object} + */ + getConfig(guildId) { + const col = this._collection(); + let record = col.findOne({ id: guildId }); + if (!record) { + record = col.insert({ + id: guildId, + levels: BUILTIN_LEVELS.map(l => ({ ...l, roles: [] })), + userOverrides: {}, + commandOverrides: {}, + settingOverrides: {} + }); + } + return record; + } + + _save(record) { + this._collection().update(record); + return record; + } + + /** + * Look up a level definition in a guild by ID. + * @returns {{id:string,name:string,weight:number,builtin?:boolean,roles:string[]} | null} + */ + getLevel(guildId, levelId) { + const cfg = this.getConfig(guildId); + return cfg.levels.find(l => l.id === levelId) || null; + } + + /** + * Create or update a level. For builtin levels only `name`, `weight`, and + * `roles` may be changed; the `id` and `builtin` flag are immutable. + * @param {string} guildId + * @param {object} level + * @param {string} level.id + * @param {string} [level.name] + * @param {number} [level.weight] + * @param {string[]} [level.roles] + * @returns {object} The saved level. + */ + setLevel(guildId, level) { + if (!level || !level.id) throw new Error('Level must have an id.'); + const cfg = this.getConfig(guildId); + const existing = cfg.levels.find(l => l.id === level.id); + + if (existing) { + if (typeof level.name === 'string') existing.name = level.name; + if (typeof level.weight === 'number') existing.weight = level.weight; + if (Array.isArray(level.roles)) existing.roles = [...new Set(level.roles)]; + } else { + cfg.levels.push({ + id: level.id, + name: level.name || level.id, + weight: typeof level.weight === 'number' ? level.weight : 0, + builtin: false, + roles: Array.isArray(level.roles) ? [...new Set(level.roles)] : [] + }); + } + this._save(cfg); + return cfg.levels.find(l => l.id === level.id); + } + + /** + * Delete a level (built-in or custom). Strips any overrides referencing it + * so they don't dangle as orphans. + * @returns {boolean} true if deleted. + */ + deleteLevel(guildId, levelId) { + const cfg = this.getConfig(guildId); + const target = cfg.levels.find(l => l.id === levelId); + if (!target) return false; + + cfg.levels = cfg.levels.filter(l => l.id !== levelId); + // Strip references in overrides (orphan IDs would silently deny). + for (const [uid, lid] of Object.entries(cfg.userOverrides)) + if (lid === levelId) delete cfg.userOverrides[uid]; + for (const [cmd, lid] of Object.entries(cfg.commandOverrides)) + if (lid === levelId) delete cfg.commandOverrides[cmd]; + for (const [k, lid] of Object.entries(cfg.settingOverrides)) + if (lid === levelId) delete cfg.settingOverrides[k]; + + this._save(cfg); + return true; + } + + /** + * Add a role to a level (idempotent). A role can belong to multiple levels; + * the resolver takes max weight across all matching bindings. + */ + bindRole(guildId, levelId, roleId) { + const cfg = this.getConfig(guildId); + const level = cfg.levels.find(l => l.id === levelId); + if (!level) throw new Error(`Unknown level "${levelId}".`); + if (!level.roles.includes(roleId)) level.roles.push(roleId); + this._save(cfg); + return level; + } + + unbindRole(guildId, levelId, roleId) { + const cfg = this.getConfig(guildId); + const level = cfg.levels.find(l => l.id === levelId); + if (!level) throw new Error(`Unknown level "${levelId}".`); + level.roles = level.roles.filter(r => r !== roleId); + this._save(cfg); + return level; + } + + setUserOverride(guildId, userId, levelId) { + const cfg = this.getConfig(guildId); + if (levelId == null) delete cfg.userOverrides[userId]; + else { + if (!cfg.levels.find(l => l.id === levelId)) + throw new Error(`Unknown level "${levelId}".`); + cfg.userOverrides[userId] = levelId; + } + this._save(cfg); + return cfg.userOverrides; + } + + setCommandOverride(guildId, commandName, levelId) { + const cfg = this.getConfig(guildId); + if (levelId == null) delete cfg.commandOverrides[commandName]; + else { + if (!cfg.levels.find(l => l.id === levelId)) + throw new Error(`Unknown level "${levelId}".`); + cfg.commandOverrides[commandName] = levelId; + } + this._save(cfg); + return cfg.commandOverrides; + } + + setSettingOverride(guildId, settingKey, levelId) { + const cfg = this.getConfig(guildId); + if (levelId == null) delete cfg.settingOverrides[settingKey]; + else { + if (!cfg.levels.find(l => l.id === levelId)) + throw new Error(`Unknown level "${levelId}".`); + cfg.settingOverrides[settingKey] = levelId; + } + this._save(cfg); + return cfg.settingOverrides; + } + + /** + * Compute the member's effective level in their guild from role bindings + * and userOverride. Returns the level object or null if no binding matches. + * @param {import('discord.js').GuildMember} member + */ + effectiveLevel(member) { + if (!member?.guild) return null; + const cfg = this.getConfig(member.guild.id); + + const override = cfg.userOverrides[member.id]; + if (override) { + const lvl = cfg.levels.find(l => l.id === override); + if (lvl) return lvl; + } + + const memberRoleIds = new Set(member.roles?.cache?.keys?.() || []); + let best = null; + for (const level of cfg.levels) { + if (level.roles.some(r => memberRoleIds.has(r))) { + if (!best || level.weight > best.weight) best = level; + } + } + return best; + } + + /** + * Override-driven authorization for a command or setting key. Returns true + * when no admin-set override applies — modules don't ship gates of their + * own; they rely on Discord-native `defaultMemberPermissions` (for + * commands) or the gating of the surrounding command (for settings). + * + * @param {import('discord.js').GuildMember} member + * @param {object} target + * @param {string} [target.commandName] Slash command name to check `commandOverrides[]` for. + * @param {string} [target.settingKey] `Module.key` to check `settingOverrides[]` for. + */ + check(member, target = {}) { + if (!member?.guild) return false; + + // 1. Bot OWNER escape hatch. + const userData = this.client.database.getUser?.(member.id); + if (userData?.powerlevel === PowerLevels.OWNER) return true; + + // 2. Guild owner. + if (member.guild.ownerId === member.id) return true; + + // 3. Discord Administrator perm. + if (member.permissions?.has?.(PermissionsBitField.Flags.Administrator)) return true; + + // 4. Resolve admin-set override for this target. + const cfg = this.getConfig(member.guild.id); + let requiredLevelId = null; + if (target.commandName && cfg.commandOverrides[target.commandName]) + requiredLevelId = cfg.commandOverrides[target.commandName]; + if (target.settingKey && cfg.settingOverrides[target.settingKey]) + requiredLevelId = cfg.settingOverrides[target.settingKey]; + + // No override set → no custom-level gate; defer to Discord-native / + // surrounding-command authorization that already let the call through. + if (!requiredLevelId) return true; + + const requiredLevel = cfg.levels.find(l => l.id === requiredLevelId); + if (!requiredLevel) { + this.logger.warn(`Guild ${member.guild.id} references unknown level "${requiredLevelId}" — denying.`); + return false; + } + + const effective = this.effectiveLevel(member); + if (!effective) return requiredLevel.weight === 0; + return effective.weight >= requiredLevel.weight; + } +}; diff --git a/core/SettingsManager.js b/core/SettingsManager.js index 2eb3c04..2ae7208 100644 --- a/core/SettingsManager.js +++ b/core/SettingsManager.js @@ -1,112 +1,307 @@ +const Logger = require('./Logger.js'); + +const SNOWFLAKE = /^\d{17,20}$/; + +/** + * Type validators / coercers. Each entry is a function that accepts the raw + * input (often a string from a slash command), returns + * `{ ok: true, value: }` on success or + * `{ ok: false, error: }` on failure. For container types, the + * helper `mkValidator(spec)` parses the type spec. + */ +const TYPES = { + string: (v) => ({ ok: true, value: String(v) }), + boolean: (v) => { + if (typeof v === 'boolean') return { ok: true, value: v }; + const s = String(v).toLowerCase(); + if (['true', '1', 'yes', 'on'].includes(s)) return { ok: true, value: true }; + if (['false', '0', 'no', 'off'].includes(s)) return { ok: true, value: false }; + return { ok: false, error: `expected boolean, got "${v}"` }; + }, + number: (v) => { + const n = typeof v === 'number' ? v : Number(v); + if (!Number.isFinite(n)) return { ok: false, error: `expected number, got "${v}"` }; + return { ok: true, value: n }; + }, + integer: (v) => { + const n = typeof v === 'number' ? v : Number(v); + if (!Number.isInteger(n)) return { ok: false, error: `expected integer, got "${v}"` }; + return { ok: true, value: n }; + }, + snowflake: (v) => { + const s = String(v).replace(/[<#@&!>]/g, ''); + if (!SNOWFLAKE.test(s)) return { ok: false, error: `expected Discord ID, got "${v}"` }; + return { ok: true, value: s }; + } +}; +TYPES.channel = TYPES.snowflake; +TYPES.role = TYPES.snowflake; +TYPES.user = TYPES.snowflake; + +/** + * Parse a type spec (e.g. `array`, `enum:foo|bar|baz`) into a + * validator function `(value) -> { ok, value | error }`. + */ +function mkValidator(spec) { + if (typeof spec === 'function') return spec; + + const arrMatch = String(spec).match(/^array<(.+)>$/); + if (arrMatch) { + const inner = mkValidator(arrMatch[1]); + return (v) => { + const arr = Array.isArray(v) ? v : [v]; + const out = []; + for (const item of arr) { + const r = inner(item); + if (!r.ok) return r; + out.push(r.value); + } + return { ok: true, value: out }; + }; + } + + if (String(spec).startsWith('enum:')) { + const choices = String(spec).slice(5).split('|'); + return (v) => { + const s = String(v); + if (!choices.includes(s)) return { ok: false, error: `expected one of ${choices.join(', ')}` }; + return { ok: true, value: s }; + }; + } + + const t = TYPES[spec]; + if (!t) throw new Error(`Unknown setting type: "${spec}"`); + return t; +} + +/** + * Schema-driven per-guild settings store. Modules declare a schema: + * + * settings: { + * welcomeChannel: { type: 'channel', default: null, description: '…' }, + * autoMod: { type: 'boolean', default: false, description: '…' }, + * bannedWords: { type: 'array', default: [], description: '…' }, + * prefix: { type: 'string', default: '!', description: '…', validate: v => v.length <= 3 }, + * } + * + * Storage shape: one record per guild — `{ id: guildId, settings: { key: value } }`. + * Live in the `settings` collection of the module's database handle. Module-side + * gating is intentionally absent: the `/settings` command itself is gated by + * Discord-native permissions, and admins delegate per-key access by setting + * `settingOverrides` via `/permissions override setting`. + */ module.exports = class SettingsManager { /** - * Instantiates a settings manager for a specific module. Settings live in - * the `settings` collection of the module's own database handle. - * @param {import('..')} client The client to use for the settings manager - * @param {import('./Module')} module The module to instantiate the settings manager for - * @param {object} defaultSettings The default settings for the module + * @param {import('..')} client + * @param {import('./Module')} module + * @param {object} schema Map of `key -> { type, default, description?, validate? }`. */ - constructor(client, module, defaultSettings = {}) { + constructor(client, module, schema = {}) { this.client = client; this.module = module; - this.defaultSettings = defaultSettings; - this._cache = new Map(); + this.logger = new Logger(`Settings:${module.options.name}`); + + this._schema = {}; + for (const [key, def] of Object.entries(schema)) { + if (!def || typeof def !== 'object' || !('type' in def)) + throw new Error(`Setting "${module.options.name}.${key}" is missing a type.`); + this._schema[key] = { + type: def.type, + default: 'default' in def ? def.default : null, + description: def.description || '', + validate: def.validate || null, + _validator: mkValidator(def.type) + }; + } + this._cache = new Map(); client.settings.set(module.options.name, this); } - /** - * @returns {import('lokijs').Collection} - */ - get collection() { - const handle = this.client.database.get(this.module.options.name) - || this.client.database.register(this.module.options.name, { collections: ['settings'] }); - return handle.collection('settings'); + get schema() { + const out = {}; + for (const [key, def] of Object.entries(this._schema)) { + out[key] = { + type: def.type, + default: def.default, + description: def.description + }; + } + return out; } - /** - * Internal method to cache settings for a guild - * @param {String} guildID The ID of the guild to cache the settings for - * @param {Object} settings The settings to cache - */ - cache(guildID, settings) { - this._cache.set(guildID, settings); + keys() { return Object.keys(this._schema); } + + has(key) { return key in this._schema; } + + /** Default values for every key in the schema. */ + defaults() { + const out = {}; + for (const [key, def] of Object.entries(this._schema)) out[key] = this._cloneDefault(def.default); + return out; + } + + _cloneDefault(v) { + if (v == null) return v; + if (Array.isArray(v)) return [...v]; + if (typeof v === 'object') return { ...v }; + return v; + } + + _collection() { + const handle = this.client.database.get(this.module.options.name); + if (!handle) throw new Error(`Module "${this.module.options.name}" has no database handle — declare \`databases\` or \`settings\` in module options.`); + return handle.collection('settings'); } /** - * @param {String} guildID The ID of the guild to get the settings for and create and cache ones that don't exist - * @returns {Object} The settings for the specified guild + * Get the full settings record for a guild. Missing keys are filled with + * defaults — but only in the returned object, not persisted. + * @returns {{ id: string, settings: object }} */ - get(guildID) { - const cached = this._cache.get(guildID); + get(guildId) { + const cached = this._cache.get(guildId); if (cached) return cached; - let guildData = this.collection.findOne({ id: guildID }); - if (!guildData) - guildData = this.collection.insert({ id: guildID, settings: this.defaultSettings }); + const col = this._collection(); + let record = col.findOne({ id: guildId }); + if (!record) { + record = col.insert({ id: guildId, settings: this.defaults() }); + } else { + // Backfill keys added in newer schema versions. + let mutated = false; + for (const [key, def] of Object.entries(this._schema)) { + if (!(key in record.settings)) { + record.settings[key] = this._cloneDefault(def.default); + mutated = true; + } + } + if (mutated) col.update(record); + } - this._cache.set(guildID, guildData); - return guildData; + this._cache.set(guildId, record); + return record; } - /** - * @param {String} guildID The ID of the guild to set the settings for - * @param {String} key The key to set the value for - * @param {String} value The value to set to the key - */ - set(guildID, key, value) { - const guildData = this.get(guildID); - guildData.settings[key] = value; - this.collection.update(guildData); - this._cache.set(guildID, guildData); + /** Convenience: just the value of a single key (or default). */ + getKey(guildId, key) { + if (!this.has(key)) throw new Error(`Unknown setting "${key}".`); + return this.get(guildId).settings[key]; } /** - * @param {String} guildID The guild ID to add the settings for - * @param {String} key Key should represent an array - * @param {String} value The value to add from the array + * Validate a value against a key's declared type. Returns coerced value or + * throws with an error message. */ - add(guildID, key, value) { - const guildData = this.get(guildID); - if (!Array.isArray(guildData.settings[key])) throw new Error("Not an array."); - guildData.settings[key].push(value); - this.collection.update(guildData); - this._cache.set(guildID, guildData); + _validate(key, value) { + const def = this._schema[key]; + if (!def) throw new Error(`Unknown setting "${key}".`); + const result = def._validator(value); + if (!result.ok) throw new Error(`Invalid value for "${key}": ${result.error}`); + if (def.validate) { + const ok = def.validate(result.value); + if (ok === false) throw new Error(`Invalid value for "${key}": rejected by custom validator`); + if (typeof ok === 'string') throw new Error(`Invalid value for "${key}": ${ok}`); + } + return result.value; } /** - * @param {String} guildID The guild ID to remove the settings for - * @param {String} key Key should represent an array - * @param {String} value The value to remove from the array + * Override-driven authorization. The `/settings` command itself is gated + * by Discord-native permissions; this layer only enforces an admin-set + * `settingOverrides[Module.key]` if one exists. No module-side defaults. + * Pass `actor` as a GuildMember to enable the check; null/undefined skips. */ - remove(guildID, key, value) { - const guildData = this.get(guildID); - if (!Array.isArray(guildData.settings[key])) throw new Error("Not an array."); - guildData.settings[key] = guildData.settings[key].filter(item => item !== value); - this.collection.update(guildData); - this._cache.set(guildID, guildData); + _authorize(key, actor) { + if (!actor) return; + const ok = this.client.permissions.check(actor, { + settingKey: `${this.module.options.name}.${key}` + }); + if (!ok) throw new Error(`Missing permission to modify "${key}" in this guild.`); } /** - * Resets the settings of a specific key to its default values - * @param {String} guildID The guild ID to reset the settings for - * @param {String} key Key should represent an array + * Set a key to a value. Validates against the schema; coerces strings + * (e.g. `"true"` → `true` for boolean keys). If `actor` is supplied, the + * key's `requires` permission is enforced. + * @param {string} guildId + * @param {string} key + * @param {*} value + * @param {object} [options] + * @param {import('discord.js').GuildMember} [options.actor] */ - reset(guildID, key) { - const guildData = this.get(guildID); - guildData.settings[key] = this.defaultSettings[key]; - this.collection.update(guildData); - this._cache.set(guildID, guildData); + set(guildId, key, value, options = {}) { + const coerced = this._validate(key, value); + this._authorize(key, options.actor); + + const record = this.get(guildId); + record.settings[key] = coerced; + this._collection().update(record); + this._cache.set(guildId, record); + return coerced; } /** - * Resets the settings of a guild to its default values - * @param {String} guildID The guild ID to reset the settings for + * Append `value` to an array-typed key. Errors if the key isn't an array + * type. Duplicates are allowed unless caller filters. */ - factoryReset(guildID) { - const guildData = this.get(guildID); - guildData.settings = this.defaultSettings; - this.collection.update(guildData); - this._cache.set(guildID, guildData); + add(guildId, key, value, options = {}) { + if (!String(this._schema[key]?.type).startsWith('array<')) + throw new Error(`"${key}" is not an array setting.`); + this._authorize(key, options.actor); + + const innerSpec = this._schema[key].type.match(/^array<(.+)>$/)[1]; + const innerValidator = mkValidator(innerSpec); + const r = innerValidator(value); + if (!r.ok) throw new Error(`Invalid value for "${key}": ${r.error}`); + + const record = this.get(guildId); + const arr = Array.isArray(record.settings[key]) ? record.settings[key] : []; + arr.push(r.value); + record.settings[key] = arr; + this._collection().update(record); + this._cache.set(guildId, record); + return arr; + } + + /** Remove `value` from an array-typed key. */ + remove(guildId, key, value, options = {}) { + if (!String(this._schema[key]?.type).startsWith('array<')) + throw new Error(`"${key}" is not an array setting.`); + this._authorize(key, options.actor); + + const innerSpec = this._schema[key].type.match(/^array<(.+)>$/)[1]; + const innerValidator = mkValidator(innerSpec); + const r = innerValidator(value); + if (!r.ok) throw new Error(`Invalid value for "${key}": ${r.error}`); + + const record = this.get(guildId); + const arr = Array.isArray(record.settings[key]) ? record.settings[key] : []; + record.settings[key] = arr.filter(x => x !== r.value); + this._collection().update(record); + this._cache.set(guildId, record); + return record.settings[key]; + } + + /** Reset a single key to its default. */ + reset(guildId, key, options = {}) { + if (!this.has(key)) throw new Error(`Unknown setting "${key}".`); + this._authorize(key, options.actor); + + const record = this.get(guildId); + record.settings[key] = this._cloneDefault(this._schema[key].default); + this._collection().update(record); + this._cache.set(guildId, record); + return record.settings[key]; + } + + /** Reset every key in this module to defaults. */ + factoryReset(guildId, options = {}) { + for (const key of this.keys()) this._authorize(key, options.actor); + const record = this.get(guildId); + record.settings = this.defaults(); + this._collection().update(record); + this._cache.set(guildId, record); + return record; } -} \ No newline at end of file +}; diff --git a/index.js b/index.js index f22f4f3..6448728 100644 --- a/index.js +++ b/index.js @@ -9,6 +9,7 @@ const ConfigurationManager = require('./core/ConfigurationManager.js'); const Utils = require('./core/Utils.js'); const LocalizationManager = require('./core/LocalizationManager.js'); const ErrorHandler = require('./core/ErrorHandler.js'); +const PermissionsManager = require('./core/Permissions.js'); BigInt.prototype.toJSON = function () { return this.toString() } // MDN https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt#use_within_json // Discord @@ -47,6 +48,7 @@ class BotClient extends Client { this.errorHandler = new ErrorHandler(this, config.get('errorReporting')); this.database = new Database(this); + this.permissions = new PermissionsManager(this); this.moduleManager.init(); } }; diff --git a/modules/InteractionCommandHandler/InteractionCommandHandler.js b/modules/InteractionCommandHandler/InteractionCommandHandler.js index b6a580c..be499c4 100644 --- a/modules/InteractionCommandHandler/InteractionCommandHandler.js +++ b/modules/InteractionCommandHandler/InteractionCommandHandler.js @@ -53,6 +53,20 @@ module.exports = class InteractionCommandHandler extends Module { return { cancelEvent: true }; } + // Per-guild custom-level override (admin-applied via /permissions). + // No module-side gate — Discord's `defaultMemberPermissions` is the + // baseline; this only kicks in when an admin has set `commandOverrides[name]`. + if (interaction.guild) { + const ok = client.permissions.check(interaction.member, { commandName: cmd.config.name }); + if (!ok) { + await this._safeReply(interaction, { + content: ":no_entry: A server admin restricted this command to a higher level than yours.", + flags: [Discord.MessageFlags.Ephemeral] + }); + return { cancelEvent: true }; + } + } + await cmd.run(client, interaction, cmdModule); } catch (e) { client.errorHandler?.capture(e, { diff --git a/modules/Utility/Utility.js b/modules/Utility/Utility.js index 81f9183..28dac60 100644 --- a/modules/Utility/Utility.js +++ b/modules/Utility/Utility.js @@ -1,5 +1,6 @@ const Module = require("@core/Module.js"); const ModulePriorities = require("@core/ModulePriorities.js"); +const PermissionsUI = require("./lib/PermissionsUI.js"); module.exports = class Utility extends Module { constructor(client) { @@ -9,17 +10,28 @@ module.exports = class Utility extends Module { enabled: true, events: ["interactionCreate"], settings: { - defaultServerLanguage: "" + defaultServerLanguage: { + type: 'string', + default: '', + description: 'Fallback language code for users without a personal preference (e.g., en-GB, it).' + } } - }) + }); + + this.permissionsUI = new PermissionsUI(this); } /** - * - * @param {import('../../index.js')} client - * @param {import('discord.js').Interaction} interaction + * @param {import('../../index.js')} client + * @param {import('discord.js').Interaction} interaction */ async interactionCreate(client, interaction) { + // Component / modal interactions belonging to the permissions GUI. + if ((interaction.isMessageComponent?.() || interaction.isModalSubmit?.()) && + interaction.customId?.startsWith('perms:')) { + return this.permissionsUI.handle(interaction); + } + if (!interaction.isAutocomplete()) return; const command = this.commands.get(interaction.commandName); if (!command) return; @@ -33,25 +45,23 @@ module.exports = class Utility extends Module { if (interaction.commandName == "settings") { switch (interaction.options.getFocused(true).name) { case "module": - let modules = [...client.moduleManager.modules.values()].filter(x=>x.options.settings).map(x=>x.options.name); + let modules = [...client.moduleManager.modules.values()].filter(x => x.options.settings).map(x => x.options.name); let options = modules.map(m => ({ name: m, value: m })); return interaction.respond(options); case "key": - const module = interaction.options.getString("module"); - const moduleSettings = this.client.moduleManager.modules.get(module).settings - if(!moduleSettings) return interaction.respond([]) - switch (interaction.options.getSubcommand()) { - case "add": - case "remove": - return interaction.respond(Object.keys(moduleSettings.defaultSettings).filter(key => moduleSettings.defaultSettings[key] instanceof Array).map(key => ({ name: key, value: key }))) - case "set": - return interaction.respond(Object.keys(moduleSettings.defaultSettings).filter(key => !(moduleSettings.defaultSettings[key] instanceof Array)).map(key => ({ name: key, value: key }))) - case "reset": - return interaction.respond(Object.keys(moduleSettings.defaultSettings).map(key => ({ name: key, value: key }))) - } + const moduleName = interaction.options.getString("module"); + const moduleSettings = this.client.moduleManager.modules.get(moduleName)?.settings; + if (!moduleSettings) return interaction.respond([]); + const schema = moduleSettings.schema; + const isArrayType = (k) => String(schema[k].type).startsWith('array<'); + const sub = interaction.options.getSubcommand(); + const keys = Object.keys(schema); + const filtered = + sub === 'add' || sub === 'remove' ? keys.filter(isArrayType) : + sub === 'set' ? keys.filter(k => !isArrayType(k)) : + keys; + return interaction.respond(filtered.map(k => ({ name: k, value: k }))); } - - console.log(interaction); } } } diff --git a/modules/Utility/commands/permissions.js b/modules/Utility/commands/permissions.js new file mode 100644 index 0000000..8c8c57e --- /dev/null +++ b/modules/Utility/commands/permissions.js @@ -0,0 +1,26 @@ +const Command = require('@core/Command.js'); +const { PermissionsBitField } = require('discord.js'); + +/** + * Entry point for the in-Discord permissions GUI. The actual rendering and + * routing lives in `modules/Utility/lib/PermissionsUI.js`; this command just + * opens the panel ephemerally. + */ +module.exports = class PermissionsCommand extends Command { + constructor(client, module) { + super(client, module, { + name: 'permissions', + description: 'Manage per-guild permission levels and role bindings.', + guildOnly: true, + defaultMemberPermissions: [PermissionsBitField.Flags.Administrator] + }); + } + + /** + * @param {import('../../../index.js')} client + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ + async run(client, interaction) { + await this.module.permissionsUI.open(interaction); + } +} diff --git a/modules/Utility/commands/settings.js b/modules/Utility/commands/settings.js index 9859eac..1078a04 100644 --- a/modules/Utility/commands/settings.js +++ b/modules/Utility/commands/settings.js @@ -1,203 +1,163 @@ const Command = require('@core/Command.js'); -const { ApplicationCommandOptionType, EmbedBuilder, userMention, User, UserContextMenuCommandInteraction, PermissionsBitField } = require('discord.js'); +const { ApplicationCommandOptionType, EmbedBuilder, MessageFlags, PermissionsBitField } = require('discord.js'); const { Pagination } = require('pagination.djs'); -const Module = require('@core/Module.js'); module.exports = class Settings extends Command { constructor(client, module) { super(client, module, { name: 'settings', - defaultMemberPermissions: [PermissionsBitField.Flags.ManageGuild], description: 'View, add or remove settings from this guild.', + defaultMemberPermissions: [PermissionsBitField.Flags.ManageGuild], + guildOnly: true, options: [ { name: "view", description: "View the current settings for this server.", type: ApplicationCommandOptionType.Subcommand, options: [ - { - name: "module", - description: "Module to view settings for", - type: ApplicationCommandOptionType.String, - required: false, - autocomplete: true - // choices: client.settings.map((v, k) => {return {name: k, value: k}}) - } - ], + { name: "module", description: "Module to view settings for", type: ApplicationCommandOptionType.String, required: false, autocomplete: true } + ] }, { name: "set", description: "Set the value of a key", type: ApplicationCommandOptionType.Subcommand, options: [ - { - name: "module", - description: "Module to set key of", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - // choices: client.settings.map((v, k) => {return {name: k, value: k}}) - }, - { - name: "key", - description: "Key to change the value of", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - }, - { - name: "value", - description: "Value to set the key to", - type: ApplicationCommandOptionType.String, - required: true - } + { name: "module", description: "Module to set key of", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, + { name: "key", description: "Key to change", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, + { name: "value", description: "Value to set", type: ApplicationCommandOptionType.String, required: true } ] }, { name: "add", - description: "Add a value to a key", + description: "Add a value to an array key", type: ApplicationCommandOptionType.Subcommand, options: [ - { - name: "module", - description: "Module to add key of", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - // choices: client.settings.map((v, k) => {return {name: k, value: k}}) - }, - { - name: "key", - description: "Key to perform action on", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - }, - { - name: "value", - description: "Value to add to the key", - type: ApplicationCommandOptionType.String, - required: true - } + { name: "module", description: "Module", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, + { name: "key", description: "Array key", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, + { name: "value", description: "Value to add", type: ApplicationCommandOptionType.String, required: true } ] }, { name: "remove", - description: "Remove a value from a key", + description: "Remove a value from an array key", type: ApplicationCommandOptionType.Subcommand, options: [ - { - name: "module", - description: "Module to remove value of", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - // choices: client.settings.map((v, k) => {return {name: k, value: k}}) - }, - { - name: "key", - description: "Key to perform action on", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - }, - { - name: "value", - description: "Value to remove from the key", - type: ApplicationCommandOptionType.String, - required: true - } + { name: "module", description: "Module", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, + { name: "key", description: "Array key", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, + { name: "value", description: "Value to remove", type: ApplicationCommandOptionType.String, required: true } ] }, { name: "reset", - description: "Retets the value of a key to it's default value", + description: "Reset a key to its default value", type: ApplicationCommandOptionType.Subcommand, options: [ - { - name: "module", - description: "Module to set key of", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - // choices: client.settings.map((v, k) => {return {name: k, value: k}}) - }, - { - name: "key", - description: "Key to change the value of", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - }, + { name: "module", description: "Module", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, + { name: "key", description: "Key to reset", type: ApplicationCommandOptionType.String, required: true, autocomplete: true } ] - }, + } ] }); } /** - * - * @param {import('../../../index.js')} client - * @param {import('discord.js').ChatInputCommandInteraction} interaction + * @param {import('../../../index.js')} client + * @param {import('discord.js').ChatInputCommandInteraction} interaction */ async run(client, interaction) { const guild = interaction.guild; - switch (interaction.options.getSubcommand()) { - case "set": { - await await this.client.settings.get(interaction.options.getString("module")).set(guild.id, interaction.options.getString("key"), interaction.options.getString("value")); - const embed = new EmbedBuilder() - .setTitle(this.t('embeds.set.title', interaction)) - .setDescription(this.t('embeds.set.description', interaction, { value: interaction.options.getString("value"), key: interaction.options.getString("key") })); - interaction.reply({ embeds: [embed] }) - break; - } case "add": { - await this.client.settings.get(interaction.options.getString("module")).add(guild.id, interaction.options.getString("key"), interaction.options.getString("value")); - const embed = new EmbedBuilder() - .setTitle(this.t('embeds.add.title', interaction)) - .setDescription(this.t('embeds.add.description', interaction, { value: interaction.options.getString("value"), key: interaction.options.getString("key") })); + const actor = interaction.member; + const sub = interaction.options.getSubcommand(); + const moduleName = interaction.options.getString("module"); + const key = interaction.options.getString("key"); + const value = interaction.options.getString("value"); - interaction.reply({ embeds: [embed] }) - break; - } case "remove": { - await this.client.settings.get(interaction.options.getString("module")).remove(guild.id, interaction.options.getString("key"), interaction.options.getString("value")); - const embed = new EmbedBuilder() - .setTitle(this.t('embeds.remove.title', interaction)) - .setDescription(this.t('embeds.remove.description', interaction, { value: interaction.options.getString("value"), key: interaction.options.getString("key") })) - interaction.reply({ embeds: [embed] }) - break; - } case "view": { - const pagination = new Pagination(interaction); - const embeds = []; + const manager = moduleName ? client.settings.get(moduleName) : null; + if (sub !== 'view' && !manager) { + return interaction.reply({ content: `:x: No settings registered for module \`${moduleName}\`.`, flags: MessageFlags.Ephemeral }); + } - let settings = client.settings.map((v, k) => { return { module: k, settings: v.get(guild.id).settings } }); - settings.forEach(async s => { + try { + switch (sub) { + case "set": { + const coerced = manager.set(guild.id, key, value, { actor }); const embed = new EmbedBuilder() - .setTitle(this.t('embeds.view.settings', interaction, { module: s.module })) - .setDescription(Object.entries(s.settings).map(([key, value]) => `\`${key}\`: ${Array.isArray(value) ? value.join(', ') : value}`).join('\n')) - embeds.push(embed); - }); - if (embeds.length == 0) { + .setTitle(this.t('embeds.set.title', interaction)) + .setDescription(this.t('embeds.set.description', interaction, { key, value: this._format(coerced) })); + return interaction.reply({ embeds: [embed] }); + } + case "add": { + const arr = manager.add(guild.id, key, value, { actor }); + const embed = new EmbedBuilder() + .setTitle(this.t('embeds.add.title', interaction)) + .setDescription(this.t('embeds.add.description', interaction, { key, value: this._format(arr) })); + return interaction.reply({ embeds: [embed] }); + } + case "remove": { + const arr = manager.remove(guild.id, key, value, { actor }); const embed = new EmbedBuilder() - .setTitle(this.t('embeds.view.nosettings.title', interaction)) - .setDescription(this.t('embeds.view.nosettings.description', interaction)) - .setColor('Random') - embeds.push(embed); + .setTitle(this.t('embeds.remove.title', interaction)) + .setDescription(this.t('embeds.remove.description', interaction, { key, value: this._format(arr) })); + return interaction.reply({ embeds: [embed] }); + } + case "reset": { + const reset = manager.reset(guild.id, key, { actor }); + const embed = new EmbedBuilder() + .setTitle(this.t('embeds.reset.title', interaction)) + .setDescription(this.t('embeds.reset.description', interaction, { key, value: this._format(reset) })); + return interaction.reply({ embeds: [embed] }); + } + case "view": { + return this._view(interaction, moduleName); } - pagination.setAuthorizedUsers([interaction.user.id]) - pagination.setEmbeds(embeds, async (embed, index, array) => { - return embed.setFooter({ text: this.t('embeds.view.page', interaction, { currentPage: index + 1, totalPages: array.length }) }); - }); - await pagination.render(); - break; - } case "reset": { - await this.client.settings.get(interaction.options.getString("module")).reset(guild.id, interaction.options.getString("key")); - const embed = new EmbedBuilder() - .setTitle(this.t('embeds.reset.title', interaction)) - .setDescription(this.t('embeds.reset.description', interaction, { key: interaction.options.getString("key") })) - interaction.reply({ embeds: [embed] }) - break; } + } catch (err) { + return interaction.reply({ content: `:x: ${err.message}`, flags: MessageFlags.Ephemeral }); } + } + + async _view(interaction, moduleNameFilter) { + const client = this.client; + const guild = interaction.guild; + const pagination = new Pagination(interaction); + const embeds = []; + + const targets = moduleNameFilter + ? [[moduleNameFilter, client.settings.get(moduleNameFilter)]].filter(([, v]) => v) + : [...client.settings.entries()]; + + for (const [name, manager] of targets) { + const record = manager.get(guild.id); + const schema = manager.schema; + const lines = Object.entries(schema).map(([k, def]) => { + const v = record.settings[k]; + return `\`${k}\`: ${this._format(v)} — \`${def.type}\``; + }); + const embed = new EmbedBuilder() + .setTitle(this.t('embeds.view.settings', interaction, { module: name })) + .setDescription(lines.length ? lines.join('\n') : '_(no keys)_'); + embeds.push(embed); + } + + if (embeds.length === 0) { + embeds.push(new EmbedBuilder() + .setTitle(this.t('embeds.view.nosettings.title', interaction)) + .setDescription(this.t('embeds.view.nosettings.description', interaction)) + .setColor('Random')); + } + + pagination.setAuthorizedUsers([interaction.user.id]); + pagination.setEmbeds(embeds, async (embed, index, array) => { + return embed.setFooter({ text: this.t('embeds.view.page', interaction, { page: index + 1, totalPages: array.length }) }); + }); + await pagination.render(); + } + _format(v) { + if (v == null) return '_unset_'; + if (Array.isArray(v)) return v.length ? v.map(x => `\`${x}\``).join(', ') : '_empty_'; + if (typeof v === 'boolean') return v ? '`true`' : '`false`'; + return `\`${v}\``; } } diff --git a/modules/Utility/lib/PermissionsUI.js b/modules/Utility/lib/PermissionsUI.js new file mode 100644 index 0000000..68f3b95 --- /dev/null +++ b/modules/Utility/lib/PermissionsUI.js @@ -0,0 +1,576 @@ +const { + EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, + StringSelectMenuBuilder, RoleSelectMenuBuilder, UserSelectMenuBuilder, + ModalBuilder, TextInputBuilder, TextInputStyle, + MessageFlags, ComponentType +} = require('discord.js'); + +/** + * Interactive in-Discord GUI for managing per-guild permission levels. + * Replaces the previous 8-subcommand /permissions interface with a single + * panel message that re-renders in place as the admin navigates. + * + * Custom-id convention: `perms:[:...]`. State that needs to + * survive across interactions is encoded into the customId of the next + * component (Discord components are stateless). + */ +module.exports = class PermissionsUI { + /** + * @param {import('../Utility.js')} utilityModule + */ + constructor(utilityModule) { + this.module = utilityModule; + this.client = utilityModule.client; + } + + /** Slash command entry point — open the main panel ephemerally. */ + async open(interaction) { + await interaction.reply({ ...this._home(interaction.guild.id), flags: MessageFlags.Ephemeral }); + } + + /** + * Top-level dispatcher. Returns true if this UI handled the interaction. + * @param {import('discord.js').Interaction} interaction + */ + async handle(interaction) { + const id = interaction.customId; + if (!id?.startsWith('perms:')) return false; + const parts = id.split(':'); // ['perms', 'screen', ...] + const screen = parts[1]; + const args = parts.slice(2); + + try { + switch (screen) { + case 'home': return this._update(interaction, this._home(interaction.guild.id)); + case 'close': return interaction.update({ content: 'Closed.', embeds: [], components: [] }); + case 'nav': return this._nav(interaction, args[0]); + case 'level': return this._level(interaction, args); + case 'role': return this._role(interaction, args); + case 'user': return this._user(interaction, args); + case 'cmd': return this._cmd(interaction, args); + case 'set': return this._set(interaction, args); + } + } catch (err) { + this.client.errorHandler?.capture(err, { source: 'PermissionsUI', userId: interaction.user?.id }); + await this._safeError(interaction, err.message); + } + return true; + } + + _home(guildId) { + const cfg = this.client.permissions.getConfig(guildId); + const ladder = [...cfg.levels].sort((a, b) => a.weight - b.weight); + + const summary = ladder + .map(l => `**${l.weight}** · \`${l.id}\` ${l.name}${l.builtin ? ' *(default)*' : ''} — ${l.roles.length} role(s)`) + .join('\n') || '_(no levels)_'; + + const embed = new EmbedBuilder() + .setTitle('🔐 Permissions') + .setDescription( + 'Per-guild permission system. Admins define **levels** (a ladder of named ' + + 'tiers with numeric weights), then bind Discord roles to those levels. ' + + 'Module-side commands and settings stay open by default; you can re-gate ' + + 'individual ones via overrides.\n\n' + + '**Resolver order** (first match wins):\n' + + '`1.` Bot OWNER\n' + + '`2.` Guild owner\n' + + '`3.` Discord `Administrator` permission\n' + + '`4.` Member\'s effective level (max over bound roles + user override) ' + + '≥ the level required by an override (if any). With no override set, access is allowed.' + ) + .addFields( + { name: 'Current ladder', value: summary, inline: false }, + { name: 'User overrides', value: `${Object.keys(cfg.userOverrides).length}`, inline: true }, + { name: 'Command overrides', value: `${Object.keys(cfg.commandOverrides).length}`, inline: true }, + { name: 'Setting overrides', value: `${Object.keys(cfg.settingOverrides).length}`, inline: true } + ) + .setFooter({ text: 'Tip: pick a section below to manage levels, role bindings, or overrides.' }); + + const nav = new StringSelectMenuBuilder() + .setCustomId('perms:nav') + .setPlaceholder('What would you like to manage?') + .addOptions( + { label: 'Levels', value: 'levels', description: 'Create, rename, reweight, delete levels.', emoji: '👑' }, + { label: 'Role bindings', value: 'roles', description: 'Bind or unbind roles to levels.', emoji: '👥' }, + { label: 'User overrides', value: 'users', description: 'Force a specific level on a user.', emoji: '👤' }, + { label: 'Command overrides', value: 'cmds', description: 'Re-gate a slash command in this guild.', emoji: '⚡' }, + { label: 'Setting overrides', value: 'sets', description: 'Re-gate a Module.key setting.', emoji: '⚙️' } + ); + + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel('Refresh').setEmoji('🔄'), + new ButtonBuilder().setCustomId('perms:close').setStyle(ButtonStyle.Danger).setLabel('Close').setEmoji('❌') + ); + + return { content: '', embeds: [embed], components: [new ActionRowBuilder().addComponents(nav), buttons] }; + } + + async _nav(interaction, target) { + const value = interaction.values?.[0] || target; + switch (value) { + case 'levels': return this._update(interaction, this._levelsScreen(interaction.guild.id)); + case 'roles': return this._update(interaction, this._rolesScreen(interaction.guild.id)); + case 'users': return this._update(interaction, this._usersScreen(interaction.guild.id)); + case 'cmds': return this._update(interaction, this._cmdsScreen(interaction.guild.id)); + case 'sets': return this._update(interaction, this._setsScreen(interaction.guild.id)); + } + } + + _levelsScreen(guildId) { + const cfg = this.client.permissions.getConfig(guildId); + const ladder = [...cfg.levels].sort((a, b) => a.weight - b.weight); + + const embed = new EmbedBuilder() + .setTitle('👑 Levels') + .setDescription( + 'Levels are tiers of access with numeric **weights**. Higher weight = more access.\n\n' + + '• **Default levels** (`member`, `helper`, `moderator`, `admin`) are seeded into ' + + 'every guild on first use, but they\'re yours — rename, reweight, or delete them freely.\n' + + '• **Custom levels** (e.g. `senior_mod` at weight 7 between `moderator` and `admin`) ' + + 'can be created with any ID and weight.\n' + + '• Levels alone do nothing — bind Discord roles to them in the **Role bindings** ' + + 'section, or assign individual users via **User overrides**.\n' + + '• Deleting a level automatically clears any overrides referencing it.' + ) + .addFields({ + name: 'Current ladder', + value: ladder.map(l => + `**${l.weight}** · \`${l.id}\` — ${l.name}${l.builtin ? ' *(default)*' : ''}\n` + + ` ${l.roles.length ? l.roles.map(r => `<@&${r}>`).join(', ') : '_(no roles bound)_'}` + ).join('\n\n') || '_(no levels)_' + }); + + const components = []; + if (ladder.length > 0) { + const editPick = new StringSelectMenuBuilder() + .setCustomId('perms:level:edit_pick') + .setPlaceholder('Edit a level…') + .addOptions(ladder.slice(0, 25).map(l => ({ + label: `${l.name} (${l.id})`, + description: `weight ${l.weight}${l.builtin ? ' • default' : ''}`, + value: l.id + }))); + components.push(new ActionRowBuilder().addComponents(editPick)); + } + components.push(new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('perms:level:create_btn').setStyle(ButtonStyle.Primary).setLabel('Create level').setEmoji('➕'), + new ButtonBuilder().setCustomId('perms:level:delete_pick').setStyle(ButtonStyle.Danger).setLabel('Delete level').setEmoji('🗑️'), + new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel('Back').setEmoji('⬅️') + )); + + return { embeds: [embed], components }; + } + + async _level(interaction, args) { + const [action, ...rest] = args; + const guildId = interaction.guild.id; + + if (action === 'edit_pick') { + const levelId = interaction.values[0]; + return this._showLevelModal(interaction, levelId); + } + if (action === 'create_btn') { + return this._showLevelModal(interaction, null); + } + if (action === 'create_modal') { + const id = interaction.fields.getTextInputValue('id').toLowerCase().replace(/[^a-z0-9_-]/g, '_').slice(0, 32); + const name = interaction.fields.getTextInputValue('name').slice(0, 64); + const weight = parseInt(interaction.fields.getTextInputValue('weight'), 10); + if (!id) return this._safeError(interaction, 'Level ID cannot be empty.'); + if (!Number.isFinite(weight)) return this._safeError(interaction, 'Weight must be an integer.'); + this.client.permissions.setLevel(guildId, { id, name, weight, roles: [] }); + return this._update(interaction, this._levelsScreen(guildId)); + } + if (action === 'edit_modal') { + const levelId = rest[0]; + const name = interaction.fields.getTextInputValue('name').slice(0, 64); + const weight = parseInt(interaction.fields.getTextInputValue('weight'), 10); + if (!Number.isFinite(weight)) return this._safeError(interaction, 'Weight must be an integer.'); + this.client.permissions.setLevel(guildId, { id: levelId, name, weight }); + return this._update(interaction, this._levelsScreen(guildId)); + } + if (action === 'delete_pick') { + const cfg = this.client.permissions.getConfig(guildId); + const candidates = [...cfg.levels].sort((a, b) => a.weight - b.weight); + if (candidates.length === 0) + return this._safeError(interaction, 'No levels to delete.'); + + const embed = new EmbedBuilder() + .setTitle('🗑️ Delete level') + .setDescription('Pick a level to delete. Any overrides referencing it will be cleared automatically.'); + const select = new StringSelectMenuBuilder() + .setCustomId('perms:level:delete_confirm') + .setPlaceholder('Pick a level to delete…') + .addOptions(candidates.slice(0, 25).map(l => ({ + label: `${l.name} (${l.id})`, + description: `weight ${l.weight}${l.builtin ? ' • default' : ''}`, + value: l.id + }))); + const back = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('perms:nav:levels').setStyle(ButtonStyle.Secondary).setLabel('Cancel').setEmoji('⬅️') + ); + return this._update(interaction, { embeds: [embed], components: [new ActionRowBuilder().addComponents(select), back] }); + } + if (action === 'delete_confirm') { + const levelId = interaction.values[0]; + this.client.permissions.deleteLevel(guildId, levelId); + return this._update(interaction, this._levelsScreen(guildId)); + } + } + + async _showLevelModal(interaction, levelId) { + const isEdit = !!levelId; + const existing = isEdit ? this.client.permissions.getLevel(interaction.guild.id, levelId) : null; + if (isEdit && !existing) return this._safeError(interaction, `Unknown level "${levelId}".`); + + const modal = new ModalBuilder() + .setCustomId(isEdit ? `perms:level:edit_modal:${levelId}` : 'perms:level:create_modal') + .setTitle(isEdit ? `Edit ${existing.name}` : 'Create a level'); + + const inputs = []; + if (!isEdit) { + inputs.push(new TextInputBuilder() + .setCustomId('id').setLabel('Level ID (lowercase, no spaces)') + .setPlaceholder('senior_mod').setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(32)); + } + inputs.push(new TextInputBuilder() + .setCustomId('name').setLabel('Display name') + .setPlaceholder('Senior Moderator').setStyle(TextInputStyle.Short) + .setRequired(true).setMaxLength(64).setValue(existing?.name || '')); + inputs.push(new TextInputBuilder() + .setCustomId('weight').setLabel('Weight (higher = more access)') + .setPlaceholder('7').setStyle(TextInputStyle.Short) + .setRequired(true).setMaxLength(6).setValue(existing ? String(existing.weight) : '')); + + modal.addComponents(...inputs.map(i => new ActionRowBuilder().addComponents(i))); + await interaction.showModal(modal); + } + + _rolesScreen(guildId, focusedLevelId = null) { + const cfg = this.client.permissions.getConfig(guildId); + const ladder = [...cfg.levels].sort((a, b) => a.weight - b.weight); + const focused = focusedLevelId ? ladder.find(l => l.id === focusedLevelId) : null; + + const embed = new EmbedBuilder().setTitle('👥 Role bindings'); + const intro = + 'Bind Discord roles to levels. A member\'s **effective level** is the **highest weight** ' + + 'across every level any of their roles is bound to.\n\n' + + '• A role may be bound to multiple levels — the resolver picks the strongest.\n' + + '• Removing a role from a level only changes this binding; the Discord role itself is untouched.\n' + + '• To force a specific user to a level regardless of their roles, use **User overrides**.'; + + if (focused) { + embed.setDescription( + intro + '\n\n' + + `**Selected:** \`${focused.id}\` — ${focused.name} (weight ${focused.weight})\n` + + (focused.roles.length ? `Bound: ${focused.roles.map(r => `<@&${r}>`).join(', ')}` : '_(no roles bound yet)_') + ); + } else { + embed.setDescription(intro); + embed.addFields({ + name: 'All levels', + value: ladder.map(l => + `\`${l.id}\` — ${l.roles.length ? l.roles.map(r => `<@&${r}>`).join(', ') : '_none_'}` + ).join('\n') || '_(no levels)_' + }); + } + + const components = []; + + if (focused) { + components.push(new ActionRowBuilder().addComponents( + new RoleSelectMenuBuilder() + .setCustomId(`perms:role:bind:${focused.id}`) + .setPlaceholder(`Bind a role to ${focused.name}…`) + .setMinValues(1).setMaxValues(1) + )); + if (focused.roles.length > 0) { + const unbind = new StringSelectMenuBuilder() + .setCustomId(`perms:role:unbind:${focused.id}`) + .setPlaceholder(`Unbind a role from ${focused.name}…`) + .addOptions(focused.roles.slice(0, 25).map(roleId => { + const role = this.client.guilds.cache.get(guildId)?.roles?.cache?.get(roleId); + return { label: role?.name || roleId, description: roleId, value: roleId }; + })); + components.push(new ActionRowBuilder().addComponents(unbind)); + } + } + + const levelPick = new StringSelectMenuBuilder() + .setCustomId('perms:role:level_pick') + .setPlaceholder(focused ? 'Switch to a different level…' : 'Choose a level to manage…') + .addOptions(ladder.slice(0, 25).map(l => ({ + label: `${l.name} (${l.id})`, + description: `${l.roles.length} role(s) • weight ${l.weight}`, + value: l.id + }))); + components.push(new ActionRowBuilder().addComponents(levelPick)); + + components.push(new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel('Back').setEmoji('⬅️') + )); + + return { embeds: [embed], components }; + } + + async _role(interaction, args) { + const [action, levelId] = args; + const guildId = interaction.guild.id; + + if (action === 'level_pick') { + return this._update(interaction, this._rolesScreen(guildId, interaction.values[0])); + } + if (action === 'bind') { + const roleId = interaction.values[0]; + this.client.permissions.bindRole(guildId, levelId, roleId); + return this._update(interaction, this._rolesScreen(guildId, levelId)); + } + if (action === 'unbind') { + const roleId = interaction.values[0]; + this.client.permissions.unbindRole(guildId, levelId, roleId); + return this._update(interaction, this._rolesScreen(guildId, levelId)); + } + } + + _usersScreen(guildId, focusedUserId = null) { + const cfg = this.client.permissions.getConfig(guildId); + const entries = Object.entries(cfg.userOverrides); + const focused = focusedUserId; + + const embed = new EmbedBuilder() + .setTitle('👤 User overrides') + .setDescription( + 'Force a specific level on an individual user, **bypassing role bindings**. ' + + 'When set, the user\'s effective level becomes the override regardless of which ' + + 'roles they have.\n\n' + + '• Useful for: testing, granting access to someone without giving them a role, ' + + 'or temporarily downgrading a user.\n' + + '• Clear an override to fall back to normal role-based resolution.' + ); + + embed.addFields({ + name: 'Active overrides', + value: entries.length + ? entries.map(([uid, lid]) => `<@${uid}> → \`${lid}\``).join('\n') + : '_(none)_' + }); + + if (focused) { + const current = cfg.userOverrides[focused]; + embed.addFields({ name: 'Selected user', value: `<@${focused}>${current ? ` (currently \`${current}\`)` : ' *(no override yet)*'}`, inline: false }); + } + + const components = []; + + components.push(new ActionRowBuilder().addComponents( + new UserSelectMenuBuilder() + .setCustomId('perms:user:pick') + .setPlaceholder('Pick a user to set/clear an override…') + .setMinValues(1).setMaxValues(1) + )); + + if (focused) { + const ladder = [...cfg.levels].sort((a, b) => a.weight - b.weight); + const opts = [ + { label: '— Clear override —', value: '__clear__', description: 'Remove this user\'s override.' }, + ...ladder.slice(0, 24).map(l => ({ + label: `${l.name} (${l.id})`, description: `weight ${l.weight}`, value: l.id + })) + ]; + const setSel = new StringSelectMenuBuilder() + .setCustomId(`perms:user:set:${focused}`) + .setPlaceholder('Assign a level (or clear)…') + .addOptions(opts); + components.push(new ActionRowBuilder().addComponents(setSel)); + } + + components.push(new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel('Back').setEmoji('⬅️') + )); + + return { embeds: [embed], components }; + } + + async _user(interaction, args) { + const [action, userId] = args; + const guildId = interaction.guild.id; + + if (action === 'pick') { + return this._update(interaction, this._usersScreen(guildId, interaction.values[0])); + } + if (action === 'set') { + const value = interaction.values[0]; + this.client.permissions.setUserOverride(guildId, userId, value === '__clear__' ? null : value); + return this._update(interaction, this._usersScreen(guildId)); + } + } + + _cmdsScreen(guildId) { + const cfg = this.client.permissions.getConfig(guildId); + const entries = Object.entries(cfg.commandOverrides); + + const embed = new EmbedBuilder() + .setTitle('⚡ Command overrides') + .setDescription( + 'By default, slash commands are gated only by Discord\'s native ' + + '`defaultMemberPermissions` (visible in **Server Settings → Integrations → ' + + 'this bot**). This screen lets you add an additional **level requirement** ' + + 'on top, in this guild only.\n\n' + + '• Example: gate `/kick` behind `moderator` — anyone with a role bound to ' + + '`moderator` (or higher) can use it.\n' + + '• Set with the **Set override** button (command name + level ID).\n' + + '• Clear an override to revert to Discord-native default.\n' + + '• Discord `Administrator`, the guild owner, and the bot OWNER always bypass overrides.' + ) + .addFields({ + name: 'Active overrides', + value: entries.length + ? entries.map(([c, l]) => `\`/${c}\` → \`${l}\``).join('\n') + : '_(none)_' + }); + + const components = []; + const buttons = [ + new ButtonBuilder().setCustomId('perms:cmd:set_btn').setStyle(ButtonStyle.Primary).setLabel('Set override').setEmoji('➕') + ]; + if (entries.length > 0) { + const clear = new StringSelectMenuBuilder() + .setCustomId('perms:cmd:clear_pick') + .setPlaceholder('Clear an override…') + .addOptions(entries.slice(0, 25).map(([c, l]) => ({ + label: `/${c}`, description: `currently \`${l}\``, value: c + }))); + components.push(new ActionRowBuilder().addComponents(clear)); + } + buttons.push(new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel('Back').setEmoji('⬅️')); + components.push(new ActionRowBuilder().addComponents(...buttons)); + return { embeds: [embed], components }; + } + + async _cmd(interaction, args) { + const [action] = args; + const guildId = interaction.guild.id; + + if (action === 'set_btn') { + const modal = new ModalBuilder() + .setCustomId('perms:cmd:set_modal') + .setTitle('Set command override'); + modal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder().setCustomId('command').setLabel('Command name (without /)') + .setPlaceholder('kick').setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(32) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder().setCustomId('level').setLabel('Level ID') + .setPlaceholder('moderator').setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(32) + ) + ); + return interaction.showModal(modal); + } + if (action === 'set_modal') { + const cmd = interaction.fields.getTextInputValue('command').replace(/^\//, '').trim(); + const level = interaction.fields.getTextInputValue('level').trim(); + this.client.permissions.setCommandOverride(guildId, cmd, level); + return this._update(interaction, this._cmdsScreen(guildId)); + } + if (action === 'clear_pick') { + const cmd = interaction.values[0]; + this.client.permissions.setCommandOverride(guildId, cmd, null); + return this._update(interaction, this._cmdsScreen(guildId)); + } + } + + _setsScreen(guildId) { + const cfg = this.client.permissions.getConfig(guildId); + const entries = Object.entries(cfg.settingOverrides); + + const embed = new EmbedBuilder() + .setTitle('⚙️ Setting overrides') + .setDescription( + 'Re-gate individual settings by their `Module.key` path. By default, anyone ' + + 'who can run `/settings` can edit any key — set an override here to require a ' + + 'specific level for one particular key in this guild.\n\n' + + '• Example: `Utility.defaultServerLanguage` → `admin` so only admins change ' + + 'the server\'s fallback language, even if mods can otherwise use `/settings`.\n' + + '• Use **Set override** with a `Module.key` path (autocomplete in `/settings` ' + + 'shows the available keys per module).\n' + + '• Clear an override to revert to the default `/settings` gate.' + ) + .addFields({ + name: 'Active overrides', + value: entries.length + ? entries.map(([k, l]) => `\`${k}\` → \`${l}\``).join('\n') + : '_(none)_' + }); + + const components = []; + const buttons = [ + new ButtonBuilder().setCustomId('perms:set:set_btn').setStyle(ButtonStyle.Primary).setLabel('Set override').setEmoji('➕') + ]; + if (entries.length > 0) { + const clear = new StringSelectMenuBuilder() + .setCustomId('perms:set:clear_pick') + .setPlaceholder('Clear an override…') + .addOptions(entries.slice(0, 25).map(([k, l]) => ({ + label: k, description: `currently \`${l}\``, value: k + }))); + components.push(new ActionRowBuilder().addComponents(clear)); + } + buttons.push(new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel('Back').setEmoji('⬅️')); + components.push(new ActionRowBuilder().addComponents(...buttons)); + return { embeds: [embed], components }; + } + + async _set(interaction, args) { + const [action] = args; + const guildId = interaction.guild.id; + + if (action === 'set_btn') { + const modal = new ModalBuilder() + .setCustomId('perms:set:set_modal') + .setTitle('Set setting override'); + modal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder().setCustomId('key').setLabel('Setting key (Module.key)') + .setPlaceholder('Utility.defaultServerLanguage').setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(80) + ), + new ActionRowBuilder().addComponents( + new TextInputBuilder().setCustomId('level').setLabel('Level ID') + .setPlaceholder('moderator').setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(32) + ) + ); + return interaction.showModal(modal); + } + if (action === 'set_modal') { + const key = interaction.fields.getTextInputValue('key').trim(); + const level = interaction.fields.getTextInputValue('level').trim(); + this.client.permissions.setSettingOverride(guildId, key, level); + return this._update(interaction, this._setsScreen(guildId)); + } + if (action === 'clear_pick') { + const key = interaction.values[0]; + this.client.permissions.setSettingOverride(guildId, key, null); + return this._update(interaction, this._setsScreen(guildId)); + } + } + + /** + * `interaction.update()` works on component AND modal-submit interactions + * (since modals here are always launched from a panel button/select). + */ + async _update(interaction, payload) { + if (interaction.replied || interaction.deferred) + return interaction.editReply(payload); + return interaction.update(payload); + } + + async _safeError(interaction, message) { + const payload = { content: `:x: ${message}`, embeds: [], components: [], flags: MessageFlags.Ephemeral }; + try { + if (interaction.replied || interaction.deferred) return interaction.followUp(payload); + if (interaction.isModalSubmit?.()) return interaction.reply(payload); + return interaction.reply(payload); + } catch { /* swallow */ } + } +}; diff --git a/modules/Utility/locales/en-GB.yaml b/modules/Utility/locales/en-GB.yaml index 83f16df..584ef07 100644 --- a/modules/Utility/locales/en-GB.yaml +++ b/modules/Utility/locales/en-GB.yaml @@ -48,19 +48,22 @@ commands: embeds: set: title: "Setting Updated" + description: "`{{key}}` is now {{value}}." add: title: "Setting Added" + description: "`{{key}}` is now {{value}}." remove: title: "Setting Removed" + description: "`{{key}}` is now {{value}}." view: - settings: "Current Settings" + settings: "Settings — {{module}}" nosettings: title: "No Settings Found" description: "There are no settings configured for this module." page: "Page {{page}} of {{totalPages}}" reset: title: "Setting Reset" - description: "The setting has been reset to its default value." + description: "`{{key}}` has been reset to {{value}}." stats: name: stats description: View bot statistics and information. diff --git a/modules/Utility/locales/it.yaml b/modules/Utility/locales/it.yaml index 62a5264..e845391 100644 --- a/modules/Utility/locales/it.yaml +++ b/modules/Utility/locales/it.yaml @@ -10,8 +10,9 @@ commands: name: ping description: Controlla la latenza e responsività del bot. messages: - pinging: "Pinging..." - pong: ":ping_pong: Pong! La latenza è **{{latency}}ms**. La latenza API è **{{apiLatency}}ms**" + pinging: Pinging... + pong: ":ping_pong: Pong! La latenza è **{{latency}}ms**. La latenza API è + **{{apiLatency}}ms**" settings: name: settings description: Gestisci le impostazioni del server corrente @@ -47,29 +48,32 @@ commands: description: Il valore per filtrare le impostazioni embeds: set: - title: "Impostazione Aggiornata" + title: Impostazione Aggiornata + description: commands.settings.embeds.set.description add: - title: "Impostazione Aggiunta" + title: Impostazione Aggiunta + description: commands.settings.embeds.add.description remove: - title: "Impostazione Rimossa" + title: Impostazione Rimossa + description: commands.settings.embeds.remove.description view: - settings: "Impostazioni Correnti" + settings: Impostazioni Correnti nosettings: - title: "Nessuna Impostazione Trovata" - description: "Non ci sono impostazioni configurate per questo modulo." - page: "Pagina {{page}} di {{totalPages}}" + title: Nessuna Impostazione Trovata + description: Non ci sono impostazioni configurate per questo modulo. + page: Pagina {{page}} di {{totalPages}} reset: - title: "Impostazione Ripristinata" - description: "L’impostazione è stata ripristinata al valore predefinito." + title: Impostazione Ripristinata + description: L’impostazione è stata ripristinata al valore predefinito. stats: name: stats description: Visualizza statistiche e informazioni sul bot. embed: - title: "STATISTICHE" - memusage: "⚙️ Utilizzo Memoria" - uptime: "⏱️ Tempo di attività" - versions: "🔢 Versioni" - creationdate: "🎂 Data di creazione" + title: STATISTICHE + memusage: ⚙️ Utilizzo Memoria + uptime: ⏱️ Tempo di attività + versions: 🔢 Versioni + creationdate: 🎂 Data di creazione setlang: name: setlang description: Imposta la tua lingua preferita per il bot @@ -80,4 +84,5 @@ commands: messages: invalidLang: ":warning: La lingua specificata non è supportata." success: ":flag_it: La tua lingua preferita è stata impostata su **Italiano**!" - reset: ":globe_with_meridians: D'ora in poi la tua lingua sarà sincronizzata con la lingua predefinita!" + reset: ":globe_with_meridians: D'ora in poi la tua lingua sarà sincronizzata con + la lingua predefinita!" From 2ecc64deeb0e816005beb4a5d2b7765449fa1879 Mon Sep 17 00:00:00 2001 From: Miky88 <44903677+Miky88@users.noreply.github.com> Date: Thu, 30 Apr 2026 20:13:22 +0200 Subject: [PATCH 7/8] feat: Localize all GUI panels and ICH error replies --- config.yml | 1 + core/LocalizationManager.js | 3 +- core/Logger.js | 13 +- core/Module.js | 109 ++-- core/ModuleManager.js | 559 ++++++++++++++---- core/ModulePriorities.js | 7 - index.js | 12 +- modules/Example/Example.js | 1 - .../InteractionCommandHandler.js | 44 +- .../locales/en-GB.yaml | 4 +- .../InteractionCommandHandler/locales/it.yaml | 8 +- modules/ReadyLog/ReadyLog.js | 1 - modules/System/System.js | 80 +-- modules/System/commands/modman.js | 214 +------ modules/System/lib/ModmanUI.js | 276 +++++++++ modules/System/locales/en-GB.yaml | 107 ++-- modules/System/locales/it.yaml | 55 +- modules/Utility/Utility.js | 34 +- modules/Utility/commands/settings.js | 162 +---- modules/Utility/lib/PermissionsUI.js | 361 ++++++----- modules/Utility/lib/SettingsUI.js | 401 +++++++++++++ modules/Utility/locales/en-GB.yaml | 252 ++++++-- modules/Utility/locales/it.yaml | 164 +++++ 23 files changed, 1980 insertions(+), 888 deletions(-) delete mode 100644 core/ModulePriorities.js create mode 100644 modules/System/lib/ModmanUI.js create mode 100644 modules/Utility/lib/SettingsUI.js diff --git a/config.yml b/config.yml index ee39b08..b7555d3 100644 --- a/config.yml +++ b/config.yml @@ -41,3 +41,4 @@ i18n: referenceLanguage: en-GB autoSync: true hotReload: false +verbose: false diff --git a/core/LocalizationManager.js b/core/LocalizationManager.js index 89d7eb4..1b1df23 100644 --- a/core/LocalizationManager.js +++ b/core/LocalizationManager.js @@ -172,7 +172,8 @@ module.exports = class LocalizationManager { * default. Returns the resolved language code (or null if nothing fits). */ resolveLanguage(requested) { - if (!requested) return this.languages[this.defaultLang] ? this.defaultLang : null; + if (!requested || typeof requested !== 'string') + return this.languages[this.defaultLang] ? this.defaultLang : null; if (this.languages[requested]) return requested; const base = requested.split('-')[0]; diff --git a/core/Logger.js b/core/Logger.js index ce26084..816a243 100644 --- a/core/Logger.js +++ b/core/Logger.js @@ -2,7 +2,13 @@ const chalk = require('chalk'); module.exports = class Logger { /** - * @param {String} name + * Global toggle for `verbose()` calls. Set once at boot from + * `config.verbose` in index.js. Defaults to `false` (verbose suppressed). + */ + static verboseEnabled = false; + + /** + * @param {String} name */ constructor(name, saveToFile = true) { this.name = name; @@ -131,10 +137,13 @@ module.exports = class Logger { } /** - * Verbose something on the console + * Verbose log — gated by the global `Logger.verboseEnabled` flag (set + * from `config.verbose` at boot in index.js). When disabled, this is a + * complete no-op (no console, no file). * @param {String} message */ verbose(...message) { + if (!Logger.verboseEnabled) return; let final = ""; for (const msg of message) { diff --git a/core/Module.js b/core/Module.js index 329990b..b92c530 100644 --- a/core/Module.js +++ b/core/Module.js @@ -1,6 +1,5 @@ const { Collection, BaseInteraction } = require('discord.js'); const fs = require('fs'); -const ModulePriorities = require('./ModulePriorities'); const ConfigurationManager = require('./ConfigurationManager'); const SettingsManager = require('./SettingsManager'); const Logger = require('./Logger'); @@ -9,26 +8,26 @@ module.exports = class Module { /** * @param {import('..')} client * @param {object} options - * @param {string} [options.name] - * @param {string} [options.info] - * @param {boolean} [options.enabled] - * @param {string[]} [options.events] - * @param {boolean | string[]} [options.databases] Either `true` for a single - * `default` collection in `data/.db`, or an array of collection - * names (e.g. `['guilds', 'logs']`) to declare upfront. - * @param {number} [options.priority] - * @param {string[]} [options.dependencies] - * @param {object} [options.config] - * @param {object} [options.settings] + * @param {string} [options.name] + * @param {string} [options.info] + * @param {string} [options.version] + * @param {string[]} [options.events] Discord event names this module handles. + * @param {string[]} [options.dependencies] Module names this module needs loaded. + * @param {string[]} [options.runBefore] Modules this one's event handlers should run before. + * @param {string[]} [options.runAfter] Modules this one's event handlers should run after. + * @param {boolean | string[]} [options.databases] `true` for a single `default` collection or an array of collection names. + * @param {object} [options.config] Default per-module config schema. + * @param {object} [options.settings] Schema-driven per-guild settings. */ constructor(client, { name = this.constructor.name, info = "No description provided.", - enabled = false, + version = null, events = [], - databases = false, - priority = ModulePriorities.NORMAL, dependencies = [], + runBefore = [], + runAfter = [], + databases = false, config = null, settings = null }) { @@ -39,10 +38,13 @@ module.exports = class Module { : (databases ? ['default'] : []); this.options = { - name, info, enabled, events, priority, + name, info, version, events, + dependencies: [...dependencies], + runBefore: [...runBefore], + runAfter: [...runAfter], databases, collections: declaredCollections, - dependencies, settings + settings }; this.commands = new Collection(); @@ -54,13 +56,45 @@ module.exports = class Module { this.settings = new SettingsManager(client, this, settings); } + // ───── lifecycle hooks ───── + // ModuleManager calls these in a defined order. Default implementations + // cover the common cases (loading commands on start, clearing on stop); + // override to add async setup, watch external resources, etc. + + /** + * Called once after the constructor, before commands or events are wired. + * Place for one-shot async setup (cache prefill, schema migration, etc.). + */ + async init(client) {} + + /** + * Called when the module transitions to the enabled state — at boot for + * modules persisted as enabled, or via the manager's enable() action. + * Default: load slash commands so they're discoverable. + */ + async start(client) { + await this.loadCommands(); + } + + /** + * Called when the module transitions to the disabled state, or before an + * unload. Default: drop the slash-command cache so the manager's + * aggregate `commands` getter no longer includes them. + */ + async stop(client) { + this.commands.clear(); + } + + /** + * Called once when the module is being unloaded, after stop(). Last + * chance to release external resources (timers, intervals, sockets). + */ + async destroy(client) {} + + // ───── i18n ───── + t(_key, interactionOrLang, vars) { let key = `modules.${this.options.name}.${_key}`; - // Lang: - // - Check if forced on user data - // - Check if forced on guild settings - // - Check discord interaction.language - // - Else, default if (interactionOrLang instanceof BaseInteraction) { const interaction = interactionOrLang; @@ -85,7 +119,6 @@ module.exports = class Module { const lang = interactionOrLang || this.client.i18n.defaultLang; return this.client.i18n.t(key, lang, vars); } - } getLocalizationObject(_key) { @@ -93,39 +126,40 @@ module.exports = class Module { return this.client.i18n.getLocalizationObject(key); } + // ───── commands ───── + async loadCommands() { const commands = fs.existsSync(`./modules/${this.options.name}/commands`) ? fs.readdirSync(`./modules/${this.options.name}/commands`).filter(file => file.endsWith(".js")) : []; commands.forEach(file => { try { - /** - * @type {import('./Command')} - */ const command = require(`../modules/${this.options.name}/commands/${file}`); delete require.cache[require.resolve(`../modules/${this.options.name}/commands/${file}`)]; const _command = new command(this.client, this); - + this.commands.set(file.split(".")[0], _command); this.logger.verbose(`Loaded command ${file.split(".")[0]} from ${this.options.name}`); } catch (e) { this.logger.error(`Failed to load command ${file} from ${this.options.name}: ${e.stack || e}`); } - }); + }); } - run(client, event, ...args) { - // Register automatic event method caller + /** + * Dispatched by ModuleManager. The last argument is an EventContext — + * call `ctx.stopPropagation()` to prevent later modules from seeing this + * event in this dispatch round. + */ + run(client, event, ...rest) { const method = this[event]; if (!method) return this.logger.error(`[${this.options.name}] There was no configured method for the ${event} event.`); - return method.call(this, client, ...args); + return method.call(this, client, ...rest); } + // ───── database ───── + /** - * The module's database handle. `null` if the module didn't opt in via - * the `databases` option. Use `this.db.collection(name)` to access a - * specific collection, or the convenience proxy (e.g. `this.db.guilds`) - * for collections declared at construction time. * @type {import('./DatabaseHandle') | null} */ get db() { @@ -133,11 +167,6 @@ module.exports = class Module { return this.client.database.get(this.options.name) || null; } - /** - * Insert-or-update a row into one of the module's collections. - * @param {string} collectionName - * @param {object} data - */ saveData(collectionName, data) { if (!this.db) throw new Error("You must declare `databases` in module options to use this method."); diff --git a/core/ModuleManager.js b/core/ModuleManager.js index 426ac52..846236b 100644 --- a/core/ModuleManager.js +++ b/core/ModuleManager.js @@ -1,180 +1,517 @@ const fs = require('fs'); +const path = require('path'); const Module = require('./Module.js'); -const Command = require('./Command.js'); const Logger = require('./Logger.js'); const SettingsManager = require('./SettingsManager.js'); +/** + * Pure data-bag handed to every event handler. Modules call + * `ctx.stopPropagation()` to prevent later modules from receiving the event + * during this dispatch round; the manager logs who stopped what. + */ +class EventContext { + constructor(eventName, manager) { + this.eventName = eventName; + this._manager = manager; + this._stoppedBy = null; + } + get propagationStopped() { return this._stoppedBy !== null; } + /** + * @param {string} [reason] Optional human-readable reason — appears in logs. + */ + stopPropagation(reason) { + if (this._stoppedBy) return; + this._stoppedBy = { reason: reason || null }; + } +} + +/** + * @template T + * @typedef {{ ok: true, value: T } | { ok: false, code: string, error: string }} Result + */ + +/** Error codes returned via Result.code. */ +const ERR = { + NOT_FOUND: 'NOT_FOUND', // module file isn't on disk + NOT_LOADED: 'NOT_LOADED', // module isn't in the registry + ALREADY_LOADED: 'ALREADY_LOADED', + LOAD_ERROR: 'LOAD_ERROR', // exception during construct/init/start + DEPENDENCY_LOCKED:'DEPENDENCY_LOCKED', // can't unload — others depend on this + MISSING_DEPENDENCY:'MISSING_DEPENDENCY', + CYCLIC_DEPENDENCY:'CYCLIC_DEPENDENCY' +}; + module.exports = class ModuleManager { /** - * @param {import('../index.js')} client + * @param {import('..')} client */ constructor(client) { this.client = client; - /** @type {Map} */ + /** @type {Map} */ this.modules = new Map(); - + /** @type {Set} Discord events the manager has wired to the client. */ this.events = new Set(); + /** @type {Map} Last load/start error per module name. */ + this.errors = new Map(); + this.logger = new Logger(this.constructor.name); } - init() { - this.logger.info(`Loading modules...`) - const modules = fs.readdirSync("./modules", { withFileTypes: true }) - .filter(dirent => dirent.isDirectory()) - .map(dirent => dirent.name); - modules.forEach(moduleName => { - if (!this.isLoaded(moduleName)) { - this.load(moduleName); - } - }); - this.logger.success(`Successfully Loaded ${this.modules.size} modules`) - } + // ───────────────────────────────── boot /** - * @param {String} moduleName + * Discover, construct, init, and start every module on disk in correct + * topological order. Called once from index.js. */ - load(moduleName) { - try { - const module = require(`@modules/${moduleName}/${moduleName}.js`); - delete require.cache[require.resolve(`@modules/${moduleName}/${moduleName}.js`)]; - const _module = new module(this.client); - if (_module.options.dependencies.length > 0) { - let dependencies = _module.options.dependencies; - for (let dependence of dependencies) { - if (!this.isLoaded(dependence)) { - this.load(dependence); - this.logger.verbose(`Successfully loaded dependence ${dependence} of ${moduleName}`); - } + async init() { + this.logger.info(`Loading modules...`); + const names = this._discoverOnDisk(); + + // Phase 1: construct so we can read each module's options.dependencies. + const constructed = new Map(); // name → module instance + for (const name of names) { + const result = this._construct(name); + if (result.ok) constructed.set(name, result.value); + else this.errors.set(name, new Error(`${result.code}: ${result.error}`)); + } + + // Phase 2: topologically order by dependencies. Reject cycles. + const order = this._topoSort( + constructed, + (m) => m.options.dependencies, + (m) => m.options.name + ); + if (!order.ok) { + this.logger.error(`Module load aborted: ${order.error}`); + return; + } + + // Phase 3: validate every dependency exists. + for (const m of order.value) { + for (const dep of m.options.dependencies) { + if (!constructed.has(dep)) { + this.errors.set(m.options.name, new Error(`Missing dependency "${dep}"`)); + this.logger.error(`Skipping ${m.options.name}: missing dependency "${dep}".`); } } - if (_module.options.enabled) - _module.loadCommands() + } - const collections = [..._module.options.collections]; - if (_module.options.settings) collections.push('settings'); - if (collections.length > 0) - this.client.database.register(_module.options.name, { collections }); + // Phase 4: register DB handles, init, then start (if persisted-enabled), in topo order. + for (const m of order.value) { + if (this.errors.has(m.options.name)) continue; + try { + this._wireDatabase(m); + this.modules.set(m.options.name, m); + await m.init(this.client); - if (_module.options.settings) - _module.settings = new SettingsManager(this.client, _module, _module.options.settings); + if (this._isEnabledPersisted(m.options.name)) + await m.start(this.client); - this.add(_module) - } catch (error) { - this.logger.error("Unable to load " + moduleName + ": " + error) - return { error } + this.logger.verbose(`${m.options.name} loaded`); + } catch (err) { + this.errors.set(m.options.name, err); + this.modules.delete(m.options.name); + this.client.errorHandler?.capture(err, { source: 'ModuleManager.init', module: m.options.name }); + } } - return {} + + // Phase 5: wire Discord event listeners (one per event type). + this._installEventDispatchers(); + + this.logger.success(`Successfully loaded ${this.modules.size} module(s)`); } + // ───────────────────────────────── public ops + /** - * @param {Module} module + * Load a module that isn't currently in the registry. + * @returns {Promise>} */ - add(module) { - this.modules.set(module.options.name, module); - this.logger.verbose(`${module.options.name} loaded`) - - const eventCallback = event => async (...args) => { - for (let [_name, module] of new Map([...this.modules.entries()].sort((a, b) => b[1].options.priority - a[1].options.priority))) { - if (module.options.enabled && module.options.events.includes(event)) { - let execution; - try { - execution = await module.run(this.client, event, ...args); - } catch (err) { - this.client.errorHandler?.capture(err, { module: _name, event }); - continue; - } + async load(name) { + if (this.modules.has(name)) return this._fail(ERR.ALREADY_LOADED, `${name} is already loaded.`); + if (!this._existsOnDisk(name)) return this._fail(ERR.NOT_FOUND, `${name} is not on disk.`); - if (execution?.cancelEvent) - break; - } + const built = this._construct(name); + if (!built.ok) return built; + const mod = built.value; + + // Verify dependencies are satisfiable. + for (const dep of mod.options.dependencies) { + if (!this.modules.has(dep) && !this._existsOnDisk(dep)) + return this._fail(ERR.MISSING_DEPENDENCY, `${name} requires "${dep}" which is missing.`); + } + // Recursively load any missing dependencies first. + for (const dep of mod.options.dependencies) { + if (!this.modules.has(dep)) { + const r = await this.load(dep); + if (!r.ok) return this._fail(ERR.MISSING_DEPENDENCY, `Failed to load dependency "${dep}": ${r.error}`); } } - module.options.events.forEach(evt => { - if (!this.events.has(evt)) { - const event = evt - this.events.add(event); - this.client.on(event, eventCallback(event)) + + try { + this._wireDatabase(mod); + this.modules.set(name, mod); + await mod.init(this.client); + if (this._isEnabledPersisted(name)) + await mod.start(this.client); + + this._installEventDispatchers(); + this.errors.delete(name); + this.logger.success(`${name} loaded`); + return this._ok(mod); + } catch (err) { + this.errors.set(name, err); + this.modules.delete(name); + this.client.errorHandler?.capture(err, { source: 'ModuleManager.load', module: name }); + return this._fail(ERR.LOAD_ERROR, err.message); + } + } + + /** + * Unload a module. + * @param {string} name + * @param {object} [opts] + * @param {boolean} [opts.force] Cascade-unload anything that depends on this. + * @returns {Promise>} + */ + async unload(name, opts = {}) { + if (!this.modules.has(name)) return this._fail(ERR.NOT_LOADED, `${name} is not loaded.`); + + const dependents = this._dependentsOf(name); + if (dependents.length > 0 && !opts.force) + return this._fail(ERR.DEPENDENCY_LOCKED, `${name} is required by: ${dependents.join(', ')}. Pass force:true to cascade.`); + + const toUnload = opts.force ? [...dependents.reverse(), name] : [name]; + + for (const target of toUnload) { + const mod = this.modules.get(target); + if (!mod) continue; + try { + if (this._isEnabledPersisted(target)) await mod.stop(this.client); + await mod.destroy(this.client); + } catch (err) { + this.client.errorHandler?.capture(err, { source: 'ModuleManager.unload', module: target }); } - }) + this.modules.delete(target); + this.logger.info(`${target} unloaded`); + } + + await this._unregisterCommandsWithDiscord(); + return this._ok({ unloaded: toUnload }); } /** - * @param {String} pluginName + * Stop, destroy, clear require.cache, then load the module fresh. Snapshots + * the prior enabled state so reload doesn't surprise-disable anything. + * @returns {Promise>} */ - reload(pluginName) { - return this.unload(pluginName) && this.load(pluginName) + async reload(name) { + if (!this.modules.has(name)) return this._fail(ERR.NOT_LOADED, `${name} is not loaded.`); + + const wasEnabled = this._isEnabledPersisted(name); + const u = await this.unload(name, { force: true }); + if (!u.ok) return u; + + const r = await this.load(name); + if (!r.ok) return r; + + // unload+load above already respected persisted enable state; reload + // shouldn't re-disable a manually-disabled module. + if (wasEnabled !== this._isEnabledPersisted(name)) + this._setEnabledPersisted(name, wasEnabled); + + return r; } /** - * @param {String} pluginName + * Persist enabled = true and call start() if it wasn't already running. */ - unload(pluginName) { - this.logger.log(`${pluginName} unloaded`); - return this.modules.delete(pluginName); + async enable(name) { + const mod = this.modules.get(name); + if (!mod) return this._fail(ERR.NOT_LOADED, `${name} is not loaded.`); + if (this._isEnabledPersisted(name)) return this._ok(mod); + + try { + await mod.start(this.client); + this._setEnabledPersisted(name, true); + this.logger.success(`${name} enabled`); + return this._ok(mod); + } catch (err) { + this.client.errorHandler?.capture(err, { source: 'ModuleManager.enable', module: name }); + return this._fail(ERR.LOAD_ERROR, err.message); + } } /** - * @param {String} pluginName + * Persist enabled = false and call stop() if it was running. */ - enable(pluginName) { - if (!this.modules.get(pluginName)) return false - this.logger.log(`${pluginName} enabled`); - return this.modules.get(pluginName).options.enabled = true; + async disable(name) { + const mod = this.modules.get(name); + if (!mod) return this._fail(ERR.NOT_LOADED, `${name} is not loaded.`); + if (!this._isEnabledPersisted(name)) return this._ok(mod); + + try { + await mod.stop(this.client); + this._setEnabledPersisted(name, false); + this.logger.info(`${name} disabled`); + return this._ok(mod); + } catch (err) { + this.client.errorHandler?.capture(err, { source: 'ModuleManager.disable', module: name }); + return this._fail(ERR.LOAD_ERROR, err.message); + } } + isLoaded(name) { return this.modules.has(name); } + isEnabled(name) { return this.modules.has(name) && this._isEnabledPersisted(name); } + isOnDisk(name) { return this._existsOnDisk(name); } + /** - * @param {String} pluginName + * Aggregated info for inspection / GUI. + * @returns {Result<{ name, info, version, enabled, loaded, dependencies, dependents, events, commands, lastError }>} */ - disable(pluginName) { - if (!this.modules.get(pluginName)) return false - this.logger.log(`${pluginName} disabled`); - return !(this.modules.get(pluginName).options.enabled = false); + info(name) { + const mod = this.modules.get(name); + if (!mod) { + if (this._existsOnDisk(name)) return this._ok({ + name, loaded: false, enabled: false, + lastError: this.errors.get(name)?.message || null + }); + return this._fail(ERR.NOT_FOUND, `${name} is not on disk.`); + } + return this._ok({ + name: mod.options.name, + info: mod.options.info, + version: mod.options.version, + enabled: this._isEnabledPersisted(name), + loaded: true, + dependencies: [...mod.options.dependencies], + dependents: this._dependentsOf(name), + events: [...mod.options.events], + commands: [...mod.commands.keys()], + lastError: this.errors.get(name)?.message || null + }); } /** - * @param {String} pluginName + * Snapshot of every module on disk. + * @returns {{ loaded: string[], available: string[], failed: { name: string, error: string }[] }} */ - isLoaded(pluginName) { - return !!this.modules.get(pluginName); + list() { + const onDisk = this._discoverOnDisk(); + const loaded = [...this.modules.keys()]; + const available = onDisk.filter(n => !this.modules.has(n)); + const failed = onDisk + .filter(n => this.errors.has(n) && !this.modules.has(n)) + .map(n => ({ name: n, error: this.errors.get(n).message })); + return { loaded, available, failed }; } /** - * @param {String} pluginName + * @returns {[Command, Module] | [null, null]} */ - info(pluginName) { - if (!this.modules.get(pluginName)) return { error: "Invalid module name" } - return { - description: this.modules.get(pluginName).options.info, - enabled: this.modules.get(pluginName).options.enabled, - loaded: this.isLoaded(pluginName), - events: this.modules.get(pluginName).options.events + getCommand(name) { + for (const module of this.modules.values()) { + if (this._isEnabledPersisted(module.options.name) && module.commands?.has(name)) + return [module.commands.get(name), module]; } + return [null, null]; } - get list() { - return { - loaded: [...this.modules.values()].map(module => `${this.isLoaded(module.options.name) ? ":white_check_mark:" : ":x:"} **${module.options.name}**`).join("\n"), - unloaded: fs.readdirSync("./modules").filter(file => file.endsWith(".js")).map(fl => fl.split(".")[0]).filter(plg => ![...this.modules.keys()].includes(plg)).map(module => `**${module}**`).join("\n") + /** Aggregated commands across enabled modules. */ + get commands() { + const out = []; + for (const m of this.modules.values()) { + if (!this._isEnabledPersisted(m.options.name)) continue; + for (const c of m.commands.values()) out.push(c); } + return out; + } + + // ───────────────────────────────── persistence + + _stateCollection() { + const handle = this.client.database.get('core'); + return handle.addCollection('module_states'); } + _isEnabledPersisted(name) { + const col = this._stateCollection(); + const rec = col.findOne({ name }); + // Default: enabled if no persisted state. + return rec ? rec.enabled !== false : true; + } + + _setEnabledPersisted(name, enabled) { + const col = this._stateCollection(); + const rec = col.findOne({ name }); + if (rec) { + rec.enabled = !!enabled; + col.update(rec); + } else { + col.insert({ name, enabled: !!enabled }); + } + } + + // ───────────────────────────────── construction + + _construct(name) { + try { + const modulePath = require.resolve(`@modules/${name}/${name}.js`); + delete require.cache[modulePath]; + const ModuleClass = require(modulePath); + const instance = new ModuleClass(this.client); + return this._ok(instance); + } catch (err) { + const msg = err.stack || err.message || String(err); + this.logger.error(`Failed to construct ${name}: ${msg}`); + return this._fail(ERR.LOAD_ERROR, err.message || String(err)); + } + } + + _wireDatabase(mod) { + const collections = [...mod.options.collections]; + if (mod.options.settings) collections.push('settings'); + if (collections.length > 0) + this.client.database.register(mod.options.name, { collections }); + if (mod.options.settings && !mod.settings) + mod.settings = new SettingsManager(this.client, mod, mod.options.settings); + } + + // ───────────────────────────────── topo sort + /** - * @param {String} cmd - * @returns {[Command, Module] | [null, null]} + * Generic Kahn's-algorithm topological sort. + * @param {Map} items + * @param {(item: T) => string[]} edgesOf Returns the names this item depends on (= incoming edges). + * @param {(item: T) => string} keyOf + * @returns {Result} */ - getCommand(cmd) { - const match = [...this.modules.values()].find(module => { - return module?.commands?.has(cmd) - }); - if (!match) - return [null, null]; - return [match.commands.get(cmd), match]; + _topoSort(items, edgesOf, keyOf) { + const order = []; + const remaining = new Map(items); + while (remaining.size > 0) { + // Pick anything whose deps are already placed (or absent from `items`). + const next = [...remaining.values()].find(item => { + return edgesOf(item).every(d => !remaining.has(d)); + }); + if (!next) { + const cycle = [...remaining.keys()].join(', '); + return this._fail(ERR.CYCLIC_DEPENDENCY, `Cyclic dependency among: ${cycle}`); + } + order.push(next); + remaining.delete(keyOf(next)); + } + return this._ok(order); + } + + _dependentsOf(name) { + return [...this.modules.values()] + .filter(m => m.options.dependencies.includes(name)) + .map(m => m.options.name); + } + + // ───────────────────────────────── filesystem + + _modulesDir() { return path.resolve('./modules'); } + + _discoverOnDisk() { + const dir = this._modulesDir(); + if (!fs.existsSync(dir)) return []; + return fs.readdirSync(dir, { withFileTypes: true }) + .filter(d => d.isDirectory()) + .map(d => d.name) + .filter(name => fs.existsSync(path.join(dir, name, `${name}.js`))); + } + + _existsOnDisk(name) { + return fs.existsSync(path.join(this._modulesDir(), name, `${name}.js`)); + } + + // ───────────────────────────────── event dispatch + + _installEventDispatchers() { + const allEvents = new Set(); + for (const m of this.modules.values()) + for (const e of m.options.events) allEvents.add(e); + + for (const event of allEvents) { + if (this.events.has(event)) continue; + this.events.add(event); + this.client.on(event, async (...args) => { + const order = this._dispatchOrderFor(event); + const ctx = new EventContext(event, this); + for (const mod of order) { + if (ctx.propagationStopped) break; + if (!this._isEnabledPersisted(mod.options.name)) continue; + if (!mod.options.events.includes(event)) continue; + try { + await mod.run(this.client, event, ...args, ctx); + } catch (err) { + this.client.errorHandler?.capture(err, { module: mod.options.name, event }); + } + } + }); + } } /** - * @type {Command[]} + * Topologically order the modules listening on a given event by their + * runBefore / runAfter constraints. References to non-listeners are + * ignored. On cycle, fall back to alphabetical with a warning. */ - get commands() { - return [...this.modules.values()].reduce((commands, module) => [...commands, ...module.commands.values()], []); + _dispatchOrderFor(event) { + const listeners = [...this.modules.values()].filter(m => m.options.events.includes(event)); + const names = new Set(listeners.map(m => m.options.name)); + + // Build incoming-edge map: edges[X] = names that must run before X. + const incoming = new Map(listeners.map(m => [m.options.name, new Set()])); + for (const m of listeners) { + for (const after of m.options.runAfter || []) { + if (names.has(after)) incoming.get(m.options.name).add(after); + } + for (const before of m.options.runBefore || []) { + if (names.has(before)) incoming.get(before)?.add(m.options.name); + } + } + + // Kahn's algorithm. + const ordered = []; + const remaining = new Map(listeners.map(m => [m.options.name, m])); + while (remaining.size > 0) { + const ready = [...remaining.keys()].filter(n => incoming.get(n).size === 0).sort(); + if (ready.length === 0) { + this.logger.warn(`Cyclic runBefore/runAfter for ${event}: ${[...remaining.keys()].join(', ')} — falling back to alphabetical.`); + ordered.push(...[...remaining.values()].sort((a, b) => a.options.name.localeCompare(b.options.name))); + break; + } + const next = ready[0]; + ordered.push(remaining.get(next)); + remaining.delete(next); + for (const set of incoming.values()) set.delete(next); + } + return ordered; } -} + + // ───────────────────────────────── Discord-side command sync + + /** + * Re-publish global commands so unloaded modules' slash commands disappear. + * Re-uses the same filter rule InteractionCommandHandler uses on boot. + */ + async _unregisterCommandsWithDiscord() { + try { + if (!this.client.isReady?.()) return; + const cmds = this.commands.filter(c => c.module.options.name !== 'System').map(c => c.toJson()); + await this.client.application.commands.set(cmds); + } catch (err) { + this.client.errorHandler?.capture(err, { source: 'ModuleManager._unregisterCommandsWithDiscord' }); + } + } + + // ───────────────────────────────── result helpers + + _ok(value) { return { ok: true, value }; } + _fail(code, error) { return { ok: false, code, error }; } + + static get ERR() { return ERR; } +}; diff --git a/core/ModulePriorities.js b/core/ModulePriorities.js deleted file mode 100644 index 8f37a2e..0000000 --- a/core/ModulePriorities.js +++ /dev/null @@ -1,7 +0,0 @@ -module.exports = ModulePriorities = Object.freeze({ - HIGHEST: 4, - HIGH: 3, - NORMAL: 2, - LOW: 1, - LOWEST: 0 -}); \ No newline at end of file diff --git a/index.js b/index.js index 6448728..9b166f2 100644 --- a/index.js +++ b/index.js @@ -10,6 +10,7 @@ const Utils = require('./core/Utils.js'); const LocalizationManager = require('./core/LocalizationManager.js'); const ErrorHandler = require('./core/ErrorHandler.js'); const PermissionsManager = require('./core/Permissions.js'); +const Logger = require('./core/Logger.js'); BigInt.prototype.toJSON = function () { return this.toString() } // MDN https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt#use_within_json // Discord @@ -21,6 +22,7 @@ class BotClient extends Client { systemServer: ["1313550337474429001"], intents: Object.keys(GatewayIntentBits).filter(i => isNaN(i)), partials: ['Reaction', 'Message'], + verbose: false, errorReporting: { channelId: null, notifyOwners: false, @@ -34,6 +36,7 @@ class BotClient extends Client { hotReload: false } }); + Logger.verboseEnabled = !!config.get('verbose'); super({ intents: config.get('intents').map(i => GatewayIntentBits[i]), partials: config.get('partials').map(i => Partials[i]) @@ -49,13 +52,14 @@ class BotClient extends Client { this.errorHandler = new ErrorHandler(this, config.get('errorReporting')); this.database = new Database(this); this.permissions = new PermissionsManager(this); - this.moduleManager.init(); } }; -const client = new BotClient(); - -client.login(process.env.TOKEN); +(async () => { + const client = new BotClient(); + await client.moduleManager.init(); + await client.login(process.env.TOKEN); +})(); module.exports = BotClient; diff --git a/modules/Example/Example.js b/modules/Example/Example.js index 96d099e..cf84a46 100644 --- a/modules/Example/Example.js +++ b/modules/Example/Example.js @@ -6,7 +6,6 @@ module.exports = class Example extends Module { super(client, { name: "Example", info: "Very important module", - enabled: true, events: ["clientReady"], config: { exampleString: 'Hello, world!', diff --git a/modules/InteractionCommandHandler/InteractionCommandHandler.js b/modules/InteractionCommandHandler/InteractionCommandHandler.js index be499c4..2cdbb33 100644 --- a/modules/InteractionCommandHandler/InteractionCommandHandler.js +++ b/modules/InteractionCommandHandler/InteractionCommandHandler.js @@ -1,23 +1,19 @@ const Discord = require('discord.js'); const Module = require("@core/Module.js"); const Command = require("@core/Command.js"); -const BotClient = require('../../index.js'); const PowerLevels = require('@core/PowerLevels.js'); -const ModulePriorities = require('@core/ModulePriorities.js'); module.exports = class InteractionCommandHandler extends Module { constructor(client) { super(client, { name: "InteractionCommandHandler", info: "Adds interaction commands support.", - enabled: true, - events: ["clientReady", "interactionCreate"], - priority: ModulePriorities.HIGH + events: ["clientReady", "interactionCreate"] }); } - + /** - * @param {BotClient} client + * @param {import('../../index.js')} client */ async clientReady(client) { try { @@ -29,11 +25,11 @@ module.exports = class InteractionCommandHandler extends Module { } /** - * @param {BotClient} client + * @param {import('../../index.js')} client * @param {Discord.Interaction} interaction - * @param {Module} module + * @param {import('@core/ModuleManager.js').EventContext} ctx */ - async interactionCreate(client, interaction, module) { + async interactionCreate(client, interaction, ctx) { if (!interaction.isCommand() && !interaction.isContextMenuCommand()) return; interaction.user.data = await client.database.forceUser(interaction.user.id); @@ -41,16 +37,18 @@ module.exports = class InteractionCommandHandler extends Module { try { [cmd, cmdModule] = this.client.moduleManager.getCommand(interaction.commandName); if (!cmd) { - await this._safeReply(interaction, { content: ":no_entry: Command not found", flags: [Discord.MessageFlags.Ephemeral] }); - return { cancelEvent: true }; + await this._safeReply(interaction, { content: this.t('errors.command-not-found', interaction), flags: [Discord.MessageFlags.Ephemeral] }); + return ctx?.stopPropagation('command not found'); } if (interaction.user.data.powerlevel < cmd.config.minLevel) { await this._safeReply(interaction, { - content: `:no_entry: You don't have permission to use this command. The required permission level is ${Object.keys(PowerLevels).find(k => PowerLevels[k] == cmd.config.minLevel)}`, + content: this.t('errors.insufficient-powerlevel', interaction, { + level: Object.keys(PowerLevels).find(k => PowerLevels[k] == cmd.config.minLevel) + }), flags: [Discord.MessageFlags.Ephemeral] }); - return { cancelEvent: true }; + return ctx?.stopPropagation('insufficient powerlevel'); } // Per-guild custom-level override (admin-applied via /permissions). @@ -60,14 +58,22 @@ module.exports = class InteractionCommandHandler extends Module { const ok = client.permissions.check(interaction.member, { commandName: cmd.config.name }); if (!ok) { await this._safeReply(interaction, { - content: ":no_entry: A server admin restricted this command to a higher level than yours.", + content: this.t('errors.guild-override-denied', interaction), flags: [Discord.MessageFlags.Ephemeral] }); - return { cancelEvent: true }; + return ctx?.stopPropagation('guild override denied'); } } + const sub = interaction.options.getSubcommand?.(false); + const grp = interaction.options.getSubcommandGroup?.(false); + const cmdPath = [cmd.config.name, grp, sub].filter(Boolean).join(' '); + const where = interaction.guild ? `${interaction.guild.name} (${interaction.guildId})` : 'DM'; + cmdModule.logger.verbose(`/${cmdPath} by ${interaction.user.tag} (${interaction.user.id}) in ${where}`); + + const t0 = Date.now(); await cmd.run(client, interaction, cmdModule); + cmdModule.logger.verbose(`/${cmdPath} completed in ${Date.now() - t0}ms`); } catch (e) { client.errorHandler?.capture(e, { module: cmdModule?.options?.name, @@ -76,19 +82,17 @@ module.exports = class InteractionCommandHandler extends Module { guildId: interaction.guildId || undefined }); await this._safeReply(interaction, { - content: ":no_entry: Uh-oh, there was an error trying to execute the command, please contact bot developers.", + content: this.t('errors.execution-error', interaction), flags: [Discord.MessageFlags.Ephemeral] }); } - return { cancelEvent: true }; + ctx?.stopPropagation('command handled'); } /** * Best-effort interaction reply that won't throw if the interaction was * already replied/deferred or has expired. - * @param {Discord.Interaction} interaction - * @param {Discord.InteractionReplyOptions} payload */ async _safeReply(interaction, payload) { try { diff --git a/modules/InteractionCommandHandler/locales/en-GB.yaml b/modules/InteractionCommandHandler/locales/en-GB.yaml index 1a41f99..2d7bf4c 100644 --- a/modules/InteractionCommandHandler/locales/en-GB.yaml +++ b/modules/InteractionCommandHandler/locales/en-GB.yaml @@ -1,4 +1,6 @@ errors: command-not-found: ":no_entry: Command not found" - insufficient-powerlevel: ":no_entry: You don't have permission to use this command. The required permission level is" + insufficient-powerlevel: ":no_entry: You don't have permission to use this command. The required permission level is **{{level}}**." + guild-override-denied: ":no_entry: A server admin restricted this command to a higher level than yours." + guild-only: ":no_entry: This command can only be used in a server." execution-error: ":no_entry: Uh-oh, there was an error trying to execute the command, please contact bot developers." diff --git a/modules/InteractionCommandHandler/locales/it.yaml b/modules/InteractionCommandHandler/locales/it.yaml index 6435df3..2e15050 100644 --- a/modules/InteractionCommandHandler/locales/it.yaml +++ b/modules/InteractionCommandHandler/locales/it.yaml @@ -1,4 +1,8 @@ errors: command-not-found: ":no_entry: Comando non trovato" - insufficient-powerlevel: ":no_entry: Non hai i permessi per usare questo comando. Il livello richiesto è" - execution-error: ":no_entry: Ops, si è verificato un errore durante l'esecuzione del comando. Contatta gli sviluppatori del bot." + insufficient-powerlevel: ":no_entry: Non hai i permessi per usare questo + comando. Il livello richiesto è" + execution-error: ":no_entry: Ops, si è verificato un errore durante l'esecuzione + del comando. Contatta gli sviluppatori del bot." + guild-override-denied: errors.guild-override-denied + guild-only: errors.guild-only diff --git a/modules/ReadyLog/ReadyLog.js b/modules/ReadyLog/ReadyLog.js index d59ad8b..cbe0d2c 100644 --- a/modules/ReadyLog/ReadyLog.js +++ b/modules/ReadyLog/ReadyLog.js @@ -5,7 +5,6 @@ module.exports = class ReadyLog extends Module { super(client, { name: "ReadyLog", info: "Logs informations once ready and sets the custom status", - enabled: true, events: ["clientReady"] }) } diff --git a/modules/System/System.js b/modules/System/System.js index 9a42236..5e7fff8 100644 --- a/modules/System/System.js +++ b/modules/System/System.js @@ -1,25 +1,40 @@ const Module = require("@core/Module.js"); const Discord = require('discord.js'); const fs = require('fs'); -const ModulePriorities = require("@core/ModulePriorities.js"); +const ModmanUI = require('./lib/ModmanUI.js'); module.exports = class System extends Module { constructor(client) { super(client, { name: "System", info: "Loads the system commands", - enabled: true, - events: ["clientReady", "interactionCreate"], - priority: ModulePriorities.HIGHEST - }) + events: ["clientReady", "interactionCreate"] + }); + + this.modmanUI = new ModmanUI(this); } /** - * @param {import('../../index.js')} client + * Override base `start()` so System keeps its custom dual-collection + * pattern: system commands live in `this.systemCommands` (registered only + * in the system-server guilds) AND are mirrored to `this.commands` so the + * regular getCommand() resolver can find them. */ - async clientReady(client) { - let serverIds = this.client.config.get('systemServer'); + async start(client) { + await this.loadCommands(); // populates this.systemCommands + this.commands = this.systemCommands; // mirror so the manager sees them + } + async stop(client) { + if (this.systemCommands) this.systemCommands.clear(); + this.commands = new Discord.Collection(); + } + + /** + * @param {import('../../index.js')} client + */ + async clientReady(client) { + const serverIds = this.client.config.get('systemServer'); if (!serverIds) { this.logger.error(`System servers not found in config.yml!`); return; @@ -27,26 +42,23 @@ module.exports = class System extends Module { for (const serverId of serverIds) { try { - let systemGuild = await this.client.guilds.fetch(serverId); - + const systemGuild = await this.client.guilds.fetch(serverId); if (!systemGuild) { this.logger.error(`System server not found: ${serverId}. Set it on config.yml!`); + continue; } - await systemGuild.commands.set(this.systemCommands.map(c => c.toJson())); } catch (error) { this.logger.error(`Failed to fetch server ${serverId}: ${error}`); } } - this.commands = this.systemCommands; - // If the bot got rebooted with reboot command, this will edit the message once ready try { const { id, channel } = require("./../reboot.json"); - let c = client.channels.cache.get(channel); + const c = client.channels.cache.get(channel); await c.messages.fetch(); - let m = c.messages.cache.get(id); + const m = c.messages.cache.get(id); await m.edit(":white_check_mark: Rebooted. It took " + ((Date.now() - m.createdTimestamp) / 1000).toFixed(1) + "ms"); fs.unlink("./reboot.json", () => { }); } catch (e) { @@ -59,36 +71,32 @@ module.exports = class System extends Module { * @param {Discord.Interaction} interaction */ async interactionCreate(client, interaction) { - if (!interaction.isAutocomplete()) return; - const command = this.systemCommands.get(interaction.commandName); - if (!command) return; - - if (interaction.commandName == "modman") { - let modules = [...this.client.moduleManager.modules.keys()].filter(m => m != this.options.name); - let options = modules.map(m => ({ name: m, value: m })); - return interaction.respond(options); + // Modman GUI component / modal interactions. + if ((interaction.isMessageComponent?.() || interaction.isModalSubmit?.()) && + interaction.customId?.startsWith('modman:')) { + return this.modmanUI.handle(interaction); } } - // Override + // Override loadCommands: System keeps its commands in `this.systemCommands` + // because they're registered to system-server guilds only, separate from the + // global slash command set ICH publishes. async loadCommands() { this.systemCommands = new Discord.Collection(); - const commands = fs.existsSync(`@modules/${this.options.name}/commands`) ? fs.readdirSync(`@modules/${this.options.name}/commands`).filter(file => file.endsWith(".js")) : []; + const dir = `./modules/${this.options.name}/commands`; + const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(file => file.endsWith(".js")) : []; - commands.forEach(file => { + for (const file of files) { try { - /** - * @type {import('./InteractionCommand')} - */ - const command = require(`@modules/${this.options.name}/commands/${file}`); - delete require.cache[require.resolve(`../../modules/${this.options.name}/commands/${file}`)]; - const _command = new command(this.client, this); - - this.systemCommands.set(file.split(".")[0], _command); + const commandPath = require.resolve(`@modules/${this.options.name}/commands/${file}`); + delete require.cache[commandPath]; + const CommandClass = require(commandPath); + const command = new CommandClass(this.client, this); + this.systemCommands.set(file.split(".")[0], command); this.logger.verbose(`Loaded system command ${file.split(".")[0]} from ${this.options.name}`); } catch (e) { this.logger.error(`Failed to load system command ${file} from ${this.options.name}: ${e.stack || e}`); } - }); + } } -} +}; diff --git a/modules/System/commands/modman.js b/modules/System/commands/modman.js index 6db57d3..3590826 100644 --- a/modules/System/commands/modman.js +++ b/modules/System/commands/modman.js @@ -1,210 +1,26 @@ const Command = require('@core/Command.js'); -let { EmbedBuilder, ApplicationCommandOptionType} = require('discord.js'); -const PowerLevels = require("@core/PowerLevels.js"); +const PowerLevels = require('@core/PowerLevels.js'); -module.exports = class ModManCommand extends Command { +/** + * Entry point for the in-Discord module-manager GUI. The actual rendering + * and routing lives in `modules/System/lib/ModmanUI.js`; this command just + * opens the panel ephemerally. Bot-OWNER only. + */ +module.exports = class ModmanCommand extends Command { constructor(client, module) { super(client, module, { - name: "modman", - description: "Manipulate Bot Modules", + name: 'modman', + description: 'Manipulate Bot Modules', cooldown: 3, - minLevel: PowerLevels.OWNER, - options: [ - { - name: "load", - description: "Load a module", - type: ApplicationCommandOptionType.Subcommand, - options: [ - { - name: "module", - description: "Module to perform action on", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - } - ] - }, - { - name: "unload", - description: "Unload a module", - type: ApplicationCommandOptionType.Subcommand, - options: [ - { - name: "module", - description: "Module to perform action on", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - } - ] - }, - { - name: "reload", - description: "Reload a module", - type: ApplicationCommandOptionType.Subcommand, - options: [ - { - name: "module", - description: "Module to perform action on", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - } - ] - }, - { - name: "enable", - description: "Enable a module", - type: ApplicationCommandOptionType.Subcommand, - options: [ - { - name: "module", - description: "Module to perform action on", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - } - ] - }, - { - name: "disable", - description: "Disable a module", - type: ApplicationCommandOptionType.Subcommand, - options: [ - { - name: "module", - description: "Module to perform action on", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - } - ] - }, - { - name: "info", - description: "Get information about a module", - type: ApplicationCommandOptionType.Subcommand, - options: [ - { - name: "module", - description: "Module to perform action on", - type: ApplicationCommandOptionType.String, - required: true, - autocomplete: true - } - ] - }, - { - name: "list", - description: "Get a list of loaded modules", - type: ApplicationCommandOptionType.Subcommand, - } - ] + minLevel: PowerLevels.OWNER }); } /** - * - * @param {import('../../../index.js')} client - * @param {import('discord.js').ChatInputCommandInteraction} interaction - * @param {import('@core/Module.js')} module + * @param {import('../../../index.js')} client + * @param {import('discord.js').ChatInputCommandInteraction} interaction */ - async run(client, interaction, module) { - let embed = new EmbedBuilder() - .setTitle("Module Manager"); - - let moduleName = interaction.options.getString("module"); - if (moduleName == module.options.name) { - embed - .setColor(0xFF0000) - .setDescription(`:x: Cannot perform actions on module **${moduleName}**`); - - return await interaction.reply({ embeds: [embed] }); - } - - let Manager = client.moduleManager; - let response; - switch (interaction.options.getSubcommand()) { - case "load": - response = await Manager.load(moduleName) - embed - .setColor(0x00FF00) - .setDescription(`:white_check_mark: **${moduleName}** successfully loaded`) - if(!response.error) return await interaction.reply({ embeds: [embed] }) - embed - .setColor(0xFF0000) - .setDescription(`:x: There was an error trying to load **${moduleName}**:\`\`\`${response.error}\`\`\``) - return await interaction.reply({ embeds: [embed] }) - case "unload": - response = await Manager.unload(moduleName) - embed - .setColor(0x00FF00) - .setDescription(`:white_check_mark: **${moduleName}** successfully unloaded`) - if(response) return await interaction.reply({ embeds: [embed] }) - embed - .setColor(0xFF0000) - .setDescription(`:x: There was an error trying to unload **${moduleName}**, is it even loaded?`) - return await interaction.reply({ embeds: [embed] }) - case "reload": - response = await Manager.reload(moduleName) - embed - .setColor(0x00FF00) - .setDescription(`:white_check_mark: **${moduleName}** successfully reloaded`) - if(response) return await interaction.reply({ embeds: [embed] }) - embed - .setColor(0xFF0000) - .setDescription(`:x: There was an error trying to reload **${moduleName}**, is it even loaded?`) - return await interaction.reply({ embeds: [embed] }) - case "enable": - response = await Manager.enable(moduleName) - embed - .setColor(0x00FF00) - .setDescription(`:white_check_mark: **${moduleName}** successfully enabled and executed`) - if(response) return await interaction.reply({ embeds: [embed] }) - embed - .setColor(0xFF0000) - .setDescription(`:x: There was an error trying to enable **${moduleName}**, is it even loaded?`) - return await interaction.reply({ embeds: [embed] }) - case "disable": - response = await Manager.disable(moduleName) - embed - .setColor(0x00FF00) - .setDescription(`:white_check_mark: **${moduleName}** successfully disabled`) - if(response) return await interaction.reply({ embeds: [embed] }) - embed - .setColor(0xFF0000) - .setDescription(`:x: There was an error trying to disable **${moduleName}**, is it even loaded?`) - return await interaction.reply({ embeds: [embed] }) - case "info": - response = await Manager.info(moduleName) - - embed - .setTitle(`Module Manager`) - .setColor(0xFF0000) - .setDescription(`:x: There was an error trying to fetch informations from **${moduleName}**:\`\`\`${response.error}\`\`\``) - if(response.error) return await interaction.reply({ embeds: [embed] }) - - embed - .setTitle(`Module Manager - ${moduleName}`) - .setColor(0x0000FF) - .setDescription(response.description) - .addFields( - { name: "Enabled", value: `${response.enabled}`, inline: true }, - { name: "Loaded", value: `${response.loaded}`, inline: true }, - { name: "Triggering Events", value: Array.isArray(response.events) ? response.events.join(", ") : response.events, inline: true } - ) - return await interaction.reply({ embeds: [embed] }) - case "list": - embed - .setTitle("Module Manager") - .addFields({ name : "Loaded", value: Manager.list.loaded || "_Nothing_", inline: true }) - .setColor(0x0000FF) - Manager.list.unloaded ? embed.addFields({ name: "Unloaded", value: Manager.list.unloaded, inline: true }) : undefined - await interaction.reply({ embeds: [embed] }) - break; - default: - await interaction.reply(`:warning: Correct usage \`modman (list|load|unload|enable|disable|info) \``) - break; - } + async run(client, interaction) { + await this.module.modmanUI.open(interaction); } -} +}; diff --git a/modules/System/lib/ModmanUI.js b/modules/System/lib/ModmanUI.js new file mode 100644 index 0000000..66deaa4 --- /dev/null +++ b/modules/System/lib/ModmanUI.js @@ -0,0 +1,276 @@ +const { + EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, + StringSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, + MessageFlags +} = require('discord.js'); + +/** + * Interactive in-Discord GUI for managing bot modules. + * Custom-id convention: `modman:[:...]`. State that needs to + * survive across interactions piggybacks on customIds; Discord components + * are stateless. All user-facing strings flow through the locale system — + * see modules/System/locales/.yaml under `commands.modman.ui.*`. + */ +module.exports = class ModmanUI { + /** + * @param {import('../System.js')} systemModule + */ + constructor(systemModule) { + this.module = systemModule; + this.client = systemModule.client; + } + + /** Localize a UI string under `commands.modman.ui.` for this interaction's locale. */ + _t(key, interaction, vars) { + return this.module.t(`commands.modman.ui.${key}`, interaction, vars); + } + + /** Slash command entry — open the home panel ephemerally. */ + async open(interaction) { + await interaction.reply({ ...this._home(interaction), flags: MessageFlags.Ephemeral }); + } + + /** + * @param {import('discord.js').Interaction} interaction + */ + async handle(interaction) { + const id = interaction.customId; + if (!id?.startsWith('modman:')) return false; + const [, screen, ...args] = id.split(':'); + + try { + switch (screen) { + case 'home': return this._update(interaction, this._home(interaction)); + case 'close': return interaction.update({ content: this._t('errors.closed', interaction), embeds: [], components: [] }); + case 'pick': return this._update(interaction, this._detail(interaction, interaction.values[0])); + case 'detail': return this._update(interaction, this._detail(interaction, args[0])); + case 'reload': return this._action(interaction, args[0], 'reload'); + case 'toggle': return this._action(interaction, args[0], 'toggle'); + case 'unload': return this._unloadFlow(interaction, args[0]); + case 'unload_force': return this._action(interaction, args[0], 'unload_force'); + case 'load_btn': return this._loadModal(interaction); + case 'load_modal': return this._loadFromModal(interaction); + case 'reload_all': return this._reloadAll(interaction); + } + } catch (err) { + this.client.errorHandler?.capture(err, { source: 'ModmanUI', userId: interaction.user?.id }); + await this._safeError(interaction, err.message); + } + return true; + } + + // ───── HOME ───── + + _home(interaction) { + const { loaded, available, failed } = this.client.moduleManager.list(); + const allNames = [...new Set([...loaded, ...available, ...failed.map(f => f.name)])].sort(); + + const lines = allNames.map(name => `${this._badge(name)} \`${name}\``); + const failedSummary = failed.length + ? `\n\n${this._t('home.failed-summary', interaction)}\n${failed.map(f => `⚠ \`${f.name}\` — ${this._truncate(f.error, 80)}`).join('\n')}` + : ''; + + const embed = new EmbedBuilder() + .setTitle(this._t('home.title', interaction)) + .setDescription(this._t('home.description', interaction) + failedSummary) + .addFields({ name: this._t('home.all-modules', interaction), value: lines.join('\n') || this._t('home.none', interaction) }) + .setFooter({ text: this._t('home.footer', interaction, { loaded: loaded.length, available: available.length, failed: failed.length }) }); + + const components = []; + if (allNames.length > 0) { + components.push(new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('modman:pick') + .setPlaceholder(this._t('home.pick-placeholder', interaction)) + .addOptions(allNames.slice(0, 25).map(n => ({ + label: n, + description: this._statusText(interaction, n), + value: n + }))) + )); + } + components.push(new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('modman:load_btn').setStyle(ButtonStyle.Primary).setLabel(this._t('buttons.load-module', interaction)).setEmoji('➕'), + new ButtonBuilder().setCustomId('modman:reload_all').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.reload-all', interaction)).setEmoji('🔁'), + new ButtonBuilder().setCustomId('modman:home').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.refresh', interaction)).setEmoji('🔄'), + new ButtonBuilder().setCustomId('modman:close').setStyle(ButtonStyle.Danger).setLabel(this._t('buttons.close', interaction)).setEmoji('❌') + )); + + return { content: '', embeds: [embed], components }; + } + + _badge(name) { + const mm = this.client.moduleManager; + if (!mm.isLoaded(name) && !mm.isOnDisk(name)) return '❓'; + if (!mm.isLoaded(name)) { + const failed = mm.list().failed.some(f => f.name === name); + return failed ? '⚠' : '⛔'; + } + return mm.isEnabled(name) ? '✅' : '🟡'; + } + + _statusText(interaction, name) { + const mm = this.client.moduleManager; + if (!mm.isLoaded(name)) { + const failed = mm.list().failed.some(f => f.name === name); + return this._t(failed ? 'status.load-failed' : 'status.not-loaded', interaction); + } + return this._t(mm.isEnabled(name) ? 'status.enabled' : 'status.disabled', interaction); + } + + // ───── DETAIL ───── + + _detail(interaction, name) { + const mm = this.client.moduleManager; + const r = mm.info(name); + if (!r.ok) return this._errorPanel(interaction, this._t('detail.not-found', interaction, { name, error: r.error })); + const i = r.value; + + const none = this._t('detail.none', interaction); + const embed = new EmbedBuilder() + .setTitle(`🧩 ${name}`) + .setDescription(i.info || this._t('detail.no-description', interaction)) + .addFields( + { name: this._t('detail.status', interaction), value: this._statusText(interaction, name), inline: true }, + { name: this._t('detail.version', interaction), value: i.version || none, inline: true }, + { name: this._t('detail.events', interaction), value: i.events?.length ? i.events.map(e => `\`${e}\``).join(', ') : none, inline: false }, + { name: this._t('detail.dependencies', interaction), value: i.dependencies?.length ? i.dependencies.map(d => `\`${d}\``).join(', ') : none, inline: true }, + { name: this._t('detail.dependents', interaction), value: i.dependents?.length ? i.dependents.map(d => `\`${d}\``).join(', ') : none, inline: true }, + { name: this._t('detail.commands', interaction), value: i.commands?.length ? i.commands.map(c => `\`/${c}\``).join(', ') : none, inline: false } + ); + if (i.lastError) embed.addFields({ name: this._t('detail.last-error', interaction), value: '```' + this._truncate(i.lastError, 1000) + '```' }); + + const components = []; + const buttons1 = []; + if (i.loaded) { + buttons1.push( + new ButtonBuilder().setCustomId(`modman:reload:${name}`).setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.reload', interaction)).setEmoji('🔁'), + new ButtonBuilder().setCustomId(`modman:toggle:${name}`).setStyle(i.enabled ? ButtonStyle.Secondary : ButtonStyle.Success) + .setLabel(this._t(i.enabled ? 'buttons.disable' : 'buttons.enable', interaction)).setEmoji(i.enabled ? '🟡' : '✅'), + new ButtonBuilder().setCustomId(`modman:unload:${name}`).setStyle(ButtonStyle.Danger).setLabel(this._t('buttons.unload', interaction)).setEmoji('🗑️') + ); + } else if (mm.isOnDisk(name)) { + buttons1.push(new ButtonBuilder().setCustomId(`modman:reload:${name}`).setStyle(ButtonStyle.Primary).setLabel(this._t('buttons.load', interaction)).setEmoji('➕')); + } + buttons1.push(new ButtonBuilder().setCustomId('modman:home').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.back', interaction)).setEmoji('⬅️')); + + components.push(new ActionRowBuilder().addComponents(...buttons1)); + return { embeds: [embed], components }; + } + + // ───── ACTIONS ───── + + async _action(interaction, name, kind) { + const mm = this.client.moduleManager; + let result; + switch (kind) { + case 'reload': + result = mm.isLoaded(name) ? await mm.reload(name) : await mm.load(name); + break; + case 'toggle': + result = mm.isEnabled(name) ? await mm.disable(name) : await mm.enable(name); + break; + case 'unload_force': + result = await mm.unload(name, { force: true }); + break; + } + if (!result?.ok) + return this._update(interaction, this._errorPanel(interaction, this._t('errors.action-failed', interaction, { + action: kind, name, code: result?.code || 'ERROR', error: result?.error || 'unknown' + }))); + return this._update(interaction, this._detail(interaction, name)); + } + + async _unloadFlow(interaction, name) { + const mm = this.client.moduleManager; + const r = await mm.unload(name); + if (r.ok) return this._update(interaction, this._home(interaction)); + + if (r.code === 'DEPENDENCY_LOCKED') { + const embed = new EmbedBuilder() + .setTitle(this._t('unload.title', interaction, { name })) + .setDescription(this._t('unload.cascade-warning', interaction, { deps: r.error })); + const buttons = new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(`modman:unload_force:${name}`).setStyle(ButtonStyle.Danger).setLabel(this._t('buttons.force-unload', interaction)).setEmoji('💥'), + new ButtonBuilder().setCustomId(`modman:detail:${name}`).setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.cancel', interaction)).setEmoji('⬅️') + ); + return this._update(interaction, { embeds: [embed], components: [buttons] }); + } + return this._update(interaction, this._errorPanel(interaction, this._t('unload.failed', interaction, { code: r.code, error: r.error }))); + } + + async _reloadAll(interaction) { + const mm = this.client.moduleManager; + const loaded = mm.list().loaded; + const results = []; + for (const name of loaded) { + const r = await mm.reload(name); + results.push(`${r.ok ? '✅' : '❌'} \`${name}\`${r.ok ? '' : ` — ${r.error}`}`); + } + const embed = new EmbedBuilder() + .setTitle(this._t('reload-all.title', interaction)) + .setDescription(results.join('\n') || this._t('reload-all.none', interaction)); + return this._update(interaction, { + embeds: [embed], + components: [new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('modman:home').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.back', interaction)).setEmoji('⬅️') + )] + }); + } + + async _loadModal(interaction) { + const modal = new ModalBuilder() + .setCustomId('modman:load_modal') + .setTitle(this._t('load-modal.title', interaction)); + modal.addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder().setCustomId('name').setLabel(this._t('load-modal.name-label', interaction)) + .setPlaceholder(this._t('load-modal.name-placeholder', interaction)).setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(64) + ) + ); + await interaction.showModal(modal); + } + + async _loadFromModal(interaction) { + const name = interaction.fields.getTextInputValue('name').trim(); + const r = await this.client.moduleManager.load(name); + if (!r.ok) + return this._update(interaction, this._errorPanel(interaction, this._t('errors.load-failed', interaction, { code: r.code, error: r.error }))); + return this._update(interaction, this._detail(interaction, name)); + } + + // ───── helpers ───── + + _errorPanel(interaction, message) { + const embed = new EmbedBuilder() + .setTitle(this._t('home.title', interaction)) + .setDescription(`:x: ${message}`) + .setColor(0xE74C3C); + return { + embeds: [embed], + components: [new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('modman:home').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.back', interaction)).setEmoji('⬅️') + )] + }; + } + + async _update(interaction, payload) { + if (interaction.replied || interaction.deferred) + return interaction.editReply(payload); + return interaction.update(payload); + } + + async _safeError(interaction, message) { + const payload = { content: `:x: ${message}`, embeds: [], components: [], flags: MessageFlags.Ephemeral }; + try { + if (interaction.replied || interaction.deferred) return interaction.followUp(payload); + return interaction.reply(payload); + } catch { /* swallow */ } + } + + _truncate(s, max) { + if (s == null) return ''; + s = String(s); + return s.length <= max ? s : s.slice(0, max - 8) + '…[…]'; + } +}; diff --git a/modules/System/locales/en-GB.yaml b/modules/System/locales/en-GB.yaml index 9c80d88..cc0de26 100644 --- a/modules/System/locales/en-GB.yaml +++ b/modules/System/locales/en-GB.yaml @@ -11,53 +11,68 @@ commands: command: The system command to execute. modman: name: modman - description: Manage modules (load, unload, reload). - options: - load: - name: load - description: Loads a module - options: - module: - name: module - description: Module to perform the action on + description: Manage bot modules (load, unload, reload, enable/disable). + ui: + home: + title: 🧩 Module Manager + description: | + Inspect, enable/disable, reload, or unload modules. Picking a module from the dropdown opens its detail view with action buttons. + + **Status badges** + ✅ loaded & enabled · 🟡 loaded & disabled · ⛔ on disk, not loaded · ⚠ load failed + failed-summary: "**Failed loads:**" + all-modules: All modules + none: _(none on disk)_ + footer: "{{loaded}} loaded · {{available}} available · {{failed}} failed" + pick-placeholder: Pick a module to inspect… + detail: + no-description: _(no description)_ + status: Status + version: Version + events: Events + dependencies: Dependencies + dependents: Dependents + commands: Commands + last-error: Last error + none: _(none)_ + not-found: "Cannot inspect `{{name}}`: {{error}}" + status: + load-failed: Load failed + not-loaded: On disk, not loaded + enabled: Loaded & enabled + disabled: Loaded but disabled + buttons: + reload: Reload + load: Load + enable: Enable + disable: Disable + unload: Unload + back: Back + cancel: Cancel + force-unload: Force unload + load-module: Load module + reload-all: Reload all + refresh: Refresh + close: Close unload: - name: unload - description: Unloads a module - options: - module: - name: module - description: Module to perform the action on - reload: - name: reload - description: Reloads a module - options: - module: - name: module - description: Module to perform the action on - enable: - name: enable - description: Enable a module - options: - module: - name: module - description: Module to perform the action on - disable: - name: disable - description: Disable a module - options: - module: - name: module - description: Module to perform the action on - info: - name: info - description: Get information about a module - options: - module: - name: module - description: Module to perform the action on - list: - name: list - description: Get a list of loaded modules + title: 🗑️ Unload `{{name}}` + cascade-warning: | + Other modules depend on this one: + {{deps}} + + Force-unload will cascade and unload them all in dependency order. + failed: "Unload failed: **{{code}}** — {{error}}" + reload-all: + title: 🔁 Reload all + none: _(no modules loaded)_ + load-modal: + title: Load module + name-label: Module name (matches folder under modules/) + name-placeholder: Example + errors: + action-failed: "`{{action}}` failed for `{{name}}`: **{{code}}** — {{error}}" + load-failed: "Load failed: **{{code}}** — {{error}}" + closed: Closed. reboot: name: reboot description: Reboots the bot if running under PM2 or a process manager. diff --git a/modules/System/locales/it.yaml b/modules/System/locales/it.yaml index 84f652c..4fb5066 100644 --- a/modules/System/locales/it.yaml +++ b/modules/System/locales/it.yaml @@ -58,6 +58,59 @@ commands: list: name: list description: Ottieni la lista dei moduli caricati + ui: + home: + title: commands.modman.ui.home.title + description: commands.modman.ui.home.description + failed-summary: commands.modman.ui.home.failed-summary + all-modules: commands.modman.ui.home.all-modules + none: commands.modman.ui.home.none + footer: commands.modman.ui.home.footer + pick-placeholder: commands.modman.ui.home.pick-placeholder + detail: + no-description: commands.modman.ui.detail.no-description + status: commands.modman.ui.detail.status + version: commands.modman.ui.detail.version + events: commands.modman.ui.detail.events + dependencies: commands.modman.ui.detail.dependencies + dependents: commands.modman.ui.detail.dependents + commands: commands.modman.ui.detail.commands + last-error: commands.modman.ui.detail.last-error + none: commands.modman.ui.detail.none + not-found: commands.modman.ui.detail.not-found + status: + load-failed: commands.modman.ui.status.load-failed + not-loaded: commands.modman.ui.status.not-loaded + enabled: commands.modman.ui.status.enabled + disabled: commands.modman.ui.status.disabled + buttons: + reload: commands.modman.ui.buttons.reload + load: commands.modman.ui.buttons.load + enable: commands.modman.ui.buttons.enable + disable: commands.modman.ui.buttons.disable + unload: commands.modman.ui.buttons.unload + back: commands.modman.ui.buttons.back + cancel: commands.modman.ui.buttons.cancel + force-unload: commands.modman.ui.buttons.force-unload + load-module: commands.modman.ui.buttons.load-module + reload-all: commands.modman.ui.buttons.reload-all + refresh: commands.modman.ui.buttons.refresh + close: commands.modman.ui.buttons.close + unload: + title: commands.modman.ui.unload.title + cascade-warning: commands.modman.ui.unload.cascade-warning + failed: commands.modman.ui.unload.failed + reload-all: + title: commands.modman.ui.reload-all.title + none: commands.modman.ui.reload-all.none + load-modal: + title: commands.modman.ui.load-modal.title + name-label: commands.modman.ui.load-modal.name-label + name-placeholder: commands.modman.ui.load-modal.name-placeholder + errors: + action-failed: commands.modman.ui.errors.action-failed + load-failed: commands.modman.ui.errors.load-failed + closed: commands.modman.ui.errors.closed reboot: name: reboot description: Riavvia il bot se in esecuzione tramite PM2 o un process manager. @@ -74,4 +127,4 @@ commands: name: update description: Aggiorna il bot all’ultima versione dal repository Git e riavvia il bot messages: - noreboot: "Comando `reboot` sconosciuto, annullo l'operazione." + noreboot: Comando `reboot` sconosciuto, annullo l'operazione. diff --git a/modules/Utility/Utility.js b/modules/Utility/Utility.js index 28dac60..d1fc1a6 100644 --- a/modules/Utility/Utility.js +++ b/modules/Utility/Utility.js @@ -1,13 +1,12 @@ const Module = require("@core/Module.js"); -const ModulePriorities = require("@core/ModulePriorities.js"); const PermissionsUI = require("./lib/PermissionsUI.js"); +const SettingsUI = require("./lib/SettingsUI.js"); module.exports = class Utility extends Module { constructor(client) { super(client, { name: "Utility", info: "Loads the utility commands", - enabled: true, events: ["interactionCreate"], settings: { defaultServerLanguage: { @@ -19,6 +18,7 @@ module.exports = class Utility extends Module { }); this.permissionsUI = new PermissionsUI(this); + this.settingsUI = new SettingsUI(this); } /** @@ -26,10 +26,11 @@ module.exports = class Utility extends Module { * @param {import('discord.js').Interaction} interaction */ async interactionCreate(client, interaction) { - // Component / modal interactions belonging to the permissions GUI. - if ((interaction.isMessageComponent?.() || interaction.isModalSubmit?.()) && - interaction.customId?.startsWith('perms:')) { - return this.permissionsUI.handle(interaction); + // Component / modal interactions belonging to one of the GUIs. + if (interaction.isMessageComponent?.() || interaction.isModalSubmit?.()) { + const id = interaction.customId || ''; + if (id.startsWith('perms:')) return this.permissionsUI.handle(interaction); + if (id.startsWith('settings:')) return this.settingsUI.handle(interaction); } if (!interaction.isAutocomplete()) return; @@ -42,26 +43,5 @@ module.exports = class Utility extends Module { value: lang }))); } - if (interaction.commandName == "settings") { - switch (interaction.options.getFocused(true).name) { - case "module": - let modules = [...client.moduleManager.modules.values()].filter(x => x.options.settings).map(x => x.options.name); - let options = modules.map(m => ({ name: m, value: m })); - return interaction.respond(options); - case "key": - const moduleName = interaction.options.getString("module"); - const moduleSettings = this.client.moduleManager.modules.get(moduleName)?.settings; - if (!moduleSettings) return interaction.respond([]); - const schema = moduleSettings.schema; - const isArrayType = (k) => String(schema[k].type).startsWith('array<'); - const sub = interaction.options.getSubcommand(); - const keys = Object.keys(schema); - const filtered = - sub === 'add' || sub === 'remove' ? keys.filter(isArrayType) : - sub === 'set' ? keys.filter(k => !isArrayType(k)) : - keys; - return interaction.respond(filtered.map(k => ({ name: k, value: k }))); - } - } } } diff --git a/modules/Utility/commands/settings.js b/modules/Utility/commands/settings.js index 1078a04..04b6750 100644 --- a/modules/Utility/commands/settings.js +++ b/modules/Utility/commands/settings.js @@ -1,63 +1,19 @@ const Command = require('@core/Command.js'); -const { ApplicationCommandOptionType, EmbedBuilder, MessageFlags, PermissionsBitField } = require('discord.js'); -const { Pagination } = require('pagination.djs'); - -module.exports = class Settings extends Command { +const { PermissionsBitField } = require('discord.js'); + +/** + * Entry point for the in-Discord settings GUI. Rendering and routing live in + * `modules/Utility/lib/SettingsUI.js`; this command just opens the panel + * ephemerally. Per-key access can be further restricted by an admin via + * `/permissions → Setting overrides`. + */ +module.exports = class SettingsCommand extends Command { constructor(client, module) { super(client, module, { name: 'settings', - description: 'View, add or remove settings from this guild.', + description: 'View or edit per-guild settings.', defaultMemberPermissions: [PermissionsBitField.Flags.ManageGuild], - guildOnly: true, - options: [ - { - name: "view", - description: "View the current settings for this server.", - type: ApplicationCommandOptionType.Subcommand, - options: [ - { name: "module", description: "Module to view settings for", type: ApplicationCommandOptionType.String, required: false, autocomplete: true } - ] - }, - { - name: "set", - description: "Set the value of a key", - type: ApplicationCommandOptionType.Subcommand, - options: [ - { name: "module", description: "Module to set key of", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, - { name: "key", description: "Key to change", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, - { name: "value", description: "Value to set", type: ApplicationCommandOptionType.String, required: true } - ] - }, - { - name: "add", - description: "Add a value to an array key", - type: ApplicationCommandOptionType.Subcommand, - options: [ - { name: "module", description: "Module", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, - { name: "key", description: "Array key", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, - { name: "value", description: "Value to add", type: ApplicationCommandOptionType.String, required: true } - ] - }, - { - name: "remove", - description: "Remove a value from an array key", - type: ApplicationCommandOptionType.Subcommand, - options: [ - { name: "module", description: "Module", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, - { name: "key", description: "Array key", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, - { name: "value", description: "Value to remove", type: ApplicationCommandOptionType.String, required: true } - ] - }, - { - name: "reset", - description: "Reset a key to its default value", - type: ApplicationCommandOptionType.Subcommand, - options: [ - { name: "module", description: "Module", type: ApplicationCommandOptionType.String, required: true, autocomplete: true }, - { name: "key", description: "Key to reset", type: ApplicationCommandOptionType.String, required: true, autocomplete: true } - ] - } - ] + guildOnly: true }); } @@ -66,98 +22,6 @@ module.exports = class Settings extends Command { * @param {import('discord.js').ChatInputCommandInteraction} interaction */ async run(client, interaction) { - const guild = interaction.guild; - const actor = interaction.member; - const sub = interaction.options.getSubcommand(); - const moduleName = interaction.options.getString("module"); - const key = interaction.options.getString("key"); - const value = interaction.options.getString("value"); - - const manager = moduleName ? client.settings.get(moduleName) : null; - if (sub !== 'view' && !manager) { - return interaction.reply({ content: `:x: No settings registered for module \`${moduleName}\`.`, flags: MessageFlags.Ephemeral }); - } - - try { - switch (sub) { - case "set": { - const coerced = manager.set(guild.id, key, value, { actor }); - const embed = new EmbedBuilder() - .setTitle(this.t('embeds.set.title', interaction)) - .setDescription(this.t('embeds.set.description', interaction, { key, value: this._format(coerced) })); - return interaction.reply({ embeds: [embed] }); - } - case "add": { - const arr = manager.add(guild.id, key, value, { actor }); - const embed = new EmbedBuilder() - .setTitle(this.t('embeds.add.title', interaction)) - .setDescription(this.t('embeds.add.description', interaction, { key, value: this._format(arr) })); - return interaction.reply({ embeds: [embed] }); - } - case "remove": { - const arr = manager.remove(guild.id, key, value, { actor }); - const embed = new EmbedBuilder() - .setTitle(this.t('embeds.remove.title', interaction)) - .setDescription(this.t('embeds.remove.description', interaction, { key, value: this._format(arr) })); - return interaction.reply({ embeds: [embed] }); - } - case "reset": { - const reset = manager.reset(guild.id, key, { actor }); - const embed = new EmbedBuilder() - .setTitle(this.t('embeds.reset.title', interaction)) - .setDescription(this.t('embeds.reset.description', interaction, { key, value: this._format(reset) })); - return interaction.reply({ embeds: [embed] }); - } - case "view": { - return this._view(interaction, moduleName); - } - } - } catch (err) { - return interaction.reply({ content: `:x: ${err.message}`, flags: MessageFlags.Ephemeral }); - } - } - - async _view(interaction, moduleNameFilter) { - const client = this.client; - const guild = interaction.guild; - const pagination = new Pagination(interaction); - const embeds = []; - - const targets = moduleNameFilter - ? [[moduleNameFilter, client.settings.get(moduleNameFilter)]].filter(([, v]) => v) - : [...client.settings.entries()]; - - for (const [name, manager] of targets) { - const record = manager.get(guild.id); - const schema = manager.schema; - const lines = Object.entries(schema).map(([k, def]) => { - const v = record.settings[k]; - return `\`${k}\`: ${this._format(v)} — \`${def.type}\``; - }); - const embed = new EmbedBuilder() - .setTitle(this.t('embeds.view.settings', interaction, { module: name })) - .setDescription(lines.length ? lines.join('\n') : '_(no keys)_'); - embeds.push(embed); - } - - if (embeds.length === 0) { - embeds.push(new EmbedBuilder() - .setTitle(this.t('embeds.view.nosettings.title', interaction)) - .setDescription(this.t('embeds.view.nosettings.description', interaction)) - .setColor('Random')); - } - - pagination.setAuthorizedUsers([interaction.user.id]); - pagination.setEmbeds(embeds, async (embed, index, array) => { - return embed.setFooter({ text: this.t('embeds.view.page', interaction, { page: index + 1, totalPages: array.length }) }); - }); - await pagination.render(); - } - - _format(v) { - if (v == null) return '_unset_'; - if (Array.isArray(v)) return v.length ? v.map(x => `\`${x}\``).join(', ') : '_empty_'; - if (typeof v === 'boolean') return v ? '`true`' : '`false`'; - return `\`${v}\``; + await this.module.settingsUI.open(interaction); } -} +}; diff --git a/modules/Utility/lib/PermissionsUI.js b/modules/Utility/lib/PermissionsUI.js index 68f3b95..341141b 100644 --- a/modules/Utility/lib/PermissionsUI.js +++ b/modules/Utility/lib/PermissionsUI.js @@ -2,17 +2,16 @@ const { EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, StringSelectMenuBuilder, RoleSelectMenuBuilder, UserSelectMenuBuilder, ModalBuilder, TextInputBuilder, TextInputStyle, - MessageFlags, ComponentType + MessageFlags } = require('discord.js'); /** * Interactive in-Discord GUI for managing per-guild permission levels. - * Replaces the previous 8-subcommand /permissions interface with a single - * panel message that re-renders in place as the admin navigates. - * * Custom-id convention: `perms:[:...]`. State that needs to * survive across interactions is encoded into the customId of the next - * component (Discord components are stateless). + * component (Discord components are stateless). All user-facing strings flow + * through the locale system — see modules/Utility/locales/.yaml under + * `commands.permissions.ui.*`. */ module.exports = class PermissionsUI { /** @@ -23,26 +22,29 @@ module.exports = class PermissionsUI { this.client = utilityModule.client; } - /** Slash command entry point — open the main panel ephemerally. */ + /** Localize a UI string under `commands.permissions.ui.`. */ + _t(key, interaction, vars) { + return this.module.t(`commands.permissions.ui.${key}`, interaction, vars); + } + async open(interaction) { - await interaction.reply({ ...this._home(interaction.guild.id), flags: MessageFlags.Ephemeral }); + await interaction.reply({ ...this._home(interaction), flags: MessageFlags.Ephemeral }); } /** - * Top-level dispatcher. Returns true if this UI handled the interaction. * @param {import('discord.js').Interaction} interaction */ async handle(interaction) { const id = interaction.customId; if (!id?.startsWith('perms:')) return false; - const parts = id.split(':'); // ['perms', 'screen', ...] + const parts = id.split(':'); const screen = parts[1]; const args = parts.slice(2); try { switch (screen) { - case 'home': return this._update(interaction, this._home(interaction.guild.id)); - case 'close': return interaction.update({ content: 'Closed.', embeds: [], components: [] }); + case 'home': return this._update(interaction, this._home(interaction)); + case 'close': return interaction.update({ content: this._t('errors.closed', interaction), embeds: [], components: [] }); case 'nav': return this._nav(interaction, args[0]); case 'level': return this._level(interaction, args); case 'role': return this._role(interaction, args); @@ -57,50 +59,43 @@ module.exports = class PermissionsUI { return true; } - _home(guildId) { + // ───── HOME ───── + + _home(interaction) { + const guildId = interaction.guild.id; const cfg = this.client.permissions.getConfig(guildId); const ladder = [...cfg.levels].sort((a, b) => a.weight - b.weight); + const defaultTag = this._t('levels.default-tag', interaction); const summary = ladder - .map(l => `**${l.weight}** · \`${l.id}\` ${l.name}${l.builtin ? ' *(default)*' : ''} — ${l.roles.length} role(s)`) - .join('\n') || '_(no levels)_'; + .map(l => `**${l.weight}** · \`${l.id}\` ${l.name}${l.builtin ? defaultTag : ''} — ${l.roles.length} role(s)`) + .join('\n') || this._t('home.no-levels', interaction); const embed = new EmbedBuilder() - .setTitle('🔐 Permissions') - .setDescription( - 'Per-guild permission system. Admins define **levels** (a ladder of named ' + - 'tiers with numeric weights), then bind Discord roles to those levels. ' + - 'Module-side commands and settings stay open by default; you can re-gate ' + - 'individual ones via overrides.\n\n' + - '**Resolver order** (first match wins):\n' + - '`1.` Bot OWNER\n' + - '`2.` Guild owner\n' + - '`3.` Discord `Administrator` permission\n' + - '`4.` Member\'s effective level (max over bound roles + user override) ' + - '≥ the level required by an override (if any). With no override set, access is allowed.' - ) + .setTitle(this._t('home.title', interaction)) + .setDescription(this._t('home.description', interaction)) .addFields( - { name: 'Current ladder', value: summary, inline: false }, - { name: 'User overrides', value: `${Object.keys(cfg.userOverrides).length}`, inline: true }, - { name: 'Command overrides', value: `${Object.keys(cfg.commandOverrides).length}`, inline: true }, - { name: 'Setting overrides', value: `${Object.keys(cfg.settingOverrides).length}`, inline: true } + { name: this._t('home.current-ladder', interaction), value: summary, inline: false }, + { name: this._t('home.user-overrides', interaction), value: `${Object.keys(cfg.userOverrides).length}`, inline: true }, + { name: this._t('home.command-overrides', interaction), value: `${Object.keys(cfg.commandOverrides).length}`, inline: true }, + { name: this._t('home.setting-overrides', interaction), value: `${Object.keys(cfg.settingOverrides).length}`, inline: true } ) - .setFooter({ text: 'Tip: pick a section below to manage levels, role bindings, or overrides.' }); + .setFooter({ text: this._t('home.footer', interaction) }); const nav = new StringSelectMenuBuilder() .setCustomId('perms:nav') - .setPlaceholder('What would you like to manage?') + .setPlaceholder(this._t('home.nav-placeholder', interaction)) .addOptions( - { label: 'Levels', value: 'levels', description: 'Create, rename, reweight, delete levels.', emoji: '👑' }, - { label: 'Role bindings', value: 'roles', description: 'Bind or unbind roles to levels.', emoji: '👥' }, - { label: 'User overrides', value: 'users', description: 'Force a specific level on a user.', emoji: '👤' }, - { label: 'Command overrides', value: 'cmds', description: 'Re-gate a slash command in this guild.', emoji: '⚡' }, - { label: 'Setting overrides', value: 'sets', description: 'Re-gate a Module.key setting.', emoji: '⚙️' } + { label: this._t('home.nav-levels', interaction), value: 'levels', description: this._t('home.nav-levels-desc', interaction), emoji: '👑' }, + { label: this._t('home.nav-roles', interaction), value: 'roles', description: this._t('home.nav-roles-desc', interaction), emoji: '👥' }, + { label: this._t('home.nav-users', interaction), value: 'users', description: this._t('home.nav-users-desc', interaction), emoji: '👤' }, + { label: this._t('home.nav-cmds', interaction), value: 'cmds', description: this._t('home.nav-cmds-desc', interaction), emoji: '⚡' }, + { label: this._t('home.nav-sets', interaction), value: 'sets', description: this._t('home.nav-sets-desc', interaction), emoji: '⚙️' } ); const buttons = new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel('Refresh').setEmoji('🔄'), - new ButtonBuilder().setCustomId('perms:close').setStyle(ButtonStyle.Danger).setLabel('Close').setEmoji('❌') + new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.refresh', interaction)).setEmoji('🔄'), + new ButtonBuilder().setCustomId('perms:close').setStyle(ButtonStyle.Danger).setLabel(this._t('buttons.close', interaction)).setEmoji('❌') ); return { content: '', embeds: [embed], components: [new ActionRowBuilder().addComponents(nav), buttons] }; @@ -109,54 +104,49 @@ module.exports = class PermissionsUI { async _nav(interaction, target) { const value = interaction.values?.[0] || target; switch (value) { - case 'levels': return this._update(interaction, this._levelsScreen(interaction.guild.id)); - case 'roles': return this._update(interaction, this._rolesScreen(interaction.guild.id)); - case 'users': return this._update(interaction, this._usersScreen(interaction.guild.id)); - case 'cmds': return this._update(interaction, this._cmdsScreen(interaction.guild.id)); - case 'sets': return this._update(interaction, this._setsScreen(interaction.guild.id)); + case 'levels': return this._update(interaction, this._levelsScreen(interaction)); + case 'roles': return this._update(interaction, this._rolesScreen(interaction)); + case 'users': return this._update(interaction, this._usersScreen(interaction)); + case 'cmds': return this._update(interaction, this._cmdsScreen(interaction)); + case 'sets': return this._update(interaction, this._setsScreen(interaction)); } } - _levelsScreen(guildId) { + // ───── LEVELS ───── + + _levelsScreen(interaction) { + const guildId = interaction.guild.id; const cfg = this.client.permissions.getConfig(guildId); const ladder = [...cfg.levels].sort((a, b) => a.weight - b.weight); + const defaultTag = this._t('levels.default-tag', interaction); const embed = new EmbedBuilder() - .setTitle('👑 Levels') - .setDescription( - 'Levels are tiers of access with numeric **weights**. Higher weight = more access.\n\n' + - '• **Default levels** (`member`, `helper`, `moderator`, `admin`) are seeded into ' + - 'every guild on first use, but they\'re yours — rename, reweight, or delete them freely.\n' + - '• **Custom levels** (e.g. `senior_mod` at weight 7 between `moderator` and `admin`) ' + - 'can be created with any ID and weight.\n' + - '• Levels alone do nothing — bind Discord roles to them in the **Role bindings** ' + - 'section, or assign individual users via **User overrides**.\n' + - '• Deleting a level automatically clears any overrides referencing it.' - ) + .setTitle(this._t('levels.title', interaction)) + .setDescription(this._t('levels.description', interaction)) .addFields({ - name: 'Current ladder', + name: this._t('levels.current-ladder', interaction), value: ladder.map(l => - `**${l.weight}** · \`${l.id}\` — ${l.name}${l.builtin ? ' *(default)*' : ''}\n` + - ` ${l.roles.length ? l.roles.map(r => `<@&${r}>`).join(', ') : '_(no roles bound)_'}` - ).join('\n\n') || '_(no levels)_' + `**${l.weight}** · \`${l.id}\` — ${l.name}${l.builtin ? defaultTag : ''}\n` + + ` ${l.roles.length ? l.roles.map(r => `<@&${r}>`).join(', ') : this._t('levels.no-roles-bound', interaction)}` + ).join('\n\n') || this._t('levels.no-levels', interaction) }); const components = []; if (ladder.length > 0) { const editPick = new StringSelectMenuBuilder() .setCustomId('perms:level:edit_pick') - .setPlaceholder('Edit a level…') + .setPlaceholder(this._t('levels.edit-placeholder', interaction)) .addOptions(ladder.slice(0, 25).map(l => ({ label: `${l.name} (${l.id})`, - description: `weight ${l.weight}${l.builtin ? ' • default' : ''}`, + description: this._t(l.builtin ? 'levels.weight-fmt-default' : 'levels.weight-fmt', interaction, { weight: l.weight }), value: l.id }))); components.push(new ActionRowBuilder().addComponents(editPick)); } components.push(new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId('perms:level:create_btn').setStyle(ButtonStyle.Primary).setLabel('Create level').setEmoji('➕'), - new ButtonBuilder().setCustomId('perms:level:delete_pick').setStyle(ButtonStyle.Danger).setLabel('Delete level').setEmoji('🗑️'), - new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel('Back').setEmoji('⬅️') + new ButtonBuilder().setCustomId('perms:level:create_btn').setStyle(ButtonStyle.Primary).setLabel(this._t('buttons.create', interaction)).setEmoji('➕'), + new ButtonBuilder().setCustomId('perms:level:delete_pick').setStyle(ButtonStyle.Danger).setLabel(this._t('buttons.delete', interaction)).setEmoji('🗑️'), + new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.back', interaction)).setEmoji('⬅️') )); return { embeds: [embed], components }; @@ -177,102 +167,105 @@ module.exports = class PermissionsUI { const id = interaction.fields.getTextInputValue('id').toLowerCase().replace(/[^a-z0-9_-]/g, '_').slice(0, 32); const name = interaction.fields.getTextInputValue('name').slice(0, 64); const weight = parseInt(interaction.fields.getTextInputValue('weight'), 10); - if (!id) return this._safeError(interaction, 'Level ID cannot be empty.'); - if (!Number.isFinite(weight)) return this._safeError(interaction, 'Weight must be an integer.'); + if (!id) return this._safeError(interaction, this._t('levels.empty-id-error', interaction)); + if (!Number.isFinite(weight)) return this._safeError(interaction, this._t('levels.weight-not-int-error', interaction)); this.client.permissions.setLevel(guildId, { id, name, weight, roles: [] }); - return this._update(interaction, this._levelsScreen(guildId)); + return this._update(interaction, this._levelsScreen(interaction)); } if (action === 'edit_modal') { const levelId = rest[0]; const name = interaction.fields.getTextInputValue('name').slice(0, 64); const weight = parseInt(interaction.fields.getTextInputValue('weight'), 10); - if (!Number.isFinite(weight)) return this._safeError(interaction, 'Weight must be an integer.'); + if (!Number.isFinite(weight)) return this._safeError(interaction, this._t('levels.weight-not-int-error', interaction)); this.client.permissions.setLevel(guildId, { id: levelId, name, weight }); - return this._update(interaction, this._levelsScreen(guildId)); + return this._update(interaction, this._levelsScreen(interaction)); } if (action === 'delete_pick') { const cfg = this.client.permissions.getConfig(guildId); const candidates = [...cfg.levels].sort((a, b) => a.weight - b.weight); if (candidates.length === 0) - return this._safeError(interaction, 'No levels to delete.'); + return this._safeError(interaction, this._t('levels.none-to-delete', interaction)); const embed = new EmbedBuilder() - .setTitle('🗑️ Delete level') - .setDescription('Pick a level to delete. Any overrides referencing it will be cleared automatically.'); + .setTitle(this._t('levels.delete-title', interaction)) + .setDescription(this._t('levels.delete-description', interaction)); const select = new StringSelectMenuBuilder() .setCustomId('perms:level:delete_confirm') - .setPlaceholder('Pick a level to delete…') + .setPlaceholder(this._t('levels.delete-placeholder', interaction)) .addOptions(candidates.slice(0, 25).map(l => ({ label: `${l.name} (${l.id})`, - description: `weight ${l.weight}${l.builtin ? ' • default' : ''}`, + description: this._t(l.builtin ? 'levels.weight-fmt-default' : 'levels.weight-fmt', interaction, { weight: l.weight }), value: l.id }))); const back = new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId('perms:nav:levels').setStyle(ButtonStyle.Secondary).setLabel('Cancel').setEmoji('⬅️') + new ButtonBuilder().setCustomId('perms:nav:levels').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.cancel', interaction)).setEmoji('⬅️') ); return this._update(interaction, { embeds: [embed], components: [new ActionRowBuilder().addComponents(select), back] }); } if (action === 'delete_confirm') { const levelId = interaction.values[0]; this.client.permissions.deleteLevel(guildId, levelId); - return this._update(interaction, this._levelsScreen(guildId)); + return this._update(interaction, this._levelsScreen(interaction)); } } async _showLevelModal(interaction, levelId) { const isEdit = !!levelId; const existing = isEdit ? this.client.permissions.getLevel(interaction.guild.id, levelId) : null; - if (isEdit && !existing) return this._safeError(interaction, `Unknown level "${levelId}".`); + if (isEdit && !existing) return this._safeError(interaction, this._t('levels.unknown-level-error', interaction, { level: levelId })); const modal = new ModalBuilder() .setCustomId(isEdit ? `perms:level:edit_modal:${levelId}` : 'perms:level:create_modal') - .setTitle(isEdit ? `Edit ${existing.name}` : 'Create a level'); + .setTitle(isEdit + ? this._t('levels.modal-edit-title', interaction, { name: existing.name }) + : this._t('levels.modal-create-title', interaction)); const inputs = []; if (!isEdit) { inputs.push(new TextInputBuilder() - .setCustomId('id').setLabel('Level ID (lowercase, no spaces)') - .setPlaceholder('senior_mod').setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(32)); + .setCustomId('id').setLabel(this._t('levels.modal-id-label', interaction)) + .setPlaceholder(this._t('levels.modal-id-placeholder', interaction)).setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(32)); } inputs.push(new TextInputBuilder() - .setCustomId('name').setLabel('Display name') - .setPlaceholder('Senior Moderator').setStyle(TextInputStyle.Short) + .setCustomId('name').setLabel(this._t('levels.modal-name-label', interaction)) + .setPlaceholder(this._t('levels.modal-name-placeholder', interaction)).setStyle(TextInputStyle.Short) .setRequired(true).setMaxLength(64).setValue(existing?.name || '')); inputs.push(new TextInputBuilder() - .setCustomId('weight').setLabel('Weight (higher = more access)') - .setPlaceholder('7').setStyle(TextInputStyle.Short) + .setCustomId('weight').setLabel(this._t('levels.modal-weight-label', interaction)) + .setPlaceholder(this._t('levels.modal-weight-placeholder', interaction)).setStyle(TextInputStyle.Short) .setRequired(true).setMaxLength(6).setValue(existing ? String(existing.weight) : '')); modal.addComponents(...inputs.map(i => new ActionRowBuilder().addComponents(i))); await interaction.showModal(modal); } - _rolesScreen(guildId, focusedLevelId = null) { + // ───── ROLE BINDINGS ───── + + _rolesScreen(interaction, focusedLevelId = null) { + const guildId = interaction.guild.id; const cfg = this.client.permissions.getConfig(guildId); const ladder = [...cfg.levels].sort((a, b) => a.weight - b.weight); const focused = focusedLevelId ? ladder.find(l => l.id === focusedLevelId) : null; - const embed = new EmbedBuilder().setTitle('👥 Role bindings'); - const intro = - 'Bind Discord roles to levels. A member\'s **effective level** is the **highest weight** ' + - 'across every level any of their roles is bound to.\n\n' + - '• A role may be bound to multiple levels — the resolver picks the strongest.\n' + - '• Removing a role from a level only changes this binding; the Discord role itself is untouched.\n' + - '• To force a specific user to a level regardless of their roles, use **User overrides**.'; + const embed = new EmbedBuilder().setTitle(this._t('roles.title', interaction)); + const intro = this._t('roles.intro', interaction); if (focused) { embed.setDescription( intro + '\n\n' + - `**Selected:** \`${focused.id}\` — ${focused.name} (weight ${focused.weight})\n` + - (focused.roles.length ? `Bound: ${focused.roles.map(r => `<@&${r}>`).join(', ')}` : '_(no roles bound yet)_') + this._t('roles.focused-line', interaction, { id: focused.id, name: focused.name, weight: focused.weight }) + '\n' + + (focused.roles.length + ? this._t('roles.bound-list', interaction, { roles: focused.roles.map(r => `<@&${r}>`).join(', ') }) + : this._t('roles.no-roles-bound', interaction)) ); } else { embed.setDescription(intro); + const none = this._t('roles.none', interaction); embed.addFields({ - name: 'All levels', + name: this._t('roles.all-levels', interaction), value: ladder.map(l => - `\`${l.id}\` — ${l.roles.length ? l.roles.map(r => `<@&${r}>`).join(', ') : '_none_'}` - ).join('\n') || '_(no levels)_' + `\`${l.id}\` — ${l.roles.length ? l.roles.map(r => `<@&${r}>`).join(', ') : none}` + ).join('\n') || this._t('home.no-levels', interaction) }); } @@ -282,13 +275,13 @@ module.exports = class PermissionsUI { components.push(new ActionRowBuilder().addComponents( new RoleSelectMenuBuilder() .setCustomId(`perms:role:bind:${focused.id}`) - .setPlaceholder(`Bind a role to ${focused.name}…`) + .setPlaceholder(this._t('roles.bind-placeholder', interaction, { name: focused.name })) .setMinValues(1).setMaxValues(1) )); if (focused.roles.length > 0) { const unbind = new StringSelectMenuBuilder() .setCustomId(`perms:role:unbind:${focused.id}`) - .setPlaceholder(`Unbind a role from ${focused.name}…`) + .setPlaceholder(this._t('roles.unbind-placeholder', interaction, { name: focused.name })) .addOptions(focused.roles.slice(0, 25).map(roleId => { const role = this.client.guilds.cache.get(guildId)?.roles?.cache?.get(roleId); return { label: role?.name || roleId, description: roleId, value: roleId }; @@ -299,16 +292,18 @@ module.exports = class PermissionsUI { const levelPick = new StringSelectMenuBuilder() .setCustomId('perms:role:level_pick') - .setPlaceholder(focused ? 'Switch to a different level…' : 'Choose a level to manage…') + .setPlaceholder(focused + ? this._t('roles.level-switch-placeholder', interaction) + : this._t('roles.level-pick-placeholder', interaction)) .addOptions(ladder.slice(0, 25).map(l => ({ label: `${l.name} (${l.id})`, - description: `${l.roles.length} role(s) • weight ${l.weight}`, + description: this._t('roles.level-pick-desc', interaction, { count: l.roles.length, weight: l.weight }), value: l.id }))); components.push(new ActionRowBuilder().addComponents(levelPick)); components.push(new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel('Back').setEmoji('⬅️') + new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.back', interaction)).setEmoji('⬅️') )); return { embeds: [embed], components }; @@ -319,46 +314,44 @@ module.exports = class PermissionsUI { const guildId = interaction.guild.id; if (action === 'level_pick') { - return this._update(interaction, this._rolesScreen(guildId, interaction.values[0])); + return this._update(interaction, this._rolesScreen(interaction, interaction.values[0])); } if (action === 'bind') { const roleId = interaction.values[0]; this.client.permissions.bindRole(guildId, levelId, roleId); - return this._update(interaction, this._rolesScreen(guildId, levelId)); + return this._update(interaction, this._rolesScreen(interaction, levelId)); } if (action === 'unbind') { const roleId = interaction.values[0]; this.client.permissions.unbindRole(guildId, levelId, roleId); - return this._update(interaction, this._rolesScreen(guildId, levelId)); + return this._update(interaction, this._rolesScreen(interaction, levelId)); } } - _usersScreen(guildId, focusedUserId = null) { + // ───── USER OVERRIDES ───── + + _usersScreen(interaction, focusedUserId = null) { + const guildId = interaction.guild.id; const cfg = this.client.permissions.getConfig(guildId); const entries = Object.entries(cfg.userOverrides); - const focused = focusedUserId; const embed = new EmbedBuilder() - .setTitle('👤 User overrides') - .setDescription( - 'Force a specific level on an individual user, **bypassing role bindings**. ' + - 'When set, the user\'s effective level becomes the override regardless of which ' + - 'roles they have.\n\n' + - '• Useful for: testing, granting access to someone without giving them a role, ' + - 'or temporarily downgrading a user.\n' + - '• Clear an override to fall back to normal role-based resolution.' - ); + .setTitle(this._t('users.title', interaction)) + .setDescription(this._t('users.description', interaction)); embed.addFields({ - name: 'Active overrides', + name: this._t('users.active-overrides', interaction), value: entries.length ? entries.map(([uid, lid]) => `<@${uid}> → \`${lid}\``).join('\n') - : '_(none)_' + : this._t('users.none', interaction) }); - if (focused) { - const current = cfg.userOverrides[focused]; - embed.addFields({ name: 'Selected user', value: `<@${focused}>${current ? ` (currently \`${current}\`)` : ' *(no override yet)*'}`, inline: false }); + if (focusedUserId) { + const current = cfg.userOverrides[focusedUserId]; + const suffix = current + ? this._t('users.selected-user-current', interaction, { level: current }) + : this._t('users.selected-user-empty', interaction); + embed.addFields({ name: this._t('users.selected-user', interaction), value: `<@${focusedUserId}>${suffix}`, inline: false }); } const components = []; @@ -366,27 +359,30 @@ module.exports = class PermissionsUI { components.push(new ActionRowBuilder().addComponents( new UserSelectMenuBuilder() .setCustomId('perms:user:pick') - .setPlaceholder('Pick a user to set/clear an override…') + .setPlaceholder(this._t('users.pick-user-placeholder', interaction)) .setMinValues(1).setMaxValues(1) )); - if (focused) { + if (focusedUserId) { const ladder = [...cfg.levels].sort((a, b) => a.weight - b.weight); const opts = [ - { label: '— Clear override —', value: '__clear__', description: 'Remove this user\'s override.' }, + { label: this._t('users.clear', interaction), value: '__clear__', description: this._t('users.clear-desc', interaction) }, ...ladder.slice(0, 24).map(l => ({ - label: `${l.name} (${l.id})`, description: `weight ${l.weight}`, value: l.id + label: `${l.name} (${l.id})`, + description: this._t('levels.weight-fmt', interaction, { weight: l.weight }), + value: l.id })) ]; - const setSel = new StringSelectMenuBuilder() - .setCustomId(`perms:user:set:${focused}`) - .setPlaceholder('Assign a level (or clear)…') - .addOptions(opts); - components.push(new ActionRowBuilder().addComponents(setSel)); + components.push(new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId(`perms:user:set:${focusedUserId}`) + .setPlaceholder(this._t('users.assign-placeholder', interaction)) + .addOptions(opts) + )); } components.push(new ActionRowBuilder().addComponents( - new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel('Back').setEmoji('⬅️') + new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.back', interaction)).setEmoji('⬅️') )); return { embeds: [embed], components }; @@ -397,53 +393,46 @@ module.exports = class PermissionsUI { const guildId = interaction.guild.id; if (action === 'pick') { - return this._update(interaction, this._usersScreen(guildId, interaction.values[0])); + return this._update(interaction, this._usersScreen(interaction, interaction.values[0])); } if (action === 'set') { const value = interaction.values[0]; this.client.permissions.setUserOverride(guildId, userId, value === '__clear__' ? null : value); - return this._update(interaction, this._usersScreen(guildId)); + return this._update(interaction, this._usersScreen(interaction)); } } - _cmdsScreen(guildId) { + // ───── COMMAND OVERRIDES ───── + + _cmdsScreen(interaction) { + const guildId = interaction.guild.id; const cfg = this.client.permissions.getConfig(guildId); const entries = Object.entries(cfg.commandOverrides); const embed = new EmbedBuilder() - .setTitle('⚡ Command overrides') - .setDescription( - 'By default, slash commands are gated only by Discord\'s native ' + - '`defaultMemberPermissions` (visible in **Server Settings → Integrations → ' + - 'this bot**). This screen lets you add an additional **level requirement** ' + - 'on top, in this guild only.\n\n' + - '• Example: gate `/kick` behind `moderator` — anyone with a role bound to ' + - '`moderator` (or higher) can use it.\n' + - '• Set with the **Set override** button (command name + level ID).\n' + - '• Clear an override to revert to Discord-native default.\n' + - '• Discord `Administrator`, the guild owner, and the bot OWNER always bypass overrides.' - ) + .setTitle(this._t('cmds.title', interaction)) + .setDescription(this._t('cmds.description', interaction)) .addFields({ - name: 'Active overrides', + name: this._t('cmds.active-overrides', interaction), value: entries.length ? entries.map(([c, l]) => `\`/${c}\` → \`${l}\``).join('\n') - : '_(none)_' + : this._t('cmds.none', interaction) }); const components = []; const buttons = [ - new ButtonBuilder().setCustomId('perms:cmd:set_btn').setStyle(ButtonStyle.Primary).setLabel('Set override').setEmoji('➕') + new ButtonBuilder().setCustomId('perms:cmd:set_btn').setStyle(ButtonStyle.Primary).setLabel(this._t('buttons.set-override', interaction)).setEmoji('➕') ]; if (entries.length > 0) { const clear = new StringSelectMenuBuilder() .setCustomId('perms:cmd:clear_pick') - .setPlaceholder('Clear an override…') + .setPlaceholder(this._t('cmds.clear-placeholder', interaction)) .addOptions(entries.slice(0, 25).map(([c, l]) => ({ - label: `/${c}`, description: `currently \`${l}\``, value: c + label: `/${c}`, description: this._t('cmds.clear-desc', interaction, { level: l }), value: c }))); components.push(new ActionRowBuilder().addComponents(clear)); } - buttons.push(new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel('Back').setEmoji('⬅️')); + buttons.push(new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.back', interaction)).setEmoji('⬅️')); components.push(new ActionRowBuilder().addComponents(...buttons)); return { embeds: [embed], components }; } @@ -455,15 +444,15 @@ module.exports = class PermissionsUI { if (action === 'set_btn') { const modal = new ModalBuilder() .setCustomId('perms:cmd:set_modal') - .setTitle('Set command override'); + .setTitle(this._t('cmds.modal-title', interaction)); modal.addComponents( new ActionRowBuilder().addComponents( - new TextInputBuilder().setCustomId('command').setLabel('Command name (without /)') - .setPlaceholder('kick').setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(32) + new TextInputBuilder().setCustomId('command').setLabel(this._t('cmds.modal-command-label', interaction)) + .setPlaceholder(this._t('cmds.modal-command-placeholder', interaction)).setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(32) ), new ActionRowBuilder().addComponents( - new TextInputBuilder().setCustomId('level').setLabel('Level ID') - .setPlaceholder('moderator').setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(32) + new TextInputBuilder().setCustomId('level').setLabel(this._t('cmds.modal-level-label', interaction)) + .setPlaceholder(this._t('cmds.modal-level-placeholder', interaction)).setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(32) ) ); return interaction.showModal(modal); @@ -472,52 +461,46 @@ module.exports = class PermissionsUI { const cmd = interaction.fields.getTextInputValue('command').replace(/^\//, '').trim(); const level = interaction.fields.getTextInputValue('level').trim(); this.client.permissions.setCommandOverride(guildId, cmd, level); - return this._update(interaction, this._cmdsScreen(guildId)); + return this._update(interaction, this._cmdsScreen(interaction)); } if (action === 'clear_pick') { const cmd = interaction.values[0]; this.client.permissions.setCommandOverride(guildId, cmd, null); - return this._update(interaction, this._cmdsScreen(guildId)); + return this._update(interaction, this._cmdsScreen(interaction)); } } - _setsScreen(guildId) { + // ───── SETTING OVERRIDES ───── + + _setsScreen(interaction) { + const guildId = interaction.guild.id; const cfg = this.client.permissions.getConfig(guildId); const entries = Object.entries(cfg.settingOverrides); const embed = new EmbedBuilder() - .setTitle('⚙️ Setting overrides') - .setDescription( - 'Re-gate individual settings by their `Module.key` path. By default, anyone ' + - 'who can run `/settings` can edit any key — set an override here to require a ' + - 'specific level for one particular key in this guild.\n\n' + - '• Example: `Utility.defaultServerLanguage` → `admin` so only admins change ' + - 'the server\'s fallback language, even if mods can otherwise use `/settings`.\n' + - '• Use **Set override** with a `Module.key` path (autocomplete in `/settings` ' + - 'shows the available keys per module).\n' + - '• Clear an override to revert to the default `/settings` gate.' - ) + .setTitle(this._t('sets.title', interaction)) + .setDescription(this._t('sets.description', interaction)) .addFields({ - name: 'Active overrides', + name: this._t('sets.active-overrides', interaction), value: entries.length ? entries.map(([k, l]) => `\`${k}\` → \`${l}\``).join('\n') - : '_(none)_' + : this._t('sets.none', interaction) }); const components = []; const buttons = [ - new ButtonBuilder().setCustomId('perms:set:set_btn').setStyle(ButtonStyle.Primary).setLabel('Set override').setEmoji('➕') + new ButtonBuilder().setCustomId('perms:set:set_btn').setStyle(ButtonStyle.Primary).setLabel(this._t('buttons.set-override', interaction)).setEmoji('➕') ]; if (entries.length > 0) { const clear = new StringSelectMenuBuilder() .setCustomId('perms:set:clear_pick') - .setPlaceholder('Clear an override…') + .setPlaceholder(this._t('sets.clear-placeholder', interaction)) .addOptions(entries.slice(0, 25).map(([k, l]) => ({ - label: k, description: `currently \`${l}\``, value: k + label: k, description: this._t('sets.clear-desc', interaction, { level: l }), value: k }))); components.push(new ActionRowBuilder().addComponents(clear)); } - buttons.push(new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel('Back').setEmoji('⬅️')); + buttons.push(new ButtonBuilder().setCustomId('perms:home').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.back', interaction)).setEmoji('⬅️')); components.push(new ActionRowBuilder().addComponents(...buttons)); return { embeds: [embed], components }; } @@ -529,15 +512,15 @@ module.exports = class PermissionsUI { if (action === 'set_btn') { const modal = new ModalBuilder() .setCustomId('perms:set:set_modal') - .setTitle('Set setting override'); + .setTitle(this._t('sets.modal-title', interaction)); modal.addComponents( new ActionRowBuilder().addComponents( - new TextInputBuilder().setCustomId('key').setLabel('Setting key (Module.key)') - .setPlaceholder('Utility.defaultServerLanguage').setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(80) + new TextInputBuilder().setCustomId('key').setLabel(this._t('sets.modal-key-label', interaction)) + .setPlaceholder(this._t('sets.modal-key-placeholder', interaction)).setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(80) ), new ActionRowBuilder().addComponents( - new TextInputBuilder().setCustomId('level').setLabel('Level ID') - .setPlaceholder('moderator').setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(32) + new TextInputBuilder().setCustomId('level').setLabel(this._t('sets.modal-level-label', interaction)) + .setPlaceholder(this._t('sets.modal-level-placeholder', interaction)).setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(32) ) ); return interaction.showModal(modal); @@ -546,19 +529,17 @@ module.exports = class PermissionsUI { const key = interaction.fields.getTextInputValue('key').trim(); const level = interaction.fields.getTextInputValue('level').trim(); this.client.permissions.setSettingOverride(guildId, key, level); - return this._update(interaction, this._setsScreen(guildId)); + return this._update(interaction, this._setsScreen(interaction)); } if (action === 'clear_pick') { const key = interaction.values[0]; this.client.permissions.setSettingOverride(guildId, key, null); - return this._update(interaction, this._setsScreen(guildId)); + return this._update(interaction, this._setsScreen(interaction)); } } - /** - * `interaction.update()` works on component AND modal-submit interactions - * (since modals here are always launched from a panel button/select). - */ + // ───── helpers ───── + async _update(interaction, payload) { if (interaction.replied || interaction.deferred) return interaction.editReply(payload); diff --git a/modules/Utility/lib/SettingsUI.js b/modules/Utility/lib/SettingsUI.js new file mode 100644 index 0000000..8271f39 --- /dev/null +++ b/modules/Utility/lib/SettingsUI.js @@ -0,0 +1,401 @@ +const { + EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, + StringSelectMenuBuilder, ChannelSelectMenuBuilder, RoleSelectMenuBuilder, UserSelectMenuBuilder, + ModalBuilder, TextInputBuilder, TextInputStyle, + MessageFlags +} = require('discord.js'); + +/** + * In-Discord GUI for per-guild settings. + * + * home → list of modules with settings + * mod → keys in a module with current values + * key → key detail with type-aware editor + * + * Custom-id convention: `settings:[:...]`. All user-facing + * strings flow through the locale system — see + * modules/Utility/locales/.yaml under `commands.settings.ui.*`. + */ +module.exports = class SettingsUI { + /** + * @param {import('../Utility.js')} utilityModule + */ + constructor(utilityModule) { + this.module = utilityModule; + this.client = utilityModule.client; + } + + /** Localize a UI string under `commands.settings.ui.`. */ + _t(key, interaction, vars) { + return this.module.t(`commands.settings.ui.${key}`, interaction, vars); + } + + async open(interaction) { + await interaction.reply({ ...this._home(interaction), flags: MessageFlags.Ephemeral }); + } + + /** + * @param {import('discord.js').Interaction} interaction + */ + async handle(interaction) { + const id = interaction.customId; + if (!id?.startsWith('settings:')) return false; + const [, screen, ...args] = id.split(':'); + + try { + switch (screen) { + case 'home': return this._update(interaction, this._home(interaction)); + case 'close': return interaction.update({ content: this._t('errors.closed', interaction), embeds: [], components: [] }); + case 'mod_pick': return this._update(interaction, this._moduleScreen(interaction, interaction.values[0])); + case 'mod': return this._update(interaction, this._moduleScreen(interaction, args[0])); + case 'key_pick': return this._update(interaction, this._keyScreen(interaction, args[0], interaction.values[0])); + case 'key': return this._update(interaction, this._keyScreen(interaction, args[0], args[1])); + case 'edit_btn': return this._showEditModal(interaction, args[0], args[1]); + case 'edit_modal': return this._submitSet(interaction, args[0], args[1], interaction.fields.getTextInputValue('value')); + case 'bool': return this._submitSet(interaction, args[0], args[1], args[2] === 'true'); + case 'enum_set': return this._submitSet(interaction, args[0], args[1], interaction.values[0]); + case 'channel_set': return this._submitSet(interaction, args[0], args[1], interaction.values[0]); + case 'role_set': return this._submitSet(interaction, args[0], args[1], interaction.values[0]); + case 'user_set': return this._submitSet(interaction, args[0], args[1], interaction.values[0]); + case 'arr_add_btn': return this._showArrayAddModal(interaction, args[0], args[1]); + case 'arr_add_modal':return this._submitAdd(interaction, args[0], args[1], interaction.fields.getTextInputValue('value')); + case 'arr_add_sel': return this._submitAdd(interaction, args[0], args[1], interaction.values[0]); + case 'arr_remove': return this._submitRemove(interaction, args[0], args[1], interaction.values[0]); + case 'reset': return this._submitReset(interaction, args[0], args[1]); + } + } catch (err) { + this.client.errorHandler?.capture(err, { source: 'SettingsUI', userId: interaction.user?.id }); + await this._safeError(interaction, err.message); + } + return true; + } + + // ───── HOME ───── + + _home(interaction) { + const guildId = interaction.guild.id; + const modules = [...this.client.settings.entries()]; + const cfg = this.client.permissions.getConfig(guildId); + + const lines = modules.map(([name, mgr]) => { + const keyCount = mgr.keys().length; + const overrideCount = Object.keys(cfg.settingOverrides).filter(k => k.startsWith(name + '.')).length; + const keyText = this._t('home.keys-suffix', interaction, { count: keyCount }); + const overrideText = overrideCount ? ` · ${this._t('home.overrides-suffix', interaction, { count: overrideCount })}` : ''; + return `**${name}** — ${keyText}${overrideText}`; + }); + + const embed = new EmbedBuilder() + .setTitle(this._t('home.title', interaction)) + .setDescription(this._t('home.description', interaction)) + .addFields({ name: this._t('home.modules-with-settings', interaction), value: lines.join('\n') || this._t('home.none', interaction) }); + + const components = []; + if (modules.length > 0) { + components.push(new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId('settings:mod_pick') + .setPlaceholder(this._t('home.pick-placeholder', interaction)) + .addOptions(modules.slice(0, 25).map(([name, mgr]) => ({ + label: name, + description: this._t('home.keys-suffix', interaction, { count: mgr.keys().length }), + value: name + }))) + )); + } + components.push(new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('settings:home').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.refresh', interaction)).setEmoji('🔄'), + new ButtonBuilder().setCustomId('settings:close').setStyle(ButtonStyle.Danger).setLabel(this._t('buttons.close', interaction)).setEmoji('❌') + )); + + return { content: '', embeds: [embed], components }; + } + + // ───── MODULE ───── + + _moduleScreen(interaction, moduleName) { + const guildId = interaction.guild.id; + const mgr = this.client.settings.get(moduleName); + if (!mgr) return this._errorPanel(interaction, this._t('module.no-settings', interaction, { name: moduleName })); + + const schema = mgr.schema; + const record = mgr.get(guildId); + const keys = Object.keys(schema); + + const lines = keys.map(k => { + const v = record.settings[k]; + return `\`${k}\` — \`${schema[k].type}\` = ${this._format(interaction, v)}`; + }); + + const embed = new EmbedBuilder() + .setTitle(this._t('module.title', interaction, { name: moduleName })) + .setDescription(this._t('module.description', interaction)) + .addFields({ name: this._t('module.keys', interaction), value: lines.join('\n') || this._t('module.no-keys', interaction) }); + + const components = []; + if (keys.length > 0) { + components.push(new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId(`settings:key_pick:${moduleName}`) + .setPlaceholder(this._t('module.pick-placeholder', interaction)) + .addOptions(keys.slice(0, 25).map(k => ({ + label: k, + description: this._truncate(schema[k].description || schema[k].type, 100), + value: k + }))) + )); + } + components.push(new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('settings:home').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.back', interaction)).setEmoji('⬅️') + )); + + return { embeds: [embed], components }; + } + + // ───── KEY ───── + + _keyScreen(interaction, moduleName, key) { + const guildId = interaction.guild.id; + const mgr = this.client.settings.get(moduleName); + if (!mgr || !mgr.has(key)) return this._errorPanel(interaction, this._t('key.unknown', interaction, { module: moduleName, key })); + + const def = mgr.schema[key]; + const value = mgr.getKey(guildId, key); + const cfg = this.client.permissions.getConfig(guildId); + const overrideKey = `${moduleName}.${key}`; + const override = cfg.settingOverrides[overrideKey]; + + const embed = new EmbedBuilder() + .setTitle(this._t('key.title', interaction, { module: moduleName, key })) + .setDescription(def.description || this._t('key.no-description', interaction)) + .addFields( + { name: this._t('key.type', interaction), value: `\`${def.type}\``, inline: true }, + { name: this._t('key.default', interaction), value: this._format(interaction, def.default), inline: true }, + { name: this._t('key.current', interaction), value: this._format(interaction, value), inline: true } + ); + if (override) { + embed.addFields({ + name: this._t('key.permission-override', interaction), + value: this._t('key.permission-override-value', interaction, { level: override }), + inline: false + }); + } + + const components = [ + ...this._editorComponents(interaction, moduleName, key, def, value), + new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(`settings:reset:${moduleName}:${key}`).setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.reset', interaction)).setEmoji('↩️'), + new ButtonBuilder().setCustomId(`settings:mod:${moduleName}`).setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.back', interaction)).setEmoji('⬅️') + ) + ]; + return { embeds: [embed], components }; + } + + /** Type-driven editor row(s). */ + _editorComponents(interaction, moduleName, key, def, value) { + const type = def.type; + + const arrMatch = String(type).match(/^array<(.+)>$/); + if (arrMatch) return this._arrayEditor(interaction, moduleName, key, arrMatch[1], value); + + if (String(type).startsWith('enum:')) { + const choices = String(type).slice(5).split('|'); + return [new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId(`settings:enum_set:${moduleName}:${key}`) + .setPlaceholder(this._t('editors.enum-placeholder', interaction)) + .addOptions(choices.slice(0, 25).map(c => ({ label: c, value: c, default: c === value }))) + )]; + } + + if (type === 'boolean') { + return [new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(`settings:bool:${moduleName}:${key}:true`).setStyle(value === true ? ButtonStyle.Success : ButtonStyle.Secondary).setLabel(this._t('editors.bool-true', interaction)), + new ButtonBuilder().setCustomId(`settings:bool:${moduleName}:${key}:false`).setStyle(value === false ? ButtonStyle.Danger : ButtonStyle.Secondary).setLabel(this._t('editors.bool-false', interaction)) + )]; + } + + if (type === 'channel') return [new ActionRowBuilder().addComponents( + new ChannelSelectMenuBuilder().setCustomId(`settings:channel_set:${moduleName}:${key}`) + .setPlaceholder(this._t('editors.channel-placeholder', interaction)).setMinValues(1).setMaxValues(1) + )]; + if (type === 'role') return [new ActionRowBuilder().addComponents( + new RoleSelectMenuBuilder().setCustomId(`settings:role_set:${moduleName}:${key}`) + .setPlaceholder(this._t('editors.role-placeholder', interaction)).setMinValues(1).setMaxValues(1) + )]; + if (type === 'user') return [new ActionRowBuilder().addComponents( + new UserSelectMenuBuilder().setCustomId(`settings:user_set:${moduleName}:${key}`) + .setPlaceholder(this._t('editors.user-placeholder', interaction)).setMinValues(1).setMaxValues(1) + )]; + + // Text-based scalars (string / number / integer / snowflake) → modal. + return [new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`settings:edit_btn:${moduleName}:${key}`) + .setStyle(ButtonStyle.Primary) + .setLabel(this._t('editors.edit-button', interaction)) + .setEmoji('✏️') + )]; + } + + _arrayEditor(interaction, moduleName, key, innerType, value) { + const components = []; + const arr = Array.isArray(value) ? value : []; + + if (innerType === 'channel') { + components.push(new ActionRowBuilder().addComponents( + new ChannelSelectMenuBuilder().setCustomId(`settings:arr_add_sel:${moduleName}:${key}`) + .setPlaceholder(this._t('editors.array-add-channel', interaction)).setMinValues(1).setMaxValues(1) + )); + } else if (innerType === 'role') { + components.push(new ActionRowBuilder().addComponents( + new RoleSelectMenuBuilder().setCustomId(`settings:arr_add_sel:${moduleName}:${key}`) + .setPlaceholder(this._t('editors.array-add-role', interaction)).setMinValues(1).setMaxValues(1) + )); + } else if (innerType === 'user') { + components.push(new ActionRowBuilder().addComponents( + new UserSelectMenuBuilder().setCustomId(`settings:arr_add_sel:${moduleName}:${key}`) + .setPlaceholder(this._t('editors.array-add-user', interaction)).setMinValues(1).setMaxValues(1) + )); + } else { + components.push(new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId(`settings:arr_add_btn:${moduleName}:${key}`) + .setStyle(ButtonStyle.Primary).setLabel(this._t('editors.array-add-button', interaction)).setEmoji('➕') + )); + } + + if (arr.length > 0) { + components.push(new ActionRowBuilder().addComponents( + new StringSelectMenuBuilder() + .setCustomId(`settings:arr_remove:${moduleName}:${key}`) + .setPlaceholder(this._t('editors.array-remove', interaction)) + .addOptions(arr.slice(0, 25).map(v => ({ + label: this._truncate(String(v), 100), + value: String(v) + }))) + )); + } + return components; + } + + // ───── handlers ───── + + async _showEditModal(interaction, moduleName, key) { + const mgr = this.client.settings.get(moduleName); + if (!mgr || !mgr.has(key)) return this._safeError(interaction, this._t('errors.unknown-setting', interaction)); + const def = mgr.schema[key]; + const current = mgr.getKey(interaction.guild.id, key); + + const modal = new ModalBuilder() + .setCustomId(`settings:edit_modal:${moduleName}:${key}`) + .setTitle(this._t('modals.edit-title', interaction, { key: this._truncate(key, 40) })); + modal.addComponents(new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('value') + .setLabel(this._t('modals.edit-label', interaction, { type: this._truncate(def.type, 40) })) + .setStyle(def.type === 'string' ? TextInputStyle.Paragraph : TextInputStyle.Short) + .setRequired(true) + .setMaxLength(2000) + .setValue(current == null ? '' : String(current)) + )); + await interaction.showModal(modal); + } + + async _showArrayAddModal(interaction, moduleName, key) { + const modal = new ModalBuilder() + .setCustomId(`settings:arr_add_modal:${moduleName}:${key}`) + .setTitle(this._t('modals.array-add-title', interaction, { key: this._truncate(key, 40) })); + modal.addComponents(new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId('value').setLabel(this._t('modals.array-add-label', interaction)) + .setStyle(TextInputStyle.Short).setRequired(true).setMaxLength(2000) + )); + await interaction.showModal(modal); + } + + async _submitSet(interaction, moduleName, key, value) { + const mgr = this.client.settings.get(moduleName); + if (!mgr) return this._safeError(interaction, this._t('errors.no-settings-module', interaction, { module: moduleName })); + try { + mgr.set(interaction.guild.id, key, value, { actor: interaction.member }); + } catch (err) { + return this._safeError(interaction, err.message); + } + return this._update(interaction, this._keyScreen(interaction, moduleName, key)); + } + + async _submitAdd(interaction, moduleName, key, value) { + const mgr = this.client.settings.get(moduleName); + if (!mgr) return this._safeError(interaction, this._t('errors.no-settings-module', interaction, { module: moduleName })); + try { + mgr.add(interaction.guild.id, key, value, { actor: interaction.member }); + } catch (err) { + return this._safeError(interaction, err.message); + } + return this._update(interaction, this._keyScreen(interaction, moduleName, key)); + } + + async _submitRemove(interaction, moduleName, key, value) { + const mgr = this.client.settings.get(moduleName); + if (!mgr) return this._safeError(interaction, this._t('errors.no-settings-module', interaction, { module: moduleName })); + try { + mgr.remove(interaction.guild.id, key, value, { actor: interaction.member }); + } catch (err) { + return this._safeError(interaction, err.message); + } + return this._update(interaction, this._keyScreen(interaction, moduleName, key)); + } + + async _submitReset(interaction, moduleName, key) { + const mgr = this.client.settings.get(moduleName); + if (!mgr) return this._safeError(interaction, this._t('errors.no-settings-module', interaction, { module: moduleName })); + try { + mgr.reset(interaction.guild.id, key, { actor: interaction.member }); + } catch (err) { + return this._safeError(interaction, err.message); + } + return this._update(interaction, this._keyScreen(interaction, moduleName, key)); + } + + // ───── helpers ───── + + _format(interaction, v) { + if (v == null || v === '') return this._t('values.unset', interaction); + if (Array.isArray(v)) return v.length ? v.map(x => `\`${x}\``).join(', ') : this._t('values.empty', interaction); + if (typeof v === 'boolean') return v ? '`true`' : '`false`'; + return `\`${this._truncate(String(v), 200)}\``; + } + + _errorPanel(interaction, message) { + const embed = new EmbedBuilder() + .setTitle(this._t('home.title', interaction)) + .setDescription(`:x: ${message}`) + .setColor(0xE74C3C); + return { + embeds: [embed], + components: [new ActionRowBuilder().addComponents( + new ButtonBuilder().setCustomId('settings:home').setStyle(ButtonStyle.Secondary).setLabel(this._t('buttons.back', interaction)).setEmoji('⬅️') + )] + }; + } + + async _update(interaction, payload) { + if (interaction.replied || interaction.deferred) + return interaction.editReply(payload); + return interaction.update(payload); + } + + async _safeError(interaction, message) { + const payload = { content: `:x: ${message}`, embeds: [], components: [], flags: MessageFlags.Ephemeral }; + try { + if (interaction.replied || interaction.deferred) return interaction.followUp(payload); + return interaction.reply(payload); + } catch { /* swallow */ } + } + + _truncate(s, max) { + if (s == null) return ''; + s = String(s); + return s.length <= max ? s : s.slice(0, max - 1) + '…'; + } +}; diff --git a/modules/Utility/locales/en-GB.yaml b/modules/Utility/locales/en-GB.yaml index 584ef07..5c39725 100644 --- a/modules/Utility/locales/en-GB.yaml +++ b/modules/Utility/locales/en-GB.yaml @@ -14,56 +14,208 @@ commands: pong: ":ping_pong: Pong! Latency is **{{latency}}ms**. API Latency is **{{apiLatency}}ms**" settings: name: settings - description: Manage the current server settings - options: - view: - name: view - description: View current settings - set: - name: set - description: Set a setting - options: - module: - name: module - description: The module to view settings for - key: - name: key - description: The setting to view - value: - name: value - description: The value to filter settings by - reset: - name: reset - description: Reset a setting to default - options: - module: - name: module - description: The module to view settings for - key: - name: key - description: The setting to view - value: - name: value - description: The value to filter settings by - embeds: - set: - title: "Setting Updated" - description: "`{{key}}` is now {{value}}." - add: - title: "Setting Added" - description: "`{{key}}` is now {{value}}." - remove: - title: "Setting Removed" - description: "`{{key}}` is now {{value}}." - view: - settings: "Settings — {{module}}" - nosettings: - title: "No Settings Found" - description: "There are no settings configured for this module." - page: "Page {{page}} of {{totalPages}}" - reset: - title: "Setting Reset" - description: "`{{key}}` has been reset to {{value}}." + description: View or edit per-guild settings. + ui: + home: + title: ⚙️ Settings + description: | + Per-guild settings, grouped by module. Pick a module to edit its keys. + + • Each key has a declared **type** (string, boolean, channel, …); editors below adapt to the type. + • To require a specific level for an individual key in this guild, use **/permissions → Setting overrides**. + modules-with-settings: Modules with settings + none: _(none)_ + pick-placeholder: Pick a module to edit… + keys-suffix: "{{count}} key(s)" + overrides-suffix: "{{count}} override(s)" + module: + title: "⚙️ {{name}}" + description: Pick a key to edit, reset, or inspect. + keys: Keys + no-keys: _(no keys)_ + pick-placeholder: Pick a key… + no-settings: "Module `{{name}}` has no settings." + key: + title: "⚙️ {{module}}.{{key}}" + no-description: _(no description)_ + type: Type + default: Default + current: Current + permission-override: Permission override + permission-override-value: "requires `{{level}}` (set via /permissions)" + unknown: "No such setting `{{module}}.{{key}}`." + editors: + enum-placeholder: Pick a value… + bool-true: "True" + bool-false: "False" + channel-placeholder: Pick a channel… + role-placeholder: Pick a role… + user-placeholder: Pick a user… + edit-button: Edit value + array-add-button: Add value + array-add-channel: Add a channel… + array-add-role: Add a role… + array-add-user: Add a user… + array-remove: Remove a value… + modals: + edit-title: "Edit {{key}}" + edit-label: "New value ({{type}})" + array-add-title: "Add to {{key}}" + array-add-label: Value to add + values: + unset: _unset_ + empty: _empty_ + buttons: + reset: Reset to default + back: Back + refresh: Refresh + close: Close + errors: + closed: Closed. + unknown-setting: Unknown setting. + no-settings-module: "Module `{{module}}` has no settings." + permissions: + name: permissions + description: Manage per-guild permission levels and role bindings. + ui: + home: + title: 🔐 Permissions + description: | + Per-guild permission system. Admins define **levels** (a ladder of named tiers with numeric weights), then bind Discord roles to those levels. Module-side commands and settings stay open by default; you can re-gate individual ones via overrides. + + **Resolver order** (first match wins): + `1.` Bot OWNER + `2.` Guild owner + `3.` Discord `Administrator` permission + `4.` Member's effective level (max over bound roles + user override) ≥ the level required by an override (if any). With no override set, access is allowed. + current-ladder: Current ladder + user-overrides: User overrides + command-overrides: Command overrides + setting-overrides: Setting overrides + none: _(none)_ + no-levels: _(no levels)_ + footer: "Tip: pick a section below to manage levels, role bindings, or overrides." + nav-placeholder: What would you like to manage? + nav-levels: Levels + nav-levels-desc: Create, rename, reweight, delete levels. + nav-roles: Role bindings + nav-roles-desc: Bind or unbind roles to levels. + nav-users: User overrides + nav-users-desc: Force a specific level on a user. + nav-cmds: Command overrides + nav-cmds-desc: Re-gate a slash command in this guild. + nav-sets: Setting overrides + nav-sets-desc: Re-gate a Module.key setting. + levels: + title: 👑 Levels + description: | + Levels are tiers of access with numeric **weights**. Higher weight = more access. + + • **Default levels** (`member`, `helper`, `moderator`, `admin`) are seeded into every guild on first use, but they're yours — rename, reweight, or delete them freely. + • **Custom levels** (e.g. `senior_mod` at weight 7 between `moderator` and `admin`) can be created with any ID and weight. + • Levels alone do nothing — bind Discord roles to them in the **Role bindings** section, or assign individual users via **User overrides**. + • Deleting a level automatically clears any overrides referencing it. + current-ladder: Current ladder + no-levels: _(no levels)_ + no-roles-bound: _(no roles bound)_ + default-tag: " *(default)*" + edit-placeholder: Edit a level… + delete-title: 🗑️ Delete level + delete-description: Pick a level to delete. Any overrides referencing it will be cleared automatically. + delete-placeholder: Pick a level to delete… + none-to-delete: No levels to delete. + weight-fmt: "weight {{weight}}" + weight-fmt-default: "weight {{weight}} • default" + modal-create-title: Create a level + modal-edit-title: "Edit {{name}}" + modal-id-label: "Level ID (lowercase, no spaces)" + modal-id-placeholder: senior_mod + modal-name-label: Display name + modal-name-placeholder: Senior Moderator + modal-weight-label: "Weight (higher = more access)" + modal-weight-placeholder: "7" + empty-id-error: Level ID cannot be empty. + weight-not-int-error: Weight must be an integer. + unknown-level-error: 'Unknown level "{{level}}".' + roles: + title: 👥 Role bindings + intro: | + Bind Discord roles to levels. A member's **effective level** is the **highest weight** across every level any of their roles is bound to. + + • A role may be bound to multiple levels — the resolver picks the strongest. + • Removing a role from a level only changes this binding; the Discord role itself is untouched. + • To force a specific user to a level regardless of their roles, use **User overrides**. + focused-line: "**Selected:** `{{id}}` — {{name}} (weight {{weight}})" + bound-list: "Bound: {{roles}}" + no-roles-bound: _(no roles bound yet)_ + all-levels: All levels + none: _none_ + bind-placeholder: "Bind a role to {{name}}…" + unbind-placeholder: "Unbind a role from {{name}}…" + level-pick-placeholder: Choose a level to manage… + level-switch-placeholder: Switch to a different level… + level-pick-desc: "{{count}} role(s) • weight {{weight}}" + users: + title: 👤 User overrides + description: | + Force a specific level on an individual user, **bypassing role bindings**. When set, the user's effective level becomes the override regardless of which roles they have. + + • Useful for: testing, granting access to someone without giving them a role, or temporarily downgrading a user. + • Clear an override to fall back to normal role-based resolution. + active-overrides: Active overrides + none: _(none)_ + selected-user: Selected user + selected-user-current: " (currently `{{level}}`)" + selected-user-empty: " *(no override yet)*" + pick-user-placeholder: Pick a user to set/clear an override… + clear: — Clear override — + clear-desc: "Remove this user's override." + assign-placeholder: Assign a level (or clear)… + cmds: + title: ⚡ Command overrides + description: | + By default, slash commands are gated only by Discord's native `defaultMemberPermissions` (visible in **Server Settings → Integrations → this bot**). This screen lets you add an additional **level requirement** on top, in this guild only. + + • Example: gate `/kick` behind `moderator` — anyone with a role bound to `moderator` (or higher) can use it. + • Set with the **Set override** button (command name + level ID). + • Clear an override to revert to Discord-native default. + • Discord `Administrator`, the guild owner, and the bot OWNER always bypass overrides. + active-overrides: Active overrides + none: _(none)_ + clear-placeholder: Clear an override… + clear-desc: "currently `{{level}}`" + modal-title: Set command override + modal-command-label: "Command name (without /)" + modal-command-placeholder: kick + modal-level-label: Level ID + modal-level-placeholder: moderator + sets: + title: ⚙️ Setting overrides + description: | + Re-gate individual settings by their `Module.key` path. By default, anyone who can run `/settings` can edit any key — set an override here to require a specific level for one particular key in this guild. + + • Example: `Utility.defaultServerLanguage` → `admin` so only admins change the server's fallback language, even if mods can otherwise use `/settings`. + • Use **Set override** with a `Module.key` path (autocomplete in `/settings` shows the available keys per module). + • Clear an override to revert to the default `/settings` gate. + active-overrides: Active overrides + none: _(none)_ + clear-placeholder: Clear an override… + clear-desc: "currently `{{level}}`" + modal-title: Set setting override + modal-key-label: "Setting key (Module.key)" + modal-key-placeholder: Utility.defaultServerLanguage + modal-level-label: Level ID + modal-level-placeholder: moderator + buttons: + create: Create level + delete: Delete level + refresh: Refresh + close: Close + back: Back + cancel: Cancel + set-override: Set override + errors: + closed: Closed. stats: name: stats description: View bot statistics and information. diff --git a/modules/Utility/locales/it.yaml b/modules/Utility/locales/it.yaml index e845391..838aff3 100644 --- a/modules/Utility/locales/it.yaml +++ b/modules/Utility/locales/it.yaml @@ -65,6 +65,61 @@ commands: reset: title: Impostazione Ripristinata description: L’impostazione è stata ripristinata al valore predefinito. + ui: + home: + title: commands.settings.ui.home.title + description: commands.settings.ui.home.description + modules-with-settings: commands.settings.ui.home.modules-with-settings + none: commands.settings.ui.home.none + pick-placeholder: commands.settings.ui.home.pick-placeholder + keys-suffix: commands.settings.ui.home.keys-suffix + overrides-suffix: commands.settings.ui.home.overrides-suffix + module: + title: commands.settings.ui.module.title + description: commands.settings.ui.module.description + keys: commands.settings.ui.module.keys + no-keys: commands.settings.ui.module.no-keys + pick-placeholder: commands.settings.ui.module.pick-placeholder + no-settings: commands.settings.ui.module.no-settings + key: + title: commands.settings.ui.key.title + no-description: commands.settings.ui.key.no-description + type: commands.settings.ui.key.type + default: commands.settings.ui.key.default + current: commands.settings.ui.key.current + permission-override: commands.settings.ui.key.permission-override + permission-override-value: commands.settings.ui.key.permission-override-value + unknown: commands.settings.ui.key.unknown + editors: + enum-placeholder: commands.settings.ui.editors.enum-placeholder + bool-true: commands.settings.ui.editors.bool-true + bool-false: commands.settings.ui.editors.bool-false + channel-placeholder: commands.settings.ui.editors.channel-placeholder + role-placeholder: commands.settings.ui.editors.role-placeholder + user-placeholder: commands.settings.ui.editors.user-placeholder + edit-button: commands.settings.ui.editors.edit-button + array-add-button: commands.settings.ui.editors.array-add-button + array-add-channel: commands.settings.ui.editors.array-add-channel + array-add-role: commands.settings.ui.editors.array-add-role + array-add-user: commands.settings.ui.editors.array-add-user + array-remove: commands.settings.ui.editors.array-remove + modals: + edit-title: commands.settings.ui.modals.edit-title + edit-label: commands.settings.ui.modals.edit-label + array-add-title: commands.settings.ui.modals.array-add-title + array-add-label: commands.settings.ui.modals.array-add-label + values: + unset: commands.settings.ui.values.unset + empty: commands.settings.ui.values.empty + buttons: + reset: commands.settings.ui.buttons.reset + back: commands.settings.ui.buttons.back + refresh: commands.settings.ui.buttons.refresh + close: commands.settings.ui.buttons.close + errors: + closed: commands.settings.ui.errors.closed + unknown-setting: commands.settings.ui.errors.unknown-setting + no-settings-module: commands.settings.ui.errors.no-settings-module stats: name: stats description: Visualizza statistiche e informazioni sul bot. @@ -86,3 +141,112 @@ commands: success: ":flag_it: La tua lingua preferita è stata impostata su **Italiano**!" reset: ":globe_with_meridians: D'ora in poi la tua lingua sarà sincronizzata con la lingua predefinita!" + permissions: + name: commands.permissions.name + description: commands.permissions.description + ui: + home: + title: commands.permissions.ui.home.title + description: commands.permissions.ui.home.description + current-ladder: commands.permissions.ui.home.current-ladder + user-overrides: commands.permissions.ui.home.user-overrides + command-overrides: commands.permissions.ui.home.command-overrides + setting-overrides: commands.permissions.ui.home.setting-overrides + none: commands.permissions.ui.home.none + no-levels: commands.permissions.ui.home.no-levels + footer: commands.permissions.ui.home.footer + nav-placeholder: commands.permissions.ui.home.nav-placeholder + nav-levels: commands.permissions.ui.home.nav-levels + nav-levels-desc: commands.permissions.ui.home.nav-levels-desc + nav-roles: commands.permissions.ui.home.nav-roles + nav-roles-desc: commands.permissions.ui.home.nav-roles-desc + nav-users: commands.permissions.ui.home.nav-users + nav-users-desc: commands.permissions.ui.home.nav-users-desc + nav-cmds: commands.permissions.ui.home.nav-cmds + nav-cmds-desc: commands.permissions.ui.home.nav-cmds-desc + nav-sets: commands.permissions.ui.home.nav-sets + nav-sets-desc: commands.permissions.ui.home.nav-sets-desc + levels: + title: commands.permissions.ui.levels.title + description: commands.permissions.ui.levels.description + current-ladder: commands.permissions.ui.levels.current-ladder + no-levels: commands.permissions.ui.levels.no-levels + no-roles-bound: commands.permissions.ui.levels.no-roles-bound + default-tag: commands.permissions.ui.levels.default-tag + edit-placeholder: commands.permissions.ui.levels.edit-placeholder + delete-title: commands.permissions.ui.levels.delete-title + delete-description: commands.permissions.ui.levels.delete-description + delete-placeholder: commands.permissions.ui.levels.delete-placeholder + none-to-delete: commands.permissions.ui.levels.none-to-delete + weight-fmt: commands.permissions.ui.levels.weight-fmt + weight-fmt-default: commands.permissions.ui.levels.weight-fmt-default + modal-create-title: commands.permissions.ui.levels.modal-create-title + modal-edit-title: commands.permissions.ui.levels.modal-edit-title + modal-id-label: commands.permissions.ui.levels.modal-id-label + modal-id-placeholder: commands.permissions.ui.levels.modal-id-placeholder + modal-name-label: commands.permissions.ui.levels.modal-name-label + modal-name-placeholder: commands.permissions.ui.levels.modal-name-placeholder + modal-weight-label: commands.permissions.ui.levels.modal-weight-label + modal-weight-placeholder: commands.permissions.ui.levels.modal-weight-placeholder + empty-id-error: commands.permissions.ui.levels.empty-id-error + weight-not-int-error: commands.permissions.ui.levels.weight-not-int-error + unknown-level-error: commands.permissions.ui.levels.unknown-level-error + roles: + title: commands.permissions.ui.roles.title + intro: commands.permissions.ui.roles.intro + focused-line: commands.permissions.ui.roles.focused-line + bound-list: commands.permissions.ui.roles.bound-list + no-roles-bound: commands.permissions.ui.roles.no-roles-bound + all-levels: commands.permissions.ui.roles.all-levels + none: commands.permissions.ui.roles.none + bind-placeholder: commands.permissions.ui.roles.bind-placeholder + unbind-placeholder: commands.permissions.ui.roles.unbind-placeholder + level-pick-placeholder: commands.permissions.ui.roles.level-pick-placeholder + level-switch-placeholder: commands.permissions.ui.roles.level-switch-placeholder + level-pick-desc: commands.permissions.ui.roles.level-pick-desc + users: + title: commands.permissions.ui.users.title + description: commands.permissions.ui.users.description + active-overrides: commands.permissions.ui.users.active-overrides + none: commands.permissions.ui.users.none + selected-user: commands.permissions.ui.users.selected-user + selected-user-current: commands.permissions.ui.users.selected-user-current + selected-user-empty: commands.permissions.ui.users.selected-user-empty + pick-user-placeholder: commands.permissions.ui.users.pick-user-placeholder + clear: commands.permissions.ui.users.clear + clear-desc: commands.permissions.ui.users.clear-desc + assign-placeholder: commands.permissions.ui.users.assign-placeholder + cmds: + title: commands.permissions.ui.cmds.title + description: commands.permissions.ui.cmds.description + active-overrides: commands.permissions.ui.cmds.active-overrides + none: commands.permissions.ui.cmds.none + clear-placeholder: commands.permissions.ui.cmds.clear-placeholder + clear-desc: commands.permissions.ui.cmds.clear-desc + modal-title: commands.permissions.ui.cmds.modal-title + modal-command-label: commands.permissions.ui.cmds.modal-command-label + modal-command-placeholder: commands.permissions.ui.cmds.modal-command-placeholder + modal-level-label: commands.permissions.ui.cmds.modal-level-label + modal-level-placeholder: commands.permissions.ui.cmds.modal-level-placeholder + sets: + title: commands.permissions.ui.sets.title + description: commands.permissions.ui.sets.description + active-overrides: commands.permissions.ui.sets.active-overrides + none: commands.permissions.ui.sets.none + clear-placeholder: commands.permissions.ui.sets.clear-placeholder + clear-desc: commands.permissions.ui.sets.clear-desc + modal-title: commands.permissions.ui.sets.modal-title + modal-key-label: commands.permissions.ui.sets.modal-key-label + modal-key-placeholder: commands.permissions.ui.sets.modal-key-placeholder + modal-level-label: commands.permissions.ui.sets.modal-level-label + modal-level-placeholder: commands.permissions.ui.sets.modal-level-placeholder + buttons: + create: commands.permissions.ui.buttons.create + delete: commands.permissions.ui.buttons.delete + refresh: commands.permissions.ui.buttons.refresh + close: commands.permissions.ui.buttons.close + back: commands.permissions.ui.buttons.back + cancel: commands.permissions.ui.buttons.cancel + set-override: commands.permissions.ui.buttons.set-override + errors: + closed: commands.permissions.ui.errors.closed From 1869948e195e8c1ab89ed26a2d95797ae022a5b0 Mon Sep 17 00:00:00 2001 From: Miky88 <44903677+Miky88@users.noreply.github.com> Date: Fri, 15 May 2026 14:20:13 +0200 Subject: [PATCH 8/8] Refactors module manager for unified access and clarity Consolidates module manager access under a single property, standardizing interactions across the codebase and replacing scattered references. Simplifies database declaration logic for modules, enforcing explicit string array usage for collection names and providing safer accessor methods. Cleans up code by removing unnecessary comments and improves maintainability with new helper methods for module queries and command publishing. --- core/Module.js | 50 ++++----- core/ModuleManager.js | 103 ++++++++++-------- index.js | 4 +- .../InteractionCommandHandler.js | 4 +- modules/System/commands/update.js | 2 +- modules/System/lib/ModmanUI.js | 24 ++-- modules/Utility/commands/perms.js | 1 - modules/Utility/lib/PermissionsUI.js | 14 --- modules/Utility/lib/SettingsUI.js | 10 -- 9 files changed, 93 insertions(+), 119 deletions(-) diff --git a/core/Module.js b/core/Module.js index b92c530..480926f 100644 --- a/core/Module.js +++ b/core/Module.js @@ -15,7 +15,7 @@ module.exports = class Module { * @param {string[]} [options.dependencies] Module names this module needs loaded. * @param {string[]} [options.runBefore] Modules this one's event handlers should run before. * @param {string[]} [options.runAfter] Modules this one's event handlers should run after. - * @param {boolean | string[]} [options.databases] `true` for a single `default` collection or an array of collection names. + * @param {string[]} [options.databases] Names of Loki collections this module wants in its database handle (e.g. `['guilds', 'logs']`). The module's per-file DB is created automatically when this list is non-empty. * @param {object} [options.config] Default per-module config schema. * @param {object} [options.settings] Schema-driven per-guild settings. */ @@ -27,23 +27,21 @@ module.exports = class Module { dependencies = [], runBefore = [], runAfter = [], - databases = false, + databases = [], config = null, settings = null }) { this.client = client; - const declaredCollections = Array.isArray(databases) - ? [...databases] - : (databases ? ['default'] : []); + if (!Array.isArray(databases) || databases.some(c => typeof c !== 'string')) + throw new Error(`Module "${name}": \`databases\` must be a string array.`); this.options = { name, info, version, events, dependencies: [...dependencies], runBefore: [...runBefore], runAfter: [...runAfter], - databases, - collections: declaredCollections, + databases: [...databases], settings }; @@ -56,7 +54,6 @@ module.exports = class Module { this.settings = new SettingsManager(client, this, settings); } - // ───── lifecycle hooks ───── // ModuleManager calls these in a defined order. Default implementations // cover the common cases (loading commands on start, clearing on stop); // override to add async setup, watch external resources, etc. @@ -91,8 +88,6 @@ module.exports = class Module { */ async destroy(client) {} - // ───── i18n ───── - t(_key, interactionOrLang, vars) { let key = `modules.${this.options.name}.${_key}`; @@ -100,7 +95,7 @@ module.exports = class Module { const interaction = interactionOrLang; const i18n = this.client.i18n; - const utility = this.client.moduleManager.modules.get("Utility")?.settings; + const utility = this.client.modules.getModule("Utility")?.settings; const guildLang = interaction.guild ? utility?.get(interaction.guild.id)?.settings?.defaultServerLanguage : null; @@ -126,8 +121,6 @@ module.exports = class Module { return this.client.i18n.getLocalizationObject(key); } - // ───── commands ───── - async loadCommands() { const commands = fs.existsSync(`./modules/${this.options.name}/commands`) ? fs.readdirSync(`./modules/${this.options.name}/commands`).filter(file => file.endsWith(".js")) : []; @@ -157,24 +150,29 @@ module.exports = class Module { return method.call(this, client, ...rest); } - // ───── database ───── - /** - * @type {import('./DatabaseHandle') | null} + * The module's database handle, or `null` if the module didn't declare + * any `databases`. Access individual collections with `handle.collection(name)`. + * @returns {import('./DatabaseHandle') | null} */ get db() { - if (this.options.collections.length === 0) return null; + if (this.options.databases.length === 0) return null; return this.client.database.get(this.options.name) || null; } - saveData(collectionName, data) { - if (!this.db) - throw new Error("You must declare `databases` in module options to use this method."); - if (!data) - throw new Error("You must pass a valid argument to data."); - - const collection = this.db.collection(collectionName); - if (data.$loki) collection.update(data); - else collection.insert(data); + /** + * Convenience accessor: returns the named collection if it was declared + * in `options.databases`; throws otherwise. Prefer this over `db.collection(name)` + * for typo safety — undeclared names are rejected. + * @param {string} name + * @returns {import('lokijs').Collection} + */ + collection(name) { + const handle = this.db; + if (!handle) + throw new Error(`Module "${this.options.name}" did not declare any databases.`); + if (!this.options.databases.includes(name)) + throw new Error(`Module "${this.options.name}" did not declare collection "${name}" in \`databases\`.`); + return handle.collection(name); } } diff --git a/core/ModuleManager.js b/core/ModuleManager.js index 846236b..c1df9ca 100644 --- a/core/ModuleManager.js +++ b/core/ModuleManager.js @@ -48,7 +48,7 @@ module.exports = class ModuleManager { constructor(client) { this.client = client; /** @type {Map} */ - this.modules = new Map(); + this._modules = new Map(); /** @type {Set} Discord events the manager has wired to the client. */ this.events = new Set(); /** @type {Map} Last load/start error per module name. */ @@ -57,8 +57,6 @@ module.exports = class ModuleManager { this.logger = new Logger(this.constructor.name); } - // ───────────────────────────────── boot - /** * Discover, construct, init, and start every module on disk in correct * topological order. Called once from index.js. @@ -101,7 +99,7 @@ module.exports = class ModuleManager { if (this.errors.has(m.options.name)) continue; try { this._wireDatabase(m); - this.modules.set(m.options.name, m); + this._modules.set(m.options.name, m); await m.init(this.client); if (this._isEnabledPersisted(m.options.name)) @@ -110,7 +108,7 @@ module.exports = class ModuleManager { this.logger.verbose(`${m.options.name} loaded`); } catch (err) { this.errors.set(m.options.name, err); - this.modules.delete(m.options.name); + this._modules.delete(m.options.name); this.client.errorHandler?.capture(err, { source: 'ModuleManager.init', module: m.options.name }); } } @@ -118,17 +116,15 @@ module.exports = class ModuleManager { // Phase 5: wire Discord event listeners (one per event type). this._installEventDispatchers(); - this.logger.success(`Successfully loaded ${this.modules.size} module(s)`); + this.logger.success(`Successfully loaded ${this._modules.size} module(s)`); } - // ───────────────────────────────── public ops - /** * Load a module that isn't currently in the registry. * @returns {Promise>} */ async load(name) { - if (this.modules.has(name)) return this._fail(ERR.ALREADY_LOADED, `${name} is already loaded.`); + if (this._modules.has(name)) return this._fail(ERR.ALREADY_LOADED, `${name} is already loaded.`); if (!this._existsOnDisk(name)) return this._fail(ERR.NOT_FOUND, `${name} is not on disk.`); const built = this._construct(name); @@ -137,12 +133,12 @@ module.exports = class ModuleManager { // Verify dependencies are satisfiable. for (const dep of mod.options.dependencies) { - if (!this.modules.has(dep) && !this._existsOnDisk(dep)) + if (!this._modules.has(dep) && !this._existsOnDisk(dep)) return this._fail(ERR.MISSING_DEPENDENCY, `${name} requires "${dep}" which is missing.`); } // Recursively load any missing dependencies first. for (const dep of mod.options.dependencies) { - if (!this.modules.has(dep)) { + if (!this._modules.has(dep)) { const r = await this.load(dep); if (!r.ok) return this._fail(ERR.MISSING_DEPENDENCY, `Failed to load dependency "${dep}": ${r.error}`); } @@ -150,7 +146,7 @@ module.exports = class ModuleManager { try { this._wireDatabase(mod); - this.modules.set(name, mod); + this._modules.set(name, mod); await mod.init(this.client); if (this._isEnabledPersisted(name)) await mod.start(this.client); @@ -161,7 +157,7 @@ module.exports = class ModuleManager { return this._ok(mod); } catch (err) { this.errors.set(name, err); - this.modules.delete(name); + this._modules.delete(name); this.client.errorHandler?.capture(err, { source: 'ModuleManager.load', module: name }); return this._fail(ERR.LOAD_ERROR, err.message); } @@ -175,7 +171,7 @@ module.exports = class ModuleManager { * @returns {Promise>} */ async unload(name, opts = {}) { - if (!this.modules.has(name)) return this._fail(ERR.NOT_LOADED, `${name} is not loaded.`); + if (!this._modules.has(name)) return this._fail(ERR.NOT_LOADED, `${name} is not loaded.`); const dependents = this._dependentsOf(name); if (dependents.length > 0 && !opts.force) @@ -184,7 +180,7 @@ module.exports = class ModuleManager { const toUnload = opts.force ? [...dependents.reverse(), name] : [name]; for (const target of toUnload) { - const mod = this.modules.get(target); + const mod = this._modules.get(target); if (!mod) continue; try { if (this._isEnabledPersisted(target)) await mod.stop(this.client); @@ -192,7 +188,7 @@ module.exports = class ModuleManager { } catch (err) { this.client.errorHandler?.capture(err, { source: 'ModuleManager.unload', module: target }); } - this.modules.delete(target); + this._modules.delete(target); this.logger.info(`${target} unloaded`); } @@ -206,7 +202,7 @@ module.exports = class ModuleManager { * @returns {Promise>} */ async reload(name) { - if (!this.modules.has(name)) return this._fail(ERR.NOT_LOADED, `${name} is not loaded.`); + if (!this._modules.has(name)) return this._fail(ERR.NOT_LOADED, `${name} is not loaded.`); const wasEnabled = this._isEnabledPersisted(name); const u = await this.unload(name, { force: true }); @@ -227,7 +223,7 @@ module.exports = class ModuleManager { * Persist enabled = true and call start() if it wasn't already running. */ async enable(name) { - const mod = this.modules.get(name); + const mod = this._modules.get(name); if (!mod) return this._fail(ERR.NOT_LOADED, `${name} is not loaded.`); if (this._isEnabledPersisted(name)) return this._ok(mod); @@ -246,7 +242,7 @@ module.exports = class ModuleManager { * Persist enabled = false and call stop() if it was running. */ async disable(name) { - const mod = this.modules.get(name); + const mod = this._modules.get(name); if (!mod) return this._fail(ERR.NOT_LOADED, `${name} is not loaded.`); if (!this._isEnabledPersisted(name)) return this._ok(mod); @@ -261,16 +257,44 @@ module.exports = class ModuleManager { } } - isLoaded(name) { return this.modules.has(name); } - isEnabled(name) { return this.modules.has(name) && this._isEnabledPersisted(name); } + isLoaded(name) { return this._modules.has(name); } + isEnabled(name) { return this._modules.has(name) && this._isEnabledPersisted(name); } isOnDisk(name) { return this._existsOnDisk(name); } + /** + * @param {string} name + * @returns {Module | null} The loaded module, or null if not loaded. + */ + getModule(name) { + return this._modules.get(name) || null; + } + + /** All currently loaded modules, regardless of enabled state. */ + allModules() { + return [...this._modules.values()]; + } + + /** Loaded modules that are persisted as enabled. */ + enabledModules() { + return this.allModules().filter(m => this._isEnabledPersisted(m.options.name)); + } + + /** + * Slash commands eligible for global Discord registration. System + * commands are excluded — they're registered to the configured system + * guilds by the System module itself, not globally. + * @returns {Command[]} + */ + getPublishableCommands() { + return this.commands.filter(c => c.module.options.name !== 'System'); + } + /** * Aggregated info for inspection / GUI. * @returns {Result<{ name, info, version, enabled, loaded, dependencies, dependents, events, commands, lastError }>} */ info(name) { - const mod = this.modules.get(name); + const mod = this._modules.get(name); if (!mod) { if (this._existsOnDisk(name)) return this._ok({ name, loaded: false, enabled: false, @@ -298,10 +322,10 @@ module.exports = class ModuleManager { */ list() { const onDisk = this._discoverOnDisk(); - const loaded = [...this.modules.keys()]; - const available = onDisk.filter(n => !this.modules.has(n)); + const loaded = [...this._modules.keys()]; + const available = onDisk.filter(n => !this._modules.has(n)); const failed = onDisk - .filter(n => this.errors.has(n) && !this.modules.has(n)) + .filter(n => this.errors.has(n) && !this._modules.has(n)) .map(n => ({ name: n, error: this.errors.get(n).message })); return { loaded, available, failed }; } @@ -310,7 +334,7 @@ module.exports = class ModuleManager { * @returns {[Command, Module] | [null, null]} */ getCommand(name) { - for (const module of this.modules.values()) { + for (const module of this._modules.values()) { if (this._isEnabledPersisted(module.options.name) && module.commands?.has(name)) return [module.commands.get(name), module]; } @@ -320,15 +344,13 @@ module.exports = class ModuleManager { /** Aggregated commands across enabled modules. */ get commands() { const out = []; - for (const m of this.modules.values()) { + for (const m of this._modules.values()) { if (!this._isEnabledPersisted(m.options.name)) continue; for (const c of m.commands.values()) out.push(c); } return out; } - // ───────────────────────────────── persistence - _stateCollection() { const handle = this.client.database.get('core'); return handle.addCollection('module_states'); @@ -352,8 +374,6 @@ module.exports = class ModuleManager { } } - // ───────────────────────────────── construction - _construct(name) { try { const modulePath = require.resolve(`@modules/${name}/${name}.js`); @@ -369,7 +389,7 @@ module.exports = class ModuleManager { } _wireDatabase(mod) { - const collections = [...mod.options.collections]; + const collections = [...mod.options.databases]; if (mod.options.settings) collections.push('settings'); if (collections.length > 0) this.client.database.register(mod.options.name, { collections }); @@ -377,8 +397,6 @@ module.exports = class ModuleManager { mod.settings = new SettingsManager(this.client, mod, mod.options.settings); } - // ───────────────────────────────── topo sort - /** * Generic Kahn's-algorithm topological sort. * @param {Map} items @@ -405,13 +423,11 @@ module.exports = class ModuleManager { } _dependentsOf(name) { - return [...this.modules.values()] + return [...this._modules.values()] .filter(m => m.options.dependencies.includes(name)) .map(m => m.options.name); } - // ───────────────────────────────── filesystem - _modulesDir() { return path.resolve('./modules'); } _discoverOnDisk() { @@ -427,11 +443,9 @@ module.exports = class ModuleManager { return fs.existsSync(path.join(this._modulesDir(), name, `${name}.js`)); } - // ───────────────────────────────── event dispatch - _installEventDispatchers() { const allEvents = new Set(); - for (const m of this.modules.values()) + for (const m of this._modules.values()) for (const e of m.options.events) allEvents.add(e); for (const event of allEvents) { @@ -460,7 +474,7 @@ module.exports = class ModuleManager { * ignored. On cycle, fall back to alphabetical with a warning. */ _dispatchOrderFor(event) { - const listeners = [...this.modules.values()].filter(m => m.options.events.includes(event)); + const listeners = [...this._modules.values()].filter(m => m.options.events.includes(event)); const names = new Set(listeners.map(m => m.options.name)); // Build incoming-edge map: edges[X] = names that must run before X. @@ -492,8 +506,6 @@ module.exports = class ModuleManager { return ordered; } - // ───────────────────────────────── Discord-side command sync - /** * Re-publish global commands so unloaded modules' slash commands disappear. * Re-uses the same filter rule InteractionCommandHandler uses on boot. @@ -501,15 +513,12 @@ module.exports = class ModuleManager { async _unregisterCommandsWithDiscord() { try { if (!this.client.isReady?.()) return; - const cmds = this.commands.filter(c => c.module.options.name !== 'System').map(c => c.toJson()); - await this.client.application.commands.set(cmds); + await this.client.application.commands.set(this.getPublishableCommands().map(c => c.toJson())); } catch (err) { this.client.errorHandler?.capture(err, { source: 'ModuleManager._unregisterCommandsWithDiscord' }); } } - // ───────────────────────────────── result helpers - _ok(value) { return { ok: true, value }; } _fail(code, error) { return { ok: false, code, error }; } diff --git a/index.js b/index.js index 9b166f2..37d9d54 100644 --- a/index.js +++ b/index.js @@ -45,7 +45,7 @@ class BotClient extends Client { this.commands = new Collection(); this.settings = new Collection(); this.utils = new Utils(); - this.moduleManager = new ModuleManager(this); + this.modules = new ModuleManager(this); this.config = config; this.i18n = new LocalizationManager(config.get('i18n')); @@ -58,7 +58,7 @@ class BotClient extends Client { (async () => { const client = new BotClient(); - await client.moduleManager.init(); + await client.modules.init(); await client.login(process.env.TOKEN); })(); diff --git a/modules/InteractionCommandHandler/InteractionCommandHandler.js b/modules/InteractionCommandHandler/InteractionCommandHandler.js index 2cdbb33..af428ec 100644 --- a/modules/InteractionCommandHandler/InteractionCommandHandler.js +++ b/modules/InteractionCommandHandler/InteractionCommandHandler.js @@ -18,7 +18,7 @@ module.exports = class InteractionCommandHandler extends Module { async clientReady(client) { try { await client.application.commands - .set(client.moduleManager.commands.filter(c => c.module.options.name !== "System").map(c => c.toJson())); + .set(client.modules.getPublishableCommands().map(c => c.toJson())); } catch (err) { client.errorHandler?.capture(err, { source: 'commandRegistration', module: this.options.name }); } @@ -35,7 +35,7 @@ module.exports = class InteractionCommandHandler extends Module { let cmd, cmdModule; try { - [cmd, cmdModule] = this.client.moduleManager.getCommand(interaction.commandName); + [cmd, cmdModule] = this.client.modules.getCommand(interaction.commandName); if (!cmd) { await this._safeReply(interaction, { content: this.t('errors.command-not-found', interaction), flags: [Discord.MessageFlags.Ephemeral] }); return ctx?.stopPropagation('command not found'); diff --git a/modules/System/commands/update.js b/modules/System/commands/update.js index 22e7ab0..5fea8d8 100644 --- a/modules/System/commands/update.js +++ b/modules/System/commands/update.js @@ -32,7 +32,7 @@ module.exports = class UpdateCommand extends Command { return await interaction.reply("```sh\n" + err + "```"); } - const [reboot] = client.moduleManager.getCommand("reboot"); + const [reboot] = client.modules.getCommand("reboot"); if (!reboot) return interaction.reply(this.t('messages.noreboot', interaction)); diff --git a/modules/System/lib/ModmanUI.js b/modules/System/lib/ModmanUI.js index 66deaa4..ff37baa 100644 --- a/modules/System/lib/ModmanUI.js +++ b/modules/System/lib/ModmanUI.js @@ -59,10 +59,8 @@ module.exports = class ModmanUI { return true; } - // ───── HOME ───── - _home(interaction) { - const { loaded, available, failed } = this.client.moduleManager.list(); + const { loaded, available, failed } = this.client.modules.list(); const allNames = [...new Set([...loaded, ...available, ...failed.map(f => f.name)])].sort(); const lines = allNames.map(name => `${this._badge(name)} \`${name}\``); @@ -100,7 +98,7 @@ module.exports = class ModmanUI { } _badge(name) { - const mm = this.client.moduleManager; + const mm = this.client.modules; if (!mm.isLoaded(name) && !mm.isOnDisk(name)) return '❓'; if (!mm.isLoaded(name)) { const failed = mm.list().failed.some(f => f.name === name); @@ -110,7 +108,7 @@ module.exports = class ModmanUI { } _statusText(interaction, name) { - const mm = this.client.moduleManager; + const mm = this.client.modules; if (!mm.isLoaded(name)) { const failed = mm.list().failed.some(f => f.name === name); return this._t(failed ? 'status.load-failed' : 'status.not-loaded', interaction); @@ -118,10 +116,8 @@ module.exports = class ModmanUI { return this._t(mm.isEnabled(name) ? 'status.enabled' : 'status.disabled', interaction); } - // ───── DETAIL ───── - _detail(interaction, name) { - const mm = this.client.moduleManager; + const mm = this.client.modules; const r = mm.info(name); if (!r.ok) return this._errorPanel(interaction, this._t('detail.not-found', interaction, { name, error: r.error })); const i = r.value; @@ -158,10 +154,8 @@ module.exports = class ModmanUI { return { embeds: [embed], components }; } - // ───── ACTIONS ───── - async _action(interaction, name, kind) { - const mm = this.client.moduleManager; + const mm = this.client.modules; let result; switch (kind) { case 'reload': @@ -182,7 +176,7 @@ module.exports = class ModmanUI { } async _unloadFlow(interaction, name) { - const mm = this.client.moduleManager; + const mm = this.client.modules; const r = await mm.unload(name); if (r.ok) return this._update(interaction, this._home(interaction)); @@ -200,7 +194,7 @@ module.exports = class ModmanUI { } async _reloadAll(interaction) { - const mm = this.client.moduleManager; + const mm = this.client.modules; const loaded = mm.list().loaded; const results = []; for (const name of loaded) { @@ -233,14 +227,12 @@ module.exports = class ModmanUI { async _loadFromModal(interaction) { const name = interaction.fields.getTextInputValue('name').trim(); - const r = await this.client.moduleManager.load(name); + const r = await this.client.modules.load(name); if (!r.ok) return this._update(interaction, this._errorPanel(interaction, this._t('errors.load-failed', interaction, { code: r.code, error: r.error }))); return this._update(interaction, this._detail(interaction, name)); } - // ───── helpers ───── - _errorPanel(interaction, message) { const embed = new EmbedBuilder() .setTitle(this._t('home.title', interaction)) diff --git a/modules/Utility/commands/perms.js b/modules/Utility/commands/perms.js index c5883bb..9633a1e 100644 --- a/modules/Utility/commands/perms.js +++ b/modules/Utility/commands/perms.js @@ -41,7 +41,6 @@ module.exports = class PermsCommand extends Command { value: `${Object.entries(PowerLevels).find(l => l[1] == data.powerlevel)[0]}` }, ]) - // .setDescription(`todo`) .setColor("Random") await interaction.reply({ embeds: [embed] }); diff --git a/modules/Utility/lib/PermissionsUI.js b/modules/Utility/lib/PermissionsUI.js index 341141b..290f9d4 100644 --- a/modules/Utility/lib/PermissionsUI.js +++ b/modules/Utility/lib/PermissionsUI.js @@ -59,8 +59,6 @@ module.exports = class PermissionsUI { return true; } - // ───── HOME ───── - _home(interaction) { const guildId = interaction.guild.id; const cfg = this.client.permissions.getConfig(guildId); @@ -112,8 +110,6 @@ module.exports = class PermissionsUI { } } - // ───── LEVELS ───── - _levelsScreen(interaction) { const guildId = interaction.guild.id; const cfg = this.client.permissions.getConfig(guildId); @@ -239,8 +235,6 @@ module.exports = class PermissionsUI { await interaction.showModal(modal); } - // ───── ROLE BINDINGS ───── - _rolesScreen(interaction, focusedLevelId = null) { const guildId = interaction.guild.id; const cfg = this.client.permissions.getConfig(guildId); @@ -328,8 +322,6 @@ module.exports = class PermissionsUI { } } - // ───── USER OVERRIDES ───── - _usersScreen(interaction, focusedUserId = null) { const guildId = interaction.guild.id; const cfg = this.client.permissions.getConfig(guildId); @@ -402,8 +394,6 @@ module.exports = class PermissionsUI { } } - // ───── COMMAND OVERRIDES ───── - _cmdsScreen(interaction) { const guildId = interaction.guild.id; const cfg = this.client.permissions.getConfig(guildId); @@ -470,8 +460,6 @@ module.exports = class PermissionsUI { } } - // ───── SETTING OVERRIDES ───── - _setsScreen(interaction) { const guildId = interaction.guild.id; const cfg = this.client.permissions.getConfig(guildId); @@ -538,8 +526,6 @@ module.exports = class PermissionsUI { } } - // ───── helpers ───── - async _update(interaction, payload) { if (interaction.replied || interaction.deferred) return interaction.editReply(payload); diff --git a/modules/Utility/lib/SettingsUI.js b/modules/Utility/lib/SettingsUI.js index 8271f39..def3021 100644 --- a/modules/Utility/lib/SettingsUI.js +++ b/modules/Utility/lib/SettingsUI.js @@ -70,8 +70,6 @@ module.exports = class SettingsUI { return true; } - // ───── HOME ───── - _home(interaction) { const guildId = interaction.guild.id; const modules = [...this.client.settings.entries()]; @@ -111,8 +109,6 @@ module.exports = class SettingsUI { return { content: '', embeds: [embed], components }; } - // ───── MODULE ───── - _moduleScreen(interaction, moduleName) { const guildId = interaction.guild.id; const mgr = this.client.settings.get(moduleName); @@ -152,8 +148,6 @@ module.exports = class SettingsUI { return { embeds: [embed], components }; } - // ───── KEY ───── - _keyScreen(interaction, moduleName, key) { const guildId = interaction.guild.id; const mgr = this.client.settings.get(moduleName); @@ -278,8 +272,6 @@ module.exports = class SettingsUI { return components; } - // ───── handlers ───── - async _showEditModal(interaction, moduleName, key) { const mgr = this.client.settings.get(moduleName); if (!mgr || !mgr.has(key)) return this._safeError(interaction, this._t('errors.unknown-setting', interaction)); @@ -357,8 +349,6 @@ module.exports = class SettingsUI { return this._update(interaction, this._keyScreen(interaction, moduleName, key)); } - // ───── helpers ───── - _format(interaction, v) { if (v == null || v === '') return this._t('values.unset', interaction); if (Array.isArray(v)) return v.length ? v.map(x => `\`${x}\``).join(', ') : this._t('values.empty', interaction);