Merge pull request #6 from Shnimlz/economy

Economy
This commit is contained in:
Shni
2025-10-07 11:59:33 -05:00
committed by GitHub
15 changed files with 2486 additions and 977 deletions

View File

@@ -21,10 +21,14 @@ ENABLE_MEMORY_OPTIMIZER=false
REDIS_URL=
REDIS_PASS=
# Appwrite (for reminders)
# Appwrite (for reminders, AI conversations, and guild cache)
APPWRITE_ENDPOINT=
APPWRITE_PROJECT_ID=
APPWRITE_API_KEY=
APPWRITE_DATABASE_ID=
APPWRITE_COLLECTION_REMINDERS_ID=
APPWRITE_COLLECTION_AI_CONVERSATIONS_ID=
APPWRITE_COLLECTION_GUILD_CACHE_ID=
# Reminders
REMINDERS_POLL_INTERVAL_SECONDS=30

17
.vscode/tasks.json vendored Normal file
View 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
View 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 ✅

View 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

View 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

View 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)

View File

@@ -5,6 +5,7 @@
"main": "src/main.ts",
"scripts": {
"start": "npx tsx watch src/main.ts",
"script:guild": "node scripts/setupGuildCacheCollection.js",
"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:mem": "MEMORY_LOG_INTERVAL_SECONDS=120 npx tsx watch src/main.ts",

View 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();

View File

@@ -1,67 +1,103 @@
import { prisma } from '../../../core/database/prisma';
import type { GameArea } from '@prisma/client';
import type { ItemProps } from '../../../game/economy/types';
import { prisma } from "../../../core/database/prisma";
import type { GameArea } from "@prisma/client";
import type { ItemProps } from "../../../game/economy/types";
import type {
Message,
TextBasedChannel,
MessageComponentInteraction,
StringSelectMenuInteraction,
ButtonInteraction,
ModalSubmitInteraction
} from 'discord.js';
import { MessageFlags } from 'discord.js';
import { ButtonStyle, ComponentType, TextInputStyle } from 'discord-api-types/v10';
ModalSubmitInteraction,
} from "discord.js";
import { MessageFlags } from "discord.js";
import {
ButtonStyle,
ComponentType,
TextInputStyle,
} from "discord-api-types/v10";
export function parseItemProps(json: unknown): ItemProps {
if (!json || typeof json !== 'object') return {};
if (!json || typeof json !== "object") return {};
return json as ItemProps;
}
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;
}
export interface ResolvedAreaInfo {
area: GameArea | null;
source: 'guild' | 'global' | 'none';
source: "guild" | "global" | "none";
}
export async function resolveGuildAreaWithFallback(guildId: string, areaKey: string): Promise<ResolvedAreaInfo> {
const guildArea = await prisma.gameArea.findFirst({ where: { key: areaKey, guildId } });
export async function resolveGuildAreaWithFallback(
guildId: string,
areaKey: string
): Promise<ResolvedAreaInfo> {
const guildArea = await prisma.gameArea.findFirst({
where: { key: areaKey, guildId },
});
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) {
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> {
const guildArea = await prisma.gameArea.findFirst({ where: { type, guildId }, orderBy: [{ createdAt: 'asc' }] });
export async function resolveAreaByType(
guildId: string,
type: string
): Promise<ResolvedAreaInfo> {
const guildArea = await prisma.gameArea.findFirst({
where: { type, guildId },
orderBy: [{ createdAt: "asc" }],
});
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) {
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> {
const prog = await prisma.playerProgress.findUnique({ where: { userId_guildId_areaId: { userId, guildId, areaId } } });
export async function getDefaultLevel(
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);
}
export async function findBestToolKey(userId: string, guildId: string, toolType: string): Promise<string | null> {
const inv = await prisma.inventoryEntry.findMany({ where: { userId, guildId, quantity: { gt: 0 } }, include: { item: true } });
export async function findBestToolKey(
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;
for (const e of inv) {
const it = e.item;
@@ -80,10 +116,12 @@ export interface ParsedGameArgs {
areaOverride: string | null;
}
const AREA_OVERRIDE_PREFIX = 'area:';
const AREA_OVERRIDE_PREFIX = "area:";
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 providedTool: string | null = null;
@@ -109,9 +147,12 @@ export function parseGameArgs(args: string[]): ParsedGameArgs {
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();
return trimmed && trimmed.length > 0 ? trimmed : fallback;
}
@@ -122,15 +163,24 @@ export function formatItemLabel(
): string {
const fallbackIcon = options.fallbackIcon ?? DEFAULT_ITEM_ICON;
const icon = resolveItemIcon(item.icon, fallbackIcon);
const label = (item.name ?? '').trim() || item.key;
const content = `${icon ? `${icon} ` : ''}${label}`.trim();
const label = (item.name ?? "").trim() || item.key;
const content = `${icon ? `${icon} ` : ""}${label}`.trim();
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>> {
const uniqueKeys = Array.from(new Set(keys.filter((key): key is string => Boolean(key && key.trim()))));
export async function fetchItemBasics(
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();
const rows = await prisma.economyItem.findMany({
@@ -138,7 +188,7 @@ export async function fetchItemBasics(guildId: string, keys: string[]): Promise<
key: { in: uniqueKeys },
OR: [{ guildId }, { guildId: null }],
},
orderBy: [{ key: 'asc' }, { guildId: 'desc' }],
orderBy: [{ key: "asc" }, { guildId: "desc" }],
select: { key: true, name: true, icon: true, guildId: true },
});
@@ -181,7 +231,7 @@ export interface KeyPickerConfig<T> {
export interface KeyPickerResult<T> {
entry: T | null;
panelMessage: Message | null;
reason: 'selected' | 'empty' | 'cancelled' | 'timeout';
reason: "selected" | "empty" | "cancelled" | "timeout";
}
export async function promptKeySelection<T>(
@@ -193,9 +243,14 @@ export async function promptKeySelection<T>(
const baseOptions = config.entries.map((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)
.join(' ')
.join(" ")
.toLowerCase();
return { entry, option, searchText };
});
@@ -203,7 +258,7 @@ export async function promptKeySelection<T>(
if (baseOptions.length === 0) {
const emptyPanel = {
type: 17,
accent_color: 0xFFA500,
accent_color: 0xffa500,
components: [
{
type: 10,
@@ -217,14 +272,14 @@ export async function promptKeySelection<T>(
reply: { messageReference: message.id },
components: [emptyPanel],
});
return { entry: null, panelMessage: null, reason: 'empty' };
return { entry: null, panelMessage: null, reason: "empty" };
}
let filter = '';
let filter = "";
let page = 0;
const pageSize = 25;
const accentColor = config.accentColor ?? 0x5865F2;
const placeholder = config.placeholder ?? 'Selecciona una opción…';
const accentColor = config.accentColor ?? 0x5865f2;
const placeholder = config.placeholder ?? "Selecciona una opción…";
const buildComponents = () => {
const normalizedFilter = filter.trim().toLowerCase();
@@ -238,10 +293,12 @@ export async function promptKeySelection<T>(
const start = safePage * 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 filterLine = filter ? `\nFiltro activo: \`${filter}\`` : '';
const hintLine = config.filterHint ? `\n${config.filterHint}` : '';
const filterLine = filter ? `\nFiltro activo: \`${filter}\`` : "";
const hintLine = config.filterHint ? `\n${config.filterHint}` : "";
const display = {
type: 17,
@@ -256,9 +313,10 @@ export async function promptKeySelection<T>(
{ type: 14, divider: true },
{
type: 10,
content: totalFiltered === 0
? 'No hay resultados para el filtro actual. Ajusta el filtro o limpia la búsqueda.'
: 'Selecciona una opción del menú desplegable para continuar.',
content:
totalFiltered === 0
? "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) {
options = [
{
label: 'Sin resultados',
label: "Sin resultados",
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,
style: ButtonStyle.Secondary,
label: '◀️',
label: "◀️",
custom_id: `${config.customIdPrefix}_prev`,
disabled: safePage <= 0 || totalFiltered === 0,
},
{
type: 2,
style: ButtonStyle.Secondary,
label: '▶️',
label: "▶️",
custom_id: `${config.customIdPrefix}_next`,
disabled: safePage >= totalPages - 1 || totalFiltered === 0,
},
{
type: 2,
style: ButtonStyle.Primary,
label: '🔎 Filtro',
label: "🔎 Filtro",
custom_id: `${config.customIdPrefix}_filter`,
},
{
type: 2,
style: ButtonStyle.Secondary,
label: 'Limpiar',
label: "Limpiar",
custom_id: `${config.customIdPrefix}_clear`,
disabled: filter.length === 0,
},
{
type: 2,
style: ButtonStyle.Danger,
label: 'Cancelar',
label: "Cancelar",
custom_id: `${config.customIdPrefix}_cancel`,
},
],
@@ -345,7 +403,10 @@ export async function promptKeySelection<T>(
let resolved = false;
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;
resolved = true;
resolve({ entry, panelMessage, reason });
@@ -353,158 +414,193 @@ export async function promptKeySelection<T>(
const collector = panelMessage.createMessageComponentCollector({
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) => {
try {
if (interaction.customId === `${config.customIdPrefix}_select` && interaction.isStringSelectMenu()) {
const select = interaction as StringSelectMenuInteraction;
const value = select.values?.[0];
const selected = baseOptions.find((opt) => opt.option.value === value);
if (!selected) {
await select.reply({ content: '❌ Opción no válida.', flags: MessageFlags.Ephemeral });
collector.on(
"collect",
async (interaction: MessageComponentInteraction) => {
try {
if (
interaction.customId === `${config.customIdPrefix}_select` &&
interaction.isStringSelectMenu()
) {
const select = interaction as StringSelectMenuInteraction;
const value = select.values?.[0];
const selected = baseOptions.find(
(opt) => opt.option.value === value
);
if (!selected) {
await select.reply({
content: "❌ Opción no válida.",
flags: MessageFlags.Ephemeral,
});
return;
}
try {
await select.update({
components: [
{
type: 17,
accent_color: accentColor,
components: [
{
type: 10,
content: `⏳ Cargando **${selected.option.label}**…`,
},
],
},
],
});
} catch {
if (!select.deferred && !select.replied) {
try {
await select.deferUpdate();
} catch {}
}
}
finish(selected.entry, "selected");
collector.stop("selected");
return;
}
try {
await select.update({
components: [
{
type: 17,
accent_color: accentColor,
components: [
{
type: 10,
content: `⏳ Cargando **${selected.option.label}**…`,
},
],
},
],
});
} catch {
if (!select.deferred && !select.replied) {
try { await select.deferUpdate(); } catch {}
}
}
finish(selected.entry, 'selected');
collector.stop('selected');
return;
}
if (interaction.customId === `${config.customIdPrefix}_prev` && interaction.isButton()) {
if (page > 0) page -= 1;
await interaction.update({ components: buildComponents() });
return;
}
if (interaction.customId === `${config.customIdPrefix}_next` && interaction.isButton()) {
page += 1;
await interaction.update({ components: buildComponents() });
return;
}
if (interaction.customId === `${config.customIdPrefix}_clear` && interaction.isButton()) {
filter = '';
page = 0;
await interaction.update({ components: buildComponents() });
return;
}
if (interaction.customId === `${config.customIdPrefix}_cancel` && interaction.isButton()) {
try {
await interaction.update({
components: [
{
type: 17,
accent_color: 0xFF0000,
components: [
{ type: 10, content: '❌ Selección cancelada.' },
],
},
],
});
} catch {
if (!interaction.deferred && !interaction.replied) {
try { await interaction.deferUpdate(); } catch {}
}
}
finish(null, 'cancelled');
collector.stop('cancelled');
return;
}
if (interaction.customId === `${config.customIdPrefix}_filter` && interaction.isButton()) {
const modal = {
title: 'Filtrar lista',
customId: `${config.customIdPrefix}_filter_modal`,
components: [
{
type: ComponentType.Label,
label: 'Texto a buscar',
component: {
type: ComponentType.TextInput,
customId: 'query',
style: TextInputStyle.Short,
required: false,
value: filter,
placeholder: 'Nombre, key, categoría…',
},
},
],
} as const;
await (interaction as ButtonInteraction).showModal(modal);
let submitted: ModalSubmitInteraction | undefined;
try {
submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (sub) => sub.user.id === userId && sub.customId === `${config.customIdPrefix}_filter_modal`,
});
} catch {
if (
interaction.customId === `${config.customIdPrefix}_prev` &&
interaction.isButton()
) {
if (page > 0) page -= 1;
await interaction.update({ components: buildComponents() });
return;
}
try {
const value = submitted.components.getTextInputValue('query')?.trim() ?? '';
filter = value;
if (
interaction.customId === `${config.customIdPrefix}_next` &&
interaction.isButton()
) {
page += 1;
await interaction.update({ components: buildComponents() });
return;
}
if (
interaction.customId === `${config.customIdPrefix}_clear` &&
interaction.isButton()
) {
filter = "";
page = 0;
await submitted.deferUpdate();
await panelMessage.edit({ components: buildComponents() });
} catch {
// ignore errors updating filter
await interaction.update({ components: buildComponents() });
return;
}
if (
interaction.customId === `${config.customIdPrefix}_cancel` &&
interaction.isButton()
) {
try {
await interaction.update({
components: [
{
type: 17,
accent_color: 0xff0000,
components: [
{ type: 10, content: "❌ Selección cancelada." },
],
},
],
});
} catch {
if (!interaction.deferred && !interaction.replied) {
try {
await interaction.deferUpdate();
} catch {}
}
}
finish(null, "cancelled");
collector.stop("cancelled");
return;
}
if (
interaction.customId === `${config.customIdPrefix}_filter` &&
interaction.isButton()
) {
const modal = {
title: "Filtrar lista",
customId: `${config.customIdPrefix}_filter_modal`,
components: [
{
type: ComponentType.Label,
label: "Texto a buscar",
component: {
type: ComponentType.TextInput,
customId: "query",
style: TextInputStyle.Short,
required: false,
value: filter,
placeholder: "Nombre, key, categoría…",
},
},
],
} as const;
await (interaction as ButtonInteraction).showModal(modal);
let submitted: ModalSubmitInteraction | undefined;
try {
submitted = await interaction.awaitModalSubmit({
time: 120_000,
filter: (sub) =>
sub.user.id === userId &&
sub.customId === `${config.customIdPrefix}_filter_modal`,
});
} catch {
return;
}
try {
const value =
submitted.components.getTextInputValue("query")?.trim() ?? "";
filter = value;
page = 0;
await submitted.deferUpdate();
await panelMessage.edit({ components: buildComponents() });
} catch {
// ignore errors updating filter
}
return;
}
} catch (err) {
if (!interaction.deferred && !interaction.replied) {
await interaction.reply({
content: "❌ Error procesando la selección.",
flags: MessageFlags.Ephemeral,
});
}
return;
}
} catch (err) {
if (!interaction.deferred && !interaction.replied) {
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;
resolved = true;
if (reason !== 'selected' && reason !== 'cancelled') {
if (reason !== "selected" && reason !== "cancelled") {
const expiredPanel = {
type: 17,
accent_color: 0xFFA500,
components: [
{ type: 10, content: '⏰ Selección expirada.' },
],
accent_color: 0xffa500,
components: [{ type: 10, content: "⏰ Selección expirada." }],
};
try {
await panelMessage.edit({ components: [expiredPanel] });
} catch {}
}
let mappedReason: 'selected' | 'cancelled' | 'timeout';
if (reason === 'selected') mappedReason = 'selected';
else if (reason === 'cancelled') mappedReason = 'cancelled';
else mappedReason = 'timeout';
let mappedReason: "selected" | "cancelled" | "timeout";
if (reason === "selected") mappedReason = "selected";
else if (reason === "cancelled") mappedReason = "cancelled";
else mappedReason = "timeout";
resolve({ entry: null, panelMessage, reason: mappedReason });
});
@@ -513,13 +609,15 @@ export async function promptKeySelection<T>(
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 };
return (channel.send as any)({
content: null,
flags: 32768,
reply: { messageReference: message.id },
message_reference: { message_id: message.id },
components: [display, ...extraComponents],
});
}

View File

@@ -3,6 +3,11 @@ import type Amayo from "../../../core/client";
import { getStreakInfo, updateStreak } from "../../../game/streaks/service";
import type { TextBasedChannel } from "discord.js";
import { fetchItemBasics, formatItemLabel, sendDisplayReply } from "./_helpers";
import {
buildDisplay,
textBlock,
dividerBlock,
} from "../../../core/lib/componentsV2";
export const command: CommandMessage = {
name: "racha",
@@ -22,51 +27,33 @@ export const command: CommandMessage = {
guildId
);
// Construir componentes
const components: any[] = [
{
type: 10,
content: `# 🔥 Racha Diaria de ${message.author.username}`,
},
{ type: 14, divider: true },
{
type: 9,
components: [
{
type: 10,
content:
`**📊 ESTADÍSTICAS**\n` +
`🔥 Racha Actual: **${streak.currentStreak}** días\n` +
`⭐ Mejor Racha: **${streak.longestStreak}** días\n` +
`📅 Días Activos: **${streak.totalDaysActive}** días`,
},
],
},
{ type: 14, spacing: 1 },
// Construir bloques de display (evitando type:9 sin accessory)
const blocks: any[] = [
textBlock(`# 🔥 Racha Diaria de ${message.author.username}`),
dividerBlock(),
textBlock(
`**📊 ESTADÍSTICAS**\n` +
`🔥 Racha Actual: **${streak.currentStreak}** días\n` +
`⭐ Mejor Racha: **${streak.longestStreak}** días\n` +
`📅 Días Activos: **${streak.totalDaysActive}** días`
),
dividerBlock({ spacing: 1 }),
];
// Mensaje de estado
if (newDay) {
if (daysIncreased) {
components.push({
type: 9,
components: [
{
type: 10,
content: `**✅ ¡RACHA INCREMENTADA!**\nHas mantenido tu racha por **${streak.currentStreak}** días seguidos.`,
},
],
});
blocks.push(
textBlock(
`**✅ ¡RACHA INCREMENTADA!**\nHas mantenido tu racha por **${streak.currentStreak}** días seguidos.`
)
);
} else {
components.push({
type: 9,
components: [
{
type: 10,
content: `**⚠️ RACHA REINICIADA**\nPasó más de un día sin actividad. Tu racha se ha reiniciado.`,
},
],
});
blocks.push(
textBlock(
`**⚠️ RACHA REINICIADA**\nPasó más de un día sin actividad. Tu racha se ha reiniciado.`
)
);
}
// Mostrar recompensas
@@ -90,27 +77,15 @@ export const command: CommandMessage = {
});
}
components.push({ type: 14, spacing: 1 });
components.push({
type: 9,
components: [
{
type: 10,
content: rewardsText,
},
],
});
blocks.push(dividerBlock({ spacing: 1 }));
blocks.push(textBlock(rewardsText));
}
} else {
components.push({
type: 9,
components: [
{
type: 10,
content: `** YA RECLAMASTE HOY**\nYa has reclamado tu recompensa diaria. Vuelve mañana para continuar tu racha.`,
},
],
});
blocks.push(
textBlock(
`** YA RECLAMASTE HOY**\nYa has reclamado tu recompensa diaria. Vuelve mañana para continuar tu racha.`
)
);
}
// Próximos hitos
@@ -119,23 +94,15 @@ export const command: CommandMessage = {
if (nextMilestone) {
const remaining = nextMilestone - streak.currentStreak;
components.push({ type: 14, spacing: 1 });
components.push({
type: 9,
components: [
{
type: 10,
content: `**🎯 PRÓXIMO HITO**\nFaltan **${remaining}** días para alcanzar el día **${nextMilestone}**`,
},
],
});
blocks.push(dividerBlock({ spacing: 1 }));
blocks.push(
textBlock(
`**🎯 PRÓXIMO HITO**\nFaltan **${remaining}** días para alcanzar el día **${nextMilestone}**`
)
);
}
const display = {
type: 17,
accent_color: daysIncreased ? 0x00ff00 : 0xffa500,
components,
};
const display = buildDisplay(daysIncreased ? 0x00ff00 : 0xffa500, blocks);
await sendDisplayReply(message, display);
} catch (error) {

File diff suppressed because it is too large Load Diff

View File

@@ -1,14 +1,18 @@
// Simple Appwrite client wrapper
// @ts-ignore
import { Client, Databases } from 'node-appwrite';
import { Client, Databases } from "node-appwrite";
const endpoint = process.env.APPWRITE_ENDPOINT || '';
const projectId = process.env.APPWRITE_PROJECT_ID || '';
const apiKey = process.env.APPWRITE_API_KEY || '';
const endpoint = process.env.APPWRITE_ENDPOINT || "";
const projectId = process.env.APPWRITE_PROJECT_ID || "";
const apiKey = process.env.APPWRITE_API_KEY || "";
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_AI_CONVERSATIONS_ID = process.env.APPWRITE_COLLECTION_AI_CONVERSATIONS_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_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 databases: Databases | null = null;
@@ -16,7 +20,10 @@ let databases: Databases | null = null;
function ensureClient() {
if (!endpoint || !projectId || !apiKey) return null;
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);
return client;
}
@@ -26,9 +33,31 @@ export function getDatabases(): Databases | null {
}
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 {
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
);
}

View 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");
}
}

View File

@@ -1,238 +1,280 @@
import {bot} from "../main";
import {Events} from "discord.js";
import {redis} from "../core/database/redis";
import {commands} from "../core/loaders/loader";
import {alliance} from "./extras/alliace";
import { bot } from "../main";
import { Events } from "discord.js";
import { redis } from "../core/database/redis";
import { commands } from "../core/loaders/loader";
import { alliance } from "./extras/alliace";
import logger from "../core/lib/logger";
import { aiService } from "../core/services/AIService";
import { getGuildConfig } from "../core/database/guildCache";
// Función para manejar respuestas automáticas a la AI
async function handleAIReply(message: any) {
// Verificar si es una respuesta a un mensaje del bot
if (!message.reference?.messageId || message.author.bot) return;
// Verificar si es una respuesta a un mensaje del bot
if (!message.reference?.messageId || message.author.bot) return;
try {
const referencedMessage = await message.channel.messages.fetch(
message.reference.messageId
);
// Verificar si el mensaje referenciado es del bot
if (referencedMessage.author.id !== message.client.user?.id) return;
// Verificar que el contenido no sea un comando (para evitar loops)
const guildConfig = await getGuildConfig(
message.guildId || message.guild!.id,
message.guild!.name,
bot.prisma
);
const PREFIX = guildConfig.prefix || "!";
if (message.content.startsWith(PREFIX)) return;
// Verificar que el mensaje tenga contenido válido
if (!message.content || message.content.trim().length === 0) return;
// Limitar longitud del mensaje
if (message.content.length > 4000) {
await message.reply(
"❌ **Error:** Tu mensaje es demasiado largo (máximo 4000 caracteres)."
);
return;
}
logger.info(
`Respuesta automática a AI detectada - Usuario: ${message.author.id}, Guild: ${message.guildId}`
);
// Indicador de que está escribiendo
const typingInterval = setInterval(() => {
message.channel.sendTyping().catch(() => {});
}, 5000);
try {
const referencedMessage = await message.channel.messages.fetch(message.reference.messageId);
// Verificar si el mensaje referenciado es del bot
if (referencedMessage.author.id !== message.client.user?.id) return;
// Verificar que el contenido no sea un comando (para evitar loops)
const server = await bot.prisma.guild.findUnique({
where: { id: message.guildId || undefined }
});
const PREFIX = server?.prefix || "!";
if (message.content.startsWith(PREFIX)) return;
// Verificar que el mensaje tenga contenido válido
if (!message.content || message.content.trim().length === 0) return;
// Limitar longitud del mensaje
if (message.content.length > 4000) {
await message.reply('❌ **Error:** Tu mensaje es demasiado largo (máximo 4000 caracteres).');
return;
// Obtener emojis personalizados del servidor
const emojiResult = {
names: [] as string[],
map: {} as Record<string, string>,
};
try {
const guild = message.guild;
if (guild) {
const emojis = await guild.emojis.fetch();
const list = Array.from(emojis.values());
for (const e of list) {
// @ts-ignore
const name = e.name;
// @ts-ignore
const id = e.id;
if (!name || !id) continue;
// @ts-ignore
const tag = e.animated ? `<a:${name}:${id}>` : `<:${name}:${id}>`;
if (!(name in emojiResult.map)) {
emojiResult.map[name] = tag;
emojiResult.names.push(name);
}
}
emojiResult.names = emojiResult.names.slice(0, 25);
}
} catch {
// Ignorar errores de emojis
}
logger.info(`Respuesta automática a AI detectada - Usuario: ${message.author.id}, Guild: ${message.guildId}`);
// Indicador de que está escribiendo
const typingInterval = setInterval(() => {
message.channel.sendTyping().catch(() => {});
}, 5000);
// Construir metadatos del mensaje
const buildMessageMeta = (msg: any, emojiNames?: string[]): string => {
try {
// Obtener emojis personalizados del servidor
const emojiResult = { names: [] as string[], map: {} as Record<string, string> };
try {
const guild = message.guild;
if (guild) {
const emojis = await guild.emojis.fetch();
const list = Array.from(emojis.values());
for (const e of list) {
// @ts-ignore
const name = e.name;
// @ts-ignore
const id = e.id;
if (!name || !id) continue;
// @ts-ignore
const tag = e.animated ? `<a:${name}:${id}>` : `<:${name}:${id}>`;
if (!(name in emojiResult.map)) {
emojiResult.map[name] = tag;
emojiResult.names.push(name);
}
}
emojiResult.names = emojiResult.names.slice(0, 25);
}
} catch {
// Ignorar errores de emojis
}
const parts: string[] = [];
// Construir metadatos del mensaje
const buildMessageMeta = (msg: any, emojiNames?: string[]): string => {
try {
const parts: string[] = [];
if (msg.channel?.name) {
parts.push(`Canal: #${msg.channel.name}`);
}
if (msg.channel?.name) {
parts.push(`Canal: #${msg.channel.name}`);
}
const userMentions = msg.mentions?.users
? Array.from(msg.mentions.users.values())
: [];
const roleMentions = msg.mentions?.roles
? Array.from(msg.mentions.roles.values())
: [];
const userMentions = msg.mentions?.users ? Array.from(msg.mentions.users.values()) : [];
const roleMentions = msg.mentions?.roles ? Array.from(msg.mentions.roles.values()) : [];
if (userMentions.length) {
parts.push(`Menciones usuario: ${userMentions.slice(0, 5).map((u: any) => u.username ?? u.tag ?? u.id).join(', ')}`);
}
if (roleMentions.length) {
parts.push(`Menciones rol: ${roleMentions.slice(0, 5).map((r: any) => r.name ?? r.id).join(', ')}`);
}
if (msg.reference?.messageId) {
parts.push('Es una respuesta a mensaje de AI');
}
if (emojiNames && emojiNames.length) {
parts.push(`Emojis personalizados disponibles (usa :nombre:): ${emojiNames.join(', ')}`);
}
const metaRaw = parts.join(' | ');
return metaRaw.length > 800 ? metaRaw.slice(0, 800) : metaRaw;
} catch {
return '';
}
};
const messageMeta = buildMessageMeta(message, emojiResult.names);
// Verificar si hay imágenes adjuntas
const attachments = Array.from(message.attachments.values());
const hasImages = attachments.length > 0 && aiService.hasImageAttachments(attachments);
// Procesar con el servicio de AI usando memoria persistente y soporte para imágenes
const aiResponse = await aiService.processAIRequestWithMemory(
message.author.id,
message.content,
message.guildId,
message.channel.id,
message.id,
message.reference.messageId,
message.client,
'normal',
{
meta: messageMeta + (hasImages ? ` | Tiene ${attachments.length} imagen(es) adjunta(s)` : ''),
attachments: hasImages ? attachments : undefined
}
if (userMentions.length) {
parts.push(
`Menciones usuario: ${userMentions
.slice(0, 5)
.map((u: any) => u.username ?? u.tag ?? u.id)
.join(", ")}`
);
}
if (roleMentions.length) {
parts.push(
`Menciones rol: ${roleMentions
.slice(0, 5)
.map((r: any) => r.name ?? r.id)
.join(", ")}`
);
}
// Reemplazar emojis personalizados
let finalResponse = aiResponse;
if (emojiResult.names.length > 0) {
finalResponse = finalResponse.replace(/:([a-zA-Z0-9_]{2,32}):/g, (match, p1: string) => {
const found = emojiResult.map[p1];
return found ? found : match;
});
if (msg.reference?.messageId) {
parts.push("Es una respuesta a mensaje de AI");
}
if (emojiNames && emojiNames.length) {
parts.push(
`Emojis personalizados disponibles (usa :nombre:): ${emojiNames.join(
", "
)}`
);
}
const metaRaw = parts.join(" | ");
return metaRaw.length > 800 ? metaRaw.slice(0, 800) : metaRaw;
} catch {
return "";
}
};
const messageMeta = buildMessageMeta(message, emojiResult.names);
// Verificar si hay imágenes adjuntas
const attachments = Array.from(message.attachments.values());
const hasImages =
attachments.length > 0 && aiService.hasImageAttachments(attachments);
// Procesar con el servicio de AI usando memoria persistente y soporte para imágenes
const aiResponse = await aiService.processAIRequestWithMemory(
message.author.id,
message.content,
message.guildId,
message.channel.id,
message.id,
message.reference.messageId,
message.client,
"normal",
{
meta:
messageMeta +
(hasImages
? ` | Tiene ${attachments.length} imagen(es) adjunta(s)`
: ""),
attachments: hasImages ? attachments : undefined,
}
);
// Reemplazar emojis personalizados
let finalResponse = aiResponse;
if (emojiResult.names.length > 0) {
finalResponse = finalResponse.replace(
/:([a-zA-Z0-9_]{2,32}):/g,
(match, p1: string) => {
const found = emojiResult.map[p1];
return found ? found : match;
}
);
}
// Enviar respuesta (dividir si es muy larga)
const MAX_CONTENT = 2000;
if (finalResponse.length > MAX_CONTENT) {
const chunks: string[] = [];
let currentChunk = "";
const lines = finalResponse.split("\n");
for (const line of lines) {
if (currentChunk.length + line.length + 1 > MAX_CONTENT) {
if (currentChunk) {
chunks.push(currentChunk.trim());
currentChunk = "";
}
// Enviar respuesta (dividir si es muy larga)
const MAX_CONTENT = 2000;
if (finalResponse.length > MAX_CONTENT) {
const chunks: string[] = [];
let currentChunk = '';
const lines = finalResponse.split('\n');
for (const line of lines) {
if (currentChunk.length + line.length + 1 > MAX_CONTENT) {
if (currentChunk) {
chunks.push(currentChunk.trim());
currentChunk = '';
}
}
currentChunk += (currentChunk ? '\n' : '') + line;
}
if (currentChunk) {
chunks.push(currentChunk.trim());
}
for (let i = 0; i < chunks.length && i < 3; i++) {
if (i === 0) {
await message.reply({ content: chunks[i] });
} else {
if ('send' in message.channel) {
await message.channel.send({ content: chunks[i] });
await new Promise(resolve => setTimeout(resolve, 500));
}
}
}
if (chunks.length > 3) {
if ('send' in message.channel) {
await message.channel.send({ content: "⚠️ Respuesta truncada por longitud." });
}
}
} else {
await message.reply({ content: finalResponse });
}
} catch (error: any) {
logger.error(`Error en respuesta automática AI:`, error);
await message.reply({
content: `❌ **Error:** ${error.message || 'No pude procesar tu respuesta. Intenta de nuevo.'}`
});
} finally {
clearInterval(typingInterval);
}
currentChunk += (currentChunk ? "\n" : "") + line;
}
} catch (error) {
// Mensaje referenciado no encontrado o error, ignorar silenciosamente
logger.debug(`Error obteniendo mensaje referenciado: ${error}`);
if (currentChunk) {
chunks.push(currentChunk.trim());
}
for (let i = 0; i < chunks.length && i < 3; i++) {
if (i === 0) {
await message.reply({ content: chunks[i] });
} else {
if ("send" in message.channel) {
await message.channel.send({ content: chunks[i] });
await new Promise((resolve) => setTimeout(resolve, 500));
}
}
}
if (chunks.length > 3) {
if ("send" in message.channel) {
await message.channel.send({
content: "⚠️ Respuesta truncada por longitud.",
});
}
}
} else {
await message.reply({ content: finalResponse });
}
} catch (error: any) {
logger.error(`Error en respuesta automática AI:`, error);
await message.reply({
content: `❌ **Error:** ${
error.message || "No pude procesar tu respuesta. Intenta de nuevo."
}`,
});
} finally {
clearInterval(typingInterval);
}
} catch (error) {
// Mensaje referenciado no encontrado o error, ignorar silenciosamente
logger.debug(`Error obteniendo mensaje referenciado: ${error}`);
}
}
bot.on(Events.MessageCreate, async (message) => {
if (message.author.bot) return;
if (message.author.bot) return;
// Manejar respuestas automáticas a la AI
await handleAIReply(message);
// Manejar respuestas automáticas a la AI
await handleAIReply(message);
await alliance(message);
const server = await bot.prisma.guild.upsert({
where: {
id: message.guildId || undefined
},
create: {
id: message!.guildId || message.guild!.id,
name: message.guild!.name
},
update: {}
})
const PREFIX = server.prefix || "!"
if (!message.content.startsWith(PREFIX)) return;
await alliance(message);
const [cmdName, ...args] = message.content.slice(PREFIX.length).trim().split(/\s+/);
const command = commands.get(cmdName);
if (!command) return;
// Usar caché para obtener la configuración del guild
const guildConfig = await getGuildConfig(
message.guildId || message.guild!.id,
message.guild!.name,
bot.prisma
);
const cooldown = Math.floor(Number(command.cooldown) || 0);
const PREFIX = guildConfig.prefix || "!";
if (!message.content.startsWith(PREFIX)) return;
if (cooldown > 0) {
const key = `cooldown:${command.name}:${message.author.id}`;
const ttl = await redis.ttl(key);
logger.debug(`Key: ${key}, TTL: ${ttl}`);
const [cmdName, ...args] = message.content
.slice(PREFIX.length)
.trim()
.split(/\s+/);
const command = commands.get(cmdName);
if (!command) return;
if (ttl > 0) {
return message.reply(`⏳ Espera ${ttl}s antes de volver a usar **${command.name}**.`);
}
const cooldown = Math.floor(Number(command.cooldown) || 0);
// SET con expiración correcta para redis v4+
await redis.set(key, "1", { EX: cooldown });
if (cooldown > 0) {
const key = `cooldown:${command.name}:${message.author.id}`;
const ttl = await redis.ttl(key);
logger.debug(`Key: ${key}, TTL: ${ttl}`);
if (ttl > 0) {
return message.reply(
`⏳ Espera ${ttl}s antes de volver a usar **${command.name}**.`
);
}
// SET con expiración correcta para redis v4+
await redis.set(key, "1", { EX: cooldown });
}
try {
await command.run(message, args, message.client);
} catch (error) {
logger.error({ err: error }, "Error ejecutando comando");
await message.reply("❌ Hubo un error ejecutando el comando.");
}
})
try {
await command.run(message, args, message.client);
} catch (error) {
logger.error({ err: error }, "Error ejecutando comando");
await message.reply("❌ Hubo un error ejecutando el comando.");
}
});

View File

@@ -1,216 +1,273 @@
import Amayo from "./core/client";
import { loadCommands } from "./core/loaders/loader";
import { loadEvents } from "./core/loaders/loaderEvents";
import { redis, redisConnect } from "./core/database/redis";
import { registeringCommands } from "./core/api/discordAPI";
import {loadComponents} from "./core/lib/components";
import { loadComponents } from "./core/lib/components";
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 { ensureRemindersSchema } from "./core/api/remindersSchema";
import { cleanExpiredGuildCache } from "./core/database/guildCache";
import logger from "./core/lib/logger";
import { applyModalSubmitInteractionPatch } from "./core/patches/discordModalPatch";
import { server } from "./server/server";
// 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) {
startMemoryMonitor({ intervalSeconds: __memInt });
startMemoryMonitor({ intervalSeconds: __memInt });
}
// Activar optimizador de memoria adicional
if (process.env.ENABLE_MEMORY_OPTIMIZER === 'true') {
memoryOptimizer.start();
if (process.env.ENABLE_MEMORY_OPTIMIZER === "true") {
memoryOptimizer.start();
}
// Apply safety patch for ModalSubmitInteraction members resolution before anything else
try {
applyModalSubmitInteractionPatch();
applyModalSubmitInteractionPatch();
} 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();
// Listeners de robustez del cliente Discord
bot.on('error', (e) => logger.error({ err: e }, '🐞 Discord client error'));
bot.on('warn', (m) => logger.warn('⚠️ Discord warn: %s', m));
bot.on("error", (e) => logger.error({ err: e }, "🐞 Discord client error"));
bot.on("warn", (m) => logger.warn("⚠️ Discord warn: %s", m));
// Evitar reintentos de re-login simultáneos
let relogging = false;
// Cuando la sesión es invalidada, intentamos reconectar/login
bot.on('invalidated', () => {
if (relogging) return;
relogging = true;
logger.error('🔄 Sesión de Discord invalidada. Reintentando login...');
withRetry('Re-login tras invalidated', () => bot.play(), { minDelayMs: 2000, maxDelayMs: 60_000 })
.catch(() => {
logger.error('No se pudo reloguear tras invalidated, se seguirá intentando en el bucle general.');
})
.finally(() => { relogging = false; });
bot.on("invalidated", () => {
if (relogging) return;
relogging = true;
logger.error("🔄 Sesión de Discord invalidada. Reintentando login...");
withRetry("Re-login tras invalidated", () => bot.play(), {
minDelayMs: 2000,
maxDelayMs: 60_000,
})
.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
async function withRetry<T>(name: string, fn: () => Promise<T>, opts?: {
async function withRetry<T>(
name: string,
fn: () => Promise<T>,
opts?: {
retries?: number;
minDelayMs?: number;
maxDelayMs?: number;
factor?: number;
jitter?: boolean;
isRetryable?: (err: unknown, attempt: number) => boolean;
}): Promise<T> {
const {
retries = Infinity,
minDelayMs = 1000,
maxDelayMs = 30_000,
factor = 1.8,
jitter = true,
isRetryable = () => true,
} = opts ?? {};
}
): Promise<T> {
const {
retries = Infinity,
minDelayMs = 1000,
maxDelayMs = 30_000,
factor = 1.8,
jitter = true,
isRetryable = () => true,
} = opts ?? {};
let attempt = 0;
let delay = minDelayMs;
let attempt = 0;
let delay = minDelayMs;
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await fn();
} catch (err) {
attempt++;
const errMsg = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
logger.error(`${name} falló (intento ${attempt}) => %s`, errMsg);
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await fn();
} catch (err) {
attempt++;
const errMsg =
err instanceof Error ? `${err.name}: ${err.message}` : String(err);
logger.error(`${name} falló (intento ${attempt}) => %s`, errMsg);
if (!isRetryable(err, attempt)) {
logger.error(`${name}: error no recuperable, deteniendo reintentos.`);
throw err;
}
if (!isRetryable(err, attempt)) {
logger.error(
`${name}: error no recuperable, deteniendo reintentos.`
);
throw err;
}
if (attempt >= retries) throw err;
if (attempt >= retries) throw err;
// calcular backoff
let wait = delay;
if (jitter) {
const rand = Math.random() + 0.5; // 0.5x a 1.5x
wait = Math.min(maxDelayMs, Math.floor(delay * rand));
} else {
wait = Math.min(maxDelayMs, delay);
}
logger.warn(`⏳ Reintentando ${name} en ${wait}ms...`);
await new Promise((r) => setTimeout(r, wait));
delay = Math.min(maxDelayMs, Math.floor(delay * factor));
}
// calcular backoff
let wait = delay;
if (jitter) {
const rand = Math.random() + 0.5; // 0.5x a 1.5x
wait = Math.min(maxDelayMs, Math.floor(delay * rand));
} else {
wait = Math.min(maxDelayMs, delay);
}
logger.warn(`⏳ Reintentando ${name} en ${wait}ms...`);
await new Promise((r) => setTimeout(r, wait));
delay = Math.min(maxDelayMs, Math.floor(delay * factor));
}
}
}
// Handlers globales para robustez
process.on('unhandledRejection', (reason: any, p) => {
logger.error({ promise: p, reason }, '🚨 UnhandledRejection en Promise');
process.on("unhandledRejection", (reason: any, p) => {
logger.error({ promise: p, reason }, "🚨 UnhandledRejection en Promise");
});
process.on('uncaughtException', (err) => {
logger.error({ err }, '🚨 UncaughtException');
// No salimos; dejamos que el bot continúe vivo
process.on("uncaughtException", (err) => {
logger.error({ err }, "🚨 UncaughtException");
// No salimos; dejamos que el bot continúe vivo
});
process.on('multipleResolves', (type, promise, reason: any) => {
// Ignorar resoluciones sin razón (ruido)
if (type === 'resolve' && (reason === undefined || reason === null)) {
return;
}
const msg = reason instanceof Error ? `${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);
if (isAbortErr && isDiscordWs) {
// Ruido benigno de reconexiones del WS de Discord: ignorar
return;
}
logger.warn('⚠️ multipleResolves: %s %s', type, msg);
process.on("multipleResolves", (type, promise, reason: any) => {
// Ignorar resoluciones sin razón (ruido)
if (type === "resolve" && (reason === undefined || reason === null)) {
return;
}
const msg =
reason instanceof Error
? `${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);
if (isAbortErr && isDiscordWs) {
// Ruido benigno de reconexiones del WS de Discord: ignorar
return;
}
logger.warn("⚠️ multipleResolves: %s %s", type, msg);
});
let shuttingDown = false;
async function gracefulShutdown() {
if (shuttingDown) return;
shuttingDown = true;
logger.info('🛑 Apagado controlado iniciado...');
try {
// Detener optimizador de memoria
memoryOptimizer.stop();
if (shuttingDown) return;
shuttingDown = true;
logger.info("🛑 Apagado controlado iniciado...");
try {
// Detener optimizador de memoria
memoryOptimizer.stop();
// Cerrar Redis si procede
try {
if (redis?.isOpen) {
await redis.quit();
logger.info('🔌 Redis cerrado');
}
} catch (e) {
logger.warn({ err: e }, 'No se pudo cerrar Redis limpiamente');
}
// Cerrar Prisma y Discord
try {
await bot.prisma.$disconnect();
} catch {}
try {
await bot.destroy();
} catch {}
} finally {
logger.info('✅ Apagado controlado completo');
// Cerrar Redis si procede
try {
if (redis?.isOpen) {
await redis.quit();
logger.info("🔌 Redis cerrado");
}
} catch (e) {
logger.warn({ err: e }, "No se pudo cerrar Redis limpiamente");
}
// Cerrar Prisma y Discord
try {
await bot.prisma.$disconnect();
} catch {}
try {
await bot.destroy();
} catch {}
} finally {
logger.info("✅ Apagado controlado completo");
}
}
process.on('SIGINT', gracefulShutdown);
process.on('SIGTERM', gracefulShutdown);
process.on("SIGINT", gracefulShutdown);
process.on("SIGTERM", gracefulShutdown);
async function bootstrap() {
logger.info("🚀 Iniciando bot...");
await server.listen(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)
try { loadCommands(); } 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'); }
logger.info("🚀 Iniciando bot...");
await server.listen(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)
try {
loadCommands();
} 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
withRetry('Registrar slash commands', async () => {
await registeringCommands();
}).catch((e) => logger.error({ err: e }, 'Registro de comandos agotó reintentos'));
// Registrar comandos en segundo plano con reintentos; no bloquea el arranque del bot
withRetry("Registrar slash commands", async () => {
await registeringCommands();
}).catch((e) =>
logger.error({ err: e }, "Registro de comandos agotó reintentos")
);
// Conectar Redis con reintentos
await withRetry('Conectar a Redis', async () => {
await redisConnect();
});
// Conectar Redis con reintentos
await withRetry("Conectar a Redis", async () => {
await redisConnect();
});
// Login Discord + DB con reintentos (gestionado en Amayo.play -> conecta Prisma + login)
await withRetry('Login de Discord', async () => {
await bot.play();
}, {
isRetryable: (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
return !/missing discord token|invalid token/i.test(msg);
}
});
// Login Discord + DB con reintentos (gestionado en Amayo.play -> conecta Prisma + login)
await withRetry(
"Login de Discord",
async () => {
await bot.play();
},
{
isRetryable: (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
return !/missing discord token|invalid token/i.test(msg);
},
}
);
// 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'); }
// 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");
}
// Iniciar poller de recordatorios si Appwrite está configurado
startReminderPoller(bot);
// Iniciar poller de recordatorios si Appwrite está configurado
startReminderPoller(bot);
logger.info("✅ Bot conectado a Discord");
// 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");
}
// Bucle de arranque resiliente: si bootstrap completo falla, reintenta sin matar el proceso
(async function startLoop() {
await withRetry('Arranque', bootstrap, {
minDelayMs: 1000,
maxDelayMs: 60_000,
isRetryable: (err) => {
const msg = err instanceof Error ? `${err.message}` : String(err);
// No reintentar en bucle si el problema es falta/invalid token
return !/missing discord token|invalid token/i.test(msg);
}
});
await withRetry("Arranque", bootstrap, {
minDelayMs: 1000,
maxDelayMs: 60_000,
isRetryable: (err) => {
const msg = err instanceof Error ? `${err.message}` : String(err);
// No reintentar en bucle si el problema es falta/invalid token
return !/missing discord token|invalid token/i.test(msg);
},
});
})();