/** * tools.js — Herramientas reales que Zelin puede ejecutar * * La IA pide herramientas antes de responder. * El sistema las ejecuta y devuelve resultados reales. * Así Zelin nunca inventa datos que puede consultar. */ import * as db from './db.js'; import * as minecraft from './minecraft.js'; import * as skills from './skills.js'; import * as brain from './brain.js'; import { readConfig } from './utils.js'; import { findChannelByNameOrId } from './memory.js'; import { webSearch, executeWebTask, getBrowserStatus } from './browser-agent.js'; import { sanitizeOutput } from './security.js'; const config = readConfig(); // ── Definición de tools (va al prompt) ─────────────────────────────────────── export const TOOL_DEFINITIONS = `## HERRAMIENTAS DISPONIBLES Cuando necesites datos reales, incluye al INICIO de tu respuesta: ["tool1", "tool2"] REGLAS: nombre EXACTO, parametro con : (web_search:minecraft noticias), max 3 tools. NO uses tools para cosas que ya sabes. SI usa tools para datos en tiempo real. OBLIGATORIO: para preguntas de hora/tiempo en cualquier país → get_time:país (NUNCA adivines) EJEMPLOS: - "qué hora es en perú" → ["get_time:peru"] - "qué hora es en japón" → ["get_time:japon"] - "cuántos jugadores hay" → ["mc_status"] REGLAS CRÍTICAS DE CUÁNDO NO USAR TOOLS: - NO uses user_info si nadie pregunta por un usuario específico con @ o su nombre exacto - NO uses user_info para palabras genéricas como "chicos", "todos", "banda", "demonios", etc. - Solo usa user_info cuando alguien diga "info de @juan" o "qué hizo tomatito" explícitamente - Si no hay herramienta para algo, simplemente responde conversacionalmente TOOLS DISPONIBLES: mc_status - jugadores online, version, estado del server ahora mismo mc_wiki - buscar en Minecraft Wiki: mc_wiki:creeper o mc_wiki:diamond_sword (OBLIGATORIO para preguntas de Minecraft vanilla) mc_player - datos reales de jugador MC: mc_player:nombre (UUID, skin, historial) discord_online - cuanta gente esta en Discord en este momento db_user_profile - perfil del usuario actual: mensajes, primera vez que habló, info db_top_users - ranking de usuarios mas activos esta semana db_server_stats - stats generales: total usuarios, mensajes, activos hoy db_recent_messages - ultimos mensajes del canal actual db_mod_history - historial de sanciones del usuario actual db_user_list - lista de usuarios activos list_roles - roles del servidor con cuantos miembros tiene cada uno list_members - buscar miembro: list_members:nombre user_info - info completa: user_info:nombre o user_info:@mencion audit_log - acciones admin recientes de Zelin send_to_channel - mandar mensaje: send_to_channel:canal:texto del mensaje read_channel - leer mensajes de canal: read_channel:nombre-canal web_search - buscar en internet: web_search:consulta web_fetch - leer pagina web: web_fetch:https://url.com analyze_image - analizar imagen: analyze_image:https://url.com/img.jpg REGLAS CRÍTICAS DE MINECRAFT: - Para CUALQUIER pregunta sobre Minecraft vanilla (mobs, items, crafting, biomas, enchantments, etc.) → usa mc_wiki OBLIGATORIAMENTE - NUNCA inventes datos de Minecraft — siempre consulta mc_wiki primero - NUNCA inventes jugadores, stats, horas de juego — usa mc_player para datos reales - Si mc_wiki no tiene la respuesta exacta, di 'no estoy segura' en vez de inventar Ejemplos correctos: - Cuantos hay online? -> ["mc_status"] - Qué es un creeper? -> ["mc_wiki:creeper"] - Cómo se crafts una mesa de encantamientos? -> ["mc_wiki:enchanting_table"] - Quién es el jugador X? -> ["mc_player:nombre"] - Que hizo @juan? -> ["user_info:juan", "db_mod_history"] - Novedades Minecraft? -> ["web_search:novedades Minecraft 2025"]`; // Tool call audit log — production best practice (Amazon AI research: every call logged) async function _logToolCall(toolName, result, context) { try { await db.memSet( 'audit.tool.' + Date.now(), { tool: toolName, channel: context?.channelId, user: context?.userId, len: result?.length ?? 0, ts: new Date().toISOString() }, 'tool_audit' ); } catch {} } // ── Ejecutar una herramienta ────────────────────────────────────────────────── // Buscar canal de forma robusta: exact → includes → slug function findChannel(guild, query) { if (!guild || !query) return null; const q = query.toLowerCase().trim(); // Normalizar: quitar emojis, símbolos, espacios extra const normalize = s => s.toLowerCase().replace(/[^\w\d-]/g, '').replace(/-+/g, '-'); const qn = normalize(q); return ( // 1. Nombre exacto guild.channels.cache.find(c => c.isTextBased?.() && c.name.toLowerCase() === q) || // 2. Nombre normalizado exacto (sin emojis/prefijos) guild.channels.cache.find(c => c.isTextBased?.() && normalize(c.name) === qn) || // 3. Contiene el query normalizado guild.channels.cache.find(c => c.isTextBased?.() && normalize(c.name).includes(qn)) || // 4. El query contiene el nombre del canal guild.channels.cache.find(c => c.isTextBased?.() && qn.includes(normalize(c.name)) && normalize(c.name).length > 2) || null ); } // Extraer texto de un mensaje (content + embeds) function extractMessageText(msg) { const parts = []; if (msg.content) parts.push(msg.content); for (const e of msg.embeds || []) { if (e.title) parts.push(`[embed: ${e.title}]`); if (e.description) parts.push(e.description.substring(0, 200)); for (const f of e.fields || []) parts.push(`${f.name}: ${f.value}`.substring(0, 100)); } if (msg.attachments?.size) parts.push(`[adjunto: ${[...msg.attachments.values()].map(a=>a.name).join(', ')}]`); return parts.join(' | ') || '(vacío)'; } export async function executeTool(toolSpec, context) { const { channelId, userId, guild, client } = context; // Parsear tool con parámetros: "send_to_channel:anuncios:Hola" const parts = toolSpec.split(':'); const toolName = parts[0]; const param1 = parts[1] ?? ''; const param2 = parts.slice(2).join(':'); try { switch (toolName) { case 'mc_status': { const s = await minecraft.fetchStatus(); const ip = `${config.server.ip}:${config.server.port}`; if (!s.online) return `Servidor MC: OFFLINE\nIP: ${ip}\nÚltimo fallo: ${s.failCount || 0} intentos`; let r = `Servidor MC: ONLINE\nIP: ${ip}\nJugadores: ${s.players}/${s.max}\nVersión: ${s.version}`; if (s.motd) r += `\nMOTD: ${s.motd}`; if (s.sample?.length) r += `\nConectados: ${s.sample.join(', ')}`; return r; } case 'mc_wiki': { // Buscar información REAL en Minecraft Wiki — NUNCA inventar datos de MC const query = param1 || toolSpec.replace('mc_wiki:', '').trim(); if (!query) return 'Error: especifica qué buscar en la wiki (ej: mc_wiki:creeper)'; try { // Estrategia 1: Minecraft Wiki API (wiki.gg) — fuente oficial const wikiSearchUrl = `https://minecraft.wiki/api.php?action=query&list=search&srsearch=${encodeURIComponent(query)}&format=json&srlimit=3&origin=*`; const searchRes = await fetch(wikiSearchUrl, { signal: AbortSignal.timeout(8000), headers: { 'User-Agent': 'Zelin-Bot/5.8 (TomateSMP Discord Bot)' } }); if (searchRes.ok) { const searchData = await searchRes.json(); const results = searchData.query?.search; if (results?.length > 0) { // Obtener el extracto del primer resultado const topResult = results[0]; const title = topResult.title; // Obtener contenido más detallado const detailUrl = `https://minecraft.wiki/api.php?action=query&titles=${encodeURIComponent(title)}&prop=extracts&exintro=true&explaintext=true&format=json&origin=*`; const detailRes = await fetch(detailUrl, { signal: AbortSignal.timeout(8000), headers: { 'User-Agent': 'Zelin-Bot/5.8 (TomateSMP Discord Bot)' } }); if (detailRes.ok) { const detailData = await detailRes.json(); const pages = detailData.query?.pages; if (pages) { const pageId = Object.keys(pages)[0]; const extract = pages[pageId]?.extract; if (extract) { // Limpiar y truncar extracto const cleaned = extract.replace(/\n{3,}/g, '\n\n').trim(); const truncated = cleaned.length > 1500 ? cleaned.slice(0, 1500) + '...' : cleaned; return `📖 Minecraft Wiki — ${title}\n\n${truncated}\n\n🔗 https://minecraft.wiki/w/${encodeURIComponent(title)}`; } } } // Fallback: usar snippet del search const snippet = topResult.snippet?.replace(/<[^>]+>/g, '').trim(); if (snippet) { return `📖 Minecraft Wiki — ${title}\n${snippet}\n\n🔗 https://minecraft.wiki/w/${encodeURIComponent(title)}`; } } } // Estrategia 2: Fallback a web_search con "minecraft wiki" try { const r = await webSearch(`minecraft wiki ${query}`, { maxResults: 3 }); if (r.results?.length > 0) { const top = r.results[0]; return `📖 Búsqueda MC Wiki: ${query}\n${top.title}\n${top.snippet?.slice(0, 300)}\n🔗 ${top.url}`; } } catch {} return `No encontré "${query}" en la Minecraft Wiki. No invento datos — mejor busca en https://minecraft.wiki`; } catch (e) { return `Error buscando en MC Wiki: ${e.message}`; } } case 'mc_player': { // Datos REALES de jugadores Minecraft via Mojang API — NUNCA inventar const playerName = param1 || toolSpec.replace('mc_player:', '').trim(); if (!playerName) return 'Error: especifica el nombre del jugador (ej: mc_player:Notch)'; try { // Mojang API: obtener UUID y perfil real del jugador const profileUrl = `https://api.mojang.com/users/profiles/minecraft/${encodeURIComponent(playerName)}`; const profileRes = await fetch(profileUrl, { signal: AbortSignal.timeout(8000), headers: { 'User-Agent': 'Zelin-Bot/5.8 (TomateSMP Discord Bot)' } }); if (!profileRes.ok) { if (profileRes.status === 404) { return `El jugador "${playerName}" NO EXISTE en Minecraft. No invento datos de jugadores.`; } return `Error consultando Mojang API (status ${profileRes.status}). No puedo verificar si el jugador existe.`; } const profileData = await profileRes.json(); const uuid = profileData.id; const name = profileData.name; // Formatear UUID con guiones const formattedUuid = uuid.replace(/(\w{8})(\w{4})(\w{4})(\w{4})(\w{12})/, '$1-$2-$3-$4-$5'); // Obtener historial de nombres let nameHistory = ''; try { const namesUrl = `https://api.mojang.com/user/profile/${uuid}/names`; const namesRes = await fetch(namesUrl, { signal: AbortSignal.timeout(5000), headers: { 'User-Agent': 'Zelin-Bot/5.8 (TomateSMP Discord Bot)' } }); if (namesRes.ok) { const namesData = await namesRes.json(); if (namesData.length > 1) { nameHistory = '\nNombres anteriores: ' + namesData.slice(0, -1).map(n => n.name).join(', '); } } } catch {} // Skin info const skinUrl = `https://sessionserver.mojang.com/session/minecraft/profile/${uuid}`; let skinInfo = ''; try { const skinRes = await fetch(skinUrl, { signal: AbortSignal.timeout(5000), headers: { 'User-Agent': 'Zelin-Bot/5.8 (TomateSMP Discord Bot)' } }); if (skinRes.ok) { skinInfo = '\nSkin: ✅ Tiene skin personalizada'; } } catch {} // Verificar si está en el servidor TomateSMP let serverInfo = ''; try { const mcStatus = await minecraft.fetchStatus(); if (mcStatus.online && mcStatus.sample?.includes(name)) { serverInfo = '\n🟢 Actualmente conectado en TomateSMP'; } } catch {} return `🎮 Jugador Minecraft VERIFICADO\nNombre: ${name}\nUUID: ${formattedUuid}\nCuenta: Premium (verificada por Mojang)${nameHistory}${skinInfo}${serverInfo}\n\n⚠️ NOTA: Mojang NO provee horas de juego ni stats públicas. Esas métricas son PRIVADAS y no accesibles. NUNCA inventes datos de horas jugadas.`; } catch (e) { return `Error consultando datos del jugador: ${e.message}. No invento datos.`; } } case 'discord_online': { if (!guild) return 'No hay guild en contexto'; const stats = brain.getGuildStats(guild); return `Discord online ahora: ${stats.online}/${stats.total} miembros\nServidor: ${stats.name}`; } case 'db_user_profile': { if (!userId) return 'Sin usuario en contexto'; const u = await db.getUserWithRoles(userId); if (!u) return 'Usuario no encontrado en DB'; const name = u.nickname || u.global_name || u.username; const roles = u.roles?.join(', ') || 'sin roles'; const since = u.joined_at ? new Date(u.joined_at).toLocaleDateString('es-ES') : '?'; return `Perfil: ${name}\nUsername: ${u.username}\nRoles: ${roles}\nMensajes: ${u.message_count || 0}\nEn el servidor desde: ${since}${u.notes ? `\nNotas: ${u.notes}` : ''}`; } case 'db_top_users': { const top = await db.getTopUsers(7, 5); if (!top.length) return 'Sin datos de actividad esta semana'; return 'Más activos esta semana:\n' + top.map((u,i)=>`${i+1}. ${u.nickname||u.username}: ${u.count} msgs`).join('\n'); } case 'db_server_stats': { const s = await db.getServerStats(); return `Stats:\nUsuarios activos: ${s.activeUsers}\nMensajes totales: ${s.totalMessages}\nActivos hoy: ${s.activeToday}\nAcciones mod: ${s.modActions}`; } case 'db_recent_messages': { if (!channelId) return 'Sin canal en contexto'; const msgs = await db.getRecentMessages(channelId, 15); if (!msgs.length) return 'No hay mensajes recientes'; return 'Últimos mensajes:\n' + msgs.map(m=>`[${m.nickname||m.username}]: ${m.content}`).join('\n'); } case 'db_mod_history': { if (!userId) return 'Sin usuario en contexto'; const hist = await db.getUserModHistory(userId, 5); if (!hist.length) return 'Sin historial de moderación'; return 'Historial mod:\n' + hist.map(h=>`- ${h.action}: ${h.reason} (${new Date(h.created_at).toLocaleDateString('es-ES')})`).join('\n'); } case 'db_user_list': { const r = await db.db.execute( `SELECT username, nickname, message_count FROM users WHERE is_active = 1 AND bot = 0 ORDER BY message_count DESC LIMIT 20` ); if (!r.rows.length) return 'No hay usuarios en la DB'; return 'Usuarios activos:\n' + r.rows.map(u=>`- ${u.nickname||u.username} (${u.message_count||0} msgs)`).join('\n'); } case 'db_recent_logs': { const r = await db.db.execute( `SELECT key, value FROM zelin_memory WHERE category = 'logs' ORDER BY key DESC LIMIT 10` ); if (!r.rows.length) return 'Sin logs recientes procesados'; return 'Logs recientes:\n' + r.rows.map(row => { try { const d = JSON.parse(row.value); return `[${d.channel}] ${d.text?.substring(0,100)}`; } catch { return row.value?.substring(0,100); } }).join('\n'); } case 'web_search': { const query = param1 || toolSpec.replace('web_search:', '').trim(); if (!query) return 'Error: especifica qué buscar'; try { const r = await webSearch(query, { maxResults: 4 }); if (r.error) return 'CAPTCHA detectado, no se pudo buscar'; const formatted = r.results.map((res, i) => `${i+1}. **${res.title}**\n${res.snippet?.slice(0, 200)}\n${res.url}` ).join('\n\n'); return formatted || 'Sin resultados'; } catch(e) { return 'Error al buscar: ' + e.message; } } case 'web_fetch': { const url = param1 || toolSpec.replace('web_fetch:', '').trim(); if (!url || !url.startsWith('http')) return 'Error: URL inválida'; try { const r = await executeWebTask(url); const content = r.data?.content?.text?.slice(0, 1500) ?? 'Sin contenido'; return `Título: ${r.data?.title}\n\nContenido:\n${content}`; } catch(e) { return 'Error: ' + e.message; } } case 'get_time': { const country = param1 || 'mexico'; const timezones = { 'mexico': 'America/Mexico_City', 'peru': 'America/Lima', 'colombia': 'America/Bogota', 'argentina': 'America/Buenos_Aires', 'chile': 'America/Santiago', 'españa': 'Europe/Madrid', 'espana': 'Europe/Madrid', 'japon': 'Asia/Tokyo', 'usa': 'America/New_York', 'eeuu': 'America/New_York', 'venezuela': 'America/Caracas', 'ecuador': 'America/Guayaquil', 'bolivia': 'America/La_Paz', 'cubano': 'America/Havana', 'cuba': 'America/Havana', }; const tz = timezones[country.toLowerCase()] ?? 'America/Mexico_City'; try { const now = new Date().toLocaleString('es-ES', { timeZone: tz, weekday: 'long', hour: '2-digit', minute: '2-digit', day: 'numeric', month: 'long' }); return `Hora en ${country}: ${now}`; } catch { return `Hora en ${country}: ${new Date().toLocaleString('es-ES')}`; } } case 'analyze_image': { const imageUrl = param1 || toolSpec.replace('analyze_image:', '').trim(); if (!imageUrl || !imageUrl.startsWith('http')) return 'Error: URL de imagen inválida'; try { const { analyzeUserImage } = await import('./vision-agent.js'); if (typeof analyzeUserImage === 'function') { const result = await analyzeUserImage(imageUrl, 'Describe esta imagen'); return result || 'No se pudo analizar la imagen'; } return 'Análisis de imágenes no disponible'; } catch(e) { return 'Error analizando imagen: ' + e.message; } } case 'skills_list': { const list = skills.listSkills(); if (!list.length) return 'No hay skills instalados'; return 'Skills activos:\n' + list.map(s=>`- ${s.name}: ${s.description} (triggers: ${s.triggers.join(', ')})`).join('\n'); } case 'send_to_channel': { if (!guild || !param1 || !param2) return 'Uso: send_to_channel:nombre-canal:mensaje'; const ch = findChannel(guild, param1); if (!ch) { const available = guild.channels.cache.filter(c=>c.isTextBased?.()).map(c=>c.name).slice(0,15).join(', '); return `Canal "${param1}" no encontrado. Disponibles: ${available}`; } try { const safeContent = sanitizeOutput(param2.substring(0, 1900)); await ch.send(safeContent); return `✅ Mensaje enviado en #${ch.name}`; } catch (e) { return `Error al enviar: ${e.message}`; } } case 'read_channel': { if (!guild || !param1) return 'Uso: read_channel:nombre-canal'; const ch = findChannel(guild, param1); if (!ch) { // Listar canales disponibles para que la IA sepa los nombres reales const available = guild.channels.cache .filter(c => c.isTextBased?.()) .map(c => c.name) .slice(0, 20) .join(', '); return `Canal "${param1}" no encontrado. Canales disponibles: ${available}`; } try { const msgs = await ch.messages.fetch({ limit: 12 }); if (!msgs.size) return `#${ch.name} está vacío`; const sorted = [...msgs.values()].sort((a,b) => a.createdTimestamp - b.createdTimestamp); return `Últimos mensajes de #${ch.name}:\n` + sorted.map(m => { const who = m.member?.nickname || m.author?.username || '?'; const when = new Date(m.createdTimestamp).toLocaleTimeString('es-ES', {hour:'2-digit',minute:'2-digit'}); return `[${when}] [${who}]: ${extractMessageText(m)}`; }).join('\n'); } catch (e) { return `Sin acceso a #${ch.name}: ${e.message}`; } } case 'list_roles': { if (!guild) return 'Sin guild en contexto'; const roles = guild.roles.cache .sort((a,b) => b.position - a.position) .map(r => `${r.name} (${r.members.size} miembros${r.mentionable ? ', mencionable' : ''})`) .slice(0, 25); return 'Roles del servidor:\n' + roles.join('\n'); } case 'list_members': { if (!guild) return 'Sin guild en contexto'; const q = param1.toLowerCase(); let members; if (q) { members = guild.members.cache.filter(m => !m.user.bot && ( m.user.username.toLowerCase().includes(q) || (m.nickname ?? '').toLowerCase().includes(q) ) ); } else { members = guild.members.cache.filter(m => !m.user.bot); } const list = [...members.values()].slice(0, 20) .map(m => `${m.displayName} (ID: ${m.id}) — ${m.roles.cache.map(r => r.name).filter(n => n !== '@everyone').join(', ') || 'sin roles'}`); return list.length ? 'Miembros:\n' + list.join('\n') : 'Nadie encontrado'; } case 'user_info': { // Buscar usuario por nombre o ID if (!guild || !param1) return 'Uso: user_info:nombre-o-id'; // Guard: palabras genéricas que NO son usuarios const GENERIC_WORDS = new Set([ 'chicos','todos','banda','gente','alguien','nadie','demonios','internos', 'error','comando','usuario','server','servidor','bot','hola','qtal','buenas', 'hell','what','the','que','esto','eso','aqui','ahi','bien','mal','si','no', 'xd','lol','jaja','ok','vale','claro','exacto','sip','nop','vro','bro', ]); if (GENERIC_WORDS.has(param1.toLowerCase())) { return `(palabra genérica "${param1}" — no es un usuario, ignora este resultado y responde conversacionalmente)`; } const q = param1.toLowerCase(); const member = guild.members.cache.find(m => m.id === param1 || m.user.username.toLowerCase().includes(q) || (m.nickname ?? '').toLowerCase().includes(q) ); if (!member) return `(usuario "${param1}" no existe en el servidor — no menciones este error al usuario, simplemente responde al mensaje original)`; const roles = member.roles.cache.filter(r => r.name !== '@everyone').map(r => r.name).join(', ') || 'ninguno'; const joined = member.joinedAt ? new Date(member.joinedAt).toLocaleDateString('es-ES') : '?'; const timeout = member.communicationDisabledUntil ? `en timeout hasta ${new Date(member.communicationDisabledUntil).toLocaleString('es-ES')}` : 'sin timeout'; return `**${member.displayName}**\nID: ${member.id}\nUsername: ${member.user.username}\nRoles: ${roles}\nEntró: ${joined}\nEstado: ${timeout}`; } case 'audit_log': { // Ver acciones admin recientes de Zelin try { const r = await db.db.execute({ sql : "SELECT value FROM zelin_memory WHERE category = 'audit_log' ORDER BY key DESC LIMIT 10", args: [], }); if (!r.rows.length) return 'Sin acciones admin registradas'; return 'Últimas acciones admin:\n' + r.rows.map(row => { try { const d = typeof row.value === 'string' ? JSON.parse(row.value) : row.value; return `• **${d.action}** — ${JSON.stringify(d.details)} (${d.timestamp?.substring(0,16)})`; } catch { return String(row.value).substring(0, 100); } }).join('\n'); } catch (e) { return 'Error leyendo audit log: ' + e.message; } } default: return `Herramienta desconocida: ${toolName}`; } } catch (err) { return `Error ejecutando ${toolName}: ${err.message}`; } } // ── Parsear tools del response de la IA ────────────────────────────────────── export function parseToolRequests(text) { const match = text.match(/([\s\S]*?)<\/tools>/); if (!match) return null; try { const list = JSON.parse(match[1]); return Array.isArray(list) ? list : null; } catch { return null; } } export function stripToolBlock(text) { // Eliminar bloque cerrado let clean = text.replace(/[\s\S]*?<\/tools>\s*/g, ''); // Eliminar bloque sin cerrar (modelo olvidó el closing tag) clean = clean.replace(/[\s\S]*/g, ''); // Eliminar cualquier tag suelto residual clean = clean.replace(/<\/?tools>/g, ''); return clean.trim(); } export async function executeToolRequests(toolNames, context) { const results = []; for (const name of toolNames.slice(0, 5)) { try { // Timeout de 8s por herramienta — ninguna puede colgar el procesamiento const result = await Promise.race([ executeTool(name, context), new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 8000)), ]); results.push(`[${name.split(':')[0]}]\n${result}`); } catch (err) { results.push(`[${name.split(':')[0]}]\nError: ${err.message}`); } } return results.join('\n\n'); }