@danieltmn/tamnora 0.9.1 → 0.9.2

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.
Files changed (27) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/harness/packages/core/src/config/app.js +2 -0
  3. package/harness/packages/core/src/index.js +1 -0
  4. package/harness/packages/core/src/stt/google.js +91 -0
  5. package/harness/packages/server/public/app.js +652 -9
  6. package/harness/packages/server/public/index.html +120 -1
  7. package/harness/packages/server/public/styles.css +19 -0
  8. package/harness/packages/server/src/lib/channels/index.js +23 -3
  9. package/harness/packages/server/src/lib/channels/telegram.js +301 -93
  10. package/harness/packages/server/src/lib/presence.js +51 -0
  11. package/harness/packages/server/src/lib/profile.js +63 -0
  12. package/harness/packages/server/src/lib/prompt.js +7 -2
  13. package/harness/packages/server/src/lib/transcribe.js +51 -0
  14. package/harness/packages/server/src/routes/channels.js +2 -0
  15. package/harness/packages/server/src/routes/presence.js +17 -0
  16. package/harness/packages/server/src/routes/profile.js +22 -0
  17. package/harness/packages/server/src/routes/providers.js +2 -0
  18. package/harness/packages/server/src/routes/transcribe.js +115 -0
  19. package/harness/packages/server/src/routes/tts.js +2 -1
  20. package/harness/packages/server/src/server.js +11 -0
  21. package/harness/packages/server/test/profile.test.js +85 -0
  22. package/harness/packages/server/test/telegram-features.test.js +241 -0
  23. package/harness/packages/server/test/telegram-voz.test.js +9 -0
  24. package/harness/packages/server/test/telegram.test.js +37 -2
  25. package/harness/packages/server/test/transcribe.test.js +215 -0
  26. package/harness/packages/server/test/tts.test.js +24 -0
  27. package/package.json +1 -1
