394 lines
12 KiB
TypeScript
394 lines
12 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
||
import { DictService } from '../../admin/dict/dict.service';
|
||
import type { QqbotCommand } from './qqbot-command.entity';
|
||
import type { QqbotNormalizedMessage } from '../qqbot.types';
|
||
import {
|
||
buildQqbotFf14MarketCatalog,
|
||
buildQqbotFf14MarketCatalogFromTree,
|
||
QQBOT_FF14_MARKET_DICT_CODES,
|
||
isQqbotFf14DataCenterName,
|
||
isQqbotFf14LocationName,
|
||
isQqbotFf14RegionName,
|
||
isQqbotFf14WorldName,
|
||
splitQqbotFf14WorldPath,
|
||
} from '../plugins/ff14Market/qqbot-ff14-worlds';
|
||
import type { QqbotFf14MarketCatalog } from '../plugins/ff14Market/qqbot-ff14-market.types';
|
||
import type { QqbotCommandMatchResult } from '../qqbot.types';
|
||
|
||
@Injectable()
|
||
export class QqbotCommandParserService {
|
||
constructor(private readonly dictService: DictService) {}
|
||
|
||
async match(command: QqbotCommand, message: QqbotNormalizedMessage) {
|
||
const source = `${message.messageText || ''}`.trim();
|
||
if (!source) return null;
|
||
|
||
const aliases = this.getAliases(command);
|
||
const prefixes = this.getPrefixes(command);
|
||
for (const alias of aliases) {
|
||
for (const prefix of prefixes) {
|
||
const commandText = `${prefix}${alias}`.trim();
|
||
const rawArgs = this.pickArgs(source, commandText);
|
||
if (rawArgs === null) continue;
|
||
return {
|
||
alias,
|
||
input: await this.parseInput(command, rawArgs),
|
||
matched: true,
|
||
rawArgs,
|
||
} satisfies QqbotCommandMatchResult;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
getAliases(command: QqbotCommand) {
|
||
return this.normalizeList(command.aliases, [command.code, command.name]);
|
||
}
|
||
|
||
getPrefixes(command: QqbotCommand) {
|
||
return this.normalizeList(command.prefixes, ['/', '!', '!']);
|
||
}
|
||
|
||
private pickArgs(source: string, commandText: string) {
|
||
if (!commandText) return null;
|
||
if (source === commandText) return '';
|
||
if (source.startsWith(`${commandText} `)) {
|
||
return source.slice(commandText.length).trim();
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private async parseInput(command: QqbotCommand, rawArgs: string) {
|
||
if (command.parserKey === 'ff14Price') {
|
||
return this.parseFf14PriceInput(rawArgs);
|
||
}
|
||
if (command.parserKey === 'fflogsCharacter') {
|
||
return this.parseFflogsCharacterInput(rawArgs);
|
||
}
|
||
const args = rawArgs ? rawArgs.split(/\s+/).filter(Boolean) : [];
|
||
return {
|
||
args,
|
||
raw: rawArgs,
|
||
text: rawArgs,
|
||
};
|
||
}
|
||
|
||
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,
|
||
metric: this.normalizeString(flags.get('metric') || flags.get('指标')),
|
||
partition: this.normalizeString(
|
||
flags.get('partition') || flags.get('分区'),
|
||
),
|
||
limit: this.normalizeString(flags.get('limit') || 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--) {
|
||
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 normalizeList(value: string | undefined, fallback: string[]) {
|
||
const raw = `${value || ''}`.trim();
|
||
const parsed = this.tryParseJsonArray(raw);
|
||
const source = parsed.length > 0 ? parsed : raw.split(',');
|
||
const list = [...source, ...fallback]
|
||
.map((item) => `${item || ''}`.trim())
|
||
.filter(Boolean);
|
||
return [...new Set(list)];
|
||
}
|
||
|
||
private tryParseJsonArray(value: string) {
|
||
if (!value.startsWith('[')) return [];
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
return Array.isArray(parsed) ? parsed : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
private normalizeString(value?: string | true) {
|
||
if (value === true) return '';
|
||
return `${value || ''}`.trim();
|
||
}
|
||
}
|