refactor: 收敛QQBot插件平台加载边界
This commit is contained in:
parent
9b14acf1bc
commit
69705281fa
@ -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>>;
|
||||
}
|
||||
@ -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<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;
|
||||
},
|
||||
{},
|
||||
);
|
||||
return this.normalizer?.normalizeInput(input) || input.input;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<typeof createRepeaterPlugin>;
|
||||
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<typeof account> => !!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<T = unknown>(filePath: string) {
|
||||
return JSON.parse(readFileSync(filePath, 'utf8')) as T;
|
||||
}
|
||||
|
||||
@ -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<string, QqbotIntegrationPlugin>();
|
||||
|
||||
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<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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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<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);
|
||||
|
||||
for (const rawKeyword of extractKeywords(keyword)) {
|
||||
ruleRegistry.match(createFuzzySearchKeyword(rawKeyword), push);
|
||||
getRuleRegistry().match(createFuzzySearchKeyword(rawKeyword), push);
|
||||
}
|
||||
|
||||
return matches;
|
||||
|
||||
@ -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();
|
||||
}
|
||||
@ -65,6 +65,10 @@ function formatFf14CheckedAt(date: Date) {
|
||||
].join('');
|
||||
}
|
||||
|
||||
export {
|
||||
parseQqbotFf14MarketPriceInput,
|
||||
type QqbotFf14MarketPriceInput,
|
||||
} from './application/ff14-market-input-parser';
|
||||
export {
|
||||
buildQqbotFf14MarketCatalog,
|
||||
buildQqbotFf14MarketCatalogFromTree,
|
||||
|
||||
@ -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();
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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())
|
||||
|
||||
45
test/modules/qqbot/plugins/plugin-input-parser.spec.ts
Normal file
45
test/modules/qqbot/plugins/plugin-input-parser.spec.ts
Normal 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 宇宙和音 阿尔卡迪亚',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user