@zaofan/dsh-qqbot 1.10.31 → 1.10.33

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 (2) hide show
  1. package/package.json +1 -1
  2. package/settings-host.js +1050 -975
package/settings-host.js CHANGED
@@ -259,20 +259,95 @@ const OWN_SETTINGS_DIR = join(DSH_HOME_DIR, 'qqbot-settings');
259
259
  * (要求其父目录名为 `profiles`,挡住源码 checkout 目录)→ 落 `_default`。改一处务必同步另一处。
260
260
  */
261
261
  function resolveProfileName() {
262
+ // ① 环境变量最可靠 —— 但 Desktop 宿主下**模块加载那一刻它是空的**
263
+ // (宿主只在每次 shell / 工具调用时临时注入),所以下面几层兜底是必须的。
262
264
  const env = String(process.env.DSH_PROFILE || '').trim();
263
265
  if (env) return env;
266
+
267
+ // ② PROFILE_ROOT 的父目录正好叫 profiles → 取它自己的目录名
264
268
  try {
265
- return basename(dirname(PROFILE_ROOT)) === 'profiles' ? basename(PROFILE_ROOT) : '';
266
- } catch {
267
- return '';
268
- }
269
+ if (basename(dirname(PROFILE_ROOT)) === 'profiles') {
270
+ const n = basename(PROFILE_ROOT);
271
+ if (n && n !== 'profiles') return n;
272
+ }
273
+ } catch { /* 走下一层 */ }
274
+
275
+ // ③ PROFILE_ROOT 里出现 /profiles/<name>/ 片段 → 取 <name>
276
+ // (生成式安装会落到 profiles/.generations/live/xxx,② 那层判不出来)
277
+ try {
278
+ const m = String(PROFILE_ROOT).split(/[\\/]profiles[\\/]/i);
279
+ if (m.length > 1) {
280
+ const seg = m[1].split(/[\\/]/)[0];
281
+ if (seg && seg !== '.generations') return seg;
282
+ }
283
+ } catch { /* 走下一层 */ }
284
+
285
+ // ④ 【2026-10-25 修 issue #20】最硬的一层:直接看存档目录的实际形状。
286
+ // 凭据就存在 qqbot-settings/<profile>/<ns>.json ——
287
+ // 如果那里只有一个真实子目录,那它就是当前 profile,猜都不用猜。
288
+ //
289
+ // 原实现(环境变量 → PROFILE_ROOT 目录名)在 Desktop 宿主下三层全空,
290
+ // 落成 `_default` → readOwnSettings 读到 {} → nsBot() 拿不到 appId/appSecret
291
+ // 直接 return null → 评分/群管理/图库面板全空白,而 bot 收发消息完全正常,
292
+ // 极易被误判成"评分没跑起来"。issue #20 就是这个。
293
+ // ⚠️ 只在「恰好只有一个真实 profile 目录」时才认 —— **不猜**。
294
+ // 一台机器上可能同时有 desktop/ 和 web/(实测本机就有这两个),
295
+ // 按 prefer 猜会让 web 用户读到 desktop 的凭据,比原来更糟。
296
+ // 而那个场景下第 1 层环境变量本来就有值,根本轮不到这里。
297
+ try {
298
+ const subs = readdirSync(OWN_SETTINGS_DIR, { withFileTypes: true })
299
+ .filter((d) => d.isDirectory() && d.name !== '_default')
300
+ .map((d) => d.name);
301
+ if (subs.length === 1) return subs[0];
302
+ } catch { /* 目录还不存在(首次启动) */ }
303
+
304
+ return '';
305
+ }
306
+ /**
307
+ * 当前 profile 对应的存档子目录名。
308
+ *
309
+ * ⚠️ 2026-10-25 修 issue #20:原来是个**模块顶层求值的 const** ——
310
+ * Desktop 宿主在模块加载那一刻还没往环境里放 DSH_PROFILE,
311
+ * 算出来就是 `_default`,之后永不重算 ⇒ 读 qqbot-settings/_default/xxx.json
312
+ * (真实存档在 qqbot-settings/desktop/xxx.json)⇒ 凭据读不到 ⇒ nsBot() null。
313
+ * 改成每次调用时现算(开销是几次字符串/目录读,API 请求级,可忽略)。
314
+ */
315
+ function nsProfileDir(ns) {
316
+ const n = String(resolveProfileName()).trim().replace(/[^A-Za-z0-9_-]/g, '_');
317
+ if (n) return n;
318
+
319
+ // ── 目录级兜底(只在上面几层全空时走到这儿)──
320
+ // 2026-10-25 修 issue #20 + 主人追问「同时装了 desktop 和 web 怎么办」:
321
+ // 一台机器上 qqbot-settings/ 可能同时有 web/ 和 desktop/,
322
+ // 这时**不能猜**(猜错 = 读到别人的凭据),要用事实来判断。
323
+ try {
324
+ const subs = readdirSync(OWN_SETTINGS_DIR, { withFileTypes: true })
325
+ .filter((d) => d.isDirectory() && d.name !== '_default')
326
+ .map((d) => d.name);
327
+
328
+ // ① 只有一个 profile 目录 → 就是它
329
+ if (subs.length === 1) return subs[0];
330
+
331
+ // ② 多个目录 → 看哪个目录里**真的存着这个 ns 的配置**
332
+ // (凭据就在 <profile>/<ns>.json,谁有谁就是当前 profile)
333
+ if (ns) {
334
+ const f = safeNsOf(ns) + '.json';
335
+ const hits = subs.filter((name) => {
336
+ try { return existsSync(join(OWN_SETTINGS_DIR, name, f)); } catch { return false; }
337
+ });
338
+ if (hits.length === 1) return hits[0];
339
+ // 两个 profile 都配过同一个 ns → 不猜,走 _default。
340
+ // 这种「共用一个 settingsNs」的情况本来就会被 writer.json 的 NS_CONFLICT 拦住。
341
+ }
342
+ } catch { /* 目录还不存在(首次启动) */ }
343
+
344
+ return '_default';
269
345
  }
270
- const NS_PROFILE_DIR = String(resolveProfileName()).trim().replace(/[^A-Za-z0-9_-]/g, '_') || '_default';
271
346
  function safeNsOf(ns) {
272
347
  return String(ns || NS).replace(/[^A-Za-z0-9_-]/g, '_') || 'im-qqbot';
273
348
  }
274
349
  function ownSettingsDir(ns) {
275
- return join(OWN_SETTINGS_DIR, NS_PROFILE_DIR);
350
+ return join(OWN_SETTINGS_DIR, nsProfileDir(ns));
276
351
  }
277
352
  function ownSettingsPath(ns) {
278
353
  return join(ownSettingsDir(ns), safeNsOf(ns) + '.json');
@@ -1273,13 +1348,13 @@ export function apply(ctx) {
1273
1348
  if (segs.length === 0) return '';
1274
1349
  const decoded = segs.map((s) => s.replace(/~([0-9A-F]{4})/g, (_m, hex) => String.fromCharCode(parseInt(hex, 16))));
1275
1350
  // 首段是盘符(D / C) → 补冒号; 其余段拼回路径分隔
1276
- // ① Windows 盘符:首段是单个字母 → X:\a\b
1277
- if (/^[A-Za-z]$/.test(decoded[0])) {
1278
- const sep = '\\';
1279
- return decoded[0] + ':' + (decoded.length > 1 ? sep + decoded.slice(1).join(sep) : sep);
1280
- }
1281
- // ② POSIX(Linux/Android):编码时丢了前导 '/',这里补回来
1282
- return '/' + decoded.join('/');
1351
+ // ① Windows 盘符:首段是单个字母 → X:\a\b
1352
+ if (/^[A-Za-z]$/.test(decoded[0])) {
1353
+ const sep = '\\';
1354
+ return decoded[0] + ':' + (decoded.length > 1 ? sep + decoded.slice(1).join(sep) : sep);
1355
+ }
1356
+ // ② POSIX(Linux/Android):编码时丢了前导 '/',这里补回来
1357
+ return '/' + decoded.join('/');
1283
1358
  } catch { return ''; }
1284
1359
  }
1285
1360
  route(ctx, 'GET', '/api/qqbot-settings/workspaces', async (_req, res) => {
@@ -4850,968 +4925,968 @@ const WHY_MAP = { busy: '目标会话回合活跃,已等待至回合结束仍超
4850
4925
  } catch (e) { writeJson(res, 500, { error: String((e && e.message) || e) }); }
4851
4926
  });
4852
4927
 
