Compare commits
No commits in common. "30f8c2bbc37895f75dd7dedafde0d41dd7407e08" and "da1ba28bf9d542db35f15bcaa90b3904e7410339" have entirely different histories.
30f8c2bbc3
...
da1ba28bf9
19
API.md
19
API.md
@ -500,25 +500,6 @@ Admin 入口为 `/qqbot/plugin-task`,用于分页查看任务、启停、修
|
|||||||
|
|
||||||
## QQBot 插件能力
|
## QQBot 插件能力
|
||||||
|
|
||||||
### Bilibili Card
|
|
||||||
|
|
||||||
插件 key:`bilibili-card`。这是事件型内置插件,不新增在线命令;启用后仍需通过账号事件绑定让指定 QQBot 账号接收 `bilibili-card.message`。
|
|
||||||
|
|
||||||
| event key | 触发来源 | 说明 |
|
|
||||||
| ------------------------ | -------- | -------------------------------------------------------------------- |
|
|
||||||
| `bilibili-card.message` | message | 从 QQ/NapCat `share/json/xml/lightapp` 卡片和文本中提取 Bilibili 链接 |
|
|
||||||
|
|
||||||
插件会解析 `www.bilibili.com`、`m.bilibili.com` 和 `b23.tv`。短链通过插件平台受控 `resolveRedirect` host 能力限制跳转次数和超时;视频信息来自 Bilibili `x/web-interface/view`,回复为纯文本标题、UP 主、时长、播放/弹幕/点赞等摘要和标准视频链接。同一账号、同一会话、同一视频在 `QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS` 内去重。
|
|
||||||
|
|
||||||
可配置键:
|
|
||||||
|
|
||||||
| 配置键 | 默认值 | 说明 |
|
|
||||||
| ------------------------------------------- | ------ | -------------------- |
|
|
||||||
| `QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS` | 6000 | HTTP 请求超时毫秒 |
|
|
||||||
| `QQBOT_BILIBILI_CARD_MAX_REDIRECTS` | 5 | `b23.tv` 最大跳转数 |
|
|
||||||
| `QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS` | 600000 | 同视频去重毫秒 |
|
|
||||||
| `QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH` | 80 | 回复中简介最大字符数 |
|
|
||||||
|
|
||||||
### BangDream
|
### BangDream
|
||||||
|
|
||||||
插件 key:`bangdream`。旧 `bangDream` 作为兼容别名仍可解析;当前源码根目录为 `src/modules/qqbot/plugins/bangdream/src`,按第三期插件结构拆分为 `operations`、`domain/*`、`application`、`infrastructure/integration`、`infrastructure/storage`、`config`、`assets` 和 `theme`,不再使用旧 `tsugu` 子目录、宿主 builtins 包装层或纯转接目录。
|
插件 key:`bangdream`。旧 `bangDream` 作为兼容别名仍可解析;当前源码根目录为 `src/modules/qqbot/plugins/bangdream/src`,按第三期插件结构拆分为 `operations`、`domain/*`、`application`、`infrastructure/integration`、`infrastructure/storage`、`config`、`assets` 和 `theme`,不再使用旧 `tsugu` 子目录、宿主 builtins 包装层或纯转接目录。
|
||||||
|
|||||||
@ -24,7 +24,6 @@
|
|||||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
||||||
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 |
|
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 |
|
||||||
| `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
| `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
||||||
| `qqbot/plugins/bilibili-card` | 解析 QQ/NapCat Bilibili 卡片和短链,按账号事件绑定回复视频文字摘要 |
|
|
||||||
| `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
| `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
||||||
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
|
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
|
||||||
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
|
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
|
||||||
@ -163,7 +162,6 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
|
|||||||
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
|
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
|
||||||
- QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
|
- QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
|
||||||
- QQBot 插件平台统一使用 `plugin.json` manifest 描述插件 key、版本、操作、事件、权限、运行预算和包入口;CLI 负责 create/validate/pack/install-local,后端只暴露受控 SDK 能力并通过插件维度记录安装、配置、账号绑定和运行事件。
|
- QQBot 插件平台统一使用 `plugin.json` manifest 描述插件 key、版本、操作、事件、权限、运行预算和包入口;CLI 负责 create/validate/pack/install-local,后端只暴露受控 SDK 能力并通过插件维度记录安装、配置、账号绑定和运行事件。
|
||||||
- Bilibili Card 是事件型内置插件:`bilibili-card.message` 只在账号绑定后监听 QQ/NapCat `share/json/xml/lightapp` 卡片或文本里的 Bilibili 链接,`b23.tv` 短链通过平台 `resolveRedirect` 受控 host 能力解析,视频信息从 Bilibili `x/web-interface/view` 获取后回复纯文本摘要。
|
|
||||||
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息,WebUI 配置或请求错误留在 `lastError`。
|
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息,WebUI 配置或请求错误留在 `lastError`。
|
||||||
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
|
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
|
||||||
- NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
- NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -1,245 +0,0 @@
|
|||||||
# QQBot Bilibili 卡片解析插件设计
|
|
||||||
|
|
||||||
## 背景
|
|
||||||
|
|
||||||
QQ 群内转发 Bilibili 内容时,NapCat/OneBot 上报不一定是普通文本链接。实际消息可能是纯文本、`share` 链接分享、`json` 卡片、`xml` 卡片,或轻应用卡片。当前 QQBot 插件体系已经支持事件插件与 worker 隔离,但还没有一个专门解析 Bilibili 卡片链接的插件。
|
|
||||||
|
|
||||||
本次目标是在第三期插件架构内新增一个内置事件插件,不把解析逻辑塞回 QQBot core,也不做命令层转接。
|
|
||||||
|
|
||||||
外部依据:
|
|
||||||
|
|
||||||
- OneBot v11 消息段包含链接分享、XML 消息和 JSON 消息,链接分享段携带 `url/title/content/image` 字段。
|
|
||||||
- NapCat 消息格式兼容表中,`share <JSON>`、`json`、`lightapp <JSON>` 都可能作为收到的卡片消息类型。
|
|
||||||
- Bilibili 视频基本信息可通过 `https://api.bilibili.com/x/web-interface/view?bvid=...` 或 `aid=...` 获取,返回 `title`、`owner`、`duration`、`stat`、`desc` 等字段。
|
|
||||||
|
|
||||||
## 目标
|
|
||||||
|
|
||||||
- 新增内置插件 `bilibili-card`,源根为 `src/modules/qqbot/plugins/bilibili-card`。
|
|
||||||
- 插件以事件方式监听 `message`,只在账号绑定后生效。
|
|
||||||
- 从 QQ 文本、链接分享、JSON/XML/轻应用卡片中提取 Bilibili URL。
|
|
||||||
- 支持直接 BV/av 链接与 `b23.tv` 短链。
|
|
||||||
- 解析成功后向来源会话回复一条纯文本视频信息摘要。
|
|
||||||
- 插件代码遵守第三期插件边界,不依赖 Nest、Host 服务实现、`axios`、`fs` 或旧 builtins 目录。
|
|
||||||
- 插件新增或触碰的函数、方法、事件处理器和导出函数都补齐有实际参数语义的 JSDoc。
|
|
||||||
|
|
||||||
## 非目标
|
|
||||||
|
|
||||||
- 不做图片渲染卡片。纯文本回复足以先完成稳定解析链路。
|
|
||||||
- 不新增 QQBot 命令入口,例如 `/解析B站`。
|
|
||||||
- 不自动绑定所有账号,启停仍由插件平台的账号绑定控制。
|
|
||||||
- 不保存用户分享的原始 `b23.tv` 短链,避免回显潜在分享追踪参数。
|
|
||||||
- 不解析番剧 `ep/ss/md`、动态、专栏、直播间等非视频对象。
|
|
||||||
|
|
||||||
## 插件结构
|
|
||||||
|
|
||||||
新增目录:
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/modules/qqbot/plugins/bilibili-card/
|
|
||||||
plugin.json
|
|
||||||
src/
|
|
||||||
index.ts
|
|
||||||
application/
|
|
||||||
bilibili-card-application.ts
|
|
||||||
config/
|
|
||||||
bilibili-card-config.ts
|
|
||||||
domain/
|
|
||||||
bilibili-card.types.ts
|
|
||||||
bilibili-reply-formatter.ts
|
|
||||||
bilibili-url-extractor.ts
|
|
||||||
bilibili-url-parser.ts
|
|
||||||
events/
|
|
||||||
message/
|
|
||||||
bilibili-card-message.handler.ts
|
|
||||||
infrastructure/
|
|
||||||
integration/
|
|
||||||
bilibili-card-host.ts
|
|
||||||
bilibili-video-client.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
职责划分:
|
|
||||||
|
|
||||||
- `plugin.json` 是插件 key、事件、权限、运行时预算和配置项的唯一 manifest 来源。
|
|
||||||
- `index.ts` 只创建插件实例,适配 worker 通用 host,不写业务分支。
|
|
||||||
- `events/message` 只负责把 worker 的 `message` 事件交给 application。
|
|
||||||
- `application` 负责绑定判断、去重、解析编排、调用 Bilibili client、发送回复和 warn。
|
|
||||||
- `domain` 只放纯解析、格式化、类型与去重 key 计算,不触碰网络和平台 host。
|
|
||||||
- `infrastructure/integration` 只通过插件 host 调用 HTTP、重定向解析、发消息和配置读取。
|
|
||||||
|
|
||||||
## Manifest 与权限
|
|
||||||
|
|
||||||
插件 key 使用 `bilibili-card`。
|
|
||||||
|
|
||||||
事件定义:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"key": "bilibili-card.message",
|
|
||||||
"name": "Bilibili 卡片解析",
|
|
||||||
"eventName": "message",
|
|
||||||
"handlerName": "handleMessage",
|
|
||||||
"description": "解析 QQ 中的 Bilibili 视频链接卡片并回复视频摘要。"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
权限:
|
|
||||||
|
|
||||||
- `qqbot.event.receive`:接收 message 事件。
|
|
||||||
- `qqbot.send`:发送解析结果。
|
|
||||||
- `runtime.http`:请求 Bilibili 接口与短链重定向。
|
|
||||||
- `plugin.config.read`:读取超时、去重和文本截断配置。
|
|
||||||
|
|
||||||
运行时预算:
|
|
||||||
|
|
||||||
- `timeoutMs`: 10000。
|
|
||||||
- `maxConcurrency`: 1。
|
|
||||||
|
|
||||||
配置项:
|
|
||||||
|
|
||||||
- `QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS`: 默认 `6000`,限制 Bilibili 接口请求。
|
|
||||||
- `QQBOT_BILIBILI_CARD_MAX_REDIRECTS`: 默认 `5`,限制短链跳转层数。
|
|
||||||
- `QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS`: 默认 `600000`,同会话同链接 10 分钟去重。
|
|
||||||
- `QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH`: 默认 `80`,限制简介回复长度。
|
|
||||||
|
|
||||||
## 消息提取流程
|
|
||||||
|
|
||||||
插件收到 `QqbotNormalizedMessage` 后按顺序处理:
|
|
||||||
|
|
||||||
1. 如果 `message.selfId === message.userId`,直接忽略,避免机器人解析自己的回复。
|
|
||||||
2. 通过 host 查询当前 `selfId` 绑定的事件插件列表;没有绑定 `bilibili-card` 时直接返回。
|
|
||||||
3. 从以下来源收集字符串候选:
|
|
||||||
- `message.messageText`
|
|
||||||
- `message.rawMessage`
|
|
||||||
- `rawEvent.message` 数组中的 `text.data.text`
|
|
||||||
- `share.data.url/title/content`
|
|
||||||
- `json.data.data` 解析后的嵌套字段
|
|
||||||
- `xml.data.data` 原文中的 URL
|
|
||||||
- `lightapp.data.data` 解析后的嵌套字段
|
|
||||||
- `rawEvent` 中键名包含 `url`、`jumpUrl`、`qqdocurl`、`sourceUrl` 的字符串字段
|
|
||||||
4. 从候选字符串中提取 URL,统一去除 HTML 实体残留、尾随标点和 QQ 卡片包裹字符。
|
|
||||||
5. 只保留 `bilibili.com`、`m.bilibili.com`、`www.bilibili.com`、`b23.tv` 域名。
|
|
||||||
6. 对 URL 去重,并只处理第一条可解析为视频的 URL。
|
|
||||||
|
|
||||||
现有 core 的 `extractMessageText` 只拼接 text 段,本插件必须读取 `rawEvent`,不能依赖 core 改成卡片感知。
|
|
||||||
|
|
||||||
## URL 解析与短链处理
|
|
||||||
|
|
||||||
直接链接解析规则:
|
|
||||||
|
|
||||||
- `/video/BV...` 提取 `bvid`。
|
|
||||||
- `/video/av...` 提取 `aid`。
|
|
||||||
- URL query、hash、尾随斜杠和卡片转义字符不影响解析。
|
|
||||||
|
|
||||||
短链解析规则:
|
|
||||||
|
|
||||||
- 如果 `b23.tv` 路径本身包含 BV 或 av,直接解析。
|
|
||||||
- 如果 `b23.tv` 是随机短码,插件调用 host 的 `resolveRedirect`。
|
|
||||||
- `resolveRedirect` 返回最终 URL 和跳转链,不返回 body,不暴露底层 HTTP client。
|
|
||||||
- `resolveRedirect` 只接受 `http:` 和 `https:`,最多跳转配置指定次数。
|
|
||||||
- 最终 URL 必须仍属于允许域名集合,否则插件忽略该消息并记录 warn。
|
|
||||||
|
|
||||||
为支持短链,需要在插件平台增加通用 host 能力:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
resolveRedirect(input: {
|
|
||||||
url: string;
|
|
||||||
maxRedirects?: number;
|
|
||||||
timeoutMs?: number;
|
|
||||||
}): Promise<{ finalUrl: string; redirects: string[] }>;
|
|
||||||
```
|
|
||||||
|
|
||||||
该能力属于平台通用 HTTP 能力,不包含 Bilibili 业务判断。插件侧负责判断域名与视频 ID。
|
|
||||||
|
|
||||||
## Bilibili 接口访问
|
|
||||||
|
|
||||||
`BilibiliVideoClient` 只通过 host 访问:
|
|
||||||
|
|
||||||
- BV 链接请求 `https://api.bilibili.com/x/web-interface/view?bvid=<bvid>`。
|
|
||||||
- av 链接请求 `https://api.bilibili.com/x/web-interface/view?aid=<aid>`。
|
|
||||||
- 请求超时使用 `QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS`。
|
|
||||||
- 只接受 `code === 0` 且 `data.bvid` 存在的响应。
|
|
||||||
- `-400`、`-403`、`-404`、`62002`、`62004`、`62012` 等错误只记录 warn,不在群内刷失败消息。
|
|
||||||
|
|
||||||
## 回复格式
|
|
||||||
|
|
||||||
成功回复纯文本:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Bilibili 视频解析
|
|
||||||
标题:<title>
|
|
||||||
UP:<owner.name>
|
|
||||||
时长:<mm:ss 或 hh:mm:ss>
|
|
||||||
播放:<view> 弹幕:<danmaku> 点赞:<like>
|
|
||||||
链接:https://www.bilibili.com/video/<bvid>
|
|
||||||
简介:<截断后的 desc>
|
|
||||||
```
|
|
||||||
|
|
||||||
规则:
|
|
||||||
|
|
||||||
- 缺失字段显示为 `未知` 或省略简介行。
|
|
||||||
- 数字按中文阅读习惯做轻量格式化,例如 `1.2万`。
|
|
||||||
- 简介去掉多余换行,长度由配置限制。
|
|
||||||
- 不回显原始短链。
|
|
||||||
|
|
||||||
## 去重与降噪
|
|
||||||
|
|
||||||
去重 key:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<selfId>:<messageType>:<targetId>:<normalizedVideoId>
|
|
||||||
```
|
|
||||||
|
|
||||||
行为:
|
|
||||||
|
|
||||||
- 命中去重 TTL 时不请求 Bilibili 接口,也不发送回复。
|
|
||||||
- 每条消息最多回复一次。
|
|
||||||
- 网络失败、接口失败和非视频链接都不写入去重,避免短暂失败后无法重试。
|
|
||||||
|
|
||||||
## 数据与管理面
|
|
||||||
|
|
||||||
SQL seed 增加 `qqbot_plugin`、`qqbot_plugin_version`、`qqbot_plugin_installation`、`qqbot_plugin_event_handler` 对应记录,使新库能在插件平台页面看到 `bilibili-card`。
|
|
||||||
|
|
||||||
不新增 `qqbot_command` 或 `qqbot_plugin_operation` 记录,因为本插件没有命令 operation。
|
|
||||||
|
|
||||||
Admin 现有插件平台页面应能通过 manifest/seed 展示插件、事件能力和账号绑定;本次不新增专门页面。
|
|
||||||
|
|
||||||
## 错误处理
|
|
||||||
|
|
||||||
- 消息结构异常:忽略并 warn,不能抛出到 worker 队列导致事件分发失败。
|
|
||||||
- JSON 卡片解析失败:继续使用原始字符串抽 URL。
|
|
||||||
- XML 卡片解析失败:按普通字符串抽 URL。
|
|
||||||
- 短链跳转超过上限:warn 并忽略。
|
|
||||||
- 跳转到非允许域名:warn 并忽略。
|
|
||||||
- Bilibili 接口错误:warn 并忽略。
|
|
||||||
- 发消息失败:warn,不重试,避免重复回复。
|
|
||||||
|
|
||||||
## 测试策略
|
|
||||||
|
|
||||||
TDD 顺序:
|
|
||||||
|
|
||||||
1. URL 提取测试先失败:覆盖文本链接、`share` 段、`json` 卡片、`xml` 卡片、`b23.tv` 短链和非 B 站链接。
|
|
||||||
2. URL 解析测试先失败:覆盖 BV、av、尾随标点、query/hash、非法域名。
|
|
||||||
3. 应用测试先失败:未绑定不处理、绑定后解析成功发送摘要、self message 忽略、同会话同视频去重。
|
|
||||||
4. Bilibili client 测试先失败:`code === 0` 成功、错误 code warn、不接受缺失 `bvid` 的响应。
|
|
||||||
5. host `resolveRedirect` 测试先失败:跟随 302、相对 Location、超过上限、非法协议。
|
|
||||||
6. 架构门禁测试更新:新插件必须有 `plugin.json`、`src/index.ts`,不能导入 Nest、axios、fs、旧 builtins 或 host 内部实现。
|
|
||||||
7. SQL/manifest 测试更新:内置插件列表包含 `bilibili-card`,seed/verify 能检查插件存在。
|
|
||||||
|
|
||||||
聚焦验证命令:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
pnpm exec jest --runTestsByPath test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts test/modules/qqbot/plugins/bilibili-card/bilibili-card-application.spec.ts test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts --runInBand
|
|
||||||
pnpm exec jest --runTestsByPath test/modules/qqbot/plugins/plugin-platform-migration.spec.ts test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts --runInBand
|
|
||||||
pnpm run typecheck
|
|
||||||
git diff --check
|
|
||||||
```
|
|
||||||
|
|
||||||
## 验收标准
|
|
||||||
|
|
||||||
- 本地单测覆盖所有解析分支并通过。
|
|
||||||
- `pnpm run typecheck` 通过。
|
|
||||||
- `git diff --check` 通过。
|
|
||||||
- 新插件可被插件平台发现并显示事件能力。
|
|
||||||
- 线上部署后,测试账号绑定 `bilibili-card`,向绑定会话发送 Bilibili QQ 卡片或链接,Bot 回复视频摘要。
|
|
||||||
- 发送普通非 B 站链接不会回复。
|
|
||||||
- 发送同一个视频不会在 TTL 内重复刷屏。
|
|
||||||
@ -249,132 +249,12 @@ INSERT INTO qqbot_plugin (
|
|||||||
'Repeater',
|
'Repeater',
|
||||||
'Built-in repeater event plugin metadata.',
|
'Built-in repeater event plugin metadata.',
|
||||||
'installed'
|
'installed'
|
||||||
),
|
|
||||||
(
|
|
||||||
1000000000000000105,
|
|
||||||
'bilibili-card',
|
|
||||||
'Bilibili Card',
|
|
||||||
'Built-in Bilibili card event plugin metadata.',
|
|
||||||
'installed'
|
|
||||||
)
|
)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
plugin_name = VALUES(plugin_name),
|
plugin_name = VALUES(plugin_name),
|
||||||
description = VALUES(description),
|
description = VALUES(description),
|
||||||
status = VALUES(status);
|
status = VALUES(status);
|
||||||
|
|
||||||
INSERT INTO qqbot_plugin_version (
|
|
||||||
id,
|
|
||||||
plugin_id,
|
|
||||||
version,
|
|
||||||
package_hash,
|
|
||||||
manifest_json
|
|
||||||
) VALUES (
|
|
||||||
1000000000000001105,
|
|
||||||
1000000000000000105,
|
|
||||||
'1.0.0',
|
|
||||||
'bilibili-card:1.0.0',
|
|
||||||
JSON_OBJECT(
|
|
||||||
'pluginKey', 'bilibili-card',
|
|
||||||
'name', 'Bilibili Card',
|
|
||||||
'version', '1.0.0',
|
|
||||||
'minApiSdkVersion', '1.0.0',
|
|
||||||
'description', '解析 QQ 中的 Bilibili 视频链接卡片并回复视频摘要。',
|
|
||||||
'author', 'KT',
|
|
||||||
'entry', 'src/index.ts',
|
|
||||||
'permissions', JSON_ARRAY(
|
|
||||||
'qqbot.event.receive',
|
|
||||||
'qqbot.send',
|
|
||||||
'runtime.http',
|
|
||||||
'plugin.config.read'
|
|
||||||
),
|
|
||||||
'runtime', JSON_OBJECT(
|
|
||||||
'workerType', 'thread',
|
|
||||||
'timeoutMs', 10000,
|
|
||||||
'memoryMb', 128,
|
|
||||||
'maxConcurrency', 1,
|
|
||||||
'configKeys', JSON_ARRAY(
|
|
||||||
'QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS',
|
|
||||||
'QQBOT_BILIBILI_CARD_MAX_REDIRECTS',
|
|
||||||
'QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS',
|
|
||||||
'QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH'
|
|
||||||
)
|
|
||||||
),
|
|
||||||
'configSchema', JSON_OBJECT(
|
|
||||||
'type', 'object',
|
|
||||||
'properties', JSON_OBJECT(
|
|
||||||
'QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS', JSON_OBJECT(
|
|
||||||
'type', 'number',
|
|
||||||
'title', 'HTTP 超时毫秒',
|
|
||||||
'default', 6000
|
|
||||||
),
|
|
||||||
'QQBOT_BILIBILI_CARD_MAX_REDIRECTS', JSON_OBJECT(
|
|
||||||
'type', 'number',
|
|
||||||
'title', '短链最大跳转次数',
|
|
||||||
'default', 5
|
|
||||||
),
|
|
||||||
'QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS', JSON_OBJECT(
|
|
||||||
'type', 'number',
|
|
||||||
'title', '同视频去重毫秒',
|
|
||||||
'default', 600000
|
|
||||||
),
|
|
||||||
'QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH', JSON_OBJECT(
|
|
||||||
'type', 'number',
|
|
||||||
'title', '简介最大长度',
|
|
||||||
'default', 80
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
'operations', JSON_ARRAY(),
|
|
||||||
'events', JSON_ARRAY(JSON_OBJECT(
|
|
||||||
'key', 'bilibili-card.message',
|
|
||||||
'eventName', 'message',
|
|
||||||
'handlerName', 'handleMessage',
|
|
||||||
'name', 'Bilibili 卡片解析',
|
|
||||||
'description', '解析 QQ 中的 Bilibili 视频链接卡片并回复视频摘要。'
|
|
||||||
)),
|
|
||||||
'assets', JSON_ARRAY(),
|
|
||||||
'migrations', JSON_ARRAY()
|
|
||||||
)
|
|
||||||
) ON DUPLICATE KEY UPDATE
|
|
||||||
package_hash = VALUES(package_hash),
|
|
||||||
manifest_json = VALUES(manifest_json);
|
|
||||||
|
|
||||||
INSERT INTO qqbot_plugin_installation (
|
|
||||||
id,
|
|
||||||
plugin_id,
|
|
||||||
version_id,
|
|
||||||
status,
|
|
||||||
runtime_status,
|
|
||||||
installed_path
|
|
||||||
) VALUES (
|
|
||||||
1000000000000001205,
|
|
||||||
1000000000000000105,
|
|
||||||
1000000000000001105,
|
|
||||||
'enabled',
|
|
||||||
'stopped',
|
|
||||||
'src/modules/qqbot/plugins/bilibili-card'
|
|
||||||
) ON DUPLICATE KEY UPDATE
|
|
||||||
version_id = VALUES(version_id),
|
|
||||||
status = VALUES(status),
|
|
||||||
runtime_status = VALUES(runtime_status),
|
|
||||||
installed_path = VALUES(installed_path);
|
|
||||||
|
|
||||||
INSERT INTO qqbot_plugin_event_handler (
|
|
||||||
id,
|
|
||||||
plugin_id,
|
|
||||||
event_key,
|
|
||||||
handler_name,
|
|
||||||
enabled
|
|
||||||
) VALUES (
|
|
||||||
1000000000000001305,
|
|
||||||
1000000000000000105,
|
|
||||||
'bilibili-card.message',
|
|
||||||
'handleMessage',
|
|
||||||
1
|
|
||||||
) ON DUPLICATE KEY UPDATE
|
|
||||||
handler_name = VALUES(handler_name),
|
|
||||||
enabled = VALUES(enabled);
|
|
||||||
|
|
||||||
INSERT INTO qqbot_command (
|
INSERT INTO qqbot_command (
|
||||||
id,
|
id,
|
||||||
operation_key,
|
operation_key,
|
||||||
|
|||||||
@ -36,39 +36,6 @@ FROM qqbot_plugin
|
|||||||
WHERE plugin_key = 'bangdream'
|
WHERE plugin_key = 'bangdream'
|
||||||
AND status = 'installed';
|
AND status = 'installed';
|
||||||
|
|
||||||
SELECT 'seed_qqbot_plugin_bilibili_card' AS check_name, COUNT(*) AS matched_rows
|
|
||||||
FROM qqbot_plugin
|
|
||||||
WHERE plugin_key = 'bilibili-card'
|
|
||||||
AND status = 'installed';
|
|
||||||
|
|
||||||
SELECT 'seed_qqbot_plugin_version_bilibili_card' AS check_name, COUNT(*) AS matched_rows
|
|
||||||
FROM qqbot_plugin_version v
|
|
||||||
JOIN qqbot_plugin p ON p.id = v.plugin_id
|
|
||||||
WHERE p.plugin_key = 'bilibili-card'
|
|
||||||
AND v.version = '1.0.0'
|
|
||||||
AND v.package_hash = 'bilibili-card:1.0.0'
|
|
||||||
AND JSON_UNQUOTE(JSON_EXTRACT(v.manifest_json, '$.pluginKey')) = 'bilibili-card'
|
|
||||||
AND JSON_UNQUOTE(JSON_EXTRACT(v.manifest_json, '$.runtime.workerType')) = 'thread'
|
|
||||||
AND JSON_UNQUOTE(JSON_EXTRACT(v.manifest_json, '$.events[0].key')) = 'bilibili-card.message';
|
|
||||||
|
|
||||||
SELECT 'seed_qqbot_plugin_installation_bilibili_card' AS check_name, COUNT(*) AS matched_rows
|
|
||||||
FROM qqbot_plugin_installation i
|
|
||||||
JOIN qqbot_plugin p ON p.id = i.plugin_id
|
|
||||||
JOIN qqbot_plugin_version v ON v.id = i.version_id
|
|
||||||
WHERE p.plugin_key = 'bilibili-card'
|
|
||||||
AND v.version = '1.0.0'
|
|
||||||
AND i.status = 'enabled'
|
|
||||||
AND i.runtime_status = 'stopped'
|
|
||||||
AND i.installed_path = 'src/modules/qqbot/plugins/bilibili-card';
|
|
||||||
|
|
||||||
SELECT 'seed_qqbot_plugin_event_bilibili_card' AS check_name, COUNT(*) AS matched_rows
|
|
||||||
FROM qqbot_plugin_event_handler h
|
|
||||||
JOIN qqbot_plugin p ON p.id = h.plugin_id
|
|
||||||
WHERE p.plugin_key = 'bilibili-card'
|
|
||||||
AND h.event_key = 'bilibili-card.message'
|
|
||||||
AND h.handler_name = 'handleMessage'
|
|
||||||
AND h.enabled = 1;
|
|
||||||
|
|
||||||
SELECT 'seed_qqbot_command_bangdream_song' AS check_name, COUNT(*) AS matched_rows
|
SELECT 'seed_qqbot_command_bangdream_song' AS check_name, COUNT(*) AS matched_rows
|
||||||
FROM qqbot_command
|
FROM qqbot_command
|
||||||
WHERE command_key = 'bangdream_song'
|
WHERE command_key = 'bangdream_song'
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import type { QqbotPluginPackageDescriptor } from '@/modules/qqbot/plugin-platfo
|
|||||||
import {
|
import {
|
||||||
QqbotPluginHttpClientService,
|
QqbotPluginHttpClientService,
|
||||||
type QqbotPluginHttpClientRequest,
|
type QqbotPluginHttpClientRequest,
|
||||||
type QqbotPluginResolveRedirectRequest,
|
|
||||||
} from '../sdk/plugin-http-client.service';
|
} from '../sdk/plugin-http-client.service';
|
||||||
import type {
|
import type {
|
||||||
QqbotPluginHostCallRequest,
|
QqbotPluginHostCallRequest,
|
||||||
@ -109,8 +108,6 @@ export class QqbotPluginHostBridgeService {
|
|||||||
return this.httpClient.requestBuffer(getHttpRequestOptions(args));
|
return this.httpClient.requestBuffer(getHttpRequestOptions(args));
|
||||||
case 'requestJson':
|
case 'requestJson':
|
||||||
return this.httpClient.requestJson(getHttpRequestOptions(args));
|
return this.httpClient.requestJson(getHttpRequestOptions(args));
|
||||||
case 'resolveRedirect':
|
|
||||||
return this.httpClient.resolveRedirect(getRedirectRequestOptions(args));
|
|
||||||
case 'sendText':
|
case 'sendText':
|
||||||
return this.sendService.sendText(
|
return this.sendService.sendText(
|
||||||
args.input as Parameters<QqbotSendService['sendText']>[0],
|
args.input as Parameters<QqbotSendService['sendText']>[0],
|
||||||
@ -353,21 +350,6 @@ function getHttpRequestOptions(
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes host-call redirect options from `{ input }`, `{ options }`, or raw option arguments.
|
|
||||||
* @param args - Worker-supplied host-call arguments.
|
|
||||||
* @returns Redirect resolver options safe to pass to QqbotPluginHttpClientService.
|
|
||||||
*/
|
|
||||||
function getRedirectRequestOptions(
|
|
||||||
args: Record<string, unknown>,
|
|
||||||
): QqbotPluginResolveRedirectRequest {
|
|
||||||
const candidate = args.input ?? args.options ?? args;
|
|
||||||
if (!isRecord(candidate)) {
|
|
||||||
throw new Error('Plugin host redirect options must be an object');
|
|
||||||
}
|
|
||||||
return candidate as QqbotPluginResolveRedirectRequest;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether an unknown value can be treated as a record of host-call options.
|
* Checks whether an unknown value can be treated as a record of host-call options.
|
||||||
* @param value - Worker-supplied value that may contain named host-call arguments.
|
* @param value - Worker-supplied value that may contain named host-call arguments.
|
||||||
|
|||||||
@ -90,7 +90,6 @@ const HOST_ARGUMENT_MAPPERS: Record<string, HostArgumentMapper> = {
|
|||||||
renameFile: (from, to) => ({ from, to }),
|
renameFile: (from, to) => ({ from, to }),
|
||||||
requestBuffer: (options) => ({ options }),
|
requestBuffer: (options) => ({ options }),
|
||||||
requestJson: (options) => ({ options }),
|
requestJson: (options) => ({ options }),
|
||||||
resolveRedirect: (input) => ({ input }),
|
|
||||||
sendText: (input) => ({ input }),
|
sendText: (input) => ({ input }),
|
||||||
sleep: (ms) => ({ ms }),
|
sleep: (ms) => ({ ms }),
|
||||||
unbindEventPlugin: (selfId, pluginKey) => ({ pluginKey, selfId }),
|
unbindEventPlugin: (selfId, pluginKey) => ({ pluginKey, selfId }),
|
||||||
|
|||||||
@ -14,53 +14,8 @@ export type QqbotPluginHttpClientRequest = {
|
|||||||
url: string | URL;
|
url: string | URL;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type QqbotPluginResolveRedirectRequest = {
|
|
||||||
context?: string;
|
|
||||||
failureMessage?: (statusCode: number) => string;
|
|
||||||
headers?: Record<string, string>;
|
|
||||||
maxRedirects?: number;
|
|
||||||
timeoutMessage?: string;
|
|
||||||
timeoutMs?: number;
|
|
||||||
url: string | URL;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type QqbotPluginRedirectResult = {
|
|
||||||
finalUrl: string;
|
|
||||||
redirects: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotPluginHttpClientService {
|
export class QqbotPluginHttpClientService {
|
||||||
/**
|
|
||||||
* Resolves an HTTP(S) URL through bounded 3xx redirects for plugin host calls without platform-specific URL rules.
|
|
||||||
* @param input - Initial URL, optional headers, timeout, and redirect limit supplied by plugin runtime code.
|
|
||||||
* @returns Final URL and the ordered chain of redirect target URLs.
|
|
||||||
*/
|
|
||||||
async resolveRedirect(
|
|
||||||
input: QqbotPluginResolveRedirectRequest,
|
|
||||||
): Promise<QqbotPluginRedirectResult> {
|
|
||||||
let currentUrl = normalizePluginHttpUrl(input.url);
|
|
||||||
const redirects: string[] = [];
|
|
||||||
const maxRedirects = normalizeMaxRedirects(input.maxRedirects);
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const location = await this.requestRedirectLocation(currentUrl, input);
|
|
||||||
if (!location) {
|
|
||||||
return {
|
|
||||||
finalUrl: currentUrl.toString(),
|
|
||||||
redirects,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (redirects.length >= maxRedirects) {
|
|
||||||
throw new Error('插件 HTTP 重定向超过上限');
|
|
||||||
}
|
|
||||||
|
|
||||||
currentUrl = normalizePluginHttpUrl(new URL(location, currentUrl));
|
|
||||||
redirects.push(currentUrl.toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 QQBot 插件平台流程。
|
* 执行 QQBot 插件平台流程。
|
||||||
* @param input - input 输入;使用 `invalidJsonMessage`、`context` 字段生成结果。
|
* @param input - input 输入;使用 `invalidJsonMessage`、`context` 字段生成结果。
|
||||||
@ -190,72 +145,6 @@ export class QqbotPluginHttpClientService {
|
|||||||
request.end();
|
request.end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Requests one URL and returns its redirect Location after the response body is drained.
|
|
||||||
* @param url - Validated HTTP(S) URL to request.
|
|
||||||
* @param input - Headers and timeout options shared across the redirect chain.
|
|
||||||
* @returns Redirect Location header when the response is 3xx, otherwise `undefined`; rejects for HTTP error statuses.
|
|
||||||
*/
|
|
||||||
private requestRedirectLocation(
|
|
||||||
url: URL,
|
|
||||||
input: QqbotPluginResolveRedirectRequest,
|
|
||||||
): Promise<string | undefined> {
|
|
||||||
const timeoutMs = input.timeoutMs || 8000;
|
|
||||||
const context = input.context || '插件 HTTP 重定向';
|
|
||||||
|
|
||||||
return new Promise<string | undefined>((resolve, reject) => {
|
|
||||||
const client = getPluginHttpModule(url);
|
|
||||||
const request = client.request(
|
|
||||||
url,
|
|
||||||
{
|
|
||||||
headers: {
|
|
||||||
Accept: '*/*',
|
|
||||||
'User-Agent': 'kt-template-online-api/qqbot-plugin',
|
|
||||||
...(input.headers || {}),
|
|
||||||
},
|
|
||||||
method: 'GET',
|
|
||||||
timeout: timeoutMs,
|
|
||||||
},
|
|
||||||
(response) => {
|
|
||||||
const statusCode = response.statusCode || 0;
|
|
||||||
const location = response.headers.location;
|
|
||||||
|
|
||||||
response.on('error', reject);
|
|
||||||
response.on('end', () => {
|
|
||||||
if (statusCode >= 400) {
|
|
||||||
reject(
|
|
||||||
createPluginHttpError(
|
|
||||||
input.failureMessage?.(statusCode) ||
|
|
||||||
`${context}请求失败:${statusCode}`,
|
|
||||||
statusCode,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
statusCode >= 300 &&
|
|
||||||
statusCode < 400 &&
|
|
||||||
typeof location === 'string' &&
|
|
||||||
location.trim()
|
|
||||||
) {
|
|
||||||
resolve(location);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
resolve(undefined);
|
|
||||||
});
|
|
||||||
response.resume();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
request.on('timeout', () => {
|
|
||||||
request.destroy(
|
|
||||||
new Error(input.timeoutMessage || `${context}请求超时`),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
request.on('error', reject);
|
|
||||||
request.end();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -271,37 +160,3 @@ function createPluginHttpError(message: string, statusCode: number) {
|
|||||||
statusCode,
|
statusCode,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a URL and enforces the plugin redirect resolver's HTTP(S)-only protocol boundary.
|
|
||||||
* @param value - Worker-supplied string or URL value.
|
|
||||||
* @returns URL instance safe to request with node:http or node:https.
|
|
||||||
*/
|
|
||||||
function normalizePluginHttpUrl(value: string | URL) {
|
|
||||||
const url = value instanceof URL ? value : new URL(value);
|
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
||||||
throw new Error('插件 HTTP 重定向仅支持 http/https');
|
|
||||||
}
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes the redirect limit used to stop infinite or overly long redirect chains.
|
|
||||||
* @param value - Optional max redirect count supplied by plugin runtime code.
|
|
||||||
* @returns Non-negative redirect count limit.
|
|
||||||
*/
|
|
||||||
function normalizeMaxRedirects(value: number | undefined) {
|
|
||||||
const maxRedirects = value ?? 5;
|
|
||||||
return Number.isFinite(maxRedirects) && maxRedirects >= 0
|
|
||||||
? Math.floor(maxRedirects)
|
|
||||||
: 5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Selects the Node HTTP module for a validated redirect URL.
|
|
||||||
* @param url - HTTP(S) URL accepted by `normalizePluginHttpUrl`.
|
|
||||||
* @returns Node request module matching the URL protocol.
|
|
||||||
*/
|
|
||||||
function getPluginHttpModule(url: URL) {
|
|
||||||
return url.protocol === 'http:' ? http : https;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,64 +0,0 @@
|
|||||||
{
|
|
||||||
"pluginKey": "bilibili-card",
|
|
||||||
"name": "Bilibili Card",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"minApiSdkVersion": "1.0.0",
|
|
||||||
"description": "解析 QQ 中的 Bilibili 视频链接卡片并回复视频摘要。",
|
|
||||||
"author": "KT",
|
|
||||||
"entry": "src/index.ts",
|
|
||||||
"permissions": [
|
|
||||||
"qqbot.event.receive",
|
|
||||||
"qqbot.send",
|
|
||||||
"runtime.http",
|
|
||||||
"plugin.config.read"
|
|
||||||
],
|
|
||||||
"runtime": {
|
|
||||||
"workerType": "thread",
|
|
||||||
"timeoutMs": 10000,
|
|
||||||
"memoryMb": 128,
|
|
||||||
"maxConcurrency": 1,
|
|
||||||
"configKeys": [
|
|
||||||
"QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS",
|
|
||||||
"QQBOT_BILIBILI_CARD_MAX_REDIRECTS",
|
|
||||||
"QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS",
|
|
||||||
"QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"configSchema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "HTTP 超时毫秒",
|
|
||||||
"default": 6000
|
|
||||||
},
|
|
||||||
"QQBOT_BILIBILI_CARD_MAX_REDIRECTS": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "短链最大跳转次数",
|
|
||||||
"default": 5
|
|
||||||
},
|
|
||||||
"QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "同视频去重毫秒",
|
|
||||||
"default": 600000
|
|
||||||
},
|
|
||||||
"QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH": {
|
|
||||||
"type": "number",
|
|
||||||
"title": "简介最大长度",
|
|
||||||
"default": 80
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"operations": [],
|
|
||||||
"events": [
|
|
||||||
{
|
|
||||||
"key": "bilibili-card.message",
|
|
||||||
"name": "Bilibili 卡片解析",
|
|
||||||
"eventName": "message",
|
|
||||||
"handlerName": "handleMessage",
|
|
||||||
"description": "解析 QQ 中的 Bilibili 视频链接卡片并回复视频摘要。"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"assets": [],
|
|
||||||
"migrations": []
|
|
||||||
}
|
|
||||||
@ -1,220 +0,0 @@
|
|||||||
import { readBilibiliCardRuntimeConfig } from '../config/bilibili-card-config';
|
|
||||||
import type {
|
|
||||||
BilibiliCardManifest,
|
|
||||||
BilibiliCardMessage,
|
|
||||||
BilibiliCardPluginHost,
|
|
||||||
BilibiliVideoReference,
|
|
||||||
} from '../domain/bilibili-card.types';
|
|
||||||
import { formatBilibiliVideoReply } from '../domain/bilibili-reply-formatter';
|
|
||||||
import { extractBilibiliUrls } from '../domain/bilibili-url-extractor';
|
|
||||||
import { parseBilibiliVideoReference } from '../domain/bilibili-url-parser';
|
|
||||||
import { BilibiliVideoClient } from '../infrastructure/integration/bilibili-video-client';
|
|
||||||
|
|
||||||
export class BilibiliCardApplication {
|
|
||||||
private readonly boundCache = new Map<
|
|
||||||
string,
|
|
||||||
{ expiresAt: number; value: boolean }
|
|
||||||
>();
|
|
||||||
private readonly dedupe = new Map<string, { expiresAt: number }>();
|
|
||||||
private readonly videoClient: BilibiliVideoClient;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initializes the Bilibili card application service.
|
|
||||||
* @param host - Package-local host facade for bindings, HTTP, send and warning capabilities.
|
|
||||||
* @param manifest - Package manifest metadata containing the plugin key.
|
|
||||||
* @param now - Millisecond clock used by binding cache and conversation dedupe.
|
|
||||||
*/
|
|
||||||
constructor(
|
|
||||||
private readonly host: BilibiliCardPluginHost,
|
|
||||||
private readonly manifest: BilibiliCardManifest,
|
|
||||||
private readonly now: () => number = Date.now,
|
|
||||||
) {
|
|
||||||
this.videoClient = new BilibiliVideoClient(host);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handles one normalized QQBot message event and replies with a Bilibili video summary when applicable.
|
|
||||||
* @param message - Normalized QQBot message plus raw OneBot event/card payload.
|
|
||||||
* @returns `true` when a summary was sent; otherwise `false`.
|
|
||||||
*/
|
|
||||||
async handleMessage(message: BilibiliCardMessage) {
|
|
||||||
if (message.userId === message.selfId) return false;
|
|
||||||
if (!(await this.isBound(message.selfId))) return false;
|
|
||||||
|
|
||||||
const config = readBilibiliCardRuntimeConfig(this.host);
|
|
||||||
const urls = extractBilibiliUrls({
|
|
||||||
messageText: message.messageText,
|
|
||||||
rawEvent: message.rawEvent,
|
|
||||||
rawMessage: message.rawMessage,
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const url of urls) {
|
|
||||||
const reference = await this.resolveReference(url, config);
|
|
||||||
if (!reference) continue;
|
|
||||||
|
|
||||||
this.pruneDedupe();
|
|
||||||
const dedupeKey = buildBilibiliCardDedupeKey(message, reference);
|
|
||||||
if (this.dedupe.has(dedupeKey)) continue;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const video = await this.videoClient.fetchVideo(reference, config);
|
|
||||||
await this.host.sendText({
|
|
||||||
channelId: message.channelId,
|
|
||||||
guildId: message.rawEvent.guild_id
|
|
||||||
? `${message.rawEvent.guild_id}`
|
|
||||||
: undefined,
|
|
||||||
message: formatBilibiliVideoReply(video, config),
|
|
||||||
selfId: message.selfId,
|
|
||||||
targetId: message.targetId,
|
|
||||||
targetType: message.messageType,
|
|
||||||
});
|
|
||||||
this.dedupe.set(dedupeKey, {
|
|
||||||
expiresAt: this.now() + config.dedupeTtlMs,
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
this.warn(`Bilibili 卡片解析失败: ${normalizeError(error)}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolves direct Bilibili URLs or `b23.tv` short links into video references.
|
|
||||||
* @param url - Candidate URL extracted from normalized text or raw card payload.
|
|
||||||
* @param config - Runtime redirect settings read from the plugin config snapshot.
|
|
||||||
* @returns Parsed video reference, or `null` when the URL is unsupported or resolution fails.
|
|
||||||
*/
|
|
||||||
private async resolveReference(
|
|
||||||
url: string,
|
|
||||||
config: { httpTimeoutMs: number; maxRedirects: number },
|
|
||||||
): Promise<BilibiliVideoReference | null> {
|
|
||||||
const direct = parseBilibiliVideoReference(url);
|
|
||||||
if (direct) return direct;
|
|
||||||
if (!isB23ShortLink(url)) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resolved = await this.host.resolveRedirect({
|
|
||||||
maxRedirects: config.maxRedirects,
|
|
||||||
timeoutMs: config.httpTimeoutMs,
|
|
||||||
url,
|
|
||||||
});
|
|
||||||
return parseBilibiliVideoReference(resolved.finalUrl);
|
|
||||||
} catch (error) {
|
|
||||||
this.warn(`Bilibili 短链解析失败: ${normalizeError(error)}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether this event plugin is bound to the current QQBot account.
|
|
||||||
* @param selfId - QQBot self account id from the normalized message.
|
|
||||||
* @returns `true` when the account has the Bilibili card event plugin bound.
|
|
||||||
*/
|
|
||||||
private async isBound(selfId: string) {
|
|
||||||
const normalizedSelfId = `${selfId || ''}`.trim();
|
|
||||||
if (!normalizedSelfId) return false;
|
|
||||||
|
|
||||||
const current = this.now();
|
|
||||||
const cached = this.boundCache.get(normalizedSelfId);
|
|
||||||
if (cached && cached.expiresAt > current) return cached.value;
|
|
||||||
|
|
||||||
const config = readBilibiliCardRuntimeConfig(this.host);
|
|
||||||
try {
|
|
||||||
const value = (
|
|
||||||
await this.host.getBoundEventPluginKeys(normalizedSelfId)
|
|
||||||
).includes(this.manifest.pluginKey);
|
|
||||||
this.boundCache.set(normalizedSelfId, {
|
|
||||||
expiresAt: current + Math.min(config.dedupeTtlMs, 60000),
|
|
||||||
value,
|
|
||||||
});
|
|
||||||
return value;
|
|
||||||
} catch (error) {
|
|
||||||
this.warn(`Bilibili 事件绑定查询失败: ${normalizeError(error)}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes expired conversation/video dedupe entries before processing a new candidate.
|
|
||||||
*/
|
|
||||||
private pruneDedupe() {
|
|
||||||
const current = this.now();
|
|
||||||
for (const [key, state] of this.dedupe.entries()) {
|
|
||||||
if (state.expiresAt <= current) this.dedupe.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emits a warning through the package host without failing event dispatch.
|
|
||||||
* @param message - Warning message safe for platform logs.
|
|
||||||
*/
|
|
||||||
private warn(message: string) {
|
|
||||||
try {
|
|
||||||
const result = this.host.warn?.(message) as unknown;
|
|
||||||
if (isThenable(result)) {
|
|
||||||
result.catch(() => undefined);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a dedupe key scoped by account, conversation target and canonical video id.
|
|
||||||
* @param message - Normalized QQBot message being handled.
|
|
||||||
* @param reference - Parsed Bilibili video reference.
|
|
||||||
* @returns Stable dedupe key for one video in one conversation.
|
|
||||||
*/
|
|
||||||
function buildBilibiliCardDedupeKey(
|
|
||||||
message: BilibiliCardMessage,
|
|
||||||
reference: BilibiliVideoReference,
|
|
||||||
) {
|
|
||||||
return [
|
|
||||||
message.selfId,
|
|
||||||
message.messageType,
|
|
||||||
message.targetId,
|
|
||||||
reference.canonicalVideoId,
|
|
||||||
].join(':');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether a candidate URL is a `b23.tv` short link that requires redirect resolution.
|
|
||||||
* @param url - Candidate URL extracted by the domain extractor.
|
|
||||||
* @returns `true` when the hostname is exactly `b23.tv`.
|
|
||||||
*/
|
|
||||||
function isB23ShortLink(url: string) {
|
|
||||||
try {
|
|
||||||
return new URL(url).hostname.toLowerCase() === 'b23.tv';
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detects promise-like warning results so rejected async loggers cannot escape later.
|
|
||||||
* @param value - Return value from the optional host warning hook.
|
|
||||||
* @returns `true` when the value exposes a callable `catch` method.
|
|
||||||
*/
|
|
||||||
function isThenable(
|
|
||||||
value: unknown,
|
|
||||||
): value is { catch: (handler: () => void) => unknown } {
|
|
||||||
return (
|
|
||||||
typeof value === 'object' &&
|
|
||||||
value !== null &&
|
|
||||||
'catch' in value &&
|
|
||||||
typeof value.catch === 'function'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts thrown values to stable warning text.
|
|
||||||
* @param error - Error or arbitrary thrown value from host or domain code.
|
|
||||||
* @returns Human-readable message.
|
|
||||||
*/
|
|
||||||
function normalizeError(error: unknown) {
|
|
||||||
return error instanceof Error && error.message ? error.message : `${error}`;
|
|
||||||
}
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
import type {
|
|
||||||
BilibiliCardPluginHost,
|
|
||||||
BilibiliCardRuntimeConfig,
|
|
||||||
} from '../domain/bilibili-card.types';
|
|
||||||
|
|
||||||
const CONFIG_RULES = {
|
|
||||||
dedupeTtlMs: {
|
|
||||||
defaultValue: 600000,
|
|
||||||
key: 'QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS',
|
|
||||||
max: 3600000,
|
|
||||||
min: 0,
|
|
||||||
},
|
|
||||||
descMaxLength: {
|
|
||||||
defaultValue: 80,
|
|
||||||
key: 'QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH',
|
|
||||||
max: 300,
|
|
||||||
min: 0,
|
|
||||||
},
|
|
||||||
httpTimeoutMs: {
|
|
||||||
defaultValue: 6000,
|
|
||||||
key: 'QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS',
|
|
||||||
max: 15000,
|
|
||||||
min: 1000,
|
|
||||||
},
|
|
||||||
maxRedirects: {
|
|
||||||
defaultValue: 5,
|
|
||||||
key: 'QQBOT_BILIBILI_CARD_MAX_REDIRECTS',
|
|
||||||
max: 10,
|
|
||||||
min: 0,
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads Bilibili card runtime config from the package host and clamps unsafe values.
|
|
||||||
* @param host - Package-local host facade backed by the worker config snapshot.
|
|
||||||
* @returns Runtime config used by redirect resolution, API requests, dedupe and reply text.
|
|
||||||
*/
|
|
||||||
export function readBilibiliCardRuntimeConfig(
|
|
||||||
host: BilibiliCardPluginHost,
|
|
||||||
): BilibiliCardRuntimeConfig {
|
|
||||||
return {
|
|
||||||
dedupeTtlMs: readClampedInteger(host, CONFIG_RULES.dedupeTtlMs),
|
|
||||||
descMaxLength: readClampedInteger(host, CONFIG_RULES.descMaxLength),
|
|
||||||
httpTimeoutMs: readClampedInteger(host, CONFIG_RULES.httpTimeoutMs),
|
|
||||||
maxRedirects: readClampedInteger(host, CONFIG_RULES.maxRedirects),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads one numeric config value and constrains it to the allowed runtime range.
|
|
||||||
* @param host - Package-local host facade that exposes manifest config values.
|
|
||||||
* @param rule - Config key, fallback and inclusive bounds for one Bilibili setting.
|
|
||||||
* @returns Integer value clamped between the rule's minimum and maximum.
|
|
||||||
*/
|
|
||||||
function readClampedInteger(
|
|
||||||
host: BilibiliCardPluginHost,
|
|
||||||
rule: {
|
|
||||||
defaultValue: number;
|
|
||||||
key: string;
|
|
||||||
max: number;
|
|
||||||
min: number;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const value = Number(host.getConfig(rule.key));
|
|
||||||
const normalized = Number.isFinite(value)
|
|
||||||
? Math.trunc(value)
|
|
||||||
: rule.defaultValue;
|
|
||||||
return Math.min(rule.max, Math.max(rule.min, normalized));
|
|
||||||
}
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
export type BilibiliVideoReference =
|
|
||||||
| {
|
|
||||||
canonicalVideoId: string;
|
|
||||||
kind: 'bvid';
|
|
||||||
sourceUrl: string;
|
|
||||||
value: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
canonicalVideoId: string;
|
|
||||||
kind: 'aid';
|
|
||||||
sourceUrl: string;
|
|
||||||
value: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BilibiliUrlExtractionInput = {
|
|
||||||
messageText?: string;
|
|
||||||
rawEvent?: Record<string, unknown>;
|
|
||||||
rawMessage?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BilibiliCardManifest = {
|
|
||||||
description?: string;
|
|
||||||
events: Array<{
|
|
||||||
description?: string;
|
|
||||||
eventName: string;
|
|
||||||
handlerName: string;
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
}>;
|
|
||||||
name: string;
|
|
||||||
pluginKey: string;
|
|
||||||
version: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BilibiliCardMessage = {
|
|
||||||
channelId?: string;
|
|
||||||
messageText: string;
|
|
||||||
messageType: string;
|
|
||||||
rawEvent: Record<string, any>;
|
|
||||||
rawMessage?: string;
|
|
||||||
selfId: string;
|
|
||||||
targetId: string;
|
|
||||||
userId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BilibiliCardRuntimeConfig = {
|
|
||||||
dedupeTtlMs: number;
|
|
||||||
descMaxLength: number;
|
|
||||||
httpTimeoutMs: number;
|
|
||||||
maxRedirects: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BilibiliCardHostJsonRequest = {
|
|
||||||
context: string;
|
|
||||||
failureMessage: (statusCode: number) => string;
|
|
||||||
invalidJsonMessage: string;
|
|
||||||
method?: string;
|
|
||||||
timeoutMessage: string;
|
|
||||||
timeoutMs: number;
|
|
||||||
url: URL;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BilibiliCardRedirectRequest = {
|
|
||||||
context?: string;
|
|
||||||
maxRedirects: number;
|
|
||||||
timeoutMessage?: string;
|
|
||||||
timeoutMs: number;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BilibiliCardRedirectResult = {
|
|
||||||
finalUrl: string;
|
|
||||||
redirects: string[];
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BilibiliCardPluginHost = {
|
|
||||||
getBoundEventPluginKeys: (selfId: string) => Promise<string[]>;
|
|
||||||
getConfig: <T = string>(key: string) => T | undefined;
|
|
||||||
requestJson: <T = unknown>(
|
|
||||||
request: BilibiliCardHostJsonRequest,
|
|
||||||
) => Promise<T>;
|
|
||||||
resolveRedirect: (
|
|
||||||
request: BilibiliCardRedirectRequest,
|
|
||||||
) => Promise<BilibiliCardRedirectResult>;
|
|
||||||
sendText: (input: {
|
|
||||||
channelId?: string;
|
|
||||||
guildId?: string;
|
|
||||||
message: string;
|
|
||||||
selfId: string;
|
|
||||||
targetId: string;
|
|
||||||
targetType: string;
|
|
||||||
}) => Promise<unknown>;
|
|
||||||
warn?: (message: string) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BilibiliVideoInfo = {
|
|
||||||
aid: number;
|
|
||||||
bvid: string;
|
|
||||||
desc: string;
|
|
||||||
duration: number;
|
|
||||||
ownerName: string;
|
|
||||||
pic: string;
|
|
||||||
stat: {
|
|
||||||
danmaku: number;
|
|
||||||
like: number;
|
|
||||||
view: number;
|
|
||||||
};
|
|
||||||
title: string;
|
|
||||||
};
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
import type {
|
|
||||||
BilibiliCardRuntimeConfig,
|
|
||||||
BilibiliVideoInfo,
|
|
||||||
} from './bilibili-card.types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a Bilibili video summary as plain text for QQBot replies.
|
|
||||||
* @param video - Normalized video info returned by the package-local Bilibili client.
|
|
||||||
* @param config - Runtime config that controls the maximum displayed description length.
|
|
||||||
* @returns Concise plain text reply with a canonical Bilibili video URL.
|
|
||||||
*/
|
|
||||||
export function formatBilibiliVideoReply(
|
|
||||||
video: BilibiliVideoInfo,
|
|
||||||
config: BilibiliCardRuntimeConfig,
|
|
||||||
) {
|
|
||||||
const lines = [
|
|
||||||
'Bilibili 视频解析',
|
|
||||||
`标题:${video.title || '未知标题'}`,
|
|
||||||
`UP:${video.ownerName || '未知UP主'}`,
|
|
||||||
`时长:${formatBilibiliDuration(video.duration)}`,
|
|
||||||
`播放:${formatBilibiliStat(video.stat.view)} 弹幕:${formatBilibiliStat(
|
|
||||||
video.stat.danmaku,
|
|
||||||
)} 点赞:${formatBilibiliStat(video.stat.like)}`,
|
|
||||||
`链接:${buildCanonicalBilibiliVideoUrl(video)}`,
|
|
||||||
];
|
|
||||||
const desc = truncateBilibiliDescription(video.desc, config.descMaxLength);
|
|
||||||
if (desc) lines.push(`简介:${desc}`);
|
|
||||||
return lines.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the canonical public Bilibili video URL used in replies.
|
|
||||||
* @param video - Normalized video info containing either a BV id or an av id fallback.
|
|
||||||
* @returns Public Bilibili video URL that does not echo short links.
|
|
||||||
*/
|
|
||||||
function buildCanonicalBilibiliVideoUrl(video: BilibiliVideoInfo) {
|
|
||||||
const videoId = video.bvid || `av${video.aid}`;
|
|
||||||
return `https://www.bilibili.com/video/${videoId}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats Bilibili stat counters using compact Chinese units.
|
|
||||||
* @param value - Raw counter from the Bilibili video API response.
|
|
||||||
* @returns Counter text such as `7890` or `12.3万`.
|
|
||||||
*/
|
|
||||||
function formatBilibiliStat(value: number) {
|
|
||||||
const normalized = Math.max(0, Math.floor(value || 0));
|
|
||||||
if (normalized < 10000) return `${normalized}`;
|
|
||||||
const wan = normalized / 10000;
|
|
||||||
const formatted = wan >= 100 ? `${Math.round(wan)}` : wan.toFixed(1);
|
|
||||||
return `${formatted.replace(/\.0$/, '')}万`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Formats a duration in seconds as `mm:ss` or `hh:mm:ss`.
|
|
||||||
* @param seconds - Duration seconds from the Bilibili video API response.
|
|
||||||
* @returns Padded duration suitable for compact message replies.
|
|
||||||
*/
|
|
||||||
function formatBilibiliDuration(seconds: number) {
|
|
||||||
const normalized = Math.max(0, Math.floor(seconds || 0));
|
|
||||||
const hours = Math.floor(normalized / 3600);
|
|
||||||
const minutes = Math.floor((normalized % 3600) / 60);
|
|
||||||
const restSeconds = normalized % 60;
|
|
||||||
if (hours > 0) {
|
|
||||||
return [hours, minutes, restSeconds]
|
|
||||||
.map((item) => `${item}`.padStart(2, '0'))
|
|
||||||
.join(':');
|
|
||||||
}
|
|
||||||
return [minutes, restSeconds]
|
|
||||||
.map((item) => `${item}`.padStart(2, '0'))
|
|
||||||
.join(':');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts multi-line descriptions into one compact line and applies the configured limit.
|
|
||||||
* @param desc - Raw Bilibili description text from the video API response.
|
|
||||||
* @param maxLength - Maximum number of visible characters configured for this plugin.
|
|
||||||
* @returns Trimmed description, with an ellipsis when content was shortened.
|
|
||||||
*/
|
|
||||||
function truncateBilibiliDescription(desc: string, maxLength: number) {
|
|
||||||
const normalized = desc.replace(/\s+/gu, ' ').trim();
|
|
||||||
if (!normalized || maxLength <= 0) return '';
|
|
||||||
if (normalized.length <= maxLength) return normalized;
|
|
||||||
return `${normalized.slice(0, maxLength)}…`;
|
|
||||||
}
|
|
||||||
@ -1,242 +0,0 @@
|
|||||||
import type { BilibiliUrlExtractionInput } from './bilibili-card.types';
|
|
||||||
import {
|
|
||||||
cleanBilibiliUrlCandidate,
|
|
||||||
isAllowedBilibiliUrl,
|
|
||||||
} from './bilibili-url-parser';
|
|
||||||
|
|
||||||
const URL_PATTERN = /https?:\/\/[^\s<>"',:!,。!?;、\]\)}]+/giu;
|
|
||||||
const HTML_ENTITY_BEFORE_SEMICOLON = /&(?:amp|quot|lt|gt|#34|#60|#62)$/iu;
|
|
||||||
const MAX_DEPTH = 7;
|
|
||||||
const MAX_NODES = 200;
|
|
||||||
const MAX_STRINGS = 200;
|
|
||||||
const MAX_STRING_LENGTH = 4000;
|
|
||||||
const MAX_URLS = 20;
|
|
||||||
const MAX_JSON_BYTES = 8000;
|
|
||||||
const URL_LIKE_KEY_PATTERN = /(?:^url$|url$|jump|qqdocurl)/iu;
|
|
||||||
|
|
||||||
type ExtractionState = {
|
|
||||||
candidates: string[];
|
|
||||||
nodes: number;
|
|
||||||
strings: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts Bilibili URL candidates from normalized message text and raw QQ card payloads.
|
|
||||||
* @param input - Normalized QQBot message fields and raw OneBot event payload from NapCat.
|
|
||||||
* @returns Unique allowed Bilibili URL strings in discovery order.
|
|
||||||
*/
|
|
||||||
export function extractBilibiliUrls(input: BilibiliUrlExtractionInput) {
|
|
||||||
const candidates = collectStringCandidates(input);
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const output: string[] = [];
|
|
||||||
|
|
||||||
for (const text of candidates) {
|
|
||||||
for (const rawUrl of text.match(URL_PATTERN) || []) {
|
|
||||||
const cleaned = cleanBilibiliUrlCandidate(
|
|
||||||
trimNonEntitySemicolonTail(rawUrl),
|
|
||||||
);
|
|
||||||
if (!isAllowedBilibiliUrl(cleaned) || seen.has(cleaned)) continue;
|
|
||||||
seen.add(cleaned);
|
|
||||||
output.push(cleaned);
|
|
||||||
if (output.length >= MAX_URLS) return output;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes semicolon-delimited CQ tail text while keeping common HTML entities intact.
|
|
||||||
* @param rawUrl - Raw URL token collected by the extractor regex.
|
|
||||||
* @returns URL token that can still be decoded by `cleanBilibiliUrlCandidate`.
|
|
||||||
*/
|
|
||||||
function trimNonEntitySemicolonTail(rawUrl: string) {
|
|
||||||
for (let index = 0; index < rawUrl.length; index += 1) {
|
|
||||||
if (rawUrl[index] !== ';') continue;
|
|
||||||
if (HTML_ENTITY_BEFORE_SEMICOLON.test(rawUrl.slice(0, index))) continue;
|
|
||||||
return rawUrl.slice(0, index);
|
|
||||||
}
|
|
||||||
return rawUrl;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collects string values that may contain links from text fields and nested QQ card objects.
|
|
||||||
* @param input - Extraction input built from normalized message state.
|
|
||||||
* @returns Candidate strings that may contain URLs.
|
|
||||||
*/
|
|
||||||
function collectStringCandidates(input: BilibiliUrlExtractionInput) {
|
|
||||||
const state = createExtractionState();
|
|
||||||
pushText(state, input.messageText);
|
|
||||||
pushText(state, input.rawMessage);
|
|
||||||
collectRawEventCandidates(input.rawEvent, state);
|
|
||||||
return state.candidates;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates mutable extraction counters used to keep event traversal predictable.
|
|
||||||
* @returns Empty extraction state with bounded candidate storage.
|
|
||||||
*/
|
|
||||||
function createExtractionState(): ExtractionState {
|
|
||||||
return {
|
|
||||||
candidates: [],
|
|
||||||
nodes: 0,
|
|
||||||
strings: 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collects raw event candidates from explicit OneBot message segments.
|
|
||||||
* @param rawEvent - Raw OneBot event payload from NapCat.
|
|
||||||
* @param state - Mutable extraction state with traversal counters.
|
|
||||||
*/
|
|
||||||
function collectRawEventCandidates(
|
|
||||||
rawEvent: BilibiliUrlExtractionInput['rawEvent'],
|
|
||||||
state: ExtractionState,
|
|
||||||
) {
|
|
||||||
if (!isRecord(rawEvent)) return;
|
|
||||||
collectMessageSegments(rawEvent.message, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collects candidates from OneBot message segment arrays or a single segment object.
|
|
||||||
* @param value - Raw `message` field from a OneBot event.
|
|
||||||
* @param state - Mutable extraction state with traversal counters.
|
|
||||||
*/
|
|
||||||
function collectMessageSegments(value: unknown, state: ExtractionState) {
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const segment of value) {
|
|
||||||
collectMessageSegment(segment, state);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
collectMessageSegment(value, state);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Collects URL-like fields and known card text payloads from one OneBot message segment.
|
|
||||||
* @param value - Raw OneBot segment value.
|
|
||||||
* @param state - Mutable extraction state with traversal counters.
|
|
||||||
*/
|
|
||||||
function collectMessageSegment(value: unknown, state: ExtractionState) {
|
|
||||||
if (!isRecord(value)) return;
|
|
||||||
const type = typeof value.type === 'string' ? value.type.toLowerCase() : '';
|
|
||||||
const data = isRecord(value.data) ? value.data : undefined;
|
|
||||||
|
|
||||||
collectUrlLikeFields(value, state, new WeakSet<object>(), 0);
|
|
||||||
|
|
||||||
if (!data) return;
|
|
||||||
if (type === 'json' || type === 'lightapp') {
|
|
||||||
pushText(state, data.data);
|
|
||||||
collectJsonCardPayload(data.data, state);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (type === 'xml') {
|
|
||||||
pushText(state, data.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Walks object payloads while collecting only values held by URL-like keys.
|
|
||||||
* @param value - Unknown raw event value to inspect.
|
|
||||||
* @param state - Mutable extraction state with traversal counters.
|
|
||||||
* @param seen - Object identity set used to avoid cyclic payloads.
|
|
||||||
* @param depth - Current recursion depth used to bound malformed payloads.
|
|
||||||
*/
|
|
||||||
function collectUrlLikeFields(
|
|
||||||
value: unknown,
|
|
||||||
state: ExtractionState,
|
|
||||||
seen: WeakSet<object>,
|
|
||||||
depth: number,
|
|
||||||
) {
|
|
||||||
if (!enterObjectNode(value, state, seen, depth)) return;
|
|
||||||
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
for (const item of value) {
|
|
||||||
collectUrlLikeFields(item, state, seen, depth + 1);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [key, nestedValue] of Object.entries(value)) {
|
|
||||||
if (typeof nestedValue === 'string') {
|
|
||||||
if (isUrlLikeKey(key)) pushText(state, nestedValue);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
collectUrlLikeFields(nestedValue, state, seen, depth + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses a JSON card string when possible and ignores invalid JSON without aborting extraction.
|
|
||||||
* @param value - Raw segment `data.data` value that may be JSON.
|
|
||||||
* @param state - Mutable extraction state with traversal counters.
|
|
||||||
*/
|
|
||||||
function collectJsonCardPayload(value: unknown, state: ExtractionState) {
|
|
||||||
if (typeof value !== 'string' || value.length > MAX_JSON_BYTES) return;
|
|
||||||
const trimmed = value.trim();
|
|
||||||
if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) return;
|
|
||||||
try {
|
|
||||||
collectUrlLikeFields(
|
|
||||||
JSON.parse(trimmed),
|
|
||||||
state,
|
|
||||||
new WeakSet<object>(),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a non-empty string candidate to the output list.
|
|
||||||
* @param state - Mutable extraction state with traversal counters.
|
|
||||||
* @param value - Candidate value from normalized text or raw card payload.
|
|
||||||
*/
|
|
||||||
function pushText(state: ExtractionState, value: unknown) {
|
|
||||||
if (state.strings >= MAX_STRINGS) return;
|
|
||||||
if (typeof value === 'string' && value.trim()) {
|
|
||||||
state.strings += 1;
|
|
||||||
state.candidates.push(value.slice(0, MAX_STRING_LENGTH));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether an unknown value is a non-null object record.
|
|
||||||
* @param value - Unknown value from the raw event tree.
|
|
||||||
* @returns `true` when the value can be inspected with object keys.
|
|
||||||
*/
|
|
||||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
||||||
return typeof value === 'object' && value !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enters an object node when recursion and node-count bounds allow it.
|
|
||||||
* @param value - Unknown value being traversed.
|
|
||||||
* @param state - Mutable extraction state with traversal counters.
|
|
||||||
* @param seen - Object identity set used to avoid cyclic payloads.
|
|
||||||
* @param depth - Current recursion depth used to bound malformed payloads.
|
|
||||||
* @returns `true` when callers may inspect the object's children.
|
|
||||||
*/
|
|
||||||
function enterObjectNode(
|
|
||||||
value: unknown,
|
|
||||||
state: ExtractionState,
|
|
||||||
seen: WeakSet<object>,
|
|
||||||
depth: number,
|
|
||||||
): value is Record<string, unknown> | unknown[] {
|
|
||||||
if (depth > MAX_DEPTH || !isRecord(value) || state.nodes >= MAX_NODES) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (seen.has(value)) return false;
|
|
||||||
seen.add(value);
|
|
||||||
state.nodes += 1;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Identifies card fields that commonly carry jump URLs instead of display text.
|
|
||||||
* @param key - Raw object key from a OneBot segment or card payload.
|
|
||||||
* @returns `true` when the key name suggests URL content.
|
|
||||||
*/
|
|
||||||
function isUrlLikeKey(key: string) {
|
|
||||||
return URL_LIKE_KEY_PATTERN.test(key);
|
|
||||||
}
|
|
||||||
@ -1,95 +0,0 @@
|
|||||||
import type { BilibiliVideoReference } from './bilibili-card.types';
|
|
||||||
|
|
||||||
const BILIBILI_HOST = 'bilibili.com';
|
|
||||||
const B23_HOST = 'b23.tv';
|
|
||||||
const TRAILING_WRAPPERS = /[\s"'<>.,!?;:,。!?、;:))\]}】]+$/u;
|
|
||||||
const LEADING_WRAPPERS = /^[\s"'<>(([{【]+/u;
|
|
||||||
const BVID_PATTERN = /^BV[0-9A-Za-z]{10}$/;
|
|
||||||
const AID_PATTERN = /^(?:av|AV)(\d+)$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes QQ card wrappers and punctuation that often stick to copied URLs.
|
|
||||||
* @param candidate - Raw string fragment found in text or card JSON.
|
|
||||||
* @returns Cleaned URL candidate ready for `URL` parsing.
|
|
||||||
*/
|
|
||||||
export function cleanBilibiliUrlCandidate(candidate: string) {
|
|
||||||
return candidate
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll('"', '"')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
.replace(LEADING_WRAPPERS, '')
|
|
||||||
.replace(TRAILING_WRAPPERS, '')
|
|
||||||
.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether a URL belongs to the Bilibili domains this plugin is allowed to parse.
|
|
||||||
* @param candidate - URL string collected from a QQ message or redirect result.
|
|
||||||
* @returns `true` when the host is Bilibili-owned or `b23.tv`.
|
|
||||||
*/
|
|
||||||
export function isAllowedBilibiliUrl(candidate: string) {
|
|
||||||
try {
|
|
||||||
const url = new URL(cleanBilibiliUrlCandidate(candidate));
|
|
||||||
const hostname = url.hostname.toLowerCase();
|
|
||||||
return (
|
|
||||||
(url.protocol === 'http:' || url.protocol === 'https:') &&
|
|
||||||
(hostname === B23_HOST ||
|
|
||||||
hostname === BILIBILI_HOST ||
|
|
||||||
hostname.endsWith(`.${BILIBILI_HOST}`))
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extracts a Bilibili video identifier from a supported URL.
|
|
||||||
* @param candidate - Direct Bilibili video URL or short-link URL that already embeds BV/av.
|
|
||||||
* @returns Parsed video reference, or `null` when the URL is not a supported video URL.
|
|
||||||
*/
|
|
||||||
export function parseBilibiliVideoReference(
|
|
||||||
candidate: string,
|
|
||||||
): BilibiliVideoReference | null {
|
|
||||||
const cleaned = cleanBilibiliUrlCandidate(candidate);
|
|
||||||
if (!isAllowedBilibiliUrl(cleaned)) return null;
|
|
||||||
|
|
||||||
const url = new URL(cleaned);
|
|
||||||
const pathSegments = url.pathname.split('/').filter(Boolean);
|
|
||||||
const hostname = url.hostname.toLowerCase();
|
|
||||||
let videoIdSegment: string | undefined;
|
|
||||||
|
|
||||||
if (hostname === B23_HOST) {
|
|
||||||
videoIdSegment = pathSegments[0];
|
|
||||||
} else {
|
|
||||||
const videoSegmentIndex = pathSegments.findIndex(
|
|
||||||
(segment) => segment.toLowerCase() === 'video',
|
|
||||||
);
|
|
||||||
if (videoSegmentIndex < 0) return null;
|
|
||||||
videoIdSegment = pathSegments[videoSegmentIndex + 1];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (videoIdSegment && BVID_PATTERN.test(videoIdSegment)) {
|
|
||||||
return {
|
|
||||||
canonicalVideoId: videoIdSegment,
|
|
||||||
kind: 'bvid',
|
|
||||||
sourceUrl: cleaned,
|
|
||||||
value: videoIdSegment,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const aid = videoIdSegment?.match(AID_PATTERN)?.[1];
|
|
||||||
if (aid) {
|
|
||||||
return {
|
|
||||||
canonicalVideoId: `av${aid}`,
|
|
||||||
kind: 'aid',
|
|
||||||
sourceUrl: cleaned,
|
|
||||||
value: aid,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
@ -1,20 +0,0 @@
|
|||||||
import type { BilibiliCardApplication } from '../../application/bilibili-card-application';
|
|
||||||
import type { BilibiliCardMessage } from '../../domain/bilibili-card.types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the message event handler for the Bilibili card plugin.
|
|
||||||
* @param application - Application service that owns parsing and reply orchestration.
|
|
||||||
* @returns Handler accepted by the plugin package entry.
|
|
||||||
*/
|
|
||||||
export function createBilibiliCardMessageHandler(
|
|
||||||
application: BilibiliCardApplication,
|
|
||||||
) {
|
|
||||||
/**
|
|
||||||
* Handles one normalized QQBot message event.
|
|
||||||
* @param message - Normalized QQBot message forwarded by the plugin platform.
|
|
||||||
* @returns Whether the plugin sent a reply.
|
|
||||||
*/
|
|
||||||
return async function handleMessage(message: BilibiliCardMessage) {
|
|
||||||
return application.handleMessage(message);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,136 +0,0 @@
|
|||||||
import { BilibiliCardApplication } from './application/bilibili-card-application';
|
|
||||||
import type {
|
|
||||||
BilibiliCardManifest,
|
|
||||||
BilibiliCardMessage,
|
|
||||||
BilibiliCardPluginHost,
|
|
||||||
} from './domain/bilibili-card.types';
|
|
||||||
import { createBilibiliCardMessageHandler } from './events/message/bilibili-card-message.handler';
|
|
||||||
import { createBilibiliCardGenericHostAdapter } from './infrastructure/integration/bilibili-card-host';
|
|
||||||
|
|
||||||
type BilibiliCardPluginOptions = {
|
|
||||||
host: BilibiliCardPluginHost;
|
|
||||||
manifest: BilibiliCardManifest;
|
|
||||||
now?: () => number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type QqbotGenericPluginCreateOptions = {
|
|
||||||
host: Record<string, unknown>;
|
|
||||||
manifest: BilibiliCardManifest & { key?: string };
|
|
||||||
normalizeError: (error: unknown, fallback?: string) => string | Error;
|
|
||||||
now: () => Date;
|
|
||||||
runtime: {
|
|
||||||
configSnapshot: Record<string, string | undefined>;
|
|
||||||
installationId: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
type BilibiliCardPluginCreateOptions =
|
|
||||||
| BilibiliCardPluginOptions
|
|
||||||
| QqbotGenericPluginCreateOptions;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the Bilibili card plugin entry for package-local tests or the generic worker runtime.
|
|
||||||
* @param options - Package-local options or generic worker options containing host facade and config snapshot.
|
|
||||||
* @returns Bilibili card event plugin instance.
|
|
||||||
*/
|
|
||||||
export function createPlugin(options: BilibiliCardPluginCreateOptions) {
|
|
||||||
if (isGenericPluginOptions(options)) {
|
|
||||||
return buildBilibiliCardPlugin({
|
|
||||||
host: createBilibiliCardGenericHostAdapter(
|
|
||||||
options.host,
|
|
||||||
options.runtime.configSnapshot,
|
|
||||||
),
|
|
||||||
manifest: normalizeManifest(options.manifest),
|
|
||||||
now: () => options.now().getTime(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return buildBilibiliCardPlugin(options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the package-local plugin instance.
|
|
||||||
* @param options - Package host, manifest and millisecond clock.
|
|
||||||
* @returns Runtime plugin object consumed by tests and worker event dispatch.
|
|
||||||
*/
|
|
||||||
function buildBilibiliCardPlugin(options: BilibiliCardPluginOptions) {
|
|
||||||
const application = new BilibiliCardApplication(
|
|
||||||
options.host,
|
|
||||||
options.manifest,
|
|
||||||
options.now,
|
|
||||||
);
|
|
||||||
const handleMessage = createBilibiliCardMessageHandler(application);
|
|
||||||
|
|
||||||
return {
|
|
||||||
/**
|
|
||||||
* Returns a simple event capability summary for local callers.
|
|
||||||
* @returns Plugin event definition based on the package manifest.
|
|
||||||
*/
|
|
||||||
getDefinition: () => ({
|
|
||||||
description: options.manifest.description,
|
|
||||||
key: options.manifest.pluginKey,
|
|
||||||
name: options.manifest.name,
|
|
||||||
remark: '解析 QQ 中的 Bilibili 视频链接卡片并回复视频摘要。',
|
|
||||||
triggerType: 'message' as const,
|
|
||||||
version: options.manifest.version,
|
|
||||||
}),
|
|
||||||
/**
|
|
||||||
* Routes generic worker event calls to the package-owned message handler.
|
|
||||||
* @param eventKey - Manifest event key, event name or handler name supplied by the worker.
|
|
||||||
* @param event - Normalized QQBot message payload.
|
|
||||||
* @returns Whether the event was handled.
|
|
||||||
*/
|
|
||||||
handleEvent: (eventKey: string, event: unknown) =>
|
|
||||||
handleGenericEvent(eventKey, event, options.manifest, handleMessage),
|
|
||||||
handleMessage,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether create options came from the generic worker runtime.
|
|
||||||
* @param options - Candidate options supplied to `createPlugin`.
|
|
||||||
* @returns `true` when the runtime config snapshot exists.
|
|
||||||
*/
|
|
||||||
function isGenericPluginOptions(
|
|
||||||
options: BilibiliCardPluginCreateOptions,
|
|
||||||
): options is QqbotGenericPluginCreateOptions {
|
|
||||||
return (
|
|
||||||
!!(options as QqbotGenericPluginCreateOptions).runtime?.configSnapshot &&
|
|
||||||
!!(options as QqbotGenericPluginCreateOptions).manifest
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fills the manifest plugin key from the parser's legacy `key` field when needed.
|
|
||||||
* @param manifest - Manifest supplied by the generic plugin descriptor.
|
|
||||||
* @returns Manifest with `pluginKey` and `events` normalized.
|
|
||||||
*/
|
|
||||||
function normalizeManifest(
|
|
||||||
manifest: QqbotGenericPluginCreateOptions['manifest'],
|
|
||||||
): BilibiliCardManifest {
|
|
||||||
return {
|
|
||||||
...manifest,
|
|
||||||
events: manifest.events || [],
|
|
||||||
pluginKey: manifest.pluginKey || manifest.key || 'bilibili-card',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dispatches one generic event to the message handler when it matches the manifest event.
|
|
||||||
* @param eventKey - Event key, event name or handler name from platform dispatch.
|
|
||||||
* @param event - Normalized QQBot event payload.
|
|
||||||
* @param manifest - Package manifest containing event metadata.
|
|
||||||
* @param handleMessage - Message handler produced by the package application.
|
|
||||||
* @returns Handler result, or `false` for unrelated events.
|
|
||||||
*/
|
|
||||||
async function handleGenericEvent(
|
|
||||||
eventKey: string,
|
|
||||||
event: unknown,
|
|
||||||
manifest: BilibiliCardManifest,
|
|
||||||
handleMessage: (message: BilibiliCardMessage) => Promise<boolean>,
|
|
||||||
) {
|
|
||||||
const matched = (manifest.events || []).some((item) =>
|
|
||||||
[item.key, item.eventName, item.handlerName].includes(eventKey),
|
|
||||||
);
|
|
||||||
if (!matched && eventKey !== 'message') return false;
|
|
||||||
return handleMessage(event as BilibiliCardMessage);
|
|
||||||
}
|
|
||||||
@ -1,109 +0,0 @@
|
|||||||
import type {
|
|
||||||
BilibiliCardHostJsonRequest,
|
|
||||||
BilibiliCardPluginHost,
|
|
||||||
} from '../../domain/bilibili-card.types';
|
|
||||||
|
|
||||||
export type BilibiliCardGenericHost = Record<string, unknown>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the package-local host adapter over the generic worker host facade.
|
|
||||||
* @param host - Generic worker host object supplied by the plugin platform runtime.
|
|
||||||
* @param configSnapshot - Startup config values captured by the worker runtime.
|
|
||||||
* @returns Package-local host contract used by Bilibili card domain and integration code.
|
|
||||||
*/
|
|
||||||
export function createBilibiliCardGenericHostAdapter(
|
|
||||||
host: BilibiliCardGenericHost,
|
|
||||||
configSnapshot: Record<string, string | undefined>,
|
|
||||||
): BilibiliCardPluginHost {
|
|
||||||
return {
|
|
||||||
/**
|
|
||||||
* Reads event plugin bindings through the generic host bridge.
|
|
||||||
* @param selfId - QQBot account id from a normalized message event.
|
|
||||||
* @returns Bound event plugin keys for the account.
|
|
||||||
*/
|
|
||||||
getBoundEventPluginKeys: async (selfId) =>
|
|
||||||
await callBilibiliCardGenericHost(host, 'getBoundEventPluginKeys', selfId),
|
|
||||||
/**
|
|
||||||
* Reads Bilibili card config from the worker startup snapshot.
|
|
||||||
* @param key - Runtime config key declared by the Bilibili card manifest.
|
|
||||||
* @returns Snapshot value cast to the requested config type.
|
|
||||||
*/
|
|
||||||
getConfig: <T = string>(key: string) =>
|
|
||||||
configSnapshot[key] as T | undefined,
|
|
||||||
/**
|
|
||||||
* Performs JSON HTTP requests through the generic host bridge.
|
|
||||||
* @param request - Package-local HTTP request options from `BilibiliVideoClient`.
|
|
||||||
* @returns Parsed JSON payload returned by the host HTTP client.
|
|
||||||
*/
|
|
||||||
requestJson: async <T = unknown>(request) =>
|
|
||||||
await callBilibiliCardGenericHost<T>(
|
|
||||||
host,
|
|
||||||
'requestJson',
|
|
||||||
serializeBilibiliCardJsonRequest(request),
|
|
||||||
),
|
|
||||||
/**
|
|
||||||
* Resolves short-link redirects through the generic host bridge.
|
|
||||||
* @param request - Redirect request containing URL, timeout and max redirect budget.
|
|
||||||
* @returns Final URL and redirect chain returned by the host HTTP client.
|
|
||||||
*/
|
|
||||||
resolveRedirect: async (request) =>
|
|
||||||
await callBilibiliCardGenericHost(host, 'resolveRedirect', request),
|
|
||||||
/**
|
|
||||||
* Sends a plain text QQBot reply through the generic host bridge.
|
|
||||||
* @param input - Target conversation and message text produced by the plugin.
|
|
||||||
* @returns Host send result.
|
|
||||||
*/
|
|
||||||
sendText: async (input) =>
|
|
||||||
await callBilibiliCardGenericHost(host, 'sendText', input),
|
|
||||||
/**
|
|
||||||
* Emits a package warning through the generic host when available.
|
|
||||||
* @param message - Warning message safe for platform logs.
|
|
||||||
*/
|
|
||||||
warn: (message) => {
|
|
||||||
void callBilibiliCardGenericHost(host, 'warn', message).catch(
|
|
||||||
() => undefined,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Calls one generic host method and fails with a package-owned error when absent.
|
|
||||||
* @param host - Generic worker host facade supplied by the platform runtime.
|
|
||||||
* @param method - Host capability required by the Bilibili card adapter.
|
|
||||||
* @param args - Positional arguments accepted by the host facade method.
|
|
||||||
* @returns Host method result cast to the requested package-local type.
|
|
||||||
*/
|
|
||||||
export async function callBilibiliCardGenericHost<TResult = unknown>(
|
|
||||||
host: BilibiliCardGenericHost,
|
|
||||||
method: string,
|
|
||||||
...args: unknown[]
|
|
||||||
): Promise<TResult> {
|
|
||||||
const fn = host[method];
|
|
||||||
if (typeof fn !== 'function') {
|
|
||||||
throw new Error(`Bilibili Card generic host 缺少 ${method}`);
|
|
||||||
}
|
|
||||||
return (await fn(...args)) as TResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts package-local JSON request options to worker-safe generic host request data.
|
|
||||||
* @param request - HTTP request containing URL and function-based failure message.
|
|
||||||
* @returns Serializable request data accepted by the generic host bridge.
|
|
||||||
*/
|
|
||||||
export function serializeBilibiliCardJsonRequest(
|
|
||||||
request: BilibiliCardHostJsonRequest,
|
|
||||||
) {
|
|
||||||
const statusPlaceholder = 599;
|
|
||||||
return {
|
|
||||||
context: request.context,
|
|
||||||
failureMessageTemplate: request
|
|
||||||
.failureMessage(statusPlaceholder)
|
|
||||||
.replaceAll(`${statusPlaceholder}`, '{statusCode}'),
|
|
||||||
invalidJsonMessage: request.invalidJsonMessage,
|
|
||||||
method: request.method,
|
|
||||||
timeoutMessage: request.timeoutMessage,
|
|
||||||
timeoutMs: request.timeoutMs,
|
|
||||||
url: request.url.toString(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
import type {
|
|
||||||
BilibiliCardPluginHost,
|
|
||||||
BilibiliCardRuntimeConfig,
|
|
||||||
BilibiliVideoInfo,
|
|
||||||
BilibiliVideoReference,
|
|
||||||
} from '../../domain/bilibili-card.types';
|
|
||||||
|
|
||||||
type BilibiliViewResponse = {
|
|
||||||
code?: number;
|
|
||||||
data?: {
|
|
||||||
aid?: unknown;
|
|
||||||
bvid?: unknown;
|
|
||||||
desc?: unknown;
|
|
||||||
duration?: unknown;
|
|
||||||
owner?: {
|
|
||||||
name?: unknown;
|
|
||||||
};
|
|
||||||
pic?: unknown;
|
|
||||||
stat?: {
|
|
||||||
danmaku?: unknown;
|
|
||||||
like?: unknown;
|
|
||||||
view?: unknown;
|
|
||||||
};
|
|
||||||
title?: unknown;
|
|
||||||
};
|
|
||||||
message?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export class BilibiliVideoClient {
|
|
||||||
/**
|
|
||||||
* Initializes the host-mediated Bilibili video client.
|
|
||||||
* @param host - Package-local host facade that owns all external HTTP access.
|
|
||||||
*/
|
|
||||||
constructor(private readonly host: BilibiliCardPluginHost) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetches and normalizes one Bilibili video through the official view endpoint.
|
|
||||||
* @param reference - Parsed BV or av reference produced by the URL parser or redirect resolver.
|
|
||||||
* @param config - Runtime config containing the host request timeout budget.
|
|
||||||
* @returns Normalized video info safe for reply formatting.
|
|
||||||
*/
|
|
||||||
async fetchVideo(
|
|
||||||
reference: BilibiliVideoReference,
|
|
||||||
config: Pick<BilibiliCardRuntimeConfig, 'httpTimeoutMs'>,
|
|
||||||
): Promise<BilibiliVideoInfo> {
|
|
||||||
const response = await this.host.requestJson<BilibiliViewResponse>({
|
|
||||||
context: 'Bilibili 视频信息获取',
|
|
||||||
/**
|
|
||||||
* Builds a readable HTTP failure message for host-mediated Bilibili API requests.
|
|
||||||
* @param statusCode - HTTP status code reported by the generic host HTTP client.
|
|
||||||
* @returns Chinese error text with the status code preserved.
|
|
||||||
*/
|
|
||||||
failureMessage: (statusCode) =>
|
|
||||||
`Bilibili 视频信息获取失败:HTTP ${statusCode}`,
|
|
||||||
invalidJsonMessage: 'Bilibili 视频信息返回不是合法 JSON',
|
|
||||||
method: 'GET',
|
|
||||||
timeoutMessage: 'Bilibili 视频信息获取超时',
|
|
||||||
timeoutMs: config.httpTimeoutMs,
|
|
||||||
url: buildBilibiliViewUrl(reference),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (response.code !== 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Bilibili 视频信息获取失败:${normalizeBilibiliMessage(
|
|
||||||
response.message,
|
|
||||||
)}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return normalizeBilibiliVideoInfo(response.data, reference);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the official Bilibili web-interface view endpoint URL for a video reference.
|
|
||||||
* @param reference - Parsed BV or av reference produced by URL parsing.
|
|
||||||
* @returns Official Bilibili API URL with either `bvid` or `aid` query parameter.
|
|
||||||
*/
|
|
||||||
function buildBilibiliViewUrl(reference: BilibiliVideoReference) {
|
|
||||||
const url = new URL('https://api.bilibili.com/x/web-interface/view');
|
|
||||||
url.searchParams.set(reference.kind === 'bvid' ? 'bvid' : 'aid', reference.value);
|
|
||||||
return url;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes the Bilibili API payload into the package-local video info contract.
|
|
||||||
* @param data - Raw `data` object returned by Bilibili's view endpoint.
|
|
||||||
* @param reference - Original parsed reference used as a fallback for missing identifiers.
|
|
||||||
* @returns Video info with safe fallback strings and numbers.
|
|
||||||
*/
|
|
||||||
function normalizeBilibiliVideoInfo(
|
|
||||||
data: BilibiliViewResponse['data'],
|
|
||||||
reference: BilibiliVideoReference,
|
|
||||||
): BilibiliVideoInfo {
|
|
||||||
return {
|
|
||||||
aid: readNumber(data?.aid, reference.kind === 'aid' ? Number(reference.value) : 0),
|
|
||||||
bvid: readText(data?.bvid, reference.kind === 'bvid' ? reference.value : ''),
|
|
||||||
desc: readText(data?.desc),
|
|
||||||
duration: readNumber(data?.duration),
|
|
||||||
ownerName: readText(data?.owner?.name, '未知UP主'),
|
|
||||||
pic: readText(data?.pic),
|
|
||||||
stat: {
|
|
||||||
danmaku: readNumber(data?.stat?.danmaku),
|
|
||||||
like: readNumber(data?.stat?.like),
|
|
||||||
view: readNumber(data?.stat?.view),
|
|
||||||
},
|
|
||||||
title: readText(data?.title, '未知标题'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts arbitrary API text values to stable strings.
|
|
||||||
* @param value - Unknown text-like field from the Bilibili API response.
|
|
||||||
* @param fallback - Domain fallback used when the API field is absent or blank.
|
|
||||||
* @returns Trimmed string or the supplied fallback.
|
|
||||||
*/
|
|
||||||
function readText(value: unknown, fallback = '') {
|
|
||||||
const text = typeof value === 'string' ? value.trim() : '';
|
|
||||||
return text || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts arbitrary API number values to non-negative integers.
|
|
||||||
* @param value - Unknown numeric field from the Bilibili API response.
|
|
||||||
* @param fallback - Domain fallback used when the API field is missing or invalid.
|
|
||||||
* @returns Non-negative integer suitable for duration, id or stat counters.
|
|
||||||
*/
|
|
||||||
function readNumber(value: unknown, fallback = 0) {
|
|
||||||
const numberValue = typeof value === 'number' ? value : Number(value);
|
|
||||||
return Number.isFinite(numberValue) && numberValue > 0
|
|
||||||
? Math.floor(numberValue)
|
|
||||||
: fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Converts Bilibili API error payloads to readable Chinese messages.
|
|
||||||
* @param message - Raw `message` field returned by the Bilibili API.
|
|
||||||
* @returns Trimmed message or a stable fallback.
|
|
||||||
*/
|
|
||||||
function normalizeBilibiliMessage(message: unknown) {
|
|
||||||
return readText(message, '未知错误');
|
|
||||||
}
|
|
||||||
@ -142,7 +142,6 @@ describe('QQBot current operation matrix', () => {
|
|||||||
it('freezes built-in plugin manifests and exposed capabilities', () => {
|
it('freezes built-in plugin manifests and exposed capabilities', () => {
|
||||||
const manifests = {
|
const manifests = {
|
||||||
bangdream: readManifest('bangdream'),
|
bangdream: readManifest('bangdream'),
|
||||||
bilibiliCard: readManifest('bilibili-card'),
|
|
||||||
ff14: readManifest('ff14-market'),
|
ff14: readManifest('ff14-market'),
|
||||||
fflogs: readManifest('fflogs'),
|
fflogs: readManifest('fflogs'),
|
||||||
repeater: readManifest('repeater'),
|
repeater: readManifest('repeater'),
|
||||||
@ -158,23 +157,6 @@ describe('QQBot current operation matrix', () => {
|
|||||||
})),
|
})),
|
||||||
).toEqual(bangdreamOperations);
|
).toEqual(bangdreamOperations);
|
||||||
|
|
||||||
expect(
|
|
||||||
manifests.bilibiliCard.events.map((event) => ({
|
|
||||||
eventName: event.eventName,
|
|
||||||
handlerName: event.handlerName,
|
|
||||||
key: event.key,
|
|
||||||
name: event.name,
|
|
||||||
})),
|
|
||||||
).toEqual([
|
|
||||||
{
|
|
||||||
eventName: 'message',
|
|
||||||
handlerName: 'handleMessage',
|
|
||||||
key: 'bilibili-card.message',
|
|
||||||
name: 'Bilibili 卡片解析',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(manifests.bilibiliCard.operations).toEqual([]);
|
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
manifests.ff14.operations.map((operation) => ({
|
manifests.ff14.operations.map((operation) => ({
|
||||||
aliases: operation.aliases,
|
aliases: operation.aliases,
|
||||||
@ -233,10 +215,6 @@ describe('QQBot current operation matrix', () => {
|
|||||||
|
|
||||||
it('freezes current online command seed linkage for command plugins', () => {
|
it('freezes current online command seed linkage for command plugins', () => {
|
||||||
const seedSql = readFileSync(join(repoRoot, 'sql/qqbot-init.sql'), 'utf8');
|
const seedSql = readFileSync(join(repoRoot, 'sql/qqbot-init.sql'), 'utf8');
|
||||||
const refactorSeedSql = readFileSync(
|
|
||||||
join(repoRoot, 'sql/refactor-v3/01-seed-core.sql'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const operation of bangdreamOperations) {
|
for (const operation of bangdreamOperations) {
|
||||||
expect(seedSql).toContain(`'${operation.key}'`);
|
expect(seedSql).toContain(`'${operation.key}'`);
|
||||||
@ -246,7 +224,5 @@ describe('QQBot current operation matrix', () => {
|
|||||||
expect(seedSql).toContain(`'ff14-market', 'ff14.market.price'`);
|
expect(seedSql).toContain(`'ff14-market', 'ff14.market.price'`);
|
||||||
expect(seedSql).toContain(`'fflogs', 'fflogs.character.summary'`);
|
expect(seedSql).toContain(`'fflogs', 'fflogs.character.summary'`);
|
||||||
expect(seedSql).toContain(`'bangdream', 'bangdream.event.stage', 'plain'`);
|
expect(seedSql).toContain(`'bangdream', 'bangdream.event.stage', 'plain'`);
|
||||||
expect(refactorSeedSql).toContain(`'bilibili-card'`);
|
|
||||||
expect(refactorSeedSql).toContain(`'bilibili-card.message'`);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -97,7 +97,6 @@ describe('QQBot plugin package boundary', () => {
|
|||||||
|
|
||||||
expect(pluginDirs).toEqual([
|
expect(pluginDirs).toEqual([
|
||||||
'bangdream',
|
'bangdream',
|
||||||
'bilibili-card',
|
|
||||||
'ff14-market',
|
'ff14-market',
|
||||||
'fflogs',
|
'fflogs',
|
||||||
'repeater',
|
'repeater',
|
||||||
@ -105,13 +104,8 @@ describe('QQBot plugin package boundary', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('uses the same package shape for every built-in plugin', () => {
|
it('uses the same package shape for every built-in plugin', () => {
|
||||||
const missing = [
|
const missing = ['bangdream', 'ff14-market', 'fflogs', 'repeater'].flatMap(
|
||||||
'bangdream',
|
(pluginKey) => {
|
||||||
'bilibili-card',
|
|
||||||
'ff14-market',
|
|
||||||
'fflogs',
|
|
||||||
'repeater',
|
|
||||||
].flatMap((pluginKey) => {
|
|
||||||
const manifest = JSON.parse(
|
const manifest = JSON.parse(
|
||||||
readFileSync(join(pluginRoot, pluginKey, 'plugin.json'), 'utf8'),
|
readFileSync(join(pluginRoot, pluginKey, 'plugin.json'), 'utf8'),
|
||||||
) as { events?: unknown[]; operations?: unknown[] };
|
) as { events?: unknown[]; operations?: unknown[] };
|
||||||
@ -125,7 +119,8 @@ describe('QQBot plugin package boundary', () => {
|
|||||||
return requiredPaths
|
return requiredPaths
|
||||||
.map((pathName) => `${pluginKey}/${pathName}`)
|
.map((pathName) => `${pluginKey}/${pathName}`)
|
||||||
.filter((pathName) => !existsSync(join(pluginRoot, pathName)));
|
.filter((pathName) => !existsSync(join(pluginRoot, pathName)));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
expect(missing).toEqual([]);
|
expect(missing).toEqual([]);
|
||||||
});
|
});
|
||||||
@ -133,7 +128,6 @@ describe('QQBot plugin package boundary', () => {
|
|||||||
it('does not keep third-phase package directories as empty shells', () => {
|
it('does not keep third-phase package directories as empty shells', () => {
|
||||||
const emptyRequiredDirs = [
|
const emptyRequiredDirs = [
|
||||||
'bangdream',
|
'bangdream',
|
||||||
'bilibili-card',
|
|
||||||
'ff14-market',
|
'ff14-market',
|
||||||
'fflogs',
|
'fflogs',
|
||||||
'repeater',
|
'repeater',
|
||||||
@ -248,7 +242,6 @@ describe('QQBot plugin package boundary', () => {
|
|||||||
it('keeps plugin package entrypoints limited to createPlugin', () => {
|
it('keeps plugin package entrypoints limited to createPlugin', () => {
|
||||||
const extraExports = [
|
const extraExports = [
|
||||||
'bangdream',
|
'bangdream',
|
||||||
'bilibili-card',
|
|
||||||
'ff14-market',
|
'ff14-market',
|
||||||
'fflogs',
|
'fflogs',
|
||||||
'repeater',
|
'repeater',
|
||||||
@ -326,7 +319,7 @@ describe('plugin platform package decoupling', () => {
|
|||||||
.join('|'),
|
.join('|'),
|
||||||
);
|
);
|
||||||
const forbiddenBranchPattern =
|
const forbiddenBranchPattern =
|
||||||
/pluginKey\s*(?:={2,3})\s*['"`](bangdream|bilibili-card|ff14-market|fflogs|repeater)['"`]|case\s+['"`](bangdream|bilibili-card|ff14-market|fflogs|repeater)['"`]/;
|
/pluginKey\s*(?:={2,3})\s*['"`](bangdream|ff14-market|fflogs|repeater)['"`]|case\s+['"`](bangdream|ff14-market|fflogs|repeater)['"`]/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escapes a literal token before it is placed into the architecture gate regexp.
|
* Escapes a literal token before it is placed into the architecture gate regexp.
|
||||||
|
|||||||
@ -13,11 +13,7 @@ describe('QQBot plugin host bridge', () => {
|
|||||||
let tempRoot: string;
|
let tempRoot: string;
|
||||||
let bridge: QqbotPluginHostBridgeService;
|
let bridge: QqbotPluginHostBridgeService;
|
||||||
let configValues: Record<string, string | undefined>;
|
let configValues: Record<string, string | undefined>;
|
||||||
let httpClient: {
|
let httpClient: { requestBuffer: jest.Mock; requestJson: jest.Mock };
|
||||||
requestBuffer: jest.Mock;
|
|
||||||
requestJson: jest.Mock;
|
|
||||||
resolveRedirect: jest.Mock;
|
|
||||||
};
|
|
||||||
let accountService: {
|
let accountService: {
|
||||||
bindEventPlugin: jest.Mock;
|
bindEventPlugin: jest.Mock;
|
||||||
getBoundEventPluginKeys: jest.Mock;
|
getBoundEventPluginKeys: jest.Mock;
|
||||||
@ -42,7 +38,6 @@ describe('QQBot plugin host bridge', () => {
|
|||||||
httpClient = {
|
httpClient = {
|
||||||
requestBuffer: jest.fn(),
|
requestBuffer: jest.fn(),
|
||||||
requestJson: jest.fn().mockResolvedValue({ ok: true }),
|
requestJson: jest.fn().mockResolvedValue({ ok: true }),
|
||||||
resolveRedirect: jest.fn(),
|
|
||||||
};
|
};
|
||||||
accountService = {
|
accountService = {
|
||||||
bindEventPlugin: jest.fn().mockResolvedValue(true),
|
bindEventPlugin: jest.fn().mockResolvedValue(true),
|
||||||
@ -128,33 +123,6 @@ describe('QQBot plugin host bridge', () => {
|
|||||||
expect(httpClient.requestJson).toHaveBeenCalledWith(options);
|
expect(httpClient.requestJson).toHaveBeenCalledWith(options);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('delegates resolveRedirect host calls to the plugin HTTP client', async () => {
|
|
||||||
const options = {
|
|
||||||
maxRedirects: 3,
|
|
||||||
timeoutMs: 1000,
|
|
||||||
url: 'https://short.example/abc123',
|
|
||||||
};
|
|
||||||
httpClient.resolveRedirect.mockResolvedValue({
|
|
||||||
finalUrl: 'https://target.example/video/123',
|
|
||||||
redirects: ['https://target.example/video/123'],
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
bridge.handleHostCall(createDescriptor(), {
|
|
||||||
args: { input: options },
|
|
||||||
method: 'resolveRedirect',
|
|
||||||
pluginKey: 'sample',
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
ok: true,
|
|
||||||
value: {
|
|
||||||
finalUrl: 'https://target.example/video/123',
|
|
||||||
redirects: ['https://target.example/video/123'],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(httpClient.resolveRedirect).toHaveBeenCalledWith(options);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('delegates event binding and text sends to core QQBot services', async () => {
|
it('delegates event binding and text sends to core QQBot services', async () => {
|
||||||
const descriptor = createDescriptor();
|
const descriptor = createDescriptor();
|
||||||
const sendInput = {
|
const sendInput = {
|
||||||
|
|||||||
@ -1,84 +0,0 @@
|
|||||||
import * as http from 'node:http';
|
|
||||||
import type { AddressInfo } from 'node:net';
|
|
||||||
import { QqbotPluginHttpClientService } from '../../../../src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service';
|
|
||||||
|
|
||||||
describe('QQBot plugin HTTP client redirect resolver', () => {
|
|
||||||
let server: http.Server;
|
|
||||||
let baseUrl: string;
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
server = http.createServer((request, response) => {
|
|
||||||
if (request.url === '/short') {
|
|
||||||
response.writeHead(302, { Location: '/video/BV1xx411c7mD' });
|
|
||||||
response.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (request.url === '/loop') {
|
|
||||||
response.writeHead(302, { Location: '/loop2' });
|
|
||||||
response.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (request.url === '/loop2') {
|
|
||||||
response.writeHead(302, { Location: '/loop' });
|
|
||||||
response.end();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (request.url === '/missing') {
|
|
||||||
response.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
||||||
response.end('missing');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
response.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
||||||
response.end('ok');
|
|
||||||
});
|
|
||||||
await new Promise<void>((resolveListen) => {
|
|
||||||
server.listen(0, '127.0.0.1', resolveListen);
|
|
||||||
});
|
|
||||||
const address = server.address() as AddressInfo;
|
|
||||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
await new Promise<void>((resolveClose) => server.close(() => resolveClose()));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the final URL and redirect chain for relative Location headers', async () => {
|
|
||||||
await expect(
|
|
||||||
new QqbotPluginHttpClientService().resolveRedirect({
|
|
||||||
maxRedirects: 3,
|
|
||||||
timeoutMs: 1000,
|
|
||||||
url: `${baseUrl}/short`,
|
|
||||||
}),
|
|
||||||
).resolves.toEqual({
|
|
||||||
finalUrl: `${baseUrl}/video/BV1xx411c7mD`,
|
|
||||||
redirects: [`${baseUrl}/video/BV1xx411c7mD`],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects redirect loops after the configured limit', async () => {
|
|
||||||
await expect(
|
|
||||||
new QqbotPluginHttpClientService().resolveRedirect({
|
|
||||||
maxRedirects: 1,
|
|
||||||
timeoutMs: 1000,
|
|
||||||
url: `${baseUrl}/loop`,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('插件 HTTP 重定向超过上限');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects non-http protocols before requesting them', async () => {
|
|
||||||
await expect(
|
|
||||||
new QqbotPluginHttpClientService().resolveRedirect({
|
|
||||||
url: 'file:///etc/passwd',
|
|
||||||
}),
|
|
||||||
).rejects.toThrow('插件 HTTP 重定向仅支持 http/https');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects HTTP error statuses while resolving redirects', async () => {
|
|
||||||
await expect(
|
|
||||||
new QqbotPluginHttpClientService().resolveRedirect({
|
|
||||||
timeoutMs: 1000,
|
|
||||||
url: `${baseUrl}/missing`,
|
|
||||||
}),
|
|
||||||
).rejects.toMatchObject({ statusCode: 404 });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,365 +0,0 @@
|
|||||||
import { BilibiliCardApplication } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/application/bilibili-card-application';
|
|
||||||
import type {
|
|
||||||
BilibiliCardManifest,
|
|
||||||
BilibiliCardMessage,
|
|
||||||
BilibiliCardPluginHost,
|
|
||||||
} from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types';
|
|
||||||
import { createPlugin } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/index';
|
|
||||||
|
|
||||||
describe('Bilibili card application', () => {
|
|
||||||
it('does nothing when plugin is not bound to account', async () => {
|
|
||||||
const host = createHost({
|
|
||||||
getBoundEventPluginKeys: jest.fn().mockResolvedValue([]),
|
|
||||||
});
|
|
||||||
const application = new BilibiliCardApplication(host, createManifest());
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
application.handleMessage(
|
|
||||||
createMessage({
|
|
||||||
messageText: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).resolves.toBe(false);
|
|
||||||
|
|
||||||
expect(host.requestJson).not.toHaveBeenCalled();
|
|
||||||
expect(host.sendText).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('warns and returns false when binding lookup fails', async () => {
|
|
||||||
const host = createHost({
|
|
||||||
getBoundEventPluginKeys: jest
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValue(new Error('binding down')),
|
|
||||||
});
|
|
||||||
const application = new BilibiliCardApplication(host, createManifest());
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
application.handleMessage(
|
|
||||||
createMessage({
|
|
||||||
messageText: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).resolves.toBe(false);
|
|
||||||
|
|
||||||
expect(host.warn).toHaveBeenCalledWith(
|
|
||||||
expect.stringContaining('binding down'),
|
|
||||||
);
|
|
||||||
expect(host.requestJson).not.toHaveBeenCalled();
|
|
||||||
expect(host.sendText).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('ignores messages sent by the bot itself', async () => {
|
|
||||||
const host = createHost({
|
|
||||||
getBoundEventPluginKeys: jest.fn().mockResolvedValue(['bilibili-card']),
|
|
||||||
});
|
|
||||||
const application = new BilibiliCardApplication(host, createManifest());
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
application.handleMessage(
|
|
||||||
createMessage({
|
|
||||||
selfId: '10001',
|
|
||||||
userId: '10001',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).resolves.toBe(false);
|
|
||||||
|
|
||||||
expect(host.getBoundEventPluginKeys).not.toHaveBeenCalled();
|
|
||||||
expect(host.requestJson).not.toHaveBeenCalled();
|
|
||||||
expect(host.sendText).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches video info and sends one summary for bound account', async () => {
|
|
||||||
const host = createHost({
|
|
||||||
getBoundEventPluginKeys: jest.fn().mockResolvedValue(['bilibili-card']),
|
|
||||||
requestJson: jest.fn().mockResolvedValue(createBilibiliViewResponse()),
|
|
||||||
});
|
|
||||||
const application = new BilibiliCardApplication(host, createManifest());
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
application.handleMessage(
|
|
||||||
createMessage({
|
|
||||||
channelId: 'channel-1',
|
|
||||||
messageText: '看看 https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
rawEvent: { guild_id: 987654321 },
|
|
||||||
targetId: 'group-1',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).resolves.toBe(true);
|
|
||||||
|
|
||||||
expect(host.requestJson).toHaveBeenCalledTimes(1);
|
|
||||||
expect(host.sendText).toHaveBeenCalledTimes(1);
|
|
||||||
expect(host.sendText).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
channelId: 'channel-1',
|
|
||||||
guildId: '987654321',
|
|
||||||
message: expect.stringContaining('标题:夏祭'),
|
|
||||||
selfId: '10001',
|
|
||||||
targetId: 'group-1',
|
|
||||||
targetType: 'group',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('resolves b23.tv short links before fetching video info', async () => {
|
|
||||||
const host = createHost({
|
|
||||||
getBoundEventPluginKeys: jest.fn().mockResolvedValue(['bilibili-card']),
|
|
||||||
requestJson: jest.fn().mockResolvedValue(createBilibiliViewResponse()),
|
|
||||||
resolveRedirect: jest.fn().mockResolvedValue({
|
|
||||||
finalUrl: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
redirects: ['https://b23.tv/abc123'],
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const application = new BilibiliCardApplication(host, createManifest());
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
application.handleMessage(
|
|
||||||
createMessage({
|
|
||||||
messageText: 'https://b23.tv/abc123',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).resolves.toBe(true);
|
|
||||||
|
|
||||||
expect(host.resolveRedirect).toHaveBeenCalledWith({
|
|
||||||
maxRedirects: 5,
|
|
||||||
timeoutMs: 6000,
|
|
||||||
url: 'https://b23.tv/abc123',
|
|
||||||
});
|
|
||||||
expect(host.requestJson).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('deduplicates same video in same conversation during TTL', async () => {
|
|
||||||
let current = 1000;
|
|
||||||
const host = createHost({
|
|
||||||
getBoundEventPluginKeys: jest.fn().mockResolvedValue(['bilibili-card']),
|
|
||||||
requestJson: jest.fn().mockResolvedValue(createBilibiliViewResponse()),
|
|
||||||
});
|
|
||||||
const application = new BilibiliCardApplication(
|
|
||||||
host,
|
|
||||||
createManifest(),
|
|
||||||
() => current,
|
|
||||||
);
|
|
||||||
const message = createMessage({
|
|
||||||
messageText: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(application.handleMessage(message)).resolves.toBe(true);
|
|
||||||
current += 1000;
|
|
||||||
await expect(application.handleMessage(message)).resolves.toBe(false);
|
|
||||||
|
|
||||||
expect(host.requestJson).toHaveBeenCalledTimes(1);
|
|
||||||
expect(host.sendText).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('routes generic worker message events to package handler', async () => {
|
|
||||||
const host = createHost({
|
|
||||||
getBoundEventPluginKeys: jest.fn().mockResolvedValue(['bilibili-card']),
|
|
||||||
requestJson: jest.fn().mockResolvedValue(createBilibiliViewResponse()),
|
|
||||||
});
|
|
||||||
const plugin = createPlugin({
|
|
||||||
host,
|
|
||||||
manifest: createManifest(),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
plugin.handleEvent(
|
|
||||||
'message',
|
|
||||||
createMessage({
|
|
||||||
messageText: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).resolves.toBe(true);
|
|
||||||
|
|
||||||
expect(host.sendText).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false for non-video or invalid URLs without HTTP or send', async () => {
|
|
||||||
const host = createHost({
|
|
||||||
getBoundEventPluginKeys: jest.fn().mockResolvedValue(['bilibili-card']),
|
|
||||||
});
|
|
||||||
const application = new BilibiliCardApplication(host, createManifest());
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
application.handleMessage(
|
|
||||||
createMessage({
|
|
||||||
messageText:
|
|
||||||
'https://space.bilibili.com/1 https://example.com/video/BV1xx411c7mD',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).resolves.toBe(false);
|
|
||||||
|
|
||||||
expect(host.resolveRedirect).not.toHaveBeenCalled();
|
|
||||||
expect(host.requestJson).not.toHaveBeenCalled();
|
|
||||||
expect(host.sendText).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when warning throws during binding lookup failure', async () => {
|
|
||||||
const host = createHost({
|
|
||||||
getBoundEventPluginKeys: jest
|
|
||||||
.fn()
|
|
||||||
.mockRejectedValue(new Error('binding down')),
|
|
||||||
warn: jest.fn(() => {
|
|
||||||
throw new Error('logger down');
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const application = new BilibiliCardApplication(host, createManifest());
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
application.handleMessage(
|
|
||||||
createMessage({
|
|
||||||
messageText: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).resolves.toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns false when warning throws during short-link failure', async () => {
|
|
||||||
const host = createHost({
|
|
||||||
getBoundEventPluginKeys: jest.fn().mockResolvedValue(['bilibili-card']),
|
|
||||||
resolveRedirect: jest.fn().mockRejectedValue(new Error('redirect down')),
|
|
||||||
warn: jest.fn(() => {
|
|
||||||
throw new Error('logger down');
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const application = new BilibiliCardApplication(host, createManifest());
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
application.handleMessage(
|
|
||||||
createMessage({
|
|
||||||
messageText: 'https://b23.tv/abc123',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).resolves.toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('attaches a rejection handler when warning returns a promise', async () => {
|
|
||||||
const catchSpy = jest.fn();
|
|
||||||
const host = createHost({
|
|
||||||
getBoundEventPluginKeys: jest.fn().mockResolvedValue(['bilibili-card']),
|
|
||||||
resolveRedirect: jest.fn().mockRejectedValue(new Error('redirect down')),
|
|
||||||
warn: jest.fn(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
catch: catchSpy,
|
|
||||||
}) as unknown as void,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
const application = new BilibiliCardApplication(host, createManifest());
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
application.handleMessage(
|
|
||||||
createMessage({
|
|
||||||
messageText: 'https://b23.tv/abc123',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).resolves.toBe(false);
|
|
||||||
|
|
||||||
expect(catchSpy).toHaveBeenCalledWith(expect.any(Function));
|
|
||||||
});
|
|
||||||
|
|
||||||
it('warns and returns false when short-link resolution fails', async () => {
|
|
||||||
const host = createHost({
|
|
||||||
getBoundEventPluginKeys: jest.fn().mockResolvedValue(['bilibili-card']),
|
|
||||||
resolveRedirect: jest.fn().mockRejectedValue(new Error('redirect down')),
|
|
||||||
});
|
|
||||||
const application = new BilibiliCardApplication(host, createManifest());
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
application.handleMessage(
|
|
||||||
createMessage({
|
|
||||||
messageText: 'https://b23.tv/abc123',
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
).resolves.toBe(false);
|
|
||||||
|
|
||||||
expect(host.warn).toHaveBeenCalledWith(
|
|
||||||
'Bilibili 短链解析失败: redirect down',
|
|
||||||
);
|
|
||||||
expect(host.requestJson).not.toHaveBeenCalled();
|
|
||||||
expect(host.sendText).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates the Bilibili card manifest used by package-local application tests.
|
|
||||||
* @returns Manifest object with one message event and plugin key metadata.
|
|
||||||
*/
|
|
||||||
function createManifest(): BilibiliCardManifest {
|
|
||||||
return {
|
|
||||||
description: '解析 QQ 中的 Bilibili 视频链接卡片并回复视频摘要。',
|
|
||||||
events: [
|
|
||||||
{
|
|
||||||
eventName: 'message',
|
|
||||||
handlerName: 'handleMessage',
|
|
||||||
key: 'bilibili-card.message',
|
|
||||||
name: 'Bilibili 卡片解析',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
name: 'Bilibili Card',
|
|
||||||
pluginKey: 'bilibili-card',
|
|
||||||
version: '1.0.0',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a normalized QQBot message with overridable fields for one test case.
|
|
||||||
* @param overrides - Message fields that differ from the default group message.
|
|
||||||
* @returns Normalized Bilibili card message accepted by the application.
|
|
||||||
*/
|
|
||||||
function createMessage(
|
|
||||||
overrides: Partial<BilibiliCardMessage> = {},
|
|
||||||
): BilibiliCardMessage {
|
|
||||||
return {
|
|
||||||
channelId: undefined,
|
|
||||||
messageText: '',
|
|
||||||
messageType: 'group',
|
|
||||||
rawEvent: {},
|
|
||||||
rawMessage: '',
|
|
||||||
selfId: '10001',
|
|
||||||
targetId: '20001',
|
|
||||||
userId: '30001',
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the package-local Bilibili host contract used by application tests.
|
|
||||||
* @param overrides - Test doubles for host methods involved in a scenario.
|
|
||||||
* @returns Host object with harmless defaults for unused plugin capabilities.
|
|
||||||
*/
|
|
||||||
function createHost(
|
|
||||||
overrides: Partial<BilibiliCardPluginHost> = {},
|
|
||||||
): BilibiliCardPluginHost {
|
|
||||||
return {
|
|
||||||
getBoundEventPluginKeys: jest.fn().mockResolvedValue([]),
|
|
||||||
getConfig: jest.fn(),
|
|
||||||
requestJson: jest.fn(),
|
|
||||||
resolveRedirect: jest.fn(),
|
|
||||||
sendText: jest.fn().mockResolvedValue(undefined),
|
|
||||||
warn: jest.fn(),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a successful Bilibili view API response fixture.
|
|
||||||
* @returns Minimal API payload consumed by `BilibiliVideoClient`.
|
|
||||||
*/
|
|
||||||
function createBilibiliViewResponse() {
|
|
||||||
return {
|
|
||||||
code: 0,
|
|
||||||
data: {
|
|
||||||
aid: 170001,
|
|
||||||
bvid: 'BV1xx411c7mD',
|
|
||||||
desc: '第一行\n第二行',
|
|
||||||
duration: 125,
|
|
||||||
owner: { name: 'UP主' },
|
|
||||||
pic: 'https://i0.hdslb.com/bfs/archive/demo.jpg',
|
|
||||||
stat: {
|
|
||||||
danmaku: 456,
|
|
||||||
like: 7890,
|
|
||||||
view: 123456,
|
|
||||||
},
|
|
||||||
title: '夏祭',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
import { extractBilibiliUrls } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-extractor';
|
|
||||||
|
|
||||||
describe('Bilibili URL extractor', () => {
|
|
||||||
it('extracts links from messageText and rawMessage while deduplicating them', () => {
|
|
||||||
expect(
|
|
||||||
extractBilibiliUrls({
|
|
||||||
messageText: '看看 https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
rawMessage:
|
|
||||||
'重复 https://www.bilibili.com/video/BV1xx411c7mD?share=qq',
|
|
||||||
rawEvent: {},
|
|
||||||
}),
|
|
||||||
).toEqual([
|
|
||||||
'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
'https://www.bilibili.com/video/BV1xx411c7mD?share=qq',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts a QQ share card URL', () => {
|
|
||||||
const urls = extractBilibiliUrls({
|
|
||||||
messageText: '',
|
|
||||||
rawMessage: '',
|
|
||||||
rawEvent: {
|
|
||||||
message: [
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
content: '夏祭 视频',
|
|
||||||
title: 'Bilibili',
|
|
||||||
url: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
},
|
|
||||||
type: 'share',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(urls).toEqual(['https://www.bilibili.com/video/BV1xx411c7mD']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts nested URLs from json and lightapp cards', () => {
|
|
||||||
const payload = JSON.stringify({
|
|
||||||
app: 'com.tencent.structmsg',
|
|
||||||
meta: {
|
|
||||||
detail: {
|
|
||||||
jumpUrl: 'https://b23.tv/abc123',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
extractBilibiliUrls({
|
|
||||||
messageText: '',
|
|
||||||
rawMessage: '',
|
|
||||||
rawEvent: {
|
|
||||||
message: [
|
|
||||||
{ data: { data: payload }, type: 'json' },
|
|
||||||
{ data: { data: payload }, type: 'lightapp' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual(['https://b23.tv/abc123']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('extracts URLs from xml card text and ignores non-Bilibili URLs', () => {
|
|
||||||
expect(
|
|
||||||
extractBilibiliUrls({
|
|
||||||
messageText: 'https://example.com/video/BV1xx411c7mD',
|
|
||||||
rawMessage: '',
|
|
||||||
rawEvent: {
|
|
||||||
message: [
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
data: '<msg url="https://m.bilibili.com/video/av170001" />',
|
|
||||||
},
|
|
||||||
type: 'xml',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual(['https://m.bilibili.com/video/av170001']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops URL tokens at CQ separators and ASCII punctuation', () => {
|
|
||||||
expect(
|
|
||||||
extractBilibiliUrls({
|
|
||||||
messageText:
|
|
||||||
'[CQ:share,url=https://b23.tv/abc123,title=视频] https://www.bilibili.com/video/BV1xx411c7mD.',
|
|
||||||
rawEvent: {},
|
|
||||||
rawMessage: '',
|
|
||||||
}),
|
|
||||||
).toEqual([
|
|
||||||
'https://b23.tv/abc123',
|
|
||||||
'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not scan unrelated rawEvent string fields', () => {
|
|
||||||
expect(
|
|
||||||
extractBilibiliUrls({
|
|
||||||
messageText: '',
|
|
||||||
rawMessage: '',
|
|
||||||
rawEvent: {
|
|
||||||
debug: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
message: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not scan root rawEvent URL-like metadata fields', () => {
|
|
||||||
expect(
|
|
||||||
extractBilibiliUrls({
|
|
||||||
messageText: '',
|
|
||||||
rawMessage: '',
|
|
||||||
rawEvent: {
|
|
||||||
debugUrl: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
message: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps exact cleaned URL dedupe while preserving distinct query URLs', () => {
|
|
||||||
expect(
|
|
||||||
extractBilibiliUrls({
|
|
||||||
messageText:
|
|
||||||
'https://b23.tv/abc123 https://b23.tv/abc123 https://b23.tv/abc123?share=qq',
|
|
||||||
rawEvent: {},
|
|
||||||
rawMessage: 'https://b23.tv/abc123',
|
|
||||||
}),
|
|
||||||
).toEqual(['https://b23.tv/abc123', 'https://b23.tv/abc123?share=qq']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles invalid JSON card strings without aborting extraction', () => {
|
|
||||||
expect(
|
|
||||||
extractBilibiliUrls({
|
|
||||||
messageText: '',
|
|
||||||
rawMessage: '',
|
|
||||||
rawEvent: {
|
|
||||||
message: [
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
data: '{"jumpUrl":"https://b23.tv/abc123"',
|
|
||||||
},
|
|
||||||
type: 'json',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual(['https://b23.tv/abc123']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps html entity wrappers intact until URL cleanup runs', () => {
|
|
||||||
expect(
|
|
||||||
extractBilibiliUrls({
|
|
||||||
messageText: '',
|
|
||||||
rawEvent: {
|
|
||||||
message: [
|
|
||||||
{
|
|
||||||
data: {
|
|
||||||
data: '<【https://www.bilibili.com/video/BV1xx411c7mD】>',
|
|
||||||
},
|
|
||||||
type: 'xml',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
rawMessage: '',
|
|
||||||
}),
|
|
||||||
).toEqual(['https://www.bilibili.com/video/BV1xx411c7mD']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('preserves ampersand query entities while removing CQ separators', () => {
|
|
||||||
expect(
|
|
||||||
extractBilibiliUrls({
|
|
||||||
messageText:
|
|
||||||
'[CQ:share,url=https://www.bilibili.com/video/BV1xx411c7mD?foo=1&bar=2,title=视频]',
|
|
||||||
rawEvent: {},
|
|
||||||
rawMessage: '',
|
|
||||||
}),
|
|
||||||
).toEqual([
|
|
||||||
'https://www.bilibili.com/video/BV1xx411c7mD?foo=1&bar=2',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
import {
|
|
||||||
cleanBilibiliUrlCandidate,
|
|
||||||
isAllowedBilibiliUrl,
|
|
||||||
parseBilibiliVideoReference,
|
|
||||||
} from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-parser';
|
|
||||||
|
|
||||||
describe('Bilibili URL parser', () => {
|
|
||||||
it('parses BV video URLs while ignoring query, hash, and trailing punctuation', () => {
|
|
||||||
const reference = parseBilibiliVideoReference(
|
|
||||||
'https://www.bilibili.com/video/BV1xx411c7mD/?share_source=qq#reply。',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(reference).toEqual({
|
|
||||||
canonicalVideoId: 'BV1xx411c7mD',
|
|
||||||
kind: 'bvid',
|
|
||||||
sourceUrl:
|
|
||||||
'https://www.bilibili.com/video/BV1xx411c7mD/?share_source=qq#reply',
|
|
||||||
value: 'BV1xx411c7mD',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses av video URLs from mobile Bilibili links', () => {
|
|
||||||
expect(
|
|
||||||
parseBilibiliVideoReference('https://m.bilibili.com/video/av170001'),
|
|
||||||
).toMatchObject({
|
|
||||||
canonicalVideoId: 'av170001',
|
|
||||||
kind: 'aid',
|
|
||||||
value: '170001',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('parses b23.tv paths that directly embed BV or av ids', () => {
|
|
||||||
expect(parseBilibiliVideoReference('https://b23.tv/BV1xx411c7mD')).toEqual({
|
|
||||||
canonicalVideoId: 'BV1xx411c7mD',
|
|
||||||
kind: 'bvid',
|
|
||||||
sourceUrl: 'https://b23.tv/BV1xx411c7mD',
|
|
||||||
value: 'BV1xx411c7mD',
|
|
||||||
});
|
|
||||||
expect(parseBilibiliVideoReference('https://b23.tv/av170001')).toMatchObject(
|
|
||||||
{
|
|
||||||
canonicalVideoId: 'av170001',
|
|
||||||
kind: 'aid',
|
|
||||||
value: '170001',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('allows only Bilibili and b23.tv hosts', () => {
|
|
||||||
expect(isAllowedBilibiliUrl('https://b23.tv/abc123')).toBe(true);
|
|
||||||
expect(isAllowedBilibiliUrl('https://space.bilibili.com/1')).toBe(true);
|
|
||||||
expect(isAllowedBilibiliUrl('https://example.com/video/BV1xx411c7mD')).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cleans card wrappers, html entities, and trailing brackets', () => {
|
|
||||||
expect(
|
|
||||||
cleanBilibiliUrlCandidate(
|
|
||||||
'"https://www.bilibili.com/video/BV1xx411c7mD?p=1")',
|
|
||||||
),
|
|
||||||
).toBe('https://www.bilibili.com/video/BV1xx411c7mD?p=1');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does not parse video ids from query or hash on non-video Bilibili pages', () => {
|
|
||||||
expect(
|
|
||||||
parseBilibiliVideoReference(
|
|
||||||
'https://space.bilibili.com/1?from=/video/BV1xx411c7mD',
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
parseBilibiliVideoReference(
|
|
||||||
'https://www.bilibili.com/search?keyword=BV1xx411c7mD',
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
parseBilibiliVideoReference(
|
|
||||||
'https://space.bilibili.com/1#/video/BV1xx411c7mD',
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects malformed BV path segments with extra characters', () => {
|
|
||||||
expect(
|
|
||||||
parseBilibiliVideoReference(
|
|
||||||
'https://www.bilibili.com/video/BV1xx411c7mDextra',
|
|
||||||
),
|
|
||||||
).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('cleans xml html entities and full-width brackets', () => {
|
|
||||||
expect(
|
|
||||||
cleanBilibiliUrlCandidate(
|
|
||||||
'<【https://www.bilibili.com/video/BV1xx411c7mD】>',
|
|
||||||
),
|
|
||||||
).toBe('https://www.bilibili.com/video/BV1xx411c7mD');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
import { readBilibiliCardRuntimeConfig } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/config/bilibili-card-config';
|
|
||||||
import type { BilibiliCardPluginHost } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types';
|
|
||||||
import { formatBilibiliVideoReply } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-reply-formatter';
|
|
||||||
import { createBilibiliCardGenericHostAdapter } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-card-host';
|
|
||||||
import { BilibiliVideoClient } from '../../../../../src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-video-client';
|
|
||||||
|
|
||||||
describe('Bilibili video client', () => {
|
|
||||||
it('fetches a BV video through the plugin host and normalizes the response', async () => {
|
|
||||||
const host = createHost({
|
|
||||||
requestJson: jest.fn().mockResolvedValue({
|
|
||||||
code: 0,
|
|
||||||
data: {
|
|
||||||
aid: 170001,
|
|
||||||
bvid: 'BV1xx411c7mD',
|
|
||||||
desc: '第一行\n第二行',
|
|
||||||
duration: 125,
|
|
||||||
owner: { name: 'UP主' },
|
|
||||||
pic: 'https://i0.hdslb.com/bfs/archive/demo.jpg',
|
|
||||||
stat: {
|
|
||||||
danmaku: 456,
|
|
||||||
like: 7890,
|
|
||||||
view: 123456,
|
|
||||||
},
|
|
||||||
title: '夏祭',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const client = new BilibiliVideoClient(host);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
client.fetchVideo(
|
|
||||||
{
|
|
||||||
canonicalVideoId: 'BV1xx411c7mD',
|
|
||||||
kind: 'bvid',
|
|
||||||
sourceUrl: 'https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
value: 'BV1xx411c7mD',
|
|
||||||
},
|
|
||||||
readBilibiliCardRuntimeConfig(host),
|
|
||||||
),
|
|
||||||
).resolves.toMatchObject({
|
|
||||||
aid: 170001,
|
|
||||||
bvid: 'BV1xx411c7mD',
|
|
||||||
duration: 125,
|
|
||||||
ownerName: 'UP主',
|
|
||||||
title: '夏祭',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(host.requestJson).toHaveBeenCalledTimes(1);
|
|
||||||
const request = (host.requestJson as jest.Mock).mock.calls[0][0];
|
|
||||||
expect(request.url.toString()).toBe(
|
|
||||||
'https://api.bilibili.com/x/web-interface/view?bvid=BV1xx411c7mD',
|
|
||||||
);
|
|
||||||
expect(request.timeoutMs).toBe(6000);
|
|
||||||
expect(request.context).toBe('Bilibili 视频信息获取');
|
|
||||||
expect(request.failureMessage(502)).toBe(
|
|
||||||
'Bilibili 视频信息获取失败:HTTP 502',
|
|
||||||
);
|
|
||||||
expect(request.invalidJsonMessage).toBe(
|
|
||||||
'Bilibili 视频信息返回不是合法 JSON',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('throws a readable error when Bilibili returns an error code', async () => {
|
|
||||||
const host = createHost({
|
|
||||||
requestJson: jest.fn().mockResolvedValue({
|
|
||||||
code: -404,
|
|
||||||
message: '啥都木有',
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
const client = new BilibiliVideoClient(host);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
client.fetchVideo(
|
|
||||||
{
|
|
||||||
canonicalVideoId: 'av170001',
|
|
||||||
kind: 'aid',
|
|
||||||
sourceUrl: 'https://m.bilibili.com/video/av170001',
|
|
||||||
value: '170001',
|
|
||||||
},
|
|
||||||
readBilibiliCardRuntimeConfig(host),
|
|
||||||
),
|
|
||||||
).rejects.toThrow('Bilibili 视频信息获取失败:啥都木有');
|
|
||||||
|
|
||||||
const request = (host.requestJson as jest.Mock).mock.calls[0][0];
|
|
||||||
expect(request.url.toString()).toBe(
|
|
||||||
'https://api.bilibili.com/x/web-interface/view?aid=170001',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('formats a concise text reply without echoing short links', () => {
|
|
||||||
expect(
|
|
||||||
formatBilibiliVideoReply(
|
|
||||||
{
|
|
||||||
aid: 170001,
|
|
||||||
bvid: 'BV1xx411c7mD',
|
|
||||||
desc: '第一行\n第二行',
|
|
||||||
duration: 125,
|
|
||||||
ownerName: 'UP主',
|
|
||||||
pic: 'https://i0.hdslb.com/bfs/archive/demo.jpg',
|
|
||||||
stat: {
|
|
||||||
danmaku: 456,
|
|
||||||
like: 7890,
|
|
||||||
view: 123456,
|
|
||||||
},
|
|
||||||
title: '夏祭',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
dedupeTtlMs: 600000,
|
|
||||||
descMaxLength: 6,
|
|
||||||
httpTimeoutMs: 6000,
|
|
||||||
maxRedirects: 5,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
).toBe(
|
|
||||||
[
|
|
||||||
'Bilibili 视频解析',
|
|
||||||
'标题:夏祭',
|
|
||||||
'UP:UP主',
|
|
||||||
'时长:02:05',
|
|
||||||
'播放:12.3万 弹幕:456 点赞:7890',
|
|
||||||
'链接:https://www.bilibili.com/video/BV1xx411c7mD',
|
|
||||||
'简介:第一行 第二…',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('clamps runtime config values from the plugin host', () => {
|
|
||||||
const host = createHost({
|
|
||||||
/**
|
|
||||||
* Reads test config values by Bilibili card runtime key.
|
|
||||||
* @param key - Runtime config key requested by `readBilibiliCardRuntimeConfig`.
|
|
||||||
* @returns String value supplied by the current clamp scenario.
|
|
||||||
*/
|
|
||||||
getConfig: (<T = string>(key: string) => {
|
|
||||||
return {
|
|
||||||
QQBOT_BILIBILI_CARD_DEDUPE_TTL_MS: '9999999',
|
|
||||||
QQBOT_BILIBILI_CARD_DESC_MAX_LENGTH: '-1',
|
|
||||||
QQBOT_BILIBILI_CARD_HTTP_TIMEOUT_MS: '10',
|
|
||||||
QQBOT_BILIBILI_CARD_MAX_REDIRECTS: '99',
|
|
||||||
}[key] as T | undefined;
|
|
||||||
}) as BilibiliCardPluginHost['getConfig'],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(readBilibiliCardRuntimeConfig(host)).toEqual({
|
|
||||||
dedupeTtlMs: 3600000,
|
|
||||||
descMaxLength: 0,
|
|
||||||
httpTimeoutMs: 1000,
|
|
||||||
maxRedirects: 10,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('emits generic host warnings without surfacing rejected log calls', async () => {
|
|
||||||
const host = {
|
|
||||||
warn: jest.fn().mockRejectedValue(new Error('log down')),
|
|
||||||
};
|
|
||||||
const adapter = createBilibiliCardGenericHostAdapter(host, {});
|
|
||||||
|
|
||||||
adapter.warn?.('hello');
|
|
||||||
await Promise.resolve();
|
|
||||||
await Promise.resolve();
|
|
||||||
|
|
||||||
expect(host.warn).toHaveBeenCalledWith('hello');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the package-local Bilibili host contract used by client and config tests.
|
|
||||||
* @param overrides - Test doubles for host methods involved in the current assertion.
|
|
||||||
* @returns Host object with harmless defaults for unused plugin capabilities.
|
|
||||||
*/
|
|
||||||
function createHost(
|
|
||||||
overrides: Partial<BilibiliCardPluginHost> = {},
|
|
||||||
): BilibiliCardPluginHost {
|
|
||||||
return {
|
|
||||||
getBoundEventPluginKeys: jest.fn().mockResolvedValue([]),
|
|
||||||
getConfig: jest.fn(),
|
|
||||||
requestJson: jest.fn(),
|
|
||||||
resolveRedirect: jest.fn(),
|
|
||||||
sendText: jest.fn().mockResolvedValue(undefined),
|
|
||||||
warn: jest.fn(),
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -41,13 +41,7 @@ describe('QQBot existing plugin platform migration', () => {
|
|||||||
readdirSync(pluginRoot)
|
readdirSync(pluginRoot)
|
||||||
.filter((name) => statSync(join(pluginRoot, name)).isDirectory())
|
.filter((name) => statSync(join(pluginRoot, name)).isDirectory())
|
||||||
.sort(),
|
.sort(),
|
||||||
).toEqual([
|
).toEqual(['bangdream', 'ff14-market', 'fflogs', 'repeater']);
|
||||||
'bangdream',
|
|
||||||
'bilibili-card',
|
|
||||||
'ff14-market',
|
|
||||||
'fflogs',
|
|
||||||
'repeater',
|
|
||||||
]);
|
|
||||||
|
|
||||||
const legacySources = collectFiles(legacyPluginRoot).filter((filePath) =>
|
const legacySources = collectFiles(legacyPluginRoot).filter((filePath) =>
|
||||||
filePath.endsWith('.ts'),
|
filePath.endsWith('.ts'),
|
||||||
@ -56,13 +50,8 @@ describe('QQBot existing plugin platform migration', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('declares parseable platform manifests for every existing plugin', () => {
|
it('declares parseable platform manifests for every existing plugin', () => {
|
||||||
const manifests = [
|
const manifests = ['bangdream', 'ff14-market', 'fflogs', 'repeater'].map(
|
||||||
'bangdream',
|
(pluginName) => {
|
||||||
'bilibili-card',
|
|
||||||
'ff14-market',
|
|
||||||
'fflogs',
|
|
||||||
'repeater',
|
|
||||||
].map((pluginName) => {
|
|
||||||
const root = join(pluginRoot, pluginName);
|
const root = join(pluginRoot, pluginName);
|
||||||
const manifest = parseQqbotPluginManifest(
|
const manifest = parseQqbotPluginManifest(
|
||||||
readJson(join(root, 'plugin.json')),
|
readJson(join(root, 'plugin.json')),
|
||||||
@ -71,11 +60,11 @@ describe('QQBot existing plugin platform migration', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
return manifest;
|
return manifest;
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
expect(manifests.map((manifest) => manifest.pluginKey).sort()).toEqual([
|
expect(manifests.map((manifest) => manifest.pluginKey).sort()).toEqual([
|
||||||
'bangdream',
|
'bangdream',
|
||||||
'bilibili-card',
|
|
||||||
'ff14-market',
|
'ff14-market',
|
||||||
'fflogs',
|
'fflogs',
|
||||||
'repeater',
|
'repeater',
|
||||||
@ -90,39 +79,6 @@ describe('QQBot existing plugin platform migration', () => {
|
|||||||
).toBe(true);
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('seeds Bilibili card plugin event metadata in refactor v3 SQL', () => {
|
|
||||||
const refactorSeedSql = readFileSync(
|
|
||||||
join(repoRoot, 'sql/refactor-v3/01-seed-core.sql'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
const refactorVerifySql = readFileSync(
|
|
||||||
join(repoRoot, 'sql/refactor-v3/99-verify.sql'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(refactorSeedSql).toContain(`'bilibili-card'`);
|
|
||||||
expect(refactorSeedSql).toContain(`'bilibili-card.message'`);
|
|
||||||
expect(refactorSeedSql).toContain(`'pluginKey', 'bilibili-card'`);
|
|
||||||
expect(refactorSeedSql).toContain(`'runtime', JSON_OBJECT(`);
|
|
||||||
expect(refactorSeedSql).toContain(`'permissions', JSON_ARRAY(`);
|
|
||||||
expect(refactorSeedSql).toContain(`'configSchema', JSON_OBJECT(`);
|
|
||||||
expect(refactorSeedSql).toContain(`'bilibili-card:1.0.0'`);
|
|
||||||
expect(refactorSeedSql).toContain(`'enabled',`);
|
|
||||||
expect(refactorSeedSql).toContain(`'stopped',`);
|
|
||||||
expect(refactorSeedSql).toContain(
|
|
||||||
`version_id = VALUES(version_id),`,
|
|
||||||
);
|
|
||||||
expect(refactorVerifySql).toContain(
|
|
||||||
`'seed_qqbot_plugin_version_bilibili_card'`,
|
|
||||||
);
|
|
||||||
expect(refactorVerifySql).toContain(
|
|
||||||
`'seed_qqbot_plugin_installation_bilibili_card'`,
|
|
||||||
);
|
|
||||||
expect(refactorVerifySql).toContain(
|
|
||||||
`JOIN qqbot_plugin p ON p.id = h.plugin_id`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps BangDream manifest operations as the single metadata source', () => {
|
it('keeps BangDream manifest operations as the single metadata source', () => {
|
||||||
const manifest = parseQqbotPluginManifest(
|
const manifest = parseQqbotPluginManifest(
|
||||||
readJson(join(pluginRoot, 'bangdream/plugin.json')),
|
readJson(join(pluginRoot, 'bangdream/plugin.json')),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user