refactor: 收敛QQBot插件包第三期结构

This commit is contained in:
sunlei 2026-06-15 18:55:47 +08:00
parent bb69427921
commit 9b14acf1bc
396 changed files with 4467 additions and 3669 deletions

10
API.md
View File

@ -449,7 +449,7 @@ pnpm qqbot-plugin install-local <packageFile>
### BangDream ### BangDream
插件 key`bangdream`。旧 `bangDream` 作为兼容别名仍可解析;当前源码根目录为 `src/modules/qqbot/plugins/bangDream`,不再使用旧 `tsugu`目录。 插件 key`bangdream`。旧 `bangDream` 作为兼容别名仍可解析;当前源码根目录为 `src/modules/qqbot/plugins/bangdream/src`,按第三期插件结构拆分为 `operations`、`domain/*`、`application`、`infrastructure/integration`、`infrastructure/storage`、`config`、`assets` 和 `theme`,不再使用旧 `tsugu` 子目录、宿主 builtins 包装层或纯转接目录。
| operation key | 命令 | 说明 | | operation key | 命令 | 说明 |
| ----------------------------- | ------------- | ------------------------ | | ----------------------------- | ------------- | ------------------------ |
@ -469,11 +469,11 @@ pnpm qqbot-plugin install-local <packageFile>
| `bangdream.cutoff.all` | `/ycxall` | 全档位预测线 | | `bangdream.cutoff.all` | `/ycxall` | 全档位预测线 |
| `bangdream.cutoff.recent` | `/lsycx` | 历史/近期档线 | | `bangdream.cutoff.recent` | `/lsycx` | 历史/近期档线 |
`registry/operation-registry.ts` 是 BangDream operation、handlerName、别名、冷却和说明的单一来源。新增或调整命令必须同步在线命令 SQL并跑 registry/command-SQL 测试。 `plugins/bangdream/plugin.json` 是 BangDream operation、handlerName、别名、权限、超时和说明的单一来源。新增或调整命令必须同步在线命令 SQL并跑 manifest/command-SQL 测试。
### FF14 Market ### FF14 Market
插件 key`ff14-market`。旧 `ff14Market` 作为兼容别名仍可解析。 插件 key`ff14-market`。旧 `ff14Market` 作为兼容别名仍可解析;源码按第三期插件结构拆分为 `operations`、`application`、`domain`、`infrastructure/integration` 和 `config`
| operation key | 说明 | | operation key | 说明 |
| ------------------- | ------------------------------------------ | | ------------------- | ------------------------------------------ |
@ -486,6 +486,8 @@ pnpm qqbot-plugin install-local <packageFile>
插件 key`fflogs`。 插件 key`fflogs`。
源码按第三期插件结构拆分为 `operations`、`application`、`domain`、`infrastructure/integration`、`infrastructure/storage` 和 `config`
| operation key | 说明 | | operation key | 说明 |
| -------------------------- | --------------------------------------------------------------- | | -------------------------- | --------------------------------------------------------------- |
| `fflogs.character.summary` | 查询 FFLogs 角色公开排名;传 `encounter` 时查询指定高难最近记录 | | `fflogs.character.summary` | 查询 FFLogs 角色公开排名;传 `encounter` 时查询指定高难最近记录 |
@ -527,7 +529,7 @@ BangDream 图片 smoke
```powershell ```powershell
.\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.song.search -Text "夏祭り" -OutFile ".kt-workspace/bangdream-smoke/song.jpg" .\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.song.search -Text "夏祭り" -OutFile ".kt-workspace/bangdream-smoke/song.jpg"
.\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.event.stage -Text "310" -OutFile ".kt-workspace/bangdream-smoke/stage.jpg" .\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.event.stage -Text "310" -OutFile ".kt-workspace/bangdream-smoke/stage.jpg" -ExpectedImageCount 5
``` ```
Jenkins/K8s 发布后还需要观察 rollout、新 Pod 日志,并跑真实运行态 smoke推送成功不等于发布完成。 Jenkins/K8s 发布后还需要观察 rollout、新 Pod 日志,并跑真实运行态 smoke推送成功不等于发布完成。

View File

@ -23,8 +23,8 @@
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 | | `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 | | `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、受控 SDK 和 CLI 脚手架 | | `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、受控 SDK 和 CLI 脚手架 |
| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 | | `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 | | `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 | | `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 | | `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
| `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 | | `common` | 响应封装、异常过滤、请求日志、日期格式化、字典解码、Snowflake、工具服务 |
@ -160,8 +160,8 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
- NapCat 设备身份按账号持久化到 `napcat_device_identity`同一账号重建容器会复用数据目录、hostname、machine-id 和 MAC并在 Docker run 中注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,避免频繁重建被 QQ 判定为全新设备。 - NapCat 设备身份按账号持久化到 `napcat_device_identity`同一账号重建容器会复用数据目录、hostname、machine-id 和 MAC并在 Docker run 中注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,避免频繁重建被 QQ 判定为全新设备。
- NapCat 新设备验证走同一 scan session`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。 - NapCat 新设备验证走同一 scan session`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。
- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000``QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。 - NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000``QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。
- BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录 - BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangdream/src`;按第三期插件结构放置真实职责代码:业务在 `domain/*`,编排在 `application`,操作在 `operations`,外部 API 在 `infrastructure/integration`,缓存/静态修正在 `infrastructure/storage`,字典和静态配置在 `config`,视觉渲染公共件在 `theme`;不要恢复旧 `tsugu` 层级、旧大桶目录、纯 re-export 转接文件或空 `.gitkeep` 目录壳
- BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。 - BangDream 在线命令以 `plugins/bangdream/plugin.json` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 manifest/command-SQL 测试。
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。 - BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。
## 轻量验证 ## 轻量验证
@ -184,6 +184,7 @@ BangDream 图片能力改动:
```powershell ```powershell
.\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.song.search -Text "夏祭り" -OutFile ".kt-workspace/bangdream-smoke/song.jpg" .\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.song.search -Text "夏祭り" -OutFile ".kt-workspace/bangdream-smoke/song.jpg"
.\scripts\bangdream-render-smoke.ps1 -OperationKey bangdream.event.stage -Text "310" -OutFile ".kt-workspace/bangdream-smoke/stage.jpg" -ExpectedImageCount 5
``` ```
接口改动必须启动或复用本地服务,并真实调用一次对应接口。 接口改动必须启动或复用本地服务,并真实调用一次对应接口。
@ -196,4 +197,4 @@ BangDream 图片能力改动:
| 一级来源 | 使用方式 | License | | 一级来源 | 使用方式 | License |
| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------- | | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------ | ------- |
| [Tsugu BangDream Bot](https://github.com/Yamamoto-2/tsugu-bangdream-bot) | BangDream QQBot 后端能力已重构合入 `src/modules/qqbot/plugins/bangDream`,保留本地 `TSUGU-LICENSE` | MIT | | [Tsugu BangDream Bot](https://github.com/Yamamoto-2/tsugu-bangdream-bot) | BangDream QQBot 后端能力已重构合入 `src/modules/qqbot/plugins/bangdream/src`,保留本地 `TSUGU-LICENSE` | MIT |

View File

@ -849,14 +849,14 @@ Run:
```powershell ```powershell
$api = 'D:\MyFiles\KT\Node\kt-template-online-api' $api = 'D:\MyFiles\KT\Node\kt-template-online-api'
$candidateFiles = @( $candidateFiles = @(
'src/modules/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts', 'src/modules/qqbot/plugins/ff14-market/qqbot-ff14-client.service.ts',
'src/modules/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts', 'src/modules/qqbot/plugins/ff14-market/qqbot-ff14-worlds.ts',
'src/modules/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts', 'src/modules/qqbot/plugins/ff14-market/qqbot-ff14-market.plugin.ts',
'src/modules/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts', 'src/modules/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts',
'src/modules/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts', 'src/modules/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts',
'src/modules/qqbot/plugins/repeater/qqbot-repeater.plugin.ts', 'src/modules/qqbot/plugins/repeater/qqbot-repeater.plugin.ts',
'src/modules/qqbot/plugins/bangDream/application/bangdream-renderer.facade.ts', 'src/modules/qqbot/plugins/bangdream/src/application/bangdream-renderer.facade.ts',
'src/modules/qqbot/plugins/bangDream/qqbot-bangdream.plugin.ts' 'src/modules/qqbot/plugins/bangdream/src/qqbot-bangdream.plugin.ts'
) )
foreach ($file in $candidateFiles) { foreach ($file in $candidateFiles) {
$absolute = Join-Path $api $file $absolute = Join-Path $api $file
@ -878,7 +878,7 @@ Record each deletion or keep decision in the inventory table.
Run: Run:
```powershell ```powershell
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/modules/qqbot/plugin-platform/manifest.spec.ts test/modules/qqbot/plugin-platform/cli.spec.ts test/modules/qqbot/plugin-platform/persistence-contract.spec.ts test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts test/modules/qqbot/plugin-platform/worker-runtime.spec.ts test/modules/qqbot/plugins/plugin-registry-compat.spec.ts test/modules/qqbot/plugins/plugin-platform-migration.spec.ts test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts test/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts test/qqbot/plugins/repeater/qqbot-repeater.plugin.spec.ts test/qqbot/plugins/bangDream/registry/operation-registry.spec.ts test/qqbot/plugins/bangDream/registry/command-sql.spec.ts pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/modules/qqbot/plugin-platform/manifest.spec.ts test/modules/qqbot/plugin-platform/cli.spec.ts test/modules/qqbot/plugin-platform/persistence-contract.spec.ts test/modules/qqbot/plugin-platform/plugin-platform-api-contract.spec.ts test/modules/qqbot/plugin-platform/worker-runtime.spec.ts test/modules/qqbot/plugins/plugin-registry-compat.spec.ts test/modules/qqbot/plugins/plugin-platform-migration.spec.ts test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts test/qqbot/plugins/ff14-market/qqbot-ff14-worlds.spec.ts test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts test/qqbot/plugins/repeater/qqbot-repeater.plugin.spec.ts test/qqbot/plugins/bangdream/manifest/operation-manifest.spec.ts test/qqbot/plugins/bangdream/manifest/command-sql.spec.ts
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck
``` ```

View File

@ -1191,13 +1191,13 @@ git -C D:\MyFiles\KT\Vue\kt-template-admin commit -m "feat: 增加QQBot插件管
**Files:** **Files:**
- Create: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugins\bangDream\plugin.json` - Create: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugins\bangdream\plugin.json`
- Create/modify: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugins\bangDream\**` - Create/modify: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugins\bangdream\**`
- Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugins\bangDream\**` - Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugins\bangdream\**`
- [x] **Step 1: Preserve business directories** - [x] **Step 1: Preserve business directories**
Keep business capabilities in `song`, `card`, `character`, `event`, `gacha`, `player`, `cutoff`, `catalog`, with cross-cutting code only in `application`, `registry`, `hook`, `provider`, `policy`, `theme`, `config`, `dictionary`, `search`, `shared`. Keep business capabilities in `domain/song`, `domain/card`, `domain/character`, `domain/event`, `domain/gacha`, `domain/player`, `domain/cutoff`, and `domain/catalog`; keep orchestration in `application`, operations in `operations`, external APIs in `infrastructure/integration`, cache/static patch adapters in `infrastructure/storage`, dictionary/static config in `config`, and visual helpers in `theme`.
- [x] **Step 2: Convert operation registry to manifest metadata** - [x] **Step 2: Convert operation registry to manifest metadata**
@ -1208,22 +1208,22 @@ Keep operation keys and aliases stable. `handlerName` becomes worker-internal.
Run: Run:
```powershell ```powershell
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/qqbot/plugins/bangDream/registry/operation-registry.spec.ts test/qqbot/plugins/bangDream/registry/command-sql.spec.ts test/modules/qqbot/plugins/bangDream pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/qqbot/plugins/bangdream/manifest/operation-manifest.spec.ts test/qqbot/plugins/bangdream/manifest/command-sql.spec.ts test/modules/qqbot/plugins/bangdream-rewrite/bangdream-operation-parity.spec.ts
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck
``` ```
Expected: PASS. Event stage smoke keeps `imageCount=5`. Expected: PASS. Event stage smoke keeps `imageCount=5`.
Actual note: BangDream moved to `src/modules/qqbot/plugins/bangDream` with the approved business and cross-cutting directory layout preserved. Added `plugin.json` with platform key `bangdream`; manifest operations are asserted against `BANGDREAM_OPERATION_REGISTRY` for stable operation keys, aliases, names, and worker-internal `handlerName`. Runtime registry now uses platform key `bangdream` as primary while resolving legacy `bangDream`; `/qqbot/plugin/*` local HTTP smoke now proves the controller returns platform keys and resolves legacy `bangDream` to `bangdream`. Verification passed via focused registry/command SQL Jest, plugin migration contract, QQBot core module contract path guard, plugin controller HTTP smoke, `pnpm run typecheck`, and `scripts/bangdream-render-smoke.ps1 -OperationKey bangdream.event.stage -Text 310`, which produced `imageCount=5`. Actual note: BangDream moved to `src/modules/qqbot/plugins/bangdream/src` with third-phase plugin ownership: business code in `domain/*`, orchestration in `application`, operations in `operations`, external APIs in `infrastructure/integration`, cache/static patch adapters in `infrastructure/storage`, dictionary/static config in `config`, and visual helpers in `theme`. Added `plugin.json` with platform key `bangdream`; manifest operations are the single metadata source for stable operation keys, aliases, names, and worker-internal `handlerName`. Runtime registry now uses platform key `bangdream` as primary while resolving legacy `bangDream`; `/qqbot/plugin/*` local HTTP smoke now proves the controller returns platform keys and resolves legacy `bangDream` to `bangdream`. Verification passed via focused manifest/command SQL Jest, plugin migration contract, QQBot core module contract path guard, plugin controller HTTP smoke, `pnpm run typecheck`, and `scripts/bangdream-render-smoke.ps1 -OperationKey bangdream.event.stage -Text 310`, which produced `imageCount=5`.
### Task 6.2: Rewrite FF14 Market, FFLogs, and Repeater ### Task 6.2: Rewrite FF14 Market, FFLogs, and Repeater
**Files:** **Files:**
- Create: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugins\ff14Market\**` - Create: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugins\ff14-market\**`
- Create: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugins\fflogs\**` - Create: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugins\fflogs\**`
- Create: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugins\repeater\**` - Create: `D:\MyFiles\KT\Node\kt-template-online-api\src\modules\qqbot\plugins\repeater\**`
- Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugins\ff14Market\**` - Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugins\ff14-market\**`
- Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugins\fflogs\**` - Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugins\fflogs\**`
- Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugins\repeater\**` - Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugins\repeater\**`
@ -1244,13 +1244,13 @@ Use host send queue and account binding. Do not bypass rate limit.
Run: Run:
```powershell ```powershell
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts test/qqbot/plugins/repeater/qqbot-repeater.plugin.spec.ts test/modules/qqbot/plugins/ff14Market test/modules/qqbot/plugins/fflogs test/modules/qqbot/plugins/repeater pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api exec jest --runInBand --runTestsByPath test/qqbot/plugins/ff14-market/qqbot-ff14-worlds.spec.ts test/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts test/qqbot/plugins/repeater/qqbot-repeater.plugin.spec.ts test/modules/qqbot/plugins/ff14-market test/modules/qqbot/plugins/fflogs test/modules/qqbot/plugins/repeater
pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck
``` ```
Expected: PASS. Expected: PASS.
Actual note: FF14 Market, FFLogs, and Repeater moved to `src/modules/qqbot/plugins/*` and each now has a platform `plugin.json`. FF14 Market uses platform key `ff14-market` with legacy `ff14Market` compatibility and delegates outbound requests through `QqbotPluginHttpClientService`; FFLogs delegates outbound requests through the same host SDK and keeps credential references in manifest config schema without real secrets; Repeater remains event-plugin driven through host account binding and send queue services. Verification passed via focused plugin Jest, migration direct-HTTP scan for FF14/FFLogs, `/qqbot/plugin/list` and `/qqbot/plugin/operation/list` local HTTP smoke, and `pnpm run typecheck`; final focused Jest covered 9 suites / 27 tests. Actual note: FF14 Market, FFLogs, and Repeater moved to `src/modules/qqbot/plugins/*` and each now has a platform `plugin.json` plus real third-phase package structure. FF14 Market splits operation binding into `operations`, use cases into `application`, world/market types into `domain`, external clients into `infrastructure/integration`, and config into `config`; FFLogs splits character-summary operation, application use case, GraphQL/OAuth client, config, and token cache; Repeater splits event handler, repeat policy/domain state, runtime config, application state, and host integration. Verification passed via focused plugin Jest, migration direct-HTTP scan for FF14/FFLogs, `/qqbot/plugin/list` and `/qqbot/plugin/operation/list` local HTTP smoke, and `pnpm run typecheck`; final focused Jest covered 9 suites / 27 tests.
### Task 6.3: Commit Batch 6 ### Task 6.3: Commit Batch 6

View File

@ -34,7 +34,7 @@ This plan is complete only when all of these are true:
- `pnpm-lock.yaml` - `pnpm-lock.yaml`
- `src/modules/qqbot/napcat/integration/napcat-login-api.client.ts` - `src/modules/qqbot/napcat/integration/napcat-login-api.client.ts`
- `src/modules/qqbot/plugin-platform/qqbot-plugin.controller.ts` - `src/modules/qqbot/plugin-platform/qqbot-plugin.controller.ts`
- `src/modules/qqbot/plugins/bangDream/shared/main-data-store.ts` - `src/modules/qqbot/plugins/bangdream/src/application/main-data-store.ts`
- `test/modules/qqbot/napcat/new-device-flow.spec.ts` - `test/modules/qqbot/napcat/new-device-flow.spec.ts`
- `test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts` - `test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts`
- Existing Admin dirty work contains the matching plugin pagination wrapper/page change: - Existing Admin dirty work contains the matching plugin pagination wrapper/page change:
@ -93,7 +93,7 @@ pnpm --dir D:\MyFiles\KT\Vue\kt-template-admin -F @vben/web-antdv-next run typec
- [ ] Commit the verified baseline in API with: - [ ] Commit the verified baseline in API with:
```powershell ```powershell
git -C D:\MyFiles\KT\Node\kt-template-online-api add package.json pnpm-lock.yaml src/modules/qqbot/napcat/integration/napcat-login-api.client.ts src/modules/qqbot/plugin-platform/qqbot-plugin.controller.ts src/modules/qqbot/plugins/bangDream/shared/main-data-store.ts test/modules/qqbot/napcat/new-device-flow.spec.ts test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts git -C D:\MyFiles\KT\Node\kt-template-online-api add package.json pnpm-lock.yaml src/modules/qqbot/napcat/integration/napcat-login-api.client.ts src/modules/qqbot/plugin-platform/qqbot-plugin.controller.ts src/modules/qqbot/plugins/bangdream/src/application/main-data-store.ts test/modules/qqbot/napcat/new-device-flow.spec.ts test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts
git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "fix: 补齐QQBot插件分页与新设备二维码" git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "fix: 补齐QQBot插件分页与新设备二维码"
``` ```
@ -472,7 +472,7 @@ powershell -ExecutionPolicy Bypass -File D:\MyFiles\KT\Node\kt-template-online-a
```powershell ```powershell
git -C D:\MyFiles\KT\Node\kt-template-online-api add src/modules/qqbot/plugins/bangdream test/modules/qqbot/plugins/bangdream-rewrite test/modules/qqbot/architecture sql/refactor-v3 docs/refactor-v3 API.md git -C D:\MyFiles\KT\Node\kt-template-online-api add src/modules/qqbot/plugins/bangdream test/modules/qqbot/plugins/bangdream-rewrite test/modules/qqbot/architecture sql/refactor-v3 docs/refactor-v3 API.md
git -C D:\MyFiles\KT\Node\kt-template-online-api add -u src/modules/qqbot/plugins/bangDream git -C D:\MyFiles\KT\Node\kt-template-online-api add -u src/modules/qqbot/plugins/bangdream/src
git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "refactor: 重写BangDream插件" git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "refactor: 重写BangDream插件"
``` ```
@ -533,7 +533,7 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck
```powershell ```powershell
git -C D:\MyFiles\KT\Node\kt-template-online-api add src/modules/qqbot/plugins/ff14-market src/modules/qqbot/plugins/fflogs src/modules/qqbot/plugins/repeater test/modules/qqbot/plugins test/modules/qqbot/architecture sql/refactor-v3 docs/refactor-v3 API.md git -C D:\MyFiles\KT\Node\kt-template-online-api add src/modules/qqbot/plugins/ff14-market src/modules/qqbot/plugins/fflogs src/modules/qqbot/plugins/repeater test/modules/qqbot/plugins test/modules/qqbot/architecture sql/refactor-v3 docs/refactor-v3 API.md
git -C D:\MyFiles\KT\Node\kt-template-online-api add -u src/modules/qqbot/plugins/ff14Market git -C D:\MyFiles\KT\Node\kt-template-online-api add -u src/modules/qqbot/plugins/ff14-market
git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "refactor: 规范化QQBot内置插件包" git -C D:\MyFiles\KT\Node\kt-template-online-api commit -m "refactor: 规范化QQBot内置插件包"
``` ```

View File

@ -5,11 +5,19 @@
"compilerOptions": { "compilerOptions": {
"assets": [ "assets": [
{ {
"include": "modules/qqbot/plugins/bangDream/assets/**/*", "include": "modules/qqbot/plugins/*/plugin.json",
"outDir": "dist" "outDir": "dist"
}, },
{ {
"include": "modules/qqbot/plugins/bangDream/static-config/**/*", "include": "modules/qqbot/plugins/bangdream/TSUGU-LICENSE",
"outDir": "dist"
},
{
"include": "modules/qqbot/plugins/bangdream/src/assets/**/*",
"outDir": "dist"
},
{
"include": "modules/qqbot/plugins/bangdream/src/config/static/**/*",
"outDir": "dist" "outDir": "dist"
} }
], ],

