feat: 生成NapCat运行态与协议Profile

This commit is contained in:
sunlei 2026-06-18 12:14:53 +08:00
parent 923a383543
commit 5559c318f2
9 changed files with 498 additions and 48 deletions

View File

@ -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`;
}
}

View File

@ -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;
}
}

View File

@ -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;
}, {});
}

View File

@ -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;
};

View File

@ -3,6 +3,7 @@ import type { NapcatDeviceIdentity } from '../../persistence/napcat-device-ident
export type NapcatDockerDeviceOptions = {
dataDir: string;
deviceEnvPath: string;
deviceIdentityId?: string;
hostname: string;
machineIdPath: string;
macAddress: string;
@ -11,18 +12,19 @@ export type NapcatDockerDeviceOptions = {
/**
* NapCat
* @param identity - identity 使 `dataDir``hostname``machineIdPath``macAddress`
* @returns NapCat NapcatDockerDeviceOptions
* @param identity - Persisted device identity row that supplies stable directory, hostname, machine-id, and MAC values for Docker.
* @returns Docker option bundle used by remote create scripts.
*/
export function toNapcatDockerDeviceOptions(
identity: Pick<
NapcatDeviceIdentity,
'dataDir' | 'hostname' | 'machineIdPath' | 'macAddress'
'dataDir' | 'hostname' | 'id' | 'machineIdPath' | 'macAddress'
>,
): NapcatDockerDeviceOptions {
return {
dataDir: identity.dataDir,
deviceEnvPath: `${identity.dataDir}/device.env`,
deviceIdentityId: identity.id,
hostname: identity.hostname,
machineIdPath: identity.machineIdPath,
macAddress: identity.macAddress,

View File

@ -7,6 +7,9 @@ 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 { NapcatRuntimeProfileService } from '../../../application/runtime/napcat-runtime-profile.service';
import type { NapcatConfigFile } from '../../../domain/runtime/napcat-profile.types';
import {
toNapcatDockerDeviceOptions,
type NapcatDockerDeviceOptions,
@ -50,13 +53,19 @@ type NapcatAutoLoginResult = {
@Injectable()
export class QqbotNapcatContainerService {
private readonly configWriterService: NapcatConfigWriterService;
private readonly runtimeProfileService: NapcatRuntimeProfileService;
/**
* QqbotNapcatContainerService
* @param configService - Nest ConfigService constructor
* @param containerRepository - NapCat仓库依赖 constructor
* @param bindingRepository - NapCat仓库依赖 constructor
* @param toolsService - ToolsService constructor
* @param deviceIdentityService - deviceIdentityService constructor
* @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.
*/
constructor(
private readonly configService: ConfigService,
@ -66,7 +75,14 @@ export class QqbotNapcatContainerService {
private readonly bindingRepository: Repository<NapcatAccountBinding>,
private readonly toolsService: ToolsService,
private readonly deviceIdentityService?: NapcatDeviceIdentityService,
) {}
runtimeProfileService?: NapcatRuntimeProfileService,
configWriterService?: NapcatConfigWriterService,
) {
this.runtimeProfileService =
runtimeProfileService || new NapcatRuntimeProfileService(configService);
this.configWriterService =
configWriterService || new NapcatConfigWriterService(toolsService);
}
/**
* NapCat
@ -1159,11 +1175,12 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
}
/**
* NapCat
* @param input - input 使 `dataDir``image``name``reverseWsUrl`
* 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;
@ -1181,6 +1198,18 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
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
@ -1222,63 +1251,61 @@ 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"
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}
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
${configWriteScript}
${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 \\
--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
*/

View File

@ -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 { QqbotNapcatLoginService } from './application/login/qqbot-napcat-login.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 { QqbotNapcatContainerService } from './infrastructure/integration/container/qqbot-napcat-container.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_PROVIDERS = [
NapcatConfigWriterService,
NapcatDeviceIdentityService,
NapcatLoginStateStoreService,
NapcatRuntimeProfileService,
QqbotNapcatAccountRuntimeService,
QqbotNapcatContainerService,
QqbotNapcatLoginService,

View File

@ -1,4 +1,7 @@
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 {
NapcatLoginEvent,
NapcatProtocolProfile,
@ -88,3 +91,79 @@ describe('NapCat runtime and protocol profile persistence', () => {
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);
});
});

View File

@ -342,6 +342,62 @@ describe('QqbotNapcatContainerService', () => {
).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 () => {
const containerRepository = {
createQueryBuilder: jest.fn(() => ({