feat: 接入BangDream查歌插件

This commit is contained in:
sunlei 2026-06-05 16:34:24 +08:00
parent f74bd14c34
commit 9d029ecb6a
7 changed files with 503 additions and 1 deletions

View File

@ -516,7 +516,8 @@ ON DUPLICATE KEY UPDATE
INSERT INTO `qqbot_command` (`id`, `code`, `name`, `aliases`, `prefixes`, `plugin_key`, `operation_key`, `parser_key`, `target_type`, `default_params`, `reply_template`, `error_template`, `enabled`, `priority`, `cooldown_ms`, `remark`)
VALUES
(2041700000000300501, 'ff14_price', 'FF14 查价', '["查价","price","ff14price"]', '["/","!",""]', 'ff14Market', 'ff14.market.price', 'ff14Price', 'all', '{"language":"chs","world":"中国"}', '', 'FF14 查价失败:{{error}}', 1, 0, 1500, '默认示例命令;请在账号配置中绑定后启用'),
(2041700000000300502, 'fflogs_character', 'FFLogs 查询', '["fflogs","logs","查logs","查log"]', '["/","!",""]', 'fflogs', 'fflogs.character.summary', 'fflogsCharacter', 'all', '{"serverRegion":"CN"}', '', 'FFLogs 查询失败:{{error}}', 1, 0, 3000, '查询 FFLogs 角色公开排名带高难任务时返回最近10次记录格式/fflogs 角色名 服务器 [高难任务]')
(2041700000000300502, 'fflogs_character', 'FFLogs 查询', '["fflogs","logs","查logs","查log"]', '["/","!",""]', 'fflogs', 'fflogs.character.summary', 'fflogsCharacter', 'all', '{"serverRegion":"CN"}', '', 'FFLogs 查询失败:{{error}}', 1, 0, 3000, '查询 FFLogs 角色公开排名带高难任务时返回最近10次记录格式/fflogs 角色名 服务器 [高难任务]'),
(2041700000000300503, 'bangdream_song', 'BangDream 查歌', '["bd","bangdream","bandori","邦邦","邦邦查歌"]', '["/","!",""]', 'bangDream', 'bangdream.song.search', 'plain', 'all', '{}', '', 'BangDream 查询失败:{{error}}', 1, 0, 1500, '查询 BanG Dream 歌曲信息;格式:/bd 歌曲名 或 /bd 歌曲ID')
ON DUPLICATE KEY UPDATE
`name` = VALUES(`name`),
`plugin_key` = VALUES(`plugin_key`),

View File

