78 KiB
QQBot NapCat Runtime Protocol Profile Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
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.mdafter files change.
- API:
- Source spec:
D:/MyFiles/KT/Node/kt-template-online-api/docs/superpowers/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/KVM52: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
o3HookModestate.
- Stores NapCat/OneBot protocol config, hashes, and
- 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.
- Exports new entities and adds tables to
- 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, andonebot11_<uin>.json.
- Builds
- 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
napcatinfo and uses login-event service for auto-login gates.
- Adds runtime/profile summaries to account
- 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
QqbotAccountNapcatRuntimeInfowith sanitized profile summaries.
- Extends
- 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.
- Builds a KT-controlled image from a pinned NapCat Docker base image with
- 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/superpowers/specs/2026-06-18-qqbot-napcat-linux-runtime-protocol-profile-design.mdonly 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:
- Record old runtime/profile evidence.
- Verify watchdog quick -> password does not create QR code.
- Trigger a controlled manual update login only once if needed.
- If captcha or new-device appears, complete the existing flow manually and confirm
recovery_suspendedwas recorded before manual action. - Enable
o3HookMode=0only for the test account. - Record NapCat version, QQNT version, image digest, config hashes, login state, and command smoke result.
- Execute one text command and one image command through
/qqbot/command/test.
Expected:
-
OneBot online state and QQ login state remain distinct.
-
manual_qr_createdis only from Admin manual action. -
No repeated
container_recreateevents 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:
- Query current
napcat_device_identity. - Record current hostname/MAC/machine-id evidence.
- Apply profile migration for one account.
- Observe new-device verification if QQ requires it.
- Complete login manually if required.
- Run text/image command smoke.
- 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=0gray 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, andriskModeconsistently across API and Admin. - Login event names match
NapcatLoginEventKind. - Device identity strategy names are
desktop-hostname-v1andphysical-oui-v1.
- Runtime summaries use