feat: 建立QQBot插件平台

This commit is contained in:
sunlei 2026-06-15 04:41:57 +08:00
parent 7c32cda506
commit 7713d6c1ab
27 changed files with 2716 additions and 55 deletions

72
API.md
View File

@ -52,20 +52,20 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
## Runtime Health
| 方法 | 路径 | 认证 | 说明 |
| ----- | ----------------- | ---- | ---------------------------------- |
| `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 |
| 方法 | 路径 | 认证 | 说明 |
| ----- | ----------------- | ---- | ---------------------------- |
| `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 |
该接口返回 plain JSON不使用 Vben 响应包装,供本地 smoke、Jenkins/K8s 和 ktWorkflow 观测脚本直接读取。接口位于 Swagger 基础能力分组 `/api/basic`
顶层字段:
| 字段 | 说明 |
| ----------- | ------------------------------------------------ |
| `service` | 固定为 `kt-template-online-api` |
| `checkedAt` | ISO 时间字符串 |
| `status` | `live`、`ready`、`degraded` 或 `blocked` |
| `checks` | 进程和配置检查列表 |
| 字段 | 说明 |
| ----------- | ---------------------------------------- |
| `service` | 固定为 `kt-template-online-api` |
| `checkedAt` | ISO 时间字符串 |
| `status` | `live`、`ready`、`degraded` 或 `blocked` |
| `checks` | 进程和配置检查列表 |
公开响应不返回数据库、WordPress、Loki、NapCat SSH 等运行拓扑配置快照;配置检查只暴露 key 级别、是否存在和缺失说明。
@ -78,19 +78,19 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
## 环境变量分组
| 分组 | 关键变量 |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` |
| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` |
| 分组 | 关键变量 |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` |
| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` |
| QQBot | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` |
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` |
| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` |
| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
真实密码、Token、OAuth secret 和生产 env 不提交到 Git。
@ -407,6 +407,36 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
| `GET` | `/qqbot/conversation/list` | 会话列表 |
| `GET` | `/qqbot/message/list` | 消息列表 |
### Plugin Platform
插件平台使用统一 `plugin.json` manifest 描述插件 key、版本、入口、操作、事件、权限和运行预算后端会校验路径必须留在插件包内权限必须命中白名单安装包 content hash 必须与 manifest 匹配。CLI 入口:
```bash
pnpm qqbot-plugin create <pluginKey>
pnpm qqbot-plugin validate <pluginDir>
pnpm qqbot-plugin pack <pluginDir>
pnpm qqbot-plugin install-local <packageFile>
```
平台管理接口:
| 方法 | 路径 | 说明 |
| ------ | ----------------------------------------- | ---------------------------------- |
| `GET` | `/qqbot/plugin-platform/installations` | 插件安装记录,支持 key/status 过滤 |
| `POST` | `/qqbot/plugin-platform/upload` | 上传插件包并返回校验摘要 |
| `POST` | `/qqbot/plugin-platform/validate` | 校验 manifest JSON |
| `POST` | `/qqbot/plugin-platform/install` | 按上传包安装插件版本 |
| `POST` | `/qqbot/plugin-platform/install-local` | 按本地包路径安装插件版本 |
| `POST` | `/qqbot/plugin-platform/enable` | 启用插件安装 |
| `POST` | `/qqbot/plugin-platform/disable` | 禁用插件安装 |
| `POST` | `/qqbot/plugin-platform/upgrade` | 升级插件安装版本 |
| `POST` | `/qqbot/plugin-platform/uninstall` | 卸载插件安装 |
| `POST` | `/qqbot/plugin-platform/config` | 保存插件配置 |
| `GET` | `/qqbot/plugin-platform/runtime-events` | 查询插件运行事件 |
| `GET` | `/qqbot/plugin-platform/account-bindings` | 查询插件账号绑定 |
`src/modules/qqbot/plugin-platform/runtime` 当前提供 host-side driver 边界和超时/崩溃事件归档;实际 worker/child-process driver 可以在后续批次接入,但插件侧只能通过受控 SDK 访问发送队列、配置、存储、HTTP、资产和事件上下文。
### OneBot Reverse WebSocket
`QQBOT_REVERSE_WS_PATH` 默认是 `/qqbot/onebot/reverse`。NapCat 通过反向 WS 连接 APItoken 使用 `QQBOT_REVERSE_WS_TOKEN`

View File

@ -16,17 +16,18 @@
## 功能模块
| 模块 | 说明 |
| -------------------------- | -------------------------------------------------------------------------------- |
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志 |
| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
| 模块 | 说明 |
| ------------------------------- | ------------------------------------------------------------------------------------------ |
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、受控 SDK 和 CLI 脚手架 |
| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
## 目录结构
@ -36,6 +37,7 @@ src/
blog/ 本地博客内容与主题配置
common/ 全局装饰器、过滤器、拦截器、logger、工具和类型
minio/ MinIO 文件服务
modules/ 第三期重构后的业务边界模块
qqbot/ QQBot 运行态、管理接口和插件生态
wordpress/ WordPress REST 代理
app.module.ts
@ -53,17 +55,17 @@ ci/ Jenkins Agent/Docker 辅助文件
主要配置分组:
| 分组 | 变量 |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` |
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
| 分组 | 变量 |
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` |
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` |
| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` |
| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` |
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` |
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。
@ -85,6 +87,10 @@ pnpm run typecheck
pnpm run lint
pnpm test
pnpm run build
pnpm qqbot-plugin create <pluginKey>
pnpm qqbot-plugin validate <pluginDir>
pnpm qqbot-plugin pack <pluginDir>
pnpm qqbot-plugin install-local <packageFile>
```
Jest 只扫描 `test/**/*.spec.ts`。如果在 Windows 下指定测试文件,使用:
@ -146,6 +152,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
- QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
- QQBot 复读机默认阈值为 4同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
- QQBot 插件平台统一使用 `plugin.json` manifest 描述插件 key、版本、操作、事件、权限、运行预算和包入口CLI 负责 create/validate/pack/install-local后端只暴露受控 SDK 能力并通过插件维度记录安装、配置、账号绑定和运行事件。
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息WebUI 配置或请求错误留在 `lastError`
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
- NapCat 账号新增/编辑支持可选 QQ 登录密码Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。

View File

@ -1044,15 +1044,15 @@ git -C D:\MyFiles\KT\Vue\kt-template-admin commit -m "refactor: 同步QQBot核
- Create: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugin-platform\persistence\**`
- Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugin-platform\manifest.spec.ts`
- [ ] **Step 1: Write RED manifest validation tests**
- [x] **Step 1: Write RED manifest validation tests**
Cover plugin key, semantic version, SDK version, operations, events, config schema, assets, migrations, permissions, and runtime limits.
- [ ] **Step 2: Implement manifest parser**
- [x] **Step 2: Implement manifest parser**
Reject unknown permissions, duplicate operation keys, duplicate event keys, missing runtime budgets, and package paths outside plugin root.
- [ ] **Step 3: Verify**
- [x] **Step 3: Verify**
Run:
@ -1062,6 +1062,8 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --run
Expected: PASS.
Actual note: Manifest RED first failed with missing `src/modules/qqbot/plugin-platform/manifest`. The parser now normalizes complete manifests and rejects unknown permissions, duplicate operation/event keys, missing runtime budgets, missing operation timeout, and paths outside the plugin root. Batch 5 persistence RED first failed with missing `persistence`; TypeORM entities now map all 9 plugin-platform v3 tables and are checked against real `sql/refactor-v3/00-full-schema.sql`.
### Task 5.2: Build plugin CLI
**Files:**
@ -1072,7 +1074,7 @@ Expected: PASS.
- Modify: `D:\MyFiles\KT\Node\kt-template-online-api\package.json`
- Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugin-platform\cli.spec.ts`
- [ ] **Step 1: Add package script**
- [x] **Step 1: Add package script**
Add:
@ -1086,7 +1088,7 @@ Add:
Keep existing scripts.
- [ ] **Step 2: Implement commands**
- [x] **Step 2: Implement commands**
Commands:
@ -1097,7 +1099,7 @@ pack <path>
install-local <package>
```
- [ ] **Step 3: Verify CLI**
- [x] **Step 3: Verify CLI**
Run:
@ -1109,6 +1111,8 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api qqbot-plugin pack .\plugins
Expected: commands exit `0`, package includes content hash.
Actual note: Added `pnpm qqbot-plugin` backed by `scripts/qqbot-plugin/cli.ts`, with `create`, `validate`, `pack`, and `install-local`. CLI tests caught a package hash mismatch caused by `undefined` fields in stable serialization; stable stringify now skips `undefined` so `pack` and `install-local` verify the same content hash. Real CLI smoke created `plugins/demo-plugin`, validated it, packed `demo-plugin-0.1.0-acc880b89e70.qqbot-plugin.json`, installed it locally, and then removed the temporary directory.
### Task 5.3: Build worker runtime and lifecycle
**Files:**
@ -1117,19 +1121,19 @@ Expected: commands exit `0`, package includes content hash.
- Create: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugin-platform\sdk\**`
- Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugin-platform\worker-runtime.spec.ts`
- [ ] **Step 1: Write RED RPC tests**
- [x] **Step 1: Write RED RPC tests**
Cover `load`, `activate`, `executeOperation`, `handleEvent`, `health`, `deactivate`, `dispose`, timeout, and crash isolation.
- [ ] **Step 2: Implement host-side runtime**
- [x] **Step 2: Implement host-side runtime**
Worker messages include correlation ID, timeout budget, operation ID, safe input summary, and structured output.
- [ ] **Step 3: Implement SDK**
- [x] **Step 3: Implement SDK**
Expose only send queue, plugin config, plugin storage, runtime HTTP client, plugin asset loader, operation context, and event context.
- [ ] **Step 4: Verify**
- [x] **Step 4: Verify**
Run:
@ -1140,6 +1144,8 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck
Expected: PASS.
Actual note: Worker runtime RED first failed on missing `runtime` and `sdk`. `QqbotPluginWorkerRuntime` now sends lifecycle/execution RPC messages with correlation ID, timeout budget, operation ID/event key, and safe input summary without raw secret values. Worker crash and timeout become plugin-scoped runtime events and set the installation runtime status boundary to failed. `createQqbotPluginSdk` exposes only host-controlled send queue, config, storage, runtime HTTP, asset, operation context, event context, and runtime-event capabilities.
### Task 5.4: Sync Admin plugin platform page
**Files:**
@ -1148,15 +1154,15 @@ Expected: PASS.
- Modify: `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\qqbot\plugin\list.tsx`
- Modify: `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\router\routes\modules\qqbot.ts`
- [ ] **Step 1: Add plugin caller**
- [x] **Step 1: Add plugin caller**
Caller methods: upload, validate, install, enable, disable, upgrade, uninstall, update config, list runtime events, list account bindings.
- [ ] **Step 2: Build management page**
- [x] **Step 2: Build management page**
Use tables, status tags, drawers/modals for upload/config/events, and Chinese operation labels.
- [ ] **Step 3: Verify**
- [x] **Step 3: Verify**
Run:
@ -1166,6 +1172,8 @@ pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin -F @vben/web-antdv-next run typec
Expected: PASS.
Actual note: Added `/qqbot/plugin-platform/*` API controller/service/module before wiring Admin so the caller methods target real routes. Admin now has `src/api/qqbot/plugin.ts` with upload, validate, install, install-local, enable, disable, upgrade, uninstall, config update, runtime events, and account bindings. The existing plugin page remains a single `Page` root, keeps the operation table, and adds Chinese platform actions plus modal/drawer flows for Manifest JSON, installation records, runtime events, and account bindings. Admin route title changed from "插件能力" to "插件平台".
### Task 5.5: Commit Batch 5
Run:

