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.
@@ -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
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bom-pi",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "description": "bom × pi — hệ sinh thái durable cho pi trên nền pi-durable: core daemon + web UI pocket + CLI",
6
6
  "engines": {
@@ -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 đã pair): BOM_PI_URL=http://host:8791 (+ BOM_PI_TOKEN
31
- // device token bom_… copy sau khi pair qua browser). CLI nói chuyện web /rpc thay unix socket.
32
- const REMOTE_URL = arg('url', process.env.BOM_PI_URL || null);
33
- const REMOTE_TOKEN = arg('token', process.env.BOM_PI_TOKEN || null);
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
- Hai đường từ máy khác:
44
- · WEB (đầy đủ nhất): mở http://<ip-máy-chạy-daemon>:8791 (hoặc tunnel https://…) rồi pair
45
- bằng code in ra từ lệnh 'bom-pi pair' chạy TRÊN MÁY CHỦ đó.
46
- · CLI REMOTE: pair qua browser trước, copy device token bom_…, rồi đặt:
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?: CLI có thể nói chuyện daemon từ xa — pair qua browser trước
55
- (http://<ip-máy-chạy-daemon>:8791), copy device token, rồi:
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
- const r = await fetch(`${REMOTE_URL.replace(/\/+$/, '')}/rpc`, {
98
- method: 'POST',
99
- headers: { 'content-type': 'application/json', ...(REMOTE_TOKEN ? { Authorization: `Bearer ${REMOTE_TOKEN}` } : {}) },
100
- body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params: params ?? {} }),
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 (!sub2) {
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|shutdown> [--socket PATH] [--cid N]');
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() : '';