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/src/gate.js ADDED
@@ -0,0 +1,325 @@
1
+ /**
2
+ * 暂停闸
3
+ * ======
4
+ *
5
+ * 一句话:**任何一只鸟、任何一个会话,都能在不改配置、不重启的前提下闭嘴,
6
+ * 而且面板说得出「为什么不能说」。**
7
+ *
8
+ * 为什么需要它:以前 `grep -rn "paused" src/` 零命中。一只鸟配错了
9
+ * (人格写飞、模型抽风、群选错了),重启之后立刻对几个群开口,**没有刹车**。
10
+ * 唯一能止住的办法是关进程——那连别的鸟一起停了。
11
+ *
12
+ * 三级闸取「与」:任一道关着就不说话。
13
+ *
14
+ * global(进程级)→ bot(每只鸟)→ chat(每个会话)
15
+ * ↘ silence(静默:带 until,到期自动解除)
16
+ *
17
+ * ## 为什么按鸟拆文件
18
+ *
19
+ * 一个进程里可能同时跑好几只鸟,它们共用一个 `gate.json` 的话,
20
+ * `.tmp` + `rename` 只能保证「不会写坏」,**不能保证不丢更新**——
21
+ * 两只鸟同时改,后写的把先写的覆盖掉,表现是「点了暂停没生效」。
22
+ *
23
+ * 所以:`data/gate/global.json` 放全局闸,`data/gate/<botId>.json` 放这只鸟的
24
+ * bot/chat/silence。跨鸟只有全局那一个文件,而且它是低频写,进程内串行化就够。
25
+ *
26
+ * ## 为什么不放在 dsh-roost
27
+ *
28
+ * 闸要拦在 pipeline 里。dsh-roost 停了 bot 还在收消息,闸必须比 dsh-roost 活得久。
29
+ */
30
+
31
+ import path from 'node:path';
32
+ import { readJsonSafe, writeJsonAtomic } from './fsutil.js';
33
+
34
+ /** 空状态。每次读盘都从它展开,字段少一个也不会 undefined */
35
+ const EMPTY = () => ({
36
+ paused: false,
37
+ at: 0,
38
+ by: '',
39
+ reason: '',
40
+ chats: {},
41
+ silence: {},
42
+ });
43
+
44
+ /**
45
+ * @param {object} o
46
+ * @param {string} o.dir 闸文件目录(`data/gate`)
47
+ * @param {string} o.botId 这只鸟的 id
48
+ * @param {(m:string)=>void} [o.log]
49
+ */
50
+ export function createGate({ dir, botId, log = () => {} }) {
51
+ const globalFile = path.join(dir, 'global.json');
52
+ const botFile = path.join(dir, `${botId}.json`);
53
+
54
+ let global = EMPTY();
55
+ let bot = EMPTY();
56
+ let opened = false;
57
+ /** 第一次读盘时文件存不存在,`pauseOnStart` 要用 */
58
+ let botExisted = false;
59
+ let globalExisted = false;
60
+
61
+ /**
62
+ * 写盘串行化。
63
+ *
64
+ * 同一只鸟的两个会话几乎不会同时改闸,但面板连点、插件批量改群闸
65
+ * 是可能并发的。串行化保证「读-改-写」不会互相覆盖。
66
+ */
67
+ let chain = Promise.resolve();
68
+
69
+ /**
70
+ * 读盘。
71
+ *
72
+ * @returns {Promise<{botExisted:boolean, globalExisted:boolean}>}
73
+ * `botExisted=false` 表示这只鸟从来没写过闸文件(首次运行),
74
+ * `gate.pauseOnStart` 靠它判断「要不要自动暂停」——
75
+ * 每次启动都暂停会让日常重启很难受,用户第一反应是「坏了」。
76
+ */
77
+ async function open() {
78
+ if (opened) return { botExisted, globalExisted };
79
+ const [g, b] = await Promise.all([
80
+ readJsonSafe(globalFile, (m) => log(`全局闸:${m}`)),
81
+ readJsonSafe(botFile, (m) => log(`闸状态:${m}`)),
82
+ ]);
83
+ botExisted = b !== null;
84
+ globalExisted = g !== null;
85
+ global = { ...EMPTY(), ...(g ?? {}) };
86
+ bot = { ...EMPTY(), ...(b ?? {}) };
87
+ opened = true;
88
+ return { botExisted, globalExisted };
89
+ }
90
+
91
+ function saveGlobal() {
92
+ const snap = { ...global };
93
+ chain = chain.then(() => writeJsonAtomic(globalFile, snap)).catch((e) => {
94
+ log(`全局闸写盘失败:${e.message}`);
95
+ });
96
+ return chain;
97
+ }
98
+
99
+ function saveBot() {
100
+ const snap = { ...bot };
101
+ chain = chain.then(() => writeJsonAtomic(botFile, snap)).catch((e) => {
102
+ log(`闸状态写盘失败:${e.message}`);
103
+ });
104
+ return chain;
105
+ }
106
+
107
+ /** 把 meta 归一成落盘字段,`by` 用来说明是谁改的(面板 / 插件 id / 命令行) */
108
+ const metaOf = (m = {}) => ({
109
+ at: Date.now(),
110
+ by: String(m.by ?? '').slice(0, 40),
111
+ reason: String(m.reason ?? '').slice(0, 200),
112
+ });
113
+
114
+ /**
115
+ * 这只鸟现在能不能对 `chatId` 说话。
116
+ *
117
+ * **只回答闸的部分**。网关在线、额度、插件启停由 `bot.canSpeak()` 汇总——
118
+ * 那些状态不在闸的职责里,混进来会让这个模块变成「什么都知道一点」。
119
+ *
120
+ * @param {string} chatId
121
+ * @param {number} [now]
122
+ * @returns {{ok:boolean, reasons:{code:string,text:string,until?:number}[]}}
123
+ */
124
+ function check(chatId, now = Date.now()) {
125
+ const reasons = [];
126
+
127
+ if (global.paused) {
128
+ reasons.push({
129
+ code: 'gate:global',
130
+ text: `全局暂停${global.reason ? `(${global.reason})` : ''}`,
131
+ });
132
+ }
133
+ if (bot.paused) {
134
+ reasons.push({
135
+ code: 'gate:bot',
136
+ text: `这只鸟暂停${bot.reason ? `(${bot.reason})` : ''}`,
137
+ });
138
+ }
139
+ if (chatId) {
140
+ const c = bot.chats?.[chatId];
141
+ if (c?.paused) {
142
+ reasons.push({
143
+ code: 'gate:chat',
144
+ text: `这个会话暂停${c.reason ? `(${c.reason})` : ''}`,
145
+ });
146
+ }
147
+ const until = Number(bot.silence?.[chatId] ?? 0);
148
+ if (until > now) {
149
+ const mins = Math.ceil((until - now) / 60_000);
150
+ reasons.push({
151
+ code: 'gate:silence',
152
+ text: `静默中,还剩 ${mins} 分钟`,
153
+ until,
154
+ });
155
+ }
156
+ }
157
+
158
+ return { ok: reasons.length === 0, reasons };
159
+ }
160
+
161
+ /* ---------------- 改闸 ---------------- */
162
+
163
+ /**
164
+ * 改全局闸。
165
+ *
166
+ * @param {boolean} paused
167
+ * @param {{by?:string, reason?:string}} [meta]
168
+ * @param {{persist?:boolean}} [opts]
169
+ * `persist: false` 只改内存不写盘 —— 给 `--paused` 启动参数用。
170
+ * 那是个**一次性的启动意图**(「这次先别说话」),不是「永久暂停」;
171
+ * 写进文件的话,下次不带参数启动却发现还在暂停,人会以为坏了。
172
+ */
173
+ async function setGlobal(paused, meta, { persist = true } = {}) {
174
+ await open();
175
+ global = { ...global, paused: Boolean(paused), ...metaOf(meta) };
176
+ if (persist) await saveGlobal();
177
+ return { ...global };
178
+ }
179
+
180
+ async function setBot(paused, meta) {
181
+ await open();
182
+ bot = { ...bot, paused: Boolean(paused), ...metaOf(meta) };
183
+ await saveBot();
184
+ return { ...bot };
185
+ }
186
+
187
+ async function setChat(chatId, paused, meta) {
188
+ await open();
189
+ if (!chatId) throw new Error('缺 chatId');
190
+ const chats = { ...(bot.chats ?? {}) };
191
+ if (paused) chats[chatId] = { paused: true, ...metaOf(meta) };
192
+ else delete chats[chatId];
193
+ bot = { ...bot, chats };
194
+ await saveBot();
195
+ return { chatId, paused: Boolean(paused) };
196
+ }
197
+
198
+ /**
199
+ * 静默一段时间。
200
+ *
201
+ * `until` 存绝对时间戳而不是「剩余毫秒」:进程重启后剩余时间还得重新算,
202
+ * 而「到几点解除」是事实,重启也成立。
203
+ *
204
+ * @param {string} chatId
205
+ * @param {number} ms 静默多久;`<= 0` 表示解除
206
+ */
207
+ async function setSilence(chatId, ms, meta) {
208
+ await open();
209
+ if (!chatId) throw new Error('缺 chatId');
210
+ const silence = { ...(bot.silence ?? {}) };
211
+ if (ms > 0) silence[chatId] = Date.now() + ms;
212
+ else delete silence[chatId];
213
+ bot = { ...bot, silence };
214
+ await saveBot();
215
+ return { chatId, until: silence[chatId] ?? 0, ...metaOf(meta) };
216
+ }
217
+
218
+ /**
219
+ * 重读全局闸。
220
+ *
221
+ * 每只鸟各有一个 gate 实例,各自缓存着一份 `global.json` 的内容。
222
+ * 面板改全局闸时只写了一个文件,其他鸟内存里还是旧值——不加这一步,
223
+ * 「全局暂停」要等重启才对其他鸟生效,等于没做。
224
+ *
225
+ * 只重读全局那一个文件:bot 文件是本实例独有的,重读会把排队中的
226
+ * 改动冲掉。
227
+ */
228
+ async function reloadGlobal() {
229
+ const g = await readJsonSafe(globalFile, (m) => log(`全局闸:${m}`));
230
+ global = { ...EMPTY(), ...(g ?? {}) };
231
+ return { ...global };
232
+ }
233
+
234
+ /**
235
+ * 清掉所有已到期的静默。
236
+ *
237
+ * `check()` 里已经按时间判断过了,这里只是别让 `silence` 表无限长——
238
+ * 每静默一次留一条,挂机半年就是几百条无用记录。
239
+ */
240
+ async function prune(now = Date.now()) {
241
+ await open();
242
+ const silence = bot.silence ?? {};
243
+ const alive = Object.entries(silence).filter(([, until]) => Number(until) > now);
244
+ if (alive.length === Object.keys(silence).length) return 0;
245
+ bot = { ...bot, silence: Object.fromEntries(alive) };
246
+ await saveBot();
247
+ return Object.keys(silence).length - alive.length;
248
+ }
249
+
250
+ /**
251
+ * 同步快照(不读盘)。
252
+ *
253
+ * `snapshot()` 要 await,而 `/api/status` 是个同步函数、每 2 秒被调一次。
254
+ * 闸在 `init()` 里已经 open 过,内存里就是最新的,没必要每次再读盘。
255
+ */
256
+ function peek() {
257
+ return {
258
+ global: { paused: global.paused, at: global.at, by: global.by, reason: global.reason },
259
+ bot: { paused: bot.paused, at: bot.at, by: bot.by, reason: bot.reason },
260
+ };
261
+ }
262
+
263
+ /** 面板用的完整快照(含每会话的闸,方便一次画完) */
264
+ async function snapshot(now = Date.now()) {
265
+ await open();
266
+ return {
267
+ global: { paused: global.paused, at: global.at, by: global.by, reason: global.reason },
268
+ bot: { paused: bot.paused, at: bot.at, by: bot.by, reason: bot.reason },
269
+ chats: Object.entries(bot.chats ?? {}).map(([chatId, v]) => ({
270
+ chatId,
271
+ paused: true,
272
+ at: v.at ?? 0,
273
+ by: v.by ?? '',
274
+ reason: v.reason ?? '',
275
+ })),
276
+ silence: Object.entries(bot.silence ?? {})
277
+ .filter(([, until]) => Number(until) > now)
278
+ .map(([chatId, until]) => ({ chatId, until })),
279
+ };
280
+ }
281
+
282
+ /** 退出前把排队的写盘任务等完 */
283
+ async function flush() {
284
+ await chain.catch(() => {});
285
+ }
286
+
287
+ return {
288
+ open,
289
+ check,
290
+ peek,
291
+ snapshot,
292
+ reloadGlobal,
293
+ setGlobal,
294
+ setBot,
295
+ setChat,
296
+ setSilence,
297
+ prune,
298
+ flush,
299
+ get dir() {
300
+ return dir;
301
+ },
302
+ };
303
+ }
304
+
305
+ /**
306
+ * 管道中间件:闸关着就不往下走。
307
+ *
308
+ * **必须排在账本之后**。`bot.js` 里 `ledger.append()` 在 `pipeline.run()` 之前,
309
+ * 所以暂停期间的消息照样入账——恢复之后 AI 能看见「你不在的时候群里聊了什么」。
310
+ * 顺序反过来的话,暂停就等于丢消息,而官方没有历史消息接口,丢了补不回来。
311
+ *
312
+ * 拦下时**不回任何话**:静默才是目的,回一句「我暂停了」等于没闭嘴。
313
+ */
314
+ export function gateMiddleware(gate, { metrics = null } = {}) {
315
+ return async (ctx, next) => {
316
+ const chatId = ctx.msg?.chatId ?? '';
317
+ const v = gate.check(chatId);
318
+ if (!v.ok) {
319
+ metrics?.bump('gated');
320
+ ctx.halt('gate');
321
+ return;
322
+ }
323
+ return next();
324
+ };
325
+ }