qq-bot-roost 0.8.13 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/package.json +1 -1
- package/src/api.js +18 -5
- package/src/bot.js +231 -1
- package/src/config.js +35 -0
- package/src/gate.js +325 -0
- package/src/ledger.js +513 -0
- package/src/main.js +1389 -1364
- package/src/message-bus.js +1 -1
- package/src/message.js +3 -3
- package/src/outbound.js +65 -15
- package/src/paths.js +20 -0
- package/src/pipeline.js +10 -1
- package/src/plugin/context.js +890 -817
- package/src/plugin/loader.js +938 -929
- package/src/reach.js +364 -0
- package/src/runtime.js +8 -1
- package/src/storage/events-schema.js +71 -0
- package/src/web/qq-call.js +1 -1
- package/src/web/server.js +263 -1
- package/web/app.js +244 -0
- package/web/index.html +107 -0
package/README.md
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
|
|
9
9
|
<img src="https://img.shields.io/badge/Node.js-22.13%2B-339933?logo=node.js&logoColor=white" alt="Node.js 22.13+">
|
|
10
10
|
<img src="https://img.shields.io/badge/dependencies-0-brightgreen" alt="零依赖">
|
|
11
|
-
<img src="https://img.shields.io/badge/tests-
|
|
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/api.js
CHANGED
|
@@ -10,8 +10,11 @@ const log = createLogger('api');
|
|
|
10
10
|
export const FILE_TYPE = { IMAGE: 1, VIDEO: 2, VOICE: 3, FILE: 4 };
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
13
|
+
* 被动回复序号的**最后一道**兜底上限。
|
|
14
|
+
*
|
|
15
|
+
* 官方口径按会话类型不同:群聊 5 次(`msg_seq` 1–5),单聊 4 次(1–4)。
|
|
16
|
+
* 这里只按 `msgId` 记,拿不到会话类型,所以取两者较大的 5 当「绝不可能超过」的硬顶。
|
|
17
|
+
* 精确拦截在 `outbound.js`(`PASSIVE_MAX_REPLIES[kind]`),正常流程走不到这里。
|
|
15
18
|
*/
|
|
16
19
|
const PASSIVE_MAX_SEQ = 5;
|
|
17
20
|
|
|
@@ -164,6 +167,16 @@ export class QQApi {
|
|
|
164
167
|
const err = new Error(`HTTP ${res.status} ${method} ${path}: ${msg}`);
|
|
165
168
|
err.status = res.status;
|
|
166
169
|
err.body = data;
|
|
170
|
+
/**
|
|
171
|
+
* QQ 的业务错误码,和 HTTP 状态码是两回事。
|
|
172
|
+
*
|
|
173
|
+
* 同样是 400,`40054004`(无好友关系)和 `40034006`(内容违规)
|
|
174
|
+
* 该做的事完全不同:前者要更新可达性状态,后者只能改文案。
|
|
175
|
+
* 上层拿不到这个码就只能瞎猜,所以在这里就拆出来。
|
|
176
|
+
*
|
|
177
|
+
* 常见值见 `docs/官方接口限制.md` 与 `src/reach.js`。
|
|
178
|
+
*/
|
|
179
|
+
err.qqCode = Number(data?.code ?? data?.err_code ?? 0) || 0;
|
|
167
180
|
throw err;
|
|
168
181
|
}
|
|
169
182
|
return data;
|
|
@@ -946,14 +959,14 @@ export class QQApi {
|
|
|
946
959
|
}
|
|
947
960
|
|
|
948
961
|
/**
|
|
949
|
-
*
|
|
950
|
-
*
|
|
962
|
+
* 被动回复序号。硬顶见 `PASSIVE_MAX_SEQ`(群聊 5 / 单聊 4,这里取 5 兜底),
|
|
963
|
+
* 超了服务端返回 40034128。正常流程由 outbound.js 按会话类型先拦。
|
|
951
964
|
*/
|
|
952
965
|
#nextSeq(msgId) {
|
|
953
966
|
const cur = this.seqMap.get(msgId) ?? 0;
|
|
954
967
|
if (cur >= PASSIVE_MAX_SEQ) {
|
|
955
968
|
throw new Error(
|
|
956
|
-
`同一条消息最多回复
|
|
969
|
+
`同一条消息最多回复 5 次(群聊)/ 4 次(单聊),这条已经回了 ${cur} 次`,
|
|
957
970
|
);
|
|
958
971
|
}
|
|
959
972
|
const next = cur + 1;
|
package/src/bot.js
CHANGED
|
@@ -16,6 +16,9 @@ import { BotMetrics } from './runtime.js';
|
|
|
16
16
|
import { claimInstance, releaseInstance } from './instance.js';
|
|
17
17
|
import { botPaths, DIRS, rel } from './paths.js';
|
|
18
18
|
import { StatsStore } from './storage/index.js';
|
|
19
|
+
import { createReach } from './reach.js';
|
|
20
|
+
import { createLedger } from './ledger.js';
|
|
21
|
+
import { createGate } from './gate.js';
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* 一个机器人的完整运行时。
|
|
@@ -86,6 +89,25 @@ export class BotRuntime {
|
|
|
86
89
|
*/
|
|
87
90
|
this.groupNames = new Map();
|
|
88
91
|
this.roles = new RoleStore(this.paths.roles);
|
|
92
|
+
/**
|
|
93
|
+
* 可达性:谁能私聊、哪个群能主动推。
|
|
94
|
+
*
|
|
95
|
+
* 官方没有查好友的接口,只能靠 6 个事件维护(FRIEND_ADD/DEL、
|
|
96
|
+
* C2C_MSG_REJECT/RECEIVE、GROUP_MSG_REJECT/RECEIVE)。
|
|
97
|
+
* 插件可以通过 `bot.reach` 直接问,不用自己监听一遍。
|
|
98
|
+
*/
|
|
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
|
+
});
|
|
89
111
|
this.outbound = null;
|
|
90
112
|
this.session = null;
|
|
91
113
|
this.pipeline = null;
|
|
@@ -159,6 +181,7 @@ export class BotRuntime {
|
|
|
159
181
|
this.pipeline = createDefaultPipeline(cfg, {
|
|
160
182
|
isSticky: (chatId) => this.session.isSticky(chatId),
|
|
161
183
|
metrics: this.metrics,
|
|
184
|
+
gate: this.gate,
|
|
162
185
|
});
|
|
163
186
|
|
|
164
187
|
/**
|
|
@@ -171,6 +194,19 @@ export class BotRuntime {
|
|
|
171
194
|
retentionDays: cfg.stats?.retentionDays ?? 90,
|
|
172
195
|
});
|
|
173
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
|
+
|
|
174
210
|
this.plugins = new PluginManager({
|
|
175
211
|
pluginsDir: DIRS.plugins,
|
|
176
212
|
paths: this.paths,
|
|
@@ -183,6 +219,9 @@ export class BotRuntime {
|
|
|
183
219
|
outbound: this.outbound,
|
|
184
220
|
session: this.session,
|
|
185
221
|
pipeline: this.pipeline,
|
|
222
|
+
reach: this.reach,
|
|
223
|
+
ledger: this.ledger,
|
|
224
|
+
gate: this.gate,
|
|
186
225
|
isOwner: (uid) => cfg.owners.includes(uid),
|
|
187
226
|
// 插件声明了 frameworkVersion 就做兼容校验;作者本地调试可以关掉
|
|
188
227
|
ignoreCompat: this.globalCfg.plugins?.ignoreFrameworkVersion === true,
|
|
@@ -194,6 +233,24 @@ export class BotRuntime {
|
|
|
194
233
|
|
|
195
234
|
// 统计库要在插件之前打开:插件上报的运行数据也算统计的一部分
|
|
196
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
|
+
}
|
|
197
254
|
|
|
198
255
|
// 按配置过滤要加载的插件
|
|
199
256
|
await this.plugins.scan({ filter: pluginFilterOf(cfg.plugins) });
|
|
@@ -344,6 +401,51 @@ export class BotRuntime {
|
|
|
344
401
|
return this.groupNames.get(groupOpenid)?.name || undefined;
|
|
345
402
|
}
|
|
346
403
|
|
|
404
|
+
/**
|
|
405
|
+
* 用 6 个事件维护可达性。
|
|
406
|
+
*
|
|
407
|
+
* 官方**没有查好友列表的接口**(sitemap 里 `friend` 只有两个事件页),
|
|
408
|
+
* 所以「接入之前就存在的关系」永远是未知的,只能维护「接入之后的变化」。
|
|
409
|
+
* 三态语义与判定顺序见 `src/reach.js` 头注释。
|
|
410
|
+
*/
|
|
411
|
+
async #noteReach(eventName, d) {
|
|
412
|
+
const openid = d?.openid ?? '';
|
|
413
|
+
switch (eventName) {
|
|
414
|
+
case 'FRIEND_ADD':
|
|
415
|
+
await this.reach.noteFriendAdd({
|
|
416
|
+
openid,
|
|
417
|
+
unionOpenid: d?.author?.union_openid ?? '',
|
|
418
|
+
scene: d?.scene ?? 0,
|
|
419
|
+
});
|
|
420
|
+
break;
|
|
421
|
+
case 'FRIEND_DEL':
|
|
422
|
+
await this.reach.noteFriendDel({ openid });
|
|
423
|
+
break;
|
|
424
|
+
case 'C2C_MSG_REJECT':
|
|
425
|
+
await this.reach.noteC2cPush({ openid, on: false });
|
|
426
|
+
break;
|
|
427
|
+
case 'C2C_MSG_RECEIVE':
|
|
428
|
+
await this.reach.noteC2cPush({ openid, on: true });
|
|
429
|
+
break;
|
|
430
|
+
case 'GROUP_MSG_REJECT':
|
|
431
|
+
await this.reach.noteGroupPush({
|
|
432
|
+
groupOpenid: d?.group_openid,
|
|
433
|
+
on: false,
|
|
434
|
+
by: d?.op_member_openid,
|
|
435
|
+
});
|
|
436
|
+
break;
|
|
437
|
+
case 'GROUP_MSG_RECEIVE':
|
|
438
|
+
await this.reach.noteGroupPush({
|
|
439
|
+
groupOpenid: d?.group_openid,
|
|
440
|
+
on: true,
|
|
441
|
+
by: d?.op_member_openid,
|
|
442
|
+
});
|
|
443
|
+
break;
|
|
444
|
+
default:
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
347
449
|
/**
|
|
348
450
|
* 补一个群的名字(给面板调的公开入口,内部走带缓存的那份)。
|
|
349
451
|
* 不会抛异常,调用方不用 try/catch。
|
|
@@ -415,11 +517,96 @@ export class BotRuntime {
|
|
|
415
517
|
await this.outbound?.flush().catch(() => {});
|
|
416
518
|
await this.groups?.flush().catch(() => {});
|
|
417
519
|
await this.roles?.flush().catch(() => {});
|
|
520
|
+
await this.reach?.flush().catch(() => {});
|
|
521
|
+
await this.gate?.flush().catch(() => {});
|
|
418
522
|
this.stats?.close();
|
|
523
|
+
this.ledger?.close();
|
|
419
524
|
if (!this.mock) await releaseInstance(this.cfg.appId).catch(() => {});
|
|
420
525
|
this.state = 'stopped';
|
|
421
526
|
}
|
|
422
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
|
+
|
|
423
610
|
/* ---------------- 事件分发 ---------------- */
|
|
424
611
|
|
|
425
612
|
/**
|
|
@@ -456,6 +643,12 @@ export class BotRuntime {
|
|
|
456
643
|
await groups.onRemoved(d?.group_openid);
|
|
457
644
|
}
|
|
458
645
|
|
|
646
|
+
// 可达性:官方没有查询接口,只能靠这 6 个事件维护(见 src/reach.js)
|
|
647
|
+
// 放在插件监听器之前,插件读到的就是最新状态
|
|
648
|
+
await this.#noteReach(eventName, d).catch((e) => {
|
|
649
|
+
this.log.warn(`可达性更新失败: ${e.message}`);
|
|
650
|
+
});
|
|
651
|
+
|
|
459
652
|
const meta = EVENTS[eventName] ?? null;
|
|
460
653
|
for (const l of registry.listeners.get(eventName) ?? []) {
|
|
461
654
|
// 事件里的群:有 group_openid 就按群开关过滤,没有(如私聊事件)就放行
|
|
@@ -478,6 +671,42 @@ export class BotRuntime {
|
|
|
478
671
|
|
|
479
672
|
/* ---- 消息事件 ---- */
|
|
480
673
|
const msg = buildMessage(eventName, d, { api: this.api, cfg, outbound, session });
|
|
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
|
+
|
|
703
|
+
// 收到私聊 = 好友关系存在的强证据。不置 c2cPush:他能发不等于他允许机器人主动推
|
|
704
|
+
if (msg.kind === 'c2c' && msg.userId) {
|
|
705
|
+
this.reach
|
|
706
|
+
.noteC2cObserved({ openid: msg.userId, unionOpenid: d?.author?.union_openid })
|
|
707
|
+
.catch(() => {});
|
|
708
|
+
}
|
|
709
|
+
|
|
481
710
|
/**
|
|
482
711
|
* 先播出去,再做业务处理。
|
|
483
712
|
*
|
|
@@ -606,7 +835,8 @@ export class BotRuntime {
|
|
|
606
835
|
await commandEntry.handler(msg, { args: msg.args, raw: msg.content, bot: this });
|
|
607
836
|
} catch (e) {
|
|
608
837
|
this.metrics.bump('errors');
|
|
609
|
-
|
|
838
|
+
// 这几种是发送侧的已知限制,不是插件写错了,只 warn 不报堆栈
|
|
839
|
+
if (['offline', 'expired', 'quota', 'rate'].includes(e?.code)) {
|
|
610
840
|
this.log.warn(`命令 ${commandEntry.name} 回复失败: ${e.message}`);
|
|
611
841
|
return;
|
|
612
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
|
});
|