qq-bot-roost 0.8.14 → 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 +6 -4
- package/package.json +1 -1
- package/src/bot.js +161 -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/outbound.js +31 -4
- package/src/paths.js +18 -0
- package/src/pipeline.js +10 -1
- package/src/plugin/context.js +890 -824
- package/src/plugin/loader.js +938 -932
- package/src/runtime.js +8 -1
- package/src/storage/events-schema.js +71 -0
- package/src/web/server.js +166 -1
- package/web/app.js +73 -0
- package/web/index.html +19 -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.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/
|
|
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 端到端测试(230 项,起真实服务打接口)
|
|
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,8 @@ 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';
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* 一个机器人的完整运行时。
|
|
@@ -95,6 +97,17 @@ export class BotRuntime {
|
|
|
95
97
|
* 插件可以通过 `bot.reach` 直接问,不用自己监听一遍。
|
|
96
98
|
*/
|
|
97
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
|
+
});
|
|
98
111
|
this.outbound = null;
|
|
99
112
|
this.session = null;
|
|
100
113
|
this.pipeline = null;
|
|
@@ -168,6 +181,7 @@ export class BotRuntime {
|
|
|
168
181
|
this.pipeline = createDefaultPipeline(cfg, {
|
|
169
182
|
isSticky: (chatId) => this.session.isSticky(chatId),
|
|
170
183
|
metrics: this.metrics,
|
|
184
|
+
gate: this.gate,
|
|
171
185
|
});
|
|
172
186
|
|
|
173
187
|
/**
|
|
@@ -180,6 +194,19 @@ export class BotRuntime {
|
|
|
180
194
|
retentionDays: cfg.stats?.retentionDays ?? 90,
|
|
181
195
|
});
|
|
182
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
|
+
|
|
183
210
|
this.plugins = new PluginManager({
|
|
184
211
|
pluginsDir: DIRS.plugins,
|
|
185
212
|
paths: this.paths,
|
|
@@ -193,6 +220,8 @@ export class BotRuntime {
|
|
|
193
220
|
session: this.session,
|
|
194
221
|
pipeline: this.pipeline,
|
|
195
222
|
reach: this.reach,
|
|
223
|
+
ledger: this.ledger,
|
|
224
|
+
gate: this.gate,
|
|
196
225
|
isOwner: (uid) => cfg.owners.includes(uid),
|
|
197
226
|
// 插件声明了 frameworkVersion 就做兼容校验;作者本地调试可以关掉
|
|
198
227
|
ignoreCompat: this.globalCfg.plugins?.ignoreFrameworkVersion === true,
|
|
@@ -204,6 +233,24 @@ export class BotRuntime {
|
|
|
204
233
|
|
|
205
234
|
// 统计库要在插件之前打开:插件上报的运行数据也算统计的一部分
|
|
206
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
|
+
}
|
|
207
254
|
|
|
208
255
|
// 按配置过滤要加载的插件
|
|
209
256
|
await this.plugins.scan({ filter: pluginFilterOf(cfg.plugins) });
|
|
@@ -471,11 +518,95 @@ export class BotRuntime {
|
|
|
471
518
|
await this.groups?.flush().catch(() => {});
|
|
472
519
|
await this.roles?.flush().catch(() => {});
|
|
473
520
|
await this.reach?.flush().catch(() => {});
|
|
521
|
+
await this.gate?.flush().catch(() => {});
|
|
474
522
|
this.stats?.close();
|
|
523
|
+
this.ledger?.close();
|
|
475
524
|
if (!this.mock) await releaseInstance(this.cfg.appId).catch(() => {});
|
|
476
525
|
this.state = 'stopped';
|
|
477
526
|
}
|
|
478
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
|
+
|
|
479
610
|
/* ---------------- 事件分发 ---------------- */
|
|
480
611
|
|
|
481
612
|
/**
|
|
@@ -541,6 +672,34 @@ export class BotRuntime {
|
|
|
541
672
|
/* ---- 消息事件 ---- */
|
|
542
673
|
const msg = buildMessage(eventName, d, { api: this.api, cfg, outbound, session });
|
|
543
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
|
+
|
|
544
703
|
// 收到私聊 = 好友关系存在的强证据。不置 c2cPush:他能发不等于他允许机器人主动推
|
|
545
704
|
if (msg.kind === 'c2c' && msg.userId) {
|
|
546
705
|
this.reach
|
|
@@ -676,7 +835,8 @@ export class BotRuntime {
|
|
|
676
835
|
await commandEntry.handler(msg, { args: msg.args, raw: msg.content, bot: this });
|
|
677
836
|
} catch (e) {
|
|
678
837
|
this.metrics.bump('errors');
|
|
679
|
-
|
|
838
|
+
// 这几种是发送侧的已知限制,不是插件写错了,只 warn 不报堆栈
|
|
839
|
+
if (['offline', 'expired', 'quota', 'rate'].includes(e?.code)) {
|
|
680
840
|
this.log.warn(`命令 ${commandEntry.name} 回复失败: ${e.message}`);
|
|
681
841
|
return;
|
|
682
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
|
});
|
package/src/gate.js
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 暂停闸
|
|
3
|
+
* ======
|
|
4
|
+
*
|
|
5
|
+
* 一句话:**任何一只鸟、任何一个会话,都能在不改配置、不重启的前提下闭嘴,
|
|
6
|
+
* 而且面板说得出「为什么不能说」。**
|
|
7
|
+
*
|
|
8
|
+
* 为什么需要它:以前 `grep -rn "paused" src/` 零命中。一只鸟配错了
|
|
9
|
+
* (人格写飞、模型抽风、群选错了),重启之后立刻对几个群开口,**没有刹车**。
|
|
10
|
+
* 唯一能止住的办法是关进程——那连别的鸟一起停了。
|
|
11
|
+
*
|
|
12
|
+
* 三级闸取「与」:任一道关着就不说话。
|
|
13
|
+
*
|
|
14
|
+
* global(进程级)→ bot(每只鸟)→ chat(每个会话)
|
|
15
|
+
* ↘ silence(静默:带 until,到期自动解除)
|
|
16
|
+
*
|
|
17
|
+
* ## 为什么按鸟拆文件
|
|
18
|
+
*
|
|
19
|
+
* 一个进程里可能同时跑好几只鸟,它们共用一个 `gate.json` 的话,
|
|
20
|
+
* `.tmp` + `rename` 只能保证「不会写坏」,**不能保证不丢更新**——
|
|
21
|
+
* 两只鸟同时改,后写的把先写的覆盖掉,表现是「点了暂停没生效」。
|
|
22
|
+
*
|
|
23
|
+
* 所以:`data/gate/global.json` 放全局闸,`data/gate/<botId>.json` 放这只鸟的
|
|
24
|
+
* bot/chat/silence。跨鸟只有全局那一个文件,而且它是低频写,进程内串行化就够。
|
|
25
|
+
*
|
|
26
|
+
* ## 为什么不放在 dsh-roost
|
|
27
|
+
*
|
|
28
|
+
* 闸要拦在 pipeline 里。dsh-roost 停了 bot 还在收消息,闸必须比 dsh-roost 活得久。
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import path from 'node:path';
|
|
32
|
+
import { readJsonSafe, writeJsonAtomic } from './fsutil.js';
|
|
33
|
+
|
|
34
|
+
/** 空状态。每次读盘都从它展开,字段少一个也不会 undefined */
|
|
35
|
+
const EMPTY = () => ({
|
|
36
|
+
paused: false,
|
|
37
|
+
at: 0,
|
|
38
|
+
by: '',
|
|
39
|
+
reason: '',
|
|
40
|
+
chats: {},
|
|
41
|
+
silence: {},
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {object} o
|
|
46
|
+
* @param {string} o.dir 闸文件目录(`data/gate`)
|
|
47
|
+
* @param {string} o.botId 这只鸟的 id
|
|
48
|
+
* @param {(m:string)=>void} [o.log]
|
|
49
|
+
*/
|
|
50
|
+
export function createGate({ dir, botId, log = () => {} }) {
|
|
51
|
+
const globalFile = path.join(dir, 'global.json');
|
|
52
|
+
const botFile = path.join(dir, `${botId}.json`);
|
|
53
|
+
|
|
54
|
+
let global = EMPTY();
|
|
55
|
+
let bot = EMPTY();
|
|
56
|
+
let opened = false;
|
|
57
|
+
/** 第一次读盘时文件存不存在,`pauseOnStart` 要用 */
|
|
58
|
+
let botExisted = false;
|
|
59
|
+
let globalExisted = false;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 写盘串行化。
|
|
63
|
+
*
|
|
64
|
+
* 同一只鸟的两个会话几乎不会同时改闸,但面板连点、插件批量改群闸
|
|
65
|
+
* 是可能并发的。串行化保证「读-改-写」不会互相覆盖。
|
|
66
|
+
*/
|
|
67
|
+
let chain = Promise.resolve();
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 读盘。
|
|
71
|
+
*
|
|
72
|
+
* @returns {Promise<{botExisted:boolean, globalExisted:boolean}>}
|
|
73
|
+
* `botExisted=false` 表示这只鸟从来没写过闸文件(首次运行),
|
|
74
|
+
* `gate.pauseOnStart` 靠它判断「要不要自动暂停」——
|
|
75
|
+
* 每次启动都暂停会让日常重启很难受,用户第一反应是「坏了」。
|
|
76
|
+
*/
|
|
77
|
+
async function open() {
|
|
78
|
+
if (opened) return { botExisted, globalExisted };
|
|
79
|
+
const [g, b] = await Promise.all([
|
|
80
|
+
readJsonSafe(globalFile, (m) => log(`全局闸:${m}`)),
|
|
81
|
+
readJsonSafe(botFile, (m) => log(`闸状态:${m}`)),
|
|
82
|
+
]);
|
|
83
|
+
botExisted = b !== null;
|
|
84
|
+
globalExisted = g !== null;
|
|
85
|
+
global = { ...EMPTY(), ...(g ?? {}) };
|
|
86
|
+
bot = { ...EMPTY(), ...(b ?? {}) };
|
|
87
|
+
opened = true;
|
|
88
|
+
return { botExisted, globalExisted };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function saveGlobal() {
|
|
92
|
+
const snap = { ...global };
|
|
93
|
+
chain = chain.then(() => writeJsonAtomic(globalFile, snap)).catch((e) => {
|
|
94
|
+
log(`全局闸写盘失败:${e.message}`);
|
|
95
|
+
});
|
|
96
|
+
return chain;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function saveBot() {
|
|
100
|
+
const snap = { ...bot };
|
|
101
|
+
chain = chain.then(() => writeJsonAtomic(botFile, snap)).catch((e) => {
|
|
102
|
+
log(`闸状态写盘失败:${e.message}`);
|
|
103
|
+
});
|
|
104
|
+
return chain;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 把 meta 归一成落盘字段,`by` 用来说明是谁改的(面板 / 插件 id / 命令行) */
|
|
108
|
+
const metaOf = (m = {}) => ({
|
|
109
|
+
at: Date.now(),
|
|
110
|
+
by: String(m.by ?? '').slice(0, 40),
|
|
111
|
+
reason: String(m.reason ?? '').slice(0, 200),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 这只鸟现在能不能对 `chatId` 说话。
|
|
116
|
+
*
|
|
117
|
+
* **只回答闸的部分**。网关在线、额度、插件启停由 `bot.canSpeak()` 汇总——
|
|
118
|
+
* 那些状态不在闸的职责里,混进来会让这个模块变成「什么都知道一点」。
|
|
119
|
+
*
|
|
120
|
+
* @param {string} chatId
|
|
121
|
+
* @param {number} [now]
|
|
122
|
+
* @returns {{ok:boolean, reasons:{code:string,text:string,until?:number}[]}}
|
|
123
|
+
*/
|
|
124
|
+
function check(chatId, now = Date.now()) {
|
|
125
|
+
const reasons = [];
|
|
126
|
+
|
|
127
|
+
if (global.paused) {
|
|
128
|
+
reasons.push({
|
|
129
|
+
code: 'gate:global',
|
|
130
|
+
text: `全局暂停${global.reason ? `(${global.reason})` : ''}`,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (bot.paused) {
|
|
134
|
+
reasons.push({
|
|
135
|
+
code: 'gate:bot',
|
|
136
|
+
text: `这只鸟暂停${bot.reason ? `(${bot.reason})` : ''}`,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
if (chatId) {
|
|
140
|
+
const c = bot.chats?.[chatId];
|
|
141
|
+
if (c?.paused) {
|
|
142
|
+
reasons.push({
|
|
143
|
+
code: 'gate:chat',
|
|
144
|
+
text: `这个会话暂停${c.reason ? `(${c.reason})` : ''}`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
const until = Number(bot.silence?.[chatId] ?? 0);
|
|
148
|
+
if (until > now) {
|
|
149
|
+
const mins = Math.ceil((until - now) / 60_000);
|
|
150
|
+
reasons.push({
|
|
151
|
+
code: 'gate:silence',
|
|
152
|
+
text: `静默中,还剩 ${mins} 分钟`,
|
|
153
|
+
until,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return { ok: reasons.length === 0, reasons };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* ---------------- 改闸 ---------------- */
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* 改全局闸。
|
|
165
|
+
*
|
|
166
|
+
* @param {boolean} paused
|
|
167
|
+
* @param {{by?:string, reason?:string}} [meta]
|
|
168
|
+
* @param {{persist?:boolean}} [opts]
|
|
169
|
+
* `persist: false` 只改内存不写盘 —— 给 `--paused` 启动参数用。
|
|
170
|
+
* 那是个**一次性的启动意图**(「这次先别说话」),不是「永久暂停」;
|
|
171
|
+
* 写进文件的话,下次不带参数启动却发现还在暂停,人会以为坏了。
|
|
172
|
+
*/
|
|
173
|
+
async function setGlobal(paused, meta, { persist = true } = {}) {
|
|
174
|
+
await open();
|
|
175
|
+
global = { ...global, paused: Boolean(paused), ...metaOf(meta) };
|
|
176
|
+
if (persist) await saveGlobal();
|
|
177
|
+
return { ...global };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function setBot(paused, meta) {
|
|
181
|
+
await open();
|
|
182
|
+
bot = { ...bot, paused: Boolean(paused), ...metaOf(meta) };
|
|
183
|
+
await saveBot();
|
|
184
|
+
return { ...bot };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function setChat(chatId, paused, meta) {
|
|
188
|
+
await open();
|
|
189
|
+
if (!chatId) throw new Error('缺 chatId');
|
|
190
|
+
const chats = { ...(bot.chats ?? {}) };
|
|
191
|
+
if (paused) chats[chatId] = { paused: true, ...metaOf(meta) };
|
|
192
|
+
else delete chats[chatId];
|
|
193
|
+
bot = { ...bot, chats };
|
|
194
|
+
await saveBot();
|
|
195
|
+
return { chatId, paused: Boolean(paused) };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* 静默一段时间。
|
|
200
|
+
*
|
|
201
|
+
* `until` 存绝对时间戳而不是「剩余毫秒」:进程重启后剩余时间还得重新算,
|
|
202
|
+
* 而「到几点解除」是事实,重启也成立。
|
|
203
|
+
*
|
|
204
|
+
* @param {string} chatId
|
|
205
|
+
* @param {number} ms 静默多久;`<= 0` 表示解除
|
|
206
|
+
*/
|
|
207
|
+
async function setSilence(chatId, ms, meta) {
|
|
208
|
+
await open();
|
|
209
|
+
if (!chatId) throw new Error('缺 chatId');
|
|
210
|
+
const silence = { ...(bot.silence ?? {}) };
|
|
211
|
+
if (ms > 0) silence[chatId] = Date.now() + ms;
|
|
212
|
+
else delete silence[chatId];
|
|
213
|
+
bot = { ...bot, silence };
|
|
214
|
+
await saveBot();
|
|
215
|
+
return { chatId, until: silence[chatId] ?? 0, ...metaOf(meta) };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* 重读全局闸。
|
|
220
|
+
*
|
|
221
|
+
* 每只鸟各有一个 gate 实例,各自缓存着一份 `global.json` 的内容。
|
|
222
|
+
* 面板改全局闸时只写了一个文件,其他鸟内存里还是旧值——不加这一步,
|
|
223
|
+
* 「全局暂停」要等重启才对其他鸟生效,等于没做。
|
|
224
|
+
*
|
|
225
|
+
* 只重读全局那一个文件:bot 文件是本实例独有的,重读会把排队中的
|
|
226
|
+
* 改动冲掉。
|
|
227
|
+
*/
|
|
228
|
+
async function reloadGlobal() {
|
|
229
|
+
const g = await readJsonSafe(globalFile, (m) => log(`全局闸:${m}`));
|
|
230
|
+
global = { ...EMPTY(), ...(g ?? {}) };
|
|
231
|
+
return { ...global };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 清掉所有已到期的静默。
|
|
236
|
+
*
|
|
237
|
+
* `check()` 里已经按时间判断过了,这里只是别让 `silence` 表无限长——
|
|
238
|
+
* 每静默一次留一条,挂机半年就是几百条无用记录。
|
|
239
|
+
*/
|
|
240
|
+
async function prune(now = Date.now()) {
|
|
241
|
+
await open();
|
|
242
|
+
const silence = bot.silence ?? {};
|
|
243
|
+
const alive = Object.entries(silence).filter(([, until]) => Number(until) > now);
|
|
244
|
+
if (alive.length === Object.keys(silence).length) return 0;
|
|
245
|
+
bot = { ...bot, silence: Object.fromEntries(alive) };
|
|
246
|
+
await saveBot();
|
|
247
|
+
return Object.keys(silence).length - alive.length;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 同步快照(不读盘)。
|
|
252
|
+
*
|
|
253
|
+
* `snapshot()` 要 await,而 `/api/status` 是个同步函数、每 2 秒被调一次。
|
|
254
|
+
* 闸在 `init()` 里已经 open 过,内存里就是最新的,没必要每次再读盘。
|
|
255
|
+
*/
|
|
256
|
+
function peek() {
|
|
257
|
+
return {
|
|
258
|
+
global: { paused: global.paused, at: global.at, by: global.by, reason: global.reason },
|
|
259
|
+
bot: { paused: bot.paused, at: bot.at, by: bot.by, reason: bot.reason },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** 面板用的完整快照(含每会话的闸,方便一次画完) */
|
|
264
|
+
async function snapshot(now = Date.now()) {
|
|
265
|
+
await open();
|
|
266
|
+
return {
|
|
267
|
+
global: { paused: global.paused, at: global.at, by: global.by, reason: global.reason },
|
|
268
|
+
bot: { paused: bot.paused, at: bot.at, by: bot.by, reason: bot.reason },
|
|
269
|
+
chats: Object.entries(bot.chats ?? {}).map(([chatId, v]) => ({
|
|
270
|
+
chatId,
|
|
271
|
+
paused: true,
|
|
272
|
+
at: v.at ?? 0,
|
|
273
|
+
by: v.by ?? '',
|
|
274
|
+
reason: v.reason ?? '',
|
|
275
|
+
})),
|
|
276
|
+
silence: Object.entries(bot.silence ?? {})
|
|
277
|
+
.filter(([, until]) => Number(until) > now)
|
|
278
|
+
.map(([chatId, until]) => ({ chatId, until })),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** 退出前把排队的写盘任务等完 */
|
|
283
|
+
async function flush() {
|
|
284
|
+
await chain.catch(() => {});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return {
|
|
288
|
+
open,
|
|
289
|
+
check,
|
|
290
|
+
peek,
|
|
291
|
+
snapshot,
|
|
292
|
+
reloadGlobal,
|
|
293
|
+
setGlobal,
|
|
294
|
+
setBot,
|
|
295
|
+
setChat,
|
|
296
|
+
setSilence,
|
|
297
|
+
prune,
|
|
298
|
+
flush,
|
|
299
|
+
get dir() {
|
|
300
|
+
return dir;
|
|
301
|
+
},
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 管道中间件:闸关着就不往下走。
|
|
307
|
+
*
|
|
308
|
+
* **必须排在账本之后**。`bot.js` 里 `ledger.append()` 在 `pipeline.run()` 之前,
|
|
309
|
+
* 所以暂停期间的消息照样入账——恢复之后 AI 能看见「你不在的时候群里聊了什么」。
|
|
310
|
+
* 顺序反过来的话,暂停就等于丢消息,而官方没有历史消息接口,丢了补不回来。
|
|
311
|
+
*
|
|
312
|
+
* 拦下时**不回任何话**:静默才是目的,回一句「我暂停了」等于没闭嘴。
|
|
313
|
+
*/
|
|
314
|
+
export function gateMiddleware(gate, { metrics = null } = {}) {
|
|
315
|
+
return async (ctx, next) => {
|
|
316
|
+
const chatId = ctx.msg?.chatId ?? '';
|
|
317
|
+
const v = gate.check(chatId);
|
|
318
|
+
if (!v.ok) {
|
|
319
|
+
metrics?.bump('gated');
|
|
320
|
+
ctx.halt('gate');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
return next();
|
|
324
|
+
};
|
|
325
|
+
}
|