fix: 补齐QQBot插件分页与新设备二维码

This commit is contained in:
sunlei 2026-06-15 15:07:40 +08:00
parent 8bcb5f78a9
commit 201fa13151
7 changed files with 196 additions and 17 deletions

View File

@ -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",

View File

@ -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

View File

@ -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',
});
}
}

View File

@ -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)
: []),
];
}
}

View File

@ -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

View File

@ -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',

View File

@ -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',
}),
]);
});
});