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 +7 -5
- package/package.json +1 -1
- package/src/api.js +18 -5
- package/src/bot.js +231 -1
- package/src/config.js +35 -0
- package/src/gate.js +325 -0
- package/src/ledger.js +513 -0
- package/src/main.js +1389 -1364
- package/src/message-bus.js +1 -1
- package/src/message.js +3 -3
- package/src/outbound.js +65 -15
- package/src/paths.js +20 -0
- package/src/pipeline.js +10 -1
- package/src/plugin/context.js +890 -817
- package/src/plugin/loader.js +938 -929
- package/src/reach.js +364 -0
- package/src/runtime.js +8 -1
- package/src/storage/events-schema.js +71 -0
- package/src/web/qq-call.js +1 -1
- package/src/web/server.js +263 -1
- package/web/app.js +244 -0
- package/web/index.html +107 -0
package/src/message-bus.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
*
|
|
29
|
-
*
|
|
46
|
+
* 同一条消息最多回复次数:群聊 5、单聊 4。
|
|
47
|
+
* 超过会返回 40034128「被动回复时间或次数超限」。
|
|
30
48
|
*/
|
|
31
|
-
const PASSIVE_MAX_REPLIES =
|
|
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
|
-
|
|
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
|
-
|
|
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 的
|
|
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)
|
|
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 >=
|
|
308
|
+
if (used >= maxReplies) {
|
|
277
309
|
if (!this.downgrade) {
|
|
278
310
|
this.stats.expired += 1;
|
|
279
311
|
throw new SendError(
|
|
280
|
-
`同一条消息最多回复 ${
|
|
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.
|
|
352
|
+
this.stats.rateBlocked += 1;
|
|
313
353
|
throw new SendError(
|
|
314
354
|
`这个会话的主动消息发得太密(官方限制 ${this.activePerMin} 条/分钟),稍后再试。`,
|
|
315
|
-
'
|
|
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 }));
|