refactor: 收敛QQBot插件平台加载边界

This commit is contained in:
sunlei 2026-06-16 00:20:58 +08:00
parent 9b14acf1bc
commit 69705281fa
15 changed files with 744 additions and 551 deletions

View File

@ -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<Record<string, any>>;
}

View File

@ -1,345 +1,19 @@
import { Injectable } from '@nestjs/common'; import { Inject, Injectable, Optional } 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 type { QqbotPluginExecutionInput } from '@/modules/qqbot/core/domain/plugin-execution.port'; import type { QqbotPluginExecutionInput } from '@/modules/qqbot/core/domain/plugin-execution.port';
import {
QQBOT_PLUGIN_INPUT_NORMALIZER,
type QqbotPluginInputNormalizerPort,
} from './plugin-input-normalizer.port';
@Injectable() @Injectable()
export class QqbotPluginArgumentParserService { export class QqbotPluginArgumentParserService {
constructor(private readonly dictService: DictService) {} constructor(
@Optional()
@Inject(QQBOT_PLUGIN_INPUT_NORMALIZER)
private readonly normalizer?: QqbotPluginInputNormalizerPort,
) {}
async normalizeInput(input: QqbotPluginExecutionInput) { async normalizeInput(input: QqbotPluginExecutionInput) {
const parserKey = `${input.context?.command?.parserKey || 'plain'}`.trim(); return this.normalizer?.normalizeInput(input) || input.input;
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<string, string | true>();
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<string, string | true>();
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<string, any>) {
return Object.entries(input).reduce<Record<string, any>>(
(result, [key, value]) => {
if (value !== undefined && value !== '') result[key] = value;
return result;
},
{},
);
} }
} }

View File

