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_DEFAULT_WORLD=中国
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
agent { label 'kt-node-agent' }
|
||||
|
||||
@ -283,7 +322,12 @@ pipeline {
|
||||
echo "K8s manifest file not found: ${manifestFile}"
|
||||
exit 1
|
||||
fi
|
||||
""".stripIndent())
|
||||
|
||||
runCmd(buildEnvFileValidationScript(containerEnvFile))
|
||||
|
||||
runCmd("""
|
||||
set -e
|
||||
kubectl ${kubeConfigArg} get namespace ${shellQuote(namespace)} >/dev/null
|
||||
kubectl ${kubeConfigArg} ${namespaceArg} create secret generic ${shellQuote(envSecret)} \\
|
||||
--from-env-file=${shellQuote(containerEnvFile)} \\
|
||||
@ -334,7 +378,12 @@ pipeline {
|
||||
echo "/home/jenkins/agent/env/kt-template-online-api/.env.production"
|
||||
exit 1
|
||||
fi
|
||||
""".stripIndent())
|
||||
|
||||
runCmd(buildEnvFileValidationScript(containerEnvFile))
|
||||
|
||||
runCmd("""
|
||||
set -e
|
||||
docker rm -f '${containerName}' >/dev/null 2>&1 || true
|
||||
docker run -d \\
|
||||
--name '${containerName}' \\
|
||||
|
||||
@ -3,6 +3,13 @@ FROM node:22-bookworm-slim
|
||||
WORKDIR /app
|
||||
|
||||
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 ./
|
||||
|
||||
|
||||
@ -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`)
|
||||
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
|
||||
`name` = VALUES(`name`),
|
||||
`plugin_key` = VALUES(`plugin_key`),
|
||||
|
||||
@ -806,9 +806,9 @@ function qqbotAccountExample() {
|
||||
function qqbotCommandExample() {
|
||||
return {
|
||||
id: '1000000000000000001',
|
||||
name: 'FF14 查价',
|
||||
command: '/price',
|
||||
pluginKey: 'ff14Market',
|
||||
name: 'FFLogs 查询',
|
||||
command: '/fflogs 角色名 服务器',
|
||||
pluginKey: 'fflogs',
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
@ -859,10 +859,10 @@ function qqbotPermissionExample() {
|
||||
|
||||
function qqbotPluginExample() {
|
||||
return {
|
||||
key: 'ff14Market',
|
||||
name: 'FF14 查价',
|
||||
key: 'fflogs',
|
||||
name: 'FFLogs 查询',
|
||||
triggerMode: 'command',
|
||||
description: '查询 FF14 市场价格',
|
||||
description: '查询 FFLogs 角色公开排名',
|
||||
};
|
||||
}
|
||||
|
||||
@ -904,8 +904,8 @@ function permissionConfigExample() {
|
||||
|
||||
function pluginHealthExample() {
|
||||
return {
|
||||
key: 'ff14Market',
|
||||
name: 'FF14 查价',
|
||||
key: 'fflogs',
|
||||
name: 'FFLogs 查询',
|
||||
available: true,
|
||||
message: '插件可用',
|
||||
};
|
||||
|
||||
@ -61,6 +61,9 @@ export class QqbotCommandParserService {
|
||||
if (command.parserKey === 'ff14Price') {
|
||||
return this.parseFf14PriceInput(rawArgs);
|
||||
}
|
||||
if (command.parserKey === 'fflogsCharacter') {
|
||||
return this.parseFflogsCharacterInput(rawArgs);
|
||||
}
|
||||
const args = rawArgs ? rawArgs.split(/\s+/).filter(Boolean) : [];
|
||||
return {
|
||||
args,
|
||||
@ -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[]) {
|
||||
const last = positional[positional.length - 1];
|
||||
if (!isQqbotFf14LocationName(last)) return null;
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Injectable, OnModuleInit } from '@nestjs/common';
|
||||
import { throwVbenError } from '@/common';
|
||||
import { QqbotFf14MarketPluginService } from '../plugins/ff14Market/qqbot-ff14-market.plugin';
|
||||
import { QqbotFflogsPluginService } from '../plugins/fflogs/qqbot-fflogs.plugin';
|
||||
import type {
|
||||
QqbotIntegrationPlugin,
|
||||
QqbotPluginHealth,
|
||||
@ -13,10 +14,14 @@ import type {
|
||||
export class QqbotPluginRegistryService implements OnModuleInit {
|
||||
private readonly plugins = new Map<string, QqbotIntegrationPlugin>();
|
||||
|
||||
constructor(private readonly ff14MarketPlugin: QqbotFf14MarketPluginService) {}
|
||||
constructor(
|
||||
private readonly ff14MarketPlugin: QqbotFf14MarketPluginService,
|
||||
private readonly fflogsPlugin: QqbotFflogsPluginService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
this.register(this.ff14MarketPlugin.getPlugin());
|
||||
this.register(this.fflogsPlugin.getPlugin());
|
||||
}
|
||||
|
||||
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 { QqbotFf14ClientService } from './plugins/ff14Market/qqbot-ff14-client.service';
|
||||
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 { QqbotRuleController } from './rule/qqbot-rule.controller';
|
||||
import { QqbotRule } from './rule/qqbot-rule.entity';
|
||||
@ -92,6 +94,8 @@ import { QqbotSendService } from './send/qqbot-send.service';
|
||||
QqbotEventService,
|
||||
QqbotFf14ClientService,
|
||||
QqbotFf14MarketPluginService,
|
||||
QqbotFflogsClientService,
|
||||
QqbotFflogsPluginService,
|
||||
QqbotMessageService,
|
||||
QqbotNapcatLoginService,
|
||||
QqbotNapcatContainerService,
|
||||
|
||||
@ -24,7 +24,7 @@ export type QqbotRuleMatchType = 'equals' | 'keyword' | 'regex';
|
||||
|
||||
export type QqbotRuleTargetType = 'all' | 'channel' | 'group' | 'private';
|
||||
|
||||
export type QqbotCommandParserType = 'ff14Price' | 'plain';
|
||||
export type QqbotCommandParserType = 'ff14Price' | 'fflogsCharacter' | 'plain';
|
||||
|
||||
export type QqbotPluginHealthStatus = 'degraded' | 'healthy' | 'offline';
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user