zelin-bot / src /brain.js
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 {}
}