View File

@ -22,6 +22,7 @@
"typecheck": "tsc --noEmit",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"qqbot-plugin": "ts-node -r tsconfig-paths/register scripts/qqbot-plugin/cli.ts",
"verify:staged": "node scripts/husky-fast-check.mjs",
"verify:commit": "pnpm run lint && pnpm run typecheck",
"test": "jest",

312
scripts/qqbot-plugin/cli.ts Normal file
View File

@ -0,0 +1,312 @@
import * as crypto from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
import {
parseQqbotPluginManifest,
type QqbotPluginManifest,
} from '../../src/modules/qqbot/plugin-platform/manifest';
export type QqbotPluginCliCommand =
| 'create'
| 'install-local'
| 'pack'
| 'validate';
export type QqbotPluginCliOptions = {
cwd?: string;
stderr?: (message: string) => void;
stdout?: (message: string) => void;
};
export type QqbotPluginCliResult = {
command: QqbotPluginCliCommand;
exitCode: number;
packageHash?: string;
packagePath?: string;
pluginKey?: string;
pluginRoot?: string;
version?: string;
};
type PackedPluginFile = {
path: string;
sha256: string;
};
type PackedPlugin = {
contentHash: string;
files: PackedPluginFile[];
manifest: QqbotPluginManifest;
};
const templateRoot = path.join(__dirname, 'templates', 'basic');
const packageExtension = '.qqbot-plugin.json';
const sha256 = (content: Buffer | string) =>
crypto.createHash('sha256').update(content).digest('hex');
const stableStringify = (value: unknown): string => {
if (Array.isArray(value)) {
return `[${value.map((item) => stableStringify(item)).join(',')}]`;
}
if (value && typeof value === 'object') {
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.filter((key) => record[key] !== undefined)
.sort()
.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`)
.join(',')}}`;
}
return JSON.stringify(value);
};
const formatPluginName = (pluginKey: string) => {
return pluginKey
.split('-')
.map((segment) => segment.charAt(0).toUpperCase() + segment.slice(1))
.join(' ');
};
const readManifest = (pluginRoot: string) => {
const manifestPath = path.join(pluginRoot, 'plugin.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8')) as unknown;
return parseQqbotPluginManifest(manifest, { pluginRoot });
};
const copyTemplateFile = (
sourcePath: string,
targetPath: string,
replacements: Record<string, string>,
) => {
const content = fs.readFileSync(sourcePath);
const textContent = content.toString('utf8');
const rendered = Object.entries(replacements).reduce(
(result, [key, value]) => result.replaceAll(key, value),
textContent,
);
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
fs.writeFileSync(targetPath, rendered);
};
const copyTemplateDirectory = (
sourceRoot: string,
targetRoot: string,
replacements: Record<string, string>,
) => {
for (const entry of fs.readdirSync(sourceRoot, { withFileTypes: true })) {
const sourcePath = path.join(sourceRoot, entry.name);
const targetPath = path.join(targetRoot, entry.name);
if (entry.isDirectory()) {
copyTemplateDirectory(sourcePath, targetPath, replacements);
continue;
}
copyTemplateFile(sourcePath, targetPath, replacements);
}
};
const listPackageFiles = (pluginRoot: string): PackedPluginFile[] => {
const files: PackedPluginFile[] = [];
const ignoredRoots = new Set(['dist', 'node_modules']);
const visit = (current: string) => {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
if (current === pluginRoot && ignoredRoots.has(entry.name)) continue;
const absolutePath = path.join(current, entry.name);
if (entry.isDirectory()) {
visit(absolutePath);
continue;
}
const relativePath = path
.relative(pluginRoot, absolutePath)
.replace(/\\/g, '/');
files.push({
path: relativePath,
sha256: sha256(fs.readFileSync(absolutePath)),
});
}
};
visit(pluginRoot);
return files.sort((a, b) => a.path.localeCompare(b.path));
};
const buildPackedPlugin = (pluginRoot: string): PackedPlugin => {
const manifest = readManifest(pluginRoot);
const files = listPackageFiles(pluginRoot);
const contentHash = sha256(
stableStringify({
files,
manifest,
}),
);
return {
contentHash,
files,
manifest,
};
};
const assertPackageIntegrity = (packedPlugin: PackedPlugin) => {
const expectedHash = sha256(
stableStringify({
files: packedPlugin.files,
manifest: packedPlugin.manifest,
}),
);
if (packedPlugin.contentHash !== expectedHash) {
throw new Error('QQBot plugin package content hash mismatch.');
}
parseQqbotPluginManifest(packedPlugin.manifest);
};
const createPlugin = (
pluginKey: string | undefined,
options: Required<QqbotPluginCliOptions>,
): QqbotPluginCliResult => {
if (!pluginKey) throw new Error('Usage: qqbot-plugin create <pluginKey>');
const pluginRoot = path.join(options.cwd, 'plugins', pluginKey);
if (fs.existsSync(pluginRoot)) {
throw new Error(`Plugin already exists: ${pluginRoot}`);
}
copyTemplateDirectory(templateRoot, pluginRoot, {
__PLUGIN_KEY__: pluginKey,
__PLUGIN_NAME__: formatPluginName(pluginKey),
});
fs.mkdirSync(path.join(pluginRoot, 'assets'), { recursive: true });
fs.mkdirSync(path.join(pluginRoot, 'migrations'), { recursive: true });
fs.mkdirSync(path.join(pluginRoot, 'tests'), { recursive: true });
readManifest(pluginRoot);
options.stdout(`Created QQBot plugin: ${pluginRoot}`);
return {
command: 'create',
exitCode: 0,
pluginKey,
pluginRoot,
version: '0.1.0',
};
};
const validatePlugin = (
pluginRootArg: string | undefined,
options: Required<QqbotPluginCliOptions>,
): QqbotPluginCliResult => {
if (!pluginRootArg) throw new Error('Usage: qqbot-plugin validate <path>');
const pluginRoot = path.resolve(options.cwd, pluginRootArg);
const manifest = readManifest(pluginRoot);
options.stdout(
`Validated QQBot plugin: ${manifest.pluginKey}@${manifest.version}`,
);
return {
command: 'validate',
exitCode: 0,
pluginKey: manifest.pluginKey,
pluginRoot,
version: manifest.version,
};
};
const packPlugin = (
pluginRootArg: string | undefined,
options: Required<QqbotPluginCliOptions>,
): QqbotPluginCliResult => {
if (!pluginRootArg) throw new Error('Usage: qqbot-plugin pack <path>');
const pluginRoot = path.resolve(options.cwd, pluginRootArg);
const packedPlugin = buildPackedPlugin(pluginRoot);
const shortHash = packedPlugin.contentHash.slice(0, 12);
const packagePath = path.join(
pluginRoot,
'dist',
`${packedPlugin.manifest.pluginKey}-${packedPlugin.manifest.version}-${shortHash}${packageExtension}`,
);
fs.mkdirSync(path.dirname(packagePath), { recursive: true });
fs.writeFileSync(packagePath, `${JSON.stringify(packedPlugin, null, 2)}\n`);
options.stdout(`Packed QQBot plugin: ${packagePath}`);
return {
command: 'pack',
exitCode: 0,
packageHash: packedPlugin.contentHash,
packagePath,
pluginKey: packedPlugin.manifest.pluginKey,
pluginRoot,
version: packedPlugin.manifest.version,
};
};
const installLocalPlugin = (
packagePathArg: string | undefined,
options: Required<QqbotPluginCliOptions>,
): QqbotPluginCliResult => {
if (!packagePathArg) {
throw new Error('Usage: qqbot-plugin install-local <package>');
}
const packagePath = path.resolve(options.cwd, packagePathArg);
const packedPlugin = JSON.parse(
fs.readFileSync(packagePath, 'utf8'),
) as PackedPlugin;
assertPackageIntegrity(packedPlugin);
options.stdout(
`Installed local QQBot plugin package: ${packedPlugin.manifest.pluginKey}@${packedPlugin.manifest.version}`,
);
return {
command: 'install-local',
exitCode: 0,
packageHash: packedPlugin.contentHash,
packagePath,
pluginKey: packedPlugin.manifest.pluginKey,
version: packedPlugin.manifest.version,
};
};
export const runQqbotPluginCli = async (
argv: string[],
options: QqbotPluginCliOptions = {},
): Promise<QqbotPluginCliResult> => {
const command = argv[0] as QqbotPluginCliCommand | undefined;
const resolvedOptions: Required<QqbotPluginCliOptions> = {
cwd: options.cwd || process.cwd(),
stderr: options.stderr || ((message) => console.error(message)),
stdout: options.stdout || ((message) => console.log(message)),
};
switch (command) {
case 'create':
return createPlugin(argv[1], resolvedOptions);
case 'install-local':
return installLocalPlugin(argv[1], resolvedOptions);
case 'pack':
return packPlugin(argv[1], resolvedOptions);
case 'validate':
return validatePlugin(argv[1], resolvedOptions);
default:
throw new Error(
'Usage: qqbot-plugin <create|validate|pack|install-local> [args]',
);
}
};
if (require.main === module) {
runQqbotPluginCli(process.argv.slice(2)).catch((error) => {
console.error(error instanceof Error ? error.message : error);
process.exitCode = 1;
});
}

