From 6c33f5d056b275e18c2590ebfa3224eef99eb4ae Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 13 Jun 2026 11:06:21 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=8B=86=E5=88=86QQBot=E8=BF=9E?= =?UTF-8?q?=E6=8E=A5=E6=80=81=E5=92=8C=E7=99=BB=E5=BD=95=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- README.md | 2 +- src/common/swagger/swagger-response.ts | 9 +- src/qqbot/account/qqbot-account.service.ts | 87 +++-- .../connection/qqbot-reverse-ws.service.ts | 30 ++ src/qqbot/event/qqbot-event.service.ts | 2 +- .../account/qqbot-account.service.spec.ts | 330 +++++++++++++++++- .../qqbot-reverse-ws.service.spec.ts | 142 ++++++++ test/qqbot/event/qqbot-event.service.spec.ts | 8 +- 9 files changed, 568 insertions(+), 44 deletions(-) create mode 100644 test/qqbot/connection/qqbot-reverse-ws.service.spec.ts diff --git a/API.md b/API.md index 423d9bf..06d8379 100644 --- a/API.md +++ b/API.md @@ -319,7 +319,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。已有账号的更新登录会先重启目标 NapCat 容器尝试 `ACCOUNT`/`-q` 快速登录;目标账号在线则直接完成会话。没有历史登录态的新容器会跳过快速登录,优先尝试保存的登录密码;快速登录失败后,如果账号保存了登录密码,会临时注入 `NAPCAT_QUICK_PASSWORD` 并按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询密码登录结果,准备阶段的扫码会话会持续续期,避免后台密码登录未完成时前端先判过期。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。密码登录成功后会重建容器移除该运行态密码,清理失败则本次登录失败;密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 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` 等账号级信号才会记录 QQ 登录态异常并生成 `qqbot.account.offline` 站内信,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新 OneBot 连接状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于 QQ 登录态离线信号;心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。 外发消息不直接抢发:后端会按 `QQBOT_SEND_GLOBAL_INTERVAL_MS`、`QQBOT_SEND_TARGET_INTERVAL_MS` 和 `QQBOT_SEND_JITTER_MS` 预约发送窗口,默认全局 2500ms、同会话 8000ms、抖动 0-800ms;如果等待超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS`,本次发送会在下发前被拒绝。在线命令和自动回复规则会叠加运行时保底冷却,默认命令 5000ms、规则 30000ms;复读机默认连续 4 次相同普通文本才触发,同一会话默认 10 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。 diff --git a/README.md b/README.md index 1fe8ee6..615ac39 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts - QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。 - QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。 - QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。 -- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,日志/WebUI 检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误;`qqLoginMessage` 只展示 QQ 登录态消息,WebUI 配置或请求错误留在 `lastError`。 +- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息,WebUI 配置或请求错误留在 `lastError`。 - 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 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。`ACCOUNT` 只负责指定快速登录账号;如果数据卷内没有该 QQ 的历史登录记录,后端会跳过快速登录并优先尝试账号保存的登录密码,密码登录会按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果,准备中的扫码会话会续期,避免后台密码登录未结束时前端先过期;如果 NapCat 返回 `proofWaterUrl` 或日志出现“需要验证码”,会保持会话 pending 并把腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`,不是让用户只打开外链;密码登录成功后会移除运行态 `NAPCAT_QUICK_PASSWORD`,清理失败则本次登录失败,避免成功态残留明文 env;密码登录也失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时才重置登录态并生成二维码。Admin「更新登录」的 SSE 步骤顺序按实际路径为 `quick-login-*`(已有历史会话)-> `password-login-*` / `password-login-captcha` -> `password-env-cleanup` -> `relogin-reset/qrcode/waiting-scan`。 diff --git a/src/common/swagger/swagger-response.ts b/src/common/swagger/swagger-response.ts index 4d418b9..6c94b2c 100644 --- a/src/common/swagger/swagger-response.ts +++ b/src/common/swagger/swagger-response.ts @@ -557,7 +557,7 @@ function getPropertyDescription(propertyName: string) { code: '响应状态码', command: '命令触发词', commandId: '在线命令 ID', - connectStatus: 'OneBot 账号在线状态', + connectStatus: 'OneBot 反向 WS 连接状态', connectionMode: '连接模式', connectionRole: 'OneBot 连接角色', containerName: 'NapCat 容器名称', @@ -576,7 +576,7 @@ function getPropertyDescription(propertyName: string) { keyword: '匹配关键词', lastConnectedAt: '最后连接时间', lastError: '最近异常', - lastHeartbeatAt: '最后心跳时间', + lastHeartbeatAt: 'OneBot/容器最后心跳时间', lastLoginAt: '最近扫码登录时间', lastMessage: '最后一条消息', lastModified: '最后修改时间', @@ -825,8 +825,13 @@ function qqbotAccountExample() { napcat: { bindStatus: 'bound', containerName: 'kt-qqbot-napcat-1914728559', + containerOnline: true, containerStatus: 'running', lastLoginAt: '2026-06-02 19:55:00', + oneBotOnline: true, + qqLoginMessage: 'QQ 已登录', + qqLoginStatus: 'online', + webuiOnline: true, webuiPort: 6100, }, selfId: '1914728559', diff --git a/src/qqbot/account/qqbot-account.service.ts b/src/qqbot/account/qqbot-account.service.ts index 2612956..bdf32eb 100644 --- a/src/qqbot/account/qqbot-account.service.ts +++ b/src/qqbot/account/qqbot-account.service.ts @@ -340,16 +340,21 @@ export class QqbotAccountService { async markOnline( selfId: string, clientRole: QqbotConnectionRole, - lastError: null | string = null, + lastError?: null | string, ) { + const payload: Partial = { + clientRole, + connectStatus: 'online', + lastConnectedAt: new Date(), + }; + if (lastError !== undefined) { + payload.lastError = lastError + ? this.toolsService.toColumnText(lastError, 500) + : null; + } await this.accountRepository.update( { selfId }, - { - clientRole, - connectStatus: 'online', - lastConnectedAt: new Date(), - lastError, - }, + payload, ); } @@ -396,6 +401,15 @@ export class QqbotAccountService { await this.accountRepository.update({ selfId }, payload); } + async markQqLoginOffline(selfId: string, lastError: string) { + await this.accountRepository.update( + { selfId }, + { + lastError: this.toolsService.toColumnText(lastError, 500), + }, + ); + } + getNapcatLoginPassword( account?: Pick | null, ) { @@ -493,7 +507,7 @@ export class QqbotAccountService { if (!container || container.status !== 'running') return runtimeStatus; if (account.connectStatus !== 'online') return runtimeStatus; - if (this.isAccountStateNewerThanRuntimeCheck(account, container)) { + if (this.isRecentConnectNewerThanRuntimeCheck(account, container)) { return runtimeStatus; } @@ -501,7 +515,7 @@ export class QqbotAccountService { this.getRuntimeStatusOfflineReason(runtimeStatus); if (runtimeOfflineReason) { if (options.autoLogin && (await this.tryAutoLogin(account, container))) { - return this.toCachedNapcatRuntimeStatus(account, container); + return this.toCachedNapcatRuntimeStatus(container); } await this.applyNapcatOfflineState( account, @@ -514,7 +528,7 @@ export class QqbotAccountService { const cachedOfflineReason = this.getFreshCachedOfflineReason(container); if (cachedOfflineReason) { if (options.autoLogin && (await this.tryAutoLogin(account, container))) { - return this.toCachedNapcatRuntimeStatus(account, container); + return this.toCachedNapcatRuntimeStatus(container); } await this.applyNapcatOfflineState( account, @@ -532,7 +546,7 @@ export class QqbotAccountService { if (!offlineReason) return runtimeStatus; if (options.autoLogin && (await this.tryAutoLogin(account, container))) { - return this.toCachedNapcatRuntimeStatus(account, container); + return this.toCachedNapcatRuntimeStatus(container); } await this.applyNapcatOfflineState(account, container, offlineReason); @@ -550,9 +564,9 @@ export class QqbotAccountService { container?: QqbotNapcatContainer, ): Promise { if (!container) return undefined; - const cached = this.toCachedNapcatRuntimeStatus(account, container); + const cached = this.toCachedNapcatRuntimeStatus(container); if (container.status !== 'running') return cached; - if (this.isAccountStateNewerThanRuntimeCheck(account, container)) { + if (this.isRecentConnectNewerThanRuntimeCheck(account, container)) { return cached; } if (this.isFreshRuntimeCheck(container.lastCheckedAt)) return cached; @@ -566,11 +580,26 @@ export class QqbotAccountService { await this.napcatContainerService.inspectRuntimeStatus(container); container.lastCheckedAt = inspected.checkedAt as any; container.lastError = inspected.lastError || null; + await this.clearQqLoginErrorIfConfirmedOnline(account, inspected); return inspected; } - private toCachedNapcatRuntimeStatus( + private async clearQqLoginErrorIfConfirmedOnline( account: QqbotAccount, + runtimeStatus: QqbotNapcatRuntimeStatusSnapshot, + ) { + if (runtimeStatus.qqLoginStatus !== 'online') return; + const lastError = this.toolsService.toTrimmedString(account.lastError); + if (!lastError || !this.isQqLoginStateError(lastError)) return; + + await this.accountRepository.update( + { selfId: account.selfId }, + { lastError: null }, + ); + account.lastError = null; + } + + private toCachedNapcatRuntimeStatus( container: QqbotNapcatContainer, ): QqbotNapcatRuntimeStatusSnapshot { const containerOnline = container.status === 'running'; @@ -585,17 +614,12 @@ export class QqbotAccountService { containerOnline, lastError: lastError || null, qqLoginMessage: offlineReason, - qqLoginStatus: this.toCachedQqLoginStatus( - account, - containerOnline, - lastError, - ), + qqLoginStatus: this.toCachedQqLoginStatus(containerOnline, lastError), webuiOnline: containerOnline ? null : false, }; } private toCachedQqLoginStatus( - account: QqbotAccount, containerOnline: boolean, lastError: string, ): QqbotNapcatRuntimeStatusSnapshot['qqLoginStatus'] { @@ -609,10 +633,17 @@ export class QqbotAccountService { if (this.toolsService.isNapcatOfflineLoginMessage(lastError)) { return 'offline'; } - if (account.connectStatus === 'online') return 'online'; return 'unknown'; } + private isQqLoginStateError(message: string) { + return ( + this.toolsService.isNapcatOfflineLoginMessage(message) || + message.includes('二维码已过期') || + message.includes('二维码过期') + ); + } + private getRuntimeStatusOfflineReason( runtimeStatus?: QqbotNapcatRuntimeStatusSnapshot, ) { @@ -649,7 +680,7 @@ export class QqbotAccountService { } if (!result.success) return false; - await this.markOnline(account.selfId, 'Universal'); + await this.markOnline(account.selfId, 'Universal', null); account.clientRole = 'Universal'; account.connectStatus = 'online'; account.lastConnectedAt = new Date() as any; @@ -665,8 +696,7 @@ export class QqbotAccountService { container: QqbotNapcatContainer, offlineReason: string, ) { - await this.markOffline(account.selfId, offlineReason); - account.connectStatus = 'offline'; + await this.markQqLoginOffline(account.selfId, offlineReason); account.lastError = offlineReason; this.publishOfflineNotice(account.selfId, offlineReason, { containerId: container.id, @@ -682,17 +712,16 @@ export class QqbotAccountService { : null; } - private isAccountStateNewerThanRuntimeCheck( + private isRecentConnectNewerThanRuntimeCheck( account: QqbotAccount, container: QqbotNapcatContainer, ) { const checkedAt = this.toTime(container.lastCheckedAt); if (!checkedAt) return false; + const connectedAt = this.toTime(account.lastConnectedAt); + if (connectedAt <= checkedAt) return false; - return ( - this.toTime(account.lastConnectedAt) > checkedAt || - this.toTime(account.lastHeartbeatAt) > checkedAt - ); + return Date.now() - connectedAt < NAPCAT_RUNTIME_CHECK_TTL_MS; } private isFreshRuntimeCheck(lastCheckedAt?: Date | null) { diff --git a/src/qqbot/connection/qqbot-reverse-ws.service.ts b/src/qqbot/connection/qqbot-reverse-ws.service.ts index 651720b..c5337a9 100644 --- a/src/qqbot/connection/qqbot-reverse-ws.service.ts +++ b/src/qqbot/connection/qqbot-reverse-ws.service.ts @@ -91,6 +91,7 @@ export class QqbotReverseWsService (resolve, reject) => { const timer = setTimeout(() => { this.pendingActions.delete(echo); + this.closeTimedOutConnection(selfId, ws); reject(new Error('OneBot action timeout')); }, this.getActionTimeout()); this.pendingActions.set(echo, { reject, resolve, timer }); @@ -153,6 +154,7 @@ export class QqbotReverseWsService }); ws.on('close', async () => { + if (!this.isCurrentConnection(key, ws)) return; this.connections.delete(key); await this.accountService.markOffline(context.selfId); await this.busService.publish(QQBOT_MQTT_TOPICS.status(context.selfId), { @@ -163,6 +165,7 @@ export class QqbotReverseWsService }); ws.on('error', async (err) => { this.logger.warn(`QQBot WS 错误 ${context.selfId}: ${err.message}`); + if (!this.isCurrentConnection(key, ws)) return; await this.accountService.markOffline(context.selfId, err.message); }); @@ -229,6 +232,33 @@ export class QqbotReverseWsService pending.resolve(payload); } + private closeTimedOutConnection(selfId: string, ws: WebSocket) { + const reason = 'OneBot action timeout'; + let closedCurrentConnection = false; + [...this.connections.entries()].forEach(([key, connection]) => { + if (!key.startsWith(`${selfId}:`) || connection !== ws) return; + this.connections.delete(key); + closedCurrentConnection = true; + try { + connection.close(1011, reason); + } catch { + // The connection is already unusable; state cleanup is the important part. + } + }); + if (!closedCurrentConnection) return; + void this.accountService.markOffline(selfId, reason).catch(() => undefined); + void this.busService + .publish(QQBOT_MQTT_TOPICS.status(selfId), { + selfId, + status: 'offline', + }) + .catch(() => undefined); + } + + private isCurrentConnection(key: string, ws: WebSocket) { + return this.connections.get(key) === ws; + } + private async authorize(request: IncomingMessage) { const url = new URL(request.url || '', `http://${request.headers.host}`); const selfId = `${ diff --git a/src/qqbot/event/qqbot-event.service.ts b/src/qqbot/event/qqbot-event.service.ts index 891554b..29428e2 100644 --- a/src/qqbot/event/qqbot-event.service.ts +++ b/src/qqbot/event/qqbot-event.service.ts @@ -71,7 +71,7 @@ export class QqbotEventService { if (!selfId) return; const offlineReason = getOneBotOfflineReason(payload); if (!offlineReason) return; - await this.accountService.markOffline(selfId, offlineReason); + await this.accountService.markQqLoginOffline(selfId, offlineReason); this.publishOfflineNotice(selfId, offlineReason, payload); } diff --git a/test/qqbot/account/qqbot-account.service.spec.ts b/test/qqbot/account/qqbot-account.service.spec.ts index 1ae2b10..d87a1cd 100644 --- a/test/qqbot/account/qqbot-account.service.spec.ts +++ b/test/qqbot/account/qqbot-account.service.spec.ts @@ -209,6 +209,58 @@ describe('QqbotAccountService', () => { ); }); + it('preserves QQ login error when OneBot connection comes online', async () => { + const accountRepository = { + update: jest.fn(), + }; + const service = new QqbotAccountService( + accountRepository as any, + {} as any, + {} as any, + {} as any, + {} as any, + new ToolsService(), + ); + + await service.markOnline('1914728559', 'Universal'); + + const updatePayload = accountRepository.update.mock.calls[0][1]; + expect(updatePayload).toEqual( + expect.objectContaining({ + clientRole: 'Universal', + connectStatus: 'online', + lastConnectedAt: expect.any(Date), + }), + ); + expect(updatePayload).not.toHaveProperty('lastError'); + }); + + it('clears QQ login error only when online state is explicitly confirmed', async () => { + const accountRepository = { + update: jest.fn(), + }; + const service = new QqbotAccountService( + accountRepository as any, + {} as any, + {} as any, + {} as any, + {} as any, + new ToolsService(), + ); + + await service.markOnline('1914728559', 'Universal', null); + + expect(accountRepository.update).toHaveBeenCalledWith( + { selfId: '1914728559' }, + expect.objectContaining({ + clientRole: 'Universal', + connectStatus: 'online', + lastConnectedAt: expect.any(Date), + lastError: null, + }), + ); + }); + it('truncates offline reason before writing lastError column', async () => { const accountRepository = { update: jest.fn(), @@ -306,14 +358,17 @@ describe('QqbotAccountService', () => { expect(accountRepository.update).toHaveBeenCalledWith( { selfId: '1914728559' }, { - connectStatus: 'offline', lastError: 'NapCat 账号状态变更为离线', }, ); expect(page.list[0]).toEqual( expect.objectContaining({ - connectStatus: 'offline', + connectStatus: 'online', lastError: 'NapCat 账号状态变更为离线', + napcat: expect.objectContaining({ + oneBotOnline: true, + qqLoginStatus: 'offline', + }), }), ); expect(systemNoticePublisher.publishSystemNotice).toHaveBeenCalledWith( @@ -575,14 +630,90 @@ describe('QqbotAccountService', () => { ); }); - it('ignores cached NapCat offline reason after the account reconnects', async () => { - const checkedAt = new Date('2026-06-11T02:00:00.000Z'); + it('does not derive QQ login online status from OneBot heartbeat cache', async () => { + const checkedAt = new Date(); const account = { connectStatus: 'online', enabled: true, id: 'account-1', isDeleted: false, - lastConnectedAt: new Date('2026-06-11T02:00:10.000Z'), + lastError: null, + lastHeartbeatAt: new Date(), + 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: 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 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({ + oneBotOnline: true, + qqLoginStatus: 'unknown', + webuiOnline: null, + }), + ); + }); + + it('ignores cached NapCat offline reason after the account reconnects', async () => { + const now = Date.now(); + const checkedAt = new Date(now - 10_000); + const account = { + connectStatus: 'online', + enabled: true, + id: 'account-1', + isDeleted: false, + lastConnectedAt: new Date(now - 5_000), lastError: null, name: '主账号', selfId: '1914728559', @@ -658,6 +789,194 @@ describe('QqbotAccountService', () => { ); }); + it('does not let heartbeat bypass stale NapCat offline inspection', async () => { + const checkedAt = new Date(Date.now() - 60_000); + const account = { + connectStatus: 'online', + enabled: true, + id: 'account-1', + isDeleted: false, + lastConnectedAt: new Date(Date.now() - 120_000), + lastError: null, + lastHeartbeatAt: new Date(), + 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: 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(), + containerOnline: true, + lastError: '账号状态变更为离线', + qqLoginMessage: '账号状态变更为离线', + qqLoginStatus: 'offline', + 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).toHaveBeenCalledWith( + { selfId: '1914728559' }, + { + lastError: '账号状态变更为离线', + }, + ); + expect(page.list[0]).toEqual( + expect.objectContaining({ + connectStatus: 'online', + lastError: '账号状态变更为离线', + napcat: expect.objectContaining({ + oneBotOnline: true, + qqLoginStatus: 'offline', + }), + }), + ); + }); + + it('clears previous QQ login error when NapCat WebUI confirms QQ is online', async () => { + const checkedAt = new Date(Date.now() - 60_000); + const account = { + connectStatus: 'online', + enabled: true, + id: 'account-1', + isDeleted: false, + lastConnectedAt: new Date(Date.now() - 120_000), + lastError: '账号状态变更为离线', + 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: '账号状态变更为离线', + 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(), + containerOnline: true, + lastError: null, + qqLoginMessage: null, + qqLoginStatus: 'online', + 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(accountRepository.update).toHaveBeenCalledWith( + { selfId: '1914728559' }, + { lastError: null }, + ); + expect(page.list[0]).toEqual( + expect.objectContaining({ + lastError: null, + napcat: expect.objectContaining({ + qqLoginStatus: 'online', + }), + }), + ); + }); + it('lets watchdog auto-login before marking an account offline', async () => { const toolsService = new ToolsService(); const account = { @@ -848,7 +1167,6 @@ describe('QqbotAccountService', () => { expect(accountRepository.update).toHaveBeenCalledWith( { selfId: '1914728559' }, { - connectStatus: 'offline', lastError: 'NapCat 自动登录后运行态密码清理失败,请手动更新登录', }, ); diff --git a/test/qqbot/connection/qqbot-reverse-ws.service.spec.ts b/test/qqbot/connection/qqbot-reverse-ws.service.spec.ts new file mode 100644 index 0000000..96bd2e1 --- /dev/null +++ b/test/qqbot/connection/qqbot-reverse-ws.service.spec.ts @@ -0,0 +1,142 @@ +jest.mock('@/qqbot/event/qqbot-event.service', () => ({ + QqbotEventService: class {}, +})); + +import { EventEmitter } from 'events'; +import { ToolsService } from '@/common'; +import { QqbotReverseWsService } from '@/qqbot/connection/qqbot-reverse-ws.service'; + +class FakeWebSocket extends EventEmitter { + closed: Array<{ code?: number; reason?: string }> = []; + readyState = 1; + sent: string[] = []; + + close(code?: number, reason?: string) { + this.closed.push({ code, reason }); + this.readyState = 3; + this.emit('close', code, reason); + } + + send(payload: string) { + this.sent.push(payload); + } +} + +function createService() { + const accountService = { + ensureRuntimeAccount: jest.fn(), + findBySelfId: jest.fn(), + findEnabledBySelfIdWithToken: jest.fn().mockResolvedValue({ + accessToken: '', + selfId: '1914728559', + }), + markHeartbeat: jest.fn().mockResolvedValue(undefined), + markOffline: jest.fn().mockResolvedValue(undefined), + markOnline: jest.fn().mockResolvedValue(undefined), + }; + const busService = { + publish: jest.fn().mockResolvedValue(undefined), + }; + const service = new QqbotReverseWsService( + { + get: jest.fn((key: string) => { + if (key === 'QQBOT_ENABLED') return 'true'; + if (key === 'QQBOT_API_TIMEOUT_MS') return '10'; + return ''; + }), + } as any, + {} as any, + {} as any, + accountService as any, + busService as any, + new ToolsService(), + ); + + return { accountService, busService, service }; +} + +function createRequest() { + return { + headers: { host: '127.0.0.1:48085' }, + url: '/onebot/v11/ws?self_id=1914728559&role=Universal', + } as any; +} + +describe('QqbotReverseWsService', () => { + it('ignores close events from a replaced OneBot connection', async () => { + const { accountService, busService, service } = createService(); + const oldWs = new FakeWebSocket(); + const currentWs = new FakeWebSocket(); + + await (service as any).handleConnection(oldWs, createRequest()); + await (service as any).handleConnection(currentWs, createRequest()); + accountService.markOffline.mockClear(); + busService.publish.mockClear(); + + oldWs.emit('close'); + await Promise.resolve(); + + expect((service as any).connections.get('1914728559:Universal')).toBe( + currentWs, + ); + expect(accountService.markOffline).not.toHaveBeenCalled(); + expect(busService.publish).not.toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ status: 'offline' }), + ); + }); + + it('does not mark OneBot offline when a replaced connection times out', async () => { + const { accountService, busService, service } = createService(); + const oldWs = new FakeWebSocket(); + const currentWs = new FakeWebSocket(); + + await (service as any).handleConnection(oldWs, createRequest()); + await (service as any).handleConnection(currentWs, createRequest()); + accountService.markOffline.mockClear(); + busService.publish.mockClear(); + + (service as any).closeTimedOutConnection('1914728559', oldWs); + await Promise.resolve(); + + expect((service as any).connections.get('1914728559:Universal')).toBe( + currentWs, + ); + expect(oldWs.closed).toEqual([]); + expect(accountService.markOffline).not.toHaveBeenCalled(); + expect(busService.publish).not.toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ status: 'offline' }), + ); + }); + + it('marks OneBot offline when the current connection times out', async () => { + const { accountService, busService, service } = createService(); + const ws = new FakeWebSocket(); + + await (service as any).handleConnection(ws, createRequest()); + accountService.markOffline.mockClear(); + busService.publish.mockClear(); + + (service as any).closeTimedOutConnection('1914728559', ws); + await Promise.resolve(); + + expect((service as any).connections.has('1914728559:Universal')).toBe( + false, + ); + expect(ws.closed).toEqual([ + { code: 1011, reason: 'OneBot action timeout' }, + ]); + expect(accountService.markOffline).toHaveBeenCalledWith( + '1914728559', + 'OneBot action timeout', + ); + expect(busService.publish).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ + selfId: '1914728559', + status: 'offline', + }), + ); + }); +}); diff --git a/test/qqbot/event/qqbot-event.service.spec.ts b/test/qqbot/event/qqbot-event.service.spec.ts index d58bc27..a725e53 100644 --- a/test/qqbot/event/qqbot-event.service.spec.ts +++ b/test/qqbot/event/qqbot-event.service.spec.ts @@ -7,7 +7,7 @@ import { QqbotEventService } from '@/qqbot/event/qqbot-event.service'; describe('QqbotEventService', () => { it('marks account offline with reason when NapCat reports kicked offline notice', async () => { const accountService = { - markOffline: jest.fn(), + markQqLoginOffline: jest.fn(), }; const systemNoticePublisher = { publishSystemNotice: jest.fn().mockResolvedValue('notice-1'), @@ -37,7 +37,7 @@ describe('QqbotEventService', () => { await service.handleIncoming(payload); - expect(accountService.markOffline).toHaveBeenCalledWith( + expect(accountService.markQqLoginOffline).toHaveBeenCalledWith( '1914728559', 'bot_offline/kick_offline:你的帐号当前登录已失效,请重新登录。', ); @@ -63,7 +63,7 @@ describe('QqbotEventService', () => { it('ignores normal group kick notices because they are not bot offline signals', async () => { const accountService = { - markOffline: jest.fn(), + markQqLoginOffline: jest.fn(), }; const systemNoticePublisher = { publishSystemNotice: jest.fn(), @@ -91,7 +91,7 @@ describe('QqbotEventService', () => { user_id: 67890, }); - expect(accountService.markOffline).not.toHaveBeenCalled(); + expect(accountService.markQqLoginOffline).not.toHaveBeenCalled(); expect(systemNoticePublisher.publishSystemNotice).not.toHaveBeenCalled(); }); });