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_TOKEN=
|
||||||
DISCORD_CLIENT_ID=
|
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=+
|
PREFIX=+
|
||||||
DEFAULT_LANG=en
|
DEFAULT_LANG=en
|
||||||
DEV_GUILD_ID=
|
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
|
- User permission checks
|
||||||
- Auto-generated help from command metadata
|
- 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
|
## Setup
|
||||||
|
|
||||||
1. Install dependencies:
|
1. Install dependencies:
|
||||||
@@ -21,11 +27,42 @@ Professional command framework template for Discord.js `14.26.2` with:
|
|||||||
3. Fill required values in `.env`:
|
3. Fill required values in `.env`:
|
||||||
- `DISCORD_TOKEN`
|
- `DISCORD_TOKEN`
|
||||||
- `DISCORD_CLIENT_ID`
|
- `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
|
npm run deploy:commands
|
||||||
5. Start in development:
|
6. Start in development:
|
||||||
npm run dev
|
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
|
## Architecture
|
||||||
|
|
||||||
- `src/framework/types/command.ts`: strict command schema
|
- `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/execution/CommandExecutor.ts`: unified pipeline (permissions/execute)
|
||||||
- `src/framework/handlers/prefixHandler.ts`: prefix entrypoint
|
- `src/framework/handlers/prefixHandler.ts`: prefix entrypoint
|
||||||
- `src/framework/handlers/slashHandler.ts`: slash 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
|
- `locales/*.json`: external i18n dictionaries
|
||||||
- `src/commands/*`: business commands only (`execute`)
|
- `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}}"
|
"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": {
|
"help": {
|
||||||
"name": "help",
|
"name": "help",
|
||||||
"description": "Display command list or details for one command.",
|
"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}}"
|
"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": {
|
"help": {
|
||||||
"name": "ayuda",
|
"name": "ayuda",
|
||||||
"description": "Mostrar lista de comandos o detalles de un comando.",
|
"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}}"
|
"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": {
|
"help": {
|
||||||
"name": "aide",
|
"name": "aide",
|
||||||
"description": "Afficher la liste des commandes ou les details d une commande.",
|
"description": "Afficher la liste des commandes ou les details d une commande.",
|
||||||
|
|||||||
Generated
+161
@@ -10,10 +10,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"discord.js": "14.26.2",
|
"discord.js": "14.26.2",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"zod": "3.23.8"
|
"zod": "3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "20.17.24",
|
"@types/node": "20.17.24",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"tsx": "4.19.2",
|
"tsx": "4.19.2",
|
||||||
"typescript": "5.8.2"
|
"typescript": "5.8.2"
|
||||||
},
|
},
|
||||||
@@ -611,6 +613,18 @@
|
|||||||
"undici-types": "~6.19.2"
|
"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": {
|
"node_modules/@types/ws": {
|
||||||
"version": "8.18.1",
|
"version": "8.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz",
|
||||||
@@ -770,6 +784,135 @@
|
|||||||
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
|
"integrity": "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/ts-mixer": {
|
||||||
"version": "6.0.4",
|
"version": "6.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "3.23.8",
|
"version": "3.23.8",
|
||||||
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
|
"resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz",
|
||||||
|
|||||||
@@ -15,10 +15,12 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"discord.js": "14.26.2",
|
"discord.js": "14.26.2",
|
||||||
"dotenv": "16.4.5",
|
"dotenv": "16.4.5",
|
||||||
|
"pg": "^8.20.0",
|
||||||
"zod": "3.23.8"
|
"zod": "3.23.8"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "20.17.24",
|
"@types/node": "20.17.24",
|
||||||
|
"@types/pg": "^8.20.0",
|
||||||
"tsx": "4.19.2",
|
"tsx": "4.19.2",
|
||||||
"typescript": "5.8.2"
|
"typescript": "5.8.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { helpCommand } from "./core/help.js";
|
import { helpCommand } from "./core/help.js";
|
||||||
import { kissCommand } from "./fun/kiss.js";
|
import { kissCommand } from "./fun/kiss.js";
|
||||||
import { advancedCommand } from "./utility/advanced.js";
|
import { advancedCommand } from "./utility/advanced.js";
|
||||||
|
import { presenceCommand } from "./utility/presence.js";
|
||||||
import { pingCommand } from "./utility/ping.js";
|
import { pingCommand } from "./utility/ping.js";
|
||||||
|
|
||||||
import type { BotCommand } from "../framework/types/command.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({
|
const envSchema = z.object({
|
||||||
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
DISCORD_TOKEN: z.string().min(1, "DISCORD_TOKEN is required"),
|
||||||
DISCORD_CLIENT_ID: z.string().min(1, "DISCORD_CLIENT_ID 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("+"),
|
PREFIX: z.string().min(1).max(5).default("+"),
|
||||||
DEFAULT_LANG: z.enum(SUPPORTED_LANGS).default("en"),
|
DEFAULT_LANG: z.enum(SUPPORTED_LANGS).default("en"),
|
||||||
DEV_GUILD_ID: z.string().optional().transform((value) => value && value.length > 0 ? value : undefined),
|
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,
|
ephemeral: _ephemeral,
|
||||||
fetchReply: _fetchReply,
|
fetchReply: _fetchReply,
|
||||||
withResponse: _withResponse,
|
withResponse: _withResponse,
|
||||||
flags: _flags,
|
|
||||||
...rest
|
...rest
|
||||||
} = payload as InteractionReplyOptions & MessageReplyOptions;
|
} = 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 { Client, Events, GatewayIntentBits } from "discord.js";
|
||||||
|
|
||||||
import { commandList } from "./commands/index.js";
|
import { commandList } from "./commands/index.js";
|
||||||
|
import { restorePresenceFromStorage } from "./commands/utility/presence.js";
|
||||||
import { deployApplicationCommands } from "./framework/commands/deploy.js";
|
import { deployApplicationCommands } from "./framework/commands/deploy.js";
|
||||||
import { CommandRegistry } from "./framework/commands/registry.js";
|
import { CommandRegistry } from "./framework/commands/registry.js";
|
||||||
import { env } from "./framework/config/env.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 { createPrefixHandler } from "./framework/handlers/prefixHandler.js";
|
||||||
import { createSlashHandler } from "./framework/handlers/slashHandler.js";
|
import { createSlashHandler } from "./framework/handlers/slashHandler.js";
|
||||||
import { I18nService } from "./framework/i18n/I18nService.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> => {
|
const bootstrap = async (): Promise<void> => {
|
||||||
|
await initPresenceStore();
|
||||||
|
bindGracefulShutdown();
|
||||||
|
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
|
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent],
|
||||||
});
|
});
|
||||||
@@ -46,6 +69,11 @@ const bootstrap = async (): Promise<void> => {
|
|||||||
|
|
||||||
client.once(Events.ClientReady, async () => {
|
client.once(Events.ClientReady, async () => {
|
||||||
console.log(`[ready] logged as ${client.user?.tag ?? "unknown"}`);
|
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) {
|
if (env.AUTO_DEPLOY_SLASH) {
|
||||||
try {
|
try {
|
||||||
@@ -68,5 +96,8 @@ const bootstrap = async (): Promise<void> => {
|
|||||||
|
|
||||||
bootstrap().catch((error) => {
|
bootstrap().catch((error) => {
|
||||||
console.error("[boot] fatal error", error);
|
console.error("[boot] fatal error", error);
|
||||||
|
void shutdownPresenceStore().catch((closeError) => {
|
||||||
|
console.error("[boot] failed to close presence store", closeError);
|
||||||
|
});
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user