@ -1,5 +1,6 @@
import { Injectable, OnModuleInit } from '@nestjs/common';
import { formatKtDateTime, throwVbenError } from '@/common';
import { QqbotBangDreamPluginService } from '../plugins/bangDream/qqbot-bangdream.plugin';
import { QqbotFf14MarketPluginService } from '../plugins/ff14Market/qqbot-ff14-market.plugin';
import { QqbotFflogsPluginService } from '../plugins/fflogs/qqbot-fflogs.plugin';
import type {
@ -15,11 +16,13 @@ export class QqbotPluginRegistryService implements OnModuleInit {
private readonly plugins = new Map<string, QqbotIntegrationPlugin>();
constructor(
private readonly bangDreamPlugin: QqbotBangDreamPluginService,
private readonly ff14MarketPlugin: QqbotFf14MarketPluginService,
private readonly fflogsPlugin: QqbotFflogsPluginService,
) {}
onModuleInit() {
this.register(this.bangDreamPlugin.getPlugin());
this.register(this.ff14MarketPlugin.getPlugin());
this.register(this.fflogsPlugin.getPlugin());
}

View File

@ -0,0 +1,274 @@
import { Injectable } from '@nestjs/common';
import type {
BestdoriBandListItem,
BestdoriLocalizedText,
BestdoriSongInfo,
BestdoriSongListItem,
QqbotBangDreamSongSearchInput,
QqbotBangDreamSongSummary,
} from './qqbot-bangdream.types';
type BangDreamSongIndexItem = {
id: number;
keys: string[];
title: string;
titles: string[];
};
const BESTDORI_API_BASE_URL = 'https://bestdori.com/api';
const BESTDORI_WEB_BASE_URL = 'https://bestdori.com';
const CACHE_TTL_MS = 10 * 60 * 1000;
const REQUEST_TIMEOUT_MS = 8_000;
const TEXT_INDEX_PRIORITY = [3, 1, 2, 0, 4];
const DIFFICULTY_LABELS: Record<string, string> = {
'0': 'EASY',
'1': 'NORMAL',
'2': 'HARD',
'3': 'EXPERT',
'4': 'SPECIAL',
};
const TAG_LABELS: Record<string, string> = {
normal: '原创',
cover: '翻唱',
tie_up: '联动',
};
@Injectable()
export class QqbotBangDreamClientService {
private bandCache?: {
expiresAt: number;
map: Map<number, string>;
};
private songIndexCache?: {
entries: BangDreamSongIndexItem[];
expiresAt: number;
};
async checkHealth() {
await this.getSongIndex();
return true;
}
async searchSong(
params: QqbotBangDreamSongSearchInput,
): Promise<QqbotBangDreamSongSummary> {
const query = this.pickQuery(params);
if (!query) throw new Error('请提供 BangDream 歌曲名或歌曲 ID');
const matched = await this.findSong(query);
if (!matched) throw new Error(`未找到 BangDream 歌曲:${query}`);
const [song, bands] = await Promise.all([
this.requestJson<BestdoriSongInfo>(`/songs/${matched.id}.json`),
this.getBandMap(),
]);
return this.buildSongSummary(matched.id, song, bands);
}
private async findSong(query: string) {
const songId = Number(query);
const entries = await this.getSongIndex();
if (Number.isInteger(songId) && songId > 0) {
return entries.find((item) => item.id === songId) || null;
}
const key = this.normalizeLookupKey(query);
const exact = entries.find((item) => item.keys.includes(key));
if (exact) return exact;
return (
entries.find((item) => item.keys.some((itemKey) => itemKey.includes(key))) ||
null
);
}
private async getSongIndex() {
if (this.songIndexCache && Date.now() < this.songIndexCache.expiresAt) {
return this.songIndexCache.entries;
}
const data =
await this.requestJson<Record<string, BestdoriSongListItem | null>>(
'/songs/all.1.json',
);
const entries = Object.entries(data)
.map(([id, item]) => this.toSongIndexItem(Number(id), item))
.filter(Boolean) as BangDreamSongIndexItem[];
this.songIndexCache = {
entries,
expiresAt: Date.now() + CACHE_TTL_MS,
};
return entries;
}
private toSongIndexItem(id: number, item?: BestdoriSongListItem | null) {
if (!Number.isInteger(id) || !item?.musicTitle) return null;
const titles = this.pickAllTexts(item.musicTitle);
if (!titles.length) return null;
return {
id,
keys: [...new Set(titles.map((title) => this.normalizeLookupKey(title)))],
title: this.pickLocalizedText(item.musicTitle),
titles,
} satisfies BangDreamSongIndexItem;
}
private async getBandMap() {
if (this.bandCache && Date.now() < this.bandCache.expiresAt) {
return this.bandCache.map;
}
const data =
await this.requestJson<Record<string, BestdoriBandListItem | null>>(
'/bands/all.1.json',
);
const map = new Map<number, string>();
for (const [id, item] of Object.entries(data)) {
const bandId = Number(id);
const bandName = this.pickLocalizedText(item?.bandName);
if (Number.isInteger(bandId) && bandName) map.set(bandId, bandName);
}
this.bandCache = {
expiresAt: Date.now() + CACHE_TTL_MS,
map,
};
return map;
}
private buildSongSummary(
id: number,
song: BestdoriSongInfo,
bands: Map<number, string>,
): QqbotBangDreamSongSummary {
const title = this.pickLocalizedText(song.musicTitle) || `歌曲 ${id}`;
const bandName = bands.get(Number(song.bandId)) || '未知乐队';
const result = {
bandName,
bpmText: this.formatBpm(song.bpm),
difficultyText: this.formatDifficulty(song.difficulty),
id,
lengthText: this.formatLength(song.length),
notesText: this.formatNotes(song.notes),
publishedText: this.formatPublishedAt(song.publishedAt),
tagText: TAG_LABELS[song.tag || ''] || song.tag || '未知类型',
title,
url: `${BESTDORI_WEB_BASE_URL}/info/songs/${id}`,
} as QqbotBangDreamSongSummary;
result.replyText = this.buildReplyText(result);
return result;
}
private buildReplyText(result: Omit<QqbotBangDreamSongSummary, 'replyText'>) {
return [
`BangDream 歌曲:${result.title}`,
`ID${result.id}|乐队:${result.bandName}|类型:${result.tagText}`,
`时长:${result.lengthText}BPM${result.bpmText}`,
`难度:${result.difficultyText}`,
`物量:${result.notesText}`,
result.publishedText ? `国服上线:${result.publishedText}` : '',
result.url,
]
.filter(Boolean)
.join('\n');
}
private pickQuery(params: QqbotBangDreamSongSearchInput) {
return `${params.query || params.text || params.raw || ''}`.trim();
}
private pickLocalizedText(values?: BestdoriLocalizedText) {
if (!Array.isArray(values)) return '';
for (const index of TEXT_INDEX_PRIORITY) {
const value = values[index];
if (value) return value;
}
return values.find((value) => !!value) || '';
}
private pickAllTexts(values: BestdoriLocalizedText) {
return [
...new Set(
values.map((value) => `${value || ''}`.trim()).filter(Boolean),
),
];
}
private formatDifficulty(value?: BestdoriSongInfo['difficulty']) {
const parts = Object.entries(value || {}).map(([key, item]) => {
const label = DIFFICULTY_LABELS[key] || key;
return `${label}${item?.playLevel ?? '-'}`;
});
return parts.length ? parts.join(' / ') : '暂无';
}
private formatNotes(value?: BestdoriSongInfo['notes']) {
const parts = Object.entries(value || {}).map(([key, notes]) => {
const label = DIFFICULTY_LABELS[key] || key;
return `${label}${notes ?? '-'}`;
});
return parts.length ? parts.join(' / ') : '暂无';
}
private formatBpm(value?: BestdoriSongInfo['bpm']) {
const bpms = Object.values(value || {})
.flat()
.map((item) => Number(item?.bpm))
.filter((item) => Number.isFinite(item));
if (!bpms.length) return '暂无';
const unique = [...new Set(bpms)].sort((left, right) => left - right);
if (unique.length === 1) return `${unique[0]}`;
return `${unique[0]}-${unique[unique.length - 1]}`;
}
private formatLength(value?: number) {
const seconds = Number(value);
if (!Number.isFinite(seconds) || seconds <= 0) return '暂无';
const minutes = Math.floor(seconds / 60);
const rest = Math.round(seconds % 60);
return `${minutes}:${`${rest}`.padStart(2, '0')}`;
}
private formatPublishedAt(values?: BestdoriLocalizedText) {
const value = values?.[3];
const time = Number(value);
if (!Number.isFinite(time) || time <= 0) return '';
return new Date(time).toLocaleDateString('zh-CN', {
day: '2-digit',
month: '2-digit',
timeZone: 'Asia/Shanghai',
year: 'numeric',
});
}
private normalizeLookupKey(value: string) {
return `${value || ''}`
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '');
}
private async requestJson<T>(path: string): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const response = await fetch(`${BESTDORI_API_BASE_URL}${path}`, {
headers: {
Accept: 'application/json',
'User-Agent': 'kt-template-online-api/qqbot',
},
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`Bestdori 接口失败:${response.status}`);
}
return (await response.json()) as T;
} catch (err) {
if ((err as Error)?.name === 'AbortError') {
throw new Error('Bestdori 接口请求超时');
}
throw err;
} finally {
clearTimeout(timer);
}
}
}

View File

@ -0,0 +1,74 @@
import { Injectable } from '@nestjs/common';
import { formatKtDateTime, ToolsService } from '@/common';
import type { QqbotIntegrationPlugin } from '../../qqbot.types';
import { QqbotBangDreamClientService } from './qqbot-bangdream-client.service';
@Injectable()
export class QqbotBangDreamPluginService {
constructor(
private readonly bangDreamClientService: QqbotBangDreamClientService,
private readonly toolsService: ToolsService,
) {}
getPlugin(): QqbotIntegrationPlugin {
return {
description:
'对接 Bestdori 公开数据,提供 BanG Dream! Girls Band Party 歌曲查询能力。',
healthCheck: async () => {
const checkedAt = formatKtDateTime(new Date());
try {
await this.bangDreamClientService.checkHealth();
return {
checkedAt,
message: 'BangDream 插件可用',
status: 'healthy',
};
} catch (err) {
return {
checkedAt,
message: this.toolsService.getErrorMessage(
err,
'BangDream 插件不可用',
),
status: 'degraded',
};
}
},
key: 'bangDream',
name: 'BangDream 查询',
operations: [
{
cacheTtlMs: 60_000,
description: '按歌曲名或 Bestdori 歌曲 ID 查询 BanG Dream 歌曲信息。',
inputSchema: {
properties: {
query: { description: '歌曲名或歌曲 ID', type: 'string' },
text: { description: '命令原始文本', type: 'string' },
},
required: ['query'],
type: 'object',
},
key: 'bangdream.song.search',
name: '歌曲查询',
outputSchema: {
properties: {
bandName: { type: 'string' },
bpmText: { type: 'string' },
difficultyText: { type: 'string' },
id: { type: 'number' },
lengthText: { type: 'string' },
notesText: { type: 'string' },
replyText: { type: 'string' },
title: { type: 'string' },
url: { type: 'string' },
},
type: 'object',
},
execute: async (input) =>
await this.bangDreamClientService.searchSong(input),
},
],
version: '1.0.0',
};
}
}

View File

