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_DEFAULT_WORLD=中国
FF14_MARKET_CACHE_TTL_MS=60000 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_WEB_BASE_URL=https://cn.fflogs.com
FFLOGS_GRAPHQL_URL=https://www.fflogs.com/api/v2/client FFLOGS_GRAPHQL_URL=https://cn.fflogs.com/api/v2/client
FFLOGS_TOKEN_URL=https://www.fflogs.com/oauth/token FFLOGS_TOKEN_URL=https://cn.fflogs.com/oauth/token
FFLOGS_CLIENT_ID= FFLOGS_CLIENT_ID=
FFLOGS_CLIENT_SECRET= FFLOGS_CLIENT_SECRET=
FFLOGS_DEFAULT_SERVER_REGION=CN 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_BATCH_MAX_BUFFER_SIZE=10000
ENV LOKI_QUERY_MAX_LIMIT=1000 ENV LOKI_QUERY_MAX_LIMIT=1000
ENV LOKI_SILENCE_ERRORS=true 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_WEB_BASE_URL=https://cn.fflogs.com
ENV FFLOGS_GRAPHQL_URL=https://www.fflogs.com/api/v2/client ENV FFLOGS_GRAPHQL_URL=https://cn.fflogs.com/api/v2/client
ENV FFLOGS_TOKEN_URL=https://www.fflogs.com/oauth/token ENV FFLOGS_TOKEN_URL=https://cn.fflogs.com/oauth/token
ENV FFLOGS_DEFAULT_SERVER_REGION=CN ENV FFLOGS_DEFAULT_SERVER_REGION=CN
ENV FFLOGS_REQUEST_TIMEOUT_MS=10000 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`) INSERT INTO `admin_dict` (`id`, `dict_code`, `label`, `value`, `children_code`, `sort`, `status`)
VALUES 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), (2041700000000300701, 'FFLOGS_JOB_LABEL', '骑士', 'paladin', NULL, 1, 1),
(2041700000000300702, 'FFLOGS_JOB_LABEL', '战士', 'warrior', NULL, 2, 1), (2041700000000300702, 'FFLOGS_JOB_LABEL', '战士', 'warrior', NULL, 2, 1),
(2041700000000300703, 'FFLOGS_JOB_LABEL', '暗黑骑士', 'darkknight', NULL, 3, 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 { QqbotFf14MarketCatalog } from '../plugins/ff14Market/qqbot-ff14-market.types';
import type { QqbotCommandMatchResult } from '../qqbot.types'; import type { QqbotCommandMatchResult } from '../qqbot.types';
const QQBOT_FFLOGS_ENCOUNTER_DICT_CODE = 'FFLOGS_ENCOUNTER_LABEL';
@Injectable() @Injectable()
export class QqbotCommandParserService { export class QqbotCommandParserService {
constructor(private readonly dictService: DictService) {} constructor(private readonly dictService: DictService) {}
@ -208,9 +206,8 @@ export class QqbotCommandParserService {
} }
if (!encounterName && remainingPositionals.length > 2) { if (!encounterName && remainingPositionals.length > 2) {
const picked = await this.pickFflogsPositionalsByKnownWorld( const picked =
remainingPositionals, await this.pickFflogsPositionalsByKnownWorld(remainingPositionals);
);
if (picked) { if (picked) {
characterName = characterName || picked.characterName; characterName = characterName || picked.characterName;
serverSlug = serverSlug || picked.serverSlug; 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) { if (!characterName && remainingPositionals.length) {
const joined = remainingPositionals.join(' '); const joined = remainingPositionals.join(' ');
if (joined.includes('@')) { if (joined.includes('@')) {
@ -350,44 +337,6 @@ export class QqbotCommandParserService {
return null; 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() { private async getFf14MarketCatalog() {
const treeCatalog = buildQqbotFf14MarketCatalogFromTree( const treeCatalog = buildQqbotFf14MarketCatalogFromTree(
await this.dictService.relationTree({ await this.dictService.relationTree({
@ -441,22 +390,4 @@ export class QqbotCommandParserService {
if (value === true) return ''; if (value === true) return '';
return `${value || ''}`.trim(); 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 * as https from 'node:https';
import { DictService } from '../../../admin/dict/dict.service'; import { DictService } from '../../../admin/dict/dict.service';
import type { import type {
FflogsCharacterEncounterRankingsResponse,
FflogsCharacterSummaryResponse, FflogsCharacterSummaryResponse,
FflogsEncounterFightCandidate, FflogsEncounterFightCandidate,
FflogsEncounterLookup, FflogsEncounterLookup,
@ -22,13 +23,20 @@ import type {
} from './qqbot-fflogs.types'; } from './qqbot-fflogs.types';
const FFLOGS_LOCALIZATION_DICT_CODES = { const FFLOGS_LOCALIZATION_DICT_CODES = {
encounter: 'FFLOGS_ENCOUNTER_LABEL',
job: 'FFLOGS_JOB_LABEL', job: 'FFLOGS_JOB_LABEL',
metric: 'FFLOGS_METRIC_LABEL', metric: 'FFLOGS_METRIC_LABEL',
role: 'FFLOGS_ROLE_LABEL', role: 'FFLOGS_ROLE_LABEL',
serverRegion: 'FFLOGS_SERVER_REGION_LABEL', serverRegion: 'FFLOGS_SERVER_REGION_LABEL',
}; };
type FflogsEncounterCatalogItem = {
displayName: string;
encounterId: number;
keys: string[];
zoneId?: number;
zoneName?: string;
};
@Injectable() @Injectable()
export class QqbotFflogsClientService { export class QqbotFflogsClientService {
private accessToken = ''; private accessToken = '';
@ -36,6 +44,10 @@ export class QqbotFflogsClientService {
private readonly baseUrl: string; private readonly baseUrl: string;
private readonly clientId: string; private readonly clientId: string;
private readonly clientSecret: string; private readonly clientSecret: string;
private encounterCatalogCache?: {
entries: FflogsEncounterCatalogItem[];
expiresAt: number;
};
private readonly graphqlUrl: string; private readonly graphqlUrl: string;
private readonly tokenUrl: string; private readonly tokenUrl: string;
private readonly webBaseUrl: string; private readonly webBaseUrl: string;
@ -44,12 +56,14 @@ export class QqbotFflogsClientService {
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly dictService: DictService, private readonly dictService: DictService,
) { ) {
this.baseUrl = this.normalizeBaseUrl(
this.configService.get<string>('FFLOGS_BASE_URL') ||
'https://www.fflogs.com',
);
this.webBaseUrl = this.normalizeBaseUrl( this.webBaseUrl = this.normalizeBaseUrl(
this.configService.get<string>('FFLOGS_WEB_BASE_URL') || 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', 'https://cn.fflogs.com',
); );
this.graphqlUrl = this.graphqlUrl =
@ -250,6 +264,7 @@ export class QqbotFflogsClientService {
name name
slug slug
} }
zoneRankings(metric: dps)
recentReports(limit: $reportsLimit) { recentReports(limit: $reportsLimit) {
data { data {
code code
@ -291,59 +306,223 @@ export class QqbotFflogsClientService {
character.server?.slug || serverSlug, character.server?.slug || serverSlug,
character.name || characterName, character.name || characterName,
); );
const replyUrl = this.buildCharacterEncounterUrl(
url,
encounterLookup,
params.partition,
);
const difficulty = this.toOptionalNumber(params.difficulty); const difficulty = this.toOptionalNumber(params.difficulty);
const encounterNameById = this.buildRankingEncounterNameById(
character.zoneRankings,
);
const candidates = this.pickEncounterFightCandidates( const candidates = this.pickEncounterFightCandidates(
character.recentReports?.data || [], character.recentReports?.data || [],
encounterLookup, encounterLookup,
difficulty, difficulty,
encounterNameById,
); );
const metricCandidates = candidates.slice(0, Math.min(limit * 3, 30)); 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 =
await Promise.all( encounterLookup.encounterId !== undefined
metricCandidates.map((candidate) => ? await this.getEncounterRankingLogs({
this.getEncounterFightLog({
candidate,
characterId: character.id,
characterName: character.name || characterName, characterName: character.name || characterName,
encounterLookup, encounterLookup,
localizationMaps, limit,
serverName, partition: params.partition,
serverRegion, serverRegion,
serverSlug: character.server?.slug || serverSlug, serverSlug: character.server?.slug || serverSlug,
}), timeframe: params.timeframe,
), })
) : [];
) const fallbackLogs = rankingLogs.length
.filter(Boolean) ? []
.slice(0, limit); : (
await Promise.all(
metricCandidates.map((candidate) =>
this.getEncounterFightLog({
candidate,
characterId: character.id,
characterName: character.name || characterName,
encounterNameById,
encounterLookup,
localizationMaps,
serverName,
serverRegion,
serverSlug: character.server?.slug || serverSlug,
}),
),
)
)
.filter(Boolean)
.slice(0, limit);
const logs = rankingLogs.length ? rankingLogs : fallbackLogs;
return { return {
characterId: character.id, characterId: character.id,
characterName: character.name || characterName, characterName: character.name || characterName,
encounterName: encounterLookup.displayName, encounterName,
encounterSuggestions,
logs, logs,
rankingSuggestions,
rankings: [], rankings: [],
replyText: this.buildEncounterLogsReplyText({ replyText: this.buildEncounterLogsReplyText({
characterId: character.id, characterId: character.id,
characterName: character.name || characterName, characterName: character.name || characterName,
encounterName: encounterLookup.displayName, encounterName,
encounterSuggestions,
logs, logs,
localizationMaps, localizationMaps,
rankingSuggestions,
serverName, serverName,
serverRegion, serverRegion,
url, url: replyUrl,
}), }),
serverName, serverName,
serverRegion, 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: { private async getEncounterFightLog(params: {
candidate: FflogsEncounterFightCandidate; candidate: FflogsEncounterFightCandidate;
characterId?: number; characterId?: number;
characterName: string; characterName: string;
encounterNameById: Map<number, string>;
encounterLookup: FflogsEncounterLookup; encounterLookup: FflogsEncounterLookup;
localizationMaps: FflogsLocalizationMaps; localizationMaps: FflogsLocalizationMaps;
serverName: string; serverName: string;
@ -380,11 +559,13 @@ export class QqbotFflogsClientService {
dataType: DamageDone dataType: DamageDone
encounterID: $encounterID encounterID: $encounterID
fightIDs: $fightIDs fightIDs: $fightIDs
translate: true
) )
healing: table( healing: table(
dataType: Healing dataType: Healing
encounterID: $encounterID encounterID: $encounterID
fightIDs: $fightIDs fightIDs: $fightIDs
translate: true
) )
} }
} }
@ -431,11 +612,11 @@ export class QqbotFflogsClientService {
); );
const encounterName = this.localizeEncounter( const encounterName = this.localizeEncounter(
this.pickText( this.pickText(
params.encounterNameById.get(encounterId),
candidate.fight.name, candidate.fight.name,
params.encounterLookup.displayName, params.encounterLookup.displayName,
`任务 ${candidate.fight.encounterID || ''}`, `任务 ${candidate.fight.encounterID || ''}`,
), ),
params.localizationMaps,
); );
return { return {
@ -599,7 +780,13 @@ export class QqbotFflogsClientService {
), ),
].join('\n') ].join('\n')
: '公开排名:暂无公开排名数据'; : '公开排名:暂无公开排名数据';
return [header, idText, params.allStarText, rankingText, params.url] return [
header,
idText,
params.allStarText,
rankingText,
this.formatDisplayUrl(params.url),
]
.filter(Boolean) .filter(Boolean)
.join('\n'); .join('\n');
} }
@ -608,8 +795,10 @@ export class QqbotFflogsClientService {
characterId?: number; characterId?: number;
characterName: string; characterName: string;
encounterName: string; encounterName: string;
encounterSuggestions?: string[];
localizationMaps: FflogsLocalizationMaps; localizationMaps: FflogsLocalizationMaps;
logs: QqbotFflogsEncounterLogItem[]; logs: QqbotFflogsEncounterLogItem[];
rankingSuggestions?: string[];
serverName: string; serverName: string;
serverRegion: string; serverRegion: string;
url: string; url: string;
@ -618,19 +807,40 @@ export class QqbotFflogsClientService {
params.serverRegion, params.serverRegion,
params.localizationMaps, params.localizationMaps,
); );
const header = `FFLogs 最近记录:${params.characterName} @ ${params.serverName}${region}`; const header = `FFLogs 最近10次记录`;
const encounterText = `高难任务:${params.encounterName}`; const characterText = `角色:${params.characterName} @ ${params.serverName}${region}`;
const encounterText = `任务:${params.encounterName}`;
const idText = params.characterId ? `角色ID${params.characterId}` : ''; const idText = params.characterId ? `角色ID${params.characterId}` : '';
const logText = params.logs.length const logText = params.logs.length
? [ ? [
'最近10次',
...params.logs.map((item, index) => ...params.logs.map((item, index) =>
this.formatEncounterLogLine(item, index), this.formatEncounterLogLine(item, index),
), ),
].join('\n') ].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) .filter(Boolean)
.join('\n'); .join('\n');
} }
@ -650,23 +860,25 @@ export class QqbotFflogsClientService {
? `${this.formatNumber(item.healingScore)}` ? `${this.formatNumber(item.healingScore)}`
: '-'; : '-';
const metrics = [ const metrics = [
`DPS ${this.formatMetricNumber(item.dps)}`, `D${this.formatMetricNumber(item.dps)}`,
`aDPS ${this.formatMetricNumber(item.adps)}`, `aD${this.formatMetricNumber(item.adps)}`,
`rDPS ${this.formatMetricNumber(item.rdps)}`, `rD${this.formatMetricNumber(item.rdps)}`,
`nDPS ${this.formatMetricNumber(item.ndps)}`, `nD${this.formatMetricNumber(item.ndps)}`,
`HPS ${this.formatMetricNumber(item.hps)}`, `H${this.formatMetricNumber(item.hps)}`,
].join(' / '); ].join('/');
return `${index + 1}. ${this.formatLogTime( return [
item.startTime, `${index + 1}. ${this.formatLogTime(item.startTime)}${status}${item.encounterName}`,
)}${status} ${item.color} ${damageScore} ${ ` 颜色:D${item.color}/H${item.healingColor}|评分:D${damageScore}/H${healingScore}`,
item.healingColor ` ${metrics}`,
} ${healingScore}${metrics}log ${item.logCode}#${item.fightId}`; ` ${this.formatDisplayUrl(item.logUrl)}`,
].join('\n');
} }
private pickEncounterFightCandidates( private pickEncounterFightCandidates(
reports: FflogsRecentReport[], reports: FflogsRecentReport[],
encounterLookup: FflogsEncounterLookup, encounterLookup: FflogsEncounterLookup,
difficulty?: number, difficulty?: number,
encounterNameById = new Map<number, string>(),
) { ) {
return reports return reports
.flatMap((report) => .flatMap((report) =>
@ -677,7 +889,9 @@ export class QqbotFflogsClientService {
report, report,
})), })),
) )
.filter(({ fight }) => this.matchEncounterFight(fight, encounterLookup)) .filter(({ fight }) =>
this.matchEncounterFight(fight, encounterLookup, encounterNameById),
)
.filter( .filter(
({ fight }) => ({ fight }) =>
difficulty === undefined || Number(fight.difficulty) === difficulty, difficulty === undefined || Number(fight.difficulty) === difficulty,
@ -685,18 +899,81 @@ export class QqbotFflogsClientService {
.sort((a, b) => b.absoluteStartTime - a.absoluteStartTime); .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( private matchEncounterFight(
fight: FflogsReportFight, fight: FflogsReportFight,
encounterLookup: FflogsEncounterLookup, encounterLookup: FflogsEncounterLookup,
encounterNameById = new Map<number, string>(),
) { ) {
const encounterId = this.toOptionalNumber(fight.encounterID);
if ( if (
encounterLookup.encounterId !== undefined && encounterLookup.encounterId !== undefined &&
Number(fight.encounterID) === encounterLookup.encounterId encounterId === encounterLookup.encounterId
) { ) {
return true; return true;
} }
const fightKeys = this.buildLookupKeys( const fightKeys = this.buildLookupKeys(
`${fight.name || ''}`, `${fight.name || ''}`,
encounterId !== undefined ? encounterNameById.get(encounterId) || '' : '',
`${fight.encounterID || ''}`, `${fight.encounterID || ''}`,
); );
return encounterLookup.keys.some((key) => fightKeys.includes(key)); return encounterLookup.keys.some((key) => fightKeys.includes(key));
@ -707,47 +984,102 @@ export class QqbotFflogsClientService {
): Promise<FflogsEncounterLookup> { ): Promise<FflogsEncounterLookup> {
const raw = `${input || ''}`.trim(); const raw = `${input || ''}`.trim();
const inputKeys = this.buildLookupKeys(raw); const inputKeys = this.buildLookupKeys(raw);
const dicts = await this.dictService.getDictItemsByKey( const catalog = await this.getFflogsEncounterCatalog();
FFLOGS_LOCALIZATION_DICT_CODES.encounter, const matched = this.findEncounterCatalogMatch(inputKeys, catalog);
); if (matched) {
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) {
return { return {
displayName: raw, displayName: matched.displayName,
encounterId: this.toOptionalNumber(raw), encounterId: matched.encounterId,
input: raw, input: raw,
keys: inputKeys, keys: [...new Set([...inputKeys, ...matched.keys])],
zoneId: matched.zoneId,
}; };
} }
return { return {
displayName: matched.displayName, displayName: raw,
encounterId: matched.encounterId, encounterId: this.toOptionalNumber(raw),
input: raw, input: raw,
keys: [...new Set([...matched.keys, ...inputKeys])], 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( private findRankingCharacter(
payload: unknown, payload: unknown,
target: { target: {
@ -845,7 +1177,6 @@ export class QqbotFflogsClientService {
item.name, item.name,
`记录 ${index + 1}`, `记录 ${index + 1}`,
), ),
localizationMaps,
); );
const percent = this.pickNumber( const percent = this.pickNumber(
item.rankPercent, item.rankPercent,
@ -883,10 +1214,10 @@ export class QqbotFflogsClientService {
const raw = Array.isArray(payload) const raw = Array.isArray(payload)
? payload ? payload
: Array.isArray(payload?.rankings) : Array.isArray(payload?.rankings)
? payload.rankings ? payload.rankings
: Array.isArray(payload?.encounters) : Array.isArray(payload?.encounters)
? payload.encounters ? payload.encounters
: []; : [];
return raw return raw
.filter((item) => item && typeof item === 'object') .filter((item) => item && typeof item === 'object')
.sort((a, b) => { .sort((a, b) => {
@ -925,6 +1256,14 @@ export class QqbotFflogsClientService {
return value; return value;
} }
private formatDisplayUrl(value: string) {
try {
return decodeURI(value);
} catch {
return value;
}
}
private buildCharacterUrl( private buildCharacterUrl(
serverRegion: string, serverRegion: string,
serverSlug: string, serverSlug: string,
@ -935,6 +1274,22 @@ export class QqbotFflogsClientService {
)}/${encodeURIComponent(serverSlug)}/${encodeURIComponent(characterName)}`; )}/${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) { private buildReportFightUrl(code: string, fightId: number) {
return `${this.webBaseUrl}/reports/${encodeURIComponent( return `${this.webBaseUrl}/reports/${encodeURIComponent(
code, code,
@ -1083,8 +1438,7 @@ export class QqbotFflogsClientService {
} }
private async getLocalizationMaps(): Promise<FflogsLocalizationMaps> { private async getLocalizationMaps(): Promise<FflogsLocalizationMaps> {
const [encounter, job, metric, role, serverRegion] = await Promise.all([ const [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.job),
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.metric), this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.metric),
this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.role), this.getNormalizedDictMap(FFLOGS_LOCALIZATION_DICT_CODES.role),
@ -1092,7 +1446,6 @@ export class QqbotFflogsClientService {
]); ]);
return { return {
encounter,
job, job,
metric, metric,
role, role,
@ -1111,13 +1464,8 @@ export class QqbotFflogsClientService {
return map; return map;
} }
private localizeEncounter( private localizeEncounter(value: string) {
value: string, return value;
localizationMaps: FflogsLocalizationMaps,
) {
return (
localizationMaps.encounter.get(this.normalizeLookupKey(value)) || value
);
} }
private localizeMetric( private localizeMetric(

View File

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

View File

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

View File

@ -35,15 +35,7 @@ describe('QqbotCommandParserService FFLogs parser', () => {
} as QqbotCommand; } as QqbotCommand;
const dictService = { const dictService = {
getDictItemsByKey: jest.fn(async (dictKey: string) => { getDictItemsByKey: jest.fn(async () => []),
if (dictKey === 'FFLOGS_ENCOUNTER_LABEL') {
return [
{ label: 'M9S 吸血鬼偶像', value: 'vampfatale' },
{ label: 'M10S Red Hot and Deep Blue', value: 'redhotanddeepblue' },
];
}
return [];
}),
relationTree: jest.fn(async () => [ relationTree: jest.fn(async () => [
{ {
children: [ children: [
@ -72,27 +64,34 @@ describe('QqbotCommandParserService FFLogs parser', () => {
const service = new QqbotCommandParserService(dictService); const service = new QqbotCommandParserService(dictService);
it('parses Chinese encounter name after character and server', async () => { beforeEach(() => {
const matched = await service.match(command, { jest.clearAllMocks();
messageText: '/logs Anbbo 琥珀原 吸血鬼偶像',
} as any);
expect(matched?.input).toMatchObject({
characterName: 'Anbbo',
encounterName: '吸血鬼偶像',
serverSlug: '琥珀原',
});
}); });
it('parses space separated encounter name when server is explicit', async () => { it('parses Chinese encounter name after character and server without encounter dict', async () => {
const matched = await service.match(command, { const matched = await service.match(command, {
messageText: '/logs Kwi 柊司 M10S Red Hot and Deep Blue server=琥珀原', messageText: '/logs Kwi柊司 琥珀原 上位护锁刃龙',
} as any); } as any);
expect(matched?.input).toMatchObject({ expect(matched?.input).toMatchObject({
characterName: 'Kwi 柊司', characterName: 'Kwi柊司',
encounterName: 'M10S Red Hot and Deep Blue', encounterName: '上位护锁刃龙',
serverSlug: '琥珀原', serverSlug: '琥珀原',
}); });
expect(dictService.getDictItemsByKey).not.toHaveBeenCalledWith(
'FFLOGS_ENCOUNTER_LABEL',
);
});
it('keeps character summary parsing when no encounter is provided', async () => {
const matched = await service.match(command, {
messageText: '/logs Kwi柊司 琥珀原',
} as any);
expect(matched?.input).toMatchObject({
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', () => { describe('QqbotFflogsClientService', () => {
const dicts = { const dicts = {
FFLOGS_ENCOUNTER_LABEL: [
{ label: 'M9S 吸血鬼偶像', value: 'vampfatale' },
{ label: 'M12S P2 Lindwurm II', value: 'lindwurmii' },
],
FFLOGS_JOB_LABEL: [{ label: '机工士', value: 'machinist' }], FFLOGS_JOB_LABEL: [{ label: '机工士', value: 'machinist' }],
FFLOGS_METRIC_LABEL: [ FFLOGS_METRIC_LABEL: [
{ label: 'DPS', value: 'dps' }, { label: 'DPS', value: 'dps' },
@ -54,6 +50,13 @@ describe('QqbotFflogsClientService', () => {
} as unknown as DictService, } 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 () => { it('formats character rankings with dict labels', async () => {
const localizationMaps = await (service as any).getLocalizationMaps(); const localizationMaps = await (service as any).getLocalizationMaps();
const replyText = (service as any).buildReplyText({ const replyText = (service as any).buildReplyText({
@ -65,26 +68,26 @@ describe('QqbotFflogsClientService', () => {
rankings: [ rankings: [
{ {
bestAmount: 36635, bestAmount: 36635,
encounter: { name: 'Vamp Fatale' }, encounter: { name: '上位护锁刃龙' },
rankPercent: 72.4, rankPercent: 72.4,
spec: 'Machinist', spec: 'Machinist',
}, },
{ {
bestAmount: 0, bestAmount: 0,
encounter: { name: 'Lindwurm II' }, encounter: { name: '下位护锁刃龙' },
rankPercent: 0, rankPercent: 0,
}, },
], ],
serverName: '琥珀原', serverName: '琥珀原',
serverRegion: 'CN', 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('FFLogs 战绩Kwi柊司 @ 琥珀原(国服)');
expect(replyText).toContain( 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 () => { it('formats recent encounter logs with Chinese encounter label and colors', async () => {
@ -100,7 +103,7 @@ describe('QqbotFflogsClientService', () => {
color: '蓝', color: '蓝',
damageScore: 66.5, damageScore: 66.5,
dps: 37560.7, dps: 37560.7,
encounterName: 'M9S 吸血鬼偶像', encounterName: '上位护锁刃龙',
fightId: 14, fightId: 14,
healingColor: '灰', healingColor: '灰',
healingScore: 0, healingScore: 0,
@ -115,21 +118,37 @@ describe('QqbotFflogsClientService', () => {
], ],
serverName: '琥珀原', serverName: '琥珀原',
serverRegion: 'CN', 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('任务M9S 吸血鬼偶像');
expect(replyText).toContain('颜色 蓝|输出 66.5|治疗 灰 0'); 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( 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 () => { it('resolves Chinese encounter input from FFLogs encounter catalog', async () => {
const lookup = await (service as any).resolveEncounterLookup('吸血鬼偶像'); jest.spyOn(service as any, 'getFflogsEncounterCatalog').mockResolvedValue([
{
displayName: '护锁刃龙',
encounterId: 1082,
keys: ['护锁刃龙', '1082'],
zoneId: 67,
},
]);
expect(lookup.displayName).toBe('M9S 吸血鬼偶像'); const lookup = await (service as any).resolveEncounterLookup(
expect(lookup.keys).toContain('vampfatale'); '上位护锁刃龙',
);
expect(lookup.displayName).toBe('护锁刃龙');
expect(lookup.encounterId).toBe(1082);
expect(lookup.zoneId).toBe(67);
}); });
}); });