From bb6d0263e787af7faf9f0fb1eb328ee1adc280f4 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 12 Jun 2026 14:50:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20NapCat=20=E5=90=AF=E7=94=A8=20-q=20?= =?UTF-8?q?=E5=BF=AB=E9=80=9F=E7=99=BB=E5=BD=95=E4=B8=8E=E7=A6=BB=E7=BA=BF?= =?UTF-8?q?=E7=9C=8B=E9=97=A8=E7=8B=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 容器为已知 selfId 创建/重建时注入 ACCOUNT 环境变量启用 NapCat -q 快速登录, 重启可从持久化会话免扫码自动重登;旧容器在「更新登录」原地重建补齐 ACCOUNT (docker inspect 已带则跳过、失败非阻断、保留 QQ 数据卷)。 - 新增 QqbotNapcatWatchdogService 定时巡检在线账号,复用既有 super 站内信离线告警, 仅检测告警不重建容器,避免与 NapCat 自身重连竞争造成设备登录抖动。 - 新增 QQBOT_NAPCAT_WATCHDOG_ENABLED/INTERVAL_MS 配置及 README/.env.example 说明。 Co-Authored-By: Claude Opus 4.8 --- .env.example | 4 + README.md | 2 + src/qqbot/account/qqbot-account.service.ts | 19 ++++ .../account/qqbot-napcat-watchdog.service.ts | 82 ++++++++++++++ .../napcat/qqbot-napcat-container.service.ts | 99 ++++++++++++++++- src/qqbot/qqbot.module.ts | 2 + .../qqbot-napcat-watchdog.service.spec.ts | 102 ++++++++++++++++++ .../qqbot-napcat-container.service.spec.ts | 95 ++++++++++++++++ 8 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 src/qqbot/account/qqbot-napcat-watchdog.service.ts create mode 100644 test/qqbot/account/qqbot-napcat-watchdog.service.spec.ts diff --git a/.env.example b/.env.example index c1c0448..fa5f45f 100644 --- a/.env.example +++ b/.env.example @@ -80,6 +80,10 @@ QQBOT_NAPCAT_PORT_START=6100 QQBOT_NAPCAT_PORT_END=6199 QQBOT_NAPCAT_BASE_URL_TEMPLATE=http://192.168.31.224:{port} QQBOT_NAPCAT_REVERSE_WS_BASE=ws://192.168.31.224:48085/qqbot/onebot/reverse +# 离线看门狗:定时巡检在线账号,掉线/被踢及时触发站内信告警(true/false,默认开启) +QQBOT_NAPCAT_WATCHDOG_ENABLED=true +# 看门狗巡检间隔毫秒,最小 30000,默认 120000 +QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS=120000 BANGDREAM_TSUGU_MAIN_SERVER=cn BANGDREAM_TSUGU_DISPLAYED_SERVERS=cn,jp diff --git a/README.md b/README.md index f0ed31c..181114f 100644 --- a/README.md +++ b/README.md @@ -137,6 +137,8 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts - QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。 - QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表日志检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误。 - NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。 +- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。 +- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,复用既有离线检测与 `super` 站内信告警,使掉线/被踢无需管理员打开列表页即可及时发现;看门狗只做检测告警、不重建容器,避免与 NapCat 自身重连竞争产生设备登录抖动。 - 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 7fb8e49..b13d106 100644 --- a/src/qqbot/account/qqbot-account.service.ts +++ b/src/qqbot/account/qqbot-account.service.ts @@ -341,6 +341,25 @@ export class QqbotAccountService { ); } + /** + * 看门狗:主动巡检在线的已绑定账号,复用既有离线检测 + 站内信告警逻辑, + * 让掉线/被踢能被及时发现并通知超管,而不必等管理员打开账号列表页。 + * 仅做检测与告警,不做容器重建(避免与 NapCat 自身重连竞争、产生设备登录抖动)。 + */ + async runOfflineWatchdog(): Promise<{ checked: number }> { + const accounts = await this.accountRepository.find({ + where: { + connectStatus: 'online', + enabled: true, + isDeleted: false, + }, + }); + if (accounts.length <= 0) return { checked: 0 }; + + await this.appendNapcatRuntime(accounts); + return { checked: accounts.length }; + } + async markOffline(selfId: string, lastError?: string) { const payload: Partial = { connectStatus: 'offline', diff --git a/src/qqbot/account/qqbot-napcat-watchdog.service.ts b/src/qqbot/account/qqbot-napcat-watchdog.service.ts new file mode 100644 index 0000000..2bf9e8b --- /dev/null +++ b/src/qqbot/account/qqbot-napcat-watchdog.service.ts @@ -0,0 +1,82 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { QqbotAccountService } from './qqbot-account.service'; + +const DEFAULT_INTERVAL_MS = 120_000; +const MIN_INTERVAL_MS = 30_000; + +/** + * NapCat 离线看门狗:定时主动巡检在线账号,及时发现掉线/被踢并触发既有站内信告警。 + * 不依赖 @nestjs/schedule,采用 OnModuleInit + setInterval(与代码库既有定时器一致)。 + * 仅检测 + 告警,自动重登由容器 ACCOUNT 快速登录(-q)+ docker restart 策略负责。 + */ +@Injectable() +export class QqbotNapcatWatchdogService + implements OnModuleInit, OnModuleDestroy +{ + private readonly logger = new Logger(QqbotNapcatWatchdogService.name); + private timer?: ReturnType; + private running = false; + + constructor( + private readonly configService: ConfigService, + private readonly accountService: QqbotAccountService, + ) {} + + onModuleInit() { + if (!this.isEnabled()) return; + + const intervalMs = this.getIntervalMs(); + this.timer = setInterval(() => void this.tick(), intervalMs); + this.timer.unref?.(); + this.logger.log(`NapCat 离线看门狗已启用,巡检间隔 ${intervalMs}ms`); + } + + onModuleDestroy() { + if (this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + private async tick() { + if (this.running) return; + this.running = true; + try { + await this.accountService.runOfflineWatchdog(); + } catch (err) { + this.logger.warn( + `NapCat 离线看门狗巡检失败:${ + err instanceof Error ? err.message : `${err}` + }`, + ); + } finally { + this.running = false; + } + } + + private isEnabled() { + const value = `${ + this.configService.get('QQBOT_NAPCAT_WATCHDOG_ENABLED') ?? 'true' + }` + .trim() + .toLowerCase(); + return value !== 'false' && value !== '0' && value !== 'off'; + } + + private getIntervalMs() { + const value = Number( + this.configService.get('QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS') || + DEFAULT_INTERVAL_MS, + ); + if (!Number.isFinite(value) || value < MIN_INTERVAL_MS) { + return DEFAULT_INTERVAL_MS; + } + return value; + } +} diff --git a/src/qqbot/napcat/qqbot-napcat-container.service.ts b/src/qqbot/napcat/qqbot-napcat-container.service.ts index 4af2014..da2e76f 100644 --- a/src/qqbot/napcat/qqbot-napcat-container.service.ts +++ b/src/qqbot/napcat/qqbot-napcat-container.service.ts @@ -42,11 +42,92 @@ export class QqbotNapcatContainerService { } const existing = await this.getPrimaryRuntime(account.id); - if (existing) return existing; + if (existing) { + await this.ensureRuntimeQuickLogin(existing, account.selfId); + return existing; + } return this.createManagedContainer(account.selfId); } + /** + * 让已绑定账号的容器带上 ACCOUNT 环境变量(NapCat 的 -q 快速登录)。 + * 仅在 ssh 托管模式下原地重建容器:保留 QQ 数据卷,因此随后的容器重启 + * 能从持久化会话免扫码自动重登。硬踢(登录已失效)时会话作废,仍需扫码。 + */ + async ensureRuntimeQuickLogin(runtime: QqbotNapcatRuntime, selfId?: string) { + const account = `${selfId || ''}`.trim(); + if (!account || this.getManagedMode() !== 'ssh' || !runtime.id) { + return false; + } + + const container = await this.containerRepository + .createQueryBuilder('container') + .addSelect('container.webuiToken') + .where('container.id = :containerId', { containerId: runtime.id }) + .andWhere('container.isDeleted = :isDeleted', { isDeleted: false }) + .getOne(); + if ( + !container || + !container.name || + !container.webuiPort || + !container.webuiToken + ) { + return false; + } + + // 已带 ACCOUNT(或无法确认)则跳过,避免重复重建:只在首次缺失时原地重建一次。 + if (await this.runtimeHasAccountEnv(container.name)) return false; + + try { + await this.createRemoteDockerContainer({ + account, + dataDir: container.dataDir || `${this.getRootDir()}/${container.name}`, + image: container.image, + name: container.name, + port: container.webuiPort, + reverseWsUrl: container.reverseWsUrl || this.buildReverseWsUrl(), + skipPull: true, + token: container.webuiToken, + }); + await this.containerRepository.update( + { id: container.id }, + { + lastError: null, + lastStartedAt: new Date(), + status: 'running', + }, + ); + return true; + } catch { + // 重建失败不阻断登录流程:保持原容器继续走扫码登录。 + return false; + } + } + + private async runtimeHasAccountEnv(name: string) { + try { + const result = await this.runProcess( + 'ssh', + [ + ...this.getSshArgs(), + 'docker', + 'inspect', + '--format', + '{{range .Config.Env}}{{println .}}{{end}}', + name, + ], + '', + undefined, + this.getRuntimeCheckTimeoutMs(), + ); + return /^ACCOUNT=\S/m.test(result.stdout); + } catch { + // 无法确认时按“已存在”处理,宁可不重建也不误删运行中的容器。 + return true; + } + } + async findRuntimeById(containerId?: string) { if (!containerId) return this.getLegacyRuntime(); @@ -502,6 +583,7 @@ docker logs --tail 300 "$NAME" 2>&1 || true try { await this.createRemoteDockerContainer({ + account: selfId, dataDir, image, name, @@ -538,11 +620,13 @@ docker logs --tail 300 "$NAME" 2>&1 || true } private async createRemoteDockerContainer(input: { + account?: string; dataDir: string; image: string; name: string; port: number; reverseWsUrl: string; + skipPull?: boolean; token: string; }) { const script = this.buildRemoteCreateScript(input); @@ -550,11 +634,13 @@ docker logs --tail 300 "$NAME" 2>&1 || true } private buildRemoteCreateScript(input: { + account?: string; dataDir: string; image: string; name: string; port: number; reverseWsUrl: string; + skipPull?: boolean; token: string; }) { const dataDir = this.sh(input.dataDir); @@ -562,6 +648,10 @@ docker logs --tail 300 "$NAME" 2>&1 || true const name = this.sh(input.name); const reverseWsUrl = this.sh(input.reverseWsUrl); const token = this.sh(input.token); + const account = `${input.account || ''}`.trim(); + const accountHeader = account ? `ACCOUNT=${this.sh(account)}\n` : ''; + const accountRunFlag = account ? ' -e ACCOUNT="$ACCOUNT" \\\n' : ''; + const pullCmd = input.skipPull ? '' : 'docker pull "$IMAGE" >/dev/null\n'; return ` set -eu @@ -571,7 +661,7 @@ NAME=${name} PORT=${input.port} REVERSE_WS_URL=${reverseWsUrl} WEBUI_TOKEN=${token} - +${accountHeader} mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" chmod 700 "$DATA_DIR" @@ -610,15 +700,14 @@ cat > "$DATA_DIR/config/onebot11.json" </dev/null -docker rm -f "$NAME" >/dev/null 2>&1 || true +${pullCmd}docker rm -f "$NAME" >/dev/null 2>&1 || true docker run -d \\ --name "$NAME" \\ --restart unless-stopped \\ -e NAPCAT_UID=0 \\ -e NAPCAT_GID=0 \\ -e WEBUI_TOKEN="$WEBUI_TOKEN" \\ - -p "$PORT:6099" \\ +${accountRunFlag} -p "$PORT:6099" \\ -v "$DATA_DIR/QQ:/app/.config/QQ" \\ -v "$DATA_DIR/config:/app/napcat/config" \\ -v "$DATA_DIR/plugins:/app/napcat/plugins" \\ diff --git a/src/qqbot/qqbot.module.ts b/src/qqbot/qqbot.module.ts index 9929965..e4df845 100644 --- a/src/qqbot/qqbot.module.ts +++ b/src/qqbot/qqbot.module.ts @@ -8,6 +8,7 @@ import { QqbotAccountAbility } from './account/qqbot-account-ability.entity'; import { QqbotAccount } from './account/qqbot-account.entity'; import { QqbotAccountService } from './account/qqbot-account.service'; import { QqbotNapcatLoginService } from './account/qqbot-napcat-login.service'; +import { QqbotNapcatWatchdogService } from './account/qqbot-napcat-watchdog.service'; import { QqbotCommandController } from './command/qqbot-command.controller'; import { QqbotCommand } from './command/qqbot-command.entity'; import { QqbotCommandEngineService } from './command/qqbot-command-engine.service'; @@ -108,6 +109,7 @@ import { QqbotSendService } from './send/qqbot-send.service'; QqbotFflogsPluginService, QqbotMessageService, QqbotNapcatLoginService, + QqbotNapcatWatchdogService, QqbotNapcatContainerService, QqbotPermissionService, QqbotEventPluginRegistryService, diff --git a/test/qqbot/account/qqbot-napcat-watchdog.service.spec.ts b/test/qqbot/account/qqbot-napcat-watchdog.service.spec.ts new file mode 100644 index 0000000..b0f9dbc --- /dev/null +++ b/test/qqbot/account/qqbot-napcat-watchdog.service.spec.ts @@ -0,0 +1,102 @@ +import { ConfigService } from '@nestjs/config'; +import { QqbotNapcatWatchdogService } from '@/qqbot/account/qqbot-napcat-watchdog.service'; + +function buildService( + configValues: Record, + runOfflineWatchdog: jest.Mock, +) { + const configService = { + get: jest.fn((key: string) => configValues[key]), + } as unknown as ConfigService; + const accountService = { runOfflineWatchdog } as any; + return new QqbotNapcatWatchdogService(configService, accountService); +} + +// 刷新微任务队列,让定时器回调里的 async tick(含 finally 复位 running)执行完。 +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +describe('QqbotNapcatWatchdogService', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('periodically triggers the offline watchdog when enabled', async () => { + jest.useFakeTimers(); + const runOfflineWatchdog = jest.fn().mockResolvedValue({ checked: 1 }); + const service = buildService( + { QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS: '30000' }, + runOfflineWatchdog, + ); + + service.onModuleInit(); + expect(runOfflineWatchdog).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(30000); + await flushMicrotasks(); + expect(runOfflineWatchdog).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(30000); + await flushMicrotasks(); + expect(runOfflineWatchdog).toHaveBeenCalledTimes(2); + + service.onModuleDestroy(); + jest.advanceTimersByTime(60000); + await flushMicrotasks(); + expect(runOfflineWatchdog).toHaveBeenCalledTimes(2); + }); + + it('does not start a timer when disabled', async () => { + jest.useFakeTimers(); + const runOfflineWatchdog = jest.fn().mockResolvedValue({ checked: 0 }); + const service = buildService( + { QQBOT_NAPCAT_WATCHDOG_ENABLED: 'false' }, + runOfflineWatchdog, + ); + + service.onModuleInit(); + jest.advanceTimersByTime(600000); + await flushMicrotasks(); + expect(runOfflineWatchdog).not.toHaveBeenCalled(); + }); + + it('clamps an unreasonably small interval to the safe default', () => { + const service = buildService( + { QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS: '1000' }, + jest.fn(), + ) as any; + expect(service.getIntervalMs()).toBe(120_000); + }); + + it('skips overlapping ticks while a previous run is still pending', async () => { + jest.useFakeTimers(); + let resolvePending: (() => void) | undefined; + const runOfflineWatchdog = jest.fn( + () => + new Promise<{ checked: number }>((resolve) => { + resolvePending = () => resolve({ checked: 1 }); + }), + ); + const service = buildService( + { QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS: '30000' }, + runOfflineWatchdog, + ); + + service.onModuleInit(); + jest.advanceTimersByTime(30000); // first tick starts, stays pending + await flushMicrotasks(); + jest.advanceTimersByTime(30000); // second tick should be skipped + await flushMicrotasks(); + expect(runOfflineWatchdog).toHaveBeenCalledTimes(1); + + resolvePending?.(); + await flushMicrotasks(); + + jest.advanceTimersByTime(30000); // running reset, a new tick can run + await flushMicrotasks(); + expect(runOfflineWatchdog).toHaveBeenCalledTimes(2); + service.onModuleDestroy(); + }); +}); diff --git a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts index 10a5a0c..dd36bd8 100644 --- a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts @@ -281,6 +281,101 @@ describe('QqbotNapcatContainerService', () => { ); }); + it('injects ACCOUNT env (NapCat -q quick login) only when a selfId is provided', () => { + const service = new QqbotNapcatContainerService( + { get: jest.fn().mockReturnValue('') } as any, + {} as any, + {} as any, + new ToolsService(), + ) as any; + + const baseInput = { + dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-test', + image: 'mlikiowa/napcat-docker:latest', + name: 'kt-qqbot-napcat-test', + port: 6100, + reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse', + token: 'token-test', + }; + + const withAccount = service.buildRemoteCreateScript({ + ...baseInput, + account: '2354598417', + }); + expect(withAccount).toContain("ACCOUNT='2354598417'"); + expect(withAccount).toContain('-e ACCOUNT="$ACCOUNT"'); + + const withoutAccount = service.buildRemoteCreateScript(baseInput); + expect(withoutAccount).not.toContain('-e ACCOUNT="$ACCOUNT"'); + expect(withoutAccount).not.toMatch(/^ACCOUNT=/m); + }); + + it('skips docker pull when recreating in place for quick login', () => { + const service = new QqbotNapcatContainerService( + { get: jest.fn().mockReturnValue('') } as any, + {} as any, + {} as any, + new ToolsService(), + ) as any; + + const baseInput = { + dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-test', + image: 'mlikiowa/napcat-docker:latest', + name: 'kt-qqbot-napcat-test', + port: 6100, + reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse', + token: 'token-test', + }; + + expect(service.buildRemoteCreateScript(baseInput)).toContain('docker pull'); + expect( + service.buildRemoteCreateScript({ ...baseInput, skipPull: true }), + ).not.toContain('docker pull'); + }); + + it('skips quick-login recreate when the container already carries ACCOUNT', async () => { + const containerRepository = { + createQueryBuilder: jest.fn(() => ({ + addSelect: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ + dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-x', + id: 'container-1', + image: 'mlikiowa/napcat-docker:latest', + name: 'kt-qqbot-napcat-x', + reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse', + webuiPort: 6100, + webuiToken: 'token-x', + }), + where: jest.fn().mockReturnThis(), + })), + update: jest.fn(), + }; + const service = new QqbotNapcatContainerService( + { + get: jest.fn((key: string) => + key === 'QQBOT_NAPCAT_CONTAINER_MODE' ? 'ssh' : '', + ), + } as any, + containerRepository as any, + {} as any, + new ToolsService(), + ) as any; + service.runProcess = jest + .fn() + .mockResolvedValue({ stderr: '', stdout: 'ACCOUNT=2354598417\nPATH=/x' }); + + const recreated = await service.ensureRuntimeQuickLogin( + { id: 'container-1', name: 'kt-qqbot-napcat-x' }, + '2354598417', + ); + + expect(recreated).toBe(false); + // 仅做了 inspect 检查,没有触发任何重建(rm/run)。 + expect(service.runProcess).toHaveBeenCalledTimes(1); + expect(containerRepository.update).not.toHaveBeenCalled(); + }); + it('requires an explicit NapCat image when creating a managed container', async () => { const containerRepository = { find: jest.fn().mockResolvedValue([]),