From 69705281fa407814b93963687020fed7214be0f6 Mon Sep 17 00:00:00 2001 From: sunlei Date: Tue, 16 Jun 2026 00:20:58 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=94=B6=E6=95=9BQQBot=E6=8F=92?= =?UTF-8?q?=E4=BB=B6=E5=B9=B3=E5=8F=B0=E5=8A=A0=E8=BD=BD=E8=BE=B9=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../argument/plugin-input-normalizer.port.ts | 9 + .../qqbot-plugin-argument-parser.service.ts | 348 +----------------- .../qqbot-event-plugin-registry.service.ts | 73 +--- .../registry/qqbot-plugin-registry.service.ts | 154 +------- .../plugin-input-normalizer.service.ts | 85 +++++ .../builtin-plugin-package-loader.service.ts | 193 ++++++++++ .../plugin-platform/plugin-platform.module.ts | 9 + .../src/domain/search/fuzzy-search.ts | 27 +- .../application/ff14-market-input-parser.ts | 154 ++++++++ .../qqbot/plugins/ff14-market/src/index.ts | 4 + .../src/application/fflogs-input-parser.ts | 159 ++++++++ src/modules/qqbot/plugins/fflogs/src/index.ts | 5 + .../qqbot-plugin-package-boundary.spec.ts | 23 ++ .../qqbot/plugins/plugin-input-parser.spec.ts | 45 +++ .../plugins/plugin-registry-compat.spec.ts | 7 +- 15 files changed, 744 insertions(+), 551 deletions(-) create mode 100644 src/modules/qqbot/plugin-platform/application/argument/plugin-input-normalizer.port.ts create mode 100644 src/modules/qqbot/plugin-platform/infrastructure/integration/argument/plugin-input-normalizer.service.ts create mode 100644 src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service.ts create mode 100644 src/modules/qqbot/plugins/ff14-market/src/application/ff14-market-input-parser.ts create mode 100644 src/modules/qqbot/plugins/fflogs/src/application/fflogs-input-parser.ts create mode 100644 test/modules/qqbot/plugins/plugin-input-parser.spec.ts diff --git a/src/modules/qqbot/plugin-platform/application/argument/plugin-input-normalizer.port.ts b/src/modules/qqbot/plugin-platform/application/argument/plugin-input-normalizer.port.ts new file mode 100644 index 0000000..55351d9 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/application/argument/plugin-input-normalizer.port.ts @@ -0,0 +1,9 @@ +import type { QqbotPluginExecutionInput } from '@/modules/qqbot/core/domain/plugin-execution.port'; + +export const QQBOT_PLUGIN_INPUT_NORMALIZER = Symbol( + 'QQBOT_PLUGIN_INPUT_NORMALIZER', +); + +export interface QqbotPluginInputNormalizerPort { + normalizeInput(input: QqbotPluginExecutionInput): Promise>; +} diff --git a/src/modules/qqbot/plugin-platform/application/argument/qqbot-plugin-argument-parser.service.ts b/src/modules/qqbot/plugin-platform/application/argument/qqbot-plugin-argument-parser.service.ts index 9016d9c..50b56b0 100644 --- a/src/modules/qqbot/plugin-platform/application/argument/qqbot-plugin-argument-parser.service.ts +++ b/src/modules/qqbot/plugin-platform/application/argument/qqbot-plugin-argument-parser.service.ts @@ -1,345 +1,19 @@ -import { Injectable } from '@nestjs/common'; -import { DictService } from '@/modules/admin/platform-config/dict/dict.service'; -import { - buildQqbotFf14MarketCatalog, - buildQqbotFf14MarketCatalogFromTree, - isQqbotFf14DataCenterName, - isQqbotFf14LocationName, - isQqbotFf14RegionName, - isQqbotFf14WorldName, - QQBOT_FF14_MARKET_DICT_CODES, - splitQqbotFf14WorldPath, -} from '@/modules/qqbot/plugins/ff14-market/src'; -import type { QqbotFf14MarketCatalog } from '@/modules/qqbot/plugins/ff14-market/src'; +import { Inject, Injectable, Optional } from '@nestjs/common'; import type { QqbotPluginExecutionInput } from '@/modules/qqbot/core/domain/plugin-execution.port'; +import { + QQBOT_PLUGIN_INPUT_NORMALIZER, + type QqbotPluginInputNormalizerPort, +} from './plugin-input-normalizer.port'; @Injectable() export class QqbotPluginArgumentParserService { - constructor(private readonly dictService: DictService) {} + constructor( + @Optional() + @Inject(QQBOT_PLUGIN_INPUT_NORMALIZER) + private readonly normalizer?: QqbotPluginInputNormalizerPort, + ) {} async normalizeInput(input: QqbotPluginExecutionInput) { - const parserKey = `${input.context?.command?.parserKey || 'plain'}`.trim(); - const rawArgs = `${input.input?.raw ?? input.input?.text ?? ''}`.trim(); - if (parserKey === 'ff14Price') { - return { - ...input.input, - ...this.removeEmpty(await this.parseFf14PriceInput(rawArgs)), - }; - } - if (parserKey === 'fflogsCharacter') { - return { - ...input.input, - ...this.removeEmpty(await this.parseFflogsCharacterInput(rawArgs)), - }; - } - return input.input; - } - - private async parseFf14PriceInput(rawArgs: string) { - const catalog = await this.getFf14MarketCatalog(); - const tokens = rawArgs.split(/\s+/).filter(Boolean); - const flags = new Map(); - const positional: string[] = []; - - for (const token of tokens) { - if (/^hq$/i.test(token)) { - flags.set('hq', true); - } else if (/^nq$/i.test(token)) { - flags.set('hq', 'false'); - } else if (token.includes('=')) { - const [key, ...rest] = token.split('='); - flags.set(key, rest.join('=')); - } else { - positional.push(token); - } - } - - let region = this.normalizeString(flags.get('region') || flags.get('地区')); - let dataCenter = this.normalizeString( - flags.get('dataCenter') || - flags.get('datacenter') || - flags.get('dc') || - flags.get('大区'), - ); - let world = this.normalizeString( - flags.get('world') || - flags.get('server') || - flags.get('服务器') || - flags.get('小区'), - ); - let item = positional.join(' '); - - const worldPath = splitQqbotFf14WorldPath(world); - if (worldPath.dataCenter && worldPath.world) { - dataCenter = dataCenter || worldPath.dataCenter; - region = region || worldPath.region || ''; - world = worldPath.world; - } - - if (!world && !dataCenter && positional.length > 1) { - const picked = this.pickTrailingFf14Location(catalog, positional); - if (picked) { - dataCenter = picked.dataCenter || dataCenter; - item = picked.item; - region = picked.region || region; - world = picked.world || world; - } - } - if (item.includes('@')) { - const [itemName, worldName] = item.split('@'); - const itemWorldPath = splitQqbotFf14WorldPath(worldName); - item = itemName.trim(); - dataCenter = dataCenter || itemWorldPath.dataCenter || ''; - region = region || itemWorldPath.region || ''; - world = world || itemWorldPath.world || worldName?.trim(); - } - - return { - dataCenter, - hq: this.normalizeHq(flags.get('hq')), - item, - language: this.normalizeString(flags.get('lang')) || 'chs', - raw: rawArgs, - region, - world, - }; - } - - private async parseFflogsCharacterInput(rawArgs: string) { - const tokens = rawArgs.split(/\s+/).filter(Boolean); - const flags = new Map(); - const positional: string[] = []; - - for (const token of tokens) { - if (token.includes('=')) { - const [key, ...rest] = token.split('='); - flags.set(key, rest.join('=')); - } else { - positional.push(token); - } - } - - let characterName = this.normalizeString( - flags.get('character') || - flags.get('name') || - flags.get('角色') || - flags.get('角色名'), - ); - let serverSlug = this.normalizeString( - flags.get('server') || - flags.get('serverSlug') || - flags.get('world') || - flags.get('服务器') || - flags.get('小区'), - ); - let encounterName = this.normalizeString( - flags.get('encounter') || - flags.get('encounterName') || - flags.get('boss') || - flags.get('fight') || - flags.get('任务') || - flags.get('高难') || - flags.get('高难任务'), - ); - let zoneId = this.normalizeString( - flags.get('zone') || - flags.get('zoneId') || - flags.get('区域') || - flags.get('副本区域'), - ); - const dungeonFlag = this.normalizeString(flags.get('副本')); - if (dungeonFlag) { - if (/^\d+$/.test(dungeonFlag)) { - zoneId = zoneId || dungeonFlag; - } else { - encounterName = encounterName || dungeonFlag; - } - } - let remainingPositionals = [...positional]; - - if (!characterName && remainingPositionals[0]?.includes('@')) { - const [name, server] = remainingPositionals[0].split('@'); - characterName = name.trim(); - serverSlug = serverSlug || server?.trim(); - if (!encounterName && remainingPositionals.length > 1) { - encounterName = remainingPositionals.slice(1).join(' '); - } - remainingPositionals = []; - } - - if (!encounterName && remainingPositionals.length > 2) { - const picked = - await this.pickFflogsPositionalsByKnownWorld(remainingPositionals); - if (picked) { - characterName = characterName || picked.characterName; - serverSlug = serverSlug || picked.serverSlug; - encounterName = picked.encounterName; - remainingPositionals = []; - } - } - - if (!characterName && remainingPositionals.length) { - const joined = remainingPositionals.join(' '); - if (joined.includes('@')) { - const [name, server] = joined.split('@'); - characterName = name.trim(); - serverSlug = serverSlug || server?.trim(); - } else if (serverSlug) { - characterName = joined; - } else if (remainingPositionals.length > 1) { - serverSlug = remainingPositionals[remainingPositionals.length - 1]; - characterName = remainingPositionals.slice(0, -1).join(' '); - } else { - characterName = joined; - } - } - - return { - characterName, - className: this.normalizeString(flags.get('class') || flags.get('职业')), - difficulty: this.normalizeString( - flags.get('difficulty') || flags.get('难度'), - ), - encounter: encounterName, - encounterName, - limit: this.normalizeString(flags.get('limit') || flags.get('数量')), - metric: this.normalizeString(flags.get('metric') || flags.get('指标')), - partition: this.normalizeString( - flags.get('partition') || flags.get('分区'), - ), - raw: rawArgs, - role: this.normalizeString(flags.get('role') || flags.get('职责')), - serverRegion: this.normalizeString( - flags.get('region') || - flags.get('serverRegion') || - flags.get('地区') || - flags.get('服务器地区'), - ), - serverSlug, - size: this.normalizeString(flags.get('size') || flags.get('人数')), - specName: this.normalizeString(flags.get('spec') || flags.get('专精')), - text: rawArgs, - timeframe: this.normalizeString( - flags.get('timeframe') || flags.get('时间') || flags.get('范围'), - ), - zoneId, - }; - } - - private pickTrailingFf14Location( - catalog: QqbotFf14MarketCatalog, - positional: string[], - ) { - const last = positional[positional.length - 1]; - if (!isQqbotFf14LocationName(catalog, last)) return null; - - const path = splitQqbotFf14WorldPath(last); - if (path.dataCenter && path.world) { - return { - dataCenter: path.dataCenter, - item: positional.slice(0, -1).join(' '), - region: path.region, - world: path.world, - }; - } - - const previous = positional[positional.length - 2]; - const beforePrevious = positional[positional.length - 3]; - if ( - previous && - isQqbotFf14DataCenterName(catalog, previous) && - isQqbotFf14WorldName(catalog, last) - ) { - const hasRegion = - beforePrevious && isQqbotFf14RegionName(catalog, beforePrevious); - return { - dataCenter: previous, - item: positional.slice(0, hasRegion ? -3 : -2).join(' '), - region: hasRegion ? beforePrevious : undefined, - world: last, - }; - } - - if ( - previous && - isQqbotFf14RegionName(catalog, previous) && - isQqbotFf14DataCenterName(catalog, last) - ) { - return { - dataCenter: last, - item: positional.slice(0, -2).join(' '), - region: previous, - }; - } - - return { - item: positional.slice(0, -1).join(' '), - world: last, - }; - } - - private async pickFflogsPositionalsByKnownWorld(positional: string[]) { - const catalog = await this.getFf14MarketCatalog(); - for (let index = positional.length - 2; index > 0; index -= 1) { - const candidate = positional[index]; - if (!isQqbotFf14LocationName(catalog, candidate)) continue; - const characterName = positional.slice(0, index).join(' ').trim(); - const encounterName = positional - .slice(index + 1) - .join(' ') - .trim(); - if (!characterName || !encounterName) continue; - const worldPath = splitQqbotFf14WorldPath(candidate); - return { - characterName, - encounterName, - serverSlug: worldPath.world || candidate, - }; - } - return null; - } - - private async getFf14MarketCatalog() { - const treeCatalog = buildQqbotFf14MarketCatalogFromTree( - await this.dictService.relationTree({ - dictCode: QQBOT_FF14_MARKET_DICT_CODES.region, - }), - ); - if (treeCatalog.dataCenters.length > 0) return treeCatalog; - - const [regions, dataCenters, worlds] = await Promise.all([ - this.dictService.getDictItemsByKey(QQBOT_FF14_MARKET_DICT_CODES.region), - this.dictService.getDictItemsByKey( - QQBOT_FF14_MARKET_DICT_CODES.dataCenter, - ), - this.dictService.getDictItemsByKey(QQBOT_FF14_MARKET_DICT_CODES.world), - ]); - return buildQqbotFf14MarketCatalog({ - dataCenters, - regions, - worlds, - }); - } - - private normalizeHq(value?: string | true) { - if (value === undefined) return undefined; - if (value === true) return true; - if (value === 'false') return false; - return ['1', 'true', 'yes', 'hq'].includes(`${value}`.toLowerCase()); - } - - private normalizeString(value?: string | true) { - if (value === true) return ''; - return `${value || ''}`.trim(); - } - - private removeEmpty(input: Record) { - return Object.entries(input).reduce>( - (result, [key, value]) => { - if (value !== undefined && value !== '') result[key] = value; - return result; - }, - {}, - ); + return this.normalizer?.normalizeInput(input) || input.input; } } diff --git a/src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service.ts b/src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service.ts index 87ae6b7..5c4511b 100644 --- a/src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service.ts +++ b/src/modules/qqbot/plugin-platform/application/registry/qqbot-event-plugin-registry.service.ts @@ -1,10 +1,6 @@ -import { Injectable, Logger } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { Injectable } from '@nestjs/common'; import { formatKtDateTime, throwVbenError } from '@/common'; import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; -import { QqbotSendService } from '@/modules/qqbot/core/application/send/qqbot-send.service'; import type { QqbotEventPluginDefinition, QqbotNormalizedMessage, @@ -12,41 +8,18 @@ import type { QqbotPluginOperationSummary, } from '@/modules/qqbot/core/contract/qqbot.types'; import { - parseQqbotPluginManifest, - type QqbotPluginManifest, -} from '@/modules/qqbot/plugin-platform/domain/manifest'; -import { createPlugin as createRepeaterPlugin } from '@/modules/qqbot/plugins/repeater/src'; + QqbotBuiltinPluginPackageLoaderService, + type QqbotRepeaterPluginPackage, +} from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service'; @Injectable() export class QqbotEventPluginRegistryService { - private readonly logger = new Logger(QqbotEventPluginRegistryService.name); - private readonly repeaterPlugin: ReturnType; + private repeaterPlugin?: QqbotRepeaterPluginPackage; constructor( private readonly accountService: QqbotAccountService, - private readonly configService: ConfigService, - private readonly sendService: QqbotSendService, - ) { - const manifest = this.loadManifest('repeater'); - this.repeaterPlugin = createRepeaterPlugin({ - host: { - bindEventPlugin: (selfId, pluginKey) => - this.accountService - .bindEventPlugin(selfId, pluginKey) - .then(() => undefined), - getBoundEventPluginKeys: (selfId) => - this.accountService.getBoundEventPluginKeys(selfId), - getConfig: (key) => this.configService.get(key), - sendText: (input) => this.sendService.sendText(input as any), - unbindEventPlugin: (selfId, pluginKey) => - this.accountService - .unbindEventPlugin(selfId, pluginKey) - .then(() => undefined), - warn: (message) => this.logger.warn(message), - }, - manifest, - }); - } + private readonly builtinPluginLoader: QqbotBuiltinPluginPackageLoaderService, + ) {} listDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] { return this.getDefinitions(pluginKey); @@ -60,7 +33,7 @@ export class QqbotEventPluginRegistryService { accounts .filter((account): account is NonNullable => !!account) .map((account) => - this.repeaterPlugin.getSummary({ + this.getRepeaterPlugin().getSummary({ accountName: account.name, connectStatus: account.connectStatus, selfId: account.selfId, @@ -95,13 +68,13 @@ export class QqbotEventPluginRegistryService { } async dispatchMessage(message: QqbotNormalizedMessage) { - return this.repeaterPlugin.handleMessage(message); + return this.getRepeaterPlugin().handleMessage(message); } async bind(pluginKey: string, selfId: string) { this.assertPlugin(pluginKey); if (pluginKey === 'repeater') { - return this.repeaterPlugin.bind(selfId); + return this.getRepeaterPlugin().bind(selfId); } await this.accountService.bindEventPlugin(selfId, pluginKey); return true; @@ -110,7 +83,7 @@ export class QqbotEventPluginRegistryService { async unbind(pluginKey: string, selfId: string) { this.assertPlugin(pluginKey); if (pluginKey === 'repeater') { - return this.repeaterPlugin.unbind(selfId); + return this.getRepeaterPlugin().unbind(selfId); } await this.accountService.unbindEventPlugin(selfId, pluginKey); return true; @@ -122,30 +95,14 @@ export class QqbotEventPluginRegistryService { } private getDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] { - const definitions = [this.repeaterPlugin.getDefinition()]; + const definitions = [this.getRepeaterPlugin().getDefinition()]; return pluginKey ? definitions.filter((definition) => definition.key === pluginKey) : definitions; } - private loadManifest(pluginKey: string): QqbotPluginManifest { - const pluginRoot = this.resolvePluginRoot(pluginKey); - return parseQqbotPluginManifest( - readJsonFile(join(pluginRoot, 'plugin.json')), - { pluginRoot }, - ); - } - - private resolvePluginRoot(pluginKey: string) { - const sourceRoot = join( - process.cwd(), - `src/modules/qqbot/plugins/${pluginKey}`, - ); - if (existsSync(join(sourceRoot, 'plugin.json'))) return sourceRoot; - return join(__dirname, `../../../plugins/${pluginKey}`); + private getRepeaterPlugin() { + this.repeaterPlugin ||= this.builtinPluginLoader.loadRepeaterPlugin(); + return this.repeaterPlugin; } } - -function readJsonFile(filePath: string) { - return JSON.parse(readFileSync(filePath, 'utf8')) as T; -} diff --git a/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts b/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts index ccf145d..47899ae 100644 --- a/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts +++ b/src/modules/qqbot/plugin-platform/application/registry/qqbot-plugin-registry.service.ts @@ -1,10 +1,5 @@ -import { Injectable, OnModuleInit } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; -import { existsSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import * as XLSX from 'xlsx'; -import { formatKtDateTime, throwVbenError, ToolsService } from '@/common'; -import { DictService } from '@/modules/admin/platform-config/dict/dict.service'; +import { Injectable, OnModuleInit, Optional } from '@nestjs/common'; +import { formatKtDateTime, throwVbenError } from '@/common'; import type { QqbotIntegrationPlugin, QqbotPluginHealth, @@ -12,16 +7,7 @@ import type { QqbotPluginOperationSummary, QqbotPluginSummary, } from '@/modules/qqbot/core/contract/qqbot.types'; -import { - parseQqbotPluginManifest, - type QqbotPluginManifest, -} from '@/modules/qqbot/plugin-platform/domain/manifest'; -import { QqbotPluginHttpClientService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/sdk'; -import { createPlugin as createBangDreamPlugin } from '@/modules/qqbot/plugins/bangdream/src'; -import type { BangDreamManifestOperation } from '@/modules/qqbot/plugins/bangdream/src'; -import type { BangDreamRuntimeIo } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io'; -import { createPlugin as createFf14MarketPlugin } from '@/modules/qqbot/plugins/ff14-market/src'; -import { createPlugin as createFflogsPlugin } from '@/modules/qqbot/plugins/fflogs/src'; +import { QqbotBuiltinPluginPackageLoaderService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service'; @Injectable() export class QqbotPluginRegistryService implements OnModuleInit { @@ -29,16 +15,14 @@ export class QqbotPluginRegistryService implements OnModuleInit { private readonly plugins = new Map(); constructor( - private readonly configService: ConfigService, - private readonly dictService: DictService, - private readonly httpClient: QqbotPluginHttpClientService, - private readonly toolsService: ToolsService, + @Optional() + private readonly builtinPluginLoader?: QqbotBuiltinPluginPackageLoaderService, ) {} onModuleInit() { - this.register(this.createBangDreamPlugin()); - this.register(this.createFf14MarketPlugin()); - this.register(this.createFflogsPlugin()); + for (const plugin of this.builtinPluginLoader?.loadCommandPlugins() || []) { + this.register(plugin); + } } register(plugin: QqbotIntegrationPlugin) { @@ -147,126 +131,4 @@ export class QqbotPluginRegistryService implements OnModuleInit { this.plugins.get(this.pluginAliases.get(pluginKey) || '') ); } - - private createBangDreamPlugin(): QqbotIntegrationPlugin { - const manifest = this.loadManifest('bangdream'); - return createBangDreamPlugin({ - configReader: { - get: (key) => this.configService.get(key), - }, - description: manifest.description, - dictionaryReader: { - getDictItemsByKey: (dictCode) => - this.dictService.getDictItemsByKey(dictCode), - }, - io: this.createBangDreamRuntimeIo(), - legacyAliases: manifest.legacyAliases, - name: manifest.name, - normalizeError: (error) => - this.toolsService.getErrorMessage(error, 'BangDream 命令执行失败'), - operations: manifest.operations.map((operation) => ({ - description: operation.description, - handlerName: operation.handlerName, - inputSchema: operation.inputSchema, - key: operation.key, - name: operation.name, - outputSchema: operation.outputSchema, - })) as BangDreamManifestOperation[], - pluginKey: manifest.pluginKey, - version: manifest.version, - }) as QqbotIntegrationPlugin; - } - - private createFf14MarketPlugin(): QqbotIntegrationPlugin { - const manifest = this.loadManifest('ff14-market'); - return createFf14MarketPlugin({ - host: { - getConfig: (key) => this.configService.get(key), - getDictItemsByKey: (dictCode) => - this.dictService.getDictItemsByKey(dictCode), - relationTree: (input) => this.dictService.relationTree(input), - requestJson: (options) => this.httpClient.requestJson(options), - }, - manifest, - normalizeError: (error, fallback) => - this.toolsService.getErrorMessage(error, fallback), - }) as QqbotIntegrationPlugin; - } - - private createFflogsPlugin(): QqbotIntegrationPlugin { - const manifest = this.loadManifest('fflogs'); - return createFflogsPlugin({ - host: { - getConfig: (key) => this.configService.get(key), - getDictByKey: (dictCode) => this.dictService.getDictByKey(dictCode), - requestJson: (options) => this.httpClient.requestJson(options), - }, - manifest, - normalizeError: (error, fallback) => - this.toolsService.getErrorMessage(error, fallback), - }) as QqbotIntegrationPlugin; - } - - private createBangDreamRuntimeIo(): BangDreamRuntimeIo { - return { - getConfig: (key) => this.configService.get(key), - readAssetFile: async (filePath) => readFileSync(filePath), - readExcelRows: async (filePath) => readExcelRows(filePath), - readJsonFile: async (filePath) => readJsonFile(filePath), - readJsonFileSync: (filePath) => readJsonFile(filePath), - requestArrayBuffer: async (url, options) => ({ - body: await this.httpClient.requestBuffer({ - context: 'BangDream 资源下载', - failureMessage: (statusCode) => - `BangDream 资源下载失败:${statusCode}`, - timeoutMessage: 'BangDream 资源下载超时', - timeoutMs: options?.timeoutMs, - url, - }), - }), - requestJson: async (url, options) => ({ - body: await this.httpClient.requestJson({ - context: 'BangDream 数据接口', - failureMessage: (statusCode) => - `BangDream 数据接口失败:${statusCode}`, - invalidJsonMessage: 'BangDream 数据接口返回不是合法 JSON', - timeoutMessage: 'BangDream 数据接口请求超时', - timeoutMs: options?.timeoutMs, - url, - }), - }), - sleep: async (ms) => - await new Promise((resolve) => setTimeout(resolve, ms)), - writeJsonFile: async (filePath, data) => - writeFileSync(filePath, JSON.stringify(data)), - }; - } - - private loadManifest(pluginKey: string): QqbotPluginManifest { - const pluginRoot = this.resolvePluginRoot(pluginKey); - return parseQqbotPluginManifest( - readJsonFile(join(pluginRoot, 'plugin.json')), - { pluginRoot }, - ); - } - - private resolvePluginRoot(pluginKey: string) { - const sourceRoot = join( - process.cwd(), - `src/modules/qqbot/plugins/${pluginKey}`, - ); - if (existsSync(join(sourceRoot, 'plugin.json'))) return sourceRoot; - return join(__dirname, `../../../plugins/${pluginKey}`); - } -} - -function readJsonFile(filePath: string) { - return JSON.parse(readFileSync(filePath, 'utf8')) as T; -} - -function readExcelRows>(filePath: string) { - const workbook = XLSX.readFile(filePath); - const sheetName = workbook.SheetNames[0]; - const worksheet = workbook.Sheets[sheetName]; - return XLSX.utils.sheet_to_json(worksheet); } diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/argument/plugin-input-normalizer.service.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/argument/plugin-input-normalizer.service.ts new file mode 100644 index 0000000..6e8d418 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/argument/plugin-input-normalizer.service.ts @@ -0,0 +1,85 @@ +import { Injectable } from '@nestjs/common'; +import { DictService } from '@/modules/admin/platform-config/dict/dict.service'; +import type { QqbotPluginExecutionInput } from '@/modules/qqbot/core/domain/plugin-execution.port'; +import { + buildQqbotFf14MarketCatalog, + buildQqbotFf14MarketCatalogFromTree, + isQqbotFf14LocationName, + parseQqbotFf14MarketPriceInput, + QQBOT_FF14_MARKET_DICT_CODES, + splitQqbotFf14WorldPath, + type QqbotFf14MarketCatalog, +} from '@/modules/qqbot/plugins/ff14-market/src'; +import { parseQqbotFflogsCharacterInput } from '@/modules/qqbot/plugins/fflogs/src'; +import type { QqbotPluginInputNormalizerPort } from '../../../application/argument/plugin-input-normalizer.port'; + +@Injectable() +export class QqbotPluginInputNormalizerService + implements QqbotPluginInputNormalizerPort +{ + constructor(private readonly dictService: DictService) {} + + async normalizeInput(input: QqbotPluginExecutionInput) { + const parserKey = `${input.context?.command?.parserKey || 'plain'}`.trim(); + const rawArgs = `${input.input?.raw ?? input.input?.text ?? ''}`.trim(); + if (parserKey === 'ff14Price') { + return { + ...input.input, + ...this.removeEmpty( + parseQqbotFf14MarketPriceInput( + rawArgs, + await this.getFf14MarketCatalog(), + ), + ), + }; + } + if (parserKey === 'fflogsCharacter') { + const catalog = await this.getFf14MarketCatalog(); + return { + ...input.input, + ...this.removeEmpty( + parseQqbotFflogsCharacterInput(rawArgs, { + resolveKnownWorld: (candidate) => { + if (!isQqbotFf14LocationName(catalog, candidate)) return null; + const worldPath = splitQqbotFf14WorldPath(candidate); + return { serverSlug: worldPath.world || candidate }; + }, + }), + ), + }; + } + return input.input; + } + + private async getFf14MarketCatalog(): Promise { + const treeCatalog = buildQqbotFf14MarketCatalogFromTree( + await this.dictService.relationTree({ + dictCode: QQBOT_FF14_MARKET_DICT_CODES.region, + }), + ); + if (treeCatalog.dataCenters.length > 0) return treeCatalog; + + const [regions, dataCenters, worlds] = await Promise.all([ + this.dictService.getDictItemsByKey(QQBOT_FF14_MARKET_DICT_CODES.region), + this.dictService.getDictItemsByKey( + QQBOT_FF14_MARKET_DICT_CODES.dataCenter, + ), + this.dictService.getDictItemsByKey(QQBOT_FF14_MARKET_DICT_CODES.world), + ]); + return buildQqbotFf14MarketCatalog({ + dataCenters, + regions, + worlds, + }); + } + + private removeEmpty(input: Record) { + return Object.entries(input).reduce>( + (result, [key, value]) => { + if (value !== undefined && value !== '') result[key] = value; + return result; + }, + {}, + ); + } +} diff --git a/src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service.ts b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service.ts new file mode 100644 index 0000000..e4c24a0 --- /dev/null +++ b/src/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service.ts @@ -0,0 +1,193 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import * as XLSX from 'xlsx'; +import { ToolsService } from '@/common'; +import { DictService } from '@/modules/admin/platform-config/dict/dict.service'; +import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.service'; +import { QqbotSendService } from '@/modules/qqbot/core/application/send/qqbot-send.service'; +import type { QqbotIntegrationPlugin } from '@/modules/qqbot/core/contract/qqbot.types'; +import { + parseQqbotPluginManifest, + type QqbotPluginManifest, +} from '@/modules/qqbot/plugin-platform/domain/manifest'; +import { QqbotPluginHttpClientService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/sdk'; +import { createPlugin as createBangDreamPlugin } from '@/modules/qqbot/plugins/bangdream/src'; +import type { BangDreamManifestOperation } from '@/modules/qqbot/plugins/bangdream/src'; +import type { BangDreamRuntimeIo } from '@/modules/qqbot/plugins/bangdream/src/infrastructure/integration/runtime-io'; +import { createPlugin as createFf14MarketPlugin } from '@/modules/qqbot/plugins/ff14-market/src'; +import { createPlugin as createFflogsPlugin } from '@/modules/qqbot/plugins/fflogs/src'; +import { createPlugin as createRepeaterPlugin } from '@/modules/qqbot/plugins/repeater/src'; + +export type QqbotRepeaterPluginPackage = ReturnType< + typeof createRepeaterPlugin +>; + +@Injectable() +export class QqbotBuiltinPluginPackageLoaderService { + private readonly logger = new Logger( + QqbotBuiltinPluginPackageLoaderService.name, + ); + + constructor( + private readonly accountService: QqbotAccountService, + private readonly configService: ConfigService, + private readonly dictService: DictService, + private readonly httpClient: QqbotPluginHttpClientService, + private readonly sendService: QqbotSendService, + private readonly toolsService: ToolsService, + ) {} + + loadCommandPlugins(): QqbotIntegrationPlugin[] { + return [ + this.createBangDreamPlugin(), + this.createFf14MarketPlugin(), + this.createFflogsPlugin(), + ]; + } + + loadRepeaterPlugin(): QqbotRepeaterPluginPackage { + const manifest = this.loadManifest('repeater'); + return createRepeaterPlugin({ + host: { + bindEventPlugin: (selfId, pluginKey) => + this.accountService + .bindEventPlugin(selfId, pluginKey) + .then(() => undefined), + getBoundEventPluginKeys: (selfId) => + this.accountService.getBoundEventPluginKeys(selfId), + getConfig: (key) => this.configService.get(key), + sendText: (input) => this.sendService.sendText(input as any), + unbindEventPlugin: (selfId, pluginKey) => + this.accountService + .unbindEventPlugin(selfId, pluginKey) + .then(() => undefined), + warn: (message) => this.logger.warn(message), + }, + manifest, + }); + } + + private createBangDreamPlugin(): QqbotIntegrationPlugin { + const manifest = this.loadManifest('bangdream'); + return createBangDreamPlugin({ + configReader: { + get: (key) => this.configService.get(key), + }, + description: manifest.description, + dictionaryReader: { + getDictItemsByKey: (dictCode) => + this.dictService.getDictItemsByKey(dictCode), + }, + io: this.createBangDreamRuntimeIo(), + legacyAliases: manifest.legacyAliases, + name: manifest.name, + normalizeError: (error) => + this.toolsService.getErrorMessage(error, 'BangDream 命令执行失败'), + operations: manifest.operations.map((operation) => ({ + description: operation.description, + handlerName: operation.handlerName, + inputSchema: operation.inputSchema, + key: operation.key, + name: operation.name, + outputSchema: operation.outputSchema, + })) as BangDreamManifestOperation[], + pluginKey: manifest.pluginKey, + version: manifest.version, + }) as QqbotIntegrationPlugin; + } + + private createFf14MarketPlugin(): QqbotIntegrationPlugin { + const manifest = this.loadManifest('ff14-market'); + return createFf14MarketPlugin({ + host: { + getConfig: (key) => this.configService.get(key), + getDictItemsByKey: (dictCode) => + this.dictService.getDictItemsByKey(dictCode), + relationTree: (input) => this.dictService.relationTree(input), + requestJson: (options) => this.httpClient.requestJson(options), + }, + manifest, + normalizeError: (error, fallback) => + this.toolsService.getErrorMessage(error, fallback), + }) as QqbotIntegrationPlugin; + } + + private createFflogsPlugin(): QqbotIntegrationPlugin { + const manifest = this.loadManifest('fflogs'); + return createFflogsPlugin({ + host: { + getConfig: (key) => this.configService.get(key), + getDictByKey: (dictCode) => this.dictService.getDictByKey(dictCode), + requestJson: (options) => this.httpClient.requestJson(options), + }, + manifest, + normalizeError: (error, fallback) => + this.toolsService.getErrorMessage(error, fallback), + }) as QqbotIntegrationPlugin; + } + + private createBangDreamRuntimeIo(): BangDreamRuntimeIo { + return { + getConfig: (key) => this.configService.get(key), + readAssetFile: async (filePath) => readFileSync(filePath), + readExcelRows: async (filePath) => readExcelRows(filePath), + readJsonFile: async (filePath) => readJsonFile(filePath), + readJsonFileSync: (filePath) => readJsonFile(filePath), + requestArrayBuffer: async (url, options) => ({ + body: await this.httpClient.requestBuffer({ + context: 'BangDream 资源下载', + failureMessage: (statusCode) => + `BangDream 资源下载失败:${statusCode}`, + timeoutMessage: 'BangDream 资源下载超时', + timeoutMs: options?.timeoutMs, + url, + }), + }), + requestJson: async (url, options) => ({ + body: await this.httpClient.requestJson({ + context: 'BangDream 数据接口', + failureMessage: (statusCode) => + `BangDream 数据接口失败:${statusCode}`, + invalidJsonMessage: 'BangDream 数据接口返回不是合法 JSON', + timeoutMessage: 'BangDream 数据接口请求超时', + timeoutMs: options?.timeoutMs, + url, + }), + }), + sleep: async (ms) => + await new Promise((resolve) => setTimeout(resolve, ms)), + writeJsonFile: async (filePath, data) => + writeFileSync(filePath, JSON.stringify(data)), + }; + } + + private loadManifest(pluginKey: string): QqbotPluginManifest { + const pluginRoot = this.resolvePluginRoot(pluginKey); + return parseQqbotPluginManifest( + readJsonFile(join(pluginRoot, 'plugin.json')), + { pluginRoot }, + ); + } + + private resolvePluginRoot(pluginKey: string) { + const sourceRoot = join( + process.cwd(), + `src/modules/qqbot/plugins/${pluginKey}`, + ); + if (existsSync(join(sourceRoot, 'plugin.json'))) return sourceRoot; + return join(__dirname, `../../../plugins/${pluginKey}`); + } +} + +function readJsonFile(filePath: string) { + return JSON.parse(readFileSync(filePath, 'utf8')) as T; +} + +function readExcelRows>(filePath: string) { + const workbook = XLSX.readFile(filePath); + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + return XLSX.utils.sheet_to_json(worksheet); +} diff --git a/src/modules/qqbot/plugin-platform/plugin-platform.module.ts b/src/modules/qqbot/plugin-platform/plugin-platform.module.ts index ac99d54..e9a95c7 100644 --- a/src/modules/qqbot/plugin-platform/plugin-platform.module.ts +++ b/src/modules/qqbot/plugin-platform/plugin-platform.module.ts @@ -6,6 +6,7 @@ import { DictModule } from '@/modules/admin/platform-config/dict/dict.module'; import { QQBOT_PLUGIN_EXECUTION_PORT } from '@/modules/qqbot/core/domain/plugin-execution.port'; import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module'; import { QqbotPluginArgumentParserService } from './application/argument/qqbot-plugin-argument-parser.service'; +import { QQBOT_PLUGIN_INPUT_NORMALIZER } from './application/argument/plugin-input-normalizer.port'; import { QqbotEventPluginRegistryService } from './application/registry/qqbot-event-plugin-registry.service'; import { QqbotPluginRegistryService } from './application/registry/qqbot-plugin-registry.service'; import { QqbotPluginExecutionAdapter } from './application/plugin-execution.adapter'; @@ -13,6 +14,8 @@ import { QqbotPluginPlatformService } from './application/plugin-platform.servic import { QqbotPluginPlatformController } from './contract/plugin-platform.controller'; import { QqbotPluginController } from './contract/qqbot-plugin.controller'; import { QQBOT_PLUGIN_PLATFORM_ENTITIES } from './infrastructure/persistence'; +import { QqbotPluginInputNormalizerService } from './infrastructure/integration/argument/plugin-input-normalizer.service'; +import { QqbotBuiltinPluginPackageLoaderService } from './infrastructure/integration/package/builtin-plugin-package-loader.service'; import { QqbotPluginHttpClientService } from './infrastructure/integration/sdk'; @Module({ @@ -33,6 +36,12 @@ import { QqbotPluginHttpClientService } from './infrastructure/integration/sdk'; QqbotEventPluginRegistryService, QqbotPluginArgumentParserService, QqbotPluginExecutionAdapter, + QqbotPluginInputNormalizerService, + QqbotBuiltinPluginPackageLoaderService, + { + provide: QQBOT_PLUGIN_INPUT_NORMALIZER, + useExisting: QqbotPluginInputNormalizerService, + }, { provide: QQBOT_PLUGIN_EXECUTION_PORT, useExisting: QqbotPluginExecutionAdapter, diff --git a/src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search.ts b/src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search.ts index 7894689..67f4a17 100644 --- a/src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search.ts +++ b/src/modules/qqbot/plugins/bangdream/src/domain/search/fuzzy-search.ts @@ -22,9 +22,28 @@ const KEYWORD_PATTERN = /["“”『』「」]([^"“”『』「」]+)["“” const QUOTE_EDGE_PATTERN = /^["“”『』「」]|["“”『』「」]$/g; const RESERVED_MATCH_KEYS = new Set(['_number', '_relationStr', '_all']); -export const config: FuzzySearchConfig = - searchDictionaryRepository.loadConfig(); -const ruleRegistry = createDefaultFuzzySearchRuleRegistry(config); +let cachedConfig: FuzzySearchConfig | undefined; +let cachedRuleRegistry: + | ReturnType + | undefined; + +export const config = new Proxy({} as FuzzySearchConfig, { + get(_target, key: string) { + return getFuzzySearchConfig()[key]; + }, +}) as FuzzySearchConfig; + +function getFuzzySearchConfig() { + cachedConfig ??= searchDictionaryRepository.loadConfig(); + return cachedConfig; +} + +function getRuleRegistry() { + cachedRuleRegistry ??= createDefaultFuzzySearchRuleRegistry( + getFuzzySearchConfig(), + ); + return cachedRuleRegistry; +} /** * 判断对象是否包含指定自有属性。 @@ -86,7 +105,7 @@ export function fuzzySearch(keyword: string): FuzzySearchResult { const push = appendTo(matches); for (const rawKeyword of extractKeywords(keyword)) { - ruleRegistry.match(createFuzzySearchKeyword(rawKeyword), push); + getRuleRegistry().match(createFuzzySearchKeyword(rawKeyword), push); } return matches; diff --git a/src/modules/qqbot/plugins/ff14-market/src/application/ff14-market-input-parser.ts b/src/modules/qqbot/plugins/ff14-market/src/application/ff14-market-input-parser.ts new file mode 100644 index 0000000..c5843ef --- /dev/null +++ b/src/modules/qqbot/plugins/ff14-market/src/application/ff14-market-input-parser.ts @@ -0,0 +1,154 @@ +import type { QqbotFf14MarketCatalog } from '../domain/ff14-market.types'; +import { + isQqbotFf14DataCenterName, + isQqbotFf14LocationName, + isQqbotFf14RegionName, + isQqbotFf14WorldName, + splitQqbotFf14WorldPath, +} from '../domain/ff14-worlds'; + +export type QqbotFf14MarketPriceInput = { + dataCenter?: string; + hq?: boolean; + item?: string; + language?: string; + raw: string; + region?: string; + world?: string; +}; + +export function parseQqbotFf14MarketPriceInput( + rawArgs: string, + catalog: QqbotFf14MarketCatalog, +): QqbotFf14MarketPriceInput { + const tokens = rawArgs.split(/\s+/).filter(Boolean); + const flags = new Map(); + const positional: string[] = []; + + for (const token of tokens) { + if (/^hq$/i.test(token)) { + flags.set('hq', true); + } else if (/^nq$/i.test(token)) { + flags.set('hq', 'false'); + } else if (token.includes('=')) { + const [key, ...rest] = token.split('='); + flags.set(key, rest.join('=')); + } else { + positional.push(token); + } + } + + let region = normalizeString(flags.get('region') || flags.get('地区')); + let dataCenter = normalizeString( + flags.get('dataCenter') || + flags.get('datacenter') || + flags.get('dc') || + flags.get('大区'), + ); + let world = normalizeString( + flags.get('world') || + flags.get('server') || + flags.get('服务器') || + flags.get('小区'), + ); + let item = positional.join(' '); + + const worldPath = splitQqbotFf14WorldPath(world); + if (worldPath.dataCenter && worldPath.world) { + dataCenter = dataCenter || worldPath.dataCenter; + region = region || worldPath.region || ''; + world = worldPath.world; + } + + if (!world && !dataCenter && positional.length > 1) { + const picked = pickTrailingFf14Location(catalog, positional); + if (picked) { + dataCenter = picked.dataCenter || dataCenter; + item = picked.item; + region = picked.region || region; + world = picked.world || world; + } + } + if (item.includes('@')) { + const [itemName, worldName] = item.split('@'); + const itemWorldPath = splitQqbotFf14WorldPath(worldName); + item = itemName.trim(); + dataCenter = dataCenter || itemWorldPath.dataCenter || ''; + region = region || itemWorldPath.region || ''; + world = world || itemWorldPath.world || worldName?.trim(); + } + + return { + dataCenter, + hq: normalizeHq(flags.get('hq')), + item, + language: normalizeString(flags.get('lang')) || 'chs', + raw: rawArgs, + region, + world, + }; +} + +function pickTrailingFf14Location( + catalog: QqbotFf14MarketCatalog, + positional: string[], +) { + const last = positional[positional.length - 1]; + if (!isQqbotFf14LocationName(catalog, last)) return null; + + const path = splitQqbotFf14WorldPath(last); + if (path.dataCenter && path.world) { + return { + dataCenter: path.dataCenter, + item: positional.slice(0, -1).join(' '), + region: path.region, + world: path.world, + }; + } + + const previous = positional[positional.length - 2]; + const beforePrevious = positional[positional.length - 3]; + if ( + previous && + isQqbotFf14DataCenterName(catalog, previous) && + isQqbotFf14WorldName(catalog, last) + ) { + const hasRegion = + beforePrevious && isQqbotFf14RegionName(catalog, beforePrevious); + return { + dataCenter: previous, + item: positional.slice(0, hasRegion ? -3 : -2).join(' '), + region: hasRegion ? beforePrevious : undefined, + world: last, + }; + } + + if ( + previous && + isQqbotFf14RegionName(catalog, previous) && + isQqbotFf14DataCenterName(catalog, last) + ) { + return { + dataCenter: last, + item: positional.slice(0, -2).join(' '), + region: previous, + }; + } + + return { + item: positional.slice(0, -1).join(' '), + world: last, + }; +} + +function normalizeHq(value?: string | true) { + if (value === undefined) return undefined; + if (value === true) return true; + if (value === 'false') return false; + return ['1', 'true', 'yes', 'hq'].includes(`${value}`.toLowerCase()); +} + +function normalizeString(value?: string | true) { + if (value === true) return ''; + return `${value || ''}`.trim(); +} diff --git a/src/modules/qqbot/plugins/ff14-market/src/index.ts b/src/modules/qqbot/plugins/ff14-market/src/index.ts index 1a2597b..0a54752 100644 --- a/src/modules/qqbot/plugins/ff14-market/src/index.ts +++ b/src/modules/qqbot/plugins/ff14-market/src/index.ts @@ -65,6 +65,10 @@ function formatFf14CheckedAt(date: Date) { ].join(''); } +export { + parseQqbotFf14MarketPriceInput, + type QqbotFf14MarketPriceInput, +} from './application/ff14-market-input-parser'; export { buildQqbotFf14MarketCatalog, buildQqbotFf14MarketCatalogFromTree, diff --git a/src/modules/qqbot/plugins/fflogs/src/application/fflogs-input-parser.ts b/src/modules/qqbot/plugins/fflogs/src/application/fflogs-input-parser.ts new file mode 100644 index 0000000..c6be0e4 --- /dev/null +++ b/src/modules/qqbot/plugins/fflogs/src/application/fflogs-input-parser.ts @@ -0,0 +1,159 @@ +export type QqbotFflogsKnownWorldResolver = ( + value: string, +) => null | { serverSlug?: string }; + +export type QqbotFflogsCharacterInputParseOptions = { + resolveKnownWorld?: QqbotFflogsKnownWorldResolver; +}; + +export function parseQqbotFflogsCharacterInput( + rawArgs: string, + options: QqbotFflogsCharacterInputParseOptions = {}, +) { + const tokens = rawArgs.split(/\s+/).filter(Boolean); + const flags = new Map(); + const positional: string[] = []; + + for (const token of tokens) { + if (token.includes('=')) { + const [key, ...rest] = token.split('='); + flags.set(key, rest.join('=')); + } else { + positional.push(token); + } + } + + let characterName = normalizeString( + flags.get('character') || + flags.get('name') || + flags.get('角色') || + flags.get('角色名'), + ); + let serverSlug = normalizeString( + flags.get('server') || + flags.get('serverSlug') || + flags.get('world') || + flags.get('服务器') || + flags.get('小区'), + ); + let encounterName = normalizeString( + flags.get('encounter') || + flags.get('encounterName') || + flags.get('boss') || + flags.get('fight') || + flags.get('任务') || + flags.get('高难') || + flags.get('高难任务'), + ); + let zoneId = normalizeString( + flags.get('zone') || + flags.get('zoneId') || + flags.get('区域') || + flags.get('副本区域'), + ); + const dungeonFlag = normalizeString(flags.get('副本')); + if (dungeonFlag) { + if (/^\d+$/.test(dungeonFlag)) { + zoneId = zoneId || dungeonFlag; + } else { + encounterName = encounterName || dungeonFlag; + } + } + let remainingPositionals = [...positional]; + + if (!characterName && remainingPositionals[0]?.includes('@')) { + const [name, server] = remainingPositionals[0].split('@'); + characterName = name.trim(); + serverSlug = serverSlug || server?.trim(); + if (!encounterName && remainingPositionals.length > 1) { + encounterName = remainingPositionals.slice(1).join(' '); + } + remainingPositionals = []; + } + + if (!encounterName && remainingPositionals.length > 2) { + const picked = pickPositionalsByKnownWorld( + remainingPositionals, + options.resolveKnownWorld, + ); + if (picked) { + characterName = characterName || picked.characterName; + serverSlug = serverSlug || picked.serverSlug; + encounterName = picked.encounterName; + remainingPositionals = []; + } + } + + if (!characterName && remainingPositionals.length) { + const joined = remainingPositionals.join(' '); + if (joined.includes('@')) { + const [name, server] = joined.split('@'); + characterName = name.trim(); + serverSlug = serverSlug || server?.trim(); + } else if (serverSlug) { + characterName = joined; + } else if (remainingPositionals.length > 1) { + serverSlug = remainingPositionals[remainingPositionals.length - 1]; + characterName = remainingPositionals.slice(0, -1).join(' '); + } else { + characterName = joined; + } + } + + return { + characterName, + className: normalizeString(flags.get('class') || flags.get('职业')), + difficulty: normalizeString(flags.get('difficulty') || flags.get('难度')), + encounter: encounterName, + encounterName, + limit: normalizeString(flags.get('limit') || flags.get('数量')), + metric: normalizeString(flags.get('metric') || flags.get('指标')), + partition: normalizeString(flags.get('partition') || flags.get('分区')), + raw: rawArgs, + role: normalizeString(flags.get('role') || flags.get('职责')), + serverRegion: normalizeString( + flags.get('region') || + flags.get('serverRegion') || + flags.get('地区') || + flags.get('服务器地区'), + ), + serverSlug, + size: normalizeString(flags.get('size') || flags.get('人数')), + specName: normalizeString(flags.get('spec') || flags.get('专精')), + text: rawArgs, + timeframe: normalizeString( + flags.get('timeframe') || flags.get('时间') || flags.get('范围'), + ), + zoneId, + }; +} + +function pickPositionalsByKnownWorld( + positional: string[], + resolveKnownWorld?: QqbotFflogsKnownWorldResolver, +) { + if (!resolveKnownWorld) return null; + + for (let index = positional.length - 2; index > 0; index -= 1) { + const candidate = positional[index]; + const resolved = resolveKnownWorld(candidate); + if (!resolved?.serverSlug) continue; + const characterName = positional.slice(0, index).join(' ').trim(); + const encounterName = positional + .slice(index + 1) + .join(' ') + .trim(); + if (!characterName || !encounterName) continue; + return { + characterName, + encounterName, + serverSlug: resolved.serverSlug, + }; + } + return null; +} + +function normalizeString(value?: string | true) { + if (value === true) return ''; + return `${value || ''}`.trim(); +} diff --git a/src/modules/qqbot/plugins/fflogs/src/index.ts b/src/modules/qqbot/plugins/fflogs/src/index.ts index 60072ff..b2e4f3d 100644 --- a/src/modules/qqbot/plugins/fflogs/src/index.ts +++ b/src/modules/qqbot/plugins/fflogs/src/index.ts @@ -60,4 +60,9 @@ export type { QqbotFflogsCharacterSummaryInput, QqbotFflogsCharacterSummaryResult, } from './domain/fflogs.types'; +export { + parseQqbotFflogsCharacterInput, + type QqbotFflogsCharacterInputParseOptions, + type QqbotFflogsKnownWorldResolver, +} from './application/fflogs-input-parser'; export type { FflogsPluginHost } from './infrastructure/integration/fflogs-client'; 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 c38c9a8..00e6fcd 100644 --- a/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts +++ b/test/modules/qqbot/architecture/qqbot-plugin-package-boundary.spec.ts @@ -53,6 +53,29 @@ describe('QQBot plugin package boundary', () => { ).toBe(false); }); + it('does not hard-code built-in plugin packages in platform application services', () => { + const applicationFiles = collectTsFiles( + join(repoRoot, 'src/modules/qqbot/plugin-platform/application'), + ); + + const violations = applicationFiles.flatMap((filePath) => { + const source = readFileSync(filePath, 'utf8'); + const file = toRepoPath(filePath); + return [ + source.includes('@/modules/qqbot/plugins/') + ? `${file} :: concrete plugin import` + : '', + /from ['"]node:fs['"]|from ['"]fs['"]|require\(['"](?:node:)?fs['"]\)/.test( + source, + ) + ? `${file} :: direct host file IO` + : '', + ].filter(Boolean); + }); + + expect(violations).toEqual([]); + }); + it('uses only approved built-in plugin package keys as directory names', () => { const pluginDirs = readdirSync(pluginRoot) .filter((name) => statSync(join(pluginRoot, name)).isDirectory()) diff --git a/test/modules/qqbot/plugins/plugin-input-parser.spec.ts b/test/modules/qqbot/plugins/plugin-input-parser.spec.ts new file mode 100644 index 0000000..fcde71d --- /dev/null +++ b/test/modules/qqbot/plugins/plugin-input-parser.spec.ts @@ -0,0 +1,45 @@ +import { parseQqbotFf14MarketPriceInput } from '../../../../src/modules/qqbot/plugins/ff14-market/src'; +import { parseQqbotFflogsCharacterInput } from '../../../../src/modules/qqbot/plugins/fflogs/src'; + +const ff14Catalog = { + dataCenters: [ + { + name: '猫小胖', + region: '国服', + worlds: ['宇宙和音', '延夏'], + }, + ], + defaultRegion: '国服', + regions: ['国服'], +}; + +describe('QQBot built-in plugin input parsers', () => { + it('keeps FF14 market price parsing inside the FF14 plugin package', () => { + expect( + parseQqbotFf14MarketPriceInput('柔韧鲫鱼 猫小胖 宇宙和音 hq', ff14Catalog), + ).toMatchObject({ + dataCenter: '猫小胖', + hq: true, + item: '柔韧鲫鱼', + language: 'chs', + raw: '柔韧鲫鱼 猫小胖 宇宙和音 hq', + region: '', + world: '宇宙和音', + }); + }); + + it('keeps FFLogs character parsing inside the FFLogs plugin package', () => { + expect( + parseQqbotFflogsCharacterInput('Kwi 宇宙和音 阿尔卡迪亚', { + resolveKnownWorld: (value) => + value === '宇宙和音' ? { serverSlug: value } : null, + }), + ).toMatchObject({ + characterName: 'Kwi', + encounterName: '阿尔卡迪亚', + raw: 'Kwi 宇宙和音 阿尔卡迪亚', + serverSlug: '宇宙和音', + text: 'Kwi 宇宙和音 阿尔卡迪亚', + }); + }); +}); diff --git a/test/modules/qqbot/plugins/plugin-registry-compat.spec.ts b/test/modules/qqbot/plugins/plugin-registry-compat.spec.ts index 2c6a480..890dfed 100644 --- a/test/modules/qqbot/plugins/plugin-registry-compat.spec.ts +++ b/test/modules/qqbot/plugins/plugin-registry-compat.spec.ts @@ -23,12 +23,7 @@ describe('QQBot plugin registry platform key compatibility', () => { const bangdream = createPlugin('bangdream', ['bangDream']); const ff14Market = createPlugin('ff14-market', ['ff14Market']); const fflogs = createPlugin('fflogs', []); - const registry = new QqbotPluginRegistryService( - {} as any, - {} as any, - {} as any, - {} as any, - ); + const registry = new QqbotPluginRegistryService(); registry.register(bangdream); registry.register(ff14Market);