kt-template-online-api/docs/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md

78 KiB
Raw Blame History

QQBot NapCat Runtime Protocol Profile Implementation Plan

Execution note: Execute this plan task-by-task with the KT-local workflow and use the checkboxes to track plan state.

Goal: Implement the confirmed NapCat session behavior, login-event minimization, protocol risk profile, real-device identity migration, and Chinese Desktop Runtime profile without adding unsafe automation or hourly/daily send budgets.

Architecture: Keep all runtime authority inside src/modules/qqbot/napcat/**, with Core consuming only the existing QqbotAccountNapcatRuntimePort. Persist profile truth in dedicated NapCat tables, generate Docker/NapCat/OneBot config from typed profile services, and expose first-stage Admin capabilities as read-only evidence plus explicit recovery status. The order of implementation is evidence first, then login-event stabilization, then session behavior, then runtime hygiene and account migration.

Tech Stack: NestJS 11, TypeORM, Jest, MySQL schema SQL, NAS Docker over SSH, NapCat Docker, OneBot v11 reverse WebSocket, Vben Admin with Vue TSX and antdv-next.


Scope And Ground Rules

  • Target repos:
    • API: D:/MyFiles/KT/Node/kt-template-online-api
    • Admin: D:/MyFiles/KT/Vue/kt-template-admin
    • Root docs index: D:/MyFiles/KT/TASKS.md after files change.
  • Source spec: D:/MyFiles/KT/Node/kt-template-online-api/docs/specs/2026-06-18-qqbot-napcat-linux-runtime-protocol-profile-design.md
  • Preserve behavior:
    • Existing scan create/refresh, captcha, new-device verification, OneBot reverse WS, and QQBot command execution must keep working.
    • Existing watchdog order remains quick login, then saved password login; it must not enter QR scanning automatically.
    • OneBot heartbeat remains separate from QQ login state.
  • Explicit non-goals:
    • No QQ captcha bypass.
    • No QQ/NTQQ private protocol signing changes.
    • No --privileged, --network=host, --pid=host, --uts=host, or host IPC.
    • No account-level hourly or daily cumulative send budget.
    • No automatic QR refresh from watchdog.
    • No Docker default 02:42, QEMU/KVM 52:54:00, VMware, Hyper-V, or other virtualization-style target MAC prefixes.

File Responsibility Map

API Persistence

  • Create: src/modules/qqbot/napcat/infrastructure/persistence/napcat-runtime-profile.entity.ts
    • Stores Chinese Desktop Runtime evidence and Docker runtime profile state.
  • Create: src/modules/qqbot/napcat/infrastructure/persistence/napcat-protocol-profile.entity.ts
    • Stores NapCat/OneBot protocol config, hashes, and o3HookMode state.
  • Create: src/modules/qqbot/napcat/infrastructure/persistence/napcat-session-behavior-profile.entity.ts
    • Stores cold-start, housekeeping, presence, and staged capability state.
  • Create: src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-event.entity.ts
    • Stores login-side risk events: quick, password, restart, recreate, QR, captcha, new-device, suspended.
  • Create: src/modules/qqbot/napcat/infrastructure/persistence/napcat-risk-mode.entity.ts
    • Stores non-budget risk degradation state: normal, cooldown, manual-only.
  • Modify: src/modules/qqbot/napcat/infrastructure/persistence/index.ts
    • Exports new entities and adds tables to NAPCAT_RUNTIME_DOMAIN_CONTRACT.
  • Modify: sql/qqbot-init.sql
    • Adds production bootstrap DDL for new tables.
  • Modify: sql/refactor-v3/00-full-schema.sql
    • Adds full schema DDL for new tables.
  • Modify: sql/refactor-v3/99-verify.sql
    • Adds table/index/column verification queries.
  • Modify: src/modules/qqbot/napcat/schema/README.md
    • Lists new tables and verification intent.

API Domain And Application

  • Create: src/modules/qqbot/napcat/domain/runtime/napcat-profile.types.ts
    • Defines runtime, protocol, session behavior, login event, and risk mode types.
  • Create: src/modules/qqbot/napcat/domain/runtime/napcat-physical-oui-catalog.ts
    • Defines approved physical-device OUI prefixes and rejected virtualization prefixes.
  • Create: src/modules/qqbot/napcat/domain/runtime/napcat-config-hash.ts
    • Produces stable JSON hashes for NapCat/OneBot config evidence.
  • Create: src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts
    • Resolves per-account runtime and protocol profiles from env, persisted state, and device identity.
  • Create: src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts
    • Builds webui.json, napcat.json, napcat_<uin>.json, onebot11.json, and onebot11_<uin>.json.
  • Create: src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile-inspector.service.ts
    • Reads Docker/NapCat runtime evidence and writes profile drift state.
  • Create: src/modules/qqbot/napcat/application/runtime/napcat-login-event.service.ts
    • Records login-side events and provides recovery lease/backoff decisions.
  • Create: src/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service.ts
    • Computes cold-start, housekeeping, presence, staged capability, and risk-mode decisions.
  • Modify: src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts
    • Replaces Docker-style hostname/MAC generation with real-device style strategy and migration evidence.
  • Modify: src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts
    • Carries runtime profile run flags, env, and mounts.
  • Modify: src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts
    • Uses profile services to generate scripts, records login events, and prevents watchdog from repeating destructive rebuilds.
  • Modify: src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts
    • Adds runtime/profile summaries to account napcat info and uses login-event service for auto-login gates.
  • Modify: src/modules/qqbot/napcat/qqbot-napcat.module.ts
    • Registers new entities and providers.
  • Modify: src/modules/qqbot/napcat/index.ts
    • Exports new domain/application symbols needed by tests.

API Contract

  • Create: src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.controller.ts
    • Read-only endpoints for account runtime profile detail and login events.
  • Create: src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.dto.ts
    • Query DTOs for runtime detail and login-event list.
  • Modify: src/modules/qqbot/core/contract/qqbot.types.ts
    • Extends QqbotAccountNapcatRuntimeInfo with sanitized profile summaries.
  • Modify: API.md
    • Documents read-only runtime profile endpoints and the no-budget/no-auto-QR boundary.

API CI/NAS Assets

  • Create: ci/napcat-desktop-cn/Dockerfile
    • Builds a KT-controlled image from a pinned NapCat Docker base image with zh_CN.UTF-8, Chinese fonts, fontconfig cache, timezone, XDG/Home, DBus/Xvfb support, and unchanged upstream entrypoint.
  • Create: ci/napcat-desktop-cn/verify.sh
    • Verifies locale, timezone, fontconfig, XDG, process user, and entrypoint container-hiding evidence.
  • Create: ci/napcat-desktop-cn/README.md
    • Records build, tag, digest, and verification commands for the controlled image.

Admin

  • Modify: Vue/kt-template-admin/apps/web-antdv-next/src/api/qqbot/index.ts
    • Adds account runtime profile summary types.
  • Modify: Vue/kt-template-admin/apps/web-antdv-next/src/api/qqbot/napcat.ts
    • Adds read-only runtime profile detail and login-event API calls.
  • Create: Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.tsx
    • Shows runtime, protocol, session behavior, risk mode, and recent login events.
  • Modify: Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot/account/list.tsx
    • Adds a row action to open read-only runtime evidence.
  • Modify: Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot/modules/status.ts
    • Adds labels/colors for recovery state, risk mode, and profile status.
  • Test: Vue/kt-template-admin/apps/web-antdv-next/src/api/qqbot/napcat.spec.ts
  • Test: Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.spec.tsx

Tests

  • Create: test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts
  • Create: test/modules/qqbot/napcat/login-event-watchdog.spec.ts
  • Create: test/modules/qqbot/napcat/session-behavior-profile.spec.ts
  • Create: test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts
  • Modify: test/modules/qqbot/napcat/device-identity.spec.ts
  • Modify: test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts
  • Modify: test/qqbot/napcat/qqbot-napcat-container.service.spec.ts
  • Modify: test/qqbot/account/qqbot-napcat-watchdog.service.spec.ts

Task 1: Persistence Model And Schema Gates

Files:

  • Create: src/modules/qqbot/napcat/infrastructure/persistence/napcat-runtime-profile.entity.ts

  • Create: src/modules/qqbot/napcat/infrastructure/persistence/napcat-protocol-profile.entity.ts

  • Create: src/modules/qqbot/napcat/infrastructure/persistence/napcat-session-behavior-profile.entity.ts

  • Create: src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-event.entity.ts

  • Create: src/modules/qqbot/napcat/infrastructure/persistence/napcat-risk-mode.entity.ts

  • Modify: src/modules/qqbot/napcat/infrastructure/persistence/index.ts

  • Modify: src/modules/qqbot/napcat/qqbot-napcat.module.ts

  • Modify: sql/qqbot-init.sql

  • Modify: sql/refactor-v3/00-full-schema.sql

  • Modify: sql/refactor-v3/99-verify.sql

  • Modify: src/modules/qqbot/napcat/schema/README.md

  • Test: test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts

  • Test: test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts

  • Step 1: Write the failing schema contract test

Add this test file:

import { getMetadataArgsStorage } from 'typeorm';
import {
  NapcatLoginEvent,
  NapcatProtocolProfile,
  NapcatRiskMode,
  NapcatRuntimeProfile,
  NapcatSessionBehaviorProfile,
  NAPCAT_RUNTIME_DOMAIN_CONTRACT,
  NAPCAT_RUNTIME_ENTITIES,
} from '../../../../src/modules/qqbot/napcat';
import { readRefactorV3SqlSchema } from '../../../helpers/sql-schema.helper';

type EntityClass = new (...args: never[]) => unknown;

/**
 * Reads a TypeORM entity table name from decorator metadata.
 * @param entity - Entity class selected by the test to compare against SQL schema ownership.
 */
