diff --git a/.env.example b/.env.example index 84fbadf..0db3197 100644 --- a/.env.example +++ b/.env.example @@ -27,9 +27,27 @@ QQBOT_ENABLED=false QQBOT_EVENT_BUS=mqtt QQBOT_REVERSE_WS_PATH=/qqbot/onebot/reverse QQBOT_REVERSE_WS_TOKEN= +QQBOT_AUTO_REGISTER_ACCOUNT=true QQBOT_REQUIRE_ALLOWLIST=true QQBOT_API_TIMEOUT_MS=10000 QQBOT_SEND_RATE_PER_SECOND=1 +NAPCAT_WEBUI_BASE_URL=http://127.0.0.1:6099 +NAPCAT_WEBUI_TOKEN= +NAPCAT_WEBUI_TIMEOUT_MS=8000 +NAPCAT_LOGIN_QR_EXPIRE_MS=120000 +NAPCAT_WEBUI_READY_RETRIES=10 +QQBOT_NAPCAT_CONTAINER_MODE= +QQBOT_NAPCAT_SSH_TARGET=nas +QQBOT_NAPCAT_SSH_PORT= +QQBOT_NAPCAT_SSH_KEY_PATH= +QQBOT_NAPCAT_ROOT=/vol1/docker/kt-qqbot/napcat-instances +QQBOT_NAPCAT_IMAGE=mlikiowa/napcat-docker:latest +QQBOT_NAPCAT_CONTAINER_PREFIX=kt-qqbot-napcat +QQBOT_NAPCAT_HOST=192.168.31.224 +QQBOT_NAPCAT_PORT_START=6100 +QQBOT_NAPCAT_PORT_END=6199 +QQBOT_NAPCAT_BASE_URL_TEMPLATE=http://192.168.31.224:{port} +QQBOT_NAPCAT_REVERSE_WS_BASE=ws://192.168.31.224:48085/qqbot/onebot/reverse MQTT_URL=mqtt://127.0.0.1:1883 MQTT_USERNAME= diff --git a/dockerfile b/dockerfile index 6c74593..195c0f4 100644 --- a/dockerfile +++ b/dockerfile @@ -6,6 +6,10 @@ ENV NODE_ENV=production COPY package.json pnpm-lock.yaml ./ +RUN apt-get update \ + && apt-get install -y --no-install-recommends openssh-client \ + && rm -rf /var/lib/apt/lists/* + # 生产镜像只安装运行依赖,dist 由 Jenkins Build stage 提前产出。 # 跳过安装阶段脚本,避免 NODE_ENV=production 时 devDependency 中的 husky 不存在导致 prepare 失败。 RUN corepack enable \ diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index 600d70a..46c7a57 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -31,10 +31,16 @@ spec: env: - name: NODE_ENV value: production + - name: QQBOT_NAPCAT_SSH_KEY_PATH + value: /app/secrets/napcat-ssh/id_rsa # Jenkins 每次发布会从 Agent 私有 .env.production 重建这个 Secret。 envFrom: - secretRef: name: kt-template-online-api-env + volumeMounts: + - name: napcat-ssh-key + mountPath: /app/secrets/napcat-ssh + readOnly: true readinessProbe: tcpSocket: port: 48085 @@ -56,6 +62,12 @@ spec: limits: cpu: 1000m memory: 768Mi + volumes: + - name: napcat-ssh-key + secret: + secretName: kt-qqbot-napcat-ssh-key + optional: true + defaultMode: 0400 --- apiVersion: v1 kind: Service diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index ec17e61..1df5f4c 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -25,6 +25,44 @@ CREATE TABLE IF NOT EXISTS `qqbot_account` ( UNIQUE KEY `uk_qqbot_account_self_id` (`self_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS `qqbot_napcat_container` ( + `id` bigint NOT NULL, + `name` varchar(120) NOT NULL, + `base_url` varchar(255) NOT NULL, + `webui_port` int DEFAULT NULL, + `webui_token` varchar(255) DEFAULT NULL, + `image` varchar(255) NOT NULL DEFAULT '', + `data_dir` varchar(500) NOT NULL DEFAULT '', + `reverse_ws_url` varchar(500) NOT NULL DEFAULT '', + `status` varchar(32) NOT NULL DEFAULT 'creating', + `last_started_at` datetime DEFAULT NULL, + `last_checked_at` datetime DEFAULT NULL, + `last_error` varchar(500) DEFAULT NULL, + `remark` varchar(255) NOT NULL DEFAULT '', + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_qqbot_napcat_container_name` (`name`), + KEY `idx_qqbot_napcat_container_status` (`status`, `is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE IF NOT EXISTS `qqbot_account_napcat` ( + `id` bigint NOT NULL, + `account_id` bigint NOT NULL, + `container_id` bigint NOT NULL, + `bind_status` varchar(32) NOT NULL DEFAULT 'pending', + `is_primary` tinyint(1) NOT NULL DEFAULT 1, + `last_login_at` datetime DEFAULT NULL, + `remark` varchar(255) NOT NULL DEFAULT '', + `is_deleted` tinyint(1) NOT NULL DEFAULT 0, + `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + PRIMARY KEY (`id`), + KEY `idx_qqbot_account_napcat_account` (`account_id`, `is_deleted`), + KEY `idx_qqbot_account_napcat_container` (`container_id`, `is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `qqbot_rule` ( `id` bigint NOT NULL, `name` varchar(120) NOT NULL DEFAULT '', @@ -152,6 +190,7 @@ VALUES (2041700000000120402, 2041700000000100402, 'QqBotAccountEdit', NULL, NULL, NULL, 'QqBot:Account:Edit', 'button', '{"title":"common.edit"}', 1, 0), (2041700000000120403, 2041700000000100402, 'QqBotAccountDelete', NULL, NULL, NULL, 'QqBot:Account:Delete', 'button', '{"title":"common.delete"}', 1, 0), (2041700000000120404, 2041700000000100402, 'QqBotAccountKick', NULL, NULL, NULL, 'QqBot:Account:Kick', 'button', '{"title":"断开连接"}', 1, 0), + (2041700000000120405, 2041700000000100402, 'QqBotAccountRefreshLogin', NULL, NULL, NULL, 'QqBot:Account:RefreshLogin', 'button', '{"title":"更新登录"}', 1, 0), (2041700000000100403, 2041700000000100400, 'QqBotRule', '/qqbot/rule', '/qqbot/rule/list', NULL, 'QqBot:Rule:List', 'menu', '{"icon":"lucide:workflow","title":"自动回复规则"}', 1, 2), (2041700000000120411, 2041700000000100403, 'QqBotRuleCreate', NULL, NULL, NULL, 'QqBot:Rule:Create', 'button', '{"title":"common.create"}', 1, 0), (2041700000000120412, 2041700000000100403, 'QqBotRuleEdit', NULL, NULL, NULL, 'QqBot:Rule:Edit', 'button', '{"title":"common.edit"}', 1, 0), diff --git a/src/qqbot/account/qqbot-account.controller.ts b/src/qqbot/account/qqbot-account.controller.ts index 7dd7067..c5501a5 100644 --- a/src/qqbot/account/qqbot-account.controller.ts +++ b/src/qqbot/account/qqbot-account.controller.ts @@ -14,9 +14,11 @@ import { vbenSuccess } from '@/common'; import { QqbotAccountBodyDto, QqbotAccountQueryDto, + QqbotAccountScanStatusDto, QqbotAccountUpdateDto, } from './qqbot-account.dto'; import { QqbotAccountService } from './qqbot-account.service'; +import { QqbotNapcatLoginService } from './qqbot-napcat-login.service'; import { QqbotReverseWsService } from '../connection/qqbot-reverse-ws.service'; @ApiTags('qqbot-account') @@ -25,6 +27,7 @@ import { QqbotReverseWsService } from '../connection/qqbot-reverse-ws.service'; export class QqbotAccountController { constructor( private readonly accountService: QqbotAccountService, + private readonly napcatLoginService: QqbotNapcatLoginService, private readonly reverseWsService: QqbotReverseWsService, ) {} @@ -54,6 +57,43 @@ export class QqbotAccountController { return vbenSuccess(await this.accountService.update(body)); } + @Post('scan/create') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '扫码新增 QQBot 账号' }) + async scanCreate() { + return vbenSuccess(await this.napcatLoginService.startCreate()); + } + + @Post('scan/refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '扫码刷新 QQBot 账号登录态' }) + @ApiQuery({ name: 'id', type: String }) + async scanRefresh(@Query('id') id: string) { + return vbenSuccess(await this.napcatLoginService.startRefresh(id)); + } + + @Get('scan/status') + @ApiOperation({ summary: '查询 QQBot 扫码登录状态' }) + async scanStatus(@Query() query: QqbotAccountScanStatusDto) { + return vbenSuccess(await this.napcatLoginService.status(query.sessionId)); + } + + @Post('scan/qrcode/refresh') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '刷新 QQBot 扫码二维码' }) + async refreshScanQrcode(@Query() query: QqbotAccountScanStatusDto) { + return vbenSuccess( + await this.napcatLoginService.refreshQrcode(query.sessionId), + ); + } + + @Post('scan/cancel') + @HttpCode(HttpStatus.OK) + @ApiOperation({ summary: '取消 QQBot 扫码登录会话' }) + async cancelScan(@Query() query: QqbotAccountScanStatusDto) { + return vbenSuccess(this.napcatLoginService.cancel(query.sessionId)); + } + @Post('delete') @HttpCode(HttpStatus.OK) @ApiOperation({ summary: '删除 QQBot 账号' }) diff --git a/src/qqbot/account/qqbot-account.dto.ts b/src/qqbot/account/qqbot-account.dto.ts index 8256e92..1a16300 100644 --- a/src/qqbot/account/qqbot-account.dto.ts +++ b/src/qqbot/account/qqbot-account.dto.ts @@ -42,3 +42,8 @@ export class QqbotAccountQueryDto { @ApiPropertyOptional() connectStatus?: string; } + +export class QqbotAccountScanStatusDto { + @ApiProperty() + sessionId: string; +} diff --git a/src/qqbot/account/qqbot-account.service.ts b/src/qqbot/account/qqbot-account.service.ts index f3946d9..8cd33cc 100644 --- a/src/qqbot/account/qqbot-account.service.ts +++ b/src/qqbot/account/qqbot-account.service.ts @@ -87,6 +87,100 @@ export class QqbotAccountService { .getOne(); } + async findById(id: string) { + return this.accountRepository.findOne({ + where: { + id, + isDeleted: false, + }, + }); + } + + async findBySelfId(selfId: string) { + return this.accountRepository.findOne({ + where: { + isDeleted: false, + selfId, + }, + }); + } + + async ensureScannedAccount(input: { + accountId?: string; + name?: string; + selfId: string; + }) { + const selfId = `${input.selfId || ''}`.trim(); + if (!selfId) { + throwVbenError('NapCat 未返回 QQ 账号'); + } + + const existing = input.accountId + ? await this.accountRepository.findOne({ where: { id: input.accountId } }) + : await this.accountRepository.findOne({ where: { selfId } }); + const payload: Partial = { + accessToken: null, + clientRole: null, + connectStatus: 'offline', + connectionMode: 'reverse-ws', + enabled: true, + isDeleted: false, + lastError: null, + name: input.name || existing?.name || `QQ ${selfId}`, + selfId, + }; + + if (existing) { + await this.accountRepository.update({ id: existing.id }, payload); + return existing.id; + } + + const saved = await this.accountRepository.save( + this.accountRepository.create({ + ...payload, + remark: '', + }), + ); + return saved.id; + } + + async ensureRuntimeAccount(selfId: string) { + const normalizedSelfId = `${selfId || ''}`.trim(); + if (!normalizedSelfId) return; + + const existing = await this.accountRepository.findOne({ + where: { + selfId: normalizedSelfId, + }, + }); + if (existing && !existing.isDeleted) return; + + if (existing) { + await this.accountRepository.update( + { id: existing.id }, + { + connectStatus: 'offline', + enabled: true, + isDeleted: false, + lastError: null, + name: existing.name || `QQ ${normalizedSelfId}`, + }, + ); + return; + } + + await this.accountRepository.save( + this.accountRepository.create({ + connectionMode: 'reverse-ws', + connectStatus: 'offline', + enabled: true, + name: `QQ ${normalizedSelfId}`, + remark: '', + selfId: normalizedSelfId, + }), + ); + } + async save(body: QqbotAccountBodyDto) { await this.assertSelfIdAvailable(body.selfId); const account = this.accountRepository.create(this.normalizeBody(body)); diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts new file mode 100644 index 0000000..da883bd --- /dev/null +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -0,0 +1,521 @@ +import * as http from 'http'; +import * as https from 'https'; +import { createHash, randomUUID } from 'crypto'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { throwVbenError } from '@/common'; +import type { QqbotLoginScanMode, QqbotLoginScanStatus } from '../qqbot.types'; +import { + QqbotNapcatContainerService, + type QqbotNapcatRuntime, +} from '../napcat/qqbot-napcat-container.service'; +import { QqbotAccountService } from './qqbot-account.service'; + +type NapcatApiResponse = { + code: number; + data?: T; + message?: string; +}; + +type NapcatCredential = { + Credential?: string; +}; + +type NapcatLoginInfo = Record & { + avatarUrl?: string; + nick?: string; + nickname?: string; + online?: boolean; + uin?: number | string; +}; + +type NapcatLoginStatus = { + isLogin?: boolean; + isOffline?: boolean; + loginError?: string; + qrcodeurl?: string; +}; + +type NapcatQrcode = { + qrcode?: string; +}; + +type QqbotLoginScanSession = { + accountId?: string; + containerId?: string; + containerName?: string; + createdAt: number; + errorMessage?: string; + expiresAt: number; + expectedSelfId?: string; + id: string; + mode: QqbotLoginScanMode; + qrcode?: string; + status: QqbotLoginScanStatus; + webuiPort?: null | number; +}; + +export type QqbotLoginScanResult = { + accountId?: string; + containerId?: string; + containerName?: string; + errorMessage?: string; + expiresAt?: number; + mode: QqbotLoginScanMode; + qrcode?: string; + selfId?: string; + sessionId?: string; + status: QqbotLoginScanStatus; + webuiPort?: null | number; +}; + +@Injectable() +export class QqbotNapcatLoginService { + private readonly sessions = new Map(); + private readonly credentials = new Map< + string, + { credential: string; expiresAt: number } + >(); + + constructor( + private readonly configService: ConfigService, + private readonly accountService: QqbotAccountService, + private readonly containerService: QqbotNapcatContainerService, + ) {} + + async startCreate() { + const container = await this.containerService.prepareCreateContainer(); + return this.startScan({ mode: 'create' }, container); + } + + async startRefresh(accountId: string) { + const account = await this.accountService.findById(accountId); + if (!account) { + throwVbenError('QQBot 账号不存在'); + } + const container = await this.containerService.prepareAccountContainer( + account, + ); + + return this.startScan( + { + accountId: account.id, + expectedSelfId: account.selfId, + mode: 'refresh', + }, + container, + ); + } + + async refreshQrcode(sessionId: string) { + const session = this.getSession(sessionId); + if (session.status !== 'pending') { + return this.toResult(session); + } + + const container = await this.getSessionContainer(session); + await this.callRefreshQrcode(container); + session.qrcode = await this.getQrcode(container); + session.expiresAt = Date.now() + this.getSessionTtlMs(); + this.sessions.set(session.id, session); + return this.toResult(session); + } + + 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); + } + + const container = await this.getSessionContainer(session); + const status = await this.getLoginStatus(container); + if (!status.isLogin) { + session.errorMessage = status.loginError || undefined; + session.qrcode = status.qrcodeurl || session.qrcode; + this.sessions.set(session.id, session); + return this.toResult(session); + } + + return this.completeLogin(session, container); + } + + cancel(sessionId: string) { + this.sessions.delete(sessionId); + return true; + } + + private async startScan( + options: { + accountId?: string; + expectedSelfId?: string; + mode: QqbotLoginScanMode; + }, + container: QqbotNapcatRuntime, + ): Promise { + this.cleanupSessions(); + + 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 = 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( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + ): Promise { + 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); + } + if (session.expectedSelfId && session.expectedSelfId !== selfId) { + session.status = 'error'; + session.errorMessage = `当前扫码账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`; + this.sessions.set(session.id, session); + return this.toResult(session); + } + + const accountId = await this.accountService.ensureScannedAccount({ + accountId: session.accountId, + name: this.getNickname(loginInfo), + selfId, + }); + await this.containerService.bindAccount(accountId, session.containerId); + session.accountId = accountId; + session.status = 'success'; + session.errorMessage = undefined; + this.sessions.set(session.id, session); + return { + ...this.toResult(session), + accountId, + selfId, + }; + } + + private createSession(input: { + accountId?: string; + container: QqbotNapcatRuntime; + expectedSelfId?: string; + mode: QqbotLoginScanMode; + qrcode?: string; + status: QqbotLoginScanStatus; + }): QqbotLoginScanSession { + const now = Date.now(); + return { + accountId: input.accountId, + containerId: input.container.id, + containerName: input.container.name, + createdAt: now, + expectedSelfId: input.expectedSelfId, + expiresAt: now + this.getSessionTtlMs(), + id: randomUUID(), + mode: input.mode, + qrcode: input.qrcode, + status: input.status, + webuiPort: input.container.webuiPort, + }; + } + + private toResult(session: QqbotLoginScanSession): QqbotLoginScanResult { + return { + accountId: session.accountId, + containerId: session.containerId, + containerName: session.containerName, + errorMessage: session.errorMessage, + expiresAt: session.expiresAt, + mode: session.mode, + qrcode: session.qrcode, + sessionId: session.id, + status: session.status, + webuiPort: session.webuiPort, + }; + } + + private getSession(sessionId: string) { + const session = this.sessions.get(sessionId); + if (!session) { + throwVbenError('扫码会话不存在或已过期'); + } + return session; + } + + private async getSessionContainer(session: QqbotLoginScanSession) { + return this.containerService.findRuntimeById(session.containerId); + } + + private cleanupSessions() { + const now = Date.now(); + this.sessions.forEach((session, sessionId) => { + if (session.status !== 'pending' || now > session.expiresAt) { + this.sessions.delete(sessionId); + } + }); + } + + private async getLoginStatus(container: QqbotNapcatRuntime, retry = false) { + if (!retry) { + return this.postNapcat( + container, + '/api/QQLogin/CheckLoginStatus', + ); + } + + let lastError: unknown; + const attempts = Number( + this.configService.get('NAPCAT_WEBUI_READY_RETRIES') || 10, + ); + for (let index = 0; index < attempts; index += 1) { + try { + return await this.postNapcat( + container, + '/api/QQLogin/CheckLoginStatus', + ); + } catch (err) { + lastError = err; + if (!this.isTemporaryNapcatError(err)) break; + await this.sleep(1500); + } + } + throw lastError; + } + + private async getLoginInfo(container: QqbotNapcatRuntime) { + return this.postNapcat( + container, + '/api/QQLogin/GetQQLoginInfo', + ); + } + + private async callRefreshQrcode( + container: QqbotNapcatRuntime, + retry = false, + ) { + await this.executeNapcatRequest(retry, async () => { + try { + await this.postNapcat(container, '/api/QQLogin/RefreshQRcode'); + } catch (err) { + if (this.isAlreadyLoggedIn(err)) return; + throw err; + } + }); + } + + private async getQrcode(container: QqbotNapcatRuntime, retry = false) { + return this.executeNapcatRequest(retry, async () => { + try { + const data = await this.postNapcat( + container, + '/api/QQLogin/GetQQLoginQrcode', + ); + if (!data.qrcode) { + throwVbenError('NapCat 未返回登录二维码'); + } + return data.qrcode; + } catch (err) { + if (this.isAlreadyLoggedIn(err)) { + const status = await this.getLoginStatus(container); + return status.qrcodeurl || ''; + } + throw err; + } + }); + } + + private async postNapcat( + container: QqbotNapcatRuntime, + path: string, + body: Record = {}, + ) { + const credential = await this.getCredential(container); + return this.requestNapcat(container, path, body, credential); + } + + private async getCredential(container: QqbotNapcatRuntime) { + const cacheKey = container.id || container.baseUrl; + const cached = this.credentials.get(cacheKey); + if (cached && Date.now() < cached.expiresAt) { + return cached.credential; + } + + const token = this.getWebuiToken(container); + const hash = createHash('sha256').update(`${token}.napcat`).digest('hex'); + const data = await this.requestNapcat( + container, + '/api/auth/login', + { hash }, + ); + if (!data.Credential) { + throwVbenError('NapCat WebUI 登录失败'); + } + this.credentials.set(cacheKey, { + credential: data.Credential, + expiresAt: Date.now() + 50 * 60 * 1000, + }); + return data.Credential; + } + + private requestNapcat( + container: QqbotNapcatRuntime, + path: string, + body: Record = {}, + credential?: string, + ): Promise { + const baseUrl = container.baseUrl; + const target = new URL(path, baseUrl); + const payload = JSON.stringify(body); + const client = target.protocol === 'https:' ? https : http; + + return new Promise((resolve, reject) => { + const req = client.request( + { + headers: { + ...(credential + ? { + Authorization: `Bearer ${credential}`, + } + : {}), + 'Content-Length': Buffer.byteLength(payload), + 'Content-Type': 'application/json', + }, + hostname: target.hostname, + method: 'POST', + path: `${target.pathname}${target.search}`, + port: target.port, + protocol: target.protocol, + timeout: this.getTimeout(), + }, + (res) => { + const chunks: Buffer[] = []; + res.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + res.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + let result: NapcatApiResponse; + try { + result = raw ? JSON.parse(raw) : ({ code: -1 } as any); + } catch { + reject(new Error('NapCat 返回非 JSON 响应')); + return; + } + if (result.code !== 0) { + reject(new Error(result.message || 'NapCat 请求失败')); + return; + } + resolve(result.data as T); + }); + }, + ); + req.on('error', reject); + req.on('timeout', () => { + req.destroy(new Error('NapCat 请求超时')); + }); + req.write(payload); + req.end(); + }).catch((err): never => { + const message = this.getErrorMessage(err); + return throwVbenError(message || 'NapCat 请求失败'); + }); + } + + private getWebuiToken(container: QqbotNapcatRuntime) { + const value = container.webuiToken || ''; + const token = `${value}`.trim(); + if (!token) { + throwVbenError('NapCat WebUI token 未配置'); + } + return token; + } + + private getSessionTtlMs() { + return Number( + this.configService.get('NAPCAT_LOGIN_QR_EXPIRE_MS') || 2 * 60 * 1000, + ); + } + + private getTimeout() { + return Number(this.configService.get('NAPCAT_WEBUI_TIMEOUT_MS') || 8000); + } + + private getSelfId(info: NapcatLoginInfo) { + return `${info.uin || info.self_id || info.selfId || ''}`.trim(); + } + + private getNickname(info: NapcatLoginInfo) { + return `${info.nick || info.nickname || info.name || ''}`.trim(); + } + + private isAlreadyLoggedIn(err: unknown) { + return this.getErrorMessage(err).includes('QQ Is Logined'); + } + + private isTemporaryNapcatError(err: unknown) { + const message = this.getErrorMessage(err); + return [ + 'ECONNREFUSED', + 'ECONNRESET', + 'ETIMEDOUT', + 'NapCat 请求超时', + 'socket hang up', + ].some((keyword) => message.includes(keyword)); + } + + private async executeNapcatRequest( + retry: boolean, + action: () => Promise, + ) { + if (!retry) return action(); + + let lastError: unknown; + const attempts = Number( + this.configService.get('NAPCAT_WEBUI_READY_RETRIES') || 10, + ); + for (let index = 0; index < attempts; index += 1) { + try { + return await action(); + } catch (err) { + lastError = err; + if (!this.isTemporaryNapcatError(err)) break; + await this.sleep(1500); + } + } + throw lastError; + } + + private getErrorMessage(err: unknown) { + const response = (err as any)?.getResponse?.(); + if (typeof response?.msg === 'string') return response.msg; + if (typeof response?.message === 'string') return response.message; + return err instanceof Error ? err.message : `${err}`; + } + + private sleep(ms: number) { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); + } +} diff --git a/src/qqbot/connection/qqbot-reverse-ws.service.ts b/src/qqbot/connection/qqbot-reverse-ws.service.ts index 02b1ec2..9848416 100644 --- a/src/qqbot/connection/qqbot-reverse-ws.service.ts +++ b/src/qqbot/connection/qqbot-reverse-ws.service.ts @@ -253,17 +253,23 @@ export class QqbotReverseWsService const account = await this.accountService.findEnabledBySelfIdWithToken( selfId, ); - if (!account) { - return { ok: false as const, message: 'unknown account' }; - } - const expectedToken = - account.accessToken || + account?.accessToken || this.configService.get('QQBOT_REVERSE_WS_TOKEN') || ''; if (expectedToken && token !== expectedToken) { return { ok: false as const, message: 'invalid token' }; } + if (!account) { + const disabledAccount = await this.accountService.findBySelfId(selfId); + if (disabledAccount) { + return { ok: false as const, message: 'account disabled' }; + } + if (!this.isAutoRegisterEnabled()) { + return { ok: false as const, message: 'unknown account' }; + } + await this.accountService.ensureRuntimeAccount(selfId); + } return { ok: true as const, role, selfId }; } @@ -299,6 +305,13 @@ export class QqbotReverseWsService return `${this.configService.get('QQBOT_ENABLED') || 'false'}` === 'true'; } + private isAutoRegisterEnabled() { + return ( + `${this.configService.get('QQBOT_AUTO_REGISTER_ACCOUNT') || 'true'}` === + 'true' + ); + } + private isReversePath(request: IncomingMessage) { const url = new URL(request.url || '', `http://${request.headers.host}`); return url.pathname === this.getReversePath(); diff --git a/src/qqbot/napcat/qqbot-account-napcat.entity.ts b/src/qqbot/napcat/qqbot-account-napcat.entity.ts new file mode 100644 index 0000000..ff09c94 --- /dev/null +++ b/src/qqbot/napcat/qqbot-account-napcat.entity.ts @@ -0,0 +1,56 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import type { QqbotAccountNapcatBindStatus } from '../qqbot.types'; + +@Entity('qqbot_account_napcat') +@Index('idx_qqbot_account_napcat_account', ['accountId', 'isDeleted']) +@Index('idx_qqbot_account_napcat_container', ['containerId', 'isDeleted']) +export class QqbotAccountNapcat { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ name: 'account_id', type: 'bigint' }) + accountId: string; + + @Column({ name: 'container_id', type: 'bigint' }) + containerId: string; + + @Column({ default: 'pending', length: 32, name: 'bind_status' }) + bindStatus: QqbotAccountNapcatBindStatus; + + @Column({ default: true, name: 'is_primary' }) + isPrimary: boolean; + + @Column({ + default: null, + name: 'last_login_at', + nullable: true, + type: 'datetime', + }) + lastLoginAt: Date | null; + + @Column({ default: '', length: 255 }) + remark: string; + + @Column({ default: false, name: 'is_deleted' }) + isDeleted: boolean; + + @CreateDateColumn({ name: 'create_time' }) + createTime: Date; + + @UpdateDateColumn({ name: 'update_time' }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/qqbot/napcat/qqbot-napcat-container.entity.ts b/src/qqbot/napcat/qqbot-napcat-container.entity.ts new file mode 100644 index 0000000..a2a1694 --- /dev/null +++ b/src/qqbot/napcat/qqbot-napcat-container.entity.ts @@ -0,0 +1,85 @@ +import { + BeforeInsert, + Column, + CreateDateColumn, + Entity, + Index, + PrimaryColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ensureSnowflakeId } from '@/common'; +import type { QqbotNapcatContainerStatus } from '../qqbot.types'; + +@Entity('qqbot_napcat_container') +@Index('uk_qqbot_napcat_container_name', ['name'], { unique: true }) +@Index('idx_qqbot_napcat_container_status', ['status', 'isDeleted']) +export class QqbotNapcatContainer { + @PrimaryColumn({ type: 'bigint' }) + id: string; + + @Column({ length: 120 }) + name: string; + + @Column({ length: 255, name: 'base_url' }) + baseUrl: string; + + @Column({ name: 'webui_port', nullable: true, type: 'int' }) + webuiPort: null | number; + + @Column({ + default: null, + length: 255, + name: 'webui_token', + nullable: true, + select: false, + }) + webuiToken: null | string; + + @Column({ default: '', length: 255 }) + image: string; + + @Column({ default: '', length: 500, name: 'data_dir' }) + dataDir: string; + + @Column({ default: '', length: 500, name: 'reverse_ws_url' }) + reverseWsUrl: string; + + @Column({ default: 'creating', length: 32 }) + status: QqbotNapcatContainerStatus; + + @Column({ + default: null, + name: 'last_started_at', + nullable: true, + type: 'datetime', + }) + lastStartedAt: Date | null; + + @Column({ + default: null, + name: 'last_checked_at', + nullable: true, + type: 'datetime', + }) + lastCheckedAt: Date | null; + + @Column({ default: null, length: 500, name: 'last_error', nullable: true }) + lastError: null | string; + + @Column({ default: '', length: 255 }) + remark: string; + + @Column({ default: false, name: 'is_deleted' }) + isDeleted: boolean; + + @CreateDateColumn({ name: 'create_time' }) + createTime: Date; + + @UpdateDateColumn({ name: 'update_time' }) + updateTime: Date; + + @BeforeInsert() + createId() { + ensureSnowflakeId(this); + } +} diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts new file mode 100644 index 0000000..2111a99 --- /dev/null +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -0,0 +1,451 @@ +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 } from '@/common'; +import { QqbotAccount } from '../account/qqbot-account.entity'; +import { QqbotAccountNapcat } from './qqbot-account-napcat.entity'; +import { QqbotNapcatContainer } from './qqbot-napcat-container.entity'; + +export type QqbotNapcatRuntime = { + baseUrl: string; + id?: string; + name: string; + webuiPort?: null | number; + webuiToken?: null | string; +}; + +@Injectable() +export class QqbotNapcatContainerService { + constructor( + private readonly configService: ConfigService, + @InjectRepository(QqbotNapcatContainer) + private readonly containerRepository: Repository, + @InjectRepository(QqbotAccountNapcat) + private readonly bindingRepository: Repository, + ) {} + + 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(), + }, + ); + return; + } + + await this.bindingRepository.save( + this.bindingRepository.create({ + accountId, + bindStatus: 'bound', + containerId, + isPrimary: true, + lastLoginAt: new Date(), + remark: '', + }), + ); + } + + 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 port = await this.allocatePort(); + const name = this.buildContainerName(selfId); + const token = randomBytes(24).toString('hex'); + const image = this.getConfig( + 'QQBOT_NAPCAT_IMAGE', + 'mlikiowa/napcat-docker:latest', + ); + 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.getErrorMessage(err); + await this.containerRepository.update( + { id: container.id }, + { + lastError: message, + 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 sh(value: string) { + return `'${`${value}`.replace(/'/g, `'\\''`)}'`; + } + + private runProcess(command: string, args: string[], input: string) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + 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('close', (code) => { + if (code === 0) { + resolve(); + return; + } + reject(new Error((stderr || stdout || `${command} failed`).trim())); + }); + child.stdin.write(input); + child.stdin.end(); + }); + } + + private getErrorMessage(err: unknown) { + return err instanceof Error ? err.message : `${err}`; + } +} diff --git a/src/qqbot/qqbot.module.ts b/src/qqbot/qqbot.module.ts index db45f7a..9a54017 100644 --- a/src/qqbot/qqbot.module.ts +++ b/src/qqbot/qqbot.module.ts @@ -5,6 +5,7 @@ import { AdminAuthGuardModule } from '@/admin/auth/admin-auth-guard.module'; import { QqbotAccountController } from './account/qqbot-account.controller'; import { QqbotAccount } from './account/qqbot-account.entity'; import { QqbotAccountService } from './account/qqbot-account.service'; +import { QqbotNapcatLoginService } from './account/qqbot-napcat-login.service'; import { QqbotReverseWsService } from './connection/qqbot-reverse-ws.service'; import { QqbotDashboardController } from './dashboard/qqbot-dashboard.controller'; import { QqbotDashboardService } from './dashboard/qqbot-dashboard.service'; @@ -16,6 +17,9 @@ import { QqbotMessageController } from './message/qqbot-message.controller'; import { QqbotMessage } from './message/qqbot-message.entity'; import { QqbotMessageService } from './message/qqbot-message.service'; import { QqbotBusService } from './mqtt/qqbot-bus.service'; +import { QqbotAccountNapcat } from './napcat/qqbot-account-napcat.entity'; +import { QqbotNapcatContainer } from './napcat/qqbot-napcat-container.entity'; +import { QqbotNapcatContainerService } from './napcat/qqbot-napcat-container.service'; import { QqbotAllowlist } from './permission/qqbot-allowlist.entity'; import { QqbotBlocklist } from './permission/qqbot-blocklist.entity'; import { QqbotPermissionController } from './permission/qqbot-permission.controller'; @@ -40,6 +44,8 @@ import { QqbotSendService } from './send/qqbot-send.service'; QqbotConversation, QqbotDedupe, QqbotMessage, + QqbotAccountNapcat, + QqbotNapcatContainer, QqbotRule, QqbotSendLog, ]), @@ -59,6 +65,8 @@ import { QqbotSendService } from './send/qqbot-send.service'; QqbotDedupeService, QqbotEventService, QqbotMessageService, + QqbotNapcatLoginService, + QqbotNapcatContainerService, QqbotPermissionService, QqbotRateLimitService, QqbotReverseWsService, @@ -66,6 +74,11 @@ import { QqbotSendService } from './send/qqbot-send.service'; QqbotRuleService, QqbotSendService, ], - exports: [QqbotAccountService, QqbotReverseWsService], + exports: [ + QqbotAccountService, + QqbotNapcatLoginService, + QqbotNapcatContainerService, + QqbotReverseWsService, + ], }) export class QqbotModule {} diff --git a/src/qqbot/qqbot.types.ts b/src/qqbot/qqbot.types.ts index 00b9871..a8d3934 100644 --- a/src/qqbot/qqbot.types.ts +++ b/src/qqbot/qqbot.types.ts @@ -4,10 +4,22 @@ export type QqbotConnectionRole = 'API' | 'Event' | 'Universal'; export type QqbotConnectionStatus = 'offline' | 'online'; +export type QqbotLoginScanMode = 'create' | 'refresh'; + +export type QqbotLoginScanStatus = 'error' | 'expired' | 'pending' | 'success'; + export type QqbotMessageDirection = 'inbound' | 'outbound'; export type QqbotMessageType = 'group' | 'private'; +export type QqbotNapcatContainerStatus = + | 'creating' + | 'error' + | 'running' + | 'stopped'; + +export type QqbotAccountNapcatBindStatus = 'bound' | 'disabled' | 'pending'; + export type QqbotRuleMatchType = 'equals' | 'keyword' | 'regex'; export type QqbotRuleTargetType = 'all' | 'group' | 'private';