qq-bot-roost 0.9.11 → 1.0.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 +13 -2
- package/package.json +3 -3
- package/src/bot.js +21 -2
- package/src/identity-commands.js +99 -0
- package/src/identity.js +886 -0
- package/src/ledger.js +52 -15
- package/src/main.js +46 -0
- package/src/message.js +44 -1
- package/src/paths.js +11 -0
- package/src/plugin/context.js +55 -1
- package/src/plugin/loader.js +4 -1
- package/src/plugin/protocol.js +12 -0
- package/src/storage/index.js +83 -0
- package/src/web/server.js +242 -0
- package/web/app.js +240 -0
- package/web/index.html +97 -0
package/src/identity.js
ADDED
|
@@ -0,0 +1,886 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 身份对照表:QQ号 / 群号 ↔ openid
|
|
3
|
+
* ================================
|
|
4
|
+
*
|
|
5
|
+
* 官方给的是 `openid`——**每个 AppID 一套**,换机器人就全变。QQ 号和群号是稳定的,
|
|
6
|
+
* 但官方报文里一个都不给,也没有任何查询接口。所以只能人工指认一次,框架负责记住、查得快。
|
|
7
|
+
*
|
|
8
|
+
* 为什么这张表必须**全局**(`data/identity.json`,不跟着机器人走):
|
|
9
|
+
* 它存在的全部意义就是横跨机器人。放进 `data/bots/<id>/` 就等于换机器人那一刻又对不上,
|
|
10
|
+
* 而这个功能要解决的正是那件事。`paths.js` 里「数据按机器人隔离」的注释要记得改,
|
|
11
|
+
* 否则下一个人会把它当 bug 修掉。
|
|
12
|
+
*
|
|
13
|
+
* 三条设计约束:
|
|
14
|
+
*
|
|
15
|
+
* 1. **openid 永远是发送凭据,号码只是识别标签。** 任何地方都不许拿号码去调接口。
|
|
16
|
+
* 2. **未绑定不落盘。** 没有记录就是没绑,查询返回 `bound:false`。否则每来一个新人都要写一次盘。
|
|
17
|
+
* 3. **写入是「读-合并-写」。** 多个机器人进程共享这一个文件,后写的不能把先写的抹掉,
|
|
18
|
+
* 所以落盘前重读一遍,claims 按 `(botId, openid)` 做并集。
|
|
19
|
+
*
|
|
20
|
+
* 数据结构(v1):
|
|
21
|
+
*
|
|
22
|
+
* {
|
|
23
|
+
* "version": 1,
|
|
24
|
+
* "updatedAt": 1793500000000,
|
|
25
|
+
* "users": { "10001": { uin, status, name, note, claims: [...] } },
|
|
26
|
+
* "groups": { "763366431": { groupUin, status, name, note, claims: [...] } }
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* claim 里的 `botId` 允许空串:旧机器人已经从配置里删掉时,账号迁移页绑出来的记录
|
|
30
|
+
* 没有归属可填。别名计算**不看 botId**,所以照样读得到旧记忆。
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import fs from 'node:fs/promises';
|
|
34
|
+
import { createLogger } from './logger.js';
|
|
35
|
+
import { writeJsonAtomic, readJsonSafe } from './fsutil.js';
|
|
36
|
+
import { PATHS } from './paths.js';
|
|
37
|
+
|
|
38
|
+
const log = createLogger('identity');
|
|
39
|
+
|
|
40
|
+
/** 文件格式版本。结构变了就 +1,读取时按版本迁移 */
|
|
41
|
+
export const DATA_VERSION = 1;
|
|
42
|
+
|
|
43
|
+
/** 错误码,插件和面板都按它判断,别去匹配 message */
|
|
44
|
+
export const UNBOUND = 'IDENTITY_UNBOUND';
|
|
45
|
+
export const CONFLICT = 'IDENTITY_CONFLICT';
|
|
46
|
+
export const INVALID = 'IDENTITY_INVALID';
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 号码:QQ 号 5–12 位,群号同量级。一律按字符串存(超大数不会变科学计数法)。
|
|
50
|
+
*
|
|
51
|
+
* **首位不能是 0**:QQ号 / 群号都不以 0 开头,而多打一个 0 是很常见的粘贴错误。
|
|
52
|
+
* 放过去的话会建出一条**查不到的死绑定**——`openidOf('12345')` 找不到
|
|
53
|
+
* 以 `'012345'` 存的记录,面板上看是「已绑定」,用起来就是「没绑」。
|
|
54
|
+
* 挡在入口比事后排查便宜得多。
|
|
55
|
+
*
|
|
56
|
+
* 这条规则 `dsh-roost/src/account.js` 里有一份**逐字相同**的副本
|
|
57
|
+
* (跨包不能直接 import)。改这里必须同步改那边,否则会出现
|
|
58
|
+
* 「这边说没绑、那边说已绑」的鬼打墙。
|
|
59
|
+
*/
|
|
60
|
+
const UIN_RE = /^[1-9]\d{4,11}$/;
|
|
61
|
+
|
|
62
|
+
/** 同一个号码最多挂多少条 openid。正常部署(几个机器人)远远到不了,纯防御 */
|
|
63
|
+
const MAX_CLAIMS = 50;
|
|
64
|
+
|
|
65
|
+
/** 已载入时,多久检查一次「文件被别的进程改过」。面板每个请求都会 load(),必须节流 */
|
|
66
|
+
const REFRESH_MS = 2000;
|
|
67
|
+
|
|
68
|
+
/** 单个 openid 的长度上限。不校验字符集——官方哪天换格式,这里不该先炸 */
|
|
69
|
+
function isOpenid(s) {
|
|
70
|
+
return typeof s === 'string' && s.length > 0 && s.length <= 64 && !/\s/.test(s);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 从任意形态的键里把号码抠出来。
|
|
75
|
+
*
|
|
76
|
+
* 旧记忆的键不止一种写法,实测见过:
|
|
77
|
+
*
|
|
78
|
+
* aiocqhttp:GroupMessage:763366431 ← AstrBot 的会话 id
|
|
79
|
+
* group_763366431 ← 带前缀
|
|
80
|
+
* 763366431 ← 纯号码
|
|
81
|
+
* 5559767753895AA8456F848BCC489133 ← 本框架的 openid,**不是号码**
|
|
82
|
+
*
|
|
83
|
+
* 最后一种必须排除:openid 里那串 `5559767753895` 有 13 位数字,
|
|
84
|
+
* 不排除的话会被当成群号,直接把两个不相干的会话并到一起。
|
|
85
|
+
*
|
|
86
|
+
* @returns {string} 号码,认不出来返回空串
|
|
87
|
+
*/
|
|
88
|
+
export function uinFromKey(key) {
|
|
89
|
+
const s = String(key ?? '').trim();
|
|
90
|
+
if (!s || s.length > 64) return '';
|
|
91
|
+
// 整串就是 openid(32 位 hex)——不是号码
|
|
92
|
+
if (/^[0-9a-f]{32}$/i.test(s)) return '';
|
|
93
|
+
|
|
94
|
+
const parts = s.split(/[^0-9a-zA-Z]+/).filter(Boolean);
|
|
95
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
96
|
+
const p = parts[i];
|
|
97
|
+
if (UIN_RE.test(p)) return p;
|
|
98
|
+
// 段里已经是 openid 了,别再往前猜——`x:5559767753895AA84…` 里没有号码
|
|
99
|
+
if (/^[0-9a-f]{32}$/i.test(p)) return '';
|
|
100
|
+
}
|
|
101
|
+
return '';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 号码是否合法 */
|
|
105
|
+
export function isValidUin(v) {
|
|
106
|
+
return UIN_RE.test(String(v ?? '').trim());
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 「没绑定」的错误。
|
|
111
|
+
*
|
|
112
|
+
* message 是**给管理员看的中文**:插件把它直接回给用户也不会显得像崩了。
|
|
113
|
+
* 真正的边界不在这句提示上——插件想自己处理就用 `has()` 先判断,那条路不抛。
|
|
114
|
+
*/
|
|
115
|
+
export function unboundError({ kind = 'user', uin = '', botId = '' } = {}) {
|
|
116
|
+
const what = kind === 'group' ? '群号' : 'QQ号';
|
|
117
|
+
const bot = botId ? `机器人「${botId}」` : '当前机器人';
|
|
118
|
+
const err = new Error(`${what} ${uin || '(空)'} 还没绑定到${bot},请管理员在面板「身份」页绑定`);
|
|
119
|
+
err.code = UNBOUND;
|
|
120
|
+
err.kind = kind;
|
|
121
|
+
err.uin = String(uin ?? '');
|
|
122
|
+
err.botId = String(botId ?? '');
|
|
123
|
+
return err;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function invalidError(msg, extra = {}) {
|
|
127
|
+
const err = new Error(msg);
|
|
128
|
+
err.code = INVALID;
|
|
129
|
+
Object.assign(err, extra);
|
|
130
|
+
return err;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function conflictError(msg, extra = {}) {
|
|
134
|
+
const err = new Error(msg);
|
|
135
|
+
err.code = CONFLICT;
|
|
136
|
+
Object.assign(err, extra);
|
|
137
|
+
return err;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 空记录 */
|
|
141
|
+
function newRecord(kind, uin, now) {
|
|
142
|
+
const base = {
|
|
143
|
+
status: 'active',
|
|
144
|
+
name: '',
|
|
145
|
+
note: '',
|
|
146
|
+
createdAt: now,
|
|
147
|
+
updatedAt: now,
|
|
148
|
+
/** 状态(active/disabled)最后变更的时间,和 `updatedAt` 分开——改备注不该影响它 */
|
|
149
|
+
statusUpdatedAt: now,
|
|
150
|
+
verifiedBy: 'panel',
|
|
151
|
+
verifiedAt: now,
|
|
152
|
+
claims: [],
|
|
153
|
+
};
|
|
154
|
+
return kind === 'group' ? { groupUin: uin, ...base } : { uin, ...base };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** 把磁盘上读到的记录补齐字段,别让半个旧文件把后面的逻辑带崩 */
|
|
158
|
+
function normalizeRecord(kind, uin, raw) {
|
|
159
|
+
const r = raw && typeof raw === 'object' ? raw : {};
|
|
160
|
+
const claims = Array.isArray(r.claims)
|
|
161
|
+
? r.claims
|
|
162
|
+
.filter((c) => c && isOpenid(c.openid))
|
|
163
|
+
.map((c) => ({
|
|
164
|
+
botId: String(c.botId ?? ''),
|
|
165
|
+
openid: String(c.openid),
|
|
166
|
+
state: c.state === 'revoked' ? 'revoked' : 'active',
|
|
167
|
+
/**
|
|
168
|
+
* **这条必须原样带回来。**
|
|
169
|
+
*
|
|
170
|
+
* 漏了它,合并时只能退回记录级 `updatedAt`,而那个值会被
|
|
171
|
+
* `setNote` / `bind` 这类无关操作顶新——于是「解绑」会被
|
|
172
|
+
* 一个只是改了备注的旧进程覆盖回去。而且丢得毫无声息:
|
|
173
|
+
* 内存里看着是对的,一重启就失效。
|
|
174
|
+
*/
|
|
175
|
+
updatedAt: Number(c.updatedAt) || 0,
|
|
176
|
+
firstSeenAt: Number(c.firstSeenAt) || 0,
|
|
177
|
+
lastSeenAt: Number(c.lastSeenAt) || 0,
|
|
178
|
+
seenCount: Number(c.seenCount) || 0,
|
|
179
|
+
scopes: Array.isArray(c.scopes) ? c.scopes.map(String).slice(0, 20) : [],
|
|
180
|
+
}))
|
|
181
|
+
: [];
|
|
182
|
+
return {
|
|
183
|
+
...(kind === 'group' ? { groupUin: uin } : { uin }),
|
|
184
|
+
status: r.status === 'disabled' ? 'disabled' : 'active',
|
|
185
|
+
name: String(r.name ?? ''),
|
|
186
|
+
note: String(r.note ?? ''),
|
|
187
|
+
createdAt: Number(r.createdAt) || 0,
|
|
188
|
+
updatedAt: Number(r.updatedAt) || 0,
|
|
189
|
+
/**
|
|
190
|
+
* 老数据没有这个字段,回退到 `updatedAt`。
|
|
191
|
+
* 没有它的话,`setNote` 这种无关操作会把 `updatedAt` 顶新,
|
|
192
|
+
* 于是一个只是改了备注的旧进程能把「停用」覆盖回「启用」——
|
|
193
|
+
* 而停用是为了让这个号码的记忆读不到,翻回 active 等于白停。
|
|
194
|
+
*/
|
|
195
|
+
statusUpdatedAt: Number(r.statusUpdatedAt) || Number(r.updatedAt) || 0,
|
|
196
|
+
verifiedBy: String(r.verifiedBy ?? 'panel'),
|
|
197
|
+
verifiedAt: Number(r.verifiedAt) || 0,
|
|
198
|
+
claims,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export class IdentityStore {
|
|
203
|
+
/** @param {string} file 全局身份表路径(`PATHS.identity`) */
|
|
204
|
+
constructor(file) {
|
|
205
|
+
this.file = file;
|
|
206
|
+
this.data = null;
|
|
207
|
+
this.mtimeMs = 0;
|
|
208
|
+
this.dirty = false;
|
|
209
|
+
this.timer = null;
|
|
210
|
+
/** 写串行化:同一进程里两次 bind 并发时,别让后一次覆盖前一次 */
|
|
211
|
+
this.chain = Promise.resolve();
|
|
212
|
+
/** `${botId}\u0000${openid}` -> uin,热路径只查它 */
|
|
213
|
+
this.userIndex = new Map();
|
|
214
|
+
this.groupIndex = new Map();
|
|
215
|
+
/** 不限机器人的反查:openid -> uin(`uinOf()` 用) */
|
|
216
|
+
this.userByOpenid = new Map();
|
|
217
|
+
this.groupByOpenid = new Map();
|
|
218
|
+
this.loaded = false;
|
|
219
|
+
/** 正在载入的 Promise,并发 load() 共用 */
|
|
220
|
+
this.loading = null;
|
|
221
|
+
/** 上次检查磁盘 mtime 的时间(节流用) */
|
|
222
|
+
this.checkedAt = 0;
|
|
223
|
+
/** 正在写盘:这段时间不许重载,否则会覆盖还没落盘的本地改动 */
|
|
224
|
+
this.flushing = false;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* ---------------- 载入 ---------------- */
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 载入。并发调用合并成一次——面板的多个请求可能同时触发,
|
|
231
|
+
* 各跑一遍会把彼此的 `this.data` 覆盖掉。
|
|
232
|
+
*
|
|
233
|
+
* 已经载入过时会**顺带检查文件有没有被别的进程改过**(节流)。
|
|
234
|
+
* 不检查的话:一只鸟的面板绑完,另一只鸟(另一个进程)永远看不到,
|
|
235
|
+
* 而文档写的是「多个机器人进程共享这一个文件」——那只对写成立。
|
|
236
|
+
*/
|
|
237
|
+
async load() {
|
|
238
|
+
if (this.loaded) {
|
|
239
|
+
await this.#refreshIfChanged();
|
|
240
|
+
return this.data;
|
|
241
|
+
}
|
|
242
|
+
this.loading ??= this.#loadOnce().finally(() => {
|
|
243
|
+
this.loading = null;
|
|
244
|
+
});
|
|
245
|
+
return this.loading;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 磁盘上的文件比内存新就重新读一遍。
|
|
250
|
+
*
|
|
251
|
+
* 两条不做的事:
|
|
252
|
+
* · **有没落盘的本地改动时不动**(`dirty` / 定时器还在)——
|
|
253
|
+
* 重新载入会把刚 bind 的东西丢掉,比看不到别人的更新严重得多。
|
|
254
|
+
* · **节流**:`load()` 会被面板的每个请求调到,每次都 stat 一下没必要。
|
|
255
|
+
*/
|
|
256
|
+
async #refreshIfChanged() {
|
|
257
|
+
if (this.dirty || this.timer || this.flushing) return;
|
|
258
|
+
const now = Date.now();
|
|
259
|
+
if (now - (this.checkedAt || 0) < REFRESH_MS) return;
|
|
260
|
+
this.checkedAt = now;
|
|
261
|
+
try {
|
|
262
|
+
const m = await this.#mtime();
|
|
263
|
+
if (!m || m === this.mtimeMs) return;
|
|
264
|
+
const raw = await readJsonSafe(this.file, (msg) => log.error(msg));
|
|
265
|
+
this.data = this.#normalizeRoot(raw);
|
|
266
|
+
this.mtimeMs = m;
|
|
267
|
+
this.#rebuildIndexes();
|
|
268
|
+
log.debug?.('身份表被别的进程改过,已重新载入');
|
|
269
|
+
} catch (e) {
|
|
270
|
+
/** 读不到就维持内存里这份,下次再试——不能因为别人写坏了就让自己不可用 */
|
|
271
|
+
log.warn?.(`身份表重载失败,继续用内存里的:${e.message}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
async #loadOnce() {
|
|
276
|
+
const raw = await readJsonSafe(this.file, (m) => log.error(m));
|
|
277
|
+
this.data = this.#normalizeRoot(raw);
|
|
278
|
+
this.mtimeMs = await this.#mtime();
|
|
279
|
+
this.#rebuildIndexes();
|
|
280
|
+
this.loaded = true;
|
|
281
|
+
const n = Object.keys(this.data.users).length + Object.keys(this.data.groups).length;
|
|
282
|
+
if (n) log.info(`身份表已载入:${Object.keys(this.data.users).length} 人 / ${Object.keys(this.data.groups).length} 群`);
|
|
283
|
+
return this.data;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
#normalizeRoot(raw) {
|
|
287
|
+
const root = raw && typeof raw === 'object' ? raw : {};
|
|
288
|
+
const out = {
|
|
289
|
+
version: DATA_VERSION,
|
|
290
|
+
updatedAt: Number(root.updatedAt) || 0,
|
|
291
|
+
users: {},
|
|
292
|
+
groups: {},
|
|
293
|
+
};
|
|
294
|
+
/**
|
|
295
|
+
* 号码不合法的记录**丢掉**,但要喊一声。
|
|
296
|
+
*
|
|
297
|
+
* 静默 `continue` 的后果很重:下一次落盘会把整份文件重写,
|
|
298
|
+
* 那条记录就**永久没了**,而用户那边只会看到「我明明绑过」。
|
|
299
|
+
* 号码规则改过一次(首位不能是 0),所以这条不是假想。
|
|
300
|
+
*/
|
|
301
|
+
const dropped = [];
|
|
302
|
+
for (const [uin, rec] of Object.entries(root.users ?? {})) {
|
|
303
|
+
if (!isValidUin(uin)) {
|
|
304
|
+
dropped.push(uin);
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
out.users[uin] = normalizeRecord('user', uin, rec);
|
|
308
|
+
}
|
|
309
|
+
for (const [uin, rec] of Object.entries(root.groups ?? {})) {
|
|
310
|
+
if (!isValidUin(uin)) {
|
|
311
|
+
dropped.push(uin);
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
out.groups[uin] = normalizeRecord('group', uin, rec);
|
|
315
|
+
}
|
|
316
|
+
if (dropped.length) {
|
|
317
|
+
log.warn(
|
|
318
|
+
`身份表里有 ${dropped.length} 个号码不合法,已跳过(下次落盘会丢掉它们):${dropped.slice(0, 5).join('、')}`,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
async #mtime() {
|
|
325
|
+
try {
|
|
326
|
+
return (await fs.stat(this.file)).mtimeMs;
|
|
327
|
+
} catch {
|
|
328
|
+
return 0;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 重建索引。
|
|
334
|
+
*
|
|
335
|
+
* 只索引 `status === 'active'` 且 `claim.state === 'active'` 的条目——
|
|
336
|
+
* 停用和解绑过的必须查不到,否则「解绑」等于没解。
|
|
337
|
+
*/
|
|
338
|
+
#rebuildIndexes() {
|
|
339
|
+
this.userIndex.clear();
|
|
340
|
+
this.groupIndex.clear();
|
|
341
|
+
this.userByOpenid.clear();
|
|
342
|
+
this.groupByOpenid.clear();
|
|
343
|
+
const fill = (recs, index, byOpenid) => {
|
|
344
|
+
for (const [uin, rec] of Object.entries(recs)) {
|
|
345
|
+
if (rec.status !== 'active') continue;
|
|
346
|
+
for (const c of rec.claims) {
|
|
347
|
+
if (c.state !== 'active') continue;
|
|
348
|
+
index.set(`${c.botId}\u0000${c.openid}`, uin);
|
|
349
|
+
// 反查不限机器人:同一个 openid 只可能属于一个号码,先到先得
|
|
350
|
+
if (!byOpenid.has(c.openid)) byOpenid.set(c.openid, uin);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
fill(this.data.users, this.userIndex, this.userByOpenid);
|
|
355
|
+
fill(this.data.groups, this.groupIndex, this.groupByOpenid);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/* ---------------- 查询(同步,热路径) ---------------- */
|
|
359
|
+
|
|
360
|
+
/** 这个人是谁。未绑定返回 null */
|
|
361
|
+
resolveUser(botId, openid) {
|
|
362
|
+
if (!this.loaded || !openid) return null;
|
|
363
|
+
const uin = this.userIndex.get(`${botId ?? ''}\u0000${openid}`);
|
|
364
|
+
if (!uin) return null;
|
|
365
|
+
return { uin, identityId: `u:${uin}`, record: this.data.users[uin] ?? null };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** 这个群是哪个群。未绑定返回 null */
|
|
369
|
+
resolveGroup(botId, groupOpenid) {
|
|
370
|
+
if (!this.loaded || !groupOpenid) return null;
|
|
371
|
+
const uin = this.groupIndex.get(`${botId ?? ''}\u0000${groupOpenid}`);
|
|
372
|
+
if (!uin) return null;
|
|
373
|
+
return { uin, identityId: `g:${uin}`, record: this.data.groups[uin] ?? null };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** openid → QQ号,**不限机器人**。查不到返回空串 */
|
|
377
|
+
uinOf(openid) {
|
|
378
|
+
return this.userByOpenid.get(String(openid ?? '')) ?? '';
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** group_openid → 群号,不限机器人 */
|
|
382
|
+
groupUinOf(groupOpenid) {
|
|
383
|
+
return this.groupByOpenid.get(String(groupOpenid ?? '')) ?? '';
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** 号码是否有有效绑定 */
|
|
387
|
+
has(kind, uin) {
|
|
388
|
+
const rec = this.#rec(kind, uin);
|
|
389
|
+
return Boolean(rec && rec.status === 'active' && rec.claims.some((c) => c.state === 'active'));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** 某个机器人下这个号码对应的 openid。取 `lastSeenAt` 最新的那条 */
|
|
393
|
+
openidOf(botId, uin) {
|
|
394
|
+
const rec = this.#rec('user', uin);
|
|
395
|
+
return this.#pickClaim(rec, botId)?.openid ?? '';
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
groupOpenidOf(botId, groupUin) {
|
|
399
|
+
const rec = this.#rec('group', groupUin);
|
|
400
|
+
return this.#pickClaim(rec, botId)?.openid ?? '';
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* 和 `openidOf` 一样,但**没绑定就抛**。
|
|
405
|
+
*
|
|
406
|
+
* 插件要的正是这个:`ctx.identity.openidOf('10001')` 未绑定时不该返回空串
|
|
407
|
+
* 让调用方自己去猜,而是一句能直接回给管理员的话。
|
|
408
|
+
*/
|
|
409
|
+
requireOpenid({ kind = 'user', botId = '', uin = '' } = {}) {
|
|
410
|
+
const openid = kind === 'group' ? this.groupOpenidOf(botId, uin) : this.openidOf(botId, uin);
|
|
411
|
+
if (!openid) throw unboundError({ kind, uin, botId });
|
|
412
|
+
return openid;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
#rec(kind, uin) {
|
|
416
|
+
if (!this.loaded) return null;
|
|
417
|
+
const key = String(uin ?? '').trim();
|
|
418
|
+
return kind === 'group' ? (this.data.groups[key] ?? null) : (this.data.users[key] ?? null);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* 挑「当前生效」的那条 claim。
|
|
423
|
+
*
|
|
424
|
+
* 按 `lastSeenAt` 取最新(真正在用的那条),相等时**后追加的优先**——
|
|
425
|
+
* 同一毫秒里连绑两条(换 AppID 后立刻补绑)时时间戳一样,
|
|
426
|
+
* 用 `>` 会选到旧的那条,发消息就发到已经作废的 openid 上了。
|
|
427
|
+
*/
|
|
428
|
+
#pickClaim(rec, botId) {
|
|
429
|
+
if (!rec || rec.status !== 'active') return null;
|
|
430
|
+
const bot = String(botId ?? '');
|
|
431
|
+
let best = null;
|
|
432
|
+
for (const c of rec.claims) {
|
|
433
|
+
if (c.state !== 'active' || String(c.botId) !== bot) continue;
|
|
434
|
+
if (!best || (c.lastSeenAt || 0) >= (best.lastSeenAt || 0)) best = c;
|
|
435
|
+
}
|
|
436
|
+
return best;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* 读旧记忆用的备用键:号码本身 + 这个号码名下的所有 openid。
|
|
441
|
+
*
|
|
442
|
+
* 为什么不带 botId 过滤:换机器人之后要读的正是**旧机器人**那些键。
|
|
443
|
+
* 只取 active 的 claim——解绑过的不该再被读到。
|
|
444
|
+
*
|
|
445
|
+
* @param {'user'|'group'} kind
|
|
446
|
+
* @param {string} uin
|
|
447
|
+
* @param {string} [current] 当前会话的 chatId,已经在读,不必重复
|
|
448
|
+
* @returns {string[]}
|
|
449
|
+
*/
|
|
450
|
+
aliasesOf(kind, uin, current = '') {
|
|
451
|
+
const rec = this.#rec(kind, uin);
|
|
452
|
+
const id = String(uin ?? '').trim();
|
|
453
|
+
/**
|
|
454
|
+
* **停用**的记录一条都不给。
|
|
455
|
+
*
|
|
456
|
+
* `resolveUser` / `openidOf` 都按 `status === 'active'` 判,只有这里不判的话,
|
|
457
|
+
* 会出现「停用了还读得到旧记忆」——面板上查不到这个号码,AI 却照样
|
|
458
|
+
* 把它的记忆读出来,两处说法不一致,排查时根本想不到是这里。
|
|
459
|
+
*/
|
|
460
|
+
if (rec && rec.status !== 'active') return [];
|
|
461
|
+
/**
|
|
462
|
+
* 没绑过的号码**仍然返回号码本身**:旧记忆的键可能就是一个纯号码
|
|
463
|
+
* (早期手工配的、别的框架迁过来的),那时候根本不会有 claim。
|
|
464
|
+
* 返回空等于把那份记忆永久锁死。
|
|
465
|
+
*/
|
|
466
|
+
const out = [];
|
|
467
|
+
const push = (v) => {
|
|
468
|
+
const s = String(v ?? '').trim();
|
|
469
|
+
if (s && s !== String(current ?? '') && !out.includes(s)) out.push(s);
|
|
470
|
+
};
|
|
471
|
+
push(id);
|
|
472
|
+
if (rec) for (const c of rec.claims) if (c.state === 'active') push(c.openid);
|
|
473
|
+
return out;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/* ---------------- 写入 ---------------- */
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* 绑定。已存在同一个 `(botId, openid)` 属于别人时抛冲突,除非 `force`。
|
|
480
|
+
*
|
|
481
|
+
* `force` 的语义是「改绑」:旧记录那条 claim 标 `revoked`,留痕,不删。
|
|
482
|
+
*
|
|
483
|
+
* **绑定 = 启用**:对一条 `disabled` 的记录调 `bind` 会把它改回 `active`。
|
|
484
|
+
* 这是刻意的——`has()` / `resolveUser` 都要求 `status === 'active'`,
|
|
485
|
+
* 不这么做会出现「明明绑了却查不到」的鬼打墙。
|
|
486
|
+
* 副作用要知道:给已停用的号码**补绑一条新 openid**(换 AppID 的常规操作)
|
|
487
|
+
* 会顺带把这个号码重新启用,记忆又开始被读到。面板上会提示。
|
|
488
|
+
*/
|
|
489
|
+
async bind({ kind = 'user', uin, openid, botId = '', force = false } = {}) {
|
|
490
|
+
const id = String(uin ?? '').trim();
|
|
491
|
+
if (!isValidUin(id)) throw invalidError(`号码不合法:${uin}(要 5–12 位数字)`, { kind, uin: id });
|
|
492
|
+
if (!isOpenid(openid)) throw invalidError('openid 不合法(空、过长或含空白)', { kind, uin: id });
|
|
493
|
+
await this.load();
|
|
494
|
+
|
|
495
|
+
const table = kind === 'group' ? this.data.groups : this.data.users;
|
|
496
|
+
const now = Date.now();
|
|
497
|
+
|
|
498
|
+
// 这个 openid 是不是已经属于别的号码了
|
|
499
|
+
const owner = this.#ownerOf(kind, botId, openid);
|
|
500
|
+
if (owner && owner !== id) {
|
|
501
|
+
if (!force) {
|
|
502
|
+
throw conflictError(`这个 openid 已经绑到 ${kind === 'group' ? '群号' : 'QQ号'} ${owner} 了`, {
|
|
503
|
+
kind,
|
|
504
|
+
uin: id,
|
|
505
|
+
openid,
|
|
506
|
+
owner,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
const old = table[owner];
|
|
510
|
+
const c = old?.claims.find((x) => x.botId === String(botId ?? '') && x.openid === openid);
|
|
511
|
+
if (c) {
|
|
512
|
+
c.state = 'revoked';
|
|
513
|
+
c.updatedAt = now;
|
|
514
|
+
old.updatedAt = now;
|
|
515
|
+
}
|
|
516
|
+
log.info(`改绑:openid ${openid} 从 ${owner} 转到 ${id}(${botId || '无归属'})`);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
const rec = table[id] ?? (table[id] = newRecord(kind, id, now));
|
|
520
|
+
const sameBot = rec.claims.filter((c) => c.botId === String(botId ?? '') && c.state === 'active');
|
|
521
|
+
const hit = rec.claims.find((c) => c.botId === String(botId ?? '') && c.openid === openid);
|
|
522
|
+
|
|
523
|
+
if (hit) {
|
|
524
|
+
hit.state = 'active';
|
|
525
|
+
hit.updatedAt = now;
|
|
526
|
+
hit.lastSeenAt = now;
|
|
527
|
+
} else {
|
|
528
|
+
/**
|
|
529
|
+
* 同一个机器人下已经有一条 active 的旧 openid:**不撤销**。
|
|
530
|
+
*
|
|
531
|
+
* 换 AppID 之后旧的那条是读旧记忆唯一的线索(别名靠它),
|
|
532
|
+
* 撤掉就等于「绑完记忆全没了」。所以只追加,旧的留着。
|
|
533
|
+
*/
|
|
534
|
+
if (sameBot.length >= MAX_CLAIMS) {
|
|
535
|
+
throw invalidError(`同一个机器人下最多 ${MAX_CLAIMS} 条 openid,先解绑一些`, { kind, uin: id });
|
|
536
|
+
}
|
|
537
|
+
rec.claims.push({
|
|
538
|
+
botId: String(botId ?? ''),
|
|
539
|
+
openid,
|
|
540
|
+
state: 'active',
|
|
541
|
+
/**
|
|
542
|
+
* 每条 claim 自己记时间。
|
|
543
|
+
*
|
|
544
|
+
* 合并时**按 claim 比新旧**,不是整条记录比一次。整条比的话:
|
|
545
|
+
* 进程 B 给同一个号码追加一条新 openid → B 的 `updatedAt` 变新 →
|
|
546
|
+
* 整条记录按 B 的版本走 → B 内存里那条「还没收到解绑通知」的
|
|
547
|
+
* active claim 就把 A 刚做的解绑覆盖回去。解绑看起来成功了,重启又活了。
|
|
548
|
+
*/
|
|
549
|
+
updatedAt: now,
|
|
550
|
+
firstSeenAt: now,
|
|
551
|
+
lastSeenAt: now,
|
|
552
|
+
seenCount: 0,
|
|
553
|
+
scopes: [],
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** 绑定等于「这个号码是活的」,所以状态时间也要跟着走(否则会被停用覆盖回去) */
|
|
558
|
+
rec.status = 'active';
|
|
559
|
+
rec.statusUpdatedAt = now;
|
|
560
|
+
rec.updatedAt = now;
|
|
561
|
+
rec.verifiedBy = 'panel';
|
|
562
|
+
rec.verifiedAt = now;
|
|
563
|
+
if (!rec.createdAt) rec.createdAt = now;
|
|
564
|
+
|
|
565
|
+
this.#rebuildIndexes();
|
|
566
|
+
this.#scheduleSave();
|
|
567
|
+
return rec;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/** 解绑:把这个号码在某个机器人下的 claim 全标 revoked,记录本身留着 */
|
|
571
|
+
async unbind({ kind = 'user', uin, botId = '' } = {}) {
|
|
572
|
+
const id = String(uin ?? '').trim();
|
|
573
|
+
await this.load();
|
|
574
|
+
const rec = this.#rec(kind, id);
|
|
575
|
+
if (!rec) return null;
|
|
576
|
+
const now = Date.now();
|
|
577
|
+
let n = 0;
|
|
578
|
+
for (const c of rec.claims) {
|
|
579
|
+
if (String(c.botId) === String(botId ?? '') && c.state === 'active') {
|
|
580
|
+
c.state = 'revoked';
|
|
581
|
+
c.updatedAt = now;
|
|
582
|
+
n += 1;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
if (n) {
|
|
586
|
+
rec.updatedAt = now;
|
|
587
|
+
this.#rebuildIndexes();
|
|
588
|
+
this.#scheduleSave();
|
|
589
|
+
}
|
|
590
|
+
return { record: rec, revoked: n };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** 启用 / 停用整条记录 */
|
|
594
|
+
async setStatus({ kind = 'user', uin, status } = {}) {
|
|
595
|
+
const id = String(uin ?? '').trim();
|
|
596
|
+
await this.load();
|
|
597
|
+
const rec = this.#rec(kind, id);
|
|
598
|
+
if (!rec) return null;
|
|
599
|
+
rec.status = status === 'disabled' ? 'disabled' : 'active';
|
|
600
|
+
rec.updatedAt = Date.now();
|
|
601
|
+
rec.statusUpdatedAt = rec.updatedAt;
|
|
602
|
+
this.#rebuildIndexes();
|
|
603
|
+
this.#scheduleSave();
|
|
604
|
+
return rec;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** 改备注名 / 备注 */
|
|
608
|
+
async setNote({ kind = 'user', uin, name, note } = {}) {
|
|
609
|
+
const id = String(uin ?? '').trim();
|
|
610
|
+
await this.load();
|
|
611
|
+
const rec = this.#rec(kind, id);
|
|
612
|
+
if (!rec) return null;
|
|
613
|
+
if (name !== undefined) rec.name = String(name ?? '').slice(0, 64);
|
|
614
|
+
if (note !== undefined) rec.note = String(note ?? '').slice(0, 200);
|
|
615
|
+
rec.updatedAt = Date.now();
|
|
616
|
+
this.#scheduleSave();
|
|
617
|
+
return rec;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* 记一次「见过」。
|
|
622
|
+
*
|
|
623
|
+
* 只改内存、只标 dirty,由 300ms 的延迟落盘合并成一次写——
|
|
624
|
+
* 每条群消息都写盘的话,磁盘会被这一个文件打满。
|
|
625
|
+
*
|
|
626
|
+
* 再加一层 60 秒节流:同一条 claim 一分钟内只更新一次。
|
|
627
|
+
* 热聊的群一分钟几百条消息,没有这层的话 `dirty` 会被反复点亮,
|
|
628
|
+
* 落盘就变成「一直在写」。`lastSeenAt` 精确到分钟对面板和 `openidOf` 都够用。
|
|
629
|
+
*/
|
|
630
|
+
touch({ kind = 'user', uin, botId = '', scope = '' } = {}) {
|
|
631
|
+
if (!this.loaded) return;
|
|
632
|
+
const rec = this.#rec(kind, uin);
|
|
633
|
+
if (!rec) return;
|
|
634
|
+
const c = rec.claims.find((x) => x.botId === String(botId ?? '') && x.state === 'active');
|
|
635
|
+
if (!c) return;
|
|
636
|
+
const now = Date.now();
|
|
637
|
+
const fresh = now - (c.lastSeenAt || 0) < 60_000;
|
|
638
|
+
if (fresh && (!scope || c.scopes.includes(scope))) return;
|
|
639
|
+
if (!fresh) {
|
|
640
|
+
c.lastSeenAt = now;
|
|
641
|
+
c.seenCount = (c.seenCount || 0) + 1;
|
|
642
|
+
}
|
|
643
|
+
if (scope && !c.scopes.includes(scope)) {
|
|
644
|
+
c.scopes = [...c.scopes, scope].slice(-20);
|
|
645
|
+
}
|
|
646
|
+
this.#scheduleSave();
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
#ownerOf(kind, botId, openid) {
|
|
650
|
+
const table = kind === 'group' ? this.data.groups : this.data.users;
|
|
651
|
+
for (const [uin, rec] of Object.entries(table)) {
|
|
652
|
+
if (rec.claims.some((c) => c.state === 'active' && c.botId === String(botId ?? '') && c.openid === openid)) {
|
|
653
|
+
return uin;
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return '';
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/* ---------------- 列表 ---------------- */
|
|
660
|
+
|
|
661
|
+
/** 全部记录(面板用)。不给 kind 就人+群一起给 */
|
|
662
|
+
async list(kind = '') {
|
|
663
|
+
await this.load();
|
|
664
|
+
const out = [];
|
|
665
|
+
if (kind !== 'group') {
|
|
666
|
+
for (const [uin, rec] of Object.entries(this.data.users)) out.push({ kind: 'user', uin, ...rec });
|
|
667
|
+
}
|
|
668
|
+
if (kind !== 'user') {
|
|
669
|
+
for (const [uin, rec] of Object.entries(this.data.groups)) out.push({ kind: 'group', uin, ...rec });
|
|
670
|
+
}
|
|
671
|
+
return out;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/** 某个机器人下已经绑过的 openid 集合,面板算「未识别」时用 */
|
|
675
|
+
async boundOpenids(botId) {
|
|
676
|
+
await this.load();
|
|
677
|
+
const users = new Set();
|
|
678
|
+
const groups = new Set();
|
|
679
|
+
const walk = (recs, set) => {
|
|
680
|
+
for (const rec of Object.values(recs)) {
|
|
681
|
+
for (const c of rec.claims) {
|
|
682
|
+
if (c.state === 'active' && String(c.botId) === String(botId ?? '')) set.add(c.openid);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
walk(this.data.users, users);
|
|
687
|
+
walk(this.data.groups, groups);
|
|
688
|
+
return { users, groups };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/* ---------------- 落盘 ---------------- */
|
|
692
|
+
|
|
693
|
+
#scheduleSave() {
|
|
694
|
+
this.dirty = true;
|
|
695
|
+
if (this.timer) return;
|
|
696
|
+
this.timer = setTimeout(() => {
|
|
697
|
+
this.timer = null;
|
|
698
|
+
this.flush().catch((e) => log.error(`身份表写盘失败:${e.message}`));
|
|
699
|
+
}, 300);
|
|
700
|
+
this.timer.unref?.();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** 立即落盘。写前重读磁盘,把别的进程写进去的 claims 并进来 */
|
|
704
|
+
async flush() {
|
|
705
|
+
/**
|
|
706
|
+
* 两个回调都要给:上一次写盘失败会让 `chain` 变成 rejected,
|
|
707
|
+
* 之后每次 `.then(fn)` 都直接跳过——身份表就永远不再落盘了。
|
|
708
|
+
*/
|
|
709
|
+
this.chain = this.chain.then(
|
|
710
|
+
() => this.#flushOnce(),
|
|
711
|
+
() => this.#flushOnce()
|
|
712
|
+
);
|
|
713
|
+
return this.chain;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async #flushOnce() {
|
|
717
|
+
if (!this.dirty || !this.data) return;
|
|
718
|
+
this.dirty = false;
|
|
719
|
+
/**
|
|
720
|
+
* 写盘期间不许刷新。
|
|
721
|
+
*
|
|
722
|
+
* `dirty` 已经置 false 了,但改动还在内存里、还没写到盘上。
|
|
723
|
+
* 这段时间里如果 `load()` 走了「磁盘更新了就重载」,本地改动会被覆盖掉。
|
|
724
|
+
* 窗口只有一次 stat 那么宽,压测没复现过,但路径是通的——
|
|
725
|
+
* 而且丢的是用户刚做的绑定,代价太高。
|
|
726
|
+
*/
|
|
727
|
+
this.flushing = true;
|
|
728
|
+
try {
|
|
729
|
+
// 写前重读:别的机器人进程可能刚写过
|
|
730
|
+
const diskMtime = await this.#mtime();
|
|
731
|
+
if (diskMtime && diskMtime !== this.mtimeMs) {
|
|
732
|
+
const raw = await readJsonSafe(this.file, (m) => log.error(m));
|
|
733
|
+
const disk = this.#normalizeRoot(raw);
|
|
734
|
+
this.data = this.#merge(this.data, disk);
|
|
735
|
+
this.#rebuildIndexes();
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
this.data.version = DATA_VERSION;
|
|
739
|
+
this.data.updatedAt = Date.now();
|
|
740
|
+
await writeJsonAtomic(this.file, this.data);
|
|
741
|
+
this.mtimeMs = await this.#mtime();
|
|
742
|
+
} catch (e) {
|
|
743
|
+
this.dirty = true;
|
|
744
|
+
throw e;
|
|
745
|
+
} finally {
|
|
746
|
+
this.flushing = false;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* 合并两份数据。
|
|
752
|
+
*
|
|
753
|
+
* claims 按 `(botId, openid)` 做**并集**——这是多进程不丢更新的关键:
|
|
754
|
+
* 两只鸟同时绑不同的人,后写的那次不会把先写的抹掉。
|
|
755
|
+
* 记录级字段(name/note/status)取 `updatedAt` 较新的那份。
|
|
756
|
+
*
|
|
757
|
+
* **重复的 claim 按它自己的 `updatedAt` 比新旧,平局时撤销优先。**
|
|
758
|
+
*
|
|
759
|
+
* 为什么不能整条记录比一次(以前的做法):进程 B 给同一个号码**追加**
|
|
760
|
+
* 一条新 openid(换机器人时的标准操作)→ B 的 `updatedAt` 变新 →
|
|
761
|
+
* 整条记录按 B 的版本走 → B 内存里那条「还没收到解绑通知」的 active claim
|
|
762
|
+
* 把 A 刚做的解绑覆盖回去。表现是「解绑成功了,重启又活了」。
|
|
763
|
+
*
|
|
764
|
+
* 平局偏向 revoked 也是刻意的:撤销是**删除类**动作,宁可多撤销一条
|
|
765
|
+
* (用户再绑一次就好),也不能让一个已经删掉的绑定自己复活。
|
|
766
|
+
*/
|
|
767
|
+
#merge(mine, theirs) {
|
|
768
|
+
const out = { version: DATA_VERSION, updatedAt: Math.max(mine.updatedAt || 0, theirs.updatedAt || 0), users: {}, groups: {} };
|
|
769
|
+
/**
|
|
770
|
+
* 老数据没有 claim 级时间时的回退。
|
|
771
|
+
*
|
|
772
|
+
* **不能退回记录级 `updatedAt`**:那个值会被 `setNote` / `bind` 这类
|
|
773
|
+
* 无关操作顶新,于是「解绑」会被一个只是改了备注的旧进程盖回去。
|
|
774
|
+
* 退回这条 claim 自己的活动时间(`lastSeenAt` / `firstSeenAt`),
|
|
775
|
+
* 都没有就返回 0——两边都 0 时会走「平局撤销优先」,
|
|
776
|
+
* 和这个函数本来的取向一致:删除类动作不该被自动撤销。
|
|
777
|
+
*/
|
|
778
|
+
/**
|
|
779
|
+
* **不用 `lastSeenAt` 回退。**
|
|
780
|
+
*
|
|
781
|
+
* `lastSeenAt` 是心跳:`touch()` 每收到一条消息就写它,不写 `updatedAt`。
|
|
782
|
+
* 对没有 `updatedAt` 的老 claim,一个陈旧进程只要**收到这个人一条消息**,
|
|
783
|
+
* 就能把 `lastSeenAt` 顶到解绑之后,merge 时 active 赢过 revoked——
|
|
784
|
+
* 表现是「解绑了,那个人一说话又绑回来了」。
|
|
785
|
+
* 只用「状态真正变过的时间」,没有就 0(走平局撤销优先)。
|
|
786
|
+
*/
|
|
787
|
+
const atOf = (c) => Number(c.updatedAt) || Number(c.firstSeenAt) || 0;
|
|
788
|
+
for (const table of ['users', 'groups']) {
|
|
789
|
+
const a = mine[table] ?? {};
|
|
790
|
+
const b = theirs[table] ?? {};
|
|
791
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
792
|
+
for (const uin of keys) {
|
|
793
|
+
const ra = a[uin];
|
|
794
|
+
const rb = b[uin];
|
|
795
|
+
if (!ra) { out[table][uin] = rb; continue; }
|
|
796
|
+
if (!rb) { out[table][uin] = ra; continue; }
|
|
797
|
+
const [first, second] = (rb.updatedAt || 0) > (ra.updatedAt || 0) ? [rb, ra] : [ra, rb];
|
|
798
|
+
const claims = [];
|
|
799
|
+
/** key → 在 `claims` 里的下标 */
|
|
800
|
+
const idx = new Map();
|
|
801
|
+
const take = (c) => {
|
|
802
|
+
const k = `${c.botId}\u0000${c.openid}`;
|
|
803
|
+
const pos = idx.get(k);
|
|
804
|
+
if (pos === undefined) {
|
|
805
|
+
idx.set(k, claims.length);
|
|
806
|
+
claims.push(c);
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const cur = claims[pos];
|
|
810
|
+
const ta = atOf(c);
|
|
811
|
+
const tb = atOf(cur);
|
|
812
|
+
// 新的一侧胜出;时间相同则撤销优先(删除类动作不该被自动撤销)
|
|
813
|
+
const win = ta > tb || (ta === tb && c.state === 'revoked' && cur.state !== 'revoked');
|
|
814
|
+
if (!win) return;
|
|
815
|
+
claims[pos] = c;
|
|
816
|
+
};
|
|
817
|
+
for (const c of first.claims ?? []) take(c);
|
|
818
|
+
for (const c of second.claims ?? []) take(c);
|
|
819
|
+
/**
|
|
820
|
+
* 状态单独合:按 `statusUpdatedAt` 比,不跟记录级 `updatedAt` 走。
|
|
821
|
+
* 平局偏向 `disabled`——停用是为了让这个号码的记忆读不到,
|
|
822
|
+
* 翻回 active 等于白停,比多停一次严重。
|
|
823
|
+
*/
|
|
824
|
+
const sa = Number(ra.statusUpdatedAt) || Number(ra.updatedAt) || 0;
|
|
825
|
+
const sb = Number(rb.statusUpdatedAt) || Number(rb.updatedAt) || 0;
|
|
826
|
+
const status =
|
|
827
|
+
sa === sb
|
|
828
|
+
? ra.status === 'disabled' || rb.status === 'disabled'
|
|
829
|
+
? 'disabled'
|
|
830
|
+
: first.status
|
|
831
|
+
: (sa > sb ? ra : rb).status;
|
|
832
|
+
/**
|
|
833
|
+
* `statusUpdatedAt` 取两边的**最大值**,不能跟着 `first` 走。
|
|
834
|
+
*
|
|
835
|
+
* `first` 是按记录级 `updatedAt` 选的,而那个值会被 `setNote` 顶新。
|
|
836
|
+
* 跟着它走会把停用时间**往回写**(比如从 200 退回 100),
|
|
837
|
+
* 之后任何一个 `statusUpdatedAt` 落在中间(120)的陈旧进程一落盘,
|
|
838
|
+
* 就能把停用翻回 active。时间只进不退,才谈得上单调。
|
|
839
|
+
*/
|
|
840
|
+
out[table][uin] = { ...first, status, statusUpdatedAt: Math.max(sa, sb), claims };
|
|
841
|
+
}
|
|
842
|
+
}
|
|
843
|
+
return out;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/** 观测用 */
|
|
847
|
+
snapshot() {
|
|
848
|
+
return {
|
|
849
|
+
loaded: this.loaded,
|
|
850
|
+
file: this.file,
|
|
851
|
+
users: this.loaded ? Object.keys(this.data.users).length : 0,
|
|
852
|
+
groups: this.loaded ? Object.keys(this.data.groups).length : 0,
|
|
853
|
+
index: this.userIndex.size + this.groupIndex.size,
|
|
854
|
+
dirty: this.dirty,
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/** 退出时把没写的写掉 */
|
|
859
|
+
async close() {
|
|
860
|
+
if (this.timer) {
|
|
861
|
+
clearTimeout(this.timer);
|
|
862
|
+
this.timer = null;
|
|
863
|
+
}
|
|
864
|
+
try {
|
|
865
|
+
await this.flush();
|
|
866
|
+
} catch {
|
|
867
|
+
/* 退出阶段,忽略 */
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* 进程级单例。
|
|
874
|
+
*
|
|
875
|
+
* 一个进程里可能同时跑好几只鸟,但它们共用**同一个** `identity.json`。
|
|
876
|
+
* 每只鸟各建一个实例的话,同一份数据会有好几个内存副本互相覆盖——
|
|
877
|
+
* 虽然 `flush()` 的「写前重读」能兜住,但那是给跨进程准备的兜底,
|
|
878
|
+
* 没必要让同进程也走这条路。
|
|
879
|
+
*
|
|
880
|
+
* 注意:`file` 只在第一次调用时生效,之后忽略。
|
|
881
|
+
*/
|
|
882
|
+
let shared = null;
|
|
883
|
+
export function sharedIdentity(file = PATHS.identity) {
|
|
884
|
+
shared ??= new IdentityStore(file);
|
|
885
|
+
return shared;
|
|
886
|
+
}
|