From c14bcea2dc5aae6fce3f8e9d4ee77500ab6ec6fb Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 19 Jun 2026 19:06:54 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0QQBot=20Bilibili?= =?UTF-8?q?=E5=8D=A1=E7=89=87=E4=BA=8B=E4=BB=B6=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qqbot/plugins/bilibili-card/plugin.json | 64 ++++ .../application/bilibili-card-application.ts | 192 ++++++++++++ .../src/domain/bilibili-card.types.ts | 25 ++ .../message/bilibili-card-message.handler.ts | 20 ++ .../qqbot/plugins/bilibili-card/src/index.ts | 136 +++++++++ .../bilibili-card-application.spec.ts | 278 ++++++++++++++++++ 6 files changed, 715 insertions(+) create mode 100644 src/modules/qqbot/plugins/bilibili-card/plugin.json create mode 100644 src/modules/qqbot/plugins/bilibili-card/src/application/bilibili-card-application.ts create mode 100644 src/modules/qqbot/plugins/bilibili-card/src/events/message/bilibili-card-message.handler.ts create mode 100644 src/modules/qqbot/plugins/bilibili-card/src/index.ts create mode 100644 test/modules/qqbot/plugins/bilibili-card/bilibili-card-application.spec.ts 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..61aaf53 --- /dev/null +++ b/src/modules/qqbot/plugins/bilibili-card/src/application/bilibili-card-application.ts @@ -0,0 +1,192 @@ +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); + 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; + } + + /** + * 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) { + this.host.warn?.(message); + } +} + +/** + * 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; + } +} + +/** + * 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/domain/bilibili-card.types.ts b/src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts index 1d60cb8..0d0996f 100644 --- 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 @@ -18,6 +18,31 @@ export type BilibiliUrlExtractionInput = { 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; 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/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..5e610c4 --- /dev/null +++ b/test/modules/qqbot/plugins/bilibili-card/bilibili-card-application.spec.ts @@ -0,0 +1,278 @@ +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('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('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: '夏祭', + }, + }; +}