import fs from 'node:fs/promises'; import {fileURLToPath} from 'node:url'; import path from 'node:path'; import readline from 'node:readline'; import {conectarEventos} from './eventos.mjs'; import {generarRespuesta,generarIntervencionAutonoma} from './ia.mjs'; import {sintetizarYReproducir} from './voz.mjs'; import {cargarAplicaciones,resumenAplicaciones} from './aplicaciones.mjs'; import {accionarAvatar} from './avatar.mjs'; import {elegirAccionIdle,puedeMoverseIdle} from './idle.mjs'; import {elegirTema,siguienteEsperaMs,puedeIniciarConversacion} from './conversacion.mjs'; import {crearOverlay} from './overlay.mjs'; import {clean,isBlocked,shouldAnswer,safeName,sleep} from './seguridad.mjs'; const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');process.chdir(root); const config=JSON.parse(await fs.readFile('config.json','utf8')); const demo=process.argv.includes('--demo'); const apps=await cargarAplicaciones(); const idleTopics=JSON.parse(await fs.readFile('data/temas.json','utf8')); const overlay=await crearOverlay(config); let demoReadline=null; let busy=false,stopping=false,lastSpeechAt=0,lastChatAt=0,lastJoinAt=0,lastEventAt=0,firstRealEvent=false; const startupAt=Date.now(); const userChatTimes=new Map(),queue=[]; let lastIdleAction=''; const autoConfig=config.autoConversation||{}; const temasRecientes=[]; let proximaConversacion=Date.now()+Math.max(15,Number(autoConfig.minSilenceSeconds)||60)*1000; const info=(...args)=>console.log(new Date().toLocaleTimeString('es-MX'),...args); const emitApp=()=>overlay.publish('app',{text:`Aplicaciones en Google Play: ${config.apps.developerName} · ${config.apps.developerProfileUrl}`}); const timed=(ms)=>Math.max(0,ms); function enqueue(item){ if(stopping)return; if(queue.length>=config.behavior.maxQueue){info('[COLA] Llena: se descartó una intervención.');return;} queue.push(item);void drain(); } async function drain(){ if(busy||stopping)return;busy=true; try{while(queue.length&&!stopping){ const item=queue.shift(); const remaining=config.behavior.speechCooldownMs-(Date.now()-lastSpeechAt); if(remaining>0)await sleep(remaining); if(stopping)break; let text=item.text; if(item.type==='chat') { try{text=await generarRespuesta({config,comentario:item.comment,usuario:item.user,apps});} catch(error){info('[IA] No disponible:',error.message);text=`Gracias por comentar, ${item.user}. Ahora mismo tengo un problema técnico para responder con IA.`;} } else if(item.type==='auto') { try{text=await generarIntervencionAutonoma({config,tema:item.topic,apps});} catch(error){info('[IA AUTÓNOMA] No disponible:',error.message);text=item.topic;} // Nunca interrumpas a un espectador para continuar con una intervención espontánea. if(lastEventAt>item.createdAt || queue.length>0){info('[AUTO] Cancelado: llegó una interacción.');continue;} } text=clean(text,config.behavior.maxSpeechChars); if(!text)continue; info('[LUNA]',text); overlay.publish('speech',{user:item.user||'',text,duration:Math.min(19000,Math.max(6500,text.length*88))}); if(item.avatar)await accionarAvatar(config,item.avatar,{user:item.user||'Luna'}); await accionarAvatar(config,'talkStart',{user:item.user||'Luna'}); try{if(demo){info('[DEMO] Voz no ejecutada. Para probar TTS: .\\scripts\\probar_voz.ps1');}else await sintetizarYReproducir({config,texto:text});} catch(error){info('[VOZ] Error:',error.message);} finally{await accionarAvatar(config,'talkEnd',{});lastSpeechAt=Date.now();} }}finally{busy=false;if(queue.length&&!stopping)void drain();} } function handle(event){ if(!demo){ // Los comentarios y regalos tienen prioridad sobre una conversación programada. for(let i=queue.length-1;i>=0;i--)if(queue[i].type==='auto')queue.splice(i,1); lastEventAt=Date.now(); if(!firstRealEvent){firstRealEvent=true;if(config.behavior.startGreetingOnFirstEvent)enqueue({type:'static',text:'¡Hola! Soy Luna, una presentadora virtual con inteligencia artificial. Bienvenidos al directo; pueden hacerme preguntas y escribirme con Luna.'});} } const user=safeName(event.user); if(event.kind==='chat'){ const comment=clean(event.comment,config.behavior.maxCommentChars); if(!comment || isBlocked(comment,config.behavior.moderationBlocklist))return; const normalized=comment.toLocaleLowerCase('es'); if(['!app','!apps','!aplicaciones'].includes(normalized) && config.apps.promoteOnCommand){enqueue({type:'static',user,text:resumenAplicaciones(config,apps)});emitApp();return;} if(normalized.startsWith('/'))return; if(!shouldAnswer(comment,config))return; if(Date.now()-lastChatAt1000)userChatTimes.clear(); enqueue({type:'chat',user,comment});return; } if(event.kind==='join'){ if(config.behavior.speakJoins && Date.now()-lastJoinAt>=config.behavior.joinCooldownMs){lastJoinAt=Date.now();enqueue({type:'static',user,text:`¡Bienvenida o bienvenido, ${user}! Qué gusto que estés aquí.`,avatar:'greet'});} return; } const messages={ follow:['speakFollows',`Gracias por seguirme, ${user}. ¡Qué gusto tenerte aquí!`,'follow'], gift:['speakGifts',`Muchas gracias por ${event.repeat>1?'los '+event.repeat+' regalos':'el regalo'} ${event.gift||'que me mandaste'}, ${user}. ¡Qué detalle!`,'gift'], share:['speakShares',`Gracias por compartir este directo, ${user}.`,'share'], subscription:['speakSubscriptions',`¡Muchas gracias por suscribirte, ${user}! Bienvenida o bienvenido.`,'subscribe'] }; const item=messages[event.kind]; if(item&&config.behavior[item[0]])enqueue({type:'static',user,text:item[1],avatar:item[2]}); } info('[INICIO]',config.app.name,demo?'(SIMULACIÓN, sin conectar TikTok)':'(MODO LIVE)'); info('[APPS]',apps.length,'app(s) individual(es) validadas.');emitApp(); let disconnect=()=>{},promotionTimer=null,idleTimer=null,conversationTimer=null; if(demo){ const samples=[ {kind:'chat',user:'EspectadoraDemo',comment:'!apps'}, {kind:'follow',user:'NuevaSeguidora'}, {kind:'gift',user:'VisitanteDemo',gift:'Rosa',repeat:1}, {kind:'chat',user:'PublicoDemo',comment:'Hola Luna ¿qué puedes hacer?'} ]; for(const e of samples)handle(e); const rl=readline.createInterface({input:process.stdin,output:process.stdout});demoReadline=rl; info('[DEMO] Escribe una pregunta para Luna (ejemplo: Luna, hola), !apps o salir.'); rl.setPrompt('Demo> ');rl.prompt();rl.on('line',line=>{ if(line.trim().toLocaleLowerCase('es')==='salir'){void stop();return;} handle({kind:'chat',user:'DemoLocal',comment:line});rl.prompt(); }); }else{ disconnect=conectarEventos({config,onEvent:handle,log:info}); const every=Number(config.apps.promotionEveryMinutes)||0; if(every>=2)promotionTimer=setInterval(()=>{enqueue({type:'static',text:resumenAplicaciones(config,apps)});emitApp()},every*60000); if(config.avatar.enabled && config.avatar.idleEveryMs>=10000)idleTimer=setInterval(()=>{ const now=Date.now(); if(!puedeMoverseIdle({now,lastInteractionAt:lastEventAt||lastSpeechAt||startupAt,lastSpeechAt,quietMs:config.avatar.idleQuietMs??12000,busy,pending:queue.length}))return; const action=elegirAccionIdle({acciones:config.avatar.idleActions||['idle'],anterior:lastIdleAction}); if(action){lastIdleAction=action;void accionarAvatar(config,action,{});} },config.avatar.idleEveryMs); // Conversaciones espontáneas incluso si el LIVE no ha recibido ningún comentario. if(autoConfig.enabled && Array.isArray(idleTopics) && idleTopics.length){ const silencioMs=Math.max(15,Number(autoConfig.minSilenceSeconds)||60)*1000; conversationTimer=setInterval(()=>{ const now=Date.now(); const ultimaActividad=Math.max(lastEventAt,lastSpeechAt); if(!puedeIniciarConversacion({now,proxima:proximaConversacion, ultimaActividad,iniciado:startupAt,ocupada:busy,pendientes:queue.length,silencioMs}))return; const tema=elegirTema(idleTopics,temasRecientes); if(!tema)return; temasRecientes.push(tema); const maxRecientes=Math.max(1,Number(autoConfig.maxRecentTopics)||4); while(temasRecientes.length>maxRecientes)temasRecientes.shift(); proximaConversacion=now+siguienteEsperaMs(autoConfig); info('[AUTO] Iniciando un tema espontáneo:',tema); enqueue({type:'auto',topic:tema,createdAt:now}); },2000); } } let stopCalled=false; async function stop(){ if(stopCalled)return;stopCalled=true;stopping=true;demoReadline?.close();clearInterval(promotionTimer);clearInterval(idleTimer);clearInterval(conversationTimer);disconnect(); await overlay.close();info('[FIN] Puente detenido. Tu aplicación de streaming debe detenerse por separado.');process.exitCode=0; } process.on('SIGINT',()=>void stop());process.on('SIGTERM',()=>void stop());