From fc0daa671df0aa88578428de2793ab2dc321ca0c Mon Sep 17 00:00:00 2001 From: Puechberty Arthur Date: Sun, 12 Apr 2026 17:04:05 +0200 Subject: [PATCH] feat: add member message functionality for welcome and goodbye events - Introduced `goodbyeCommand` and `welcomeCommand` to handle member leave and join events. - Implemented `memberMessagePanel` for interactive message configuration. - Created `dispatchMemberMessage` to send messages based on member events. - Added database support for storing member message configurations. - Integrated image rendering for member messages using `@napi-rs/canvas`. - Registered event listeners for `GuildMemberAdd` and `GuildMemberRemove` to trigger messages. - Updated `package.json` to include `@napi-rs/canvas` dependency. --- locales/en.json | 146 +++++++ locales/es.json | 146 +++++++ locales/fr.json | 146 +++++++ package-lock.json | 250 +++++++++++ package.json | 1 + src/commands/index.ts | 12 +- src/commands/utility/goodbye.ts | 19 + src/commands/utility/memberMessagePanel.ts | 407 ++++++++++++++++++ src/commands/utility/welcome.ts | 19 + src/events/memberMessageEvents.ts | 30 ++ .../memberMessages/memberMessageImage.ts | 185 ++++++++ .../memberMessages/memberMessageSender.ts | 295 +++++++++++++ .../memberMessages/memberMessageStore.ts | 153 +++++++ .../memberMessages/memberMessageTypes.ts | 27 ++ src/index.ts | 21 +- 15 files changed, 1855 insertions(+), 2 deletions(-) create mode 100644 src/commands/utility/goodbye.ts create mode 100644 src/commands/utility/memberMessagePanel.ts create mode 100644 src/commands/utility/welcome.ts create mode 100644 src/events/memberMessageEvents.ts create mode 100644 src/framework/memberMessages/memberMessageImage.ts create mode 100644 src/framework/memberMessages/memberMessageSender.ts create mode 100644 src/framework/memberMessages/memberMessageStore.ts create mode 100644 src/framework/memberMessages/memberMessageTypes.ts diff --git a/locales/en.json b/locales/en.json index 0eb3e9c..7d975d5 100644 --- a/locales/en.json +++ b/locales/en.json @@ -157,6 +157,152 @@ } } }, + "welcome": { + "name": "welcome", + "description": "Configure welcome messages with an interactive panel.", + "examples": { + "slash": "Open the welcome message configuration panel" + }, + "responses": { + "guildOnly": "This command can only be used inside a server.", + "notOwner": "Only the command author can use this panel.", + "invalidSelection": "Invalid selection.", + "testSuccess": "Test message sent in {{channel}}.", + "testDisabled": "Welcome module is disabled.", + "testMissingChannel": "No channel configured yet. Use the channel button first.", + "testChannelUnavailable": "Configured channel was not found or is not supported.", + "testMissingPermissions": "Bot is missing permission to send messages in that channel.", + "testFailed": "Failed to send test message. Please try again later." + }, + "ui": { + "embed": { + "title": "Welcome Configuration", + "description": "Configure status, channel, message type, and run a test send.", + "fields": { + "status": "Status", + "channel": "Channel", + "type": "Type", + "channelPicker": "Channel picker" + } + }, + "status": { + "enabled": "🟢 Enabled", + "disabled": "🔴 Disabled" + }, + "channelNotConfigured": "#not-configured", + "channelPickerPlaceholder": "Select a welcome channel", + "channelPickerHint": "Use the select menu below to choose the destination channel.", + "buttons": { + "toggle": "Toggle status", + "channel": "Set channel", + "channelCancel": "Close channel picker", + "test": "Test" + }, + "type": { + "placeholder": "Choose a message type", + "options": { + "simple": { + "label": "Simple message", + "description": "Send a classic text message" + }, + "embed": { + "label": "Embed", + "description": "Send a rich embed message" + }, + "container": { + "label": "Container (V2)", + "description": "Send a Components V2 container" + }, + "image": { + "label": "Image", + "description": "Send an embed with an image" + } + } + } + }, + "templates": { + "simple": "🎉 Welcome {{user}} to **{{guild}}**!", + "embedTitle": "Welcome!", + "embedDescription": "{{user}} just joined **{{guild}}**.", + "containerTitle": "Welcome", + "containerDescription": "{{user}} just joined **{{guild}}**.", + "imageTitle": "Welcome", + "imageDescription": "on the Discord server {{guild}}" + } + }, + "goodbye": { + "name": "goodbye", + "description": "Configure goodbye messages with an interactive panel.", + "examples": { + "slash": "Open the goodbye message configuration panel" + }, + "responses": { + "guildOnly": "This command can only be used inside a server.", + "notOwner": "Only the command author can use this panel.", + "invalidSelection": "Invalid selection.", + "testSuccess": "Test message sent in {{channel}}.", + "testDisabled": "Goodbye module is disabled.", + "testMissingChannel": "No channel configured yet. Use the channel button first.", + "testChannelUnavailable": "Configured channel was not found or is not supported.", + "testMissingPermissions": "Bot is missing permission to send messages in that channel.", + "testFailed": "Failed to send test message. Please try again later." + }, + "ui": { + "embed": { + "title": "Goodbye Configuration", + "description": "Configure status, channel, message type, and run a test send.", + "fields": { + "status": "Status", + "channel": "Channel", + "type": "Type", + "channelPicker": "Channel picker" + } + }, + "status": { + "enabled": "🟢 Enabled", + "disabled": "🔴 Disabled" + }, + "channelNotConfigured": "#not-configured", + "channelPickerPlaceholder": "Select a goodbye channel", + "channelPickerHint": "Use the select menu below to choose the destination channel.", + "buttons": { + "toggle": "Toggle status", + "channel": "Set channel", + "channelCancel": "Close channel picker", + "test": "Test" + }, + "type": { + "placeholder": "Choose a message type", + "options": { + "simple": { + "label": "Simple message", + "description": "Send a classic text message" + }, + "embed": { + "label": "Embed", + "description": "Send a rich embed message" + }, + "container": { + "label": "Container (V2)", + "description": "Send a Components V2 container" + }, + "image": { + "label": "Image", + "description": "Send an embed with an image" + } + } + } + }, + "templates": { + "simple": "👋 {{user}} left **{{guild}}**.", + "embedTitle": "Goodbye", + "embedDescription": "{{user}} has left **{{guild}}**.", + "containerTitle": "Goodbye", + "containerDescription": "{{user}} has left **{{guild}}**.", + "imageTitle": "Goodbye", + "imageDescription": "from the Discord server {{guild}}" + } + }, "help": { "name": "help", "description": "Display command list or details for one command.", diff --git a/locales/es.json b/locales/es.json index 0a353c0..c42ca2b 100644 --- a/locales/es.json +++ b/locales/es.json @@ -157,6 +157,152 @@ } } }, + "welcome": { + "name": "bienvenida", + "description": "Configurar mensajes de bienvenida con un panel interactivo.", + "examples": { + "slash": "Abrir el panel de configuracion de bienvenida" + }, + "responses": { + "guildOnly": "Este comando solo puede usarse dentro de un servidor.", + "notOwner": "Solo el autor del comando puede usar este panel.", + "invalidSelection": "Seleccion invalida.", + "testSuccess": "Mensaje de prueba enviado en {{channel}}.", + "testDisabled": "El modulo de bienvenida esta desactivado.", + "testMissingChannel": "No hay canal configurado. Usa primero el boton de canal.", + "testChannelUnavailable": "El canal configurado no existe o no es compatible.", + "testMissingPermissions": "El bot no tiene permiso para enviar mensajes en ese canal.", + "testFailed": "No se pudo enviar el mensaje de prueba. Intentalo mas tarde." + }, + "ui": { + "embed": { + "title": "Configuracion Welcome", + "description": "Ajusta estado, canal, tipo de mensaje y ejecuta una prueba.", + "fields": { + "status": "Estado", + "channel": "Canal", + "type": "Tipo", + "channelPicker": "Selector de canal" + } + }, + "status": { + "enabled": "🟢 Activado", + "disabled": "🔴 Desactivado" + }, + "channelNotConfigured": "#sin-configurar", + "channelPickerPlaceholder": "Selecciona un canal de bienvenida", + "channelPickerHint": "Usa el menu de seleccion de abajo para elegir el canal destino.", + "buttons": { + "toggle": "Alternar estado", + "channel": "Configurar canal", + "channelCancel": "Cerrar selector", + "test": "Probar" + }, + "type": { + "placeholder": "Elige un tipo de mensaje", + "options": { + "simple": { + "label": "Mensaje simple", + "description": "Enviar un mensaje de texto clasico" + }, + "embed": { + "label": "Embed", + "description": "Enviar un embed de bienvenida" + }, + "container": { + "label": "Container (V2)", + "description": "Enviar un container Components V2" + }, + "image": { + "label": "Imagen", + "description": "Enviar un embed con imagen" + } + } + } + }, + "templates": { + "simple": "🎉 Bienvenido {{user}} a **{{guild}}**!", + "embedTitle": "Bienvenido", + "embedDescription": "{{user}} acaba de entrar en **{{guild}}**.", + "containerTitle": "Bienvenido", + "containerDescription": "{{user}} acaba de entrar en **{{guild}}**.", + "imageTitle": "Bienvenido", + "imageDescription": "al servidor de Discord {{guild}}" + } + }, + "goodbye": { + "name": "despedida", + "description": "Configurar mensajes de despedida con un panel interactivo.", + "examples": { + "slash": "Abrir el panel de configuracion de despedida" + }, + "responses": { + "guildOnly": "Este comando solo puede usarse dentro de un servidor.", + "notOwner": "Solo el autor del comando puede usar este panel.", + "invalidSelection": "Seleccion invalida.", + "testSuccess": "Mensaje de prueba enviado en {{channel}}.", + "testDisabled": "El modulo de despedida esta desactivado.", + "testMissingChannel": "No hay canal configurado. Usa primero el boton de canal.", + "testChannelUnavailable": "El canal configurado no existe o no es compatible.", + "testMissingPermissions": "El bot no tiene permiso para enviar mensajes en ese canal.", + "testFailed": "No se pudo enviar el mensaje de prueba. Intentalo mas tarde." + }, + "ui": { + "embed": { + "title": "Configuracion Goodbye", + "description": "Ajusta estado, canal, tipo de mensaje y ejecuta una prueba.", + "fields": { + "status": "Estado", + "channel": "Canal", + "type": "Tipo", + "channelPicker": "Selector de canal" + } + }, + "status": { + "enabled": "🟢 Activado", + "disabled": "🔴 Desactivado" + }, + "channelNotConfigured": "#sin-configurar", + "channelPickerPlaceholder": "Selecciona un canal de despedida", + "channelPickerHint": "Usa el menu de seleccion de abajo para elegir el canal destino.", + "buttons": { + "toggle": "Alternar estado", + "channel": "Configurar canal", + "channelCancel": "Cerrar selector", + "test": "Probar" + }, + "type": { + "placeholder": "Elige un tipo de mensaje", + "options": { + "simple": { + "label": "Mensaje simple", + "description": "Enviar un mensaje de texto clasico" + }, + "embed": { + "label": "Embed", + "description": "Enviar un embed de despedida" + }, + "container": { + "label": "Container (V2)", + "description": "Enviar un container Components V2" + }, + "image": { + "label": "Imagen", + "description": "Enviar un embed con imagen" + } + } + } + }, + "templates": { + "simple": "👋 {{user}} salio de **{{guild}}**.", + "embedTitle": "Despedida", + "embedDescription": "{{user}} salio de **{{guild}}**.", + "containerTitle": "Despedida", + "containerDescription": "{{user}} salio de **{{guild}}**.", + "imageTitle": "Hasta luego", + "imageDescription": "del servidor de Discord {{guild}}" + } + }, "help": { "name": "ayuda", "description": "Mostrar lista de comandos o detalles de un comando.", diff --git a/locales/fr.json b/locales/fr.json index 77a4672..7f540ba 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -157,6 +157,152 @@ } } }, + "welcome": { + "name": "bienvenue", + "description": "Configurer les messages de bienvenue avec un panneau interactif.", + "examples": { + "slash": "Ouvrir le panneau de configuration des messages de bienvenue" + }, + "responses": { + "guildOnly": "Cette commande doit etre utilisee dans un serveur.", + "notOwner": "Seul l auteur de la commande peut utiliser ce panneau.", + "invalidSelection": "Selection invalide.", + "testSuccess": "Message de test envoye dans {{channel}}.", + "testDisabled": "Le module de bienvenue est desactive.", + "testMissingChannel": "Aucun salon configure. Utilise le bouton Salon.", + "testChannelUnavailable": "Le salon configure est introuvable ou non compatible.", + "testMissingPermissions": "Le bot n a pas la permission d envoyer des messages dans ce salon.", + "testFailed": "Impossible d envoyer le message de test. Reessaie plus tard." + }, + "ui": { + "embed": { + "title": "Configuration Welcome", + "description": "Regle le statut, le salon, le type de message et lance un test.", + "fields": { + "status": "Statut", + "channel": "Salon", + "type": "Type", + "channelPicker": "Selection du salon" + } + }, + "status": { + "enabled": "🟢 Active", + "disabled": "🔴 Desactive" + }, + "channelNotConfigured": "#non-configure", + "channelPickerPlaceholder": "Selectionne un salon de bienvenue", + "channelPickerHint": "Choisis le salon dans le select menu ci-dessous.", + "buttons": { + "toggle": "Toggle statut", + "channel": "Configurer salon", + "channelCancel": "Fermer select salon", + "test": "Tester" + }, + "type": { + "placeholder": "Choisir un type de message", + "options": { + "simple": { + "label": "Message simple", + "description": "Envoie un message texte classique" + }, + "embed": { + "label": "Embed", + "description": "Envoie un embed de bienvenue" + }, + "container": { + "label": "Container (V2)", + "description": "Envoie un container Components V2" + }, + "image": { + "label": "Image", + "description": "Envoie un embed avec image" + } + } + } + }, + "templates": { + "simple": "🎉 Bienvenue {{user}} sur **{{guild}}** !", + "embedTitle": "Bienvenue !", + "embedDescription": "{{user}} vient de rejoindre **{{guild}}**.", + "containerTitle": "Bienvenue", + "containerDescription": "{{user}} vient de rejoindre **{{guild}}**.", + "imageTitle": "Bienvenue", + "imageDescription": "sur le serveur Discord {{guild}}" + } + }, + "goodbye": { + "name": "aurevoir", + "description": "Configurer les messages d au revoir avec un panneau interactif.", + "examples": { + "slash": "Ouvrir le panneau de configuration des messages d au revoir" + }, + "responses": { + "guildOnly": "Cette commande doit etre utilisee dans un serveur.", + "notOwner": "Seul l auteur de la commande peut utiliser ce panneau.", + "invalidSelection": "Selection invalide.", + "testSuccess": "Message de test envoye dans {{channel}}.", + "testDisabled": "Le module d au revoir est desactive.", + "testMissingChannel": "Aucun salon configure. Utilise le bouton Salon.", + "testChannelUnavailable": "Le salon configure est introuvable ou non compatible.", + "testMissingPermissions": "Le bot n a pas la permission d envoyer des messages dans ce salon.", + "testFailed": "Impossible d envoyer le message de test. Reessaie plus tard." + }, + "ui": { + "embed": { + "title": "Configuration Goodbye", + "description": "Regle le statut, le salon, le type de message et lance un test.", + "fields": { + "status": "Statut", + "channel": "Salon", + "type": "Type", + "channelPicker": "Selection du salon" + } + }, + "status": { + "enabled": "🟢 Active", + "disabled": "🔴 Desactive" + }, + "channelNotConfigured": "#non-configure", + "channelPickerPlaceholder": "Selectionne un salon d au revoir", + "channelPickerHint": "Choisis le salon dans le select menu ci-dessous.", + "buttons": { + "toggle": "Toggle statut", + "channel": "Configurer salon", + "channelCancel": "Fermer select salon", + "test": "Tester" + }, + "type": { + "placeholder": "Choisir un type de message", + "options": { + "simple": { + "label": "Message simple", + "description": "Envoie un message texte classique" + }, + "embed": { + "label": "Embed", + "description": "Envoie un embed d au revoir" + }, + "container": { + "label": "Container (V2)", + "description": "Envoie un container Components V2" + }, + "image": { + "label": "Image", + "description": "Envoie un embed avec image" + } + } + } + }, + "templates": { + "simple": "👋 {{user}} a quitte **{{guild}}**.", + "embedTitle": "Au revoir", + "embedDescription": "{{user}} a quitte **{{guild}}**.", + "containerTitle": "Au revoir", + "containerDescription": "{{user}} a quitte **{{guild}}**.", + "imageTitle": "Au revoir", + "imageDescription": "du serveur Discord {{guild}}" + } + }, "help": { "name": "aide", "description": "Afficher la liste des commandes ou les details d une commande.", diff --git a/package-lock.json b/package-lock.json index 54c06b4..1c2b415 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "discordjs-framework-template", "version": "1.0.0", "dependencies": { + "@napi-rs/canvas": "^0.1.97", "discord.js": "14.26.2", "dotenv": "16.4.5", "pg": "^8.20.0", @@ -571,6 +572,255 @@ "node": ">=18" } }, + "node_modules/@napi-rs/canvas": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.97.tgz", + "integrity": "sha512-8cFniXvrIEnVwuNSRCW9wirRZbHvrD3JVujdS2P5n5xiJZNZMOZcfOvJ1pb66c7jXMKHHglJEDVJGbm8XWFcXQ==", + "license": "MIT", + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "0.1.97", + "@napi-rs/canvas-darwin-arm64": "0.1.97", + "@napi-rs/canvas-darwin-x64": "0.1.97", + "@napi-rs/canvas-linux-arm-gnueabihf": "0.1.97", + "@napi-rs/canvas-linux-arm64-gnu": "0.1.97", + "@napi-rs/canvas-linux-arm64-musl": "0.1.97", + "@napi-rs/canvas-linux-riscv64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-gnu": "0.1.97", + "@napi-rs/canvas-linux-x64-musl": "0.1.97", + "@napi-rs/canvas-win32-arm64-msvc": "0.1.97", + "@napi-rs/canvas-win32-x64-msvc": "0.1.97" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.97.tgz", + "integrity": "sha512-V1c/WVw+NzH8vk7ZK/O8/nyBSCQimU8sfMsB/9qeSvdkGKNU7+mxy/bIF0gTgeBFmHpj30S4E9WHMSrxXGQuVQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.97.tgz", + "integrity": "sha512-ok+SCEF4YejcxuJ9Rm+WWunHHpf2HmiPxfz6z1a/NFQECGXtsY7A4B8XocK1LmT1D7P174MzwPF9Wy3AUAwEPw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.97.tgz", + "integrity": "sha512-PUP6e6/UGlclUvAQNnuXCcnkpdUou6VYZfQOQxExLp86epOylmiwLkqXIvpFmjoTEDmPmXrI+coL/9EFU1gKPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.97.tgz", + "integrity": "sha512-XyXH2L/cic8eTNtbrXCcvqHtMX/nEOxN18+7rMrAM2XtLYC/EB5s0wnO1FsLMWmK+04ZSLN9FBGipo7kpIkcOw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.97.tgz", + "integrity": "sha512-Kuq/M3djq0K8ktgz6nPlK7Ne5d4uWeDxPpyKWOjWDK2RIOhHVtLtyLiJw2fuldw7Vn4mhw05EZXCEr4Q76rs9w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.97.tgz", + "integrity": "sha512-kKmSkQVnWeqg7qdsiXvYxKhAFuHz3tkBjW/zyQv5YKUPhotpaVhpBGv5LqCngzyuRV85SXoe+OFj+Tv0a0QXkQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.97.tgz", + "integrity": "sha512-Jc7I3A51jnEOIAXeLsN/M/+Z28LUeakcsXs07FLq9prXc0eYOtVwsDEv913Gr+06IRo34gJJVgT0TXvmz+N2VA==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.97.tgz", + "integrity": "sha512-iDUBe7AilfuBSRbSa8/IGX38Mf+iCSBqoVKLSQ5XaY2JLOaqz1TVyPFEyIck7wT6mRQhQt5sN6ogfjIDfi74tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.97.tgz", + "integrity": "sha512-AKLFd/v0Z5fvgqBDqhvqtAdx+fHMJ5t9JcUNKq4FIZ5WH+iegGm8HPdj00NFlCSnm83Fp3Ln8I2f7uq1aIiWaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.97.tgz", + "integrity": "sha512-u883Yr6A6fO7Vpsy9YE4FVCIxzzo5sO+7pIUjjoDLjS3vQaNMkVzx5bdIpEL+ob+gU88WDK4VcxYMZ6nmnoX9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "0.1.97", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.97.tgz", + "integrity": "sha512-sWtD2EE3fV0IzN+iiQUqr/Q1SwqWhs2O1FKItFlxtdDkikpEj5g7DKQpY3x55H/MAOnL8iomnlk3mcEeGiUMoQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@sapphire/async-queue": { "version": "1.5.5", "resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz", diff --git a/package.json b/package.json index bb95b08..e08600b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "deploy:commands": "tsx src/scripts/deployCommands.ts" }, "dependencies": { + "@napi-rs/canvas": "^0.1.97", "discord.js": "14.26.2", "dotenv": "16.4.5", "pg": "^8.20.0", diff --git a/src/commands/index.ts b/src/commands/index.ts index dcdeb9c..c185652 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,9 +1,19 @@ import { helpCommand } from "./core/help.js"; import { kissCommand } from "./fun/kiss.js"; import { advancedCommand } from "./utility/advanced.js"; +import { goodbyeCommand } from "./utility/goodbye.js"; import { presenceCommand } from "./utility/presence.js"; import { pingCommand } from "./utility/ping.js"; +import { welcomeCommand } from "./utility/welcome.js"; import type { BotCommand } from "../framework/types/command.js"; -export const commandList: BotCommand[] = [kissCommand, pingCommand, advancedCommand, presenceCommand, helpCommand]; +export const commandList: BotCommand[] = [ + kissCommand, + pingCommand, + advancedCommand, + welcomeCommand, + goodbyeCommand, + presenceCommand, + helpCommand, +]; diff --git a/src/commands/utility/goodbye.ts b/src/commands/utility/goodbye.ts new file mode 100644 index 0000000..09dacb8 --- /dev/null +++ b/src/commands/utility/goodbye.ts @@ -0,0 +1,19 @@ +import { PermissionFlagsBits } from "discord.js"; + +import { defineCommand } from "../../framework/commands/defineCommand.js"; +import { createMemberMessageExecute } from "./memberMessagePanel.js"; + +export const goodbyeCommand = defineCommand({ + meta: { + name: "goodbye", + category: "utility", + }, + permissions: [PermissionFlagsBits.ManageGuild], + examples: [ + { + source: "slash", + descriptionKey: "examples.slash", + }, + ], + execute: createMemberMessageExecute("goodbye"), +}); diff --git a/src/commands/utility/memberMessagePanel.ts b/src/commands/utility/memberMessagePanel.ts new file mode 100644 index 0000000..ddd4c02 --- /dev/null +++ b/src/commands/utility/memberMessagePanel.ts @@ -0,0 +1,407 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ChannelSelectMenuBuilder, + ChannelType, + ContainerBuilder, + MessageFlags, + StringSelectMenuBuilder, + TextDisplayBuilder, + type Message, +} from "discord.js"; + +import { env } from "../../framework/config/env.js"; +import { I18nService } from "../../framework/i18n/I18nService.js"; +import { dispatchMemberMessage } from "../../framework/memberMessages/memberMessageSender.js"; +import { getMemberMessageStore } from "../../framework/memberMessages/memberMessageStore.js"; +import { + MEMBER_MESSAGE_RENDER_TYPES, + isMemberMessageRenderTypeValue, + type MemberMessageConfig, + type MemberMessageKind, + type MemberMessageRenderType, +} from "../../framework/memberMessages/memberMessageTypes.js"; +import type { CommandExecutionContext } from "../../framework/types/command.js"; + +const memberMessageI18n = new I18nService(env.DEFAULT_LANG); + +interface MemberMessageCustomIds { + toggleButton: string; + channelButton: string; + channelCancelButton: string; + typeSelect: string; + channelSelect: string; + testButton: string; +} + +interface MemberMessagePanelUiState { + channelPickerOpen: boolean; +} + +interface MemberMessagePanelSession { + collector: ReturnType; + disable: () => Promise; +} + +const activePanelsByUser = new Map(); + +const panelSessionKey = (kind: MemberMessageKind, guildId: string, userId: string): string => { + return `${kind}:${guildId}:${userId}`; +}; + +const createCustomIds = (kind: MemberMessageKind): MemberMessageCustomIds => { + const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`; + + return { + toggleButton: `${kind}:toggle:${nonce}`, + channelButton: `${kind}:channel:${nonce}`, + channelCancelButton: `${kind}:channel-cancel:${nonce}`, + typeSelect: `${kind}:type:${nonce}`, + channelSelect: `${kind}:channel-select:${nonce}`, + testButton: `${kind}:test:${nonce}`, + }; +}; + +const isMessageResult = (value: unknown): value is Message => { + if (!value || typeof value !== "object") { + return false; + } + + return "createMessageComponentCollector" in value && "edit" in value; +}; + +const resolveReplyMessage = (value: unknown): Message | null => { + if (isMessageResult(value)) { + return value; + } + + if (!value || typeof value !== "object" || !("resource" in value)) { + return null; + } + + const resource = (value as { resource?: unknown }).resource; + if (!resource || typeof resource !== "object" || !("message" in resource)) { + return null; + } + + const message = (resource as { message?: unknown }).message; + return isMessageResult(message) ? message : null; +}; + +const statusLabel = (ctx: CommandExecutionContext, enabled: boolean): string => { + return enabled ? ctx.ct("ui.status.enabled") : ctx.ct("ui.status.disabled"); +}; + +const messageTypeLabel = (ctx: CommandExecutionContext, messageType: MemberMessageRenderType): string => { + return ctx.ct(`ui.type.options.${messageType}.label`); +}; + +const panelContent = ( + ctx: CommandExecutionContext, + config: MemberMessageConfig, + uiState: MemberMessagePanelUiState, +): string => { + const channelDisplay = config.channelId ? `<#${config.channelId}>` : ctx.ct("ui.channelNotConfigured"); + const lines = [ + `## ${ctx.ct("ui.embed.title")}`, + ctx.ct("ui.embed.description"), + "", + `${ctx.ct("ui.embed.fields.status")}: ${statusLabel(ctx, config.enabled)}`, + `${ctx.ct("ui.embed.fields.channel")}: ${channelDisplay}`, + `${ctx.ct("ui.embed.fields.type")}: ${messageTypeLabel(ctx, config.messageType)}`, + ]; + + if (uiState.channelPickerOpen) { + lines.push("", `${ctx.ct("ui.embed.fields.channelPicker")}: ${ctx.ct("ui.channelPickerHint")}`); + } + + return lines.join("\n"); +}; + +const buildContainer = ( + ctx: CommandExecutionContext, + customIds: MemberMessageCustomIds, + config: MemberMessageConfig, + uiState: MemberMessagePanelUiState, + disabled = false, +): ContainerBuilder => { + const toggleButton = new ButtonBuilder() + .setCustomId(customIds.toggleButton) + .setLabel(ctx.ct("ui.buttons.toggle")) + .setStyle(config.enabled ? ButtonStyle.Success : ButtonStyle.Secondary) + .setDisabled(disabled); + + const channelButton = new ButtonBuilder() + .setCustomId(uiState.channelPickerOpen ? customIds.channelCancelButton : customIds.channelButton) + .setLabel(uiState.channelPickerOpen ? ctx.ct("ui.buttons.channelCancel") : ctx.ct("ui.buttons.channel")) + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled); + + const testButton = new ButtonBuilder() + .setCustomId(customIds.testButton) + .setLabel(ctx.ct("ui.buttons.test")) + .setStyle(ButtonStyle.Primary) + .setDisabled(disabled); + + const typeSelect = new StringSelectMenuBuilder() + .setCustomId(customIds.typeSelect) + .setPlaceholder(ctx.ct("ui.type.placeholder")) + .setMinValues(1) + .setMaxValues(1) + .setDisabled(disabled) + .setOptions( + MEMBER_MESSAGE_RENDER_TYPES.map((renderType) => ({ + label: ctx.ct(`ui.type.options.${renderType}.label`), + description: ctx.ct(`ui.type.options.${renderType}.description`), + value: renderType, + default: renderType === config.messageType, + })), + ); + + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(panelContent(ctx, config, uiState)), + ); + container.addActionRowComponents( + new ActionRowBuilder().addComponents(toggleButton, channelButton, testButton), + ); + container.addActionRowComponents( + new ActionRowBuilder().addComponents(typeSelect), + ); + + if (uiState.channelPickerOpen) { + const channelSelect = new ChannelSelectMenuBuilder() + .setCustomId(customIds.channelSelect) + .setPlaceholder(ctx.ct("ui.channelPickerPlaceholder")) + .setMinValues(1) + .setMaxValues(1) + .setDisabled(disabled) + .setChannelTypes(ChannelType.GuildText, ChannelType.GuildAnnouncement); + + container.addActionRowComponents( + new ActionRowBuilder().addComponents(channelSelect), + ); + } + + return container; +}; + +const testFeedbackKey = (reason: string): string => { + switch (reason) { + case "disabled": + return "responses.testDisabled"; + case "missing_channel": + return "responses.testMissingChannel"; + case "channel_not_found": + case "channel_not_sendable": + return "responses.testChannelUnavailable"; + case "missing_permissions": + return "responses.testMissingPermissions"; + default: + return "responses.testFailed"; + } +}; + +export const createMemberMessageExecute = (kind: MemberMessageKind) => { + return async (ctx: CommandExecutionContext): Promise => { + if (!ctx.guild) { + await ctx.reply(ctx.ct("responses.guildOnly")); + return; + } + + const guild = ctx.guild; + const botId = ctx.client.user?.id; + if (!botId) { + await ctx.reply(ctx.t("errors.execution")); + return; + } + + const config = await getMemberMessageStore().getByBotGuildKind(botId, guild.id, kind); + const customIds = createCustomIds(kind); + const uiState: MemberMessagePanelUiState = { + channelPickerOpen: false, + }; + + const replyResult = await ctx.reply({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, customIds, config, uiState)], + withResponse: true, + }); + + const replyMessage = resolveReplyMessage(replyResult); + if (!replyMessage) { + return; + } + + const ownerId = ctx.user.id; + const sessionKey = panelSessionKey(kind, guild.id, ownerId); + const existingPanel = activePanelsByUser.get(sessionKey); + if (existingPanel) { + existingPanel.collector.stop("replaced"); + await existingPanel.disable().catch(() => undefined); + activePanelsByUser.delete(sessionKey); + } + + const saveConfig = async (): Promise => { + await getMemberMessageStore().upsertByBotGuildKind(botId, guild.id, kind, config); + }; + + const disablePanel = async (): Promise => { + await replyMessage + .edit({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, customIds, config, uiState, true)], + }) + .catch(() => undefined); + }; + + const collector = replyMessage.createMessageComponentCollector({ time: 15 * 60_000 }); + activePanelsByUser.set(sessionKey, { + collector, + disable: disablePanel, + }); + + collector.on("collect", async (interaction) => { + if (interaction.user.id !== ownerId) { + await interaction.reply({ + content: ctx.ct("responses.notOwner"), + flags: [MessageFlags.Ephemeral], + }); + return; + } + + try { + if (interaction.isButton()) { + if (interaction.customId === customIds.toggleButton) { + config.enabled = !config.enabled; + await saveConfig(); + await interaction.update({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, customIds, config, uiState)], + }); + return; + } + + if (interaction.customId === customIds.channelButton) { + uiState.channelPickerOpen = true; + await interaction.update({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, customIds, config, uiState)], + }); + return; + } + + if (interaction.customId === customIds.channelCancelButton) { + uiState.channelPickerOpen = false; + await interaction.update({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, customIds, config, uiState)], + }); + return; + } + + if (interaction.customId === customIds.testButton) { + await interaction.deferReply({ flags: [MessageFlags.Ephemeral] }); + const testResult = await dispatchMemberMessage({ + client: ctx.client, + i18n: memberMessageI18n, + guild, + user: ctx.user, + kind, + ignoreEnabled: true, + }); + + if (testResult.sent) { + await interaction.editReply({ + content: ctx.ct("responses.testSuccess", { + channel: testResult.channelId ? `<#${testResult.channelId}>` : ctx.ct("ui.channelNotConfigured"), + }), + }); + return; + } + + await interaction.editReply({ + content: ctx.ct(testFeedbackKey(testResult.reason)), + }); + return; + } + + await interaction.reply({ + content: ctx.ct("responses.invalidSelection"), + flags: [MessageFlags.Ephemeral], + }); + return; + } + + if (interaction.isStringSelectMenu() && interaction.customId === customIds.typeSelect) { + const nextType = interaction.values[0]; + if (!nextType || !isMemberMessageRenderTypeValue(nextType)) { + await interaction.reply({ + content: ctx.ct("responses.invalidSelection"), + flags: [MessageFlags.Ephemeral], + }); + return; + } + + config.messageType = nextType; + await saveConfig(); + await interaction.update({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, customIds, config, uiState)], + }); + return; + } + + if (interaction.isChannelSelectMenu() && interaction.customId === customIds.channelSelect) { + const channelId = interaction.values[0]; + if (!channelId) { + await interaction.reply({ + content: ctx.ct("responses.invalidSelection"), + flags: [MessageFlags.Ephemeral], + }); + return; + } + + config.channelId = channelId; + uiState.channelPickerOpen = false; + await saveConfig(); + await interaction.update({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, customIds, config, uiState)], + }); + return; + } + + await interaction.reply({ + content: ctx.ct("responses.invalidSelection"), + flags: [MessageFlags.Ephemeral], + }); + } catch (error) { + console.error(`[command:${kind}] interaction failed`, error); + + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ + content: ctx.t("errors.execution"), + flags: [MessageFlags.Ephemeral], + }).catch(() => undefined); + return; + } + + await interaction.followUp({ + content: ctx.t("errors.execution"), + flags: [MessageFlags.Ephemeral], + }).catch(() => undefined); + } + }); + + collector.on("end", async () => { + const currentPanel = activePanelsByUser.get(sessionKey); + if (currentPanel?.collector === collector) { + activePanelsByUser.delete(sessionKey); + } + + await disablePanel(); + }); + }; +}; diff --git a/src/commands/utility/welcome.ts b/src/commands/utility/welcome.ts new file mode 100644 index 0000000..547622f --- /dev/null +++ b/src/commands/utility/welcome.ts @@ -0,0 +1,19 @@ +import { PermissionFlagsBits } from "discord.js"; + +import { defineCommand } from "../../framework/commands/defineCommand.js"; +import { createMemberMessageExecute } from "./memberMessagePanel.js"; + +export const welcomeCommand = defineCommand({ + meta: { + name: "welcome", + category: "utility", + }, + permissions: [PermissionFlagsBits.ManageGuild], + examples: [ + { + source: "slash", + descriptionKey: "examples.slash", + }, + ], + execute: createMemberMessageExecute("welcome"), +}); diff --git a/src/events/memberMessageEvents.ts b/src/events/memberMessageEvents.ts new file mode 100644 index 0000000..e3fb13c --- /dev/null +++ b/src/events/memberMessageEvents.ts @@ -0,0 +1,30 @@ +import { Events, type Client } from "discord.js"; + +import type { I18nService } from "../framework/i18n/I18nService.js"; +import { dispatchMemberMessage } from "../framework/memberMessages/memberMessageSender.js"; + +export const registerMemberMessageEvents = (client: Client, i18n: I18nService): void => { + client.on(Events.GuildMemberAdd, (member) => { + void dispatchMemberMessage({ + client, + i18n, + guild: member.guild, + user: member.user, + kind: "welcome", + }).catch((error) => { + console.error("[event:guildMemberAdd] failed to send welcome message", error); + }); + }); + + client.on(Events.GuildMemberRemove, (member) => { + void dispatchMemberMessage({ + client, + i18n, + guild: member.guild, + user: member.user, + kind: "goodbye", + }).catch((error) => { + console.error("[event:guildMemberRemove] failed to send goodbye message", error); + }); + }); +}; diff --git a/src/framework/memberMessages/memberMessageImage.ts b/src/framework/memberMessages/memberMessageImage.ts new file mode 100644 index 0000000..195f69c --- /dev/null +++ b/src/framework/memberMessages/memberMessageImage.ts @@ -0,0 +1,185 @@ +import { createCanvas, loadImage, type SKRSContext2D } from "@napi-rs/canvas"; + +import type { MemberMessageKind } from "./memberMessageTypes.js"; + +interface MemberMessageImageInput { + kind: MemberMessageKind; + title: string; + subtitle: string; + username: string; + avatarUrl: string; +} + +const CARD_WIDTH = 1024; +const CARD_HEIGHT = 320; +const AVATAR_SIZE = 220; +const AVATAR_X = 42; +const AVATAR_Y = (CARD_HEIGHT - AVATAR_SIZE) / 2; +const TEXT_X = AVATAR_X + AVATAR_SIZE + 56; +const TEXT_MAX_WIDTH = CARD_WIDTH - TEXT_X - 36; + +const fitFontSize = ( + context: SKRSContext2D, + text: string, + startSize: number, + minSize: number, + maxWidth: number, + fontWeight = 700, +): number => { + let size = startSize; + + while (size > minSize) { + context.font = `${fontWeight} ${size}px sans-serif`; + if (context.measureText(text).width <= maxWidth) { + break; + } + + size -= 2; + } + + return size; +}; + +const wrapText = ( + context: SKRSContext2D, + text: string, + maxWidth: number, + maxLines: number, +): string[] => { + const words = text.trim().split(/\s+/g).filter((value) => value.length > 0); + if (words.length === 0) { + return [""]; + } + + const lines: string[] = []; + let current = ""; + + for (const word of words) { + const next = current.length === 0 ? word : `${current} ${word}`; + if (context.measureText(next).width <= maxWidth) { + current = next; + continue; + } + + if (current.length > 0) { + lines.push(current); + current = word; + } else { + lines.push(word); + current = ""; + } + + if (lines.length >= maxLines - 1) { + break; + } + } + + if (lines.length < maxLines && current.length > 0) { + lines.push(current); + } + + if (lines.length > maxLines) { + lines.length = maxLines; + } + + const lastLine = lines[maxLines - 1]; + if (lastLine && lines.length === maxLines) { + let value = lastLine; + while (context.measureText(`${value}...`).width > maxWidth && value.length > 0) { + value = value.slice(0, -1); + } + + lines[maxLines - 1] = value.length === lastLine.length ? value : `${value}...`; + } + + return lines; +}; + +const drawAvatar = async ( + context: SKRSContext2D, + username: string, + avatarUrl: string, +): Promise => { + context.fillStyle = "#1f2937"; + context.beginPath(); + context.arc(AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2, AVATAR_SIZE / 2, 0, Math.PI * 2); + context.closePath(); + context.fill(); + + try { + const avatar = await loadImage(avatarUrl); + context.save(); + context.beginPath(); + context.arc(AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2, AVATAR_SIZE / 2, 0, Math.PI * 2); + context.closePath(); + context.clip(); + context.drawImage(avatar, AVATAR_X, AVATAR_Y, AVATAR_SIZE, AVATAR_SIZE); + context.restore(); + } catch { + const initial = username.trim().charAt(0).toUpperCase() || "?"; + context.fillStyle = "#f8fafc"; + context.font = "700 92px sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.fillText(initial, AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2); + context.textAlign = "left"; + context.textBaseline = "alphabetic"; + } + + context.strokeStyle = "rgba(248, 250, 252, 0.9)"; + context.lineWidth = 6; + context.beginPath(); + context.arc(AVATAR_X + AVATAR_SIZE / 2, AVATAR_Y + AVATAR_SIZE / 2, AVATAR_SIZE / 2 - 3, 0, Math.PI * 2); + context.closePath(); + context.stroke(); +}; + +export const renderMemberMessageImage = async (input: MemberMessageImageInput): Promise => { + const canvas = createCanvas(CARD_WIDTH, CARD_HEIGHT); + const context = canvas.getContext("2d"); + + const gradient = context.createLinearGradient(0, 0, CARD_WIDTH, CARD_HEIGHT); + if (input.kind === "welcome") { + gradient.addColorStop(0, "#2b303b"); + gradient.addColorStop(1, "#1e232d"); + } else { + gradient.addColorStop(0, "#36252a"); + gradient.addColorStop(1, "#241b21"); + } + + context.fillStyle = gradient; + context.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT); + + context.globalAlpha = 0.14; + context.fillStyle = input.kind === "welcome" ? "#93c5fd" : "#fca5a5"; + context.beginPath(); + context.arc(CARD_WIDTH - 110, -20, 220, 0, Math.PI * 2); + context.closePath(); + context.fill(); + context.globalAlpha = 1; + + await drawAvatar(context, input.username, input.avatarUrl); + + const headlineSize = fitFontSize(context, input.title, 84, 52, TEXT_MAX_WIDTH, 700); + context.font = `700 ${headlineSize}px sans-serif`; + context.fillStyle = "#f8fafc"; + context.fillText(input.title, TEXT_X, 116); + + const subtitleSize = fitFontSize(context, input.subtitle, 58, 30, TEXT_MAX_WIDTH, 500); + context.font = `500 ${subtitleSize}px sans-serif`; + context.fillStyle = "#e5e7eb"; + + const subtitleLines = wrapText(context, input.subtitle, TEXT_MAX_WIDTH, 2); + const subtitleStartY = 162; + const subtitleLineHeight = subtitleSize + 8; + subtitleLines.forEach((line, index) => { + context.fillText(line, TEXT_X, subtitleStartY + index * subtitleLineHeight); + }); + + const usernameSize = fitFontSize(context, input.username, 54, 28, TEXT_MAX_WIDTH, 700); + context.font = `700 ${usernameSize}px sans-serif`; + context.fillStyle = input.kind === "welcome" ? "#93c5fd" : "#fda4af"; + context.fillText(input.username, TEXT_X, CARD_HEIGHT - 34); + + return canvas.toBuffer("image/png"); +}; diff --git a/src/framework/memberMessages/memberMessageSender.ts b/src/framework/memberMessages/memberMessageSender.ts new file mode 100644 index 0000000..40c2a07 --- /dev/null +++ b/src/framework/memberMessages/memberMessageSender.ts @@ -0,0 +1,295 @@ +import { + AttachmentBuilder, + ContainerBuilder, + EmbedBuilder, + MessageFlags, + PermissionFlagsBits, + TextDisplayBuilder, + type Client, + type Guild, + type MessageCreateOptions, + type User, +} from "discord.js"; + +import type { I18nService } from "../i18n/I18nService.js"; +import type { SupportedLang } from "../types/command.js"; +import { renderMemberMessageImage } from "./memberMessageImage.js"; +import { getMemberMessageStore } from "./memberMessageStore.js"; +import { + type MemberMessageKind, + type MemberMessageRenderType, +} from "./memberMessageTypes.js"; + +export type DispatchMemberMessageFailureReason = + | "bot_not_ready" + | "disabled" + | "missing_channel" + | "channel_not_found" + | "channel_not_sendable" + | "missing_permissions" + | "send_failed"; + +export interface DispatchMemberMessageResult { + sent: boolean; + reason: DispatchMemberMessageFailureReason | "sent"; + channelId: string | null; +} + +interface DispatchMemberMessageInput { + client: Client; + i18n?: I18nService; + guild: Guild; + user: User; + kind: MemberMessageKind; + ignoreEnabled?: boolean; +} + +interface SendableChannel { + send: (payload: string | MessageCreateOptions) => Promise; +} + +const hasSendMethod = (value: unknown): value is SendableChannel => { + if (!value || typeof value !== "object") { + return false; + } + + return "send" in value && typeof (value as { send?: unknown }).send === "function"; +}; + +const hasCode = (error: unknown): error is { code: number } => { + if (!error || typeof error !== "object") { + return false; + } + + return "code" in error && typeof (error as { code?: unknown }).code === "number"; +}; + +const messageTemplateVars = (guild: Guild, user: User): Record => ({ + user: `<@${user.id}>`, + username: user.username, + guild: guild.name, +}); + +const messageColor = (kind: MemberMessageKind): number => { + return kind === "welcome" ? 0x57f287 : 0xed4245; +}; + +type TemplateSuffix = + | "simple" + | "embedTitle" + | "embedDescription" + | "containerTitle" + | "containerDescription" + | "imageTitle" + | "imageDescription"; + +const defaultTemplate = ( + kind: MemberMessageKind, + suffix: TemplateSuffix, + vars: Record, +): string => { + if (kind === "welcome") { + switch (suffix) { + case "simple": + return `🎉 Welcome ${vars.user} to **${vars.guild}**!`; + case "embedTitle": + return "Welcome!"; + case "embedDescription": + return `${vars.user} just joined **${vars.guild}**.`; + case "containerTitle": + return "Welcome"; + case "containerDescription": + return `${vars.user} just joined **${vars.guild}**.`; + case "imageTitle": + return "New member"; + case "imageDescription": + return `Glad to have you here, ${vars.user}!`; + default: + return ""; + } + } + + switch (suffix) { + case "simple": + return `👋 ${vars.user} left **${vars.guild}**.`; + case "embedTitle": + return "Goodbye"; + case "embedDescription": + return `${vars.user} has left **${vars.guild}**.`; + case "containerTitle": + return "Goodbye"; + case "containerDescription": + return `${vars.user} has left **${vars.guild}**.`; + case "imageTitle": + return "Member left"; + case "imageDescription": + return `${vars.user} has left the server.`; + default: + return ""; + } +}; + +const resolveTemplate = ( + i18n: I18nService | undefined, + lang: SupportedLang, + kind: MemberMessageKind, + suffix: TemplateSuffix, + vars: Record, +): string => { + if (!i18n) { + return defaultTemplate(kind, suffix, vars); + } + + const key = `commands.${kind}.templates.${suffix}`; + const translated = i18n.t(lang, key, vars); + return translated === key ? defaultTemplate(kind, suffix, vars) : translated; +}; + +const buildMemberMessagePayload = async ( + i18n: I18nService | undefined, + lang: SupportedLang, + kind: MemberMessageKind, + renderType: MemberMessageRenderType, + guild: Guild, + user: User, +): Promise => { + const vars = messageTemplateVars(guild, user); + const allowedMentions: NonNullable = { + parse: [], + users: [user.id], + }; + + if (renderType === "simple") { + return { + content: resolveTemplate(i18n, lang, kind, "simple", vars), + allowedMentions, + }; + } + + if (renderType === "embed") { + return { + allowedMentions, + embeds: [ + new EmbedBuilder() + .setColor(messageColor(kind)) + .setTitle(resolveTemplate(i18n, lang, kind, "embedTitle", vars)) + .setDescription(resolveTemplate(i18n, lang, kind, "embedDescription", vars)), + ], + }; + } + + if (renderType === "container") { + const container = new ContainerBuilder(); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent( + `## ${resolveTemplate(i18n, lang, kind, "containerTitle", vars)}\n${resolveTemplate(i18n, lang, kind, "containerDescription", vars)}`, + ), + ); + + return { + flags: MessageFlags.IsComponentsV2, + components: [container], + allowedMentions, + }; + } + + const imageBuffer = await renderMemberMessageImage({ + kind, + title: resolveTemplate(i18n, lang, kind, "imageTitle", vars), + subtitle: resolveTemplate(i18n, lang, kind, "imageDescription", vars), + username: user.globalName ?? user.username, + avatarUrl: user.displayAvatarURL({ extension: "png", size: 512 }), + }); + + const fileName = `${kind}-${guild.id}-${user.id}.png`; + + return { + allowedMentions, + content: resolveTemplate(i18n, lang, kind, "simple", vars), + files: [new AttachmentBuilder(imageBuffer, { name: fileName })], + }; +}; + +export const dispatchMemberMessage = async (input: DispatchMemberMessageInput): Promise => { + const botId = input.client.user?.id; + if (!botId) { + return { + sent: false, + reason: "bot_not_ready", + channelId: null, + }; + } + + const config = await getMemberMessageStore().getByBotGuildKind(botId, input.guild.id, input.kind); + + if (!input.ignoreEnabled && !config.enabled) { + return { + sent: false, + reason: "disabled", + channelId: config.channelId, + }; + } + + if (!config.channelId) { + return { + sent: false, + reason: "missing_channel", + channelId: null, + }; + } + + const channel = await input.guild.channels.fetch(config.channelId).catch(() => null); + if (!channel) { + return { + sent: false, + reason: "channel_not_found", + channelId: config.channelId, + }; + } + + if (!hasSendMethod(channel)) { + return { + sent: false, + reason: "channel_not_sendable", + channelId: config.channelId, + }; + } + + const me = input.guild.members.me; + if (me && "permissionsFor" in channel && typeof channel.permissionsFor === "function") { + const permissions = channel.permissionsFor(me); + if (!permissions || !permissions.has(PermissionFlagsBits.ViewChannel) || !permissions.has(PermissionFlagsBits.SendMessages)) { + return { + sent: false, + reason: "missing_permissions", + channelId: config.channelId, + }; + } + } + + const lang = input.i18n?.resolveLang(input.guild.preferredLocale ?? null) ?? "en"; + const payload = await buildMemberMessagePayload(input.i18n, lang, input.kind, config.messageType, input.guild, input.user); + + try { + await channel.send(payload); + return { + sent: true, + reason: "sent", + channelId: config.channelId, + }; + } catch (error) { + if (hasCode(error) && error.code === 50013) { + return { + sent: false, + reason: "missing_permissions", + channelId: config.channelId, + }; + } + + return { + sent: false, + reason: "send_failed", + channelId: config.channelId, + }; + } +}; diff --git a/src/framework/memberMessages/memberMessageStore.ts b/src/framework/memberMessages/memberMessageStore.ts new file mode 100644 index 0000000..6759c66 --- /dev/null +++ b/src/framework/memberMessages/memberMessageStore.ts @@ -0,0 +1,153 @@ +import { Pool } from "pg"; + +import { env } from "../config/env.js"; +import { + createDefaultMemberMessageConfig, + isMemberMessageRenderTypeValue, + type MemberMessageConfig, + type MemberMessageKind, +} from "./memberMessageTypes.js"; + +interface MemberMessageRow { + enabled: boolean; + channel_id: string | null; + message_type: string; +} + +const tableSql = ` +CREATE TABLE IF NOT EXISTS bot_member_message_configs ( + bot_id TEXT NOT NULL, + guild_id TEXT NOT NULL, + kind TEXT NOT NULL, + enabled BOOLEAN NOT NULL DEFAULT FALSE, + channel_id TEXT, + message_type TEXT NOT NULL DEFAULT 'simple', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (bot_id, guild_id, kind) +); +`; + +const migrationSql = ` +ALTER TABLE bot_member_message_configs + ADD COLUMN IF NOT EXISTS enabled BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE bot_member_message_configs + ADD COLUMN IF NOT EXISTS channel_id TEXT; + +ALTER TABLE bot_member_message_configs + ADD COLUMN IF NOT EXISTS message_type TEXT NOT NULL DEFAULT 'simple'; +`; + +const toConfig = (row: MemberMessageRow): MemberMessageConfig => { + const fallback = createDefaultMemberMessageConfig(); + + return { + enabled: row.enabled, + channelId: row.channel_id, + messageType: isMemberMessageRenderTypeValue(row.message_type) ? row.message_type : fallback.messageType, + }; +}; + +class MemberMessageStore { + public constructor(private readonly pool: Pool) {} + + public async init(): Promise { + await this.pool.query(tableSql); + await this.pool.query(migrationSql); + } + + public async getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise { + const result = await this.pool.query( + ` + SELECT enabled, channel_id, message_type + FROM bot_member_message_configs + WHERE bot_id = $1 AND guild_id = $2 AND kind = $3 + LIMIT 1 + `, + [botId, guildId, kind], + ); + + const row = result.rows[0]; + if (!row) { + return createDefaultMemberMessageConfig(); + } + + return toConfig(row); + } + + public async upsertByBotGuildKind( + botId: string, + guildId: string, + kind: MemberMessageKind, + config: MemberMessageConfig, + ): Promise { + await this.pool.query( + ` + INSERT INTO bot_member_message_configs ( + bot_id, + guild_id, + kind, + enabled, + channel_id, + message_type, + updated_at + ) + VALUES ($1, $2, $3, $4, $5, $6, NOW()) + ON CONFLICT (bot_id, guild_id, kind) + DO UPDATE SET + enabled = EXCLUDED.enabled, + channel_id = EXCLUDED.channel_id, + message_type = EXCLUDED.message_type, + updated_at = NOW() + `, + [botId, guildId, kind, config.enabled, config.channelId, config.messageType], + ); + } + + public async close(): Promise { + await this.pool.end(); + } +} + +let store: MemberMessageStore | null = null; + +export const initMemberMessageStore = async (): Promise => { + if (store) { + return store; + } + + const pool = new Pool({ + connectionString: env.DATABASE_URL, + ssl: env.DATABASE_SSL ? { rejectUnauthorized: false } : undefined, + }); + + const nextStore = new MemberMessageStore(pool); + + try { + await nextStore.init(); + store = nextStore; + return nextStore; + } catch (error) { + await pool.end().catch(() => { + // Ignore close errors; the original init error should be preserved. + }); + throw error; + } +}; + +export const getMemberMessageStore = (): MemberMessageStore => { + if (!store) { + throw new Error("MemberMessageStore is not initialized. Call initMemberMessageStore() during bootstrap."); + } + + return store; +}; + +export const shutdownMemberMessageStore = async (): Promise => { + if (!store) { + return; + } + + await store.close(); + store = null; +}; diff --git a/src/framework/memberMessages/memberMessageTypes.ts b/src/framework/memberMessages/memberMessageTypes.ts new file mode 100644 index 0000000..3e0c4a5 --- /dev/null +++ b/src/framework/memberMessages/memberMessageTypes.ts @@ -0,0 +1,27 @@ +export const MEMBER_MESSAGE_KINDS = ["welcome", "goodbye"] as const; +export type MemberMessageKind = (typeof MEMBER_MESSAGE_KINDS)[number]; + +export const MEMBER_MESSAGE_RENDER_TYPES = ["simple", "embed", "container", "image"] as const; +export type MemberMessageRenderType = (typeof MEMBER_MESSAGE_RENDER_TYPES)[number]; + +export const DEFAULT_MEMBER_MESSAGE_RENDER_TYPE: MemberMessageRenderType = "simple"; + +export interface MemberMessageConfig { + enabled: boolean; + channelId: string | null; + messageType: MemberMessageRenderType; +} + +export const createDefaultMemberMessageConfig = (): MemberMessageConfig => ({ + enabled: false, + channelId: null, + messageType: DEFAULT_MEMBER_MESSAGE_RENDER_TYPE, +}); + +export const isMemberMessageKindValue = (value: string): value is MemberMessageKind => { + return MEMBER_MESSAGE_KINDS.includes(value as MemberMessageKind); +}; + +export const isMemberMessageRenderTypeValue = (value: string): value is MemberMessageRenderType => { + return MEMBER_MESSAGE_RENDER_TYPES.includes(value as MemberMessageRenderType); +}; diff --git a/src/index.ts b/src/index.ts index a9b5331..7114755 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import { Client, Events, GatewayIntentBits } from "discord.js"; import { commandList } from "./commands/index.js"; import { restorePresenceFromStorage, shutdownPresenceRuntime } from "./commands/utility/presence.js"; +import { registerMemberMessageEvents } from "./events/memberMessageEvents.js"; import { deployApplicationCommands } from "./framework/commands/deploy.js"; import { CommandRegistry } from "./framework/commands/registry.js"; import { env } from "./framework/config/env.js"; @@ -9,12 +10,19 @@ 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"; +import { + initMemberMessageStore, + shutdownMemberMessageStore, +} from "./framework/memberMessages/memberMessageStore.js"; import { initPresenceStore, shutdownPresenceStore } from "./framework/presence/presenceStore.js"; const bindGracefulShutdown = (): void => { const shutdown = async (signal: string): Promise => { console.log(`[shutdown] ${signal}`); shutdownPresenceRuntime(); + await shutdownMemberMessageStore().catch((error) => { + console.error("[shutdown] member message store close failed", error); + }); await shutdownPresenceStore().catch((error) => { console.error("[shutdown] presence store close failed", error); }); @@ -32,6 +40,7 @@ const bindGracefulShutdown = (): void => { const bootstrap = async (): Promise => { await initPresenceStore(); + await initMemberMessageStore(); bindGracefulShutdown(); process.on("unhandledRejection", (reason) => { @@ -43,7 +52,12 @@ const bootstrap = async (): Promise => { }); const client = new Client({ - intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent], + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], }); const i18n = new I18nService(env.DEFAULT_LANG); @@ -82,6 +96,8 @@ const bootstrap = async (): Promise => { }); }); + registerMemberMessageEvents(client, i18n); + client.once(Events.ClientReady, async () => { console.log(`[ready] logged as ${client.user?.tag ?? "unknown"}`); try { @@ -112,6 +128,9 @@ const bootstrap = async (): Promise => { bootstrap().catch(async (error) => { console.error("[boot] fatal error", error); shutdownPresenceRuntime(); + await shutdownMemberMessageStore().catch((closeError) => { + console.error("[boot] failed to close member message store", closeError); + }); await shutdownPresenceStore().catch((closeError) => { console.error("[boot] failed to close presence store", closeError); });