@zaofan/dsh-qqbot 1.10.48 → 1.11.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.
Files changed (47) hide show
  1. package/README.md +40 -3
  2. package/client/qqbot-settings.js +487 -30
  3. package/dist/features/ext-capabilities.d.ts +2 -0
  4. package/dist/features/ext-capabilities.d.ts.map +1 -1
  5. package/dist/features/ext-capabilities.js +38 -0
  6. package/dist/features/ext-capabilities.js.map +1 -1
  7. package/dist/features/ext-reload.d.ts +2 -2
  8. package/dist/features/ext-reload.d.ts.map +1 -1
  9. package/dist/features/ext-reload.js +30 -8
  10. package/dist/features/ext-reload.js.map +1 -1
  11. package/dist/features/ext-ui-seed.d.ts +25 -0
  12. package/dist/features/ext-ui-seed.d.ts.map +1 -0
  13. package/dist/features/ext-ui-seed.js +187 -0
  14. package/dist/features/ext-ui-seed.js.map +1 -0
  15. package/dist/features/ext-ui.d.ts +3 -5
  16. package/dist/features/ext-ui.d.ts.map +1 -1
  17. package/dist/features/ext-ui.js +34 -12
  18. package/dist/features/ext-ui.js.map +1 -1
  19. package/dist/features/extension-store.d.ts.map +1 -1
  20. package/dist/features/extension-store.js +3 -0
  21. package/dist/features/extension-store.js.map +1 -1
  22. package/dist/gateway/bootstrap.d.ts.map +1 -1
  23. package/dist/gateway/bootstrap.js +18 -1
  24. package/dist/gateway/bootstrap.js.map +1 -1
  25. package/dist/gateway/middleware-setup.d.ts.map +1 -1
  26. package/dist/gateway/middleware-setup.js +11 -1
  27. package/dist/gateway/middleware-setup.js.map +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +131 -1
  30. package/dist/index.js.map +1 -1
  31. package/dist/session/session-manager.d.ts.map +1 -1
  32. package/dist/session/session-manager.js +2 -1
  33. package/dist/session/session-manager.js.map +1 -1
  34. package/docs/extension-contract.md +400 -255
  35. package/examples/ext-commands//346/211/251/345/261/225/347/244/272/344/276/213.mjs +84 -0
  36. package/examples/ext-hooks/demo-store.mjs +75 -0
  37. package/examples/ext-ui/demo/dock/card.css +55 -0
  38. package/examples/ext-ui/demo/dock/card.html +95 -0
  39. package/examples/ext-ui/demo/dock/card.js +261 -0
  40. package/examples/ext-ui/demo/settings/card.css +61 -0
  41. package/examples/ext-ui/demo/settings/card.html +38 -0
  42. package/examples/ext-ui/demo/settings/card.js +87 -0
  43. package/package.json +145 -144
  44. package/settings-host.js +6680 -6244
  45. package/examples/ext-ui/local-llm/card.css +0 -66
  46. package/examples/ext-ui/local-llm/card.html +0 -38
  47. package/examples/ext-ui/local-llm/card.js +0 -59
