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/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/README.md b/README.md index 70115a3..3e75534 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) { @@ -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..b7555d3 100644 --- a/config.yml +++ b/config.yml @@ -9,6 +9,7 @@ intents: - GuildMembers - GuildModeration - GuildBans + - GuildExpressions - GuildEmojisAndStickers - GuildIntegrations - GuildWebhooks @@ -30,3 +31,14 @@ intents: partials: - Reaction - Message +errorReporting: + channelId: null + notifyOwners: false + dedupWindowMs: 60000 + exitOnUncaught: true +i18n: + defaultLang: en-GB + referenceLanguage: en-GB + autoSync: true + hotReload: false +verbose: false diff --git a/structures/Command.js b/core/Command.js similarity index 95% rename from structures/Command.js rename to core/Command.js index c41ba5f..730f44c 100644 --- a/structures/Command.js +++ b/core/Command.js @@ -10,11 +10,11 @@ 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' }) { - /** @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/core/ConfigurationManager.js similarity index 91% rename from structures/ConfigurationManager.js rename to core/ConfigurationManager.js index 30434ef..ca457ae 100644 --- a/structures/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 { /** @@ -26,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 new file mode 100644 index 0000000..0053f42 --- /dev/null +++ b/core/Database.js @@ -0,0 +1,244 @@ +const Loki = require('lokijs'); +const path = require('path'); +const fs = require('fs'); +const Logger = require('./Logger.js'); +const PowerLevels = require('./PowerLevels.js'); +const DatabaseHandle = require('./DatabaseHandle.js'); + +const userCache = new Map(); + +const DEFAULT_DATA_DIR = 'data'; +const CORE_FILE = 'database.db'; + +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 {import('..')} client + */ + constructor(client) { + this.client = client; + this.logger = new Logger('DB'); + /** @type {Map} */ + this.handles = new Map(); + + this.core = this.register('core', { + file: CORE_FILE, + collections: ['users'] + }); + } + + /** + * 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} 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.users.insert({ + id: userID, + powerlevel: PowerLevels.USER, + language: null + }); + this.cacheUser(user); + return user; + } + + /** + * @param {import('discord.js').User | { id: string }} user + */ + cacheUser(user) { + userCache.set(user.id, user); + } + + /** + * @param {String} userID + */ + getUser(userID) { + const data = userCache.get(userID) || this.users.findOne({ id: userID }); + if (data) this.cacheUser(data); + return data; + } + + /** + * @param {String} userID + */ + forceUser(userID) { + let user = this.getUser(userID); + if (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); + } + + /** + * @param {*} data + */ + async updateUser(data) { + delete data.user; + this.users.update(data); + + const update = this.users.findOne({ id: data.id }); + this.cacheUser(update); + return update; + } + + /** + * @param {import('lokijs').Collection} collection + */ + fixCollection(collection) { + this.logger.debug('Fixing collection', collection.name); + const deduplicateSet = new Set(); + const data = collection.data + .sort((a, b) => a.meta.created - b.meta.created) + .filter((x) => { + const duplicated = deduplicateSet.has(x.$loki); + deduplicateSet.add(x.$loki); + 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; + + collection.data = data; + collection.idIndex = index; + collection.maxId = collection.data?.length + ? Math.max(...collection.data.map((x) => x.$loki)) + : 0; + collection.dirty = 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 new file mode 100644 index 0000000..1b1df23 --- /dev/null +++ b/core/LocalizationManager.js @@ -0,0 +1,316 @@ +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 {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.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(); + } + + 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 + ); + } + } + } + + _readYaml(filePath) { + try { + const text = fs.readFileSync(filePath, 'utf8'); + return parse(text) || {}; + } catch (err) { + this.logger.error(`Failed to load ${filePath}: ${err.message}`); + return {}; + } + } + + /** + * 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; + } + + 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 + ); + } + } + } + + /** + * 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 }; + } + + _getKey(obj, key) { + 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, '\\$&'); + return Object.entries(vars).reduce((out, [k, v]) => { + const pattern = new RegExp(`{{\\s*${escapeRegExp(k)}\\s*}}`, 'g'); + return out.replace(pattern, () => String(v)); + }, String(str)); + } + + /** + * 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). + */ + resolveLanguage(requested) { + if (!requested || typeof requested !== 'string') + 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); + + if (value === undefined && langCode !== this.defaultLang) + value = this._getKey(this.languages[this.defaultLang] || {}, key); + + if (value === undefined) { + this.logger.warn(`Missing localization for key "${key}" in language "${langCode}"`); + return key; + } + + 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; + } + + /** + * 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; + } + } + + 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); + } + + _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')); + } + } + + _scheduleReload() { + clearTimeout(this._reloadTimer); + this._reloadTimer = setTimeout(() => { + this.logger.info('Locale file change detected — reloading.'); + this.load(); + if (this.autoSync) this.syncMissingKeys(); + }, 500); + } + + _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 out; + } +}; diff --git a/structures/Logger.js b/core/Logger.js similarity index 89% rename from structures/Logger.js rename to core/Logger.js index ce26084..816a243 100644 --- a/structures/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 new file mode 100644 index 0000000..480926f --- /dev/null +++ b/core/Module.js @@ -0,0 +1,178 @@ +const { Collection, BaseInteraction } = require('discord.js'); +const fs = require('fs'); +const ConfigurationManager = require('./ConfigurationManager'); +const SettingsManager = require('./SettingsManager'); +const Logger = require('./Logger'); + +module.exports = class Module { + /** + * @param {import('..')} client + * @param {object} options + * @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 {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. + */ + constructor(client, { + name = this.constructor.name, + info = "No description provided.", + version = null, + events = [], + dependencies = [], + runBefore = [], + runAfter = [], + databases = [], + config = null, + settings = null + }) { + this.client = client; + + 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: [...databases], + settings + }; + + this.commands = new Collection(); + this.logger = new Logger(this.options.name); + + if (config) + this.config = new ConfigurationManager(this, config); + if (settings) + this.settings = new SettingsManager(client, this, settings); + } + + // 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) {} + + t(_key, interactionOrLang, vars) { + let key = `modules.${this.options.name}.${_key}`; + + if (interactionOrLang instanceof BaseInteraction) { + const interaction = interactionOrLang; + const i18n = this.client.i18n; + + const utility = this.client.modules.getModule("Utility")?.settings; + const guildLang = interaction.guild + ? utility?.get(interaction.guild.id)?.settings?.defaultServerLanguage + : null; + const userData = this.client.database.forceUser(interaction.user.id); + + // 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 i18n.t(key, lang, vars); + } else { + const lang = interactionOrLang || this.client.i18n.defaultLang; + return this.client.i18n.t(key, lang, vars); + } + } + + getLocalizationObject(_key) { + let key = `modules.${this.options.name}.${_key}`; + return this.client.i18n.getLocalizationObject(key); + } + + 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 { + 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}`); + } + }); + } + + /** + * 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, ...rest); + } + + /** + * 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.databases.length === 0) return null; + return this.client.database.get(this.options.name) || null; + } + + /** + * 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 new file mode 100644 index 0000000..c1df9ca --- /dev/null +++ b/core/ModuleManager.js @@ -0,0 +1,526 @@ +const fs = require('fs'); +const path = require('path'); +const Module = require('./Module.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('..')} client + */ + constructor(client) { + this.client = client; + /** @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); + } + + /** + * Discover, construct, init, and start every module on disk in correct + * topological order. Called once from index.js. + */ + 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}".`); + } + } + } + + // 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 (this._isEnabledPersisted(m.options.name)) + await m.start(this.client); + + 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 }); + } + } + + // Phase 5: wire Discord event listeners (one per event type). + this._installEventDispatchers(); + + this.logger.success(`Successfully loaded ${this._modules.size} module(s)`); + } + + /** + * 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._existsOnDisk(name)) return this._fail(ERR.NOT_FOUND, `${name} is not on disk.`); + + 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}`); + } + } + + 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 }); + } + + /** + * Stop, destroy, clear require.cache, then load the module fresh. Snapshots + * the prior enabled state so reload doesn't surprise-disable anything. + * @returns {Promise>} + */ + 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; + } + + /** + * Persist enabled = true and call start() if it wasn't already running. + */ + 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); + } + } + + /** + * Persist enabled = false and call stop() if it was running. + */ + 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} 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); + 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 + }); + } + + /** + * Snapshot of every module on disk. + * @returns {{ loaded: string[], available: string[], failed: { name: string, error: string }[] }} + */ + 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 }; + } + + /** + * @returns {[Command, Module] | [null, null]} + */ + 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]; + } + + /** 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; + } + + _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 }); + } + } + + _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.databases]; + 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); + } + + /** + * 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} + */ + _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); + } + + _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`)); + } + + _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 }); + } + } + }); + } + } + + /** + * 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. + */ + _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; + } + + /** + * 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; + await this.client.application.commands.set(this.getPublishableCommands().map(c => c.toJson())); + } catch (err) { + this.client.errorHandler?.capture(err, { source: 'ModuleManager._unregisterCommandsWithDiscord' }); + } + } + + _ok(value) { return { ok: true, value }; } + _fail(code, error) { return { ok: false, code, error }; } + + static get ERR() { return ERR; } +}; 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/structures/PowerLevels.js b/core/PowerLevels.js similarity index 100% rename from structures/PowerLevels.js rename to core/PowerLevels.js diff --git a/core/SettingsManager.js b/core/SettingsManager.js new file mode 100644 index 0000000..2ae7208 --- /dev/null +++ b/core/SettingsManager.js @@ -0,0 +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 { + /** + * @param {import('..')} client + * @param {import('./Module')} module + * @param {object} schema Map of `key -> { type, default, description?, validate? }`. + */ + constructor(client, module, schema = {}) { + this.client = client; + this.module = module; + 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); + } + + 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; + } + + 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'); + } + + /** + * 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); + if (cached) return cached; + + 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, record); + return record; + } + + /** 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]; + } + + /** + * Validate a value against a key's declared type. Returns coerced value or + * throws with an error message. + */ + _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; + } + + /** + * 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. + */ + _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.`); + } + + /** + * 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] + */ + 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; + } + + /** + * Append `value` to an array-typed key. Errors if the key isn't an array + * type. Duplicates are allowed unless caller filters. + */ + 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; + } +}; diff --git a/structures/Utils.js b/core/Utils.js similarity index 100% rename from structures/Utils.js rename to core/Utils.js diff --git a/index.js b/index.js index c72a54d..37d9d54 100644 --- a/index.js +++ b/index.js @@ -1,12 +1,16 @@ // Imports require('dotenv').config(); +require("module-alias/register"); 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('./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'); +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 @@ -17,8 +21,22 @@ class BotClient extends Client { owners: ["311929179186790400", "422418878459674624"], systemServer: ["1313550337474429001"], intents: Object.keys(GatewayIntentBits).filter(i => isNaN(i)), - partials: ['Reaction', 'Message'] + partials: ['Reaction', 'Message'], + verbose: false, + errorReporting: { + channelId: null, + notifyOwners: false, + dedupWindowMs: 60000, + exitOnUncaught: true + }, + i18n: { + defaultLang: 'en-GB', + referenceLanguage: 'en-GB', + autoSync: true, + hotReload: false + } }); + Logger.verboseEnabled = !!config.get('verbose'); super({ intents: config.get('intents').map(i => GatewayIntentBits[i]), partials: config.get('partials').map(i => Partials[i]) @@ -27,18 +45,21 @@ 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(); + this.i18n = new LocalizationManager(config.get('i18n')); + this.errorHandler = new ErrorHandler(this, config.get('errorReporting')); this.database = new Database(this); - this.moduleManager.init(); + this.permissions = new PermissionsManager(this); } }; -const client = new BotClient(); - -client.login(process.env.TOKEN); +(async () => { + const client = new BotClient(); + await client.modules.init(); + await client.login(process.env.TOKEN); +})(); module.exports = BotClient; 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/Example/Example.js b/modules/Example/Example.js new file mode 100644 index 0000000..cf84a46 --- /dev/null +++ b/modules/Example/Example.js @@ -0,0 +1,31 @@ +const Module = require("@core/Module.js"); +const ImportantFile = require("./lib/importantFile.js"); + +module.exports = class Example extends Module { + constructor(client) { + super(client, { + name: "Example", + info: "Very important module", + events: ["clientReady"], + config: { + exampleString: 'Hello, world!', + exampleNumber: 67, + exampleArray: [1, 2, 3], + exampleBoolean: true + } + }) + } + + /** + * @param {import('../../index.js')} 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')}`); + 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/commands/example.js b/modules/Example/commands/example.js new file mode 100644 index 0000000..09c0c45 --- /dev/null +++ b/modules/Example/commands/example.js @@ -0,0 +1,21 @@ +const Command = require('@core/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/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/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.js b/modules/InteractionCommandHandler.js deleted file mode 100644 index 844f47a..0000000 --- a/modules/InteractionCommandHandler.js +++ /dev/null @@ -1,55 +0,0 @@ -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'); - -module.exports = class InteractionCommandHandler extends Module { - constructor(client) { - super(client, { - name: "InteractionCommandHandler", - info: "Adds interaction commands support.", - enabled: true, - events: ["ready", "interactionCreate"], - priority: ModulePriorities.HIGH - }); - } - - /** - * @param {BotClient} client - */ - async ready(client) { - client.application.commands - .set(client.moduleManager.commands.filter(c => c.module.options.name !== "System").map(c => c.toJson())) - } - - /** - * @param {BotClient} client - * @param {Discord.Interaction} interaction - * @param {Module} module - */ - async interactionCreate(client, interaction, module) { - if (!interaction.isCommand() && !interaction.isContextMenuCommand()) return; - interaction.user.data = await client.database.forceUser(interaction.user.id); - - 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] }); - - 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] }); - - await cmd.run(client, interaction, module); - } catch (e) { - interaction.reply({ - 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 }; - } -} diff --git a/modules/InteractionCommandHandler/InteractionCommandHandler.js b/modules/InteractionCommandHandler/InteractionCommandHandler.js new file mode 100644 index 0000000..af428ec --- /dev/null +++ b/modules/InteractionCommandHandler/InteractionCommandHandler.js @@ -0,0 +1,111 @@ +const Discord = require('discord.js'); +const Module = require("@core/Module.js"); +const Command = require("@core/Command.js"); +const PowerLevels = require('@core/PowerLevels.js'); + +module.exports = class InteractionCommandHandler extends Module { + constructor(client) { + super(client, { + name: "InteractionCommandHandler", + info: "Adds interaction commands support.", + events: ["clientReady", "interactionCreate"] + }); + } + + /** + * @param {import('../../index.js')} client + */ + async clientReady(client) { + try { + await client.application.commands + .set(client.modules.getPublishableCommands().map(c => c.toJson())); + } catch (err) { + client.errorHandler?.capture(err, { source: 'commandRegistration', module: this.options.name }); + } + } + + /** + * @param {import('../../index.js')} client + * @param {Discord.Interaction} interaction + * @param {import('@core/ModuleManager.js').EventContext} ctx + */ + async interactionCreate(client, interaction, ctx) { + if (!interaction.isCommand() && !interaction.isContextMenuCommand()) return; + interaction.user.data = await client.database.forceUser(interaction.user.id); + + let cmd, cmdModule; + try { + [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'); + } + + if (interaction.user.data.powerlevel < cmd.config.minLevel) { + await this._safeReply(interaction, { + content: this.t('errors.insufficient-powerlevel', interaction, { + level: Object.keys(PowerLevels).find(k => PowerLevels[k] == cmd.config.minLevel) + }), + flags: [Discord.MessageFlags.Ephemeral] + }); + return ctx?.stopPropagation('insufficient powerlevel'); + } + + // 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: this.t('errors.guild-override-denied', interaction), + flags: [Discord.MessageFlags.Ephemeral] + }); + 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, + command: cmd?.config?.name || interaction.commandName, + userId: interaction.user?.id, + guildId: interaction.guildId || undefined + }); + await this._safeReply(interaction, { + content: this.t('errors.execution-error', interaction), + flags: [Discord.MessageFlags.Ephemeral] + }); + } + + ctx?.stopPropagation('command handled'); + } + + /** + * Best-effort interaction reply that won't throw if the interaction was + * already replied/deferred or has expired. + */ + 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..2d7bf4c --- /dev/null +++ b/modules/InteractionCommandHandler/locales/en-GB.yaml @@ -0,0 +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 **{{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 new file mode 100644 index 0000000..2e15050 --- /dev/null +++ b/modules/InteractionCommandHandler/locales/it.yaml @@ -0,0 +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." + guild-override-denied: errors.guild-override-denied + guild-only: errors.guild-only diff --git a/modules/ReadyLog.js b/modules/ReadyLog/ReadyLog.js similarity index 56% rename from modules/ReadyLog.js rename to modules/ReadyLog/ReadyLog.js index ed43bc4..cbe0d2c 100644 --- a/modules/ReadyLog.js +++ b/modules/ReadyLog/ReadyLog.js @@ -1,20 +1,19 @@ -const Module = require("../structures/Module.js"); +const Module = require("@core/Module.js"); module.exports = class ReadyLog extends Module { constructor(client) { super(client, { name: "ReadyLog", info: "Logs informations once ready and sets the custom status", - enabled: true, - events: ["ready"] + events: ["clientReady"] }) } /** - * @param {import('../index.js')} client + * @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}`); @@ -24,17 +23,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.js b/modules/System.js deleted file mode 100644 index 77fbeae..0000000 --- a/modules/System.js +++ /dev/null @@ -1,83 +0,0 @@ -const Module = require("../structures/Module.js"); -const Discord = require('discord.js'); -const fs = require('fs'); -const ModulePriorities = require("../structures/ModulePriorities.js"); - -module.exports = class System extends Module { - constructor(client) { - super(client, { - name: "System", - info: "Loads the system commands", - enabled: true, - events: ["ready", "interactionCreate"], - priority: ModulePriorities.HIGHEST - }) - } - - /** - * @param {import('../index.js')} client - */ - async ready(client) { - let serverIds = this.client.config.get('systemServer'); - - if (!serverIds) { - this.logger.error(`System servers not found in config.yml!`); - return; - } - - for (const serverId of serverIds) { - try { - let systemGuild = await this.client.guilds.fetch(serverId); - - if (!systemGuild) { - this.logger.error(`System server not found: ${serverId}. Set it on config.yml!`); - } - - 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; - } - - /** - * @param {import('../index.js')} client - * @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); - } - } - - // 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")) : []; - - 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 = new command(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/System.js b/modules/System/System.js new file mode 100644 index 0000000..5e7fff8 --- /dev/null +++ b/modules/System/System.js @@ -0,0 +1,102 @@ +const Module = require("@core/Module.js"); +const Discord = require('discord.js'); +const fs = require('fs'); +const ModmanUI = require('./lib/ModmanUI.js'); + +module.exports = class System extends Module { + constructor(client) { + super(client, { + name: "System", + info: "Loads the system commands", + events: ["clientReady", "interactionCreate"] + }); + + this.modmanUI = new ModmanUI(this); + } + + /** + * 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 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; + } + + for (const serverId of serverIds) { + try { + 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}`); + } + } + + // If the bot got rebooted with reboot command, this will edit the message once ready + try { + const { id, channel } = require("./../reboot.json"); + const c = client.channels.cache.get(channel); + await c.messages.fetch(); + 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) { + // pass + } + } + + /** + * @param {import('../../index.js')} client + * @param {Discord.Interaction} interaction + */ + async interactionCreate(client, interaction) { + // Modman GUI component / modal interactions. + if ((interaction.isMessageComponent?.() || interaction.isModalSubmit?.()) && + interaction.customId?.startsWith('modman:')) { + return this.modmanUI.handle(interaction); + } + } + + // 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 dir = `./modules/${this.options.name}/commands`; + const files = fs.existsSync(dir) ? fs.readdirSync(dir).filter(file => file.endsWith(".js")) : []; + + for (const file of files) { + try { + 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/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..8aab36e 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("@core/Command.js"); const { inspect } = require("util"); const { EmbedBuilder, ApplicationCommandOptionType } = require("discord.js"); -const PowerLevels = require("../../structures/PowerLevels.js"); +const PowerLevels = require("@core/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..f98541c 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("@core/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("@core/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/commands/modman.js b/modules/System/commands/modman.js new file mode 100644 index 0000000..3590826 --- /dev/null +++ b/modules/System/commands/modman.js @@ -0,0 +1,26 @@ +const Command = require('@core/Command.js'); +const PowerLevels = require('@core/PowerLevels.js'); + +/** + * 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', + cooldown: 3, + minLevel: PowerLevels.OWNER + }); + } + + /** + * @param {import('../../../index.js')} client + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ + async run(client, interaction) { + await this.module.modmanUI.open(interaction); + } +}; 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..dce4103 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('@core/Command.js'); +const PowerLevels = require("@core/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..098bffe 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('@core/Command.js'); const { ApplicationCommandOptionType, EmbedBuilder, userMention, User, UserContextMenuCommandInteraction } = require('discord.js'); -const PowerLevels = require('../../structures/PowerLevels.js'); +const PowerLevels = require('@core/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 83% rename from modules/System/update.js rename to modules/System/commands/update.js index 6d6fd1c..5fea8d8 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("@core/Command.js"); +const PowerLevels = require("@core/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) { @@ -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 new file mode 100644 index 0000000..ff37baa --- /dev/null +++ b/modules/System/lib/ModmanUI.js @@ -0,0 +1,268 @@ +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(interaction) { + 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}\``); + 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.modules; + 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.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); + } + return this._t(mm.isEnabled(name) ? 'status.enabled' : 'status.disabled', interaction); + } + + _detail(interaction, name) { + 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; + + 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 }; + } + + async _action(interaction, name, kind) { + const mm = this.client.modules; + 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.modules; + 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.modules; + 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.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)); + } + + _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 new file mode 100644 index 0000000..cc0de26 --- /dev/null +++ b/modules/System/locales/en-GB.yaml @@ -0,0 +1,92 @@ +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 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: + 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. + 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..4fb5066 --- /dev/null +++ b/modules/System/locales/it.yaml @@ -0,0 +1,130 @@ +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 + 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. + 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/System/modman.js b/modules/System/modman.js deleted file mode 100644 index f6fd259..0000000 --- a/modules/System/modman.js +++ /dev/null @@ -1,210 +0,0 @@ -const Command = require('../../structures/Command.js'); -let { EmbedBuilder, ApplicationCommandOptionType} = require('discord.js'); -const PowerLevels = require("../../structures/PowerLevels.js"); - -module.exports = class ModManCommand extends Command { - constructor(client, module) { - super(client, module, { - 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, - } - ] - }); - } - - /** - * - * @param {import('../../index.js')} client - * @param {import('discord.js').ChatInputCommandInteraction} interaction - * @param {import('../../structures/Module.js')} module - */ - 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; - } - } -} diff --git a/modules/Utility.js b/modules/Utility.js deleted file mode 100644 index 045ec01..0000000 --- a/modules/Utility.js +++ /dev/null @@ -1,54 +0,0 @@ -const Module = require("../structures/Module.js"); -const ModulePriorities = require("../structures/ModulePriorities.js"); - -module.exports = class Utility extends Module { - constructor(client) { - super(client, { - name: "Utility", - info: "Loads the utility commands", - enabled: true, - events: ["interactionCreate"], - settings: { - defaultServerLanguage: "" - } - }) - } - - /** - * - * @param {import('../index.js')} client - * @param {import('discord.js').Interaction} interaction - */ - async interactionCreate(client, interaction) { - if (!interaction.isAutocomplete()) return; - const command = this.commands.get(interaction.commandName); - 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 }))); - } - 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 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 }))) - } - } - - console.log(interaction); - } - } -} diff --git a/modules/Utility/Utility.js b/modules/Utility/Utility.js new file mode 100644 index 0000000..d1fc1a6 --- /dev/null +++ b/modules/Utility/Utility.js @@ -0,0 +1,47 @@ +const Module = require("@core/Module.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", + events: ["interactionCreate"], + settings: { + defaultServerLanguage: { + type: 'string', + default: '', + description: 'Fallback language code for users without a personal preference (e.g., en-GB, it).' + } + } + }); + + this.permissionsUI = new PermissionsUI(this); + this.settingsUI = new SettingsUI(this); + } + + /** + * @param {import('../../index.js')} client + * @param {import('discord.js').Interaction} interaction + */ + async interactionCreate(client, 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; + const command = this.commands.get(interaction.commandName); + if (!command) return; + + if (interaction.commandName == "setlang") { + return interaction.respond(Object.keys(client.i18n.languages || {}).concat(['default']).map(lang => ({ + name: lang === 'default' ? 'Default' : client.i18n.languageName(lang), + value: lang + }))); + } + } +} 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/perms.js b/modules/Utility/commands/perms.js similarity index 88% rename from modules/Utility/perms.js rename to modules/Utility/commands/perms.js index d6424b6..9633a1e 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('@core/Command.js'); +const PowerLevels = require('@core/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) { @@ -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/ping.js b/modules/Utility/commands/ping.js similarity index 53% rename from modules/Utility/ping.js rename to modules/Utility/commands/ping.js index 7a81281..5644586 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('@core/Command.js'); module.exports = class PingCommand extends Command { constructor(client, module) { @@ -11,11 +11,12 @@ 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) { - 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/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..ebf0632 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('@core/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/commands/settings.js b/modules/Utility/commands/settings.js new file mode 100644 index 0000000..04b6750 --- /dev/null +++ b/modules/Utility/commands/settings.js @@ -0,0 +1,27 @@ +const Command = require('@core/Command.js'); +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 or edit per-guild settings.', + defaultMemberPermissions: [PermissionsBitField.Flags.ManageGuild], + guildOnly: true + }); + } + + /** + * @param {import('../../../index.js')} client + * @param {import('discord.js').ChatInputCommandInteraction} interaction + */ + async run(client, interaction) { + await this.module.settingsUI.open(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..b8b77c9 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('@core/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/lib/PermissionsUI.js b/modules/Utility/lib/PermissionsUI.js new file mode 100644 index 0000000..290f9d4 --- /dev/null +++ b/modules/Utility/lib/PermissionsUI.js @@ -0,0 +1,543 @@ +const { + EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, + StringSelectMenuBuilder, RoleSelectMenuBuilder, UserSelectMenuBuilder, + ModalBuilder, TextInputBuilder, TextInputStyle, + MessageFlags +} = require('discord.js'); + +/** + * Interactive in-Discord GUI for managing per-guild permission levels. + * Custom-id convention: `perms:[:...]`. State that needs to + * survive across interactions is encoded into the customId of the next + * 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 { + /** + * @param {import('../Utility.js')} utilityModule + */ + constructor(utilityModule) { + this.module = utilityModule; + this.client = utilityModule.client; + } + + /** 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), flags: MessageFlags.Ephemeral }); + } + + /** + * @param {import('discord.js').Interaction} interaction + */ + async handle(interaction) { + const id = interaction.customId; + if (!id?.startsWith('perms:')) return false; + 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)); + 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); + 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(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 ? defaultTag : ''} — ${l.roles.length} role(s)`) + .join('\n') || this._t('home.no-levels', interaction); + + const embed = new EmbedBuilder() + .setTitle(this._t('home.title', interaction)) + .setDescription(this._t('home.description', interaction)) + .addFields( + { 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: this._t('home.footer', interaction) }); + + const nav = new StringSelectMenuBuilder() + .setCustomId('perms:nav') + .setPlaceholder(this._t('home.nav-placeholder', interaction)) + .addOptions( + { 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(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] }; + } + + async _nav(interaction, target) { + const value = interaction.values?.[0] || target; + switch (value) { + 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(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(this._t('levels.title', interaction)) + .setDescription(this._t('levels.description', interaction)) + .addFields({ + name: this._t('levels.current-ladder', interaction), + value: ladder.map(l => + `**${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(this._t('levels.edit-placeholder', interaction)) + .addOptions(ladder.slice(0, 25).map(l => ({ + label: `${l.name} (${l.id})`, + 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(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 }; + } + + 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, 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(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, this._t('levels.weight-not-int-error', interaction)); + this.client.permissions.setLevel(guildId, { id: levelId, name, weight }); + 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, this._t('levels.none-to-delete', interaction)); + + const embed = new EmbedBuilder() + .setTitle(this._t('levels.delete-title', interaction)) + .setDescription(this._t('levels.delete-description', interaction)); + const select = new StringSelectMenuBuilder() + .setCustomId('perms:level:delete_confirm') + .setPlaceholder(this._t('levels.delete-placeholder', interaction)) + .addOptions(candidates.slice(0, 25).map(l => ({ + label: `${l.name} (${l.id})`, + 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(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(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, 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 + ? 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(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(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(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(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(this._t('roles.title', interaction)); + const intro = this._t('roles.intro', interaction); + + if (focused) { + embed.setDescription( + intro + '\n\n' + + 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: this._t('roles.all-levels', interaction), + value: ladder.map(l => + `\`${l.id}\` — ${l.roles.length ? l.roles.map(r => `<@&${r}>`).join(', ') : none}` + ).join('\n') || this._t('home.no-levels', interaction) + }); + } + + const components = []; + + if (focused) { + components.push(new ActionRowBuilder().addComponents( + new RoleSelectMenuBuilder() + .setCustomId(`perms:role:bind:${focused.id}`) + .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(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 }; + })); + components.push(new ActionRowBuilder().addComponents(unbind)); + } + } + + const levelPick = new StringSelectMenuBuilder() + .setCustomId('perms:role:level_pick') + .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: 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(this._t('buttons.back', interaction)).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(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(interaction, levelId)); + } + if (action === 'unbind') { + const roleId = interaction.values[0]; + this.client.permissions.unbindRole(guildId, levelId, roleId); + return this._update(interaction, this._rolesScreen(interaction, levelId)); + } + } + + _usersScreen(interaction, focusedUserId = null) { + const guildId = interaction.guild.id; + const cfg = this.client.permissions.getConfig(guildId); + const entries = Object.entries(cfg.userOverrides); + + const embed = new EmbedBuilder() + .setTitle(this._t('users.title', interaction)) + .setDescription(this._t('users.description', interaction)); + + embed.addFields({ + name: this._t('users.active-overrides', interaction), + value: entries.length + ? entries.map(([uid, lid]) => `<@${uid}> → \`${lid}\``).join('\n') + : this._t('users.none', interaction) + }); + + 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 = []; + + components.push(new ActionRowBuilder().addComponents( + new UserSelectMenuBuilder() + .setCustomId('perms:user:pick') + .setPlaceholder(this._t('users.pick-user-placeholder', interaction)) + .setMinValues(1).setMaxValues(1) + )); + + if (focusedUserId) { + const ladder = [...cfg.levels].sort((a, b) => a.weight - b.weight); + const opts = [ + { 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: this._t('levels.weight-fmt', interaction, { weight: l.weight }), + value: l.id + })) + ]; + 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(this._t('buttons.back', interaction)).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(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(interaction)); + } + } + + _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(this._t('cmds.title', interaction)) + .setDescription(this._t('cmds.description', interaction)) + .addFields({ + name: this._t('cmds.active-overrides', interaction), + value: entries.length + ? entries.map(([c, l]) => `\`/${c}\` → \`${l}\``).join('\n') + : this._t('cmds.none', interaction) + }); + + const components = []; + const buttons = [ + 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(this._t('cmds.clear-placeholder', interaction)) + .addOptions(entries.slice(0, 25).map(([c, l]) => ({ + 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(this._t('buttons.back', interaction)).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(this._t('cmds.modal-title', interaction)); + modal.addComponents( + new ActionRowBuilder().addComponents( + 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(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); + } + 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(interaction)); + } + if (action === 'clear_pick') { + const cmd = interaction.values[0]; + this.client.permissions.setCommandOverride(guildId, cmd, null); + return this._update(interaction, this._cmdsScreen(interaction)); + } + } + + _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(this._t('sets.title', interaction)) + .setDescription(this._t('sets.description', interaction)) + .addFields({ + name: this._t('sets.active-overrides', interaction), + value: entries.length + ? entries.map(([k, l]) => `\`${k}\` → \`${l}\``).join('\n') + : this._t('sets.none', interaction) + }); + + const components = []; + const buttons = [ + 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(this._t('sets.clear-placeholder', interaction)) + .addOptions(entries.slice(0, 25).map(([k, l]) => ({ + 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(this._t('buttons.back', interaction)).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(this._t('sets.modal-title', interaction)); + modal.addComponents( + new ActionRowBuilder().addComponents( + 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(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); + } + 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(interaction)); + } + if (action === 'clear_pick') { + const key = interaction.values[0]; + this.client.permissions.setSettingOverride(guildId, key, null); + return this._update(interaction, this._setsScreen(interaction)); + } + } + + 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/lib/SettingsUI.js b/modules/Utility/lib/SettingsUI.js new file mode 100644 index 0000000..def3021 --- /dev/null +++ b/modules/Utility/lib/SettingsUI.js @@ -0,0 +1,391 @@ +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(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 }; + } + + _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 }; + } + + _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; + } + + 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)); + } + + _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 new file mode 100644 index 0000000..5c39725 --- /dev/null +++ b/modules/Utility/locales/en-GB.yaml @@ -0,0 +1,238 @@ +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: 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. + 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..838aff3 --- /dev/null +++ b/modules/Utility/locales/it.yaml @@ -0,0 +1,252 @@ +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 + description: commands.settings.embeds.set.description + add: + title: Impostazione Aggiunta + description: commands.settings.embeds.add.description + remove: + title: Impostazione Rimossa + description: commands.settings.embeds.remove.description + 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. + 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. + 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!" + 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 diff --git a/modules/Utility/settings.js b/modules/Utility/settings.js deleted file mode 100644 index 5a137e9..0000000 --- a/modules/Utility/settings.js +++ /dev/null @@ -1,203 +0,0 @@ -const Command = require('../../structures/Command.js'); -const { ApplicationCommandOptionType, EmbedBuilder, userMention, User, UserContextMenuCommandInteraction, PermissionsBitField } = require('discord.js'); -const { Pagination } = require('pagination.djs'); -const Module = require('../../structures/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.', - 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: "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: "add", - description: "Add a value to a 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: "remove", - description: "Remove a value from a 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: "reset", - description: "Retets the value of a key to it's 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 - }, - ] - }, - ] - }); - } - - /** - * - * @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") })); - - 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 = []; - - let settings = client.settings.map((v, k) => { return { module: k, settings: v.get(guild.id).settings } }); - settings.forEach(async s => { - 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) { - 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); - } - 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; - } - } - - } -} diff --git a/package-lock.json b/package-lock.json index 502fd20..960c069 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,33 +7,34 @@ "": { "name": "modulardiscordbot", "version": "2.0", - "license": "ISC", + "license": "GNU", "dependencies": { "chalk": "^4.1.2", "common-tags": "^1.8.2", - "discord.js": "^14.18.0", - "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.10.1", - "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.10.1.tgz", - "integrity": "sha512-OWo1fY4ztL1/M/DUyRPShB4d/EzVfuUvPTRRHRIt/YxBrUYSz0a+JicD5F5zHFoNs2oTuWavxCOVFV1UljHTng==", + "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.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.40", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" @@ -55,12 +56,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,20 +71,20 @@ } }, "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.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.37.119", - "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.1" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -104,11 +105,24 @@ "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.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 +131,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 +199,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 +285,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.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.18.0", - "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.18.0.tgz", - "integrity": "sha512-SvU5kVUvwunQhN2/+0t55QW/1EHfB1lp0TtLZUSXVHDmyHTrdOj5LRKdR0zLcybaA15F+NtdWuWmGOX9lE+CAw==", + "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.10.1", + "@discordjs/builders": "^1.14.1", "@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.1", + "@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.40", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", + "magic-bytes.js": "^1.13.0", "tslib": "^2.6.3", - "undici": "6.21.1" + "undici": "6.24.1" }, "engines": { "node": ">=18" @@ -303,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" @@ -336,9 +354,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.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": { @@ -354,9 +372,15 @@ "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/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": { @@ -375,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": { @@ -408,24 +432,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.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" } }, "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" @@ -444,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 98d5889..cc1a567 100644 --- a/package.json +++ b/package.json @@ -13,18 +13,23 @@ "bot", "discord" ], - "author": "Miky88", - "license": "ISC", + "author": "Miky88, Just1diaxx, GalaxyVinci05", + "license": "GNU", "dependencies": { "chalk": "^4.1.2", "common-tags": "^1.8.2", - "discord.js": "^14.18.0", - "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", + "@modules": "modules" } } diff --git a/structures/Database.js b/structures/Database.js deleted file mode 100644 index 241575f..0000000 --- a/structures/Database.js +++ /dev/null @@ -1,132 +0,0 @@ -const Loki = require('lokijs'); -const BotClient = require('..'); -const Logger = require('./Logger.js'); -const PowerLevels = require('./PowerLevels.js'); -const cache = new Map(); - -module.exports = class Database { - /** - * The bot's main database - * @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'); - - // console.log(this.db.listCollections()) - this.logger.verbose('Database loaded') - } - - reconfigure() { - this.db.configureOptions({ - autoload: true, - autosave: true, - autoloadCallback: () => this.collections.forEach((collection) => this.db[collection] = this.db.addCollection(collection)), - autosaveInterval: 1000, - }) - } - - /** - * @param {String} userID - */ - addUser(userID) { - const user = this.db.users.insert({ - id: userID, - powerlevel: PowerLevels.USER, - language: null - }) - this.cacheUser(user) - return user; - } - - /** - * @param {import('discord.js').User} user - */ - cacheUser(user) { - cache.set(user.id, user) - } - - /** - * @param {String} userID - */ - getUser(userID) { - const data = cache.get(userID) || this.db.users.findOne({ id: userID }) - if (data) this.cacheUser(data) - return data - } - - /** - * @param {String} userID - */ - forceUser(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) - } - return user; - } - return this.addUser(userID) - } - - /** - * @param {*} data - */ - async updateUser(data) { - delete data.user - this.db.users.update(data) - - const update = await this.db.users.findOne({ id: data.id }) - this.cacheUser(update) - return update - } - - /** - * @param {import('lokijs').Collection} collection - */ - fixCollection (collection) { - this.logger.debug('Fixing collection', collection.name); - const deduplicateSet = new Set(); - const data = collection.data - .sort((a, b) => a.meta.created - b.meta.created) - .filter((x) => { - const duplicated = deduplicateSet.has(x.$loki); - deduplicateSet.add(x.$loki); - - 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; - } - - collection.data = data; - collection.idIndex = index; - collection.maxId = collection.data?.length - ? Math.max(...collection.data.map((x) => x.$loki)) - : 0; - collection.dirty = true; - collection.checkAllIndexes({ - randomSampling: true, - repair: true, - }); - this.logger.success('Done!'); - } -} diff --git a/structures/LocalizationManager.js b/structures/LocalizationManager.js deleted file mode 100644 index 45d2867..0000000 --- a/structures/LocalizationManager.js +++ /dev/null @@ -1,112 +0,0 @@ -const { parse } = require('yaml') -const fs = require("fs"); -const path = require("path"); -const Logger = require('./Logger'); - -module.exports = class LocalizationManager { - /** - * @param {{ defaultLang?: string, localesDir?: string }} options - */ - constructor(options = {}) { - this.defaultLang = options.defaultLang || "en-GB"; - this.localesDir = options.localesDir || path.join(__dirname, "..", "locales"); - this.languages = {}; - this.logger = new Logger('i18n'); - - this.loadLocales(); - } - - loadLocales() { - if (!fs.existsSync(this.localesDir)) { - this.logger.warn(`Locales directory not found at ${this.localesDir}`); - 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") - ); - // 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. - */ - _getKey(obj, key) { - 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, "\\$&"); - 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 - 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 - */ - t(key, lang, maybeVars) { - let langCode = lang || this.defaultLang; - let vars = maybeVars || {}; - - 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); - } - - // last fallback: show the key - if (value === undefined) { - this.logger.warn(`Missing localization for key "${key}" in language "${langCode}"`); - value = key; - } - - if (typeof value === "string") { - return this._interpolate(value, vars); - } - - // 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); - } - } - - return String(value); - } - - 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; - } - } - return localizationObj; - } -} \ No newline at end of file diff --git a/structures/Module.js b/structures/Module.js deleted file mode 100644 index 4eb02b5..0000000 --- a/structures/Module.js +++ /dev/null @@ -1,125 +0,0 @@ -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'); -const SettingsManager = require('./SettingsManager'); -const Logger = require('./Logger'); -const Database = require('./Database') - -module.exports = class Module { - /** - * @param {BotClient} client - * @param {} options - * @param {string[]} [options.events] - */ - constructor(client, { - name = this.constructor.name, - info = "No description provided.", - enabled = false, - events = [], - usesDB = false, - priority = ModulePriorities.NORMAL, - dependencies = [], - config = null, - settings = null - }) { - this.client = client; - this.options = { name, info, enabled, events, priority, usesDB, dependencies, settings}; - - this.commands = new Collection(); - this.logger = new Logger(this.options.name); - - if(config) - this.config = new ConfigurationManager(this, config); - if(settings) - this.settings = new SettingsManager(client, this, settings); - } - - 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; - - 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 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; - - return this.client.i18n.t(key, lang, vars); - } else { - const lang = interactionOrLang || this.client.i18n.defaultLang; - return this.client.i18n.t(key, lang, vars); - } - - } - - getLocalizationObject(_key) { - let key = `modules.${this.options.name}.${_key}`; - return this.client.i18n.getLocalizationObject(key); - } - - async loadCommands() { - const commands = fs.existsSync(`./modules/${this.options.name}`) ? fs.readdirSync(`./modules/${this.options.name}`).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 = 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 - 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); - } - - /** - * @type {LokiCollection} - */ - get db() { - if (!this.options.usesDB) - return null; - - return this.client.database.db[`module_${this.options.name}`]; - } - - saveData(data) { - if (!this.db) - throw new Error("You must use usesDB: true 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); - } -} diff --git a/structures/ModuleManager.js b/structures/ModuleManager.js deleted file mode 100644 index cbb1188..0000000 --- a/structures/ModuleManager.js +++ /dev/null @@ -1,175 +0,0 @@ -const fs = require('fs'); -const Module = require('./Module.js'); -const Command = require('./Command.js'); -const Logger = require('./Logger.js'); -const SettingsManager = require('./SettingsManager.js'); - -module.exports = class ModuleManager { - /** - * @param {import('..')} client - */ - constructor(client) { - this.client = client; - /** @type {Map} */ - this.modules = new Map(); - - this.events = new Set(); - this.logger = new Logger(this.constructor.name); - } - - 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) - } - }); - this.client.database.reconfigure(); - this.logger.success(`Successfully Loaded ${this.modules.size} modules`) - } - - /** - * @param {String} moduleName - */ - load(moduleName) { - try { - const module = require(`../modules/${moduleName}`); - delete require.cache[require.resolve(`../modules/${moduleName}`)]; - 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}`); - } - } - } - 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) { - _module.settings = new SettingsManager(this.client, _module, _module.options.settings); - this.client.database.collections.push(`settings_${_module.options.name}`); - } - - this.add(_module) - } catch (error) { - this.logger.error("Unable to load " + moduleName + ": " + error) - return { error } - } - return {} - } - - /** - * @param {Module} module - */ - 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; - - execution = await module.run(this.client, event, ...args); - - if (execution?.cancelEvent) - break; - } - } - } - module.options.events.forEach(evt => { - if (!this.events.has(evt)) { - const event = evt - this.events.add(event); - this.client.on(event, eventCallback(event)) - } - }) - } - - /** - * @param {String} pluginName - */ - reload(pluginName) { - return this.unload(pluginName) && this.load(pluginName) - } - - /** - * @param {String} pluginName - */ - unload(pluginName) { - this.logger.log(`${pluginName} unloaded`); - return this.modules.delete(pluginName); - } - - /** - * @param {String} pluginName - */ - enable(pluginName) { - if (!this.modules.get(pluginName)) return false - this.logger.log(`${pluginName} enabled`); - return this.modules.get(pluginName).options.enabled = true; - } - - /** - * @param {String} pluginName - */ - disable(pluginName) { - if (!this.modules.get(pluginName)) return false - this.logger.log(`${pluginName} disabled`); - return !(this.modules.get(pluginName).options.enabled = false); - } - - /** - * @param {String} pluginName - */ - isLoaded(pluginName) { - return !!this.modules.get(pluginName); - } - - /** - * @param {String} pluginName - */ - 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 - } - } - - 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") - } - } - - /** - * @param {String} cmd - * @returns {[Command, Module] | [null, null]} - */ - 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]; - } - - /** - * @type {Command[]} - */ - get commands() { - return [...this.modules.values()].reduce((commands, module) => [...commands, ...module.commands.values()], []); - } -} diff --git a/structures/ModulePriorities.js b/structures/ModulePriorities.js deleted file mode 100644 index 8f37a2e..0000000 --- a/structures/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/structures/SettingsManager.js b/structures/SettingsManager.js deleted file mode 100644 index bfb53aa..0000000 --- a/structures/SettingsManager.js +++ /dev/null @@ -1,106 +0,0 @@ -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('./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); - } - - /** - * 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); - } - - /** - * @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(guildID) { - 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 }); - - 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 - */ - set(guildID, key, value) { - const guildData = this.get(guildID); - guildData.settings[key] = value; - this.client.database.db[this.dbName].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."); - guildData.settings[key].push(value); - this.client.database.db[this.dbName].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} 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."); - guildData.settings[key] = guildData.settings[key].filter(item => item !== value); - this.client.database.db[this.dbName].update(guildData); - this._cache.set(guildID, guildData); - } - - /** - * 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 - */ - reset(guildID, key) { - const guildData = this.get(guildID); - guildData.settings[key] = this.defaultSettings[key]; - this.client.database.db[this.dbName].update(guildData); - this._cache.set(guildID, guildData); - } - - /** - * Resets the settings of a guild to its default values - * @param {String} guildID The guild ID to reset the settings for - */ - factoryReset(guildID) { - const guildData = this.get(guildID); - guildData.settings = this.defaultSettings; - this.client.database.db[this.dbName].update(guildData); - this._cache.set(guildID, guildData); - } -} \ No newline at end of file