kt-template-online-api/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts

1939 lines
60 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import * as http from 'http';
import * as https from 'https';
import { spawn } from 'child_process';
import { createHash, randomBytes, randomUUID } from 'crypto';
import { Injectable, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { throwVbenError, ToolsService } from '@/common';
import { NapcatConfigWriterService } from '../../../application/runtime/napcat-config-writer.service';
import { NapcatLoginEventService } from '../../../application/runtime/napcat-login-event.service';
import { NapcatRuntimeProfileService } from '../../../application/runtime/napcat-runtime-profile.service';
import type { NapcatConfigFile } from '../../../domain/runtime/napcat-profile.types';
import {
toNapcatDockerDeviceOptions,
type NapcatDockerDeviceOptions,
} from './napcat-docker-device-options';
import { NapcatDeviceIdentityService } from '../device/napcat-device-identity.service';
import { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity';
import { NapcatAccountBinding } from '../../persistence/napcat-account-binding.entity';
import { NapcatContainer } from '../../persistence/napcat-container.entity';
import type { NapcatLoginEventKind } from '../../persistence/napcat-login-event.entity';
import type {
NapcatApiResponse,
NapcatCredential,
NapcatLoginStatus,
QqbotNapcatRuntime,
QqbotNapcatRuntimeLoginStatus,
QqbotNapcatRuntimeStatusSnapshot,
} from '@/modules/qqbot/core/contract/qqbot.types';
type NapcatLoginLogState = 'offline' | 'online' | 'unknown';
type NapcatLoginLogResult = {
offlineReason: string | null;
state: NapcatLoginLogState;
};
type NapcatLoginEnvOptions = {
clearLoginPassword?: boolean;
loginPassword?: string;
selfId?: string;
};
type NapcatLoginEnvUpdateResult = {
changed: boolean;
ok: boolean;
};
type NapcatAutoLoginResult = {
cleanupFailed?: boolean;
method?: 'password' | 'quick';
success: boolean;
};
@Injectable()
export class QqbotNapcatContainerService {
private readonly configWriterService: NapcatConfigWriterService;
private readonly runtimeProfileService: NapcatRuntimeProfileService;
/**
* 初始化 QqbotNapcatContainerService 实例。
* @param configService - Runtime configuration source for NAS SSH, Docker image, port pool, and profile defaults.
* @param containerRepository - Persistence adapter for NapCat container rows created or updated by runtime actions.
* @param bindingRepository - Persistence adapter that links QQBot accounts to their primary NapCat containers.
* @param toolsService - Shared helper for error extraction, text truncation, and bounded sleeps.
* @param deviceIdentityService - Device identity resolver that supplies stable hostname, MAC, data-dir, and machine-id values.
* @param runtimeProfileService - Runtime profile resolver used to generate Docker env and mount settings.
* @param configWriterService - Config writer used to generate NapCat and OneBot config files.
* @param loginEventService - Optional login-event recorder used by watchdog auto-login attempts.
*/
constructor(
private readonly configService: ConfigService,
@InjectRepository(NapcatContainer)
private readonly containerRepository: Repository<NapcatContainer>,
@InjectRepository(NapcatAccountBinding)
private readonly bindingRepository: Repository<NapcatAccountBinding>,
private readonly toolsService: ToolsService,
private readonly deviceIdentityService?: NapcatDeviceIdentityService,
runtimeProfileService?: NapcatRuntimeProfileService,
configWriterService?: NapcatConfigWriterService,
@Optional()
private readonly loginEventService?: NapcatLoginEventService,
) {
this.runtimeProfileService =
runtimeProfileService || new NapcatRuntimeProfileService(configService);
this.configWriterService =
configWriterService || new NapcatConfigWriterService(toolsService);
}
/**
* 执行 NapCat 登录运行态流程。
*/
async prepareCreateContainer() {
if (!this.isManagedMode()) {
return this.getLegacyRuntime();
}
return this.createManagedContainer();
}
/**
* 执行 NapCat 登录运行态流程。
* @param account - account 输入;使用 `id`、`selfId` 字段生成结果。
*/
async prepareAccountContainer(account: QqbotAccount) {
if (!this.isManagedMode()) {
return this.getLegacyRuntime();
}
const existing = await this.getPrimaryRuntime(account.id);
if (existing) {
await this.ensureRuntimeQuickLogin(existing, account.selfId);
return { ...existing, hasExistingPrimaryBinding: true };
}
const created = await this.createManagedContainer(
account.selfId,
undefined,
account.id,
);
return { ...created, hasExistingPrimaryBinding: false };
}
/**
* 让已绑定账号的容器带上 ACCOUNT 环境变量NapCat 的 -q 快速登录)。
* 仅在 ssh 托管模式下原地重建容器:保留 QQ 数据卷,因此随后的容器重启
* 能从持久化会话免扫码自动重登。硬踢(登录已失效)时会话作废,仍需扫码。
*/
async ensureRuntimeQuickLogin(runtime: QqbotNapcatRuntime, selfId?: string) {
return this.ensureRuntimeLoginEnv(runtime, { selfId });
}
/**
* 确保Runtime Login Env。
* @param runtime - runtime 输入;使用 `id` 字段生成结果。
* @param options - NapCat列表使用 `selfId`、`clearLoginPassword`、`loginPassword` 字段生成结果。
* @returns 异步完成后的 NapCat 登录运行态结果。
*/
async ensureRuntimeLoginEnv(
runtime: QqbotNapcatRuntime,
options: NapcatLoginEnvOptions,
): Promise<NapcatLoginEnvUpdateResult> {
if (this.getManagedMode() !== 'ssh' || !runtime.id) {
return { changed: false, ok: true };
}
const account = this.toolsService.toTrimmedString(options.selfId);
if (!account) {
return { changed: false, ok: false };
}
const container = await this.findContainerWithToken(runtime.id);
if (
!container ||
!container.name ||
!container.webuiPort ||
!container.webuiToken
) {
return { changed: false, ok: false };
}
if (
await this.runtimeMatchesLoginEnv(container.name, {
...options,
selfId: account,
})
) {
return { changed: false, ok: true };
}
try {
const deviceIdentity = await this.resolveRuntimeDeviceIdentity(
container,
account,
);
await this.createRemoteDockerContainer({
account,
dataDir:
deviceIdentity?.dataDir ||
container.dataDir ||
`${this.getRootDir()}/${container.name}`,
deviceIdentity,
image: container.image,
loginPassword: options.clearLoginPassword
? undefined
: this.toolsService.toSecretText(options.loginPassword),
name: container.name,
port: container.webuiPort,
reverseWsUrl: container.reverseWsUrl || this.buildReverseWsUrl(),
skipPull: true,
token: container.webuiToken,
});
const verified = await this.runtimeMatchesLoginEnv(container.name, {
...options,
selfId: account,
});
await this.containerRepository.update(
{ id: container.id },
{
lastError: verified ? null : 'NapCat 运行态登录环境校验失败',
lastStartedAt: new Date(),
status: 'running',
},
);
return { changed: true, ok: verified };
} catch {
return { changed: false, ok: false };
}
}
/**
* 解析Runtime Device Identity。
* @param container - container 输入;使用 `id` 字段生成结果。
* @param selfId - 账号 ID定位本次读取、更新、删除或关联的账号。
* @returns NapCat 登录运行态转换后的值。
*/
private async resolveRuntimeDeviceIdentity(
container: NapcatContainer,
selfId: string,
): Promise<NapcatDockerDeviceOptions | undefined> {
if (
!this.deviceIdentityService ||
!container.id ||
typeof this.bindingRepository.findOne !== 'function'
) {
return undefined;
}
const binding = await this.bindingRepository.findOne({
where: {
bindStatus: 'bound',
containerId: container.id,
isDeleted: false,
isPrimary: true,
},
});
if (!binding?.accountId) return undefined;
const identity = await this.deviceIdentityService.resolveForAccount({
accountId: binding.accountId,
containerId: container.id,
selfId,
});
return toNapcatDockerDeviceOptions(identity);
}
/**
* 执行 NapCat 登录运行态流程。
* @param container - container 输入;使用 `id` 字段生成结果。
* @param options - NapCat列表使用 `selfId`、`loginPassword` 字段生成结果。
* @returns 异步完成后的 NapCat 登录运行态结果。
*/
async tryAutoLogin(
container: NapcatContainer,
options: NapcatLoginEnvOptions,
): Promise<NapcatAutoLoginResult> {
const selfId = this.toolsService.toTrimmedString(options.selfId);
if (!selfId || this.getManagedMode() !== 'ssh' || !container.id) {
return { success: false };
}
const runtimeContainer = await this.findContainerWithToken(container.id);
if (!runtimeContainer?.name) return { success: false };
const runtime = this.toRuntime(runtimeContainer);
const accountId = this.getAutoLoginAccountId(container, runtimeContainer);
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'quick_attempt',
eventStatus: 'pending',
evidence: { containerName: runtime.name },
});
const quickCleanup = await this.ensureRuntimeLoginEnv(runtime, {
clearLoginPassword: true,
selfId,
});
if (!quickCleanup.ok) {
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'quick_attempt',
eventStatus: 'failed',
evidence: {
containerName: runtime.name,
reason: 'login-env-cleanup-failed',
},
});
return { cleanupFailed: true, success: false };
}
const quickState = await this.restartAndDetectLoginState(runtime);
if (quickState.state === 'online') {
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'quick_attempt',
eventStatus: 'success',
evidence: { containerName: runtime.name },
});
return { method: 'quick', success: true };
}
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'quick_attempt',
eventStatus: 'failed',
evidence: {
containerName: runtime.name,
offlineReason: quickState.offlineReason,
},
});
const quickBlockReason = this.toAutoLoginSuspendedReason(
quickState.offlineReason,
);
if (quickBlockReason) {
await this.recordAutoLoginSuspended({
accountId,
container,
offlineReason: quickState.offlineReason,
reason: quickBlockReason,
});
return { success: false };
}
const loginPassword = this.toolsService.toSecretText(options.loginPassword);
if (!loginPassword) {
await this.recordAutoLoginSuspended({
accountId,
container,
offlineReason: quickState.offlineReason,
reason: 'recovery_suspended',
suspendedReason: 'login-password-missing',
});
return { success: false };
}
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'pending',
evidence: { containerName: runtime.name },
});
const passwordEnv = await this.ensureRuntimeLoginEnv(runtime, {
loginPassword,
selfId,
});
if (!passwordEnv.ok) {
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'failed',
evidence: {
containerName: runtime.name,
reason: 'login-env-prepare-failed',
},
});
await this.recordAutoLoginSuspended({
accountId,
container,
reason: 'recovery_suspended',
suspendedReason: 'login-env-prepare-failed',
});
return { success: false };
}
const passwordState = await this.restartAndDetectLoginState(runtime);
if (passwordState.state !== 'online') {
const cleaned = await this.ensureRuntimeLoginEnv(runtime, {
clearLoginPassword: true,
selfId,
});
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'failed',
evidence: {
cleanupOk: cleaned.ok,
containerName: runtime.name,
offlineReason: passwordState.offlineReason,
},
});
await this.recordAutoLoginSuspended({
accountId,
container,
offlineReason: passwordState.offlineReason,
reason:
this.toAutoLoginSuspendedReason(passwordState.offlineReason) ||
'recovery_suspended',
suspendedReason: 'password-auto-login-failed',
});
return cleaned.ok
? { success: false }
: { cleanupFailed: true, success: false };
}
const cleaned = await this.ensureRuntimeLoginEnv(runtime, {
clearLoginPassword: true,
selfId,
});
if (!cleaned.ok) {
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'failed',
evidence: {
containerName: runtime.name,
reason: 'login-env-cleanup-failed',
},
});
return { cleanupFailed: true, success: false };
}
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'success',
evidence: { containerName: runtime.name },
});
return { method: 'password', success: true };
}
/**
* Chooses the account id used by watchdog login-event records.
* @param inputContainer - Container row passed by account runtime, usually carrying account ownership.
* @param runtimeContainer - Container row reloaded with WebUI token for runtime actions.
* @returns Account id when it can be derived without looking up additional state.
*/
private getAutoLoginAccountId(
inputContainer: NapcatContainer,
runtimeContainer: NapcatContainer,
) {
return this.toolsService.toTrimmedString(
inputContainer.accountId || runtimeContainer.accountId,
);
}
/**
* Records a watchdog quick/password attempt when the owning account is known.
* @param input - Attempt kind, status, container, and non-secret runtime evidence.
*/
private async recordAutoLoginEvent(input: {
accountId: string;
container: NapcatContainer;
eventKind: 'password_attempt' | 'quick_attempt';
eventStatus: 'failed' | 'pending' | 'success';
evidence?: Record<string, unknown>;
}) {
if (!this.loginEventService || !input.accountId) return;
await this.loginEventService.record({
accountId: input.accountId,
containerId: input.container.id,
eventKind: input.eventKind,
eventSource: 'watchdog',
eventStatus: input.eventStatus,
evidence: input.evidence,
});
}
/**
* Records that watchdog auto-login must stop and wait for a human-visible login path.
* @param input - Account/container plus the reason automation cannot safely continue.
*/
private async recordAutoLoginSuspended(input: {
accountId: string;
container: NapcatContainer;
offlineReason?: null | string;
reason: NapcatLoginEventKind;
suspendedReason?: string;
}) {
if (!this.loginEventService || !input.accountId) return;
await this.loginEventService.recordSuspended({
accountId: input.accountId,
containerId: input.container.id,
evidence: {
offlineReason: input.offlineReason || undefined,
reason: input.suspendedReason || input.reason,
},
reason: input.reason,
source: 'watchdog',
});
}
/**
* Maps runtime login failure text to the first manual login path watchdog must not enter.
* @param offlineReason - NapCat log/status text produced after quick or password login attempt.
* @returns Blocking event kind when the next step requires captcha, new-device verification, or manual QR.
*/
private toAutoLoginSuspendedReason(
offlineReason?: null | string,
): NapcatLoginEventKind | null {
const reason = this.toolsService.toTrimmedString(offlineReason);
if (!reason) return null;
if (/captcha|proofWater|验证码|安全验证/i.test(reason)) {
return 'captcha_required';
}
if (/new.?device|新设备|设备验证/i.test(reason)) {
return 'new_device_required';
}
if (/qr.?code|二维码/i.test(reason)) {
return 'manual_qr_created';
}
return null;
}
/**
* 执行 NapCat 登录运行态流程。
* @param name - 名称文本;驱动 `this.runProcess()` 的 NapCat步骤。
* @param options - NapCat列表使用 `selfId`、`clearLoginPassword`、`loginPassword` 字段生成结果。
*/
private async runtimeMatchesLoginEnv(
name: string,
options: NapcatLoginEnvOptions,
) {
try {
const result = await this.runProcess(
'ssh',
[...this.getSshArgs(), 'sh -s'],
this.buildRemoteInspectEnvScript(name),
undefined,
this.getRuntimeCheckTimeoutMs(),
);
const env = this.parseDockerEnv(result.stdout);
if (
env.get('ACCOUNT') !== this.toolsService.toTrimmedString(options.selfId)
) {
return false;
}
const hasPassword =
env.has('NAPCAT_QUICK_PASSWORD') ||
env.has('NAPCAT_QUICK_PASSWORD_MD5');
if (options.clearLoginPassword) return !hasPassword;
const loginPassword = this.toolsService.toSecretText(
options.loginPassword,
);
if (loginPassword) {
return env.get('NAPCAT_QUICK_PASSWORD') === loginPassword;
}
return true;
} catch {
return false;
}
}
/**
* 创建 NapCat 登录运行态对象或配置。
* @param name - 名称文本;驱动 `this.sh()` 的 NapCat步骤。
*/
private buildRemoteInspectEnvScript(name: string) {
return `
set -eu
NAME=${this.sh(name)}
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
`;
}
/**
* 解析Docker Env。
* @param stdout - stdout 输入;影响 parseDockerEnv 的返回值。
*/
private parseDockerEnv(stdout: string) {
const env = new Map<string, string>();
stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => {
const index = line.indexOf('=');
if (index <= 0) return;
env.set(line.slice(0, index), line.slice(index + 1));
});
return env;
}
/**
* 查询 NapCat 登录运行态数据。
* @param containerId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
async findRuntimeById(containerId?: string) {
if (!containerId) return this.getLegacyRuntime();
const container = await this.containerRepository
.createQueryBuilder('container')
.addSelect('container.webuiToken')
.where('container.id = :containerId', { containerId })
.andWhere('container.isDeleted = :isDeleted', { isDeleted: false })
.getOne();
if (!container) {
throwVbenError('NapCat 容器不存在或已删除');
}
return this.toRuntime(container);
}
/**
* 执行 NapCat 登录运行态流程。
* @param accountId - 账号 ID定位本次读取、更新、删除或关联的账号。
* @param containerId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
async bindAccount(accountId: string, containerId?: string) {
if (!containerId) return;
await this.bindingRepository.update(
{ accountId, isDeleted: false },
{ isPrimary: false },
);
const existing = await this.bindingRepository.findOne({
where: {
accountId,
},
});
if (existing) {
await this.bindingRepository.update(
{ id: existing.id },
{
bindStatus: 'bound',
containerId,
isPrimary: true,
isDeleted: false,
lastLoginAt: new Date(),
remark: '',
},
);
await this.removeOtherAccountContainers(accountId, containerId);
return;
}
await this.bindingRepository.save(
this.bindingRepository.create({
accountId,
bindStatus: 'bound',
containerId,
isPrimary: true,
lastLoginAt: new Date(),
remark: '',
}),
);
await this.removeOtherAccountContainers(accountId, containerId);
}
/**
* 清理 NapCat 登录运行态状态。
* @param accountId - 账号 ID定位本次读取、更新、删除或关联的账号。
*/
async removeAccountContainers(accountId: string) {
const bindings = await this.bindingRepository.find({
where: {
accountId,
isDeleted: false,
},
});
if (bindings.length <= 0) return { deletedContainers: 0 };
let deletedContainers = 0;
for (const binding of bindings) {
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) continue;
const deleted = await this.removeContainer(binding.containerId);
if (deleted) deletedContainers += 1;
}
await this.bindingRepository.update(
{ accountId, isDeleted: false },
{
bindStatus: 'disabled',
isDeleted: true,
isPrimary: false,
},
);
return { deletedContainers };
}
/**
* 清理 NapCat 登录运行态状态。
* @param containerId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
async removeUnboundContainer(containerId?: string) {
if (!containerId) return false;
const bindingCount = await this.bindingRepository.count({
where: {
containerId,
isDeleted: false,
},
});
if (bindingCount > 0) return false;
return this.removeContainer(containerId);
}
/**
* 执行 NapCat 登录运行态流程。
* @param runtime - runtime 输入;使用 `id`、`name` 字段生成结果。
*/
async restartRuntimeContainer(runtime: QqbotNapcatRuntime) {
if (this.getManagedMode() !== 'ssh' || !runtime.id || !runtime.name) {
return false;
}
await this.runProcess(
'ssh',
[...this.getSshArgs(), 'docker', 'restart', runtime.name],
'',
);
await this.containerRepository.update(
{ id: runtime.id },
{
lastError: null,
lastStartedAt: new Date(),
status: 'running',
},
);
return true;
}
/**
* 重置Runtime Login State。
* @param runtime - runtime 输入;使用 `id`、`name` 字段生成结果。
* @param onProgress - NapCat列表影响 resetRuntimeLoginState 的返回值。
*/
async resetRuntimeLoginState(
runtime: QqbotNapcatRuntime,
onProgress?: (step: string, message: string) => void,
) {
if (this.getManagedMode() !== 'ssh' || !runtime.id || !runtime.name) {
return false;
}
const container = await this.containerRepository.findOne({
where: {
id: runtime.id,
isDeleted: false,
},
});
if (!container) {
throwVbenError('NapCat 容器不存在或已删除');
}
const script = this.buildRemoteResetLoginStateScript(container);
await this.runProcess(
'ssh',
[...this.getSshArgs(), 'sh -s'],
script,
(line) => {
const matched = line.match(/^__KT_PROGRESS__:([^:]+):(.+)$/);
if (matched) onProgress?.(matched[1], matched[2]);
},
);
await this.containerRepository.update(
{ id: runtime.id },
{
lastError: null,
lastStartedAt: new Date(),
status: 'running',
},
);
return true;
}
/**
* 执行 NapCat 登录运行态流程。
* @param container - container 输入;使用 `name`、`id` 字段生成结果。
*/
async detectRuntimeOffline(container: NapcatContainer) {
if (this.getManagedMode() !== 'ssh' || !container.name) return null;
try {
const result = await this.runProcess(
'ssh',
[...this.getSshArgs(), 'sh -s'],
this.buildRemoteRecentLogsScript(container),
undefined,
this.getRuntimeCheckTimeoutMs(),
);
const loginState = this.extractLoginState(result.stdout);
await this.containerRepository.update(
{ id: container.id },
{
lastCheckedAt: new Date(),
...(loginState.state === 'offline'
? {
lastError: loginState.offlineReason
? this.toolsService.toColumnText(
loginState.offlineReason,
500,
)
: null,
}
: {}),
...(loginState.state === 'online' ? { lastError: null } : {}),
},
);
return loginState.offlineReason;
} catch (err) {
await this.containerRepository.update(
{ id: container.id },
{
lastCheckedAt: new Date(),
lastError: this.toolsService.toColumnText(
this.toolsService.getErrorMessage(err),
500,
),
},
);
return null;
}
}
/**
* 执行 NapCat 登录运行态流程。
* @param runtime - runtime 输入;使用 `name` 字段生成结果。
* @param sinceMs - NapCat列表驱动 `Number.isFinite()` 的 NapCat步骤。
*/
async detectRuntimeCaptchaUrl(
runtime: Pick<QqbotNapcatRuntime, 'name'>,
sinceMs?: number,
) {
if (this.getManagedMode() !== 'ssh' || !runtime.name) return null;
const since =
typeof sinceMs === 'number' && Number.isFinite(sinceMs)
? new Date(Math.max(0, sinceMs - 1000)).toISOString()
: '';
const script = since
? this.buildRemoteRecentLogsSinceScript(runtime.name, since)
: this.buildRemoteRecentLogsByNameScript(runtime.name);
try {
const result = await this.runProcess(
'ssh',
[...this.getSshArgs(), 'sh -s'],
script,
undefined,
this.getCaptchaLogReadTimeoutMs(),
);
return this.toolsService.extractNapcatCaptchaUrl(result.stdout) || null;
} catch {
return null;
}
}
/**
* 执行 NapCat 登录运行态流程。
* @param container - container 输入;使用 `status`、`lastError`、`baseUrl`、`webuiToken` 字段生成结果。
* @returns 异步完成后的 NapCat 登录运行态结果。
*/
async inspectRuntimeStatus(
container: NapcatContainer,
): Promise<QqbotNapcatRuntimeStatusSnapshot> {
const checkedAt = new Date();
const containerOnline = container.status === 'running';
if (!containerOnline) {
return {
checkedAt,
containerOnline,
lastError: container.lastError,
qqLoginMessage: null,
qqLoginStatus: 'offline',
webuiOnline: false,
};
}
if (!container.baseUrl || !container.webuiToken) {
const message = 'NapCat WebUI 配置缺失';
await this.containerRepository.update(
{ id: container.id },
{
lastCheckedAt: checkedAt,
lastError: message,
},
);
return {
checkedAt,
containerOnline,
lastError: message,
qqLoginMessage: null,
qqLoginStatus: 'unknown',
webuiOnline: false,
};
}
try {
const runtime = this.toRuntime(container);
const credential = await this.getNapcatCredential(runtime);
const status = await this.requestNapcat<NapcatLoginStatus>(
runtime,
'/api/QQLogin/CheckLoginStatus',
{},
credential,
);
const snapshot = this.toRuntimeStatusSnapshot(
status,
containerOnline,
checkedAt,
);
await this.containerRepository.update(
{ id: container.id },
{
lastCheckedAt: checkedAt,
lastError:
snapshot.qqLoginStatus === 'online'
? null
: this.toolsService.toColumnText(
snapshot.qqLoginMessage || snapshot.lastError || '',
500,
) || null,
},
);
return snapshot;
} catch (err) {
const message = this.toolsService.toColumnText(
this.toolsService.getErrorMessage(err),
500,
);
await this.containerRepository.update(
{ id: container.id },
{
lastCheckedAt: checkedAt,
lastError: message,
},
);
return {
checkedAt,
containerOnline,
lastError: message,
qqLoginMessage: null,
qqLoginStatus: 'unknown',
webuiOnline: false,
};
}
}
/**
* 清理 NapCat 登录运行态状态。
* @param containerId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
private async removeContainer(containerId: string) {
const container = await this.containerRepository.findOne({
where: {
id: containerId,
isDeleted: false,
},
});
if (!container) return false;
if (this.getManagedMode() === 'ssh') {
await this.removeRemoteDockerContainer(container);
}
await this.containerRepository.update(
{ id: container.id },
{
isDeleted: true,
lastError: null,
status: 'stopped',
},
);
return true;
}
/**
* 清理 NapCat 登录运行态状态。
* @param accountId - 账号 ID定位本次读取、更新、删除或关联的账号。
* @param keepContainerId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
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,
},
);
}
}
/**
* 清理 NapCat 登录运行态状态。
* @param container - container 输入;驱动 `this.buildRemoteRemoveScript()` 的 NapCat步骤。
*/
private async removeRemoteDockerContainer(container: NapcatContainer) {
const script = this.buildRemoteRemoveScript(container);
await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script);
}
/**
* 创建 NapCat 登录运行态对象或配置。
* @param container - container 输入;使用 `dataDir`、`name` 字段生成结果。
*/
private buildRemoteRemoveScript(container: NapcatContainer) {
const dataDir = this.sh(container.dataDir || '');
const name = this.sh(container.name);
const rootDir = this.sh(this.getRootDir());
return `
set -eu
NAME=${name}
DATA_DIR=${dataDir}
ROOT_DIR=${rootDir}
docker rm -f "$NAME" >/dev/null 2>&1 || true
if [ -n "$DATA_DIR" ] && [ "$DATA_DIR" != "/" ]; then
case "$DATA_DIR" in
"$ROOT_DIR"/*)
rm -rf "$DATA_DIR"
;;
*)
echo "skip unsafe data dir: $DATA_DIR" >&2
;;
esac
fi
`;
}
/**
* 创建 NapCat 登录运行态对象或配置。
* @param container - container 输入;使用 `dataDir`、`name` 字段生成结果。
*/
private buildRemoteResetLoginStateScript(container: NapcatContainer) {
const dataDir = this.sh(container.dataDir || '');
const name = this.sh(container.name);
const rootDir = this.sh(this.getRootDir());
return `
set -eu
NAME=${name}
DATA_DIR=${dataDir}
ROOT_DIR=${rootDir}
if [ -z "$DATA_DIR" ] || [ "$DATA_DIR" = "/" ]; then
echo "unsafe empty data dir" >&2
exit 1
fi
case "$DATA_DIR" in
"$ROOT_DIR"/*)
;;
*)
echo "skip unsafe data dir: $DATA_DIR" >&2
exit 1
;;
esac
docker exec "$NAME" rm -f /app/napcat/cache/qrcode.png >/dev/null 2>&1 || true
echo "__KT_PROGRESS__:container-stop:正在停止 NapCat 容器"
docker stop "$NAME" >/dev/null 2>&1 || true
echo "__KT_PROGRESS__:login-data-clean:正在清理旧 QQ 登录态"
mkdir -p "$DATA_DIR/QQ"
find "$DATA_DIR/QQ" -mindepth 1 -maxdepth 1 -exec rm -rf -- {} +
echo "__KT_PROGRESS__:container-start:正在启动 NapCat 容器"
docker start "$NAME" >/dev/null
echo "__KT_PROGRESS__:container-started:NapCat 容器已启动"
`;
}
/**
* 创建 NapCat 登录运行态对象或配置。
* @param container - container 输入;使用 `name` 字段生成结果。
*/
private buildRemoteRecentLogsScript(container: NapcatContainer) {
return this.buildRemoteRecentLogsByNameScript(container.name);
}
/**
* 创建 NapCat 登录运行态对象或配置。
* @param name - 名称文本;驱动 `this.sh()` 的 NapCat步骤。
*/
private buildRemoteRecentLogsByNameScript(name: string) {
return `
set -eu
NAME=${this.sh(name)}
docker logs --tail 300 "$NAME" 2>&1 || true
`;
}
/**
* 创建 NapCat 登录运行态对象或配置。
* @param name - 名称文本;驱动 `this.sh()` 的 NapCat步骤。
* @param since - since 输入;驱动 `this.sh()` 的 NapCat步骤。
*/
private buildRemoteRecentLogsSinceScript(name: string, since: string) {
return `
set -eu
NAME=${this.sh(name)}
SINCE=${this.sh(since)}
docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
`;
}
/**
* 执行 NapCat 登录运行态流程。
* @param runtime - runtime 输入;使用 `name` 字段生成结果。
*/
private async restartAndDetectLoginState(runtime: QqbotNapcatRuntime) {
const since = new Date(Date.now() - 1000).toISOString();
await this.restartRuntimeContainer(runtime);
const attempts = Number(
this.getConfig('QQBOT_NAPCAT_AUTO_LOGIN_RETRIES', '10'),
);
const delayMs = Number(
this.getConfig('QQBOT_NAPCAT_AUTO_LOGIN_INTERVAL_MS', '2000'),
);
let latest: NapcatLoginLogResult = {
offlineReason: null,
state: 'unknown',
};
for (let index = 0; index < attempts; index += 1) {
await this.toolsService.sleep(Number.isFinite(delayMs) ? delayMs : 2000);
latest = await this.detectRuntimeLoginStateSince(runtime.name, since);
if (latest.state !== 'unknown') return latest;
}
return latest;
}
/**
* 执行 NapCat 登录运行态流程。
* @param name - 名称文本;驱动 `this.runProcess()` 的 NapCat步骤。
* @param since - since 输入;驱动 `this.runProcess()` 的 NapCat步骤。
*/
private async detectRuntimeLoginStateSince(name: string, since: string) {
const result = await this.runProcess(
'ssh',
[...this.getSshArgs(), 'sh -s'],
this.buildRemoteRecentLogsSinceScript(name, since),
undefined,
this.getRuntimeCheckTimeoutMs(),
);
return this.extractLoginState(result.stdout);
}
/**
* 执行 NapCat 登录运行态流程。
* @param logs - NapCat列表影响 extractLoginState 的返回值。
* @returns NapCat 登录运行态产出的 NapcatLoginLogResult。
*/
private extractLoginState(logs: string): NapcatLoginLogResult {
const lines = logs
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.reverse();
const matchedLine = lines.find(
(line) =>
this.toolsService.isNapcatOfflineLoginMessage(line) ||
this.toolsService.isNapcatOnlineLoginMessage(line),
);
if (!matchedLine) {
return {
offlineReason: null,
state: 'unknown',
};
}
if (this.toolsService.isNapcatOnlineLoginMessage(matchedLine)) {
return {
offlineReason: null,
state: 'online',
};
}
const message = matchedLine
.replace(/^\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s+\[[^\]]+\]\s+/, '')
.replace(/^Mirror\s*\|\s*/, '')
.replace(/\[KickedOffLine]/gi, '')
.replace(/\[下线通知]/g, '')
.trim();
const offlineReason = this.toolsService.isNapcatOfflineFlagMessage(
matchedLine,
)
? 'NapCat 账号状态变更为离线'
: message || 'NapCat 账号状态变更为离线';
return {
offlineReason,
state: 'offline',
};
}
/**
* 查询 NapCat 登录运行态数据。
* @param accountId - 账号 ID定位本次读取、更新、删除或关联的账号。
*/
private async getPrimaryRuntime(accountId: string) {
const binding = await this.bindingRepository.findOne({
order: {
updateTime: 'DESC',
},
where: {
accountId,
bindStatus: 'bound',
isDeleted: false,
isPrimary: true,
},
});
if (!binding) return null;
const container = await this.containerRepository
.createQueryBuilder('container')
.addSelect('container.webuiToken')
.where('container.id = :containerId', {
containerId: binding.containerId,
})
.andWhere('container.isDeleted = :isDeleted', { isDeleted: false })
.andWhere('container.status != :status', { status: 'error' })
.getOne();
return container ? this.toRuntime(container) : null;
}
/**
* 查询 NapCat 登录运行态数据。
* @param containerId - NapCat ID定位本次读取、更新、删除或关联的NapCat。
*/
private async findContainerWithToken(containerId: string) {
return this.containerRepository
.createQueryBuilder('container')
.addSelect('container.webuiToken')
.where('container.id = :containerId', { containerId })
.andWhere('container.isDeleted = :isDeleted', { isDeleted: false })
.getOne();
}
/**
* 创建 NapCat 登录运行态对象或配置。
* @param selfId - 账号 ID定位本次读取、更新、删除或关联的账号。
* @param loginPassword - loginPassword 输入;生成 NapCat对象。
* @param accountId - 账号 ID定位本次读取、更新、删除或关联的账号。
*/
private async createManagedContainer(
selfId?: string,
loginPassword?: string,
accountId?: string,
) {
const mode = this.getManagedMode();
if (mode !== 'ssh') {
throwVbenError('当前仅支持通过 SSH 创建 NapCat 容器');
}
const image = this.getConfig('QQBOT_NAPCAT_IMAGE');
if (!image) {
throwVbenError('NapCat 镜像未配置,请先设置 QQBOT_NAPCAT_IMAGE');
}
const port = await this.allocatePort();
const name = this.buildContainerName(selfId);
const token = randomBytes(24).toString('hex');
let dataDir = `${this.getRootDir()}/${name}`;
let deviceIdentity: NapcatDockerDeviceOptions | undefined;
if (accountId && this.deviceIdentityService) {
const identity = await this.deviceIdentityService.resolveForAccount({
accountId,
selfId,
});
dataDir = identity.dataDir || dataDir;
deviceIdentity = toNapcatDockerDeviceOptions(identity);
}
const baseUrl = this.buildBaseUrl(port);
const reverseWsUrl = this.buildReverseWsUrl();
const container = await this.containerRepository.save(
this.containerRepository.create({
baseUrl,
accountId: accountId || null,
dataDir,
image,
isDeleted: false,
lastError: null,
name,
remark: '',
reverseWsUrl,
status: 'creating',
webuiPort: port,
webuiToken: token,
}),
);
try {
await this.createRemoteDockerContainer({
account: selfId,
dataDir,
deviceIdentity,
image,
loginPassword,
name,
port,
reverseWsUrl,
token,
});
await this.containerRepository.update(
{ id: container.id },
{
lastError: null,
lastStartedAt: new Date(),
status: 'running',
},
);
if (accountId && this.deviceIdentityService) {
await this.deviceIdentityService.resolveForAccount({
accountId,
containerId: container.id,
selfId,
});
}
return {
baseUrl,
dataDir,
id: container.id,
name,
webuiPort: port,
webuiToken: token,
};
} catch (err) {
const message = this.toolsService.getErrorMessage(err);
await this.containerRepository.update(
{ id: container.id },
{
lastError: this.toolsService.toColumnText(message, 500),
status: 'error',
},
);
throwVbenError(`创建 NapCat 容器失败:${message}`);
}
}
/**
* 创建 NapCat 登录运行态对象或配置。
* @param input - input 输入;驱动 `this.buildRemoteCreateScript()` 的 NapCat步骤。
*/
private async createRemoteDockerContainer(input: {
account?: string;
dataDir: string;
deviceIdentity?: NapcatDockerDeviceOptions;
image: string;
loginPassword?: string;
name: string;
port: number;
reverseWsUrl: string;
skipPull?: boolean;
token: string;
}) {
const script = this.buildRemoteCreateScript(input);
await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script);
}
/**
* Builds the remote shell script that creates or recreates a managed NapCat container.
* @param input - Container image, account, data-dir, device identity, and reverse-WS values that become Docker flags and config files.
*/
private buildRemoteCreateScript(input: {
account?: string;
containerId?: string;
dataDir: string;
deviceIdentity?: NapcatDockerDeviceOptions;
image: string;
loginPassword?: string;
name: string;
port: number;
reverseWsUrl: string;
skipPull?: boolean;
token: string;
}) {
const dataDir = this.sh(input.dataDir);
const image = this.sh(input.image);
const name = this.sh(input.name);
const reverseWsUrl = this.sh(input.reverseWsUrl);
const token = this.sh(input.token);
const account = `${input.account || ''}`.trim();
const loginPassword = this.toolsService.toSecretText(input.loginPassword);
const runtimeProfile = this.runtimeProfileService.resolveRuntimeProfile({
accountId: account || input.name,
containerId: input.containerId,
dataDir: input.dataDir,
deviceIdentityId: input.deviceIdentity?.deviceIdentityId,
});
const configBundle = this.configWriterService.buildConfigFiles({
account,
reverseWsUrl: '$REVERSE_WS_URL',
token: '$WEBUI_TOKEN',
});
const configWriteScript = this.renderConfigFiles(configBundle.files);
const accountHeader = account ? `ACCOUNT=${this.sh(account)}\n` : '';
const accountRunFlag = account ? ' -e ACCOUNT="$ACCOUNT" \\\n' : '';
const passwordHeader = loginPassword
? `NAPCAT_QUICK_PASSWORD=${this.sh(loginPassword)}\n`
: '';
const passwordRunFlag = loginPassword
? ' -e NAPCAT_QUICK_PASSWORD="$NAPCAT_QUICK_PASSWORD" \\\n'
: '';
const pullCmd = input.skipPull ? '' : 'docker pull "$IMAGE" >/dev/null\n';
const deviceHeader = input.deviceIdentity
? [
`NAPCAT_HOSTNAME=${this.sh(input.deviceIdentity.hostname)}`,
`NAPCAT_MAC_ADDRESS=${this.sh(input.deviceIdentity.macAddress)}`,
`MACHINE_ID_PATH=${this.sh(input.deviceIdentity.machineIdPath)}`,
`DEVICE_ENV_PATH=${this.sh(input.deviceIdentity.deviceEnvPath)}`,
].join('\n') + '\n'
: '';
const devicePrepareScript = input.deviceIdentity
? `
cat > "$DEVICE_ENV_PATH" <<EOF
NAPCAT_HOSTNAME=$NAPCAT_HOSTNAME
NAPCAT_MAC_ADDRESS=$NAPCAT_MAC_ADDRESS
MACHINE_ID_PATH=$MACHINE_ID_PATH
EOF
if [ ! -s "$MACHINE_ID_PATH" ]; then
printf '%s' "$NAME" | sha256sum | cut -c 1-32 > "$MACHINE_ID_PATH"
fi
`
: '';
const deviceRunFlags = input.deviceIdentity
? ' --hostname "$NAPCAT_HOSTNAME" \\\n --mac-address "$NAPCAT_MAC_ADDRESS" \\\n -v "$MACHINE_ID_PATH:/etc/machine-id:ro" \\\n'
: '';
return `
set -eu
DATA_DIR=${dataDir}
IMAGE=${image}
NAME=${name}
PORT=${input.port}
REVERSE_WS_URL=${reverseWsUrl}
WEBUI_TOKEN=${token}
NAPCAT_UID=${this.sh(`${runtimeProfile.runtimeUid}`)}
NAPCAT_GID=${this.sh(`${runtimeProfile.runtimeGid}`)}
NAPCAT_SHM_SIZE=${this.sh(runtimeProfile.shmSize)}
${accountHeader}
${passwordHeader}
${deviceHeader}
mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share"
chmod 700 "$DATA_DIR"
${devicePrepareScript}
${configWriteScript}
${pullCmd}docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run -d \\
--name "$NAME" \\
--restart unless-stopped \\
--init \\
--shm-size "$NAPCAT_SHM_SIZE" \\
-e NAPCAT_UID="$NAPCAT_UID" \\
-e NAPCAT_GID="$NAPCAT_GID" \\
-e WEBUI_TOKEN="$WEBUI_TOKEN" \\
-e LANG=${runtimeProfile.locale} \\
-e LC_ALL=${runtimeProfile.locale} \\
-e LANGUAGE=zh_CN:zh \\
-e TZ=${runtimeProfile.timezone} \\
-e HOME=/app \\
-e XDG_CONFIG_HOME=${runtimeProfile.xdgConfigHome} \\
-e XDG_CACHE_HOME=${runtimeProfile.xdgCacheHome} \\
-e XDG_DATA_HOME=${runtimeProfile.xdgDataHome} \\
-e XDG_RUNTIME_DIR=/tmp/runtime-napcat \\
${accountRunFlag}${passwordRunFlag}${deviceRunFlags} -p "$PORT:6099" \\
-v "$DATA_DIR/QQ:/app/.config/QQ" \\
-v "$DATA_DIR/config:/app/napcat/config" \\
-v "$DATA_DIR/plugins:/app/napcat/plugins" \\
-v "$DATA_DIR/cache:/app/.cache" \\
-v "$DATA_DIR/local-share:/app/.local/share" \\
-v "$DATA_DIR/logs:/app/napcat/logs" \\
"$IMAGE" >/dev/null
`;
}
/**
* Renders NapCat config files as shell here-doc writes under the account config directory.
* @param files - Config files generated by `NapcatConfigWriterService` for this container.
* @returns Shell fragment that writes each config file before Docker starts.
*/
private renderConfigFiles(files: NapcatConfigFile[]) {
return files
.map((file) => {
return `cat > "$DATA_DIR/config/${file.path}" <<EOF
${file.content}EOF`;
})
.join('\n\n');
}
/**
* 执行 NapCat 登录运行态流程。
*/
private async allocatePort() {
const start = Number(this.getConfig('QQBOT_NAPCAT_PORT_START', '6100'));
const end = Number(this.getConfig('QQBOT_NAPCAT_PORT_END', '6199'));
if (!Number.isFinite(start) || !Number.isFinite(end) || start > end) {
throwVbenError('NapCat 端口池配置错误');
}
const containers = await this.containerRepository.find({
select: ['webuiPort'],
where: {
isDeleted: false,
},
});
const used = new Set(
containers
.map((container) => container.webuiPort)
.filter((port): port is number => typeof port === 'number'),
);
for (let port = start; port <= end; port += 1) {
if (!used.has(port)) return port;
}
throwVbenError('NapCat 端口池已用完');
}
/**
* 创建 NapCat 登录运行态对象或配置。
* @param selfId - 账号 ID定位本次读取、更新、删除或关联的账号。
*/
private buildContainerName(selfId?: string) {
const prefix = this.getConfig(
'QQBOT_NAPCAT_CONTAINER_PREFIX',
'kt-qqbot-napcat',
);
const suffix = `${selfId || randomUUID().slice(0, 8)}`
.replace(/[^a-zA-Z0-9_.-]/g, '-')
.toLowerCase();
return `${prefix}-${suffix}`.replace(/-+/g, '-').slice(0, 120);
}
/**
* 创建 NapCat 登录运行态对象或配置。
* @param port - port 输入;生成 NapCat对象。
*/
private buildBaseUrl(port: number) {
const template = this.getConfig('QQBOT_NAPCAT_BASE_URL_TEMPLATE', '');
if (template) {
return template.replace('{port}', `${port}`);
}
const host = this.getConfig('QQBOT_NAPCAT_HOST', '127.0.0.1');
return `http://${host}:${port}`;
}
/**
* 创建 NapCat 登录运行态对象或配置。
*/
private buildReverseWsUrl() {
const configured =
this.getConfig('QQBOT_NAPCAT_REVERSE_WS_URL', '') ||
this.getConfig('QQBOT_NAPCAT_REVERSE_WS_BASE', '');
const path = this.getConfig(
'QQBOT_REVERSE_WS_PATH',
'/qqbot/onebot/reverse',
);
const base = configured || `ws://127.0.0.1:48085${path}`;
const token = this.getConfig('QQBOT_REVERSE_WS_TOKEN', '');
if (!token || base.includes('token=')) return base;
const joiner = base.includes('?') ? '&' : '?';
return `${base}${joiner}token=${encodeURIComponent(token)}`;
}
/**
* 查询 NapCat 登录运行态数据。
* @returns NapCat 登录运行态查询结果。
*/
private getLegacyRuntime(): QqbotNapcatRuntime {
return {
baseUrl: this.normalizeBaseUrl(
this.getConfig('NAPCAT_WEBUI_BASE_URL', '') ||
this.getConfig('QQBOT_NAPCAT_WEBUI_URL', ''),
),
name: 'kt-qqbot-napcat',
webuiToken:
this.getConfig('NAPCAT_WEBUI_TOKEN', '') ||
this.getConfig('QQBOT_NAPCAT_WEBUI_TOKEN', ''),
};
}
/**
* 执行 NapCat 登录运行态流程。
* @param container - container 输入;使用 `baseUrl`、`id`、`name`、`webuiPort` 字段生成结果。
* @returns NapCat 登录运行态产出的 QqbotNapcatRuntime。
*/
private toRuntime(container: NapcatContainer): QqbotNapcatRuntime {
return {
baseUrl: this.normalizeBaseUrl(container.baseUrl),
id: container.id,
name: container.name,
webuiPort: container.webuiPort,
webuiToken: container.webuiToken,
};
}
/**
* 查询 NapCat 登录运行态数据。
* @param runtime - runtime 输入;使用 `webuiToken` 字段生成结果。
*/
private async getNapcatCredential(runtime: QqbotNapcatRuntime) {
const token = runtime.webuiToken || '';
const hash = createHash('sha256').update(`${token}.napcat`).digest('hex');
const data = await this.requestNapcat<NapcatCredential>(
runtime,
'/api/auth/login',
{ hash },
);
if (!data.Credential) {
throwVbenError('NapCat WebUI 登录失败');
}
return data.Credential;
}
/**
* 执行 NapCat 登录运行态流程。
* @param status - NapCat列表使用 `loginError` 字段生成结果。
* @param containerOnline - containerOnline 输入;影响 toRuntimeStatusSnapshot 的返回值。
* @param checkedAt - checkedAt 输入;影响 toRuntimeStatusSnapshot 的返回值。
* @returns NapCat 登录运行态产出的 QqbotNapcatRuntimeStatusSnapshot。
*/
private toRuntimeStatusSnapshot(
status: NapcatLoginStatus,
containerOnline: boolean,
checkedAt: Date,
): QqbotNapcatRuntimeStatusSnapshot {
const message = this.toolsService.toTrimmedString(status.loginError);
const qqLoginStatus = this.toQqLoginStatus(status, message);
return {
checkedAt,
containerOnline,
lastError: qqLoginStatus === 'online' ? null : message || null,
qqLoginMessage: qqLoginStatus === 'online' ? null : message || null,
qqLoginStatus,
webuiOnline: true,
};
}
/**
* 执行 NapCat 登录运行态流程。
* @param status - NapCat列表使用 `isLogin`、`qrcodeurl`、`isOffline` 字段生成结果。
* @param message - message 输入;计算 NapCat布尔判断。
* @returns NapCat 登录运行态产出的 QqbotNapcatRuntimeLoginStatus。
*/
private toQqLoginStatus(
status: NapcatLoginStatus,
message: string,
): QqbotNapcatRuntimeLoginStatus {
if (status.isLogin) return 'online';
if (
this.toolsService.isNapcatExpiredQrcodeStatus(status) ||
message.includes('二维码已过期')
) {
return 'qrcode_expired';
}
if (status.qrcodeurl) return 'qrcode_pending';
if (
status.isOffline ||
this.toolsService.isNapcatOfflineLoginMessage(message)
) {
return 'offline';
}
return 'unknown';
}
/**
* 执行 NapCat 登录运行态流程。
* @param runtime - runtime 输入;使用 `baseUrl` 字段生成结果。
* @param path - 路由或文件路径;驱动 `URL()` 的 NapCat步骤。
* @param body - 请求体 DTO承载 NapCat新增、更新、导入或执行字段。
* @param credential - credential 输入;影响 requestNapcat 的返回值。
* @returns 异步完成后的 NapCat 登录运行态结果。
*/
private requestNapcat<T>(
runtime: QqbotNapcatRuntime,
path: string,
body: Record<string, any> = {},
credential?: string,
): Promise<T> {
const target = new URL(path, runtime.baseUrl);
const payload = JSON.stringify(body);
const client = target.protocol === 'https:' ? https : http;
return new Promise<T>((resolve, reject) => {
const req = client.request(
{
headers: {
...(credential
? {
Authorization: `Bearer ${credential}`,
}
: {}),
'Content-Length': Buffer.byteLength(payload),
'Content-Type': 'application/json',
},
hostname: target.hostname,
method: 'POST',
path: `${target.pathname}${target.search}`,
port: target.port,
protocol: target.protocol,
timeout: this.getRuntimeCheckTimeoutMs(),
},
(res) => {
const chunks: Buffer[] = [];
res.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
res.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
let result: NapcatApiResponse<T>;
try {
result = raw ? JSON.parse(raw) : ({ code: -1 } as any);
} catch {
reject(new Error('NapCat 返回非 JSON 响应'));
return;
}
if (result.code !== 0) {
reject(new Error(result.message || 'NapCat 请求失败'));
return;
}
resolve(result.data as T);
});
},
);
req.on('error', reject);
req.on('timeout', () => {
req.destroy(new Error('NapCat 请求超时'));
});
req.write(payload);
req.end();
});
}
/**
* 转换 NapCat 登录运行态输入。
* @param value - 待转换值;影响 normalizeBaseUrl 的返回值。
*/
private normalizeBaseUrl(value: string) {
const baseUrl = `${value || ''}`.trim();
if (!baseUrl) {
throwVbenError('NapCat WebUI 地址未配置');
}
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
}
/**
* 查询 NapCat 登录运行态数据。
*/
private getRootDir() {
return this.getConfig(
'QQBOT_NAPCAT_ROOT',
'/vol1/docker/kt-qqbot/napcat-instances',
).replace(/\/+$/, '');
}
/**
* 判断 NapCat 登录运行态条件。
*/
private isManagedMode() {
return !!this.getManagedMode();
}
/**
* 查询 NapCat 登录运行态数据。
*/
private getManagedMode() {
return this.getConfig('QQBOT_NAPCAT_CONTAINER_MODE', '').toLowerCase();
}
/**
* 查询 NapCat 登录运行态数据。
*/
private getSshArgs() {
const target = this.getConfig('QQBOT_NAPCAT_SSH_TARGET', 'nas');
if (!target) {
throwVbenError('NapCat SSH 目标未配置');
}
const args: string[] = [
'-o',
'StrictHostKeyChecking=accept-new',
'-o',
'UserKnownHostsFile=/tmp/qqbot-napcat-known-hosts',
];
const port = this.getConfig('QQBOT_NAPCAT_SSH_PORT', '');
const keyPath = this.getConfig('QQBOT_NAPCAT_SSH_KEY_PATH', '');
if (port) args.push('-p', port);
if (keyPath) args.push('-i', keyPath);
args.push(target);
return args;
}
/**
* 查询 NapCat 登录运行态数据。
* @param key - 键名;限定 NapCat查询范围。
* @param defaultValue - defaultValue 输入;限定 NapCat查询范围。
*/
private getConfig(key: string, defaultValue = '') {
return `${this.configService.get<string>(key) || defaultValue}`.trim();
}
/**
* 查询 NapCat 登录运行态数据。
*/
private getProcessTimeoutMs() {
const timeoutMs = Number(
this.getConfig('QQBOT_NAPCAT_SSH_TIMEOUT_MS', '120000'),
);
return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120000;
}
/**
* 查询 NapCat 登录运行态数据。
*/
private getRuntimeCheckTimeoutMs() {
const timeoutMs = Number(
this.getConfig('QQBOT_NAPCAT_RUNTIME_CHECK_TIMEOUT_MS', '5000'),
);
return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 5000;
}
/**
* 查询 NapCat 登录运行态数据。
*/
private getCaptchaLogReadTimeoutMs() {
return Math.max(this.getRuntimeCheckTimeoutMs(), 15000);
}
/**
* 执行 NapCat 登录运行态流程。
* @param value - 待转换值;影响 sh 的返回值。
*/
private sh(value: string) {
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
}
/**
* 执行Process。
* @param command - command 输入;驱动 `spawn()` 的 NapCat步骤。
* @param args - NapCat列表驱动 `spawn()` 的 NapCat步骤。
* @param input - input 输入;驱动 `stdin.write()` 的 NapCat步骤。
* @param onStdoutLine - onStdoutLine 输入;驱动 `map()` 的 NapCat步骤。
* @param timeoutMs - NapCat列表影响 runProcess 的返回值。
*/
private runProcess(
command: string,
args: string[],
input: string,
onStdoutLine?: (line: string) => void,
timeoutMs = this.getProcessTimeoutMs(),
) {
return new Promise<{ stderr: string; stdout: string }>(
(resolve, reject) => {
const child = spawn(command, args, {
windowsHide: true,
});
let settled = false;
let stdout = '';
let stderr = '';
let stdoutLineBuffer = '';
const timer = setTimeout(() => {
if (settled) return;
settled = true;
child.kill('SIGTERM');
reject(new Error(`${command} timeout after ${timeoutMs}ms`));
}, timeoutMs);
/**
* 收束 NapCat 登录运行态异步流程。
* @param callback - callback 输入;影响 finish 的返回值。
*/
const finish = (callback: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
callback();
};
child.stdout.on('data', (chunk) => {
const text = Buffer.from(chunk).toString('utf8');
stdout += text;
if (onStdoutLine) {
const lines = `${stdoutLineBuffer}${text}`.split(/\r?\n/);
stdoutLineBuffer = lines.pop() || '';
lines
.map((line) => line.trim())
.filter(Boolean)
.forEach((line) => onStdoutLine(line));
}
});
child.stderr.on('data', (chunk) => {
stderr += Buffer.from(chunk).toString('utf8');
});
child.on('error', (err) => {
finish(() => reject(err));
});
child.on('close', (code) => {
finish(() => {
if (onStdoutLine && stdoutLineBuffer.trim()) {
onStdoutLine(stdoutLineBuffer.trim());
}
if (code === 0) {
resolve({ stderr, stdout });
return;
}
reject(new Error((stderr || stdout || `${command} failed`).trim()));
});
});
child.stdin.write(input);
child.stdin.end();
},
);
}
}