@ -1,10 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { formatKtDateTime, throwVbenError } from '@/common'; import { formatKtDateTime, throwVbenError } from '@/common';
import { QqbotAccountService } from '@/modules/qqbot/core/application/account/qqbot-account.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 { import type {
QqbotEventPluginDefinition, QqbotEventPluginDefinition,
QqbotNormalizedMessage, QqbotNormalizedMessage,
@ -12,41 +8,18 @@ import type {
QqbotPluginOperationSummary, QqbotPluginOperationSummary,
} from '@/modules/qqbot/core/contract/qqbot.types'; } from '@/modules/qqbot/core/contract/qqbot.types';
import { import {
parseQqbotPluginManifest, QqbotBuiltinPluginPackageLoaderService,
type QqbotPluginManifest, type QqbotRepeaterPluginPackage,
} from '@/modules/qqbot/plugin-platform/domain/manifest'; } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service';
import { createPlugin as createRepeaterPlugin } from '@/modules/qqbot/plugins/repeater/src';
@Injectable() @Injectable()
export class QqbotEventPluginRegistryService { export class QqbotEventPluginRegistryService {
private readonly logger = new Logger(QqbotEventPluginRegistryService.name); private repeaterPlugin?: QqbotRepeaterPluginPackage;
private readonly repeaterPlugin: ReturnType<typeof createRepeaterPlugin>;
constructor( constructor(
private readonly accountService: QqbotAccountService, private readonly accountService: QqbotAccountService,
private readonly configService: ConfigService, private readonly builtinPluginLoader: QqbotBuiltinPluginPackageLoaderService,
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,
});
}
listDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] { listDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] {
return this.getDefinitions(pluginKey); return this.getDefinitions(pluginKey);
@ -60,7 +33,7 @@ export class QqbotEventPluginRegistryService {
accounts accounts
.filter((account): account is NonNullable<typeof account> => !!account) .filter((account): account is NonNullable<typeof account> => !!account)
.map((account) => .map((account) =>
this.repeaterPlugin.getSummary({ this.getRepeaterPlugin().getSummary({
accountName: account.name, accountName: account.name,
connectStatus: account.connectStatus, connectStatus: account.connectStatus,
selfId: account.selfId, selfId: account.selfId,
@ -95,13 +68,13 @@ export class QqbotEventPluginRegistryService {
} }
async dispatchMessage(message: QqbotNormalizedMessage) { async dispatchMessage(message: QqbotNormalizedMessage) {
return this.repeaterPlugin.handleMessage(message); return this.getRepeaterPlugin().handleMessage(message);
} }
async bind(pluginKey: string, selfId: string) { async bind(pluginKey: string, selfId: string) {
this.assertPlugin(pluginKey); this.assertPlugin(pluginKey);
if (pluginKey === 'repeater') { if (pluginKey === 'repeater') {
return this.repeaterPlugin.bind(selfId); return this.getRepeaterPlugin().bind(selfId);
} }
await this.accountService.bindEventPlugin(selfId, pluginKey); await this.accountService.bindEventPlugin(selfId, pluginKey);
return true; return true;
@ -110,7 +83,7 @@ export class QqbotEventPluginRegistryService {
async unbind(pluginKey: string, selfId: string) { async unbind(pluginKey: string, selfId: string) {
this.assertPlugin(pluginKey); this.assertPlugin(pluginKey);
if (pluginKey === 'repeater') { if (pluginKey === 'repeater') {
return this.repeaterPlugin.unbind(selfId); return this.getRepeaterPlugin().unbind(selfId);
} }
await this.accountService.unbindEventPlugin(selfId, pluginKey); await this.accountService.unbindEventPlugin(selfId, pluginKey);
return true; return true;
@ -122,30 +95,14 @@ export class QqbotEventPluginRegistryService {
} }
private getDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] { private getDefinitions(pluginKey?: string): QqbotEventPluginDefinition[] {
const definitions = [this.repeaterPlugin.getDefinition()]; const definitions = [this.getRepeaterPlugin().getDefinition()];
return pluginKey return pluginKey
? definitions.filter((definition) => definition.key === pluginKey) ? definitions.filter((definition) => definition.key === pluginKey)
: definitions; : definitions;
} }
private loadManifest(pluginKey: string): QqbotPluginManifest { private getRepeaterPlugin() {
const pluginRoot = this.resolvePluginRoot(pluginKey); this.repeaterPlugin ||= this.builtinPluginLoader.loadRepeaterPlugin();
return parseQqbotPluginManifest( return this.repeaterPlugin;
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<T = unknown>(filePath: string) {
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
}

View File

@ -1,10 +1,5 @@
import { Injectable, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { formatKtDateTime, throwVbenError } from '@/common';
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 type { import type {
QqbotIntegrationPlugin, QqbotIntegrationPlugin,
QqbotPluginHealth, QqbotPluginHealth,
@ -12,16 +7,7 @@ import type {
QqbotPluginOperationSummary, QqbotPluginOperationSummary,
QqbotPluginSummary, QqbotPluginSummary,
} from '@/modules/qqbot/core/contract/qqbot.types'; } from '@/modules/qqbot/core/contract/qqbot.types';
import { import { QqbotBuiltinPluginPackageLoaderService } from '@/modules/qqbot/plugin-platform/infrastructure/integration/package/builtin-plugin-package-loader.service';
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';
@Injectable() @Injectable()
export class QqbotPluginRegistryService implements OnModuleInit { export class QqbotPluginRegistryService implements OnModuleInit {
@ -29,16 +15,14 @@ export class QqbotPluginRegistryService implements OnModuleInit {
private readonly plugins = new Map<string, QqbotIntegrationPlugin>(); private readonly plugins = new Map<string, QqbotIntegrationPlugin>();
constructor( constructor(
private readonly configService: ConfigService, @Optional()
private readonly dictService: DictService, private readonly builtinPluginLoader?: QqbotBuiltinPluginPackageLoaderService,
private readonly httpClient: QqbotPluginHttpClientService,
private readonly toolsService: ToolsService,
) {} ) {}
onModuleInit() { onModuleInit() {
this.register(this.createBangDreamPlugin()); for (const plugin of this.builtinPluginLoader?.loadCommandPlugins() || []) {
this.register(this.createFf14MarketPlugin()); this.register(plugin);
this.register(this.createFflogsPlugin()); }
} }
register(plugin: QqbotIntegrationPlugin) { register(plugin: QqbotIntegrationPlugin) {
@ -147,126 +131,4 @@ export class QqbotPluginRegistryService implements OnModuleInit {
this.plugins.get(this.pluginAliases.get(pluginKey) || '') 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<T = unknown>(filePath: string) {
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
}
function readExcelRows<T extends Record<string, unknown>>(filePath: string) {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
return XLSX.utils.sheet_to_json<T>(worksheet);
} }

View File

@ -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<QqbotFf14MarketCatalog> {
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<string, any>) {
return Object.entries(input).reduce<Record<string, any>>(
(result, [key, value]) => {
if (value !== undefined && value !== '') result[key] = value;
return result;
},
{},
);
}
}

View File

@ -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<T = unknown>(filePath: string) {
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
}
function readExcelRows<T extends Record<string, unknown>>(filePath: string) {
const workbook = XLSX.readFile(filePath);
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
return XLSX.utils.sheet_to_json<T>(worksheet);
}

View File

@ -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 { QQBOT_PLUGIN_EXECUTION_PORT } from '@/modules/qqbot/core/domain/plugin-execution.port';
import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module'; import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module';
import { QqbotPluginArgumentParserService } from './application/argument/qqbot-plugin-argument-parser.service'; 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 { QqbotEventPluginRegistryService } from './application/registry/qqbot-event-plugin-registry.service';
import { QqbotPluginRegistryService } from './application/registry/qqbot-plugin-registry.service'; import { QqbotPluginRegistryService } from './application/registry/qqbot-plugin-registry.service';
import { QqbotPluginExecutionAdapter } from './application/plugin-execution.adapter'; 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 { QqbotPluginPlatformController } from './contract/plugin-platform.controller';
import { QqbotPluginController } from './contract/qqbot-plugin.controller'; import { QqbotPluginController } from './contract/qqbot-plugin.controller';
import { QQBOT_PLUGIN_PLATFORM_ENTITIES } from './infrastructure/persistence'; 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'; import { QqbotPluginHttpClientService } from './infrastructure/integration/sdk';
@Module({ @Module({
@ -33,6 +36,12 @@ import { QqbotPluginHttpClientService } from './infrastructure/integration/sdk';
QqbotEventPluginRegistryService, QqbotEventPluginRegistryService,
QqbotPluginArgumentParserService, QqbotPluginArgumentParserService,
QqbotPluginExecutionAdapter, QqbotPluginExecutionAdapter,
QqbotPluginInputNormalizerService,
QqbotBuiltinPluginPackageLoaderService,
{
provide: QQBOT_PLUGIN_INPUT_NORMALIZER,
useExisting: QqbotPluginInputNormalizerService,
},
{ {
provide: QQBOT_PLUGIN_EXECUTION_PORT, provide: QQBOT_PLUGIN_EXECUTION_PORT,
useExisting: QqbotPluginExecutionAdapter, useExisting: QqbotPluginExecutionAdapter,

View File

@ -22,9 +22,28 @@ const KEYWORD_PATTERN = /["“”『』「」]([^"“”『』「」]+)["“”
const QUOTE_EDGE_PATTERN = /^["“”『』「」]|["“”『』「」]$/g; const QUOTE_EDGE_PATTERN = /^["“”『』「」]|["“”『』「」]$/g;
const RESERVED_MATCH_KEYS = new Set(['_number', '_relationStr', '_all']); const RESERVED_MATCH_KEYS = new Set(['_number', '_relationStr', '_all']);
export const config: FuzzySearchConfig = let cachedConfig: FuzzySearchConfig | undefined;
searchDictionaryRepository.loadConfig(); let cachedRuleRegistry:
const ruleRegistry = createDefaultFuzzySearchRuleRegistry(config); | ReturnType<typeof createDefaultFuzzySearchRuleRegistry>
| 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); const push = appendTo(matches);
for (const rawKeyword of extractKeywords(keyword)) { for (const rawKeyword of extractKeywords(keyword)) {
ruleRegistry.match(createFuzzySearchKeyword(rawKeyword), push); getRuleRegistry().match(createFuzzySearchKeyword(rawKeyword), push);
} }
return matches; return matches;

View File

@ -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<string, string | true>();
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();
}

View File

@ -65,6 +65,10 @@ function formatFf14CheckedAt(date: Date) {
].join(''); ].join('');
} }
export {
parseQqbotFf14MarketPriceInput,
type QqbotFf14MarketPriceInput,
} from './application/ff14-market-input-parser';
export { export {
buildQqbotFf14MarketCatalog, buildQqbotFf14MarketCatalog,
buildQqbotFf14MarketCatalogFromTree, buildQqbotFf14MarketCatalogFromTree,

View File

@ -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<string, string | true>();
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();
}

View File

@ -60,4 +60,9 @@ export type {
QqbotFflogsCharacterSummaryInput, QqbotFflogsCharacterSummaryInput,
QqbotFflogsCharacterSummaryResult, QqbotFflogsCharacterSummaryResult,
} from './domain/fflogs.types'; } from './domain/fflogs.types';
export {
parseQqbotFflogsCharacterInput,
type QqbotFflogsCharacterInputParseOptions,
type QqbotFflogsKnownWorldResolver,
} from './application/fflogs-input-parser';
export type { FflogsPluginHost } from './infrastructure/integration/fflogs-client'; export type { FflogsPluginHost } from './infrastructure/integration/fflogs-client';

View File

@ -53,6 +53,29 @@ describe('QQBot plugin package boundary', () => {
).toBe(false); ).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', () => { it('uses only approved built-in plugin package keys as directory names', () => {
const pluginDirs = readdirSync(pluginRoot) const pluginDirs = readdirSync(pluginRoot)
.filter((name) => statSync(join(pluginRoot, name)).isDirectory()) .filter((name) => statSync(join(pluginRoot, name)).isDirectory())

View File

@ -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 宇宙和音 阿尔卡迪亚',
});
});
});

View File

@ -23,12 +23,7 @@ describe('QQBot plugin registry platform key compatibility', () => {
const bangdream = createPlugin('bangdream', ['bangDream']); const bangdream = createPlugin('bangdream', ['bangDream']);
const ff14Market = createPlugin('ff14-market', ['ff14Market']); const ff14Market = createPlugin('ff14-market', ['ff14Market']);
const fflogs = createPlugin('fflogs', []); const fflogs = createPlugin('fflogs', []);
const registry = new QqbotPluginRegistryService( const registry = new QqbotPluginRegistryService();
{} as any,
{} as any,
{} as any,
{} as any,
);
registry.register(bangdream); registry.register(bangdream);
registry.register(ff14Market); registry.register(ff14Market);