feat: NapCat 启用 -q 快速登录与离线看门狗

- 容器为已知 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 <noreply@anthropic.com>
This commit is contained in:
sunlei 2026-06-12 14:50:08 +08:00
parent 4d8e8c3bbc
commit bb6d0263e7
8 changed files with 400 additions and 5 deletions

View File

@ -80,6 +80,10 @@ QQBOT_NAPCAT_PORT_START=6100
QQBOT_NAPCAT_PORT_END=6199 QQBOT_NAPCAT_PORT_END=6199
QQBOT_NAPCAT_BASE_URL_TEMPLATE=http://192.168.31.224:{port} 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 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_MAIN_SERVER=cn
BANGDREAM_TSUGU_DISPLAYED_SERVERS=cn,jp BANGDREAM_TSUGU_DISPLAYED_SERVERS=cn,jp

View File

@ -137,6 +137,8 @@ pnpm exec jest --runInBand --runTestsByPath test/path/to/file.spec.ts
- QQBot 复读机默认阈值为 4同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。 - QQBot 复读机默认阈值为 4同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表日志检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误。 - QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表日志检测带近期缓存和短超时,账号连接时间或心跳晚于容器检测时间时以账号在线态为准,最新日志为在线时清空容器旧离线错误。
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。 - NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
- NapCat 容器为已知 `selfId` 创建/重建时会注入 `ACCOUNT` 环境变量启用 `-q` 快速登录:容器重启(崩溃/重启策略/宿主重启)能从持久化会话免扫码自动重登;硬踢 `登录已失效` 会话作废仍需扫码。已绑定但缺少 `ACCOUNT` 的旧容器在下一次「更新登录」时原地重建一次补齐(保留 QQ 数据卷),`docker inspect` 已带 `ACCOUNT` 则跳过,重建失败不阻断登录。
- NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000``QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,复用既有离线检测与 `super` 站内信告警,使掉线/被踢无需管理员打开列表页即可及时发现;看门狗只做检测告警、不重建容器,避免与 NapCat 自身重连竞争产生设备登录抖动。
- BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。 - BangDream 当前源码根目录是 `src/qqbot/plugins/bangDream`;不要恢复旧 `tsugu` 层级或旧大桶目录。
- BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。 - BangDream 在线命令以 `registry/operation-registry.ts` 为单一来源,新增命令必须同步 SQL/在线命令表并跑 registry/command-SQL 测试。
- BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。 - BangDream event stage 大图必须保持分页拆图行为,线上 smoke 关注 `imageCount=5`,避免大 canvas OOM 回归。

View File

