diff --git a/.env.example b/.env.example index fa5f45f..e8fffc3 100644 --- a/.env.example +++ b/.env.example @@ -44,6 +44,7 @@ LOKI_QUERY_SELECTOR= LOKI_SILENCE_ERRORS=true QQBOT_ENABLED=false +QQBOT_ACCOUNT_SECRET_KEY= QQBOT_EVENT_BUS=mqtt QQBOT_REVERSE_WS_PATH=/qqbot/onebot/reverse QQBOT_REVERSE_WS_TOKEN= diff --git a/API.md b/API.md index 7ade0a1..cbc9604 100644 --- a/API.md +++ b/API.md @@ -59,7 +59,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管 | Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | | WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD` | | Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_QUERY_SELECTOR` | -| QQBot | `QQBOT_ENABLED`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` | +| QQBot | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_PATH`、`QQBOT_REVERSE_WS_TOKEN`、`QQBOT_EVENT_BUS`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*` | | NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` | | MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` | | BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | @@ -314,7 +314,9 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT | `POST` | `/qqbot/account/bind/rule` | 绑定账号和自动回复规则 | | `POST` | `/qqbot/account/unbind/rule` | 解绑账号和自动回复规则 | -扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。 +账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。 + +扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会短暂注入 `NAPCAT_QUICK_PASSWORD` 继续尝试密码登录,密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、触发强制扫码验证、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。 同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会把账号标记为离线,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新离线状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于离线信号;如果账号连接时间或心跳晚于容器检测时间,以账号在线态为准;如果最新日志状态是在线,会清空容器旧离线错误;如果最新状态是离线或被踢下线,会同步回写账号离线状态并生成 `qqbot.account.offline` 站内信。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。 diff --git a/README.md b/README.md index d5c0f9f..f8cbc45 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ ci/ Jenkins Agent/Docker 辅助文件 | Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | | WordPress | `WORDPRESS_BASE_URL`、`WORDPRESS_HOST_HEADER`、`WORDPRESS_ADMIN_USERNAME`、`WORDPRESS_ADMIN_PASSWORD`、`WORDPRESS_*_TIMEOUT_MS` | | Logging/Loki | `LOG_LEVEL`、`LOG_APP_NAME`、`LOKI_URL`、`LOKI_QUERY_HOST`、`LOKI_*` | -| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` | +| QQBot/NapCat | `QQBOT_ENABLED`、`QQBOT_ACCOUNT_SECRET_KEY`、`QQBOT_REVERSE_WS_*`、`QQBOT_SEND_*`、`QQBOT_COMMAND_MIN_COOLDOWN_MS`、`QQBOT_RULE_MIN_COOLDOWN_MS`、`QQBOT_REPEATER_*`、`NAPCAT_*`、`QQBOT_NAPCAT_*`、`MQTT_*` | | BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | | FF14 Market | `FF14_XIVAPI_BASE_URL`、`FF14_UNIVERSALIS_BASE_URL`、`FF14_MARKET_CACHE_TTL_MS` | | FFLogs | `FFLOGS_BASE_URL`、`FFLOGS_GRAPHQL_URL`、`FFLOGS_TOKEN_URL`、`FFLOGS_CLIENT_ID`、`FFLOGS_CLIENT_SECRET` | @@ -137,8 +137,9 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts - QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。 - QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表日志检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误。 - NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。 -- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。 -- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,复用既有离线检测与 `super` 站内信告警,使掉线/被踢无需管理员打开列表页即可及时发现;看门狗只做检测告警、不重建容器,避免与 NapCat 自身重连竞争产生设备登录抖动。 +- NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。 +- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 的历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败或触发强制扫码验证时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`。 +- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后先尝试 `ACCOUNT` 历史会话快速登录,再尝试账号保存的登录密码,仍失败时写入离线原因并复用 `super` 站内信告警;看门狗不自动进入扫码阶段。 - BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。 - BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。 - BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。 diff --git a/sql/qqbot-init.sql b/sql/qqbot-init.sql index f4e7d1e..591162e 100644 --- a/sql/qqbot-init.sql +++ b/sql/qqbot-init.sql @@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS `qqbot_account` ( `self_id` varchar(64) NOT NULL, `name` varchar(120) NOT NULL DEFAULT '', `access_token` varchar(255) DEFAULT NULL, + `napcat_login_password_secret` varchar(1024) DEFAULT NULL, `enabled` tinyint(1) NOT NULL DEFAULT 1, `connect_status` varchar(32) NOT NULL DEFAULT 'offline', `client_role` varchar(32) DEFAULT NULL, @@ -290,6 +291,21 @@ PREPARE qqbot_stmt FROM @qqbot_sql; EXECUTE qqbot_stmt; DEALLOCATE PREPARE qqbot_stmt; +SET @qqbot_sql = ( + SELECT IF( + COUNT(*) = 0, + 'ALTER TABLE `qqbot_account` ADD COLUMN `napcat_login_password_secret` varchar(1024) DEFAULT NULL', + 'SELECT 1' + ) + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = 'qqbot_account' + AND column_name = 'napcat_login_password_secret' +); +PREPARE qqbot_stmt FROM @qqbot_sql; +EXECUTE qqbot_stmt; +DEALLOCATE PREPARE qqbot_stmt; + SET @qqbot_sql = ( SELECT IF( COUNT(*) = 0, diff --git a/src/common/logger/pino-logger.config.ts b/src/common/logger/pino-logger.config.ts index 8756e11..caeb7d5 100644 --- a/src/common/logger/pino-logger.config.ts +++ b/src/common/logger/pino-logger.config.ts @@ -13,23 +13,26 @@ const REDACT_PATHS = [ 'body.adminToken', 'body.authorization', 'body.cookie', + 'body.encryptedLoginPassword', + 'body.loginPassword', 'body.password', 'body.refreshToken', 'body.secret', 'body.token', '*.clientSecret', + '*.encryptedLoginPassword', + '*.loginPassword', '*.password', '*.secret', '*.token', ]; -export function createPinoLoggerParams( - configService: ConfigService, -): Params { +export function createPinoLoggerParams(configService: ConfigService): Params { const nodeEnv = getString(configService, 'NODE_ENV', 'development'); const appName = getAppName(configService); const lokiHost = normalizeUrl( - getString(configService, 'LOKI_HOST') || getString(configService, 'LOKI_URL'), + getString(configService, 'LOKI_HOST') || + getString(configService, 'LOKI_URL'), ); const logLevel = getString( configService, @@ -80,7 +83,11 @@ export function getAppName(configService: ConfigService) { } export function getLokiEnvironment(configService: ConfigService) { - return getString(configService, 'LOKI_ENV', getString(configService, 'NODE_ENV', 'development')); + return getString( + configService, + 'LOKI_ENV', + getString(configService, 'NODE_ENV', 'development'), + ); } function createTransport( @@ -131,7 +138,11 @@ function createTransport( service: 'api', }, propsToLabels: ['context'], - silenceErrors: getBoolean(configService, 'LOKI_SILENCE_ERRORS', true), + silenceErrors: getBoolean( + configService, + 'LOKI_SILENCE_ERRORS', + true, + ), timeout: getNumber(configService, 'LOKI_PUSH_TIMEOUT_MS', 30000), }, target: 'pino-loki', @@ -182,11 +193,7 @@ function getRequestId(req: Request) { return `${(req as any).id || getHeader(req, 'x-request-id') || ''}`.trim(); } -function getString( - configService: ConfigService, - key: string, - fallback = '', -) { +function getString(configService: ConfigService, key: string, fallback = '') { const value = configService.get(key); const normalized = `${value ?? ''}`.trim(); return normalized || fallback; diff --git a/src/common/services/tool.service.ts b/src/common/services/tool.service.ts index bc844a0..4e4331e 100644 --- a/src/common/services/tool.service.ts +++ b/src/common/services/tool.service.ts @@ -1,4 +1,9 @@ -import { createHash } from 'node:crypto'; +import { + createCipheriv, + createDecipheriv, + createHash, + randomBytes, +} from 'node:crypto'; import { Injectable } from '@nestjs/common'; import * as svgCaptcha from 'svg-captcha'; import { normalizeVbenErrorText } from '../response/vben-response'; @@ -125,6 +130,12 @@ export class ToolsService { return `${value ?? ''}`.trim(); } + toSecretText(value: unknown) { + if (value === undefined || value === null) return ''; + const text = `${value}`; + return text.trim() ? text : ''; + } + normalizeWhitespaceText(value: unknown) { return this.toTrimmedString(value).replace(/\s+/g, ' '); } @@ -207,6 +218,47 @@ export class ToolsService { return nextValue ? nextValue : null; } + encryptSecretText(value: unknown, secret: unknown) { + const text = this.toSecretText(value); + if (!text) return null; + + const key = this.deriveSecretKey(secret); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const encrypted = Buffer.concat([ + cipher.update(text, 'utf8'), + cipher.final(), + ]); + const tag = cipher.getAuthTag(); + return [ + 'ktv1', + iv.toString('base64url'), + tag.toString('base64url'), + encrypted.toString('base64url'), + ].join(':'); + } + + decryptSecretText(value: unknown, secret: unknown) { + const text = this.toTrimmedString(value); + if (!text) return ''; + + const [version, ivText, tagText, encryptedText] = text.split(':'); + if (version !== 'ktv1' || !ivText || !tagText || !encryptedText) { + throw new Error('密文格式不正确'); + } + + const decipher = createDecipheriv( + 'aes-256-gcm', + this.deriveSecretKey(secret), + Buffer.from(ivText, 'base64url'), + ); + decipher.setAuthTag(Buffer.from(tagText, 'base64url')); + return Buffer.concat([ + decipher.update(Buffer.from(encryptedText, 'base64url')), + decipher.final(), + ]).toString('utf8'); + } + pickFirstText(...values: unknown[]) { for (const value of values) { const text = this.toTrimmedString(value); @@ -408,6 +460,14 @@ export class ToolsService { ).test(text); } + private deriveSecretKey(secret: unknown) { + const normalizedSecret = this.toTrimmedString(secret); + if (!normalizedSecret) { + throw new Error('密钥不能为空'); + } + return createHash('sha256').update(normalizedSecret).digest(); + } + isNapcatExpiredQrcodeStatus(status: NapcatLoginStatusLike) { const message = status.loginError || ''; return ( diff --git a/src/qqbot/account/qqbot-account.dto.ts b/src/qqbot/account/qqbot-account.dto.ts index 1a16300..0cfc7db 100644 --- a/src/qqbot/account/qqbot-account.dto.ts +++ b/src/qqbot/account/qqbot-account.dto.ts @@ -14,6 +14,9 @@ export class QqbotAccountBodyDto { @ApiPropertyOptional({ description: 'OneBot 反向 WS token' }) accessToken?: string; + @ApiPropertyOptional({ description: 'RSA-OAEP 加密后的 NapCat 登录密码' }) + encryptedLoginPassword?: string; + @ApiPropertyOptional({ default: true }) enabled?: boolean; diff --git a/src/qqbot/account/qqbot-account.entity.ts b/src/qqbot/account/qqbot-account.entity.ts index 0aa95a0..637a4a4 100644 --- a/src/qqbot/account/qqbot-account.entity.ts +++ b/src/qqbot/account/qqbot-account.entity.ts @@ -63,6 +63,15 @@ export class QqbotAccount { @Column({ default: null, length: 500, name: 'last_error', nullable: true }) lastError: null | string; + @Column({ + default: null, + length: 1024, + name: 'napcat_login_password_secret', + nullable: true, + select: false, + }) + napcatLoginPasswordSecret: null | string; + @Column({ default: '', length: 255 }) remark: string; diff --git a/src/qqbot/account/qqbot-account.service.ts b/src/qqbot/account/qqbot-account.service.ts index b13d106..c7c1e9f 100644 --- a/src/qqbot/account/qqbot-account.service.ts +++ b/src/qqbot/account/qqbot-account.service.ts @@ -1,4 +1,5 @@ import { Inject, Injectable, Optional } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { @@ -7,6 +8,7 @@ import { throwVbenError, ToolsService, } from '@/common'; +import { AdminPasswordCryptoService } from '@/admin/auth/admin-password-crypto.service'; import { QqbotAccountAbility } from './qqbot-account-ability.entity'; import { QqbotAccount } from './qqbot-account.entity'; import type { @@ -28,6 +30,12 @@ import type { } from '../qqbot.types'; const NAPCAT_RUNTIME_CHECK_TTL_MS = 30_000; +const NAPCAT_AUTO_LOGIN_CLEANUP_FAILED_MESSAGE = + 'NapCat 自动登录后运行态密码清理失败,请手动更新登录'; +const INSECURE_ACCOUNT_SECRET_VALUES = new Set([ + 'change-me', + 'kt-template-online-admin-token-secret', +]); @Injectable() export class QqbotAccountService { @@ -45,6 +53,10 @@ export class QqbotAccountService { @Optional() @Inject(SYSTEM_NOTICE_PUBLISHER) private readonly systemNoticePublisher?: SystemNoticePublisher, + @Optional() + private readonly configService?: ConfigService, + @Optional() + private readonly passwordCryptoService?: AdminPasswordCryptoService, ) {} async page(query: QqbotAccountQueryDto) { @@ -166,6 +178,15 @@ export class QqbotAccountService { }); } + async findByIdWithNapcatLoginSecret(id: string) { + return this.accountRepository + .createQueryBuilder('account') + .addSelect('account.napcatLoginPasswordSecret') + .where('account.id = :id', { id }) + .andWhere('account.isDeleted = :isDeleted', { isDeleted: false }) + .getOne(); + } + async findBySelfId(selfId: string) { return this.accountRepository.findOne({ where: { @@ -344,19 +365,21 @@ export class QqbotAccountService { /** * 看门狗:主动巡检在线的已绑定账号,复用既有离线检测 + 站内信告警逻辑, * 让掉线/被踢能被及时发现并通知超管,而不必等管理员打开账号列表页。 - * 仅做检测与告警,不做容器重建(避免与 NapCat 自身重连竞争、产生设备登录抖动)。 + * 检测到离线后先尝试快速登录,再尝试密码登录;扫码登录仍只由管理员手动触发。 */ async runOfflineWatchdog(): Promise<{ checked: number }> { - const accounts = await this.accountRepository.find({ - where: { + const accounts = await this.accountRepository + .createQueryBuilder('account') + .addSelect('account.napcatLoginPasswordSecret') + .where('account.connectStatus = :connectStatus', { connectStatus: 'online', - enabled: true, - isDeleted: false, - }, - }); + }) + .andWhere('account.enabled = :enabled', { enabled: true }) + .andWhere('account.isDeleted = :isDeleted', { isDeleted: false }) + .getMany(); if (accounts.length <= 0) return { checked: 0 }; - await this.appendNapcatRuntime(accounts); + await this.appendNapcatRuntime(accounts, { autoLogin: true }); return { checked: accounts.length }; } @@ -372,8 +395,22 @@ export class QqbotAccountService { await this.accountRepository.update({ selfId }, payload); } + getNapcatLoginPassword( + account?: Pick | null, + ) { + const secret = this.toolsService.toTrimmedString( + account?.napcatLoginPasswordSecret, + ); + if (!secret) return ''; + return this.toolsService.decryptSecretText( + secret, + this.getAccountSecretKey(), + ); + } + private async appendNapcatRuntime( accounts: QqbotAccount[], + options: { autoLogin?: boolean } = {}, ): Promise { if (accounts.length <= 0) return []; @@ -415,7 +452,7 @@ export class QqbotAccountService { } const container = containerMap.get(binding.containerId); - await this.syncNapcatOfflineState(account, container); + await this.syncNapcatOfflineState(account, container, options); return Object.assign(account, { napcat: { bindStatus: binding.bindStatus, @@ -436,6 +473,7 @@ export class QqbotAccountService { private async syncNapcatOfflineState( account: QqbotAccount, container?: QqbotNapcatContainer, + options: { autoLogin?: boolean } = {}, ) { if ( account.connectStatus !== 'online' || @@ -449,6 +487,9 @@ export class QqbotAccountService { const cachedOfflineReason = this.getFreshCachedOfflineReason(container); if (cachedOfflineReason) { + if (options.autoLogin && (await this.tryAutoLogin(account, container))) { + return; + } await this.applyNapcatOfflineState( account, container, @@ -462,9 +503,43 @@ export class QqbotAccountService { await this.napcatContainerService.detectRuntimeOffline(container); if (!offlineReason) return; + if (options.autoLogin && (await this.tryAutoLogin(account, container))) { + return; + } + await this.applyNapcatOfflineState(account, container, offlineReason); } + private async tryAutoLogin( + account: QqbotAccount, + container: QqbotNapcatContainer, + ) { + try { + const result = await this.napcatContainerService.tryAutoLogin(container, { + loginPassword: this.getNapcatLoginPassword(account), + selfId: account.selfId, + }); + if (result.cleanupFailed) { + await this.applyNapcatOfflineState( + account, + container, + NAPCAT_AUTO_LOGIN_CLEANUP_FAILED_MESSAGE, + ); + return true; + } + if (!result.success) return false; + + await this.markOnline(account.selfId, 'Universal'); + account.clientRole = 'Universal'; + account.connectStatus = 'online'; + account.lastConnectedAt = new Date() as any; + account.lastError = null; + return true; + } catch { + return false; + } + } + private async applyNapcatOfflineState( account: QqbotAccount, container: QqbotNapcatContainer, @@ -578,7 +653,7 @@ export class QqbotAccountService { } private normalizeBody(body: Partial) { - return { + const payload: Partial = { accessToken: this.toolsService.normalizeNullableString(body.accessToken), connectionMode: body.connectionMode || 'reverse-ws', enabled: body.enabled ?? true, @@ -586,7 +661,44 @@ export class QqbotAccountService { remark: body.remark || '', selfId: typeof body.selfId === 'string' ? body.selfId.trim() : body.selfId, - } as Partial; + }; + const napcatLoginPasswordSecret = this.toNapcatLoginPasswordSecret( + body.encryptedLoginPassword, + ); + if (napcatLoginPasswordSecret !== undefined) { + payload.napcatLoginPasswordSecret = napcatLoginPasswordSecret; + } + return payload; + } + + private toNapcatLoginPasswordSecret(encryptedLoginPassword?: string) { + if (!encryptedLoginPassword) return undefined; + if (!this.passwordCryptoService) { + throwVbenError('登录密码解密服务未配置'); + } + + const password = this.toolsService.toSecretText( + this.passwordCryptoService.decryptPassword(encryptedLoginPassword), + ); + return password + ? this.toolsService.encryptSecretText( + password, + this.getAccountSecretKey(), + ) + : null; + } + + private getAccountSecretKey() { + const secret = this.toolsService.pickFirstText( + this.configService?.get('QQBOT_ACCOUNT_SECRET_KEY'), + this.configService?.get('ADMIN_TOKEN_SECRET'), + ); + if (!secret || INSECURE_ACCOUNT_SECRET_VALUES.has(secret)) { + throwVbenError( + 'QQBot 账号登录密码密钥未配置,请设置 QQBOT_ACCOUNT_SECRET_KEY 或 ADMIN_TOKEN_SECRET', + ); + } + return secret; } private async bindAbility( diff --git a/src/qqbot/account/qqbot-napcat-login.service.ts b/src/qqbot/account/qqbot-napcat-login.service.ts index a92549c..eaee083 100644 --- a/src/qqbot/account/qqbot-napcat-login.service.ts +++ b/src/qqbot/account/qqbot-napcat-login.service.ts @@ -50,19 +50,22 @@ export class QqbotNapcatLoginService { } async startRefresh(accountId: string) { - const account = await this.accountService.findById(accountId); + const account = + await this.accountService.findByIdWithNapcatLoginSecret(accountId); if (!account) { throwVbenError('QQBot 账号不存在'); } - const container = await this.containerService.prepareAccountContainer( - account, - ); + const loginPassword = this.accountService.getNapcatLoginPassword(account); + const container = + await this.containerService.prepareAccountContainer(account); return this.startScan( { accountId: account.id, expectedSelfId: account.selfId, forceRelogin: true, + hasExistingPrimaryBinding: container.hasExistingPrimaryBinding, + loginPassword, mode: 'refresh', }, container, @@ -77,7 +80,7 @@ export class QqbotNapcatLoginService { if (session.preparingRelogin) { return this.keepSessionPending( session, - session.errorMessage || 'NapCat 正在重置登录态并生成二维码,请稍后', + session.errorMessage || 'NapCat 正在尝试快速登录,请稍后', ); } @@ -117,8 +120,7 @@ export class QqbotNapcatLoginService { session.lastRestartedAt = Date.now(); return this.keepSessionPending( session, - loginStatus.loginError || - 'NapCat 账号已离线,已重新生成二维码', + loginStatus.loginError || 'NapCat 账号已离线,已重新生成二维码', true, ); } @@ -240,6 +242,8 @@ export class QqbotNapcatLoginService { accountId?: string; expectedSelfId?: string; forceRelogin?: boolean; + hasExistingPrimaryBinding?: boolean; + loginPassword?: string; mode: QqbotLoginScanMode; }, container: QqbotNapcatRuntime, @@ -254,7 +258,7 @@ export class QqbotNapcatLoginService { status: 'pending', }); session.lastRestartedAt = Date.now(); - session.errorMessage = 'NapCat 正在重置登录态并生成二维码,请稍后'; + session.errorMessage = this.getReloginPreparingMessage(options); this.sessions.set(session.id, session); this.publishScanResultEvent( session, @@ -262,7 +266,13 @@ export class QqbotNapcatLoginService { 'processing', '已创建更新登录会话', ); - void this.prepareReloginQrcode(session, container); + const reloginTask = this.prepareReloginQrcode( + session, + container, + options.loginPassword, + options.hasExistingPrimaryBinding, + ); + void reloginTask; return this.toResult(session); } @@ -277,8 +287,7 @@ export class QqbotNapcatLoginService { }); session.lastRestartedAt = Date.now(); session.errorMessage = - loginStatus.loginError || - 'NapCat 账号已离线,已重新生成二维码'; + loginStatus.loginError || 'NapCat 账号已离线,已重新生成二维码'; this.sessions.set(session.id, session); this.publishScanResultEvent( session, @@ -341,8 +350,9 @@ export class QqbotNapcatLoginService { private async completeLogin( session: QqbotLoginScanSession, container: QqbotNapcatRuntime, + options: { loginInfo?: NapcatLoginInfo; successMessage?: string } = {}, ): Promise { - const loginInfo = await this.getLoginInfo(container); + const loginInfo = options.loginInfo ?? (await this.getLoginInfo(container)); if (loginInfo.online === false) { return this.failSession(session, 'NapCat 当前账号已离线,请重新更新登录'); } @@ -374,7 +384,7 @@ export class QqbotNapcatLoginService { selfId, }; this.publishScanEvent(session, { - message: '扫码登录成功', + message: options.successMessage || '扫码登录成功', result, status: 'success', step: 'login-success', @@ -770,8 +780,27 @@ export class QqbotNapcatLoginService { private async prepareReloginQrcode( session: QqbotLoginScanSession, container: QqbotNapcatRuntime, + loginPassword?: string, + hasExistingPrimaryBinding = true, ) { try { + const password = this.toolsService.toSecretText(loginPassword); + if (hasExistingPrimaryBinding) { + const quickLoginCompleted = await this.tryQuickRelogin( + session, + container, + !!password, + ); + if (quickLoginCompleted) return; + } + + const passwordLoginCompleted = await this.tryPasswordRelogin( + session, + container, + password, + ); + if (passwordLoginCompleted) return; + this.publishScanResultEvent( session, 'relogin-reset-start', @@ -846,13 +875,333 @@ export class QqbotNapcatLoginService { } } + private async tryQuickRelogin( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + hasPasswordFallback = false, + ) { + let loginInfo: NapcatLoginInfo; + session.errorMessage = 'NapCat 正在尝试快速登录,请稍后'; + this.sessions.set(session.id, session); + this.publishScanResultEvent( + session, + 'quick-login-start', + 'processing', + '正在尝试 NapCat -q 快速登录', + ); + await this.clearRuntimeLoginPasswordBeforeQuick(session, container); + + try { + await this.restartNapcatForLogin(container, { waitForReady: false }); + session.lastRestartedAt = Date.now(); + this.publishScanResultEvent( + session, + 'quick-login-wait', + 'processing', + '等待 NapCat 快速登录结果', + ); + await this.toolsService.sleep(this.getRestartDelayMs()); + const loginStatus = await this.getLoginStatus(container, true); + if (!loginStatus.isLogin) { + this.publishQuickLoginFallback( + session, + loginStatus.loginError, + hasPasswordFallback, + ); + return false; + } + + loginInfo = await this.getLoginInfo(container); + if (loginInfo.online === false) { + this.publishQuickLoginFallback( + session, + 'NapCat 当前账号已离线', + hasPasswordFallback, + ); + return false; + } + + const selfId = this.toolsService.pickNapcatSelfId(loginInfo); + if (!selfId) { + this.publishQuickLoginFallback( + session, + 'NapCat 未返回 QQ 号', + hasPasswordFallback, + ); + return false; + } + if (session.expectedSelfId && session.expectedSelfId !== selfId) { + this.publishQuickLoginFallback( + session, + `当前快速登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`, + hasPasswordFallback, + ); + return false; + } + } catch (err) { + this.publishQuickLoginFallback( + session, + this.toolsService.getErrorMessage(err), + hasPasswordFallback, + ); + return false; + } + + await this.completeLogin(session, container, { + loginInfo, + successMessage: '快速登录成功', + }); + return true; + } + + private async tryPasswordRelogin( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + loginPassword?: string, + ) { + const password = this.toolsService.toSecretText(loginPassword); + if (!password) { + this.publishPasswordLoginFallback(session, '未配置 QQ 登录密码'); + return false; + } + + let loginInfo: NapcatLoginInfo | undefined; + let loggedInSelfId = ''; + session.errorMessage = 'NapCat 正在尝试密码登录,请稍后'; + this.sessions.set(session.id, session); + this.publishScanResultEvent( + session, + 'password-login-start', + 'processing', + '正在尝试 NapCat 密码登录', + ); + + const passwordEnv = await this.containerService.ensureRuntimeLoginEnv( + container, + { + loginPassword: password, + selfId: session.expectedSelfId, + }, + ); + if (!passwordEnv.ok) { + this.publishPasswordLoginFallback(session, '运行态密码环境准备失败'); + return false; + } + + let loginStatus: NapcatLoginStatus; + try { + await this.restartNapcatForLogin(container, { waitForReady: false }); + session.lastRestartedAt = Date.now(); + this.publishScanResultEvent( + session, + 'password-login-wait', + 'processing', + '等待 NapCat 密码登录结果', + ); + await this.toolsService.sleep(this.getRestartDelayMs()); + loginStatus = await this.getLoginStatus(container, true); + + if (loginStatus.isLogin) { + loginInfo = await this.getLoginInfo(container); + } + } catch (err) { + await this.clearRuntimeLoginPasswordAfterFailedPassword( + session, + container, + session.expectedSelfId || loggedInSelfId, + ); + this.publishPasswordLoginFallback( + session, + this.toolsService.getErrorMessage(err), + ); + return false; + } + + if (!loginStatus.isLogin) { + await this.clearRuntimeLoginPasswordAfterFailedPassword( + session, + container, + session.expectedSelfId, + ); + this.publishPasswordLoginFallback(session, loginStatus.loginError); + return false; + } + + if (loginInfo?.online === false) { + await this.clearRuntimeLoginPasswordAfterFailedPassword( + session, + container, + session.expectedSelfId, + ); + this.publishPasswordLoginFallback(session, 'NapCat 当前账号已离线'); + return false; + } + if (!loginInfo) { + await this.clearRuntimeLoginPasswordAfterFailedPassword( + session, + container, + session.expectedSelfId, + ); + this.publishPasswordLoginFallback(session, 'NapCat 未返回登录信息'); + return false; + } + + const selfId = this.toolsService.pickNapcatSelfId(loginInfo); + if (!selfId) { + await this.clearRuntimeLoginPasswordAfterFailedPassword( + session, + container, + session.expectedSelfId, + ); + this.publishPasswordLoginFallback(session, 'NapCat 未返回 QQ 号'); + return false; + } + loggedInSelfId = selfId; + if (session.expectedSelfId && session.expectedSelfId !== selfId) { + await this.clearRuntimeLoginPasswordAfterFailedPassword( + session, + container, + selfId, + ); + this.publishPasswordLoginFallback( + session, + `当前密码登录账号 ${selfId} 与目标账号 ${session.expectedSelfId} 不一致`, + ); + return false; + } + + await this.clearRuntimeLoginPasswordAfterSuccess( + session, + container, + loggedInSelfId, + ); + await this.completeLogin(session, container, { + loginInfo, + successMessage: '密码登录成功', + }); + return true; + } + + private async clearRuntimeLoginPasswordBeforeQuick( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + ) { + this.publishScanResultEvent( + session, + 'quick-login-cleanup', + 'processing', + '正在清理运行态登录密码', + ); + const cleaned = await this.containerService.ensureRuntimeLoginEnv(container, { + clearLoginPassword: true, + selfId: session.expectedSelfId, + }); + if (!cleaned.ok) { + throw new Error('NapCat 快速登录前运行态密码清理失败,请重试更新登录'); + } + } + + private async clearRuntimeLoginPasswordAfterFailedPassword( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + selfId?: string, + ) { + this.publishScanResultEvent( + session, + 'password-env-cleanup', + 'processing', + '正在移除运行态登录密码', + ); + const cleaned = await this.containerService.ensureRuntimeLoginEnv(container, { + clearLoginPassword: true, + selfId, + }); + if (!cleaned.ok) { + throw new Error( + 'NapCat 密码登录未完成,且运行态密码清理失败,请重试更新登录', + ); + } + } + + private async clearRuntimeLoginPasswordAfterSuccess( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + selfId: string, + ) { + this.publishScanResultEvent( + session, + 'password-env-cleanup', + 'processing', + '正在移除运行态登录密码', + ); + const cleaned = await this.containerService.ensureRuntimeLoginEnv(container, { + clearLoginPassword: true, + selfId, + }); + if (!cleaned.ok) { + throw new Error( + 'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录', + ); + } + } + + private getReloginPreparingMessage(options: { + hasExistingPrimaryBinding?: boolean; + loginPassword?: string; + }) { + if (options.hasExistingPrimaryBinding !== false) { + return 'NapCat 正在尝试快速登录,请稍后'; + } + return this.toolsService.toSecretText(options.loginPassword) + ? 'NapCat 正在尝试密码登录,请稍后' + : 'NapCat 正在准备登录二维码,请稍后'; + } + + private publishQuickLoginFallback( + session: QqbotLoginScanSession, + reason?: string, + hasPasswordFallback = false, + ) { + const nextStepMessage = hasPasswordFallback + ? '开始尝试密码登录' + : '开始生成二维码'; + session.errorMessage = reason + ? `快速登录未完成:${reason},${nextStepMessage}` + : `快速登录未完成,${nextStepMessage}`; + this.sessions.set(session.id, session); + this.publishScanResultEvent( + session, + 'quick-login-fallback', + 'processing', + session.errorMessage, + ); + } + + private publishPasswordLoginFallback( + session: QqbotLoginScanSession, + reason?: string, + ) { + session.errorMessage = reason + ? `密码登录未完成:${reason},开始生成二维码` + : '密码登录未完成,开始生成二维码'; + this.sessions.set(session.id, session); + this.publishScanResultEvent( + session, + 'password-login-fallback', + 'processing', + session.errorMessage, + ); + } + private async resetNapcatForLogin( container: QqbotNapcatRuntime, options: NapcatRestartOptions = {}, onProgress?: (step: string, message: string) => void, ) { - const resetByContainer = - await this.containerService.resetRuntimeLoginState(container, onProgress); + const resetByContainer = await this.containerService.resetRuntimeLoginState( + container, + onProgress, + ); if (!resetByContainer) { onProgress?.('napcat-restart-webui', '正在调用 NapCat 重启接口'); await this.restartNapcatForLogin(container, options); diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index da2e76f..c1182af 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -17,6 +17,23 @@ type NapcatLoginLogResult = { state: NapcatLoginLogState; }; +type NapcatLoginEnvOptions = { + clearLoginPassword?: boolean; + loginPassword?: string; + selfId?: string; +}; + +type NapcatLoginEnvUpdateResult = { + changed: boolean; + ok: boolean; +}; + +type NapcatAutoLoginResult = { + cleanupFailed?: boolean; + method?: 'password' | 'quick'; + success: boolean; +}; + @Injectable() export class QqbotNapcatContainerService { constructor( @@ -44,10 +61,11 @@ export class QqbotNapcatContainerService { const existing = await this.getPrimaryRuntime(account.id); if (existing) { await this.ensureRuntimeQuickLogin(existing, account.selfId); - return existing; + return { ...existing, hasExistingPrimaryBinding: true }; } - return this.createManagedContainer(account.selfId); + const created = await this.createManagedContainer(account.selfId); + return { ...created, hasExistingPrimaryBinding: false }; } /** @@ -56,34 +74,49 @@ export class QqbotNapcatContainerService { * 能从持久化会话免扫码自动重登。硬踢(登录已失效)时会话作废,仍需扫码。 */ async ensureRuntimeQuickLogin(runtime: QqbotNapcatRuntime, selfId?: string) { - const account = `${selfId || ''}`.trim(); - if (!account || this.getManagedMode() !== 'ssh' || !runtime.id) { - return false; + return this.ensureRuntimeLoginEnv(runtime, { selfId }); + } + + async ensureRuntimeLoginEnv( + runtime: QqbotNapcatRuntime, + options: NapcatLoginEnvOptions, + ): Promise { + if (this.getManagedMode() !== 'ssh' || !runtime.id) { + return { changed: false, ok: true }; } - const container = await this.containerRepository - .createQueryBuilder('container') - .addSelect('container.webuiToken') - .where('container.id = :containerId', { containerId: runtime.id }) - .andWhere('container.isDeleted = :isDeleted', { isDeleted: false }) - .getOne(); + const account = this.toolsService.toTrimmedString(options.selfId); + if (!account) { + return { changed: false, ok: false }; + } + + const container = await this.findContainerWithToken(runtime.id); if ( !container || !container.name || !container.webuiPort || !container.webuiToken ) { - return false; + return { changed: false, ok: false }; } - // 已带 ACCOUNT(或无法确认)则跳过,避免重复重建:只在首次缺失时原地重建一次。 - if (await this.runtimeHasAccountEnv(container.name)) return false; + if ( + await this.runtimeMatchesLoginEnv(container.name, { + ...options, + selfId: account, + }) + ) { + return { changed: false, ok: true }; + } try { await this.createRemoteDockerContainer({ account, dataDir: container.dataDir || `${this.getRootDir()}/${container.name}`, image: container.image, + loginPassword: options.clearLoginPassword + ? undefined + : this.toolsService.toSecretText(options.loginPassword), name: container.name, port: container.webuiPort, reverseWsUrl: container.reverseWsUrl || this.buildReverseWsUrl(), @@ -98,34 +131,118 @@ export class QqbotNapcatContainerService { status: 'running', }, ); + return { changed: true, ok: true }; + } catch { + return { changed: false, ok: false }; + } + } + + async tryAutoLogin( + container: QqbotNapcatContainer, + options: NapcatLoginEnvOptions, + ): Promise { + const selfId = this.toolsService.toTrimmedString(options.selfId); + if (!selfId || this.getManagedMode() !== 'ssh' || !container.id) { + return { success: false }; + } + + const runtimeContainer = await this.findContainerWithToken(container.id); + if (!runtimeContainer?.name) return { success: false }; + const runtime = this.toRuntime(runtimeContainer); + + const quickCleanup = await this.ensureRuntimeLoginEnv(runtime, { + clearLoginPassword: true, + selfId, + }); + if (!quickCleanup.ok) return { cleanupFailed: true, success: false }; + + const quickState = await this.restartAndDetectLoginState(runtime); + if (quickState.state === 'online') { + return { method: 'quick', success: true }; + } + + const loginPassword = this.toolsService.toSecretText(options.loginPassword); + if (!loginPassword) return { success: false }; + + const passwordEnv = await this.ensureRuntimeLoginEnv(runtime, { + loginPassword, + selfId, + }); + if (!passwordEnv.ok) return { success: false }; + + const passwordState = await this.restartAndDetectLoginState(runtime); + if (passwordState.state !== 'online') { + const cleaned = await this.ensureRuntimeLoginEnv(runtime, { + clearLoginPassword: true, + selfId, + }); + return cleaned.ok + ? { success: false } + : { cleanupFailed: true, success: false }; + } + + const cleaned = await this.ensureRuntimeLoginEnv(runtime, { + clearLoginPassword: true, + selfId, + }); + if (!cleaned.ok) return { cleanupFailed: true, success: false }; + + return { method: 'password', success: true }; + } + + private async runtimeMatchesLoginEnv( + name: string, + options: NapcatLoginEnvOptions, + ) { + try { + const result = await this.runProcess( + 'ssh', + [...this.getSshArgs(), 'sh -s'], + this.buildRemoteInspectEnvScript(name), + undefined, + this.getRuntimeCheckTimeoutMs(), + ); + const env = this.parseDockerEnv(result.stdout); + if ( + env.get('ACCOUNT') !== this.toolsService.toTrimmedString(options.selfId) + ) { + return false; + } + const hasPassword = + env.has('NAPCAT_QUICK_PASSWORD') || + env.has('NAPCAT_QUICK_PASSWORD_MD5'); + if (options.clearLoginPassword) return !hasPassword; + + const loginPassword = this.toolsService.toSecretText(options.loginPassword); + if (loginPassword) { + return env.get('NAPCAT_QUICK_PASSWORD') === loginPassword; + } return true; } catch { - // 重建失败不阻断登录流程:保持原容器继续走扫码登录。 return false; } } - private async runtimeHasAccountEnv(name: string) { - try { - const result = await this.runProcess( - 'ssh', - [ - ...this.getSshArgs(), - 'docker', - 'inspect', - '--format', - '{{range .Config.Env}}{{println .}}{{end}}', - name, - ], - '', - undefined, - this.getRuntimeCheckTimeoutMs(), - ); - return /^ACCOUNT=\S/m.test(result.stdout); - } catch { - // 无法确认时按“已存在”处理,宁可不重建也不误删运行中的容器。 - return true; - } + private buildRemoteInspectEnvScript(name: string) { + return ` +set -eu +NAME=${this.sh(name)} +docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" +`; + } + + private parseDockerEnv(stdout: string) { + const env = new Map(); + stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .forEach((line) => { + const index = line.indexOf('='); + if (index <= 0) return; + env.set(line.slice(0, index), line.slice(index + 1)); + }); + return env; } async findRuntimeById(containerId?: string) { @@ -478,6 +595,47 @@ docker logs --tail 300 "$NAME" 2>&1 || true `; } + private buildRemoteRecentLogsSinceScript(name: string, since: string) { + return ` +set -eu +NAME=${this.sh(name)} +SINCE=${this.sh(since)} +docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true +`; + } + + private async restartAndDetectLoginState(runtime: QqbotNapcatRuntime) { + const since = new Date(Date.now() - 1000).toISOString(); + await this.restartRuntimeContainer(runtime); + const attempts = Number( + this.getConfig('QQBOT_NAPCAT_AUTO_LOGIN_RETRIES', '10'), + ); + const delayMs = Number( + this.getConfig('QQBOT_NAPCAT_AUTO_LOGIN_INTERVAL_MS', '2000'), + ); + let latest: NapcatLoginLogResult = { + offlineReason: null, + state: 'unknown', + }; + for (let index = 0; index < attempts; index += 1) { + await this.toolsService.sleep(Number.isFinite(delayMs) ? delayMs : 2000); + latest = await this.detectRuntimeLoginStateSince(runtime.name, since); + if (latest.state !== 'unknown') return latest; + } + return latest; + } + + private async detectRuntimeLoginStateSince(name: string, since: string) { + const result = await this.runProcess( + 'ssh', + [...this.getSshArgs(), 'sh -s'], + this.buildRemoteRecentLogsSinceScript(name, since), + undefined, + this.getRuntimeCheckTimeoutMs(), + ); + return this.extractLoginState(result.stdout); + } + private extractLoginState(logs: string): NapcatLoginLogResult { const lines = logs .split(/\r?\n/) @@ -548,7 +706,19 @@ docker logs --tail 300 "$NAME" 2>&1 || true return container ? this.toRuntime(container) : null; } - private async createManagedContainer(selfId?: string) { + private async findContainerWithToken(containerId: string) { + return this.containerRepository + .createQueryBuilder('container') + .addSelect('container.webuiToken') + .where('container.id = :containerId', { containerId }) + .andWhere('container.isDeleted = :isDeleted', { isDeleted: false }) + .getOne(); + } + + private async createManagedContainer( + selfId?: string, + loginPassword?: string, + ) { const mode = this.getManagedMode(); if (mode !== 'ssh') { throwVbenError('当前仅支持通过 SSH 创建 NapCat 容器'); @@ -586,6 +756,7 @@ docker logs --tail 300 "$NAME" 2>&1 || true account: selfId, dataDir, image, + loginPassword, name, port, reverseWsUrl, @@ -623,6 +794,7 @@ docker logs --tail 300 "$NAME" 2>&1 || true account?: string; dataDir: string; image: string; + loginPassword?: string; name: string; port: number; reverseWsUrl: string; @@ -637,6 +809,7 @@ docker logs --tail 300 "$NAME" 2>&1 || true account?: string; dataDir: string; image: string; + loginPassword?: string; name: string; port: number; reverseWsUrl: string; @@ -649,8 +822,15 @@ docker logs --tail 300 "$NAME" 2>&1 || true const reverseWsUrl = this.sh(input.reverseWsUrl); const token = this.sh(input.token); const account = `${input.account || ''}`.trim(); + const loginPassword = this.toolsService.toSecretText(input.loginPassword); const accountHeader = account ? `ACCOUNT=${this.sh(account)}\n` : ''; const accountRunFlag = account ? ' -e ACCOUNT="$ACCOUNT" \\\n' : ''; + const passwordHeader = loginPassword + ? `NAPCAT_QUICK_PASSWORD=${this.sh(loginPassword)}\n` + : ''; + const passwordRunFlag = loginPassword + ? ' -e NAPCAT_QUICK_PASSWORD="$NAPCAT_QUICK_PASSWORD" \\\n' + : ''; const pullCmd = input.skipPull ? '' : 'docker pull "$IMAGE" >/dev/null\n'; return ` @@ -662,6 +842,7 @@ PORT=${input.port} REVERSE_WS_URL=${reverseWsUrl} WEBUI_TOKEN=${token} ${accountHeader} +${passwordHeader} mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" chmod 700 "$DATA_DIR" @@ -707,7 +888,7 @@ docker run -d \\ -e NAPCAT_UID=0 \\ -e NAPCAT_GID=0 \\ -e WEBUI_TOKEN="$WEBUI_TOKEN" \\ -${accountRunFlag} -p "$PORT:6099" \\ +${accountRunFlag}${passwordRunFlag} -p "$PORT:6099" \\ -v "$DATA_DIR/QQ:/app/.config/QQ" \\ -v "$DATA_DIR/config:/app/napcat/config" \\ -v "$DATA_DIR/plugins:/app/napcat/plugins" \\ diff --git a/src/qqbot/qqbot.types.ts b/src/qqbot/qqbot.types.ts index 0e7936b..5f8bc2d 100644 --- a/src/qqbot/qqbot.types.ts +++ b/src/qqbot/qqbot.types.ts @@ -279,6 +279,8 @@ export type QqbotLoginScanSession = { export type QqbotNapcatRuntime = { baseUrl: string; + /** Heuristic for quick login: existing primary binding implies a reusable data volume may exist. */ + hasExistingPrimaryBinding?: boolean; id?: string; name: string; webuiPort?: null | number; diff --git a/test/qqbot/account/qqbot-account.controller.spec.ts b/test/qqbot/account/qqbot-account.controller.spec.ts new file mode 100644 index 0000000..6aae601 --- /dev/null +++ b/test/qqbot/account/qqbot-account.controller.spec.ts @@ -0,0 +1,79 @@ +jest.mock('@/admin/auth/jwt-auth.guard', () => ({ + JwtAuthGuard: class { + canActivate() { + return true; + } + }, +})); +jest.mock('@/qqbot/account/qqbot-account.service', () => ({ + QqbotAccountService: class {}, +})); +jest.mock('@/qqbot/account/qqbot-napcat-login.service', () => ({ + QqbotNapcatLoginService: class {}, +})); +jest.mock('@/qqbot/connection/qqbot-reverse-ws.service', () => ({ + QqbotReverseWsService: class {}, +})); + +import * as request from 'supertest'; +import { Test } from '@nestjs/testing'; +import type { INestApplication } from '@nestjs/common'; +import { QqbotAccountController } from '@/qqbot/account/qqbot-account.controller'; +import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service'; +import { QqbotNapcatLoginService } from '@/qqbot/account/qqbot-napcat-login.service'; +import { QqbotReverseWsService } from '@/qqbot/connection/qqbot-reverse-ws.service'; + +describe('QqbotAccountController', () => { + let app: INestApplication; + const accountService = { + save: jest.fn().mockResolvedValue('account-1'), + }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [QqbotAccountController], + providers: [ + { provide: QqbotAccountService, useValue: accountService }, + { provide: QqbotNapcatLoginService, useValue: {} }, + { provide: QqbotReverseWsService, useValue: {} }, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(() => { + accountService.save.mockClear(); + }); + + it('accepts encrypted NapCat login password through account save API', async () => { + await request(app.getHttpServer()) + .post('/qqbot/account/save') + .send({ + encryptedLoginPassword: 'encrypted-login-password', + name: 'Mirror', + selfId: '1914728559', + }) + .expect(200) + .expect((response) => { + expect(response.body).toEqual( + expect.objectContaining({ + code: 200, + data: 'account-1', + }), + ); + }); + + expect(accountService.save).toHaveBeenCalledWith( + expect.objectContaining({ + encryptedLoginPassword: 'encrypted-login-password', + selfId: '1914728559', + }), + ); + }); +}); diff --git a/test/qqbot/account/qqbot-account.service.spec.ts b/test/qqbot/account/qqbot-account.service.spec.ts index 02315aa..2e7c0e7 100644 --- a/test/qqbot/account/qqbot-account.service.spec.ts +++ b/test/qqbot/account/qqbot-account.service.spec.ts @@ -2,6 +2,190 @@ import { ToolsService } from '@/common'; import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service'; describe('QqbotAccountService', () => { + it('stores NapCat login password as encrypted secret and never persists the transport field', async () => { + const toolsService = new ToolsService(); + const accountRepository = { + create: jest.fn((input) => input), + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn(async (input) => ({ ...input, id: 'account-1' })), + }; + const service = new QqbotAccountService( + accountRepository as any, + {} as any, + {} as any, + {} as any, + {} as any, + toolsService, + undefined, + { + get: jest.fn((key: string) => + key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '', + ), + } as any, + { + decryptPassword: jest.fn().mockReturnValue('qq-login-password'), + } as any, + ); + + await service.save({ + encryptedLoginPassword: 'encrypted-payload', + selfId: '1914728559', + }); + + const payload = accountRepository.create.mock.calls[0][0]; + expect(payload.encryptedLoginPassword).toBeUndefined(); + expect(payload.napcatLoginPasswordSecret).toBeTruthy(); + expect(payload.napcatLoginPasswordSecret).not.toContain( + 'qq-login-password', + ); + expect( + toolsService.decryptSecretText( + payload.napcatLoginPasswordSecret, + 'unit-secret', + ), + ).toBe('qq-login-password'); + }); + + it('requires an explicit secret key before storing NapCat login password', async () => { + const accountRepository = { + create: jest.fn((input) => input), + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn(async (input) => ({ ...input, id: 'account-1' })), + }; + const service = new QqbotAccountService( + accountRepository as any, + {} as any, + {} as any, + {} as any, + {} as any, + new ToolsService(), + undefined, + { + get: jest.fn().mockReturnValue(''), + } as any, + { + decryptPassword: jest.fn().mockReturnValue('qq-login-password'), + } as any, + ); + + await expect( + service.save({ + encryptedLoginPassword: 'encrypted-payload', + selfId: '1914728559', + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + msg: 'QQBot 账号登录密码密钥未配置,请设置 QQBOT_ACCOUNT_SECRET_KEY 或 ADMIN_TOKEN_SECRET', + }), + }); + expect(accountRepository.save).not.toHaveBeenCalled(); + }); + + it('rejects public placeholder secret before storing NapCat login password', async () => { + const accountRepository = { + create: jest.fn((input) => input), + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn(async (input) => ({ ...input, id: 'account-1' })), + }; + const service = new QqbotAccountService( + accountRepository as any, + {} as any, + {} as any, + {} as any, + {} as any, + new ToolsService(), + undefined, + { + get: jest.fn((key: string) => + key === 'ADMIN_TOKEN_SECRET' ? 'change-me' : '', + ), + } as any, + { + decryptPassword: jest.fn().mockReturnValue('qq-login-password'), + } as any, + ); + + await expect( + service.save({ + encryptedLoginPassword: 'encrypted-payload', + selfId: '1914728559', + }), + ).rejects.toMatchObject({ + response: expect.objectContaining({ + msg: 'QQBot 账号登录密码密钥未配置,请设置 QQBOT_ACCOUNT_SECRET_KEY 或 ADMIN_TOKEN_SECRET', + }), + }); + expect(accountRepository.save).not.toHaveBeenCalled(); + }); + + it('preserves NapCat login password whitespace after decryption', async () => { + const toolsService = new ToolsService(); + const accountRepository = { + create: jest.fn((input) => input), + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn(async (input) => ({ ...input, id: 'account-1' })), + }; + const service = new QqbotAccountService( + accountRepository as any, + {} as any, + {} as any, + {} as any, + {} as any, + toolsService, + undefined, + { + get: jest.fn((key: string) => + key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '', + ), + } as any, + { + decryptPassword: jest.fn().mockReturnValue(' qq-login-password '), + } as any, + ); + + await service.save({ + encryptedLoginPassword: 'encrypted-payload', + selfId: '1914728559', + }); + + const payload = accountRepository.create.mock.calls[0][0]; + expect( + toolsService.decryptSecretText( + payload.napcatLoginPasswordSecret, + 'unit-secret', + ), + ).toBe(' qq-login-password '); + }); + + it('does not update NapCat login password when edit leaves the password blank', async () => { + const accountRepository = { + findOne: jest.fn().mockResolvedValue({ id: 'account-1' }), + update: jest.fn(), + }; + const service = new QqbotAccountService( + accountRepository as any, + { update: jest.fn() } as any, + {} as any, + {} as any, + {} as any, + new ToolsService(), + ); + + await service.update({ + id: 'account-1', + name: 'Mirror', + selfId: '1914728559', + }); + + expect(accountRepository.update).toHaveBeenCalledWith( + { id: 'account-1' }, + expect.not.objectContaining({ + encryptedLoginPassword: expect.anything(), + napcatLoginPasswordSecret: expect.anything(), + }), + ); + }); + it('preserves previous offline reason when later disconnect has no explicit error', async () => { const accountRepository = { update: jest.fn(), @@ -267,7 +451,7 @@ describe('QqbotAccountService', () => { detectRuntimeOffline: jest.fn(), }; const systemNoticePublisher = { - publishSystemNotice: jest.fn(), + publishSystemNotice: jest.fn().mockResolvedValue(undefined), }; const service = new QqbotAccountService( accountRepository as any, @@ -305,4 +489,211 @@ describe('QqbotAccountService', () => { }), ); }); + + it('lets watchdog auto-login before marking an account offline', async () => { + const toolsService = new ToolsService(); + const account = { + connectStatus: 'online', + enabled: true, + id: 'account-1', + isDeleted: false, + lastError: null, + name: '主账号', + napcatLoginPasswordSecret: toolsService.encryptSecretText( + 'qq-login-password', + 'unit-secret', + ), + selfId: '1914728559', + }; + const binding = { + accountId: 'account-1', + bindStatus: 'bound', + containerId: 'container-1', + isDeleted: false, + isPrimary: true, + lastLoginAt: new Date('2026-06-10T12:00:00.000Z'), + }; + const container = { + id: 'container-1', + isDeleted: false, + lastError: null, + name: 'kt-qqbot-napcat-1914728559', + status: 'running', + webuiPort: 6101, + }; + const accountRepository = { + createQueryBuilder: jest.fn(() => ({ + addSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([account]), + where: jest.fn().mockReturnThis(), + })), + update: jest.fn(), + }; + const napcatContainerService = { + detectRuntimeOffline: jest + .fn() + .mockResolvedValue('NapCat 账号状态变更为离线'), + tryAutoLogin: jest.fn().mockResolvedValue({ + method: 'password', + success: true, + }), + }; + const systemNoticePublisher = { + publishSystemNotice: jest.fn().mockResolvedValue(undefined), + }; + const service = new QqbotAccountService( + accountRepository as any, + {} as any, + { + createQueryBuilder: jest.fn(() => ({ + addOrderBy: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([binding]), + orderBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + })), + } as any, + { + createQueryBuilder: jest.fn(() => ({ + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([container]), + where: jest.fn().mockReturnThis(), + })), + } as any, + napcatContainerService as any, + toolsService, + systemNoticePublisher as any, + { + get: jest.fn((key: string) => + key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '', + ), + } as any, + ); + + const result = await service.runOfflineWatchdog(); + + expect(result).toEqual({ checked: 1 }); + expect(napcatContainerService.tryAutoLogin).toHaveBeenCalledWith( + container, + { + loginPassword: 'qq-login-password', + selfId: '1914728559', + }, + ); + expect(accountRepository.update).toHaveBeenCalledWith( + { selfId: '1914728559' }, + expect.objectContaining({ + clientRole: 'Universal', + connectStatus: 'online', + lastConnectedAt: expect.any(Date), + lastError: null, + }), + ); + expect(systemNoticePublisher.publishSystemNotice).not.toHaveBeenCalled(); + }); + + it('records a cleanup failure when watchdog auto-login leaves runtime password env uncertain', async () => { + const toolsService = new ToolsService(); + const account = { + connectStatus: 'online', + enabled: true, + id: 'account-1', + isDeleted: false, + lastError: null, + name: '主账号', + napcatLoginPasswordSecret: toolsService.encryptSecretText( + 'qq-login-password', + 'unit-secret', + ), + selfId: '1914728559', + }; + const binding = { + accountId: 'account-1', + bindStatus: 'bound', + containerId: 'container-1', + isDeleted: false, + isPrimary: true, + lastLoginAt: new Date('2026-06-10T12:00:00.000Z'), + }; + const container = { + id: 'container-1', + isDeleted: false, + lastError: null, + name: 'kt-qqbot-napcat-1914728559', + status: 'running', + webuiPort: 6101, + }; + const accountRepository = { + createQueryBuilder: jest.fn(() => ({ + addSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([account]), + where: jest.fn().mockReturnThis(), + })), + update: jest.fn(), + }; + const napcatContainerService = { + detectRuntimeOffline: jest + .fn() + .mockResolvedValue('NapCat 账号状态变更为离线'), + tryAutoLogin: jest.fn().mockResolvedValue({ + cleanupFailed: true, + success: false, + }), + }; + const systemNoticePublisher = { + publishSystemNotice: jest.fn().mockResolvedValue(undefined), + }; + const service = new QqbotAccountService( + accountRepository as any, + {} as any, + { + createQueryBuilder: jest.fn(() => ({ + addOrderBy: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([binding]), + orderBy: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + })), + } as any, + { + createQueryBuilder: jest.fn(() => ({ + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([container]), + where: jest.fn().mockReturnThis(), + })), + } as any, + napcatContainerService as any, + toolsService, + systemNoticePublisher as any, + { + get: jest.fn((key: string) => + key === 'QQBOT_ACCOUNT_SECRET_KEY' ? 'unit-secret' : '', + ), + } as any, + ); + + await service.runOfflineWatchdog(); + + expect(accountRepository.update).toHaveBeenCalledTimes(1); + expect(accountRepository.update).toHaveBeenCalledWith( + { selfId: '1914728559' }, + { + connectStatus: 'offline', + lastError: 'NapCat 自动登录后运行态密码清理失败,请手动更新登录', + }, + ); + expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'NapCat 自动登录后运行态密码清理失败,请手动更新登录', + eventType: 'qqbot.account.offline', + severity: 'error', + title: 'QQBot 账号已下线:1914728559', + }), + ); + expect( + JSON.stringify(accountRepository.update.mock.calls), + ).not.toContain('NapCat 账号状态变更为离线'); + }); }); diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index c08a710..e6609b5 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -73,6 +73,8 @@ describe('QqbotNapcatLoginService', () => { }; const accountService = { findById: jest.fn().mockResolvedValue(account), + findByIdWithNapcatLoginSecret: jest.fn().mockResolvedValue(account), + getNapcatLoginPassword: jest.fn().mockReturnValue('qq-password'), }; const containerService = { prepareAccountContainer: jest.fn().mockResolvedValue(existingContainer), @@ -89,6 +91,10 @@ describe('QqbotNapcatLoginService', () => { await refreshService.startRefresh('account-1'); + expect(accountService.findByIdWithNapcatLoginSecret).toHaveBeenCalledWith( + 'account-1', + ); + expect(accountService.getNapcatLoginPassword).toHaveBeenCalledWith(account); expect(containerService.prepareAccountContainer).toHaveBeenCalledWith( account, ); @@ -97,31 +103,23 @@ describe('QqbotNapcatLoginService', () => { accountId: 'account-1', expectedSelfId: '10001', forceRelogin: true, + loginPassword: 'qq-password', mode: 'refresh', }, existingContainer, ); }); - it('returns refresh login scan immediately while resetting in background', async () => { + it('returns refresh login scan immediately while trying quick login in background', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-current', name: 'napcat-10001', }; - let resolveReset!: () => void; - const containerService = { - resetRuntimeLoginState: jest.fn( - () => - new Promise((resolve) => { - resolveReset = () => resolve(true); - }), - ), - }; const refreshService = new QqbotNapcatLoginService( { get: jest.fn() } as unknown as ConfigService, {} as QqbotAccountService, - containerService as unknown as QqbotNapcatContainerService, + {} as QqbotNapcatContainerService, new ToolsService(), ); jest @@ -130,9 +128,9 @@ describe('QqbotNapcatLoginService', () => { jest .spyOn(refreshService as any, 'cleanupSessions') .mockResolvedValue(undefined); - jest - .spyOn(refreshService as any, 'refreshOrGetQrcode') - .mockResolvedValue('fresh-qrcode'); + const prepareReloginQrcode = jest + .spyOn(refreshService as any, 'prepareReloginQrcode') + .mockResolvedValue(undefined); const result = await (refreshService as any).startScan( { @@ -146,14 +144,692 @@ describe('QqbotNapcatLoginService', () => { expect(result.status).toBe('pending'); expect(result.qrcode).toBeUndefined(); - expect(result.errorMessage).toBe( - 'NapCat 正在重置登录态并生成二维码,请稍后', + expect(result.errorMessage).toBe('NapCat 正在尝试快速登录,请稍后'); + expect(prepareReloginQrcode).toHaveBeenCalledWith( + expect.objectContaining({ + errorMessage: 'NapCat 正在尝试快速登录,请稍后', + status: 'pending', + }), + container, + undefined, + undefined, + ); + }); + + it('uses NapCat -q quick login before generating qrcode for refresh login', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-quick', + name: 'napcat-10001', + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValue({ changed: false, ok: true }), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: true, + }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Kwi', + online: true, + uin: '10001', + }); + const refreshQrcode = jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fallback-qrcode'); + + await (refreshService as any).prepareReloginQrcode(session, container); + + expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( + container, + { + clearLoginPassword: true, + selfId: '10001', + }, + ); + expect(containerService.restartRuntimeContainer).toHaveBeenCalledWith( + container, + ); + expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); + expect(refreshQrcode).not.toHaveBeenCalled(); + expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ + accountId: 'account-1', + name: 'Kwi', + selfId: '10001', + }); + expect(containerService.bindAccount).toHaveBeenCalledWith( + 'account-1', + 'container-quick', + ); + expect(session.status).toBe('success'); + const events = (refreshService as any).sessionEventLogs.get(session.id); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ step: 'quick-login-start' }), + expect.objectContaining({ + message: '快速登录成功', + step: 'login-success', + }), + ]), + ); + }); + + it('does not complete quick login when runtime password env cleanup fails', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-quick-cleanup-failed', + name: 'napcat-10001', + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValue({ changed: false, ok: false }), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: true, + }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Kwi', + online: true, + uin: '10001', + }); + + await (refreshService as any).prepareReloginQrcode(session, container); + + expect(accountService.ensureScannedAccount).not.toHaveBeenCalled(); + expect(containerService.bindAccount).not.toHaveBeenCalled(); + expect(session.status).toBe('error'); + expect(session.errorMessage).toBe( + 'NapCat 快速登录前运行态密码清理失败,请重试更新登录', + ); + const steps = ( + (refreshService as any).sessionEventLogs.get(session.id) ?? [] + ).map((event: { step: string }) => event.step); + expect(steps).toContain('quick-login-cleanup'); + expect(steps).not.toContain('login-success'); + }); + + it('falls back to fresh qrcode when NapCat -q quick login fails', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-fallback', + name: 'napcat-10001', + }; + const containerService = { + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValue({ changed: false, ok: true }), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + {} as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: false, + loginError: '快速登录未成功', + }); + jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fallback-qrcode'); + + await (refreshService as any).prepareReloginQrcode(session, container); + + expect(containerService.restartRuntimeContainer).toHaveBeenCalledWith( + container, ); expect(containerService.resetRuntimeLoginState).toHaveBeenCalledWith( container, expect.any(Function), ); - resolveReset(); + expect( + containerService.restartRuntimeContainer.mock.invocationCallOrder[0], + ).toBeLessThan( + containerService.resetRuntimeLoginState.mock.invocationCallOrder[0], + ); + expect(session.qrcode).toBe('fallback-qrcode'); + const steps = ( + (refreshService as any).sessionEventLogs.get(session.id) ?? [] + ).map((event: { step: string }) => event.step); + expect(steps.indexOf('quick-login-start')).toBeLessThan( + steps.indexOf('quick-login-fallback'), + ); + expect(steps.indexOf('quick-login-fallback')).toBeLessThan( + steps.indexOf('relogin-reset-start'), + ); + }); + + it('tries password login after quick login fails and before qrcode fallback', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-password', + name: 'napcat-10001', + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValue({ changed: true, ok: true }), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest + .spyOn(refreshService as any, 'getLoginStatus') + .mockResolvedValueOnce({ + isLogin: false, + loginError: '快速登录未找到历史会话', + }) + .mockResolvedValueOnce({ + isLogin: true, + }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Kwi', + online: true, + uin: '10001', + }); + const refreshQrcode = jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fallback-qrcode'); + + await (refreshService as any).prepareReloginQrcode( + session, + container, + 'qq-password', + ); + + expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( + container, + { + clearLoginPassword: true, + selfId: '10001', + }, + ); + expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( + container, + { + loginPassword: 'qq-password', + selfId: '10001', + }, + ); + expect(containerService.ensureRuntimeLoginEnv).toHaveBeenCalledWith( + container, + { + clearLoginPassword: true, + selfId: '10001', + }, + ); + expect(containerService.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( + 3, + container, + { + clearLoginPassword: true, + selfId: '10001', + }, + ); + expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); + expect(refreshQrcode).not.toHaveBeenCalled(); + expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ + accountId: 'account-1', + name: 'Kwi', + selfId: '10001', + }); + expect(session.status).toBe('success'); + const steps = ( + (refreshService as any).sessionEventLogs.get(session.id) ?? [] + ).map((event: { step: string }) => event.step); + expect(steps).toEqual( + expect.arrayContaining([ + 'quick-login-start', + 'quick-login-fallback', + 'password-login-start', + 'password-login-wait', + 'login-success', + ]), + ); + expect(steps.indexOf('quick-login-fallback')).toBeLessThan( + steps.indexOf('password-login-start'), + ); + expect(steps.indexOf('password-login-start')).toBeLessThan( + steps.indexOf('login-success'), + ); + }); + + it('uses password before qrcode without historical NapCat session', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + hasExistingPrimaryBinding: false, + id: 'container-new', + name: 'napcat-10001', + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValue({ changed: true, ok: true }), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: true, + }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Kwi', + online: true, + uin: '10001', + }); + const refreshQrcode = jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fallback-qrcode'); + + await (refreshService as any).prepareReloginQrcode( + session, + container, + 'qq-password', + false, + ); + + expect(containerService.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( + 1, + container, + { + loginPassword: 'qq-password', + selfId: '10001', + }, + ); + expect(refreshQrcode).not.toHaveBeenCalled(); + expect(session.status).toBe('success'); + const steps = ( + (refreshService as any).sessionEventLogs.get(session.id) ?? [] + ).map((event: { step: string }) => event.step); + expect(steps).not.toContain('quick-login-start'); + expect(steps).toEqual( + expect.arrayContaining(['password-login-start', 'login-success']), + ); + }); + + it('fails password refresh when runtime password env cleanup fails', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-password-cleanup-failed', + name: 'napcat-10001', + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValueOnce({ changed: false, ok: true }) + .mockResolvedValueOnce({ changed: true, ok: true }) + .mockResolvedValueOnce({ changed: false, ok: false }), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest + .spyOn(refreshService as any, 'getLoginStatus') + .mockResolvedValueOnce({ + isLogin: false, + loginError: '快速登录未找到历史会话', + }) + .mockResolvedValueOnce({ + isLogin: true, + }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Kwi', + online: true, + uin: '10001', + }); + + await (refreshService as any).prepareReloginQrcode( + session, + container, + 'qq-password', + ); + + expect(containerService.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( + 3, + container, + { + clearLoginPassword: true, + selfId: '10001', + }, + ); + expect(accountService.ensureScannedAccount).not.toHaveBeenCalled(); + expect(containerService.bindAccount).not.toHaveBeenCalled(); + expect(session.status).toBe('error'); + expect(session.errorMessage).toBe( + 'NapCat 密码登录已完成,但运行态密码清理失败,请重试更新登录', + ); + const steps = ( + (refreshService as any).sessionEventLogs.get(session.id) ?? [] + ).map((event: { step: string }) => event.step); + expect(steps).toContain('password-env-cleanup'); + expect(steps).not.toContain('login-success'); + }); + + it('falls back to qrcode only after quick login and password login both fail', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-password-fallback', + name: 'napcat-10001', + }; + const containerService = { + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValue({ changed: true, ok: true }), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + {} as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest + .spyOn(refreshService as any, 'getLoginStatus') + .mockResolvedValueOnce({ + isLogin: false, + loginError: '快速登录未找到历史会话', + }) + .mockResolvedValueOnce({ + isLogin: false, + loginError: '密码登录未完成', + }); + jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fallback-qrcode'); + + await (refreshService as any).prepareReloginQrcode( + session, + container, + 'qq-password', + ); + + expect(containerService.resetRuntimeLoginState).toHaveBeenCalledWith( + container, + expect.any(Function), + ); + expect(session.qrcode).toBe('fallback-qrcode'); + const steps = ( + (refreshService as any).sessionEventLogs.get(session.id) ?? [] + ).map((event: { step: string }) => event.step); + expect(steps.indexOf('quick-login-fallback')).toBeLessThan( + steps.indexOf('password-login-start'), + ); + expect(steps.indexOf('password-login-fallback')).toBeLessThan( + steps.indexOf('relogin-reset-start'), + ); + }); + + it('does not enter qrcode fallback when password env cleanup after failed password login fails', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-password-failed-cleanup-failed', + name: 'napcat-10001', + }; + const containerService = { + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValueOnce({ changed: false, ok: true }) + .mockResolvedValueOnce({ changed: true, ok: true }) + .mockResolvedValueOnce({ changed: false, ok: false }), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + {} as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest + .spyOn(refreshService as any, 'getLoginStatus') + .mockResolvedValueOnce({ + isLogin: false, + loginError: '快速登录未找到历史会话', + }) + .mockResolvedValueOnce({ + isLogin: false, + loginError: '密码登录未完成', + }); + const refreshQrcode = jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fallback-qrcode'); + + await (refreshService as any).prepareReloginQrcode( + session, + container, + 'qq-password', + ); + + expect(containerService.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( + 3, + container, + { + clearLoginPassword: true, + selfId: '10001', + }, + ); + expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); + expect(refreshQrcode).not.toHaveBeenCalled(); + expect(session.status).toBe('error'); + expect(session.errorMessage).toBe( + 'NapCat 密码登录未完成,且运行态密码清理失败,请重试更新登录', + ); + }); + + it('does not reset login state when quick login succeeds but binding fails', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-bind-error', + name: 'napcat-10001', + }; + const accountService = { + ensureScannedAccount: jest + .fn() + .mockRejectedValue(new Error('账号绑定写入失败')), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValue({ changed: false, ok: true }), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isLogin: true, + }); + jest.spyOn(refreshService as any, 'getLoginInfo').mockResolvedValue({ + nickname: 'Kwi', + online: true, + uin: '10001', + }); + const refreshQrcode = jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('fallback-qrcode'); + + await (refreshService as any).prepareReloginQrcode(session, container); + + expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); + expect(refreshQrcode).not.toHaveBeenCalled(); + expect(session.status).toBe('error'); + expect(session.qrcode).toBeUndefined(); + expect(session.errorMessage).toBe('账号绑定写入失败'); + const steps = ( + (refreshService as any).sessionEventLogs.get(session.id) ?? [] + ).map((event: { step: string }) => event.step); + expect(steps).toContain('quick-login-start'); + expect(steps).not.toContain('quick-login-fallback'); + expect(steps).not.toContain('relogin-reset-start'); }); it('does not complete refresh login from stale status while relogin is preparing', async () => { @@ -161,7 +837,7 @@ describe('QqbotNapcatLoginService', () => { containerId: 'container-preparing', containerName: 'napcat-preparing', createdAt: Date.now(), - errorMessage: 'NapCat 正在重置登录态并生成二维码,请稍后', + errorMessage: 'NapCat 正在尝试快速登录,请稍后', expiresAt: Date.now() + 60_000, id: 'session-preparing', mode: 'refresh', @@ -174,9 +850,7 @@ describe('QqbotNapcatLoginService', () => { const result = await service.status('session-preparing'); expect(result.status).toBe('pending'); - expect(result.errorMessage).toBe( - 'NapCat 正在重置登录态并生成二维码,请稍后', - ); + expect(result.errorMessage).toBe('NapCat 正在尝试快速登录,请稍后'); expect(getLoginStatus).not.toHaveBeenCalled(); }); diff --git a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts index a5a405c..721b034 100644 --- a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts @@ -280,7 +280,7 @@ describe('QqbotNapcatContainerService', () => { ); }); - it('injects ACCOUNT env (NapCat -q quick login) only when a selfId is provided', () => { + it('injects ACCOUNT and NAPCAT_QUICK_PASSWORD env only when provided', () => { const service = new QqbotNapcatContainerService( { get: jest.fn().mockReturnValue('') } as any, {} as any, @@ -300,13 +300,19 @@ describe('QqbotNapcatContainerService', () => { const withAccount = service.buildRemoteCreateScript({ ...baseInput, account: '2354598417', + loginPassword: 'qq-password', }); expect(withAccount).toContain("ACCOUNT='2354598417'"); expect(withAccount).toContain('-e ACCOUNT="$ACCOUNT"'); + expect(withAccount).toContain("NAPCAT_QUICK_PASSWORD='qq-password'"); + expect(withAccount).toContain( + '-e NAPCAT_QUICK_PASSWORD="$NAPCAT_QUICK_PASSWORD"', + ); const withoutAccount = service.buildRemoteCreateScript(baseInput); expect(withoutAccount).not.toContain('-e ACCOUNT="$ACCOUNT"'); expect(withoutAccount).not.toMatch(/^ACCOUNT=/m); + expect(withoutAccount).not.toContain('NAPCAT_QUICK_PASSWORD'); }); it('skips docker pull when recreating in place for quick login', () => { @@ -369,12 +375,332 @@ describe('QqbotNapcatContainerService', () => { '2354598417', ); - expect(recreated).toBe(false); + expect(recreated).toEqual({ changed: false, ok: true }); // 仅做了 inspect 检查,没有触发任何重建(rm/run)。 expect(service.runProcess).toHaveBeenCalledTimes(1); + expect(service.runProcess.mock.calls[0][1]).toEqual( + expect.arrayContaining(['sh -s']), + ); + expect(service.runProcess.mock.calls[0][2]).toContain( + "docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"$NAME\"", + ); expect(containerRepository.update).not.toHaveBeenCalled(); }); + it('recreates login env when docker env inspection fails', async () => { + const containerRepository = { + createQueryBuilder: jest.fn(() => ({ + addSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ + dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x', + id: 'container-1', + image: 'mlikiowa/napcat-docker:latest', + name: 'kt-qqbot-napcat-x', + reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse', + webuiPort: 6100, + webuiToken: 'token-x', + }), + where: jest.fn().mockReturnThis(), + })), + update: jest.fn(), + }; + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => + key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', + ), + } as any, + containerRepository as any, + {} as any, + new ToolsService(), + ) as any; + service.runProcess = jest + .fn() + .mockRejectedValueOnce(new Error('inspect failed')) + .mockResolvedValueOnce({ stderr: '', stdout: '' }); + + const recreated = await service.ensureRuntimeLoginEnv( + { id: 'container-1', name: 'kt-qqbot-napcat-x' }, + { + loginPassword: 'qq-password', + selfId: '2354598417', + }, + ); + + expect(recreated).toEqual({ changed: true, ok: true }); + expect(service.runProcess).toHaveBeenCalledTimes(2); + expect(service.runProcess.mock.calls[1][2]).toContain( + "NAPCAT_QUICK_PASSWORD='qq-password'", + ); + }); + + it('recreates quick-login runtime when password env is missing', async () => { + const containerRepository = { + createQueryBuilder: jest.fn(() => ({ + addSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ + dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x', + id: 'container-1', + image: 'mlikiowa/napcat-docker:latest', + name: 'kt-qqbot-napcat-x', + reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse', + webuiPort: 6100, + webuiToken: 'token-x', + }), + where: jest.fn().mockReturnThis(), + })), + update: jest.fn(), + }; + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => + key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', + ), + } as any, + containerRepository as any, + {} as any, + new ToolsService(), + ) as any; + service.runProcess = jest + .fn() + .mockResolvedValueOnce({ stderr: '', stdout: 'ACCOUNT=2354598417\n' }) + .mockResolvedValueOnce({ stderr: '', stdout: '' }); + + const recreated = await service.ensureRuntimeLoginEnv( + { id: 'container-1', name: 'kt-qqbot-napcat-x' }, + { + loginPassword: 'qq-password', + selfId: '2354598417', + }, + ); + + expect(recreated).toEqual({ changed: true, ok: true }); + expect(service.runProcess).toHaveBeenCalledTimes(2); + expect(service.runProcess.mock.calls[1][2]).toContain( + "NAPCAT_QUICK_PASSWORD='qq-password'", + ); + expect(containerRepository.update).toHaveBeenCalledWith( + { id: 'container-1' }, + expect.objectContaining({ + lastError: null, + status: 'running', + }), + ); + }); + + it('removes password env for a quick-login-only runtime stage', async () => { + const containerRepository = { + createQueryBuilder: jest.fn(() => ({ + addSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ + dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x', + id: 'container-1', + image: 'mlikiowa/napcat-docker:latest', + name: 'kt-qqbot-napcat-x', + reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse', + webuiPort: 6100, + webuiToken: 'token-x', + }), + where: jest.fn().mockReturnThis(), + })), + update: jest.fn(), + }; + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => + key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', + ), + } as any, + containerRepository as any, + {} as any, + new ToolsService(), + ) as any; + service.runProcess = jest + .fn() + .mockResolvedValueOnce({ + stderr: '', + stdout: 'ACCOUNT=2354598417\nNAPCAT_QUICK_PASSWORD=old-password\n', + }) + .mockResolvedValueOnce({ stderr: '', stdout: '' }); + + const recreated = await service.ensureRuntimeLoginEnv( + { id: 'container-1', name: 'kt-qqbot-napcat-x' }, + { + clearLoginPassword: true, + selfId: '2354598417', + }, + ); + + expect(recreated).toEqual({ changed: true, ok: true }); + expect(service.runProcess.mock.calls[1][2]).not.toContain( + 'NAPCAT_QUICK_PASSWORD=', + ); + }); + + it('does not report password auto-login success when password env cleanup fails', async () => { + const containerRepository = { + createQueryBuilder: jest.fn(() => ({ + addSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ + baseUrl: 'http://127.0.0.1:6100/', + id: 'container-1', + name: 'kt-qqbot-napcat-x', + webuiPort: 6100, + webuiToken: 'token-x', + }), + where: jest.fn().mockReturnThis(), + })), + }; + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => + key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', + ), + } as any, + containerRepository as any, + {} as any, + new ToolsService(), + ) as any; + jest + .spyOn(service, 'ensureRuntimeLoginEnv') + .mockResolvedValueOnce({ changed: false, ok: true }) + .mockResolvedValueOnce({ changed: true, ok: true }) + .mockResolvedValueOnce({ changed: false, ok: false }); + jest + .spyOn(service, 'restartAndDetectLoginState') + .mockResolvedValueOnce({ offlineReason: '历史会话失效', state: 'offline' }) + .mockResolvedValueOnce({ offlineReason: null, state: 'online' }); + + const result = await service.tryAutoLogin( + { id: 'container-1' }, + { + loginPassword: 'qq-password', + selfId: '2354598417', + }, + ); + + expect(result).toEqual({ + cleanupFailed: true, + success: false, + }); + expect(service.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ id: 'container-1' }), + { + clearLoginPassword: true, + selfId: '2354598417', + }, + ); + }); + + it('does not leave password env untracked when password auto-login fails and cleanup fails', async () => { + const containerRepository = { + createQueryBuilder: jest.fn(() => ({ + addSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ + baseUrl: 'http://127.0.0.1:6100/', + id: 'container-1', + name: 'kt-qqbot-napcat-x', + webuiPort: 6100, + webuiToken: 'token-x', + }), + where: jest.fn().mockReturnThis(), + })), + }; + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => + key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', + ), + } as any, + containerRepository as any, + {} as any, + new ToolsService(), + ) as any; + jest + .spyOn(service, 'ensureRuntimeLoginEnv') + .mockResolvedValueOnce({ changed: false, ok: true }) + .mockResolvedValueOnce({ changed: true, ok: true }) + .mockResolvedValueOnce({ changed: false, ok: false }); + jest + .spyOn(service, 'restartAndDetectLoginState') + .mockResolvedValueOnce({ offlineReason: '历史会话失效', state: 'offline' }) + .mockResolvedValueOnce({ offlineReason: '密码登录失败', state: 'offline' }); + + const result = await service.tryAutoLogin( + { id: 'container-1' }, + { + loginPassword: 'qq-password', + selfId: '2354598417', + }, + ); + + expect(result).toEqual({ + cleanupFailed: true, + success: false, + }); + expect(service.ensureRuntimeLoginEnv).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ id: 'container-1' }), + { + clearLoginPassword: true, + selfId: '2354598417', + }, + ); + }); + + it('does not restart quick auto-login when password env cleanup fails first', async () => { + const containerRepository = { + createQueryBuilder: jest.fn(() => ({ + addSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ + baseUrl: 'http://127.0.0.1:6100/', + id: 'container-1', + name: 'kt-qqbot-napcat-x', + webuiPort: 6100, + webuiToken: 'token-x', + }), + where: jest.fn().mockReturnThis(), + })), + }; + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => + key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', + ), + } as any, + containerRepository as any, + {} as any, + new ToolsService(), + ) as any; + jest + .spyOn(service, 'ensureRuntimeLoginEnv') + .mockResolvedValueOnce({ changed: false, ok: false }); + jest + .spyOn(service, 'restartAndDetectLoginState') + .mockResolvedValueOnce({ offlineReason: null, state: 'online' }); + + const result = await service.tryAutoLogin( + { id: 'container-1' }, + { + loginPassword: 'qq-password', + selfId: '2354598417', + }, + ); + + expect(result).toEqual({ + cleanupFailed: true, + success: false, + }); + expect(service.restartAndDetectLoginState).not.toHaveBeenCalled(); + }); + it('requires an explicit NapCat image when creating a managed container', async () => { const containerRepository = { find: jest.fn().mockResolvedValue([]),