@zaofan/dsh-qqbot 1.10.47 → 1.11.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.
Files changed (45) hide show
  1. package/README.md +40 -3
  2. package/client/qqbot-settings.js +694 -12
  3. package/dist/features/ext-capabilities.d.ts +2 -0
  4. package/dist/features/ext-capabilities.d.ts.map +1 -1
  5. package/dist/features/ext-capabilities.js +38 -0
  6. package/dist/features/ext-capabilities.js.map +1 -1
  7. package/dist/features/ext-reload.d.ts +2 -2
  8. package/dist/features/ext-reload.d.ts.map +1 -1
  9. package/dist/features/ext-reload.js +30 -8
  10. package/dist/features/ext-reload.js.map +1 -1
  11. package/dist/features/ext-ui-seed.d.ts +25 -0
  12. package/dist/features/ext-ui-seed.d.ts.map +1 -0
  13. package/dist/features/ext-ui-seed.js +187 -0
  14. package/dist/features/ext-ui-seed.js.map +1 -0
  15. package/dist/features/ext-ui.d.ts +42 -0
  16. package/dist/features/ext-ui.d.ts.map +1 -0
  17. package/dist/features/ext-ui.js +207 -0
  18. package/dist/features/ext-ui.js.map +1 -0
  19. package/dist/features/extension-store.d.ts.map +1 -1
  20. package/dist/features/extension-store.js +3 -0
  21. package/dist/features/extension-store.js.map +1 -1
  22. package/dist/gateway/bootstrap.d.ts.map +1 -1
  23. package/dist/gateway/bootstrap.js +18 -1
  24. package/dist/gateway/bootstrap.js.map +1 -1
  25. package/dist/gateway/middleware-setup.d.ts.map +1 -1
  26. package/dist/gateway/middleware-setup.js +11 -1
  27. package/dist/gateway/middleware-setup.js.map +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +131 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/session/session-manager.d.ts.map +1 -1
  32. package/dist/session/session-manager.js +2 -1
  33. package/dist/session/session-manager.js.map +1 -1
  34. package/docs/extension-contract.md +400 -255
  35. package/examples/ext-commands//346/211/251/345/261/225/347/244/272/344/276/213.mjs +84 -0
  36. package/examples/ext-hooks/demo-store.mjs +75 -0
  37. package/examples/ext-ui/README.md +95 -0
  38. package/examples/ext-ui/demo/dock/card.css +55 -0
  39. package/examples/ext-ui/demo/dock/card.html +95 -0
  40. package/examples/ext-ui/demo/dock/card.js +261 -0
  41. package/examples/ext-ui/demo/settings/card.css +61 -0
  42. package/examples/ext-ui/demo/settings/card.html +38 -0
  43. package/examples/ext-ui/demo/settings/card.js +87 -0
  44. package/package.json +145 -144
  45. package/settings-host.js +585 -4
package/settings-host.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { readFileSync, writeFileSync, appendFileSync, rmSync, mkdirSync, existsSync, readdirSync, statSync, cpSync, renameSync, openSync, readSync, closeSync, createWriteStream } from 'node:fs';
7
7
  import { basename, extname, join, resolve, dirname, normalize, sep } from 'node:path';
8
8
  import { fileURLToPath, pathToFileURL } from 'node:url';
9
+ import { createRequire } from 'node:module'; // 懒加载 zlib(zstd 是 Node 22.15+ 才有,静态 import 会在老 Node 上直接炸)
9
10
  import { spawn, execFileSync } from 'node:child_process';
10
11
  import { homedir } from 'node:os';
11
12
  import { createHash } from 'node:crypto';
@@ -1426,6 +1427,10 @@ export function apply(ctx) {
1426
1427
  // 账号数据目录(各号各库各定时): 图库={cwd}/表情包, 定时={cwd}/.qqbot
1427
1428
  dataDir: droot ? join(droot, '表情包') : '',
1428
1429
  schedDir: droot ? join(droot, '.qqbot') : '',
1430
+ // ★ 2026-10-27 补:把**数据根本身**也回给前端。
1431
+ // 上面 dataDir 是「图库目录」({dataRoot}/表情包),不是 dataRoot ——
1432
+ // 扩展 UI 按 dataRoot 扫({dataRoot}/.qqbot-extensions/ui/),拿 dataDir 会找错地方。
1433
+ dataRoot: droot || '',
1429
1434
  };
1430
1435
  }),
1431
1436
  });
