fix: 修复NapCat密码登录状态拆分
This commit is contained in:
parent
e951f5068a
commit
185457362c
4
API.md
4
API.md
@ -314,9 +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` | 解绑账号和自动回复规则 |
|
||||||
|
|
||||||
账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。
|
账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。账号列表里的 `connectStatus` 只表示 OneBot 反向 WS;`napcat.oneBotOnline`、`napcat.containerOnline`、`napcat.webuiOnline`、`napcat.qqLoginStatus`、`napcat.qqLoginMessage` 分别表示 OneBot、容器、WebUI 和 QQ 登录态,`webuiOnline=null` 表示本次使用缓存且未重新探测 WebUI;`qqLoginMessage` 只承载真实 QQ 登录态消息,WebUI 配置缺失或请求异常只放在 `lastError`。
|
||||||
|
|
||||||
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会短暂注入 `NAPCAT_QUICK_PASSWORD` 继续尝试密码登录,密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、触发强制扫码验证、离线、账号不匹配或缺少 QQ 号时,再进入重置登录态和二维码兜底流程。看门狗自动登录使用同样的 quick -> password 顺序,但不会自动进入扫码阶段。
|
扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、触发强制扫码验证、离线、账号不匹配或缺少 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` 默认镜像。
|
||||||
|
|
||||||
|
|||||||
@ -135,10 +135,10 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts
|
|||||||
- QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。
|
- QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。
|
||||||
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
|
- QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。
|
||||||
- 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 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,日志/WebUI 检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误;`qqLoginMessage` 只展示 QQ 登录态消息,WebUI 配置或请求错误留在 `lastError`。
|
||||||
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
|
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
|
||||||
- NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
- NapCat 账号新增/编辑支持可选 QQ 登录密码:Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
|
||||||
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 的历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败或触发强制扫码验证时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`。
|
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 的历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;密码登录成功后会移除运行态 `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` 站内信告警;看门狗不自动进入扫码阶段。
|
- 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 测试。
|
||||||
|
|||||||
@ -27,6 +27,7 @@ import type {
|
|||||||
QqbotAccountAbilityType,
|
QqbotAccountAbilityType,
|
||||||
QqbotAccountListItem,
|
QqbotAccountListItem,
|
||||||
QqbotConnectionRole,
|
QqbotConnectionRole,
|
||||||
|
QqbotNapcatRuntimeStatusSnapshot,
|
||||||
} from '../qqbot.types';
|
} from '../qqbot.types';
|
||||||
|
|
||||||
const NAPCAT_RUNTIME_CHECK_TTL_MS = 30_000;
|
const NAPCAT_RUNTIME_CHECK_TTL_MS = 30_000;
|
||||||
@ -434,8 +435,10 @@ export class QqbotAccountService {
|
|||||||
);
|
);
|
||||||
const containerMap = new Map<string, QqbotNapcatContainer>();
|
const containerMap = new Map<string, QqbotNapcatContainer>();
|
||||||
if (containerIds.length > 0) {
|
if (containerIds.length > 0) {
|
||||||
const containers = await this.napcatContainerRepository
|
const containerBuilder = this.napcatContainerRepository
|
||||||
.createQueryBuilder('container')
|
.createQueryBuilder('container');
|
||||||
|
containerBuilder.addSelect?.('container.webuiToken');
|
||||||
|
const containers = await containerBuilder
|
||||||
.where('container.id IN (:...containerIds)', { containerIds })
|
.where('container.id IN (:...containerIds)', { containerIds })
|
||||||
.andWhere('container.isDeleted = :isDeleted', { isDeleted: false })
|
.andWhere('container.isDeleted = :isDeleted', { isDeleted: false })
|
||||||
.getMany();
|
.getMany();
|
||||||
@ -452,17 +455,28 @@ export class QqbotAccountService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const container = containerMap.get(binding.containerId);
|
const container = containerMap.get(binding.containerId);
|
||||||
await this.syncNapcatOfflineState(account, container, options);
|
const runtimeStatus = await this.syncNapcatRuntimeState(
|
||||||
|
account,
|
||||||
|
container,
|
||||||
|
options,
|
||||||
|
);
|
||||||
return Object.assign(account, {
|
return Object.assign(account, {
|
||||||
napcat: {
|
napcat: {
|
||||||
bindStatus: binding.bindStatus,
|
bindStatus: binding.bindStatus,
|
||||||
containerId: binding.containerId,
|
containerId: binding.containerId,
|
||||||
containerName: container?.name,
|
containerName: container?.name,
|
||||||
|
containerOnline:
|
||||||
|
runtimeStatus?.containerOnline ??
|
||||||
|
(container?.status === 'running' || false),
|
||||||
containerStatus: container?.status,
|
containerStatus: container?.status,
|
||||||
lastCheckedAt: container?.lastCheckedAt,
|
lastCheckedAt: runtimeStatus?.checkedAt || container?.lastCheckedAt,
|
||||||
lastError: container?.lastError,
|
lastError: runtimeStatus?.lastError ?? container?.lastError,
|
||||||
lastLoginAt: binding.lastLoginAt,
|
lastLoginAt: binding.lastLoginAt,
|
||||||
lastStartedAt: container?.lastStartedAt,
|
lastStartedAt: container?.lastStartedAt,
|
||||||
|
oneBotOnline: account.connectStatus === 'online',
|
||||||
|
qqLoginMessage: runtimeStatus?.qqLoginMessage,
|
||||||
|
qqLoginStatus: runtimeStatus?.qqLoginStatus,
|
||||||
|
webuiOnline: runtimeStatus?.webuiOnline,
|
||||||
webuiPort: container?.webuiPort,
|
webuiPort: container?.webuiPort,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -470,44 +484,150 @@ export class QqbotAccountService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async syncNapcatOfflineState(
|
private async syncNapcatRuntimeState(
|
||||||
account: QqbotAccount,
|
account: QqbotAccount,
|
||||||
container?: QqbotNapcatContainer,
|
container?: QqbotNapcatContainer,
|
||||||
options: { autoLogin?: boolean } = {},
|
options: { autoLogin?: boolean } = {},
|
||||||
) {
|
) {
|
||||||
if (
|
const runtimeStatus = await this.getNapcatRuntimeStatus(account, container);
|
||||||
account.connectStatus !== 'online' ||
|
if (!container || container.status !== 'running') return runtimeStatus;
|
||||||
!container ||
|
if (account.connectStatus !== 'online') return runtimeStatus;
|
||||||
container.status !== 'running'
|
|
||||||
) {
|
if (this.isAccountStateNewerThanRuntimeCheck(account, container)) {
|
||||||
return;
|
return runtimeStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isAccountStateNewerThanRuntimeCheck(account, container)) return;
|
const runtimeOfflineReason =
|
||||||
|
this.getRuntimeStatusOfflineReason(runtimeStatus);
|
||||||
|
if (runtimeOfflineReason) {
|
||||||
|
if (options.autoLogin && (await this.tryAutoLogin(account, container))) {
|
||||||
|
return this.toCachedNapcatRuntimeStatus(account, container);
|
||||||
|
}
|
||||||
|
await this.applyNapcatOfflineState(
|
||||||
|
account,
|
||||||
|
container,
|
||||||
|
runtimeOfflineReason,
|
||||||
|
);
|
||||||
|
return runtimeStatus;
|
||||||
|
}
|
||||||
|
|
||||||
const cachedOfflineReason = this.getFreshCachedOfflineReason(container);
|
const cachedOfflineReason = this.getFreshCachedOfflineReason(container);
|
||||||
if (cachedOfflineReason) {
|
if (cachedOfflineReason) {
|
||||||
if (options.autoLogin && (await this.tryAutoLogin(account, container))) {
|
if (options.autoLogin && (await this.tryAutoLogin(account, container))) {
|
||||||
return;
|
return this.toCachedNapcatRuntimeStatus(account, container);
|
||||||
}
|
}
|
||||||
await this.applyNapcatOfflineState(
|
await this.applyNapcatOfflineState(
|
||||||
account,
|
account,
|
||||||
container,
|
container,
|
||||||
cachedOfflineReason,
|
cachedOfflineReason,
|
||||||
);
|
);
|
||||||
return;
|
return runtimeStatus;
|
||||||
|
}
|
||||||
|
if (this.isFreshRuntimeCheck(container.lastCheckedAt)) {
|
||||||
|
return runtimeStatus;
|
||||||
}
|
}
|
||||||
if (this.isFreshRuntimeCheck(container.lastCheckedAt)) return;
|
|
||||||
|
|
||||||
const offlineReason =
|
const offlineReason =
|
||||||
await this.napcatContainerService.detectRuntimeOffline(container);
|
await this.napcatContainerService.detectRuntimeOffline(container);
|
||||||
if (!offlineReason) return;
|
if (!offlineReason) return runtimeStatus;
|
||||||
|
|
||||||
if (options.autoLogin && (await this.tryAutoLogin(account, container))) {
|
if (options.autoLogin && (await this.tryAutoLogin(account, container))) {
|
||||||
return;
|
return this.toCachedNapcatRuntimeStatus(account, container);
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.applyNapcatOfflineState(account, container, offlineReason);
|
await this.applyNapcatOfflineState(account, container, offlineReason);
|
||||||
|
return {
|
||||||
|
...runtimeStatus,
|
||||||
|
checkedAt: new Date(),
|
||||||
|
lastError: offlineReason,
|
||||||
|
qqLoginMessage: offlineReason,
|
||||||
|
qqLoginStatus: 'offline',
|
||||||
|
} as QqbotNapcatRuntimeStatusSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getNapcatRuntimeStatus(
|
||||||
|
account: QqbotAccount,
|
||||||
|
container?: QqbotNapcatContainer,
|
||||||
|
): Promise<QqbotNapcatRuntimeStatusSnapshot | undefined> {
|
||||||
|
if (!container) return undefined;
|
||||||
|
const cached = this.toCachedNapcatRuntimeStatus(account, container);
|
||||||
|
if (container.status !== 'running') return cached;
|
||||||
|
if (this.isAccountStateNewerThanRuntimeCheck(account, container)) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
if (this.isFreshRuntimeCheck(container.lastCheckedAt)) return cached;
|
||||||
|
if (
|
||||||
|
typeof this.napcatContainerService.inspectRuntimeStatus !== 'function'
|
||||||
|
) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const inspected =
|
||||||
|
await this.napcatContainerService.inspectRuntimeStatus(container);
|
||||||
|
container.lastCheckedAt = inspected.checkedAt as any;
|
||||||
|
container.lastError = inspected.lastError || null;
|
||||||
|
return inspected;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toCachedNapcatRuntimeStatus(
|
||||||
|
account: QqbotAccount,
|
||||||
|
container: QqbotNapcatContainer,
|
||||||
|
): QqbotNapcatRuntimeStatusSnapshot {
|
||||||
|
const containerOnline = container.status === 'running';
|
||||||
|
const lastError = this.toolsService.toTrimmedString(container.lastError);
|
||||||
|
const offlineReason = this.toolsService.isNapcatOfflineLoginMessage(
|
||||||
|
lastError,
|
||||||
|
)
|
||||||
|
? lastError
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
checkedAt: container.lastCheckedAt || undefined,
|
||||||
|
containerOnline,
|
||||||
|
lastError: lastError || null,
|
||||||
|
qqLoginMessage: offlineReason,
|
||||||
|
qqLoginStatus: this.toCachedQqLoginStatus(
|
||||||
|
account,
|
||||||
|
containerOnline,
|
||||||
|
lastError,
|
||||||
|
),
|
||||||
|
webuiOnline: containerOnline ? null : false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toCachedQqLoginStatus(
|
||||||
|
account: QqbotAccount,
|
||||||
|
containerOnline: boolean,
|
||||||
|
lastError: string,
|
||||||
|
): QqbotNapcatRuntimeStatusSnapshot['qqLoginStatus'] {
|
||||||
|
if (!containerOnline) return 'offline';
|
||||||
|
if (
|
||||||
|
lastError.includes('二维码已过期') ||
|
||||||
|
lastError.includes('二维码过期')
|
||||||
|
) {
|
||||||
|
return 'qrcode_expired';
|
||||||
|
}
|
||||||
|
if (this.toolsService.isNapcatOfflineLoginMessage(lastError)) {
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
|
if (account.connectStatus === 'online') return 'online';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private getRuntimeStatusOfflineReason(
|
||||||
|
runtimeStatus?: QqbotNapcatRuntimeStatusSnapshot,
|
||||||
|
) {
|
||||||
|
if (!runtimeStatus) return null;
|
||||||
|
if (
|
||||||
|
runtimeStatus.qqLoginStatus !== 'offline' &&
|
||||||
|
runtimeStatus.qqLoginStatus !== 'qrcode_expired'
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
this.toolsService.toTrimmedString(runtimeStatus.qqLoginMessage) ||
|
||||||
|
this.toolsService.toTrimmedString(runtimeStatus.lastError) ||
|
||||||
|
'NapCat QQ 登录态不可用'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async tryAutoLogin(
|
private async tryAutoLogin(
|
||||||
|
|||||||
@ -153,11 +153,17 @@ export class QqbotNapcatLoginService {
|
|||||||
|
|
||||||
async status(sessionId: string) {
|
async status(sessionId: string) {
|
||||||
const session = this.getSession(sessionId);
|
const session = this.getSession(sessionId);
|
||||||
if (Date.now() > session.expiresAt) {
|
if (session.status !== 'pending') {
|
||||||
return this.expireSession(session);
|
return this.toResult(session);
|
||||||
}
|
}
|
||||||
if (session.preparingRelogin) {
|
if (session.preparingRelogin) {
|
||||||
return this.toResult(session);
|
return this.keepSessionPending(
|
||||||
|
session,
|
||||||
|
session.errorMessage || 'NapCat 正在准备登录,请稍后',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (Date.now() > session.expiresAt) {
|
||||||
|
return this.expireSession(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = await this.getSessionContainer(session);
|
const container = await this.getSessionContainer(session);
|
||||||
@ -377,6 +383,7 @@ export class QqbotNapcatLoginService {
|
|||||||
session.accountId = accountId;
|
session.accountId = accountId;
|
||||||
session.status = 'success';
|
session.status = 'success';
|
||||||
session.errorMessage = undefined;
|
session.errorMessage = undefined;
|
||||||
|
session.preparingRelogin = false;
|
||||||
this.sessions.set(session.id, session);
|
this.sessions.set(session.id, session);
|
||||||
const result = {
|
const result = {
|
||||||
...this.toResult(session),
|
...this.toResult(session),
|
||||||
@ -455,6 +462,10 @@ export class QqbotNapcatLoginService {
|
|||||||
status: QqbotLoginScanEvent['status'],
|
status: QqbotLoginScanEvent['status'],
|
||||||
message: string,
|
message: string,
|
||||||
) {
|
) {
|
||||||
|
if (session.status === 'pending') {
|
||||||
|
session.expiresAt = Date.now() + this.getSessionTtlMs();
|
||||||
|
this.sessions.set(session.id, session);
|
||||||
|
}
|
||||||
this.publishScanEvent(session, {
|
this.publishScanEvent(session, {
|
||||||
message,
|
message,
|
||||||
result: this.toResult(session),
|
result: this.toResult(session),
|
||||||
@ -529,6 +540,7 @@ export class QqbotNapcatLoginService {
|
|||||||
) {
|
) {
|
||||||
session.status = 'error';
|
session.status = 'error';
|
||||||
session.errorMessage = errorMessage;
|
session.errorMessage = errorMessage;
|
||||||
|
session.preparingRelogin = false;
|
||||||
this.publishScanResultEvent(session, 'login-error', 'error', errorMessage);
|
this.publishScanResultEvent(session, 'login-error', 'error', errorMessage);
|
||||||
this.sessions.delete(session.id);
|
this.sessions.delete(session.id);
|
||||||
await this.cleanupSessionContainer(session);
|
await this.cleanupSessionContainer(session);
|
||||||
@ -859,6 +871,7 @@ export class QqbotNapcatLoginService {
|
|||||||
} else {
|
} else {
|
||||||
session.status = 'error';
|
session.status = 'error';
|
||||||
session.errorMessage = message || 'NapCat 重置登录态失败';
|
session.errorMessage = message || 'NapCat 重置登录态失败';
|
||||||
|
session.preparingRelogin = false;
|
||||||
this.publishScanResultEvent(
|
this.publishScanResultEvent(
|
||||||
session,
|
session,
|
||||||
'relogin-error',
|
'relogin-error',
|
||||||
@ -998,8 +1011,7 @@ export class QqbotNapcatLoginService {
|
|||||||
'processing',
|
'processing',
|
||||||
'等待 NapCat 密码登录结果',
|
'等待 NapCat 密码登录结果',
|
||||||
);
|
);
|
||||||
await this.toolsService.sleep(this.getRestartDelayMs());
|
loginStatus = await this.waitForPasswordLoginStatus(container);
|
||||||
loginStatus = await this.getLoginStatus(container, true);
|
|
||||||
|
|
||||||
if (loginStatus.isLogin) {
|
if (loginStatus.isLogin) {
|
||||||
loginInfo = await this.getLoginInfo(container);
|
loginInfo = await this.getLoginInfo(container);
|
||||||
@ -1355,6 +1367,48 @@ export class QqbotNapcatLoginService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async waitForPasswordLoginStatus(container: QqbotNapcatRuntime) {
|
||||||
|
let latestStatus: NapcatLoginStatus = { isLogin: false };
|
||||||
|
const attempts = this.getLoginPollAttempts(
|
||||||
|
this.getPasswordLoginWaitMs(),
|
||||||
|
this.getLoginPollIntervalMs(),
|
||||||
|
);
|
||||||
|
for (let index = 0; index < attempts; index += 1) {
|
||||||
|
if (index > 0) {
|
||||||
|
await this.toolsService.sleep(this.getLoginPollIntervalMs());
|
||||||
|
}
|
||||||
|
latestStatus = await this.getLoginStatus(container, true);
|
||||||
|
if (latestStatus.isLogin) return latestStatus;
|
||||||
|
}
|
||||||
|
return latestStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLoginPollAttempts(waitMs: number, intervalMs: number) {
|
||||||
|
const normalizedWaitMs = Number.isFinite(waitMs) && waitMs > 0 ? waitMs : 1;
|
||||||
|
const normalizedIntervalMs =
|
||||||
|
Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 1;
|
||||||
|
return Math.max(1, Math.ceil(normalizedWaitMs / normalizedIntervalMs));
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPasswordLoginWaitMs() {
|
||||||
|
return this.getPositiveConfigNumber(
|
||||||
|
'QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS',
|
||||||
|
120_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getLoginPollIntervalMs() {
|
||||||
|
return this.getPositiveConfigNumber(
|
||||||
|
'QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS',
|
||||||
|
3000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getPositiveConfigNumber(key: string, fallback: number) {
|
||||||
|
const value = Number(this.configService.get(key) || fallback);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
private async executeNapcatRequest<T>(
|
private async executeNapcatRequest<T>(
|
||||||
retry: boolean,
|
retry: boolean,
|
||||||
action: () => Promise<T>,
|
action: () => Promise<T>,
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
|
import * as http from 'http';
|
||||||
|
import * as https from 'https';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { randomBytes, randomUUID } from 'crypto';
|
import { createHash, randomBytes, randomUUID } from 'crypto';
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
@ -8,7 +10,14 @@ import { throwVbenError, ToolsService } from '@/common';
|
|||||||
import { QqbotAccount } from '../account/qqbot-account.entity';
|
import { QqbotAccount } from '../account/qqbot-account.entity';
|
||||||
import { QqbotAccountNapcat } from './qqbot-account-napcat.entity';
|
import { QqbotAccountNapcat } from './qqbot-account-napcat.entity';
|
||||||
import { QqbotNapcatContainer } from './qqbot-napcat-container.entity';
|
import { QqbotNapcatContainer } from './qqbot-napcat-container.entity';
|
||||||
import type { QqbotNapcatRuntime } from '../qqbot.types';
|
import type {
|
||||||
|
NapcatApiResponse,
|
||||||
|
NapcatCredential,
|
||||||
|
NapcatLoginStatus,
|
||||||
|
QqbotNapcatRuntime,
|
||||||
|
QqbotNapcatRuntimeLoginStatus,
|
||||||
|
QqbotNapcatRuntimeStatusSnapshot,
|
||||||
|
} from '../qqbot.types';
|
||||||
|
|
||||||
type NapcatLoginLogState = 'offline' | 'online' | 'unknown';
|
type NapcatLoginLogState = 'offline' | 'online' | 'unknown';
|
||||||
|
|
||||||
@ -457,6 +466,92 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async inspectRuntimeStatus(
|
||||||
|
container: QqbotNapcatContainer,
|
||||||
|
): Promise<QqbotNapcatRuntimeStatusSnapshot> {
|
||||||
|
const checkedAt = new Date();
|
||||||
|
const containerOnline = container.status === 'running';
|
||||||
|
if (!containerOnline) {
|
||||||
|
return {
|
||||||
|
checkedAt,
|
||||||
|
containerOnline,
|
||||||
|
lastError: container.lastError,
|
||||||
|
qqLoginMessage: null,
|
||||||
|
qqLoginStatus: 'offline',
|
||||||
|
webuiOnline: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!container.baseUrl || !container.webuiToken) {
|
||||||
|
const message = 'NapCat WebUI 配置缺失';
|
||||||
|
await this.containerRepository.update(
|
||||||
|
{ id: container.id },
|
||||||
|
{
|
||||||
|
lastCheckedAt: checkedAt,
|
||||||
|
lastError: message,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
checkedAt,
|
||||||
|
containerOnline,
|
||||||
|
lastError: message,
|
||||||
|
qqLoginMessage: null,
|
||||||
|
qqLoginStatus: 'unknown',
|
||||||
|
webuiOnline: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const runtime = this.toRuntime(container);
|
||||||
|
const credential = await this.getNapcatCredential(runtime);
|
||||||
|
const status = await this.requestNapcat<NapcatLoginStatus>(
|
||||||
|
runtime,
|
||||||
|
'/api/QQLogin/CheckLoginStatus',
|
||||||
|
{},
|
||||||
|
credential,
|
||||||
|
);
|
||||||
|
const snapshot = this.toRuntimeStatusSnapshot(
|
||||||
|
status,
|
||||||
|
containerOnline,
|
||||||
|
checkedAt,
|
||||||
|
);
|
||||||
|
await this.containerRepository.update(
|
||||||
|
{ id: container.id },
|
||||||
|
{
|
||||||
|
lastCheckedAt: checkedAt,
|
||||||
|
lastError:
|
||||||
|
snapshot.qqLoginStatus === 'online'
|
||||||
|
? null
|
||||||
|
: this.toolsService.toColumnText(
|
||||||
|
snapshot.qqLoginMessage || snapshot.lastError || '',
|
||||||
|
500,
|
||||||
|
) || null,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return snapshot;
|
||||||
|
} catch (err) {
|
||||||
|
const message = this.toolsService.toColumnText(
|
||||||
|
this.toolsService.getErrorMessage(err),
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
await this.containerRepository.update(
|
||||||
|
{ id: container.id },
|
||||||
|
{
|
||||||
|
lastCheckedAt: checkedAt,
|
||||||
|
lastError: message,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
checkedAt,
|
||||||
|
containerOnline,
|
||||||
|
lastError: message,
|
||||||
|
qqLoginMessage: null,
|
||||||
|
qqLoginStatus: 'unknown',
|
||||||
|
webuiOnline: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async removeContainer(containerId: string) {
|
private async removeContainer(containerId: string) {
|
||||||
const container = await this.containerRepository.findOne({
|
const container = await this.containerRepository.findOne({
|
||||||
where: {
|
where: {
|
||||||
@ -979,6 +1074,116 @@ ${accountRunFlag}${passwordRunFlag} -p "$PORT:6099" \\
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async getNapcatCredential(runtime: QqbotNapcatRuntime) {
|
||||||
|
const token = runtime.webuiToken || '';
|
||||||
|
const hash = createHash('sha256').update(`${token}.napcat`).digest('hex');
|
||||||
|
const data = await this.requestNapcat<NapcatCredential>(
|
||||||
|
runtime,
|
||||||
|
'/api/auth/login',
|
||||||
|
{ hash },
|
||||||
|
);
|
||||||
|
if (!data.Credential) {
|
||||||
|
throwVbenError('NapCat WebUI 登录失败');
|
||||||
|
}
|
||||||
|
return data.Credential;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toRuntimeStatusSnapshot(
|
||||||
|
status: NapcatLoginStatus,
|
||||||
|
containerOnline: boolean,
|
||||||
|
checkedAt: Date,
|
||||||
|
): QqbotNapcatRuntimeStatusSnapshot {
|
||||||
|
const message = this.toolsService.toTrimmedString(status.loginError);
|
||||||
|
const qqLoginStatus = this.toQqLoginStatus(status, message);
|
||||||
|
return {
|
||||||
|
checkedAt,
|
||||||
|
containerOnline,
|
||||||
|
lastError: qqLoginStatus === 'online' ? null : message || null,
|
||||||
|
qqLoginMessage: qqLoginStatus === 'online' ? null : message || null,
|
||||||
|
qqLoginStatus,
|
||||||
|
webuiOnline: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private toQqLoginStatus(
|
||||||
|
status: NapcatLoginStatus,
|
||||||
|
message: string,
|
||||||
|
): QqbotNapcatRuntimeLoginStatus {
|
||||||
|
if (status.isLogin) return 'online';
|
||||||
|
if (
|
||||||
|
this.toolsService.isNapcatExpiredQrcodeStatus(status) ||
|
||||||
|
message.includes('二维码已过期')
|
||||||
|
) {
|
||||||
|
return 'qrcode_expired';
|
||||||
|
}
|
||||||
|
if (status.qrcodeurl) return 'qrcode_pending';
|
||||||
|
if (
|
||||||
|
status.isOffline ||
|
||||||
|
this.toolsService.isNapcatOfflineLoginMessage(message)
|
||||||
|
) {
|
||||||
|
return 'offline';
|
||||||
|
}
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
private requestNapcat<T>(
|
||||||
|
runtime: QqbotNapcatRuntime,
|
||||||
|
path: string,
|
||||||
|
body: Record<string, any> = {},
|
||||||
|
credential?: string,
|
||||||
|
): Promise<T> {
|
||||||
|
const target = new URL(path, runtime.baseUrl);
|
||||||
|
const payload = JSON.stringify(body);
|
||||||
|
const client = target.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const req = client.request(
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
...(credential
|
||||||
|
? {
|
||||||
|
Authorization: `Bearer ${credential}`,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
'Content-Length': Buffer.byteLength(payload),
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
hostname: target.hostname,
|
||||||
|
method: 'POST',
|
||||||
|
path: `${target.pathname}${target.search}`,
|
||||||
|
port: target.port,
|
||||||
|
protocol: target.protocol,
|
||||||
|
timeout: this.getRuntimeCheckTimeoutMs(),
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
|
||||||
|
res.on('end', () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString('utf8');
|
||||||
|
let result: NapcatApiResponse<T>;
|
||||||
|
try {
|
||||||
|
result = raw ? JSON.parse(raw) : ({ code: -1 } as any);
|
||||||
|
} catch {
|
||||||
|
reject(new Error('NapCat 返回非 JSON 响应'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (result.code !== 0) {
|
||||||
|
reject(new Error(result.message || 'NapCat 请求失败'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
resolve(result.data as T);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
req.on('error', reject);
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy(new Error('NapCat 请求超时'));
|
||||||
|
});
|
||||||
|
req.write(payload);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private normalizeBaseUrl(value: string) {
|
private normalizeBaseUrl(value: string) {
|
||||||
const baseUrl = `${value || ''}`.trim();
|
const baseUrl = `${value || ''}`.trim();
|
||||||
if (!baseUrl) {
|
if (!baseUrl) {
|
||||||
|
|||||||
@ -63,17 +63,38 @@ export type QqbotNapcatContainerStatus =
|
|||||||
|
|
||||||
export type QqbotAccountNapcatBindStatus = 'bound' | 'disabled' | 'pending';
|
export type QqbotAccountNapcatBindStatus = 'bound' | 'disabled' | 'pending';
|
||||||
|
|
||||||
|
export type QqbotNapcatRuntimeLoginStatus =
|
||||||
|
| 'offline'
|
||||||
|
| 'online'
|
||||||
|
| 'qrcode_expired'
|
||||||
|
| 'qrcode_pending'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export type QqbotNapcatRuntimeStatusSnapshot = {
|
||||||
|
checkedAt?: Date;
|
||||||
|
containerOnline: boolean;
|
||||||
|
lastError?: null | string;
|
||||||
|
qqLoginMessage?: null | string;
|
||||||
|
qqLoginStatus: QqbotNapcatRuntimeLoginStatus;
|
||||||
|
webuiOnline?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type QqbotAccountAbilityType = 'command' | 'event_plugin' | 'rule';
|
export type QqbotAccountAbilityType = 'command' | 'event_plugin' | 'rule';
|
||||||
|
|
||||||
export type QqbotAccountNapcatRuntimeInfo = {
|
export type QqbotAccountNapcatRuntimeInfo = {
|
||||||
bindStatus?: QqbotAccountNapcatBindStatus;
|
bindStatus?: QqbotAccountNapcatBindStatus;
|
||||||
containerId?: string;
|
containerId?: string;
|
||||||
containerName?: string;
|
containerName?: string;
|
||||||
|
containerOnline?: boolean;
|
||||||
containerStatus?: QqbotNapcatContainerStatus;
|
containerStatus?: QqbotNapcatContainerStatus;
|
||||||
lastCheckedAt?: Date | null;
|
lastCheckedAt?: Date | null;
|
||||||
lastError?: null | string;
|
lastError?: null | string;
|
||||||
lastLoginAt?: Date | null;
|
lastLoginAt?: Date | null;
|
||||||
lastStartedAt?: Date | null;
|
lastStartedAt?: Date | null;
|
||||||
|
oneBotOnline?: boolean;
|
||||||
|
qqLoginMessage?: null | string;
|
||||||
|
qqLoginStatus?: QqbotNapcatRuntimeLoginStatus;
|
||||||
|
webuiOnline?: boolean | null;
|
||||||
webuiPort?: null | number;
|
webuiPort?: null | number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -407,6 +407,174 @@ describe('QqbotAccountService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('separates OneBot, container, WebUI and QQ login status for offline accounts', async () => {
|
||||||
|
const account = {
|
||||||
|
connectStatus: 'offline',
|
||||||
|
enabled: true,
|
||||||
|
id: 'account-1',
|
||||||
|
isDeleted: false,
|
||||||
|
lastError: null,
|
||||||
|
name: '主账号',
|
||||||
|
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,
|
||||||
|
lastCheckedAt: new Date('2026-06-11T02:00:00.000Z'),
|
||||||
|
lastError: null,
|
||||||
|
name: 'kt-qqbot-napcat-1914728559',
|
||||||
|
status: 'running',
|
||||||
|
webuiPort: 6101,
|
||||||
|
};
|
||||||
|
const accountRepository = {
|
||||||
|
createQueryBuilder: jest.fn(() => ({
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
skip: jest.fn().mockReturnThis(),
|
||||||
|
take: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
})),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
const napcatContainerService = {
|
||||||
|
inspectRuntimeStatus: jest.fn().mockResolvedValue({
|
||||||
|
checkedAt: new Date('2026-06-12T12:00:00.000Z'),
|
||||||
|
containerOnline: true,
|
||||||
|
lastError: '二维码已过期,请刷新',
|
||||||
|
qqLoginMessage: '二维码已过期,请刷新',
|
||||||
|
qqLoginStatus: 'qrcode_expired',
|
||||||
|
webuiOnline: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
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(() => ({
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn().mockResolvedValue([container]),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
})),
|
||||||
|
} as any,
|
||||||
|
napcatContainerService as any,
|
||||||
|
new ToolsService(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const page = await service.page({});
|
||||||
|
|
||||||
|
expect(napcatContainerService.inspectRuntimeStatus).toHaveBeenCalledWith(
|
||||||
|
container,
|
||||||
|
);
|
||||||
|
expect(accountRepository.update).not.toHaveBeenCalled();
|
||||||
|
expect(page.list[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
connectStatus: 'offline',
|
||||||
|
napcat: expect.objectContaining({
|
||||||
|
containerOnline: true,
|
||||||
|
oneBotOnline: false,
|
||||||
|
qqLoginMessage: '二维码已过期,请刷新',
|
||||||
|
qqLoginStatus: 'qrcode_expired',
|
||||||
|
webuiOnline: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not expose WebUI errors as QQ login messages in cached runtime status', async () => {
|
||||||
|
const checkedAt = new Date();
|
||||||
|
const account = {
|
||||||
|
connectStatus: 'offline',
|
||||||
|
enabled: true,
|
||||||
|
id: 'account-1',
|
||||||
|
isDeleted: false,
|
||||||
|
lastError: null,
|
||||||
|
name: '主账号',
|
||||||
|
selfId: '1914728559',
|
||||||
|
};
|
||||||
|
const binding = {
|
||||||
|
accountId: 'account-1',
|
||||||
|
bindStatus: 'bound',
|
||||||
|
containerId: 'container-1',
|
||||||
|
isDeleted: false,
|
||||||
|
isPrimary: true,
|
||||||
|
lastLoginAt: checkedAt,
|
||||||
|
};
|
||||||
|
const container = {
|
||||||
|
id: 'container-1',
|
||||||
|
isDeleted: false,
|
||||||
|
lastCheckedAt: checkedAt,
|
||||||
|
lastError: 'NapCat WebUI 配置缺失',
|
||||||
|
name: 'kt-qqbot-napcat-1914728559',
|
||||||
|
status: 'running',
|
||||||
|
webuiPort: 6101,
|
||||||
|
};
|
||||||
|
const accountRepository = {
|
||||||
|
createQueryBuilder: jest.fn(() => ({
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getManyAndCount: jest.fn().mockResolvedValue([[account], 1]),
|
||||||
|
orderBy: jest.fn().mockReturnThis(),
|
||||||
|
skip: jest.fn().mockReturnThis(),
|
||||||
|
take: jest.fn().mockReturnThis(),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
})),
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
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(() => ({
|
||||||
|
addSelect: jest.fn().mockReturnThis(),
|
||||||
|
andWhere: jest.fn().mockReturnThis(),
|
||||||
|
getMany: jest.fn().mockResolvedValue([container]),
|
||||||
|
where: jest.fn().mockReturnThis(),
|
||||||
|
})),
|
||||||
|
} as any,
|
||||||
|
{} as any,
|
||||||
|
new ToolsService(),
|
||||||
|
);
|
||||||
|
|
||||||
|
const page = await service.page({});
|
||||||
|
|
||||||
|
expect(page.list[0].napcat).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
containerOnline: true,
|
||||||
|
lastError: 'NapCat WebUI 配置缺失',
|
||||||
|
qqLoginMessage: null,
|
||||||
|
qqLoginStatus: 'unknown',
|
||||||
|
webuiOnline: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('ignores cached NapCat offline reason after the account reconnects', async () => {
|
it('ignores cached NapCat offline reason after the account reconnects', async () => {
|
||||||
const checkedAt = new Date('2026-06-11T02:00:00.000Z');
|
const checkedAt = new Date('2026-06-11T02:00:00.000Z');
|
||||||
const account = {
|
const account = {
|
||||||
|
|||||||
@ -156,6 +156,44 @@ describe('QqbotNapcatLoginService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps refresh login session alive while relogin is still preparing', async () => {
|
||||||
|
const containerService = {
|
||||||
|
removeUnboundContainer: jest.fn().mockResolvedValue(false),
|
||||||
|
};
|
||||||
|
const refreshService = new QqbotNapcatLoginService(
|
||||||
|
{
|
||||||
|
get: jest.fn((key: string) =>
|
||||||
|
key === 'NAPCAT_LOGIN_QR_EXPIRE_MS' ? '120000' : '',
|
||||||
|
),
|
||||||
|
} as unknown as ConfigService,
|
||||||
|
{} as QqbotAccountService,
|
||||||
|
containerService as unknown as QqbotNapcatContainerService,
|
||||||
|
new ToolsService(),
|
||||||
|
);
|
||||||
|
const session = (refreshService as any).createSession({
|
||||||
|
accountId: 'account-1',
|
||||||
|
container: {
|
||||||
|
id: 'container-ttl',
|
||||||
|
name: 'napcat-10001',
|
||||||
|
},
|
||||||
|
expectedSelfId: '10001',
|
||||||
|
mode: 'refresh',
|
||||||
|
preparingRelogin: true,
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
session.errorMessage = 'NapCat 正在尝试密码登录,请稍后';
|
||||||
|
session.expiresAt = Date.now() - 1000;
|
||||||
|
(refreshService as any).sessions.set(session.id, session);
|
||||||
|
|
||||||
|
const result = await refreshService.status(session.id);
|
||||||
|
|
||||||
|
expect(result.status).toBe('pending');
|
||||||
|
expect(result.errorMessage).toBe('NapCat 正在尝试密码登录,请稍后');
|
||||||
|
expect(result.expiresAt).toBeGreaterThan(Date.now());
|
||||||
|
expect(containerService.removeUnboundContainer).not.toHaveBeenCalled();
|
||||||
|
expect((refreshService as any).sessions.has(session.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('uses NapCat -q quick login before generating qrcode for refresh login', async () => {
|
it('uses NapCat -q quick login before generating qrcode for refresh login', async () => {
|
||||||
const container = {
|
const container = {
|
||||||
baseUrl: 'http://127.0.0.1:6103/',
|
baseUrl: 'http://127.0.0.1:6103/',
|
||||||
@ -479,6 +517,91 @@ describe('QqbotNapcatLoginService', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('waits for password login to finish instead of falling back on the first pending status', async () => {
|
||||||
|
const container = {
|
||||||
|
baseUrl: 'http://127.0.0.1:6103/',
|
||||||
|
id: 'container-password-wait',
|
||||||
|
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((key: string) => {
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS: '1',
|
||||||
|
QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS: '3',
|
||||||
|
QQBOT_NAPCAT_QUICK_LOGIN_WAIT_MS: '1',
|
||||||
|
};
|
||||||
|
return values[key] || '';
|
||||||
|
}),
|
||||||
|
} 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: false,
|
||||||
|
loginError: '密码登录处理中',
|
||||||
|
})
|
||||||
|
.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(refreshQrcode).not.toHaveBeenCalled();
|
||||||
|
expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({
|
||||||
|
accountId: 'account-1',
|
||||||
|
name: 'Kwi',
|
||||||
|
selfId: '10001',
|
||||||
|
});
|
||||||
|
expect(session.status).toBe('success');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses password before qrcode without historical NapCat session', async () => {
|
it('uses password before qrcode without historical NapCat session', async () => {
|
||||||
const container = {
|
const container = {
|
||||||
baseUrl: 'http://127.0.0.1:6103/',
|
baseUrl: 'http://127.0.0.1:6103/',
|
||||||
@ -854,6 +977,51 @@ describe('QqbotNapcatLoginService', () => {
|
|||||||
expect(getLoginStatus).not.toHaveBeenCalled();
|
expect(getLoginStatus).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps successful refresh login terminal when status is polled after background relogin', async () => {
|
||||||
|
(service as any).sessions.set('session-success', {
|
||||||
|
accountId: 'account-1',
|
||||||
|
containerId: 'container-success',
|
||||||
|
containerName: 'napcat-success',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
id: 'session-success',
|
||||||
|
mode: 'refresh',
|
||||||
|
preparingRelogin: true,
|
||||||
|
status: 'success',
|
||||||
|
webuiPort: 6108,
|
||||||
|
});
|
||||||
|
const getLoginStatus = jest.spyOn(service as any, 'getLoginStatus');
|
||||||
|
|
||||||
|
const result = await service.status('session-success');
|
||||||
|
|
||||||
|
expect(result.status).toBe('success');
|
||||||
|
expect(result.errorMessage).toBeUndefined();
|
||||||
|
expect(getLoginStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps failed refresh login terminal when status is polled after background relogin', async () => {
|
||||||
|
(service as any).sessions.set('session-error', {
|
||||||
|
accountId: 'account-1',
|
||||||
|
containerId: 'container-error',
|
||||||
|
containerName: 'napcat-error',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
errorMessage: 'NapCat 快速登录失败',
|
||||||
|
expiresAt: Date.now() + 60_000,
|
||||||
|
id: 'session-error',
|
||||||
|
mode: 'refresh',
|
||||||
|
preparingRelogin: true,
|
||||||
|
status: 'error',
|
||||||
|
webuiPort: 6109,
|
||||||
|
});
|
||||||
|
const getLoginStatus = jest.spyOn(service as any, 'getLoginStatus');
|
||||||
|
|
||||||
|
const result = await service.status('session-error');
|
||||||
|
|
||||||
|
expect(result.status).toBe('error');
|
||||||
|
expect(result.errorMessage).toBe('NapCat 快速登录失败');
|
||||||
|
expect(getLoginStatus).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('replays scan progress events to late SSE subscribers', () => {
|
it('replays scan progress events to late SSE subscribers', () => {
|
||||||
const session = {
|
const session = {
|
||||||
containerId: 'container-events',
|
containerId: 'container-events',
|
||||||
|
|||||||
@ -0,0 +1,79 @@
|
|||||||
|
jest.mock('@/common', () => {
|
||||||
|
const actualCommon = jest.requireActual('@/common');
|
||||||
|
return {
|
||||||
|
...actualCommon,
|
||||||
|
throwVbenError: (message: string) => {
|
||||||
|
throw new Error(message);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { ToolsService } from '@/common';
|
||||||
|
import { QqbotNapcatContainerService } from '@/qqbot/napcat/qqbot-napcat-container.service';
|
||||||
|
|
||||||
|
describe('QqbotNapcatContainerService runtime status', () => {
|
||||||
|
const createService = (repository: {
|
||||||
|
update: jest.Mock;
|
||||||
|
}) =>
|
||||||
|
new QqbotNapcatContainerService(
|
||||||
|
{ get: jest.fn() } as any,
|
||||||
|
repository as any,
|
||||||
|
{} as any,
|
||||||
|
new ToolsService(),
|
||||||
|
);
|
||||||
|
|
||||||
|
it('keeps WebUI configuration errors out of the QQ login message', async () => {
|
||||||
|
const repository = {
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
const service = createService(repository);
|
||||||
|
|
||||||
|
const snapshot = await service.inspectRuntimeStatus({
|
||||||
|
id: 'container-1',
|
||||||
|
lastError: null,
|
||||||
|
status: 'running',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(snapshot).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
lastError: 'NapCat WebUI 配置缺失',
|
||||||
|
qqLoginMessage: null,
|
||||||
|
qqLoginStatus: 'unknown',
|
||||||
|
webuiOnline: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(repository.update).toHaveBeenCalledWith(
|
||||||
|
{ id: 'container-1' },
|
||||||
|
expect.objectContaining({
|
||||||
|
lastError: 'NapCat WebUI 配置缺失',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps WebUI request errors out of the QQ login message', async () => {
|
||||||
|
const repository = {
|
||||||
|
update: jest.fn(),
|
||||||
|
};
|
||||||
|
const service = createService(repository);
|
||||||
|
jest
|
||||||
|
.spyOn(service as any, 'getNapcatCredential')
|
||||||
|
.mockRejectedValue(new Error('NapCat WebUI 请求超时'));
|
||||||
|
|
||||||
|
const snapshot = await service.inspectRuntimeStatus({
|
||||||
|
baseUrl: 'http://127.0.0.1:6100/',
|
||||||
|
id: 'container-1',
|
||||||
|
lastError: null,
|
||||||
|
status: 'running',
|
||||||
|
webuiToken: 'token',
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
expect(snapshot).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
lastError: 'NapCat WebUI 请求超时',
|
||||||
|
qqLoginMessage: null,
|
||||||
|
qqLoginStatus: 'unknown',
|
||||||
|
webuiOnline: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user