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`)
|
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),
|
||||||
|
|||||||
@ -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() {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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;
|
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,
|
||||||
|
|||||||
@ -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',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
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 = {
|
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,
|
||||||
|
|||||||
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,
|
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, '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 Fatale:72.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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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>) {
|
||||||
|
|||||||
@ -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' },
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user