@@ -21,10 +21,14 @@ ENABLE_MEMORY_OPTIMIZER=false
|
|||||||
REDIS_URL=
|
REDIS_URL=
|
||||||
REDIS_PASS=
|
REDIS_PASS=
|
||||||
|
|
||||||
# Appwrite (for reminders)
|
# Appwrite (for reminders, AI conversations, and guild cache)
|
||||||
APPWRITE_ENDPOINT=
|
APPWRITE_ENDPOINT=
|
||||||
APPWRITE_PROJECT_ID=
|
APPWRITE_PROJECT_ID=
|
||||||
APPWRITE_API_KEY=
|
APPWRITE_API_KEY=
|
||||||
|
APPWRITE_DATABASE_ID=
|
||||||
|
APPWRITE_COLLECTION_REMINDERS_ID=
|
||||||
|
APPWRITE_COLLECTION_AI_CONVERSATIONS_ID=
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID=
|
||||||
|
|
||||||
# Reminders
|
# Reminders
|
||||||
REMINDERS_POLL_INTERVAL_SECONDS=30
|
REMINDERS_POLL_INTERVAL_SECONDS=30
|
||||||
|
|||||||
17
.vscode/tasks.json
vendored
Normal file
17
.vscode/tasks.json
vendored
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"version": "2.0.0",
|
||||||
|
"tasks": [
|
||||||
|
{
|
||||||
|
"label": "Typecheck: tsc --noEmit",
|
||||||
|
"type": "shell",
|
||||||
|
"command": "npm",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"-s",
|
||||||
|
"tsc",
|
||||||
|
"--",
|
||||||
|
"--noEmit"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
124
README/FIX_OUT_OF_MEMORY.md
Normal file
124
README/FIX_OUT_OF_MEMORY.md
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
# Fix: Out of Memory en PostgreSQL
|
||||||
|
|
||||||
|
## Problema Identificado
|
||||||
|
|
||||||
|
El bot estaba experimentando errores `out of memory` (código 53200) en PostgreSQL debido a que **en cada mensaje recibido** se estaba ejecutando un `prisma.guild.upsert()` para obtener la configuración del servidor (principalmente el prefix).
|
||||||
|
|
||||||
|
### Síntomas
|
||||||
|
```
|
||||||
|
ConnectorError(ConnectorError {
|
||||||
|
kind: QueryError(PostgresError {
|
||||||
|
code: "53200",
|
||||||
|
message: "out of memory",
|
||||||
|
severity: "ERROR",
|
||||||
|
detail: Some("Failed on request of size 8192.")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Esto ocurría en:
|
||||||
|
- `messageCreate.ts` línea 199: `await bot.prisma.guild.upsert(...)` en cada mensaje
|
||||||
|
- `handleAIReply()`: `await bot.prisma.guild.findUnique(...)` en cada respuesta a la IA
|
||||||
|
|
||||||
|
## Solución Implementada
|
||||||
|
|
||||||
|
### 1. Sistema de Caché con Redis
|
||||||
|
Se creó un nuevo módulo `guildCache.ts` que:
|
||||||
|
|
||||||
|
- **Almacena en caché** la configuración de cada guild por 5 minutos (TTL: 300s)
|
||||||
|
- **Reduce consultas a PostgreSQL** en ~99% (solo 1 consulta cada 5 minutos por guild)
|
||||||
|
- **Maneja errores gracefully** retornando valores por defecto si Redis o PostgreSQL fallan
|
||||||
|
|
||||||
|
#### Funciones principales:
|
||||||
|
```typescript
|
||||||
|
// Obtiene config desde caché o DB (con upsert automático)
|
||||||
|
getGuildConfig(guildId, guildName, prisma): Promise<GuildConfig>
|
||||||
|
|
||||||
|
// Invalida el caché cuando se actualiza la config
|
||||||
|
invalidateGuildCache(guildId): Promise<void>
|
||||||
|
|
||||||
|
// Actualiza directamente el caché
|
||||||
|
updateGuildCache(config): Promise<void>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Actualización de `messageCreate.ts`
|
||||||
|
- Reemplazó `prisma.guild.upsert()` por `getGuildConfig()`
|
||||||
|
- Ahora usa caché en Redis antes de consultar PostgreSQL
|
||||||
|
- Aplica en:
|
||||||
|
- Handler principal de mensajes
|
||||||
|
- `handleAIReply()` para respuestas a la IA
|
||||||
|
|
||||||
|
### 3. Invalidación de Caché en `settings.ts`
|
||||||
|
Se agregó invalidación automática del caché cuando se actualiza:
|
||||||
|
- **Prefix del servidor**
|
||||||
|
- **Roles de staff**
|
||||||
|
- **AI Role Prompt**
|
||||||
|
|
||||||
|
Esto asegura que los cambios se reflejen inmediatamente en el próximo mensaje.
|
||||||
|
|
||||||
|
## Impacto en el Rendimiento
|
||||||
|
|
||||||
|
### Antes:
|
||||||
|
- **Por cada mensaje**: 1 consulta a PostgreSQL (upsert)
|
||||||
|
- En un servidor activo con 100 mensajes/minuto: **100 consultas/minuto**
|
||||||
|
- En 10 servidores: **1,000 consultas/minuto**
|
||||||
|
|
||||||
|
### Después:
|
||||||
|
- **Primera consulta**: va a PostgreSQL + guarda en Redis (TTL 5 min)
|
||||||
|
- **Siguientes consultas**: se obtienen de Redis (0 consultas a PostgreSQL)
|
||||||
|
- En un servidor activo: **~1 consulta cada 5 minutos**
|
||||||
|
- En 10 servidores: **~10 consultas cada 5 minutos** (reducción del 99.8%)
|
||||||
|
|
||||||
|
## Archivos Modificados
|
||||||
|
|
||||||
|
1. **`src/core/database/guildCache.ts`** (NUEVO)
|
||||||
|
- Sistema completo de caché con Redis
|
||||||
|
- Manejo de errores robusto
|
||||||
|
- Logging detallado
|
||||||
|
|
||||||
|
2. **`src/events/messageCreate.ts`**
|
||||||
|
- Reemplazó `prisma.guild.upsert()` con `getGuildConfig()`
|
||||||
|
- Reemplazó `prisma.guild.findUnique()` en `handleAIReply()`
|
||||||
|
|
||||||
|
3. **`src/commands/messages/settings-server/settings.ts`**
|
||||||
|
- Agregó `invalidateGuildCache()` después de:
|
||||||
|
- Actualizar prefix
|
||||||
|
- Actualizar staff roles
|
||||||
|
- Actualizar AI role prompt
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
Para verificar que funciona:
|
||||||
|
|
||||||
|
1. **Logs de Redis**: Buscar mensajes como:
|
||||||
|
```
|
||||||
|
✅ Guild config obtenida desde caché
|
||||||
|
✅ Guild config guardada en caché
|
||||||
|
🗑️ Caché de guild invalidada
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Logs de Prisma**: Deberías ver **mucho menos** `prisma.guild.upsert()` en los logs
|
||||||
|
|
||||||
|
3. **Memoria de PostgreSQL**: Debería estabilizarse y no crecer descontroladamente
|
||||||
|
|
||||||
|
## Recomendaciones Adicionales
|
||||||
|
|
||||||
|
Si el problema persiste:
|
||||||
|
|
||||||
|
1. **Revisar otras consultas frecuentes** que puedan estar saturando PostgreSQL
|
||||||
|
2. **Aumentar memoria de PostgreSQL** si es posible en el plan de Heroku/hosting
|
||||||
|
3. **Implementar connection pooling** para Prisma si no está configurado
|
||||||
|
4. **Considerar agregar índices** en tablas con consultas pesadas
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Asegúrate de que:
|
||||||
|
- ✅ Redis está configurado y accesible (`REDIS_URL` y `REDIS_PASS` en `.env`)
|
||||||
|
- ✅ El bot tiene conexión a Redis antes de procesar mensajes
|
||||||
|
- ✅ Se ejecuta `npm run build` o el equivalente para compilar TypeScript
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fecha**: 2025-10-07
|
||||||
|
**Severidad**: CRÍTICA
|
||||||
|
**Estado**: RESUELTO ✅
|
||||||
141
README/GUIA_MANUAL_APPWRITE.md
Normal file
141
README/GUIA_MANUAL_APPWRITE.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# 📋 Guía Paso a Paso: Crear Colección Guild Cache en Appwrite
|
||||||
|
|
||||||
|
## Paso 1: Acceder a tu Database
|
||||||
|
|
||||||
|
1. Ve a [Appwrite Console](https://cloud.appwrite.io) (o tu instancia)
|
||||||
|
2. Selecciona tu proyecto
|
||||||
|
3. En el menú lateral, haz clic en **Databases**
|
||||||
|
4. Selecciona tu database (el que tienes en `APPWRITE_DATABASE_ID`)
|
||||||
|
|
||||||
|
## Paso 2: Crear la Colección
|
||||||
|
|
||||||
|
1. Haz clic en **Create Collection**
|
||||||
|
2. **Collection Name**: `guild_cache`
|
||||||
|
3. **Collection ID**: Déjalo autogenerar o usa `guild_cache`
|
||||||
|
4. Haz clic en **Create**
|
||||||
|
|
||||||
|
## Paso 3: Agregar Atributos
|
||||||
|
|
||||||
|
En la colección que acabas de crear, ve a la pestaña **Attributes** y crea estos 4 atributos:
|
||||||
|
|
||||||
|
### Atributo 1: guildId
|
||||||
|
- Haz clic en **Create Attribute** → **String**
|
||||||
|
- **Attribute Key**: `guildId`
|
||||||
|
- **Size**: `32`
|
||||||
|
- **Required**: ✅ Sí (marcado)
|
||||||
|
- **Array**: ❌ No
|
||||||
|
- Haz clic en **Create**
|
||||||
|
|
||||||
|
### Atributo 2: name
|
||||||
|
- Haz clic en **Create Attribute** → **String**
|
||||||
|
- **Attribute Key**: `name`
|
||||||
|
- **Size**: `100`
|
||||||
|
- **Required**: ✅ Sí (marcado)
|
||||||
|
- **Array**: ❌ No
|
||||||
|
- Haz clic en **Create**
|
||||||
|
|
||||||
|
### Atributo 3: prefix
|
||||||
|
- Haz clic en **Create Attribute** → **String**
|
||||||
|
- **Attribute Key**: `prefix`
|
||||||
|
- **Size**: `10`
|
||||||
|
- **Required**: ❌ No (desmarcado)
|
||||||
|
- **Default value**: (déjalo vacío)
|
||||||
|
- **Array**: ❌ No
|
||||||
|
- Haz clic en **Create**
|
||||||
|
|
||||||
|
### Atributo 4: expiresAt
|
||||||
|
- Haz clic en **Create Attribute** → **DateTime**
|
||||||
|
- **Attribute Key**: `expiresAt`
|
||||||
|
- **Required**: ✅ Sí (marcado)
|
||||||
|
- **Array**: ❌ No
|
||||||
|
- Haz clic en **Create**
|
||||||
|
|
||||||
|
⏳ **IMPORTANTE**: Espera unos segundos a que todos los atributos estén en estado **Available** antes de continuar.
|
||||||
|
|
||||||
|
## Paso 4: Crear Índices
|
||||||
|
|
||||||
|
Ve a la pestaña **Indexes** y crea estos 2 índices:
|
||||||
|
|
||||||
|
### Índice 1: guildId (único)
|
||||||
|
- Haz clic en **Create Index**
|
||||||
|
- **Index Key**: `idx_guildId`
|
||||||
|
- **Index Type**: **Unique**
|
||||||
|
- **Attributes**: Selecciona `guildId`
|
||||||
|
- **Order**: ASC
|
||||||
|
- Haz clic en **Create**
|
||||||
|
|
||||||
|
### Índice 2: expiresAt
|
||||||
|
- Haz clic en **Create Index**
|
||||||
|
- **Index Key**: `idx_expiresAt`
|
||||||
|
- **Index Type**: **Key**
|
||||||
|
- **Attributes**: Selecciona `expiresAt`
|
||||||
|
- **Order**: ASC
|
||||||
|
- Haz clic en **Create**
|
||||||
|
|
||||||
|
## Paso 5: Configurar Permisos
|
||||||
|
|
||||||
|
Ve a la pestaña **Settings** → **Permissions**:
|
||||||
|
|
||||||
|
1. Por defecto debería estar configurado como "API Key"
|
||||||
|
2. Si no, agrega estos permisos:
|
||||||
|
- **Role**: `Any`
|
||||||
|
- **Permissions**: Read, Create, Update, Delete (todas marcadas)
|
||||||
|
|
||||||
|
## Paso 6: Copiar el Collection ID
|
||||||
|
|
||||||
|
1. En la parte superior de la colección, verás el **Collection ID**
|
||||||
|
2. Cópialo (algo como `67xxxxxx` o `guild_cache` si lo personalizaste)
|
||||||
|
|
||||||
|
## Paso 7: Actualizar Variables de Entorno
|
||||||
|
|
||||||
|
Agrega a tu `.env` (o Config Vars en Heroku):
|
||||||
|
|
||||||
|
```env
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID=el_collection_id_que_copiaste
|
||||||
|
```
|
||||||
|
|
||||||
|
## Paso 8: Verificar
|
||||||
|
|
||||||
|
Para verificar que todo está bien:
|
||||||
|
|
||||||
|
1. Ve a la colección
|
||||||
|
2. Pestaña **Attributes**: Deberías ver 4 atributos (guildId, name, prefix, expiresAt)
|
||||||
|
3. Pestaña **Indexes**: Deberías ver 2 índices (idx_guildId, idx_expiresAt)
|
||||||
|
|
||||||
|
## Paso 9: Redeploy el Bot
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Si es local
|
||||||
|
npm run build
|
||||||
|
npm start
|
||||||
|
|
||||||
|
# Si es Heroku
|
||||||
|
git add .
|
||||||
|
git commit -m "chore: agregar APPWRITE_COLLECTION_GUILD_CACHE_ID"
|
||||||
|
git push heroku main
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Listo!
|
||||||
|
|
||||||
|
Después del redeploy, busca en los logs:
|
||||||
|
```
|
||||||
|
✅ Guild config guardada en caché (Appwrite)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐛 Solución de Problemas
|
||||||
|
|
||||||
|
### Error: "Attribute already exists"
|
||||||
|
- El atributo ya existe, pasa al siguiente
|
||||||
|
|
||||||
|
### Error: "Index already exists"
|
||||||
|
- El índice ya existe, pasa al siguiente
|
||||||
|
|
||||||
|
### Error: "Collection not found"
|
||||||
|
- Verifica que el `APPWRITE_COLLECTION_GUILD_CACHE_ID` sea correcto
|
||||||
|
|
||||||
|
### No veo mensajes de caché en los logs
|
||||||
|
- Verifica que todas las variables de Appwrite estén configuradas
|
||||||
|
- Revisa que el Collection ID sea correcto
|
||||||
|
- Comprueba que la colección tenga los permisos correctos
|
||||||
215
README/MIGRACION_CACHE_APPWRITE.md
Normal file
215
README/MIGRACION_CACHE_APPWRITE.md
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
# Migración de Caché: Redis → Appwrite
|
||||||
|
|
||||||
|
## Problema
|
||||||
|
|
||||||
|
Redis con 30MB de memoria ya estaba usando 2.4MB (8%) solo para el caché de configuración de guilds. Con el crecimiento del bot, esto podría saturar rápidamente la instancia de Redis.
|
||||||
|
|
||||||
|
## Solución: Usar Appwrite Database como Caché
|
||||||
|
|
||||||
|
Appwrite Database ofrece:
|
||||||
|
- ✅ **Más espacio**: Sin límites estrictos de 30MB
|
||||||
|
- ✅ **Persistencia**: Los datos sobreviven reinicios
|
||||||
|
- ✅ **Gratuito**: Ya lo tienes configurado en el proyecto
|
||||||
|
- ✅ **Consultas avanzadas**: Permite búsquedas y filtros complejos
|
||||||
|
|
||||||
|
## Configuración en Appwrite
|
||||||
|
|
||||||
|
### 1. Crear la Colección
|
||||||
|
|
||||||
|
En tu consola de Appwrite (`console.appwrite.io` o tu instancia):
|
||||||
|
|
||||||
|
1. Ve a **Databases** → Selecciona tu database
|
||||||
|
2. Crea una nueva colección llamada `guild_cache`
|
||||||
|
3. Configura los siguientes atributos:
|
||||||
|
|
||||||
|
| Atributo | Tipo | Tamaño | Requerido | Único | Default |
|
||||||
|
|----------|------|--------|-----------|-------|---------|
|
||||||
|
| `guildId` | String | 32 | ✅ Sí | ✅ Sí | - |
|
||||||
|
| `name` | String | 100 | ✅ Sí | ❌ No | - |
|
||||||
|
| `prefix` | String | 10 | ❌ No | ❌ No | `null` |
|
||||||
|
| `expiresAt` | DateTime | - | ✅ Sí | ❌ No | - |
|
||||||
|
|
||||||
|
4. Crea un **Índice** en `expiresAt` (tipo: Key, ascendente) para optimizar las búsquedas de limpieza
|
||||||
|
|
||||||
|
### 2. Configurar Permisos
|
||||||
|
|
||||||
|
En la colección, ve a **Settings** → **Permissions**:
|
||||||
|
- **Create**: API Key
|
||||||
|
- **Read**: API Key
|
||||||
|
- **Update**: API Key
|
||||||
|
- **Delete**: API Key
|
||||||
|
|
||||||
|
### 3. Variables de Entorno
|
||||||
|
|
||||||
|
Agrega a tu `.env`:
|
||||||
|
|
||||||
|
```env
|
||||||
|
# Appwrite
|
||||||
|
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||||
|
APPWRITE_PROJECT_ID=tu_project_id
|
||||||
|
APPWRITE_API_KEY=tu_api_key
|
||||||
|
APPWRITE_DATABASE_ID=tu_database_id
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID=guild_cache_collection_id
|
||||||
|
```
|
||||||
|
|
||||||
|
Para obtener el `APPWRITE_COLLECTION_GUILD_CACHE_ID`:
|
||||||
|
1. En la consola de Appwrite, abre la colección `guild_cache`
|
||||||
|
2. Copia el **Collection ID** que aparece en la parte superior
|
||||||
|
|
||||||
|
## Cambios Implementados
|
||||||
|
|
||||||
|
### Archivos Modificados
|
||||||
|
|
||||||
|
1. **`src/core/api/appwrite.ts`**
|
||||||
|
- Agregado: `APPWRITE_COLLECTION_GUILD_CACHE_ID`
|
||||||
|
- Nueva función: `isGuildCacheConfigured()`
|
||||||
|
|
||||||
|
2. **`src/core/database/guildCache.ts`** (REESCRITO COMPLETAMENTE)
|
||||||
|
- Migrado de Redis a Appwrite Database
|
||||||
|
- `getGuildConfig()`: Lee desde Appwrite, valida expiración
|
||||||
|
- `invalidateGuildCache()`: Elimina documento de Appwrite
|
||||||
|
- `updateGuildCache()`: Actualiza o crea documento
|
||||||
|
- `cleanExpiredGuildCache()`: Limpia documentos expirados (nueva función)
|
||||||
|
|
||||||
|
3. **`src/main.ts`**
|
||||||
|
- Agregado job de limpieza cada 10 minutos
|
||||||
|
- Importa `cleanExpiredGuildCache()`
|
||||||
|
|
||||||
|
4. **`.env.example`**
|
||||||
|
- Documentadas todas las variables de Appwrite
|
||||||
|
|
||||||
|
### Funciones
|
||||||
|
|
||||||
|
#### `getGuildConfig(guildId, guildName, prisma)`
|
||||||
|
```typescript
|
||||||
|
// 1. Intenta leer desde Appwrite
|
||||||
|
// 2. Verifica si expiró (expiresAt < now)
|
||||||
|
// 3. Si expiró o no existe, hace upsert en PostgreSQL
|
||||||
|
// 4. Guarda en Appwrite con TTL de 5 minutos
|
||||||
|
// 5. Retorna la configuración
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `invalidateGuildCache(guildId)`
|
||||||
|
```typescript
|
||||||
|
// Elimina el documento de Appwrite
|
||||||
|
// Se llama cuando se actualiza: prefix, staff, AI role prompt
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `cleanExpiredGuildCache()`
|
||||||
|
```typescript
|
||||||
|
// Busca documentos con expiresAt < now
|
||||||
|
// Elimina hasta 100 documentos expirados por ejecución
|
||||||
|
// Se ejecuta cada 10 minutos automáticamente
|
||||||
|
```
|
||||||
|
|
||||||
|
## Comparación: Redis vs Appwrite
|
||||||
|
|
||||||
|
| Característica | Redis (Antes) | Appwrite (Ahora) |
|
||||||
|
|----------------|---------------|------------------|
|
||||||
|
| **Memoria** | 30MB límite | ~Ilimitado |
|
||||||
|
| **Persistencia** | Volátil (se pierde al reiniciar) | Persistente |
|
||||||
|
| **TTL** | Automático (`SETEX`) | Manual (verificación en lectura) |
|
||||||
|
| **Costo** | Limitado por plan | Incluido en plan gratis |
|
||||||
|
| **Queries** | Básicas (key-value) | Avanzadas (filtros, búsquedas) |
|
||||||
|
| **Latencia** | ~1-5ms | ~50-100ms |
|
||||||
|
|
||||||
|
### Nota sobre Latencia
|
||||||
|
|
||||||
|
Appwrite es **ligeramente más lento** que Redis (~50-100ms vs ~1-5ms), pero:
|
||||||
|
- ✅ Solo se consulta cada 5 minutos por guild
|
||||||
|
- ✅ El 99% de las consultas vienen de caché
|
||||||
|
- ✅ La diferencia es imperceptible para el usuario final
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
### 1. Verificar que funciona
|
||||||
|
|
||||||
|
Busca en los logs:
|
||||||
|
```
|
||||||
|
✅ Guild config obtenida desde caché (Appwrite)
|
||||||
|
✅ Guild config guardada en caché (Appwrite)
|
||||||
|
🗑️ Caché de guild invalidada (Appwrite)
|
||||||
|
🧹 Documentos expirados eliminados de caché
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Verificar en Appwrite Console
|
||||||
|
|
||||||
|
1. Ve a tu colección `guild_cache`
|
||||||
|
2. Deberías ver documentos con:
|
||||||
|
- `guildId`: ID del servidor
|
||||||
|
- `name`: Nombre del servidor
|
||||||
|
- `prefix`: Prefix configurado (o vacío)
|
||||||
|
- `expiresAt`: Fecha de expiración (5 minutos en el futuro)
|
||||||
|
|
||||||
|
### 3. Probar cambio de prefix
|
||||||
|
|
||||||
|
1. Ejecuta `!settings` en Discord
|
||||||
|
2. Cambia el prefix
|
||||||
|
3. Verifica en los logs: `🗑️ Caché de guild invalidada`
|
||||||
|
4. El próximo comando debería usar el nuevo prefix inmediatamente
|
||||||
|
|
||||||
|
## Uso de Memoria
|
||||||
|
|
||||||
|
### Estimación por Guild
|
||||||
|
|
||||||
|
Cada documento en Appwrite ocupa aproximadamente:
|
||||||
|
```
|
||||||
|
guildId: 20 bytes
|
||||||
|
name: 50 bytes (promedio)
|
||||||
|
prefix: 3 bytes
|
||||||
|
expiresAt: 8 bytes
|
||||||
|
Metadata: ~50 bytes (Appwrite overhead)
|
||||||
|
───────────────────────
|
||||||
|
TOTAL: ~131 bytes
|
||||||
|
```
|
||||||
|
|
||||||
|
Para **1,000 guilds** = **~128 KB** (mucho menos que Redis)
|
||||||
|
|
||||||
|
### Redis Liberado
|
||||||
|
|
||||||
|
Al migrar el caché de guilds a Appwrite:
|
||||||
|
- **Antes**: ~2.4 MB en Redis
|
||||||
|
- **Después**: ~0 MB en Redis (solo para otras cosas)
|
||||||
|
- **Ahorro**: ~8% de la memoria de Redis
|
||||||
|
|
||||||
|
## Rollback (si algo falla)
|
||||||
|
|
||||||
|
Si necesitas volver a Redis:
|
||||||
|
|
||||||
|
1. Restaura el archivo anterior:
|
||||||
|
```bash
|
||||||
|
git checkout HEAD~1 src/core/database/guildCache.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Comenta las líneas en `main.ts`:
|
||||||
|
```typescript
|
||||||
|
// import { cleanExpiredGuildCache } from "./core/database/guildCache";
|
||||||
|
// setInterval(async () => { ... }, 10 * 60 * 1000);
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Redeploy
|
||||||
|
|
||||||
|
## Próximos Pasos (Opcional)
|
||||||
|
|
||||||
|
Si quieres optimizar aún más:
|
||||||
|
|
||||||
|
1. **Migrar otros cachés a Appwrite**:
|
||||||
|
- Cooldowns de usuarios
|
||||||
|
- Stats frecuentes
|
||||||
|
- Inventarios activos
|
||||||
|
|
||||||
|
2. **Implementar caché híbrido**:
|
||||||
|
- Memoria local (LRU) para guilds muy activos
|
||||||
|
- Appwrite para persistencia
|
||||||
|
|
||||||
|
3. **Agregar métricas**:
|
||||||
|
- Cache hit rate
|
||||||
|
- Latencia promedio
|
||||||
|
- Documentos expirados/hora
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Fecha**: 2025-10-07
|
||||||
|
**Cambio**: Migración de Redis → Appwrite para caché de guilds
|
||||||
|
**Razón**: Ahorrar memoria en Redis (30MB limitados)
|
||||||
|
**Estado**: ✅ COMPLETADO
|
||||||
68
README/SETUP_APPWRITE_CACHE.md
Normal file
68
README/SETUP_APPWRITE_CACHE.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# 🚀 Guía Rápida: Configurar Caché de Guilds con Appwrite
|
||||||
|
|
||||||
|
## ¿Por qué Appwrite en vez de Redis?
|
||||||
|
|
||||||
|
- ✅ Redis: Solo 30MB disponibles (ya usando 8%)
|
||||||
|
- ✅ Appwrite: Sin límites estrictos, incluido en plan gratis
|
||||||
|
- ✅ Ahorra ~2.4MB de Redis para otros usos
|
||||||
|
|
||||||
|
## Configuración (5 minutos)
|
||||||
|
|
||||||
|
### Opción Recomendada: Manual (Consola de Appwrite) 📝
|
||||||
|
|
||||||
|
**Por qué manual**: La API Key de tu proyecto requiere permisos elevados para crear colecciones. Es más rápido hacerlo desde la consola web.
|
||||||
|
|
||||||
|
📋 **[Sigue esta guía paso a paso](./GUIA_MANUAL_APPWRITE.md)** ← Click aquí
|
||||||
|
|
||||||
|
**Resumen rápido:**
|
||||||
|
1. Crea colección `guild_cache` en Appwrite Console
|
||||||
|
2. Agrega 4 atributos: `guildId`, `name`, `prefix`, `expiresAt`
|
||||||
|
3. Crea 2 índices en `guildId` y `expiresAt`
|
||||||
|
4. Copia el Collection ID
|
||||||
|
5. Agrégalo a `.env` como `APPWRITE_COLLECTION_GUILD_CACHE_ID`
|
||||||
|
|
||||||
|
### Opción Alternativa: Script Automático 🤖
|
||||||
|
|
||||||
|
⚠️ **Requiere API Key con permisos completos** (databases.write, collections.write, etc.)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Si tienes una API Key con permisos suficientes:
|
||||||
|
node scripts/setupGuildCacheCollection.js
|
||||||
|
|
||||||
|
# Luego agrega el ID a .env
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID=el_id_generado
|
||||||
|
```
|
||||||
|
|
||||||
|
## Variables de Entorno Necesarias
|
||||||
|
|
||||||
|
Asegúrate de tener en tu `.env` (o Config Vars de Heroku):
|
||||||
|
|
||||||
|
```env
|
||||||
|
APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
|
||||||
|
APPWRITE_PROJECT_ID=tu_project_id
|
||||||
|
APPWRITE_API_KEY=tu_api_key
|
||||||
|
APPWRITE_DATABASE_ID=tu_database_id
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID=tu_collection_id_nuevo
|
||||||
|
```
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
Después de desplegar, busca en los logs:
|
||||||
|
|
||||||
|
```
|
||||||
|
✅ Guild config obtenida desde caché (Appwrite)
|
||||||
|
✅ Guild config guardada en caché (Appwrite)
|
||||||
|
🧹 Documentos expirados eliminados de caché
|
||||||
|
```
|
||||||
|
|
||||||
|
## ¿Qué hace esto?
|
||||||
|
|
||||||
|
- **Antes**: Cada mensaje → consulta a PostgreSQL (miles por minuto)
|
||||||
|
- **Ahora**: Cada mensaje → consulta a Appwrite caché (1 vez cada 5 min por servidor)
|
||||||
|
- **Resultado**: 99.8% menos carga en PostgreSQL
|
||||||
|
|
||||||
|
## Más Información
|
||||||
|
|
||||||
|
Lee la documentación completa en:
|
||||||
|
- [README/MIGRACION_CACHE_APPWRITE.md](./MIGRACION_CACHE_APPWRITE.md)
|
||||||
|
- [README/FIX_OUT_OF_MEMORY.md](./FIX_OUT_OF_MEMORY.md)
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
"main": "src/main.ts",
|
"main": "src/main.ts",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npx tsx watch src/main.ts",
|
"start": "npx tsx watch src/main.ts",
|
||||||
|
"script:guild": "node scripts/setupGuildCacheCollection.js",
|
||||||
"dev": "npx tsx watch src/main.ts",
|
"dev": "npx tsx watch src/main.ts",
|
||||||
"dev:light": "CACHE_MESSAGES_LIMIT=25 CACHE_MEMBERS_LIMIT=50 SWEEP_MESSAGES_LIFETIME_SECONDS=600 SWEEP_MESSAGES_INTERVAL_SECONDS=240 npx tsx watch --clear-screen=false src/main.ts",
|
"dev:light": "CACHE_MESSAGES_LIMIT=25 CACHE_MEMBERS_LIMIT=50 SWEEP_MESSAGES_LIFETIME_SECONDS=600 SWEEP_MESSAGES_INTERVAL_SECONDS=240 npx tsx watch --clear-screen=false src/main.ts",
|
||||||
"dev:mem": "MEMORY_LOG_INTERVAL_SECONDS=120 npx tsx watch src/main.ts",
|
"dev:mem": "MEMORY_LOG_INTERVAL_SECONDS=120 npx tsx watch src/main.ts",
|
||||||
|
|||||||
159
scripts/setupGuildCacheCollection.js
Normal file
159
scripts/setupGuildCacheCollection.js
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Script para crear automáticamente la colección de caché de guilds en Appwrite
|
||||||
|
*
|
||||||
|
* Uso:
|
||||||
|
* node scripts/setupGuildCacheCollection.js
|
||||||
|
*
|
||||||
|
* Requisitos:
|
||||||
|
* - Tener las variables de entorno de Appwrite configuradas
|
||||||
|
* - Tener node-appwrite instalado
|
||||||
|
*/
|
||||||
|
process.loadEnvFile();
|
||||||
|
const { Client, Databases, Permission, Role } = require('node-appwrite');
|
||||||
|
|
||||||
|
const COLLECTION_NAME = 'guild_cache_id';
|
||||||
|
|
||||||
|
async function setup() {
|
||||||
|
console.log('🚀 Configurando colección de caché de guilds en Appwrite...\n');
|
||||||
|
|
||||||
|
// Validar variables de entorno
|
||||||
|
const endpoint = process.env.APPWRITE_ENDPOINT;
|
||||||
|
const projectId = process.env.APPWRITE_PROJECT_ID;
|
||||||
|
const apiKey = process.env.APPWRITE_API_KEY;
|
||||||
|
const databaseId = process.env.APPWRITE_DATABASE_ID;
|
||||||
|
|
||||||
|
if (!endpoint || !projectId || !apiKey || !databaseId) {
|
||||||
|
console.error('❌ Error: Faltan variables de entorno de Appwrite');
|
||||||
|
console.error(' Asegúrate de tener configurado:');
|
||||||
|
console.error(' - APPWRITE_ENDPOINT');
|
||||||
|
console.error(' - APPWRITE_PROJECT_ID');
|
||||||
|
console.error(' - APPWRITE_API_KEY');
|
||||||
|
console.error(' - APPWRITE_DATABASE_ID');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inicializar cliente
|
||||||
|
const client = new Client()
|
||||||
|
.setEndpoint(endpoint)
|
||||||
|
.setProject(projectId)
|
||||||
|
.setKey(apiKey);
|
||||||
|
|
||||||
|
const databases = new Databases(client);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Crear colección
|
||||||
|
console.log('📦 Creando colección...');
|
||||||
|
const collection = await databases.createCollection(
|
||||||
|
databaseId,
|
||||||
|
'unique()', // ID autogenerado
|
||||||
|
COLLECTION_NAME,
|
||||||
|
[
|
||||||
|
Permission.read(Role.any()),
|
||||||
|
Permission.create(Role.any()),
|
||||||
|
Permission.update(Role.any()),
|
||||||
|
Permission.delete(Role.any())
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log(`✅ Colección creada: ${collection.$id}\n`);
|
||||||
|
const collectionId = collection.$id;
|
||||||
|
|
||||||
|
// 2. Crear atributo guildId (string, required, unique)
|
||||||
|
console.log('📝 Creando atributo: guildId');
|
||||||
|
await databases.createStringAttribute(
|
||||||
|
databaseId,
|
||||||
|
collectionId,
|
||||||
|
'guildId',
|
||||||
|
32,
|
||||||
|
true, // required
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
console.log('✅ Atributo guildId creado');
|
||||||
|
|
||||||
|
// 3. Crear atributo name (string, required)
|
||||||
|
console.log('📝 Creando atributo: name');
|
||||||
|
await databases.createStringAttribute(
|
||||||
|
databaseId,
|
||||||
|
collectionId,
|
||||||
|
'name',
|
||||||
|
100,
|
||||||
|
true, // required
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
console.log('✅ Atributo name creado');
|
||||||
|
|
||||||
|
// 4. Crear atributo prefix (string, optional)
|
||||||
|
console.log('📝 Creando atributo: prefix');
|
||||||
|
await databases.createStringAttribute(
|
||||||
|
databaseId,
|
||||||
|
collectionId,
|
||||||
|
'prefix',
|
||||||
|
10,
|
||||||
|
false, // not required
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
console.log('✅ Atributo prefix creado');
|
||||||
|
|
||||||
|
// 5. Crear atributo expiresAt (datetime, required)
|
||||||
|
console.log('📝 Creando atributo: expiresAt');
|
||||||
|
await databases.createDatetimeAttribute(
|
||||||
|
databaseId,
|
||||||
|
collectionId,
|
||||||
|
'expiresAt',
|
||||||
|
true, // required
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
console.log('✅ Atributo expiresAt creado');
|
||||||
|
|
||||||
|
// Esperar un poco para que Appwrite procese los atributos
|
||||||
|
console.log('\n⏳ Esperando 5 segundos para que los atributos se procesen...');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
|
|
||||||
|
// 6. Crear índice en guildId (unique)
|
||||||
|
console.log('📝 Creando índice único en guildId');
|
||||||
|
await databases.createIndex(
|
||||||
|
databaseId,
|
||||||
|
collectionId,
|
||||||
|
'idx_guildId',
|
||||||
|
'unique',
|
||||||
|
['guildId'],
|
||||||
|
['ASC']
|
||||||
|
);
|
||||||
|
console.log('✅ Índice en guildId creado');
|
||||||
|
|
||||||
|
// 7. Crear índice en expiresAt (para queries de limpieza)
|
||||||
|
console.log('📝 Creando índice en expiresAt');
|
||||||
|
await databases.createIndex(
|
||||||
|
databaseId,
|
||||||
|
collectionId,
|
||||||
|
'idx_expiresAt',
|
||||||
|
'key',
|
||||||
|
['expiresAt'],
|
||||||
|
['ASC']
|
||||||
|
);
|
||||||
|
console.log('✅ Índice en expiresAt creado');
|
||||||
|
|
||||||
|
console.log('\n🎉 ¡Configuración completada exitosamente!');
|
||||||
|
console.log('\n📋 Agrega esta variable a tu .env:');
|
||||||
|
console.log(`APPWRITE_COLLECTION_GUILD_CACHE_ID=${collectionId}`);
|
||||||
|
console.log('\n💡 Recuerda reiniciar tu bot después de agregar la variable.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('\n❌ Error durante la configuración:', error.message);
|
||||||
|
if (error.response) {
|
||||||
|
console.error('Detalles:', error.response);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setup();
|
||||||
@@ -1,67 +1,103 @@
|
|||||||
import { prisma } from '../../../core/database/prisma';
|
import { prisma } from "../../../core/database/prisma";
|
||||||
import type { GameArea } from '@prisma/client';
|
import type { GameArea } from "@prisma/client";
|
||||||
import type { ItemProps } from '../../../game/economy/types';
|
import type { ItemProps } from "../../../game/economy/types";
|
||||||
import type {
|
import type {
|
||||||
Message,
|
Message,
|
||||||
TextBasedChannel,
|
TextBasedChannel,
|
||||||
MessageComponentInteraction,
|
MessageComponentInteraction,
|
||||||
StringSelectMenuInteraction,
|
StringSelectMenuInteraction,
|
||||||
ButtonInteraction,
|
ButtonInteraction,
|
||||||
ModalSubmitInteraction
|
ModalSubmitInteraction,
|
||||||
} from 'discord.js';
|
} from "discord.js";
|
||||||
import { MessageFlags } from 'discord.js';
|
import { MessageFlags } from "discord.js";
|
||||||
import { ButtonStyle, ComponentType, TextInputStyle } from 'discord-api-types/v10';
|
import {
|
||||||
|
ButtonStyle,
|
||||||
|
ComponentType,
|
||||||
|
TextInputStyle,
|
||||||
|
} from "discord-api-types/v10";
|
||||||
|
|
||||||
export function parseItemProps(json: unknown): ItemProps {
|
export function parseItemProps(json: unknown): ItemProps {
|
||||||
if (!json || typeof json !== 'object') return {};
|
if (!json || typeof json !== "object") return {};
|
||||||
return json as ItemProps;
|
return json as ItemProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveArea(guildId: string, areaKey: string) {
|
export async function resolveArea(guildId: string, areaKey: string) {
|
||||||
const area = await prisma.gameArea.findFirst({ where: { key: areaKey, OR: [{ guildId }, { guildId: null }] }, orderBy: [{ guildId: 'desc' }] });
|
const area = await prisma.gameArea.findFirst({
|
||||||
|
where: { key: areaKey, OR: [{ guildId }, { guildId: null }] },
|
||||||
|
orderBy: [{ guildId: "desc" }],
|
||||||
|
});
|
||||||
return area;
|
return area;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedAreaInfo {
|
export interface ResolvedAreaInfo {
|
||||||
area: GameArea | null;
|
area: GameArea | null;
|
||||||
source: 'guild' | 'global' | 'none';
|
source: "guild" | "global" | "none";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveGuildAreaWithFallback(guildId: string, areaKey: string): Promise<ResolvedAreaInfo> {
|
export async function resolveGuildAreaWithFallback(
|
||||||
const guildArea = await prisma.gameArea.findFirst({ where: { key: areaKey, guildId } });
|
guildId: string,
|
||||||
|
areaKey: string
|
||||||
|
): Promise<ResolvedAreaInfo> {
|
||||||
|
const guildArea = await prisma.gameArea.findFirst({
|
||||||
|
where: { key: areaKey, guildId },
|
||||||
|
});
|
||||||
if (guildArea) {
|
if (guildArea) {
|
||||||
return { area: guildArea, source: 'guild' };
|
return { area: guildArea, source: "guild" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalArea = await prisma.gameArea.findFirst({ where: { key: areaKey, guildId: null } });
|
const globalArea = await prisma.gameArea.findFirst({
|
||||||
|
where: { key: areaKey, guildId: null },
|
||||||
|
});
|
||||||
if (globalArea) {
|
if (globalArea) {
|
||||||
return { area: globalArea, source: 'global' };
|
return { area: globalArea, source: "global" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { area: null, source: 'none' };
|
return { area: null, source: "none" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveAreaByType(guildId: string, type: string): Promise<ResolvedAreaInfo> {
|
export async function resolveAreaByType(
|
||||||
const guildArea = await prisma.gameArea.findFirst({ where: { type, guildId }, orderBy: [{ createdAt: 'asc' }] });
|
guildId: string,
|
||||||
|
type: string
|
||||||
|
): Promise<ResolvedAreaInfo> {
|
||||||
|
const guildArea = await prisma.gameArea.findFirst({
|
||||||
|
where: { type, guildId },
|
||||||
|
orderBy: [{ createdAt: "asc" }],
|
||||||
|
});
|
||||||
if (guildArea) {
|
if (guildArea) {
|
||||||
return { area: guildArea, source: 'guild' };
|
return { area: guildArea, source: "guild" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const globalArea = await prisma.gameArea.findFirst({ where: { type, guildId: null }, orderBy: [{ createdAt: 'asc' }] });
|
const globalArea = await prisma.gameArea.findFirst({
|
||||||
|
where: { type, guildId: null },
|
||||||
|
orderBy: [{ createdAt: "asc" }],
|
||||||
|
});
|
||||||
if (globalArea) {
|
if (globalArea) {
|
||||||
return { area: globalArea, source: 'global' };
|
return { area: globalArea, source: "global" };
|
||||||
}
|
}
|
||||||
|
|
||||||
return { area: null, source: 'none' };
|
return { area: null, source: "none" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getDefaultLevel(userId: string, guildId: string, areaId: string): Promise<number> {
|
export async function getDefaultLevel(
|
||||||
const prog = await prisma.playerProgress.findUnique({ where: { userId_guildId_areaId: { userId, guildId, areaId } } });
|
userId: string,
|
||||||
|
guildId: string,
|
||||||
|
areaId: string
|
||||||
|
): Promise<number> {
|
||||||
|
const prog = await prisma.playerProgress.findUnique({
|
||||||
|
where: { userId_guildId_areaId: { userId, guildId, areaId } },
|
||||||
|
});
|
||||||
return Math.max(1, prog?.highestLevel ?? 1);
|
return Math.max(1, prog?.highestLevel ?? 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findBestToolKey(userId: string, guildId: string, toolType: string): Promise<string | null> {
|
export async function findBestToolKey(
|
||||||
const inv = await prisma.inventoryEntry.findMany({ where: { userId, guildId, quantity: { gt: 0 } }, include: { item: true } });
|
userId: string,
|
||||||
|
guildId: string,
|
||||||
|
toolType: string
|
||||||
|
): Promise<string | null> {
|
||||||
|
const inv = await prisma.inventoryEntry.findMany({
|
||||||
|
where: { userId, guildId, quantity: { gt: 0 } },
|
||||||
|
include: { item: true },
|
||||||
|
});
|
||||||
let best: { key: string; tier: number } | null = null;
|
let best: { key: string; tier: number } | null = null;
|
||||||
for (const e of inv) {
|
for (const e of inv) {
|
||||||
const it = e.item;
|
const it = e.item;
|
||||||
@@ -80,10 +116,12 @@ export interface ParsedGameArgs {
|
|||||||
areaOverride: string | null;
|
areaOverride: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AREA_OVERRIDE_PREFIX = 'area:';
|
const AREA_OVERRIDE_PREFIX = "area:";
|
||||||
|
|
||||||
export function parseGameArgs(args: string[]): ParsedGameArgs {
|
export function parseGameArgs(args: string[]): ParsedGameArgs {
|
||||||
const tokens = args.filter((arg): arg is string => typeof arg === 'string' && arg.trim().length > 0);
|
const tokens = args.filter(
|
||||||
|
(arg): arg is string => typeof arg === "string" && arg.trim().length > 0
|
||||||
|
);
|
||||||
|
|
||||||
let levelArg: number | null = null;
|
let levelArg: number | null = null;
|
||||||
let providedTool: string | null = null;
|
let providedTool: string | null = null;
|
||||||
@@ -109,9 +147,12 @@ export function parseGameArgs(args: string[]): ParsedGameArgs {
|
|||||||
return { levelArg, providedTool, areaOverride };
|
return { levelArg, providedTool, areaOverride };
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_ITEM_ICON = '📦';
|
const DEFAULT_ITEM_ICON = "📦";
|
||||||
|
|
||||||
export function resolveItemIcon(icon?: string | null, fallback = DEFAULT_ITEM_ICON) {
|
export function resolveItemIcon(
|
||||||
|
icon?: string | null,
|
||||||
|
fallback = DEFAULT_ITEM_ICON
|
||||||
|
) {
|
||||||
const trimmed = icon?.trim();
|
const trimmed = icon?.trim();
|
||||||
return trimmed && trimmed.length > 0 ? trimmed : fallback;
|
return trimmed && trimmed.length > 0 ? trimmed : fallback;
|
||||||
}
|
}
|
||||||
@@ -122,15 +163,24 @@ export function formatItemLabel(
|
|||||||
): string {
|
): string {
|
||||||
const fallbackIcon = options.fallbackIcon ?? DEFAULT_ITEM_ICON;
|
const fallbackIcon = options.fallbackIcon ?? DEFAULT_ITEM_ICON;
|
||||||
const icon = resolveItemIcon(item.icon, fallbackIcon);
|
const icon = resolveItemIcon(item.icon, fallbackIcon);
|
||||||
const label = (item.name ?? '').trim() || item.key;
|
const label = (item.name ?? "").trim() || item.key;
|
||||||
const content = `${icon ? `${icon} ` : ''}${label}`.trim();
|
const content = `${icon ? `${icon} ` : ""}${label}`.trim();
|
||||||
return options.bold ? `**${content}**` : content;
|
return options.bold ? `**${content}**` : content;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ItemBasicInfo = { key: string; name: string | null; icon: string | null };
|
export type ItemBasicInfo = {
|
||||||
|
key: string;
|
||||||
|
name: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchItemBasics(guildId: string, keys: string[]): Promise<Map<string, ItemBasicInfo>> {
|
export async function fetchItemBasics(
|
||||||
const uniqueKeys = Array.from(new Set(keys.filter((key): key is string => Boolean(key && key.trim()))));
|
guildId: string,
|
||||||
|
keys: string[]
|
||||||
|
): Promise<Map<string, ItemBasicInfo>> {
|
||||||
|
const uniqueKeys = Array.from(
|
||||||
|
new Set(keys.filter((key): key is string => Boolean(key && key.trim())))
|
||||||
|
);
|
||||||
if (uniqueKeys.length === 0) return new Map();
|
if (uniqueKeys.length === 0) return new Map();
|
||||||
|
|
||||||
const rows = await prisma.economyItem.findMany({
|
const rows = await prisma.economyItem.findMany({
|
||||||
@@ -138,7 +188,7 @@ export async function fetchItemBasics(guildId: string, keys: string[]): Promise<
|
|||||||
key: { in: uniqueKeys },
|
key: { in: uniqueKeys },
|
||||||
OR: [{ guildId }, { guildId: null }],
|
OR: [{ guildId }, { guildId: null }],
|
||||||
},
|
},
|
||||||
orderBy: [{ key: 'asc' }, { guildId: 'desc' }],
|
orderBy: [{ key: "asc" }, { guildId: "desc" }],
|
||||||
select: { key: true, name: true, icon: true, guildId: true },
|
select: { key: true, name: true, icon: true, guildId: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -181,7 +231,7 @@ export interface KeyPickerConfig<T> {
|
|||||||
export interface KeyPickerResult<T> {
|
export interface KeyPickerResult<T> {
|
||||||
entry: T | null;
|
entry: T | null;
|
||||||
panelMessage: Message | null;
|
panelMessage: Message | null;
|
||||||
reason: 'selected' | 'empty' | 'cancelled' | 'timeout';
|
reason: "selected" | "empty" | "cancelled" | "timeout";
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function promptKeySelection<T>(
|
export async function promptKeySelection<T>(
|
||||||
@@ -193,9 +243,14 @@ export async function promptKeySelection<T>(
|
|||||||
|
|
||||||
const baseOptions = config.entries.map((entry) => {
|
const baseOptions = config.entries.map((entry) => {
|
||||||
const option = config.getOption(entry);
|
const option = config.getOption(entry);
|
||||||
const searchText = [option.label, option.description, option.value, ...(option.keywords ?? [])]
|
const searchText = [
|
||||||
|
option.label,
|
||||||
|
option.description,
|
||||||
|
option.value,
|
||||||
|
...(option.keywords ?? []),
|
||||||
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ')
|
.join(" ")
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
return { entry, option, searchText };
|
return { entry, option, searchText };
|
||||||
});
|
});
|
||||||
@@ -203,7 +258,7 @@ export async function promptKeySelection<T>(
|
|||||||
if (baseOptions.length === 0) {
|
if (baseOptions.length === 0) {
|
||||||
const emptyPanel = {
|
const emptyPanel = {
|
||||||
type: 17,
|
type: 17,
|
||||||
accent_color: 0xFFA500,
|
accent_color: 0xffa500,
|
||||||
components: [
|
components: [
|
||||||
{
|
{
|
||||||
type: 10,
|
type: 10,
|
||||||
@@ -217,14 +272,14 @@ export async function promptKeySelection<T>(
|
|||||||
reply: { messageReference: message.id },
|
reply: { messageReference: message.id },
|
||||||
components: [emptyPanel],
|
components: [emptyPanel],
|
||||||
});
|
});
|
||||||
return { entry: null, panelMessage: null, reason: 'empty' };
|
return { entry: null, panelMessage: null, reason: "empty" };
|
||||||
}
|
}
|
||||||
|
|
||||||
let filter = '';
|
let filter = "";
|
||||||
let page = 0;
|
let page = 0;
|
||||||
const pageSize = 25;
|
const pageSize = 25;
|
||||||
const accentColor = config.accentColor ?? 0x5865F2;
|
const accentColor = config.accentColor ?? 0x5865f2;
|
||||||
const placeholder = config.placeholder ?? 'Selecciona una opción…';
|
const placeholder = config.placeholder ?? "Selecciona una opción…";
|
||||||
|
|
||||||
const buildComponents = () => {
|
const buildComponents = () => {
|
||||||
const normalizedFilter = filter.trim().toLowerCase();
|
const normalizedFilter = filter.trim().toLowerCase();
|
||||||
@@ -238,10 +293,12 @@ export async function promptKeySelection<T>(
|
|||||||
const start = safePage * pageSize;
|
const start = safePage * pageSize;
|
||||||
const slice = filtered.slice(start, start + pageSize);
|
const slice = filtered.slice(start, start + pageSize);
|
||||||
|
|
||||||
const pageLabel = `Página ${totalFiltered === 0 ? 0 : safePage + 1}/${totalPages}`;
|
const pageLabel = `Página ${
|
||||||
|
totalFiltered === 0 ? 0 : safePage + 1
|
||||||
|
}/${totalPages}`;
|
||||||
const statsLine = `Total: **${baseOptions.length}** • Coincidencias: **${totalFiltered}**\n${pageLabel}`;
|
const statsLine = `Total: **${baseOptions.length}** • Coincidencias: **${totalFiltered}**\n${pageLabel}`;
|
||||||
const filterLine = filter ? `\nFiltro activo: \`${filter}\`` : '';
|
const filterLine = filter ? `\nFiltro activo: \`${filter}\`` : "";
|
||||||
const hintLine = config.filterHint ? `\n${config.filterHint}` : '';
|
const hintLine = config.filterHint ? `\n${config.filterHint}` : "";
|
||||||
|
|
||||||
const display = {
|
const display = {
|
||||||
type: 17,
|
type: 17,
|
||||||
@@ -256,9 +313,10 @@ export async function promptKeySelection<T>(
|
|||||||
{ type: 14, divider: true },
|
{ type: 14, divider: true },
|
||||||
{
|
{
|
||||||
type: 10,
|
type: 10,
|
||||||
content: totalFiltered === 0
|
content:
|
||||||
? 'No hay resultados para el filtro actual. Ajusta el filtro o limpia la búsqueda.'
|
totalFiltered === 0
|
||||||
: 'Selecciona una opción del menú desplegable para continuar.',
|
? "No hay resultados para el filtro actual. Ajusta el filtro o limpia la búsqueda."
|
||||||
|
: "Selecciona una opción del menú desplegable para continuar.",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
@@ -273,9 +331,9 @@ export async function promptKeySelection<T>(
|
|||||||
if (selectDisabled) {
|
if (selectDisabled) {
|
||||||
options = [
|
options = [
|
||||||
{
|
{
|
||||||
label: 'Sin resultados',
|
label: "Sin resultados",
|
||||||
value: `${config.customIdPrefix}_empty`,
|
value: `${config.customIdPrefix}_empty`,
|
||||||
description: 'Ajusta el filtro para ver opciones.',
|
description: "Ajusta el filtro para ver opciones.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -299,34 +357,34 @@ export async function promptKeySelection<T>(
|
|||||||
{
|
{
|
||||||
type: 2,
|
type: 2,
|
||||||
style: ButtonStyle.Secondary,
|
style: ButtonStyle.Secondary,
|
||||||
label: '◀️',
|
label: "◀️",
|
||||||
custom_id: `${config.customIdPrefix}_prev`,
|
custom_id: `${config.customIdPrefix}_prev`,
|
||||||
disabled: safePage <= 0 || totalFiltered === 0,
|
disabled: safePage <= 0 || totalFiltered === 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 2,
|
type: 2,
|
||||||
style: ButtonStyle.Secondary,
|
style: ButtonStyle.Secondary,
|
||||||
label: '▶️',
|
label: "▶️",
|
||||||
custom_id: `${config.customIdPrefix}_next`,
|
custom_id: `${config.customIdPrefix}_next`,
|
||||||
disabled: safePage >= totalPages - 1 || totalFiltered === 0,
|
disabled: safePage >= totalPages - 1 || totalFiltered === 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 2,
|
type: 2,
|
||||||
style: ButtonStyle.Primary,
|
style: ButtonStyle.Primary,
|
||||||
label: '🔎 Filtro',
|
label: "🔎 Filtro",
|
||||||
custom_id: `${config.customIdPrefix}_filter`,
|
custom_id: `${config.customIdPrefix}_filter`,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 2,
|
type: 2,
|
||||||
style: ButtonStyle.Secondary,
|
style: ButtonStyle.Secondary,
|
||||||
label: 'Limpiar',
|
label: "Limpiar",
|
||||||
custom_id: `${config.customIdPrefix}_clear`,
|
custom_id: `${config.customIdPrefix}_clear`,
|
||||||
disabled: filter.length === 0,
|
disabled: filter.length === 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 2,
|
type: 2,
|
||||||
style: ButtonStyle.Danger,
|
style: ButtonStyle.Danger,
|
||||||
label: 'Cancelar',
|
label: "Cancelar",
|
||||||
custom_id: `${config.customIdPrefix}_cancel`,
|
custom_id: `${config.customIdPrefix}_cancel`,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -345,7 +403,10 @@ export async function promptKeySelection<T>(
|
|||||||
let resolved = false;
|
let resolved = false;
|
||||||
|
|
||||||
const result = await new Promise<KeyPickerResult<T>>((resolve) => {
|
const result = await new Promise<KeyPickerResult<T>>((resolve) => {
|
||||||
const finish = (entry: T | null, reason: 'selected' | 'cancelled' | 'timeout') => {
|
const finish = (
|
||||||
|
entry: T | null,
|
||||||
|
reason: "selected" | "cancelled" | "timeout"
|
||||||
|
) => {
|
||||||
if (resolved) return;
|
if (resolved) return;
|
||||||
resolved = true;
|
resolved = true;
|
||||||
resolve({ entry, panelMessage, reason });
|
resolve({ entry, panelMessage, reason });
|
||||||
@@ -353,17 +414,28 @@ export async function promptKeySelection<T>(
|
|||||||
|
|
||||||
const collector = panelMessage.createMessageComponentCollector({
|
const collector = panelMessage.createMessageComponentCollector({
|
||||||
time: 5 * 60_000,
|
time: 5 * 60_000,
|
||||||
filter: (i: MessageComponentInteraction) => i.user.id === userId && i.customId.startsWith(config.customIdPrefix),
|
filter: (i: MessageComponentInteraction) =>
|
||||||
|
i.user.id === userId && i.customId.startsWith(config.customIdPrefix),
|
||||||
});
|
});
|
||||||
|
|
||||||
collector.on('collect', async (interaction: MessageComponentInteraction) => {
|
collector.on(
|
||||||
|
"collect",
|
||||||
|
async (interaction: MessageComponentInteraction) => {
|
||||||
try {
|
try {
|
||||||
if (interaction.customId === `${config.customIdPrefix}_select` && interaction.isStringSelectMenu()) {
|
if (
|
||||||
|
interaction.customId === `${config.customIdPrefix}_select` &&
|
||||||
|
interaction.isStringSelectMenu()
|
||||||
|
) {
|
||||||
const select = interaction as StringSelectMenuInteraction;
|
const select = interaction as StringSelectMenuInteraction;
|
||||||
const value = select.values?.[0];
|
const value = select.values?.[0];
|
||||||
const selected = baseOptions.find((opt) => opt.option.value === value);
|
const selected = baseOptions.find(
|
||||||
|
(opt) => opt.option.value === value
|
||||||
|
);
|
||||||
if (!selected) {
|
if (!selected) {
|
||||||
await select.reply({ content: '❌ Opción no válida.', flags: MessageFlags.Ephemeral });
|
await select.reply({
|
||||||
|
content: "❌ Opción no válida.",
|
||||||
|
flags: MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -384,73 +456,92 @@ export async function promptKeySelection<T>(
|
|||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
if (!select.deferred && !select.replied) {
|
if (!select.deferred && !select.replied) {
|
||||||
try { await select.deferUpdate(); } catch {}
|
try {
|
||||||
|
await select.deferUpdate();
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finish(selected.entry, 'selected');
|
finish(selected.entry, "selected");
|
||||||
collector.stop('selected');
|
collector.stop("selected");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.customId === `${config.customIdPrefix}_prev` && interaction.isButton()) {
|
if (
|
||||||
|
interaction.customId === `${config.customIdPrefix}_prev` &&
|
||||||
|
interaction.isButton()
|
||||||
|
) {
|
||||||
if (page > 0) page -= 1;
|
if (page > 0) page -= 1;
|
||||||
await interaction.update({ components: buildComponents() });
|
await interaction.update({ components: buildComponents() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.customId === `${config.customIdPrefix}_next` && interaction.isButton()) {
|
if (
|
||||||
|
interaction.customId === `${config.customIdPrefix}_next` &&
|
||||||
|
interaction.isButton()
|
||||||
|
) {
|
||||||
page += 1;
|
page += 1;
|
||||||
await interaction.update({ components: buildComponents() });
|
await interaction.update({ components: buildComponents() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.customId === `${config.customIdPrefix}_clear` && interaction.isButton()) {
|
if (
|
||||||
filter = '';
|
interaction.customId === `${config.customIdPrefix}_clear` &&
|
||||||
|
interaction.isButton()
|
||||||
|
) {
|
||||||
|
filter = "";
|
||||||
page = 0;
|
page = 0;
|
||||||
await interaction.update({ components: buildComponents() });
|
await interaction.update({ components: buildComponents() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.customId === `${config.customIdPrefix}_cancel` && interaction.isButton()) {
|
if (
|
||||||
|
interaction.customId === `${config.customIdPrefix}_cancel` &&
|
||||||
|
interaction.isButton()
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
await interaction.update({
|
await interaction.update({
|
||||||
components: [
|
components: [
|
||||||
{
|
{
|
||||||
type: 17,
|
type: 17,
|
||||||
accent_color: 0xFF0000,
|
accent_color: 0xff0000,
|
||||||
components: [
|
components: [
|
||||||
{ type: 10, content: '❌ Selección cancelada.' },
|
{ type: 10, content: "❌ Selección cancelada." },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
if (!interaction.deferred && !interaction.replied) {
|
if (!interaction.deferred && !interaction.replied) {
|
||||||
try { await interaction.deferUpdate(); } catch {}
|
try {
|
||||||
|
await interaction.deferUpdate();
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
finish(null, 'cancelled');
|
finish(null, "cancelled");
|
||||||
collector.stop('cancelled');
|
collector.stop("cancelled");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.customId === `${config.customIdPrefix}_filter` && interaction.isButton()) {
|
if (
|
||||||
|
interaction.customId === `${config.customIdPrefix}_filter` &&
|
||||||
|
interaction.isButton()
|
||||||
|
) {
|
||||||
const modal = {
|
const modal = {
|
||||||
title: 'Filtrar lista',
|
title: "Filtrar lista",
|
||||||
customId: `${config.customIdPrefix}_filter_modal`,
|
customId: `${config.customIdPrefix}_filter_modal`,
|
||||||
components: [
|
components: [
|
||||||
{
|
{
|
||||||
type: ComponentType.Label,
|
type: ComponentType.Label,
|
||||||
label: 'Texto a buscar',
|
label: "Texto a buscar",
|
||||||
component: {
|
component: {
|
||||||
type: ComponentType.TextInput,
|
type: ComponentType.TextInput,
|
||||||
customId: 'query',
|
customId: "query",
|
||||||
style: TextInputStyle.Short,
|
style: TextInputStyle.Short,
|
||||||
required: false,
|
required: false,
|
||||||
value: filter,
|
value: filter,
|
||||||
placeholder: 'Nombre, key, categoría…',
|
placeholder: "Nombre, key, categoría…",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -461,14 +552,17 @@ export async function promptKeySelection<T>(
|
|||||||
try {
|
try {
|
||||||
submitted = await interaction.awaitModalSubmit({
|
submitted = await interaction.awaitModalSubmit({
|
||||||
time: 120_000,
|
time: 120_000,
|
||||||
filter: (sub) => sub.user.id === userId && sub.customId === `${config.customIdPrefix}_filter_modal`,
|
filter: (sub) =>
|
||||||
|
sub.user.id === userId &&
|
||||||
|
sub.customId === `${config.customIdPrefix}_filter_modal`,
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const value = submitted.components.getTextInputValue('query')?.trim() ?? '';
|
const value =
|
||||||
|
submitted.components.getTextInputValue("query")?.trim() ?? "";
|
||||||
filter = value;
|
filter = value;
|
||||||
page = 0;
|
page = 0;
|
||||||
await submitted.deferUpdate();
|
await submitted.deferUpdate();
|
||||||
@@ -480,31 +574,33 @@ export async function promptKeySelection<T>(
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!interaction.deferred && !interaction.replied) {
|
if (!interaction.deferred && !interaction.replied) {
|
||||||
await interaction.reply({ content: '❌ Error procesando la selección.', flags: MessageFlags.Ephemeral });
|
await interaction.reply({
|
||||||
}
|
content: "❌ Error procesando la selección.",
|
||||||
}
|
flags: MessageFlags.Ephemeral,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
collector.on('end', async (_collected, reason) => {
|
collector.on("end", async (_collected, reason) => {
|
||||||
if (resolved) return;
|
if (resolved) return;
|
||||||
resolved = true;
|
resolved = true;
|
||||||
if (reason !== 'selected' && reason !== 'cancelled') {
|
if (reason !== "selected" && reason !== "cancelled") {
|
||||||
const expiredPanel = {
|
const expiredPanel = {
|
||||||
type: 17,
|
type: 17,
|
||||||
accent_color: 0xFFA500,
|
accent_color: 0xffa500,
|
||||||
components: [
|
components: [{ type: 10, content: "⏰ Selección expirada." }],
|
||||||
{ type: 10, content: '⏰ Selección expirada.' },
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
await panelMessage.edit({ components: [expiredPanel] });
|
await panelMessage.edit({ components: [expiredPanel] });
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
let mappedReason: 'selected' | 'cancelled' | 'timeout';
|
let mappedReason: "selected" | "cancelled" | "timeout";
|
||||||
if (reason === 'selected') mappedReason = 'selected';
|
if (reason === "selected") mappedReason = "selected";
|
||||||
else if (reason === 'cancelled') mappedReason = 'cancelled';
|
else if (reason === "cancelled") mappedReason = "cancelled";
|
||||||
else mappedReason = 'timeout';
|
else mappedReason = "timeout";
|
||||||
|
|
||||||
resolve({ entry: null, panelMessage, reason: mappedReason });
|
resolve({ entry: null, panelMessage, reason: mappedReason });
|
||||||
});
|
});
|
||||||
@@ -513,13 +609,15 @@ export async function promptKeySelection<T>(
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendDisplayReply(message: Message, display: any, extraComponents: any[] = []) {
|
export function sendDisplayReply(
|
||||||
|
message: Message,
|
||||||
|
display: any,
|
||||||
|
extraComponents: any[] = []
|
||||||
|
) {
|
||||||
const channel = message.channel as TextBasedChannel & { send: Function };
|
const channel = message.channel as TextBasedChannel & { send: Function };
|
||||||
return (channel.send as any)({
|
return (channel.send as any)({
|
||||||
content: null,
|
|
||||||
flags: 32768,
|
flags: 32768,
|
||||||
reply: { messageReference: message.id },
|
message_reference: { message_id: message.id },
|
||||||
components: [display, ...extraComponents],
|
components: [display, ...extraComponents],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import type Amayo from "../../../core/client";
|
|||||||
import { getStreakInfo, updateStreak } from "../../../game/streaks/service";
|
import { getStreakInfo, updateStreak } from "../../../game/streaks/service";
|
||||||
import type { TextBasedChannel } from "discord.js";
|
import type { TextBasedChannel } from "discord.js";
|
||||||
import { fetchItemBasics, formatItemLabel, sendDisplayReply } from "./_helpers";
|
import { fetchItemBasics, formatItemLabel, sendDisplayReply } from "./_helpers";
|
||||||
|
import {
|
||||||
|
buildDisplay,
|
||||||
|
textBlock,
|
||||||
|
dividerBlock,
|
||||||
|
} from "../../../core/lib/componentsV2";
|
||||||
|
|
||||||
export const command: CommandMessage = {
|
export const command: CommandMessage = {
|
||||||
name: "racha",
|
name: "racha",
|
||||||
@@ -22,51 +27,33 @@ export const command: CommandMessage = {
|
|||||||
guildId
|
guildId
|
||||||
);
|
);
|
||||||
|
|
||||||
// Construir componentes
|
// Construir bloques de display (evitando type:9 sin accessory)
|
||||||
const components: any[] = [
|
const blocks: any[] = [
|
||||||
{
|
textBlock(`# 🔥 Racha Diaria de ${message.author.username}`),
|
||||||
type: 10,
|
dividerBlock(),
|
||||||
content: `# 🔥 Racha Diaria de ${message.author.username}`,
|
textBlock(
|
||||||
},
|
|
||||||
{ type: 14, divider: true },
|
|
||||||
{
|
|
||||||
type: 9,
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
type: 10,
|
|
||||||
content:
|
|
||||||
`**📊 ESTADÍSTICAS**\n` +
|
`**📊 ESTADÍSTICAS**\n` +
|
||||||
`🔥 Racha Actual: **${streak.currentStreak}** días\n` +
|
`🔥 Racha Actual: **${streak.currentStreak}** días\n` +
|
||||||
`⭐ Mejor Racha: **${streak.longestStreak}** días\n` +
|
`⭐ Mejor Racha: **${streak.longestStreak}** días\n` +
|
||||||
`📅 Días Activos: **${streak.totalDaysActive}** días`,
|
`📅 Días Activos: **${streak.totalDaysActive}** días`
|
||||||
},
|
),
|
||||||
],
|
dividerBlock({ spacing: 1 }),
|
||||||
},
|
|
||||||
{ type: 14, spacing: 1 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Mensaje de estado
|
// Mensaje de estado
|
||||||
if (newDay) {
|
if (newDay) {
|
||||||
if (daysIncreased) {
|
if (daysIncreased) {
|
||||||
components.push({
|
blocks.push(
|
||||||
type: 9,
|
textBlock(
|
||||||
components: [
|
`**✅ ¡RACHA INCREMENTADA!**\nHas mantenido tu racha por **${streak.currentStreak}** días seguidos.`
|
||||||
{
|
)
|
||||||
type: 10,
|
);
|
||||||
content: `**✅ ¡RACHA INCREMENTADA!**\nHas mantenido tu racha por **${streak.currentStreak}** días seguidos.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
} else {
|
} else {
|
||||||
components.push({
|
blocks.push(
|
||||||
type: 9,
|
textBlock(
|
||||||
components: [
|
`**⚠️ RACHA REINICIADA**\nPasó más de un día sin actividad. Tu racha se ha reiniciado.`
|
||||||
{
|
)
|
||||||
type: 10,
|
);
|
||||||
content: `**⚠️ RACHA REINICIADA**\nPasó más de un día sin actividad. Tu racha se ha reiniciado.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mostrar recompensas
|
// Mostrar recompensas
|
||||||
@@ -90,27 +77,15 @@ export const command: CommandMessage = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
components.push({ type: 14, spacing: 1 });
|
blocks.push(dividerBlock({ spacing: 1 }));
|
||||||
components.push({
|
blocks.push(textBlock(rewardsText));
|
||||||
type: 9,
|
|
||||||
components: [
|
|
||||||
{
|
|
||||||
type: 10,
|
|
||||||
content: rewardsText,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
components.push({
|
blocks.push(
|
||||||
type: 9,
|
textBlock(
|
||||||
components: [
|
`**ℹ️ YA RECLAMASTE HOY**\nYa has reclamado tu recompensa diaria. Vuelve mañana para continuar tu racha.`
|
||||||
{
|
)
|
||||||
type: 10,
|
);
|
||||||
content: `**ℹ️ YA RECLAMASTE HOY**\nYa has reclamado tu recompensa diaria. Vuelve mañana para continuar tu racha.`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Próximos hitos
|
// Próximos hitos
|
||||||
@@ -119,23 +94,15 @@ export const command: CommandMessage = {
|
|||||||
|
|
||||||
if (nextMilestone) {
|
if (nextMilestone) {
|
||||||
const remaining = nextMilestone - streak.currentStreak;
|
const remaining = nextMilestone - streak.currentStreak;
|
||||||
components.push({ type: 14, spacing: 1 });
|
blocks.push(dividerBlock({ spacing: 1 }));
|
||||||
components.push({
|
blocks.push(
|
||||||
type: 9,
|
textBlock(
|
||||||
components: [
|
`**🎯 PRÓXIMO HITO**\nFaltan **${remaining}** días para alcanzar el día **${nextMilestone}**`
|
||||||
{
|
)
|
||||||
type: 10,
|
);
|
||||||
content: `**🎯 PRÓXIMO HITO**\nFaltan **${remaining}** días para alcanzar el día **${nextMilestone}**`,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const display = {
|
const display = buildDisplay(daysIncreased ? 0x00ff00 : 0xffa500, blocks);
|
||||||
type: 17,
|
|
||||||
accent_color: daysIncreased ? 0x00ff00 : 0xffa500,
|
|
||||||
components,
|
|
||||||
};
|
|
||||||
|
|
||||||
await sendDisplayReply(message, display);
|
await sendDisplayReply(message, display);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -3,94 +3,118 @@ import { CommandMessage } from "../../../core/types/commands";
|
|||||||
import { ComponentType, TextInputStyle } from "discord-api-types/v10";
|
import { ComponentType, TextInputStyle } from "discord-api-types/v10";
|
||||||
import { hasManageGuildOrStaff } from "../../../core/lib/permissions";
|
import { hasManageGuildOrStaff } from "../../../core/lib/permissions";
|
||||||
import { aiService } from "../../../core/services/AIService";
|
import { aiService } from "../../../core/services/AIService";
|
||||||
|
import { invalidateGuildCache } from "../../../core/database/guildCache";
|
||||||
|
|
||||||
function toStringArray(input: unknown): string[] {
|
function toStringArray(input: unknown): string[] {
|
||||||
if (!Array.isArray(input)) return [];
|
if (!Array.isArray(input)) return [];
|
||||||
return (input as unknown[]).filter((v): v is string => typeof v === 'string');
|
return (input as unknown[]).filter((v): v is string => typeof v === "string");
|
||||||
}
|
}
|
||||||
|
|
||||||
export const command: CommandMessage = {
|
export const command: CommandMessage = {
|
||||||
name: 'configuracion',
|
name: "configuracion",
|
||||||
type: "message",
|
type: "message",
|
||||||
aliases: ['config', 'ajustes', 'settings'],
|
aliases: ["config", "ajustes", "settings"],
|
||||||
cooldown: 5,
|
cooldown: 5,
|
||||||
description: 'Abre el panel de configuración del servidor (prefix, staff y más).',
|
description:
|
||||||
category: 'Configuración',
|
"Abre el panel de configuración del servidor (prefix, staff y más).",
|
||||||
usage: 'configuracion',
|
category: "Configuración",
|
||||||
|
usage: "configuracion",
|
||||||
run: async (message, args, client) => {
|
run: async (message, args, client) => {
|
||||||
const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, client.prisma);
|
const allowed = await hasManageGuildOrStaff(
|
||||||
|
message.member,
|
||||||
|
message.guild!.id,
|
||||||
|
client.prisma
|
||||||
|
);
|
||||||
if (!allowed) {
|
if (!allowed) {
|
||||||
await message.reply("❌ No tienes permisos de ManageGuild ni rol de staff.");
|
await message.reply(
|
||||||
|
"❌ No tienes permisos de ManageGuild ni rol de staff."
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = await client.prisma.guild.findFirst({
|
const server = await client.prisma.guild.findFirst({
|
||||||
where: { id: message.guild!.id }
|
where: { id: message.guild!.id },
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentPrefix = server?.prefix || "!";
|
const currentPrefix = server?.prefix || "!";
|
||||||
const staffRoles: string[] = toStringArray(server?.staff);
|
const staffRoles: string[] = toStringArray(server?.staff);
|
||||||
const staffDisplay = staffRoles.length
|
const staffDisplay = staffRoles.length
|
||||||
? staffRoles.map((id) => `<@&${id}>`).join(', ')
|
? staffRoles.map((id) => `<@&${id}>`).join(", ")
|
||||||
: 'Sin staff configurado';
|
: "Sin staff configurado";
|
||||||
const aiRolePrompt = server?.aiRolePrompt ?? null;
|
const aiRolePrompt = server?.aiRolePrompt ?? null;
|
||||||
const aiPreview = aiRolePrompt ? (aiRolePrompt.length > 80 ? aiRolePrompt.slice(0, 77) + '…' : aiRolePrompt) : 'No configurado';
|
const aiPreview = aiRolePrompt
|
||||||
|
? aiRolePrompt.length > 80
|
||||||
|
? aiRolePrompt.slice(0, 77) + "…"
|
||||||
|
: aiRolePrompt
|
||||||
|
: "No configurado";
|
||||||
|
|
||||||
// Panel de configuración usando DisplayComponents
|
// Panel de configuración usando DisplayComponents
|
||||||
const settingsPanel = {
|
const settingsPanel = {
|
||||||
type: 17,
|
type: 17,
|
||||||
accent_color: 6178018, // Color del ejemplo
|
accent_color: 6178018, // Color del ejemplo
|
||||||
components: [
|
components: [
|
||||||
{ type: 10, content: "### <:invisible:1418684224441028608> 梅,panel admin,📢\n" },
|
{
|
||||||
|
type: 10,
|
||||||
|
content: "### <:invisible:1418684224441028608> 梅,panel admin,📢\n",
|
||||||
|
},
|
||||||
{ type: 14, spacing: 1, divider: false },
|
{ type: 14, spacing: 1, divider: false },
|
||||||
{ type: 10, content: "Configuracion del Servidor:" },
|
{ type: 10, content: "Configuracion del Servidor:" },
|
||||||
{
|
{
|
||||||
type: 9,
|
type: 9,
|
||||||
components: [ { type: 10, content: `**Prefix:**<:invisible:1418684224441028608>\`${currentPrefix}\`` } ],
|
components: [
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: `**Prefix:**<:invisible:1418684224441028608>\`${currentPrefix}\``,
|
||||||
|
},
|
||||||
|
],
|
||||||
accessory: {
|
accessory: {
|
||||||
type: 2,
|
type: 2,
|
||||||
style: 2,
|
style: 2,
|
||||||
emoji: { name: "⚙️" },
|
emoji: { name: "⚙️" },
|
||||||
custom_id: "open_prefix_modal",
|
custom_id: "open_prefix_modal",
|
||||||
label: "Cambiar"
|
label: "Cambiar",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{ type: 14, divider: false },
|
{ type: 14, divider: false },
|
||||||
{
|
{
|
||||||
type: 9,
|
type: 9,
|
||||||
components: [ { type: 10, content: `**Staff (roles):** ${staffDisplay}` } ],
|
components: [
|
||||||
|
{ type: 10, content: `**Staff (roles):** ${staffDisplay}` },
|
||||||
|
],
|
||||||
accessory: {
|
accessory: {
|
||||||
type: 2,
|
type: 2,
|
||||||
style: 2, // Secondary
|
style: 2, // Secondary
|
||||||
emoji: { name: "🛡️" },
|
emoji: { name: "🛡️" },
|
||||||
custom_id: "open_staff_modal",
|
custom_id: "open_staff_modal",
|
||||||
label: "Configurar"
|
label: "Configurar",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{ type: 14, divider: false },
|
{ type: 14, divider: false },
|
||||||
{
|
{
|
||||||
type: 9,
|
type: 9,
|
||||||
components: [ { type: 10, content: `**AI Role Prompt:** ${aiPreview}` } ],
|
components: [
|
||||||
|
{ type: 10, content: `**AI Role Prompt:** ${aiPreview}` },
|
||||||
|
],
|
||||||
accessory: {
|
accessory: {
|
||||||
type: 2,
|
type: 2,
|
||||||
style: 2,
|
style: 2,
|
||||||
emoji: { name: "🧠" },
|
emoji: { name: "🧠" },
|
||||||
custom_id: "open_ai_role_modal",
|
custom_id: "open_ai_role_modal",
|
||||||
label: "Configurar"
|
label: "Configurar",
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{ type: 14, divider: false }
|
},
|
||||||
]
|
{ type: 14, divider: false },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const panelMessage = await message.reply({
|
const panelMessage = await message.reply({
|
||||||
flags: 32768, // Components v2
|
flags: 32768, // Components v2
|
||||||
components: [settingsPanel]
|
components: [settingsPanel],
|
||||||
});
|
});
|
||||||
|
|
||||||
const collector = panelMessage.createMessageComponentCollector({
|
const collector = panelMessage.createMessageComponentCollector({
|
||||||
time: 300000, // 5 minutos
|
time: 300000, // 5 minutos
|
||||||
filter: (i: any) => i.user.id === message.author.id
|
filter: (i: any) => i.user.id === message.author.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
collector.on("collect", async (interaction: any) => {
|
collector.on("collect", async (interaction: any) => {
|
||||||
@@ -111,8 +135,8 @@ export const command: CommandMessage = {
|
|||||||
required: true,
|
required: true,
|
||||||
maxLength: 10,
|
maxLength: 10,
|
||||||
minLength: 1,
|
minLength: 1,
|
||||||
value: currentPrefix
|
value: currentPrefix,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: ComponentType.Label,
|
type: ComponentType.Label,
|
||||||
@@ -123,56 +147,115 @@ export const command: CommandMessage = {
|
|||||||
style: TextInputStyle.Paragraph,
|
style: TextInputStyle.Paragraph,
|
||||||
placeholder: "Ej: evitar conflictos con otros bots...",
|
placeholder: "Ej: evitar conflictos con otros bots...",
|
||||||
required: false,
|
required: false,
|
||||||
maxLength: 200
|
maxLength: 200,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await interaction.showModal(prefixModal);
|
await interaction.showModal(prefixModal);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { await interaction.reply({ content: '❌ No se pudo abrir el modal de prefix.', flags: 64 }); } catch {}
|
try {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "❌ No se pudo abrir el modal de prefix.",
|
||||||
|
flags: 64,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const modalInteraction = await interaction.awaitModalSubmit({
|
const modalInteraction = await interaction.awaitModalSubmit({
|
||||||
time: 300000,
|
time: 300000,
|
||||||
filter: (modalInt: any) => modalInt.customId === "prefix_settings_modal" && modalInt.user.id === message.author.id
|
filter: (modalInt: any) =>
|
||||||
|
modalInt.customId === "prefix_settings_modal" &&
|
||||||
|
modalInt.user.id === message.author.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newPrefix = modalInteraction.components.getTextInputValue("new_prefix_input");
|
const newPrefix =
|
||||||
const description = modalInteraction.components.getTextInputValue("prefix_description") || "Sin descripción";
|
modalInteraction.components.getTextInputValue("new_prefix_input");
|
||||||
|
const description =
|
||||||
|
modalInteraction.components.getTextInputValue(
|
||||||
|
"prefix_description"
|
||||||
|
) || "Sin descripción";
|
||||||
|
|
||||||
if (!newPrefix || newPrefix.length > 10) {
|
if (!newPrefix || newPrefix.length > 10) {
|
||||||
await modalInteraction.reply({ content: "❌ **Error:** El prefix debe tener entre 1 y 10 caracteres.", flags: 64 });
|
await modalInteraction.reply({
|
||||||
|
content:
|
||||||
|
"❌ **Error:** El prefix debe tener entre 1 y 10 caracteres.",
|
||||||
|
flags: 64,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.prisma.guild.upsert({
|
await client.prisma.guild.upsert({
|
||||||
where: { id: message.guild!.id },
|
where: { id: message.guild!.id },
|
||||||
create: { id: message.guild!.id, name: message.guild!.name, prefix: newPrefix },
|
create: {
|
||||||
update: { prefix: newPrefix, name: message.guild!.name }
|
id: message.guild!.id,
|
||||||
|
name: message.guild!.name,
|
||||||
|
prefix: newPrefix,
|
||||||
|
},
|
||||||
|
update: { prefix: newPrefix, name: message.guild!.name },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Invalidar el caché del guild para reflejar el cambio
|
||||||
|
await invalidateGuildCache(message.guild!.id);
|
||||||
|
|
||||||
const successPanel = {
|
const successPanel = {
|
||||||
type: 17,
|
type: 17,
|
||||||
accent_color: 3066993,
|
accent_color: 3066993,
|
||||||
components: [
|
components: [
|
||||||
{ type: 10, content: "### ✅ **Prefix Actualizado Exitosamente**" },
|
{
|
||||||
|
type: 10,
|
||||||
|
content: "### ✅ **Prefix Actualizado Exitosamente**",
|
||||||
|
},
|
||||||
{ type: 14, spacing: 2, divider: true },
|
{ type: 14, spacing: 2, divider: true },
|
||||||
{ type: 9, components: [ { type: 10, content: `**Prefix anterior:** \`${currentPrefix}\`\n**Prefix nuevo:** \`${newPrefix}\`\n\n**Motivo:** ${description}` } ], accessory: { type: 2, style: 3, label: "✓ Listo", custom_id: "prefix_confirmed", emoji: { name: "✅" } } },
|
{
|
||||||
|
type: 9,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 10,
|
||||||
|
content: `**Prefix anterior:** \`${currentPrefix}\`\n**Prefix nuevo:** \`${newPrefix}\`\n\n**Motivo:** ${description}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
accessory: {
|
||||||
|
type: 2,
|
||||||
|
style: 3,
|
||||||
|
label: "✓ Listo",
|
||||||
|
custom_id: "prefix_confirmed",
|
||||||
|
emoji: { name: "✅" },
|
||||||
|
},
|
||||||
|
},
|
||||||
{ type: 14, spacing: 1, divider: false },
|
{ type: 14, spacing: 1, divider: false },
|
||||||
{ type: 10, content: "🚀 **¡Listo!** Ahora puedes usar los comandos con el nuevo prefix.\n\n💡 **Ejemplo:** `" + newPrefix + "help`, `" + newPrefix + "embedlist`" }
|
{
|
||||||
]
|
type: 10,
|
||||||
|
content:
|
||||||
|
"🚀 **¡Listo!** Ahora puedes usar los comandos con el nuevo prefix.\n\n💡 **Ejemplo:** `" +
|
||||||
|
newPrefix +
|
||||||
|
"help`, `" +
|
||||||
|
newPrefix +
|
||||||
|
"embedlist`",
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const backToSettingsRow = { type: 1, components: [ { type: 2, style: 2, label: "↩️ Volver a Configuración", custom_id: "back_to_settings" } ] };
|
const backToSettingsRow = {
|
||||||
|
type: 1,
|
||||||
await modalInteraction.update({ components: [successPanel, backToSettingsRow] });
|
components: [
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
style: 2,
|
||||||
|
label: "↩️ Volver a Configuración",
|
||||||
|
custom_id: "back_to_settings",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await modalInteraction.update({
|
||||||
|
components: [successPanel, backToSettingsRow],
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorPanel = {
|
const errorPanel = {
|
||||||
type: 17,
|
type: 17,
|
||||||
@@ -180,16 +263,40 @@ export const command: CommandMessage = {
|
|||||||
components: [
|
components: [
|
||||||
{ type: 10, content: "### ❌ **Error al Actualizar Prefix**" },
|
{ type: 10, content: "### ❌ **Error al Actualizar Prefix**" },
|
||||||
{ type: 14, spacing: 2, divider: true },
|
{ type: 14, spacing: 2, divider: true },
|
||||||
{ type: 10, content: `**Error:** No se pudo actualizar el prefix a \`${newPrefix}\`\n\n**Posibles causas:**\n• Error de conexión con la base de datos\n• Prefix contiene caracteres no válidos\n• Permisos insuficientes\n\n🔄 **Solución:** Intenta nuevamente con un prefix diferente.` }
|
{
|
||||||
]
|
type: 10,
|
||||||
|
content: `**Error:** No se pudo actualizar el prefix a \`${newPrefix}\`\n\n**Posibles causas:**\n• Error de conexión con la base de datos\n• Prefix contiene caracteres no válidos\n• Permisos insuficientes\n\n🔄 **Solución:** Intenta nuevamente con un prefix diferente.`,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const retryRow = { type: 1, components: [ { type: 2, style: 2, label: "🔄 Reintentar", custom_id: "open_prefix_modal" }, { type: 2, style: 4, label: "❌ Cancelar", custom_id: "cancel_prefix_change" } ] };
|
const retryRow = {
|
||||||
|
type: 1,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
style: 2,
|
||||||
|
label: "🔄 Reintentar",
|
||||||
|
custom_id: "open_prefix_modal",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
style: 4,
|
||||||
|
label: "❌ Cancelar",
|
||||||
|
custom_id: "cancel_prefix_change",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
await modalInteraction.update({ components: [errorPanel, retryRow] });
|
await modalInteraction.update({
|
||||||
|
components: [errorPanel, retryRow],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.info("Modal timeout o error:", error?.message || String(error));
|
logger.info(
|
||||||
|
"Modal timeout o error:",
|
||||||
|
error?.message || String(error)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,25 +306,50 @@ export const command: CommandMessage = {
|
|||||||
title: "🛡️ Configurar Roles de Staff",
|
title: "🛡️ Configurar Roles de Staff",
|
||||||
customId: "staff_roles_modal",
|
customId: "staff_roles_modal",
|
||||||
components: [
|
components: [
|
||||||
{ type: ComponentType.Label, label: "Selecciona hasta 3 roles de staff", component: { type: ComponentType.RoleSelect, customId: "staff_roles", required: false, minValues: 0, maxValues: 3, placeholder: "Roles de staff..." } }
|
{
|
||||||
]
|
type: ComponentType.Label,
|
||||||
|
label: "Selecciona hasta 3 roles de staff",
|
||||||
|
component: {
|
||||||
|
type: ComponentType.RoleSelect,
|
||||||
|
customId: "staff_roles",
|
||||||
|
required: false,
|
||||||
|
minValues: 0,
|
||||||
|
maxValues: 3,
|
||||||
|
placeholder: "Roles de staff...",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
await interaction.showModal(staffModal);
|
await interaction.showModal(staffModal);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const modalInteraction = await interaction.awaitModalSubmit({ time: 300000 });
|
const modalInteraction = await interaction.awaitModalSubmit({
|
||||||
const selected = modalInteraction.components.getSelectedRoles('staff_roles');
|
time: 300000,
|
||||||
|
});
|
||||||
|
const selected =
|
||||||
|
modalInteraction.components.getSelectedRoles("staff_roles");
|
||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
const roleIds: string[] = selected ? Array.from(selected.keys()).slice(0, 3) : [];
|
const roleIds: string[] = selected
|
||||||
|
? Array.from(selected.keys()).slice(0, 3)
|
||||||
|
: [];
|
||||||
|
|
||||||
await client.prisma.guild.upsert({
|
await client.prisma.guild.upsert({
|
||||||
where: { id: message.guild!.id },
|
where: { id: message.guild!.id },
|
||||||
create: { id: message.guild!.id, name: message.guild!.name, staff: roleIds },
|
create: {
|
||||||
update: { staff: roleIds, name: message.guild!.name }
|
id: message.guild!.id,
|
||||||
|
name: message.guild!.name,
|
||||||
|
staff: roleIds,
|
||||||
|
},
|
||||||
|
update: { staff: roleIds, name: message.guild!.name },
|
||||||
});
|
});
|
||||||
|
|
||||||
const updatedDisplay = roleIds.length ? roleIds.map((id) => `<@&${id}>`).join(', ') : 'Sin staff configurado';
|
// Invalidar el caché del guild para reflejar el cambio
|
||||||
|
await invalidateGuildCache(message.guild!.id);
|
||||||
|
|
||||||
|
const updatedDisplay = roleIds.length
|
||||||
|
? roleIds.map((id) => `<@&${id}>`).join(", ")
|
||||||
|
: "Sin staff configurado";
|
||||||
|
|
||||||
const successPanel = {
|
const successPanel = {
|
||||||
type: 17,
|
type: 17,
|
||||||
@@ -225,20 +357,37 @@ export const command: CommandMessage = {
|
|||||||
components: [
|
components: [
|
||||||
{ type: 10, content: "### ✅ **Staff Actualizado**" },
|
{ type: 10, content: "### ✅ **Staff Actualizado**" },
|
||||||
{ type: 14, spacing: 2, divider: true },
|
{ type: 14, spacing: 2, divider: true },
|
||||||
{ type: 10, content: `**Nuevos roles de staff:** ${updatedDisplay}` }
|
{
|
||||||
]
|
type: 10,
|
||||||
|
content: `**Nuevos roles de staff:** ${updatedDisplay}`,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const backRow = { type: 1, components: [ { type: 2, style: 2, label: '↩️ Volver a Configuración', custom_id: 'back_to_settings' } ] };
|
const backRow = {
|
||||||
await modalInteraction.update({ components: [successPanel, backRow] });
|
type: 1,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
style: 2,
|
||||||
|
label: "↩️ Volver a Configuración",
|
||||||
|
custom_id: "back_to_settings",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
await modalInteraction.update({
|
||||||
|
components: [successPanel, backRow],
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// timeout o error
|
// timeout o error
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.customId === "open_ai_role_modal") {
|
if (interaction.customId === "open_ai_role_modal") {
|
||||||
const currentServer = await client.prisma.guild.findFirst({ where: { id: message.guild!.id } });
|
const currentServer = await client.prisma.guild.findFirst({
|
||||||
const currentAiPrompt = currentServer?.aiRolePrompt ?? '';
|
where: { id: message.guild!.id },
|
||||||
|
});
|
||||||
|
const currentAiPrompt = currentServer?.aiRolePrompt ?? "";
|
||||||
const aiModal = {
|
const aiModal = {
|
||||||
title: "🧠 Configurar AI Role Prompt",
|
title: "🧠 Configurar AI Role Prompt",
|
||||||
customId: "ai_role_prompt_modal",
|
customId: "ai_role_prompt_modal",
|
||||||
@@ -251,41 +400,63 @@ export const command: CommandMessage = {
|
|||||||
customId: "ai_role_prompt_input",
|
customId: "ai_role_prompt_input",
|
||||||
style: TextInputStyle.Paragraph,
|
style: TextInputStyle.Paragraph,
|
||||||
required: false,
|
required: false,
|
||||||
placeholder: "Ej: Eres un asistente amistoso del servidor, responde en español, evita spoilers...",
|
placeholder:
|
||||||
|
"Ej: Eres un asistente amistoso del servidor, responde en español, evita spoilers...",
|
||||||
maxLength: 1500,
|
maxLength: 1500,
|
||||||
value: currentAiPrompt.slice(0, 1500)
|
value: currentAiPrompt.slice(0, 1500),
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await interaction.showModal(aiModal);
|
await interaction.showModal(aiModal);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
try { await interaction.reply({ content: '❌ No se pudo abrir el modal de AI.', flags: 64 }); } catch {}
|
try {
|
||||||
|
await interaction.reply({
|
||||||
|
content: "❌ No se pudo abrir el modal de AI.",
|
||||||
|
flags: 64,
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const modalInteraction = await interaction.awaitModalSubmit({
|
const modalInteraction = await interaction.awaitModalSubmit({
|
||||||
time: 300000,
|
time: 300000,
|
||||||
filter: (m: any) => m.customId === 'ai_role_prompt_modal' && m.user.id === message.author.id
|
filter: (m: any) =>
|
||||||
|
m.customId === "ai_role_prompt_modal" &&
|
||||||
|
m.user.id === message.author.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const newPromptRaw = modalInteraction.components.getTextInputValue('ai_role_prompt_input') ?? '';
|
const newPromptRaw =
|
||||||
|
modalInteraction.components.getTextInputValue(
|
||||||
|
"ai_role_prompt_input"
|
||||||
|
) ?? "";
|
||||||
const newPrompt = newPromptRaw.trim();
|
const newPrompt = newPromptRaw.trim();
|
||||||
const toSave: string | null = newPrompt.length > 0 ? newPrompt : null;
|
const toSave: string | null = newPrompt.length > 0 ? newPrompt : null;
|
||||||
|
|
||||||
await client.prisma.guild.upsert({
|
await client.prisma.guild.upsert({
|
||||||
where: { id: message.guild!.id },
|
where: { id: message.guild!.id },
|
||||||
create: { id: message.guild!.id, name: message.guild!.name, aiRolePrompt: toSave },
|
create: {
|
||||||
update: { aiRolePrompt: toSave, name: message.guild!.name }
|
id: message.guild!.id,
|
||||||
|
name: message.guild!.name,
|
||||||
|
aiRolePrompt: toSave,
|
||||||
|
},
|
||||||
|
update: { aiRolePrompt: toSave, name: message.guild!.name },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Invalida el cache del servicio para reflejar cambios al instante
|
// Invalida el cache del servicio para reflejar cambios al instante
|
||||||
aiService.invalidateGuildConfig(message.guild!.id);
|
aiService.invalidateGuildConfig(message.guild!.id);
|
||||||
|
|
||||||
const preview = toSave ? (toSave.length > 200 ? toSave.slice(0, 197) + '…' : toSave) : 'Prompt eliminado (sin configuración)';
|
// Invalidar el caché del guild también
|
||||||
|
await invalidateGuildCache(message.guild!.id);
|
||||||
|
|
||||||
|
const preview = toSave
|
||||||
|
? toSave.length > 200
|
||||||
|
? toSave.slice(0, 197) + "…"
|
||||||
|
: toSave
|
||||||
|
: "Prompt eliminado (sin configuración)";
|
||||||
|
|
||||||
const successPanel = {
|
const successPanel = {
|
||||||
type: 17,
|
type: 17,
|
||||||
@@ -293,12 +464,24 @@ export const command: CommandMessage = {
|
|||||||
components: [
|
components: [
|
||||||
{ type: 10, content: "### ✅ **AI Role Prompt Actualizado**" },
|
{ type: 10, content: "### ✅ **AI Role Prompt Actualizado**" },
|
||||||
{ type: 14, spacing: 2, divider: true },
|
{ type: 14, spacing: 2, divider: true },
|
||||||
{ type: 10, content: `**Nuevo valor:**\n${preview}` }
|
{ type: 10, content: `**Nuevo valor:**\n${preview}` },
|
||||||
]
|
],
|
||||||
|
};
|
||||||
|
const backRow = {
|
||||||
|
type: 1,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: 2,
|
||||||
|
style: 2,
|
||||||
|
label: "↩️ Volver a Configuración",
|
||||||
|
custom_id: "back_to_settings",
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
const backRow = { type: 1, components: [ { type: 2, style: 2, label: '↩️ Volver a Configuración', custom_id: 'back_to_settings' } ] };
|
|
||||||
|
|
||||||
await modalInteraction.update({ components: [successPanel, backRow] });
|
await modalInteraction.update({
|
||||||
|
components: [successPanel, backRow],
|
||||||
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// timeout o cancelado
|
// timeout o cancelado
|
||||||
}
|
}
|
||||||
@@ -306,27 +489,75 @@ export const command: CommandMessage = {
|
|||||||
|
|
||||||
// Manejar botones adicionales
|
// Manejar botones adicionales
|
||||||
if (interaction.customId === "back_to_settings") {
|
if (interaction.customId === "back_to_settings") {
|
||||||
const updatedServer = await client.prisma.guild.findFirst({ where: { id: message.guild!.id } });
|
const updatedServer = await client.prisma.guild.findFirst({
|
||||||
|
where: { id: message.guild!.id },
|
||||||
|
});
|
||||||
const newCurrentPrefix = updatedServer?.prefix || "!";
|
const newCurrentPrefix = updatedServer?.prefix || "!";
|
||||||
const staffRoles2: string[] = toStringArray(updatedServer?.staff);
|
const staffRoles2: string[] = toStringArray(updatedServer?.staff);
|
||||||
const staffDisplay2 = staffRoles2.length ? staffRoles2.map((id) => `<@&${id}>`).join(', ') : 'Sin staff configurado';
|
const staffDisplay2 = staffRoles2.length
|
||||||
|
? staffRoles2.map((id) => `<@&${id}>`).join(", ")
|
||||||
|
: "Sin staff configurado";
|
||||||
const aiRolePrompt2 = updatedServer?.aiRolePrompt ?? null;
|
const aiRolePrompt2 = updatedServer?.aiRolePrompt ?? null;
|
||||||
const aiPreview2 = aiRolePrompt2 ? (aiRolePrompt2.length > 80 ? aiRolePrompt2.slice(0, 77) + '…' : aiRolePrompt2) : 'No configurado';
|
const aiPreview2 = aiRolePrompt2
|
||||||
|
? aiRolePrompt2.length > 80
|
||||||
|
? aiRolePrompt2.slice(0, 77) + "…"
|
||||||
|
: aiRolePrompt2
|
||||||
|
: "No configurado";
|
||||||
|
|
||||||
const updatedSettingsPanel = {
|
const updatedSettingsPanel = {
|
||||||
type: 17,
|
type: 17,
|
||||||
accent_color: 6178018,
|
accent_color: 6178018,
|
||||||
components: [
|
components: [
|
||||||
{ type: 10, content: "### <:invisible:1418684224441028608> 梅,panel admin,📢\n" },
|
{
|
||||||
|
type: 10,
|
||||||
|
content:
|
||||||
|
"### <:invisible:1418684224441028608> 梅,panel admin,📢\n",
|
||||||
|
},
|
||||||
{ type: 14, spacing: 1, divider: false },
|
{ type: 14, spacing: 1, divider: false },
|
||||||
{ type: 10, content: "Configuracion del Servidor:" },
|
{ type: 10, content: "Configuracion del Servidor:" },
|
||||||
{ type: 9, components: [ { type: 10, content: `**Prefix:** \`${newCurrentPrefix}\`` } ], accessory: { type: 2, style: 2, emoji: { name: "⚙️" }, custom_id: "open_prefix_modal", label: "Cambiar" } },
|
{
|
||||||
|
type: 9,
|
||||||
|
components: [
|
||||||
|
{ type: 10, content: `**Prefix:** \`${newCurrentPrefix}\`` },
|
||||||
|
],
|
||||||
|
accessory: {
|
||||||
|
type: 2,
|
||||||
|
style: 2,
|
||||||
|
emoji: { name: "⚙️" },
|
||||||
|
custom_id: "open_prefix_modal",
|
||||||
|
label: "Cambiar",
|
||||||
|
},
|
||||||
|
},
|
||||||
{ type: 14, divider: false },
|
{ type: 14, divider: false },
|
||||||
{ type: 9, components: [ { type: 10, content: `**Staff (roles):** ${staffDisplay2}` } ], accessory: { type: 2, style: 2, emoji: { name: "🛡️" }, custom_id: "open_staff_modal", label: "Configurar" } },
|
{
|
||||||
|
type: 9,
|
||||||
|
components: [
|
||||||
|
{ type: 10, content: `**Staff (roles):** ${staffDisplay2}` },
|
||||||
|
],
|
||||||
|
accessory: {
|
||||||
|
type: 2,
|
||||||
|
style: 2,
|
||||||
|
emoji: { name: "🛡️" },
|
||||||
|
custom_id: "open_staff_modal",
|
||||||
|
label: "Configurar",
|
||||||
|
},
|
||||||
|
},
|
||||||
{ type: 14, divider: false },
|
{ type: 14, divider: false },
|
||||||
{ type: 9, components: [ { type: 10, content: `**AI Role Prompt:** ${aiPreview2}` } ], accessory: { type: 2, style: 2, emoji: { name: "🧠" }, custom_id: "open_ai_role_modal", label: "Configurar" } },
|
{
|
||||||
{ type: 14, divider: false }
|
type: 9,
|
||||||
]
|
components: [
|
||||||
|
{ type: 10, content: `**AI Role Prompt:** ${aiPreview2}` },
|
||||||
|
],
|
||||||
|
accessory: {
|
||||||
|
type: 2,
|
||||||
|
style: 2,
|
||||||
|
emoji: { name: "🧠" },
|
||||||
|
custom_id: "open_ai_role_modal",
|
||||||
|
label: "Configurar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 14, divider: false },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await interaction.update({ components: [updatedSettingsPanel] });
|
await interaction.update({ components: [updatedSettingsPanel] });
|
||||||
@@ -334,26 +565,74 @@ export const command: CommandMessage = {
|
|||||||
|
|
||||||
if (interaction.customId === "cancel_prefix_change") {
|
if (interaction.customId === "cancel_prefix_change") {
|
||||||
// Volver al panel original
|
// Volver al panel original
|
||||||
const updatedServer = await client.prisma.guild.findFirst({ where: { id: message.guild!.id } });
|
const updatedServer = await client.prisma.guild.findFirst({
|
||||||
|
where: { id: message.guild!.id },
|
||||||
|
});
|
||||||
const staffRoles3: string[] = toStringArray(updatedServer?.staff);
|
const staffRoles3: string[] = toStringArray(updatedServer?.staff);
|
||||||
const staffDisplay3 = staffRoles3.length ? staffRoles3.map((id) => `<@&${id}>`).join(', ') : 'Sin staff configurado';
|
const staffDisplay3 = staffRoles3.length
|
||||||
|
? staffRoles3.map((id) => `<@&${id}>`).join(", ")
|
||||||
|
: "Sin staff configurado";
|
||||||
const aiRolePrompt3 = updatedServer?.aiRolePrompt ?? null;
|
const aiRolePrompt3 = updatedServer?.aiRolePrompt ?? null;
|
||||||
const aiPreview3 = aiRolePrompt3 ? (aiRolePrompt3.length > 80 ? aiRolePrompt3.slice(0, 77) + '…' : aiRolePrompt3) : 'No configurado';
|
const aiPreview3 = aiRolePrompt3
|
||||||
|
? aiRolePrompt3.length > 80
|
||||||
|
? aiRolePrompt3.slice(0, 77) + "…"
|
||||||
|
: aiRolePrompt3
|
||||||
|
: "No configurado";
|
||||||
|
|
||||||
const originalPanel = {
|
const originalPanel = {
|
||||||
type: 17,
|
type: 17,
|
||||||
accent_color: 6178018,
|
accent_color: 6178018,
|
||||||
components: [
|
components: [
|
||||||
{ type: 10, content: "### <:invisible:1418684224441028608> 梅,panel admin,📢\n" },
|
{
|
||||||
|
type: 10,
|
||||||
|
content:
|
||||||
|
"### <:invisible:1418684224441028608> 梅,panel admin,📢\n",
|
||||||
|
},
|
||||||
{ type: 14, spacing: 1, divider: false },
|
{ type: 14, spacing: 1, divider: false },
|
||||||
{ type: 10, content: "Configuracion del Servidor:" },
|
{ type: 10, content: "Configuracion del Servidor:" },
|
||||||
{ type: 9, components: [ { type: 10, content: `**Prefix:** \`${currentPrefix}\`` } ], accessory: { type: 2, style: 2, emoji: { name: "⚙️" }, custom_id: "open_prefix_modal", label: "Cambiar" } },
|
{
|
||||||
|
type: 9,
|
||||||
|
components: [
|
||||||
|
{ type: 10, content: `**Prefix:** \`${currentPrefix}\`` },
|
||||||
|
],
|
||||||
|
accessory: {
|
||||||
|
type: 2,
|
||||||
|
style: 2,
|
||||||
|
emoji: { name: "⚙️" },
|
||||||
|
custom_id: "open_prefix_modal",
|
||||||
|
label: "Cambiar",
|
||||||
|
},
|
||||||
|
},
|
||||||
{ type: 14, divider: false },
|
{ type: 14, divider: false },
|
||||||
{ type: 9, components: [ { type: 10, content: `**Staff (roles):** ${staffDisplay3}` } ], accessory: { type: 2, style: 2, emoji: { name: "🛡️" }, custom_id: "open_staff_modal", label: "Configurar" } },
|
{
|
||||||
|
type: 9,
|
||||||
|
components: [
|
||||||
|
{ type: 10, content: `**Staff (roles):** ${staffDisplay3}` },
|
||||||
|
],
|
||||||
|
accessory: {
|
||||||
|
type: 2,
|
||||||
|
style: 2,
|
||||||
|
emoji: { name: "🛡️" },
|
||||||
|
custom_id: "open_staff_modal",
|
||||||
|
label: "Configurar",
|
||||||
|
},
|
||||||
|
},
|
||||||
{ type: 14, divider: false },
|
{ type: 14, divider: false },
|
||||||
{ type: 9, components: [ { type: 10, content: `**AI Role Prompt:** ${aiPreview3}` } ], accessory: { type: 2, style: 2, emoji: { name: "🧠" }, custom_id: "open_ai_role_modal", label: "Configurar" } },
|
{
|
||||||
{ type: 14, divider: false }
|
type: 9,
|
||||||
]
|
components: [
|
||||||
|
{ type: 10, content: `**AI Role Prompt:** ${aiPreview3}` },
|
||||||
|
],
|
||||||
|
accessory: {
|
||||||
|
type: 2,
|
||||||
|
style: 2,
|
||||||
|
emoji: { name: "🧠" },
|
||||||
|
custom_id: "open_ai_role_modal",
|
||||||
|
label: "Configurar",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: 14, divider: false },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await interaction.update({ components: [originalPanel] });
|
await interaction.update({ components: [originalPanel] });
|
||||||
@@ -368,8 +647,12 @@ export const command: CommandMessage = {
|
|||||||
components: [
|
components: [
|
||||||
{ type: 10, content: "### ⏰ **Panel Expirado**" },
|
{ type: 10, content: "### ⏰ **Panel Expirado**" },
|
||||||
{ type: 14, spacing: 1, divider: true },
|
{ type: 14, spacing: 1, divider: true },
|
||||||
{ type: 10, content: "El panel de configuración ha expirado por inactividad.\n\nUsa `!settings` para abrir un nuevo panel." }
|
{
|
||||||
]
|
type: 10,
|
||||||
|
content:
|
||||||
|
"El panel de configuración ha expirado por inactividad.\n\nUsa `!settings` para abrir un nuevo panel.",
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -379,5 +662,5 @@ export const command: CommandMessage = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
// Simple Appwrite client wrapper
|
// Simple Appwrite client wrapper
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import { Client, Databases } from 'node-appwrite';
|
import { Client, Databases } from "node-appwrite";
|
||||||
|
|
||||||
const endpoint = process.env.APPWRITE_ENDPOINT || '';
|
const endpoint = process.env.APPWRITE_ENDPOINT || "";
|
||||||
const projectId = process.env.APPWRITE_PROJECT_ID || '';
|
const projectId = process.env.APPWRITE_PROJECT_ID || "";
|
||||||
const apiKey = process.env.APPWRITE_API_KEY || '';
|
const apiKey = process.env.APPWRITE_API_KEY || "";
|
||||||
|
|
||||||
export const APPWRITE_DATABASE_ID = process.env.APPWRITE_DATABASE_ID || '';
|
export const APPWRITE_DATABASE_ID = process.env.APPWRITE_DATABASE_ID || "";
|
||||||
export const APPWRITE_COLLECTION_REMINDERS_ID = process.env.APPWRITE_COLLECTION_REMINDERS_ID || '';
|
export const APPWRITE_COLLECTION_REMINDERS_ID =
|
||||||
export const APPWRITE_COLLECTION_AI_CONVERSATIONS_ID = process.env.APPWRITE_COLLECTION_AI_CONVERSATIONS_ID || '';
|
process.env.APPWRITE_COLLECTION_REMINDERS_ID || "";
|
||||||
|
export const APPWRITE_COLLECTION_AI_CONVERSATIONS_ID =
|
||||||
|
process.env.APPWRITE_COLLECTION_AI_CONVERSATIONS_ID || "";
|
||||||
|
export const APPWRITE_COLLECTION_GUILD_CACHE_ID =
|
||||||
|
process.env.APPWRITE_COLLECTION_GUILD_CACHE_ID || "";
|
||||||
|
|
||||||
let client: Client | null = null;
|
let client: Client | null = null;
|
||||||
let databases: Databases | null = null;
|
let databases: Databases | null = null;
|
||||||
@@ -16,7 +20,10 @@ let databases: Databases | null = null;
|
|||||||
function ensureClient() {
|
function ensureClient() {
|
||||||
if (!endpoint || !projectId || !apiKey) return null;
|
if (!endpoint || !projectId || !apiKey) return null;
|
||||||
if (client) return client;
|
if (client) return client;
|
||||||
client = new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey);
|
client = new Client()
|
||||||
|
.setEndpoint(endpoint)
|
||||||
|
.setProject(projectId)
|
||||||
|
.setKey(apiKey);
|
||||||
databases = new Databases(client);
|
databases = new Databases(client);
|
||||||
return client;
|
return client;
|
||||||
}
|
}
|
||||||
@@ -26,9 +33,31 @@ export function getDatabases(): Databases | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isAppwriteConfigured(): boolean {
|
export function isAppwriteConfigured(): boolean {
|
||||||
return Boolean(endpoint && projectId && apiKey && APPWRITE_DATABASE_ID && APPWRITE_COLLECTION_REMINDERS_ID);
|
return Boolean(
|
||||||
|
endpoint &&
|
||||||
|
projectId &&
|
||||||
|
apiKey &&
|
||||||
|
APPWRITE_DATABASE_ID &&
|
||||||
|
APPWRITE_COLLECTION_REMINDERS_ID
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isAIConversationsConfigured(): boolean {
|
export function isAIConversationsConfigured(): boolean {
|
||||||
return Boolean(endpoint && projectId && apiKey && APPWRITE_DATABASE_ID && APPWRITE_COLLECTION_AI_CONVERSATIONS_ID);
|
return Boolean(
|
||||||
|
endpoint &&
|
||||||
|
projectId &&
|
||||||
|
apiKey &&
|
||||||
|
APPWRITE_DATABASE_ID &&
|
||||||
|
APPWRITE_COLLECTION_AI_CONVERSATIONS_ID
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isGuildCacheConfigured(): boolean {
|
||||||
|
return Boolean(
|
||||||
|
endpoint &&
|
||||||
|
projectId &&
|
||||||
|
apiKey &&
|
||||||
|
APPWRITE_DATABASE_ID &&
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
304
src/core/database/guildCache.ts
Normal file
304
src/core/database/guildCache.ts
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import {
|
||||||
|
getDatabases,
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||||
|
isGuildCacheConfigured,
|
||||||
|
} from "../api/appwrite";
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import logger from "../lib/logger";
|
||||||
|
import { Query } from "node-appwrite";
|
||||||
|
|
||||||
|
const GUILD_CACHE_TTL = 300; // 5 minutos en segundos
|
||||||
|
|
||||||
|
export interface GuildConfig {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
prefix: string | null;
|
||||||
|
createdAt?: Date;
|
||||||
|
updatedAt?: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Obtiene la configuración de un guild desde caché o base de datos
|
||||||
|
*/
|
||||||
|
export async function getGuildConfig(
|
||||||
|
guildId: string,
|
||||||
|
guildName: string,
|
||||||
|
prisma: PrismaClient
|
||||||
|
): Promise<GuildConfig> {
|
||||||
|
try {
|
||||||
|
// Intentar obtener desde Appwrite
|
||||||
|
if (isGuildCacheConfigured()) {
|
||||||
|
const databases = getDatabases();
|
||||||
|
if (databases) {
|
||||||
|
try {
|
||||||
|
const doc = await databases.getDocument(
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||||
|
guildId
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verificar si el documento ha expirado
|
||||||
|
const expiresAt = new Date(doc.expiresAt);
|
||||||
|
if (expiresAt > new Date()) {
|
||||||
|
logger.debug(
|
||||||
|
{ guildId },
|
||||||
|
"✅ Guild config obtenida desde caché (Appwrite)"
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: doc.guildId,
|
||||||
|
name: doc.name,
|
||||||
|
prefix: doc.prefix || null,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Documento expirado, eliminarlo
|
||||||
|
await databases.deleteDocument(
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||||
|
guildId
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
{ guildId },
|
||||||
|
"🗑️ Caché expirada eliminada de Appwrite"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Si es 404, el documento no existe, continuar
|
||||||
|
if (error?.code !== 404) {
|
||||||
|
logger.error(
|
||||||
|
{ error, guildId },
|
||||||
|
"❌ Error al leer caché de guild en Appwrite"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, guildId }, "❌ Error al acceder a Appwrite");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no está en caché, hacer upsert en la base de datos
|
||||||
|
try {
|
||||||
|
const guild = await prisma.guild.upsert({
|
||||||
|
where: { id: guildId },
|
||||||
|
create: {
|
||||||
|
id: guildId,
|
||||||
|
name: guildName,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const config: GuildConfig = {
|
||||||
|
id: guild.id,
|
||||||
|
name: guild.name,
|
||||||
|
prefix: guild.prefix,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Guardar en caché de Appwrite
|
||||||
|
try {
|
||||||
|
if (isGuildCacheConfigured()) {
|
||||||
|
const databases = getDatabases();
|
||||||
|
if (databases) {
|
||||||
|
const expiresAt = new Date(Date.now() + GUILD_CACHE_TTL * 1000);
|
||||||
|
|
||||||
|
await databases.createDocument(
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||||
|
guildId, // usar guildId como document ID para que sea único
|
||||||
|
{
|
||||||
|
guildId: guild.id,
|
||||||
|
name: guild.name,
|
||||||
|
prefix: guild.prefix || "",
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
{ guildId },
|
||||||
|
"✅ Guild config guardada en caché (Appwrite)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Si el documento ya existe (409), actualizarlo
|
||||||
|
if (error?.code === 409) {
|
||||||
|
try {
|
||||||
|
const databases = getDatabases();
|
||||||
|
if (databases) {
|
||||||
|
const expiresAt = new Date(Date.now() + GUILD_CACHE_TTL * 1000);
|
||||||
|
|
||||||
|
await databases.updateDocument(
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||||
|
guildId,
|
||||||
|
{
|
||||||
|
name: guild.name,
|
||||||
|
prefix: guild.prefix || "",
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
{ guildId },
|
||||||
|
"♻️ Guild config actualizada en caché (Appwrite)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (updateError) {
|
||||||
|
logger.error(
|
||||||
|
{ error: updateError, guildId },
|
||||||
|
"❌ Error al actualizar caché en Appwrite"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.error(
|
||||||
|
{ error, guildId },
|
||||||
|
"❌ Error al guardar caché en Appwrite"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error, guildId }, "❌ Error al hacer upsert de guild");
|
||||||
|
|
||||||
|
// Retornar configuración por defecto en caso de error
|
||||||
|
return {
|
||||||
|
id: guildId,
|
||||||
|
name: guildName,
|
||||||
|
prefix: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalida el caché de un guild (llamar cuando se actualice la configuración)
|
||||||
|
*/
|
||||||
|
export async function invalidateGuildCache(guildId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (isGuildCacheConfigured()) {
|
||||||
|
const databases = getDatabases();
|
||||||
|
if (databases) {
|
||||||
|
await databases.deleteDocument(
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||||
|
guildId
|
||||||
|
);
|
||||||
|
logger.debug({ guildId }, "🗑️ Caché de guild invalidada (Appwrite)");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// Si es 404, el documento ya no existe
|
||||||
|
if (error?.code !== 404) {
|
||||||
|
logger.error(
|
||||||
|
{ error, guildId },
|
||||||
|
"❌ Error al invalidar caché de guild en Appwrite"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actualiza directamente el caché de un guild (útil después de updates)
|
||||||
|
*/
|
||||||
|
export async function updateGuildCache(config: GuildConfig): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (isGuildCacheConfigured()) {
|
||||||
|
const databases = getDatabases();
|
||||||
|
if (databases) {
|
||||||
|
const expiresAt = new Date(Date.now() + GUILD_CACHE_TTL * 1000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await databases.updateDocument(
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||||
|
config.id,
|
||||||
|
{
|
||||||
|
name: config.name,
|
||||||
|
prefix: config.prefix || "",
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
{ guildId: config.id },
|
||||||
|
"♻️ Caché de guild actualizada (Appwrite)"
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Si no existe (404), crearlo
|
||||||
|
if (error?.code === 404) {
|
||||||
|
await databases.createDocument(
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||||
|
config.id,
|
||||||
|
{
|
||||||
|
guildId: config.id,
|
||||||
|
name: config.name,
|
||||||
|
prefix: config.prefix || "",
|
||||||
|
expiresAt: expiresAt.toISOString(),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
{ guildId: config.id },
|
||||||
|
"✅ Caché de guild creada (Appwrite)"
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ error, guildId: config.id },
|
||||||
|
"❌ Error al actualizar caché de guild en Appwrite"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Limpia documentos expirados de la caché (ejecutar periódicamente)
|
||||||
|
*/
|
||||||
|
export async function cleanExpiredGuildCache(): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (isGuildCacheConfigured()) {
|
||||||
|
const databases = getDatabases();
|
||||||
|
if (databases) {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
// Buscar documentos que hayan expirado
|
||||||
|
const expired = await databases.listDocuments(
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||||
|
[
|
||||||
|
Query.lessThan("expiresAt", now),
|
||||||
|
Query.limit(100), // Límite para evitar sobrecarga
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Eliminar documentos expirados
|
||||||
|
for (const doc of expired.documents) {
|
||||||
|
try {
|
||||||
|
await databases.deleteDocument(
|
||||||
|
APPWRITE_DATABASE_ID,
|
||||||
|
APPWRITE_COLLECTION_GUILD_CACHE_ID,
|
||||||
|
doc.$id
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ error, docId: doc.$id },
|
||||||
|
"❌ Error al eliminar documento expirado"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expired.documents.length > 0) {
|
||||||
|
logger.info(
|
||||||
|
{ count: expired.documents.length },
|
||||||
|
"🧹 Documentos expirados eliminados de caché"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "❌ Error al limpiar caché expirada en Appwrite");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {commands} from "../core/loaders/loader";
|
|||||||
import { alliance } from "./extras/alliace";
|
import { alliance } from "./extras/alliace";
|
||||||
import logger from "../core/lib/logger";
|
import logger from "../core/lib/logger";
|
||||||
import { aiService } from "../core/services/AIService";
|
import { aiService } from "../core/services/AIService";
|
||||||
|
import { getGuildConfig } from "../core/database/guildCache";
|
||||||
|
|
||||||
// Función para manejar respuestas automáticas a la AI
|
// Función para manejar respuestas automáticas a la AI
|
||||||
async function handleAIReply(message: any) {
|
async function handleAIReply(message: any) {
|
||||||
@@ -12,16 +13,20 @@ async function handleAIReply(message: any) {
|
|||||||
if (!message.reference?.messageId || message.author.bot) return;
|
if (!message.reference?.messageId || message.author.bot) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const referencedMessage = await message.channel.messages.fetch(message.reference.messageId);
|
const referencedMessage = await message.channel.messages.fetch(
|
||||||
|
message.reference.messageId
|
||||||
|
);
|
||||||
|
|
||||||
// Verificar si el mensaje referenciado es del bot
|
// Verificar si el mensaje referenciado es del bot
|
||||||
if (referencedMessage.author.id !== message.client.user?.id) return;
|
if (referencedMessage.author.id !== message.client.user?.id) return;
|
||||||
|
|
||||||
// Verificar que el contenido no sea un comando (para evitar loops)
|
// Verificar que el contenido no sea un comando (para evitar loops)
|
||||||
const server = await bot.prisma.guild.findUnique({
|
const guildConfig = await getGuildConfig(
|
||||||
where: { id: message.guildId || undefined }
|
message.guildId || message.guild!.id,
|
||||||
});
|
message.guild!.name,
|
||||||
const PREFIX = server?.prefix || "!";
|
bot.prisma
|
||||||
|
);
|
||||||
|
const PREFIX = guildConfig.prefix || "!";
|
||||||
|
|
||||||
if (message.content.startsWith(PREFIX)) return;
|
if (message.content.startsWith(PREFIX)) return;
|
||||||
|
|
||||||
@@ -30,11 +35,15 @@ async function handleAIReply(message: any) {
|
|||||||
|
|
||||||
// Limitar longitud del mensaje
|
// Limitar longitud del mensaje
|
||||||
if (message.content.length > 4000) {
|
if (message.content.length > 4000) {
|
||||||
await message.reply('❌ **Error:** Tu mensaje es demasiado largo (máximo 4000 caracteres).');
|
await message.reply(
|
||||||
|
"❌ **Error:** Tu mensaje es demasiado largo (máximo 4000 caracteres)."
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.info(`Respuesta automática a AI detectada - Usuario: ${message.author.id}, Guild: ${message.guildId}`);
|
logger.info(
|
||||||
|
`Respuesta automática a AI detectada - Usuario: ${message.author.id}, Guild: ${message.guildId}`
|
||||||
|
);
|
||||||
|
|
||||||
// Indicador de que está escribiendo
|
// Indicador de que está escribiendo
|
||||||
const typingInterval = setInterval(() => {
|
const typingInterval = setInterval(() => {
|
||||||
@@ -43,7 +52,10 @@ async function handleAIReply(message: any) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Obtener emojis personalizados del servidor
|
// Obtener emojis personalizados del servidor
|
||||||
const emojiResult = { names: [] as string[], map: {} as Record<string, string> };
|
const emojiResult = {
|
||||||
|
names: [] as string[],
|
||||||
|
map: {} as Record<string, string>,
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
const guild = message.guild;
|
const guild = message.guild;
|
||||||
if (guild) {
|
if (guild) {
|
||||||
@@ -77,28 +89,46 @@ async function handleAIReply(message: any) {
|
|||||||
parts.push(`Canal: #${msg.channel.name}`);
|
parts.push(`Canal: #${msg.channel.name}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const userMentions = msg.mentions?.users ? Array.from(msg.mentions.users.values()) : [];
|
const userMentions = msg.mentions?.users
|
||||||
const roleMentions = msg.mentions?.roles ? Array.from(msg.mentions.roles.values()) : [];
|
? Array.from(msg.mentions.users.values())
|
||||||
|
: [];
|
||||||
|
const roleMentions = msg.mentions?.roles
|
||||||
|
? Array.from(msg.mentions.roles.values())
|
||||||
|
: [];
|
||||||
|
|
||||||
if (userMentions.length) {
|
if (userMentions.length) {
|
||||||
parts.push(`Menciones usuario: ${userMentions.slice(0, 5).map((u: any) => u.username ?? u.tag ?? u.id).join(', ')}`);
|
parts.push(
|
||||||
|
`Menciones usuario: ${userMentions
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((u: any) => u.username ?? u.tag ?? u.id)
|
||||||
|
.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (roleMentions.length) {
|
if (roleMentions.length) {
|
||||||
parts.push(`Menciones rol: ${roleMentions.slice(0, 5).map((r: any) => r.name ?? r.id).join(', ')}`);
|
parts.push(
|
||||||
|
`Menciones rol: ${roleMentions
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((r: any) => r.name ?? r.id)
|
||||||
|
.join(", ")}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (msg.reference?.messageId) {
|
if (msg.reference?.messageId) {
|
||||||
parts.push('Es una respuesta a mensaje de AI');
|
parts.push("Es una respuesta a mensaje de AI");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (emojiNames && emojiNames.length) {
|
if (emojiNames && emojiNames.length) {
|
||||||
parts.push(`Emojis personalizados disponibles (usa :nombre:): ${emojiNames.join(', ')}`);
|
parts.push(
|
||||||
|
`Emojis personalizados disponibles (usa :nombre:): ${emojiNames.join(
|
||||||
|
", "
|
||||||
|
)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const metaRaw = parts.join(' | ');
|
const metaRaw = parts.join(" | ");
|
||||||
return metaRaw.length > 800 ? metaRaw.slice(0, 800) : metaRaw;
|
return metaRaw.length > 800 ? metaRaw.slice(0, 800) : metaRaw;
|
||||||
} catch {
|
} catch {
|
||||||
return '';
|
return "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -106,7 +136,8 @@ async function handleAIReply(message: any) {
|
|||||||
|
|
||||||
// Verificar si hay imágenes adjuntas
|
// Verificar si hay imágenes adjuntas
|
||||||
const attachments = Array.from(message.attachments.values());
|
const attachments = Array.from(message.attachments.values());
|
||||||
const hasImages = attachments.length > 0 && aiService.hasImageAttachments(attachments);
|
const hasImages =
|
||||||
|
attachments.length > 0 && aiService.hasImageAttachments(attachments);
|
||||||
|
|
||||||
// Procesar con el servicio de AI usando memoria persistente y soporte para imágenes
|
// Procesar con el servicio de AI usando memoria persistente y soporte para imágenes
|
||||||
const aiResponse = await aiService.processAIRequestWithMemory(
|
const aiResponse = await aiService.processAIRequestWithMemory(
|
||||||
@@ -117,37 +148,44 @@ async function handleAIReply(message: any) {
|
|||||||
message.id,
|
message.id,
|
||||||
message.reference.messageId,
|
message.reference.messageId,
|
||||||
message.client,
|
message.client,
|
||||||
'normal',
|
"normal",
|
||||||
{
|
{
|
||||||
meta: messageMeta + (hasImages ? ` | Tiene ${attachments.length} imagen(es) adjunta(s)` : ''),
|
meta:
|
||||||
attachments: hasImages ? attachments : undefined
|
messageMeta +
|
||||||
|
(hasImages
|
||||||
|
? ` | Tiene ${attachments.length} imagen(es) adjunta(s)`
|
||||||
|
: ""),
|
||||||
|
attachments: hasImages ? attachments : undefined,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reemplazar emojis personalizados
|
// Reemplazar emojis personalizados
|
||||||
let finalResponse = aiResponse;
|
let finalResponse = aiResponse;
|
||||||
if (emojiResult.names.length > 0) {
|
if (emojiResult.names.length > 0) {
|
||||||
finalResponse = finalResponse.replace(/:([a-zA-Z0-9_]{2,32}):/g, (match, p1: string) => {
|
finalResponse = finalResponse.replace(
|
||||||
|
/:([a-zA-Z0-9_]{2,32}):/g,
|
||||||
|
(match, p1: string) => {
|
||||||
const found = emojiResult.map[p1];
|
const found = emojiResult.map[p1];
|
||||||
return found ? found : match;
|
return found ? found : match;
|
||||||
});
|
}
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enviar respuesta (dividir si es muy larga)
|
// Enviar respuesta (dividir si es muy larga)
|
||||||
const MAX_CONTENT = 2000;
|
const MAX_CONTENT = 2000;
|
||||||
if (finalResponse.length > MAX_CONTENT) {
|
if (finalResponse.length > MAX_CONTENT) {
|
||||||
const chunks: string[] = [];
|
const chunks: string[] = [];
|
||||||
let currentChunk = '';
|
let currentChunk = "";
|
||||||
const lines = finalResponse.split('\n');
|
const lines = finalResponse.split("\n");
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (currentChunk.length + line.length + 1 > MAX_CONTENT) {
|
if (currentChunk.length + line.length + 1 > MAX_CONTENT) {
|
||||||
if (currentChunk) {
|
if (currentChunk) {
|
||||||
chunks.push(currentChunk.trim());
|
chunks.push(currentChunk.trim());
|
||||||
currentChunk = '';
|
currentChunk = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
currentChunk += (currentChunk ? '\n' : '') + line;
|
currentChunk += (currentChunk ? "\n" : "") + line;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentChunk) {
|
if (currentChunk) {
|
||||||
@@ -158,31 +196,33 @@ async function handleAIReply(message: any) {
|
|||||||
if (i === 0) {
|
if (i === 0) {
|
||||||
await message.reply({ content: chunks[i] });
|
await message.reply({ content: chunks[i] });
|
||||||
} else {
|
} else {
|
||||||
if ('send' in message.channel) {
|
if ("send" in message.channel) {
|
||||||
await message.channel.send({ content: chunks[i] });
|
await message.channel.send({ content: chunks[i] });
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chunks.length > 3) {
|
if (chunks.length > 3) {
|
||||||
if ('send' in message.channel) {
|
if ("send" in message.channel) {
|
||||||
await message.channel.send({ content: "⚠️ Respuesta truncada por longitud." });
|
await message.channel.send({
|
||||||
|
content: "⚠️ Respuesta truncada por longitud.",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await message.reply({ content: finalResponse });
|
await message.reply({ content: finalResponse });
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
logger.error(`Error en respuesta automática AI:`, error);
|
logger.error(`Error en respuesta automática AI:`, error);
|
||||||
await message.reply({
|
await message.reply({
|
||||||
content: `❌ **Error:** ${error.message || 'No pude procesar tu respuesta. Intenta de nuevo.'}`
|
content: `❌ **Error:** ${
|
||||||
|
error.message || "No pude procesar tu respuesta. Intenta de nuevo."
|
||||||
|
}`,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
clearInterval(typingInterval);
|
clearInterval(typingInterval);
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Mensaje referenciado no encontrado o error, ignorar silenciosamente
|
// Mensaje referenciado no encontrado o error, ignorar silenciosamente
|
||||||
logger.debug(`Error obteniendo mensaje referenciado: ${error}`);
|
logger.debug(`Error obteniendo mensaje referenciado: ${error}`);
|
||||||
@@ -196,20 +236,21 @@ bot.on(Events.MessageCreate, async (message) => {
|
|||||||
await handleAIReply(message);
|
await handleAIReply(message);
|
||||||
|
|
||||||
await alliance(message);
|
await alliance(message);
|
||||||
const server = await bot.prisma.guild.upsert({
|
|
||||||
where: {
|
// Usar caché para obtener la configuración del guild
|
||||||
id: message.guildId || undefined
|
const guildConfig = await getGuildConfig(
|
||||||
},
|
message.guildId || message.guild!.id,
|
||||||
create: {
|
message.guild!.name,
|
||||||
id: message!.guildId || message.guild!.id,
|
bot.prisma
|
||||||
name: message.guild!.name
|
);
|
||||||
},
|
|
||||||
update: {}
|
const PREFIX = guildConfig.prefix || "!";
|
||||||
})
|
|
||||||
const PREFIX = server.prefix || "!"
|
|
||||||
if (!message.content.startsWith(PREFIX)) return;
|
if (!message.content.startsWith(PREFIX)) return;
|
||||||
|
|
||||||
const [cmdName, ...args] = message.content.slice(PREFIX.length).trim().split(/\s+/);
|
const [cmdName, ...args] = message.content
|
||||||
|
.slice(PREFIX.length)
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/);
|
||||||
const command = commands.get(cmdName);
|
const command = commands.get(cmdName);
|
||||||
if (!command) return;
|
if (!command) return;
|
||||||
|
|
||||||
@@ -221,18 +262,19 @@ bot.on(Events.MessageCreate, async (message) => {
|
|||||||
logger.debug(`Key: ${key}, TTL: ${ttl}`);
|
logger.debug(`Key: ${key}, TTL: ${ttl}`);
|
||||||
|
|
||||||
if (ttl > 0) {
|
if (ttl > 0) {
|
||||||
return message.reply(`⏳ Espera ${ttl}s antes de volver a usar **${command.name}**.`);
|
return message.reply(
|
||||||
|
`⏳ Espera ${ttl}s antes de volver a usar **${command.name}**.`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// SET con expiración correcta para redis v4+
|
// SET con expiración correcta para redis v4+
|
||||||
await redis.set(key, "1", { EX: cooldown });
|
await redis.set(key, "1", { EX: cooldown });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await command.run(message, args, message.client);
|
await command.run(message, args, message.client);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ err: error }, "Error ejecutando comando");
|
logger.error({ err: error }, "Error ejecutando comando");
|
||||||
await message.reply("❌ Hubo un error ejecutando el comando.");
|
await message.reply("❌ Hubo un error ejecutando el comando.");
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|||||||
147
src/main.ts
147
src/main.ts
@@ -1,4 +1,3 @@
|
|||||||
|
|
||||||
import Amayo from "./core/client";
|
import Amayo from "./core/client";
|
||||||
import { loadCommands } from "./core/loaders/loader";
|
import { loadCommands } from "./core/loaders/loader";
|
||||||
import { loadEvents } from "./core/loaders/loaderEvents";
|
import { loadEvents } from "./core/loaders/loaderEvents";
|
||||||
@@ -9,18 +8,19 @@ import { startMemoryMonitor } from "./core/memory/memoryMonitor";
|
|||||||
import { memoryOptimizer } from "./core/memory/memoryOptimizer";
|
import { memoryOptimizer } from "./core/memory/memoryOptimizer";
|
||||||
import { startReminderPoller } from "./core/api/reminders";
|
import { startReminderPoller } from "./core/api/reminders";
|
||||||
import { ensureRemindersSchema } from "./core/api/remindersSchema";
|
import { ensureRemindersSchema } from "./core/api/remindersSchema";
|
||||||
|
import { cleanExpiredGuildCache } from "./core/database/guildCache";
|
||||||
import logger from "./core/lib/logger";
|
import logger from "./core/lib/logger";
|
||||||
import { applyModalSubmitInteractionPatch } from "./core/patches/discordModalPatch";
|
import { applyModalSubmitInteractionPatch } from "./core/patches/discordModalPatch";
|
||||||
import { server } from "./server/server";
|
import { server } from "./server/server";
|
||||||
|
|
||||||
// Activar monitor de memoria si se define la variable
|
// Activar monitor de memoria si se define la variable
|
||||||
const __memInt = parseInt(process.env.MEMORY_LOG_INTERVAL_SECONDS || '0', 10);
|
const __memInt = parseInt(process.env.MEMORY_LOG_INTERVAL_SECONDS || "0", 10);
|
||||||
if (__memInt > 0) {
|
if (__memInt > 0) {
|
||||||
startMemoryMonitor({ intervalSeconds: __memInt });
|
startMemoryMonitor({ intervalSeconds: __memInt });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Activar optimizador de memoria adicional
|
// Activar optimizador de memoria adicional
|
||||||
if (process.env.ENABLE_MEMORY_OPTIMIZER === 'true') {
|
if (process.env.ENABLE_MEMORY_OPTIMIZER === "true") {
|
||||||
memoryOptimizer.start();
|
memoryOptimizer.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,38 +28,52 @@ if (process.env.ENABLE_MEMORY_OPTIMIZER === 'true') {
|
|||||||
try {
|
try {
|
||||||
applyModalSubmitInteractionPatch();
|
applyModalSubmitInteractionPatch();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn({ err: e }, 'No se pudo aplicar el patch de ModalSubmitInteraction');
|
logger.warn(
|
||||||
|
{ err: e },
|
||||||
|
"No se pudo aplicar el patch de ModalSubmitInteraction"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const bot = new Amayo();
|
export const bot = new Amayo();
|
||||||
|
|
||||||
// Listeners de robustez del cliente Discord
|
// Listeners de robustez del cliente Discord
|
||||||
bot.on('error', (e) => logger.error({ err: e }, '🐞 Discord client error'));
|
bot.on("error", (e) => logger.error({ err: e }, "🐞 Discord client error"));
|
||||||
bot.on('warn', (m) => logger.warn('⚠️ Discord warn: %s', m));
|
bot.on("warn", (m) => logger.warn("⚠️ Discord warn: %s", m));
|
||||||
|
|
||||||
// Evitar reintentos de re-login simultáneos
|
// Evitar reintentos de re-login simultáneos
|
||||||
let relogging = false;
|
let relogging = false;
|
||||||
// Cuando la sesión es invalidada, intentamos reconectar/login
|
// Cuando la sesión es invalidada, intentamos reconectar/login
|
||||||
bot.on('invalidated', () => {
|
bot.on("invalidated", () => {
|
||||||
if (relogging) return;
|
if (relogging) return;
|
||||||
relogging = true;
|
relogging = true;
|
||||||
logger.error('🔄 Sesión de Discord invalidada. Reintentando login...');
|
logger.error("🔄 Sesión de Discord invalidada. Reintentando login...");
|
||||||
withRetry('Re-login tras invalidated', () => bot.play(), { minDelayMs: 2000, maxDelayMs: 60_000 })
|
withRetry("Re-login tras invalidated", () => bot.play(), {
|
||||||
.catch(() => {
|
minDelayMs: 2000,
|
||||||
logger.error('No se pudo reloguear tras invalidated, se seguirá intentando en el bucle general.');
|
maxDelayMs: 60_000,
|
||||||
})
|
})
|
||||||
.finally(() => { relogging = false; });
|
.catch(() => {
|
||||||
|
logger.error(
|
||||||
|
"No se pudo reloguear tras invalidated, se seguirá intentando en el bucle general."
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
relogging = false;
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Utilidad: reintentos con backoff exponencial + jitter
|
// Utilidad: reintentos con backoff exponencial + jitter
|
||||||
async function withRetry<T>(name: string, fn: () => Promise<T>, opts?: {
|
async function withRetry<T>(
|
||||||
|
name: string,
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
opts?: {
|
||||||
retries?: number;
|
retries?: number;
|
||||||
minDelayMs?: number;
|
minDelayMs?: number;
|
||||||
maxDelayMs?: number;
|
maxDelayMs?: number;
|
||||||
factor?: number;
|
factor?: number;
|
||||||
jitter?: boolean;
|
jitter?: boolean;
|
||||||
isRetryable?: (err: unknown, attempt: number) => boolean;
|
isRetryable?: (err: unknown, attempt: number) => boolean;
|
||||||
}): Promise<T> {
|
}
|
||||||
|
): Promise<T> {
|
||||||
const {
|
const {
|
||||||
retries = Infinity,
|
retries = Infinity,
|
||||||
minDelayMs = 1000,
|
minDelayMs = 1000,
|
||||||
@@ -78,11 +92,14 @@ async function withRetry<T>(name: string, fn: () => Promise<T>, opts?: {
|
|||||||
return await fn();
|
return await fn();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
attempt++;
|
attempt++;
|
||||||
const errMsg = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
const errMsg =
|
||||||
|
err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
||||||
logger.error(`❌ ${name} falló (intento ${attempt}) => %s`, errMsg);
|
logger.error(`❌ ${name} falló (intento ${attempt}) => %s`, errMsg);
|
||||||
|
|
||||||
if (!isRetryable(err, attempt)) {
|
if (!isRetryable(err, attempt)) {
|
||||||
logger.error(`⛔ ${name}: error no recuperable, deteniendo reintentos.`);
|
logger.error(
|
||||||
|
`⛔ ${name}: error no recuperable, deteniendo reintentos.`
|
||||||
|
);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,36 +121,43 @@ async function withRetry<T>(name: string, fn: () => Promise<T>, opts?: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handlers globales para robustez
|
// Handlers globales para robustez
|
||||||
process.on('unhandledRejection', (reason: any, p) => {
|
process.on("unhandledRejection", (reason: any, p) => {
|
||||||
logger.error({ promise: p, reason }, '🚨 UnhandledRejection en Promise');
|
logger.error({ promise: p, reason }, "🚨 UnhandledRejection en Promise");
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('uncaughtException', (err) => {
|
process.on("uncaughtException", (err) => {
|
||||||
logger.error({ err }, '🚨 UncaughtException');
|
logger.error({ err }, "🚨 UncaughtException");
|
||||||
// No salimos; dejamos que el bot continúe vivo
|
// No salimos; dejamos que el bot continúe vivo
|
||||||
});
|
});
|
||||||
|
|
||||||
process.on('multipleResolves', (type, promise, reason: any) => {
|
process.on("multipleResolves", (type, promise, reason: any) => {
|
||||||
// Ignorar resoluciones sin razón (ruido)
|
// Ignorar resoluciones sin razón (ruido)
|
||||||
if (type === 'resolve' && (reason === undefined || reason === null)) {
|
if (type === "resolve" && (reason === undefined || reason === null)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const msg = reason instanceof Error ? `${reason.name}: ${reason.message}` : String(reason);
|
const msg =
|
||||||
const stack = (reason && (reason as any).stack) ? String((reason as any).stack) : '';
|
reason instanceof Error
|
||||||
const isAbortErr = (reason && ((reason as any).code === 'ABORT_ERR' || /AbortError|operation was aborted/i.test(msg)));
|
? `${reason.name}: ${reason.message}`
|
||||||
|
: String(reason);
|
||||||
|
const stack =
|
||||||
|
reason && (reason as any).stack ? String((reason as any).stack) : "";
|
||||||
|
const isAbortErr =
|
||||||
|
reason &&
|
||||||
|
((reason as any).code === "ABORT_ERR" ||
|
||||||
|
/AbortError|operation was aborted/i.test(msg));
|
||||||
const isDiscordWs = /@discordjs\/ws|WebSocketShard/.test(stack);
|
const isDiscordWs = /@discordjs\/ws|WebSocketShard/.test(stack);
|
||||||
if (isAbortErr && isDiscordWs) {
|
if (isAbortErr && isDiscordWs) {
|
||||||
// Ruido benigno de reconexiones del WS de Discord: ignorar
|
// Ruido benigno de reconexiones del WS de Discord: ignorar
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
logger.warn('⚠️ multipleResolves: %s %s', type, msg);
|
logger.warn("⚠️ multipleResolves: %s %s", type, msg);
|
||||||
});
|
});
|
||||||
|
|
||||||
let shuttingDown = false;
|
let shuttingDown = false;
|
||||||
async function gracefulShutdown() {
|
async function gracefulShutdown() {
|
||||||
if (shuttingDown) return;
|
if (shuttingDown) return;
|
||||||
shuttingDown = true;
|
shuttingDown = true;
|
||||||
logger.info('🛑 Apagado controlado iniciado...');
|
logger.info("🛑 Apagado controlado iniciado...");
|
||||||
try {
|
try {
|
||||||
// Detener optimizador de memoria
|
// Detener optimizador de memoria
|
||||||
memoryOptimizer.stop();
|
memoryOptimizer.stop();
|
||||||
@@ -142,10 +166,10 @@ async function gracefulShutdown() {
|
|||||||
try {
|
try {
|
||||||
if (redis?.isOpen) {
|
if (redis?.isOpen) {
|
||||||
await redis.quit();
|
await redis.quit();
|
||||||
logger.info('🔌 Redis cerrado');
|
logger.info("🔌 Redis cerrado");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn({ err: e }, 'No se pudo cerrar Redis limpiamente');
|
logger.warn({ err: e }, "No se pudo cerrar Redis limpiamente");
|
||||||
}
|
}
|
||||||
// Cerrar Prisma y Discord
|
// Cerrar Prisma y Discord
|
||||||
try {
|
try {
|
||||||
@@ -155,62 +179,95 @@ async function gracefulShutdown() {
|
|||||||
await bot.destroy();
|
await bot.destroy();
|
||||||
} catch {}
|
} catch {}
|
||||||
} finally {
|
} finally {
|
||||||
logger.info('✅ Apagado controlado completo');
|
logger.info("✅ Apagado controlado completo");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
process.on('SIGINT', gracefulShutdown);
|
process.on("SIGINT", gracefulShutdown);
|
||||||
process.on('SIGTERM', gracefulShutdown);
|
process.on("SIGTERM", gracefulShutdown);
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
logger.info("🚀 Iniciando bot...");
|
logger.info("🚀 Iniciando bot...");
|
||||||
await server.listen(process.env.PORT || 3000, () => {
|
await server.listen(process.env.PORT || 3000, () => {
|
||||||
logger.info(`📘 Amayo Docs disponible en http://localhost:${process.env.PORT || 3000}`);
|
logger.info(
|
||||||
|
`📘 Amayo Docs disponible en http://localhost:${process.env.PORT || 3000}`
|
||||||
|
);
|
||||||
});
|
});
|
||||||
// Cargar recursos locales (no deberían tirar el proceso si fallan)
|
// Cargar recursos locales (no deberían tirar el proceso si fallan)
|
||||||
try { loadCommands(); } catch (e) { logger.error({ err: e }, 'Error cargando comandos'); }
|
try {
|
||||||
try { loadComponents(); } catch (e) { logger.error({ err: e }, 'Error cargando componentes'); }
|
loadCommands();
|
||||||
try { loadEvents(); } catch (e) { logger.error({ err: e }, 'Error cargando eventos'); }
|
} catch (e) {
|
||||||
|
logger.error({ err: e }, "Error cargando comandos");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loadComponents();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({ err: e }, "Error cargando componentes");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
loadEvents();
|
||||||
|
} catch (e) {
|
||||||
|
logger.error({ err: e }, "Error cargando eventos");
|
||||||
|
}
|
||||||
|
|
||||||
// Registrar comandos en segundo plano con reintentos; no bloquea el arranque del bot
|
// Registrar comandos en segundo plano con reintentos; no bloquea el arranque del bot
|
||||||
withRetry('Registrar slash commands', async () => {
|
withRetry("Registrar slash commands", async () => {
|
||||||
await registeringCommands();
|
await registeringCommands();
|
||||||
}).catch((e) => logger.error({ err: e }, 'Registro de comandos agotó reintentos'));
|
}).catch((e) =>
|
||||||
|
logger.error({ err: e }, "Registro de comandos agotó reintentos")
|
||||||
|
);
|
||||||
|
|
||||||
// Conectar Redis con reintentos
|
// Conectar Redis con reintentos
|
||||||
await withRetry('Conectar a Redis', async () => {
|
await withRetry("Conectar a Redis", async () => {
|
||||||
await redisConnect();
|
await redisConnect();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Login Discord + DB con reintentos (gestionado en Amayo.play -> conecta Prisma + login)
|
// Login Discord + DB con reintentos (gestionado en Amayo.play -> conecta Prisma + login)
|
||||||
await withRetry('Login de Discord', async () => {
|
await withRetry(
|
||||||
|
"Login de Discord",
|
||||||
|
async () => {
|
||||||
await bot.play();
|
await bot.play();
|
||||||
}, {
|
},
|
||||||
|
{
|
||||||
isRetryable: (err) => {
|
isRetryable: (err) => {
|
||||||
const msg = err instanceof Error ? `${err.message}` : String(err);
|
const msg = err instanceof Error ? `${err.message}` : String(err);
|
||||||
// Si falta el TOKEN o token inválido, no tiene sentido reintentar sin cambiar config
|
// Si falta el TOKEN o token inválido, no tiene sentido reintentar sin cambiar config
|
||||||
return !/missing discord token|invalid token/i.test(msg);
|
return !/missing discord token|invalid token/i.test(msg);
|
||||||
|
},
|
||||||
}
|
}
|
||||||
});
|
);
|
||||||
|
|
||||||
// Asegurar esquema de Appwrite para recordatorios (colección + atributos + índice)
|
// Asegurar esquema de Appwrite para recordatorios (colección + atributos + índice)
|
||||||
try { await ensureRemindersSchema(); } catch (e) { logger.warn({ err: e }, 'No se pudo asegurar el esquema de recordatorios'); }
|
try {
|
||||||
|
await ensureRemindersSchema();
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn({ err: e }, "No se pudo asegurar el esquema de recordatorios");
|
||||||
|
}
|
||||||
|
|
||||||
// Iniciar poller de recordatorios si Appwrite está configurado
|
// Iniciar poller de recordatorios si Appwrite está configurado
|
||||||
startReminderPoller(bot);
|
startReminderPoller(bot);
|
||||||
|
|
||||||
|
// Iniciar limpieza periódica de caché de guilds (cada 10 minutos)
|
||||||
|
setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await cleanExpiredGuildCache();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error({ error }, "❌ Error en limpieza periódica de caché");
|
||||||
|
}
|
||||||
|
}, 10 * 60 * 1000); // 10 minutos
|
||||||
|
|
||||||
logger.info("✅ Bot conectado a Discord");
|
logger.info("✅ Bot conectado a Discord");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Bucle de arranque resiliente: si bootstrap completo falla, reintenta sin matar el proceso
|
// Bucle de arranque resiliente: si bootstrap completo falla, reintenta sin matar el proceso
|
||||||
(async function startLoop() {
|
(async function startLoop() {
|
||||||
await withRetry('Arranque', bootstrap, {
|
await withRetry("Arranque", bootstrap, {
|
||||||
minDelayMs: 1000,
|
minDelayMs: 1000,
|
||||||
maxDelayMs: 60_000,
|
maxDelayMs: 60_000,
|
||||||
isRetryable: (err) => {
|
isRetryable: (err) => {
|
||||||
const msg = err instanceof Error ? `${err.message}` : String(err);
|
const msg = err instanceof Error ? `${err.message}` : String(err);
|
||||||
// No reintentar en bucle si el problema es falta/invalid token
|
// No reintentar en bucle si el problema es falta/invalid token
|
||||||
return !/missing discord token|invalid token/i.test(msg);
|
return !/missing discord token|invalid token/i.test(msg);
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
Reference in New Issue
Block a user