View File

@ -0,0 +1,54 @@
{
"pluginKey": "__PLUGIN_KEY__",
"name": "__PLUGIN_NAME__",
"description": "Generated QQBot plugin",
"version": "0.1.0",
"author": "KT",
"license": "UNLICENSED",
"homepage": "",
"minApiSdkVersion": "1.0.0",
"entry": "src/index.ts",
"permissions": ["qqbot.send", "plugin.config.read", "plugin.storage.write"],
"operations": [
{
"key": "__PLUGIN_KEY__.echo",
"name": "Echo",
"description": "Echo input text",
"handlerName": "echo",
"aliases": ["/__PLUGIN_KEY__"],
"permissions": ["qqbot.send"],
"timeoutMs": 3000,
"inputSchema": {
"type": "object",
"properties": {
"text": {
"type": "string"
}
}
},
"outputSchema": {
"type": "object"
}
}
],
"events": [
{
"key": "__PLUGIN_KEY__.message",
"name": "Message Event",
"eventName": "message.created",
"handlerName": "onMessage"
}
],
"configSchema": {
"type": "object",
"properties": {}
},
"assets": [],
"migrations": [],
"runtime": {
"workerType": "node-worker",
"timeoutMs": 5000,
"memoryMb": 128,
"maxConcurrency": 1
}
}

View File

@ -0,0 +1,11 @@
export async function echo(input: { text?: string }) {
return {
replyText: input.text || 'pong',
};
}
export async function onMessage() {
return {
handled: false,
};
}

View File

@ -17,6 +17,7 @@ import { AdminModule } from './modules/admin/admin.module';
import { AssetModule } from './modules/asset/asset.module';
import { BlogContentModule } from './modules/blog/blog-content.module';
import { QqbotCoreModule } from './modules/qqbot/core/qqbot-core.module';
import { QqbotPluginPlatformModule } from './modules/qqbot/plugin-platform/plugin-platform.module';
import { WordpressMirrorModule } from './modules/wordpress/wordpress-mirror.module';
import { RuntimeModule } from './runtime';
@ -70,6 +71,7 @@ import { RuntimeModule } from './runtime';
WordpressMirrorModule,
AssetModule,
QqbotCoreModule,
QqbotPluginPlatformModule,
],
providers: [
AppService,

View File

@ -0,0 +1,2 @@
export * from './manifest.parser';
export * from './manifest.types';

View File

@ -0,0 +1,395 @@
import * as path from 'path';
import {
QQBOT_PLUGIN_ALLOWED_PERMISSIONS,
QQBOT_PLUGIN_WORKER_TYPES,
type QqbotPluginAssetManifest,
type QqbotPluginEventManifest,
type QqbotPluginManifest,
type QqbotPluginManifestParseOptions,
type QqbotPluginManifestValidationIssue,
type QqbotPluginMigrationManifest,
type QqbotPluginOperationManifest,
type QqbotPluginPermission,
type QqbotPluginRuntimeManifest,
type QqbotPluginWorkerType,
} from './manifest.types';
const pluginKeyPattern = /^[a-z][a-z0-9-]{2,63}$/;
const semanticVersionPattern = /^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
const capabilityKeyPattern = /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+$/;
const windowsAbsolutePathPattern = /^[a-zA-Z]:[\\/]/;
const allowedPermissionSet = new Set<string>(QQBOT_PLUGIN_ALLOWED_PERMISSIONS);
const allowedWorkerTypeSet = new Set<string>(QQBOT_PLUGIN_WORKER_TYPES);
export class QqbotPluginManifestValidationError extends Error {
constructor(readonly issues: QqbotPluginManifestValidationIssue[]) {
super(
`QQBot plugin manifest validation failed: ${issues
.map((issue) => `${issue.path}:${issue.code}`)
.join(', ')}`,
);
this.name = 'QqbotPluginManifestValidationError';
}
}
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return !!value && typeof value === 'object' && !Array.isArray(value);
};
const getString = (
source: Record<string, unknown>,
key: string,
): string | undefined => {
const value = source[key];
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
};
const getStringArray = (
source: Record<string, unknown>,
key: string,
): string[] => {
const value = source[key];
if (!Array.isArray(value)) return [];
return value
.filter((item): item is string => typeof item === 'string')
.map((item) => item.trim())
.filter(Boolean);
};
const getNumber = (
source: Record<string, unknown>,
key: string,
): number | undefined => {
const value = source[key];
return typeof value === 'number' && Number.isFinite(value)
? value
: undefined;
};
const pushIssue = (
issues: QqbotPluginManifestValidationIssue[],
code: string,
pathName: string,
message: string,
) => {
issues.push({ code, message, path: pathName });
};
const normalizePackagePath = (
value: unknown,
pathName: string,
issues: QqbotPluginManifestValidationIssue[],
): string => {
if (typeof value !== 'string' || !value.trim()) {
pushIssue(issues, 'REQUIRED_PATH', pathName, 'Package path is required.');
return '';
}
const candidate = value.trim().replace(/\\/g, '/');
const normalized = path.posix.normalize(candidate);
const outsideRoot =
candidate.includes('\0') ||
normalized === '..' ||
normalized.startsWith('../') ||
normalized.startsWith('/') ||
path.posix.isAbsolute(candidate) ||
windowsAbsolutePathPattern.test(candidate) ||
normalized.split('/').includes('..');
if (outsideRoot) {
pushIssue(
issues,
'PATH_OUTSIDE_PLUGIN_ROOT',
pathName,
'Package paths must stay inside the plugin root.',
);
return candidate;
}
return normalized;
};
const normalizePermissions = (
value: unknown,
pathName: string,
issues: QqbotPluginManifestValidationIssue[],
): QqbotPluginPermission[] => {
if (!Array.isArray(value)) return [];
const result: QqbotPluginPermission[] = [];
value.forEach((item, index) => {
if (typeof item !== 'string' || !item.trim()) {
pushIssue(
issues,
'INVALID_PERMISSION',
`${pathName}[${index}]`,
'Permission must be a non-empty string.',
);
return;
}
const permission = item.trim();
if (!allowedPermissionSet.has(permission)) {
pushIssue(
issues,
'UNKNOWN_PERMISSION',
`${pathName}[${index}]`,
`Unknown permission: ${permission}.`,
);
return;
}
if (!result.includes(permission as QqbotPluginPermission)) {
result.push(permission as QqbotPluginPermission);
}
});
return result;
};
const requireSemver = (
value: string | undefined,
pathName: string,
issues: QqbotPluginManifestValidationIssue[],
) => {
if (!value || !semanticVersionPattern.test(value)) {
pushIssue(
issues,
'INVALID_SEMVER',
pathName,
'Version must use semantic version format.',
);
}
};
const requireKey = (
value: string | undefined,
pathName: string,
issues: QqbotPluginManifestValidationIssue[],
) => {
if (!value || !capabilityKeyPattern.test(value)) {
pushIssue(
issues,
'INVALID_CAPABILITY_KEY',
pathName,
'Capability key must be dot-separated lower-case segments.',
);
}
};
const parseRuntime = (
source: Record<string, unknown>,
issues: QqbotPluginManifestValidationIssue[],
): QqbotPluginRuntimeManifest => {
const runtime = isPlainObject(source.runtime) ? source.runtime : {};
const timeoutMs = getNumber(runtime, 'timeoutMs');
const memoryMb = getNumber(runtime, 'memoryMb');
const maxConcurrency = getNumber(runtime, 'maxConcurrency');
const workerType = getString(runtime, 'workerType');
if (!timeoutMs || !memoryMb || !maxConcurrency) {
pushIssue(
issues,
'MISSING_RUNTIME_BUDGET',
'runtime',
'Runtime must declare timeoutMs, memoryMb, and maxConcurrency.',
);
}
if (!workerType || !allowedWorkerTypeSet.has(workerType)) {
pushIssue(
issues,
'INVALID_WORKER_TYPE',
'runtime.workerType',
'Runtime workerType is not supported.',
);
}
return {
maxConcurrency: maxConcurrency || 1,
memoryMb: memoryMb || 128,
timeoutMs: timeoutMs || 1000,
workerType: (workerType || 'node-worker') as QqbotPluginWorkerType,
};
};
const parseOperations = (
source: Record<string, unknown>,
issues: QqbotPluginManifestValidationIssue[],
): QqbotPluginOperationManifest[] => {
const operations = Array.isArray(source.operations) ? source.operations : [];
const seenKeys = new Set<string>();
return operations.filter(isPlainObject).map((operation, index) => {
const pathPrefix = `operations[${index}]`;
const key = getString(operation, 'key') || '';
const timeoutMs = getNumber(operation, 'timeoutMs');
requireKey(key, `${pathPrefix}.key`, issues);
if (seenKeys.has(key)) {
pushIssue(
issues,
'DUPLICATE_OPERATION_KEY',
pathPrefix,
`Duplicate operation key: ${key}.`,
);
}
seenKeys.add(key);
if (!timeoutMs) {
pushIssue(
issues,
'MISSING_OPERATION_TIMEOUT',
`${pathPrefix}.timeoutMs`,
'Operation timeoutMs is required.',
);
}
return {
aliases: getStringArray(operation, 'aliases'),
description: getString(operation, 'description'),
handlerName: getString(operation, 'handlerName') || '',
inputSchema: isPlainObject(operation.inputSchema)
? operation.inputSchema
: undefined,
key,
name: getString(operation, 'name') || key,
outputSchema: isPlainObject(operation.outputSchema)
? operation.outputSchema
: undefined,
permissions: normalizePermissions(
operation.permissions,
`${pathPrefix}.permissions`,
issues,
),
timeoutMs: timeoutMs || 1000,
};
});
};
const parseEvents = (
source: Record<string, unknown>,
issues: QqbotPluginManifestValidationIssue[],
): QqbotPluginEventManifest[] => {
const events = Array.isArray(source.events) ? source.events : [];
const seenKeys = new Set<string>();
return events.filter(isPlainObject).map((event, index) => {
const pathPrefix = `events[${index}]`;
const key = getString(event, 'key') || '';
requireKey(key, `${pathPrefix}.key`, issues);
if (seenKeys.has(key)) {
pushIssue(
issues,
'DUPLICATE_EVENT_KEY',
pathPrefix,
`Duplicate event key: ${key}.`,
);
}
seenKeys.add(key);
return {
description: getString(event, 'description'),
eventName: getString(event, 'eventName') || '',
handlerName: getString(event, 'handlerName') || '',
key,
name: getString(event, 'name') || key,
};
});
};
const parseAssets = (
source: Record<string, unknown>,
issues: QqbotPluginManifestValidationIssue[],
): QqbotPluginAssetManifest[] => {
const assets = Array.isArray(source.assets) ? source.assets : [];
return assets.filter(isPlainObject).map((asset, index) => ({
contentHash: getString(asset, 'contentHash'),
key: getString(asset, 'key') || '',
path: normalizePackagePath(asset.path, `assets[${index}].path`, issues),
}));
};
const parseMigrations = (
source: Record<string, unknown>,
issues: QqbotPluginManifestValidationIssue[],
): QqbotPluginMigrationManifest[] => {
const migrations = Array.isArray(source.migrations) ? source.migrations : [];
return migrations.filter(isPlainObject).map((migration, index) => ({
path: normalizePackagePath(
migration.path,
`migrations[${index}].path`,
issues,
),
version: getString(migration, 'version') || '',
}));
};
export const parseQqbotPluginManifest = (
manifestLike: unknown,
options: QqbotPluginManifestParseOptions = {},
): QqbotPluginManifest => {
void options;
const issues: QqbotPluginManifestValidationIssue[] = [];
if (!isPlainObject(manifestLike)) {
throw new QqbotPluginManifestValidationError([
{
code: 'INVALID_MANIFEST',
message: 'Manifest must be a JSON object.',
path: '$',
},
]);
}
const pluginKey = getString(manifestLike, 'pluginKey') || '';
if (!pluginKeyPattern.test(pluginKey)) {
pushIssue(
issues,
'INVALID_PLUGIN_KEY',
'pluginKey',
'Plugin key must be lower-case kebab-case.',
);
}
const version = getString(manifestLike, 'version') || '';
const minApiSdkVersion = getString(manifestLike, 'minApiSdkVersion') || '';
requireSemver(version, 'version', issues);
requireSemver(minApiSdkVersion, 'minApiSdkVersion', issues);
const parsedManifest: QqbotPluginManifest = {
assets: parseAssets(manifestLike, issues),
author: getString(manifestLike, 'author'),
configSchema: isPlainObject(manifestLike.configSchema)
? manifestLike.configSchema
: {},
description: getString(manifestLike, 'description'),
entry: normalizePackagePath(manifestLike.entry, 'entry', issues),
events: parseEvents(manifestLike, issues),
homepage: getString(manifestLike, 'homepage'),
license: getString(manifestLike, 'license'),
migrations: parseMigrations(manifestLike, issues),
minApiSdkVersion,
name: getString(manifestLike, 'name') || pluginKey,
operations: parseOperations(manifestLike, issues),
permissions: normalizePermissions(
manifestLike.permissions,
'permissions',
issues,
),
pluginKey,
runtime: parseRuntime(manifestLike, issues),
version,
};
if (issues.length > 0) {
throw new QqbotPluginManifestValidationError(issues);
}
return parsedManifest;
};

