qq-bot-roost 0.9.7 → 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 CHANGED
@@ -8,9 +8,9 @@
8
8
 
9
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-700%2B%20passed-brightgreen" alt="测试 700+ 项">
11
+ <img src="https://img.shields.io/badge/tests-679%20passed-brightgreen" alt="测试 679 项">
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.9.7-blueviolet" alt="版本 0.9.7">
13
+ <img src="https://img.shields.io/badge/version-0.9.8-blueviolet" alt="版本 0.9.8">
14
14
  <img src="https://img.shields.io/badge/license-Non--Commercial-red" alt="禁止商用">
15
15
  <a href="https://github.com/tdcq9594/qq-bot-roost/actions"><img src="https://img.shields.io/github/actions/workflow/status/tdcq9594/qq-bot-roost/ci.yml?branch=master" alt="CI"></a>
16
16
 
@@ -23,8 +23,8 @@
23
23
  用 Node.js 从零实现 QQ 开放平台 v2 协议,**不依赖任何第三方包**——装个 Node 22 就能跑,
24
24
  不需要 `npm install`,**装插件和更新框架也不需要 git**(下载 GitHub 的 tar 包自己解,不调外部命令)。
25
25
 
26
- 官方接口比第三方协议严格得多:被动回复只有 5 分钟且同一条最多回 5 次、同一会话每秒最多 5 条、
27
- 主动消息单会话 20 条/分钟且每群每天 1000 条。
26
+ 官方接口比第三方协议严格得多:群聊被动回复 5 分钟、同一条最多回 5 次(单聊 60 分钟 / 4 次)、
27
+ 发送接口 100 QPS、主动消息单会话 20 条/分钟且每群每天 1000 条。
28
28
  这些限制框架已经统一兜住,写插件时不用算时间、不用管额度,调 `msg.reply()` 就行。
29
29
 
30
30
  插件有两种形态,互不影响:
@@ -62,7 +62,7 @@
62
62
 
63
63
  **官方接口这一层**
64
64
 
65
- - **限制全兜底**:出站调度器统一处理 5 分钟窗口、每秒 5 条限速、主动消息每日额度、断线拒发、审核中识别——插件只管调 `msg.reply()`
65
+ - **限制全兜底**:出站调度器统一处理被动窗口(群 5 分钟 / 单聊 60 分钟)、会话内限速、主动消息每日额度、断线拒发、审核中识别——插件只管调 `msg.reply()`
66
66
  - **`msg_seq` 自动维护**:同一 `msg_id` 被动回复必须递增,这是官方硬要求,漏一次就发不出去
67
67
  - **免@粘性**:@ 过一次后 N 分钟内该会话免@ 也算在跟机器人说话
68
68
  - **多轮对话**:`ctx.prompt()` 问一句等回答,文字游戏的地基
@@ -124,6 +124,7 @@
124
124
  - 5 种作用域 × 数字等级(0 主人 / 1 管理员 / 2 群管 / 3 助手 / 10+ 插件区)
125
125
  - 一个人在不同群、不同插件里可以有完全不同的权限,互不干扰
126
126
  - 只能授予数字更大的等级,不能给自己或同级提权
127
+ - **权限管的是「谁能让机器人做什么」,不是「插件代码能做什么」。** 内建插件跑在主进程、外部插件跑在子进程,两者都拥有和你相同的文件与网络权限。装来路不明的插件,等于让它的作者以你的身份运行代码。
127
128
 
128
129
  **群管能力**
129
130
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.9.7",
3
+ "version": "0.9.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 && node test/deps.mjs",
32
+ "test": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.mjs",
33
33
  "test:e2e": "node test/e2e.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",
34
+ "test:all": "node test/selftest.mjs && node test/qrlogin.mjs && node test/deps.mjs && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.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",
@@ -39,7 +39,8 @@
39
39
  "market:index": "node scripts/build-market-index.mjs",
40
40
  "sums": "node scripts/build-sums.mjs",
41
41
  "sums:check": "node scripts/build-sums.mjs --check",
42
- "sums:verify": "node scripts/build-sums.mjs --verify"
42
+ "sums:verify": "node scripts/build-sums.mjs --verify",
43
+ "test:coverage": "node --experimental-test-coverage --test-coverage-lines=95 --test-coverage-branches=80 --test-coverage-include=src/trust.js --test-coverage-include=src/plugin/manifest.js --test-coverage-include=src/trace.js --test test/trust.mjs test/manifest.mjs test/trace.mjs"
43
44
  },
