From 9d029ecb6a1b94a869e0b488ad187f2efc6a4f83 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 5 Jun 2026 16:34:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5BangDream=E6=9F=A5?= =?UTF-8?q?=E6=AD=8C=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- sql/qqbot-init.sql | 3 +- .../plugin/qqbot-plugin-registry.service.ts | 3 + .../qqbot-bangdream-client.service.ts | 274 ++++++++++++++++++ .../bangDream/qqbot-bangdream.plugin.ts | 74 +++++ .../bangDream/qqbot-bangdream.types.ts | 40 +++ src/qqbot/qqbot.module.ts | 4 + .../qqbot-bangdream-client.service.spec.ts | 106 +++++++ 7 files changed, 503 insertions(+), 1 deletion(-) create mode 100644 src/qqbot/plugins/bangDream/qqbot-bangdream-client.service.ts create mode 100644 src/qqbot/plugins/bangDream/qqbot-bangdream.plugin.ts create mode 100644 src/qqbot/plugins/bangDream/qqbot-bangdream.types.ts create mode 100644 test/qqbot/plugins/bangDream/qqbot-bangdream-client.service.spec.ts diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index 3cb86de..c898fda 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -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`), diff --git a/src/qqbot/plugin/qqbot-plugin-registry.service.ts b/src/qqbot/plugin/qqbot-plugin-registry.service.ts index 2483934..1e34d90 100644 --- a/src/qqbot/plugin/qqbot-plugin-registry.service.ts +++ b/src/qqbot/plugin/qqbot-plugin-registry.service.ts @@ -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(); 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()); } diff --git a/src/qqbot/plugins/bangDream/qqbot-bangdream-client.service.ts b/src/qqbot/plugins/bangDream/qqbot-bangdream-client.service.ts new file mode 100644 index 0000000..c59c9a0 --- /dev/null +++ b/src/qqbot/plugins/bangDream/qqbot-bangdream-client.service.ts @@ -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 = { + '0': 'EASY', + '1': 'NORMAL', + '2': 'HARD', + '3': 'EXPERT', + '4': 'SPECIAL', +}; +const TAG_LABELS: Record = { + normal: '原创', + cover: '翻唱', + tie_up: '联动', +}; + +@Injectable() +export class QqbotBangDreamClientService { + private bandCache?: { + expiresAt: number; + map: Map; + }; + private songIndexCache?: { + entries: BangDreamSongIndexItem[]; + expiresAt: number; + }; + + async checkHealth() { + await this.getSongIndex(); + return true; + } + + async searchSong( + params: QqbotBangDreamSongSearchInput, + ): Promise { + 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(`/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>( + '/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>( + '/bands/all.1.json', + ); + const map = new Map(); + 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, + ): 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) { + 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(path: string): Promise { + 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); + } + } +} diff --git a/src/qqbot/plugins/bangDream/qqbot-bangdream.plugin.ts b/src/qqbot/plugins/bangDream/qqbot-bangdream.plugin.ts new file mode 100644 index 0000000..b16caa4 --- /dev/null +++ b/src/qqbot/plugins/bangDream/qqbot-bangdream.plugin.ts @@ -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', + }; + } +} diff --git a/src/qqbot/plugins/bangDream/qqbot-bangdream.types.ts b/src/qqbot/plugins/bangDream/qqbot-bangdream.types.ts new file mode 100644 index 0000000..da3c08d --- /dev/null +++ b/src/qqbot/plugins/bangDream/qqbot-bangdream.types.ts @@ -0,0 +1,40 @@ +export type BestdoriLocalizedText = Array; + +export type BestdoriSongListItem = { + musicTitle?: BestdoriLocalizedText; +}; + +export type BestdoriSongInfo = { + bandId?: number; + bpm?: Record>; + difficulty?: Record; + length?: number; + musicTitle?: BestdoriLocalizedText; + notes?: Record; + 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; +}; diff --git a/src/qqbot/qqbot.module.ts b/src/qqbot/qqbot.module.ts index 5d12ef9..9f7ca3a 100644 --- a/src/qqbot/qqbot.module.ts +++ b/src/qqbot/qqbot.module.ts @@ -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, diff --git a/test/qqbot/plugins/bangDream/qqbot-bangdream-client.service.spec.ts b/test/qqbot/plugins/bangDream/qqbot-bangdream-client.service.spec.ts new file mode 100644 index 0000000..34a845f --- /dev/null +++ b/test/qqbot/plugins/bangDream/qqbot-bangdream-client.service.spec.ts @@ -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, + }), + ); +}