View File

@ -0,0 +1,88 @@
export const QQBOT_PLUGIN_ALLOWED_PERMISSIONS = [
'asset.read',
'plugin.config.read',
'plugin.config.write',
'plugin.storage.read',
'plugin.storage.write',
'qqbot.command.read',
'qqbot.event.receive',
'qqbot.send',
'runtime.http',
] as const;
export const QQBOT_PLUGIN_WORKER_TYPES = [
'child-process',
'node-worker',
] as const;
export type QqbotPluginPermission =
(typeof QQBOT_PLUGIN_ALLOWED_PERMISSIONS)[number];
export type QqbotPluginWorkerType = (typeof QQBOT_PLUGIN_WORKER_TYPES)[number];
export type QqbotPluginRuntimeManifest = {
maxConcurrency: number;
memoryMb: number;
timeoutMs: number;
workerType: QqbotPluginWorkerType;
};
export type QqbotPluginOperationManifest = {
aliases: string[];
description?: string;
handlerName: string;
inputSchema?: Record<string, unknown>;
key: string;
name: string;
outputSchema?: Record<string, unknown>;
permissions: QqbotPluginPermission[];
timeoutMs: number;
};
export type QqbotPluginEventManifest = {
description?: string;
eventName: string;
handlerName: string;
key: string;
name: string;
};
export type QqbotPluginAssetManifest = {
contentHash?: string;
key: string;
path: string;
};
export type QqbotPluginMigrationManifest = {
path: string;
version: string;
};
export type QqbotPluginManifest = {
assets: QqbotPluginAssetManifest[];
author?: string;
configSchema: Record<string, unknown>;
description?: string;
entry: string;
events: QqbotPluginEventManifest[];
homepage?: string;
license?: string;
migrations: QqbotPluginMigrationManifest[];
minApiSdkVersion: string;
name: string;
operations: QqbotPluginOperationManifest[];
permissions: QqbotPluginPermission[];
pluginKey: string;
runtime: QqbotPluginRuntimeManifest;
version: string;
};
export type QqbotPluginManifestValidationIssue = {
code: string;
message: string;
path: string;
};
export type QqbotPluginManifestParseOptions = {
pluginRoot?: string;
};

View File

@ -0,0 +1,2 @@
export * from './plugin-platform.contract';
export * from './plugin-platform.entities';

View File

@ -0,0 +1,20 @@
export const QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT = {
routes: {
accountBindings: '/qqbot/plugin-platform/account-bindings',
installLocal: '/qqbot/plugin-platform/install-local',
installations: '/qqbot/plugin-platform/installations',
runtimeEvents: '/qqbot/plugin-platform/runtime-events',
validate: '/qqbot/plugin-platform/validate',
},
tables: [
'qqbot_plugin',
'qqbot_plugin_version',
'qqbot_plugin_installation',
'qqbot_plugin_operation',
'qqbot_plugin_event_handler',
'qqbot_plugin_account_binding',
'qqbot_plugin_config',
'qqbot_plugin_asset',
'qqbot_plugin_runtime_event',
],
} as const;

View File

@ -0,0 +1,303 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtUpdateDateColumn,
} from '@/common';
export type QqbotPluginInstallStatus =
| 'disabled'
| 'enabled'
| 'failed'
| 'installed'
| 'uninstalled'
| 'uploaded'
| 'validated';
export type QqbotPluginRuntimeStatus =
| 'crashed'
| 'healthy'
| 'starting'
| 'stopped'
| 'unhealthy';
export type QqbotPluginRuntimeEventLevel = 'error' | 'info' | 'warn';
@Entity('qqbot_plugin')
@Index('uk_qqbot_plugin_key', ['pluginKey'], { unique: true })
export class QqbotPlugin {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ length: 128, name: 'plugin_key' })
pluginKey: string;
@Column({ length: 128, name: 'plugin_name' })
pluginName: string;
@Column({ name: 'description', nullable: true, type: 'text' })
description: null | string;
@Column({ length: 32 })
status: QqbotPluginInstallStatus;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_version')
@Index('uk_qqbot_plugin_version', ['pluginId', 'version'], { unique: true })
export class QqbotPluginVersion {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ length: 64 })
version: string;
@Column({ length: 128, name: 'package_hash' })
packageHash: string;
@Column({ name: 'manifest_json', type: 'simple-json' })
manifestJson: Record<string, unknown>;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_installation')
@Index('idx_qqbot_plugin_installation_status', ['status'])
export class QqbotPluginInstallation {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ name: 'version_id', type: 'bigint' })
versionId: string;
@Column({ length: 32 })
status: QqbotPluginInstallStatus;
@Column({ length: 32, name: 'runtime_status' })
runtimeStatus: QqbotPluginRuntimeStatus;
@Column({ length: 512, name: 'installed_path' })
installedPath: string;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_operation')
@Index('uk_qqbot_plugin_operation', ['pluginId', 'operationKey'], {
unique: true,
})
export class QqbotPluginOperation {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ length: 128, name: 'operation_key' })
operationKey: string;
@Column({ length: 128, name: 'operation_name' })
operationName: string;
@Column({ length: 128, name: 'handler_name' })
handlerName: string;
@Column({ default: true })
enabled: boolean;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_event_handler')
@Index('uk_qqbot_plugin_event_handler', ['pluginId', 'eventKey'], {
unique: true,
})
export class QqbotPluginEventHandler {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ length: 128, name: 'event_key' })
eventKey: string;
@Column({ length: 128, name: 'handler_name' })
handlerName: string;
@Column({ default: true })
enabled: boolean;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_account_binding')
@Index('uk_qqbot_plugin_account_binding', ['pluginId', 'accountId'], {
unique: true,
})
export class QqbotPluginAccountBinding {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ name: 'account_id', type: 'bigint' })
accountId: string;
@Column({ default: true })
enabled: boolean;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_config')
@Index('uk_qqbot_plugin_config', ['pluginId', 'configKey'], { unique: true })
export class QqbotPluginConfig {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ length: 128, name: 'config_key' })
configKey: string;
@Column({ name: 'config_value', nullable: true, type: 'simple-json' })
configValue: null | Record<string, unknown>;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_asset')
@Index('uk_qqbot_plugin_asset', ['pluginId', 'assetKey'], { unique: true })
export class QqbotPluginAsset {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({ length: 255, name: 'asset_key' })
assetKey: string;
@Column({ length: 512, name: 'asset_path' })
assetPath: string;
@Column({ length: 128, name: 'content_hash' })
contentHash: string;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
@Entity('qqbot_plugin_runtime_event')
@Index('idx_qqbot_plugin_runtime_event_plugin', ['pluginId'])
export class QqbotPluginRuntimeEvent {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'plugin_id', type: 'bigint' })
pluginId: string;
@Column({
default: null,
name: 'installation_id',
nullable: true,
type: 'bigint',
})
installationId: null | string;
@Column({ length: 64, name: 'event_type' })
eventType: string;
@Column({ length: 32 })
level: QqbotPluginRuntimeEventLevel;
@Column({ name: 'safe_summary', nullable: true, type: 'simple-json' })
safeSummary: null | Record<string, unknown>;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}
export const QQBOT_PLUGIN_PLATFORM_ENTITIES = [
QqbotPlugin,
QqbotPluginVersion,
QqbotPluginInstallation,
QqbotPluginOperation,
QqbotPluginEventHandler,
QqbotPluginAccountBinding,
QqbotPluginConfig,
QqbotPluginAsset,
QqbotPluginRuntimeEvent,
] as const;

