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.
This commit is contained in:
Puechberty Arthur
2026-04-12 17:04:05 +02:00
parent f6a2e2b970
commit fc0daa671d
15 changed files with 1855 additions and 2 deletions
+146
View File
@@ -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.",
+146
View File
@@ -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.",
+146
View File
@@ -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.",
+250
View File
@@ -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",
+1
View File
@@ -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",
+11 -1
View File
@@ -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,
];
+19
View File
@@ -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"),
});
+407
View File
@@ -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<Message["createMessageComponentCollector"]>;
disable: () => Promise<void>;
}
const activePanelsByUser = new Map<string, MemberMessagePanelSession>();
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<ButtonBuilder>().addComponents(toggleButton, channelButton, testButton),
);
container.addActionRowComponents(
new ActionRowBuilder<StringSelectMenuBuilder>().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<ChannelSelectMenuBuilder>().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<void> => {
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<void> => {
await getMemberMessageStore().upsertByBotGuildKind(botId, guild.id, kind, config);
};
const disablePanel = async (): Promise<void> => {
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();
});
};
};
+19
View File
@@ -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"),
});
+30
View File
@@ -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);
});
});
};
@@ -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<void> => {
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<Buffer> => {
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");
};
@@ -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<unknown>;
}
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<string, string> => ({
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, string>,
): 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, string>,
): 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<MessageCreateOptions> => {
const vars = messageTemplateVars(guild, user);
const allowedMentions: NonNullable<MessageCreateOptions["allowedMentions"]> = {
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<DispatchMemberMessageResult> => {
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,
};
}
};
@@ -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<void> {
await this.pool.query(tableSql);
await this.pool.query(migrationSql);
}
public async getByBotGuildKind(botId: string, guildId: string, kind: MemberMessageKind): Promise<MemberMessageConfig> {
const result = await this.pool.query<MemberMessageRow>(
`
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<void> {
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<void> {
await this.pool.end();
}
}
let store: MemberMessageStore | null = null;
export const initMemberMessageStore = async (): Promise<MemberMessageStore> => {
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<void> => {
if (!store) {
return;
}
await store.close();
store = null;
};
@@ -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);
};
+20 -1
View File
@@ -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<void> => {
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<void> => {
await initPresenceStore();
await initMemberMessageStore();
bindGracefulShutdown();
process.on("unhandledRejection", (reason) => {
@@ -43,7 +52,12 @@ const bootstrap = async (): Promise<void> => {
});
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<void> => {
});
});
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<void> => {
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);
});