From a9087261ca12d38feced426b81c9f54fc178ec7a Mon Sep 17 00:00:00 2001 From: shni Date: Tue, 7 Oct 2025 10:52:47 -0500 Subject: [PATCH 1/7] 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); + }, + }); })(); From 6a9135c03a1fc7215fa51072688c62c4a09ffbca Mon Sep 17 00:00:00 2001 From: shni Date: Tue, 7 Oct 2025 10:52:47 -0500 Subject: [PATCH 2/7] feat(settings): invalidate guild cache after settings updates and improve settings panel UX --- .../messages/settings-server/settings.ts | 991 +++++++++++------- 1 file changed, 637 insertions(+), 354 deletions(-) diff --git a/src/commands/messages/settings-server/settings.ts b/src/commands/messages/settings-server/settings.ts index 0a7d223..c9647aa 100644 --- a/src/commands/messages/settings-server/settings.ts +++ b/src/commands/messages/settings-server/settings.ts @@ -3,381 +3,664 @@ import { CommandMessage } from "../../../core/types/commands"; import { ComponentType, TextInputStyle } from "discord-api-types/v10"; import { hasManageGuildOrStaff } from "../../../core/lib/permissions"; import { aiService } from "../../../core/services/AIService"; +import { invalidateGuildCache } from "../../../core/database/guildCache"; function toStringArray(input: unknown): string[] { - if (!Array.isArray(input)) return []; - return (input as unknown[]).filter((v): v is string => typeof v === 'string'); + if (!Array.isArray(input)) return []; + return (input as unknown[]).filter((v): v is string => typeof v === "string"); } export const command: CommandMessage = { - name: 'configuracion', - type: "message", - aliases: ['config', 'ajustes', 'settings'], - cooldown: 5, - description: 'Abre el panel de configuración del servidor (prefix, staff y más).', - category: 'Configuración', - usage: 'configuracion', - run: async (message, args, client) => { - const allowed = await hasManageGuildOrStaff(message.member, message.guild!.id, client.prisma); - if (!allowed) { - await message.reply("❌ No tienes permisos de ManageGuild ni rol de staff."); - return; + name: "configuracion", + type: "message", + aliases: ["config", "ajustes", "settings"], + cooldown: 5, + description: + "Abre el panel de configuración del servidor (prefix, staff y más).", + category: "Configuración", + usage: "configuracion", + run: async (message, args, client) => { + const allowed = await hasManageGuildOrStaff( + message.member, + message.guild!.id, + client.prisma + ); + if (!allowed) { + await message.reply( + "❌ No tienes permisos de ManageGuild ni rol de staff." + ); + return; + } + + const server = await client.prisma.guild.findFirst({ + where: { id: message.guild!.id }, + }); + + const currentPrefix = server?.prefix || "!"; + const staffRoles: string[] = toStringArray(server?.staff); + const staffDisplay = staffRoles.length + ? staffRoles.map((id) => `<@&${id}>`).join(", ") + : "Sin staff configurado"; + const aiRolePrompt = server?.aiRolePrompt ?? null; + const aiPreview = aiRolePrompt + ? aiRolePrompt.length > 80 + ? aiRolePrompt.slice(0, 77) + "…" + : aiRolePrompt + : "No configurado"; + + // Panel de configuración usando DisplayComponents + const settingsPanel = { + type: 17, + accent_color: 6178018, // Color del ejemplo + components: [ + { + type: 10, + content: "### <:invisible:1418684224441028608> 梅,panel admin,📢\n", + }, + { type: 14, spacing: 1, divider: false }, + { type: 10, content: "Configuracion del Servidor:" }, + { + type: 9, + components: [ + { + type: 10, + content: `**Prefix:**<:invisible:1418684224441028608>\`${currentPrefix}\``, + }, + ], + accessory: { + type: 2, + style: 2, + emoji: { name: "⚙️" }, + custom_id: "open_prefix_modal", + label: "Cambiar", + }, + }, + { type: 14, divider: false }, + { + type: 9, + components: [ + { type: 10, content: `**Staff (roles):** ${staffDisplay}` }, + ], + accessory: { + type: 2, + style: 2, // Secondary + emoji: { name: "🛡️" }, + custom_id: "open_staff_modal", + label: "Configurar", + }, + }, + { type: 14, divider: false }, + { + type: 9, + components: [ + { type: 10, content: `**AI Role Prompt:** ${aiPreview}` }, + ], + accessory: { + type: 2, + style: 2, + emoji: { name: "🧠" }, + custom_id: "open_ai_role_modal", + label: "Configurar", + }, + }, + { type: 14, divider: false }, + ], + }; + + const panelMessage = await message.reply({ + flags: 32768, // Components v2 + components: [settingsPanel], + }); + + const collector = panelMessage.createMessageComponentCollector({ + time: 300000, // 5 minutos + filter: (i: any) => i.user.id === message.author.id, + }); + + collector.on("collect", async (interaction: any) => { + if (interaction.customId === "open_prefix_modal") { + // Crear y mostrar modal para cambiar prefix (formato consistente con otros modales) + const prefixModal = { + title: "⚙️ Configurar Prefix del Servidor", + customId: "prefix_settings_modal", + components: [ + { + type: ComponentType.Label, + label: "Nuevo Prefix", + component: { + type: ComponentType.TextInput, + customId: "new_prefix_input", + style: TextInputStyle.Short, + placeholder: `Prefix actual: ${currentPrefix}`, + required: true, + maxLength: 10, + minLength: 1, + value: currentPrefix, + }, + }, + { + type: ComponentType.Label, + label: "Motivo (opcional)", + component: { + type: ComponentType.TextInput, + customId: "prefix_description", + style: TextInputStyle.Paragraph, + placeholder: "Ej: evitar conflictos con otros bots...", + required: false, + maxLength: 200, + }, + }, + ], + } as const; + + try { + await interaction.showModal(prefixModal); + } catch (err) { + try { + await interaction.reply({ + content: "❌ No se pudo abrir el modal de prefix.", + flags: 64, + }); + } catch {} + return; } - const server = await client.prisma.guild.findFirst({ - where: { id: message.guild!.id } - }); + try { + const modalInteraction = await interaction.awaitModalSubmit({ + time: 300000, + filter: (modalInt: any) => + modalInt.customId === "prefix_settings_modal" && + modalInt.user.id === message.author.id, + }); - const currentPrefix = server?.prefix || "!"; - const staffRoles: string[] = toStringArray(server?.staff); - const staffDisplay = staffRoles.length - ? staffRoles.map((id) => `<@&${id}>`).join(', ') - : 'Sin staff configurado'; - const aiRolePrompt = server?.aiRolePrompt ?? null; - const aiPreview = aiRolePrompt ? (aiRolePrompt.length > 80 ? aiRolePrompt.slice(0, 77) + '…' : aiRolePrompt) : 'No configurado'; + const newPrefix = + modalInteraction.components.getTextInputValue("new_prefix_input"); + const description = + modalInteraction.components.getTextInputValue( + "prefix_description" + ) || "Sin descripción"; - // Panel de configuración usando DisplayComponents - const settingsPanel = { - type: 17, - accent_color: 6178018, // Color del ejemplo - components: [ - { type: 10, content: "### <:invisible:1418684224441028608> 梅,panel admin,📢\n" }, + if (!newPrefix || newPrefix.length > 10) { + await modalInteraction.reply({ + content: + "❌ **Error:** El prefix debe tener entre 1 y 10 caracteres.", + flags: 64, + }); + return; + } + + try { + await client.prisma.guild.upsert({ + where: { id: message.guild!.id }, + create: { + id: message.guild!.id, + name: message.guild!.name, + prefix: newPrefix, + }, + update: { prefix: newPrefix, name: message.guild!.name }, + }); + + // Invalidar el caché del guild para reflejar el cambio + await invalidateGuildCache(message.guild!.id); + + const successPanel = { + type: 17, + accent_color: 3066993, + components: [ + { + type: 10, + content: "### ✅ **Prefix Actualizado Exitosamente**", + }, + { type: 14, spacing: 2, divider: true }, + { + type: 9, + components: [ + { + type: 10, + content: `**Prefix anterior:** \`${currentPrefix}\`\n**Prefix nuevo:** \`${newPrefix}\`\n\n**Motivo:** ${description}`, + }, + ], + accessory: { + type: 2, + style: 3, + label: "✓ Listo", + custom_id: "prefix_confirmed", + emoji: { name: "✅" }, + }, + }, { type: 14, spacing: 1, divider: false }, - { type: 10, content: "Configuracion del Servidor:" }, { - type: 9, - components: [ { type: 10, content: `**Prefix:**<:invisible:1418684224441028608>\`${currentPrefix}\`` } ], - accessory: { - type: 2, - style: 2, - emoji: { name: "⚙️" }, - custom_id: "open_prefix_modal", - label: "Cambiar" - } + type: 10, + content: + "🚀 **¡Listo!** Ahora puedes usar los comandos con el nuevo prefix.\n\n💡 **Ejemplo:** `" + + newPrefix + + "help`, `" + + newPrefix + + "embedlist`", }, - { type: 14, divider: false }, + ], + }; + + const backToSettingsRow = { + type: 1, + components: [ { - type: 9, - components: [ { type: 10, content: `**Staff (roles):** ${staffDisplay}` } ], - accessory: { - type: 2, - style: 2, // Secondary - emoji: { name: "🛡️" }, - custom_id: "open_staff_modal", - label: "Configurar" - } + type: 2, + style: 2, + label: "↩️ Volver a Configuración", + custom_id: "back_to_settings", }, - { type: 14, divider: false }, + ], + }; + + await modalInteraction.update({ + components: [successPanel, backToSettingsRow], + }); + } catch (error) { + const errorPanel = { + type: 17, + accent_color: 15548997, + components: [ + { type: 10, content: "### ❌ **Error al Actualizar Prefix**" }, + { type: 14, spacing: 2, divider: true }, { - type: 9, - components: [ { type: 10, content: `**AI Role Prompt:** ${aiPreview}` } ], - accessory: { - type: 2, - style: 2, - emoji: { name: "🧠" }, - custom_id: "open_ai_role_modal", - label: "Configurar" - } + type: 10, + content: `**Error:** No se pudo actualizar el prefix a \`${newPrefix}\`\n\n**Posibles causas:**\n• Error de conexión con la base de datos\n• Prefix contiene caracteres no válidos\n• Permisos insuficientes\n\n🔄 **Solución:** Intenta nuevamente con un prefix diferente.`, }, - { type: 14, divider: false } - ] + ], + }; + + const retryRow = { + type: 1, + components: [ + { + type: 2, + style: 2, + label: "🔄 Reintentar", + custom_id: "open_prefix_modal", + }, + { + type: 2, + style: 4, + label: "❌ Cancelar", + custom_id: "cancel_prefix_change", + }, + ], + }; + + await modalInteraction.update({ + components: [errorPanel, retryRow], + }); + } + } catch (error: any) { + logger.info( + "Modal timeout o error:", + error?.message || String(error) + ); + } + } + + if (interaction.customId === "open_staff_modal") { + // Modal para seleccionar hasta 3 roles de staff + const staffModal = { + title: "🛡️ Configurar Roles de Staff", + customId: "staff_roles_modal", + components: [ + { + type: ComponentType.Label, + label: "Selecciona hasta 3 roles de staff", + component: { + type: ComponentType.RoleSelect, + customId: "staff_roles", + required: false, + minValues: 0, + maxValues: 3, + placeholder: "Roles de staff...", + }, + }, + ], + } as const; + + await interaction.showModal(staffModal); + + try { + const modalInteraction = await interaction.awaitModalSubmit({ + time: 300000, + }); + const selected = + modalInteraction.components.getSelectedRoles("staff_roles"); + //@ts-ignore + const roleIds: string[] = selected + ? Array.from(selected.keys()).slice(0, 3) + : []; + + await client.prisma.guild.upsert({ + where: { id: message.guild!.id }, + create: { + id: message.guild!.id, + name: message.guild!.name, + staff: roleIds, + }, + update: { staff: roleIds, name: message.guild!.name }, + }); + + // Invalidar el caché del guild para reflejar el cambio + await invalidateGuildCache(message.guild!.id); + + const updatedDisplay = roleIds.length + ? roleIds.map((id) => `<@&${id}>`).join(", ") + : "Sin staff configurado"; + + const successPanel = { + type: 17, + accent_color: 3066993, + components: [ + { type: 10, content: "### ✅ **Staff Actualizado**" }, + { type: 14, spacing: 2, divider: true }, + { + type: 10, + content: `**Nuevos roles de staff:** ${updatedDisplay}`, + }, + ], + }; + + const backRow = { + type: 1, + components: [ + { + type: 2, + style: 2, + label: "↩️ Volver a Configuración", + custom_id: "back_to_settings", + }, + ], + }; + await modalInteraction.update({ + components: [successPanel, backRow], + }); + } catch (error) { + // timeout o error + } + } + + if (interaction.customId === "open_ai_role_modal") { + const currentServer = await client.prisma.guild.findFirst({ + where: { id: message.guild!.id }, + }); + const currentAiPrompt = currentServer?.aiRolePrompt ?? ""; + const aiModal = { + title: "🧠 Configurar AI Role Prompt", + customId: "ai_role_prompt_modal", + components: [ + { + type: ComponentType.Label, + label: "Prompt de rol (opcional)", + component: { + type: ComponentType.TextInput, + customId: "ai_role_prompt_input", + style: TextInputStyle.Paragraph, + required: false, + placeholder: + "Ej: Eres un asistente amistoso del servidor, responde en español, evita spoilers...", + maxLength: 1500, + value: currentAiPrompt.slice(0, 1500), + }, + }, + ], + } as const; + + try { + await interaction.showModal(aiModal); + } catch (err) { + try { + await interaction.reply({ + content: "❌ No se pudo abrir el modal de AI.", + flags: 64, + }); + } catch {} + return; + } + + try { + const modalInteraction = await interaction.awaitModalSubmit({ + time: 300000, + filter: (m: any) => + m.customId === "ai_role_prompt_modal" && + m.user.id === message.author.id, + }); + + const newPromptRaw = + modalInteraction.components.getTextInputValue( + "ai_role_prompt_input" + ) ?? ""; + const newPrompt = newPromptRaw.trim(); + const toSave: string | null = newPrompt.length > 0 ? newPrompt : null; + + await client.prisma.guild.upsert({ + where: { id: message.guild!.id }, + create: { + id: message.guild!.id, + name: message.guild!.name, + aiRolePrompt: toSave, + }, + update: { aiRolePrompt: toSave, name: message.guild!.name }, + }); + + // Invalida el cache del servicio para reflejar cambios al instante + aiService.invalidateGuildConfig(message.guild!.id); + + // Invalidar el caché del guild también + await invalidateGuildCache(message.guild!.id); + + const preview = toSave + ? toSave.length > 200 + ? toSave.slice(0, 197) + "…" + : toSave + : "Prompt eliminado (sin configuración)"; + + const successPanel = { + type: 17, + accent_color: 3066993, + components: [ + { type: 10, content: "### ✅ **AI Role Prompt Actualizado**" }, + { type: 14, spacing: 2, divider: true }, + { type: 10, content: `**Nuevo valor:**\n${preview}` }, + ], + }; + const backRow = { + type: 1, + components: [ + { + type: 2, + style: 2, + label: "↩️ Volver a Configuración", + custom_id: "back_to_settings", + }, + ], + }; + + await modalInteraction.update({ + components: [successPanel, backRow], + }); + } catch (e) { + // timeout o cancelado + } + } + + // Manejar botones adicionales + if (interaction.customId === "back_to_settings") { + const updatedServer = await client.prisma.guild.findFirst({ + where: { id: message.guild!.id }, + }); + const newCurrentPrefix = updatedServer?.prefix || "!"; + const staffRoles2: string[] = toStringArray(updatedServer?.staff); + const staffDisplay2 = staffRoles2.length + ? staffRoles2.map((id) => `<@&${id}>`).join(", ") + : "Sin staff configurado"; + const aiRolePrompt2 = updatedServer?.aiRolePrompt ?? null; + const aiPreview2 = aiRolePrompt2 + ? aiRolePrompt2.length > 80 + ? aiRolePrompt2.slice(0, 77) + "…" + : aiRolePrompt2 + : "No configurado"; + + const updatedSettingsPanel = { + type: 17, + accent_color: 6178018, + components: [ + { + type: 10, + content: + "### <:invisible:1418684224441028608> 梅,panel admin,📢\n", + }, + { type: 14, spacing: 1, divider: false }, + { type: 10, content: "Configuracion del Servidor:" }, + { + type: 9, + components: [ + { type: 10, content: `**Prefix:** \`${newCurrentPrefix}\`` }, + ], + accessory: { + type: 2, + style: 2, + emoji: { name: "⚙️" }, + custom_id: "open_prefix_modal", + label: "Cambiar", + }, + }, + { type: 14, divider: false }, + { + type: 9, + components: [ + { type: 10, content: `**Staff (roles):** ${staffDisplay2}` }, + ], + accessory: { + type: 2, + style: 2, + emoji: { name: "🛡️" }, + custom_id: "open_staff_modal", + label: "Configurar", + }, + }, + { type: 14, divider: false }, + { + type: 9, + components: [ + { type: 10, content: `**AI Role Prompt:** ${aiPreview2}` }, + ], + accessory: { + type: 2, + style: 2, + emoji: { name: "🧠" }, + custom_id: "open_ai_role_modal", + label: "Configurar", + }, + }, + { type: 14, divider: false }, + ], }; - const panelMessage = await message.reply({ - flags: 32768, // Components v2 - components: [settingsPanel] + await interaction.update({ components: [updatedSettingsPanel] }); + } + + if (interaction.customId === "cancel_prefix_change") { + // Volver al panel original + const updatedServer = await client.prisma.guild.findFirst({ + where: { id: message.guild!.id }, }); + const staffRoles3: string[] = toStringArray(updatedServer?.staff); + const staffDisplay3 = staffRoles3.length + ? staffRoles3.map((id) => `<@&${id}>`).join(", ") + : "Sin staff configurado"; + const aiRolePrompt3 = updatedServer?.aiRolePrompt ?? null; + const aiPreview3 = aiRolePrompt3 + ? aiRolePrompt3.length > 80 + ? aiRolePrompt3.slice(0, 77) + "…" + : aiRolePrompt3 + : "No configurado"; - const collector = panelMessage.createMessageComponentCollector({ - time: 300000, // 5 minutos - filter: (i: any) => i.user.id === message.author.id - }); + const originalPanel = { + type: 17, + accent_color: 6178018, + components: [ + { + type: 10, + content: + "### <:invisible:1418684224441028608> 梅,panel admin,📢\n", + }, + { type: 14, spacing: 1, divider: false }, + { type: 10, content: "Configuracion del Servidor:" }, + { + type: 9, + components: [ + { type: 10, content: `**Prefix:** \`${currentPrefix}\`` }, + ], + accessory: { + type: 2, + style: 2, + emoji: { name: "⚙️" }, + custom_id: "open_prefix_modal", + label: "Cambiar", + }, + }, + { type: 14, divider: false }, + { + type: 9, + components: [ + { type: 10, content: `**Staff (roles):** ${staffDisplay3}` }, + ], + accessory: { + type: 2, + style: 2, + emoji: { name: "🛡️" }, + custom_id: "open_staff_modal", + label: "Configurar", + }, + }, + { type: 14, divider: false }, + { + type: 9, + components: [ + { type: 10, content: `**AI Role Prompt:** ${aiPreview3}` }, + ], + accessory: { + type: 2, + style: 2, + emoji: { name: "🧠" }, + custom_id: "open_ai_role_modal", + label: "Configurar", + }, + }, + { type: 14, divider: false }, + ], + }; - collector.on("collect", async (interaction: any) => { - if (interaction.customId === "open_prefix_modal") { - // Crear y mostrar modal para cambiar prefix (formato consistente con otros modales) - const prefixModal = { - title: "⚙️ Configurar Prefix del Servidor", - customId: "prefix_settings_modal", - components: [ - { - type: ComponentType.Label, - label: "Nuevo Prefix", - component: { - type: ComponentType.TextInput, - customId: "new_prefix_input", - style: TextInputStyle.Short, - placeholder: `Prefix actual: ${currentPrefix}`, - required: true, - maxLength: 10, - minLength: 1, - value: currentPrefix - } - }, - { - type: ComponentType.Label, - label: "Motivo (opcional)", - component: { - type: ComponentType.TextInput, - customId: "prefix_description", - style: TextInputStyle.Paragraph, - placeholder: "Ej: evitar conflictos con otros bots...", - required: false, - maxLength: 200 - } - } - ] - } as const; + await interaction.update({ components: [originalPanel] }); + } + }); - try { - await interaction.showModal(prefixModal); - } catch (err) { - try { await interaction.reply({ content: '❌ No se pudo abrir el modal de prefix.', flags: 64 }); } catch {} - return; - } + collector.on("end", async (_: any, reason: string) => { + if (reason === "time") { + const timeoutPanel = { + type: 17, + accent_color: 6178018, + components: [ + { type: 10, content: "### ⏰ **Panel Expirado**" }, + { type: 14, spacing: 1, divider: true }, + { + type: 10, + content: + "El panel de configuración ha expirado por inactividad.\n\nUsa `!settings` para abrir un nuevo panel.", + }, + ], + }; - try { - const modalInteraction = await interaction.awaitModalSubmit({ - time: 300000, - filter: (modalInt: any) => modalInt.customId === "prefix_settings_modal" && modalInt.user.id === message.author.id - }); - - const newPrefix = modalInteraction.components.getTextInputValue("new_prefix_input"); - const description = modalInteraction.components.getTextInputValue("prefix_description") || "Sin descripción"; - - if (!newPrefix || newPrefix.length > 10) { - await modalInteraction.reply({ content: "❌ **Error:** El prefix debe tener entre 1 y 10 caracteres.", flags: 64 }); - return; - } - - try { - await client.prisma.guild.upsert({ - where: { id: message.guild!.id }, - create: { id: message.guild!.id, name: message.guild!.name, prefix: newPrefix }, - update: { prefix: newPrefix, name: message.guild!.name } - }); - - const successPanel = { - type: 17, - accent_color: 3066993, - components: [ - { type: 10, content: "### ✅ **Prefix Actualizado Exitosamente**" }, - { type: 14, spacing: 2, divider: true }, - { type: 9, components: [ { type: 10, content: `**Prefix anterior:** \`${currentPrefix}\`\n**Prefix nuevo:** \`${newPrefix}\`\n\n**Motivo:** ${description}` } ], accessory: { type: 2, style: 3, label: "✓ Listo", custom_id: "prefix_confirmed", emoji: { name: "✅" } } }, - { type: 14, spacing: 1, divider: false }, - { type: 10, content: "🚀 **¡Listo!** Ahora puedes usar los comandos con el nuevo prefix.\n\n💡 **Ejemplo:** `" + newPrefix + "help`, `" + newPrefix + "embedlist`" } - ] - }; - - const backToSettingsRow = { type: 1, components: [ { type: 2, style: 2, label: "↩️ Volver a Configuración", custom_id: "back_to_settings" } ] }; - - await modalInteraction.update({ components: [successPanel, backToSettingsRow] }); - - } catch (error) { - const errorPanel = { - type: 17, - accent_color: 15548997, - components: [ - { type: 10, content: "### ❌ **Error al Actualizar Prefix**" }, - { type: 14, spacing: 2, divider: true }, - { type: 10, content: `**Error:** No se pudo actualizar el prefix a \`${newPrefix}\`\n\n**Posibles causas:**\n• Error de conexión con la base de datos\n• Prefix contiene caracteres no válidos\n• Permisos insuficientes\n\n🔄 **Solución:** Intenta nuevamente con un prefix diferente.` } - ] - }; - - const retryRow = { type: 1, components: [ { type: 2, style: 2, label: "🔄 Reintentar", custom_id: "open_prefix_modal" }, { type: 2, style: 4, label: "❌ Cancelar", custom_id: "cancel_prefix_change" } ] }; - - await modalInteraction.update({ components: [errorPanel, retryRow] }); - } - } catch (error: any) { - logger.info("Modal timeout o error:", error?.message || String(error)); - } - } - - if (interaction.customId === "open_staff_modal") { - // Modal para seleccionar hasta 3 roles de staff - const staffModal = { - title: "🛡️ Configurar Roles de Staff", - customId: "staff_roles_modal", - components: [ - { type: ComponentType.Label, label: "Selecciona hasta 3 roles de staff", component: { type: ComponentType.RoleSelect, customId: "staff_roles", required: false, minValues: 0, maxValues: 3, placeholder: "Roles de staff..." } } - ] - } as const; - - await interaction.showModal(staffModal); - - try { - const modalInteraction = await interaction.awaitModalSubmit({ time: 300000 }); - const selected = modalInteraction.components.getSelectedRoles('staff_roles'); - //@ts-ignore - const roleIds: string[] = selected ? Array.from(selected.keys()).slice(0, 3) : []; - - await client.prisma.guild.upsert({ - where: { id: message.guild!.id }, - create: { id: message.guild!.id, name: message.guild!.name, staff: roleIds }, - update: { staff: roleIds, name: message.guild!.name } - }); - - const updatedDisplay = roleIds.length ? roleIds.map((id) => `<@&${id}>`).join(', ') : 'Sin staff configurado'; - - const successPanel = { - type: 17, - accent_color: 3066993, - components: [ - { type: 10, content: "### ✅ **Staff Actualizado**" }, - { type: 14, spacing: 2, divider: true }, - { type: 10, content: `**Nuevos roles de staff:** ${updatedDisplay}` } - ] - }; - - const backRow = { type: 1, components: [ { type: 2, style: 2, label: '↩️ Volver a Configuración', custom_id: 'back_to_settings' } ] }; - await modalInteraction.update({ components: [successPanel, backRow] }); - } catch (error) { - // timeout o error - } - } - - if (interaction.customId === "open_ai_role_modal") { - const currentServer = await client.prisma.guild.findFirst({ where: { id: message.guild!.id } }); - const currentAiPrompt = currentServer?.aiRolePrompt ?? ''; - const aiModal = { - title: "🧠 Configurar AI Role Prompt", - customId: "ai_role_prompt_modal", - components: [ - { - type: ComponentType.Label, - label: "Prompt de rol (opcional)", - component: { - type: ComponentType.TextInput, - customId: "ai_role_prompt_input", - style: TextInputStyle.Paragraph, - required: false, - placeholder: "Ej: Eres un asistente amistoso del servidor, responde en español, evita spoilers...", - maxLength: 1500, - value: currentAiPrompt.slice(0, 1500) - } - } - ] - } as const; - - try { - await interaction.showModal(aiModal); - } catch (err) { - try { await interaction.reply({ content: '❌ No se pudo abrir el modal de AI.', flags: 64 }); } catch {} - return; - } - - try { - const modalInteraction = await interaction.awaitModalSubmit({ - time: 300000, - filter: (m: any) => m.customId === 'ai_role_prompt_modal' && m.user.id === message.author.id - }); - - const newPromptRaw = modalInteraction.components.getTextInputValue('ai_role_prompt_input') ?? ''; - const newPrompt = newPromptRaw.trim(); - const toSave: string | null = newPrompt.length > 0 ? newPrompt : null; - - await client.prisma.guild.upsert({ - where: { id: message.guild!.id }, - create: { id: message.guild!.id, name: message.guild!.name, aiRolePrompt: toSave }, - update: { aiRolePrompt: toSave, name: message.guild!.name } - }); - - // Invalida el cache del servicio para reflejar cambios al instante - aiService.invalidateGuildConfig(message.guild!.id); - - const preview = toSave ? (toSave.length > 200 ? toSave.slice(0, 197) + '…' : toSave) : 'Prompt eliminado (sin configuración)'; - - const successPanel = { - type: 17, - accent_color: 3066993, - components: [ - { type: 10, content: "### ✅ **AI Role Prompt Actualizado**" }, - { type: 14, spacing: 2, divider: true }, - { type: 10, content: `**Nuevo valor:**\n${preview}` } - ] - }; - const backRow = { type: 1, components: [ { type: 2, style: 2, label: '↩️ Volver a Configuración', custom_id: 'back_to_settings' } ] }; - - await modalInteraction.update({ components: [successPanel, backRow] }); - } catch (e) { - // timeout o cancelado - } - } - - // Manejar botones adicionales - if (interaction.customId === "back_to_settings") { - const updatedServer = await client.prisma.guild.findFirst({ where: { id: message.guild!.id } }); - const newCurrentPrefix = updatedServer?.prefix || "!"; - const staffRoles2: string[] = toStringArray(updatedServer?.staff); - const staffDisplay2 = staffRoles2.length ? staffRoles2.map((id) => `<@&${id}>`).join(', ') : 'Sin staff configurado'; - const aiRolePrompt2 = updatedServer?.aiRolePrompt ?? null; - const aiPreview2 = aiRolePrompt2 ? (aiRolePrompt2.length > 80 ? aiRolePrompt2.slice(0, 77) + '…' : aiRolePrompt2) : 'No configurado'; - - const updatedSettingsPanel = { - type: 17, - accent_color: 6178018, - components: [ - { type: 10, content: "### <:invisible:1418684224441028608> 梅,panel admin,📢\n" }, - { type: 14, spacing: 1, divider: false }, - { type: 10, content: "Configuracion del Servidor:" }, - { type: 9, components: [ { type: 10, content: `**Prefix:** \`${newCurrentPrefix}\`` } ], accessory: { type: 2, style: 2, emoji: { name: "⚙️" }, custom_id: "open_prefix_modal", label: "Cambiar" } }, - { type: 14, divider: false }, - { type: 9, components: [ { type: 10, content: `**Staff (roles):** ${staffDisplay2}` } ], accessory: { type: 2, style: 2, emoji: { name: "🛡️" }, custom_id: "open_staff_modal", label: "Configurar" } }, - { type: 14, divider: false }, - { type: 9, components: [ { type: 10, content: `**AI Role Prompt:** ${aiPreview2}` } ], accessory: { type: 2, style: 2, emoji: { name: "🧠" }, custom_id: "open_ai_role_modal", label: "Configurar" } }, - { type: 14, divider: false } - ] - }; - - await interaction.update({ components: [updatedSettingsPanel] }); - } - - if (interaction.customId === "cancel_prefix_change") { - // Volver al panel original - const updatedServer = await client.prisma.guild.findFirst({ where: { id: message.guild!.id } }); - const staffRoles3: string[] = toStringArray(updatedServer?.staff); - const staffDisplay3 = staffRoles3.length ? staffRoles3.map((id) => `<@&${id}>`).join(', ') : 'Sin staff configurado'; - const aiRolePrompt3 = updatedServer?.aiRolePrompt ?? null; - const aiPreview3 = aiRolePrompt3 ? (aiRolePrompt3.length > 80 ? aiRolePrompt3.slice(0, 77) + '…' : aiRolePrompt3) : 'No configurado'; - - const originalPanel = { - type: 17, - accent_color: 6178018, - components: [ - { type: 10, content: "### <:invisible:1418684224441028608> 梅,panel admin,📢\n" }, - { type: 14, spacing: 1, divider: false }, - { type: 10, content: "Configuracion del Servidor:" }, - { type: 9, components: [ { type: 10, content: `**Prefix:** \`${currentPrefix}\`` } ], accessory: { type: 2, style: 2, emoji: { name: "⚙️" }, custom_id: "open_prefix_modal", label: "Cambiar" } }, - { type: 14, divider: false }, - { type: 9, components: [ { type: 10, content: `**Staff (roles):** ${staffDisplay3}` } ], accessory: { type: 2, style: 2, emoji: { name: "🛡️" }, custom_id: "open_staff_modal", label: "Configurar" } }, - { type: 14, divider: false }, - { type: 9, components: [ { type: 10, content: `**AI Role Prompt:** ${aiPreview3}` } ], accessory: { type: 2, style: 2, emoji: { name: "🧠" }, custom_id: "open_ai_role_modal", label: "Configurar" } }, - { type: 14, divider: false } - ] - }; - - await interaction.update({ components: [originalPanel] }); - } - }); - - collector.on("end", async (_: any, reason: string) => { - if (reason === "time") { - const timeoutPanel = { - type: 17, - accent_color: 6178018, - components: [ - { type: 10, content: "### ⏰ **Panel Expirado**" }, - { type: 14, spacing: 1, divider: true }, - { type: 10, content: "El panel de configuración ha expirado por inactividad.\n\nUsa `!settings` para abrir un nuevo panel." } - ] - }; - - try { - await panelMessage.edit({ components: [timeoutPanel] }); - } catch (error) { - // Mensaje eliminado o error de edición - } - } - }); - } + try { + await panelMessage.edit({ components: [timeoutPanel] }); + } catch (error) { + // Mensaje eliminado o error de edición + } + } + }); + }, }; From b4737a536f3f080f0ea7ea679fb35865293e0011 Mon Sep 17 00:00:00 2001 From: shni Date: Tue, 7 Oct 2025 10:55:45 -0500 Subject: [PATCH 3/7] =?UTF-8?q?feat(cache):=20migrar=20cach=C3=A9=20de=20g?= =?UTF-8?q?uilds=20de=20Redis=20a=20Appwrite=20para=20mejorar=20rendimient?= =?UTF-8?q?o=20y=20persistencia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README/FIX_OUT_OF_MEMORY.md | 124 +++++++++++ README/GUIA_MANUAL_APPWRITE.md | 141 +++++++++++++ README/MIGRACION_CACHE_APPWRITE.md | 215 +++++++++++++++++++ README/SETUP_APPWRITE_CACHE.md | 68 ++++++ scripts/setupGuildCacheCollection.js | 159 ++++++++++++++ src/core/database/guildCache.ts | 304 +++++++++++++++++++++++++++ 6 files changed, 1011 insertions(+) create mode 100644 README/FIX_OUT_OF_MEMORY.md create mode 100644 README/GUIA_MANUAL_APPWRITE.md create mode 100644 README/MIGRACION_CACHE_APPWRITE.md create mode 100644 README/SETUP_APPWRITE_CACHE.md create mode 100644 scripts/setupGuildCacheCollection.js create mode 100644 src/core/database/guildCache.ts diff --git a/README/FIX_OUT_OF_MEMORY.md b/README/FIX_OUT_OF_MEMORY.md new file mode 100644 index 0000000..3d319f4 --- /dev/null +++ b/README/FIX_OUT_OF_MEMORY.md @@ -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 + +// Invalida el caché cuando se actualiza la config +invalidateGuildCache(guildId): Promise + +// Actualiza directamente el caché +updateGuildCache(config): Promise +``` + +### 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 ✅ diff --git a/README/GUIA_MANUAL_APPWRITE.md b/README/GUIA_MANUAL_APPWRITE.md new file mode 100644 index 0000000..0f50861 --- /dev/null +++ b/README/GUIA_MANUAL_APPWRITE.md @@ -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 diff --git a/README/MIGRACION_CACHE_APPWRITE.md b/README/MIGRACION_CACHE_APPWRITE.md new file mode 100644 index 0000000..af64eef --- /dev/null +++ b/README/MIGRACION_CACHE_APPWRITE.md @@ -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 diff --git a/README/SETUP_APPWRITE_CACHE.md b/README/SETUP_APPWRITE_CACHE.md new file mode 100644 index 0000000..3529c30 --- /dev/null +++ b/README/SETUP_APPWRITE_CACHE.md @@ -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) diff --git a/scripts/setupGuildCacheCollection.js b/scripts/setupGuildCacheCollection.js new file mode 100644 index 0000000..b5d1de5 --- /dev/null +++ b/scripts/setupGuildCacheCollection.js @@ -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(); diff --git a/src/core/database/guildCache.ts b/src/core/database/guildCache.ts new file mode 100644 index 0000000..8b497b5 --- /dev/null +++ b/src/core/database/guildCache.ts @@ -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 { + 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 { + 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 { + 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 { + 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"); + } +} From e18caf414d82b24aae3b97e450c490bb6abbb7aa Mon Sep 17 00:00:00 2001 From: shni Date: Tue, 7 Oct 2025 11:20:22 -0500 Subject: [PATCH 4/7] fix: corregir la referencia del mensaje en sendDisplayReply para cumplir con la nueva API --- src/commands/messages/game/_helpers.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/commands/messages/game/_helpers.ts b/src/commands/messages/game/_helpers.ts index d0e2124..0b622d9 100644 --- a/src/commands/messages/game/_helpers.ts +++ b/src/commands/messages/game/_helpers.ts @@ -516,9 +516,8 @@ export async function promptKeySelection( 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], }); } From 7f2d1903bb3358aafd7fd691cea0d63bcdaf4661 Mon Sep 17 00:00:00 2001 From: shni Date: Tue, 7 Oct 2025 11:20:58 -0500 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20mejorar=20la=20legibilidad=20y?= =?UTF-8?q?=20consistencia=20del=20c=C3=B3digo=20en=20=5Fhelpers.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commands/messages/game/_helpers.ts | 477 +++++++++++++++---------- 1 file changed, 288 insertions(+), 189 deletions(-) diff --git a/src/commands/messages/game/_helpers.ts b/src/commands/messages/game/_helpers.ts index 0b622d9..ac1c978 100644 --- a/src/commands/messages/game/_helpers.ts +++ b/src/commands/messages/game/_helpers.ts @@ -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 { - const guildArea = await prisma.gameArea.findFirst({ where: { key: areaKey, guildId } }); +export async function resolveGuildAreaWithFallback( + guildId: string, + areaKey: string +): Promise { + 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 { - const guildArea = await prisma.gameArea.findFirst({ where: { type, guildId }, orderBy: [{ createdAt: 'asc' }] }); +export async function resolveAreaByType( + guildId: string, + type: string +): Promise { + 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 { - const prog = await prisma.playerProgress.findUnique({ where: { userId_guildId_areaId: { userId, guildId, areaId } } }); +export async function getDefaultLevel( + userId: string, + guildId: string, + areaId: string +): Promise { + 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 { - 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 { + 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> { - 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> { + 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 { export interface KeyPickerResult { entry: T | null; panelMessage: Message | null; - reason: 'selected' | 'empty' | 'cancelled' | 'timeout'; + reason: "selected" | "empty" | "cancelled" | "timeout"; } export async function promptKeySelection( @@ -193,9 +243,14 @@ export async function promptKeySelection( 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( 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( 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( 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( { 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( 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( { 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( let resolved = false; const result = await new Promise>((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( 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,7 +609,11 @@ export async function promptKeySelection( 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)({ flags: 32768, @@ -521,4 +621,3 @@ export function sendDisplayReply(message: Message, display: any, extraComponents components: [display, ...extraComponents], }); } - From 1665c58a33a2053ef846807859ab140f07e02c83 Mon Sep 17 00:00:00 2001 From: shni Date: Tue, 7 Oct 2025 11:34:46 -0500 Subject: [PATCH 6/7] fix(racha): evitar type:9 sin accessory usando componentsV2 builder; corrige Invalid Form Body en display de racha --- src/commands/messages/game/racha.ts | 112 ++++++++++------------------ 1 file changed, 39 insertions(+), 73 deletions(-) diff --git a/src/commands/messages/game/racha.ts b/src/commands/messages/game/racha.ts index 0a7bd61..e26f913 100644 --- a/src/commands/messages/game/racha.ts +++ b/src/commands/messages/game/racha.ts @@ -3,6 +3,7 @@ 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 +23,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 +73,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 +90,18 @@ 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) { From 3f5757d28f369c7ac2edf7ce7f5c43f5c6a6aee4 Mon Sep 17 00:00:00 2001 From: shni Date: Tue, 7 Oct 2025 11:35:08 -0500 Subject: [PATCH 7/7] =?UTF-8?q?fix(racha):=20mejorar=20la=20construcci?= =?UTF-8?q?=C3=B3n=20del=20display=20en=20el=20comando=20de=20racha?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/tasks.json | 17 +++++++++++++++++ src/commands/messages/game/racha.ts | 11 ++++++----- 2 files changed, 23 insertions(+), 5 deletions(-) create mode 100644 .vscode/tasks.json diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..3a6ec43 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,17 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "Typecheck: tsc --noEmit", + "type": "shell", + "command": "npm", + "args": [ + "run", + "-s", + "tsc", + "--", + "--noEmit" + ] + } + ] +} \ No newline at end of file diff --git a/src/commands/messages/game/racha.ts b/src/commands/messages/game/racha.ts index e26f913..4d46244 100644 --- a/src/commands/messages/game/racha.ts +++ b/src/commands/messages/game/racha.ts @@ -3,7 +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"; +import { + buildDisplay, + textBlock, + dividerBlock, +} from "../../../core/lib/componentsV2"; export const command: CommandMessage = { name: "racha", @@ -98,10 +102,7 @@ export const command: CommandMessage = { ); } - const display = buildDisplay( - daysIncreased ? 0x00ff00 : 0xffa500, - blocks - ); + const display = buildDisplay(daysIncreased ? 0x00ff00 : 0xffa500, blocks); await sendDisplayReply(message, display); } catch (error) {