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/reach.js
ADDED
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 可达性:这个人能不能私聊、这个群能不能主动推。
|
|
3
|
+
* ==============================================
|
|
4
|
+
*
|
|
5
|
+
* 为什么需要单独一个模块:官方**没有查好友列表的接口**(sitemap 里 `friend`
|
|
6
|
+
* 只有两个事件页,没有任何 API)。但官方会把变化**推**过来,所以状态能维护,
|
|
7
|
+
* 只是「接入之前就已经存在的关系」永远是未知的。
|
|
8
|
+
*
|
|
9
|
+
* ── 两层开关,不是一层 ──
|
|
10
|
+
*
|
|
11
|
+
* 很多人以为「加了好友就能发私聊」。官方文档里 `C2C_MSG_REJECT` 的原话是:
|
|
12
|
+
*
|
|
13
|
+
* > 用户在机器人资料卡手动关闭「主动消息」推送时触发。
|
|
14
|
+
* > **关闭后机器人无法向该用户发送主动消息。**
|
|
15
|
+
*
|
|
16
|
+
* 也就是说:**有好友 ≠ 能主动发**。用户可以在资料卡里单独关掉推送。
|
|
17
|
+
* 所以这里必须分两列存,判定时先看好友、再看推送开关。
|
|
18
|
+
*
|
|
19
|
+
* 群聊同理,`GROUP_MSG_REJECT` / `GROUP_MSG_RECEIVE` 是**群的属性**
|
|
20
|
+
* (事件体只有 `group_openid` + `op_member_openid`,没有目标用户),
|
|
21
|
+
* 所以群维度单独一张表,不挂在人身上。
|
|
22
|
+
*
|
|
23
|
+
* ── 三个值,不是两个 ──
|
|
24
|
+
*
|
|
25
|
+
* `1` 已知可以、`0` 已知不可以、`null` 未知。
|
|
26
|
+
*
|
|
27
|
+
* **`null` 绝不能当 `0` 用**:群里聊过的人很可能早就加了好友,只是还没私聊过。
|
|
28
|
+
* 把未知当不可,会让主动搭话永远开不了口;把未知当可,最多发失败一次
|
|
29
|
+
* ——两害相权,未知应该放行,失败再记下来。
|
|
30
|
+
*
|
|
31
|
+
* ── 存 JSON 不存 SQLite ──
|
|
32
|
+
*
|
|
33
|
+
* 一个机器人几百个好友,几十 KB,读多写少。框架里已有 `quota.json`
|
|
34
|
+
* 的先例(`outbound.js` 的 QuotaLedger),同一套「内存缓存 + 延迟落盘」,
|
|
35
|
+
* 不引迁移、能手改、出问题能直接删。
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import fs from 'node:fs/promises';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { createLogger } from './logger.js';
|
|
41
|
+
|
|
42
|
+
const log = createLogger('reach');
|
|
43
|
+
|
|
44
|
+
/** 落盘延迟。好友变动是低频事件,攒一下再写,避免连续事件写多次盘 */
|
|
45
|
+
const SAVE_DELAY_MS = 500;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 用户档案条数上限。正常部署远远到不了,纯粹防「事件风暴把文件写爆」。
|
|
49
|
+
* 超了按 `updatedAt` 丢最旧的。
|
|
50
|
+
*/
|
|
51
|
+
const MAX_USERS = 5000;
|
|
52
|
+
|
|
53
|
+
/** 值归一:只认 1 / 0 / null,别的一律当未知 */
|
|
54
|
+
function tri(v) {
|
|
55
|
+
if (v === 1 || v === true) return 1;
|
|
56
|
+
if (v === 0 || v === false) return 0;
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* @param {object} p
|
|
62
|
+
* @param {string} p.file reach.json 的路径(每鸟一份)
|
|
63
|
+
*/
|
|
64
|
+
export function createReach({ file }) {
|
|
65
|
+
/** @type {{users: Record<string, object>, groups: Record<string, object>}|null} */
|
|
66
|
+
let data = null;
|
|
67
|
+
let dirty = false;
|
|
68
|
+
let timer = null;
|
|
69
|
+
|
|
70
|
+
async function load() {
|
|
71
|
+
if (data) return data;
|
|
72
|
+
try {
|
|
73
|
+
const raw = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
74
|
+
const users = raw?.users && typeof raw.users === 'object' ? raw.users : {};
|
|
75
|
+
// 手改文件写错类型时不至于让判定崩掉,一律归一成 1 / 0 / null
|
|
76
|
+
for (const u of Object.values(users)) {
|
|
77
|
+
if (!u || typeof u !== 'object') continue;
|
|
78
|
+
u.friend = tri(u.friend);
|
|
79
|
+
u.c2cPush = tri(u.c2cPush);
|
|
80
|
+
}
|
|
81
|
+
const groups = raw?.groups && typeof raw.groups === 'object' ? raw.groups : {};
|
|
82
|
+
for (const g of Object.values(groups)) {
|
|
83
|
+
if (!g || typeof g !== 'object') continue;
|
|
84
|
+
g.push = tri(g.push);
|
|
85
|
+
}
|
|
86
|
+
data = { users, groups };
|
|
87
|
+
} catch {
|
|
88
|
+
data = { users: {}, groups: {} };
|
|
89
|
+
}
|
|
90
|
+
return data;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function scheduleSave() {
|
|
94
|
+
dirty = true;
|
|
95
|
+
if (timer) return;
|
|
96
|
+
timer = setTimeout(() => {
|
|
97
|
+
timer = null;
|
|
98
|
+
flush().catch((e) => log.warn('可达性写盘失败:', e.message));
|
|
99
|
+
}, SAVE_DELAY_MS);
|
|
100
|
+
timer.unref?.();
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function flush() {
|
|
104
|
+
if (timer) {
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
timer = null;
|
|
107
|
+
}
|
|
108
|
+
if (!dirty || !data) return;
|
|
109
|
+
dirty = false;
|
|
110
|
+
try {
|
|
111
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
112
|
+
await fs.writeFile(file, JSON.stringify(data, null, 2), 'utf8');
|
|
113
|
+
} catch (e) {
|
|
114
|
+
log.warn('可达性写盘失败:', e.message);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 取或建一个用户档案 */
|
|
119
|
+
function slot(users, openid) {
|
|
120
|
+
const id = String(openid ?? '').trim();
|
|
121
|
+
if (!id) return null;
|
|
122
|
+
users[id] ??= { friend: null, c2cPush: null, unionOpenid: '', evidence: '', updatedAt: 0 };
|
|
123
|
+
return users[id];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 超出上限时按 updatedAt 丢最旧的 */
|
|
127
|
+
function trim(users) {
|
|
128
|
+
const ids = Object.keys(users);
|
|
129
|
+
if (ids.length <= MAX_USERS) return;
|
|
130
|
+
ids
|
|
131
|
+
.sort((a, b) => (users[a].updatedAt ?? 0) - (users[b].updatedAt ?? 0))
|
|
132
|
+
.slice(0, ids.length - MAX_USERS)
|
|
133
|
+
.forEach((id) => delete users[id]);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
/**
|
|
138
|
+
* `FRIEND_ADD`:用户加了机器人。
|
|
139
|
+
*
|
|
140
|
+
* 这是**唯一能拿到 `union_openid` 的地方**(消息事件里也可能有,但经常是空串)。
|
|
141
|
+
* 顺手存下来,将来跨机器人关联人物时用得上。
|
|
142
|
+
*/
|
|
143
|
+
async noteFriendAdd({ openid, unionOpenid = '', scene = 0 } = {}) {
|
|
144
|
+
const d = await load();
|
|
145
|
+
const u = slot(d.users, openid);
|
|
146
|
+
if (!u) return false;
|
|
147
|
+
u.friend = 1;
|
|
148
|
+
// 空串不覆盖已有的值——消息事件里 union_openid 常常是空的
|
|
149
|
+
if (unionOpenid) u.unionOpenid = String(unionOpenid);
|
|
150
|
+
u.evidence = `friend_add:${scene}`;
|
|
151
|
+
u.updatedAt = Date.now();
|
|
152
|
+
trim(d.users);
|
|
153
|
+
scheduleSave();
|
|
154
|
+
return true;
|
|
155
|
+
},
|
|
156
|
+
|
|
157
|
+
/** `FRIEND_DEL`:用户删了机器人。这时私聊一定发不出去 */
|
|
158
|
+
async noteFriendDel({ openid } = {}) {
|
|
159
|
+
const d = await load();
|
|
160
|
+
const u = slot(d.users, openid);
|
|
161
|
+
if (!u) return false;
|
|
162
|
+
u.friend = 0;
|
|
163
|
+
u.evidence = 'friend_del';
|
|
164
|
+
u.updatedAt = Date.now();
|
|
165
|
+
scheduleSave();
|
|
166
|
+
return true;
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* `C2C_MSG_REJECT` / `C2C_MSG_RECEIVE`:用户在资料卡里关/开了主动推送。
|
|
171
|
+
*
|
|
172
|
+
* 注意这是**独立于好友关系**的一层:好友还在,但推送被关了,照样发不出去。
|
|
173
|
+
*/
|
|
174
|
+
async noteC2cPush({ openid, on } = {}) {
|
|
175
|
+
const d = await load();
|
|
176
|
+
const u = slot(d.users, openid);
|
|
177
|
+
if (!u) return false;
|
|
178
|
+
u.c2cPush = on ? 1 : 0;
|
|
179
|
+
u.evidence = on ? 'c2c_msg_receive' : 'c2c_msg_reject';
|
|
180
|
+
u.updatedAt = Date.now();
|
|
181
|
+
scheduleSave();
|
|
182
|
+
return true;
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* `GROUP_MSG_REJECT` / `GROUP_MSG_RECEIVE`:群管理员关/开了机器人通知。
|
|
187
|
+
*
|
|
188
|
+
* 存群维度,不存人——事件体里只有 `group_openid` 和操作人,
|
|
189
|
+
* 没有「这个开关对谁生效」的概念,它就是对整个群生效。
|
|
190
|
+
*/
|
|
191
|
+
async noteGroupPush({ groupOpenid, on, by = '' } = {}) {
|
|
192
|
+
const gid = String(groupOpenid ?? '').trim();
|
|
193
|
+
if (!gid) return false;
|
|
194
|
+
const d = await load();
|
|
195
|
+
d.groups[gid] = {
|
|
196
|
+
push: on ? 1 : 0,
|
|
197
|
+
by: String(by ?? ''),
|
|
198
|
+
evidence: on ? 'group_msg_receive' : 'group_msg_reject',
|
|
199
|
+
updatedAt: Date.now(),
|
|
200
|
+
};
|
|
201
|
+
scheduleSave();
|
|
202
|
+
return true;
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 收到这个人的单聊消息 —— 最强证据:他能发,说明好友一定在。
|
|
207
|
+
*
|
|
208
|
+
* 同时把 `c2cPush` 留空而不是置 1:他主动发消息不代表他允许机器人主动推。
|
|
209
|
+
*/
|
|
210
|
+
async noteC2cObserved({ openid, unionOpenid = '' } = {}) {
|
|
211
|
+
const d = await load();
|
|
212
|
+
const u = slot(d.users, openid);
|
|
213
|
+
if (!u) return false;
|
|
214
|
+
u.friend = 1;
|
|
215
|
+
if (unionOpenid) u.unionOpenid = String(unionOpenid);
|
|
216
|
+
u.evidence = 'observed_c2c';
|
|
217
|
+
u.updatedAt = Date.now();
|
|
218
|
+
scheduleSave();
|
|
219
|
+
return true;
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 主动发送失败了 —— 按 QQ 业务错误码更新状态。
|
|
224
|
+
*
|
|
225
|
+
* 这些码是官方文档里明写的(`/v2/users/{user_openid}/messages` 错误码表),
|
|
226
|
+
* 所以能**精确判定**,不用再靠人猜:
|
|
227
|
+
*
|
|
228
|
+
* 40054004 无好友关系 → is_friend = 0
|
|
229
|
+
* 40054006 验证好友关系失败 → 不改状态(官方建议「重试」)
|
|
230
|
+
* 40054013 用户拒收消息 → c2c_push = 0(对应 C2C_MSG_REJECT)
|
|
231
|
+
*
|
|
232
|
+
* 其它码(频控 40034100、内容违规 40034006、审核……)**一律不改状态**:
|
|
233
|
+
* 它们跟「这个人能不能收到」无关,改了就等于一次抖动永久封掉一个人。
|
|
234
|
+
*
|
|
235
|
+
* @param {object} p
|
|
236
|
+
* @param {string} p.openid
|
|
237
|
+
* @param {number} p.qqCode QQ 业务错误码(不是 HTTP 状态码)
|
|
238
|
+
*/
|
|
239
|
+
async noteSendError({ openid, qqCode } = {}) {
|
|
240
|
+
const d = await load();
|
|
241
|
+
const u = slot(d.users, openid);
|
|
242
|
+
if (!u) return false;
|
|
243
|
+
const code = Number(qqCode) || 0;
|
|
244
|
+
u.lastFailAt = Date.now();
|
|
245
|
+
u.lastFailCode = code;
|
|
246
|
+
|
|
247
|
+
if (code === 40054004) {
|
|
248
|
+
u.friend = 0;
|
|
249
|
+
u.evidence = 'send:no-friend';
|
|
250
|
+
} else if (code === 40054013) {
|
|
251
|
+
u.c2cPush = 0;
|
|
252
|
+
u.evidence = 'send:rejected';
|
|
253
|
+
}
|
|
254
|
+
// 40054006 只记时间:官方自己都说「请重试」
|
|
255
|
+
u.updatedAt = Date.now();
|
|
256
|
+
scheduleSave();
|
|
257
|
+
return true;
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
/** 主动发送成功了 —— 反证「可达」 */
|
|
261
|
+
async noteSendOk({ openid } = {}) {
|
|
262
|
+
const d = await load();
|
|
263
|
+
const u = slot(d.users, openid);
|
|
264
|
+
if (!u) return false;
|
|
265
|
+
u.friend = 1;
|
|
266
|
+
u.lastOkAt = Date.now();
|
|
267
|
+
scheduleSave();
|
|
268
|
+
return true;
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* 人工标注 —— 面板上手动纠正状态。
|
|
273
|
+
*
|
|
274
|
+
* 为什么必须有这个口子:好友关系的事件是**增量**的,接入之前就存在的关系
|
|
275
|
+
* 永远拿不到事件,状态会一直停在 `null`。线上真出问题时,运维只能靠
|
|
276
|
+
* 「手动发一条试试」来试,而失败的证据又常常和网络/限流混在一起分不清。
|
|
277
|
+
* 与其让人去改 JSON,不如给一个能校验的入口。
|
|
278
|
+
*
|
|
279
|
+
* `friend` / `c2cPush` 传 `undefined` 表示「这项不动」,传 `null` 表示「重置为未知」。
|
|
280
|
+
* 两者语义不同,所以不能用 `??` 或真值判断。
|
|
281
|
+
*
|
|
282
|
+
* @param {object} p
|
|
283
|
+
* @param {string} p.openid
|
|
284
|
+
* @param {1|0|null} [p.friend]
|
|
285
|
+
* @param {1|0|null} [p.c2cPush]
|
|
286
|
+
*/
|
|
287
|
+
async noteManual({ openid, friend, c2cPush } = {}) {
|
|
288
|
+
const d = await load();
|
|
289
|
+
const u = slot(d.users, openid);
|
|
290
|
+
if (!u) return false;
|
|
291
|
+
if (friend !== undefined) u.friend = tri(friend);
|
|
292
|
+
if (c2cPush !== undefined) u.c2cPush = tri(c2cPush);
|
|
293
|
+
u.evidence = 'manual';
|
|
294
|
+
u.updatedAt = Date.now();
|
|
295
|
+
trim(d.users);
|
|
296
|
+
scheduleSave();
|
|
297
|
+
return true;
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* 群维度的人工标注。`push` 传 `undefined` 不动、`null` 重置为未知。
|
|
302
|
+
*
|
|
303
|
+
* @param {object} p
|
|
304
|
+
* @param {string} p.groupOpenid
|
|
305
|
+
* @param {1|0|null} [p.push]
|
|
306
|
+
*/
|
|
307
|
+
async noteGroupManual({ groupOpenid, push } = {}) {
|
|
308
|
+
const gid = String(groupOpenid ?? '').trim();
|
|
309
|
+
if (!gid) return false;
|
|
310
|
+
const d = await load();
|
|
311
|
+
const g = d.groups[gid] ?? { push: null, by: '', evidence: '', updatedAt: 0 };
|
|
312
|
+
if (push !== undefined) g.push = tri(push);
|
|
313
|
+
g.evidence = 'manual';
|
|
314
|
+
g.updatedAt = Date.now();
|
|
315
|
+
d.groups[gid] = g;
|
|
316
|
+
scheduleSave();
|
|
317
|
+
return true;
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* 能不能给这个人发私聊。
|
|
322
|
+
*
|
|
323
|
+
* @returns {{verdict:'yes'|'no'|'unknown', reason?:string}}
|
|
324
|
+
*/
|
|
325
|
+
async canC2C(openid) {
|
|
326
|
+
const d = await load();
|
|
327
|
+
const u = d.users[String(openid ?? '').trim()];
|
|
328
|
+
if (!u) return { verdict: 'unknown' };
|
|
329
|
+
if (u.friend === 0) return { verdict: 'no', reason: 'reach:not-friend' };
|
|
330
|
+
if (u.c2cPush === 0) return { verdict: 'no', reason: 'reach:push-off' };
|
|
331
|
+
if (u.friend === 1) return { verdict: 'yes' };
|
|
332
|
+
return { verdict: 'unknown' };
|
|
333
|
+
},
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* 能不能往这个群主动推。
|
|
337
|
+
*
|
|
338
|
+
* @returns {{verdict:'yes'|'no'|'unknown', reason?:string}}
|
|
339
|
+
*/
|
|
340
|
+
async canGroupPush(groupOpenid) {
|
|
341
|
+
const d = await load();
|
|
342
|
+
const g = d.groups[String(groupOpenid ?? '').trim()];
|
|
343
|
+
if (!g) return { verdict: 'unknown' };
|
|
344
|
+
if (g.push === 0) return { verdict: 'no', reason: 'reach:group-push-off' };
|
|
345
|
+
return { verdict: 'yes' };
|
|
346
|
+
},
|
|
347
|
+
|
|
348
|
+
/** 单个人档案,面板用 */
|
|
349
|
+
async get(openid) {
|
|
350
|
+
const d = await load();
|
|
351
|
+
return d.users[String(openid ?? '').trim()] ?? null;
|
|
352
|
+
},
|
|
353
|
+
|
|
354
|
+
/** 面板用:全量快照(只读副本) */
|
|
355
|
+
async snapshot() {
|
|
356
|
+
const d = await load();
|
|
357
|
+
return { users: { ...d.users }, groups: { ...d.groups } };
|
|
358
|
+
},
|
|
359
|
+
|
|
360
|
+
flush,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export { tri };
|
package/src/runtime.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* 敏感字段名。**导出给事件账本用**——账本要存原始报文,
|
|
3
|
+
* 但它和日志的取舍不同:日志里整个 `ext` 都可以扔(没人靠日志排查 msg_idx),
|
|
4
|
+
* 账本必须保留 `msg_idx` / `ref_msg_idx`(引用边全靠它),只能删 `auth_token`。
|
|
5
|
+
* 所以共用这份名单,各写各的遍历。
|
|
6
|
+
*/
|
|
7
|
+
export const SENSITIVE_KEY = /^(auth_token|token|access_token|secret|client_secret|cookie|password)$/i;
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* 事件报文脱敏。
|
|
@@ -72,6 +78,7 @@ export class BotMetrics {
|
|
|
72
78
|
dedup: 0, // 被去重拦掉
|
|
73
79
|
limited: 0, // 被限流拦掉
|
|
74
80
|
rejected: 0, // 被白名单拦掉
|
|
81
|
+
gated: 0, // 被暂停闸拦掉(这只鸟或这个会话被暂停了)
|
|
75
82
|
ignored: 0, // 收到但没有任何插件回应,静默丢弃
|
|
76
83
|
};
|
|
77
84
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 事件账本的表结构
|
|
3
|
+
* ==================
|
|
4
|
+
*
|
|
5
|
+
* 和 `schema.js`(统计库)分开:两套表在**两个文件**里,各有各的 `user_version`。
|
|
6
|
+
* 混在一份里的话,加账本表就得动统计库的版本号,升级路径互相牵连。
|
|
7
|
+
*
|
|
8
|
+
* ── 为什么不复用 `messages` 表 ──
|
|
9
|
+
*
|
|
10
|
+
* `messages` 是「计数」,只记谁在哪天发了多少条,**刻意不存正文**。
|
|
11
|
+
* 账本是「发生过什么」,必须存正文——这两件事的隐私承诺相反,
|
|
12
|
+
* 合表等于把统计库那句「不存消息正文」变成假话。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const EVENTS_SCHEMA_VERSION = 1;
|
|
16
|
+
|
|
17
|
+
const V1 = `
|
|
18
|
+
CREATE TABLE IF NOT EXISTS events (
|
|
19
|
+
seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
20
|
+
id TEXT NOT NULL UNIQUE,
|
|
21
|
+
bot_id TEXT NOT NULL,
|
|
22
|
+
kind TEXT NOT NULL,
|
|
23
|
+
chat_id TEXT NOT NULL,
|
|
24
|
+
msg_id TEXT,
|
|
25
|
+
user_id TEXT,
|
|
26
|
+
user_name TEXT,
|
|
27
|
+
-- 正文单独一列,不是「payload 里反正有」。
|
|
28
|
+
-- 两个原因:① payload 有大小上限,截断时正文不能跟着丢;
|
|
29
|
+
-- ② 将来要做全文检索(FTS/LIKE)必须有独立列,藏在 JSON 里查不了。
|
|
30
|
+
content TEXT NOT NULL DEFAULT '',
|
|
31
|
+
at INTEGER NOT NULL,
|
|
32
|
+
observed_at INTEGER NOT NULL,
|
|
33
|
+
time_basis TEXT NOT NULL,
|
|
34
|
+
dedupe TEXT NOT NULL UNIQUE,
|
|
35
|
+
msg_idx TEXT,
|
|
36
|
+
ref_msg_idx TEXT,
|
|
37
|
+
union_openid TEXT,
|
|
38
|
+
quoted TEXT,
|
|
39
|
+
payload TEXT NOT NULL
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
-- 邻文查询:按会话 + 时间取前后几条。
|
|
43
|
+
-- 把 time_basis 放进索引的 WHERE,是为了**不让 unknown 进索引**——
|
|
44
|
+
-- 时间不可靠的事件拼出来的上下文看着合理但是错的,比没有更糟。
|
|
45
|
+
CREATE INDEX IF NOT EXISTS events_timeline
|
|
46
|
+
ON events(bot_id, chat_id, at, seq) WHERE time_basis != 'unknown';
|
|
47
|
+
|
|
48
|
+
CREATE INDEX IF NOT EXISTS events_user ON events(bot_id, user_id, at);
|
|
49
|
+
CREATE INDEX IF NOT EXISTS events_at ON events(at);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE IF NOT EXISTS event_refs (
|
|
52
|
+
from_id TEXT NOT NULL,
|
|
53
|
+
to_id TEXT,
|
|
54
|
+
to_msg_id TEXT,
|
|
55
|
+
kind TEXT NOT NULL,
|
|
56
|
+
resolved INTEGER NOT NULL DEFAULT 0
|
|
57
|
+
);
|
|
58
|
+
CREATE INDEX IF NOT EXISTS refs_from ON event_refs(from_id);
|
|
59
|
+
CREATE INDEX IF NOT EXISTS refs_to ON event_refs(to_id) WHERE resolved = 1;
|
|
60
|
+
`;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {object} db DatabaseSync
|
|
64
|
+
*/
|
|
65
|
+
export function migrateEvents(db) {
|
|
66
|
+
const cur = Number(db.prepare('PRAGMA user_version').get()?.user_version ?? 0);
|
|
67
|
+
if (cur >= EVENTS_SCHEMA_VERSION) return { from: cur, to: cur };
|
|
68
|
+
if (cur < 1) db.exec(V1);
|
|
69
|
+
db.exec(`PRAGMA user_version = ${EVENTS_SCHEMA_VERSION}`);
|
|
70
|
+
return { from: cur, to: EVENTS_SCHEMA_VERSION };
|
|
71
|
+
}
|
package/src/web/qq-call.js
CHANGED
|
@@ -29,7 +29,7 @@ const RISK_BY_API = (() => {
|
|
|
29
29
|
|
|
30
30
|
/** 官方错误码 → 人话。AI 拿到的是这句,不是一串数字 */
|
|
31
31
|
const ERR_HINTS = {
|
|
32
|
-
40034128: '
|
|
32
|
+
40034128: '被动回复超限(群聊 5 分钟 / 5 次,单聊 60 分钟 / 4 次)。等用户再说一句,或改用主动消息',
|
|
33
33
|
40034100: '被官方限流了,稍后再试',
|
|
34
34
|
40034008: '消息内容不合法(markdown 和 content 不能同时给)',
|
|
35
35
|
304023: '消息已进入审核(这不是失败,会正常发出)',
|