qq-bot-roost 0.8.13 → 0.9.0

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-600%2B%20passed-brightgreen" alt="测试 600+ 项">
11
+ <img src="https://img.shields.io/badge/tests-700%2B%20passed-brightgreen" alt="测试 700+ 项">
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-blueviolet" alt="版本 0.8.13">
13
+ <img src="https://img.shields.io/badge/version-0.9.0-blueviolet" alt="版本 0.9.0">
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
 
@@ -418,14 +418,16 @@ bot/
418
418
  │ ├── api.js HTTP API
419
419
  │ ├── message.js 事件 → 统一 Message
420
420
  │ ├── roles.js 权限存储
421
+ │ ├── reach.js 可达性:好友关系 + 主动推送开关(官方没有查询接口,靠事件维护)
422
+ │ ├── ledger.js 事件账本:原始消息落库,官方无历史消息接口,这是唯一的历史来源
421
423
  │ ├── logger.js 日志:控制台 + 落盘 + 轮转
422
424
  │ ├── services/ 后台服务:健康巡检、清理、配置热加载、定时重启、插件热重载
423
- │ ├── storage/ 统计库:SQLite 连接、表结构迁移、消息与成员统计
425
+ │ ├── storage/ SQLite 连接与两套表结构:统计库(不存正文)/ 事件账本(存原文)
424
426
  │ ├── web/server.js 面板服务端(REST + SSE)
425
427
  │ └── plugin/ 插件加载、配置、依赖、子进程宿主、面板页面注册
426
428
  ├── test/
427
- │ ├── selftest.mjs 框架自检(431 项)
428
- │ ├── e2e.mjs 端到端测试(168 项,起真实服务打接口)
429
+ │ ├── selftest.mjs 框架自检(538 项)
430
+ │ ├── e2e.mjs 端到端测试(230 项,起真实服务打接口)
429
431
  │ ├── qrlogin.mjs 扫码建机器人(8 项)
430
432
  │ └── deps.mjs 插件依赖安装(11 项)
431
433
  ├── web/ 面板前端(原生 JS/CSS,无构建)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "qq-bot-roost",
3
- "version": "0.8.13",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "栖 Roost:零依赖的 QQ 官方机器人框架,填 AppID/AppSecret 即连,插件可写任意语言",
6
6
  "license": "SEE LICENSE IN LICENSE",
package/src/api.js CHANGED
@@ -10,8 +10,11 @@ const log = createLogger('api');
10
10
  export const FILE_TYPE = { IMAGE: 1, VIDEO: 2, VOICE: 3, FILE: 4 };
11
11
 
12
12
  /**
13
- * 官方硬限制:同一条消息最多被动回复 5 次(`msg_seq` 取 1–5)。
14
- * 第 6 次服务端返回 40034128「被动回复时间或次数超限」。
13
+ * 被动回复序号的**最后一道**兜底上限。
14
+ *
15
+ * 官方口径按会话类型不同:群聊 5 次(`msg_seq` 1–5),单聊 4 次(1–4)。
16
+ * 这里只按 `msgId` 记,拿不到会话类型,所以取两者较大的 5 当「绝不可能超过」的硬顶。
17
+ * 精确拦截在 `outbound.js`(`PASSIVE_MAX_REPLIES[kind]`),正常流程走不到这里。
15
18
  */
16
19
  const PASSIVE_MAX_SEQ = 5;
17
20
 
@@ -164,6 +167,16 @@ export class QQApi {
164
167
  const err = new Error(`HTTP ${res.status} ${method} ${path}: ${msg}`);
165
168
  err.status = res.status;
166
169
  err.body = data;
170
+ /**
171
+ * QQ 的业务错误码,和 HTTP 状态码是两回事。
172
+ *
173
+ * 同样是 400,`40054004`(无好友关系)和 `40034006`(内容违规)
174
+ * 该做的事完全不同:前者要更新可达性状态,后者只能改文案。
175
+ * 上层拿不到这个码就只能瞎猜,所以在这里就拆出来。
176
+ *
177
+ * 常见值见 `docs/官方接口限制.md` 与 `src/reach.js`。
178
+ */
179
+ err.qqCode = Number(data?.code ?? data?.err_code ?? 0) || 0;
167
180
  throw err;
168
181
  }