@@ -0,0 +1,241 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { Store } from '../../core/src/index.js';
7
+ import { createApprovalBroker } from '../src/lib/approvals.js';
8
+ import { createPresence } from '../src/lib/presence.js';
9
+ import { startTelegramChannel } from '../src/lib/channels/telegram.js';
10
+
11
+ const reply = (data) => ({ ok: true, json: async () => data });
12
+
13
+ class FakeWebSocket {
14
+ constructor() {
15
+ this.listeners = new Map();
16
+ this.binaryType = 'arraybuffer';
17
+ setTimeout(() => {
18
+ this.fire('open', {});
19
+ this.fire('message', new TextEncoder().encode('Path:audio\r\nFFF364MP3').buffer);
20
+ this.fire('message', 'X-RequestId:1\r\nPath:turn.end\r\n');
21
+ }, 0);
22
+ }
23
+ addEventListener(type, fn) {
24
+ if (!this.listeners.has(type)) this.listeners.set(type, new Set());
25
+ this.listeners.get(type).add(fn);
26
+ }
27
+ removeEventListener() {}
28
+ send() {}
29
+ close() {
30
+ this.fire('close', {});
31
+ }
32
+ fire(type, arg) {
33
+ for (const fn of this.listeners.get(type) ?? []) fn({ data: arg, message: 'fake' });
34
+ }
35
+ }
36
+
37
+ function installFetchMock() {
38
+ const sent = [];
39
+ const multipart = [];
40
+ const updates = [];
41
+ const original = global.fetch;
42
+ global.fetch = async (url, opts = {}) => {
43
+ const method = String(url).split('/').pop();
44
+ if (opts.body instanceof FormData) {
45
+ multipart.push({ method, chatId: String(opts.body.get('chat_id') ?? '') });
46
+ return reply({ ok: true, result: true });
47
+ }
48
+ const body = opts.body ? JSON.parse(opts.body) : {};
49
+ if (method === 'getUpdates') {
50
+ const out = updates.splice(0, updates.length);
51
+ if (!out.length) await new Promise((resolve) => setTimeout(resolve, 20));
52
+ return reply({ ok: true, result: out });
53
+ }
54
+ if (method === 'sendMessage' || method === 'editMessageText') {
55
+ sent.push(body);
56
+ return reply({ ok: true, result: { message_id: sent.length } });
57
+ }
58
+ return reply({ ok: true, result: true });
59
+ };
60
+ return { sent, multipart, updates, restore: () => { global.fetch = original; } };
61
+ }
62
+
63
+ function makeCtx(cwd, { app = {}, presence } = {}) {
64
+ const store = new Store(':memory:');
65
+ const provider = {
66
+ id: 'fake',
67
+ async streamChat(_config, { onEvent }) {
68
+ onEvent?.({ type: 'text', delta: 'listo', full: 'listo' });
69
+ return { content: 'listo', reasoning: '', toolCalls: [] };
70
+ },
71
+ };
72
+ return {
73
+ cwd,
74
+ config: { maxRounds: 2, permissions: {}, telegram: {} },
75
+ app,
76
+ presence,
77
+ store,
78
+ approvals: createApprovalBroker({ timeoutMs: 1000 }),
79
+ effectiveProviderId: () => 'fake',
80
+ effectiveModel: () => 'm',
81
+ effectiveMode: () => 'agent',
82
+ providerSettings: () => ({ baseUrl: 'http://127.0.0.1:1', apiKey: '' }),
83
+ apiKeyFor: () => '',
84
+ customProviderDefs: () => [],
85
+ resolveProvider: () => ({ provider, config: {} }),
86
+ getMcp: async () => null,
87
+ systemPromptFor: async () => 'sys',
88
+ agentTools: { definitions: [], handles: () => false, execute: async () => '' },
89
+ };
90
+ }
91
+
92
+ async function waitFor(fn, timeout = 3000) {
93
+ const start = Date.now();
94
+ while (Date.now() - start < timeout) {
95
+ if (fn()) return;
96
+ await new Promise((resolve) => setTimeout(resolve, 10));
97
+ }
98
+ throw new Error('timeout esperando la condición');
99
+ }
100
+
101
+ function seedConversation(ctx, cwd, chatId = '7') {
102
+ const sessionId = ctx.store.createSession({ title: 'test', provider: 'fake', model: 'm', cwd });
103
+ ctx.store.setSetting(`telegram:chat:${chatId}`, sessionId);
104
+ ctx.store.addMessages(sessionId, [
105
+ { role: 'user', content: 'hola' },
106
+ { role: 'assistant', content: 'listo' },
107
+ ]);
108
+ return sessionId;
109
+ }
110
+
111
+ test('/resumen devuelve un resumen en texto', async () => {
112
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgfeat-'));
113
+ const ctx = makeCtx(cwd);
114
+ seedConversation(ctx, cwd);
115
+ const mock = installFetchMock();
116
+ const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
117
+ try {
118
+ mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: '/resumen' } });
119
+ await waitFor(() => mock.sent.some((m) => /📝 Resumen/.test(m.text ?? '')));
120
+ const resumen = mock.sent.find((m) => /📝 Resumen/.test(m.text ?? ''));
121
+ assert.match(resumen.text, /listo/);
122
+ } finally {
123
+ channel.stop();
124
+ mock.restore();
125
+ ctx.store.close();
126
+ }
127
+ });
128
+
129
+ test('/audioresumen devuelve el resumen como nota de voz', async () => {
130
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgfeat-'));
131
+ const ctx = makeCtx(cwd);
132
+ seedConversation(ctx, cwd);
133
+ const mock = installFetchMock();
134
+ const original = global.WebSocket;
135
+ global.WebSocket = FakeWebSocket;
136
+ const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
137
+ try {
138
+ mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: '/audioresumen' } });
139
+ await waitFor(() => mock.multipart.length > 0);
140
+ assert.ok(mock.sent.some((m) => /📝 Resumen/.test(m.text ?? '')));
141
+ assert.equal(mock.multipart[0].chatId, '7');
142
+ } finally {
143
+ global.WebSocket = original;
144
+ channel.stop();
145
+ mock.restore();
146
+ ctx.store.close();
147
+ }
148
+ });
149
+
150
+ test('/proyecto elige una carpeta y crea la sesión ahí', async () => {
151
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgfeat-'));
152
+ const ctx = makeCtx(cwd);
153
+ const mock = installFetchMock();
154
+ const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
155
+ try {
156
+ mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: `/proyecto ${cwd}` } });
157
+ await waitFor(() => mock.sent.some((m) => /Proyecto «/.test(m.text ?? '')));
158
+ const sessionId = ctx.store.getSetting('telegram:chat:7');
159
+ assert.ok(sessionId);
160
+ assert.equal(ctx.store.getSession(sessionId).cwd, cwd);
161
+ } finally {
162
+ channel.stop();
163
+ mock.restore();
164
+ ctx.store.close();
165
+ }
166
+ });
167
+
168
+ test('notify avisa de un proyecto abierto, con el nombre del proyecto', async () => {
169
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgfeat-'));
170
+ const presence = createPresence();
171
+ const ctx = makeCtx(cwd, { presence });
172
+ const sessionId = ctx.store.createSession({ title: 'consola', provider: 'fake', model: 'm', cwd });
173
+ presence.touch([sessionId]);
174
+ const mock = installFetchMock();
175
+ const channel = startTelegramChannel(ctx, {
176
+ token: 'x',
177
+ allowedChatIds: ['7'],
178
+ notify: { enabled: true },
179
+ log: () => {},
180
+ });
181
+ try {
182
+ channel.notify(sessionId, { type: 'done', data: { content: 'hecho', rounds: 1, toolCalls: 0, durationMs: 5 } });
183
+ await waitFor(() => mock.sent.some((m) => /Sesión terminada/.test(m.text ?? '')));
184
+ const msg = mock.sent.find((m) => /Sesión terminada/.test(m.text ?? ''));
185
+ assert.match(msg.text, new RegExp(`\\[${path.basename(cwd)}\\]`));
186
+ assert.match(msg.text, /hecho/);
187
+ } finally {
188
+ channel.stop();
189
+ mock.restore();
190
+ ctx.store.close();
191
+ }
192
+ });
193
+
194
+ test('notify no avisa si el proyecto no está abierto en la consola', async () => {
195
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgfeat-'));
196
+ const ctx = makeCtx(cwd, { presence: createPresence() });
197
+ const sessionId = ctx.store.createSession({ title: 'consola', provider: 'fake', model: 'm', cwd });
198
+ const mock = installFetchMock();
199
+ const channel = startTelegramChannel(ctx, {
200
+ token: 'x',
201
+ allowedChatIds: ['7'],
202
+ notify: { enabled: true },
203
+ log: () => {},
204
+ });
205
+ try {
206
+ channel.notify(sessionId, { type: 'done', data: { content: 'hecho' } });
207
+ await new Promise((resolve) => setTimeout(resolve, 150));
208
+ assert.equal(mock.sent.length, 0);
209
+ } finally {
210
+ channel.stop();
211
+ mock.restore();
212
+ ctx.store.close();
213
+ }
214
+ });
215
+
216
+ test('notify de permiso incluye el proyecto y los botones', async () => {
217
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgfeat-'));
218
+ const presence = createPresence();
219
+ const ctx = makeCtx(cwd, { presence });
220
+ const sessionId = ctx.store.createSession({ title: 'consola', provider: 'fake', model: 'm', cwd });
221
+ presence.touch([sessionId]);
222
+ const mock = installFetchMock();
223
+ const channel = startTelegramChannel(ctx, {
224
+ token: 'x',
225
+ allowedChatIds: ['7'],
226
+ notify: { enabled: true },
227
+ log: () => {},
228
+ });
229
+ try {
230
+ channel.notify(sessionId, { type: 'permission', data: { askId: 'ask-1', name: 'bash' } });
231
+ await waitFor(() => mock.sent.some((m) => /Permiso para bash/.test(m.text ?? '')));
232
+ const msg = mock.sent.find((m) => /Permiso para bash/.test(m.text ?? ''));
233
+ assert.match(msg.text, new RegExp(`\\[${path.basename(cwd)}\\]`));
234
+ const datas = msg.reply_markup.inline_keyboard.flat().map((b) => b.callback_data);
235
+ assert.ok(datas.includes('perm:ask-1:allow'));
236
+ } finally {
237
+ channel.stop();
238
+ mock.restore();
239
+ ctx.store.close();
240
+ }
241
+ });
@@ -100,9 +100,17 @@ async function waitFor(fn, timeout = 3000) {
100
100
  throw new Error('timeout esperando la condición');
101
101
  }
