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/README.md +2 -2
- package/package.json +57 -57
- package/src/bot.js +10 -1
- package/src/config.js +741 -730
- package/src/ledger.js +905 -554
- package/src/message.js +998 -900
- package/src/outbound.js +791 -690
- package/src/plugin/api-record.js +129 -0
- package/src/plugin/context.js +990 -976
- package/src/storage/events-schema.js +124 -99
- package/src/web/server.js +3494 -3280
- package/web/app.js +4372 -4053
- package/web/index.html +1203 -1116
- package/web/style.css +44 -0
package/src/ledger.js
CHANGED
|
@@ -1,554 +1,905 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 事件账本:把「发生过什么」原样记下来
|
|
3
|
-
* ====================================
|
|
4
|
-
*
|
|
5
|
-
* ── 为什么需要它 ──
|
|
6
|
-
*
|
|
7
|
-
* 官方**没有任何历史消息接口**(`src/endpoints.js` 的 17 个 GET 里没有)。
|
|
8
|
-
* 一旦这条消息没被记下来,它就永久消失了。而在此之前我们只存了
|
|
9
|
-
* 「谁在哪天发了多少条」(`stats.db`),正文从来没落过盘——
|
|
10
|
-
* 于是所有「记忆」的原料都是模型当时抽的二手摘要,上限被原料决定。
|
|
11
|
-
*
|
|
12
|
-
* 这份账本是**唯一的历史来源**。
|
|
13
|
-
*
|
|
14
|
-
* ── 三个不能省的列 ──
|
|
15
|
-
*
|
|
16
|
-
* `time_basis`:官方给的 `timestamp` 有时缺、有时不可信(补录、迁移)。
|
|
17
|
-
* 三态 `message` / `observation` / `unknown`。
|
|
18
|
-
* **`unknown` 的事件不参与时间邻文**——拿不可靠的时间拼上下文,
|
|
19
|
-
* 拼出来的是「看着合理但是错的」对话,比没有更糟。
|
|
20
|
-
*
|
|
21
|
-
* `dedupe`:官方原文「为确保消息可达,**相同 msg_id 可能重复推送**」。
|
|
22
|
-
* 所以去重不能靠「我记得刚见过」,得有唯一约束兜底。
|
|
23
|
-
*
|
|
24
|
-
* `msg_idx` / `ref_msg_idx`:引用边的唯一来源。以前以为解不出,其实
|
|
25
|
-
* 非机器人消息从 `message_scene.ext.msg_idx` 取、自己发的从发送响应
|
|
26
|
-
* `ext_info.ref_idx` 取。
|
|
27
|
-
*
|
|
28
|
-
* ── 保留策略:7 天滚动删 ──
|
|
29
|
-
*
|
|
30
|
-
* 全量存正文是隐私问题,所以不留永久。**证据由 B 侧(journal)自己留快照**,
|
|
31
|
-
* 账本可以无脑滚动删——它不需要知道谁引用了它。
|
|
32
|
-
* (原设计想让「被引用的永久保留」,但 A 侧根本看不到 B 侧的引用,
|
|
33
|
-
* 那条路走不通。)
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
|
-
import crypto from 'node:crypto';
|
|
37
|
-
import fs from 'node:fs';
|
|
38
|
-
import path from 'node:path';
|
|
39
|
-
import { createLogger } from './logger.js';
|
|
40
|
-
import { probeSqlite, openDatabase } from './storage/db.js';
|
|
41
|
-
import { migrateEvents } from './storage/events-schema.js';
|
|
42
|
-
import { SENSITIVE_KEY } from './runtime.js';
|
|
43
|
-
|
|
44
|
-
const log = createLogger('ledger');
|
|
45
|
-
|
|
46
|
-
/** 每次滚动删最多删多少行。分批是为了不长时间锁库,收消息会卡 */
|
|
47
|
-
const PRUNE_BATCH = 5000;
|
|
48
|
-
|
|
49
|
-
/** 自动清理间隔 */
|
|
50
|
-
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* 报文脱敏:只删凭证,不截断。
|
|
54
|
-
*
|
|
55
|
-
* 和 `runtime.js` 的 `sanitizePayload` 的区别就在这:
|
|
56
|
-
* 那个是给日志/接口看的,整个 `ext` 都扔、字符串截到 400 字;
|
|
57
|
-
* 账本要留着 `msg_idx`,也不能把长消息截断——截了就不是原文了。
|
|
58
|
-
*
|
|
59
|
-
* @param {unknown} value
|
|
60
|
-
* @param {number} [depth]
|
|
61
|
-
*/
|
|
62
|
-
function cleanPayload(value, depth = 0) {
|
|
63
|
-
// 深度上限防环形引用和畸形报文,正常 QQ 事件撑死 6 层
|
|
64
|
-
if (depth > 12) return null;
|
|
65
|
-
if (Array.isArray(value)) return value.map((v) => cleanPayload(v, depth + 1));
|
|
66
|
-
if (value && typeof value === 'object') {
|
|
67
|
-
const out = {};
|
|
68
|
-
for (const [k, v] of Object.entries(value)) {
|
|
69
|
-
out[k] = SENSITIVE_KEY.test(k) ? '***' : cleanPayload(v, depth + 1);
|
|
70
|
-
}
|
|
71
|
-
return out;
|
|
72
|
-
}
|
|
73
|
-
return value;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* 单条 payload 上限。合并转发的聊天记录能有几十 KB,正常远到不了这里;
|
|
78
|
-
* 设上限是为了防畸形/超大报文把库撑爆(一条几 MB 的行会拖慢所有查询)。
|
|
79
|
-
* 超了截断并留标记——`content` 是单独一列,正文不受影响。
|
|
80
|
-
*/
|
|
81
|
-
const MAX_PAYLOAD_BYTES = 256 * 1024;
|
|
82
|
-
|
|
83
|
-
/**
|
|
84
|
-
* 时间戳归一成毫秒。
|
|
85
|
-
*
|
|
86
|
-
* QQ 的 `timestamp` 有的场景是**秒**、有的是 ISO 字符串、有的干脆没有。
|
|
87
|
-
* 返回 `{ at, basis }`:拿不准就退回本地收到时间,并标记 `observation`。
|
|
88
|
-
*/
|
|
89
|
-
export function resolveTime(raw, receivedAt = Date.now()) {
|
|
90
|
-
const t = raw?.timestamp;
|
|
91
|
-
if (t === undefined || t === null || t === '') return { at: receivedAt, basis: 'observation' };
|
|
92
|
-
|
|
93
|
-
if (typeof t === 'number' || /^\d+$/.test(String(t))) {
|
|
94
|
-
const n = Number(t);
|
|
95
|
-
if (!Number.isFinite(n) || n <= 0) return { at: receivedAt, basis: 'observation' };
|
|
96
|
-
// 10 位是秒,13 位是毫秒。1e11 ≈ 1973 年(毫秒),所以小于它只能是秒
|
|
97
|
-
const ms = n < 1e11 ? n * 1000 : n;
|
|
98
|
-
// 离谱的未来时间当不可信(时钟错乱、字段被塞了别的东西)
|
|
99
|
-
if (ms > Date.now() + 24 * 3600 * 1000) return { at: receivedAt, basis: 'observation' };
|
|
100
|
-
return { at: ms, basis: 'message' };
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
const parsed = Date.parse(String(t));
|
|
104
|
-
if (Number.isFinite(parsed) && parsed > 0 && parsed <= Date.now() + 24 * 3600 * 1000) {
|
|
105
|
-
return { at: parsed, basis: 'message' };
|
|
106
|
-
}
|
|
107
|
-
return { at: receivedAt, basis: 'observation' };
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/** 内容指纹:没有 msg_id 时用它兜底去重 */
|
|
111
|
-
function digestOf(msg) {
|
|
112
|
-
const s = `${msg.chatId ?? ''}\u0000${msg.userId ?? ''}\u0000${msg.content ?? ''}`;
|
|
113
|
-
return crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
export class EventLedger {
|
|
117
|
-
/**
|
|
118
|
-
* @param {object} p
|
|
119
|
-
* @param {string} p.file events.db 路径
|
|
120
|
-
* @param {string} p.botId
|
|
121
|
-
* @param {boolean} [p.enabled]
|
|
122
|
-
* @param {number} [p.retentionDays]
|
|
123
|
-
*/
|
|
124
|
-
constructor({ file, botId = '', enabled = true, retentionDays =
|
|
125
|
-
this.file = file;
|
|
126
|
-
this.botId = botId;
|
|
127
|
-
this.enabled = enabled !== false;
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
this.
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
this.
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
String(msg.
|
|
246
|
-
String(msg.
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
String(msg.
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
return 0;
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
)
|
|
372
|
-
|
|
373
|
-
if (
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
:
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
const
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (this.
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 事件账本:把「发生过什么」原样记下来
|
|
3
|
+
* ====================================
|
|
4
|
+
*
|
|
5
|
+
* ── 为什么需要它 ──
|
|
6
|
+
*
|
|
7
|
+
* 官方**没有任何历史消息接口**(`src/endpoints.js` 的 17 个 GET 里没有)。
|
|
8
|
+
* 一旦这条消息没被记下来,它就永久消失了。而在此之前我们只存了
|
|
9
|
+
* 「谁在哪天发了多少条」(`stats.db`),正文从来没落过盘——
|
|
10
|
+
* 于是所有「记忆」的原料都是模型当时抽的二手摘要,上限被原料决定。
|
|
11
|
+
*
|
|
12
|
+
* 这份账本是**唯一的历史来源**。
|
|
13
|
+
*
|
|
14
|
+
* ── 三个不能省的列 ──
|
|
15
|
+
*
|
|
16
|
+
* `time_basis`:官方给的 `timestamp` 有时缺、有时不可信(补录、迁移)。
|
|
17
|
+
* 三态 `message` / `observation` / `unknown`。
|
|
18
|
+
* **`unknown` 的事件不参与时间邻文**——拿不可靠的时间拼上下文,
|
|
19
|
+
* 拼出来的是「看着合理但是错的」对话,比没有更糟。
|
|
20
|
+
*
|
|
21
|
+
* `dedupe`:官方原文「为确保消息可达,**相同 msg_id 可能重复推送**」。
|
|
22
|
+
* 所以去重不能靠「我记得刚见过」,得有唯一约束兜底。
|
|
23
|
+
*
|
|
24
|
+
* `msg_idx` / `ref_msg_idx`:引用边的唯一来源。以前以为解不出,其实
|
|
25
|
+
* 非机器人消息从 `message_scene.ext.msg_idx` 取、自己发的从发送响应
|
|
26
|
+
* `ext_info.ref_idx` 取。
|
|
27
|
+
*
|
|
28
|
+
* ── 保留策略:7 天滚动删 ──
|
|
29
|
+
*
|
|
30
|
+
* 全量存正文是隐私问题,所以不留永久。**证据由 B 侧(journal)自己留快照**,
|
|
31
|
+
* 账本可以无脑滚动删——它不需要知道谁引用了它。
|
|
32
|
+
* (原设计想让「被引用的永久保留」,但 A 侧根本看不到 B 侧的引用,
|
|
33
|
+
* 那条路走不通。)
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import crypto from 'node:crypto';
|
|
37
|
+
import fs from 'node:fs';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
import { createLogger } from './logger.js';
|
|
40
|
+
import { probeSqlite, openDatabase } from './storage/db.js';
|
|
41
|
+
import { migrateEvents } from './storage/events-schema.js';
|
|
42
|
+
import { SENSITIVE_KEY } from './runtime.js';
|
|
43
|
+
|
|
44
|
+
const log = createLogger('ledger');
|
|
45
|
+
|
|
46
|
+
/** 每次滚动删最多删多少行。分批是为了不长时间锁库,收消息会卡 */
|
|
47
|
+
const PRUNE_BATCH = 5000;
|
|
48
|
+
|
|
49
|
+
/** 自动清理间隔 */
|
|
50
|
+
const PRUNE_INTERVAL_MS = 60 * 60 * 1000;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 报文脱敏:只删凭证,不截断。
|
|
54
|
+
*
|
|
55
|
+
* 和 `runtime.js` 的 `sanitizePayload` 的区别就在这:
|
|
56
|
+
* 那个是给日志/接口看的,整个 `ext` 都扔、字符串截到 400 字;
|
|
57
|
+
* 账本要留着 `msg_idx`,也不能把长消息截断——截了就不是原文了。
|
|
58
|
+
*
|
|
59
|
+
* @param {unknown} value
|
|
60
|
+
* @param {number} [depth]
|
|
61
|
+
*/
|
|
62
|
+
function cleanPayload(value, depth = 0) {
|
|
63
|
+
// 深度上限防环形引用和畸形报文,正常 QQ 事件撑死 6 层
|
|
64
|
+
if (depth > 12) return null;
|
|
65
|
+
if (Array.isArray(value)) return value.map((v) => cleanPayload(v, depth + 1));
|
|
66
|
+
if (value && typeof value === 'object') {
|
|
67
|
+
const out = {};
|
|
68
|
+
for (const [k, v] of Object.entries(value)) {
|
|
69
|
+
out[k] = SENSITIVE_KEY.test(k) ? '***' : cleanPayload(v, depth + 1);
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 单条 payload 上限。合并转发的聊天记录能有几十 KB,正常远到不了这里;
|
|
78
|
+
* 设上限是为了防畸形/超大报文把库撑爆(一条几 MB 的行会拖慢所有查询)。
|
|
79
|
+
* 超了截断并留标记——`content` 是单独一列,正文不受影响。
|
|
80
|
+
*/
|
|
81
|
+
const MAX_PAYLOAD_BYTES = 256 * 1024;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 时间戳归一成毫秒。
|
|
85
|
+
*
|
|
86
|
+
* QQ 的 `timestamp` 有的场景是**秒**、有的是 ISO 字符串、有的干脆没有。
|
|
87
|
+
* 返回 `{ at, basis }`:拿不准就退回本地收到时间,并标记 `observation`。
|
|
88
|
+
*/
|
|
89
|
+
export function resolveTime(raw, receivedAt = Date.now()) {
|
|
90
|
+
const t = raw?.timestamp;
|
|
91
|
+
if (t === undefined || t === null || t === '') return { at: receivedAt, basis: 'observation' };
|
|
92
|
+
|
|
93
|
+
if (typeof t === 'number' || /^\d+$/.test(String(t))) {
|
|
94
|
+
const n = Number(t);
|
|
95
|
+
if (!Number.isFinite(n) || n <= 0) return { at: receivedAt, basis: 'observation' };
|
|
96
|
+
// 10 位是秒,13 位是毫秒。1e11 ≈ 1973 年(毫秒),所以小于它只能是秒
|
|
97
|
+
const ms = n < 1e11 ? n * 1000 : n;
|
|
98
|
+
// 离谱的未来时间当不可信(时钟错乱、字段被塞了别的东西)
|
|
99
|
+
if (ms > Date.now() + 24 * 3600 * 1000) return { at: receivedAt, basis: 'observation' };
|
|
100
|
+
return { at: ms, basis: 'message' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const parsed = Date.parse(String(t));
|
|
104
|
+
if (Number.isFinite(parsed) && parsed > 0 && parsed <= Date.now() + 24 * 3600 * 1000) {
|
|
105
|
+
return { at: parsed, basis: 'message' };
|
|
106
|
+
}
|
|
107
|
+
return { at: receivedAt, basis: 'observation' };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 内容指纹:没有 msg_id 时用它兜底去重 */
|
|
111
|
+
function digestOf(msg) {
|
|
112
|
+
const s = `${msg.chatId ?? ''}\u0000${msg.userId ?? ''}\u0000${msg.content ?? ''}`;
|
|
113
|
+
return crypto.createHash('sha1').update(s).digest('hex').slice(0, 16);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class EventLedger {
|
|
117
|
+
/**
|
|
118
|
+
* @param {object} p
|
|
119
|
+
* @param {string} p.file events.db 路径
|
|
120
|
+
* @param {string} p.botId
|
|
121
|
+
* @param {boolean} [p.enabled]
|
|
122
|
+
* @param {number} [p.retentionDays] 保留天数。**0 = 永久不删**;缺省按 0 处理
|
|
123
|
+
*/
|
|
124
|
+
constructor({ file, botId = '', enabled = true, retentionDays = 0 }) {
|
|
125
|
+
this.file = file;
|
|
126
|
+
this.botId = botId;
|
|
127
|
+
this.enabled = enabled !== false;
|
|
128
|
+
/**
|
|
129
|
+
* `0` 是**合法值**(永久),不能像以前那样 `|| 7` 把它吃掉。
|
|
130
|
+
*
|
|
131
|
+
* 这里必须和 `config.js` 的默认值、`bot.js` 的 `?? 0` 三处一致。
|
|
132
|
+
* 任何一处留 `7`,表现都是「面板显示永久、实际按 7 天删」——不报错、查不出来。
|
|
133
|
+
*/
|
|
134
|
+
const n = Number(retentionDays);
|
|
135
|
+
this.retentionDays = n === 0 ? 0 : Math.max(1, Number.isFinite(n) ? n : 0);
|
|
136
|
+
|
|
137
|
+
this.db = null;
|
|
138
|
+
this.available = false;
|
|
139
|
+
this.closed = false;
|
|
140
|
+
this.pruneTimer = null;
|
|
141
|
+
/** 只在第一次出错时提醒,别刷屏 */
|
|
142
|
+
this.warned = false;
|
|
143
|
+
/** 待压缩标记(面板点「压缩」后置位,定时清理时消费) */
|
|
144
|
+
this.needVacuum = false;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** 打开库并建表。失败只警告——账本是增强功能,不能因此让机器人起不来 */
|
|
148
|
+
open() {
|
|
149
|
+
if (!this.enabled) {
|
|
150
|
+
log.info('事件账本已关闭(ledger.enabled = false)');
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
if (!probeSqlite()) {
|
|
154
|
+
log.warn('当前 Node 不支持 node:sqlite,事件账本不可用(其它功能正常)');
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
this.db = openDatabase(this.file);
|
|
159
|
+
migrateEvents(this.db);
|
|
160
|
+
this.available = true;
|
|
161
|
+
/** 上次进程退出前点过「压缩」但没跑到,这里接上 */
|
|
162
|
+
this.needVacuum = fs.existsSync(this.vacuumMark());
|
|
163
|
+
} catch (e) {
|
|
164
|
+
log.warn(`事件账本打不开(${this.file}):${e.message}`);
|
|
165
|
+
this.db = null;
|
|
166
|
+
this.available = false;
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// 启动先清一次:进程可能停了很久,攒下的一堆过期数据要立刻走
|
|
171
|
+
this.prune().catch((e) => log.warn('账本清理失败:', e.message));
|
|
172
|
+
this.pruneTimer = setInterval(() => {
|
|
173
|
+
this.prune().catch((e) => log.warn('账本清理失败:', e.message));
|
|
174
|
+
}, PRUNE_INTERVAL_MS);
|
|
175
|
+
this.pruneTimer.unref?.();
|
|
176
|
+
return true;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 记一条消息。
|
|
181
|
+
*
|
|
182
|
+
* **必须在 pipeline 之前调**:被去重 / 白名单 / 限流 / 暂停拦下的消息
|
|
183
|
+
* 也是「发生过的事实」,账本要记的是事实,不是处理结果。
|
|
184
|
+
*
|
|
185
|
+
* @param {object} msg `buildMessage()` 的产物
|
|
186
|
+
* @param {object} [opts]
|
|
187
|
+
* @param {'message'|'observation'|'unknown'} [opts.timeBasis]
|
|
188
|
+
* 强制指定时间基准。**只有导入 / 补录 / 迁移才该用**——
|
|
189
|
+
* 正常收消息时自动判(`resolveTime`),判不准会退回 `observation`,
|
|
190
|
+
* 而 `unknown` 是「连本地收到时间都不可信」(批量导入一批几年前的聊天记录,
|
|
191
|
+
* 这时候的 `Date.now()` 是导入时间,不是说话时间)。
|
|
192
|
+
* `unknown` 的事件**不参与时间邻文**,这是故意的。
|
|
193
|
+
* @returns {{seq:number, id:string}|null} 重复消息返回 null
|
|
194
|
+
*/
|
|
195
|
+
append(msg, { timeBasis } = {}) {
|
|
196
|
+
if (!this.available || this.closed || !msg) return null;
|
|
197
|
+
|
|
198
|
+
const receivedAt = Date.now();
|
|
199
|
+
const resolved = resolveTime(msg.raw, receivedAt);
|
|
200
|
+
const basis = ['message', 'observation', 'unknown'].includes(timeBasis) ? timeBasis : resolved.basis;
|
|
201
|
+
/**
|
|
202
|
+
* 显式指定基准时 `at` 用本地收到时间。
|
|
203
|
+
*
|
|
204
|
+
* 这对 `unknown` 有个副作用,但方向是对的:导入的三年老聊天记录 `at` 是导入时间,
|
|
205
|
+
* 于是它会跟着往后留满一个保留期,而不是进来就被滚动删掉。
|
|
206
|
+
* 宁可多留一周让人处理,也不能让「刚导进来就没了」。
|
|
207
|
+
*/
|
|
208
|
+
const at = basis === resolved.basis ? resolved.at : receivedAt;
|
|
209
|
+
const msgId = String(msg.id ?? '').trim();
|
|
210
|
+
// 有 msg_id 就用它(官方重推同一条时 msg_id 相同);
|
|
211
|
+
// 没有就退化成「会话+人+正文」的指纹,同一个人在同一会话里重复发同一句话
|
|
212
|
+
// 会被误判成重复——这是已知代价,但比丢消息好
|
|
213
|
+
const dedupe = msgId
|
|
214
|
+
? `${this.botId}:${msgId}`
|
|
215
|
+
: `${this.botId}:${msg.chatId ?? ''}:${at}:${digestOf(msg)}`;
|
|
216
|
+
|
|
217
|
+
const id = crypto.randomUUID();
|
|
218
|
+
const payload = (() => {
|
|
219
|
+
try {
|
|
220
|
+
let s = JSON.stringify(cleanPayload(msg.raw));
|
|
221
|
+
if (Buffer.byteLength(s, 'utf8') > MAX_PAYLOAD_BYTES) {
|
|
222
|
+
// 截断会切坏 UTF-8 序列,JSON.parse 时才发现就晚了。
|
|
223
|
+
// 干脆换成一条合法的标记对象,读的时候拿到 {} 语义(contentOf 返回空串),
|
|
224
|
+
// 正文从 `quoted` / 消息本身还能还原,不会静默给出半截乱码。
|
|
225
|
+
s = JSON.stringify({ _truncated: true, _bytes: Buffer.byteLength(s, 'utf8') });
|
|
226
|
+
}
|
|
227
|
+
return s;
|
|
228
|
+
} catch {
|
|
229
|
+
return '{}';
|
|
230
|
+
}
|
|
231
|
+
})();
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const r = this.db
|
|
235
|
+
.prepare(
|
|
236
|
+
`INSERT OR IGNORE INTO events
|
|
237
|
+
(id, bot_id, kind, chat_id, msg_id, user_id, user_name, content,
|
|
238
|
+
at, observed_at, time_basis, dedupe,
|
|
239
|
+
msg_idx, ref_msg_idx, union_openid, quoted, payload, trace, plugin)
|
|
240
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
241
|
+
)
|
|
242
|
+
.run(
|
|
243
|
+
id,
|
|
244
|
+
this.botId,
|
|
245
|
+
String(msg.kind ?? ''),
|
|
246
|
+
String(msg.chatId ?? ''),
|
|
247
|
+
msgId || null,
|
|
248
|
+
String(msg.userId ?? '') || null,
|
|
249
|
+
String(msg.userName ?? '') || null,
|
|
250
|
+
String(msg.content ?? ''),
|
|
251
|
+
at,
|
|
252
|
+
receivedAt,
|
|
253
|
+
basis,
|
|
254
|
+
dedupe,
|
|
255
|
+
String(msg.msgIdx ?? '') || null,
|
|
256
|
+
String(msg.refMsgIdx ?? '') || null,
|
|
257
|
+
String(msg.raw?.author?.union_openid ?? '') || null,
|
|
258
|
+
String(msg.quoted ?? '') || null,
|
|
259
|
+
payload,
|
|
260
|
+
// 链路 id:来自 buildMessage(见 src/trace.js)。老调用方没有就给空串
|
|
261
|
+
String(msg.traceId ?? ''),
|
|
262
|
+
/**
|
|
263
|
+
* 谁说的(v3)。入站一律空串;出站由 `outbound` 带插件 id,
|
|
264
|
+
* 多插件合并时是逗号分隔(`echo,ai-chat`)。
|
|
265
|
+
*/
|
|
266
|
+
String(msg.plugin ?? ''),
|
|
267
|
+
);
|
|
268
|
+
// changes === 0 说明撞了 dedupe 唯一约束 = 官方重推的同一条消息
|
|
269
|
+
if (!r?.changes) {
|
|
270
|
+
/**
|
|
271
|
+
* 重推时**仍然返回已有那条的 id**。
|
|
272
|
+
*
|
|
273
|
+
* 官方明说「相同 msg_id 可能重复推送」。调用方(dsh-roost 写记忆)
|
|
274
|
+
* 拿不到 eventId 就没法给出处——而这条消息事实上就在账本里,
|
|
275
|
+
* 只是不是这次写的。
|
|
276
|
+
*/
|
|
277
|
+
const old = this.db.prepare('SELECT seq, id FROM events WHERE dedupe = ?').get(dedupe);
|
|
278
|
+
return old ? { seq: Number(old.seq), id: String(old.id), duplicate: true } : null;
|
|
279
|
+
}
|
|
280
|
+
const seq = Number(r.lastInsertRowid);
|
|
281
|
+
|
|
282
|
+
// 引用边:先记下「它引用了谁」,能不能对上目标另说(resolved 由 resolveRefs 补)
|
|
283
|
+
const refIdx = String(msg.refMsgIdx ?? '').trim();
|
|
284
|
+
if (refIdx) {
|
|
285
|
+
this.db
|
|
286
|
+
.prepare('INSERT INTO event_refs (from_id, to_id, to_msg_id, kind, resolved) VALUES (?,?,?,?,0)')
|
|
287
|
+
.run(id, null, refIdx, 'quote');
|
|
288
|
+
}
|
|
289
|
+
return { seq, id };
|
|
290
|
+
} catch (e) {
|
|
291
|
+
if (!this.warned) {
|
|
292
|
+
this.warned = true;
|
|
293
|
+
log.warn('账本写入失败(后续同类错误不再提示):', e.message);
|
|
294
|
+
}
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 把「引用了某个 msg_idx」的边对到具体事件上。
|
|
301
|
+
*
|
|
302
|
+
* 官方文档里 `ref_msg_idx` 出现过 `TMP_…` 前缀,和 `REFIDX_…` 不是同一命名空间,
|
|
303
|
+
* **能不能对上要实测**。对不上就保持 `resolved=0`,退化成「只知道引用了谁、不知道是哪条」,
|
|
304
|
+
* 不抛错也不丢数据。
|
|
305
|
+
*
|
|
306
|
+
* @returns {number} 本次解析成功的条数
|
|
307
|
+
*/
|
|
308
|
+
resolveRefs(limit = 500) {
|
|
309
|
+
if (!this.available || this.closed) return 0;
|
|
310
|
+
try {
|
|
311
|
+
const rows = this.db
|
|
312
|
+
.prepare('SELECT rowid, from_id, to_msg_id FROM event_refs WHERE resolved = 0 LIMIT ?')
|
|
313
|
+
.all(limit);
|
|
314
|
+
if (!rows.length) return 0;
|
|
315
|
+
|
|
316
|
+
const upd = this.db.prepare('UPDATE event_refs SET to_id = ?, resolved = 1 WHERE rowid = ?');
|
|
317
|
+
let n = 0;
|
|
318
|
+
for (const r of rows) {
|
|
319
|
+
const hit = this.db
|
|
320
|
+
.prepare('SELECT id FROM events WHERE bot_id = ? AND msg_idx = ? LIMIT 1')
|
|
321
|
+
.get(this.botId, r.to_msg_id);
|
|
322
|
+
if (hit?.id) {
|
|
323
|
+
upd.run(hit.id, r.rowid);
|
|
324
|
+
n += 1;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return n;
|
|
328
|
+
} catch (e) {
|
|
329
|
+
log.warn('引用边解析失败:', e.message);
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* 滚动删。按 `seq` 分批,一次 ≤5000 行。
|
|
336
|
+
*
|
|
337
|
+
* 删之前先解析一遍引用边——过期的边留着也没意义,
|
|
338
|
+
* 顺手让「最近 7 天内的引用」尽量对上。
|
|
339
|
+
*
|
|
340
|
+
* @returns {Promise<number>} 删掉的行数
|
|
341
|
+
*/
|
|
342
|
+
async prune() {
|
|
343
|
+
if (!this.available || this.closed) return 0;
|
|
344
|
+
/**
|
|
345
|
+
* **先解析引用边,再决定删不删。**
|
|
346
|
+
*
|
|
347
|
+
* `resolveRefs()` 是全仓唯一的引用边解析入口(另一个调用点在测试里),
|
|
348
|
+
* 把它挪到 `retentionDays` 的 guard 后面,永久模式下引用边就永远不解析,
|
|
349
|
+
* `stats().unresolvedRefs` 会单调涨到全量。
|
|
350
|
+
*/
|
|
351
|
+
this.resolveRefs();
|
|
352
|
+
|
|
353
|
+
/** 0 = 永久:只解析引用,不删任何行 */
|
|
354
|
+
if (!this.retentionDays) {
|
|
355
|
+
this.runVacuumIfAsked();
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const cutoff = Date.now() - this.retentionDays * 24 * 3600 * 1000;
|
|
360
|
+
let total = 0;
|
|
361
|
+
for (;;) {
|
|
362
|
+
const r = this.db
|
|
363
|
+
.prepare(
|
|
364
|
+
`DELETE FROM events WHERE seq IN (
|
|
365
|
+
SELECT seq FROM events WHERE at < ? ORDER BY seq LIMIT ?
|
|
366
|
+
)`,
|
|
367
|
+
)
|
|
368
|
+
.run(cutoff, PRUNE_BATCH);
|
|
369
|
+
const n = Number(r?.changes ?? 0);
|
|
370
|
+
total += n;
|
|
371
|
+
if (n < PRUNE_BATCH) break;
|
|
372
|
+
}
|
|
373
|
+
if (total) {
|
|
374
|
+
// 孤儿引用边一起清掉,不然表会慢慢涨
|
|
375
|
+
this.db.exec('DELETE FROM event_refs WHERE from_id NOT IN (SELECT id FROM events)');
|
|
376
|
+
log.info(`账本清理:删了 ${total} 条(保留 ${this.retentionDays} 天)`);
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* 压缩只在**这里**做,不在 HTTP 请求里做:`DatabaseSync` 是同步 API,
|
|
380
|
+
* VACUUM 锁几秒会把所有机器人的收消息一起卡住(见 `host.js:4188` 的同款裁定)。
|
|
381
|
+
*/
|
|
382
|
+
this.runVacuumIfAsked();
|
|
383
|
+
return total;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* 取某条消息前后各 N 条。
|
|
388
|
+
*
|
|
389
|
+
* **锚点必须是 time_basis != 'unknown'**,否则返回空——时间不可靠的锚点
|
|
390
|
+
* 拼出来的上下文是错的。这条限制在 SQL 里用索引兜,不在 JS 里过滤。
|
|
391
|
+
*
|
|
392
|
+
* @param {object} p
|
|
393
|
+
* @param {string} p.chatId
|
|
394
|
+
* @param {number} [p.anchorSeq]
|
|
395
|
+
* @param {number} [p.before] 往前几条,默认 2
|
|
396
|
+
* @param {number} [p.after] 往后几条,默认 2
|
|
397
|
+
* @param {number} [p.maxGapMs] 前后最多跨多久,默认 10 分钟。相邻不等于同一话题
|
|
398
|
+
*/
|
|
399
|
+
timeline({ chatId, anchorSeq, before = 2, after = 2, maxGapMs = 10 * 60 * 1000 } = {}) {
|
|
400
|
+
if (!this.available || this.closed || !chatId) return [];
|
|
401
|
+
const anchor = this.db
|
|
402
|
+
.prepare(
|
|
403
|
+
`SELECT * FROM events
|
|
404
|
+
WHERE bot_id = ? AND chat_id = ? AND seq = ? AND time_basis != 'unknown'`,
|
|
405
|
+
)
|
|
406
|
+
.get(this.botId, String(chatId), Number(anchorSeq) || 0);
|
|
407
|
+
if (!anchor) return [];
|
|
408
|
+
|
|
409
|
+
const cols = `seq, id, kind, chat_id, msg_id, user_id, user_name, content, at, time_basis, msg_idx, ref_msg_idx, quoted`;
|
|
410
|
+
const prev = this.db
|
|
411
|
+
.prepare(
|
|
412
|
+
`SELECT ${cols} FROM events
|
|
413
|
+
WHERE bot_id = ? AND chat_id = ? AND time_basis != 'unknown'
|
|
414
|
+
AND at >= ? AND seq < ?
|
|
415
|
+
ORDER BY seq DESC LIMIT ?`,
|
|
416
|
+
)
|
|
417
|
+
.all(this.botId, String(chatId), anchor.at - maxGapMs, anchor.seq, before);
|
|
418
|
+
const next = this.db
|
|
419
|
+
.prepare(
|
|
420
|
+
`SELECT ${cols} FROM events
|
|
421
|
+
WHERE bot_id = ? AND chat_id = ? AND time_basis != 'unknown'
|
|
422
|
+
AND at <= ? AND seq > ?
|
|
423
|
+
ORDER BY seq ASC LIMIT ?`,
|
|
424
|
+
)
|
|
425
|
+
.all(this.botId, String(chatId), anchor.at + maxGapMs, anchor.seq, after);
|
|
426
|
+
|
|
427
|
+
return [...prev.reverse(), { ...anchor, hit: true }, ...next].map((r) => ({
|
|
428
|
+
seq: r.seq,
|
|
429
|
+
id: r.id,
|
|
430
|
+
kind: r.kind,
|
|
431
|
+
chatId: r.chat_id,
|
|
432
|
+
msgId: r.msg_id,
|
|
433
|
+
userId: r.user_id,
|
|
434
|
+
userName: r.user_name,
|
|
435
|
+
at: r.at,
|
|
436
|
+
timeBasis: r.time_basis,
|
|
437
|
+
msgIdx: r.msg_idx,
|
|
438
|
+
refMsgIdx: r.ref_msg_idx,
|
|
439
|
+
quoted: r.quoted,
|
|
440
|
+
content: contentOf(r),
|
|
441
|
+
role: r.hit ? 'hit' : 'neighbor',
|
|
442
|
+
}));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** 按 id 取一条(含正文) */
|
|
446
|
+
get(id) {
|
|
447
|
+
if (!this.available || this.closed) return null;
|
|
448
|
+
return this.db.prepare('SELECT * FROM events WHERE id = ?').get(String(id ?? '')) ?? null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 某会话最近的若干条,按时间正序返回 */
|
|
452
|
+
recent({ chatId, limit = 50, beforeSeq } = {}) {
|
|
453
|
+
if (!this.available || this.closed || !chatId) return [];
|
|
454
|
+
const lim = Math.min(Math.max(Number(limit) || 50, 1), 500);
|
|
455
|
+
const rows = beforeSeq
|
|
456
|
+
? this.db
|
|
457
|
+
.prepare(
|
|
458
|
+
`SELECT seq, id, kind, chat_id, user_id, user_name, content, at, time_basis, msg_idx, quoted, trace, plugin, bot_id
|
|
459
|
+
FROM events WHERE bot_id = ? AND chat_id = ? AND seq < ?
|
|
460
|
+
ORDER BY seq DESC LIMIT ?`,
|
|
461
|
+
)
|
|
462
|
+
.all(this.botId, String(chatId), Number(beforeSeq), lim)
|
|
463
|
+
: this.db
|
|
464
|
+
.prepare(
|
|
465
|
+
`SELECT seq, id, kind, chat_id, user_id, user_name, content, at, time_basis, msg_idx, quoted, trace, plugin, bot_id
|
|
466
|
+
FROM events WHERE bot_id = ? AND chat_id = ?
|
|
467
|
+
ORDER BY seq DESC LIMIT ?`,
|
|
468
|
+
)
|
|
469
|
+
.all(this.botId, String(chatId), lim);
|
|
470
|
+
return rows.reverse().map(mapRow);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* 某个人说过的话(跨会话)。
|
|
475
|
+
*
|
|
476
|
+
* 面板「身份」页要用它:管理员看到一串 openid 时,得先知道「这人是谁、
|
|
477
|
+
* 在哪个群说过什么」才能确认该绑哪个 QQ号。只有 stats 的计数不够——
|
|
478
|
+
* 计数长得都差不多,一句原文立刻就能认出来。
|
|
479
|
+
*
|
|
480
|
+
* 走 `events_user` 索引 `(bot_id, user_id, at)`,不需要新建索引。
|
|
481
|
+
*
|
|
482
|
+
* @param {object} p
|
|
483
|
+
* @param {string} p.userId openid
|
|
484
|
+
* @param {number} [p.limit] 默认 20,上限 200
|
|
485
|
+
* @returns {Array<object>} 按时间正序(最后一条是最近的)
|
|
486
|
+
*/
|
|
487
|
+
byUser({ userId, limit = 20 } = {}) {
|
|
488
|
+
if (!this.available || this.closed || !userId) return [];
|
|
489
|
+
const lim = Math.min(Math.max(Number(limit) || 20, 1), 200);
|
|
490
|
+
const rows = this.db
|
|
491
|
+
.prepare(
|
|
492
|
+
`SELECT seq, id, kind, chat_id, user_id, user_name, content, at, time_basis, msg_idx, quoted, trace, plugin, bot_id
|
|
493
|
+
FROM events WHERE bot_id = ? AND user_id = ?
|
|
494
|
+
ORDER BY at DESC, seq DESC LIMIT ?`,
|
|
495
|
+
)
|
|
496
|
+
.all(this.botId, String(userId), lim);
|
|
497
|
+
return rows.reverse().map(mapRow);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/** 面板用:总量、时间范围、保留策略 */
|
|
501
|
+
stats() {
|
|
502
|
+
if (!this.available || this.closed) {
|
|
503
|
+
return { available: false, total: 0, retentionDays: this.retentionDays };
|
|
504
|
+
}
|
|
505
|
+
const row = this.db
|
|
506
|
+
.prepare('SELECT COUNT(*) AS n, MIN(at) AS oldest, MAX(at) AS newest, MAX(seq) AS seqMax FROM events')
|
|
507
|
+
.get();
|
|
508
|
+
const refs = this.db.prepare('SELECT COUNT(*) AS n FROM event_refs WHERE resolved = 0').get();
|
|
509
|
+
/**
|
|
510
|
+
* 面板要的「占多少、都有谁」。
|
|
511
|
+
*
|
|
512
|
+
* **`total` / `seqMax` 不能改名**:`test/selftest.mjs` 有 5 处断言在吃它们,
|
|
513
|
+
* `web/server.js:2268` 的兜底也是 `{available:false,total:0}`。
|
|
514
|
+
* 这里只加字段,不换结构。
|
|
515
|
+
*/
|
|
516
|
+
let bytes = 0;
|
|
517
|
+
try {
|
|
518
|
+
bytes = fs.statSync(this.file).size;
|
|
519
|
+
const wal = `${this.file}-wal`;
|
|
520
|
+
if (fs.existsSync(wal)) bytes += fs.statSync(wal).size;
|
|
521
|
+
} catch {}
|
|
522
|
+
let byChat = [];
|
|
523
|
+
try {
|
|
524
|
+
byChat = this.db
|
|
525
|
+
.prepare(
|
|
526
|
+
`SELECT chat_id, kind, COUNT(*) AS n, MIN(at) AS oldest, MAX(at) AS newest
|
|
527
|
+
FROM events GROUP BY chat_id, kind ORDER BY newest DESC LIMIT 50`,
|
|
528
|
+
)
|
|
529
|
+
.all()
|
|
530
|
+
.map((r) => ({
|
|
531
|
+
chatId: String(r.chat_id ?? ''),
|
|
532
|
+
kind: String(r.kind ?? ''),
|
|
533
|
+
rows: Number(r.n ?? 0),
|
|
534
|
+
oldestAt: Number(r.oldest ?? 0),
|
|
535
|
+
newestAt: Number(r.newest ?? 0),
|
|
536
|
+
}));
|
|
537
|
+
} catch {}
|
|
538
|
+
return {
|
|
539
|
+
available: true,
|
|
540
|
+
total: Number(row?.n ?? 0),
|
|
541
|
+
rows: Number(row?.n ?? 0),
|
|
542
|
+
oldestAt: Number(row?.oldest ?? 0),
|
|
543
|
+
newestAt: Number(row?.newest ?? 0),
|
|
544
|
+
seqMax: Number(row?.seqMax ?? 0),
|
|
545
|
+
unresolvedRefs: Number(refs?.n ?? 0),
|
|
546
|
+
retentionDays: this.retentionDays,
|
|
547
|
+
bytes,
|
|
548
|
+
file: this.file,
|
|
549
|
+
byChat,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** 待压缩标记的路径。同目录一个空文件,跨进程有效(照 journal 的做法) */
|
|
554
|
+
vacuumMark() {
|
|
555
|
+
return `${this.file}.vacuum`;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 请求压缩:**只写标记,不在这里 VACUUM**。
|
|
560
|
+
*
|
|
561
|
+
* `DatabaseSync` 是同步 API,VACUUM 锁几秒会把整个进程卡住——所有机器人的
|
|
562
|
+
* 收消息一起停。所以由定时清理(`prune`)顺手消费,面板点完立刻返回。
|
|
563
|
+
*/
|
|
564
|
+
requestVacuum() {
|
|
565
|
+
try {
|
|
566
|
+
fs.mkdirSync(path.dirname(this.vacuumMark()), { recursive: true });
|
|
567
|
+
fs.writeFileSync(this.vacuumMark(), String(Date.now()), 'utf8');
|
|
568
|
+
this.needVacuum = true;
|
|
569
|
+
return true;
|
|
570
|
+
} catch (e) {
|
|
571
|
+
log.warn(`压缩标记没写进去:${e.message}`);
|
|
572
|
+
return false;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/** 有标记就压一次。返回是否真的压了 */
|
|
577
|
+
runVacuumIfAsked() {
|
|
578
|
+
if (!this.available || this.closed || !this.db) return false;
|
|
579
|
+
const mark = this.vacuumMark();
|
|
580
|
+
if (!this.needVacuum && !fs.existsSync(mark)) return false;
|
|
581
|
+
this.needVacuum = false;
|
|
582
|
+
try {
|
|
583
|
+
fs.unlinkSync(mark);
|
|
584
|
+
} catch {}
|
|
585
|
+
try {
|
|
586
|
+
this.db.exec('VACUUM');
|
|
587
|
+
/**
|
|
588
|
+
* `PRAGMA wal_checkpoint` **返回一行结果**,用 `exec` 拿不到,
|
|
589
|
+
* 必须 `prepare().get()`。压完顺手把 WAL 截断,否则文件大小不减。
|
|
590
|
+
*/
|
|
591
|
+
try {
|
|
592
|
+
this.db.prepare('PRAGMA wal_checkpoint(TRUNCATE)').get();
|
|
593
|
+
} catch {}
|
|
594
|
+
log.info('账本已压缩');
|
|
595
|
+
return true;
|
|
596
|
+
} catch (e) {
|
|
597
|
+
log.warn(`账本压缩失败:${e.message}`);
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* 手动删记录(面板用)。三种口径互斥,至少要给一个。
|
|
604
|
+
*
|
|
605
|
+
* @param {object} p
|
|
606
|
+
* @param {string} [p.chatId] 限定会话
|
|
607
|
+
* @param {number} [p.before] 删这个时间点之前的
|
|
608
|
+
* @param {number} [p.keepLast] 只保留最近 N 条(**必须同时给 chatId**)
|
|
609
|
+
* @param {string[]} [p.ids] 按 id 精确删
|
|
610
|
+
* @returns {{removed:number, error?:string}}
|
|
611
|
+
*/
|
|
612
|
+
remove({ chatId = '', before = 0, keepLast = 0, ids = [] } = {}) {
|
|
613
|
+
if (!this.available || this.closed || !this.db) return { removed: 0, error: '账本没开' };
|
|
614
|
+
const chat = String(chatId || '');
|
|
615
|
+
const ts = Number(before) || 0;
|
|
616
|
+
const keep = Number(keepLast) || 0;
|
|
617
|
+
const list = (Array.isArray(ids) ? ids : []).map(String).filter(Boolean).slice(0, 500);
|
|
618
|
+
|
|
619
|
+
if (!chat && !ts && !keep && !list.length) return { removed: 0, error: '没给删除条件' };
|
|
620
|
+
if (keep && !chat) return { removed: 0, error: '「只保留最近 N 条」必须指定会话' };
|
|
621
|
+
|
|
622
|
+
let removed = 0;
|
|
623
|
+
try {
|
|
624
|
+
if (list.length) {
|
|
625
|
+
const marks = list.map(() => '?').join(',');
|
|
626
|
+
removed += Number(
|
|
627
|
+
this.db.prepare(`DELETE FROM events WHERE id IN (${marks})`).run(...list).changes ?? 0,
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
if (keep && chat) {
|
|
631
|
+
/**
|
|
632
|
+
* 用 `seq` 而不是 `at` 定「最近 N 条」:同一毫秒可能有多条,
|
|
633
|
+
* 按时间排会留下谁说不清,按自增序号才是确定的。
|
|
634
|
+
*/
|
|
635
|
+
removed += Number(
|
|
636
|
+
this.db
|
|
637
|
+
.prepare(
|
|
638
|
+
`DELETE FROM events
|
|
639
|
+
WHERE chat_id = ? AND seq < (
|
|
640
|
+
SELECT MIN(seq) FROM (
|
|
641
|
+
SELECT seq FROM events WHERE chat_id = ? ORDER BY seq DESC LIMIT ?
|
|
642
|
+
)
|
|
643
|
+
)`,
|
|
644
|
+
)
|
|
645
|
+
.run(chat, chat, keep).changes ?? 0,
|
|
646
|
+
);
|
|
647
|
+
} else if (ts) {
|
|
648
|
+
removed += Number(
|
|
649
|
+
this.db
|
|
650
|
+
.prepare(
|
|
651
|
+
`DELETE FROM events WHERE at < ?${chat ? ' AND chat_id = ?' : ''}`,
|
|
652
|
+
)
|
|
653
|
+
.run(...(chat ? [ts, chat] : [ts])).changes ?? 0,
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
if (removed) {
|
|
657
|
+
this.db.exec('DELETE FROM event_refs WHERE from_id NOT IN (SELECT id FROM events)');
|
|
658
|
+
this.requestVacuum();
|
|
659
|
+
log.info(`账本手动删除 ${removed} 条${chat ? `(会话 ${chat.slice(0, 8)})` : ''}`);
|
|
660
|
+
}
|
|
661
|
+
return { removed };
|
|
662
|
+
} catch (e) {
|
|
663
|
+
log.warn(`账本删除失败:${e.message}`);
|
|
664
|
+
return { removed, error: e.message };
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
* 删除前的预览:**只数不删**。
|
|
670
|
+
*
|
|
671
|
+
* 面板的确认框要求用户输入「将删除 N 条」里的 N,这个 N 必须来自服务端——
|
|
672
|
+
* 前端自己算的数和真正删掉的可能不一样(期间有新消息进来),
|
|
673
|
+
* 拿前端的数去校验等于没校验。
|
|
674
|
+
*/
|
|
675
|
+
countFor({ chatId = '', before = 0, keepLast = 0 } = {}) {
|
|
676
|
+
if (!this.available || this.closed || !this.db) return 0;
|
|
677
|
+
const chat = String(chatId || '');
|
|
678
|
+
const ts = Number(before) || 0;
|
|
679
|
+
const keep = Number(keepLast) || 0;
|
|
680
|
+
try {
|
|
681
|
+
if (keep && chat) {
|
|
682
|
+
return Number(
|
|
683
|
+
this.db
|
|
684
|
+
.prepare(
|
|
685
|
+
`SELECT COUNT(*) AS n FROM events
|
|
686
|
+
WHERE chat_id = ? AND seq < (
|
|
687
|
+
SELECT MIN(seq) FROM (
|
|
688
|
+
SELECT seq FROM events WHERE chat_id = ? ORDER BY seq DESC LIMIT ?
|
|
689
|
+
)
|
|
690
|
+
)`,
|
|
691
|
+
)
|
|
692
|
+
.get(chat, chat, keep)?.n ?? 0,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
if (ts) {
|
|
696
|
+
const sql = `SELECT COUNT(*) AS n FROM events WHERE at < ?${chat ? ' AND chat_id = ?' : ''}`;
|
|
697
|
+
return Number(this.db.prepare(sql).get(...(chat ? [ts, chat] : [ts]))?.n ?? 0);
|
|
698
|
+
}
|
|
699
|
+
if (chat) {
|
|
700
|
+
return Number(this.db.prepare('SELECT COUNT(*) AS n FROM events WHERE chat_id = ?').get(chat)?.n ?? 0);
|
|
701
|
+
}
|
|
702
|
+
return Number(this.db.prepare('SELECT COUNT(*) AS n FROM events').get()?.n ?? 0);
|
|
703
|
+
} catch {
|
|
704
|
+
return 0;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* 批量导入(面板用)。
|
|
710
|
+
*
|
|
711
|
+
* **走 `append()`,不自己拼 SQL**:去重、索引、`event_refs` 清理全复用同一套逻辑。
|
|
712
|
+
* 这样「导回去」和「正常收到」写出来的行没有区别,以后改 schema 也不用改两处。
|
|
713
|
+
*
|
|
714
|
+
* 幂等靠两层:
|
|
715
|
+
* · 有 `msgId` → dedupe = `botId:msgId`,和原来那条一模一样;
|
|
716
|
+
* · 没有 `msgId` → dedupe 里带 `at`,同一份文件导两次也只会进一次。
|
|
717
|
+
*
|
|
718
|
+
* @param {Array<object>} rows 每行 `{at, kind, chatId, msgId, userId, userName, content, plugin}`
|
|
719
|
+
* @returns {{added:number, skipped:number, bad:number, error?:string}}
|
|
720
|
+
*/
|
|
721
|
+
importRows(rows, { max = 5000 } = {}) {
|
|
722
|
+
if (!this.available || this.closed) return { added: 0, skipped: 0, bad: 0, error: '账本没开' };
|
|
723
|
+
const list = Array.isArray(rows) ? rows.slice(0, max) : [];
|
|
724
|
+
let added = 0;
|
|
725
|
+
let skipped = 0;
|
|
726
|
+
let bad = 0;
|
|
727
|
+
for (const r of list) {
|
|
728
|
+
const at = Number(r?.at);
|
|
729
|
+
const chatId = String(r?.chatId ?? '').trim();
|
|
730
|
+
/**
|
|
731
|
+
* 时间必须能解析成有效毫秒。`resolveTime` 会把解析不出来的当「本地收到时间」,
|
|
732
|
+
* 那样导进来的历史会全部堆到"现在",时间线彻底失真——所以这里直接拒。
|
|
733
|
+
*/
|
|
734
|
+
if (!chatId || !Number.isFinite(at) || at <= 0) {
|
|
735
|
+
bad += 1;
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
const got = this.append({
|
|
740
|
+
/** 有 msgId 就用它做去重键;没有就走指纹 */
|
|
741
|
+
id: String(r?.msgId ?? ''),
|
|
742
|
+
kind: String(r?.kind ?? 'group'),
|
|
743
|
+
chatId,
|
|
744
|
+
userId: String(r?.userId ?? ''),
|
|
745
|
+
userName: String(r?.userName ?? ''),
|
|
746
|
+
content: String(r?.content ?? ''),
|
|
747
|
+
plugin: String(r?.plugin ?? ''),
|
|
748
|
+
/**
|
|
749
|
+
* `append()` 只从 `raw.timestamp` 推 `at`(传 `at` 无效)。
|
|
750
|
+
* 给 ISO 字符串,`resolveTime` 会解析成 `basis: 'message'`——
|
|
751
|
+
* 正是「这条消息官方给了时间」的语义。
|
|
752
|
+
*/
|
|
753
|
+
raw: { timestamp: new Date(at).toISOString() },
|
|
754
|
+
});
|
|
755
|
+
if (!got) bad += 1;
|
|
756
|
+
else if (got.duplicate) skipped += 1;
|
|
757
|
+
else added += 1;
|
|
758
|
+
} catch {
|
|
759
|
+
bad += 1;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (added) {
|
|
763
|
+
this.db.exec('DELETE FROM event_refs WHERE from_id NOT IN (SELECT id FROM events)');
|
|
764
|
+
log.info(`账本导入 ${added} 条(跳过重复 ${skipped},无效 ${bad})`);
|
|
765
|
+
}
|
|
766
|
+
return { added, skipped, bad };
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* 换 openid:把账本里旧 openid 的记录改成新的。
|
|
771
|
+
*
|
|
772
|
+
* 场景:换了 AppID / 重新绑定之后,同一个人的 openid 变了。
|
|
773
|
+
* 旧记录还挂在旧 openid 下,按新 openid 查就是空的——「记忆看起来丢了」。
|
|
774
|
+
*
|
|
775
|
+
* **为什么必须由人指定 from → to**:`identity.js` 的 `bind` 有意不撤销旧 claim
|
|
776
|
+
* (旧 openid 是读旧记忆唯一的线索),所以一个号码下面可能同时有多条 active 的
|
|
777
|
+
* openid,代码无法判断哪条才是"新"的。自动猜必然猜错。
|
|
778
|
+
*
|
|
779
|
+
* `dedupe` **不动**:它里面也含 chatId,但它是唯一键,批量改可能撞车。
|
|
780
|
+
* 代价是这批老消息以后不会被当成重复——它们本来就是历史,不会重推。
|
|
781
|
+
*/
|
|
782
|
+
remapOpenid({ from = '', to = '', scope = 'both', dryRun = false } = {}) {
|
|
783
|
+
if (!this.available || this.closed || !this.db) return { chat: 0, user: 0, error: '账本没开' };
|
|
784
|
+
const a = String(from || '').trim();
|
|
785
|
+
const b = String(to || '').trim();
|
|
786
|
+
const sc = scope === 'chat' || scope === 'user' ? scope : 'both';
|
|
787
|
+
if (!a || !b) return { chat: 0, user: 0, error: '要给旧 openid 和新 openid' };
|
|
788
|
+
if (a === b) return { chat: 0, user: 0, error: '新旧是同一个,不用改' };
|
|
789
|
+
|
|
790
|
+
const count = (sql) => Number(this.db.prepare(sql).get(a)?.n ?? 0);
|
|
791
|
+
try {
|
|
792
|
+
const out = { chat: 0, user: 0 };
|
|
793
|
+
if (sc !== 'user') out.chat = count('SELECT COUNT(*) AS n FROM events WHERE chat_id = ?');
|
|
794
|
+
if (sc !== 'chat') out.user = count('SELECT COUNT(*) AS n FROM events WHERE user_id = ?');
|
|
795
|
+
if (dryRun) return out;
|
|
796
|
+
if (sc !== 'user') this.db.prepare('UPDATE events SET chat_id = ? WHERE chat_id = ?').run(b, a);
|
|
797
|
+
if (sc !== 'chat') this.db.prepare('UPDATE events SET user_id = ? WHERE user_id = ?').run(b, a);
|
|
798
|
+
if (out.chat || out.user) {
|
|
799
|
+
this.requestVacuum();
|
|
800
|
+
log.info(`账本换 openid:会话 ${out.chat} 条、发送者 ${out.user} 条(${a.slice(0, 8)} → ${b.slice(0, 8)})`);
|
|
801
|
+
}
|
|
802
|
+
return out;
|
|
803
|
+
} catch (e) {
|
|
804
|
+
log.warn(`账本换 openid 失败:${e.message}`);
|
|
805
|
+
return { chat: 0, user: 0, error: e.message };
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** 导出用:按条件取行(给 server 拼 CSV) */
|
|
810
|
+
exportRows({ chatId = '', before = 0, limit = 5000 } = {}) {
|
|
811
|
+
if (!this.available || this.closed || !this.db) return [];
|
|
812
|
+
const lim = Math.min(Math.max(Number(limit) || 5000, 1), 50000);
|
|
813
|
+
const chat = String(chatId || '');
|
|
814
|
+
const ts = Number(before) || 0;
|
|
815
|
+
try {
|
|
816
|
+
const where = [];
|
|
817
|
+
const args = [];
|
|
818
|
+
if (chat) { where.push('chat_id = ?'); args.push(chat); }
|
|
819
|
+
if (ts) { where.push('at < ?'); args.push(ts); }
|
|
820
|
+
const sql = `SELECT seq, id, kind, chat_id, msg_id, user_id, user_name, content, at, time_basis, plugin, bot_id
|
|
821
|
+
FROM events ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
|
|
822
|
+
ORDER BY seq ASC LIMIT ?`;
|
|
823
|
+
return this.db.prepare(sql).all(...args, lim);
|
|
824
|
+
} catch (e) {
|
|
825
|
+
log.warn(`账本导出失败:${e.message}`);
|
|
826
|
+
return [];
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
close() {
|
|
831
|
+
this.closed = true;
|
|
832
|
+
if (this.pruneTimer) {
|
|
833
|
+
clearInterval(this.pruneTimer);
|
|
834
|
+
this.pruneTimer = null;
|
|
835
|
+
}
|
|
836
|
+
try {
|
|
837
|
+
this.db?.close();
|
|
838
|
+
} catch {
|
|
839
|
+
/* 关不上就算了,进程要退了 */
|
|
840
|
+
}
|
|
841
|
+
this.db = null;
|
|
842
|
+
this.available = false;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** 从行里取正文。优先独立列;老数据(只有 payload)退回解析,解析失败给空串 */
|
|
847
|
+
function contentOf(row) {
|
|
848
|
+
if (typeof row?.content === 'string' && row.content) return row.content;
|
|
849
|
+
try {
|
|
850
|
+
return String(JSON.parse(row?.payload ?? '{}')?.content ?? '');
|
|
851
|
+
} catch {
|
|
852
|
+
return '';
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
/**
|
|
857
|
+
* events 行 → 对外的对象。
|
|
858
|
+
*
|
|
859
|
+
* 抽出来是因为 `recent()` 和 `byUser()` 查的是同一张表、同一个返回形状,
|
|
860
|
+
* 各写一遍迟早会漏字段(`trace` 就是后加的,漏了没人发现)。
|
|
861
|
+
*/
|
|
862
|
+
function mapRow(r) {
|
|
863
|
+
return {
|
|
864
|
+
seq: r.seq,
|
|
865
|
+
id: r.id,
|
|
866
|
+
kind: r.kind,
|
|
867
|
+
chatId: r.chat_id,
|
|
868
|
+
/** 哪个机器人实例(运行副本当前是 main)。出站行没有 user_name,靠它显示「谁」 */
|
|
869
|
+
botId: r.bot_id ?? '',
|
|
870
|
+
userId: r.user_id,
|
|
871
|
+
userName: r.user_name,
|
|
872
|
+
at: r.at,
|
|
873
|
+
timeBasis: r.time_basis,
|
|
874
|
+
msgIdx: r.msg_idx,
|
|
875
|
+
quoted: r.quoted,
|
|
876
|
+
// 链路 id:调用方拿它去查「这条消息后来发生了什么」
|
|
877
|
+
trace: r.trace ?? '',
|
|
878
|
+
/**
|
|
879
|
+
* 谁说的(v3):出站是插件 id,多插件合并时逗号分隔;入站是空串。
|
|
880
|
+
* 别默认成 `''` 就当成"入站"——老库里的出站行也是空串。
|
|
881
|
+
*/
|
|
882
|
+
plugin: r.plugin ?? '',
|
|
883
|
+
content: contentOf(r),
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* @param {object} p
|
|
889
|
+
* @param {string} p.file
|
|
890
|
+
* @param {string} p.botId
|
|
891
|
+
* @param {boolean} [p.enabled]
|
|
892
|
+
* @param {number} [p.retentionDays]
|
|
893
|
+
*/
|
|
894
|
+
export function createLedger({ file, botId, enabled, retentionDays }) {
|
|
895
|
+
return new EventLedger({ file, botId, enabled, retentionDays });
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** 目录存在性检查,测试和面板诊断用 */
|
|
899
|
+
export function ledgerExists(file) {
|
|
900
|
+
try {
|
|
901
|
+
return fs.existsSync(path.resolve(file));
|
|
902
|
+
} catch {
|
|
903
|
+
return false;
|
|
904
|
+
}
|
|
905
|
+
}
|