diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index 0e1222a..272f64f 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -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), diff --git a/src/admin/dict/dict.controller.ts b/src/admin/dict/dict.controller.ts index 74def63..3e0119f 100644 --- a/src/admin/dict/dict.controller.ts +++ b/src/admin/dict/dict.controller.ts @@ -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() { diff --git a/src/admin/dict/dict.dto.ts b/src/admin/dict/dict.dto.ts index 3205142..394ebbf 100644 --- a/src/admin/dict/dict.dto.ts +++ b/src/admin/dict/dict.dto.ts @@ -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; diff --git a/src/admin/dict/dict.service.spec.ts b/src/admin/dict/dict.service.spec.ts new file mode 100644 index 0000000..50571f6 --- /dev/null +++ b/src/admin/dict/dict.service.spec.ts @@ -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']); + }); +}); diff --git a/src/admin/dict/dict.service.ts b/src/admin/dict/dict.service.ts index 2b5a6a8..62e5e27 100644 --- a/src/admin/dict/dict.service.ts +++ b/src/admin/dict/dict.service.ts @@ -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, diff --git a/src/common/swagger/swagger-response.ts b/src/common/swagger/swagger-response.ts index 3de233a..83ab10d 100644 --- a/src/common/swagger/swagger-response.ts +++ b/src/common/swagger/swagger-response.ts @@ -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', }; } diff --git a/src/qqbot/account/qqbot-account.service.ts b/src/qqbot/account/qqbot-account.service.ts index d53ad15..29b2b4c 100644 --- a/src/qqbot/account/qqbot-account.service.ts +++ b/src/qqbot/account/qqbot-account.service.ts @@ -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, @InjectRepository(QqbotAccountAbility) private readonly accountAbilityRepository: Repository, + @InjectRepository(QqbotAccountNapcat) + private readonly accountNapcatRepository: Repository, + @InjectRepository(QqbotNapcatContainer) + private readonly napcatContainerRepository: Repository, 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 { + 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(); + 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(); + 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: { diff --git a/src/qqbot/account/qqbot-napcat-login.service.spec.ts b/src/qqbot/account/qqbot-napcat-login.service.spec.ts new file mode 100644 index 0000000..6e335a3 --- /dev/null +++ b/src/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -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('二维码已过期'); + }); +}); diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts index e43fb05..9510ae4 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -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(container, '/api/QQLogin/RefreshQRcode'); + const data = await this.postNapcat( + 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( container: QqbotNapcatRuntime, path: string, diff --git a/src/qqbot/command/qqbot-command-parser.service.spec.ts b/src/qqbot/command/qqbot-command-parser.service.spec.ts new file mode 100644 index 0000000..84fadb4 --- /dev/null +++ b/src/qqbot/command/qqbot-command-parser.service.spec.ts @@ -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: '琥珀原', + }); + }); +}); diff --git a/src/qqbot/command/qqbot-command-parser.service.ts b/src/qqbot/command/qqbot-command-parser.service.ts index 90aac96..cf33bd2 100644 --- a/src/qqbot/command/qqbot-command-parser.service.ts +++ b/src/qqbot/command/qqbot-command-parser.service.ts @@ -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; @@ -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(); 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(); + for (const item of dicts) { + for (const key of this.buildFflogsLookupKeys(item.label, item.value)) { + keys.add(key); + } + } + return keys; + } + + private isFflogsEncounterName(catalog: Set, 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, ''); + } } diff --git a/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts b/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts index 5fb04fe..4619615 100644 --- a/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts +++ b/src/qqbot/plugins/ff14Market/qqbot-ff14-client.service.ts @@ -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, }), ); diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts index 038439c..9be98bd 100644 --- a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts +++ b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.spec.ts @@ -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'); + }); }); diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts index 2bdcd08..1c89e02 100644 --- a/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts +++ b/src/qqbot/plugins/fflogs/qqbot-fflogs-client.service.ts @@ -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; +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 { + 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( + `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 { + 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( + `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 { + 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 = { - 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(); + 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) { diff --git a/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts b/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts index 0fc20cb..8bd2058 100644 --- a/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts +++ b/src/qqbot/plugins/fflogs/qqbot-fflogs.plugin.ts @@ -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' },