feat: 接入QQBot FFLogs查询命令

This commit is contained in:
sunlei 2026-06-03 20:41:39 +08:00
parent e60e98bf49
commit c73084b4d1
11 changed files with 770 additions and 11 deletions

View File

@ -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
View File

@ -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}' \\

View File

@ -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 ./

View File

@ -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`),

View File

@ -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: '插件可用',
};

View File

@ -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;

View File

@ -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) {

View 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,
);
}
}

View 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',
};
}
}

View File

@ -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,

View File

@ -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';