102
102
 
103
+ /** Deja un proyecto ya elegido (sin pasar por el selector). */
104
+ function preselect(ctx, cwd, chatId = '7') {
105
+ const sessionId = ctx.store.createSession({ title: 'test', provider: 'fake', model: 'm', cwd });
106
+ ctx.store.setSetting(`telegram:chat:${chatId}`, sessionId);
107
+ return sessionId;
108
+ }
109
+
103
110
  test('con la voz activada, el turno de Telegram termina con una nota de voz', async () => {
104
111
  const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgvoz-'));
105
112
  const ctx = makeCtx(cwd, { tts: { enabled: true, voice: 'es-AR-ElenaNeural', rate: '+0%', announce: { end: true } } });
113
+ preselect(ctx, cwd);
106
114
  const mock = installVozFetchMock();
107
115
  const original = global.WebSocket;
108
116
  global.WebSocket = FakeWebSocket;
@@ -123,6 +131,7 @@ test('con la voz activada, el turno de Telegram termina con una nota de voz', as
123
131
  test('con la voz apagada, el turno de Telegram termina sin audios', async () => {
124
132
  const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgvoz-'));
125
133
  const ctx = makeCtx(cwd, {});
134
+ preselect(ctx, cwd);
126
135
  const mock = installVozFetchMock();
127
136
  const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
128
137
  try {
@@ -67,9 +67,17 @@ async function waitFor(fn, timeout = 3000) {
67
67
  throw new Error('timeout esperando la condición');
68
68
  }
69
69
 
70
+ /** Deja un proyecto ya elegido (sin pasar por el selector). */
71
+ function preselect(ctx, cwd, chatId = '7') {
72
+ const sessionId = ctx.store.createSession({ title: 'test', provider: 'fake', model: 'm', cwd });
73
+ ctx.store.setSetting(`telegram:chat:${chatId}`, sessionId);
74
+ return sessionId;
75
+ }
76
+
70
77
  test('el canal de Telegram cierra el turno y muestra el panel', async () => {
71
78
  const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tg-'));
72
79
  const ctx = makeCtx(cwd);
80
+ preselect(ctx, cwd);
73
81
  const mock = installFetchMock();
74
82
  const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
75
83
  try {
@@ -80,8 +88,9 @@ test('el canal de Telegram cierra el turno y muestra el panel', async () => {
80
88
  assert.match(completion.text, /1 ronda/);
81
89
 
82
90
  mock.updates.push({ update_id: 2, message: { chat: { id: 7 }, text: '/menu' } });
83
- await waitFor(() => mock.sent.some((m) => m.reply_markup?.inline_keyboard));
84
- const menu = mock.sent.find((m) => m.reply_markup?.inline_keyboard);
91
+ await waitFor(() => mock.sent.some((m) => m.text?.startsWith('⚙ Panel')));
92
+ const menu = mock.sent.find((m) => m.text?.startsWith('⚙ Panel'));
93
+ assert.match(menu.text, /proyecto:/);
85
94
  const datas = menu.reply_markup.inline_keyboard.flat().map((b) => b.callback_data);
86
95
  assert.ok(datas.includes('mode:plan'));
87
96
  assert.ok(datas.includes('menu:models'));
@@ -92,3 +101,29 @@ test('el canal de Telegram cierra el turno y muestra el panel', async () => {
92
101
  ctx.store.close();
93
102
  }
94
103
  });
104
+
105
+ test('sin proyecto elegido, el bot pregunta en qué proyecto trabajar', async () => {
106
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tg-'));
107
+ const ctx = makeCtx(cwd);
108
+ const mock = installFetchMock();
109
+ const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
110
+ try {
111
+ mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: 'hola' } });
112
+ await waitFor(() => mock.sent.some((m) => /En qué proyecto/.test(m.text ?? '')));
113
+ const picker = mock.sent.find((m) => /En qué proyecto/.test(m.text ?? ''));
114
+ const datas = picker.reply_markup.inline_keyboard.flat().map((b) => b.callback_data);
115
+ assert.ok(datas.includes('proj:root'));
116
+
117
+ // Al elegir el proyecto corre el mensaje que había quedado pendiente.
118
+ mock.updates.push({
119
+ update_id: 2,
120
+ callback_query: { id: 'cb1', message: { chat: { id: 7 }, message_id: 1 }, data: 'proj:root' },
121
+ });
122
+ await waitFor(() => mock.sent.some((m) => /✓ terminado/.test(m.text ?? '')));
123
+ assert.ok(ctx.store.getSetting('telegram:chat:7'));
124
+ } finally {
125
+ channel.stop();
126
+ mock.restore();
127
+ ctx.store.close();
128
+ }
129
+ });
@@ -0,0 +1,215 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import { createTamnoraServer } from '../src/index.js';
7
+ import { setHome } from '../../core/src/index.js';
8
+ import { resolveTranscribe } from '../src/routes/transcribe.js';
9
+
10
+ /** Intercepta las llamadas de transcripción (proveedor y Google). */
11
+ function installTranscribeMock({ text = 'hola mundo', status = 200, error = 'audio inválido', googleText = 'hola google' } = {}) {
12
+ const original = global.fetch;
13
+ const calls = [];
14
+ const googleCalls = [];
15
+ global.fetch = async (url, opts = {}) => {
16
+ const target = String(url);
17
+ if (target.includes('/speech-api/v2/recognize')) {
18
+ googleCalls.push({ url: target });
19
+ const body = `${JSON.stringify({ result: [] })}\n${JSON.stringify({
20
+ result: [{ alternative: [{ transcript: googleText }], final: true }],
21
+ })}\n`;
22
+ return { ok: status === 200, status, text: async () => body, json: async () => ({}) };
23
+ }
24
+ if (target.includes('/audio/transcriptions')) {
25
+ const file = opts.body?.get?.('file');
26
+ calls.push({
27
+ url: target,
28
+ model: opts.body?.get?.('model'),
29
+ filename: file?.name,
30
+ type: file?.type,
31
+ language: opts.body?.get?.('language'),
32
+ });
33
+ return {
34
+ ok: status === 200,
35
+ status,
36
+ json: async () => ({ text }),
37
+ text: async () => JSON.stringify({ error: { message: error } }),
38
+ };
39
+ }
40
+ return original(url, opts);
41
+ };
42
+ return { calls, googleCalls, restore: () => { global.fetch = original; } };
43
+ }
44
+
45
+ async function startServer() {
46
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tr-'));
47
+ setHome(cwd);
48
+ const instance = await createTamnoraServer({
49
+ cwd,
50
+ dbPath: path.join(cwd, 'tamnora.db'),
51
+ loadMcp: false,
52
+ });
53
+ const info = await instance.listen({ port: 0 });
54
+ return { instance, base: info.url };
55
+ }
56
+
57
+ const dataUrl = (bytes) => `data:audio/webm;base64,${Buffer.from(bytes).toString('base64')}`;
58
+
59
+ test('resolveTranscribe prioriza own -> Telegram -> activo', () => {
60
+ assert.deepEqual(resolveTranscribe({}), { provider: undefined, model: undefined });
61
+ const fromTelegram = resolveTranscribe({ telegram: { transcribeProvider: 'groq', transcribeModel: 'whisper-large-v3-turbo' } });
62
+ assert.equal(fromTelegram.provider, 'groq');
63
+ assert.equal(fromTelegram.model, 'whisper-large-v3-turbo');
64
+ const own = resolveTranscribe(
65
+ { transcribe: { provider: 'openai', model: 'whisper-1' }, telegram: { transcribeProvider: 'groq' } },
66
+ { provider: 'mistral' }
67
+ );
68
+ assert.equal(own.provider, 'mistral');
69
+ assert.equal(own.model, 'whisper-1');
70
+ });
71
+
72
+ test('el dictado guarda su proveedor/modelo y lo usa', async () => {
73
+ const { instance, base } = await startServer();
74
+ const mock = installTranscribeMock({ text: 'dale' });
75
+ try {
76
+ const put = await fetch(`${base}/api/transcribe/settings`, {
77
+ method: 'PUT',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({ mode: 'browser', language: 'es-MX', provider: 'groq', model: 'whisper-large-v3-turbo' }),
80
+ });
81
+ assert.equal(put.status, 200);
82
+ const saved = await put.json();
83
+ assert.equal(saved.mode, 'browser');
84
+ assert.equal(saved.language, 'es-MX');
85
+
86
+ // El modo browser no afecta la transcripcion por server: usa el provider.
87
+ const res = await fetch(`${base}/api/transcribe`, {
88
+ method: 'POST',
89
+ headers: { 'Content-Type': 'application/json' },
90
+ body: JSON.stringify({ audio: dataUrl('x') }),
91
+ });
92
+ assert.equal(res.status, 200);
93
+ assert.match(mock.calls[0].url, /groq\.com/);
94
+ assert.equal(mock.calls[0].model, 'whisper-large-v3-turbo');
95
+ } finally {
96
+ mock.restore();
97
+ await instance.close();
98
+ }
99
+ });
100
+
101
+ test('POST /api/transcribe devuelve el texto del proveedor', async () => {
102
+ const { instance, base } = await startServer();
103
+ const mock = installTranscribeMock({ text: 'prendé las luces' });
104
+ try {
105
+ const res = await fetch(`${base}/api/transcribe`, {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({ audio: dataUrl('fake-audio') }),
109
+ });
110
+ assert.equal(res.status, 200);
111
+ assert.equal((await res.json()).text, 'prendé las luces');
112
+ assert.equal(mock.calls.length, 1);
113
+ assert.equal(mock.calls[0].model, 'whisper-1');
114
+ } finally {
115
+ mock.restore();
116
+ await instance.close();
117
+ }
118
+ });
119
+
120
+ test('acepta data URLs con parámetros de tipo (codecs)', async () => {
121
+ const { instance, base } = await startServer();
122
+ const mock = installTranscribeMock({ text: 'ok' });
123
+ try {
124
+ const audio = `data:audio/webm;codecs=opus;base64,${Buffer.from('x').toString('base64')}`;
125
+ const res = await fetch(`${base}/api/transcribe`, {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json' },
128
+ body: JSON.stringify({ audio }),
129
+ });
130
+ assert.equal(res.status, 200);
131
+ assert.equal((await res.json()).text, 'ok');
132
+ } finally {
133
+ mock.restore();
134
+ await instance.close();
135
+ }
136
+ });
137
+
138
+ test('el WAV del dictado se manda con nombre y tipo correctos', async () => {
139
+ const { instance, base } = await startServer();
140
+ const mock = installTranscribeMock({ text: 'ok' });
141
+ try {
142
+ const audio = `data:audio/wav;base64,${Buffer.from('RIFF....').toString('base64')}`;
143
+ const res = await fetch(`${base}/api/transcribe`, {
144
+ method: 'POST',
145
+ headers: { 'Content-Type': 'application/json' },
146
+ body: JSON.stringify({ audio }),
147
+ });
148
+ assert.equal(res.status, 200);
149
+ assert.equal(mock.calls[0].filename, 'audio.wav');
150
+ assert.equal(mock.calls[0].type, 'audio/wav');
151
+ // El idioma configurado se manda como ISO-639-1 (es-AR -> es).
152
+ assert.equal(mock.calls[0].language, 'es');
153
+ } finally {
154
+ mock.restore();
155
+ await instance.close();
156
+ }
157
+ });
158
+
159
+ test('el motor google transcribe en el servidor sin API key', async () => {
160
+ const { instance, base } = await startServer();
161
+ const mock = installTranscribeMock({ googleText: 'prendé la luz' });
162
+ try {
163
+ await fetch(`${base}/api/transcribe/settings`, {
164
+ method: 'PUT',
165
+ headers: { 'Content-Type': 'application/json' },
166
+ body: JSON.stringify({ mode: 'google', language: 'es-AR' }),
167
+ });
168
+ const res = await fetch(`${base}/api/transcribe`, {
169
+ method: 'POST',
170
+ headers: { 'Content-Type': 'application/json' },
171
+ body: JSON.stringify({ audio: `data:audio/wav;base64,${Buffer.from('RIFF....WAVEfmt ').toString('base64')}` }),
172
+ });
173
+ assert.equal(res.status, 200);
174
+ assert.equal((await res.json()).text, 'prendé la luz');
175
+ assert.match(mock.googleCalls[0].url, /speech-api\/v2\/recognize/);
176
+ assert.match(mock.googleCalls[0].url, /lang=es-AR/);
177
+ // No tocó al proveedor con API key.
178
+ assert.equal(mock.calls.length, 0);
179
+ } finally {
180
+ mock.restore();
181
+ await instance.close();
182
+ }
183
+ });
184
+
185
+ test('POST /api/transcribe rechaza un cuerpo sin audio', async () => {
186
+ const { instance, base } = await startServer();
187
+ try {
188
+ const res = await fetch(`${base}/api/transcribe`, {
189
+ method: 'POST',
190
+ headers: { 'Content-Type': 'application/json' },
191
+ body: JSON.stringify({}),
192
+ });
193
+ assert.equal(res.status, 400);
194
+ } finally {
195
+ await instance.close();
196
+ }
197
+ });
198
+
199
+ test('POST /api/transcribe expone el fallo del proveedor como 502', async () => {
200
+ const { instance, base } = await startServer();
201
+ const mock = installTranscribeMock({ status: 400, error: 'invalid file format' });
202
+ try {
203
+ const res = await fetch(`${base}/api/transcribe`, {
204
+ method: 'POST',
205
+ headers: { 'Content-Type': 'application/json' },
206
+ body: JSON.stringify({ audio: dataUrl('x') }),
207
+ });
208
+ assert.equal(res.status, 502);
209
+ // El mensaje del proveedor viaja en la respuesta, no un genérico.
210
+ assert.match((await res.json()).error, /invalid file format/);
211
+ } finally {
212
+ mock.restore();
213
+ await instance.close();
214
+ }
215
+ });
@@ -117,6 +117,30 @@ test('la sintesis exige texto y entrega MP3', async () => {
117
117
  }
118
118
  });
119
119
 
120
+ test('el catálogo de voces filtra por idioma (default español)', async () => {
121
+ const { instance, base } = await startServer();
122
+ const original = global.fetch;
123
+ const voces = [
124
+ { ShortName: 'es-AR-ElenaNeural', Locale: 'es-AR', Gender: 'Female' },
125
+ { ShortName: 'es-MX-JorgeNeural', Locale: 'es-MX', Gender: 'Male' },
126
+ { ShortName: 'en-US-AriaNeural', Locale: 'en-US', Gender: 'Female' },
127
+ { ShortName: 'fr-FR-DeniseNeural', Locale: 'fr-FR', Gender: 'Female' },
128
+ ];
129
+ global.fetch = async (url, opts = {}) => {
130
+ if (String(url).includes('voices/list')) return { ok: true, status: 200, json: async () => voces };
131
+ return original(url, opts);
132
+ };
133
+ try {
134
+ const todas = await (await fetch(`${base}/api/tts/voices`)).json();
135
+ assert.deepEqual(todas.voices.map((v) => v.ShortName), ['es-AR-ElenaNeural', 'es-MX-JorgeNeural']);
136
+ const francesas = await (await fetch(`${base}/api/tts/voices?filter=fr-`)).json();
137
+ assert.deepEqual(francesas.voices.map((v) => v.ShortName), ['fr-FR-DeniseNeural']);
138
+ } finally {
139
+ global.fetch = original;
140
+ await instance.close();
141
+ }
142
+ });
143
+
120
144
  test('un fallo del servicio de voz se ve como 502, no como crash', async () => {
121
145
  const { instance, base } = await startServer();
122
146
  const original = global.WebSocket;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.9.1",
3
+ "version": "0.9.2",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",