@zaofan/dsh-qqbot 1.8.8 → 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.
- package/README.md +113 -26
- package/README_EN.md +122 -8
- package/dist/features/ext-capabilities.d.ts +85 -0
- package/dist/features/ext-capabilities.d.ts.map +1 -1
- package/dist/features/ext-capabilities.js +4 -0
- package/dist/features/ext-capabilities.js.map +1 -1
- package/dist/features/ext-hooks.d.ts +84 -0
- package/dist/features/ext-hooks.d.ts.map +1 -0
- package/dist/features/ext-hooks.js +299 -0
- package/dist/features/ext-hooks.js.map +1 -0
- package/dist/features/ext-inbound-context.d.ts +202 -0
- package/dist/features/ext-inbound-context.d.ts.map +1 -0
- package/dist/features/ext-inbound-context.js +724 -0
- package/dist/features/ext-inbound-context.js.map +1 -0
- package/dist/features/ext-inbound-state.d.ts +153 -0
- package/dist/features/ext-inbound-state.d.ts.map +1 -0
- package/dist/features/ext-inbound-state.js +419 -0
- package/dist/features/ext-inbound-state.js.map +1 -0
- package/dist/features/ext-inbound.d.ts +117 -0
- package/dist/features/ext-inbound.d.ts.map +1 -0
- package/dist/features/ext-inbound.js +518 -0
- package/dist/features/ext-inbound.js.map +1 -0
- package/dist/features/ext-reload.d.ts +22 -0
- package/dist/features/ext-reload.d.ts.map +1 -1
- package/dist/features/ext-reload.js +32 -0
- package/dist/features/ext-reload.js.map +1 -1
- package/dist/features/extension-store.d.ts +13 -0
- package/dist/features/extension-store.d.ts.map +1 -1
- package/dist/features/extension-store.js +32 -4
- package/dist/features/extension-store.js.map +1 -1
- package/dist/gateway/bootstrap.d.ts.map +1 -1
- package/dist/gateway/bootstrap.js +44 -0
- package/dist/gateway/bootstrap.js.map +1 -1
- package/dist/gateway/debounce.d.ts +43 -1
- package/dist/gateway/debounce.d.ts.map +1 -1
- package/dist/gateway/debounce.js +71 -35
- package/dist/gateway/debounce.js.map +1 -1
- package/dist/gateway/middleware-setup.d.ts.map +1 -1
- package/dist/gateway/middleware-setup.js +88 -1
- package/dist/gateway/middleware-setup.js.map +1 -1
- package/dist/session/session-manager.d.ts.map +1 -1
- package/dist/session/session-manager.js +7 -3
- package/dist/session/session-manager.js.map +1 -1
- package/dist/shared/utils.d.ts +6 -0
- package/dist/shared/utils.d.ts.map +1 -1
- package/dist/shared/utils.js +6 -2
- package/dist/shared/utils.js.map +1 -1
- package/dist/transport/inbound.d.ts.map +1 -1
- package/dist/transport/inbound.js +23 -3
- package/dist/transport/inbound.js.map +1 -1
- package/docs/USER-GUIDE.md +92 -35
- package/docs/botplay-contract.md +2 -0
- package/examples/ext-hooks/README.md +95 -0
- package/examples/ext-hooks/hello-hook.mjs +54 -0
- package/examples/ext-hooks/session-watch-hook.mjs +119 -0
- package/examples/ext-inbound/README.md +230 -0
- package/examples/ext-inbound/class-advisor.mjs +306 -0
- package/examples/ext-inbound/no-wake-on-child-quote.mjs +192 -0
- package/package.json +1 -1
|
@@ -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` |
|
|
@@ -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
|
+
// }
|