mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
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:
+146
@@ -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
@@ -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
@@ -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.",
|
||||
|
||||
Generated
+250
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -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"),
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user