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.
This commit is contained in:
Puechberty Arthur
2026-04-12 02:34:53 +02:00
commit be8288ab5e
28 changed files with 2775 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
DISCORD_TOKEN=
DISCORD_CLIENT_ID=
PREFIX=+
DEFAULT_LANG=en
DEV_GUILD_ID=
AUTO_DEPLOY_SLASH=false
+10
View File
@@ -0,0 +1,10 @@
node_modules/
dist/
.env
.env.*
!.env.example
.vscode/
*.log
npm-debug.log*
yarn-error.log*
pnpm-debug.log*
+53
View File
@@ -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`)
+100
View File
@@ -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."
}
}
}
}
+100
View File
@@ -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."
}
}
}
}
+100
View File
@@ -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."
}
}
}
}
+875
View File
@@ -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"
}
}
}
}
+25
View File
@@ -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"
}
}
+154
View File
@@ -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<string, BotCommand[]>();
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} <command>` }));
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: "<command>",
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)] });
},
});
+36
View File
@@ -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}>`,
}),
);
},
});
+8
View File
@@ -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];
+106
View File
@@ -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<string, unknown> | 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,
}),
);
},
});
+23
View File
@@ -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<string, unknown> | undefined) ?? {};
const template = typeof responses.pong === "string" ? responses.pong : "Pong {{latency}}ms";
await ctx.reply(
ctx.format(template, {
latency: ctx.client.ws.ping,
}),
);
},
});
+178
View File
@@ -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<string, CommandArgValue>;
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<ParsedArgumentsResult> => {
const tokens = tokenizePrefixInput(rawInput);
const values: Record<string, CommandArgValue> = {};
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<string, CommandArgValue> = {};
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;
}
};
+20
View File
@@ -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,
};
};
+31
View File
@@ -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<DeployCommandsResult> => {
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 };
};
+76
View File
@@ -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<string, BotCommand>();
private readonly prefixTriggers = new Map<string, PrefixTriggerMatch>();
private readonly slashTriggers = new Map<string, BotCommand>();
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);
}
}
}
+193
View File
@@ -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<SupportedLang, string> = {
en: "en-US",
fr: "fr",
es: "es-ES",
};
const toLocalizationMap = (source: Partial<Record<SupportedLang, string>>): Record<string, string> => {
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;
}
};
+44
View File
@@ -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}` : ""}`;
};
+21
View File
@@ -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);
@@ -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<void> {
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<PermissionsBitField> | 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<PermissionsBitField> | 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();
}
}
+94
View File
@@ -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<ReplyPayload, string>): 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<void> => {
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, string | number | boolean | null | undefined>): 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, string | number | boolean | null | undefined>): 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,
});
};
};
+84
View File
@@ -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<unknown> => {
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<void> => {
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, string | number | boolean | null | undefined>): 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, string | number | boolean | null | undefined>): 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,
});
};
};
+138
View File
@@ -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<string, unknown>;
const DISCORD_LOCALE_MAP: Record<string, SupportedLang> = {
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<SupportedLang, JsonObject>;
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<string, unknown> {
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<SupportedLang, JsonObject> {
const localeDir = path.join(process.cwd(), "locales");
return SUPPORTED_LANGS.reduce<Record<SupportedLang, JsonObject>>((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<SupportedLang, JsonObject>);
}
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<string, unknown> {
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);
});
}
}
+110
View File
@@ -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<string, string | number | boolean | null | undefined>;
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<string, unknown>;
format: (template: string, vars?: TranslationVars) => string;
}
export interface CommandExecutionContext {
client: Client;
user: User;
guild: Guild | null;
channel: TextBasedChannel | null;
lang: SupportedLang;
args: Record<string, CommandArgValue>;
command: BotCommand;
source: CommandSource;
t: (key: string, vars?: TranslationVars) => string;
ct: (relativeKey: string, vars?: TranslationVars) => string;
commandText: Record<string, unknown>;
format: (template: string, vars?: TranslationVars) => string;
i18n: CommandI18nTools;
raw: Message | ChatInputCommandInteraction;
reply: (payload: ReplyPayload) => Promise<unknown>;
registry: CommandRegistryReader;
prefix: string;
defaultLang: SupportedLang;
}
export interface BotCommandInput {
meta: CommandMeta;
args?: CommandArgument[];
permissions?: PermissionResolvable[];
examples?: CommandExample[];
execute: (ctx: CommandExecutionContext) => Promise<void>;
}
export interface BotCommand {
meta: CommandMeta;
args: CommandArgument[];
permissions: PermissionResolvable[];
examples: CommandExample[];
execute: (ctx: CommandExecutionContext) => Promise<void>;
}
+72
View File
@@ -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<void> => {
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;
});
+25
View File
@@ -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<void> => {
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;
});
+17
View File
@@ -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"]
}