diff --git a/.env b/.env index 7994d15..bc72df0 100644 --- a/.env +++ b/.env @@ -12,6 +12,7 @@ APPWRITE_API_KEY="standard_b123c1dbaaf7d3f99aa81492509d6277da0cb89eaf86bb8c42210 APPWRITE_DATABASE_ID="68d8cb9a00250607e236" APPWRITE_COLLECTION_REMINDERS_ID="reminders_id" REMINDERS_POLL_INTERVAL_SECONDS="30" +APPWRITE_COLLECTION_AI_CONVERSATIONS_ID="aiconversation" # =========================================== # CONFIGURACIÓN DE DISCORD diff --git a/src/commands/messages/AI/chat.ts b/src/commands/messages/AI/chat.ts index cadee39..a23e345 100644 --- a/src/commands/messages/AI/chat.ts +++ b/src/commands/messages/AI/chat.ts @@ -195,28 +195,59 @@ export const command: CommandMessage = { return; } - // Emojis personalizados del servidor - const { names: emojiNames, map: emojiMap } = await getGuildCustomEmojis(message); - - // Construir metadatos del mensaje para mejor contexto (incluye emojis) - const meta = buildMessageMeta(message, emojiNames); - - // Indicador de escritura mejorado + // Indicador de que está escribiendo const typingInterval = setInterval(() => { channel.sendTyping().catch(() => {}); }, 5000); - try { - // Usar el servicio mejorado con manejo de prioridad - const priority = message.member?.permissions.has('Administrator') ? 'high' : 'normal'; + // Emojis personalizados del servidor + const { names: emojiNames, map: emojiMap } = await getGuildCustomEmojis(message); - let aiResponse = await aiService.processAIRequest( - userId, - prompt, - guildId, - priority, - { meta } - ); + // Construir metadatos del mensaje para mejor contexto (incluye emojis) + const messageMeta = buildMessageMeta(message, emojiNames); + + // Verificar si es una respuesta a un mensaje de la AI + let referencedMessageId: string | undefined; + let isReplyToAI = false; + + if (message.reference?.messageId) { + try { + const referencedMessage = await message.channel.messages.fetch(message.reference.messageId); + if (referencedMessage.author.id === message.client.user?.id) { + isReplyToAI = true; + referencedMessageId = message.reference.messageId; + } + } catch (error) { + // Mensaje referenciado no encontrado, ignorar + } + } + + try { + let aiResponse: string; + + // Usar el nuevo método con memoria persistente + if (isReplyToAI || true) { // Siempre usar memoria por ahora + aiResponse = await aiService.processAIRequestWithMemory( + userId, + prompt, + guildId, + message.channel.id, + message.id, + referencedMessageId, + message.client, + 'normal', + { meta: messageMeta } + ); + } else { + // Método legacy para compatibilidad + aiResponse = await aiService.processAIRequest( + userId, + prompt, + guildId, + 'normal', + { meta: messageMeta } + ); + } // Reemplazar :nombre: por el tag real del emoji, evitando bloques de código if (emojiNames.length > 0) { diff --git a/src/components/buttons/aiClearCache.ts b/src/components/buttons/aiClearCache.ts index f4ef64a..31f5345 100644 --- a/src/components/buttons/aiClearCache.ts +++ b/src/components/buttons/aiClearCache.ts @@ -19,12 +19,12 @@ export default { try { await interaction.deferUpdate(); - // Limpiar cache de conversaciones + // Limpiar cache pero mantener memoria persistente const stats = aiService.getStats(); const conversationsCleared = stats.activeConversations; - // Aquí iría la lógica real de limpieza: - // aiService.clearAllConversations(); + // Usar el nuevo método que mantiene memoria persistente + aiService.clearCache(); // Crear panel de éxito usando objetos planos const successPanel = { diff --git a/src/components/buttons/aiFullReset.ts b/src/components/buttons/aiFullReset.ts index d648375..267c58e 100644 --- a/src/components/buttons/aiFullReset.ts +++ b/src/components/buttons/aiFullReset.ts @@ -22,8 +22,8 @@ export default { const conversationsCleared = statsBefore.activeConversations; const requestsCleared = statsBefore.queueLength; - // Aquí irían las funciones reales de reset del servicio: - // aiService.fullReset(); + // Usar el nuevo método que mantiene memoria persistente + aiService.fullReset(); const resetTimestamp = new Date().toISOString().replace('T', ' ').split('.')[0]; diff --git a/src/core/api/appwrite.ts b/src/core/api/appwrite.ts index 9b49eae..b590652 100644 --- a/src/core/api/appwrite.ts +++ b/src/core/api/appwrite.ts @@ -8,6 +8,7 @@ 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 || ''; let client: Client | null = null; let databases: Databases | null = null; @@ -27,3 +28,7 @@ export function getDatabases(): Databases | null { export function isAppwriteConfigured(): boolean { 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); +} diff --git a/src/core/services/AIService.ts b/src/core/services/AIService.ts index 66cbffc..01237a2 100644 --- a/src/core/services/AIService.ts +++ b/src/core/services/AIService.ts @@ -2,6 +2,7 @@ import { GoogleGenerativeAI, HarmCategory, HarmBlockThreshold } from "@google/ge 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"; // Tipos mejorados para mejor type safety interface ConversationContext { @@ -10,17 +11,22 @@ interface ConversationContext { 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; @@ -28,6 +34,24 @@ interface AIRequest { reject: (error: Error) => void; aiRolePrompt?: string; meta?: string; + messageId?: string; + referencedMessageId?: string; +} + +interface AppwriteConversation { + userId: string; + guildId?: string; + channelId?: string; + conversationId: string; + messages: Array<{ + role: 'user' | 'assistant'; + content: string; + timestamp: number; + messageId?: string; + referencedMessageId?: string; + }>; + lastActivity: number; + createdAt: number; } // Utility function para manejar errores de forma type-safe @@ -39,7 +63,7 @@ function getErrorMessage(error: unknown): string { return error; } if (error && typeof error === 'object' && 'message' in error) { - return String(error.message); + return String((error as any).message); } return 'Error desconocido'; } @@ -225,13 +249,172 @@ export class AIService { } /** - * Procesa una request individual con manejo completo de errores + * 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'; + } + + /** + * 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 } + ): 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, + channelId, + prompt: prompt.trim(), + priority, + timestamp: Date.now(), + resolve, + reject, + aiRolePrompt: options?.aiRolePrompt, + meta: options?.meta, + messageId, + referencedMessageId, + }; + + // 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()); + }); + } + + /** + * Procesa una request individual con manejo completo de errores y memoria persistente */ private async processRequest(request: AIRequest): Promise { try { - const { userId, prompt, guildId } = request; - const context = this.getOrCreateContext(userId, guildId); + const { userId, prompt, guildId, channelId, messageId, referencedMessageId } = request; + const context = await this.getOrCreateContextWithMemory(userId, guildId, channelId); 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); @@ -241,7 +424,7 @@ export class AIService { // Verificar límites de tokens const estimatedTokens = this.estimateTokens(prompt); if (context.totalTokens + estimatedTokens > this.config.maxInputTokens * this.config.tokenResetThreshold) { - this.resetConversation(userId); + this.resetConversation(userId, guildId); logger.info(`Conversación reseteada para usuario ${userId} por límite de tokens`); } @@ -251,13 +434,26 @@ export class AIService { effectiveAiRolePrompt = (await this.getGuildAiPrompt(guildId)) ?? undefined; } + // Obtener jerarquía de roles si está en un servidor + let roleHierarchy = ''; + if (guildId) { + // Necesitamos acceso al cliente de Discord - lo pasaremos desde el comando + 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 const systemPrompt = this.buildSystemPrompt( prompt, context, isImageRequest, effectiveAiRolePrompt, - request.meta + enhancedMeta ); // Usar la API correcta de Google Generative AI @@ -291,11 +487,19 @@ export class AIService { return; } - // Actualizar contexto de forma eficiente - this.updateContext(context, prompt, aiResponse, estimatedTokens, isImageRequest); - + // Actualizar contexto con memoria persistente + await this.updateContextWithMemory( + context, + prompt, + aiResponse, + estimatedTokens, + isImageRequest, + messageId, + referencedMessageId + ); + request.resolve(aiResponse); - + } catch (error) { // Manejo type-safe de errores sin ts-ignore const errorMessage = this.parseAPIError(error); @@ -360,7 +564,7 @@ Responde de forma directa y útil:`; 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, @@ -368,11 +572,11 @@ Responde de forma directa y útil:`; }); return true; } - + if (userLimit.count >= this.config.rateLimitMax) { return false; } - + userLimit.count++; return true; } @@ -386,7 +590,7 @@ Responde de forma directa y útil:`; 'generar imagen', 'create image', 'picture', 'foto', 'ilustración', 'arte', 'pintura', 'sketch' ]; - + const lowerPrompt = prompt.toLowerCase(); return imageKeywords.some(keyword => lowerPrompt.includes(keyword)); } @@ -398,18 +602,18 @@ Responde de forma directa y útil:`; // 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 + * Obtener o crear contexto de conversación (método legacy) */ private getOrCreateContext(userId: string, guildId?: string): ConversationContext { const key = `${userId}-${guildId || 'dm'}`; let context = this.conversations.get(key); - + if (!context) { context = { messages: [], @@ -421,127 +625,149 @@ Responde de forma directa y útil:`; }; this.conversations.set(key, context); } - + context.lastActivity = Date.now(); return context; } /** - * Actualizar contexto de forma eficiente + * Actualizar contexto de forma eficiente (método legacy) */ private updateContext( - context: ConversationContext, - userPrompt: string, - aiResponse: string, + 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 + * Actualizar contexto de forma eficiente con guardado en Appwrite */ - private resetConversation(userId: string, guildId?: string): void { + 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)}`); + }); + } + + /** + * 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'}`; - this.conversations.delete(key); + 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; } /** - * Servicio de limpieza automática + * Limpiar cache pero mantener memoria persistente */ - 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); + public clearCache(): void { + this.conversations.clear(); + this.userCooldowns.clear(); + this.rateLimitTracker.clear(); + this.guildPromptCache.clear(); + logger.info('Cache de AI limpiado, memoria persistente mantenida'); } /** - * Parser mejorado de errores de API - Type-safe sin ts-ignore + * Reset completo pero mantener memoria persistente */ - 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'; + public fullReset(): void { + this.clearCache(); + this.requestQueue.length = 0; + logger.info('AI completamente reseteada, memoria persistente mantenida'); } /** @@ -560,6 +786,137 @@ Responde de forma directa y útil:`; 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 { + const databases = getDatabases(); + if (!databases) return; + + const conversationId = context.conversationId || `${context.userId}-${context.guildId || 'dm'}-${Date.now()}`; + context.conversationId = conversationId; + + const appwriteData: AppwriteConversation = { + userId: context.userId, + guildId: context.guildId, + channelId: context.channelId, + conversationId, + messages: context.messages.map(msg => ({ + role: msg.role, + content: msg.content, + timestamp: msg.timestamp, + messageId: msg.messageId, + referencedMessageId: msg.referencedMessageId + })), + lastActivity: context.lastActivity, + createdAt: Date.now() + }; + + await databases.createDocument( + APPWRITE_DATABASE_ID, + APPWRITE_COLLECTION_AI_CONVERSATIONS_ID, + conversationId, + appwriteData + ); + + 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 { + const databases = getDatabases(); + if (!databases) return null; + + // Buscar conversaciones recientes del usuario + const response = await databases.listDocuments( + APPWRITE_DATABASE_ID, + APPWRITE_COLLECTION_AI_CONVERSATIONS_ID, + [ + `userId=${userId}`, + guildId ? `guildId=${guildId}` : '', + channelId ? `channelId=${channelId}` : '' + ].filter(Boolean) + ); + + if (response.documents.length === 0) { + return null; + } + + // Obtener la conversación más reciente + const latestDoc = response.documents.sort((a: any, b: any) => b.lastActivity - a.lastActivity)[0]; + const data = latestDoc as any as AppwriteConversation; + + // Crear contexto desde los datos de Appwrite + const context: ConversationContext = { + messages: data.messages.map(msg => ({ + ...msg, + tokens: this.estimateTokens(msg.content) + })), + totalTokens: data.messages.reduce((sum, msg) => sum + this.estimateTokens(msg.content), 0), + imageRequests: 0, // Resetear conteo de imágenes + lastActivity: data.lastActivity, + userId: data.userId, + guildId: data.guildId, + channelId: data.channelId, + conversationId: data.conversationId + }; + + logger.debug(`Conversación cargada desde Appwrite: ${data.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 = []; + 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 ''; + } + } } // Instancia singleton diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 39bce18..0f44673 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -4,10 +4,186 @@ 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"; +// 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; + + 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; + } + + 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 { + // 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 + } + + // 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}`); + } + + 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); + + // Procesar con el servicio de AI usando memoria persistente + 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 } + ); + + // 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 = []; + 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 { + await message.channel.send({ content: chunks[i] }); + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + if (chunks.length > 3) { + 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; + + // Manejar respuestas automáticas a la AI + await handleAIReply(message); + await alliance(message); const server = await bot.prisma.guild.upsert({ where: {