feat: 建立QQBot插件平台
This commit is contained in:
parent
7c32cda506
commit
7713d6c1ab
72
API.md
72
API.md
@ -52,20 +52,20 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
|||||||
|
|
||||||
## Runtime Health
|
## Runtime Health
|
||||||
|
|
||||||
| 方法 | 路径 | 认证 | 说明 |
|
| 方法 | 路径 | 认证 | 说明 |
|
||||||
| ----- | ----------------- | ---- | ---------------------------------- |
|
| ----- | ----------------- | ---- | ---------------------------- |
|
||||||
| `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 |
|
| `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 |
|
||||||
|
|
||||||
该接口返回 plain JSON,不使用 Vben 响应包装,供本地 smoke、Jenkins/K8s 和 ktWorkflow 观测脚本直接读取。接口位于 Swagger 基础能力分组 `/api/basic`。
|
该接口返回 plain JSON,不使用 Vben 响应包装,供本地 smoke、Jenkins/K8s 和 ktWorkflow 观测脚本直接读取。接口位于 Swagger 基础能力分组 `/api/basic`。
|
||||||
|
|
||||||
顶层字段:
|
顶层字段:
|
||||||
|
|
||||||
| 字段 | 说明 |
|
| 字段 | 说明 |
|
||||||
| ----------- | ------------------------------------------------ |
|
| ----------- | ---------------------------------------- |
|
||||||
| `service` | 固定为 `kt-template-online-api` |
|
| `service` | 固定为 `kt-template-online-api` |
|
||||||
| `checkedAt` | ISO 时间字符串 |
|
| `checkedAt` | ISO 时间字符串 |
|
||||||
| `status` | `live`、`ready`、`degraded` 或 `blocked` |
|
| `status` | `live`、`ready`、`degraded` 或 `blocked` |
|
||||||
| `checks` | 进程和配置检查列表 |
|
| `checks` | 进程和配置检查列表 |
|
||||||
|
|
||||||
公开响应不返回数据库、WordPress、Loki、NapCat SSH 等运行拓扑配置快照;配置检查只暴露 key 级别、是否存在和缺失说明。
|
公开响应不返回数据库、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` |
|
| 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` |
|
| 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` |
|
| 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 | `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` |
|
| 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_*` |
|
| 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_*` |
|
| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
|
||||||
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
|
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
|
||||||
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` |
|
| 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` |
|
| 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` |
|
| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
|
||||||
|
|
||||||
真实密码、Token、OAuth secret 和生产 env 不提交到 Git。
|
真实密码、Token、OAuth secret 和生产 env 不提交到 Git。
|
||||||
|
|
||||||
@ -407,6 +407,36 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
|||||||
| `GET` | `/qqbot/conversation/list` | 会话列表 |
|
| `GET` | `/qqbot/conversation/list` | 会话列表 |
|
||||||
| `GET` | `/qqbot/message/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
|
### OneBot Reverse WebSocket
|
||||||
|
|
||||||
`QQBOT_REVERSE_WS_PATH` 默认是 `/qqbot/onebot/reverse`。NapCat 通过反向 WS 连接 API,token 使用 `QQBOT_REVERSE_WS_TOKEN`。
|
`QQBOT_REVERSE_WS_PATH` 默认是 `/qqbot/onebot/reverse`。NapCat 通过反向 WS 连接 API,token 使用 `QQBOT_REVERSE_WS_TOKEN`。
|
||||||
|
|||||||
49
README.md
49
README.md
@ -16,17 +16,18 @@
|
|||||||
|
|
||||||
## 功能模块
|
## 功能模块
|
||||||
|
|
||||||
| 模块 | 说明 |
|
| 模块 | 说明 |
|
||||||
| -------------------------- | -------------------------------------------------------------------------------- |
|
| ------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
|
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
|
||||||
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
||||||
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
||||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志 |
|
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
||||||
| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、受控 SDK 和 CLI 脚手架 |
|
||||||
| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
||||||
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
|
| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
||||||
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
|
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
|
||||||
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
|
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
|
||||||
|
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
@ -36,6 +37,7 @@ src/
|
|||||||
blog/ 本地博客内容与主题配置
|
blog/ 本地博客内容与主题配置
|
||||||
common/ 全局装饰器、过滤器、拦截器、logger、工具和类型
|
common/ 全局装饰器、过滤器、拦截器、logger、工具和类型
|
||||||
minio/ MinIO 文件服务
|
minio/ MinIO 文件服务
|
||||||
|
modules/ 第三期重构后的业务边界模块
|
||||||
qqbot/ QQBot 运行态、管理接口和插件生态
|
qqbot/ QQBot 运行态、管理接口和插件生态
|
||||||
wordpress/ WordPress REST 代理
|
wordpress/ WordPress REST 代理
|
||||||
app.module.ts
|
app.module.ts
|
||||||
@ -53,17 +55,17 @@ ci/ Jenkins Agent/Docker 辅助文件
|
|||||||
|
|
||||||
主要配置分组:
|
主要配置分组:
|
||||||
|
|
||||||
| 分组 | 变量 |
|
| 分组 | 变量 |
|
||||||
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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_*` |
|
| 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_*` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` |
|
||||||
|
|
||||||
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。
|
`DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。
|
||||||
|
|
||||||
@ -85,6 +87,10 @@ pnpm run typecheck
|
|||||||
pnpm run lint
|
pnpm run lint
|
||||||
pnpm test
|
pnpm test
|
||||||
pnpm run build
|
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 下指定测试文件,使用:
|
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 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。
|
||||||
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
|
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
|
||||||
- QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
|
- 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`。
|
- 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 托管容器必须显式配置 `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` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
- NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
||||||
|
|||||||
@ -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\**`
|
- 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`
|
- 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.
|
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.
|
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:
|
Run:
|
||||||
|
|
||||||
@ -1062,6 +1062,8 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --run
|
|||||||
|
|
||||||
Expected: PASS.
|
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
|
### Task 5.2: Build plugin CLI
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
@ -1072,7 +1074,7 @@ Expected: PASS.
|
|||||||
- Modify: `D:\MyFiles\KT\Node\kt-template-online-api\package.json`
|
- 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`
|
- 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:
|
Add:
|
||||||
|
|
||||||
@ -1086,7 +1088,7 @@ Add:
|
|||||||
|
|
||||||
Keep existing scripts.
|
Keep existing scripts.
|
||||||
|
|
||||||
- [ ] **Step 2: Implement commands**
|
- [x] **Step 2: Implement commands**
|
||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
|
|
||||||
@ -1097,7 +1099,7 @@ pack <path>
|
|||||||
install-local <package>
|
install-local <package>
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Verify CLI**
|
- [x] **Step 3: Verify CLI**
|
||||||
|
|
||||||
Run:
|
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.
|
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
|
### Task 5.3: Build worker runtime and lifecycle
|
||||||
|
|
||||||
**Files:**
|
**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\**`
|
- 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`
|
- 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.
|
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.
|
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.
|
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:
|
Run:
|
||||||
|
|
||||||
@ -1140,6 +1144,8 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck
|
|||||||
|
|
||||||
Expected: PASS.
|
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
|
### Task 5.4: Sync Admin plugin platform page
|
||||||
|
|
||||||
**Files:**
|
**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\views\qqbot\plugin\list.tsx`
|
||||||
- Modify: `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\router\routes\modules\qqbot.ts`
|
- 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.
|
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.
|
Use tables, status tags, drawers/modals for upload/config/events, and Chinese operation labels.
|
||||||
|
|
||||||
- [ ] **Step 3: Verify**
|
- [x] **Step 3: Verify**
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|
||||||
@ -1166,6 +1172,8 @@ pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin -F @vben/web-antdv-next run typec
|
|||||||
|
|
||||||
Expected: PASS.
|
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
|
### Task 5.5: Commit Batch 5
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
|
|||||||
@ -22,6 +22,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\"",
|
||||||
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
|
"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:staged": "node scripts/husky-fast-check.mjs",
|
||||||
"verify:commit": "pnpm run lint && pnpm run typecheck",
|
"verify:commit": "pnpm run lint && pnpm run typecheck",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
|
|||||||
312
scripts/qqbot-plugin/cli.ts
Normal file
312
scripts/qqbot-plugin/cli.ts
Normal 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;
|
||||||
|
});
|
||||||
|
}
|
||||||
54
scripts/qqbot-plugin/templates/basic/plugin.json
Normal file
54
scripts/qqbot-plugin/templates/basic/plugin.json
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
11
scripts/qqbot-plugin/templates/basic/src/index.ts
Normal file
11
scripts/qqbot-plugin/templates/basic/src/index.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export async function echo(input: { text?: string }) {
|
||||||
|
return {
|
||||||
|
replyText: input.text || 'pong',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function onMessage() {
|
||||||
|
return {
|
||||||
|
handled: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -17,6 +17,7 @@ import { AdminModule } from './modules/admin/admin.module';
|
|||||||
import { AssetModule } from './modules/asset/asset.module';
|
import { AssetModule } from './modules/asset/asset.module';
|
||||||
import { BlogContentModule } from './modules/blog/blog-content.module';
|
import { BlogContentModule } from './modules/blog/blog-content.module';
|
||||||
import { QqbotCoreModule } from './modules/qqbot/core/qqbot-core.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 { WordpressMirrorModule } from './modules/wordpress/wordpress-mirror.module';
|
||||||
import { RuntimeModule } from './runtime';
|
import { RuntimeModule } from './runtime';
|
||||||
|
|
||||||
@ -70,6 +71,7 @@ import { RuntimeModule } from './runtime';
|
|||||||
WordpressMirrorModule,
|
WordpressMirrorModule,
|
||||||
AssetModule,
|
AssetModule,
|
||||||
QqbotCoreModule,
|
QqbotCoreModule,
|
||||||
|
QqbotPluginPlatformModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
AppService,
|
AppService,
|
||||||
|
|||||||
2
src/modules/qqbot/plugin-platform/manifest/index.ts
Normal file
2
src/modules/qqbot/plugin-platform/manifest/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './manifest.parser';
|
||||||
|
export * from './manifest.types';
|
||||||
395
src/modules/qqbot/plugin-platform/manifest/manifest.parser.ts
Normal file
395
src/modules/qqbot/plugin-platform/manifest/manifest.parser.ts
Normal 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;
|
||||||
|
};
|
||||||
88
src/modules/qqbot/plugin-platform/manifest/manifest.types.ts
Normal file
88
src/modules/qqbot/plugin-platform/manifest/manifest.types.ts
Normal 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;
|
||||||
|
};
|
||||||
2
src/modules/qqbot/plugin-platform/persistence/index.ts
Normal file
2
src/modules/qqbot/plugin-platform/persistence/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './plugin-platform.contract';
|
||||||
|
export * from './plugin-platform.entities';
|
||||||
@ -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;
|
||||||
@ -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;
|
||||||
135
src/modules/qqbot/plugin-platform/plugin-platform.controller.ts
Normal file
135
src/modules/qqbot/plugin-platform/plugin-platform.controller.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/modules/qqbot/plugin-platform/plugin-platform.module.ts
Normal file
17
src/modules/qqbot/plugin-platform/plugin-platform.module.ts
Normal 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 {}
|
||||||
168
src/modules/qqbot/plugin-platform/plugin-platform.service.ts
Normal file
168
src/modules/qqbot/plugin-platform/plugin-platform.service.ts
Normal 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,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/modules/qqbot/plugin-platform/runtime/index.ts
Normal file
2
src/modules/qqbot/plugin-platform/runtime/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export * from './worker-runtime';
|
||||||
|
export * from './worker-runtime.types';
|
||||||
200
src/modules/qqbot/plugin-platform/runtime/worker-runtime.ts
Normal file
200
src/modules/qqbot/plugin-platform/runtime/worker-runtime.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
};
|
||||||
1
src/modules/qqbot/plugin-platform/sdk/index.ts
Normal file
1
src/modules/qqbot/plugin-platform/sdk/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './plugin-sdk';
|
||||||
53
src/modules/qqbot/plugin-platform/sdk/plugin-sdk.ts
Normal file
53
src/modules/qqbot/plugin-platform/sdk/plugin-sdk.ts
Normal 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,
|
||||||
|
});
|
||||||
|
};
|
||||||
124
test/modules/qqbot/plugin-platform/cli.spec.ts
Normal file
124
test/modules/qqbot/plugin-platform/cli.spec.ts
Normal 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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
208
test/modules/qqbot/plugin-platform/manifest.spec.ts
Normal file
208
test/modules/qqbot/plugin-platform/manifest.spec.ts
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
226
test/modules/qqbot/plugin-platform/worker-runtime.spec.ts
Normal file
226
test/modules/qqbot/plugin-platform/worker-runtime.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user