Spaces:
Paused
Paused
Z User
v5.8.6: fix greeting spam on restart - 30min grace, channel verification, file fallback
9ef0e78 | /** | |
| * 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 {} | |
| } | |