diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts new file mode 100644 index 0000000..b263973 --- /dev/null +++ b/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts @@ -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) { + return `${JSON.stringify(value, null, 2)}\n`; + } +} diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts new file mode 100644 index 0000000..8c05e21 --- /dev/null +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -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(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(key) || defaultValue); + return Number.isFinite(value) && value > 0 ? value : defaultValue; + } +} diff --git a/src/modules/qqbot/napcat/domain/runtime/napcat-config-hash.ts b/src/modules/qqbot/napcat/domain/runtime/napcat-config-hash.ts new file mode 100644 index 0000000..dffaa61 --- /dev/null +++ b/src/modules/qqbot/napcat/domain/runtime/napcat-config-hash.ts @@ -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) + .sort() + .reduce>((result, key) => { + result[key] = sortJsonValue((value as Record)[key]); + return result; + }, {}); +} diff --git a/src/modules/qqbot/napcat/domain/runtime/napcat-profile.types.ts b/src/modules/qqbot/napcat/domain/runtime/napcat-profile.types.ts new file mode 100644 index 0000000..612ce97 --- /dev/null +++ b/src/modules/qqbot/napcat/domain/runtime/napcat-profile.types.ts @@ -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; +}; diff --git a/src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts b/src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts index a1cdf14..4b789db 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts @@ -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, diff --git a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts index 5ca9bd2..bd7e43d 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts @@ -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, 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" < "$DATA_DIR/config/onebot11.json" </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}" < { 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 = { + 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); + }); +}); diff --git a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts index 7f693e8..c830d3d 100644 --- a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts @@ -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 = { + 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(() => ({