import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } from "@google/generative-ai"; import { GoogleGenAI, PersonGeneration } from "@google/genai"; import logger from "../lib/logger"; import { Collection } from "discord.js"; import { prisma } from "../database/prisma"; import { getDatabases, APPWRITE_DATABASE_ID, APPWRITE_COLLECTION_AI_CONVERSATIONS_ID, isAIConversationsConfigured } from "../api/appwrite"; import { ensureAIConversationsSchema } from "../api/aiConversationsSchema"; // eslint-disable-next-line @typescript-eslint/no-var-requires const sdk: any = require('node-appwrite'); // Tipos mejorados para mejor type safety interface ConversationContext { messages: Array<{ role: 'user' | 'assistant'; content: string; timestamp: number; tokens: number; messageId?: string; // ID del mensaje de Discord referencedMessageId?: string; // ID del mensaje al que responde }>; totalTokens: number; imageRequests: number; lastActivity: number; userId: string; guildId?: string; channelId?: string; conversationId?: string; // ID único de la conversación } interface AIRequest { userId: string; guildId?: string; channelId?: string; prompt: string; priority: 'low' | 'normal' | 'high'; timestamp: number; resolve: (value: string) => void; reject: (error: Error) => void; aiRolePrompt?: string; meta?: string; messageId?: string; referencedMessageId?: string; } interface AppwriteConversation { $id?: string; userId: string; guildId?: string | null; channelId?: string | null; conversationId: string; messagesJson?: string; // JSON serializado del historial lastActivity: string; // ISO createdAt: string; // ISO } // 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 as any).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' ); } const sleep = (ms: number): Promise => new Promise(resolve => setTimeout(resolve, ms)); function isServiceUnavailableError(error: unknown): boolean { if (!error) { return false; } const message = getErrorMessage(error).toLowerCase(); if ( message.includes('503') || message.includes('service unavailable') || message.includes('model is overloaded') || message.includes('model estuvo sobrecargado') || message.includes('overloaded') || message.includes('temporarily unavailable') ) { return true; } const status = (error as any)?.status ?? (error as any)?.statusCode ?? (error as any)?.code; if (typeof status === 'number' && status === 503) { return true; } if (typeof status === 'string' && status.includes('503')) { return true; } if (isAPIError(error)) { const apiMessage = error.message.toLowerCase(); return ( apiMessage.includes('503') || apiMessage.includes('service unavailable') || apiMessage.includes('overloaded') ); } return false; } export class AIService { private genAI: GoogleGenerativeAI; private genAIv2: any; // Cache del modelo de imágenes detectado private imageModelName?: string | null; private conversations = new Collection(); private requestQueue: AIRequest[] = []; private processing = false; private userCooldowns = new Collection(); private rateLimitTracker = new Collection(); // Cache de configuración por guild private guildPromptCache = new Collection(); // Configuración mejorada y escalable private readonly config = { maxInputTokens: 1048576, // 1M tokens Gemini 2.5 Flash (entrada) maxOutputTokens: 65536, // 65,536 salida (según aclaración del usuario para preview 09-2025) 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 guildConfigTTL: 5 * 60 * 1000, // 5 minutos de cache para prompts de guild } as const; constructor() { const apiKey = process.env.GOOGLE_AI_API_KEY || process.env.GEMINI_API_KEY; if (!apiKey) { throw new Error('Falta la clave de Google AI. Define GOOGLE_AI_API_KEY o GEMINI_API_KEY en las variables de entorno.'); } this.genAI = new GoogleGenerativeAI(apiKey); try { this.genAIv2 = new GoogleGenAI({ apiKey }); logger.info('GoogleGenAI v2 inicializado correctamente para generación de imágenes'); } catch (e) { logger.warn(`GoogleGenAI v2 no pudo inicializarse: ${getErrorMessage(e)}`); this.genAIv2 = null; } // Permitir override de modelo por variable de entorno const envImageModel = process.env.GENAI_IMAGE_MODEL; if (envImageModel && envImageModel.trim()) { this.imageModelName = envImageModel.trim(); logger.info({ model: this.imageModelName }, 'Modelo de imágenes fijado por GENAI_IMAGE_MODEL'); } this.startQueueProcessor(); this.startCleanupService(); this.detectImageModel(); } /** * Auto-detectar modelo de imagen disponible */ private async detectImageModel(): Promise { if (!this.genAIv2) { logger.warn('GoogleGenAI v2 no disponible; sin soporte para imágenes'); return null; } // Lista de candidatos de modelos de imagen ordenados por preferencia (Imagen 4.0 primero, con retrocompatibilidad) const candidates = [ 'models/imagen-4.0-generate-001', 'imagen-4.0-generate-001', 'models/imagen-3.0-fast', 'imagen-3.0-fast', 'models/imagen-3.0', 'imagen-3.0', 'models/gemini-2.5-flash-image', 'gemini-2.5-flash-image', ]; // Intentar listar modelos primero try { const listed: any = await (this.genAIv2 as any).models?.listModels?.(); if (listed?.models && Array.isArray(listed.models)) { const models: string[] = listed.models .map((m: any) => m?.name || m?.model || m?.id || m?.displayName) .filter(Boolean); logger.debug({ availableModels: models }, 'Modelos disponibles detectados'); // Buscar modelos de imagen disponibles const imageModels = models.filter((id: string) => /imagen|image|generate|vision/i.test(id) && !/text|chat|embed|code/i.test(id) ); if (imageModels.length > 0) { // Priorizar según orden de candidatos for (const candidate of candidates) { const candidateBase = candidate.replace(/^models\//, ''); const found = imageModels.find(m => m === candidate || m === candidateBase || m.includes(candidateBase) ); if (found) { this.imageModelName = found; logger.info({ model: found, source: 'listModels' }, 'Modelo de imágenes detectado automáticamente'); return found; } } // Si no coincide con candidatos conocidos, usar el primero disponible this.imageModelName = imageModels[0]; logger.info({ model: imageModels[0], source: 'listModels-fallback' }, 'Modelo de imágenes detectado (fallback)'); return imageModels[0]; } } } catch (e) { logger.debug({ err: getErrorMessage(e) }, 'listModels no disponible'); } // Fallback: probar modelos uno por uno for (const candidate of candidates) { try { await (this.genAIv2 as any).models.generateImages({ model: candidate, prompt: 'test', config: { numberOfImages: 1, outputMimeType: 'image/jpeg', aspectRatio: '1:1', imageSize: '1K', } }); // Si no lanza error, el modelo existe this.imageModelName = candidate; logger.info({ model: candidate, source: 'direct-test' }, 'Modelo de imágenes detectado por prueba directa'); return candidate; } catch (e: any) { const msg = getErrorMessage(e); if (msg.includes('not found') || msg.includes('404')) { continue; // Modelo no disponible, probar siguiente } // Otros errores pueden indicar que el modelo existe pero falló por otra razón logger.debug({ candidate, err: msg }, 'Modelo podría existir pero falló la prueba'); } } // No se encontró ningún modelo de imagen this.imageModelName = null; logger.warn('No se detectó ningún modelo de imagen disponible'); return null; } /** * Obtener prompt de rol de IA por guild con caché */ public async getGuildAiPrompt(guildId: string): Promise { try { const cached = this.guildPromptCache.get(guildId); const now = Date.now(); if (cached && (now - cached.fetchedAt) < this.config.guildConfigTTL) { return cached.prompt; } // @ts-ignore const guild = await prisma.guild.findUnique({ where: { id: guildId }, select: { aiRolePrompt: true } }); //@ts-ignore const prompt = guild?.aiRolePrompt ?? null; this.guildPromptCache.set(guildId, { prompt, fetchedAt: now }); return prompt; } catch (e) { logger.warn(`No se pudo cargar aiRolePrompt para guild ${guildId}: ${getErrorMessage(e)}`); return null; } } /** * Invalidar cache de configuración de un guild (llamar tras guardar cambios) */ public invalidateGuildConfig(guildId: string): void { this.guildPromptCache.delete(guildId); } /** * Procesa una request de IA de forma asíncrona y controlada */ async processAIRequest( userId: string, prompt: string, guildId?: string, priority: 'low' | 'normal' | 'high' = 'normal', options?: { aiRolePrompt?: string; meta?: string } ): 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, aiRolePrompt: options?.aiRolePrompt, meta: options?.meta, }; // 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 } /** * 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('service unavailable') || apiMessage.includes('overloaded') || apiMessage.includes('503')) { return 'El servicio de IA está saturado. Intenta de nuevo en unos segundos'; } 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('service unavailable') || message.includes('overloaded') || message.includes('503')) { return 'El servicio de IA está saturado. Intenta de nuevo en unos segundos'; } 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'; } private async generateContentWithRetries(model: any, content: any, options?: { maxAttempts?: number; baseDelayMs?: number; maxDelayMs?: number; }): Promise { const { maxAttempts = 3, baseDelayMs = 1200, maxDelayMs = 10_000 } = options ?? {}; let lastError: unknown; for (let attempt = 0; attempt < maxAttempts; attempt++) { try { return await model.generateContent(content); } catch (error) { lastError = error; const isRetryable = isServiceUnavailableError(error); const isLastAttempt = attempt === maxAttempts - 1; if (!isRetryable || isLastAttempt) { throw error; } const backoff = Math.min(maxDelayMs, Math.floor(baseDelayMs * Math.pow(2, attempt))); const jitter = Math.floor(Math.random() * Math.max(200, Math.floor(baseDelayMs / 2))); const waitMs = backoff + jitter; logger.warn( { attempt: attempt + 1, waitMs }, `Gemini respondió 503 (overloaded). Reintentando en ${waitMs}ms (intento ${attempt + 2}/${maxAttempts})` ); await sleep(waitMs); } } throw lastError ?? new Error('Error desconocido al generar contenido con Gemini'); } /** * Procesa una request de IA con soporte para conversaciones y memoria persistente */ async processAIRequestWithMemory( userId: string, prompt: string, guildId?: string, channelId?: string, messageId?: string, referencedMessageId?: string, client?: any, priority: 'low' | 'normal' | 'high' = 'normal', options?: { aiRolePrompt?: string; meta?: string; attachments?: any[] } ): 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 & { client?: any; attachments?: any[] } = { userId, guildId, channelId, prompt: prompt.trim(), priority, timestamp: Date.now(), resolve, reject, aiRolePrompt: options?.aiRolePrompt, meta: options?.meta, messageId, referencedMessageId, client, attachments: options?.attachments }; // 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()); }); } /** * Lista modelos de imagen visibles por la clave (si el SDK lo permite) */ public async listImageModels(): Promise { if (!this.genAIv2 || !(this.genAIv2 as any).models?.listModels) return []; try { const listed: any = await (this.genAIv2 as any).models.listModels(); const models: string[] = Array.isArray(listed?.models) ? listed.models.map((m: any) => m?.name || m?.model || m?.id).filter(Boolean) : []; // Filtrar a modelos de imagen de forma heurística return models.filter((id) => /imagen|image/i.test(id)); } catch { return []; } } // Override manual del modelo de imágenes (útil para runtime) public setImageModel(model: string | null | undefined): void { this.imageModelName = model ?? null; if (this.imageModelName) { logger.info({ model: this.imageModelName }, 'Modelo de imágenes fijado manualmente'); } else { logger.info('Modelo de imágenes reseteado; se volverá a detectar automáticamente'); } } /** * Detectar si hay imágenes adjuntas en el mensaje para análisis */ public hasImageAttachments(attachments?: any[]): boolean { if (!attachments || attachments.length === 0) return false; const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp']; const imageMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/bmp']; return attachments.some(attachment => { const hasImageExtension = imageExtensions.some(ext => attachment.name?.toLowerCase().endsWith(ext) ); const hasImageMimeType = imageMimeTypes.includes(attachment.contentType?.toLowerCase()); return hasImageExtension || hasImageMimeType; }); } /** * Procesar imágenes adjuntas para análisis con Gemini Vision */ private async processImageAttachments(attachments: any[]): Promise { const imageAttachments: Array<{ inlineData: { data: string; mimeType: string } }> = []; for (const attachment of attachments) { if (this.hasImageAttachments([attachment])) { try { // Descargar la imagen const response = await fetch(attachment.url); if (!response.ok) { logger.warn(`Error descargando imagen: ${response.statusText}`); continue; } const arrayBuffer = await response.arrayBuffer(); const base64Data = Buffer.from(arrayBuffer).toString('base64'); // Determinar el tipo MIME let mimeType = attachment.contentType || 'image/png'; if (!mimeType.startsWith('image/')) { // Inferir del nombre del archivo const ext = attachment.name?.toLowerCase().split('.').pop(); switch (ext) { case 'jpg': case 'jpeg': mimeType = 'image/jpeg'; break; case 'png': mimeType = 'image/png'; break; case 'gif': mimeType = 'image/gif'; break; case 'webp': mimeType = 'image/webp'; break; default: mimeType = 'image/png'; } } imageAttachments.push({ inlineData: { data: base64Data, mimeType: mimeType } }); logger.info(`Imagen procesada: ${attachment.name} (${mimeType})`); } catch (error) { logger.error(`Error procesando imagen ${attachment.name}: ${getErrorMessage(error)}`); } } } return imageAttachments; } /** * Procesa una request individual con manejo completo de errores y memoria persistente */ private async processRequest(request: AIRequest): Promise { try { const { userId, prompt, guildId, channelId, messageId, referencedMessageId } = request; const context = await this.getOrCreateContextWithMemory(userId, guildId, channelId); // Obtener imágenes adjuntas si existen const messageAttachments = (request as any).attachments || []; const hasImages = this.hasImageAttachments(messageAttachments); 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, guildId); logger.info(`Conversación reseteada para usuario ${userId} por límite de tokens`); } // Obtener prompt del sistema (desde opciones o DB) let effectiveAiRolePrompt = request.aiRolePrompt; if (effectiveAiRolePrompt === undefined && guildId) { effectiveAiRolePrompt = (await this.getGuildAiPrompt(guildId)) ?? undefined; } // Obtener jerarquía de roles si está en un servidor let roleHierarchy = ''; if (guildId) { const client = (request as any).client; if (client) { roleHierarchy = await this.getGuildRoleHierarchy(guildId, client); } } // Construir metadatos mejorados const enhancedMeta = (request.meta || '') + roleHierarchy; // Construir prompt del sistema optimizado let systemPrompt = this.buildSystemPrompt( prompt, context, isImageRequest, effectiveAiRolePrompt, enhancedMeta ); // Procesar imágenes si las hay let imageAttachments: any[] = []; if (hasImages) { imageAttachments = await this.processImageAttachments(messageAttachments); if (imageAttachments.length > 0) { systemPrompt = `${systemPrompt}\n\n## Imágenes adjuntas:\nPor favor, analiza las imágenes proporcionadas y responde de acuerdo al contexto.`; } } // Usar gemini-2.5-flash-preview-09-2025 que puede leer imágenes y responder con texto const model = this.genAI.getGenerativeModel({ model: "gemini-2.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 } ] }); // Construir el contenido para la API let content: any; if (hasImages && imageAttachments.length > 0) { // Para multimodal (texto + imágenes) content = [ { text: systemPrompt }, ...imageAttachments ]; logger.info(`Procesando ${imageAttachments.length} imagen(es) con Gemini Vision`); } else { // Solo texto content = systemPrompt; } const result = await this.generateContentWithRetries(model, content); 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 con memoria persistente await this.updateContextWithMemory( context, prompt, aiResponse, estimatedTokens, isImageRequest || hasImages, messageId, referencedMessageId ); 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, aiRolePrompt?: string, meta?: string ): string { const recentMessages = context.messages .slice(-4) .map(msg => `${msg.role === 'user' ? 'Usuario' : 'Asistente'}: ${msg.content}`) .join('\n'); const roleBlock = aiRolePrompt && aiRolePrompt.trim() ? `\n## Rol del sistema (servidor):\n${aiRolePrompt.trim().slice(0, 1200)}\n` : ''; const metaBlock = meta && meta.trim() ? `\n## Contexto del mensaje:\n${meta.trim().slice(0, 800)}\n` : ''; return `Eres una hermana mayor kawaii y cariñosa que habla por Discord. Responde de manera natural, útil y concisa.${roleBlock}${metaBlock} ## Reglas Discord: - USA **markdown de Discord**: **negrita**, *cursiva*, \`código\`, \`\`\`bloques\`\`\` - NUNCA uses LaTeX ($$) - Máximo 2-3 emojis por respuesta - Prefiere emojis Unicode estándar (🙂, 🎯, etc.) cuando no haya más contexto - Si se te proporciona una lista de "Emojis personalizados disponibles", puedes usarlos escribiendo :nombre: exactamente como aparece; NO inventes nombres - 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; } /** * 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)); } /** * 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)); } /** * Obtener o crear contexto de conversación con carga desde Appwrite */ private async getOrCreateContextWithMemory(userId: string, guildId?: string, channelId?: string): Promise { const key = `${userId}-${guildId || 'dm'}`; let context = this.conversations.get(key); if (!context) { // Intentar cargar desde Appwrite const loadedContext = await this.loadConversationFromAppwrite(userId, guildId, channelId); if (loadedContext) { context = loadedContext; } else { // Crear nuevo contexto si no existe en Appwrite context = { messages: [], totalTokens: 0, imageRequests: 0, lastActivity: Date.now(), userId, guildId, channelId }; } this.conversations.set(key, context); } context.lastActivity = Date.now(); return context; } /** * Actualizar contexto de forma eficiente con guardado en Appwrite */ private async updateContextWithMemory( context: ConversationContext, userPrompt: string, aiResponse: string, inputTokens: number, isImageRequest: boolean, messageId?: string, referencedMessageId?: string ): Promise { const outputTokens = this.estimateTokens(aiResponse); const now = Date.now(); // Agregar mensajes con IDs de Discord context.messages.push( { role: 'user', content: userPrompt, timestamp: now, tokens: inputTokens, messageId, referencedMessageId }, { 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++; } // Guardar en Appwrite de forma asíncrona this.saveConversationToAppwrite(context).catch(error => { logger.warn(`Error guardando conversación: ${getErrorMessage(error)}`); }); } /** * Limpiar cache pero mantener memoria persistente */ public clearCache(): void { this.conversations.clear(); this.userCooldowns.clear(); this.rateLimitTracker.clear(); this.guildPromptCache.clear(); logger.info('Cache de AI limpiado, memoria persistente mantenida'); } /** * Reset completo pero mantener memoria persistente */ public fullReset(): void { this.clearCache(); this.requestQueue.length = 0; logger.info('AI completamente reseteada, memoria persistente mantenida'); } /** * 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 }; } /** * Guardar conversación en Appwrite para memoria persistente */ private async saveConversationToAppwrite(context: ConversationContext): Promise { if (!isAIConversationsConfigured()) { return; // Si no está configurado, no guardamos } try { await ensureAIConversationsSchema(); const databases = getDatabases(); if (!databases) return; // Asegurar conversationId válido y corto para Appwrite let conversationId = context.conversationId; if (!conversationId) { const userIdShort = context.userId.slice(-8); const guildIdShort = context.guildId ? context.guildId.slice(-8) : 'dm'; const timestamp = Date.now().toString(36); conversationId = `ai_${userIdShort}_${guildIdShort}_${timestamp}`.slice(0, 36); context.conversationId = conversationId; } // Serializar mensajes a JSON const messagesPayload = context.messages.map(m => ({ role: m.role, content: m.content, timestamp: m.timestamp, messageId: m.messageId, referencedMessageId: m.referencedMessageId, })); const messagesJson = JSON.stringify(messagesPayload); const data: AppwriteConversation = { userId: context.userId, guildId: context.guildId ?? null, channelId: context.channelId ?? null, conversationId, messagesJson, lastActivity: new Date(context.lastActivity).toISOString(), createdAt: new Date().toISOString(), }; // Upsert por ID estable try { await databases.updateDocument( APPWRITE_DATABASE_ID, APPWRITE_COLLECTION_AI_CONVERSATIONS_ID, conversationId, data ); } catch (updateError) { await databases.createDocument( APPWRITE_DATABASE_ID, APPWRITE_COLLECTION_AI_CONVERSATIONS_ID, conversationId, data ); } logger.debug(`Conversación guardada en Appwrite: ${conversationId}`); } catch (error) { logger.warn(`Error guardando conversación en Appwrite: ${getErrorMessage(error)}`); } } /** * Cargar conversación desde Appwrite */ private async loadConversationFromAppwrite(userId: string, guildId?: string, channelId?: string): Promise { if (!isAIConversationsConfigured()) { return null; } try { await ensureAIConversationsSchema(); const databases = getDatabases(); if (!databases) return null; const queries: any[] = [sdk.Query.equal('userId', userId)]; if (guildId) queries.push(sdk.Query.equal('guildId', guildId)); if (channelId) queries.push(sdk.Query.equal('channelId', channelId)); queries.push(sdk.Query.orderDesc('lastActivity')); queries.push(sdk.Query.limit(1)); const response = await databases.listDocuments( APPWRITE_DATABASE_ID, APPWRITE_COLLECTION_AI_CONVERSATIONS_ID, queries ) as unknown as { documents: AppwriteConversation[] }; const docs = (response?.documents || []) as AppwriteConversation[]; if (!docs.length) return null; const latest = docs[0]; const messagesArray: any[] = (() => { try { return latest.messagesJson ? JSON.parse(latest.messagesJson) : []; } catch { return []; } })(); const context: ConversationContext = { messages: messagesArray.map((msg: any) => ({ role: msg.role === 'assistant' ? 'assistant' : 'user', content: String(msg.content || ''), timestamp: Number(msg.timestamp || Date.now()), tokens: this.estimateTokens(String(msg.content || '')), messageId: msg.messageId, referencedMessageId: msg.referencedMessageId, })), totalTokens: messagesArray.reduce((sum: number, m: any) => sum + this.estimateTokens(String(m.content || '')), 0), imageRequests: 0, lastActivity: Date.parse(latest.lastActivity || new Date().toISOString()) || Date.now(), userId: latest.userId, guildId: latest.guildId || undefined, channelId: latest.channelId || undefined, conversationId: latest.conversationId, }; logger.debug(`Conversación cargada desde Appwrite: ${latest.conversationId}`); return context; } catch (error) { logger.warn(`Error cargando conversación desde Appwrite: ${getErrorMessage(error)}`); return null; } } /** * Obtener jerarquía de roles de un servidor */ private async getGuildRoleHierarchy(guildId: string, client: any): Promise { try { const guild = await client.guilds.fetch(guildId); if (!guild) return ''; const roles = await guild.roles.fetch(); const sortedRoles = roles .filter((role: any) => role.id !== guild.id) // Excluir @everyone .sort((a: any, b: any) => b.position - a.position) .map((role: any) => { const permissions: string[] = []; if (role.permissions.has('Administrator')) permissions.push('Admin'); if (role.permissions.has('ManageGuild')) permissions.push('Manage Server'); if (role.permissions.has('ManageChannels')) permissions.push('Manage Channels'); if (role.permissions.has('ManageMessages')) permissions.push('Manage Messages'); if (role.permissions.has('ModerateMembers')) permissions.push('Moderate Members'); const permStr = permissions.length > 0 ? ` (${permissions.join(', ')})` : ''; return `- ${role.name}${permStr}`; }) .slice(0, 15) // Limitar a 15 roles principales .join('\n'); return sortedRoles ? `\n## Jerarquía de roles del servidor:\n${sortedRoles}\n` : ''; } catch (error) { logger.warn(`Error obteniendo jerarquía de roles: ${getErrorMessage(error)}`); return ''; } } /** * Generar imagen usando la nueva API de @google/genai (basada en Google AI Studio) * Retorna un objeto con los bytes de la imagen y el tipo MIME. */ public async generateImage(prompt: string, options?: { size?: 'square' | 'portrait' | 'landscape'; mimeType?: string; numberOfImages?: number; personGeneration?: boolean; }): Promise<{ data: Buffer; mimeType: string; fileName: string; }> { if (!prompt?.trim()) { throw new Error('El prompt de imagen no puede estar vacío'); } if (!this.genAIv2) { throw new Error('El SDK moderno (@google/genai) no está inicializado'); } // Obtener/descubrir el modelo const model = this.imageModelName ?? (await this.detectImageModel()); if (!model) { throw new Error('El generador de imágenes no está disponible para tu cuenta o región. Habilita Imagen 4.0 (imagen-4.0-generate-001) en Google AI Studio.'); } const mimeType = options?.mimeType ?? 'image/jpeg'; const size = options?.size ?? 'square'; const numberOfImages = options?.numberOfImages ?? 1; const personGeneration = options?.personGeneration ?? true; // Mapear tamaño a aspectRatio según la nueva API const aspectRatio = size === 'portrait' ? '9:16' : size === 'landscape' ? '16:9' : '1:1'; try { logger.info({ model, prompt: prompt.slice(0, 100) }, 'Generando imagen con nueva API'); const response: any = await (this.genAIv2 as any).models.generateImages({ model: model, prompt: prompt, config: { numberOfImages: numberOfImages, outputMimeType: mimeType, personGeneration: personGeneration ? PersonGeneration.ALLOW_ALL : PersonGeneration.DONT_ALLOW, aspectRatio: aspectRatio, imageSize: '1K', // Usar 1K como tamaño estándar } }); if (!response?.generatedImages || !Array.isArray(response.generatedImages) || response.generatedImages.length === 0) { logger.error({ response, model }, 'No se generaron imágenes en la respuesta'); throw new Error('No se generaron imágenes'); } // Tomar la primera imagen generada const generatedImage = response.generatedImages[0]; if (!generatedImage?.image?.imageBytes) { logger.error({ generatedImage, model }, 'La imagen generada no contiene datos de bytes'); throw new Error('La imagen generada no contiene datos válidos'); } const base64Data = generatedImage.image.imageBytes; const buffer = Buffer.from(base64Data, 'base64'); // Generar nombre de archivo basado en el tipo MIME let fileName = `gen_${Date.now()}`; if (mimeType.includes('png')) fileName += '.png'; else if (mimeType.includes('jpeg') || mimeType.includes('jpg')) fileName += '.jpg'; else if (mimeType.includes('webp')) fileName += '.webp'; else fileName += '.img'; logger.info({ fileName, mimeType, bufferSize: buffer.length, model }, 'Imagen generada exitosamente'); return { data: buffer, mimeType: mimeType, fileName: fileName }; } catch (e) { logger.error({ err: e as any, model, prompt: prompt.slice(0, 100) }, 'Error en generateImage'); const parsed = this.parseAPIError(e); const original = getErrorMessage(e); // Proporcionar mensajes de error más específicos if (original.includes('not found') || original.includes('404')) { throw new Error('El modelo de generación de imágenes no está disponible. Verifica que Imagen 4.0 esté habilitado en tu cuenta de Google AI Studio.'); } if (original.includes('quota') || original.includes('limit')) { throw new Error('Has alcanzado el límite de generación de imágenes. Intenta más tarde.'); } // Si el parser no aporta información útil, usar el mensaje original const message = parsed === 'Error temporal del servicio de IA. Intenta de nuevo' ? original : parsed; throw new Error(message || parsed); } } } // Instancia singleton export const aiService = new AIService();