View File

@ -4,6 +4,7 @@ param(
[string]$DisplayedServerList = "cn jp", [string]$DisplayedServerList = "cn jp",
[string]$OutFile = ".kt-workspace/bangdream-smoke/bangdream-smoke.jpg", [string]$OutFile = ".kt-workspace/bangdream-smoke/bangdream-smoke.jpg",
[int]$TimeoutSeconds = 45, [int]$TimeoutSeconds = 45,
[int]$ExpectedImageCount = 0,
[switch]$UseEasyBg [switch]$UseEasyBg
) )
@ -38,6 +39,7 @@ $Payload = @{
} }
operationKey = $OperationKey operationKey = $OperationKey
outFile = $ResolvedOutFile outFile = $ResolvedOutFile
expectedImageCount = $ExpectedImageCount
} }
$PayloadJson = $Payload | ConvertTo-Json -Compress -Depth 5 $PayloadJson = $Payload | ConvertTo-Json -Compress -Depth 5
$PayloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($PayloadJson)) $PayloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($PayloadJson))
@ -45,17 +47,96 @@ $PayloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Paylo
$NodeCode = @" $NodeCode = @"
const fs = require("fs"); const fs = require("fs");
const path = require("path"); const path = require("path");
const { ConfigService } = require("@nestjs/config"); const XLSX = require("xlsx");
const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8")); const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8"));
const { ToolsService } = require("./src/common/services/tool.service"); const { createPlugin } = require("./src/modules/qqbot/plugins/bangdream/src");
const { QqbotBangDreamRendererService } = require("./src/modules/qqbot/plugins/bangDream/application/bangdream-renderer.facade");
const { TsuguApplicationService } = require("./src/modules/qqbot/plugins/bangDream/application/bangdream-application.service"); function createHttpError(message, statusCode) {
const error = new Error(message);
error.statusCode = statusCode;
error.response = { status: statusCode };
return error;
}
async function fetchWithTimeout(url, options = {}) {
const controller = new AbortController();
const timeoutMs = options.timeoutMs || 30000;
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, {
headers: options.headers,
signal: controller.signal,
});
} finally {
clearTimeout(timer);
}
}
function readJsonFile(filePath) {
return JSON.parse(fs.readFileSync(filePath, "utf8"));
}
function readExcelRows(filePath) {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
return XLSX.utils.sheet_to_json(workbook.Sheets[sheetName]);
}
async function requestBuffer(url, options) {
const response = await fetchWithTimeout(url, options);
const body = Buffer.from(await response.arrayBuffer());
if (!response.ok) {
throw createHttpError("BangDream resource request failed: " + response.status, response.status);
}
return {
body,
statusCode: response.status,
};
}
async function requestJson(url, options) {
const response = await fetchWithTimeout(url, options);
const text = await response.text();
if (!response.ok) {
throw createHttpError("BangDream JSON request failed: " + response.status, response.status);
}
return {
body: JSON.parse(text),
statusCode: response.status,
};
}
(async () => { (async () => {
const renderer = new QqbotBangDreamRendererService(new ConfigService({}), undefined); const manifest = readJsonFile(path.join(process.cwd(), "src/modules/qqbot/plugins/bangdream/plugin.json"));
const service = new TsuguApplicationService(renderer, new ToolsService()); const operations = manifest.operations.map((operation) => ({
await service.onApplicationBootstrap(); handlerName: operation.handlerName,
const result = await service.execute(payload.operationKey, payload.input); key: operation.key,
}));
const plugin = createPlugin({
io: {
getConfig: (key) => process.env[key],
readAssetFile: async (filePath) => fs.promises.readFile(filePath),
readExcelRows: async (filePath) => readExcelRows(filePath),
readJsonFile: async (filePath) => readJsonFile(filePath),
readJsonFileSync: (filePath) => readJsonFile(filePath),
requestArrayBuffer: requestBuffer,
requestJson,
sleep: async (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
writeJsonFile: async (filePath, data) => {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(data));
},
},
normalizeError: (error) => String(error?.message || error || "BangDream command failed"),
operations,
});
await plugin.activate();
const result = await plugin.executeOperation(payload.operationKey, payload.input);
if (payload.expectedImageCount > 0 && result.imageCount !== payload.expectedImageCount) {
throw new Error(
"Expected imageCount=" + payload.expectedImageCount + ", got " + result.imageCount,
);
}
const matches = [...result.replyText.matchAll(/base64:\/\/([A-Za-z0-9+/=]+)/g)]; const matches = [...result.replyText.matchAll(/base64:\/\/([A-Za-z0-9+/=]+)/g)];
if (matches.length === 0) throw new Error("No image CQ payload"); if (matches.length === 0) throw new Error("No image CQ payload");
fs.mkdirSync(path.dirname(payload.outFile), { recursive: true }); fs.mkdirSync(path.dirname(payload.outFile), { recursive: true });

View File

@ -756,23 +756,23 @@ ON DUPLICATE KEY UPDATE
INSERT INTO `qqbot_command` (`id`, `code`, `name`, `aliases`, `prefixes`, `plugin_key`, `operation_key`, `parser_key`, `target_type`, `default_params`, `reply_template`, `error_template`, `enabled`, `priority`, `cooldown_ms`, `remark`) INSERT INTO `qqbot_command` (`id`, `code`, `name`, `aliases`, `prefixes`, `plugin_key`, `operation_key`, `parser_key`, `target_type`, `default_params`, `reply_template`, `error_template`, `enabled`, `priority`, `cooldown_ms`, `remark`)
VALUES VALUES
(2041700000000300501, 'ff14_price', 'FF14 查价', '["查价","price","ff14price"]', '["/","!",""]', 'ff14Market', 'ff14.market.price', 'ff14Price', 'all', '{"language":"chs","world":"中国"}', '', 'FF14 查价失败:{{error}}', 1, 0, 1500, '默认示例命令;请在账号配置中绑定后启用'), (2041700000000300501, 'ff14_price', 'FF14 查价', '["查价","price","ff14price"]', '["/","!",""]', 'ff14-market', 'ff14.market.price', 'ff14Price', 'all', '{"language":"chs","world":"中国"}', '', 'FF14 查价失败:{{error}}', 1, 0, 1500, '默认示例命令;请在账号配置中绑定后启用'),
(2041700000000300502, 'fflogs_character', 'FFLogs 查询', '["fflogs","logs","查logs","查log"]', '["/","!",""]', 'fflogs', 'fflogs.character.summary', 'fflogsCharacter', 'all', '{"serverRegion":"CN"}', '', 'FFLogs 查询失败:{{error}}', 1, 0, 3000, '查询 FFLogs 角色公开排名带高难任务时返回最近10次记录格式/fflogs 角色名 服务器 [高难任务]'), (2041700000000300502, 'fflogs_character', 'FFLogs 查询', '["fflogs","logs","查logs","查log"]', '["/","!",""]', 'fflogs', 'fflogs.character.summary', 'fflogsCharacter', 'all', '{"serverRegion":"CN"}', '', 'FFLogs 查询失败:{{error}}', 1, 0, 3000, '查询 FFLogs 角色公开排名带高难任务时返回最近10次记录格式/fflogs 角色名 服务器 [高难任务]'),
(2041700000000300503, 'bangdream_song', 'BangDream 查曲', '["查曲","bd","bangdream","bandori","邦邦","邦邦查歌"]', '["/","!",""]', 'bangDream', 'bangdream.song.search', 'plain', 'all', '{}', '', 'BangDream 查曲失败:{{error}}', 1, 0, 1500, '查询 BanG Dream 歌曲信息;格式:/查曲 歌曲名 或 /查曲 歌曲ID'), (2041700000000300503, 'bangdream_song', 'BangDream 查曲', '["查曲","bd","bangdream","bandori","邦邦","邦邦查歌"]', '["/","!",""]', 'bangdream', 'bangdream.song.search', 'plain', 'all', '{}', '', 'BangDream 查曲失败:{{error}}', 1, 0, 1500, '查询 BanG Dream 歌曲信息;格式:/查曲 歌曲名 或 /查曲 歌曲ID'),
(2041700000000300504, 'bangdream_song_chart', 'BangDream 查谱面', '["查谱面","谱面","bd谱面"]', '["/","!",""]', 'bangDream', 'bangdream.song.chart', 'plain', 'all', '{}', '', 'BangDream 查谱面失败:{{error}}', 1, 0, 1500, '查询歌曲谱面;格式:/查谱面 歌曲ID [难度]'), (2041700000000300504, 'bangdream_song_chart', 'BangDream 查谱面', '["查谱面","谱面","bd谱面"]', '["/","!",""]', 'bangdream', 'bangdream.song.chart', 'plain', 'all', '{}', '', 'BangDream 查谱面失败:{{error}}', 1, 0, 1500, '查询歌曲谱面;格式:/查谱面 歌曲ID [难度]'),
(2041700000000300505, 'bangdream_song_random', 'BangDream 随机曲', '["随机曲","随机","bd随机"]', '["/","!",""]', 'bangDream', 'bangdream.song.random', 'plain', 'all', '{}', '', 'BangDream 随机曲失败:{{error}}', 1, 0, 1500, '按关键词随机歌曲;格式:/随机曲 [关键词]'), (2041700000000300505, 'bangdream_song_random', 'BangDream 随机曲', '["随机曲","随机","bd随机"]', '["/","!",""]', 'bangdream', 'bangdream.song.random', 'plain', 'all', '{}', '', 'BangDream 随机曲失败:{{error}}', 1, 0, 1500, '按关键词随机歌曲;格式:/随机曲 [关键词]'),
(2041700000000300506, 'bangdream_song_meta', 'BangDream 分数表', '["查询分数表","查分数表","查询分数榜","查分数榜","bd分数表"]', '["/","!",""]', 'bangDream', 'bangdream.song.meta', 'plain', 'all', '{}', '', 'BangDream 分数表失败:{{error}}', 1, 0, 1500, '查询歌曲分数榜;格式:/查询分数表 [服务器]'), (2041700000000300506, 'bangdream_song_meta', 'BangDream 分数表', '["查询分数表","查分数表","查询分数榜","查分数榜","bd分数表"]', '["/","!",""]', 'bangdream', 'bangdream.song.meta', 'plain', 'all', '{}', '', 'BangDream 分数表失败:{{error}}', 1, 0, 1500, '查询歌曲分数榜;格式:/查询分数表 [服务器]'),
(2041700000000300507, 'bangdream_card', 'BangDream 查卡', '["查卡","查卡牌","bd查卡"]', '["/","!",""]', 'bangDream', 'bangdream.card.search', 'plain', 'all', '{}', '', 'BangDream 查卡失败:{{error}}', 1, 0, 1500, '查询卡牌信息;格式:/查卡 卡牌关键词 或 /查卡 卡牌ID'), (2041700000000300507, 'bangdream_card', 'BangDream 查卡', '["查卡","查卡牌","bd查卡"]', '["/","!",""]', 'bangdream', 'bangdream.card.search', 'plain', 'all', '{}', '', 'BangDream 查卡失败:{{error}}', 1, 0, 1500, '查询卡牌信息;格式:/查卡 卡牌关键词 或 /查卡 卡牌ID'),
(2041700000000300508, 'bangdream_card_illustration', 'BangDream 查卡面', '["查卡面","查卡插画","查插画","bd卡面"]', '["/","!",""]', 'bangDream', 'bangdream.card.illustration', 'plain', 'all', '{}', '', 'BangDream 查卡面失败:{{error}}', 1, 0, 1500, '查询卡牌插画;格式:/查卡面 卡牌ID'), (2041700000000300508, 'bangdream_card_illustration', 'BangDream 查卡面', '["查卡面","查卡插画","查插画","bd卡面"]', '["/","!",""]', 'bangdream', 'bangdream.card.illustration', 'plain', 'all', '{}', '', 'BangDream 查卡面失败:{{error}}', 1, 0, 1500, '查询卡牌插画;格式:/查卡面 卡牌ID'),
(2041700000000300509, 'bangdream_character', 'BangDream 查角色', '["查角色","bd角色"]', '["/","!",""]', 'bangDream', 'bangdream.character.search', 'plain', 'all', '{}', '', 'BangDream 查角色失败:{{error}}', 1, 0, 1500, '查询角色信息;格式:/查角色 角色关键词 或 /查角色 角色ID'), (2041700000000300509, 'bangdream_character', 'BangDream 查角色', '["查角色","bd角色"]', '["/","!",""]', 'bangdream', 'bangdream.character.search', 'plain', 'all', '{}', '', 'BangDream 查角色失败:{{error}}', 1, 0, 1500, '查询角色信息;格式:/查角色 角色关键词 或 /查角色 角色ID'),
(2041700000000300510, 'bangdream_event', 'BangDream 查活动', '["查活动","bd活动"]', '["/","!",""]', 'bangDream', 'bangdream.event.search', 'plain', 'all', '{}', '', 'BangDream 查活动失败:{{error}}', 1, 0, 1500, '查询活动信息;格式:/查活动 活动关键词 或 /查活动 活动ID'), (2041700000000300510, 'bangdream_event', 'BangDream 查活动', '["查活动","bd活动"]', '["/","!",""]', 'bangdream', 'bangdream.event.search', 'plain', 'all', '{}', '', 'BangDream 查活动失败:{{error}}', 1, 0, 1500, '查询活动信息;格式:/查活动 活动关键词 或 /查活动 活动ID'),
(2041700000000300511, 'bangdream_event_stage', 'BangDream 查试炼', '["查试炼","查stage","查舞台","查festival","查5v5"]', '["/","!",""]', 'bangDream', 'bangdream.event.stage', 'plain', 'all', '{}', '', 'BangDream 查试炼失败:{{error}}', 1, 0, 1500, '查询活动试炼;格式:/查试炼 [活动ID] [-m]'), (2041700000000300511, 'bangdream_event_stage', 'BangDream 查试炼', '["查试炼","查stage","查舞台","查festival","查5v5"]', '["/","!",""]', 'bangdream', 'bangdream.event.stage', 'plain', 'all', '{}', '', 'BangDream 查试炼失败:{{error}}', 1, 0, 1500, '查询活动试炼;格式:/查试炼 [活动ID] [-m]'),
(2041700000000300512, 'bangdream_player', 'BangDream 查玩家', '["查玩家","查询玩家","bd玩家"]', '["/","!",""]', 'bangDream', 'bangdream.player.search', 'plain', 'all', '{}', '', 'BangDream 查玩家失败:{{error}}', 1, 0, 1500, '查询玩家信息;格式:/查玩家 玩家ID [服务器]'), (2041700000000300512, 'bangdream_player', 'BangDream 查玩家', '["查玩家","查询玩家","bd玩家"]', '["/","!",""]', 'bangdream', 'bangdream.player.search', 'plain', 'all', '{}', '', 'BangDream 查玩家失败:{{error}}', 1, 0, 1500, '查询玩家信息;格式:/查玩家 玩家ID [服务器]'),
(2041700000000300513, 'bangdream_gacha', 'BangDream 查卡池', '["查卡池","bd卡池"]', '["/","!",""]', 'bangDream', 'bangdream.gacha.search', 'plain', 'all', '{}', '', 'BangDream 查卡池失败:{{error}}', 1, 0, 1500, '查询卡池信息;格式:/查卡池 卡池ID'), (2041700000000300513, 'bangdream_gacha', 'BangDream 查卡池', '["查卡池","bd卡池"]', '["/","!",""]', 'bangdream', 'bangdream.gacha.search', 'plain', 'all', '{}', '', 'BangDream 查卡池失败:{{error}}', 1, 0, 1500, '查询卡池信息;格式:/查卡池 卡池ID'),
(2041700000000300514, 'bangdream_gacha_simulate', 'BangDream 抽卡模拟', '["抽卡模拟","bd抽卡"]', '["/","!",""]', 'bangDream', 'bangdream.gacha.simulate', 'plain', 'all', '{}', '', 'BangDream 抽卡模拟失败:{{error}}', 1, 0, 3000, '模拟抽卡;格式:/抽卡模拟 [次数] [卡池ID]'), (2041700000000300514, 'bangdream_gacha_simulate', 'BangDream 抽卡模拟', '["抽卡模拟","bd抽卡"]', '["/","!",""]', 'bangdream', 'bangdream.gacha.simulate', 'plain', 'all', '{}', '', 'BangDream 抽卡模拟失败:{{error}}', 1, 0, 3000, '模拟抽卡;格式:/抽卡模拟 [次数] [卡池ID]'),
(2041700000000300515, 'bangdream_cutoff_detail', 'BangDream ycx', '["ycx","预测线","查档线","bd档线"]', '["/","!",""]', 'bangDream', 'bangdream.cutoff.detail', 'plain', 'all', '{}', '', 'BangDream ycx 失败:{{error}}', 1, 0, 3000, '查询指定档位预测线;格式:/ycx 档位 [活动ID] [服务器]'), (2041700000000300515, 'bangdream_cutoff_detail', 'BangDream ycx', '["ycx","预测线","查档线","bd档线"]', '["/","!",""]', 'bangdream', 'bangdream.cutoff.detail', 'plain', 'all', '{}', '', 'BangDream ycx 失败:{{error}}', 1, 0, 3000, '查询指定档位预测线;格式:/ycx 档位 [活动ID] [服务器]'),
(2041700000000300516, 'bangdream_cutoff_all', 'BangDream ycxall', '["ycxall","myycx","全部档线"]', '["/","!",""]', 'bangDream', 'bangdream.cutoff.all', 'plain', 'all', '{}', '', 'BangDream ycxall 失败:{{error}}', 1, 0, 3000, '查询所有档位预测线;格式:/ycxall [活动ID] [服务器]'), (2041700000000300516, 'bangdream_cutoff_all', 'BangDream ycxall', '["ycxall","myycx","全部档线"]', '["/","!",""]', 'bangdream', 'bangdream.cutoff.all', 'plain', 'all', '{}', '', 'BangDream ycxall 失败:{{error}}', 1, 0, 3000, '查询所有档位预测线;格式:/ycxall [活动ID] [服务器]'),
(2041700000000300517, 'bangdream_cutoff_recent', 'BangDream lsycx', '["lsycx","历史档线","近期档线"]', '["/","!",""]', 'bangDream', 'bangdream.cutoff.recent', 'plain', 'all', '{}', '', 'BangDream lsycx 失败:{{error}}', 1, 0, 3000, '查询同类型活动档线;格式:/lsycx 档位 [活动ID] [服务器]') (2041700000000300517, 'bangdream_cutoff_recent', 'BangDream lsycx', '["lsycx","历史档线","近期档线"]', '["/","!",""]', 'bangdream', 'bangdream.cutoff.recent', 'plain', 'all', '{}', '', 'BangDream lsycx 失败:{{error}}', 1, 0, 3000, '查询同类型活动档线;格式:/lsycx 档位 [活动ID] [服务器]')
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
`code` = VALUES(`code`), `code` = VALUES(`code`),
`name` = VALUES(`name`), `name` = VALUES(`name`),

View File

@ -274,21 +274,178 @@ INSERT INTO qqbot_command (
plugin_key, plugin_key,
enabled, enabled,
cooldown_seconds cooldown_seconds
) VALUES ( ) VALUES
1000000000000000201, (
'bangdream.song.search', 1000000000000000201,
'bangdream_song', 'bangdream.song.search',
'bd', 'bangdream_song',
'BangDream 查歌', 'bd',
'["查曲","bd","bangdream","bandori","邦邦","邦邦查歌"]', 'BangDream 查歌',
'bangdream', '["查曲","bd","bangdream","bandori","邦邦","邦邦查歌"]',
1, 'bangdream',
2 1,
) ON DUPLICATE KEY UPDATE 2
),
(
1000000000000000202,
'bangdream.song.chart',
'bangdream_song_chart',
'bangdream_song_chart',
'BangDream 查谱面',
'["查谱面","谱面","bd谱面"]',
'bangdream',
1,
2
),
(
1000000000000000203,
'bangdream.song.random',
'bangdream_song_random',
'bangdream_song_random',
'BangDream 随机曲',
'["随机曲","随机","bd随机"]',
'bangdream',
1,
2
),
(
1000000000000000204,
'bangdream.song.meta',
'bangdream_song_meta',
'bangdream_song_meta',
'BangDream 分数表',
'["查询分数表","查分数表","查询分数榜","查分数榜","bd分数表"]',
'bangdream',
1,
2
),
(
1000000000000000205,
'bangdream.card.search',
'bangdream_card',
'bangdream_card',
'BangDream 查卡',
'["查卡","查卡牌","bd查卡"]',
'bangdream',
1,
2
),
(
1000000000000000206,
'bangdream.card.illustration',
'bangdream_card_illustration',
'bangdream_card_illustration',
'BangDream 查卡面',
'["查卡面","查卡插画","查插画","bd卡面"]',
'bangdream',
1,
2
),
(
1000000000000000207,
'bangdream.character.search',
'bangdream_character',
'bangdream_character',
'BangDream 查角色',
'["查角色","bd角色"]',
'bangdream',
1,
2
),
(
1000000000000000208,
'bangdream.event.search',
'bangdream_event',
'bangdream_event',
'BangDream 查活动',
'["查活动","bd活动"]',
'bangdream',
1,
2
),
(
1000000000000000209,
'bangdream.event.stage',
'bangdream_event_stage',
'bangdream_event_stage',
'BangDream 查试炼',
'["查试炼","查stage","查舞台","查festival","查5v5"]',
'bangdream',
1,
2
),
(
1000000000000000210,
'bangdream.player.search',
'bangdream_player',
'bangdream_player',
'BangDream 查玩家',
'["查玩家","查询玩家","bd玩家"]',
'bangdream',
1,
2
),
(
1000000000000000211,
'bangdream.gacha.search',
'bangdream_gacha',
'bangdream_gacha',
'BangDream 查卡池',
'["查卡池","bd卡池"]',
'bangdream',
1,
2
),
(
1000000000000000212,
'bangdream.gacha.simulate',
'bangdream_gacha_simulate',
'bangdream_gacha_simulate',
'BangDream 抽卡模拟',
'["抽卡模拟","bd抽卡"]',
'bangdream',
1,
3
),
(
1000000000000000213,
'bangdream.cutoff.detail',
'bangdream_cutoff_detail',
'bangdream_cutoff_detail',
'BangDream ycx',
'["ycx","预测线","查档线","bd档线"]',
'bangdream',
1,
3
),
(
1000000000000000214,
'bangdream.cutoff.all',
'bangdream_cutoff_all',
'bangdream_cutoff_all',
'BangDream ycxall',
'["ycxall","myycx","全部档线"]',
'bangdream',
1,
3
),
(
1000000000000000215,
'bangdream.cutoff.recent',
'bangdream_cutoff_recent',
'bangdream_cutoff_recent',
'BangDream lsycx',
'["lsycx","历史档线","近期档线"]',
'bangdream',
1,
3
)
ON DUPLICATE KEY UPDATE
operation_key = VALUES(operation_key), operation_key = VALUES(operation_key),
code = VALUES(code), code = VALUES(code),
name = VALUES(name), name = VALUES(name),
aliases = VALUES(aliases), aliases = VALUES(aliases),
plugin_key = VALUES(plugin_key), plugin_key = VALUES(plugin_key),
enabled = VALUES(enabled), enabled = VALUES(enabled),
cooldown_seconds = VALUES(cooldown_seconds); cooldown_seconds = VALUES(cooldown_seconds),
is_deleted = 0;

View File

@ -31,6 +31,13 @@ WHERE command_key = 'bangdream_song'
AND plugin_key = 'bangdream' AND plugin_key = 'bangdream'
AND enabled = 1; AND enabled = 1;
SELECT 'seed_qqbot_command_bangdream_all' AS check_name, COUNT(*) AS matched_rows
FROM qqbot_command
WHERE plugin_key = 'bangdream'
AND operation_key LIKE 'bangdream.%'
AND enabled = 1
AND is_deleted = 0;
SELECT 'index_admin_user_username' AS check_name, COUNT(*) AS matched_rows SELECT 'index_admin_user_username' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics FROM information_schema.statistics
WHERE table_schema = DATABASE() WHERE table_schema = DATABASE()

View File

@ -9,8 +9,8 @@ import {
isQqbotFf14WorldName, isQqbotFf14WorldName,
QQBOT_FF14_MARKET_DICT_CODES, QQBOT_FF14_MARKET_DICT_CODES,
splitQqbotFf14WorldPath, splitQqbotFf14WorldPath,
} from '@/modules/qqbot/plugins/ff14Market/qqbot-ff14-worlds'; } from '@/modules/qqbot/plugins/ff14-market/src';
import type { QqbotFf14MarketCatalog } from '@/modules/qqbot/plugins/ff14Market/qqbot-ff14-market.types'; import type { QqbotFf14MarketCatalog } from '@/modules/qqbot/plugins/ff14-market/src';
import type { QqbotPluginExecutionInput } from '@/modules/qqbot/core/domain/plugin-execution.port'; import type { QqbotPluginExecutionInput } from '@/modules/qqbot/core/domain/plugin-execution.port';
@Injectable() @Injectable()

View File

@ -1,20 +1,52 @@
import { Injectable } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { formatKtDateTime, throwVbenError } from '@/common'; import { formatKtDateTime, throwVbenError } from '@/common';
import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service';
import { QqbotRepeaterPluginService } from '@/modules/qqbot/plugins/repeater/qqbot-repeater.plugin'; import { QqbotSendService } from '@/modules/qqbot/core/application/send/qqbot-send.service';
import type { import type {
QqbotEventPluginDefinition, QqbotEventPluginDefinition,
QqbotNormalizedMessage, QqbotNormalizedMessage,
QqbotPluginHealth, QqbotPluginHealth,
QqbotPluginOperationSummary, QqbotPluginOperationSummary,
} from '@/modules/qqbot/core/contract/qqbot.types'; } from '@/modules/qqbot/core/contract/qqbot.types';
import {
parseQqbotPluginManifest,
type QqbotPluginManifest,
} from '@/modules/qqbot/plugin-platform/domain/manifest';
import { createPlugin as createRepeaterPlugin } from '@/modules/qqbot/plugins/repeater/src';
@Injectable() @Injectable()
export class QqbotEventPluginRegistryService { export class QqbotEventPluginRegistryService {
private readonly logger = new Logger(QqbotEventPluginRegistryService.name);
private readonly repeaterPlugin: ReturnType<typeof createRepeaterPlugin>;
constructor( constructor(
private readonly accountService: QqbotAccountService, private readonly accountService: QqbotAccountService,
private readonly repeaterPlugin: QqbotRepeaterPluginService, private readonly configService: ConfigService,
) {} private readonly sendService: QqbotSendService,
) {
const manifest = this.loadManifest('repeater');
this.repeaterPlugin = createRepeaterPlugin({
host: {
bindEventPlugin: (selfId, pluginKey) =>
this.accountService
.bindEventPlugin(selfId, pluginKey)
.then(() => undefined),
getBoundEventPluginKeys: (selfId) =>
this.accountService.getBoundEventPluginKeys(selfId),
getConfig: (key) => this.configService.get(key),
sendText: (input) => this.sendService.sendText(input as any),
unbindEventPlugin: (selfId, pluginKey) =>
this.accountService
.unbindEventPlugin(selfId, pluginKey)
.then(() => undefined),
warn: (message) => this.logger.warn(message),
},
manifest,
});
}
listDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] { listDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] {
return this.getDefinitions(pluginKey); return this.getDefinitions(pluginKey);
@ -95,4 +127,25 @@ export class QqbotEventPluginRegistryService {
? definitions.filter((definition) => definition.key === pluginKey) ? definitions.filter((definition) => definition.key === pluginKey)
: definitions; : definitions;
} }
private loadManifest(pluginKey: string): QqbotPluginManifest {
const pluginRoot = this.resolvePluginRoot(pluginKey);
return parseQqbotPluginManifest(
readJsonFile(join(pluginRoot, 'plugin.json')),
{ pluginRoot },
);
}
private resolvePluginRoot(pluginKey: string) {
const sourceRoot = join(
process.cwd(),
`src/modules/qqbot/plugins/${pluginKey}`,
);
if (existsSync(join(sourceRoot, 'plugin.json'))) return sourceRoot;
return join(__dirname, `../../../plugins/${pluginKey}`);
}
}
function readJsonFile<T = unknown>(filePath: string) {
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
} }

View File

@ -1,8 +1,10 @@
import { Injectable, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleInit } from '@nestjs/common';
import { formatKtDateTime, throwVbenError } from '@/common'; import { ConfigService } from '@nestjs/config';
import { QqbotBangDreamPluginService } from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.plugin'; import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { QqbotFf14MarketPluginService } from '@/modules/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin'; import { join } from 'node:path';
import { QqbotFflogsPluginService } from '@/modules/qqbot/plugins/fflogs/qqbot-fflogs.plugin'; import * as XLSX from 'xlsx';
import { formatKtDateTime, throwVbenError, ToolsService } from '@/common';
import { DictService } from '@/modules/admin/platform-config/dict/dict.service';
import type { import type {
QqbotIntegrationPlugin, QqbotIntegrationPlugin,
QqbotPluginHealth, QqbotPluginHealth,
@ -10,6 +12,16 @@ import type {
QqbotPluginOperationSummary, QqbotPluginOperationSummary,
QqbotPluginSummary, QqbotPluginSummary,
} from '@/modules/qqbot/core/contract/qqbot.types'; } from '@/modules/qqbot/core/contract/qqbot.types';
import {
parseQqbotPluginManifest,
type QqbotPluginManifest,
} from '@/modules/qqbot/plugin-platform/domain/manifest';
import { QqbotPluginHttpClientService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/sdk';
import { createPlugin as createBangDreamPlugin } from '@/modules/qqbot/plugins/bangdream/src';
import type { BangDreamManifestOperation } from '@/modules/qqbot/plugins/bangdream/src';
import type { BangDreamRuntimeIo } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io';
import { createPlugin as createFf14MarketPlugin } from '@/modules/qqbot/plugins/ff14-market/src';
import { createPlugin as createFflogsPlugin } from '@/modules/qqbot/plugins/fflogs/src';
@Injectable() @Injectable()
export class QqbotPluginRegistryService implements OnModuleInit { export class QqbotPluginRegistryService implements OnModuleInit {
@ -17,15 +29,16 @@ export class QqbotPluginRegistryService implements OnModuleInit {
private readonly plugins = new Map<string, QqbotIntegrationPlugin>(); private readonly plugins = new Map<string, QqbotIntegrationPlugin>();
constructor( constructor(
private readonly bangDreamPlugin: QqbotBangDreamPluginService, private readonly configService: ConfigService,
private readonly ff14MarketPlugin: QqbotFf14MarketPluginService, private readonly dictService: DictService,
private readonly fflogsPlugin: QqbotFflogsPluginService, private readonly httpClient: QqbotPluginHttpClientService,
private readonly toolsService: ToolsService,
) {} ) {}
onModuleInit() { onModuleInit() {
this.register(this.bangDreamPlugin.getPlugin()); this.register(this.createBangDreamPlugin());
this.register(this.ff14MarketPlugin.getPlugin()); this.register(this.createFf14MarketPlugin());
this.register(this.fflogsPlugin.getPlugin()); this.register(this.createFflogsPlugin());
} }
register(plugin: QqbotIntegrationPlugin) { register(plugin: QqbotIntegrationPlugin) {
@ -134,4 +147,126 @@ export class QqbotPluginRegistryService implements OnModuleInit {
this.plugins.get(this.pluginAliases.get(pluginKey) || '') this.plugins.get(this.pluginAliases.get(pluginKey) || '')
); );
} }
private createBangDreamPlugin(): QqbotIntegrationPlugin {
const manifest = this.loadManifest('bangdream');
return createBangDreamPlugin({
configReader: {
get: (key) => this.configService.get(key),
},
description: manifest.description,
dictionaryReader: {
getDictItemsByKey: (dictCode) =>
this.dictService.getDictItemsByKey(dictCode),
},
io: this.createBangDreamRuntimeIo(),
legacyAliases: manifest.legacyAliases,
name: manifest.name,
normalizeError: (error) =>
this.toolsService.getErrorMessage(error, 'BangDream 命令执行失败'),
operations: manifest.operations.map((operation) => ({
description: operation.description,
handlerName: operation.handlerName,
inputSchema: operation.inputSchema,
key: operation.key,
name: operation.name,
outputSchema: operation.outputSchema,
})) as BangDreamManifestOperation[],
pluginKey: manifest.pluginKey,
version: manifest.version,
}) as QqbotIntegrationPlugin;
}
private createFf14MarketPlugin(): QqbotIntegrationPlugin {
const manifest = this.loadManifest('ff14-market');
return createFf14MarketPlugin({
host: {
getConfig: (key) => this.configService.get(key),
getDictItemsByKey: (dictCode) =>
this.dictService.getDictItemsByKey(dictCode),
relationTree: (input) => this.dictService.relationTree(input),
requestJson: (options) => this.httpClient.requestJson(options),
},
manifest,
normalizeError: (error, fallback) =>
this.toolsService.getErrorMessage(error, fallback),
}) as QqbotIntegrationPlugin;
}
private createFflogsPlugin(): QqbotIntegrationPlugin {
const manifest = this.loadManifest('fflogs');
return createFflogsPlugin({
host: {
getConfig: (key) => this.configService.get(key),
getDictByKey: (dictCode) => this.dictService.getDictByKey(dictCode),
requestJson: (options) => this.httpClient.requestJson(options),
},
manifest,
normalizeError: (error, fallback) =>
this.toolsService.getErrorMessage(error, fallback),
}) as QqbotIntegrationPlugin;
}
private createBangDreamRuntimeIo(): BangDreamRuntimeIo {
return {
getConfig: (key) => this.configService.get(key),
readAssetFile: async (filePath) => readFileSync(filePath),
readExcelRows: async (filePath) => readExcelRows(filePath),
readJsonFile: async (filePath) => readJsonFile(filePath),
readJsonFileSync: (filePath) => readJsonFile(filePath),
requestArrayBuffer: async (url, options) => ({
body: await this.httpClient.requestBuffer({
context: 'BangDream 资源下载',
failureMessage: (statusCode) =>
`BangDream 资源下载失败:${statusCode}`,
timeoutMessage: 'BangDream 资源下载超时',
timeoutMs: options?.timeoutMs,
url,
}),
}),
requestJson: async (url, options) => ({
body: await this.httpClient.requestJson({
context: 'BangDream 数据接口',
failureMessage: (statusCode) =>
`BangDream 数据接口失败:${statusCode}`,
invalidJsonMessage: 'BangDream 数据接口返回不是合法 JSON',
timeoutMessage: 'BangDream 数据接口请求超时',
timeoutMs: options?.timeoutMs,
url,
}),
}),
sleep: async (ms) =>
await new Promise((resolve) => setTimeout(resolve, ms)),
writeJsonFile: async (filePath, data) =>
writeFileSync(filePath, JSON.stringify(data)),
};
}
private loadManifest(pluginKey: string): QqbotPluginManifest {
const pluginRoot = this.resolvePluginRoot(pluginKey);
return parseQqbotPluginManifest(
readJsonFile(join(pluginRoot, 'plugin.json')),
{ pluginRoot },
);
}
private resolvePluginRoot(pluginKey: string) {
const sourceRoot = join(
process.cwd(),
`src/modules/qqbot/plugins/${pluginKey}`,
);
if (existsSync(join(sourceRoot, 'plugin.json'))) return sourceRoot;
return join(__dirname, `../../../plugins/${pluginKey}`);
}
}
function readJsonFile<T = unknown>(filePath: string) {
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
}
function readExcelRows<T extends Record<string, unknown>>(filePath: string) {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
return XLSX.utils.sheet_to_json<T>(worksheet);
} }

View File

@ -28,6 +28,57 @@ export class QqbotPluginHttpClientService {
} }
} }
requestBuffer(input: QqbotPluginHttpClientRequest): Promise<Buffer> {
const url = input.url instanceof URL ? input.url : new URL(input.url);
const method = input.method || 'GET';
const timeoutMs = input.timeoutMs || 8000;
const context = input.context || '插件 HTTP 接口';
return new Promise<Buffer>((resolve, reject) => {
const client = url.protocol === 'http:' ? http : https;
const request = client.request(
url,
{
headers: {
Accept: '*/*',
'User-Agent': 'kt-template-online-api/qqbot-plugin',
...(input.headers || {}),
},
method,
timeout: timeoutMs,
},
(response) => {
const chunks: Buffer[] = [];
response.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
response.on('end', () => {
const statusCode = response.statusCode || 500;
if (statusCode >= 400) {
reject(
createPluginHttpError(
input.failureMessage?.(statusCode) ||
`${context}请求失败:${statusCode}`,
statusCode,
),
);
return;
}
resolve(Buffer.concat(chunks));
});
},
);
request.on('timeout', () => {
request.destroy(
new Error(input.timeoutMessage || `${context}请求超时`),
);
});
request.on('error', reject);
if (input.body) request.write(input.body);
request.end();
});
}
requestText(input: QqbotPluginHttpClientRequest): Promise<string> { requestText(input: QqbotPluginHttpClientRequest): Promise<string> {
const url = input.url instanceof URL ? input.url : new URL(input.url); const url = input.url instanceof URL ? input.url : new URL(input.url);
const method = input.method || 'GET'; const method = input.method || 'GET';
@ -57,9 +108,10 @@ export class QqbotPluginHttpClientService {
const statusCode = response.statusCode || 500; const statusCode = response.statusCode || 500;
if (statusCode >= 400) { if (statusCode >= 400) {
reject( reject(
new Error( createPluginHttpError(
input.failureMessage?.(statusCode) || input.failureMessage?.(statusCode) ||
`${context}请求失败:${statusCode}`, `${context}请求失败:${statusCode}`,
statusCode,
), ),
); );
return; return;
@ -79,3 +131,12 @@ export class QqbotPluginHttpClientService {
}); });
} }
} }
function createPluginHttpError(message: string, statusCode: number) {
return Object.assign(new Error(message), {
response: {
status: statusCode,
},
statusCode,
});
}

