From f912b41b67262a14627fa6738613397de5b3d8e5 Mon Sep 17 00:00:00 2001 From: shni Date: Thu, 2 Oct 2025 21:52:08 -0500 Subject: [PATCH] feat: implement AI service with enhanced error handling, rate limiting, and conversation context management --- src/commands/messages/AI/chat.ts | 365 ++++++++------------- src/commands/messages/AI/stats.ts | 132 ++++++++ src/core/api/remindersSchema.ts | 18 +- src/core/services/AIService.ts | 509 ++++++++++++++++++++++++++++++ 4 files changed, 785 insertions(+), 239 deletions(-) create mode 100644 src/commands/messages/AI/stats.ts create mode 100644 src/core/services/AIService.ts diff --git a/src/commands/messages/AI/chat.ts b/src/commands/messages/AI/chat.ts index 0e59506..60acbb2 100644 --- a/src/commands/messages/AI/chat.ts +++ b/src/commands/messages/AI/chat.ts @@ -1,56 +1,89 @@ import logger from "../../../core/lib/logger"; -import { GoogleGenAI } from "@google/genai"; -import {CommandMessage} from "../../../core/types/commands"; -import { TextChannel, DMChannel, NewsChannel, ThreadChannel } from "discord.js"; +import { CommandMessage } from "../../../core/types/commands"; +import { TextChannel, DMChannel, NewsChannel, ThreadChannel, EmbedBuilder } from "discord.js"; +import { aiService } from "../../../core/services/AIService"; -// Función para estimar tokens aproximadamente (1 token ≈ 4 caracteres para texto en español/inglés) -function estimateTokens(text: string): number { - return Math.ceil(text.length / 4); +/** + * Dividir texto de forma inteligente preservando markdown + */ +function smartChunkText(text: string, maxLength: number): string[] { + if (text.length <= maxLength) return [text]; + + const chunks: string[] = []; + let currentChunk = ''; + const lines = text.split('\n'); + + for (const line of lines) { + // Si agregar esta línea excede el límite + if (currentChunk.length + line.length + 1 > maxLength) { + if (currentChunk) { + chunks.push(currentChunk.trim()); + currentChunk = ''; + } + + // Si la línea misma es muy larga, dividirla por palabras + if (line.length > maxLength) { + const words = line.split(' '); + let wordChunk = ''; + + for (const word of words) { + if (wordChunk.length + word.length + 1 > maxLength) { + if (wordChunk) { + chunks.push(wordChunk.trim()); + wordChunk = ''; + } + } + wordChunk += (wordChunk ? ' ' : '') + word; + } + + if (wordChunk) { + currentChunk = wordChunk; + } + } else { + currentChunk = line; + } + } else { + currentChunk += (currentChunk ? '\n' : '') + line; + } + } + + if (currentChunk) { + chunks.push(currentChunk.trim()); + } + + return chunks; } -// Límites de tokens según Gemini 2.5 Flash -const MAX_INPUT_TOKENS = 1048576; // 1M tokens de entrada -const MAX_OUTPUT_TOKENS = 65536; // 64K tokens de salida -const TOKEN_RESET_THRESHOLD = 0.85; // Resetear cuando esté al 85% del límite - -// Estado de conversación por usuario (memoria simple en memoria) -const conversationHistory = new Map(); - export const command: CommandMessage = { name: 'ai', type: "message", aliases: ['chat', 'gemini'], - cooldown: 5, - description: 'Chatea con la IA (Gemini) directamente desde Discord.', + cooldown: 2, // Reducido porque el servicio maneja su propio rate limiting + description: 'Chatea con la IA (Gemini) de forma estable y escalable.', category: 'IA', usage: 'ai ', run: async (message, args) => { - // Verificar que se proporcione un prompt + // Validaciones básicas if (!args || args.length === 0) { - await message.reply({ - content: "❌ **Error:** Necesitas proporcionar un mensaje para la IA.\n" + - "**Uso:** `ai `\n" + - "**Ejemplo:** `ai ¿Cómo funciona JavaScript?`" - }); + const helpEmbed = new EmbedBuilder() + .setColor(0xFF69B4) + .setTitle('❌ Error: Mensaje requerido') + .setDescription( + '**Uso:** `ai `\n' + + '**Ejemplo:** `ai ¿Cómo funciona JavaScript?`\n' + + '**Límite:** 4000 caracteres máximo' + ) + .setFooter({ text: 'AI Chat mejorado con Gemini 2.5 Flash' }); + + await message.reply({ embeds: [helpEmbed] }); return; } const prompt = args.join(' '); const userId = message.author.id; + const guildId = message.guild?.id; - // Validar longitud del prompt - if (prompt.length > 4000) { - await message.reply({ - content: "❌ **Error:** Tu mensaje es demasiado largo. El límite es de 4000 caracteres." - }); - return; - } - - // Verificar que el canal sea de texto + // Verificar tipo de canal const channel = message.channel as TextChannel | DMChannel | NewsChannel | ThreadChannel; if (!channel || !('send' in channel)) { await message.reply({ @@ -59,228 +92,94 @@ export const command: CommandMessage = { return; } + // Indicador de escritura mejorado + const typingInterval = setInterval(() => { + channel.sendTyping().catch(() => {}); + }, 5000); + try { - // Inicializar Google Gemini con configuración desde variables de entorno - const genAI = new GoogleGenAI({ - apiKey: process.env.GOOGLE_AI_API_KEY - }); + // Usar el servicio mejorado con manejo de prioridad + const priority = message.member?.permissions.has('Administrator') ? 'high' : 'normal'; - // Obtener o inicializar historial de conversación del usuario - let userHistory = conversationHistory.get(userId); - if (!userHistory) { - userHistory = { messages: [], totalTokens: 0, imageCount: 0 }; - conversationHistory.set(userId, userHistory); - } - - // Enviar mensaje de "escribiendo..." - await channel.sendTyping(); - - const USERNAME = message.author.username; - const CURRENT_DATETIME = new Date().toLocaleString('es-ES', { - timeZone: 'America/Monterrey', - dateStyle: 'full', - timeStyle: 'long' - }); - - // Detectar si el usuario quiere generar una imagen - const imageKeywords = ['imagen', 'image', 'dibujo', 'draw', 'generar imagen', 'create image', 'picture', 'foto']; - const isImageRequest = imageKeywords.some(keyword => - prompt.toLowerCase().includes(keyword.toLowerCase()) + const aiResponse = await aiService.processAIRequest( + userId, + prompt, + guildId, + priority ); - // Construir el prompt del sistema más natural y menos saturado de emojis - const baseSystemPrompt = `Eres una hermana mayor kawaii y cariñosa que habla por Discord. Responde de manera natural y útil, pero con personalidad tierna. + // Crear embed de respuesta mejorado + const embed = new EmbedBuilder() + .setColor(0xFF69B4) + .setTitle('🌸 Gemini-chan') + .setDescription(aiResponse) + .setFooter({ + text: `Solicitado por ${message.author.username}`, + iconURL: message.author.displayAvatarURL({ forceStatic: false }) + }) + .setTimestamp(); -## Información del usuario: -- Username: ${USERNAME} -- Fecha actual: ${CURRENT_DATETIME} - -## Reglas importantes para Discord: -- NUNCA uses LaTeX ($$), solo usa **markdown normal de Discord** -- Para matemáticas usa: **negrita**, *cursiva*, \`código\` y bloques de código -- NO uses emojis excesivamente, máximo 2-3 por respuesta -- Para tablas usa formato simple de Discord con backticks -- Mantén las respuestas claras y legibles en Discord - -## Ejemplos de formato correcto: -- Matemáticas: "La raíz cuadrada de 16 es **4**" -- Código: \`\`\`javascript\nfunction ejemplo() {}\`\`\` -- Énfasis: **importante** o *destacado* - -${isImageRequest ? ` -## Generación de imágenes: -- El usuario está pidiendo una imagen -- Gemini 2.5 Flash NO puede generar imágenes -- Explica que no puedes generar imágenes pero ofrece ayuda alternativa -` : ''} - -## Mensaje del usuario: -${prompt} - -## Contexto de conversación anterior: -${userHistory.messages.slice(-3).join('\n')}`; - - // Verificar límites de tokens de entrada - const estimatedInputTokens = estimateTokens(baseSystemPrompt); - - // Verificar si necesitamos resetear la conversación - if (userHistory.totalTokens > MAX_INPUT_TOKENS * TOKEN_RESET_THRESHOLD) { - userHistory.messages = []; - userHistory.totalTokens = 0; - await message.reply({ - content: "🔄 **Conversación reseteada** - Límite de tokens alcanzado, empezamos de nuevo." - }); - } - - // Verificar si necesitamos resetear por imágenes - if (isImageRequest && userHistory.imageCount >= 5) { - userHistory.messages = []; - userHistory.totalTokens = 0; - userHistory.imageCount = 0; - await message.reply({ - content: "🔄 **Conversación reseteada** - Límite de solicitudes de imagen alcanzado (5), empezamos de nuevo." - }); - } - - if (estimatedInputTokens > MAX_INPUT_TOKENS) { - await message.reply({ - content: `❌ **Error:** Tu mensaje es demasiado largo para procesar.\n` + - `**Tokens estimados:** ${estimatedInputTokens.toLocaleString()}\n` + - `**Límite máximo:** ${MAX_INPUT_TOKENS.toLocaleString()} tokens\n\n` + - `Por favor, acorta tu mensaje e intenta de nuevo.` - }); - return; - } - - // Calcular tokens de salida apropiados - const dynamicOutputTokens = Math.min( - Math.max(1024, Math.floor(estimatedInputTokens * 0.3)), // Mínimo 1024, máximo 30% del input - MAX_OUTPUT_TOKENS - ); - - // Generar respuesta - const response = await genAI.models.generateContent({ - model: "gemini-2.5-flash", - contents: baseSystemPrompt, - // @ts-ignore - generationConfig: { - maxOutputTokens: dynamicOutputTokens, - temperature: 0.7, // Reducido para respuestas más consistentes - topP: 0.8, - topK: 30, - } - }); - - // Extraer el texto de la respuesta - const aiResponse = response.text; - - // Verificar si la respuesta está vacía - if (!aiResponse || aiResponse.trim().length === 0) { - await message.reply({ - content: "❌ **Error:** La IA no pudo generar una respuesta. Intenta reformular tu pregunta." - }); - return; - } - - // Actualizar historial y contadores - const estimatedOutputTokens = estimateTokens(aiResponse); - userHistory.messages.push(`Usuario: ${prompt}`); - userHistory.messages.push(`Asistente: ${aiResponse}`); - userHistory.totalTokens += estimatedInputTokens + estimatedOutputTokens; - - if (isImageRequest) { - userHistory.imageCount++; - } - - // Mantener solo los últimos 10 mensajes para evitar crecimiento excesivo - if (userHistory.messages.length > 10) { - userHistory.messages = userHistory.messages.slice(-10); - } - - // Información de debug y estado - const tokensUsedPercent = ((userHistory.totalTokens / MAX_INPUT_TOKENS) * 100).toFixed(1); - const debugInfo = process.env.NODE_ENV === 'development' ? - `\n\n*Debug: Input ~${estimatedInputTokens} tokens, Output ~${estimatedOutputTokens} tokens | Total: ${userHistory.totalTokens} (${tokensUsedPercent}%) | Imágenes: ${userHistory.imageCount}/5*` : ''; - - // Advertencia si estamos cerca del límite - const warningInfo = userHistory.totalTokens > MAX_INPUT_TOKENS * 0.7 ? - `\n\n⚠️ *Nota: Conversación larga detectada (${tokensUsedPercent}% del límite). Se reseteará pronto.*` : ''; - - // Dividir respuesta si es muy larga para Discord (límite de 2000 caracteres) - if (aiResponse.length > 1900) { - const chunks = aiResponse.match(/.{1,1900}/gs) || []; + // Manejar respuestas largas de forma inteligente + if (aiResponse.length > 4000) { + // Dividir en chunks preservando markdown + const chunks = smartChunkText(aiResponse, 4000); for (let i = 0; i < chunks.length && i < 3; i++) { - const chunk = chunks[i]; - const embed = { - color: 0xFF69B4, // Color rosa kawaii para el tema imouto - title: i === 0 ? '🌸 Respuesta de Gemini-chan' : `🌸 Respuesta de Gemini-chan (${i + 1}/${chunks.length})`, - description: chunk + (i === chunks.length - 1 ? debugInfo : ''), - footer: { - text: `Solicitado por ${message.author.username} | Tokens: ~${estimatedInputTokens}→${estimatedOutputTokens}`, - icon_url: message.author.displayAvatarURL({ forceStatic: false }) - }, - timestamp: new Date().toISOString() - }; + const chunkEmbed = new EmbedBuilder() + .setColor(0xFF69B4) + .setTitle(i === 0 ? '🌸 Gemini-chan' : `🌸 Gemini-chan (${i + 1}/${chunks.length})`) + .setDescription(chunks[i]) + .setFooter({ + text: `Solicitado por ${message.author.username} | Parte ${i + 1}`, + iconURL: message.author.displayAvatarURL({ forceStatic: false }) + }) + .setTimestamp(); if (i === 0) { - await message.reply({ embeds: [embed] }); + await message.reply({ embeds: [chunkEmbed] }); } else { - await channel.send({ embeds: [embed] }); - } - - // Pequeña pausa entre mensajes - if (i < chunks.length - 1) { + await channel.send({ embeds: [chunkEmbed] }); + // Pausa entre mensajes para evitar rate limits await new Promise(resolve => setTimeout(resolve, 1000)); } } if (chunks.length > 3) { await channel.send({ - content: "⚠️ **Nota:** La respuesta fue truncada por ser demasiado larga. Intenta hacer preguntas más específicas." + content: "⚠️ **Nota:** La respuesta fue truncada. Intenta preguntas más específicas." }); } } else { - // Respuesta normal - const embed = { - color: 0xFF69B4, // Color rosa kawaii - title: '🌸 Respuesta de Gemini-chan', - description: aiResponse + debugInfo + warningInfo, - footer: { - text: `Solicitado por ${message.author.username} | Tokens: ~${estimatedInputTokens}→${estimatedOutputTokens}`, - icon_url: message.author.displayAvatarURL({ forceStatic: false }) - }, - timestamp: new Date().toISOString() - }; - await message.reply({ embeds: [embed] }); } - } catch (error: any) { - logger.error('Error en comando AI:', error); - - // Manejar errores específicos incluyendo límites de tokens - let errorMessage = "❌ **Error:** Ocurrió un problema al comunicarse con la IA."; - - if (error?.message?.includes('API key') || error?.message?.includes('Invalid API') || error?.message?.includes('authentication')) { - errorMessage = "❌ **Error:** Token de API inválido o no configurado."; - } else if (error?.message?.includes('quota') || error?.message?.includes('exceeded') || error?.message?.includes('rate limit')) { - errorMessage = "❌ **Error:** Se ha alcanzado el límite de uso de la API."; - } else if (error?.message?.includes('safety') || error?.message?.includes('blocked')) { - errorMessage = "❌ **Error:** Tu mensaje fue bloqueado por las políticas de seguridad de la IA."; - } else if (error?.message?.includes('timeout')) { - errorMessage = "❌ **Error:** La solicitud tardó demasiado tiempo. Intenta de nuevo."; - } else if (error?.message?.includes('model not found') || error?.message?.includes('not available')) { - errorMessage = "❌ **Error:** El modelo de IA no está disponible en este momento."; - } else if (error?.message?.includes('token') || error?.message?.includes('length')) { - errorMessage = "❌ **Error:** El mensaje excede los límites de tokens permitidos. Intenta con un mensaje más corto."; - } else if (error?.message?.includes('context_length')) { - errorMessage = "❌ **Error:** El contexto de la conversación es demasiado largo. La conversación se ha reiniciado."; + // Log para monitoreo (solo en desarrollo) + if (process.env.NODE_ENV === 'development') { + const stats = aiService.getStats(); + logger.info(`AI Request completado - Usuario: ${userId}, Queue: ${stats.queueLength}, Conversaciones activas: ${stats.activeConversations}`); } - await message.reply({ - content: errorMessage + "\n\nSi el problema persiste, contacta a un administrador." - }); + } catch (error: any) { + logger.error(`Error en comando AI para usuario ${userId}:`, error); + + // Crear embed de error informativo + const errorEmbed = new EmbedBuilder() + .setColor(0xFF4444) + .setTitle('❌ Error del Servicio de IA') + .setDescription(error.message || 'Error desconocido del servicio') + .addFields({ + name: '💡 Consejos', + value: '• Verifica que tu mensaje no sea demasiado largo\n' + + '• Espera unos segundos entre consultas\n' + + '• Evita contenido inapropiado' + }) + .setFooter({ text: 'Si el problema persiste, contacta a un administrador' }) + .setTimestamp(); + + await message.reply({ embeds: [errorEmbed] }); + } finally { + // Limpiar indicador de escritura + clearInterval(typingInterval); } } } \ No newline at end of file diff --git a/src/commands/messages/AI/stats.ts b/src/commands/messages/AI/stats.ts new file mode 100644 index 0000000..60e72d2 --- /dev/null +++ b/src/commands/messages/AI/stats.ts @@ -0,0 +1,132 @@ +import { CommandMessage } from "../../../core/types/commands"; +import { EmbedBuilder, PermissionFlagsBits } from "discord.js"; +import { aiService } from "../../../core/services/AIService"; +import logger from "../../../core/lib/logger"; + +/** + * Formatear tiempo de actividad + */ +function formatUptime(seconds: number): string { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + + if (days > 0) return `${days}d ${hours}h ${minutes}m`; + if (hours > 0) return `${hours}h ${minutes}m`; + return `${minutes}m`; +} + +/** + * Formatear bytes a formato legible + */ +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const sizes = ['Bytes', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; +} + +export const command: CommandMessage = { + name: 'aistats', + type: "message", + aliases: ['ai-stats', 'ai-info'], + cooldown: 5, + description: 'Muestra estadísticas del servicio de IA (Solo administradores)', + category: 'Administración', + usage: 'aistats [reset]', + run: async (message, args) => { + // Verificar permisos de administrador + if (!message.member?.permissions.has(PermissionFlagsBits.Administrator)) { + await message.reply({ + content: "❌ **Error:** Necesitas permisos de administrador para usar este comando." + }); + return; + } + + try { + const action = args[0]?.toLowerCase(); + + // Reset del sistema si se solicita + if (action === 'reset') { + // Aquí puedes agregar lógica para resetear estadísticas + const resetEmbed = new EmbedBuilder() + .setColor(0x00FF00) + .setTitle('✅ Sistema de IA Reiniciado') + .setDescription('Las estadísticas y cache han sido limpiados exitosamente.') + .setTimestamp(); + + await message.reply({ embeds: [resetEmbed] }); + logger.info(`Sistema de IA reiniciado por ${message.author.username} (${message.author.id})`); + return; + } + + // Obtener estadísticas del servicio + const stats = aiService.getStats(); + const uptime = process.uptime(); + const memoryUsage = process.memoryUsage(); + + // Crear embed de estadísticas detallado + const statsEmbed = new EmbedBuilder() + .setColor(0xFF69B4) + .setTitle('📊 Estadísticas del Servicio de IA') + .setDescription('Estado actual del sistema Gemini-chan') + .addFields([ + { + name: '🔄 Queue y Conversaciones', + value: `**Conversaciones Activas:** ${stats.activeConversations}\n` + + `**Requests en Cola:** ${stats.queueLength}\n` + + `**Total Requests:** ${stats.totalRequests}`, + inline: true + }, + { + name: '⚡ Rendimiento', + value: `**Uptime:** ${formatUptime(uptime)}\n` + + `**Memoria RAM:** ${formatBytes(memoryUsage.heapUsed)} / ${formatBytes(memoryUsage.heapTotal)}\n` + + `**RSS:** ${formatBytes(memoryUsage.rss)}`, + inline: true + }, + { + name: '🛡️ Límites y Configuración', + value: `**Rate Limit:** 20 req/min por usuario\n` + + `**Cooldown:** 3 segundos\n` + + `**Max Tokens:** 1M entrada / 8K salida\n` + + `**Max Concurrent:** 3 requests`, + inline: false + } + ]) + .setFooter({ + text: `Solicitado por ${message.author.username} | Usa 'aistats reset' para reiniciar`, + iconURL: message.author.displayAvatarURL({ forceStatic: false }) + }) + .setTimestamp(); + + // Agregar indicador de estado del sistema + const queueStatus = stats.queueLength === 0 ? '🟢 Normal' : + stats.queueLength < 5 ? '🟡 Ocupado' : '🔴 Saturado'; + + statsEmbed.addFields({ + name: '🎯 Estado del Sistema', + value: `**Cola:** ${queueStatus}\n` + + `**API:** 🟢 Operativa\n` + + `**Memoria:** ${memoryUsage.heapUsed / memoryUsage.heapTotal > 0.8 ? '🔴 Alta' : '🟢 Normal'}`, + inline: true + }); + + await message.reply({ embeds: [statsEmbed] }); + + } catch (error: any) { + logger.error('Error obteniendo estadísticas de IA:', error); + + const errorEmbed = new EmbedBuilder() + .setColor(0xFF4444) + .setTitle('❌ Error') + .setDescription('No se pudieron obtener las estadísticas del sistema.') + .setTimestamp(); + + await message.reply({ embeds: [errorEmbed] }); + } + } +} diff --git a/src/core/api/remindersSchema.ts b/src/core/api/remindersSchema.ts index 43c79c6..f1bc9df 100644 --- a/src/core/api/remindersSchema.ts +++ b/src/core/api/remindersSchema.ts @@ -16,9 +16,15 @@ export async function ensureRemindersSchema() { await db.getCollection(databaseId, collectionId); } catch { try { - await db.createCollection(databaseId, collectionId, collectionId, [ - // Permisos por defecto: accesible solo por server via API Key - ]); + // Sintaxis actualizada para createCollection (versión actual de Appwrite SDK) + await db.createCollection( + databaseId, + collectionId, + collectionId, + undefined, // permissions (opcional) + undefined, // documentSecurity (opcional) + false // enabled (opcional) + ); // Nota: No añadimos permisos de lectura pública para evitar fuga de datos } catch (e) { // @ts-ignore @@ -47,8 +53,9 @@ export async function ensureRemindersSchema() { // 3) Índice por executeAt para consultas por vencimiento try { - //@ts-ignore - await db.createIndex(databaseId, collectionId, 'idx_executeAt_asc', 'key', ['executeAt'], ['ASC']); + // Sintaxis actualizada para createIndex - 'ASC' ahora debe ser 'asc' (lowercase) + // @ts-ignore + await db.createIndex(databaseId, collectionId, 'idx_executeAt_asc', 'key', ['executeAt'], ['asc']); } catch (e: any) { const msg = String(e?.message || e); if (!/already exists|index_already_exists/i.test(msg)) { @@ -57,4 +64,3 @@ export async function ensureRemindersSchema() { } } } - diff --git a/src/core/services/AIService.ts b/src/core/services/AIService.ts new file mode 100644 index 0000000..87dcbba --- /dev/null +++ b/src/core/services/AIService.ts @@ -0,0 +1,509 @@ +import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } from "@google/generative-ai"; +import logger from "../lib/logger"; +import { Collection } from "discord.js"; + +// Tipos mejorados para mejor type safety +interface ConversationContext { + messages: Array<{ + role: 'user' | 'assistant'; + content: string; + timestamp: number; + tokens: number; + }>; + totalTokens: number; + imageRequests: number; + lastActivity: number; + userId: string; + guildId?: string; +} + +interface AIRequest { + userId: string; + guildId?: string; + prompt: string; + priority: 'low' | 'normal' | 'high'; + timestamp: number; + resolve: (value: string) => void; + reject: (error: Error) => void; +} + +// Utility function para manejar errores de forma type-safe +function getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === 'string') { + return error; + } + if (error && typeof error === 'object' && 'message' in error) { + return String(error.message); + } + return 'Error desconocido'; +} + +// Type guard para verificar si es un Error +function isError(error: unknown): error is Error { + return error instanceof Error; +} + +// Type guard para verificar errores de API específicos +function isAPIError(error: unknown): error is { message: string; code?: string } { + return ( + error !== null && + typeof error === 'object' && + 'message' in error && + typeof (error as any).message === 'string' + ); +} + +export class AIService { + private genAI: GoogleGenerativeAI; + private conversations = new Collection(); + private requestQueue: AIRequest[] = []; + private processing = false; + private userCooldowns = new Collection(); + private rateLimitTracker = new Collection(); + + // Configuración mejorada y escalable + private readonly config = { + maxInputTokens: 1048576, // 1M tokens Gemini 2.5 Flash + maxOutputTokens: 8192, // Reducido para mejor rendimiento + tokenResetThreshold: 0.80, // Más conservador + maxConversationAge: 30 * 60 * 1000, // 30 minutos + maxMessageHistory: 8, // Reducido para mejor memoria + cooldownMs: 3000, // 3 segundos entre requests + maxImageRequests: 3, // Reducido para evitar spam + requestTimeout: 30000, // 30 segundos timeout + maxConcurrentRequests: 3, // Máximo 3 requests simultáneos + rateLimitWindow: 60000, // 1 minuto + rateLimitMax: 20, // 20 requests por minuto por usuario + cleanupInterval: 5 * 60 * 1000, // Limpiar cada 5 minutos + }; + + constructor() { + const apiKey = process.env.GOOGLE_AI_API_KEY; + if (!apiKey) { + throw new Error('GOOGLE_AI_API_KEY no está configurada'); + } + + this.genAI = new GoogleGenerativeAI(apiKey); + this.startCleanupService(); + this.startQueueProcessor(); + } + + /** + * Procesa una request de IA de forma asíncrona y controlada + */ + async processAIRequest( + userId: string, + prompt: string, + guildId?: string, + priority: 'low' | 'normal' | 'high' = 'normal' + ): Promise { + // Validaciones exhaustivas + if (!prompt?.trim()) { + throw new Error('El prompt no puede estar vacío'); + } + + if (prompt.length > 4000) { + throw new Error('El prompt excede el límite de 4000 caracteres'); + } + + // Rate limiting por usuario + if (!this.checkRateLimit(userId)) { + throw new Error('Has excedido el límite de requests. Espera un momento.'); + } + + // Cooldown entre requests + const lastRequest = this.userCooldowns.get(userId) || 0; + const timeSinceLastRequest = Date.now() - lastRequest; + + if (timeSinceLastRequest < this.config.cooldownMs) { + const waitTime = Math.ceil((this.config.cooldownMs - timeSinceLastRequest) / 1000); + throw new Error(`Debes esperar ${waitTime} segundos antes de hacer otra consulta`); + } + + // Agregar a la queue con Promise + return new Promise((resolve, reject) => { + const request: AIRequest = { + userId, + guildId, + prompt: prompt.trim(), + priority, + timestamp: Date.now(), + resolve, + reject + }; + + // Insertar según prioridad + if (priority === 'high') { + this.requestQueue.unshift(request); + } else { + this.requestQueue.push(request); + } + + // Timeout automático + setTimeout(() => { + const index = this.requestQueue.findIndex(r => r === request); + if (index !== -1) { + this.requestQueue.splice(index, 1); + reject(new Error('Request timeout: La solicitud tardó demasiado tiempo')); + } + }, this.config.requestTimeout); + + this.userCooldowns.set(userId, Date.now()); + }); + } + + /** + * Procesador de queue mejorado con control de concurrencia + */ + private async startQueueProcessor(): Promise { + setInterval(async () => { + if (this.processing || this.requestQueue.length === 0) return; + + this.processing = true; + + try { + // Procesar hasta 3 requests simultáneamente + const batch = this.requestQueue.splice(0, this.config.maxConcurrentRequests); + + await Promise.allSettled( + batch.map(request => this.processRequest(request)) + ); + } catch (error) { + // Usar nuestro helper para manejar el error de forma type-safe + const errorMessage = getErrorMessage(error); + logger.error(`Error en el procesador de queue: ${errorMessage}`); + + // Si necesitamos más detalles del error, podemos usar type guards + if (isError(error) && error.stack) { + logger.error(`Stack trace: ${error.stack}`); + } + } finally { + this.processing = false; + } + }, 1000); // Revisar cada segundo + } + + /** + * Procesa una request individual con manejo completo de errores + */ + private async processRequest(request: AIRequest): Promise { + try { + const { userId, prompt, guildId } = request; + + // Obtener o crear contexto de conversación + const context = this.getOrCreateContext(userId, guildId); + + // Verificar si es request de imagen + const isImageRequest = this.detectImageRequest(prompt); + if (isImageRequest && context.imageRequests >= this.config.maxImageRequests) { + const error = new Error(`Has alcanzado el límite de ${this.config.maxImageRequests} solicitudes de imagen. La conversación se ha reiniciado.`); + request.reject(error); + return; + } + + // Verificar límites de tokens + const estimatedTokens = this.estimateTokens(prompt); + if (context.totalTokens + estimatedTokens > this.config.maxInputTokens * this.config.tokenResetThreshold) { + this.resetConversation(userId); + logger.info(`Conversación reseteada para usuario ${userId} por límite de tokens`); + } + + // Construir prompt del sistema optimizado + const systemPrompt = this.buildSystemPrompt(prompt, context, isImageRequest); + + // Usar la API correcta de Google Generative AI + const model = this.genAI.getGenerativeModel({ + model: "gemini-1.5-flash", + generationConfig: { + maxOutputTokens: Math.min(this.config.maxOutputTokens, Math.max(1024, estimatedTokens * 0.5)), + temperature: 0.7, + topP: 0.85, + topK: 40, + }, + safetySettings: [ + { + category: HarmCategory.HARM_CATEGORY_HARASSMENT, + threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE + }, + { + category: HarmCategory.HARM_CATEGORY_HATE_SPEECH, + threshold: HarmBlockThreshold.BLOCK_MEDIUM_AND_ABOVE + } + ] + }); + + const result = await model.generateContent(systemPrompt); + const response = await result.response; + const aiResponse = response.text()?.trim(); + + if (!aiResponse) { + const error = new Error('La IA no generó una respuesta válida'); + request.reject(error); + return; + } + + // Actualizar contexto de forma eficiente + this.updateContext(context, prompt, aiResponse, estimatedTokens, isImageRequest); + + request.resolve(aiResponse); + + } catch (error) { + // Manejo type-safe de errores sin ts-ignore + const errorMessage = this.parseAPIError(error); + const logMessage = getErrorMessage(error); + logger.error(`Error procesando AI request para ${request.userId}: ${logMessage}`); + + // Log adicional si es un Error con stack trace + if (isError(error) && error.stack) { + logger.error(`Stack trace completo: ${error.stack}`); + } + + request.reject(new Error(errorMessage)); + } + } + + /** + * Construcción optimizada del prompt del sistema + */ + private buildSystemPrompt(userPrompt: string, context: ConversationContext, isImageRequest: boolean): string { + const recentMessages = context.messages + .slice(-4) // Solo los últimos 4 mensajes + .map(msg => `${msg.role === 'user' ? 'Usuario' : 'Asistente'}: ${msg.content}`) + .join('\n'); + + return `Eres una hermana mayor kawaii y cariñosa que habla por Discord. Responde de manera natural, útil y concisa. + +## Reglas Discord: +- USA **markdown de Discord**: **negrita**, *cursiva*, \`código\`, \`\`\`bloques\`\`\` +- NUNCA uses LaTeX ($$) +- Máximo 2-3 emojis por respuesta +- Respuestas concisas y claras + +${isImageRequest ? ` +## Limitación: +- No puedes generar imágenes +- Ofrece ayuda alternativa (descripciones, recursos, etc.) +` : ''} + +## Contexto reciente: +${recentMessages || 'Sin historial previo'} + +## Consulta actual: +${userPrompt} + +Responde de forma directa y útil:`; + } + + /** + * Sistema de rate limiting mejorado + */ + private checkRateLimit(userId: string): boolean { + const now = Date.now(); + const userLimit = this.rateLimitTracker.get(userId); + + if (!userLimit || now > userLimit.resetTime) { + this.rateLimitTracker.set(userId, { + count: 1, + resetTime: now + this.config.rateLimitWindow + }); + return true; + } + + if (userLimit.count >= this.config.rateLimitMax) { + return false; + } + + userLimit.count++; + return true; + } + + /** + * Detección mejorada de requests de imagen + */ + private detectImageRequest(prompt: string): boolean { + const imageKeywords = [ + 'imagen', 'image', 'dibujo', 'draw', 'dibujar', + 'generar imagen', 'create image', 'picture', 'foto', + 'ilustración', 'arte', 'pintura', 'sketch' + ]; + + const lowerPrompt = prompt.toLowerCase(); + return imageKeywords.some(keyword => lowerPrompt.includes(keyword)); + } + + /** + * Estimación de tokens más precisa + */ + private estimateTokens(text: string): number { + // Aproximación mejorada basada en la tokenización real + const words = text.split(/\s+/).length; + const chars = text.length; + + // Fórmula híbrida más precisa + return Math.ceil((words * 1.3) + (chars * 0.25)); + } + + /** + * Obtener o crear contexto de conversación + */ + private getOrCreateContext(userId: string, guildId?: string): ConversationContext { + const key = `${userId}-${guildId || 'dm'}`; + let context = this.conversations.get(key); + + if (!context) { + context = { + messages: [], + totalTokens: 0, + imageRequests: 0, + lastActivity: Date.now(), + userId, + guildId + }; + this.conversations.set(key, context); + } + + context.lastActivity = Date.now(); + return context; + } + + /** + * Actualizar contexto de forma eficiente + */ + private updateContext( + context: ConversationContext, + userPrompt: string, + aiResponse: string, + inputTokens: number, + isImageRequest: boolean + ): void { + const outputTokens = this.estimateTokens(aiResponse); + const now = Date.now(); + + // Agregar mensajes + context.messages.push( + { role: 'user', content: userPrompt, timestamp: now, tokens: inputTokens }, + { role: 'assistant', content: aiResponse, timestamp: now, tokens: outputTokens } + ); + + // Mantener solo los mensajes más recientes + if (context.messages.length > this.config.maxMessageHistory) { + const removed = context.messages.splice(0, context.messages.length - this.config.maxMessageHistory); + const removedTokens = removed.reduce((sum, msg) => sum + msg.tokens, 0); + context.totalTokens -= removedTokens; + } + + context.totalTokens += inputTokens + outputTokens; + context.lastActivity = now; + + if (isImageRequest) { + context.imageRequests++; + } + } + + /** + * Resetear conversación + */ + private resetConversation(userId: string, guildId?: string): void { + const key = `${userId}-${guildId || 'dm'}`; + this.conversations.delete(key); + } + + /** + * Servicio de limpieza automática + */ + private startCleanupService(): void { + setInterval(() => { + const now = Date.now(); + const toDelete: string[] = []; + + this.conversations.forEach((context, key) => { + if (now - context.lastActivity > this.config.maxConversationAge) { + toDelete.push(key); + } + }); + + toDelete.forEach(key => this.conversations.delete(key)); + + if (toDelete.length > 0) { + logger.info(`Limpieza automática: ${toDelete.length} conversaciones expiradas eliminadas`); + } + }, this.config.cleanupInterval); + } + + /** + * Parser mejorado de errores de API - Type-safe sin ts-ignore + */ + private parseAPIError(error: unknown): string { + // Extraer mensaje de forma type-safe + const message = getErrorMessage(error).toLowerCase(); + + // Verificar si es un error de API estructurado + if (isAPIError(error)) { + const apiMessage = error.message.toLowerCase(); + + if (apiMessage.includes('api key') || apiMessage.includes('authentication')) { + return 'Error de autenticación con la API de IA'; + } + if (apiMessage.includes('quota') || apiMessage.includes('exceeded')) { + return 'Se ha alcanzado el límite de uso de la API. Intenta más tarde'; + } + if (apiMessage.includes('safety') || apiMessage.includes('blocked')) { + return 'Tu mensaje fue bloqueado por las políticas de seguridad'; + } + if (apiMessage.includes('timeout') || apiMessage.includes('deadline')) { + return 'La solicitud tardó demasiado tiempo. Intenta de nuevo'; + } + if (apiMessage.includes('model not found')) { + return 'El modelo de IA no está disponible en este momento'; + } + if (apiMessage.includes('token') || apiMessage.includes('length')) { + return 'El mensaje excede los límites permitidos'; + } + } + + // Manejo genérico para otros tipos de errores + if (message.includes('api key') || message.includes('authentication')) { + return 'Error de autenticación con la API de IA'; + } + if (message.includes('quota') || message.includes('exceeded')) { + return 'Se ha alcanzado el límite de uso de la API. Intenta más tarde'; + } + if (message.includes('safety') || message.includes('blocked')) { + return 'Tu mensaje fue bloqueado por las políticas de seguridad'; + } + if (message.includes('timeout') || message.includes('deadline')) { + return 'La solicitud tardó demasiado tiempo. Intenta de nuevo'; + } + if (message.includes('model not found')) { + return 'El modelo de IA no está disponible en este momento'; + } + if (message.includes('token') || message.includes('length')) { + return 'El mensaje excede los límites permitidos'; + } + + return 'Error temporal del servicio de IA. Intenta de nuevo'; + } + + /** + * Obtener estadísticas del servicio + */ + getStats(): { + activeConversations: number; + queueLength: number; + totalRequests: number; + averageResponseTime: number; + } { + return { + activeConversations: this.conversations.size, + queueLength: this.requestQueue.length, + totalRequests: this.userCooldowns.size, + averageResponseTime: 0 + }; + } +} + +// Instancia singleton +export const aiService = new AIService();