refactor: 收敛QQBot插件包第三期结构
10
API.md
@ -449,7 +449,7 @@ pnpm qqbot-plugin install-local <packageFile>
|
||||
|
||||
### 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 | 命令 | 说明 |
|
||||
| ----------------------------- | ------------- | ------------------------ |
|
||||
@ -469,11 +469,11 @@ pnpm qqbot-plugin install-local <packageFile>
|
||||
| `bangdream.cutoff.all` | `/ycxall` | 全档位预测线 |
|
||||
| `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
|
||||
|
||||
插件 key:`ff14-market`。旧 `ff14Market` 作为兼容别名仍可解析。
|
||||
插件 key:`ff14-market`。旧 `ff14Market` 作为兼容别名仍可解析;源码按第三期插件结构拆分为 `operations`、`application`、`domain`、`infrastructure/integration` 和 `config`。
|
||||
|
||||
| operation key | 说明 |
|
||||
| ------------------- | ------------------------------------------ |
|
||||
@ -486,6 +486,8 @@ pnpm qqbot-plugin install-local <packageFile>
|
||||
|
||||
插件 key:`fflogs`。
|
||||
|
||||
源码按第三期插件结构拆分为 `operations`、`application`、`domain`、`infrastructure/integration`、`infrastructure/storage` 和 `config`。
|
||||
|
||||
| operation key | 说明 |
|
||||
| -------------------------- | --------------------------------------------------------------- |
|
||||
| `fflogs.character.summary` | 查询 FFLogs 角色公开排名;传 `encounter` 时查询指定高难最近记录 |
|
||||
@ -527,7 +529,7 @@ BangDream 图片 smoke:
|
||||
|
||||
```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.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;推送成功不等于发布完成。
|
||||
|
||||
11
README.md
@ -23,8 +23,8 @@
|
||||
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
||||
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、受控 SDK 和 CLI 脚手架 |
|
||||
| `qqbot/plugins/bangDream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
||||
| `qqbot/plugins/ff14Market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
||||
| `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
||||
| `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
||||
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
|
||||
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
|
||||
| `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 新设备验证走同一 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` 站内信告警;看门狗不自动进入扫码阶段。
|
||||
- BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
|
||||
- BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。
|
||||
- BangDream 当前源码根目录是 `src/modules/qqbot/plugins/bangdream/src`;按第三期插件结构放置真实职责代码:业务在 `domain/*`,编排在 `application`,操作在 `operations`,外部 API 在 `infrastructure/integration`,缓存/静态修正在 `infrastructure/storage`,字典和静态配置在 `config`,视觉渲染公共件在 `theme`;不要恢复旧 `tsugu` 层级、旧大桶目录、纯 re-export 转接文件或空 `.gitkeep` 目录壳。
|
||||
- BangDream 在线命令以 `plugins/bangdream/plugin.json` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 manifest/command-SQL 测试。
|
||||
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。
|
||||
|
||||
## 轻量验证
|
||||
@ -184,6 +184,7 @@ BangDream 图片能力改动:
|
||||
|
||||
```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.event.stage -Text "310" -OutFile ".kt-workspace/bangdream-smoke/stage.jpg" -ExpectedImageCount 5
|
||||
```
|
||||
|
||||
接口改动必须启动或复用本地服务,并真实调用一次对应接口。
|
||||
@ -196,4 +197,4 @@ BangDream 图片能力改动:
|
||||
|
||||
| 一级来源 | 使用方式 | 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 |
|
||||
|
||||
@ -849,14 +849,14 @@ Run:
|
||||
```powershell
|
||||
$api = 'D:\MyFiles\KT\Node\kt-template-online-api'
|
||||
$candidateFiles = @(
|
||||
'src/modules/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts',
|
||||
'src/modules/qqbot/plugins/ff14Market/qqbot-ff14-worlds.ts',
|
||||
'src/modules/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin.ts',
|
||||
'src/modules/qqbot/plugins/ff14-market/qqbot-ff14-client.service.ts',
|
||||
'src/modules/qqbot/plugins/ff14-market/qqbot-ff14-worlds.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.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/qqbot-bangdream.plugin.ts'
|
||||
'src/modules/qqbot/plugins/bangdream/src/application/bangdream-renderer.facade.ts',
|
||||
'src/modules/qqbot/plugins/bangdream/src/qqbot-bangdream.plugin.ts'
|
||||
)
|
||||
foreach ($file in $candidateFiles) {
|
||||
$absolute = Join-Path $api $file
|
||||
@ -878,7 +878,7 @@ Record each deletion or keep decision in the inventory table.
|
||||
Run:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
|
||||
@ -1191,13 +1191,13 @@ git -C D:\MyFiles\KT\Vue\kt-template-admin commit -m "feat: 增加QQBot插件管
|
||||
|
||||
**Files:**
|
||||
|
||||
- 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\**`
|
||||
- Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugins\bangDream\**`
|
||||
- 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\**`
|
||||
- Test: `D:\MyFiles\KT\Node\kt-template-online-api\test\modules\qqbot\plugins\bangdream\**`
|
||||
|
||||
- [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**
|
||||
|
||||
@ -1208,22 +1208,22 @@ Keep operation keys and aliases stable. `handlerName` becomes worker-internal.
|
||||
Run:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
**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\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\repeater\**`
|
||||
|
||||
@ -1244,13 +1244,13 @@ Use host send queue and account binding. Do not bypass rate limit.
|
||||
Run:
|
||||
|
||||
```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
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -34,7 +34,7 @@ This plan is complete only when all of these are true:
|
||||
- `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`
|
||||
- `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`
|
||||
- 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:
|
||||
|
||||
```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插件分页与新设备二维码"
|
||||
```
|
||||
|
||||
@ -472,7 +472,7 @@ powershell -ExecutionPolicy Bypass -File D:\MyFiles\KT\Node\kt-template-online-a
|
||||
|
||||
```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 -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插件"
|
||||
```
|
||||
|
||||
@ -533,7 +533,7 @@ pnpm --dir D:\MyFiles\KT\Node\kt-template-online-api run typecheck
|
||||
|
||||
```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 -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内置插件包"
|
||||
```
|
||||
|
||||
|
||||
@ -5,11 +5,19 @@
|
||||
"compilerOptions": {
|
||||
"assets": [
|
||||
{
|
||||
"include": "modules/qqbot/plugins/bangDream/assets/**/*",
|
||||
"include": "modules/qqbot/plugins/*/plugin.json",
|
||||
"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"
|
||||
}
|
||||
],
|
||||
|
||||
@ -4,6 +4,7 @@ param(
|
||||
[string]$DisplayedServerList = "cn jp",
|
||||
[string]$OutFile = ".kt-workspace/bangdream-smoke/bangdream-smoke.jpg",
|
||||
[int]$TimeoutSeconds = 45,
|
||||
[int]$ExpectedImageCount = 0,
|
||||
[switch]$UseEasyBg
|
||||
)
|
||||
|
||||
@ -38,6 +39,7 @@ $Payload = @{
|
||||
}
|
||||
operationKey = $OperationKey
|
||||
outFile = $ResolvedOutFile
|
||||
expectedImageCount = $ExpectedImageCount
|
||||
}
|
||||
$PayloadJson = $Payload | ConvertTo-Json -Compress -Depth 5
|
||||
$PayloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($PayloadJson))
|
||||
@ -45,17 +47,96 @@ $PayloadBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Paylo
|
||||
$NodeCode = @"
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { ConfigService } = require("@nestjs/config");
|
||||
const XLSX = require("xlsx");
|
||||
const payload = JSON.parse(Buffer.from("$PayloadBase64", "base64").toString("utf8"));
|
||||
const { ToolsService } = require("./src/common/services/tool.service");
|
||||
const { QqbotBangDreamRendererService } = require("./src/modules/qqbot/plugins/bangDream/application/bangdream-renderer.facade");
|
||||
const { TsuguApplicationService } = require("./src/modules/qqbot/plugins/bangDream/application/bangdream-application.service");
|
||||
const { createPlugin } = require("./src/modules/qqbot/plugins/bangdream/src");
|
||||
|
||||
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 () => {
|
||||
const renderer = new QqbotBangDreamRendererService(new ConfigService({}), undefined);
|
||||
const service = new TsuguApplicationService(renderer, new ToolsService());
|
||||
await service.onApplicationBootstrap();
|
||||
const result = await service.execute(payload.operationKey, payload.input);
|
||||
const manifest = readJsonFile(path.join(process.cwd(), "src/modules/qqbot/plugins/bangdream/plugin.json"));
|
||||
const operations = manifest.operations.map((operation) => ({
|
||||
handlerName: operation.handlerName,
|
||||
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)];
|
||||
if (matches.length === 0) throw new Error("No image CQ payload");
|
||||
fs.mkdirSync(path.dirname(payload.outFile), { recursive: true });
|
||||
|
||||
@ -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`)
|
||||
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 角色名 服务器 [高难任务]'),
|
||||
(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 [难度]'),
|
||||
(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, '查询歌曲分数榜;格式:/查询分数表 [服务器]'),
|
||||
(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'),
|
||||
(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'),
|
||||
(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 [服务器]'),
|
||||
(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]'),
|
||||
(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] [服务器]'),
|
||||
(2041700000000300517, 'bangdream_cutoff_recent', 'BangDream lsycx', '["lsycx","历史档线","近期档线"]', '["/","!","!"]', 'bangDream', 'bangdream.cutoff.recent', 'plain', 'all', '{}', '', 'BangDream lsycx 失败:{{error}}', 1, 0, 3000, '查询同类型活动档线;格式:/lsycx 档位 [活动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 [难度]'),
|
||||
(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, '查询歌曲分数榜;格式:/查询分数表 [服务器]'),
|
||||
(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'),
|
||||
(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'),
|
||||
(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 [服务器]'),
|
||||
(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]'),
|
||||
(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] [服务器]'),
|
||||
(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
|
||||
`code` = VALUES(`code`),
|
||||
`name` = VALUES(`name`),
|
||||
|
||||
@ -274,21 +274,178 @@ INSERT INTO qqbot_command (
|
||||
plugin_key,
|
||||
enabled,
|
||||
cooldown_seconds
|
||||
) VALUES (
|
||||
1000000000000000201,
|
||||
'bangdream.song.search',
|
||||
'bangdream_song',
|
||||
'bd',
|
||||
'BangDream 查歌',
|
||||
'["查曲","bd","bangdream","bandori","邦邦","邦邦查歌"]',
|
||||
'bangdream',
|
||||
1,
|
||||
2
|
||||
) ON DUPLICATE KEY UPDATE
|
||||
) VALUES
|
||||
(
|
||||
1000000000000000201,
|
||||
'bangdream.song.search',
|
||||
'bangdream_song',
|
||||
'bd',
|
||||
'BangDream 查歌',
|
||||
'["查曲","bd","bangdream","bandori","邦邦","邦邦查歌"]',
|
||||
'bangdream',
|
||||
1,
|
||||
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),
|
||||
code = VALUES(code),
|
||||
name = VALUES(name),
|
||||
aliases = VALUES(aliases),
|
||||
plugin_key = VALUES(plugin_key),
|
||||
enabled = VALUES(enabled),
|
||||
cooldown_seconds = VALUES(cooldown_seconds);
|
||||
cooldown_seconds = VALUES(cooldown_seconds),
|
||||
is_deleted = 0;
|
||||
|
||||
@ -31,6 +31,13 @@ WHERE command_key = 'bangdream_song'
|
||||
AND plugin_key = 'bangdream'
|
||||
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
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = DATABASE()
|
||||
|
||||
@ -9,8 +9,8 @@ import {
|
||||
isQqbotFf14WorldName,
|
||||
QQBOT_FF14_MARKET_DICT_CODES,
|
||||
splitQqbotFf14WorldPath,
|
||||
} from '@/modules/qqbot/plugins/ff14Market/qqbot-ff14-worlds';
|
||||
import type { QqbotFf14MarketCatalog } from '@/modules/qqbot/plugins/ff14Market/qqbot-ff14-market.types';
|
||||
} from '@/modules/qqbot/plugins/ff14-market/src';
|
||||
import type { QqbotFf14MarketCatalog } from '@/modules/qqbot/plugins/ff14-market/src';
|
||||
import type { QqbotPluginExecutionInput } from '@/modules/qqbot/core/domain/plugin-execution.port';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@ -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 { 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 {
|
||||
QqbotEventPluginDefinition,
|
||||
QqbotNormalizedMessage,
|
||||
QqbotPluginHealth,
|
||||
QqbotPluginOperationSummary,
|
||||
} 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()
|
||||
export class QqbotEventPluginRegistryService {
|
||||
private readonly logger = new Logger(QqbotEventPluginRegistryService.name);
|
||||
private readonly repeaterPlugin: ReturnType<typeof createRepeaterPlugin>;
|
||||
|
||||
constructor(
|
||||
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[] {
|
||||
return this.getDefinitions(pluginKey);
|
||||
@ -95,4 +127,25 @@ export class QqbotEventPluginRegistryService {
|
||||
? definitions.filter((definition) => definition.key === pluginKey)
|
||||
: 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;
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { formatKtDateTime, throwVbenError } from '@/common';
|
||||
import { QqbotBangDreamPluginService } from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.plugin';
|
||||
import { QqbotFf14MarketPluginService } from '@/modules/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin';
|
||||
import { QqbotFflogsPluginService } from '@/modules/qqbot/plugins/fflogs/qqbot-fflogs.plugin';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import * as XLSX from 'xlsx';
|
||||
import { formatKtDateTime, throwVbenError, ToolsService } from '@/common';
|
||||
import { DictService } from '@/modules/admin/platform-config/dict/dict.service';
|
||||
import type {
|
||||
QqbotIntegrationPlugin,
|
||||
QqbotPluginHealth,
|
||||
@ -10,6 +12,16 @@ import type {
|
||||
QqbotPluginOperationSummary,
|
||||
QqbotPluginSummary,
|
||||
} 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()
|
||||
export class QqbotPluginRegistryService implements OnModuleInit {
|
||||
@ -17,15 +29,16 @@ export class QqbotPluginRegistryService implements OnModuleInit {
|
||||
private readonly plugins = new Map<string, QqbotIntegrationPlugin>();
|
||||
|
||||
constructor(
|
||||
private readonly bangDreamPlugin: QqbotBangDreamPluginService,
|
||||
private readonly ff14MarketPlugin: QqbotFf14MarketPluginService,
|
||||
private readonly fflogsPlugin: QqbotFflogsPluginService,
|
||||
private readonly configService: ConfigService,
|
||||
private readonly dictService: DictService,
|
||||
private readonly httpClient: QqbotPluginHttpClientService,
|
||||
private readonly toolsService: ToolsService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.register(this.bangDreamPlugin.getPlugin());
|
||||
this.register(this.ff14MarketPlugin.getPlugin());
|
||||
this.register(this.fflogsPlugin.getPlugin());
|
||||
this.register(this.createBangDreamPlugin());
|
||||
this.register(this.createFf14MarketPlugin());
|
||||
this.register(this.createFflogsPlugin());
|
||||
}
|
||||
|
||||
register(plugin: QqbotIntegrationPlugin) {
|
||||
@ -134,4 +147,126 @@ export class QqbotPluginRegistryService implements OnModuleInit {
|
||||
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);
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
const url = input.url instanceof URL ? input.url : new URL(input.url);
|
||||
const method = input.method || 'GET';
|
||||
@ -57,9 +108,10 @@ export class QqbotPluginHttpClientService {
|
||||
const statusCode = response.statusCode || 500;
|
||||
if (statusCode >= 400) {
|
||||
reject(
|
||||
new Error(
|
||||
createPluginHttpError(
|
||||
input.failureMessage?.(statusCode) ||
|
||||
`${context}请求失败:${statusCode}`,
|
||||
statusCode,
|
||||
),
|
||||
);
|
||||
return;
|
||||
@ -79,3 +131,12 @@ export class QqbotPluginHttpClientService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function createPluginHttpError(message: string, statusCode: number) {
|
||||
return Object.assign(new Error(message), {
|
||||
response: {
|
||||
status: statusCode,
|
||||
},
|
||||
statusCode,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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 { QQBOT_PLUGIN_EXECUTION_PORT } from '@/modules/qqbot/core/domain/plugin-execution.port';
|
||||
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 { QqbotEventPluginRegistryService } from './application/registry/qqbot-event-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]),
|
||||
],
|
||||
providers: [
|
||||
QqbotBangDreamClientService,
|
||||
QqbotBangDreamPluginService,
|
||||
QqbotBangDreamRendererService,
|
||||
QqbotEventPluginRegistryService,
|
||||
QqbotFf14ClientService,
|
||||
QqbotFf14MarketPluginService,
|
||||
QqbotFflogsClientService,
|
||||
QqbotFflogsPluginService,
|
||||
QqbotPluginArgumentParserService,
|
||||
QqbotPluginExecutionAdapter,
|
||||
{
|
||||
@ -56,8 +40,6 @@ import { QqbotPluginHttpClientService } from './infrastructure/integration/sdk';
|
||||
QqbotPluginHttpClientService,
|
||||
QqbotPluginPlatformService,
|
||||
QqbotPluginRegistryService,
|
||||
QqbotRepeaterPluginService,
|
||||
TsuguApplicationService,
|
||||
],
|
||||
})
|
||||
export class QqbotPluginPlatformModule {}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
}));
|
||||
@ -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}`);
|
||||
}
|
||||
}
|
||||
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
@ -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);
|
||||
}
|
||||
@ -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();
|
||||
@ -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';
|
||||
@ -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 = [];
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,8 @@
|
||||
"description": "BanG Dream! Girls Band Party 查询、出图和档线能力。",
|
||||
"author": "KT",
|
||||
"license": "MIT",
|
||||
"entry": "qqbot-bangdream.plugin.ts",
|
||||
"entry": "src/index.ts",
|
||||
"legacyAliases": ["bangDream"],
|
||||
"permissions": [
|
||||
"qqbot.send",
|
||||
"runtime.http",
|
||||
@ -260,7 +261,7 @@
|
||||
"assets": [
|
||||
{
|
||||
"key": "bangdream-assets",
|
||||
"path": "assets"
|
||||
"path": "src/assets"
|
||||
}
|
||||
],
|
||||
"migrations": []
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,3 @@
|
||||
import { Logger } from '@nestjs/common';
|
||||
|
||||
const tsuguLogger = new Logger('BangDreamTsugu');
|
||||
|
||||
/**
|
||||
* 在运行时配置层中处理logger。
|
||||
*
|
||||
@ -9,5 +5,5 @@ const tsuguLogger = new Logger('BangDreamTsugu');
|
||||
* @param message - message参数。
|
||||
*/
|
||||
export function logger(type: string, message: unknown) {
|
||||
tsuguLogger.log(`[${type}] ${String(message)}`);
|
||||
console.info(`[BangDream][${type}] ${String(message)}`);
|
||||
}
|
||||
@ -1,75 +1,78 @@
|
||||
import type {
|
||||
QqbotBangDreamCommandInput,
|
||||
QqbotBangDreamOperationKey,
|
||||
} from '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.types';
|
||||
import { logger } from '@/modules/qqbot/plugins/bangDream/shared/bangdream-logger';
|
||||
} from '@/modules/qqbot/plugins/bangdream/src/domain/common/qqbot-bangdream.types';
|
||||
import { logger } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger';
|
||||
|
||||
export type TsuguExecutionStage =
|
||||
export type BangDreamExecutionStage =
|
||||
| 'handler'
|
||||
| 'mainData'
|
||||
| 'operation'
|
||||
| 'output'
|
||||
| 'start';
|
||||
|
||||
export type TsuguHookContext = {
|
||||
export type BangDreamHookContext = {
|
||||
handlerName?: string;
|
||||
imageCount?: number;
|
||||
input: QqbotBangDreamCommandInput;
|
||||
operationKey: QqbotBangDreamOperationKey;
|
||||
query?: string;
|
||||
stage: TsuguExecutionStage;
|
||||
stage: BangDreamExecutionStage;
|
||||
startedAt: number;
|
||||
};
|
||||
|
||||
export type TsuguHook = {
|
||||
afterOutput?: (context: TsuguHookContext) => Promise<void> | void;
|
||||
afterResolve?: (context: TsuguHookContext) => Promise<void> | void;
|
||||
beforeParse?: (context: TsuguHookContext) => Promise<void> | void;
|
||||
beforeRender?: (context: TsuguHookContext) => Promise<void> | void;
|
||||
export type BangDreamHook = {
|
||||
afterOutput?: (context: BangDreamHookContext) => Promise<void> | void;
|
||||
afterResolve?: (context: BangDreamHookContext) => Promise<void> | void;
|
||||
beforeParse?: (context: BangDreamHookContext) => Promise<void> | void;
|
||||
beforeRender?: (context: BangDreamHookContext) => Promise<void> | void;
|
||||
name: string;
|
||||
onError?: (context: TsuguHookContext, error: unknown) => Promise<void> | void;
|
||||
onError?: (
|
||||
context: BangDreamHookContext,
|
||||
error: unknown,
|
||||
) => Promise<void> | void;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
type TsuguSimpleHookMethod =
|
||||
type BangDreamSimpleHookMethod =
|
||||
| 'afterOutput'
|
||||
| 'afterResolve'
|
||||
| 'beforeParse'
|
||||
| 'beforeRender';
|
||||
|
||||
/**
|
||||
* 维护 Tsugu 命令执行生命周期 hook。
|
||||
*/
|
||||
export class TsuguHookRegistry {
|
||||
private readonly hooks: TsuguHook[];
|
||||
export class BangDreamHookRegistry {
|
||||
private readonly hooks: BangDreamHook[];
|
||||
|
||||
constructor(hooks: readonly TsuguHook[] = []) {
|
||||
constructor(hooks: readonly BangDreamHook[] = []) {
|
||||
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);
|
||||
}
|
||||
|
||||
async afterResolve(context: TsuguHookContext) {
|
||||
async afterResolve(context: BangDreamHookContext) {
|
||||
await this.emit('afterResolve', context);
|
||||
}
|
||||
|
||||
async beforeRender(context: TsuguHookContext) {
|
||||
async beforeRender(context: BangDreamHookContext) {
|
||||
await this.emit('beforeRender', context);
|
||||
}
|
||||
|
||||
async afterOutput(context: TsuguHookContext) {
|
||||
async afterOutput(context: BangDreamHookContext) {
|
||||
await this.emit('afterOutput', context);
|
||||
}
|
||||
|
||||
async onError(context: TsuguHookContext, error: unknown) {
|
||||
async onError(context: BangDreamHookContext, error: unknown) {
|
||||
for (const hook of this.hooks) {
|
||||
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) {
|
||||
const handler = hook[method];
|
||||
if (typeof handler === 'function') {
|
||||
@ -79,46 +82,40 @@ export class TsuguHookRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建命令执行上下文,供 hook 在全链路中共享。
|
||||
*/
|
||||
export function createTsuguHookContext(
|
||||
export function createBangDreamHookContext(
|
||||
operationKey: QqbotBangDreamOperationKey,
|
||||
input: QqbotBangDreamCommandInput,
|
||||
): TsuguHookContext {
|
||||
): BangDreamHookContext {
|
||||
return {
|
||||
input,
|
||||
operationKey,
|
||||
query: extractTsuguInputText(input),
|
||||
query: extractBangDreamInputText(input),
|
||||
stage: 'start',
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建默认日志 hook。
|
||||
*/
|
||||
export function createTsuguLogHook(): TsuguHook {
|
||||
export function createBangDreamLogHook(): BangDreamHook {
|
||||
return {
|
||||
afterOutput: (context) => {
|
||||
logger('operation', formatTsuguHookMessage('success', context));
|
||||
logger('operation', formatBangDreamHookMessage('success', context));
|
||||
},
|
||||
beforeParse: (context) => {
|
||||
logger('operation', formatTsuguHookMessage('start', context));
|
||||
logger('operation', formatBangDreamHookMessage('start', context));
|
||||
},
|
||||
name: 'TsuguLogHook',
|
||||
name: 'BangDreamLogHook',
|
||||
onError: (context, error) => {
|
||||
logger(
|
||||
'operation',
|
||||
`${formatTsuguHookMessage('error', context)} error=${getHookErrorMessage(error)}`,
|
||||
`${formatBangDreamHookMessage('error', context)} error=${getHookErrorMessage(error)}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatTsuguHookMessage(
|
||||
function formatBangDreamHookMessage(
|
||||
status: 'error' | 'start' | 'success',
|
||||
context: TsuguHookContext,
|
||||
context: BangDreamHookContext,
|
||||
) {
|
||||
const durationMs = Date.now() - context.startedAt;
|
||||
return [
|
||||
@ -134,7 +131,7 @@ function formatTsuguHookMessage(
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function extractTsuguInputText(input: QqbotBangDreamCommandInput) {
|
||||
function extractBangDreamInputText(input: QqbotBangDreamCommandInput) {
|
||||
const direct = `${input.query || input.text || input.raw || ''}`.trim();
|
||||
if (direct) return direct;
|
||||
return Array.isArray(input.args) ? input.args.join(' ').trim() : '';
|
||||
@ -1,11 +1,15 @@
|
||||
import { bestdoriApiPath } from '@/modules/qqbot/plugins/bangDream/config/runtime-config';
|
||||
import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangDream/provider/bestdori.provider';
|
||||
import { bangDreamStaticPatchProvider } from '@/modules/qqbot/plugins/bangDream/provider/static-patch.provider';
|
||||
import { logger } from '@/modules/qqbot/plugins/bangDream/shared/bangdream-logger';
|
||||
import { bestdoriApiPath } from '@/modules/qqbot/plugins/bangdream/src/config/runtime-config';
|
||||
import { bangDreamBestdoriProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/bestdori.provider';
|
||||
import { bangDreamStaticPatchProvider } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/storage/static-patch.provider';
|
||||
import { logger } from '@/modules/qqbot/plugins/bangdream/src/application/bangdream-logger';
|
||||
import {
|
||||
BANGDREAM_TSUGU_ENV_KEYS,
|
||||
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 REQUIRED_MAIN_DATA_KEYS = [
|
||||
@ -19,7 +23,7 @@ const DEFAULT_MAIN_DATA_READY_TIMEOUT_MS = 15000;
|
||||
|
||||
function getMainDataReadyTimeoutMs(): number {
|
||||
return normalizeBangDreamPositiveInteger(
|
||||
process.env[BANGDREAM_TSUGU_ENV_KEYS.mainDataReadyTimeoutMs],
|
||||
readBangDreamRuntimeConfig(BANGDREAM_TSUGU_ENV_KEYS.mainDataReadyTimeoutMs),
|
||||
DEFAULT_MAIN_DATA_READY_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
@ -32,7 +36,7 @@ function isMainDataReady(): boolean {
|
||||
}
|
||||
|
||||
async function rejectAfter(ms: number): Promise<never> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
await sleepBangDreamRuntime(ms);
|
||||
throw new Error(`BangDream 主数据首次加载超时:${ms}ms`);
|
||||
}
|
||||
|
||||
@ -104,14 +108,18 @@ async function loadMainAPI(useCache: boolean = false) {
|
||||
logger('mainAPI', 'mainAPI loaded');
|
||||
}
|
||||
|
||||
logger('mainAPI', 'initializing...');
|
||||
const initialLoadPromise = loadMainAPI(true).then(() => {
|
||||
logger('mainAPI', 'initializing done');
|
||||
loadMainAPI();
|
||||
});
|
||||
let initialLoadPromise: Promise<void> | undefined;
|
||||
|
||||
const refreshTimer = setInterval(loadMainAPI, 1000 * 60 * 5); //5分钟更新一次
|
||||
refreshTimer.unref?.();
|
||||
function ensureMainDataInitialLoad() {
|
||||
if (!initialLoadPromise) {
|
||||
logger('mainAPI', 'initializing...');
|
||||
initialLoadPromise = loadMainAPI(true).then(async () => {
|
||||
logger('mainAPI', 'initializing done');
|
||||
await loadMainAPI();
|
||||
});
|
||||
}
|
||||
return initialLoadPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* 等待 BangDream 主数据完成首次加载。
|
||||
@ -121,7 +129,7 @@ export async function waitForMainDataReady(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
await Promise.race([
|
||||
initialLoadPromise,
|
||||
ensureMainDataInitialLoad(),
|
||||
rejectAfter(getMainDataReadyTimeoutMs()),
|
||||
]);
|
||||
if (!isMainDataReady()) {
|
||||
@ -1,5 +1,5 @@
|
||||
import mainAPI from '@/modules/qqbot/plugins/bangDream/shared/main-data-store';
|
||||
import type { BANGDREAM_BESTDORI_API_PATHS } from '@/modules/qqbot/plugins/bangDream/shared/bangdream-protocol';
|
||||
import mainAPI from '@/modules/qqbot/plugins/bangdream/src/application/main-data-store';
|
||||
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 BangDreamMainDataCollection<T = unknown> = Record<string, T>;
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 445 KiB After Width: | Height: | Size: 445 KiB |
|
Before Width: | Height: | Size: 594 KiB After Width: | Height: | Size: 594 KiB |
|
Before Width: | Height: | Size: 8.9 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 524 B After Width: | Height: | Size: 524 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 223 B After Width: | Height: | Size: 223 B |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 3.6 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 457 B After Width: | Height: | Size: 457 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 247 B After Width: | Height: | Size: 247 B |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 804 B After Width: | Height: | Size: 804 B |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |