qq-bot-roost 0.9.8 → 0.9.10

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
@@ -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-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.8-blueviolet" alt="版本 0.9.8">
13
+ <img src="https://img.shields.io/badge/version-0.9.10-blueviolet" alt="版本 0.9.10">
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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
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 && node --test test/trust.mjs && node --test test/manifest.mjs && node --test test/trace.mjs && node --test test/fuzz-tar.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 && node --test test/group-name.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 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",
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 test/group-name.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/bot.js CHANGED
@@ -89,6 +89,16 @@ export class BotRuntime {
89
89
  * 拉不到就退回显示 openid,不影响任何功能。
90
90
  */
91
91
  this.groupNames = new Map();
92
+ /** 同群并发补名时复用同一个请求,避免一条消息打两次接口 */
93
+ this.groupNameInflight = new Map();
94
+ /**
95
+ * 群名接口的**全局**并发闸(`getGroupInfo` 是 30 QPM)。
96
+ *
97
+ * 原来只在 `fetchGroupNames` 里限流,那是「每次调用」的上限:
98
+ * 面板批量拉一次、消息链路每条群消息又拉一次,两条并发就翻倍。
99
+ */
100
+ this.groupNameActive = 0;
101
+ this.groupNameWaiters = [];
92
102
  this.roles = new RoleStore(this.paths.roles);
93
103
  /**
94
104
  * 可达性:谁能私聊、哪个群能主动推。
@@ -484,20 +494,45 @@ export class BotRuntime {
484
494
  * 再自己刷新一次很别扭;但也不能为了几个群名把面板卡住(接口有频控,
485
495
  * 群多了还会串行超时)。所以给一个总超时,到点有多少用多少。
486
496
  *
497
+ * **并发要限**:`getGroupInfo` 是 30 QPM。以前这里是 `allSettled` 全并行,
498
+ * 群多的用户一次打开面板就打 30+ 个请求,正好撞在限频上——
499
+ * 结果是**一个群名都拿不到**,面板继续显示一串 openid。
500
+ *
487
501
  * @param {string[]} ids
488
- * @param {{timeoutMs?:number}} [opts]
502
+ * @param {{timeoutMs?:number, concurrency?:number}} [opts]
489
503
  */
490
- async fetchGroupNames(ids = [], { timeoutMs = 2500 } = {}) {
491
- const todo = [...new Set(ids.filter((id) => id && !this.groupName(id)))];
504
+ async fetchGroupNames(ids = [], { timeoutMs = 2500, concurrency = 4 } = {}) {
505
+ /** NaN / 0 / 负数都退回默认值:`Math.min(NaN, n)` 是 NaN,`Array.from({length:NaN})` 是空数组,一个群都不拉 */
506
+ const limit = Number.isFinite(concurrency) && concurrency >= 1 ? Math.floor(concurrency) : 4;
507
+ /**
508
+ * 不能只拉「还没有名字」的群:那样群改名后缓存永远不刷新。
509
+ * 统一交给 #ensureGroupName 按 10 分钟 TTL 判断——缓存新的时候它直接返回,不打接口。
510
+ */
511
+ const todo = [...new Set(ids.filter(Boolean))];
492
512
  if (!todo.length) return;
493
513
 
494
- const work = Promise.allSettled(todo.map((id) => this.#ensureGroupName(id)));
514
+ const queue = todo.slice();
515
+ /** 到点之后不再派新的:已经发出去的请求收不回来,但尾巴不能一直延长 */
516
+ let stopped = false;
517
+ const worker = async () => {
518
+ for (;;) {
519
+ if (stopped) return;
520
+ const id = queue.shift();
521
+ if (!id) return;
522
+ // #ensureGroupName 自己吞异常,这里再兜一层只为保险
523
+ await this.#ensureGroupName(id).catch(() => {});
524
+ }
525
+ };
526
+ const n = Math.max(1, Math.min(limit, queue.length));
527
+ const work = Promise.all(Array.from({ length: n }, worker));
528
+
495
529
  let timer;
496
530
  const cap = new Promise((r) => {
497
531
  timer = setTimeout(r, timeoutMs);
498
532
  timer.unref?.();
499
533
  });
500
534
  await Promise.race([work, cap]);
535
+ stopped = true;
501
536
  clearTimeout(timer);
502
537
  }
503
538
 
@@ -516,16 +551,58 @@ export class BotRuntime {
516
551
  async #ensureGroupName(groupOpenid) {
517
552
  if (!groupOpenid) return;
518
553
  const hit = this.groupNames.get(groupOpenid);
519
- if (hit && Date.now() - hit.at < 10 * 60_000) return;
554
+ /** ttl 由写入时决定:成功 10 分钟;11253(无权限)一天;其它失败 30 秒 */
555
+ if (hit && Date.now() - hit.at < (hit.ttl ?? 10 * 60_000)) return;
520
556
 
521
- try {
522
- const info = await this.api.getGroupInfo(groupOpenid);
523
- const name = String(info?.group_name ?? info?.name ?? '').trim();
524
- this.groupNames.set(groupOpenid, { name, at: Date.now() });
525
- } catch {
526
- // 拉不到就算了:openid 也能认,只是没那么好认
527
- this.groupNames.set(groupOpenid, { name: '', at: Date.now() });
557
+ /**
558
+ * 同一条群消息可能触发两次(消息事件里一次、面板补一次),
559
+ * 两次都在 `await getGroupInfo` 之前查缓存 → 都判 miss → 打两次接口。
560
+ * 用 in-flight Map 让同 key 复用同一个 promise。
561
+ */
562
+ const flying = this.groupNameInflight.get(groupOpenid);
563
+ if (flying) return flying;
564
+
565
+ const job = (async () => {
566
+ /** 全局闸:不管从哪条链路进来,同时在飞的群名请求不超过 4 个 */
567
+ await this.#groupNameSlot();
568
+ try {
569
+ const info = await this.api.getGroupInfo(groupOpenid);
570
+ const name = String(info?.group_name ?? info?.name ?? '').trim();
571
+ this.groupNames.set(groupOpenid, { name, at: Date.now(), ttl: 10 * 60_000 });
572
+ } catch (e) {
573
+ // 拉不到就算了:openid 也能认,只是没那么好认
574
+ /** api.js 抛的是 `qqCode`,不是 code;读错字段会让 11253 永远按 30 秒重试 */
575
+ const code = Number(e?.qqCode ?? e?.code ?? e?.errCode ?? e?.errcode ?? 0);
576
+ this.groupNames.set(groupOpenid, {
577
+ name: '',
578
+ at: Date.now(),
579
+ // 11253 = 应用无接口访问权限(仅白名单机器人可用),别每 30 秒重试
580
+ ttl: code === 11253 ? 24 * 3600_000 : 30_000,
581
+ });
582
+ } finally {
583
+ this.#freeGroupNameSlot();
584
+ this.groupNameInflight.delete(groupOpenid);
585
+ }
586
+ })();
587
+
588
+ this.groupNameInflight.set(groupOpenid, job);
589
+ return job;
590
+ }
591
+
592
+ /** 拿一个群名请求的名额(最多 4 个同时在飞) */
593
+ async #groupNameSlot() {
594
+ if (this.groupNameActive < 4) {
595
+ this.groupNameActive += 1;
596
+ return;
528
597
  }
598
+ await new Promise((resolve) => this.groupNameWaiters.push(resolve));
599
+ }
600
+
601
+ #freeGroupNameSlot() {
602
+ const next = this.groupNameWaiters.shift();
603
+ // 有人排队就把名额直接转给它(active 不变),没人排队才真的空出来
604
+ if (next) next();
605
+ else this.groupNameActive = Math.max(0, this.groupNameActive - 1);
529
606
  }
530
607
 
531
608
  async stop() {
@@ -695,6 +772,20 @@ export class BotRuntime {
695
772
  /* ---- 消息事件 ---- */
696
773
  const msg = buildMessage(eventName, d, { api: this.api, cfg, outbound, session });
697
774
 
775
+ /**
776
+ * 群名不在官方事件里(只有 `group_openid`),但框架有缓存。
777
+ *
778
+ * 以前只缓存不挂到消息上,ai-chat 取不到群名就回退成**发消息人的昵称**——
779
+ * 结果群会话的标题显示成「我的昵称」,和私聊分不开。
780
+ * 这里挂上缓存值,并异步补拉(不阻塞这条消息)。补拉按 TTL 去重,
781
+ * 所以群改名后最多 10 分钟能刷到。
782
+ */
783
+ if (msg.kind === 'group' && msg.groupId) {
784
+ msg.groupName = this.groupName(msg.groupId) || '';
785
+ /** 每条群消息都问一次(内部按 TTL 去重):群改名后能刷到,没权限时也不会反复打接口 */
786
+ this.fetchGroupNames([msg.groupId]).catch(() => {});
787
+ }
788
+
698
789
  /**
699
790
  * 入账:**第一件要做的事**,在去重 / 白名单 / 限流 / 暂停之前。
700
791
  *
package/src/message.js CHANGED
@@ -259,6 +259,11 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session, traceI
259
259
  * 让插件生成会出现「同一条消息被两个插件各生成一个 id」。
260
260
  */
261
261
  traceId: String(traceId || newTraceId()),
262
+ /**
263
+ * 内核拿到这条事件的时间。四段耗时的第一段靠它算:
264
+ * 官方事件到达 → 插件真正拿到,中间是内核的分发排队。
265
+ */
266
+ receivedAt,
262
267
  kind,
263
268
  event: eventName,
264
269
  // 事件元信息(属于哪个 intent、中文说明),来自 src/events.js
package/src/updater.js CHANGED
@@ -74,9 +74,9 @@ export async function currentVersion() {
74
74
  *
75
75
  * 留 2 秒容差:解压/拷贝和 stat 之间总有毫秒级误差。
76
76
  */
77
- async function updatedButNotRestarted() {
77
+ async function updatedButNotRestarted(pkgPath = path.join(ROOT, 'package.json')) {
78
78
  try {
79
- const st = await fs.stat(path.join(ROOT, 'package.json'));
79
+ const st = await fs.stat(pkgPath);
80
80
  const startedAt = Date.now() - process.uptime() * 1000;
81
81
  return st.mtimeMs > startedAt + 2000;
82
82
  } catch {
@@ -179,8 +179,13 @@ async function fetchTreeSha256(version) {
179
179
  }
180
180
  }
181
181
 
182
- export async function checkUpdate() {
183
- const needsRestart = await updatedButNotRestarted();
182
+ /**
183
+ * @param {{pkgPath?: string}} [o] pkgPath 只给测试用:
184
+ * 自测原来直接改仓库里 package.json 的 mtime,两个 `npm test` 并行就会互相毒化
185
+ * (一个把 mtime 拨到未来、另一个正好读到,于是「正常时不提示重启」假失败)。
186
+ */
187
+ export async function checkUpdate({ pkgPath } = {}) {
188
+ const needsRestart = await updatedButNotRestarted(pkgPath);
184
189
  const r = await checkUpdateCore();
185
190
  // 有新版本才值得多跑一次网络请求
186
191
  if (r?.ok && r.hasUpdate) {
package/web/app.js CHANGED
@@ -2268,6 +2268,7 @@ function renderMarketRows() {
2268
2268
  <span class="kind-tag ${p.runtime === 'node' ? 'int' : 'ext'}">
2269
2269
  ${p.runtime === 'node' ? '内建 · node' : '外部 · ' + escapeHtml(p.runtime || '')}
2270
2270
  </span>
2271
+ ${verifyTag(p)}
2271
2272
  ${p.author ? `<span class="hint">by ${escapeHtml(p.author)}</span>` : ''}
2272
2273
  ${p.credits ? `<span class="hint">${escapeHtml(p.credits)}</span>` : ''}
2273
2274
  ${p.description ? '<br>' + escapeHtml(p.description) : ''}
@@ -3585,6 +3586,23 @@ function escapeHtml(s) {
3585
3586
  return String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
3586
3587
  }
3587
3588
 
3589
+ /**
3590
+ * 插件分级标签。
3591
+ *
3592
+ * 分级是**我们的判断**(写在插件仓的 verified.json 里),不是插件自述;
3593
+ * 签名是另一回事——它是「这份索引没被改过」的全体属性,所有签名插件
3594
+ * 都落同一个状态,所以不能做成档位。
3595
+ */
3596
+ function verifyTag(p) {
3597
+ const map = {
3598
+ official: ['官方维护', 'vt-official', '跟着框架一起发布,代码在同一个仓库里'],
3599
+ reviewed: ['已复核', 'vt-reviewed', '我们看过代码、试过能跑,但不由我们维护'],
3600
+ self: ['作者自证', 'vt-self', '作者声明的内容,我们没有核对'],
3601
+ };
3602
+ const [label, cls, tip] = map[p.verified] || ['未验证', 'vt-none', '没有分级信息'];
3603
+ return `<span class="verify-tag ${cls}" title="${escapeHtml(tip)}">${label}</span>`;
3604
+ }
3605
+
3588
3606
  function applyFilter() {
3589
3607
  renderLogs();
3590
3608
  }
package/web/style.css CHANGED
@@ -971,6 +971,17 @@ label.switch.small { font-size: 12.5px; color: var(--muted); }
971
971
  background: var(--primary-soft, var(--panel-3)); color: var(--primary);
972
972
  }
973
973
 
974
+ /* 插件分级标签:官方 / 已复核 / 作者自证 / 未验证 */
975
+ .verify-tag {
976
+ display: inline-block; margin-right: 6px; padding: 0 6px;
977
+ border-radius: 4px; font-size: 10.5px; font-weight: 600;
978
+ vertical-align: 1px;
979
+ }
980
+ .verify-tag.vt-official { background: var(--primary-soft); color: var(--primary); }
981
+ .verify-tag.vt-reviewed { background: var(--c-teal-soft); color: var(--c-teal); }
982
+ .verify-tag.vt-self { background: var(--warn-soft); color: var(--warn); }
983
+ .verify-tag.vt-none { background: var(--panel-3); color: var(--muted); }
984
+
974
985
  /* Beta 标记:签名开关还是 beta,不能让它看起来跟别的开关一样成熟 */
975
986
  .beta-tag {
976
987
  display: inline-block; margin-left: 6px; padding: 0 7px;