@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,306 @@
1
+ /**
2
+ * class-advisor.mjs — 「辅导员」示例:把群当班级管(2026-10-12)
3
+ *
4
+ * 抄这个文件就能改出自己的群规:它是**四个动作全都用上**的完整样例,
5
+ * 每个动作都标了"为什么必须在这一步"。
6
+ *
7
+ * 放在 `{数据根}/.qqbot-extensions/inbound/class-advisor.mjs`
8
+ * (插件启动时会自动铺一份,只补不覆盖)。
9
+ *
10
+ * ✅ **本示例默认「只观察、不动手」**:`DRY_RUN = true`(见下面「一、可调参数」)。
11
+ * 装上之后它会正常跑,但**只往日志里写"我本来会做什么"**,不会消灭消息、不会禁言、不会改文本。
12
+ * 你先看几天日志(`[class-advisor] DRY` 开头那些行),确认规则符合预期,再把 `DRY_RUN` 改成 `false`。
13
+ *
14
+ * 改成 false 之后就真的会动手,默认规则只有两类,都由关键词/频率触发:
15
+ * · 广告特征(加微信 / 兼职刷单日赚 / "点击链接" / 短链)→ 消灭 + 临时禁言 10 分钟;
16
+ * · 同一人 30 秒内连发 8 条 → 消灭 + 临时禁言 10 分钟(阈值见下面 SPAM_*)。
17
+ * **不确定就先别开**:保持 `DRY_RUN = true`,或把文件名改成 `class-advisor.mjs.bak`
18
+ * (非 .mjs 不扫描),或把 `AD_MUTE_MINUTES` 设成 0(只删不封)、把 `SPAM_MAX` 调大。
19
+ * 每一次动作(含被白名单/熔断拒绝的)都会写进
20
+ * `{数据根}/.qqbot-extensions/inbound/.state/audit.jsonl`,随时可回看;
21
+ * 主人(`config.groupAdmin.owners`)**永远不会**被它消灭或禁言(插件强制)。
22
+ *
23
+ * ══════════════════════════════════════════════════════════════════════════
24
+ * 辅导员要的四件事,粒度与位置各不相同:
25
+ *
26
+ * | 能力 | 粒度 | 本文件在哪做(为什么) |
27
+ * |---|---|---|
28
+ * | **禁言群友**(按发送者静默一段时间,跨重启仍有效) | 用户维 | 早挂点,**最前**(名单必须在"要不要回/@没@/冷却"之前生效) |
29
+ * | **替换/和谐入站文本** | 消息维 | 早挂点,`ctx.rewrite` —— **必须在记群历史之前**,否则库里存的是原文 |
30
+ * | **强制入站**(不管值不值得回、有没有@、冷却到没到) | 消息维 | 早挂点,`ctx.forceWake`(它同时压过 @门控 与价值评分/冷却) |
31
+ * | **直接不入站**(消灭:不进历史、不进上下文、不占冷却) | 消息维 | 早挂点,`ctx.annihilate` + **不调 next()** |
32
+ * | (附加)**@ 覆盖**:引用转发不算 @ | 消息维 | 晚挂点(`export default` / `onDecide`),见同目录 no-wake-on-child-quote.mjs |
33
+ *
34
+ * ⚠️ **顺序即优先级**(这是本文件最要紧的一条):
35
+ * ① 主人指令 → ② **求助关键词(必须写在黑名单判断之前!)** → ③ 广告/刷屏 → ④ 禁言名单 → ⑤ 敏感词改写
36
+ * 把"求助"排在"名单"后面,就会出现"被禁言的人喊救命没人理"——
37
+ * 强唤醒要压过静默,这是主人点名的保命件之一。
38
+ * ══════════════════════════════════════════════════════════════════════════
39
+ *
40
+ * ── 你能用的东西(完整清单见同目录 README.md)──────────────────────────────
41
+ * ctx.text / ctx.scope / ctx.sender / ctx.senderName / ctx.isOwner / ctx.groupId / ctx.mentions
42
+ * ctx.quoted.text / .id / .fromSelf 被引原文、被引那条的发送者、是不是她自己说的
43
+ * ctx.platform.mentioned 平台 @ 信号(早挂点读不到 ctx.state.mention)
44
+ * ctx.logger / ctx.dataRoot / ctx.kv.get|set 日志、数据根、每个扩展自己的持久化
45
+ * ctx.annihilate / ctx.rewrite / ctx.forceWake / ctx.decide
46
+ * ctx.muteUser / ctx.unmuteUser / ctx.isMuted / ctx.mutedUsers
47
+ *
48
+ * ── 四条保命件(插件侧强制,你写错也兜得住)───────────────────────────────
49
+ * · 白名单最高优先:主人(`config.groupAdmin.owners`)**永不可被静默/消灭**,
50
+ * 调 `annihilate`/`muteUser` 会被拒 + 告警,消息照常放行;
51
+ * · 强唤醒优先于静默:`forceWake` 不被 `annihilate` 以外的东西抵消(顺序由你排,见上面 ⚠️);
52
+ * · 熔断:本扩展短期内消灭/静默占比过高(>50%)或 10 秒内超过 20 次 ⇒
53
+ * 停掉**本扩展的破坏性动作**(消息照常放行)+ 一条 warn;改文件后 `/tools-reload` 即复位;
54
+ * · 审计 + 可撤销:每次动作追加一行
55
+ * `{数据根}/.qqbot-extensions/inbound/.state/audit.jsonl`;
56
+ * `ctx.muteUser` 随时可以用 `ctx.unmuteUser({ id })` 解掉。
57
+ *
58
+ * ── 本文件**不会被自动铺到你的目录**(2026-10-12 主人定)────────────────────
59
+ * 同目录的 `no-wake-on-child-quote.mjs` 属"装上就该有"的规则,插件首次启动会替你铺一份;
60
+ * 本文件不铺 —— 因为它会**真动手删消息、封人**,不该替你决定。要试就自己拷:
61
+ * copy 本文件 → `{数据根}/.qqbot-extensions/inbound/class-advisor.mjs`
62
+ * (拷完 `/tools-reload` 或重启即生效;**记得先留 `DRY_RUN = true` 观察几天**。)
63
+ *
64
+ * ── 它会往你数据目录里写什么(就这两样,别的一样不碰)──────────────────────
65
+ * · `{数据根}/.qqbot-extensions/inbound/.state/<本文件名>.json`
66
+ * 形状:`{ "mutes": [{ "id", "until", "reason", "by", "at" }], "kv": {} }`
67
+ * **只在真的禁言/解禁时才写**;`until = 0` 表示永久(`minutes <= 0`);读名单时惰性清理过期项;
68
+ * 文件坏了当空处理(插件不会因为一个坏 JSON 崩掉)。
69
+ * · `{数据根}/.qqbot-extensions/inbound/.state/audit.jsonl`
70
+ * 每次真动作(含被白名单/熔断拒绝的)追加一行,摘要截断、512KB 轮转。
71
+ * ⚠️ **刷屏计数不写盘**(只在内存里存 30 秒窗口)—— 早先版本用 `ctx.kv` 落盘,
72
+ * 而它对每一条消息都会被调到 ⇒ 每条群消息写一次文件。已改掉,这条路径不碰磁盘。
73
+ * ⚠️ **`DRY_RUN = true` 时上面两个文件都不会出现**(只打日志、不动手)。
74
+ */
75
+
76
+ /** 日志展示名 */
77
+ export const name = 'class-advisor';
78
+
79
+ // ══════════════════════════════════════════════════════════════
80
+ // 一、可调参数(抄去改这里)
81
+ // ══════════════════════════════════════════════════════════════
82
+
83
+ /**
84
+ * ★★ **安全默认:`true` = 只观察不动手**(本文件唯一的"总闸")★★
85
+ *
86
+ * true —— 下面所有**自动规则**(广告/刷屏/禁言名单/敏感词和谐)都只往日志写一行
87
+ * `[class-advisor] DRY …`,说明"我本来会做什么",**不消灭、不禁言、不改文本**。
88
+ * 消息照常入站,等于一份"这个规则在我群里会怎么判"的体检报告。
89
+ * false —— 真的动手。
90
+ *
91
+ * 为什么默认 true:**装上不等于放权**。一个从网上抄来的规则文件不该在你没看过它判断之前
92
+ * 就开始删消息、封人。
93
+ *
94
+ * 推荐节奏:先留 true 观察几天 → 翻日志确认它只在你想拦的那些消息上命中 → 改成 false。
95
+ *
96
+ * ⚠️ 例外:**主人亲口下的指令不受总闸影响**(下面 ① 的 `/禁言`、`/解禁` 照常执行)——
97
+ * 那是你明确下的令,不是这条规则自己判断的。
98
+ */
99
+ let DRY_RUN = true;
100
+
101
+ /**
102
+ * 运行时切换总闸(`setLive(true)` = 真的动手);返回"这次之前是不是 live"。
103
+ * 平时用不着 —— 直接改上面那行 `let DRY_RUN` 更直观;
104
+ * 这个出口是给**测试**和"想临时开一下 / 临时关掉"的场景用的。
105
+ */
106
+ export function setLive(live) {
107
+ const wasLive = !DRY_RUN;
108
+ DRY_RUN = !live;
109
+ return wasLive;
110
+ }
111
+
112
+ /** 当前是不是"真的动手"(`false` = 只观察不动手) */
113
+ export function isLive() {
114
+ return !DRY_RUN;
115
+ }
116
+
117
+ /**
118
+ * **求助关键词** —— 命中就 `forceWake`:不管有没有 @ 她、分数多低、群冷却到没到,都要让她看到。
119
+ * ⚠️ 判断必须放在**禁言名单之前**(见文件头"顺序即优先级")。
120
+ * 注:`forceWake` 属于"让消息更可能被看见",不是破坏性动作,**不受 DRY_RUN 影响**(它只会多看见、不会漏看)。
121
+ */
122
+ const HELP_KEYWORDS = ['求助', '救命', '急', '报错', '怎么解决', '谁来帮', '帮我看下', '大佬救命', '卡住了'];
123
+
124
+ /** 广告/刷屏特征(正则)。命中 → 消灭;连续刷屏的还会被临时禁言 */
125
+ const AD_PATTERNS = [
126
+ /加\s*微\s*信/i,
127
+ /(?:兼职|刷单|日赚|躺赚|带赚)/,
128
+ /(?:点击|戳)\s*(?:链接|这里)/,
129
+ /https?:\/\/\S*(?:t\.cn|dwz|url\.cn|sourl)/i, // 短链(自己群里正常的网盘长链不受影响)
130
+ ];
131
+
132
+ /** 刷屏判定:同一人 SPAM_WINDOW_MS 内发到 SPAM_MAX 条 → 算刷屏
133
+ * ⚠️ 阈值刻意保守(30 秒内 8 条):正常连发几句不会被误伤;真的要灌水才会命中。
134
+ * 觉得太严就调大 SPAM_MAX / 调小窗口,或者干脆把下面那段 `noteAndCheckSpam` 去掉。 */
135
+ const SPAM_WINDOW_MS = 30_000;
136
+ const SPAM_MAX = 8;
137
+
138
+ /** 广告初犯禁言多久(分钟);`<= 0` = 永久。想"只删不封"就把这里设成 0 并删掉 muteUser 那两行 */
139
+ const AD_MUTE_MINUTES = 10;
140
+
141
+ /** 敏感词 → 和谐成 `***`(**改写在记群历史之前**,所以 AI 与历史里看到的都是和谐后的文本) */
142
+ const SENSITIVE_WORDS = ['傻逼', '妈的', '滚蛋']; // ← 按自己群的情况改
143
+
144
+ /** 手机号打码(11 位,1 开头)—— 群里晒号很常见,顺手和谐掉 */
145
+ const PHONE_RE = /(?<!\d)(1[3-9]\d)\d{4}(\d{4})(?!\d)/g;
146
+
147
+ // ══════════════════════════════════════════════════════════════
148
+ // 二、纯函数(可单测、可以单独抄走)
149
+ // ══════════════════════════════════════════════════════════════
150
+
151
+ /** 这条文本是不是在求助? */
152
+ export function isHelpText(text) {
153
+ const t = String(text ?? '');
154
+ if (!t) return false;
155
+ return HELP_KEYWORDS.some((k) => t.includes(k));
156
+ }
157
+
158
+ /** 这条文本像不像广告? */
159
+ export function isAdText(text) {
160
+ const t = String(text ?? '');
161
+ if (!t) return false;
162
+ return AD_PATTERNS.some((re) => re.test(t));
163
+ }
164
+
165
+ /** 和谐敏感词与手机号;没有可和谐的返回 null(省一次 rewrite) */
166
+ export function maskText(text) {
167
+ let out = String(text ?? '');
168
+ const before = out;
169
+ for (const w of SENSITIVE_WORDS) {
170
+ if (!w) continue;
171
+ out = out.split(w).join('*'.repeat(w.length));
172
+ }
173
+ out = out.replace(PHONE_RE, '$1****$2');
174
+ return out === before ? null : out;
175
+ }
176
+
177
+ /**
178
+ * 刷屏判定:**只存在内存里**(30 秒的窗口,重启清零完全没影响)。
179
+ *
180
+ * ⚠️ 2026-10-12 改(主人:"别给我拉屎"):原来这里用 `ctx.kv` **落盘**,而这个函数对
181
+ * **每一条**入站消息都会被调到 ⇒ **每来一条群消息就同步写一次状态文件**。
182
+ * 那既没必要(窗口只有 30 秒,重启还留着它毫无意义),也是在用户数据目录里拉屎。
183
+ * 改成进程内的 Map 之后,**这条路径彻底不碰磁盘**。
184
+ */
185
+ const spamHits = new Map(); // senderId -> 最近发言时刻数组
186
+
187
+ export function noteAndCheckSpam(ctx) {
188
+ const key = String(ctx.sender ?? '');
189
+ const now = Date.now();
190
+ const prev = spamHits.get(key) ?? [];
191
+ const recent = prev.filter((t) => now - t < SPAM_WINDOW_MS);
192
+ recent.push(now);
193
+ spamHits.set(key, recent.slice(-SPAM_MAX)); // 只留最近 SPAM_MAX 个时刻
194
+ // 顺带把"早就不说话了"的人清掉,别让 Map 无限涨(几百人规模也只是个小 Map)
195
+ if (spamHits.size > 500) {
196
+ for (const [k, v] of spamHits) {
197
+ if (!v.some((t) => now - t < SPAM_WINDOW_MS)) spamHits.delete(k);
198
+ }
199
+ }
200
+ return recent.length >= SPAM_MAX;
201
+ }
202
+
203
+ // ══════════════════════════════════════════════════════════════
204
+ // 三、早挂点:四件事全在这(记群历史之前)
205
+ // ══════════════════════════════════════════════════════════════
206
+
207
+ /**
208
+ * `onInbound` —— 挂在 **quoteRef 之后、记群历史之前**。
209
+ * 这里做的每一步都会**改变"群里到底存了什么/她到底看不看得到"**,所以要按优先级排好。
210
+ */
211
+ export async function onInbound(ctx, next) {
212
+ try {
213
+ const text = String(ctx.text ?? '');
214
+
215
+ // ── ① 主人指令(只有主人能下令;`ctx.isOwner` 就是 `config.groupAdmin.owners` 里有没有他)──
216
+ // 用法:主人**回复**某个群友的消息,然后发 `/禁言 30`(分钟;0 或省略 = 永久)。
217
+ // ⚠️ 注意这里用 `rewrite` 而不是 `annihilate`:**主人的消息永远不被入站扩展吞掉**
218
+ // (白名单最高优先,引擎发现"消灭被拒"或"没放行主人的消息"会替它放行并告警)。
219
+ // 指令消息改成一行留痕,既不污染上下文、又符合"主人消息必须放行"的规矩。
220
+ const cmd = /^\/禁言\s*(\d+)?\s*$/.exec(text.trim());
221
+ if (cmd && ctx.isOwner) {
222
+ const target = ctx.quoted.id; // 被回复那条的作者 openid(拿不到是空串)
223
+ const minutes = cmd[1] === undefined ? 0 : Number(cmd[1]);
224
+ if (!target) {
225
+ ctx.logger.warn?.('[class-advisor] /禁言:要**回复**某个群友的消息再发这条指令(拿不到被回复人)');
226
+ } else {
227
+ const m = ctx.muteUser({ id: target, minutes, reason: '主人指令 /禁言' });
228
+ ctx.logger.info?.(`[class-advisor] 主人禁言 ${target}:${m ? (m.until === 0 ? '永久' : minutes + ' 分钟') : '失败(见审计)'}`);
229
+ }
230
+ ctx.rewrite({ text: '(辅导员:禁言指令已处理)', reason: '主人指令' });
231
+ return next();
232
+ }
233
+ // 解禁:`/解禁` + 回复某人
234
+ if (/^\/解禁\s*$/.test(text.trim()) && ctx.isOwner) {
235
+ const target = ctx.quoted.id;
236
+ if (target) ctx.unmuteUser({ id: target });
237
+ ctx.rewrite({ text: '(辅导员:解禁指令已处理)', reason: '主人指令' });
238
+ return next();
239
+ }
240
+
241
+ // ── ② 求助优先(**必须写在黑名单判断之前**)──
242
+ // 被禁言的人喊"救命"也要放行:强唤醒优先于静默。
243
+ if (isHelpText(text)) {
244
+ ctx.forceWake({ reason: '求助关键词' });
245
+ ctx.logger.debug?.(`[class-advisor] 求助关键词命中 → 强制入站: "${text.slice(0, 30)}"`);
246
+ return next(); // ★ 仍然往下走(forceWake 只是"必须进站",不是"拦掉别人")
247
+ }
248
+
249
+ // ── ③ 广告 / 刷屏 → 消灭(顺手临时禁言;白名单与熔断会兜住误伤)──
250
+ if (isAdText(text) || noteAndCheckSpam(ctx)) {
251
+ const why = isAdText(text) ? '广告特征' : `${SPAM_WINDOW_MS / 1000}s 内连发 ${SPAM_MAX} 条`;
252
+ if (DRY_RUN) {
253
+ // 只观察:说清"本来会做什么",然后把消息放过去(绝不 return)
254
+ ctx.logger.info?.(`[class-advisor] DRY 本来要【消灭${AD_MUTE_MINUTES !== 0 ? ` + 禁言 ${AD_MUTE_MINUTES} 分钟` : ''}】`
255
+ + `(${why})sender=${ctx.senderName || ctx.sender} text="${text.slice(0, 40)}"`);
256
+ } else {
257
+ if (AD_MUTE_MINUTES !== 0) {
258
+ ctx.muteUser({ minutes: AD_MUTE_MINUTES, reason: `广告/刷屏:${why}` });
259
+ }
260
+ ctx.annihilate({ reason: `广告/刷屏:${why}` });
261
+ return; // ★ 不调 next() = 不进群历史、不进上下文、不占群冷却
262
+ }
263
+ }
264
+
265
+ // ── ④ 禁言名单 → 静默(名单是持久的,重启仍在;`isMuted()` 缺省查当前发送者)──
266
+ const muted = ctx.isMuted();
267
+ if (muted) {
268
+ const left = muted.permanent ? '永久' : `还剩 ${muted.remainingMinutes} 分钟`;
269
+ if (DRY_RUN) {
270
+ ctx.logger.info?.(`[class-advisor] DRY 本来要【静默】sender=${ctx.senderName || ctx.sender}`
271
+ + `(禁言中 · ${left} · ${muted.reason ?? '未写原因'})`);
272
+ } else {
273
+ ctx.annihilate({ reason: `禁言中(${left}):${muted.reason ?? '未写原因'}` });
274
+ return; // ★ 同样是不调 next()
275
+ }
276
+ }
277
+
278
+ // ── ⑤ 敏感词/手机号 → 改写(**必须在记群历史之前**:库里存的就是和谐后的文本)──
279
+ const masked = maskText(text);
280
+ if (masked !== null) {
281
+ if (DRY_RUN) {
282
+ ctx.logger.info?.(`[class-advisor] DRY 本来要【改写】"${text.slice(0, 40)}" → "${masked.slice(0, 40)}"`);
283
+ } else {
284
+ ctx.rewrite({ text: masked, reason: '敏感词/手机号和谐' });
285
+ ctx.logger.debug?.(`[class-advisor] 已和谐入站文本(历史与上下文都用改后文本)`);
286
+ }
287
+ }
288
+ } catch {
289
+ /* 兜底:本扩展出任何问题都绝不影响主链(引擎也还有一层 try/catch) */
290
+ }
291
+ return next();
292
+ }
293
+
294
+ // ══════════════════════════════════════════════════════════════
295
+ // 四、可选:晚挂点(@ 覆盖)
296
+ // ══════════════════════════════════════════════════════════════
297
+ // 本文件**故意不导出**晚挂点:上面四件事全都必须发生在"记群历史之前"。
298
+ // 需要"引用转发不算 @"这类**改 @ 判定**的规则,请照抄同目录的
299
+ // no-wake-on-child-quote.mjs(它同时演示了两个挂点),或者在本文件里额外加:
300
+ //
301
+ // export default async function onDecide(ctx, next) {
302
+ // if (ctx.state?.mention?.wasMentioned === true && ctx.quoted.fromSelf === false && /【子会话/.test(ctx.quoted.text)) {
303
+ // ctx.decide({ mention: 'ignore', reason: '引用的是子会话转发' });
304
+ // }
305
+ // return next();
306
+ // }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * no-wake-on-child-quote.mjs — 示例入站中间件:引用「子会话转发」的消息时不按 @ 处理
3
+ * (2026-10-12 升级:**同时演示早/晚两个挂点**;晚挂点那段逻辑一个字没改)
4
+ *
5
+ * 放在 `{数据根}/.qqbot-extensions/inbound/no-wake-on-child-quote.mjs`
6
+ * (插件启动时会自动铺一份,只补不覆盖)。
7
+ *
8
+ * ── 它解决什么 ──────────────────────────────────────────────────────────────
9
+ * 群里有人把别的会话(子会话)的转发消息**引用**过来,再顺带 @ 一下她 ——
10
+ * 平台信号上这确实是「被 @ 了」,于是:
11
+ * · 门控放行;· debounce 认为"窗口里有 @" → **绕过群冷却、静默 3 秒后强制唤醒**;
12
+ * · 上下文里那条还会被标成 `(@you)`、结构化标记 `mentioned:true`。
13
+ * 但那条被引用的原文其实只是「另一个会话的转发内容」,不该因此强行叫醒她 ——
14
+ * 她照常看到这条消息(进历史、进上下文),只是**不按"被点名"处理**(该不该开口交给她自己按守则判断)。
15
+ *
16
+ * ── 两个挂点各干什么(本文件同时演示)──────────────────────────────────────
17
+ *
18
+ * ```
19
+ * … → quoteRef → ★onInbound(早,本文件的 export function onInbound)★
20
+ * → 引用图预取 → 群历史缓冲 → … → mentionGate(@ 门控)
21
+ * → ★默认导出 / onDecide(晚,本文件的 export default)★ → contentSanitizer → debounce → …
22
+ * ```
23
+ *
24
+ * | | 早(`onInbound`) | 晚(`export default` / `onDecide`) |
25
+ * |---|---|---|
26
+ * | 能读 | `ctx.message` / `ctx.state.quote` / `ctx.platform.mentioned` | 以上全部 **+ `ctx.state.mention`**(门控判定) |
27
+ * | 能做 | 消灭 · 改写 · 强制入站 · 查/写禁言名单 | 覆盖 @ 判定 · 改写 · 强制入站 · 查/写禁言名单 |
28
+ * | 不能做 | `ctx.decide`(mention 还没写出来) | `ctx.annihilate`(群历史已经记过了,会被拒绝) |
29
+ *
30
+ * 晚挂点怎么做到"降级 @ 判定":链上位置在 mentionGate **之后**,所以这里:
31
+ * ① 读得到 `ctx.state.mention`(门控刚写下的判定);
32
+ * ② **改得动**它 —— `ctx.decide({ mention: 'ignore' })`(等价于直接写 `wasMentioned = false`,
33
+ * 但会落审计、也会把"早挂点误调"这种用法明确拒绝掉);
34
+ * ③ 还能直接不调 `next()` 把这条**整条拦掉**(本示例不用,它只是"降级"不是"拦截")。
35
+ * 被引原文的来源是现成的 `ctx.state.quote.text`(quoteRef 中间件在链前部赋的值,
36
+ * media-history 与 debounce 都在读它)——**别自己写引用解析**。
37
+ *
38
+ * 早挂点在本文件里做什么:**只读观测 + 两个默认关闭的可选动作**。
39
+ * 它是链上唯一"还没记群历史"的位置 —— 所以消灭(`ctx.annihilate`)、改写(`ctx.rewrite`)、
40
+ * 强制入站(`ctx.forceWake`)都只在这里才是"真动作"。本示例默认**不做**破坏性动作
41
+ * (它是插件自动铺给每个用户的示例,不能悄悄吞消息),把写法留在下面两个开关里:
42
+ * 想用就把开关改成 true,改完 `/tools-reload` 立即生效。
43
+ *
44
+ * ── 用法/契约速查 ──────────────────────────────────────────────────────────
45
+ * · 早挂点:`export async function onInbound(ctx, next)`(也可以是 `export const onInbound = …`);
46
+ * · 晚挂点:`export default async (ctx, next)`,**语义等同 `export async function onDecide(ctx, next)`**;
47
+ * 一个文件可以同时导出两个,互不影响;只导出其一也完全可以;
48
+ * · 可选导出 `dispose()`,插件卸载时调用(本示例没有需要收尾的东西,故不导出);
49
+ * · `{数据根}/.qqbot-extensions/inbound/` 目录不存在或为空 = 完全安静(不打任何日志);
50
+ * · 单个扩展抛错只告警并继续 `next()` —— 用户脚本坏了**绝不会**把入站消息吞掉;
51
+ * · 任何动作(annihilate/rewrite/forceWake/decide/muteUser…)内部都有 try/catch,**不会抛到链上**;
52
+ * · 改完文件后:`/tools-reload`(或工具 `tools_reload`)即可热重载;重启插件当然也生效。
53
+ */
54
+
55
+ /** 只用于日志展示(不写就用文件名) */
56
+ export const name = 'no-wake-on-child-quote';
57
+
58
+ /**
59
+ * 子会话转发前缀的宽松匹配。
60
+ *
61
+ * 宽松是刻意的:**不抠 id、不要求右括号** —— 被引原文可能被平台按 60 字截断,
62
+ * 于是 `【子会话 abc1`(无 `】`)与光秃秃的 `【子会话`(正好截在前缀处)都要命中。
63
+ * 普通文本里出现"子会话"三个字但**没有 `【`** 一律不命中(避免误伤正常聊天)。
64
+ */
65
+ const CHILD_SESSION_QUOTE_RE = /【子会话(?:\s|\u3000|$)/;
66
+
67
+ /**
68
+ * 纯函数(可单测、也可被别的扩展/debug 复用):
69
+ * 这段"被引用的原文"是不是一条子会话转发消息?
70
+ *
71
+ * @param {string|null|undefined} quotedText 被引原文(= `ctx.state.quote?.text`)
72
+ * @returns {boolean} true = 命中子会话转发前缀
73
+ */
74
+ export function isChildSessionForwardQuote(quotedText) {
75
+ const t = String(quotedText ?? '');
76
+ if (!t) return false;
77
+ return CHILD_SESSION_QUOTE_RE.test(t);
78
+ }
79
+
80
+ // ══════════════════════════════════════════════════════════════
81
+ // 开关:早挂点里两个**默认关闭**的动作示范
82
+ // ══════════════════════════════════════════════════════════════
83
+
84
+ /**
85
+ * true = 把"纯转发壳"消息**消灭**掉(不进群历史、不进上下文、不占群冷却)。
86
+ *
87
+ * 什么算纯转发壳:正文去掉 @ 之后**什么都不剩**,且**没有附件**。
88
+ * 这种消息对她没有任何信息量,却会占到一次群历史与一枚群冷却。
89
+ * ⚠️ 默认 **false**:本文件是插件自动铺给每个用户的示例,不能悄悄吞消息 ——
90
+ * 想用请自己改成 true,并确认你知道"消灭 = 这条连历史都不进"。
91
+ */
92
+ const ANNIHILATE_EMPTY_CHILD_FORWARD = false;
93
+
94
+ /**
95
+ * true = 把正文开头的转发头 `【子会话 xxx】` **改写**掉(群历史里存的就是改后文本)。
96
+ * ⚠️ 默认 **false**:转发头有时是用户自己想要的线索,先看清楚再开。
97
+ */
98
+ const REWRITE_STRIP_CHILD_PREFIX = false;
99
+
100
+ /** 转发头(比"引用判定"严格:要求带 id 与右括号)—— 只给上面两个开关用 */
101
+ const CHILD_SESSION_PREFIX_RE = /^【子会话\s*[0-9a-fA-F]{1,32}】\s*/;
102
+
103
+ /** 只留"真正的内容":去掉 @ 标记与空白,用来判断"这条是不是空壳" */
104
+ function contentWithoutMentions(ctx) {
105
+ return String(ctx?.text ?? '')
106
+ .replace(/<@!?[^>]+>/g, '') // QQ 的 @ 标记:<@openid> / <@!openid>
107
+ .replace(/@bot/g, '') // 进上下文前后插件会把 @她 换成 @bot(这里只是保险)
108
+ .replace(/\s+/g, '');
109
+ }
110
+
111
+ /**
112
+ * ★ 早挂点 `onInbound`(2026-10-12 新增)★
113
+ *
114
+ * 位置:**quoteRef 之后、记群历史之前**。这一层能读、能做的东西与晚挂点不同:
115
+ * · 读得到 `ctx.message` / `ctx.state.quote`(被引原文)/ `ctx.platform.mentioned`(平台 @ 信号);
116
+ * · **读不到** `ctx.state.mention`(那是后面 mentionGate 才写的,这里永远是 undefined —— 别读);
117
+ * · 只有在这里:`ctx.rewrite()` 能改到**群历史**里存的文本;`ctx.annihilate()` 能真的"消灭"
118
+ * (不调 next() ⇒ 后面的群历史缓冲、门控、评分、冷却全都不会跑)。
119
+ *
120
+ * ⚠️ 要把 `next()` 交出去(`return next()`):除非你**确实**想消灭这条消息。
121
+ */
122
+ export async function onInbound(ctx, next) {
123
+ try {
124
+ // 这里读不到 ctx.state.mention —— 想看"平台认为这条 @ 了她吗",用 ctx.platform.mentioned
125
+ // (判据与 SDK 的 mentionGate 完全一致:rawEventType / mentions[].is_you / 正文里的 <@appId>)
126
+ const mentioned = ctx?.platform?.mentioned === true;
127
+ const quoted = ctx?.quoted?.text ?? '';
128
+
129
+ if (ctx?.scope === 'group' && isChildSessionForwardQuote(quoted)) {
130
+ // 只读观测(debug 级,别刷屏):这一步群历史**还没记** —— 对照晚挂点那条日志的时间序
131
+ ctx?.logger?.debug?.(
132
+ `[ext-inbound:${name}] 早挂点:引用的是子会话转发(平台@=${mentioned},正文"${String(ctx.text).slice(0, 20)}")` +
133
+ ' → 群历史尚未记录,改写/消灭只能在这里做',
134
+ );
135
+
136
+ // ── 可选动作 ①:改写(把转发头从**要进群历史**的正文里去掉)──
137
+ if (REWRITE_STRIP_CHILD_PREFIX && CHILD_SESSION_PREFIX_RE.test(String(ctx.text ?? ''))) {
138
+ ctx.rewrite({
139
+ text: String(ctx.text).replace(CHILD_SESSION_PREFIX_RE, ''),
140
+ reason: '去掉子会话转发头(历史里不必留机器前缀)',
141
+ });
142
+ }
143
+
144
+ // ── 可选动作 ②:消灭(纯转发空壳:没正文、没附件,只剩一个 @)──
145
+ const atts = Array.isArray(ctx?.message?.attachments) ? ctx.message.attachments : [];
146
+ if (ANNIHILATE_EMPTY_CHILD_FORWARD && atts.length === 0 && contentWithoutMentions(ctx) === '') {
147
+ ctx.annihilate({ reason: '子会话转发空壳(无正文无附件)' });
148
+ return; // ★ 不调 next() = 这条不进历史、不进上下文、不占冷却
149
+ }
150
+
151
+ // ── 可选动作 ③:强制入站(确定要她看到这条时)──
152
+ // ctx.forceWake({ reason: '引用里点名了她' }); // 跳过 @门控 / 价值评分 / 群冷却
153
+ }
154
+ } catch {
155
+ /* 兜底:本扩展出任何问题都绝不影响主链 */
156
+ }
157
+ return next();
158
+ }
159
+
160
+ /**
161
+ * ★ 晚挂点(默认导出 = `onDecide`)★ —— 只做编排,判定逻辑在上面的纯函数里。
162
+ *
163
+ * ⚠️ 只在**群聊**生效:私聊的 `wasMentioned=true` 是 SDK 的 passthrough 标记(`reason: 'passthrough'`),
164
+ * 不是"被 @ 了"的判定;压它只会让上下文丢掉 `(@you)` 标签,而私聊本来每条都唤醒 ⇒
165
+ * 既拦不住也不该动。
166
+ *
167
+ * ⚠️ 永远 `await next()`(这里直接 `return next()`):本示例是"降级"不是"拦截",
168
+ * 绝不能把消息吞掉 —— 吞掉会让这条既不进上下文也不进历史。
169
+ */
170
+ export default async function noWakeOnChildQuote(ctx, next) {
171
+ try {
172
+ if (ctx?.message?.kind === 'group') {
173
+ const quoted = ctx?.state?.quote?.text;
174
+ if (isChildSessionForwardQuote(quoted)) {
175
+ const mention = ctx?.state?.mention;
176
+ // 只压「确实是 @ 判定为 true」的那种;别的(本来就没@)不用动
177
+ if (mention && mention.wasMentioned === true) {
178
+ // 走动作 API(而不是直接改 ctx.state.mention):会落一行审计,
179
+ // 而且"在早挂点调 decide"这种用法会被明确拒绝 + 告警。
180
+ ctx.decide({ mention: 'ignore', reason: '引用的是子会话转发消息' });
181
+ // debug 级 —— 别刷屏(群消息多,这条规则会经常命中)
182
+ ctx.log?.debug?.(
183
+ `[ext-inbound:${name}] 引用的是子会话转发消息 → 不按 @ 处理: ${String(quoted).slice(0, 40)}`,
184
+ );
185
+ }
186
+ }
187
+ }
188
+ } catch {
189
+ /* 兜底:本扩展出任何问题都绝不影响主链 */
190
+ }
191
+ return next();
192
+ }
@@ -46,6 +46,28 @@
46
46
  | 抓取通道 | 先走 Bing 的 RSS 端点(`?format=rss`,解析最稳、不需要 key),失败回退 HTML 抓取;国内可直连 `cn.bing.com` |
