From 58aac5562001c53121ad2aba4da5c87941bca401 Mon Sep 17 00:00:00 2001 From: Puechberty Arthur Date: Sun, 12 Apr 2026 10:46:51 +0200 Subject: [PATCH] feat: add interactive presence command for bot status management - Implemented a new `/presence` command allowing users to configure the bot's presence with an interactive panel. - Added localization support for Spanish and French languages. - Integrated PostgreSQL for storing and retrieving bot presence states. - Created a Dockerfile and docker-compose configuration for easy deployment. - Updated environment configuration to include database connection settings. - Refactored command handling to include the new presence command. - Enhanced error handling and user feedback for the presence command interactions. --- .dockerignore | 11 + .env.example | 7 + Dockerfile | 27 ++ README.md | 43 ++- docker-compose.multi-bot.example.yml | 49 +++ docker-compose.yml | 35 +++ locales/en.json | 89 ++++++ locales/es.json | 89 ++++++ locales/fr.json | 89 ++++++ package-lock.json | 161 ++++++++++ package.json | 2 + src/commands/index.ts | 3 +- src/commands/utility/presence.ts | 378 ++++++++++++++++++++++++ src/framework/config/env.ts | 11 + src/framework/handlers/prefixHandler.ts | 1 - src/framework/presence/presenceStore.ts | 123 ++++++++ src/framework/presence/presenceTypes.ts | 80 +++++ src/index.ts | 31 ++ 18 files changed, 1225 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.multi-bot.example.yml create mode 100644 docker-compose.yml create mode 100644 src/commands/utility/presence.ts create mode 100644 src/framework/presence/presenceStore.ts create mode 100644 src/framework/presence/presenceTypes.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..26898c5 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +node_modules +dist +.git +.gitignore +.env +.env.* +!.env.example +*.log +npm-debug.log* +yarn-error.log* +pnpm-debug.log* diff --git a/.env.example b/.env.example index f42d7c1..9e22ac3 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,12 @@ DISCORD_TOKEN= DISCORD_CLIENT_ID= +DATABASE_URL=postgresql://discord:discord@localhost:5432/discord_bots +DATABASE_SSL=false +PRESENCE_STREAM_URL=https://twitch.tv/discord +POSTGRES_DB=discord_bots +POSTGRES_USER=discord +POSTGRES_PASSWORD=discord +POSTGRES_PORT=5432 PREFIX=+ DEFAULT_LANG=en DEV_GUILD_ID= diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2e49864 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +COPY package.json package-lock.json ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY src ./src +COPY locales ./locales +COPY data ./data + +RUN npm run build + +FROM node:20-alpine AS runner + +WORKDIR /app +ENV NODE_ENV=production + +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev + +COPY --from=builder /app/dist ./dist +COPY locales ./locales +COPY data ./data + +CMD ["node", "dist/index.js"] diff --git a/README.md b/README.md index fc9fa87..65c5f28 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ Professional command framework template for Discord.js `14.26.2` with: - User permission checks - Auto-generated help from command metadata +## Presence Storage + +- The `presence` command is persisted in PostgreSQL. +- Storage is keyed by `bot_id` (Discord user id), so one PostgreSQL instance can serve multiple bots. +- Presence survives bot restarts and container restarts. + ## Setup 1. Install dependencies: @@ -21,11 +27,42 @@ Professional command framework template for Discord.js `14.26.2` with: 3. Fill required values in `.env`: - `DISCORD_TOKEN` - `DISCORD_CLIENT_ID` -4. Deploy slash commands: + - `DATABASE_URL` +4. Optional values: + - `DATABASE_SSL` (`true` for managed cloud DB, `false` for local Docker) + - `PRESENCE_STREAM_URL` (used when activity type is `STREAMING`) +5. Deploy slash commands: npm run deploy:commands -5. Start in development: +6. Start in development: npm run dev +## Docker Deployment (Bot + PostgreSQL) + +1. Configure `.env` (at minimum `DISCORD_TOKEN`, `DISCORD_CLIENT_ID`). +2. Start stack: + docker compose up -d --build +3. Stop stack: + docker compose down + +By default, `docker-compose.yml` provisions: +- `bot`: the Discord bot container +- `postgres`: PostgreSQL 16 with persistent volume `postgres_data` + +The bot container uses: +- `DATABASE_URL=postgresql://:@postgres:5432/` + +## Multi-Bot With One DB + +You can run several bot services against the same PostgreSQL instance. + +- Keep one shared `postgres` service. +- Add additional bot services with different `DISCORD_TOKEN` / `DISCORD_CLIENT_ID`. +- Keep `DATABASE_URL` pointing to the same PostgreSQL service. + +Because rows are keyed by `bot_id`, each bot keeps its own independent presence configuration. + +An example with two bot services is available in `docker-compose.multi-bot.example.yml`. + ## Architecture - `src/framework/types/command.ts`: strict command schema @@ -35,6 +72,8 @@ Professional command framework template for Discord.js `14.26.2` with: - `src/framework/execution/CommandExecutor.ts`: unified pipeline (permissions/execute) - `src/framework/handlers/prefixHandler.ts`: prefix entrypoint - `src/framework/handlers/slashHandler.ts`: slash entrypoint +- `src/framework/presence/presenceStore.ts`: PostgreSQL presence storage +- `src/framework/presence/presenceTypes.ts`: shared presence types/validation - `locales/*.json`: external i18n dictionaries - `src/commands/*`: business commands only (`execute`) diff --git a/docker-compose.multi-bot.example.yml b/docker-compose.multi-bot.example.yml new file mode 100644 index 0000000..633712d --- /dev/null +++ b/docker-compose.multi-bot.example.yml @@ -0,0 +1,49 @@ +services: + bot_alpha: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + env_file: + - .env.bot-alpha + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-discord}:${POSTGRES_PASSWORD:-discord}@postgres:5432/${POSTGRES_DB:-discord_bots} + DATABASE_SSL: "false" + + bot_beta: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + env_file: + - .env.bot-beta + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-discord}:${POSTGRES_PASSWORD:-discord}@postgres:5432/${POSTGRES_DB:-discord_bots} + DATABASE_SSL: "false" + + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-discord_bots} + POSTGRES_USER: ${POSTGRES_USER:-discord} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-discord} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-discord} -d ${POSTGRES_DB:-discord_bots}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + postgres_data: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..0b90680 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,35 @@ +services: + bot: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + env_file: + - .env + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-discord}:${POSTGRES_PASSWORD:-discord}@postgres:5432/${POSTGRES_DB:-discord_bots} + DATABASE_SSL: "false" + + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-discord_bots} + POSTGRES_USER: ${POSTGRES_USER:-discord} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-discord} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-discord} -d ${POSTGRES_DB:-discord_bots}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + +volumes: + postgres_data: diff --git a/locales/en.json b/locales/en.json index 5004ca1..a6b1ad7 100644 --- a/locales/en.json +++ b/locales/en.json @@ -61,6 +61,95 @@ "summary": "Advanced command summary\ntext={{text}}\ncount={{count}}\nratio={{ratio}}\nenabled={{enabled}}\nuser={{user}}\nchannel={{channel}}\nrole={{role}}\nsource={{source}}" } }, + "presence": { + "name": "presence", + "description": "Configure bot presence with an interactive panel.", + "examples": { + "slash": "Open the interactive presence panel" + }, + "responses": { + "slashOnly": "This command is only available as a slash command.", + "panel": "Presence panel\nStatus: {{status}}\nActivity: {{activityType}}\nText: {{activityText}}", + "statusUpdated": "Status updated to {{status}}.", + "activityUpdated": "Activity type updated to {{activityType}}.", + "textUpdated": "Presence text updated to: {{activityText}}", + "notOwner": "Only the command author can use this panel.", + "invalidSelection": "Invalid selection received.", + "modalTimeout": "Text update timed out. Click the button again." + }, + "ui": { + "status": { + "placeholder": "Choose a status", + "options": { + "online": { + "label": "Online", + "description": "Displayed as online" + }, + "idle": { + "label": "Idle", + "description": "Displayed as idle" + }, + "dnd": { + "label": "Do Not Disturb", + "description": "Displayed as do not disturb" + }, + "invisible": { + "label": "Invisible", + "description": "Displayed as offline" + }, + "streaming": { + "label": "Streaming", + "description": "Displayed in streaming mode" + } + } + }, + "activity": { + "placeholder": "Choose an activity type", + "options": { + "PLAYING": { + "label": "Playing", + "description": "Shows as Playing ..." + }, + "STREAMING": { + "label": "Streaming", + "description": "Shows as Streaming ..." + }, + "WATCHING": { + "label": "Watching", + "description": "Shows as Watching ..." + }, + "LISTENING": { + "label": "Listening", + "description": "Shows as Listening to ..." + }, + "COMPETING": { + "label": "Competing", + "description": "Shows as Competing in ..." + }, + "CUSTOM": { + "label": "CustomStatus", + "description": "Shows a custom status" + } + } + }, + "embed": { + "title": "Presence Configuration", + "description": "Change status, activity type, and text in real time.", + "footer": "Interactive /presence panel", + "fields": { + "status": "Status", + "activity": "Activity type", + "text": "Text" + } + }, + "textButton": "📝 Edit text", + "modal": { + "title": "Presence text", + "label": "Activity text", + "placeholder": "Example: 🚀 In maintenance" + } + } + }, "help": { "name": "help", "description": "Display command list or details for one command.", diff --git a/locales/es.json b/locales/es.json index 4e45355..cba826b 100644 --- a/locales/es.json +++ b/locales/es.json @@ -61,6 +61,95 @@ "summary": "Resumen comando avanzada\ntext={{text}}\ncount={{count}}\nratio={{ratio}}\nenabled={{enabled}}\nuser={{user}}\nchannel={{channel}}\nrole={{role}}\nsource={{source}}" } }, + "presence": { + "name": "presence", + "description": "Configurar la presencia del bot con un panel interactivo.", + "examples": { + "slash": "Abrir el panel interactivo de presencia" + }, + "responses": { + "slashOnly": "Este comando solo esta disponible como slash.", + "panel": "Panel de presencia\nEstado: {{status}}\nActividad: {{activityType}}\nTexto: {{activityText}}", + "statusUpdated": "Estado actualizado a {{status}}.", + "activityUpdated": "Tipo de actividad actualizado a {{activityType}}.", + "textUpdated": "Texto de presencia actualizado: {{activityText}}", + "notOwner": "Solo el autor del comando puede usar este panel.", + "invalidSelection": "Seleccion invalida.", + "modalTimeout": "Tiempo agotado para editar el texto. Pulsa el boton otra vez." + }, + "ui": { + "status": { + "placeholder": "Elegir un estado", + "options": { + "online": { + "label": "En linea", + "description": "Muestra el bot en linea" + }, + "idle": { + "label": "Inactivo", + "description": "Muestra el bot inactivo" + }, + "dnd": { + "label": "No molestar", + "description": "Muestra el bot en no molestar" + }, + "invisible": { + "label": "Desconectado", + "description": "Muestra el bot fuera de linea" + }, + "streaming": { + "label": "Streaming", + "description": "Muestra el bot en modo streaming" + } + } + }, + "activity": { + "placeholder": "Elegir un tipo de actividad", + "options": { + "PLAYING": { + "label": "Jugando", + "description": "Muestra Jugando a ..." + }, + "STREAMING": { + "label": "Streaming", + "description": "Muestra En directo en ..." + }, + "WATCHING": { + "label": "Viendo", + "description": "Muestra Viendo ..." + }, + "LISTENING": { + "label": "Escuchando", + "description": "Muestra Escuchando ..." + }, + "COMPETING": { + "label": "Compitiendo", + "description": "Muestra Compitiendo en ..." + }, + "CUSTOM": { + "label": "CustomStatus", + "description": "Muestra un estado personalizado" + } + } + }, + "embed": { + "title": "Configuracion de Presencia", + "description": "Cambia el estado, el tipo de actividad y el texto en tiempo real.", + "footer": "Panel interactivo /presence", + "fields": { + "status": "Estado", + "activity": "Tipo de actividad", + "text": "Texto" + } + }, + "textButton": "📝 Editar texto", + "modal": { + "title": "Texto de presencia", + "label": "Texto de actividad", + "placeholder": "Ejemplo: 🚀 En mantenimiento" + } + } + }, "help": { "name": "ayuda", "description": "Mostrar lista de comandos o detalles de un comando.", diff --git a/locales/fr.json b/locales/fr.json index 44171be..5a12373 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -61,6 +61,95 @@ "summary": "Resume commande avancee\ntext={{text}}\ncount={{count}}\nratio={{ratio}}\nenabled={{enabled}}\nuser={{user}}\nchannel={{channel}}\nrole={{role}}\nsource={{source}}" } }, + "presence": { + "name": "presence", + "description": "Configurer la presence du bot avec un panneau interactif.", + "examples": { + "slash": "Ouvrir le panneau interactif de presence" + }, + "responses": { + "slashOnly": "Cette commande est disponible uniquement en slash.", + "panel": "Panneau presence\nStatut: {{status}}\nActivite: {{activityType}}\nTexte: {{activityText}}", + "statusUpdated": "Statut mis a jour: {{status}}.", + "activityUpdated": "Type d activite mis a jour: {{activityType}}.", + "textUpdated": "Texte de presence mis a jour: {{activityText}}", + "notOwner": "Seul l auteur de la commande peut utiliser ce panneau.", + "invalidSelection": "Selection invalide.", + "modalTimeout": "Temps depasse pour la modification du texte. Clique a nouveau sur le bouton." + }, + "ui": { + "status": { + "placeholder": "Choisir un statut", + "options": { + "online": { + "label": "En ligne", + "description": "Affiche le bot en ligne" + }, + "idle": { + "label": "Inactif", + "description": "Affiche le bot inactif" + }, + "dnd": { + "label": "Ne pas deranger", + "description": "Affiche le bot en mode ne pas deranger" + }, + "invisible": { + "label": "Hors ligne", + "description": "Affiche le bot hors ligne" + }, + "streaming": { + "label": "Streaming", + "description": "Affiche le bot en mode streaming" + } + } + }, + "activity": { + "placeholder": "Choisir un type d activite", + "options": { + "PLAYING": { + "label": "Joue a", + "description": "Affiche Joue a ..." + }, + "STREAMING": { + "label": "Streaming", + "description": "Affiche En direct sur ..." + }, + "WATCHING": { + "label": "Regarde", + "description": "Affiche Regarde ..." + }, + "LISTENING": { + "label": "Ecoute", + "description": "Affiche Ecoute ..." + }, + "COMPETING": { + "label": "Competition", + "description": "Affiche Competition ..." + }, + "CUSTOM": { + "label": "CustomStatus", + "description": "Affiche un statut personnalise" + } + } + }, + "embed": { + "title": "Configuration de la presence", + "description": "Modifie le statut, le type d activite et le texte en direct.", + "footer": "Panneau interactif /presence", + "fields": { + "status": "Statut", + "activity": "Type d activite", + "text": "Texte" + } + }, + "textButton": "📝 Modifier le texte", + "modal": { + "title": "Texte de presence", + "label": "Texte d activite", + "placeholder": "Exemple: 🚀 En maintenance" + } + } + }, "help": { "name": "aide", "description": "Afficher la liste des commandes ou les details d une commande.", diff --git a/package-lock.json b/package-lock.json index 039e5e2..54c06b4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,10 +10,12 @@ "dependencies": { "discord.js": "14.26.2", "dotenv": "16.4.5", + "pg": "^8.20.0", "zod": "3.23.8" }, "devDependencies": { "@types/node": "20.17.24", + "@types/pg": "^8.20.0", "tsx": "4.19.2", "typescript": "5.8.2" }, @@ -611,6 +613,18 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "pg-protocol": "*", + "pg-types": "^2.2.0" + } + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -770,6 +784,135 @@ "integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==", "license": "MIT" }, + "node_modules/pg": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==", + "license": "MIT", + "peer": true, + "dependencies": { + "pg-connection-string": "^2.12.0", + "pg-pool": "^3.13.0", + "pg-protocol": "^1.13.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.3.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz", + "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz", + "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.13.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz", + "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz", + "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -780,6 +923,15 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", @@ -862,6 +1014,15 @@ } } }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/zod": { "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", diff --git a/package.json b/package.json index 9746642..a6745d9 100644 --- a/package.json +++ b/package.json @@ -15,10 +15,12 @@ "dependencies": { "discord.js": "14.26.2", "dotenv": "16.4.5", + "pg": "^8.20.0", "zod": "3.23.8" }, "devDependencies": { "@types/node": "20.17.24", + "@types/pg": "^8.20.0", "tsx": "4.19.2", "typescript": "5.8.2" } diff --git a/src/commands/index.ts b/src/commands/index.ts index 61153ae..dcdeb9c 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -1,8 +1,9 @@ import { helpCommand } from "./core/help.js"; import { kissCommand } from "./fun/kiss.js"; import { advancedCommand } from "./utility/advanced.js"; +import { presenceCommand } from "./utility/presence.js"; import { pingCommand } from "./utility/ping.js"; import type { BotCommand } from "../framework/types/command.js"; -export const commandList: BotCommand[] = [kissCommand, pingCommand, advancedCommand, helpCommand]; +export const commandList: BotCommand[] = [kissCommand, pingCommand, advancedCommand, presenceCommand, helpCommand]; diff --git a/src/commands/utility/presence.ts b/src/commands/utility/presence.ts new file mode 100644 index 0000000..04095dc --- /dev/null +++ b/src/commands/utility/presence.ts @@ -0,0 +1,378 @@ +import { + ActivityType, + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + ContainerBuilder, + MessageFlags, + ModalBuilder, + StringSelectMenuBuilder, + TextDisplayBuilder, + TextInputBuilder, + TextInputStyle, + type Client, + type Message, +} from "discord.js"; +import { defineCommand } from "../../framework/commands/defineCommand.js"; +import { env } from "../../framework/config/env.js"; +import { getPresenceStore } from "../../framework/presence/presenceStore.js"; +import { + PRESENCE_ACTIVITY_TYPES, + PRESENCE_STATUSES, + createDefaultPresenceState, + isPresenceActivityTypeValue, + isPresenceStatusValue, + sanitizeActivityText, + type PresenceActivityTypeValue, + type PresenceState, + type PresenceStatusValue, +} from "../../framework/presence/presenceTypes.js"; +import type { CommandExecutionContext } from "../../framework/types/command.js"; + +interface PresenceCustomIds { + statusSelect: string; + activitySelect: string; + textButton: string; + textModal: string; + textInput: string; +} + +const DISCORD_ACTIVITY_TYPES: Record = { + PLAYING: ActivityType.Playing, + STREAMING: ActivityType.Streaming, + WATCHING: ActivityType.Watching, + LISTENING: ActivityType.Listening, + COMPETING: ActivityType.Competing, + CUSTOM: ActivityType.Custom, +}; + +type DiscordPresenceStatus = "online" | "idle" | "dnd" | "invisible"; + +const resolveDiscordStatus = (status: PresenceStatusValue): DiscordPresenceStatus => + status === "streaming" ? "online" : status; + +const resolveBotId = (client: Client): string | null => client.user?.id ?? null; + +const loadPresenceState = async (client: Client): Promise => { + const botId = resolveBotId(client); + if (!botId) { + return createDefaultPresenceState(); + } + + return getPresenceStore().getByBotId(botId); +}; + +const savePresenceState = async (client: Client, state: PresenceState): Promise => { + const botId = resolveBotId(client); + if (!botId) { + return; + } + + await getPresenceStore().upsertByBotId(botId, state); +}; + +const applyPresenceState = (client: Client, state: PresenceState): void => { + if (!client.user) { + return; + } + + const status = resolveDiscordStatus(state.status); + const text = sanitizeActivityText(state.activity.text); + if (state.status === "streaming" || state.activity.type === "STREAMING") { + client.user.setPresence({ + status, + activities: [ + { + type: ActivityType.Streaming, + name: text, + url: env.PRESENCE_STREAM_URL, + }, + ], + }); + return; + } + + if (state.activity.type === "CUSTOM") { + client.user.setPresence({ + status, + activities: [ + { + type: ActivityType.Custom, + name: "Custom Status", + state: text, + }, + ], + }); + return; + } + + client.user.setPresence({ + status, + activities: [ + { + type: DISCORD_ACTIVITY_TYPES[state.activity.type], + name: text, + }, + ], + }); +}; + +const createCustomIds = (): PresenceCustomIds => { + const nonce = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 9)}`; + return { + statusSelect: `presence:status:${nonce}`, + activitySelect: `presence:activity:${nonce}`, + textButton: `presence:text:${nonce}`, + textModal: `presence:modal:${nonce}`, + textInput: `presence:text-input:${nonce}`, + }; +}; + +const statusLabel = (ctx: CommandExecutionContext, status: PresenceStatusValue): string => + ctx.ct(`ui.status.options.${status}.label`); + +const activityLabel = (ctx: CommandExecutionContext, activityType: PresenceActivityTypeValue): string => + ctx.ct(`ui.activity.options.${activityType}.label`); + +const panelContent = (ctx: CommandExecutionContext, state: PresenceState): string => { + const summary = ctx.ct("responses.panel", { + status: statusLabel(ctx, state.status), + activityType: activityLabel(ctx, state.activity.type), + activityText: sanitizeActivityText(state.activity.text), + }); + + return `## ${ctx.ct("ui.embed.title")}\n${ctx.ct("ui.embed.description")}\n\n${summary}`; +}; + +const buildContainer = ( + ctx: CommandExecutionContext, + state: PresenceState, + customIds: PresenceCustomIds, + disabled = false, +): ContainerBuilder => { + const statusSelect = new StringSelectMenuBuilder() + .setCustomId(customIds.statusSelect) + .setPlaceholder(ctx.ct("ui.status.placeholder")) + .setMinValues(1) + .setMaxValues(1) + .setDisabled(disabled) + .setOptions( + PRESENCE_STATUSES.map((status) => ({ + label: statusLabel(ctx, status), + description: ctx.ct(`ui.status.options.${status}.description`), + value: status, + default: status === state.status, + })), + ); + + const activitySelect = new StringSelectMenuBuilder() + .setCustomId(customIds.activitySelect) + .setPlaceholder(ctx.ct("ui.activity.placeholder")) + .setMinValues(1) + .setMaxValues(1) + .setDisabled(disabled) + .setOptions( + PRESENCE_ACTIVITY_TYPES.map((activityType) => ({ + label: activityLabel(ctx, activityType), + description: ctx.ct(`ui.activity.options.${activityType}.description`), + value: activityType, + default: activityType === state.activity.type, + })), + ); + + const textButton = new ButtonBuilder() + .setCustomId(customIds.textButton) + .setLabel(ctx.ct("ui.textButton")) + .setStyle(ButtonStyle.Secondary) + .setDisabled(disabled); + + const container = new ContainerBuilder(); + + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(panelContent(ctx, state)), + ); + + container.addActionRowComponents( + new ActionRowBuilder().addComponents(statusSelect), + ); + container.addActionRowComponents( + new ActionRowBuilder().addComponents(activitySelect), + ); + container.addActionRowComponents( + new ActionRowBuilder().addComponents(textButton), + ); + + return container; +}; + +const isMessageResult = (value: unknown): value is Message => { + if (!value || typeof value !== "object") { + return false; + } + + return "createMessageComponentCollector" in value && "edit" in value; +}; + +const persistAndApplyPresence = async (ctx: CommandExecutionContext, state: PresenceState): Promise => { + applyPresenceState(ctx.client, state); + await savePresenceState(ctx.client, state); +}; + +export const restorePresenceFromStorage = async (client: Client): Promise => { + const state = await loadPresenceState(client); + applyPresenceState(client, state); +}; + +export const presenceCommand = defineCommand({ + meta: { + name: "presence", + category: "utility", + }, + examples: [ + { + source: "slash", + descriptionKey: "examples.slash", + }, + ], + execute: async (ctx) => { + const state = await loadPresenceState(ctx.client); + applyPresenceState(ctx.client, state); + + const customIds = createCustomIds(); + + const replyResult = await ctx.reply({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, state, customIds)], + fetchReply: true, + }); + + if (!isMessageResult(replyResult)) { + return; + } + + const ownerId = ctx.user.id; + const collector = replyResult.createMessageComponentCollector({ time: 15 * 60_000 }); + + collector.on("collect", async (interaction) => { + if (interaction.user.id !== ownerId) { + await interaction.reply({ + content: ctx.ct("responses.notOwner"), + ephemeral: true, + }); + return; + } + + try { + if (interaction.isStringSelectMenu()) { + if (interaction.customId === customIds.statusSelect) { + const nextStatus = interaction.values[0]; + if (!nextStatus || !isPresenceStatusValue(nextStatus)) { + await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); + return; + } + + state.status = nextStatus; + await persistAndApplyPresence(ctx, state); + await interaction.update({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, state, customIds)], + }); + return; + } + + if (interaction.customId === customIds.activitySelect) { + const nextType = interaction.values[0]; + if (!nextType || !isPresenceActivityTypeValue(nextType)) { + await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); + return; + } + + state.activity.type = nextType; + await persistAndApplyPresence(ctx, state); + await interaction.update({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, state, customIds)], + }); + return; + } + + await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); + return; + } + + if (interaction.isButton()) { + if (interaction.customId !== customIds.textButton) { + await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); + return; + } + + const modal = new ModalBuilder() + .setCustomId(customIds.textModal) + .setTitle(ctx.ct("ui.modal.title")) + .addComponents( + new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId(customIds.textInput) + .setLabel(ctx.ct("ui.modal.label")) + .setStyle(TextInputStyle.Short) + .setPlaceholder(ctx.ct("ui.modal.placeholder")) + .setRequired(true) + .setMaxLength(128) + .setValue(sanitizeActivityText(state.activity.text)), + ), + ); + + await interaction.showModal(modal); + + try { + const submitted = await interaction.awaitModalSubmit({ + time: 120_000, + filter: (modalInteraction) => + modalInteraction.customId === customIds.textModal + && modalInteraction.user.id === ownerId, + }); + + const nextText = sanitizeActivityText(submitted.fields.getTextInputValue(customIds.textInput)); + state.activity.text = nextText; + await persistAndApplyPresence(ctx, state); + + await submitted.deferUpdate(); + + await replyResult.edit({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, state, customIds)], + }); + } catch { + await interaction.followUp({ + content: ctx.ct("responses.modalTimeout"), + ephemeral: true, + }).catch(() => undefined); + } + + return; + } + + await interaction.reply({ content: ctx.ct("responses.invalidSelection"), ephemeral: true }); + } catch (error) { + console.error("[command:presence] interaction failed", error); + const fallback = ctx.t("errors.execution"); + + if (!interaction.replied && !interaction.deferred) { + await interaction.reply({ content: fallback, ephemeral: true }).catch(() => undefined); + return; + } + + await interaction.followUp({ content: fallback, ephemeral: true }).catch(() => undefined); + } + }); + + collector.on("end", async () => { + await replyResult + .edit({ + flags: MessageFlags.IsComponentsV2, + components: [buildContainer(ctx, state, customIds, true)], + }) + .catch(() => undefined); + }); + }, +}); diff --git a/src/framework/config/env.ts b/src/framework/config/env.ts index 7e42792..d4b00bd 100644 --- a/src/framework/config/env.ts +++ b/src/framework/config/env.ts @@ -8,6 +8,17 @@ loadEnv(); const envSchema = z.object({ DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"), DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID is required"), + DATABASE_URL: z.string().optional().transform((value) => value && value.length > 0 ? value : undefined), + DATABASE_SSL: z + .string() + .optional() + .default("false") + .transform((value) => value.toLowerCase() === "true"), + PRESENCE_STREAM_URL: z + .string() + .url("PRESENCE_STREAM_URL must be a valid URL") + .optional() + .default("https://twitch.tv/discord"), PREFIX: z.string().min(1).max(5).default("+"), DEFAULT_LANG: z.enum(SUPPORTED_LANGS).default("en"), DEV_GUILD_ID: z.string().optional().transform((value) => value && value.length > 0 ? value : undefined), diff --git a/src/framework/handlers/prefixHandler.ts b/src/framework/handlers/prefixHandler.ts index 470687f..5450d85 100644 --- a/src/framework/handlers/prefixHandler.ts +++ b/src/framework/handlers/prefixHandler.ts @@ -20,7 +20,6 @@ const toMessageReplyOptions = (payload: Exclude): MessageR ephemeral: _ephemeral, fetchReply: _fetchReply, withResponse: _withResponse, - flags: _flags, ...rest } = payload as InteractionReplyOptions & MessageReplyOptions; diff --git a/src/framework/presence/presenceStore.ts b/src/framework/presence/presenceStore.ts new file mode 100644 index 0000000..9f113e1 --- /dev/null +++ b/src/framework/presence/presenceStore.ts @@ -0,0 +1,123 @@ +import { Pool } from "pg"; + +import { env } from "../config/env.js"; +import { + createDefaultPresenceState, + isPresenceActivityTypeValue, + isPresenceStatusValue, + sanitizeActivityText, + type PresenceActivityTypeValue, + type PresenceState, + type PresenceStatusValue, +} from "./presenceTypes.js"; + +interface PresenceRow { + status: string; + activity_type: string; + activity_text: string; +} + +const tableSql = ` +CREATE TABLE IF NOT EXISTS bot_presence_states ( + bot_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + activity_type TEXT NOT NULL, + activity_text TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +`; + +const toPresenceState = (row: PresenceRow): PresenceState | null => { + if (!isPresenceStatusValue(row.status) || !isPresenceActivityTypeValue(row.activity_type)) { + return null; + } + + return { + status: row.status as PresenceStatusValue, + activity: { + type: row.activity_type as PresenceActivityTypeValue, + text: sanitizeActivityText(row.activity_text), + }, + }; +}; + +class PresenceStore { + public constructor(private readonly pool: Pool) {} + + public async init(): Promise { + await this.pool.query(tableSql); + } + + public async getByBotId(botId: string): Promise { + const result = await this.pool.query( + "SELECT status, activity_type, activity_text FROM bot_presence_states WHERE bot_id = $1 LIMIT 1", + [botId], + ); + + const row = result.rows[0]; + if (!row) { + return createDefaultPresenceState(); + } + + return toPresenceState(row) ?? createDefaultPresenceState(); + } + + public async upsertByBotId(botId: string, state: PresenceState): Promise { + await this.pool.query( + ` + INSERT INTO bot_presence_states (bot_id, status, activity_type, activity_text, updated_at) + VALUES ($1, $2, $3, $4, NOW()) + ON CONFLICT (bot_id) + DO UPDATE SET + status = EXCLUDED.status, + activity_type = EXCLUDED.activity_type, + activity_text = EXCLUDED.activity_text, + updated_at = NOW() + `, + [botId, state.status, state.activity.type, sanitizeActivityText(state.activity.text)], + ); + } + + public async close(): Promise { + await this.pool.end(); + } +} + +let store: PresenceStore | null = null; + +export const initPresenceStore = async (): Promise => { + if (store) { + return store; + } + + if (!env.DATABASE_URL) { + throw new Error("DATABASE_URL is required to initialize PostgreSQL presence storage."); + } + + const pool = new Pool({ + connectionString: env.DATABASE_URL, + ssl: env.DATABASE_SSL ? { rejectUnauthorized: false } : undefined, + }); + + const nextStore = new PresenceStore(pool); + await nextStore.init(); + store = nextStore; + return nextStore; +}; + +export const getPresenceStore = (): PresenceStore => { + if (!store) { + throw new Error("PresenceStore is not initialized. Call initPresenceStore() during bootstrap."); + } + + return store; +}; + +export const shutdownPresenceStore = async (): Promise => { + if (!store) { + return; + } + + await store.close(); + store = null; +}; diff --git a/src/framework/presence/presenceTypes.ts b/src/framework/presence/presenceTypes.ts new file mode 100644 index 0000000..cb6208c --- /dev/null +++ b/src/framework/presence/presenceTypes.ts @@ -0,0 +1,80 @@ +export const PRESENCE_STATUSES = ["online", "idle", "dnd", "invisible", "streaming"] as const; +export const PRESENCE_ACTIVITY_TYPES = [ + "PLAYING", + "STREAMING", + "WATCHING", + "LISTENING", + "COMPETING", + "CUSTOM", +] as const; + +export type PresenceStatusValue = (typeof PRESENCE_STATUSES)[number]; +export type PresenceActivityTypeValue = (typeof PRESENCE_ACTIVITY_TYPES)[number]; + +export interface PresenceState { + status: PresenceStatusValue; + activity: { + type: PresenceActivityTypeValue; + text: string; + }; +} + +export const DEFAULT_ACTIVITY_TEXT = "Ready to help"; + +export const createDefaultPresenceState = (): PresenceState => ({ + status: "online", + activity: { + type: "CUSTOM", + text: DEFAULT_ACTIVITY_TEXT, + }, +}); + +export const isPresenceStatusValue = (value: string): value is PresenceStatusValue => + (PRESENCE_STATUSES as readonly string[]).includes(value); + +export const isPresenceActivityTypeValue = (value: string): value is PresenceActivityTypeValue => + (PRESENCE_ACTIVITY_TYPES as readonly string[]).includes(value); + +export const sanitizeActivityText = (value: string): string => { + const trimmed = value.trim(); + if (trimmed.length === 0) { + return DEFAULT_ACTIVITY_TEXT; + } + + return trimmed.slice(0, 128); +}; + +const isRecord = (value: unknown): value is Record => + Boolean(value) && typeof value === "object" && !Array.isArray(value); + +export const parsePresenceState = (value: unknown): PresenceState | null => { + if (!isRecord(value)) { + return null; + } + + const statusValue = value.status; + const activityValue = value.activity; + + if (typeof statusValue !== "string" || !isPresenceStatusValue(statusValue) || !isRecord(activityValue)) { + return null; + } + + const activityType = activityValue.type; + const activityText = activityValue.text; + + if ( + typeof activityType !== "string" + || !isPresenceActivityTypeValue(activityType) + || typeof activityText !== "string" + ) { + return null; + } + + return { + status: statusValue, + activity: { + type: activityType, + text: sanitizeActivityText(activityText), + }, + }; +}; diff --git a/src/index.ts b/src/index.ts index ec851cf..a91bfad 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ import { Client, Events, GatewayIntentBits } from "discord.js"; import { commandList } from "./commands/index.js"; +import { restorePresenceFromStorage } from "./commands/utility/presence.js"; import { deployApplicationCommands } from "./framework/commands/deploy.js"; import { CommandRegistry } from "./framework/commands/registry.js"; import { env } from "./framework/config/env.js"; @@ -8,8 +9,30 @@ 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 { initPresenceStore, shutdownPresenceStore } from "./framework/presence/presenceStore.js"; + +const bindGracefulShutdown = (): void => { + const shutdown = async (signal: string): Promise => { + console.log(`[shutdown] ${signal}`); + await shutdownPresenceStore().catch((error) => { + console.error("[shutdown] presence store close failed", error); + }); + process.exit(0); + }; + + process.once("SIGINT", () => { + void shutdown("SIGINT"); + }); + + process.once("SIGTERM", () => { + void shutdown("SIGTERM"); + }); +}; const bootstrap = async (): Promise => { + await initPresenceStore(); + bindGracefulShutdown(); + const client = new Client({ intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent], }); @@ -46,6 +69,11 @@ const bootstrap = async (): Promise => { client.once(Events.ClientReady, async () => { console.log(`[ready] logged as ${client.user?.tag ?? "unknown"}`); + try { + await restorePresenceFromStorage(client); + } catch (error) { + console.error("[ready] failed to restore bot presence", error); + } if (env.AUTO_DEPLOY_SLASH) { try { @@ -68,5 +96,8 @@ const bootstrap = async (): Promise => { bootstrap().catch((error) => { console.error("[boot] fatal error", error); + void shutdownPresenceStore().catch((closeError) => { + console.error("[boot] failed to close presence store", closeError); + }); process.exitCode = 1; });