diff --git a/API.md b/API.md index de893a7..2866b80 100644 --- a/API.md +++ b/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 包装层或纯转接目录。 diff --git a/README.md b/README.md index a451453..cc64eff 100644 --- a/README.md +++ b/README.md @@ -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` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。 diff --git a/sql/refactor-v3/01-seed-core.sql b/sql/refactor-v3/01-seed-core.sql index ca407ad..e33cad1 100644 --- a/sql/refactor-v3/01-seed-core.sql +++ b/sql/refactor-v3/01-seed-core.sql @@ -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, diff --git a/sql/refactor-v3/99-verify.sql b/sql/refactor-v3/99-verify.sql index f61dfa9..5eb57b0 100644 --- a/sql/refactor-v3/99-verify.sql +++ b/sql/refactor-v3/99-verify.sql @@ -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' diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service.ts index 18860b2..1ed5318 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service.ts @@ -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[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, +): 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. diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-worker.thread.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-worker.thread.ts index 84489e6..79c8377 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-worker.thread.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-worker.thread.ts @@ -90,6 +90,7 @@ const HOST_ARGUMENT_MAPPERS: Record = { 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 }), diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service.ts index 0f8d533..c72b462 100644 --- a/src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service.ts +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service.ts @@ -14,8 +14,53 @@ export type QqbotPluginHttpClientRequest = { url: string | URL; }; +export type QqbotPluginResolveRedirectRequest = { + context?: string; + failureMessage?: (statusCode: number) => string; + headers?: Record; + 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 { + 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 { + const timeoutMs = input.timeoutMs || 8000; + const context = input.context || '插件 HTTP 重定向'; + + return new Promise((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; +} diff --git a/src/modules/qqbot/plugins/bilibili-card/plugin.json b/src/modules/qqbot/plugins/bilibili-card/plugin.json new file mode 100644 index 0000000..1c82525 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/plugin.json @@ -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": [] +} diff --git a/src/modules/qqbot/plugins/bilibili-card/src/application/bilibili-card-application.ts b/src/modules/qqbot/plugins/bilibili-card/src/application/bilibili-card-application.ts new file mode 100644 index 0000000..8aeefa5 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/application/bilibili-card-application.ts @@ -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(); + 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 { + 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}`; +} diff --git a/src/modules/qqbot/plugins/bilibili-card/src/config/bilibili-card-config.ts b/src/modules/qqbot/plugins/bilibili-card/src/config/bilibili-card-config.ts new file mode 100644 index 0000000..c2b7e93 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/config/bilibili-card-config.ts @@ -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)); +} diff --git a/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts new file mode 100644 index 0000000..0d0996f --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts @@ -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; + 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; + 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; + getConfig: (key: string) => T | undefined; + requestJson: ( + request: BilibiliCardHostJsonRequest, + ) => Promise; + resolveRedirect: ( + request: BilibiliCardRedirectRequest, + ) => Promise; + sendText: (input: { + channelId?: string; + guildId?: string; + message: string; + selfId: string; + targetId: string; + targetType: string; + }) => Promise; + 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; +}; diff --git a/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-reply-formatter.ts b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-reply-formatter.ts new file mode 100644 index 0000000..25877f9 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-reply-formatter.ts @@ -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)}…`; +} diff --git a/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-extractor.ts b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-extractor.ts new file mode 100644 index 0000000..75a6333 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-extractor.ts @@ -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(); + 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(), 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, + 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(), + 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 { + 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, + depth: number, +): value is Record | 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); +} diff --git a/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-parser.ts b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-parser.ts new file mode 100644 index 0000000..c234be3 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-parser.ts @@ -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; +} diff --git a/src/modules/qqbot/plugins/bilibili-card/src/events/message/bilibili-card-message.handler.ts b/src/modules/qqbot/plugins/bilibili-card/src/events/message/bilibili-card-message.handler.ts new file mode 100644 index 0000000..df3b0f4 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/events/message/bilibili-card-message.handler.ts @@ -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); + }; +} diff --git a/src/modules/qqbot/plugins/bilibili-card/src/index.ts b/src/modules/qqbot/plugins/bilibili-card/src/index.ts new file mode 100644 index 0000000..b324a75 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/index.ts @@ -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; + manifest: BilibiliCardManifest & { key?: string }; + normalizeError: (error: unknown, fallback?: string) => string | Error; + now: () => Date; + runtime: { + configSnapshot: Record; + 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, +) { + 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); +} diff --git a/src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-card-host.ts b/src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-card-host.ts new file mode 100644 index 0000000..76f645b --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-card-host.ts @@ -0,0 +1,109 @@ +import type { + BilibiliCardHostJsonRequest, + BilibiliCardPluginHost, +} from '../../domain/bilibili-card.types'; + +export type BilibiliCardGenericHost = Record; + +/** + * 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, +): 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: (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 (request) => + await callBilibiliCardGenericHost( + 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( + host: BilibiliCardGenericHost, + method: string, + ...args: unknown[] +): Promise { + 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(), + }; +} diff --git a/src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-video-client.ts b/src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-video-client.ts new file mode 100644 index 0000000..91d67f0 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-video-client.ts @@ -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, + ): Promise { + const response = await this.host.requestJson({ + 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, '未知错误'); +} diff --git a/test/modules/qqbot/architecture/qqbot-current-operation-matrix.spec.ts b/test/modules/qqbot/architecture/qqbot-current-operation-matrix.spec.ts index c33f0e5..8047e05 100644 --- a/test/modules/qqbot/architecture/qqbot-current-operation-matrix.spec.ts +++ b/test/modules/qqbot/architecture/qqbot-current-operation-matrix.spec.ts @@ -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'`); }); }); diff --git a/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts b/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts index 2fd2987..e888e7b 100644 --- a/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts +++ b/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts @@ -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. diff --git a/test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts b/test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts index a2a95a9..9d82e8c 100644 --- a/test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts +++ b/test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts @@ -13,7 +13,11 @@ describe('QQBot plugin host bridge', () => { let tempRoot: string; let bridge: QqbotPluginHostBridgeService; let configValues: Record; - 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 = { diff --git a/test/modules/qqbot/plugin-platform/plugin-http-client.spec.ts b/test/modules/qqbot/plugin-platform/plugin-http-client.spec.ts new file mode 100644 index 0000000..b75f853 --- /dev/null +++ b/test/modules/qqbot/plugin-platform/plugin-http-client.spec.ts @@ -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((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((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 }); + }); +}); diff --git a/test/modules/qqbot/plugins/bilibili-card/bilibili-card-application.spec.ts b/test/modules/qqbot/plugins/bilibili-card/bilibili-card-application.spec.ts new file mode 100644 index 0000000..2ae3396 --- /dev/null +++ b/test/modules/qqbot/plugins/bilibili-card/bilibili-card-application.spec.ts @@ -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 { + 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 { + 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: '夏祭', + }, + }; +} diff --git a/test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts b/test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts new file mode 100644 index 0000000..9ebca74 --- /dev/null +++ b/test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts @@ -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: '', + }, + 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', + ]); + }); +}); diff --git a/test/modules/qqbot/plugins/bilibili-card/bilibili-url-parser.spec.ts b/test/modules/qqbot/plugins/bilibili-card/bilibili-url-parser.spec.ts new file mode 100644 index 0000000..9adc902 --- /dev/null +++ b/test/modules/qqbot/plugins/bilibili-card/bilibili-url-parser.spec.ts @@ -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'); + }); +}); diff --git a/test/modules/qqbot/plugins/bilibili-card/bilibili-video-client.spec.ts b/test/modules/qqbot/plugins/bilibili-card/bilibili-video-client.spec.ts new file mode 100644 index 0000000..472ad4b --- /dev/null +++ b/test/modules/qqbot/plugins/bilibili-card/bilibili-video-client.spec.ts @@ -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: ((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 { + return { + getBoundEventPluginKeys: jest.fn().mockResolvedValue([]), + getConfig: jest.fn(), + requestJson: jest.fn(), + resolveRedirect: jest.fn(), + sendText: jest.fn().mockResolvedValue(undefined), + warn: jest.fn(), + ...overrides, + }; +} diff --git a/test/modules/qqbot/plugins/plugin-platform-migration.spec.ts b/test/modules/qqbot/plugins/plugin-platform-migration.spec.ts index 068e369..82b4612 100644 --- a/test/modules/qqbot/plugins/plugin-platform-migration.spec.ts +++ b/test/modules/qqbot/plugins/plugin-platform-migration.spec.ts @@ -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')),