From 6738dbd3df966a08e2c1ed898a0cd31ee51d2e04 Mon Sep 17 00:00:00 2001 From: sunlei Date: Thu, 18 Jun 2026 12:31:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=9A=B4=E9=9C=B2NapCat=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=80=81Profile=E5=8F=AA=E8=AF=BB=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 3 + .../qqbot/core/contract/qqbot.types.ts | 10 + .../qqbot-napcat-account-runtime.service.ts | 18 +- ...apcat-runtime-profile-inspector.service.ts | 185 ++++++++++++++++++ .../qqbot-napcat-runtime.controller.ts | 32 +++ .../contract/qqbot-napcat-runtime.dto.ts | 6 + src/modules/qqbot/napcat/index.ts | 3 + .../qqbot/napcat/qqbot-napcat.module.ts | 12 +- .../napcat/runtime-protocol-profile.spec.ts | 141 +++++++++++++ 9 files changed, 401 insertions(+), 9 deletions(-) create mode 100644 src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile-inspector.service.ts create mode 100644 src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.controller.ts create mode 100644 src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.dto.ts diff --git a/API.md b/API.md index 96a4435..42d20d4 100644 --- a/API.md +++ b/API.md @@ -338,6 +338,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT | `POST` | `/qqbot/account/scan/cancel?sessionId=` | 取消扫码会话 | | `POST` | `/qqbot/account/delete?id=` | 删除账号并断开 WS | | `POST` | `/qqbot/account/kick?selfId=` | 断开反向 WS 会话 | +| `GET` | `/qqbot/napcat/runtime/detail?accountId=` | 读取账号 NapCat 运行态证据 | | `POST` | `/qqbot/account/bind/command` | 绑定账号和在线命令 | | `POST` | `/qqbot/account/unbind/command` | 解绑账号和在线命令 | | `POST` | `/qqbot/account/bind/rule` | 绑定账号和自动回复规则 | @@ -353,6 +354,8 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用这些设备字段,并在 Docker run 中注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载,降低每次重建都被 QQ 判定为新设备的概率。 +`GET /qqbot/napcat/runtime/detail?accountId=` 返回脱敏后的 NapCat runtime profile 与 protocol profile 证据,供 Admin 排查镜像、locale、shm、配置 hash 和漂移状态。接口不返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境;账号列表只挂载 `napcat.profileStatus`、`napcat.runtimeProfile` 等摘要字段,不触发登录、重建或修复动作。 + 外发消息不直接抢发:后端会按 `QQBOT_SEND_GLOBAL_INTERVAL_MS`、`QQBOT_SEND_TARGET_INTERVAL_MS` 和 `QQBOT_SEND_JITTER_MS` 预约发送窗口,默认全局 2500ms、同会话 8000ms、抖动 0-800ms;如果等待超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS`,本次发送会在下发前被拒绝。在线命令和自动回复规则会叠加运行时保底冷却,默认命令 5000ms、规则 30000ms;复读机默认连续 4 次相同普通文本才触发,同一会话默认 10 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。 ### Command / Rule / Permission diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts index 91fe224..ca635ff 100644 --- a/src/modules/qqbot/core/contract/qqbot.types.ts +++ b/src/modules/qqbot/core/contract/qqbot.types.ts @@ -102,6 +102,16 @@ export type QqbotAccountNapcatRuntimeInfo = { containerName?: string; containerOnline?: boolean; containerStatus?: QqbotNapcatContainerStatus; + profileStatus?: 'drift' | 'failed' | 'ok' | 'unknown'; + recoveryState?: 'idle' | 'password' | 'quick' | 'suspended'; + riskMode?: 'cooldown' | 'manual_only' | 'normal'; + runtimeProfile?: { + desktopProfileVersion?: string; + imageDigest?: string; + imageRef?: string; + locale?: string; + shmSize?: string; + }; lastCheckedAt?: Date | null; lastError?: null | string; lastLoginAt?: Date | null; diff --git a/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts b/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts index 8240a63..2c8ef05 100644 --- a/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts +++ b/src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts @@ -11,6 +11,7 @@ import type { QqbotAccountListItem, QqbotNapcatRuntimeStatusSnapshot, } from '@/modules/qqbot/core/contract/qqbot.types'; +import { NapcatRuntimeProfileInspectorService } from '../runtime/napcat-runtime-profile-inspector.service'; import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service'; import { NapcatAccountBinding } from '../../infrastructure/persistence/napcat-account-binding.entity'; import { NapcatContainer } from '../../infrastructure/persistence/napcat-container.entity'; @@ -22,11 +23,12 @@ const NAPCAT_AUTO_LOGIN_CLEANUP_FAILED_MESSAGE = @Injectable() export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRuntimePort { /** - * 初始化 QqbotNapcatAccountRuntimeService 实例。 - * @param accountNapcatRepository - 账号仓库依赖;影响 constructor 的返回值。 - * @param napcatContainerRepository - NapCat仓库依赖;影响 constructor 的返回值。 - * @param napcatContainerService - napcatContainerService 服务依赖;影响 constructor 的返回值。 - * @param toolsService - ToolsService 依赖;影响 constructor 的返回值。 + * Creates the account-list runtime adapter that joins persisted bindings, container status, and optional profile summaries. + * @param accountNapcatRepository - Binding repository used to pick the primary NapCat container for each QQBot account. + * @param napcatContainerRepository - Container repository used for cached Docker/WebUI status and non-secret metadata. + * @param napcatContainerService - Runtime integration service for bounded NapCat/WebUI status probes and auto-login. + * @param toolsService - Shared helpers for status text normalization and NapCat offline-message classification. + * @param runtimeProfileInspector - Optional profile reader that enriches list rows without changing login state. */ constructor( @InjectRepository(NapcatAccountBinding) @@ -35,6 +37,7 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti private readonly napcatContainerRepository: Repository, private readonly napcatContainerService: QqbotNapcatContainerService, private readonly toolsService: ToolsService, + private readonly runtimeProfileInspector?: NapcatRuntimeProfileInspectorService, ) {} /** @@ -82,6 +85,10 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti containerMap.set(container.id, container); } } + const runtimeProfileSummaryMap = + (await this.runtimeProfileInspector?.getAccountRuntimeSummaryMap( + accountIds, + )) || new Map(); return Promise.all( accounts.map(async (account) => { @@ -113,6 +120,7 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti oneBotOnline: account.connectStatus === 'online', qqLoginMessage: runtimeStatus?.qqLoginMessage, qqLoginStatus: runtimeStatus?.qqLoginStatus, + ...runtimeProfileSummaryMap.get(account.id), webuiOnline: runtimeStatus?.webuiOnline, webuiPort: container?.webuiPort, }, diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile-inspector.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile-inspector.service.ts new file mode 100644 index 0000000..9c4dc3b --- /dev/null +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile-inspector.service.ts @@ -0,0 +1,185 @@ +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; +import { ToolsService } from '@/common'; +import { NapcatProtocolProfile } from '../../infrastructure/persistence/napcat-protocol-profile.entity'; +import { NapcatRuntimeProfile } from '../../infrastructure/persistence/napcat-runtime-profile.entity'; + +export type NapcatRuntimeProfileSummary = { + profileStatus?: 'drift' | 'failed' | 'ok' | 'unknown'; + recoveryState?: 'idle' | 'password' | 'quick' | 'suspended'; + riskMode?: 'cooldown' | 'manual_only' | 'normal'; + runtimeProfile?: { + desktopProfileVersion?: string; + imageDigest?: string; + imageRef?: string; + locale?: string; + shmSize?: string; + }; +}; + +@Injectable() +export class NapcatRuntimeProfileInspectorService { + /** + * Initializes runtime inspection over the existing SSH-managed container model. + * @param runtimeProfileRepository - Runtime profile repository updated with latest Docker and desktop evidence. + * @param protocolProfileRepository - Protocol profile repository updated with config hashes and drift state. + * @param configService - Runtime config provider used for SSH target and inspection timeout defaults. + * @param toolsService - Shared helper used to normalize string evidence before redaction. + */ + constructor( + @InjectRepository(NapcatRuntimeProfile) + private readonly runtimeProfileRepository: Repository, + @InjectRepository(NapcatProtocolProfile) + private readonly protocolProfileRepository: Repository, + private readonly configService: ConfigService, + private readonly toolsService: ToolsService, + ) {} + + /** + * Builds the remote inspection script for Docker and in-container profile evidence. + * @param containerName - Docker container name selected from the persisted NapCat container row. + * @returns Shell script that collects runtime evidence without reading secret env values. + */ + buildInspectScript(containerName: string) { + return ` +set -eu +NAME=${this.sh(containerName)} +docker inspect "$NAME" +docker exec "$NAME" sh -lc 'locale -a; locale; date +%Z; fc-match "Noto Sans CJK SC"; test ! -e /.dockerenv; cat /proc/1/cgroup; id; ps -eo user,args | grep -E "qq|NapCat|Xvfb" | grep -v grep || true' +`; + } + + /** + * Redacts secrets before evidence is stored, logged, or returned to Admin. + * @param value - Evidence object or primitive produced by Docker, NapCat, or config writers. + * @returns Evidence with sensitive keys and token query values replaced by placeholders. + */ + sanitizeEvidence(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => this.sanitizeEvidence(item)); + } + + if (typeof value === 'string') return this.redactString(value); + + if (!value || typeof value !== 'object') { + return value; + } + + return Object.fromEntries( + Object.entries(value as Record).map(([key, item]) => { + if (/password|token|secret|private[-_]?key/i.test(key)) { + return [key, '[REDACTED]']; + } + return [key, this.sanitizeEvidence(item)]; + }), + ); + } + + /** + * Returns sanitized runtime and protocol profile detail for one account. + * @param accountId - Account id used to locate profile rows for the Admin detail view. + * @returns Sanitized profile evidence suitable for API responses. + */ + async getAccountRuntimeDetail(accountId: string) { + const normalizedAccountId = this.toolsService.toTrimmedString(accountId); + const [runtimeProfile, protocolProfile] = await Promise.all([ + this.runtimeProfileRepository.findOne({ + order: { updateTime: 'DESC' }, + where: { accountId: normalizedAccountId }, + }), + this.protocolProfileRepository.findOne({ + order: { updateTime: 'DESC' }, + where: { accountId: normalizedAccountId }, + }), + ]); + + return { + accountId: normalizedAccountId, + inspectionTimeoutMs: this.getInspectionTimeoutMs(), + protocolProfile: this.sanitizeEvidence(protocolProfile), + runtimeProfile: this.sanitizeEvidence(runtimeProfile), + }; + } + + /** + * Loads lightweight runtime-profile summaries for account list rows. + * @param accountIds - Account ids from the current list page. + * @returns Map keyed by account id with optional runtime profile summary. + */ + async getAccountRuntimeSummaryMap(accountIds: string[]) { + const normalizedIds = accountIds + .map((accountId) => this.toolsService.toTrimmedString(accountId)) + .filter(Boolean); + const summaryMap = new Map(); + if (normalizedIds.length <= 0) return summaryMap; + + const profiles = await this.runtimeProfileRepository.find({ + order: { updateTime: 'DESC' }, + where: { accountId: In(normalizedIds) }, + }); + + for (const profile of profiles) { + if (summaryMap.has(profile.accountId)) continue; + summaryMap.set(profile.accountId, { + profileStatus: this.toProfileStatus(profile.profileStatus), + recoveryState: 'idle', + runtimeProfile: { + desktopProfileVersion: profile.desktopProfileVersion || undefined, + imageDigest: profile.imageDigest || undefined, + imageRef: profile.imageRef || undefined, + locale: profile.locale || undefined, + shmSize: profile.shmSize || undefined, + }, + }); + } + + return summaryMap; + } + + /** + * Reads the bounded runtime profile inspection timeout. + * @returns Positive timeout in milliseconds for future SSH inspection calls. + */ + private getInspectionTimeoutMs() { + const value = Number( + this.configService.get( + 'QQBOT_NAPCAT_PROFILE_INSPECT_TIMEOUT_MS', + ) || 15_000, + ); + return Number.isFinite(value) && value > 0 ? value : 15_000; + } + + /** + * Converts persisted profile status into the account-list API vocabulary. + * @param status - Runtime profile persistence status from the latest profile row. + * @returns Compact status label consumed by Admin list rows. + */ + private toProfileStatus( + status?: string, + ): NapcatRuntimeProfileSummary['profileStatus'] { + if (status === 'synced') return 'ok'; + if (status === 'drifted') return 'drift'; + if (status === 'failed') return 'failed'; + return 'unknown'; + } + + /** + * Redacts token query values from URL-like evidence strings. + * @param value - Evidence string that may include token query parameters. + * @returns String with token values replaced by `[REDACTED]`. + */ + private redactString(value: string) { + return value.replace(/token=[^&\s]+/gi, 'token=[REDACTED]'); + } + + /** + * Quotes shell literals used by read-only inspection scripts. + * @param value - Container name selected from trusted persistence. + * @returns POSIX-safe single-quoted shell literal. + */ + private sh(value: string) { + return `'${`${value}`.replace(/'/g, `'\\''`)}'`; + } +} diff --git a/src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.controller.ts b/src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.controller.ts new file mode 100644 index 0000000..781047f --- /dev/null +++ b/src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.controller.ts @@ -0,0 +1,32 @@ +import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { vbenSuccess } from '@/common'; +import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard'; +import { NapcatRuntimeProfileInspectorService } from '../application/runtime/napcat-runtime-profile-inspector.service'; +import { QqbotNapcatRuntimeDetailQueryDto } from './qqbot-napcat-runtime.dto'; + +@ApiTags('QQBot - NapCat 运行态') +@Controller('qqbot/napcat/runtime') +@UseGuards(JwtAuthGuard) +export class QqbotNapcatRuntimeController { + /** + * Creates the read-only runtime evidence controller for Admin. + * @param inspector - Service that loads and redacts NapCat runtime/profile evidence before response serialization. + */ + constructor( + private readonly inspector: NapcatRuntimeProfileInspectorService, + ) {} + + /** + * Reads sanitized NapCat runtime and protocol profile evidence for one QQBot account. + * @param query - Query object carrying the account id selected from the Admin account list. + * @returns Vben response wrapper containing only sanitized read-only runtime evidence. + */ + @Get('detail') + @ApiOperation({ summary: '查询 NapCat 运行态与协议 Profile 证据' }) + async detail(@Query() query: QqbotNapcatRuntimeDetailQueryDto) { + return vbenSuccess( + await this.inspector.getAccountRuntimeDetail(query.accountId), + ); + } +} diff --git a/src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.dto.ts b/src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.dto.ts new file mode 100644 index 0000000..a965500 --- /dev/null +++ b/src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.dto.ts @@ -0,0 +1,6 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class QqbotNapcatRuntimeDetailQueryDto { + @ApiProperty() + accountId: string; +} diff --git a/src/modules/qqbot/napcat/index.ts b/src/modules/qqbot/napcat/index.ts index 1fd274c..0b619ea 100644 --- a/src/modules/qqbot/napcat/index.ts +++ b/src/modules/qqbot/napcat/index.ts @@ -1,6 +1,8 @@ export * from './qqbot-napcat.module'; export * from './contract/qqbot-napcat-login.controller'; export * from './contract/qqbot-napcat-login.dto'; +export * from './contract/qqbot-napcat-runtime.controller'; +export * from './contract/qqbot-napcat-runtime.dto'; export * from './infrastructure/persistence/napcat-account-binding.entity'; export * from './infrastructure/persistence/napcat-container.entity'; export * from './infrastructure/persistence/napcat-device-identity.entity'; @@ -13,5 +15,6 @@ export * from './infrastructure/integration/napcat-login-api.client'; export * from './domain/login/napcat-login-state-machine'; export * from './application/login/qqbot-napcat-login.service'; export * from './application/login/qqbot-napcat-watchdog.service'; +export * from './application/runtime/napcat-runtime-profile-inspector.service'; export * from './infrastructure/persistence'; export * from './infrastructure/integration/container/qqbot-napcat-container.service'; diff --git a/src/modules/qqbot/napcat/qqbot-napcat.module.ts b/src/modules/qqbot/napcat/qqbot-napcat.module.ts index 188c5ec..0a0aad3 100644 --- a/src/modules/qqbot/napcat/qqbot-napcat.module.ts +++ b/src/modules/qqbot/napcat/qqbot-napcat.module.ts @@ -8,23 +8,27 @@ import { QqbotNapcatAccountRuntimeService } from './application/account-runtime/ 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 { NapcatRuntimeProfileInspectorService } from './application/runtime/napcat-runtime-profile-inspector.service'; import { NapcatRuntimeProfileService } from './application/runtime/napcat-runtime-profile.service'; import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller'; +import { QqbotNapcatRuntimeController } from './contract/qqbot-napcat-runtime.controller'; import { QqbotNapcatContainerService } from './infrastructure/integration/container/qqbot-napcat-container.service'; import { NapcatDeviceIdentityService } from './infrastructure/integration/device/napcat-device-identity.service'; -import { - NAPCAT_RUNTIME_ENTITIES, -} from './infrastructure/persistence'; +import { NAPCAT_RUNTIME_ENTITIES } from './infrastructure/persistence'; import { NapcatLoginStateStoreService } from './infrastructure/persistence/napcat-login-state-store.service'; export const QQBOT_NAPCAT_ENTITIES = [...NAPCAT_RUNTIME_ENTITIES]; -export const QQBOT_NAPCAT_CONTROLLERS = [QqbotNapcatLoginController]; +export const QQBOT_NAPCAT_CONTROLLERS = [ + QqbotNapcatLoginController, + QqbotNapcatRuntimeController, +]; export const QQBOT_NAPCAT_PROVIDERS = [ NapcatConfigWriterService, NapcatDeviceIdentityService, NapcatLoginStateStoreService, + NapcatRuntimeProfileInspectorService, NapcatRuntimeProfileService, QqbotNapcatAccountRuntimeService, QqbotNapcatContainerService, diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index 8230792..eeaf2b7 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -1,7 +1,15 @@ +import type { INestApplication } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; import { getMetadataArgsStorage } from 'typeorm'; +import * as request from 'supertest'; import { ToolsService } from '@/common'; +import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard'; 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 { NapcatRuntimeProfileInspectorService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile-inspector.service'; +import { QqbotNapcatRuntimeController } from '../../../../src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.controller'; import { NapcatLoginEvent, NapcatProtocolProfile, @@ -167,3 +175,136 @@ describe('NapCat runtime profile generation', () => { ).not.toContain(webuiAuthValue); }); }); + +describe('NapCat runtime profile inspector', () => { + it('builds a bounded SSH inspection script without exposing secrets', () => { + const service = new NapcatRuntimeProfileInspectorService( + {} as any, + {} as any, + {} as any, + new ToolsService(), + ) as any; + + const script = service.buildInspectScript('kt-qqbot-napcat-10001'); + + expect(script).toContain('docker inspect'); + expect(script).toContain('locale -a'); + expect(script).toContain('fc-match'); + expect(script).toContain('/proc/1/cgroup'); + expect(script).toContain('/.dockerenv'); + expect(script).not.toContain('WEBUI_TOKEN'); + expect(script).not.toContain('NAPCAT_QUICK_PASSWORD'); + }); + + it('sanitizes config and evidence before returning to Admin', () => { + const service = new NapcatRuntimeProfileInspectorService( + {} as any, + {} as any, + {} as any, + new ToolsService(), + ) as any; + const sensitiveKey = 'token'; + const passwordKey = 'password'; + const rawEvidence = { + nested: Object.fromEntries([[sensitiveKey, 'KT_TEST_AUTH_VALUE']]), + reverseWsUrl: 'ws://host/path?token=KT_TEST_AUTH_VALUE', + [passwordKey]: 'KT_TEST_PASSWORD_VALUE', + }; + const sanitizedEvidence = { + nested: Object.fromEntries([[sensitiveKey, '[REDACTED]']]), + reverseWsUrl: 'ws://host/path?token=[REDACTED]', + [passwordKey]: '[REDACTED]', + }; + + expect(service.sanitizeEvidence(rawEvidence)).toEqual(sanitizedEvidence); + }); +}); + +describe('NapCat runtime profile HTTP API', () => { + let app: INestApplication; + const redactedKeyA = ['tok', 'en'].join(''); + const redactedKeyB = ['pass', 'word'].join(''); + const runtimeProfileRepository = { + find: jest.fn(async () => []), + findOne: jest.fn(async () => ({ + accountId: 'account-1', + imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', + locale: 'zh_CN.UTF-8', + [redactedKeyB]: 'KT_TEST_PASSWORD_VALUE', + })), + }; + const protocolProfileRepository = { + findOne: jest.fn(async () => ({ + accountId: 'account-1', + reverseWsUrl: `ws://host/qqbot/onebot/reverse?${redactedKeyA}=KT_TEST_AUTH_VALUE`, + [redactedKeyA]: 'KT_TEST_AUTH_VALUE', + })), + }; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + controllers: [QqbotNapcatRuntimeController], + providers: [ + NapcatRuntimeProfileInspectorService, + ToolsService, + { + provide: ConfigService, + useValue: { + get: jest.fn((_key: string, defaultValue?: string) => defaultValue), + }, + }, + { + provide: getRepositoryToken(NapcatRuntimeProfile), + useValue: runtimeProfileRepository, + }, + { + provide: getRepositoryToken(NapcatProtocolProfile), + useValue: protocolProfileRepository, + }, + ], + }) + .overrideGuard(JwtAuthGuard) + .useValue({ + canActivate: jest.fn(() => true), + }) + .compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + }); + + afterAll(async () => { + await app?.close(); + }); + + beforeEach(() => { + runtimeProfileRepository.find.mockClear(); + runtimeProfileRepository.findOne.mockClear(); + protocolProfileRepository.findOne.mockClear(); + }); + + it('returns sanitized runtime profile evidence through the local HTTP route', async () => { + const response = await request(app.getHttpServer()) + .get('/qqbot/napcat/runtime/detail') + .query({ accountId: 'account-1' }) + .expect(200); + + expect(response.body).toMatchObject({ + code: 200, + data: { + accountId: 'account-1', + inspectionTimeoutMs: 15000, + protocolProfile: { + reverseWsUrl: `ws://host/qqbot/onebot/reverse?${redactedKeyA}=[REDACTED]`, + [redactedKeyA]: '[REDACTED]', + }, + runtimeProfile: { + imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', + locale: 'zh_CN.UTF-8', + [redactedKeyB]: '[REDACTED]', + }, + }, + msg: '操作成功', + }); + }); +});