From 5f0351132dfc9c97296a995ce15c02293561865e Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 10 Jun 2026 11:40:33 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DQQBot=20NapCat?= =?UTF-8?q?=E4=B8=8B=E7=BA=BF=E8=AF=8A=E6=96=AD=E6=8A=A4=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 + README.md | 1 + src/qqbot/account/qqbot-account.service.ts | 14 ++-- src/qqbot/event/qqbot-event-normalizer.ts | 30 +++++++ src/qqbot/event/qqbot-event.service.ts | 18 +++- .../napcat/qqbot-napcat-container.service.ts | 38 +++++++++ .../account/qqbot-account.service.spec.ts | 26 ++++++ test/qqbot/event/qqbot-event.service.spec.ts | 38 +++++++++ .../qqbot-napcat-container.service.spec.ts | 82 +++++++++++++++++++ 9 files changed, 241 insertions(+), 8 deletions(-) create mode 100644 test/qqbot/account/qqbot-account.service.spec.ts create mode 100644 test/qqbot/event/qqbot-event.service.spec.ts create mode 100644 test/qqbot/napcat/qqbot-napcat-container.service.spec.ts diff --git a/API.md b/API.md index 598578e..6ed8c6a 100644 --- a/API.md +++ b/API.md @@ -302,6 +302,8 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成。 +同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。NapCat 上报下线 notice 时,账号会被标记为离线,并把下线原因写入 `lastError`;后续无错误的普通断连只更新离线状态,不清空该原因。 + ### Command / Rule / Permission | 方法 | 路径 | 说明 | diff --git a/README.md b/README.md index fbd74a7..bed8bde 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,7 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts - WordPress 自动登录失败不会阻断 Admin 主登录,会通过菜单和权限码过滤不可用的 Blog 管理入口。 - 系统日志由 pino 输出,Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。 - QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应。 +- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,下线 notice 会写入账号 `lastError`,后续无错误的普通断连不能清空该原因。 - BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。 - BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。 - BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。 diff --git a/src/qqbot/account/qqbot-account.service.ts b/src/qqbot/account/qqbot-account.service.ts index c302795..6114201 100644 --- a/src/qqbot/account/qqbot-account.service.ts +++ b/src/qqbot/account/qqbot-account.service.ts @@ -332,13 +332,13 @@ export class QqbotAccountService { } async markOffline(selfId: string, lastError?: string) { - await this.accountRepository.update( - { selfId }, - { - connectStatus: 'offline', - lastError: lastError || null, - }, - ); + const payload: Partial = { + connectStatus: 'offline', + }; + if (lastError !== undefined) { + payload.lastError = lastError || null; + } + await this.accountRepository.update({ selfId }, payload); } private async appendNapcatRuntime( diff --git a/src/qqbot/event/qqbot-event-normalizer.ts b/src/qqbot/event/qqbot-event-normalizer.ts index 0e582b9..81461c7 100644 --- a/src/qqbot/event/qqbot-event-normalizer.ts +++ b/src/qqbot/event/qqbot-event-normalizer.ts @@ -67,6 +67,36 @@ export function buildDedupeKey(message: QqbotNormalizedMessage) { ].join(':'); } +export function getOneBotOfflineReason(payload: QqbotOneBotEvent) { + if (payload?.post_type !== 'notice') return null; + + const noticeType = `${payload.notice_type || ''}`.trim(); + const subType = `${payload.sub_type || ''}`.trim(); + const content = [ + payload.message, + payload.reason, + payload.raw_message, + payload.title, + payload.tips, + payload.loginError, + ] + .filter((item) => typeof item === 'string' && item.trim()) + .join(' ') + .trim(); + const probe = `${noticeType} ${subType} ${content}`; + if (!/offline|kick|KickedOffLine|下线|离线|登录已失效/i.test(probe)) { + return null; + } + + const source = [noticeType, subType].filter(Boolean).join('/') || 'offline'; + const message = content + .replace(/\[KickedOffLine\]/gi, '') + .replace(/\[下线通知\]/g, '') + .replace(/\s+/g, ' ') + .trim(); + return `${source}:${message || '账号已离线,请重新登录'}`; +} + function extractMessageText(payload: QqbotOneBotEvent) { if (payload.raw_message) return payload.raw_message; if (typeof payload.message === 'string') return payload.message; diff --git a/src/qqbot/event/qqbot-event.service.ts b/src/qqbot/event/qqbot-event.service.ts index c741f11..8130886 100644 --- a/src/qqbot/event/qqbot-event.service.ts +++ b/src/qqbot/event/qqbot-event.service.ts @@ -7,10 +7,12 @@ import { QqbotBusService } from '../mqtt/qqbot-bus.service'; import type { QqbotOneBotEvent } from '../qqbot.types'; import { buildDedupeKey, + getOneBotOfflineReason, isOneBotMessageEvent, normalizeOneBotMessage, } from './qqbot-event-normalizer'; import { QqbotRuleEngineService } from '../rule/qqbot-rule-engine.service'; +import { QqbotAccountService } from '../account/qqbot-account.service'; @Injectable() export class QqbotEventService { @@ -22,6 +24,7 @@ export class QqbotEventService { private readonly messageService: QqbotMessageService, private readonly ruleEngineService: QqbotRuleEngineService, private readonly toolsService: ToolsService, + private readonly accountService: QqbotAccountService, ) {} async handleIncoming(payload: QqbotOneBotEvent) { @@ -33,7 +36,10 @@ export class QqbotEventService { ); } - if (!isOneBotMessageEvent(payload)) return; + if (!isOneBotMessageEvent(payload)) { + await this.handleRuntimeNotice(selfId, payload); + return; + } const message = normalizeOneBotMessage(payload, this.toolsService); if (!message.selfId || !message.targetId || !message.userId) { this.logger.warn('QQBot 收到缺少关键字段的消息事件,已忽略'); @@ -50,4 +56,14 @@ export class QqbotEventService { ); await this.ruleEngineService.handleMessage(message); } + + private async handleRuntimeNotice( + selfId: string, + payload: QqbotOneBotEvent, + ) { + if (!selfId) return; + const offlineReason = getOneBotOfflineReason(payload); + if (!offlineReason) return; + await this.accountService.markOffline(selfId, offlineReason); + } } diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index 63269ed..335c108 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -79,6 +79,7 @@ export class QqbotNapcatContainerService { lastLoginAt: new Date(), }, ); + await this.removeOtherAccountContainers(accountId, containerId); return; } @@ -92,6 +93,7 @@ export class QqbotNapcatContainerService { remark: '', }), ); + await this.removeOtherAccountContainers(accountId, containerId); } async removeAccountContainers(accountId: string) { @@ -229,6 +231,42 @@ export class QqbotNapcatContainerService { return true; } + private async removeOtherAccountContainers( + accountId: string, + keepContainerId: string, + ) { + const bindings = await this.bindingRepository.find({ + where: { + accountId, + isDeleted: false, + }, + }); + for (const binding of bindings) { + if (binding.containerId === keepContainerId) continue; + + const sharedCount = await this.bindingRepository + .createQueryBuilder('binding') + .where('binding.containerId = :containerId', { + containerId: binding.containerId, + }) + .andWhere('binding.accountId != :accountId', { accountId }) + .andWhere('binding.isDeleted = :isDeleted', { isDeleted: false }) + .getCount(); + if (sharedCount <= 0) { + await this.removeContainer(binding.containerId); + } + + await this.bindingRepository.update( + { id: binding.id }, + { + bindStatus: 'disabled', + isDeleted: true, + isPrimary: false, + }, + ); + } + } + private async removeRemoteDockerContainer(container: QqbotNapcatContainer) { const script = this.buildRemoteRemoveScript(container); await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script); diff --git a/test/qqbot/account/qqbot-account.service.spec.ts b/test/qqbot/account/qqbot-account.service.spec.ts new file mode 100644 index 0000000..a741adf --- /dev/null +++ b/test/qqbot/account/qqbot-account.service.spec.ts @@ -0,0 +1,26 @@ +import { QqbotAccountService } from '@/qqbot/account/qqbot-account.service'; + +describe('QqbotAccountService', () => { + it('preserves previous offline reason when later disconnect has no explicit error', async () => { + const accountRepository = { + update: jest.fn(), + }; + const service = new QqbotAccountService( + accountRepository as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + + await service.markOffline('1914728559'); + + expect(accountRepository.update).toHaveBeenCalledWith( + { selfId: '1914728559' }, + { + connectStatus: 'offline', + }, + ); + }); +}); diff --git a/test/qqbot/event/qqbot-event.service.spec.ts b/test/qqbot/event/qqbot-event.service.spec.ts new file mode 100644 index 0000000..439247a --- /dev/null +++ b/test/qqbot/event/qqbot-event.service.spec.ts @@ -0,0 +1,38 @@ +jest.mock('@/qqbot/rule/qqbot-rule-engine.service', () => ({ + QqbotRuleEngineService: class QqbotRuleEngineService {}, +})); + +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(), + }; + const service = new QqbotEventService( + { publish: jest.fn() } as any, + {} as any, + {} as any, + {} as any, + { + getErrorMessage: (error: unknown) => + error instanceof Error ? error.message : `${error}`, + toStringId: (value: unknown) => `${value || ''}`, + } as any, + accountService as any, + ); + + await service.handleIncoming({ + message: '[KickedOffLine] [下线通知] 你的帐号当前登录已失效,请重新登录。', + notice_type: 'bot_offline', + post_type: 'notice', + self_id: 1914728559, + sub_type: 'kick_offline', + }); + + expect(accountService.markOffline).toHaveBeenCalledWith( + '1914728559', + 'bot_offline/kick_offline:你的帐号当前登录已失效,请重新登录。', + ); + }); +}); diff --git a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts new file mode 100644 index 0000000..0bc5f20 --- /dev/null +++ b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts @@ -0,0 +1,82 @@ +jest.mock('@/common', () => { + const actualCommon = jest.requireActual('@/common'); + return { + FormatDateTime: actualCommon.FormatDateTime, + ToolsService: actualCommon.ToolsService, + ensureSnowflakeId: jest.fn(), + formatKtDateTime: actualCommon.formatKtDateTime, + setDictDecodeCache: jest.fn(), + throwVbenError: (message: string) => { + throw new Error(message); + }, + }; +}); + +import { ConfigService } from '@nestjs/config'; +import { ToolsService } from '@/common'; +import { QqbotNapcatContainerService } from '@/qqbot/napcat/qqbot-napcat-container.service'; + +describe('QqbotNapcatContainerService', () => { + it('removes previous account containers when binding a new primary container', async () => { + const bindingRepository = { + create: jest.fn((input) => input), + createQueryBuilder: jest.fn(() => ({ + andWhere: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(0), + where: jest.fn().mockReturnThis(), + })), + find: jest.fn().mockResolvedValue([ + { + accountId: 'account-1', + containerId: 'container-old', + id: 'binding-old', + isDeleted: false, + }, + ]), + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn(), + update: jest.fn(), + }; + const containerRepository = { + findOne: jest.fn().mockResolvedValue({ + id: 'container-old', + isDeleted: false, + name: 'napcat-old', + }), + update: jest.fn(), + }; + const service = new QqbotNapcatContainerService( + { get: jest.fn().mockReturnValue('') } as unknown as ConfigService, + containerRepository as any, + bindingRepository as any, + new ToolsService(), + ); + jest.spyOn(service as any, 'getManagedMode').mockReturnValue(''); + + await service.bindAccount('account-1', 'container-new'); + + expect(bindingRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 'account-1', + bindStatus: 'bound', + containerId: 'container-new', + isPrimary: true, + }), + ); + expect(containerRepository.update).toHaveBeenCalledWith( + { id: 'container-old' }, + expect.objectContaining({ + isDeleted: true, + status: 'stopped', + }), + ); + expect(bindingRepository.update).toHaveBeenCalledWith( + { id: 'binding-old' }, + expect.objectContaining({ + bindStatus: 'disabled', + isDeleted: true, + isPrimary: false, + }), + ); + }); +});