47
47
  | 暴露范围 | 声明了 `expose: 'all'` ⇒ 全部会话可用(含 web / 终端);只想在 QQ 会话可见就删掉那一行 |
48
48
 
49
+ ## 跨会话四件套 — `session_create` / `session_send` / `session_watch` / `sessions_list`
50
+
51
+ 演示「**让一个会话去开另一个会话、把话投过去、再把结果收回来**」。四个配合用;`lib/` 里是它们
52
+ 按 mtime 版本化加载的内部模块(**子目录不参与工具扫描**,放那里最安全;插件铺设时也会连子目录一起搬)。
53
+
54
+ | 工具 | 作用 | 关键点 |
55
+ |---|---|---|
56
+ | `session_create` | 建**子会话**并把初始任务投进去 | 可指定工作区与人设 —— 工作区名、人设的**显示名或 id** 都认(忽略大小写、唯一子串也认) |
57
+ | `session_send` | 往**任意会话**投消息(发送工具) | 照抄本插件的**入站模式**:忙时插话(`inject`)/ 空闲唤醒(`followup`);首行带来源会话 id |
58
+ | `session_watch` | 挂 / 摘「回合结束回灌」钩子 | 子会话跑完自动把末段正文送回来;`action:"list"` 看钩子名单 |
59
+ | `sessions_list` | 列工作区与会话(只读) | 工作区按**子串**过滤(注意「A」也会命中「AB」) |
60
+
61
+ **三条经验(都是踩出来的)**
62
+
63
+ 1. **别自己拼消息对象** —— 往会话塞消息用宿主原语(`agent.inject` / `agent.followup`);
64
+ 手拼 `user/message` 容易写坏会话(本项目实测触发过 `agent/inbox/spliced requires message objects`,
65
+ 只能靠离线修数据恢复)。
66
+ 2. **投递前先 resume** —— 非 live 的会话直接 `append` 会被**静默丢弃**(不报错,最难查的一种)。
67
+ 3. **可见性靠 `expose` 控** —— `expose:'all'` 会被挂到插件根、**所有会话都能看到**(含 web / 终端);
68
+ 不写该字段(或写别的值)则**只在 QQ 会话注册**,子会话看不到。建议:建会话/挂钩子这类
69
+ **父会话专用**件不给子会话看(免得无限套娃),只把「投递/回执」那一个开放给子会话。
70
+
49
71
  ## 写自己的「按钮回调」工具,只要记住三件事