@@ -1,255 +1,400 @@
1
- # 扩展契约(写 dsh-qqbot 扩展前,先读这一份)
2
-
3
- > 适用版本:**v1.9.2+**(本文写的就是当前真实实现,不是设想)
4
- > 面向:给 QQ 机器人写扩展的用户 / 让 AI 帮你写扩展的人
5
- > 配套:用户手册 `README.md` §用户扩展、`docs/USER-GUIDE.md` §统一能力包、入站中间件详解 `examples/ext-inbound/README.md`
6
-
7
- ---
8
-
9
- ## 0. 一句话
10
-
11
- **五套扩展点,一套能力包。** 你在哪一套里写代码,拿到的能力面都一样 ——
12
- 不用记五套 API,也不用"自己接生"发消息/存数据/调官方接口这些事。
13
-
14
- ```
15
- ┌─────────────── 统一能力包(makeExtCaps)───────────────┐
16
- tools/ env ──┤ text markdown markdownCard media image voice video file│
17
- commands/ ctx ──┤ at api() appId/appSecret store registerInteractionCard │
18
- botplay/ env ──┤ appendSilent appendWake canAppendSilent │
19
- hooks/ env ──┤ user owners peer log kernel restart │
20
- inbound/ ctx ──┤ + inbound: { annihilate rewrite forceWake decide │
21
- │ silentInto mute* message kv } ← 只有它 │
22
- └───────────────────────────────────────────────────────┘
23
- ```
24
-
25
- `inbound` 那一组为什么只有它有值:**只有它跑在"一条消息正在进站"的时刻**,
26
- "消灭/改写/覆盖 @ 判定"这些动作在别处没有对象。别的四套拿到的 `caps.inbound` 是
27
- `undefined` —— **这是语义,不是缺失**。
28
-
29
- ---
30
-
31
- ## 1. 五套扩展点:目录、时机、拿到什么
32
-
33
- 全部位于 **`{数据根}/.qqbot-extensions/`** 下(数据根 = 工作目录里的 `dshqqbot/`):
34
-
35
- | 目录 | 什么时候跑 | 你的导出 | 你拿到 |
36
- |---|---|---|---|
37
- | `tools/*.mjs` | LLM 调这个工具时 | `export default { name, description, inputSchema, run(args, env) }` | `env`(能力包平铺) |
38
- | `commands/*.mjs` | 用户敲 `/你的命令` 时 | `{ name, handler(ctx) }` | handler 的 `ctx`(能力包平铺) |
39
- | `botplay/*.mjs` | 卡片按钮被点时 | `{ name, events: {...} }` | `env` |
40
- | `hooks/*.mjs` | **插件启动时跑一次** | `export default async (env) => {}` | `env`(含 `env.caps`) |
41
- | `inbound/*.mjs` | **每一条入站消息** | `export async function onInbound(ctx,next)` / `onDecide(ctx,next)`(或 `export default`) | 挂点的 `ctx` |
42
-
43
- 扫描规则(五套一致):**只看目录下的直接子文件**,`.js` / `.mjs` / `.cjs`,按文件名排序;
44
- 子目录不扫描(可放私有模块)。改完文件调 `tools_reload`(或 `/tools-reload`)**即时生效**;
45
- 扩展命令需要重启宿主。
46
-
47
- ---
48
-
49
- ## 2. 统一能力包(五套都有)
50
-
51
- 成员**平铺**在你的 `env` / `ctx` 上,**同时**在命名空间 `env.caps` / `ctx.caps` 里也有一份。
52
-
53
- | 能力 | 用法 | 说明 |
54
- |---|---|---|
55
- | 发纯文本 | `await env.text('hi')` | |
56
- | 发 markdown | `await env.markdown('# 标题')` | 富文本卡片通道,**自带纯文本降级** |
57
- | 发 markdown+按钮 | `await env.markdownCard(md, keyboard)` | 自己拼 keyboard(≤5 行) |
58
- | 发富媒体 | `await env.image(src)` / `voice` / `video` / `file` | `src` 可传 URL 字符串或 `{url}` / `{localPath}` |
59
- | 任意媒体 | `await env.media('file', src)` | kind = image / voice / video / file |
60
- | @人 | `env.at(openid)` | 返回 `<@openid>` 片段,拼进文本 |
61
- | 官方 API | `await env.api('/v2/users/@me')` | **自带 token 与 2h 缓存**;返回 `{ok,data}` 或 `{ok:false,err:{code,human}}` |
62
- | 钥匙 | `env.appId` / `env.appSecret` | 想自己换 token 调任意接口时用 |
63
- | 持久化 | `env.store.load()` / `env.store.save(obj)` | 落 `…/data/<你的文件名>.json`,**升级插件不丢** |
64
- | **进上下文(静默)** | `await env.appendSilent('...')` | **只落上下文、不唤醒**(详见 §4 安全红线) |
65
- | 进上下文(唤醒) | `await env.appendWake('...')` | 走正常回合唤醒一轮 AI(耗 token) |
66
- | **点名唤醒任意会话** | `await env.caps.wakeTo(scope, peerId, text)` | **不需要「当前会话」**,直接点名唤醒某个群/私聊里的 AI。`scope`=`group`/`c2c`,`peerId`=群 openid / 用户 openid。走正常回合:AI 带着该会话的历史与人设自己作答,回复由宿主发到 QQ。<br>**会话被回收、或宿主重启后活跃表为空时,会自动 `getOrCreate` 重建再注入**,调用方不用管。<br>返回 `true`=已排进该会话的回合;宿主没接/找不到目标=`false`(**不抛错**,调用方自己决定回退)。⚠️ `true` 不等于 QQ 一定送达。 |
67
- | **身份** | `env.user.openid` / `env.user.name` / `env.user.isOwner` | 当前发言者。`isOwner` 已按主人白名单**算好布尔值**,直接 `if (!env.user.isOwner) return` 即可,不必自己比数组 |
68
- | **主人判定(便捷)** | `env.isOwner` | 等价于 `env.user.isOwner` 的顶层布尔,最省事的写法 |
69
- | **主人白名单** | `env.owners`(`string[]`) | 原始 openid 数组,`[]` = 未配置主人 |
70
- | **发送者 openid(平铺)** | `env.actorOpenid` | 与 `env.user.openid` 同值。v1.10.42 起才真正暴露(此前只在内部算 `isOwner` 时用到) |
71
- | **会话标识** | `env.peer.scope` / `env.peer.peerId` | `scope` = `'group'` / `'c2c'`;`peerId` = 群 openid 或用户 openid |
72
- | **宿主配置** | `env.config.appId` / `env.config.appSecret` / `env.config.owners` | 给「想自己换 token 调接口」或「自己判主人」的扩展用。⚠️ 命令侧 `ctx.config` **同时**还包含该扩展自己的设置项(面板里填的值),两者合并 |
73
- | **本会话 id** | `env.peerSessionId` | 本 QQ 会话对应的 **DSH 会话 id**(= `session/event` 里那个 `session.id`)。⚠️ **不是 `agent.id`** —— 跨会话回灌/引用回流判"父会话"用的正是它。v1.10.0 起提供;命令与按钮回调侧都能拿到(回调侧字面同值,直接读即可) |
74
- | 日志 | `env.log(...)` | 落插件 logger |
75
- | 内核句柄 | `env.kernel`(工具侧也叫 `env.ctx`) | **全权限"遥控器"**:`ctx.get('服务名')` / `ctx.on(...)` / `ctx.webServer.register(...)`。⚠️ 形状跟随 dsh 版本;`register` 返回的 disposer 要自己收尾 |
76
- | 宿主自重启 | `await env.restart()` | 复用内置 `/bot-restart`(自动识别启动命令、强补 `--no-open`)。默认**仅主人**可触发;要放开写 `env.restart({ requireOwner: false })` |
77
- | 回调按钮卡 | `env.registerInteractionCard({cardId, buttonIds, expireAt})` | 发**带回调按钮**的卡片必须先登记,否则点了没人认 |
78
-
79
- ### 扩展设置页的字段类型(schema)
80
-
81
- 扩展文件里 `export const qqbotSettingsPage = { title, desc, schema: [...] }`,面板就会给它生成设置页。
82
- `schema` 每项支持:
83
-
84
- | type | 控件 | 说明 |
85
- | --- | --- | --- |
86
- | `bool` | 开关 | 值 true/false |
87
- | `number` | 数字框 | 值 number |
88
- | `string`(默认) | 输入框 | 加 `secret: true` 变密码框 |
89
- | `list` | 多行文本 | 一行一项,值 string[] |
90
- | `select` | 下拉 | `options` 可写字符串数组,也可写 `{ value, label }` |
91
-
92
- **`select` + `searchable: true`(1.10.46 起)**
93
-
94
- 选项很多(几十上百条)时,原生 `<select>` 根本没法找。加上这个标记会渲染成
95
- `<input list="…"> + <datalist>`:浏览器原生**输入过滤**,并且**允许手填列表外的值**。
96
-
97
- ```js
98
- { key: 'defaultSpeaker', type: 'select', searchable: true, label: '默认音色',
99
- default: 'Vivi 2.0|zh_female_vv_uranus_bigtts',
100
- options: VOICES.map(v => v.label) } // ['中文名|voice_type', ...]
101
- ```
102
-
103
- ⚠️ 两个必须注意的点:
104
-
105
- 1. **`select` 取到的值永远是字符串**(`'24000'`,不是 `24000`)。
106
- 如果你的扩展要把它传给要求严格类型的第三方接口,**自己转数字**,否则会像 TTS 那样撞
107
- `55000000 cannot unmarshal string into ... of type int`。
108
- 2. 用 `中文名|id` 这种复合值时,**读取侧要负责剥出真值**(本项目 TTS 包用 `pickSpeaker()` 统一处理)。
109
-
110
- ### 扩展包声明「最低插件版本」(1.10.44 起)
111
-
112
- 包的清单文件里可以写 `minPluginVersion`,面板会拿它跟当前装的插件版本对照,不满足就标红:
113
-
114
- ```json
115
- {
116
- "name": "主动陪伴与私聊关怀",
117
- "version": "1.0.6",
118
- "minPluginVersion": "1.10.44"
119
- }
120
- ```
121
-
122
- 规则:
123
-
124
- - **不写 = 不检查**,任何插件版本都允许安装
125
- - 写了的包,市场卡片与扩展总览都会显示「需要插件 vX」;当前版本低于它时红字提示「需要插件 ≥ vX(当前 vY)」
126
- - 扩展总览接口同时返回 `pluginVersion`(插件自身版本),面板顶部「插件版本 vX.Y.Z」那一行就是它
127
- - **用了新能力就应该声明**,否则老用户装上去会在运行期撞 `undefined`
128
-
129
- ### 主动唤醒别人的会话:怎么写 prompt
130
-
131
- `caps.wakeTo` 的第三个参数会被当成**用户消息**注入目标会话,所以写法上有三个要点:
132
-
133
- 1. **自报家门**:`(系统提示,不是主人说的话)现在是 22:50,你和主人已经有 3 小时没有互动了。` —— 否则 AI 会以为对方真的开口了
134
- 2. **说清要它做什么**:`这次想主动做的是:晚安陪伴。请主动发一条私聊消息,一到两句,用你自己的语气说。`
135
- 3. **禁止项**:`不要提"系统""提醒""定时""唤醒"这些词,不要解释你为什么突然说话。`
136
-
137
- ⚠️ 唤醒可能被**价值评分**拦掉:纯打招呼、纯「我收到了」这类内容容易被判低价值而不产生回合。要唤醒就写清「为什么找你 + 要你做什么」;纯告知请改用 `appendSilent`。
138
-
139
-
140
- ---
141
-
142
- ## 3. 入站操控:`caps.inbound`(只有入站扩展有)
143
-
144
- 入站扩展的两个挂点,**链上位置决定你能做什么** —— 这是最重要的一张表:
145
-
146
- | | 早(`onInbound`) | 晚(`onDecide` / `export default`) |
147
- |---|---|---|
148
- | 链上位置 | 引用解析**之后**、**记群历史之前** | @门控**之后** |
149
- | 读得到 | `ctx.message` / `ctx.state.quote` / `ctx.platform.mentioned` | 以上全部 **+ `ctx.state.mention`**(门控判定结果) |
150
- | `annihilate` 消灭 | ✅ 真消灭(不进历史/不进上下文/不占冷却) | ❌ 会被拒绝 + 告警(历史已记) |
151
- | `rewrite` 改写 | ✅ **连群历史一起改** | ⚠️ 只改后续上下文,历史已是原文 |
152
- | `forceWake` 强制入站 | ✅ | ✅ |
153
- | `decide` 覆盖 @ 判定 | ❌(判定还没写出来) | ✅ |
154
- | `silentInto` 静默进上下文 | ✅ | ✅ |
155
- | 禁言 / kv | ✅ | ✅ |
156
-
157
- 动作清单:
158
-
159
- ```
160
- ctx.caps.inbound.annihilate({reason}) // 不调 next():这条不进群历史、不进上下文、不占冷却
161
- ctx.caps.inbound.rewrite({text,reason}) // 换掉正文(早挂点连历史一起换)
162
- ctx.caps.inbound.forceWake({reason}) // 跳过 @门控 / 价值评分 / 群冷却
163
- // ⚠️ 只是"必进",不代表她一定会说话
164
- ctx.caps.inbound.decide({mention:'ignore'|'force', reason}) // 覆盖 @ 判定
165
- ctx.caps.inbound.silentInto(text?) // 内容进 AI 上下文、不唤醒(走宿主 agent.inject)
166
- ctx.caps.inbound.muteUser/unmuteUser/isMuted/mutedUsers // 用户维禁言(带期限、持久化)
167
- ctx.caps.inbound.message.{text,sender,senderName,scope,groupId,isOwner,mentions,quoted,platform}
168
- ctx.caps.inbound.kv.get/set // 该扩展自己的持久化
169
- ```
170
-
171
- 同一套动作在 `ctx` 上**也平铺了一份**(`ctx.annihilate` / `ctx.silentInto` / `ctx.muteUser` …),
172
- 两者是同一套内核的两个入口,随便用哪个。
173
-
174
- ### 示例:引用"子会话转发消息"不算 @
175
-
176
- ```js
177
- // {数据根}/.qqbot-extensions/inbound/no-wake-on-child-quote.mjs
178
- export default async function (ctx, next) {
179
- if (ctx.message.kind === 'group') {
180
- const quoted = ctx.state?.quote?.text ?? '';
181
- if (/【子会话(?:\s|\u3000|$)/.test(quoted) && ctx.state?.mention?.wasMentioned === true) {
182
- ctx.decide({ mention: 'ignore', reason: '引用的是子会话转发消息' });
183
- }
184
- }
185
- return next();
186
- }
187
- ```
188
-
189
- ### 示例:广告直接消灭 + 静默留档
190
-
191
- ```js
192
- export async function onInbound(ctx, next) {
193
- if (isAd(ctx.text)) {
194
- ctx.silentInto(); // 内容仍进她上下文(下次跑就能看到)
195
- ctx.annihilate({ reason: '广告' });
196
- return; // ★ 不调 next() = 不进历史/不占冷却/不叫醒
197
- }
198
- return next();
199
- }
200
- ```
201
-
202
- ---
203
-
204
- ## 4. 四条安全红线
205
-
206
- 1. **`appendSilent` 的人设红线**:dsh 要求"人设"是会话上下文的**第一个节点**。
207
- 全新会话(还没跑过任何回合)里静默追加会**把整份会话日志写废**,用户会看到「历史加载失败」。
208
- 所以此能力**内置守卫**:不安全时直接返回 `false`、**绝不写入**。想在这种会话里送内容,用 `appendWake`。
209
- 2. **主人白名单最高优先**:主人(`config.groupAdmin.owners`)的消息**不可被 `annihilate` 消灭、不可被禁言** ——
210
- 扩展这么调会被拒 + 告警 + 落审计,消息照常放行。
211
- (`silentInto` 不受此限:内容仍进上下文,不存在"被吞"风险;但它照旧落审计、计入熔断。)
212
- 3. **熔断**:单个扩展的破坏性动作占比过高(>50%,样本 ≥50)或短时爆发(10 秒内 ≥20 次),
213
- 插件会**自动停掉它的破坏性动作**并告警 —— 消息改回放行,不会因为你的 bug 静默吞群消息。
214
- 4. **优雅降级**:任何能力拿不到依赖都返回 `false` / 结构化错误,**绝不抛**;
215
- 你写的扩展抛错时,插件**只告警 + 继续 `next()`** —— 用户脚本坏了永远不会把消息吞掉。
216
-
217
- 另外:所有动作都会落 **`{数据根}/.qqbot-extensions/inbound/.state/audit.jsonl`**(带轮转),
218
- 禁言名单与 kv 落同目录的 `<你的文件名>.json`,**随时可回看、可撤销**。
219
-
220
- ---
221
-
222
- ## 5. 三条容易踩的坑
223
-
224
- 1. **`text` 同名冲突(只有入站侧)**:入站 `ctx.text` = **当前这条消息的正文**(读);
225
- 其余四套 `env.text('...')` = **发一条纯文本**。入站侧要发文本请走 `ctx.caps.text('...')`。
226
- 2. **入站是热路径**:两个挂点**每条消息都跑一遍**。别在这儿发消息、打接口、做长任务 ——
227
- `markdown` 会真发群、`appendWake` 会唤醒一轮 AI、`api()` 走网络。
228
- 3. **能力包"只补不覆盖"**:插件往你的 `ctx` 上合并能力时,判据是"**值为 undefined 才补**"
229
- (中间件 ctx 上不少键存在但值是 undefined)。你自己已经定义的同名成员不会被抢走。
230
-
231
- ---
232
-
233
- ## 6. 我能写哪一套?怎么选
234
-
235
- ```
236
- 有人敲命令才做事 → commands/
237
- LLM 该主动调用才做事 → tools/
238
- 要等人点卡片按钮 → botplay/
239
- 启动就得跑一次(恢复任务/起定时器/预取) → hooks/
240
- 要影响"每条消息进不进、长什么样、叫不叫醒她" → inbound/
241
- ```
242
-
243
- 写之前建议先读 `examples/` 下对应目录的示例(插件首次启动会**自动铺一份**到你的扩展目录;
244
- `examples/ext-inbound/` 里只有 `no-wake-on-child-quote.mjs` 会自动铺,
245
- `class-advisor.mjs` 那种**会真删消息/真封人**的示例要自己拷)。
246
-
247
- ---
248
-
249
- ## 7. 变更记录(契约相关)
250
-
251
- - **v1.9.2**:入站扩展(第五套)**能力面对齐** —— 直接复用统一能力包(此前只有一套私有小 ctx,
252
- `store` / `api()` / `markdown` / `appendSilent` 等一项都没有);**入站操控那一组也进了包**(`caps.inbound`)。
253
- - **v1.9.0**:新增第五套扩展点 `inbound/`(早/晚双挂点 + 四个动作 + 四条保命件)。
254
- - **v1.8.9**:新增第四套扩展点 `hooks/`(启动钩子)。
255
- - **v1.7.0**:三套(命令/工具/卡片)能力面统一为「统一能力包」。
1
+ # 扩展契约(写 dsh-qqbot 扩展前,先读这一份)
2
+
3
+ > 适用版本:**v1.9.2+**(本文写的就是当前真实实现,不是设想)
4
+ > 面向:给 QQ 机器人写扩展的用户 / 让 AI 帮你写扩展的人
5
+ > 配套:用户手册 `README.md` §用户扩展、`docs/USER-GUIDE.md` §统一能力包、入站中间件详解 `examples/ext-inbound/README.md`
6
+
7
+ ---
8
+
9
+ ## 0. 一句话
10
+
11
+ **五套扩展点,一套能力包。** 你在哪一套里写代码,拿到的能力面都一样 ——
12
+ 不用记五套 API,也不用"自己接生"发消息/存数据/调官方接口这些事。
13
+
14
+ ```
15
+ ┌─────────────── 统一能力包(makeExtCaps)───────────────┐
16
+ tools/ env ──┤ text markdown markdownCard media image voice video file│
17
+ commands/ ctx ──┤ at api() appId/appSecret store registerInteractionCard │
18
+ botplay/ env ──┤ appendSilent appendWake canAppendSilent │
19
+ hooks/ env ──┤ user owners peer log kernel restart │
20
+ inbound/ ctx ──┤ + inbound: { annihilate rewrite forceWake decide │
21
+ │ silentInto mute* message kv } ← 只有它 │
22
+ └───────────────────────────────────────────────────────┘
23
+ ```
24
+
25
+ `inbound` 那一组为什么只有它有值:**只有它跑在"一条消息正在进站"的时刻**,
26
+ "消灭/改写/覆盖 @ 判定"这些动作在别处没有对象。别的四套拿到的 `caps.inbound` 是
27
+ `undefined` —— **这是语义,不是缺失**。
28
+
29
+ ---
30
+
31
+ ## 1. 扩展点总览:五套代码扩展 + 一套 UI 扩展
32
+
33
+ 全部位于 **`{数据根}/.qqbot-extensions/`** 下(数据根 = 工作目录里的 `dshqqbot/`):
34
+
35
+ | 目录 | 什么时候跑 | 你的导出 | 你拿到 |
36
+ |---|---|---|---|
37
+ | `tools/*.mjs` | LLM 调这个工具时 | `export default { name, description, inputSchema, run(args, env) }` | `env`(能力包平铺) |
38
+ | `commands/*.mjs` | 用户敲 `/你的命令` 时 | `{ name, handler(ctx) }` | handler 的 `ctx`(能力包平铺) |
39
+ | `botplay/*.mjs` | 卡片按钮被点时 | `{ name, events: {...} }` | `env` |
40
+ | `hooks/*.mjs` | **插件启动时跑一次** | `export default async (env) => {}` | `env`(含 `env.caps`) |
41
+ | `inbound/*.mjs` | **每一条入站消息** | `export async function onInbound(ctx,next)` / `onDecide(ctx,next)`(或 `export default`) | 挂点的 `ctx` |
42
+
43
+ 扫描规则(五套一致):**只看目录下的直接子文件**,`.js` / `.mjs` / `.cjs`,按文件名排序;
44
+ 子目录不扫描(可放私有模块)。改完文件调 `tools_reload`(或 `/tools-reload`)**即时生效**;
45
+ 扩展命令需要重启宿主。
46
+
47
+ ### 第六套:`ui/` —— 自定义界面卡片(2026-10-26 新增)
48
+
49
+ 前五套是「代码模块」,`ui/` 是**静态资源**(html/css/js),由面板加载到 **Shadow DOM** 里跑。
50
+ 它不参与 `import()`,所以放错位置只会被静默跳过,不会报错。
51
+
52
+ **两个源,按 slot 分**(放错源就不显示):
53
+
54
+ | slot | 界面位置 | 放哪 | 语义 |
55
+ |---|---|---|---|
56
+ | `settings` | 设置页顶栏多一个页签 | `{PROFILE_ROOT}/.qqbot-extensions/ui/<目录>/` | **profile 级**,全局一份 |
57
+ | `dock` | 悬浮球面板多一个页签 | `{dataRoot}/.qqbot-extensions/ui/<目录>/` | **每实例一份**(能读当前会话数据) |
58
+
59
+ 目录里放三个文件(都可不写,写什么加载什么):
60
+
61
+ ```
62
+ card.html 必需(没有它这个扩展就不出现)
63
+ card.css 可选,样式
64
+ card.js 可选,逻辑
65
+ ```
66
+
67
+ **元信息**写在 `card.html` 前 2KB 的注释里:
68
+
69
+ ```html
70
+ <!-- qqbot-ui: { "id": "demo", "slot": "dock", "title": "🔌 扩展示例", "order": 5 } -->
71
+ ```
72
+
73
+ - `id` 可选:覆盖目录名。**同一个扩展的两张卡(settings + dock)应该给相同的 id**,
74
+ 这样它们共用同一份配置(`getConfig()`);不给 id 就用目录名。
75
+ - `slot` 必填:`settings` 或 `dock`。
76
+ - `title` 页签文字;`order` 排序(小的在前,默认 100)。
77
+
78
+ **卡片里能用的东西**(`card.js` 里有两个全局变量:`qqbotExt` 和 `shadowRoot`):
79
+
80
+ ```js
81
+ // 身份
82
+ qqbotExt.extId // 你的扩展 id
83
+
84
+ // 配置(两级,见 §2.1)
85
+ qqbotExt.getConfig() // 默认作用域(settings 卡=profile,dock 卡=data)
86
+ qqbotExt.getConfig('profile' | 'data')
87
+ qqbotExt.setConfig({...}) // 同理
88
+ qqbotExt.acct // 当前选中的账号 {id, ns, cwd, dataRoot, appId}
89
+
90
+ // 会话(只有 dock 卡有)
91
+ qqbotExt.dock.sid // 当前 web 会话 id(宿主 agent 表按它索引)
92
+ qqbotExt.dock.peerId / scope / ns
93
+ qqbotExt.dock.dataRoot / cwd
94
+ qqbotExt.dock.context() // 实例 + 会话信息
95
+ qqbotExt.dock.sessions() // 这个机器人管过哪些会话
96
+ qqbotExt.dock.history(n) // 当前会话最近 n 条消息
97
+ qqbotExt.dock.members() // 群友台账
98
+
99
+ // 能力(与代码扩展对齐,走白名单 RPC)
100
+ qqbotExt.store.load() / save(v) // 数据根下的 KV
101
+ qqbotExt.kernel.get('服务名') // 宿主服务(同 env.kernel.get)
102
+ qqbotExt.kernel.has('服务名')
103
+ qqbotExt.log('日志') // 进宿主控制台
104
+ qqbotExt.owners() / appIdOf()
105
+ qqbotExt.cap('fs.read', 路径) // 读文本文件(≤2MB,只放行文本扩展名)
106
+ qqbotExt.cap('fs.write', 路径, 内容) // 写(自动备份 .bak-时间戳)
107
+ qqbotExt.cap('appendSilent', 文本) // 往当前会话静默注入一条消息(不唤醒 AI)
108
+ qqbotExt.cap('text' | 'markdown', 文本) // 发消息
109
+
110
+ // 自己的后端(见 §7)
111
+ qqbotExt.api('/你的路由') // → /api/ext/dispatch?id=<你>&p=/你的路由
112
+ ```
113
+
114
+ **写卡片的几条硬边界**(都是设计如此,不是 bug):
115
+
116
+ 1. **样式双向隔离**:Shadow DOM 让卡片样式不污染面板,但**面板的 CSS 变量/组件样式也进不来**,得自己写。
117
+ 2. **碰不到面板 DOM**:`document.getElementById` 查不到外面 —— 只能操作**自己那张卡内部**。
118
+ 卡片内部用 `shadowRoot.getElementById(...)`。
119
+ 3. **用不了面板的 React 组件**(`SButton` / `FieldRow` / `h`),只能手写原生元素。
120
+ 4. **没有构建步骤**:不能写 TS/JSX,不能 `import` npm 包(`card.js` 是用 `new Function` 执行的)。
121
+ 5. **一切数据走后端**:浏览器沙箱碰不了文件系统 —— 要读 SQLite/扫目录/跑 shell,
122
+ 都在 `hooks/` 或 `tools/` 里做完,`registerRoute` 注册成路由,卡片用 `api()` 取。
123
+
124
+ **崩溃隔离**:卡片脚本报错只白自己那张卡(外面套了 try/catch),不影响面板其他部分。
125
+
126
+ **两张图看懂**(都是 `examples/ext-ui/demo/` 跑出来的真实效果):
127
+
128
+ 设置页那张 —— 一张**全局 UI** 也能操作当前选中账号:
129
+
130
+ ![设置页扩展卡片](images/ext-ui-settings.png)
131
+
132
+ dock 那张 —— 五个子标签,右边三个演示了 dock 独有的能力(读会话数据 / 读写文件 / 静默注入):
133
+
134
+ ![dock 扩展卡片](images/ext-ui-dock.png)
135
+
136
+ **示例**:`examples/ext-ui/demo/`(`settings/` + `dock/` 两套,启动时自动铺到对应位置)。
137
+ `examples/ext-commands/扩展示例.mjs` 是配套的 `/扩展示例` 命令,演示「全局改 + 实例改 +
138
+ 命令读两级配置」的完整链路。
139
+
140
+ ---
141
+
142
+ ## 2. 统一能力包(五套代码扩展都有)
143
+
144
+ > `ui/` 卡片拿到的是**子集**,见 §1 末尾「卡片里能用的东西」——浏览器里没有 kernel、
145
+ > 没有 sender,服务端能力一律走 `/api/ext/dispatch?p=/__cap` 白名单 RPC。
146
+
147
+ ### 2.1 扩展自己的配置:两级作用域
148
+
149
+ 扩展可以有自己的配置,**存在两级**,互不干扰:
150
+
151
+ | 作用域 | 文件位置 | 谁在用 | 语义 |
152
+ |---|---|---|---|
153
+ | `profile` | `{PROFILE_ROOT}/.qqbot-extensions/.state/ext-config/<id>.json` | 设置页的 UI 卡片(默认)、需要全局配置的代码扩展 | **所有机器人实例共用一份** |
154
+ | `data` | `{dataRoot}/.qqbot-extensions/.state/ext-config/<id>.json` | dock 的 UI 卡片(默认) | **每个机器人实例各一份** |
155
+
156
+ 代码扩展里用 `env.getConfig?.(scope)` / `env.setConfig?.(patch, scope)`(老版本可能没有,
157
+ 自己读文件也行);UI 卡片里就是 `qqbotExt.getConfig(scope)` / `setConfig(patch, scope)`。
158
+
159
+ **取值建议:实例优先、全局兜底**。典型写法(见 `examples/ext-commands/扩展示例.mjs`):
160
+
161
+ ```js
162
+ const t1 = dataCfg.text1 || profileCfg.text1 || '(默认值)';
163
+ ```
164
+
165
+ 这样「全局」才是真正的**默认值**,用户给某个机器人单独调过就以那个为准 ——
166
+ 而不是硬性覆盖。
167
+
168
+ **写前自动备份**:`setConfig` 落盘前会把旧文件存成 `.bak-<时间戳>`,改坏了能捞回来。
169
+
170
+ ### 2.2 注册自己的 HTTP 路由
171
+
172
+ 扩展想用自己的存储(SQLite / 自有 json / 任意目录),就得有办法把数据交给自己的 UI 卡片。
173
+ 在 `hooks/` 或 `tools/` 里注册:
174
+
175
+ ```js
176
+ export default async (env) => {
177
+ env.registerRoute('GET', '/rows', async ({ query }) => myDb.all(...), '你的扩展id');
178
+ env.registerRoute('POST', '/add', async ({ body }) => { myDb.insert(body); return { ok: true }; }, '你的扩展id');
179
+ };
180
+ ```
181
+
182
+ 卡片里:
183
+
184
+ ```js
185
+ qqbotExt.api('/rows').then(r => r.data)
186
+ ```
187
+
188
+ **实现细节**:实际请求走 `/api/ext/dispatch?id=<你的id>&p=/rows`,由宿主派发。
189
+ handler 返回 `{ok:false,...}` 会原样透传,其它值包成 `{ok:true, data}`。
190
+ `method` 传 `'*'` 表示任意方法都收。注册表放 `globalThis.__qqbotExtRoutes`(跨模块图)。
191
+
192
+ **注意**:`hooks/` 是「插件启动时跑一次」,拿到的 `env.dataRoot` 是**插件级**的
193
+ (主实例那份),不是当前机器人实例的。需要按实例工作时,从**请求参数**里取
194
+ (`query.dataRoot` / `query.sid`),别依赖 `env.dataRoot`。
195
+
196
+ 成员**平铺**在你的 `env` / `ctx` 上,**同时**在命名空间 `env.caps` / `ctx.caps` 里也有一份。
197
+
198
+ | 能力 | 用法 | 说明 |
199
+ |---|---|---|
200
+ | 发纯文本 | `await env.text('hi')` | |
201
+ | 发 markdown | `await env.markdown('# 标题')` | 富文本卡片通道,**自带纯文本降级** |
202
+ | 发 markdown+按钮 | `await env.markdownCard(md, keyboard)` | 自己拼 keyboard(≤5 行) |
203
+ | 发富媒体 | `await env.image(src)` / `voice` / `video` / `file` | `src` 可传 URL 字符串或 `{url}` / `{localPath}` |
204
+ | 任意媒体 | `await env.media('file', src)` | kind = image / voice / video / file |
205
+ | @人 | `env.at(openid)` | 返回 `<@openid>` 片段,拼进文本 |
206
+ | 官方 API | `await env.api('/v2/users/@me')` | **自带 token 与 2h 缓存**;返回 `{ok,data}` 或 `{ok:false,err:{code,human}}` |
207
+ | 钥匙 | `env.appId` / `env.appSecret` | 想自己换 token 调任意接口时用 |
208
+ | 持久化 | `env.store.load()` / `env.store.save(obj)` | 落 `…/data/<你的文件名>.json`,**升级插件不丢** |
209
+ | **进上下文(静默)** | `await env.appendSilent('...')` | **只落上下文、不唤醒**(详见 §4 安全红线) |
210
+ | 进上下文(唤醒) | `await env.appendWake('...')` | 走正常回合唤醒一轮 AI(耗 token) |
211
+ | **点名唤醒任意会话** | `await env.caps.wakeTo(scope, peerId, text)` | **不需要「当前会话」**,直接点名唤醒某个群/私聊里的 AI。`scope`=`group`/`c2c`,`peerId`=群 openid / 用户 openid。走正常回合:AI 带着该会话的历史与人设自己作答,回复由宿主发到 QQ。<br>**会话被回收、或宿主重启后活跃表为空时,会自动 `getOrCreate` 重建再注入**,调用方不用管。<br>返回 `true`=已排进该会话的回合;宿主没接/找不到目标=`false`(**不抛错**,调用方自己决定回退)。⚠️ `true` 不等于 QQ 一定送达。 |
212
+ | **身份** | `env.user.openid` / `env.user.name` / `env.user.isOwner` | 当前发言者。`isOwner` 已按主人白名单**算好布尔值**,直接 `if (!env.user.isOwner) return` 即可,不必自己比数组 |
213
+ | **主人判定(便捷)** | `env.isOwner` | 等价于 `env.user.isOwner` 的顶层布尔,最省事的写法 |
214
+ | **主人白名单** | `env.owners`(`string[]`) | 原始 openid 数组,`[]` = 未配置主人 |
215
+ | **发送者 openid(平铺)** | `env.actorOpenid` | 与 `env.user.openid` 同值。v1.10.42 起才真正暴露(此前只在内部算 `isOwner` 时用到) |
216
+ | **会话标识** | `env.peer.scope` / `env.peer.peerId` | `scope` = `'group'` / `'c2c'`;`peerId` = 群 openid 或用户 openid |
217
+ | **宿主配置** | `env.config.appId` / `env.config.appSecret` / `env.config.owners` | 给「想自己换 token 调接口」或「自己判主人」的扩展用。⚠️ 命令侧 `ctx.config` **同时**还包含该扩展自己的设置项(面板里填的值),两者合并 |
218
+ | **本会话 id** | `env.peerSessionId` | 本 QQ 会话对应的 **DSH 会话 id**(= `session/event` 里那个 `session.id`)。⚠️ **不是 `agent.id`** —— 跨会话回灌/引用回流判"父会话"用的正是它。v1.10.0 起提供;命令与按钮回调侧都能拿到(回调侧字面同值,直接读即可) |
219
+ | 日志 | `env.log(...)` | 落插件 logger |
220
+ | 内核句柄 | `env.kernel`(工具侧也叫 `env.ctx`) | **全权限"遥控器"**:`ctx.get('服务名')` / `ctx.on(...)` / `ctx.webServer.register(...)`。⚠️ 形状跟随 dsh 版本;`register` 返回的 disposer 要自己收尾 |
221
+ | 宿主自重启 | `await env.restart()` | 复用内置 `/bot-restart`(自动识别启动命令、强补 `--no-open`)。默认**仅主人**可触发;要放开写 `env.restart({ requireOwner: false })` |
222
+ | 回调按钮卡 | `env.registerInteractionCard({cardId, buttonIds, expireAt})` | 发**带回调按钮**的卡片必须先登记,否则点了没人认 |
223
+
224
+ ### 扩展设置页的字段类型(schema)
225
+
226
+ 扩展文件里 `export const qqbotSettingsPage = { title, desc, schema: [...] }`,面板就会给它生成设置页。
227
+ `schema` 每项支持:
228
+
229
+ | type | 控件 | 说明 |
230
+ | --- | --- | --- |
231
+ | `bool` | 开关 | 值 true/false |
232
+ | `number` | 数字框 | 值 number |
233
+ | `string`(默认) | 输入框 | 加 `secret: true` 变密码框 |
234
+ | `list` | 多行文本 | 一行一项,值 string[] |
235
+ | `select` | 下拉 | `options` 可写字符串数组,也可写 `{ value, label }` |
236
+
237
+ **`select` + `searchable: true`(1.10.46 起)**
238
+
239
+ 选项很多(几十上百条)时,原生 `<select>` 根本没法找。加上这个标记会渲染成
240
+ `<input list="…"> + <datalist>`:浏览器原生**输入过滤**,并且**允许手填列表外的值**。
241
+
242
+ ```js
243
+ { key: 'defaultSpeaker', type: 'select', searchable: true, label: '默认音色',
244
+ default: 'Vivi 2.0|zh_female_vv_uranus_bigtts',
245
+ options: VOICES.map(v => v.label) } // ['中文名|voice_type', ...]
246
+ ```
247
+
248
+ ⚠️ 两个必须注意的点:
249
+
250
+ 1. **`select` 取到的值永远是字符串**(`'24000'`,不是 `24000`)。
251
+ 如果你的扩展要把它传给要求严格类型的第三方接口,**自己转数字**,否则会像 TTS 那样撞
252
+ `55000000 cannot unmarshal string into ... of type int`。
253
+ 2. 用 `中文名|id` 这种复合值时,**读取侧要负责剥出真值**(本项目 TTS 包用 `pickSpeaker()` 统一处理)。
254
+
255
+ ### 扩展包声明「最低插件版本」(1.10.44 起)
256
+
257
+ 包的清单文件里可以写 `minPluginVersion`,面板会拿它跟当前装的插件版本对照,不满足就标红:
258
+
259
+ ```json
260
+ {
261
+ "name": "主动陪伴与私聊关怀",
262
+ "version": "1.0.6",
263
+ "minPluginVersion": "1.10.44"
264
+ }
265
+ ```
266
+
267
+ 规则:
268
+
269
+ - **不写 = 不检查**,任何插件版本都允许安装
270
+ - 写了的包,市场卡片与扩展总览都会显示「需要插件 vX」;当前版本低于它时红字提示「需要插件 ≥ vX(当前 vY)」
271
+ - 扩展总览接口同时返回 `pluginVersion`(插件自身版本),面板顶部「插件版本 vX.Y.Z」那一行就是它
272
+ - **用了新能力就应该声明**,否则老用户装上去会在运行期撞 `undefined`
273
+
274
+ ### 主动唤醒别人的会话:怎么写 prompt
275
+
276
+ `caps.wakeTo` 的第三个参数会被当成**用户消息**注入目标会话,所以写法上有三个要点:
277
+
278
+ 1. **自报家门**:`(系统提示,不是主人说的话)现在是 22:50,你和主人已经有 3 小时没有互动了。` —— 否则 AI 会以为对方真的开口了
279
+ 2. **说清要它做什么**:`这次想主动做的是:晚安陪伴。请主动发一条私聊消息,一到两句,用你自己的语气说。`
280
+ 3. **禁止项**:`不要提"系统""提醒""定时""唤醒"这些词,不要解释你为什么突然说话。`
281
+
282
+ ⚠️ 唤醒可能被**价值评分**拦掉:纯打招呼、纯「我收到了」这类内容容易被判低价值而不产生回合。要唤醒就写清「为什么找你 + 要你做什么」;纯告知请改用 `appendSilent`。
283
+
284
+
285
+ ---
286
+
287
+ ## 3. 入站操控:`caps.inbound`(只有入站扩展有)
288
+
289
+ 入站扩展的两个挂点,**链上位置决定你能做什么** —— 这是最重要的一张表:
290
+
291
+ | | 早(`onInbound`) | 晚(`onDecide` / `export default`) |
292
+ |---|---|---|
293
+ | 链上位置 | 引用解析**之后**、**记群历史之前** | @门控**之后** |
294
+ | 读得到 | `ctx.message` / `ctx.state.quote` / `ctx.platform.mentioned` | 以上全部 **+ `ctx.state.mention`**(门控判定结果) |
295
+ | `annihilate` 消灭 | ✅ 真消灭(不进历史/不进上下文/不占冷却) | ❌ 会被拒绝 + 告警(历史已记) |
296
+ | `rewrite` 改写 | ✅ **连群历史一起改** | ⚠️ 只改后续上下文,历史已是原文 |
297
+ | `forceWake` 强制入站 | ✅ | ✅ |
298
+ | `decide` 覆盖 @ 判定 | ❌(判定还没写出来) | ✅ |
299
+ | `silentInto` 静默进上下文 | ✅ | ✅ |
300
+ | 禁言 / kv | ✅ | ✅ |
301
+
302
+ 动作清单:
303
+
304
+ ```
305
+ ctx.caps.inbound.annihilate({reason}) // 不调 next():这条不进群历史、不进上下文、不占冷却
306
+ ctx.caps.inbound.rewrite({text,reason}) // 换掉正文(早挂点连历史一起换)
307
+ ctx.caps.inbound.forceWake({reason}) // 跳过 @门控 / 价值评分 / 群冷却
308
+ // ⚠️ 只是"必进",不代表她一定会说话
309
+ ctx.caps.inbound.decide({mention:'ignore'|'force', reason}) // 覆盖 @ 判定
310
+ ctx.caps.inbound.silentInto(text?) // 内容进 AI 上下文、不唤醒(走宿主 agent.inject)
311
+ ctx.caps.inbound.muteUser/unmuteUser/isMuted/mutedUsers // 用户维禁言(带期限、持久化)
312
+ ctx.caps.inbound.message.{text,sender,senderName,scope,groupId,isOwner,mentions,quoted,platform}
313
+ ctx.caps.inbound.kv.get/set // 该扩展自己的持久化
314
+ ```
315
+
316
+ 同一套动作在 `ctx` 上**也平铺了一份**(`ctx.annihilate` / `ctx.silentInto` / `ctx.muteUser` …),
317
+ 两者是同一套内核的两个入口,随便用哪个。
318
+
319
+ ### 示例:引用"子会话转发消息"不算 @
320
+
321
+ ```js
322
+ // {数据根}/.qqbot-extensions/inbound/no-wake-on-child-quote.mjs
323
+ export default async function (ctx, next) {
324
+ if (ctx.message.kind === 'group') {
325
+ const quoted = ctx.state?.quote?.text ?? '';
326
+ if (/【子会话(?:\s|\u3000|$)/.test(quoted) && ctx.state?.mention?.wasMentioned === true) {
327
+ ctx.decide({ mention: 'ignore', reason: '引用的是子会话转发消息' });
328
+ }
329
+ }
330
+ return next();
331
+ }
332
+ ```
333
+
334
+ ### 示例:广告直接消灭 + 静默留档
335
+
336
+ ```js
337
+ export async function onInbound(ctx, next) {
338
+ if (isAd(ctx.text)) {
339
+ ctx.silentInto(); // 内容仍进她上下文(下次跑就能看到)
340
+ ctx.annihilate({ reason: '广告' });
341
+ return; // ★ 不调 next() = 不进历史/不占冷却/不叫醒
342
+ }
343
+ return next();
344
+ }
345
+ ```
346
+
347
+ ---
348
+
349
+ ## 4. 四条安全红线
350
+
351
+ 1. **`appendSilent` 的人设红线**:dsh 要求"人设"是会话上下文的**第一个节点**。
352
+ 全新会话(还没跑过任何回合)里静默追加会**把整份会话日志写废**,用户会看到「历史加载失败」。
353
+ 所以此能力**内置守卫**:不安全时直接返回 `false`、**绝不写入**。想在这种会话里送内容,用 `appendWake`。
354
+ 2. **主人白名单最高优先**:主人(`config.groupAdmin.owners`)的消息**不可被 `annihilate` 消灭、不可被禁言** ——
355
+ 扩展这么调会被拒 + 告警 + 落审计,消息照常放行。
356
+ (`silentInto` 不受此限:内容仍进上下文,不存在"被吞"风险;但它照旧落审计、计入熔断。)
357
+ 3. **熔断**:单个扩展的破坏性动作占比过高(>50%,样本 ≥50)或短时爆发(10 秒内 ≥20 次),
358
+ 插件会**自动停掉它的破坏性动作**并告警 —— 消息改回放行,不会因为你的 bug 静默吞群消息。
359
+ 4. **优雅降级**:任何能力拿不到依赖都返回 `false` / 结构化错误,**绝不抛**;
360
+ 你写的扩展抛错时,插件**只告警 + 继续 `next()`** —— 用户脚本坏了永远不会把消息吞掉。
361
+
362
+ 另外:所有动作都会落 **`{数据根}/.qqbot-extensions/inbound/.state/audit.jsonl`**(带轮转),
363
+ 禁言名单与 kv 落同目录的 `<你的文件名>.json`,**随时可回看、可撤销**。
364
+
365
+ ---
366
+
367
+ ## 5. 三条容易踩的坑
368
+
369
+ 1. **`text` 同名冲突(只有入站侧)**:入站 `ctx.text` = **当前这条消息的正文**(读);
370
+ 其余四套 `env.text('...')` = **发一条纯文本**。入站侧要发文本请走 `ctx.caps.text('...')`。
371
+ 2. **入站是热路径**:两个挂点**每条消息都跑一遍**。别在这儿发消息、打接口、做长任务 ——
372
+ `markdown` 会真发群、`appendWake` 会唤醒一轮 AI、`api()` 走网络。
373
+ 3. **能力包"只补不覆盖"**:插件往你的 `ctx` 上合并能力时,判据是"**值为 undefined 才补**"
374
+ (中间件 ctx 上不少键存在但值是 undefined)。你自己已经定义的同名成员不会被抢走。
375
+
376
+ ---
377
+
378
+ ## 6. 我能写哪一套?怎么选
379
+
380
+ ```
381
+ 有人敲命令才做事 → commands/
382
+ LLM 该主动调用才做事 → tools/
383
+ 要等人点卡片按钮 → botplay/
384
+ 启动就得跑一次(恢复任务/起定时器/预取) → hooks/
385
+ 要影响"每条消息进不进、长什么样、叫不叫醒她" → inbound/
386
+ ```
387
+
388
+ 写之前建议先读 `examples/` 下对应目录的示例(插件首次启动会**自动铺一份**到你的扩展目录;
389
+ `examples/ext-inbound/` 里只有 `no-wake-on-child-quote.mjs` 会自动铺,
390
+ `class-advisor.mjs` 那种**会真删消息/真封人**的示例要自己拷)。
391
+
392
+ ---
393
+
394
+ ## 7. 变更记录(契约相关)
395
+
396
+ - **v1.9.2**:入站扩展(第五套)**能力面对齐** —— 直接复用统一能力包(此前只有一套私有小 ctx,
397
+ `store` / `api()` / `markdown` / `appendSilent` 等一项都没有);**入站操控那一组也进了包**(`caps.inbound`)。
398
+ - **v1.9.0**:新增第五套扩展点 `inbound/`(早/晚双挂点 + 四个动作 + 四条保命件)。
399
+ - **v1.8.9**:新增第四套扩展点 `hooks/`(启动钩子)。
400
+ - **v1.7.0**:三套(命令/工具/卡片)能力面统一为「统一能力包」。