@ -0,0 +1,40 @@
export type BestdoriLocalizedText = Array<null | string>;
export type BestdoriSongListItem = {
musicTitle?: BestdoriLocalizedText;
};
export type BestdoriSongInfo = {
bandId?: number;
bpm?: Record<string, Array<{ bpm?: number }>>;
difficulty?: Record<string, { playLevel?: number }>;
length?: number;
musicTitle?: BestdoriLocalizedText;
notes?: Record<string, number>;
publishedAt?: BestdoriLocalizedText;
tag?: string;
};
export type BestdoriBandListItem = {
bandName?: BestdoriLocalizedText;
};
export type QqbotBangDreamSongSearchInput = {
query?: string;
raw?: string;
text?: string;
};
export type QqbotBangDreamSongSummary = {
bandName: string;
bpmText: string;
difficultyText: string;
id: number;
lengthText: string;
notesText: string;
publishedText: string;
replyText: string;
tagText: string;
title: string;
url: string;
};

View File

@ -38,6 +38,8 @@ import { QqbotPermissionService } from './permission/qqbot-permission.service';
import { QqbotEventPluginRegistryService } from './plugin/qqbot-event-plugin-registry.service';
import { QqbotPluginController } from './plugin/qqbot-plugin.controller';
import { QqbotPluginRegistryService } from './plugin/qqbot-plugin-registry.service';
import { QqbotBangDreamClientService } from './plugins/bangDream/qqbot-bangdream-client.service';
import { QqbotBangDreamPluginService } from './plugins/bangDream/qqbot-bangdream.plugin';
import { QqbotFf14ClientService } from './plugins/ff14Market/qqbot-ff14-client.service';
import { QqbotFf14MarketPluginService } from './plugins/ff14Market/qqbot-ff14-market.plugin';
import { QqbotFflogsClientService } from './plugins/fflogs/qqbot-fflogs-client.service';
@ -94,6 +96,8 @@ import { QqbotSendService } from './send/qqbot-send.service';
QqbotDashboardService,
QqbotDedupeService,
QqbotEventService,
QqbotBangDreamClientService,
QqbotBangDreamPluginService,
QqbotFf14ClientService,
QqbotFf14MarketPluginService,
QqbotFflogsClientService,

View File

@ -0,0 +1,106 @@
import { QqbotBangDreamClientService } from '@/qqbot/plugins/bangDream/qqbot-bangdream-client.service';
describe('QqbotBangDreamClientService', () => {
let fetchSpy: jest.SpyInstance;
let service: QqbotBangDreamClientService;
beforeEach(() => {
service = new QqbotBangDreamClientService();
fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation(async (url) => {
const target = `${url}`;
if (target.endsWith('/songs/all.1.json')) {
return jsonResponse({
'180': {
musicTitle: ['Returns', 'Returns', 'Returns', 'Returns', 'Returns'],
},
});
}
if (target.endsWith('/songs/180.json')) {
return jsonResponse({
bandId: 1,
bpm: {
'3': [{ bpm: 185 }],
},
difficulty: {
'0': { playLevel: 7 },
'1': { playLevel: 14 },
'2': { playLevel: 19 },
'3': { playLevel: 25 },
},
length: 132.36,
musicTitle: ['Returns', 'Returns', 'Returns', 'Returns', 'Returns'],
notes: {
'0': 140,
'1': 269,
'2': 473,
'3': 698,
},
publishedAt: [
'1553234400000',
'1584864000000',
'1563519600000',
'1577854800000',
'1553234400000',
],
tag: 'normal',
});
}
if (target.endsWith('/bands/all.1.json')) {
return jsonResponse({
'1': {
bandName: [
"Poppin'Party",
"Poppin'Party",
"Poppin'Party",
"Poppin'Party",
"Poppin'Party",
],
},
});
}
return jsonResponse({}, 404);
});
});
afterEach(() => {
fetchSpy.mockRestore();
});
it('searches Bestdori song by title and builds localized QQ reply text', async () => {
const result = await service.searchSong({ text: 'Returns' });
expect(result).toMatchObject({
bandName: "Poppin'Party",
bpmText: '185',
difficultyText: 'EASY7 / NORMAL14 / HARD19 / EXPERT25',
id: 180,
lengthText: '2:12',
notesText: 'EASY140 / NORMAL269 / HARD473 / EXPERT698',
tagText: '原创',
title: 'Returns',
url: 'https://bestdori.com/info/songs/180',
});
expect(result.replyText).toContain('BangDream 歌曲Returns');
expect(result.replyText).toContain('乐队Poppin');
expect(result.replyText).toContain('难度EASY7 / NORMAL14');
});
it('searches Bestdori song by numeric id', async () => {
const result = await service.searchSong({ text: '180' });
expect(result.id).toBe(180);
expect(fetchSpy).toHaveBeenCalledWith(
'https://bestdori.com/api/songs/180.json',
expect.any(Object),
);
});
});
function jsonResponse(body: unknown, status = 200) {
return Promise.resolve(
new Response(JSON.stringify(body), {
headers: { 'Content-Type': 'application/json' },
status,
}),
);
}