@ -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) { async markOffline(selfId: string, lastError?: string) {
const payload: Partial<QqbotAccount> = { const payload: Partial<QqbotAccount> = {
connectStatus: 'offline', connectStatus: 'offline',

View File

@ -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<typeof setInterval>;
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<string>('QQBOT_NAPCAT_WATCHDOG_ENABLED') ?? 'true'
}`
.trim()
.toLowerCase();
return value !== 'false' && value !== '0' && value !== 'off';
}
private getIntervalMs() {
const value = Number(
this.configService.get<string>('QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS') ||
DEFAULT_INTERVAL_MS,
);
if (!Number.isFinite(value) || value < MIN_INTERVAL_MS) {
return DEFAULT_INTERVAL_MS;
}
return value;
}
}

View File

@ -42,11 +42,92 @@ export class QqbotNapcatContainerService {
} }
const existing = await this.getPrimaryRuntime(account.id); 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); 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) { async findRuntimeById(containerId?: string) {
if (!containerId) return this.getLegacyRuntime(); if (!containerId) return this.getLegacyRuntime();
@ -502,6 +583,7 @@ docker logs --tail 300 "$NAME" 2>&1 || true
try { try {
await this.createRemoteDockerContainer({ await this.createRemoteDockerContainer({
account: selfId,
dataDir, dataDir,
image, image,
name, name,
@ -538,11 +620,13 @@ docker logs --tail 300 "$NAME" 2>&1 || true
} }
private async createRemoteDockerContainer(input: { private async createRemoteDockerContainer(input: {
account?: string;
dataDir: string; dataDir: string;
image: string; image: string;
name: string; name: string;
port: number; port: number;
reverseWsUrl: string; reverseWsUrl: string;
skipPull?: boolean;
token: string; token: string;
}) { }) {
const script = this.buildRemoteCreateScript(input); const script = this.buildRemoteCreateScript(input);
@ -550,11 +634,13 @@ docker logs --tail 300 "$NAME" 2>&1 || true
} }
private buildRemoteCreateScript(input: { private buildRemoteCreateScript(input: {
account?: string;
dataDir: string; dataDir: string;
image: string; image: string;
name: string; name: string;
port: number; port: number;
reverseWsUrl: string; reverseWsUrl: string;
skipPull?: boolean;
token: string; token: string;
}) { }) {
const dataDir = this.sh(input.dataDir); 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 name = this.sh(input.name);
const reverseWsUrl = this.sh(input.reverseWsUrl); const reverseWsUrl = this.sh(input.reverseWsUrl);
const token = this.sh(input.token); const token = this.sh(input.token);
const account = `${input.account || ''}`.trim();
const 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 ` return `
set -eu set -eu
@ -571,7 +661,7 @@ NAME=${name}
PORT=${input.port} PORT=${input.port}
REVERSE_WS_URL=${reverseWsUrl} REVERSE_WS_URL=${reverseWsUrl}
WEBUI_TOKEN=${token} WEBUI_TOKEN=${token}
${accountHeader}
mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs"
chmod 700 "$DATA_DIR" chmod 700 "$DATA_DIR"
@ -610,15 +700,14 @@ cat > "$DATA_DIR/config/onebot11.json" <<EOF
} }
EOF EOF
docker pull "$IMAGE" >/dev/null ${pullCmd}docker rm -f "$NAME" >/dev/null 2>&1 || true
docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run -d \\ docker run -d \\
--name "$NAME" \\ --name "$NAME" \\
--restart unless-stopped \\ --restart unless-stopped \\
-e NAPCAT_UID=0 \\ -e NAPCAT_UID=0 \\
-e NAPCAT_GID=0 \\ -e NAPCAT_GID=0 \\
-e WEBUI_TOKEN="$WEBUI_TOKEN" \\ -e WEBUI_TOKEN="$WEBUI_TOKEN" \\
-p "$PORT:6099" \\ ${accountRunFlag} -p "$PORT:6099" \\
-v "$DATA_DIR/QQ:/app/.config/QQ" \\ -v "$DATA_DIR/QQ:/app/.config/QQ" \\
-v "$DATA_DIR/config:/app/napcat/config" \\ -v "$DATA_DIR/config:/app/napcat/config" \\
-v "$DATA_DIR/plugins:/app/napcat/plugins" \\ -v "$DATA_DIR/plugins:/app/napcat/plugins" \\

View File

@ -8,6 +8,7 @@ import { QqbotAccountAbility } from './account/qqbot-account-ability.entity';
import { QqbotAccount } from './account/qqbot-account.entity'; import { QqbotAccount } from './account/qqbot-account.entity';
import { QqbotAccountService } from './account/qqbot-account.service'; import { QqbotAccountService } from './account/qqbot-account.service';
import { QqbotNapcatLoginService } from './account/qqbot-napcat-login.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 { QqbotCommandController } from './command/qqbot-command.controller';
import { QqbotCommand } from './command/qqbot-command.entity'; import { QqbotCommand } from './command/qqbot-command.entity';
import { QqbotCommandEngineService } from './command/qqbot-command-engine.service'; import { QqbotCommandEngineService } from './command/qqbot-command-engine.service';
@ -108,6 +109,7 @@ import { QqbotSendService } from './send/qqbot-send.service';
QqbotFflogsPluginService, QqbotFflogsPluginService,
QqbotMessageService, QqbotMessageService,
QqbotNapcatLoginService, QqbotNapcatLoginService,
QqbotNapcatWatchdogService,
QqbotNapcatContainerService, QqbotNapcatContainerService,
QqbotPermissionService, QqbotPermissionService,
QqbotEventPluginRegistryService, QqbotEventPluginRegistryService,

View File

@ -0,0 +1,102 @@
import { ConfigService } from '@nestjs/config';
import { QqbotNapcatWatchdogService } from '@/qqbot/account/qqbot-napcat-watchdog.service';
function buildService(
configValues: Record<string, string | undefined>,
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();
});
});

View File

@ -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 () => { it('requires an explicit NapCat image when creating a managed container', async () => {
const containerRepository = { const containerRepository = {
find: jest.fn().mockResolvedValue([]), find: jest.fn().mockResolvedValue([]),