169
182
  return data;
@@ -946,14 +959,14 @@ export class QQApi {
946
959
  }
947
960
 
948
961
  /**
949
- * 被动回复序号。官方硬限制:**同一条消息最多回复 5 次**(msg_seq 取 1–5),
950
- * 第 6 次返回 40034128。这里兜一道,正常流程由 outbound.js 先拦。
962
+ * 被动回复序号。硬顶见 `PASSIVE_MAX_SEQ`(群聊 5 / 单聊 4,这里取 5 兜底),
963
+ * 超了服务端返回 40034128。正常流程由 outbound.js 按会话类型先拦。
951
964
  */
952
965
  #nextSeq(msgId) {
953
966
  const cur = this.seqMap.get(msgId) ?? 0;
954
967
  if (cur >= PASSIVE_MAX_SEQ) {
955
968
  throw new Error(
956
- `同一条消息最多回复 ${PASSIVE_MAX_SEQ} 次(QQ 官方限制),这条已经回了 ${cur} 次`,
969
+ `同一条消息最多回复 5 次(群聊)/ 4 次(单聊),这条已经回了 ${cur} 次`,
957
970
  );
958
971
  }
959
972
  const next = cur + 1;
package/src/bot.js CHANGED
@@ -16,6 +16,9 @@ import { BotMetrics } from './runtime.js';
16
16
  import { claimInstance, releaseInstance } from './instance.js';
17
17
  import { botPaths, DIRS, rel } from './paths.js';
18
18
  import { StatsStore } from './storage/index.js';
19
+ import { createReach } from './reach.js';
20
+ import { createLedger } from './ledger.js';
21
+ import { createGate } from './gate.js';
19
22
 
20
23
  /**
21
24
  * 一个机器人的完整运行时。
@@ -86,6 +89,25 @@ export class BotRuntime {
86
89
  */
87
90
  this.groupNames = new Map();
88
91
  this.roles = new RoleStore(this.paths.roles);
92
+ /**
93
+ * 可达性:谁能私聊、哪个群能主动推。
94
+ *
95
+ * 官方没有查好友的接口,只能靠 6 个事件维护(FRIEND_ADD/DEL、
96
+ * C2C_MSG_REJECT/RECEIVE、GROUP_MSG_REJECT/RECEIVE)。
97
+ * 插件可以通过 `bot.reach` 直接问,不用自己监听一遍。
98
+ */
99
+ this.reach = createReach({ file: this.paths.reach });
100
+ /**
101
+ * 暂停闸:三级(全局 / 这只鸟 / 单个会话)+ 静默。
102
+ *
103
+ * 这是**唯一的刹车**。以前配错一只鸟只能关进程,那连别的鸟一起停了。
104
+ * 闸状态按鸟拆文件(`data/gate/<id>.json`),跨鸟只有 `global.json` 一个。
105
+ */
106
+ this.gate = createGate({
107
+ dir: DIRS.gate,
108
+ botId: this.id,
109
+ log: (m) => this.log.warn(m),
110
+ });
89
111
  this.outbound = null;
90
112
  this.session = null;
91
113
  this.pipeline = null;
