@zaofan/dsh-qqbot 1.8.6 → 1.9.2

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.
Files changed (76) hide show
  1. package/README.md +114 -27
  2. package/README_EN.md +122 -8
  3. package/dist/features/ext-capabilities.d.ts +85 -0
  4. package/dist/features/ext-capabilities.d.ts.map +1 -1
  5. package/dist/features/ext-capabilities.js +4 -0
  6. package/dist/features/ext-capabilities.js.map +1 -1
  7. package/dist/features/ext-hooks.d.ts +84 -0
  8. package/dist/features/ext-hooks.d.ts.map +1 -0
  9. package/dist/features/ext-hooks.js +299 -0
  10. package/dist/features/ext-hooks.js.map +1 -0
  11. package/dist/features/ext-inbound-context.d.ts +202 -0
  12. package/dist/features/ext-inbound-context.d.ts.map +1 -0
  13. package/dist/features/ext-inbound-context.js +724 -0
  14. package/dist/features/ext-inbound-context.js.map +1 -0
  15. package/dist/features/ext-inbound-state.d.ts +153 -0
  16. package/dist/features/ext-inbound-state.d.ts.map +1 -0
  17. package/dist/features/ext-inbound-state.js +419 -0
  18. package/dist/features/ext-inbound-state.js.map +1 -0
  19. package/dist/features/ext-inbound.d.ts +117 -0
  20. package/dist/features/ext-inbound.d.ts.map +1 -0
  21. package/dist/features/ext-inbound.js +518 -0
  22. package/dist/features/ext-inbound.js.map +1 -0
  23. package/dist/features/ext-reload.d.ts +22 -0
  24. package/dist/features/ext-reload.d.ts.map +1 -1
  25. package/dist/features/ext-reload.js +32 -0
  26. package/dist/features/ext-reload.js.map +1 -1
  27. package/dist/features/extension-store.d.ts +13 -0
  28. package/dist/features/extension-store.d.ts.map +1 -1
  29. package/dist/features/extension-store.js +76 -18
  30. package/dist/features/extension-store.js.map +1 -1
  31. package/dist/gateway/bootstrap.d.ts +14 -1
  32. package/dist/gateway/bootstrap.d.ts.map +1 -1
  33. package/dist/gateway/bootstrap.js +61 -2
  34. package/dist/gateway/bootstrap.js.map +1 -1
  35. package/dist/gateway/debounce.d.ts +43 -1
  36. package/dist/gateway/debounce.d.ts.map +1 -1
  37. package/dist/gateway/debounce.js +71 -35
  38. package/dist/gateway/debounce.js.map +1 -1
  39. package/dist/gateway/middleware-setup.d.ts.map +1 -1
  40. package/dist/gateway/middleware-setup.js +88 -1
  41. package/dist/gateway/middleware-setup.js.map +1 -1
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +49 -9
  44. package/dist/index.js.map +1 -1
  45. package/dist/session/session-manager.d.ts.map +1 -1
  46. package/dist/session/session-manager.js +7 -3
  47. package/dist/session/session-manager.js.map +1 -1
  48. package/dist/session/surface-guard.d.ts +1 -1
  49. package/dist/shared/index.d.ts +1 -1
  50. package/dist/shared/index.d.ts.map +1 -1
  51. package/dist/shared/index.js +1 -1
  52. package/dist/shared/index.js.map +1 -1
  53. package/dist/shared/utils.d.ts +40 -0
  54. package/dist/shared/utils.d.ts.map +1 -1
  55. package/dist/shared/utils.js +59 -2
  56. package/dist/shared/utils.js.map +1 -1
  57. package/dist/transport/inbound.d.ts.map +1 -1
  58. package/dist/transport/inbound.js +23 -3
  59. package/dist/transport/inbound.js.map +1 -1
  60. package/docs/USER-GUIDE.md +93 -36
  61. package/docs/botplay-contract.md +2 -0
  62. package/examples/ext-hooks/README.md +95 -0
  63. package/examples/ext-hooks/hello-hook.mjs +54 -0
  64. package/examples/ext-hooks/session-watch-hook.mjs +119 -0
  65. package/examples/ext-inbound/README.md +230 -0
  66. package/examples/ext-inbound/class-advisor.mjs +306 -0
  67. package/examples/ext-inbound/no-wake-on-child-quote.mjs +192 -0
  68. package/examples/ext-tools/README.md +22 -0
  69. package/examples/ext-tools/lib/preset_resolve.mjs +85 -0
  70. package/examples/ext-tools/lib/session_watch_lib.mjs +350 -0
  71. package/examples/ext-tools/session_create.mjs +201 -0
  72. package/examples/ext-tools/session_send.mjs +141 -0
  73. package/examples/ext-tools/session_watch.mjs +93 -0
  74. package/examples/ext-tools/sessions_list.mjs +180 -0
  75. package/package.json +141 -141
  76. package/settings-host.js +22 -5