4853
- // ═══════════════════════════════════════════════════════════════════════════
4854
- // 「QQ机器人扩展」面板数据源(2026-10-24 主人定)
4855
- //
4856
- // 两类扩展来源:
4857
- // ① 内置目录 {dataRoot}/.qqbot-extensions/{tools,commands,botplay,hooks,inbound,outbound}/
4858
- // ② 扩展包 {dataRoot}/extension-packs/{包名}/{同样六个子目录}/
4859
- // 包名目录里可放 qqbot-pack.json:{ name, author, desc, version, homepage }
4860
- // 没有清单也能用,只是面板里显示为「未命名扩展包」
4861
- //
4862
- // 为什么要扩展包:插件作者的扩展不该被迫塞进 .qqbot-extensions(那是用户自己的目录),
4863
- // 放独立目录既能整包分发/更新,也能在面板里按作者分组显示。
4864
- // ═══════════════════════════════════════════════════════════════════════════
4865
- const EXT_KINDS = ['tools', 'commands', 'botplay', 'hooks', 'inbound', 'outbound'];
4866
-
4867
- /** 扫一个 kind 目录,返回文件清单(只扫根下直接子文件,与宿主扫描口径一致) */
4868
- function scanExtKind(dir, dataRoot) {
4869
- const out = [];
4870
- let names = [];
4871
- try { names = readdirSync(dir); } catch { return out; }
4872
- for (const name of names) {
4873
- if (!/\.(mjs|js|cjs)$/i.test(name)) continue;
4874
- const full = join(dir, name);
4875
- let st = null;
4876
- try { st = statSync(full); } catch { continue; }
4877
- if (!st.isFile()) continue;
4878
- // 读文件头,看有没有声明「设置页」(约定:文件里出现 qqbotSettingsPage 或 export const settingsPage)
4879
- let hasSettings = false;
4880
- let title = '';
4881
- try {
4882
- const head = readFileSync(full, 'utf8').slice(0, 4096);
4883
- hasSettings = /qqbotSettingsPage|export\s+const\s+settingsPage/.test(head);
4884
- const m = /qqbotSettingsPage[\s\S]{0,200}?title\s*:\s*['"]([^'"]+)['"]/.exec(head);
4885
- if (m) title = m[1];
4886
- } catch { /* 读不了就当没有 */ }
4887
- out.push({
4888
- __abs: full, // 真实绝对路径,供 extensions 接口标 disabled
4889
- name,
4890
- id: name.replace(/\.(mjs|js|cjs)$/i, ''),
4891
- size: st.size,
4892
- mtime: st.mtimeMs,
4893
- hasSettings,
4894
- settingsTitle: title,
4895
- disabled: false, // 启停状态在 extensions 路由里统一补(scanExtKind 是顶层函数,拿不到闭包)
4896
- });
4897
- }
4898
- out.sort((a, b) => a.name.localeCompare(b.name));
4899
- return out;
4900
- }
4901
-
4902
- /**
4903
- * 把工作区路径归一成**绝对路径**(与 src/util/abs-path.ts 的 absCwdOf 同一套规则)。
4904
- * 绝对路径原样返回,只给相对路径补前导 `/`。
4905
- */
4906
- function absCwd(p) {
4907
- const raw = String(p == null ? '' : p).trim();
4908
- if (!raw) return '';
4909
- if (/^[A-Za-z]:[\\/]/.test(raw)) return raw;
4910
- if (raw.startsWith('\\\\') || raw.startsWith('//')) return raw;
4911
- if (raw.startsWith('/')) return raw;
4912
- const s = raw.replace(/\\/g, '/');
4913
- if (process.platform !== 'win32') return '/' + s;
4914
- return raw;
4915
- }
4916
-
4917
- /** 扫一个扩展根(含六个 kind 子目录),返回 { kinds:{tools:[...]}, total } */
4918
- function scanExtRoot(root, dataRoot) {
4919
- const kinds = {};
4920
- let total = 0;
4921
- for (const k of EXT_KINDS) {
4922
- const list = scanExtKind(join(root, k));
4923
- kinds[k] = list;
4924
- total += list.length;
4925
- }
4926
- return { root, kinds, total };
4927
- }
4928
-
4929
- /**
4930
- * 某个工作区真实对应的 dataRoot(2026-10-24 补:尊重 bot 配置里的 dataRoot)
4931
- *
4932
- * 优先级(主人指出必须先看配置):
4933
- * ① 该工作区 bot 显式配置的 cfg.dataRoot
4934
- * ② 该工作区 bot 配置的 cfg.cwd + 'dshqqbot'
4935
- * ③ 传入的 cwd + 'dshqqbot'
4936
- * ④ 首个未禁用 bot 的 dataRoot / cwd(= 老的 resolveContextlessDataRoot 行为)
4937
- *
4938
- * 返回 { dataRoot, source },source 说明是哪一级命中的,便于面板上排查。
4939
- */
4940
- function resolveDataRootForWorkspace(cwdHint) {
4941
- const hint = String(cwdHint || '').trim();
4942
- const norm = (p) => String(p || '').replace(/[\\/]+$/, '').toLowerCase();
4943
- let bots = [];
4944
- try { const parsed = parsePatch(); bots = (parsed && Array.isArray(parsed.bots)) ? parsed.bots : []; } catch { bots = []; }
4945
-
4946
- // ①/② 找「属于这个工作区」的 bot(先按 cwd 精确匹配)
4947
- if (hint) {
4948
- const target = norm(hint);
4949
- for (const b of bots) {
4950
- const cfg = (b && b.cfg) || {};
4951
- const bcwd = norm(cfg.cwd);
4952
- if (!bcwd || bcwd !== target) continue;
4953
- if (cfg.dataRoot) return { dataRoot: String(cfg.dataRoot), source: 'bot.dataRoot' };
4954
- return { dataRoot: join(absCwd(cfg.cwd) || String(cfg.cwd), 'dshqqbot'), source: 'bot.cwd' };
4955
- }
4956
- }
4957
-
4958
- // ③ 传入的 cwd(该工作区没绑 bot 时,扩展目录仍在它自己的 dshqqbot 下)
4959
- if (hint) return { dataRoot: join(absCwd(hint) || hint, 'dshqqbot'), source: 'workspace' };
4960
-
4961
- // ④ 兜底:老的推断
4962
- for (const b of bots) {
4963
- const cfg = (b && b.cfg) || {};
4964
- if (b && b.disabled) continue;
4965
- if (cfg.dataRoot) return { dataRoot: String(cfg.dataRoot), source: 'default.bot' };
4966
- if (cfg.cwd) return { dataRoot: join(String(cfg.cwd), 'dshqqbot'), source: 'default.bot' };
4967
- }
4968
- return { dataRoot: resolveContextlessDataRoot(), source: 'default' };
4969
- }
4970
-
4971
- // ═══════════════════════════════════════════════════════════════════════════
4972
- // 扩展自定义设置面板(2026-10-24)
4973
- //
4974
- // 契约:扩展文件里 export 一个 qqbotSettingsPage:
4975
- // export const qqbotSettingsPage = {
4976
- // title: '我的设置',
4977
- // schema: [
4978
- // { key: 'enabled', type: 'bool', label: '启用' },
4979
- // { key: 'dirs', type: 'list', label: '目录列表' },
4980
- // { key: 'apiKey', type: 'string', label: 'API Key', secret: true },
4981
- // { key: 'mode', type: 'select', label: '模式', options: ['a','b'] },
4982
- // ],
4983
- // }
4984
- //
4985
- // 配置落盘:{dataRoot}/.qqbot-extensions/.state/ext-config/{扩展id}.json
4986
- // 扩展自己在代码里可用 readExtConfig 的方式拿到(暂未内置 API,先读那个 json 即可)。
4987
- // ═══════════════════════════════════════════════════════════════════════════
4988
-
4989
- /** 扩展配置文件路径 */
4990
- function extConfigPath(dataRoot, extId) {
4991
- const safe = String(extId || '').replace(/[^a-zA-Z0-9_\-\u4e00-\u9fa5]/g, '_');
4992
- if (!safe) return '';
4993
- return join(dataRoot, '.qqbot-extensions', '.state', 'ext-config', safe + '.json');
4994
- }
4995
-
4996
- /** 读某个扩展的 qqbotSettingsPage 声明(动态 import 扩展文件,失败返回 null) */
4997
- /**
4998
- * 从扩展**源码**里解析 qqbotSettingsPage(2026-10-24,绕开宿主里 import 拿不到导出的问题)
4999
- *
5000
- * 为什么不 import:
5001
- * 同一个 file:// URL 在 Node CLI 里 import 能拿到导出,但在 dsh 宿主进程里
5002
- * (插件模块内部再 await import 另一个 .mjs)拿不到 —— 机制不明,实测踩到。
5003
- * 而 qqbotSettingsPage 只是个纯数据对象 { title, desc, schema:[...] },
5004
- * 完全没必要执行整个模块;直接抠源码更稳,也顺带避免执行第三方代码。
5005
- *
5006
- * 做法:定位 "export const qqbotSettingsPage = {" 然后做**括号配对**截取对象字面量,
5007
- * 再用 new Function 求值(只执行这个字面量,不跑模块其余部分)。
5008
- * 求值失败一律返回 null,绝不抛。
5009
- */
5010
- function parseSettingsPageFromSource(absFile) {
5011
- let src = '';
5012
- try { src = readFileSync(absFile, 'utf8'); } catch { return null; }
5013
-
5014
- // 找起点的几种写法:export const / const + 后续 export
5015
- const patterns = [
5016
- /export\s+(?:const|let|var)\s+qqbotSettingsPage\s*=\s*/,
5017
- /(?:const|let|var)\s+qqbotSettingsPage\s*=\s*/,
5018
- ];
5019
- let start = -1;
5020
- for (const re of patterns) {
5021
- const m = re.exec(src);
5022
- if (m) { start = m.index + m[0].length; break; }
5023
- }
5024
- if (start < 0) return null;
5025
- if (src[start] !== '{') return null;
5026
-
5027
- // 括号配对(跳过字符串/模板/注释里的括号)
5028
- let depth = 0;
5029
- let i = start;
5030
- let inStr = '';
5031
- let inLineComment = false;
5032
- let inBlockComment = false;
5033
- let end = -1;
5034
- while (i < src.length) {
5035
- const ch = src[i];
5036
- const nxt = src[i + 1];
5037
-
5038
- if (inLineComment) { if (ch === '\n') inLineComment = false; i++; continue; }
5039
- if (inBlockComment) { if (ch === '*' && nxt === '/') { inBlockComment = false; i += 2; continue; } i++; continue; }
5040
- if (inStr) {
5041
- if (ch === '\\') { i += 2; continue; }
5042
- if (ch === inStr) inStr = '';
5043
- i++; continue;
5044
- }
5045
- if (ch === '/' && nxt === '/') { inLineComment = true; i += 2; continue; }
5046
- if (ch === '/' && nxt === '*') { inBlockComment = true; i += 2; continue; }
5047
- if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; i++; continue; }
5048
-
5049
- if (ch === '{') depth++;
5050
- else if (ch === '}') {
5051
- depth--;
5052
- if (depth === 0) { end = i + 1; break; }
5053
- }
5054
- i++;
5055
- }
5056
- if (end < 0) return null;
5057
-
5058
- const literal = src.slice(start, end);
5059
- let page = null;
5060
- try {
5061
- // 只求值这个对象字面量;扩展本来就允许执行任意代码(handler),这里不引入新风险
5062
- page = new Function('return (' + literal + ')')();
5063
- } catch {
5064
- return null;
5065
- }
5066
- if (!page || typeof page !== 'object') return null;
5067
- return {
5068
- title: String(page.title || ''),
5069
- desc: String(page.desc || ''),
5070
- schema: Array.isArray(page.schema) ? page.schema : [],
5071
- _src: true,
5072
- };
5073
- }
5074
-
5075
- async function readExtSettingsPage(absFile) {
5076
- // 诊断落盘(宿主进程里 import 扩展拿不到导出时用)
5077
- const diag = (msg) => {
5078
- try {
5079
- const p = join(process.env.USERPROFILE || process.env.HOME || homedir(), 'qqbot-extpanel-diag.log');
5080
- appendFileSync(p, new Date().toISOString() + ' ' + msg + '\n');
5081
- } catch { /* ignore */ }
5082
- };
5083
- // ① 先走源码解析(最稳,不依赖宿主 import 行为)
5084
- try {
5085
- const parsed = parseSettingsPageFromSource(absFile);
5086
- diag('source-parse ' + (parsed ? ('ok schemaLen=' + parsed.schema.length) : 'miss'));
5087
- if (parsed && parsed.schema.length > 0) return parsed;
5088
- } catch (e) {
5089
- diag('source-parse ERROR ' + String((e && e.message) || e));
5090
- }
5091
-
5092
- // ② 源码里没有 schema,再退回执行模块
5093
- try {
5094
- const url = pathToFileURL(absFile).href + '?v=' + Date.now();
5095
- diag('import start abs=' + absFile + ' url=' + url.slice(0, 160));
5096
- const mod = await import(url);
5097
- const keys = mod && typeof mod === 'object' ? Object.keys(mod) : [];
5098
- diag('import ok keys=[' + keys.join(',') + '] typeofDefault=' + typeof (mod && mod.default));
5099
- const page = mod && (mod.qqbotSettingsPage || (mod.default && mod.default.qqbotSettingsPage));
5100
- diag('page=' + (page ? ('object schemaLen=' + (Array.isArray(page.schema) ? page.schema.length : 'notArray')) : String(page)));
5101
- if (!page || typeof page !== 'object') {
5102
- // 文件里没有 qqbotSettingsPage:把实际导出键回传,前端能直接看出问题
5103
- const keys = mod && typeof mod === 'object' ? Object.keys(mod) : [];
5104
- return { title: '', desc: '', schema: [], _miss: true, _keys: keys };
5105
- }
5106
- return {
5107
- title: String(page.title || ''),
5108
- desc: String(page.desc || ''),
5109
- schema: Array.isArray(page.schema) ? page.schema : [],
5110
- };
5111
- } catch (e) {
5112
- diag('import ERROR: ' + String((e && e.stack) || e).slice(0, 500));
5113
- return { title: '', desc: '', schema: [], _err: String((e && e.message) || e) };
5114
- }
5115
- }
5116
-
5117
- /**
5118
- * GET /api/qqbot-settings/ext-panel?kind=&file=&cwd=
5119
- * 读某个扩展的设置声明 + 当前值
5120
- * POST /api/qqbot-settings/ext-panel
5121
- * body: { kind, file, cwd, values }
5122
- * 写回配置
5123
- */
5124
- /**
5125
- * 在「内置目录 + 所有扩展包」里找某个扩展文件(2026-10-24 修)
5126
- *
5127
- * 原实现只 join(dataRoot, '.qqbot-extensions', kind, file) —— 扩展包里的扩展根本找不到,
5128
- * 面板点开会显示「没有声明配置项」(主人实测发现)。
5129
- */
5130
- function findExtFileInAnyRoot(dataRoot, kind, file) {
5131
- const name = basename(String(file || ''));
5132
- if (!name) return '';
5133
- const roots = [join(dataRoot, '.qqbot-extensions', kind)];
5134
- const packsRoot = join(dataRoot, 'extension-packs');
5135
- let packs = [];
5136
- try { packs = readdirSync(packsRoot); } catch { packs = []; }
5137
- for (const p of packs) {
5138
- const d = join(packsRoot, p, kind);
5139
- try { if (statSync(d).isDirectory()) roots.push(d); } catch { /* 没这个 kind */ }
5140
- }
5141
- for (const dir of roots) {
5142
- const abs = join(dir, name);
5143
- try { if (statSync(abs).isFile()) return abs; } catch { /* 继续找 */ }
5144
- }
5145
- return '';
5146
- }
5147
-
5148
- /**
5149
- * 递归查找(兜底):有些扩展可能放在更深层(如 tools/lib/ 下的辅助模块)
5150
- */
5151
- function findExtFileDeep(dataRoot, kind, file) {
5152
- const name = basename(String(file || ''));
5153
- if (!name) return '';
5154
- const bases = [join(dataRoot, '.qqbot-extensions', kind), join(dataRoot, 'extension-packs')];
5155
- function walk(dir, depth) {
5156
- if (depth > 4) return '';
5157
- let entries = [];
5158
- try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return ''; }
5159
- for (const e of entries) {
5160
- const full = join(dir, e.name);
5161
- if (e.isFile() && e.name === name) return full;
5162
- }
5163
- for (const e of entries) {
5164
- if (!e.isDirectory()) continue;
5165
- const r = walk(join(dir, e.name), depth + 1);
5166
- if (r) return r;
5167
- }
5168
- return '';
5169
- }
5170
- for (const b of bases) {
5171
- const r = walk(b, 0);
5172
- if (r) return r;
5173
- }
5174
- return '';
5175
- }
5176
-
5177
- route(ctx, ['GET', 'POST'], '/api/qqbot-settings/ext-panel', async (req, res) => {
5178
- try {
5179
- const q = new URL(req.url, 'http://localhost').searchParams;
5180
- let kind = '';
5181
- let file = '';
5182
- let cwdParam = '';
5183
- let values = null;
5184
-
5185
- if (req.method === 'GET') {
5186
- kind = String(q.get('kind') || '').trim();
5187
- file = String(q.get('file') || '').trim();
5188
- cwdParam = String(q.get('cwd') || '').trim();
5189
- } else {
5190
- const body = await readJsonBody(req);
5191
- if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
5192
- kind = String(body.kind || '').trim();
5193
- file = String(body.file || '').trim();
5194
- cwdParam = String(body.cwd || '').trim();
5195
- values = body.values && typeof body.values === 'object' ? body.values : {};
5196
- }
5197
-
5198
- if (!kind || !file) return writeJson(res, 400, { error: 'kind 与 file 必填' });
5199
-
5200
- // 定位文件(只允许 EXT_KINDS 内的六类,防路径逃逸)
5201
- const { dataRoot } = resolveDataRootForWorkspace(cwdParam);
5202
- if (!dataRoot) return writeJson(res, 500, { error: '推不出 dataRoot' });
5203
- // 内置目录 + 所有扩展包里找(扩展包的扩展不在 .qqbot-extensions 下)
5204
- let abs = findExtFileInAnyRoot(dataRoot, kind, file);
5205
- if (!abs) abs = findExtFileDeep(dataRoot, kind, file);
5206
- if (!abs) {
5207
- return writeJson(res, 404, { error: '扩展文件不存在:' + file + '(已在内置目录与 extension-packs 下查找)' });
5208
- }
5209
-
5210
- const extId = String(file).replace(/\.(mjs|js|cjs)$/i, '');
5211
-
5212
- if (req.method === 'GET') {
5213
- const page = await readExtSettingsPage(abs);
5214
- // 当前值
5215
- let current = {};
5216
- const cfgPath = extConfigPath(dataRoot, extId);
5217
- try { if (cfgPath && existsSync(cfgPath)) current = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { current = {}; }
5218
- return writeJson(res, 200, {
5219
- ok: true,
5220
- extId,
5221
- foundAt: abs,
5222
- file: abs,
5223
- page: page || { title: '', desc: '', schema: [] },
5224
- values: current,
5225
- configPath: cfgPath,
5226
- });
5227
- }
5228
-
5229
- // POST:写回
5230
- const cfgPath = extConfigPath(dataRoot, extId);
5231
- if (!cfgPath) return writeJson(res, 400, { error: 'extId 不合法' });
5232
- try {
5233
- mkdirSync(dirname(cfgPath), { recursive: true });
5234
- writeFileSync(cfgPath, JSON.stringify(values, null, 2), 'utf8');
5235
- } catch (e) {
5236
- return writeJson(res, 500, { error: '写入失败: ' + String((e && e.message) || e) });
5237
- }
5238
- return writeJson(res, 200, { ok: true, configPath: cfgPath });
5239
- } catch (e) {
5240
- writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5241
- }
5242
- });
5243
-
5244
- /**
5245
- * 工作区转义目录名 → 真实路径(与扩展 commands/会话列表.mjs 的 wsPath 同款规则)
5246
- * --D-newwenjianjia-aiwork-~9CB8~9C7C~5A18-- ⇒ D:\newwenjianjia\aiwork\鲸鱼娘
5247
- * '-' = 分隔符;'~XXXX' = 一个 UTF-16 码元;首段单字母 = 盘符
5248
- */
5249
- /**
5250
- * `~/.dsh/sessions/{转义目录名}` → 工作区真实路径。
5251
- *
5252
- * 2026-10-24 修 issue #19:这里原来自己写了一遍解码(和 decodeSessionDirName 重复),
5253
- * 而且有三个叠加缺陷:
5254
- * ① `segs.join(String.fromCharCode(92, 92))` —— 拼出了**两个**反斜杠字符;
5255
- * ② 分隔符写死反斜杠,POSIX 上直接坏;
5256
- * ③ 目录名转义时根部的 `/` 就丢了,还原后也没补。
5257
- * 结果 Android/Linux 上还原成 `storage\\emulated\\0\\大肥鱼` —— 相对路径,
5258
- * 扩展面板拿它去 readdirSync 必然落空,三块计数全 0。
5259
- *
5260
- * 现在直接复用 decodeSessionDirName(那边已经按盘符/POSIX 分支处理过)。
5261
- */
5262
- function wsPathFromDir(dir) {
5263
- return decodeSessionDirName(String(dir || ''));
5264
- }
5265
-
5266
- /**
5267
- * 用 sessionId 反查它属于哪个工作区(DSH 把会话存在 ~/.dsh/sessions/{工作区转义}/{会话id}/)
5268
- * 返回工作区真实路径;找不到返回 ''。
5269
- */
5270
- function resolveWorkspaceBySid(sid) {
5271
- const id = String(sid || '').trim();
5272
- if (!id) return '';
5273
- const home = process.env.USERPROFILE || process.env.HOME || homedir();
5274
- const root = join(home, '.dsh', 'sessions');
5275
- let wsDirs = [];
5276
- try { wsDirs = readdirSync(root); } catch { return ''; }
5277
- // 会话目录名可能是纯 uuid,也可能带 'session-' 前缀,两种都认
5278
- const candidates = [id, 'session-' + id];
5279
- for (const ws of wsDirs) {
5280
- if (!ws.startsWith('--')) continue;
5281
- const wsDir = join(root, ws);
5282
- let subs = [];
5283
- try { subs = readdirSync(wsDir); } catch { continue; }
5284
- for (const sub of subs) {
5285
- if (candidates.includes(sub)) return wsPathFromDir(ws);
5286
- }
5287
- }
5288
- return '';
5289
- }
5290
-
5291
- /**
5292
- * 扩展启停开关(2026-10-24)
5293
- * POST /api/qqbot-settings/ext-toggle
5294
- * body: { cwd?, kind, file, enabled }
5295
- * 写 {dataRoot}/.qqbot-extensions/.state/ext-disabled.json
5296
- */
5297
- route(ctx, 'POST', '/api/qqbot-settings/ext-toggle', async (req, res) => {
5298
- try {
5299
- const body = await readJsonBody(req);
5300
- if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
5301
- const kind = String(body.kind || '').trim();
5302
- const file = String(body.file || '').trim();
5303
- const enabled = body.enabled !== false;
5304
- const cwdParam = String(body.cwd || '').trim();
5305
- if (!kind || !file) return writeJson(res, 400, { error: 'kind 与 file 必填' });
5306
-
5307
- const { dataRoot } = resolveDataRootForWorkspace(cwdParam);
5308
- if (!dataRoot) return writeJson(res, 500, { error: '推不出 dataRoot' });
5309
-
5310
- let abs = findExtFileInAnyRoot(dataRoot, kind, file);
5311
- if (!abs) abs = findExtFileDeep(dataRoot, kind, file);
5312
- if (!abs) return writeJson(res, 404, { error: '扩展文件不存在:' + file });
5313
-
5314
- const { readDisabledKeys, writeDisabledKeys, extDisabledKey } = await import('./dist/features/ext-packs.js');
5315
- const key = extDisabledKey(dataRoot, abs);
5316
- const set = readDisabledKeys(dataRoot);
5317
- if (enabled) set.delete(key); else set.add(key);
5318
- writeDisabledKeys(dataRoot, [...set]);
5319
-
5320
- // ★ 2026-10-24:立刻热重载,不用重启宿主
5321
- // ext-reload.ts 是项目里现成的重载注册表(middleware-setup 启动时注册了各 reloader),
5322
- // 走它就不用自己碰 manager/闭包。工具(tools/)由 SessionManager.reloadAllChannelTools
5323
- // 负责,这里够不到,所以 tools 的启停仍需 /tools-reload 或重启(回执里说明)。
5324
- const reloaded = { commands: '', inbound: '', outbound: '', tools: '' };
5325
- try {
5326
- const mod = await import('./dist/features/ext-reload.js');
5327
- if (typeof mod.reloadExtCommands === 'function') {
5328
- const r = await mod.reloadExtCommands();
5329
- reloaded.commands = (r && r.error) ? ('失败: ' + r.error) : ((r && r.names) ? r.names.length + ' 个' : 'ok');
5330
- }
5331
- if (typeof mod.reloadExtInbound === 'function') {
5332
- const r = await mod.reloadExtInbound();
5333
- reloaded.inbound = (r && r.error) ? ('失败: ' + r.error) : 'ok';
5334
- }
5335
- if (typeof mod.reloadExtOutbound === 'function') {
5336
- const r = await mod.reloadExtOutbound();
5337
- reloaded.outbound = (r && r.error) ? ('失败: ' + r.error) : 'ok';
5338
- }
5339
- } catch (e) {
5340
- reloaded.error = String((e && e.message) || e);
5341
- }
5342
-
5343
- // 工具注册表归 SessionManager,只能借 middleware-setup 登记在 globalThis 的 reloader
5344
- try {
5345
- const reg = globalThis.__qqbotReloaders;
5346
- const fn = reg instanceof Map ? (reg.get(dataRoot) || [...reg.values()][0]) : undefined;
5347
- if (typeof fn === 'function') {
5348
- const summary = await fn();
5349
- reloaded.tools = '已重载';
5350
- reloaded.toolsSummary = String(summary || '').slice(0, 300);
5351
- } else {
5352
- reloaded.tools = '需 /tools-reload(没找到 reloader)';
5353
- }
5354
- } catch (e) {
5355
- reloaded.tools = '失败: ' + String((e && e.message) || e);
5356
- }
5357
-
5358
- writeJson(res, 200, { ok: true, key, enabled, disabled: [...set], reloaded });
5359
- } catch (e) {
5360
- writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5361
- }
5362
- });
5363
-
5364
- // ═══════════════════════════════════════════════════════════════════════════
5365
- // 插件市场(2026-10-24)
5366
- //
5367
- // 索引仓库:zaofan-make/dsh-qqbot-plugins,根目录 index.json:
5368
- // {
5369
- // "version": 1,
5370
- // "updated": "2026-10-24",
5371
- // "plugins": [
5372
- // { "id":"demo-author", "name":"示例扩展包", "author":"做早饭", "version":"1.0.0",
5373
- // "description":"…", "homepage":"…", "repo":"zaofan-make/dsh-qqbot-plugins",
5374
- // "path":"packs/demo-author", "tags":["示例"], "kinds":["tools","commands"] }
5375
- // ]
5376
- // }
5377
- //
5378
- // 安装 = 用 GitHub API 递归拉 path 下所有文件,落到 {dataRoot}/extension-packs/{id}/
5379
- // (不用 zip:Node 没内置解压,tar 不认 zip;API 递归最稳,还能挑文件)
5380
- // 卸载 = 删目录(先确认它在 extension-packs 下,防误删)
5381
- // ═══════════════════════════════════════════════════════════════════════════
5382
-
5383
- // 索引地址:国内直连 raw.githubusercontent.com 常年不通(主人实测 fetch failed),
5384
- // 所以按顺序试多个源,第一个成功的就用;回执里会带上用的是哪个。
5385
- const MARKET_REPO = 'zaofan-make/dsh-qqbot-plugins';
5386
- const MARKET_BRANCH = 'main';
5387
- // 索引源顺序(2026-10-24 改):**实时源优先**
5388
- // jsDelivr 对 @main 有缓存,刚合并的提交在它那儿看不到(实测面板一直只有 1 个插件,
5389
- // 而 raw 上已经是 3 个)。所以索引不能把 jsDelivr 放第一位 ——
5390
- // 它只留作最后的兜底(CDN 稳,但内容可能滞后)。
5391
- // 注意:**插件文件**下载仍然用 jsDelivr(文件内容不变,缓存无害,且墙内稳定)。
5392
- const MARKET_SOURCES = [
5393
- 'https://gh-proxy.com/https://raw.githubusercontent.com/' + MARKET_REPO + '/' + MARKET_BRANCH + '/index.json',
5394
- 'https://raw.githubusercontent.com/' + MARKET_REPO + '/' + MARKET_BRANCH + '/index.json',
5395
- 'https://ghproxy.net/https://raw.githubusercontent.com/' + MARKET_REPO + '/' + MARKET_BRANCH + '/index.json',
5396
- 'https://cdn.jsdelivr.net/gh/' + MARKET_REPO + '@' + MARKET_BRANCH + '/index.json',
5397
- ];
5398
- const MARKET_INDEX_URL = MARKET_SOURCES[0]; // 兼容旧引用(展示用)
5399
-
5400
- /** 依次试多个索引源,返回 { json, source } */
5401
- async function fetchMarketIndex() {
5402
- const errs = [];
5403
- for (const url of MARKET_SOURCES) {
5404
- try {
5405
- const txt = await fetchText(url, 8000);
5406
- const j = JSON.parse(txt);
5407
- if (!Array.isArray(j.plugins)) j.plugins = [];
5408
- return { json: j, source: url };
5409
- } catch (e) {
5410
- errs.push(url.split('/')[2] + ': ' + String((e && e.message) || e));
5411
- }
5412
- }
5413
- throw new Error('所有索引源都失败 → ' + errs.join(' | '));
5414
- }
5415
-
5416
- /**
5417
- * jsDelivr 文件地址(2026-10-24)
5418
- * 墙内 raw.githubusercontent.com / api.github.com / data.jsdelivr.com 都连不上,
5419
- * 实测只有 cdn.jsdelivr.net 通 —— 文件内容也走它。
5420
- */
5421
- function jsdelivrFileUrl(repo, branch, relPath) {
5422
- const segs = String(relPath).replace(/\\/g, '/').split('/').map(encodeURIComponent).join('/');
5423
- return 'https://cdn.jsdelivr.net/gh/' + repo + '@' + (branch || 'main') + '/' + segs;
5424
- }
5425
-
5426
- /** 把 GitHub raw / API 下载链换成可用镜像(封面图等前端直接用) */
5427
- function mirrorUrl(url) {
5428
- if (!url) return url;
5429
- const s = String(url);
5430
- if (s.indexOf('raw.githubusercontent.com/') >= 0) {
5431
- const tail = s.split('raw.githubusercontent.com/')[1]; // owner/repo/branch/path
5432
- return 'https://cdn.jsdelivr.net/gh/' + tail;
5433
- }
5434
- return s;
5435
- }
5436
-
5437
- /**
5438
- * 市场代理配置(2026-10-24 主人要求)
5439
- *
5440
- * 背景:墙内 raw.githubusercontent.com / api.github.com / data.jsdelivr.com 全连不上,
5441
- * 而 Node 原生 fetch **不读 HTTP_PROXY 环境变量**(Node 18+ 就这么设计的),
5442
- * 面板里点了安装就是一句干巴巴的 "fetch failed"。
5443
- * 这里给一个手动填代理的口子,填了就所有市场请求都走它。
5444
- *
5445
- * 落盘:{dataRoot}/.qqbot-extensions/.state/market-config.json
5446
- * { "proxy": "http://127.0.0.1:29290" }
5447
- *
5448
- * 实现:用 curl 子进程(Windows 10+ 自带 curl.exe;比手写 CONNECT 稳)。
5449
- * 没配代理时仍走原生 fetch(大陆 CDN 如 cdn.jsdelivr.net 通常直连没问题)。
5450
- */
5451
- function marketConfigPath(dataRoot) {
5452
- return join(dataRoot, '.qqbot-extensions', '.state', 'market-config.json');
5453
- }
5454
-
5455
- function readMarketConfig(dataRoot) {
5456
- try {
5457
- const p = marketConfigPath(dataRoot);
5458
- if (!existsSync(p)) return {};
5459
- const j = JSON.parse(readFileSync(p, 'utf8'));
5460
- return j && typeof j === 'object' ? j : {};
5461
- } catch { return {}; }
5462
- }
5463
-
5464
- function writeMarketConfig(dataRoot, cfg) {
5465
- try {
5466
- const p = marketConfigPath(dataRoot);
5467
- mkdirSync(dirname(p), { recursive: true });
5468
- writeFileSync(p, JSON.stringify(cfg, null, 2), 'utf8');
5469
- return true;
5470
- } catch { return false; }
5471
- }
5472
-
5473
- /** 用 curl 走代理取文本(同步执行,简单可靠) */
5474
- function curlText(url, proxy, timeoutMs) {
5475
- // execFileSync 已在文件顶部 import(ESM 里不能用 require)
5476
- const args = ['-sS', '--max-time', String(Math.ceil((timeoutMs || 15000) / 1000)), '-L'];
5477
- if (proxy) args.push('-x', proxy);
5478
- args.push('-H', 'User-Agent: dsh-qqbot-plugin-market', url);
5479
- const out = execFileSync('curl', args, { encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 });
5480
- return out.toString('utf8');
5481
- }
5482
-
5483
- /** 取 GitHub raw 文本(带超时与 UA,GitHub 要 UA) */
5484
- async function fetchText(url, timeoutMs) {
5485
- const ac = new AbortController();
5486
- const t = setTimeout(() => ac.abort(), timeoutMs || 12000);
5487
- try {
5488
- const r = await fetch(url, {
5489
- signal: ac.signal,
5490
- headers: { 'User-Agent': 'dsh-qqbot-plugin-market', 'Accept': 'application/vnd.github+json' },
5491
- });
5492
- if (!r.ok) throw new Error('HTTP ' + r.status);
5493
- return await r.text();
5494
- } finally {
5495
- clearTimeout(t);
5496
- }
5497
- }
5498
-
5499
- /** 取 GitHub API JSON */
5500
- async function fetchJson(url, timeoutMs) {
5501
- const txt = await fetchText(url, timeoutMs);
5502
- return JSON.parse(txt);
5503
- }
5504
-
5505
- /** 递归列出一个仓库子目录下的所有文件(GitHub contents API) */
5506
- async function listRepoFiles(repo, dir, apiBase) {
5507
- const out = [];
5508
- const base = apiBase || 'https://api.github.com';
5509
- async function walk(d, depth) {
5510
- if (depth > 6) return;
5511
- const items = await fetchJson(base + '/repos/' + repo + '/contents/' + encodeURI(d));
5512
- if (!Array.isArray(items)) return;
5513
- for (const it of items) {
5514
- if (it.type === 'file' && it.download_url) out.push({ path: it.path, url: it.download_url, size: it.size || 0 });
5515
- else if (it.type === 'dir') await walk(it.path, depth + 1);
5516
- }
5517
- }
5518
- await walk(dir, 0);
5519
- return out;
5520
- }
5521
-
5522
- /**
5523
- * GET /api/qqbot-settings/market?cwd=
5524
- * → { ok, index:{plugins:[…]}, installed:['demo-author'…], packsRoot, indexUrl }
5525
- */
5526
- route(ctx, 'GET', '/api/qqbot-settings/market', async (req, res) => {
5527
- try {
5528
- const q = new URL(req.url, 'http://localhost').searchParams;
5529
- const cwdParam = String(q.get('cwd') || '').trim();
5530
- const { dataRoot } = resolveDataRootForWorkspace(cwdParam);
5531
- if (!dataRoot) return writeJson(res, 500, { error: '推不出 dataRoot' });
5532
- const packsRoot = join(dataRoot, 'extension-packs');
5533
- const mcfg = readMarketConfig(dataRoot);
5534
- const curProxy = String(mcfg.proxy || '');
5535
-
5536
- let index = { plugins: [] };
5537
- let indexErr = '';
5538
- let indexSource = '';
5539
- try {
5540
- const r = curProxy
5541
- ? { json: JSON.parse(curlText(MARKET_SOURCES[0], curProxy, 15000)), source: MARKET_SOURCES[0] + '(走代理 ' + curProxy + ')' }
5542
- : await fetchMarketIndex();
5543
- index = r.json;
5544
- indexSource = r.source;
5545
- // 封面等图片地址换成镜像,否则前端在墙内显示不出来
5546
- for (const p of index.plugins) if (p && p.cover) p.cover = mirrorUrl(p.cover);
5547
- } catch (e) {
5548
- indexErr = String((e && e.message) || e);
5549
- }
5550
-
5551
- // installed 带上本地版本,前端才能判断「有没有新版可升」
5552
- let installed = [];
5553
- try {
5554
- installed = readdirSync(packsRoot)
5555
- .filter((n) => statSync(join(packsRoot, n)).isDirectory())
5556
- .map((n) => {
5557
- let version = '';
5558
- try {
5559
- const mf = join(packsRoot, n, 'qqbot-pack.json');
5560
- if (existsSync(mf)) version = String(JSON.parse(readFileSync(mf, 'utf8')).version || '');
5561
- } catch {}
5562
- return { id: n, version };
5563
- });
5564
- } catch { installed = []; }
5565
-
5566
- writeJson(res, 200, { ok: true, index, indexErr, indexSource, proxy: curProxy, installed, packsRoot, indexUrl: MARKET_INDEX_URL + '(多源,实际用: ' + (indexSource || '无') + ')' });
5567
- } catch (e) {
5568
- writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5569
- }
5570
- });
5571
-
5572
- /**
5573
- * POST /api/qqbot-settings/market/install
5574
- * body: { cwd?, id }
5575
- * 按索引里的 repo/path 拉全部文件,写到 extension-packs/{id}/
5576
- */
5577
- /**
5578
- * POST /api/qqbot-settings/market/config
5579
- * body: { cwd?, proxy }
5580
- * 存市场配置(目前只有 proxy)
5581
- */
5582
- route(ctx, 'POST', '/api/qqbot-settings/market/config', async (req, res) => {
5583
- try {
5584
- const body = await readJsonBody(req);
5585
- const cwdParam = String((body && body.cwd) || '').trim();
5586
- const { dataRoot } = resolveDataRootForWorkspace(cwdParam);
5587
- if (!dataRoot) return writeJson(res, 500, { error: '推不出 dataRoot' });
5588
- const cfg = readMarketConfig(dataRoot);
5589
- if (body && typeof body.proxy === 'string') cfg.proxy = body.proxy.trim();
5590
- const ok = writeMarketConfig(dataRoot, cfg);
5591
- writeJson(res, ok ? 200 : 500, { ok, config: cfg, path: marketConfigPath(dataRoot) });
5592
- } catch (e) {
5593
- writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5594
- }
5595
- });
5596
-
5597
- route(ctx, 'POST', '/api/qqbot-settings/market/install', async (req, res) => {
5598
- try {
5599
- const rawType = String((req.headers && req.headers['content-type']) || '');
5600
- const body = await readJsonBody(req);
5601
- const id = String((body && body.id) || '').trim();
5602
- const cwdParam = String((body && body.cwd) || '').trim();
5603
- if (!body || typeof body !== 'object') {
5604
- return writeJson(res, 400, { stage: 'parse', error: '请求体解析失败(body 为空或不是 JSON)', contentType: rawType });
5605
- }
5606
- if (!id) {
5607
- return writeJson(res, 400, { stage: 'id', error: 'id 必填', got: body });
5608
- }
5609
- if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
5610
- return writeJson(res, 400, { stage: 'id-format', error: 'id 只允许字母数字-_', got: id });
5611
- }
5612
-
5613
- const { dataRoot } = resolveDataRootForWorkspace(cwdParam);
5614
- if (!dataRoot) return writeJson(res, 500, { error: '推不出 dataRoot' });
5615
-
5616
- // 拉索引找到这一条
5617
- const idxR = await fetchMarketIndex();
5618
- const idx = idxR.json;
5619
- const item = (idx.plugins || []).find((p) => p && p.id === id);
5620
- if (!item) return writeJson(res, 404, { error: '索引里没有这个插件: ' + id, indexSource: idxR.source, ids: (idx.plugins || []).map((x) => x && x.id) });
5621
- if (!item.repo || !item.path) return writeJson(res, 400, { stage: 'repo-path', error: '索引条目缺 repo/path', item });
5622
-
5623
- // 文件清单来自索引的 files 字段 —— 不走 GitHub 目录 API(墙内连不上)。
5624
- const list = Array.isArray(item.files) ? item.files : [];
5625
- if (!list.length) {
5626
- return writeJson(res, 400, {
5627
- stage: 'files',
5628
- error: '索引条目缺 files 清单',
5629
- indexSource: idxR.source,
5630
- itemKeys: Object.keys(item),
5631
- item,
5632
- });
5633
- }
5634
-
5635
- const dest = join(dataRoot, 'extension-packs', id);
5636
- mkdirSync(dest, { recursive: true });
5637
- const branch = item.branch || 'main';
5638
- const destNorm = dest.replace(/\\/g, '/');
5639
-
5640
- // 并发下载(原来串行 + 每个文件起一个 curl 进程,几 KB 也要好几秒)
5641
- const mcfg2 = readMarketConfig(dataRoot);
5642
- const px = String(mcfg2.proxy || '');
5643
- const jobs = list.map(async (rel) => {
5644
- const relPath = String(rel).replace(/^\.?\/+/, '');
5645
- if (!relPath) return { ok: false, rel, err: '空路径' };
5646
- const abs = join(dest, relPath);
5647
- if (!abs.replace(/\\/g, '/').startsWith(destNorm + '/')) return { ok: false, rel, err: '路径越界' };
5648
- try {
5649
- mkdirSync(dirname(abs), { recursive: true });
5650
- const url = jsdelivrFileUrl(item.repo, branch, String(item.path) + '/' + relPath);
5651
- const content = px ? curlText(url, px, 20000) : await fetchText(url, 20000);
5652
- writeFileSync(abs, content, 'utf8');
5653
- return { ok: true, rel };
5654
- } catch (e) {
5655
- return { ok: false, rel, err: String((e && e.message) || e) };
5656
- }
5657
- });
5658
- const results = await Promise.all(jobs);
5659
- let written = 0; const failed = [];
5660
- for (const r of results) {
5661
- if (r.ok) written++;
5662
- else failed.push(r.rel + ': ' + (r.err || '未知'));
5663
- }
5664
-
5665
- writeJson(res, 200, { ok: written > 0, written, total: files.length, failed, dest });
5666
- } catch (e) {
5667
- writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5668
- }
5669
- });
5670
-
5671
- /**
5672
- * POST /api/qqbot-settings/market/remove
5673
- * body: { cwd?, id }
5674
- */
5675
- route(ctx, 'POST', '/api/qqbot-settings/market/remove', async (req, res) => {
5676
- try {
5677
- const body = await readJsonBody(req);
5678
- const id = String((body && body.id) || '').trim();
5679
- const cwdParam = String((body && body.cwd) || '').trim();
5680
- if (!id) return writeJson(res, 400, { error: 'id 必填' });
5681
- if (!/^[a-zA-Z0-9_-]+$/.test(id)) return writeJson(res, 400, { error: 'id 只允许字母数字-_' });
5682
-
5683
- const { dataRoot } = resolveDataRootForWorkspace(cwdParam);
5684
- if (!dataRoot) return writeJson(res, 500, { error: '推不出 dataRoot' });
5685
- const packsRoot = join(dataRoot, 'extension-packs');
5686
- const target = join(packsRoot, id);
5687
- const norm = (p) => p.replace(/\\/g, '/');
5688
- if (!norm(target).startsWith(norm(packsRoot) + '/')) return writeJson(res, 400, { error: '路径越界' });
5689
- if (!existsSync(target)) return writeJson(res, 404, { error: '没装: ' + id });
5690
-
5691
- rmSync(target, { recursive: true, force: true });
5692
- writeJson(res, 200, { ok: true, removed: target });
5693
- } catch (e) {
5694
- writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5695
- }
5696
- });
5697
-
5698
- route(ctx, 'GET', '/api/qqbot-settings/extensions', async (req, res) => {
5699
- try {
5700
- // ★ 2026-10-24 修:优先用 ?cwd=(当前会话的工作区),扩展目录跟着工作区走
5701
- // 原先只用 resolveContextlessDataRoot()(=首个 bot 实例的 dataRoot),
5702
- // 切换会话到别的工作区后面板还指向老工作区,主人实测发现。
5703
- // 工作区来源优先级:?cwd= > ?sid= 反查 > 默认 dataRoot
5704
- let cwdParam = '';
5705
- let sidParam = '';
5706
- try {
5707
- const q = new URL(req.url, 'http://localhost').searchParams;
5708
- cwdParam = String(q.get('cwd') || '').trim();
5709
- sidParam = String(q.get('sid') || '').trim();
5710
- } catch { /* ignore */ }
5711
- let wsResolvedBy = cwdParam ? 'cwd' : '';
5712
- if (!cwdParam && sidParam) {
5713
- const bySid = resolveWorkspaceBySid(sidParam);
5714
- if (bySid) { cwdParam = bySid; wsResolvedBy = 'sid'; }
5715
- }
5716
- // ★ 尊重 bot 配置里的 dataRoot(主人 2026-10-24 指出)
5717
- const _dr = resolveDataRootForWorkspace(cwdParam);
5718
- const dataRoot = _dr.dataRoot;
5719
- if (!dataRoot) return writeJson(res, 200, { ok: false, error: '推不出 dataRoot(还没绑过机器人?)' });
5720
-
5721
- const builtinRoot = join(dataRoot, '.qqbot-extensions');
5722
- const builtin = scanExtRoot(builtinRoot, dataRoot);
5723
-
5724
- // ① 内置目录
5725
- const result = {
5726
- ok: true,
5727
- dataRoot,
5728
- workspaceCwd: cwdParam || '',
5729
- workspaceFrom: wsResolvedBy || 'default',
5730
- dataRootFrom: _dr.source,
5731
- builtinRoot,
5732
- builtin,
5733
- packsRoot: join(dataRoot, 'extension-packs'),
5734
- packs: [],
5735
- };
5736
-
5737
- // ② 扩展包:扫 packsRoot 下每个子目录
5738
- const packsRoot = result.packsRoot;
5739
- let packNames = [];
5740
- try { packNames = readdirSync(packsRoot); } catch { packNames = []; }
5741
- for (const name of packNames) {
5742
- const dir = join(packsRoot, name);
5743
- let st = null;
5744
- try { st = statSync(dir); } catch { continue; }
5745
- if (!st.isDirectory()) continue;
5746
-
5747
- // 清单文件(可选)
5748
- let meta = {};
5749
- for (const f of ['qqbot-pack.json', 'pack.json', 'package.json']) {
5750
- const mf = join(dir, f);
5751
- if (!existsSync(mf)) continue;
5752
- try {
5753
- const j = JSON.parse(readFileSync(mf, 'utf8'));
5754
- meta = {
5755
- name: j.name || j.displayName || '',
5756
- author: j.author || (j.author && j.author.name) || '',
5757
- desc: j.description || j.desc || '',
5758
- version: j.version || '',
5759
- homepage: j.homepage || j.repository || '',
5760
- };
5761
- break;
5762
- } catch { /* 清单坏了就当没有 */ }
5763
- }
5764
-
5765
- const scanned = scanExtRoot(dir, dataRoot);
5766
- if (scanned.total === 0) continue; // 空目录不显示
5767
- result.packs.push({
5768
- dirName: name,
5769
- dir,
5770
- meta,
5771
- kinds: scanned.kinds,
5772
- total: scanned.total,
5773
- });
5774
- }
5775
- result.packs.sort((a, b) => a.dirName.localeCompare(b.dirName));
5776
-
5777
- // ③ 汇总:哪几个扩展声明了设置页
5778
- const settingsPages = [];
5779
- const collect = (list, from) => {
5780
- for (const it of list) {
5781
- if (it.hasSettings) settingsPages.push({ from, id: it.id, title: it.settingsTitle || it.id });
5782
- }
5783
- };
5784
- for (const k of EXT_KINDS) collect(builtin.kinds[k], 'builtin');
5785
- for (const p of result.packs) {
5786
- for (const k of EXT_KINDS) collect(p.kinds[k], p.meta.name || p.dirName);
5787
- }
5788
- result.settingsPages = settingsPages;
5789
-
5790
- // ★ 2026-10-24:统一补启停状态(scanExtKind 是顶层函数,拿不到路由里的 disabledSet)
5791
- let _ds = new Set();
5792
- try {
5793
- const m = await import('./dist/features/ext-packs.js');
5794
- _ds = m.readDisabledKeys(dataRoot);
5795
- } catch { /* 拿不到就当全启用 */ }
5796
- const _root = String(dataRoot).split('\\').join('/').split('/').join('/');
5797
- const _mark = (list) => {
5798
- for (const it of list) {
5799
- const abs = it.__abs || '';
5800
- const rel = abs.split('\\').join('/').split('/').join('/');
5801
- const key = rel.indexOf(_root + '/') === 0 ? rel.slice(_root.length + 1) : abs;
5802
- it.disabled = _ds.has(key);
5803
- }
5804
- };
5805
- for (const k of EXT_KINDS) _mark(builtin.kinds[k] || []);
5806
- for (const pk of result.packs) for (const k of EXT_KINDS) _mark((pk.kinds && pk.kinds[k]) || []);
5807
-
5808
-
5809
- writeJson(res, 200, result);
5810
- } catch (e) {
5811
- writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5812
- }
5813
- });
5814
-
4928
+ // ═══════════════════════════════════════════════════════════════════════════
4929
+ // 「QQ机器人扩展」面板数据源(2026-10-24 主人定)
4930
+ //
4931
+ // 两类扩展来源:
4932
+ // ① 内置目录 {dataRoot}/.qqbot-extensions/{tools,commands,botplay,hooks,inbound,outbound}/
4933
+ // ② 扩展包 {dataRoot}/extension-packs/{包名}/{同样六个子目录}/
4934
+ // 包名目录里可放 qqbot-pack.json:{ name, author, desc, version, homepage }
4935
+ // 没有清单也能用,只是面板里显示为「未命名扩展包」
4936
+ //
4937
+ // 为什么要扩展包:插件作者的扩展不该被迫塞进 .qqbot-extensions(那是用户自己的目录),
4938
+ // 放独立目录既能整包分发/更新,也能在面板里按作者分组显示。
4939
+ // ═══════════════════════════════════════════════════════════════════════════
4940
+ const EXT_KINDS = ['tools', 'commands', 'botplay', 'hooks', 'inbound', 'outbound'];
4941
+
4942
+ /** 扫一个 kind 目录,返回文件清单(只扫根下直接子文件,与宿主扫描口径一致) */
4943
+ function scanExtKind(dir, dataRoot) {
4944
+ const out = [];
4945
+ let names = [];
4946
+ try { names = readdirSync(dir); } catch { return out; }
4947
+ for (const name of names) {
4948
+ if (!/\.(mjs|js|cjs)$/i.test(name)) continue;
4949
+ const full = join(dir, name);
4950
+ let st = null;
4951
+ try { st = statSync(full); } catch { continue; }
4952
+ if (!st.isFile()) continue;
4953
+ // 读文件头,看有没有声明「设置页」(约定:文件里出现 qqbotSettingsPage 或 export const settingsPage)
4954
+ let hasSettings = false;
4955
+ let title = '';
4956
+ try {
4957
+ const head = readFileSync(full, 'utf8').slice(0, 4096);
4958
+ hasSettings = /qqbotSettingsPage|export\s+const\s+settingsPage/.test(head);
4959
+ const m = /qqbotSettingsPage[\s\S]{0,200}?title\s*:\s*['"]([^'"]+)['"]/.exec(head);
4960
+ if (m) title = m[1];
4961
+ } catch { /* 读不了就当没有 */ }
4962
+ out.push({
4963
+ __abs: full, // 真实绝对路径,供 extensions 接口标 disabled
4964
+ name,
4965
+ id: name.replace(/\.(mjs|js|cjs)$/i, ''),
4966
+ size: st.size,
4967
+ mtime: st.mtimeMs,
4968
+ hasSettings,
4969
+ settingsTitle: title,
4970
+ disabled: false, // 启停状态在 extensions 路由里统一补(scanExtKind 是顶层函数,拿不到闭包)
4971
+ });
4972
+ }
4973
+ out.sort((a, b) => a.name.localeCompare(b.name));
4974
+ return out;
4975
+ }
4976
+
4977
+ /**
4978
+ * 把工作区路径归一成**绝对路径**(与 src/util/abs-path.ts 的 absCwdOf 同一套规则)。
4979
+ * 绝对路径原样返回,只给相对路径补前导 `/`。
4980
+ */
4981
+ function absCwd(p) {
4982
+ const raw = String(p == null ? '' : p).trim();
4983
+ if (!raw) return '';
4984
+ if (/^[A-Za-z]:[\\/]/.test(raw)) return raw;
4985
+ if (raw.startsWith('\\\\') || raw.startsWith('//')) return raw;
4986
+ if (raw.startsWith('/')) return raw;
4987
+ const s = raw.replace(/\\/g, '/');
4988
+ if (process.platform !== 'win32') return '/' + s;
4989
+ return raw;
4990
+ }
4991
+
4992
+ /** 扫一个扩展根(含六个 kind 子目录),返回 { kinds:{tools:[...]}, total } */
4993
+ function scanExtRoot(root, dataRoot) {
4994
+ const kinds = {};
4995
+ let total = 0;
4996
+ for (const k of EXT_KINDS) {
4997
+ const list = scanExtKind(join(root, k));
4998
+ kinds[k] = list;
4999
+ total += list.length;
5000
+ }
5001
+ return { root, kinds, total };
5002
+ }
5003
+
5004
+ /**
5005
+ * 某个工作区真实对应的 dataRoot(2026-10-24 补:尊重 bot 配置里的 dataRoot)
5006
+ *
5007
+ * 优先级(主人指出必须先看配置):
5008
+ * ① 该工作区 bot 显式配置的 cfg.dataRoot
5009
+ * ② 该工作区 bot 配置的 cfg.cwd + 'dshqqbot'
5010
+ * ③ 传入的 cwd + 'dshqqbot'
5011
+ * ④ 首个未禁用 bot 的 dataRoot / cwd(= 老的 resolveContextlessDataRoot 行为)
5012
+ *
5013
+ * 返回 { dataRoot, source },source 说明是哪一级命中的,便于面板上排查。
5014
+ */
5015
+ function resolveDataRootForWorkspace(cwdHint) {
5016
+ const hint = String(cwdHint || '').trim();
5017
+ const norm = (p) => String(p || '').replace(/[\\/]+$/, '').toLowerCase();
5018
+ let bots = [];
5019
+ try { const parsed = parsePatch(); bots = (parsed && Array.isArray(parsed.bots)) ? parsed.bots : []; } catch { bots = []; }
5020
+
5021
+ // ①/② 找「属于这个工作区」的 bot(先按 cwd 精确匹配)
5022
+ if (hint) {
5023
+ const target = norm(hint);
5024
+ for (const b of bots) {
5025
+ const cfg = (b && b.cfg) || {};
5026
+ const bcwd = norm(cfg.cwd);
5027
+ if (!bcwd || bcwd !== target) continue;
5028
+ if (cfg.dataRoot) return { dataRoot: String(cfg.dataRoot), source: 'bot.dataRoot' };
5029
+ return { dataRoot: join(absCwd(cfg.cwd) || String(cfg.cwd), 'dshqqbot'), source: 'bot.cwd' };
5030
+ }
5031
+ }
5032
+
5033
+ // ③ 传入的 cwd(该工作区没绑 bot 时,扩展目录仍在它自己的 dshqqbot 下)
5034
+ if (hint) return { dataRoot: join(absCwd(hint) || hint, 'dshqqbot'), source: 'workspace' };
5035
+
5036
+ // ④ 兜底:老的推断
5037
+ for (const b of bots) {
5038
+ const cfg = (b && b.cfg) || {};
5039
+ if (b && b.disabled) continue;
5040
+ if (cfg.dataRoot) return { dataRoot: String(cfg.dataRoot), source: 'default.bot' };
5041
+ if (cfg.cwd) return { dataRoot: join(String(cfg.cwd), 'dshqqbot'), source: 'default.bot' };
5042
+ }
5043
+ return { dataRoot: resolveContextlessDataRoot(), source: 'default' };
5044
+ }
5045
+
5046
+ // ═══════════════════════════════════════════════════════════════════════════
5047
+ // 扩展自定义设置面板(2026-10-24)
5048
+ //
5049
+ // 契约:扩展文件里 export 一个 qqbotSettingsPage:
5050
+ // export const qqbotSettingsPage = {
5051
+ // title: '我的设置',
5052
+ // schema: [
5053
+ // { key: 'enabled', type: 'bool', label: '启用' },
5054
+ // { key: 'dirs', type: 'list', label: '目录列表' },
5055
+ // { key: 'apiKey', type: 'string', label: 'API Key', secret: true },
5056
+ // { key: 'mode', type: 'select', label: '模式', options: ['a','b'] },
5057
+ // ],
5058
+ // }
5059
+ //
5060
+ // 配置落盘:{dataRoot}/.qqbot-extensions/.state/ext-config/{扩展id}.json
5061
+ // 扩展自己在代码里可用 readExtConfig 的方式拿到(暂未内置 API,先读那个 json 即可)。
5062
+ // ═══════════════════════════════════════════════════════════════════════════
5063
+
5064
+ /** 扩展配置文件路径 */
5065
+ function extConfigPath(dataRoot, extId) {
5066
+ const safe = String(extId || '').replace(/[^a-zA-Z0-9_\-\u4e00-\u9fa5]/g, '_');
5067
+ if (!safe) return '';
5068
+ return join(dataRoot, '.qqbot-extensions', '.state', 'ext-config', safe + '.json');
5069
+ }
5070
+
5071
+ /** 读某个扩展的 qqbotSettingsPage 声明(动态 import 扩展文件,失败返回 null) */
5072
+ /**
5073
+ * 从扩展**源码**里解析 qqbotSettingsPage(2026-10-24,绕开宿主里 import 拿不到导出的问题)
5074
+ *
5075
+ * 为什么不 import:
5076
+ * 同一个 file:// URL 在 Node CLI 里 import 能拿到导出,但在 dsh 宿主进程里
5077
+ * (插件模块内部再 await import 另一个 .mjs)拿不到 —— 机制不明,实测踩到。
5078
+ * 而 qqbotSettingsPage 只是个纯数据对象 { title, desc, schema:[...] },
5079
+ * 完全没必要执行整个模块;直接抠源码更稳,也顺带避免执行第三方代码。
5080
+ *
5081
+ * 做法:定位 "export const qqbotSettingsPage = {" 然后做**括号配对**截取对象字面量,
5082
+ * 再用 new Function 求值(只执行这个字面量,不跑模块其余部分)。
5083
+ * 求值失败一律返回 null,绝不抛。
5084
+ */
5085
+ function parseSettingsPageFromSource(absFile) {
5086
+ let src = '';
5087
+ try { src = readFileSync(absFile, 'utf8'); } catch { return null; }
5088
+
5089
+ // 找起点的几种写法:export const / const + 后续 export
5090
+ const patterns = [
5091
+ /export\s+(?:const|let|var)\s+qqbotSettingsPage\s*=\s*/,
5092
+ /(?:const|let|var)\s+qqbotSettingsPage\s*=\s*/,
5093
+ ];
5094
+ let start = -1;
5095
+ for (const re of patterns) {
5096
+ const m = re.exec(src);
5097
+ if (m) { start = m.index + m[0].length; break; }
5098
+ }
5099
+ if (start < 0) return null;
5100
+ if (src[start] !== '{') return null;
5101
+
5102
+ // 括号配对(跳过字符串/模板/注释里的括号)
5103
+ let depth = 0;
5104
+ let i = start;
5105
+ let inStr = '';
5106
+ let inLineComment = false;
5107
+ let inBlockComment = false;
5108
+ let end = -1;
5109
+ while (i < src.length) {
5110
+ const ch = src[i];
5111
+ const nxt = src[i + 1];
5112
+
5113
+ if (inLineComment) { if (ch === '\n') inLineComment = false; i++; continue; }
5114
+ if (inBlockComment) { if (ch === '*' && nxt === '/') { inBlockComment = false; i += 2; continue; } i++; continue; }
5115
+ if (inStr) {
5116
+ if (ch === '\\') { i += 2; continue; }
5117
+ if (ch === inStr) inStr = '';
5118
+ i++; continue;
5119
+ }
5120
+ if (ch === '/' && nxt === '/') { inLineComment = true; i += 2; continue; }
5121
+ if (ch === '/' && nxt === '*') { inBlockComment = true; i += 2; continue; }
5122
+ if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; i++; continue; }
5123
+
5124
+ if (ch === '{') depth++;
5125
+ else if (ch === '}') {
5126
+ depth--;
5127
+ if (depth === 0) { end = i + 1; break; }
5128
+ }
5129
+ i++;
5130
+ }
5131
+ if (end < 0) return null;
5132
+
5133
+ const literal = src.slice(start, end);
5134
+ let page = null;
5135
+ try {
5136
+ // 只求值这个对象字面量;扩展本来就允许执行任意代码(handler),这里不引入新风险
5137
+ page = new Function('return (' + literal + ')')();
5138
+ } catch {
5139
+ return null;
5140
+ }
5141
+ if (!page || typeof page !== 'object') return null;
5142
+ return {
5143
+ title: String(page.title || ''),
5144
+ desc: String(page.desc || ''),
5145
+ schema: Array.isArray(page.schema) ? page.schema : [],
5146
+ _src: true,
5147
+ };
5148
+ }
5149
+
5150
+ async function readExtSettingsPage(absFile) {
5151
+ // 诊断落盘(宿主进程里 import 扩展拿不到导出时用)
5152
+ const diag = (msg) => {
5153
+ try {
5154
+ const p = join(process.env.USERPROFILE || process.env.HOME || homedir(), 'qqbot-extpanel-diag.log');
5155
+ appendFileSync(p, new Date().toISOString() + ' ' + msg + '\n');
5156
+ } catch { /* ignore */ }
5157
+ };
5158
+ // ① 先走源码解析(最稳,不依赖宿主 import 行为)
5159
+ try {
5160
+ const parsed = parseSettingsPageFromSource(absFile);
5161
+ diag('source-parse ' + (parsed ? ('ok schemaLen=' + parsed.schema.length) : 'miss'));
5162
+ if (parsed && parsed.schema.length > 0) return parsed;
5163
+ } catch (e) {
5164
+ diag('source-parse ERROR ' + String((e && e.message) || e));
5165
+ }
5166
+
5167
+ // ② 源码里没有 schema,再退回执行模块
5168
+ try {
5169
+ const url = pathToFileURL(absFile).href + '?v=' + Date.now();
5170
+ diag('import start abs=' + absFile + ' url=' + url.slice(0, 160));
5171
+ const mod = await import(url);
5172
+ const keys = mod && typeof mod === 'object' ? Object.keys(mod) : [];
5173
+ diag('import ok keys=[' + keys.join(',') + '] typeofDefault=' + typeof (mod && mod.default));
5174
+ const page = mod && (mod.qqbotSettingsPage || (mod.default && mod.default.qqbotSettingsPage));
5175
+ diag('page=' + (page ? ('object schemaLen=' + (Array.isArray(page.schema) ? page.schema.length : 'notArray')) : String(page)));
5176
+ if (!page || typeof page !== 'object') {
5177
+ // 文件里没有 qqbotSettingsPage:把实际导出键回传,前端能直接看出问题
5178
+ const keys = mod && typeof mod === 'object' ? Object.keys(mod) : [];
5179
+ return { title: '', desc: '', schema: [], _miss: true, _keys: keys };
5180
+ }
5181
+ return {
5182
+ title: String(page.title || ''),
5183
+ desc: String(page.desc || ''),
5184
+ schema: Array.isArray(page.schema) ? page.schema : [],
5185
+ };
5186
+ } catch (e) {
5187
+ diag('import ERROR: ' + String((e && e.stack) || e).slice(0, 500));
5188
+ return { title: '', desc: '', schema: [], _err: String((e && e.message) || e) };
5189
+ }
5190
+ }
5191
+
5192
+ /**
5193
+ * GET /api/qqbot-settings/ext-panel?kind=&file=&cwd=
5194
+ * 读某个扩展的设置声明 + 当前值
5195
+ * POST /api/qqbot-settings/ext-panel
5196
+ * body: { kind, file, cwd, values }
5197
+ * 写回配置
5198
+ */
5199
+ /**
5200
+ * 在「内置目录 + 所有扩展包」里找某个扩展文件(2026-10-24 修)
5201
+ *
5202
+ * 原实现只 join(dataRoot, '.qqbot-extensions', kind, file) —— 扩展包里的扩展根本找不到,
5203
+ * 面板点开会显示「没有声明配置项」(主人实测发现)。
5204
+ */
5205
+ function findExtFileInAnyRoot(dataRoot, kind, file) {
5206
+ const name = basename(String(file || ''));
5207
+ if (!name) return '';
5208
+ const roots = [join(dataRoot, '.qqbot-extensions', kind)];
5209
+ const packsRoot = join(dataRoot, 'extension-packs');
5210
+ let packs = [];
5211
+ try { packs = readdirSync(packsRoot); } catch { packs = []; }
5212
+ for (const p of packs) {
5213
+ const d = join(packsRoot, p, kind);
5214
+ try { if (statSync(d).isDirectory()) roots.push(d); } catch { /* 没这个 kind */ }
5215
+ }
5216
+ for (const dir of roots) {
5217
+ const abs = join(dir, name);
5218
+ try { if (statSync(abs).isFile()) return abs; } catch { /* 继续找 */ }
5219
+ }
5220
+ return '';
5221
+ }
5222
+
5223
+ /**
5224
+ * 递归查找(兜底):有些扩展可能放在更深层(如 tools/lib/ 下的辅助模块)
5225
+ */
5226
+ function findExtFileDeep(dataRoot, kind, file) {
5227
+ const name = basename(String(file || ''));
5228
+ if (!name) return '';
5229
+ const bases = [join(dataRoot, '.qqbot-extensions', kind), join(dataRoot, 'extension-packs')];
5230
+ function walk(dir, depth) {
5231
+ if (depth > 4) return '';
5232
+ let entries = [];
5233
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return ''; }
5234
+ for (const e of entries) {
5235
+ const full = join(dir, e.name);
5236
+ if (e.isFile() && e.name === name) return full;
5237
+ }
5238
+ for (const e of entries) {
5239
+ if (!e.isDirectory()) continue;
5240
+ const r = walk(join(dir, e.name), depth + 1);
5241
+ if (r) return r;
5242
+ }
5243
+ return '';
5244
+ }
5245
+ for (const b of bases) {
5246
+ const r = walk(b, 0);
5247
+ if (r) return r;
5248
+ }
5249
+ return '';
5250
+ }
5251
+
5252
+ route(ctx, ['GET', 'POST'], '/api/qqbot-settings/ext-panel', async (req, res) => {
5253
+ try {
5254
+ const q = new URL(req.url, 'http://localhost').searchParams;
5255
+ let kind = '';
5256
+ let file = '';
5257
+ let cwdParam = '';
5258
+ let values = null;
5259
+
5260
+ if (req.method === 'GET') {
5261
+ kind = String(q.get('kind') || '').trim();
5262
+ file = String(q.get('file') || '').trim();
5263
+ cwdParam = String(q.get('cwd') || '').trim();
5264
+ } else {
5265
+ const body = await readJsonBody(req);
5266
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
5267
+ kind = String(body.kind || '').trim();
5268
+ file = String(body.file || '').trim();
5269
+ cwdParam = String(body.cwd || '').trim();
5270
+ values = body.values && typeof body.values === 'object' ? body.values : {};
5271
+ }
5272
+
5273
+ if (!kind || !file) return writeJson(res, 400, { error: 'kind 与 file 必填' });
5274
+
5275
+ // 定位文件(只允许 EXT_KINDS 内的六类,防路径逃逸)
5276
+ const { dataRoot } = resolveDataRootForWorkspace(cwdParam);
5277
+ if (!dataRoot) return writeJson(res, 500, { error: '推不出 dataRoot' });
5278
+ // 内置目录 + 所有扩展包里找(扩展包的扩展不在 .qqbot-extensions 下)
5279
+ let abs = findExtFileInAnyRoot(dataRoot, kind, file);
5280
+ if (!abs) abs = findExtFileDeep(dataRoot, kind, file);
5281
+ if (!abs) {
5282
+ return writeJson(res, 404, { error: '扩展文件不存在:' + file + '(已在内置目录与 extension-packs 下查找)' });
5283
+ }
5284
+
5285
+ const extId = String(file).replace(/\.(mjs|js|cjs)$/i, '');
5286
+
5287
+ if (req.method === 'GET') {
5288
+ const page = await readExtSettingsPage(abs);
5289
+ // 当前值
5290
+ let current = {};
5291
+ const cfgPath = extConfigPath(dataRoot, extId);
5292
+ try { if (cfgPath && existsSync(cfgPath)) current = JSON.parse(readFileSync(cfgPath, 'utf8')); } catch { current = {}; }
5293
+ return writeJson(res, 200, {
5294
+ ok: true,
5295
+ extId,
5296
+ foundAt: abs,
5297
+ file: abs,
5298
+ page: page || { title: '', desc: '', schema: [] },
5299
+ values: current,
5300
+ configPath: cfgPath,
5301
+ });
5302
+ }
5303
+
5304
+ // POST:写回
5305
+ const cfgPath = extConfigPath(dataRoot, extId);
5306
+ if (!cfgPath) return writeJson(res, 400, { error: 'extId 不合法' });
5307
+ try {
5308
+ mkdirSync(dirname(cfgPath), { recursive: true });
5309
+ writeFileSync(cfgPath, JSON.stringify(values, null, 2), 'utf8');
5310
+ } catch (e) {
5311
+ return writeJson(res, 500, { error: '写入失败: ' + String((e && e.message) || e) });
5312
+ }
5313
+ return writeJson(res, 200, { ok: true, configPath: cfgPath });
5314
+ } catch (e) {
5315
+ writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5316
+ }
5317
+ });
5318
+
5319
+ /**
5320
+ * 工作区转义目录名 → 真实路径(与扩展 commands/会话列表.mjs 的 wsPath 同款规则)
5321
+ * --D-newwenjianjia-aiwork-~9CB8~9C7C~5A18-- ⇒ D:\newwenjianjia\aiwork\鲸鱼娘
5322
+ * '-' = 分隔符;'~XXXX' = 一个 UTF-16 码元;首段单字母 = 盘符
5323
+ */
5324
+ /**
5325
+ * `~/.dsh/sessions/{转义目录名}` → 工作区真实路径。
5326
+ *
5327
+ * 2026-10-24 修 issue #19:这里原来自己写了一遍解码(和 decodeSessionDirName 重复),
5328
+ * 而且有三个叠加缺陷:
5329
+ * ① `segs.join(String.fromCharCode(92, 92))` —— 拼出了**两个**反斜杠字符;
5330
+ * ② 分隔符写死反斜杠,POSIX 上直接坏;
5331
+ * ③ 目录名转义时根部的 `/` 就丢了,还原后也没补。
5332
+ * 结果 Android/Linux 上还原成 `storage\\emulated\\0\\大肥鱼` —— 相对路径,
5333
+ * 扩展面板拿它去 readdirSync 必然落空,三块计数全 0。
5334
+ *
5335
+ * 现在直接复用 decodeSessionDirName(那边已经按盘符/POSIX 分支处理过)。
5336
+ */
5337
+ function wsPathFromDir(dir) {
5338
+ return decodeSessionDirName(String(dir || ''));
5339
+ }
5340
+
5341
+ /**
5342
+ * 用 sessionId 反查它属于哪个工作区(DSH 把会话存在 ~/.dsh/sessions/{工作区转义}/{会话id}/)
5343
+ * 返回工作区真实路径;找不到返回 ''。
5344
+ */
5345
+ function resolveWorkspaceBySid(sid) {
5346
+ const id = String(sid || '').trim();
5347
+ if (!id) return '';
5348
+ const home = process.env.USERPROFILE || process.env.HOME || homedir();
5349
+ const root = join(home, '.dsh', 'sessions');
5350
+ let wsDirs = [];
5351
+ try { wsDirs = readdirSync(root); } catch { return ''; }
5352
+ // 会话目录名可能是纯 uuid,也可能带 'session-' 前缀,两种都认
5353
+ const candidates = [id, 'session-' + id];
5354
+ for (const ws of wsDirs) {
5355
+ if (!ws.startsWith('--')) continue;
5356
+ const wsDir = join(root, ws);
5357
+ let subs = [];
5358
+ try { subs = readdirSync(wsDir); } catch { continue; }
5359
+ for (const sub of subs) {
5360
+ if (candidates.includes(sub)) return wsPathFromDir(ws);
5361
+ }
5362
+ }
5363
+ return '';
5364
+ }
5365
+
5366
+ /**
5367
+ * 扩展启停开关(2026-10-24)
5368
+ * POST /api/qqbot-settings/ext-toggle
5369
+ * body: { cwd?, kind, file, enabled }
5370
+ * 写 {dataRoot}/.qqbot-extensions/.state/ext-disabled.json
5371
+ */
5372
+ route(ctx, 'POST', '/api/qqbot-settings/ext-toggle', async (req, res) => {
5373
+ try {
5374
+ const body = await readJsonBody(req);
5375
+ if (!body || typeof body !== 'object') return writeJson(res, 400, { error: 'bad body' });
5376
+ const kind = String(body.kind || '').trim();
5377
+ const file = String(body.file || '').trim();
5378
+ const enabled = body.enabled !== false;
5379
+ const cwdParam = String(body.cwd || '').trim();
5380
+ if (!kind || !file) return writeJson(res, 400, { error: 'kind 与 file 必填' });
5381
+
5382
+ const { dataRoot } = resolveDataRootForWorkspace(cwdParam);
5383
+ if (!dataRoot) return writeJson(res, 500, { error: '推不出 dataRoot' });
5384
+
5385
+ let abs = findExtFileInAnyRoot(dataRoot, kind, file);
5386
+ if (!abs) abs = findExtFileDeep(dataRoot, kind, file);
5387
+ if (!abs) return writeJson(res, 404, { error: '扩展文件不存在:' + file });
5388
+
5389
+ const { readDisabledKeys, writeDisabledKeys, extDisabledKey } = await import('./dist/features/ext-packs.js');
5390
+ const key = extDisabledKey(dataRoot, abs);
5391
+ const set = readDisabledKeys(dataRoot);
5392
+ if (enabled) set.delete(key); else set.add(key);
5393
+ writeDisabledKeys(dataRoot, [...set]);
5394
+
5395
+ // ★ 2026-10-24:立刻热重载,不用重启宿主
5396
+ // ext-reload.ts 是项目里现成的重载注册表(middleware-setup 启动时注册了各 reloader),
5397
+ // 走它就不用自己碰 manager/闭包。工具(tools/)由 SessionManager.reloadAllChannelTools
5398
+ // 负责,这里够不到,所以 tools 的启停仍需 /tools-reload 或重启(回执里说明)。
5399
+ const reloaded = { commands: '', inbound: '', outbound: '', tools: '' };
5400
+ try {
5401
+ const mod = await import('./dist/features/ext-reload.js');
5402
+ if (typeof mod.reloadExtCommands === 'function') {
5403
+ const r = await mod.reloadExtCommands();
5404
+ reloaded.commands = (r && r.error) ? ('失败: ' + r.error) : ((r && r.names) ? r.names.length + ' 个' : 'ok');
5405
+ }
5406
+ if (typeof mod.reloadExtInbound === 'function') {
5407
+ const r = await mod.reloadExtInbound();
5408
+ reloaded.inbound = (r && r.error) ? ('失败: ' + r.error) : 'ok';
5409
+ }
5410
+ if (typeof mod.reloadExtOutbound === 'function') {
5411
+ const r = await mod.reloadExtOutbound();
5412
+ reloaded.outbound = (r && r.error) ? ('失败: ' + r.error) : 'ok';
5413
+ }
5414
+ } catch (e) {
5415
+ reloaded.error = String((e && e.message) || e);
5416
+ }
5417
+
5418
+ // 工具注册表归 SessionManager,只能借 middleware-setup 登记在 globalThis 的 reloader
5419
+ try {
5420
+ const reg = globalThis.__qqbotReloaders;
5421
+ const fn = reg instanceof Map ? (reg.get(dataRoot) || [...reg.values()][0]) : undefined;
5422
+ if (typeof fn === 'function') {
5423
+ const summary = await fn();
5424
+ reloaded.tools = '已重载';
5425
+ reloaded.toolsSummary = String(summary || '').slice(0, 300);
5426
+ } else {
5427
+ reloaded.tools = '需 /tools-reload(没找到 reloader)';
5428
+ }
5429
+ } catch (e) {
5430
+ reloaded.tools = '失败: ' + String((e && e.message) || e);
5431
+ }
5432
+
5433
+ writeJson(res, 200, { ok: true, key, enabled, disabled: [...set], reloaded });
5434
+ } catch (e) {
5435
+ writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5436
+ }
5437
+ });
5438
+
5439
+ // ═══════════════════════════════════════════════════════════════════════════
5440
+ // 插件市场(2026-10-24)
5441
+ //
5442
+ // 索引仓库:zaofan-make/dsh-qqbot-plugins,根目录 index.json:
5443
+ // {
5444
+ // "version": 1,
5445
+ // "updated": "2026-10-24",
5446
+ // "plugins": [
5447
+ // { "id":"demo-author", "name":"示例扩展包", "author":"做早饭", "version":"1.0.0",
5448
+ // "description":"…", "homepage":"…", "repo":"zaofan-make/dsh-qqbot-plugins",
5449
+ // "path":"packs/demo-author", "tags":["示例"], "kinds":["tools","commands"] }
5450
+ // ]
5451
+ // }
5452
+ //
5453
+ // 安装 = 用 GitHub API 递归拉 path 下所有文件,落到 {dataRoot}/extension-packs/{id}/
5454
+ // (不用 zip:Node 没内置解压,tar 不认 zip;API 递归最稳,还能挑文件)
5455
+ // 卸载 = 删目录(先确认它在 extension-packs 下,防误删)
5456
+ // ═══════════════════════════════════════════════════════════════════════════
5457
+
5458
+ // 索引地址:国内直连 raw.githubusercontent.com 常年不通(主人实测 fetch failed),
5459
+ // 所以按顺序试多个源,第一个成功的就用;回执里会带上用的是哪个。
5460
+ const MARKET_REPO = 'zaofan-make/dsh-qqbot-plugins';
5461
+ const MARKET_BRANCH = 'main';
5462
+ // 索引源顺序(2026-10-24 改):**实时源优先**
5463
+ // jsDelivr 对 @main 有缓存,刚合并的提交在它那儿看不到(实测面板一直只有 1 个插件,
5464
+ // 而 raw 上已经是 3 个)。所以索引不能把 jsDelivr 放第一位 ——
5465
+ // 它只留作最后的兜底(CDN 稳,但内容可能滞后)。
5466
+ // 注意:**插件文件**下载仍然用 jsDelivr(文件内容不变,缓存无害,且墙内稳定)。
5467
+ const MARKET_SOURCES = [
5468
+ 'https://gh-proxy.com/https://raw.githubusercontent.com/' + MARKET_REPO + '/' + MARKET_BRANCH + '/index.json',
5469
+ 'https://raw.githubusercontent.com/' + MARKET_REPO + '/' + MARKET_BRANCH + '/index.json',
5470
+ 'https://ghproxy.net/https://raw.githubusercontent.com/' + MARKET_REPO + '/' + MARKET_BRANCH + '/index.json',
5471
+ 'https://cdn.jsdelivr.net/gh/' + MARKET_REPO + '@' + MARKET_BRANCH + '/index.json',
5472
+ ];
5473
+ const MARKET_INDEX_URL = MARKET_SOURCES[0]; // 兼容旧引用(展示用)
5474
+
5475
+ /** 依次试多个索引源,返回 { json, source } */
5476
+ async function fetchMarketIndex() {
5477
+ const errs = [];
5478
+ for (const url of MARKET_SOURCES) {
5479
+ try {
5480
+ const txt = await fetchText(url, 8000);
5481
+ const j = JSON.parse(txt);
5482
+ if (!Array.isArray(j.plugins)) j.plugins = [];
5483
+ return { json: j, source: url };
5484
+ } catch (e) {
5485
+ errs.push(url.split('/')[2] + ': ' + String((e && e.message) || e));
5486
+ }
5487
+ }
5488
+ throw new Error('所有索引源都失败 → ' + errs.join(' | '));
5489
+ }
5490
+
5491
+ /**
5492
+ * jsDelivr 文件地址(2026-10-24)
5493
+ * 墙内 raw.githubusercontent.com / api.github.com / data.jsdelivr.com 都连不上,
5494
+ * 实测只有 cdn.jsdelivr.net 通 —— 文件内容也走它。
5495
+ */
5496
+ function jsdelivrFileUrl(repo, branch, relPath) {
5497
+ const segs = String(relPath).replace(/\\/g, '/').split('/').map(encodeURIComponent).join('/');
5498
+ return 'https://cdn.jsdelivr.net/gh/' + repo + '@' + (branch || 'main') + '/' + segs;
5499
+ }
5500
+
5501
+ /** 把 GitHub raw / API 下载链换成可用镜像(封面图等前端直接用) */
5502
+ function mirrorUrl(url) {
5503
+ if (!url) return url;
5504
+ const s = String(url);
5505
+ if (s.indexOf('raw.githubusercontent.com/') >= 0) {
5506
+ const tail = s.split('raw.githubusercontent.com/')[1]; // owner/repo/branch/path
5507
+ return 'https://cdn.jsdelivr.net/gh/' + tail;
5508
+ }
5509
+ return s;
5510
+ }
5511
+
5512
+ /**
5513
+ * 市场代理配置(2026-10-24 主人要求)
5514
+ *
5515
+ * 背景:墙内 raw.githubusercontent.com / api.github.com / data.jsdelivr.com 全连不上,
5516
+ * 而 Node 原生 fetch **不读 HTTP_PROXY 环境变量**(Node 18+ 就这么设计的),
5517
+ * 面板里点了安装就是一句干巴巴的 "fetch failed"。
5518
+ * 这里给一个手动填代理的口子,填了就所有市场请求都走它。
5519
+ *
5520
+ * 落盘:{dataRoot}/.qqbot-extensions/.state/market-config.json
5521
+ * { "proxy": "http://127.0.0.1:29290" }
5522
+ *
5523
+ * 实现:用 curl 子进程(Windows 10+ 自带 curl.exe;比手写 CONNECT 稳)。
5524
+ * 没配代理时仍走原生 fetch(大陆 CDN 如 cdn.jsdelivr.net 通常直连没问题)。
5525
+ */
5526
+ function marketConfigPath(dataRoot) {
5527
+ return join(dataRoot, '.qqbot-extensions', '.state', 'market-config.json');
5528
+ }
5529
+
5530
+ function readMarketConfig(dataRoot) {
5531
+ try {
5532
+ const p = marketConfigPath(dataRoot);
5533
+ if (!existsSync(p)) return {};
5534
+ const j = JSON.parse(readFileSync(p, 'utf8'));
5535
+ return j && typeof j === 'object' ? j : {};
5536
+ } catch { return {}; }
5537
+ }
5538
+
5539
+ function writeMarketConfig(dataRoot, cfg) {
5540
+ try {
5541
+ const p = marketConfigPath(dataRoot);
5542
+ mkdirSync(dirname(p), { recursive: true });
5543
+ writeFileSync(p, JSON.stringify(cfg, null, 2), 'utf8');
5544
+ return true;
5545
+ } catch { return false; }
5546
+ }
5547
+
5548
+ /** 用 curl 走代理取文本(同步执行,简单可靠) */
5549
+ function curlText(url, proxy, timeoutMs) {
5550
+ // execFileSync 已在文件顶部 import(ESM 里不能用 require)
5551
+ const args = ['-sS', '--max-time', String(Math.ceil((timeoutMs || 15000) / 1000)), '-L'];
5552
+ if (proxy) args.push('-x', proxy);
5553
+ args.push('-H', 'User-Agent: dsh-qqbot-plugin-market', url);
5554
+ const out = execFileSync('curl', args, { encoding: 'buffer', maxBuffer: 32 * 1024 * 1024 });
5555
+ return out.toString('utf8');
5556
+ }
5557
+
5558
+ /** 取 GitHub raw 文本(带超时与 UA,GitHub 要 UA) */
5559
+ async function fetchText(url, timeoutMs) {
5560
+ const ac = new AbortController();
5561
+ const t = setTimeout(() => ac.abort(), timeoutMs || 12000);
5562
+ try {
5563
+ const r = await fetch(url, {
5564
+ signal: ac.signal,
5565
+ headers: { 'User-Agent': 'dsh-qqbot-plugin-market', 'Accept': 'application/vnd.github+json' },
5566
+ });
5567
+ if (!r.ok) throw new Error('HTTP ' + r.status);
5568
+ return await r.text();
5569
+ } finally {
5570
+ clearTimeout(t);
5571
+ }
5572
+ }
5573
+
5574
+ /** 取 GitHub API JSON */
5575
+ async function fetchJson(url, timeoutMs) {
5576
+ const txt = await fetchText(url, timeoutMs);
5577
+ return JSON.parse(txt);
5578
+ }
5579
+
5580
+ /** 递归列出一个仓库子目录下的所有文件(GitHub contents API) */
5581
+ async function listRepoFiles(repo, dir, apiBase) {
5582
+ const out = [];
5583
+ const base = apiBase || 'https://api.github.com';
5584
+ async function walk(d, depth) {
5585
+ if (depth > 6) return;
5586
+ const items = await fetchJson(base + '/repos/' + repo + '/contents/' + encodeURI(d));
5587
+ if (!Array.isArray(items)) return;
5588
+ for (const it of items) {
5589
+ if (it.type === 'file' && it.download_url) out.push({ path: it.path, url: it.download_url, size: it.size || 0 });
5590
+ else if (it.type === 'dir') await walk(it.path, depth + 1);
5591
+ }
5592
+ }
5593
+ await walk(dir, 0);
5594
+ return out;
5595
+ }
5596
+
5597
+ /**
5598
+ * GET /api/qqbot-settings/market?cwd=
5599
+ * → { ok, index:{plugins:[…]}, installed:['demo-author'…], packsRoot, indexUrl }
5600
+ */
5601
+ route(ctx, 'GET', '/api/qqbot-settings/market', async (req, res) => {
5602
+ try {
5603
+ const q = new URL(req.url, 'http://localhost').searchParams;
5604
+ const cwdParam = String(q.get('cwd') || '').trim();
5605
+ const { dataRoot } = resolveDataRootForWorkspace(cwdParam);
5606
+ if (!dataRoot) return writeJson(res, 500, { error: '推不出 dataRoot' });
5607
+ const packsRoot = join(dataRoot, 'extension-packs');
5608
+ const mcfg = readMarketConfig(dataRoot);
5609
+ const curProxy = String(mcfg.proxy || '');
5610
+
5611
+ let index = { plugins: [] };
5612
+ let indexErr = '';
5613
+ let indexSource = '';
5614
+ try {
5615
+ const r = curProxy
5616
+ ? { json: JSON.parse(curlText(MARKET_SOURCES[0], curProxy, 15000)), source: MARKET_SOURCES[0] + '(走代理 ' + curProxy + ')' }
5617
+ : await fetchMarketIndex();
5618
+ index = r.json;
5619
+ indexSource = r.source;
5620
+ // 封面等图片地址换成镜像,否则前端在墙内显示不出来
5621
+ for (const p of index.plugins) if (p && p.cover) p.cover = mirrorUrl(p.cover);
5622
+ } catch (e) {
5623
+ indexErr = String((e && e.message) || e);
5624
+ }
5625
+
5626
+ // installed 带上本地版本,前端才能判断「有没有新版可升」
5627
+ let installed = [];
5628
+ try {
5629
+ installed = readdirSync(packsRoot)
5630
+ .filter((n) => statSync(join(packsRoot, n)).isDirectory())
5631
+ .map((n) => {
5632
+ let version = '';
5633
+ try {
5634
+ const mf = join(packsRoot, n, 'qqbot-pack.json');
5635
+ if (existsSync(mf)) version = String(JSON.parse(readFileSync(mf, 'utf8')).version || '');
5636
+ } catch {}
5637
+ return { id: n, version };
5638
+ });
5639
+ } catch { installed = []; }
5640
+
5641
+ writeJson(res, 200, { ok: true, index, indexErr, indexSource, proxy: curProxy, installed, packsRoot, indexUrl: MARKET_INDEX_URL + '(多源,实际用: ' + (indexSource || '无') + ')' });
5642
+ } catch (e) {
5643
+ writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5644
+ }
5645
+ });
5646
+
5647
+ /**
5648
+ * POST /api/qqbot-settings/market/install
5649
+ * body: { cwd?, id }
5650
+ * 按索引里的 repo/path 拉全部文件,写到 extension-packs/{id}/
5651
+ */
5652
+ /**
5653
+ * POST /api/qqbot-settings/market/config
5654
+ * body: { cwd?, proxy }
5655
+ * 存市场配置(目前只有 proxy)
5656
+ */
5657
+ route(ctx, 'POST', '/api/qqbot-settings/market/config', async (req, res) => {
5658
+ try {
5659
+ const body = await readJsonBody(req);
5660
+ const cwdParam = String((body && body.cwd) || '').trim();
5661
+ const { dataRoot } = resolveDataRootForWorkspace(cwdParam);
5662
+ if (!dataRoot) return writeJson(res, 500, { error: '推不出 dataRoot' });
5663
+ const cfg = readMarketConfig(dataRoot);
5664
+ if (body && typeof body.proxy === 'string') cfg.proxy = body.proxy.trim();
5665
+ const ok = writeMarketConfig(dataRoot, cfg);
5666
+ writeJson(res, ok ? 200 : 500, { ok, config: cfg, path: marketConfigPath(dataRoot) });
5667
+ } catch (e) {
5668
+ writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5669
+ }
5670
+ });
5671
+
5672
+ route(ctx, 'POST', '/api/qqbot-settings/market/install', async (req, res) => {
5673
+ try {
5674
+ const rawType = String((req.headers && req.headers['content-type']) || '');
5675
+ const body = await readJsonBody(req);
5676
+ const id = String((body && body.id) || '').trim();
5677
+ const cwdParam = String((body && body.cwd) || '').trim();
5678
+ if (!body || typeof body !== 'object') {
5679
+ return writeJson(res, 400, { stage: 'parse', error: '请求体解析失败(body 为空或不是 JSON)', contentType: rawType });
5680
+ }
5681
+ if (!id) {
5682
+ return writeJson(res, 400, { stage: 'id', error: 'id 必填', got: body });
5683
+ }
5684
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
5685
+ return writeJson(res, 400, { stage: 'id-format', error: 'id 只允许字母数字-_', got: id });
5686
+ }
5687
+
5688
+ const { dataRoot } = resolveDataRootForWorkspace(cwdParam);
5689
+ if (!dataRoot) return writeJson(res, 500, { error: '推不出 dataRoot' });
5690
+
5691
+ // 拉索引找到这一条
5692
+ const idxR = await fetchMarketIndex();
5693
+ const idx = idxR.json;
5694
+ const item = (idx.plugins || []).find((p) => p && p.id === id);
5695
+ if (!item) return writeJson(res, 404, { error: '索引里没有这个插件: ' + id, indexSource: idxR.source, ids: (idx.plugins || []).map((x) => x && x.id) });
5696
+ if (!item.repo || !item.path) return writeJson(res, 400, { stage: 'repo-path', error: '索引条目缺 repo/path', item });
5697
+
5698
+ // 文件清单来自索引的 files 字段 —— 不走 GitHub 目录 API(墙内连不上)。
5699
+ const list = Array.isArray(item.files) ? item.files : [];
5700
+ if (!list.length) {
5701
+ return writeJson(res, 400, {
5702
+ stage: 'files',
5703
+ error: '索引条目缺 files 清单',
5704
+ indexSource: idxR.source,
5705
+ itemKeys: Object.keys(item),
5706
+ item,
5707
+ });
5708
+ }
5709
+
5710
+ const dest = join(dataRoot, 'extension-packs', id);
5711
+ mkdirSync(dest, { recursive: true });
5712
+ const branch = item.branch || 'main';
5713
+ const destNorm = dest.replace(/\\/g, '/');
5714
+
5715
+ // 并发下载(原来串行 + 每个文件起一个 curl 进程,几 KB 也要好几秒)
5716
+ const mcfg2 = readMarketConfig(dataRoot);
5717
+ const px = String(mcfg2.proxy || '');
5718
+ const jobs = list.map(async (rel) => {
5719
+ const relPath = String(rel).replace(/^\.?\/+/, '');
5720
+ if (!relPath) return { ok: false, rel, err: '空路径' };
5721
+ const abs = join(dest, relPath);
5722
+ if (!abs.replace(/\\/g, '/').startsWith(destNorm + '/')) return { ok: false, rel, err: '路径越界' };
5723
+ try {
5724
+ mkdirSync(dirname(abs), { recursive: true });
5725
+ const url = jsdelivrFileUrl(item.repo, branch, String(item.path) + '/' + relPath);
5726
+ const content = px ? curlText(url, px, 20000) : await fetchText(url, 20000);
5727
+ writeFileSync(abs, content, 'utf8');
5728
+ return { ok: true, rel };
5729
+ } catch (e) {
5730
+ return { ok: false, rel, err: String((e && e.message) || e) };
5731
+ }
5732
+ });
5733
+ const results = await Promise.all(jobs);
5734
+ let written = 0; const failed = [];
5735
+ for (const r of results) {
5736
+ if (r.ok) written++;
5737
+ else failed.push(r.rel + ': ' + (r.err || '未知'));
5738
+ }
5739
+
5740
+ writeJson(res, 200, { ok: written > 0, written, total: files.length, failed, dest });
5741
+ } catch (e) {
5742
+ writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5743
+ }
5744
+ });
5745
+
5746
+ /**
5747
+ * POST /api/qqbot-settings/market/remove
5748
+ * body: { cwd?, id }
5749
+ */
5750
+ route(ctx, 'POST', '/api/qqbot-settings/market/remove', async (req, res) => {
5751
+ try {
5752
+ const body = await readJsonBody(req);
5753
+ const id = String((body && body.id) || '').trim();
5754
+ const cwdParam = String((body && body.cwd) || '').trim();
5755
+ if (!id) return writeJson(res, 400, { error: 'id 必填' });
5756
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) return writeJson(res, 400, { error: 'id 只允许字母数字-_' });
5757
+
5758
+ const { dataRoot } = resolveDataRootForWorkspace(cwdParam);
5759
+ if (!dataRoot) return writeJson(res, 500, { error: '推不出 dataRoot' });
5760
+ const packsRoot = join(dataRoot, 'extension-packs');
5761
+ const target = join(packsRoot, id);
5762
+ const norm = (p) => p.replace(/\\/g, '/');
5763
+ if (!norm(target).startsWith(norm(packsRoot) + '/')) return writeJson(res, 400, { error: '路径越界' });
5764
+ if (!existsSync(target)) return writeJson(res, 404, { error: '没装: ' + id });
5765
+
5766
+ rmSync(target, { recursive: true, force: true });
5767
+ writeJson(res, 200, { ok: true, removed: target });
5768
+ } catch (e) {
5769
+ writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5770
+ }
5771
+ });
5772
+
5773
+ route(ctx, 'GET', '/api/qqbot-settings/extensions', async (req, res) => {
5774
+ try {
5775
+ // ★ 2026-10-24 修:优先用 ?cwd=(当前会话的工作区),扩展目录跟着工作区走
5776
+ // 原先只用 resolveContextlessDataRoot()(=首个 bot 实例的 dataRoot),
5777
+ // 切换会话到别的工作区后面板还指向老工作区,主人实测发现。
5778
+ // 工作区来源优先级:?cwd= > ?sid= 反查 > 默认 dataRoot
5779
+ let cwdParam = '';
5780
+ let sidParam = '';
5781
+ try {
5782
+ const q = new URL(req.url, 'http://localhost').searchParams;
5783
+ cwdParam = String(q.get('cwd') || '').trim();
5784
+ sidParam = String(q.get('sid') || '').trim();
5785
+ } catch { /* ignore */ }
5786
+ let wsResolvedBy = cwdParam ? 'cwd' : '';
5787
+ if (!cwdParam && sidParam) {
5788
+ const bySid = resolveWorkspaceBySid(sidParam);
5789
+ if (bySid) { cwdParam = bySid; wsResolvedBy = 'sid'; }
5790
+ }
5791
+ // ★ 尊重 bot 配置里的 dataRoot(主人 2026-10-24 指出)
5792
+ const _dr = resolveDataRootForWorkspace(cwdParam);
5793
+ const dataRoot = _dr.dataRoot;
5794
+ if (!dataRoot) return writeJson(res, 200, { ok: false, error: '推不出 dataRoot(还没绑过机器人?)' });
5795
+
5796
+ const builtinRoot = join(dataRoot, '.qqbot-extensions');
5797
+ const builtin = scanExtRoot(builtinRoot, dataRoot);
5798
+
5799
+ // ① 内置目录
5800
+ const result = {
5801
+ ok: true,
5802
+ dataRoot,
5803
+ workspaceCwd: cwdParam || '',
5804
+ workspaceFrom: wsResolvedBy || 'default',
5805
+ dataRootFrom: _dr.source,
5806
+ builtinRoot,
5807
+ builtin,
5808
+ packsRoot: join(dataRoot, 'extension-packs'),
5809
+ packs: [],
5810
+ };
5811
+
5812
+ // ② 扩展包:扫 packsRoot 下每个子目录
5813
+ const packsRoot = result.packsRoot;
5814
+ let packNames = [];
5815
+ try { packNames = readdirSync(packsRoot); } catch { packNames = []; }
5816
+ for (const name of packNames) {
5817
+ const dir = join(packsRoot, name);
5818
+ let st = null;
5819
+ try { st = statSync(dir); } catch { continue; }
5820
+ if (!st.isDirectory()) continue;
5821
+
5822
+ // 清单文件(可选)
5823
+ let meta = {};
5824
+ for (const f of ['qqbot-pack.json', 'pack.json', 'package.json']) {
5825
+ const mf = join(dir, f);
5826
+ if (!existsSync(mf)) continue;
5827
+ try {
5828
+ const j = JSON.parse(readFileSync(mf, 'utf8'));
5829
+ meta = {
5830
+ name: j.name || j.displayName || '',
5831
+ author: j.author || (j.author && j.author.name) || '',
5832
+ desc: j.description || j.desc || '',
5833
+ version: j.version || '',
5834
+ homepage: j.homepage || j.repository || '',
5835
+ };
5836
+ break;
5837
+ } catch { /* 清单坏了就当没有 */ }
5838
+ }
5839
+
5840
+ const scanned = scanExtRoot(dir, dataRoot);
5841
+ if (scanned.total === 0) continue; // 空目录不显示
5842
+ result.packs.push({
5843
+ dirName: name,
5844
+ dir,
5845
+ meta,
5846
+ kinds: scanned.kinds,
5847
+ total: scanned.total,
5848
+ });
5849
+ }
5850
+ result.packs.sort((a, b) => a.dirName.localeCompare(b.dirName));
5851
+
5852
+ // ③ 汇总:哪几个扩展声明了设置页
5853
+ const settingsPages = [];
5854
+ const collect = (list, from) => {
5855
+ for (const it of list) {
5856
+ if (it.hasSettings) settingsPages.push({ from, id: it.id, title: it.settingsTitle || it.id });
5857
+ }
5858
+ };
5859
+ for (const k of EXT_KINDS) collect(builtin.kinds[k], 'builtin');
5860
+ for (const p of result.packs) {
5861
+ for (const k of EXT_KINDS) collect(p.kinds[k], p.meta.name || p.dirName);
5862
+ }
5863
+ result.settingsPages = settingsPages;
5864
+
5865
+ // ★ 2026-10-24:统一补启停状态(scanExtKind 是顶层函数,拿不到路由里的 disabledSet)
5866
+ let _ds = new Set();
5867
+ try {
5868
+ const m = await import('./dist/features/ext-packs.js');
5869
+ _ds = m.readDisabledKeys(dataRoot);
5870
+ } catch { /* 拿不到就当全启用 */ }
5871
+ const _root = String(dataRoot).split('\\').join('/').split('/').join('/');
5872
+ const _mark = (list) => {
5873
+ for (const it of list) {
5874
+ const abs = it.__abs || '';
5875
+ const rel = abs.split('\\').join('/').split('/').join('/');
5876
+ const key = rel.indexOf(_root + '/') === 0 ? rel.slice(_root.length + 1) : abs;
5877
+ it.disabled = _ds.has(key);
5878
+ }
5879
+ };
5880
+ for (const k of EXT_KINDS) _mark(builtin.kinds[k] || []);
5881
+ for (const pk of result.packs) for (const k of EXT_KINDS) _mark((pk.kinds && pk.kinds[k]) || []);
5882
+
5883
+
5884
+ writeJson(res, 200, result);
5885
+ } catch (e) {
5886
+ writeJson(res, 500, { ok: false, error: String((e && e.message) || e) });
5887
+ }
5888
+ });
5889
+
5815
5890
  // ── 🚀 群发任务「后台自动推进」(2026-09-10 修: 原设计靠 client 每次 list 驱动, 页面不动就永久卡 queued) ──
5816
5891
  // 每隔 BC_TICK_MS 扫全部实例, 有 queued/sending 任务就推进一步(串行 → 天然限频); 单实例失败不影响其他实例。
5817
5892
  // 节流依据(**官方文档已核对**, 群发走的是"主动消息"):