mirror of
https://github.com/arthur-pbty/flint.git
synced 2026-08-01 20:29:03 +02:00
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.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
@@ -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=
|
||||
|
||||
+27
@@ -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"]
|
||||
@@ -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_USER>:<POSTGRES_PASSWORD>@postgres:5432/<POSTGRES_DB>`
|
||||
|
||||
## 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`)
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Generated
+161
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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<PresenceActivityTypeValue, ActivityType> = {
|
||||
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<PresenceState> => {
|
||||
const botId = resolveBotId(client);
|
||||
if (!botId) {
|
||||
return createDefaultPresenceState();
|
||||
}
|
||||
|
||||
return getPresenceStore().getByBotId(botId);
|
||||
};
|
||||
|
||||
const savePresenceState = async (client: Client, state: PresenceState): Promise<void> => {
|
||||
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<StringSelectMenuBuilder>().addComponents(statusSelect),
|
||||
);
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(activitySelect),
|
||||
);
|
||||
container.addActionRowComponents(
|
||||
new ActionRowBuilder<ButtonBuilder>().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<void> => {
|
||||
applyPresenceState(ctx.client, state);
|
||||
await savePresenceState(ctx.client, state);
|
||||
};
|
||||
|
||||
export const restorePresenceFromStorage = async (client: Client): Promise<void> => {
|
||||
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<TextInputBuilder>().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);
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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),
|
||||
|
||||
@@ -20,7 +20,6 @@ const toMessageReplyOptions = (payload: Exclude<ReplyPayload, string>): MessageR
|
||||
ephemeral: _ephemeral,
|
||||
fetchReply: _fetchReply,
|
||||
withResponse: _withResponse,
|
||||
flags: _flags,
|
||||
...rest
|
||||
} = payload as InteractionReplyOptions & MessageReplyOptions;
|
||||
|
||||
|
||||
@@ -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<void> {
|
||||
await this.pool.query(tableSql);
|
||||
}
|
||||
|
||||
public async getByBotId(botId: string): Promise<PresenceState> {
|
||||
const result = await this.pool.query<PresenceRow>(
|
||||
"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<void> {
|
||||
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<void> {
|
||||
await this.pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
let store: PresenceStore | null = null;
|
||||
|
||||
export const initPresenceStore = async (): Promise<PresenceStore> => {
|
||||
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<void> => {
|
||||
if (!store) {
|
||||
return;
|
||||
}
|
||||
|
||||
await store.close();
|
||||
store = null;
|
||||
};
|
||||
@@ -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<string, unknown> =>
|
||||
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),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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<void> => {
|
||||
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<void> => {
|
||||
await initPresenceStore();
|
||||
bindGracefulShutdown();
|
||||
|
||||
const client = new Client({
|
||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
|
||||
});
|
||||
@@ -46,6 +69,11 @@ const bootstrap = async (): Promise<void> => {
|
||||
|
||||
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<void> => {
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user