const getEntityTableName = (entity: EntityClass) =>
  getMetadataArgsStorage().tables.find((table) => table.target === entity)
    ?.name;

/**
 * Reads entity column names as they are persisted in MySQL.
 * @param entity - Entity class whose decorator column metadata must match refactor-v3 SQL.
 */
const getEntityColumnNames = (entity: EntityClass) =>
  getMetadataArgsStorage()
    .columns.filter((column) => column.target === entity)
    .map((column) => `${column.options.name || column.propertyName}`);

describe('NapCat runtime and protocol profile persistence', () => {
  const schema = readRefactorV3SqlSchema();

  it('declares runtime profile tables as NapCat-owned domain tables', () => {
    expect(NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables).toEqual(
      expect.arrayContaining([
        'napcat_runtime_profile',
        'napcat_protocol_profile',
        'napcat_session_behavior_profile',
        'napcat_login_event',
        'napcat_risk_mode',
      ]),
    );
  });

  it.each([
    [NapcatRuntimeProfile, 'napcat_runtime_profile'],
    [NapcatProtocolProfile, 'napcat_protocol_profile'],
    [NapcatSessionBehaviorProfile, 'napcat_session_behavior_profile'],
    [NapcatLoginEvent, 'napcat_login_event'],
    [NapcatRiskMode, 'napcat_risk_mode'],
  ])('maps %p to %s in the v3 SQL schema', (entity, tableName) => {
    expect(NAPCAT_RUNTIME_ENTITIES).toContain(entity);
    expect(getEntityTableName(entity)).toBe(tableName);
    schema.expectTableColumns(tableName, getEntityColumnNames(entity));
  });

  it('keeps login-event fields separate from send budget fields', () => {
    const loginEventColumns = getEntityColumnNames(NapcatLoginEvent);
    expect(loginEventColumns).toEqual(
      expect.arrayContaining([
        'account_id',
        'container_id',
        'event_kind',
        'event_source',
        'event_status',
        'evidence',
      ]),
    );
    expect(loginEventColumns.join(' ')).not.toMatch(
      /hour|daily|quota|budget|limit_count/i,
    );
  });

  it('keeps risk mode separate from account send budgets', () => {
    const riskColumns = getEntityColumnNames(NapcatRiskMode);
    expect(riskColumns).toEqual(
      expect.arrayContaining([
        'account_id',
        'risk_mode',
        'reason',
        'source_event',
        'expires_at',
        'last_evidence',
      ]),
    );
    expect(riskColumns.join(' ')).not.toMatch(/daily|hour|budget|quota/i);
  });
});
  • Step 2: Run the failing test

Run:

pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts

Expected: FAIL because the new entity exports and SQL tables do not exist.

  • Step 3: Add the TypeORM entities

Create src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-event.entity.ts:

import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
  ensureSnowflakeId,
  KtCreateDateColumn,
  KtDateTime,
  KtUpdateDateColumn,
} from '@/common';

export type NapcatLoginEventKind =
  | 'captcha_required'
  | 'container_recreate'
  | 'container_restart'
  | 'manual_qr_created'
  | 'manual_qr_scanned'
  | 'new_device_required'
  | 'password_attempt'
  | 'quick_attempt'
  | 'recovery_suspended';

export type NapcatLoginEventSource =
  | 'admin'
  | 'runtime'
  | 'system'
  | 'watchdog';

@Entity('napcat_login_event')
@Index('idx_napcat_login_event_account', ['accountId', 'createTime'])
@Index('idx_napcat_login_event_container', ['containerId', 'createTime'])
export class NapcatLoginEvent {
  @PrimaryColumn({ type: 'bigint' })
  id: string;

  @Column({ name: 'account_id', type: 'bigint' })
  accountId: string;

  @Column({
    default: null,
    name: 'container_id',
    nullable: true,
    type: 'bigint',
  })
  containerId: null | string;

  @Column({ length: 64, name: 'event_kind' })
  eventKind: NapcatLoginEventKind;

  @Column({ length: 32, name: 'event_source' })
  eventSource: NapcatLoginEventSource;

  @Column({ length: 32, name: 'event_status' })
  eventStatus: 'blocked' | 'failed' | 'pending' | 'skipped' | 'success';

  @Column({ default: null, nullable: true, type: 'json' })
  evidence: null | Record<string, unknown>;

  @KtCreateDateColumn({ name: 'create_time' })
  createTime: KtDateTime;

  @KtUpdateDateColumn({ name: 'update_time' })
  updateTime: KtDateTime;

  /**
   * Assigns a Snowflake id before TypeORM inserts a login-event row.
   */
  @BeforeInsert()
  beforeInsert() {
    ensureSnowflakeId(this);
  }
}

Also create the other four entities using the same PrimaryColumn, KtCreateDateColumn, and KtUpdateDateColumn pattern. The exact required persisted property names are:

// NapcatRuntimeProfile
accountId, containerId, deviceIdentityId, profileVersion, imageRef,
imageDigest, baseImageDigest, desktopProfileVersion, localeAvailable,
fontconfigEvidence, timezoneEvidence, runtimeUid, runtimeGid, shmSize,
locale, xdgConfigHome, xdgCacheHome, xdgDataHome, persistCache,
persistLocalShare, persistLogs, hostnameStrategy, macStrategy,
migrateDeviceIdentity, profileStatus, lastCheckEvidence, lastCheckedAt

// NapcatProtocolProfile
accountId, containerId, profileVersion, packetBackend, packetServer,
o3HookMode, o3HookGrayEnabled, onebotConfigHash, onebotConfigJson,
napcatConfigHash, napcatConfigJson, profileStatus, lastCheckEvidence,
lastCheckedAt

// NapcatSessionBehaviorProfile
accountId, profileVersion, enabled, coldStartUntil, housekeepingEnabled,
housekeepingIntervalMs, nextHousekeepingAt, lastHousekeepingAt,
lastHousekeepingResult, presenceEnabled, presenceStrategy,
lastPresenceEventAt, nextPresenceEventAt, autoCapabilityStage,
lastBehaviorEvidence

// NapcatRiskMode
accountId, riskMode, reason, sourceEvent, expiresAt, lastEvidence

Each touched class and exported helper must include JSDoc for new functions or methods.

  • Step 4: Register entity exports

Update src/modules/qqbot/napcat/infrastructure/persistence/index.ts:

import { NapcatLoginEvent } from './napcat-login-event.entity';
import { NapcatProtocolProfile } from './napcat-protocol-profile.entity';
import { NapcatRiskMode } from './napcat-risk-mode.entity';
import { NapcatRuntimeProfile } from './napcat-runtime-profile.entity';
import { NapcatSessionBehaviorProfile } from './napcat-session-behavior-profile.entity';

export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = {
  tables: [
    'napcat_container',
    'napcat_device_identity',
    'napcat_account_binding',
    'napcat_login_session',
    'napcat_login_challenge',
    'napcat_runtime_cleanup',
    'napcat_runtime_profile',
    'napcat_protocol_profile',
    'napcat_session_behavior_profile',
    'napcat_login_event',
    'napcat_risk_mode',
  ],
} as const;

export const NAPCAT_RUNTIME_ENTITIES = [
  NapcatAccountBinding,
  NapcatContainer,
  NapcatDeviceIdentity,
  NapcatLoginChallengeEntity,
  NapcatLoginSession,
  NapcatRuntimeCleanup,
  NapcatRuntimeProfile,
  NapcatProtocolProfile,
  NapcatSessionBehaviorProfile,
  NapcatLoginEvent,
  NapcatRiskMode,
];

