fix: 修复NapCat更新登录二维码兜底

This commit is contained in:
sunlei 2026-06-13 10:03:31 +08:00
parent 0a46e44097
commit d03e7c09a4
4 changed files with 316 additions and 7 deletions

View File

@ -1285,6 +1285,10 @@ export class QqbotNapcatLoginService {
}
if (!loginStatus.isLogin) {
if (this.isPasswordQrcodeChallenge(loginStatus)) {
await this.keepPasswordQrcodePending(session, container, loginStatus);
return true;
}
const captchaUrl = await this.resolvePasswordCaptchaUrl(
container,
loginStatus,
@ -1363,6 +1367,45 @@ export class QqbotNapcatLoginService {
return true;
}
private async keepPasswordQrcodePending(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
loginStatus: NapcatLoginStatus,
) {
await this.clearRuntimeLoginPasswordAfterFailedPassword(
session,
container,
session.expectedSelfId,
);
this.publishScanResultEvent(
session,
'password-login-qrcode',
'processing',
'密码登录未完成,已切换到扫码确认',
);
session.qrcode = await this.refreshOrGetQrcode(container, true, {
fallbackStatus: loginStatus,
requireFresh: this.toolsService.isNapcatExpiredQrcodeStatus(loginStatus),
staleQrcode: loginStatus.qrcodeurl,
});
session.captchaUrl = undefined;
session.errorMessage = undefined;
session.expiresAt = Date.now() + this.getSessionTtlMs();
this.sessions.set(session.id, session);
this.publishScanResultEvent(
session,
'qrcode-ready',
'success',
'登录二维码已生成',
);
this.publishScanResultEvent(
session,
'waiting-scan',
'processing',
'等待扫码确认',
);
}
private async resolvePasswordCaptchaUrl(
container: QqbotNapcatRuntime,
loginStatus: NapcatLoginStatus,
@ -1385,6 +1428,13 @@ export class QqbotNapcatLoginService {
return this.toolsService.extractNapcatCaptchaUrl(status.loginError);
}
private isPasswordQrcodeChallenge(status: NapcatLoginStatus) {
return (
!!this.toolsService.toTrimmedString(status.qrcodeurl) ||
this.toolsService.isNapcatExpiredQrcodeStatus(status)
);
}
private async clearRuntimeLoginPasswordBeforeQuick(
session: QqbotLoginScanSession,
container: QqbotNapcatRuntime,
@ -1666,6 +1716,7 @@ export class QqbotNapcatLoginService {
latestStatus = await this.getLoginStatus(container, true);
if (
latestStatus.isLogin ||
this.isPasswordQrcodeChallenge(latestStatus) ||
this.toolsService.isNapcatCaptchaRequiredMessage(
latestStatus.loginError,
)

View File

@ -132,15 +132,19 @@ export class QqbotNapcatContainerService {
skipPull: true,
token: container.webuiToken,
});
const verified = await this.runtimeMatchesLoginEnv(container.name, {
...options,
selfId: account,
});
await this.containerRepository.update(
{ id: container.id },
{
lastError: null,
lastError: verified ? null : 'NapCat 运行态登录环境校验失败',
lastStartedAt: new Date(),
status: 'running',
},
);
return { changed: true, ok: true };
return { changed: true, ok: verified };
} catch {
return { changed: false, ok: false };
}

View File

@ -748,6 +748,162 @@ describe('QqbotNapcatLoginService', () => {
expect(session.captchaUrl).toBe(captchaUrl);
});
it('keeps qrcode pending when password login falls into NapCat qrcode status', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-password-qrcode',
name: 'napcat-10001',
};
const containerService = {
ensureRuntimeLoginEnv: jest
.fn()
.mockResolvedValueOnce({ changed: false, ok: true })
.mockResolvedValueOnce({ changed: true, ok: true })
.mockResolvedValueOnce({ changed: true, ok: true }),
resetRuntimeLoginState: jest.fn().mockResolvedValue(true),
restartRuntimeContainer: jest.fn().mockResolvedValue(true),
};
const refreshService = new QqbotNapcatLoginService(
{
get: jest.fn((key: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1',
QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '10',
QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS: '1',
};
return values[key] || '';
}),
} as unknown as ConfigService,
{} as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const session = (refreshService as any).createSession({
accountId: 'account-1',
container,
expectedSelfId: '10001',
mode: 'refresh',
preparingRelogin: true,
status: 'pending',
});
(refreshService as any).sessions.set(session.id, session);
jest
.spyOn((refreshService as any).toolsService, 'sleep')
.mockResolvedValue(undefined);
jest
.spyOn(refreshService as any, 'getLoginStatus')
.mockResolvedValueOnce({
isLogin: false,
loginError: '快速登录未找到历史会话',
})
.mockResolvedValueOnce({
isLogin: false,
loginError: '二维码已过期,请刷新',
qrcodeurl: 'expired-qrcode',
});
const refreshQrcode = jest
.spyOn(refreshService as any, 'refreshOrGetQrcode')
.mockResolvedValue('fresh-qrcode');
await (refreshService as any).prepareReloginQrcode(
session,
container,
'qq-password',
);
expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled();
expect(refreshQrcode).toHaveBeenCalledWith(
container,
true,
expect.objectContaining({
requireFresh: true,
staleQrcode: 'expired-qrcode',
}),
);
expect(session.status).toBe('pending');
expect(session.captchaUrl).toBeUndefined();
expect(session.passwordMd5).toBeUndefined();
expect(session.qrcode).toBe('fresh-qrcode');
const steps = (
(refreshService as any).sessionEventLogs.get(session.id) ?? []
).map((event: { step: string }) => event.step);
expect(steps).toContain('password-login-qrcode');
expect(steps).not.toContain('relogin-reset-start');
});
it('prioritizes current qrcode status over stale captcha URL in runtime logs', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-password-qrcode-over-captcha-log',
name: 'napcat-10001',
};
const containerService = {
detectRuntimeCaptchaUrl: jest
.fn()
.mockResolvedValue(
'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001',
),
ensureRuntimeLoginEnv: jest
.fn()
.mockResolvedValueOnce({ changed: false, ok: true })
.mockResolvedValueOnce({ changed: true, ok: true })
.mockResolvedValueOnce({ changed: true, ok: true }),
resetRuntimeLoginState: jest.fn().mockResolvedValue(true),
restartRuntimeContainer: jest.fn().mockResolvedValue(true),
};
const refreshService = new QqbotNapcatLoginService(
{
get: jest.fn((key: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1',
QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '10',
};
return values[key] || '';
}),
} as unknown as ConfigService,
{} as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
new ToolsService(),
);
const session = (refreshService as any).createSession({
accountId: 'account-1',
container,
expectedSelfId: '10001',
mode: 'refresh',
preparingRelogin: true,
status: 'pending',
});
(refreshService as any).sessions.set(session.id, session);
jest
.spyOn((refreshService as any).toolsService, 'sleep')
.mockResolvedValue(undefined);
jest
.spyOn(refreshService as any, 'getLoginStatus')
.mockResolvedValueOnce({
isLogin: false,
loginError: '快速登录未找到历史会话',
})
.mockResolvedValueOnce({
isLogin: false,
loginError: '二维码已过期,请刷新',
qrcodeurl: 'expired-qrcode',
});
jest
.spyOn(refreshService as any, 'refreshOrGetQrcode')
.mockResolvedValue('fresh-qrcode');
await (refreshService as any).prepareReloginQrcode(
session,
container,
'qq-password',
);
expect(containerService.detectRuntimeCaptchaUrl).not.toHaveBeenCalled();
expect(session.status).toBe('pending');
expect(session.captchaUrl).toBeUndefined();
expect(session.qrcode).toBe('fresh-qrcode');
});
it('submits captcha result back to NapCat and completes password login', async () => {
const accountService = {
ensureScannedAccount: jest.fn().mockResolvedValue('account-1'),
@ -964,6 +1120,42 @@ describe('QqbotNapcatLoginService', () => {
expect(sleep).not.toHaveBeenCalled();
});
it('returns qrcode status immediately without waiting full password window', async () => {
const refreshService = new QqbotNapcatLoginService(
{
get: jest.fn((key: string) => {
const values: Record<string, string> = {
QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1000',
QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '3000',
};
return values[key] || '';
}),
} as unknown as ConfigService,
{} as QqbotAccountService,
{} as QqbotNapcatContainerService,
new ToolsService(),
);
const getLoginStatus = jest
.spyOn(refreshService as any, 'getLoginStatus')
.mockResolvedValue({
isLogin: false,
loginError: '二维码已过期,请刷新',
qrcodeurl: 'expired-qrcode',
});
const sleep = jest.spyOn((refreshService as any).toolsService, 'sleep');
const status = await (refreshService as any).waitForPasswordLoginStatus({
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-qrcode-early',
name: 'napcat-10001',
webuiToken: 'token',
});
expect(status.qrcodeurl).toBe('expired-qrcode');
expect(getLoginStatus).toHaveBeenCalledTimes(1);
expect(sleep).not.toHaveBeenCalled();
});
it('does not keep stale captcha pending after captcha submit returns non-captcha failure', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',

View File

@ -418,7 +418,11 @@ describe('QqbotNapcatContainerService', () => {
service.runProcess = jest
.fn()
.mockRejectedValueOnce(new Error('inspect failed'))
.mockResolvedValueOnce({ stderr: '', stdout: '' });
.mockResolvedValueOnce({ stderr: '', stdout: '' })
.mockResolvedValueOnce({
stderr: '',
stdout: 'ACCOUNT=2354598417\nNAPCAT_QUICK_PASSWORD=qq-password\n',
});
const recreated = await service.ensureRuntimeLoginEnv(
{ id: 'container-1', name: 'kt-qqbot-napcat-x' },
@ -429,7 +433,7 @@ describe('QqbotNapcatContainerService', () => {
);
expect(recreated).toEqual({ changed: true, ok: true });
expect(service.runProcess).toHaveBeenCalledTimes(2);
expect(service.runProcess).toHaveBeenCalledTimes(3);
expect(service.runProcess.mock.calls[1][2]).toContain(
"NAPCAT_QUICK_PASSWORD='qq-password'",
);
@ -466,7 +470,11 @@ describe('QqbotNapcatContainerService', () => {
service.runProcess = jest
.fn()
.mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=2354598417\n' })
.mockResolvedValueOnce({ stderr: '', stdout: '' });
.mockResolvedValueOnce({ stderr: '', stdout: '' })
.mockResolvedValueOnce({
stderr: '',
stdout: 'ACCOUNT=2354598417\nNAPCAT_QUICK_PASSWORD=qq-password\n',
});
const recreated = await service.ensureRuntimeLoginEnv(
{ id: 'container-1', name: 'kt-qqbot-napcat-x' },
@ -477,7 +485,7 @@ describe('QqbotNapcatContainerService', () => {
);
expect(recreated).toEqual({ changed: true, ok: true });
expect(service.runProcess).toHaveBeenCalledTimes(2);
expect(service.runProcess).toHaveBeenCalledTimes(3);
expect(service.runProcess.mock.calls[1][2]).toContain(
"NAPCAT_QUICK_PASSWORD='qq-password'",
);
@ -490,6 +498,59 @@ describe('QqbotNapcatContainerService', () => {
);
});
it('fails login env preparation when recreated container still misses password env', async () => {
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getOne: jest.fn().mockResolvedValue({
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x',
id: 'container-1',
image: 'mlikiowa/napcat-docker:latest',
name: 'kt-qqbot-napcat-x',
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
webuiPort: 6100,
webuiToken: 'token-x',
}),
where: jest.fn().mockReturnThis(),
})),
update: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string) =>
key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '',
),
} as any,
containerRepository as any,
{} as any,
new ToolsService(),
) as any;
service.runProcess = jest
.fn()
.mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=2354598417\n' })
.mockResolvedValueOnce({ stderr: '', stdout: '' })
.mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=2354598417\n' });
const recreated = await service.ensureRuntimeLoginEnv(
{ id: 'container-1', name: 'kt-qqbot-napcat-x' },
{
loginPassword: 'qq-password',
selfId: '2354598417',
},
);
expect(recreated).toEqual({ changed: true, ok: false });
expect(service.runProcess).toHaveBeenCalledTimes(3);
expect(containerRepository.update).toHaveBeenCalledWith(
{ id: 'container-1' },
expect.objectContaining({
lastError: 'NapCat 运行态登录环境校验失败',
status: 'running',
}),
);
});
it('removes password env for a quick-login-only runtime stage', async () => {
const containerRepository = {
createQueryBuilder: jest.fn(() => ({
@ -524,7 +585,8 @@ describe('QqbotNapcatContainerService', () => {
stderr: '',
stdout: 'ACCOUNT=2354598417\nNAPCAT_QUICK_PASSWORD=old-password\n',
})
.mockResolvedValueOnce({ stderr: '', stdout: '' });
.mockResolvedValueOnce({ stderr: '', stdout: '' })
.mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=2354598417\n' });
const recreated = await service.ensureRuntimeLoginEnv(
{ id: 'container-1', name: 'kt-qqbot-napcat-x' },