View File

@ -5,15 +5,6 @@ import { AdminAuthGuardModule } from '@/modules/admin/identity/auth/admin-auth-g
import { DictModule } from '@/modules/admin/platform-config/dict/dict.module'; import { DictModule } from '@/modules/admin/platform-config/dict/dict.module';
import { QQBOT_PLUGIN_EXECUTION_PORT } from '@/modules/qqbot/core/domain/plugin-execution.port'; import { QQBOT_PLUGIN_EXECUTION_PORT } from '@/modules/qqbot/core/domain/plugin-execution.port';
import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module'; import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module';
import { QqbotBangDreamClientService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-client.service';
import { TsuguApplicationService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-application.service';
import { QqbotBangDreamRendererService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-renderer.facade';
import { QqbotBangDreamPluginService } from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.plugin';
import { QqbotFf14ClientService } from '@/modules/qqbot/plugins/ff14Market/qqbot-ff14-client.service';
import { QqbotFf14MarketPluginService } from '@/modules/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin';
import { QqbotFflogsClientService } from '@/modules/qqbot/plugins/fflogs/qqbot-fflogs-client.service';
import { QqbotFflogsPluginService } from '@/modules/qqbot/plugins/fflogs/qqbot-fflogs.plugin';
import { QqbotRepeaterPluginService } from '@/modules/qqbot/plugins/repeater/qqbot-repeater.plugin';
import { QqbotPluginArgumentParserService } from './application/argument/qqbot-plugin-argument-parser.service'; import { QqbotPluginArgumentParserService } from './application/argument/qqbot-plugin-argument-parser.service';
import { QqbotEventPluginRegistryService } from './application/registry/qqbot-event-plugin-registry.service'; import { QqbotEventPluginRegistryService } from './application/registry/qqbot-event-plugin-registry.service';
import { QqbotPluginRegistryService } from './application/registry/qqbot-plugin-registry.service'; import { QqbotPluginRegistryService } from './application/registry/qqbot-plugin-registry.service';
@ -39,14 +30,7 @@ import { QqbotPluginHttpClientService } from './infrastructure/integration/sdk';
TypeOrmModule.forFeature([...QQBOT_PLUGIN_PLATFORM_ENTITIES]), TypeOrmModule.forFeature([...QQBOT_PLUGIN_PLATFORM_ENTITIES]),
], ],
providers: [ providers: [
QqbotBangDreamClientService,
QqbotBangDreamPluginService,
QqbotBangDreamRendererService,
QqbotEventPluginRegistryService, QqbotEventPluginRegistryService,
QqbotFf14ClientService,
QqbotFf14MarketPluginService,
QqbotFflogsClientService,
QqbotFflogsPluginService,
QqbotPluginArgumentParserService, QqbotPluginArgumentParserService,
QqbotPluginExecutionAdapter, QqbotPluginExecutionAdapter,
{ {
@ -56,8 +40,6 @@ import { QqbotPluginHttpClientService } from './infrastructure/integration/sdk';
QqbotPluginHttpClientService, QqbotPluginHttpClientService,
QqbotPluginPlatformService, QqbotPluginPlatformService,
QqbotPluginRegistryService, QqbotPluginRegistryService,
QqbotRepeaterPluginService,
TsuguApplicationService,
], ],
}) })
export class QqbotPluginPlatformModule {} export class QqbotPluginPlatformModule {}