export {
  NapcatLoginEvent,
  NapcatProtocolProfile,
  NapcatRiskMode,
  NapcatRuntimeProfile,
  NapcatSessionBehaviorProfile,
};
  • Step 5: Add MySQL schema

Add DDL blocks to sql/qqbot-init.sql and sql/refactor-v3/00-full-schema.sql. Use this shape exactly for login events:

CREATE TABLE IF NOT EXISTS `napcat_login_event` (
  `id` bigint NOT NULL,
  `account_id` bigint NOT NULL,
  `container_id` bigint DEFAULT NULL,
  `event_kind` varchar(64) NOT NULL,
  `event_source` varchar(32) NOT NULL,
  `event_status` varchar(32) NOT NULL,
  `evidence` json DEFAULT NULL,
  `create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
  `update_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
  PRIMARY KEY (`id`),
  KEY `idx_napcat_login_event_account` (`account_id`, `create_time`),
  KEY `idx_napcat_login_event_container` (`container_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Use JSON columns only for evidence/config snapshots, never for tokens, passwords, or SSH private-key material. Add corresponding table-count and index checks to sql/refactor-v3/99-verify.sql.

  • Step 6: Run schema tests

Run:

pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts

Expected: PASS.

  • Step 7: Commit
git status --short
git add src/modules/qqbot/napcat/infrastructure/persistence src/modules/qqbot/napcat/qqbot-napcat.module.ts src/modules/qqbot/napcat/index.ts sql/qqbot-init.sql sql/refactor-v3/00-full-schema.sql sql/refactor-v3/99-verify.sql src/modules/qqbot/napcat/schema/README.md test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts
git commit -m "feat: 新增NapCat运行态Profile持久化"

Task 2: Real-Device Identity Strategy And Migration Evidence

Files:

  • Create: src/modules/qqbot/napcat/domain/runtime/napcat-physical-oui-catalog.ts

  • Modify: src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts

  • Modify: src/modules/qqbot/napcat/infrastructure/persistence/napcat-device-identity.entity.ts

  • Modify: sql/qqbot-init.sql

  • Modify: sql/refactor-v3/00-full-schema.sql

  • Modify: sql/refactor-v3/99-verify.sql

  • Test: test/modules/qqbot/napcat/device-identity.spec.ts

  • Step 1: Add failing tests for hostname and MAC strategy

First extend the local createIdentityRepository() helper in test/modules/qqbot/napcat/device-identity.spec.ts:

seedIdentity: jest.fn((identity: NapcatDeviceIdentity) => {
  identities.set(identity.accountId, identity);
}),

Then append these tests to the same file:

it('generates a real-device style hostname without QQBot or container words', async () => {
  const repository = createIdentityRepository();
  const service = new NapcatDeviceIdentityService(
    repository as any,
    createIdentityConfig(),
  );

  const identity = await service.resolveForAccount({
    accountId: 'account-10001',
    containerId: 'container-first',
    selfId: '10001',
  });

  expect(identity.hostname).toMatch(/^(ubuntu|linux)-pc-[a-f0-9]{8,12}$/);
  expect(identity.hostname).not.toMatch(/10001|qq|bot|napcat|docker/i);
});

it('generates a stable MAC from approved physical OUI prefixes', async () => {
  const repository = createIdentityRepository();
  const service = new NapcatDeviceIdentityService(
    repository as any,
    createIdentityConfig(),
  );

  const identity = await service.resolveForAccount({
    accountId: 'account-10001',
    containerId: 'container-first',
    selfId: '10001',
  });

  expect(identity.macAddress).toMatch(/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/);
  expect(identity.macAddress).not.toMatch(/^02:42/i);
  expect(identity.macAddress).not.toMatch(/^52:54:00/i);
  expect(identity.macAddress).not.toMatch(/^(00:05:69|00:0c:29|00:1c:14|00:50:56)/i);
  expect(identity.macStrategy).toBe('physical-oui-v1');
});

it('records migration evidence when an existing Docker-style identity is upgraded', async () => {
  const repository = createIdentityRepository();
  repository.seedIdentity({
    accountId: 'account-10001',
    containerId: 'container-first',
    dataDir: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001',
    hostname: 'kt-qqbot-napcat-10001',
    id: 'identity-1',
    lastLoginEvidence: null,
    macAddress: '02:42:aa:bb:cc:dd',
    machineIdPath: '/vol1/docker/kt-qqbot/napcat-instances/kt-qqbot-napcat-10001/machine-id',
    verificationStatus: 'pending',
  } as NapcatDeviceIdentity);
  const service = new NapcatDeviceIdentityService(
    repository as any,
    createIdentityConfig(),
  );

  const identity = await service.resolveForAccount({
    accountId: 'account-10001',
    containerId: 'container-rebuilt',
    selfId: '10001',
  });

  expect(identity.macAddress).not.toBe('02:42:aa:bb:cc:dd');
  expect(identity.hostname).not.toBe('kt-qqbot-napcat-10001');
  expect(identity.lastLoginEvidence).toMatchObject({
    migration: {
      fromMacAddress: '02:42:aa:bb:cc:dd',
      strategy: 'physical-oui-v1',
      trigger: 'legacy-docker-identity-upgrade',
    },
  });
});
  • Step 2: Run the failing tests
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts

Expected: FAIL because current code still generates 02:42 MAC addresses and hostnames such as kt-qqbot-napcat-10001.

  • Step 3: Add the OUI catalog

Create src/modules/qqbot/napcat/domain/runtime/napcat-physical-oui-catalog.ts:

export const NAPCAT_PHYSICAL_OUI_PREFIXES = [
  '00:1A:2B',
  '00:1B:21',
  '00:1E:67',
  '00:22:68',
  '00:24:D7',
  '00:25:90',
  '00:26:B9',
  '3C:97:0E',
  '44:8A:5B',
  '58:11:22',
  '6C:88:14',
  '70:85:C2',
  '84:2B:2B',
  'A0:36:9F',
  'B4:2E:99',
] as const;

export const NAPCAT_REJECTED_VIRTUAL_OUI_PREFIXES = [
  '02:42',
  '52:54:00',
  '00:05:69',
  '00:0C:29',
  '00:1C:14',
  '00:50:56',
  '00:15:5D',
  '00:03:FF',
] as const;

/**
 * Checks whether a generated MAC starts with a rejected Docker or VM prefix.
 * @param macAddress - Stable MAC candidate generated from account/device seed.
 */
export function isRejectedVirtualMacPrefix(macAddress: string) {
  const normalized = macAddress.toUpperCase();
  return NAPCAT_REJECTED_VIRTUAL_OUI_PREFIXES.some((prefix) =>
    normalized.startsWith(prefix.toUpperCase()),
  );
}
  • Step 4: Extend the device identity entity

Add columns to NapcatDeviceIdentity:

@Column({ default: 'legacy', length: 64, name: 'hostname_strategy' })
hostnameStrategy: string;

@Column({ default: 'legacy', length: 64, name: 'mac_strategy' })
macStrategy: string;

Update both SQL schema files with matching columns.

  • Step 5: Replace hostname/MAC generation

Modify NapcatDeviceIdentityService:

/**
 * Builds a stable desktop-like hostname that avoids account identifiers.
 * @param seed - Account and container seed used only for deterministic hashing.
 */
private buildDesktopHostname(seed: string) {
  const hash = createHash('sha256').update(seed).digest('hex');
  return `ubuntu-pc-${hash.slice(0, 10)}`;
}

/**
 * Builds a stable MAC using a physical-device-style OUI prefix.
 * @param accountId - Account id used as a deterministic seed, not as visible output.
 * @param containerName - Container name mixed into the deterministic seed.
 */
private buildPhysicalMacAddress(accountId: string, containerName: string) {
  const hash = createHash('sha256')
    .update(`${accountId}:${containerName}:physical-oui-v1`)
    .digest('hex');
  const prefixIndex =
    Number.parseInt(hash.slice(0, 4), 16) % NAPCAT_PHYSICAL_OUI_PREFIXES.length;
  const prefix = NAPCAT_PHYSICAL_OUI_PREFIXES[prefixIndex];
  const suffix = [hash.slice(4, 6), hash.slice(6, 8), hash.slice(8, 10)];
  const mac = `${prefix}:${suffix.join(':')}`.toLowerCase();
  if (isRejectedVirtualMacPrefix(mac)) {
    throw new Error(`Rejected generated virtual MAC prefix: ${mac}`);
  }
  return mac;
}

In resolveForAccount, when an existing identity has legacy hostname or rejected MAC, update it once with migration evidence:

const nextHostname = this.buildDesktopHostname(`${accountId}:${input.selfId || ''}`);
const nextMacAddress = this.buildPhysicalMacAddress(accountId, containerName);
const needsMigration =
  existing.hostname !== nextHostname ||
  isRejectedVirtualMacPrefix(existing.macAddress);

if (needsMigration) {
  const migrationEvidence = {
    migration: {
      fromHostname: existing.hostname,
      fromMacAddress: existing.macAddress,
      strategy: 'physical-oui-v1',
      toHostname: nextHostname,
      toMacAddress: nextMacAddress,
      trigger: 'legacy-docker-identity-upgrade',
    },
  };
  await this.identityRepository.update(
    { id: existing.id },
    {
      hostname: nextHostname,
      hostnameStrategy: 'desktop-hostname-v1',
      lastLoginEvidence: migrationEvidence,
      macAddress: nextMacAddress,
      macStrategy: 'physical-oui-v1',
    },
  );
  Object.assign(existing, {
    hostname: nextHostname,
    hostnameStrategy: 'desktop-hostname-v1',
    lastLoginEvidence: migrationEvidence,
    macAddress: nextMacAddress,
    macStrategy: 'physical-oui-v1',
  });
}
  • Step 6: Run the identity test
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts

Expected: PASS.

  • Step 7: Commit
git add src/modules/qqbot/napcat/domain/runtime/napcat-physical-oui-catalog.ts src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts src/modules/qqbot/napcat/infrastructure/persistence/napcat-device-identity.entity.ts sql/qqbot-init.sql sql/refactor-v3/00-full-schema.sql sql/refactor-v3/99-verify.sql test/modules/qqbot/napcat/device-identity.spec.ts
git commit -m "feat: 迁移NapCat真实设备风格身份"

Task 3: Runtime And Protocol Profile Generation

Files:

  • Create: src/modules/qqbot/napcat/domain/runtime/napcat-profile.types.ts

  • Create: src/modules/qqbot/napcat/domain/runtime/napcat-config-hash.ts

  • Create: src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts

  • Create: src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts

  • Modify: src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts

  • Modify: src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts

  • Modify: src/modules/qqbot/napcat/qqbot-napcat.module.ts

  • Test: test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts

  • Test: test/qqbot/napcat/qqbot-napcat-container.service.spec.ts

  • Step 1: Add failing tests for Docker script and config output

Append to runtime-protocol-profile.spec.ts:

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';

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);
  });
});
  • Step 2: Run the failing tests
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts test/qqbot/napcat/qqbot-napcat-container.service.spec.ts

Expected: FAIL because profile/config services do not exist and Docker script lacks runtime flags.

  • Step 3: Add profile types

Create napcat-profile.types.ts:

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;
};
  • Step 4: Implement runtime profile resolver

Create napcat-runtime-profile.service.ts:

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.
   */
  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.
   * @param key - Environment key that controls NapCat runtime profile generation.
   * @param defaultValue - Value used when the key is absent from runtime config.
   */
  private getString(key: string, defaultValue: string) {
    return `${this.configService.get<string>(key) || defaultValue}`.trim();
  }

  /**
   * Reads a positive numeric config value.
   * @param key - Environment key that should contain a numeric UID/GID value.
   * @param defaultValue - Safe non-root fallback for profile generation.
   */
  private getNumber(key: string, defaultValue: number) {
    const value = Number(this.configService.get<string>(key) || defaultValue);
    return Number.isFinite(value) && value > 0 ? value : defaultValue;
  }
}
  • Step 5: Implement config writer

Create napcat-config-writer.service.ts:

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';

@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.
   */
  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 = {
      bypass: {
        container: false,
        hook: false,
        js: false,
        module: false,
        process: false,
        window: false,
      },
      o3HookMode: 1,
      packetBackend: 'auto',
      packetServer: '',
    };
    const 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[] = [
      { path: 'webui.json', content: this.stringify(webuiConfig) },
      { path: 'napcat.json', content: this.stringify(napcatConfig) },
      { path: 'onebot11.json', content: this.stringify(onebotConfig) },
    ];
    if (account) {
      files.push(
        { path: `napcat_${account}.json`, content: this.stringify(napcatConfig) },
        { path: `onebot11_${account}.json`, content: this.stringify(onebotConfig) },
      );
    }

    return {
      files,
      napcatConfig,
      napcatConfigHash: stableJsonHash(napcatConfig),
      onebotConfig,
      onebotConfigHash: stableJsonHash(onebotConfig),
    };
  }

  /**
   * Serializes config JSON with stable indentation for hash and script tests.
   * @param value - Config object that will be written to `/app/napcat/config`.
   */
  private stringify(value: Record<string, unknown>) {
    return `${JSON.stringify(value, null, 2)}\n`;
  }
}
  • Step 6: Modify Docker script generation

Inject NapcatRuntimeProfileService and NapcatConfigWriterService into QqbotNapcatContainerService. In buildRemoteCreateScript, add profile variables and run flags:

const runtimeProfile = this.runtimeProfileService.resolveRuntimeProfile({
  accountId: input.account || input.name,
  containerId: input.containerId,
  dataDir: input.dataDir,
  deviceIdentityId: input.deviceIdentity?.deviceIdentityId,
});
const configBundle = this.configWriter.buildConfigFiles({
  account,
  reverseWsUrl: input.reverseWsUrl,
  token: input.token,
});

The generated shell must contain these lines:

mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share"
docker run -d \
  --name "$NAME" \
  --restart unless-stopped \
  --init \
  --shm-size "$NAPCAT_SHM_SIZE" \
  -e NAPCAT_UID="$NAPCAT_UID" \
  -e NAPCAT_GID="$NAPCAT_GID" \
  -e LANG=zh_CN.UTF-8 \
  -e LC_ALL=zh_CN.UTF-8 \
  -e LANGUAGE=zh_CN:zh \
  -e TZ=Asia/Shanghai \
  -e HOME=/app \
  -e XDG_CONFIG_HOME=/app/.config \
  -e XDG_CACHE_HOME=/app/.cache \
  -e XDG_DATA_HOME=/app/.local/share \
  -e XDG_RUNTIME_DIR=/tmp/runtime-napcat \
  -v "$DATA_DIR/cache:/app/.cache" \
  -v "$DATA_DIR/local-share:/app/.local/share" \
  -v "$DATA_DIR/logs:/app/napcat/logs"

Do not remove the current docker rm -f "$NAME" from manual create/update yet; Task 6 adds event gating and watchdog limits around it.

  • Step 7: Run focused tests
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts test/qqbot/napcat/qqbot-napcat-container.service.spec.ts

Expected: PASS.

  • Step 8: Commit
git add src/modules/qqbot/napcat/domain/runtime src/modules/qqbot/napcat/application/runtime src/modules/qqbot/napcat/infrastructure/integration/container src/modules/qqbot/napcat/qqbot-napcat.module.ts test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts test/qqbot/napcat/qqbot-napcat-container.service.spec.ts
git commit -m "feat: 生成NapCat运行态与协议Profile"

Task 4: Chinese Desktop Runtime Image Assets

Files:

  • Create: ci/napcat-desktop-cn/Dockerfile

  • Create: ci/napcat-desktop-cn/verify.sh

  • Create: ci/napcat-desktop-cn/README.md

  • Test: test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts

  • Step 1: Write static image asset tests

Create test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts:

import { readFileSync } from 'fs';
import { join } from 'path';

const repoRoot = join(__dirname, '../../../..');

/**
 * Reads a repo file as UTF-8 text for static image asset assertions.
 * @param relativePath - Repository-relative path under `Node/kt-template-online-api`.
 */
const readSource = (relativePath: string) =>
  readFileSync(join(repoRoot, relativePath), 'utf8');

describe('NapCat Chinese Desktop Runtime image assets', () => {
  it('builds from an explicitly supplied pinned base image', () => {
    const dockerfile = readSource('ci/napcat-desktop-cn/Dockerfile');
    expect(dockerfile).toContain('ARG NAPCAT_BASE_IMAGE=');
    expect(dockerfile).toContain('FROM ${NAPCAT_BASE_IMAGE}');
    expect(dockerfile).not.toContain('mlikiowa/napcat-docker:latest');
  });

  it('installs Chinese locale, fonts, timezone, DBus, and fontconfig cache', () => {
    const dockerfile = readSource('ci/napcat-desktop-cn/Dockerfile');
    expect(dockerfile).toContain('zh_CN.UTF-8 UTF-8');
    expect(dockerfile).toContain('LANG=zh_CN.UTF-8');
    expect(dockerfile).toContain('LC_ALL=zh_CN.UTF-8');
    expect(dockerfile).toContain('Asia/Shanghai');
    expect(dockerfile).toMatch(/fonts-noto-cjk|fonts-wqy-microhei/);
    expect(dockerfile).toContain('fontconfig');
    expect(dockerfile).toContain('fc-cache -fv');
    expect(dockerfile).toContain('dbus-x11');
  });

  it('verifies locale, fontconfig, XDG, process user, and container hiding evidence', () => {
    const verify = readSource('ci/napcat-desktop-cn/verify.sh');
    expect(verify).toContain('locale -a');
    expect(verify).toContain('zh_CN.utf8');
    expect(verify).toContain('fc-match');
    expect(verify).toContain('/.dockerenv');
    expect(verify).toContain('/proc/1/cgroup');
    expect(verify).toContain('XDG_CONFIG_HOME=/app/.config');
    expect(verify).toContain('Asia/Shanghai');
  });
});
  • Step 2: Run the failing test
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts

Expected: FAIL because ci/napcat-desktop-cn does not exist.

  • Step 3: Add ci/napcat-desktop-cn/Dockerfile
ARG NAPCAT_BASE_IMAGE
FROM ${NAPCAT_BASE_IMAGE}

USER root

RUN set -eux; \
  apt-get update; \
  DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
    dbus-x11 \
    fontconfig \
    fonts-noto-cjk \
    fonts-wqy-microhei \
    locales \
    tzdata; \
  sed -i 's/^# *zh_CN.UTF-8 UTF-8/zh_CN.UTF-8 UTF-8/' /etc/locale.gen; \
  locale-gen zh_CN.UTF-8; \
  ln -snf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime; \
  echo Asia/Shanghai > /etc/timezone; \
  fc-cache -fv; \
  rm -rf /var/lib/apt/lists/*

ENV LANG=zh_CN.UTF-8 \
  LC_ALL=zh_CN.UTF-8 \
  LANGUAGE=zh_CN:zh \
  TZ=Asia/Shanghai \
  HOME=/app \
  XDG_CONFIG_HOME=/app/.config \
  XDG_CACHE_HOME=/app/.cache \
  XDG_DATA_HOME=/app/.local/share \
  XDG_RUNTIME_DIR=/tmp/runtime-napcat

Do not override upstream ENTRYPOINT or CMD.

  • Step 4: Add image verification script

Create ci/napcat-desktop-cn/verify.sh:

#!/bin/sh
set -eu

locale -a | grep -i '^zh_CN\.utf8$'
locale | grep 'LANG=zh_CN.UTF-8'
test "$(cat /etc/timezone)" = "Asia/Shanghai"
fc-match "Noto Sans CJK SC" | grep -E 'Noto|WenQuanYi|wqy'
test "${XDG_CONFIG_HOME:-}" = "/app/.config"
test "${XDG_CACHE_HOME:-}" = "/app/.cache"
test "${XDG_DATA_HOME:-}" = "/app/.local/share"
test ! -e /.dockerenv
grep -q '^0::/$' /proc/1/cgroup
  • Step 5: Add README with exact commands

Create ci/napcat-desktop-cn/README.md:

# NapCat Chinese Desktop Runtime Image

Build from the locally inspected upstream digest:

```powershell
$baseImage = docker image inspect mlikiowa/napcat-docker:latest --format '{{index .RepoDigests 0}}'
if (-not $baseImage) { throw 'NapCat upstream image digest not found; pull and inspect the image before building.' }
docker build `
  --build-arg NAPCAT_BASE_IMAGE=$baseImage `
  -t kt-napcat-desktop-cn:desktop-cn-v1 `
  -f ci/napcat-desktop-cn/Dockerfile .

Verify:

docker run --rm kt-napcat-desktop-cn:desktop-cn-v1 sh /ci/napcat-desktop-cn/verify.sh

Record the final digest in QQBOT_NAPCAT_IMAGE.


- [ ] **Step 6: Run the image asset test**

```powershell
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts

Expected: PASS.

  • Step 7: Commit
git add ci/napcat-desktop-cn test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts
git commit -m "chore: 增加NapCat中文桌面派生镜像资产"

Task 5: Runtime Inspector, Drift Evidence, And Read-Only API

Files:

  • Create: src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile-inspector.service.ts

  • Create: src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.dto.ts

  • Create: src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.controller.ts

  • Modify: src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts

  • Modify: src/modules/qqbot/core/contract/qqbot.types.ts

  • Modify: src/modules/qqbot/napcat/qqbot-napcat.module.ts

  • Test: test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts

  • Step 1: Add failing inspector/API tests

Append to runtime-protocol-profile.spec.ts:

import { NapcatRuntimeProfileInspectorService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile-inspector.service';

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);
  });
});
  • Step 2: Run the failing tests
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts

