fix: 完善 NapCat 密码自动登录清理闭环
This commit is contained in:
parent
da5e5f27e2
commit
e951f5068a
@ -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
6
API.md
@ -59,7 +59,7 @@ Admin、Component、Dict、MinIO、Blog 管理、WordPress 管理和 QQBot 管
|
|||||||
| Admin | `ADMIN_TOKEN_SECRET`、`ADMIN_COOKIE_SECURE`、`SNOWFLAKE_WORKER_ID`、`SNOWFLAKE_DATACENTER_ID` |
|
| 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` 默认镜像。
|
||||||
|
|
||||||
|
|||||||
@ -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 回归。
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 (
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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" \\
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
79
test/qqbot/account/qqbot-account.controller.spec.ts
Normal file
79
test/qqbot/account/qqbot-account.controller.spec.ts
Normal 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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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 账号状态变更为离线');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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([]),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user