bom-pi 0.0.4 → 0.0.6
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/apps/web/pocket-adapter.mjs +30 -1
- package/apps/web/server.mjs +24 -0
- package/package.json +1 -1
- package/packages/cli/src/bom-pi.mjs +85 -21
- package/packages/core/src/daemon.mjs +124 -1
|
@@ -169,6 +169,17 @@ export function makePocketAdapter({ rpc, checkToken, push }) {
|
|
|
169
169
|
};
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// title conversation = message user đầu (cùng logic sessionsPayload) — cho targetTitle webhook
|
|
173
|
+
async function convTitle(convId) {
|
|
174
|
+
try {
|
|
175
|
+
const raw = await rpc('transcript.raw', { conversationId: convId });
|
|
176
|
+
const first = (raw.entries ?? []).find((e) => e.kind === 'pi.user');
|
|
177
|
+
const m0 = first?.model?.[0];
|
|
178
|
+
const text = typeof m0?.content === 'string' ? m0.content : Array.isArray(m0?.content) ? m0.content.filter((p) => p?.type === 'text').map((p) => p.text).join(' ') : '';
|
|
179
|
+
return text ? text.split('\n')[0].slice(0, 60) : `session ${convId}`;
|
|
180
|
+
} catch { return `session ${convId}`; }
|
|
181
|
+
}
|
|
182
|
+
|
|
172
183
|
const json = (res, code, obj) => { res.writeHead(code, { 'content-type': 'application/json', 'access-control-allow-origin': '*' }); res.end(JSON.stringify(obj)); };
|
|
173
184
|
const readBody = (req) => new Promise((done) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => { try { done(JSON.parse(b || '{}')); } catch { done({}); } }); });
|
|
174
185
|
|
|
@@ -334,10 +345,28 @@ export function makePocketAdapter({ rpc, checkToken, push }) {
|
|
|
334
345
|
return true;
|
|
335
346
|
}
|
|
336
347
|
|
|
348
|
+
// ---- webhooks (vòng 9 — port pi-pocket): list/add/rotate/remove; dispatch ở route public /hook/<id>/<secret> ----
|
|
349
|
+
if (first === 'hooks') {
|
|
350
|
+
if (second === undefined && method === 'GET') {
|
|
351
|
+
const r = await rpc('hooks.list');
|
|
352
|
+
const out = [];
|
|
353
|
+
for (const h of r.hooks ?? []) out.push({ ...h, targetTitle: await convTitle(h.target) });
|
|
354
|
+
return json(res, 200, out), true;
|
|
355
|
+
}
|
|
356
|
+
if (second === undefined && isPost) {
|
|
357
|
+
const body = await readBody(req);
|
|
358
|
+
return json(res, 200, await rpc('hooks.add', {
|
|
359
|
+
target: body.target, name: body.name, instructions: body.instructions,
|
|
360
|
+
...(body.signed === true ? { signed: true } : {}),
|
|
361
|
+
})), true;
|
|
362
|
+
}
|
|
363
|
+
if (second !== undefined && third === 'rotate' && isPost) { await readBody(req); return json(res, 200, await rpc('hooks.rotate', { id: second })), true; }
|
|
364
|
+
if (second !== undefined && third === 'remove' && isPost) { await readBody(req); return json(res, 200, await rpc('hooks.remove', { id: second })), true; }
|
|
365
|
+
}
|
|
366
|
+
|
|
337
367
|
// ---- stub rỗng: tính năng pocket chưa có ở bom-pi ----
|
|
338
368
|
if (first === 'users' && method === 'GET') return json(res, 200, [OWNER]), true;
|
|
339
369
|
if (first === 'subagents' && method === 'GET') return json(res, 200, []), true;
|
|
340
|
-
if (first === 'hooks' && method === 'GET') return json(res, 200, []), true;
|
|
341
370
|
if (first === 'folder-favorites' && method === 'GET') return json(res, 200, []), true;
|
|
342
371
|
if (first === 'pi-sessions' && method === 'GET') return json(res, 200, []), true;
|
|
343
372
|
if (first === 'fs') return json(res, 200, { entries: [] }), true; // PORT(bom-pi): file tree
|
package/apps/web/server.mjs
CHANGED
|
@@ -726,6 +726,30 @@ const http = createHttpServer(async (req, res) => {
|
|
|
726
726
|
try { return json2(res, 200, await rpc('pair.poll', { enrollId: p.enrollId })); }
|
|
727
727
|
catch (e) { return json2(res, 404, { error: e.message.replace(/^\d+: /, '') }); }
|
|
728
728
|
}
|
|
729
|
+
// ---- /hook/<id>[/<secret>]: webhook public (vòng 9 — port pi-pocket hooks, bản đơn).
|
|
730
|
+
// Dịch vụ ngoài (GitHub/CI/monitor/cron) POST ⇒ message vào conversation. KHÔNG token:
|
|
731
|
+
// secret nằm trong path (unsigned) hoặc chữ ký X-Hub-Signature-256 (signed) — daemon verify. ----
|
|
732
|
+
if (req.method === 'POST' && url.pathname.startsWith('/hook/')) {
|
|
733
|
+
const parts = url.pathname.slice('/hook/'.length).split('/').filter(Boolean);
|
|
734
|
+
if (parts.length < 1 || parts.length > 2 || parts.some((p) => p === '')) { res.writeHead(404, { 'content-type': 'application/json' }); return res.end('{"error":"Not found"}'); }
|
|
735
|
+
if (Number(req.headers['content-length'] ?? 0) > 5 * 1024 * 1024) { res.writeHead(413, { 'content-type': 'application/json' }); return res.end('{"error":"Request too large"}'); }
|
|
736
|
+
const body = await new Promise((done) => { let b = ''; req.on('data', (c) => (b += c)); req.on('end', () => done(b)); });
|
|
737
|
+
try {
|
|
738
|
+
const r = await rpc('hooks.dispatch', {
|
|
739
|
+
id: parts[0],
|
|
740
|
+
...(parts[1] !== undefined ? { secret: parts[1] } : {}),
|
|
741
|
+
body,
|
|
742
|
+
signature: req.headers['x-hub-signature-256'],
|
|
743
|
+
delivery: req.headers['x-github-delivery'] ?? req.headers['idempotency-key'],
|
|
744
|
+
});
|
|
745
|
+
return json2(res, 200, r);
|
|
746
|
+
} catch (e) {
|
|
747
|
+
const msg = String(e?.message ?? e).replace(/^-?\d+: /, '');
|
|
748
|
+
if (/not found/.test(msg)) return json2(res, 404, { error: 'Not found' }); // không tiết lộ hook tồn tại与否
|
|
749
|
+
if (/rate-limited/.test(msg)) { res.writeHead(429, { 'content-type': 'application/json', 'retry-after': '60', ...CORS }); return res.end('{"error":"Too many posts this hour"}'); }
|
|
750
|
+
return json2(res, 502, { error: msg });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
729
753
|
// ---- UI pocket (public/): shell + module + vendor tĩnh, KHÔNG cần token ----
|
|
730
754
|
// serveStatic phục vụ cả sw.js/manifest/icon của pocket (public/) — không còn hằng
|
|
731
755
|
// SW cũ: sw.js pocket có push handler (notification từ push service).
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// Mọi lệnh conversation nhận --cid N (mặc định: conversation active của daemon).
|
|
6
6
|
import { connect } from 'node:net';
|
|
7
7
|
import { join, resolve } from 'node:path';
|
|
8
|
-
import { userInfo } from 'node:os';
|
|
8
|
+
import { userInfo, homedir, hostname } from 'node:os';
|
|
9
9
|
import { readFile, writeFile, mkdir, stat } from 'node:fs/promises';
|
|
10
10
|
import { execFile } from 'node:child_process';
|
|
11
11
|
import { promisify } from 'node:util';
|
|
@@ -27,10 +27,25 @@ const cid = arg('cid', null); // conversation id cho submit/transcript/reset/wat
|
|
|
27
27
|
const cidParams = () => (cid ? { conversationId: Number(cid) } : {});
|
|
28
28
|
const cmd = process.argv[2] ?? 'status';
|
|
29
29
|
|
|
30
|
-
// Remote (thiết bị khác — Windows/phone
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
const
|
|
30
|
+
// Remote (thiết bị khác — Windows/phone): pair MỘT lần bằng 'bom-pi pair <code> --url …'
|
|
31
|
+
// ⇒ CLI tự lưu {url, token} vào ~/.bom-pi.json và dùng mãi (env BOM_PI_URL/BOM_PI_TOKEN
|
|
32
|
+
// vẫn đè lên nếu đặt; --url/--token đè lên tất cả). CLI nói chuyện web /rpc thay unix socket.
|
|
33
|
+
const CONFIG_PATH = join(homedir(), '.bom-pi.json');
|
|
34
|
+
let cliConfig = {};
|
|
35
|
+
try { cliConfig = JSON.parse(await readFile(CONFIG_PATH, 'utf8')); } catch { /* chưa pair bao giờ */ }
|
|
36
|
+
async function saveCliConfig(next) {
|
|
37
|
+
cliConfig = next;
|
|
38
|
+
await writeFile(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
const REMOTE_URL = arg('url', process.env.BOM_PI_URL || cliConfig.url || null);
|
|
41
|
+
const REMOTE_TOKEN = arg('token', process.env.BOM_PI_TOKEN || cliConfig.token || null);
|
|
42
|
+
|
|
43
|
+
// -v/--version trước mọi thứ — user kiểm bản nào đang chạy (bài học: máy Windows kẹt bản cũ)
|
|
44
|
+
const VERSION = (await import('../../../package.json', { with: { type: 'json' } })).default.version;
|
|
45
|
+
if (cmd === '-v' || cmd === '--version' || cmd === 'version') {
|
|
46
|
+
console.log(`bom-pi ${VERSION}${REMOTE_URL ? ` (remote ${REMOTE_URL})` : ''}`);
|
|
47
|
+
process.exit(0);
|
|
48
|
+
}
|
|
34
49
|
|
|
35
50
|
// Không thấy daemon: hướng dẫn theo nơi đang chạy — máy chủ (chưa start?) hay
|
|
36
51
|
// thiết bị khác (Windows/phone: bom-core là daemon unix-socket systemd, chưa port).
|
|
@@ -40,20 +55,17 @@ function unreachable(e) {
|
|
|
40
55
|
return `${head}
|
|
41
56
|
|
|
42
57
|
bom-core chạy trên máy chủ Linux (unix socket + systemd) — CLI chưa chạy được trên Windows.
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
set BOM_PI_URL=http://<ip>:8791 & set BOM_PI_TOKEN=bom_…
|
|
48
|
-
(PowerShell: $env:BOM_PI_URL=…; $env:BOM_PI_TOKEN=…) rồi chạy bom-pi status`;
|
|
58
|
+
Từ máy khác, pair MỘT LẦN bằng CLI (cần code in từ lệnh 'bom-pi pair' chạy TRÊN MÁY CHỦ):
|
|
59
|
+
bom-pi pair <CODE> --url http://<ip-máy-chủ>:8791
|
|
60
|
+
xong CLI tự nhớ (lưu ~/.bom-pi.json) — mọi lệnh chạy remote: bom-pi status / sessions …
|
|
61
|
+
(hoặc dùng WEB đầy đủ: mở http://<ip-máy-chủ>:8791 — pair bằng cùng code đó)`;
|
|
49
62
|
}
|
|
50
63
|
return `${head}
|
|
51
64
|
|
|
52
65
|
daemon chưa chạy? thử: systemctl --user start bom-core (xem trạng thái: bom-pi status)
|
|
53
66
|
chưa cài daemon?: bom-pi setup
|
|
54
|
-
ở THIẾT BỊ KHÁC?:
|
|
55
|
-
|
|
56
|
-
BOM_PI_URL=http://<ip>:8791 BOM_PI_TOKEN=bom_… bom-pi status`;
|
|
67
|
+
ở THIẾT BỊ KHÁC?: pair một lần rồi mọi lệnh chạy remote:
|
|
68
|
+
bom-pi pair <CODE> --url http://<ip-máy-chạy-daemon>:8791`;
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
function rpcSocket(method, params) {
|
|
@@ -94,11 +106,16 @@ const pretty = (o) => console.log(JSON.stringify(o, null, 2));
|
|
|
94
106
|
|
|
95
107
|
// Dispatcher: remote (/rpc qua HTTP) khi BOM_PI_URL đặt — thiết bị khác không có unix socket
|
|
96
108
|
async function rpcRemote(method, params) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
109
|
+
let r;
|
|
110
|
+
try {
|
|
111
|
+
r = await fetch(`${REMOTE_URL.replace(/\/+$/, '')}/rpc`, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'content-type': 'application/json', ...(REMOTE_TOKEN ? { Authorization: `Bearer ${REMOTE_TOKEN}` } : {}) },
|
|
114
|
+
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: params ?? {} }),
|
|
115
|
+
});
|
|
116
|
+
} catch (e) {
|
|
117
|
+
throw new Error(`không nối được ${REMOTE_URL}: ${e.message} — kiểm tra URL và web daemon (bom-web) đang chạy ở đó`);
|
|
118
|
+
}
|
|
102
119
|
const j = await r.json().catch(() => ({}));
|
|
103
120
|
if (!r.ok) throw new Error(`bom-web ${REMOTE_URL} ${r.status}: ${j?.error?.message ?? r.statusText}`);
|
|
104
121
|
return j;
|
|
@@ -310,7 +327,45 @@ try {
|
|
|
310
327
|
// M8 + security: pairing 2 CHIỀU — thiết bị nhập code (chiều 1) ⇒ enrollment chờ;
|
|
311
328
|
// token chỉ cấp khi duyệt tại máy (chiều 2): pair approve <mã ghép>
|
|
312
329
|
const sub2 = process.argv[3];
|
|
313
|
-
if (
|
|
330
|
+
if (/^[A-Z2-7]{8}$/i.test(sub2 ?? '')) {
|
|
331
|
+
// THIẾT BỊ KHÁC pair từ CLI: code in ra từ máy chủ + URL web của máy chủ đó.
|
|
332
|
+
// /api/pair/start + /api/pair/poll công khai (code + operator duyệt tự vệ) —
|
|
333
|
+
// xong CLI TỰ LƯU {url, token} vào ~/.bom-pi.json, các lệnh sau chạy remote luôn.
|
|
334
|
+
const url = arg('url', REMOTE_URL || process.env.BOM_PI_URL || cliConfig.url || null);
|
|
335
|
+
if (!url) {
|
|
336
|
+
console.error('usage: bom-pi pair <code> --url http://<ip-máy-chủ>:8791 [--name tên-máy]\n (URL là máy CHẠY DAEMON — in từ lệnh \'bom-pi pair\' trên máy đó, vd https://bom.example.io.vn hoặc http://192.168.1.4:8791)');
|
|
337
|
+
process.exit(64);
|
|
338
|
+
}
|
|
339
|
+
const base = url.replace(/\/+$/, '');
|
|
340
|
+
const name = arg('name', `${hostname() || 'device'} (cli)`);
|
|
341
|
+
const post = async (path, body) => {
|
|
342
|
+
const r = await fetch(`${base}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
|
|
343
|
+
const j = await r.json().catch(() => ({}));
|
|
344
|
+
if (!r.ok) throw new Error(`${path} ${r.status}: ${j?.error ?? r.statusText}`);
|
|
345
|
+
return j;
|
|
346
|
+
};
|
|
347
|
+
const st = await post('/api/pair/start', { code: sub2.toUpperCase(), name });
|
|
348
|
+
console.log(`đã gửi — chờ duyệt tại MÁY CHỦ. Ghép mã: ${st.displayId}`);
|
|
349
|
+
console.log(`(trên máy chủ: bom-pi pair approve ${st.displayId})`);
|
|
350
|
+
const t0 = Date.now();
|
|
351
|
+
for (;;) {
|
|
352
|
+
await new Promise((s) => setTimeout(s, st.pollAfterMs || 1500));
|
|
353
|
+
const p = await post('/api/pair/poll', { enrollId: st.enrollId });
|
|
354
|
+
if (p.status === 'approved') {
|
|
355
|
+
await saveCliConfig({ url: base, token: p.deviceToken, deviceId: p.deviceId, pairedAt: Date.now() });
|
|
356
|
+
console.log(`\nPAIRED ✓ — đã lưu ${CONFIG_PATH}`);
|
|
357
|
+
console.log(`mọi lệnh giờ tự chạy tới ${base}: bom-pi status / sessions / submit …`);
|
|
358
|
+
console.log(`(gỡ: bom-pi unpair)`);
|
|
359
|
+
process.exit(0);
|
|
360
|
+
}
|
|
361
|
+
if (p.status === 'rejected') { console.error('từ chối bởi operator tại máy chủ.'); process.exit(1); }
|
|
362
|
+
if (Date.now() - t0 > 5 * 60 * 1000) { console.error('hết thời gian chờ duyệt (5 phút) — chạy lại.'); process.exit(1); }
|
|
363
|
+
process.stdout.write('.');
|
|
364
|
+
}
|
|
365
|
+
} else if (!sub2 && REMOTE_URL) {
|
|
366
|
+
console.error(`máy này đã pair remote tới ${REMOTE_URL} (config ${CONFIG_PATH}).\n 'bom-pi pair' (in code/QR) là lệnh chạy TRÊN MÁY CHỦ — chạy đó, hoặc gỡ remote: bom-pi unpair.`);
|
|
367
|
+
process.exit(64);
|
|
368
|
+
} else if (!sub2) {
|
|
314
369
|
const r = await rpc('pair.code');
|
|
315
370
|
const nets = await import('node:os');
|
|
316
371
|
const lan = Object.values(nets.networkInterfaces() ?? {}).flat()
|
|
@@ -354,6 +409,15 @@ try {
|
|
|
354
409
|
console.log(`${d.revokedAtMs ? '○' : '●'} ${d.id} ${(d.name ?? '?').padEnd(18)} ${d.revokedAtMs ? 'revoked ' + new Date(d.revokedAtMs).toLocaleString() : 'paired ' + when} hash ${d.tokenHash8}`);
|
|
355
410
|
}
|
|
356
411
|
console.error(`# ${r.devices.filter((d) => !d.revokedAtMs).length} đang sống — thu hồi: bom-pi device rm <id>`);
|
|
412
|
+
} else if (cmd === 'unpair') {
|
|
413
|
+
// Gỡ remote khỏi máy này: thu hồi device TẠI MÁY CHỦ (best-effort) rồi xoá config local
|
|
414
|
+
if (!cliConfig.url || !cliConfig.token) { console.error('# máy này chưa pair remote (không có config)'); process.exit(0); }
|
|
415
|
+
try {
|
|
416
|
+
await rpcRemote('devices.remove', { id: cliConfig.deviceId });
|
|
417
|
+
console.log('đã thu hồi device tại máy chủ.');
|
|
418
|
+
} catch (e) { console.error(`# không thu hồi được tại máy chủ (${e.message}) — xoá config local thôi.`); }
|
|
419
|
+
await saveCliConfig({});
|
|
420
|
+
console.log(`đã xoá ${CONFIG_PATH} — CLI quay về local socket.`);
|
|
357
421
|
} else if (cmd === 'setup') {
|
|
358
422
|
// Sinh systemd user units đúng đường dẫn cài (npm global HOẶC working tree) + enable.
|
|
359
423
|
// root = 3 cấp trên file CLI này: packages/cli/src → gốc package (cả hai kiểu cài đều đúng)
|
|
@@ -488,7 +552,7 @@ WantedBy=default.target
|
|
|
488
552
|
} else if (cmd === 'shutdown') {
|
|
489
553
|
pretty(await rpc('shutdown'));
|
|
490
554
|
} else {
|
|
491
|
-
console.error('usage: bom-pi <hello|status|models|agents|sessions|new|use|submit|reset|transcript|approvals|approve|deny|schedules|schedule|unschedule|factory|limits|abort|watch|
|
|
555
|
+
console.error('usage: bom-pi <hello|status|models|agents|sessions|new|use|submit|reset|transcript|approvals|approve|deny|schedules|schedule|unschedule|factory|limits|abort|watch|unpair|version> [--socket PATH] [--cid N] [--url http://host:8791] [--token bom_…]\n thiết bị khác: bom-pi pair <CODE> --url http://host:8791 — pair một lần, CLI tự nhớ (xem bom-pi version)');
|
|
492
556
|
process.exit(64);
|
|
493
557
|
}
|
|
494
558
|
if (cmd !== 'watch') process.exit(0);
|
|
@@ -31,7 +31,7 @@ import { homedir, userInfo } from 'node:os';
|
|
|
31
31
|
import process from 'node:process';
|
|
32
32
|
import { execFile as execFileCb } from 'node:child_process';
|
|
33
33
|
import { promisify } from 'node:util';
|
|
34
|
-
import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes } from 'node:crypto';
|
|
34
|
+
import { createHash, createHmac, createPrivateKey, createPublicKey, generateKeyPairSync, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
35
35
|
import { createLimitGate } from './limit-gate.mjs';
|
|
36
36
|
|
|
37
37
|
const VERSION = '0.0.4';
|
|
@@ -990,6 +990,37 @@ async function saveDevices() {
|
|
|
990
990
|
await writeFile(tmp, JSON.stringify(webDevices, null, 2) + '\n');
|
|
991
991
|
await rename(tmp, devicesPath);
|
|
992
992
|
}
|
|
993
|
+
|
|
994
|
+
// ---- Webhooks (vòng 9 — port pi-pocket hooks.ts, bản đơn có chủ ý): địa chỉ mà dịch vụ
|
|
995
|
+
// ngoài (GitHub/CI/monitor/cron) POST đến ⇒ message vào conversation. Secret trong path
|
|
996
|
+
// (unsigned) hoặc chữ ký X-Hub-Signature-256 (signed, khuôn GitHub). PORT(bom-pi): lọc
|
|
997
|
+
// "người ngoài repo" của pocket (cần gh query writers) — vòng này post hợp lệ đều qua. ----
|
|
998
|
+
const hooksPath = join(dataDir, 'hooks.json');
|
|
999
|
+
let hooks = []; // [{id, secret, name, instructions, target, signed?, by, createdAt, count, lastAt?}]
|
|
1000
|
+
const hookSeen = new Map(); // idempotency: key → ts (delivery id / Idempotency-Key, 1h)
|
|
1001
|
+
const hookHits = new Map(); // rate-limit: hook id → [ts] (30 post/h, cửa sổ trượt)
|
|
1002
|
+
const HOOK_LIMITS = { max: 20, nameLen: 60, instructionsLen: 2000, perHour: 30, payloadChars: 8000 };
|
|
1003
|
+
const HOOK_DEFAULT_INSTRUCTIONS = 'Something happened that you were set up to hear about. Say in a line what it is, and whether anything needs doing.';
|
|
1004
|
+
async function loadHooks() {
|
|
1005
|
+
try { hooks = JSON.parse(await readFile(hooksPath, 'utf8')); } catch { hooks = []; }
|
|
1006
|
+
}
|
|
1007
|
+
async function saveHooks() {
|
|
1008
|
+
const tmp = hooksPath + '.tmp';
|
|
1009
|
+
await writeFile(tmp, JSON.stringify(hooks, null, 2) + '\n');
|
|
1010
|
+
await rename(tmp, hooksPath);
|
|
1011
|
+
}
|
|
1012
|
+
const hookLabel = (name) => String(name ?? '').replace(/[[\]]/g, '').replace(/\s+/g, ' ').trim();
|
|
1013
|
+
const sameSecret = (a, b) => {
|
|
1014
|
+
const l = createHash('sha256').update(String(a)).digest();
|
|
1015
|
+
const r = createHash('sha256').update(String(b)).digest();
|
|
1016
|
+
return timingSafeEqual(l, r) && String(a).length === String(b).length;
|
|
1017
|
+
};
|
|
1018
|
+
function hookView(h) {
|
|
1019
|
+
const { secret, ...rest } = h;
|
|
1020
|
+
// unsigned: secret nằm trong path (địa chỉ tự chịu); signed: path không secret —
|
|
1021
|
+
// secret hiển thị riêng để paste vào GitHub Webhook secret
|
|
1022
|
+
return h.signed === true ? { ...rest, path: `/hook/${h.id}`, secret } : { ...rest, path: `/hook/${h.id}/${secret}` };
|
|
1023
|
+
}
|
|
993
1024
|
const sha256hex = (s) => createHash('sha256').update(s).digest('hex');
|
|
994
1025
|
// Daemon keypair Ed25519 — sinh tại lần boot đầu (khuôn ssh host key / spin daemon):
|
|
995
1026
|
// MÁY NÀO cài bom-pi cũng tự có cặp key riêng. fp = 8 hex đầu sha256(publicKey DER) —
|
|
@@ -1325,6 +1356,7 @@ try {
|
|
|
1325
1356
|
await loadFactories();
|
|
1326
1357
|
await loadMetrics(); // M6c: ledger số đo trust ladder
|
|
1327
1358
|
await loadDevices(); // M8: thiết bị đã pair (web-devices.json)
|
|
1359
|
+
await loadHooks(); // vòng 9: webhook ngoài → conversation (hooks.json)
|
|
1328
1360
|
await loadDaemonKeys(); // offer pair: keypair danh tính — fp + pubkey cho device đối chiếu
|
|
1329
1361
|
if (factories.length) log('factories nạp lại:', factories.length, '—', factories.map((f) => f.id).join(', '));
|
|
1330
1362
|
await loadConfigWatches(); // M5b: đăng lại watch config (scan-timer là M5d)
|
|
@@ -1570,6 +1602,97 @@ const methods = {
|
|
|
1570
1602
|
await conv.configure(change, BACKGROUND_CONTEXT);
|
|
1571
1603
|
return { ok: true, conversationId: conv.id };
|
|
1572
1604
|
},
|
|
1605
|
+
// ---- Webhooks (vòng 9): quản lý qua CLI/web, dispatch từ route public /hook/<id>/<secret> ----
|
|
1606
|
+
async hooksList() {
|
|
1607
|
+
return { hooks: hooks.map(hookView) };
|
|
1608
|
+
},
|
|
1609
|
+
async hooksAdd(params) {
|
|
1610
|
+
const name = hookLabel(params?.name);
|
|
1611
|
+
const instructions = typeof params?.instructions === 'string' ? params.instructions.trim() : '';
|
|
1612
|
+
if (!name || name.length > HOOK_LIMITS.nameLen) throw new Error(`name: 1–${HOOK_LIMITS.nameLen} ký tự, một dòng`);
|
|
1613
|
+
if (instructions.length > HOOK_LIMITS.instructionsLen) throw new Error(`instructions ≤ ${HOOK_LIMITS.instructionsLen} ký tự`);
|
|
1614
|
+
const target = Number(params?.target);
|
|
1615
|
+
if (!Number.isSafeInteger(target)) throw new Error('target: conversation id (xem session.list)');
|
|
1616
|
+
await convFor({ conversationId: target }); // phải là conversation thật
|
|
1617
|
+
if (hooks.length >= HOOK_LIMITS.max) throw new Error(`tối đa ${HOOK_LIMITS.max} webhook — bỏ cái cũ trước (hooks.remove)`);
|
|
1618
|
+
// PORT(bom-pi): 'anyone' của pocket (lọc người ngoài repo qua gh) chưa có — bỏ qua
|
|
1619
|
+
const hook = {
|
|
1620
|
+
id: randomBytes(6).toString('base64url'),
|
|
1621
|
+
secret: randomBytes(24).toString('base64url'),
|
|
1622
|
+
name, instructions, target,
|
|
1623
|
+
...(params?.signed === true ? { signed: true } : {}),
|
|
1624
|
+
by: 'bom-owner', createdAt: Date.now(), count: 0,
|
|
1625
|
+
};
|
|
1626
|
+
hooks.push(hook);
|
|
1627
|
+
await saveHooks();
|
|
1628
|
+
log('webhook tạo:', hook.id, '→ conv', target, hook.signed ? '(GitHub signed)' : '');
|
|
1629
|
+
return hookView(hook);
|
|
1630
|
+
},
|
|
1631
|
+
async hooksRemove({ id }) {
|
|
1632
|
+
const before = hooks.length;
|
|
1633
|
+
hooks = hooks.filter((h) => h.id !== String(id ?? ''));
|
|
1634
|
+
if (hooks.length === before) throw new Error('không có webhook đó — hooks.list');
|
|
1635
|
+
hookHits.delete(String(id));
|
|
1636
|
+
await saveHooks();
|
|
1637
|
+
return { ok: true };
|
|
1638
|
+
},
|
|
1639
|
+
async hooksRotate({ id }) {
|
|
1640
|
+
const h = hooks.find((x) => x.id === String(id ?? ''));
|
|
1641
|
+
if (!h) throw new Error('không có webhook đó — hooks.list');
|
|
1642
|
+
h.secret = randomBytes(24).toString('base64url');
|
|
1643
|
+
await saveHooks();
|
|
1644
|
+
log('webhook xoay secret:', h.id);
|
|
1645
|
+
return hookView(h);
|
|
1646
|
+
},
|
|
1647
|
+
async hooksDispatch({ id, secret, body, signature, delivery }) {
|
|
1648
|
+
const h = hooks.find((x) => x.id === String(id ?? ''));
|
|
1649
|
+
// Không tiết lộ hook có tồn tại hay không — 404 chung chung như pocket
|
|
1650
|
+
if (!h) throw new Error('not found');
|
|
1651
|
+
const text = String(body ?? '');
|
|
1652
|
+
if (secret !== undefined) {
|
|
1653
|
+
if (h.signed === true || !sameSecret(secret, h.secret)) throw new Error('not found');
|
|
1654
|
+
} else {
|
|
1655
|
+
// Khuôn GitHub: X-Hub-Signature-256 = sha256=HMAC_SHA256(secret, body)
|
|
1656
|
+
const sig = String(signature ?? '');
|
|
1657
|
+
const expect = `sha256=${createHmac('sha256', h.secret).update(text).digest('hex')}`;
|
|
1658
|
+
if (h.signed !== true || !sameSecret(sig, expect)) throw new Error('not found');
|
|
1659
|
+
}
|
|
1660
|
+
// Idempotency (GitHub delivery id / Idempotency-Key): post lại không thành message thứ hai
|
|
1661
|
+
const key = delivery !== undefined && delivery !== '' ? String(delivery) : null;
|
|
1662
|
+
if (key) {
|
|
1663
|
+
const seen = hookSeen.get(`${h.id}:${key}`);
|
|
1664
|
+
if (seen && Date.now() - seen < 3_600_000) return { ok: true, duplicate: true, conversationId: h.target };
|
|
1665
|
+
hookSeen.set(`${h.id}:${key}`, Date.now());
|
|
1666
|
+
if (hookSeen.size > 500) { // prune — giữ map nhỏ
|
|
1667
|
+
const cut = Date.now() - 3_600_000;
|
|
1668
|
+
for (const [k, ts] of hookSeen) if (ts < cut) hookSeen.delete(k);
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
// Rate-limit 30 post/h mỗi hook (cửa sổ trượt)
|
|
1672
|
+
const now = Date.now();
|
|
1673
|
+
const hits = (hookHits.get(h.id) ?? []).filter((t) => now - t < 3_600_000);
|
|
1674
|
+
if (hits.length >= HOOK_LIMITS.perHour) throw new Error('rate-limited: too many posts this hour');
|
|
1675
|
+
hits.push(now);
|
|
1676
|
+
hookHits.set(h.id, hits);
|
|
1677
|
+
// Message khuôn pocket: nhãn + instructions + payload fence (đọc như DATA, không phải lệnh)
|
|
1678
|
+
const payload = text.length > HOOK_LIMITS.payloadChars ? `${text.slice(0, HOOK_LIMITS.payloadChars)}\n… (clipped)` : text;
|
|
1679
|
+
const message = `[via webhook ${h.name}]\n${h.instructions || HOOK_DEFAULT_INSTRUCTIONS}\n\n<webhook-payload>\n${payload}\n</webhook-payload>`;
|
|
1680
|
+
try {
|
|
1681
|
+
await methods.sessionSubmit({
|
|
1682
|
+
conversationId: h.target,
|
|
1683
|
+
text: message,
|
|
1684
|
+
...(key ? { requestId: `hook-${h.id}-${key}` } : {}), // durable idempotent khi có delivery id
|
|
1685
|
+
});
|
|
1686
|
+
} catch (e) {
|
|
1687
|
+
hookSeen.delete(`${h.id}:${key}`); // không vào transcript ⇒ lần sau thử lại được
|
|
1688
|
+
throw new Error(`không submit được: ${e.message}`);
|
|
1689
|
+
}
|
|
1690
|
+
h.count += 1;
|
|
1691
|
+
h.lastAt = now;
|
|
1692
|
+
await saveHooks();
|
|
1693
|
+
return { ok: true, conversationId: h.target };
|
|
1694
|
+
},
|
|
1695
|
+
|
|
1573
1696
|
async sessionSubmit(params) {
|
|
1574
1697
|
const conv = await convFor(params);
|
|
1575
1698
|
const text = typeof params.text === 'string' ? params.text.trim() : '';
|