import logger from "../../../core/lib/logger"; import { CommandMessage } from "../../../core/types/commands"; import { TextChannel, DMChannel, ThreadChannel, ChannelType, GuildEmoji } from "discord.js"; import { aiService } from "../../../core/services/AIService"; /** * 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; } function replaceShortcodesWithEmojisOutsideCode(text: string, emojiMap: Record): string { if (!text) return text; // Split by triple backticks to avoid code blocks const parts = text.split(/```/); for (let i = 0; i < parts.length; i++) { // Only replace in non-code blocks (even indices) if (i % 2 === 0) { // Also avoid inline code wrapped in single backticks by a simple pass const inlineParts = parts[i].split(/`/); for (let j = 0; j < inlineParts.length; j++) { if (j % 2 === 0) { inlineParts[j] = inlineParts[j].replace(/:([a-zA-Z0-9_]{2,32}):/g, (match, p1: string) => { const key = p1; const found = emojiMap[key]; return found ? found : match; }); } } parts[i] = inlineParts.join('`'); } } return parts.join('```'); } async function getGuildCustomEmojis(message: any): Promise<{ names: string[]; map: Record }> { const result = { names: [] as string[], map: {} as Record }; try { const guild = message.guild; if (!guild) return result; // Ensure emojis are fetched const emojis = await guild.emojis.fetch(); const list = Array.from(emojis.values()) as GuildEmoji[]; for (const e of list) { const name = e.name ?? undefined; const id = e.id; if (!name || !id) continue; const tag = e.animated ? `` : `<:${name}:${id}>`; if (!(name in result.map)) { result.map[name] = tag; result.names.push(name); } } // Limit names to 25 for meta context brevity result.names = result.names.slice(0, 25); } catch { // ignore } return result; } function buildMessageMeta(message: any, emojiNames?: string[]): string { try { const parts: string[] = []; const inGuild = !!message.guild; // Canal / hilo if (message.channel) { if (message.channel.type === ChannelType.GuildText) { parts.push(`Canal: #${message.channel.name}`); } else if (message.channel.isThread?.()) { const parent = message.channel.parent as TextChannel | null; const threadName = message.channel.name; const parentName = parent?.name ? ` en #${parent.name}` : ''; parts.push(`Hilo: ${threadName}${parentName}`); } else if (message.channel.type === ChannelType.DM) { parts.push('DM'); } } // Menciones const userMentions = message.mentions?.users ? Array.from(message.mentions.users.values()) : []; const roleMentions = message.mentions?.roles ? Array.from(message.mentions.roles.values()) : []; const channelMentions = message.mentions?.channels ? Array.from(message.mentions.channels.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 (channelMentions.length) { parts.push(`Menciones canal: ${channelMentions.slice(0, 3).map((c: any) => c.name ?? c.id).join(', ')}`); } // ¿Mención al bot? const botId = message.client?.user?.id; if (botId && message.mentions?.users?.has?.(botId)) { parts.push('El mensaje menciona al bot'); } // Respuesta/Referencia if (message.reference?.messageId) { parts.push('Es una respuesta a otro mensaje'); } // Adjuntos const attachments = message.attachments ? Array.from(message.attachments.values()) : []; if (attachments.length) { const info = attachments.slice(0, 2).map((a: any) => a.name || a.contentType || 'adjunto').join(', '); parts.push(`Adjuntos: ${info}`); } 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 ''; } } export const command: CommandMessage = { name: 'ai', type: "message", aliases: ['chat', 'gemini'], 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) => { // Validaciones básicas if (!args || args.length === 0) { await message.reply({ content: '**Uso:** `ai `\n**Ejemplo:** `ai ¿Cómo funciona JavaScript?`\n**Límite:** 4000 caracteres máximo' }); return; } const prompt = args.join(' '); const userId = message.author.id; const guildId = message.guild?.id; // Verificar tipo de canal const channel = message.channel as TextChannel | DMChannel | ThreadChannel; if (!channel || !('send' in channel)) { await message.reply({ content: "❌ **Error:** Este comando no se puede usar en este tipo de canal." }); return; } // Indicador de que está escribiendo const typingInterval = setInterval(() => { channel.sendTyping().catch(() => {}); }, 5000); // Emojis personalizados del servidor const { names: emojiNames, map: emojiMap } = await getGuildCustomEmojis(message); // 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; // Verificar si hay imágenes adjuntas const attachments = Array.from(message.attachments.values()); const hasImages = attachments.length > 0 && aiService.hasImageAttachments(attachments); // Usar el método unificado con memoria persistente y soporte para imágenes aiResponse = await aiService.processAIRequestWithMemory( userId, prompt, guildId, message.channel.id, message.id, referencedMessageId, message.client, 'normal', { meta: messageMeta + (hasImages ? ` | Tiene ${attachments.length} imagen(es) adjunta(s)` : ''), attachments: hasImages ? attachments : undefined } ); // Reemplazar :nombre: por el tag real del emoji, evitando bloques de código if (emojiNames.length > 0) { aiResponse = replaceShortcodesWithEmojisOutsideCode(aiResponse, emojiMap); } // Discord limita el contenido a ~2000 caracteres const MAX_CONTENT = 2000; if (aiResponse.length > MAX_CONTENT) { const chunks = smartChunkText(aiResponse, MAX_CONTENT); for (let i = 0; i < chunks.length && i < 6; i++) { if (i === 0) { await message.reply({ content: chunks[i] }); } else { await channel.send({ content: chunks[i] }); // Pausa entre mensajes para evitar rate limits await new Promise(resolve => setTimeout(resolve, 500)); } } if (chunks.length > 6) { await channel.send({ content: "⚠️ Nota: La respuesta fue truncada. Intenta una pregunta más específica." }); } } else { await message.reply({ content: aiResponse }); } // 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}`); } } catch (error: any) { logger.error(`Error en comando AI para usuario ${userId}:`, error); await message.reply({ content: `❌ Error del Servicio de IA: ${error.message || 'Error desconocido del servicio'}` }); } finally { // Limpiar indicador de escritura clearInterval(typingInterval); } } }