Spaces:
Paused
Paused
| /** | |
| * 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: | |
| <tools>["tool1", "tool2"]</tools> | |
| 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ú" → <tools>["get_time:peru"]</tools> | |
| - "qué hora es en japón" → <tools>["get_time:japon"]</tools> | |
| - "cuántos jugadores hay" → <tools>["mc_status"]</tools> | |
| 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? -> <tools>["mc_status"]</tools> | |
| - Qué es un creeper? -> <tools>["mc_wiki:creeper"]</tools> | |
| - Cómo se crafts una mesa de encantamientos? -> <tools>["mc_wiki:enchanting_table"]</tools> | |
| - Quién es el jugador X? -> <tools>["mc_player:nombre"]</tools> | |
| - Que hizo @juan? -> <tools>["user_info:juan", "db_mod_history"]</tools> | |
| - Novedades Minecraft? -> <tools>["web_search:novedades Minecraft 2025"]</tools>`; | |
| // 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(/<tools>([\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(/<tools>[\s\S]*?<\/tools>\s*/g, ''); | |
| // Eliminar bloque sin cerrar (modelo olvidó el closing tag) | |
| clean = clean.replace(/<tools>[\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'); | |
| } |