@@ -1445,7 +1450,109 @@ export function apply(ctx) {
1445
1450
  // 列出"已有 dsh 会话"的工作区目录 + 会话数, 供账号 cwd 挑选(避免指到空目录/错目录)。
1446
1451
  // dsh 把会话按工作区路径归到 ~/.dsh/sessions/--<编码路径>--, 目录内 *.zstd = 会话数。
1447
1452
  /** dsh session 目录名 → 工作区绝对路径(~XXXX 为 UTF-16 code unit, - 为段分隔, 首段补盘符冒号) */
1453
+ // ── 会话文件头读 cwd(2026-10-26,为 decodeSessionDirName 的第二层)──────────
1454
+ // 为什么需要:registry 只认「已登记的工作区」。用 session_create 在带横杠的目录里
1455
+ // 建会话时它落"未分组",registry 查不到,只能靠文件头。
1456
+ // 文件是 zstd 压缩(session*.jsonl.zstd),Node 22.15+ 才有 zstdDecompressSync;
1457
+ // 拿不到就返回 '',调用方退到旧的拆 '-' 逻辑。
1458
+ let _zlibLazy; // undefined=没试过 / null=不可用 / obj=可用
1459
+ function getZlibSync() {
1460
+ if (_zlibLazy !== undefined) return _zlibLazy;
1461
+ try {
1462
+ const req = createRequire(import.meta.url);
1463
+ const z = req('node:zlib');
1464
+ _zlibLazy = (z && typeof z.zstdDecompressSync === 'function') ? z : null;
1465
+ } catch { _zlibLazy = null; }
1466
+ return _zlibLazy;
1467
+ }
1468
+ const _cwdHeaderCache = new Map(); // dirName → cwd(结果稳定,缓存不过期)
1469
+ function readCwdFromSessionHeader(dirName) {
1470
+ if (!dirName) return '';
1471
+ if (_cwdHeaderCache.has(dirName)) return _cwdHeaderCache.get(dirName);
1472
+ let result = '';
1473
+ const z = getZlibSync();
1474
+ if (z) {
1475
+ const root = join(homedir(), '.dsh', 'sessions', dirName);
1476
+ const stack = [root];
1477
+ let guard = 0;
1478
+ while (stack.length && guard++ < 200 && !result) {
1479
+ const cur = stack.pop();
1480
+ let ents = [];
1481
+ try { ents = readdirSync(cur, { withFileTypes: true }); } catch { continue; }
1482
+ for (const f of ents) {
1483
+ const p = join(cur, f.name);
1484
+ if (f.isDirectory()) { stack.push(p); continue; }
1485
+ if (!/session.*\.jsonl\.zstd$/i.test(f.name)) continue;
1486
+ try {
1487
+ const txt = z.zstdDecompressSync(readFileSync(p)).toString('utf8');
1488
+ const j = JSON.parse(txt.split(String.fromCharCode(10))[0]);
1489
+ if (j && typeof j.cwd === 'string' && j.cwd) { result = j.cwd; break; }
1490
+ } catch { /* 该文件读不通,继续下一个 */ }
1491
+ }
1492
+ }
1493
+ }
1494
+ _cwdHeaderCache.set(dirName, result);
1495
+ return result;
1496
+ }
1497
+
1448
1498
  function decodeSessionDirName(name) {
1499
+ // ══ ★ 2026-10-26(实测验证后改):先问 dsh 官方 Workspace Registry,用 sid 反查 ══
1500
+ // dsh 把工作区路径转成 `--D-agent-sandbox--` 这种目录名时,转义是**有损**的:
1501
+ // '-' 既是路径分隔符、也是路径本身的合法字符,编码没有区分两者。
1502
+ // 于是 `split('-')` 还原必然出歧义 —— 路径里一带连字符就错:
1503
+ // D:\agent-sandbox → 被还原成 D:\agent\sandbox
1504
+ // 现象(PR #23):扩展面板「当前工作区/内置目录/扩展包目录」全指向不存在的路径,
1505
+ // 内置扩展计数恒为 0,工作区选择器列出一堆假的。
1506
+ //
1507
+ // 根治方式不是"猜转义规则",而是**根本不解码**:
1508
+ // 实测 ctx.get('workspaceRegistry').list() 返回
1509
+ // [{ id, record: { path, title, sessionIds: [...] } }, ...]
1510
+ // 拿「这个 sessions 目录里出现的 sid 集合」去反查 record.sessionIds,命中即得真 path。
1511
+ // 零歧义、不受 dsh 以后改转义规则影响。
1512
+ try {
1513
+ const reg = (typeof ctx.get === 'function') ? ctx.get('workspaceRegistry') : null;
1514
+ if (reg && typeof reg.list === 'function') {
1515
+ const sroot = join(homedir(), '.dsh', 'sessions', String(name || ''));
1516
+ if (existsSync(sroot)) {
1517
+ const sidSet = new Set();
1518
+ const stack = [sroot];
1519
+ let guard = 0;
1520
+ while (stack.length && guard++ < 500) {
1521
+ const cur = stack.pop();
1522
+ let ents = [];
1523
+ try { ents = readdirSync(cur, { withFileTypes: true }); } catch { continue; }
1524
+ for (const f of ents) {
1525
+ if (!f.isDirectory()) continue;
1526
+ // 会话子目录名就是 sessionId(UUID)
1527
+ if (/^[0-9a-f]{8}-[0-9a-f]{4}-/i.test(f.name)) sidSet.add(f.name);
1528
+ stack.push(join(cur, f.name));
1529
+ }
1530
+ }
1531
+ if (sidSet.size) {
1532
+ const all = reg.list() || [];
1533
+ const hit = all.find((w) => {
1534
+ const ids = (w && w.record && w.record.sessionIds) || [];
1535
+ return ids.some((s) => sidSet.has(String(s)));
1536
+ });
1537
+ if (hit && hit.record && hit.record.path) return String(hit.record.path);
1538
+ }
1539
+ }
1540
+ }
1541
+ } catch { /* 拿不到就往下走旧逻辑,绝不影响主链 */ }
1542
+
1543
+ // ══ ★ 2026-10-26 第二层:registry 没命中(工作区未分组/未登记)→ 读 session header 的 cwd ══
1544
+ // 实测(造 D:/newwenjianjia/aiwork/test-ws 验证过):用 session_create 在带横杠的目录里
1545
+ // 建会话时,该目录还没被登记成"工作区",registry.list() 查不到它,第一层就落空。
1546
+ // 但**会话文件头里写着真实 cwd**:
1547
+ // sessions/<目录名>/<sid>/session*.jsonl.zstd → 第一行 JSON 的 cwd
1548
+ // 实测该用例:旧解码给出 D:\...\test\ws(错),header 给出 D:/.../test-ws(对)。
1549
+ // 文件是 zstd 压缩(Node 22.15+ 才有 zstdDecompressSync),拿不到就跳过、退第三层。
1550
+ try {
1551
+ const fromHeader = readCwdFromSessionHeader(String(name || ''));
1552
+ if (fromHeader) return fromHeader;
1553
+ } catch { /* 继续往下 */ }
1554
+
1555
+ // ② 旧逻辑:按 '-' 拆分还原(保留为兜底 —— 老宿主没有 workspaceRegistry 时)
1449
1556
  try {
1450
1557
  const segs = name.split('-').filter(Boolean);
1451
1558
  if (segs.length === 0) return '';
@@ -5177,6 +5284,480 @@ const WHY_MAP = { busy: '目标会话回合活跃,已等待至回合结束仍超
5177
5284
  // 扩展自己在代码里可用 readExtConfig 的方式拿到(暂未内置 API,先读那个 json 即可)。
5178
5285
  // ═══════════════════════════════════════════════════════════════════════════
5179
5286
 
5287
+ // ══ 扩展 UI 卡片(2026-10-26):给扩展一个声明式 UI 入口 ══════════════════
5288
+ // 扩展在 {dataRoot}/.qqbot-extensions/ui/<id>/card.html(或扩展包内 ui/<id>/)放静态文件,
5289
+ // 面板从这两条路由取。详见 src/features/ext-ui.ts 与 docs/extension-contract.md。
5290
+ //
5291
+ // GET …/ext-ui/list?cwd=… → { ok, items:[{extId,slot,title,order,files,from}] }
5292
+ // GET …/ext-ui/file?cwd=&id=&f= → { ok, text } | { ok:false, reason }
5293
+ //
5294
+ // 安全:extId 白名单正则 + 必须是扫描结果里真实存在的;f 只允许 card.html/card.css/card.js;
5295
+ // 路径 resolve 后还要校验仍在扩展目录内(防 .. 穿越)—— 全在 ext-ui.ts 里做。
5296
+ route(ctx, 'GET', '/api/qqbot-settings/ext-ui/list', async (req, res) => {
5297
+ let cwd = '';
5298
+ let explicitRoot = '';
5299
+ try {
5300
+ const sp = new URL(req.url ?? '/', 'http://x').searchParams;
5301
+ cwd = String(sp.get('cwd') || '').trim();
5302
+ explicitRoot = String(sp.get('dataRoot') || '').trim();
5303
+ } catch { cwd = ''; }
5304
+ try {
5305
+ // ★ 2026-10-27 修(主人实测:切到 im-qqbot-3 仍显示 im-qqbot-2 的扩展卡片):
5306
+ // 面板是「按机器人实例」看的,各实例 dataRoot 不同(im-qqbot-3 甚至没配 dataRoot、
5307
+ // 只配了 cwd)。原来这里只按 cwd 推、前端又没传 → 永远落到宿主默认那份。
5308
+ // 现在前端把当前实例的 dataDir 直接传上来(dataRoot 参数),优先用它。
5309
+ const dataRoot = explicitRoot || (resolveDataRootForWorkspace(cwd) || {}).dataRoot;
5310
+ if (!dataRoot) return writeJson(res, 200, { ok: false, items: [], error: '推不出 dataRoot' });
5311
+ const mod = await import('./dist/features/ext-ui.js');
5312
+ const items = mod.listExtUi(dataRoot, PROFILE_ROOT).map((x) => ({
5313
+ extId: x.extId, slot: x.slot, title: x.title, order: x.order, files: x.files, from: x.from,
5314
+ }));
5315
+ return writeJson(res, 200, { ok: true, items, dataRoot });
5316
+ } catch (e) {
5317
+ return writeJson(res, 200, { ok: false, items: [], error: e instanceof Error ? e.message : String(e) });
5318
+ }
5319
+ });
5320
+
5321
+ route(ctx, 'GET', '/api/qqbot-settings/ext-ui/file', async (req, res) => {
5322
+ let q = null;
5323
+ try { q = new URL(req.url ?? '/', 'http://x').searchParams; } catch { q = null; }
5324
+ const cwd = String(q?.get('cwd') || '').trim();
5325
+ const extId = String(q?.get('id') || '').trim();
5326
+ const fileName = String(q?.get('f') || '').trim();
5327
+ try {
5328
+ const { dataRoot } = resolveDataRootForWorkspace(cwd);
5329
+ if (!dataRoot) return writeJson(res, 200, { ok: false, reason: '推不出 dataRoot' });
5330
+ const mod = await import('./dist/features/ext-ui.js');
5331
+ const r = mod.readExtUiFile(dataRoot, PROFILE_ROOT, extId, fileName, String(q?.get("slot") || "").trim());
5332
+ return writeJson(res, 200, r.ok ? { ok: true, text: r.text } : { ok: false, reason: r.reason });
5333
+ } catch (e) {
5334
+ return writeJson(res, 200, { ok: false, reason: e instanceof Error ? e.message : String(e) });
5335
+ }
5336
+ });
5337
+
5338
+ // ══ 扩展自己的配置(2026-10-26):profile 级 / 数据根 双源 ══════════════════
5339
+ // 为什么要两套:
5340
+ // scope=profile → {PROFILE_ROOT}/.qqbot-extensions/.state/ext-config/<id>.json
5341
+ // 全局一份,影响**所有机器人实例**(扩展在设置页里改的走这个)
5342
+ // scope=data → {dataRoot}/.qqbot-extensions/.state/ext-config/<id>.json
5343
+ // 每个实例一份(扩展在 dock 球里改的走这个)
5344
+ // 前端卡片用 qqbotExt.getConfig(scope) / setConfig(scope, patch) 调用。
5345
+ function extCfgSafeId(extId) {
5346
+ return String(extId || '').trim().replace(/[^a-zA-Z0-9_\-]/g, '');
5347
+ }
5348
+ function extCfgPathOf(scope, cwd, extId) {
5349
+ const safe = extCfgSafeId(extId);
5350
+ if (!safe) return '';
5351
+ if (scope === 'profile') {
5352
+ return join(PROFILE_ROOT, '.qqbot-extensions', '.state', 'ext-config', safe + '.json');
5353
+ }
5354
+ const { dataRoot } = resolveDataRootForWorkspace(cwd);
5355
+ if (!dataRoot) return '';
5356
+ return join(dataRoot, '.qqbot-extensions', '.state', 'ext-config', safe + '.json');
5357
+ }
5358
+
5359
+ route(ctx, 'GET', '/api/qqbot-settings/ext-config', async (req, res) => {
5360
+ let q = null;
5361
+ try { q = new URL(req.url ?? '/', 'http://x').searchParams; } catch { q = null; }
5362
+ const id = String(q?.get('id') || '').trim();
5363
+ const scope = String(q?.get('scope') || 'data').trim() === 'profile' ? 'profile' : 'data';
5364
+ const cwd = String(q?.get('cwd') || '').trim();
5365
+ try {
5366
+ const p = extCfgPathOf(scope, cwd, id);
5367
+ if (!p) return writeJson(res, 200, { ok: false, reason: '推不出路径(id 非法或缺 dataRoot)' });
5368
+ let config = {};
5369
+ try { if (existsSync(p)) config = JSON.parse(readFileSync(p, 'utf8')) || {}; } catch { config = {}; }
5370
+ return writeJson(res, 200, { ok: true, id, scope, path: p, config });
5371
+ } catch (e) {
5372
+ return writeJson(res, 200, { ok: false, reason: e instanceof Error ? e.message : String(e) });
5373
+ }
5374
+ });
5375
+
5376
+ route(ctx, 'POST', '/api/qqbot-settings/ext-config', async (req, res) => {
5377
+ const body = await readJsonBody(req);
5378
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { ok: false, reason: 'body 必须是 JSON' });
5379
+ const id = String(body.id || '').trim();
5380
+ const scope = String(body.scope || 'data').trim() === 'profile' ? 'profile' : 'data';
5381
+ const cwd = String(body.cwd || '').trim();
5382
+ const patch = body.patch && typeof body.patch === 'object' ? body.patch : {};
5383
+ try {
5384
+ const p = extCfgPathOf(scope, cwd, id);
5385
+ if (!p) return writeJson(res, 200, { ok: false, reason: '推不出路径(id 非法或缺 dataRoot)' });
5386
+ let cur = {};
5387
+ try { if (existsSync(p)) cur = JSON.parse(readFileSync(p, 'utf8')) || {}; } catch { cur = {}; }
5388
+ // 浅合并(够用;扩展要嵌套就自己整份写回)
5389
+ const next = Object.assign({}, cur, patch);
5390
+ mkdirSync(dirname(p), { recursive: true });
5391
+ // 写前备份一份,出问题能捞回来
5392
+ try { if (existsSync(p)) writeFileSync(p + '.bak-' + Date.now(), readFileSync(p)); } catch { /* ignore */ }
5393
+ writeFileSync(p, JSON.stringify(next, null, 2), 'utf8');
5394
+ return writeJson(res, 200, { ok: true, id, scope, path: p, config: next });
5395
+ } catch (e) {
5396
+ return writeJson(res, 200, { ok: false, reason: e instanceof Error ? e.message : String(e) });
5397
+ }
5398
+ });
5399
+
5400
+ // ══ dock 卡片的数据源(2026-10-26)══════════════════════════════════════════
5401
+ // dock 卡片的独特价值就是「能看到当前实例和会话的真实数据」。这里给四种:
5402
+ // what=context 当前实例 + 会话信息(ns/appId/dataRoot/peer/群名/几条统计)
5403
+ // what=sessions 这个机器人管过哪些会话(本实例)
5404
+ // what=history 当前会话最近 N 条消息(读 .qqbot/msg-history 的 jsonl)
5405
+ // what=members 群友台账(谁说过话、印象分)
5406
+ // 数据范围天然按 dataRoot 隔离 —— 每个机器人实例看到的是自己那份。
5407
+ route(ctx, 'GET', '/api/qqbot-settings/dock-data', async (req, res) => {
5408
+ let q = null;
5409
+ try { q = new URL(req.url ?? '/', 'http://x').searchParams; } catch { q = null; }
5410
+ const what = String(q?.get('what') || 'context').trim();
5411
+ const ns = String(q?.get('ns') || '').trim() || NS;
5412
+ const peerId = String(q?.get('peer') || '').trim();
5413
+ const scope = String(q?.get('scope') || 'group').trim() === 'c2c' ? 'c2c' : 'group';
5414
+ const limit = Math.max(1, Math.min(200, Number(q?.get('limit') || 20) || 20));
5415
+ // ★ 2026-10-27 修(主人实测:切到 im-qqbot-3 读到的还是鲸鱼娘的 dataRoot):
5416
+ // 前端原来只传 ns,没传 dataRoot → 这里 resolveDataRootForWorkspace('') 落到宿主默认。
5417
+ // 现在前端把当前实例的 dataRoot 直接传上来,优先用它。
5418
+ const explicitRoot = String(q?.get('dataRoot') || '').trim();
5419
+ const explicitCwd = String(q?.get('cwd') || '').trim();
5420
+ try {
5421
+ const dataRoot = explicitRoot || (resolveDataRootForWorkspace(explicitCwd) || {}).dataRoot;
5422
+ if (!dataRoot) return writeJson(res, 200, { ok: false, reason: '推不出 dataRoot' });
5423
+
5424
+ if (what === 'context') {
5425
+ const view = viewOf(ctx.settings, ns) || {};
5426
+ const v = view.value || {};
5427
+ // ★ 2026-10-27 修(主人实测数据错):
5428
+ // · ns 传空时原来直接用模块级 NS 默认值(im-qqbot),读到的是**别的实例**的配置,
5429
+ // 于是 appId 显示出 "1111" 这种假值。现在把 ns 原样回显,前端能对照发现没传对。
5430
+ // · cwd 原来取 process.cwd()(宿主进程目录,如 C:\Users\作早饭),那是错的;
5431
+ // 应取**该实例配置里的工作区**,没配就退回数据根。
5432
+ return writeJson(res, 200, {
5433
+ ok: true,
5434
+ context: {
5435
+ ns,
5436
+ appId: String(v.appId || ''),
5437
+ dataRoot,
5438
+ cwd: String(v.cwd || dataRoot || ''),
5439
+ processCwd: process.cwd(),
5440
+ peer: peerId ? { scope, peerId } : null,
5441
+ // 顺手给几个统计,卡片可以直接显示
5442
+ counts: {
5443
+ extensions: (() => { try { return readdirSync(join(dataRoot, '.qqbot-extensions')).length; } catch { return 0; } })(),
5444
+ packs: (() => { try { return readdirSync(join(dataRoot, 'extension-packs')).length; } catch { return 0; } })(),
5445
+ },
5446
+ },
5447
+ });
5448
+ }
5449
+
5450
+ if (what === 'sessions') {
5451
+ let out = [];
5452
+ try {
5453
+ const reg = join(dataRoot, '.qqbot', 'session-registry.json');
5454
+ if (existsSync(reg)) out = JSON.parse(readFileSync(reg, 'utf8')) || [];
5455
+ } catch { out = []; }
5456
+ return writeJson(res, 200, { ok: true, sessions: Array.isArray(out) ? out.slice(0, 100) : [] });
5457
+ }
5458
+
5459
+ if (what === 'history') {
5460
+ if (!peerId) return writeJson(res, 200, { ok: false, reason: '需要 peer 参数', messages: [] });
5461
+ // 消息历史落在 {dataRoot}/.qqbot/msg-history/<scope>/<peerId>/*.jsonl
5462
+ const base = join(dataRoot, '.qqbot', 'msg-history');
5463
+ const dirs = [join(base, scope, peerId), join(base, peerId)];
5464
+ let rows = [];
5465
+ for (const d of dirs) {
5466
+ if (!existsSync(d)) continue;
5467
+ let files = [];
5468
+ try { files = readdirSync(d).filter((f) => f.endsWith('.jsonl')).sort(); } catch { continue; }
5469
+ for (const f of files.slice(-3)) { // 最近 3 天的文件足够拼出 limit 条
5470
+ try {
5471
+ const txt = readFileSync(join(d, f), 'utf8');
5472
+ for (const line of txt.split('\n')) {
5473
+ if (!line.trim()) continue;
5474
+ try { rows.push(JSON.parse(line)); } catch { /* 坏行跳过 */ }
5475
+ }
5476
+ } catch { /* ignore */ }
5477
+ }
5478
+ if (rows.length) break;
5479
+ }
5480
+ rows = rows.slice(-limit);
5481
+ return writeJson(res, 200, { ok: true, messages: rows });
5482
+ }
5483
+
5484
+ if (what === 'members') {
5485
+ // 群友台账(people-memo 落的那份)
5486
+ let out = {};
5487
+ try {
5488
+ for (const p of [join(dataRoot, '.qqbot', 'people-memo.json'), join(dataRoot, '.qqbot-extensions', '.state', 'people-memo.json')]) {
5489
+ if (existsSync(p)) { out = JSON.parse(readFileSync(p, 'utf8')) || {}; break; }
5490
+ }
5491
+ } catch { out = {}; }
5492
+ return writeJson(res, 200, { ok: true, members: out });
5493
+ }
5494
+
5495
+ return writeJson(res, 200, { ok: false, reason: '不认识的 what: ' + what });
5496
+ } catch (e) {
5497
+ return writeJson(res, 200, { ok: false, reason: e instanceof Error ? e.message : String(e) });
5498
+ }
5499
+ });
5500
+
5501
+ // ══ 扩展自有路由的出口(2026-10-26)══════════════════════════════════════
5502
+ // 为什么要有:扩展想用自己的存储(SQLite / 自有 json / 任意目录),就得有办法把数据
5503
+ // 通过 HTTP 交给自己的 UI 卡片。以前没出口,只能写 patch 改这个文件。
5504
+ //
5505
+ // 约定:
5506
+ // 扩展在 hooks/tools 里调 env.registerRoute('GET', '/rows', handler) 注册;
5507
+ // 卡片里写 qqbotExt.api('/rows'),前端请求:
5508
+ // /api/ext/dispatch?id=<extId>&p=/rows
5509
+ // 本路由查 globalThis.__qqbotExtRoutes 派发。
5510
+ //
5511
+ // handler 签名:async ({ req, path, query, body }) => 任意 JSON 可序列化值
5512
+ // 返回 {ok:false,...} 原样透传;其他值包成 {ok:true, data}
5513
+ // ══ 扩展 UI 卡片的「能力调用」通道(2026-10-27 主人要求)════════════════════
5514
+ // 目标:让自定义 UI 卡片能用上**和其余扩展(tools/commands/hooks)同一套能力**
5515
+ // (store / api / markdown / kernel / owners / log …),以及 DSH 宿主的全能力。
5516
+ //
5517
+ // 为什么不能在浏览器直接用:卡片跑在浏览器里,能力都在服务端。
5518
+ // 所以走 RPC:卡片 → /api/ext/dispatch?p=/__cap&fn=<名字> → 这里执行 → 返回 JSON。
5519
+ //
5520
+ // 安全:**只允许白名单里的功能**,绝不把整个 caps 对象丢给前端(里面有 sender、
5521
+ // kernel 的子对象等,序列化会炸、语义也不对)。
5522
+ const EXT_CAP_ALLOW = {
5523
+ // store:数据根下的 KV(扩展自己的持久化)
5524
+ 'store.load': (c) => (c.store && typeof c.store.load === 'function' ? c.store.load() : null),
5525
+ 'store.save': (c, a) => (c.store && typeof c.store.save === 'function' ? c.store.save(a[0]) : false),
5526
+ // 元信息
5527
+ 'appId': (c) => c.appId || '',
5528
+ 'owners': (c) => (Array.isArray(c.owners) ? c.owners : []),
5529
+ // 宿主能力(DSH cordis Context):只暴露「读服务」和「注入」两件事
5530
+ 'kernel.get': (c, a) => {
5531
+ const k = c.kernel;
5532
+ if (!k || typeof k.get !== 'function') return { ok: false, reason: '宿主不支持 kernel.get' };
5533
+ try {
5534
+ const v = k.get(String(a[0]));
5535
+ // 只回能序列化的东西;函数/复杂对象回描述
5536
+ if (v === undefined || v === null) return null;
5537
+ try { return JSON.parse(JSON.stringify(v)); } catch { return { __type: typeof v, __note: '不可序列化' }; }
5538
+ } catch (e) {
5539
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
5540
+ }
5541
+ },
5542
+ 'kernel.has': (c, a) => {
5543
+ const k = c.kernel;
5544
+ if (!k || typeof k.get !== 'function') return false;
5545
+ try { return k.get(String(a[0])) !== undefined; } catch { return false; }
5546
+ },
5547
+ // 日志(进宿主控制台,便于扩展自诊断)
5548
+ 'log': (c, a) => {
5549
+ try { (c.log || console.log)('[ext-ui] ' + String(a[0])); return true; } catch { return false; }
5550
+ },
5551
+ // 往当前会话发消息(需要请求里带 peer)
5552
+ 'text': async (c, a) => (typeof c.text === 'function' ? await c.text(String(a[0])) : false),
5553
+ // ★ 任意文本文件读写(2026-10-27 主人要求:卡片能选电脑上任意 txt 展示并修改)
5554
+ // 只放行文本类扩展名,避免被当成二进制搬运工。
5555
+ 'fs.read': async (_c, a) => {
5556
+ const p = String(a[0] || '').trim();
5557
+ if (!p) return { ok: false, reason: '缺路径' };
5558
+ if (!/\.(txt|md|json|log|yml|yaml|csv|ini|conf|js|ts|mjs|cjs|html|css)$/i.test(p)) {
5559
+ return { ok: false, reason: '只允许文本文件(txt/md/json/log/yml/csv/ini/js/ts/html/css)' };
5560
+ }
5561
+ try {
5562
+ if (!existsSync(p)) return { ok: false, reason: '文件不存在' };
5563
+ const st = statSync(p);
5564
+ if (!st.isFile()) return { ok: false, reason: '不是文件' };
5565
+ if (st.size > 2 * 1024 * 1024) return { ok: false, reason: '文件太大(>2MB)' };
5566
+ return { ok: true, path: p, text: readFileSync(p, 'utf8'), size: st.size, mtime: st.mtimeMs };
5567
+ } catch (e) {
5568
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
5569
+ }
5570
+ },
5571
+ 'fs.write': async (_c, a) => {
5572
+ const p = String(a[0] || '').trim();
5573
+ const txt = String(a[1] == null ? '' : a[1]);
5574
+ if (!p) return { ok: false, reason: '缺路径' };
5575
+ if (!/\.(txt|md|json|log|yml|yaml|csv|ini|conf|js|ts|mjs|cjs|html|css)$/i.test(p)) {
5576
+ return { ok: false, reason: '只允许文本文件' };
5577
+ }
5578
+ try {
5579
+ // 写前备份一份(原地 .bak-时间戳),出问题能捞回来
5580
+ if (existsSync(p)) {
5581
+ try { writeFileSync(p + '.bak-' + Date.now(), readFileSync(p)); } catch { /* ignore */ }
5582
+ } else {
5583
+ mkdirSync(dirname(p), { recursive: true });
5584
+ }
5585
+ writeFileSync(p, txt, 'utf8');
5586
+ return { ok: true, path: p, size: Buffer.byteLength(txt, 'utf8') };
5587
+ } catch (e) {
5588
+ return { ok: false, reason: e instanceof Error ? e.message : String(e) };
5589
+ }
5590
+ },
5591
+ 'markdown': async (c, a) => (typeof c.markdown === 'function' ? await c.markdown(String(a[0])) : false),
5592
+ // 静默注入 / 唤醒(需要 agent,UI 侧一般不可用,可用性探测见 __caps)
5593
+ 'appendSilent': async (c, a) => {
5594
+ if (typeof c.appendSilent !== 'function') return { ok: false, reason: '能力包没有 appendSilent' };
5595
+ const r = await c.appendSilent(String(a[0]));
5596
+ if (r === true) return true;
5597
+ // ★ 2026-10-27:失败不再只回 false —— 把"为什么失败"带出去,否则没法排查。
5598
+ const d = c.__diag || {};
5599
+ return {
5600
+ ok: false,
5601
+ reason: !d.hasAgent
5602
+ ? '拿不到当前会话的 agent(manager 里 findByPeer 没命中)'
5603
+ : 'appendSilent 被拒(可能该会话人设还没落盘,见 canSilentlyAppend 的注释)',
5604
+ diag: d,
5605
+ };
5606
+ },
5607
+ };
5608
+
5609
+ route(ctx, ['GET', 'POST', 'PATCH', 'DELETE'], '/api/ext/dispatch', async (req, res) => {
5610
+ let q = null;
5611
+ try { q = new URL(req.url ?? '/', 'http://x').searchParams; } catch { q = null; }
5612
+ const id = extCfgSafeId(String(q?.get('id') || ''));
5613
+ const sub = String(q?.get('p') || '/').trim() || '/';
5614
+ if (!id) return writeJson(res, 400, { ok: false, reason: '缺 id' });
5615
+
5616
+ // ★ 能力调用通道(2026-10-27):p=/__cap&fn=<名字>&args=<JSON数组>
5617
+ // 给 UI 卡片用,能力和其余扩展对齐(store / kernel / log / text …)。
5618
+ if (sub === '/__cap') {
5619
+ // ⚠️ 2026-10-27 修:这个分支是独立作用域,`ns` 必须自己算 ——
5620
+ // 原来直接用了 dock-data 路由里的局部变量 ns,运行时报 "ns is not defined",
5621
+ // 导致 store/kernel/fs/appendSilent 全部返回 {ok:false}。
5622
+ const capNs = String(q?.get('ns') || '').trim() || NS;
5623
+ const fn = String(q?.get('fn') || '').trim();
5624
+ let args = [];
5625
+ try { args = JSON.parse(String(q?.get('args') || '[]')); } catch { args = []; }
5626
+ if (!Array.isArray(args)) args = [args];
5627
+ const impl = EXT_CAP_ALLOW[fn];
5628
+ if (typeof impl !== 'function') {
5629
+ return writeJson(res, 200, { ok: false, reason: '不在能力白名单里: ' + fn, allow: Object.keys(EXT_CAP_ALLOW) });
5630
+ }
5631
+ try {
5632
+ // 按请求的数据根构造一份能力包(UI 卡片没有"加载时机",只能即时构造)
5633
+ const { dataRoot } = resolveDataRootForWorkspace(String(q?.get('cwd') || '').trim());
5634
+ const explicit = String(q?.get('dataRoot') || '').trim();
5635
+ const dr = explicit || dataRoot || '';
5636
+ // ── 按 peer 找当前会话的 agent(appendSilent 等需要)──
5637
+ // ★ 2026-10-27:改用插件现成的 findManagerByPeer(),别自己造轮子。
5638
+ // 它本身带三层兜底(活跃会话表 → 群注册表归属 → 任意已注册),
5639
+ // 比我原来在 globalThis.__qqbotManagers 上遍历要准得多。
5640
+ const __capSid = String(q?.get('sid') || '').trim();
5641
+ const __capPeer = String(q?.get('peer') || '').trim();
5642
+ const __capScope = String(q?.get('scope') || 'group').trim() === 'c2c' ? 'c2c' : 'group';
5643
+ let __agentForCap = undefined;
5644
+ const __diagFind = { peer: __capPeer, scope: __capScope, sid: __capSid, found: false, hasRec: false, err: '' };
5645
+ try {
5646
+ // ★★ 2026-10-27(主人点明):**只要当前会话 id 就够了** —— 最短最准的一条路。
5647
+ // 宿主自己就是按 sessionId 索引 agent 的,findManagerBySessionId 一步定位。
5648
+ // 不用猜 scope/peer、不用查群注册表、不用建会话。
5649
+ if (__capSid) {
5650
+ const reg0 = await import('./dist/features/session-registry.js');
5651
+ const mgrS = typeof reg0.findManagerBySessionId === 'function'
5652
+ ? reg0.findManagerBySessionId(__capSid)
5653
+ : undefined;
5654
+ __diagFind.sidFound = !!mgrS;
5655
+ if (mgrS) {
5656
+ const recS = typeof mgrS.findBySessionId === 'function' ? mgrS.findBySessionId(__capSid) : undefined;
5657
+ __diagFind.sidHasRec = !!recS;
5658
+ if (recS && recS.agent) { __agentForCap = recS.agent; __diagFind.via = 'sid'; }
5659
+ // 宿主全局 agent 表兜底(web / hub 这类非 QQ 会话也在这里)
5660
+ if (!__agentForCap && typeof mgrS.findHostAgent === 'function') {
5661
+ const ha = mgrS.findHostAgent(__capSid);
5662
+ if (ha && ha.agent) { __agentForCap = ha.agent; __diagFind.via = 'sid-hostAgent'; }
5663
+ }
5664
+ }
5665
+ }
5666
+ if (!__agentForCap && __capPeer) {
5667
+ // ⚠️ 不能用 '@zaofan/dsh-qqbot/session-registry' —— package.json 的 exports
5668
+ // 里没暴露这个子路径(只有 channel-tools / sticker-store / schedule-store / client)。
5669
+ // settings-host.js 就在包根,直接用相对路径最稳。
5670
+ const reg = await import('./dist/features/session-registry.js');
5671
+ const mgr = typeof reg.findManagerByPeer === 'function'
5672
+ ? reg.findManagerByPeer(__capScope, __capPeer)
5673
+ : undefined;
5674
+ __diagFind.found = !!mgr;
5675
+ if (mgr && typeof mgr.findByPeer === 'function') {
5676
+ let rec = mgr.findByPeer(__capScope, __capPeer);
5677
+ __diagFind.hasRec = !!rec;
5678
+ if (rec && rec.agent) __agentForCap = rec.agent;
5679
+ // ★ 2026-10-27(主人指出):不该要求"必须是活跃会话" ——
5680
+ // 这是面板行为,凭什么非得先在 QQ 里发条消息把会话跑起来?
5681
+ // 直接用插件早就有的 getOrCreateByPeerWeb()(与入群申请通知/定时任务
5682
+ // 同一条路:活跃表 miss 就恢复/重建会话,**不开回合**)。
5683
+ if (!__agentForCap && typeof reg.getOrCreateByPeerWeb === 'function') {
5684
+ try {
5685
+ const w = await reg.getOrCreateByPeerWeb(capNs, __capScope, __capPeer, 'master');
5686
+ if (w && w.agent) {
5687
+ __agentForCap = w.agent;
5688
+ __diagFind.created = true;
5689
+ } else {
5690
+ __diagFind.createErr = 'getOrCreateByPeerWeb 没返回 agent';
5691
+ }
5692
+ } catch (e2) {
5693
+ __diagFind.createErr = e2 instanceof Error ? e2.message : String(e2);
5694
+ }
5695
+ }
5696
+ }
5697
+ }
5698
+ } catch (e) {
5699
+ __diagFind.err = e instanceof Error ? e.message : String(e);
5700
+ }
5701
+ const capsMod = await import('./dist/features/ext-capabilities.js');
5702
+ const caps = capsMod.makeExtCaps({
5703
+ dataRoot: dr,
5704
+ logger: console,
5705
+ selfName: id,
5706
+ kind: 'ui',
5707
+ appId: String(viewOf(ctx.settings, capNs)?.value?.appId || ''),
5708
+ owners: (() => {
5709
+ try {
5710
+ const v = viewOf(ctx.settings, capNs)?.value || {};
5711
+ const g = v.groupAdmin || {};
5712
+ return Array.isArray(g.owners) ? g.owners.map(String) : [];
5713
+ } catch { return []; }
5714
+ })(),
5715
+ scope: String(q?.get('scope') || 'group').trim() === 'c2c' ? 'c2c' : 'group',
5716
+ peerId: String(q?.get('peer') || '').trim(),
5717
+ // ★ 2026-10-27:按 peer 找当前会话的 agent —— appendSilent/appendWake 靠它
5718
+ agent: __agentForCap,
5719
+ });
5720
+ // 诊断信息挂到 caps 上,白名单里的实现可以读它来解释"为什么失败"
5721
+ try {
5722
+ const gm = globalThis.__qqbotManagers;
5723
+ caps.__diag = {
5724
+ managers: gm && typeof gm.keys === 'function' ? Array.from(gm.keys()) : null,
5725
+ managersSize: gm && typeof gm.size === 'number' ? gm.size : -1,
5726
+ dr,
5727
+ peer: String(q?.get('peer') || ''),
5728
+ scope: String(q?.get('scope') || 'group'),
5729
+ hasAgent: !!__agentForCap,
5730
+ find: __diagFind,
5731
+ peer: __capPeer,
5732
+ scope: __capScope,
5733
+ };
5734
+ } catch { /* ignore */ }
5735
+ const out = await impl(caps, args);
5736
+ return writeJson(res, 200, { ok: true, data: out });
5737
+ } catch (e) {
5738
+ return writeJson(res, 200, { ok: false, reason: e instanceof Error ? e.message : String(e) });
5739
+ }
5740
+ }
5741
+
5742
+ const table = globalThis.__qqbotExtRoutes;
5743
+ if (!table || typeof table.get !== 'function') {
5744
+ return writeJson(res, 200, { ok: false, reason: '还没有任何扩展注册路由' });
5745
+ }
5746
+ const method = String(req.method || 'GET').toUpperCase();
5747
+ const h = table.get(id + '|' + method + '|' + sub) || table.get(id + '|*|' + sub);
5748
+ if (typeof h !== 'function') {
5749
+ return writeJson(res, 200, { ok: false, reason: '扩展没注册这个路由: ' + sub + ' (' + method + ')' });
5750
+ }
5751
+ try {
5752
+ const body = method === 'GET' ? undefined : await readJsonBody(req);
5753
+ const out = await h({ req, path: sub, query: Object.fromEntries(q || []), body });
5754
+ if (out && typeof out === 'object' && 'ok' in out) return writeJson(res, 200, out);
5755
+ return writeJson(res, 200, { ok: true, data: out });
5756
+ } catch (e) {
5757
+ return writeJson(res, 200, { ok: false, reason: e instanceof Error ? e.message : String(e) });
5758
+ }
5759
+ });
5760
+
5180
5761
  /** 扩展配置文件路径 */
5181
5762
  function extConfigPath(dataRoot, extId) {
5182
5763
  const safe = String(extId || '').replace(/[^a-zA-Z0-9_\-\u4e00-\u9fa5]/g, '_');
@@ -5593,10 +6174,10 @@ const WHY_MAP = { busy: '目标会话回合活跃,已等待至回合结束仍超
5593
6174
  const errs = [];
5594
6175
  for (const url of MARKET_SOURCES) {
5595
6176
  try {
5596
- // 2026-10-25 修:jsDelivr 对 @main 有 CDN 缓存(实测删了目录它还返回旧的 4 个包)。
5597
- // 前三个源拿到的就是最新内容,但都超时时会走到 jsdelivr 拿到陈旧索引 ——
5598
- // 所以每个源都拼一个 __cb 时间戳:GitHub raw 忽略它,jsDelivr 当新 URL 绕开缓存。
5599
- const bust = url + (url.indexOf("?") >= 0 ? "&" : "?") + "__cb=" + Date.now();
6177
+ // 2026-10-25 修:jsDelivr 对 @main 有 CDN 缓存(实测删了目录它还返回旧的 4 个包)。
6178
+ // 前三个源拿到的就是最新内容,但都超时时会走到 jsdelivr 拿到陈旧索引 ——
6179
+ // 所以每个源都拼一个 __cb 时间戳:GitHub raw 忽略它,jsDelivr 当新 URL 绕开缓存。
6180
+ const bust = url + (url.indexOf("?") >= 0 ? "&" : "?") + "__cb=" + Date.now();
5600
6181
  const txt = await fetchText(bust, 8000);
5601
6182
  const j = JSON.parse(txt);
5602
6183
  if (!Array.isArray(j.plugins)) j.plugins = [];