From a9087261ca12d38feced426b81c9f54fc178ec7a Mon Sep 17 00:00:00 2001 From: shni Date: Tue, 7 Oct 2025 10:52:47 -0500 Subject: [PATCH] feat: introduce Appwrite guild cache support and refactor guild config caching --- .env.example | 6 +- package.json | 1 + src/core/api/appwrite.ts | 49 +++- src/events/messageCreate.ts | 454 ++++++++++++++++++++---------------- src/main.ts | 341 ++++++++++++++++----------- 5 files changed, 492 insertions(+), 359 deletions(-) diff --git a/.env.example b/.env.example index 4066c02..21d4392 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/package.json b/package.json index 74292d3..3b88e0a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/core/api/appwrite.ts b/src/core/api/appwrite.ts index b590652..759b1ef 100644 --- a/src/core/api/appwrite.ts +++ b/src/core/api/appwrite.ts @@ -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 + ); } diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index c216558..040fb4c 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -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, + }; + 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 ? `` : `<:${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 }; - 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 ? `` : `<:${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."); - } -}) \ No newline at end of file + 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."); + } +}); diff --git a/src/main.ts b/src/main.ts index e118b59..d8b0668 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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(name: string, fn: () => Promise, opts?: { +async function withRetry( + name: string, + fn: () => Promise, + opts?: { retries?: number; minDelayMs?: number; maxDelayMs?: number; factor?: number; jitter?: boolean; isRetryable?: (err: unknown, attempt: number) => boolean; -}): Promise { - const { - retries = Infinity, - minDelayMs = 1000, - maxDelayMs = 30_000, - factor = 1.8, - jitter = true, - isRetryable = () => true, - } = opts ?? {}; + } +): Promise { + 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); + }, + }); })();