@@ -0,0 +1,54 @@
1
+ /**
2
+ * hello-hook.mjs — 最小可跑的「启动钩子」示例
3
+ *
4
+ * 放在 `{数据根}/.qqbot-extensions/hooks/hello-hook.mjs`(插件启动时会自动铺一份,只补不覆盖)。
5
+ * 它是**日志型**的:不注册任何清理、不碰宿主状态 —— 用来确认"启动钩子这条链路是通的"。
6
+ *
7
+ * 契约(完整版见 README「启动钩子」一节):
8
+ * · 导出 `run(env)`(也接受 `export default { run }` / `export default async (env) => {}`);
9
+ * · 可以是 async,返回值只记日志,不影响启动;
10
+ * · 可选导出 `dispose(env)`,插件卸载 / 宿主退出 / 重建网关时调用。
11
+ *
12
+ * env 给了什么:{ ctx, logger, dataRoot, config, registerDispose, pluginVersion, hookFile, caps, store }
13
+ */
14
+
15
+ /** 每个启动钩子都可以导出 `name` 给日志用(不写就用文件名) */
16
+ export const name = 'hello-hook';
17
+
18
+ /** 本次启动的时间戳,dispose 时拿来算存活时长 */
19
+ let bootedAt = 0;
20
+
21
+ export async function run(env) {
22
+ bootedAt = Date.now();
23
+ // env.logger 就是插件 logger;建议前缀带上钩子名,日志里好认
24
+ env.logger.info(
25
+ `[ext-hook:hello] 你好,插件 v${env.pluginVersion} 启动完成;数据根 = ${env.dataRoot}`,
26
+ );
27
+
28
+ // ── 用 store 记一点跨会话的小状态(落 {数据根}/.qqbot-extensions/hooks/data/hello-hook.json)──
29
+ // ⚠️ 这里演示"读 → 改 → 写";真实用途可以是"上次跑到哪了 / 上次的游标"。
30
+ const prev = env.store.load() ?? {};
31
+ const boots = Number(prev.boots ?? 0) + 1;
32
+ const ok = env.store.save({
33
+ boots,
34
+ firstBootAt: prev.firstBootAt ?? new Date().toISOString(),
35
+ lastBootAt: new Date().toISOString(),
36
+ lastPluginVersion: env.pluginVersion,
37
+ });
38
+ env.logger.info(`[ext-hook:hello] 这是第 ${boots} 次启动(状态写入${ok ? '成功' : '失败'})`);
39
+
40
+ // 演示"启动时跳过一段时间"的老套路:store 里存着"下次才处理"的时间戳,没到就直接返回
41
+ // return { skipped: true }; ← 返回什么都行,只是个记录
42
+
43
+ return { boots };
44
+ }
45
+
46
+ /**
47
+ * 可选:清理函数。示例里只是打一行日志。
48
+ * ⚠️ 真实场景(定时器 / 监听 / 订阅)**必须**在这里(或 env.registerDispose)收尾,
49
+ * 否则重建网关会不断累积 —— 本插件自己在这个坑上摔过(见维护手册:disposer 要收尾)。
50
+ */
51
+ export async function dispose(env) {
52
+ const aliveMs = bootedAt ? Date.now() - bootedAt : 0;
53
+ env.logger.info(`[ext-hook:hello] 再见(本次存活 ${Math.round(aliveMs / 1000)}s)`);
54
+ }
@@ -0,0 +1,119 @@
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
+ }
@@ -0,0 +1,230 @@
1
+ # 入站中间件(`{数据根}/.qqbot-extensions/inbound/`)
2
+
3
+ > 这一套扩展能**影响每一条入站消息的命运**:让它别进来、把它的文本换掉、让它一定要叫醒她、或者反过来"这条不算 @ 她"。
4
+ > v1.9.0+。
5
+ > **这一套里"会自动铺到你目录"的只有 `no-wake-on-child-quote.mjs` 一个**(插件首次启动铺一份,只补不存在、绝不覆盖你改过的)。
6
+ > 其余示例(尤其 `class-advisor.mjs` —— 它会**真删消息、真封人**)**不自动铺**,想看想用自己拷过去。
7
+ > 理由:示例是给人**看和抄**的,不该在你没读过它之前就自己跑起来。
8
+
9
+ ---
10
+
11
+ ## 一、两个挂点:链上位置决定你能做什么
12
+
13
+ ```
14
+ … → messageFilter → accessPolicy → quoteRef
15
+ → ★① 早挂点 onInbound ★ ← 记群历史之前!
16
+ → 引用图预取 → 群历史缓冲(store)→ 表情包/台账
17
+ → mentionGate(@ 门控)
18
+ → ★② 晚挂点 onDecide / export default ★
19
+ → contentSanitizer → debounce(延迟聚合)→ 命令 → 群冷却 → 并发合并 → envelope → AI
20
+ ```
21
+
22
+ | | **① 早:`onInbound`** | **② 晚:`onDecide` / `export default`** |
23
+ |---|---|---|
24
+ | 链上位置 | `quoteRef` 之后、**记群历史之前** | `mentionGate` 之后、`contentSanitizer` 之前 |
25
+ | 读得到 | `ctx.message` / `ctx.state.quote` / `ctx.platform.mentioned` | 上面全部 **+ `ctx.state.mention`**(@ 判定) |
26
+ | 做得了 | **消灭**、**改写**、**强制入站**、查/写禁言名单 | **覆盖 @ 判定**、改写、强制入站、查/写禁言名单 |
27
+ | 做不到 | `ctx.decide`(`ctx.state.mention` 还没写出来,调了会被拒 + 告警) | `ctx.annihilate`(群历史已经记过了,调了会被拒 + 告警) |
28
+
29
+ 一个文件可以**同时导出两个挂点**(互不影响);只导出 `export default` 就是最早那份契约,**语义等同 `onDecide`**,老扩展不用改。
30
+
31
+ ```js
32
+ // {数据根}/.qqbot-extensions/inbound/my-rule.mjs
33
+ export async function onInbound(ctx, next) { // ① 早
34
+ return next();
35
+ }
36
+ export default async function onDecide(ctx, next) { // ② 晚(= 原来的写法)
37
+ return next();
38
+ }
39
+ export function dispose() { /* 可选:插件卸载/重建网关时清理 */ }
40
+ ```
41
+
42
+ 四种写法都认(挑一种就行):
43
+
44
+ ```js
45
+ export default async (ctx, next) => next(); // 裸函数默认导出(= 晚挂点)
46
+ export async function onDecide(ctx, next) { … } // 具名晚挂点
47
+ export async function onInbound(ctx, next) { … } // 具名早挂点
48
+ export const onInbound = async (ctx, next) => next();// 箭头式也行
49
+ export default { name: 'x', onInbound, onDecide, dispose() {} }; // 对象默认导出
50
+ ```
51
+
52
+ > ⚠️ 一个扩展文件里,晚挂点只认一个:**具名 `onDecide` > 裸函数 `export default` > 对象里的 `onDecide`/`middleware` > 具名 `middleware`**。
53
+
54
+ ## 二、为什么"改写 / 消灭"必须写在**早挂点**(这是最容易踩的一处)
55
+
56
+ 链上第 4 步是**群历史缓冲**:它把每条消息的正文写进 store(AI 的 `[历史]` 段就是从这来的),而它读的是 `ctx.message.content`。
57
+ 所以:
58
+
59
+ * **改写**写在早挂点 → 进 store 的就是改后文本(AI 看到的、历史里存的都是和谐版);
60
+ 写在晚挂点 → **store 里已经是原文了**,只有"当前这条"是改后的(要连历史一起改,只能在早挂点)。
61
+ * **消灭**(`ctx.annihilate()`,等价于"不调 `next()`")写在早挂点 → 链停在这里,**群历史、@ 门控、价值评分、群冷却全都不跑**,这条消息等于没来过;
62
+ 写在晚挂点 → 历史已经记了、门控已经过了,就算拦住派发,它也已经躺在历史里了。
63
+ * **@ 判定覆盖**(`ctx.decide`)只能写在晚挂点 —— 早挂点跑的时候 `mentionGate` 还没执行。
64
+
65
+ ## 三、动作 API 完整签名表(用户看到的就是这些)
66
+
67
+ ### 消息维
68
+
69
+ | 调用 | 作用 | 用在哪 |
70
+ |---|---|---|
71
+ | `ctx.annihilate({ reason })` | **消灭**这条:不进群历史、不进上下文、不占群冷却。内部 = 让引擎不再 `next()` | 早挂点(晚挂点会被拒 + 告警) |
72
+ | `ctx.rewrite({ text, reason })` | **替换文本**(改的是 `ctx.message.content`) | 早挂点(历史也是改后文本);晚挂点只改"当前这条" |
73
+ | `ctx.forceWake({ reason })` | **强制入站**:跳过 @ 门控、价值评分、群冷却等一切"值不值得回"的判断;**不伪造 @**(不会多出假的 `(@you)`) | 两个挂点都能调;要连"没 @ 也放行"必须用早挂点 |
74
+ | `ctx.decide({ mention: 'ignore' \| 'force', reason })` | **覆盖 @ 判定**:`ignore` = 这条不按被 @ 处理;`force` = 按被 @ 处理(会被当"点名") | 晚挂点(早挂点会被拒 + 告警) |
75
+
76
+ 返回值:成功 `true`,被拒/参数不合法 `false`(并在日志里留一条 warn)。
77
+
78
+ ### 用户维(名单,两块挂点都能用)
79
+
80
+ | 调用 | 作用 |
81
+ |---|---|
82
+ | `ctx.muteUser({ id?, minutes, reason? })` | 禁言:`id` 缺省 = **当前发送者**;`minutes > 0` = 这么多分钟,**`minutes <= 0` = 永久**。成功返回名单项 `{ id, until, reason, by, at }`,失败 `false` |
83
+ | `ctx.unmuteUser({ id? })` | 解禁(可撤销通道);返回是否真的解掉了一条 |
84
+ | `ctx.isMuted(id?)` | 查一条(缺省 = 当前发送者):命中返回 `{ id, until, reason, by, at, permanent, remainingMs, remainingMinutes }`,没命中 `null` |
85
+ | `ctx.mutedUsers()` | 整份名单(已做过期清理,带剩余时间,按到期时间升序,永久排最后) |
86
+
87
+ ### 通用取值 / 工具
88
+
89
+ | 字段 | 说明 |
90
+ |---|---|
91
+ | `ctx.text` | 当前消息文本(**可写**:赋值 = 直接改 `ctx.message.content`,但不会落审计,建议用 `ctx.rewrite`) |
92
+ | `ctx.scope` | `'group' \| 'c2c'` |
93
+ | `ctx.sender` / `ctx.senderName` / `ctx.isOwner` | 发送者 openid / 昵称 / 是否主人(`config.groupAdmin.owners`) |
94
+ | `ctx.groupId` | 群 openid(私聊是 `''`) |
95
+ | `ctx.mentions` | SDK 的 mentions 数组原样 |
96
+ | `ctx.quoted` | 被引原文:`{ text, id, fromSelf }`。`id` = 被引那条的发送者 openid("回复某人再下令"用它);`fromSelf` = 是不是她自己说的(**启发式**,靠"她最近一条发言"的文本比对,可能不准)。也可以写成 `ctx.quoted()` 拿一份快照 |
97
+ | `ctx.platform` | `{ mentioned }`:**平台**认为这条 @ 了她吗(判据与 SDK `mentionGate` 一致:`rawEventType` / `mentions[].is_you` / 正文里的 `<@appId>`)。早挂点读不到 `ctx.state.mention`,要判 @ 就用它。也可以写成 `ctx.platform()` |
98
+ | `ctx.logger` | 插件 logger(`info/warn/error/debug`) |
99
+ | `ctx.dataRoot` | 数据根路径(放自己的文件用) |
100
+ | `ctx.kv.get(key)` / `ctx.kv.set(key, value)` | **每个扩展自己的持久化**(与禁言名单同一个文件);`set(key, undefined)` = 删掉这个键 |
101
+
102
+ > 所有动作**内部都有 try/catch,任何情况下都不会抛到链上**;出错只 warn 并返回 `false`。
103
+ > `ctx` 上这些属性是**不可枚举**的(不会跟着 `{...ctx}` 被复制走、也不会被 JSON 序列化抓走)。
104
+
105
+ ## 四、禁言名单存哪、怎么过期
106
+
107
+ ```
108
+ {数据根}/.qqbot-extensions/inbound/.state/<你的扩展文件名>.json
109
+ {
110
+ "mutes": [ { "id": "群友openid", "until": 1760000000000, "reason": "广告", "by": "class-advisor", "at": 1759999000000 } ],
111
+ "kv": { "spam:<openid>": [1759999000000, 1759999010000] }
112
+ }
113
+ ```
114
+
115
+ * `until` = 到期时间戳(ms);**`until: 0` = 永久**(`minutes <= 0` 时写 0,永远不会被清理)。
116
+ * **惰性清理**:每次读名单都会剔掉已过期的项,并把清理结果写回文件。
117
+ * **跨重启有效**:名单在盘上,插件重启/网关重建都还在。
118
+ * **坏文件不崩**:JSON 坏了、形状不对、没权限 —— 一律当空(`mutes: []`、`kv: {}`),插件与消息链照常跑。
119
+ * `.state/` 是**子目录**,不会被当扩展装载(扫描只看 `inbound/` 根下的直接子文件)。
120
+ * 审计账本是**共用**的一个文件:`{数据根}/.qqbot-extensions/inbound/.state/audit.jsonl`。
121
+
122
+ ## 五、四条保命件(插件侧强制,写错也兜得住)
123
+
124
+ 1. **白名单最高优先** —— 主人(`config.groupAdmin.owners` 里的 openid)**永不可被静默/消灭**。
125
+ 扩展对主人调 `ctx.annihilate()` 或 `ctx.muteUser({ id: 主人 })` ⇒ **拒绝执行** + 一条 warn(消息照常放行),审计里记 `result: "refused-owner"`。
126
+ > ⚠️ 光"拒绝动作"是不够的:最常见的写法是 `if (该消灭) { ctx.annihilate(); return; }` ——
127
+ > 那个 `return`(不调 `next()`)本来会把链停住,等于"插件说拒绝了、消息还是被吞了"。
128
+ > 所以引擎还有一条**兜底放行**:扩展既没调 `next()`、又没有一次成功的消灭时 ——
129
+ > ① 消灭**被拒**过(主人/熔断/挂点不允许)⇒ 引擎替它放行;② 发送者是**主人** ⇒ 引擎替它放行。
130
+ > 两种情况都会写一条 warn。⇒ **主人的消息永远不会被入站扩展吞掉**(要拦主人请自己去改插件)。
131
+ > 其余情况(普通群友、扩展自己有意 `return`)保持老契约:链停在这里,消息不进后续任何环节。
132
+ 2. **强唤醒优先于静默** —— `ctx.forceWake()` 不会被 `annihilate` 之外的东西抵消;
133
+ **顺序由你自己排**:把"求助关键词 → forceWake"写在"禁言名单 → annihilate"**之前**,
134
+ 否则会出现"被禁言的人喊救命没人理"(`class-advisor.mjs` 就是照这个顺序写的)。
135
+ 3. **熔断** —— 按**每个扩展**统计破坏性动作(消灭/静默)的密度:
136
+ * 该扩展参与的最近 50 条里,破坏性动作 > 50% ⇒ 熔断;
137
+ * 该扩展 10 秒内破坏性动作 ≥ 20 次 ⇒ 熔断。
138
+
139
+ 熔断后:**只停掉该扩展的破坏性动作**(`annihilate`/`muteUser` 返回 `false` + warn,消息照常放行,
140
+ 同样由上面的"兜底放行"保证真的放行),`rewrite`/`forceWake`/`decide` 不受影响;告警里带扩展名与原因。
141
+ **复位**:改文件后 `/tools-reload`(或重启插件)即可 —— 每次装载都会清掉该扩展的熔断记录。
142
+ 4. **审计 + 可撤销** —— 每次 `annihilate / rewrite / forceWake / decide / muteUser / unmuteUser`(含被拒的那种)
143
+ 都往 `audit.jsonl` 追加一行:`{ at, file, name, action, target, reason, text, result }`;
144
+ `text` 是**截断后的**摘要(120 字),不会把整条消息塞进去;文件超过 512KB 自动轮转成 `audit.jsonl.1`(只留一代)。
145
+ `muteUser` 随时可以用 `unmuteUser` 解掉。
146
+
147
+ ## 六、从零写一个自己的规则(走查)
148
+
149
+ **目标**:群里有人发"@她 + 名字"时必须有回应;有人刷同样的句子三次以上就禁言 5 分钟;正文里的手机号要打码。
150
+
151
+ 1. **建文件**:在 `{数据根}/.qqbot-extensions/inbound/` 下新建 `my-rule.mjs`(扩展名只认 `.js/.mjs/.cjs`;文件名就是排序与状态文件的名字)。
152
+ 2. **先只写"放行"**,确认能装载:
153
+
154
+ ```js
155
+ export const name = 'my-rule';
156
+ export async function onInbound(ctx, next) {
157
+ ctx.logger.info?.('[my-rule] 看到一条:' + ctx.text.slice(0, 20));
158
+ return next();
159
+ }
160
+ ```
161
+ 存盘 → 群里发一句 → `/tools-reload`(或重启插件)。启动日志里会出现
162
+ `[ext-inbound] 已装载 N 个入站中间件: …, my-rule(其中 M 个含早挂点 onInbound)`;
163
+ 没有这行就看日志里的 `[ext-inbound] ✗ my-rule …`(装载失败会说原因)。
164
+ 3. **加规则**(都在早挂点里,因为要影响历史/是否进站):
165
+
166
+ ```js
167
+ export async function onInbound(ctx, next) {
168
+ const text = String(ctx.text ?? '');
169
+
170
+ // ① 求助/点名优先 —— 必须写在"静默"判断之前
171
+ if (text.includes('救命') || /在吗|求助/.test(text)) {
172
+ ctx.forceWake({ reason: '求助关键词' }); // 跳过 @门控/评分/冷却
173
+ return next();
174
+ }
175
+
176
+ // ② 复读刷屏 → 禁言 5 分钟 + 消灭这条
177
+ const key = 'say:' + ctx.sender;
178
+ if (ctx.kv.get(key) === text) {
179
+ ctx.muteUser({ minutes: 5, reason: '复读刷屏' });
180
+ ctx.annihilate({ reason: '复读刷屏' });
181
+ return; // ★ 不调 next() = 不进历史、不进上下文
182
+ }
183
+ ctx.kv.set(key, text);
184
+
185
+ // ③ 手机号打码(改写:历史里也是打码后的)
186
+ const masked = text.replace(/(?<!\d)(1[3-9]\d)\d{4}(\d{4})(?!\d)/g, '$1****$2');
187
+ if (masked !== text) ctx.rewrite({ text: masked, reason: '手机号打码' });
188
+
189
+ return next();
190
+ }
191
+ ```
192
+ 4. **验证**(每条都看一眼):
193
+ * 刷屏的那条**没有**出现在 `[历史]` 里(说明消灭真的生效);
194
+ * `{数据根}/.qqbot-extensions/inbound/.state/audit.jsonl` 里有一行 `{"action":"annihilate", …}`;
195
+ * `{数据根}/.qqbot-extensions/inbound/.state/my-rule.mjs.json` 里有 `mutes` 项;
196
+ * 用主人账号发同样的话 → **不该**被禁言(白名单),日志里有一条 warn。
197
+ 5. **调参/关掉**:改文件 → `/tools-reload` 生效;把文件改名成 `my-rule.mjs.bak`(非 `.js/.mjs/.cjs` 不扫描)→ 立即失效。
198
+
199
+ ## 七、本目录的示例
200
+
201
+ | 文件 | 会自动铺到你的目录吗 | 演示什么 |
202
+ |---|---|---|
203
+ | `no-wake-on-child-quote.mjs` | ✅ **会**(只铺这一个) | **两个挂点同时演示**。晚挂点:引用"子会话转发"的消息不算 @(`ctx.decide`)。早挂点:只读观测 + 两个默认关闭的开关(改写转发头 / 消灭纯转发空壳) |
204
+ | `class-advisor.mjs` | ❌ **不会**,要自己拷 | **辅导员**:① 按发送者禁言(名单+时长,含主人 `/禁言` `/解禁` 指令)② 敏感词/手机号改写 ③ 求助关键词强制到场(**写在黑名单之前**)④ 广告/刷屏直接消灭。**默认 `DRY_RUN = true` 只观察不动手**,看完日志再改 |
205
+
206
+ > 为什么 `class-advisor` 不自动铺:它会**真删消息、真封人**。插件不该替用户决定"我的群要不要开这套规则",
207
+ > 更不该在用户数据目录里凭空多出一个会写状态的文件。想看、想用,自己拷过去(记得先留 `DRY_RUN = true`)。
208
+
209
+ ## 八、`hooks/`、`tools/` 与它的区别
210
+
211
+ | | `hooks/` | **`inbound/`** | `tools/` |
212
+ |---|---|---|---|
213
+ | 跑几次 | **启动时一次** | **每条入站消息一次** | LLM 调一次 |
214
+ | 拿到什么 | `env`(`{ctx, logger, dataRoot, …}`) | `(ctx, next)`(与 `bot.use(mw)` 同签名) | 工具参数 |
215
+ | 干什么 | 起定时器 / 注册监听 / 恢复任务 | **处理这条消息**:消灭 / 改写 / 强制入站 / 管名单 | 查数据、发消息 |
216
+ | 写错了 | 启动少跑一段 | 那条消息处理受影响(引擎兜底:抛错只告警并继续 `next()`) | 那次调用失败 |
217
+
218
+ 一句话:**hooks 管"起什么",inbound 管"这条怎么处理"**。两者不通用。
219
+
220
+ ## 九、排查速查
221
+
222
+ | 现象 | 先看这里 |
223
+ |---|---|
224
+ | 扩展没生效 | 文件名扩展名对不对(`.js/.mjs/.cjs`)、日志里有没有 `[ext-inbound] ✗ …`、是否忘了 `/tools-reload` |
225
+ | 消息被吞了 | 你自己的 `annihilate` / 没调 `next()`;审计里有 `annihilate` 行;熔断告警也在日志里 |
226
+ | `ctx.decide` 没反应 | 是不是写在早挂点了(会被拒 + warn:`decide 只在晚挂点 onDecide 有效`) |
227
+ | `ctx.annihilate` 没反应 | 是不是写在晚挂点了(会被拒 + warn:`annihilate 只在早挂点 onInbound 有效`);或者对方是主人(`refused-owner`) |
228
+ | 低分消息还是回话了 | 日志里搜 `强制入站(入站扩展 forceWake…` —— 可能是你自己的扩展要求的;也可能是 `forceWake` 之后 debounce 把标记带下去了(正常) |
229
+ | 禁言没生效 | `isMuted()` 是不是写在"消灭"之前却被后面的分支覆盖了;名单文件在 `.state/<你的文件名>.json` |
230
+ | 熔断了 | 日志搜 `⚠ 熔断「…」`;复位 = 改文件 + `/tools-reload` |