Expected: FAIL because inspector service does not exist.

  • Step 3: Implement inspector service

Create napcat-runtime-profile-inspector.service.ts with methods:

@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 timeouts.
   * @param toolsService - Shared helper used to redact sensitive evidence fields.
   */
  constructor(
    @InjectRepository(NapcatRuntimeProfile)
    private readonly runtimeProfileRepository: Repository<NapcatRuntimeProfile>,
    @InjectRepository(NapcatProtocolProfile)
    private readonly protocolProfileRepository: Repository<NapcatProtocolProfile>,
    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.
   */
  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.
   */
  sanitizeEvidence(value: unknown): unknown {
    if (Array.isArray(value)) return value.map((item) => this.sanitizeEvidence(item));
    if (!value || typeof value !== 'object') return value;
    return Object.fromEntries(
      Object.entries(value as Record<string, unknown>).map(([key, item]) => {
        if (/password|token|secret|privateKey/i.test(key)) {
          return [key, '[REDACTED]'];
        }
        if (typeof item === 'string') {
          return [key, item.replace(/token=[^&\s]+/gi, 'token=[REDACTED]')];
        }
        return [key, this.sanitizeEvidence(item)];
      }),
    );
  }
}
  • Step 4: Add read-only controller

Create controller endpoints:

@Controller('qqbot/napcat/runtime')
@UseGuards(JwtAuthGuard)
export class QqbotNapcatRuntimeController {
  /**
   * Initializes the read-only NapCat runtime controller.
   * @param inspector - Inspector service that returns sanitized profile detail and login-event evidence.
   */
  constructor(private readonly inspector: NapcatRuntimeProfileInspectorService) {}

