chore: 合并Bilibili卡片插件分支
This commit is contained in:
commit
30f8c2bbc3
19
API.md
19
API.md
@ -500,6 +500,25 @@ Admin 入口为 `/qqbot/plugin-task`,用于分页查看任务、启停、修
|
||||
|
||||
## 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
|
||||
|
||||
插件 key:`bangdream`。旧 `bangDream` 作为兼容别名仍可解析;当前源码根目录为 `src/modules/qqbot/plugins/bangdream/src`,按第三期插件结构拆分为 `operations`、`domain/*`、`application`、`infrastructure/integration`、`infrastructure/storage`、`config`、`assets` 和 `theme`,不再使用旧 `tsugu` 子目录、宿主 builtins 包装层或纯转接目录。
|
||||
|
||||
@ -24,6 +24,7 @@
|
||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
||||
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 |
|
||||
| `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
||||
| `qqbot/plugins/bilibili-card` | 解析 QQ/NapCat Bilibili 卡片和短链,按账号事件绑定回复视频文字摘要 |
|
||||
| `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
||||
| `qqbot/plugins/fflogs` | FFLogs v2 GraphQL 角色排名和指定高难最近记录查询 |
|
||||
| `minio` | Bucket 检查、上传、列表、临时 URL、代理下载、删除 |
|
||||
@ -162,6 +163,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
|
||||
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
|
||||
- QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
|
||||
- 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`。
|
||||
- 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` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
||||
|
||||
@ -249,12 +249,132 @@ INSERT INTO qqbot_plugin (
|
||||
'Repeater',
|
||||
'Built-in repeater event plugin metadata.',
|
||||
'installed'
|
||||
),
|
||||
(
|
||||
1000000000000000105,
|
||||
'bilibili-card',
|
||||
'Bilibili Card',
|
||||
'Built-in Bilibili card event plugin metadata.',
|
||||
'installed'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
plugin_name = VALUES(plugin_name),
|
||||
description = VALUES(description),
|
||||
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 (
|
||||
id,
|
||||
operation_key,
|
||||
|
||||
@ -36,6 +36,39 @@ FROM qqbot_plugin
|
||||
WHERE plugin_key = 'bangdream'
|
||||
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
|
||||
FROM qqbot_command
|
||||
WHERE command_key = 'bangdream_song'
|
||||
|
||||
@ -9,6 +9,7 @@ import type { QqbotPluginPackageDescriptor } from '@/modules/qqbot/plugin-platfo
|
||||
import {
|
||||
QqbotPluginHttpClientService,
|
||||
type QqbotPluginHttpClientRequest,
|
||||
type QqbotPluginResolveRedirectRequest,
|
||||
} from '../sdk/plugin-http-client.service';
|
||||
import type {
|
||||
QqbotPluginHostCallRequest,
|
||||
@ -108,6 +109,8 @@ export class QqbotPluginHostBridgeService {
|
||||
return this.httpClient.requestBuffer(getHttpRequestOptions(args));
|
||||
case 'requestJson':
|
||||
return this.httpClient.requestJson(getHttpRequestOptions(args));
|
||||
case 'resolveRedirect':
|
||||
return this.httpClient.resolveRedirect(getRedirectRequestOptions(args));
|
||||
case 'sendText':
|
||||
return this.sendService.sendText(
|
||||
args.input as Parameters<QqbotSendService['sendText']>[0],
|
||||
@ -350,6 +353,21 @@ function getHttpRequestOptions(
|
||||
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.
|
||||
* @param value - Worker-supplied value that may contain named host-call arguments.
|
||||
|
||||
@ -90,6 +90,7 @@ const HOST_ARGUMENT_MAPPERS: Record<string, HostArgumentMapper> = {
|
||||
renameFile: (from, to) => ({ from, to }),
|
||||
requestBuffer: (options) => ({ options }),
|
||||
requestJson: (options) => ({ options }),
|
||||
resolveRedirect: (input) => ({ input }),
|
||||
sendText: (input) => ({ input }),
|
||||
sleep: (ms) => ({ ms }),
|
||||
unbindEventPlugin: (selfId, pluginKey) => ({ pluginKey, selfId }),
|
||||
|
||||
@ -14,8 +14,53 @@ export type QqbotPluginHttpClientRequest = {
|
||||
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()
|
||||
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 插件平台流程。
|
||||
* @param input - input 输入;使用 `invalidJsonMessage`、`context` 字段生成结果。
|
||||
@ -145,6 +190,72 @@ export class QqbotPluginHttpClientService {
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -160,3 +271,37 @@ function createPluginHttpError(message: string, statusCode: number) {
|
||||
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;
|
||||
}
|
||||
|
||||
64
src/modules/qqbot/plugins/bilibili-card/plugin.json
Normal file
64
src/modules/qqbot/plugins/bilibili-card/plugin.json
Normal file
@ -0,0 +1,64 @@
|
||||
{
|
||||
"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": []
|
||||
}
|
||||
@ -0,0 +1,220 @@
|
||||
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}`;
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
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));
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
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;
|
||||
};
|
||||
@ -0,0 +1,85 @@
|
||||
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)}…`;
|
||||
}
|
||||
@ -0,0 +1,242 @@
|
||||
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);
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
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;
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
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);
|
||||
};
|
||||
}
|
||||
136
src/modules/qqbot/plugins/bilibili-card/src/index.ts
Normal file
136
src/modules/qqbot/plugins/bilibili-card/src/index.ts
Normal file
@ -0,0 +1,136 @@
|
||||
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);
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
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(),
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
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,6 +142,7 @@ describe('QQBot current operation matrix', () => {
|
||||
it('freezes built-in plugin manifests and exposed capabilities', () => {
|
||||
const manifests = {
|
||||
bangdream: readManifest('bangdream'),
|
||||
bilibiliCard: readManifest('bilibili-card'),
|
||||
ff14: readManifest('ff14-market'),
|
||||
fflogs: readManifest('fflogs'),
|
||||
repeater: readManifest('repeater'),
|
||||
@ -157,6 +158,23 @@ describe('QQBot current operation matrix', () => {
|
||||
})),
|
||||
).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(
|
||||
manifests.ff14.operations.map((operation) => ({
|
||||
aliases: operation.aliases,
|
||||
@ -215,6 +233,10 @@ describe('QQBot current operation matrix', () => {
|
||||
|
||||
it('freezes current online command seed linkage for command plugins', () => {
|
||||
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) {
|
||||
expect(seedSql).toContain(`'${operation.key}'`);
|
||||
@ -224,5 +246,7 @@ describe('QQBot current operation matrix', () => {
|
||||
expect(seedSql).toContain(`'ff14-market', 'ff14.market.price'`);
|
||||
expect(seedSql).toContain(`'fflogs', 'fflogs.character.summary'`);
|
||||
expect(seedSql).toContain(`'bangdream', 'bangdream.event.stage', 'plain'`);
|
||||
expect(refactorSeedSql).toContain(`'bilibili-card'`);
|
||||
expect(refactorSeedSql).toContain(`'bilibili-card.message'`);
|
||||
});
|
||||
});
|
||||
|
||||
@ -97,6 +97,7 @@ describe('QQBot plugin package boundary', () => {
|
||||
|
||||
expect(pluginDirs).toEqual([
|
||||
'bangdream',
|
||||
'bilibili-card',
|
||||
'ff14-market',
|
||||
'fflogs',
|
||||
'repeater',
|
||||
@ -104,23 +105,27 @@ describe('QQBot plugin package boundary', () => {
|
||||
});
|
||||
|
||||
it('uses the same package shape for every built-in plugin', () => {
|
||||
const missing = ['bangdream', 'ff14-market', 'fflogs', 'repeater'].flatMap(
|
||||
(pluginKey) => {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(pluginRoot, pluginKey, 'plugin.json'), 'utf8'),
|
||||
) as { events?: unknown[]; operations?: unknown[] };
|
||||
const requiredPaths = [
|
||||
...requiredPluginPaths,
|
||||
...((manifest.operations || []).length
|
||||
? requiredCommandPluginPaths
|
||||
: []),
|
||||
...((manifest.events || []).length ? requiredEventPluginPaths : []),
|
||||
];
|
||||
return requiredPaths
|
||||
.map((pathName) => `${pluginKey}/${pathName}`)
|
||||
.filter((pathName) => !existsSync(join(pluginRoot, pathName)));
|
||||
},
|
||||
);
|
||||
const missing = [
|
||||
'bangdream',
|
||||
'bilibili-card',
|
||||
'ff14-market',
|
||||
'fflogs',
|
||||
'repeater',
|
||||
].flatMap((pluginKey) => {
|
||||
const manifest = JSON.parse(
|
||||
readFileSync(join(pluginRoot, pluginKey, 'plugin.json'), 'utf8'),
|
||||
) as { events?: unknown[]; operations?: unknown[] };
|
||||
const requiredPaths = [
|
||||
...requiredPluginPaths,
|
||||
...((manifest.operations || []).length
|
||||
? requiredCommandPluginPaths
|
||||
: []),
|
||||
...((manifest.events || []).length ? requiredEventPluginPaths : []),
|
||||
];
|
||||
return requiredPaths
|
||||
.map((pathName) => `${pluginKey}/${pathName}`)
|
||||
.filter((pathName) => !existsSync(join(pluginRoot, pathName)));
|
||||
});
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
@ -128,6 +133,7 @@ describe('QQBot plugin package boundary', () => {
|
||||
it('does not keep third-phase package directories as empty shells', () => {
|
||||
const emptyRequiredDirs = [
|
||||
'bangdream',
|
||||
'bilibili-card',
|
||||
'ff14-market',
|
||||
'fflogs',
|
||||
'repeater',
|
||||
@ -242,6 +248,7 @@ describe('QQBot plugin package boundary', () => {
|
||||
it('keeps plugin package entrypoints limited to createPlugin', () => {
|
||||
const extraExports = [
|
||||
'bangdream',
|
||||
'bilibili-card',
|
||||
'ff14-market',
|
||||
'fflogs',
|
||||
'repeater',
|
||||
@ -319,7 +326,7 @@ describe('plugin platform package decoupling', () => {
|
||||
.join('|'),
|
||||
);
|
||||
const forbiddenBranchPattern =
|
||||
/pluginKey\s*(?:={2,3})\s*['"`](bangdream|ff14-market|fflogs|repeater)['"`]|case\s+['"`](bangdream|ff14-market|fflogs|repeater)['"`]/;
|
||||
/pluginKey\s*(?:={2,3})\s*['"`](bangdream|bilibili-card|ff14-market|fflogs|repeater)['"`]|case\s+['"`](bangdream|bilibili-card|ff14-market|fflogs|repeater)['"`]/;
|
||||
|
||||
/**
|
||||
* Escapes a literal token before it is placed into the architecture gate regexp.
|
||||
|
||||
@ -13,7 +13,11 @@ describe('QQBot plugin host bridge', () => {
|
||||
let tempRoot: string;
|
||||
let bridge: QqbotPluginHostBridgeService;
|
||||
let configValues: Record<string, string | undefined>;
|
||||
let httpClient: { requestBuffer: jest.Mock; requestJson: jest.Mock };
|
||||
let httpClient: {
|
||||
requestBuffer: jest.Mock;
|
||||
requestJson: jest.Mock;
|
||||
resolveRedirect: jest.Mock;
|
||||
};
|
||||
let accountService: {
|
||||
bindEventPlugin: jest.Mock;
|
||||
getBoundEventPluginKeys: jest.Mock;
|
||||
@ -38,6 +42,7 @@ describe('QQBot plugin host bridge', () => {
|
||||
httpClient = {
|
||||
requestBuffer: jest.fn(),
|
||||
requestJson: jest.fn().mockResolvedValue({ ok: true }),
|
||||
resolveRedirect: jest.fn(),
|
||||
};
|
||||
accountService = {
|
||||
bindEventPlugin: jest.fn().mockResolvedValue(true),
|
||||
@ -123,6 +128,33 @@ describe('QQBot plugin host bridge', () => {
|
||||
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 () => {
|
||||
const descriptor = createDescriptor();
|
||||
const sendInput = {
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,365 @@
|
||||
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: '夏祭',
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,183 @@
|
||||
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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,97 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,183 @@
|
||||
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,7 +41,13 @@ describe('QQBot existing plugin platform migration', () => {
|
||||
readdirSync(pluginRoot)
|
||||
.filter((name) => statSync(join(pluginRoot, name)).isDirectory())
|
||||
.sort(),
|
||||
).toEqual(['bangdream', 'ff14-market', 'fflogs', 'repeater']);
|
||||
).toEqual([
|
||||
'bangdream',
|
||||
'bilibili-card',
|
||||
'ff14-market',
|
||||
'fflogs',
|
||||
'repeater',
|
||||
]);
|
||||
|
||||
const legacySources = collectFiles(legacyPluginRoot).filter((filePath) =>
|
||||
filePath.endsWith('.ts'),
|
||||
@ -50,21 +56,26 @@ describe('QQBot existing plugin platform migration', () => {
|
||||
});
|
||||
|
||||
it('declares parseable platform manifests for every existing plugin', () => {
|
||||
const manifests = ['bangdream', 'ff14-market', 'fflogs', 'repeater'].map(
|
||||
(pluginName) => {
|
||||
const root = join(pluginRoot, pluginName);
|
||||
const manifest = parseQqbotPluginManifest(
|
||||
readJson(join(root, 'plugin.json')),
|
||||
{
|
||||
pluginRoot: root,
|
||||
},
|
||||
);
|
||||
return manifest;
|
||||
},
|
||||
);
|
||||
const manifests = [
|
||||
'bangdream',
|
||||
'bilibili-card',
|
||||
'ff14-market',
|
||||
'fflogs',
|
||||
'repeater',
|
||||
].map((pluginName) => {
|
||||
const root = join(pluginRoot, pluginName);
|
||||
const manifest = parseQqbotPluginManifest(
|
||||
readJson(join(root, 'plugin.json')),
|
||||
{
|
||||
pluginRoot: root,
|
||||
},
|
||||
);
|
||||
return manifest;
|
||||
});
|
||||
|
||||
expect(manifests.map((manifest) => manifest.pluginKey).sort()).toEqual([
|
||||
'bangdream',
|
||||
'bilibili-card',
|
||||
'ff14-market',
|
||||
'fflogs',
|
||||
'repeater',
|
||||
@ -79,6 +90,39 @@ describe('QQBot existing plugin platform migration', () => {
|
||||
).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', () => {
|
||||
const manifest = parseQqbotPluginManifest(
|
||||
readJson(join(pluginRoot, 'bangdream/plugin.json')),
|
||||
|
||||
Loading…
Reference in New Issue
Block a user