koishi-plugin-qq-command-registrar
v1.0.0
Published
为 QQ 官方机器人自动注册/更新指令面板与全局自定义菜单的 Koishi 插件
Maintainers
Readme
koishi-plugin-qq-command-registrar
自动把当前启用的 Koishi 指令注册成 QQ 官方机器人的指令面板(/v2/panels)和全局自定义菜单(/v2/menu),并做查重处理 —— 内容没变就不会重复下发。
- 自动识别当前激活的指令,以及它们的参数要求(
<必填>/[选填]/...可变)和说明文案 - 配置里的「注册指令 / 注册分类」是选单,选项直接来自当前已激活的指令列表
- 「生效场景」是多选勾选框(单聊 / 群聊 / 文字子频道 / 频道私信)
- 两层查重:本地内容未变时一个请求都不发;即使插件重启,也会先查询线上现状再比对,只更新有差异的面板
- 面板用
remark打标(koishi-cmd-registrar:<场景>:<序号>:<内容指纹>),只管理自己创建的面板,不会碰别人的 - 按官方 QPM 限流(面板写入 10 QPM、菜单写入 5 QPM 等),自动串行、自动重试「操作进行中」
⚠️ 前置要求
必须安装
@koishijs/plugin-adapter-qq(平台名qq),并在其中填好机器人的 AppID 与 Secret。 本插件通过该适配器获取 HTTP 客户端与 access token;没有它插件不会工作。npm i @koishijs/plugin-adapter-qq机器人需要开通指令面板 / 自定义菜单相关接口权限(这两个接口 2026-08-12 才上线)。
生产环境必须配置 IP 白名单:官方对新机器人强制开启 IP 白名单且不可关闭,只有白名单内的出口 IP 才能调用 OpenAPI。 漏配时会返回
11298 接口访问源 IP 不在白名单内(插件会把这条错误码翻译成中文提示)。沙箱环境不受影响。需要 HTTP 服务。插件已在
package.json的koishi.service.required与代码里的inject.required中声明对http的依赖。
安装
npm i koishi-plugin-qq-command-registrar在 koishi.yml 中启用:
plugins:
qq-command-registrar:
enabled: true # 打开后才会进行初次注册
panel:
enabled: true
commands: [] # 留空 = 全部已激活指令
scopes: ['单聊', '群聊']
menu:
enabled: true
categories: []AppID / Secret 不需要重复填写 —— 插件会自动读取 adapter-qq 的配置。
配置
配置只有四个区块。
连接
| 字段 | 默认值 | 说明 |
| --- | --- | --- |
| enabled | false | 注册开关。打开即开始初次注册,之后自动增量同步 |
| checkMode | false | 检查模式(演练):只查询线上现状并在日志里打印差异,不发送任何写请求 |
| appid | 空(自动) | 机器人 AppID。留空自动读取 adapter-qq;填写后只管理 AppID 匹配的机器人 |
| secret | 空(自动) | 机器人密钥(隐藏内容)。留空自动读取 adapter-qq;仅在适配器换不到 token 时作为兜底 |
指令面板
| 字段 | 默认值 | 说明 |
| --- | --- | --- |
| enabled | true | 是否管理指令面板 |
| commands | 空(全部) | 选单,选项来自当前已激活的指令列表。留空表示全部 |
| scopes | 单聊 + 群聊 | 多选勾选框:单聊 / 群聊 / 文字子频道 / 频道私信 |
| overrides | 空 | 手工覆盖指令的注册内容,见下节 |
四种场景都以「全量生效」的方式注册。一个机器人最多 20 个面板,每个面板最多 20 个元素;指令多了会自动拆成多个面板。
写 YAML 时
scopes也可以用数组写法:['单聊', '群聊'],与勾选等价。
指令覆盖 overrides
指令的内容、参数、说明默认全部自动识别,并且会随指令变化热重载(插件重启、指令增删改都会刷新)。
overrides 用来在需要时手工改写,只影响注册出去的菜单/面板,不改变指令本身的行为。
| 字段 | 说明 |
| --- | --- |
| command | 选单,要覆盖哪条指令 |
| content | 面板元素名 / 菜单按钮文字。留空用自动识别结果 |
| description | 展示用的说明。留空用指令自身的说明;填空串则清空(描述里就只剩参数用法) |
| arguments | 覆盖参数列表。留空用指令自身声明的参数 |
| arguments[].name | 参数名 |
| arguments[].required | 必填显示为 <参数>,选填显示为 [参数] |
| arguments[].variadic | 可变参数,显示为 ... |
| arguments[].type | 参数种类,如 number / user;string 是默认种类,不展示 |
panel:
overrides:
- command: echo
content: 复读 # 面板按钮文字
description: 重复 # 描述里显示什么
arguments:
- { name: 内容, required: true }
- { name: 次数, required: false, type: number }注册出去就成了:面板元素名 复读、说明 重复 <内容> [次数:number];菜单按钮文字也是 复读,
但 send_message 仍然是真实指令名,保证点了能触发。
注意面板元素名同时是点击后填入输入框的内容,把它改成非指令名会导致点击后触发不了指令。 说明与参数要一起显示时受 30 字符(约 15 个汉字)上限约束,放不下会只保留说明 —— 把说明改短即可让参数露出来。 覆盖项里写了、但当前不存在的指令名会被忽略,并在日志里提示。
控制台上选单的每个选项都会带上自动识别到的「说明 · 用法」,同时 debug 日志里会逐条打印识别结果,照着填即可:
已注册指令选单,共 3 个选项
识别到指令 echo:复读机|参数:content(必填), times
识别到指令 roll:掷骰子|参数:max:number菜单面板
| 字段 | 默认值 | 说明 |
| --- | --- | --- |
| enabled | true | 是否管理全局菜单(仅单聊窗口可见) |
| categories | 空 | 注册分类列表。留空则把所有指令平铺成一级按钮(最多 10 个) |
categories 每一项:
| 字段 | 说明 |
| --- | --- |
| name | 一级按钮名称,最多 5 个汉字 |
| commands | 选单,选项来自当前已激活的指令列表,最多 5 个 |
menu:
categories:
- name: 娱乐
commands: [roll, echo, jrrp]
- name: 管理
commands: [mute, kick]选单在控制台里渲染成下拉框;直接写 YAML 时也可以填
*通配符(如cmd*)。
其他
| 字段 | 默认值 | 说明 |
| --- | --- | --- |
| logLevel | info | 日志打印等级:silent / error / info / debug |
合并延迟、请求间隔、QPM 限流、重试次数、指令前缀、
prune、onlyAdmin等内部参数没有暴露成配置项,使用内置默认值。需要调整时可以覆盖导出的RUNTIME常量,或构造new Registrar(ctx, config, { delay: 0 })。
初次注册与检查模式
enabled 就是「注册按钮」:
- 装好并配置好 adapter-qq 后,插件启动时会在日志里打印识别到的机器人(AppID / 环境)。
- 打开 连接 →
enabled—— 这就是初次注册。 - 之后保持打开即可:指令增删改、或配置本身被修改,都会自动增量同步。
控制台保存配置会重新加载插件,插件对已经在线的机器人会主动补一次同步,所以「改完配置立刻就生效」。
不确定会改什么时,先打开 checkMode:插件只查询线上现状,把「将新增 / 将更新 / 将删除」打进日志,不发送任何写请求。确认无误再关掉它。
指令到菜单的映射
| Koishi | QQ 面板元素 | QQ 菜单按钮 |
| --- | --- | --- |
| name | name(点击后填入输入框) | 一级 name(≤10)/ 子项 name(≤14) |
| description | desc(≤30) | — |
| arguments | 拼进 desc,如 echo <内容> [次数] | — |
| — | type: 'command' | type: 'send_message',send_message = 指令名 |
长度按「1 个汉字 = 2 个字符」计算,与官方口径一致。
面板按钮是把文本填进输入框的,所以填入的内容会跟随 Commander 的前缀配置(Koishi 默认无前缀,即裸指令名)。
注意:面板元素的 name 既是标签也是点击后填入输入框的内容,所以指令名超过 14 字符宽时面板会整个跳过这条指令(截断会填出一条不存在的指令),并在日志里提醒。菜单项不受影响 —— 它的 name 只是标签,send_message 才是填入的文本,标签会截断、指令名保持完整。
查重是怎么做的
- 本地查重:把本次要下发的全部内容算一个指纹。如果和上次成功下发的一模一样,直接返回,一个 HTTP 请求都不发。
- 线上查重:即使指纹不同(比如插件刚重启),也会先
GET /v2/panels(按场景)和GET /v2/menu拉取现状,逐面板比对items与remark:- 完全一致 → 不发写请求
- 有差异 → 只
PUT那一个面板 - 数量变少 →
DELETE多出来的
- 只有全部成功后才记录指纹;中途失败的话下次会重新尝试。
取消勾选某个场景后,插件仍会查询该场景,把之前留下的面板回收掉。
比对时会先做归一化(补齐 only_admin: false、link: '' 等默认值),避免平台补全的默认值被误判成「有变更」。
日志
日志前缀统一是 qq-command-registrar,等级由「其他 → 日志打印等级」控制。
info(默认)—— 注册情况
识别到 QQ 机器人 bot-1:AppID=102000001,密钥=已读取,环境=正式
开始同步(启动就绪):待注册指令 3 个、面板 2 个、菜单 3 项
同步完成(启动就绪):新增 2 个面板;更新全局菜单debug —— 排查细节
已注册指令选单,共 3 个选项
场景 c2c:线上共 1 个面板,其中 1 个由本插件管理
场景 group:线上共 1 个面板,其中 1 个由本插件管理(未勾选,只做清理)
+ 新增面板 panel-1:场景 c2c,3 个元素
~ 更新面板 panel-3:内容有变化
- 删除多余面板 panel-1
→ POST /v2/panels?scope=c2c {...}
← POST /v2/panels ok
接口 panelWrite 已达 10 QPM,等待 4820msSecret 只会显示「已读取 / 未读取」,不会写进日志。
接口对接情况
| 用途 | 接口 | 我们发的请求 |
| --- | --- | --- |
| 查面板列表 | GET /v2/panels | scope(必填,按场景逐个查)、limit=50、翻页用 cursor |
| 查面板详情 | GET /v2/panels/{panel_id} | 列表没返回 items 时才补 |
| 建面板 | POST /v2/panels | { scope, target_type: 'all', panel: { items, remark } } |
| 改面板 | PUT /v2/panels/{panel_id} | { panel: { items, remark } } |
| 删面板 | DELETE /v2/panels/{panel_id} | 无 body |
| 查菜单 | GET /v2/menu | 无参数 |
| 改菜单 | PUT /v2/menu | { menu: { items } },整体覆盖 |
- 面板元素只发官方定义过的字段:
name/desc/type(only_admin仅在开启时带上)。 - 长度与数量都按官方口径(1 个汉字 = 2 字符)在本地先截断或拆分,不把超限数据发出去。
POST /v2/panels只返回{ panel_id }(不含面板内容),所以新建后会靠下一轮同步或详情接口补齐状态;插件对「对象」和「裸字符串」两种响应都做了兼容。panel.version我们不带:官方文档把它列为可选,且没有说明它是乐观锁;唯一与并发相关的错误码是40030009 操作进行中,并未与 version 挂钩。既然语义未定义,带上反而可能引入失败。PUT /v2/menu没有请求侧 version:version只在响应里返回,官方未定义条件写入语义,所以不存在「版本不匹配被拒」的问题。
测试里有一组官方文档一致性用例,直接断言我们发出去的路径、查询参数名、请求体字段名与嵌套结构,改动时能立刻发现不合规。
安全
| 项目 | 处理方式 |
| --- | --- |
| 鉴权头 | 复用适配器的 bot.http,自带 Authorization: QQBot <access_token> 与 X-Union-Appid,不用手工拼 |
| token 获取 | 优先 bot.getAccessToken()(适配器自带缓存与自动续期;在有效期内重复请求返回同一个 token) |
| token 兜底 | 适配器取不到时,用 AppID / Secret 打 POST /app/getAppAccessToken 换取,缓存到过期前 60 秒(官方正是在最后 60 秒才签发新 token,旧 token 在此期间仍然有效),并把新 token 装回 bot.http |
| Secret 展示 | 配置项声明为 role('secret'),控制台里打码 |
| Secret 日志 | 请求体写日志前统一走 redactForLog() 脱敏;secret / clientSecret / access_token 等字段一律变 ***。日志里只会出现「密钥=已读取 / 未读取」 |
| 传输 | endpoint 来自适配器,默认 https://api.bot.qq.com/;沙箱会自动加 sandbox. 前缀(换 token 也走同一个 endpoint) |
| 频率限制 | 按官方 QPM 分档限流(面板读 30 / 面板写 10 / target 60 / 菜单读 30 / 菜单写 5),滑动窗口,且所有请求串行 |
| 内容安全 | 官方在提交时做内容安全检查,没有客户端预检接口;返回 40030020 时插件带中文提示抛出,不会静默重试 |
| 排障 | 错误里保留官方的 trace_id(响应体字段或 X-Tps-trace-ID 响应头),便于找官方支持 |
关于错误码,两点实测结论:
- 官方确实会用真实的 HTTP 状态码(401 / 404 / 429 / 5xx),但也会在响应体里放
err_code(0 表示成功),而且 换 token 的接口反过来:失败也返回 HTTP 200,错误码在code字段里。插件两套都读(err_code ?? code),两种失败形态都能识别。 - 面板/菜单接口用
4003xxxx,通用错误用另一组。插件内置了中文释义,11298(IP 白名单)、100016(AppID/Secret 错误)、11253(未开通接口权限)这些最常踩的坑会直接打印人话,不用再去查文档。
配置里的
secret如果手工填写,会以明文存在koishi.yml中 —— 这是 Koishi 配置的固有行为(adapter-qq 也一样)。留空即可,插件会自动从适配器读取,不必把密钥再抄一遍。补充一句事实边界:官方明确要求的是「不要在应用前端使用访问凭证,请在服务端发起 API 访问」;至于「不要打印密钥到日志」, 官方文档里并没有明文规定,属于通用安全实践。插件按实践做了脱敏,但请不要把它当成官方合规要求。
另外:
X-Union-Appid这个头不在官方的接口调用指南里,但主流 SDK(simbot / botpy / StarBot 等)都会带, 且新的 access_token 鉴权方式实际上要求它。插件从适配器的bot.http继承该头,所以不要绕开bot.http自己发请求。
控制台页面
装上 @koishijs/plugin-console 后,控制台侧边栏会多出一个 「QQ 指令注册」 页面,用来直接在网页上看注册情况,不用翻终端:
- 当前状态:注册开关、检查模式、指令前缀、日志等级、识别到的机器人(AppID / 是否沙箱)
- 最近的同步记录:每次同步的时间、触发原因、结果摘要、失败时的完整报错,以及那次涉及的指令/面板/菜单数量。 连「内容与上次一致,已跳过」也会记一笔,方便确认「为什么什么都没发生」
- 自动识别到的指令:指令名、说明、用法,也就是插件实际读到的东西,照着它填「指令覆盖」
页面数据走控制台自己的 RPC(ctx.console.addListener + 客户端 send),不额外开任何 HTTP 端口,也就不存在绕过控制台鉴权的问题。
@koishijs/plugin-console 是可选依赖:没装时这一段会静默跳过(用 ctx.using(['console'], ...) 声明,而不是 if (ctx.console)),插件其余功能完全不受影响。
指令
| 指令 | 说明 |
| --- | --- |
| qq-command-registrar / qq-menu | 手动同步一次(开关关闭时也可用) |
| qq-menu -f | 忽略查重结果,强制重新下发 |
注意事项
- 全局自定义菜单只在 C2C 单聊窗口底部展示,群聊里看不到。
- 一个机器人最多 20 个指令面板,每个面板最多 20 个元素。指令多了会自动拆成多个面板;超过 20 个面板时会在日志里告警。
- 这套接口是 2026-08-12 新上的,需要较新的 QQ 客户端(手机版优先),灰度期间可能配置成功但客户端不显示。
- 插件自身的
qq-command-registrar指令名超过 14 字符宽,不会进入指令面板,但仍会出现在全局菜单里。 - 官方没有说明
PUT /v2/menu传空items是否合法,所以指令清空时插件不会去清空线上菜单,只告警一次。
开发
npm install
npm run build # 后端 tsc -> lib/,控制台前端 -> dist/
npm testnpm run build 会跑两步:
tsc -b:后端代码输出到lib/node scripts/build-client.cjs:控制台前端从client/index.ts打包到dist/(直接调用@koishijs/client官方导出的build(),外置依赖vue/@koishijs/client等由控制台宿主提供,和官方插件的构建方式一致)
发布时必须带上
dist/。这是控制台扩展最常见的坑:dist没构建或没发到 npm,开发环境看着正常,装到生产环境后页面就不出现。本项目的package.json的files已包含lib和dist。
测试使用 test/mock-server.js —— 一个严格复刻官方文档约束的假上游,不需要连接真实服务器就能验证生成的请求是否合规(GET /v2/panels 必须带 scope、列表接口不返回 openid、各处长度上限、QPM、HTTP 200 + err_code 等)。控制台扩展也有测试:没装 console 时静默跳过、装了之后注册入口与状态接口、状态接口返回的同步记录是否正确。