View File

@ -0,0 +1,135 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '@/admin/auth/jwt-auth.guard';
import { vbenSuccess } from '@/common';
import { QqbotPluginPlatformService } from './plugin-platform.service';
@ApiTags('QQBot - 插件平台')
@Controller('qqbot/plugin-platform')
@UseGuards(JwtAuthGuard)
export class QqbotPluginPlatformController {
constructor(private readonly service: QqbotPluginPlatformService) {}
@Get('installations')
@ApiOperation({ summary: '插件安装列表' })
async installations() {
return vbenSuccess(await this.service.listInstallations());
}
@Post('validate')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '校验插件 manifest' })
async validate(@Body() body: { manifest?: unknown }) {
return vbenSuccess(this.service.validateManifest(body));
}
@Post('upload')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '上传插件包' })
async upload(
@Body()
body: {
manifest?: unknown;
packageHash?: string;
packagePath?: string;
},
) {
return vbenSuccess(this.service.validateManifest(body));
}
@Post('install')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '安装插件包' })
async install(
@Body()
body: {
manifest?: unknown;
packageHash?: string;
packagePath?: string;
},
) {
return vbenSuccess(await this.service.installLocal(body));
}
@Post('install-local')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '本地安装插件包' })
async installLocal(
@Body()
body: {
manifest?: unknown;
packageHash?: string;
packagePath?: string;
},
) {
return vbenSuccess(await this.service.installLocal(body));
}
@Post('enable')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '启用插件' })
async enable(@Body() body: { id?: string }) {
return vbenSuccess(
await this.service.setInstallationStatus(body, 'enabled'),
);
}
@Post('disable')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '禁用插件' })
async disable(@Body() body: { id?: string }) {
return vbenSuccess(
await this.service.setInstallationStatus(body, 'disabled'),
);
}
@Post('upgrade')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '升级插件' })
async upgrade(@Body() body: { id?: string }) {
return vbenSuccess(
await this.service.setInstallationStatus(body, 'installed'),
);
}
@Post('uninstall')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '卸载插件' })
async uninstall(@Body() body: { id?: string }) {
return vbenSuccess(
await this.service.setInstallationStatus(body, 'uninstalled'),
);
}
@Post('config')
@HttpCode(HttpStatus.OK)
@ApiOperation({ summary: '更新插件配置' })
async config(
@Body() body: { configKey?: string; pluginId?: string; value?: unknown },
) {
return vbenSuccess(await this.service.updateConfig(body));
}
@Get('runtime-events')
@ApiOperation({ summary: '插件运行事件列表' })
@ApiQuery({ name: 'pluginId', required: false, type: String })
async runtimeEvents(@Query('pluginId') pluginId?: string) {
return vbenSuccess(await this.service.listRuntimeEvents(pluginId));
}
@Get('account-bindings')
@ApiOperation({ summary: '插件账号绑定列表' })
@ApiQuery({ name: 'pluginId', required: false, type: String })
async accountBindings(@Query('pluginId') pluginId?: string) {
return vbenSuccess(await this.service.listAccountBindings(pluginId));
}
}

View File

@ -0,0 +1,17 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module';
import { QqbotPluginPlatformController } from './plugin-platform.controller';
import { QqbotPluginPlatformService } from './plugin-platform.service';
import { QQBOT_PLUGIN_PLATFORM_ENTITIES } from './persistence';
@Module({
controllers: [QqbotPluginPlatformController],
exports: [QqbotPluginPlatformService],
imports: [
AdminAuthGuardModule,
TypeOrmModule.forFeature([...QQBOT_PLUGIN_PLATFORM_ENTITIES]),
],
providers: [QqbotPluginPlatformService],
})
export class QqbotPluginPlatformModule {}

View File

@ -0,0 +1,168 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { throwVbenError } from '@/common';
import { parseQqbotPluginManifest, type QqbotPluginManifest } from './manifest';
import {
QqbotPlugin,
QqbotPluginAccountBinding,
QqbotPluginAsset,
QqbotPluginConfig,
QqbotPluginEventHandler,
QqbotPluginInstallation,
QqbotPluginOperation,
QqbotPluginRuntimeEvent,
QqbotPluginVersion,
type QqbotPluginInstallStatus,
} from './persistence';
type ValidateManifestBody = {
manifest?: unknown;
};
type InstallLocalBody = {
manifest?: unknown;
packageHash?: string;
packagePath?: string;
};
type InstallationActionBody = {
id?: string;
};
type UpdateConfigBody = {
configKey?: string;
pluginId?: string;
value?: unknown;
};
@Injectable()
export class QqbotPluginPlatformService {
constructor(
@InjectRepository(QqbotPlugin)
private readonly pluginRepository: Repository<QqbotPlugin>,
@InjectRepository(QqbotPluginVersion)
private readonly versionRepository: Repository<QqbotPluginVersion>,
@InjectRepository(QqbotPluginInstallation)
private readonly installationRepository: Repository<QqbotPluginInstallation>,
@InjectRepository(QqbotPluginOperation)
private readonly operationRepository: Repository<QqbotPluginOperation>,
@InjectRepository(QqbotPluginEventHandler)
private readonly eventHandlerRepository: Repository<QqbotPluginEventHandler>,
@InjectRepository(QqbotPluginAccountBinding)
private readonly accountBindingRepository: Repository<QqbotPluginAccountBinding>,
@InjectRepository(QqbotPluginConfig)
private readonly configRepository: Repository<QqbotPluginConfig>,
@InjectRepository(QqbotPluginAsset)
private readonly assetRepository: Repository<QqbotPluginAsset>,
@InjectRepository(QqbotPluginRuntimeEvent)
private readonly runtimeEventRepository: Repository<QqbotPluginRuntimeEvent>,
) {}
async listInstallations() {
return this.installationRepository.find();
}
validateManifest(body: ValidateManifestBody) {
return {
manifest: parseQqbotPluginManifest(body.manifest),
valid: true,
};
}
async installLocal(body: InstallLocalBody) {
const manifest = parseQqbotPluginManifest(body.manifest);
const plugin = await this.pluginRepository.save({
pluginKey: manifest.pluginKey,
pluginName: manifest.name,
description: manifest.description || null,
status: 'installed',
});
const version = await this.versionRepository.save({
manifestJson: manifest,
packageHash: body.packageHash || 'local-dev-package',
pluginId: plugin.id,
version: manifest.version,
});
await this.persistManifestCapabilities(plugin.id, manifest);
return this.installationRepository.save({
installedPath: body.packagePath || '',
pluginId: plugin.id,
runtimeStatus: 'stopped',
status: 'installed',
versionId: version.id,
});
}
async setInstallationStatus(
body: InstallationActionBody,
status: QqbotPluginInstallStatus,
) {
if (!body.id) throwVbenError('请选择插件安装记录');
await this.installationRepository.update({ id: body.id }, { status });
return {
id: body.id,
status,
};
}
async updateConfig(body: UpdateConfigBody) {
if (!body.pluginId || !body.configKey) {
throwVbenError('请选择插件和配置项');
}
return this.configRepository.save({
configKey: body.configKey,
configValue: body.value === undefined ? null : { value: body.value },
pluginId: body.pluginId,
});
}
async listRuntimeEvents(pluginId?: string) {
return this.runtimeEventRepository.find({
where: pluginId ? { pluginId } : undefined,
});
}
async listAccountBindings(pluginId?: string) {
return this.accountBindingRepository.find({
where: pluginId ? { pluginId } : undefined,
});
}
private async persistManifestCapabilities(
pluginId: string,
manifest: QqbotPluginManifest,
) {
await Promise.all([
...manifest.operations.map((operation) =>
this.operationRepository.save({
enabled: true,
handlerName: operation.handlerName,
operationKey: operation.key,
operationName: operation.name,
pluginId,
}),
),
...manifest.events.map((event) =>
this.eventHandlerRepository.save({
enabled: true,
eventKey: event.key,
handlerName: event.handlerName,
pluginId,
}),
),
...manifest.assets.map((asset) =>
this.assetRepository.save({
assetKey: asset.key,
assetPath: asset.path,
contentHash: asset.contentHash || '',
pluginId,
}),
),
]);
}
}

View File

@ -0,0 +1,2 @@
export * from './worker-runtime';
export * from './worker-runtime.types';

View File

