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_LOGIN_QR_EXPIRE_MS=120000
NAPCAT_WEBUI_READY_RETRIES=10
NAPCAT_WEBUI_RESTART_DELAY_MS=3000
QQBOT_NAPCAT_CONTAINER_MODE=
QQBOT_NAPCAT_SSH_TARGET=nas
QQBOT_NAPCAT_SSH_PORT=

View File

@ -42,6 +42,9 @@ describe('QqbotNapcatLoginService', () => {
jest
.spyOn(service as any, 'getSessionContainer')
.mockResolvedValue({ id: 'container-1' });
jest.spyOn(service as any, 'getLoginStatus').mockResolvedValue({
isLogin: false,
});
jest
.spyOn(service as any, 'callRefreshQrcode')
.mockResolvedValue('new-qrcode-image');
@ -108,6 +111,10 @@ describe('QqbotNapcatLoginService', () => {
jest
.spyOn(service as any, 'getSessionContainer')
.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, '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 () => {
jest.spyOn(service as any, 'sleep').mockResolvedValue(undefined);
jest

View File

@ -125,9 +125,11 @@ export class QqbotNapcatLoginService {
}
const container = await this.getSessionContainer(session);
const loginStatus = await this.getLoginStatus(container);
session.qrcode = await this.refreshOrGetQrcode(container, true, {
fallbackStatus: loginStatus,
requireFresh: true,
staleQrcode: session.qrcode,
staleQrcode: session.qrcode || loginStatus.qrcodeurl,
});
session.expiresAt = Date.now() + this.getSessionTtlMs();
session.errorMessage = undefined;
@ -436,10 +438,15 @@ export class QqbotNapcatLoginService {
retry = false,
options: QrcodeRefreshOptions = {},
) {
let fallbackStatus = options.fallbackStatus;
const lookupOptions: QrcodeLookupOptions = {
requireFresh: options.requireFresh,
staleQrcode: options.staleQrcode,
requireFresh: options.requireFresh || fallbackStatus?.isOffline,
staleQrcode: options.staleQrcode || fallbackStatus?.qrcodeurl,
};
if (fallbackStatus?.isOffline) {
await this.restartNapcatForLogin(container);
fallbackStatus = undefined;
}
try {
const refreshedQrcode = await this.callRefreshQrcode(container, retry);
if (refreshedQrcode) {
@ -449,10 +456,10 @@ export class QqbotNapcatLoginService {
} catch (err) {
if (
!lookupOptions.requireFresh &&
options.fallbackStatus?.qrcodeurl &&
!this.isExpiredQrcodeStatus(options.fallbackStatus)
fallbackStatus?.qrcodeurl &&
!this.isExpiredQrcodeStatus(fallbackStatus)
) {
return options.fallbackStatus.qrcodeurl;
return fallbackStatus.qrcodeurl;
}
throw err;
}
@ -505,8 +512,31 @@ export class QqbotNapcatLoginService {
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) {
const cacheKey = container.id || container.baseUrl;
const cacheKey = this.getCredentialCacheKey(container);
const cached = this.credentials.get(cacheKey);
if (cached && Date.now() < cached.expiresAt) {
return cached.credential;
@ -610,6 +640,12 @@ export class QqbotNapcatLoginService {
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) {
return `${info.uin || info.self_id || info.selfId || ''}`.trim();
}

View File

@ -151,6 +151,27 @@ export class QqbotNapcatContainerService {
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) {
const container = await this.containerRepository.findOne({
where: {