nio-cat 0.5.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.
- package/README.md +39 -6
- package/bin/nio.js +158 -9
- package/package.json +3 -2
- package/src/agents/claude.js +8 -0
- package/src/audit.js +55 -0
- package/src/auth.js +175 -0
- package/src/claude-hook.js +6 -0
- package/src/control.js +238 -0
- package/src/directory.js +56 -0
- package/src/hub.js +33 -12
- package/src/jobs.js +287 -0
- package/src/mcp.js +142 -0
- package/src/memory.js +131 -0
- package/src/proc.js +1 -1
- package/src/push.js +52 -18
- package/src/server.js +97 -33
- package/src/service.js +123 -0
- package/src/telegram.js +311 -0
- package/src/tunnel.js +150 -34
- package/src/voice.js +164 -0
- package/web/app/app.js +693 -86
- package/web/app/style.css +68 -7
- package/web/board.css +3 -2
- package/web/board.js +15 -14
- package/web/index.html +35 -17
- package/web/privadesa.html +148 -0
- package/web/sw.js +13 -9
package/src/server.js
CHANGED
|
@@ -13,6 +13,7 @@ const github = require('./github');
|
|
|
13
13
|
const { addServer, knownHosts } = require('./ssh');
|
|
14
14
|
const { agentCommands } = require('./commands');
|
|
15
15
|
const { pairLink, qrSvg, makeCode, codeLink } = require('./pair');
|
|
16
|
+
const directory = require('./directory');
|
|
16
17
|
|
|
17
18
|
// L'app web: copiada dins del paquet en publicar-lo, o l'arrel del repositori en desenvolupament.
|
|
18
19
|
const WEB_DIRS = [path.join(__dirname, '..', 'web'), path.join(__dirname, '..', '..')];
|
|
@@ -42,14 +43,6 @@ function originAllowed(cfg, origin) {
|
|
|
42
43
|
});
|
|
43
44
|
}
|
|
44
45
|
|
|
45
|
-
function tokenOk(cfg, req) {
|
|
46
|
-
const h = req.headers.authorization || '';
|
|
47
|
-
const t = h.startsWith('Bearer ') ? h.slice(7) : '';
|
|
48
|
-
const a = Buffer.from(t);
|
|
49
|
-
const b = Buffer.from(cfg.token);
|
|
50
|
-
return a.length === b.length && crypto.timingSafeEqual(a, b);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
46
|
function isLoopbackDirect(req) {
|
|
54
47
|
const ip = req.socket.remoteAddress || '';
|
|
55
48
|
const local = ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
|
@@ -141,26 +134,53 @@ function listDir(cfg, raw) {
|
|
|
141
134
|
// Noms de model d'Ollama: «qwen3:4b», «hf.co/usuari/model:Q4_K_M»…
|
|
142
135
|
const MODEL_NAME = /^[\w][\w.\-/]{0,100}(:[\w.\-]{1,60})?$/;
|
|
143
136
|
|
|
144
|
-
|
|
145
|
-
|
|
137
|
+
// Rutes que afegeixen altres mòduls (memòria, feines, Telegram, veu, navegador…).
|
|
138
|
+
// Cada ruta: { method, path: RegExp|string, handler: async ({ req, res, url, params, body, device }), raw?, limit? }.
|
|
139
|
+
function matchRoute(routes, method, p) {
|
|
140
|
+
for (const r of routes) {
|
|
141
|
+
if (r.method !== method) continue;
|
|
142
|
+
if (typeof r.path === 'string') {
|
|
143
|
+
if (r.path === p) return { r, params: [] };
|
|
144
|
+
} else {
|
|
145
|
+
const m = p.match(r.path);
|
|
146
|
+
if (m) return { r, params: m.slice(1).map((x) => decodeURIComponent(x)) };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function createServer({ cfg, hub, auth, audit, getPublicUrl, getApp, routes = [], info = () => ({}), beforeApproval = null }) {
|
|
153
|
+
const streams = new Map(); // res -> id del dispositiu
|
|
154
|
+
auth.onRevoke((d) => {
|
|
155
|
+
for (const [res, dev] of streams) if (dev === d.id) { res.end(); streams.delete(res); }
|
|
156
|
+
const before = (cfg.pushSubs || []).length;
|
|
157
|
+
cfg.pushSubs = (cfg.pushSubs || []).filter((x) => x.device !== d.id);
|
|
158
|
+
if (cfg.pushSubs.length !== before) save(cfg);
|
|
159
|
+
});
|
|
146
160
|
const seenDevices = new Map(); // mòbils que s'han connectat (per avisar l'ordinador que mostra el QR)
|
|
147
161
|
const pulls = new Map(); // descàrregues de models d'Ollama en curs
|
|
148
162
|
|
|
149
163
|
hub.on('out', (msg) => {
|
|
150
164
|
const data = `data: ${JSON.stringify(msg)}\n\n`;
|
|
151
|
-
for (const res of streams) res.write(data);
|
|
165
|
+
for (const res of streams.keys()) res.write(data);
|
|
152
166
|
});
|
|
153
167
|
setInterval(() => {
|
|
154
|
-
for (const res of streams) res.write(': ping\n\n');
|
|
168
|
+
for (const res of streams.keys()) res.write(': ping\n\n');
|
|
155
169
|
}, 15000).unref();
|
|
156
170
|
|
|
157
171
|
const hookUrl = () => `http://127.0.0.1:${cfg.port}/internal/approval`;
|
|
158
172
|
|
|
159
|
-
async function api(req, res, url) {
|
|
173
|
+
async function api(req, res, url, device) {
|
|
160
174
|
const p = url.pathname;
|
|
161
175
|
const m = (re) => p.match(re);
|
|
162
176
|
const method = req.method;
|
|
163
177
|
|
|
178
|
+
const hit = matchRoute(routes, method, p);
|
|
179
|
+
if (hit) {
|
|
180
|
+
const body = method === 'GET' || method === 'DELETE' || hit.r.raw ? undefined : await readBody(req, hit.r.limit);
|
|
181
|
+
return hit.r.handler({ req, res, url, params: hit.params, body, device });
|
|
182
|
+
}
|
|
183
|
+
|
|
164
184
|
if (p === '/api/info' && method === 'GET') {
|
|
165
185
|
return {
|
|
166
186
|
name: 'nio',
|
|
@@ -173,8 +193,32 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
173
193
|
roots: cfg.roots,
|
|
174
194
|
vapidKey: cfg.vapid.publicKey,
|
|
175
195
|
id: cfg.id,
|
|
196
|
+
device: { id: device.id, name: device.name, primary: !!device.primary },
|
|
197
|
+
identity: directory.identity(cfg),
|
|
198
|
+
...(await info()),
|
|
176
199
|
};
|
|
177
200
|
}
|
|
201
|
+
if (p === '/api/devices' && method === 'GET') return auth.list(device.id);
|
|
202
|
+
let dv;
|
|
203
|
+
if ((dv = m(/^\/api\/devices\/([\w-]+)$/))) {
|
|
204
|
+
if (method === 'PATCH') {
|
|
205
|
+
const b = await readBody(req);
|
|
206
|
+
const r = auth.rename(dv[1], b.name);
|
|
207
|
+
if (!r) throw httpErr(404, 'Aquest dispositiu no existeix');
|
|
208
|
+
return r;
|
|
209
|
+
}
|
|
210
|
+
if (method === 'DELETE') {
|
|
211
|
+
const r = auth.revoke(dv[1]);
|
|
212
|
+
if (!r) throw httpErr(404, 'Aquest dispositiu no existeix');
|
|
213
|
+
audit.add({ type: 'device', what: `Accés revocat: ${r.name}`, by: device.name });
|
|
214
|
+
return r;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (p === '/api/audit' && method === 'GET') {
|
|
218
|
+
const limit = Math.min(1000, Number(url.searchParams.get('limit')) || 200);
|
|
219
|
+
const type = url.searchParams.get('type');
|
|
220
|
+
return audit.recent(limit, type ? (e) => e.type === type : null);
|
|
221
|
+
}
|
|
178
222
|
if (p === '/api/ssh/hosts' && method === 'GET') return knownHosts();
|
|
179
223
|
if (p === '/api/projects' && method === 'GET') return listProjects(cfg);
|
|
180
224
|
if (p === '/api/fs' && method === 'GET') return listDir(cfg, url.searchParams.get('path'));
|
|
@@ -236,9 +280,11 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
236
280
|
// Enllaç i QR per connectar un altre dispositiu (el mòbil) amb aquest ordinador.
|
|
237
281
|
const pub = getPublicUrl();
|
|
238
282
|
if (!pub || /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)/i.test(pub)) return { link: null };
|
|
239
|
-
|
|
240
|
-
const code =
|
|
241
|
-
|
|
283
|
+
// Un secret d'un sol ús (no la clau principal): el mòbil el canvia per una clau pròpia i revocable.
|
|
284
|
+
const { code: secret, expires } = auth.createCode({ secret: true });
|
|
285
|
+
const link = pairLink(getApp ? getApp() : cfg.app, pub, secret);
|
|
286
|
+
const code = makeCode(pub, secret);
|
|
287
|
+
return { link, code, svg: qrSvg(codeLink(getApp ? getApp() : cfg.app, code)), url: pub, lan: /^http:\/\//.test(pub), expires };
|
|
242
288
|
}
|
|
243
289
|
if (p === '/api/github' && method === 'GET') return github.status();
|
|
244
290
|
if (p === '/api/github/repos' && method === 'GET') return github.repos(url.searchParams.get('owner') || '');
|
|
@@ -293,7 +339,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
293
339
|
if (p === '/api/approvals' && method === 'GET') return hub.pendingApprovals();
|
|
294
340
|
if ((r = m(/^\/api\/approvals\/([\w-]+)$/)) && method === 'POST') {
|
|
295
341
|
const b = await readBody(req);
|
|
296
|
-
const a = hub.decide(r[1], b.decision === 'allow' ? 'allow' : 'deny', b.reason);
|
|
342
|
+
const a = hub.decide(r[1], b.decision === 'allow' ? 'allow' : 'deny', b.reason, device.name);
|
|
297
343
|
if (!a) throw httpErr(404, 'Aquesta petició ja s’ha resolt');
|
|
298
344
|
return a;
|
|
299
345
|
}
|
|
@@ -301,9 +347,10 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
301
347
|
if (p === '/api/push' && method === 'POST') {
|
|
302
348
|
const b = await readBody(req);
|
|
303
349
|
if (!b.endpoint || !/^https:\/\//.test(b.endpoint)) throw httpErr(400, 'La subscripció als avisos no és vàlida');
|
|
350
|
+
const keys = b.keys && typeof b.keys.p256dh === 'string' && typeof b.keys.auth === 'string' ? { p256dh: b.keys.p256dh.slice(0, 200), auth: b.keys.auth.slice(0, 100) } : null;
|
|
304
351
|
cfg.pushSubs = (cfg.pushSubs || []).filter((x) => x.endpoint !== b.endpoint);
|
|
305
|
-
cfg.pushSubs.push({ endpoint: b.endpoint, added: Date.now() });
|
|
306
|
-
cfg.pushSubs = cfg.pushSubs.slice(-
|
|
352
|
+
cfg.pushSubs.push({ endpoint: String(b.endpoint).slice(0, 1000), keys, device: device.id, added: Date.now() });
|
|
353
|
+
cfg.pushSubs = cfg.pushSubs.slice(-20);
|
|
307
354
|
save(cfg);
|
|
308
355
|
return { ok: true };
|
|
309
356
|
}
|
|
@@ -314,7 +361,7 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
314
361
|
throw httpErr(404, 'Aquesta ruta no existeix');
|
|
315
362
|
}
|
|
316
363
|
|
|
317
|
-
const PUBLIC = /^\/(?:$|index\.html$|app(?:\/|$)|
|
|
364
|
+
const PUBLIC = /^\/(?:$|index\.html$|app(?:\/|$)|privadesa$|[\w.-]+\.(?:css|js|svg|png|webmanifest|json|ico|txt|html)$)/;
|
|
318
365
|
function serveStatic(req, res, url) {
|
|
319
366
|
let rel = decodeURIComponent(url.pathname);
|
|
320
367
|
if (!PUBLIC.test(rel) || rel.split('/').some((p) => p.startsWith('.')) || /^\/(daemon|node_modules)\b/.test(rel)) {
|
|
@@ -361,7 +408,9 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
361
408
|
if (!isLoopbackDirect(req)) throw httpErr(403, 'Només s’accepta des d’aquest ordinador');
|
|
362
409
|
const b = await readBody(req);
|
|
363
410
|
if (!hub.checkHookKey(b.session, req.headers['x-nio-key'])) throw httpErr(403, 'La clau no és correcta');
|
|
364
|
-
|
|
411
|
+
// Si l'ordre actua sobre el navegador o l'escriptori, hi afegim una captura de la pantalla.
|
|
412
|
+
const extra = beforeApproval ? await beforeApproval(b.tool, b.input).catch(() => ({})) : {};
|
|
413
|
+
const decision = await hub.requestApproval({ session: b.session, tool: b.tool, input: b.input, extra });
|
|
365
414
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
366
415
|
return res.end(JSON.stringify({ decision }));
|
|
367
416
|
} catch (e) {
|
|
@@ -376,11 +425,26 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
376
425
|
return res.end(JSON.stringify({ nio: true, id: cfg.id, name: os.hostname() }));
|
|
377
426
|
}
|
|
378
427
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
428
|
+
const json = (status, obj) => {
|
|
429
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
430
|
+
res.end(JSON.stringify(obj));
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
// Aparellament: canvia un codi d'un sol ús per una clau pròpia d'aquest dispositiu. No demana clau.
|
|
434
|
+
if (url.pathname === '/api/pair' && req.method === 'POST') {
|
|
435
|
+
try {
|
|
436
|
+
const b = await readBody(req, 4096);
|
|
437
|
+
const r = auth.redeem(b.code, b.name);
|
|
438
|
+
audit.add({ type: 'device', what: `Dispositiu aparellat: ${r.device.name}`, by: r.device.name });
|
|
439
|
+
return json(200, { ...r, id: cfg.id, host: os.hostname(), identity: directory.identity(cfg) });
|
|
440
|
+
} catch (e) {
|
|
441
|
+
return json(e.status || 500, { error: e.message });
|
|
383
442
|
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if (url.pathname.startsWith('/api/')) {
|
|
446
|
+
const device = auth.fromRequest(req);
|
|
447
|
+
if (!device) return json(401, { error: 'Aquest dispositiu no té accés (o l’has revocat). Torna a aparellar-lo amb un codi nou.' });
|
|
384
448
|
if (url.pathname === '/api/stream') {
|
|
385
449
|
res.writeHead(200, {
|
|
386
450
|
'content-type': 'text/event-stream; charset=utf-8',
|
|
@@ -392,10 +456,10 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
392
456
|
const dev = String(url.searchParams.get('d') || '').slice(0, 40), kind = url.searchParams.get('k') === 'phone' ? 'phone' : 'desktop';
|
|
393
457
|
if (dev && kind === 'phone' && Date.now() - (seenDevices.get(dev) || 0) > 10 * 60 * 1000) {
|
|
394
458
|
const data = `data: ${JSON.stringify({ type: 'device', device: dev, kind })}\n\n`;
|
|
395
|
-
for (const o of streams) o.write(data);
|
|
459
|
+
for (const o of streams.keys()) o.write(data);
|
|
396
460
|
}
|
|
397
461
|
if (dev) seenDevices.set(dev, Date.now());
|
|
398
|
-
streams.
|
|
462
|
+
streams.set(res, device.id);
|
|
399
463
|
req.on('close', () => streams.delete(res));
|
|
400
464
|
return;
|
|
401
465
|
}
|
|
@@ -414,12 +478,12 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
414
478
|
return res.end();
|
|
415
479
|
}
|
|
416
480
|
try {
|
|
417
|
-
const out = await api(req, res, url);
|
|
418
|
-
res.
|
|
419
|
-
|
|
481
|
+
const out = await api(req, res, url, device);
|
|
482
|
+
if (res.headersSent) return; // la ruta ja ha respost (p. ex. àudio o imatges)
|
|
483
|
+
json(200, out);
|
|
420
484
|
} catch (e) {
|
|
421
|
-
res.
|
|
422
|
-
|
|
485
|
+
if (res.headersSent) return res.end();
|
|
486
|
+
json(e.status || 500, { error: e.message });
|
|
423
487
|
}
|
|
424
488
|
return;
|
|
425
489
|
}
|
|
@@ -433,4 +497,4 @@ function createServer({ cfg, hub, getPublicUrl, getApp }) {
|
|
|
433
497
|
return server;
|
|
434
498
|
}
|
|
435
499
|
|
|
436
|
-
module.exports = { createServer, originAllowed };
|
|
500
|
+
module.exports = { createServer, originAllowed, readBody };
|
package/src/service.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Servei del sistema perquè nio arrenqui sol amb l'ordinador (systemd a Linux, launchd a macOS).
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { spawnSync } = require('child_process');
|
|
7
|
+
const { HOME } = require('./config');
|
|
8
|
+
const { searchPath } = require('./proc');
|
|
9
|
+
|
|
10
|
+
const LABEL = 'cat.nio.daemon';
|
|
11
|
+
|
|
12
|
+
function entry() {
|
|
13
|
+
return fs.realpathSync(path.join(__dirname, '..', 'bin', 'nio.js'));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function q(s) {
|
|
17
|
+
// Cometes per a ExecStart de systemd.
|
|
18
|
+
return /[\s"\\]/.test(s) ? `"${String(s).replace(/(["\\])/g, '\\$1')}"` : s;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function systemdUnit({ roots, system }) {
|
|
22
|
+
const env = [`PATH=${searchPath().join(':')}`, process.env.NIO_HOME ? `NIO_HOME=${process.env.NIO_HOME}` : null].filter(Boolean);
|
|
23
|
+
return `[Unit]
|
|
24
|
+
Description=nio · els teus agents, des del mòbil
|
|
25
|
+
After=network-online.target
|
|
26
|
+
Wants=network-online.target
|
|
27
|
+
|
|
28
|
+
[Service]
|
|
29
|
+
Type=simple
|
|
30
|
+
ExecStart=${[process.execPath, entry(), '--no-open', ...roots].map(q).join(' ')}
|
|
31
|
+
WorkingDirectory=${roots[0] || os.homedir()}
|
|
32
|
+
${env.map((e) => `Environment=${q(e)}`).join('\n')}
|
|
33
|
+
Environment=NO_COLOR=1
|
|
34
|
+
Restart=always
|
|
35
|
+
RestartSec=3
|
|
36
|
+
${system ? `User=${os.userInfo().username}\n` : ''}
|
|
37
|
+
[Install]
|
|
38
|
+
WantedBy=${system ? 'multi-user.target' : 'default.target'}
|
|
39
|
+
`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function launchdPlist({ roots }) {
|
|
43
|
+
const x = (s) => String(s).replace(/&/g, '&').replace(/</g, '<');
|
|
44
|
+
const args = [process.execPath, entry(), '--no-open', ...roots].map((a) => ` <string>${x(a)}</string>`).join('\n');
|
|
45
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
46
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
47
|
+
<plist version="1.0">
|
|
48
|
+
<dict>
|
|
49
|
+
<key>Label</key><string>${LABEL}</string>
|
|
50
|
+
<key>ProgramArguments</key>
|
|
51
|
+
<array>
|
|
52
|
+
${args}
|
|
53
|
+
</array>
|
|
54
|
+
<key>WorkingDirectory</key><string>${x(roots[0] || os.homedir())}</string>
|
|
55
|
+
<key>EnvironmentVariables</key>
|
|
56
|
+
<dict>
|
|
57
|
+
<key>PATH</key><string>${x(searchPath().join(':'))}</string>
|
|
58
|
+
<key>NO_COLOR</key><string>1</string>${process.env.NIO_HOME ? `\n <key>NIO_HOME</key><string>${x(process.env.NIO_HOME)}</string>` : ''}
|
|
59
|
+
</dict>
|
|
60
|
+
<key>RunAtLoad</key><true/>
|
|
61
|
+
<key>KeepAlive</key><true/>
|
|
62
|
+
<key>StandardOutPath</key><string>${x(path.join(HOME, 'nio.log'))}</string>
|
|
63
|
+
<key>StandardErrorPath</key><string>${x(path.join(HOME, 'nio.log'))}</string>
|
|
64
|
+
</dict>
|
|
65
|
+
</plist>
|
|
66
|
+
`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function sh(cmd, args) {
|
|
70
|
+
const r = spawnSync(cmd, args, { encoding: 'utf8' });
|
|
71
|
+
return { ok: r.status === 0, out: `${r.stdout || ''}${r.stderr || ''}`.trim() };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function install({ roots, log = console.log }) {
|
|
75
|
+
if (entry().includes(`${path.sep}_npx${path.sep}`)) {
|
|
76
|
+
log(' Avís: nio s’executa des de la memòria cau de npx, que es pot esborrar.');
|
|
77
|
+
log(' Per a un servei estable, instal·la’l primer: npm install -g https://www.nio.cat/nio.tgz\n');
|
|
78
|
+
}
|
|
79
|
+
if (process.platform === 'darwin') {
|
|
80
|
+
const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
81
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
82
|
+
sh('launchctl', ['unload', file]);
|
|
83
|
+
fs.writeFileSync(file, launchdPlist({ roots }));
|
|
84
|
+
const r = sh('launchctl', ['load', '-w', file]);
|
|
85
|
+
if (!r.ok) throw new Error(`launchctl no l’ha pogut carregar: ${r.out}`);
|
|
86
|
+
return { file, logs: `tail -f ${path.join(HOME, 'nio.log')}` };
|
|
87
|
+
}
|
|
88
|
+
if (process.platform !== 'linux') throw new Error('El servei automàtic només funciona a Linux (systemd) i macOS (launchd).');
|
|
89
|
+
if (!sh('systemctl', ['--version']).ok) throw new Error('Aquest sistema no té systemd.');
|
|
90
|
+
const system = process.getuid && process.getuid() === 0;
|
|
91
|
+
const file = system ? '/etc/systemd/system/nio.service' : path.join(os.homedir(), '.config', 'systemd', 'user', 'nio.service');
|
|
92
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
93
|
+
fs.writeFileSync(file, systemdUnit({ roots, system }));
|
|
94
|
+
const ctl = system ? ['systemctl'] : ['systemctl', '--user'];
|
|
95
|
+
sh(ctl[0], [...ctl.slice(1), 'daemon-reload']);
|
|
96
|
+
const r = sh(ctl[0], [...ctl.slice(1), 'enable', '--now', 'nio.service']);
|
|
97
|
+
if (!r.ok) throw new Error(`systemctl no l’ha pogut engegar: ${r.out}`);
|
|
98
|
+
sh(ctl[0], [...ctl.slice(1), 'restart', 'nio.service']);
|
|
99
|
+
let linger = null;
|
|
100
|
+
if (!system) {
|
|
101
|
+
// Sense «linger», el servei d'usuari s'atura quan tanques la sessió (i no arrenca en reiniciar).
|
|
102
|
+
linger = sh('loginctl', ['enable-linger', os.userInfo().username]).ok;
|
|
103
|
+
}
|
|
104
|
+
return { file, linger, logs: system ? 'journalctl -u nio -f' : 'journalctl --user -u nio -f' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function uninstall() {
|
|
108
|
+
if (process.platform === 'darwin') {
|
|
109
|
+
const file = path.join(os.homedir(), 'Library', 'LaunchAgents', `${LABEL}.plist`);
|
|
110
|
+
sh('launchctl', ['unload', '-w', file]);
|
|
111
|
+
fs.rmSync(file, { force: true });
|
|
112
|
+
return file;
|
|
113
|
+
}
|
|
114
|
+
const system = process.getuid && process.getuid() === 0;
|
|
115
|
+
const ctl = system ? ['systemctl'] : ['systemctl', '--user'];
|
|
116
|
+
sh(ctl[0], [...ctl.slice(1), 'disable', '--now', 'nio.service']);
|
|
117
|
+
const file = system ? '/etc/systemd/system/nio.service' : path.join(os.homedir(), '.config', 'systemd', 'user', 'nio.service');
|
|
118
|
+
fs.rmSync(file, { force: true });
|
|
119
|
+
sh(ctl[0], [...ctl.slice(1), 'daemon-reload']);
|
|
120
|
+
return file;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { install, uninstall, systemdUnit, launchdPlist };
|
package/src/telegram.js
ADDED
|
@@ -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 };
|