qq-bot-roost 0.8.7 → 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 +1 -1
- package/package.json +3 -3
- package/src/plugin/deps.js +160 -26
- package/src/plugin/pages.js +38 -2
- package/src/web/server.js +306 -9
- package/web/app.js +3275 -3265
- package/web/index.html +2 -10
- package/web/theme-boot.js +17 -0
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
|
|
11
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.8.
|
|
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)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qq-bot-roost",
|
|
3
|
-
"version": "0.8.
|
|
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",
|
package/src/plugin/deps.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
254
|
-
|
|
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
|
|
269
|
-
if (!rt.
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
}
|
package/src/plugin/pages.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|