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:
parent
4d8e8c3bbc
commit
bb6d0263e7
@ -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
|
||||||
|
|||||||
@ -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 回归。
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
82
src/qqbot/account/qqbot-napcat-watchdog.service.ts
Normal file
82
src/qqbot/account/qqbot-napcat-watchdog.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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" \\
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
102
test/qqbot/account/qqbot-napcat-watchdog.service.spec.ts
Normal file
102
test/qqbot/account/qqbot-napcat-watchdog.service.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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([]),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user