@danieltmn/tamnora 0.7.1 → 0.9.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/CHANGELOG.md +95 -0
- package/README.md +9 -0
- package/SECURITY.md +11 -6
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +180 -119
- package/harness/packages/cli/src/commands/run.js +1 -1
- package/harness/packages/cli/src/commands/server.js +26 -50
- package/harness/packages/cli/src/commands/tui.js +1 -1
- package/harness/packages/cli/src/lib/daemon.js +92 -40
- package/harness/packages/cli/test/release.test.js +9 -0
- package/harness/packages/core/src/agent/modes.js +1 -1
- package/harness/packages/core/src/agent/runAgent.js +3 -0
- package/harness/packages/core/src/git/index.js +177 -0
- package/harness/packages/core/src/index.js +16 -1
- package/harness/packages/core/src/open.js +43 -0
- package/harness/packages/core/src/pid.js +35 -0
- package/harness/packages/core/src/procs/kill.js +95 -0
- package/harness/packages/core/src/tools/executors.js +16 -0
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/permissions.js +1 -0
- package/harness/packages/core/src/tools/schema.js +17 -0
- package/harness/packages/core/src/tts/edge.js +232 -0
- package/harness/packages/core/src/tunnel.js +180 -19
- package/harness/packages/core/test/open.test.js +19 -0
- package/harness/packages/core/test/procs.test.js +50 -0
- package/harness/packages/core/test/tools.test.js +12 -0
- package/harness/packages/core/test/tts.test.js +41 -0
- package/harness/packages/server/public/app.js +4715 -3669
- package/harness/packages/server/public/index.html +94 -5
- package/harness/packages/server/public/landing/index.html +5 -5
- package/harness/packages/server/public/reconcile.js +121 -0
- package/harness/packages/server/public/relayClient.js +170 -0
- package/harness/packages/server/public/runStream.js +207 -0
- package/harness/packages/server/public/styles.css +196 -23
- package/harness/packages/server/src/auth.js +29 -5
- package/harness/packages/server/src/lib/agentRun.js +77 -0
- package/harness/packages/server/src/lib/announcer.js +92 -0
- package/harness/packages/server/src/lib/approvals.js +25 -3
- package/harness/packages/server/src/lib/channels/telegram.js +85 -20
- package/harness/packages/server/src/lib/ffmpeg.js +48 -0
- package/harness/packages/server/src/lib/procs.js +3 -18
- package/harness/packages/server/src/lib/runManager.js +24 -2
- package/harness/packages/server/src/lib/sessionView.js +43 -0
- package/harness/packages/server/src/lib/tasks.js +4 -9
- package/harness/packages/server/src/lib/ttsSettings.js +42 -0
- package/harness/packages/server/src/lib/workspace.js +37 -1
- package/harness/packages/server/src/routes/chat.js +51 -47
- package/harness/packages/server/src/routes/events.js +35 -0
- package/harness/packages/server/src/routes/files.js +22 -26
- package/harness/packages/server/src/routes/folders.js +9 -17
- package/harness/packages/server/src/routes/git.js +35 -0
- package/harness/packages/server/src/routes/procs.js +25 -15
- package/harness/packages/server/src/routes/sessions.js +12 -19
- package/harness/packages/server/src/routes/shell.js +19 -0
- package/harness/packages/server/src/routes/state.js +32 -0
- package/harness/packages/server/src/routes/tts.js +82 -0
- package/harness/packages/server/src/server.js +25 -1
- package/harness/packages/server/test/announcer.test.js +51 -0
- package/harness/packages/server/test/reconcile.test.js +146 -0
- package/harness/packages/server/test/relay.test.js +219 -0
- package/harness/packages/server/test/runStream.test.js +245 -0
- package/harness/packages/server/test/security.test.js +252 -0
- package/harness/packages/server/test/server.test.js +617 -1
- package/harness/packages/server/test/telegram-voz.test.js +156 -0
- package/harness/packages/server/test/tts.test.js +133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
const RUN_TIMEOUT_MS = 8000;
|
|
4
|
+
|
|
5
|
+
/** Run a command, resolving `{ code, stdout }` (never rejects). */
|
|
6
|
+
function run(command, args, { timeout = RUN_TIMEOUT_MS } = {}) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
let child;
|
|
9
|
+
try {
|
|
10
|
+
child = spawn(command, args, { windowsHide: true });
|
|
11
|
+
} catch {
|
|
12
|
+
resolve({ code: -1, stdout: '' });
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
let stdout = '';
|
|
16
|
+
let settled = false;
|
|
17
|
+
const finish = (result) => {
|
|
18
|
+
if (settled) return;
|
|
19
|
+
settled = true;
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
resolve(result);
|
|
22
|
+
};
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
try {
|
|
25
|
+
child.kill();
|
|
26
|
+
} catch {
|
|
27
|
+
// already gone
|
|
28
|
+
}
|
|
29
|
+
finish({ code: -1, stdout });
|
|
30
|
+
}, timeout);
|
|
31
|
+
child.stdout?.on('data', (chunk) => {
|
|
32
|
+
if (stdout.length < 20000) stdout += chunk.toString();
|
|
33
|
+
});
|
|
34
|
+
child.on('error', () => finish({ code: -1, stdout }));
|
|
35
|
+
child.on('close', (code) => finish({ code: code ?? -1, stdout }));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function uniquePids(values) {
|
|
40
|
+
return [...new Set(values.map(Number).filter((pid) => Number.isInteger(pid) && pid > 0))];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Find PIDs listening on a TCP/UDP port (Windows `netstat`). */
|
|
44
|
+
async function pidsOnWindows(port) {
|
|
45
|
+
const { stdout } = await run('netstat', ['-ano']);
|
|
46
|
+
const pids = [];
|
|
47
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
48
|
+
const fields = line.trim().split(/\s+/);
|
|
49
|
+
if (fields.length < 4) continue;
|
|
50
|
+
if (fields[0].toUpperCase() !== 'TCP' && fields[0].toUpperCase() !== 'UDP') continue;
|
|
51
|
+
if (!fields[1].endsWith(`:${port}`)) continue;
|
|
52
|
+
pids.push(fields[fields.length - 1]);
|
|
53
|
+
}
|
|
54
|
+
return uniquePids(pids);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Find PIDs listening on a port (Unix: lsof, fuser or ss). */
|
|
58
|
+
async function pidsOnUnix(port) {
|
|
59
|
+
const lsof = await run('lsof', ['-ti', `tcp:${port}`]);
|
|
60
|
+
if (lsof.code === 0 && lsof.stdout.trim()) {
|
|
61
|
+
return uniquePids(lsof.stdout.split(/\s+/));
|
|
62
|
+
}
|
|
63
|
+
const fuser = await run('fuser', [`${port}/tcp`]);
|
|
64
|
+
if (fuser.code === 0 && fuser.stdout.trim()) {
|
|
65
|
+
return uniquePids(fuser.stdout.match(/\d+/g) ?? []);
|
|
66
|
+
}
|
|
67
|
+
const ss = await run('ss', ['-ltnp', `sport = :${port}`]);
|
|
68
|
+
if (ss.code === 0 && ss.stdout.trim()) {
|
|
69
|
+
return uniquePids([...ss.stdout.matchAll(/pid=(\d+)/g)].map((match) => match[1]));
|
|
70
|
+
}
|
|
71
|
+
return [];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Kill every process listening on `port`.
|
|
76
|
+
* @returns {Promise<{port:number,pids:number[],killed:number[],errors:string[]}>}
|
|
77
|
+
*/
|
|
78
|
+
export async function killPort(port) {
|
|
79
|
+
const value = Number(port);
|
|
80
|
+
if (!Number.isInteger(value) || value < 1 || value > 65535) {
|
|
81
|
+
throw new Error('Puerto inválido (usá 1-65535).');
|
|
82
|
+
}
|
|
83
|
+
const pids = process.platform === 'win32' ? await pidsOnWindows(value) : await pidsOnUnix(value);
|
|
84
|
+
const killed = [];
|
|
85
|
+
const errors = [];
|
|
86
|
+
for (const pid of pids) {
|
|
87
|
+
const result =
|
|
88
|
+
process.platform === 'win32'
|
|
89
|
+
? await run('taskkill', ['/PID', String(pid), '/T', '/F'])
|
|
90
|
+
: await run('kill', ['-9', String(pid)]);
|
|
91
|
+
if (result.code === 0) killed.push(pid);
|
|
92
|
+
else errors.push(String(pid));
|
|
93
|
+
}
|
|
94
|
+
return { port: value, pids, killed, errors };
|
|
95
|
+
}
|
|
@@ -404,6 +404,21 @@ export async function gitCommitTool(root, { message, files } = {}) {
|
|
|
404
404
|
return runGit(root, ['commit', '-m', message]);
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
+
const MAX_SPEAK_TEXT = 1200;
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Pedido de audio: valida el texto y lo devuelve. La sintesis y la reproduccion
|
|
411
|
+
* son del cliente (la consola lo reproduce y Telegram lo manda como nota de
|
|
412
|
+
* voz), asi que aca solo se normaliza el pedido y se acota el largo.
|
|
413
|
+
*/
|
|
414
|
+
export function speakTool(_root, { texto, voice, rate } = {}) {
|
|
415
|
+
const value = String(texto ?? '').replace(/\s+/g, ' ').trim();
|
|
416
|
+
if (!value) return 'Error: falta "texto".';
|
|
417
|
+
const recortado = value.length > MAX_SPEAK_TEXT ? `${value.slice(0, MAX_SPEAK_TEXT - 1).trimEnd()}…` : value;
|
|
418
|
+
const opciones = [voice, rate].filter(Boolean).join(', ');
|
|
419
|
+
return `Audio pedido${opciones ? ` (${opciones})` : ''}: "${recortado}". El cliente lo reproduce o lo envía como nota de voz.`;
|
|
420
|
+
}
|
|
421
|
+
|
|
407
422
|
export const EXECUTORS = {
|
|
408
423
|
read: readTool,
|
|
409
424
|
write: writeTool,
|
|
@@ -417,4 +432,5 @@ export const EXECUTORS = {
|
|
|
417
432
|
git_diff: gitDiffTool,
|
|
418
433
|
git_log: gitLogTool,
|
|
419
434
|
git_commit: gitCommitTool,
|
|
435
|
+
speak: speakTool,
|
|
420
436
|
};
|
|
@@ -50,6 +50,8 @@ export function summarizeToolCall(name, args = {}) {
|
|
|
50
50
|
}
|
|
51
51
|
case 'fetch_url':
|
|
52
52
|
return shorten(a.url, 90);
|
|
53
|
+
case 'speak':
|
|
54
|
+
return shorten(a.texto, 90);
|
|
53
55
|
case 'git_diff':
|
|
54
56
|
return [a.ref ? `vs ${shorten(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? path(a.path) : '']
|
|
55
57
|
.filter(Boolean)
|
|
@@ -133,6 +133,23 @@ export const TOOL_DEFINITIONS = [
|
|
|
133
133
|
},
|
|
134
134
|
},
|
|
135
135
|
},
|
|
136
|
+
{
|
|
137
|
+
type: 'function',
|
|
138
|
+
function: {
|
|
139
|
+
name: 'speak',
|
|
140
|
+
description:
|
|
141
|
+
'Speak a short text aloud to the user with text-to-speech. Use it when the user asks to hear something (e.g. an audio summary). Keep it concise: the web console plays it and Telegram sends it as a voice note.',
|
|
142
|
+
parameters: {
|
|
143
|
+
type: 'object',
|
|
144
|
+
properties: {
|
|
145
|
+
texto: { type: 'string', description: 'Text to speak (long text is trimmed)' },
|
|
146
|
+
voice: { type: 'string', description: 'Optional voice id, e.g. "es-AR-ElenaNeural"' },
|
|
147
|
+
rate: { type: 'string', description: 'Optional speed, e.g. "+10%"' },
|
|
148
|
+
},
|
|
149
|
+
required: ['texto'],
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
},
|
|
136
153
|
{
|
|
137
154
|
type: 'function',
|
|
138
155
|
function: {
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
// Sintesis de voz (TTS) con el servicio Edge Read Aloud de Microsoft: las
|
|
2
|
+
// voces neuronales gratuitas de Edge, sin API key ni cuenta de Azure.
|
|
3
|
+
//
|
|
4
|
+
// Port del cliente de streaming de voice-tts, adaptado al WebSocket global de
|
|
5
|
+
// Node (>=22, undici): ese WebSocket acepta `origin` y `headers` como bag de
|
|
6
|
+
// opciones (extension no estandar de undici), que el servicio exige (User-Agent
|
|
7
|
+
// de Edge + token anti-abuso Sec-MS-GEC en la URL). Sin dependencias.
|
|
8
|
+
//
|
|
9
|
+
// Aqui la salida es buffered (Promise<Buffer>): las frases que habla el harness
|
|
10
|
+
// son cortas, el MP3 completo ronda los 50-150 KB y no vale la pena exponer un
|
|
11
|
+
// stream con backpressure. Trampas heredadas del original:
|
|
12
|
+
// - sin Sec-MS-GEC (SHA-256 del file time de Windows + token) el servicio da 403;
|
|
13
|
+
// - un cierre del socket antes de `turn.end` deja un MP3 "truncado pero
|
|
14
|
+
// reproducible": eso se convierte en error, no en silencio.
|
|
15
|
+
|
|
16
|
+
import { createHash, randomBytes } from 'node:crypto';
|
|
17
|
+
|
|
18
|
+
/** Token publico del cliente de Edge (via en el codigo del navegador). */
|
|
19
|
+
export const TRUSTED_CLIENT_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
|
|
20
|
+
|
|
21
|
+
/** Version de Edge que declaramos. Si el servicio empieza a rechazar, subirla. */
|
|
22
|
+
export const EDGE_VERSION = process.env.TTS_EDGE_VERSION ?? '143.0.3650.75';
|
|
23
|
+
|
|
24
|
+
/** Formato por defecto: MP3 a 24 kHz, 48 kbps, mono (CBR). */
|
|
25
|
+
export const FORMATO_POR_DEFECTO = 'audio-24khz-48kbitrate-mono-mp3';
|
|
26
|
+
|
|
27
|
+
/** Tope de texto por sintesis: el servicio rechaza mas de ~3000 caracteres. */
|
|
28
|
+
export const MAX_TEXTO = 2000;
|
|
29
|
+
|
|
30
|
+
/** Voz por defecto del harness. */
|
|
31
|
+
export const VOZ_POR_DEFECTO = 'es-AR-ElenaNeural';
|
|
32
|
+
|
|
33
|
+
const URL_BASE = 'wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1';
|
|
34
|
+
const URL_VOCES = `https://speech.platform.bing.com/consumer/speech/synthesize/readaloud/voices/list?trustedclienttoken=${TRUSTED_CLIENT_TOKEN}`;
|
|
35
|
+
const ORIGEN_EDGE = 'chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold';
|
|
36
|
+
const SEGUNDOS_EPOCA_WINDOWS = 11644473600;
|
|
37
|
+
const VENTANA_TICKS = 3000000000n; // 5 minutos en ticks de 100 ns
|
|
38
|
+
const DELIMITADOR_AUDIO = Buffer.from('Path:audio\r\n');
|
|
39
|
+
|
|
40
|
+
const UA_EDGE = `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${EDGE_VERSION.split('.')[0]}.0.0.0 Safari/537.36 Edg/${EDGE_VERSION.split('.')[0]}.0.0.0`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Token anti-abuso `Sec-MS-GEC` que exige el servicio: SHA-256 del file time
|
|
44
|
+
* de Windows redondeado a 5 minutos + el client token.
|
|
45
|
+
*
|
|
46
|
+
* @param {number} [ahora] Marca de tiempo en ms (inyectable para pruebas).
|
|
47
|
+
* @returns {string} SHA-256 en mayusculas.
|
|
48
|
+
*/
|
|
49
|
+
export function generarSecMsGec(ahora = Date.now()) {
|
|
50
|
+
const segundos = Math.floor(ahora / 1000 + SEGUNDOS_EPOCA_WINDOWS);
|
|
51
|
+
const ticks = BigInt(segundos) * 10000000n;
|
|
52
|
+
const redondeado = ticks - (ticks % VENTANA_TICKS);
|
|
53
|
+
return createHash('sha256').update(`${redondeado}${TRUSTED_CLIENT_TOKEN}`, 'ascii').digest('hex').toUpperCase();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function escaparXml(valor) {
|
|
57
|
+
return String(valor).replace(/[<>&"']/g, (c) => {
|
|
58
|
+
switch (c) {
|
|
59
|
+
case '<': return '<';
|
|
60
|
+
case '>': return '>';
|
|
61
|
+
case '&': return '&';
|
|
62
|
+
case '"': return '"';
|
|
63
|
+
default: return ''';
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** SSML de una sintesis. Exportado para probar el escape y la prosodia. */
|
|
69
|
+
export function generarSsml({ voz, lang, rate, pitch, volume, texto }) {
|
|
70
|
+
const locale = lang ?? (/\w{2}-\w{2}/.exec(String(voz ?? ''))?.[0] ?? '');
|
|
71
|
+
if (!locale) throw new Error(`No se pudo deducir el locale de la voz "${voz}".`);
|
|
72
|
+
return (
|
|
73
|
+
`<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="${escaparXml(locale)}">` +
|
|
74
|
+
`<voice name="${escaparXml(voz)}">` +
|
|
75
|
+
`<prosody rate="${escaparXml(rate)}" pitch="${escaparXml(pitch)}" volume="${escaparXml(volume)}">` +
|
|
76
|
+
`${escaparXml(texto)}` +
|
|
77
|
+
'</prosody></voice></speak>'
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function urlSintesis() {
|
|
82
|
+
const parametros = new URLSearchParams({
|
|
83
|
+
TrustedClientToken: TRUSTED_CLIENT_TOKEN,
|
|
84
|
+
'Sec-MS-GEC': generarSecMsGec(),
|
|
85
|
+
'Sec-MS-GEC-Version': `1-${EDGE_VERSION}`,
|
|
86
|
+
ConnectionId: randomBytes(16).toString('hex'),
|
|
87
|
+
});
|
|
88
|
+
return `${URL_BASE}?${parametros.toString()}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Sintetiza texto y devuelve el MP3 completo.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} texto Texto a sintetizar (se recorta a MAX_TEXTO).
|
|
95
|
+
* @param {object} [opciones]
|
|
96
|
+
* @param {string} [opciones.voice] Voz (ej. "es-AR-ElenaNeural").
|
|
97
|
+
* @param {string} [opciones.rate] Velocidad, ej. "+20%".
|
|
98
|
+
* @param {string} [opciones.pitch] Tono, ej. "-10Hz".
|
|
99
|
+
* @param {string} [opciones.volume] Volumen, ej. "+0%".
|
|
100
|
+
* @param {string} [opciones.outputFormat] Formato de salida del servicio.
|
|
101
|
+
* @param {number} [opciones.idleTimeout] Maximo de ms sin frames antes de abortar.
|
|
102
|
+
* @param {AbortSignal} [opciones.signal] Para cancelar la sintesis.
|
|
103
|
+
* @returns {Promise<Buffer>} MP3.
|
|
104
|
+
*/
|
|
105
|
+
export function sintetizar(texto, opciones = {}) {
|
|
106
|
+
const {
|
|
107
|
+
voice: voz = VOZ_POR_DEFECTO,
|
|
108
|
+
rate = '+0%',
|
|
109
|
+
pitch = '+0Hz',
|
|
110
|
+
volume = '+0%',
|
|
111
|
+
outputFormat = FORMATO_POR_DEFECTO,
|
|
112
|
+
idleTimeout = 15000,
|
|
113
|
+
signal,
|
|
114
|
+
} = opciones;
|
|
115
|
+
|
|
116
|
+
const contenido = String(texto ?? '').slice(0, MAX_TEXTO).trim();
|
|
117
|
+
if (!contenido) throw new Error('El texto está vacío: no hay nada que sintetizar.');
|
|
118
|
+
if (signal?.aborted) throw new Error('La síntesis se canceló antes de empezar.');
|
|
119
|
+
if (typeof WebSocket === 'undefined') {
|
|
120
|
+
throw new Error('Este Node no trae WebSocket global (se necesita Node 22+).');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
const chunks = [];
|
|
125
|
+
const ws = new WebSocket(urlSintesis(), {
|
|
126
|
+
origin: ORIGEN_EDGE,
|
|
127
|
+
headers: {
|
|
128
|
+
Pragma: 'no-cache',
|
|
129
|
+
'Cache-Control': 'no-cache',
|
|
130
|
+
'Accept-Encoding': 'gzip, deflate, br, zstd',
|
|
131
|
+
'Accept-Language': 'en-US,en;q=0.9',
|
|
132
|
+
'User-Agent': UA_EDGE,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
ws.binaryType = 'arraybuffer';
|
|
136
|
+
|
|
137
|
+
let termino = false;
|
|
138
|
+
let vigilante = null;
|
|
139
|
+
|
|
140
|
+
const fallar = (error) => {
|
|
141
|
+
if (termino) return;
|
|
142
|
+
termino = true;
|
|
143
|
+
clearTimeout(vigilante);
|
|
144
|
+
signal?.removeEventListener('abort', alAbortar);
|
|
145
|
+
try {
|
|
146
|
+
ws.close();
|
|
147
|
+
} catch {
|
|
148
|
+
// ya cerrado
|
|
149
|
+
}
|
|
150
|
+
reject(error);
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const alAbortar = () => fallar(new Error('La síntesis se canceló.'));
|
|
154
|
+
const vigilar = () => {
|
|
155
|
+
clearTimeout(vigilante);
|
|
156
|
+
vigilante = setTimeout(() => fallar(new Error(`El servicio no envió audio en ${idleTimeout} ms.`)), idleTimeout);
|
|
157
|
+
vigilante.unref?.();
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
signal?.addEventListener('abort', alAbortar, { once: true });
|
|
161
|
+
vigilar();
|
|
162
|
+
|
|
163
|
+
ws.addEventListener('open', () => {
|
|
164
|
+
ws.send(
|
|
165
|
+
'Content-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n' +
|
|
166
|
+
JSON.stringify({
|
|
167
|
+
context: {
|
|
168
|
+
synthesis: {
|
|
169
|
+
audio: {
|
|
170
|
+
metadataoptions: { sentenceBoundaryEnabled: false, wordBoundaryEnabled: false },
|
|
171
|
+
outputFormat,
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
);
|
|
177
|
+
ws.send(
|
|
178
|
+
`X-RequestId:${randomBytes(16).toString('hex')}\r\nContent-Type:application/ssml+xml\r\nPath:ssml\r\n\r\n` +
|
|
179
|
+
generarSsml({ voz, rate, pitch, volume, texto: contenido })
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
ws.addEventListener('message', (event) => {
|
|
184
|
+
vigilar();
|
|
185
|
+
if (event.data instanceof ArrayBuffer) {
|
|
186
|
+
const frame = Buffer.from(event.data);
|
|
187
|
+
const corte = frame.indexOf(DELIMITADOR_AUDIO);
|
|
188
|
+
if (corte === -1) return;
|
|
189
|
+
const audio = frame.subarray(corte + DELIMITADOR_AUDIO.length);
|
|
190
|
+
if (audio.length) chunks.push(Buffer.from(audio));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
const frame = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString();
|
|
194
|
+
if (frame.includes('Path:turn.end')) {
|
|
195
|
+
termino = true;
|
|
196
|
+
clearTimeout(vigilante);
|
|
197
|
+
signal?.removeEventListener('abort', alAbortar);
|
|
198
|
+
ws.close();
|
|
199
|
+
if (!chunks.length) reject(new Error('El servicio cerró sin entregar audio.'));
|
|
200
|
+
else resolve(Buffer.concat(chunks));
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
ws.addEventListener('error', (event) => {
|
|
205
|
+
fallar(new Error(`Error de WebSocket con el servicio: ${event.message ?? 'sin detalle'}`));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
ws.addEventListener('close', () => {
|
|
209
|
+
fallar(new Error('El servicio cerró la conexión antes de terminar: el audio quedaría truncado.'));
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Catalogo de voces del servicio (endpoint publico, con cache propio).
|
|
216
|
+
*
|
|
217
|
+
* @param {string} [filtro] Prefijo de locale (ej. "es-"). Vacio = todas.
|
|
218
|
+
* @param {object} [opciones]
|
|
219
|
+
* @param {AbortSignal} [opciones.signal] Para cancelar la peticion.
|
|
220
|
+
* @returns {Promise<Array<object>>} Voces que empiezan con el filtro.
|
|
221
|
+
*/
|
|
222
|
+
export async function listarVoces(filtro = 'es-', { signal } = {}) {
|
|
223
|
+
const respuesta = await fetch(URL_VOCES, {
|
|
224
|
+
headers: { 'User-Agent': UA_EDGE, Accept: 'application/json' },
|
|
225
|
+
signal,
|
|
226
|
+
});
|
|
227
|
+
if (!respuesta.ok) throw new Error(`El servicio de voces respondió HTTP ${respuesta.status}.`);
|
|
228
|
+
const voces = await respuesta.json();
|
|
229
|
+
if (!Array.isArray(voces)) throw new Error('La respuesta del servicio de voces no es una lista.');
|
|
230
|
+
if (!filtro) return voces;
|
|
231
|
+
return voces.filter((v) => String(v.Locale ?? '').startsWith(filtro));
|
|
232
|
+
}
|
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { bridgeStatePath } from './config/home.js';
|
|
4
|
+
import { pidAlive, killPidTree } from './pid.js';
|
|
2
5
|
|
|
3
6
|
/**
|
|
4
7
|
* Tuneles publicos. v1 soporta tunnelmole (gratis, sin cuenta; URL aleatoria)
|
|
@@ -7,27 +10,39 @@ import { spawn } from 'node:child_process';
|
|
|
7
10
|
|
|
8
11
|
function killTree(child) {
|
|
9
12
|
if (!child || child.killed) return;
|
|
10
|
-
|
|
13
|
+
killPidTree(child.pid);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Estado del bridge (escrito por `tamnora bridge`), si el supervisor sigue
|
|
18
|
+
* vivo. Lo consulta el server para saber que hay un tunel publico activo y
|
|
19
|
+
* ajustar el modelo de confianza (guards locales, cookie Secure).
|
|
20
|
+
* Cache de 1 s: se lee en cada request sensible y el pid no cambia tan rapido.
|
|
21
|
+
*/
|
|
22
|
+
let tunnelCache = { key: null, at: 0, state: null };
|
|
23
|
+
export function tunnelState() {
|
|
24
|
+
let statePath;
|
|
11
25
|
try {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
try {
|
|
23
|
-
child.kill('SIGTERM');
|
|
24
|
-
} catch {
|
|
25
|
-
/* ya murio */
|
|
26
|
-
}
|
|
27
|
-
}
|
|
26
|
+
statePath = bridgeStatePath();
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
if (tunnelCache.key === statePath && now - tunnelCache.at < 1000) return tunnelCache.state;
|
|
32
|
+
let state = null;
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(readFileSync(statePath, 'utf8'));
|
|
35
|
+
if (parsed?.pid && pidAlive(parsed.pid)) state = parsed;
|
|
28
36
|
} catch {
|
|
29
|
-
/*
|
|
37
|
+
/* sin bridge */
|
|
30
38
|
}
|
|
39
|
+
tunnelCache = { key: statePath, at: now, state };
|
|
40
|
+
return state;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** True si hay un tunel publico activo hacia este server. */
|
|
44
|
+
export function tunnelActive() {
|
|
45
|
+
return Boolean(tunnelState());
|
|
31
46
|
}
|
|
32
47
|
|
|
33
48
|
export function parseTunnelmoleUrls(text) {
|
|
@@ -74,7 +89,7 @@ function spawnTunnel(bin, args, opts = {}) {
|
|
|
74
89
|
settled = true;
|
|
75
90
|
clearTimeout(timer);
|
|
76
91
|
log(`${label}: ${url} -> localhost:${opts.port}`);
|
|
77
|
-
resolve({ url, provider: opts.provider, pid: child.pid, stop: () => killTree(child) });
|
|
92
|
+
resolve({ url, provider: opts.provider, pid: child.pid, child, stop: () => killTree(child) });
|
|
78
93
|
}
|
|
79
94
|
};
|
|
80
95
|
child.stdout.on('data', feed);
|
|
@@ -104,10 +119,156 @@ export function startTunnelmole(port, opts = {}) {
|
|
|
104
119
|
});
|
|
105
120
|
}
|
|
106
121
|
|
|
122
|
+
/**
|
|
123
|
+
* Relay propio (harness.tamnora.com): mailbox HTTP sobre cPanel (solo PHP).
|
|
124
|
+
* El daemon no recibe conexiones: llama hacia afuera (a=poll), ejecuta cada
|
|
125
|
+
* request contra el server local y empuja las respuestas (a=push).
|
|
126
|
+
*/
|
|
127
|
+
export function startRelay(port, opts = {}) {
|
|
128
|
+
const relayUrl = String(opts.relayUrl ?? '').replace(/\/+$/, '');
|
|
129
|
+
const token = String(opts.token ?? '');
|
|
130
|
+
if (!relayUrl || !/^https?:\/\//.test(relayUrl)) {
|
|
131
|
+
throw new Error('relay: falta tunnel.relayUrl (https://harness.tamnora.com/index.php)');
|
|
132
|
+
}
|
|
133
|
+
if (!token) throw new Error('relay: falta tunnel.token');
|
|
134
|
+
const log = opts.log ?? (() => {});
|
|
135
|
+
const local = `http://127.0.0.1:${port}`;
|
|
136
|
+
const state = { stopped: false };
|
|
137
|
+
|
|
138
|
+
const call = async (action, body = {}, { timeoutMs = 30000 } = {}) => {
|
|
139
|
+
const res = await fetch(`${relayUrl}?a=${action}`, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { 'Content-Type': 'application/json' },
|
|
142
|
+
body: JSON.stringify({ token, ...body }),
|
|
143
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok) throw new Error(`relay ${action}: HTTP ${res.status}`);
|
|
146
|
+
return res.json();
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
async function push(rid, chunks) {
|
|
150
|
+
await call('push', { rid, chunks }, { timeoutMs: 60000 });
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function handleRequest(row) {
|
|
154
|
+
const envelope = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
|
|
155
|
+
const method = envelope.method ?? 'GET';
|
|
156
|
+
const path = String(envelope.path ?? '/');
|
|
157
|
+
const target = `${local}${path.startsWith('/') ? path : `/${path}`}`;
|
|
158
|
+
const init = { method, headers: { ...envelope.headers } };
|
|
159
|
+
delete init.headers.host;
|
|
160
|
+
if (envelope.body != null) init.body = envelope.body;
|
|
161
|
+
const res = await fetch(target, init);
|
|
162
|
+
const contentType = String(res.headers.get('content-type') ?? '');
|
|
163
|
+
const flat = {};
|
|
164
|
+
for (const header of ['content-type']) {
|
|
165
|
+
const value = res.headers.get(header);
|
|
166
|
+
if (value) flat[header] = value;
|
|
167
|
+
}
|
|
168
|
+
const cookies = res.headers.getSetCookie?.() ?? [];
|
|
169
|
+
if (cookies.length) flat['set-cookie'] = cookies;
|
|
170
|
+
|
|
171
|
+
if (!contentType.includes('text/event-stream')) {
|
|
172
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
173
|
+
await push(row.id, [
|
|
174
|
+
{
|
|
175
|
+
status: res.status,
|
|
176
|
+
headers: flat,
|
|
177
|
+
body: buffer.toString('base64'),
|
|
178
|
+
done: true,
|
|
179
|
+
},
|
|
180
|
+
]);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// SSE: reenviamos cada frame tal cual (el cliente sabe reconectar con cursor).
|
|
185
|
+
let seq = 0;
|
|
186
|
+
await push(row.id, [{ seq: seq++, status: res.status, headers: flat, done: false }]);
|
|
187
|
+
const reader = res.body.getReader();
|
|
188
|
+
const decoder = new TextDecoder();
|
|
189
|
+
let buffer = '';
|
|
190
|
+
for (;;) {
|
|
191
|
+
const { value, done } = await reader.read();
|
|
192
|
+
if (done) break;
|
|
193
|
+
buffer += decoder.decode(value, { stream: true });
|
|
194
|
+
let index;
|
|
195
|
+
while ((index = buffer.indexOf('\n\n')) !== -1) {
|
|
196
|
+
const frame = buffer.slice(0, index);
|
|
197
|
+
buffer = buffer.slice(index + 2);
|
|
198
|
+
let ev = '';
|
|
199
|
+
let data = '';
|
|
200
|
+
let id = null;
|
|
201
|
+
for (const line of frame.split('\n')) {
|
|
202
|
+
if (line.startsWith('event:')) ev = line.slice(6).trim();
|
|
203
|
+
else if (line.startsWith('data:')) data += line.slice(5).trim();
|
|
204
|
+
else if (line.startsWith('id:')) id = line.slice(3).trim();
|
|
205
|
+
}
|
|
206
|
+
if (!ev && !data) continue;
|
|
207
|
+
await push(row.id, [{ seq: seq++, ev: ev || 'message', data, id, done: false }]);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
await push(row.id, [{ done: true }]);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
async function pump() {
|
|
214
|
+
const registered = await call('register', {}, { timeoutMs: 20000 });
|
|
215
|
+
let cursor = Number(registered.cursor) || 0;
|
|
216
|
+
log(`relay: conectado a ${relayUrl} (inbox desde ${cursor})`);
|
|
217
|
+
while (!state.stopped) {
|
|
218
|
+
let rows;
|
|
219
|
+
try {
|
|
220
|
+
const batch = await call('poll', { after: cursor, wait: 20 }, { timeoutMs: 40000 });
|
|
221
|
+
rows = batch.rows ?? [];
|
|
222
|
+
} catch (error) {
|
|
223
|
+
if (state.stopped) break;
|
|
224
|
+
log(`relay poll: ${error?.message ?? error}`);
|
|
225
|
+
await new Promise((r) => setTimeout(r, 3000));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
for (const row of rows) {
|
|
229
|
+
cursor = Math.max(cursor, Number(row.id) || 0);
|
|
230
|
+
try {
|
|
231
|
+
await handleRequest(row);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
log(`relay request ${row.id}: ${error?.message ?? error}`);
|
|
234
|
+
try {
|
|
235
|
+
await push(row.id, [
|
|
236
|
+
{
|
|
237
|
+
status: 502,
|
|
238
|
+
headers: { 'content-type': 'application/json; charset=utf-8' },
|
|
239
|
+
body: Buffer.from(JSON.stringify({ error: `harness inaccesible: ${error?.message ?? error}` })).toString('base64'),
|
|
240
|
+
done: true,
|
|
241
|
+
},
|
|
242
|
+
]);
|
|
243
|
+
} catch {
|
|
244
|
+
/* nada */
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
pump().catch((error) => {
|
|
252
|
+
if (!state.stopped) log(`relay pump: ${error?.message ?? error}`);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
url: relayUrl,
|
|
257
|
+
provider: 'relay',
|
|
258
|
+
pid: 0,
|
|
259
|
+
child: null,
|
|
260
|
+
probeUrl: `${relayUrl}?a=status`,
|
|
261
|
+
stop() {
|
|
262
|
+
state.stopped = true;
|
|
263
|
+
},
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
107
267
|
/** Devuelve { url, provider, pid, stop }. provider 'none' no abre nada. */
|
|
108
268
|
export async function startTunnel(port, provider = 'tunnelmole', opts = {}) {
|
|
109
269
|
const p = String(provider || 'tunnelmole').toLowerCase();
|
|
110
270
|
if (p === 'none' || p === 'off' || p === '') return { url: '', provider: 'none', pid: 0, stop: () => {} };
|
|
111
271
|
if (p === 'tunnelmole' || p === 'tmole') return startTunnelmole(port, opts);
|
|
272
|
+
if (p === 'relay') return startRelay(port, opts);
|
|
112
273
|
throw new Error(`proveedor de tunel no soportado: ${provider}`);
|
|
113
274
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { openerFor } from '../src/open.js';
|
|
4
|
+
|
|
5
|
+
test('usa el abridor nativo de cada plataforma', () => {
|
|
6
|
+
// En Windows `rundll32` evita el shell: una URL con & o ? no debe pasar por cmd.
|
|
7
|
+
assert.deepEqual(openerFor('http://127.0.0.1:4517', 'win32'), [
|
|
8
|
+
'rundll32.exe',
|
|
9
|
+
['url.dll,FileProtocolHandler', 'http://127.0.0.1:4517'],
|
|
10
|
+
]);
|
|
11
|
+
assert.deepEqual(openerFor('http://127.0.0.1:4517', 'darwin'), ['open', ['http://127.0.0.1:4517']]);
|
|
12
|
+
assert.deepEqual(openerFor('http://127.0.0.1:4517', 'linux'), ['xdg-open', ['http://127.0.0.1:4517']]);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('mantiene la query string intacta', () => {
|
|
16
|
+
const url = 'http://127.0.0.1:4517/?a=1&b=2';
|
|
17
|
+
const [, args] = openerFor(url, 'linux');
|
|
18
|
+
assert.deepEqual(args, [url]);
|
|
19
|
+
});
|