fix: 修复NapCat扫码状态回填
This commit is contained in:
parent
2e960f94d3
commit
0497233798
@ -28,6 +28,7 @@ import type {
|
||||
QqbotAccountAbilityType,
|
||||
QqbotAccountListItem,
|
||||
QqbotConnectionRole,
|
||||
QqbotNapcatRuntimeLoginStatus,
|
||||
QqbotNapcatWebuiStatus,
|
||||
QqbotRuntimeContainerStatus,
|
||||
} from '../../contract/qqbot.types';
|
||||
@ -546,6 +547,28 @@ export class QqbotAccountService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the QQ-login-only status reported by NapCat WebUI without changing OneBot or container connectivity.
|
||||
* @param selfId - QQ account number whose NapCat login state was just probed.
|
||||
* @param qqLoginStatus - WebUI login state such as qrcode pending, offline, or online.
|
||||
* @param lastError - Optional human-facing reason; null clears stale risk/offline text when the state is actionable.
|
||||
*/
|
||||
async markQqLoginStatus(
|
||||
selfId: string,
|
||||
qqLoginStatus: QqbotNapcatRuntimeLoginStatus,
|
||||
lastError?: null | string,
|
||||
) {
|
||||
const payload: Partial<QqbotAccount> = {
|
||||
qqLoginStatus,
|
||||
};
|
||||
if (lastError !== undefined) {
|
||||
payload.lastError = lastError
|
||||
? this.toolsService.toColumnText(lastError, 500)
|
||||
: null;
|
||||
}
|
||||
await this.accountRepository.update({ selfId }, payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询 QQBot 核心数据。
|
||||
* @param account - account 输入;驱动 `toolsService.toTrimmedString()` 的 QQBot步骤。
|
||||
|
||||
@ -21,6 +21,7 @@ import type {
|
||||
QqbotLoginScanResult,
|
||||
QqbotLoginScanSession,
|
||||
QqbotLoginScanStatus,
|
||||
QqbotNapcatRuntimeLoginStatus,
|
||||
QqbotNapcatRuntime,
|
||||
QrcodeLookupOptions,
|
||||
QrcodeRefreshOptions,
|
||||
@ -242,6 +243,9 @@ export class QqbotNapcatLoginService {
|
||||
return this.toResult(session);
|
||||
}
|
||||
if (session.preparingRelogin) {
|
||||
if (this.recoverStaleReloginPreparation(session)) {
|
||||
return this.refreshQrcode(sessionId);
|
||||
}
|
||||
return this.keepSessionPending(
|
||||
session,
|
||||
session.errorMessage || 'NapCat 正在尝试快速登录,请稍后',
|
||||
@ -272,6 +276,9 @@ export class QqbotNapcatLoginService {
|
||||
true,
|
||||
);
|
||||
}
|
||||
if (!loginStatus.isLogin) {
|
||||
await this.syncSessionQqLoginStatus(session, loginStatus);
|
||||
}
|
||||
|
||||
if (loginStatus.isOffline && session.mode !== 'refresh') {
|
||||
await this.restartNapcatForLogin(container, { waitForReady: false });
|
||||
@ -354,6 +361,7 @@ export class QqbotNapcatLoginService {
|
||||
);
|
||||
}
|
||||
if (!status.isLogin) {
|
||||
await this.syncSessionQqLoginStatus(session, status);
|
||||
const captchaUrl = this.getCaptchaUrlFromStatus(status);
|
||||
if (captchaUrl) {
|
||||
return this.keepPasswordCaptchaPending(
|
||||
@ -912,6 +920,83 @@ export class QqbotNapcatLoginService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Persists the QQ-login-only status observed during a scan session without altering container or OneBot state.
|
||||
* @param session - Login session that carries the target QQ number for refresh-login flows.
|
||||
* @param status - Latest NapCat WebUI CheckLoginStatus response used as the QQ login source of truth.
|
||||
*/
|
||||
private async syncSessionQqLoginStatus(
|
||||
session: QqbotLoginScanSession,
|
||||
status: NapcatLoginStatus,
|
||||
) {
|
||||
const selfId = this.toolsService.toTrimmedString(session.expectedSelfId);
|
||||
if (!selfId) return;
|
||||
|
||||
const marker = (
|
||||
this.accountService as unknown as {
|
||||
markQqLoginStatus?: (
|
||||
selfId: string,
|
||||
qqLoginStatus: QqbotNapcatRuntimeLoginStatus,
|
||||
lastError?: null | string,
|
||||
) => Promise<void>;
|
||||
}
|
||||
).markQqLoginStatus;
|
||||
if (!marker) return;
|
||||
|
||||
const qqLoginStatus = this.toSessionQqLoginStatus(status);
|
||||
const lastError = this.toSessionQqLoginError(status, qqLoginStatus);
|
||||
await marker.call(this.accountService, selfId, qqLoginStatus, lastError);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a NapCat WebUI login probe into the account-table QQ login status vocabulary.
|
||||
* @param status - Raw WebUI CheckLoginStatus payload returned by the current container.
|
||||
* @returns Persistable QQ-login-only state.
|
||||
*/
|
||||
private toSessionQqLoginStatus(
|
||||
status: NapcatLoginStatus,
|
||||
): QqbotNapcatRuntimeLoginStatus {
|
||||
const message = this.toolsService.toTrimmedString(status.loginError);
|
||||
if (status.isLogin) return 'online';
|
||||
if (
|
||||
this.toolsService.isNapcatExpiredQrcodeStatus(status) ||
|
||||
message.includes('二维码已过期')
|
||||
) {
|
||||
return 'qrcode_expired';
|
||||
}
|
||||
if (status.qrcodeurl) return 'qrcode_pending';
|
||||
if (
|
||||
status.isOffline ||
|
||||
this.toolsService.isNapcatOfflineLoginMessage(message)
|
||||
) {
|
||||
return 'offline';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the account error text that should accompany a QQ-login-only status update.
|
||||
* @param status - Raw WebUI status containing the optional NapCat login error text.
|
||||
* @param qqLoginStatus - Normalized state that decides whether stale error text should be cleared.
|
||||
* @returns Null to clear stale errors, a reason string, or undefined when the account row should be left unchanged.
|
||||
*/
|
||||
private toSessionQqLoginError(
|
||||
status: NapcatLoginStatus,
|
||||
qqLoginStatus: QqbotNapcatRuntimeLoginStatus,
|
||||
) {
|
||||
const message = this.toolsService.toTrimmedString(status.loginError);
|
||||
if (qqLoginStatus === 'online' || qqLoginStatus === 'qrcode_pending') {
|
||||
return message || null;
|
||||
}
|
||||
if (qqLoginStatus === 'offline') {
|
||||
return message || 'NapCat 账号已离线,请重新扫码登录';
|
||||
}
|
||||
if (qqLoginStatus === 'qrcode_expired') {
|
||||
return message || 'NapCat 登录二维码已过期';
|
||||
}
|
||||
return message || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动New Device Verification。
|
||||
* @param session - session 输入;使用 `status`、`captchaUrl`、`qrcode`、`deviceVerifyUrl` 字段生成结果。
|
||||
@ -2178,6 +2263,10 @@ export class QqbotNapcatLoginService {
|
||||
'processing',
|
||||
'等待扫码确认',
|
||||
);
|
||||
await this.syncSessionQqLoginStatus(session, {
|
||||
isLogin: false,
|
||||
qrcodeurl: session.qrcode,
|
||||
});
|
||||
} catch (err) {
|
||||
const message = this.toolsService.getErrorMessage(err);
|
||||
if (this.toolsService.isNapcatTemporaryError(err)) {
|
||||
|
||||
@ -251,6 +251,11 @@ export class NapcatLoginStateStoreService {
|
||||
*/
|
||||
private async markCompleted(sessionId: string) {
|
||||
if (!this.loginSessionRepository) return;
|
||||
const current = await this.loginSessionRepository.findOne({
|
||||
where: { sessionKey: sessionId },
|
||||
});
|
||||
if (!current || current.status === 'pending') return;
|
||||
|
||||
await this.loginSessionRepository.update(
|
||||
{ sessionKey: sessionId },
|
||||
{
|
||||
|
||||
@ -264,6 +264,37 @@ describe('NapCat persistent login state contract', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not mark a still-pending scan session completed when it is removed from the runtime cache', async () => {
|
||||
const loginSessionRepository = createRepository<NapcatLoginSession>();
|
||||
const store = new NapcatLoginStateStoreService(
|
||||
loginSessionRepository as any,
|
||||
);
|
||||
|
||||
store.set({
|
||||
containerId: 'container-pending-delete',
|
||||
containerName: 'kt-qqbot-napcat-pending-delete',
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + 60_000,
|
||||
id: 'pending-delete-session',
|
||||
mode: 'refresh',
|
||||
qrcode: 'https://txz.qq.com/p?k=fresh',
|
||||
status: 'pending',
|
||||
webuiPort: 6099,
|
||||
});
|
||||
await store.flushSessionWrites('pending-delete-session');
|
||||
|
||||
store.delete('pending-delete-session');
|
||||
await store.flushSessionWrites('pending-delete-session');
|
||||
|
||||
expect(loginSessionRepository.rows[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
completedAt: null,
|
||||
sessionKey: 'pending-delete-session',
|
||||
status: 'pending',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('recovers captcha, new-device, and cleanup blockers after cache miss', async () => {
|
||||
const loginSessionRepository = createRepository<NapcatLoginSession>();
|
||||
const loginChallengeRepository =
|
||||
|
||||
@ -3107,6 +3107,87 @@ describe('QqbotNapcatLoginService', () => {
|
||||
expect(getLoginStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refreshes stale relogin qrcode from NapCat status instead of staying in quick login pending', async () => {
|
||||
(service as any).sessions.set('session-stale-relogin-qrcode', {
|
||||
accountId: 'account-1',
|
||||
containerId: 'container-stale-relogin',
|
||||
containerName: 'napcat-stale-relogin',
|
||||
createdAt: Date.now(),
|
||||
errorMessage: 'NapCat 正在尝试快速登录,请稍后',
|
||||
expectedSelfId: '10001',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
id: 'session-stale-relogin-qrcode',
|
||||
lastRestartedAt: 1,
|
||||
mode: 'refresh',
|
||||
preparingRelogin: true,
|
||||
status: 'pending',
|
||||
webuiPort: 6110,
|
||||
});
|
||||
const container = { id: 'container-stale-relogin' };
|
||||
jest
|
||||
.spyOn(service as any, 'getSessionContainer')
|
||||
.mockResolvedValue(container);
|
||||
jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({
|
||||
isLogin: false,
|
||||
qrcodeurl: 'status-qrcode',
|
||||
});
|
||||
jest
|
||||
.spyOn(service as any, 'callRefreshQrcode')
|
||||
.mockResolvedValue('fresh-qrcode');
|
||||
|
||||
const result = await service.refreshQrcode('session-stale-relogin-qrcode');
|
||||
|
||||
expect(result.status).toBe('pending');
|
||||
expect(result.qrcode).toBe('fresh-qrcode');
|
||||
expect(result.errorMessage).toBeUndefined();
|
||||
expect((service as any).sessions.get('session-stale-relogin-qrcode')).toEqual(
|
||||
expect.objectContaining({
|
||||
preparingRelogin: false,
|
||||
qrcode: 'fresh-qrcode',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('syncs account qrcode status when scan status reads a NapCat qrcode', async () => {
|
||||
const accountService = {
|
||||
markQqLoginStatus: jest.fn(),
|
||||
};
|
||||
const syncService = new QqbotNapcatLoginService(
|
||||
{ get: jest.fn() } as unknown as ConfigService,
|
||||
accountService as unknown as QqbotAccountService,
|
||||
{} as QqbotNapcatContainerService,
|
||||
new ToolsService(),
|
||||
);
|
||||
(syncService as any).sessions.set('session-sync-qrcode-status', {
|
||||
accountId: 'account-1',
|
||||
containerId: 'container-sync-status',
|
||||
containerName: 'napcat-sync-status',
|
||||
createdAt: Date.now(),
|
||||
expectedSelfId: '10001',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
id: 'session-sync-qrcode-status',
|
||||
mode: 'refresh',
|
||||
status: 'pending',
|
||||
webuiPort: 6111,
|
||||
});
|
||||
jest
|
||||
.spyOn(syncService as any, 'getSessionContainer')
|
||||
.mockResolvedValue({ id: 'container-sync-status' });
|
||||
jest.spyOn(syncService as any, 'getLoginStatus').mockResolvedValue({
|
||||
isLogin: false,
|
||||
qrcodeurl: 'status-qrcode',
|
||||
});
|
||||
|
||||
const result = await syncService.status('session-sync-qrcode-status');
|
||||
|
||||
expect(result.qrcode).toBe('status-qrcode');
|
||||
expect(accountService.markQqLoginStatus).toHaveBeenCalledWith(
|
||||
'10001',
|
||||
'qrcode_pending',
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('replays scan progress events to late SSE subscribers', () => {
|
||||
const session = {
|
||||
containerId: 'container-events',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user