fix(api): 修复QQBot被踢下线扫码更新

This commit is contained in:
sunlei 2026-06-04 09:19:15 +08:00
parent 8a03e8c8b9
commit 0ddf785b8b
4 changed files with 125 additions and 7 deletions

View File

@ -39,6 +39,7 @@ NAPCAT_WEBUI_TOKEN=
NAPCAT_WEBUI_TIMEOUT_MS=8000 NAPCAT_WEBUI_TIMEOUT_MS=8000
NAPCAT_LOGIN_QR_EXPIRE_MS=120000 NAPCAT_LOGIN_QR_EXPIRE_MS=120000
NAPCAT_WEBUI_READY_RETRIES=10 NAPCAT_WEBUI_READY_RETRIES=10
NAPCAT_WEBUI_RESTART_DELAY_MS=3000
QQBOT_NAPCAT_CONTAINER_MODE= QQBOT_NAPCAT_CONTAINER_MODE=
QQBOT_NAPCAT_SSH_TARGET=nas QQBOT_NAPCAT_SSH_TARGET=nas
QQBOT_NAPCAT_SSH_PORT= QQBOT_NAPCAT_SSH_PORT=

View File

@ -42,6 +42,9 @@ describe('QqbotNapcatLoginService', () => {
jest jest
.spyOn(service as any, 'getSessionContainer') .spyOn(service as any, 'getSessionContainer')
.mockResolvedValue({ id: 'container-1' }); .mockResolvedValue({ id: 'container-1' });
jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({
isLogin: false,
});
jest jest
.spyOn(service as any, 'callRefreshQrcode') .spyOn(service as any, 'callRefreshQrcode')
.mockResolvedValue('new-qrcode-image'); .mockResolvedValue('new-qrcode-image');
@ -108,6 +111,10 @@ describe('QqbotNapcatLoginService', () => {
jest jest
.spyOn(service as any, 'getSessionContainer') .spyOn(service as any, 'getSessionContainer')
.mockResolvedValue(container); .mockResolvedValue(container);
jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({
isLogin: false,
qrcodeurl: 'old-qrcode',
});
jest.spyOn(service as any, 'callRefreshQrcode').mockResolvedValue(''); jest.spyOn(service as any, 'callRefreshQrcode').mockResolvedValue('');
jest.spyOn(service as any, 'getQrcode').mockResolvedValue('new-qrcode'); jest.spyOn(service as any, 'getQrcode').mockResolvedValue('new-qrcode');
@ -120,6 +127,59 @@ describe('QqbotNapcatLoginService', () => {
}); });
}); });
it('restarts the existing NapCat container before qrcode refresh when account is kicked offline', async () => {
const container = {
baseUrl: 'http://127.0.0.1:6103/',
id: 'container-3',
name: 'napcat-10001',
};
const containerService = {
restartRuntimeContainer: jest.fn().mockResolvedValue(true),
};
const refreshService = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService,
{} as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService,
);
jest.spyOn(refreshService as any, 'sleep').mockResolvedValue(undefined);
jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({
isLogin: false,
qrcodeurl: 'new-status-qrcode',
});
jest
.spyOn(refreshService as any, 'callRefreshQrcode')
.mockResolvedValue('');
jest
.spyOn(refreshService as any, 'getQrcode')
.mockResolvedValue('new-qrcode');
const result = await (refreshService as any).refreshOrGetQrcode(
container,
true,
{
fallbackStatus: {
isLogin: false,
isOffline: true,
loginError: '您的账号已在另一台终端登录',
qrcodeurl: 'old-qrcode',
},
},
);
expect(result).toBe('new-qrcode');
expect(containerService.restartRuntimeContainer).toHaveBeenCalledWith(
container,
);
expect((refreshService as any).getQrcode).toHaveBeenCalledWith(
container,
true,
{
requireFresh: true,
staleQrcode: 'old-qrcode',
},
);
});
it('retries while NapCat still exposes the stale qrcode', async () => { it('retries while NapCat still exposes the stale qrcode', async () => {
jest.spyOn(service as any, 'sleep').mockResolvedValue(undefined); jest.spyOn(service as any, 'sleep').mockResolvedValue(undefined);
jest jest

View File

@ -125,9 +125,11 @@ export class QqbotNapcatLoginService {
} }
const container = await this.getSessionContainer(session); const container = await this.getSessionContainer(session);
const loginStatus = await this.getLoginStatus(container);
session.qrcode = await this.refreshOrGetQrcode(container, true, { session.qrcode = await this.refreshOrGetQrcode(container, true, {
fallbackStatus: loginStatus,
requireFresh: true, requireFresh: true,
staleQrcode: session.qrcode, staleQrcode: session.qrcode || loginStatus.qrcodeurl,
}); });
session.expiresAt = Date.now() + this.getSessionTtlMs(); session.expiresAt = Date.now() + this.getSessionTtlMs();
session.errorMessage = undefined; session.errorMessage = undefined;
@ -436,10 +438,15 @@ export class QqbotNapcatLoginService {
retry = false, retry = false,
options: QrcodeRefreshOptions = {}, options: QrcodeRefreshOptions = {},
) { ) {
let fallbackStatus = options.fallbackStatus;
const lookupOptions: QrcodeLookupOptions = { const lookupOptions: QrcodeLookupOptions = {
requireFresh: options.requireFresh, requireFresh: options.requireFresh || fallbackStatus?.isOffline,
staleQrcode: options.staleQrcode, staleQrcode: options.staleQrcode || fallbackStatus?.qrcodeurl,
}; };
if (fallbackStatus?.isOffline) {
await this.restartNapcatForLogin(container);
fallbackStatus = undefined;
}
try { try {
const refreshedQrcode = await this.callRefreshQrcode(container, retry); const refreshedQrcode = await this.callRefreshQrcode(container, retry);
if (refreshedQrcode) { if (refreshedQrcode) {
@ -449,10 +456,10 @@ export class QqbotNapcatLoginService {
} catch (err) { } catch (err) {
if ( if (
!lookupOptions.requireFresh && !lookupOptions.requireFresh &&
options.fallbackStatus?.qrcodeurl && fallbackStatus?.qrcodeurl &&
!this.isExpiredQrcodeStatus(options.fallbackStatus) !this.isExpiredQrcodeStatus(fallbackStatus)
) { ) {
return options.fallbackStatus.qrcodeurl; return fallbackStatus.qrcodeurl;
} }
throw err; throw err;
} }
@ -505,8 +512,31 @@ export class QqbotNapcatLoginService {
return this.requestNapcat<T>(container, path, body, credential); return this.requestNapcat<T>(container, path, body, credential);
} }
private async restartNapcatForLogin(container: QqbotNapcatRuntime) {
const restartedByContainer =
await this.containerService.restartRuntimeContainer(container);
if (!restartedByContainer) {
try {
await this.postNapcat<Record<string, any> | null>(
container,
'/api/QQLogin/RestartNapCat',
);
} catch (err) {
if (!this.isTemporaryNapcatError(err)) throw err;
}
}
this.credentials.delete(this.getCredentialCacheKey(container));
await this.sleep(this.getRestartDelayMs());
await this.getLoginStatus(container, true);
}
private getCredentialCacheKey(container: QqbotNapcatRuntime) {
return container.id || container.baseUrl;
}
private async getCredential(container: QqbotNapcatRuntime) { private async getCredential(container: QqbotNapcatRuntime) {
const cacheKey = container.id || container.baseUrl; const cacheKey = this.getCredentialCacheKey(container);
const cached = this.credentials.get(cacheKey); const cached = this.credentials.get(cacheKey);
if (cached && Date.now() < cached.expiresAt) { if (cached && Date.now() < cached.expiresAt) {
return cached.credential; return cached.credential;
@ -610,6 +640,12 @@ export class QqbotNapcatLoginService {
return Number(this.configService.get('NAPCAT_WEBUI_TIMEOUT_MS') || 8000); return Number(this.configService.get('NAPCAT_WEBUI_TIMEOUT_MS') || 8000);
} }
private getRestartDelayMs() {
return Number(
this.configService.get('NAPCAT_WEBUI_RESTART_DELAY_MS') || 3000,
);
}
private getSelfId(info: NapcatLoginInfo) { private getSelfId(info: NapcatLoginInfo) {
return `${info.uin || info.self_id || info.selfId || ''}`.trim(); return `${info.uin || info.self_id || info.selfId || ''}`.trim();
} }

View File

@ -151,6 +151,27 @@ export class QqbotNapcatContainerService {
return this.removeContainer(containerId); return this.removeContainer(containerId);
} }
async restartRuntimeContainer(runtime: QqbotNapcatRuntime) {
if (this.getManagedMode() !== 'ssh' || !runtime.id || !runtime.name) {
return false;
}
await this.runProcess(
'ssh',
[...this.getSshArgs(), 'docker', 'restart', runtime.name],
'',
);
await this.containerRepository.update(
{ id: runtime.id },
{
lastError: null,
lastStartedAt: new Date(),
status: 'running',
},
);
return true;
}
private async removeContainer(containerId: string) { private async removeContainer(containerId: string) {
const container = await this.containerRepository.findOne({ const container = await this.containerRepository.findOne({
where: { where: {