From c73084b4d1f2b40ec62e8bb598a869e6f9cb5a7a Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 3 Jun 2026 20:41:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8E=A5=E5=85=A5QQBot=20FFLogs?= =?UTF-8?q?=E6=9F=A5=E8=AF=A2=E5=91=BD=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 10 + Jenkinsfile | 49 ++ dockerfile | 7 + sql/qqbot-init.sql | 3 +- src/common/swagger/swagger-response.ts | 16 +- .../command/qqbot-command-parser.service.ts | 78 +++ .../plugin/qqbot-plugin-registry.service.ts | 7 +- .../fflogs/qqbot-fflogs-client.service.ts | 533 ++++++++++++++++++ .../plugins/fflogs/qqbot-fflogs.plugin.ts | 72 +++ src/qqbot/qqbot.module.ts | 4 + src/qqbot/qqbot.types.ts | 2 +- 11 files changed, 770 insertions(+), 11 deletions(-) create mode 100644 src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts create mode 100644 src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts diff --git a/.env.example b/.env.example index 7b7ec55..6b816f6 100644 --- a/.env.example +++ b/.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 diff --git a/Jenkinsfile b/Jenkinsfile index 2b00591..77d24b5 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -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}' \\ diff --git a/dockerfile b/dockerfile index 195c0f4..342eee8 100644 --- a/dockerfile +++ b/dockerfile @@ -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 ./ diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index e0061e9..7d501f7 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -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`), diff --git a/src/common/swagger/swagger-response.ts b/src/common/swagger/swagger-response.ts index 83e87d9..4783f8c 100644 --- a/src/common/swagger/swagger-response.ts +++ b/src/common/swagger/swagger-response.ts @@ -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: '插件可用', }; diff --git a/src/qqbot/command/qqbot-command-parser.service.ts b/src/qqbot/command/qqbot-command-parser.service.ts index a14a2cb..6cc8d9b 100644 --- a/src/qqbot/command/qqbot-command-parser.service.ts +++ b/src/qqbot/command/qqbot-command-parser.service.ts @@ -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(); + 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; diff --git a/src/qqbot/plugin/qqbot-plugin-registry.service.ts b/src/qqbot/plugin/qqbot-plugin-registry.service.ts index 484176f..cc5dfa4 100644 --- a/src/qqbot/plugin/qqbot-plugin-registry.service.ts +++ b/src/qqbot/plugin/qqbot-plugin-registry.service.ts @@ -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(); - 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) { diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts new file mode 100644 index 0000000..48b1503 --- /dev/null +++ b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts @@ -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 = { + 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; + +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('FFLOGS_BASE_URL') || + 'https://www.fflogs.com', + ); + this.webBaseUrl = this.normalizeBaseUrl( + this.configService.get('FFLOGS_WEB_BASE_URL') || + 'https://cn.fflogs.com', + ); + this.graphqlUrl = + this.configService.get('FFLOGS_GRAPHQL_URL') || + `${this.baseUrl}/api/v2/client`; + this.tokenUrl = + this.configService.get('FFLOGS_TOKEN_URL') || + `${this.baseUrl}/oauth/token`; + this.clientId = `${ + this.configService.get('FFLOGS_CLIENT_ID') || '' + }`.trim(); + this.clientSecret = `${ + this.configService.get('FFLOGS_CLIENT_SECRET') || '' + }`.trim(); + } + + async checkHealth() { + await this.getAccessToken(); + return true; + } + + async getCharacterSummary( + params: QqbotFflogsCharacterSummaryInput, + ): Promise { + 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( + `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( + 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( + query: string, + variables: Record, + ) { + const token = await this.getAccessToken(); + const response = await this.requestJson>( + 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( + url: URL, + method: HttpMethod, + options: { body?: string; headers?: Record } = {}, + ) { + return new Promise((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 = { + 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 = { + 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) { + return Object.entries(input).reduce>( + (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('FFLOGS_DEFAULT_SERVER') || ''; + } + + private getDefaultServerRegion() { + return ( + this.configService.get('FFLOGS_DEFAULT_SERVER_REGION') || 'CN' + ); + } + + private getTimeoutMs() { + return Number( + this.configService.get('FFLOGS_REQUEST_TIMEOUT_MS') || 10_000, + ); + } +} diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts b/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts new file mode 100644 index 0000000..0fc20cb --- /dev/null +++ b/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts @@ -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', + }; + } +} diff --git a/src/qqbot/qqbot.module.ts b/src/qqbot/qqbot.module.ts index 1891162..fcaffdc 100644 --- a/src/qqbot/qqbot.module.ts +++ b/src/qqbot/qqbot.module.ts @@ -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, diff --git a/src/qqbot/qqbot.types.ts b/src/qqbot/qqbot.types.ts index 2394bd5..059f44f 100644 --- a/src/qqbot/qqbot.types.ts +++ b/src/qqbot/qqbot.types.ts @@ -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';