feat: 生成NapCat运行态与协议Profile
This commit is contained in:
parent
923a383543
commit
5559c318f2
@ -0,0 +1,141 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ToolsService } from '@/common';
|
||||||
|
import { stableJsonHash } from '../../domain/runtime/napcat-config-hash';
|
||||||
|
import type { NapcatConfigFile } from '../../domain/runtime/napcat-profile.types';
|
||||||
|
|
||||||
|
type OnebotReverseWsClientConfig = {
|
||||||
|
debug: false;
|
||||||
|
enable: true;
|
||||||
|
heartInterval: 30000;
|
||||||
|
messagePostFormat: 'array';
|
||||||
|
name: 'kt-template-online-api-reverse';
|
||||||
|
reconnectInterval: 5000;
|
||||||
|
reportSelfMessage: false;
|
||||||
|
token: '';
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OnebotConfig = {
|
||||||
|
enableLocalFile2Url: false;
|
||||||
|
musicSignUrl: '';
|
||||||
|
network: {
|
||||||
|
httpClients: [];
|
||||||
|
httpServers: [];
|
||||||
|
websocketClients: OnebotReverseWsClientConfig[];
|
||||||
|
websocketServers: [];
|
||||||
|
};
|
||||||
|
parseMultMsg: false;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NapcatConfig = {
|
||||||
|
bypass: {
|
||||||
|
container: false;
|
||||||
|
hook: false;
|
||||||
|
js: false;
|
||||||
|
module: false;
|
||||||
|
process: false;
|
||||||
|
window: false;
|
||||||
|
};
|
||||||
|
o3HookMode: 1;
|
||||||
|
packetBackend: 'auto';
|
||||||
|
packetServer: '';
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NapcatConfigWriterService {
|
||||||
|
/**
|
||||||
|
* Initializes the config writer with shared text helpers for sanitization.
|
||||||
|
* @param toolsService - Shared helper used to trim account and URL values before writing config files.
|
||||||
|
*/
|
||||||
|
constructor(private readonly toolsService: ToolsService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds all NapCat and OneBot config files for one account container.
|
||||||
|
* @param input - Account id, reverse WS URL, and WebUI token used to build runtime config files.
|
||||||
|
* @returns Config file bundle plus sanitized hashes for protocol-profile evidence.
|
||||||
|
*/
|
||||||
|
buildConfigFiles(input: {
|
||||||
|
account?: string;
|
||||||
|
reverseWsUrl: string;
|
||||||
|
token: string;
|
||||||
|
}) {
|
||||||
|
const account = this.toolsService.toTrimmedString(input.account);
|
||||||
|
const webuiConfig = {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
loginRate: 3,
|
||||||
|
port: 6099,
|
||||||
|
token: input.token,
|
||||||
|
};
|
||||||
|
const napcatConfig: NapcatConfig = {
|
||||||
|
bypass: {
|
||||||
|
container: false,
|
||||||
|
hook: false,
|
||||||
|
js: false,
|
||||||
|
module: false,
|
||||||
|
process: false,
|
||||||
|
window: false,
|
||||||
|
},
|
||||||
|
o3HookMode: 1,
|
||||||
|
packetBackend: 'auto',
|
||||||
|
packetServer: '',
|
||||||
|
};
|
||||||
|
const onebotConfig: OnebotConfig = {
|
||||||
|
enableLocalFile2Url: false,
|
||||||
|
musicSignUrl: '',
|
||||||
|
network: {
|
||||||
|
httpClients: [],
|
||||||
|
httpServers: [],
|
||||||
|
websocketClients: [
|
||||||
|
{
|
||||||
|
debug: false,
|
||||||
|
enable: true,
|
||||||
|
heartInterval: 30000,
|
||||||
|
messagePostFormat: 'array',
|
||||||
|
name: 'kt-template-online-api-reverse',
|
||||||
|
reconnectInterval: 5000,
|
||||||
|
reportSelfMessage: false,
|
||||||
|
token: '',
|
||||||
|
url: input.reverseWsUrl,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
websocketServers: [],
|
||||||
|
},
|
||||||
|
parseMultMsg: false,
|
||||||
|
};
|
||||||
|
const files: NapcatConfigFile[] = [
|
||||||
|
{ content: this.stringify(webuiConfig), path: 'webui.json' },
|
||||||
|
{ content: this.stringify(napcatConfig), path: 'napcat.json' },
|
||||||
|
{ content: this.stringify(onebotConfig), path: 'onebot11.json' },
|
||||||
|
];
|
||||||
|
|
||||||
|
if (account) {
|
||||||
|
files.push(
|
||||||
|
{
|
||||||
|
content: this.stringify(napcatConfig),
|
||||||
|
path: `napcat_${account}.json`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
content: this.stringify(onebotConfig),
|
||||||
|
path: `onebot11_${account}.json`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
napcatConfig,
|
||||||
|
napcatConfigHash: stableJsonHash(napcatConfig),
|
||||||
|
onebotConfig,
|
||||||
|
onebotConfigHash: stableJsonHash(onebotConfig),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes config JSON with stable indentation for script and hash tests.
|
||||||
|
* @param value - Config object that will be written to `/app/napcat/config`.
|
||||||
|
* @returns Pretty JSON content with trailing newline for here-doc output.
|
||||||
|
*/
|
||||||
|
private stringify(value: Record<string, unknown>) {
|
||||||
|
return `${JSON.stringify(value, null, 2)}\n`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import type { NapcatRuntimeProfileSnapshot } from '../../domain/runtime/napcat-profile.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class NapcatRuntimeProfileService {
|
||||||
|
/**
|
||||||
|
* Initializes the profile resolver used before Docker script generation.
|
||||||
|
* @param configService - Nest config provider that supplies image ref, UID/GID, shm size, and profile version.
|
||||||
|
*/
|
||||||
|
constructor(private readonly configService: ConfigService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the runtime profile for an account-owned NapCat container.
|
||||||
|
* @param input - Account, container, data directory, and device identity ids that tie generated profile evidence to persistence.
|
||||||
|
* @returns Runtime profile snapshot used by Docker script generation and later persistence.
|
||||||
|
*/
|
||||||
|
resolveRuntimeProfile(input: {
|
||||||
|
accountId: string;
|
||||||
|
containerId?: string;
|
||||||
|
dataDir: string;
|
||||||
|
deviceIdentityId?: string;
|
||||||
|
}): NapcatRuntimeProfileSnapshot {
|
||||||
|
return {
|
||||||
|
accountId: input.accountId,
|
||||||
|
containerId: input.containerId,
|
||||||
|
dataDir: input.dataDir,
|
||||||
|
desktopProfileVersion: this.getString(
|
||||||
|
'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION',
|
||||||
|
'desktop-cn-v1',
|
||||||
|
),
|
||||||
|
deviceIdentityId: input.deviceIdentityId,
|
||||||
|
imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''),
|
||||||
|
locale: 'zh_CN.UTF-8',
|
||||||
|
persistCache: true,
|
||||||
|
persistLocalShare: true,
|
||||||
|
persistLogs: true,
|
||||||
|
runtimeGid: this.getNumber('QQBOT_NAPCAT_RUNTIME_GID', 1101),
|
||||||
|
runtimeUid: this.getNumber('QQBOT_NAPCAT_RUNTIME_UID', 1101),
|
||||||
|
shmSize: this.getString('QQBOT_NAPCAT_SHM_SIZE', '512m'),
|
||||||
|
timezone: 'Asia/Shanghai',
|
||||||
|
xdgCacheHome: '/app/.cache',
|
||||||
|
xdgConfigHome: '/app/.config',
|
||||||
|
xdgDataHome: '/app/.local/share',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a trimmed string config value for profile generation.
|
||||||
|
* @param key - Environment key that controls NapCat runtime profile generation.
|
||||||
|
* @param defaultValue - Value used when the key is absent from runtime config.
|
||||||
|
* @returns Trimmed string value consumed by Docker script generation.
|
||||||
|
*/
|
||||||
|
private getString(key: string, defaultValue: string) {
|
||||||
|
return `${this.configService.get<string>(key) || defaultValue}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a positive numeric config value for UID/GID profile fields.
|
||||||
|
* @param key - Environment key that should contain a numeric UID/GID value.
|
||||||
|
* @param defaultValue - Safe non-root fallback for profile generation.
|
||||||
|
* @returns Positive integer used as the container runtime UID/GID.
|
||||||
|
*/
|
||||||
|
private getNumber(key: string, defaultValue: number) {
|
||||||
|
const value = Number(this.configService.get<string>(key) || defaultValue);
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Produces a deterministic JSON hash for NapCat and OneBot config snapshots.
|
||||||
|
* @param value - Config value that may contain nested plain objects or arrays.
|
||||||
|
* @returns SHA-256 digest of the stable JSON representation.
|
||||||
|
*/
|
||||||
|
export function stableJsonHash(value: unknown) {
|
||||||
|
return createHash('sha256').update(stableStringify(value)).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializes config objects with stable object-key ordering.
|
||||||
|
* @param value - JSON-compatible value written to NapCat config evidence.
|
||||||
|
* @returns JSON string whose object key order is deterministic.
|
||||||
|
*/
|
||||||
|
function stableStringify(value: unknown): string {
|
||||||
|
return JSON.stringify(sortJsonValue(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively sorts plain-object keys while preserving array order.
|
||||||
|
* @param value - JSON-compatible value to normalize before hashing.
|
||||||
|
* @returns Normalized value with deterministic object key order.
|
||||||
|
*/
|
||||||
|
function sortJsonValue(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map((item) => sortJsonValue(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!value || typeof value !== 'object') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(value as Record<string, unknown>)
|
||||||
|
.sort()
|
||||||
|
.reduce<Record<string, unknown>>((result, key) => {
|
||||||
|
result[key] = sortJsonValue((value as Record<string, unknown>)[key]);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
export type NapcatRuntimeProfileSnapshot = {
|
||||||
|
accountId: string;
|
||||||
|
containerId?: string;
|
||||||
|
dataDir: string;
|
||||||
|
desktopProfileVersion: string;
|
||||||
|
deviceIdentityId?: string;
|
||||||
|
imageRef: string;
|
||||||
|
locale: 'zh_CN.UTF-8';
|
||||||
|
persistCache: true;
|
||||||
|
persistLocalShare: true;
|
||||||
|
persistLogs: true;
|
||||||
|
runtimeGid: number;
|
||||||
|
runtimeUid: number;
|
||||||
|
shmSize: string;
|
||||||
|
timezone: 'Asia/Shanghai';
|
||||||
|
xdgCacheHome: '/app/.cache';
|
||||||
|
xdgConfigHome: '/app/.config';
|
||||||
|
xdgDataHome: '/app/.local/share';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NapcatProtocolProfileSnapshot = {
|
||||||
|
o3HookGrayEnabled: boolean;
|
||||||
|
o3HookMode: 0 | 1;
|
||||||
|
onebotConfigHash: string;
|
||||||
|
packetBackend: 'auto';
|
||||||
|
packetServer: '';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type NapcatConfigFile = {
|
||||||
|
content: string;
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
@ -3,6 +3,7 @@ import type { NapcatDeviceIdentity } from '../../persistence/napcat-device-ident
|
|||||||
export type NapcatDockerDeviceOptions = {
|
export type NapcatDockerDeviceOptions = {
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
deviceEnvPath: string;
|
deviceEnvPath: string;
|
||||||
|
deviceIdentityId?: string;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
machineIdPath: string;
|
machineIdPath: string;
|
||||||
macAddress: string;
|
macAddress: string;
|
||||||
@ -11,18 +12,19 @@ export type NapcatDockerDeviceOptions = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 NapCat 登录运行态流程。
|
* 执行 NapCat 登录运行态流程。
|
||||||
* @param identity - identity 输入;使用 `dataDir`、`hostname`、`machineIdPath`、`macAddress` 字段生成结果。
|
* @param identity - Persisted device identity row that supplies stable directory, hostname, machine-id, and MAC values for Docker.
|
||||||
* @returns NapCat 登录运行态产出的 NapcatDockerDeviceOptions。
|
* @returns Docker option bundle used by remote create scripts.
|
||||||
*/
|
*/
|
||||||
export function toNapcatDockerDeviceOptions(
|
export function toNapcatDockerDeviceOptions(
|
||||||
identity: Pick<
|
identity: Pick<
|
||||||
NapcatDeviceIdentity,
|
NapcatDeviceIdentity,
|
||||||
'dataDir' | 'hostname' | 'machineIdPath' | 'macAddress'
|
'dataDir' | 'hostname' | 'id' | 'machineIdPath' | 'macAddress'
|
||||||
>,
|
>,
|
||||||
): NapcatDockerDeviceOptions {
|
): NapcatDockerDeviceOptions {
|
||||||
return {
|
return {
|
||||||
dataDir: identity.dataDir,
|
dataDir: identity.dataDir,
|
||||||
deviceEnvPath: `${identity.dataDir}/device.env`,
|
deviceEnvPath: `${identity.dataDir}/device.env`,
|
||||||
|
deviceIdentityId: identity.id,
|
||||||
hostname: identity.hostname,
|
hostname: identity.hostname,
|
||||||
machineIdPath: identity.machineIdPath,
|
machineIdPath: identity.machineIdPath,
|
||||||
macAddress: identity.macAddress,
|
macAddress: identity.macAddress,
|
||||||
|
|||||||
@ -7,6 +7,9 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { throwVbenError, ToolsService } from '@/common';
|
import { throwVbenError, ToolsService } from '@/common';
|
||||||
|
import { NapcatConfigWriterService } from '../../../application/runtime/napcat-config-writer.service';
|
||||||
|
import { NapcatRuntimeProfileService } from '../../../application/runtime/napcat-runtime-profile.service';
|
||||||
|
import type { NapcatConfigFile } from '../../../domain/runtime/napcat-profile.types';
|
||||||
import {
|
import {
|
||||||
toNapcatDockerDeviceOptions,
|
toNapcatDockerDeviceOptions,
|
||||||
type NapcatDockerDeviceOptions,
|
type NapcatDockerDeviceOptions,
|
||||||
@ -50,13 +53,19 @@ type NapcatAutoLoginResult = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class QqbotNapcatContainerService {
|
export class QqbotNapcatContainerService {
|
||||||
|
private readonly configWriterService: NapcatConfigWriterService;
|
||||||
|
|
||||||
|
private readonly runtimeProfileService: NapcatRuntimeProfileService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 初始化 QqbotNapcatContainerService 实例。
|
* 初始化 QqbotNapcatContainerService 实例。
|
||||||
* @param configService - Nest ConfigService 依赖;影响 constructor 的返回值。
|
* @param configService - Runtime configuration source for NAS SSH, Docker image, port pool, and profile defaults.
|
||||||
* @param containerRepository - NapCat仓库依赖;影响 constructor 的返回值。
|
* @param containerRepository - Persistence adapter for NapCat container rows created or updated by runtime actions.
|
||||||
* @param bindingRepository - NapCat仓库依赖;影响 constructor 的返回值。
|
* @param bindingRepository - Persistence adapter that links QQBot accounts to their primary NapCat containers.
|
||||||
* @param toolsService - ToolsService 依赖;影响 constructor 的返回值。
|
* @param toolsService - Shared helper for error extraction, text truncation, and bounded sleeps.
|
||||||
* @param deviceIdentityService - deviceIdentityService 服务依赖;影响 constructor 的返回值。
|
* @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.
|
||||||
*/
|
*/
|
||||||
constructor(
|
constructor(
|
||||||
private readonly configService: ConfigService,
|
private readonly configService: ConfigService,
|
||||||
@ -66,7 +75,14 @@ export class QqbotNapcatContainerService {
|
|||||||
private readonly bindingRepository: Repository<NapcatAccountBinding>,
|
private readonly bindingRepository: Repository<NapcatAccountBinding>,
|
||||||
private readonly toolsService: ToolsService,
|
private readonly toolsService: ToolsService,
|
||||||
private readonly deviceIdentityService?: NapcatDeviceIdentityService,
|
private readonly deviceIdentityService?: NapcatDeviceIdentityService,
|
||||||
) {}
|
runtimeProfileService?: NapcatRuntimeProfileService,
|
||||||
|
configWriterService?: NapcatConfigWriterService,
|
||||||
|
) {
|
||||||
|
this.runtimeProfileService =
|
||||||
|
runtimeProfileService || new NapcatRuntimeProfileService(configService);
|
||||||
|
this.configWriterService =
|
||||||
|
configWriterService || new NapcatConfigWriterService(toolsService);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行 NapCat 登录运行态流程。
|
* 执行 NapCat 登录运行态流程。
|
||||||
@ -1159,11 +1175,12 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建 NapCat 登录运行态对象或配置。
|
* Builds the remote shell script that creates or recreates a managed NapCat container.
|
||||||
* @param input - input 输入;使用 `dataDir`、`image`、`name`、`reverseWsUrl` 字段生成结果。
|
* @param input - Container image, account, data-dir, device identity, and reverse-WS values that become Docker flags and config files.
|
||||||
*/
|
*/
|
||||||
private buildRemoteCreateScript(input: {
|
private buildRemoteCreateScript(input: {
|
||||||
account?: string;
|
account?: string;
|
||||||
|
containerId?: string;
|
||||||
dataDir: string;
|
dataDir: string;
|
||||||
deviceIdentity?: NapcatDockerDeviceOptions;
|
deviceIdentity?: NapcatDockerDeviceOptions;
|
||||||
image: string;
|
image: string;
|
||||||
@ -1181,6 +1198,18 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
|
|||||||
const token = this.sh(input.token);
|
const token = this.sh(input.token);
|
||||||
const account = `${input.account || ''}`.trim();
|
const account = `${input.account || ''}`.trim();
|
||||||
const loginPassword = this.toolsService.toSecretText(input.loginPassword);
|
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 accountHeader = account ? `ACCOUNT=${this.sh(account)}\n` : '';
|
||||||
const accountRunFlag = account ? ' -e ACCOUNT="$ACCOUNT" \\\n' : '';
|
const accountRunFlag = account ? ' -e ACCOUNT="$ACCOUNT" \\\n' : '';
|
||||||
const passwordHeader = loginPassword
|
const passwordHeader = loginPassword
|
||||||
@ -1222,63 +1251,61 @@ NAME=${name}
|
|||||||
PORT=${input.port}
|
PORT=${input.port}
|
||||||
REVERSE_WS_URL=${reverseWsUrl}
|
REVERSE_WS_URL=${reverseWsUrl}
|
||||||
WEBUI_TOKEN=${token}
|
WEBUI_TOKEN=${token}
|
||||||
|
NAPCAT_UID=${this.sh(`${runtimeProfile.runtimeUid}`)}
|
||||||
|
NAPCAT_GID=${this.sh(`${runtimeProfile.runtimeGid}`)}
|
||||||
|
NAPCAT_SHM_SIZE=${this.sh(runtimeProfile.shmSize)}
|
||||||
${accountHeader}
|
${accountHeader}
|
||||||
${passwordHeader}
|
${passwordHeader}
|
||||||
${deviceHeader}
|
${deviceHeader}
|
||||||
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" "$DATA_DIR/cache" "$DATA_DIR/local-share"
|
||||||
chmod 700 "$DATA_DIR"
|
chmod 700 "$DATA_DIR"
|
||||||
${devicePrepareScript}
|
${devicePrepareScript}
|
||||||
|
|
||||||
cat > "$DATA_DIR/config/webui.json" <<EOF
|
${configWriteScript}
|
||||||
{
|
|
||||||
"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
|
${pullCmd}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 \\
|
--init \\
|
||||||
-e NAPCAT_GID=0 \\
|
--shm-size "$NAPCAT_SHM_SIZE" \\
|
||||||
|
-e NAPCAT_UID="$NAPCAT_UID" \\
|
||||||
|
-e NAPCAT_GID="$NAPCAT_GID" \\
|
||||||
-e WEBUI_TOKEN="$WEBUI_TOKEN" \\
|
-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" \\
|
${accountRunFlag}${passwordRunFlag}${deviceRunFlags} -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" \\
|
||||||
|
-v "$DATA_DIR/cache:/app/.cache" \\
|
||||||
|
-v "$DATA_DIR/local-share:/app/.local/share" \\
|
||||||
|
-v "$DATA_DIR/logs:/app/napcat/logs" \\
|
||||||
"$IMAGE" >/dev/null
|
"$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 登录运行态流程。
|
* 执行 NapCat 登录运行态流程。
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module';
|
|||||||
import { QqbotNapcatAccountRuntimeService } from './application/account-runtime/qqbot-napcat-account-runtime.service';
|
import { QqbotNapcatAccountRuntimeService } from './application/account-runtime/qqbot-napcat-account-runtime.service';
|
||||||
import { QqbotNapcatLoginService } from './application/login/qqbot-napcat-login.service';
|
import { QqbotNapcatLoginService } from './application/login/qqbot-napcat-login.service';
|
||||||
import { QqbotNapcatWatchdogService } from './application/login/qqbot-napcat-watchdog.service';
|
import { QqbotNapcatWatchdogService } from './application/login/qqbot-napcat-watchdog.service';
|
||||||
|
import { NapcatConfigWriterService } from './application/runtime/napcat-config-writer.service';
|
||||||
|
import { NapcatRuntimeProfileService } from './application/runtime/napcat-runtime-profile.service';
|
||||||
import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller';
|
import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller';
|
||||||
import { QqbotNapcatContainerService } from './infrastructure/integration/container/qqbot-napcat-container.service';
|
import { QqbotNapcatContainerService } from './infrastructure/integration/container/qqbot-napcat-container.service';
|
||||||
import { NapcatDeviceIdentityService } from './infrastructure/integration/device/napcat-device-identity.service';
|
import { NapcatDeviceIdentityService } from './infrastructure/integration/device/napcat-device-identity.service';
|
||||||
@ -20,8 +22,10 @@ export const QQBOT_NAPCAT_ENTITIES = [...NAPCAT_RUNTIME_ENTITIES];
|
|||||||
export const QQBOT_NAPCAT_CONTROLLERS = [QqbotNapcatLoginController];
|
export const QQBOT_NAPCAT_CONTROLLERS = [QqbotNapcatLoginController];
|
||||||
|
|
||||||
export const QQBOT_NAPCAT_PROVIDERS = [
|
export const QQBOT_NAPCAT_PROVIDERS = [
|
||||||
|
NapcatConfigWriterService,
|
||||||
NapcatDeviceIdentityService,
|
NapcatDeviceIdentityService,
|
||||||
NapcatLoginStateStoreService,
|
NapcatLoginStateStoreService,
|
||||||
|
NapcatRuntimeProfileService,
|
||||||
QqbotNapcatAccountRuntimeService,
|
QqbotNapcatAccountRuntimeService,
|
||||||
QqbotNapcatContainerService,
|
QqbotNapcatContainerService,
|
||||||
QqbotNapcatLoginService,
|
QqbotNapcatLoginService,
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import { getMetadataArgsStorage } from 'typeorm';
|
import { getMetadataArgsStorage } from 'typeorm';
|
||||||
|
import { ToolsService } from '@/common';
|
||||||
|
import { NapcatConfigWriterService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service';
|
||||||
|
import { NapcatRuntimeProfileService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service';
|
||||||
import {
|
import {
|
||||||
NapcatLoginEvent,
|
NapcatLoginEvent,
|
||||||
NapcatProtocolProfile,
|
NapcatProtocolProfile,
|
||||||
@ -88,3 +91,79 @@ describe('NapCat runtime and protocol profile persistence', () => {
|
|||||||
expect(riskColumns.join(' ')).not.toMatch(/daily|hour|budget|quota/i);
|
expect(riskColumns.join(' ')).not.toMatch(/daily|hour|budget|quota/i);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('NapCat runtime profile generation', () => {
|
||||||
|
it('resolves Chinese Desktop Runtime defaults without C.UTF-8 fallback', () => {
|
||||||
|
const service = new NapcatRuntimeProfileService({
|
||||||
|
get: jest.fn((key: string, defaultValue?: string) => {
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
QQBOT_NAPCAT_IMAGE: 'kt-napcat-desktop-cn@sha256:profiledigest',
|
||||||
|
QQBOT_NAPCAT_RUNTIME_GID: '1101',
|
||||||
|
QQBOT_NAPCAT_RUNTIME_UID: '1101',
|
||||||
|
QQBOT_NAPCAT_SHM_SIZE: '512m',
|
||||||
|
};
|
||||||
|
return values[key] || defaultValue || '';
|
||||||
|
}),
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const profile = service.resolveRuntimeProfile({
|
||||||
|
accountId: 'account-1',
|
||||||
|
containerId: 'container-1',
|
||||||
|
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/linux-pc-a1b2',
|
||||||
|
deviceIdentityId: 'identity-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(profile).toMatchObject({
|
||||||
|
imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest',
|
||||||
|
locale: 'zh_CN.UTF-8',
|
||||||
|
runtimeGid: 1101,
|
||||||
|
runtimeUid: 1101,
|
||||||
|
shmSize: '512m',
|
||||||
|
xdgCacheHome: '/app/.cache',
|
||||||
|
xdgConfigHome: '/app/.config',
|
||||||
|
xdgDataHome: '/app/.local/share',
|
||||||
|
});
|
||||||
|
expect(profile.locale).not.toBe('C.UTF-8');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes account-level NapCat and OneBot configs with minimal reverse WS only', () => {
|
||||||
|
const writer = new NapcatConfigWriterService(new ToolsService());
|
||||||
|
const webuiAuthValue = 'KT_TEST_WEBUI_AUTH_VALUE';
|
||||||
|
const result = writer.buildConfigFiles({
|
||||||
|
account: '10001',
|
||||||
|
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
|
||||||
|
token: webuiAuthValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.files.map((file) => file.path)).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
'webui.json',
|
||||||
|
'napcat.json',
|
||||||
|
'napcat_10001.json',
|
||||||
|
'onebot11.json',
|
||||||
|
'onebot11_10001.json',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(result.onebotConfig.network.websocketClients).toHaveLength(1);
|
||||||
|
expect(result.onebotConfig.network.httpServers).toEqual([]);
|
||||||
|
expect(result.onebotConfig.network.websocketServers).toEqual([]);
|
||||||
|
expect(result.onebotConfig.network.websocketClients[0]).toMatchObject({
|
||||||
|
debug: false,
|
||||||
|
enable: true,
|
||||||
|
heartInterval: 30000,
|
||||||
|
messagePostFormat: 'array',
|
||||||
|
reconnectInterval: 5000,
|
||||||
|
reportSelfMessage: false,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
result.files.find((file) => file.path === 'webui.json')?.content,
|
||||||
|
).toContain(webuiAuthValue);
|
||||||
|
expect(
|
||||||
|
JSON.stringify({
|
||||||
|
napcatConfigHash: result.napcatConfigHash,
|
||||||
|
onebotConfig: result.onebotConfig,
|
||||||
|
onebotConfigHash: result.onebotConfigHash,
|
||||||
|
}),
|
||||||
|
).not.toContain(webuiAuthValue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -342,6 +342,62 @@ describe('QqbotNapcatContainerService', () => {
|
|||||||
).not.toContain('docker pull');
|
).not.toContain('docker pull');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('generates Chinese desktop runtime flags and account config files', () => {
|
||||||
|
const service = new QqbotNapcatContainerService(
|
||||||
|
{
|
||||||
|
get: jest.fn((key: string, defaultValue?: string) => {
|
||||||
|
const values: Record<string, string> = {
|
||||||
|
QQBOT_NAPCAT_IMAGE: 'kt-napcat-desktop-cn@sha256:profiledigest',
|
||||||
|
QQBOT_NAPCAT_RUNTIME_GID: '1101',
|
||||||
|
QQBOT_NAPCAT_RUNTIME_UID: '1101',
|
||||||
|
QQBOT_NAPCAT_SHM_SIZE: '512m',
|
||||||
|
};
|
||||||
|
return values[key] || defaultValue || '';
|
||||||
|
}),
|
||||||
|
} as any,
|
||||||
|
{} as any,
|
||||||
|
{} as any,
|
||||||
|
new ToolsService(),
|
||||||
|
) as any;
|
||||||
|
|
||||||
|
const script = service.buildRemoteCreateScript({
|
||||||
|
account: '10001',
|
||||||
|
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/linux-pc-a1b2',
|
||||||
|
image: 'kt-napcat-desktop-cn@sha256:profiledigest',
|
||||||
|
name: 'linux-pc-a1b2',
|
||||||
|
port: 6100,
|
||||||
|
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
|
||||||
|
token: 'token-test',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(script).toContain(
|
||||||
|
'mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share"',
|
||||||
|
);
|
||||||
|
expect(script).toContain("NAPCAT_UID='1101'");
|
||||||
|
expect(script).toContain("NAPCAT_GID='1101'");
|
||||||
|
expect(script).toContain("NAPCAT_SHM_SIZE='512m'");
|
||||||
|
expect(script).toContain('--init \\');
|
||||||
|
expect(script).toContain('--shm-size "$NAPCAT_SHM_SIZE"');
|
||||||
|
expect(script).toContain('-e NAPCAT_UID="$NAPCAT_UID"');
|
||||||
|
expect(script).toContain('-e NAPCAT_GID="$NAPCAT_GID"');
|
||||||
|
expect(script).toContain('-e LANG=zh_CN.UTF-8');
|
||||||
|
expect(script).toContain('-e LC_ALL=zh_CN.UTF-8');
|
||||||
|
expect(script).toContain('-e LANGUAGE=zh_CN:zh');
|
||||||
|
expect(script).toContain('-e TZ=Asia/Shanghai');
|
||||||
|
expect(script).toContain('-e HOME=/app');
|
||||||
|
expect(script).toContain('-e XDG_CONFIG_HOME=/app/.config');
|
||||||
|
expect(script).toContain('-e XDG_CACHE_HOME=/app/.cache');
|
||||||
|
expect(script).toContain('-e XDG_DATA_HOME=/app/.local/share');
|
||||||
|
expect(script).toContain('-e XDG_RUNTIME_DIR=/tmp/runtime-napcat');
|
||||||
|
expect(script).toContain('-v "$DATA_DIR/cache:/app/.cache"');
|
||||||
|
expect(script).toContain(
|
||||||
|
'-v "$DATA_DIR/local-share:/app/.local/share"',
|
||||||
|
);
|
||||||
|
expect(script).toContain('-v "$DATA_DIR/logs:/app/napcat/logs"');
|
||||||
|
expect(script).toContain('cat > "$DATA_DIR/config/napcat_10001.json"');
|
||||||
|
expect(script).toContain('cat > "$DATA_DIR/config/onebot11_10001.json"');
|
||||||
|
});
|
||||||
|
|
||||||
it('skips quick-login recreate when the container already carries ACCOUNT', async () => {
|
it('skips quick-login recreate when the container already carries ACCOUNT', async () => {
|
||||||
const containerRepository = {
|
const containerRepository = {
|
||||||
createQueryBuilder: jest.fn(() => ({
|
createQueryBuilder: jest.fn(() => ({
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user