import { spawn } from 'child_process'; import { randomBytes, randomUUID } from 'crypto'; import { Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { throwVbenError, ToolsService } from '@/common'; import { QqbotAccount } from '../account/qqbot-account.entity'; import { QqbotAccountNapcat } from './qqbot-account-napcat.entity'; import { QqbotNapcatContainer } from './qqbot-napcat-container.entity'; import type { QqbotNapcatRuntime } from '../qqbot.types'; type NapcatLoginLogState = 'offline' | 'online' | 'unknown'; type NapcatLoginLogResult = { offlineReason: string | null; state: NapcatLoginLogState; }; @Injectable() export class QqbotNapcatContainerService { constructor( private readonly configService: ConfigService, @InjectRepository(QqbotNapcatContainer) private readonly containerRepository: Repository, @InjectRepository(QqbotAccountNapcat) private readonly bindingRepository: Repository, private readonly toolsService: ToolsService, ) {} async prepareCreateContainer() { if (!this.isManagedMode()) { return this.getLegacyRuntime(); } return this.createManagedContainer(); } async prepareAccountContainer(account: QqbotAccount) { if (!this.isManagedMode()) { return this.getLegacyRuntime(); } const existing = await this.getPrimaryRuntime(account.id); if (existing) return existing; return this.createManagedContainer(account.selfId); } async findRuntimeById(containerId?: string) { if (!containerId) return this.getLegacyRuntime(); const container = await this.containerRepository .createQueryBuilder('container') .addSelect('container.webuiToken') .where('container.id = :containerId', { containerId }) .andWhere('container.isDeleted = :isDeleted', { isDeleted: false }) .getOne(); if (!container) { throwVbenError('NapCat 容器不存在或已删除'); } return this.toRuntime(container); } async bindAccount(accountId: string, containerId?: string) { if (!containerId) return; await this.bindingRepository.update( { accountId, isDeleted: false }, { isPrimary: false }, ); const existing = await this.bindingRepository.findOne({ where: { accountId, containerId, isDeleted: false, }, }); if (existing) { await this.bindingRepository.update( { id: existing.id }, { bindStatus: 'bound', isPrimary: true, lastLoginAt: new Date(), }, ); await this.removeOtherAccountContainers(accountId, containerId); return; } await this.bindingRepository.save( this.bindingRepository.create({ accountId, bindStatus: 'bound', containerId, isPrimary: true, lastLoginAt: new Date(), remark: '', }), ); await this.removeOtherAccountContainers(accountId, containerId); } async removeAccountContainers(accountId: string) { const bindings = await this.bindingRepository.find({ where: { accountId, isDeleted: false, }, }); if (bindings.length <= 0) return { deletedContainers: 0 }; let deletedContainers = 0; for (const binding of bindings) { const sharedCount = await this.bindingRepository .createQueryBuilder('binding') .where('binding.containerId = :containerId', { containerId: binding.containerId, }) .andWhere('binding.accountId != :accountId', { accountId }) .andWhere('binding.isDeleted = :isDeleted', { isDeleted: false }) .getCount(); if (sharedCount > 0) continue; const deleted = await this.removeContainer(binding.containerId); if (deleted) deletedContainers += 1; } await this.bindingRepository.update( { accountId, isDeleted: false }, { bindStatus: 'disabled', isDeleted: true, isPrimary: false, }, ); 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); } 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; } async resetRuntimeLoginState( runtime: QqbotNapcatRuntime, onProgress?: (step: string, message: string) => void, ) { if (this.getManagedMode() !== 'ssh' || !runtime.id || !runtime.name) { return false; } const container = await this.containerRepository.findOne({ where: { id: runtime.id, isDeleted: false, }, }); if (!container) { throwVbenError('NapCat 容器不存在或已删除'); } const script = this.buildRemoteResetLoginStateScript(container); await this.runProcess( 'ssh', [...this.getSshArgs(), 'sh -s'], script, (line) => { const matched = line.match(/^__KT_PROGRESS__:([^:]+):(.+)$/); if (matched) onProgress?.(matched[1], matched[2]); }, ); await this.containerRepository.update( { id: runtime.id }, { lastError: null, lastStartedAt: new Date(), status: 'running', }, ); return true; } async detectRuntimeOffline(container: QqbotNapcatContainer) { if (this.getManagedMode() !== 'ssh' || !container.name) return null; try { const result = await this.runProcess( 'ssh', [...this.getSshArgs(), 'sh -s'], this.buildRemoteRecentLogsScript(container), undefined, this.getRuntimeCheckTimeoutMs(), ); const loginState = this.extractLoginState(result.stdout); await this.containerRepository.update( { id: container.id }, { lastCheckedAt: new Date(), ...(loginState.state === 'offline' ? { lastError: loginState.offlineReason ? this.toolsService.toColumnText( loginState.offlineReason, 500, ) : null, } : {}), ...(loginState.state === 'online' ? { lastError: null } : {}), }, ); return loginState.offlineReason; } catch (err) { await this.containerRepository.update( { id: container.id }, { lastCheckedAt: new Date(), lastError: this.toolsService.toColumnText( this.toolsService.getErrorMessage(err), 500, ), }, ); return null; } } private async removeContainer(containerId: string) { const container = await this.containerRepository.findOne({ where: { id: containerId, isDeleted: false, }, }); if (!container) return false; if (this.getManagedMode() === 'ssh') { await this.removeRemoteDockerContainer(container); } await this.containerRepository.update( { id: container.id }, { isDeleted: true, lastError: null, status: 'stopped', }, ); return true; } private async removeOtherAccountContainers( accountId: string, keepContainerId: string, ) { const bindings = await this.bindingRepository.find({ where: { accountId, isDeleted: false, }, }); for (const binding of bindings) { if (binding.containerId === keepContainerId) continue; const sharedCount = await this.bindingRepository .createQueryBuilder('binding') .where('binding.containerId = :containerId', { containerId: binding.containerId, }) .andWhere('binding.accountId != :accountId', { accountId }) .andWhere('binding.isDeleted = :isDeleted', { isDeleted: false }) .getCount(); if (sharedCount <= 0) { await this.removeContainer(binding.containerId); } await this.bindingRepository.update( { id: binding.id }, { bindStatus: 'disabled', isDeleted: true, isPrimary: false, }, ); } } private async removeRemoteDockerContainer(container: QqbotNapcatContainer) { const script = this.buildRemoteRemoveScript(container); await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script); } private buildRemoteRemoveScript(container: QqbotNapcatContainer) { const dataDir = this.sh(container.dataDir || ''); const name = this.sh(container.name); const rootDir = this.sh(this.getRootDir()); return ` set -eu NAME=${name} DATA_DIR=${dataDir} ROOT_DIR=${rootDir} docker rm -f "$NAME" >/dev/null 2>&1 || true if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/" ]; then case "$DATA_DIR" in "$ROOT_DIR"/*) rm -rf "$DATA_DIR" ;; *) echo "skip unsafe data dir: $DATA_DIR" >&2 ;; esac fi `; } private buildRemoteResetLoginStateScript(container: QqbotNapcatContainer) { const dataDir = this.sh(container.dataDir || ''); const name = this.sh(container.name); const rootDir = this.sh(this.getRootDir()); return ` set -eu NAME=${name} DATA_DIR=${dataDir} ROOT_DIR=${rootDir} if [ -z "$DATA_DIR" ] || [ "$DATA_DIR" = "/" ]; then echo "unsafe empty data dir" >&2 exit 1 fi case "$DATA_DIR" in "$ROOT_DIR"/*) ;; *) echo "skip unsafe data dir: $DATA_DIR" >&2 exit 1 ;; esac docker exec "$NAME" rm -f /app/napcat/cache/qrcode.png >/dev/null 2>&1 || true echo "__KT_PROGRESS__:container-stop:正在停止 NapCat 容器" docker stop "$NAME" >/dev/null 2>&1 || true echo "__KT_PROGRESS__:login-data-clean:正在清理旧 QQ 登录态" mkdir -p "$DATA_DIR/QQ" find "$DATA_DIR/QQ" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + echo "__KT_PROGRESS__:container-start:正在启动 NapCat 容器" docker start "$NAME" >/dev/null echo "__KT_PROGRESS__:container-started:NapCat 容器已启动" `; } private buildRemoteRecentLogsScript(container: QqbotNapcatContainer) { const name = this.sh(container.name); return ` set -eu NAME=${name} docker logs --tail 300 "$NAME" 2>&1 || true `; } private extractLoginState(logs: string): NapcatLoginLogResult { const lines = logs .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .reverse(); const matchedLine = lines.find( (line) => this.toolsService.isNapcatOfflineLoginMessage(line) || this.toolsService.isNapcatOnlineLoginMessage(line), ); if (!matchedLine) { return { offlineReason: null, state: 'unknown', }; } if (this.toolsService.isNapcatOnlineLoginMessage(matchedLine)) { return { offlineReason: null, state: 'online', }; } const message = matchedLine .replace(/^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+\[[^\]]+\]\s+/, '') .replace(/^Mirror\s*\|\s*/, '') .replace(/\[KickedOffLine]/gi, '') .replace(/\[下线通知]/g, '') .trim(); const offlineReason = this.toolsService.isNapcatOfflineFlagMessage( matchedLine, ) ? 'NapCat 账号状态变更为离线' : message || 'NapCat 账号状态变更为离线'; return { offlineReason, state: 'offline', }; } private async getPrimaryRuntime(accountId: string) { const binding = await this.bindingRepository.findOne({ order: { updateTime: 'DESC', }, where: { accountId, bindStatus: 'bound', isDeleted: false, isPrimary: true, }, }); if (!binding) return null; const container = await this.containerRepository .createQueryBuilder('container') .addSelect('container.webuiToken') .where('container.id = :containerId', { containerId: binding.containerId, }) .andWhere('container.isDeleted = :isDeleted', { isDeleted: false }) .andWhere('container.status != :status', { status: 'error' }) .getOne(); return container ? this.toRuntime(container) : null; } private async createManagedContainer(selfId?: string) { const mode = this.getManagedMode(); if (mode !== 'ssh') { throwVbenError('当前仅支持通过 SSH 创建 NapCat 容器'); } const image = this.getConfig('QQBOT_NAPCAT_IMAGE'); if (!image) { throwVbenError('NapCat 镜像未配置,请先设置 QQBOT_NAPCAT_IMAGE'); } const port = await this.allocatePort(); const name = this.buildContainerName(selfId); const token = randomBytes(24).toString('hex'); const dataDir = `${this.getRootDir()}/${name}`; const baseUrl = this.buildBaseUrl(port); const reverseWsUrl = this.buildReverseWsUrl(); const container = await this.containerRepository.save( this.containerRepository.create({ baseUrl, dataDir, image, isDeleted: false, lastError: null, name, remark: '', reverseWsUrl, status: 'creating', webuiPort: port, webuiToken: token, }), ); try { await this.createRemoteDockerContainer({ dataDir, image, name, port, reverseWsUrl, token, }); await this.containerRepository.update( { id: container.id }, { lastError: null, lastStartedAt: new Date(), status: 'running', }, ); return { baseUrl, id: container.id, name, webuiPort: port, webuiToken: token, }; } catch (err) { const message = this.toolsService.getErrorMessage(err); await this.containerRepository.update( { id: container.id }, { lastError: this.toolsService.toColumnText(message, 500), status: 'error', }, ); throwVbenError(`创建 NapCat 容器失败:${message}`); } } private async createRemoteDockerContainer(input: { dataDir: string; image: string; name: string; port: number; reverseWsUrl: string; token: string; }) { const script = this.buildRemoteCreateScript(input); await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script); } private buildRemoteCreateScript(input: { dataDir: string; image: string; name: string; port: number; reverseWsUrl: string; token: string; }) { const dataDir = this.sh(input.dataDir); const image = this.sh(input.image); const name = this.sh(input.name); const reverseWsUrl = this.sh(input.reverseWsUrl); const token = this.sh(input.token); return ` set -eu DATA_DIR=${dataDir} IMAGE=${image} NAME=${name} PORT=${input.port} REVERSE_WS_URL=${reverseWsUrl} WEBUI_TOKEN=${token} mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" chmod 700 "$DATA_DIR" cat > "$DATA_DIR/config/webui.json" < "$DATA_DIR/config/onebot11.json" </dev/null docker rm -f "$NAME" >/dev/null 2>&1 || true docker run -d \\ --name "$NAME" \\ --restart unless-stopped \\ -e NAPCAT_UID=0 \\ -e NAPCAT_GID=0 \\ -e WEBUI_TOKEN="$WEBUI_TOKEN" \\ -p "$PORT:6099" \\ -v "$DATA_DIR/QQ:/app/.config/QQ" \\ -v "$DATA_DIR/config:/app/napcat/config" \\ -v "$DATA_DIR/plugins:/app/napcat/plugins" \\ "$IMAGE" >/dev/null `; } private async allocatePort() { const start = Number(this.getConfig('QQBOT_NAPCAT_PORT_START', '6100')); const end = Number(this.getConfig('QQBOT_NAPCAT_PORT_END', '6199')); if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) { throwVbenError('NapCat 端口池配置错误'); } const containers = await this.containerRepository.find({ select: ['webuiPort'], where: { isDeleted: false, }, }); const used = new Set( containers .map((container) => container.webuiPort) .filter((port): port is number => typeof port === 'number'), ); for (let port = start; port <= end; port += 1) { if (!used.has(port)) return port; } throwVbenError('NapCat 端口池已用完'); } private buildContainerName(selfId?: string) { const prefix = this.getConfig( 'QQBOT_NAPCAT_CONTAINER_PREFIX', 'kt-qqbot-napcat', ); const suffix = `${selfId || randomUUID().slice(0, 8)}` .replace(/[^a-zA-Z0-9_.-]/g, '-') .toLowerCase(); return `${prefix}-${suffix}`.replace(/-+/g, '-').slice(0, 120); } private buildBaseUrl(port: number) { const template = this.getConfig('QQBOT_NAPCAT_BASE_URL_TEMPLATE', ''); if (template) { return template.replace('{port}', `${port}`); } const host = this.getConfig('QQBOT_NAPCAT_HOST', '127.0.0.1'); return `http://${host}:${port}`; } private buildReverseWsUrl() { const configured = this.getConfig('QQBOT_NAPCAT_REVERSE_WS_URL', '') || this.getConfig('QQBOT_NAPCAT_REVERSE_WS_BASE', ''); const path = this.getConfig( 'QQBOT_REVERSE_WS_PATH', '/qqbot/onebot/reverse', ); const base = configured || `ws://127.0.0.1:48085${path}`; const token = this.getConfig('QQBOT_REVERSE_WS_TOKEN', ''); if (!token || base.includes('token=')) return base; const joiner = base.includes('?') ? '&' : '?'; return `${base}${joiner}token=${encodeURIComponent(token)}`; } private getLegacyRuntime(): QqbotNapcatRuntime { return { baseUrl: this.normalizeBaseUrl( this.getConfig('NAPCAT_WEBUI_BASE_URL', '') || this.getConfig('QQBOT_NAPCAT_WEBUI_URL', ''), ), name: 'kt-qqbot-napcat', webuiToken: this.getConfig('NAPCAT_WEBUI_TOKEN', '') || this.getConfig('QQBOT_NAPCAT_WEBUI_TOKEN', ''), }; } private toRuntime(container: QqbotNapcatContainer): QqbotNapcatRuntime { return { baseUrl: this.normalizeBaseUrl(container.baseUrl), id: container.id, name: container.name, webuiPort: container.webuiPort, webuiToken: container.webuiToken, }; } private normalizeBaseUrl(value: string) { const baseUrl = `${value || ''}`.trim(); if (!baseUrl) { throwVbenError('NapCat WebUI 地址未配置'); } return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; } private getRootDir() { return this.getConfig( 'QQBOT_NAPCAT_ROOT', '/vol1/docker/kt-qqbot/napcat-instances', ).replace(/\/+$/, ''); } private isManagedMode() { return !!this.getManagedMode(); } private getManagedMode() { return this.getConfig('QQBOT_NAPCAT_CONTAINER_MODE', '').toLowerCase(); } private getSshArgs() { const target = this.getConfig('QQBOT_NAPCAT_SSH_TARGET', 'nas'); if (!target) { throwVbenError('NapCat SSH 目标未配置'); } const args: string[] = [ '-o', 'StrictHostKeyChecking=accept-new', '-o', 'UserKnownHostsFile=/tmp/qqbot-napcat-known-hosts', ]; const port = this.getConfig('QQBOT_NAPCAT_SSH_PORT', ''); const keyPath = this.getConfig('QQBOT_NAPCAT_SSH_KEY_PATH', ''); if (port) args.push('-p', port); if (keyPath) args.push('-i', keyPath); args.push(target); return args; } private getConfig(key: string, defaultValue = '') { 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 getRuntimeCheckTimeoutMs() { const timeoutMs = Number( this.getConfig('QQBOT_NAPCAT_RUNTIME_CHECK_TIMEOUT_MS', '5000'), ); return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 5000; } private sh(value: string) { return `'${`${value}`.replace(/'/g, `'\\''`)}'`; } private runProcess( command: string, args: string[], input: string, onStdoutLine?: (line: string) => void, timeoutMs = this.getProcessTimeoutMs(), ) { return new Promise<{ stderr: string; stdout: string }>( (resolve, reject) => { const child = spawn(command, args, { windowsHide: true, }); let settled = false; let stdout = ''; let stderr = ''; let stdoutLineBuffer = ''; 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) => { const text = Buffer.from(chunk).toString('utf8'); stdout += text; if (onStdoutLine) { const lines = `${stdoutLineBuffer}${text}`.split(/\r?\n/); stdoutLineBuffer = lines.pop() || ''; lines .map((line) => line.trim()) .filter(Boolean) .forEach((line) => onStdoutLine(line)); } }); child.stderr.on('data', (chunk) => { stderr += Buffer.from(chunk).toString('utf8'); }); child.on('error', (err) => { finish(() => reject(err)); }); child.on('close', (code) => { finish(() => { if (onStdoutLine && stdoutLineBuffer.trim()) { onStdoutLine(stdoutLineBuffer.trim()); } if (code === 0) { resolve({ stderr, stdout }); return; } reject(new Error((stderr || stdout || `${command} failed`).trim())); }); }); child.stdin.write(input); child.stdin.end(); }, ); } }