@ -0,0 +1,200 @@
import {
type QqbotPluginEventRequest,
type QqbotPluginOperationRequest,
type QqbotPluginRuntimeErrorCode,
type QqbotPluginRuntimeEvent,
type QqbotPluginRuntimeStatus,
type QqbotPluginSafeInputSummary,
type QqbotPluginWorkerDriver,
type QqbotPluginWorkerRequest,
type QqbotPluginWorkerRequestType,
type QqbotPluginWorkerRuntimeOptions,
} from './worker-runtime.types';
export class QqbotPluginRuntimeError extends Error {
constructor(
readonly code: QqbotPluginRuntimeErrorCode,
readonly pluginKey: string,
message: string,
readonly safeSummary: Record<string, unknown> = {},
) {
super(message);
this.name = 'QqbotPluginRuntimeError';
}
}
const createCorrelationId = () => {
return `qqbot-plugin-${Date.now()}-${Math.random().toString(16).slice(2)}`;
};
const summarizeInput = (
input: Record<string, unknown>,
): QqbotPluginSafeInputSummary => {
return {
fieldCount: Object.keys(input).length,
keys: Object.keys(input).sort(),
};
};
export class QqbotPluginWorkerRuntime {
private readonly runtimeEvents: QqbotPluginRuntimeEvent[] = [];
status: QqbotPluginRuntimeStatus = 'stopped';
constructor(
private readonly driver: QqbotPluginWorkerDriver,
private readonly options: QqbotPluginWorkerRuntimeOptions,
) {}
async load(manifest: unknown) {
const result = await this.request('load', {
manifest,
});
this.status = 'loaded';
return result;
}
async activate() {
const result = await this.request('activate');
this.status = 'active';
return result;
}
async executeOperation(request: QqbotPluginOperationRequest) {
return this.request(
'executeOperation',
{
input: request.input,
operationId: request.operationId,
operationKey: request.operationKey,
safeInputSummary: summarizeInput(request.input),
},
request.timeoutMs,
);
}
async handleEvent(request: QqbotPluginEventRequest) {
return this.request(
'handleEvent',
{
event: request.event,
eventKey: request.eventKey,
safeInputSummary: summarizeInput(request.event),
},
request.timeoutMs,
);
}
async health() {
return this.request('health');
}
async deactivate() {
const result = await this.request('deactivate');
this.status = 'stopped';
return result;
}
async dispose() {
try {
await this.request('dispose');
} finally {
await this.driver.dispose();
this.status = 'stopped';
}
}
listRuntimeEvents() {
return [...this.runtimeEvents];
}
private async request(
type: QqbotPluginWorkerRequestType,
payload: Partial<QqbotPluginWorkerRequest> = {},
timeoutMs = this.options.defaultTimeoutMs,
) {
const message: QqbotPluginWorkerRequest = {
correlationId: createCorrelationId(),
installationId: this.options.installationId,
pluginKey: this.options.pluginKey,
timeoutMs,
type,
...payload,
};
const requestPromise = Promise.resolve().then(() =>
this.driver.request(message),
);
requestPromise.catch(() => undefined);
try {
return await Promise.race([
requestPromise,
this.createTimeoutPromise(type, message, timeoutMs),
]);
} catch (error) {
if (error instanceof QqbotPluginRuntimeError) {
throw error;
}
this.status = 'failed';
const runtimeError = new QqbotPluginRuntimeError(
'PLUGIN_WORKER_CRASH',
this.options.pluginKey,
'QQBot plugin worker crashed.',
{
correlationId: message.correlationId,
message: error instanceof Error ? error.message : `${error}`,
operationId: message.operationId,
type,
},
);
this.recordRuntimeEvent('worker-crash', runtimeError.safeSummary);
throw runtimeError;
}
}
private createTimeoutPromise(
type: QqbotPluginWorkerRequestType,
message: QqbotPluginWorkerRequest,
timeoutMs: number,
) {
return new Promise<never>((_, reject) => {
const timer = setTimeout(async () => {
this.status = 'failed';
const error = new QqbotPluginRuntimeError(
'PLUGIN_WORKER_TIMEOUT',
this.options.pluginKey,
'QQBot plugin worker timed out.',
{
correlationId: message.correlationId,
operationId: message.operationId,
timeoutMs,
type,
},
);
this.recordRuntimeEvent('worker-timeout', error.safeSummary);
try {
await this.driver.dispose();
} catch {
// Timeout disposal is best-effort; the timeout error is the primary signal.
}
reject(error);
}, timeoutMs);
timer.unref?.();
});
}
private recordRuntimeEvent(
eventType: string,
safeSummary: Record<string, unknown>,
) {
this.runtimeEvents.push({
eventType,
level: 'error',
pluginKey: this.options.pluginKey,
safeSummary,
});
}
}

View File

@ -0,0 +1,69 @@
export type QqbotPluginWorkerRequestType =
| 'activate'
| 'deactivate'
| 'dispose'
| 'executeOperation'
| 'handleEvent'
| 'health'
| 'load';
export type QqbotPluginRuntimeStatus =
| 'active'
| 'failed'
| 'loaded'
| 'stopped';
export type QqbotPluginSafeInputSummary = {
fieldCount: number;
keys: string[];
};
export type QqbotPluginWorkerRequest = {
correlationId: string;
event?: unknown;
eventKey?: string;
installationId?: string;
manifest?: unknown;
operationId?: string;
operationKey?: string;
input?: unknown;
pluginKey: string;
safeInputSummary?: QqbotPluginSafeInputSummary;
timeoutMs: number;
type: QqbotPluginWorkerRequestType;
};
export type QqbotPluginWorkerDriver = {
dispose(): Promise<void>;
request(message: QqbotPluginWorkerRequest): Promise<unknown>;
};
export type QqbotPluginWorkerRuntimeOptions = {
defaultTimeoutMs: number;
installationId: string;
pluginKey: string;
};
export type QqbotPluginRuntimeEvent = {
eventType: string;
level: 'error' | 'info' | 'warn';
pluginKey: string;
safeSummary: Record<string, unknown>;
};
export type QqbotPluginRuntimeErrorCode =
| 'PLUGIN_WORKER_CRASH'
| 'PLUGIN_WORKER_TIMEOUT';
export type QqbotPluginOperationRequest = {
input: Record<string, unknown>;
operationId: string;
operationKey: string;
timeoutMs?: number;
};
export type QqbotPluginEventRequest = {
event: Record<string, unknown>;
eventKey: string;
timeoutMs?: number;
};

View File

@ -0,0 +1 @@
export * from './plugin-sdk';

View File

@ -0,0 +1,53 @@
export type QqbotPluginSendQueueSdk = {
sendMessage(input: Record<string, unknown>): Promise<unknown>;
};
export type QqbotPluginConfigSdk = {
getConfig(key: string): Promise<unknown>;
setConfig(key: string, value: unknown): Promise<void>;
};
export type QqbotPluginStorageSdk = {
get(key: string): Promise<unknown>;
set(key: string, value: unknown): Promise<void>;
};
export type QqbotPluginHttpSdk = {
request(input: Record<string, unknown>): Promise<unknown>;
};
export type QqbotPluginAssetSdk = {
readAsset(assetKey: string): Promise<Buffer>;
};
export type QqbotPluginRuntimeEventSdk = {
emitRuntimeEvent(input: Record<string, unknown>): Promise<void>;
};
export type QqbotPluginSdkFactoryInput = {
assets: QqbotPluginAssetSdk;
config: QqbotPluginConfigSdk;
eventContext: Record<string, unknown>;
events: QqbotPluginRuntimeEventSdk;
http: QqbotPluginHttpSdk;
operationContext: Record<string, unknown>;
sendQueue: QqbotPluginSendQueueSdk;
storage: QqbotPluginStorageSdk;
};
export type QqbotPluginSdk = Readonly<QqbotPluginSdkFactoryInput>;
export const createQqbotPluginSdk = (
input: QqbotPluginSdkFactoryInput,
): QqbotPluginSdk => {
return Object.freeze({
assets: input.assets,
config: input.config,
eventContext: Object.freeze({ ...input.eventContext }),
events: input.events,
http: input.http,
operationContext: Object.freeze({ ...input.operationContext }),
sendQueue: input.sendQueue,
storage: input.storage,
});
};

View File

