Yunzai NG 插件开发
Yunzai NG 是「可插拔运行时内核」:核心不含任何 QQ 概念,账号登录、消息收发、面板前端全部由插件提供。
本技能覆盖三条开发线:内核插件、协议适配器、WebUI 面板插件。
事实来源与优先级
开发前先确认框架版本,三者冲突时按此顺序采信:
- 源码与类型定义:
packages/types/src/*.ts(plugin.ts/adapter.ts/bot.ts/event.ts/segment.ts/server.ts)—— 权威接口。 - 仓库内文档:
yunzai-ng.github.io/docs/下的plugin-api.md、adapter.md、panel-plugin.md、market.md、architecture.md。 - 本技能的
references/—— 上述文档的提炼版,用于快速定位;出现分歧时回到 1、2。
若当前工作目录不是框架仓库(例如在插件自己的仓库里开发),按下方「获取框架类型」一节建立链接。
第一步:判断属于哪条开发线
| 开发线 | 运行位置 | 入口写法 | 落点 |
|---|---|---|---|
| 内核插件 | Node 进程 | export default definePlugin({ name, setup(ctx) }) |
<home>/plugins/<name>/ |
| 协议适配器 | Node 进程(是内核插件的特例) | setup 内调用 ctx.registerAdapter(provider) |
<home>/plugins/adapter-<平台>/ |
| 面板插件(WebUI) | 浏览器(可选带 Node 侧) | export default { id, page, title, setup(api) } 或组件数组 |
plugins/webui/plugins/ |
需要命令、定时任务、适配器、渲染器、数据库 → 内核插件;只是往面板加一格界面 → 面板插件。
面板插件的 Node 侧是受限上下文(只有 name/dir/dataDir/logger/route/config),拿不到内核适配器与消息总线。
先破除四个常见误解
这些是旧版 Yunzai / 其他框架的习惯,在 NG 上会直接写错:
- 没有类式插件。 不存在
class Xxx extends plugin、plugin基类,也没有name/event/rule/task/priority这类插件类属性。生命周期只有setup(ctx)+ctx.onDispose()+ctx.on("app/ready" | "app/stopping")。 package.json只认yunzai: { name, disabled, entry }。 没有permissions、hidden、type字段。权限走命令选项的.master()/.admin(),hidden是CommandOptions的一项。- 适配器没有
protocols/support声明字段。 能力全部写在代码里:AdapterProvider(id/name/platform/accountSchema/loginModes)+BotDriver.caps。 - 面板插件不是
<插件名>/webui/布局。 唯一落点是plugins/webui/plugins/,且只认.js与.mjs(.ts会被拒绝并记一条警告),无需构建、刷新页面即生效。
命名硬约束(三条线通用)
插件名 / 适配器 id 须匹配 /^[a-z][a-z0-9._-]*$/i。该名称同时是 config/<name>.yaml 文件名、KV 命名空间前缀、/plugin/<name> URL 前缀与日志 scope,不合法会在 import 的一瞬间抛错。
内核插件:最短可用路径
- 生成骨架:运行
scripts/scaffold.mjs(见下方「模板与脚手架」),或框架自带的
node packages/cli/dist/bin.js plugin new <name>。 - 写一条命令:
ctx.command("#ping").desc("…").action(async e => { await e.reply("pong") })。 - 声明配置:
configSchema: s.object({ … })—— 一份声明同时驱动 YAML、面板表单与写入校验。 - 需要状态:
ctx.kv(已带plugin:<name>:前缀)或ctx.sql()(多行带索引的数据)。 - 需要定时:
ctx.cron("0 0 8 * * *", async signal => { … }, { overlap: "skip" })。 - 验证:见下方「验证与门禁」。
关键 API 速查表在 references/plugin.md,其中「命令与事件」「消息段」「配置与存储」三节是日常最常翻的部分。
协议适配器:核心工作只有一件事
把平台报文翻译成 IncomingEvent 并 host.submit()。
id、time、platform、selfId、bot、reply、isMaster、text、images 等派生项全部由内核补齐。
骨架、AdapterHost 成员表、BotApi 必选/可选方法、caps 能力标记、交互式登录(扫码/验证码/token)、
MediaRef 四种来源与其优先级,见 references/adapter.md。
四条硬约束(违反会在内核侧被断言或造成内存问题):
host.submit()同步返回,不等待结果。- 联系人缓存必须用
host.createCache({ max, ttl }),不得用裸Map(旧适配器千人群常驻数百 MB 即由此产生)。 disconnect()之后不得再投递任何事件。- 被动模式(反向 WS / HTTP 回调)必须先检查
host.server.enabled。
面板插件(WebUI)
两种形态,判据是「node 侧读得到什么」:
| 形态 | 长什么样 | 自报信息 | Node 侧 | 配置项 | 可上商店 |
|---|---|---|---|---|---|
| 单文件 | <名>.js |
文件内 export const meta |
不能 | 不能 | 不能 |
| 插件包 | <名>/index.js + package.json |
package.json 常规字段 |
能 | 能 | 能 |
- 必须自报家门:
version、description、repository缺一不可(author缺则从仓库地址推),否则不加载、只得到一格占位。 setup(api)返回一个渲染函数(不是 vnode)。h是 Vue 的h,但拿不到 Vue 本体(无createApp/watch,写不了.vue)。- 不能
import裸包名(浏览器按 URL 解析,import dayjs from "dayjs"是一条 404)。要用第三方库只能给相对 URL。 - 不要把令牌/密钥写进面板插件:面板插件是静态资源,刻意不鉴权。取数一律走
api.get/api.own。 - 自带计时器/监听器必须用
api.onUnmounted收尾;api.onTick(5 秒共享节拍)由面板代劳退订,且不触发第一拍,首轮取数要自己发起。 - 测不到的值应当缺席,不要记 0(
fmt的几个函数在undefined时给破折号)。
组件字段、tabs 页签、webuiPanel 清单(server/style/config/install)、
api 全部成员、面板 CSS 类与 CSS 变量、node 侧 ctx 的五项成员、上架索引 webui_index.json,
见 references/panel-plugin.md。
模板与脚手架
scripts/scaffold.mjs 按类型把 assets/templates/ 下的模板复制到目标目录,并替换 __NAME__ 占位符:
node <skill>/scripts/scaffold.mjs --type plugin --name my-plugin # 内核插件(JS)
node <skill>/scripts/scaffold.mjs --type plugin-ts --name my-plugin # 内核插件(TS,编译到 dist)
node <skill>/scripts/scaffold.mjs --type adapter --name adapter-myplatform # 协议适配器(TS)
node <skill>/scripts/scaffold.mjs --type panel --name clock # 面板插件·单文件
node <skill>/scripts/scaffold.mjs --type panel-package --name my-panel # 面板插件·插件包
加 --out <目录> 指定输出位置(缺省为当前目录下的 <name>)。已存在的目录不会被覆盖,除非加 --force。
模板清单:assets/templates/plugin/、plugin-ts/、adapter/、panel/、panel-package/。
不想跑脚本而只想看写法时,直接读这些模板文件 —— 它们都是完整可运行的骨架,不是片段。
验证与门禁
框架仓库自带门禁,改完代码后按需运行(先 pnpm install && pnpm run build):
pnpm run build # tsc -b
pnpm run typecheck # 只做类型检查
pnpm run lint # eslint(含 eslint-plugin-jsdoc 注释规范)
pnpm run test # vitest
pnpm run check:layering # 分层门禁:内核不得依赖插件
pnpm run verify # 以上全部串起来
分层门禁是硬约束:packages/core 只许依赖 @yunzai-ng/types;任何 packages/* 都不得 import plugins/*。
插件作者唯一被允许 import 的内部子路径是 @yunzai-ng/core/testing(createMockAdapter 等测试替身),
其余 @yunzai-ng/* 深层子路径与旧别名 #miao / #yunzai 一律拒绝。
不启动真实内核即可测试:
import { createMockAdapter } from "@yunzai-ng/core/testing"
const mock = createMockAdapter({ platform: "mock", caps: ["recall"] })
app.runtime.adapters.register(mock.provider, "test-plugin")
await app.runtime.accounts.create("mock", { selfId: "10000" })
mock.driver.receivePrivate("#ping")
await mock.waitForSend()
expect(mock.texts).toEqual(["pong"])
获取框架类型(在插件自己的仓库里开发时)
@yunzai-ng/{core,types,jsx} 是 peerDependencies,且 .npmrc 设了 auto-install-peers=false,
因此必须另行链接。官方插件仓库都带 scripts/link-framework.mjs(查找顺序:
YZNG_FRAMEWORK 环境变量 → ../yunzai-ng → ../../yunzai-ng;Windows 用 junction,无需开发者模式)。
插件装在 <home>/plugins/ 之下时,内核的 init / start 会自动完成链接。
常见陷阱
| 现象 | 原因 |
|---|---|
| 插件未被加载 | 源码目录下启动时遗漏 --plugins .\plugins;目录名以 . 或 _ 开头;yunzai.disabled 为真 |
Unknown file extension ".ts" |
TS 插件未编译。入口须指向 dist/index.js |
ERR_MODULE_NOT_FOUND: @yunzai-ng/core |
框架包未链接(见上一节) |
改了 e.message 但 e.text 未变 |
遗漏 e.refresh() |
| 命令被另一条更短的命令抢走 | 前缀遮蔽,给更具体的那条设置更小的 priority |
| 正则命令拖慢每条消息 | 首字符无法判定的正则落入未分桶列表;改写成固定前缀字符串 |
| 热重载后仍有任务在跑 | 在 ctx 之外自开的资源未经 ctx.onDispose() 登记 |
| 面板里不显示配置表单 | 未声明 configSchema;或插件加载失败(看日志) |
setup 里 await 慢请求导致加载失败 |
setup 有 30 秒超时,预热应移到 ctx.on("app/ready", …) |
面板插件 .ts 放进去没反应 |
面板插件只认 .js / .mjs |
| 面板插件组件不出现 | 缺自报信息(version/description/repository)、page 写错、声明 resizable 却漏 minW/minH |
已知与实现不一致之处(勿据此编码)
plugins.hotReload只有配置项,没有实现:全仓库仅packages/core/src/config/core-config.ts一处声明,
没有任何代码读取它或监听插件目录。实际可用的重载路径是面板的POST /api/plugins/:name/reload或重启进程。
框架文档中「启用plugins.hotReload时无须重启」的描述在代码中没有对应实现。- 配置热加载有实现:
ConfigStore用node:fs.watch,插件经ctx.config.onChange()即时生效。