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/src/ledger.js
ADDED
|
@@ -0,0 +1,513 @@
|
|
|
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] 保留天数,默认 7
|
|
123
|
+
*/
|
|
124
|
+
constructor({ file, botId = '', enabled = true, retentionDays = 7 }) {
|
|
125
|
+
this.file = file;
|
|
126
|
+
this.botId = botId;
|
|
127
|
+
this.enabled = enabled !== false;
|
|
128
|
+
this.retentionDays = Math.max(1, Number(retentionDays) || 7);
|
|
129
|
+
|
|
130
|
+
this.db = null;
|
|
131
|
+
this.available = false;
|
|
132
|
+
this.closed = false;
|
|
133
|
+
this.pruneTimer = null;
|
|
134
|
+
/** 只在第一次出错时提醒,别刷屏 */
|
|
135
|
+
this.warned = false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** 打开库并建表。失败只警告——账本是增强功能,不能因此让机器人起不来 */
|
|
139
|
+
open() {
|
|
140
|
+
if (!this.enabled) {
|
|
141
|
+
log.info('事件账本已关闭(ledger.enabled = false)');
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
if (!probeSqlite()) {
|
|
145
|
+
log.warn('当前 Node 不支持 node:sqlite,事件账本不可用(其它功能正常)');
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
try {
|
|
149
|
+
this.db = openDatabase(this.file);
|
|
150
|
+
migrateEvents(this.db);
|
|
151
|
+
this.available = true;
|
|
152
|
+
} catch (e) {
|
|
153
|
+
log.warn(`事件账本打不开(${this.file}):${e.message}`);
|
|
154
|
+
this.db = null;
|
|
155
|
+
this.available = false;
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// 启动先清一次:进程可能停了很久,攒下的一堆过期数据要立刻走
|
|
160
|
+
this.prune().catch((e) => log.warn('账本清理失败:', e.message));
|
|
161
|
+
this.pruneTimer = setInterval(() => {
|
|
162
|
+
this.prune().catch((e) => log.warn('账本清理失败:', e.message));
|
|
163
|
+
}, PRUNE_INTERVAL_MS);
|
|
164
|
+
this.pruneTimer.unref?.();
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* 记一条消息。
|
|
170
|
+
*
|
|
171
|
+
* **必须在 pipeline 之前调**:被去重 / 白名单 / 限流 / 暂停拦下的消息
|
|
172
|
+
* 也是「发生过的事实」,账本要记的是事实,不是处理结果。
|
|
173
|
+
*
|
|
174
|
+
* @param {object} msg `buildMessage()` 的产物
|
|
175
|
+
* @param {object} [opts]
|
|
176
|
+
* @param {'message'|'observation'|'unknown'} [opts.timeBasis]
|
|
177
|
+
* 强制指定时间基准。**只有导入 / 补录 / 迁移才该用**——
|
|
178
|
+
* 正常收消息时自动判(`resolveTime`),判不准会退回 `observation`,
|
|
179
|
+
* 而 `unknown` 是「连本地收到时间都不可信」(批量导入一批几年前的聊天记录,
|
|
180
|
+
* 这时候的 `Date.now()` 是导入时间,不是说话时间)。
|
|
181
|
+
* `unknown` 的事件**不参与时间邻文**,这是故意的。
|
|
182
|
+
* @returns {{seq:number, id:string}|null} 重复消息返回 null
|
|
183
|
+
*/
|
|
184
|
+
append(msg, { timeBasis } = {}) {
|
|
185
|
+
if (!this.available || this.closed || !msg) return null;
|
|
186
|
+
|
|
187
|
+
const receivedAt = Date.now();
|
|
188
|
+
const resolved = resolveTime(msg.raw, receivedAt);
|
|
189
|
+
const basis = ['message', 'observation', 'unknown'].includes(timeBasis) ? timeBasis : resolved.basis;
|
|
190
|
+
/**
|
|
191
|
+
* 显式指定基准时 `at` 用本地收到时间。
|
|
192
|
+
*
|
|
193
|
+
* 这对 `unknown` 有个副作用,但方向是对的:导入的三年老聊天记录 `at` 是导入时间,
|
|
194
|
+
* 于是它会跟着往后留满一个保留期,而不是进来就被滚动删掉。
|
|
195
|
+
* 宁可多留一周让人处理,也不能让「刚导进来就没了」。
|
|
196
|
+
*/
|
|
197
|
+
const at = basis === resolved.basis ? resolved.at : receivedAt;
|
|
198
|
+
const msgId = String(msg.id ?? '').trim();
|
|
199
|
+
// 有 msg_id 就用它(官方重推同一条时 msg_id 相同);
|
|
200
|
+
// 没有就退化成「会话+人+正文」的指纹,同一个人在同一会话里重复发同一句话
|
|
201
|
+
// 会被误判成重复——这是已知代价,但比丢消息好
|
|
202
|
+
const dedupe = msgId
|
|
203
|
+
? `${this.botId}:${msgId}`
|
|
204
|
+
: `${this.botId}:${msg.chatId ?? ''}:${at}:${digestOf(msg)}`;
|
|
205
|
+
|
|
206
|
+
const id = crypto.randomUUID();
|
|
207
|
+
const payload = (() => {
|
|
208
|
+
try {
|
|
209
|
+
let s = JSON.stringify(cleanPayload(msg.raw));
|
|
210
|
+
if (Buffer.byteLength(s, 'utf8') > MAX_PAYLOAD_BYTES) {
|
|
211
|
+
// 截断会切坏 UTF-8 序列,JSON.parse 时才发现就晚了。
|
|
212
|
+
// 干脆换成一条合法的标记对象,读的时候拿到 {} 语义(contentOf 返回空串),
|
|
213
|
+
// 正文从 `quoted` / 消息本身还能还原,不会静默给出半截乱码。
|
|
214
|
+
s = JSON.stringify({ _truncated: true, _bytes: Buffer.byteLength(s, 'utf8') });
|
|
215
|
+
}
|
|
216
|
+
return s;
|
|
217
|
+
} catch {
|
|
218
|
+
return '{}';
|
|
219
|
+
}
|
|
220
|
+
})();
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const r = this.db
|
|
224
|
+
.prepare(
|
|
225
|
+
`INSERT OR IGNORE INTO events
|
|
226
|
+
(id, bot_id, kind, chat_id, msg_id, user_id, user_name, content,
|
|
227
|
+
at, observed_at, time_basis, dedupe,
|
|
228
|
+
msg_idx, ref_msg_idx, union_openid, quoted, payload)
|
|
229
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
|
230
|
+
)
|
|
231
|
+
.run(
|
|
232
|
+
id,
|
|
233
|
+
this.botId,
|
|
234
|
+
String(msg.kind ?? ''),
|
|
235
|
+
String(msg.chatId ?? ''),
|
|
236
|
+
msgId || null,
|
|
237
|
+
String(msg.userId ?? '') || null,
|
|
238
|
+
String(msg.userName ?? '') || null,
|
|
239
|
+
String(msg.content ?? ''),
|
|
240
|
+
at,
|
|
241
|
+
receivedAt,
|
|
242
|
+
basis,
|
|
243
|
+
dedupe,
|
|
244
|
+
String(msg.msgIdx ?? '') || null,
|
|
245
|
+
String(msg.refMsgIdx ?? '') || null,
|
|
246
|
+
String(msg.raw?.author?.union_openid ?? '') || null,
|
|
247
|
+
String(msg.quoted ?? '') || null,
|
|
248
|
+
payload,
|
|
249
|
+
);
|
|
250
|
+
// changes === 0 说明撞了 dedupe 唯一约束 = 官方重推的同一条消息
|
|
251
|
+
if (!r?.changes) {
|
|
252
|
+
/**
|
|
253
|
+
* 重推时**仍然返回已有那条的 id**。
|
|
254
|
+
*
|
|
255
|
+
* 官方明说「相同 msg_id 可能重复推送」。调用方(dsh-roost 写记忆)
|
|
256
|
+
* 拿不到 eventId 就没法给出处——而这条消息事实上就在账本里,
|
|
257
|
+
* 只是不是这次写的。
|
|
258
|
+
*/
|
|
259
|
+
const old = this.db.prepare('SELECT seq, id FROM events WHERE dedupe = ?').get(dedupe);
|
|
260
|
+
return old ? { seq: Number(old.seq), id: String(old.id), duplicate: true } : null;
|
|
261
|
+
}
|
|
262
|
+
const seq = Number(r.lastInsertRowid);
|
|
263
|
+
|
|
264
|
+
// 引用边:先记下「它引用了谁」,能不能对上目标另说(resolved 由 resolveRefs 补)
|
|
265
|
+
const refIdx = String(msg.refMsgIdx ?? '').trim();
|
|
266
|
+
if (refIdx) {
|
|
267
|
+
this.db
|
|
268
|
+
.prepare('INSERT INTO event_refs (from_id, to_id, to_msg_id, kind, resolved) VALUES (?,?,?,?,0)')
|
|
269
|
+
.run(id, null, refIdx, 'quote');
|
|
270
|
+
}
|
|
271
|
+
return { seq, id };
|
|
272
|
+
} catch (e) {
|
|
273
|
+
if (!this.warned) {
|
|
274
|
+
this.warned = true;
|
|
275
|
+
log.warn('账本写入失败(后续同类错误不再提示):', e.message);
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* 把「引用了某个 msg_idx」的边对到具体事件上。
|
|
283
|
+
*
|
|
284
|
+
* 官方文档里 `ref_msg_idx` 出现过 `TMP_…` 前缀,和 `REFIDX_…` 不是同一命名空间,
|
|
285
|
+
* **能不能对上要实测**。对不上就保持 `resolved=0`,退化成「只知道引用了谁、不知道是哪条」,
|
|
286
|
+
* 不抛错也不丢数据。
|
|
287
|
+
*
|
|
288
|
+
* @returns {number} 本次解析成功的条数
|
|
289
|
+
*/
|
|
290
|
+
resolveRefs(limit = 500) {
|
|
291
|
+
if (!this.available || this.closed) return 0;
|
|
292
|
+
try {
|
|
293
|
+
const rows = this.db
|
|
294
|
+
.prepare('SELECT rowid, from_id, to_msg_id FROM event_refs WHERE resolved = 0 LIMIT ?')
|
|
295
|
+
.all(limit);
|
|
296
|
+
if (!rows.length) return 0;
|
|
297
|
+
|
|
298
|
+
const upd = this.db.prepare('UPDATE event_refs SET to_id = ?, resolved = 1 WHERE rowid = ?');
|
|
299
|
+
let n = 0;
|
|
300
|
+
for (const r of rows) {
|
|
301
|
+
const hit = this.db
|
|
302
|
+
.prepare('SELECT id FROM events WHERE bot_id = ? AND msg_idx = ? LIMIT 1')
|
|
303
|
+
.get(this.botId, r.to_msg_id);
|
|
304
|
+
if (hit?.id) {
|
|
305
|
+
upd.run(hit.id, r.rowid);
|
|
306
|
+
n += 1;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return n;
|
|
310
|
+
} catch (e) {
|
|
311
|
+
log.warn('引用边解析失败:', e.message);
|
|
312
|
+
return 0;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* 滚动删。按 `seq` 分批,一次 ≤5000 行。
|
|
318
|
+
*
|
|
319
|
+
* 删之前先解析一遍引用边——过期的边留着也没意义,
|
|
320
|
+
* 顺手让「最近 7 天内的引用」尽量对上。
|
|
321
|
+
*
|
|
322
|
+
* @returns {Promise<number>} 删掉的行数
|
|
323
|
+
*/
|
|
324
|
+
async prune() {
|
|
325
|
+
if (!this.available || this.closed) return 0;
|
|
326
|
+
this.resolveRefs();
|
|
327
|
+
|
|
328
|
+
const cutoff = Date.now() - this.retentionDays * 24 * 3600 * 1000;
|
|
329
|
+
let total = 0;
|
|
330
|
+
for (;;) {
|
|
331
|
+
const r = this.db
|
|
332
|
+
.prepare(
|
|
333
|
+
`DELETE FROM events WHERE seq IN (
|
|
334
|
+
SELECT seq FROM events WHERE at < ? ORDER BY seq LIMIT ?
|
|
335
|
+
)`,
|
|
336
|
+
)
|
|
337
|
+
.run(cutoff, PRUNE_BATCH);
|
|
338
|
+
const n = Number(r?.changes ?? 0);
|
|
339
|
+
total += n;
|
|
340
|
+
if (n < PRUNE_BATCH) break;
|
|
341
|
+
}
|
|
342
|
+
if (total) {
|
|
343
|
+
// 孤儿引用边一起清掉,不然表会慢慢涨
|
|
344
|
+
this.db.exec('DELETE FROM event_refs WHERE from_id NOT IN (SELECT id FROM events)');
|
|
345
|
+
log.info(`账本清理:删了 ${total} 条(保留 ${this.retentionDays} 天)`);
|
|
346
|
+
}
|
|
347
|
+
return total;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* 取某条消息前后各 N 条。
|
|
352
|
+
*
|
|
353
|
+
* **锚点必须是 time_basis != 'unknown'**,否则返回空——时间不可靠的锚点
|
|
354
|
+
* 拼出来的上下文是错的。这条限制在 SQL 里用索引兜,不在 JS 里过滤。
|
|
355
|
+
*
|
|
356
|
+
* @param {object} p
|
|
357
|
+
* @param {string} p.chatId
|
|
358
|
+
* @param {number} [p.anchorSeq]
|
|
359
|
+
* @param {number} [p.before] 往前几条,默认 2
|
|
360
|
+
* @param {number} [p.after] 往后几条,默认 2
|
|
361
|
+
* @param {number} [p.maxGapMs] 前后最多跨多久,默认 10 分钟。相邻不等于同一话题
|
|
362
|
+
*/
|
|
363
|
+
timeline({ chatId, anchorSeq, before = 2, after = 2, maxGapMs = 10 * 60 * 1000 } = {}) {
|
|
364
|
+
if (!this.available || this.closed || !chatId) return [];
|
|
365
|
+
const anchor = this.db
|
|
366
|
+
.prepare(
|
|
367
|
+
`SELECT * FROM events
|
|
368
|
+
WHERE bot_id = ? AND chat_id = ? AND seq = ? AND time_basis != 'unknown'`,
|
|
369
|
+
)
|
|
370
|
+
.get(this.botId, String(chatId), Number(anchorSeq) || 0);
|
|
371
|
+
if (!anchor) return [];
|
|
372
|
+
|
|
373
|
+
const cols = `seq, id, kind, chat_id, msg_id, user_id, user_name, content, at, time_basis, msg_idx, ref_msg_idx, quoted`;
|
|
374
|
+
const prev = this.db
|
|
375
|
+
.prepare(
|
|
376
|
+
`SELECT ${cols} FROM events
|
|
377
|
+
WHERE bot_id = ? AND chat_id = ? AND time_basis != 'unknown'
|
|
378
|
+
AND at >= ? AND seq < ?
|
|
379
|
+
ORDER BY seq DESC LIMIT ?`,
|
|
380
|
+
)
|
|
381
|
+
.all(this.botId, String(chatId), anchor.at - maxGapMs, anchor.seq, before);
|
|
382
|
+
const next = this.db
|
|
383
|
+
.prepare(
|
|
384
|
+
`SELECT ${cols} FROM events
|
|
385
|
+
WHERE bot_id = ? AND chat_id = ? AND time_basis != 'unknown'
|
|
386
|
+
AND at <= ? AND seq > ?
|
|
387
|
+
ORDER BY seq ASC LIMIT ?`,
|
|
388
|
+
)
|
|
389
|
+
.all(this.botId, String(chatId), anchor.at + maxGapMs, anchor.seq, after);
|
|
390
|
+
|
|
391
|
+
return [...prev.reverse(), { ...anchor, hit: true }, ...next].map((r) => ({
|
|
392
|
+
seq: r.seq,
|
|
393
|
+
id: r.id,
|
|
394
|
+
kind: r.kind,
|
|
395
|
+
chatId: r.chat_id,
|
|
396
|
+
msgId: r.msg_id,
|
|
397
|
+
userId: r.user_id,
|
|
398
|
+
userName: r.user_name,
|
|
399
|
+
at: r.at,
|
|
400
|
+
timeBasis: r.time_basis,
|
|
401
|
+
msgIdx: r.msg_idx,
|
|
402
|
+
refMsgIdx: r.ref_msg_idx,
|
|
403
|
+
quoted: r.quoted,
|
|
404
|
+
content: contentOf(r),
|
|
405
|
+
role: r.hit ? 'hit' : 'neighbor',
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** 按 id 取一条(含正文) */
|
|
410
|
+
get(id) {
|
|
411
|
+
if (!this.available || this.closed) return null;
|
|
412
|
+
return this.db.prepare('SELECT * FROM events WHERE id = ?').get(String(id ?? '')) ?? null;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** 某会话最近的若干条,按时间正序返回 */
|
|
416
|
+
recent({ chatId, limit = 50, beforeSeq } = {}) {
|
|
417
|
+
if (!this.available || this.closed || !chatId) return [];
|
|
418
|
+
const lim = Math.min(Math.max(Number(limit) || 50, 1), 500);
|
|
419
|
+
const rows = beforeSeq
|
|
420
|
+
? this.db
|
|
421
|
+
.prepare(
|
|
422
|
+
`SELECT seq, id, kind, chat_id, user_id, user_name, content, at, time_basis, msg_idx, quoted
|
|
423
|
+
FROM events WHERE bot_id = ? AND chat_id = ? AND seq < ?
|
|
424
|
+
ORDER BY seq DESC LIMIT ?`,
|
|
425
|
+
)
|
|
426
|
+
.all(this.botId, String(chatId), Number(beforeSeq), lim)
|
|
427
|
+
: this.db
|
|
428
|
+
.prepare(
|
|
429
|
+
`SELECT seq, id, kind, chat_id, user_id, user_name, content, at, time_basis, msg_idx, quoted
|
|
430
|
+
FROM events WHERE bot_id = ? AND chat_id = ?
|
|
431
|
+
ORDER BY seq DESC LIMIT ?`,
|
|
432
|
+
)
|
|
433
|
+
.all(this.botId, String(chatId), lim);
|
|
434
|
+
return rows.reverse().map((r) => ({
|
|
435
|
+
seq: r.seq,
|
|
436
|
+
id: r.id,
|
|
437
|
+
kind: r.kind,
|
|
438
|
+
chatId: r.chat_id,
|
|
439
|
+
userId: r.user_id,
|
|
440
|
+
userName: r.user_name,
|
|
441
|
+
at: r.at,
|
|
442
|
+
timeBasis: r.time_basis,
|
|
443
|
+
msgIdx: r.msg_idx,
|
|
444
|
+
quoted: r.quoted,
|
|
445
|
+
content: contentOf(r),
|
|
446
|
+
}));
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** 面板用:总量、时间范围、保留策略 */
|
|
450
|
+
stats() {
|
|
451
|
+
if (!this.available || this.closed) {
|
|
452
|
+
return { available: false, total: 0, retentionDays: this.retentionDays };
|
|
453
|
+
}
|
|
454
|
+
const row = this.db
|
|
455
|
+
.prepare('SELECT COUNT(*) AS n, MIN(at) AS oldest, MAX(at) AS newest, MAX(seq) AS seqMax FROM events')
|
|
456
|
+
.get();
|
|
457
|
+
const refs = this.db.prepare('SELECT COUNT(*) AS n FROM event_refs WHERE resolved = 0').get();
|
|
458
|
+
return {
|
|
459
|
+
available: true,
|
|
460
|
+
total: Number(row?.n ?? 0),
|
|
461
|
+
oldestAt: Number(row?.oldest ?? 0),
|
|
462
|
+
newestAt: Number(row?.newest ?? 0),
|
|
463
|
+
seqMax: Number(row?.seqMax ?? 0),
|
|
464
|
+
unresolvedRefs: Number(refs?.n ?? 0),
|
|
465
|
+
retentionDays: this.retentionDays,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
close() {
|
|
470
|
+
this.closed = true;
|
|
471
|
+
if (this.pruneTimer) {
|
|
472
|
+
clearInterval(this.pruneTimer);
|
|
473
|
+
this.pruneTimer = null;
|
|
474
|
+
}
|
|
475
|
+
try {
|
|
476
|
+
this.db?.close();
|
|
477
|
+
} catch {
|
|
478
|
+
/* 关不上就算了,进程要退了 */
|
|
479
|
+
}
|
|
480
|
+
this.db = null;
|
|
481
|
+
this.available = false;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** 从行里取正文。优先独立列;老数据(只有 payload)退回解析,解析失败给空串 */
|
|
486
|
+
function contentOf(row) {
|
|
487
|
+
if (typeof row?.content === 'string' && row.content) return row.content;
|
|
488
|
+
try {
|
|
489
|
+
return String(JSON.parse(row?.payload ?? '{}')?.content ?? '');
|
|
490
|
+
} catch {
|
|
491
|
+
return '';
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* @param {object} p
|
|
497
|
+
* @param {string} p.file
|
|
498
|
+
* @param {string} p.botId
|
|
499
|
+
* @param {boolean} [p.enabled]
|
|
500
|
+
* @param {number} [p.retentionDays]
|
|
501
|
+
*/
|
|
502
|
+
export function createLedger({ file, botId, enabled, retentionDays }) {
|
|
503
|
+
return new EventLedger({ file, botId, enabled, retentionDays });
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** 目录存在性检查,测试和面板诊断用 */
|
|
507
|
+
export function ledgerExists(file) {
|
|
508
|
+
try {
|
|
509
|
+
return fs.existsSync(path.resolve(file));
|
|
510
|
+
} catch {
|
|
511
|
+
return false;
|
|
512
|
+
}
|
|
513
|
+
}
|