qq-bot-roost 0.9.6 → 0.9.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 +7 -6
- package/package.json +5 -4
- package/src/api.js +229 -16
- package/src/ledger.js +8 -4
- package/src/message.js +55 -19
- package/src/outbound.js +3 -2
- package/src/plugin/loader.js +77 -0
- package/src/plugin/manifest.js +149 -0
- package/src/plugin/market.js +70 -3
- package/src/plugin/protocol.js +2 -0
- package/src/storage/events-schema.js +29 -1
- package/src/tarball.js +32 -4
- package/src/trace.js +28 -0
- package/src/trust.js +264 -0
- package/src/updater.js +58 -8
- package/src/web/server.js +55 -4
- package/web/app.js +45 -0
- package/web/index.html +18 -0
- package/web/style.css +9 -0
package/src/plugin/loader.js
CHANGED
|
@@ -13,6 +13,7 @@ import { ensurePluginDeps } from './deps.js';
|
|
|
13
13
|
import { satisfies, frameworkVersion } from '../semver.js';
|
|
14
14
|
import { PageRegistry } from './pages.js';
|
|
15
15
|
import { RouteRegistry } from './routes.js';
|
|
16
|
+
import { Declarations, normalizeManifest, checkEngines } from './manifest.js';
|
|
16
17
|
|
|
17
18
|
const log = createLogger('plugins');
|
|
18
19
|
|
|
@@ -29,6 +30,8 @@ const log = createLogger('plugins');
|
|
|
29
30
|
export class PluginManager {
|
|
30
31
|
constructor({ pluginsDir, paths, registry, api, cfg, goBin, groups, roles, outbound, session, pipeline, isOwner, reach, ledger, gate, delivery, ignoreCompat }) {
|
|
31
32
|
this.dir = pluginsDir;
|
|
33
|
+
/** 插件声明的名字占用表(命令/工具/钩子),加载前查冲突、卸载时释放 */
|
|
34
|
+
this.decl = new Declarations();
|
|
32
35
|
// 该机器人的全套路径(配置/数据/日志),多机器人下互不干扰
|
|
33
36
|
this.paths = paths;
|
|
34
37
|
// 全局配置里的 go 可执行文件路径(机器人配置里没有这一项)
|
|
@@ -240,6 +243,49 @@ export class PluginManager {
|
|
|
240
243
|
);
|
|
241
244
|
}
|
|
242
245
|
|
|
246
|
+
/**
|
|
247
|
+
* 清单检查:版本要求 + 名字声明。
|
|
248
|
+
*
|
|
249
|
+
* 放在 `setup()` 之前,理由和 `#assertCompatible` 一样——跑起来才发现
|
|
250
|
+
* 「这个命令被别的插件占了」太晚,用户看到的是「插件好像没生效」。
|
|
251
|
+
*
|
|
252
|
+
* 三件事:
|
|
253
|
+
* 1. `engines` 不满足就拒绝加载(roost / node / dsh,查不到的键跳过)
|
|
254
|
+
* 2. `provides` 有冲突就拒绝加载,并说清是谁占了哪个名字
|
|
255
|
+
* 3. **没声明 `provides` 的老插件照常加载**,只记一条 debug
|
|
256
|
+
*/
|
|
257
|
+
#assertManifest(id, rawMeta) {
|
|
258
|
+
const m = normalizeManifest(rawMeta);
|
|
259
|
+
|
|
260
|
+
if (!this.ignoreCompat) {
|
|
261
|
+
const r = checkEngines(
|
|
262
|
+
m.engines,
|
|
263
|
+
{ roost: frameworkVersion(), node: process.versions.node },
|
|
264
|
+
satisfies,
|
|
265
|
+
);
|
|
266
|
+
if (!r.ok) {
|
|
267
|
+
throw new Error(`${r.why}。升级后再启用,或者把插件里的 engines 改成兼容的范围`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const total = m.provides.commands.length + m.provides.tools.length + m.provides.hooks.length;
|
|
272
|
+
if (!total) {
|
|
273
|
+
log.debug(`插件 ${id} 没有声明 provides,跳过预检(老插件正常加载)`);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const conflicts = this.decl.claim(id, m.provides);
|
|
278
|
+
if (!conflicts.length) return;
|
|
279
|
+
|
|
280
|
+
const label = { commands: '命令', tools: '工具名', hooks: '钩子' };
|
|
281
|
+
const first = conflicts[0];
|
|
282
|
+
const extra = conflicts.length > 1 ? `(另有 ${conflicts.length - 1} 项冲突)` : '';
|
|
283
|
+
throw new Error(
|
|
284
|
+
`和插件 ${first.owner} 抢同一个${label[first.kind] ?? first.kind}「${first.name}」${extra}。` +
|
|
285
|
+
'两个插件都声明了它,后加载的这一个不会生效——先停用其中一个,或者让作者改个名字',
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
243
289
|
async #detectKind(dir) {
|
|
244
290
|
try {
|
|
245
291
|
await fs.access(path.join(dir, 'plugin.json'));
|
|
@@ -301,6 +347,8 @@ export class PluginManager {
|
|
|
301
347
|
}
|
|
302
348
|
this.errors.set(id, e.message);
|
|
303
349
|
this.errorStacks.set(id, e.stack || e.message);
|
|
350
|
+
// 声明在 setup 之前就认领了,加载失败必须还回去,否则下次重试会「自己和自己冲突」
|
|
351
|
+
this.decl.release(id);
|
|
304
352
|
log.error(`插件 ${id} 加载失败: ${e.stack || e.message}`);
|
|
305
353
|
/**
|
|
306
354
|
* 失败时把已经建起来的东西收干净。
|
|
@@ -381,6 +429,7 @@ export class PluginManager {
|
|
|
381
429
|
meta.id = id;
|
|
382
430
|
// 版本兼容在 setup 之前拦——跑起来才发现「ctx.xxx 不是函数」就太晚了
|
|
383
431
|
this.#assertCompatible(id, meta);
|
|
432
|
+
this.#assertManifest(id, meta);
|
|
384
433
|
|
|
385
434
|
// 每个插件一份自己的日志文件:logs/bots/<机器人>/plugins/<id>.log
|
|
386
435
|
if (this.cfg.logs?.enabled !== false) {
|
|
@@ -461,6 +510,11 @@ export class PluginManager {
|
|
|
461
510
|
repo: raw.repo ?? '',
|
|
462
511
|
supportPlatforms: Array.isArray(raw.supportPlatforms) ? raw.supportPlatforms : [],
|
|
463
512
|
frameworkVersion: raw.frameworkVersion ?? raw.requires?.framework ?? '',
|
|
513
|
+
/** 清单声明(engines / provides),见 manifest.js */
|
|
514
|
+
...(() => {
|
|
515
|
+
const mf = normalizeManifest(raw);
|
|
516
|
+
return { engines: mf.engines, provides: mf.provides };
|
|
517
|
+
})(),
|
|
464
518
|
/**
|
|
465
519
|
* 监听器优先级:高的先跑。见 bot.js 里的排序说明。
|
|
466
520
|
* 外部插件在 plugin.json 里写 `"priority": -100` 就能排到最后。
|
|
@@ -471,6 +525,8 @@ export class PluginManager {
|
|
|
471
525
|
};
|
|
472
526
|
|
|
473
527
|
this.#assertCompatible(id, meta);
|
|
528
|
+
// 用 raw 而不是 meta:meta 是逐字段拼的,没有 provides / engines
|
|
529
|
+
this.#assertManifest(id, raw);
|
|
474
530
|
|
|
475
531
|
// 每个插件一份自己的日志文件:logs/bots/<机器人>/plugins/<id>.log
|
|
476
532
|
if (this.cfg.logs?.enabled !== false) {
|
|
@@ -576,6 +632,8 @@ export class PluginManager {
|
|
|
576
632
|
/* ---------------- 卸载与重载 ---------------- */
|
|
577
633
|
|
|
578
634
|
async unload(id) {
|
|
635
|
+
// 先释放声明:加载失败时也可能已经认领过(claim 在 setup 之前)
|
|
636
|
+
this.decl.release(id);
|
|
579
637
|
const item = this.loaded.get(id);
|
|
580
638
|
if (!item) return false;
|
|
581
639
|
|
|
@@ -625,6 +683,8 @@ export class PluginManager {
|
|
|
625
683
|
* 重装之后会被这条旧记录挡住不加载,用户会以为装坏了。
|
|
626
684
|
*/
|
|
627
685
|
async forget(id) {
|
|
686
|
+
// 目录已经删了,但声明可能还占着名字——不释放会让重装的同名插件「和自己冲突」
|
|
687
|
+
this.decl.release(id);
|
|
628
688
|
let dirty = false;
|
|
629
689
|
if (this.state && id in this.state) {
|
|
630
690
|
delete this.state[id];
|
|
@@ -684,6 +744,8 @@ export class PluginManager {
|
|
|
684
744
|
repo: meta?.repo || '',
|
|
685
745
|
supportPlatforms: Array.isArray(meta?.supportPlatforms) ? meta.supportPlatforms : [],
|
|
686
746
|
frameworkVersion: meta?.frameworkVersion || meta?.requires?.framework || '',
|
|
747
|
+
engines: normalizeManifest(meta).engines,
|
|
748
|
+
provides: normalizeManifest(meta).provides,
|
|
687
749
|
kind,
|
|
688
750
|
runtime,
|
|
689
751
|
});
|
|
@@ -910,9 +972,13 @@ export class PluginManager {
|
|
|
910
972
|
// diskIds 是「装了但没启用」的那批(include 模式下常见),
|
|
911
973
|
// 也要列出来——否则用户在面板里根本看不到它,也就没法启用。
|
|
912
974
|
const ids = new Set([...this.loaded.keys(), ...this.meta.keys(), ...this.diskIds]);
|
|
975
|
+
/** 一次算好再进循环:byPlugin() 每次都重建整张表,放进 map 里就是 O(n²) */
|
|
976
|
+
const claimed = this.decl.byPlugin();
|
|
913
977
|
return [...ids].map((id) => {
|
|
914
978
|
const item = this.loaded.get(id);
|
|
915
979
|
const cached = this.meta.get(id) ?? null;
|
|
980
|
+
/** 清单声明统一从这里取:加载中用 meta,禁用中用缓存,两者字段一致 */
|
|
981
|
+
const mf = normalizeManifest(item?.meta ?? cached ?? {});
|
|
916
982
|
const ext = item?.kind === 'external' ? item.host.status : null;
|
|
917
983
|
return {
|
|
918
984
|
id,
|
|
@@ -925,6 +991,17 @@ export class PluginManager {
|
|
|
925
991
|
repo: item?.meta?.repo ?? cached?.repo ?? '',
|
|
926
992
|
supportPlatforms: item?.meta?.supportPlatforms ?? cached?.supportPlatforms ?? [],
|
|
927
993
|
frameworkVersion: item?.meta?.frameworkVersion ?? cached?.frameworkVersion ?? '',
|
|
994
|
+
/**
|
|
995
|
+
* 声明与兼容性。`provides` 优先用**实际认领**的那份(decl),
|
|
996
|
+
* 因为插件可能声明了却没用上;没加载时退回声明原文。
|
|
997
|
+
*/
|
|
998
|
+
provides: claimed.get(id) ?? mf.provides,
|
|
999
|
+
engines: mf.engines,
|
|
1000
|
+
enginesOk: checkEngines(
|
|
1001
|
+
mf.engines,
|
|
1002
|
+
{ roost: frameworkVersion(), node: process.versions.node },
|
|
1003
|
+
satisfies,
|
|
1004
|
+
).ok,
|
|
928
1005
|
enabled: this.isEnabled(id),
|
|
929
1006
|
loaded: Boolean(item),
|
|
930
1007
|
// 未加载时用缓存里的类型,别一律当成 internal
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件清单:能力声明与版本要求
|
|
3
|
+
* ============================
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要它:命名冲突现在只在**注册那一刻**才炸——命令重复要等用户敲到
|
|
6
|
+
* 那条命令、工具重复要等 AI 真的去调。用户看到的是「这个插件好像没生效」,
|
|
7
|
+
* 而不是「A 和 B 都声明了 dice」。更麻烦的是 `hooks`:多个插件都能监听消息,
|
|
8
|
+
* 没有任何一处会告诉你「这两个插件在抢同一件事」。
|
|
9
|
+
*
|
|
10
|
+
* 这里把「我占哪些名字」提前成**声明**,加载前就能对表。
|
|
11
|
+
*
|
|
12
|
+
* 三个原则:
|
|
13
|
+
* 1. **声明可选**。老插件不写 `provides` 照常加载,只是不参与预检(会 warn)。
|
|
14
|
+
* 2. **冲突 fail-closed**。后加载的那个拒绝加载,并说清是谁占了。
|
|
15
|
+
* 3. **只声明不强制**。`provides` 是「我打算用」,真正的注册仍走原有路径,
|
|
16
|
+
* 两边对不上时以实际注册为准——声明不能变成第二套权限。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** 三类可声明的名字。`hooks` 只是提示,多个插件监听同一条消息是合法的。 */
|
|
20
|
+
export const KINDS = ['commands', 'tools', 'hooks'];
|
|
21
|
+
|
|
22
|
+
/** 把任意输入压成 `{ commands: [], tools: [], hooks: [] }`,去掉空值与非字符串 */
|
|
23
|
+
export function normalizeProvides(raw) {
|
|
24
|
+
const out = { commands: [], tools: [], hooks: [] };
|
|
25
|
+
if (!raw || typeof raw !== 'object') return out;
|
|
26
|
+
for (const k of KINDS) {
|
|
27
|
+
const v = raw[k];
|
|
28
|
+
if (!Array.isArray(v)) continue;
|
|
29
|
+
out[k] = [...new Set(v.map((x) => String(x ?? '').trim()).filter(Boolean))];
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 解析整个清单。`engines` 只保留认识的键,值统一成字符串。
|
|
36
|
+
*
|
|
37
|
+
* 兼容两种写法:顶层 `provides`,或 `requires.provides`(后者是给
|
|
38
|
+
* `frameworkVersion` 那套 `requires.*` 习惯留的)。
|
|
39
|
+
*/
|
|
40
|
+
export function normalizeManifest(raw = {}) {
|
|
41
|
+
const src = raw && typeof raw === 'object' ? raw : {};
|
|
42
|
+
const req = src.requires && typeof src.requires === 'object' ? src.requires : {};
|
|
43
|
+
const enginesRaw = src.engines && typeof src.engines === 'object' ? src.engines : {};
|
|
44
|
+
const engines = {};
|
|
45
|
+
for (const k of ['roost', 'node', 'dsh']) {
|
|
46
|
+
const v = enginesRaw[k];
|
|
47
|
+
if (v !== undefined && v !== null && String(v).trim()) engines[k] = String(v).trim();
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
provides: normalizeProvides(src.provides ?? req.provides),
|
|
51
|
+
consumes: normalizeProvides(src.consumes ?? req.consumes),
|
|
52
|
+
engines,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 版本要求检查。
|
|
58
|
+
*
|
|
59
|
+
* `actual` 里缺的键**跳过**:比如 Roost 进程里根本不知道 DSH 版本,
|
|
60
|
+
* 那就不能因为「查不到」判定不兼容——那会把所有声明了 dsh 的插件全拒掉。
|
|
61
|
+
*
|
|
62
|
+
* @param {Record<string,string>} engines
|
|
63
|
+
* @param {{roost?:string, node?:string, dsh?:string}} actual
|
|
64
|
+
* @param {(v:string, r:string)=>boolean} satisfies
|
|
65
|
+
* @returns {{ok:boolean, why?:string}}
|
|
66
|
+
*/
|
|
67
|
+
export function checkEngines(engines, actual, satisfies) {
|
|
68
|
+
for (const [k, range] of Object.entries(engines ?? {})) {
|
|
69
|
+
const cur = actual?.[k];
|
|
70
|
+
if (!cur) continue; // 不知道就不判
|
|
71
|
+
let ok = false;
|
|
72
|
+
try {
|
|
73
|
+
ok = satisfies(cur, range);
|
|
74
|
+
} catch {
|
|
75
|
+
return { ok: false, why: `engines.${k} 不是合法的版本范围:${range}` };
|
|
76
|
+
}
|
|
77
|
+
if (!ok) return { ok: false, why: `要求 ${k} ${range},当前是 ${cur}` };
|
|
78
|
+
}
|
|
79
|
+
return { ok: true };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 声明占用表。
|
|
84
|
+
*
|
|
85
|
+
* 用法:加载前 `claim()`,卸载时 `release()`。
|
|
86
|
+
* `claim` **不注册冲突项**——调用方拿到 conflicts 后应拒绝加载,
|
|
87
|
+
* 这样表里永远只有「真正生效」的声明,不会因为一次失败加载留下幽灵条目。
|
|
88
|
+
*/
|
|
89
|
+
export class Declarations {
|
|
90
|
+
constructor() {
|
|
91
|
+
/** @type {Map<string, {kind:string, name:string, pluginId:string}>} */
|
|
92
|
+
this.map = new Map();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
static key(kind, name) {
|
|
96
|
+
return `${kind}:${name}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 认领一组名字。
|
|
101
|
+
* @param {string} pluginId
|
|
102
|
+
* @param {{commands?:string[], tools?:string[], hooks?:string[]}} provides
|
|
103
|
+
* @returns {Array<{kind:string, name:string, owner:string}>} 冲突项(空数组=全部拿到)
|
|
104
|
+
*/
|
|
105
|
+
claim(pluginId, provides) {
|
|
106
|
+
const p = normalizeProvides(provides);
|
|
107
|
+
const conflicts = [];
|
|
108
|
+
const mine = [];
|
|
109
|
+
|
|
110
|
+
for (const kind of KINDS) {
|
|
111
|
+
for (const name of p[kind]) {
|
|
112
|
+
const k = Declarations.key(kind, name);
|
|
113
|
+
const held = this.map.get(k);
|
|
114
|
+
if (held && held.pluginId !== pluginId) {
|
|
115
|
+
conflicts.push({ kind, name, owner: held.pluginId });
|
|
116
|
+
} else {
|
|
117
|
+
mine.push([k, { kind, name, pluginId }]);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 有冲突就整批不落:半套声明比没有更难查
|
|
123
|
+
if (conflicts.length) return conflicts;
|
|
124
|
+
for (const [k, v] of mine) this.map.set(k, v);
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** 卸载时释放这个插件的全部声明 */
|
|
129
|
+
release(pluginId) {
|
|
130
|
+
let n = 0;
|
|
131
|
+
for (const [k, v] of this.map) {
|
|
132
|
+
if (v.pluginId === pluginId) {
|
|
133
|
+
this.map.delete(k);
|
|
134
|
+
n += 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 面板「资源占用」用:按插件分组 */
|
|
141
|
+
byPlugin() {
|
|
142
|
+
const out = new Map();
|
|
143
|
+
for (const v of this.map.values()) {
|
|
144
|
+
if (!out.has(v.pluginId)) out.set(v.pluginId, { commands: [], tools: [], hooks: [] });
|
|
145
|
+
out.get(v.pluginId)[v.kind].push(v.name);
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
}
|
package/src/plugin/market.js
CHANGED
|
@@ -10,6 +10,7 @@ import { hashDir } from '../hashdir.js';
|
|
|
10
10
|
import { compareVersion } from '../semver.js';
|
|
11
11
|
import { fetchViaMirror, currentMirror } from '../mirror.js';
|
|
12
12
|
import { fetchGiteeTree } from '../gitee.js';
|
|
13
|
+
import { resolveTrust, checkTrust } from '../trust.js';
|
|
13
14
|
|
|
14
15
|
const log = createLogger('market');
|
|
15
16
|
|
|
@@ -20,6 +21,25 @@ const indexInFlight = new Map();
|
|
|
20
21
|
export const DEFAULT_INDEX_URL =
|
|
21
22
|
'https://raw.githubusercontent.com/tdcq9594/qq-bot-roost-plugins/main/index.json';
|
|
22
23
|
|
|
24
|
+
/**
|
|
25
|
+
* 这个索引地址算不算「官方源」。
|
|
26
|
+
*
|
|
27
|
+
* 只有官方源强制验签。用户把 `plugins.marketUrl` 指向自己的仓库时,
|
|
28
|
+
* 那是他自己的信任决定——我们拦他,等于不让他分享自己的插件包。
|
|
29
|
+
*
|
|
30
|
+
* 自建代理 / 镜像可以通过 `security.trust.officialUrls` 声明进来,
|
|
31
|
+
* 否则它会被当成第三方源(不拦,但也不享受强制保护)。
|
|
32
|
+
*
|
|
33
|
+
* @param {string} [url]
|
|
34
|
+
* @param {object} [cfg]
|
|
35
|
+
*/
|
|
36
|
+
export function isOfficialSource(url, cfg = {}) {
|
|
37
|
+
const u = String(url || DEFAULT_INDEX_URL);
|
|
38
|
+
const extra = cfg?.security?.trust?.officialUrls;
|
|
39
|
+
const list = [DEFAULT_INDEX_URL, ...(Array.isArray(extra) ? extra.map(String) : [])];
|
|
40
|
+
return list.includes(u);
|
|
41
|
+
}
|
|
42
|
+
|
|
23
43
|
/**
|
|
24
44
|
* 拉取插件市场的索引。
|
|
25
45
|
*
|
|
@@ -59,13 +79,17 @@ function assertSafeIndexUrl(raw) {
|
|
|
59
79
|
throw new Error(`插件市场地址必须用 https${hint}:${u.protocol}//${host}`);
|
|
60
80
|
}
|
|
61
81
|
|
|
62
|
-
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} [url]
|
|
84
|
+
* @param {{trust?: object}} [opts] 信任策略;不传就按「没配公钥」处理(本版不验签)
|
|
85
|
+
*/
|
|
86
|
+
export async function fetchIndex(url = DEFAULT_INDEX_URL, opts = {}) {
|
|
63
87
|
const key = String(url || DEFAULT_INDEX_URL);
|
|
64
88
|
assertSafeIndexUrl(key);
|
|
65
89
|
const running = indexInFlight.get(key);
|
|
66
90
|
if (running) return running;
|
|
67
91
|
|
|
68
|
-
const task = fetchIndexOnce(key)
|
|
92
|
+
const task = fetchIndexOnce(key, opts)
|
|
69
93
|
.then(async (data) => {
|
|
70
94
|
await writeIndexCache(key, data).catch(() => {});
|
|
71
95
|
return data;
|
|
@@ -112,7 +136,33 @@ async function readIndexCache(url) {
|
|
|
112
136
|
return { data: obj.data, age };
|
|
113
137
|
}
|
|
114
138
|
|
|
115
|
-
|
|
139
|
+
/**
|
|
140
|
+
* 拉 `.sig`。**只有配了公钥才拉**——没配就是本版不验签,多一次请求纯属浪费,
|
|
141
|
+
* 也会让离线/镜像用户多等一次超时。
|
|
142
|
+
*
|
|
143
|
+
* 地址构造用 `URL` 而不是字符串拼接:自定义市场源可能带 query,
|
|
144
|
+
* 直接拼 `.sig` 会拼到 query 后面去。
|
|
145
|
+
*/
|
|
146
|
+
async function fetchIndexSig(url, bust) {
|
|
147
|
+
let sigUrl;
|
|
148
|
+
try {
|
|
149
|
+
const u = new URL(url);
|
|
150
|
+
u.pathname = `${u.pathname}.sig`;
|
|
151
|
+
u.searchParams.set('_t', bust);
|
|
152
|
+
sigUrl = u.toString();
|
|
153
|
+
} catch {
|
|
154
|
+
return '';
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const res = await fetchViaMirror(sigUrl, { signal: AbortSignal.timeout(8000) });
|
|
158
|
+
if (!res.ok) return '';
|
|
159
|
+
return await res.text();
|
|
160
|
+
} catch {
|
|
161
|
+
return '';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function fetchIndexOnce(url, opts = {}) {
|
|
116
166
|
let lastErr = null;
|
|
117
167
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
118
168
|
if (attempt > 0) await new Promise((r) => setTimeout(r, 1500));
|
|
@@ -151,6 +201,23 @@ async function fetchIndexOnce(url) {
|
|
|
151
201
|
}
|
|
152
202
|
if (!Array.isArray(data?.plugins)) throw new Error('插件市场索引格式不对(缺少 plugins 数组)');
|
|
153
203
|
|
|
204
|
+
/**
|
|
205
|
+
* 验签。**默认不阻断**(`trust.enforce` 为 false),只把结论挂在 `_trust` 上,
|
|
206
|
+
* 由安装接口决定拦不拦。这是两段式上线的第一段:先把签名铺出去,
|
|
207
|
+
* 让生态有时间跟上,下一版才默认拒绝。
|
|
208
|
+
*/
|
|
209
|
+
const trust = opts.trust ?? resolveTrust({});
|
|
210
|
+
let sigText = '';
|
|
211
|
+
if (trust.pubkey) sigText = await fetchIndexSig(url, `${Date.now()}-${attempt}`);
|
|
212
|
+
|
|
213
|
+
const verdict = checkTrust({ data, sigText, trust, kind: '插件索引' });
|
|
214
|
+
data._trust = verdict;
|
|
215
|
+
// `why` 里已经带了「插件索引」前缀,这里不要再加一遍
|
|
216
|
+
if (verdict.status === 'bad') log.error(verdict.why);
|
|
217
|
+
else if (verdict.status === 'invalid') log.error(verdict.why);
|
|
218
|
+
else if (verdict.status === 'unsigned') log.warn(`${verdict.why}(本版只警告,不阻断)`);
|
|
219
|
+
else if (verdict.status === 'ok') log.info('插件索引签名校验通过');
|
|
220
|
+
|
|
154
221
|
return data;
|
|
155
222
|
}
|
|
156
223
|
throw lastErr ?? new Error('连不上插件市场');
|
package/src/plugin/protocol.js
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* 合表等于把统计库那句「不存消息正文」变成假话。
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
export const EVENTS_SCHEMA_VERSION =
|
|
15
|
+
export const EVENTS_SCHEMA_VERSION = 2;
|
|
16
16
|
|
|
17
17
|
const V1 = `
|
|
18
18
|
CREATE TABLE IF NOT EXISTS events (
|
|
@@ -59,6 +59,29 @@ CREATE INDEX IF NOT EXISTS refs_from ON event_refs(from_id);
|
|
|
59
59
|
CREATE INDEX IF NOT EXISTS refs_to ON event_refs(to_id) WHERE resolved = 1;
|
|
60
60
|
`;
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* v2:链路 id。
|
|
64
|
+
*
|
|
65
|
+
* 一条消息从进站到回话的唯一标识(见 `src/trace.js`)。加它的理由很具体:
|
|
66
|
+
* 用户说"刚才那条没回",现在只能翻四个日志文件对时间戳;有了这一列,
|
|
67
|
+
* 拿 traceId 一条 SQL 就能找到那次请求对应的原文。
|
|
68
|
+
*
|
|
69
|
+
* 老库补空串——空串表示"这条早于 traceId 上线",不是"查不到"。
|
|
70
|
+
*/
|
|
71
|
+
const V2 = `
|
|
72
|
+
ALTER TABLE events ADD COLUMN trace TEXT NOT NULL DEFAULT '';
|
|
73
|
+
CREATE INDEX IF NOT EXISTS events_trace ON events(bot_id, trace) WHERE trace != '';
|
|
74
|
+
`;
|
|
75
|
+
|
|
76
|
+
/** 表里有没有这一列(迁移要幂等,重复 ALTER 会抛) */
|
|
77
|
+
function hasColumn(db, table, col) {
|
|
78
|
+
try {
|
|
79
|
+
return db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col);
|
|
80
|
+
} catch {
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
62
85
|
/**
|
|
63
86
|
* @param {object} db DatabaseSync
|
|
64
87
|
*/
|
|
@@ -66,6 +89,11 @@ export function migrateEvents(db) {
|
|
|
66
89
|
const cur = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
|
|
67
90
|
if (cur >= EVENTS_SCHEMA_VERSION) return { from: cur, to: cur };
|
|
68
91
|
if (cur < 1) db.exec(V1);
|
|
92
|
+
/**
|
|
93
|
+
* 幂等:老库里如果已经手工加过这一列,`ALTER` 会抛 duplicate column。
|
|
94
|
+
* 版本号可能因为半途失败停在 1,所以这里必须按「列在不在」判断,不能只看版本。
|
|
95
|
+
*/
|
|
96
|
+
if (!hasColumn(db, 'events', 'trace')) db.exec(V2);
|
|
69
97
|
db.exec(`PRAGMA user_version = ${EVENTS_SCHEMA_VERSION}`);
|
|
70
98
|
return { from: cur, to: EVENTS_SCHEMA_VERSION };
|
|
71
99
|
}
|
package/src/tarball.js
CHANGED
|
@@ -34,18 +34,32 @@ export function parseTar(buf) {
|
|
|
34
34
|
let off = 0;
|
|
35
35
|
|
|
36
36
|
while (off + 512 <= buf.length) {
|
|
37
|
+
/** 本轮起点:用来判断「有没有真的往前走」,见下面的 next 检查 */
|
|
38
|
+
const start = off;
|
|
37
39
|
const header = buf.subarray(off, off + 512);
|
|
38
40
|
// 全零块 = 结束
|
|
39
41
|
if (header.every((b) => b === 0)) break;
|
|
40
42
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
/**
|
|
44
|
+
* 参数叫 `pos` 而不是 `start`:外层那个 `start` 是「本轮在 buf 里的起点」,
|
|
45
|
+
* 两者含义完全不同,同名遮蔽会让人以为能互相引用。
|
|
46
|
+
*/
|
|
47
|
+
const readStr = (pos, len) => {
|
|
48
|
+
const slice = header.subarray(pos, pos + len);
|
|
43
49
|
const end = slice.indexOf(0);
|
|
44
50
|
return slice.subarray(0, end === -1 ? len : end).toString('utf8');
|
|
45
51
|
};
|
|
46
52
|
|
|
47
53
|
const name = readStr(0, 100);
|
|
48
|
-
|
|
54
|
+
/**
|
|
55
|
+
* size 必须夹到非负。
|
|
56
|
+
*
|
|
57
|
+
* 八进制字段里出现 `-` 时 `parseInt` 会给出负数,后面
|
|
58
|
+
* `Math.ceil(size / 512) * 512` 就是负的,游标「前进 512 又倒退 512」
|
|
59
|
+
* 原地打转——而这个循环每轮都 push 一条条目,于是**内存直接爆掉**。
|
|
60
|
+
* 一个几十字节的畸形 tar 就能把机器人打死(2026-10-31 用 fuzz 撞出来的)。
|
|
61
|
+
*/
|
|
62
|
+
const size = Math.max(0, parseInt(readStr(124, 12).trim(), 8) || 0);
|
|
49
63
|
const type = String.fromCharCode(header[156]) || '0';
|
|
50
64
|
// ustar 前缀,长路径会用它
|
|
51
65
|
const prefix = readStr(345, 155);
|
|
@@ -53,7 +67,21 @@ export function parseTar(buf) {
|
|
|
53
67
|
|
|
54
68
|
off += 512;
|
|
55
69
|
const content = buf.subarray(off, off + size);
|
|
56
|
-
|
|
70
|
+
/**
|
|
71
|
+
* 游标必须严格前进。即使 size 已经夹到非负,这里再兜一道:
|
|
72
|
+
* 畸形头导致的任何「原地踏步」都会变成死循环 + 内存爆炸,
|
|
73
|
+
* 而正常的 tar 每轮至少前进 512 字节,不可能触发。
|
|
74
|
+
*/
|
|
75
|
+
const next = off + Math.ceil(size / 512) * 512;
|
|
76
|
+
/**
|
|
77
|
+
* 和**本轮起点**比,不是和 `off` 比。
|
|
78
|
+
*
|
|
79
|
+
* `size = 0`(目录条目)时 `next` 正好等于 `off`,拿 `off` 比会把
|
|
80
|
+
* 正常的空条目误判成「没前进」而提前结束——第一版就是这么写的,
|
|
81
|
+
* 结果一个含目录的正常 tar 只解析出第一条。
|
|
82
|
+
*/
|
|
83
|
+
if (next <= start) break;
|
|
84
|
+
off = next;
|
|
57
85
|
|
|
58
86
|
if (type === '5') entries.push({ name: fullName, dir: true });
|
|
59
87
|
else if (type === '0' || type === '\0') entries.push({ name: fullName, dir: false, content });
|
package/src/trace.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 链路追踪 id
|
|
3
|
+
* ===========
|
|
4
|
+
*
|
|
5
|
+
* 一条消息要穿过四段:QQ 事件 → Roost 内核 → ai-chat 插件 → dsh-roost → 模型。
|
|
6
|
+
* 出问题时现在只能翻四个日志文件对时间戳——用户说「刚才那条没回」,
|
|
7
|
+
* 根本不知道该看哪一段。
|
|
8
|
+
*
|
|
9
|
+
* 这里生成一个短 id,进站时打上,每段日志都带着它,面板也能按它串起来。
|
|
10
|
+
*
|
|
11
|
+
* 为什么不是 UUID:要能在日志里一眼扫到、能手打、能贴进群里问。
|
|
12
|
+
* 时间前缀(36 进制)让同一秒内的 id 也有序,后面 6 位十六进制够区分。
|
|
13
|
+
* 碰撞概率:同一毫秒内 1/1677 万,对一个群聊机器人足够。
|
|
14
|
+
*/
|
|
15
|
+
import crypto from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @returns {string} 形如 `m3k9x2a1f3c4`
|
|
19
|
+
*/
|
|
20
|
+
export function newTraceId() {
|
|
21
|
+
return `${Date.now().toString(36)}${crypto.randomBytes(3).toString('hex')}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 日志前缀:统一成 `[trace:xxxx]`,方便 grep */
|
|
25
|
+
export function traceTag(id) {
|
|
26
|
+
const t = String(id ?? '').trim();
|
|
27
|
+
return t ? `[trace:${t}]` : '';
|
|
28
|
+
}
|