50
72
 
51
73
  1. **发卡前登记卡片**(不登记 = 按钮点了没人认):
@@ -0,0 +1,85 @@
1
+ /**
2
+ * preset_resolve.mjs — 把「人设」输入解析成宿主的 preset **id**
3
+ *
4
+ * ── 为什么单独成库(2026-10-12)────────────────────────────────────────────
5
+ * 解析规则还会变,而工具文件**一旦落名就不能再改**(宿主扫描后按 `name@tag`
6
+ * 幂等跳过,改了不生效 ⇒ 只能再升一个版本号)。把易变逻辑放这里,用
7
+ * `?v=<mtimeMs>` 版本化加载,改本文件即时生效,工具名不用动。
8
+ *
9
+ * ── 主人指定的匹配顺序(2026-10-12)──────────────────────────────────────
10
+ * 收**任何字符**,依次尝试:
11
+ * ① 预设名(显示名)精确匹配 → 反查 id
12
+ * ② id 精确匹配
13
+ * ③ 两者的「忽略大小写」精确匹配
14
+ * ④ 唯一子串匹配(名或 id 包含输入)——唯一才用,多个则报歧义
15
+ * 都失败 → 返回 error,并把可用清单带上(供调用方直接回给用户)。
16
+ *
17
+ * 输入的容忍度:去首尾空白;剥掉成对的「」『』【】《》""'' 方括号/引号。
18
+ */
19
+ export const __LIB_VERSION__ = 'preset-resolve-v1-20261012-先名后id';
20
+
21
+ /** 归一化输入:trim + 剥掉包裹的引号/书名号 */
22
+ export function normPresetKey(s) {
23
+ let t = String(s ?? '').trim();
24
+ for (let i = 0; i < 3; i += 1) {
25
+ const before = t;
26
+ t = t.replace(/^[「『【《〈((\["'“‘]+/, '').replace(/[」』】》〉))\]"'”’]+$/, '').trim();
27
+ if (t === before) break;
28
+ }
29
+ return t;
30
+ }
31
+
32
+ /** 取全部预设(id + 显示名)——权威来源是宿主服务 `ctx.agentPresets` */
33
+ export async function listPresets(root) {
34
+ const get = (k) => { try { return root?.get?.(k); } catch { return undefined; } };
35
+ const reg = get('agentPresets');
36
+ if (!reg || typeof reg.list !== 'function') return [];
37
+ let all = [];
38
+ try { all = (await reg.list()) || []; } catch { return []; }
39
+ if (!Array.isArray(all)) return [];
40
+ return all
41
+ .map((p) => ({ id: String(p?.id ?? '').trim(), name: String(p?.name ?? '').trim() }))
42
+ .filter((p) => p.id);
43
+ }
44
+
45
+ /** 一行展示:id(显示名) */
46
+ export function presetLabel(p) {
47
+ return p && p.id ? p.id + (p.name ? '(' + p.name + ')' : '') : String(p ?? '');
48
+ }
49
+
50
+ /**
51
+ * 解析人设输入 → preset id。
52
+ * @returns {{id:string, via:string, all:Array, error?:string}}
53
+ * via: 'name' | 'id' | 'name(i)' | 'id(i)' | 'substring' | ''(失败)
54
+ */
55
+ export async function resolvePreset(root, want) {
56
+ const raw = String(want ?? '');
57
+ const all = await listPresets(root);
58
+ // 拿不到注册表 → 原样放行(交给宿主报错,至少不阻断)
59
+ if (!all.length) return { id: normPresetKey(raw), via: 'passthrough', all };
60
+ const k = normPresetKey(raw);
61
+ if (!k) return { id: '', via: '', all, error: '人设参数是空的' };
62
+ const lk = k.toLowerCase();
63
+
64
+ // ① 预设名(显示名)精确 → 反查 id ★主人指定优先
65
+ const n1 = all.find((p) => p.name === k);
66
+ if (n1) return { id: n1.id, via: 'name', all };
67
+
68
+ // ② id 精确
69
+ const i1 = all.find((p) => p.id === k);
70
+ if (i1) return { id: i1.id, via: 'id', all };
71
+
72
+ // ③ 忽略大小写的精确(名 → id)
73
+ const n2 = all.find((p) => p.name.toLowerCase() === lk);
74
+ if (n2) return { id: n2.id, via: 'name(i)', all };
75
+ const i2 = all.find((p) => p.id.toLowerCase() === lk);
76
+ if (i2) return { id: i2.id, via: 'id(i)', all };
77
+
78
+ // ④ 唯一子串(名或 id 包含输入)
79
+ const subs = all.filter((p) => (p.name && p.name.toLowerCase().includes(lk)) || p.id.toLowerCase().includes(lk));
80
+ if (subs.length === 1) return { id: subs[0].id, via: 'substring', all };
81
+ if (subs.length > 1) {
82
+ return { id: '', via: '', all, error: '「' + raw + '」匹配到多个人设,请说得更准: ' + subs.map(presetLabel).join('、') };
83
+ }
84
+ return { id: '', via: '', all, error: '没找到人设「' + raw + '」。可用: ' + all.map(presetLabel).join('、') };
85
+ }