fix: 修正 QQBot FFLogs 国服查询

This commit is contained in:
sunlei 2026-06-04 19:58:02 +08:00
parent a85db5e045
commit b1249517e8
9 changed files with 523 additions and 221 deletions

View File

@ -84,10 +84,10 @@ FF14_UNIVERSALIS_BASE_URL=https://universalis.app/api/v2
FF14_DEFAULT_WORLD=中国
FF14_MARKET_CACHE_TTL_MS=60000
FFLOGS_BASE_URL=https://www.fflogs.com
FFLOGS_BASE_URL=https://cn.fflogs.com
FFLOGS_WEB_BASE_URL=https://cn.fflogs.com
FFLOGS_GRAPHQL_URL=https://www.fflogs.com/api/v2/client
FFLOGS_TOKEN_URL=https://www.fflogs.com/oauth/token
FFLOGS_GRAPHQL_URL=https://cn.fflogs.com/api/v2/client
FFLOGS_TOKEN_URL=https://cn.fflogs.com/oauth/token
FFLOGS_CLIENT_ID=
FFLOGS_CLIENT_SECRET=
FFLOGS_DEFAULT_SERVER_REGION=CN

View File

@ -16,10 +16,10 @@ ENV LOKI_BATCH_INTERVAL_SECONDS=5
ENV LOKI_BATCH_MAX_BUFFER_SIZE=10000
ENV LOKI_QUERY_MAX_LIMIT=1000
ENV LOKI_SILENCE_ERRORS=true
ENV FFLOGS_BASE_URL=https://www.fflogs.com
ENV FFLOGS_BASE_URL=https://cn.fflogs.com
ENV FFLOGS_WEB_BASE_URL=https://cn.fflogs.com
ENV FFLOGS_GRAPHQL_URL=https://www.fflogs.com/api/v2/client
ENV FFLOGS_TOKEN_URL=https://www.fflogs.com/oauth/token
ENV FFLOGS_GRAPHQL_URL=https://cn.fflogs.com/api/v2/client
ENV FFLOGS_TOKEN_URL=https://cn.fflogs.com/oauth/token
ENV FFLOGS_DEFAULT_SERVER_REGION=CN
ENV FFLOGS_REQUEST_TIMEOUT_MS=10000

View File

@ -545,11 +545,6 @@ ON DUPLICATE KEY UPDATE
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),

View File

