nio-cat 0.6.1 → 0.8.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.
@@ -0,0 +1,311 @@
1
+ 'use strict';
2
+ // Telegram: el teu bot (BotFather) parla amb aquest ordinador per long polling. És una connexió
3
+ // de sortida, així que no necessita túnel. Només els xats de la llista blanca poden donar ordres;
4
+ // la resta s'ignoren (només en queda constància perquè els puguis autoritzar des de l'app).
5
+ //
6
+ // Des de Telegram: escriure o enviar una nota de veu (continua la conversa o n'obre una de nova),
7
+ // /nova <text>, /estat, /atura, /ajuda. Hi arriben els avisos, i les aprovacions amb botons.
8
+ const config = require('./config');
9
+ const voice = require('./voice');
10
+
11
+ const httpErr = (status, message) => Object.assign(new Error(message), { status });
12
+ const TOKEN_RE = /^\d{5,15}:[\w-]{30,60}$/;
13
+ const clip = (s, n) => (String(s).length > n ? `${String(s).slice(0, n)}…` : String(s));
14
+
15
+ class Telegram {
16
+ constructor({ cfg, hub, audit, available = () => ['claude', 'codex'] }) {
17
+ this.cfg = cfg;
18
+ this.hub = hub;
19
+ this.audit = audit || { add() {} };
20
+ this.available = available;
21
+ this.chatOf = new Map(); // sessió -> xat que l'ha començada
22
+ this.running = false;
23
+ this.me = null;
24
+ hub.on('notice', (n, s) => this.onNotice(n, s).catch(() => {}));
25
+ hub.on('out', (m) => this.onHub(m));
26
+ }
27
+
28
+ get t() {
29
+ return this.cfg.telegram || {};
30
+ }
31
+ base() {
32
+ return (this.t.api || 'https://api.telegram.org').replace(/\/$/, '');
33
+ }
34
+ async call(method, body, { timeout = 15000 } = {}) {
35
+ const r = await fetch(`${this.base()}/bot${this.t.token}/${method}`, {
36
+ method: 'POST',
37
+ headers: { 'content-type': 'application/json' },
38
+ body: JSON.stringify(body || {}),
39
+ signal: AbortSignal.timeout(timeout),
40
+ });
41
+ const j = await r.json().catch(() => ({}));
42
+ if (!j.ok) throw httpErr(r.status === 401 ? 400 : 502, `Telegram: ${j.description || r.status}`);
43
+ return j.result;
44
+ }
45
+
46
+ allowed(id) {
47
+ return (this.t.allowed || []).some((a) => String(a.id) === String(id));
48
+ }
49
+
50
+ // ---------- bucle de long polling
51
+ start() {
52
+ if (this.running || !this.t.token) return;
53
+ this.running = true;
54
+ this.loop();
55
+ }
56
+ stop() {
57
+ this.running = false;
58
+ this.ctrl?.abort();
59
+ }
60
+ async loop() {
61
+ let wait = 1000;
62
+ while (this.running && this.t.token) {
63
+ try {
64
+ if (!this.me) this.me = await this.call('getMe');
65
+ this.ctrl = new AbortController();
66
+ const r = await fetch(`${this.base()}/bot${this.t.token}/getUpdates`, {
67
+ method: 'POST',
68
+ headers: { 'content-type': 'application/json' },
69
+ body: JSON.stringify({ offset: this.offset, timeout: this.t.pollTimeout ?? 50, allowed_updates: ['message', 'callback_query'] }),
70
+ signal: this.ctrl.signal,
71
+ });
72
+ const j = await r.json();
73
+ if (!j.ok) throw new Error(j.description || r.status);
74
+ for (const u of j.result) {
75
+ this.offset = u.update_id + 1;
76
+ await this.onUpdate(u).catch((e) => this.audit.add({ type: 'telegram', what: `Error: ${e.message}` }));
77
+ }
78
+ wait = 1000;
79
+ } catch (e) {
80
+ if (!this.running) return;
81
+ await new Promise((res) => setTimeout(res, wait));
82
+ wait = Math.min(60000, wait * 2);
83
+ }
84
+ }
85
+ }
86
+
87
+ // ---------- missatges entrants
88
+ async onUpdate(u) {
89
+ if (u.callback_query) return this.onCallback(u.callback_query);
90
+ const m = u.message;
91
+ if (!m || !m.chat) return;
92
+ const chat = m.chat.id;
93
+ const who = [m.from?.first_name, m.from?.last_name].filter(Boolean).join(' ') || m.chat.title || m.from?.username || String(chat);
94
+ if (!this.allowed(chat)) {
95
+ // Fora de la llista: no responem res. Només ho anotem perquè el puguis autoritzar des de l'app.
96
+ const t = (this.cfg.telegram.pending = (this.t.pending || []).filter((p) => String(p.id) !== String(chat)));
97
+ t.unshift({ id: chat, name: who, username: m.from?.username || null, t: Date.now() });
98
+ this.cfg.telegram.pending = t.slice(0, 10);
99
+ config.save(this.cfg);
100
+ return;
101
+ }
102
+ let text = (m.text || m.caption || '').trim();
103
+ if (m.voice || m.audio) {
104
+ const f = m.voice || m.audio;
105
+ try {
106
+ const audio = await this.download(f.file_id);
107
+ text = (await voice.transcribe(this.cfg, audio, f.mime_type || 'audio/ogg')).text;
108
+ if (!text) return this.send(chat, 'No he entès la nota de veu.');
109
+ await this.send(chat, `🎙 «${clip(text, 500)}»`);
110
+ } catch (e) {
111
+ return this.send(chat, `No he pogut transcriure la nota de veu: ${e.message}`);
112
+ }
113
+ }
114
+ if (!text) return;
115
+ const [cmd, ...rest] = text.split(/\s+/);
116
+ const arg = rest.join(' ').trim();
117
+ switch (cmd.replace(/@.*$/, '').toLowerCase()) {
118
+ case '/start':
119
+ case '/ajuda':
120
+ case '/help':
121
+ return this.send(chat, 'Escriu-me què vols que facin els agents d’aquest ordinador (o envia una nota de veu).\n\n/nova <text> — comença una conversa nova\n/estat — què està fent cada agent\n/atura — atura la conversa en curs\n\nT’avisaré quan calgui una aprovació o quan acabi una feina.');
122
+ case '/estat': {
123
+ const list = this.hub.list().filter((s) => s.state === 'running' || s.state === 'waiting').slice(0, 10);
124
+ return this.send(chat, list.length ? list.map((s) => `• ${s.title || 'Sessió'} — ${s.state === 'waiting' ? 'espera el teu permís' : 'treballant'}`).join('\n') : 'Cap agent treballant ara mateix.');
125
+ }
126
+ case '/atura': {
127
+ const sid = this.t.last?.[chat];
128
+ return this.send(chat, sid && this.hub.stop(sid) ? 'Aturat.' : 'No hi ha res en marxa.');
129
+ }
130
+ case '/nova':
131
+ if (!arg) return this.send(chat, 'Escriu què ha de fer després de /nova.');
132
+ return this.startSession(chat, arg, who, true);
133
+ default:
134
+ if (cmd.startsWith('/')) return this.send(chat, 'No conec aquesta ordre. Escriu /ajuda.');
135
+ return this.startSession(chat, text, who, false);
136
+ }
137
+ }
138
+
139
+ async startSession(chat, text, who, fresh) {
140
+ const last = this.t.last?.[chat];
141
+ const s = !fresh && last ? this.hub.get(last) : null;
142
+ try {
143
+ if (s && (s.state === 'running' || s.state === 'waiting')) return this.send(chat, 'L’agent encara està treballant en l’anterior. Espera o escriu /atura.');
144
+ let sid = s?.id;
145
+ if (!sid) {
146
+ const avail = this.available();
147
+ const agent = avail.includes(this.t.agent) ? this.t.agent : ['claude', 'codex'].find((a) => avail.includes(a)) || avail[0];
148
+ const ns = this.hub.create({ agent, cwd: this.t.cwd || this.cfg.roots?.[0], mode: this.t.mode || 'preguntar', title: '' });
149
+ sid = ns.id;
150
+ this.cfg.telegram.last = { ...(this.t.last || {}), [chat]: sid };
151
+ config.save(this.cfg);
152
+ }
153
+ this.chatOf.set(sid, chat);
154
+ this.hub.send(sid, text, this.hookUrl ? this.hookUrl() : undefined);
155
+ this.audit.add({ type: 'telegram', what: `Missatge de Telegram: ${clip(text, 120)}`, by: `Telegram · ${who}`, session: sid });
156
+ return this.send(chat, '👌 Hi treballo.');
157
+ } catch (e) {
158
+ return this.send(chat, `No he pogut començar: ${e.message}`);
159
+ }
160
+ }
161
+
162
+ async onCallback(q) {
163
+ const chat = q.message?.chat?.id;
164
+ if (!this.allowed(chat)) return this.call('answerCallbackQuery', { callback_query_id: q.id, text: 'No autoritzat' }).catch(() => {});
165
+ const m = String(q.data || '').match(/^a:([\w-]+):(allow|deny)$/);
166
+ if (!m) return this.call('answerCallbackQuery', { callback_query_id: q.id }).catch(() => {});
167
+ const who = [q.from?.first_name, q.from?.last_name].filter(Boolean).join(' ') || q.from?.username || 'Telegram';
168
+ const a = this.hub.decide(m[1], m[2], undefined, `Telegram · ${who}`);
169
+ await this.call('answerCallbackQuery', { callback_query_id: q.id, text: a ? (m[2] === 'allow' ? 'Aprovat' : 'Rebutjat') : 'Ja estava resolt' }).catch(() => {});
170
+ if (q.message) {
171
+ const verdict = a ? (m[2] === 'allow' ? '✅ Aprovat' : '❌ Rebutjat') : 'Ja estava resolt';
172
+ await this.call('editMessageText', { chat_id: chat, message_id: q.message.message_id, text: `${q.message.text || ''}\n\n${verdict}` }).catch(() => {});
173
+ }
174
+ }
175
+
176
+ // ---------- avisos sortints
177
+ async onNotice(n, s) {
178
+ if (!this.t.token || this.t.notify === false) return;
179
+ const chats = (this.t.allowed || []).map((a) => a.id);
180
+ if (!chats.length) return;
181
+ const text = `${n.title}\n${n.body || ''}`.trim();
182
+ if (n.approval) {
183
+ const a = this.hub.approvals.get(n.approval);
184
+ const why = a?.reason ? `\n\nMotiu: ${clip(a.reason, 300)}` : '';
185
+ const detail = a ? `\n\n${clip(a.input?.command || a.input?.file_path || JSON.stringify(a.input || {}), 600)}` : '';
186
+ for (const c of chats) {
187
+ await this.call('sendMessage', {
188
+ chat_id: c,
189
+ text: clip(`${text}${detail}${why}`, 3800),
190
+ reply_markup: { inline_keyboard: [[{ text: '✅ Aprova', callback_data: `a:${n.approval}:allow` }, { text: '❌ Rebutja', callback_data: `a:${n.approval}:deny` }]] },
191
+ }).catch(() => {});
192
+ }
193
+ return;
194
+ }
195
+ // Les feines que ha començat Telegram ja reben la resposta sencera (onHub); aquí la resta.
196
+ if (s && this.chatOf.has(s.id)) return;
197
+ for (const c of chats) await this.send(c, text);
198
+ }
199
+
200
+ onHub(m) {
201
+ if (m.type !== 'event' || m.event?.kind !== 'status' || m.event.state === 'running') return;
202
+ const chat = this.chatOf.get(m.session);
203
+ if (!chat) return;
204
+ const s = this.hub.get(m.session);
205
+ const evs = s ? s.events : [];
206
+ const start = evs.map((e) => e.kind).lastIndexOf('user');
207
+ const texts = evs.slice(start + 1).filter((e) => e.kind === 'text').map((e) => e.text);
208
+ const err = evs.slice(start + 1).reverse().find((e) => e.kind === 'error');
209
+ const out = m.event.state === 'error' ? `⚠️ ${err?.text || 'Hi ha hagut un error'}` : texts.join('\n\n') || 'Fet.';
210
+ this.send(chat, clip(out, 3900)).catch(() => {});
211
+ }
212
+
213
+ send(chat, text) {
214
+ return this.call('sendMessage', { chat_id: chat, text: clip(text, 4000) }).catch(() => {});
215
+ }
216
+
217
+ async download(fileId) {
218
+ const f = await this.call('getFile', { file_id: fileId });
219
+ const r = await fetch(`${this.base()}/file/bot${this.t.token}/${f.file_path}`, { signal: AbortSignal.timeout(30000) });
220
+ if (!r.ok) throw new Error(`no he pogut baixar l’àudio (${r.status})`);
221
+ return Buffer.from(await r.arrayBuffer());
222
+ }
223
+
224
+ // ---------- configuració des de l'app (el testimoni del bot no surt mai de l'ordinador)
225
+ status() {
226
+ const t = this.t;
227
+ return {
228
+ configured: !!t.token,
229
+ bot: this.me ? { username: this.me.username, name: this.me.first_name } : null,
230
+ running: this.running,
231
+ allowed: t.allowed || [],
232
+ pending: (t.pending || []).filter((p) => !this.allowed(p.id)),
233
+ agent: t.agent || null,
234
+ cwd: t.cwd || this.cfg.roots?.[0] || null,
235
+ mode: t.mode || 'preguntar',
236
+ notify: t.notify !== false,
237
+ voice: !!voice.pick(this.cfg, 'stt'),
238
+ };
239
+ }
240
+
241
+ routes() {
242
+ const save = () => config.save(this.cfg);
243
+ return [
244
+ { method: 'GET', path: '/api/telegram', handler: () => this.status() },
245
+ {
246
+ method: 'PUT',
247
+ path: '/api/telegram',
248
+ handler: async ({ body }) => {
249
+ const t = (this.cfg.telegram = this.cfg.telegram || {});
250
+ if (body.token === null) {
251
+ this.stop();
252
+ delete t.token;
253
+ this.me = null;
254
+ } else if (typeof body.token === 'string' && body.token.trim()) {
255
+ const tok = body.token.trim();
256
+ if (!TOKEN_RE.test(tok)) throw httpErr(400, 'Aquest no sembla un testimoni de BotFather (123456:ABC…)');
257
+ const prev = t.token;
258
+ t.token = tok;
259
+ try {
260
+ this.me = await this.call('getMe');
261
+ } catch (e) {
262
+ t.token = prev;
263
+ throw httpErr(400, `Telegram no accepta aquest testimoni: ${e.message}`);
264
+ }
265
+ this.stop();
266
+ this.offset = undefined;
267
+ }
268
+ if (['claude', 'codex', 'demo'].includes(body.agent)) t.agent = body.agent;
269
+ if (['llegir', 'preguntar', 'tot'].includes(body.mode)) t.mode = body.mode;
270
+ if (typeof body.cwd === 'string' && body.cwd) t.cwd = body.cwd;
271
+ if (typeof body.notify === 'boolean') t.notify = body.notify;
272
+ save();
273
+ if (t.token) setTimeout(() => this.start(), 0);
274
+ return this.status();
275
+ },
276
+ },
277
+ {
278
+ method: 'POST',
279
+ path: '/api/telegram/allow',
280
+ handler: ({ body, device }) => {
281
+ const t = (this.cfg.telegram = this.cfg.telegram || {});
282
+ const id = Number(body.id);
283
+ if (!Number.isSafeInteger(id)) throw httpErr(400, 'Identificador de xat no vàlid');
284
+ const p = (t.pending || []).find((x) => Number(x.id) === id);
285
+ t.allowed = (t.allowed || []).filter((a) => Number(a.id) !== id);
286
+ t.allowed.push({ id, name: String(body.name || p?.name || id).slice(0, 60), added: Date.now() });
287
+ t.pending = (t.pending || []).filter((x) => Number(x.id) !== id);
288
+ save();
289
+ this.audit.add({ type: 'device', what: `Xat de Telegram autoritzat: ${t.allowed.at(-1).name}`, by: device?.name });
290
+ this.send(id, 'Fet: ja pots parlar amb els agents d’aquest ordinador. Escriu /ajuda.');
291
+ return this.status();
292
+ },
293
+ },
294
+ {
295
+ method: 'DELETE',
296
+ path: /^\/api\/telegram\/allow\/(-?\d+)$/,
297
+ handler: ({ params, device }) => {
298
+ const t = this.cfg.telegram || {};
299
+ const gone = (t.allowed || []).find((a) => String(a.id) === params[0]);
300
+ t.allowed = (t.allowed || []).filter((a) => String(a.id) !== params[0]);
301
+ t.pending = (t.pending || []).filter((a) => String(a.id) !== params[0]);
302
+ save();
303
+ if (gone) this.audit.add({ type: 'device', what: `Xat de Telegram tret: ${gone.name}`, by: device?.name });
304
+ return this.status();
305
+ },
306
+ },
307
+ ];
308
+ }
309
+ }
310
+
311
+ module.exports = { Telegram, TOKEN_RE };
package/src/voice.js ADDED
@@ -0,0 +1,164 @@
1
+ 'use strict';
2
+ // Veu: transcripció (STT) i lectura en veu alta (TTS) amb les claus de l'usuari.
3
+ // Les claus viuen en aquest ordinador (~/.nio/config.json → voice) i mai no tornen a l'app.
4
+ // L'àudio fa: mòbil → aquest ordinador → proveïdor. Proveïdors intercanviables: ElevenLabs i OpenAI.
5
+ const config = require('./config');
6
+
7
+ const httpErr = (status, message) => Object.assign(new Error(message), { status });
8
+ const MAX_AUDIO = 25 * 1024 * 1024;
9
+
10
+ const PROVIDERS = {
11
+ elevenlabs: {
12
+ name: 'ElevenLabs',
13
+ async stt(key, audio, mime, lang) {
14
+ const fd = new FormData();
15
+ fd.append('file', new Blob([audio], { type: mime || 'audio/webm' }), 'audio');
16
+ fd.append('model_id', 'scribe_v1');
17
+ if (lang) fd.append('language_code', lang);
18
+ const r = await fetch('https://api.elevenlabs.io/v1/speech-to-text', { method: 'POST', headers: { 'xi-api-key': key }, body: fd });
19
+ if (!r.ok) throw httpErr(502, `ElevenLabs: ${await errText(r)}`);
20
+ return (await r.json()).text || '';
21
+ },
22
+ async tts(key, text, opts) {
23
+ const voice = opts.voice || 'JBFqnCBsd6RMkjVDRZzb';
24
+ const r = await fetch(`https://api.elevenlabs.io/v1/text-to-speech/${encodeURIComponent(voice)}?output_format=mp3_44100_64`, {
25
+ method: 'POST',
26
+ headers: { 'xi-api-key': key, 'content-type': 'application/json' },
27
+ body: JSON.stringify({ text, model_id: opts.model || 'eleven_multilingual_v2' }),
28
+ });
29
+ if (!r.ok) throw httpErr(502, `ElevenLabs: ${await errText(r)}`);
30
+ return Buffer.from(await r.arrayBuffer());
31
+ },
32
+ },
33
+ openai: {
34
+ name: 'OpenAI',
35
+ async stt(key, audio, mime, lang) {
36
+ const fd = new FormData();
37
+ fd.append('file', new Blob([audio], { type: mime || 'audio/webm' }), `audio.${ext(mime)}`);
38
+ fd.append('model', 'gpt-4o-mini-transcribe');
39
+ if (lang) fd.append('language', lang);
40
+ const r = await fetch('https://api.openai.com/v1/audio/transcriptions', { method: 'POST', headers: { Authorization: `Bearer ${key}` }, body: fd });
41
+ if (!r.ok) throw httpErr(502, `OpenAI: ${await errText(r)}`);
42
+ return (await r.json()).text || '';
43
+ },
44
+ async tts(key, text, opts) {
45
+ const r = await fetch('https://api.openai.com/v1/audio/speech', {
46
+ method: 'POST',
47
+ headers: { Authorization: `Bearer ${key}`, 'content-type': 'application/json' },
48
+ body: JSON.stringify({ model: opts.model || 'gpt-4o-mini-tts', voice: opts.voice || 'alloy', input: text, response_format: 'mp3' }),
49
+ });
50
+ if (!r.ok) throw httpErr(502, `OpenAI: ${await errText(r)}`);
51
+ return Buffer.from(await r.arrayBuffer());
52
+ },
53
+ },
54
+ };
55
+
56
+ function ext(mime) {
57
+ return /ogg/.test(mime || '') ? 'ogg' : /mp4|m4a|aac/.test(mime || '') ? 'm4a' : /mpeg|mp3/.test(mime || '') ? 'mp3' : /wav/.test(mime || '') ? 'wav' : 'webm';
58
+ }
59
+ async function errText(r) {
60
+ const t = await r.text().catch(() => '');
61
+ try {
62
+ const j = JSON.parse(t);
63
+ return (j.detail?.message || j.detail || j.error?.message || j.error || t).toString().slice(0, 200);
64
+ } catch {
65
+ return `${r.status} ${t.slice(0, 160)}`;
66
+ }
67
+ }
68
+
69
+ const cfgOf = (cfg) => cfg.voice || {};
70
+ // Proveïdor per a cada cosa: el triat, si en té la clau; si no, qualsevol que en tingui.
71
+ function pick(cfg, which) {
72
+ const v = cfgOf(cfg), keys = v.keys || {};
73
+ const want = v[which] || v.provider;
74
+ if (want && keys[want]) return want;
75
+ return Object.keys(PROVIDERS).find((p) => keys[p]) || null;
76
+ }
77
+
78
+ function status(cfg) {
79
+ const v = cfgOf(cfg), keys = v.keys || {};
80
+ return {
81
+ providers: Object.fromEntries(Object.entries(PROVIDERS).map(([id, p]) => [id, { name: p.name, key: keys[id] ? `…${keys[id].slice(-4)}` : null }])),
82
+ stt: pick(cfg, 'stt'),
83
+ tts: pick(cfg, 'tts'),
84
+ speak: !!v.speak,
85
+ lang: v.lang || 'ca',
86
+ voice: v.voice || {},
87
+ };
88
+ }
89
+
90
+ async function transcribe(cfg, audio, mime) {
91
+ const p = pick(cfg, 'stt');
92
+ if (!p) throw httpErr(400, 'Aquest ordinador no té cap clau de veu. Posa-la a Ajustos → Veu.');
93
+ if (!audio?.length) throw httpErr(400, 'No hi ha àudio');
94
+ return { text: (await PROVIDERS[p].stt(cfg.voice.keys[p], audio, mime, cfgOf(cfg).lang || 'ca')).trim(), provider: p };
95
+ }
96
+
97
+ async function speak(cfg, text) {
98
+ const p = pick(cfg, 'tts');
99
+ if (!p) throw httpErr(400, 'Aquest ordinador no té cap clau de veu. Posa-la a Ajustos → Veu.');
100
+ const clean = String(text || '').replace(/```[\s\S]*?```/g, ' (codi) ').replace(/[*_`#>]/g, '').trim().slice(0, 2500);
101
+ if (!clean) throw httpErr(400, 'No hi ha text per llegir');
102
+ return PROVIDERS[p].tts(cfg.voice.keys[p], clean, cfgOf(cfg).voice?.[p] || {});
103
+ }
104
+
105
+ function readRaw(req, limit = MAX_AUDIO) {
106
+ return new Promise((resolve, reject) => {
107
+ const chunks = [];
108
+ let size = 0;
109
+ req.on('data', (c) => {
110
+ size += c.length;
111
+ if (size > limit) { reject(httpErr(413, 'L’àudio és massa llarg')); req.destroy(); } else chunks.push(c);
112
+ });
113
+ req.on('end', () => resolve(Buffer.concat(chunks)));
114
+ req.on('error', reject);
115
+ });
116
+ }
117
+
118
+ function routes(cfg) {
119
+ return [
120
+ { method: 'GET', path: '/api/voice', handler: () => status(cfg) },
121
+ {
122
+ method: 'PUT',
123
+ path: '/api/voice',
124
+ handler: ({ body }) => {
125
+ const v = (cfg.voice = cfg.voice || {});
126
+ v.keys = v.keys || {};
127
+ for (const id of Object.keys(PROVIDERS)) {
128
+ const k = body.keys?.[id];
129
+ if (k === null || k === '') delete v.keys[id];
130
+ else if (typeof k === 'string' && k.trim()) {
131
+ if (!/^[\w.-]{16,200}$/.test(k.trim())) throw httpErr(400, `La clau de ${PROVIDERS[id].name} no té bon aspecte`);
132
+ v.keys[id] = k.trim();
133
+ }
134
+ }
135
+ for (const w of ['stt', 'tts']) if (body[w] === null || PROVIDERS[body[w]]) v[w] = body[w] || undefined;
136
+ if (typeof body.speak === 'boolean') v.speak = body.speak;
137
+ if (typeof body.lang === 'string' && /^[a-z]{2}$/.test(body.lang)) v.lang = body.lang;
138
+ if (body.voice && typeof body.voice === 'object') {
139
+ v.voice = v.voice || {};
140
+ for (const id of Object.keys(PROVIDERS)) if (body.voice[id]) v.voice[id] = { voice: String(body.voice[id].voice || '').slice(0, 80) || undefined, model: String(body.voice[id].model || '').slice(0, 80) || undefined };
141
+ }
142
+ config.save(cfg);
143
+ return status(cfg);
144
+ },
145
+ },
146
+ {
147
+ method: 'POST',
148
+ path: '/api/voice/stt',
149
+ raw: true,
150
+ handler: async ({ req }) => transcribe(cfg, await readRaw(req), String(req.headers['content-type'] || 'audio/webm').split(';')[0]),
151
+ },
152
+ {
153
+ method: 'POST',
154
+ path: '/api/voice/tts',
155
+ handler: async ({ res, body }) => {
156
+ const mp3 = await speak(cfg, body.text);
157
+ res.writeHead(200, { 'content-type': 'audio/mpeg', 'content-length': mp3.length, 'cache-control': 'no-store' });
158
+ res.end(mp3);
159
+ },
160
+ },
161
+ ];
162
+ }
163
+
164
+ module.exports = { routes, transcribe, speak, status, pick, PROVIDERS };