fix: 完善 NapCat 密码自动登录清理闭环

This commit is contained in:
sunlei 2026-06-12 19:53:41 +08:00
parent da5e5f27e2
commit e951f5068a
16 changed files with 2318 additions and 105 deletions

View File

@ -44,6 +44,7 @@ LOKI_QUERY_SELECTOR=
LOKI_SILENCE_ERRORS=true LOKI_SILENCE_ERRORS=true
QQBOT_ENABLED=false QQBOT_ENABLED=false
QQBOT_ACCOUNT_SECRET_KEY=
QQBOT_EVENT_BUS=mqtt QQBOT_EVENT_BUS=mqtt
QQBOT_REVERSE_WS_PATH=/qqbot/onebot/reverse QQBOT_REVERSE_WS_PATH=/qqbot/onebot/reverse
QQBOT_REVERSE_WS_TOKEN= QQBOT_REVERSE_WS_TOKEN=

6
API.md
View File

@ -59,7 +59,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | | 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 | `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` | | 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_*` | | NapCat | `NAPCAT_WEBUI_BASE_URL`、`NAPCAT_WEBUI_TOKEN`、`QQBOT_NAPCAT_*` |
| MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` | | MQTT | `MQTT_URL`、`MQTT_USERNAME`、`MQTT_PASSWORD`、`MQTT_CLIENT_ID` |
| BangDream | `BANGDREAM_TSUGU_MAIN_SERVER`、`BANGDREAM_TSUGU_DISPLAYED_SERVERS`、`BANGDREAM_TSUGU_CACHE_ROOT` | | 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/bind/rule` | 绑定账号和自动回复规则 |
| `POST` | `/qqbot/account/unbind/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` 默认镜像。 同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器后端会释放旧绑定和未共享的旧容器避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会把账号标记为离线,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新离线状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于离线信号;如果账号连接时间或心跳晚于容器检测时间,以账号在线态为准;如果最新日志状态是在线,会清空容器旧离线错误;如果最新状态是离线或被踢下线,会同步回写账号离线状态并生成 `qqbot.account.offline` 站内信。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。

View File

@ -60,7 +60,7 @@ ci/ Jenkins Agent/Docker 辅助文件
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` | | 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` | | 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_*` | | 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` | | 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` | | 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` | | 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 复读机默认阈值为 4同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表日志检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误。 - QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表日志检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误。
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。 - NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。 - NapCat 账号新增/编辑支持可选 QQ 登录密码Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000``QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,复用既有离线检测与 `super` 站内信告警,使掉线/被踢无需管理员打开列表页即可及时发现;看门狗只做检测告警、不重建容器,避免与 NapCat 自身重连竞争产生设备登录抖动。 - 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 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
- BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。 - BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。 - BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。

View File

@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS `qqbot_account` (
`self_id` varchar(64) NOT NULL, `self_id` varchar(64) NOT NULL,
`name` varchar(120) NOT NULL DEFAULT '', `name` varchar(120) NOT NULL DEFAULT '',
`access_token` varchar(255) DEFAULT NULL, `access_token` varchar(255) DEFAULT NULL,
`napcat_login_password_secret` varchar(1024) DEFAULT NULL,
`enabled` tinyint(1) NOT NULL DEFAULT 1, `enabled` tinyint(1) NOT NULL DEFAULT 1,
`connect_status` varchar(32) NOT NULL DEFAULT 'offline', `connect_status` varchar(32) NOT NULL DEFAULT 'offline',
`client_role` varchar(32) DEFAULT NULL, `client_role` varchar(32) DEFAULT NULL,
@ -290,6 +291,21 @@ PREPARE qqbot_stmt FROM @qqbot_sql;
EXECUTE qqbot_stmt; EXECUTE qqbot_stmt;
DEALLOCATE PREPARE 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 = ( SET @qqbot_sql = (
SELECT IF( SELECT IF(
COUNT(*) = 0, COUNT(*) = 0,

View File

@ -13,23 +13,26 @@ const REDACT_PATHS = [
'body.adminToken', 'body.adminToken',
'body.authorization', 'body.authorization',
'body.cookie', 'body.cookie',
'body.encryptedLoginPassword',
'body.loginPassword',
'body.password', 'body.password',
'body.refreshToken', 'body.refreshToken',
'body.secret', 'body.secret',
'body.token', 'body.token',
'*.clientSecret', '*.clientSecret',
'*.encryptedLoginPassword',
'*.loginPassword',
'*.password', '*.password',
'*.secret', '*.secret',
'*.token', '*.token',
]; ];
export function createPinoLoggerParams( export function createPinoLoggerParams(configService: ConfigService): Params {
configService: ConfigService,
): Params {
const nodeEnv = getString(configService, 'NODE_ENV', 'development'); const nodeEnv = getString(configService, 'NODE_ENV', 'development');
const appName = getAppName(configService); const appName = getAppName(configService);
const lokiHost = normalizeUrl( const lokiHost = normalizeUrl(
getString(configService, 'LOKI_HOST') || getString(configService, 'LOKI_URL'), getString(configService, 'LOKI_HOST') ||
getString(configService, 'LOKI_URL'),
); );
const logLevel = getString( const logLevel = getString(
configService, configService,
@ -80,7 +83,11 @@ export function getAppName(configService: ConfigService) {
} }
export function getLokiEnvironment(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( function createTransport(
@ -131,7 +138,11 @@ function createTransport(
service: 'api', service: 'api',
}, },
propsToLabels: ['context'], propsToLabels: ['context'],
silenceErrors: getBoolean(configService, 'LOKI_SILENCE_ERRORS', true), silenceErrors: getBoolean(
configService,
'LOKI_SILENCE_ERRORS',
true,
),
timeout: getNumber(configService, 'LOKI_PUSH_TIMEOUT_MS', 30000), timeout: getNumber(configService, 'LOKI_PUSH_TIMEOUT_MS', 30000),
}, },
target: 'pino-loki', target: 'pino-loki',
@ -182,11 +193,7 @@ function getRequestId(req: Request) {
return `${(req as any).id || getHeader(req, 'x-request-id') || ''}`.trim(); return `${(req as any).id || getHeader(req, 'x-request-id') || ''}`.trim();
} }
function getString( function getString(configService: ConfigService, key: string, fallback = '') {
configService: ConfigService,
key: string,
fallback = '',
) {
const value = configService.get<string>(key); const value = configService.get<string>(key);
const normalized = `${value ?? ''}`.trim(); const normalized = `${value ?? ''}`.trim();
return normalized || fallback; return normalized || fallback;

View File

@ -1,4 +1,9 @@
import { createHash } from 'node:crypto'; import {
createCipheriv,
createDecipheriv,
createHash,
randomBytes,
} from 'node:crypto';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import * as svgCaptcha from 'svg-captcha'; import * as svgCaptcha from 'svg-captcha';
import { normalizeVbenErrorText } from '../response/vben-response'; import { normalizeVbenErrorText } from '../response/vben-response';
@ -125,6 +130,12 @@ export class ToolsService {
return `${value ?? ''}`.trim(); return `${value ?? ''}`.trim();
} }
toSecretText(value: unknown) {
if (value === undefined || value === null) return '';
const text = `${value}`;
return text.trim() ? text : '';
}
normalizeWhitespaceText(value: unknown) { normalizeWhitespaceText(value: unknown) {
return this.toTrimmedString(value).replace(/\s+/g, ' '); return this.toTrimmedString(value).replace(/\s+/g, ' ');
} }
@ -207,6 +218,47 @@ export class ToolsService {
return nextValue ? nextValue : null; 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[]) { pickFirstText(...values: unknown[]) {
for (const value of values) { for (const value of values) {
const text = this.toTrimmedString(value); const text = this.toTrimmedString(value);
@ -408,6 +460,14 @@ export class ToolsService {
).test(text); ).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) { isNapcatExpiredQrcodeStatus(status: NapcatLoginStatusLike) {
const message = status.loginError || ''; const message = status.loginError || '';
return ( return (

View File

@ -14,6 +14,9 @@ export class QqbotAccountBodyDto {
@ApiPropertyOptional({ description: 'OneBot 反向 WS token' }) @ApiPropertyOptional({ description: 'OneBot 反向 WS token' })
accessToken?: string; accessToken?: string;
@ApiPropertyOptional({ description: 'RSA-OAEP 加密后的 NapCat 登录密码' })
encryptedLoginPassword?: string;
@ApiPropertyOptional({ default: true }) @ApiPropertyOptional({ default: true })
enabled?: boolean; enabled?: boolean;

View File

@ -63,6 +63,15 @@ export class QqbotAccount {
@Column({ default: null, length: 500, name: 'last_error', nullable: true }) @Column({ default: null, length: 500, name: 'last_error', nullable: true })
lastError: null | string; lastError: null | string;
@Column({
default: null,
length: 1024,
name: 'napcat_login_password_secret',
nullable: true,
select: false,
})
napcatLoginPasswordSecret: null | string;
@Column({ default: '', length: 255 }) @Column({ default: '', length: 255 })
remark: string; remark: string;

View File

@ -1,4 +1,5 @@
import { Inject, Injectable, Optional } from '@nestjs/common'; import { Inject, Injectable, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { import {
@ -7,6 +8,7 @@ import {
throwVbenError, throwVbenError,
ToolsService, ToolsService,
} from '@/common'; } from '@/common';
import { AdminPasswordCryptoService } from '@/admin/auth/admin-password-crypto.service';
import { QqbotAccountAbility } from './qqbot-account-ability.entity'; import { QqbotAccountAbility } from './qqbot-account-ability.entity';
import { QqbotAccount } from './qqbot-account.entity'; import { QqbotAccount } from './qqbot-account.entity';
import type { import type {
@ -28,6 +30,12 @@ import type {
} from '../qqbot.types'; } from '../qqbot.types';
const NAPCAT_RUNTIME_CHECK_TTL_MS = 30_000; 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() @Injectable()
export class QqbotAccountService { export class QqbotAccountService {
@ -45,6 +53,10 @@ export class QqbotAccountService {
@Optional() @Optional()
@Inject(SYSTEM_NOTICE_PUBLISHER) @Inject(SYSTEM_NOTICE_PUBLISHER)
private readonly systemNoticePublisher?: SystemNoticePublisher, private readonly systemNoticePublisher?: SystemNoticePublisher,
@Optional()
private readonly configService?: ConfigService,
@Optional()
private readonly passwordCryptoService?: AdminPasswordCryptoService,
) {} ) {}
async page(query: QqbotAccountQueryDto) { 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) { async findBySelfId(selfId: string) {
return this.accountRepository.findOne({ return this.accountRepository.findOne({
where: { where: {
@ -344,19 +365,21 @@ export class QqbotAccountService {
/** /**
* 线线 + * 线线 +
* 线/ * 线/
* NapCat * 线
*/ */
async runOfflineWatchdog(): Promise<{ checked: number }> { async runOfflineWatchdog(): Promise<{ checked: number }> {
const accounts = await this.accountRepository.find({ const accounts = await this.accountRepository
where: { .createQueryBuilder('account')
.addSelect('account.napcatLoginPasswordSecret')
.where('account.connectStatus = :connectStatus', {
connectStatus: 'online', 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 }; if (accounts.length <= 0) return { checked: 0 };
await this.appendNapcatRuntime(accounts); await this.appendNapcatRuntime(accounts, { autoLogin: true });
return { checked: accounts.length }; return { checked: accounts.length };
} }
@ -372,8 +395,22 @@ export class QqbotAccountService {
await this.accountRepository.update({ selfId }, payload); await this.accountRepository.update({ selfId }, payload);
} }
getNapcatLoginPassword(
account?: Pick<QqbotAccount, 'napcatLoginPasswordSecret'> | null,
) {
const secret = this.toolsService.toTrimmedString(
account?.napcatLoginPasswordSecret,
);
if (!secret) return '';
return this.toolsService.decryptSecretText(
secret,
this.getAccountSecretKey(),
);
}
private async appendNapcatRuntime( private async appendNapcatRuntime(
accounts: QqbotAccount[], accounts: QqbotAccount[],
options: { autoLogin?: boolean } = {},
): Promise<QqbotAccountListItem[]> { ): Promise<QqbotAccountListItem[]> {
if (accounts.length <= 0) return []; if (accounts.length <= 0) return [];
@ -415,7 +452,7 @@ export class QqbotAccountService {
} }
const container = containerMap.get(binding.containerId); const container = containerMap.get(binding.containerId);
await this.syncNapcatOfflineState(account, container); await this.syncNapcatOfflineState(account, container, options);
return Object.assign(account, { return Object.assign(account, {
napcat: { napcat: {
bindStatus: binding.bindStatus, bindStatus: binding.bindStatus,
@ -436,6 +473,7 @@ export class QqbotAccountService {
private async syncNapcatOfflineState( private async syncNapcatOfflineState(
account: QqbotAccount, account: QqbotAccount,
container?: QqbotNapcatContainer, container?: QqbotNapcatContainer,
options: { autoLogin?: boolean } = {},
) { ) {
if ( if (
account.connectStatus !== 'online' || account.connectStatus !== 'online' ||
@ -449,6 +487,9 @@ export class QqbotAccountService {
const cachedOfflineReason = this.getFreshCachedOfflineReason(container); const cachedOfflineReason = this.getFreshCachedOfflineReason(container);
if (cachedOfflineReason) { if (cachedOfflineReason) {
if (options.autoLogin && (await this.tryAutoLogin(account, container))) {
return;
}
await this.applyNapcatOfflineState( await this.applyNapcatOfflineState(
account, account,
container, container,
@ -462,9 +503,43 @@ export class QqbotAccountService {
await this.napcatContainerService.detectRuntimeOffline(container); await this.napcatContainerService.detectRuntimeOffline(container);
if (!offlineReason) return; if (!offlineReason) return;
if (options.autoLogin && (await this.tryAutoLogin(account, container))) {
return;
}
await this.applyNapcatOfflineState(account, container, offlineReason); 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( private async applyNapcatOfflineState(
account: QqbotAccount, account: QqbotAccount,
container: QqbotNapcatContainer, container: QqbotNapcatContainer,
@ -578,7 +653,7 @@ export class QqbotAccountService {
} }
private normalizeBody(body: Partial<QqbotAccountBodyDto>) { private normalizeBody(body: Partial<QqbotAccountBodyDto>) {
return { const payload: Partial<QqbotAccount> = {
accessToken: this.toolsService.normalizeNullableString(body.accessToken), accessToken: this.toolsService.normalizeNullableString(body.accessToken),
connectionMode: body.connectionMode || 'reverse-ws', connectionMode: body.connectionMode || 'reverse-ws',
enabled: body.enabled ?? true, enabled: body.enabled ?? true,
@ -586,7 +661,44 @@ export class QqbotAccountService {
remark: body.remark || '', remark: body.remark || '',
selfId: selfId:
typeof body.selfId === 'string' ? body.selfId.trim() : body.selfId, typeof body.selfId === 'string' ? body.selfId.trim() : body.selfId,
} as Partial<QqbotAccount>; };
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<string>('QQBOT_ACCOUNT_SECRET_KEY'),
this.configService?.get<string>('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( private async bindAbility(

View File

@ -50,19 +50,22 @@ export class QqbotNapcatLoginService {
} }
async startRefresh(accountId: string) { async startRefresh(accountId: string) {
const account = await this.accountService.findById(accountId); const account =
await this.accountService.findByIdWithNapcatLoginSecret(accountId);
if (!account) { if (!account) {
throwVbenError('QQBot 账号不存在'); throwVbenError('QQBot 账号不存在');
} }
const container = await this.containerService.prepareAccountContainer( const loginPassword = this.accountService.getNapcatLoginPassword(account);
account, const container =
); await this.containerService.prepareAccountContainer(account);
return this.startScan( return this.startScan(
{ {
accountId: account.id, accountId: account.id,
expectedSelfId: account.selfId, expectedSelfId: account.selfId,
forceRelogin: true, forceRelogin: true,
hasExistingPrimaryBinding: container.hasExistingPrimaryBinding,
loginPassword,
mode: 'refresh', mode: 'refresh',
}, },
container, container,
@ -77,7 +80,7 @@ export class QqbotNapcatLoginService {
if (session.preparingRelogin) { if (session.preparingRelogin) {
return this.keepSessionPending( return this.keepSessionPending(
session, session,
session.errorMessage || 'NapCat 正在重置登录态并生成二维码,请稍后', session.errorMessage || 'NapCat 正在尝试快速登录,请稍后',
); );
} }
@ -117,8 +120,7 @@ export class QqbotNapcatLoginService {
session.lastRestartedAt = Date.now(); session.lastRestartedAt = Date.now();
return this.keepSessionPending( return this.keepSessionPending(
session, session,
loginStatus.loginError || loginStatus.loginError || 'NapCat 账号已离线,已重新生成二维码',
'NapCat 账号已离线,已重新生成二维码',
true, true,
); );
} }
@ -240,6 +242,8 @@ export class QqbotNapcatLoginService {
accountId?: string; accountId?: string;
expectedSelfId?: string; expectedSelfId?: string;
forceRelogin?: boolean; forceRelogin?: boolean;
hasExistingPrimaryBinding?: boolean;
loginPassword?: string;
mode: QqbotLoginScanMode; mode: QqbotLoginScanMode;
}, },
container: QqbotNapcatRuntime, container: QqbotNapcatRuntime,
@ -254,7 +258,7 @@ export class QqbotNapcatLoginService {
status: 'pending', status: 'pending',
}); });
session.lastRestartedAt = Date.now(); session.lastRestartedAt = Date.now();
session.errorMessage = 'NapCat 正在重置登录态并生成二维码,请稍后'; session.errorMessage = this.getReloginPreparingMessage(options);
this.sessions.set(session.id, session); this.sessions.set(session.id, session);
this.publishScanResultEvent( this.publishScanResultEvent(
session, session,
@ -262,7 +266,13 @@ export class QqbotNapcatLoginService {
'processing', 'processing',
'已创建更新登录会话', '已创建更新登录会话',
); );
void this.prepareReloginQrcode(session, container); const reloginTask = this.prepareReloginQrcode(
session,
container,
options.loginPassword,
options.hasExistingPrimaryBinding,
);
void reloginTask;
return this.toResult(session); return this.toResult(session);
} }
@ -277,8 +287,7 @@ export class QqbotNapcatLoginService {
}); });
session.lastRestartedAt = Date.now(); session.lastRestartedAt = Date.now();
session.errorMessage = session.errorMessage =
loginStatus.loginError || loginStatus.loginError || 'NapCat 账号已离线,已重新生成二维码';
'NapCat 账号已离线,已重新生成二维码';
this.sessions.set(session.id, session); this.sessions.set(session.id, session);
this.publishScanResultEvent( this.publishScanResultEvent(
session, session,
@ -341,8 +350,9 @@ export class QqbotNapcatLoginService {
private async completeLogin( private async completeLogin(
session: QqbotLoginScanSession, session: QqbotLoginScanSession,
container: QqbotNapcatRuntime, container: QqbotNapcatRuntime,
options: { loginInfo?: NapcatLoginInfo; successMessage?: string } = {},
): Promise<QqbotLoginScanResult> { ): Promise<QqbotLoginScanResult> {
const loginInfo = await this.getLoginInfo(container); const loginInfo = options.loginInfo ?? (await this.getLoginInfo(container));
if (loginInfo.online === false) { if (loginInfo.online === false) {
return this.failSession(session, 'NapCat 当前账号已离线,请重新更新登录'); return this.failSession(session, 'NapCat 当前账号已离线,请重新更新登录');
} }
@ -374,7 +384,7 @@ export class QqbotNapcatLoginService {
selfId, selfId,
}; };
this.publishScanEvent(session, { this.publishScanEvent(session, {
message: '扫码登录成功', message: options.successMessage || '扫码登录成功',
result, result,
status: 'success', status: 'success',
step: 'login-success', step: 'login-success',
@ -770,8 +780,27 @@ export class QqbotNapcatLoginService {
private async prepareReloginQrcode( private async prepareReloginQrcode(
session: QqbotLoginScanSession, session: QqbotLoginScanSession,
container: QqbotNapcatRuntime, container: QqbotNapcatRuntime,
loginPassword?: string,
hasExistingPrimaryBinding = true,
) { ) {
try { 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( this.publishScanResultEvent(
session, session,
'relogin-reset-start', '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( private async resetNapcatForLogin(
container: QqbotNapcatRuntime, container: QqbotNapcatRuntime,
options: NapcatRestartOptions = {}, options: NapcatRestartOptions = {},
onProgress?: (step: string, message: string) => void, onProgress?: (step: string, message: string) => void,
) { ) {
const resetByContainer = const resetByContainer = await this.containerService.resetRuntimeLoginState(
await this.containerService.resetRuntimeLoginState(container, onProgress); container,
onProgress,
);
if (!resetByContainer) { if (!resetByContainer) {
onProgress?.('napcat-restart-webui', '正在调用 NapCat 重启接口'); onProgress?.('napcat-restart-webui', '正在调用 NapCat 重启接口');
await this.restartNapcatForLogin(container, options); await this.restartNapcatForLogin(container, options);

View File

@ -17,6 +17,23 @@ type NapcatLoginLogResult = {
state: NapcatLoginLogState; 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() @Injectable()
export class QqbotNapcatContainerService { export class QqbotNapcatContainerService {
constructor( constructor(
@ -44,10 +61,11 @@ export class QqbotNapcatContainerService {
const existing = await this.getPrimaryRuntime(account.id); const existing = await this.getPrimaryRuntime(account.id);
if (existing) { if (existing) {
await this.ensureRuntimeQuickLogin(existing, account.selfId); 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) { async ensureRuntimeQuickLogin(runtime: QqbotNapcatRuntime, selfId?: string) {
const account = `${selfId || ''}`.trim(); return this.ensureRuntimeLoginEnv(runtime, { selfId });
if (!account || this.getManagedMode() !== 'ssh' || !runtime.id) { }
return false;
async ensureRuntimeLoginEnv(
runtime: QqbotNapcatRuntime,
options: NapcatLoginEnvOptions,
): Promise<NapcatLoginEnvUpdateResult> {
if (this.getManagedMode() !== 'ssh' || !runtime.id) {
return { changed: false, ok: true };
} }
const container = await this.containerRepository const account = this.toolsService.toTrimmedString(options.selfId);
.createQueryBuilder('container') if (!account) {
.addSelect('container.webuiToken') return { changed: false, ok: false };
.where('container.id = :containerId', { containerId: runtime.id }) }
.andWhere('container.isDeleted = :isDeleted', { isDeleted: false })
.getOne(); const container = await this.findContainerWithToken(runtime.id);
if ( if (
!container || !container ||
!container.name || !container.name ||
!container.webuiPort || !container.webuiPort ||
!container.webuiToken !container.webuiToken
) { ) {
return false; return { changed: false, ok: false };
} }
// 已带 ACCOUNT或无法确认则跳过避免重复重建只在首次缺失时原地重建一次。 if (
if (await this.runtimeHasAccountEnv(container.name)) return false; await this.runtimeMatchesLoginEnv(container.name, {
...options,
selfId: account,
})
) {
return { changed: false, ok: true };
}
try { try {
await this.createRemoteDockerContainer({ await this.createRemoteDockerContainer({
account, account,
dataDir: container.dataDir || `${this.getRootDir()}/${container.name}`, dataDir: container.dataDir || `${this.getRootDir()}/${container.name}`,
image: container.image, image: container.image,
loginPassword: options.clearLoginPassword
? undefined
: this.toolsService.toSecretText(options.loginPassword),
name: container.name, name: container.name,
port: container.webuiPort, port: container.webuiPort,
reverseWsUrl: container.reverseWsUrl || this.buildReverseWsUrl(), reverseWsUrl: container.reverseWsUrl || this.buildReverseWsUrl(),
@ -98,34 +131,118 @@ export class QqbotNapcatContainerService {
status: 'running', status: 'running',
}, },
); );
return { changed: true, ok: true };
} catch {
return { changed: false, ok: false };
}
}
async tryAutoLogin(
container: QqbotNapcatContainer,
options: NapcatLoginEnvOptions,
): Promise<NapcatAutoLoginResult> {
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; return true;
} catch { } catch {
// 重建失败不阻断登录流程:保持原容器继续走扫码登录。
return false; return false;
} }
} }
private async runtimeHasAccountEnv(name: string) { private buildRemoteInspectEnvScript(name: string) {
try { return `
const result = await this.runProcess( set -eu
'ssh', NAME=${this.sh(name)}
[ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
...this.getSshArgs(), `;
'docker', }
'inspect',
'--format', private parseDockerEnv(stdout: string) {
'{{range .Config.Env}}{{println .}}{{end}}', const env = new Map<string, string>();
name, stdout
], .split(/\r?\n/)
'', .map((line) => line.trim())
undefined, .filter(Boolean)
this.getRuntimeCheckTimeoutMs(), .forEach((line) => {
); const index = line.indexOf('=');
return /^ACCOUNT=\S/m.test(result.stdout); if (index <= 0) return;
} catch { env.set(line.slice(0, index), line.slice(index + 1));
// 无法确认时按“已存在”处理,宁可不重建也不误删运行中的容器。 });
return true; return env;
}
} }
async findRuntimeById(containerId?: string) { 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 { private extractLoginState(logs: string): NapcatLoginLogResult {
const lines = logs const lines = logs
.split(/\r?\n/) .split(/\r?\n/)
@ -548,7 +706,19 @@ docker logs --tail 300 "$NAME" 2>&1 || true
return container ? this.toRuntime(container) : null; 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(); const mode = this.getManagedMode();
if (mode !== 'ssh') { if (mode !== 'ssh') {
throwVbenError('当前仅支持通过 SSH 创建 NapCat 容器'); throwVbenError('当前仅支持通过 SSH 创建 NapCat 容器');
@ -586,6 +756,7 @@ docker logs --tail 300 "$NAME" 2>&1 || true
account: selfId, account: selfId,
dataDir, dataDir,
image, image,
loginPassword,
name, name,
port, port,
reverseWsUrl, reverseWsUrl,
@ -623,6 +794,7 @@ docker logs --tail 300 "$NAME" 2>&1 || true
account?: string; account?: string;
dataDir: string; dataDir: string;
image: string; image: string;
loginPassword?: string;
name: string; name: string;
port: number; port: number;
reverseWsUrl: string; reverseWsUrl: string;
@ -637,6 +809,7 @@ docker logs --tail 300 "$NAME" 2>&1 || true
account?: string; account?: string;
dataDir: string; dataDir: string;
image: string; image: string;
loginPassword?: string;
name: string; name: string;
port: number; port: number;
reverseWsUrl: string; reverseWsUrl: string;
@ -649,8 +822,15 @@ docker logs --tail 300 "$NAME" 2>&1 || true
const reverseWsUrl = this.sh(input.reverseWsUrl); const reverseWsUrl = this.sh(input.reverseWsUrl);
const token = this.sh(input.token); const token = this.sh(input.token);
const account = `${input.account || ''}`.trim(); const account = `${input.account || ''}`.trim();
const loginPassword = this.toolsService.toSecretText(input.loginPassword);
const accountHeader = account ? `ACCOUNT=${this.sh(account)}\n` : ''; const accountHeader = account ? `ACCOUNT=${this.sh(account)}\n` : '';
const accountRunFlag = account ? ' -e ACCOUNT="$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'; const pullCmd = input.skipPull ? '' : 'docker pull "$IMAGE" >/dev/null\n';
return ` return `
@ -662,6 +842,7 @@ PORT=${input.port}
REVERSE_WS_URL=${reverseWsUrl} REVERSE_WS_URL=${reverseWsUrl}
WEBUI_TOKEN=${token} WEBUI_TOKEN=${token}
${accountHeader} ${accountHeader}
${passwordHeader}
mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs"
chmod 700 "$DATA_DIR" chmod 700 "$DATA_DIR"
@ -707,7 +888,7 @@ docker run -d \\
-e NAPCAT_UID=0 \\ -e NAPCAT_UID=0 \\
-e NAPCAT_GID=0 \\ -e NAPCAT_GID=0 \\
-e WEBUI_TOKEN="$WEBUI_TOKEN" \\ -e WEBUI_TOKEN="$WEBUI_TOKEN" \\
${accountRunFlag} -p "$PORT:6099" \\ ${accountRunFlag}${passwordRunFlag} -p "$PORT:6099" \\
-v "$DATA_DIR/QQ:/app/.config/QQ" \\ -v "$DATA_DIR/QQ:/app/.config/QQ" \\
-v "$DATA_DIR/config:/app/napcat/config" \\ -v "$DATA_DIR/config:/app/napcat/config" \\
-v "$DATA_DIR/plugins:/app/napcat/plugins" \\ -v "$DATA_DIR/plugins:/app/napcat/plugins" \\

View File

@ -279,6 +279,8 @@ export type QqbotLoginScanSession = {
export type QqbotNapcatRuntime = { export type QqbotNapcatRuntime = {
baseUrl: string; baseUrl: string;
/** Heuristic for quick login: existing primary binding implies a reusable data volume may exist. */
hasExistingPrimaryBinding?: boolean;
id?: string; id?: string;
name: string; name: string;
webuiPort?: null | number; webuiPort?: null | number;

View File

@ -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',
}),
);
});
});

View File

@ -2,6 +2,190 @@ import { ToolsService } from '@/common';
import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service'; import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service';
describe('QqbotAccountService', () => { 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 () => { it('preserves previous offline reason when later disconnect has no explicit error', async () => {
const accountRepository = { const accountRepository = {
update: jest.fn(), update: jest.fn(),
@ -267,7 +451,7 @@ describe('QqbotAccountService', () => {
detectRuntimeOffline: jest.fn(), detectRuntimeOffline: jest.fn(),
}; };
const systemNoticePublisher = { const systemNoticePublisher = {
publishSystemNotice: jest.fn(), publishSystemNotice: jest.fn().mockResolvedValue(undefined),
}; };
const service = new QqbotAccountService( const service = new QqbotAccountService(
accountRepository as any, 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 账号状态变更为离线');
});
}); });

View File

@ -73,6 +73,8 @@ describe('QqbotNapcatLoginService', () => {
}; };
const accountService = { const accountService = {
findById: jest.fn().mockResolvedValue(account), findById: jest.fn().mockResolvedValue(account),
findByIdWithNapcatLoginSecret: jest.fn().mockResolvedValue(account),
getNapcatLoginPassword: jest.fn().mockReturnValue('qq-password'),
}; };
const containerService = { const containerService = {
prepareAccountContainer: jest.fn().mockResolvedValue(existingContainer), prepareAccountContainer: jest.fn().mockResolvedValue(existingContainer),
@ -89,6 +91,10 @@ describe('QqbotNapcatLoginService', () => {
await refreshService.startRefresh('account-1'); await refreshService.startRefresh('account-1');
expect(accountService.findByIdWithNapcatLoginSecret).toHaveBeenCalledWith(
'account-1',
);
expect(accountService.getNapcatLoginPassword).toHaveBeenCalledWith(account);
expect(containerService.prepareAccountContainer).toHaveBeenCalledWith( expect(containerService.prepareAccountContainer).toHaveBeenCalledWith(
account, account,
); );
@ -97,31 +103,23 @@ describe('QqbotNapcatLoginService', () => {
accountId: 'account-1', accountId: 'account-1',
expectedSelfId: '10001', expectedSelfId: '10001',
forceRelogin: true, forceRelogin: true,
loginPassword: 'qq-password',
mode: 'refresh', mode: 'refresh',
}, },
existingContainer, 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 = { const container = {
baseUrl: 'http://127.0.0.1:6103/', baseUrl: 'http://127.0.0.1:6103/',
id: 'container-current', id: 'container-current',
name: 'napcat-10001', name: 'napcat-10001',
}; };
let resolveReset!: () => void;
const containerService = {
resetRuntimeLoginState: jest.fn(
() =>
new Promise<boolean>((resolve) => {
resolveReset = () => resolve(true);
}),
),
};
const refreshService = new QqbotNapcatLoginService( const refreshService = new QqbotNapcatLoginService(
{ get: jest.fn() } as unknown as ConfigService, { get: jest.fn() } as unknown as ConfigService,
{} as QqbotAccountService, {} as QqbotAccountService,
containerService as unknown as QqbotNapcatContainerService, {} as QqbotNapcatContainerService,
new ToolsService(), new ToolsService(),
); );
jest jest
@ -130,9 +128,9 @@ describe('QqbotNapcatLoginService', () => {
jest jest
.spyOn(refreshService as any, 'cleanupSessions') .spyOn(refreshService as any, 'cleanupSessions')
.mockResolvedValue(undefined); .mockResolvedValue(undefined);
jest const prepareReloginQrcode = jest
.spyOn(refreshService as any, 'refreshOrGetQrcode') .spyOn(refreshService as any, 'prepareReloginQrcode')
.mockResolvedValue('fresh-qrcode'); .mockResolvedValue(undefined);
const result = await (refreshService as any).startScan( const result = await (refreshService as any).startScan(
{ {
@ -146,14 +144,692 @@ describe('QqbotNapcatLoginService', () => {
expect(result.status).toBe('pending'); expect(result.status).toBe('pending');
expect(result.qrcode).toBeUndefined(); expect(result.qrcode).toBeUndefined();
expect(result.errorMessage).toBe( expect(result.errorMessage).toBe('NapCat 正在尝试快速登录,请稍后');
'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( expect(containerService.resetRuntimeLoginState).toHaveBeenCalledWith(
container, container,
expect.any(Function), 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 () => { it('does not complete refresh login from stale status while relogin is preparing', async () => {
@ -161,7 +837,7 @@ describe('QqbotNapcatLoginService', () => {
containerId: 'container-preparing', containerId: 'container-preparing',
containerName: 'napcat-preparing', containerName: 'napcat-preparing',
createdAt: Date.now(), createdAt: Date.now(),
errorMessage: 'NapCat 正在重置登录态并生成二维码,请稍后', errorMessage: 'NapCat 正在尝试快速登录,请稍后',
expiresAt: Date.now() + 60_000, expiresAt: Date.now() + 60_000,
id: 'session-preparing', id: 'session-preparing',
mode: 'refresh', mode: 'refresh',
@ -174,9 +850,7 @@ describe('QqbotNapcatLoginService', () => {
const result = await service.status('session-preparing'); const result = await service.status('session-preparing');
expect(result.status).toBe('pending'); expect(result.status).toBe('pending');
expect(result.errorMessage).toBe( expect(result.errorMessage).toBe('NapCat 正在尝试快速登录,请稍后');
'NapCat 正在重置登录态并生成二维码,请稍后',
);
expect(getLoginStatus).not.toHaveBeenCalled(); expect(getLoginStatus).not.toHaveBeenCalled();
}); });

View File

@ -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( const service = new QqbotNapcatContainerService(
{ get: jest.fn().mockReturnValue('') } as any, { get: jest.fn().mockReturnValue('') } as any,
{} as any, {} as any,
@ -300,13 +300,19 @@ describe('QqbotNapcatContainerService', () => {
const withAccount = service.buildRemoteCreateScript({ const withAccount = service.buildRemoteCreateScript({
...baseInput, ...baseInput,
account: '2354598417', account: '2354598417',
loginPassword: 'qq-password',
}); });
expect(withAccount).toContain("ACCOUNT='2354598417'"); expect(withAccount).toContain("ACCOUNT='2354598417'");
expect(withAccount).toContain('-e ACCOUNT="$ACCOUNT"'); 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); const withoutAccount = service.buildRemoteCreateScript(baseInput);
expect(withoutAccount).not.toContain('-e ACCOUNT="$ACCOUNT"'); expect(withoutAccount).not.toContain('-e ACCOUNT="$ACCOUNT"');
expect(withoutAccount).not.toMatch(/^ACCOUNT=/m); expect(withoutAccount).not.toMatch(/^ACCOUNT=/m);
expect(withoutAccount).not.toContain('NAPCAT_QUICK_PASSWORD');
}); });
it('skips docker pull when recreating in place for quick login', () => { it('skips docker pull when recreating in place for quick login', () => {
@ -369,12 +375,332 @@ describe('QqbotNapcatContainerService', () => {
'2354598417', '2354598417',
); );
expect(recreated).toBe(false); expect(recreated).toEqual({ changed: false, ok: true });
// 仅做了 inspect 检查没有触发任何重建rm/run // 仅做了 inspect 检查没有触发任何重建rm/run
expect(service.runProcess).toHaveBeenCalledTimes(1); 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(); 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 () => { it('requires an explicit NapCat image when creating a managed container', async () => {
const containerRepository = { const containerRepository = {
find: jest.fn().mockResolvedValue([]), find: jest.fn().mockResolvedValue([]),