@ing.jorgeu/wargaming-overlay 2.4.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/PUBLISHING.md +13 -20
- package/README.md +244 -4
- package/THIRD_PARTY_ASSETS.md +8 -0
- package/battle_reader.py +50 -7
- package/cli.js +27 -7
- package/device_connection.py +14 -5
- package/game-store.js +26 -3
- package/package.json +25 -6
- package/public/army-list.css +18 -0
- package/public/army-list.html +1 -0
- package/public/army-list.js +59 -0
- package/public/control.html +3 -3
- package/public/control.js +3 -2
- package/public/detachments.css +2 -2
- package/public/live-score.css +3 -3
- package/public/live-score.js +8 -4
- package/public/match-view.js +15 -4
- package/public/roster-view.js +8 -0
- package/public/rosters-control.js +77 -0
- package/public/style.css +8 -0
- package/reader-status.js +19 -0
- package/rosters.js +39 -0
- package/scripts/setup-kokoro.js +2 -0
- package/scripts/tts-demo.js +10 -0
- package/server.js +53 -11
- package/tts/kokoro-assets.js +27 -0
- package/tts/kokoro-engine.js +31 -0
- package/tts/kokoro-worker.js +13 -0
- package/tts/licenses/espeak-ng-COPYING +674 -0
- package/tts/playback.js +28 -0
- package/tts/queue.js +22 -0
- package/tts/sanitizer.js +31 -0
- package/tts/speech.js +72 -0
- package/tts/voice-command.js +14 -0
- package/tts/wav.js +6 -0
- package/tts/windows-playback.js +14 -0
- package/turn-tracker.js +33 -21
- package/youtube/auth.js +71 -0
- package/youtube/chat.proto +29 -0
- package/youtube/index.js +23 -0
- package/youtube/live-broadcast.js +13 -0
- package/youtube/live-chat.js +24 -0
- package/youtube/poll-chat.js +15 -0
- package/youtube/service.js +73 -0
package/tts/playback.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// No external player: the npm package contains native CoreAudio/WASAPI/ALSA bindings.
|
|
2
|
+
async function play(samples,sampleRate,{cpal=require('node-cpal')}={}){
|
|
3
|
+
let host,device,stream;
|
|
4
|
+
try{
|
|
5
|
+
host=cpal.defaultHost();device=host.defaultOutputDevice();
|
|
6
|
+
if(!device)throw Error('No hay salida de audio predeterminada.');
|
|
7
|
+
let supported=device.defaultOutputConfig();
|
|
8
|
+
if(supported.sampleFormat()!==cpal.SampleFormat.F32){
|
|
9
|
+
const candidate=[...device.supportedOutputConfigs()].find(c=>c.sampleFormat()===cpal.SampleFormat.F32);
|
|
10
|
+
if(!candidate)throw Error('La salida de audio no admite float32.');
|
|
11
|
+
supported=candidate.tryWithSampleRate(supported.sampleRate())||candidate.tryWithStandardSampleRate()||candidate.withMaxSampleRate();
|
|
12
|
+
}
|
|
13
|
+
const config=supported.config();let position=0,finished=false,drainTimer;
|
|
14
|
+
await new Promise((resolve,reject)=>{
|
|
15
|
+
stream=device.buildOutputStream(config,cpal.SampleFormat.F32,data=>{
|
|
16
|
+
for(let frame=0;frame<data.length/config.channels;frame++){
|
|
17
|
+
const left=Math.floor(position),fraction=position-left;
|
|
18
|
+
const sample=left<samples.length?(samples[left]*(1-fraction)+(samples[Math.min(left+1,samples.length-1)]||0)*fraction):0;
|
|
19
|
+
for(let channel=0;channel<config.channels;channel++)data[frame*config.channels+channel]=sample;
|
|
20
|
+
position+=sampleRate/config.sampleRate;
|
|
21
|
+
}
|
|
22
|
+
if(position>=samples.length&&!finished){finished=true;drainTimer=setTimeout(resolve,Math.ceil(data.length/config.channels/config.sampleRate*1000)+250);}
|
|
23
|
+
},error=>{clearTimeout(drainTimer);reject(error);});
|
|
24
|
+
stream.play();
|
|
25
|
+
});
|
|
26
|
+
}finally{stream?.close();device?.close();host?.close();}
|
|
27
|
+
}
|
|
28
|
+
module.exports={play};
|
package/tts/queue.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
const {speak}=require('./speech');
|
|
2
|
+
class SpeechQueue {
|
|
3
|
+
constructor({maxQueueLength=10,speech=speak,log=console.log}={}){this.max=maxQueueLength;this.speech=speech;this.log=log;this.queue=[];this.state='IDLE';this.closed=false;this.task=null;this.failed=0;}
|
|
4
|
+
enqueue(message){
|
|
5
|
+
if(this.closed)return;
|
|
6
|
+
if(this.queue.length>=this.max){this.queue.shift();this.log('[TTS] Se descartó el mensaje pendiente más antiguo: cola llena.');}
|
|
7
|
+
this.queue.push(message);this.log(`[TTS] En cola: ${message.author}. Pendientes: ${this.queue.length}.`);
|
|
8
|
+
if(!this.task)this.task=this.drain().finally(()=>{this.task=null;});
|
|
9
|
+
}
|
|
10
|
+
async drain(){
|
|
11
|
+
while(!this.closed&&this.queue.length){
|
|
12
|
+
const item=this.queue.shift();this.state='SPEAKING';this.controller=new AbortController();
|
|
13
|
+
this.log(`[TTS] Leyendo mensaje de ${item.author}.`);
|
|
14
|
+
try{await this.speech(item.spoken,{signal:this.controller.signal});}catch{this.failed++;this.log('[TTS] Falló la reproducción; continúo con el siguiente mensaje.');}
|
|
15
|
+
}
|
|
16
|
+
this.state='IDLE';
|
|
17
|
+
}
|
|
18
|
+
clear(){this.queue=[];this.controller?.abort();}
|
|
19
|
+
async stop(){this.closed=true;this.clear();await this.task;}
|
|
20
|
+
async idle(){await this.task;}
|
|
21
|
+
}
|
|
22
|
+
module.exports={SpeechQueue};
|
package/tts/sanitizer.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Deliberately small local ES/EN filter, not semantic moderation.
|
|
2
|
+
// Ordinary profanity and in-game violence are not blanket-blocked.
|
|
3
|
+
const severe=[
|
|
4
|
+
/\b(?:n[i1]gg(?:er|a)s?|f[a4]gg?[o0]ts?|k[i1]kes?|sp[i1]cs?|sudacas?|maricones)\b/giu,
|
|
5
|
+
/\b(?:heil\s+hitler|sieg\s+heil)\b/giu,
|
|
6
|
+
/\b(?:te\s+voy\s+a\s+(?:matar|violar)|voy\s+a\s+(?:matarte|violarte)|i(?:'ll|\s+will)\s+(?:kill|rape)\s+you)\b/giu,
|
|
7
|
+
/\b(?:maten|matad|exterminen|hay\s+que\s+matar|kill|exterminate)\s+(?:(?:a|a\s+los|los|las|all|the)\s+)*(?:judios|negros|gays|musulmanes|inmigrantes|jews|blacks|muslims|immigrants)\b/giu,
|
|
8
|
+
/\b(?:chupa(?:me)?\s+(?:la\s+)?(?:polla|verga)|suck\s+my\s+(?:dick|cock)|(?:sexo|pornografia|porn)\s+(?:con\s+)?(?:ninos|menores|children))\b/giu
|
|
9
|
+
];
|
|
10
|
+
function truncate(text,max){const chars=Array.from(text);if(chars.length<=max)return text;let cut=chars.slice(0,max-1).join('');const space=cut.lastIndexOf(' ');if(space>max*.7)cut=cut.slice(0,space);return cut.trimEnd()+'…';}
|
|
11
|
+
function clean(input,{filterUrls=true,extraBlockedTerms=[]}={}){
|
|
12
|
+
let text=String(input||'').slice(0,10000).normalize('NFKC').replace(/[\p{Cc}\p{Cf}]/gu,' ').replace(/[\[\]<>]/g,' ').replace(/\s+/g,' ').trim();
|
|
13
|
+
if(filterUrls)text=text.replace(/(?:[a-z][a-z\d+.-]*:\/\/|www\.)[^\s]+|\b(?:[\p{L}\d-]+\.)+[a-z]{2,63}(?:\/[^\s]*)?/giu,' Enlace ');
|
|
14
|
+
// Accent-insensitive matching with offsets mapped back to the original spelling.
|
|
15
|
+
const chars=Array.from(text),folded=[],offsets=[];let offset=0;
|
|
16
|
+
for(const ch of chars){const f=ch.normalize('NFD').replace(/\p{M}/gu,'');for(let j=0;j<f.length;j++)offsets.push([offset,offset+ch.length]);folded.push(f);offset+=ch.length;}
|
|
17
|
+
const normalized=folded.join(''),spans=[];
|
|
18
|
+
const patterns=[...severe,...extraBlockedTerms.filter(t=>typeof t==='string'&&t.trim()).slice(0,100).map(t=>new RegExp(t.normalize('NFD').replace(/\p{M}/gu,'').replace(/[.*+?^${}()|[\]\\]/g,'\\$&'),'giu'))];
|
|
19
|
+
for(const re of patterns){re.lastIndex=0;for(const m of normalized.matchAll(re))if(m[0].length)spans.push([offsets[m.index][0],offsets[m.index+m[0].length-1][1]]);}
|
|
20
|
+
spans.sort((a,b)=>a[0]-b[0]);const merged=[];
|
|
21
|
+
for(const span of spans){const last=merged.at(-1);if(last&&span[0]<=last[1])last[1]=Math.max(last[1],span[1]);else merged.push([...span]);}
|
|
22
|
+
for(const [start,end] of merged.reverse())text=text.slice(0,start)+'[censurado]'+text.slice(end);
|
|
23
|
+
return text.replace(/\s+/g,' ').trim();
|
|
24
|
+
}
|
|
25
|
+
function sanitize(message,config={}){
|
|
26
|
+
const text=truncate(clean(message.text,config),config.maxMessageLength||300);
|
|
27
|
+
if(!text || !text.replace(/\[censurado\]|[^\p{L}\p{N}]/gu,''))return null;
|
|
28
|
+
const author=truncate(clean(message.author,config),40)||'Usuario';
|
|
29
|
+
return {author,text,spoken:config.readUsername===false?text:`${author} dice: ${text}`};
|
|
30
|
+
}
|
|
31
|
+
module.exports={sanitize,clean,truncate};
|
package/tts/speech.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const {spawn}=require('node:child_process');
|
|
2
|
+
function say(text,{spawnImpl=spawn,platform=process.platform,signal,timeoutMs=120000}={}){
|
|
3
|
+
if(platform!=='darwin')return Promise.reject(Error('El TTS local requiere macOS.'));
|
|
4
|
+
if(signal?.aborted)return Promise.resolve();
|
|
5
|
+
return new Promise((resolve,reject)=>{
|
|
6
|
+
// Feed text through stdin, not a shell/option. Remove Apple's embedded speech commands.
|
|
7
|
+
const child=spawnImpl('/usr/bin/say',[],{stdio:['pipe','ignore','pipe'],shell:false});
|
|
8
|
+
let timedOut=false,done=false;
|
|
9
|
+
const stop=()=>{
|
|
10
|
+
if(platform==='win32'&&child.pid){const killer=spawnImpl('taskkill.exe',['/PID',String(child.pid),'/T','/F'],{shell:false,windowsHide:true,stdio:'ignore'});killer.once('error',()=>child.kill('SIGKILL'));killer.unref?.();}
|
|
11
|
+
else child.kill('SIGKILL');
|
|
12
|
+
};
|
|
13
|
+
const timer=setTimeout(()=>{timedOut=true;stop();},timeoutMs);
|
|
14
|
+
const finish=(error)=>{if(done)return;done=true;clearTimeout(timer);signal?.removeEventListener('abort',stop);error?reject(error):resolve();};
|
|
15
|
+
signal?.addEventListener('abort',stop,{once:true});
|
|
16
|
+
child.stderr?.resume();child.stdin.on('error',()=>{});
|
|
17
|
+
child.once('error',()=>finish(Error('No se pudo iniciar la voz nativa de macOS.')));
|
|
18
|
+
child.once('close',code=>finish(signal?.aborted?null:timedOut?Error('Se agotó el tiempo de voz.'):code===0?null:Error('La voz de macOS terminó con un error.')));
|
|
19
|
+
child.stdin.end(String(text).replace(/[\[\]]/g,''));
|
|
20
|
+
if(signal?.aborted)stop();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
const path=require('node:path'),fs=require('node:fs'),os=require('node:os');
|
|
26
|
+
const {cacheDirectory}=require('./kokoro-assets');
|
|
27
|
+
function settings(config={},env=process.env,platform=process.platform){
|
|
28
|
+
const opts={provider:env.TTS_PROVIDER||config.ttsProvider||'kokoro',fallback:env.TTS_FALLBACK||config.ttsFallback||(platform==='darwin'?'say':'none'),voice:env.KOKORO_VOICE||config.kokoroVoice||'ef_dora',speed:Number(env.KOKORO_SPEED??config.kokoroSpeed??1),cacheDir:env.KOKORO_CACHE_DIR||config.kokoroCacheDir||cacheDirectory()};
|
|
29
|
+
if(!['kokoro','say'].includes(opts.provider))throw Error('ttsProvider debe ser kokoro o say.');
|
|
30
|
+
if(!['say','none'].includes(opts.fallback))throw Error('ttsFallback debe ser say o none.');
|
|
31
|
+
if((opts.provider==='say'||opts.fallback==='say')&&platform!=='darwin')throw Error('say solo está disponible en macOS. Usa kokoro y ttsFallback=none.');
|
|
32
|
+
if(!['ef_dora','em_alex','em_santa'].includes(opts.voice))throw Error('kokoroVoice debe ser ef_dora, em_alex o em_santa.');
|
|
33
|
+
if(!Number.isFinite(opts.speed)||opts.speed<0.5||opts.speed>2)throw Error('kokoroSpeed debe estar entre 0.5 y 2.');
|
|
34
|
+
if(typeof opts.cacheDir!=='string'||!opts.cacheDir)throw Error('kokoroCacheDir debe ser una ruta.');
|
|
35
|
+
return opts;
|
|
36
|
+
}
|
|
37
|
+
function kokoro(text,{spawnImpl=spawn,platform=process.platform,signal,timeoutMs=120000,log=console.log,...opts}={}){
|
|
38
|
+
if(signal?.aborted)return Promise.resolve();
|
|
39
|
+
return new Promise((resolve,reject)=>{
|
|
40
|
+
const temporary=fs.mkdtempSync(path.join(os.tmpdir(),'overlay-tts-'));
|
|
41
|
+
const child=spawnImpl(process.execPath,[path.join(__dirname,'kokoro-worker.js')],{stdio:['pipe','ignore','pipe'],shell:false,windowsHide:true,cwd:temporary,...(temporary?{env:{...process.env,OVERLAY_TTS_TEMP_DIR:temporary}}:{})});
|
|
42
|
+
let done=false,timedOut=false;
|
|
43
|
+
const stop=()=>{
|
|
44
|
+
if(platform==='win32'&&child.pid){const killer=spawnImpl('taskkill.exe',['/PID',String(child.pid),'/T','/F'],{shell:false,windowsHide:true,stdio:'ignore'});killer.once('error',()=>child.kill('SIGKILL'));killer.unref?.();}
|
|
45
|
+
else child.kill('SIGKILL');
|
|
46
|
+
};
|
|
47
|
+
const timer=setTimeout(()=>{timedOut=true;stop();},timeoutMs);
|
|
48
|
+
const finish=error=>{if(done)return;done=true;clearTimeout(timer);signal?.removeEventListener('abort',stop);
|
|
49
|
+
const cleanup=temporary?require('node:fs/promises').rm(temporary,{recursive:true,force:true,maxRetries:5,retryDelay:100}).catch(()=>log('[TTS] No se pudo limpiar un archivo de audio temporal.')):Promise.resolve();
|
|
50
|
+
cleanup.then(()=>error?reject(error):resolve());
|
|
51
|
+
};
|
|
52
|
+
signal?.addEventListener('abort',stop,{once:true});
|
|
53
|
+
let stderr='';child.stderr?.setEncoding('utf8');child.stderr?.on('data',chunk=>{stderr=(stderr+chunk).slice(-4000);});
|
|
54
|
+
child.stdin.on('error',()=>{});
|
|
55
|
+
child.once('error',()=>finish(Error('No se pudo iniciar Kokoro.')));
|
|
56
|
+
child.once('close',code=>{if(stderr.trim())log(stderr.trim());finish(signal?.aborted?null:timedOut?Error('Se agotó el tiempo de Kokoro.'):code===0?null:Error('Falló Kokoro o la salida de audio.'));});
|
|
57
|
+
child.stdin.end(JSON.stringify({text:String(text),voice:opts.voice,speed:opts.speed,cacheDir:opts.cacheDir}));
|
|
58
|
+
if(signal?.aborted)stop();
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function createSpeech(config={}, {env=process.env,platform=process.platform,log=console.log,spawnImpl=spawn}={}){
|
|
62
|
+
const opts=settings(config,env,platform);
|
|
63
|
+
return async(text,options={})=>{
|
|
64
|
+
if(options.signal?.aborted)return;
|
|
65
|
+
const common={...options,platform,spawnImpl,log};
|
|
66
|
+
if(opts.provider==='say')return say(text,common);
|
|
67
|
+
try{await kokoro(text,{...common,...opts});}
|
|
68
|
+
catch(e){if(options.signal?.aborted)return;if(opts.fallback!=='say')throw e;log('[TTS] Kokoro falló; leyendo este mensaje con say.');await say(text,common);}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
function speak(text,options={}){return createSpeech(options.config,{env:options.env,platform:options.platform,log:options.log,spawnImpl:options.spawnImpl})(text,options);}
|
|
72
|
+
module.exports={speak,say,kokoro,createSpeech,settings};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const fs=require('node:fs'),path=require('node:path');
|
|
2
|
+
const {createSpeech}=require('./speech');
|
|
3
|
+
async function testVoice(text,{dataDir,provider,voice,speed,fallback}={}, {speechFactory=createSpeech,signals=process,env=process.env}={}){
|
|
4
|
+
if(typeof text!=='string'||!text.trim())throw Error('Indica un texto: wargaming-overlay --test-tts "Hola, esta es una prueba."');
|
|
5
|
+
let config={};
|
|
6
|
+
if(dataDir){try{config=JSON.parse(fs.readFileSync(path.join(dataDir,'youtube-tts.json'),'utf8'));}catch(e){if(e.code!=='ENOENT')throw Error('Configuración youtube-tts.json inválida.');}}
|
|
7
|
+
// CLI overrides environment and file settings, without changing the saved configuration.
|
|
8
|
+
const overrides={...env};
|
|
9
|
+
for(const [key,value] of Object.entries({TTS_PROVIDER:provider,KOKORO_VOICE:voice,KOKORO_SPEED:speed,TTS_FALLBACK:fallback}))if(value!==undefined)overrides[key]=String(value);
|
|
10
|
+
const speech=speechFactory(config,{env:overrides});const controller=new AbortController();
|
|
11
|
+
const stop=()=>controller.abort();signals.once('SIGINT',stop);signals.once('SIGTERM',stop);
|
|
12
|
+
try{await speech(text,{signal:controller.signal});}finally{signals.removeListener('SIGINT',stop);signals.removeListener('SIGTERM',stop);}
|
|
13
|
+
}
|
|
14
|
+
module.exports={testVoice};
|
package/tts/wav.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
function wav(samples,sampleRate){
|
|
2
|
+
const data=Buffer.alloc(44+samples.length*2);data.write('RIFF');data.writeUInt32LE(data.length-8,4);data.write('WAVEfmt ',8);data.writeUInt32LE(16,16);data.writeUInt16LE(1,20);data.writeUInt16LE(1,22);data.writeUInt32LE(sampleRate,24);data.writeUInt32LE(sampleRate*2,28);data.writeUInt16LE(2,32);data.writeUInt16LE(16,34);data.write('data',36);data.writeUInt32LE(samples.length*2,40);
|
|
3
|
+
for(let i=0;i<samples.length;i++)data.writeInt16LE(Math.round(Math.max(-1,Math.min(1,samples[i]))*32767),44+i*2);
|
|
4
|
+
return data;
|
|
5
|
+
}
|
|
6
|
+
module.exports={wav};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
const fs=require('node:fs/promises'),os=require('node:os'),path=require('node:path');
|
|
2
|
+
const {spawn}=require('node:child_process');const {wav}=require('./wav');
|
|
3
|
+
async function playWindows(samples,sampleRate,{spawnImpl=spawn}={}){
|
|
4
|
+
const dir=process.env.OVERLAY_TTS_TEMP_DIR||await fs.mkdtemp(path.join(os.tmpdir(),'overlay-tts-'));const file=path.join(dir,'speech.wav');
|
|
5
|
+
try{
|
|
6
|
+
await fs.writeFile(file,wav(samples,sampleRate));
|
|
7
|
+
await new Promise((resolve,reject)=>{
|
|
8
|
+
const child=spawnImpl('powershell.exe',['-NoProfile','-NonInteractive','-Command',"$ErrorActionPreference='Stop'; $player=New-Object System.Media.SoundPlayer; try { $player.SoundLocation=$env:OVERLAY_TTS_WAV; $player.Load(); $player.PlaySync() } finally { $player.Dispose() }"],{shell:false,windowsHide:true,stdio:'ignore',env:{...process.env,OVERLAY_TTS_WAV:file}});
|
|
9
|
+
child.once('error',()=>reject(Error('No se pudo abrir el audio integrado de Windows.')));
|
|
10
|
+
child.once('close',code=>code===0?resolve():reject(Error('Falló el audio integrado de Windows.')));
|
|
11
|
+
});
|
|
12
|
+
}finally{await fs.rm(dir,{recursive:true,force:true});}
|
|
13
|
+
}
|
|
14
|
+
module.exports={playWindows};
|
package/turn-tracker.js
CHANGED
|
@@ -1,35 +1,47 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Keep the union of observed cards for each player and round across partial snapshots.
|
|
2
|
+
// A first-seen unscored card after the initial reading is a draw heuristic, not proof.
|
|
3
3
|
function inferTurn(previous, incoming, samePlayers) {
|
|
4
|
-
|
|
4
|
+
// The parser keeps Tabletop Battles player order even when a heading is off-screen.
|
|
5
|
+
const firstPlayer=incoming.players[0].name;
|
|
6
|
+
const fresh={firstPlayer,orderSource:'ui-order',detectionSource:'secondary-draws',activePlayer:null,half:null,highestRound:incoming.round,seen:{},reason:'Orden de Tabletop Battles identificado; esperando nuevas secundarias para detectar el turno'};
|
|
5
7
|
let t=samePlayers && previous.turn ? structuredClone(previous.turn) : fresh;
|
|
6
8
|
if(incoming.round<t.highestRound) return {...t,reason:'Ronda histórica: turno sin determinar'};
|
|
7
|
-
|
|
9
|
+
// Old versions learned order from scoring; discard that inference on migration.
|
|
10
|
+
if(t.orderSource!=='ui-order' || t.firstPlayer!==firstPlayer)t=fresh;
|
|
11
|
+
if(incoming.round>t.highestRound){t={...t,highestRound:incoming.round,seen:{},cards:{},cardBaselines:{},activePlayer:null,half:null,reason:fresh.reason};}
|
|
12
|
+
// Preserve card history, but discard halves inferred by the former scoring policy.
|
|
13
|
+
if(t.detectionSource!=='secondary-draws'){
|
|
14
|
+
t.detectionSource='secondary-draws';t.activePlayer=null;t.half=null;t.reason=fresh.reason;
|
|
15
|
+
}
|
|
8
16
|
const events=[];
|
|
17
|
+
t.cards=t.cards||{};
|
|
9
18
|
for(const p of incoming.players){
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
let activity=false;
|
|
20
|
+
if(Array.isArray(p.secondaries)){
|
|
21
|
+
const names=[...new Set(p.secondaries.map(m=>m.name))];
|
|
22
|
+
const prior=t.cards[p.name];
|
|
23
|
+
const known=Array.isArray(prior)?prior:[];
|
|
24
|
+
const added=p.secondaries.filter(m=>m.enabled!==false && !known.includes(m.name));
|
|
25
|
+
// A complete baseline proves an addition. Otherwise a new zero-point card is
|
|
26
|
+
// useful evidence, including the first visible BOTTOM card after TOP played.
|
|
27
|
+
if(t.cardBaselines?.[p.name] && added.length)activity=true;
|
|
28
|
+
else if(Object.hasOwn(t.seen,p.name) && added.some(m=>m.score===0))activity=true;
|
|
29
|
+
t.cards[p.name]=[...new Set([...known,...names])];
|
|
30
|
+
if(p.secondariesComplete){
|
|
31
|
+
t.cardBaselines=t.cardBaselines||{};
|
|
32
|
+
t.cardBaselines[p.name]=true;
|
|
33
|
+
}
|
|
22
34
|
}
|
|
23
|
-
t.seen[p.name]=
|
|
35
|
+
t.seen[p.name]=true;
|
|
24
36
|
if(activity)events.push(p.name);
|
|
25
37
|
}
|
|
26
38
|
if(events.length===1){
|
|
27
39
|
const name=events[0];
|
|
28
|
-
|
|
29
|
-
t.activePlayer=name;
|
|
30
|
-
// Bottom is terminal for this round
|
|
40
|
+
// Late card changes by TOP must not rewind a round after BOTTOM began.
|
|
41
|
+
if(t.half!=='bottom')t.activePlayer=name;
|
|
42
|
+
// Bottom is terminal for this round.
|
|
31
43
|
if(t.firstPlayer && t.half!=='bottom')t.half=name===t.firstPlayer?'top':'bottom';
|
|
32
|
-
t.reason=
|
|
44
|
+
t.reason='Turno identificado por nuevas secundarias; orden de Tabletop Battles';
|
|
33
45
|
}
|
|
34
46
|
if(events.length>1)t.reason='Cambios en ambos jugadores; se conserva el último turno detectado';
|
|
35
47
|
return t;
|
package/youtube/auth.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
const fs=require('node:fs');
|
|
2
|
+
const path=require('node:path');
|
|
3
|
+
const os=require('node:os');
|
|
4
|
+
const http=require('node:http');
|
|
5
|
+
const crypto=require('node:crypto');
|
|
6
|
+
const {spawn}=require('node:child_process');
|
|
7
|
+
const SCOPE='https://www.googleapis.com/auth/youtube.readonly';
|
|
8
|
+
const credentialDir=()=>path.join(os.homedir(),'.config','wargaming-overlay','youtube');
|
|
9
|
+
function privateWrite(file,value){
|
|
10
|
+
fs.mkdirSync(path.dirname(file),{recursive:true,mode:0o700});
|
|
11
|
+
const tmp=file+'.'+crypto.randomBytes(6).toString('hex')+'.tmp';
|
|
12
|
+
try{fs.writeFileSync(tmp,JSON.stringify(value),{mode:0o600,flag:'wx'});fs.renameSync(tmp,file);}finally{try{fs.unlinkSync(tmp)}catch{}}
|
|
13
|
+
}
|
|
14
|
+
function read(file){try{return JSON.parse(fs.readFileSync(file,'utf8'))}catch(e){if(e.code==='ENOENT')return null;throw Error('No se pueden leer las credenciales locales de YouTube.');}}
|
|
15
|
+
function clientConfig(json){
|
|
16
|
+
const c=json?.installed;
|
|
17
|
+
if(!c?.client_id || !c.client_secret)throw Error('Usa el JSON OAuth de tipo Aplicación de escritorio de Google.');
|
|
18
|
+
return {client_id:c.client_id,client_secret:c.client_secret};
|
|
19
|
+
}
|
|
20
|
+
class Auth {
|
|
21
|
+
constructor({dir=credentialDir(),fetchImpl=fetch,now=Date.now}={}){this.dir=dir;this.fetch=fetchImpl;this.now=now;this.pending=null;}
|
|
22
|
+
load(){const c=read(path.join(this.dir,'client.json'));if(!c)throw Error('Configura OAuth: wargaming-overlay youtube-auth --client-secret /ruta/client.json');return c;}
|
|
23
|
+
async exchange(params,signal){
|
|
24
|
+
const response=await this.fetch('https://oauth2.googleapis.com/token',{method:'POST',body:new URLSearchParams(params),signal:signal?AbortSignal.any([signal,AbortSignal.timeout(20000)]):AbortSignal.timeout(20000)});
|
|
25
|
+
const data=await response.json();
|
|
26
|
+
if(!response.ok || !data.access_token)throw Error(data.error==='invalid_grant'?'Autorización revocada o vencida. Ejecuta youtube-auth de nuevo.':'No se pudo obtener autorización de Google; se reintentará.');
|
|
27
|
+
return data;
|
|
28
|
+
}
|
|
29
|
+
save(data,old={}){
|
|
30
|
+
const tokens={refresh_token:data.refresh_token||old.refresh_token,access_token:data.access_token,expires_at:this.now()+Number(data.expires_in||3600)*1000,scope:data.scope||old.scope||SCOPE};
|
|
31
|
+
if(!tokens.refresh_token)throw Error('Google no entregó refresh token. Autoriza de nuevo con youtube-auth.');
|
|
32
|
+
if(!tokens.scope.split(' ').includes(SCOPE))throw Error('No se concedió el permiso de lectura de YouTube.');
|
|
33
|
+
privateWrite(path.join(this.dir,'tokens.json'),tokens);return tokens;
|
|
34
|
+
}
|
|
35
|
+
async token(signal){
|
|
36
|
+
const t=read(path.join(this.dir,'tokens.json'));
|
|
37
|
+
if(!t?.refresh_token)throw Error('YouTube sin autorizar. Ejecuta wargaming-overlay youtube-auth --client-secret /ruta/client.json');
|
|
38
|
+
if(t.access_token && t.expires_at>this.now()+60000)return t.access_token;
|
|
39
|
+
if(!this.pending)this.pending=(async()=>{const c=this.load();return this.save(await this.exchange({...c,grant_type:'refresh_token',refresh_token:t.refresh_token},signal),t).access_token;})().finally(()=>{this.pending=null;});
|
|
40
|
+
return this.pending;
|
|
41
|
+
}
|
|
42
|
+
invalidate(){const file=path.join(this.dir,'tokens.json'),t=read(file);if(t)privateWrite(file,{...t,expires_at:0});}
|
|
43
|
+
async authorize(clientFile,{open=url=>{const child=spawn(process.platform==='darwin'?'open':process.platform==='win32'?'explorer.exe':'xdg-open',[url],{stdio:'ignore',shell:false});child.on('error',()=>{});child.unref();},log=console.log,timeoutMs=180000}={}){
|
|
44
|
+
const c=clientFile?clientConfig(read(path.resolve(clientFile))):this.load();
|
|
45
|
+
const state=crypto.randomBytes(24).toString('hex'),verifier=crypto.randomBytes(32).toString('base64url');
|
|
46
|
+
const challenge=crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
47
|
+
let resolveCode,rejectCode;
|
|
48
|
+
const codePromise=new Promise((resolve,reject)=>{resolveCode=resolve;rejectCode=reject;});
|
|
49
|
+
const server=http.createServer((req,res)=>{
|
|
50
|
+
const u=new URL(req.url,'http://127.0.0.1');
|
|
51
|
+
res.setHeader('Content-Type','text/plain; charset=utf-8');res.setHeader('Cache-Control','no-store');
|
|
52
|
+
if(u.pathname!=='/oauth/callback'){res.writeHead(404);res.end();return;}
|
|
53
|
+
if(u.searchParams.get('state')!==state){res.writeHead(400);res.end('Solicitud no válida.');return;}
|
|
54
|
+
if(u.searchParams.get('error')){res.end('Autorización cancelada. Vuelve a la terminal.');rejectCode(Error('Autorización cancelada por el usuario.'));return;}
|
|
55
|
+
const code=u.searchParams.get('code');if(!code){res.writeHead(400);res.end('Falta autorización.');return;}
|
|
56
|
+
res.end('Autorización recibida. Puedes cerrar esta ventana y volver a la terminal.');resolveCode(code);
|
|
57
|
+
});
|
|
58
|
+
await new Promise((resolve,reject)=>{server.once('error',reject);server.listen(0,'127.0.0.1',resolve);});
|
|
59
|
+
const timer=setTimeout(()=>rejectCode(Error('Se agotó el tiempo de autorización. Ejecuta youtube-auth de nuevo.')),timeoutMs);
|
|
60
|
+
const redirect=`http://127.0.0.1:${server.address().port}/oauth/callback`;
|
|
61
|
+
try{
|
|
62
|
+
const url=new URL('https://accounts.google.com/o/oauth2/v2/auth');url.search=new URLSearchParams({client_id:c.client_id,redirect_uri:redirect,response_type:'code',scope:SCOPE,access_type:'offline',prompt:'consent',state,code_challenge:challenge,code_challenge_method:'S256'});
|
|
63
|
+
log('[YouTube] Autoriza el canal en el navegador. Si no se abre: '+url.href);open(url.href);
|
|
64
|
+
const code=await codePromise;
|
|
65
|
+
const tokens=await this.exchange({...c,code,redirect_uri:redirect,grant_type:'authorization_code',code_verifier:verifier});
|
|
66
|
+
this.save(tokens);privateWrite(path.join(this.dir,'client.json'),c);
|
|
67
|
+
log('[YouTube] Autorización guardada. Ya puedes iniciar el backend con YOUTUBE_CHAT_TTS_ENABLED=true.');
|
|
68
|
+
}finally{clearTimeout(timer);server.close();server.closeAllConnections();}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
module.exports={Auth,credentialDir,privateWrite,SCOPE};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Minimal wire-compatible subset of the official YouTube streaming schema.
|
|
2
|
+
// https://developers.google.com/youtube/v3/live/streaming-live-chat
|
|
3
|
+
syntax = "proto2";
|
|
4
|
+
package youtube.api.v3;
|
|
5
|
+
service V3DataLiveChatMessageService {
|
|
6
|
+
rpc StreamList(LiveChatMessageListRequest) returns (stream LiveChatMessageListResponse) {}
|
|
7
|
+
}
|
|
8
|
+
message LiveChatMessageListRequest {
|
|
9
|
+
optional string live_chat_id = 1;
|
|
10
|
+
optional string page_token = 99;
|
|
11
|
+
repeated string part = 100;
|
|
12
|
+
}
|
|
13
|
+
message LiveChatMessageListResponse {
|
|
14
|
+
optional string offline_at = 2;
|
|
15
|
+
optional string next_page_token = 100602;
|
|
16
|
+
repeated LiveChatMessage items = 1007;
|
|
17
|
+
}
|
|
18
|
+
message LiveChatMessage {
|
|
19
|
+
optional string id = 101;
|
|
20
|
+
optional LiveChatMessageSnippet snippet = 2;
|
|
21
|
+
optional LiveChatMessageAuthorDetails author_details = 3;
|
|
22
|
+
}
|
|
23
|
+
message LiveChatMessageAuthorDetails { optional string display_name = 103; }
|
|
24
|
+
message LiveChatMessageSnippet {
|
|
25
|
+
optional int32 type = 1;
|
|
26
|
+
optional string published_at = 4;
|
|
27
|
+
optional LiveChatTextMessageDetails text_message_details = 19;
|
|
28
|
+
}
|
|
29
|
+
message LiveChatTextMessageDetails { optional string message_text = 1; }
|
package/youtube/index.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const fs=require('node:fs'),path=require('node:path');
|
|
2
|
+
function configFor(dataDir,env=process.env){
|
|
3
|
+
let file={};try{file=JSON.parse(fs.readFileSync(path.join(dataDir,'youtube-tts.json'),'utf8'));}catch(e){if(e.code!=='ENOENT')throw Error('Configuración youtube-tts.json inválida.');}
|
|
4
|
+
const config={enabled:true,maxMessageLength:300,maxQueueLength:10,readUsername:true,filterUrls:true,extraBlockedTerms:[],...file};
|
|
5
|
+
for(const [key,min,max] of [['maxMessageLength',50,300],['maxQueueLength',1,100]])if(!Number.isInteger(config[key])||config[key]<min||config[key]>max)throw Error(`Configuración ${key} fuera de rango.`);
|
|
6
|
+
for(const key of ['enabled','readUsername','filterUrls'])if(typeof config[key]!=='boolean')throw Error(`Configuración ${key} debe ser true/false.`);
|
|
7
|
+
if(!Array.isArray(config.extraBlockedTerms)||config.extraBlockedTerms.some(t=>typeof t!=='string'||t.length>100))throw Error('extraBlockedTerms debe ser una lista de textos cortos.');
|
|
8
|
+
if(config.httpFallback!==undefined&&typeof config.httpFallback!=='boolean')throw Error('httpFallback debe ser true/false.');
|
|
9
|
+
if(config.apiKeyFile){if(typeof config.apiKeyFile!=='string')throw Error('apiKeyFile debe ser una ruta.');try{config.apiKey=fs.readFileSync(path.resolve(config.apiKeyFile),'utf8').trim();}catch{throw Error('No se pudo leer apiKeyFile.');}if(!config.apiKey||/\s/.test(config.apiKey))throw Error('El archivo API key debe contener únicamente la clave.');}
|
|
10
|
+
return config;
|
|
11
|
+
}
|
|
12
|
+
function startYouTube(dataDir,{env=process.env,platform=process.platform,log=console.log}={}){
|
|
13
|
+
if(env.YOUTUBE_CHAT_TTS_ENABLED!=='true'){log('[YouTube] Chat TTS desactivado. Actívalo con YOUTUBE_CHAT_TTS_ENABLED=true.');return null;}
|
|
14
|
+
try{
|
|
15
|
+
if(!['darwin','win32','linux'].includes(platform))throw Error('Sistema no compatible con TTS.');
|
|
16
|
+
const config=configFor(dataDir,env);if(!config.enabled){log('[YouTube] TTS desactivado en configuración.');return null;}
|
|
17
|
+
require('../tts/speech').settings(config,env,platform);
|
|
18
|
+
const {YouTubeService}=require('./service');
|
|
19
|
+
log('[YouTube] Iniciando chat TTS. Si falta autorización, ejecuta wargaming-overlay youtube-auth --client-secret /ruta/client.json.');
|
|
20
|
+
return new YouTubeService({config,log,speech:require('../tts/speech').createSpeech(config,{env,platform,log})}).start();
|
|
21
|
+
}catch(e){log('[YouTube] '+e.message+' El overlay continúa funcionando.');return null;}
|
|
22
|
+
}
|
|
23
|
+
module.exports={startYouTube,configFor};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
async function findBroadcast(auth,{signal,fetchImpl=fetch}={}){
|
|
2
|
+
const token=await auth.token(signal);
|
|
3
|
+
const url=new URL('https://www.googleapis.com/youtube/v3/liveBroadcasts');
|
|
4
|
+
// API accepts exactly one filter: active, NOT active + mine.
|
|
5
|
+
url.search=new URLSearchParams({part:'snippet,status',broadcastStatus:'active',broadcastType:'all',maxResults:'50'});
|
|
6
|
+
const response=await fetchImpl(url,{headers:{Authorization:`Bearer ${token}`},signal:signal?AbortSignal.any([signal,AbortSignal.timeout(20000)]):AbortSignal.timeout(20000)});
|
|
7
|
+
if(response.status===401){auth.invalidate();throw Error('Autorización vencida; renovando.');}
|
|
8
|
+
if(!response.ok)throw Error(`Consulta de directos falló (HTTP ${response.status}). Revisa permisos/cuota de YouTube.`);
|
|
9
|
+
const data=await response.json();
|
|
10
|
+
const active=(data.items||[]).filter(b=>b.snippet?.liveChatId).sort((a,b)=>String(a.id).localeCompare(String(b.id)));
|
|
11
|
+
return active[0]?{id:active[0].id,liveChatId:active[0].snippet.liveChatId,title:active[0].snippet.title,multiple:active.length>1}:null;
|
|
12
|
+
}
|
|
13
|
+
module.exports={findBroadcast};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const path=require('node:path');
|
|
2
|
+
function normalize(item){
|
|
3
|
+
if(item.snippet?.type!==1 || !item.id || typeof item.snippet.textMessageDetails?.messageText!=='string')return null;
|
|
4
|
+
return {id:item.id,type:'youtube_chat_message',author:item.authorDetails?.displayName||'Usuario',text:item.snippet.textMessageDetails.messageText,timestamp:item.snippet.publishedAt,messageType:'textMessageEvent'};
|
|
5
|
+
}
|
|
6
|
+
async function* streamChat({auth,liveChatId,pageToken,signal,createClient,apiKey}){
|
|
7
|
+
const grpc=require('@grpc/grpc-js'),loader=require('@grpc/proto-loader');
|
|
8
|
+
const def=loader.loadSync(path.join(__dirname,'chat.proto'));
|
|
9
|
+
const Service=grpc.loadPackageDefinition(def).youtube.api.v3.V3DataLiveChatMessageService;
|
|
10
|
+
const client=createClient?createClient(Service,grpc):new Service('youtube.googleapis.com:443',grpc.credentials.createSsl());
|
|
11
|
+
let call;
|
|
12
|
+
const cancel=()=>call?.cancel();
|
|
13
|
+
try{
|
|
14
|
+
const meta=new grpc.Metadata();
|
|
15
|
+
if(apiKey)meta.set('x-goog-api-key',apiKey);
|
|
16
|
+
else meta.set('authorization',`Bearer ${await auth.token(signal)}`);
|
|
17
|
+
if(signal?.aborted)return;
|
|
18
|
+
// Reopen periodically with fresh credentials, retaining the continuation token.
|
|
19
|
+
call=client.streamList({liveChatId,pageToken,part:['id','snippet','authorDetails']},meta,{deadline:Date.now()+45*60*1000});
|
|
20
|
+
signal?.addEventListener('abort',cancel,{once:true});
|
|
21
|
+
for await(const batch of call)yield batch;
|
|
22
|
+
}finally{signal?.removeEventListener('abort',cancel);call?.cancel();client.close();}
|
|
23
|
+
}
|
|
24
|
+
module.exports={streamChat,normalize};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
const {setTimeout:delay}=require('node:timers/promises');
|
|
2
|
+
async function* pollChat({auth,liveChatId,pageToken,signal,fetchImpl=fetch,sleep=ms=>delay(ms,null,{signal})}){
|
|
3
|
+
while(!signal?.aborted){
|
|
4
|
+
const url=new URL('https://www.googleapis.com/youtube/v3/liveChat/messages');
|
|
5
|
+
url.search=new URLSearchParams({liveChatId,part:'id,snippet,authorDetails',...(pageToken?{pageToken}:{})});
|
|
6
|
+
const response=await fetchImpl(url,{headers:{Authorization:`Bearer ${await auth.token(signal)}`},signal:signal?AbortSignal.any([signal,AbortSignal.timeout(20000)]):AbortSignal.timeout(20000)});
|
|
7
|
+
const data=await response.json();
|
|
8
|
+
if(!response.ok){const reason=data.error?.errors?.[0]?.reason;const code=response.status===401?16:response.status===404?5:['liveChatEnded','liveChatDisabled'].includes(reason)?9:response.status===400?3:response.status===403?7:14;throw Object.assign(Error('No se pudo consultar el chat por HTTP.'),{code});}
|
|
9
|
+
yield {...data,items:(data.items||[]).map(item=>({...item,snippet:{...item.snippet,type:({textMessageEvent:1,chatEndedEvent:4})[item.snippet?.type]||0}}))};
|
|
10
|
+
if(data.offlineAt)return;
|
|
11
|
+
pageToken=data.nextPageToken||pageToken;
|
|
12
|
+
await sleep(Math.max(15000,Number(data.pollingIntervalMillis)||0));
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
module.exports={pollChat};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const {EventEmitter}=require('node:events');
|
|
2
|
+
const {setTimeout:delay}=require('node:timers/promises');
|
|
3
|
+
const {Auth}=require('./auth');
|
|
4
|
+
const {findBroadcast}=require('./live-broadcast');
|
|
5
|
+
const {streamChat,normalize}=require('./live-chat');
|
|
6
|
+
const {pollChat}=require('./poll-chat');
|
|
7
|
+
const {SpeechQueue}=require('../tts/queue');
|
|
8
|
+
const {sanitize}=require('../tts/sanitizer');
|
|
9
|
+
const backoff=[1000,2000,5000,10000,30000];
|
|
10
|
+
class YouTubeService extends EventEmitter {
|
|
11
|
+
constructor({config={},auth=new Auth(),discover=findBroadcast,stream=streamChat,poll=pollChat,queue,speech,log=console.log,sleep=(ms,signal)=>delay(ms,null,{signal}),now=Date.now}={}){
|
|
12
|
+
super();this.config=config;this.auth=auth;this.discover=discover;this.stream=stream;this.poll=poll;this.polling=false;this.log=log;this.sleep=sleep;this.now=now;
|
|
13
|
+
this.queue=queue||new SpeechQueue({maxQueueLength:config.maxQueueLength||10,speech:speech||require('../tts/speech').createSpeech(config,{log}),log});this.state='DISCONNECTED';this.seen=new Set();this.controller=new AbortController();this.chat=null;this.pageToken=undefined;this.task=null;
|
|
14
|
+
}
|
|
15
|
+
status(state,message){if(this.state!==state||this.lastMessage!==message){this.state=state;this.lastMessage=message;this.log('[YouTube] '+message);}}
|
|
16
|
+
start(){if(!this.task)this.task=this.run().catch(()=>{if(!this.controller.signal.aborted)this.status('DISCONNECTED','Servicio detenido por un error inesperado; el overlay continúa.');});return this;}
|
|
17
|
+
async stop(){this.controller.abort();await this.queue.stop();await this.task;}
|
|
18
|
+
endChat(){this.chat=null;this.pageToken=undefined;this.queue.clear();this.status('WAITING_FOR_STREAM','Directo terminado o chat no disponible; buscando otro directo.');}
|
|
19
|
+
async run(){
|
|
20
|
+
const signal=this.controller.signal;let failures=0;
|
|
21
|
+
while(!signal.aborted){
|
|
22
|
+
try{
|
|
23
|
+
if(!this.chat){
|
|
24
|
+
await this.auth.token(signal);this.status('AUTHENTICATED','Autorización cargada.');
|
|
25
|
+
const broadcast=await this.discover(this.auth,{signal});
|
|
26
|
+
if(!broadcast){this.status('WAITING_FOR_STREAM','Sin directo activo con chat; volveré a comprobar en 60 segundos.');await this.sleep(60000,signal);continue;}
|
|
27
|
+
this.chat=broadcast.liveChatId;this.pageToken=undefined;this.startedAt=this.now();
|
|
28
|
+
this.log(`[YouTube] Directo detectado${broadcast.multiple?' (hay varios; se usa el primero por ID)':''}.`);
|
|
29
|
+
}
|
|
30
|
+
let ended=false,received=false,continuation=false;
|
|
31
|
+
if(this.state!=='CONNECTED_TO_CHAT')this.status('CONNECTING_TO_CHAT',this.polling?'Conectando al chat mediante HTTP (respaldo, cada 15 s)…':'Conectando al chat en vivo mediante streamList…');
|
|
32
|
+
for await(const batch of (this.polling?this.poll:this.stream)({auth:this.auth,liveChatId:this.chat,pageToken:this.pageToken,signal,apiKey:this.config.apiKey})){
|
|
33
|
+
if(signal.aborted)break;
|
|
34
|
+
received=true;continuation=!!batch.nextPageToken;
|
|
35
|
+
failures=0;this.status('CONNECTED_TO_CHAT','Chat conectado; esperando mensajes nuevos.');
|
|
36
|
+
if(batch.offlineAt || (batch.items||[]).some(i=>i.snippet?.type===4)){ended=true;break;}
|
|
37
|
+
for(const item of batch.items||[]){
|
|
38
|
+
if(!item.id||this.seen.has(item.id))continue;
|
|
39
|
+
this.seen.add(item.id);if(this.seen.size>1000)this.seen.delete(this.seen.values().next().value);
|
|
40
|
+
const event=normalize(item);if(!event)continue;
|
|
41
|
+
// Initial streamList replies include history: never speak pre-connection messages.
|
|
42
|
+
if(!Number.isFinite(Date.parse(event.timestamp)) || Date.parse(event.timestamp)<this.startedAt)continue;
|
|
43
|
+
const safe=sanitize(event,this.config);if(!safe)continue;
|
|
44
|
+
this.log(`[Chat] ${safe.author}: ${safe.text}`);
|
|
45
|
+
// Future consumers receive the normalized event; listener failures stay isolated.
|
|
46
|
+
try{this.emit('message',event);}catch{this.log('[YouTube] Falló un consumidor de mensajes.');}
|
|
47
|
+
this.queue.enqueue(safe);
|
|
48
|
+
}
|
|
49
|
+
this.pageToken=batch.nextPageToken||this.pageToken;
|
|
50
|
+
}
|
|
51
|
+
if(ended){this.endChat();await this.sleep(60000,signal);continue;}
|
|
52
|
+
if(!signal.aborted){
|
|
53
|
+
// Successful EOF with a cursor is a continuation, not an authentication failure.
|
|
54
|
+
if(received&&continuation){await this.sleep(1000,signal);continue;}
|
|
55
|
+
throw Object.assign(Error('Chat stream ended without continuation'),{code:14});
|
|
56
|
+
}
|
|
57
|
+
}catch(e){
|
|
58
|
+
if(signal.aborted)break;
|
|
59
|
+
if(e.code===16){
|
|
60
|
+
if(!this.config.apiKey||this.polling)this.auth.invalidate();
|
|
61
|
+
if(!this.polling&&this.config.httpFallback===true){this.polling=true;this.pageToken=undefined;this.status('DISCONNECTED','streamList rechazó las credenciales. Cambiando a HTTP de respaldo (cada 15 s).');continue;}
|
|
62
|
+
}
|
|
63
|
+
if([5,9].includes(e.code)){this.endChat();await this.sleep(60000,signal);continue;}
|
|
64
|
+
if(e.code===3)this.pageToken=undefined;
|
|
65
|
+
const ms=backoff[Math.min(failures++,backoff.length-1)];
|
|
66
|
+
// Do not print upstream payloads or request objects: they may contain credentials.
|
|
67
|
+
this.status('DISCONNECTED',e.code===16?`streamList/YouTube rechazó las credenciales (código 16). Reintento en ${ms/1000}s. Si OAuth funciona por HTTP, configura apiKeyFile para streamList. El respaldo HTTP requiere httpFallback: true.`:`Conexión/autorización no disponible${Number.isInteger(e.code)?' (código '+e.code+')':''}. Reintento en ${ms/1000}s. Si persiste, revisa OAuth, API y cuota.`);
|
|
68
|
+
await this.sleep(ms,signal);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
module.exports={YouTubeService,backoff};
|