# 扩展契约(写 dsh-qqbot 扩展前,先读这一份)
> 适用版本:**v1.9.2+**(本文写的就是当前真实实现,不是设想)
> 面向:给 QQ 机器人写扩展的用户 / 让 AI 帮你写扩展的人
> 配套:用户手册 `README.md` §用户扩展、`docs/USER-GUIDE.md` §统一能力包、入站中间件详解 `examples/ext-inbound/README.md`
---
## 0. 一句话
**五套扩展点,一套能力包。** 你在哪一套里写代码,拿到的能力面都一样 ——
不用记五套 API,也不用"自己接生"发消息/存数据/调官方接口这些事。
```
┌─────────────── 统一能力包(makeExtCaps)───────────────┐
tools/ env ──┤ text markdown markdownCard media image voice video file│
commands/ ctx ──┤ at api() appId/appSecret store registerInteractionCard │
botplay/ env ──┤ appendSilent appendWake canAppendSilent │
hooks/ env ──┤ user owners peer log kernel restart │
inbound/ ctx ──┤ + inbound: { annihilate rewrite forceWake decide │
│ silentInto mute* message kv } ← 只有它 │
└───────────────────────────────────────────────────────┘
```
`inbound` 那一组为什么只有它有值:**只有它跑在"一条消息正在进站"的时刻**,
"消灭/改写/覆盖 @ 判定"这些动作在别处没有对象。别的四套拿到的 `caps.inbound` 是
`undefined` —— **这是语义,不是缺失**。
---
## 1. 五套扩展点:目录、时机、拿到什么
全部位于 **`{数据根}/.qqbot-extensions/`** 下(数据根 = 工作目录里的 `dshqqbot/`):
| 目录 | 什么时候跑 | 你的导出 | 你拿到 |
|---|---|---|---|
| `tools/*.mjs` | LLM 调这个工具时 | `export default { name, description, inputSchema, run(args, env) }` | `env`(能力包平铺) |
| `commands/*.mjs` | 用户敲 `/你的命令` 时 | `{ name, handler(ctx) }` | handler 的 `ctx`(能力包平铺) |
| `botplay/*.mjs` | 卡片按钮被点时 | `{ name, events: {...} }` | `env` |
| `hooks/*.mjs` | **插件启动时跑一次** | `export default async (env) => {}` | `env`(含 `env.caps`) |
| `inbound/*.mjs` | **每一条入站消息** | `export async function onInbound(ctx,next)` / `onDecide(ctx,next)`(或 `export default`) | 挂点的 `ctx` |
扫描规则(五套一致):**只看目录下的直接子文件**,`.js` / `.mjs` / `.cjs`,按文件名排序;
子目录不扫描(可放私有模块)。改完文件调 `tools_reload`(或 `/tools-reload`)**即时生效**;
扩展命令需要重启宿主。
---
## 2. 统一能力包(五套都有)
成员**平铺**在你的 `env` / `ctx` 上,**同时**在命名空间 `env.caps` / `ctx.caps` 里也有一份。
| 能力 | 用法 | 说明 |
|---|---|---|
| 发纯文本 | `await env.text('hi')` | |
| 发 markdown | `await env.markdown('# 标题')` | 富文本卡片通道,**自带纯文本降级** |
| 发 markdown+按钮 | `await env.markdownCard(md, keyboard)` | 自己拼 keyboard(≤5 行) |
| 发富媒体 | `await env.image(src)` / `voice` / `video` / `file` | `src` 可传 URL 字符串或 `{url}` / `{localPath}` |
| 任意媒体 | `await env.media('file', src)` | kind = image / voice / video / file |
| @人 | `env.at(openid)` | 返回 `<@openid>` 片段,拼进文本 |
| 官方 API | `await env.api('/v2/users/@me')` | **自带 token 与 2h 缓存**;返回 `{ok,data}` 或 `{ok:false,err:{code,human}}` |
| 钥匙 | `env.appId` / `env.appSecret` | 想自己换 token 调任意接口时用 |
| 持久化 | `env.store.load()` / `env.store.save(obj)` | 落 `…/data/<你的文件名>.json`,**升级插件不丢** |
| **进上下文(静默)** | `await env.appendSilent('...')` | **只落上下文、不唤醒**(详见 §4 安全红线) |
| 进上下文(唤醒) | `await env.appendWake('...')` | 走正常回合唤醒一轮 AI(耗 token) |
| **点名唤醒任意会话** | `await env.caps.wakeTo(scope, peerId, text)` | **不需要「当前会话」**,直接点名唤醒某个群/私聊里的 AI。`scope`=`group`/`c2c`,`peerId`=群 openid / 用户 openid。走正常回合:AI 带着该会话的历史与人设自己作答,回复由宿主发到 QQ。
**会话被回收、或宿主重启后活跃表为空时,会自动 `getOrCreate` 重建再注入**,调用方不用管。
返回 `true`=已排进该会话的回合;宿主没接/找不到目标=`false`(**不抛错**,调用方自己决定回退)。⚠️ `true` 不等于 QQ 一定送达。 |
| **身份** | `env.user.openid` / `env.user.name` / `env.user.isOwner` | 当前发言者。`isOwner` 已按主人白名单**算好布尔值**,直接 `if (!env.user.isOwner) return` 即可,不必自己比数组 |
| **主人判定(便捷)** | `env.isOwner` | 等价于 `env.user.isOwner` 的顶层布尔,最省事的写法 |
| **主人白名单** | `env.owners`(`string[]`) | 原始 openid 数组,`[]` = 未配置主人 |
| **发送者 openid(平铺)** | `env.actorOpenid` | 与 `env.user.openid` 同值。v1.10.42 起才真正暴露(此前只在内部算 `isOwner` 时用到) |
| **会话标识** | `env.peer.scope` / `env.peer.peerId` | `scope` = `'group'` / `'c2c'`;`peerId` = 群 openid 或用户 openid |
| **宿主配置** | `env.config.appId` / `env.config.appSecret` / `env.config.owners` | 给「想自己换 token 调接口」或「自己判主人」的扩展用。⚠️ 命令侧 `ctx.config` **同时**还包含该扩展自己的设置项(面板里填的值),两者合并 |
| **本会话 id** | `env.peerSessionId` | 本 QQ 会话对应的 **DSH 会话 id**(= `session/event` 里那个 `session.id`)。⚠️ **不是 `agent.id`** —— 跨会话回灌/引用回流判"父会话"用的正是它。v1.10.0 起提供;命令与按钮回调侧都能拿到(回调侧字面同值,直接读即可) |
| 日志 | `env.log(...)` | 落插件 logger |
| 内核句柄 | `env.kernel`(工具侧也叫 `env.ctx`) | **全权限"遥控器"**:`ctx.get('服务名')` / `ctx.on(...)` / `ctx.webServer.register(...)`。⚠️ 形状跟随 dsh 版本;`register` 返回的 disposer 要自己收尾 |
| 宿主自重启 | `await env.restart()` | 复用内置 `/bot-restart`(自动识别启动命令、强补 `--no-open`)。默认**仅主人**可触发;要放开写 `env.restart({ requireOwner: false })` |
| 回调按钮卡 | `env.registerInteractionCard({cardId, buttonIds, expireAt})` | 发**带回调按钮**的卡片必须先登记,否则点了没人认 |
### 扩展设置页的字段类型(schema)
扩展文件里 `export const qqbotSettingsPage = { title, desc, schema: [...] }`,面板就会给它生成设置页。
`schema` 每项支持:
| type | 控件 | 说明 |
| --- | --- | --- |
| `bool` | 开关 | 值 true/false |
| `number` | 数字框 | 值 number |
| `string`(默认) | 输入框 | 加 `secret: true` 变密码框 |
| `list` | 多行文本 | 一行一项,值 string[] |
| `select` | 下拉 | `options` 可写字符串数组,也可写 `{ value, label }` |
**`select` + `searchable: true`(1.10.46 起)**
选项很多(几十上百条)时,原生 `