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

1111 lines
31 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 { spawn } from 'child_process';
import { randomBytes, randomUUID } from 'crypto';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { throwVbenError, ToolsService } from '@/common';
import { QqbotAccount } from '../account/qqbot-account.entity';
import { QqbotAccountNapcat } from './qqbot-account-napcat.entity';
import { QqbotNapcatContainer } from './qqbot-napcat-container.entity';
import type { QqbotNapcatRuntime } from '../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 {
constructor(
private readonly configService: ConfigService,
@InjectRepository(QqbotNapcatContainer)
private readonly containerRepository: Repository<QqbotNapcatContainer>,
@InjectRepository(QqbotAccountNapcat)
private readonly bindingRepository: Repository<QqbotAccountNapcat>,
private readonly toolsService: ToolsService,
) {}
async prepareCreateContainer() {
if (!this.isManagedMode()) {
return this.getLegacyRuntime();
}
return this.createManagedContainer();
}
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);
return { ...created, hasExistingPrimaryBinding: false };
}
/**
* 让已绑定账号的容器带上 ACCOUNT 环境变量NapCat 的 -q 快速登录)。
* 仅在 ssh 托管模式下原地重建容器:保留 QQ 数据卷,因此随后的容器重启
* 能从持久化会话免扫码自动重登。硬踢(登录已失效)时会话作废,仍需扫码。
*/
async ensureRuntimeQuickLogin(runtime: QqbotNapcatRuntime, selfId?: string) {
return this.ensureRuntimeLoginEnv(runtime, { selfId });
}
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 {
await this.createRemoteDockerContainer({
account,
dataDir: container.dataDir || `${this.getRootDir()}/${container.name}`,
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,
});
await this.containerRepository.update(
{ id: container.id },
{
lastError: null,
lastStartedAt: new Date(),
status: 'running',
},
);
return { changed: true, ok: true };
} catch {
return { changed: false, ok: false };
}
}
async tryAutoLogin(
container: QqbotNapcatContainer,
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 quickCleanup = await this.ensureRuntimeLoginEnv(runtime, {
clearLoginPassword: true,
selfId,
});
if (!quickCleanup.ok) return { cleanupFailed: true, success: false };
const quickState = await this.restartAndDetectLoginState(runtime);
if (quickState.state === 'online') {
return { method: 'quick', success: true };
}
const loginPassword = this.toolsService.toSecretText(options.loginPassword);
if (!loginPassword) return { success: false };
const passwordEnv = await this.ensureRuntimeLoginEnv(runtime, {
loginPassword,
selfId,
});
if (!passwordEnv.ok) return { success: false };
const passwordState = await this.restartAndDetectLoginState(runtime);
if (passwordState.state !== 'online') {
const cleaned = await this.ensureRuntimeLoginEnv(runtime, {
clearLoginPassword: true,
selfId,
});
return cleaned.ok
? { success: false }
: { cleanupFailed: true, success: false };
}
const cleaned = await this.ensureRuntimeLoginEnv(runtime, {
clearLoginPassword: true,
selfId,
});
if (!cleaned.ok) return { cleanupFailed: true, success: false };
return { method: 'password', success: true };
}
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;
}
}
private buildRemoteInspectEnvScript(name: string) {
return `
set -eu
NAME=${this.sh(name)}
docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME"
`;
}
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;
}
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);
}
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,
containerId,
isDeleted: false,
},
});
if (existing) {
await this.bindingRepository.update(
{ id: existing.id },
{
bindStatus: 'bound',
isPrimary: true,
lastLoginAt: new Date(),
},
);
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);
}
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 };
}
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);
}
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;
}
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;
}
async detectRuntimeOffline(container: QqbotNapcatContainer) {
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;
}
}
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;
}
private async removeOtherAccountContainers(
accountId: string,
keepContainerId: string,
) {
const bindings = await this.bindingRepository.find({
where: {
accountId,
isDeleted: false,
},
});
for (const binding of bindings) {
if (binding.containerId === keepContainerId) continue;
const sharedCount = await this.bindingRepository
.createQueryBuilder('binding')
.where('binding.containerId = :containerId', {
containerId: binding.containerId,
})
.andWhere('binding.accountId != :accountId', { accountId })
.andWhere('binding.isDeleted = :isDeleted', { isDeleted: false })
.getCount();
if (sharedCount <= 0) {
await this.removeContainer(binding.containerId);
}
await this.bindingRepository.update(
{ id: binding.id },
{
bindStatus: 'disabled',
isDeleted: true,
isPrimary: false,
},
);
}
}
private async removeRemoteDockerContainer(container: QqbotNapcatContainer) {
const script = this.buildRemoteRemoveScript(container);
await this.runProcess('ssh', [...this.getSshArgs(), 'sh -s'], script);
}
private buildRemoteRemoveScript(container: QqbotNapcatContainer) {
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
`;
}
private buildRemoteResetLoginStateScript(container: QqbotNapcatContainer) {
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 容器已启动"
`;
}
private buildRemoteRecentLogsScript(container: QqbotNapcatContainer) {
const name = this.sh(container.name);
return `
set -eu
NAME=${name}
docker logs --tail 300 "$NAME" 2>&1 || true
`;
}
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
`;
}
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;
}
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);
}
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',
};
}
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;
}
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();
}
private async createManagedContainer(
selfId?: string,
loginPassword?: 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');
const dataDir = `${this.getRootDir()}/${name}`;
const baseUrl = this.buildBaseUrl(port);
const reverseWsUrl = this.buildReverseWsUrl();
const container = await this.containerRepository.save(
this.containerRepository.create({
baseUrl,
dataDir,
image,
isDeleted: false,
lastError: null,
name,
remark: '',
reverseWsUrl,
status: 'creating',
webuiPort: port,
webuiToken: token,
}),
);
try {
await this.createRemoteDockerContainer({
account: selfId,
dataDir,
image,
loginPassword,
name,
port,
reverseWsUrl,
token,
});
await this.containerRepository.update(
{ id: container.id },
{
lastError: null,
lastStartedAt: new Date(),
status: 'running',
},
);
return {
baseUrl,
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}`);
}
}
private async createRemoteDockerContainer(input: {
account?: string;
dataDir: string;
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);
}
private buildRemoteCreateScript(input: {
account?: string;
dataDir: string;
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 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';
return `
set -eu
DATA_DIR=${dataDir}
IMAGE=${image}
NAME=${name}
PORT=${input.port}
REVERSE_WS_URL=${reverseWsUrl}
WEBUI_TOKEN=${token}
${accountHeader}
${passwordHeader}
mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs"
chmod 700 "$DATA_DIR"
cat > "$DATA_DIR/config/webui.json" <<EOF
{
"host": "0.0.0.0",
"port": 6099,
"token": "$WEBUI_TOKEN",
"loginRate": 3
}
EOF
cat > "$DATA_DIR/config/onebot11.json" <<EOF
{
"network": {
"httpServers": [],
"httpClients": [],
"websocketServers": [],
"websocketClients": [
{
"name": "kt-template-online-api-reverse",
"enable": true,
"url": "$REVERSE_WS_URL",
"messagePostFormat": "array",
"reportSelfMessage": false,
"reconnectInterval": 5000,
"token": "",
"debug": false,
"heartInterval": 30000
}
]
},
"musicSignUrl": "",
"enableLocalFile2Url": false,
"parseMultMsg": false
}
EOF
${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" \\
${accountRunFlag}${passwordRunFlag} -p "$PORT:6099" \\
-v "$DATA_DIR/QQ:/app/.config/QQ" \\
-v "$DATA_DIR/config:/app/napcat/config" \\
-v "$DATA_DIR/plugins:/app/napcat/plugins" \\
"$IMAGE" >/dev/null
`;
}
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 端口池已用完');
}
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);
}
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}`;
}
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)}`;
}
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', ''),
};
}
private toRuntime(container: QqbotNapcatContainer): QqbotNapcatRuntime {
return {
baseUrl: this.normalizeBaseUrl(container.baseUrl),
id: container.id,
name: container.name,
webuiPort: container.webuiPort,
webuiToken: container.webuiToken,
};
}
private normalizeBaseUrl(value: string) {
const baseUrl = `${value || ''}`.trim();
if (!baseUrl) {
throwVbenError('NapCat WebUI 地址未配置');
}
return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
}
private getRootDir() {
return this.getConfig(
'QQBOT_NAPCAT_ROOT',
'/vol1/docker/kt-qqbot/napcat-instances',
).replace(/\/+$/, '');
}
private isManagedMode() {
return !!this.getManagedMode();
}
private getManagedMode() {
return this.getConfig('QQBOT_NAPCAT_CONTAINER_MODE', '').toLowerCase();
}
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;
}
private getConfig(key: string, defaultValue = '') {
return `${this.configService.get<string>(key) || defaultValue}`.trim();
}
private getProcessTimeoutMs() {
const timeoutMs = Number(
this.getConfig('QQBOT_NAPCAT_SSH_TIMEOUT_MS', '120000'),
);
return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 120000;
}
private getRuntimeCheckTimeoutMs() {
const timeoutMs = Number(
this.getConfig('QQBOT_NAPCAT_RUNTIME_CHECK_TIMEOUT_MS', '5000'),
);
return Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 5000;
}
private sh(value: string) {
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
}
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);
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();
},
);
}
}