feat: 接入QQBot FFLogs查询命令
This commit is contained in:
parent
e60e98bf49
commit
c73084b4d1
10
.env.example
10
.env.example
@ -63,3 +63,13 @@ FF14_XIVAPI_CHS_BASE_URL=https://xivapi-v2.xivcdn.com/api
|
|||||||
FF14_UNIVERSALIS_BASE_URL=https://universalis.app/api/v2
|
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_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_CLIENT_ID=
|
||||||
|
FFLOGS_CLIENT_SECRET=
|
||||||
|
FFLOGS_DEFAULT_SERVER_REGION=CN
|
||||||
|
FFLOGS_DEFAULT_SERVER=
|
||||||
|
FFLOGS_REQUEST_TIMEOUT_MS=10000
|
||||||
|
|||||||
49
Jenkinsfile
vendored
49
Jenkinsfile
vendored
@ -25,6 +25,45 @@ def isPublishBranch(String branchName, String pattern) {
|
|||||||
return branchName ==~ pattern
|
return branchName ==~ pattern
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def requiredRuntimeEnvKeys() {
|
||||||
|
return [
|
||||||
|
'DB_HOST',
|
||||||
|
'DB_PORT',
|
||||||
|
'DB_USERNAME',
|
||||||
|
'DB_PASSWORD',
|
||||||
|
'DB_DATABASE',
|
||||||
|
'ADMIN_TOKEN_SECRET',
|
||||||
|
'FFLOGS_CLIENT_ID',
|
||||||
|
'FFLOGS_CLIENT_SECRET',
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def buildEnvFileValidationScript(String envFile) {
|
||||||
|
def checks = requiredRuntimeEnvKeys().collect { key ->
|
||||||
|
"""
|
||||||
|
if ! grep -Eq '^[[:space:]]*${key}[[:space:]]*=[[:space:]]*[^[:space:]]+' "\$ENV_FILE"; then
|
||||||
|
echo "Missing required runtime env key: ${key}"
|
||||||
|
missing=1
|
||||||
|
fi
|
||||||
|
""".stripIndent()
|
||||||
|
}.join('\n')
|
||||||
|
|
||||||
|
return """
|
||||||
|
set -e
|
||||||
|
ENV_FILE=${shellQuote(envFile)}
|
||||||
|
if [ ! -f "\$ENV_FILE" ]; then
|
||||||
|
echo "Container env file not found: ${envFile}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
missing=0
|
||||||
|
${checks}
|
||||||
|
if [ "\$missing" -ne 0 ]; then
|
||||||
|
echo "Update the private .env.production used by Jenkins before deploying."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
""".stripIndent()
|
||||||
|
}
|
||||||
|
|
||||||
pipeline {
|
pipeline {
|
||||||
agent { label 'kt-node-agent' }
|
agent { label 'kt-node-agent' }
|
||||||
|
|
||||||
@ -283,7 +322,12 @@ pipeline {
|
|||||||
echo "K8s manifest file not found: ${manifestFile}"
|
echo "K8s manifest file not found: ${manifestFile}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
""".stripIndent())
|
||||||
|
|
||||||
|
runCmd(buildEnvFileValidationScript(containerEnvFile))
|
||||||
|
|
||||||
|
runCmd("""
|
||||||
|
set -e
|
||||||
kubectl ${kubeConfigArg} get namespace ${shellQuote(namespace)} >/dev/null
|
kubectl ${kubeConfigArg} get namespace ${shellQuote(namespace)} >/dev/null
|
||||||
kubectl ${kubeConfigArg} ${namespaceArg} create secret generic ${shellQuote(envSecret)} \\
|
kubectl ${kubeConfigArg} ${namespaceArg} create secret generic ${shellQuote(envSecret)} \\
|
||||||
--from-env-file=${shellQuote(containerEnvFile)} \\
|
--from-env-file=${shellQuote(containerEnvFile)} \\
|
||||||
@ -334,7 +378,12 @@ pipeline {
|
|||||||
echo "/home/jenkins/agent/env/kt-template-online-api/.env.production"
|
echo "/home/jenkins/agent/env/kt-template-online-api/.env.production"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
""".stripIndent())
|
||||||
|
|
||||||
|
runCmd(buildEnvFileValidationScript(containerEnvFile))
|
||||||
|
|
||||||
|
runCmd("""
|
||||||
|
set -e
|
||||||
docker rm -f '${containerName}' >/dev/null 2>&1 || true
|
docker rm -f '${containerName}' >/dev/null 2>&1 || true
|
||||||
docker run -d \\
|
docker run -d \\
|
||||||
--name '${containerName}' \\
|
--name '${containerName}' \\
|
||||||
|
|||||||
@ -3,6 +3,13 @@ FROM node:22-bookworm-slim
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
|
ENV APP_PORT=48085
|
||||||
|
ENV FFLOGS_BASE_URL=https://www.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_DEFAULT_SERVER_REGION=CN
|
||||||
|
ENV FFLOGS_REQUEST_TIMEOUT_MS=10000
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
|||||||
@ -515,7 +515,8 @@ ON DUPLICATE KEY UPDATE
|
|||||||
|
|
||||||
INSERT INTO `qqbot_command` (`id`, `code`, `name`, `aliases`, `prefixes`, `plugin_key`, `operation_key`, `parser_key`, `target_type`, `default_params`, `reply_template`, `error_template`, `enabled`, `priority`, `cooldown_ms`, `remark`)
|
INSERT INTO `qqbot_command` (`id`, `code`, `name`, `aliases`, `prefixes`, `plugin_key`, `operation_key`, `parser_key`, `target_type`, `default_params`, `reply_template`, `error_template`, `enabled`, `priority`, `cooldown_ms`, `remark`)
|
||||||
VALUES
|
VALUES
|
||||||
(2041700000000300501, 'ff14_price', 'FF14 查价', '["查价","price","ff14price"]', '["/","!","!"]', 'ff14Market', 'ff14.market.price', 'ff14Price', 'all', '{"language":"chs","world":"中国"}', '', 'FF14 查价失败:{{error}}', 1, 0, 1500, '默认示例命令;请在账号配置中绑定后启用')
|
(2041700000000300501, 'ff14_price', 'FF14 查价', '["查价","price","ff14price"]', '["/","!","!"]', 'ff14Market', 'ff14.market.price', 'ff14Price', 'all', '{"language":"chs","world":"中国"}', '', 'FF14 查价失败:{{error}}', 1, 0, 1500, '默认示例命令;请在账号配置中绑定后启用'),
|
||||||
|
(2041700000000300502, 'fflogs_character', 'FFLogs 查询', '["fflogs","logs","查logs","查log"]', '["/","!","!"]', 'fflogs', 'fflogs.character.summary', 'fflogsCharacter', 'all', '{"serverRegion":"CN"}', '', 'FFLogs 查询失败:{{error}}', 1, 0, 3000, '查询 FFLogs 角色公开排名;格式:/fflogs 角色名 服务器')
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
`name` = VALUES(`name`),
|
`name` = VALUES(`name`),
|
||||||
`plugin_key` = VALUES(`plugin_key`),
|
`plugin_key` = VALUES(`plugin_key`),
|
||||||
|
|||||||
@ -806,9 +806,9 @@ function qqbotAccountExample() {
|
|||||||
function qqbotCommandExample() {
|
function qqbotCommandExample() {
|
||||||
return {
|
return {
|
||||||
id: '1000000000000000001',
|
id: '1000000000000000001',
|
||||||
name: 'FF14 查价',
|
name: 'FFLogs 查询',
|
||||||
command: '/price',
|
command: '/fflogs 角色名 服务器',
|
||||||
pluginKey: 'ff14Market',
|
pluginKey: 'fflogs',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -859,10 +859,10 @@ function qqbotPermissionExample() {
|
|||||||
|
|
||||||
function qqbotPluginExample() {
|
function qqbotPluginExample() {
|
||||||
return {
|
return {
|
||||||
key: 'ff14Market',
|
key: 'fflogs',
|
||||||
name: 'FF14 查价',
|
name: 'FFLogs 查询',
|
||||||
triggerMode: 'command',
|
triggerMode: 'command',
|
||||||
description: '查询 FF14 市场价格',
|
description: '查询 FFLogs 角色公开排名',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -904,8 +904,8 @@ function permissionConfigExample() {
|
|||||||
|
|
||||||
function pluginHealthExample() {
|
function pluginHealthExample() {
|
||||||
return {
|
return {
|
||||||
key: 'ff14Market',
|
key: 'fflogs',
|
||||||
name: 'FF14 查价',
|
name: 'FFLogs 查询',
|
||||||
available: true,
|
available: true,
|
||||||
message: '插件可用',
|
message: '插件可用',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -61,6 +61,9 @@ export class QqbotCommandParserService {
|
|||||||
if (command.parserKey === 'ff14Price') {
|
if (command.parserKey === 'ff14Price') {
|
||||||
return this.parseFf14PriceInput(rawArgs);
|
return this.parseFf14PriceInput(rawArgs);
|
||||||
}
|
}
|
||||||
|
if (command.parserKey === 'fflogsCharacter') {
|
||||||
|
return this.parseFflogsCharacterInput(rawArgs);
|
||||||
|
}
|
||||||
const args = rawArgs ? rawArgs.split(/\s+/).filter(Boolean) : [];
|
const args = rawArgs ? rawArgs.split(/\s+/).filter(Boolean) : [];
|
||||||
return {
|
return {
|
||||||
args,
|
args,
|
||||||
@ -138,6 +141,81 @@ export class QqbotCommandParserService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private parseFflogsCharacterInput(rawArgs: string) {
|
||||||
|
const tokens = rawArgs.split(/\s+/).filter(Boolean);
|
||||||
|
const flags = new Map<string, string | true>();
|
||||||
|
const positional: string[] = [];
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (token.includes('=')) {
|
||||||
|
const [key, ...rest] = token.split('=');
|
||||||
|
flags.set(key, rest.join('='));
|
||||||
|
} else {
|
||||||
|
positional.push(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let characterName = this.normalizeString(
|
||||||
|
flags.get('character') ||
|
||||||
|
flags.get('name') ||
|
||||||
|
flags.get('角色') ||
|
||||||
|
flags.get('角色名'),
|
||||||
|
);
|
||||||
|
let serverSlug = this.normalizeString(
|
||||||
|
flags.get('server') ||
|
||||||
|
flags.get('serverSlug') ||
|
||||||
|
flags.get('world') ||
|
||||||
|
flags.get('服务器') ||
|
||||||
|
flags.get('小区'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!characterName && positional.length) {
|
||||||
|
const joined = positional.join(' ');
|
||||||
|
if (joined.includes('@')) {
|
||||||
|
const [name, server] = joined.split('@');
|
||||||
|
characterName = name.trim();
|
||||||
|
serverSlug = serverSlug || server?.trim();
|
||||||
|
} else if (serverSlug) {
|
||||||
|
characterName = joined;
|
||||||
|
} else if (positional.length > 1) {
|
||||||
|
serverSlug = positional[positional.length - 1];
|
||||||
|
characterName = positional.slice(0, -1).join(' ');
|
||||||
|
} else {
|
||||||
|
characterName = joined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
characterName,
|
||||||
|
className: this.normalizeString(flags.get('class') || flags.get('职业')),
|
||||||
|
difficulty: this.normalizeString(
|
||||||
|
flags.get('difficulty') || flags.get('难度'),
|
||||||
|
),
|
||||||
|
metric: this.normalizeString(flags.get('metric') || flags.get('指标')),
|
||||||
|
partition: this.normalizeString(
|
||||||
|
flags.get('partition') || flags.get('分区'),
|
||||||
|
),
|
||||||
|
raw: rawArgs,
|
||||||
|
role: this.normalizeString(flags.get('role') || flags.get('职责')),
|
||||||
|
serverRegion: this.normalizeString(
|
||||||
|
flags.get('region') ||
|
||||||
|
flags.get('serverRegion') ||
|
||||||
|
flags.get('地区') ||
|
||||||
|
flags.get('服务器地区'),
|
||||||
|
),
|
||||||
|
serverSlug,
|
||||||
|
size: this.normalizeString(flags.get('size') || flags.get('人数')),
|
||||||
|
specName: this.normalizeString(flags.get('spec') || flags.get('专精')),
|
||||||
|
text: rawArgs,
|
||||||
|
timeframe: this.normalizeString(
|
||||||
|
flags.get('timeframe') || flags.get('时间') || flags.get('范围'),
|
||||||
|
),
|
||||||
|
zoneId: this.normalizeString(
|
||||||
|
flags.get('zone') || flags.get('zoneId') || flags.get('副本'),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private pickTrailingFf14Location(positional: string[]) {
|
private pickTrailingFf14Location(positional: string[]) {
|
||||||
const last = positional[positional.length - 1];
|
const last = positional[positional.length - 1];
|
||||||
if (!isQqbotFf14LocationName(last)) return null;
|
if (!isQqbotFf14LocationName(last)) return null;
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||||
import { throwVbenError } from '@/common';
|
import { throwVbenError } from '@/common';
|
||||||
import { QqbotFf14MarketPluginService } from '../plugins/ff14Market/qqbot-ff14-market.plugin';
|
import { QqbotFf14MarketPluginService } from '../plugins/ff14Market/qqbot-ff14-market.plugin';
|
||||||
|
import { QqbotFflogsPluginService } from '../plugins/fflogs/qqbot-fflogs.plugin';
|
||||||
import type {
|
import type {
|
||||||
QqbotIntegrationPlugin,
|
QqbotIntegrationPlugin,
|
||||||
QqbotPluginHealth,
|
QqbotPluginHealth,
|
||||||
@ -13,10 +14,14 @@ import type {
|
|||||||
export class QqbotPluginRegistryService implements OnModuleInit {
|
export class QqbotPluginRegistryService implements OnModuleInit {
|
||||||
private readonly plugins = new Map<string, QqbotIntegrationPlugin>();
|
private readonly plugins = new Map<string, QqbotIntegrationPlugin>();
|
||||||
|
|
||||||
constructor(private readonly ff14MarketPlugin: QqbotFf14MarketPluginService) {}
|
constructor(
|
||||||
|
private readonly ff14MarketPlugin: QqbotFf14MarketPluginService,
|
||||||
|
private readonly fflogsPlugin: QqbotFflogsPluginService,
|
||||||
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
this.register(this.ff14MarketPlugin.getPlugin());
|
this.register(this.ff14MarketPlugin.getPlugin());
|
||||||
|
this.register(this.fflogsPlugin.getPlugin());
|
||||||
}
|
}
|
||||||
|
|
||||||
register(plugin: QqbotIntegrationPlugin) {
|
register(plugin: QqbotIntegrationPlugin) {
|
||||||
|
|||||||
533
src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts
Normal file
533
src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts
Normal file
@ -0,0 +1,533 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import * as http from 'node:http';
|
||||||
|
import * as https from 'node:https';
|
||||||
|
|
||||||
|
type HttpMethod = 'GET' | 'POST';
|
||||||
|
|
||||||
|
type FflogsTokenResponse = {
|
||||||
|
access_token?: string;
|
||||||
|
expires_in?: number;
|
||||||
|
token_type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FflogsGraphqlResponse<T> = {
|
||||||
|
data?: T;
|
||||||
|
errors?: Array<{ message?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FflogsCharacter = {
|
||||||
|
id?: number;
|
||||||
|
lodestoneID?: number;
|
||||||
|
name?: string;
|
||||||
|
server?: {
|
||||||
|
name?: string;
|
||||||
|
slug?: string;
|
||||||
|
};
|
||||||
|
zoneRankings?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FflogsCharacterSummaryResponse = {
|
||||||
|
characterData?: {
|
||||||
|
character?: FflogsCharacter | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type FflogsRankingItem = Record<string, any>;
|
||||||
|
|
||||||
|
export type QqbotFflogsCharacterSummaryInput = {
|
||||||
|
character?: string;
|
||||||
|
characterName?: string;
|
||||||
|
className?: string;
|
||||||
|
difficulty?: number | string;
|
||||||
|
metric?: string;
|
||||||
|
partition?: number | string;
|
||||||
|
role?: string;
|
||||||
|
server?: string;
|
||||||
|
serverRegion?: string;
|
||||||
|
serverSlug?: string;
|
||||||
|
size?: number | string;
|
||||||
|
specName?: string;
|
||||||
|
timeframe?: string;
|
||||||
|
zoneId?: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type QqbotFflogsCharacterSummaryResult = {
|
||||||
|
allStarText?: string;
|
||||||
|
characterId?: number;
|
||||||
|
characterName: string;
|
||||||
|
rankings: FflogsRankingItem[];
|
||||||
|
replyText: string;
|
||||||
|
serverName: string;
|
||||||
|
serverRegion: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QqbotFflogsClientService {
|
||||||
|
private accessToken = '';
|
||||||
|
private accessTokenExpireAt = 0;
|
||||||
|
private readonly baseUrl: string;
|
||||||
|
private readonly clientId: string;
|
||||||
|
private readonly clientSecret: string;
|
||||||
|
private readonly graphqlUrl: string;
|
||||||
|
private readonly tokenUrl: string;
|
||||||
|
private readonly webBaseUrl: string;
|
||||||
|
|
||||||
|
constructor(private readonly configService: ConfigService) {
|
||||||
|
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') ||
|
||||||
|
'https://cn.fflogs.com',
|
||||||
|
);
|
||||||
|
this.graphqlUrl =
|
||||||
|
this.configService.get<string>('FFLOGS_GRAPHQL_URL') ||
|
||||||
|
`${this.baseUrl}/api/v2/client`;
|
||||||
|
this.tokenUrl =
|
||||||
|
this.configService.get<string>('FFLOGS_TOKEN_URL') ||
|
||||||
|
`${this.baseUrl}/oauth/token`;
|
||||||
|
this.clientId = `${
|
||||||
|
this.configService.get<string>('FFLOGS_CLIENT_ID') || ''
|
||||||
|
}`.trim();
|
||||||
|
this.clientSecret = `${
|
||||||
|
this.configService.get<string>('FFLOGS_CLIENT_SECRET') || ''
|
||||||
|
}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkHealth() {
|
||||||
|
await this.getAccessToken();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCharacterSummary(
|
||||||
|
params: QqbotFflogsCharacterSummaryInput,
|
||||||
|
): Promise<QqbotFflogsCharacterSummaryResult> {
|
||||||
|
const characterName = `${
|
||||||
|
params.characterName || params.character || ''
|
||||||
|
}`.trim();
|
||||||
|
const serverSlug = `${
|
||||||
|
params.serverSlug || params.server || this.getDefaultServer()
|
||||||
|
}`.trim();
|
||||||
|
const serverRegion = `${
|
||||||
|
params.serverRegion || this.getDefaultServerRegion()
|
||||||
|
}`.trim();
|
||||||
|
|
||||||
|
if (!characterName) throw new Error('请提供 FFLogs 角色名');
|
||||||
|
if (!serverSlug) throw new Error('请提供 FFLogs 服务器名');
|
||||||
|
if (!serverRegion)
|
||||||
|
throw new Error('请提供 FFLogs 服务器地区,如 CN/JP/NA/EU');
|
||||||
|
|
||||||
|
const variables = {
|
||||||
|
characterName,
|
||||||
|
className: this.normalizeOptionalString(params.className),
|
||||||
|
difficulty: this.toOptionalNumber(params.difficulty),
|
||||||
|
metric: this.normalizeMetric(params.metric),
|
||||||
|
partition: this.toOptionalNumber(params.partition),
|
||||||
|
role: this.normalizeRole(params.role),
|
||||||
|
serverRegion: serverRegion.toUpperCase(),
|
||||||
|
serverSlug,
|
||||||
|
size: this.toOptionalNumber(params.size),
|
||||||
|
specName: this.normalizeOptionalString(params.specName),
|
||||||
|
timeframe: this.normalizeTimeframe(params.timeframe),
|
||||||
|
zoneID: this.toOptionalNumber(params.zoneId),
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = await this.requestGraphql<FflogsCharacterSummaryResponse>(
|
||||||
|
`query QqbotFflogsCharacterSummary(
|
||||||
|
$characterName: String!
|
||||||
|
$serverSlug: String!
|
||||||
|
$serverRegion: String!
|
||||||
|
$zoneID: Int
|
||||||
|
$difficulty: Int
|
||||||
|
$metric: CharacterPageRankingMetricType
|
||||||
|
$partition: Int
|
||||||
|
$size: Int
|
||||||
|
$specName: String
|
||||||
|
$className: String
|
||||||
|
$role: RoleType
|
||||||
|
$timeframe: RankingTimeframeType
|
||||||
|
) {
|
||||||
|
characterData {
|
||||||
|
character(
|
||||||
|
name: $characterName
|
||||||
|
serverSlug: $serverSlug
|
||||||
|
serverRegion: $serverRegion
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
lodestoneID
|
||||||
|
name
|
||||||
|
server {
|
||||||
|
name
|
||||||
|
slug
|
||||||
|
}
|
||||||
|
zoneRankings(
|
||||||
|
zoneID: $zoneID
|
||||||
|
difficulty: $difficulty
|
||||||
|
metric: $metric
|
||||||
|
partition: $partition
|
||||||
|
size: $size
|
||||||
|
specName: $specName
|
||||||
|
className: $className
|
||||||
|
role: $role
|
||||||
|
timeframe: $timeframe
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
variables,
|
||||||
|
);
|
||||||
|
|
||||||
|
const character = data.characterData?.character;
|
||||||
|
if (!character) {
|
||||||
|
throw new Error(
|
||||||
|
`未找到 FFLogs 角色:${characterName} / ${serverRegion} / ${serverSlug}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rankingsPayload = this.normalizeJsonPayload(character.zoneRankings);
|
||||||
|
const rankings = this.pickRankings(rankingsPayload).slice(0, 5);
|
||||||
|
const allStarText = this.pickAllStarText(rankingsPayload);
|
||||||
|
const serverName = character.server?.name || serverSlug;
|
||||||
|
const url = this.buildCharacterUrl(
|
||||||
|
serverRegion,
|
||||||
|
character.server?.slug || serverSlug,
|
||||||
|
character.name || characterName,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
allStarText,
|
||||||
|
characterId: character.id,
|
||||||
|
characterName: character.name || characterName,
|
||||||
|
rankings,
|
||||||
|
replyText: this.buildReplyText({
|
||||||
|
allStarText,
|
||||||
|
characterId: character.id,
|
||||||
|
characterName: character.name || characterName,
|
||||||
|
rankings,
|
||||||
|
serverName,
|
||||||
|
serverRegion,
|
||||||
|
url,
|
||||||
|
}),
|
||||||
|
serverName,
|
||||||
|
serverRegion,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAccessToken() {
|
||||||
|
if (this.accessToken && Date.now() < this.accessTokenExpireAt) {
|
||||||
|
return this.accessToken;
|
||||||
|
}
|
||||||
|
if (!this.clientId || !this.clientSecret) {
|
||||||
|
throw new Error('未配置 FFLOGS_CLIENT_ID / FFLOGS_CLIENT_SECRET');
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = 'grant_type=client_credentials';
|
||||||
|
const auth = Buffer.from(`${this.clientId}:${this.clientSecret}`).toString(
|
||||||
|
'base64',
|
||||||
|
);
|
||||||
|
const data = await this.requestJson<FflogsTokenResponse>(
|
||||||
|
new URL(this.tokenUrl),
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
body,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Basic ${auth}`,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!data.access_token) throw new Error('FFLogs 未返回 access_token');
|
||||||
|
const expiresIn = Number(data.expires_in || 3600);
|
||||||
|
this.accessToken = data.access_token;
|
||||||
|
this.accessTokenExpireAt = Date.now() + Math.max(expiresIn - 60, 60) * 1000;
|
||||||
|
return this.accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestGraphql<T>(
|
||||||
|
query: string,
|
||||||
|
variables: Record<string, any>,
|
||||||
|
) {
|
||||||
|
const token = await this.getAccessToken();
|
||||||
|
const response = await this.requestJson<FflogsGraphqlResponse<T>>(
|
||||||
|
new URL(this.graphqlUrl),
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
variables: this.removeUndefined(variables),
|
||||||
|
}),
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (response.errors?.length) {
|
||||||
|
const message = response.errors
|
||||||
|
.map((item) => item.message)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('; ');
|
||||||
|
throw new Error(message || 'FFLogs GraphQL 查询失败');
|
||||||
|
}
|
||||||
|
if (!response.data) throw new Error('FFLogs GraphQL 未返回 data');
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestJson<T>(
|
||||||
|
url: URL,
|
||||||
|
method: HttpMethod,
|
||||||
|
options: { body?: string; headers?: Record<string, string> } = {},
|
||||||
|
) {
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const client = url.protocol === 'http:' ? http : https;
|
||||||
|
const request = client.request(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Accept: 'application/json',
|
||||||
|
'User-Agent': 'kt-template-online-api/qqbot',
|
||||||
|
...(options.headers || {}),
|
||||||
|
},
|
||||||
|
method,
|
||||||
|
timeout: this.getTimeoutMs(),
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
let body = '';
|
||||||
|
response.setEncoding('utf8');
|
||||||
|
response.on('data', (chunk) => {
|
||||||
|
body += chunk;
|
||||||
|
});
|
||||||
|
response.on('end', () => {
|
||||||
|
if ((response.statusCode || 500) >= 400) {
|
||||||
|
reject(new Error(`FFLogs 请求失败:${response.statusCode}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(body) as T);
|
||||||
|
} catch {
|
||||||
|
reject(new Error('FFLogs 返回不是合法 JSON'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
request.on('timeout', () => {
|
||||||
|
request.destroy(new Error('FFLogs 请求超时'));
|
||||||
|
});
|
||||||
|
request.on('error', reject);
|
||||||
|
if (options.body) request.write(options.body);
|
||||||
|
request.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildReplyText(params: {
|
||||||
|
allStarText?: string;
|
||||||
|
characterId?: number;
|
||||||
|
characterName: string;
|
||||||
|
rankings: FflogsRankingItem[];
|
||||||
|
serverName: string;
|
||||||
|
serverRegion: string;
|
||||||
|
url: string;
|
||||||
|
}) {
|
||||||
|
const header = `FFLogs:${params.characterName} - ${params.serverName} (${params.serverRegion})`;
|
||||||
|
const idText = params.characterId ? `角色 ID:${params.characterId}` : '';
|
||||||
|
const rankingText = params.rankings.length
|
||||||
|
? params.rankings
|
||||||
|
.map((item, index) => this.formatRanking(item, index))
|
||||||
|
.join('\n')
|
||||||
|
: '暂无公开排名数据';
|
||||||
|
return [header, idText, params.allStarText, rankingText, params.url]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatRanking(item: FflogsRankingItem, index: number) {
|
||||||
|
const encounter = this.pickText(
|
||||||
|
item.encounter?.name,
|
||||||
|
item.encounterName,
|
||||||
|
item.name,
|
||||||
|
`记录 ${index + 1}`,
|
||||||
|
);
|
||||||
|
const percent = this.pickNumber(
|
||||||
|
item.rankPercent,
|
||||||
|
item.percentile,
|
||||||
|
item.bestPercent,
|
||||||
|
item.historicalPercent,
|
||||||
|
);
|
||||||
|
const amount = this.pickNumber(item.bestAmount, item.amount, item.total);
|
||||||
|
const spec = this.pickText(item.spec, item.specName, item.class, item.role);
|
||||||
|
const rank = this.pickText(item.rank, item.regionRank, item.serverRank);
|
||||||
|
const parts = [
|
||||||
|
`${index + 1}. ${encounter}`,
|
||||||
|
percent !== undefined ? `${this.formatNumber(percent)}%` : '',
|
||||||
|
amount !== undefined ? this.formatNumber(amount) : '',
|
||||||
|
spec,
|
||||||
|
rank ? `Rank ${rank}` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.join(' / ');
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickRankings(payload: any): FflogsRankingItem[] {
|
||||||
|
const raw = Array.isArray(payload)
|
||||||
|
? payload
|
||||||
|
: Array.isArray(payload?.rankings)
|
||||||
|
? payload.rankings
|
||||||
|
: Array.isArray(payload?.encounters)
|
||||||
|
? payload.encounters
|
||||||
|
: [];
|
||||||
|
return raw
|
||||||
|
.filter((item) => item && typeof item === 'object')
|
||||||
|
.sort((a, b) => {
|
||||||
|
const ap = this.pickNumber(a.rankPercent, a.percentile) || 0;
|
||||||
|
const bp = this.pickNumber(b.rankPercent, b.percentile) || 0;
|
||||||
|
return bp - ap;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickAllStarText(payload: any) {
|
||||||
|
const allStars = Array.isArray(payload?.allStars)
|
||||||
|
? payload.allStars[0]
|
||||||
|
: payload?.allStars;
|
||||||
|
if (!allStars || typeof allStars !== 'object') return undefined;
|
||||||
|
const points = this.pickNumber(allStars.points, allStars.score);
|
||||||
|
const rank = this.pickText(
|
||||||
|
allStars.rank,
|
||||||
|
allStars.regionRank,
|
||||||
|
allStars.serverRank,
|
||||||
|
);
|
||||||
|
const parts = [
|
||||||
|
points !== undefined ? `全明星分:${this.formatNumber(points)}` : '',
|
||||||
|
rank ? `名次:${rank}` : '',
|
||||||
|
].filter(Boolean);
|
||||||
|
return parts.length ? parts.join(' / ') : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeJsonPayload(value: unknown) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildCharacterUrl(
|
||||||
|
serverRegion: string,
|
||||||
|
serverSlug: string,
|
||||||
|
characterName: string,
|
||||||
|
) {
|
||||||
|
return `${this.webBaseUrl}/character/${encodeURIComponent(
|
||||||
|
serverRegion.toLowerCase(),
|
||||||
|
)}/${encodeURIComponent(serverSlug)}/${encodeURIComponent(characterName)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeMetric(value?: string) {
|
||||||
|
const raw = `${value || ''}`.trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
cdps: 'DPS',
|
||||||
|
damage: 'DPS',
|
||||||
|
dps: 'DPS',
|
||||||
|
healer: 'HPS',
|
||||||
|
healing: 'HPS',
|
||||||
|
hps: 'HPS',
|
||||||
|
};
|
||||||
|
return map[lower] || raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeRole(value?: string) {
|
||||||
|
const raw = `${value || ''}`.trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
dps: 'DPS',
|
||||||
|
healer: 'Healer',
|
||||||
|
tank: 'Tank',
|
||||||
|
治疗: 'Healer',
|
||||||
|
输出: 'DPS',
|
||||||
|
坦克: 'Tank',
|
||||||
|
};
|
||||||
|
return map[lower] || raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeTimeframe(value?: string) {
|
||||||
|
const raw = `${value || ''}`.trim();
|
||||||
|
if (!raw) return undefined;
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
if (['today', 'current', '当前', '今天'].includes(lower)) return 'Today';
|
||||||
|
if (['historical', 'history', '历史'].includes(lower)) return 'Historical';
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toOptionalNumber(value?: number | string) {
|
||||||
|
if (value === undefined || value === null || `${value}`.trim() === '') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeOptionalString(value?: string) {
|
||||||
|
const raw = `${value || ''}`.trim();
|
||||||
|
return raw || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickText(...values: any[]) {
|
||||||
|
const picked = values.find(
|
||||||
|
(item) => item !== undefined && item !== null && `${item}`.trim() !== '',
|
||||||
|
);
|
||||||
|
return picked === undefined ? '' : `${picked}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private pickNumber(...values: any[]) {
|
||||||
|
for (const value of values) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
private formatNumber(value: number) {
|
||||||
|
const digits = Math.abs(value) >= 100 ? 0 : 1;
|
||||||
|
return value.toLocaleString('en-US', {
|
||||||
|
maximumFractionDigits: digits,
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeUndefined(input: Record<string, any>) {
|
||||||
|
return Object.entries(input).reduce<Record<string, any>>(
|
||||||
|
(result, [key, value]) => {
|
||||||
|
if (value !== undefined && value !== '') result[key] = value;
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeBaseUrl(value: string) {
|
||||||
|
return value.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultServer() {
|
||||||
|
return this.configService.get<string>('FFLOGS_DEFAULT_SERVER') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultServerRegion() {
|
||||||
|
return (
|
||||||
|
this.configService.get<string>('FFLOGS_DEFAULT_SERVER_REGION') || 'CN'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getTimeoutMs() {
|
||||||
|
return Number(
|
||||||
|
this.configService.get('FFLOGS_REQUEST_TIMEOUT_MS') || 10_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts
Normal file
72
src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import type { QqbotIntegrationPlugin } from '../../plugin/qqbot-plugin.types';
|
||||||
|
import { QqbotFflogsClientService } from './qqbot-fflogs-client.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QqbotFflogsPluginService {
|
||||||
|
constructor(private readonly fflogsClientService: QqbotFflogsClientService) {}
|
||||||
|
|
||||||
|
getPlugin(): QqbotIntegrationPlugin {
|
||||||
|
return {
|
||||||
|
description: '对接 FFLogs v2 GraphQL,提供 FF14 角色公开排名查询能力。',
|
||||||
|
healthCheck: async () => {
|
||||||
|
const checkedAt = new Date().toISOString();
|
||||||
|
try {
|
||||||
|
await this.fflogsClientService.checkHealth();
|
||||||
|
return {
|
||||||
|
checkedAt,
|
||||||
|
message: 'FFLogs 插件可用',
|
||||||
|
status: 'healthy',
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
checkedAt,
|
||||||
|
message: err instanceof Error ? err.message : 'FFLogs 插件不可用',
|
||||||
|
status: 'degraded',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
key: 'fflogs',
|
||||||
|
name: 'FFLogs 查询',
|
||||||
|
operations: [
|
||||||
|
{
|
||||||
|
cacheTtlMs: 60_000,
|
||||||
|
description: '查询指定角色的 FFLogs 公开排名摘要。',
|
||||||
|
inputSchema: {
|
||||||
|
properties: {
|
||||||
|
characterName: { description: '角色名', type: 'string' },
|
||||||
|
metric: { description: '排名指标,如 dps/hps', type: 'string' },
|
||||||
|
serverRegion: {
|
||||||
|
default: 'CN',
|
||||||
|
description: '服务器地区,如 CN/JP/NA/EU',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
serverSlug: { description: '服务器名或 slug', type: 'string' },
|
||||||
|
timeframe: {
|
||||||
|
description: 'Today 或 Historical',
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
zoneId: { description: '副本区域 ID', type: 'number' },
|
||||||
|
},
|
||||||
|
required: ['characterName', 'serverSlug'],
|
||||||
|
type: 'object',
|
||||||
|
},
|
||||||
|
key: 'fflogs.character.summary',
|
||||||
|
name: '角色排名摘要',
|
||||||
|
outputSchema: {
|
||||||
|
properties: {
|
||||||
|
characterName: { type: 'string' },
|
||||||
|
rankings: { type: 'array' },
|
||||||
|
replyText: { type: 'string' },
|
||||||
|
url: { type: 'string' },
|
||||||
|
},
|
||||||
|
type: 'object',
|
||||||
|
},
|
||||||
|
execute: async (input) =>
|
||||||
|
await this.fflogsClientService.getCharacterSummary(input),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
version: '1.0.0',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -39,6 +39,8 @@ import { QqbotPluginController } from './plugin/qqbot-plugin.controller';
|
|||||||
import { QqbotPluginRegistryService } from './plugin/qqbot-plugin-registry.service';
|
import { QqbotPluginRegistryService } from './plugin/qqbot-plugin-registry.service';
|
||||||
import { QqbotFf14ClientService } from './plugins/ff14Market/qqbot-ff14-client.service';
|
import { QqbotFf14ClientService } from './plugins/ff14Market/qqbot-ff14-client.service';
|
||||||
import { QqbotFf14MarketPluginService } from './plugins/ff14Market/qqbot-ff14-market.plugin';
|
import { QqbotFf14MarketPluginService } from './plugins/ff14Market/qqbot-ff14-market.plugin';
|
||||||
|
import { QqbotFflogsClientService } from './plugins/fflogs/qqbot-fflogs-client.service';
|
||||||
|
import { QqbotFflogsPluginService } from './plugins/fflogs/qqbot-fflogs.plugin';
|
||||||
import { QqbotRepeaterPluginService } from './plugins/repeater/qqbot-repeater.plugin';
|
import { QqbotRepeaterPluginService } from './plugins/repeater/qqbot-repeater.plugin';
|
||||||
import { QqbotRuleController } from './rule/qqbot-rule.controller';
|
import { QqbotRuleController } from './rule/qqbot-rule.controller';
|
||||||
import { QqbotRule } from './rule/qqbot-rule.entity';
|
import { QqbotRule } from './rule/qqbot-rule.entity';
|
||||||
@ -92,6 +94,8 @@ import { QqbotSendService } from './send/qqbot-send.service';
|
|||||||
QqbotEventService,
|
QqbotEventService,
|
||||||
QqbotFf14ClientService,
|
QqbotFf14ClientService,
|
||||||
QqbotFf14MarketPluginService,
|
QqbotFf14MarketPluginService,
|
||||||
|
QqbotFflogsClientService,
|
||||||
|
QqbotFflogsPluginService,
|
||||||
QqbotMessageService,
|
QqbotMessageService,
|
||||||
QqbotNapcatLoginService,
|
QqbotNapcatLoginService,
|
||||||
QqbotNapcatContainerService,
|
QqbotNapcatContainerService,
|
||||||
|
|||||||
@ -24,7 +24,7 @@ export type QqbotRuleMatchType = 'equals' | 'keyword' | 'regex';
|
|||||||
|
|
||||||
export type QqbotRuleTargetType = 'all' | 'channel' | 'group' | 'private';
|
export type QqbotRuleTargetType = 'all' | 'channel' | 'group' | 'private';
|
||||||
|
|
||||||
export type QqbotCommandParserType = 'ff14Price' | 'plain';
|
export type QqbotCommandParserType = 'ff14Price' | 'fflogsCharacter' | 'plain';
|
||||||
|
|
||||||
export type QqbotPluginHealthStatus = 'degraded' | 'healthy' | 'offline';
|
export type QqbotPluginHealthStatus = 'degraded' | 'healthy' | 'offline';
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user