@zaofan/dsh-qqbot 1.9.3 → 1.10.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.
@@ -1,119 +1,53 @@
1
- /**
2
- * session-watch-hook.mjs — 演示「注册监听 + dispose 清理」的启动钩子示例
3
- *
4
- * 放在 `{数据根}/.qqbot-extensions/hooks/session-watch-hook.mjs`
5
- * (插件启动时会自动铺一份,只补不覆盖;你改过的版本永远不会被覆盖)。
6
- *
7
- * 它做三件"必须自己收尾"的事,正是不清理就会在重建网关时不断累积的典型:
8
- * ① `env.ctx.on('session/event', …)` 订阅宿主事件 → 返回 disposer;
9
- * ② 起一个 `setInterval` 定时器 → 自己 clearInterval;
10
- * ③ 用 `env.store.save()` 落盘一个累计计数。
11
- *
12
- * 收尾入口**两个都写了**(都合法,任选其一即可;这里是为让示例把两条路都展示出来):
13
- * · `run()` 里 `env.registerDispose(fn)` —— 适合"清理函数是闭包、写在 run 内部"的情形;
14
- * · 模块级 `export function dispose(env)` —— 适合"清理逻辑是模块级的"。
15
- * 执行顺序:同一钩子内 registerDispose 的**逆序**先跑,然后跑模块级 dispose()。
16
- *
17
- * ⚠️ 三条经验(踩出来的):
18
- * 1. **必须真的收尾**:不 dispose 的话,每次"保存连接相关设置 → 重建网关"都会多一份监听;
19
- * 2. **run 与 dispose 之间有竞态**:极端情况下 dispose 可能先于 run 的末尾执行 ——
20
- * 所以用 `cancelled` 标志兜住(本示例的写法可以直接抄);
21
- * 3. **别在启动路径上做重活**:钩子会拖慢插件启动(虽然有 30s 超时保护,但超时=白等 30s)。
22
- */
23
-
24
- export const name = 'session-watch-hook';
25
-
26
- const TICK_MS = 60_000;
27
-
28
- /** 每次 run 的现场(重建网关会有新的一份;旧的由 dispose 清掉) */
29
- let state = null;
30
-
31
- export default {
32
- name,
33
-
34
- /** 事件计数与定时器句柄都挂在 state 上,避免模块级散落变量互相踩 */
35
- async run(env) {
36
- const startAt = Date.now();
37
- const prev = env.store.load() ?? {};
38
- state = {
39
- events: 0,
40
- starts: Number(prev.starts ?? 0) + 1,
41
- cancelled: false,
42
- disposers: [],
43
- startAt,
44
- timer: undefined,
45
- };
46
-
47
- env.logger.info(
48
- `[ext-hook:session-watch] 启动第 ${state.starts} 次(插件 v${env.pluginVersion})——` +
49
- `开始订阅 session/event,并起一个 ${TICK_MS / 1000}s 的统计定时器`,
50
- );
51
-
52
- // ── ① 订阅宿主事件(有就订,没有就算了:ctx 形状跟随宿主版本,别因为拿不到就崩) ──
53
- try {
54
- const off = env.ctx?.on?.('session/event', () => {
55
- state.events += 1;
56
- if (state.events % 10 === 0) {
57
- env.logger.info(`[ext-hook:session-watch] 已观察到 ${state.events} 个 session/event`);
58
- }
59
- });
60
- if (typeof off === 'function') state.disposers.push(off);
61
- else env.logger.warn('[ext-hook:session-watch] ctx.on 没返回 disposer —— 跳过订阅(宿主版本差异?)');
62
- } catch (err) {
63
- // 钩子里的失败**不要往外抛**:抛了也只是被引擎记成"失败",不如自己降级
64
- env.logger.warn(`[ext-hook:session-watch] 订阅 session/event 失败(已降级): ${err?.message ?? err}`);
65
- }
66
-
67
- // ── ② 定时器(记得 unref:不该因为一个统计定时器让宿主进程退不掉) ──
68
- state.timer = setInterval(() => {
69
- if (state.cancelled) return;
70
- env.logger.info(
71
- `[ext-hook:session-watch] 心跳:本次启动已观察 ${state.events} 个事件,` +
72
- `存活 ${Math.round((Date.now() - state.startAt) / 1000)}s`,
73
- );
74
- }, TICK_MS);
75
- state.timer.unref?.();
76
-
77
- // ── ③ 登记清理(同时用 registerDispose 展示这条通路) ──
78
- env.registerDispose(() => {
79
- // 这里只是再打一行日志;真正的清理由下面 cleanup() 统一做(避免两处清理重复)
80
- env.logger.info('[ext-hook:session-watch] registerDispose 回调执行');
81
- });
82
-
83
- // ── 竞态兜底:如果 run 还没跑完,dispose 就已经被调用了,那就**立刻**收尾 ──
84
- if (state.cancelled) {
85
- env.logger.warn('[ext-hook:session-watch] 启动与卸载撞上了,立即收尾');
86
- cleanup(env);
87
- return { cancelled: true };
88
- }
89
-
90
- return { starts: state.starts };
91
- },
92
-
93
- /** 模块级 dispose:引擎在插件卸载 / 宿主退出 / 重建网关时调用(逆序、逐个 try/catch) */
94
- async dispose(env) {
95
- if (state) state.cancelled = true; // 先立标志:run 若还在后面跑,它会自己收尾
96
- cleanup(env);
97
- },
98
- };
99
-
100
- /** 幂等收尾:disposer 逐个吞错 + 定时器清掉 + 计数字段落盘 */
101
- function cleanup(env) {
102
- const s = state;
103
- state = null;
104
- if (!s) return;
105
- s.cancelled = true;
106
- for (const off of s.disposers.splice(0).reverse()) {
107
- try { off(); } catch (err) {
108
- env.logger.warn(`[ext-hook:session-watch] 取消订阅失败(已忽略): ${err?.message ?? err}`);
109
- }
110
- }
111
- if (s.timer) { clearInterval(s.timer); s.timer = undefined; }
112
- env.store.save({
113
- starts: s.starts,
114
- lastEvents: s.events,
115
- lastRunMs: Date.now() - s.startAt,
116
- lastStopAt: new Date().toISOString(),
117
- });
118
- env.logger.info(`[ext-hook:session-watch] 已收尾(观察 ${s.events} 个事件,监听与定时器都已归还)`);
119
- }
1
+ /**
2
+ * session-watch-hook.mjs — 启动钩子:**重启后把「回合结束回灌」接回来**
3
+ *
4
+ * 背景(2026-10-13 主人定「做成持久化的本地存档,不要只靠内存」):
5
+ * · **盯梢名单本身就以存档为准**:
6
+ * `{数据根}/.qqbot-extensions/tools/data/session-watch-watching.json`
7
+ * (和 `vote.json` 同一个 data 目录)—— register / 改档 / 取消都会落盘,
8
+ * 所以**重启后名单不会丢**;
9
+ * · **但监听器是内存态**:回灌靠 `root.on('session/event', …)` 这个订阅,
10
+ * 它随插件进程一起消失 ⇒ 重启后"名单还在、却没人听了"。
11
+ * 这就是之前那个「重启后引用子会话没用了」的根因。
12
+ * ⇒ 所以本钩子只做一件事:`rehydrate(env.ctx)` = **读回存档 + 重装监听器**。
13
+ *
14
+ * 为什么写在这里:hooks/ 是插件**启动期**跑的(每次启动 / 重建网关都会走一遍),
15
+ * 正是"把内存态重新接上"的唯一时机。
16
+ *
17
+ * ⚠️ **改这个文件要重启宿主才生效**(hooks 是启动期加载,不走 /tools-reload)。
18
+ */
19
+ export const name = 'session-watch-hook';
20
+
21
+ /** 本次 run 的现场(重建网关会有新的一份) */
22
+ let state = null;
23
+
24
+ export default {
25
+ name,
26
+
27
+ async run(env) {
28
+ state = { restored: 0, hooked: false };
29
+ try {
30
+ // 同一份库(tools/lib/);不带 ?v= —— 名单走 globalThis 共享,无需新实例
31
+ const lib = await import('../tools/lib/session_watch_lib.mjs');
32
+ const r = await lib.rehydrate(env.ctx);
33
+ state.restored = Number(r?.restored ?? 0);
34
+ state.hooked = !!r?.hooked;
35
+ env.logger.info(
36
+ `[ext-hook:session-watch] 启动恢复:读回 ${state.restored} 条盯梢,` +
37
+ `回灌监听器${state.hooked ? '已装' : '未能装上'}` +
38
+ `(名单存档 tools/data/session-watch-watching.json)`,
39
+ );
40
+ return { restored: state.restored, hooked: state.hooked };
41
+ } catch (err) {
42
+ // 钩子里的失败不要往外抛:抛了也只是被引擎记成"失败",不如降级 + 留日志
43
+ const msg = String(err?.message ?? err);
44
+ env.logger.warn(`[ext-hook:session-watch] 启动恢复失败(不影响其它功能): ${msg}`);
45
+ return { restored: 0, hooked: false, error: msg };
46
+ }
47
+ },
48
+
49
+ /** 引擎在插件卸载 / 宿主退出 / 重建网关时调用(本钩子没有要归还的资源) */
50
+ async dispose() {
51
+ state = null;
52
+ },
53
+ };