feat(api): 完善字典与QQBot能力
This commit is contained in:
parent
a9bc963442
commit
3720819b96
@ -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`)
|
||||
VALUES
|
||||
(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
|
||||
`name` = VALUES(`name`),
|
||||
`plugin_key` = VALUES(`plugin_key`),
|
||||
@ -579,6 +579,7 @@ VALUES
|
||||
(2041700000000300805, 'FFLOGS_METRIC_LABEL', 'nDPS', 'ndps', NULL, 5, 1),
|
||||
(2041700000000300806, 'FFLOGS_METRIC_LABEL', 'Boss DPS', 'bossdps', NULL, 6, 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),
|
||||
(2041700000000300902, 'FFLOGS_ROLE_LABEL', '治疗', 'healer', NULL, 2, 1),
|
||||
(2041700000000300903, 'FFLOGS_ROLE_LABEL', '输出', 'dps', NULL, 3, 1),
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
import {
|
||||
AdminDictBodyDto,
|
||||
AdminDictDto,
|
||||
AdminDictGroupDto,
|
||||
AdminDictQueryDto,
|
||||
AdminDictTreeDto,
|
||||
AdminDictUpdateDto,
|
||||
@ -104,6 +105,22 @@ export class DictController {
|
||||
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: '获取字典编码选项' })
|
||||
@Get('codes')
|
||||
async codes() {
|
||||
|
||||
@ -78,6 +78,33 @@ export class AdminDictTreeDto extends AdminDictDto {
|
||||
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 {
|
||||
@ApiPropertyOptional()
|
||||
page?: number | string;
|
||||
|
||||
128
src/admin/dict/dict.service.spec.ts
Normal file
128
src/admin/dict/dict.service.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
@ -17,6 +17,14 @@ export type AdminDictItem = {
|
||||
value: string;
|
||||
};
|
||||
|
||||
export type AdminDictGroupItem = {
|
||||
dictCode: string;
|
||||
id: string;
|
||||
itemCount: number;
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type AdminDictSerialized = {
|
||||
childrenCode?: string | null;
|
||||
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 = {}) {
|
||||
return this.relationTree(query);
|
||||
}
|
||||
|
||||
async relationTree(query: AdminDictQueryDto = {}) {
|
||||
const items = await this.dictRepository.find({
|
||||
where: {
|
||||
isDeleted: false,
|
||||
@ -135,9 +181,9 @@ export class DictService implements OnApplicationBootstrap {
|
||||
},
|
||||
});
|
||||
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) {
|
||||
@ -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 dictCodes = new Set(items.map((item) => item.dictCode));
|
||||
const referencedCodes = new Set(
|
||||
@ -365,7 +413,7 @@ export class DictService implements OnApplicationBootstrap {
|
||||
return node;
|
||||
}
|
||||
|
||||
private filterTreeItems(
|
||||
private filterRelationTreeItems(
|
||||
items: AdminDictSerialized[],
|
||||
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(
|
||||
value: number | string | undefined,
|
||||
fallback: number,
|
||||
|
||||
@ -560,7 +560,11 @@ function getPropertyDescription(propertyName: string) {
|
||||
code: '响应状态码',
|
||||
command: '命令触发词',
|
||||
commandId: '在线命令 ID',
|
||||
connectStatus: 'OneBot 账号在线状态',
|
||||
connectionMode: '连接模式',
|
||||
connectionRole: 'OneBot 连接角色',
|
||||
containerName: 'NapCat 容器名称',
|
||||
containerStatus: 'NapCat 容器运行状态',
|
||||
count: '数量',
|
||||
data: '业务数据',
|
||||
description: '描述',
|
||||
@ -573,7 +577,10 @@ function getPropertyDescription(propertyName: string) {
|
||||
items: '列表数据',
|
||||
key: '唯一键',
|
||||
keyword: '匹配关键词',
|
||||
lastConnectedAt: '最后连接时间',
|
||||
lastError: '最近异常',
|
||||
lastHeartbeatAt: '最后心跳时间',
|
||||
lastLoginAt: '最近扫码登录时间',
|
||||
lastMessage: '最后一条消息',
|
||||
lastModified: '最后修改时间',
|
||||
matchType: '匹配方式',
|
||||
@ -582,6 +589,7 @@ function getPropertyDescription(propertyName: string) {
|
||||
mode: '过滤模式',
|
||||
msg: '响应消息',
|
||||
name: '名称',
|
||||
napcat: 'NapCat 容器运行信息',
|
||||
nickname: '昵称',
|
||||
objectName: '对象名称',
|
||||
onlineAccountCount: '在线账号数',
|
||||
@ -608,6 +616,7 @@ function getPropertyDescription(propertyName: string) {
|
||||
todaySendCount: '今日发送数',
|
||||
total: '总条数',
|
||||
triggerMode: '触发方式',
|
||||
webuiPort: 'NapCat WebUI 端口',
|
||||
type: '类型',
|
||||
url: '访问地址',
|
||||
userId: '用户 QQ 号',
|
||||
@ -809,12 +818,20 @@ function componentExample() {
|
||||
|
||||
function qqbotAccountExample() {
|
||||
return {
|
||||
connectStatus: 'online',
|
||||
connectionMode: 'reverse-ws',
|
||||
enabled: true,
|
||||
id: '1000000000000000001',
|
||||
selfId: '1914728559',
|
||||
nickname: 'Mirror',
|
||||
status: 'online',
|
||||
connectionRole: 'universal',
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -12,10 +12,32 @@ import type {
|
||||
QqbotAccountQueryDto,
|
||||
QqbotAccountUpdateDto,
|
||||
} 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 type { QqbotConnectionRole } from '../qqbot.types';
|
||||
import type {
|
||||
QqbotAccountNapcatBindStatus,
|
||||
QqbotConnectionRole,
|
||||
QqbotNapcatContainerStatus,
|
||||
} from '../qqbot.types';
|
||||
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()
|
||||
export class QqbotAccountService {
|
||||
constructor(
|
||||
@ -23,6 +45,10 @@ export class QqbotAccountService {
|
||||
private readonly accountRepository: Repository<QqbotAccount>,
|
||||
@InjectRepository(QqbotAccountAbility)
|
||||
private readonly accountAbilityRepository: Repository<QqbotAccountAbility>,
|
||||
@InjectRepository(QqbotAccountNapcat)
|
||||
private readonly accountNapcatRepository: Repository<QqbotAccountNapcat>,
|
||||
@InjectRepository(QqbotNapcatContainer)
|
||||
private readonly napcatContainerRepository: Repository<QqbotNapcatContainer>,
|
||||
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')
|
||||
.skip(skip)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
const list = await this.appendNapcatRuntime(accounts);
|
||||
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) {
|
||||
const exists = await this.accountRepository.findOne({
|
||||
where: {
|
||||
|
||||
82
src/qqbot/account/qqbot-napcat-login.service.spec.ts
Normal file
82
src/qqbot/account/qqbot-napcat-login.service.spec.ts
Normal 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('二维码已过期');
|
||||
});
|
||||
});
|
||||
@ -38,6 +38,8 @@ type NapcatLoginStatus = {
|
||||
|
||||
type NapcatQrcode = {
|
||||
qrcode?: string;
|
||||
qrcodeurl?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
type QqbotLoginScanSession = {
|
||||
@ -114,9 +116,9 @@ export class QqbotNapcatLoginService {
|
||||
}
|
||||
|
||||
const container = await this.getSessionContainer(session);
|
||||
await this.callRefreshQrcode(container);
|
||||
session.qrcode = await this.getQrcode(container);
|
||||
session.qrcode = await this.refreshOrGetQrcode(container, true);
|
||||
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||||
session.errorMessage = undefined;
|
||||
this.sessions.set(session.id, session);
|
||||
return this.toResult(session);
|
||||
}
|
||||
@ -131,7 +133,9 @@ export class QqbotNapcatLoginService {
|
||||
const status = await this.getLoginStatus(container);
|
||||
if (!status.isLogin) {
|
||||
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);
|
||||
return this.toResult(session);
|
||||
}
|
||||
@ -170,13 +174,11 @@ export class QqbotNapcatLoginService {
|
||||
return this.completeLogin(session, container);
|
||||
}
|
||||
|
||||
let qrcode = this.isExpiredQrcodeStatus(loginStatus)
|
||||
? ''
|
||||
: loginStatus.qrcodeurl || '';
|
||||
if (!qrcode) {
|
||||
await this.callRefreshQrcode(container, true);
|
||||
qrcode = await this.getQrcode(container, true);
|
||||
}
|
||||
const qrcode = await this.refreshOrGetQrcode(
|
||||
container,
|
||||
true,
|
||||
loginStatus,
|
||||
);
|
||||
const session = this.createSession({
|
||||
...options,
|
||||
container,
|
||||
@ -374,11 +376,15 @@ export class QqbotNapcatLoginService {
|
||||
container: QqbotNapcatRuntime,
|
||||
retry = false,
|
||||
) {
|
||||
await this.executeNapcatRequest(retry, async () => {
|
||||
return this.executeNapcatRequest(retry, async () => {
|
||||
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) {
|
||||
if (this.isAlreadyLoggedIn(err)) return;
|
||||
if (this.isAlreadyLoggedIn(err)) return '';
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
@ -391,10 +397,11 @@ export class QqbotNapcatLoginService {
|
||||
container,
|
||||
'/api/QQLogin/GetQQLoginQrcode',
|
||||
);
|
||||
if (!data.qrcode) {
|
||||
const qrcode = this.pickQrcode(data);
|
||||
if (!qrcode) {
|
||||
return this.getQrcodeFromStatus(container);
|
||||
}
|
||||
return data.qrcode;
|
||||
return qrcode;
|
||||
} catch (err) {
|
||||
if (this.isAlreadyLoggedIn(err)) {
|
||||
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) {
|
||||
const status = await this.getLoginStatus(container);
|
||||
if (status.qrcodeurl && !this.isExpiredQrcodeStatus(status)) {
|
||||
@ -416,6 +443,11 @@ export class QqbotNapcatLoginService {
|
||||
throwVbenError('NapCat 未返回登录二维码');
|
||||
}
|
||||
|
||||
private pickQrcode(data?: NapcatQrcode | null) {
|
||||
if (!data) return '';
|
||||
return `${data.qrcode || data.qrcodeurl || data.url || ''}`.trim();
|
||||
}
|
||||
|
||||
private async postNapcat<T>(
|
||||
container: QqbotNapcatRuntime,
|
||||
path: string,
|
||||
|
||||
84
src/qqbot/command/qqbot-command-parser.service.spec.ts
Normal file
84
src/qqbot/command/qqbot-command-parser.service.spec.ts
Normal 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: '琥珀原',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -14,6 +14,8 @@ import {
|
||||
splitQqbotFf14WorldPath,
|
||||
} from '../plugins/ff14Market/qqbot-ff14-worlds';
|
||||
|
||||
const QQBOT_FFLOGS_ENCOUNTER_DICT_CODE = 'FFLOGS_ENCOUNTER_LABEL';
|
||||
|
||||
export type QqbotCommandMatchResult = {
|
||||
alias: string;
|
||||
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 flags = new Map<string, string | true>();
|
||||
const positional: string[] = [];
|
||||
@ -176,18 +178,74 @@ export class QqbotCommandParserService {
|
||||
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) {
|
||||
const joined = positional.join(' ');
|
||||
if (!characterName && remainingPositionals[0]?.includes('@')) {
|
||||
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('@')) {
|
||||
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 if (remainingPositionals.length > 1) {
|
||||
serverSlug = remainingPositionals[remainingPositionals.length - 1];
|
||||
characterName = remainingPositionals.slice(0, -1).join(' ');
|
||||
} else {
|
||||
characterName = joined;
|
||||
}
|
||||
@ -199,10 +257,13 @@ export class QqbotCommandParserService {
|
||||
difficulty: this.normalizeString(
|
||||
flags.get('difficulty') || flags.get('难度'),
|
||||
),
|
||||
encounter: encounterName,
|
||||
encounterName,
|
||||
metric: this.normalizeString(flags.get('metric') || flags.get('指标')),
|
||||
partition: this.normalizeString(
|
||||
flags.get('partition') || flags.get('分区'),
|
||||
),
|
||||
limit: this.normalizeString(flags.get('limit') || flags.get('数量')),
|
||||
raw: rawArgs,
|
||||
role: this.normalizeString(flags.get('role') || flags.get('职责')),
|
||||
serverRegion: this.normalizeString(
|
||||
@ -218,9 +279,7 @@ export class QqbotCommandParserService {
|
||||
timeframe: this.normalizeString(
|
||||
flags.get('timeframe') || flags.get('时间') || flags.get('范围'),
|
||||
),
|
||||
zoneId: this.normalizeString(
|
||||
flags.get('zone') || flags.get('zoneId') || flags.get('副本'),
|
||||
),
|
||||
zoneId,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
const treeCatalog = buildQqbotFf14MarketCatalogFromTree(
|
||||
await this.dictService.tree({
|
||||
await this.dictService.relationTree({
|
||||
dictCode: QQBOT_FF14_MARKET_DICT_CODES.region,
|
||||
}),
|
||||
);
|
||||
@ -329,4 +447,22 @@ export class QqbotCommandParserService {
|
||||
if (value === true) return '';
|
||||
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, '');
|
||||
}
|
||||
}
|
||||
|
||||
@ -292,7 +292,7 @@ export class QqbotFf14ClientService {
|
||||
|
||||
private async getFf14MarketCatalog() {
|
||||
const treeCatalog = buildQqbotFf14MarketCatalogFromTree(
|
||||
await this.dictService.tree({
|
||||
await this.dictService.relationTree({
|
||||
dictCode: QQBOT_FF14_MARKET_DICT_CODES.region,
|
||||
}),
|
||||
);
|
||||
|
||||
@ -14,11 +14,14 @@ import { QqbotFflogsClientService } from './qqbot-fflogs-client.service';
|
||||
describe('QqbotFflogsClientService', () => {
|
||||
const dicts = {
|
||||
FFLOGS_ENCOUNTER_LABEL: [
|
||||
{ label: 'M9S Vamp Fatale', value: 'vampfatale' },
|
||||
{ label: 'M9S 吸血鬼偶像', value: 'vampfatale' },
|
||||
{ label: 'M12S P2 Lindwurm II', value: 'lindwurmii' },
|
||||
],
|
||||
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_SERVER_REGION_LABEL: [{ label: '国服', value: 'cn' }],
|
||||
};
|
||||
@ -31,6 +34,9 @@ describe('QqbotFflogsClientService', () => {
|
||||
getDictByKey: jest.fn(
|
||||
async (dictKey: keyof typeof dicts) => dicts[dictKey] || [],
|
||||
),
|
||||
getDictItemsByKey: jest.fn(
|
||||
async (dictKey: keyof typeof dicts) => dicts[dictKey] || [],
|
||||
),
|
||||
} as unknown as DictService,
|
||||
);
|
||||
|
||||
@ -62,8 +68,54 @@ describe('QqbotFflogsClientService', () => {
|
||||
|
||||
expect(replyText).toContain('FFLogs 战绩:Kwi柊司 @ 琥珀原(国服)');
|
||||
expect(replyText).toContain(
|
||||
'1. M9S Vamp Fatale:72.4% | DPS 36,635 | 机工士',
|
||||
'1. M9S 吸血鬼偶像:72.4% | DPS 36,635 | 机工士',
|
||||
);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -21,6 +21,9 @@ type FflogsCharacter = {
|
||||
id?: number;
|
||||
lodestoneID?: number;
|
||||
name?: string;
|
||||
recentReports?: {
|
||||
data?: FflogsRecentReport[];
|
||||
};
|
||||
server?: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
@ -28,14 +31,87 @@ type FflogsCharacter = {
|
||||
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 = {
|
||||
characterData?: {
|
||||
character?: FflogsCharacter | null;
|
||||
};
|
||||
};
|
||||
|
||||
type FflogsReportFightMetricsResponse = {
|
||||
reportData?: {
|
||||
report?: {
|
||||
damage?: unknown;
|
||||
dpsRankings?: unknown;
|
||||
healing?: unknown;
|
||||
hpsRankings?: unknown;
|
||||
} | null;
|
||||
};
|
||||
};
|
||||
|
||||
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 = {
|
||||
encounter: 'FFLOGS_ENCOUNTER_LABEL',
|
||||
job: 'FFLOGS_JOB_LABEL',
|
||||
@ -54,8 +130,12 @@ export type QqbotFflogsCharacterSummaryInput = {
|
||||
characterName?: string;
|
||||
className?: string;
|
||||
difficulty?: number | string;
|
||||
encounter?: string;
|
||||
encounterName?: string;
|
||||
limit?: number | string;
|
||||
metric?: string;
|
||||
partition?: number | string;
|
||||
reportsLimit?: number | string;
|
||||
role?: string;
|
||||
server?: string;
|
||||
serverRegion?: string;
|
||||
@ -70,6 +150,8 @@ export type QqbotFflogsCharacterSummaryResult = {
|
||||
allStarText?: string;
|
||||
characterId?: number;
|
||||
characterName: string;
|
||||
encounterName?: string;
|
||||
logs?: QqbotFflogsEncounterLogItem[];
|
||||
rankings: FflogsRankingItem[];
|
||||
replyText: string;
|
||||
serverName: string;
|
||||
@ -137,6 +219,17 @@ export class QqbotFflogsClientService {
|
||||
if (!serverRegion)
|
||||
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 = {
|
||||
characterName,
|
||||
className: this.normalizeOptionalString(params.className),
|
||||
@ -224,7 +317,7 @@ export class QqbotFflogsClientService {
|
||||
allStarText,
|
||||
characterId: character.id,
|
||||
characterName: character.name || characterName,
|
||||
metric: variables.metric || 'DPS',
|
||||
metric: variables.metric || 'dps',
|
||||
rankings,
|
||||
localizationMaps,
|
||||
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() {
|
||||
if (this.accessToken && Date.now() < this.accessTokenExpireAt) {
|
||||
return this.accessToken;
|
||||
@ -378,6 +734,234 @@ export class QqbotFflogsClientService {
|
||||
.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(
|
||||
item: FflogsRankingItem,
|
||||
index: number,
|
||||
@ -481,17 +1065,30 @@ export class QqbotFflogsClientService {
|
||||
)}/${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) {
|
||||
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',
|
||||
adps: 'cdps',
|
||||
cdps: 'cdps',
|
||||
damage: 'dps',
|
||||
dps: 'dps',
|
||||
healer: 'hps',
|
||||
healing: 'hps',
|
||||
hps: 'hps',
|
||||
ndps: 'ndps',
|
||||
rdps: 'rdps',
|
||||
};
|
||||
return map[lower] || raw;
|
||||
}
|
||||
@ -528,6 +1125,24 @@ export class QqbotFflogsClientService {
|
||||
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) {
|
||||
const raw = `${value || ''}`.trim();
|
||||
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) {
|
||||
const rank = value.replace(/^#/, '').trim();
|
||||
if (!rank) return '';
|
||||
@ -590,12 +1232,13 @@ export class QqbotFflogsClientService {
|
||||
|
||||
private async getNormalizedDictMap(dictCode: string) {
|
||||
const dicts = await this.dictService.getDictByKey(dictCode);
|
||||
return new Map(
|
||||
dicts.map(({ label, value }) => [
|
||||
this.normalizeLookupKey(`${value}`),
|
||||
`${label}`,
|
||||
]),
|
||||
);
|
||||
const map = new Map<string, string>();
|
||||
for (const { label, value } of dicts) {
|
||||
for (const key of this.buildLookupKeys(`${value}`, `${label}`)) {
|
||||
map.set(key, `${label}`);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private localizeEncounter(
|
||||
@ -639,7 +1282,25 @@ export class QqbotFflogsClientService {
|
||||
}
|
||||
|
||||
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>) {
|
||||
|
||||
@ -8,7 +8,8 @@ export class QqbotFflogsPluginService {
|
||||
|
||||
getPlugin(): QqbotIntegrationPlugin {
|
||||
return {
|
||||
description: '对接 FFLogs v2 GraphQL,提供 FF14 角色公开排名查询能力。',
|
||||
description:
|
||||
'对接 FFLogs v2 GraphQL,提供 FF14 角色公开排名和指定高难最近记录查询能力。',
|
||||
healthCheck: async () => {
|
||||
const checkedAt = new Date().toISOString();
|
||||
try {
|
||||
@ -31,10 +32,20 @@ export class QqbotFflogsPluginService {
|
||||
operations: [
|
||||
{
|
||||
cacheTtlMs: 60_000,
|
||||
description: '查询指定角色的 FFLogs 公开排名摘要。',
|
||||
description:
|
||||
'查询指定角色的 FFLogs 公开排名摘要;传入 encounter/任务 后查询指定高难最近10次记录。',
|
||||
inputSchema: {
|
||||
properties: {
|
||||
characterName: { description: '角色名', type: 'string' },
|
||||
encounter: {
|
||||
description: '高难任务名,支持字典维护的中文名/英文名/缩写',
|
||||
type: 'string',
|
||||
},
|
||||
limit: {
|
||||
default: 10,
|
||||
description: '最近记录数量,最多10条',
|
||||
type: 'number',
|
||||
},
|
||||
metric: { description: '排名指标,如 dps/hps', type: 'string' },
|
||||
serverRegion: {
|
||||
default: 'CN',
|
||||
@ -46,7 +57,10 @@ export class QqbotFflogsPluginService {
|
||||
description: 'Today 或 Historical',
|
||||
type: 'string',
|
||||
},
|
||||
zoneId: { description: '副本区域 ID', type: 'number' },
|
||||
zoneId: {
|
||||
description: '副本区域 ID,用于排名摘要',
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
required: ['characterName', 'serverSlug'],
|
||||
type: 'object',
|
||||
@ -56,6 +70,8 @@ export class QqbotFflogsPluginService {
|
||||
outputSchema: {
|
||||
properties: {
|
||||
characterName: { type: 'string' },
|
||||
encounterName: { type: 'string' },
|
||||
logs: { type: 'array' },
|
||||
rankings: { type: 'array' },
|
||||
replyText: { type: 'string' },
|
||||
url: { type: 'string' },
|
||||
|
||||
Loading…
Reference in New Issue
Block a user