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.
@@ -104,7 +104,7 @@ export function serializeMessage(botId, msg) {
104
104
  mentioned: Boolean(msg.mentioned),
105
105
  addressed: Boolean(msg.addressed),
106
106
  isCommand: Boolean(msg.isCommand),
107
- /** 被动回复用(5 分钟窗口,同一条最多 5 次) */
107
+ /** 被动回复用(群聊 5 分钟 / 5 次,单聊 60 分钟 / 4 次) */
108
108
  msgId: msg.id,
109
109
  /** 引用回复用(message_reference.message_id) */
110
110
  msgIdx: msg.msgIdx,
package/src/message.js CHANGED
@@ -362,7 +362,7 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
362
362
  }
363
363
 
364
364
  // 底层发送:group 走群接口,c2c 走单聊接口。
365
- // 一律经过 outbound,由它处理 5 分钟窗口、每秒限速、主动消息额度。
365
+ // 一律经过 outbound,由它处理被动窗口(群聊 5 分钟 / 单聊 60 分钟)、每秒限速、主动消息额度。
366
366
  const send = (opts, passive) => {
367
367
  if (kind !== 'group' && kind !== 'c2c') return Promise.resolve(null);
368
368
  const body = passive ? { msgId: msg.id, ...opts } : { ...opts };
@@ -410,7 +410,7 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
410
410
  const MAX_CHUNK = 1500; // 单条上限,超了按行切成多条
411
411
 
412
412
  /**
413
- * 被动回复(带 msg_id,5 分钟内有效)。
413
+ * 被动回复(带 msg_id,群聊 5 分钟内、单聊 60 分钟内有效)。
414
414
  * **不会立刻发出**,而是攒到本次处理结束,由 flushReplies() 合并成一条。
415
415
  * 想立刻发用 msg.replyNow()。
416
416
  */
@@ -446,7 +446,7 @@ export function buildMessage(eventName, d, { api, cfg, outbound, session }) {
446
446
  * 引用回复:以「引用某条消息」的样式发出去(官方 `message_reference`)。
447
447
  *
448
448
  * 跟 `reply()` 不是一回事:
449
- * - `reply()` 是**被动回复**,靠 msg_id,5 分钟内、同一条最多 5 次
449
+ * - `reply()` 是**被动回复**,靠 msg_id,群聊 5 分钟 / 5 次,单聊 60 分钟 / 4 次
450
450
  * - 这个是**引用**,靠 `REFIDX`,视觉上带"回复某某"的引用块
451
451
  *
452
452
  * 默认引用当前这条消息(用它的 `msg_idx`)。想引用别的传 `refIdx`。
package/src/outbound.js CHANGED
@@ -20,15 +20,36 @@ const log = createLogger('outbound');
20
20
  * 插件只管调 msg.reply(),剩下的交给这里。
21
21
  */
22
22
 
23
- /** 官方给的有效期是 5 分钟,留 15 秒给网络和服务端时钟差 */
24
- const PASSIVE_WINDOW_MS = 5 * 60 * 1000;
25
- const PASSIVE_SAFE_MS = PASSIVE_WINDOW_MS - 15_000;
23
+ /**
24
+ * 被动回复窗口。**两个接口不一样,别再用一个值。**
25
+ *
26
+ * 群聊 `/v2/groups/{group_openid}/messages`:5 分钟、最多 5 次
27
+ * 单聊 `/v2/users/{user_openid}/messages`:**60 分钟、最多 4 次**
28
+ *
29
+ * 以前两者共用「5 分钟 / 5 次」(群聊的口径),单聊的后果是:
30
+ * · 5 分钟后就降级成主动消息,白吃「每个好友 1000 条/天」的额度,
31
+ * 而被动回复本来不占主动额度;
32
+ * · 第 5 次回复会被服务端拒(40034128)。
33
+ *
34
+ * 各留 15 秒给网络和服务端时钟差。
35
+ */
36
+ const PASSIVE_WINDOW_MS = {
37
+ group: 5 * 60 * 1000,
38
+ c2c: 60 * 60 * 1000,
39
+ };
40
+ const PASSIVE_SAFE_MS = {
41
+ group: PASSIVE_WINDOW_MS.group - 15_000,
42
+ c2c: PASSIVE_WINDOW_MS.c2c - 15_000,
43
+ };
26
44
 
27
45
  /**
28
- * 官方硬限制:同一条消息最多回复 5 次(`msg_seq` 取 1–5)。
29
- * 第 6 次服务端会返回 40034128「被动回复时间或次数超限」。
46
+ * 同一条消息最多回复次数:群聊 5、单聊 4。
47
+ * 超过会返回 40034128「被动回复时间或次数超限」。
30
48
  */
31
- const PASSIVE_MAX_REPLIES = 5;
49
+ const PASSIVE_MAX_REPLIES = {
50
+ group: 5,
51
+ c2c: 4,
52
+ };
32
53
 
33
54
  /** 主动消息进入审核的返回码,属于正常结果 */
34
55
  const CODE_AUDIT = 304023;
@@ -69,7 +90,15 @@ export class SendError extends Error {
69
90
  constructor(message, code, detail) {
70
91
  super(message);
71
92
  this.name = 'SendError';
72
- this.code = code; // offline | expired | quota | api
93
+ /**
94
+ * 五种,按「多久能好」排序:
95
+ * offline 连不上 QQ —— 等它上线
96
+ * expired 被动窗口过了 —— 等用户再说一句
97
+ * quota 今天这个会话的主动额度用完了 —— 等跨天
98
+ * rate 这一分钟发得太密 —— 等一分钟
99
+ * api 接口报错 —— 看具体错误码
100
+ */
101
+ this.code = code; // offline | expired | quota | rate | api
73
102
  this.detail = detail;
74
103
  }
75
104
  }
@@ -207,7 +236,8 @@ export class Outbound {
207
236
  active: 0,
208
237
  degraded: 0, // 被动超窗降级为主动的次数
209
238
  expired: 0, // 因超窗被拒绝的次数
210
- quotaBlocked: 0, // 因额度用完被拒绝的次数
239
+ quotaBlocked: 0, // 因日额度用完被拒绝的次数(等跨天才好)
240
+ rateBlocked: 0, // 因分钟限流被拒绝的次数(等一分钟就好)
211
241
  throttled: 0, // 因限速排队过的次数
212
242
  audit: 0, // 进入审核的主动消息
213
243
  offline: 0,
@@ -245,11 +275,12 @@ export class Outbound {
245
275
  // 1) 被动窗口检查:快过期就别发请求了,直接按主动消息处理
246
276
  if (wantPassive) {
247
277
  const age = receivedAt ? Date.now() - receivedAt : 0;
248
- if (age > PASSIVE_SAFE_MS) {
278
+ const safeMs = PASSIVE_SAFE_MS[kind];
279
+ if (age > safeMs) {
249
280
  if (!this.downgrade) {
250
281
  this.stats.expired += 1;
251
282
  throw new SendError(
252
- `这条消息已经过去 ${Math.round(age / 1000)} 秒,超过 QQ 的 5 分钟被动回复窗口,回复发不出去了`,
283
+ `这条消息已经过去 ${Math.round(age / 1000)} 秒,超过 QQ 的 ${Math.round(safeMs / 1000)} 秒被动回复窗口,回复发不出去了`,
253
284
  'expired',
254
285
  );
255
286
  }
@@ -267,17 +298,18 @@ export class Outbound {
267
298
  // 2) 排队 + 限速 + 额度扣减 + 实际发送
268
299
  // 额度的「查」和「扣」都放在队列里,避免两个并发推送同时通过检查后超额
269
300
  return this.#enqueue(chatKey, async () => {
270
- // 2a) 被动回复次数上限:官方「每个消息最多回复 5 次」
301
+ // 2a) 被动回复次数上限:群聊 5 次、单聊 4 次
271
302
  // 同一条消息被多个插件连着回、或用户一句话触发多轮时很容易撞上,
272
303
  // 本地先拦,省得服务端返回 40034128 再兜底
273
304
  let passiveNow = wantPassive && !active;
274
305
  if (passiveNow) {
306
+ const maxReplies = PASSIVE_MAX_REPLIES[kind];
275
307
  const used = this.passiveCount.get(body.msgId) ?? 0;
276
- if (used >= PASSIVE_MAX_REPLIES) {
308
+ if (used >= maxReplies) {
277
309
  if (!this.downgrade) {
278
310
  this.stats.expired += 1;
279
311
  throw new SendError(
280
- `同一条消息最多回复 ${PASSIVE_MAX_REPLIES} 次(QQ 官方限制),这条已经回满了。\n` +
312
+ `同一条消息最多回复 ${maxReplies} 次(QQ 官方限制),这条已经回满了。\n` +
281
313
  `等用户再说一句,就能继续用被动回复。`,
282
314
  'expired',
283
315
  );
@@ -308,11 +340,19 @@ export class Outbound {
308
340
  'quota',
309
341
  );
310
342
  }
343
+ /**
344
+ * 分钟限流单独一个 code(`rate`),**不能和日额度共用 `quota`**。
345
+ *
346
+ * 两者的「多久能好」差三个数量级:日额度要等跨天,分钟限流等一分钟。
347
+ * 以前都叫 `quota`,调用方只能按最坏情况处理——dsh-roost 的主动搭话
348
+ * 会把「发得太密」当成「今天说满了」,直接放弃一整天。
349
+ * 反过来按最好情况处理,就是每分钟重试一次空转到跨天。
350
+ */
311
351
  if (!this.#allowActiveThisMinute(chatKey)) {
312
- this.stats.quotaBlocked += 1;
352
+ this.stats.rateBlocked += 1;
313
353
  throw new SendError(
314
354
  `这个会话的主动消息发得太密(官方限制 ${this.activePerMin} 条/分钟),稍后再试。`,
315
- 'quota',
355
+ 'rate',
316
356
  );
317
357
  }
318
358
  await this.quota.consume(chatKey);
@@ -392,6 +432,16 @@ export class Outbound {
392
432
  );
393
433
  }
394
434
 
435
+ /**
436
+ * 最近 60 秒这个会话发了几条。
437
+ *
438
+ * 只读,不占坑——`#allowActiveThisMinute` 会顺手把当前时刻压进队列,
439
+ * 诊断用的时候不能有这个副作用(面板刷一次就少一次额度就离谱了)。
440
+ */
441
+ rateUsed(chatKey, now = Date.now()) {
442
+ return (this.activeLog.get(chatKey) ?? []).filter((t) => now - t < 60_000).length;
443
+ }
444
+
395
445
  /**
396
446
  * 主动消息分钟级限流。官方单关系维度是 20/qpm,
397
447
  * 日额度够但一分钟内猛发照样会被服务端 40034100 拦下,本地先挡。
package/src/paths.js CHANGED
@@ -57,6 +57,14 @@ export const DIRS = {
57
57
  botsData: path.join(HOME, 'data', 'bots'),
58
58
  /** 机器人日志目录 */
59
59
  botsLogs: path.join(HOME, 'logs', 'bots'),
60
+ /**
61
+ * 暂停闸状态目录:`global.json` + `<botId>.json`。
62
+ *
63
+ * 按鸟拆文件而不是一个 `gate.json`:一个进程里可能同时跑好几只鸟,
64
+ * 共用一个文件时 `.tmp` + `rename` 只能保证「不会写坏」,
65
+ * 不能保证不丢更新(两只鸟同时改,后写的覆盖先写的)。
66
+ */
67
+ gate: path.join(HOME, 'data', 'gate'),
60
68
  };
61
69
 
62
70
  export const PATHS = {
@@ -113,8 +121,20 @@ export function botPaths(id) {
113
121
  groups: path.join(base, 'groups.json'),
114
122
  roles: path.join(base, 'roles.json'),
115
123
  quota: path.join(base, 'quota.json'),
124
+ /** 可达性(好友关系 / 主动推送开关),官方无查询接口,靠事件维护 */
125
+ reach: path.join(base, 'reach.json'),
116
126
  /** 统计库(SQLite,存消息计数与成员活跃,不存正文) */
117
127
  stats: path.join(base, 'stats.db'),
128
+ /**
129
+ * 事件账本(SQLite,存原始消息)。
130
+ *
131
+ * 和 `stats.db` 分开两个文件,不是随手分的:
132
+ * - 生命周期不同:stats 留 90 天聚合,events 只留 7 天全文,滚动删的策略和频率都不一样
133
+ * - 隐私边界不同:stats 明确「不存正文」,events 存正文。混一个库的话,
134
+ * 「这个库会不会被拷走」这个问题的答案就说不清了
135
+ * - 备份/删除粒度不同:用户想清掉聊天记录,删 `events.db` 一个文件就够
136
+ */
137
+ events: path.join(base, 'events.db'),
118
138
  pluginState: path.join(base, 'plugins.json'),
119
139
  pluginDataDir: path.join(base, 'plugins'),
120
140
  /** 日志根目录 */
package/src/pipeline.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createLogger } from './logger.js';
2
+ import { gateMiddleware } from './gate.js';
2
3
 
3
4
  const log = createLogger('pipeline');
4
5
 
@@ -250,8 +251,16 @@ export function cooldownMiddleware() {
250
251
  }
251
252
 
252
253
  /** 按配置组装默认管道 */
253
- export function createDefaultPipeline(cfg, { isSticky, metrics = null } = {}) {
254
+ export function createDefaultPipeline(cfg, { isSticky, metrics = null, gate = null } = {}) {
254
255
  const p = new Pipeline({ metrics });
256
+ /**
257
+ * 闸在最前面,连去重表都不写。
258
+ *
259
+ * 账本**不在这里**——`bot.js` 里 `ledger.append()` 在 `pipeline.run()` 之前,
260
+ * 所以暂停期间的消息照样入账。这条顺序是暂停闸能「不丢消息」的前提:
261
+ * 闸放在账本之前的话,暂停就等于丢消息,而官方没有历史消息接口,丢了补不回来。
262
+ */
263
+ if (gate) p.use('gate', gateMiddleware(gate, { metrics }));
255
264
  p.use('dedup', dedupMiddleware(cfg, { metrics }));
256
265
  p.use('whitelist', whitelistMiddleware(cfg, { metrics }));
257
266
  p.use('ratelimit', ratelimitMiddleware(cfg, { isSticky, metrics }));