qq-bot-roost 0.8.14 → 0.9.1
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 +6 -4
- package/package.json +1 -1
- package/src/bot.js +184 -1
- package/src/config.js +35 -0
- package/src/delivery.js +271 -0
- package/src/gate.js +325 -0
- package/src/ledger.js +513 -0
- package/src/main.js +1389 -1364
- package/src/message.js +10 -1
- package/src/outbound.js +133 -8
- package/src/paths.js +18 -0
- package/src/pipeline.js +10 -1
- package/src/plugin/context.js +914 -824
- package/src/plugin/loader.js +941 -932
- package/src/runtime.js +8 -1
- package/src/storage/events-schema.js +71 -0
- package/src/storage/schema.js +32 -2
- package/src/web/server.js +202 -2
- package/web/app.js +140 -0
- package/web/index.html +48 -0
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-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.
|
|
13
|
+
<img src="https://img.shields.io/badge/version-0.9.1-blueviolet" alt="版本 0.9.1">
|
|
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/
|
|
425
|
+
│ ├── storage/ SQLite 连接与两套表结构:统计库(不存正文)/ 事件账本(存原文)
|
|
424
426
|
│ ├── web/server.js 面板服务端(REST + SSE)
|
|
425
427
|
│ └── plugin/ 插件加载、配置、依赖、子进程宿主、面板页面注册
|
|
426
428
|
├── test/
|
|
427
|
-
│ ├── selftest.mjs 框架自检(
|
|
428
|
-
│ ├── e2e.mjs 端到端测试(
|
|
429
|
+
│ ├── selftest.mjs 框架自检(538 项)
|
|
430
|
+
│ ├── e2e.mjs 端到端测试(243 项,起真实服务打接口)
|
|
429
431
|
│ ├── qrlogin.mjs 扫码建机器人(8 项)
|
|
430
432
|
│ └── deps.mjs 插件依赖安装(11 项)
|
|
431
433
|
├── web/ 面板前端(原生 JS/CSS,无构建)
|
package/package.json
CHANGED
package/src/bot.js
CHANGED
|
@@ -17,6 +17,9 @@ import { claimInstance, releaseInstance } from './instance.js';
|
|
|
17
17
|
import { botPaths, DIRS, rel } from './paths.js';
|
|
18
18
|
import { StatsStore } from './storage/index.js';
|
|
19
19
|
import { createReach } from './reach.js';
|
|
20
|
+
import { createLedger } from './ledger.js';
|
|
21
|
+
import { createGate } from './gate.js';
|
|
22
|
+
import { createDeliveryLedger } from './delivery.js';
|
|
20
23
|
|
|
21
24
|
/**
|
|
22
25
|
* 一个机器人的完整运行时。
|
|
@@ -95,6 +98,17 @@ export class BotRuntime {
|
|
|
95
98
|
* 插件可以通过 `bot.reach` 直接问,不用自己监听一遍。
|
|
96
99
|
*/
|
|
97
100
|
this.reach = createReach({ file: this.paths.reach });
|
|
101
|
+
/**
|
|
102
|
+
* 暂停闸:三级(全局 / 这只鸟 / 单个会话)+ 静默。
|
|
103
|
+
*
|
|
104
|
+
* 这是**唯一的刹车**。以前配错一只鸟只能关进程,那连别的鸟一起停了。
|
|
105
|
+
* 闸状态按鸟拆文件(`data/gate/<id>.json`),跨鸟只有 `global.json` 一个。
|
|
106
|
+
*/
|
|
107
|
+
this.gate = createGate({
|
|
108
|
+
dir: DIRS.gate,
|
|
109
|
+
botId: this.id,
|
|
110
|
+
log: (m) => this.log.warn(m),
|
|
111
|
+
});
|
|
98
112
|
this.outbound = null;
|
|
99
113
|
this.session = null;
|
|
100
114
|
this.pipeline = null;
|
|
@@ -168,6 +182,7 @@ export class BotRuntime {
|
|
|
168
182
|
this.pipeline = createDefaultPipeline(cfg, {
|
|
169
183
|
isSticky: (chatId) => this.session.isSticky(chatId),
|
|
170
184
|
metrics: this.metrics,
|
|
185
|
+
gate: this.gate,
|
|
171
186
|
});
|
|
172
187
|
|
|
173
188
|
/**
|
|
@@ -180,6 +195,19 @@ export class BotRuntime {
|
|
|
180
195
|
retentionDays: cfg.stats?.retentionDays ?? 90,
|
|
181
196
|
});
|
|
182
197
|
|
|
198
|
+
/**
|
|
199
|
+
* 事件账本:官方没有历史消息接口,这是**唯一的历史来源**。
|
|
200
|
+
*
|
|
201
|
+
* 和 `stats` 是两个库、两套保留策略:统计留 90 天聚合且不存正文,
|
|
202
|
+
* 账本只留 7 天但存原文。混一个库的话「这个库会不会被拷走」就说不清了。
|
|
203
|
+
*/
|
|
204
|
+
this.ledger = createLedger({
|
|
205
|
+
file: this.paths.events,
|
|
206
|
+
botId: this.id,
|
|
207
|
+
enabled: cfg.ledger?.enabled !== false,
|
|
208
|
+
retentionDays: cfg.ledger?.retentionDays ?? 7,
|
|
209
|
+
});
|
|
210
|
+
|
|
183
211
|
this.plugins = new PluginManager({
|
|
184
212
|
pluginsDir: DIRS.plugins,
|
|
185
213
|
paths: this.paths,
|
|
@@ -193,6 +221,8 @@ export class BotRuntime {
|
|
|
193
221
|
session: this.session,
|
|
194
222
|
pipeline: this.pipeline,
|
|
195
223
|
reach: this.reach,
|
|
224
|
+
ledger: this.ledger,
|
|
225
|
+
gate: this.gate,
|
|
196
226
|
isOwner: (uid) => cfg.owners.includes(uid),
|
|
197
227
|
// 插件声明了 frameworkVersion 就做兼容校验;作者本地调试可以关掉
|
|
198
228
|
ignoreCompat: this.globalCfg.plugins?.ignoreFrameworkVersion === true,
|
|
@@ -205,6 +235,46 @@ export class BotRuntime {
|
|
|
205
235
|
// 统计库要在插件之前打开:插件上报的运行数据也算统计的一部分
|
|
206
236
|
this.stats.open();
|
|
207
237
|
|
|
238
|
+
/**
|
|
239
|
+
* 投递账本(阶段 5)放在统计库里,所以必须等 `stats.open()` 之后。
|
|
240
|
+
*
|
|
241
|
+
* `outbound` 在更早的地方就构造好了(它不依赖库),这里补上引用——
|
|
242
|
+
* 账本是**增强**,`stats` 打不开时 `db` 是 null,账本自己会退回「照发」。
|
|
243
|
+
*/
|
|
244
|
+
this.delivery = createDeliveryLedger({
|
|
245
|
+
db: this.stats.db,
|
|
246
|
+
botId: this.id,
|
|
247
|
+
log: (m) => this.log.warn(m),
|
|
248
|
+
});
|
|
249
|
+
if (this.outbound) this.outbound.delivery = this.delivery;
|
|
250
|
+
/** 插件管理器更早构造(那时还没有库),这里补上引用 */
|
|
251
|
+
if (this.plugins) this.plugins.delivery = this.delivery;
|
|
252
|
+
/**
|
|
253
|
+
* 崩溃恢复:上次进程死掉时还在 `pending` 的,全部标 `unknown`。
|
|
254
|
+
*
|
|
255
|
+
* 现在还没有任何发送在跑,所以那些 `pending` 一定是崩溃残留——
|
|
256
|
+
* 我们不知道发没发出去,只能标未知,等人核对。**不能当失败重发。**
|
|
257
|
+
*/
|
|
258
|
+
this.delivery.recoverPending();
|
|
259
|
+
// 账本更要在插件之前开:插件一加载就可能开始收消息,漏一条就永久没了
|
|
260
|
+
this.ledger.open();
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* 闸要在插件加载之前读好,否则插件 setup 里发的第一条主动消息就漏过了闸。
|
|
264
|
+
*
|
|
265
|
+
* `pauseOnStart` 只在**首次运行**生效(闸文件还不存在)。
|
|
266
|
+
* 每次启动都暂停会让人以为坏了——重启之后不说话,第一反应是「出故障了」,
|
|
267
|
+
* 不是「等我确认」。方案里那条「安装时自动暂停」靠这个分支实现。
|
|
268
|
+
*/
|
|
269
|
+
const { botExisted } = await this.gate.open();
|
|
270
|
+
if (cfg.gate?.pauseOnStart && !botExisted) {
|
|
271
|
+
await this.gate.setBot(true, {
|
|
272
|
+
by: 'config',
|
|
273
|
+
reason: '首次启动自动暂停(gate.pauseOnStart),确认配置无误后在面板放开',
|
|
274
|
+
});
|
|
275
|
+
this.log.warn('首次启动已自动暂停,确认配置无误后在面板「运行」页放开');
|
|
276
|
+
}
|
|
277
|
+
|
|
208
278
|
// 按配置过滤要加载的插件
|
|
209
279
|
await this.plugins.scan({ filter: pluginFilterOf(cfg.plugins) });
|
|
210
280
|
}
|
|
@@ -471,11 +541,95 @@ export class BotRuntime {
|
|
|
471
541
|
await this.groups?.flush().catch(() => {});
|
|
472
542
|
await this.roles?.flush().catch(() => {});
|
|
473
543
|
await this.reach?.flush().catch(() => {});
|
|
544
|
+
await this.gate?.flush().catch(() => {});
|
|
474
545
|
this.stats?.close();
|
|
546
|
+
this.ledger?.close();
|
|
475
547
|
if (!this.mock) await releaseInstance(this.cfg.appId).catch(() => {});
|
|
476
548
|
this.state = 'stopped';
|
|
477
549
|
}
|
|
478
550
|
|
|
551
|
+
/**
|
|
552
|
+
* 这只鸟现在能不能对 `chatId` 说话,以及不能的**原因**。
|
|
553
|
+
*
|
|
554
|
+
* 故障排查从「猜」变成「读」:面板直接把 `reasons` 列成中文,
|
|
555
|
+
* 用户不用去翻日志猜「为什么她不说话」。
|
|
556
|
+
*
|
|
557
|
+
* 归到这里的四类状态:闸(三级 + 静默)、网关在线、日额度、分钟限流。
|
|
558
|
+
* 没放 `Outbound` 里的理由:outbound 是发送执行器,只知道额度和离线,
|
|
559
|
+
* 不知道闸;而这个方法要回答的是「这只鸟现在能不能开口」,
|
|
560
|
+
* 是**运行时诊断**,不是发送动作。
|
|
561
|
+
*
|
|
562
|
+
* @param {string} chatId
|
|
563
|
+
* @param {{kind?:'group'|'c2c'}} [opts] 给了就按对应口径查额度;
|
|
564
|
+
* 不给就取两种里用得多的那个(chatId 在群和私聊里不会撞)
|
|
565
|
+
* @returns {Promise<{ok:boolean, reasons:{code:string,text:string}[]}>}
|
|
566
|
+
*/
|
|
567
|
+
async canSpeak(chatId, { kind = '' } = {}) {
|
|
568
|
+
const reasons = [...this.gate.check(chatId).reasons];
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* 可达性:只对**主动消息**有意义。
|
|
572
|
+
*
|
|
573
|
+
* 用户刚说完话时的被动回复不受这两道门限制——他就在这儿,回一句当然收得到。
|
|
574
|
+
* 所以必须传 `kind` 才查:不知道是群还是私聊就查不了,而「不知道」不该变成「不能」。
|
|
575
|
+
*
|
|
576
|
+
* `unknown` 一律放行:群里聊过的人很可能早就加了好友,只是还没私聊过。
|
|
577
|
+
*/
|
|
578
|
+
if (kind === 'c2c') {
|
|
579
|
+
const v = await this.reach?.canC2C?.(chatId).catch(() => null);
|
|
580
|
+
if (v?.verdict === 'no') {
|
|
581
|
+
reasons.push({
|
|
582
|
+
code: v.reason || 'reach:not-friend',
|
|
583
|
+
text:
|
|
584
|
+
v.reason === 'reach:push-off'
|
|
585
|
+
? '对方在资料卡里关掉了主动消息推送'
|
|
586
|
+
: '对方没有加机器人好友',
|
|
587
|
+
});
|
|
588
|
+
}
|
|
589
|
+
} else if (kind === 'group') {
|
|
590
|
+
const v = await this.reach?.canGroupPush?.(chatId).catch(() => null);
|
|
591
|
+
if (v?.verdict === 'no') {
|
|
592
|
+
reasons.push({
|
|
593
|
+
code: v.reason || 'reach:push-off',
|
|
594
|
+
text: '群管理员关闭了机器人的主动消息推送',
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (!this.mock && this.state !== 'online') {
|
|
600
|
+
reasons.push({
|
|
601
|
+
code: 'gateway:offline',
|
|
602
|
+
text: `没连上 QQ(当前状态:${this.state})`,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (this.outbound) {
|
|
607
|
+
const keys = kind ? [`${kind}:${chatId}`] : [`group:${chatId}`, `c2c:${chatId}`];
|
|
608
|
+
const q = await this.outbound.quotaSnapshot().catch(() => ({}));
|
|
609
|
+
const used = Math.max(0, ...keys.map((k) => Number(q[k] ?? 0)));
|
|
610
|
+
if (used >= this.outbound.dailyActive) {
|
|
611
|
+
reasons.push({
|
|
612
|
+
code: 'quota:daily',
|
|
613
|
+
text: `今天的主动消息额度用完了(${used}/${this.outbound.dailyActive} 条)`,
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* 分钟限流也列出来,但它是**瞬时**状态——面板上看到这条通常
|
|
618
|
+
* 等一分钟就自己好了。列它的价值是「不用怀疑别的」,
|
|
619
|
+
* 而不是让用户去改配置。
|
|
620
|
+
*/
|
|
621
|
+
const burst = Math.max(0, ...keys.map((k) => this.outbound.rateUsed(k)));
|
|
622
|
+
if (burst >= this.outbound.activePerMin && this.outbound.activePerMin > 0) {
|
|
623
|
+
reasons.push({
|
|
624
|
+
code: 'quota:permin',
|
|
625
|
+
text: `这一分钟发得太密(${burst}/${this.outbound.activePerMin} 条),等一分钟就好`,
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return { ok: reasons.length === 0, reasons };
|
|
631
|
+
}
|
|
632
|
+
|
|
479
633
|
/* ---------------- 事件分发 ---------------- */
|
|
480
634
|
|
|
481
635
|
/**
|
|
@@ -541,6 +695,34 @@ export class BotRuntime {
|
|
|
541
695
|
/* ---- 消息事件 ---- */
|
|
542
696
|
const msg = buildMessage(eventName, d, { api: this.api, cfg, outbound, session });
|
|
543
697
|
|
|
698
|
+
/**
|
|
699
|
+
* 入账:**第一件要做的事**,在去重 / 白名单 / 限流 / 暂停之前。
|
|
700
|
+
*
|
|
701
|
+
* 顺序是有讲究的。账本记的是「发生过什么」,不是「我们怎么处理的」:
|
|
702
|
+
* - 被去重拦下的那条,官方确实推了两次,事实是「推了两次」,只是我们处理了一次;
|
|
703
|
+
* - 暂停期间的消息如果不记,恢复后就永久消失了——官方没有历史消息接口,补不回来。
|
|
704
|
+
*
|
|
705
|
+
* 重复推送由 `dedupe` 唯一约束兜底(官方明说「相同 msg_id 可能重复推送」),
|
|
706
|
+
* 所以放在最前面也不会记重。
|
|
707
|
+
*
|
|
708
|
+
* 同步写:`node:sqlite` 是同步 API,一条 INSERT 在 WAL 下是微秒级,
|
|
709
|
+
* 比「攒批 + 定时刷」更安全——进程被 kill 时不会丢最后一批。
|
|
710
|
+
*/
|
|
711
|
+
/**
|
|
712
|
+
* 入账的返回值挂到消息上:`msg.eventId` / `msg.eventSeq`。
|
|
713
|
+
*
|
|
714
|
+
* dsh-roost 写记忆时要记「这条记忆从哪条消息来」(场景片段,阶段 4),
|
|
715
|
+
* 而它是另一个进程,只能靠转发时带过去的 id 反查原文。
|
|
716
|
+
*
|
|
717
|
+
* 撞 dedupe(官方重推同一条)时 `append` 也返回已有那条的 id,
|
|
718
|
+
* 所以重推的消息一样有出处。
|
|
719
|
+
*/
|
|
720
|
+
const rec = this.ledger?.append(msg);
|
|
721
|
+
if (rec) {
|
|
722
|
+
msg.eventId = rec.id;
|
|
723
|
+
msg.eventSeq = rec.seq;
|
|
724
|
+
}
|
|
725
|
+
|
|
544
726
|
// 收到私聊 = 好友关系存在的强证据。不置 c2cPush:他能发不等于他允许机器人主动推
|
|
545
727
|
if (msg.kind === 'c2c' && msg.userId) {
|
|
546
728
|
this.reach
|
|
@@ -676,7 +858,8 @@ export class BotRuntime {
|
|
|
676
858
|
await commandEntry.handler(msg, { args: msg.args, raw: msg.content, bot: this });
|
|
677
859
|
} catch (e) {
|
|
678
860
|
this.metrics.bump('errors');
|
|
679
|
-
|
|
861
|
+
// 这几种是发送侧的已知限制,不是插件写错了,只 warn 不报堆栈
|
|
862
|
+
if (['offline', 'expired', 'quota', 'rate'].includes(e?.code)) {
|
|
680
863
|
this.log.warn(`命令 ${commandEntry.name} 回复失败: ${e.message}`);
|
|
681
864
|
return;
|
|
682
865
|
}
|
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
|
});
|
package/src/delivery.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 投递账本
|
|
3
|
+
* ========
|
|
4
|
+
*
|
|
5
|
+
* 一句话:**发出的每一条消息都有据可查,且永远不会因为「不确定」而被重发。**
|
|
6
|
+
*
|
|
7
|
+
* ── 为什么需要 ──
|
|
8
|
+
*
|
|
9
|
+
* 现在「我说过 X」靠**调用顺序**保证——必须挂在发送成功之后。
|
|
10
|
+
* 这在 `msg_seq` 群 1–5 / 单聊 1–4 的约束下很脆:一次超时重发就撞上限流,
|
|
11
|
+
* 还会把「我说过」写进记忆,形成自洽幻觉(她坚称自己说过一句没人看见的话)。
|
|
12
|
+
*
|
|
13
|
+
* 改造后靠**状态查询**:顺序会被并发打乱,状态不会。
|
|
14
|
+
*
|
|
15
|
+
* ── 状态机 ──
|
|
16
|
+
*
|
|
17
|
+
* ┌──────── 304023 ────────→ audited(等审核事件结算)
|
|
18
|
+
* │
|
|
19
|
+
* created → pending ┼──────── 有 msg_id ──────→ sent
|
|
20
|
+
* │
|
|
21
|
+
* ├── 超窗/配额/离线/限流 ──→ failed(可重试)
|
|
22
|
+
* │
|
|
23
|
+
* └── 超时/5xx/连接重置 ───→ unknown(**永不自动重发**)
|
|
24
|
+
*
|
|
25
|
+
* 三个非显然的点:
|
|
26
|
+
*
|
|
27
|
+
* 1. **`audited` 不是成功也不是失败。** 官方文档里的 `pending` 指「结果未确定」,
|
|
28
|
+
* 而账本里的 `pending` 指「尚未发出」——两个 `pending` 会打架,所以这里叫 `audited`。
|
|
29
|
+
* 审核期间**不能回写「我说过」**,否则审核不通过就变成她坚称说过一句没人看见的话。
|
|
30
|
+
*
|
|
31
|
+
* 2. **`unknown` 必须存在。** 网络超时后我们**无法知道**服务端是否收到。
|
|
32
|
+
* 原则是「未知结果必须人工核对,绝不自动重发」——官方配额下重发的代价更高
|
|
33
|
+
* (撞限流 + 重复消息)。
|
|
34
|
+
*
|
|
35
|
+
* 3. **`pending` 会在崩溃后残留。** 启动时扫一遍全部转 `unknown`。
|
|
36
|
+
* 不做这一步,重启后这些记录永远卡在「正在发送」。
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { createHash } from 'node:crypto';
|
|
40
|
+
|
|
41
|
+
export const DELIVERY_STATE = {
|
|
42
|
+
PENDING: 'pending',
|
|
43
|
+
SENT: 'sent',
|
|
44
|
+
AUDITED: 'audited',
|
|
45
|
+
FAILED: 'failed',
|
|
46
|
+
UNKNOWN: 'unknown',
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const ALL_STATES = new Set(Object.values(DELIVERY_STATE));
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 摘要:前 120 字 + 总长 + sha1 前 8 位。
|
|
53
|
+
*
|
|
54
|
+
* **不存全文**——沿用 `schema.js` 的原则「只存聚合结果,不存消息正文」。
|
|
55
|
+
* 全文只在事件账本里(且受 7 天保留策略约束)。
|
|
56
|
+
* 摘要的用途是「人工核对时认出是哪一条」,不是「重发时照抄」。
|
|
57
|
+
*
|
|
58
|
+
* @param {string} text
|
|
59
|
+
* @returns {string}
|
|
60
|
+
*/
|
|
61
|
+
export function digestOf(text) {
|
|
62
|
+
const s = String(text ?? '');
|
|
63
|
+
const sha = createHash('sha1').update(s, 'utf8').digest('hex').slice(0, 8);
|
|
64
|
+
return `${s.slice(0, 120)}…(${s.length})#${sha}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {object} o
|
|
69
|
+
* @param {object} o.db StatsStore 的 DatabaseSync(库不可用时为 null)
|
|
70
|
+
* @param {string} o.botId
|
|
71
|
+
* @param {(m:string)=>void} [o.log]
|
|
72
|
+
*/
|
|
73
|
+
export function createDeliveryLedger({ db, botId, log = () => {} }) {
|
|
74
|
+
const ready = () => Boolean(db);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 认领一次发送。
|
|
78
|
+
*
|
|
79
|
+
* 这是整个账本的入口:**同一个 requestId 调多少次,都只有第一次会真的发**。
|
|
80
|
+
*
|
|
81
|
+
* @param {object} p
|
|
82
|
+
* @param {string} p.requestId 幂等键,由调用方给(`qq_send` 生成)
|
|
83
|
+
* @param {'group'|'c2c'} p.kind
|
|
84
|
+
* @param {string} p.chatId
|
|
85
|
+
* @param {string} [p.replyTo] 被动回复时的 msg_id
|
|
86
|
+
* @param {number} [p.msgSeq]
|
|
87
|
+
* @param {string} p.digest
|
|
88
|
+
* @returns {{action:'send'|'reuse'|'unknown'|'wait', row?:object}}
|
|
89
|
+
* - `send` 没发过 / 上次 failed,可以发
|
|
90
|
+
* - `reuse` 已经 sent/audited,直接返回上次结果
|
|
91
|
+
* - `unknown` 上次结果未知,**不能重发**
|
|
92
|
+
* - `wait` 正在发送中(并发调用)
|
|
93
|
+
*/
|
|
94
|
+
function claim({ requestId, kind, chatId, replyTo, msgSeq, digest }) {
|
|
95
|
+
if (!ready()) return { action: 'send' };
|
|
96
|
+
const id = String(requestId ?? '').trim();
|
|
97
|
+
if (!id) return { action: 'send' };
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const row = db.prepare('SELECT * FROM deliveries WHERE request_id = ?').get(id);
|
|
101
|
+
if (!row) {
|
|
102
|
+
db.prepare(
|
|
103
|
+
`INSERT INTO deliveries
|
|
104
|
+
(request_id, bot_id, kind, chat_id, reply_to, msg_seq, digest, state, attempts, created_at)
|
|
105
|
+
VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
|
106
|
+
).run(
|
|
107
|
+
id,
|
|
108
|
+
botId,
|
|
109
|
+
String(kind ?? ''),
|
|
110
|
+
String(chatId ?? ''),
|
|
111
|
+
replyTo ?? null,
|
|
112
|
+
msgSeq ?? null,
|
|
113
|
+
String(digest ?? ''),
|
|
114
|
+
DELIVERY_STATE.PENDING,
|
|
115
|
+
0,
|
|
116
|
+
Date.now(),
|
|
117
|
+
);
|
|
118
|
+
return { action: 'send' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (row.state === DELIVERY_STATE.SENT || row.state === DELIVERY_STATE.AUDITED) {
|
|
122
|
+
return { action: 'reuse', row };
|
|
123
|
+
}
|
|
124
|
+
if (row.state === DELIVERY_STATE.UNKNOWN) return { action: 'unknown', row };
|
|
125
|
+
if (row.state === DELIVERY_STATE.PENDING) return { action: 'wait', row };
|
|
126
|
+
|
|
127
|
+
// failed:允许重试,复用同一行(不新建,否则同一个 requestId 会散成好几条)
|
|
128
|
+
db.prepare(
|
|
129
|
+
'UPDATE deliveries SET state = ?, attempts = attempts + 1, code = NULL, settled_at = NULL WHERE request_id = ?',
|
|
130
|
+
).run(DELIVERY_STATE.PENDING, id);
|
|
131
|
+
return { action: 'send', row };
|
|
132
|
+
} catch (e) {
|
|
133
|
+
/**
|
|
134
|
+
* 账本坏了不能把消息也堵死。
|
|
135
|
+
* 记账是增强,发送是主链路——这里退回「照发」,并留一条日志。
|
|
136
|
+
*/
|
|
137
|
+
log(`投递账本认领失败(照常发送):${e.message}`);
|
|
138
|
+
return { action: 'send' };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 结算。
|
|
144
|
+
*
|
|
145
|
+
* @param {string} requestId
|
|
146
|
+
* @param {string} state `sent` / `audited` / `failed` / `unknown`
|
|
147
|
+
* @param {{msgId?:string, code?:string}} [extra]
|
|
148
|
+
*/
|
|
149
|
+
function settle(requestId, state, { msgId, code } = {}) {
|
|
150
|
+
if (!ready()) return false;
|
|
151
|
+
const id = String(requestId ?? '').trim();
|
|
152
|
+
if (!id) return false;
|
|
153
|
+
if (!ALL_STATES.has(state) || state === DELIVERY_STATE.PENDING) {
|
|
154
|
+
log(`投递账本结算状态不合法:${state}`);
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
const r = db
|
|
159
|
+
.prepare(
|
|
160
|
+
'UPDATE deliveries SET state = ?, msg_id = ?, code = ?, settled_at = ? WHERE request_id = ?',
|
|
161
|
+
)
|
|
162
|
+
.run(state, msgId ?? null, code ?? null, Date.now(), id);
|
|
163
|
+
/**
|
|
164
|
+
* 返回**是否真的更新了一行**。
|
|
165
|
+
*
|
|
166
|
+
* 结算一个不存在的 requestId 会静默 0 行——以前这里无条件 `return true`,
|
|
167
|
+
* 调用方以为结算成功,实际那条记录还是 pending,重启后又被转成 unknown。
|
|
168
|
+
*/
|
|
169
|
+
return Number(r?.changes ?? 0) > 0;
|
|
170
|
+
} catch (e) {
|
|
171
|
+
log(`投递账本结算失败:${e.message}`);
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* 崩溃恢复:把所有 `pending` 转 `unknown`。
|
|
178
|
+
*
|
|
179
|
+
* 只在**启动时**调。那时没有任何发送在跑,所以 `pending` 一定是上次崩了
|
|
180
|
+
* 留下的——那些消息我们不知道发没发出去,只能标未知,等人核对。
|
|
181
|
+
*
|
|
182
|
+
* @returns {number} 转了多少条
|
|
183
|
+
*/
|
|
184
|
+
function recoverPending() {
|
|
185
|
+
if (!ready()) return 0;
|
|
186
|
+
try {
|
|
187
|
+
const r = db
|
|
188
|
+
.prepare(
|
|
189
|
+
`UPDATE deliveries SET state = ?, code = 'crash', settled_at = ? WHERE state = ?`,
|
|
190
|
+
)
|
|
191
|
+
.run(DELIVERY_STATE.UNKNOWN, Date.now(), DELIVERY_STATE.PENDING);
|
|
192
|
+
const n = Number(r?.changes ?? 0);
|
|
193
|
+
if (n > 0) log(`${n} 条投递记录在崩溃前没结算,已标为未知(不会自动重发)`);
|
|
194
|
+
return n;
|
|
195
|
+
} catch (e) {
|
|
196
|
+
log(`投递账本恢复失败:${e.message}`);
|
|
197
|
+
return 0;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 查一条(面板 / 跨进程桥 3 用) */
|
|
202
|
+
function get(requestId) {
|
|
203
|
+
if (!ready()) return null;
|
|
204
|
+
try {
|
|
205
|
+
return db.prepare('SELECT * FROM deliveries WHERE request_id = ?').get(String(requestId ?? '')) ?? null;
|
|
206
|
+
} catch {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** 最近若干条,面板用 */
|
|
212
|
+
function recent({ limit = 50, state = '' } = {}) {
|
|
213
|
+
if (!ready()) return [];
|
|
214
|
+
try {
|
|
215
|
+
const n = Math.min(200, Math.max(1, Number(limit) || 50));
|
|
216
|
+
return state
|
|
217
|
+
? db.prepare('SELECT * FROM deliveries WHERE state = ? ORDER BY created_at DESC LIMIT ?').all(state, n)
|
|
218
|
+
: db.prepare('SELECT * FROM deliveries ORDER BY created_at DESC LIMIT ?').all(n);
|
|
219
|
+
} catch {
|
|
220
|
+
return [];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** 统计:每种状态多少条。面板卡片用 */
|
|
225
|
+
function stats() {
|
|
226
|
+
if (!ready()) return { available: false };
|
|
227
|
+
try {
|
|
228
|
+
const rows = db.prepare('SELECT state, COUNT(*) AS c FROM deliveries GROUP BY state').all();
|
|
229
|
+
const by = Object.fromEntries(rows.map((r) => [r.state, Number(r.c)]));
|
|
230
|
+
return { available: true, by, total: Object.values(by).reduce((a, b) => a + b, 0) };
|
|
231
|
+
} catch {
|
|
232
|
+
return { available: false };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 审核事件结算(`MESSAGE_AUDIT_PASS` / `MESSAGE_AUDIT_REJECT`)。
|
|
238
|
+
*
|
|
239
|
+
* 官方在审核有结果时推事件,事件里带的是 `msg_id`(不是我们给的 requestId),
|
|
240
|
+
* 所以按 `msg_id` 反查。
|
|
241
|
+
*
|
|
242
|
+
* @param {string} msgId
|
|
243
|
+
* @param {boolean} passed
|
|
244
|
+
* @returns {boolean} 有没有找到并结算
|
|
245
|
+
*/
|
|
246
|
+
function settleByMsgId(msgId, passed) {
|
|
247
|
+
if (!ready()) return false;
|
|
248
|
+
const id = String(msgId ?? '').trim();
|
|
249
|
+
if (!id) return false;
|
|
250
|
+
try {
|
|
251
|
+
const r = db
|
|
252
|
+
.prepare(
|
|
253
|
+
`UPDATE deliveries SET state = ?, code = ?, settled_at = ?
|
|
254
|
+
WHERE msg_id = ? AND state = ?`,
|
|
255
|
+
)
|
|
256
|
+
.run(
|
|
257
|
+
passed ? DELIVERY_STATE.SENT : DELIVERY_STATE.FAILED,
|
|
258
|
+
passed ? null : 'audit-reject',
|
|
259
|
+
Date.now(),
|
|
260
|
+
id,
|
|
261
|
+
DELIVERY_STATE.AUDITED,
|
|
262
|
+
);
|
|
263
|
+
return Number(r?.changes ?? 0) > 0;
|
|
264
|
+
} catch (e) {
|
|
265
|
+
log(`审核结算失败:${e.message}`);
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { claim, settle, settleByMsgId, recoverPending, get, recent, stats };
|
|
271
|
+
}
|