From be8288ab5e583eea84eddb433206114f45767646 Mon Sep 17 00:00:00 2001 From: Puechberty Arthur Date: Sun, 12 Apr 2026 02:34:53 +0200 Subject: [PATCH] feat: implement utility commands and command framework - Add advanced command for complex argument handling. - Introduce ping command for latency checking. - Create argument parser for prefix and slash commands. - Define command structure and registry for command management. - Implement command deployment for Discord application commands. - Add internationalization support for commands. - Create handlers for prefix and slash command execution. - Establish command execution context and permissions handling. - Set up environment configuration with dotenv and zod. - Add TypeScript configuration for strict type checking. --- .env.example | 6 + .gitignore | 10 + README.md | 53 ++ locales/en.json | 100 +++ locales/es.json | 100 +++ locales/fr.json | 100 +++ package-lock.json | 875 +++++++++++++++++++++ package.json | 25 + src/commands/core/help.ts | 154 ++++ src/commands/fun/kiss.ts | 36 + src/commands/index.ts | 8 + src/commands/utility/advanced.ts | 106 +++ src/commands/utility/ping.ts | 23 + src/framework/commands/argParser.ts | 178 +++++ src/framework/commands/defineCommand.ts | 20 + src/framework/commands/deploy.ts | 31 + src/framework/commands/registry.ts | 76 ++ src/framework/commands/slashBuilder.ts | 193 +++++ src/framework/commands/usage.ts | 44 ++ src/framework/config/env.ts | 21 + src/framework/execution/CommandExecutor.ts | 76 ++ src/framework/handlers/prefixHandler.ts | 94 +++ src/framework/handlers/slashHandler.ts | 84 ++ src/framework/i18n/I18nService.ts | 138 ++++ src/framework/types/command.ts | 110 +++ src/index.ts | 72 ++ src/scripts/deployCommands.ts | 25 + tsconfig.json | 17 + 28 files changed, 2775 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 locales/en.json create mode 100644 locales/es.json create mode 100644 locales/fr.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/commands/core/help.ts create mode 100644 src/commands/fun/kiss.ts create mode 100644 src/commands/index.ts create mode 100644 src/commands/utility/advanced.ts create mode 100644 src/commands/utility/ping.ts create mode 100644 src/framework/commands/argParser.ts create mode 100644 src/framework/commands/defineCommand.ts create mode 100644 src/framework/commands/deploy.ts create mode 100644 src/framework/commands/registry.ts create mode 100644 src/framework/commands/slashBuilder.ts create mode 100644 src/framework/commands/usage.ts create mode 100644 src/framework/config/env.ts create mode 100644 src/framework/execution/CommandExecutor.ts create mode 100644 src/framework/handlers/prefixHandler.ts create mode 100644 src/framework/handlers/slashHandler.ts create mode 100644 src/framework/i18n/I18nService.ts create mode 100644 src/framework/types/command.ts create mode 100644 src/index.ts create mode 100644 src/scripts/deployCommands.ts create mode 100644 tsconfig.json diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..f42d7c1 --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +DISCORD_TOKEN= +DISCORD_CLIENT_ID= +PREFIX=+ +DEFAULT_LANG=en +DEV_GUILD_ID= +AUTO_DEPLOY_SLASH=false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af0670d --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.env +.env.* +!.env.example +.vscode/ +*.log +npm-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/README.md b/README.md new file mode 100644 index 0000000..fc9fa87 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# Discord.js v14 Framework Template + +Professional command framework template for Discord.js `14.26.2` with: + +- Single command object schema +- Minimal command authoring (`name`, `category`, `execute`) +- Shared execution logic for prefix and slash +- External JSON i18n +- Automatic prefix and slash localizations from locale files +- One localized `name` per language drives both prefix and slash triggers +- Typed argument schema +- User permission checks +- Auto-generated help from command metadata + +## Setup + +1. Install dependencies: + npm install +2. Create environment file: + cp .env.example .env +3. Fill required values in `.env`: + - `DISCORD_TOKEN` + - `DISCORD_CLIENT_ID` +4. Deploy slash commands: + npm run deploy:commands +5. Start in development: + npm run dev + +## Architecture + +- `src/framework/types/command.ts`: strict command schema +- `src/framework/commands/defineCommand.ts`: default command completion +- `src/framework/commands/registry.ts`: trigger/name mapping generated from locales +- `src/framework/commands/argParser.ts`: prefix/slash args parsing from schema +- `src/framework/execution/CommandExecutor.ts`: unified pipeline (permissions/execute) +- `src/framework/handlers/prefixHandler.ts`: prefix entrypoint +- `src/framework/handlers/slashHandler.ts`: slash entrypoint +- `locales/*.json`: external i18n dictionaries +- `src/commands/*`: business commands only (`execute`) + +## Included Commands + +- `kiss` (`fun`) with required `user` arg +- `ping` (`utility`) +- `advanced` (`utility`) with full argument/permission example +- `help` (`core`) with auto category and usage generation + +## Adding A Command + +1. Create a command object in `src/commands/...` +2. Follow the schema in `src/framework/types/command.ts` +3. Add command to `src/commands/index.ts` +4. Deploy slash commands again (`npm run deploy:commands`) diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..5004ca1 --- /dev/null +++ b/locales/en.json @@ -0,0 +1,100 @@ +{ + "errors": { + "args": { + "missing": "Missing argument {{arg}}. Usage: {{usage}}", + "invalidInt": "Argument must be an integer. Received: {{value}}", + "invalidNumber": "Argument must be a number. Received: {{value}}", + "invalidBoolean": "Argument must be a boolean value. Received: {{value}}", + "invalidUser": "Could not resolve a user from {{value}}" + }, + "permissions": { + "user": "You are missing permissions: {{permissions}}" + }, + "execution": "An unexpected error happened while running this command." + }, + "categories": { + "fun": "Fun", + "utility": "Utility", + "core": "Core" + }, + "commands": { + "kiss": { + "name": "kiss", + "description": "Send a kiss to another user.", + "args": { + "user": "Target user" + }, + "examples": { + "basic": "Send a kiss to a member" + }, + "responses": { + "success": "{{from}} sends a kiss to {{to}}." + } + }, + "ping": { + "name": "ping", + "description": "Check bot websocket latency.", + "examples": { + "basic": "Basic health check" + }, + "responses": { + "pong": "Pong. Websocket latency: {{latency}}ms." + } + }, + "advanced": { + "name": "advanced", + "description": "Showcase command using every available command parameter.", + "args": { + "text": "Required text value", + "count": "Required integer value", + "ratio": "Optional decimal number", + "enabled": "Optional boolean flag", + "user": "Required target user", + "channel": "Optional channel", + "role": "Optional role" + }, + "examples": { + "full": "Complete prefix example with all argument types", + "slash": "Equivalent slash usage" + }, + "responses": { + "summary": "Advanced command summary\ntext={{text}}\ncount={{count}}\nratio={{ratio}}\nenabled={{enabled}}\nuser={{user}}\nchannel={{channel}}\nrole={{role}}\nsource={{source}}" + } + }, + "help": { + "name": "help", + "description": "Display command list or details for one command.", + "args": { + "command": "Optional command name or trigger" + }, + "examples": { + "basic": "List all commands", + "single": "Show details for one command" + }, + "errors": { + "notFound": "No command matches {{query}}." + }, + "embed": { + "title": "Command Help", + "description": "Use {{usage}} to inspect one command.", + "categoryEmpty": "No commands in this category.", + "detailsTitle": "Command: {{name}}", + "detailsDescription": "{{description}}", + "footer": "Category: {{source}}", + "fields": { + "usage": "Usage", + "arguments": "Arguments", + "examples": "Examples" + } + }, + "labels": { + "prefix": "Prefix", + "slash": "Slash", + "required": "required", + "optional": "optional", + "noArgs": "No arguments.", + "noExamples": "No examples." + } + } + } +} diff --git a/locales/es.json b/locales/es.json new file mode 100644 index 0000000..4e45355 --- /dev/null +++ b/locales/es.json @@ -0,0 +1,100 @@ +{ + "errors": { + "args": { + "missing": "Falta el argumento {{arg}}. Uso: {{usage}}", + "invalidInt": "El argumento debe ser un entero. Recibido: {{value}}", + "invalidNumber": "El argumento debe ser un numero. Recibido: {{value}}", + "invalidBoolean": "El argumento debe ser un valor booleano. Recibido: {{value}}", + "invalidUser": "No se pudo resolver un usuario desde {{value}}" + }, + "permissions": { + "user": "Te faltan permisos: {{permissions}}" + }, + "execution": "Ocurrio un error inesperado al ejecutar este comando." + }, + "categories": { + "fun": "Diversion", + "utility": "Utilidad", + "core": "Nucleo" + }, + "commands": { + "kiss": { + "name": "beso", + "description": "Enviar un beso a otro usuario.", + "args": { + "user": "Usuario objetivo" + }, + "examples": { + "basic": "Enviar un beso a un miembro" + }, + "responses": { + "success": "{{from}} envia un beso a {{to}}." + } + }, + "ping": { + "name": "ping", + "description": "Revisar latencia websocket del bot.", + "examples": { + "basic": "Chequeo basico" + }, + "responses": { + "pong": "Pong. Latencia websocket: {{latency}}ms." + } + }, + "advanced": { + "name": "avanzada", + "description": "Comando de demostracion que usa todos los parametros disponibles.", + "args": { + "text": "Texto obligatorio", + "count": "Entero obligatorio", + "ratio": "Numero decimal opcional", + "enabled": "Bandera booleana opcional", + "user": "Usuario objetivo obligatorio", + "channel": "Canal opcional", + "role": "Rol opcional" + }, + "examples": { + "full": "Ejemplo prefix completo con todos los tipos de argumentos", + "slash": "Equivalente en slash" + }, + "responses": { + "summary": "Resumen comando avanzada\ntext={{text}}\ncount={{count}}\nratio={{ratio}}\nenabled={{enabled}}\nuser={{user}}\nchannel={{channel}}\nrole={{role}}\nsource={{source}}" + } + }, + "help": { + "name": "ayuda", + "description": "Mostrar lista de comandos o detalles de un comando.", + "args": { + "command": "Nombre o trigger opcional del comando" + }, + "examples": { + "basic": "Listar todos los comandos", + "single": "Mostrar detalles de un comando" + }, + "errors": { + "notFound": "Ningun comando coincide con {{query}}." + }, + "embed": { + "title": "Ayuda de Comandos", + "description": "Usa {{usage}} para inspeccionar un comando.", + "categoryEmpty": "No hay comandos en esta categoria.", + "detailsTitle": "Comando: {{name}}", + "detailsDescription": "{{description}}", + "footer": "Categoria: {{source}}", + "fields": { + "usage": "Uso", + "arguments": "Argumentos", + "examples": "Ejemplos" + } + }, + "labels": { + "prefix": "Prefijo", + "slash": "Slash", + "required": "requerido", + "optional": "opcional", + "noArgs": "Sin argumentos.", + "noExamples": "Sin ejemplos." + } + } + } +} diff --git a/locales/fr.json b/locales/fr.json new file mode 100644 index 0000000..44171be --- /dev/null +++ b/locales/fr.json @@ -0,0 +1,100 @@ +{ + "errors": { + "args": { + "missing": "Argument manquant {{arg}}. Usage: {{usage}}", + "invalidInt": "L argument doit etre un entier. Recu: {{value}}", + "invalidNumber": "L argument doit etre un nombre. Recu: {{value}}", + "invalidBoolean": "L argument doit etre un booleen. Recu: {{value}}", + "invalidUser": "Impossible de trouver un utilisateur depuis {{value}}" + }, + "permissions": { + "user": "Tu n as pas les permissions: {{permissions}}" + }, + "execution": "Une erreur inattendue est survenue pendant l execution." + }, + "categories": { + "fun": "Fun", + "utility": "Utilitaire", + "core": "Base" + }, + "commands": { + "kiss": { + "name": "bisou", + "description": "Envoyer un bisou a un utilisateur.", + "args": { + "user": "Utilisateur cible" + }, + "examples": { + "basic": "Envoyer un bisou a un membre" + }, + "responses": { + "success": "{{from}} envoie un bisou a {{to}}." + } + }, + "ping": { + "name": "ping", + "description": "Verifier la latence websocket du bot.", + "examples": { + "basic": "Verification de sante" + }, + "responses": { + "pong": "Pong. Latence websocket: {{latency}}ms." + } + }, + "advanced": { + "name": "avance", + "description": "Commande de demonstration utilisant tous les parametres disponibles.", + "args": { + "text": "Texte requis", + "count": "Entier requis", + "ratio": "Nombre decimal optionnel", + "enabled": "Booleen optionnel", + "user": "Utilisateur cible requis", + "channel": "Salon optionnel", + "role": "Role optionnel" + }, + "examples": { + "full": "Exemple prefix complet avec tous les types d arguments", + "slash": "Equivalent en slash" + }, + "responses": { + "summary": "Resume commande avancee\ntext={{text}}\ncount={{count}}\nratio={{ratio}}\nenabled={{enabled}}\nuser={{user}}\nchannel={{channel}}\nrole={{role}}\nsource={{source}}" + } + }, + "help": { + "name": "aide", + "description": "Afficher la liste des commandes ou les details d une commande.", + "args": { + "command": "Nom ou trigger de commande optionnel" + }, + "examples": { + "basic": "Afficher toutes les commandes", + "single": "Afficher les details d une commande" + }, + "errors": { + "notFound": "Aucune commande ne correspond a {{query}}." + }, + "embed": { + "title": "Aide Commandes", + "description": "Utilise {{usage}} pour inspecter une commande.", + "categoryEmpty": "Aucune commande dans cette categorie.", + "detailsTitle": "Commande: {{name}}", + "detailsDescription": "{{description}}", + "footer": "Categorie: {{source}}", + "fields": { + "usage": "Usage", + "arguments": "Arguments", + "examples": "Exemples" + } + }, + "labels": { + "prefix": "Prefix", + "slash": "Slash", + "required": "requis", + "optional": "optionnel", + "noArgs": "Aucun argument.", + "noExamples": "Aucun exemple." + } + } + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..039e5e2 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,875 @@ +{ + "name": "discordjs-framework-template", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "discordjs-framework-template", + "version": "1.0.0", + "dependencies": { + "discord.js": "14.26.2", + "dotenv": "16.4.5", + "zod": "3.23.8" + }, + "devDependencies": { + "@types/node": "20.17.24", + "tsx": "4.19.2", + "typescript": "5.8.2" + }, + "engines": { + "node": ">=18.17.0" + } + }, + "node_modules/@discordjs/builders": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.14.1.tgz", + "integrity": "sha512-gSKkhXLqs96TCzk66VZuHHl8z2bQMJFGwrXC0f33ngK+FLNau4hU1PYny3DNJfNdSH+gVMzE85/d5FQ2BpcNwQ==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/formatters": "^0.6.2", + "@discordjs/util": "^1.2.0", + "@sapphire/shapeshift": "^4.0.0", + "discord-api-types": "^0.38.40", + "fast-deep-equal": "^3.1.3", + "ts-mixer": "^6.0.4", + "tslib": "^2.6.3" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/collection": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz", + "integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=16.11.0" + } + }, + "node_modules/@discordjs/formatters": { + "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.38.33" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest": { + "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.2.0", + "@sapphire/async-queue": "^1.5.3", + "@sapphire/snowflake": "^3.5.5", + "@vladfrangu/async_event_emitter": "^2.4.6", + "discord-api-types": "^0.38.40", + "magic-bytes.js": "^1.13.0", + "tslib": "^2.6.3", + "undici": "6.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/rest/node_modules/@sapphire/snowflake": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.5.tgz", + "integrity": "sha512-xzvBr1Q1c4lCe7i6sRnrofxeO1QTP/LKQ6A6qy0iB4x5yfiSfARMEQEghojzTNALDTcv8En04qYNIco9/K9eZQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@discordjs/util": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.2.0.tgz", + "integrity": "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg==", + "license": "Apache-2.0", + "dependencies": { + "discord-api-types": "^0.38.33" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws": { + "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.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.38.1", + "tslib": "^2.6.2", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=16.11.0" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@discordjs/ws/node_modules/@discordjs/collection": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz", + "integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.23.1.tgz", + "integrity": "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.23.1.tgz", + "integrity": "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.23.1.tgz", + "integrity": "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.23.1.tgz", + "integrity": "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.23.1.tgz", + "integrity": "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.23.1.tgz", + "integrity": "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.1.tgz", + "integrity": "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.23.1.tgz", + "integrity": "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.23.1.tgz", + "integrity": "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.23.1.tgz", + "integrity": "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.23.1.tgz", + "integrity": "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.23.1.tgz", + "integrity": "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.23.1.tgz", + "integrity": "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.23.1.tgz", + "integrity": "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.23.1.tgz", + "integrity": "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.23.1.tgz", + "integrity": "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.23.1.tgz", + "integrity": "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.23.1.tgz", + "integrity": "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.1.tgz", + "integrity": "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.23.1.tgz", + "integrity": "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.23.1.tgz", + "integrity": "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.23.1.tgz", + "integrity": "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.23.1.tgz", + "integrity": "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.23.1.tgz", + "integrity": "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@sapphire/async-queue": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", + "integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@sapphire/shapeshift": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz", + "integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">=v16" + } + }, + "node_modules/@sapphire/snowflake": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz", + "integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==", + "license": "MIT", + "engines": { + "node": ">=v14.0.0", + "npm": ">=7.0.0" + } + }, + "node_modules/@types/node": { + "version": "20.17.24", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.24.tgz", + "integrity": "sha512-d7fGCyB96w9BnWQrOsJtpyiSaBcAYYr75bnK6ZRjDbql2cGLj/3GsL5OYmLPNq76l7Gf2q4Rv9J2o6h5CrD9sA==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.19.2" + } + }, + "node_modules/@types/ws": { + "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.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", + "npm": ">=7.0.0" + } + }, + "node_modules/discord-api-types": { + "version": "0.38.45", + "resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.38.45.tgz", + "integrity": "sha512-DiI01i00FPv6n+hXcFkFxK8Y/rFRpKs6U6aP32N4T73nTbj37Eua3H/95TBpLktLWB6xnLXhYDGvyLq6zzYY2w==", + "license": "MIT", + "workspaces": [ + "scripts/actions/documentation" + ] + }, + "node_modules/discord.js": { + "version": "14.26.2", + "resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.26.2.tgz", + "integrity": "sha512-feShi+gULJ6R2MAA4/KkCFnkJcuVrROJrKk4czplzq8gE1oqhqgOy9K0Scu44B8oGeWKe04egquzf+ia6VtXAw==", + "license": "Apache-2.0", + "dependencies": { + "@discordjs/builders": "^1.14.1", + "@discordjs/collection": "1.5.3", + "@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.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.24.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/discordjs/discord.js?sponsor" + } + }, + "node_modules/dotenv": { + "version": "16.4.5", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", + "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/esbuild": { + "version": "0.23.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.23.1.tgz", + "integrity": "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.23.1", + "@esbuild/android-arm": "0.23.1", + "@esbuild/android-arm64": "0.23.1", + "@esbuild/android-x64": "0.23.1", + "@esbuild/darwin-arm64": "0.23.1", + "@esbuild/darwin-x64": "0.23.1", + "@esbuild/freebsd-arm64": "0.23.1", + "@esbuild/freebsd-x64": "0.23.1", + "@esbuild/linux-arm": "0.23.1", + "@esbuild/linux-arm64": "0.23.1", + "@esbuild/linux-ia32": "0.23.1", + "@esbuild/linux-loong64": "0.23.1", + "@esbuild/linux-mips64el": "0.23.1", + "@esbuild/linux-ppc64": "0.23.1", + "@esbuild/linux-riscv64": "0.23.1", + "@esbuild/linux-s390x": "0.23.1", + "@esbuild/linux-x64": "0.23.1", + "@esbuild/netbsd-x64": "0.23.1", + "@esbuild/openbsd-arm64": "0.23.1", + "@esbuild/openbsd-x64": "0.23.1", + "@esbuild/sunos-x64": "0.23.1", + "@esbuild/win32-arm64": "0.23.1", + "@esbuild/win32-ia32": "0.23.1", + "@esbuild/win32-x64": "0.23.1" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/lodash": { + "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": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz", + "integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw==", + "license": "MIT" + }, + "node_modules/magic-bytes.js": { + "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/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/ts-mixer": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", + "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tsx": { + "version": "4.19.2", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.19.2.tgz", + "integrity": "sha512-pOUl6Vo2LUq/bSa8S5q7b91cgNSjctn9ugq/+Mvow99qW6x/UZYwzxy/3NmqoT66eHYfCVvFvACC58UBPFf28g==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.23.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.8.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.2.tgz", + "integrity": "sha512-aJn6wq13/afZp/jT9QZmwEjDqqvSGp1VT5GVg+f/t6/oVyrgXM6BY1h9BRh/O5p3PlUPAe+WuiEZOmb/49RqoQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "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.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", + "license": "MIT" + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/zod": { + "version": "3.23.8", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", + "integrity": "sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..9746642 --- /dev/null +++ b/package.json @@ -0,0 +1,25 @@ +{ + "name": "discordjs-framework-template", + "version": "1.0.0", + "private": true, + "type": "module", + "engines": { + "node": ">=18.17.0" + }, + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc -p tsconfig.json", + "start": "node dist/index.js", + "deploy:commands": "tsx src/scripts/deployCommands.ts" + }, + "dependencies": { + "discord.js": "14.26.2", + "dotenv": "16.4.5", + "zod": "3.23.8" + }, + "devDependencies": { + "@types/node": "20.17.24", + "tsx": "4.19.2", + "typescript": "5.8.2" + } +} diff --git a/src/commands/core/help.ts b/src/commands/core/help.ts new file mode 100644 index 0000000..ea1a981 --- /dev/null +++ b/src/commands/core/help.ts @@ -0,0 +1,154 @@ +import { EmbedBuilder } from "discord.js"; + +import { buildPrefixUsage, buildSlashUsage, resolvePrefixTrigger, resolveSlashName } from "../../framework/commands/usage.js"; +import { defineCommand } from "../../framework/commands/defineCommand.js"; +import type { BotCommand, CommandExecutionContext } from "../../framework/types/command.js"; + +const categoryName = (command: BotCommand): string => command.meta.category; + +const commandDescription = (ctx: CommandExecutionContext, command: BotCommand): string => + ctx.i18n.commandT(ctx.lang, command.meta.name, "description"); + +const resolveCommandFromQuery = (ctx: CommandExecutionContext, query: string): BotCommand | undefined => { + const normalized = query.toLowerCase(); + const byName = ctx.registry.findByName(normalized); + if (byName) { + return byName; + } + + return ctx.registry.findByAnyPrefixTrigger(normalized)?.command; +}; + +const buildGlobalHelpEmbed = (ctx: CommandExecutionContext): EmbedBuilder => { + const commands = [...ctx.registry.getAll()]; + const grouped = new Map(); + + for (const command of commands) { + const key = categoryName(command); + if (!grouped.has(key)) { + grouped.set(key, []); + } + grouped.get(key)?.push(command); + } + + const helpUsage = buildPrefixUsage(helpCommand, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n); + + const embed = new EmbedBuilder() + .setColor(0x2b6cb0) + .setTitle(ctx.ct("embed.title")) + .setDescription(ctx.ct("embed.description", { prefix: ctx.prefix, usage: `${helpUsage} ` })); + + for (const [category, categoryCommands] of grouped.entries()) { + const lines = categoryCommands + .map((command) => { + const slashLabel = `/${resolveSlashName(command, ctx.lang, ctx.i18n)}`; + const prefixLabel = `${ctx.prefix}${resolvePrefixTrigger(command, ctx.lang, ctx.defaultLang, ctx.i18n)}`; + return `${slashLabel} | ${prefixLabel} - ${commandDescription(ctx, command)}`; + }) + .join("\n"); + + embed.addFields({ + name: ctx.t(`categories.${category}`), + value: lines.length > 0 ? lines : ctx.ct("embed.categoryEmpty"), + }); + } + + return embed; +}; + +const buildCommandDetailsEmbed = (ctx: CommandExecutionContext, command: BotCommand): EmbedBuilder => { + const usagePrefix = buildPrefixUsage(command, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n); + const usageSlash = buildSlashUsage(command, ctx.lang, ctx.i18n); + const prefixTrigger = resolvePrefixTrigger(command, ctx.lang, ctx.defaultLang, ctx.i18n); + const slashTrigger = resolveSlashName(command, ctx.lang, ctx.i18n); + const localizedCommandName = ctx.i18n.commandName(ctx.lang, command.meta.name); + + const args = command.args.length === 0 + ? ctx.ct("labels.noArgs") + : command.args + .map((arg) => { + const description = ctx.i18n.commandT(ctx.lang, command.meta.name, arg.descriptionKey); + const requirement = arg.required ? ctx.ct("labels.required") : ctx.ct("labels.optional"); + return `- ${arg.name} (${arg.type}, ${requirement}): ${description}`; + }) + .join("\n"); + + const examples = command.examples.length === 0 + ? ctx.ct("labels.noExamples") + : command.examples + .map((example) => { + const source = example.source ?? "prefix"; + const baseUsage = source === "slash" + ? buildSlashUsage(command, ctx.lang, ctx.i18n) + : buildPrefixUsage(command, ctx.prefix, ctx.lang, ctx.defaultLang, ctx.i18n); + + const baseCommand = source === "slash" + ? `/${slashTrigger}` + : `${ctx.prefix}${prefixTrigger}`; + + const input = example.args ? `${baseCommand} ${example.args}` : baseUsage; + const description = ctx.i18n.commandT(ctx.lang, command.meta.name, example.descriptionKey); + return `- ${input}: ${description}`; + }) + .join("\n"); + + return new EmbedBuilder() + .setColor(0x2f855a) + .setTitle(ctx.ct("embed.detailsTitle", { name: localizedCommandName })) + .setDescription(ctx.ct("embed.detailsDescription", { description: commandDescription(ctx, command) })) + .addFields( + { + name: ctx.ct("embed.fields.usage"), + value: `${ctx.ct("labels.prefix")}: ${usagePrefix}\n${ctx.ct("labels.slash")}: ${usageSlash}`, + }, + { + name: ctx.ct("embed.fields.arguments"), + value: args, + }, + { + name: ctx.ct("embed.fields.examples"), + value: examples, + }, + ) + .setFooter({ text: ctx.ct("embed.footer", { source: command.meta.category }) }); +}; + +export const helpCommand = defineCommand({ + meta: { + name: "help", + category: "core", + }, + args: [ + { + name: "command", + type: "string", + required: false, + descriptionKey: "args.command", + }, + ], + examples: [ + { + descriptionKey: "examples.basic", + }, + { + args: "", + descriptionKey: "examples.single", + }, + ], + execute: async (ctx) => { + const queryArg = ctx.args.command; + + if (typeof queryArg === "string" && queryArg.trim().length > 0) { + const command = resolveCommandFromQuery(ctx, queryArg.trim()); + if (!command) { + await ctx.reply(ctx.ct("errors.notFound", { query: queryArg })); + return; + } + + await ctx.reply({ embeds: [buildCommandDetailsEmbed(ctx, command)] }); + return; + } + + await ctx.reply({ embeds: [buildGlobalHelpEmbed(ctx)] }); + }, +}); diff --git a/src/commands/fun/kiss.ts b/src/commands/fun/kiss.ts new file mode 100644 index 0000000..9c26592 --- /dev/null +++ b/src/commands/fun/kiss.ts @@ -0,0 +1,36 @@ +import { defineCommand } from "../../framework/commands/defineCommand.js"; + +export const kissCommand = defineCommand({ + meta: { + name: "kiss", + category: "fun", + }, + args: [ + { + name: "user", + type: "user", + required: true, + descriptionKey: "args.user", + }, + ], + examples: [ + { + args: "@Arthur", + descriptionKey: "examples.basic", + }, + ], + execute: async (ctx) => { + const target = ctx.args.user; + if (!target || typeof target !== "object" || !("id" in target)) { + await ctx.reply(ctx.t("errors.args.invalidUser", { value: String(target) })); + return; + } + + await ctx.reply( + ctx.ct("responses.success", { + from: `<@${ctx.user.id}>`, + to: `<@${target.id}>`, + }), + ); + }, +}); diff --git a/src/commands/index.ts b/src/commands/index.ts new file mode 100644 index 0000000..61153ae --- /dev/null +++ b/src/commands/index.ts @@ -0,0 +1,8 @@ +import { helpCommand } from "./core/help.js"; +import { kissCommand } from "./fun/kiss.js"; +import { advancedCommand } from "./utility/advanced.js"; +import { pingCommand } from "./utility/ping.js"; + +import type { BotCommand } from "../framework/types/command.js"; + +export const commandList: BotCommand[] = [kissCommand, pingCommand, advancedCommand, helpCommand]; diff --git a/src/commands/utility/advanced.ts b/src/commands/utility/advanced.ts new file mode 100644 index 0000000..fe25166 --- /dev/null +++ b/src/commands/utility/advanced.ts @@ -0,0 +1,106 @@ +import { PermissionFlagsBits } from "discord.js"; + +import { defineCommand } from "../../framework/commands/defineCommand.js"; + +const extractId = (value: unknown): string | null => { + if (!value || typeof value !== "object" || !("id" in value)) { + return null; + } + + const id = (value as { id?: unknown }).id; + return typeof id === "string" ? id : null; +}; + +const toDisplayValue = (value: unknown, formatter?: (id: string) => string): string => { + const id = extractId(value); + if (id && formatter) { + return formatter(id); + } + + if (value === undefined || value === null) { + return "-"; + } + + return String(value); +}; + +export const advancedCommand = defineCommand({ + meta: { + name: "advanced", + category: "utility", + }, + permissions: [PermissionFlagsBits.ManageMessages], + args: [ + { + name: "text", + type: "string", + required: true, + descriptionKey: "args.text", + }, + { + name: "count", + type: "int", + required: true, + descriptionKey: "args.count", + }, + { + name: "ratio", + type: "number", + required: false, + descriptionKey: "args.ratio", + }, + { + name: "enabled", + type: "boolean", + required: false, + descriptionKey: "args.enabled", + }, + { + name: "user", + type: "user", + required: true, + descriptionKey: "args.user", + }, + { + name: "channel", + type: "channel", + required: false, + descriptionKey: "args.channel", + }, + { + name: "role", + type: "role", + required: false, + descriptionKey: "args.role", + }, + ], + examples: [ + { + args: '"hello world" 5 1.5 true @Arthur #general @Moderators', + descriptionKey: "examples.full", + }, + { + source: "slash", + descriptionKey: "examples.slash", + }, + ], + execute: async (ctx) => { + const responses = (ctx.commandText.responses as Record | undefined) ?? {}; + const template = typeof responses.summary === "string" + ? responses.summary + : "text={{text}} count={{count}} ratio={{ratio}} enabled={{enabled}} user={{user}} channel={{channel}} role={{role}} source={{source}}"; + + await ctx.reply( + ctx.format(template, { + text: toDisplayValue(ctx.args.text), + count: toDisplayValue(ctx.args.count), + ratio: toDisplayValue(ctx.args.ratio), + enabled: toDisplayValue(ctx.args.enabled), + user: toDisplayValue(ctx.args.user, (id) => `<@${id}>`), + channel: toDisplayValue(ctx.args.channel, (id) => `<#${id}>`), + role: toDisplayValue(ctx.args.role, (id) => `<@&${id}>`), + source: ctx.source, + }), + ); + }, +}); diff --git a/src/commands/utility/ping.ts b/src/commands/utility/ping.ts new file mode 100644 index 0000000..b74421c --- /dev/null +++ b/src/commands/utility/ping.ts @@ -0,0 +1,23 @@ +import { defineCommand } from "../../framework/commands/defineCommand.js"; + +export const pingCommand = defineCommand({ + meta: { + name: "ping", + category: "utility", + }, + examples: [ + { + descriptionKey: "examples.basic", + }, + ], + execute: async (ctx) => { + const responses = (ctx.commandText.responses as Record | undefined) ?? {}; + const template = typeof responses.pong === "string" ? responses.pong : "Pong {{latency}}ms"; + + await ctx.reply( + ctx.format(template, { + latency: ctx.client.ws.ping, + }), + ); + }, +}); diff --git a/src/framework/commands/argParser.ts b/src/framework/commands/argParser.ts new file mode 100644 index 0000000..7b9cd2d --- /dev/null +++ b/src/framework/commands/argParser.ts @@ -0,0 +1,178 @@ +import type { ChatInputCommandInteraction, Message } from "discord.js"; + +import type { CommandArgValue, CommandArgument, TranslationVars } from "../types/command.js"; + +export interface ArgumentParseError { + key: string; + vars?: TranslationVars; +} + +export interface ParsedArgumentsResult { + values: Record; + errors: ArgumentParseError[]; +} + +const USER_MENTION_PATTERN = /^<@!?(\d{16,22})>$/; +const USER_ID_PATTERN = /^(\d{16,22})$/; + +const BOOLEAN_TRUE = new Set(["true", "1", "yes", "y", "on"]); +const BOOLEAN_FALSE = new Set(["false", "0", "no", "n", "off"]); + +export const tokenizePrefixInput = (raw: string): string[] => { + const tokens: string[] = []; + const regex = /"([^"\\]*(?:\\.[^"\\]*)*)"|'([^'\\]*(?:\\.[^'\\]*)*)'|(\S+)/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(raw)) !== null) { + tokens.push((match[1] ?? match[2] ?? match[3] ?? "").replace(/\\(["'])/g, "$1")); + } + + return tokens; +}; + +export const parsePrefixArgs = async ( + message: Message, + definitions: CommandArgument[], + rawInput: string, +): Promise => { + const tokens = tokenizePrefixInput(rawInput); + const values: Record = {}; + const errors: ArgumentParseError[] = []; + + for (const definition of definitions) { + const token = tokens.shift(); + + if (!token) { + if (definition.required) { + errors.push({ key: "errors.args.missing", vars: { arg: definition.name } }); + } + values[definition.name] = undefined; + continue; + } + + const parsed = await parseByTypeFromPrefix(message, definition.type, token); + if (parsed.ok) { + values[definition.name] = parsed.value; + continue; + } + + errors.push(parsed.error); + } + + return { values, errors }; +}; + +export const parseSlashArgs = ( + interaction: ChatInputCommandInteraction, + definitions: CommandArgument[], +): ParsedArgumentsResult => { + const values: Record = {}; + const errors: ArgumentParseError[] = []; + + for (const definition of definitions) { + try { + values[definition.name] = parseByTypeFromSlash(interaction, definition); + } catch { + if (definition.required) { + errors.push({ key: "errors.args.missing", vars: { arg: definition.name } }); + } + values[definition.name] = undefined; + } + } + + return { values, errors }; +}; + +const parseByTypeFromPrefix = async ( + message: Message, + type: CommandArgument["type"], + token: string, +): Promise<{ ok: true; value: CommandArgValue } | { ok: false; error: ArgumentParseError }> => { + switch (type) { + case "string": + return { ok: true, value: token }; + + case "int": { + const value = Number.parseInt(token, 10); + if (Number.isNaN(value)) { + return { ok: false, error: { key: "errors.args.invalidInt", vars: { value: token } } }; + } + return { ok: true, value }; + } + + case "number": { + const value = Number(token); + if (Number.isNaN(value)) { + return { ok: false, error: { key: "errors.args.invalidNumber", vars: { value: token } } }; + } + return { ok: true, value }; + } + + case "boolean": { + const normalized = token.toLowerCase(); + if (BOOLEAN_TRUE.has(normalized)) { + return { ok: true, value: true }; + } + if (BOOLEAN_FALSE.has(normalized)) { + return { ok: true, value: false }; + } + return { ok: false, error: { key: "errors.args.invalidBoolean", vars: { value: token } } }; + } + + case "user": { + const mentionMatch = token.match(USER_MENTION_PATTERN); + const idMatch = token.match(USER_ID_PATTERN); + const userId = mentionMatch?.[1] ?? idMatch?.[1]; + + if (!userId) { + return { ok: false, error: { key: "errors.args.invalidUser", vars: { value: token } } }; + } + + const fromMention = message.mentions.users.get(userId); + if (fromMention) { + return { ok: true, value: fromMention }; + } + + try { + const fetched = await message.client.users.fetch(userId); + return { ok: true, value: fetched }; + } catch { + return { ok: false, error: { key: "errors.args.invalidUser", vars: { value: token } } }; + } + } + + default: + return { ok: true, value: token }; + } +}; + +const parseByTypeFromSlash = ( + interaction: ChatInputCommandInteraction, + definition: CommandArgument, +): CommandArgValue => { + switch (definition.type) { + case "string": + return interaction.options.getString(definition.name, definition.required) ?? undefined; + + case "int": + return interaction.options.getInteger(definition.name, definition.required) ?? undefined; + + case "number": + return interaction.options.getNumber(definition.name, definition.required) ?? undefined; + + case "boolean": + return interaction.options.getBoolean(definition.name, definition.required) ?? undefined; + + case "user": + return interaction.options.getUser(definition.name, definition.required) ?? undefined; + + case "channel": + return (interaction.options.getChannel(definition.name, definition.required) ?? undefined) as CommandArgValue; + + case "role": + return (interaction.options.getRole(definition.name, definition.required) ?? undefined) as CommandArgValue; + + default: + return interaction.options.getString(definition.name, definition.required) ?? undefined; + } +}; diff --git a/src/framework/commands/defineCommand.ts b/src/framework/commands/defineCommand.ts new file mode 100644 index 0000000..d1c4bd8 --- /dev/null +++ b/src/framework/commands/defineCommand.ts @@ -0,0 +1,20 @@ +import type { BotCommand, BotCommandInput } from "../types/command.js"; + +export const defineCommand = (input: BotCommandInput): BotCommand => { + // Discord requires all required slash options before any optional ones. + const normalizedArgs = [...(input.args ?? [])].sort((a, b) => { + if (a.required === b.required) { + return 0; + } + + return a.required ? -1 : 1; + }); + + return { + meta: input.meta, + args: normalizedArgs, + permissions: input.permissions ?? [], + examples: input.examples ?? [], + execute: input.execute, + }; +}; diff --git a/src/framework/commands/deploy.ts b/src/framework/commands/deploy.ts new file mode 100644 index 0000000..a22083d --- /dev/null +++ b/src/framework/commands/deploy.ts @@ -0,0 +1,31 @@ +import { REST, Routes } from "discord.js"; + +import { buildSlashPayload } from "./slashBuilder.js"; +import type { CommandRegistry } from "./registry.js"; +import type { I18nService } from "../i18n/I18nService.js"; + +export interface DeployCommandsOptions { + token: string; + clientId: string; + guildId?: string; + registry: CommandRegistry; + i18n: I18nService; +} + +export interface DeployCommandsResult { + scope: "guild" | "global"; + count: number; +} + +export const deployApplicationCommands = async (options: DeployCommandsOptions): Promise => { + const body = buildSlashPayload(options.registry.getAll(), options.i18n); + const rest = new REST({ version: "10" }).setToken(options.token); + + if (options.guildId) { + await rest.put(Routes.applicationGuildCommands(options.clientId, options.guildId), { body }); + return { scope: "guild", count: body.length }; + } + + await rest.put(Routes.applicationCommands(options.clientId), { body }); + return { scope: "global", count: body.length }; +}; diff --git a/src/framework/commands/registry.ts b/src/framework/commands/registry.ts new file mode 100644 index 0000000..9a68bc1 --- /dev/null +++ b/src/framework/commands/registry.ts @@ -0,0 +1,76 @@ +import type { BotCommand, PrefixTriggerMatch } from "../types/command.js"; +import { SUPPORTED_LANGS } from "../types/command.js"; +import type { I18nService } from "../i18n/I18nService.js"; + +const normalize = (value: string): string => value.trim().toLowerCase(); + +export class CommandRegistry { + private readonly commandsByName = new Map(); + private readonly prefixTriggers = new Map(); + private readonly slashTriggers = new Map(); + + public constructor( + commands: BotCommand[], + private readonly i18n: I18nService, + ) { + for (const command of commands) { + this.register(command); + } + } + + public getAll(): readonly BotCommand[] { + return [...this.commandsByName.values()]; + } + + public findByName(name: string): BotCommand | undefined { + return this.commandsByName.get(normalize(name)); + } + + public findByAnyPrefixTrigger(trigger: string): PrefixTriggerMatch | undefined { + return this.prefixTriggers.get(normalize(trigger)); + } + + public findBySlashTrigger(trigger: string): BotCommand | undefined { + return this.slashTriggers.get(normalize(trigger)); + } + + private register(command: BotCommand): void { + const name = normalize(command.meta.name); + if (this.commandsByName.has(name)) { + throw new Error(`Duplicate command meta.name: ${command.meta.name}`); + } + + this.commandsByName.set(name, command); + + for (const lang of SUPPORTED_LANGS) { + const trigger = normalize(this.i18n.commandTrigger(lang, command.meta.name)); + const existing = this.prefixTriggers.get(trigger); + + if (existing) { + if (existing.command.meta.name !== command.meta.name) { + throw new Error(`Duplicate prefix trigger: ${trigger}`); + } + continue; + } + + this.prefixTriggers.set(trigger, { + command, + lang, + trigger, + }); + } + + const slashKeys = [command.meta.name, ...SUPPORTED_LANGS.map((lang) => this.i18n.commandName(lang, command.meta.name))]; + + for (const keyRaw of slashKeys) { + const key = normalize(keyRaw); + const existing = this.slashTriggers.get(key); + + if (existing && existing.meta.name !== command.meta.name) { + throw new Error(`Duplicate slash trigger: ${key}`); + } + + this.slashTriggers.set(key, command); + } + } +} diff --git a/src/framework/commands/slashBuilder.ts b/src/framework/commands/slashBuilder.ts new file mode 100644 index 0000000..74ee7a1 --- /dev/null +++ b/src/framework/commands/slashBuilder.ts @@ -0,0 +1,193 @@ +import { + ApplicationCommandOptionType, + SlashCommandBuilder, + type RESTPostAPIChatInputApplicationCommandsJSONBody, +} from "discord.js"; + +import type { BotCommand, CommandArgument, SupportedLang } from "../types/command.js"; +import type { I18nService } from "../i18n/I18nService.js"; + +const LANG_TO_DISCORD_LOCALE: Record = { + en: "en-US", + fr: "fr", + es: "es-ES", +}; + +const toLocalizationMap = (source: Partial>): Record => { + const entries = Object.entries(source) + .filter(([, value]) => Boolean(value)) + .map(([lang, value]) => [LANG_TO_DISCORD_LOCALE[lang as SupportedLang], value as string]); + + return Object.fromEntries(entries); +}; + +const argDescriptionKey = (command: BotCommand, arg: CommandArgument): string => { + return `commands.${command.meta.name}.${arg.descriptionKey}`; +}; + +const commandDescriptionKey = (command: BotCommand): string => { + return `commands.${command.meta.name}.description`; +}; + +const buildHelpCommandChoices = (commands: readonly BotCommand[], i18n: I18nService): Array<{ name: string; value: string }> => { + return commands + .slice() + .sort((a, b) => a.meta.name.localeCompare(b.meta.name)) + .slice(0, 25) + .map((cmd) => ({ + name: i18n.commandName("en", cmd.meta.name), + value: cmd.meta.name, + })); +}; + +const applyOption = ( + builder: SlashCommandBuilder, + command: BotCommand, + arg: CommandArgument, + i18n: I18nService, + allCommands: readonly BotCommand[], +): void => { + const descriptionKey = argDescriptionKey(command, arg); + const descriptionEn = i18n.t("en", descriptionKey); + const descriptionLocalizations = toLocalizationMap({ + en: i18n.t("en", descriptionKey), + fr: i18n.t("fr", descriptionKey), + es: i18n.t("es", descriptionKey), + }); + + if (arg.type === "user") { + builder.addUserOption((opt) => + opt + .setName(arg.name) + .setDescription(descriptionEn) + .setDescriptionLocalizations(descriptionLocalizations) + .setRequired(arg.required), + ); + return; + } + + if (arg.type === "int") { + builder.addIntegerOption((opt) => + opt + .setName(arg.name) + .setDescription(descriptionEn) + .setDescriptionLocalizations(descriptionLocalizations) + .setRequired(arg.required), + ); + return; + } + + if (arg.type === "number") { + builder.addNumberOption((opt) => + opt + .setName(arg.name) + .setDescription(descriptionEn) + .setDescriptionLocalizations(descriptionLocalizations) + .setRequired(arg.required), + ); + return; + } + + if (arg.type === "boolean") { + builder.addBooleanOption((opt) => + opt + .setName(arg.name) + .setDescription(descriptionEn) + .setDescriptionLocalizations(descriptionLocalizations) + .setRequired(arg.required), + ); + return; + } + + if (arg.type === "channel") { + builder.addChannelOption((opt) => + opt + .setName(arg.name) + .setDescription(descriptionEn) + .setDescriptionLocalizations(descriptionLocalizations) + .setRequired(arg.required), + ); + return; + } + + if (arg.type === "role") { + builder.addRoleOption((opt) => + opt + .setName(arg.name) + .setDescription(descriptionEn) + .setDescriptionLocalizations(descriptionLocalizations) + .setRequired(arg.required), + ); + return; + } + + builder.addStringOption((opt) => + { + const configured = opt + .setName(arg.name) + .setDescription(descriptionEn) + .setDescriptionLocalizations(descriptionLocalizations) + .setRequired(arg.required); + + if (command.meta.name === "help" && arg.name === "command") { + const choices = buildHelpCommandChoices(allCommands, i18n); + if (choices.length > 0) { + configured.addChoices(...choices); + } + } + + return configured; + }, + ); +}; + +export const buildSlashPayload = ( + commands: readonly BotCommand[], + i18n: I18nService, +): RESTPostAPIChatInputApplicationCommandsJSONBody[] => { + return commands.map((command) => { + const descriptionKey = commandDescriptionKey(command); + + const slashLocalizations = toLocalizationMap({ + fr: i18n.commandName("fr", command.meta.name), + es: i18n.commandName("es", command.meta.name), + }); + + const slashBuilder = new SlashCommandBuilder() + .setName(command.meta.name) + .setDescription(i18n.t("en", descriptionKey)) + .setDescriptionLocalizations( + toLocalizationMap({ + en: i18n.t("en", descriptionKey), + fr: i18n.t("fr", descriptionKey), + es: i18n.t("es", descriptionKey), + }), + ) + .setNameLocalizations(slashLocalizations); + + for (const arg of command.args) { + applyOption(slashBuilder, command, arg, i18n, commands); + } + + return slashBuilder.toJSON() as RESTPostAPIChatInputApplicationCommandsJSONBody; + }); +}; + +export const commandOptionType = (type: CommandArgument["type"]): ApplicationCommandOptionType => { + switch (type) { + case "user": + return ApplicationCommandOptionType.User; + case "int": + return ApplicationCommandOptionType.Integer; + case "number": + return ApplicationCommandOptionType.Number; + case "boolean": + return ApplicationCommandOptionType.Boolean; + case "channel": + return ApplicationCommandOptionType.Channel; + case "role": + return ApplicationCommandOptionType.Role; + default: + return ApplicationCommandOptionType.String; + } +}; diff --git a/src/framework/commands/usage.ts b/src/framework/commands/usage.ts new file mode 100644 index 0000000..c9791c7 --- /dev/null +++ b/src/framework/commands/usage.ts @@ -0,0 +1,44 @@ +import type { BotCommand, CommandI18nTools, SupportedLang } from "../types/command.js"; + +const formatArgToken = (name: string, required: boolean): string => required ? `<${name}>` : `[${name}]`; + +export const resolvePrefixTrigger = ( + command: BotCommand, + lang: SupportedLang, + defaultLang: SupportedLang, + i18n: CommandI18nTools, +): string => { + const fromLang = i18n.commandTrigger(lang, command.meta.name); + if (fromLang) { + return fromLang; + } + + const fromDefault = i18n.commandTrigger(defaultLang, command.meta.name); + if (fromDefault) { + return fromDefault; + } + + return command.meta.name; +}; + +export const resolveSlashName = (command: BotCommand, lang: SupportedLang, i18n: CommandI18nTools): string => { + return i18n.commandName(lang, command.meta.name); +}; + +export const buildPrefixUsage = ( + command: BotCommand, + prefix: string, + lang: SupportedLang, + defaultLang: SupportedLang, + i18n: CommandI18nTools, +): string => { + const trigger = resolvePrefixTrigger(command, lang, defaultLang, i18n); + const args = command.args.map((arg) => formatArgToken(arg.name, arg.required)).join(" "); + return `${prefix}${trigger}${args.length > 0 ? ` ${args}` : ""}`; +}; + +export const buildSlashUsage = (command: BotCommand, lang: SupportedLang, i18n: CommandI18nTools): string => { + const slashName = resolveSlashName(command, lang, i18n); + const args = command.args.map((arg) => formatArgToken(arg.name, arg.required)).join(" "); + return `/${slashName}${args.length > 0 ? ` ${args}` : ""}`; +}; diff --git a/src/framework/config/env.ts b/src/framework/config/env.ts new file mode 100644 index 0000000..7e42792 --- /dev/null +++ b/src/framework/config/env.ts @@ -0,0 +1,21 @@ +import { config as loadEnv } from "dotenv"; +import { z } from "zod"; + +import { SUPPORTED_LANGS } from "../types/command.js"; + +loadEnv(); + +const envSchema = z.object({ + DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"), + DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"), + PREFIX: z.string().min(1).max(5).default("+"), + DEFAULT_LANG: z.enum(SUPPORTED_LANGS).default("en"), + DEV_GUILD_ID: z.string().optional().transform((value) => value && value.length > 0 ? value : undefined), + AUTO_DEPLOY_SLASH: z + .string() + .optional() + .default("false") + .transform((value) => value.toLowerCase() === "true"), +}); + +export const env = envSchema.parse(process.env); diff --git a/src/framework/execution/CommandExecutor.ts b/src/framework/execution/CommandExecutor.ts new file mode 100644 index 0000000..f2f3ae3 --- /dev/null +++ b/src/framework/execution/CommandExecutor.ts @@ -0,0 +1,76 @@ +import { + PermissionsBitField, + type ChatInputCommandInteraction, + type Message, + type PermissionResolvable, +} from "discord.js"; + +import type { BotCommand, CommandExecutionContext } from "../types/command.js"; + +export class CommandExecutor { + public async run(command: BotCommand, ctx: CommandExecutionContext): Promise { + const missingUserPermissions = this.getMissingPermissions(command.permissions, this.memberPermissions(ctx)); + if (missingUserPermissions.length > 0) { + await ctx.reply(ctx.t("errors.permissions.user", { permissions: missingUserPermissions.join(", ") })); + return; + } + + try { + await command.execute(ctx); + } catch (error) { + console.error(`[command:${command.meta.name}] execution failed`, error); + await ctx.reply(ctx.t("errors.execution")); + } + } + + private memberPermissions(ctx: CommandExecutionContext): Readonly | null { + if (ctx.source === "slash") { + return (ctx.raw as ChatInputCommandInteraction).memberPermissions ?? null; + } + + const message = ctx.raw as Message; + return message.member?.permissions ?? null; + } + + private getMissingPermissions( + required: PermissionResolvable[], + available: Readonly | null, + ): string[] { + if (required.length === 0) { + return []; + } + + if (!available) { + return [...new Set(required.flatMap((permission) => this.permissionToLabels(permission)))]; + } + + return [ + ...new Set( + required + .filter((permission) => !available.has(permission)) + .flatMap((permission) => this.permissionToLabels(permission)), + ), + ]; + } + + private permissionToLabels(permission: PermissionResolvable): string[] { + try { + const resolved = PermissionsBitField.resolve(permission); + const labels = new PermissionsBitField(resolved).toArray().map(this.formatPermissionLabel); + if (labels.length > 0) { + return labels; + } + } catch { + // Fallback handled below. + } + + return [String(permission)]; + } + + private formatPermissionLabel(permission: string): string { + return permission + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/_/g, " ") + .trim(); + } +} diff --git a/src/framework/handlers/prefixHandler.ts b/src/framework/handlers/prefixHandler.ts new file mode 100644 index 0000000..470687f --- /dev/null +++ b/src/framework/handlers/prefixHandler.ts @@ -0,0 +1,94 @@ +import type { InteractionReplyOptions, Message, MessageReplyOptions } from "discord.js"; + +import { parsePrefixArgs } from "../commands/argParser.js"; +import { buildPrefixUsage } from "../commands/usage.js"; +import type { CommandRegistry } from "../commands/registry.js"; +import { CommandExecutor } from "../execution/CommandExecutor.js"; +import type { I18nService } from "../i18n/I18nService.js"; +import type { ReplyPayload, SupportedLang } from "../types/command.js"; + +interface PrefixHandlerDeps { + registry: CommandRegistry; + i18n: I18nService; + executor: CommandExecutor; + prefix: string; + defaultLang: SupportedLang; +} + +const toMessageReplyOptions = (payload: Exclude): MessageReplyOptions => { + const { + ephemeral: _ephemeral, + fetchReply: _fetchReply, + withResponse: _withResponse, + flags: _flags, + ...rest + } = payload as InteractionReplyOptions & MessageReplyOptions; + + return rest as MessageReplyOptions; +}; + +export const createPrefixHandler = (deps: PrefixHandlerDeps) => { + return async (message: Message): Promise => { + if (message.author.bot || !message.content.startsWith(deps.prefix)) { + return; + } + + const content = message.content.slice(deps.prefix.length).trim(); + if (!content) { + return; + } + + const firstSpaceIndex = content.indexOf(" "); + const trigger = (firstSpaceIndex === -1 ? content : content.slice(0, firstSpaceIndex)).toLowerCase(); + const rawArgs = firstSpaceIndex === -1 ? "" : content.slice(firstSpaceIndex + 1); + + const match = deps.registry.findByAnyPrefixTrigger(trigger); + if (!match) { + return; + } + + const command = match.command; + const lang = match.lang; + const t = (key: string, vars?: Record): string => + deps.i18n.t(lang, key, vars); + + const parsed = await parsePrefixArgs(message, command.args, rawArgs); + if (parsed.errors.length > 0) { + const firstError = parsed.errors[0]; + if (!firstError) { + return; + } + + const usage = buildPrefixUsage(command, deps.prefix, lang, deps.defaultLang, deps.i18n); + await message.reply(t(firstError.key, { ...(firstError.vars ?? {}), usage })); + return; + } + + const ct = (relativeKey: string, vars?: Record): string => + deps.i18n.commandT(lang, command.meta.name, relativeKey, vars); + + await deps.executor.run(command, { + client: message.client, + user: message.author, + guild: message.guild, + channel: message.channel, + lang, + args: parsed.values, + command, + source: "prefix", + t, + ct, + commandText: deps.i18n.commandObject(lang, command.meta.name), + format: (template, vars) => deps.i18n.format(template, vars), + i18n: deps.i18n, + raw: message, + reply: (payload: ReplyPayload) => + typeof payload === "string" + ? message.reply(payload) + : message.reply(toMessageReplyOptions(payload)), + registry: deps.registry, + prefix: deps.prefix, + defaultLang: deps.defaultLang, + }); + }; +}; diff --git a/src/framework/handlers/slashHandler.ts b/src/framework/handlers/slashHandler.ts new file mode 100644 index 0000000..dd2120f --- /dev/null +++ b/src/framework/handlers/slashHandler.ts @@ -0,0 +1,84 @@ +import type { ChatInputCommandInteraction } from "discord.js"; + +import { parseSlashArgs } from "../commands/argParser.js"; +import { buildSlashUsage } from "../commands/usage.js"; +import type { CommandRegistry } from "../commands/registry.js"; +import { CommandExecutor } from "../execution/CommandExecutor.js"; +import type { I18nService } from "../i18n/I18nService.js"; +import type { ReplyPayload, SupportedLang } from "../types/command.js"; + +interface SlashHandlerDeps { + registry: CommandRegistry; + i18n: I18nService; + executor: CommandExecutor; + prefix: string; + defaultLang: SupportedLang; +} + +const resolveReply = async (interaction: ChatInputCommandInteraction, payload: ReplyPayload): Promise => { + if (typeof payload === "string") { + if (interaction.replied || interaction.deferred) { + return interaction.followUp({ content: payload }); + } + + return interaction.reply({ content: payload }); + } + + if (interaction.replied || interaction.deferred) { + return interaction.followUp(payload as never); + } + + return interaction.reply(payload as never); +}; + +export const createSlashHandler = (deps: SlashHandlerDeps) => { + return async (interaction: ChatInputCommandInteraction): Promise => { + const command = deps.registry.findBySlashTrigger(interaction.commandName); + if (!command) { + return; + } + + const lang = deps.i18n.resolveLang(interaction.locale ?? interaction.guildLocale); + const t = (key: string, vars?: Record): string => + deps.i18n.t(lang, key, vars); + + const parsed = parseSlashArgs(interaction, command.args); + if (parsed.errors.length > 0) { + const firstError = parsed.errors[0]; + if (!firstError) { + return; + } + + const usage = buildSlashUsage(command, lang, deps.i18n); + await resolveReply(interaction, { + content: t(firstError.key, { ...(firstError.vars ?? {}), usage }), + ephemeral: true, + }); + return; + } + + const ct = (relativeKey: string, vars?: Record): string => + deps.i18n.commandT(lang, command.meta.name, relativeKey, vars); + + await deps.executor.run(command, { + client: interaction.client, + user: interaction.user, + guild: interaction.guild, + channel: interaction.channel, + lang, + args: parsed.values, + command, + source: "slash", + t, + ct, + commandText: deps.i18n.commandObject(lang, command.meta.name), + format: (template, vars) => deps.i18n.format(template, vars), + i18n: deps.i18n, + raw: interaction, + reply: (payload: ReplyPayload) => resolveReply(interaction, payload), + registry: deps.registry, + prefix: deps.prefix, + defaultLang: deps.defaultLang, + }); + }; +}; diff --git a/src/framework/i18n/I18nService.ts b/src/framework/i18n/I18nService.ts new file mode 100644 index 0000000..1a89842 --- /dev/null +++ b/src/framework/i18n/I18nService.ts @@ -0,0 +1,138 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import { SUPPORTED_LANGS, type SupportedLang, type TranslationVars } from "../types/command.js"; + +type JsonObject = Record; + +const DISCORD_LOCALE_MAP: Record = { + en: "en", + "en-us": "en", + "en-gb": "en", + fr: "fr", + "fr-fr": "fr", + es: "es", + "es-es": "es", + "es-419": "es", +}; + +export class I18nService { + private readonly dictionaries: Record; + + public constructor(private readonly defaultLang: SupportedLang) { + this.dictionaries = this.loadDictionaries(); + } + + public resolveLang(input?: string | null): SupportedLang { + if (!input) { + return this.defaultLang; + } + + const normalized = input.toLowerCase(); + + const direct = DISCORD_LOCALE_MAP[normalized]; + if (direct) { + return direct; + } + + const short = normalized.split("-")[0]; + const fromShort = short ? DISCORD_LOCALE_MAP[short] : undefined; + if (fromShort) { + return fromShort; + } + + return this.defaultLang; + } + + public t(lang: SupportedLang, key: string, vars: TranslationVars = {}): string { + const fromLang = this.lookup(this.dictionaries[lang], key); + const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key); + + const template = typeof fromLang === "string" ? fromLang : typeof fromDefault === "string" ? fromDefault : key; + + return this.format(template, vars); + } + + public commandT(lang: SupportedLang, commandName: string, relativeKey: string, vars: TranslationVars = {}): string { + return this.t(lang, `${this.commandBaseKey(commandName)}.${relativeKey}`, vars); + } + + public commandName(lang: SupportedLang, commandName: string): string { + const key = `${this.commandBaseKey(commandName)}.name`; + const fromLang = this.lookup(this.dictionaries[lang], key); + if (typeof fromLang === "string" && fromLang.length > 0) { + return fromLang; + } + + const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key); + if (typeof fromDefault === "string" && fromDefault.length > 0) { + return fromDefault; + } + + return commandName; + } + + public commandTrigger(lang: SupportedLang, commandName: string): string { + return this.commandName(lang, commandName).trim().toLowerCase(); + } + + public commandObject(lang: SupportedLang, commandName: string): Record { + const key = this.commandBaseKey(commandName); + const fromLang = this.lookup(this.dictionaries[lang], key); + if (this.isObject(fromLang)) { + return fromLang; + } + + const fromDefault = this.lookup(this.dictionaries[this.defaultLang], key); + if (this.isObject(fromDefault)) { + return fromDefault; + } + + return {}; + } + + public format(template: string, vars: TranslationVars = {}): string { + return this.interpolate(template, vars); + } + + private loadDictionaries(): Record { + const localeDir = path.join(process.cwd(), "locales"); + + return SUPPORTED_LANGS.reduce>((acc, lang) => { + const filePath = path.join(localeDir, `${lang}.json`); + const raw = readFileSync(filePath, "utf-8"); + acc[lang] = JSON.parse(raw) as JsonObject; + return acc; + }, {} as Record); + } + + private lookup(source: JsonObject, key: string): unknown { + const parts = key.split("."); + let current: unknown = source; + + for (const part of parts) { + if (!current || typeof current !== "object" || Array.isArray(current)) { + return undefined; + } + + current = (current as JsonObject)[part]; + } + + return current; + } + + private commandBaseKey(commandName: string): string { + return `commands.${commandName}`; + } + + private isObject(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); + } + + private interpolate(template: string, vars: TranslationVars): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, variable: string) => { + const value = vars[variable]; + return value === undefined || value === null ? "" : String(value); + }); + } +} diff --git a/src/framework/types/command.ts b/src/framework/types/command.ts new file mode 100644 index 0000000..e2df09c --- /dev/null +++ b/src/framework/types/command.ts @@ -0,0 +1,110 @@ +import type { + ChatInputCommandInteraction, + Client, + Guild, + GuildBasedChannel, + GuildMember, + InteractionReplyOptions, + Message, + MessageCreateOptions, + MessageReplyOptions, + PermissionResolvable, + Role, + TextBasedChannel, + User, +} from "discord.js"; + +export const SUPPORTED_LANGS = ["en", "fr", "es"] as const; + +export type SupportedLang = (typeof SUPPORTED_LANGS)[number]; +export type CommandSource = "prefix" | "slash"; +export type CommandArgType = "string" | "user" | "int" | "boolean" | "number" | "channel" | "role"; + +export type TranslationVars = Record; +export type ReplyPayload = string | MessageCreateOptions | MessageReplyOptions | InteractionReplyOptions; + +export type CommandArgValue = + | string + | number + | boolean + | User + | GuildMember + | Role + | GuildBasedChannel + | null + | undefined; + +export interface CommandMeta { + name: string; + category: string; +} + +export interface CommandArgument { + name: string; + type: CommandArgType; + required: boolean; + descriptionKey: string; +} + +export interface CommandExample { + source?: CommandSource; + args?: string; + descriptionKey: string; +} + +export interface PrefixTriggerMatch { + command: BotCommand; + lang: SupportedLang; + trigger: string; +} + +export interface CommandRegistryReader { + getAll(): readonly BotCommand[]; + findByName(name: string): BotCommand | undefined; + findByAnyPrefixTrigger(trigger: string): PrefixTriggerMatch | undefined; +} + +export interface CommandI18nTools { + commandName: (lang: SupportedLang, commandName: string) => string; + commandTrigger: (lang: SupportedLang, commandName: string) => string; + commandT: (lang: SupportedLang, commandName: string, relativeKey: string, vars?: TranslationVars) => string; + commandObject: (lang: SupportedLang, commandName: string) => Record; + format: (template: string, vars?: TranslationVars) => string; +} + +export interface CommandExecutionContext { + client: Client; + user: User; + guild: Guild | null; + channel: TextBasedChannel | null; + lang: SupportedLang; + args: Record; + command: BotCommand; + source: CommandSource; + t: (key: string, vars?: TranslationVars) => string; + ct: (relativeKey: string, vars?: TranslationVars) => string; + commandText: Record; + format: (template: string, vars?: TranslationVars) => string; + i18n: CommandI18nTools; + raw: Message | ChatInputCommandInteraction; + reply: (payload: ReplyPayload) => Promise; + registry: CommandRegistryReader; + prefix: string; + defaultLang: SupportedLang; +} + +export interface BotCommandInput { + meta: CommandMeta; + args?: CommandArgument[]; + permissions?: PermissionResolvable[]; + examples?: CommandExample[]; + execute: (ctx: CommandExecutionContext) => Promise; +} + +export interface BotCommand { + meta: CommandMeta; + args: CommandArgument[]; + permissions: PermissionResolvable[]; + examples: CommandExample[]; + execute: (ctx: CommandExecutionContext) => Promise; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..ec851cf --- /dev/null +++ b/src/index.ts @@ -0,0 +1,72 @@ +import { Client, Events, GatewayIntentBits } from "discord.js"; + +import { commandList } from "./commands/index.js"; +import { deployApplicationCommands } from "./framework/commands/deploy.js"; +import { CommandRegistry } from "./framework/commands/registry.js"; +import { env } from "./framework/config/env.js"; +import { CommandExecutor } from "./framework/execution/CommandExecutor.js"; +import { createPrefixHandler } from "./framework/handlers/prefixHandler.js"; +import { createSlashHandler } from "./framework/handlers/slashHandler.js"; +import { I18nService } from "./framework/i18n/I18nService.js"; + +const bootstrap = async (): Promise => { + const client = new Client({ + intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent], + }); + + const i18n = new I18nService(env.DEFAULT_LANG); + const registry = new CommandRegistry(commandList, i18n); + const executor = new CommandExecutor(); + + const onPrefixMessage = createPrefixHandler({ + registry, + i18n, + executor, + prefix: env.PREFIX, + defaultLang: env.DEFAULT_LANG, + }); + + const onSlashInteraction = createSlashHandler({ + registry, + i18n, + executor, + prefix: env.PREFIX, + defaultLang: env.DEFAULT_LANG, + }); + + client.on(Events.MessageCreate, onPrefixMessage); + + client.on(Events.InteractionCreate, async (interaction) => { + if (!interaction.isChatInputCommand()) { + return; + } + + await onSlashInteraction(interaction); + }); + + client.once(Events.ClientReady, async () => { + console.log(`[ready] logged as ${client.user?.tag ?? "unknown"}`); + + if (env.AUTO_DEPLOY_SLASH) { + try { + const result = await deployApplicationCommands({ + token: env.DISCORD_TOKEN, + clientId: env.DISCORD_CLIENT_ID, + registry, + i18n, + ...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}), + }); + console.log(`[ready] slash sync done (${result.scope}, ${result.count} commands)`); + } catch (error) { + console.error("[ready] slash sync failed", error); + } + } + }); + + await client.login(env.DISCORD_TOKEN); +}; + +bootstrap().catch((error) => { + console.error("[boot] fatal error", error); + process.exitCode = 1; +}); diff --git a/src/scripts/deployCommands.ts b/src/scripts/deployCommands.ts new file mode 100644 index 0000000..9496b45 --- /dev/null +++ b/src/scripts/deployCommands.ts @@ -0,0 +1,25 @@ +import { commandList } from "../commands/index.js"; +import { deployApplicationCommands } from "../framework/commands/deploy.js"; +import { CommandRegistry } from "../framework/commands/registry.js"; +import { env } from "../framework/config/env.js"; +import { I18nService } from "../framework/i18n/I18nService.js"; + +const main = async (): Promise => { + const i18n = new I18nService(env.DEFAULT_LANG); + const registry = new CommandRegistry(commandList, i18n); + + const result = await deployApplicationCommands({ + token: env.DISCORD_TOKEN, + clientId: env.DISCORD_CLIENT_ID, + registry, + i18n, + ...(env.DEV_GUILD_ID ? { guildId: env.DEV_GUILD_ID } : {}), + }); + + console.log(`[deploy] ${result.count} commands deployed (${result.scope})`); +}; + +main().catch((error) => { + console.error("[deploy] failed", error); + process.exitCode = 1; +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..7c5b545 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "rootDir": "src", + "outDir": "dist", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true + }, + "include": ["src/**/*.ts"] +}