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:
Puechberty Arthur
2026-04-12 10:46:51 +02:00
parent be8288ab5e
commit 58aac55620
18 changed files with 1225 additions and 4 deletions
+11
View File
@@ -0,0 +1,11 @@
node_modules
dist
.git
.gitignore
.env
.env.*
!.env.example
*.log
npm-debug.log*
yarn-error.log*
pnpm-debug.log*
+7
View File
@@ -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
View File
@@ -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"]
+41 -2
View File
@@ -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`)
+49
View File
@@ -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:
+35
View File
@@ -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:
+89
View File
@@ -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.",
+89
View File
@@ -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.",
+89
View File
@@ -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.",
+161
View File
@@ -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",
+2
View File
@@ -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"
} }
+2 -1
View File
@@ -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];
+378
View File
@@ -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);
});
},
});
+11
View File
@@ -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),
-1
View File
@@ -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;
+123
View File
@@ -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;
};
+80
View File
@@ -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),
},
};
};
+31
View File
@@ -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;
}); });