44
45
  "author": {
45
46
  "name": "倦飞之鸟"
package/src/ledger.js CHANGED
@@ -225,8 +225,8 @@ export class EventLedger {
225
225
  `INSERT OR IGNORE INTO events
226
226
  (id, bot_id, kind, chat_id, msg_id, user_id, user_name, content,
227
227
  at, observed_at, time_basis, dedupe,
228
- msg_idx, ref_msg_idx, union_openid, quoted, payload)
229
- VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
228
+ msg_idx, ref_msg_idx, union_openid, quoted, payload, trace)
229
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
230
230
  )
231
231
  .run(
232
232
  id,
@@ -246,6 +246,8 @@ export class EventLedger {
246
246
  String(msg.raw?.author?.union_openid ?? '') || null,
247
247
  String(msg.quoted ?? '') || null,
248
248
  payload,
249
+ // 链路 id:来自 buildMessage(见 src/trace.js)。老调用方没有就给空串
250
+ String(msg.traceId ?? ''),
249
251
  );
250
252
  // changes === 0 说明撞了 dedupe 唯一约束 = 官方重推的同一条消息
251
253
  if (!r?.changes) {
@@ -419,14 +421,14 @@ export class EventLedger {
419
421
  const rows = beforeSeq
420
422
  ? this.db
421
423
  .prepare(
422
- `SELECT seq, id, kind, chat_id, user_id, user_name, content, at, time_basis, msg_idx, quoted
424
+ `SELECT seq, id, kind, chat_id, user_id, user_name, content, at, time_basis, msg_idx, quoted, trace
423
425
  FROM events WHERE bot_id = ? AND chat_id = ? AND seq < ?
424
426
  ORDER BY seq DESC LIMIT ?`,
425
427
  )
426
428
  .all(this.botId, String(chatId), Number(beforeSeq), lim)
427
429
  : this.db
428
430
  .prepare(
429
- `SELECT seq, id, kind, chat_id, user_id, user_name, content, at, time_basis, msg_idx, quoted
431
+ `SELECT seq, id, kind, chat_id, user_id, user_name, content, at, time_basis, msg_idx, quoted, trace
430
432
  FROM events WHERE bot_id = ? AND chat_id = ?
431
433
  ORDER BY seq DESC LIMIT ?`,
432
434
  )
@@ -442,6 +444,8 @@ export class EventLedger {
442
444
  timeBasis: r.time_basis,
443
445
  msgIdx: r.msg_idx,
444
446
  quoted: r.quoted,
447
+ // 链路 id:调用方拿它去查"这条消息后来发生了什么"
448
+ trace: r.trace ?? '',
445
449
  content: contentOf(r),
446
450
  }));
447
451
  }
package/src/message.js CHANGED
@@ -3,6 +3,7 @@ import { createLogger } from './logger.js';
3
3
  import { FILE_TYPE, MEDIA_SOFT_LIMIT, MEDIA_HARD_LIMIT, DIRECT_UPLOAD_LIMIT } from './api.js';
4
4
  import { messageKindOf, EVENTS } from './events.js';
5
5
  import { renderMenuMd, mdToPlain } from './markdown.js';
6
+ import { newTraceId } from './trace.js';
6
7
 
7
8
  const log = createLogger('message');
8
9
 
@@ -210,7 +211,7 @@ export function parseElements(list, depth = 0) {
210
211
  /**
211
212
  * 把官方事件体统一成好用的 Message 对象,插件只跟它打交道。
212
213
  */
213
- export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
214
+ export function buildMessage(eventName, d, { api, cfg, outbound, session, traceId }) {
214
215
  const kind = messageKindOf(eventName) ?? 'unknown';
215
216
 
216
217
  let groupId = '';
@@ -251,6 +252,13 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
251
252
 
252
253
  const msg = {
253
254
  id: d.id ?? '',
255
+ /**
256
+ * 链路 id:这一条消息从进站到回话的唯一标识。
257
+ *
258
+ * 生成点必须在内核——它先看到原始事件;插件只是路过,
259
+ * 让插件生成会出现「同一条消息被两个插件各生成一个 id」。
260
+ */
261
+ traceId: String(traceId || newTraceId()),
254
262
  kind,
255
263
  event: eventName,
256
264
  // 事件元信息(属于哪个 intent、中文说明),来自 src/events.js
package/src/outbound.js CHANGED
@@ -13,7 +13,7 @@ const log = createLogger('outbound');
13
13
  *
14
14
  * 1. 被动回复只有 5 分钟有效期,超时必失败 → 快过期时降级为主动消息
15
15
  * 2. 同一条消息最多被动回复 5 次(msg_seq 1–5)→ 用满后同样降级
16
- * 3. 同一会话每秒最多 5 条 → 本地令牌队列,超出的排队等
16
+ * 3. 发送接口 100 QPS(「每秒 5 条」只属于文字子频道,群聊 / 单聊没有这条)→ 本地令牌队列,超出的排队等
17
17
  * 4. 主动消息有两个维度:单关系 20/分钟 + 每群 1000/天 → 本地记账,用完直接拒绝
18
18
  * 5. 必须 WebSocket 在线才能发 → 离线时直接拒绝,不浪费一次网络请求
19
19
  * 6. 主动消息返回 304023 表示「已进入审核」 → 这不是失败,不能当错误处理
@@ -254,7 +254,8 @@ export class Outbound {
254
254
  this.isOnline = isOnline;
255
255
 
256
256
  const o = cfg.outbound ?? {};
257
- // 官方是 5 条/秒,默认压到 4 条,给其他来源留一点余量
257
+ // 官方没有「每会话每秒 N 条」这条限制(那只适用于文字子频道),接口级是 100 QPS;
258
+ // 这里压到 4 条/秒纯属自家的保守策略,给其他来源留一点余量
258
259
  this.perSec = Math.max(1, o.perSec ?? 4);
259
260
  /**
260
261
  * 主动消息额度。官方文档(`autogen/api/.../messages.post`)写的是两个维度:
@@ -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
+ }
@@ -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
- export async function fetchIndex(url = DEFAULT_INDEX_URL) {
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
- async function fetchIndexOnce(url) {
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('连不上插件市场');
@@ -38,6 +38,8 @@ export const DEFAULTS = {
38
38
  export function serializeMessage(msg, extra = {}) {
39
39
  return {
40
40
  id: msg.id,
41
+ // 外部插件也要能拿到,否则「任意语言插件」这条路上链路就断了
42
+ trace: msg.traceId ?? '',
41
43
  kind: msg.kind,
42
44
  group: msg.groupId || '',
43
45
  user: msg.userId || '',
@@ -12,7 +12,7 @@
12
12
  * 合表等于把统计库那句「不存消息正文」变成假话。
13
13
  */
14
14
 
15
- export const EVENTS_SCHEMA_VERSION = 1;
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
  }