diff --git a/API.md b/API.md index 49402bc..6d9a634 100644 --- a/API.md +++ b/API.md @@ -52,20 +52,20 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 ## Runtime Health -| 方法 | 路径 | 认证 | 说明 | -| ----- | ----------------- | ---- | ---------------------------------- | -| `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 | +| 方法 | 路径 | 认证 | 说明 | +| ----- | ----------------- | ---- | ---------------------------- | +| `GET` | `/health/runtime` | 否 | API 运行时健康和配置检查状态 | 该接口返回 plain JSON,不使用 Vben 响应包装,供本地 smoke、Jenkins/K8s 和 ktWorkflow 观测脚本直接读取。接口位于 Swagger 基础能力分组 `/api/basic`。 顶层字段: -| 字段 | 说明 | -| ----------- | ------------------------------------------------ | -| `service` | 固定为 `kt-template-online-api` | -| `checkedAt` | ISO 时间字符串 | -| `status` | `live`、`ready`、`degraded` 或 `blocked` | -| `checks` | 进程和配置检查列表 | +| 字段 | 说明 | +| ----------- | ---------------------------------------- | +| `service` | 固定为 `kt-template-online-api` | +| `checkedAt` | ISO 时间字符串 | +| `status` | `live`、`ready`、`degraded` 或 `blocked` | +| `checks` | 进程和配置检查列表 | 公开响应不返回数据库、WordPress、Loki、NapCat SSH 等运行拓扑配置快照;配置检查只暴露 key 级别、是否存在和缺失说明。 @@ -78,19 +78,19 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 ## 环境变量分组 -| 分组 | 关键变量 | -| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` | -| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` | -| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | -| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` | -| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` | +| 分组 | 关键变量 | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` | +| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` | +| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | +| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` | +| Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` | | QQBot | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` | -| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` | -| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` | -| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | -| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` | -| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | +| NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` | +| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` | +| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | +| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_DEFAULT_WORLD` | +| FFLogs | `FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | 真实密码、Token、OAuth secret 和生产 env 不提交到 Git。 @@ -407,6 +407,36 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT | `GET` | `/qqbot/conversation/list` | 会话列表 | | `GET` | `/qqbot/message/list` | 消息列表 | +### Plugin Platform + +插件平台使用统一 `plugin.json` manifest 描述插件 key、版本、入口、操作、事件、权限和运行预算;后端会校验路径必须留在插件包内,权限必须命中白名单,安装包 content hash 必须与 manifest 匹配。CLI 入口: + +```bash +pnpm qqbot-plugin create +pnpm qqbot-plugin validate +pnpm qqbot-plugin pack +pnpm qqbot-plugin install-local +``` + +平台管理接口: + +| 方法 | 路径 | 说明 | +| ------ | ----------------------------------------- | ---------------------------------- | +| `GET` | `/qqbot/plugin-platform/installations` | 插件安装记录,支持 key/status 过滤 | +| `POST` | `/qqbot/plugin-platform/upload` | 上传插件包并返回校验摘要 | +| `POST` | `/qqbot/plugin-platform/validate` | 校验 manifest JSON | +| `POST` | `/qqbot/plugin-platform/install` | 按上传包安装插件版本 | +| `POST` | `/qqbot/plugin-platform/install-local` | 按本地包路径安装插件版本 | +| `POST` | `/qqbot/plugin-platform/enable` | 启用插件安装 | +| `POST` | `/qqbot/plugin-platform/disable` | 禁用插件安装 | +| `POST` | `/qqbot/plugin-platform/upgrade` | 升级插件安装版本 | +| `POST` | `/qqbot/plugin-platform/uninstall` | 卸载插件安装 | +| `POST` | `/qqbot/plugin-platform/config` | 保存插件配置 | +| `GET` | `/qqbot/plugin-platform/runtime-events` | 查询插件运行事件 | +| `GET` | `/qqbot/plugin-platform/account-bindings` | 查询插件账号绑定 | + +`src/modules/qqbot/plugin-platform/runtime` 当前提供 host-side driver 边界和超时/崩溃事件归档;实际 worker/child-process driver 可以在后续批次接入,但插件侧只能通过受控 SDK 访问发送队列、配置、存储、HTTP、资产和事件上下文。 + ### OneBot Reverse WebSocket `QQBOT_REVERSE_WS_PATH` 默认是 `/qqbot/onebot/reverse`。NapCat 通过反向 WS 连接 API,token 使用 `QQBOT_REVERSE_WS_TOKEN`。 diff --git a/README.md b/README.md index cd15ff8..c953dec 100644 --- a/README.md +++ b/README.md @@ -16,17 +16,18 @@ ## 功能模块 -| 模块 | 说明 | -| -------------------------- | -------------------------------------------------------------------------------- | -| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 | -| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 | -| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 | -| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志 | -| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 | -| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 | -| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 | -| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 | -| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 | +| 模块 | 说明 | +| ------------------------------- | ------------------------------------------------------------------------------------------ | +| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 | +| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 | +| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 | +| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 | +| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、受控 SDK 和 CLI 脚手架 | +| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 | +| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 | +| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 | +| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 | +| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 | ## 目录结构 @@ -36,6 +37,7 @@ src/ blog/ 本地博客内容与主题配置 common/ 全局装饰器、过滤器、拦截器、logger、工具和类型 minio/ MinIO 文件服务 + modules/ 第三期重构后的业务边界模块 qqbot/ QQBot 运行态、管理接口和插件生态 wordpress/ WordPress REST 代理 app.module.ts @@ -53,17 +55,17 @@ ci/ Jenkins Agent/Docker 辅助文件 主要配置分组: -| 分组 | 变量 | -| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` | -| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` | -| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | -| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` | -| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` | +| 分组 | 变量 | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| MySQL | `DB_HOST`、`DB_PORT`、`DB_USERNAME`、`DB_PASSWORD`、`DB_DATABASE`、`DB_SYNC` | +| MinIO | `MINIO_ENDPOINT`、`MINIO_PORT`、`MINIO_ACCESS_KEY`、`MINIO_SECRET_KEY`、`MINIO_BUCKET` | +| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | +| WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` | +| Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` | | QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` | -| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | -| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` | -| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | +| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | +| FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` | +| FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | `DB_SYNC=true` 只适合本地开发或明确允许自动同步表结构的环境;生产应关闭并使用 SQL/迁移脚本。 @@ -85,6 +87,10 @@ pnpm run typecheck pnpm run lint pnpm test pnpm run build +pnpm qqbot-plugin create +pnpm qqbot-plugin validate +pnpm qqbot-plugin pack +pnpm qqbot-plugin install-local ``` Jest 只扫描 `test/**/*.spec.ts`。如果在 Windows 下指定测试文件,使用: @@ -146,6 +152,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。 - QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。 - QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。 +- QQBot 插件平台统一使用 `plugin.json` manifest 描述插件 key、版本、操作、事件、权限、运行预算和包入口;CLI 负责 create/validate/pack/install-local,后端只暴露受控 SDK 能力并通过插件维度记录安装、配置、账号绑定和运行事件。 - QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息,WebUI 配置或请求错误留在 `lastError`。 - NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。 - NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。 diff --git a/docs/superpowers/plans/2026-06-15-api-admin-full-refactor-v3.md b/docs/superpowers/plans/2026-06-15-api-admin-full-refactor-v3.md index ebd687c..24052fc 100644 --- a/docs/superpowers/plans/2026-06-15-api-admin-full-refactor-v3.md +++ b/docs/superpowers/plans/2026-06-15-api-admin-full-refactor-v3.md @@ -1044,15 +1044,15 @@ git -C D:\MyFiles\KT\Vue\kt-template-admin commit -m "refactor: 同步QQBot核 - Create: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugin-platform\persistence\**` - Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugin-platform\manifest.spec.ts` -- [ ] **Step 1: Write RED manifest validation tests** +- [x] **Step 1: Write RED manifest validation tests** Cover plugin key, semantic version, SDK version, operations, events, config schema, assets, migrations, permissions, and runtime limits. -- [ ] **Step 2: Implement manifest parser** +- [x] **Step 2: Implement manifest parser** Reject unknown permissions, duplicate operation keys, duplicate event keys, missing runtime budgets, and package paths outside plugin root. -- [ ] **Step 3: Verify** +- [x] **Step 3: Verify** Run: @@ -1062,6 +1062,8 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --run Expected: PASS. +Actual note: Manifest RED first failed with missing `src/modules/qqbot/plugin-platform/manifest`. The parser now normalizes complete manifests and rejects unknown permissions, duplicate operation/event keys, missing runtime budgets, missing operation timeout, and paths outside the plugin root. Batch 5 persistence RED first failed with missing `persistence`; TypeORM entities now map all 9 plugin-platform v3 tables and are checked against real `sql/refactor-v3/00-full-schema.sql`. + ### Task 5.2: Build plugin CLI **Files:** @@ -1072,7 +1074,7 @@ Expected: PASS. - Modify: `D:\MyFiles\KT\Node\kt-template-online-api\package.json` - Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugin-platform\cli.spec.ts` -- [ ] **Step 1: Add package script** +- [x] **Step 1: Add package script** Add: @@ -1086,7 +1088,7 @@ Add: Keep existing scripts. -- [ ] **Step 2: Implement commands** +- [x] **Step 2: Implement commands** Commands: @@ -1097,7 +1099,7 @@ pack install-local ``` -- [ ] **Step 3: Verify CLI** +- [x] **Step 3: Verify CLI** Run: @@ -1109,6 +1111,8 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api qqbot-plugin pack .\plugins Expected: commands exit `0`, package includes content hash. +Actual note: Added `pnpm qqbot-plugin` backed by `scripts/qqbot-plugin/cli.ts`, with `create`, `validate`, `pack`, and `install-local`. CLI tests caught a package hash mismatch caused by `undefined` fields in stable serialization; stable stringify now skips `undefined` so `pack` and `install-local` verify the same content hash. Real CLI smoke created `plugins/demo-plugin`, validated it, packed `demo-plugin-0.1.0-acc880b89e70.qqbot-plugin.json`, installed it locally, and then removed the temporary directory. + ### Task 5.3: Build worker runtime and lifecycle **Files:** @@ -1117,19 +1121,19 @@ Expected: commands exit `0`, package includes content hash. - Create: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugin-platform\sdk\**` - Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugin-platform\worker-runtime.spec.ts` -- [ ] **Step 1: Write RED RPC tests** +- [x] **Step 1: Write RED RPC tests** Cover `load`, `activate`, `executeOperation`, `handleEvent`, `health`, `deactivate`, `dispose`, timeout, and crash isolation. -- [ ] **Step 2: Implement host-side runtime** +- [x] **Step 2: Implement host-side runtime** Worker messages include correlation ID, timeout budget, operation ID, safe input summary, and structured output. -- [ ] **Step 3: Implement SDK** +- [x] **Step 3: Implement SDK** Expose only send queue, plugin config, plugin storage, runtime HTTP client, plugin asset loader, operation context, and event context. -- [ ] **Step 4: Verify** +- [x] **Step 4: Verify** Run: @@ -1140,6 +1144,8 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck Expected: PASS. +Actual note: Worker runtime RED first failed on missing `runtime` and `sdk`. `QqbotPluginWorkerRuntime` now sends lifecycle/execution RPC messages with correlation ID, timeout budget, operation ID/event key, and safe input summary without raw secret values. Worker crash and timeout become plugin-scoped runtime events and set the installation runtime status boundary to failed. `createQqbotPluginSdk` exposes only host-controlled send queue, config, storage, runtime HTTP, asset, operation context, event context, and runtime-event capabilities. + ### Task 5.4: Sync Admin plugin platform page **Files:** @@ -1148,15 +1154,15 @@ Expected: PASS. - Modify: `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\views\qqbot\plugin\list.tsx` - Modify: `D:\MyFiles\KT\Vue\kt-template-admin\apps\web-antdv-next\src\router\routes\modules\qqbot.ts` -- [ ] **Step 1: Add plugin caller** +- [x] **Step 1: Add plugin caller** Caller methods: upload, validate, install, enable, disable, upgrade, uninstall, update config, list runtime events, list account bindings. -- [ ] **Step 2: Build management page** +- [x] **Step 2: Build management page** Use tables, status tags, drawers/modals for upload/config/events, and Chinese operation labels. -- [ ] **Step 3: Verify** +- [x] **Step 3: Verify** Run: @@ -1166,6 +1172,8 @@ pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin -F @vben/web-antdv-next run typec Expected: PASS. +Actual note: Added `/qqbot/plugin-platform/*` API controller/service/module before wiring Admin so the caller methods target real routes. Admin now has `src/api/qqbot/plugin.ts` with upload, validate, install, install-local, enable, disable, upgrade, uninstall, config update, runtime events, and account bindings. The existing plugin page remains a single `Page` root, keeps the operation table, and adds Chinese platform actions plus modal/drawer flows for Manifest JSON, installation records, runtime events, and account bindings. Admin route title changed from "插件能力" to "插件平台". + ### Task 5.5: Commit Batch 5 Run: diff --git a/package.json b/package.json index 41ba4c4..7fa4d17 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "typecheck": "tsc --noEmit", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\"", "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "qqbot-plugin": "ts-node -r tsconfig-paths/register scripts/qqbot-plugin/cli.ts", "verify:staged": "node scripts/husky-fast-check.mjs", "verify:commit": "pnpm run lint && pnpm run typecheck", "test": "jest", diff --git a/scripts/qqbot-plugin/cli.ts b/scripts/qqbot-plugin/cli.ts new file mode 100644 index 0000000..3a89487 --- /dev/null +++ b/scripts/qqbot-plugin/cli.ts @@ -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; + 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, +) => { + 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, +) => { + 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, +): QqbotPluginCliResult => { + if (!pluginKey) throw new Error('Usage: qqbot-plugin create '); + + 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, +): QqbotPluginCliResult => { + if (!pluginRootArg) throw new Error('Usage: qqbot-plugin validate '); + + 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, +): QqbotPluginCliResult => { + if (!pluginRootArg) throw new Error('Usage: qqbot-plugin pack '); + + 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, +): QqbotPluginCliResult => { + if (!packagePathArg) { + throw new Error('Usage: qqbot-plugin install-local '); + } + + 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 => { + const command = argv[0] as QqbotPluginCliCommand | undefined; + const resolvedOptions: Required = { + 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 [args]', + ); + } +}; + +if (require.main === module) { + runQqbotPluginCli(process.argv.slice(2)).catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/scripts/qqbot-plugin/templates/basic/plugin.json b/scripts/qqbot-plugin/templates/basic/plugin.json new file mode 100644 index 0000000..20a9f6b --- /dev/null +++ b/scripts/qqbot-plugin/templates/basic/plugin.json @@ -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 + } +} diff --git a/scripts/qqbot-plugin/templates/basic/src/index.ts b/scripts/qqbot-plugin/templates/basic/src/index.ts new file mode 100644 index 0000000..2874244 --- /dev/null +++ b/scripts/qqbot-plugin/templates/basic/src/index.ts @@ -0,0 +1,11 @@ +export async function echo(input: { text?: string }) { + return { + replyText: input.text || 'pong', + }; +} + +export async function onMessage() { + return { + handled: false, + }; +} diff --git a/src/app.module.ts b/src/app.module.ts index f66feb5..9136b79 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -17,6 +17,7 @@ import { AdminModule } from './modules/admin/admin.module'; import { AssetModule } from './modules/asset/asset.module'; import { BlogContentModule } from './modules/blog/blog-content.module'; import { QqbotCoreModule } from './modules/qqbot/core/qqbot-core.module'; +import { QqbotPluginPlatformModule } from './modules/qqbot/plugin-platform/plugin-platform.module'; import { WordpressMirrorModule } from './modules/wordpress/wordpress-mirror.module'; import { RuntimeModule } from './runtime'; @@ -70,6 +71,7 @@ import { RuntimeModule } from './runtime'; WordpressMirrorModule, AssetModule, QqbotCoreModule, + QqbotPluginPlatformModule, ], providers: [ AppService, diff --git a/src/modules/qqbot/plugin-platform/manifest/index.ts b/src/modules/qqbot/plugin-platform/manifest/index.ts new file mode 100644 index 0000000..9b4d011 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/manifest/index.ts @@ -0,0 +1,2 @@ +export * from './manifest.parser'; +export * from './manifest.types'; diff --git a/src/modules/qqbot/plugin-platform/manifest/manifest.parser.ts b/src/modules/qqbot/plugin-platform/manifest/manifest.parser.ts new file mode 100644 index 0000000..4a687f3 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/manifest/manifest.parser.ts @@ -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(QQBOT_PLUGIN_ALLOWED_PERMISSIONS); +const allowedWorkerTypeSet = new Set(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 => { + return !!value && typeof value === 'object' && !Array.isArray(value); +}; + +const getString = ( + source: Record, + key: string, +): string | undefined => { + const value = source[key]; + return typeof value === 'string' && value.trim() ? value.trim() : undefined; +}; + +const getStringArray = ( + source: Record, + 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, + 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, + 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, + issues: QqbotPluginManifestValidationIssue[], +): QqbotPluginOperationManifest[] => { + const operations = Array.isArray(source.operations) ? source.operations : []; + const seenKeys = new Set(); + + 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, + issues: QqbotPluginManifestValidationIssue[], +): QqbotPluginEventManifest[] => { + const events = Array.isArray(source.events) ? source.events : []; + const seenKeys = new Set(); + + 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, + 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, + 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; +}; diff --git a/src/modules/qqbot/plugin-platform/manifest/manifest.types.ts b/src/modules/qqbot/plugin-platform/manifest/manifest.types.ts new file mode 100644 index 0000000..7345413 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/manifest/manifest.types.ts @@ -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; + key: string; + name: string; + outputSchema?: Record; + 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; + 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; +}; diff --git a/src/modules/qqbot/plugin-platform/persistence/index.ts b/src/modules/qqbot/plugin-platform/persistence/index.ts new file mode 100644 index 0000000..d317842 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/persistence/index.ts @@ -0,0 +1,2 @@ +export * from './plugin-platform.contract'; +export * from './plugin-platform.entities'; diff --git a/src/modules/qqbot/plugin-platform/persistence/plugin-platform.contract.ts b/src/modules/qqbot/plugin-platform/persistence/plugin-platform.contract.ts new file mode 100644 index 0000000..8700586 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/persistence/plugin-platform.contract.ts @@ -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; diff --git a/src/modules/qqbot/plugin-platform/persistence/plugin-platform.entities.ts b/src/modules/qqbot/plugin-platform/persistence/plugin-platform.entities.ts new file mode 100644 index 0000000..0da4616 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/persistence/plugin-platform.entities.ts @@ -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; + + @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; + + @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; + + @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; diff --git a/src/modules/qqbot/plugin-platform/plugin-platform.controller.ts b/src/modules/qqbot/plugin-platform/plugin-platform.controller.ts new file mode 100644 index 0000000..72ee5e7 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/plugin-platform.controller.ts @@ -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)); + } +} diff --git a/src/modules/qqbot/plugin-platform/plugin-platform.module.ts b/src/modules/qqbot/plugin-platform/plugin-platform.module.ts new file mode 100644 index 0000000..19f0635 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/plugin-platform.module.ts @@ -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 {} diff --git a/src/modules/qqbot/plugin-platform/plugin-platform.service.ts b/src/modules/qqbot/plugin-platform/plugin-platform.service.ts new file mode 100644 index 0000000..322e4e2 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/plugin-platform.service.ts @@ -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, + @InjectRepository(QqbotPluginVersion) + private readonly versionRepository: Repository, + @InjectRepository(QqbotPluginInstallation) + private readonly installationRepository: Repository, + @InjectRepository(QqbotPluginOperation) + private readonly operationRepository: Repository, + @InjectRepository(QqbotPluginEventHandler) + private readonly eventHandlerRepository: Repository, + @InjectRepository(QqbotPluginAccountBinding) + private readonly accountBindingRepository: Repository, + @InjectRepository(QqbotPluginConfig) + private readonly configRepository: Repository, + @InjectRepository(QqbotPluginAsset) + private readonly assetRepository: Repository, + @InjectRepository(QqbotPluginRuntimeEvent) + private readonly runtimeEventRepository: Repository, + ) {} + + 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, + }), + ), + ]); + } +} diff --git a/src/modules/qqbot/plugin-platform/runtime/index.ts b/src/modules/qqbot/plugin-platform/runtime/index.ts new file mode 100644 index 0000000..04ee5eb --- /dev/null +++ b/src/modules/qqbot/plugin-platform/runtime/index.ts @@ -0,0 +1,2 @@ +export * from './worker-runtime'; +export * from './worker-runtime.types'; diff --git a/src/modules/qqbot/plugin-platform/runtime/worker-runtime.ts b/src/modules/qqbot/plugin-platform/runtime/worker-runtime.ts new file mode 100644 index 0000000..07a2baa --- /dev/null +++ b/src/modules/qqbot/plugin-platform/runtime/worker-runtime.ts @@ -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 = {}, + ) { + super(message); + this.name = 'QqbotPluginRuntimeError'; + } +} + +const createCorrelationId = () => { + return `qqbot-plugin-${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; + +const summarizeInput = ( + input: Record, +): 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 = {}, + 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((_, 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, + ) { + this.runtimeEvents.push({ + eventType, + level: 'error', + pluginKey: this.options.pluginKey, + safeSummary, + }); + } +} diff --git a/src/modules/qqbot/plugin-platform/runtime/worker-runtime.types.ts b/src/modules/qqbot/plugin-platform/runtime/worker-runtime.types.ts new file mode 100644 index 0000000..aed03a5 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/runtime/worker-runtime.types.ts @@ -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; + request(message: QqbotPluginWorkerRequest): Promise; +}; + +export type QqbotPluginWorkerRuntimeOptions = { + defaultTimeoutMs: number; + installationId: string; + pluginKey: string; +}; + +export type QqbotPluginRuntimeEvent = { + eventType: string; + level: 'error' | 'info' | 'warn'; + pluginKey: string; + safeSummary: Record; +}; + +export type QqbotPluginRuntimeErrorCode = + | 'PLUGIN_WORKER_CRASH' + | 'PLUGIN_WORKER_TIMEOUT'; + +export type QqbotPluginOperationRequest = { + input: Record; + operationId: string; + operationKey: string; + timeoutMs?: number; +}; + +export type QqbotPluginEventRequest = { + event: Record; + eventKey: string; + timeoutMs?: number; +}; diff --git a/src/modules/qqbot/plugin-platform/sdk/index.ts b/src/modules/qqbot/plugin-platform/sdk/index.ts new file mode 100644 index 0000000..2d43e69 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/sdk/index.ts @@ -0,0 +1 @@ +export * from './plugin-sdk'; diff --git a/src/modules/qqbot/plugin-platform/sdk/plugin-sdk.ts b/src/modules/qqbot/plugin-platform/sdk/plugin-sdk.ts new file mode 100644 index 0000000..93b3915 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/sdk/plugin-sdk.ts @@ -0,0 +1,53 @@ +export type QqbotPluginSendQueueSdk = { + sendMessage(input: Record): Promise; +}; + +export type QqbotPluginConfigSdk = { + getConfig(key: string): Promise; + setConfig(key: string, value: unknown): Promise; +}; + +export type QqbotPluginStorageSdk = { + get(key: string): Promise; + set(key: string, value: unknown): Promise; +}; + +export type QqbotPluginHttpSdk = { + request(input: Record): Promise; +}; + +export type QqbotPluginAssetSdk = { + readAsset(assetKey: string): Promise; +}; + +export type QqbotPluginRuntimeEventSdk = { + emitRuntimeEvent(input: Record): Promise; +}; + +export type QqbotPluginSdkFactoryInput = { + assets: QqbotPluginAssetSdk; + config: QqbotPluginConfigSdk; + eventContext: Record; + events: QqbotPluginRuntimeEventSdk; + http: QqbotPluginHttpSdk; + operationContext: Record; + sendQueue: QqbotPluginSendQueueSdk; + storage: QqbotPluginStorageSdk; +}; + +export type QqbotPluginSdk = Readonly; + +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, + }); +}; diff --git a/test/modules/qqbot/plugin-platform/cli.spec.ts b/test/modules/qqbot/plugin-platform/cli.spec.ts new file mode 100644 index 0000000..7625326 --- /dev/null +++ b/test/modules/qqbot/plugin-platform/cli.spec.ts @@ -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; +}; + +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', + }); + }); +}); diff --git a/test/modules/qqbot/plugin-platform/manifest.spec.ts b/test/modules/qqbot/plugin-platform/manifest.spec.ts new file mode 100644 index 0000000..99c60f7 --- /dev/null +++ b/test/modules/qqbot/plugin-platform/manifest.spec.ts @@ -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', + ); + }); +}); diff --git a/test/modules/qqbot/plugin-platform/persistence-contract.spec.ts b/test/modules/qqbot/plugin-platform/persistence-contract.spec.ts new file mode 100644 index 0000000..38a89b4 --- /dev/null +++ b/test/modules/qqbot/plugin-platform/persistence-contract.spec.ts @@ -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); + } + }); +}); diff --git a/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts b/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts new file mode 100644 index 0000000..f4ca9e2 --- /dev/null +++ b/test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts @@ -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); + }); +}); diff --git a/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts b/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts new file mode 100644 index 0000000..d801b99 --- /dev/null +++ b/test/modules/qqbot/plugin-platform/worker-runtime.spec.ts @@ -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(); + + constructor(private readonly rejectTypes = new Map()) {} + + async dispose() { + this.disposed = true; + } + + async request(message: QqbotPluginWorkerRequest): Promise { + 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); + }); +});