@ -0,0 +1,124 @@
import * as fs from 'fs';
import * as path from 'path';
import { runQqbotPluginCli } from '../../../../scripts/qqbot-plugin/cli';
import { parseQqbotPluginManifest } from '../../../../src/modules/qqbot/plugin-platform/manifest';
const projectRoot = path.resolve(__dirname, '..', '..', '..', '..');
const workspaceTmpRoot = path.resolve(
projectRoot,
'..',
'..',
'.kt-workspace',
'tmp',
);
const readJson = (filePath: string) => {
return JSON.parse(fs.readFileSync(filePath, 'utf8')) as Record<string, any>;
};
const silentCliOptions = (cwd: string) => ({
cwd,
stderr: jest.fn(),
stdout: jest.fn(),
});
describe('QQBot plugin CLI', () => {
let sandbox: string;
beforeEach(() => {
sandbox = path.join(
workspaceTmpRoot,
`qqbot-plugin-cli-${process.pid}-${Date.now()}`,
);
fs.mkdirSync(sandbox, { recursive: true });
});
afterEach(() => {
fs.rmSync(sandbox, { force: true, recursive: true });
});
it('registers the package script used by local and CI workflows', () => {
const packageJson = readJson(path.join(projectRoot, 'package.json'));
expect(packageJson.scripts['qqbot-plugin']).toBe(
'ts-node -r tsconfig-paths/register scripts/qqbot-plugin/cli.ts',
);
});
it('creates, validates, packs, and locally installs a plugin through one validation chain', async () => {
const created = await runQqbotPluginCli(
['create', 'demo-plugin'],
silentCliOptions(sandbox),
);
const pluginRoot = path.join(sandbox, 'plugins', 'demo-plugin');
expect(created).toMatchObject({
command: 'create',
exitCode: 0,
pluginRoot,
});
expect(fs.existsSync(path.join(pluginRoot, 'plugin.json'))).toBe(true);
expect(fs.existsSync(path.join(pluginRoot, 'src', 'index.ts'))).toBe(true);
expect(fs.existsSync(path.join(pluginRoot, 'migrations'))).toBe(true);
expect(fs.existsSync(path.join(pluginRoot, 'assets'))).toBe(true);
expect(() =>
parseQqbotPluginManifest(readJson(path.join(pluginRoot, 'plugin.json')), {
pluginRoot,
}),
).not.toThrow();
await expect(
runQqbotPluginCli(['validate', pluginRoot], silentCliOptions(sandbox)),
).resolves.toMatchObject({
command: 'validate',
exitCode: 0,
pluginKey: 'demo-plugin',
});
const packed = await runQqbotPluginCli(
['pack', pluginRoot],
silentCliOptions(sandbox),
);
expect(packed).toMatchObject({
command: 'pack',
exitCode: 0,
pluginKey: 'demo-plugin',
});
expect(packed.packagePath).toMatch(
/demo-plugin-0\.1\.0-[a-f0-9]{12}\.qqbot-plugin\.json$/,
);
const packageJson = readJson(packed.packagePath || '');
expect(packageJson).toMatchObject({
contentHash: expect.stringMatching(/^[a-f0-9]{64}$/),
manifest: expect.objectContaining({
pluginKey: 'demo-plugin',
version: '0.1.0',
}),
});
expect(packageJson.files).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: 'plugin.json',
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
}),
expect.objectContaining({
path: 'src/index.ts',
sha256: expect.stringMatching(/^[a-f0-9]{64}$/),
}),
]),
);
await expect(
runQqbotPluginCli(
['install-local', packed.packagePath || ''],
silentCliOptions(sandbox),
),
).resolves.toMatchObject({
command: 'install-local',
exitCode: 0,
packageHash: packageJson.contentHash,
pluginKey: 'demo-plugin',
});
});
});

View File

@ -0,0 +1,208 @@
import {
QQBOT_PLUGIN_ALLOWED_PERMISSIONS,
parseQqbotPluginManifest,
QqbotPluginManifestValidationError,
} from '../../../../src/modules/qqbot/plugin-platform/manifest';
const createValidManifest = () => ({
assets: [
{
key: 'song-card-bg',
path: 'assets/song-card-bg.png',
},
],
author: 'KT',
configSchema: {
properties: {
apiBaseUrl: {
default: 'https://example.com',
type: 'string',
},
},
type: 'object',
},
description: 'BangDream query operations',
entry: 'src/index.ts',
events: [
{
eventName: 'message.group',
handlerName: 'onGroupMessage',
key: 'bangdream.group-message',
name: '群消息事件',
},
],
homepage: 'https://example.com/bangdream',
license: 'MIT',
migrations: [
{
path: 'migrations/001-init.sql',
version: '1.0.0',
},
],
minApiSdkVersion: '1.0.0',
name: 'BangDream',
operations: [
{
aliases: ['/查歌', '/bd song'],
description: 'Search songs',
handlerName: 'searchSong',
inputSchema: {
properties: {
keyword: {
type: 'string',
},
},
type: 'object',
},
key: 'bangdream.song.search',
name: '查歌',
outputSchema: {
type: 'object',
},
permissions: ['qqbot.send', 'runtime.http', 'asset.read'],
timeoutMs: 5000,
},
],
permissions: ['qqbot.send', 'runtime.http', 'asset.read'],
pluginKey: 'bangdream',
runtime: {
maxConcurrency: 2,
memoryMb: 256,
timeoutMs: 8000,
workerType: 'node-worker',
},
version: '1.2.3',
});
const expectValidationError = (
manifest: unknown,
code: string,
path?: string,
) => {
try {
parseQqbotPluginManifest(manifest, { pluginRoot: 'D:/plugins/bangdream' });
throw new Error('Expected manifest validation to fail');
} catch (error) {
expect(error).toBeInstanceOf(QqbotPluginManifestValidationError);
expect((error as QqbotPluginManifestValidationError).issues).toEqual(
expect.arrayContaining([
expect.objectContaining({
code,
...(path ? { path } : {}),
}),
]),
);
}
};
describe('QQBot plugin manifest contract', () => {
it('parses a complete manifest and normalizes declared plugin capabilities', () => {
const manifest = parseQqbotPluginManifest(createValidManifest(), {
pluginRoot: 'D:/plugins/bangdream',
});
expect(manifest).toMatchObject({
entry: 'src/index.ts',
minApiSdkVersion: '1.0.0',
pluginKey: 'bangdream',
runtime: {
maxConcurrency: 2,
memoryMb: 256,
timeoutMs: 8000,
workerType: 'node-worker',
},
version: '1.2.3',
});
expect(manifest.permissions).toEqual([
'qqbot.send',
'runtime.http',
'asset.read',
]);
expect(manifest.operations[0]).toMatchObject({
aliases: ['/查歌', '/bd song'],
key: 'bangdream.song.search',
permissions: ['qqbot.send', 'runtime.http', 'asset.read'],
timeoutMs: 5000,
});
expect(manifest.events[0]).toMatchObject({
eventName: 'message.group',
key: 'bangdream.group-message',
});
expect(manifest.assets[0].path).toBe('assets/song-card-bg.png');
expect(manifest.migrations[0].path).toBe('migrations/001-init.sql');
expect(QQBOT_PLUGIN_ALLOWED_PERMISSIONS).toEqual(
expect.arrayContaining([
'asset.read',
'plugin.config.read',
'plugin.config.write',
'plugin.storage.read',
'plugin.storage.write',
'qqbot.event.receive',
'qqbot.send',
'runtime.http',
]),
);
});
it('rejects unknown permissions from both plugin and operation scopes', () => {
const manifest = createValidManifest();
manifest.permissions = ['qqbot.send', 'host.env.read'];
manifest.operations[0].permissions = ['qqbot.send', 'host.fs.read'];
expectValidationError(manifest, 'UNKNOWN_PERMISSION', 'permissions[1]');
expectValidationError(
manifest,
'UNKNOWN_PERMISSION',
'operations[0].permissions[1]',
);
});
it('rejects duplicate operation keys and duplicate event keys', () => {
const manifest = createValidManifest();
manifest.operations.push({
...manifest.operations[0],
handlerName: 'searchSongAgain',
});
manifest.events.push({
...manifest.events[0],
handlerName: 'onGroupMessageAgain',
});
expectValidationError(manifest, 'DUPLICATE_OPERATION_KEY', 'operations[1]');
expectValidationError(manifest, 'DUPLICATE_EVENT_KEY', 'events[1]');
});
it('rejects manifests without explicit runtime budgets', () => {
const manifest = createValidManifest();
manifest.runtime = {
workerType: 'node-worker',
} as typeof manifest.runtime;
delete manifest.operations[0].timeoutMs;
expectValidationError(manifest, 'MISSING_RUNTIME_BUDGET', 'runtime');
expectValidationError(
manifest,
'MISSING_OPERATION_TIMEOUT',
'operations[0].timeoutMs',
);
});
it('rejects package paths outside the plugin root', () => {
const manifest = createValidManifest();
manifest.entry = '../outside.ts';
manifest.assets[0].path = 'assets/../../secret.txt';
manifest.migrations[0].path = 'C:/secrets/drop.sql';
expectValidationError(manifest, 'PATH_OUTSIDE_PLUGIN_ROOT', 'entry');
expectValidationError(
manifest,
'PATH_OUTSIDE_PLUGIN_ROOT',
'assets[0].path',
);
expectValidationError(
manifest,
'PATH_OUTSIDE_PLUGIN_ROOT',
'migrations[0].path',
);
});
});

View File

@ -0,0 +1,67 @@
import { getMetadataArgsStorage } from 'typeorm';
import {
QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT,
QQBOT_PLUGIN_PLATFORM_ENTITIES,
} from '../../../../src/modules/qqbot/plugin-platform/persistence';
import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper';
type EntityClass = new (...args: never[]) => unknown;
const getEntityTableName = (entity: EntityClass) => {
return getMetadataArgsStorage().tables.find(
(table) => table.target === entity,
)?.name;
};
const getEntityColumnNames = (entity: EntityClass) => {
return getMetadataArgsStorage()
.columns.filter((column) => column.target === entity)
.map((column) => `${column.options.name || column.propertyName}`);
};
describe('QQBot plugin platform persistence contract', () => {
const schema = readRefactorV3SqlSchema();
it('declares every plugin-platform table owned by Batch 5', () => {
expect(QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT.tables).toEqual([
'qqbot_plugin',
'qqbot_plugin_version',
'qqbot_plugin_installation',
'qqbot_plugin_operation',
'qqbot_plugin_event_handler',
'qqbot_plugin_account_binding',
'qqbot_plugin_config',
'qqbot_plugin_asset',
'qqbot_plugin_runtime_event',
]);
for (const table of QQBOT_PLUGIN_PLATFORM_DOMAIN_CONTRACT.tables) {
expect(schema.hasTable(table)).toBe(true);
}
});
it('maps plugin-platform entities to the v3 SQL schema', () => {
expect(QQBOT_PLUGIN_PLATFORM_ENTITIES.map((entity) => entity.name)).toEqual(
[
'QqbotPlugin',
'QqbotPluginVersion',
'QqbotPluginInstallation',
'QqbotPluginOperation',
'QqbotPluginEventHandler',
'QqbotPluginAccountBinding',
'QqbotPluginConfig',
'QqbotPluginAsset',
'QqbotPluginRuntimeEvent',
],
);
for (const entity of QQBOT_PLUGIN_PLATFORM_ENTITIES) {
const tableName = getEntityTableName(entity);
const columns = getEntityColumnNames(entity);
expect(tableName).toBeTruthy();
expect(schema.hasTable(tableName || '')).toBe(true);
schema.expectTableColumns(tableName || '', columns);
}
});
});

View File

@ -0,0 +1,156 @@
import { readFileSync } from 'fs';
import { join } from 'path';
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import * as request from 'supertest';
import { JwtAuthGuard } from '../../../../src/admin/auth/jwt-auth.guard';
import { QqbotPluginPlatformController } from '../../../../src/modules/qqbot/plugin-platform/plugin-platform.controller';
import { QqbotPluginPlatformModule } from '../../../../src/modules/qqbot/plugin-platform/plugin-platform.module';
import { QqbotPluginPlatformService } from '../../../../src/modules/qqbot/plugin-platform/plugin-platform.service';
import {
QQBOT_PLUGIN_PLATFORM_ENTITIES,
QqbotPlugin,
QqbotPluginAccountBinding,
QqbotPluginAsset,
QqbotPluginConfig,
QqbotPluginEventHandler,
QqbotPluginInstallation,
QqbotPluginOperation,
QqbotPluginRuntimeEvent,
QqbotPluginVersion,
} from '../../../../src/modules/qqbot/plugin-platform/persistence';
import {
collectControllerRoutes,
routeKey,
} from '../../../helpers/controller-route.helper';
const createRepositoryMock = () => ({
find: jest.fn(async () => []),
save: jest.fn(async (value) => value),
update: jest.fn(async () => ({ affected: 1 })),
});
const createManifest = () => ({
assets: [],
configSchema: {
type: 'object',
},
entry: 'src/index.ts',
events: [],
minApiSdkVersion: '1.0.0',
name: 'Demo Plugin',
operations: [
{
handlerName: 'echo',
key: 'demo-plugin.echo',
name: 'Echo',
permissions: ['qqbot.send'],
timeoutMs: 3000,
},
],
permissions: ['qqbot.send'],
pluginKey: 'demo-plugin',
runtime: {
maxConcurrency: 1,
memoryMb: 128,
timeoutMs: 5000,
workerType: 'node-worker',
},
version: '0.1.0',
});
describe('QQBot plugin platform API contract', () => {
let app: INestApplication;
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [QqbotPluginPlatformController],
providers: [
QqbotPluginPlatformService,
...[
QqbotPlugin,
QqbotPluginVersion,
QqbotPluginInstallation,
QqbotPluginOperation,
QqbotPluginEventHandler,
QqbotPluginAccountBinding,
QqbotPluginConfig,
QqbotPluginAsset,
QqbotPluginRuntimeEvent,
].map((entity) => ({
provide: getRepositoryToken(entity),
useFactory: createRepositoryMock,
})),
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterEach(async () => {
await app.close();
});
it('registers plugin-platform as a first-class AppModule import', () => {
const source = readFileSync(
join(__dirname, '../../../../src/app.module.ts'),
'utf8',
);
expect(source).toContain('QqbotPluginPlatformModule');
});
it('exposes plugin-platform management routes', () => {
expect(routeKey).toBeDefined();
expect(
collectControllerRoutes([QqbotPluginPlatformController]).map(routeKey),
).toEqual(
expect.arrayContaining([
'GET /qqbot/plugin-platform/installations',
'POST /qqbot/plugin-platform/upload',
'POST /qqbot/plugin-platform/validate',
'POST /qqbot/plugin-platform/install',
'POST /qqbot/plugin-platform/install-local',
'POST /qqbot/plugin-platform/enable',
'POST /qqbot/plugin-platform/disable',
'POST /qqbot/plugin-platform/upgrade',
'POST /qqbot/plugin-platform/uninstall',
'POST /qqbot/plugin-platform/config',
'GET /qqbot/plugin-platform/runtime-events',
'GET /qqbot/plugin-platform/account-bindings',
]),
);
});
it('validates manifests through the real HTTP wrapper', async () => {
const response = await request(app.getHttpServer())
.post('/qqbot/plugin-platform/validate')
.send({
manifest: createManifest(),
})
.expect(200);
expect(response.body).toMatchObject({
code: 200,
data: {
manifest: {
pluginKey: 'demo-plugin',
runtime: {
timeoutMs: 5000,
},
},
valid: true,
},
});
});
it('keeps TypeORM entity registration aligned with the persistence contract', () => {
expect(QqbotPluginPlatformModule).toBeDefined();
expect(QQBOT_PLUGIN_PLATFORM_ENTITIES).toHaveLength(9);
});
});

View File

@ -0,0 +1,226 @@
import {
QqbotPluginRuntimeError,
QqbotPluginWorkerRuntime,
type QqbotPluginWorkerDriver,
type QqbotPluginWorkerRequest,
} from '../../../../src/modules/qqbot/plugin-platform/runtime';
import { createQqbotPluginSdk } from '../../../../src/modules/qqbot/plugin-platform/sdk';
class RecordingDriver implements QqbotPluginWorkerDriver {
disposed = false;
readonly requests: QqbotPluginWorkerRequest[] = [];
readonly responses = new Map<string, unknown>();
constructor(private readonly rejectTypes = new Map<string, Error>()) {}
async dispose() {
this.disposed = true;
}
async request(message: QqbotPluginWorkerRequest): Promise<unknown> {
this.requests.push(message);
const rejection = this.rejectTypes.get(message.type);
if (rejection) throw rejection;
if (this.responses.has(message.type)) {
return this.responses.get(message.type);
}
return {
ok: true,
type: message.type,
};
}
}
const createRuntime = (driver = new RecordingDriver()) => {
const runtime = new QqbotPluginWorkerRuntime(driver, {
defaultTimeoutMs: 50,
installationId: 'install-1',
pluginKey: 'demo-plugin',
});
return { driver, runtime };
};
describe('QQBot plugin worker runtime', () => {
it('sends lifecycle and execution RPC messages with correlation IDs and safe input summaries', async () => {
const { driver, runtime } = createRuntime();
driver.responses.set('executeOperation', { replyText: 'ok' });
driver.responses.set('handleEvent', { handled: true });
await runtime.load({
entry: 'src/index.ts',
pluginKey: 'demo-plugin',
version: '0.1.0',
});
await runtime.activate();
await expect(
runtime.executeOperation({
input: {
accessToken: 'raw-secret',
text: 'hello',
},
operationId: 'op-1',
operationKey: 'demo-plugin.echo',
timeoutMs: 30,
}),
).resolves.toEqual({ replyText: 'ok' });
await expect(
runtime.handleEvent({
event: {
message: 'hello',
rawEvent: {
token: 'another-secret',
},
},
eventKey: 'demo-plugin.message',
}),
).resolves.toEqual({ handled: true });
await runtime.health();
await runtime.deactivate();
await runtime.dispose();
expect(driver.requests.map((request) => request.type)).toEqual([
'load',
'activate',
'executeOperation',
'handleEvent',
'health',
'deactivate',
'dispose',
]);
expect(driver.requests.every((request) => request.correlationId)).toBe(
true,
);
expect(driver.requests[2]).toMatchObject({
operationId: 'op-1',
operationKey: 'demo-plugin.echo',
safeInputSummary: {
fieldCount: 2,
keys: ['accessToken', 'text'],
},
timeoutMs: 30,
type: 'executeOperation',
});
expect(JSON.stringify(driver.requests[2].safeInputSummary)).not.toContain(
'raw-secret',
);
expect(JSON.stringify(driver.requests[3].safeInputSummary)).not.toContain(
'another-secret',
);
expect(driver.disposed).toBe(true);
});
it('isolates worker crashes as plugin runtime events without throwing raw errors', async () => {
const { runtime } = createRuntime(
new RecordingDriver(
new Map([['executeOperation', new Error('worker crashed')]]),
),
);
await expect(
runtime.executeOperation({
input: { text: 'boom' },
operationId: 'op-crash',
operationKey: 'demo-plugin.echo',
}),
).rejects.toMatchObject({
code: 'PLUGIN_WORKER_CRASH',
pluginKey: 'demo-plugin',
});
expect(runtime.status).toBe('failed');
expect(runtime.listRuntimeEvents()).toEqual([
expect.objectContaining({
eventType: 'worker-crash',
level: 'error',
safeSummary: expect.objectContaining({
message: 'worker crashed',
operationId: 'op-crash',
}),
}),
]);
});
it('times out slow worker calls and disposes the driver boundary', async () => {
const driver: QqbotPluginWorkerDriver = {
dispose: jest.fn(async () => undefined),
request: jest.fn(
() => new Promise((resolve) => setTimeout(resolve, 1000)),
),
};
const runtime = new QqbotPluginWorkerRuntime(driver, {
defaultTimeoutMs: 5,
installationId: 'install-timeout',
pluginKey: 'demo-plugin',
});
await expect(
runtime.executeOperation({
input: { text: 'slow' },
operationId: 'op-timeout',
operationKey: 'demo-plugin.echo',
}),
).rejects.toBeInstanceOf(QqbotPluginRuntimeError);
await expect(
runtime.executeOperation({
input: { text: 'slow' },
operationId: 'op-timeout-2',
operationKey: 'demo-plugin.echo',
}),
).rejects.toMatchObject({
code: 'PLUGIN_WORKER_TIMEOUT',
});
expect(driver.dispose).toHaveBeenCalled();
expect(runtime.status).toBe('failed');
});
});
describe('QQBot plugin SDK contract', () => {
it('exposes only host-controlled capabilities to plugin code', () => {
const sdk = createQqbotPluginSdk({
assets: {
readAsset: jest.fn(),
},
config: {
getConfig: jest.fn(),
setConfig: jest.fn(),
},
eventContext: {
eventKey: 'demo-plugin.message',
},
events: {
emitRuntimeEvent: jest.fn(),
},
http: {
request: jest.fn(),
},
operationContext: {
operationKey: 'demo-plugin.echo',
},
sendQueue: {
sendMessage: jest.fn(),
},
storage: {
get: jest.fn(),
set: jest.fn(),
},
});
expect(Object.keys(sdk).sort()).toEqual([
'assets',
'config',
'eventContext',
'events',
'http',
'operationContext',
'sendQueue',
'storage',
]);
expect('env' in sdk).toBe(false);
expect('fs' in sdk).toBe(false);
expect('nest' in sdk).toBe(false);
expect('repository' in sdk).toBe(false);
});
});