feat(api): 完善字典与QQBot能力

This commit is contained in:
sunlei 2026-06-04 03:13:39 +08:00
parent a9bc963442
commit 3720819b96
15 changed files with 1456 additions and 57 deletions

View File

@ -516,7 +516,7 @@ 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 角色名 服务器') (2041700000000300502, 'fflogs_character', 'FFLogs 查询', '["fflogs","logs","查logs","查log"]', '["/","!",""]', 'fflogs', 'fflogs.character.summary', 'fflogsCharacter', 'all', '{"serverRegion":"CN"}', '', 'FFLogs 查询失败:{{error}}', 1, 0, 3000, '查询 FFLogs 角色公开排名;带高难任务时返回最近10次记录格式:/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`),
@ -579,6 +579,7 @@ VALUES
(2041700000000300805, 'FFLOGS_METRIC_LABEL', 'nDPS', 'ndps', NULL, 5, 1), (2041700000000300805, 'FFLOGS_METRIC_LABEL', 'nDPS', 'ndps', NULL, 5, 1),
(2041700000000300806, 'FFLOGS_METRIC_LABEL', 'Boss DPS', 'bossdps', NULL, 6, 1), (2041700000000300806, 'FFLOGS_METRIC_LABEL', 'Boss DPS', 'bossdps', NULL, 6, 1),
(2041700000000300807, 'FFLOGS_METRIC_LABEL', 'Boss rDPS', 'bossrdps', NULL, 7, 1), (2041700000000300807, 'FFLOGS_METRIC_LABEL', 'Boss rDPS', 'bossrdps', NULL, 7, 1),
(2041700000000300808, 'FFLOGS_METRIC_LABEL', 'aDPS', 'cdps', NULL, 8, 1),
(2041700000000300901, 'FFLOGS_ROLE_LABEL', '坦克', 'tank', NULL, 1, 1), (2041700000000300901, 'FFLOGS_ROLE_LABEL', '坦克', 'tank', NULL, 1, 1),
(2041700000000300902, 'FFLOGS_ROLE_LABEL', '治疗', 'healer', NULL, 2, 1), (2041700000000300902, 'FFLOGS_ROLE_LABEL', '治疗', 'healer', NULL, 2, 1),
(2041700000000300903, 'FFLOGS_ROLE_LABEL', '输出', 'dps', NULL, 3, 1), (2041700000000300903, 'FFLOGS_ROLE_LABEL', '输出', 'dps', NULL, 3, 1),

View File

@ -22,6 +22,7 @@ import {
import { import {
AdminDictBodyDto, AdminDictBodyDto,
AdminDictDto, AdminDictDto,
AdminDictGroupDto,
AdminDictQueryDto, AdminDictQueryDto,
AdminDictTreeDto, AdminDictTreeDto,
AdminDictUpdateDto, AdminDictUpdateDto,
@ -104,6 +105,22 @@ export class DictController {
return vbenSuccess(await this.dictService.tree(query)); return vbenSuccess(await this.dictService.tree(query));
} }
@ApiOperation({ summary: '获取字典编码分组列表' })
@ApiPageResponse(AdminDictGroupDto, [
{
dictCode: 'COMPONENT_TYPE',
id: 'dict-code:COMPONENT_TYPE',
itemCount: 2,
label: 'COMPONENT_TYPE',
value: 'COMPONENT_TYPE',
},
])
@Get('groups')
async groups(@Query() query: AdminDictQueryDto) {
const page = await this.dictService.groups(query);
return vbenPage(page.items, page.total);
}
@ApiOperation({ summary: '获取字典编码选项' }) @ApiOperation({ summary: '获取字典编码选项' })
@Get('codes') @Get('codes')
async codes() { async codes() {

View File

@ -78,6 +78,33 @@ export class AdminDictTreeDto extends AdminDictDto {
children?: AdminDictTreeDto[]; children?: AdminDictTreeDto[];
} }
export class AdminDictGroupDto {
@ApiProperty({
example: 'COMPONENT_TYPE',
})
dictCode: string;
@ApiProperty({
example: 'dict-code:COMPONENT_TYPE',
})
id: string;
@ApiProperty({
example: 2,
})
itemCount: number;
@ApiProperty({
example: 'COMPONENT_TYPE',
})
label: string;
@ApiProperty({
example: 'COMPONENT_TYPE',
})
value: string;
}
export class AdminDictQueryDto { export class AdminDictQueryDto {
@ApiPropertyOptional() @ApiPropertyOptional()
page?: number | string; page?: number | string;

View File

@ -0,0 +1,128 @@
jest.mock(
'@/common',
() => ({
setDictDecodeCache: jest.fn(),
throwVbenError: (message: string) => {
throw new Error(message);
},
}),
{ virtual: true },
);
import { DictService } from './dict.service';
describe('DictService', () => {
const dictRows = [
{
childrenCode: null,
createTime: new Date('2026-01-01T00:00:00.000Z'),
dictCode: 'CHART',
id: 'chart-line',
label: 'Line',
sort: 1,
status: 1,
updateTime: new Date('2026-01-01T00:00:00.000Z'),
value: 'line',
},
{
childrenCode: 'CHART',
createTime: new Date('2026-01-01T00:00:00.000Z'),
dictCode: 'COMPONENT_TYPE',
id: 'component-type-chart',
label: 'Chart',
sort: 1,
status: 1,
updateTime: new Date('2026-01-01T00:00:00.000Z'),
value: 'chart',
},
{
childrenCode: null,
createTime: new Date('2026-01-01T00:00:00.000Z'),
dictCode: 'COMPONENT_TYPE',
id: 'component-type-basic',
label: 'Basic',
sort: 2,
status: 1,
updateTime: new Date('2026-01-01T00:00:00.000Z'),
value: 'basic',
},
];
function createService() {
return new DictService({
find: jest.fn().mockResolvedValue(dictRows),
} as any);
}
function createGroupService() {
const countBuilder = {
getRawOne: jest.fn().mockResolvedValue({ total: '2' }),
select: jest.fn().mockReturnThis(),
};
const builder = {
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
clone: jest.fn().mockReturnValue(countBuilder),
getRawMany: jest.fn().mockResolvedValue([
{ dictCode: 'CHART', itemCount: '1' },
{ dictCode: 'COMPONENT_TYPE', itemCount: '2' },
]),
groupBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
};
return {
builder,
service: new DictService({
createQueryBuilder: jest.fn().mockReturnValue(builder),
} as any),
};
}
it('paginates dict code groups without building relation tree', async () => {
const { builder, service } = createGroupService();
const page = await service.groups({
dictCode: 'COMPONENT_TYPE',
pageNo: 1,
pageSize: 10,
});
expect(builder.groupBy).toHaveBeenCalledWith('dict.dictCode');
expect(page).toEqual({
items: [
{
dictCode: 'CHART',
id: 'dict-code:CHART',
itemCount: 1,
label: 'CHART',
value: 'CHART',
},
{
dictCode: 'COMPONENT_TYPE',
id: 'dict-code:COMPONENT_TYPE',
itemCount: 2,
label: 'COMPONENT_TYPE',
value: 'COMPONENT_TYPE',
},
],
total: 2,
});
});
it('keeps relation tree for childrenCode based business lookup', async () => {
const tree = await createService().relationTree({
dictCode: 'COMPONENT_TYPE',
});
expect(tree).toHaveLength(2);
expect(tree[0]).toMatchObject({
dictCode: 'COMPONENT_TYPE',
id: 'component-type-chart',
});
expect(tree[0].children?.map((item) => item.id)).toEqual(['chart-line']);
});
});

View File

@ -17,6 +17,14 @@ export type AdminDictItem = {
value: string; value: string;
}; };
export type AdminDictGroupItem = {
dictCode: string;
id: string;
itemCount: number;
label: string;
value: string;
};
type AdminDictSerialized = { type AdminDictSerialized = {
childrenCode?: string | null; childrenCode?: string | null;
createTime?: Date; createTime?: Date;
@ -123,7 +131,45 @@ export class DictService implements OnApplicationBootstrap {
})); }));
} }
async groups(query: AdminDictQueryDto = {}) {
const pageNo = this.toPositiveNumber(query.pageNo ?? query.page, 1);
const pageSize = this.toPositiveNumber(query.pageSize, 20);
const builder = this.dictRepository
.createQueryBuilder('dict')
.where('dict.isDeleted = :isDeleted', { isDeleted: false });
const keyword = this.normalizeText(query.keyword);
if (keyword) {
builder.andWhere('dict.dictCode LIKE :keyword', {
keyword: `%${keyword}%`,
});
}
this.applyLikeFilter(builder, 'dictCode', query.dictCode);
const totalRow = await builder
.clone()
.select('COUNT(DISTINCT dict.dictCode)', 'total')
.getRawOne<{ total: string }>();
const rows = await builder
.select('dict.dictCode', 'dictCode')
.addSelect('COUNT(dict.id)', 'itemCount')
.groupBy('dict.dictCode')
.orderBy('dict.dictCode', 'ASC')
.offset((pageNo - 1) * pageSize)
.limit(pageSize)
.getRawMany<{ dictCode: string; itemCount: string }>();
return {
items: rows.map((item) => this.serializeDictGroup(item)),
total: Number(totalRow?.total || 0),
};
}
async tree(query: AdminDictQueryDto = {}) { async tree(query: AdminDictQueryDto = {}) {
return this.relationTree(query);
}
async relationTree(query: AdminDictQueryDto = {}) {
const items = await this.dictRepository.find({ const items = await this.dictRepository.find({
where: { where: {
isDeleted: false, isDeleted: false,
@ -135,9 +181,9 @@ export class DictService implements OnApplicationBootstrap {
}, },
}); });
const serializedItems = items.map((item) => this.serializeDict(item)); const serializedItems = items.map((item) => this.serializeDict(item));
const visibleItems = this.filterTreeItems(serializedItems, query); const visibleItems = this.filterRelationTreeItems(serializedItems, query);
return this.buildDictTree(visibleItems); return this.buildDictRelationTree(visibleItems);
} }
async save(body: AdminDictBodyDto) { async save(body: AdminDictBodyDto) {
@ -308,7 +354,9 @@ export class DictService implements OnApplicationBootstrap {
}); });
} }
private buildDictTree(items: AdminDictSerialized[]): AdminDictTreeItem[] { private buildDictRelationTree(
items: AdminDictSerialized[],
): AdminDictTreeItem[] {
const byDictCode = this.groupItemsByDictCode(items); const byDictCode = this.groupItemsByDictCode(items);
const dictCodes = new Set(items.map((item) => item.dictCode)); const dictCodes = new Set(items.map((item) => item.dictCode));
const referencedCodes = new Set( const referencedCodes = new Set(
@ -365,7 +413,7 @@ export class DictService implements OnApplicationBootstrap {
return node; return node;
} }
private filterTreeItems( private filterRelationTreeItems(
items: AdminDictSerialized[], items: AdminDictSerialized[],
query: AdminDictQueryDto, query: AdminDictQueryDto,
) { ) {
@ -551,6 +599,19 @@ export class DictService implements OnApplicationBootstrap {
}; };
} }
private serializeDictGroup(item: {
dictCode: string;
itemCount: number | string;
}): AdminDictGroupItem {
return {
dictCode: item.dictCode,
id: `dict-code:${item.dictCode}`,
itemCount: Number(item.itemCount || 0),
label: item.dictCode,
value: item.dictCode,
};
}
private toPositiveNumber( private toPositiveNumber(
value: number | string | undefined, value: number | string | undefined,
fallback: number, fallback: number,

View File

@ -560,7 +560,11 @@ function getPropertyDescription(propertyName: string) {
code: '响应状态码', code: '响应状态码',
command: '命令触发词', command: '命令触发词',
commandId: '在线命令 ID', commandId: '在线命令 ID',
connectStatus: 'OneBot 账号在线状态',
connectionMode: '连接模式',
connectionRole: 'OneBot 连接角色', connectionRole: 'OneBot 连接角色',
containerName: 'NapCat 容器名称',
containerStatus: 'NapCat 容器运行状态',
count: '数量', count: '数量',
data: '业务数据', data: '业务数据',
description: '描述', description: '描述',
@ -573,7 +577,10 @@ function getPropertyDescription(propertyName: string) {
items: '列表数据', items: '列表数据',
key: '唯一键', key: '唯一键',
keyword: '匹配关键词', keyword: '匹配关键词',
lastConnectedAt: '最后连接时间',
lastError: '最近异常',
lastHeartbeatAt: '最后心跳时间', lastHeartbeatAt: '最后心跳时间',
lastLoginAt: '最近扫码登录时间',
lastMessage: '最后一条消息', lastMessage: '最后一条消息',
lastModified: '最后修改时间', lastModified: '最后修改时间',
matchType: '匹配方式', matchType: '匹配方式',
@ -582,6 +589,7 @@ function getPropertyDescription(propertyName: string) {
mode: '过滤模式', mode: '过滤模式',
msg: '响应消息', msg: '响应消息',
name: '名称', name: '名称',
napcat: 'NapCat 容器运行信息',
nickname: '昵称', nickname: '昵称',
objectName: '对象名称', objectName: '对象名称',
onlineAccountCount: '在线账号数', onlineAccountCount: '在线账号数',
@ -608,6 +616,7 @@ function getPropertyDescription(propertyName: string) {
todaySendCount: '今日发送数', todaySendCount: '今日发送数',
total: '总条数', total: '总条数',
triggerMode: '触发方式', triggerMode: '触发方式',
webuiPort: 'NapCat WebUI 端口',
type: '类型', type: '类型',
url: '访问地址', url: '访问地址',
userId: '用户 QQ 号', userId: '用户 QQ 号',
@ -809,12 +818,20 @@ function componentExample() {
function qqbotAccountExample() { function qqbotAccountExample() {
return { return {
connectStatus: 'online',
connectionMode: 'reverse-ws',
enabled: true,
id: '1000000000000000001', id: '1000000000000000001',
selfId: '1914728559',
nickname: 'Mirror',
status: 'online',
connectionRole: 'universal',
lastHeartbeatAt: '2026-06-02T12:00:00.000Z', lastHeartbeatAt: '2026-06-02T12:00:00.000Z',
name: '主账号',
napcat: {
bindStatus: 'bound',
containerName: 'kt-qqbot-napcat-1914728559',
containerStatus: 'running',
lastLoginAt: '2026-06-02T11:55:00.000Z',
webuiPort: 6100,
},
selfId: '1914728559',
}; };
} }

View File

@ -12,10 +12,32 @@ import type {
QqbotAccountQueryDto, QqbotAccountQueryDto,
QqbotAccountUpdateDto, QqbotAccountUpdateDto,
} from './qqbot-account.dto'; } from './qqbot-account.dto';
import { QqbotAccountNapcat } from '../napcat/qqbot-account-napcat.entity';
import { QqbotNapcatContainer } from '../napcat/qqbot-napcat-container.entity';
import { QqbotNapcatContainerService } from '../napcat/qqbot-napcat-container.service'; import { QqbotNapcatContainerService } from '../napcat/qqbot-napcat-container.service';
import type { QqbotConnectionRole } from '../qqbot.types'; import type {
QqbotAccountNapcatBindStatus,
QqbotConnectionRole,
QqbotNapcatContainerStatus,
} from '../qqbot.types';
import { getPageParams, normalizeNullableString } from '../qqbot.utils'; import { getPageParams, normalizeNullableString } from '../qqbot.utils';
export type QqbotAccountNapcatRuntimeInfo = {
bindStatus?: QqbotAccountNapcatBindStatus;
containerId?: string;
containerName?: string;
containerStatus?: QqbotNapcatContainerStatus;
lastCheckedAt?: Date | null;
lastError?: null | string;
lastLoginAt?: Date | null;
lastStartedAt?: Date | null;
webuiPort?: null | number;
};
export type QqbotAccountListItem = QqbotAccount & {
napcat?: null | QqbotAccountNapcatRuntimeInfo;
};
@Injectable() @Injectable()
export class QqbotAccountService { export class QqbotAccountService {
constructor( constructor(
@ -23,6 +45,10 @@ export class QqbotAccountService {
private readonly accountRepository: Repository<QqbotAccount>, private readonly accountRepository: Repository<QqbotAccount>,
@InjectRepository(QqbotAccountAbility) @InjectRepository(QqbotAccountAbility)
private readonly accountAbilityRepository: Repository<QqbotAccountAbility>, private readonly accountAbilityRepository: Repository<QqbotAccountAbility>,
@InjectRepository(QqbotAccountNapcat)
private readonly accountNapcatRepository: Repository<QqbotAccountNapcat>,
@InjectRepository(QqbotNapcatContainer)
private readonly napcatContainerRepository: Repository<QqbotNapcatContainer>,
private readonly napcatContainerService: QqbotNapcatContainerService, private readonly napcatContainerService: QqbotNapcatContainerService,
) {} ) {}
@ -46,11 +72,12 @@ export class QqbotAccountService {
}); });
} }
const [list, total] = await builder const [accounts, total] = await builder
.orderBy('account.createTime', 'DESC') .orderBy('account.createTime', 'DESC')
.skip(skip) .skip(skip)
.take(pageSize) .take(pageSize)
.getManyAndCount(); .getManyAndCount();
const list = await this.appendNapcatRuntime(accounts);
return { list, pageNo, pageSize, total }; return { list, pageNo, pageSize, total };
} }
@ -325,6 +352,64 @@ export class QqbotAccountService {
); );
} }
private async appendNapcatRuntime(
accounts: QqbotAccount[],
): Promise<QqbotAccountListItem[]> {
if (accounts.length <= 0) return [];
const accountIds = accounts.map((account) => account.id);
const bindings = await this.accountNapcatRepository
.createQueryBuilder('binding')
.where('binding.accountId IN (:...accountIds)', { accountIds })
.andWhere('binding.isDeleted = :isDeleted', { isDeleted: false })
.orderBy('binding.isPrimary', 'DESC')
.addOrderBy('binding.updateTime', 'DESC')
.getMany();
const bindingMap = new Map<string, QqbotAccountNapcat>();
for (const binding of bindings) {
if (!bindingMap.has(binding.accountId)) {
bindingMap.set(binding.accountId, binding);
}
}
const containerIds = Array.from(
new Set(bindings.map((binding) => binding.containerId).filter(Boolean)),
);
const containerMap = new Map<string, QqbotNapcatContainer>();
if (containerIds.length > 0) {
const containers = await this.napcatContainerRepository
.createQueryBuilder('container')
.where('container.id IN (:...containerIds)', { containerIds })
.andWhere('container.isDeleted = :isDeleted', { isDeleted: false })
.getMany();
for (const container of containers) {
containerMap.set(container.id, container);
}
}
return accounts.map((account) => {
const binding = bindingMap.get(account.id);
if (!binding) {
return Object.assign(account, { napcat: null });
}
const container = containerMap.get(binding.containerId);
return Object.assign(account, {
napcat: {
bindStatus: binding.bindStatus,
containerId: binding.containerId,
containerName: container?.name,
containerStatus: container?.status,
lastCheckedAt: container?.lastCheckedAt,
lastError: container?.lastError,
lastLoginAt: binding.lastLoginAt,
lastStartedAt: container?.lastStartedAt,
webuiPort: container?.webuiPort,
},
});
});
}
private async assertSelfIdAvailable(selfId: string, id?: string) { private async assertSelfIdAvailable(selfId: string, id?: string) {
const exists = await this.accountRepository.findOne({ const exists = await this.accountRepository.findOne({
where: { where: {

View File

@ -0,0 +1,82 @@
jest.mock(
'@/common',
() => ({
ensureSnowflakeId: jest.fn(),
setDictDecodeCache: jest.fn(),
throwVbenError: (message: string) => {
throw new Error(message);
},
}),
{ virtual: true },
);
import { ConfigService } from '@nestjs/config';
import { QqbotNapcatContainerService } from '../napcat/qqbot-napcat-container.service';
import { QqbotAccountService } from './qqbot-account.service';
import { QqbotNapcatLoginService } from './qqbot-napcat-login.service';
describe('QqbotNapcatLoginService', () => {
const service = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService,
{} as QqbotAccountService,
{} as QqbotNapcatContainerService,
);
beforeEach(() => {
(service as any).sessions.clear();
jest.restoreAllMocks();
});
it('uses qrcode returned by NapCat refresh endpoint', async () => {
(service as any).sessions.set('session-1', {
containerId: 'container-1',
containerName: 'napcat-1',
createdAt: Date.now(),
expiresAt: Date.now() + 60_000,
id: 'session-1',
mode: 'refresh',
qrcode: 'old-qrcode',
status: 'pending',
webuiPort: 6100,
});
jest
.spyOn(service as any, 'getSessionContainer')
.mockResolvedValue({ id: 'container-1' });
jest
.spyOn(service as any, 'callRefreshQrcode')
.mockResolvedValue('new-qrcode-image');
const getQrcode = jest.spyOn(service as any, 'getQrcode');
const result = await service.refreshQrcode('session-1');
expect(result.qrcode).toBe('new-qrcode-image');
expect(getQrcode).not.toHaveBeenCalled();
});
it('does not replace current qrcode with expired status qrcode', async () => {
(service as any).sessions.set('session-2', {
containerId: 'container-2',
containerName: 'napcat-2',
createdAt: Date.now(),
expiresAt: Date.now() + 60_000,
id: 'session-2',
mode: 'refresh',
qrcode: 'current-qrcode',
status: 'pending',
webuiPort: 6101,
});
jest
.spyOn(service as any, 'getSessionContainer')
.mockResolvedValue({ id: 'container-2' });
jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({
isLogin: false,
loginError: '二维码已过期',
qrcodeurl: 'expired-qrcode',
});
const result = await service.status('session-2');
expect(result.qrcode).toBe('current-qrcode');
expect(result.errorMessage).toBe('二维码已过期');
});
});

View File

@ -38,6 +38,8 @@ type NapcatLoginStatus = {
type NapcatQrcode = { type NapcatQrcode = {
qrcode?: string; qrcode?: string;
qrcodeurl?: string;
url?: string;
}; };
type QqbotLoginScanSession = { type QqbotLoginScanSession = {
@ -114,9 +116,9 @@ export class QqbotNapcatLoginService {
} }
const container = await this.getSessionContainer(session); const container = await this.getSessionContainer(session);
await this.callRefreshQrcode(container); session.qrcode = await this.refreshOrGetQrcode(container, true);
session.qrcode = await this.getQrcode(container);
session.expiresAt = Date.now() + this.getSessionTtlMs(); session.expiresAt = Date.now() + this.getSessionTtlMs();
session.errorMessage = undefined;
this.sessions.set(session.id, session); this.sessions.set(session.id, session);
return this.toResult(session); return this.toResult(session);
} }
@ -131,7 +133,9 @@ export class QqbotNapcatLoginService {
const status = await this.getLoginStatus(container); const status = await this.getLoginStatus(container);
if (!status.isLogin) { if (!status.isLogin) {
session.errorMessage = status.loginError || undefined; session.errorMessage = status.loginError || undefined;
session.qrcode = status.qrcodeurl || session.qrcode; if (status.qrcodeurl && !this.isExpiredQrcodeStatus(status)) {
session.qrcode = status.qrcodeurl;
}
this.sessions.set(session.id, session); this.sessions.set(session.id, session);
return this.toResult(session); return this.toResult(session);
} }
@ -170,13 +174,11 @@ export class QqbotNapcatLoginService {
return this.completeLogin(session, container); return this.completeLogin(session, container);
} }
let qrcode = this.isExpiredQrcodeStatus(loginStatus) const qrcode = await this.refreshOrGetQrcode(
? '' container,
: loginStatus.qrcodeurl || ''; true,
if (!qrcode) { loginStatus,
await this.callRefreshQrcode(container, true); );
qrcode = await this.getQrcode(container, true);
}
const session = this.createSession({ const session = this.createSession({
...options, ...options,
container, container,
@ -374,11 +376,15 @@ export class QqbotNapcatLoginService {
container: QqbotNapcatRuntime, container: QqbotNapcatRuntime,
retry = false, retry = false,
) { ) {
await this.executeNapcatRequest(retry, async () => { return this.executeNapcatRequest(retry, async () => {
try { try {
await this.postNapcat<null>(container, '/api/QQLogin/RefreshQRcode'); const data = await this.postNapcat<NapcatQrcode | null>(
container,
'/api/QQLogin/RefreshQRcode',
);
return this.pickQrcode(data);
} catch (err) { } catch (err) {
if (this.isAlreadyLoggedIn(err)) return; if (this.isAlreadyLoggedIn(err)) return '';
throw err; throw err;
} }
}); });
@ -391,10 +397,11 @@ export class QqbotNapcatLoginService {
container, container,
'/api/QQLogin/GetQQLoginQrcode', '/api/QQLogin/GetQQLoginQrcode',
); );
if (!data.qrcode) { const qrcode = this.pickQrcode(data);
if (!qrcode) {
return this.getQrcodeFromStatus(container); return this.getQrcodeFromStatus(container);
} }
return data.qrcode; return qrcode;
} catch (err) { } catch (err) {
if (this.isAlreadyLoggedIn(err)) { if (this.isAlreadyLoggedIn(err)) {
const status = await this.getLoginStatus(container); const status = await this.getLoginStatus(container);
@ -408,6 +415,26 @@ export class QqbotNapcatLoginService {
}); });
} }
private async refreshOrGetQrcode(
container: QqbotNapcatRuntime,
retry = false,
fallbackStatus?: NapcatLoginStatus,
) {
try {
const refreshedQrcode = await this.callRefreshQrcode(container, retry);
if (refreshedQrcode) return refreshedQrcode;
return await this.getQrcode(container, retry);
} catch (err) {
if (
fallbackStatus?.qrcodeurl &&
!this.isExpiredQrcodeStatus(fallbackStatus)
) {
return fallbackStatus.qrcodeurl;
}
throw err;
}
}
private async getQrcodeFromStatus(container: QqbotNapcatRuntime) { private async getQrcodeFromStatus(container: QqbotNapcatRuntime) {
const status = await this.getLoginStatus(container); const status = await this.getLoginStatus(container);
if (status.qrcodeurl && !this.isExpiredQrcodeStatus(status)) { if (status.qrcodeurl && !this.isExpiredQrcodeStatus(status)) {
@ -416,6 +443,11 @@ export class QqbotNapcatLoginService {
throwVbenError('NapCat 未返回登录二维码'); throwVbenError('NapCat 未返回登录二维码');
} }
private pickQrcode(data?: NapcatQrcode | null) {
if (!data) return '';
return `${data.qrcode || data.qrcodeurl || data.url || ''}`.trim();
}
private async postNapcat<T>( private async postNapcat<T>(
container: QqbotNapcatRuntime, container: QqbotNapcatRuntime,
path: string, path: string,

View File

@ -0,0 +1,84 @@
jest.mock(
'@/common',
() => ({
ensureSnowflakeId: jest.fn(),
setDictDecodeCache: jest.fn(),
}),
{ virtual: true },
);
import { DictService } from '../../admin/dict/dict.service';
import { QqbotCommandParserService } from './qqbot-command-parser.service';
import type { QqbotCommand } from './qqbot-command.entity';
describe('QqbotCommandParserService FFLogs parser', () => {
const command = {
aliases: '["logs"]',
code: 'fflogs_character',
name: 'FFLogs 查询',
parserKey: 'fflogsCharacter',
prefixes: '["/"]',
} as QqbotCommand;
const dictService = {
getDictItemsByKey: jest.fn(async (dictKey: string) => {
if (dictKey === 'FFLOGS_ENCOUNTER_LABEL') {
return [
{ label: 'M9S 吸血鬼偶像', value: 'vampfatale' },
{ label: 'M10S Red Hot and Deep Blue', value: 'redhotanddeepblue' },
];
}
return [];
}),
relationTree: jest.fn(async () => [
{
children: [
{
children: [
{
dictCode: 'FF14_MARKET_WORLD_CN_MAOXIAOPANG',
label: '琥珀原',
treeKey: 'world-1',
value: '琥珀原',
},
],
dictCode: 'FF14_MARKET_DATA_CENTER_CN',
label: '猫小胖',
treeKey: 'dc-1',
value: '猫小胖',
},
],
dictCode: 'FF14_MARKET_REGION',
label: '中国',
treeKey: 'region-1',
value: '中国',
},
]),
} as unknown as DictService;
const service = new QqbotCommandParserService(dictService);
it('parses Chinese encounter name after character and server', async () => {
const matched = await service.match(command, {
messageText: '/logs Anbbo 琥珀原 吸血鬼偶像',
} as any);
expect(matched?.input).toMatchObject({
characterName: 'Anbbo',
encounterName: '吸血鬼偶像',
serverSlug: '琥珀原',
});
});
it('parses space separated encounter name when server is explicit', async () => {
const matched = await service.match(command, {
messageText: '/logs Kwi 柊司 M10S Red Hot and Deep Blue server=琥珀原',
} as any);
expect(matched?.input).toMatchObject({
characterName: 'Kwi 柊司',
encounterName: 'M10S Red Hot and Deep Blue',
serverSlug: '琥珀原',
});
});
});

View File

@ -14,6 +14,8 @@ import {
splitQqbotFf14WorldPath, splitQqbotFf14WorldPath,
} from '../plugins/ff14Market/qqbot-ff14-worlds'; } from '../plugins/ff14Market/qqbot-ff14-worlds';
const QQBOT_FFLOGS_ENCOUNTER_DICT_CODE = 'FFLOGS_ENCOUNTER_LABEL';
export type QqbotCommandMatchResult = { export type QqbotCommandMatchResult = {
alias: string; alias: string;
input: Record<string, any>; input: Record<string, any>;
@ -149,7 +151,7 @@ export class QqbotCommandParserService {
}; };
} }
private parseFflogsCharacterInput(rawArgs: string) { private async parseFflogsCharacterInput(rawArgs: string) {
const tokens = rawArgs.split(/\s+/).filter(Boolean); const tokens = rawArgs.split(/\s+/).filter(Boolean);
const flags = new Map<string, string | true>(); const flags = new Map<string, string | true>();
const positional: string[] = []; const positional: string[] = [];
@ -176,18 +178,74 @@ export class QqbotCommandParserService {
flags.get('服务器') || flags.get('服务器') ||
flags.get('小区'), flags.get('小区'),
); );
let encounterName = this.normalizeString(
flags.get('encounter') ||
flags.get('encounterName') ||
flags.get('boss') ||
flags.get('fight') ||
flags.get('任务') ||
flags.get('高难') ||
flags.get('高难任务'),
);
let zoneId = this.normalizeString(
flags.get('zone') ||
flags.get('zoneId') ||
flags.get('区域') ||
flags.get('副本区域'),
);
const dungeonFlag = this.normalizeString(flags.get('副本'));
if (dungeonFlag) {
if (/^\d+$/.test(dungeonFlag)) {
zoneId = zoneId || dungeonFlag;
} else {
encounterName = encounterName || dungeonFlag;
}
}
let remainingPositionals = [...positional];
if (!characterName && positional.length) { if (!characterName && remainingPositionals[0]?.includes('@')) {
const joined = positional.join(' '); const [name, server] = remainingPositionals[0].split('@');
characterName = name.trim();
serverSlug = serverSlug || server?.trim();
if (!encounterName && remainingPositionals.length > 1) {
encounterName = remainingPositionals.slice(1).join(' ');
}
remainingPositionals = [];
}
if (!encounterName && remainingPositionals.length > 2) {
const picked = await this.pickFflogsPositionalsByKnownWorld(
remainingPositionals,
);
if (picked) {
characterName = characterName || picked.characterName;
serverSlug = serverSlug || picked.serverSlug;
encounterName = picked.encounterName;
remainingPositionals = [];
}
}
if (!encounterName && remainingPositionals.length > 1) {
const picked = await this.pickTrailingFflogsEncounter(
remainingPositionals,
);
if (picked) {
encounterName = picked.encounterName;
remainingPositionals = picked.positionals;
}
}
if (!characterName && remainingPositionals.length) {
const joined = remainingPositionals.join(' ');
if (joined.includes('@')) { if (joined.includes('@')) {
const [name, server] = joined.split('@'); const [name, server] = joined.split('@');
characterName = name.trim(); characterName = name.trim();
serverSlug = serverSlug || server?.trim(); serverSlug = serverSlug || server?.trim();
} else if (serverSlug) { } else if (serverSlug) {
characterName = joined; characterName = joined;
} else if (positional.length > 1) { } else if (remainingPositionals.length > 1) {
serverSlug = positional[positional.length - 1]; serverSlug = remainingPositionals[remainingPositionals.length - 1];
characterName = positional.slice(0, -1).join(' '); characterName = remainingPositionals.slice(0, -1).join(' ');
} else { } else {
characterName = joined; characterName = joined;
} }
@ -199,10 +257,13 @@ export class QqbotCommandParserService {
difficulty: this.normalizeString( difficulty: this.normalizeString(
flags.get('difficulty') || flags.get('难度'), flags.get('difficulty') || flags.get('难度'),
), ),
encounter: encounterName,
encounterName,
metric: this.normalizeString(flags.get('metric') || flags.get('指标')), metric: this.normalizeString(flags.get('metric') || flags.get('指标')),
partition: this.normalizeString( partition: this.normalizeString(
flags.get('partition') || flags.get('分区'), flags.get('partition') || flags.get('分区'),
), ),
limit: this.normalizeString(flags.get('limit') || flags.get('数量')),
raw: rawArgs, raw: rawArgs,
role: this.normalizeString(flags.get('role') || flags.get('职责')), role: this.normalizeString(flags.get('role') || flags.get('职责')),
serverRegion: this.normalizeString( serverRegion: this.normalizeString(
@ -218,9 +279,7 @@ export class QqbotCommandParserService {
timeframe: this.normalizeString( timeframe: this.normalizeString(
flags.get('timeframe') || flags.get('时间') || flags.get('范围'), flags.get('timeframe') || flags.get('时间') || flags.get('范围'),
), ),
zoneId: this.normalizeString( zoneId,
flags.get('zone') || flags.get('zoneId') || flags.get('副本'),
),
}; };
} }
@ -276,9 +335,68 @@ export class QqbotCommandParserService {
}; };
} }
private async pickFflogsPositionalsByKnownWorld(positional: string[]) {
const catalog = await this.getFf14MarketCatalog();
for (let index = positional.length - 2; index > 0; index--) {
const candidate = positional[index];
if (!isQqbotFf14LocationName(catalog, candidate)) continue;
const characterName = positional.slice(0, index).join(' ').trim();
const encounterName = positional
.slice(index + 1)
.join(' ')
.trim();
if (!characterName || !encounterName) continue;
const worldPath = splitQqbotFf14WorldPath(candidate);
return {
characterName,
encounterName,
serverSlug: worldPath.world || candidate,
};
}
return null;
}
private async pickTrailingFflogsEncounter(positional: string[]) {
const catalog = await this.getFflogsEncounterCatalog();
for (let index = 1; index < positional.length; index++) {
const encounterName = positional.slice(index).join(' ').trim();
if (!this.isFflogsEncounterName(catalog, encounterName)) continue;
return {
encounterName,
positionals: positional.slice(0, index),
};
}
return null;
}
private async getFflogsEncounterCatalog() {
const dicts = await this.dictService.getDictItemsByKey(
QQBOT_FFLOGS_ENCOUNTER_DICT_CODE,
);
const keys = new Set<string>();
for (const item of dicts) {
for (const key of this.buildFflogsLookupKeys(item.label, item.value)) {
keys.add(key);
}
}
return keys;
}
private isFflogsEncounterName(catalog: Set<string>, value: string) {
const keys = this.buildFflogsLookupKeys(value);
return keys.some(
(key) =>
catalog.has(key) ||
(key.length >= 3 &&
[...catalog].some(
(candidate) => candidate.startsWith(key) || candidate.includes(key),
)),
);
}
private async getFf14MarketCatalog() { private async getFf14MarketCatalog() {
const treeCatalog = buildQqbotFf14MarketCatalogFromTree( const treeCatalog = buildQqbotFf14MarketCatalogFromTree(
await this.dictService.tree({ await this.dictService.relationTree({
dictCode: QQBOT_FF14_MARKET_DICT_CODES.region, dictCode: QQBOT_FF14_MARKET_DICT_CODES.region,
}), }),
); );
@ -329,4 +447,22 @@ export class QqbotCommandParserService {
if (value === true) return ''; if (value === true) return '';
return `${value || ''}`.trim(); return `${value || ''}`.trim();
} }
private buildFflogsLookupKeys(...values: string[]) {
const keys = values
.flatMap((value) => {
const normalized = this.normalizeFflogsLookupKey(value);
const withoutAnd = normalized.replace(/and/g, '');
return [normalized, withoutAnd];
})
.filter(Boolean);
return [...new Set(keys)];
}
private normalizeFflogsLookupKey(value: string) {
return `${value || ''}`
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '');
}
} }

View File

@ -292,7 +292,7 @@ export class QqbotFf14ClientService {
private async getFf14MarketCatalog() { private async getFf14MarketCatalog() {
const treeCatalog = buildQqbotFf14MarketCatalogFromTree( const treeCatalog = buildQqbotFf14MarketCatalogFromTree(
await this.dictService.tree({ await this.dictService.relationTree({
dictCode: QQBOT_FF14_MARKET_DICT_CODES.region, dictCode: QQBOT_FF14_MARKET_DICT_CODES.region,
}), }),
); );

View File

@ -14,11 +14,14 @@ import { QqbotFflogsClientService } from './qqbot-fflogs-client.service';
describe('QqbotFflogsClientService', () => { describe('QqbotFflogsClientService', () => {
const dicts = { const dicts = {
FFLOGS_ENCOUNTER_LABEL: [ FFLOGS_ENCOUNTER_LABEL: [
{ label: 'M9S Vamp Fatale', value: 'vampfatale' }, { label: 'M9S 吸血鬼偶像', value: 'vampfatale' },
{ label: 'M12S P2 Lindwurm II', value: 'lindwurmii' }, { label: 'M12S P2 Lindwurm II', value: 'lindwurmii' },
], ],
FFLOGS_JOB_LABEL: [{ label: '机工士', value: 'machinist' }], FFLOGS_JOB_LABEL: [{ label: '机工士', value: 'machinist' }],
FFLOGS_METRIC_LABEL: [{ label: 'DPS', value: 'dps' }], FFLOGS_METRIC_LABEL: [
{ label: 'DPS', value: 'dps' },
{ label: 'aDPS', value: 'cdps' },
],
FFLOGS_ROLE_LABEL: [], FFLOGS_ROLE_LABEL: [],
FFLOGS_SERVER_REGION_LABEL: [{ label: '国服', value: 'cn' }], FFLOGS_SERVER_REGION_LABEL: [{ label: '国服', value: 'cn' }],
}; };
@ -31,6 +34,9 @@ describe('QqbotFflogsClientService', () => {
getDictByKey: jest.fn( getDictByKey: jest.fn(
async (dictKey: keyof typeof dicts) => dicts[dictKey] || [], async (dictKey: keyof typeof dicts) => dicts[dictKey] || [],
), ),
getDictItemsByKey: jest.fn(
async (dictKey: keyof typeof dicts) => dicts[dictKey] || [],
),
} as unknown as DictService, } as unknown as DictService,
); );
@ -62,8 +68,54 @@ describe('QqbotFflogsClientService', () => {
expect(replyText).toContain('FFLogs 战绩Kwi柊司 @ 琥珀原(国服)'); expect(replyText).toContain('FFLogs 战绩Kwi柊司 @ 琥珀原(国服)');
expect(replyText).toContain( expect(replyText).toContain(
'1. M9S Vamp Fatale72.4% DPS 36,635 机工士', '1. M9S 吸血鬼偶像72.4% DPS 36,635 机工士',
); );
expect(replyText).toContain('2. M12S P2 Lindwurm II暂无有效排名'); expect(replyText).toContain('2. M12S P2 Lindwurm II暂无有效排名');
}); });
it('formats recent encounter logs with Chinese encounter label and colors', async () => {
const localizationMaps = await (service as any).getLocalizationMaps();
const replyText = (service as any).buildEncounterLogsReplyText({
characterId: 20962075,
characterName: 'Kwi柊司',
encounterName: 'M9S 吸血鬼偶像',
localizationMaps,
logs: [
{
adps: 37560.7,
color: '蓝',
damageScore: 66.5,
dps: 37560.7,
encounterName: 'M9S 吸血鬼偶像',
fightId: 14,
healingColor: '灰',
healingScore: 0,
hps: 0,
kill: true,
logCode: 'CgvFRqyxJhLtmND7',
logUrl: 'https://cn.fflogs.com/reports/CgvFRqyxJhLtmND7#fight=14',
ndps: 36635.1,
rdps: 36635.1,
startTime: new Date('2026-05-01T20:30:00+08:00').getTime(),
},
],
serverName: '琥珀原',
serverRegion: 'CN',
url: 'https://cn.fflogs.com/character/cn/example/Kwi',
});
expect(replyText).toContain('高难任务M9S 吸血鬼偶像');
expect(replyText).toContain('颜色 蓝|输出 66.5|治疗 灰 0');
expect(replyText).toContain(
'DPS 37,561 / aDPS 37,561 / rDPS 36,635 / nDPS 36,635 / HPS 0',
);
expect(replyText).toContain('log CgvFRqyxJhLtmND7#14');
});
it('resolves Chinese encounter input from dict labels', async () => {
const lookup = await (service as any).resolveEncounterLookup('吸血鬼偶像');
expect(lookup.displayName).toBe('M9S 吸血鬼偶像');
expect(lookup.keys).toContain('vampfatale');
});
}); });

View File

@ -21,6 +21,9 @@ type FflogsCharacter = {
id?: number; id?: number;
lodestoneID?: number; lodestoneID?: number;
name?: string; name?: string;
recentReports?: {
data?: FflogsRecentReport[];
};
server?: { server?: {
name?: string; name?: string;
slug?: string; slug?: string;
@ -28,14 +31,87 @@ type FflogsCharacter = {
zoneRankings?: unknown; zoneRankings?: unknown;
}; };
type FflogsReportFight = {
difficulty?: number | null;
encounterID?: number;
endTime?: number;
id?: number;
kill?: boolean | null;
name?: string;
startTime?: number;
};
type FflogsRecentReport = {
code?: string;
endTime?: number;
fights?: FflogsReportFight[];
startTime?: number;
title?: string;
zone?: {
id?: number;
name?: string;
};
};
type FflogsCharacterSummaryResponse = { type FflogsCharacterSummaryResponse = {
characterData?: { characterData?: {
character?: FflogsCharacter | null; character?: FflogsCharacter | null;
}; };
}; };
type FflogsReportFightMetricsResponse = {
reportData?: {
report?: {
damage?: unknown;
dpsRankings?: unknown;
healing?: unknown;
hpsRankings?: unknown;
} | null;
};
};
type FflogsRankingItem = Record<string, any>; type FflogsRankingItem = Record<string, any>;
type FflogsEncounterLookup = {
displayName: string;
encounterId?: number;
input: string;
keys: string[];
};
type FflogsEncounterFightCandidate = {
absoluteStartTime: number;
fight: FflogsReportFight;
report: FflogsRecentReport;
};
type FflogsParseMetric = {
amount?: number;
color: string;
percent?: number;
rank?: string;
};
export type QqbotFflogsEncounterLogItem = {
adps?: number;
color: string;
damageScore?: number;
dps?: number;
durationMs?: number;
encounterName: string;
fightId?: number;
healingColor: string;
healingScore?: number;
hps?: number;
kill?: boolean | null;
logCode: string;
logUrl: string;
ndps?: number;
rdps?: number;
reportTitle?: string;
startTime?: number;
};
const FFLOGS_LOCALIZATION_DICT_CODES = { const FFLOGS_LOCALIZATION_DICT_CODES = {
encounter: 'FFLOGS_ENCOUNTER_LABEL', encounter: 'FFLOGS_ENCOUNTER_LABEL',
job: 'FFLOGS_JOB_LABEL', job: 'FFLOGS_JOB_LABEL',
@ -54,8 +130,12 @@ export type QqbotFflogsCharacterSummaryInput = {
characterName?: string; characterName?: string;
className?: string; className?: string;
difficulty?: number | string; difficulty?: number | string;
encounter?: string;
encounterName?: string;
limit?: number | string;
metric?: string; metric?: string;
partition?: number | string; partition?: number | string;
reportsLimit?: number | string;
role?: string; role?: string;
server?: string; server?: string;
serverRegion?: string; serverRegion?: string;
@ -70,6 +150,8 @@ export type QqbotFflogsCharacterSummaryResult = {
allStarText?: string; allStarText?: string;
characterId?: number; characterId?: number;
characterName: string; characterName: string;
encounterName?: string;
logs?: QqbotFflogsEncounterLogItem[];
rankings: FflogsRankingItem[]; rankings: FflogsRankingItem[];
replyText: string; replyText: string;
serverName: string; serverName: string;
@ -137,6 +219,17 @@ export class QqbotFflogsClientService {
if (!serverRegion) if (!serverRegion)
throw new Error('请提供 FFLogs 服务器地区,如 CN/JP/NA/EU'); throw new Error('请提供 FFLogs 服务器地区,如 CN/JP/NA/EU');
const encounterInput = this.normalizeEncounterInput(params);
if (encounterInput) {
return this.getCharacterEncounterLogs({
...params,
characterName,
encounter: encounterInput,
serverRegion,
serverSlug,
});
}
const variables = { const variables = {
characterName, characterName,
className: this.normalizeOptionalString(params.className), className: this.normalizeOptionalString(params.className),
@ -224,7 +317,7 @@ export class QqbotFflogsClientService {
allStarText, allStarText,
characterId: character.id, characterId: character.id,
characterName: character.name || characterName, characterName: character.name || characterName,
metric: variables.metric || 'DPS', metric: variables.metric || 'dps',
rankings, rankings,
localizationMaps, localizationMaps,
serverName, serverName,
@ -237,6 +330,269 @@ export class QqbotFflogsClientService {
}; };
} }
private async getCharacterEncounterLogs(
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();
const encounterInput = this.normalizeEncounterInput(params);
if (!encounterInput) throw new Error('请提供 FFLogs 高难任务名');
const limit = this.toLimitedPositiveNumber(params.limit, 10, 1, 10);
const reportsLimit = this.toLimitedPositiveNumber(
params.reportsLimit,
Math.max(limit * 5, 20),
1,
50,
);
const encounterLookup = await this.resolveEncounterLookup(encounterInput);
const variables = {
characterName,
reportsLimit,
serverRegion: serverRegion.toUpperCase(),
serverSlug,
};
const data = await this.requestGraphql<FflogsCharacterSummaryResponse>(
`query QqbotFflogsCharacterEncounterReports(
$characterName: String!
$serverSlug: String!
$serverRegion: String!
$reportsLimit: Int
) {
characterData {
character(
name: $characterName
serverSlug: $serverSlug
serverRegion: $serverRegion
) {
id
lodestoneID
name
server {
name
slug
}
recentReports(limit: $reportsLimit) {
data {
code
title
startTime
endTime
zone {
id
name
}
fights(translate: true) {
id
name
encounterID
difficulty
kill
startTime
endTime
}
}
}
}
}
}`,
variables,
);
const character = data.characterData?.character;
if (!character) {
throw new Error(
`未找到 FFLogs 角色:${characterName} / ${serverRegion} / ${serverSlug}`,
);
}
const localizationMaps = await this.getLocalizationMaps();
const serverName = character.server?.name || serverSlug;
const url = this.buildCharacterUrl(
serverRegion,
character.server?.slug || serverSlug,
character.name || characterName,
);
const difficulty = this.toOptionalNumber(params.difficulty);
const candidates = this.pickEncounterFightCandidates(
character.recentReports?.data || [],
encounterLookup,
difficulty,
);
const metricCandidates = candidates.slice(0, Math.min(limit * 3, 30));
const logs = (
await Promise.all(
metricCandidates.map((candidate) =>
this.getEncounterFightLog({
candidate,
characterId: character.id,
characterName: character.name || characterName,
encounterLookup,
localizationMaps,
serverName,
serverRegion,
serverSlug: character.server?.slug || serverSlug,
}),
),
)
)
.filter(Boolean)
.slice(0, limit);
return {
characterId: character.id,
characterName: character.name || characterName,
encounterName: encounterLookup.displayName,
logs,
rankings: [],
replyText: this.buildEncounterLogsReplyText({
characterId: character.id,
characterName: character.name || characterName,
encounterName: encounterLookup.displayName,
logs,
localizationMaps,
serverName,
serverRegion,
url,
}),
serverName,
serverRegion,
url,
};
}
private async getEncounterFightLog(params: {
candidate: FflogsEncounterFightCandidate;
characterId?: number;
characterName: string;
encounterLookup: FflogsEncounterLookup;
localizationMaps: FflogsLocalizationMaps;
serverName: string;
serverRegion: string;
serverSlug: string;
}): Promise<QqbotFflogsEncounterLogItem | null> {
const { candidate } = params;
const code = `${candidate.report.code || ''}`.trim();
const fightId = this.toOptionalNumber(candidate.fight.id);
const encounterId = this.toOptionalNumber(candidate.fight.encounterID);
if (!code || fightId === undefined || encounterId === undefined) {
return null;
}
const data = await this.requestGraphql<FflogsReportFightMetricsResponse>(
`query QqbotFflogsEncounterFightMetrics(
$code: String!
$encounterID: Int!
$fightIDs: [Int]
) {
reportData {
report(code: $code) {
dpsRankings: rankings(
encounterID: $encounterID
fightIDs: $fightIDs
playerMetric: dps
)
hpsRankings: rankings(
encounterID: $encounterID
fightIDs: $fightIDs
playerMetric: hps
)
damage: table(
dataType: DamageDone
encounterID: $encounterID
fightIDs: $fightIDs
)
healing: table(
dataType: Healing
encounterID: $encounterID
fightIDs: $fightIDs
)
}
}
}`,
{
code,
encounterID: encounterId,
fightIDs: [fightId],
},
);
const report = data.reportData?.report;
if (!report) return null;
const target = {
characterId: params.characterId,
characterName: params.characterName,
serverName: params.serverName,
serverRegion: params.serverRegion,
serverSlug: params.serverSlug,
};
const damageRanking = this.extractParseMetric(
this.findRankingCharacter(report.dpsRankings, target),
);
const healingRanking = this.extractParseMetric(
this.findRankingCharacter(report.hpsRankings, target),
);
const damageEntry = this.findTableEntry(report.damage, target);
const healingEntry = this.findTableEntry(report.healing, target);
if (
!damageEntry &&
!healingEntry &&
damageRanking.amount === undefined &&
healingRanking.amount === undefined
) {
return null;
}
const damagePayload = this.normalizeJsonPayload(report.damage) as any;
const healingPayload = this.normalizeJsonPayload(report.healing) as any;
const combatTimeMs = this.pickNumber(
damagePayload?.data?.combatTime,
healingPayload?.data?.combatTime,
(candidate.fight.endTime || 0) - (candidate.fight.startTime || 0),
);
const encounterName = this.localizeEncounter(
this.pickText(
candidate.fight.name,
params.encounterLookup.displayName,
`任务 ${candidate.fight.encounterID || ''}`,
),
params.localizationMaps,
);
return {
adps: this.toPerSecond(damageEntry?.totalADPS, combatTimeMs),
color: damageRanking.color,
damageScore: damageRanking.percent,
dps:
damageRanking.amount ||
this.toPerSecond(damageEntry?.total, combatTimeMs),
durationMs: combatTimeMs,
encounterName,
fightId,
healingColor: healingRanking.color,
healingScore: healingRanking.percent,
hps:
healingRanking.amount ||
this.toPerSecond(healingEntry?.total, combatTimeMs),
kill: candidate.fight.kill,
logCode: code,
logUrl: this.buildReportFightUrl(code, fightId),
ndps: this.toPerSecond(damageEntry?.totalNDPS, combatTimeMs),
rdps: this.toPerSecond(damageEntry?.totalRDPS, combatTimeMs),
reportTitle: candidate.report.title,
startTime: candidate.absoluteStartTime,
};
}
private async getAccessToken() { private async getAccessToken() {
if (this.accessToken && Date.now() < this.accessTokenExpireAt) { if (this.accessToken && Date.now() < this.accessTokenExpireAt) {
return this.accessToken; return this.accessToken;
@ -378,6 +734,234 @@ export class QqbotFflogsClientService {
.join('\n'); .join('\n');
} }
private buildEncounterLogsReplyText(params: {
characterId?: number;
characterName: string;
encounterName: string;
localizationMaps: FflogsLocalizationMaps;
logs: QqbotFflogsEncounterLogItem[];
serverName: string;
serverRegion: string;
url: string;
}) {
const region = this.localizeServerRegion(
params.serverRegion,
params.localizationMaps,
);
const header = `FFLogs 最近记录:${params.characterName} @ ${params.serverName}${region}`;
const encounterText = `高难任务:${params.encounterName}`;
const idText = params.characterId ? `角色ID${params.characterId}` : '';
const logText = params.logs.length
? [
'最近10次',
...params.logs.map((item, index) =>
this.formatEncounterLogLine(item, index),
),
].join('\n')
: '最近10次暂无匹配的公开记录';
return [header, encounterText, idText, logText, params.url]
.filter(Boolean)
.join('\n');
}
private formatEncounterLogLine(
item: QqbotFflogsEncounterLogItem,
index: number,
) {
const status =
item.kill === true ? '击杀' : item.kill === false ? '灭团' : '未知';
const damageScore =
item.damageScore !== undefined
? `${this.formatNumber(item.damageScore)}`
: '-';
const healingScore =
item.healingScore !== undefined
? `${this.formatNumber(item.healingScore)}`
: '-';
const metrics = [
`DPS ${this.formatMetricNumber(item.dps)}`,
`aDPS ${this.formatMetricNumber(item.adps)}`,
`rDPS ${this.formatMetricNumber(item.rdps)}`,
`nDPS ${this.formatMetricNumber(item.ndps)}`,
`HPS ${this.formatMetricNumber(item.hps)}`,
].join(' / ');
return `${index + 1}. ${this.formatLogTime(
item.startTime,
)}${status} ${item.color} ${damageScore} ${
item.healingColor
} ${healingScore}${metrics}log ${item.logCode}#${item.fightId}`;
}
private pickEncounterFightCandidates(
reports: FflogsRecentReport[],
encounterLookup: FflogsEncounterLookup,
difficulty?: number,
) {
return reports
.flatMap((report) =>
(report.fights || []).map((fight) => ({
absoluteStartTime:
Number(report.startTime || 0) + Number(fight.startTime || 0),
fight,
report,
})),
)
.filter(({ fight }) => this.matchEncounterFight(fight, encounterLookup))
.filter(
({ fight }) =>
difficulty === undefined || Number(fight.difficulty) === difficulty,
)
.sort((a, b) => b.absoluteStartTime - a.absoluteStartTime);
}
private matchEncounterFight(
fight: FflogsReportFight,
encounterLookup: FflogsEncounterLookup,
) {
if (
encounterLookup.encounterId !== undefined &&
Number(fight.encounterID) === encounterLookup.encounterId
) {
return true;
}
const fightKeys = this.buildLookupKeys(
`${fight.name || ''}`,
`${fight.encounterID || ''}`,
);
return encounterLookup.keys.some((key) => fightKeys.includes(key));
}
private async resolveEncounterLookup(
input: string,
): Promise<FflogsEncounterLookup> {
const raw = `${input || ''}`.trim();
const inputKeys = this.buildLookupKeys(raw);
const dicts = await this.dictService.getDictItemsByKey(
FFLOGS_LOCALIZATION_DICT_CODES.encounter,
);
const entries = dicts.map((item) => ({
displayName: `${item.label || item.value}`.trim(),
encounterId: this.toOptionalNumber(item.value),
keys: this.buildLookupKeys(`${item.label}`, `${item.value}`),
}));
const exact = entries.find((entry) =>
inputKeys.some((inputKey) => entry.keys.includes(inputKey)),
);
const prefix = exact
? undefined
: entries.find((entry) =>
inputKeys.some(
(inputKey) =>
inputKey.length >= 3 &&
entry.keys.some(
(key) => key.startsWith(inputKey) || key.includes(inputKey),
),
),
);
const matched = exact || prefix;
if (!matched) {
return {
displayName: raw,
encounterId: this.toOptionalNumber(raw),
input: raw,
keys: inputKeys,
};
}
return {
displayName: matched.displayName,
encounterId: matched.encounterId,
input: raw,
keys: [...new Set([...matched.keys, ...inputKeys])],
};
}
private findRankingCharacter(
payload: unknown,
target: {
characterId?: number;
characterName: string;
serverName: string;
serverRegion: string;
serverSlug: string;
},
) {
const normalized = this.normalizeJsonPayload(payload) as any;
const rankings = Array.isArray(normalized?.data) ? normalized.data : [];
for (const ranking of rankings) {
const roles = ranking?.roles || {};
for (const role of ['tanks', 'healers', 'dps']) {
const characters = Array.isArray(roles?.[role]?.characters)
? roles[role].characters
: [];
const matched = characters.find(
(item) => !item?.id_2 && this.isTargetRankingCharacter(item, target),
);
if (matched) return matched;
}
}
return undefined;
}
private findTableEntry(
payload: unknown,
target: {
characterName: string;
},
) {
const normalized = this.normalizeJsonPayload(payload) as any;
const entries = Array.isArray(normalized?.data?.entries)
? normalized.data.entries
: [];
const targetName = this.normalizeCharacterKey(target.characterName);
return entries.find(
(item) => this.normalizeCharacterKey(item?.name) === targetName,
);
}
private isTargetRankingCharacter(
item: any,
target: {
characterId?: number;
characterName: string;
serverName: string;
serverRegion: string;
serverSlug: string;
},
) {
if (!item || typeof item !== 'object') return false;
if (
target.characterId !== undefined &&
Number(item.id) === target.characterId
) {
return true;
}
if (
this.normalizeCharacterKey(item.name) !==
this.normalizeCharacterKey(target.characterName)
) {
return false;
}
const serverName = this.pickText(item.server?.name, item.server?.slug);
if (!serverName) return true;
const serverKey = this.normalizeCharacterKey(serverName);
return [target.serverName, target.serverSlug, target.serverRegion]
.map((value) => this.normalizeCharacterKey(value))
.includes(serverKey);
}
private extractParseMetric(item: any): FflogsParseMetric {
const percent = this.pickNumber(item?.rankPercent, item?.bracketPercent);
return {
amount: this.pickNumber(item?.amount),
color: this.getParseColor(percent),
percent,
rank: this.pickText(item?.rank, item?.best),
};
}
private formatRanking( private formatRanking(
item: FflogsRankingItem, item: FflogsRankingItem,
index: number, index: number,
@ -481,17 +1065,30 @@ export class QqbotFflogsClientService {
)}/${encodeURIComponent(serverSlug)}/${encodeURIComponent(characterName)}`; )}/${encodeURIComponent(serverSlug)}/${encodeURIComponent(characterName)}`;
} }
private buildReportFightUrl(code: string, fightId: number) {
return `${this.webBaseUrl}/reports/${encodeURIComponent(
code,
)}#fight=${encodeURIComponent(`${fightId}`)}`;
}
private normalizeEncounterInput(params: QqbotFflogsCharacterSummaryInput) {
return `${params.encounterName || params.encounter || ''}`.trim();
}
private normalizeMetric(value?: string) { private normalizeMetric(value?: string) {
const raw = `${value || ''}`.trim(); const raw = `${value || ''}`.trim();
if (!raw) return undefined; if (!raw) return undefined;
const lower = raw.toLowerCase(); const lower = raw.toLowerCase();
const map: Record<string, string> = { const map: Record<string, string> = {
cdps: 'DPS', adps: 'cdps',
damage: 'DPS', cdps: 'cdps',
dps: 'DPS', damage: 'dps',
healer: 'HPS', dps: 'dps',
healing: 'HPS', healer: 'hps',
hps: 'HPS', healing: 'hps',
hps: 'hps',
ndps: 'ndps',
rdps: 'rdps',
}; };
return map[lower] || raw; return map[lower] || raw;
} }
@ -528,6 +1125,24 @@ export class QqbotFflogsClientService {
return Number.isFinite(parsed) ? parsed : undefined; return Number.isFinite(parsed) ? parsed : undefined;
} }
private toLimitedPositiveNumber(
value: number | string | undefined,
fallback: number,
min: number,
max: number,
) {
const parsed = this.toOptionalNumber(value);
const normalized = parsed === undefined ? fallback : parsed;
return Math.min(Math.max(Math.floor(normalized), min), max);
}
private toPerSecond(value: any, durationMs?: number) {
const amount = this.pickNumber(value);
if (amount === undefined || !durationMs || durationMs <= 0)
return undefined;
return amount / (durationMs / 1000);
}
private normalizeOptionalString(value?: string) { private normalizeOptionalString(value?: string) {
const raw = `${value || ''}`.trim(); const raw = `${value || ''}`.trim();
return raw || undefined; return raw || undefined;
@ -556,6 +1171,33 @@ export class QqbotFflogsClientService {
}); });
} }
private formatMetricNumber(value?: number) {
return value === undefined ? '-' : this.formatNumber(value);
}
private formatLogTime(value?: number) {
if (!value) return '时间未知';
return new Date(value).toLocaleString('zh-CN', {
day: '2-digit',
hour: '2-digit',
hour12: false,
minute: '2-digit',
month: '2-digit',
timeZone: 'Asia/Shanghai',
});
}
private getParseColor(percent?: number) {
if (percent === undefined) return '无色';
if (percent >= 100) return '金';
if (percent >= 99) return '粉';
if (percent >= 95) return '橙';
if (percent >= 75) return '紫';
if (percent >= 50) return '蓝';
if (percent >= 25) return '绿';
return '灰';
}
private formatRank(value: string) { private formatRank(value: string) {
const rank = value.replace(/^#/, '').trim(); const rank = value.replace(/^#/, '').trim();
if (!rank) return ''; if (!rank) return '';
@ -590,12 +1232,13 @@ export class QqbotFflogsClientService {
private async getNormalizedDictMap(dictCode: string) { private async getNormalizedDictMap(dictCode: string) {
const dicts = await this.dictService.getDictByKey(dictCode); const dicts = await this.dictService.getDictByKey(dictCode);
return new Map( const map = new Map<string, string>();
dicts.map(({ label, value }) => [ for (const { label, value } of dicts) {
this.normalizeLookupKey(`${value}`), for (const key of this.buildLookupKeys(`${value}`, `${label}`)) {
`${label}`, map.set(key, `${label}`);
]), }
); }
return map;
} }
private localizeEncounter( private localizeEncounter(
@ -639,7 +1282,25 @@ export class QqbotFflogsClientService {
} }
private normalizeLookupKey(value: string) { private normalizeLookupKey(value: string) {
return `${value || ''}`.toLowerCase().replace(/[^a-z0-9]/g, ''); return `${value || ''}`
.normalize('NFKC')
.toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '');
}
private buildLookupKeys(...values: string[]) {
const keys = values
.flatMap((value) => {
const normalized = this.normalizeLookupKey(value);
const withoutAnd = normalized.replace(/and/g, '');
return [normalized, withoutAnd];
})
.filter(Boolean);
return [...new Set(keys)];
}
private normalizeCharacterKey(value: string) {
return `${value || ''}`.normalize('NFKC').toLowerCase().replace(/\s+/g, '');
} }
private removeUndefined(input: Record<string, any>) { private removeUndefined(input: Record<string, any>) {

View File

@ -8,7 +8,8 @@ export class QqbotFflogsPluginService {
getPlugin(): QqbotIntegrationPlugin { getPlugin(): QqbotIntegrationPlugin {
return { return {
description: '对接 FFLogs v2 GraphQL提供 FF14 角色公开排名查询能力。', description:
'对接 FFLogs v2 GraphQL提供 FF14 角色公开排名和指定高难最近记录查询能力。',
healthCheck: async () => { healthCheck: async () => {
const checkedAt = new Date().toISOString(); const checkedAt = new Date().toISOString();
try { try {
@ -31,10 +32,20 @@ export class QqbotFflogsPluginService {
operations: [ operations: [
{ {
cacheTtlMs: 60_000, cacheTtlMs: 60_000,
description: '查询指定角色的 FFLogs 公开排名摘要。', description:
'查询指定角色的 FFLogs 公开排名摘要;传入 encounter/任务 后查询指定高难最近10次记录。',
inputSchema: { inputSchema: {
properties: { properties: {
characterName: { description: '角色名', type: 'string' }, characterName: { description: '角色名', type: 'string' },
encounter: {
description: '高难任务名,支持字典维护的中文名/英文名/缩写',
type: 'string',
},
limit: {
default: 10,
description: '最近记录数量最多10条',
type: 'number',
},
metric: { description: '排名指标,如 dps/hps', type: 'string' }, metric: { description: '排名指标,如 dps/hps', type: 'string' },
serverRegion: { serverRegion: {
default: 'CN', default: 'CN',
@ -46,7 +57,10 @@ export class QqbotFflogsPluginService {
description: 'Today 或 Historical', description: 'Today 或 Historical',
type: 'string', type: 'string',
}, },
zoneId: { description: '副本区域 ID', type: 'number' }, zoneId: {
description: '副本区域 ID用于排名摘要',
type: 'number',
},
}, },
required: ['characterName', 'serverSlug'], required: ['characterName', 'serverSlug'],
type: 'object', type: 'object',
@ -56,6 +70,8 @@ export class QqbotFflogsPluginService {
outputSchema: { outputSchema: {
properties: { properties: {
characterName: { type: 'string' }, characterName: { type: 'string' },
encounterName: { type: 'string' },
logs: { type: 'array' },
rankings: { type: 'array' }, rankings: { type: 'array' },
replyText: { type: 'string' }, replyText: { type: 'string' },
url: { type: 'string' }, url: { type: 'string' },