@ -15,8 +15,6 @@ import {
import type { QqbotFf14MarketCatalog } from '../plugins/ff14Market/qqbot-ff14-market.types';
import type { QqbotCommandMatchResult } from '../qqbot.types';
const QQBOT_FFLOGS_ENCOUNTER_DICT_CODE = 'FFLOGS_ENCOUNTER_LABEL';
@Injectable()
export class QqbotCommandParserService {
constructor(private readonly dictService: DictService) {}
@ -208,9 +206,8 @@ export class QqbotCommandParserService {
}
if (!encounterName && remainingPositionals.length > 2) {
const picked = await this.pickFflogsPositionalsByKnownWorld(
remainingPositionals,
);
const picked =
await this.pickFflogsPositionalsByKnownWorld(remainingPositionals);
if (picked) {
characterName = characterName || picked.characterName;
serverSlug = serverSlug || picked.serverSlug;
@ -219,16 +216,6 @@ export class QqbotCommandParserService {
}
}
if (!encounterName && remainingPositionals.length > 1) {
const picked = await this.pickTrailingFflogsEncounter(
remainingPositionals,
);
if (picked) {
encounterName = picked.encounterName;
remainingPositionals = picked.positionals;
}
}
if (!characterName && remainingPositionals.length) {
const joined = remainingPositionals.join(' ');
if (joined.includes('@')) {
@ -350,44 +337,6 @@ export class QqbotCommandParserService {
return null;
}
private async pickTrailingFflogsEncounter(positional: string[]) {
const catalog = await this.getFflogsEncounterCatalog();
for (let index = 1; index < positional.length; index++) {
const encounterName = positional.slice(index).join(' ').trim();
if (!this.isFflogsEncounterName(catalog, encounterName)) continue;
return {
encounterName,
positionals: positional.slice(0, index),
};
}
return null;
}
private async getFflogsEncounterCatalog() {
const dicts = await this.dictService.getDictItemsByKey(
QQBOT_FFLOGS_ENCOUNTER_DICT_CODE,
);
const keys = new Set<string>();
for (const item of dicts) {
for (const key of this.buildFflogsLookupKeys(item.label, item.value)) {
keys.add(key);
}
}
return keys;
}
private isFflogsEncounterName(catalog: Set<string>, value: string) {
const keys = this.buildFflogsLookupKeys(value);
return keys.some(
(key) =>
catalog.has(key) ||
(key.length >= 3 &&
[...catalog].some(
(candidate) => candidate.startsWith(key) || candidate.includes(key),
)),
);
}
private async getFf14MarketCatalog() {
const treeCatalog = buildQqbotFf14MarketCatalogFromTree(
await this.dictService.relationTree({
@ -441,22 +390,4 @@ export class QqbotCommandParserService {
if (value === true) return '';
return `${value || ''}`.trim();
}
private buildFflogsLookupKeys(...values: string[]) {
const keys = values
.flatMap((value) => {
const normalized = this.normalizeFflogsLookupKey(value);
const withoutAnd = normalized.replace(/and/g, '');
return [normalized, withoutAnd];
})
.filter(Boolean);
return [...new Set(keys)];
}
private normalizeFflogsLookupKey(value: string) {
return `${value || ''}`
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '');
}
}

View File

@ -4,6 +4,7 @@ import * as http from 'node:http';
import * as https from 'node:https';
import { DictService } from '../../../admin/dict/dict.service';
import type {
FflogsCharacterEncounterRankingsResponse,
FflogsCharacterSummaryResponse,
FflogsEncounterFightCandidate,
FflogsEncounterLookup,
@ -22,13 +23,20 @@ import type {
} from './qqbot-fflogs.types';
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 FflogsEncounterCatalogItem = {
displayName: string;
encounterId: number;
keys: string[];
zoneId?: number;
zoneName?: string;
};
@Injectable()
export class QqbotFflogsClientService {
private accessToken = '';
@ -36,6 +44,10 @@ export class QqbotFflogsClientService {
private readonly baseUrl: string;
private readonly clientId: string;
private readonly clientSecret: string;
private encounterCatalogCache?: {
entries: FflogsEncounterCatalogItem[];
expiresAt: number;
};
private readonly graphqlUrl: string;
private readonly tokenUrl: string;
private readonly webBaseUrl: string;
@ -44,12 +56,14 @@ export class QqbotFflogsClientService {
private readonly configService: ConfigService,
private readonly dictService: DictService,
) {
this.baseUrl = this.normalizeBaseUrl(
this.configService.get<string>('FFLOGS_BASE_URL') ||
'https://www.fflogs.com',
);
this.webBaseUrl = this.normalizeBaseUrl(
this.configService.get<string>('FFLOGS_WEB_BASE_URL') ||
this.configService.get<string>('FFLOGS_BASE_URL') ||
'https://cn.fflogs.com',
);
this.baseUrl = this.normalizeBaseUrl(
this.configService.get<string>('FFLOGS_BASE_URL') ||
this.webBaseUrl ||
'https://cn.fflogs.com',
);
this.graphqlUrl =
@ -250,6 +264,7 @@ export class QqbotFflogsClientService {
name
slug
}
zoneRankings(metric: dps)
recentReports(limit: $reportsLimit) {
data {
code
@ -291,21 +306,58 @@ export class QqbotFflogsClientService {
character.server?.slug || serverSlug,
character.name || characterName,
);
const replyUrl = this.buildCharacterEncounterUrl(
url,
encounterLookup,
params.partition,
);
const difficulty = this.toOptionalNumber(params.difficulty);
const encounterNameById = this.buildRankingEncounterNameById(
character.zoneRankings,
);
const candidates = this.pickEncounterFightCandidates(
character.recentReports?.data || [],
encounterLookup,
difficulty,
encounterNameById,
);
const metricCandidates = candidates.slice(0, Math.min(limit * 3, 30));
const encounterName = this.pickText(
encounterLookup.displayName,
candidates[0]?.fight?.name,
);
const encounterSuggestions = candidates.length
? []
: this.pickRecentEncounterSuggestions(
character.recentReports?.data || [],
encounterNameById,
);
const rankingSuggestions = candidates.length
? []
: this.pickRankingEncounterSuggestions(character.zoneRankings);
const logs = (
const rankingLogs =
encounterLookup.encounterId !== undefined
? await this.getEncounterRankingLogs({
characterName: character.name || characterName,
encounterLookup,
limit,
partition: params.partition,
serverRegion,
serverSlug: character.server?.slug || serverSlug,
timeframe: params.timeframe,
})
: [];
const fallbackLogs = rankingLogs.length
? []
: (
await Promise.all(
metricCandidates.map((candidate) =>
this.getEncounterFightLog({
candidate,
characterId: character.id,
characterName: character.name || characterName,
encounterNameById,
encounterLookup,
localizationMaps,
serverName,
@ -317,33 +369,160 @@ export class QqbotFflogsClientService {
)
.filter(Boolean)
.slice(0, limit);
const logs = rankingLogs.length ? rankingLogs : fallbackLogs;
return {
characterId: character.id,
characterName: character.name || characterName,
encounterName: encounterLookup.displayName,
encounterName,
encounterSuggestions,
logs,
rankingSuggestions,
rankings: [],
replyText: this.buildEncounterLogsReplyText({
characterId: character.id,
characterName: character.name || characterName,
encounterName: encounterLookup.displayName,
encounterName,
encounterSuggestions,
logs,
localizationMaps,
rankingSuggestions,
serverName,
serverRegion,
url,
url: replyUrl,
}),
serverName,
serverRegion,
url,
url: replyUrl,
};
}
private async getEncounterRankingLogs(params: {
characterName: string;
encounterLookup: FflogsEncounterLookup;
limit: number;
partition?: number | string;
serverRegion: string;
serverSlug: string;
timeframe?: string;
}) {
if (params.encounterLookup.encounterId === undefined) return [];
const data =
await this.requestGraphql<FflogsCharacterEncounterRankingsResponse>(
`query QqbotFflogsCharacterEncounterRankings(
$characterName: String!
$serverSlug: String!
$serverRegion: String!
$encounterID: Int!
$partition: Int
$timeframe: RankingTimeframeType
) {
characterData {
character(
name: $characterName
serverSlug: $serverSlug
serverRegion: $serverRegion
) {
dpsRankings: encounterRankings(
encounterID: $encounterID
metric: dps
partition: $partition
timeframe: $timeframe
)
hpsRankings: encounterRankings(
encounterID: $encounterID
metric: hps
partition: $partition
timeframe: $timeframe
)
}
}
}`,
{
characterName: params.characterName,
encounterID: params.encounterLookup.encounterId,
partition: this.toOptionalNumber(params.partition),
serverRegion: params.serverRegion.toUpperCase(),
serverSlug: params.serverSlug,
timeframe: this.normalizeTimeframe(params.timeframe) || 'Historical',
},
);
const character = data.characterData?.character;
if (!character) return [];
const dpsPayload = this.normalizeJsonPayload(character.dpsRankings) as any;
const hpsPayload = this.normalizeJsonPayload(character.hpsRankings) as any;
const dpsRanks = Array.isArray(dpsPayload?.ranks) ? dpsPayload.ranks : [];
const hpsRanks = Array.isArray(hpsPayload?.ranks) ? hpsPayload.ranks : [];
const hpsByFight = new Map<string, any>();
for (const rank of hpsRanks) {
const key = this.buildRankingFightKey(rank);
if (key) hpsByFight.set(key, rank);
}
return dpsRanks
.map((rank) =>
this.buildEncounterRankingLogItem(
rank,
hpsByFight.get(this.buildRankingFightKey(rank)),
params.encounterLookup,
),
)
.filter(Boolean)
.sort((a, b) => Number(b.startTime || 0) - Number(a.startTime || 0))
.slice(0, params.limit);
}
private buildEncounterRankingLogItem(
damageRank: any,
healingRank: any,
encounterLookup: FflogsEncounterLookup,
): QqbotFflogsEncounterLogItem | null {
const code = `${damageRank?.report?.code || ''}`.trim();
const fightId = this.toOptionalNumber(damageRank?.report?.fightID);
if (!code || fightId === undefined) return null;
const damageScore = this.pickNumber(
damageRank?.rankPercent,
damageRank?.historicalPercent,
damageRank?.todayPercent,
);
const healingScore = this.pickNumber(
healingRank?.rankPercent,
healingRank?.historicalPercent,
healingRank?.todayPercent,
);
return {
adps: this.pickNumber(damageRank?.aDPS, damageRank?.cDPS),
color: this.getParseColor(damageScore),
damageScore,
dps: this.pickNumber(damageRank?.amount, damageRank?.pDPS),
durationMs: this.pickNumber(damageRank?.duration),
encounterName: encounterLookup.displayName,
fightId,
healingColor: this.getParseColor(healingScore),
healingScore,
hps: this.pickNumber(healingRank?.amount),
kill: true,
logCode: code,
logUrl: this.buildReportFightUrl(code, fightId),
ndps: this.pickNumber(damageRank?.nDPS),
rdps: this.pickNumber(damageRank?.rDPS),
startTime: this.pickNumber(
damageRank?.startTime,
damageRank?.report?.startTime,
),
};
}
private buildRankingFightKey(rank: any) {
const code = `${rank?.report?.code || ''}`.trim();
const fightId = this.toOptionalNumber(rank?.report?.fightID);
return code && fightId !== undefined ? `${code}#${fightId}` : '';
}
private async getEncounterFightLog(params: {
candidate: FflogsEncounterFightCandidate;
characterId?: number;
characterName: string;
encounterNameById: Map<number, string>;
encounterLookup: FflogsEncounterLookup;
localizationMaps: FflogsLocalizationMaps;
serverName: string;
@ -380,11 +559,13 @@ export class QqbotFflogsClientService {
dataType: DamageDone
encounterID: $encounterID
fightIDs: $fightIDs
translate: true
)
healing: table(
dataType: Healing
encounterID: $encounterID
fightIDs: $fightIDs
translate: true
)
}
}
@ -431,11 +612,11 @@ export class QqbotFflogsClientService {
);
const encounterName = this.localizeEncounter(
this.pickText(
params.encounterNameById.get(encounterId),
candidate.fight.name,
params.encounterLookup.displayName,
`任务 ${candidate.fight.encounterID || ''}`,
),
params.localizationMaps,
);
return {
@ -599,7 +780,13 @@ export class QqbotFflogsClientService {
),
].join('\n')
: '公开排名:暂无公开排名数据';
return [header, idText, params.allStarText, rankingText, params.url]
return [
header,
idText,
params.allStarText,
rankingText,
this.formatDisplayUrl(params.url),
]
.filter(Boolean)
.join('\n');
}
@ -608,8 +795,10 @@ export class QqbotFflogsClientService {
characterId?: number;
characterName: string;
encounterName: string;
encounterSuggestions?: string[];
localizationMaps: FflogsLocalizationMaps;
logs: QqbotFflogsEncounterLogItem[];
rankingSuggestions?: string[];
serverName: string;
serverRegion: string;
url: string;
@ -618,19 +807,40 @@ export class QqbotFflogsClientService {
params.serverRegion,
params.localizationMaps,
);
const header = `FFLogs 最近记录:${params.characterName} @ ${params.serverName}${region}`;
const encounterText = `高难任务:${params.encounterName}`;
const header = `FFLogs 最近10次记录`;
const characterText = `角色:${params.characterName} @ ${params.serverName}${region}`;
const encounterText = `任务:${params.encounterName}`;
const idText = params.characterId ? `角色ID${params.characterId}` : '';
const logText = params.logs.length
? [
'最近10次',
...params.logs.map((item, index) =>
this.formatEncounterLogLine(item, index),
),
].join('\n')
: '最近10次暂无匹配的公开记录';
: [
'暂无匹配的公开记录',
this.formatSuggestionLine(
'最近报告中可查',
params.encounterSuggestions,
),
this.formatSuggestionLine(
'公开排名中可查',
params.rankingSuggestions,
),
]
.filter(Boolean)
.join('\n');
return [header, encounterText, idText, logText, params.url]
return [
header,
characterText,
encounterText,
idText,
'',
logText,
'',
this.formatDisplayUrl(params.url),
]
.filter(Boolean)
.join('\n');
}
@ -650,23 +860,25 @@ export class QqbotFflogsClientService {
? `${this.formatNumber(item.healingScore)}`
: '-';
const metrics = [
`DPS ${this.formatMetricNumber(item.dps)}`,
`aDPS ${this.formatMetricNumber(item.adps)}`,
`rDPS ${this.formatMetricNumber(item.rdps)}`,
`nDPS ${this.formatMetricNumber(item.ndps)}`,
`HPS ${this.formatMetricNumber(item.hps)}`,
].join(' / ');
return `${index + 1}. ${this.formatLogTime(
item.startTime,
)}${status} ${item.color} ${damageScore} ${
item.healingColor
} ${healingScore}${metrics}log ${item.logCode}#${item.fightId}`;
`D${this.formatMetricNumber(item.dps)}`,
`aD${this.formatMetricNumber(item.adps)}`,
`rD${this.formatMetricNumber(item.rdps)}`,
`nD${this.formatMetricNumber(item.ndps)}`,
`H${this.formatMetricNumber(item.hps)}`,
].join('/');
return [
`${index + 1}. ${this.formatLogTime(item.startTime)}${status}${item.encounterName}`,
` 颜色:D${item.color}/H${item.healingColor}|评分:D${damageScore}/H${healingScore}`,
` ${metrics}`,
` ${this.formatDisplayUrl(item.logUrl)}`,
].join('\n');
}
private pickEncounterFightCandidates(
reports: FflogsRecentReport[],
encounterLookup: FflogsEncounterLookup,
difficulty?: number,
encounterNameById = new Map<number, string>(),
) {
return reports
.flatMap((report) =>
@ -677,7 +889,9 @@ export class QqbotFflogsClientService {
report,
})),
)
.filter(({ fight }) => this.matchEncounterFight(fight, encounterLookup))
.filter(({ fight }) =>
this.matchEncounterFight(fight, encounterLookup, encounterNameById),
)
.filter(
({ fight }) =>
difficulty === undefined || Number(fight.difficulty) === difficulty,
@ -685,18 +899,81 @@ export class QqbotFflogsClientService {
.sort((a, b) => b.absoluteStartTime - a.absoluteStartTime);
}
private pickRecentEncounterSuggestions(
reports: FflogsRecentReport[],
encounterNameById = new Map<number, string>(),
) {
const names = reports.flatMap((report) =>
(report.fights || []).map((fight) =>
this.pickText(
encounterNameById.get(this.toOptionalNumber(fight.encounterID) || 0),
fight.name,
),
),
);
return this.pickDistinctSuggestions(names, 8);
}
private pickRankingEncounterSuggestions(payload: unknown) {
const rankingsPayload = this.normalizeJsonPayload(payload) as any;
const rankings = this.pickRankings(rankingsPayload);
const names = rankings.map((item) =>
this.pickText(item.encounter?.name, item.encounterName, item.name),
);
return this.pickDistinctSuggestions(names, 8);
}
private buildRankingEncounterNameById(payload: unknown) {
const rankingsPayload = this.normalizeJsonPayload(payload) as any;
const rankings = this.pickRankings(rankingsPayload);
const map = new Map<number, string>();
for (const item of rankings) {
const id = this.pickNumber(item.encounter?.id, item.encounterID, item.id);
const name = this.pickText(
item.encounter?.name,
item.encounterName,
item.name,
);
if (id !== undefined && name) map.set(id, name);
}
return map;
}
private pickDistinctSuggestions(values: any[], limit: number) {
const suggestions: string[] = [];
const keys = new Set<string>();
for (const value of values) {
const text = `${value || ''}`.trim();
if (!text || text.toLowerCase() === 'unknown') continue;
const key = this.normalizeLookupKey(text);
if (!key || keys.has(key)) continue;
keys.add(key);
suggestions.push(text);
if (suggestions.length >= limit) break;
}
return suggestions;
}
private formatSuggestionLine(label: string, values?: string[]) {
const list = (values || []).filter(Boolean);
return list.length ? `${label}${list.join('、')}` : '';
}
private matchEncounterFight(
fight: FflogsReportFight,
encounterLookup: FflogsEncounterLookup,
encounterNameById = new Map<number, string>(),
) {
const encounterId = this.toOptionalNumber(fight.encounterID);
if (
encounterLookup.encounterId !== undefined &&
Number(fight.encounterID) === encounterLookup.encounterId
encounterId === encounterLookup.encounterId
) {
return true;
}
const fightKeys = this.buildLookupKeys(
`${fight.name || ''}`,
encounterId !== undefined ? encounterNameById.get(encounterId) || '' : '',
`${fight.encounterID || ''}`,
);
return encounterLookup.keys.some((key) => fightKeys.includes(key));
@ -707,31 +984,17 @@ export class QqbotFflogsClientService {
): Promise<FflogsEncounterLookup> {
const raw = `${input || ''}`.trim();
const inputKeys = this.buildLookupKeys(raw);
const dicts = await this.dictService.getDictItemsByKey(
FFLOGS_LOCALIZATION_DICT_CODES.encounter,
);
const entries = dicts.map((item) => ({
displayName: `${item.label || item.value}`.trim(),
encounterId: this.toOptionalNumber(item.value),
keys: this.buildLookupKeys(`${item.label}`, `${item.value}`),
}));
const exact = entries.find((entry) =>
inputKeys.some((inputKey) => entry.keys.includes(inputKey)),
);
const prefix = exact
? undefined
: entries.find((entry) =>
inputKeys.some(
(inputKey) =>
inputKey.length >= 3 &&
entry.keys.some(
(key) => key.startsWith(inputKey) || key.includes(inputKey),
),
),
);
const matched = exact || prefix;
if (!matched) {
const catalog = await this.getFflogsEncounterCatalog();
const matched = this.findEncounterCatalogMatch(inputKeys, catalog);
if (matched) {
return {
displayName: matched.displayName,
encounterId: matched.encounterId,
input: raw,
keys: [...new Set([...inputKeys, ...matched.keys])],
zoneId: matched.zoneId,
};
}
return {
displayName: raw,
encounterId: this.toOptionalNumber(raw),
@ -740,12 +1003,81 @@ export class QqbotFflogsClientService {
};
}
return {
displayName: matched.displayName,
encounterId: matched.encounterId,
input: raw,
keys: [...new Set([...matched.keys, ...inputKeys])],
private async getFflogsEncounterCatalog() {
if (
this.encounterCatalogCache &&
Date.now() < this.encounterCatalogCache.expiresAt
) {
return this.encounterCatalogCache.entries;
}
const data = await this.requestGraphql<{
worldData?: {
zones?: Array<{
encounters?: Array<{ id?: number; name?: string }>;
id?: number;
name?: string;
}>;
};
}>(
`query QqbotFflogsEncounterCatalog {
worldData {
zones {
id
name
encounters {
id
name
}
}
}
}`,
{},
);
const entries = (data.worldData?.zones || []).flatMap((zone) =>
(zone.encounters || [])
.map((encounter) => {
const encounterId = this.toOptionalNumber(encounter.id);
const displayName = `${encounter.name || ''}`.trim();
if (encounterId === undefined || !displayName) return undefined;
return {
displayName,
encounterId,
keys: this.buildLookupKeys(
displayName,
`${encounterId}`,
`${zone.name || ''}`,
),
zoneId: this.toOptionalNumber(zone.id),
zoneName: zone.name,
} satisfies FflogsEncounterCatalogItem;
})
.filter(Boolean),
) as FflogsEncounterCatalogItem[];
this.encounterCatalogCache = {
entries,
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
};
return entries;
}
private findEncounterCatalogMatch(
inputKeys: string[],
catalog: FflogsEncounterCatalogItem[],
) {
const exact = catalog.find((entry) =>
inputKeys.some((inputKey) => entry.keys.includes(inputKey)),
);
if (exact) return exact;
return catalog.find((entry) =>
inputKeys.some((inputKey) =>
entry.keys.some(
(key) =>
inputKey.length >= 2 &&
key.length >= 2 &&
(key.includes(inputKey) || inputKey.includes(key)),
),
),
);
}
private findRankingCharacter(
@ -845,7 +1177,6 @@ export class QqbotFflogsClientService {
item.name,
`记录 ${index + 1}`,
),
localizationMaps,
);
const percent = this.pickNumber(
item.rankPercent,
@ -925,6 +1256,14 @@ export class QqbotFflogsClientService {
return value;
}
private formatDisplayUrl(value: string) {
try {
return decodeURI(value);
} catch {
return value;
}
}
private buildCharacterUrl(
serverRegion: string,
serverSlug: string,
@ -935,6 +1274,22 @@ export class QqbotFflogsClientService {
)}/${encodeURIComponent(serverSlug)}/${encodeURIComponent(characterName)}`;
}
private buildCharacterEncounterUrl(
url: string,
encounterLookup: FflogsEncounterLookup,
partition?: number | string,
) {
if (encounterLookup.encounterId === undefined) return url;
const searchParams = new URLSearchParams();
if (encounterLookup.zoneId !== undefined) {
searchParams.set('zone', `${encounterLookup.zoneId}`);
}
searchParams.set('boss', `${encounterLookup.encounterId}`);
const partitionValue = this.toOptionalNumber(partition);
searchParams.set('partition', `${partitionValue ?? 0}`);
return `${url}?${searchParams.toString()}`;
}
private buildReportFightUrl(code: string, fightId: number) {
return `${this.webBaseUrl}/reports/${encodeURIComponent(
code,
@ -1083,8 +1438,7 @@ export class QqbotFflogsClientService {
}
private async getLocalizationMaps(): Promise<FflogsLocalizationMaps> {
const [encounter, job, metric, role, serverRegion] = await Promise.all([
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.encounter),
const [job, metric, role, serverRegion] = await Promise.all([
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.job),
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.metric),
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.role),
@ -1092,7 +1446,6 @@ export class QqbotFflogsClientService {
]);
return {
encounter,
job,
metric,
role,
@ -1111,13 +1464,8 @@ export class QqbotFflogsClientService {
return map;
}
private localizeEncounter(
value: string,
localizationMaps: FflogsLocalizationMaps,
) {
return (
localizationMaps.encounter.get(this.normalizeLookupKey(value)) || value
);
private localizeEncounter(value: string) {
return value;
}
private localizeMetric(

View File

@ -45,7 +45,8 @@ export class QqbotFflogsPluginService {
properties: {
characterName: { description: '角色名', type: 'string' },
encounter: {
description: '高难任务名,支持字典维护的中文名/英文名/缩写',
description:
'高难任务名,按 FFLogs 公开报告中的任务名或 encounterID 匹配',
type: 'string',
},
limit: {

View File

@ -18,6 +18,17 @@ export type FflogsCharacterSummaryResponse = {
};
};
export type FflogsCharacterEncounterRankingsResponse = {
characterData?: {
character?:
| (FflogsCharacter & {
dpsRankings?: unknown;
hpsRankings?: unknown;
})
| null;
};
};
export type FflogsEncounterFightCandidate = {
absoluteStartTime: number;
fight: FflogsReportFight;
@ -29,6 +40,7 @@ export type FflogsEncounterLookup = {
encounterId?: number;
input: string;
keys: string[];
zoneId?: number;
};
export type FflogsGraphqlResponse<T> = {
@ -38,12 +50,7 @@ export type FflogsGraphqlResponse<T> = {
export type FflogsHttpMethod = 'GET' | 'POST';
export type FflogsLocalizationKey =
| 'encounter'
| 'job'
| 'metric'
| 'role'
| 'serverRegion';
export type FflogsLocalizationKey = 'job' | 'metric' | 'role' | 'serverRegion';
export type FflogsLocalizationMaps = Record<
FflogsLocalizationKey,
@ -124,7 +131,9 @@ export type QqbotFflogsCharacterSummaryResult = {
characterId?: number;
characterName: string;
encounterName?: string;
encounterSuggestions?: string[];
logs?: QqbotFflogsEncounterLogItem[];
rankingSuggestions?: string[];
rankings: FflogsRankingItem[];
replyText: string;
serverName: string;

View File

@ -35,15 +35,7 @@ describe('QqbotCommandParserService FFLogs parser', () => {
} as QqbotCommand;
const dictService = {
getDictItemsByKey: jest.fn(async (dictKey: string) => {
if (dictKey === 'FFLOGS_ENCOUNTER_LABEL') {
return [
{ label: 'M9S 吸血鬼偶像', value: 'vampfatale' },
{ label: 'M10S Red Hot and Deep Blue', value: 'redhotanddeepblue' },
];
}
return [];
}),
getDictItemsByKey: jest.fn(async () => []),
relationTree: jest.fn(async () => [
{
children: [
@ -72,27 +64,34 @@ describe('QqbotCommandParserService FFLogs parser', () => {
const service = new QqbotCommandParserService(dictService);
it('parses Chinese encounter name after character and server', async () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('parses Chinese encounter name after character and server without encounter dict', async () => {
const matched = await service.match(command, {
messageText: '/logs Anbbo 琥珀原 吸血鬼偶像',
messageText: '/logs Kwi柊司 琥珀原 上位护锁刃龙',
} as any);
expect(matched?.input).toMatchObject({
characterName: 'Anbbo',
encounterName: '吸血鬼偶像',
characterName: 'Kwi柊司',
encounterName: '上位护锁刃龙',
serverSlug: '琥珀原',
});
expect(dictService.getDictItemsByKey).not.toHaveBeenCalledWith(
'FFLOGS_ENCOUNTER_LABEL',
);
});
it('parses space separated encounter name when server is explicit', async () => {
it('keeps character summary parsing when no encounter is provided', async () => {
const matched = await service.match(command, {
messageText: '/logs Kwi 柊司 M10S Red Hot and Deep Blue server=琥珀原',
messageText: '/logs Kwi柊司 琥珀原',
} as any);
expect(matched?.input).toMatchObject({
characterName: 'Kwi 柊司',
encounterName: 'M10S Red Hot and Deep Blue',
characterName: 'Kwi柊司',
serverSlug: '琥珀原',
});
expect((matched?.input as any).encounterName).toBe('');
});
});

View File

@ -27,10 +27,6 @@ import { QqbotFflogsClientService } from '@/qqbot/plugins/fflogs/qqbot-fflogs-cl
describe('QqbotFflogsClientService', () => {
const dicts = {
FFLOGS_ENCOUNTER_LABEL: [
{ label: 'M9S 吸血鬼偶像', value: 'vampfatale' },
{ label: 'M12S P2 Lindwurm II', value: 'lindwurmii' },
],
FFLOGS_JOB_LABEL: [{ label: '机工士', value: 'machinist' }],
FFLOGS_METRIC_LABEL: [
{ label: 'DPS', value: 'dps' },
@ -54,6 +50,13 @@ describe('QqbotFflogsClientService', () => {
} as unknown as DictService,
);
it('uses cn FFLogs API endpoints by default', () => {
expect((service as any).graphqlUrl).toBe(
'https://cn.fflogs.com/api/v2/client',
);
expect((service as any).tokenUrl).toBe('https://cn.fflogs.com/oauth/token');
});
it('formats character rankings with dict labels', async () => {
const localizationMaps = await (service as any).getLocalizationMaps();
const replyText = (service as any).buildReplyText({
@ -65,26 +68,26 @@ describe('QqbotFflogsClientService', () => {
rankings: [
{
bestAmount: 36635,
encounter: { name: 'Vamp Fatale' },
encounter: { name: '上位护锁刃龙' },
rankPercent: 72.4,
spec: 'Machinist',
},
{
bestAmount: 0,
encounter: { name: 'Lindwurm II' },
encounter: { name: '下位护锁刃龙' },
rankPercent: 0,
},
],
serverName: '琥珀原',
serverRegion: 'CN',
url: 'https://cn.fflogs.com/character/cn/example/Kwi',
url: 'https://cn.fflogs.com/character/cn/%E7%90%A5%E7%8F%80%E5%8E%9F/Kwi%E6%9F%8A%E5%8F%B8?zone=67&boss=1082&partition=0',
});
expect(replyText).toContain('FFLogs 战绩Kwi柊司 @ 琥珀原(国服)');
expect(replyText).toContain(
'1. M9S 吸血鬼偶像72.4% DPS 36,635 机工士',
'1. 上位护锁刃龙72.4% DPS 36,635 机工士',
);
expect(replyText).toContain('2. M12S P2 Lindwurm II:暂无有效排名');
expect(replyText).toContain('2. 下位护锁刃龙:暂无有效排名');
});
it('formats recent encounter logs with Chinese encounter label and colors', async () => {
@ -100,7 +103,7 @@ describe('QqbotFflogsClientService', () => {
color: '蓝',
damageScore: 66.5,
dps: 37560.7,
encounterName: 'M9S 吸血鬼偶像',
encounterName: '上位护锁刃龙',
fightId: 14,
healingColor: '灰',
healingScore: 0,
@ -115,21 +118,37 @@ describe('QqbotFflogsClientService', () => {
],
serverName: '琥珀原',
serverRegion: 'CN',
url: 'https://cn.fflogs.com/character/cn/example/Kwi',
url: 'https://cn.fflogs.com/character/cn/%E7%90%A5%E7%8F%80%E5%8E%9F/Kwi%E6%9F%8A%E5%8F%B8?zone=67&boss=1082&partition=0',
});
expect(replyText).toContain('高难任务M9S 吸血鬼偶像');
expect(replyText).toContain('颜色 蓝|输出 66.5|治疗 灰 0');
expect(replyText).toContain('任务M9S 吸血鬼偶像');
expect(replyText).toContain('1. 05/01 20:30击杀上位护锁刃龙');
expect(replyText).toContain('颜色:D蓝/H灰评分:D66.5/H0');
expect(replyText).toContain('D37,561/aD37,561/rD36,635/nD36,635/H0');
expect(replyText).toContain(
'DPS 37,561 / aDPS 37,561 / rDPS 36,635 / nDPS 36,635 / HPS 0',
'https://cn.fflogs.com/reports/CgvFRqyxJhLtmND7#fight=14',
);
expect(replyText).toContain(
'https://cn.fflogs.com/character/cn/琥珀原/Kwi柊司?zone=67&boss=1082&partition=0',
);
expect(replyText).toContain('log CgvFRqyxJhLtmND7#14');
});
it('resolves Chinese encounter input from dict labels', async () => {
const lookup = await (service as any).resolveEncounterLookup('吸血鬼偶像');
it('resolves Chinese encounter input from FFLogs encounter catalog', async () => {
jest.spyOn(service as any, 'getFflogsEncounterCatalog').mockResolvedValue([
{
displayName: '护锁刃龙',
encounterId: 1082,
keys: ['护锁刃龙', '1082'],
zoneId: 67,
},
]);
expect(lookup.displayName).toBe('M9S 吸血鬼偶像');
expect(lookup.keys).toContain('vampfatale');
const lookup = await (service as any).resolveEncounterLookup(
'上位护锁刃龙',
);
expect(lookup.displayName).toBe('护锁刃龙');
expect(lookup.encounterId).toBe(1082);
expect(lookup.zoneId).toBe(67);
});
});