feat: QQBot映射改为字典维护
This commit is contained in:
parent
c73084b4d1
commit
cf2c20ba22
@ -543,6 +543,101 @@ ON DUPLICATE KEY UPDATE
|
|||||||
`status` = VALUES(`status`),
|
`status` = VALUES(`status`),
|
||||||
`is_deleted` = 0;
|
`is_deleted` = 0;
|
||||||
|
|
||||||
|
INSERT INTO `admin_dict` (`id`, `dict_code`, `label`, `value`, `children_code`, `sort`, `status`)
|
||||||
|
VALUES
|
||||||
|
(2041700000000300601, 'FFLOGS_ENCOUNTER_LABEL', 'M9S Vamp Fatale', 'vampfatale', NULL, 1, 1),
|
||||||
|
(2041700000000300602, 'FFLOGS_ENCOUNTER_LABEL', 'M10S Red Hot and Deep Blue', 'redhotanddeepblue', NULL, 2, 1),
|
||||||
|
(2041700000000300603, 'FFLOGS_ENCOUNTER_LABEL', 'M11S The Tyrant', 'thetyrant', NULL, 3, 1),
|
||||||
|
(2041700000000300604, 'FFLOGS_ENCOUNTER_LABEL', 'M12S P1 Lindwurm', 'lindwurm', NULL, 4, 1),
|
||||||
|
(2041700000000300605, 'FFLOGS_ENCOUNTER_LABEL', 'M12S P2 Lindwurm II', 'lindwurmii', NULL, 5, 1),
|
||||||
|
(2041700000000300701, 'FFLOGS_JOB_LABEL', '骑士', 'paladin', NULL, 1, 1),
|
||||||
|
(2041700000000300702, 'FFLOGS_JOB_LABEL', '战士', 'warrior', NULL, 2, 1),
|
||||||
|
(2041700000000300703, 'FFLOGS_JOB_LABEL', '暗黑骑士', 'darkknight', NULL, 3, 1),
|
||||||
|
(2041700000000300704, 'FFLOGS_JOB_LABEL', '绝枪战士', 'gunbreaker', NULL, 4, 1),
|
||||||
|
(2041700000000300705, 'FFLOGS_JOB_LABEL', '白魔法师', 'whitemage', NULL, 5, 1),
|
||||||
|
(2041700000000300706, 'FFLOGS_JOB_LABEL', '学者', 'scholar', NULL, 6, 1),
|
||||||
|
(2041700000000300707, 'FFLOGS_JOB_LABEL', '占星术士', 'astrologian', NULL, 7, 1),
|
||||||
|
(2041700000000300708, 'FFLOGS_JOB_LABEL', '贤者', 'sage', NULL, 8, 1),
|
||||||
|
(2041700000000300709, 'FFLOGS_JOB_LABEL', '武僧', 'monk', NULL, 9, 1),
|
||||||
|
(2041700000000300710, 'FFLOGS_JOB_LABEL', '龙骑士', 'dragoon', NULL, 10, 1),
|
||||||
|
(2041700000000300711, 'FFLOGS_JOB_LABEL', '忍者', 'ninja', NULL, 11, 1),
|
||||||
|
(2041700000000300712, 'FFLOGS_JOB_LABEL', '武士', 'samurai', NULL, 12, 1),
|
||||||
|
(2041700000000300713, 'FFLOGS_JOB_LABEL', '钐镰客', 'reaper', NULL, 13, 1),
|
||||||
|
(2041700000000300714, 'FFLOGS_JOB_LABEL', '蝰蛇剑士', 'viper', NULL, 14, 1),
|
||||||
|
(2041700000000300715, 'FFLOGS_JOB_LABEL', '吟游诗人', 'bard', NULL, 15, 1),
|
||||||
|
(2041700000000300716, 'FFLOGS_JOB_LABEL', '机工士', 'machinist', NULL, 16, 1),
|
||||||
|
(2041700000000300717, 'FFLOGS_JOB_LABEL', '舞者', 'dancer', NULL, 17, 1),
|
||||||
|
(2041700000000300718, 'FFLOGS_JOB_LABEL', '黑魔法师', 'blackmage', NULL, 18, 1),
|
||||||
|
(2041700000000300719, 'FFLOGS_JOB_LABEL', '召唤师', 'summoner', NULL, 19, 1),
|
||||||
|
(2041700000000300720, 'FFLOGS_JOB_LABEL', '赤魔法师', 'redmage', NULL, 20, 1),
|
||||||
|
(2041700000000300721, 'FFLOGS_JOB_LABEL', '绘灵法师', 'pictomancer', NULL, 21, 1),
|
||||||
|
(2041700000000300722, 'FFLOGS_JOB_LABEL', '青魔法师', 'bluemage', NULL, 22, 1),
|
||||||
|
(2041700000000300801, 'FFLOGS_METRIC_LABEL', 'DPS', 'dps', NULL, 1, 1),
|
||||||
|
(2041700000000300802, 'FFLOGS_METRIC_LABEL', 'HPS', 'hps', NULL, 2, 1),
|
||||||
|
(2041700000000300803, 'FFLOGS_METRIC_LABEL', 'rDPS', 'rdps', NULL, 3, 1),
|
||||||
|
(2041700000000300804, 'FFLOGS_METRIC_LABEL', 'aDPS', 'adps', NULL, 4, 1),
|
||||||
|
(2041700000000300805, 'FFLOGS_METRIC_LABEL', 'nDPS', 'ndps', NULL, 5, 1),
|
||||||
|
(2041700000000300806, 'FFLOGS_METRIC_LABEL', 'Boss DPS', 'bossdps', NULL, 6, 1),
|
||||||
|
(2041700000000300807, 'FFLOGS_METRIC_LABEL', 'Boss rDPS', 'bossrdps', NULL, 7, 1),
|
||||||
|
(2041700000000300901, 'FFLOGS_ROLE_LABEL', '坦克', 'tank', NULL, 1, 1),
|
||||||
|
(2041700000000300902, 'FFLOGS_ROLE_LABEL', '治疗', 'healer', NULL, 2, 1),
|
||||||
|
(2041700000000300903, 'FFLOGS_ROLE_LABEL', '输出', 'dps', NULL, 3, 1),
|
||||||
|
(2041700000000301001, 'FFLOGS_SERVER_REGION_LABEL', '国服', 'cn', NULL, 1, 1),
|
||||||
|
(2041700000000301002, 'FFLOGS_SERVER_REGION_LABEL', '日服', 'jp', NULL, 2, 1),
|
||||||
|
(2041700000000301003, 'FFLOGS_SERVER_REGION_LABEL', '美服', 'na', NULL, 3, 1),
|
||||||
|
(2041700000000301004, 'FFLOGS_SERVER_REGION_LABEL', '欧服', 'eu', NULL, 4, 1),
|
||||||
|
(2041700000000301005, 'FFLOGS_SERVER_REGION_LABEL', '韩服', 'kr', NULL, 5, 1),
|
||||||
|
(2041700000000301006, 'FFLOGS_SERVER_REGION_LABEL', '台服', 'tw', NULL, 6, 1),
|
||||||
|
(2041700000000301007, 'FFLOGS_SERVER_REGION_LABEL', '澳服', 'oc', NULL, 7, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`label` = VALUES(`label`),
|
||||||
|
`children_code` = VALUES(`children_code`),
|
||||||
|
`sort` = VALUES(`sort`),
|
||||||
|
`status` = VALUES(`status`),
|
||||||
|
`is_deleted` = 0;
|
||||||
|
|
||||||
|
INSERT INTO `admin_dict` (`id`, `dict_code`, `label`, `value`, `children_code`, `sort`, `status`)
|
||||||
|
VALUES
|
||||||
|
(2041700000000301101, 'FF14_MARKET_REGION', '中国', '中国', NULL, 1, 1),
|
||||||
|
(2041700000000301201, 'FF14_MARKET_DATA_CENTER', '陆行鸟', '陆行鸟', '中国', 1, 1),
|
||||||
|
(2041700000000301202, 'FF14_MARKET_DATA_CENTER', '莫古力', '莫古力', '中国', 2, 1),
|
||||||
|
(2041700000000301203, 'FF14_MARKET_DATA_CENTER', '猫小胖', '猫小胖', '中国', 3, 1),
|
||||||
|
(2041700000000301204, 'FF14_MARKET_DATA_CENTER', '豆豆柴', '豆豆柴', '中国', 4, 1),
|
||||||
|
(2041700000000301301, 'FF14_MARKET_WORLD', '红玉海', '红玉海', '陆行鸟', 1, 1),
|
||||||
|
(2041700000000301302, 'FF14_MARKET_WORLD', '神意之地', '神意之地', '陆行鸟', 2, 1),
|
||||||
|
(2041700000000301303, 'FF14_MARKET_WORLD', '拉诺西亚', '拉诺西亚', '陆行鸟', 3, 1),
|
||||||
|
(2041700000000301304, 'FF14_MARKET_WORLD', '幻影群岛', '幻影群岛', '陆行鸟', 4, 1),
|
||||||
|
(2041700000000301305, 'FF14_MARKET_WORLD', '萌芽池', '萌芽池', '陆行鸟', 5, 1),
|
||||||
|
(2041700000000301306, 'FF14_MARKET_WORLD', '宇宙和音', '宇宙和音', '陆行鸟', 6, 1),
|
||||||
|
(2041700000000301307, 'FF14_MARKET_WORLD', '沃仙曦染', '沃仙曦染', '陆行鸟', 7, 1),
|
||||||
|
(2041700000000301308, 'FF14_MARKET_WORLD', '晨曦王座', '晨曦王座', '陆行鸟', 8, 1),
|
||||||
|
(2041700000000301309, 'FF14_MARKET_WORLD', '白银乡', '白银乡', '莫古力', 9, 1),
|
||||||
|
(2041700000000301310, 'FF14_MARKET_WORLD', '白金幻象', '白金幻象', '莫古力', 10, 1),
|
||||||
|
(2041700000000301311, 'FF14_MARKET_WORLD', '神拳痕', '神拳痕', '莫古力', 11, 1),
|
||||||
|
(2041700000000301312, 'FF14_MARKET_WORLD', '潮风亭', '潮风亭', '莫古力', 12, 1),
|
||||||
|
(2041700000000301313, 'FF14_MARKET_WORLD', '旅人栈桥', '旅人栈桥', '莫古力', 13, 1),
|
||||||
|
(2041700000000301314, 'FF14_MARKET_WORLD', '拂晓之间', '拂晓之间', '莫古力', 14, 1),
|
||||||
|
(2041700000000301315, 'FF14_MARKET_WORLD', '龙巢神殿', '龙巢神殿', '莫古力', 15, 1),
|
||||||
|
(2041700000000301316, 'FF14_MARKET_WORLD', '梦羽宝境', '梦羽宝境', '莫古力', 16, 1),
|
||||||
|
(2041700000000301317, 'FF14_MARKET_WORLD', '紫水栈桥', '紫水栈桥', '猫小胖', 17, 1),
|
||||||
|
(2041700000000301318, 'FF14_MARKET_WORLD', '延夏', '延夏', '猫小胖', 18, 1),
|
||||||
|
(2041700000000301319, 'FF14_MARKET_WORLD', '静语庄园', '静语庄园', '猫小胖', 19, 1),
|
||||||
|
(2041700000000301320, 'FF14_MARKET_WORLD', '摩杜纳', '摩杜纳', '猫小胖', 20, 1),
|
||||||
|
(2041700000000301321, 'FF14_MARKET_WORLD', '海猫茶屋', '海猫茶屋', '猫小胖', 21, 1),
|
||||||
|
(2041700000000301322, 'FF14_MARKET_WORLD', '柔风海湾', '柔风海湾', '猫小胖', 22, 1),
|
||||||
|
(2041700000000301323, 'FF14_MARKET_WORLD', '琥珀原', '琥珀原', '猫小胖', 23, 1),
|
||||||
|
(2041700000000301324, 'FF14_MARKET_WORLD', '水晶塔', '水晶塔', '豆豆柴', 24, 1),
|
||||||
|
(2041700000000301325, 'FF14_MARKET_WORLD', '银泪湖', '银泪湖', '豆豆柴', 25, 1),
|
||||||
|
(2041700000000301326, 'FF14_MARKET_WORLD', '太阳海岸', '太阳海岸', '豆豆柴', 26, 1),
|
||||||
|
(2041700000000301327, 'FF14_MARKET_WORLD', '伊修加德', '伊修加德', '豆豆柴', 27, 1),
|
||||||
|
(2041700000000301328, 'FF14_MARKET_WORLD', '红茶川', '红茶川', '豆豆柴', 28, 1)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
`label` = VALUES(`label`),
|
||||||
|
`children_code` = VALUES(`children_code`),
|
||||||
|
`sort` = VALUES(`sort`),
|
||||||
|
`status` = VALUES(`status`),
|
||||||
|
`is_deleted` = 0;
|
||||||
|
|
||||||
INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`, `auth_code`, `type`, `meta`, `status`, `sort`)
|
INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`, `auth_code`, `type`, `meta`, `status`, `sort`)
|
||||||
VALUES
|
VALUES
|
||||||
(2041700000000100400, 0, 'QqBot', '/qqbot', NULL, '/qqbot/dashboard', NULL, 'catalog', '{"icon":"lucide:bot","order":110,"title":"QQBot 管理"}', 1, 110),
|
(2041700000000100400, 0, 'QqBot', '/qqbot', NULL, '/qqbot/dashboard', NULL, 'catalog', '{"icon":"lucide:bot","order":110,"title":"QQBot 管理"}', 1, 110),
|
||||||
|
|||||||
@ -6,6 +6,12 @@ import { AdminDict } from './admin-dict.entity';
|
|||||||
|
|
||||||
const COMPONENT_TYPE_DICT_KEY = 'COMPONENT_TYPE';
|
const COMPONENT_TYPE_DICT_KEY = 'COMPONENT_TYPE';
|
||||||
|
|
||||||
|
export type AdminDictItem = {
|
||||||
|
childrenCode?: string | null;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DictService implements OnApplicationBootstrap {
|
export class DictService implements OnApplicationBootstrap {
|
||||||
constructor(
|
constructor(
|
||||||
@ -18,6 +24,15 @@ export class DictService implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getDictByKey(dictKey: string): Promise<Dict[]> {
|
async getDictByKey(dictKey: string): Promise<Dict[]> {
|
||||||
|
const list = await this.getDictItemsByKey(dictKey);
|
||||||
|
|
||||||
|
return list.map(({ label, value }) => ({
|
||||||
|
label,
|
||||||
|
value: Number.isNaN(Number(value)) ? value : Number(value),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDictItemsByKey(dictKey: string): Promise<AdminDictItem[]> {
|
||||||
const list = await this.dictRepository.find({
|
const list = await this.dictRepository.find({
|
||||||
where: {
|
where: {
|
||||||
dictCode: dictKey,
|
dictCode: dictKey,
|
||||||
@ -30,9 +45,10 @@ export class DictService implements OnApplicationBootstrap {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return list.map(({ label, value }) => ({
|
return list.map(({ childrenCode, label, value }) => ({
|
||||||
|
childrenCode,
|
||||||
label,
|
label,
|
||||||
value: Number.isNaN(Number(value)) ? value : Number(value),
|
value,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export class QqbotCommandEngineService {
|
|||||||
async handleMessage(message: QqbotNormalizedMessage) {
|
async handleMessage(message: QqbotNormalizedMessage) {
|
||||||
const commands = await this.commandService.listEnabledForMessage(message);
|
const commands = await this.commandService.listEnabledForMessage(message);
|
||||||
for (const command of commands) {
|
for (const command of commands) {
|
||||||
const matched = this.commandParser.match(command, message);
|
const matched = await this.commandParser.match(command, message);
|
||||||
if (!matched) continue;
|
if (!matched) continue;
|
||||||
if (this.commandService.isInCooldown(command)) return true;
|
if (this.commandService.isInCooldown(command)) return true;
|
||||||
|
|
||||||
@ -61,7 +61,8 @@ export class QqbotCommandEngineService {
|
|||||||
status: 'success',
|
status: 'success',
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = err instanceof Error ? err.message : '命令执行失败';
|
const errorMessage =
|
||||||
|
err instanceof Error ? err.message : '命令执行失败';
|
||||||
await this.commandService.logExecution({
|
await this.commandService.logExecution({
|
||||||
command,
|
command,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@ -82,7 +83,7 @@ export class QqbotCommandEngineService {
|
|||||||
const command = body.commandId
|
const command = body.commandId
|
||||||
? await this.commandService.findById(body.commandId)
|
? await this.commandService.findById(body.commandId)
|
||||||
: await this.findMatchedCommand(message);
|
: await this.findMatchedCommand(message);
|
||||||
const matched = this.commandParser.match(command, message);
|
const matched = await this.commandParser.match(command, message);
|
||||||
if (!matched) {
|
if (!matched) {
|
||||||
return {
|
return {
|
||||||
matched: false,
|
matched: false,
|
||||||
@ -127,13 +128,12 @@ export class QqbotCommandEngineService {
|
|||||||
|
|
||||||
private async findMatchedCommand(message: QqbotNormalizedMessage) {
|
private async findMatchedCommand(message: QqbotNormalizedMessage) {
|
||||||
const commands = await this.commandService.listEnabledForMessage(message);
|
const commands = await this.commandService.listEnabledForMessage(message);
|
||||||
const command = commands.find((item) =>
|
for (const command of commands) {
|
||||||
this.commandParser.match(item, message),
|
if (await this.commandParser.match(command, message)) {
|
||||||
);
|
return command;
|
||||||
if (!command) {
|
}
|
||||||
throw new Error('未匹配到命令');
|
|
||||||
}
|
}
|
||||||
return command;
|
throw new Error('未匹配到命令');
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildReplyText(
|
private buildReplyText(
|
||||||
@ -202,7 +202,9 @@ export class QqbotCommandEngineService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildPreviewMessage(body: QqbotCommandTestDto): QqbotNormalizedMessage {
|
private buildPreviewMessage(
|
||||||
|
body: QqbotCommandTestDto,
|
||||||
|
): QqbotNormalizedMessage {
|
||||||
const targetType = body.targetType || 'private';
|
const targetType = body.targetType || 'private';
|
||||||
const targetId = body.targetId || body.userId || '10000';
|
const targetId = body.targetId || body.userId || '10000';
|
||||||
const userId = body.userId || targetId;
|
const userId = body.userId || targetId;
|
||||||
|
|||||||
@ -1,7 +1,11 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { DictService } from '../../admin/dict/dict.service';
|
||||||
import type { QqbotCommand } from './qqbot-command.entity';
|
import type { QqbotCommand } from './qqbot-command.entity';
|
||||||
import type { QqbotNormalizedMessage } from '../qqbot.types';
|
import type { QqbotNormalizedMessage } from '../qqbot.types';
|
||||||
import {
|
import {
|
||||||
|
buildQqbotFf14MarketCatalog,
|
||||||
|
QQBOT_FF14_MARKET_DICT_CODES,
|
||||||
|
type QqbotFf14MarketCatalog,
|
||||||
isQqbotFf14DataCenterName,
|
isQqbotFf14DataCenterName,
|
||||||
isQqbotFf14LocationName,
|
isQqbotFf14LocationName,
|
||||||
isQqbotFf14RegionName,
|
isQqbotFf14RegionName,
|
||||||
@ -18,7 +22,9 @@ export type QqbotCommandMatchResult = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotCommandParserService {
|
export class QqbotCommandParserService {
|
||||||
match(command: QqbotCommand, message: QqbotNormalizedMessage) {
|
constructor(private readonly dictService: DictService) {}
|
||||||
|
|
||||||
|
async match(command: QqbotCommand, message: QqbotNormalizedMessage) {
|
||||||
const source = `${message.messageText || ''}`.trim();
|
const source = `${message.messageText || ''}`.trim();
|
||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
|
|
||||||
@ -31,7 +37,7 @@ export class QqbotCommandParserService {
|
|||||||
if (rawArgs === null) continue;
|
if (rawArgs === null) continue;
|
||||||
return {
|
return {
|
||||||
alias,
|
alias,
|
||||||
input: this.parseInput(command, rawArgs),
|
input: await this.parseInput(command, rawArgs),
|
||||||
matched: true,
|
matched: true,
|
||||||
rawArgs,
|
rawArgs,
|
||||||
} satisfies QqbotCommandMatchResult;
|
} satisfies QqbotCommandMatchResult;
|
||||||
@ -57,7 +63,7 @@ export class QqbotCommandParserService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseInput(command: QqbotCommand, rawArgs: string) {
|
private async parseInput(command: QqbotCommand, rawArgs: string) {
|
||||||
if (command.parserKey === 'ff14Price') {
|
if (command.parserKey === 'ff14Price') {
|
||||||
return this.parseFf14PriceInput(rawArgs);
|
return this.parseFf14PriceInput(rawArgs);
|
||||||
}
|
}
|
||||||
@ -72,7 +78,8 @@ export class QqbotCommandParserService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseFf14PriceInput(rawArgs: string) {
|
private async parseFf14PriceInput(rawArgs: string) {
|
||||||
|
const catalog = await this.getFf14MarketCatalog();
|
||||||
const tokens = rawArgs.split(/\s+/).filter(Boolean);
|
const tokens = rawArgs.split(/\s+/).filter(Boolean);
|
||||||
const flags = new Map<string, string | true>();
|
const flags = new Map<string, string | true>();
|
||||||
const positional: string[] = [];
|
const positional: string[] = [];
|
||||||
@ -113,7 +120,7 @@ export class QqbotCommandParserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!world && !dataCenter && positional.length > 1) {
|
if (!world && !dataCenter && positional.length > 1) {
|
||||||
const picked = this.pickTrailingFf14Location(positional);
|
const picked = this.pickTrailingFf14Location(catalog, positional);
|
||||||
if (picked) {
|
if (picked) {
|
||||||
dataCenter = picked.dataCenter || dataCenter;
|
dataCenter = picked.dataCenter || dataCenter;
|
||||||
item = picked.item;
|
item = picked.item;
|
||||||
@ -216,9 +223,12 @@ export class QqbotCommandParserService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private pickTrailingFf14Location(positional: string[]) {
|
private pickTrailingFf14Location(
|
||||||
|
catalog: QqbotFf14MarketCatalog,
|
||||||
|
positional: string[],
|
||||||
|
) {
|
||||||
const last = positional[positional.length - 1];
|
const last = positional[positional.length - 1];
|
||||||
if (!isQqbotFf14LocationName(last)) return null;
|
if (!isQqbotFf14LocationName(catalog, last)) return null;
|
||||||
|
|
||||||
const path = splitQqbotFf14WorldPath(last);
|
const path = splitQqbotFf14WorldPath(last);
|
||||||
if (path.dataCenter && path.world) {
|
if (path.dataCenter && path.world) {
|
||||||
@ -234,10 +244,11 @@ export class QqbotCommandParserService {
|
|||||||
const beforePrevious = positional[positional.length - 3];
|
const beforePrevious = positional[positional.length - 3];
|
||||||
if (
|
if (
|
||||||
previous &&
|
previous &&
|
||||||
isQqbotFf14DataCenterName(previous) &&
|
isQqbotFf14DataCenterName(catalog, previous) &&
|
||||||
isQqbotFf14WorldName(last)
|
isQqbotFf14WorldName(catalog, last)
|
||||||
) {
|
) {
|
||||||
const hasRegion = beforePrevious && isQqbotFf14RegionName(beforePrevious);
|
const hasRegion =
|
||||||
|
beforePrevious && isQqbotFf14RegionName(catalog, beforePrevious);
|
||||||
return {
|
return {
|
||||||
dataCenter: previous,
|
dataCenter: previous,
|
||||||
item: positional.slice(0, hasRegion ? -3 : -2).join(' '),
|
item: positional.slice(0, hasRegion ? -3 : -2).join(' '),
|
||||||
@ -248,8 +259,8 @@ export class QqbotCommandParserService {
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
previous &&
|
previous &&
|
||||||
isQqbotFf14RegionName(previous) &&
|
isQqbotFf14RegionName(catalog, previous) &&
|
||||||
isQqbotFf14DataCenterName(last)
|
isQqbotFf14DataCenterName(catalog, last)
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
dataCenter: last,
|
dataCenter: last,
|
||||||
@ -264,6 +275,21 @@ export class QqbotCommandParserService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getFf14MarketCatalog() {
|
||||||
|
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) {
|
private normalizeHq(value?: string | true) {
|
||||||
if (value === undefined) return undefined;
|
if (value === undefined) return undefined;
|
||||||
if (value === true) return true;
|
if (value === true) return true;
|
||||||
|
|||||||
@ -2,7 +2,12 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
import { resolveQqbotFf14MarketTarget } from './qqbot-ff14-worlds';
|
import { DictService } from '../../../admin/dict/dict.service';
|
||||||
|
import {
|
||||||
|
buildQqbotFf14MarketCatalog,
|
||||||
|
QQBOT_FF14_MARKET_DICT_CODES,
|
||||||
|
resolveQqbotFf14MarketTarget,
|
||||||
|
} from './qqbot-ff14-worlds';
|
||||||
|
|
||||||
type HttpMethod = 'GET';
|
type HttpMethod = 'GET';
|
||||||
|
|
||||||
@ -67,7 +72,10 @@ export class QqbotFf14ClientService {
|
|||||||
private readonly xivapiChsBaseUrl: string;
|
private readonly xivapiChsBaseUrl: string;
|
||||||
private readonly universalisBaseUrl: string;
|
private readonly universalisBaseUrl: string;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly dictService: DictService,
|
||||||
|
) {
|
||||||
this.xivapiBaseUrl =
|
this.xivapiBaseUrl =
|
||||||
this.configService.get<string>('FF14_XIVAPI_BASE_URL') ||
|
this.configService.get<string>('FF14_XIVAPI_BASE_URL') ||
|
||||||
'https://v2.xivapi.com/api';
|
'https://v2.xivapi.com/api';
|
||||||
@ -114,7 +122,7 @@ export class QqbotFf14ClientService {
|
|||||||
region?: string;
|
region?: string;
|
||||||
world?: string;
|
world?: string;
|
||||||
}): Promise<QqbotFf14PriceResult> {
|
}): Promise<QqbotFf14PriceResult> {
|
||||||
const marketTarget = this.resolveMarketTarget(params);
|
const marketTarget = await this.resolveMarketTarget(params);
|
||||||
const item = await this.resolveItem(params);
|
const item = await this.resolveItem(params);
|
||||||
if (item.isUntradable) {
|
if (item.isUntradable) {
|
||||||
return {
|
return {
|
||||||
@ -127,9 +135,9 @@ export class QqbotFf14ClientService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(
|
const url = new URL(
|
||||||
`${this.universalisBaseUrl}/${encodeURIComponent(
|
`${this.universalisBaseUrl}/${encodeURIComponent(marketTarget.target)}/${
|
||||||
marketTarget.target,
|
item.itemId
|
||||||
)}/${item.itemId}`,
|
}`,
|
||||||
);
|
);
|
||||||
url.searchParams.set('entries', '10');
|
url.searchParams.set('entries', '10');
|
||||||
url.searchParams.set('listings', '10');
|
url.searchParams.set('listings', '10');
|
||||||
@ -211,7 +219,9 @@ export class QqbotFf14ClientService {
|
|||||||
const total = item.total || price * quantity;
|
const total = item.total || price * quantity;
|
||||||
const retainerName = item.retainerName || '未知雇员';
|
const retainerName = item.retainerName || '未知雇员';
|
||||||
const worldName = item.worldName || result.world;
|
const worldName = item.worldName || result.world;
|
||||||
return `[${hq}]${this.formatPrice(price)} x ${quantity} = ${this.formatPrice(
|
return `[${hq}]${this.formatPrice(
|
||||||
|
price,
|
||||||
|
)} x ${quantity} = ${this.formatPrice(
|
||||||
total,
|
total,
|
||||||
)} ${retainerName} (${worldName})`;
|
)} ${retainerName} (${worldName})`;
|
||||||
})
|
})
|
||||||
@ -255,25 +265,45 @@ export class QqbotFf14ClientService {
|
|||||||
return Math.round(value).toLocaleString('en-US');
|
return Math.round(value).toLocaleString('en-US');
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeWorld(world?: string) {
|
private normalizeWorld(world?: string, fallback?: string) {
|
||||||
const raw =
|
const raw = `${
|
||||||
`${world || this.configService.get<string>('FF14_DEFAULT_WORLD') || '中国'}`.trim();
|
world ||
|
||||||
|
this.configService.get<string>('FF14_DEFAULT_WORLD') ||
|
||||||
|
fallback ||
|
||||||
|
''
|
||||||
|
}`.trim();
|
||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveMarketTarget(params: {
|
private async resolveMarketTarget(params: {
|
||||||
dataCenter?: string;
|
dataCenter?: string;
|
||||||
region?: string;
|
region?: string;
|
||||||
world?: string;
|
world?: string;
|
||||||
}) {
|
}) {
|
||||||
return resolveQqbotFf14MarketTarget({
|
const catalog = await this.getFf14MarketCatalog();
|
||||||
|
return resolveQqbotFf14MarketTarget(catalog, {
|
||||||
dataCenter: params.dataCenter,
|
dataCenter: params.dataCenter,
|
||||||
fallback: this.normalizeWorld(params.world),
|
fallback: this.normalizeWorld(params.world, catalog.defaultRegion),
|
||||||
region: params.region,
|
region: params.region,
|
||||||
world: params.world,
|
world: params.world,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getFf14MarketCatalog() {
|
||||||
|
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 normalizeXivapiLanguage(language?: string) {
|
private normalizeXivapiLanguage(language?: string) {
|
||||||
const value = `${language || 'chs'}`.trim().toLowerCase();
|
const value = `${language || 'chs'}`.trim().toLowerCase();
|
||||||
if (['zh', 'zh-cn', 'zh_hans', 'cn', 'chs'].includes(value)) return 'chs';
|
if (['zh', 'zh-cn', 'zh_hans', 'cn', 'chs'].includes(value)) return 'chs';
|
||||||
@ -328,8 +358,7 @@ export class QqbotFf14ClientService {
|
|||||||
'XIVAPI 物品解析',
|
'XIVAPI 物品解析',
|
||||||
);
|
);
|
||||||
return (data.results || []).filter(
|
return (data.results || []).filter(
|
||||||
(result) =>
|
(result) => result.sheet === 'Item' || result.fields?.Name || result.name,
|
||||||
result.sheet === 'Item' || result.fields?.Name || result.name,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -400,9 +429,7 @@ export class QqbotFf14ClientService {
|
|||||||
});
|
});
|
||||||
response.on('end', () => {
|
response.on('end', () => {
|
||||||
if ((response.statusCode || 500) >= 400) {
|
if ((response.statusCode || 500) >= 400) {
|
||||||
reject(
|
reject(new Error(`${context}失败:${response.statusCode}`));
|
||||||
new Error(`${context}失败:${response.statusCode}`),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
|||||||
37
src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts
Normal file
37
src/qqbot/plugins/ff14Market/qqbot-ff14-worlds.spec.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import {
|
||||||
|
buildQqbotFf14MarketCatalog,
|
||||||
|
resolveQqbotFf14MarketTarget,
|
||||||
|
} from './qqbot-ff14-worlds';
|
||||||
|
|
||||||
|
describe('qqbot ff14 market worlds', () => {
|
||||||
|
const catalog = buildQqbotFf14MarketCatalog({
|
||||||
|
dataCenters: [
|
||||||
|
{ childrenCode: '中国', label: '猫小胖', value: '猫小胖' },
|
||||||
|
{ childrenCode: '中国', label: '陆行鸟', value: '陆行鸟' },
|
||||||
|
],
|
||||||
|
regions: [{ label: '中国', value: '中国' }],
|
||||||
|
worlds: [
|
||||||
|
{ childrenCode: '猫小胖', label: '琥珀原', value: '琥珀原' },
|
||||||
|
{ childrenCode: '陆行鸟', label: '红玉海', value: '红玉海' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves world labels from dict catalog', () => {
|
||||||
|
expect(resolveQqbotFf14MarketTarget(catalog, { world: '琥珀原' })).toEqual({
|
||||||
|
dataCenter: '猫小胖',
|
||||||
|
label: '中国 / 猫小胖 / 琥珀原',
|
||||||
|
region: '中国',
|
||||||
|
target: '琥珀原',
|
||||||
|
world: '琥珀原',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects world and data center mismatch from dict catalog', () => {
|
||||||
|
expect(() =>
|
||||||
|
resolveQqbotFf14MarketTarget(catalog, {
|
||||||
|
dataCenter: '陆行鸟',
|
||||||
|
world: '琥珀原',
|
||||||
|
}),
|
||||||
|
).toThrow('服务器 琥珀原 不属于大区 陆行鸟');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,9 +1,17 @@
|
|||||||
|
import type { AdminDictItem } from '../../../admin/dict/dict.service';
|
||||||
|
|
||||||
export type QqbotFf14DataCenter = {
|
export type QqbotFf14DataCenter = {
|
||||||
name: string;
|
name: string;
|
||||||
region: string;
|
region: string;
|
||||||
worlds: string[];
|
worlds: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type QqbotFf14MarketCatalog = {
|
||||||
|
dataCenters: QqbotFf14DataCenter[];
|
||||||
|
defaultRegion?: string;
|
||||||
|
regions: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export type QqbotFf14MarketTarget = {
|
export type QqbotFf14MarketTarget = {
|
||||||
dataCenter?: string;
|
dataCenter?: string;
|
||||||
label: string;
|
label: string;
|
||||||
@ -12,80 +20,76 @@ export type QqbotFf14MarketTarget = {
|
|||||||
world?: string;
|
world?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const QQBOT_FF14_DEFAULT_REGION = '中国';
|
export const QQBOT_FF14_MARKET_DICT_CODES = {
|
||||||
|
dataCenter: 'FF14_MARKET_DATA_CENTER',
|
||||||
|
region: 'FF14_MARKET_REGION',
|
||||||
|
world: 'FF14_MARKET_WORLD',
|
||||||
|
};
|
||||||
|
|
||||||
export const QQBOT_FF14_CHINA_DATA_CENTERS: QqbotFf14DataCenter[] = [
|
export function buildQqbotFf14MarketCatalog(input: {
|
||||||
{
|
dataCenters: AdminDictItem[];
|
||||||
name: '陆行鸟',
|
regions: AdminDictItem[];
|
||||||
region: QQBOT_FF14_DEFAULT_REGION,
|
worlds: AdminDictItem[];
|
||||||
worlds: [
|
}): QqbotFf14MarketCatalog {
|
||||||
'红玉海',
|
const regions = input.regions.map(getDictDisplayValue).filter(Boolean);
|
||||||
'神意之地',
|
const defaultRegion = regions[0];
|
||||||
'拉诺西亚',
|
const dataCenters = input.dataCenters
|
||||||
'幻影群岛',
|
.map((item) => {
|
||||||
'萌芽池',
|
const name = getDictDisplayValue(item);
|
||||||
'宇宙和音',
|
if (!name) return null;
|
||||||
'沃仙曦染',
|
return {
|
||||||
'晨曦王座',
|
name,
|
||||||
],
|
region:
|
||||||
},
|
normalizeQqbotFf14WorldValue(item.childrenCode) || defaultRegion,
|
||||||
{
|
worlds: input.worlds
|
||||||
name: '莫古力',
|
.filter(({ childrenCode }) => childrenCode === getDictRawValue(item))
|
||||||
region: QQBOT_FF14_DEFAULT_REGION,
|
.map(getDictDisplayValue)
|
||||||
worlds: [
|
.filter(Boolean),
|
||||||
'白银乡',
|
};
|
||||||
'白金幻象',
|
})
|
||||||
'神拳痕',
|
.filter((item): item is QqbotFf14DataCenter => !!item);
|
||||||
'潮风亭',
|
|
||||||
'旅人栈桥',
|
|
||||||
'拂晓之间',
|
|
||||||
'龙巢神殿',
|
|
||||||
'梦羽宝境',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '猫小胖',
|
|
||||||
region: QQBOT_FF14_DEFAULT_REGION,
|
|
||||||
worlds: [
|
|
||||||
'紫水栈桥',
|
|
||||||
'延夏',
|
|
||||||
'静语庄园',
|
|
||||||
'摩杜纳',
|
|
||||||
'海猫茶屋',
|
|
||||||
'柔风海湾',
|
|
||||||
'琥珀原',
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '豆豆柴',
|
|
||||||
region: QQBOT_FF14_DEFAULT_REGION,
|
|
||||||
worlds: ['水晶塔', '银泪湖', '太阳海岸', '伊修加德', '红茶川'],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function isQqbotFf14DataCenterName(value?: string) {
|
return {
|
||||||
|
dataCenters,
|
||||||
|
defaultRegion,
|
||||||
|
regions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isQqbotFf14DataCenterName(
|
||||||
|
catalog: QqbotFf14MarketCatalog,
|
||||||
|
value?: string,
|
||||||
|
) {
|
||||||
const name = normalizeQqbotFf14WorldValue(value);
|
const name = normalizeQqbotFf14WorldValue(value);
|
||||||
return QQBOT_FF14_CHINA_DATA_CENTERS.some((item) => item.name === name);
|
return catalog.dataCenters.some((item) => item.name === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isQqbotFf14RegionName(value?: string) {
|
export function isQqbotFf14RegionName(
|
||||||
return normalizeQqbotFf14WorldValue(value) === QQBOT_FF14_DEFAULT_REGION;
|
catalog: QqbotFf14MarketCatalog,
|
||||||
}
|
value?: string,
|
||||||
|
) {
|
||||||
export function isQqbotFf14WorldName(value?: string) {
|
|
||||||
const name = normalizeQqbotFf14WorldValue(value);
|
const name = normalizeQqbotFf14WorldValue(value);
|
||||||
return QQBOT_FF14_CHINA_DATA_CENTERS.some((item) =>
|
return catalog.regions.includes(name);
|
||||||
item.worlds.includes(name),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isQqbotFf14LocationName(value?: string) {
|
export function isQqbotFf14WorldName(
|
||||||
|
catalog: QqbotFf14MarketCatalog,
|
||||||
|
value?: string,
|
||||||
|
) {
|
||||||
|
const name = normalizeQqbotFf14WorldValue(value);
|
||||||
|
return catalog.dataCenters.some((item) => item.worlds.includes(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isQqbotFf14LocationName(
|
||||||
|
catalog: QqbotFf14MarketCatalog,
|
||||||
|
value?: string,
|
||||||
|
) {
|
||||||
const name = normalizeQqbotFf14WorldValue(value);
|
const name = normalizeQqbotFf14WorldValue(value);
|
||||||
const path = splitQqbotFf14WorldPath(name);
|
const path = splitQqbotFf14WorldPath(name);
|
||||||
return (
|
return (
|
||||||
isQqbotFf14RegionName(name) ||
|
isQqbotFf14RegionName(catalog, name) ||
|
||||||
isQqbotFf14DataCenterName(name) ||
|
isQqbotFf14DataCenterName(catalog, name) ||
|
||||||
isQqbotFf14WorldName(name) ||
|
isQqbotFf14WorldName(catalog, name) ||
|
||||||
(!!path.dataCenter && !!path.world)
|
(!!path.dataCenter && !!path.world)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -114,19 +118,24 @@ export function splitQqbotFf14WorldPath(value?: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findQqbotFf14DataCenterByWorld(world?: string) {
|
export function findQqbotFf14DataCenterByWorld(
|
||||||
|
catalog: QqbotFf14MarketCatalog,
|
||||||
|
world?: string,
|
||||||
|
) {
|
||||||
const worldName = normalizeQqbotFf14WorldValue(world);
|
const worldName = normalizeQqbotFf14WorldValue(world);
|
||||||
return QQBOT_FF14_CHINA_DATA_CENTERS.find((item) =>
|
return catalog.dataCenters.find((item) => item.worlds.includes(worldName));
|
||||||
item.worlds.includes(worldName),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveQqbotFf14MarketTarget(params: {
|
export function resolveQqbotFf14MarketTarget(
|
||||||
dataCenter?: string;
|
catalog: QqbotFf14MarketCatalog,
|
||||||
fallback?: string;
|
params: {
|
||||||
region?: string;
|
dataCenter?: string;
|
||||||
world?: string;
|
fallback?: string;
|
||||||
}): QqbotFf14MarketTarget {
|
region?: string;
|
||||||
|
world?: string;
|
||||||
|
},
|
||||||
|
): QqbotFf14MarketTarget {
|
||||||
|
const defaultRegion = catalog.defaultRegion || '';
|
||||||
const fallback = normalizeQqbotFf14WorldValue(params.fallback);
|
const fallback = normalizeQqbotFf14WorldValue(params.fallback);
|
||||||
const path = splitQqbotFf14WorldPath(params.world);
|
const path = splitQqbotFf14WorldPath(params.world);
|
||||||
const region = normalizeQqbotFf14WorldValue(params.region || path.region);
|
const region = normalizeQqbotFf14WorldValue(params.region || path.region);
|
||||||
@ -134,10 +143,8 @@ export function resolveQqbotFf14MarketTarget(params: {
|
|||||||
params.dataCenter || path.dataCenter,
|
params.dataCenter || path.dataCenter,
|
||||||
);
|
);
|
||||||
const rawWorld = normalizeQqbotFf14WorldValue(path.world || params.world);
|
const rawWorld = normalizeQqbotFf14WorldValue(path.world || params.world);
|
||||||
const world =
|
const world = dataCenter && rawWorld === defaultRegion ? '' : rawWorld;
|
||||||
dataCenter && rawWorld === QQBOT_FF14_DEFAULT_REGION ? '' : rawWorld;
|
const raw = world || dataCenter || region || fallback || defaultRegion;
|
||||||
const raw =
|
|
||||||
world || dataCenter || region || fallback || QQBOT_FF14_DEFAULT_REGION;
|
|
||||||
|
|
||||||
if (region && dataCenter && (!world || world === region)) {
|
if (region && dataCenter && (!world || world === region)) {
|
||||||
return {
|
return {
|
||||||
@ -148,16 +155,16 @@ export function resolveQqbotFf14MarketTarget(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raw === QQBOT_FF14_DEFAULT_REGION) {
|
if (raw && raw === defaultRegion) {
|
||||||
return {
|
return {
|
||||||
label: QQBOT_FF14_DEFAULT_REGION,
|
label: defaultRegion,
|
||||||
region: QQBOT_FF14_DEFAULT_REGION,
|
region: defaultRegion,
|
||||||
target: QQBOT_FF14_DEFAULT_REGION,
|
target: defaultRegion,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dataCenter && world && world !== dataCenter) {
|
if (dataCenter && world && world !== dataCenter) {
|
||||||
const matchedDataCenter = QQBOT_FF14_CHINA_DATA_CENTERS.find(
|
const matchedDataCenter = catalog.dataCenters.find(
|
||||||
(item) => item.name === dataCenter,
|
(item) => item.name === dataCenter,
|
||||||
);
|
);
|
||||||
if (matchedDataCenter && !matchedDataCenter.worlds.includes(world)) {
|
if (matchedDataCenter && !matchedDataCenter.worlds.includes(world)) {
|
||||||
@ -174,7 +181,7 @@ export function resolveQqbotFf14MarketTarget(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const matchedWorldDataCenter = findQqbotFf14DataCenterByWorld(raw);
|
const matchedWorldDataCenter = findQqbotFf14DataCenterByWorld(catalog, raw);
|
||||||
if (matchedWorldDataCenter) {
|
if (matchedWorldDataCenter) {
|
||||||
return {
|
return {
|
||||||
dataCenter: matchedWorldDataCenter.name,
|
dataCenter: matchedWorldDataCenter.name,
|
||||||
@ -185,11 +192,11 @@ export function resolveQqbotFf14MarketTarget(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isQqbotFf14DataCenterName(raw)) {
|
if (isQqbotFf14DataCenterName(catalog, raw)) {
|
||||||
return {
|
return {
|
||||||
dataCenter: raw,
|
dataCenter: raw,
|
||||||
label: `${QQBOT_FF14_DEFAULT_REGION} / ${raw}`,
|
label: defaultRegion ? `${defaultRegion} / ${raw}` : raw,
|
||||||
region: QQBOT_FF14_DEFAULT_REGION,
|
region: defaultRegion,
|
||||||
target: raw,
|
target: raw,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -200,6 +207,14 @@ export function resolveQqbotFf14MarketTarget(params: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeQqbotFf14WorldValue(value?: string) {
|
function getDictDisplayValue(item: AdminDictItem) {
|
||||||
|
return normalizeQqbotFf14WorldValue(item.label || item.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDictRawValue(item: AdminDictItem) {
|
||||||
|
return normalizeQqbotFf14WorldValue(item.value || item.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeQqbotFf14WorldValue(value?: string | null) {
|
||||||
return `${value || ''}`.trim();
|
return `${value || ''}`.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
69
src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts
Normal file
69
src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
jest.mock(
|
||||||
|
'@/common',
|
||||||
|
() => ({
|
||||||
|
ensureSnowflakeId: jest.fn(),
|
||||||
|
setDictDecodeCache: jest.fn(),
|
||||||
|
}),
|
||||||
|
{ virtual: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { DictService } from '../../../admin/dict/dict.service';
|
||||||
|
import { QqbotFflogsClientService } from './qqbot-fflogs-client.service';
|
||||||
|
|
||||||
|
describe('QqbotFflogsClientService', () => {
|
||||||
|
const dicts = {
|
||||||
|
FFLOGS_ENCOUNTER_LABEL: [
|
||||||
|
{ label: 'M9S Vamp Fatale', value: 'vampfatale' },
|
||||||
|
{ label: 'M12S P2 Lindwurm II', value: 'lindwurmii' },
|
||||||
|
],
|
||||||
|
FFLOGS_JOB_LABEL: [{ label: '机工士', value: 'machinist' }],
|
||||||
|
FFLOGS_METRIC_LABEL: [{ label: 'DPS', value: 'dps' }],
|
||||||
|
FFLOGS_ROLE_LABEL: [],
|
||||||
|
FFLOGS_SERVER_REGION_LABEL: [{ label: '国服', value: 'cn' }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const service = new QqbotFflogsClientService(
|
||||||
|
{
|
||||||
|
get: jest.fn(),
|
||||||
|
} as unknown as ConfigService,
|
||||||
|
{
|
||||||
|
getDictByKey: jest.fn(
|
||||||
|
async (dictKey: keyof typeof dicts) => dicts[dictKey] || [],
|
||||||
|
),
|
||||||
|
} as unknown as DictService,
|
||||||
|
);
|
||||||
|
|
||||||
|
it('formats character rankings with dict labels', async () => {
|
||||||
|
const localizationMaps = await (service as any).getLocalizationMaps();
|
||||||
|
const replyText = (service as any).buildReplyText({
|
||||||
|
allStarText: '全明星:318分 / 排名第1857',
|
||||||
|
characterId: 20962075,
|
||||||
|
characterName: 'Kwi柊司',
|
||||||
|
localizationMaps,
|
||||||
|
metric: 'DPS',
|
||||||
|
rankings: [
|
||||||
|
{
|
||||||
|
bestAmount: 36635,
|
||||||
|
encounter: { name: 'Vamp Fatale' },
|
||||||
|
rankPercent: 72.4,
|
||||||
|
spec: 'Machinist',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
bestAmount: 0,
|
||||||
|
encounter: { name: 'Lindwurm II' },
|
||||||
|
rankPercent: 0,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
serverName: '琥珀原',
|
||||||
|
serverRegion: 'CN',
|
||||||
|
url: 'https://cn.fflogs.com/character/cn/example/Kwi',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replyText).toContain('FFLogs 战绩:Kwi柊司 @ 琥珀原(国服)');
|
||||||
|
expect(replyText).toContain(
|
||||||
|
'1. M9S Vamp Fatale:72.4% | DPS 36,635 | 机工士',
|
||||||
|
);
|
||||||
|
expect(replyText).toContain('2. M12S P2 Lindwurm II:暂无有效排名');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import * as https from 'node:https';
|
import * as https from 'node:https';
|
||||||
|
import { DictService } from '../../../admin/dict/dict.service';
|
||||||
|
|
||||||
type HttpMethod = 'GET' | 'POST';
|
type HttpMethod = 'GET' | 'POST';
|
||||||
|
|
||||||
@ -35,6 +36,19 @@ type FflogsCharacterSummaryResponse = {
|
|||||||
|
|
||||||
type FflogsRankingItem = Record<string, any>;
|
type FflogsRankingItem = Record<string, any>;
|
||||||
|
|
||||||
|
const FFLOGS_LOCALIZATION_DICT_CODES = {
|
||||||
|
encounter: 'FFLOGS_ENCOUNTER_LABEL',
|
||||||
|
job: 'FFLOGS_JOB_LABEL',
|
||||||
|
metric: 'FFLOGS_METRIC_LABEL',
|
||||||
|
role: 'FFLOGS_ROLE_LABEL',
|
||||||
|
serverRegion: 'FFLOGS_SERVER_REGION_LABEL',
|
||||||
|
};
|
||||||
|
|
||||||
|
type FflogsLocalizationMaps = Record<
|
||||||
|
keyof typeof FFLOGS_LOCALIZATION_DICT_CODES,
|
||||||
|
Map<string, string>
|
||||||
|
>;
|
||||||
|
|
||||||
export type QqbotFflogsCharacterSummaryInput = {
|
export type QqbotFflogsCharacterSummaryInput = {
|
||||||
character?: string;
|
character?: string;
|
||||||
characterName?: string;
|
characterName?: string;
|
||||||
@ -74,7 +88,10 @@ export class QqbotFflogsClientService {
|
|||||||
private readonly tokenUrl: string;
|
private readonly tokenUrl: string;
|
||||||
private readonly webBaseUrl: string;
|
private readonly webBaseUrl: string;
|
||||||
|
|
||||||
constructor(private readonly configService: ConfigService) {
|
constructor(
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly dictService: DictService,
|
||||||
|
) {
|
||||||
this.baseUrl = this.normalizeBaseUrl(
|
this.baseUrl = this.normalizeBaseUrl(
|
||||||
this.configService.get<string>('FFLOGS_BASE_URL') ||
|
this.configService.get<string>('FFLOGS_BASE_URL') ||
|
||||||
'https://www.fflogs.com',
|
'https://www.fflogs.com',
|
||||||
@ -196,6 +213,7 @@ export class QqbotFflogsClientService {
|
|||||||
character.server?.slug || serverSlug,
|
character.server?.slug || serverSlug,
|
||||||
character.name || characterName,
|
character.name || characterName,
|
||||||
);
|
);
|
||||||
|
const localizationMaps = await this.getLocalizationMaps();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
allStarText,
|
allStarText,
|
||||||
@ -206,7 +224,9 @@ export class QqbotFflogsClientService {
|
|||||||
allStarText,
|
allStarText,
|
||||||
characterId: character.id,
|
characterId: character.id,
|
||||||
characterName: character.name || characterName,
|
characterName: character.name || characterName,
|
||||||
|
metric: variables.metric || 'DPS',
|
||||||
rankings,
|
rankings,
|
||||||
|
localizationMaps,
|
||||||
serverName,
|
serverName,
|
||||||
serverRegion,
|
serverRegion,
|
||||||
url,
|
url,
|
||||||
@ -327,29 +347,51 @@ export class QqbotFflogsClientService {
|
|||||||
allStarText?: string;
|
allStarText?: string;
|
||||||
characterId?: number;
|
characterId?: number;
|
||||||
characterName: string;
|
characterName: string;
|
||||||
|
localizationMaps: FflogsLocalizationMaps;
|
||||||
|
metric: string;
|
||||||
rankings: FflogsRankingItem[];
|
rankings: FflogsRankingItem[];
|
||||||
serverName: string;
|
serverName: string;
|
||||||
serverRegion: string;
|
serverRegion: string;
|
||||||
url: string;
|
url: string;
|
||||||
}) {
|
}) {
|
||||||
const header = `FFLogs:${params.characterName} - ${params.serverName} (${params.serverRegion})`;
|
const region = this.localizeServerRegion(
|
||||||
const idText = params.characterId ? `角色 ID:${params.characterId}` : '';
|
params.serverRegion,
|
||||||
|
params.localizationMaps,
|
||||||
|
);
|
||||||
|
const header = `FFLogs 战绩:${params.characterName} @ ${params.serverName}(${region})`;
|
||||||
|
const idText = params.characterId ? `角色ID:${params.characterId}` : '';
|
||||||
const rankingText = params.rankings.length
|
const rankingText = params.rankings.length
|
||||||
? params.rankings
|
? [
|
||||||
.map((item, index) => this.formatRanking(item, index))
|
'公开排名:',
|
||||||
.join('\n')
|
...params.rankings.map((item, index) =>
|
||||||
: '暂无公开排名数据';
|
this.formatRanking(
|
||||||
|
item,
|
||||||
|
index,
|
||||||
|
params.metric,
|
||||||
|
params.localizationMaps,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].join('\n')
|
||||||
|
: '公开排名:暂无公开排名数据';
|
||||||
return [header, idText, params.allStarText, rankingText, params.url]
|
return [header, idText, params.allStarText, rankingText, params.url]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n');
|
.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatRanking(item: FflogsRankingItem, index: number) {
|
private formatRanking(
|
||||||
const encounter = this.pickText(
|
item: FflogsRankingItem,
|
||||||
item.encounter?.name,
|
index: number,
|
||||||
item.encounterName,
|
fallbackMetric: string,
|
||||||
item.name,
|
localizationMaps: FflogsLocalizationMaps,
|
||||||
`记录 ${index + 1}`,
|
) {
|
||||||
|
const encounter = this.localizeEncounter(
|
||||||
|
this.pickText(
|
||||||
|
item.encounter?.name,
|
||||||
|
item.encounterName,
|
||||||
|
item.name,
|
||||||
|
`记录 ${index + 1}`,
|
||||||
|
),
|
||||||
|
localizationMaps,
|
||||||
);
|
);
|
||||||
const percent = this.pickNumber(
|
const percent = this.pickNumber(
|
||||||
item.rankPercent,
|
item.rankPercent,
|
||||||
@ -358,16 +400,29 @@ export class QqbotFflogsClientService {
|
|||||||
item.historicalPercent,
|
item.historicalPercent,
|
||||||
);
|
);
|
||||||
const amount = this.pickNumber(item.bestAmount, item.amount, item.total);
|
const amount = this.pickNumber(item.bestAmount, item.amount, item.total);
|
||||||
const spec = this.pickText(item.spec, item.specName, item.class, item.role);
|
const spec = this.localizeSpec(
|
||||||
|
this.pickText(item.spec, item.specName, item.class, item.role),
|
||||||
|
localizationMaps,
|
||||||
|
);
|
||||||
const rank = this.pickText(item.rank, item.regionRank, item.serverRank);
|
const rank = this.pickText(item.rank, item.regionRank, item.serverRank);
|
||||||
|
|
||||||
|
if (!this.hasMeaningfulRanking(percent, amount)) {
|
||||||
|
return `${index + 1}. ${encounter}:暂无有效排名`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metric = this.localizeMetric(
|
||||||
|
this.pickText(item.metric, item.metricName, fallbackMetric),
|
||||||
|
localizationMaps,
|
||||||
|
);
|
||||||
const parts = [
|
const parts = [
|
||||||
`${index + 1}. ${encounter}`,
|
`${index + 1}. ${encounter}:${
|
||||||
percent !== undefined ? `${this.formatNumber(percent)}%` : '',
|
percent !== undefined ? `${this.formatNumber(percent)}%` : '百分位暂无'
|
||||||
amount !== undefined ? this.formatNumber(amount) : '',
|
}`,
|
||||||
|
amount !== undefined ? `${metric} ${this.formatNumber(amount)}` : '',
|
||||||
spec,
|
spec,
|
||||||
rank ? `Rank ${rank}` : '',
|
rank ? this.formatRank(rank) : '',
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return parts.join(' / ');
|
return parts.join(' | ');
|
||||||
}
|
}
|
||||||
|
|
||||||
private pickRankings(payload: any): FflogsRankingItem[] {
|
private pickRankings(payload: any): FflogsRankingItem[] {
|
||||||
@ -399,8 +454,8 @@ export class QqbotFflogsClientService {
|
|||||||
allStars.serverRank,
|
allStars.serverRank,
|
||||||
);
|
);
|
||||||
const parts = [
|
const parts = [
|
||||||
points !== undefined ? `全明星分:${this.formatNumber(points)}` : '',
|
points !== undefined ? `全明星:${this.formatNumber(points)}分` : '',
|
||||||
rank ? `名次:${rank}` : '',
|
rank ? this.formatRank(rank) : '',
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return parts.length ? parts.join(' / ') : undefined;
|
return parts.length ? parts.join(' / ') : undefined;
|
||||||
}
|
}
|
||||||
@ -495,12 +550,98 @@ export class QqbotFflogsClientService {
|
|||||||
|
|
||||||
private formatNumber(value: number) {
|
private formatNumber(value: number) {
|
||||||
const digits = Math.abs(value) >= 100 ? 0 : 1;
|
const digits = Math.abs(value) >= 100 ? 0 : 1;
|
||||||
return value.toLocaleString('en-US', {
|
return value.toLocaleString('zh-CN', {
|
||||||
maximumFractionDigits: digits,
|
maximumFractionDigits: digits,
|
||||||
minimumFractionDigits: 0,
|
minimumFractionDigits: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private formatRank(value: string) {
|
||||||
|
const rank = value.replace(/^#/, '').trim();
|
||||||
|
if (!rank) return '';
|
||||||
|
if (rank.startsWith('第') || rank.endsWith('名')) return `排名${rank}`;
|
||||||
|
return `排名第${rank}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private hasMeaningfulRanking(percent?: number, amount?: number) {
|
||||||
|
return (
|
||||||
|
(percent !== undefined && percent > 0) ||
|
||||||
|
(amount !== undefined && amount > 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getLocalizationMaps(): Promise<FflogsLocalizationMaps> {
|
||||||
|
const [encounter, job, metric, role, serverRegion] = await Promise.all([
|
||||||
|
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.encounter),
|
||||||
|
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.job),
|
||||||
|
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.metric),
|
||||||
|
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.role),
|
||||||
|
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.serverRegion),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
encounter,
|
||||||
|
job,
|
||||||
|
metric,
|
||||||
|
role,
|
||||||
|
serverRegion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getNormalizedDictMap(dictCode: string) {
|
||||||
|
const dicts = await this.dictService.getDictByKey(dictCode);
|
||||||
|
return new Map(
|
||||||
|
dicts.map(({ label, value }) => [
|
||||||
|
this.normalizeLookupKey(`${value}`),
|
||||||
|
`${label}`,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private localizeEncounter(
|
||||||
|
value: string,
|
||||||
|
localizationMaps: FflogsLocalizationMaps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
localizationMaps.encounter.get(this.normalizeLookupKey(value)) || value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private localizeMetric(
|
||||||
|
value: string,
|
||||||
|
localizationMaps: FflogsLocalizationMaps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
localizationMaps.metric.get(this.normalizeLookupKey(value)) ||
|
||||||
|
value ||
|
||||||
|
'DPS'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private localizeServerRegion(
|
||||||
|
value: string,
|
||||||
|
localizationMaps: FflogsLocalizationMaps,
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
localizationMaps.serverRegion.get(this.normalizeLookupKey(value)) ||
|
||||||
|
value.toUpperCase()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private localizeSpec(
|
||||||
|
value: string,
|
||||||
|
localizationMaps: FflogsLocalizationMaps,
|
||||||
|
) {
|
||||||
|
const key = this.normalizeLookupKey(value);
|
||||||
|
return (
|
||||||
|
localizationMaps.job.get(key) || localizationMaps.role.get(key) || value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeLookupKey(value: string) {
|
||||||
|
return `${value || ''}`.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
private removeUndefined(input: Record<string, any>) {
|
private removeUndefined(input: Record<string, any>) {
|
||||||
return Object.entries(input).reduce<Record<string, any>>(
|
return Object.entries(input).reduce<Record<string, any>>(
|
||||||
(result, [key, value]) => {
|
(result, [key, value]) => {
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module';
|
import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module';
|
||||||
|
import { DictModule } from '@/admin/dict/dict.module';
|
||||||
import { QqbotAccountController } from './account/qqbot-account.controller';
|
import { QqbotAccountController } from './account/qqbot-account.controller';
|
||||||
import { QqbotAccountAbility } from './account/qqbot-account-ability.entity';
|
import { QqbotAccountAbility } from './account/qqbot-account-ability.entity';
|
||||||
import { QqbotAccount } from './account/qqbot-account.entity';
|
import { QqbotAccount } from './account/qqbot-account.entity';
|
||||||
@ -55,6 +56,7 @@ import { QqbotSendService } from './send/qqbot-send.service';
|
|||||||
imports: [
|
imports: [
|
||||||
ConfigModule,
|
ConfigModule,
|
||||||
AdminAuthGuardModule,
|
AdminAuthGuardModule,
|
||||||
|
DictModule,
|
||||||
TypeOrmModule.forFeature([
|
TypeOrmModule.forFeature([
|
||||||
QqbotAccount,
|
QqbotAccount,
|
||||||
QqbotAccountAbility,
|
QqbotAccountAbility,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user