qq-bot-roost 0.8.6 → 0.8.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,11 +6,11 @@
6
6
 
7
7
  **填 AppID + AppSecret 就能连上 QQ 官方机器人。零依赖,插件可写任意语言,自带可视化面板。**
8
8
 
9
- <img src="https://img.shields.io/badge/Node.js-22%2B-339933?logo=node.js&logoColor=white" alt="Node.js 22+">
9
+ <img src="https://img.shields.io/badge/Node.js-22.13%2B-339933?logo=node.js&logoColor=white" alt="Node.js 22.13+">
10
10
  <img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
11
- <img src="https://img.shields.io/badge/tests-551%20passed-brightgreen" alt="测试 551 项">
11
+ <img src="https://img.shields.io/badge/tests-600%2B%20passed-brightgreen" alt="测试 600+ 项">
12
12
  <img src="https://img.shields.io/badge/plugins-JS%20%7C%20Python%20%7C%20Go%20%7C%20any-blue" alt="多语言插件">
13
- <img src="https://img.shields.io/badge/version-0.6.14-blueviolet" alt="版本 0.6.14">
13
+ <img src="https://img.shields.io/badge/version-0.8.8-blueviolet" alt="版本 0.8.8">
14
14
  <img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
15
15
 
16
16
  [快速开始](#3-分钟跑起来) | [写插件](docs/插件开发规范.md) | [外部插件协议](docs/插件协议.md) | [配置参考](docs/配置参考.md) | [常见问题](docs/常见问题.md) | [贡献](CONTRIBUTING.md)
@@ -106,6 +106,10 @@
106
106
  - **面板页能读自己的数据**:`ctx.registerRoute()` 注册 REST 接口(`/api/plugins/<id>/r/…`),
107
107
  鉴权/CSRF/同源校验全复用面板那套,插件拿不到裸 req/res
108
108
  - **元数据能表达兼容性**:`frameworkVersion` 声明需要的框架版本,不满足时**在加载前拦住**并说清原因,而不是跑起来才报「xxx 不是函数」
109
+ - **插件能给 AI 贡献工具(0.8.6+)**:`ctx.registerTool()` 注册一个带 JSON Schema 的工具
110
+ (名字全局唯一,建议带插件前缀),别的插件用 `ctx.tools()` 拿清单、`ctx.callTool()` 直接调用。
111
+ 于是「AI 能查斗罗玩家数据」这种事,**AI 插件和斗罗插件两边不用互相认识**——都只认这张工具表。
112
+ 工具还能标 `ownerOnly` / `admin` 限权限,`timeoutMs` 限超时,插件卸载时自动注销
109
113
 
110
114
  **统计**
111
115
 
@@ -142,16 +146,20 @@
142
146
 
143
147
  ## 3 分钟跑起来
144
148
 
145
- ### 1. 装 Node.js 22+
149
+ ### 1. 装 Node.js 22.13+
146
150
 
147
151
  ```bash
148
- node -v # 需要 >= 22
152
+ node -v # 需要 >= 22.13
149
153
  ```
150
154
 
151
155
  > 用到了 Node 22 内置的全局 `WebSocket`,所以不用装 `ws`,也不需要 `npm install`。
152
156
 
153
157
  ### 2. 拿凭证
154
158
 
159
+ **扫码最省事(0.8.4+)**:面板 → 「新建机器人」 → 点「**扫码一键创建**」,
160
+ 用手机 QQ 扫一下,AppID / AppSecret **自动填好**,不用去网页复制。
161
+ 扫码用不了、或已有机器人时,同一处还有「或者手动填」:
162
+
155
163
  1. <https://q.qq.com> → 创建机器人
156
164
  2. 「开发设置」里拿到 **AppID** 和 **AppSecret**
157
165
  3. **关键**:「功能配置」里开通 **群聊** 能力,否则连接会被拒(关闭码 4014)
@@ -358,6 +366,8 @@ export default {
358
366
  | `ctx.storage.get/set/all` | 插件私有存档 |
359
367
  | `ctx.config` / `ctx.setConfig()` | 插件配置读写 |
360
368
  | `ctx.can` / `ctx.levelOf` / `ctx.scopeOf` | 权限判断 |
369
+ | `ctx.registerTool(工具)` | 注册一个 AI 可调用的工具(跨插件,0.8.6+),返回注销函数 |
370
+ | `ctx.tools()` / `ctx.callTool(名, 参数)` | 列出 / 调用别插件注册的工具 |
361
371
  | `msg.reply` / `push` / `replyMarkdown` / `replyImage` / … | 各种发送方式 |
362
372
 
363
373
  ---
@@ -400,6 +410,7 @@ bot/
400
410
  │ ├── gateway.js WebSocket 网关
401
411
  │ ├── webhook.js Webhook 接收服务(多机器人共用一个端口)
402
412
  │ ├── sign.js Webhook 回调签名(Ed25519)
413
+ │ ├── qq/ 扫码一键建机器人(qrlogin.js / qrsvg.js)
403
414
  │ ├── tarball.js tar.gz 下载解包(不依赖 git)
404
415
  │ ├── updater.js 框架自身检查更新与升级
405
416
  │ ├── semver.js 版本比较与范围匹配(框架更新/市场/兼容校验共用)
@@ -412,8 +423,9 @@ bot/
412
423
  │ ├── web/server.js 面板服务端(REST + SSE)
413
424
  │ └── plugin/ 插件加载、配置、依赖、子进程宿主、面板页面注册
414
425
  ├── test/
415
- │ ├── selftest.mjs 框架自检(322 项)
416
- │ └── e2e.mjs 端到端测试(122 项,起真实服务打接口)
426
+ │ ├── selftest.mjs 框架自检(474 项)
427
+ │ ├── e2e.mjs 端到端测试(147 项,起真实服务打接口)
428
+ │ └── qrlogin.mjs 扫码建机器人(10 项)
417
429
  ├── web/ 面板前端(原生 JS/CSS,无构建)
418
430
  └── docs/
419
431
  ```
@@ -458,9 +470,9 @@ openid 完全不同。共享 `roles.json` 等于所有权限记录全部对不
458
470
  ## 开发
459
471
 
460
472
  ```bash
461
- npm test # 框架自检 345 项
462
- npm run test:e2e # 端到端 150 项(起 mock 服务打全部接口,跑在临时目录)
463
- npm run test:all # 两个都跑
473
+ npm test # 自检 484 项(selftest 474 + qrlogin 10)
474
+ npm run test:e2e # 端到端 147 项(起 mock 服务打全部接口,跑在临时目录)
475
+ npm run test:all # 全量约 631 项:自检 + 扫码 + e2e(真机驱动要真机,没配就跳过)
464
476
  npm run check:secrets # 扫硬编码凭据(CI 也跑这个,提交前建议过一遍)
465
477
  node src/main.js --mock # 不连 QQ,命令行发假消息调插件
466
478
  node src/main.js --mock --serve # 只开面板,调界面
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.8.6",
3
+ "version": "0.8.8",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -29,9 +29,9 @@
29
29
  "start": "node src/main.js",
30
30
  "mock": "node src/main.js --mock",
31
31
  "check": "node src/main.js --check",
32
- "test": "node test/selftest.mjs && node test/qrlogin.mjs",
32
+ "test": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs",
33
33
  "test:e2e": "node test/e2e.mjs",
34
- "test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/e2e.mjs && node test/live-driver.mjs",
34
+ "test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node test/e2e.mjs && node test/live-driver.mjs",
35
35
  "check:secrets": "node scripts/check-secrets.mjs",
36
36
  "create": "node scripts/create-plugin.mjs",
37
37
  "test:plugin": "node scripts/test-plugin.mjs",
@@ -1,5 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { existsSync, readdirSync } from 'node:fs';
2
3
  import fs from 'node:fs/promises';
4
+ import os from 'node:os';
3
5
  import path from 'node:path';
4
6
  import { createHash } from 'node:crypto';
5
7
  import { rel } from '../paths.js';
@@ -74,9 +76,65 @@ async function usable(cmd, args = ['--version']) {
74
76
 
75
77
  let cachedRuntimes = null;
76
78
 
79
+ const IS_WIN = process.platform === 'win32';
80
+
81
+ /** npm 的安装参数。`--ignore-scripts` 见下面调用处的注释 */
82
+ const NPM_ARGS = ['install', '--omit=dev', '--no-audit', '--no-fund', '--ignore-scripts'];
83
+
84
+ /**
85
+ * pnpm 的安装参数。
86
+ *
87
+ * `--config.node-linker=hoisted`:默认的 pnpm 用软链把 `node_modules` 指到全局
88
+ * store,插件是被人手动丢进 `plugins/` 的,不归 pnpm 管;store 一清,
89
+ * `import` 就断。hoisted 给的是跟 npm 一样的扁平目录,行为最可预期。
90
+ *
91
+ * `--ignore-workspace`:插件目录万一落在某个 pnpm workspace 里面(用户把
92
+ * Roost 放在自己项目里就会),pnpm 会拒绝安装并让你去 workspace 根装。
93
+ */
94
+ const PNPM_ARGS = ['install', '--prod', '--ignore-scripts', '--config.node-linker=hoisted', '--ignore-workspace'];
95
+
96
+ /**
97
+ * DSH 桌面版自带的运行时目录。
98
+ *
99
+ * **为什么必须找它**:DSH 是 Electron,安装目录里既没有 `node.exe` 也没有
100
+ * npm;但它自己带了一整套运行时(node / pnpm / python),放在
101
+ * `~/.dsh/dsh-runtimes/<runtime>/dependencies/` 下——DSH 装插件用的就是那套 pnpm。
102
+ *
103
+ * 只查 PATH 的后果很严重:没单独装过 Node.js 的用户 PATH 里当然没有 npm,
104
+ * 于是任何带依赖的插件都会「缺少 npm」而**整个加载失败**,
105
+ * 明明机器上有一套现成能用的。
106
+ */
107
+ export function bundledRuntimeRoots({ home = os.homedir() } = {}) {
108
+ const base = path.join(home, '.dsh', 'dsh-runtimes');
109
+ const out = [];
110
+ try {
111
+ for (const name of readdirSync(base)) {
112
+ const dir = path.join(base, name, 'dependencies');
113
+ if (existsSync(dir)) out.push(dir);
114
+ }
115
+ } catch {
116
+ // 没有这个目录很正常:用户可能根本没装 DSH 桌面版
117
+ }
118
+ // 名字里带 primary 的排前面——DSH 自己也优先用它
119
+ return out.sort((a, b) => Number(b.includes('primary')) - Number(a.includes('primary')));
120
+ }
121
+
122
+ /** 在若干运行时根目录里找同一个相对路径,找到就返回绝对路径 */
123
+ function pickIn(roots, ...segs) {
124
+ for (const r of roots) {
125
+ const p = path.join(r, ...segs);
126
+ if (existsSync(p)) return p;
127
+ }
128
+ return null;
129
+ }
130
+
77
131
  /**
78
- * 找出这台机器上能用的 Python / Node 命令。
132
+ * 找出这台机器上能用的 Python / Node 命令,以及「用谁来装 Node 依赖」。
79
133
  * 只在第一次调用时真正探测,之后走缓存。
134
+ *
135
+ * 返回里的 `installer` 是个描述符 `{ kind, cmd, args, label }`——
136
+ * 因为 npm 和 pnpm 的参数不一样,而且 DSH 自带的 pnpm 还得靠自带的 node 去跑,
137
+ * 光有一个命令名表达不了。
80
138
  */
81
139
  export async function detectRuntimes({ refresh = false } = {}) {
82
140
  if (cachedRuntimes && !refresh) return cachedRuntimes;
@@ -103,7 +161,50 @@ export async function detectRuntimes({ refresh = false } = {}) {
103
161
  }
104
162
  }
105
163
 
106
- cachedRuntimes = { python, npm, node: process.execPath };
164
+ // PATH 上的 pnpm:装了 Node.js 的人常常也装了它,参数比 npm 好控
165
+ let pnpm = null;
166
+ for (const c of IS_WIN ? ['pnpm.cmd', 'pnpm'] : ['pnpm']) {
167
+ if (await usable(c)) {
168
+ pnpm = c;
169
+ break;
170
+ }
171
+ }
172
+
173
+ /* ---------- DSH 自带运行时(PATH 上找不到时的兜底) ---------- */
174
+ const roots = bundledRuntimeRoots();
175
+ const bundledNode = pickIn(roots, 'node', 'bin', IS_WIN ? 'node.exe' : 'node');
176
+ const bundledPnpm = pickIn(roots, 'pnpm', 'bin', 'pnpm.cjs');
177
+ const bundledPython = IS_WIN
178
+ ? pickIn(roots, 'python', 'python.exe')
179
+ : (pickIn(roots, 'python', 'bin', 'python3') || pickIn(roots, 'python', 'bin', 'python'));
180
+
181
+ // 系统 Python 优先(用户可能装了带自己库的那个),找不到才用自带的
182
+ if (!python && bundledPython) python = bundledPython;
183
+
184
+ /**
185
+ * 装依赖用哪个工具。顺序:npm → pnpm → DSH 自带 pnpm。
186
+ *
187
+ * 自带 pnpm 必须用**自带的 node** 去跑 `pnpm.cjs`,不能用 `process.execPath`:
188
+ * 那是 DSH 的 Electron 主程序,只有在带 `ELECTRON_RUN_AS_NODE` 时才当 node 用,
189
+ * 而这个变量不在 `buildPluginEnv` 的白名单里,直接跑会把 GUI 拉起来。
190
+ */
191
+ let installer = null;
192
+ if (npm) {
193
+ installer = { kind: 'npm', cmd: npm, args: NPM_ARGS, label: 'npm' };
194
+ } else if (pnpm) {
195
+ installer = { kind: 'pnpm', cmd: pnpm, args: PNPM_ARGS, label: 'pnpm' };
196
+ } else if (bundledPnpm && bundledNode) {
197
+ installer = { kind: 'dsh-pnpm', cmd: bundledNode, args: [bundledPnpm, ...PNPM_ARGS], label: 'DSH 自带 pnpm' };
198
+ }
199
+
200
+ cachedRuntimes = {
201
+ python,
202
+ npm,
203
+ pnpm,
204
+ node: process.execPath,
205
+ installer,
206
+ bundled: { node: bundledNode, pnpm: bundledPnpm, python: bundledPython },
207
+ };
107
208
  return cachedRuntimes;
108
209
  }
109
210
 
@@ -146,15 +247,22 @@ function venvPython(venvDir) {
146
247
  * @param {object} p.manifest plugin.json 内容
147
248
  * @param {object} p.log 日志器
148
249
  * @param {boolean} [p.enabled] 是否允许自动安装(来自 config.plugins.autoInstallDeps)
250
+ * @param {object} [p.runtimes] 直接指定运行时探测结果,跳过 detectRuntimes()。
251
+ * **只给测试用**:`detectRuntimes()` 带全局缓存,测「一个工具都没有」这条分支
252
+ * 只能靠伪造 PATH / HOME,会把别的测试带歪。
149
253
  * @returns {Promise<{python?:string, installed:string[], skipped:string[], warnings:string[]}>}
150
254
  */
151
- export async function ensurePluginDeps({ dir, manifest = {}, log = null, enabled = true }) {
255
+ export async function ensurePluginDeps({ dir, manifest = {}, log = null, enabled = true, runtimes = null }) {
152
256
  const result = { python: null, installed: [], skipped: [], warnings: [] };
153
257
  // 统一转绝对路径:下面给 pip / npm 传的路径和 cwd 都以它为准,避免相对路径被拼两次
154
258
  dir = path.resolve(dir);
155
259
  const runtime = String(manifest.runtime ?? '').toLowerCase();
156
260
  const wantsPython = runtime === 'python' || runtime === 'python3';
157
261
 
262
+ // 一次探测,两处用;注入的优先
263
+ let rt = runtimes;
264
+ const getRt = async () => (rt ??= await detectRuntimes());
265
+
158
266
  const reqFile = path.join(dir, 'requirements.txt');
159
267
  const pkgFile = path.join(dir, 'package.json');
160
268
  const requirements = await readIfExists(reqFile);
@@ -162,10 +270,10 @@ export async function ensurePluginDeps({ dir, manifest = {}, log = null, enabled
162
270
 
163
271
  // 先确定 Python 解释器:有 venv 用 venv 的,没有就找系统的
164
272
  if (wantsPython) {
165
- const rt = await detectRuntimes();
273
+ const rt = await getRt();
166
274
  if (!rt.python) {
167
275
  throw new Error(
168
- '这个插件需要 Python,但系统里找不到 python / python3 / py。\n' +
276
+ '这个插件需要 Python,但系统里找不到 python / python3 / py,也没找到 DSH 自带的 Python。\n' +
169
277
  '请先安装 Python 3.9+(https://www.python.org/downloads/),装完重试。',
170
278
  );
171
279
  }
@@ -250,8 +358,20 @@ export async function ensurePluginDeps({ dir, manifest = {}, log = null, enabled
250
358
  } catch {
251
359
  result.warnings.push('package.json 不是合法 JSON,已跳过依赖安装');
252
360
  }
253
- const deps = pkg ? Object.keys({ ...(pkg.dependencies ?? {}), ...(pkg.optionalDependencies ?? {}) }) : [];
254
- if (deps.length) {
361
+
362
+ /**
363
+ * **必装和可选必须分开。**
364
+ *
365
+ * `dependencies` 缺了插件就是坏的;`optionalDependencies` 缺了只是某个功能
366
+ * 用不了。以前两者合并成一个列表,于是「语音这种可选能力」能把整个插件
367
+ * 拦在门外:找不到 npm 就 throw,`loader.js` 接住之后插件直接不加载。
368
+ * ai-chat 就是这么在没装 Node.js 的机器上整个起不来的。
369
+ */
370
+ const required = pkg ? Object.keys(pkg.dependencies ?? {}) : [];
371
+ const optional = pkg ? Object.keys(pkg.optionalDependencies ?? {}) : [];
372
+ const all = [...new Set([...required, ...optional])];
373
+
374
+ if (all.length) {
255
375
  const hasModules = await fs
256
376
  .access(path.join(dir, 'node_modules'))
257
377
  .then(() => true)
@@ -265,27 +385,41 @@ export async function ensurePluginDeps({ dir, manifest = {}, log = null, enabled
265
385
  '检测到 package.json 依赖需要安装,但 config 里 plugins.autoInstallDeps = false,已跳过',
266
386
  );
267
387
  } else {
268
- const rt = await detectRuntimes();
269
- if (!rt.npm) {
270
- throw new Error('这个插件需要 npm 安装依赖,但系统里找不到 npm(通常随 Node.js 一起安装)');
271
- }
272
- log?.info(`安装 Node 依赖(${deps.length} 个),首次会慢一点…`);
273
- // --ignore-scripts:依赖的 postinstall 就是任意代码执行入口,
274
- // 装依赖不该顺带跑别人的安装脚本
275
- const r = await run(
276
- rt.npm,
277
- ['install', '--omit=dev', '--no-audit', '--no-fund', '--ignore-scripts'],
278
- { cwd: dir },
279
- );
280
- if (!r.ok) {
281
- throw new Error(
282
- `安装 Node 依赖失败:${r.reason || (r.err || r.out).slice(-600)}\n` +
283
- `可以手动执行:cd ${rel(dir)} && npm install`,
388
+ const rt = await getRt();
389
+ if (!rt.installer) {
390
+ // 必装缺工具才算致命;可选缺工具只丢功能,不能让插件起不来
391
+ if (required.length) {
392
+ throw new Error(
393
+ `这个插件需要 ${required.length} 个 Node 依赖(${required.join('、')}),`
394
+ + '但系统里既没有 npm 也没有 pnpm,也没找到 DSH 自带的运行时。\n'
395
+ + '装一个 Node.js(https://nodejs.org/)再重启即可。',
396
+ );
397
+ }
398
+ result.warnings.push(
399
+ `有 ${optional.length} 个可选依赖(${optional.join('、')})没装:`
400
+ + '找不到 npm / pnpm,也没找到 DSH 自带的运行时。相关功能会不可用,其余功能正常。',
401
+ );
402
+ } else {
403
+ log?.info(
404
+ `安装 Node 依赖(${all.length} 个,用 ${rt.installer.label})`
405
+ + `${optional.length && !required.length ? ',都是可选的' : ''},首次会慢一点…`,
284
406
  );
407
+ // --ignore-scripts:依赖的 postinstall 就是任意代码执行入口,
408
+ // 装依赖不该顺带跑别人的安装脚本
409
+ const r = await run(rt.installer.cmd, rt.installer.args, { cwd: dir });
410
+ const detail = `${r.reason || (r.err || r.out).slice(-600)}\n`
411
+ + `可以手动执行:cd ${rel(dir)} && ${rt.installer.label} install`;
412
+ if (!r.ok) {
413
+ if (required.length) throw new Error(`安装 Node 依赖失败:${detail}`);
414
+ result.warnings.push(`可选依赖没装上(不影响主功能):${detail}`);
415
+ } else {
416
+ result.installed.push(`Node 依赖(${rt.installer.label})`);
417
+ }
418
+ // 装过就记账(成不成功都记)。**不能靠它拦住重试**——
419
+ // 失败时 node_modules 仍然不存在,上面 `!hasModules` 会让下次加载再试一遍
420
+ marker.packageJson = wantPkg;
421
+ await writeMarker(dir, marker);
285
422
  }
286
- result.installed.push('Node 依赖');
287
- marker.packageJson = wantPkg;
288
- await writeMarker(dir, marker);
289
423
  }
290
424
  }
291
425
  }
@@ -17,8 +17,23 @@
17
17
  * - 页面在 iframe 里渲染:CSS 不会漏到面板上,脚本也拿不到面板的 DOM
18
18
  *
19
19
  * 关于安全:插件**本身**就是可信代码(内建插件跑在主进程,外部插件是子进程),
20
- * 所以这里不做权限校验。iframe 的作用是样式隔离和避免互相干扰,不是沙箱边界。
20
+ * 所以这里不做权限校验。iframe 的作用是样式隔离和避免互相干扰。
21
+ *
22
+ * ## 页面令牌(page token)
23
+ *
24
+ * 2026-10-28 加:以前 iframe 地址上直接拼面板的 accessKey,插件页一行
25
+ * `location.search` 就能拿到面板全权(装插件 = 交出面板密码)。现在每个页面
26
+ * 注册时生成一个随机 token,iframe 只带 `?pt=<token>`,并且在面板侧配
27
+ * `sandbox="allow-scripts allow-forms"`(不给 allow-same-origin),
28
+ * 插件页既读不到父页 localStorage,也拿不到面板密钥。
29
+ *
30
+ * token 的权限边界由 server.js 的 `authorized()` 决定:只放行**该插件自己**的
31
+ * 页面与接口(`/api/pages/<id>:<key>/html`、`/api/plugins/<id>/panel`、
32
+ * `/api/plugins/<id>/r/*`),不放行 `/api/config`、`/api/logs` 等面板接口。
33
+ * 方法不限于 GET —— 斗罗那类运营后台要 POST 改数据,只读会直接废掉它,
34
+ * 而限制「插件写自己的数据」没有安全收益(插件本来就有子进程执行权)。
21
35
  */
36
+ import crypto from 'node:crypto';
22
37
  import { createLogger } from '../logger.js';
23
38
 
24
39
  const log = createLogger('pages');
@@ -51,7 +66,13 @@ export class PageRegistry {
51
66
  }
52
67
 
53
68
  const full = `${pluginId}:${key}`;
54
- this.pages.set(full, { key: full, shortKey: key, label, html, pluginId });
69
+ /**
70
+ * 页面令牌:24 字节随机(48 位十六进制),进程内有效。
71
+ * 插件重载会重新 register,于是自动换一把新的——旧页面标签刷一下就拿不到了,
72
+ * 这正是想要的(卸载/重载后旧链接失效)。
73
+ */
74
+ const token = crypto.randomBytes(24).toString('hex');
75
+ this.pages.set(full, { key: full, shortKey: key, label, html, pluginId, token });
55
76
  log.info(`插件 ${pluginId} 注册了面板页面「${label}」(${full})`);
56
77
  return full;
57
78
  }
@@ -75,9 +96,24 @@ export class PageRegistry {
75
96
  key: p.key,
76
97
  label: p.label,
77
98
  pluginId: p.pluginId,
99
+ // 前端拼 iframe 地址用。列表接口本身是鉴权过的,所以这里给得出去
100
+ token: p.token,
78
101
  }));
79
102
  }
80
103
 
104
+ /**
105
+ * 按页面令牌反查。给 `authorized()` 用:iframe 是 opaque origin,
106
+ * 拿不到 Cookie,只能靠这个 token 证明「我是这个面板页」。
107
+ * @returns {{key:string, pluginId:string, token:string}|null}
108
+ */
109
+ findByToken(token) {
110
+ if (!token) return null;
111
+ for (const p of this.pages.values()) {
112
+ if (p.token === token) return p;
113
+ }
114
+ return null;
115
+ }
116
+
81
117
  get(key) {
82
118
  return this.pages.get(String(key)) ?? null;
83
119
  }