fix: 修复NapCat扫码状态回填

This commit is contained in:
sunlei 2026-06-23 01:29:50 +08:00
parent 2e960f94d3
commit 0497233798
5 changed files with 229 additions and 0 deletions

View File

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

View File

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

View File

@ -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 },
{

View File

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

View File

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