qq-bot-roost 1.0.2 → 1.0.4

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/src/outbound.js CHANGED
@@ -1,690 +1,791 @@
1
- import fs from 'node:fs/promises';
2
- import path from 'node:path';
3
- import { createLogger } from './logger.js';
4
- import { DELIVERY_STATE, digestOf } from './delivery.js';
5
- import { DIRS } from './paths.js';
6
-
7
- const log = createLogger('outbound');
8
-
9
- /**
10
- * 出站消息调度器。
11
- *
12
- * QQ 官方接口的硬限制都在这里统一处理,插件和内核都不用关心:
13
- *
14
- * 1. 被动回复只有 5 分钟有效期,超时必失败 → 快过期时降级为主动消息
15
- * 2. 同一条消息最多被动回复 5 次(msg_seq 1–5)→ 用满后同样降级
16
- * 3. 发送接口 100 QPS(「每秒 5 条」只属于文字子频道,群聊 / 单聊没有这条)→ 本地令牌队列,超出的排队等
17
- * 4. 主动消息有两个维度:单关系 20/分钟 + 每群 1000/天 → 本地记账,用完直接拒绝
18
- * 5. 必须 WebSocket 在线才能发 → 离线时直接拒绝,不浪费一次网络请求
19
- * 6. 主动消息返回 304023 表示「已进入审核」 → 这不是失败,不能当错误处理
20
- *
21
- * 插件只管调 msg.reply(),剩下的交给这里。
22
- */
23
-
24
- /**
25
- * 被动回复窗口。**两个接口不一样,别再用一个值。**
26
- *
27
- * 群聊 `/v2/groups/{group_openid}/messages`:5 分钟、最多 5 次
28
- * 单聊 `/v2/users/{user_openid}/messages`:**60 分钟、最多 4 次**
29
- *
30
- * 以前两者共用「5 分钟 / 5 次」(群聊的口径),单聊的后果是:
31
- * · 5 分钟后就降级成主动消息,白吃「每个好友 1000 条/天」的额度,
32
- * 而被动回复本来不占主动额度;
33
- * · 第 5 次回复会被服务端拒(40034128)。
34
- *
35
- * 各留 15 秒给网络和服务端时钟差。
36
- */
37
- const PASSIVE_WINDOW_MS = {
38
- group: 5 * 60 * 1000,
39
- c2c: 60 * 60 * 1000,
40
- };
41
- const PASSIVE_SAFE_MS = {
42
- group: PASSIVE_WINDOW_MS.group - 15_000,
43
- c2c: PASSIVE_WINDOW_MS.c2c - 15_000,
44
- };
45
-
46
- /**
47
- * 同一条消息最多回复次数:群聊 5、单聊 4。
48
- * 超过会返回 40034128「被动回复时间或次数超限」。
49
- */
50
- const PASSIVE_MAX_REPLIES = {
51
- group: 5,
52
- c2c: 4,
53
- };
54
-
55
- /** 主动消息进入审核的返回码,属于正常结果 */
56
- const CODE_AUDIT = 304023;
57
-
58
- /**
59
- * 把出站 payload 压成一行人能看懂的文字,用于日志。
60
- *
61
- * 排查时最常遇到的问题是「机器人到底回没回、回了什么」——发送侧以前
62
- * 一行日志都没有,只能去翻 QQ 客户端对着猜。现在打出来。
63
- *
64
- * @param {object} payload api.js 驼峰字段的 payload
65
- * @returns {string}
66
- */
67
- function briefOf(payload) {
68
- const b = payload ?? {};
69
- const parts = [];
70
- if (typeof b.content === 'string' && b.content.trim()) parts.push(b.content.trim());
71
- if (b.markdown?.content) {
72
- parts.push(`[markdown] ${String(b.markdown.content).replace(/\s+/g, ' ').trim()}`);
73
- }
74
- if (b.ark) parts.push('[ark 卡片]');
75
- if (b.media) {
76
- // fileType 是数字(1 图 / 2 视频 / 3 语音 / 4 文件),直接打数字没人看得懂
77
- const name = { 1: '图片', 2: '视频', 3: '语音', 4: '文件' }[b.media.fileType];
78
- parts.push(name ? `[${name}消息]` : '[媒体消息]');
79
- }
80
- if (b.keyboard) parts.push('[按钮键盘]');
81
- if (b.image) parts.push('[图片]');
82
- if (b.file_uuid) parts.push('[文件]');
83
-
84
- let text = parts.join(' | ');
85
- if (text.length > 120) text = `${text.slice(0, 120)}…`;
86
- return text;
87
- }
88
-
89
- /** 带错误码的发送异常,方便调用方分辨该提示用户什么 */
90
- export class SendError extends Error {
91
- constructor(message, code, detail) {
92
- super(message);
93
- this.name = 'SendError';
94
- /**
95
- * 五种,按「多久能好」排序:
96
- * offline 连不上 QQ —— 等它上线
97
- * expired 被动窗口过了 —— 等用户再说一句
98
- * quota 今天这个会话的主动额度用完了 —— 等跨天
99
- * rate 这一分钟发得太密 —— 等一分钟
100
- * api 接口报错 —— 看具体错误码
101
- */
102
- this.code = code; // offline | expired | quota | rate | api
103
- this.detail = detail;
104
- }
105
- }
106
-
107
- /**
108
- * 这次失败是「确定没发出去」还是「不知道发没发出去」。
109
- *
110
- * 只有 `api` 这一类可能不确定:超时、5xx、连接重置——请求发出去了但响应没回来。
111
- * 其余四种(`offline` / `expired` / `quota` / `rate`)都是**本地判定**,
112
- * 消息一定没发出去,记 `failed` 可以放心重试。
113
- *
114
- * 判错的代价不对称:把「确定失败」误判成 unknown,用户要人工核对(麻烦但安全);
115
- * 把 unknown 误判成「确定失败」,就会重发一条可能已经发出去的消息(重复打扰)。
116
- * 所以这里宁可保守——拿不准就当 unknown。
117
- */
118
- function uncertainOf(e) {
119
- if (e?.code !== 'api') return false;
120
- const s = String(e?.detail ?? e?.message ?? '');
121
- return /timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|socket hang up|aborted|\b5\d\d\b/i.test(s);
122
- }
123
-
124
- const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
125
-
126
- /** 本地日期键,按用户所在时区算,不用 UTC 免得跨天错位 */
127
- export function dateKey(d = new Date()) {
128
- const p = (n) => String(n).padStart(2, '0');
129
- return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
130
- }
131
-
132
- /**
133
- * 主动消息额度账本。
134
- * 按「日期 → 会话 → 已用条数」记账,落盘到 data/quota.json,重启不清零。
135
- */
136
- class QuotaLedger {
137
- constructor(file) {
138
- this.file = file;
139
- this.data = null; // { '2026-10-22': { 'group:xxx': 3 } }
140
- this.dirty = false;
141
- this.timer = null;
142
- }
143
-
144
- async #load() {
145
- if (this.data) return this.data;
146
- try {
147
- this.data = JSON.parse(await fs.readFile(this.file, 'utf8'));
148
- } catch {
149
- this.data = {};
150
- }
151
- // 只保留最近 3 天,避免文件无限增长
152
- const keys = Object.keys(this.data).sort();
153
- for (const k of keys.slice(0, Math.max(0, keys.length - 3))) delete this.data[k];
154
- return this.data;
155
- }
156
-
157
- async used(chatKey) {
158
- const d = await this.#load();
159
- return d[dateKey()]?.[chatKey] ?? 0;
160
- }
161
-
162
- async consume(chatKey, n = 1) {
163
- const d = await this.#load();
164
- const day = dateKey();
165
- d[day] ??= {};
166
- d[day][chatKey] = (d[day][chatKey] ?? 0) + n;
167
- this.#scheduleSave();
168
- return d[day][chatKey];
169
- }
170
-
171
- /**
172
- * 退还额度。发送失败时用——「先扣后发」不退还的话,一次网络抖动就白扣一条。
173
- */
174
- async refund(chatKey, n = 1) {
175
- const d = await this.#load();
176
- const day = dateKey();
177
- if (!d[day]?.[chatKey]) return 0;
178
- d[day][chatKey] = Math.max(0, d[day][chatKey] - n);
179
- this.#scheduleSave();
180
- return d[day][chatKey];
181
- }
182
-
183
- async snapshot() {
184
- const d = await this.#load();
185
- return { ...(d[dateKey()] ?? {}) };
186
- }
187
-
188
- #scheduleSave() {
189
- this.dirty = true;
190
- if (this.timer) return;
191
- this.timer = setTimeout(() => {
192
- this.timer = null;
193
- this.flush().catch((e) => log.warn('额度账本写盘失败:', e.message));
194
- }, 500);
195
- this.timer.unref?.();
196
- }
197
-
198
- async flush() {
199
- if (this.timer) {
200
- clearTimeout(this.timer);
201
- this.timer = null;
202
- }
203
- if (!this.dirty || !this.data) return;
204
- this.dirty = false;
205
- try {
206
- await fs.mkdir(path.dirname(this.file), { recursive: true });
207
- await fs.writeFile(this.file, JSON.stringify(this.data, null, 2), 'utf8');
208
- } catch (e) {
209
- log.warn('额度账本写盘失败:', e.message);
210
- }
211
- }
212
- }
213
-
214
- /**
215
- * 从出站报文里挑出「人看得懂的那部分」,喂给 `digestOf`。
216
- *
217
- * 报文不止一种形状,只认顶层 `content` 会漏掉一大半:
218
- *
219
- * · 纯文本 `{ content }`
220
- * · markdown `{ markdown: { content } }` ← ai-chat 默认就走这条
221
- * · 卡片 `{ markdown, keyboard, ark }`
222
- * · 富媒体 `{ msgType: 7, media }`,正文只有一个占位空格
223
- *
224
- * 漏掉的后果不是发不出去(账本的状态机不看摘要),而是摘要列全变成
225
- * `…(0)#da39a3ee`——所有记录长得一模一样,「人工核对」这条路等于堵死,
226
- * 真出现 `unknown` 的时候只能靠猜。
227
- */
228
- export function digestTextOf(payload) {
229
- const p = payload ?? {};
230
- const md = p.markdown;
231
- const text = p.content ?? p.text ?? (typeof md === 'string' ? md : md?.content);
232
- if (typeof text === 'string' && text.trim()) return text;
233
- /**
234
- * 没有正文的报文给个占位。一串空摘要里冒出 `[媒体]` / `[卡片]`,
235
- * 至少能分辨「这条本来就没有文字」,而不是「摘要功能又坏了」。
236
- */
237
- if (p.media) return '[媒体]';
238
- if (p.ark) return '[卡片]';
239
- return '';
240
- }
241
-
242
- export class Outbound {
243
- /**
244
- * @param {object} opts
245
- * @param {object} opts.api QQApi 实例
246
- * @param {object} opts.cfg 全局配置
247
- * @param {() => boolean} opts.isOnline 机器人是否已连上网关
248
- */
249
- constructor({ api, cfg, isOnline = () => true, quotaFile, delivery = null }) {
250
- this.api = api;
251
- /** 投递账本(阶段 5)。没有它就退回原来的「发一次算一次」 */
252
- this.delivery = delivery;
253
- this.cfg = cfg;
254
- this.isOnline = isOnline;
255
-
256
- const o = cfg.outbound ?? {};
257
- // 官方没有「每会话每秒 N 条」这条限制(那只适用于文字子频道),接口级是 100 QPS;
258
- // 这里压到 4 条/秒纯属自家的保守策略,给其他来源留一点余量
259
- this.perSec = Math.max(1, o.perSec ?? 4);
260
- /**
261
- * 主动消息额度。官方文档(`autogen/api/.../messages.post`)写的是两个维度:
262
- *
263
- * - 单关系(接收方):20/qpm,且**每个群 1 天最多接收 1000 条**
264
- * - Bot(发送方):认证 60/qpm,未认证 30/qpm
265
- *
266
- * 这里以前按「20 条/天」记账(把频道的口径套到了群聊),少算了 50 倍,
267
- * 主动推送被白白拦掉。现在按官方数值:1000/天 + 20/分钟。
268
- */
269
- this.dailyActive = Math.max(0, o.dailyActive ?? 1000);
270
- this.activePerMin = Math.max(0, o.activePerMinute ?? 20);
271
- this.downgrade = o.downgradeToActive !== false;
272
-
273
- /** msgId -> 已用被动回复次数。官方:同一条消息最多 5 次 */
274
- this.passiveCount = new Map();
275
- /** chatKey -> 最近 60 秒的主动发送时间戳 */
276
- this.activeLog = new Map();
277
-
278
- // 额度账本按机器人分开,不同机器人的额度互不影响
279
- this.quota = new QuotaLedger(quotaFile ?? path.join(DIRS.data, 'quota.json'));
280
- /** chatKey -> { chain: Promise, history: number[] } */
281
- this.queues = new Map();
282
-
283
- this.stats = {
284
- passive: 0,
285
- active: 0,
286
- degraded: 0, // 被动超窗降级为主动的次数
287
- expired: 0, // 因超窗被拒绝的次数
288
- quotaBlocked: 0, // 因日额度用完被拒绝的次数(等跨天才好)
289
- rateBlocked: 0, // 因分钟限流被拒绝的次数(等一分钟就好)
290
- throttled: 0, // 因限速排队过的次数
291
- audit: 0, // 进入审核的主动消息
292
- offline: 0,
293
- };
294
- }
295
-
296
- /* ---------------- 对外主入口 ---------------- */
297
-
298
- /**
299
- * 发送一条消息。payload 里带 msg_id 就是被动回复,不带就是主动推送。
300
- *
301
- * @param {object} p
302
- * @param {'group'|'c2c'} p.kind
303
- * @param {string} p.target 群 openid 或用户 openid
304
- * @param {string} p.chatKey 记账与限速用的会话键,如 group:xxx
305
- * @param {object} p.payload 发给接口的报文
306
- * @param {number} [p.receivedAt] 触发本次回复的消息接收时间
307
- * @returns {Promise<{id?:string, pending?:boolean, degraded?:boolean}>}
308
- */
309
- /**
310
- * 发送。**有 `requestId` 就走投递账本,没有就照旧发一次。**
311
- *
312
- * 为什么不是「所有发送都记账」:`requestId` 由调用方生成,只有它知道
313
- * 「一段话」的语义边界(`qq_send` 每调一次是一段独立消息)。插件直接
314
- * `msg.reply()` 那种没有 id,按原行为走——被动回复本身有 `msg_seq` 兜底。
315
- *
316
- * 账本是**增强**不是主链路:`claim` 失败一律退回「照发」,不能让记账把消息堵死。
317
- */
318
- async send(opts) {
319
- const requestId = String(opts?.requestId ?? '').trim();
320
- if (!requestId || !this.delivery) return this.#sendOnce(opts);
321
-
322
- const c = this.delivery.claim({
323
- requestId,
324
- kind: opts.kind,
325
- chatId: opts.target,
326
- replyTo: opts.payload?.msgId,
327
- msgSeq: opts.payload?.msgSeq,
328
- digest: digestOf(digestTextOf(opts.payload)),
329
- });
330
-
331
- if (c.action === 'reuse') {
332
- // 已经发过:直接回上次的结果,**不再打接口**
333
- return {
334
- id: c.row?.msg_id || '',
335
- reused: true,
336
- pending: c.row?.state === DELIVERY_STATE.AUDITED,
337
- requestId,
338
- };
339
- }
340
- if (c.action === 'unknown') {
341
- /**
342
- * 上次结果未知,**绝不自动重发**。
343
- *
344
- * 官方配额下重发的代价是双份:可能重复发一条,还会撞上限流。
345
- * 宁可让人去面板核对一次。
346
- */
347
- throw new SendError(
348
- '这条消息上次的发送结果未知(超时或进程崩溃),不会自动重发。请到面板「投递」页核对',
349
- 'unknown',
350
- );
351
- }
352
- if (c.action === 'wait') {
353
- throw new SendError('这条消息正在发送中,别重复调用', 'pending');
354
- }
355
-
356
- try {
357
- const r = await this.#sendOnce(opts);
358
- this.delivery.settle(
359
- requestId,
360
- r?.pending ? DELIVERY_STATE.AUDITED : DELIVERY_STATE.SENT,
361
- { msgId: r?.id },
362
- );
363
- return { ...r, requestId };
364
- } catch (e) {
365
- /**
366
- * 失败也要结算,否则这一行永远卡在 `pending`。
367
- *
368
- * `unknown` 只给「不知道发没发出去」的情况(超时 / 5xx / 连接重置)。
369
- * `offline` / `expired` / `quota` / `rate` 是**确定没发出去**,记 `failed` 允许重试。
370
- */
371
- this.delivery.settle(requestId, uncertainOf(e) ? DELIVERY_STATE.UNKNOWN : DELIVERY_STATE.FAILED, {
372
- code: e?.code || 'error',
373
- });
374
- throw e;
375
- }
376
- }
377
-
378
- async #sendOnce({ kind, target, chatKey, payload, receivedAt }) {
379
- if (kind !== 'group' && kind !== 'c2c') {
380
- throw new SendError(`不支持的会话类型: ${kind}`, 'api');
381
- }
382
- if (!this.isOnline()) {
383
- this.stats.offline += 1;
384
- /**
385
- * 错误消息要**带上下一步怎么办**。
386
- *
387
- * 只解释原因(「没连上」)时,调用方只能自己猜是重试还是放弃;
388
- * 写明「等它上线」,它就知道这类错误不该重试。
389
- */
390
- throw new SendError(
391
- '机器人当前没有连上 QQ,消息发不出去。等它重新上线再试(面板「运行」页能看状态)。',
392
- 'offline',
393
- );
394
- }
395
-
396
- const body = { ...payload };
397
- // 约定:payload 用 api.js 的驼峰字段(msgId / msgSeq),由 api.js 转成 msg_id / msg_seq
398
- const wantPassive = Boolean(body.msgId);
399
- let active = !wantPassive;
400
- let degraded = false;
401
-
402
- // 1) 被动窗口检查:快过期就别发请求了,直接按主动消息处理
403
- if (wantPassive) {
404
- const age = receivedAt ? Date.now() - receivedAt : 0;
405
- const safeMs = PASSIVE_SAFE_MS[kind];
406
- if (age > safeMs) {
407
- if (!this.downgrade) {
408
- this.stats.expired += 1;
409
- throw new SendError(
410
- `这条消息已经过去 ${Math.round(age / 1000)} 秒,超过 QQ 的 ${Math.round(safeMs / 1000)} 秒被动回复窗口,回复发不出去了`,
411
- 'expired',
412
- );
413
- }
414
- delete body.msgId;
415
- delete body.msgSeq;
416
- active = true;
417
- degraded = true;
418
- this.stats.degraded += 1;
419
- log.warn(
420
- `被动窗口已过(${Math.round(age / 1000)}s),降级为主动消息 · ${chatKey}`,
421
- );
422
- }
423
- }
424
-
425
- // 2) 排队 + 限速 + 额度扣减 + 实际发送
426
- // 额度的「查」和「扣」都放在队列里,避免两个并发推送同时通过检查后超额
427
- return this.#enqueue(chatKey, async () => {
428
- // 2a) 被动回复次数上限:群聊 5 次、单聊 4 次
429
- // 同一条消息被多个插件连着回、或用户一句话触发多轮时很容易撞上,
430
- // 本地先拦,省得服务端返回 40034128 再兜底
431
- let passiveNow = wantPassive && !active;
432
- if (passiveNow) {
433
- const maxReplies = PASSIVE_MAX_REPLIES[kind];
434
- const used = this.passiveCount.get(body.msgId) ?? 0;
435
- if (used >= maxReplies) {
436
- if (!this.downgrade) {
437
- this.stats.expired += 1;
438
- throw new SendError(
439
- `同一条消息最多回复 ${maxReplies} 次(QQ 官方限制),这条已经回满了。\n` +
440
- `等用户再说一句,就能继续用被动回复。`,
441
- 'expired',
442
- );
443
- }
444
- delete body.msgId;
445
- delete body.msgSeq;
446
- passiveNow = false;
447
- active = true;
448
- degraded = true;
449
- this.stats.degraded += 1;
450
- log.warn(
451
- `同一条消息已回复 ${used} 次,达到官方上限,降级为主动消息 · ${chatKey}`,
452
- );
453
- } else {
454
- this.passiveCount.set(body.msgId, used + 1);
455
- this.#prunePassiveCount();
456
- }
457
- }
458
-
459
- // 2b) 主动消息额度:日额度 + 分钟额度,两个都要过
460
- if (active) {
461
- const used = await this.quota.used(chatKey);
462
- if (used >= this.dailyActive) {
463
- this.stats.quotaBlocked += 1;
464
- throw new SendError(
465
- `这个会话今天的主动消息额度已用完(${this.dailyActive} 条/天),QQ 不再允许主动推送。\n` +
466
- `等用户 @ 一次机器人,就能继续用被动回复。`,
467
- 'quota',
468
- );
469
- }
470
- /**
471
- * 分钟限流单独一个 code(`rate`),**不能和日额度共用 `quota`**。
472
- *
473
- * 两者的「多久能好」差三个数量级:日额度要等跨天,分钟限流等一分钟。
474
- * 以前都叫 `quota`,调用方只能按最坏情况处理——dsh-roost 的主动搭话
475
- * 会把「发得太密」当成「今天说满了」,直接放弃一整天。
476
- * 反过来按最好情况处理,就是每分钟重试一次空转到跨天。
477
- */
478
- if (!this.#allowActiveThisMinute(chatKey)) {
479
- this.stats.rateBlocked += 1;
480
- throw new SendError(
481
- `这个会话的主动消息发得太密(官方限制 ${this.activePerMin} 条/分钟),**等 1 分钟**就好。`,
482
- 'rate',
483
- );
484
- }
485
- await this.quota.consume(chatKey);
486
- }
487
-
488
- try {
489
- const r = await this.#post(kind, target, body, active ? '主动' : '被动');
490
- if (active) this.stats.active += 1;
491
- else this.stats.passive += 1;
492
- if (r?.pending) this.stats.audit += 1;
493
- return { ...r, degraded };
494
- } catch (e) {
495
- // 被动回复没发出去,把这次占用的次数退回去,不然 5 次额度会白白烧掉
496
- if (passiveNow) {
497
- const n = this.passiveCount.get(body.msgId) ?? 0;
498
- if (n > 0) this.passiveCount.set(body.msgId, n - 1);
499
- }
500
- // 主动消息是「先扣后发」,发失败要把额度退回去
501
- if (active) await this.quota.refund(chatKey).catch(() => {});
502
- // 被动回复被服务端判定过期(本地时钟差或排队等太久),再降级试一次
503
- const canRetry =
504
- wantPassive && !active && this.downgrade && this.#looksExpired(e);
505
- if (!canRetry) throw e;
506
-
507
- const used = await this.quota.used(chatKey);
508
- if (used >= this.dailyActive || !this.#allowActiveThisMinute(chatKey)) {
509
- this.stats.expired += 1;
510
- throw new SendError(
511
- 'QQ 判定这条被动回复已过期,且主动消息额度不够补救',
512
- 'expired',
513
- );
514
- }
515
- await this.quota.consume(chatKey);
516
- const fallback = { ...body };
517
- delete fallback.msgId;
518
- delete fallback.msgSeq;
519
- log.warn(`被动回复被拒(${e.message}),改发主动消息 · ${chatKey}`);
520
- const r = await this.#post(kind, target, fallback, '降级主动');
521
- this.stats.degraded += 1;
522
- this.stats.active += 1;
523
- if (r?.pending) this.stats.audit += 1;
524
- return { ...r, degraded: true };
525
- }
526
- });
527
- }
528
-
529
- /* ---------------- 内部 ---------------- */
530
-
531
- async #post(kind, target, body, how = '') {
532
- const r = kind === 'group'
533
- ? await this.api.sendGroup(target, body)
534
- : await this.api.sendC2C(target, body);
535
-
536
- // 发出去了什么。放在这里而不是调用处:三个调用点(正常 / 降级 / 审核中)
537
- // 都要打,写在一处不会漏
538
- const where = String(target ?? '').slice(0, 10) || '-';
539
- const what = briefOf(body) || '(空)';
540
-
541
- // 主动消息需要审核,接口会用业务码返回「审核中」,这不是失败
542
- if (r && typeof r === 'object' && Number(r.code) === CODE_AUDIT) {
543
- log.info(`发出 ${kind} →${where}(${how}·审核中):${what}`);
544
- return { pending: true, id: r.id ?? '', raw: r };
545
- }
546
- log.info(`发出 ${kind} →${where}${how ? `(${how})` : ''}:${what}`);
547
- return { id: r?.id ?? '', raw: r };
548
- }
549
-
550
- /** 从异常里判断是不是「被动窗口过期」这类可降级的错误 */
551
- #looksExpired(e) {
552
- const text = `${e?.message ?? ''} ${JSON.stringify(e?.body ?? {})}`.toLowerCase();
553
- return (
554
- text.includes('msg_id') ||
555
- text.includes('expire') ||
556
- text.includes('过期') ||
557
- text.includes('超时') ||
558
- text.includes('40034')
559
- );
560
- }
561
-
562
- /**
563
- * 最近 60 秒这个会话发了几条。
564
- *
565
- * 只读,不占坑——`#allowActiveThisMinute` 会顺手把当前时刻压进队列,
566
- * 诊断用的时候不能有这个副作用(面板刷一次就少一次额度就离谱了)。
567
- */
568
- rateUsed(chatKey, now = Date.now()) {
569
- return (this.activeLog.get(chatKey) ?? []).filter((t) => now - t < 60_000).length;
570
- }
571
-
572
- /**
573
- * 主动消息分钟级限流。官方单关系维度是 20/qpm,
574
- * 日额度够但一分钟内猛发照样会被服务端 40034100 拦下,本地先挡。
575
- * @returns {boolean} 这一分钟还允许发吗(允许时顺便占一个坑)
576
- */
577
- #allowActiveThisMinute(chatKey) {
578
- if (this.activePerMin <= 0) return true;
579
- const now = Date.now();
580
- const list = (this.activeLog.get(chatKey) ?? []).filter((t) => now - t < 60_000);
581
- if (list.length >= this.activePerMin) {
582
- this.activeLog.set(chatKey, list);
583
- return false;
584
- }
585
- list.push(now);
586
- this.activeLog.set(chatKey, list);
587
- // 会话数很多时别让这张表无限长
588
- if (this.activeLog.size > 500) {
589
- const oldest = this.activeLog.keys().next().value;
590
- if (oldest !== undefined) this.activeLog.delete(oldest);
591
- }
592
- return true;
593
- }
594
-
595
- /** 被动次数表不能无限长,超了丢最旧的(和 api.js 的 seqMap 一个套路) */
596
- #prunePassiveCount() {
597
- if (this.passiveCount.size <= 2000) return;
598
- const oldest = this.passiveCount.keys().next().value;
599
- if (oldest !== undefined) this.passiveCount.delete(oldest);
600
- }
601
-
602
- /**
603
- * 同一会话串行执行,并保证 1 秒内不超过 perSec 条。
604
- * 不同会话之间互不影响,可以并行。
605
- */
606
- #enqueue(chatKey, task) {
607
- let q = this.queues.get(chatKey);
608
- if (!q) {
609
- q = { chain: Promise.resolve(), history: [] };
610
- this.queues.set(chatKey, q);
611
- }
612
- const run = q.chain.then(async () => {
613
- await this.#throttle(q);
614
- return task();
615
- });
616
- // 链不能因为某次失败就断掉
617
- q.chain = run.then(
618
- () => {},
619
- () => {},
620
- );
621
- return run;
622
- }
623
-
624
- async #throttle(q) {
625
- for (;;) {
626
- const now = Date.now();
627
- q.history = q.history.filter((t) => now - t < 1000);
628
- if (q.history.length < this.perSec) {
629
- q.history.push(now);
630
- return;
631
- }
632
- this.stats.throttled += 1;
633
- const wait = 1000 - (now - q.history[0]) + 10;
634
- if (wait > 0) await sleep(wait);
635
- }
636
- }
637
-
638
- /* ---------------- 状态 ---------------- */
639
-
640
- async quotaSnapshot() {
641
- return this.quota.snapshot();
642
- }
643
-
644
- /** 给 /status 和面板用的一行摘要 */
645
- async summary() {
646
- const used = await this.quota.snapshot();
647
- const total = Object.values(used).reduce((a, b) => a + b, 0);
648
- return {
649
- ...this.stats,
650
- perSec: this.perSec,
651
- dailyActive: this.dailyActive,
652
- activePerMinute: this.activePerMin,
653
- activeUsedToday: total,
654
- activeByChat: used,
655
- };
656
- }
657
-
658
- async flush() {
659
- await this.quota.flush();
660
-
661
- /**
662
- * 等排队中的发送发完再退出。
663
- *
664
- * 以前只刷额度账本、完全不看 `this.queues`:重启/更新/关机时,
665
- * 用户看到命令执行了,回复却永远没发出去,额度还照扣。
666
- * 最多等 5 秒,超时就明确说丢了几条。
667
- */
668
- const chains = [...this.queues.values()].map((q) => q.chain);
669
- if (chains.length) {
670
- let timedOut = false;
671
- const timeout = new Promise((resolve) => {
672
- const t = setTimeout(() => {
673
- timedOut = true;
674
- resolve();
675
- }, 5000);
676
- t.unref?.();
677
- });
678
- await Promise.race([Promise.allSettled(chains), timeout]);
679
- if (timedOut) {
680
- log.warn(`退出前还有 ${this.queues.size} 个会话的发送任务没排完,已放弃等待`);
681
- }
682
- }
683
- }
684
- }
685
-
686
- /** 把 SendError 翻译成给用户看的话;非 SendError 返回 null */
687
- export function userFacingSendError(e) {
688
- if (e instanceof SendError) return e.message;
689
- return null;
690
- }
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createLogger } from './logger.js';
4
+ import { DELIVERY_STATE, digestOf } from './delivery.js';
5
+ import { DIRS } from './paths.js';
6
+
7
+ const log = createLogger('outbound');
8
+
9
+ /**
10
+ * 出站消息调度器。
11
+ *
12
+ * QQ 官方接口的硬限制都在这里统一处理,插件和内核都不用关心:
13
+ *
14
+ * 1. 被动回复只有 5 分钟有效期,超时必失败 → 快过期时降级为主动消息
15
+ * 2. 同一条消息最多被动回复 5 次(msg_seq 1–5)→ 用满后同样降级
16
+ * 3. 发送接口 100 QPS(「每秒 5 条」只属于文字子频道,群聊 / 单聊没有这条)→ 本地令牌队列,超出的排队等
17
+ * 4. 主动消息有两个维度:单关系 20/分钟 + 每群 1000/天 → 本地记账,用完直接拒绝
18
+ * 5. 必须 WebSocket 在线才能发 → 离线时直接拒绝,不浪费一次网络请求
19
+ * 6. 主动消息返回 304023 表示「已进入审核」 → 这不是失败,不能当错误处理
20
+ *
21
+ * 插件只管调 msg.reply(),剩下的交给这里。
22
+ */
23
+
24
+ /**
25
+ * 被动回复窗口。**两个接口不一样,别再用一个值。**
26
+ *
27
+ * 群聊 `/v2/groups/{group_openid}/messages`:5 分钟、最多 5 次
28
+ * 单聊 `/v2/users/{user_openid}/messages`:**60 分钟、最多 4 次**
29
+ *
30
+ * 以前两者共用「5 分钟 / 5 次」(群聊的口径),单聊的后果是:
31
+ * · 5 分钟后就降级成主动消息,白吃「每个好友 1000 条/天」的额度,
32
+ * 而被动回复本来不占主动额度;
33
+ * · 第 5 次回复会被服务端拒(40034128)。
34
+ *
35
+ * 各留 15 秒给网络和服务端时钟差。
36
+ */
37
+ const PASSIVE_WINDOW_MS = {
38
+ group: 5 * 60 * 1000,
39
+ c2c: 60 * 60 * 1000,
40
+ };
41
+ const PASSIVE_SAFE_MS = {
42
+ group: PASSIVE_WINDOW_MS.group - 15_000,
43
+ c2c: PASSIVE_WINDOW_MS.c2c - 15_000,
44
+ };
45
+
46
+ /**
47
+ * 同一条消息最多回复次数:群聊 5、单聊 4。
48
+ * 超过会返回 40034128「被动回复时间或次数超限」。
49
+ */
50
+ const PASSIVE_MAX_REPLIES = {
51
+ group: 5,
52
+ c2c: 4,
53
+ };
54
+
55
+ /** 主动消息进入审核的返回码,属于正常结果 */
56
+ const CODE_AUDIT = 304023;
57
+
58
+ /**
59
+ * 把出站 payload 压成一行人能看懂的文字,用于日志。
60
+ *
61
+ * 排查时最常遇到的问题是「机器人到底回没回、回了什么」——发送侧以前
62
+ * 一行日志都没有,只能去翻 QQ 客户端对着猜。现在打出来。
63
+ *
64
+ * @param {object} payload api.js 驼峰字段的 payload
65
+ * @returns {string}
66
+ */
67
+ function briefOf(payload) {
68
+ const b = payload ?? {};
69
+ const parts = [];
70
+ if (typeof b.content === 'string' && b.content.trim()) parts.push(b.content.trim());
71
+ if (b.markdown?.content) {
72
+ parts.push(`[markdown] ${String(b.markdown.content).replace(/\s+/g, ' ').trim()}`);
73
+ }
74
+ if (b.ark) parts.push('[ark 卡片]');
75
+ if (b.media) {
76
+ // fileType 是数字(1 图 / 2 视频 / 3 语音 / 4 文件),直接打数字没人看得懂
77
+ const name = { 1: '图片', 2: '视频', 3: '语音', 4: '文件' }[b.media.fileType ?? b.fileType];
78
+ parts.push(name ? `[${name}消息]` : '[媒体消息]');
79
+ }
80
+ if (b.keyboard) parts.push('[按钮键盘]');
81
+ if (b.image) parts.push('[图片]');
82
+ if (b.file_uuid) parts.push('[文件]');
83
+
84
+ let text = parts.join(' | ');
85
+ if (text.length > 120) text = `${text.slice(0, 120)}…`;
86
+ return text;
87
+ }
88
+
89
+ /** 带错误码的发送异常,方便调用方分辨该提示用户什么 */
90
+ export class SendError extends Error {
91
+ constructor(message, code, detail) {
92
+ super(message);
93
+ this.name = 'SendError';
94
+ /**
95
+ * 五种,按「多久能好」排序:
96
+ * offline 连不上 QQ —— 等它上线
97
+ * expired 被动窗口过了 —— 等用户再说一句
98
+ * quota 今天这个会话的主动额度用完了 —— 等跨天
99
+ * rate 这一分钟发得太密 —— 等一分钟
100
+ * api 接口报错 —— 看具体错误码
101
+ */
102
+ this.code = code; // offline | expired | quota | rate | api
103
+ this.detail = detail;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * 这次失败是「确定没发出去」还是「不知道发没发出去」。
109
+ *
110
+ * 只有 `api` 这一类可能不确定:超时、5xx、连接重置——请求发出去了但响应没回来。
111
+ * 其余四种(`offline` / `expired` / `quota` / `rate`)都是**本地判定**,
112
+ * 消息一定没发出去,记 `failed` 可以放心重试。
113
+ *
114
+ * 判错的代价不对称:把「确定失败」误判成 unknown,用户要人工核对(麻烦但安全);
115
+ * 把 unknown 误判成「确定失败」,就会重发一条可能已经发出去的消息(重复打扰)。
116
+ * 所以这里宁可保守——拿不准就当 unknown。
117
+ */
118
+ function uncertainOf(e) {
119
+ if (e?.code !== 'api') return false;
120
+ const s = String(e?.detail ?? e?.message ?? '');
121
+ return /timeout|timed out|ETIMEDOUT|ECONNRESET|ECONNREFUSED|socket hang up|aborted|\b5\d\d\b/i.test(s);
122
+ }
123
+
124
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
125
+
126
+ /** 本地日期键,按用户所在时区算,不用 UTC 免得跨天错位 */
127
+ export function dateKey(d = new Date()) {
128
+ const p = (n) => String(n).padStart(2, '0');
129
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
130
+ }
131
+
132
+ /**
133
+ * 主动消息额度账本。
134
+ * 按「日期 → 会话 → 已用条数」记账,落盘到 data/quota.json,重启不清零。
135
+ */
136
+ class QuotaLedger {
137
+ constructor(file) {
138
+ this.file = file;
139
+ this.data = null; // { '2026-10-22': { 'group:xxx': 3 } }
140
+ this.dirty = false;
141
+ this.timer = null;
142
+ }
143
+
144
+ async #load() {
145
+ if (this.data) return this.data;
146
+ try {
147
+ this.data = JSON.parse(await fs.readFile(this.file, 'utf8'));
148
+ } catch {
149
+ this.data = {};
150
+ }
151
+ // 只保留最近 3 天,避免文件无限增长
152
+ const keys = Object.keys(this.data).sort();
153
+ for (const k of keys.slice(0, Math.max(0, keys.length - 3))) delete this.data[k];
154
+ return this.data;
155
+ }
156
+
157
+ async used(chatKey) {
158
+ const d = await this.#load();
159
+ return d[dateKey()]?.[chatKey] ?? 0;
160
+ }
161
+
162
+ async consume(chatKey, n = 1) {
163
+ const d = await this.#load();
164
+ const day = dateKey();
165
+ d[day] ??= {};
166
+ d[day][chatKey] = (d[day][chatKey] ?? 0) + n;
167
+ this.#scheduleSave();
168
+ return d[day][chatKey];
169
+ }
170
+
171
+ /**
172
+ * 退还额度。发送失败时用——「先扣后发」不退还的话,一次网络抖动就白扣一条。
173
+ */
174
+ async refund(chatKey, n = 1) {
175
+ const d = await this.#load();
176
+ const day = dateKey();
177
+ if (!d[day]?.[chatKey]) return 0;
178
+ d[day][chatKey] = Math.max(0, d[day][chatKey] - n);
179
+ this.#scheduleSave();
180
+ return d[day][chatKey];
181
+ }
182
+
183
+ async snapshot() {
184
+ const d = await this.#load();
185
+ return { ...(d[dateKey()] ?? {}) };
186
+ }
187
+
188
+ #scheduleSave() {
189
+ this.dirty = true;
190
+ if (this.timer) return;
191
+ this.timer = setTimeout(() => {
192
+ this.timer = null;
193
+ this.flush().catch((e) => log.warn('额度账本写盘失败:', e.message));
194
+ }, 500);
195
+ this.timer.unref?.();
196
+ }
197
+
198
+ async flush() {
199
+ if (this.timer) {
200
+ clearTimeout(this.timer);
201
+ this.timer = null;
202
+ }
203
+ if (!this.dirty || !this.data) return;
204
+ this.dirty = false;
205
+ try {
206
+ await fs.mkdir(path.dirname(this.file), { recursive: true });
207
+ await fs.writeFile(this.file, JSON.stringify(this.data, null, 2), 'utf8');
208
+ } catch (e) {
209
+ log.warn('额度账本写盘失败:', e.message);
210
+ }
211
+ }
212
+ }
213
+
214
+ /**
215
+ * 从出站报文里挑出「人看得懂的那部分」,喂给 `digestOf`。
216
+ *
217
+ * 报文不止一种形状,只认顶层 `content` 会漏掉一大半:
218
+ *
219
+ * · 纯文本 `{ content }`
220
+ * · markdown `{ markdown: { content } }` ← ai-chat 默认就走这条
221
+ * · 卡片 `{ markdown, keyboard, ark }`
222
+ * · 富媒体 `{ msgType: 7, media }`,正文只有一个占位空格
223
+ *
224
+ * 漏掉的后果不是发不出去(账本的状态机不看摘要),而是摘要列全变成
225
+ * `…(0)#da39a3ee`——所有记录长得一模一样,「人工核对」这条路等于堵死,
226
+ * 真出现 `unknown` 的时候只能靠猜。
227
+ */
228
+ export function digestTextOf(payload) {
229
+ const p = payload ?? {};
230
+ const md = p.markdown;
231
+ const text = p.content ?? p.text ?? (typeof md === 'string' ? md : md?.content);
232
+ if (typeof text === 'string' && text.trim()) return text;
233
+ /**
234
+ * 没有正文的报文给个占位。一串空摘要里冒出 `[媒体]` / `[卡片]`,
235
+ * 至少能分辨「这条本来就没有文字」,而不是「摘要功能又坏了」。
236
+ */
237
+ /**
238
+ * 富媒体占位。
239
+ *
240
+ * 数值来自 api.js 的 FILE_TYPE,要能一眼看出是哪种媒体。
241
+ * 注意 `sendMedia` 的报文是 `{fileType, url}`——**没有 `media` 字段**,
242
+ * 以前只看 `p.media` 时,插件经 `ctx.api.sendMedia` 直发的消息在账本里
243
+ * 就是一条空摘要(复核实测:`digestTextOf({fileType:1,url}) === ''`)。
244
+ */
245
+ const mediaName = { 1: '(图片)', 2: '(视频)', 3: '(语音)', 4: '(文件)' }[Number(p.fileType)];
246
+ if (p.media) return mediaName ?? '[媒体]';
247
+ if (mediaName) return mediaName;
248
+ if (p.ark) return '[卡片]';
249
+ return '';
250
+ }
251
+
252
+ export class Outbound {
253
+ /**
254
+ * @param {object} opts
255
+ * @param {object} opts.api QQApi 实例
256
+ * @param {object} opts.cfg 全局配置
257
+ * @param {() => boolean} opts.isOnline 机器人是否已连上网关
258
+ */
259
+ constructor({ api, cfg, isOnline = () => true, quotaFile, delivery = null, ledger = null }) {
260
+ this.api = api;
261
+ /** 投递账本(阶段 5)。没有它就退回原来的「发一次算一次」 */
262
+ this.delivery = delivery;
263
+ /**
264
+ * 事件账本(v3 出站入账)。**不能走构造函数传**——`bot.js` 里
265
+ * `createLedger` 在 `new Outbound` 之后才执行,这里拿到的是 undefined。
266
+ * 真正生效的是 `bot.js` 里那句后置注入 `this.outbound.ledger = this.ledger`。
267
+ */
268
+ this.ledger = ledger;
269
+ this.cfg = cfg;
270
+ this.isOnline = isOnline;
271
+
272
+ const o = cfg.outbound ?? {};
273
+ // 官方没有「每会话每秒 N 条」这条限制(那只适用于文字子频道),接口级是 100 QPS;
274
+ // 这里压到 4 条/秒纯属自家的保守策略,给其他来源留一点余量
275
+ this.perSec = Math.max(1, o.perSec ?? 4);
276
+ /**
277
+ * 主动消息额度。官方文档(`autogen/api/.../messages.post`)写的是两个维度:
278
+ *
279
+ * - 单关系(接收方):20/qpm,且**每个群 1 天最多接收 1000 条**
280
+ * - Bot(发送方):认证 60/qpm,未认证 30/qpm
281
+ *
282
+ * 这里以前按「20 条/天」记账(把频道的口径套到了群聊),少算了 50 倍,
283
+ * 主动推送被白白拦掉。现在按官方数值:1000/天 + 20/分钟。
284
+ */
285
+ this.dailyActive = Math.max(0, o.dailyActive ?? 1000);
286
+ this.activePerMin = Math.max(0, o.activePerMinute ?? 20);
287
+ this.downgrade = o.downgradeToActive !== false;
288
+
289
+ /** msgId -> 已用被动回复次数。官方:同一条消息最多 5 次 */
290
+ this.passiveCount = new Map();
291
+ /** chatKey -> 最近 60 秒的主动发送时间戳 */
292
+ this.activeLog = new Map();
293
+
294
+ // 额度账本按机器人分开,不同机器人的额度互不影响
295
+ this.quota = new QuotaLedger(quotaFile ?? path.join(DIRS.data, 'quota.json'));
296
+ /** chatKey -> { chain: Promise, history: number[] } */
297
+ this.queues = new Map();
298
+
299
+ this.stats = {
300
+ passive: 0,
301
+ active: 0,
302
+ degraded: 0, // 被动超窗降级为主动的次数
303
+ expired: 0, // 因超窗被拒绝的次数
304
+ quotaBlocked: 0, // 因日额度用完被拒绝的次数(等跨天才好)
305
+ rateBlocked: 0, // 因分钟限流被拒绝的次数(等一分钟就好)
306
+ throttled: 0, // 因限速排队过的次数
307
+ audit: 0, // 进入审核的主动消息
308
+ offline: 0,
309
+ };
310
+ }
311
+
312
+ /* ---------------- 对外主入口 ---------------- */
313
+
314
+ /**
315
+ * 发送一条消息。payload 里带 msg_id 就是被动回复,不带就是主动推送。
316
+ *
317
+ * @param {object} p
318
+ * @param {'group'|'c2c'} p.kind
319
+ * @param {string} p.target 群 openid 或用户 openid
320
+ * @param {string} p.chatKey 记账与限速用的会话键,如 group:xxx
321
+ * @param {object} p.payload 发给接口的报文
322
+ * @param {number} [p.receivedAt] 触发本次回复的消息接收时间
323
+ * @returns {Promise<{id?:string, pending?:boolean, degraded?:boolean}>}
324
+ */
325
+ /**
326
+ * 发送。**有 `requestId` 就走投递账本,没有就照旧发一次。**
327
+ *
328
+ * 为什么不是「所有发送都记账」:`requestId` 由调用方生成,只有它知道
329
+ * 「一段话」的语义边界(`qq_send` 每调一次是一段独立消息)。插件直接
330
+ * `msg.reply()` 那种没有 id,按原行为走——被动回复本身有 `msg_seq` 兜底。
331
+ *
332
+ * 账本是**增强**不是主链路:`claim` 失败一律退回「照发」,不能让记账把消息堵死。
333
+ */
334
+ async send(opts) {
335
+ const requestId = String(opts?.requestId ?? '').trim();
336
+ if (!requestId || !this.delivery) {
337
+ /**
338
+ * 没有幂等键(被动回复 / 富媒体 / 框架菜单)走这里。
339
+ * 以前直接 return,导致图片、菜单全都不入账。
340
+ * 与下面的 settle 分支互斥:这里不经过 delivery,reuse 也进不来。
341
+ */
342
+ const r = await this.#sendOnce(opts);
343
+ this.#recordOutbound(opts, r);
344
+ return r;
345
+ }
346
+
347
+ const c = this.delivery.claim({
348
+ requestId,
349
+ kind: opts.kind,
350
+ chatId: opts.target,
351
+ replyTo: opts.payload?.msgId,
352
+ msgSeq: opts.payload?.msgSeq,
353
+ digest: digestOf(digestTextOf(opts.payload)),
354
+ });
355
+
356
+ if (c.action === 'reuse') {
357
+ // 已经发过:直接回上次的结果,**不再打接口**
358
+ return {
359
+ id: c.row?.msg_id || '',
360
+ reused: true,
361
+ pending: c.row?.state === DELIVERY_STATE.AUDITED,
362
+ requestId,
363
+ };
364
+ }
365
+ if (c.action === 'unknown') {
366
+ /**
367
+ * 上次结果未知,**绝不自动重发**。
368
+ *
369
+ * 官方配额下重发的代价是双份:可能重复发一条,还会撞上限流。
370
+ * 宁可让人去面板核对一次。
371
+ */
372
+ throw new SendError(
373
+ '这条消息上次的发送结果未知(超时或进程崩溃),不会自动重发。请到面板「投递」页核对',
374
+ 'unknown',
375
+ );
376
+ }
377
+ if (c.action === 'wait') {
378
+ throw new SendError('这条消息正在发送中,别重复调用', 'pending');
379
+ }
380
+
381
+ try {
382
+ const r = await this.#sendOnce(opts);
383
+ this.delivery.settle(
384
+ requestId,
385
+ r?.pending ? DELIVERY_STATE.AUDITED : DELIVERY_STATE.SENT,
386
+ { msgId: r?.id },
387
+ );
388
+ /**
389
+ * 两个成功出口各记一次,**互斥**:
390
+ * · 这里 = 有 requestId、走 delivery 的路径;
391
+ * · 早返回分支 = 无 requestId(被动回复 / 富媒体 / 菜单)。
392
+ * reuse 不调 #sendOnce,catch 里发送失败,两者都不记。
393
+ */
394
+ this.#recordOutbound(opts, r);
395
+ return { ...r, requestId };
396
+ } catch (e) {
397
+ /**
398
+ * 失败也要结算,否则这一行永远卡在 `pending`。
399
+ *
400
+ * `unknown` 只给「不知道发没发出去」的情况(超时 / 5xx / 连接重置)。
401
+ * `offline` / `expired` / `quota` / `rate` 是**确定没发出去**,记 `failed` 允许重试。
402
+ */
403
+ this.delivery.settle(requestId, uncertainOf(e) ? DELIVERY_STATE.UNKNOWN : DELIVERY_STATE.FAILED, {
404
+ code: e?.code || 'error',
405
+ });
406
+ throw e;
407
+ }
408
+ }
409
+
410
+ /**
411
+ * 出站入账(v3)。
412
+ *
413
+ * 只在**发送成功之后**记:没发出去就等于没说过,记了会污染时间线,
414
+ * 而时间线正是 `chat_look` 和巩固的原料。
415
+ *
416
+ * 三条边界:
417
+ * · 账本没接(老版本 / 被关)→ 直接返回,不报错;
418
+ * · `append` 抛错 → 只 warn,**绝不影响发送**(账本是增强不是主链路);
419
+ * · `at` 由 `timeBasis:'observation'` 决定(= 本地发送时刻)。
420
+ * `append()` 不认外部传的 `at`,它只从 `msg.raw.timestamp` 推——别传,
421
+ * 传了也不会生效,反而会让人以为时间被固定住了。
422
+ *
423
+ * `userId` / `userName` 留空:机器人自己的 openid 要 `api.getMe()`(网络调用),
424
+ * 不能阻塞发送。时间线里出站行显示的是插件名,不缺这两个字段。
425
+ */
426
+ /**
427
+ * 给「不经过本类 send() 的发送路径」补记一笔出站事件。
428
+ *
429
+ * 目前只有 `msg.replyStream`:它直连 `api.sendStreamMessage`,绕过
430
+ * `#sendOnce` 与 delivery,所以 `send()` 里那两个记账出口都覆盖不到它
431
+ * —— 单聊长回复会整条不进账本、「谁」列依旧空(复核抓到的 P1)。
432
+ * 由调用方在**发送成功之后**调一次,幂等由调用方保证。
433
+ */
434
+ recordDirect({ kind, target, content, payload = null, plugin = '', id = '' } = {}) {
435
+ this.#recordOutbound(
436
+ {
437
+ kind,
438
+ target,
439
+ /**
440
+ * 能传完整报文就传:只给 `content` 的话,插件发 `{markdown:{content}}`
441
+ * 或纯媒体时摘要会是空串,账本里就成了一行没有内容的记录。
442
+ */
443
+ payload: payload ?? { content: String(content ?? '') },
444
+ plugin,
445
+ },
446
+ { id: String(id ?? '') },
447
+ );
448
+ }
449
+
450
+ #recordOutbound(opts, r) {
451
+ if (!this.ledger?.append) return;
452
+ try {
453
+ this.ledger.append(
454
+ {
455
+ kind: String(opts.kind ?? ''),
456
+ chatId: String(opts.target ?? ''),
457
+ userId: '',
458
+ userName: '',
459
+ content: digestTextOf({
460
+ ...opts.payload,
461
+ ...(opts.fileType != null ? { fileType: opts.fileType } : {}),
462
+ }),
463
+ id: String(r?.id ?? ''),
464
+ msgIdx: String(r?.raw?.ext_info?.ref_idx ?? ''),
465
+ /**
466
+ * 谁说的。多插件合并时是逗号分隔(`echo,ai-chat`),
467
+ * 由 `message.js` 的 `flushReplies` 拼好传下来。
468
+ */
469
+ plugin: String(opts.plugin ?? ''),
470
+ },
471
+ { timeBasis: 'observation' },
472
+ );
473
+ } catch (e) {
474
+ log.warn(`出站没记进账本(不影响发送):${e.message}`);
475
+ }
476
+ }
477
+
478
+ async #sendOnce({ kind, target, chatKey, payload, receivedAt, fileType }) {
479
+ if (kind !== 'group' && kind !== 'c2c') {
480
+ throw new SendError(`不支持的会话类型: ${kind}`, 'api');
481
+ }
482
+ if (!this.isOnline()) {
483
+ this.stats.offline += 1;
484
+ /**
485
+ * 错误消息要**带上下一步怎么办**。
486
+ *
487
+ * 只解释原因(「没连上」)时,调用方只能自己猜是重试还是放弃;
488
+ * 写明「等它上线」,它就知道这类错误不该重试。
489
+ */
490
+ throw new SendError(
491
+ '机器人当前没有连上 QQ,消息发不出去。等它重新上线再试(面板「运行」页能看状态)。',
492
+ 'offline',
493
+ );
494
+ }
495
+
496
+ const body = { ...payload };
497
+ // 约定:payload 用 api.js 的驼峰字段(msgId / msgSeq),由 api.js 转成 msg_id / msg_seq
498
+ const wantPassive = Boolean(body.msgId);
499
+ let active = !wantPassive;
500
+ let degraded = false;
501
+
502
+ // 1) 被动窗口检查:快过期就别发请求了,直接按主动消息处理
503
+ if (wantPassive) {
504
+ const age = receivedAt ? Date.now() - receivedAt : 0;
505
+ const safeMs = PASSIVE_SAFE_MS[kind];
506
+ if (age > safeMs) {
507
+ if (!this.downgrade) {
508
+ this.stats.expired += 1;
509
+ throw new SendError(
510
+ `这条消息已经过去 ${Math.round(age / 1000)} 秒,超过 QQ 的 ${Math.round(safeMs / 1000)} 秒被动回复窗口,回复发不出去了`,
511
+ 'expired',
512
+ );
513
+ }
514
+ delete body.msgId;
515
+ delete body.msgSeq;
516
+ active = true;
517
+ degraded = true;
518
+ this.stats.degraded += 1;
519
+ log.warn(
520
+ `被动窗口已过(${Math.round(age / 1000)}s),降级为主动消息 · ${chatKey}`,
521
+ );
522
+ }
523
+ }
524
+
525
+ // 2) 排队 + 限速 + 额度扣减 + 实际发送
526
+ // 额度的「查」和「扣」都放在队列里,避免两个并发推送同时通过检查后超额
527
+ return this.#enqueue(chatKey, async () => {
528
+ // 2a) 被动回复次数上限:群聊 5 次、单聊 4 次
529
+ // 同一条消息被多个插件连着回、或用户一句话触发多轮时很容易撞上,
530
+ // 本地先拦,省得服务端返回 40034128 再兜底
531
+ let passiveNow = wantPassive && !active;
532
+ if (passiveNow) {
533
+ const maxReplies = PASSIVE_MAX_REPLIES[kind];
534
+ const used = this.passiveCount.get(body.msgId) ?? 0;
535
+ if (used >= maxReplies) {
536
+ if (!this.downgrade) {
537
+ this.stats.expired += 1;
538
+ throw new SendError(
539
+ `同一条消息最多回复 ${maxReplies} 次(QQ 官方限制),这条已经回满了。\n` +
540
+ `等用户再说一句,就能继续用被动回复。`,
541
+ 'expired',
542
+ );
543
+ }
544
+ delete body.msgId;
545
+ delete body.msgSeq;
546
+ passiveNow = false;
547
+ active = true;
548
+ degraded = true;
549
+ this.stats.degraded += 1;
550
+ log.warn(
551
+ `同一条消息已回复 ${used} 次,达到官方上限,降级为主动消息 · ${chatKey}`,
552
+ );
553
+ } else {
554
+ this.passiveCount.set(body.msgId, used + 1);
555
+ this.#prunePassiveCount();
556
+ }
557
+ }
558
+
559
+ // 2b) 主动消息额度:日额度 + 分钟额度,两个都要过
560
+ if (active) {
561
+ const used = await this.quota.used(chatKey);
562
+ if (used >= this.dailyActive) {
563
+ this.stats.quotaBlocked += 1;
564
+ throw new SendError(
565
+ `这个会话今天的主动消息额度已用完(${this.dailyActive} 条/天),QQ 不再允许主动推送。\n` +
566
+ `等用户 @ 一次机器人,就能继续用被动回复。`,
567
+ 'quota',
568
+ );
569
+ }
570
+ /**
571
+ * 分钟限流单独一个 code(`rate`),**不能和日额度共用 `quota`**。
572
+ *
573
+ * 两者的「多久能好」差三个数量级:日额度要等跨天,分钟限流等一分钟。
574
+ * 以前都叫 `quota`,调用方只能按最坏情况处理——dsh-roost 的主动搭话
575
+ * 会把「发得太密」当成「今天说满了」,直接放弃一整天。
576
+ * 反过来按最好情况处理,就是每分钟重试一次空转到跨天。
577
+ */
578
+ if (!this.#allowActiveThisMinute(chatKey)) {
579
+ this.stats.rateBlocked += 1;
580
+ throw new SendError(
581
+ `这个会话的主动消息发得太密(官方限制 ${this.activePerMin} 条/分钟),**等 1 分钟**就好。`,
582
+ 'rate',
583
+ );
584
+ }
585
+ await this.quota.consume(chatKey);
586
+ }
587
+
588
+ try {
589
+ const r = await this.#post(kind, target, body, active ? '主动' : '被动', fileType);
590
+ if (active) this.stats.active += 1;
591
+ else this.stats.passive += 1;
592
+ if (r?.pending) this.stats.audit += 1;
593
+ return { ...r, degraded };
594
+ } catch (e) {
595
+ // 被动回复没发出去,把这次占用的次数退回去,不然 5 次额度会白白烧掉
596
+ if (passiveNow) {
597
+ const n = this.passiveCount.get(body.msgId) ?? 0;
598
+ if (n > 0) this.passiveCount.set(body.msgId, n - 1);
599
+ }
600
+ // 主动消息是「先扣后发」,发失败要把额度退回去
601
+ if (active) await this.quota.refund(chatKey).catch(() => {});
602
+ // 被动回复被服务端判定过期(本地时钟差或排队等太久),再降级试一次
603
+ const canRetry =
604
+ wantPassive && !active && this.downgrade && this.#looksExpired(e);
605
+ if (!canRetry) throw e;
606
+
607
+ const used = await this.quota.used(chatKey);
608
+ if (used >= this.dailyActive || !this.#allowActiveThisMinute(chatKey)) {
609
+ this.stats.expired += 1;
610
+ throw new SendError(
611
+ 'QQ 判定这条被动回复已过期,且主动消息额度不够补救',
612
+ 'expired',
613
+ );
614
+ }
615
+ await this.quota.consume(chatKey);
616
+ const fallback = { ...body };
617
+ delete fallback.msgId;
618
+ delete fallback.msgSeq;
619
+ log.warn(`被动回复被拒(${e.message}),改发主动消息 · ${chatKey}`);
620
+ const r = await this.#post(kind, target, fallback, '降级主动', fileType);
621
+ this.stats.degraded += 1;
622
+ this.stats.active += 1;
623
+ if (r?.pending) this.stats.audit += 1;
624
+ return { ...r, degraded: true };
625
+ }
626
+ });
627
+ }
628
+
629
+ /* ---------------- 内部 ---------------- */
630
+
631
+ async #post(kind, target, body, how = '', fileType) {
632
+ const r = kind === 'group'
633
+ ? await this.api.sendGroup(target, body)
634
+ : await this.api.sendC2C(target, body);
635
+
636
+ // 发出去了什么。放在这里而不是调用处:三个调用点(正常 / 降级 / 审核中)
637
+ // 都要打,写在一处不会漏
638
+ const where = String(target ?? '').slice(0, 10) || '-';
639
+ /** 顶层 fileType 不进 body(会被 api 当未知字段带出去),只用于日志与账本 */
640
+ const what = briefOf({ ...body, ...(fileType != null ? { fileType } : {}) }) || '(空)';
641
+
642
+ // 主动消息需要审核,接口会用业务码返回「审核中」,这不是失败
643
+ if (r && typeof r === 'object' && Number(r.code) === CODE_AUDIT) {
644
+ log.info(`发出 ${kind} →${where}(${how}·审核中):${what}`);
645
+ return { pending: true, id: r.id ?? '', raw: r };
646
+ }
647
+ log.info(`发出 ${kind} →${where}${how ? `(${how})` : ''}:${what}`);
648
+ return { id: r?.id ?? '', raw: r };
649
+ }
650
+
651
+ /** 从异常里判断是不是「被动窗口过期」这类可降级的错误 */
652
+ #looksExpired(e) {
653
+ const text = `${e?.message ?? ''} ${JSON.stringify(e?.body ?? {})}`.toLowerCase();
654
+ return (
655
+ text.includes('msg_id') ||
656
+ text.includes('expire') ||
657
+ text.includes('过期') ||
658
+ text.includes('超时') ||
659
+ text.includes('40034')
660
+ );
661
+ }
662
+
663
+ /**
664
+ * 最近 60 秒这个会话发了几条。
665
+ *
666
+ * 只读,不占坑——`#allowActiveThisMinute` 会顺手把当前时刻压进队列,
667
+ * 诊断用的时候不能有这个副作用(面板刷一次就少一次额度就离谱了)。
668
+ */
669
+ rateUsed(chatKey, now = Date.now()) {
670
+ return (this.activeLog.get(chatKey) ?? []).filter((t) => now - t < 60_000).length;
671
+ }
672
+
673
+ /**
674
+ * 主动消息分钟级限流。官方单关系维度是 20/qpm,
675
+ * 日额度够但一分钟内猛发照样会被服务端 40034100 拦下,本地先挡。
676
+ * @returns {boolean} 这一分钟还允许发吗(允许时顺便占一个坑)
677
+ */
678
+ #allowActiveThisMinute(chatKey) {
679
+ if (this.activePerMin <= 0) return true;
680
+ const now = Date.now();
681
+ const list = (this.activeLog.get(chatKey) ?? []).filter((t) => now - t < 60_000);
682
+ if (list.length >= this.activePerMin) {
683
+ this.activeLog.set(chatKey, list);
684
+ return false;
685
+ }
686
+ list.push(now);
687
+ this.activeLog.set(chatKey, list);
688
+ // 会话数很多时别让这张表无限长
689
+ if (this.activeLog.size > 500) {
690
+ const oldest = this.activeLog.keys().next().value;
691
+ if (oldest !== undefined) this.activeLog.delete(oldest);
692
+ }
693
+ return true;
694
+ }
695
+
696
+ /** 被动次数表不能无限长,超了丢最旧的(和 api.js 的 seqMap 一个套路) */
697
+ #prunePassiveCount() {
698
+ if (this.passiveCount.size <= 2000) return;
699
+ const oldest = this.passiveCount.keys().next().value;
700
+ if (oldest !== undefined) this.passiveCount.delete(oldest);
701
+ }
702
+
703
+ /**
704
+ * 同一会话串行执行,并保证 1 秒内不超过 perSec 条。
705
+ * 不同会话之间互不影响,可以并行。
706
+ */
707
+ #enqueue(chatKey, task) {
708
+ let q = this.queues.get(chatKey);
709
+ if (!q) {
710
+ q = { chain: Promise.resolve(), history: [] };
711
+ this.queues.set(chatKey, q);
712
+ }
713
+ const run = q.chain.then(async () => {
714
+ await this.#throttle(q);
715
+ return task();
716
+ });
717
+ // 链不能因为某次失败就断掉
718
+ q.chain = run.then(
719
+ () => {},
720
+ () => {},
721
+ );
722
+ return run;
723
+ }
724
+
725
+ async #throttle(q) {
726
+ for (;;) {
727
+ const now = Date.now();
728
+ q.history = q.history.filter((t) => now - t < 1000);
729
+ if (q.history.length < this.perSec) {
730
+ q.history.push(now);
731
+ return;
732
+ }
733
+ this.stats.throttled += 1;
734
+ const wait = 1000 - (now - q.history[0]) + 10;
735
+ if (wait > 0) await sleep(wait);
736
+ }
737
+ }
738
+
739
+ /* ---------------- 状态 ---------------- */
740
+
741
+ async quotaSnapshot() {
742
+ return this.quota.snapshot();
743
+ }
744
+
745
+ /** 给 /status 和面板用的一行摘要 */
746
+ async summary() {
747
+ const used = await this.quota.snapshot();
748
+ const total = Object.values(used).reduce((a, b) => a + b, 0);
749
+ return {
750
+ ...this.stats,
751
+ perSec: this.perSec,
752
+ dailyActive: this.dailyActive,
753
+ activePerMinute: this.activePerMin,
754
+ activeUsedToday: total,
755
+ activeByChat: used,
756
+ };
757
+ }
758
+
759
+ async flush() {
760
+ await this.quota.flush();
761
+
762
+ /**
763
+ * 等排队中的发送发完再退出。
764
+ *
765
+ * 以前只刷额度账本、完全不看 `this.queues`:重启/更新/关机时,
766
+ * 用户看到命令执行了,回复却永远没发出去,额度还照扣。
767
+ * 最多等 5 秒,超时就明确说丢了几条。
768
+ */
769
+ const chains = [...this.queues.values()].map((q) => q.chain);
770
+ if (chains.length) {
771
+ let timedOut = false;
772
+ const timeout = new Promise((resolve) => {
773
+ const t = setTimeout(() => {
774
+ timedOut = true;
775
+ resolve();
776
+ }, 5000);
777
+ t.unref?.();
778
+ });
779
+ await Promise.race([Promise.allSettled(chains), timeout]);
780
+ if (timedOut) {
781
+ log.warn(`退出前还有 ${this.queues.size} 个会话的发送任务没排完,已放弃等待`);
782
+ }
783
+ }
784
+ }
785
+ }
786
+
787
+ /** 把 SendError 翻译成给用户看的话;非 SendError 返回 null */
788
+ export function userFacingSendError(e) {
789
+ if (e instanceof SendError) return e.message;
790
+ return null;
791
+ }