View File

@ -1,52 +0,0 @@
import { Injectable, OnApplicationBootstrap } from '@nestjs/common';
import { ToolsService } from '@/common';
import type {
QqbotBangDreamCommandInput,
QqbotBangDreamCommandOutput,
QqbotBangDreamOperationKey,
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
import { waitForMainDataReady } from '@/modules/qqbot/plugins/bangDream/shared/main-data-store';
import {
createTsuguLogHook,
TsuguHookRegistry,
} from '@/modules/qqbot/plugins/bangDream/hook/hook-registry';
import { getBangDreamOperationDefinition } from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
import { TsuguOperationPipeline } from '@/modules/qqbot/plugins/bangDream/application/operation-pipeline';
import { QqbotBangDreamRendererService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-renderer.facade';
@Injectable()
export class TsuguApplicationService implements OnApplicationBootstrap {
private readonly hookRegistry = new TsuguHookRegistry([createTsuguLogHook()]);
private readonly operationPipeline: TsuguOperationPipeline;
constructor(
private readonly rendererService: QqbotBangDreamRendererService,
private readonly toolsService: ToolsService,
) {
this.operationPipeline = new TsuguOperationPipeline({
executeHandler: async (handlerName, input) =>
await this.rendererService.executeOperationHandler(handlerName, input),
hookRegistry: this.hookRegistry,
normalizeError: (error) =>
this.toolsService.getErrorMessage(error, 'BangDream 命令执行失败'),
resolveOperation: (operationKey) =>
getBangDreamOperationDefinition(operationKey),
waitForReady: async () => await waitForMainDataReady(),
});
}
async onApplicationBootstrap() {
await this.rendererService.refreshDictionaryCache();
}
async checkHealth() {
return await this.rendererService.checkHealth();
}
async execute(
operationKey: QqbotBangDreamOperationKey,
input: QqbotBangDreamCommandInput,
): Promise<QqbotBangDreamCommandOutput> {
return await this.operationPipeline.run(operationKey, input);
}
}

View File

@ -1,35 +0,0 @@
import { Injectable } from '@nestjs/common';
import type {
QqbotBangDreamCommandInput,
QqbotBangDreamOperationKey,
QqbotBangDreamSongSearchInput,
QqbotBangDreamSongSummary,
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
import { TsuguApplicationService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-application.service';
@Injectable()
export class QqbotBangDreamClientService {
constructor(
private readonly tsuguApplicationService: TsuguApplicationService,
) {}
async checkHealth() {
return this.tsuguApplicationService.checkHealth();
}
async execute(
operationKey: QqbotBangDreamOperationKey,
input: QqbotBangDreamCommandInput,
) {
return await this.tsuguApplicationService.execute(operationKey, input);
}
async searchSong(
params: QqbotBangDreamSongSearchInput,
): Promise<QqbotBangDreamSongSummary> {
return await this.tsuguApplicationService.execute(
'bangdream.song.search',
params,
);
}
}

View File

@ -1,555 +0,0 @@
import { Injectable, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DictService } from '@/modules/admin/platform-config/dict/dict.service';
import { Card } from '@/modules/qqbot/plugins/bangDream/card/card.model';
import { getPresentEvent } from '@/modules/qqbot/plugins/bangDream/event/event.model';
import {
Gacha,
getPresentGachaList,
} from '@/modules/qqbot/plugins/bangDream/gacha/gacha.model';
import {
BANGDREAM_GACHA_DEFAULT_SPIN_COUNT,
isBirthdayGachaType,
} from '@/modules/qqbot/plugins/bangDream/policy/gacha.policy';
import { Server } from '@/modules/qqbot/plugins/bangDream/catalog/server.model';
import { Song } from '@/modules/qqbot/plugins/bangDream/song/song.model';
import mainAPI, {
waitForMainDataReady,
} from '@/modules/qqbot/plugins/bangDream/shared/main-data-store';
import {
fuzzySearch,
type FuzzySearchResult,
} from '@/modules/qqbot/plugins/bangDream/search/fuzzy-search';
import { drawCardDetail } from '@/modules/qqbot/plugins/bangDream/card/card-detail.renderer';
import { drawCardList } from '@/modules/qqbot/plugins/bangDream/card/card-search.renderer';
import { drawCharacterDetail } from '@/modules/qqbot/plugins/bangDream/character/character-detail.renderer';
import { drawCharacterList } from '@/modules/qqbot/plugins/bangDream/character/character-search.renderer';
import { drawCutoffAll } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-all.renderer';
import { drawCutoffDetail } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-detail.renderer';
import { drawCutoffEventTop } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-event-top.renderer';
import { drawCutoffListOfRecentEvent } from '@/modules/qqbot/plugins/bangDream/cutoff/cutoff-recent.renderer';
import { drawEventDetail } from '@/modules/qqbot/plugins/bangDream/event/event-detail.renderer';
import { drawEventList } from '@/modules/qqbot/plugins/bangDream/event/event-search.renderer';
import { drawEventStage } from '@/modules/qqbot/plugins/bangDream/event/event-stage.renderer';
import { drawGachaDetail } from '@/modules/qqbot/plugins/bangDream/gacha/gacha-detail.renderer';
import { drawRandomGacha } from '@/modules/qqbot/plugins/bangDream/gacha/gacha-simulate.renderer';
import { drawPlayerDetail } from '@/modules/qqbot/plugins/bangDream/player/player-detail.renderer';
import { drawSongChart } from '@/modules/qqbot/plugins/bangDream/song/song-chart.renderer';
import { drawSongDetail } from '@/modules/qqbot/plugins/bangDream/song/song-detail.renderer';
import { drawSongList } from '@/modules/qqbot/plugins/bangDream/song/song-search.renderer';
import { drawSongMetaList } from '@/modules/qqbot/plugins/bangDream/song/song-meta.renderer';
import { drawSongRandom } from '@/modules/qqbot/plugins/bangDream/song/song-random.renderer';
import {
BangDreamDictionaryLoader,
type BangDreamDictionaryItem,
} from '@/modules/qqbot/plugins/bangDream/dictionary/dictionary-loader';
import type { QqbotBangDreamOperationHandlerName } from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
import {
BANGDREAM_TSUGU_ENV_KEYS,
normalizeBangDreamBoolean,
splitBangDreamOptionList,
} from '@/modules/qqbot/plugins/bangDream/config/runtime-options';
import type {
QqbotBangDreamCommandInput,
QqbotBangDreamCommandOutput,
QqbotBangDreamOperationKey,
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
const SOURCE_NAME = 'Tsugu BangDream Bot 内置源码';
@Injectable()
export class QqbotBangDreamRendererService {
private readonly dictionaryLoader = new BangDreamDictionaryLoader();
constructor(
private readonly configService: ConfigService,
@Optional()
private readonly dictService?: DictService,
) {}
async refreshDictionaryCache() {
await this.dictionaryLoader.refresh((dictCode) =>
this.fetchDictionaryItems(dictCode),
);
}
async checkHealth() {
await waitForMainDataReady();
const data = mainAPI as { cards?: unknown; songs?: unknown };
if (!data.songs || !data.cards) {
throw new Error('Tsugu 数据配置未加载');
}
fuzzySearch('夏祭り');
return true;
}
async executeOperationHandler(
handlerName: QqbotBangDreamOperationHandlerName,
input: QqbotBangDreamCommandInput,
) {
const handler = this[handlerName];
if (typeof handler !== 'function') {
throw new Error(`BangDream 插件能力未绑定执行器:${handlerName}`);
}
return await (
handler as (
input: QqbotBangDreamCommandInput,
) => Promise<QqbotBangDreamCommandOutput>
).call(this, input);
}
async searchSong(input: QqbotBangDreamCommandInput) {
const query = this.requireText(input, '请提供歌曲名或歌曲 ID');
const options = this.getRenderOptions(input);
const images = this.isInteger(query)
? await drawSongDetail(
new Song(Number(query)),
options.displayedServerList,
options.compress,
)
: await this.drawFuzzyResult(query, (matches) =>
drawSongList(matches, options.displayedServerList, options.compress),
);
return this.toImageReply('bangdream.song.search', query, images);
}
async searchCard(input: QqbotBangDreamCommandInput) {
const query = this.requireText(input, '请提供卡牌关键词或卡牌 ID');
const options = this.getRenderOptions(input);
const images = this.isInteger(query)
? await drawCardDetail(
Number(query),
options.displayedServerList,
options.useEasyBG,
options.compress,
)
: await this.drawFuzzyResult(query, (matches) =>
drawCardList(matches, options.displayedServerList, options.compress),
);
return this.toImageReply('bangdream.card.search', query, images);
}
async getCardIllustration(input: QqbotBangDreamCommandInput) {
const cardId = this.requireNumber(
input.cardId,
this.firstToken(input),
'请提供卡牌 ID',
);
const card = new Card(cardId);
if (!card.isExist) {
return this.toImageReply('bangdream.card.illustration', `${cardId}`, [
'错误: 该卡不存在',
]);
}
const images: Array<Buffer | string> = [];
for (const trainingStatus of card.getTrainingStatusList()) {
images.push(await card.getCardIllustrationImageBuffer(trainingStatus));
}
return this.toImageReply(
'bangdream.card.illustration',
`${cardId}`,
images,
);
}
async searchCharacter(input: QqbotBangDreamCommandInput) {
const query = this.requireText(input, '请提供角色关键词或角色 ID');
const options = this.getRenderOptions(input);
const images = this.isInteger(query)
? await drawCharacterDetail(
Number(query),
options.displayedServerList,
options.compress,
)
: await this.drawFuzzyResult(query, (matches) =>
drawCharacterList(
matches,
options.displayedServerList,
options.compress,
),
);
return this.toImageReply('bangdream.character.search', query, images);
}
async searchEvent(input: QqbotBangDreamCommandInput) {
const query = this.requireText(input, '请提供活动关键词或活动 ID');
const options = this.getRenderOptions(input);
const images = this.isInteger(query)
? await drawEventDetail(
Number(query),
options.displayedServerList,
options.useEasyBG,
options.compress,
)
: await this.drawFuzzyResult(query, (matches) =>
drawEventList(matches, options.displayedServerList, options.compress),
);
return this.toImageReply('bangdream.event.search', query, images);
}
async searchPlayer(input: QqbotBangDreamCommandInput) {
const tokens = this.getTokens(input);
const playerId = this.requireNumber(
input.playerId,
tokens[0],
'请提供玩家 ID',
);
const server = this.pickMainServer(input, tokens.slice(1));
const options = this.getRenderOptions(input);
return this.toImageReply(
'bangdream.player.search',
`${playerId}`,
await drawPlayerDetail(
playerId,
server,
options.useEasyBG,
options.compress,
),
);
}
async getSongChart(input: QqbotBangDreamCommandInput) {
const tokens = this.getTokens(input);
const songId = this.requireNumber(input.songId, tokens[0], '请提供歌曲 ID');
const difficulty = this.pickDifficulty(
input.difficulty ?? input.difficultyText ?? tokens.slice(1).join(' '),
);
const options = this.getRenderOptions(input);
return this.toImageReply(
'bangdream.song.chart',
`${songId}${difficulty === undefined ? '' : ` ${difficulty}`}`,
await drawSongChart(
songId,
difficulty ?? 3,
options.displayedServerList,
options.compress,
),
);
}
async randomSong(input: QqbotBangDreamCommandInput) {
const query = this.pickText(input);
const options = this.getRenderOptions(input);
const matches = query ? fuzzySearch(query) : {};
return this.toImageReply(
'bangdream.song.random',
query || '随机曲',
await drawSongRandom(
matches,
[options.mainServer],
true,
options.compress,
),
);
}
async getSongMeta(input: QqbotBangDreamCommandInput) {
const mainServer = this.pickMainServer(input, this.getTokens(input));
const options = this.getRenderOptions({ ...input, mainServer });
return this.toImageReply(
'bangdream.song.meta',
Server[mainServer],
await drawSongMetaList(mainServer, options.compress),
);
}
async getEventStage(input: QqbotBangDreamCommandInput) {
const tokens = this.getTokens(input).filter((item) => item !== '-m');
const mainServer = this.pickMainServer(input, tokens);
const eventId =
this.optionalNumber(input.eventId) ??
this.firstNumber(tokens) ??
getPresentEvent(mainServer).eventId;
const meta = this.normalizeBoolean(
input.meta,
this.getTokens(input).includes('-m'),
);
const options = this.getRenderOptions({ ...input, mainServer });
return this.toImageReply(
'bangdream.event.stage',
`${eventId}`,
await drawEventStage(eventId, mainServer, meta, options.compress),
);
}
async searchGacha(input: QqbotBangDreamCommandInput) {
const gachaId = this.requireNumber(
input.gachaId,
this.firstToken(input),
'请提供卡池 ID',
);
const options = this.getRenderOptions(input);
return this.toImageReply(
'bangdream.gacha.search',
`${gachaId}`,
await drawGachaDetail(
gachaId,
options.displayedServerList,
options.useEasyBG,
options.compress,
),
);
}
async getCutoffDetail(input: QqbotBangDreamCommandInput) {
const tokens = this.getTokens(input);
const tier = this.requireNumber(input.tier, tokens[0], '请提供档位');
const mainServer = this.pickMainServer(input, tokens.slice(1));
const eventId =
this.optionalNumber(input.eventId) ??
this.firstNumber(tokens.slice(1)) ??
getPresentEvent(mainServer).eventId;
const options = this.getRenderOptions({ ...input, mainServer });
const images =
tier === 10
? await drawCutoffEventTop(eventId, mainServer, options.compress)
: await drawCutoffDetail(eventId, tier, mainServer, options.compress);
return this.toImageReply(
'bangdream.cutoff.detail',
`${tier} ${eventId}`,
images,
);
}
async getCutoffAll(input: QqbotBangDreamCommandInput) {
const tokens = this.getTokens(input);
const mainServer = this.pickMainServer(input, tokens);
const eventId =
this.optionalNumber(input.eventId) ??
this.firstNumber(tokens) ??
getPresentEvent(mainServer).eventId;
const options = this.getRenderOptions({ ...input, mainServer });
return this.toImageReply(
'bangdream.cutoff.all',
`${eventId}`,
await drawCutoffAll(eventId, mainServer, options.compress),
);
}
async getCutoffRecent(input: QqbotBangDreamCommandInput) {
const tokens = this.getTokens(input);
const tier = this.requireNumber(input.tier, tokens[0], '请提供档位');
const mainServer = this.pickMainServer(input, tokens.slice(1));
const eventId =
this.optionalNumber(input.eventId) ??
this.firstNumber(tokens.slice(1)) ??
getPresentEvent(mainServer).eventId;
const options = this.getRenderOptions({ ...input, mainServer });
return this.toImageReply(
'bangdream.cutoff.recent',
`${tier} ${eventId}`,
await drawCutoffListOfRecentEvent(
eventId,
tier,
mainServer,
options.compress,
),
);
}
async simulateGacha(input: QqbotBangDreamCommandInput) {
const tokens = this.getTokens(input);
const mainServer = this.pickMainServer(input, tokens);
const times =
this.optionalNumber(input.times) ??
this.firstNumber(tokens) ??
BANGDREAM_GACHA_DEFAULT_SPIN_COUNT;
const gachaId =
this.optionalNumber(input.gachaId) ?? this.secondNumber(tokens);
const options = this.getRenderOptions({ ...input, mainServer });
const gacha = gachaId
? new Gacha(gachaId)
: await this.pickPresentGacha(mainServer);
return this.toImageReply(
'bangdream.gacha.simulate',
`${times}${gachaId ? ` ${gachaId}` : ''}`,
await drawRandomGacha(gacha, times, options.compress),
);
}
private async pickPresentGacha(mainServer: Server) {
const gachaList = await getPresentGachaList(mainServer);
const gacha = gachaList.find((item) => !isBirthdayGachaType(item.type));
if (!gacha) throw new Error('错误: 该服务器没有正在进行的卡池');
return gacha;
}
private async drawFuzzyResult(
query: string,
render: (matches: FuzzySearchResult) => Promise<Array<Buffer | string>>,
) {
const matches = fuzzySearch(query);
if (Object.keys(matches).length === 0) {
return ['错误: 没有有效的关键词'];
}
return await render(matches);
}
private toImageReply(
operationKey: QqbotBangDreamOperationKey,
query: string,
list: Array<Buffer | string>,
): QqbotBangDreamCommandOutput {
const images = list.filter((item): item is Buffer => Buffer.isBuffer(item));
if (images.length === 0) {
const message =
list.find((item): item is string => typeof item === 'string') ||
'Tsugu 未返回图片';
throw new Error(message);
}
return {
imageCount: images.length,
operationKey,
query,
replyText: images
.map((item) => `[CQ:image,file=base64://${item.toString('base64')}]`)
.join('\n'),
source: SOURCE_NAME,
};
}
private getRenderOptions(
input: QqbotBangDreamCommandInput,
defaults: { useEasyBG?: boolean } = {},
) {
return {
compress: normalizeBangDreamBoolean(
input.compress,
normalizeBangDreamBoolean(
this.configService.get<string>(BANGDREAM_TSUGU_ENV_KEYS.compress),
true,
),
),
displayedServerList: this.pickDisplayedServerList(input),
mainServer: this.pickMainServer(input, []),
useEasyBG: normalizeBangDreamBoolean(
input.useEasyBG,
normalizeBangDreamBoolean(
this.configService.get<string>(BANGDREAM_TSUGU_ENV_KEYS.useEasyBg),
defaults.useEasyBG ?? false,
),
),
};
}
private pickDisplayedServerList(input: QqbotBangDreamCommandInput) {
const source =
input.displayedServerList ||
this.configService.get<string>(BANGDREAM_TSUGU_ENV_KEYS.displayedServers);
const defaultServers = this.dictionaryLoader.getDefaultDisplayedServers();
if (!source) return defaultServers;
const values = splitBangDreamOptionList(source);
const servers = values
.map((item) => this.normalizeServer(item))
.filter((item) => item !== undefined) as Server[];
return servers.length > 0 ? [...new Set(servers)] : defaultServers;
}
private pickMainServer(
input: QqbotBangDreamCommandInput,
tokens: string[],
): Server {
const explicit = this.firstDefined(
input.mainServer,
input.serverName,
input.server,
tokens.find((item) => this.normalizeServer(item) !== undefined) ||
this.configService.get<string>(BANGDREAM_TSUGU_ENV_KEYS.mainServer),
);
return this.normalizeServer(explicit) ?? Server.cn;
}
private pickDifficulty(value: unknown) {
const source = `${value || ''}`.trim();
if (!source) return undefined;
const numeric = this.optionalNumber(source);
if (numeric !== undefined) return numeric;
const alias = this.dictionaryLoader.resolveDifficulty(source);
if (alias !== undefined) return alias;
const matched = fuzzySearch(source)?.difficulty?.[0];
return typeof matched === 'number' ? matched : undefined;
}
private normalizeServer(value: unknown): Server | undefined {
if (value === undefined || value === null || value === '') return undefined;
const raw = `${value}`.trim();
const numeric = Number(raw);
if (Number.isInteger(numeric) && numeric >= 0 && numeric <= 4) {
return numeric as Server;
}
const server = this.dictionaryLoader.resolveServer(raw);
return server === undefined ? undefined : (server as Server);
}
private requireText(input: QqbotBangDreamCommandInput, message: string) {
const text = this.pickText(input);
if (!text) throw new Error(message);
return text;
}
private pickText(input: QqbotBangDreamCommandInput) {
return `${input.query || input.text || input.raw || ''}`.trim();
}
private getTokens(input: QqbotBangDreamCommandInput) {
if (Array.isArray(input.args)) {
return input.args.map((item) => `${item}`.trim()).filter(Boolean);
}
return this.pickText(input).split(/\s+/).filter(Boolean);
}
private firstToken(input: QqbotBangDreamCommandInput) {
return this.getTokens(input)[0];
}
private requireNumber(explicit: unknown, fallback: unknown, message: string) {
const value =
this.optionalNumber(explicit) ?? this.optionalNumber(fallback);
if (value === undefined) throw new Error(message);
return value;
}
private optionalNumber(value: unknown) {
if (value === undefined || value === null || value === '') return undefined;
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : undefined;
}
private firstNumber(tokens: string[]) {
return tokens
.map((item) => this.optionalNumber(item))
.find((item) => item !== undefined);
}
private secondNumber(tokens: string[]) {
return tokens
.map((item) => this.optionalNumber(item))
.filter((item) => item !== undefined)[1];
}
private normalizeBoolean(value: unknown, fallback: boolean) {
return normalizeBangDreamBoolean(value, fallback);
}
private isInteger(value: string) {
return /^(0|[1-9]\d*)$/.test(value);
}
private firstDefined(...values: unknown[]) {
return values.find(
(value) => value !== undefined && value !== null && value !== '',
);
}
private async fetchDictionaryItems(
dictCode: string,
): Promise<BangDreamDictionaryItem[]> {
if (!this.dictService) return [];
const items = await this.dictService.getDictItemsByKey(dictCode);
return items.map(({ label, value }) => ({
label,
value,
}));
}
}

View File

@ -1,71 +0,0 @@
import type {
QqbotBangDreamCommandInput,
QqbotBangDreamCommandOutput,
QqbotBangDreamOperationKey,
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
import type {
QqbotBangDreamOperationHandlerName,
TsuguOperationDefinition,
} from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
import {
createTsuguHookContext,
type TsuguHookRegistry,
} from '@/modules/qqbot/plugins/bangDream/hook/hook-registry';
export type TsuguOperationPipelineOptions = {
executeHandler: (
handlerName: QqbotBangDreamOperationHandlerName,
input: QqbotBangDreamCommandInput,
) => Promise<QqbotBangDreamCommandOutput>;
hookRegistry: TsuguHookRegistry;
normalizeError: (error: unknown) => string;
resolveOperation: (
operationKey: QqbotBangDreamOperationKey,
) => TsuguOperationDefinition | undefined;
waitForReady: () => Promise<void>;
};
/**
* Tsugu operation handler hook
*/
export class TsuguOperationPipeline {
constructor(private readonly options: TsuguOperationPipelineOptions) {}
async run(
operationKey: QqbotBangDreamOperationKey,
input: QqbotBangDreamCommandInput,
) {
const context = createTsuguHookContext(operationKey, input);
await this.options.hookRegistry.beforeParse(context);
try {
context.stage = 'mainData';
await this.options.waitForReady();
context.stage = 'operation';
const operation = this.options.resolveOperation(operationKey);
if (!operation) {
throw new Error(`BangDream 插件能力不存在:${operationKey}`);
}
context.handlerName = operation.handlerName;
await this.options.hookRegistry.afterResolve(context);
context.stage = 'handler';
await this.options.hookRegistry.beforeRender(context);
const output = await this.options.executeHandler(
operation.handlerName,
input,
);
context.stage = 'output';
context.imageCount = output.imageCount;
context.query = output.query || context.query;
await this.options.hookRegistry.afterOutput(context);
return output;
} catch (error) {
const message = this.options.normalizeError(error);
await this.options.hookRegistry.onError(context, message);
throw new Error(message);
}
}
}

View File

@ -1,35 +0,0 @@
import {
BANGDREAM_OPERATION_REGISTRY,
type QqbotBangDreamOperationKey,
} from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
export const BANGDREAM_INPUT_SCHEMA = {
properties: {
args: { description: '命令参数数组', type: 'array' },
query: { description: '查询关键词', type: 'string' },
raw: { description: '命令原始参数', type: 'string' },
text: { description: '命令原始文本', type: 'string' },
},
type: 'object',
};
export const BANGDREAM_OUTPUT_SCHEMA = {
properties: {
imageCount: { type: 'number' },
operationKey: { type: 'string' },
query: { type: 'string' },
replyText: { type: 'string' },
source: { type: 'string' },
},
type: 'object',
};
export const BANGDREAM_OPERATION_DEFS: Array<{
description: string;
key: QqbotBangDreamOperationKey;
name: string;
}> = BANGDREAM_OPERATION_REGISTRY.map((operation) => ({
description: operation.description,
key: operation.key,
name: operation.name,
}));

View File

@ -1,201 +0,0 @@
import axios from 'axios';
import * as path from 'path';
import * as fs from 'fs';
import {
BANGDREAM_TSUGU_ENV_KEYS,
normalizeBangDreamPositiveInteger,
} from '@/modules/qqbot/plugins/bangDream/config/runtime-options';
import {
BANGDREAM_MISSING_URL_CACHE_EXPIRY_MS,
getCacheClientErrorMessage,
getCacheClientResponseStatus,
} from '@/modules/qqbot/plugins/bangDream/provider/cache-policy';
const errorUrlCache: { [url: string]: number } = {};
const DEFAULT_REQUEST_TIMEOUT_MS = 8000;
function getRequestTimeoutMs(): number {
return normalizeBangDreamPositiveInteger(
process.env[BANGDREAM_TSUGU_ENV_KEYS.requestTimeoutMs],
DEFAULT_REQUEST_TIMEOUT_MS,
);
}
/**
*
*
* @param url -
* @param directory - directory参数使
* @param fileName - file名称参数使
* @param cacheTime - 使
* @returns
*/
export async function download(
url: string,
directory?: string,
fileName?: string,
cacheTime = 0,
): Promise<Buffer> {
if (directory != undefined && fileName != undefined) {
ensureDirectoryExists(directory);
}
try {
if (isErrorUrlCacheActive(url)) {
throw new Error('downloadFile: errorUrlCache includes url');
}
let eTag: string | undefined;
const cacheFilePath = path.join(directory || '', `${fileName || ''}`);
if (fileName && directory) {
const eTagFilePath = path.join(directory, `${fileName}.etag`);
eTag = fs.existsSync(eTagFilePath)
? fs.readFileSync(eTagFilePath, 'utf-8')
: undefined;
if (fs.existsSync(cacheFilePath)) {
const stat = fs.statSync(cacheFilePath);
const now = Date.now();
if (now - stat.mtimeMs < cacheTime * 1000) {
const cachedData = fs.readFileSync(cacheFilePath);
return cachedData;
}
}
}
const headers = eTag ? { 'If-None-Match': eTag } : {};
let response;
try {
response = await axios.get(url, {
headers,
responseType: 'arraybuffer',
timeout: getRequestTimeoutMs(),
});
} catch (error) {
if (error.response && error.response.status === 304) {
const cachedData = fs.readFileSync(cacheFilePath);
return cachedData;
} else {
throw error;
}
}
const fileBuffer = Buffer.from(response.data, 'binary');
const newETag = response.headers.etag;
if (newETag && directory && fileName) {
fs.writeFileSync(path.join(directory, `${fileName}.etag`), newETag);
}
if (directory && fileName) {
fs.writeFileSync(path.join(directory, fileName), fileBuffer);
}
return fileBuffer;
} catch (e) {
if (getCacheClientResponseStatus(e) === 404) {
errorUrlCache[url] = Date.now();
}
if (url.includes('.png') || url.includes('.svg')) {
throw e;
} else {
throw new Error(
`Failed to download file from "${url}". Error: ${getCacheClientErrorMessage(e)}`,
);
}
}
}
function isErrorUrlCacheActive(url: string): boolean {
const cachedAt = errorUrlCache[url];
if (cachedAt == null) {
return false;
}
if (Date.now() - cachedAt >= BANGDREAM_MISSING_URL_CACHE_EXPIRY_MS) {
delete errorUrlCache[url];
return false;
}
return true;
}
/**
* DirectoryExists
*
* @param filepath - filepath参数
*/
function ensureDirectoryExists(filepath: string) {
if (!fs.existsSync(filepath)) {
try {
fs.mkdirSync(filepath, { recursive: true });
} catch {}
}
}
/**
* JSONAndSave
*
* @param url -
* @param directory - directory参数使
* @param fileName - file名称参数使
* @param cacheTime - 使
* @returns
*/
export async function getJsonAndSave(
url: string,
directory?: string,
fileName?: string,
cacheTime = 0,
): Promise<object> {
try {
if (directory != undefined && fileName != undefined) {
ensureDirectoryExists(directory);
}
let eTag: string | undefined;
const cacheFilePath = path.join(directory || '', `${fileName || ''}`);
if (fileName && directory) {
const eTagFilePath = path.join(directory, `${fileName}.etag`);
eTag = fs.existsSync(eTagFilePath)
? fs.readFileSync(eTagFilePath, 'utf-8')
: undefined;
if (fs.existsSync(cacheFilePath)) {
const stat = fs.statSync(cacheFilePath);
const now = Date.now();
if (now - stat.mtimeMs < cacheTime * 1000) {
const cachedData = fs.readFileSync(cacheFilePath, 'utf-8');
const cachedJson = JSON.parse(cachedData);
return cachedJson;
}
}
}
const headers = eTag ? { 'If-None-Match': eTag } : {};
let response;
try {
response = await axios.get(url, {
headers,
responseType: 'arraybuffer',
timeout: getRequestTimeoutMs(),
});
} catch (error) {
if (error.response && error.response.status === 304) {
const cachedData = fs.readFileSync(cacheFilePath, 'utf-8');
const cachedJson = JSON.parse(cachedData);
return cachedJson;
} else {
throw error;
}
}
const fileBuffer = Buffer.from(response.data, 'binary');
const fileContent = fileBuffer.toString('utf-8');
const jsonObject = JSON.parse(fileContent);
const newETag = response.headers.etag;
if (newETag && directory && fileName) {
fs.writeFileSync(path.join(directory, `${fileName}.etag`), newETag);
}
if (directory && fileName) {
fs.writeFileSync(path.join(directory, fileName), fileContent);
}
return jsonObject;
} catch (e) {
throw e;
//throw new Error(`Failed to download JSON data from "${url}". Error: ${e.message}`);
}
}

View File

@ -1,58 +0,0 @@
import { Injectable } from '@nestjs/common';
import { formatKtDateTime, ToolsService } from '@/common';
import type { QqbotIntegrationPlugin } from '@/modules/qqbot/core/contract/qqbot.types';
import {
BANGDREAM_INPUT_SCHEMA,
BANGDREAM_OUTPUT_SCHEMA,
} from '@/modules/qqbot/plugins/bangDream/commands/qqbot-bangdream-command.definitions';
import { QqbotBangDreamClientService } from '@/modules/qqbot/plugins/bangDream/application/bangdream-client.service';
import { BANGDREAM_OPERATION_REGISTRY } from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
@Injectable()
export class QqbotBangDreamPluginService {
constructor(
private readonly bangDreamClientService: QqbotBangDreamClientService,
private readonly toolsService: ToolsService,
) {}
getPlugin(): QqbotIntegrationPlugin {
return {
description:
'合入 Tsugu BangDream Bot 后端开源源码,提供 BanG Dream! Girls Band Party 公开查询出图能力。',
healthCheck: async () => {
const checkedAt = formatKtDateTime(new Date());
try {
await this.bangDreamClientService.checkHealth();
return {
checkedAt,
message: 'BangDream 插件可用',
status: 'healthy',
};
} catch (err) {
return {
checkedAt,
message: this.toolsService.getErrorMessage(
err,
'BangDream 插件不可用',
),
status: 'degraded',
};
}
},
key: 'bangdream',
legacyKeys: ['bangDream'],
name: 'BangDream 查询',
operations: BANGDREAM_OPERATION_REGISTRY.map((operation) => ({
cacheTtlMs: 60_000,
description: operation.description,
inputSchema: BANGDREAM_INPUT_SCHEMA,
key: operation.key,
name: operation.name,
outputSchema: BANGDREAM_OUTPUT_SCHEMA,
execute: async (input) =>
await this.bangDreamClientService.execute(operation.key, input),
})),
version: '2.0.0',
};
}
}

View File

@ -1,42 +0,0 @@
import type { QqbotBangDreamOperationKey } from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
export type { QqbotBangDreamOperationKey } from '@/modules/qqbot/plugins/bangDream/registry/operation-registry';
export type QqbotBangDreamCommandInput = {
args?: string[];
cardId?: number | string;
compress?: boolean | string;
difficulty?: number | string;
difficultyText?: string;
displayedServerList?: Array<number | string> | string;
eventId?: number | string;
gachaId?: number | string;
mainServer?: number | string;
meta?: boolean | string;
playerId?: number | string;
query?: string;
raw?: string;
server?: number | string;
serverName?: number | string;
songId?: number | string;
text?: string;
tier?: number | string;
times?: number | string;
useEasyBG?: boolean | string;
};
export type QqbotBangDreamCommandOutput = {
imageCount: number;
operationKey: QqbotBangDreamOperationKey;
query: string;
replyText: string;
source: string;
};
export type QqbotBangDreamSongSearchInput = {
query?: string;
raw?: string;
text?: string;
};
export type QqbotBangDreamSongSummary = QqbotBangDreamCommandOutput;

View File

@ -1,219 +0,0 @@
export type QqbotBangDreamOperationHandlerName =
| 'getCardIllustration'
| 'getCutoffAll'
| 'getCutoffDetail'
| 'getCutoffRecent'
| 'getEventStage'
| 'getSongChart'
| 'getSongMeta'
| 'randomSong'
| 'searchCard'
| 'searchCharacter'
| 'searchEvent'
| 'searchGacha'
| 'searchPlayer'
| 'searchSong'
| 'simulateGacha';
export type TsuguOperationDefinition = {
description: string;
handlerName: QqbotBangDreamOperationHandlerName;
key: string;
name: string;
onlineCommand: {
aliases: readonly string[];
cooldownMs: number;
remark: string;
};
};
export const BANGDREAM_OPERATION_REGISTRY = [
{
description: '按歌曲名、昵称、标签或歌曲 ID 查询歌曲信息图片。',
handlerName: 'searchSong',
key: 'bangdream.song.search',
name: '查曲',
onlineCommand: {
aliases: ['查曲', 'bd', 'bangdream', 'bandori', '邦邦', '邦邦查歌'],
cooldownMs: 1500,
remark: '查询 BanG Dream 歌曲信息;格式:/查曲 歌曲名 或 /查曲 歌曲ID',
},
},
{
description: '按歌曲 ID 和难度查询谱面图片。',
handlerName: 'getSongChart',
key: 'bangdream.song.chart',
name: '查谱面',
onlineCommand: {
aliases: ['查谱面', '谱面', 'bd谱面'],
cooldownMs: 1500,
remark: '查询歌曲谱面;格式:/查谱面 歌曲ID [难度]',
},
},
{
description: '按关键词随机抽取一首歌曲并输出歌曲图片。',
handlerName: 'randomSong',
key: 'bangdream.song.random',
name: '随机曲',
onlineCommand: {
aliases: ['随机曲', '随机', 'bd随机'],
cooldownMs: 1500,
remark: '按关键词随机歌曲;格式:/随机曲 [关键词]',
},
},
{
description: '查询指定服务器歌曲分数榜图片。',
handlerName: 'getSongMeta',
key: 'bangdream.song.meta',
name: '查询分数表',
onlineCommand: {
aliases: ['查询分数表', '查分数表', '查询分数榜', '查分数榜', 'bd分数表'],
cooldownMs: 1500,
remark: '查询歌曲分数榜;格式:/查询分数表 [服务器]',
},
},
{
description: '按卡牌关键词或卡牌 ID 查询卡牌信息图片。',
handlerName: 'searchCard',
key: 'bangdream.card.search',
name: '查卡',
onlineCommand: {
aliases: ['查卡', '查卡牌', 'bd查卡'],
cooldownMs: 1500,
remark: '查询卡牌信息;格式:/查卡 卡牌关键词 或 /查卡 卡牌ID',
},
},
{
description: '按卡牌 ID 查询卡面插画图片。',
handlerName: 'getCardIllustration',
key: 'bangdream.card.illustration',
name: '查卡面',
onlineCommand: {
aliases: ['查卡面', '查卡插画', '查插画', 'bd卡面'],
cooldownMs: 1500,
remark: '查询卡牌插画;格式:/查卡面 卡牌ID',
},
},
{
description: '按角色关键词或角色 ID 查询角色信息图片。',
handlerName: 'searchCharacter',
key: 'bangdream.character.search',
name: '查角色',
onlineCommand: {
aliases: ['查角色', 'bd角色'],
cooldownMs: 1500,
remark: '查询角色信息;格式:/查角色 角色关键词 或 /查角色 角色ID',
},
},
{
description: '按活动关键词或活动 ID 查询活动信息图片。',
handlerName: 'searchEvent',
key: 'bangdream.event.search',
name: '查活动',
onlineCommand: {
aliases: ['查活动', 'bd活动'],
cooldownMs: 1500,
remark: '查询活动信息;格式:/查活动 活动关键词 或 /查活动 活动ID',
},
},
{
description: '查询当前或指定活动的试炼信息图片。',
handlerName: 'getEventStage',
key: 'bangdream.event.stage',
name: '查试炼',
onlineCommand: {
aliases: ['查试炼', '查stage', '查舞台', '查festival', '查5v5'],
cooldownMs: 1500,
remark: '查询活动试炼;格式:/查试炼 [活动ID] [-m]',
},
},
{
description: '按玩家 ID 和服务器查询玩家信息图片。',
handlerName: 'searchPlayer',
key: 'bangdream.player.search',
name: '查玩家',
onlineCommand: {
aliases: ['查玩家', '查询玩家', 'bd玩家'],
cooldownMs: 1500,
remark: '查询玩家信息;格式:/查玩家 玩家ID [服务器]',
},
},
{
description: '按卡池 ID 查询卡池信息图片。',
handlerName: 'searchGacha',
key: 'bangdream.gacha.search',
name: '查卡池',
onlineCommand: {
aliases: ['查卡池', 'bd卡池'],
cooldownMs: 1500,
remark: '查询卡池信息;格式:/查卡池 卡池ID',
},
},
{
description: '模拟指定次数抽卡并输出结果图片。',
handlerName: 'simulateGacha',
key: 'bangdream.gacha.simulate',
name: '抽卡模拟',
onlineCommand: {
aliases: ['抽卡模拟', 'bd抽卡'],
cooldownMs: 3000,
remark: '模拟抽卡;格式:/抽卡模拟 [次数] [卡池ID]',
},
},
{
description: '查询指定档位的当前活动档线与预测线图片。',
handlerName: 'getCutoffDetail',
key: 'bangdream.cutoff.detail',
name: 'ycx',
onlineCommand: {
aliases: ['ycx', '预测线', '查档线', 'bd档线'],
cooldownMs: 3000,
remark: '查询指定档位预测线;格式:/ycx 档位 [活动ID] [服务器]',
},
},
{
description: '查询当前活动所有档位的档线与预测线图片。',
handlerName: 'getCutoffAll',
key: 'bangdream.cutoff.all',
name: 'ycxall',
onlineCommand: {
aliases: ['ycxall', 'myycx', '全部档线'],
cooldownMs: 3000,
remark: '查询所有档位预测线;格式:/ycxall [活动ID] [服务器]',
},
},
{
description: '查询指定档位与最近同类型活动档线图片。',
handlerName: 'getCutoffRecent',
key: 'bangdream.cutoff.recent',
name: 'lsycx',
onlineCommand: {
aliases: ['lsycx', '历史档线', '近期档线'],
cooldownMs: 3000,
remark: '查询同类型活动档线;格式:/lsycx 档位 [活动ID] [服务器]',
},
},
] as const satisfies readonly TsuguOperationDefinition[];
export type QqbotBangDreamOperationKey =
(typeof BANGDREAM_OPERATION_REGISTRY)[number]['key'];
export type TsuguOperationRegistryItem =
(typeof BANGDREAM_OPERATION_REGISTRY)[number];
export const BANGDREAM_OPERATION_KEYS = BANGDREAM_OPERATION_REGISTRY.map(
(operation) => operation.key,
);
const BANGDREAM_OPERATION_BY_KEY = new Map<string, TsuguOperationRegistryItem>(
BANGDREAM_OPERATION_REGISTRY.map((operation) => [operation.key, operation]),
);
/**
* operation key BangDream Tsugu
*
* @param operationKey - BangDream operation key
*/
export function getBangDreamOperationDefinition(operationKey: string) {
return BANGDREAM_OPERATION_BY_KEY.get(operationKey);
}

View File

@ -1,19 +0,0 @@
import * as fs from 'fs';
import { fuzzySearchPath } from '@/modules/qqbot/plugins/bangDream/config/runtime-config';
import { logger } from '@/modules/qqbot/plugins/bangDream/shared/bangdream-logger';
import type { FuzzySearchConfig } from '@/modules/qqbot/plugins/bangDream/search/fuzzy-search.types';
export class SearchDictionaryRepository {
constructor(private readonly filePath = fuzzySearchPath) {}
/**
*
*/
loadConfig(): FuzzySearchConfig {
const fileContent = fs.readFileSync(this.filePath, 'utf-8');
logger('fuzzySearch', 'loaded fuzzy search config');
return JSON.parse(fileContent);
}
}
export const searchDictionaryRepository = new SearchDictionaryRepository();

View File

@ -1,3 +0,0 @@
export * from '@/modules/qqbot/plugins/bangDream/shared/bangdream-protocol';
export * from '@/modules/qqbot/plugins/bangDream/dictionary/default-dictionary';
export * from '@/modules/qqbot/plugins/bangDream/config/runtime-options';

View File

@ -1,162 +0,0 @@
import * as XLSX from 'xlsx';
import * as fs from 'fs';
/**
* BangDream JSON
*
* @param filepath - filepath参数
* @returns
*/
export async function readJSON(filepath: string): Promise<object> {
//读取json文件子程序返回json数据
const promise: object = new Promise(function (resolve) {
const rawdata = fs.readFileSync(filepath);
const rawstring = rawdata.toString();
const data: object = JSON.parse(rawstring);
resolve(data);
});
return promise;
}
/**
* BangDream JSONFrom缓冲区
*
* @param buffer -
* @returns
*/
export async function readJSONFromBuffer(buffer: Buffer): Promise<object> {
//读取json文件子程序返回json数据
const rawstring = buffer.toString();
const data: object = JSON.parse(rawstring);
return data;
}
/**
* BangDream JSON
*
* @param filepath - filepath参数
* @param data -
*/
export async function writeJSON(filepath: string, data: object) {
//写入json文件子程序
const rawdata = JSON.stringify(data);
fs.writeFileSync(filepath, rawdata);
}
/**
* BangDream ExcelFile
*
* @param filePath -
* @returns
*/
export async function readExcelFile<
T extends Record<string, unknown> = Record<string, unknown>,
>(filePath: string): Promise<T[]> {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
return XLSX.utils.sheet_to_json<T>(worksheet);
}
//将string[]转变为number[]
/**
* BangDream stringTo数字Array
*
* @param stringArray - stringArray参数
* @returns
*/
export function stringToNumberArray(
stringArray: Array<string | null>,
): number[] {
const numberArray: number[] = [];
for (let i = 0; i < stringArray.length; i++) {
if (stringArray[i] == null) {
numberArray.push(null);
} else {
numberArray.push(Number(stringArray[i]));
}
}
return numberArray;
}
/**
* BangDream
*
* @param num - num参数
* @param length - length参数
* @returns
*/
export function formatNumber(num: number, length: number): string {
// 将数字转换为字符串
const str = num.toString();
// 如果字符串长度小于3前面补0直到长度为3
if (str.length < length) {
return str.padStart(length, '0');
}
return str;
}
//栈函数
export class Stack<T> {
stack: T[];
private maxLength: number;
/**
* Stack
*
* @param maxLength - maxLength参数
*/
constructor(maxLength: number) {
this.stack = [];
this.maxLength = maxLength;
}
/**
* Stack
*
* @param item -
*/
push(item: T): void {
this.stack.unshift(item); // 将新元素插入到堆栈的最前面
if (this.stack.length > this.maxLength) {
this.stack.pop(); // 如果堆栈长度超过指定的最大长度,自动弹出最后一个元素
}
}
/**
* Stack pop
*
* @returns
*/
pop(): T | undefined {
return this.stack.shift(); // 弹出并返回最前面的元素
}
/**
* Stack Empty
*
* @returns
*/
isEmpty(): boolean {
return this.stack.length === 0;
}
/**
* Stack size
*
* @returns
*/
size(): number {
return this.stack.length;
}
/**
* Stack
*/
clear(): void {
this.stack = [];
}
}

View File

@ -6,7 +6,8 @@
"description": "BanG Dream! Girls Band Party 查询、出图和档线能力。", "description": "BanG Dream! Girls Band Party 查询、出图和档线能力。",
"author": "KT", "author": "KT",
"license": "MIT", "license": "MIT",
"entry": "qqbot-bangdream.plugin.ts", "entry": "src/index.ts",
"legacyAliases": ["bangDream"],
"permissions": [ "permissions": [
"qqbot.send", "qqbot.send",
"runtime.http", "runtime.http",
@ -260,7 +261,7 @@
"assets": [ "assets": [
{ {
"key": "bangdream-assets", "key": "bangdream-assets",
"path": "assets" "path": "src/assets"
} }
], ],
"migrations": [] "migrations": []

View File

@ -0,0 +1,248 @@
import { Server } from '@/modules/qqbot/plugins/bangdream/src/domain/catalog/server.model';
import {
BangDreamDictionaryLoader,
type BangDreamDictionaryItem,
} from '@/modules/qqbot/plugins/bangdream/src/config/dictionary/dictionary-loader';
import {
BANGDREAM_TSUGU_ENV_KEYS,
normalizeBangDreamBoolean,
splitBangDreamOptionList,
} from '@/modules/qqbot/plugins/bangdream/src/config/runtime-options';
import {
fuzzySearch,
type FuzzySearchResult,
} from '@/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search';
import mainAPI, {
waitForMainDataReady,
} from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store';
import type {
QqbotBangDreamCommandInput,
QqbotBangDreamCommandOutput,
QqbotBangDreamOperationKey,
} from '@/modules/qqbot/plugins/bangdream/src/domain/common/qqbot-bangdream.types';
const SOURCE_NAME = 'BangDream 内置插件';
export type BangDreamConfigReader = {
get<T = string>(key: string): T | undefined;
};
export type BangDreamDictionaryReader = {
getDictItemsByKey(dictCode: string): Promise<
Array<{
label: string;
value: string;
}>
>;
};
export type BangDreamCommandContextOptions = {
configReader?: BangDreamConfigReader;
dictionaryReader?: BangDreamDictionaryReader;
};
export class BangDreamCommandContext {
private readonly configReader?: BangDreamConfigReader;
private readonly dictionaryLoader = new BangDreamDictionaryLoader();
private readonly dictionaryReader?: BangDreamDictionaryReader;
constructor(options: BangDreamCommandContextOptions = {}) {
this.configReader = options.configReader;
this.dictionaryReader = options.dictionaryReader;
}
async refreshDictionaryCache() {
await this.dictionaryLoader.refresh((dictCode) =>
this.fetchDictionaryItems(dictCode),
);
}
async checkHealth() {
await waitForMainDataReady();
const data = mainAPI as { cards?: unknown; songs?: unknown };
if (!data.songs || !data.cards) {
throw new Error('BangDream 数据配置未加载');
}
fuzzySearch('夏祭り');
return true;
}
async drawFuzzyResult(
query: string,
render: (matches: FuzzySearchResult) => Promise<Array<Buffer | string>>,
) {
const matches = fuzzySearch(query);
if (Object.keys(matches).length === 0) {
return ['错误: 没有有效的关键词'];
}
return await render(matches);
}
toImageReply(
operationKey: QqbotBangDreamOperationKey,
query: string,
list: Array<Buffer | string>,
): QqbotBangDreamCommandOutput {
const images = list.filter((item): item is Buffer => Buffer.isBuffer(item));
if (images.length === 0) {
const message =
list.find((item): item is string => typeof item === 'string') ||
'BangDream 未返回图片';
throw new Error(message);
}
return {
imageCount: images.length,
operationKey,
query,
replyText: images
.map((item) => `[CQ:image,file=base64://${item.toString('base64')}]`)
.join('\n'),
source: SOURCE_NAME,
};
}
getRenderOptions(
input: QqbotBangDreamCommandInput,
defaults: { useEasyBG?: boolean } = {},
) {
return {
compress: normalizeBangDreamBoolean(
input.compress,
normalizeBangDreamBoolean(
this.readConfig(BANGDREAM_TSUGU_ENV_KEYS.compress),
true,
),
),
displayedServerList: this.pickDisplayedServerList(input),
mainServer: this.pickMainServer(input, []),
useEasyBG: normalizeBangDreamBoolean(
input.useEasyBG,
normalizeBangDreamBoolean(
this.readConfig(BANGDREAM_TSUGU_ENV_KEYS.useEasyBg),
defaults.useEasyBG ?? false,
),
),
};
}
pickDisplayedServerList(input: QqbotBangDreamCommandInput) {
const source =
input.displayedServerList ||
this.readConfig(BANGDREAM_TSUGU_ENV_KEYS.displayedServers);
const defaultServers = this.dictionaryLoader.getDefaultDisplayedServers();
if (!source) return defaultServers;
const values = splitBangDreamOptionList(source);
const servers = values
.map((item) => this.normalizeServer(item))
.filter((item) => item !== undefined) as Server[];
return servers.length > 0 ? [...new Set(servers)] : defaultServers;
}
pickMainServer(input: QqbotBangDreamCommandInput, tokens: string[]): Server {
const explicit = this.firstDefined(
input.mainServer,
input.serverName,
input.server,
tokens.find((item) => this.normalizeServer(item) !== undefined) ||
this.readConfig(BANGDREAM_TSUGU_ENV_KEYS.mainServer),
);
return this.normalizeServer(explicit) ?? Server.cn;
}
pickDifficulty(value: unknown) {
const source = `${value || ''}`.trim();
if (!source) return undefined;
const numeric = this.optionalNumber(source);
if (numeric !== undefined) return numeric;
const alias = this.dictionaryLoader.resolveDifficulty(source);
if (alias !== undefined) return alias;
const matched = fuzzySearch(source)?.difficulty?.[0];
return typeof matched === 'number' ? matched : undefined;
}
normalizeServer(value: unknown): Server | undefined {
if (value === undefined || value === null || value === '') return undefined;
const raw = `${value}`.trim();
const numeric = Number(raw);
if (Number.isInteger(numeric) && numeric >= 0 && numeric <= 4) {
return numeric as Server;
}
const server = this.dictionaryLoader.resolveServer(raw);
return server === undefined ? undefined : (server as Server);
}
requireText(input: QqbotBangDreamCommandInput, message: string) {
const text = this.pickText(input);
if (!text) throw new Error(message);
return text;
}
pickText(input: QqbotBangDreamCommandInput) {
return `${input.query || input.text || input.raw || ''}`.trim();
}
getTokens(input: QqbotBangDreamCommandInput) {
if (Array.isArray(input.args)) {
return input.args.map((item) => `${item}`.trim()).filter(Boolean);
}
return this.pickText(input).split(/\s+/).filter(Boolean);
}
firstToken(input: QqbotBangDreamCommandInput) {
return this.getTokens(input)[0];
}
requireNumber(explicit: unknown, fallback: unknown, message: string) {
const value =
this.optionalNumber(explicit) ?? this.optionalNumber(fallback);
if (value === undefined) throw new Error(message);
return value;
}
optionalNumber(value: unknown) {
if (value === undefined || value === null || value === '') return undefined;
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : undefined;
}
firstNumber(tokens: string[]) {
return tokens
.map((item) => this.optionalNumber(item))
.find((item) => item !== undefined);
}
secondNumber(tokens: string[]) {
return tokens
.map((item) => this.optionalNumber(item))
.filter((item) => item !== undefined)[1];
}
normalizeBoolean(value: unknown, fallback: boolean) {
return normalizeBangDreamBoolean(value, fallback);
}
isInteger(value: string) {
return /^(0|[1-9]\d*)$/.test(value);
}
private firstDefined(...values: unknown[]) {
return values.find(
(value) => value !== undefined && value !== null && value !== '',
);
}
private async fetchDictionaryItems(
dictCode: string,
): Promise<BangDreamDictionaryItem[]> {
if (!this.dictionaryReader) return [];
const items = await this.dictionaryReader.getDictItemsByKey(dictCode);
return items.map(({ label, value }) => ({
label,
value,
}));
}
private readConfig(key: string) {
return this.configReader?.get<string>(key);
}
}

View File

@ -1,7 +1,3 @@
import { Logger } from '@nestjs/common';
const tsuguLogger = new Logger('BangDreamTsugu');
/** /**
* logger * logger
* *
@ -9,5 +5,5 @@ const tsuguLogger = new Logger('BangDreamTsugu');
* @param message - message参数 * @param message - message参数
*/ */
export function logger(type: string, message: unknown) { export function logger(type: string, message: unknown) {
tsuguLogger.log(`[${type}] ${String(message)}`); console.info(`[BangDream][${type}] ${String(message)}`);
} }

View File

@ -1,75 +1,78 @@
import type { import type {
QqbotBangDreamCommandInput, QqbotBangDreamCommandInput,
QqbotBangDreamOperationKey, QqbotBangDreamOperationKey,
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types'; } from '@/modules/qqbot/plugins/bangdream/src/domain/common/qqbot-bangdream.types';
import { logger } from '@/modules/qqbot/plugins/bangDream/shared/bangdream-logger'; import { logger } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger';
export type TsuguExecutionStage = export type BangDreamExecutionStage =
| 'handler' | 'handler'
| 'mainData' | 'mainData'
| 'operation' | 'operation'
| 'output' | 'output'
| 'start'; | 'start';
export type TsuguHookContext = { export type BangDreamHookContext = {
handlerName?: string; handlerName?: string;
imageCount?: number; imageCount?: number;
input: QqbotBangDreamCommandInput; input: QqbotBangDreamCommandInput;
operationKey: QqbotBangDreamOperationKey; operationKey: QqbotBangDreamOperationKey;
query?: string; query?: string;
stage: TsuguExecutionStage; stage: BangDreamExecutionStage;
startedAt: number; startedAt: number;
}; };
export type TsuguHook = { export type BangDreamHook = {
afterOutput?: (context: TsuguHookContext) => Promise<void> | void; afterOutput?: (context: BangDreamHookContext) => Promise<void> | void;
afterResolve?: (context: TsuguHookContext) => Promise<void> | void; afterResolve?: (context: BangDreamHookContext) => Promise<void> | void;
beforeParse?: (context: TsuguHookContext) => Promise<void> | void; beforeParse?: (context: BangDreamHookContext) => Promise<void> | void;
beforeRender?: (context: TsuguHookContext) => Promise<void> | void; beforeRender?: (context: BangDreamHookContext) => Promise<void> | void;
name: string; name: string;
onError?: (context: TsuguHookContext, error: unknown) => Promise<void> | void; onError?: (
context: BangDreamHookContext,
error: unknown,
) => Promise<void> | void;
order?: number; order?: number;
}; };
type TsuguSimpleHookMethod = type BangDreamSimpleHookMethod =
| 'afterOutput' | 'afterOutput'
| 'afterResolve' | 'afterResolve'
| 'beforeParse' | 'beforeParse'
| 'beforeRender'; | 'beforeRender';
/** export class BangDreamHookRegistry {
* Tsugu hook private readonly hooks: BangDreamHook[];
*/
export class TsuguHookRegistry {
private readonly hooks: TsuguHook[];
constructor(hooks: readonly TsuguHook[] = []) { constructor(hooks: readonly BangDreamHook[] = []) {
this.hooks = [...hooks].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); this.hooks = [...hooks].sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
} }
async beforeParse(context: TsuguHookContext) { async beforeParse(context: BangDreamHookContext) {
await this.emit('beforeParse', context); await this.emit('beforeParse', context);
} }
async afterResolve(context: TsuguHookContext) { async afterResolve(context: BangDreamHookContext) {
await this.emit('afterResolve', context); await this.emit('afterResolve', context);
} }
async beforeRender(context: TsuguHookContext) { async beforeRender(context: BangDreamHookContext) {
await this.emit('beforeRender', context); await this.emit('beforeRender', context);
} }
async afterOutput(context: TsuguHookContext) { async afterOutput(context: BangDreamHookContext) {
await this.emit('afterOutput', context); await this.emit('afterOutput', context);
} }
async onError(context: TsuguHookContext, error: unknown) { async onError(context: BangDreamHookContext, error: unknown) {
for (const hook of this.hooks) { for (const hook of this.hooks) {
await hook.onError?.(context, error); await hook.onError?.(context, error);
} }
} }
private async emit(method: TsuguSimpleHookMethod, context: TsuguHookContext) { private async emit(
method: BangDreamSimpleHookMethod,
context: BangDreamHookContext,
) {
for (const hook of this.hooks) { for (const hook of this.hooks) {
const handler = hook[method]; const handler = hook[method];
if (typeof handler === 'function') { if (typeof handler === 'function') {
@ -79,46 +82,40 @@ export class TsuguHookRegistry {
} }
} }
/** export function createBangDreamHookContext(
* hook
*/
export function createTsuguHookContext(
operationKey: QqbotBangDreamOperationKey, operationKey: QqbotBangDreamOperationKey,
input: QqbotBangDreamCommandInput, input: QqbotBangDreamCommandInput,
): TsuguHookContext { ): BangDreamHookContext {
return { return {
input, input,
operationKey, operationKey,
query: extractTsuguInputText(input), query: extractBangDreamInputText(input),
stage: 'start', stage: 'start',
startedAt: Date.now(), startedAt: Date.now(),
}; };
} }
/** export function createBangDreamLogHook(): BangDreamHook {
* hook
*/
export function createTsuguLogHook(): TsuguHook {
return { return {
afterOutput: (context) => { afterOutput: (context) => {
logger('operation', formatTsuguHookMessage('success', context)); logger('operation', formatBangDreamHookMessage('success', context));
}, },
beforeParse: (context) => { beforeParse: (context) => {
logger('operation', formatTsuguHookMessage('start', context)); logger('operation', formatBangDreamHookMessage('start', context));
}, },
name: 'TsuguLogHook', name: 'BangDreamLogHook',
onError: (context, error) => { onError: (context, error) => {
logger( logger(
'operation', 'operation',
`${formatTsuguHookMessage('error', context)} error=${getHookErrorMessage(error)}`, `${formatBangDreamHookMessage('error', context)} error=${getHookErrorMessage(error)}`,
); );
}, },
}; };
} }
function formatTsuguHookMessage( function formatBangDreamHookMessage(
status: 'error' | 'start' | 'success', status: 'error' | 'start' | 'success',
context: TsuguHookContext, context: BangDreamHookContext,
) { ) {
const durationMs = Date.now() - context.startedAt; const durationMs = Date.now() - context.startedAt;
return [ return [
@ -134,7 +131,7 @@ function formatTsuguHookMessage(
.join(' '); .join(' ');
} }
function extractTsuguInputText(input: QqbotBangDreamCommandInput) { function extractBangDreamInputText(input: QqbotBangDreamCommandInput) {
const direct = `${input.query || input.text || input.raw || ''}`.trim(); const direct = `${input.query || input.text || input.raw || ''}`.trim();
if (direct) return direct; if (direct) return direct;
return Array.isArray(input.args) ? input.args.join(' ').trim() : ''; return Array.isArray(input.args) ? input.args.join(' ').trim() : '';

View File

@ -1,11 +1,15 @@
import { bestdoriApiPath } from '@/modules/qqbot/plugins/bangDream/config/runtime-config'; import { bestdoriApiPath } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config';
import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangDream/provider/bestdori.provider'; import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider';
import { bangDreamStaticPatchProvider } from '@/modules/qqbot/plugins/bangDream/provider/static-patch.provider'; import { bangDreamStaticPatchProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/static-patch.provider';
import { logger } from '@/modules/qqbot/plugins/bangDream/shared/bangdream-logger'; import { logger } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger';
import { import {
BANGDREAM_TSUGU_ENV_KEYS, BANGDREAM_TSUGU_ENV_KEYS,
normalizeBangDreamPositiveInteger, normalizeBangDreamPositiveInteger,
} from '@/modules/qqbot/plugins/bangDream/config/runtime-options'; } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-options';
import {
readBangDreamRuntimeConfig,
sleepBangDreamRuntime,
} from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io';
const mainAPI: Record<string, any> = {}; //main对象,用于存放所有api数据,数据来源于Bestdori网站 const mainAPI: Record<string, any> = {}; //main对象,用于存放所有api数据,数据来源于Bestdori网站
const REQUIRED_MAIN_DATA_KEYS = [ const REQUIRED_MAIN_DATA_KEYS = [
@ -19,7 +23,7 @@ const DEFAULT_MAIN_DATA_READY_TIMEOUT_MS = 15000;
function getMainDataReadyTimeoutMs(): number { function getMainDataReadyTimeoutMs(): number {
return normalizeBangDreamPositiveInteger( return normalizeBangDreamPositiveInteger(
process.env[BANGDREAM_TSUGU_ENV_KEYS.mainDataReadyTimeoutMs], readBangDreamRuntimeConfig(BANGDREAM_TSUGU_ENV_KEYS.mainDataReadyTimeoutMs),
DEFAULT_MAIN_DATA_READY_TIMEOUT_MS, DEFAULT_MAIN_DATA_READY_TIMEOUT_MS,
); );
} }
@ -32,7 +36,7 @@ function isMainDataReady(): boolean {
} }
async function rejectAfter(ms: number): Promise<never> { async function rejectAfter(ms: number): Promise<never> {
await new Promise((resolve) => setTimeout(resolve, ms)); await sleepBangDreamRuntime(ms);
throw new Error(`BangDream 主数据首次加载超时:${ms}ms`); throw new Error(`BangDream 主数据首次加载超时:${ms}ms`);
} }
@ -104,14 +108,18 @@ async function loadMainAPI(useCache: boolean = false) {
logger('mainAPI', 'mainAPI loaded'); logger('mainAPI', 'mainAPI loaded');
} }
logger('mainAPI', 'initializing...'); let initialLoadPromise: Promise<void> | undefined;
const initialLoadPromise = loadMainAPI(true).then(() => {
logger('mainAPI', 'initializing done');
loadMainAPI();
});
const refreshTimer = setInterval(loadMainAPI, 1000 * 60 * 5); //5分钟更新一次 function ensureMainDataInitialLoad() {
refreshTimer.unref?.(); if (!initialLoadPromise) {
logger('mainAPI', 'initializing...');
initialLoadPromise = loadMainAPI(true).then(async () => {
logger('mainAPI', 'initializing done');
await loadMainAPI();
});
}
return initialLoadPromise;
}
/** /**
* BangDream * BangDream
@ -121,7 +129,7 @@ export async function waitForMainDataReady(): Promise<void> {
return; return;
} }
await Promise.race([ await Promise.race([
initialLoadPromise, ensureMainDataInitialLoad(),
rejectAfter(getMainDataReadyTimeoutMs()), rejectAfter(getMainDataReadyTimeoutMs()),
]); ]);
if (!isMainDataReady()) { if (!isMainDataReady()) {

View File

@ -1,5 +1,5 @@
import mainAPI from '@/modules/qqbot/plugins/bangDream/shared/main-data-store'; import mainAPI from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store';
import type { BANGDREAM_BESTDORI_API_PATHS } from '@/modules/qqbot/plugins/bangDream/shared/bangdream-protocol'; import type { BANGDREAM_BESTDORI_API_PATHS } from '@/modules/qqbot/plugins/bangdream/src/domain/common/bangdream-protocol';
export type BangDreamMainDataKey = keyof typeof BANGDREAM_BESTDORI_API_PATHS; export type BangDreamMainDataKey = keyof typeof BANGDREAM_BESTDORI_API_PATHS;
export type BangDreamMainDataCollection<T = unknown> = Record<string, T>; export type BangDreamMainDataCollection<T = unknown> = Record<string, T>;

View File

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

Before

Width:  |  Height:  |  Size: 445 KiB

After

Width:  |  Height:  |  Size: 445 KiB

View File

Before

Width:  |  Height:  |  Size: 594 KiB

After

Width:  |  Height:  |  Size: 594 KiB

View File

Before

Width:  |  Height:  |  Size: 8.9 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

Before

Width:  |  Height:  |  Size: 524 B

After

Width:  |  Height:  |  Size: 524 B

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 223 B

After

Width:  |  Height:  |  Size: 223 B

View File

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

View File

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

View File

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View File

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

View File

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

View File

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 457 B

After

Width:  |  Height:  |  Size: 457 B

View File

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View File

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

View File

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

Before

Width:  |  Height:  |  Size: 247 B

After

Width:  |  Height:  |  Size: 247 B

View File

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

Before

Width:  |  Height:  |  Size: 6.8 KiB

After

Width:  |  Height:  |  Size: 6.8 KiB

View File

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Some files were not shown because too many files have changed in this diff Show More