kt-template-online-api/src/qqbot/command/qqbot-command-parser.service.ts

394 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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();
}
}