chore: 合并Bilibili卡片插件分支

This commit is contained in:
sunlei 2026-06-19 21:06:06 +08:00
commit 30f8c2bbc3
27 changed files with 2680 additions and 32 deletions

19
API.md
View File

@ -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 包装层或纯转接目录。

View File

@ -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` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。

View File

@ -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,

View File

@ -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'

View File

@ -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.

View File

@ -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 }),

View File

@ -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;
}

View 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": []
}

View File

@ -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}`;
}

View File

@ -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));
}

View File

@ -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;
};

View File

@ -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)}`;
}

View File

@ -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);
}

View File

@ -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('&amp;', '&')
.replaceAll('&quot;', '"')
.replaceAll('&#34;', '"')
.replaceAll('&lt;', '<')
.replaceAll('&#60;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&#62;', '>')
.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;
}

View File

@ -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);
};
}

View 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);
}

View File

@ -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(),
};
}

View File

@ -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, '未知错误');
}

View File

@ -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'`);
});
});

View File

@ -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.

View File

@ -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 = {

View File

@ -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 });
});
});

View File

@ -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: '夏祭',
},
};
}

View File

@ -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: '&lt;【https://www.bilibili.com/video/BV1xx411c7mD】&gt;',
},
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&amp;bar=2,title=视频]',
rawEvent: {},
rawMessage: '',
}),
).toEqual([
'https://www.bilibili.com/video/BV1xx411c7mD?foo=1&bar=2',
]);
});
});

View File

@ -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(
'&quot;https://www.bilibili.com/video/BV1xx411c7mD?p=1&quot;',
),
).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(
'&lt;【https://www.bilibili.com/video/BV1xx411c7mD】&gt;',
),
).toBe('https://www.bilibili.com/video/BV1xx411c7mD');
});
});

View File

@ -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 视频解析',
'标题:夏祭',
'UPUP主',
'时长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,
};
}

View File

@ -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')),