# QQBot Bilibili Card Plugin Implementation Plan
> **Execution note:** Execute this plan task-by-task with the KT-local workflow and use the checkboxes to track plan state.
**Goal:** Build a QQBot built-in event plugin that parses Bilibili links from QQ/NapCat card messages and replies with a concise video summary.
**Architecture:** Add a new third-phase plugin package under `src/modules/qqbot/plugins/bilibili-card`. Keep parsing and formatting in package-local domain files, keep Bilibili HTTP and short-link redirect access behind the generic plugin host bridge, and keep activation controlled by existing account plugin bindings.
**Tech Stack:** NestJS 11 host platform, QQBot plugin worker thread runtime, Jest, TypeScript, Node `http`/`https` for host-mediated HTTP, existing `pnpm` scripts.
---
## File Structure
Create:
- `src/modules/qqbot/plugins/bilibili-card/plugin.json`
Built-in plugin manifest: key, event, runtime budget, permissions, config keys, no command operations.
- `src/modules/qqbot/plugins/bilibili-card/src/index.ts`
Package entry. Exports only `createPlugin`.
- `src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts`
Package-local message, host, config, video and reference types.
- `src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-parser.ts`
Pure URL cleanup, domain allowlist and BV/av extraction.
- `src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-extractor.ts`
Pure recursive extraction from normalized message and raw OneBot card payloads.
- `src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-reply-formatter.ts`
Pure text reply formatting and number/duration formatting.
- `src/modules/qqbot/plugins/bilibili-card/src/config/bilibili-card-config.ts`
Runtime config parsing from package host.
- `src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-card-host.ts`
Package-local host adapter types and generic worker host calls.
- `src/modules/qqbot/plugins/bilibili-card/src/infrastructure/integration/bilibili-video-client.ts`
Host-mediated Bilibili video API client.
- `src/modules/qqbot/plugins/bilibili-card/src/application/bilibili-card-application.ts`
Binding check, dedupe, redirect resolution, API fetch, send and warn orchestration.
- `src/modules/qqbot/plugins/bilibili-card/src/events/message/bilibili-card-message.handler.ts`
Event handler factory that delegates `message` events to the application.
- `test/modules/qqbot/plugins/bilibili-card/bilibili-url-parser.spec.ts`
- `test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts`
- `test/modules/qqbot/plugins/bilibili-card/bilibili-video-client.spec.ts`
- `test/modules/qqbot/plugins/bilibili-card/bilibili-card-application.spec.ts`
- `test/modules/qqbot/plugin-platform/plugin-http-client.spec.ts`
Modify:
- `src/modules/qqbot/plugin-platform/infrastructure/integration/sdk/plugin-http-client.service.ts`
Add bounded `resolveRedirect` generic HTTP capability.
- `src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-host-bridge.service.ts`
Dispatch `resolveRedirect` host calls to the plugin HTTP client.
- `src/modules/qqbot/plugin-platform/infrastructure/integration/runtime/plugin-worker.thread.ts`
Add an explicit argument mapper for `resolveRedirect`.
- `test/modules/qqbot/plugin-platform/plugin-host-bridge.spec.ts`
Assert bridge delegates `resolveRedirect`.
- `test/modules/qqbot/plugins/plugin-platform-migration.spec.ts`
Include `bilibili-card` in plugin discovery and manifest parse expectations.
- `test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts`
Include `bilibili-card` in approved built-in plugin keys and structure gates.
- `test/modules/qqbot/architecture/qqbot-current-operation-matrix.spec.ts`
Freeze `bilibili-card` event capability and keep command seed checks command-only.
- `sql/refactor-v3/01-seed-core.sql`
Seed `bilibili-card` plugin, version, installation and event handler metadata.
- `sql/refactor-v3/99-verify.sql`
Add seed verification for `bilibili-card`.
- `TASKS.md`
Record implementation evidence after code and verification.
Do not create:
- `src/modules/qqbot/plugin-platform/infrastructure/integration/builtins/**`
- per-plugin Nest wrapper services
- `src/modules/qqbot/plugins/bilibili-card/src/index.ts` re-export buckets
- `.gitkeep`
- command operation metadata for this plugin
---
### Task 1: Pure Bilibili URL Parser
**Files:**
- Create: `test/modules/qqbot/plugins/bilibili-card/bilibili-url-parser.spec.ts`
- Create: `src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts`
- Create: `src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-parser.ts`
- [ ] **Step 1: Write the failing parser tests**
Create `test/modules/qqbot/plugins/bilibili-card/bilibili-url-parser.spec.ts`:
```ts
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('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');
});
});
```
- [ ] **Step 2: Run the parser tests and verify RED**
Run:
```powershell
pnpm exec jest --runTestsByPath test/modules/qqbot/plugins/bilibili-card/bilibili-url-parser.spec.ts --runInBand
```
Expected: FAIL because `bilibili-url-parser.ts` does not exist.
- [ ] **Step 3: Add parser types and minimal implementation**
Create `src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts`:
```ts
export type BilibiliVideoReference =
| {
canonicalVideoId: string;
kind: 'bvid';
sourceUrl: string;
value: string;
}
| {
canonicalVideoId: string;
kind: 'aid';
sourceUrl: string;
value: string;
};
```
Create `src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-parser.ts`:
```ts
import type { BilibiliVideoReference } from './bilibili-card.types';
const ALLOWED_HOSTS = new Set([
'b23.tv',
'bilibili.com',
'm.bilibili.com',
'www.bilibili.com',
]);
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('"', '"')
.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));
return (
url.protocol === 'http:' ||
url.protocol === 'https:'
) && ALLOWED_HOSTS.has(url.hostname.toLowerCase());
} 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 probe = `${url.pathname}${url.search}${url.hash}`;
const bvid = probe.match(BVID_PATTERN)?.[1];
if (bvid) {
return {
canonicalVideoId: bvid,
kind: 'bvid',
sourceUrl: cleaned,
value: bvid,
};
}
const aid = `${url.pathname}/`.match(AID_PATTERN)?.[1];
if (aid) {
return {
canonicalVideoId: `av${aid}`,
kind: 'aid',
sourceUrl: cleaned,
value: aid,
};
}
return null;
}
```
- [ ] **Step 4: Run the parser tests and verify GREEN**
Run:
```powershell
pnpm exec jest --runTestsByPath test/modules/qqbot/plugins/bilibili-card/bilibili-url-parser.spec.ts --runInBand
```
Expected: PASS.
- [ ] **Step 5: Commit parser slice**
```powershell
git add test/modules/qqbot/plugins/bilibili-card/bilibili-url-parser.spec.ts src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-parser.ts
git commit -m "feat: 添加Bilibili链接解析域逻辑"
```
---
### Task 2: RawEvent URL Extractor
**Files:**
- Create: `test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts`
- Modify: `src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts`
- Create: `src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-extractor.ts`
- [ ] **Step 1: Write the failing extractor tests**
Create `test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts`:
```ts
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']);
});
});
```
- [ ] **Step 2: Run the extractor tests and verify RED**
Run:
```powershell
pnpm exec jest --runTestsByPath test/modules/qqbot/plugins/bilibili-card/bilibili-url-extractor.spec.ts --runInBand
```
Expected: FAIL because `bilibili-url-extractor.ts` does not exist.
- [ ] **Step 3: Add extraction types and implementation**
Extend `src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-card.types.ts`:
```ts
export type BilibiliUrlExtractionInput = {
messageText?: string;
rawEvent?: Record;
rawMessage?: string;
};
```
Create `src/modules/qqbot/plugins/bilibili-card/src/domain/bilibili-url-extractor.ts`:
```ts
import type { BilibiliUrlExtractionInput } from './bilibili-card.types';
import {
cleanBilibiliUrlCandidate,
isAllowedBilibiliUrl,
} from './bilibili-url-parser';
const URL_PATTERN = /https?:\/\/[^\s<>"',。!?;、]+/giu;
const MAX_DEPTH = 7;
/**
* 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(rawUrl);
if (!isAllowedBilibiliUrl(cleaned) || seen.has(cleaned)) continue;
seen.add(cleaned);
output.push(cleaned);
}
}
return output;
}
/**
* 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 output: string[] = [];
const seen = new WeakSet