diff --git a/.env.example b/.env.example index 893c797..d5511e2 100644 --- a/.env.example +++ b/.env.example @@ -28,7 +28,6 @@ QQBOT_EVENT_BUS=mqtt QQBOT_REVERSE_WS_PATH=/qqbot/onebot/reverse QQBOT_REVERSE_WS_TOKEN= QQBOT_AUTO_REGISTER_ACCOUNT=true -QQBOT_REQUIRE_ALLOWLIST=false QQBOT_API_TIMEOUT_MS=10000 QQBOT_SEND_RATE_PER_SECOND=1 NAPCAT_WEBUI_BASE_URL=http://127.0.0.1:6099 @@ -40,6 +39,7 @@ QQBOT_NAPCAT_CONTAINER_MODE= QQBOT_NAPCAT_SSH_TARGET=nas QQBOT_NAPCAT_SSH_PORT= QQBOT_NAPCAT_SSH_KEY_PATH= +QQBOT_NAPCAT_SSH_TIMEOUT_MS=120000 QQBOT_NAPCAT_ROOT=/vol1/docker/kt-qqbot/napcat-instances QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest QQBOT_NAPCAT_CONTAINER_PREFIX=kt-qqbot-napcat diff --git a/src/qqbot/account/qqbot-account.controller.ts b/src/qqbot/account/qqbot-account.controller.ts index ab58992..0fec251 100644 --- a/src/qqbot/account/qqbot-account.controller.ts +++ b/src/qqbot/account/qqbot-account.controller.ts @@ -91,7 +91,7 @@ export class QqbotAccountController { @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '取消 QQBot 扫码登录会话' }) async cancelScan(@Query() query: QqbotAccountScanStatusDto) { - return vbenSuccess(this.napcatLoginService.cancel(query.sessionId)); + return vbenSuccess(await this.napcatLoginService.cancel(query.sessionId)); } @Post('delete') diff --git a/src/qqbot/account/qqbot-account.service.ts b/src/qqbot/account/qqbot-account.service.ts index 75389db..3eef5d4 100644 --- a/src/qqbot/account/qqbot-account.service.ts +++ b/src/qqbot/account/qqbot-account.service.ts @@ -184,8 +184,12 @@ export class QqbotAccountService { } async save(body: QqbotAccountBodyDto) { - await this.assertSelfIdAvailable(body.selfId); - const account = this.accountRepository.create(this.normalizeBody(body)); + const payload = this.normalizeBody(body); + const restored = await this.restoreDeletedAccount(payload); + if (restored) return restored.id; + + await this.assertSelfIdAvailable(payload.selfId || ''); + const account = this.accountRepository.create(payload); const saved = await this.accountRepository.save(account); return saved.id; } @@ -268,15 +272,42 @@ export class QqbotAccountService { private async assertSelfIdAvailable(selfId: string, id?: string) { const exists = await this.accountRepository.findOne({ where: { - isDeleted: false, selfId, }, }); if (exists && exists.id !== id) { - throwVbenError('QQBot 账号 selfId 已存在'); + throwVbenError( + exists.isDeleted + ? 'QQBot 账号 selfId 已存在于已删除账号,请通过新增恢复该账号' + : 'QQBot 账号 selfId 已存在', + ); } } + private async restoreDeletedAccount(payload: Partial) { + if (!payload.selfId) return null; + + const existing = await this.accountRepository.findOne({ + where: { + selfId: payload.selfId, + }, + }); + if (!existing || !existing.isDeleted) return null; + + await this.accountRepository.update( + { id: existing.id }, + { + ...payload, + clientRole: null, + connectStatus: 'offline', + isDeleted: false, + lastError: null, + lastHeartbeatAt: null, + }, + ); + return existing; + } + private normalizeBody(body: Partial) { return { accessToken: normalizeNullableString(body.accessToken), @@ -284,7 +315,8 @@ export class QqbotAccountService { enabled: body.enabled ?? true, name: body.name || '', remark: body.remark || '', - selfId: body.selfId, + selfId: + typeof body.selfId === 'string' ? body.selfId.trim() : body.selfId, } as Partial; } } diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts index 2f1056f..e43fb05 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -124,9 +124,7 @@ export class QqbotNapcatLoginService { async status(sessionId: string) { const session = this.getSession(sessionId); if (Date.now() > session.expiresAt) { - session.status = 'expired'; - this.sessions.set(session.id, session); - return this.toResult(session); + return this.expireSession(session); } const container = await this.getSessionContainer(session); @@ -141,8 +139,12 @@ export class QqbotNapcatLoginService { return this.completeLogin(session, container); } - cancel(sessionId: string) { - this.sessions.delete(sessionId); + async cancel(sessionId: string) { + const session = this.sessions.get(sessionId); + if (session) { + this.sessions.delete(sessionId); + await this.cleanupSessionContainer(session); + } return true; } @@ -154,34 +156,44 @@ export class QqbotNapcatLoginService { }, container: QqbotNapcatRuntime, ): Promise { - this.cleanupSessions(); + await this.cleanupSessions(); - const loginStatus = await this.getLoginStatus(container, true); - if (loginStatus.isLogin) { + try { + const loginStatus = await this.getLoginStatus(container, true); + if (loginStatus.isLogin) { + const session = this.createSession({ + ...options, + container, + qrcode: loginStatus.qrcodeurl, + status: 'success', + }); + return this.completeLogin(session, container); + } + + let qrcode = this.isExpiredQrcodeStatus(loginStatus) + ? '' + : loginStatus.qrcodeurl || ''; + if (!qrcode) { + await this.callRefreshQrcode(container, true); + qrcode = await this.getQrcode(container, true); + } const session = this.createSession({ ...options, container, - qrcode: loginStatus.qrcodeurl, - status: 'success', + qrcode, + status: 'pending', }); - return this.completeLogin(session, container); + this.sessions.set(session.id, session); + return this.toResult(session); + } catch (err) { + const cleanupError = await this.cleanupRuntimeContainer(container); + if (cleanupError) { + throwVbenError( + `${this.getErrorMessage(err)};清理未绑定容器失败:${cleanupError}`, + ); + } + throw err; } - - let qrcode = this.isExpiredQrcodeStatus(loginStatus) - ? '' - : loginStatus.qrcodeurl || ''; - if (!qrcode) { - await this.callRefreshQrcode(container, true); - qrcode = await this.getQrcode(container, true); - } - const session = this.createSession({ - ...options, - container, - qrcode, - status: 'pending', - }); - this.sessions.set(session.id, session); - return this.toResult(session); } private async completeLogin( @@ -191,16 +203,13 @@ export class QqbotNapcatLoginService { const loginInfo = await this.getLoginInfo(container); const selfId = this.getSelfId(loginInfo); if (!selfId) { - session.status = 'error'; - session.errorMessage = 'NapCat 已登录但未返回 QQ 号'; - this.sessions.set(session.id, session); - return this.toResult(session); + return this.failSession(session, 'NapCat 已登录但未返回 QQ 号'); } if (session.expectedSelfId && session.expectedSelfId !== selfId) { - session.status = 'error'; - session.errorMessage = `当前扫码账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`; - this.sessions.set(session.id, session); - return this.toResult(session); + return this.failSession( + session, + `当前扫码账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`, + ); } const accountId = await this.accountService.ensureScannedAccount({ @@ -271,13 +280,60 @@ export class QqbotNapcatLoginService { return this.containerService.findRuntimeById(session.containerId); } - private cleanupSessions() { + private async cleanupSessions() { const now = Date.now(); + const expiredSessions: QqbotLoginScanSession[] = []; this.sessions.forEach((session, sessionId) => { if (session.status !== 'pending' || now > session.expiresAt) { this.sessions.delete(sessionId); + expiredSessions.push(session); } }); + await Promise.all( + expiredSessions.map((session) => this.cleanupSessionContainer(session)), + ); + } + + private async expireSession(session: QqbotLoginScanSession) { + session.status = 'expired'; + session.errorMessage = session.errorMessage || '扫码会话已过期'; + this.sessions.delete(session.id); + await this.cleanupSessionContainer(session); + return this.toResult(session); + } + + private async failSession( + session: QqbotLoginScanSession, + errorMessage: string, + ) { + session.status = 'error'; + session.errorMessage = errorMessage; + this.sessions.delete(session.id); + await this.cleanupSessionContainer(session); + return this.toResult(session); + } + + private async cleanupSessionContainer(session: QqbotLoginScanSession) { + const cleanupError = await this.cleanupRuntimeContainer({ + baseUrl: '', + id: session.containerId, + name: session.containerName || '', + webuiPort: session.webuiPort, + }); + if (cleanupError) { + session.errorMessage = session.errorMessage + ? `${session.errorMessage};清理未绑定容器失败:${cleanupError}` + : `清理未绑定容器失败:${cleanupError}`; + } + } + + private async cleanupRuntimeContainer(container: QqbotNapcatRuntime) { + try { + await this.containerService.removeUnboundContainer(container.id); + return null; + } catch (err) { + return this.getErrorMessage(err); + } } private async getLoginStatus(container: QqbotNapcatRuntime, retry = false) { diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index f01650f..d390037 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -137,6 +137,20 @@ export class QqbotNapcatContainerService { return { deletedContainers }; } + async removeUnboundContainer(containerId?: string) { + if (!containerId) return false; + + const bindingCount = await this.bindingRepository.count({ + where: { + containerId, + isDeleted: false, + }, + }); + if (bindingCount > 0) return false; + + return this.removeContainer(containerId); + } + private async removeContainer(containerId: string) { const container = await this.containerRepository.findOne({ where: { @@ -507,6 +521,13 @@ docker run -d \\ return `${this.configService.get(key) || defaultValue}`.trim(); } + private getProcessTimeoutMs() { + const timeoutMs = Number( + this.getConfig('QQBOT_NAPCAT_SSH_TIMEOUT_MS', '120000'), + ); + return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120000; + } + private sh(value: string) { return `'${`${value}`.replace(/'/g, `'\\''`)}'`; } @@ -516,21 +537,39 @@ docker run -d \\ const child = spawn(command, args, { windowsHide: true, }); + let settled = false; let stdout = ''; let stderr = ''; + const timeoutMs = this.getProcessTimeoutMs(); + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill('SIGTERM'); + reject(new Error(`${command} timeout after ${timeoutMs}ms`)); + }, timeoutMs); + const finish = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(timer); + callback(); + }; child.stdout.on('data', (chunk) => { stdout += Buffer.from(chunk).toString('utf8'); }); child.stderr.on('data', (chunk) => { stderr += Buffer.from(chunk).toString('utf8'); }); - child.on('error', reject); + child.on('error', (err) => { + finish(() => reject(err)); + }); child.on('close', (code) => { - if (code === 0) { - resolve(); - return; - } - reject(new Error((stderr || stdout || `${command} failed`).trim())); + finish(() => { + if (code === 0) { + resolve(); + return; + } + reject(new Error((stderr || stdout || `${command} failed`).trim())); + }); }); child.stdin.write(input); child.stdin.end();