  /**
   * Returns sanitized runtime profile detail for one account.
   * @param accountId - Account id used to locate NapCat profile rows and recent login events.
   */
  @Get('detail')
  async detail(@Query('accountId') accountId: string) {
    return this.inspector.getAccountRuntimeDetail(accountId);
  }
}

Register the controller in QQBOT_NAPCAT_CONTROLLERS.

  • Step 5: Extend account list runtime summary

Add summary fields in QqbotAccountNapcatRuntimeInfo:

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

Populate them in QqbotNapcatAccountRuntimeService.appendRuntime() using the new profile repositories or inspector summary method.

  • Step 6: Run focused tests and typecheck
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts
pnpm run typecheck

Expected: PASS.

  • Step 7: Commit
git add src/modules/qqbot/napcat/application/runtime src/modules/qqbot/napcat/contract src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts src/modules/qqbot/core/contract/qqbot.types.ts src/modules/qqbot/napcat/qqbot-napcat.module.ts test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts
git commit -m "feat: 暴露NapCat运行态Profile只读证据"

Task 6: Login Events, Recovery Lease, And Watchdog Boundaries

Files:

  • Create: src/modules/qqbot/napcat/application/runtime/napcat-login-event.service.ts

  • Modify: src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts

  • Modify: src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts

  • Modify: src/modules/qqbot/core/application/account/qqbot-account.service.ts

  • Test: test/modules/qqbot/napcat/login-event-watchdog.spec.ts

  • Test: test/qqbot/account/qqbot-napcat-watchdog.service.spec.ts

  • Test: test/qqbot/napcat/qqbot-napcat-container.service.spec.ts

  • Step 1: Add failing watchdog boundary tests

Create test/modules/qqbot/napcat/login-event-watchdog.spec.ts:

import { NapcatLoginEventService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-login-event.service';

const createRepository = () => ({
  create: jest.fn((input) => input),
  findOne: jest.fn(),
  save: jest.fn(async (input) => input),
  update: jest.fn(),
});

describe('NapCat login event and recovery lease', () => {
  it('records quick and password attempts as login events, not send budgets', async () => {
    const repository = createRepository();
    const service = new NapcatLoginEventService(repository as any);

    await service.record({
      accountId: 'account-1',
      containerId: 'container-1',
      eventKind: 'quick_attempt',
      eventSource: 'watchdog',
      eventStatus: 'success',
      evidence: { method: 'quick' },
    });
    await service.record({
      accountId: 'account-1',
      containerId: 'container-1',
      eventKind: 'password_attempt',
      eventSource: 'watchdog',
      eventStatus: 'failed',
      evidence: { method: 'password' },
    });

    expect(repository.save).toHaveBeenCalledTimes(2);
    expect(JSON.stringify(repository.save.mock.calls)).not.toMatch(/daily|hour|quota|budget/i);
  });

  it('suspends automatic recovery after captcha, new-device, or manual QR is required', async () => {
    const repository = createRepository();
    const service = new NapcatLoginEventService(repository as any);

    await service.recordSuspended({
      accountId: 'account-1',
      containerId: 'container-1',
      evidence: { reason: 'new-device-required' },
      reason: 'new_device_required',
      source: 'watchdog',
    });

    expect(repository.save).toHaveBeenCalledWith(
      expect.objectContaining({
        eventKind: 'recovery_suspended',
        eventSource: 'watchdog',
        eventStatus: 'blocked',
      }),
    );
  });
});
  • Step 2: Run failing tests
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/login-event-watchdog.spec.ts test/qqbot/account/qqbot-napcat-watchdog.service.spec.ts

Expected: FAIL because NapcatLoginEventService does not exist.

  • Step 3: Implement login event service

Create napcat-login-event.service.ts:

@Injectable()
export class NapcatLoginEventService {
  /**
   * Initializes login-event persistence.
   * @param loginEventRepository - Repository used to append login-side risk events.
   */
  constructor(
    @InjectRepository(NapcatLoginEvent)
    private readonly loginEventRepository: Repository<NapcatLoginEvent>,
  ) {}

  /**
   * Records a login-side event for audit, recovery gating, and Admin display.
   * @param input - Event payload produced by Admin actions, watchdog, runtime checks, or system workflows.
   */
  async record(input: {
    accountId: string;
    containerId?: null | string;
    eventKind: NapcatLoginEventKind;
    eventSource: NapcatLoginEventSource;
    eventStatus: 'blocked' | 'failed' | 'pending' | 'skipped' | 'success';
    evidence?: Record<string, unknown>;
  }) {
    return this.loginEventRepository.save(
      this.loginEventRepository.create({
        accountId: input.accountId,
        containerId: input.containerId || null,
        eventKind: input.eventKind,
        eventSource: input.eventSource,
        eventStatus: input.eventStatus,
        evidence: input.evidence || null,
      }),
    );
  }

  /**
   * Records that automatic recovery stopped before QR, captcha, or new-device flow.
   * @param input - Account, container, source, and reason that explain why automation stopped.
   */
  recordSuspended(input: {
    accountId: string;
    containerId?: null | string;
    evidence: Record<string, unknown>;
    reason: NapcatLoginEventKind;
    source: NapcatLoginEventSource;
  }) {
    return this.record({
      accountId: input.accountId,
      containerId: input.containerId,
      eventKind: 'recovery_suspended',
      eventSource: input.source,
      eventStatus: 'blocked',
      evidence: {
        ...input.evidence,
        reason: input.reason,
      },
    });
  }
}
  • Step 4: Wire event recording into auto-login

In QqbotNapcatContainerService.tryAutoLogin():

await this.loginEventService.record({
  accountId: container.accountId || '',
  containerId: container.id,
  eventKind: 'quick_attempt',
  eventSource: 'watchdog',
  eventStatus: 'pending',
  evidence: { containerName: container.name },
});

Record password_attempt before password recovery. If quick/password flows encounter captcha/new-device/manual QR, call recordSuspended() and return { success: false }.

  • Step 5: Prevent repeated destructive rebuilds from watchdog

Add a guard in QqbotNapcatAccountRuntimeService.tryAutoLogin() before calling container auto-login:

const recoveryGate = await this.loginEventService.canAttemptAutomaticRecovery({
  accountId: account.id,
  containerId: container.id,
});
if (!recoveryGate.allowed) {
  await this.loginEventService.recordSuspended({
    accountId: account.id,
    containerId: container.id,
    evidence: { reason: recoveryGate.reason },
    reason: 'recovery_suspended',
    source: 'watchdog',
  });
  return false;
}

canAttemptAutomaticRecovery() must block if the most recent relevant event is captcha_required, new_device_required, manual_qr_created, or recovery_suspended without manual reset evidence.

  • Step 6: Add negative tests for no auto QR and no repeated docker rm -f

Append to qqbot-napcat-container.service.spec.ts:

it('does not create a manual QR session from watchdog auto-login', async () => {
  const service = new QqbotNapcatContainerService(
    { get: jest.fn().mockReturnValue('') } as any,
    {} as any,
    {} as any,
    new ToolsService(),
  ) as any;
  service.getManagedMode = jest.fn().mockReturnValue('ssh');
  service.findContainerWithToken = jest.fn().mockResolvedValue({
    id: 'container-1',
    name: 'kt-qqbot-napcat-10001',
  });
  service.ensureRuntimeLoginEnv = jest.fn().mockResolvedValue({ changed: false, ok: true });
  service.restartAndDetectLoginState = jest.fn().mockResolvedValue({ state: 'offline' });

  const result = await service.tryAutoLogin(
    { id: 'container-1', name: 'kt-qqbot-napcat-10001' },
    { selfId: '10001' },
  );

  expect(result.success).toBe(false);
  expect(JSON.stringify(service.runProcess?.mock?.calls || [])).not.toContain('qrcode');
});
  • Step 7: Run focused tests
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/login-event-watchdog.spec.ts test/qqbot/account/qqbot-napcat-watchdog.service.spec.ts test/qqbot/napcat/qqbot-napcat-container.service.spec.ts

Expected: PASS.

  • Step 8: Commit
git add src/modules/qqbot/napcat/application/runtime/napcat-login-event.service.ts src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service.ts src/modules/qqbot/core/application/account/qqbot-account.service.ts test/modules/qqbot/napcat/login-event-watchdog.spec.ts test/qqbot/account/qqbot-napcat-watchdog.service.spec.ts test/qqbot/napcat/qqbot-napcat-container.service.spec.ts
git commit -m "feat: 稳定NapCat自动恢复登录事件"

Task 7: Session Behavior Profile And Risk Degradation

Files:

  • Create: src/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service.ts

  • Modify: src/modules/qqbot/core/application/send/qqbot-rate-limit.service.ts

  • Modify: src/modules/qqbot/core/application/send/qqbot-send.service.ts

  • Modify: src/modules/qqbot/core/application/rule/qqbot-rule-engine.service.ts

  • Modify: src/modules/qqbot/core/application/command/qqbot-command-engine.service.ts

  • Test: test/modules/qqbot/napcat/session-behavior-profile.spec.ts

  • Test existing send/rule/command tests as touched.

  • Step 1: Add failing behavior profile tests

Create test/modules/qqbot/napcat/session-behavior-profile.spec.ts:

import { NapcatSessionBehaviorService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service';

describe('NapCat session behavior profile', () => {
  it('keeps cold-start staged capability separate from send budgets', () => {
    const service = new NapcatSessionBehaviorService();
    const profile = service.createDefaultProfile('account-1', new Date('2026-06-18T03:00:00.000Z'));

    expect(profile).toMatchObject({
      accountId: 'account-1',
      autoCapabilityStage: 'manual_command',
      housekeepingEnabled: true,
      presenceEnabled: false,
    });
    expect(JSON.stringify(profile)).not.toMatch(/daily|hour|quota|budget/i);
  });

  it('does not trigger login reset, password retry, docker recreate, or QR refresh on housekeeping failure', () => {
    const service = new NapcatSessionBehaviorService();
    const decision = service.handleHousekeepingFailure({
      accountId: 'account-1',
      failureMessage: 'NapCat status API timeout',
    });

    expect(decision).toEqual({
      disableBehaviorExtensions: true,
      loginAction: 'none',
      recordEvidence: true,
    });
  });

  it('steps capability recovery from manual command to automation only after windows pass', () => {
    const service = new NapcatSessionBehaviorService();
    expect(service.nextCapabilityStage('manual_command')).toBe('low_risk_text');
    expect(service.nextCapabilityStage('low_risk_text')).toBe('image_and_large_message');
    expect(service.nextCapabilityStage('image_and_large_message')).toBe('automation');
    expect(service.nextCapabilityStage('automation')).toBe('automation');
  });
});
  • Step 2: Run failing tests
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/session-behavior-profile.spec.ts

Expected: FAIL because service does not exist.

  • Step 3: Implement behavior service

Create napcat-session-behavior.service.ts:

export type NapcatAutoCapabilityStage =
  | 'automation'
  | 'image_and_large_message'
  | 'low_risk_text'
  | 'manual_command';

@Injectable()
export class NapcatSessionBehaviorService {
  /**
   * Creates the first behavior profile after account login or profile migration.
   * @param accountId - Account id whose automation stage and housekeeping schedule are initialized.
   * @param now - Current time supplied by caller for deterministic tests and evidence.
   */
  createDefaultProfile(accountId: string, now = new Date()) {
    return {
      accountId,
      autoCapabilityStage: 'manual_command' as const,
      coldStartUntil: new Date(now.getTime() + 10 * 60_000),
      housekeepingEnabled: true,
      housekeepingIntervalMs: 30 * 60_000,
      nextHousekeepingAt: new Date(now.getTime() + 30 * 60_000),
      presenceEnabled: false,
      presenceStrategy: 'disabled',
      profileVersion: 'session-behavior-v1',
    };
  }

  /**
   * Converts housekeeping failure into evidence-only action.
   * @param input - Account and failure summary from a low-side-effect housekeeping call.
   */
  handleHousekeepingFailure(input: {
    accountId: string;
    failureMessage: string;
  }) {
    return {
      disableBehaviorExtensions: true,
      loginAction: 'none' as const,
      recordEvidence: true,
    };
  }

  /**
   * Calculates the next automation recovery stage after the current stage passes its observation window.
   * @param stage - Current staged capability value persisted for the account.
   */
  nextCapabilityStage(stage: NapcatAutoCapabilityStage): NapcatAutoCapabilityStage {
    if (stage === 'manual_command') return 'low_risk_text';
    if (stage === 'low_risk_text') return 'image_and_large_message';
    return 'automation';
  }
}
  • Step 4: Wire risk degradation into send/rule/command paths

Add a small read-only decision call before automatic rule replies and event-driven automation. Manual /qqbot/command/test and Admin-triggered smoke must remain allowed.

Use this decision shape:

type NapcatAutomationDecision = {
  allowed: boolean;
  reason?: string;
};

When blocked, log the skip and avoid sending a reply. Do not write or check any hourly/daily quota field.

  • Step 5: Run behavior tests and targeted send tests
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/session-behavior-profile.spec.ts
pnpm exec jest --runInBand --runTestsByPath test/qqbot/account/qqbot-napcat-watchdog.service.spec.ts

Expected: PASS.

  • Step 6: Run forbidden budget scan
rg -n "daily.*send|hour.*send|quota|budget|dailyLimit|hourlyLimit" src/modules/qqbot test/modules/qqbot test/qqbot

Expected: no matches outside comments that explicitly say the feature is not implemented. If a match appears in new implementation code, remove that design.

  • Step 7: Commit
git add src/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service.ts src/modules/qqbot/core/application/send src/modules/qqbot/core/application/rule src/modules/qqbot/core/application/command test/modules/qqbot/napcat/session-behavior-profile.spec.ts
git commit -m "feat: 增加NapCat会话行为Profile"

Task 8: Admin Read-Only Runtime Profile Drawer

Files:

  • Modify: Vue/kt-template-admin/apps/web-antdv-next/src/api/qqbot/index.ts

  • Modify: Vue/kt-template-admin/apps/web-antdv-next/src/api/qqbot/napcat.ts

  • Create: Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.tsx

  • Modify: Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot/account/list.tsx

  • Modify: Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot/modules/status.ts

  • Test: Vue/kt-template-admin/apps/web-antdv-next/src/api/qqbot/napcat.spec.ts

  • Test: Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.spec.tsx

  • Step 1: Add API caller tests

In napcat.spec.ts, add:

it('builds the read-only NapCat runtime detail request', async () => {
  const request = vi.spyOn(requestClient, 'get').mockResolvedValue({} as never);

  await getQqbotNapcatRuntimeDetail('account-1');

  expect(request).toHaveBeenCalledWith('/qqbot/napcat/runtime/detail', {
    params: { accountId: 'account-1' },
  });
});
  • Step 2: Add API caller

In napcat.ts:

export interface NapcatRuntimeProfileDetail {
  loginEvents: Array<{
    createTime?: string;
    eventKind: string;
    eventSource: string;
    eventStatus: string;
  }>;
  protocolProfile?: Record<string, unknown>;
  riskMode?: Record<string, unknown>;
  runtimeProfile?: Record<string, unknown>;
  sessionBehaviorProfile?: Record<string, unknown>;
}

export function getQqbotNapcatRuntimeDetail(accountId: string) {
  return requestClient.get<NapcatRuntimeProfileDetail>(
    '/qqbot/napcat/runtime/detail',
    { params: { accountId } },
  );
}
  • Step 3: Create drawer component

Create NapcatRuntimeProfileDrawer.tsx:

import type { PropType } from 'vue';
import type { QqbotApi } from '#/api/qqbot';

import { defineComponent, ref, watch } from 'vue';
import { Descriptions, Drawer, List, Spin, Tag } from 'antdv-next';
import {
  getQqbotNapcatRuntimeDetail,
  type NapcatRuntimeProfileDetail,
} from '#/api/qqbot/napcat';

const ADescriptions = Descriptions as any;
const ADrawer = Drawer as any;
const AList = List as any;
const ASpin = Spin as any;

export default defineComponent({
  name: 'NapcatRuntimeProfileDrawer',
  props: {
    account: {
      default: undefined,
      type: Object as PropType<QqbotApi.Account | undefined>,
    },
    open: {
      default: false,
      type: Boolean,
    },
  },
  emits: ['update:open'],
  setup(props, { emit }) {
    const detail = ref<NapcatRuntimeProfileDetail>();
    const loading = ref(false);

    watch(
      () => [props.open, props.account?.id] as const,
      () => {
        if (props.open && props.account?.id) void loadDetail();
      },
      { immediate: true },
    );

    /**
     * Loads sanitized runtime profile evidence for the selected account.
     */
    async function loadDetail() {
      if (!props.account?.id) return;
      loading.value = true;
      try {
        detail.value = await getQqbotNapcatRuntimeDetail(props.account.id);
      } finally {
        loading.value = false;
      }
    }

    return () => (
      <ADrawer
        open={props.open}
        title="NapCat 运行态证据"
        width={720}
        onClose={() => emit('update:open', false)}
      >
        <ASpin spinning={loading.value}>
          <ADescriptions column={1} bordered size="small">
            <ADescriptions.Item label="Profile 状态">
              <Tag>{props.account?.napcat?.profileStatus || 'unknown'}</Tag>
            </ADescriptions.Item>
            <ADescriptions.Item label="风险模式">
              <Tag>{props.account?.napcat?.riskMode || 'normal'}</Tag>
            </ADescriptions.Item>
            <ADescriptions.Item label="恢复状态">
              <Tag>{props.account?.napcat?.recoveryState || 'idle'}</Tag>
            </ADescriptions.Item>
          </ADescriptions>
          <AList
            class="mt-4"
            dataSource={detail.value?.loginEvents || []}
            renderItem={({ item }: any) => (
              <AList.Item>
                <span>{item.createTime}</span>
                <span class="ml-3">{item.eventKind}</span>
                <Tag class="ml-3">{item.eventStatus}</Tag>
              </AList.Item>
            )}
          />
        </ASpin>
      </ADrawer>
    );
  },
});
  • Step 4: Add list row action

In account/list.tsx, add a ref for drawer state and a row action:

const runtimeProfileOpen = ref(false);
const runtimeProfileAccount = ref<QqbotApi.Account>();

function openRuntimeProfile(row: QqbotApi.Account) {
  runtimeProfileAccount.value = row;
  runtimeProfileOpen.value = true;
}

Add action:

{
  key: 'runtimeProfile',
  label: '运行态',
  onClick: openRuntimeProfile,
  permissionCodes: ['QqBot:Account:Config'],
}

Render drawer beside NapcatLoginModal under the single page root.

  • Step 5: Run Admin tests and typecheck
pnpm --dir D:/MyFiles/KT/Vue/kt-template-admin exec vitest run apps/web-antdv-next/src/api/qqbot/napcat.spec.ts apps/web-antdv-next/src/views/qqbot/account/napcat/NapcatRuntimeProfileDrawer.spec.tsx
pnpm --dir D:/MyFiles/KT/Vue/kt-template-admin run typecheck

Expected: PASS.

  • Step 6: Commit Admin repo
git -C D:/MyFiles/KT/Vue/kt-template-admin status --short
git -C D:/MyFiles/KT/Vue/kt-template-admin add apps/web-antdv-next/src/api/qqbot apps/web-antdv-next/src/views/qqbot/account apps/web-antdv-next/src/views/qqbot/modules/status.ts
git -C D:/MyFiles/KT/Vue/kt-template-admin commit -m "feat: 展示NapCat运行态Profile证据"

Task 9: API Docs, Online Migration Runbook, And Verification Matrix

Files:

  • Modify: API.md

  • Modify: README.md

  • Modify: docs/specs/2026-06-18-qqbot-napcat-linux-runtime-protocol-profile-design.md only if implementation changes the confirmed design boundary.

  • Modify: D:/MyFiles/KT/docs/qqbot-nas-runtime.md

  • Modify: D:/MyFiles/KT/docs/obsidian/modules/KT 模块 - QQBot BangDream FFLogs.md

  • Modify: D:/MyFiles/KT/TASKS.md

  • Step 1: Update API docs

Add to API.md under QQBot/NapCat:

### NapCat Runtime Profile

| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/qqbot/napcat/runtime/detail?accountId=` | 读取账号 NapCat runtime/protocol/session behavior profile、风险降载和最近登录事件 |

该接口只返回脱敏后的运行态证据,不返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境。watchdog 自动恢复只允许 quick -> password遇到二维码、验证码或新设备验证会挂起并记录 `recovery_suspended`
  • Step 2: Update online runbook

In D:/MyFiles/KT/docs/qqbot-nas-runtime.md, add a section:

## NapCat Runtime/Protocol Profile 上线顺序

1. 先上线 profile/evidence 和只读 Admin 展示。
2. 再上线登录事件与 watchdog quick -> password 熔断。
3. 再上线 session behavior profile。
4. 测试账号灰度 `o3HookMode=0`5. 构建并使用 `kt-napcat-desktop-cn` 派生镜像。
6. 现有风控账号按批次迁移真实设备风格 hostname/MAC/machine-id。

watchdog 不自动生成或刷新二维码,不重复 `docker rm -f`,不做账号小时/每日发送预算。
  • Step 3: Run doc sync tool
pnpm --dir D:/MyFiles/KT/mcp/ktWorkflow run obsidian-validate

Expected: PASS. If this fails because only docs links changed, fix the broken link reported by the validator before continuing.

  • Step 4: Run API verification
pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts test/modules/qqbot/napcat/device-identity.spec.ts test/modules/qqbot/napcat/login-event-watchdog.spec.ts test/modules/qqbot/napcat/session-behavior-profile.spec.ts test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts test/qqbot/napcat/qqbot-napcat-container.service.spec.ts test/qqbot/account/qqbot-napcat-watchdog.service.spec.ts
pnpm run typecheck
git diff --check

Expected: all tests PASS, typecheck PASS, diff-check exit 0.

  • Step 5: Run global review
pnpm --dir D:/MyFiles/KT/mcp/ktWorkflow run global-review -- --projects api,admin --changed

Expected: findings=[]. Real findings must be fixed. Confirmed false positives must be encoded in mcp/ktWorkflow review rules before claiming completion.

  • Step 6: Commit docs/root updates

Commit API docs with the API commit if they are part of the same repo changes. Commit root docs separately:

git -C D:/MyFiles/KT status --short
git -C D:/MyFiles/KT add docs/qqbot-nas-runtime.md "docs/obsidian/modules/KT 模块 - QQBot BangDream FFLogs.md" TASKS.md
git -C D:/MyFiles/KT commit -m "docs: 同步NapCat运行态Profile上线计划"

Task 10: Deploy And Online Closed Loop

Files:

  • No new source files unless deploy observation exposes a real bug.

  • Evidence goes under D:/MyFiles/KT/.kt-workspace/test-artifacts.

  • Step 1: Push API and Admin only after local validation passes

git -C D:/MyFiles/KT/Node/kt-template-online-api status --short
git -C D:/MyFiles/KT/Vue/kt-template-admin status --short
git -C D:/MyFiles/KT/Node/kt-template-online-api push
git -C D:/MyFiles/KT/Vue/kt-template-admin push

Expected: pushes succeed. If Gitea reports mirror/read-only, stop and apply the existing Gitea mirror fix from root AGENTS.md.

  • Step 2: Observe Jenkins/K8s
pnpm --dir D:/MyFiles/KT/mcp/ktWorkflow run deploy-observation -- --project api --job KT-Template/KT-Template-API/main --execute

Expected: Jenkins final result SUCCESS, K8s Deployment observed generation current, Ready replicas match desired, pod image matches pushed commit, restart count stable.

  • Step 3: Online API smoke

Use a real Admin token from the current login session through an environment variable and query the first available account:

$base = 'https://admin.kwitsukasa.top/api'
if (-not $env:KT_ADMIN_ACCESS_TOKEN) { throw 'KT_ADMIN_ACCESS_TOKEN is required for online runtime profile smoke.' }
$headers = @{ Authorization = "Bearer $env:KT_ADMIN_ACCESS_TOKEN" }
$accountPage = Invoke-RestMethod "$base/qqbot/account/page?pageNo=1&pageSize=1" -Headers $headers
$accountId = $accountPage.data.list[0].id
if (-not $accountId) { throw 'No QQBot account found for runtime profile smoke.' }
Invoke-RestMethod "$base/qqbot/napcat/runtime/detail?accountId=$accountId" -Headers $headers

Expected:

  • Response code=200.

  • Runtime evidence is present.

  • token, password, secret, and runtime password env are redacted.

  • Login events list is present.

  • No hourly/daily send budget fields exist.

  • Step 4: Test account gray flow

Run one test account in this order:

  1. Record old runtime/profile evidence.
  2. Verify watchdog quick -> password does not create QR code.
  3. Trigger a controlled manual update login only once if needed.
  4. If captcha or new-device appears, complete the existing flow manually and confirm recovery_suspended was recorded before manual action.
  5. Enable o3HookMode=0 only for the test account.
  6. Record NapCat version, QQNT version, image digest, config hashes, login state, and command smoke result.
  7. Execute one text command and one image command through /qqbot/command/test.

Expected:

  • OneBot online state and QQ login state remain distinct.

  • manual_qr_created is only from Admin manual action.

  • No repeated container_recreate events from watchdog.

  • Session behavior evidence exists and does not send group/private messages by itself.

  • Step 5: Existing account migration batches

For each existing wind-controlled account:

  1. Query current napcat_device_identity.
  2. Record current hostname/MAC/machine-id evidence.
  3. Apply profile migration for one account.
  4. Observe new-device verification if QQ requires it.
  5. Complete login manually if required.
  6. Run text/image command smoke.
  7. Record final evidence and decide whether to continue to the next account.

Expected:

  • New hostname does not include QQ number or NapCat/Docker words.

  • New MAC does not start with rejected virtual prefixes.

  • One account failure does not trigger concurrent rebuilds for other accounts.

  • Step 6: Cleanup and closeout

pnpm --dir D:/MyFiles/KT/mcp/ktWorkflow run cleanup-history -- --dry-run
pnpm --dir D:/MyFiles/KT/mcp/ktWorkflow run cleanup-history -- --execute
pnpm --dir D:/MyFiles/KT/mcp/ktWorkflow run cleanup-history -- --dry-run

Expected: final dry-run reports deleted=[].


Self-Review

  • Spec coverage:
    • Session behavior profile: Task 7.
    • Login-event minimization and watchdog quick -> password: Task 6.
    • Protocol risk profile and o3HookMode=0 gray path: Tasks 3, 5, 10.
    • Chinese Desktop Runtime profile: Tasks 3, 4, 10.
    • Real physical OUI and existing account migration: Tasks 2, 10.
    • Read-only Admin evidence: Tasks 5, 8.
    • No hourly/daily send budget: Tasks 1, 7, 10 include negative checks.
  • Placeholder scan:
    • No unresolved placeholder markers.
    • No unbounded edge-case task.
    • Every code task has a concrete test, code snippet, command, and expected result.
  • Type consistency:
    • Runtime summaries use profileStatus, recoveryState, and riskMode consistently across API and Admin.
    • Login event names match NapcatLoginEventKind.
    • Device identity strategy names are desktop-hostname-v1 and physical-oui-v1.