/** * brain.js — Cerebro Autónomo de Zelin v5.8 * FIX: getMainChannel ahora excluye "chat-staff" y otros canales privados * que matcheaban por contener "chat" en el nombre. */ import { callAI, callAIBackground } from './ai.js'; import { sanitizeOutput } from './security.js'; import { humanize } from './humanizer.js'; import * as db from './db.js'; import * as minecraft from './minecraft.js'; import * as memory from './memory.js'; import { findChannelByNameOrId, getChannelRegistry } from './memory.js'; import { getStateSnapshot } from './psyche.js'; import fs from 'fs'; import path from 'path'; let client = null; let brainTimer = null; let greetedMorning = ''; let greetedNight = ''; let _startupTime = Date.now(); // Track when brain started — no greetings in first 30 min const GREETING_GRACE_PERIOD = 30 * 60 * 1000; // 30 minutes after startup — no greetings (was 5min, caused spam on restarts) const GREETING_FILE = '/tmp/zelin-greeting-state.json'; // File-based fallback for greeting state const cooldowns = new Map(); function canDo(action, minMs) { const last = cooldowns.get(action) ?? 0; if (Date.now() - last < minMs) return false; cooldowns.set(action, Date.now()); return true; } export function startBrain(discordClient) { client = discordClient; _startupTime = Date.now(); // Load greeting state from DB so we don't re-greet after restarts _loadGreetingState(); brainTimer = setInterval(tick, 60 * 1000); setTimeout(tick, 45 * 1000); console.log('[Brain] Cerebro autónomo iniciado (greeting state loaded from DB)'); } // ── Persist greeting state in DB ───────────────────────────────────────────── // This prevents Zelin from sending "buenos días" / "buenas noches" every time // it restarts (which was causing spam on HF Spaces) async function _loadGreetingState() { // 1. Try loading from DB first try { const morning = await db.memGet('brain.greeted_morning'); const night = await db.memGet('brain.greeted_night'); if (morning) greetedMorning = morning; if (night) greetedNight = night; console.log(`[Brain] Greeting state loaded from DB: morning=${greetedMorning}, night=${greetedNight}`); } catch (e) { console.warn('[Brain] Could not load greeting state from DB:', e.message); } // 2. Fallback: try file-based state (survives restarts even if DB is down) if (!greetedMorning || !greetedNight) { try { if (fs.existsSync(GREETING_FILE)) { const fileState = JSON.parse(fs.readFileSync(GREETING_FILE, 'utf8')); const today = new Date().toISOString().split('T')[0]; if (!greetedMorning && fileState.greetedMorning === today) greetedMorning = today; if (!greetedNight && fileState.greetedNight === today) greetedNight = today; console.log(`[Brain] Greeting state loaded from file fallback: morning=${greetedMorning}, night=${greetedNight}`); } } catch (e) { console.warn('[Brain] Could not load greeting state from file:', e.message); } } // 3. Last resort: if today is already greeted (from any source), we're good const today = new Date().toISOString().split('T')[0]; console.log(`[Brain] Final greeting state: morning=${greetedMorning === today ? 'TODAY' : greetedMorning || 'never'}, night=${greetedNight === today ? 'TODAY' : greetedNight || 'never'}`); } async function _saveGreetingState() { // 1. Save to DB try { if (greetedMorning) await db.memSet('brain.greeted_morning', greetedMorning, 'brain'); if (greetedNight) await db.memSet('brain.greeted_night', greetedNight, 'brain'); } catch (e) { console.warn('[Brain] Could not save greeting state to DB:', e.message); } // 2. Also save to file as fallback (survives restarts even if DB is down) try { const state = { greetedMorning, greetedNight, savedAt: Date.now() }; fs.writeFileSync(GREETING_FILE, JSON.stringify(state)); } catch (e) { console.warn('[Brain] Could not save greeting state to file:', e.message); } } export function stopBrain() { if (brainTimer) { clearInterval(brainTimer); brainTimer = null; } } async function zelinDominatesChannel(channel, lastN = 5) { try { const msgs = await channel.messages.fetch({ limit: lastN }); const sorted = [...msgs.values()].sort((a,b) => b.createdTimestamp - a.createdTimestamp); const zelinId = client?.user?.id; if (sorted[0]?.author.id === zelinId) return true; const zelinCount = sorted.filter(m => m.author.id === zelinId).length; return zelinCount >= Math.ceil(lastN / 2); } catch { return false; } } // ── Check if Zelin already sent a greeting today in the channel ──────────────── // This is a hard safety net: even if DB/file state fails, we check the channel async function alreadyGreetedToday(channel, type) { try { const msgs = await channel.messages.fetch({ limit: 30 }); const zelinId = client?.user?.id; const today = new Date(); today.setHours(0, 0, 0, 0); const greetingPatterns = type === 'good_morning' ? /buenos\s*di[as]|good\s*morning|buenas\s*mañana/i : /buenas\s*noches|good\s*night/i; for (const msg of msgs.values()) { if (msg.author.id !== zelinId) continue; if (msg.createdTimestamp < today.getTime()) continue; // only today's messages if (greetingPatterns.test(msg.content)) return true; } return false; } catch { return false; } // if we can't check, allow greeting (safer than blocking) } async function tick() { try { const guild = client?.guilds.cache.first(); if (!guild) return; const now = new Date(); const hour = now.getUTCHours() + 1; const onlineCount = getOnlineCount(guild); const mcStatus = minecraft.getServerStatus(); // FIX: No greetings within grace period after startup — prevents spam on restart const uptimeMs = Date.now() - _startupTime; const canGreet = uptimeMs >= GREETING_GRACE_PERIOD; const today = now.toISOString().split('T')[0]; if (canGreet && today !== greetedMorning) { if (hour >= 9 && hour <= 11 && canDo('good_morning', 20 * 60 * 60 * 1000)) { // Triple safety check: verify Zelin hasn't already greeted today in channel const mainCh = getMainChannel(guild); const alreadyGreeted = mainCh ? await alreadyGreetedToday(mainCh, 'good_morning') : false; if (alreadyGreeted) { console.log('[Brain] Already greeted morning today in channel — skipping (anti-spam)'); greetedMorning = today; await _saveGreetingState(); } else { greetedMorning = today; await _saveGreetingState(); await sendBrainMessage(guild, 'chat', 'good_morning', { hour, onlineCount, mcStatus }); } } } if (canGreet && today !== greetedNight) { if ((hour >= 23 || hour <= 1) && canDo('good_night', 20 * 60 * 60 * 1000)) { // Triple safety check: verify Zelin hasn't already greeted today in channel const mainCh = getMainChannel(guild); const alreadyGreeted = mainCh ? await alreadyGreetedToday(mainCh, 'good_night') : false; if (alreadyGreeted) { console.log('[Brain] Already greeted night today in channel — skipping (anti-spam)'); greetedNight = today; await _saveGreetingState(); } else { greetedNight = today; await _saveGreetingState(); await sendBrainMessage(guild, 'chat', 'good_night', { hour, onlineCount }); } } } if (onlineCount >= 3 && canDo('revival', 45 * 60 * 1000)) { const mainCh = getMainChannel(guild); if (mainCh) { const lastMsgTime = await getLastMessageTime(mainCh); const silentMs = Date.now() - lastMsgTime; if (silentMs > 30 * 60 * 1000 && silentMs < 4 * 60 * 60 * 1000) { const dominated = await zelinDominatesChannel(mainCh, 3); if (!dominated) { const pingMention = await getPingRoleMention(guild); await sendBrainMessage(guild, mainCh, 'revival', { onlineCount, mcStatus, silentMs, pingMention }); } } } } const prevMcKey = await db.memGet('brain.prev_mc_status').catch(() => null); const mcKey = mcStatus.online ? `online_${mcStatus.players}` : 'offline'; if (prevMcKey !== mcKey && mcStatus.online && mcStatus.players > 0) { if (canDo('mc_comment', 20 * 60 * 1000)) { await sendBrainMessage(guild, 'chat', 'mc_players', { mcStatus }); } } await db.memSet('brain.prev_mc_status', mcKey, 'brain').catch(() => {}); const spontCooldownMs = await db.memGet('brain.spontaneous_cooldown_ms').catch(() => null) ?? 15 * 60 * 1000; if (canDo('spontaneous', spontCooldownMs)) { await maybeSpeakSpontaneously(guild, onlineCount, mcStatus); } // ── YouTube lifecycle evaluation ───────────────────────────────────────── // Check if it's time to record, edit, or upload a video if (canDo('youtube_lifecycle', 60 * 60 * 1000)) { // once per hour max await evaluateYouTubeLifecycle(guild).catch(() => {}); } } catch (err) { console.error('[Brain] Error en tick:', err.message); } } async function maybeSpeakSpontaneously(guild, onlineCount, mcStatus) { if (onlineCount < 2) return; const mainCh = getMainChannel(guild); if (!mainCh) return; const dominated = await zelinDominatesChannel(mainCh, 6); if (dominated) return; try { const recentMsgs = await mainCh.messages.fetch({ limit: 10 }).catch(() => null); if (!recentMsgs || !recentMsgs.size) return; const sorted = [...recentMsgs.values()].sort((a,b) => a.createdTimestamp - b.createdTimestamp); const msgCtx = sorted.map(m => `${m.member?.displayName || m.author.username}: ${m.content || '(sin texto)'}`).join('\n'); const lastMsgTime = sorted[sorted.length - 1]?.createdTimestamp ?? 0; const silentMin = Math.round((Date.now() - lastMsgTime) / 60000); const decisionRaw = await callAIBackground([ { role: 'system', content: `Eres el sistema de decisión de Zelin. Decides si Zelin debe intervenir ahora en el chat. REGLAS DURAS: - Si el último mensaje es de Zelin: NUNCA hablar (hablar=false) - Si hay conversación activa entre usuarios (último msg < 5min): NO intervenir salvo algo muy bueno - Si el chat lleva mucho tiempo muerto (>20min) y hay gente online: PUEDE hablar - Solo hablar si hay algo GENUINO que aportar - NO hablar si el chat va bien sin ti Contexto: ${onlineCount} online en Discord | MC: ${mcStatus?.online ? mcStatus.players + ' jugadores' : 'offline'} | Silencio: ${silentMin}min Últimos mensajes: ${msgCtx || '(vacío)'} Responde SOLO JSON: {"hablar": true/false, "tipo": "pregunta|dato|gracioso|mc|debate", "tema": "en qué basar el mensaje"}` }, { role: 'user', content: 'decide' }, ], 'fast', 100, 'decide'); let decision; try { const clean = decisionRaw.replace(/```json|```/g, '').trim(); decision = JSON.parse(clean.slice(clean.indexOf('{'), clean.lastIndexOf('}') + 1)); } catch { return; } if (!decision.hablar) return; const msgRaw = await callAIBackground([ { role: 'system', content: `Eres Zelin, la IA de TomateSMP. Escribe UN mensaje espontáneo para el canal de chat. Estilo: informal, minúsculas, máx 2 líneas, sin emojis de relleno. Tipo de mensaje: ${decision.tipo} — Tema base: ${decision.tema} No empieces con "ola" ni "hola". No uses asteriscos ni emotes entre *. Contexto del chat: ${msgCtx}` }, { role: 'user', content: 'escribe el mensaje' }, ], 'chat', 120, decision.tema); const safe = sanitizeOutput(humanize(msgRaw)); if (safe && safe.length > 3) { await mainCh.send(safe); console.log(`[Brain] 💬 Espontáneo (${decision.tipo}) en #${mainCh.name}`); } } catch { /* silencioso */ } } // ── YouTube Lifecycle Evaluation ────────────────────────────────────────────── async function evaluateYouTubeLifecycle(guild) { const psyche = getStateSnapshot(); const hour = new Date().getUTCHours() + 1; // Import youtube-life module let ytLife = null; try { ytLife = await import('./youtube-life.js'); } catch { /* youtube-life not available */ } if (!ytLife) { // Fallback: use the old inline logic if (hour >= 15 && hour <= 22 && psyche.energy > 0.5) { try { const ytLifeOld = await import('./youtube-life.js').catch(() => null); if (ytLifeOld?.shouldStartRecording?.()) { console.log('[Brain] YouTube: Starting recording session'); ytLifeOld.startRecordingSession?.().catch(() => {}); } } catch { /* youtube-life not available */ } } return; } // Use youtube-life.js tick() — drives the lifecycle forward try { await ytLife.tick?.(); } catch (e) { console.warn('[Brain] YouTube lifecycle tick error:', e.message); } } async function sendBrainMessage(guild, channelHint, type, ctx) { let channel; if (typeof channelHint === 'string') { // Primero usar el ChannelRegistry (tiene IDs reales, ignora emojis) const reg = findChannelByNameOrId(channelHint); if (reg) { channel = guild.channels.cache.get(reg.id); } else { // Fallback: búsqueda por nombre parcial channel = guild.channels.cache.find(c => c.isTextBased?.() && c.name.includes(channelHint)); } } else { channel = channelHint; } if (!channel) return; const dominated = await zelinDominatesChannel(channel, 3); if (dominated && type !== 'good_morning' && type !== 'good_night') return; const prompt = await buildAutonomousPrompt(type, ctx, guild); try { const raw = await callAIBackground([ { role: 'system', content: prompt }, { role: 'user', content: 'genera el mensaje' }, ], 'chat', 150, 'genera el mensaje'); const safe = sanitizeOutput(humanize(raw)); if (!safe || safe.length < 3) return; const sentMsg = await channel.send(safe); await trackBrainMsg(sentMsg, type); console.log(`[Brain] ✅ Mensaje (${type}) en #${channel.name}`); } catch (err) { console.error(`[Brain] Error en sendBrainMessage (${type}):`, err.message); } } async function buildAutonomousPrompt(type, ctx, guild) { const base = `Eres Zelin, la IA de TomateSMP. Escribes un mensaje por tu cuenta. Estilo: natural, informal, minúsculas. 1-2 líneas. Sin @everyone. Sin asteriscos. Sin emotes entre *. Servidor: ${guild?.name || 'TomateSMP'}`; const mc = ctx.mcStatus; const mcStr = mc?.online ? `MC online con ${mc.players}/${mc.max} jugadores` : 'MC offline'; switch (type) { case 'good_morning': { // Obtener stats reales de la DB let statsStr = ''; try { const recent = await db.getRecentMessages(null, 20).catch(() => []); const activeUsers = new Set(recent.map(m => m.user_id).filter(Boolean)).size; statsStr = activeUsers > 0 ? ` ${activeUsers} usuarios activos recientemente.` : ''; } catch {} return `${base}\n\nEs por la mañana (~${ctx.hour}h), ${ctx.onlineCount} personas online en Discord.${statsStr} ${mcStr}.\nDa los buenos días de forma espontánea y natural. Corto, 1-2 líneas. Sin "buenos días" genérico — di algo específico del servidor.`; } case 'good_night': return `${base}\n\nEs de noche (~${ctx.hour}h), ${ctx.onlineCount} personas online.\nBuenas noches tranquilas. Corto.`; case 'revival': { const silentMin = Math.round((ctx.silentMs || 0) / 60000); const ping = ctx.pingMention || ''; return `${base}\n\nEl chat lleva ${silentMin} minutos muerto pero hay ${ctx.onlineCount} online. ${mcStr}.\n${ping ? 'Puedes usar este ping: ' + ping : ''}\nRevive el chat: pregunta, reto, dato del servidor, tema de debate.`; } case 'mc_players': return `${base}\n\n${mcStr}. Comenta esto brevemente, de forma natural.`; default: return `${base}\n\nDi algo interesante o útil para el servidor.`; } } export function getOnlineCount(guild) { if (!guild) return 0; let count = 0; for (const [, member] of guild.members.cache) { if (!member.user.bot && member.presence?.status && member.presence.status !== 'offline') count++; } return count; } // FIX: getMainChannel ahora excluye "staff", "admin", "mod" aunque tengan "chat" en el nombre function getMainChannel(guild) { const EXCLUDE = ['log', 'staff', 'bot', 'ticket', 'anunci', 'rule', 'normat', 'mod', 'admin', 'helper']; const isExcluded = (name) => EXCLUDE.some(ex => name.includes(ex)); // Primero buscar exactamente "general" o "chat-general" o "principal" return ( guild.channels.cache.find(c => c.isTextBased?.() && !isExcluded(c.name) && (c.name === 'general' || c.name === 'chat-general' || c.name === 'principal' || c.name === 'chat') ) || // Luego cualquiera que contenga "general" y no esté excluido guild.channels.cache.find(c => c.isTextBased?.() && !isExcluded(c.name) && c.name.includes('general') ) || // Fallback: primer canal de texto no excluido guild.channels.cache.find(c => c.isTextBased?.() && !isExcluded(c.name) ) ); } async function getLastMessageTime(channel) { try { const msgs = await channel.messages.fetch({ limit: 1 }); if (!msgs.size) return 0; return msgs.first().createdTimestamp; } catch { return 0; } } async function getPingRoleMention(guild) { const r = guild.roles.cache.find(r => { const n = r.name.toLowerCase(); return n.includes('revivir') || n === 'ping' || n.includes('ping revivir'); }); return r ? `<@&${r.id}>` : ''; } export async function processLogEmbed(message) { if (!message.embeds?.length) return; const channelName = message.channel?.name ?? ''; if (!channelName.includes('log')) return; for (const embed of message.embeds) { try { const text = [embed.title, embed.description, ...(embed.fields ?? []).map(f => `${f.name}: ${f.value}`)].filter(Boolean).join(' | '); if (!text) continue; await db.memSet(`log.${Date.now()}.${message.channelId}`, { channel: channelName, text: text.substring(0, 500), timestamp: new Date().toISOString() }, 'logs').catch(() => {}); } catch {} } } export function getGuildStats(guild) { if (!guild) return null; const online = getOnlineCount(guild); const total = guild.members.cache.filter(m => !m.user.bot).size; return { online, total, name: guild.name }; } async function trackBrainMsg(msg, type) { try { const { trackZelinMessage } = await import('./learning.js'); trackZelinMessage(msg, type); } catch {} }