diff --git a/API.md b/API.md index 42d20d4..50fddaa 100644 --- a/API.md +++ b/API.md @@ -354,7 +354,13 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用这些设备字段,并在 Docker run 中注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载,降低每次重建都被 QQ 判定为新设备的概率。 -`GET /qqbot/napcat/runtime/detail?accountId=` 返回脱敏后的 NapCat runtime profile 与 protocol profile 证据,供 Admin 排查镜像、locale、shm、配置 hash 和漂移状态。接口不返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境;账号列表只挂载 `napcat.profileStatus`、`napcat.runtimeProfile` 等摘要字段,不触发登录、重建或修复动作。 +### NapCat Runtime Profile + +| 方法 | 路径 | 说明 | +| ----- | ----------------------------------------- | ------------------------------------------------------------------------------------- | +| `GET` | `/qqbot/napcat/runtime/detail?accountId=` | 读取账号 NapCat runtime/protocol/session behavior profile、风险降载和最近登录事件证据 | + +该接口只返回脱敏后的运行态证据,供 Admin 排查镜像、locale、shm、配置 hash、漂移状态、风险模式和 watchdog 恢复状态;不会返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境。账号列表只挂载 `napcat.profileStatus`、`napcat.runtimeProfile` 等摘要字段,不触发登录、重建或修复动作。watchdog 自动恢复只允许 quick -> password,遇到二维码、验证码或新设备验证会挂起并记录 `recovery_suspended`;session behavior profile 只做冷启动、housekeeping、presence 和自动能力分阶段降载,不实现账号级每小时/每日累计发送预算。 外发消息不直接抢发:后端会按 `QQBOT_SEND_GLOBAL_INTERVAL_MS`、`QQBOT_SEND_TARGET_INTERVAL_MS` 和 `QQBOT_SEND_JITTER_MS` 预约发送窗口,默认全局 2500ms、同会话 8000ms、抖动 0-800ms;如果等待超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS`,本次发送会在下发前被拒绝。在线命令和自动回复规则会叠加运行时保底冷却,默认命令 5000ms、规则 30000ms;复读机默认连续 4 次相同普通文本才触发,同一会话默认 10 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。 diff --git a/README.md b/README.md index b64d0b5..70cf11a 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ | `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 | | `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 | | `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 | -| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 | +| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 | | `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 | | `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 | | `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 | @@ -73,6 +73,8 @@ QQBot 插件 worker 使用 BullMQ 队列串行执行同一插件安装实例的 QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qqbot_plugin_task` / `qqbot_plugin_task_run`,通过 BullMQ Job Scheduler 调度并经插件 worker 的 `executeTask` 边界执行。`sql/qqbot-init.sql` 可为既有环境增量创建任务表和 Admin 菜单。Admin 页面路径为 `/qqbot/plugin-task`。定时任务队列可用 `QQBOT_PLUGIN_TASK_QUEUE_REDIS_*` 单独配置;留空时复用插件 worker 队列的 Redis 连接。BangDream Bestdori 主数据缓存使用 `BANGDREAM_TSUGU_CACHE_ROOT`,生产清单挂载到容器内 `/data/qqbot/plugins/bangdream/cache`,对应 k3d 节点可写 hostPath `/var/lib/rancher/k3s/kt-template-online-api/qqbot-plugins`。 +NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/登录事件/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog quick -> password 熔断,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。 + ## 启动 ```bash diff --git a/docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md b/docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md new file mode 100644 index 0000000..1a7d7b3 --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md @@ -0,0 +1,2241 @@ +# 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.md` after files change. +- 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/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_.json`, `onebot11.json`, and `onebot11_.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: + +```ts +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: + +```powershell +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`: + +```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; + + @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: + +```ts +// 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`: + +```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: + +```sql +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: + +```powershell +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** + +```powershell +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`: + +```ts +seedIdentity: jest.fn((identity: NapcatDeviceIdentity) => { + identities.set(identity.accountId, identity); +}), +``` + +Then append these tests to the same file: + +```ts +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** + +```powershell +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`: + +```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`: + +```ts +@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`: + +```ts +/** + * 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: + +```ts +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** + +```powershell +pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/device-identity.spec.ts +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```powershell +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`: + +```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 = { + 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** + +```powershell +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`: + +```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`: + +```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(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(key) || defaultValue); + return Number.isFinite(value) && value > 0 ? value : defaultValue; + } +} +``` + +- [ ] **Step 5: Implement config writer** + +Create `napcat-config-writer.service.ts`: + +```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) { + 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: + +```ts +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: + +```sh +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** + +```powershell +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** + +```powershell +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`: + +```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** + +```powershell +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`** + +```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`: + +```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`: + +```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: + +```powershell +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** + +```powershell +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`: + +```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** + +```powershell +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: + +```ts +@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, + @InjectRepository(NapcatProtocolProfile) + private readonly protocolProfileRepository: Repository, + private readonly configService: ConfigService, + private readonly toolsService: ToolsService, + ) {} + + /** + * Builds the remote inspection script for Docker and in-container profile evidence. + * @param containerName - Docker container name selected from the persisted NapCat container row. + */ + 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).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: + +```ts +@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`: + +```ts +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** + +```powershell +pnpm exec jest --runInBand --runTestsByPath test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +pnpm run typecheck +``` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```powershell +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`: + +```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** + +```powershell +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`: + +```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, + ) {} + + /** + * 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; + }) { + 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; + 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()`: + +```ts +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: + +```ts +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`: + +```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** + +```powershell +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** + +```powershell +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`: + +```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** + +```powershell +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`: + +```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: + +```ts +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** + +```powershell +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** + +```powershell +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** + +```powershell +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: + +```ts +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`: + +```ts +export interface NapcatRuntimeProfileDetail { + loginEvents: Array<{ + createTime?: string; + eventKind: string; + eventSource: string; + eventStatus: string; + }>; + protocolProfile?: Record; + riskMode?: Record; + runtimeProfile?: Record; + sessionBehaviorProfile?: Record; +} + +export function getQqbotNapcatRuntimeDetail(accountId: string) { + return requestClient.get( + '/qqbot/napcat/runtime/detail', + { params: { accountId } }, + ); +} +``` + +- [ ] **Step 3: Create drawer component** + +Create `NapcatRuntimeProfileDrawer.tsx`: + +```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, + }, + open: { + default: false, + type: Boolean, + }, + }, + emits: ['update:open'], + setup(props, { emit }) { + const detail = ref(); + 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 () => ( + emit('update:open', false)} + > + + + + {props.account?.napcat?.profileStatus || 'unknown'} + + + {props.account?.napcat?.riskMode || 'normal'} + + + {props.account?.napcat?.recoveryState || 'idle'} + + + ( + + {item.createTime} + {item.eventKind} + {item.eventStatus} + + )} + /> + + + ); + }, +}); +``` + +- [ ] **Step 4: Add list row action** + +In `account/list.tsx`, add a ref for drawer state and a row action: + +```tsx +const runtimeProfileOpen = ref(false); +const runtimeProfileAccount = ref(); + +function openRuntimeProfile(row: QqbotApi.Account) { + runtimeProfileAccount.value = row; + runtimeProfileOpen.value = true; +} +``` + +Add action: + +```ts +{ + 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** + +```powershell +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** + +```powershell +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.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: + +```md +### 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: + +```md +## 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** + +```powershell +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** + +```powershell +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** + +```powershell +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: + +```powershell +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** + +```powershell +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** + +```powershell +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: + +```powershell +$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** + +```powershell +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`. diff --git a/docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.zh-CN.md b/docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.zh-CN.md new file mode 100644 index 0000000..456628c --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.zh-CN.md @@ -0,0 +1,313 @@ +# QQBot NapCat Runtime / Protocol Profile 中文方案 + +> 本文是给人读的中文方案说明。英文版 `2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 是给执行代理使用的逐任务实施清单,后续代码实现以英文清单为准。 + +## 一句话结论 + +这轮不是单纯把 Docker 做得更像 Linux,而是先把 QQBot 的登录事件、会话行为、协议配置和运行环境都变成可观测、可回滚、可灰度的 Profile。真正优先级是:会话/行为画像、登录事件最小化、官方建议的 `o3HookMode=0`/IP/设备迁移,最后才是 Docker/中文桌面运行态卫生。 + +## 当前状态 + +- 设计文档已确认:`docs/superpowers/specs/2026-06-18-qqbot-napcat-linux-runtime-protocol-profile-design.md`。 +- 英文实施计划已生成:`docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md`。 +- 当前还没有进入代码实现阶段,线上规则仍按现有 NapCat 链路运行。 +- 用户已确认:现有账号已经进入风控,可以做受控设备身份迁移;MAC 不走 Docker/QEMU/KVM/VMware/Hyper-V 风格前缀;`zh_CN.UTF-8` 派生镜像和真实中国桌面环境必须做;不做账号级每小时/每日发送预算。 + +## 总目标 + +1. 让每个 NapCat 账号拥有稳定、可审计的设备身份:dataDir、hostname、MAC、machine-id。 +2. 让登录恢复链路稳住 quick -> password,减少无意义的 `docker rm -f`、重建容器、刷新二维码和反复扫码。 +3. 建立 `Session Behavior Profile`,降低“零客户端行为 + 永久在线 + 登录后立即自动化输出”的异常画像。 +4. 建立 `Protocol Risk Profile`,统一管理 NapCat/OneBot 配置、`o3HookMode` 灰度、版本 drift、出口/IP/代理证据。 +5. 构建 KT 受控的 `Chinese Desktop Runtime` 派生镜像,提供 `zh_CN.UTF-8`、中文字体、fontconfig、上海时区、XDG/Home、DBus/Xvfb/QQ 进程环境。 +6. Admin 先做只读证据展示,危险操作后续单独设计,不在首版开放批量按钮。 + +## 明确不做 + +- 不绕过 QQ 验证码、新设备验证或安全验证。 +- 不伪造 QQ/NTQQ 私有协议签名,不写未验证的内部协议“真人模拟”。 +- 不启用 `--privileged`、`--network=host`、`--pid=host`、`--uts=host`、host IPC。 +- 不把 watchdog 做成自动扫码、自动刷新二维码、自动反复重建容器的兜底机制。 +- 不做账号级每小时/每日累计发送预算,也不做变相累计硬额度。 +- 不把 OneBot 心跳当成 QQ 登录成功证据。 + +## 架构分层 + +```text +Admin 只读展示 + -> API QQBot Core + -> QqbotAccountNapcatRuntimePort + -> NapCat Runtime/Profile 应用层 + -> 设备身份 Profile + -> 登录事件 Profile + -> Session Behavior Profile + -> Protocol Risk Profile + -> Chinese Desktop Runtime Profile + -> NAS SSH / Docker / NapCat WebUI / OneBot reverse WS +``` + +边界原则: + +- Core 只通过端口消费 NapCat 能力,不直接拼 Docker/NapCat 细节。 +- NapCat 模块内部负责 profile 生成、配置写入、证据采集、恢复租约和登录事件。 +- Admin 首版只展示 evidence 和状态,不直接承载危险批量迁移。 +- 所有敏感字段在入库前、日志前、API 返回前三层脱敏。 + +## 数据表设计摘要 + +### `napcat_device_identity` + +继续作为设备身份真相源,保存账号稳定的 dataDir、hostname、MAC、machine-id、验证状态和最近登录证据。它只管“这台设备是谁”,不塞会话行为或风险降载状态。 + +### `napcat_runtime_profile` + +保存 Docker/中文桌面运行态证据,包括镜像 ref/digest、base digest、desktop profile version、locale、fontconfig、时区、UID/GID、shm、XDG、持久目录、自检 evidence、profile 状态。 + +### `napcat_protocol_profile` + +保存 NapCat/OneBot 协议配置,包括 `packetBackend`、`packetServer`、`o3HookMode`、账号级灰度状态、OneBot/NapCat 配置 JSON 与 hash、版本 drift evidence。 + +### `napcat_session_behavior_profile` + +保存会话行为策略,包括冷启动窗口、housekeeping 是否启用、下一次 housekeeping、presence 能力、自动能力阶段和最近行为 evidence。这个表不保存小时/日发送额度。 + +### `napcat_login_event` + +记录登录侧风控事件,包括 quick 尝试、password 尝试、容器 restart/recreate、二维码生成/扫码、验证码、新设备验证、恢复挂起。它用于审计和熔断,不是消息发送预算。 + +### 风险降载状态 + +可独立成轻量表,也可落在现有账号运行态里,但语义必须独立于 QQ 登录态、OneBot 连接态和发送日志。状态只表达 `normal`、`cooldown`、`manual_only` 这类运行模式,不表达每日额度。 + +## 第一优先级:登录事件最小化 + +每一次容器删除重建、扫码、验证码、新设备验证,都会增加登录侧风险。watchdog 的目标不是“永远自动恢复”,而是“在不制造新登录事件的前提下尽量恢复”。 + +watchdog 自动链路只允许: + +1. 获取同账号恢复租约,保证同一账号同一时间只有一个恢复流程。 +2. 检查账号绑定唯一、容器唯一、账号与容器一致。 +3. 复用当前容器、当前 dataDir、当前 hostname/MAC/machine-id。 +4. 先 quick 恢复。 +5. quick 失败且账号保存了密码时,再 password 恢复。 +6. 密码登录成功后清理运行态密码环境,清理失败必须阻断成功。 +7. 遇到验证码、新设备验证、二维码兜底、账号不匹配、连续恢复失败或 profile drift,立即挂起自动恢复并通知 Admin。 + +watchdog 明确不做: + +- 不自动清理 QQ 登录态。 +- 不自动删除 dataDir。 +- 不反复 `docker rm -f`。 +- 不自动刷新二维码。 +- 不在验证码或新设备验证 pending 时切换其他登录路径。 + +## 第二优先级:会话行为 Profile + +重点是降低无头会话画像,而不是简单“少发消息”。 + +首版只做低副作用行为: + +- 登录成功后的冷启动窗口:先允许手动 smoke,再逐步恢复文本命令、图片命令、自动回复、复读机。 +- 低频 housekeeping:刷新自身状态、账号登录态、群/好友基础缓存或 NapCat 稳定公开接口,不能变成群聊刷存在感。 +- presence capability detection:只有 NapCat/OneBot 有稳定公开能力时才启用在线/离开类状态切换;没有公开能力就不做。 +- 风险事件降载:出现验证码、新设备验证、KickedOffLine、连续发送失败后,自动回复/复读机进入保守状态,管理员手动命令仍可低频测试。 + +housekeeping 或 presence 失败只记录 evidence,不触发登录 reset、密码重试、容器重建或二维码刷新。 + +## 第三优先级:Protocol Risk Profile + +NapCat/OneBot 配置由 API 统一生成,不再依赖手工漂移: + +- `webui.json` +- `napcat.json` +- `napcat_.json` +- `onebot11.json` +- `onebot11_.json` + +默认策略: + +- `packetBackend=auto` +- `o3HookMode=1` +- OneBot 只启用反向 WebSocket client。 +- `messagePostFormat=array` +- `reportSelfMessage=false` +- `debug=false` +- `parseMultMsg=false` +- 不启用 HTTP server/client、WebSocket server。 + +灰度策略: + +- `o3HookMode=0` 只允许账号级灰度。 +- 先用测试账号,记录 NapCat 版本、QQNT 版本、镜像 digest、登录结果、收发结果、是否触发验证码/新设备/掉线。 +- 如果测试账号收益明确,再按账号批次推广。 + +IP/代理/出口证据: + +- 本轮先做可观测,不自动全局切换网络。 +- 记录账号相关连接出口、地域、代理策略和变化窗口。 +- 后续是否引入账号级代理,单独做方案。 + +## 第四优先级:真实设备身份迁移 + +新账号和现有账号都走稳定设备身份,但现有账号已经被确认风控,因此允许直接迁移到真实设备风格: + +- hostname 不包含 QQ 号、bot、napcat、docker 等词。 +- MAC 使用真实物理设备风格 OUI catalog。 +- 明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀。 +- machine-id 继续稳定生成并只读挂载。 + +迁移流程: + +1. 记录迁移前 hostname/MAC/machine-id/登录状态。 +2. 生成新的真实设备风格 hostname/MAC/machine-id。 +3. 按登录事件最小化规则重建目标容器。 +4. 如触发新设备验证,按现有新设备链路完成,不当作代码失败。 +5. 登录完成后记录迁移后 evidence、登录事件和收发结果。 +6. 单账号失败只回滚单账号,不做盲目全量反复重建。 + +## 第五优先级:Chinese Desktop Runtime + +这一层是运行卫生和证据能力,不是主要风控缓解项。必须做,但不能把它的收益讲过头。 + +派生镜像要求: + +- 基础镜像必须 pin 到明确 digest。 +- 镜像内生成并启用 `zh_CN.UTF-8`。 +- 默认 `LANG=zh_CN.UTF-8`、`LC_ALL=zh_CN.UTF-8`、`LANGUAGE=zh_CN:zh`。 +- 时区固定 `Asia/Shanghai`。 +- 安装可再分发中文字体,预生成 fontconfig cache。 +- `fc-match` 能解析常见中文字体 fallback。 +- 保持上游 QQ/Xvfb/NapCat entrypoint 行为,不破坏隐藏容器痕迹的初始化逻辑。 +- 支持 XDG/Home、cache、local-share、config、plugins、logs 持久化。 + +Docker run 运行态: + +- 增加 `--init`。 +- 增加 `--shm-size`,默认建议 `512m`。 +- 不启用 host PID/UTS/IPC/network。 +- 默认不再以 root 运行 QQ/Xvfb,改用 NAS 专用普通 UID/GID。 +- 非 root 改动必须验证没有削弱 entrypoint 现有容器隐藏行为。 + +持久目录建议: + +```text +account-data/ + QQ/ + cache/ + local-share/ + config/ + plugins/ + logs/ + machine-id + device.env + runtime-profile.json + protocol-profile.json +``` + +重置登录态时不得删除 device/profile/cache/local-share/logs,除非用户明确执行“重建设备身份”。 + +## API 和 Admin 首版能力 + +API 首版提供只读接口: + +- runtime profile 摘要。 +- protocol profile 摘要。 +- session behavior profile 摘要。 +- profile drift。 +- 最近登录事件。 +- 风险降载状态。 +- 下一次自动恢复时间。 +- 最近 housekeeping/presence evidence。 + +Admin 首版做只读 Drawer: + +- 镜像 ref/digest、base digest、desktop profile version。 +- `zh_CN.UTF-8`、字体/fontconfig、时区、XDG、UID/GID、shm。 +- hostname/MAC/machine-id 一致性。 +- `packetBackend`、`o3HookMode`、OneBot 配置 hash。 +- 冷启动窗口、housekeeping、presence、自动能力阶段。 +- 最近 `docker rm -f`、重建、扫码、验证码、新设备验证、恢复挂起记录。 +- 风险降载原因和解除时间。 + +首版不开放批量 `o3HookMode=0`、批量设备迁移、批量清登录态等危险按钮。 + +## 分期落地路径 + +### Phase 1:表结构和门禁 + +- 建 profile/event/risk 相关表。 +- 加 SQL verify。 +- 加 no daily/hour budget、MAC 前缀排除、敏感字段脱敏的测试门禁。 + +### Phase 2:设备身份和配置生成 + +- 收敛 hostname/MAC/machine-id 策略。 +- 引入真实物理设备风格 OUI catalog。 +- 统一生成 NapCat/OneBot 配置并计算 hash。 +- 记录 profile drift evidence。 + +### Phase 3:登录事件和 watchdog 稳定化 + +- 加恢复租约。 +- 记录登录事件。 +- 强制 watchdog 只走 quick -> password。 +- 遇到验证码、新设备验证、二维码兜底和连续失败时挂起自动恢复。 + +### Phase 4:Session Behavior Profile + +- 实现冷启动窗口。 +- 实现 housekeeping 调度和 evidence。 +- 实现 presence capability detection。 +- 实现自动能力逐步恢复和风险降载。 + +### Phase 5:Chinese Desktop Runtime + +- 构建 KT 派生镜像。 +- 验证 `zh_CN.UTF-8`、中文字体、fontconfig、时区、XDG、DBus/Xvfb/QQ 进程环境。 +- 引入 `--init`、`--shm-size`、非 root UID/GID。 +- 验证不破坏 entrypoint 现有隐藏能力。 + +### Phase 6:API/Admin 只读闭环 + +- 暴露 runtime/protocol/session behavior/login event 只读接口。 +- Admin 账号页接 Runtime Profile Drawer。 +- 页面明确区分 QQ 登录态、OneBot 连接态、容器状态、恢复状态。 + +### Phase 7:线上灰度和迁移 + +- 测试账号先跑完整 profile 自检和收发 smoke。 +- 测试账号灰度 `o3HookMode=0`。 +- 测试账号做真实设备身份迁移。 +- 现有风控账号按批次迁移。 +- 每个账号记录迁移前后 evidence、登录事件、收发结果和回滚点。 + +## 验收标准 + +本轮完成后,至少要能证明: + +- API 能查询每个账号的 runtime/protocol/session behavior profile。 +- 登录事件能区分 quick、password、restart、recreate、QR、captcha、新设备、suspended。 +- watchdog 不会自动进入 QR、不反复 `docker rm -f`、不自动刷新二维码。 +- `o3HookMode=0` 只能账号级灰度,且有版本/digest/收发证据。 +- MAC 策略排除 Docker/QEMU/KVM/VMware/Hyper-V 前缀。 +- Chinese Desktop 派生镜像通过 `zh_CN.UTF-8`、fontconfig、时区、XDG、QQ/Xvfb 进程、entrypoint 行为验证。 +- Admin 能只读展示 profile evidence 和风险状态。 +- 没有账号级小时/日累计发送预算配置。 +- 敏感字段不会进入日志、API 响应或未脱敏 evidence。 +- 线上至少完成一个测试账号闭环,再迁移现有账号。 + +## 回滚策略 + +- 表结构新增不影响旧链路,必要时先停用 profile 服务,不删除数据。 +- `o3HookMode=0` 可按账号回滚到 `1`。 +- Chinese Desktop Runtime 失败时可按账号回滚到旧镜像 ref,但保留 profile evidence。 +- 非 root UID/GID 失败时只回退进程用户策略,不回退中文 locale/字体/时区镜像建设。 +- 设备身份迁移失败时只回滚单账号的 device identity,不并发重建其他账号。 +- watchdog 熔断后等待人工处理,不用自动重试掩盖问题。 + +## 和英文实施计划的关系 + +- 本中文文档用于确认方向、边界、风险和验收。 +- 英文实施计划用于执行,里面有具体文件、测试、提交节奏和任务拆分。 +- 后续开始实现时,应按英文计划走 `superpowers:subagent-driven-development` 或 `superpowers:executing-plans`。 +- 如果实现阶段发现英文计划与本中文方案冲突,以本中文方案的目标和边界为准,再同步修订英文计划。