@@ -159,6 +181,7 @@ export class BotRuntime {
159
181
  this.pipeline = createDefaultPipeline(cfg, {
160
182
  isSticky: (chatId) => this.session.isSticky(chatId),
161
183
  metrics: this.metrics,
184
+ gate: this.gate,
162
185
  });
163
186
 
164
187
  /**
@@ -171,6 +194,19 @@ export class BotRuntime {
171
194
  retentionDays: cfg.stats?.retentionDays ?? 90,
172
195
  });
173
196
 
197
+ /**
198
+ * 事件账本:官方没有历史消息接口,这是**唯一的历史来源**。
199
+ *
200
+ * 和 `stats` 是两个库、两套保留策略:统计留 90 天聚合且不存正文,
201
+ * 账本只留 7 天但存原文。混一个库的话「这个库会不会被拷走」就说不清了。
202
+ */
203
+ this.ledger = createLedger({
204
+ file: this.paths.events,
205
+ botId: this.id,
206
+ enabled: cfg.ledger?.enabled !== false,
207
+ retentionDays: cfg.ledger?.retentionDays ?? 7,
208
+ });
209
+
174
210
  this.plugins = new PluginManager({
175
211
  pluginsDir: DIRS.plugins,
176
212
  paths: this.paths,
@@ -183,6 +219,9 @@ export class BotRuntime {
183
219
  outbound: this.outbound,
184
220
  session: this.session,
185
221
  pipeline: this.pipeline,
222
+ reach: this.reach,
223
+ ledger: this.ledger,
224
+ gate: this.gate,
186
225
  isOwner: (uid) => cfg.owners.includes(uid),
187
226
  // 插件声明了 frameworkVersion 就做兼容校验;作者本地调试可以关掉
188
227
  ignoreCompat: this.globalCfg.plugins?.ignoreFrameworkVersion === true,
@@ -194,6 +233,24 @@ export class BotRuntime {
194
233
 
195
234
  // 统计库要在插件之前打开:插件上报的运行数据也算统计的一部分
196
235
  this.stats.open();
236
+ // 账本更要在插件之前开:插件一加载就可能开始收消息,漏一条就永久没了
237
+ this.ledger.open();
238
+
239
+ /**
240
+ * 闸要在插件加载之前读好,否则插件 setup 里发的第一条主动消息就漏过了闸。
241
+ *
242
+ * `pauseOnStart` 只在**首次运行**生效(闸文件还不存在)。
243
+ * 每次启动都暂停会让人以为坏了——重启之后不说话,第一反应是「出故障了」,
244
+ * 不是「等我确认」。方案里那条「安装时自动暂停」靠这个分支实现。
245
+ */
246
+ const { botExisted } = await this.gate.open();
247
+ if (cfg.gate?.pauseOnStart && !botExisted) {
248
+ await this.gate.setBot(true, {
249
+ by: 'config',
250
+ reason: '首次启动自动暂停(gate.pauseOnStart),确认配置无误后在面板放开',
251
+ });
252
+ this.log.warn('首次启动已自动暂停,确认配置无误后在面板「运行」页放开');
253
+ }
197
254
 
198
255
  // 按配置过滤要加载的插件
199
256
  await this.plugins.scan({ filter: pluginFilterOf(cfg.plugins) });
@@ -344,6 +401,51 @@ export class BotRuntime {
344
401
  return this.groupNames.get(groupOpenid)?.name || undefined;
345
402
  }
346
403
 
404
+ /**
405
+ * 用 6 个事件维护可达性。
406
+ *
407
+ * 官方**没有查好友列表的接口**(sitemap 里 `friend` 只有两个事件页),
408
+ * 所以「接入之前就存在的关系」永远是未知的,只能维护「接入之后的变化」。
409
+ * 三态语义与判定顺序见 `src/reach.js` 头注释。
410
+ */
411
+ async #noteReach(eventName, d) {
412
+ const openid = d?.openid ?? '';
413
+ switch (eventName) {
414
+ case 'FRIEND_ADD':
415
+ await this.reach.noteFriendAdd({
416
+ openid,
417
+ unionOpenid: d?.author?.union_openid ?? '',
418
+ scene: d?.scene ?? 0,
419
+ });
420
+ break;
421
+ case 'FRIEND_DEL':
422
+ await this.reach.noteFriendDel({ openid });
423
+ break;
424
+ case 'C2C_MSG_REJECT':
425
+ await this.reach.noteC2cPush({ openid, on: false });
426
+ break;
427
+ case 'C2C_MSG_RECEIVE':
428
+ await this.reach.noteC2cPush({ openid, on: true });
429
+ break;
430
+ case 'GROUP_MSG_REJECT':
431
+ await this.reach.noteGroupPush({
432
+ groupOpenid: d?.group_openid,
433
+ on: false,
434
+ by: d?.op_member_openid,
435
+ });
436
+ break;
437
+ case 'GROUP_MSG_RECEIVE':
438
+ await this.reach.noteGroupPush({
439
+ groupOpenid: d?.group_openid,
440
+ on: true,
441
+ by: d?.op_member_openid,
442
+ });
443
+ break;
444
+ default:
445
+ break;
446
+ }
447
+ }
448
+
347
449
  /**
348
450
  * 补一个群的名字(给面板调的公开入口,内部走带缓存的那份)。
349
451
  * 不会抛异常,调用方不用 try/catch。
@@ -415,11 +517,96 @@ export class BotRuntime {
415
517
  await this.outbound?.flush().catch(() => {});
416
518
  await this.groups?.flush().catch(() => {});
417
519
  await this.roles?.flush().catch(() => {});
520
+ await this.reach?.flush().catch(() => {});
521
+ await this.gate?.flush().catch(() => {});
418
522
  this.stats?.close();
523
+ this.ledger?.close();
419
524
  if (!this.mock) await releaseInstance(this.cfg.appId).catch(() => {});
420
525
  this.state = 'stopped';
421
526
  }
422
527
 
528
+ /**
529
+ * 这只鸟现在能不能对 `chatId` 说话,以及不能的**原因**。
530
+ *
531
+ * 故障排查从「猜」变成「读」:面板直接把 `reasons` 列成中文,
532
+ * 用户不用去翻日志猜「为什么她不说话」。
533
+ *
534
+ * 归到这里的四类状态:闸(三级 + 静默)、网关在线、日额度、分钟限流。
535
+ * 没放 `Outbound` 里的理由:outbound 是发送执行器,只知道额度和离线,
536
+ * 不知道闸;而这个方法要回答的是「这只鸟现在能不能开口」,
537
+ * 是**运行时诊断**,不是发送动作。
538
+ *
539
+ * @param {string} chatId
540
+ * @param {{kind?:'group'|'c2c'}} [opts] 给了就按对应口径查额度;
541
+ * 不给就取两种里用得多的那个(chatId 在群和私聊里不会撞)
542
+ * @returns {Promise<{ok:boolean, reasons:{code:string,text:string}[]}>}
543
+ */
544
+ async canSpeak(chatId, { kind = '' } = {}) {
545
+ const reasons = [...this.gate.check(chatId).reasons];
546
+
547
+ /**
548
+ * 可达性:只对**主动消息**有意义。
549
+ *
550
+ * 用户刚说完话时的被动回复不受这两道门限制——他就在这儿,回一句当然收得到。
551
+ * 所以必须传 `kind` 才查:不知道是群还是私聊就查不了,而「不知道」不该变成「不能」。
552
+ *
553
+ * `unknown` 一律放行:群里聊过的人很可能早就加了好友,只是还没私聊过。
554
+ */
555
+ if (kind === 'c2c') {
556
+ const v = await this.reach?.canC2C?.(chatId).catch(() => null);
557
+ if (v?.verdict === 'no') {
558
+ reasons.push({
559
+ code: v.reason || 'reach:not-friend',
560
+ text:
561
+ v.reason === 'reach:push-off'
562
+ ? '对方在资料卡里关掉了主动消息推送'
563
+ : '对方没有加机器人好友',
564
+ });
565
+ }
566
+ } else if (kind === 'group') {
567
+ const v = await this.reach?.canGroupPush?.(chatId).catch(() => null);
568
+ if (v?.verdict === 'no') {
569
+ reasons.push({
570
+ code: v.reason || 'reach:push-off',
571
+ text: '群管理员关闭了机器人的主动消息推送',
572
+ });
573
+ }
574
+ }
575
+
576
+ if (!this.mock && this.state !== 'online') {
577
+ reasons.push({
578
+ code: 'gateway:offline',
579
+ text: `没连上 QQ(当前状态:${this.state})`,
580
+ });
581
+ }
582
+
583
+ if (this.outbound) {
584
+ const keys = kind ? [`${kind}:${chatId}`] : [`group:${chatId}`, `c2c:${chatId}`];
585
+ const q = await this.outbound.quotaSnapshot().catch(() => ({}));
586
+ const used = Math.max(0, ...keys.map((k) => Number(q[k] ?? 0)));
587
+ if (used >= this.outbound.dailyActive) {
588
+ reasons.push({
589
+ code: 'quota:daily',
590
+ text: `今天的主动消息额度用完了(${used}/${this.outbound.dailyActive} 条)`,
591
+ });
592
+ }
593
+ /**
594
+ * 分钟限流也列出来,但它是**瞬时**状态——面板上看到这条通常
595
+ * 等一分钟就自己好了。列它的价值是「不用怀疑别的」,
596
+ * 而不是让用户去改配置。
597
+ */
598
+ const burst = Math.max(0, ...keys.map((k) => this.outbound.rateUsed(k)));
599
+ if (burst >= this.outbound.activePerMin && this.outbound.activePerMin > 0) {
600
+ reasons.push({
601
+ code: 'quota:permin',
602
+ text: `这一分钟发得太密(${burst}/${this.outbound.activePerMin} 条),等一分钟就好`,
603
+ });
604
+ }
605
+ }
606
+
607
+ return { ok: reasons.length === 0, reasons };
608
+ }
609
+
423
610
  /* ---------------- 事件分发 ---------------- */
424
611
 
425
612
  /**
@@ -456,6 +643,12 @@ export class BotRuntime {
456
643
  await groups.onRemoved(d?.group_openid);
457
644
  }
458
645
 
646
+ // 可达性:官方没有查询接口,只能靠这 6 个事件维护(见 src/reach.js)
647
+ // 放在插件监听器之前,插件读到的就是最新状态
648
+ await this.#noteReach(eventName, d).catch((e) => {
649
+ this.log.warn(`可达性更新失败: ${e.message}`);
650
+ });
651
+
459
652
  const meta = EVENTS[eventName] ?? null;
460
653
  for (const l of registry.listeners.get(eventName) ?? []) {
461
654
  // 事件里的群:有 group_openid 就按群开关过滤,没有(如私聊事件)就放行
@@ -478,6 +671,42 @@ export class BotRuntime {
478
671
 
479
672
  /* ---- 消息事件 ---- */
480
673
  const msg = buildMessage(eventName, d, { api: this.api, cfg, outbound, session });
674
+
675
+ /**
676
+ * 入账:**第一件要做的事**,在去重 / 白名单 / 限流 / 暂停之前。
677
+ *
678
+ * 顺序是有讲究的。账本记的是「发生过什么」,不是「我们怎么处理的」:
679
+ * - 被去重拦下的那条,官方确实推了两次,事实是「推了两次」,只是我们处理了一次;
680
+ * - 暂停期间的消息如果不记,恢复后就永久消失了——官方没有历史消息接口,补不回来。
681
+ *
682
+ * 重复推送由 `dedupe` 唯一约束兜底(官方明说「相同 msg_id 可能重复推送」),
683
+ * 所以放在最前面也不会记重。
684
+ *
685
+ * 同步写:`node:sqlite` 是同步 API,一条 INSERT 在 WAL 下是微秒级,
686
+ * 比「攒批 + 定时刷」更安全——进程被 kill 时不会丢最后一批。
687
+ */
688
+ /**
689
+ * 入账的返回值挂到消息上:`msg.eventId` / `msg.eventSeq`。
690
+ *
691
+ * dsh-roost 写记忆时要记「这条记忆从哪条消息来」(场景片段,阶段 4),
692
+ * 而它是另一个进程,只能靠转发时带过去的 id 反查原文。
693
+ *
694
+ * 撞 dedupe(官方重推同一条)时 `append` 也返回已有那条的 id,
695
+ * 所以重推的消息一样有出处。
696
+ */
697
+ const rec = this.ledger?.append(msg);
698
+ if (rec) {
699
+ msg.eventId = rec.id;
700
+ msg.eventSeq = rec.seq;
701
+ }
702
+
703
+ // 收到私聊 = 好友关系存在的强证据。不置 c2cPush:他能发不等于他允许机器人主动推
704
+ if (msg.kind === 'c2c' && msg.userId) {
705
+ this.reach
706
+ .noteC2cObserved({ openid: msg.userId, unionOpenid: d?.author?.union_openid })
707
+ .catch(() => {});
708
+ }
709
+
481
710
  /**
482
711
  * 先播出去,再做业务处理。
483
712
  *
@@ -606,7 +835,8 @@ export class BotRuntime {
606
835
  await commandEntry.handler(msg, { args: msg.args, raw: msg.content, bot: this });
607
836
  } catch (e) {
608
837
  this.metrics.bump('errors');
609
- if (e?.code === 'offline' || e?.code === 'expired' || e?.code === 'quota') {
838
+ // 这几种是发送侧的已知限制,不是插件写错了,只 warn 不报堆栈
839
+ if (['offline', 'expired', 'quota', 'rate'].includes(e?.code)) {
610
840
  this.log.warn(`命令 ${commandEntry.name} 回复失败: ${e.message}`);
611
841
  return;
612
842
  }
package/src/config.js CHANGED
@@ -242,6 +242,39 @@ const BOT_DEFAULTS = {
242
242
  retentionDays: 90,
243
243
  },
244
244
 
245
+ /**
246
+ * 事件账本:存原始消息,官方没有历史消息接口,这是唯一的历史来源。
247
+ *
248
+ * 和 `stats` 刻意分开:stats 不存正文(只计数),ledger 存正文。
249
+ * 保留期也短得多——全文落库是隐私成本,只留够做上下文和幂等的窗口即可,
250
+ * 长期记忆的证据由 dsh-roost 的 journal 自己留快照。
251
+ */
252
+ ledger: {
253
+ enabled: true,
254
+ /** 原始消息保留天数。7 天够「最近聊了什么」,再长就该由记忆层负责了 */
255
+ retentionDays: 7,
256
+ },
257
+
258
+ /**
259
+ * 暂停闸。
260
+ *
261
+ * 三级闸本身的状态不写在配置里(它天天变,写在 `data/gate/`),
262
+ * 这里只有一个**启动策略**。
263
+ */
264
+ gate: {
265
+ /**
266
+ * 首次启动自动暂停。**默认 false**。
267
+ *
268
+ * 对标的产品每次启动都全局暂停,我们不抄:他们是「先隔离检查再放开」的
269
+ * 探索型工具,我们是长期挂机的生产型框架。重启之后不说话,
270
+ * 用户第一反应是「坏了」,不是「等我确认」。
271
+ *
272
+ * 所以只在**首次运行**(闸文件还不存在)时生效:第一次装机要确认,
273
+ * 之后日常重启不添堵。安装向导生成配置时可以写 true。
274
+ */
275
+ pauseOnStart: false,
276
+ },
277
+
245
278
  /**
246
279
  * 启用哪些插件。三态:
247
280
  * all — 全部启用,新装插件自动生效
@@ -471,6 +504,8 @@ export async function saveBotConfig(id, cfg) {
471
504
  outbound: cfg.outbound,
472
505
  session: cfg.session,
473
506
  stats: cfg.stats ?? { enabled: true, retentionDays: 90 },
507
+ ledger: cfg.ledger ?? { enabled: true, retentionDays: 7 },
508
+ gate: cfg.gate ?? { pauseOnStart: false },
474
509
  plugins: cfg.plugins,
475
510
  groupManage: cfg.groupManage ?? { invited: false },
476
511
  });