diff --git a/package.json b/package.json index 7fa4d17..2860d8c 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "pino": "^10.3.1", "pino-http": "^11.0.0", "pino-loki": "^3.0.0", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rehype-parse": "^9.0.1", "rehype-raw": "^7.0.0", @@ -81,6 +82,7 @@ "@types/jest": "29.2.4", "@types/lodash": "^4.17.24", "@types/node": "22.19.19", + "@types/qrcode": "^1.5.6", "@types/supertest": "^2.0.16", "@types/ws": "^8.18.1", "@typescript-eslint/eslint-plugin": "^8.60.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9457f15..395f023 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: pino-loki: specifier: ^3.0.0 version: 3.0.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -147,6 +150,9 @@ importers: '@types/node': specifier: 22.19.19 version: 22.19.19 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/supertest': specifier: ^2.0.16 version: 2.0.16 @@ -1206,6 +1212,9 @@ packages: '@types/node@22.19.19': resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -1757,6 +1766,9 @@ packages: resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==} engines: {node: '>= 12'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1904,6 +1916,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -1978,6 +1994,9 @@ packages: resolution: {integrity: sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==} engines: {node: '>=0.3.1'} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + doctrine@3.0.0: resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} engines: {node: '>=6.0.0'} @@ -3493,6 +3512,10 @@ packages: resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==} engines: {node: '>=4.0.0'} + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + possible-typed-array-names@1.1.0: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} @@ -3549,6 +3572,11 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.15.2: resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} @@ -3644,6 +3672,9 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + requireindex@1.2.0: resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} engines: {node: '>=0.10.5'} @@ -4392,6 +4423,9 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which-typed-array@1.1.21: resolution: {integrity: sha512-zbRA8cVm6io/d5W8uIe2hblzN76/Wm3v/yiythQvr+dpBWeqhPSWIDNj4zOyHi4zKbMK6DN34Xsr9jPHJERAEw==} engines: {node: '>= 0.4'} @@ -4488,6 +4522,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4498,10 +4535,18 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.2: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} @@ -5758,6 +5803,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.19.19 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -6377,6 +6426,12 @@ snapshots: cli-width@4.1.0: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -6498,6 +6553,8 @@ snapshots: dependencies: ms: 2.1.3 + decamelize@1.2.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -6551,6 +6608,8 @@ snapshots: diff@4.0.4: {} + dijkstrajs@1.0.3: {} + doctrine@3.0.0: dependencies: esutils: 2.0.3 @@ -8568,6 +8627,8 @@ snapshots: pngjs@3.4.0: {} + pngjs@5.0.0: {} + possible-typed-array-names@1.1.0: {} prelude-ls@1.2.1: {} @@ -8613,6 +8674,12 @@ snapshots: pure-rand@6.1.0: {} + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -8743,6 +8810,8 @@ snapshots: require-from-string@2.0.2: {} + require-main-filename@2.0.0: {} + requireindex@1.2.0: {} resolve-cwd@3.0.0: @@ -9496,6 +9565,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-module@2.0.1: {} + which-typed-array@1.1.21: dependencies: available-typed-arrays: 1.0.7 @@ -9606,14 +9677,35 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} yallist@4.0.0: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.2: dependencies: cliui: 8.0.1 diff --git a/src/modules/qqbot/napcat/integration/napcat-login-api.client.ts b/src/modules/qqbot/napcat/integration/napcat-login-api.client.ts index 5385a57..403bb60 100644 --- a/src/modules/qqbot/napcat/integration/napcat-login-api.client.ts +++ b/src/modules/qqbot/napcat/integration/napcat-login-api.client.ts @@ -1,3 +1,5 @@ +import * as QRCode from 'qrcode'; + export type NewDeviceQrStatus = | 'confirming' | 'expired' @@ -7,6 +9,7 @@ export type NewDeviceQrStatus = | 'verified'; export type NewDeviceQrCode = { + deviceVerifyUrl?: string; pullQrCodeSig?: string; qrcodeUrl: string; sessionId: string; @@ -44,13 +47,16 @@ export class NapcatLoginApiClient { data.qrcode, data.url, ); - if (!qrcodeUrl) { + const jumpUrl = this.pickString(data.jumpUrl, data.verifyUrl); + const newDeviceQrcodeUrl = qrcodeUrl || (await this.createQrcode(jumpUrl)); + if (!newDeviceQrcodeUrl) { throw new Error('NapCat 未返回新设备验证二维码'); } return { + deviceVerifyUrl: jumpUrl || undefined, pullQrCodeSig: this.pickString(data.newDevicePullQrCodeSig, data.sig), - qrcodeUrl, + qrcodeUrl: newDeviceQrcodeUrl, sessionId, status: 'qr-pending', }; @@ -106,4 +112,14 @@ export class NapcatLoginApiClient { } return ''; } + + private async createQrcode(text: string) { + if (!text) return ''; + return QRCode.toDataURL(text, { + errorCorrectionLevel: 'M', + margin: 1, + scale: 6, + type: 'image/png', + }); + } } diff --git a/src/modules/qqbot/plugin-platform/qqbot-plugin.controller.ts b/src/modules/qqbot/plugin-platform/qqbot-plugin.controller.ts index 542f365..5ed89f3 100644 --- a/src/modules/qqbot/plugin-platform/qqbot-plugin.controller.ts +++ b/src/modules/qqbot/plugin-platform/qqbot-plugin.controller.ts @@ -9,10 +9,20 @@ import { } from '@nestjs/common'; import { ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger'; import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard'; -import { vbenSuccess } from '@/common'; +import { ToolsService, vbenSuccess } from '@/common'; import { QqbotEventPluginRegistryService } from './registry/qqbot-event-plugin-registry.service'; import { QqbotPluginRegistryService } from './registry/qqbot-plugin-registry.service'; -import type { QqbotPluginTriggerMode } from '@/modules/qqbot/core/contract/qqbot.types'; +import type { + QqbotPluginOperationSummary, + QqbotPluginTriggerMode, +} from '@/modules/qqbot/core/contract/qqbot.types'; + +type QqbotPluginOperationPageQuery = { + pageNo?: number | string; + pageSize?: number | string; + pluginKey?: string; + triggerMode?: QqbotPluginTriggerMode; +}; @ApiTags('QQBot - 插件能力') @Controller('qqbot/plugin') @@ -21,6 +31,7 @@ export class QqbotPluginController { constructor( private readonly eventPluginRegistry: QqbotEventPluginRegistryService, private readonly pluginRegistry: QqbotPluginRegistryService, + private readonly toolsService: ToolsService, ) {} @Get('list') @@ -60,14 +71,29 @@ export class QqbotPluginController { @Query('pluginKey') pluginKey?: string, @Query('triggerMode') triggerMode?: QqbotPluginTriggerMode, ) { - return vbenSuccess([ - ...(this.includesTriggerMode('command', triggerMode) - ? this.pluginRegistry.listOperations(pluginKey) - : []), - ...(this.includesTriggerMode('event', triggerMode) - ? this.eventPluginRegistry.listOperations(pluginKey) - : []), - ]); + return vbenSuccess(this.listOperations(pluginKey, triggerMode)); + } + + @Get('operation/page') + @ApiOperation({ summary: 'QQBot 插件能力分页列表' }) + @ApiQuery({ name: 'pageNo', required: false, type: Number }) + @ApiQuery({ name: 'pageSize', required: false, type: Number }) + @ApiQuery({ name: 'pluginKey', required: false, type: String }) + @ApiQuery({ + enum: ['command', 'event'], + name: 'triggerMode', + required: false, + }) + async operationPage(@Query() query: QqbotPluginOperationPageQuery) { + const { pageNo, pageSize, skip } = this.toolsService.getPageParams(query); + const operations = this.listOperations(query.pluginKey, query.triggerMode); + + return vbenSuccess({ + list: operations.slice(skip, skip + pageSize), + pageNo, + pageSize, + total: operations.length, + }); } @Get('health') @@ -132,4 +158,18 @@ export class QqbotPluginController { ) { return !triggerMode || triggerMode === target; } + + private listOperations( + pluginKey?: string, + triggerMode?: QqbotPluginTriggerMode, + ): QqbotPluginOperationSummary[] { + return [ + ...(this.includesTriggerMode('command', triggerMode) + ? this.pluginRegistry.listOperations(pluginKey) + : []), + ...(this.includesTriggerMode('event', triggerMode) + ? this.eventPluginRegistry.listOperations(pluginKey) + : []), + ]; + } } diff --git a/src/modules/qqbot/plugins/bangDream/shared/main-data-store.ts b/src/modules/qqbot/plugins/bangDream/shared/main-data-store.ts index f7adc56..26f3d2e 100644 --- a/src/modules/qqbot/plugins/bangDream/shared/main-data-store.ts +++ b/src/modules/qqbot/plugins/bangDream/shared/main-data-store.ts @@ -110,7 +110,8 @@ const initialLoadPromise = loadMainAPI(true).then(() => { loadMainAPI(); }); -setInterval(loadMainAPI, 1000 * 60 * 5); //5分钟更新一次 +const refreshTimer = setInterval(loadMainAPI, 1000 * 60 * 5); //5分钟更新一次 +refreshTimer.unref?.(); /** * 等待 BangDream 主数据完成首次加载。 diff --git a/test/modules/qqbot/napcat/new-device-flow.spec.ts b/test/modules/qqbot/napcat/new-device-flow.spec.ts index b9ff3fb..1c30b82 100644 --- a/test/modules/qqbot/napcat/new-device-flow.spec.ts +++ b/test/modules/qqbot/napcat/new-device-flow.spec.ts @@ -64,7 +64,7 @@ describe('NapCat new-device flow API client', () => { ]); }); - it('normalizes waiting, expired, and failed poll responses without exposing jumpUrl as the completion mechanism', async () => { + it('encodes jumpUrl as a QR image before polling new-device verification', async () => { const client = new NapcatLoginApiClient({ post: jest .fn() @@ -74,9 +74,15 @@ describe('NapCat new-device flow API client', () => { .mockResolvedValueOnce({ status: 'failed', message: 'denied' }), }); - await expect(client.getNewDeviceQRCode('session-2')).rejects.toThrow( - 'NapCat 未返回新设备验证二维码', - ); + const qr = await client.getNewDeviceQRCode('session-2'); + + expect(qr).toMatchObject({ + deviceVerifyUrl: 'https://qq.example/new-device', + sessionId: 'session-2', + status: 'qr-pending', + }); + expect(qr.qrcodeUrl).toMatch(/^data:image\/png;base64,/); + expect(qr.qrcodeUrl).not.toBe('https://qq.example/new-device'); await expect(client.pollNewDeviceQR('session-2')).resolves.toEqual({ message: undefined, sessionId: 'session-2', diff --git a/test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts b/test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts index c6264d4..06f6e08 100644 --- a/test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts +++ b/test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts @@ -21,6 +21,7 @@ jest.mock('@/modules/qqbot/plugins/fflogs/qqbot-fflogs.plugin', () => ({ import type { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; import * as request from 'supertest'; +import { ToolsService } from '../../../../src/common'; import { QqbotBangDreamPluginService } from '../../../../src/modules/qqbot/plugins/bangDream/qqbot-bangdream.plugin'; import { QqbotFf14MarketPluginService } from '../../../../src/modules/qqbot/plugins/ff14Market/qqbot-ff14-market.plugin'; import { QqbotFflogsPluginService } from '../../../../src/modules/qqbot/plugins/fflogs/qqbot-fflogs.plugin'; @@ -53,6 +54,7 @@ describe('QQBot plugin controller local HTTP smoke', () => { const moduleRef = await Test.createTestingModule({ controllers: [QqbotPluginController], providers: [ + ToolsService, QqbotPluginRegistryService, { provide: QqbotBangDreamPluginService, @@ -115,4 +117,24 @@ describe('QQBot plugin controller local HTTP smoke', () => { triggerMode: 'command', }); }); + + it('returns paged plugin operations for KtTable pagination', async () => { + const response = await request(app.getHttpServer()) + .get('/qqbot/plugin/operation/page') + .query({ pageNo: 2, pageSize: 1, triggerMode: 'command' }) + .expect(200); + + expect(response.body.data).toMatchObject({ + pageNo: 2, + pageSize: 1, + total: 3, + }); + expect(response.body.data.list).toEqual([ + expect.objectContaining({ + key: 'ff14-market.operation', + pluginKey: 'ff14-market', + triggerMode: 'command', + }), + ]); + }); });