Compare commits

...

12 Commits

44 changed files with 6423 additions and 105 deletions

9
API.md
View File

@ -338,6 +338,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
| `POST` | `/qqbot/account/scan/cancel?sessionId=` | 取消扫码会话 |
| `POST` | `/qqbot/account/delete?id=` | 删除账号并断开 WS |
| `POST` | `/qqbot/account/kick?selfId=` | 断开反向 WS 会话 |
| `GET` | `/qqbot/napcat/runtime/detail?accountId=` | 读取账号 NapCat 运行态证据 |
| `POST` | `/qqbot/account/bind/command` | 绑定账号和在线命令 |
| `POST` | `/qqbot/account/unbind/command` | 解绑账号和在线命令 |
| `POST` | `/qqbot/account/bind/rule` | 绑定账号和自动回复规则 |
@ -353,6 +354,14 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用这些设备字段,并在 Docker run 中注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载,降低每次重建都被 QQ 判定为新设备的概率。
### 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 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。
### Command / Rule / Permission

View File

@ -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/MACNapCat/OneBot 配置 hashKT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口watchdog quick -> password 熔断,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network也不做账号级每小时/每日累计发送预算。
## 启动
```bash

View File

@ -0,0 +1,30 @@
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

View File

@ -0,0 +1,20 @@
# 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`.

View File

@ -0,0 +1,12 @@
#!/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=${XDG_CONFIG_HOME:-}" = "XDG_CONFIG_HOME=/app/.config"
test "XDG_CACHE_HOME=${XDG_CACHE_HOME:-}" = "XDG_CACHE_HOME=/app/.cache"
test "XDG_DATA_HOME=${XDG_DATA_HOME:-}" = "XDG_DATA_HOME=/app/.local/share"
test ! -e /.dockerenv
grep -q '^0::/$' /proc/1/cgroup

View File

@ -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_<uin>.json`
- `onebot11.json`
- `onebot11_<uin>.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 4Session Behavior Profile
- 实现冷启动窗口。
- 实现 housekeeping 调度和 evidence。
- 实现 presence capability detection。
- 实现自动能力逐步恢复和风险降载。
### Phase 5Chinese Desktop Runtime
- 构建 KT 派生镜像。
- 验证 `zh_CN.UTF-8`、中文字体、fontconfig、时区、XDG、DBus/Xvfb/QQ 进程环境。
- 引入 `--init`、`--shm-size`、非 root UID/GID。
- 验证不破坏 entrypoint 现有隐藏能力。
### Phase 6API/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`
- 如果实现阶段发现英文计划与本中文方案冲突,以本中文方案的目标和边界为准,再同步修订英文计划。

View File

@ -0,0 +1,697 @@
# QQBot NapCat 会话行为、Chinese Desktop Runtime 与协议风险 Profile 设计
## 背景
线上 QQBot 使用 API 通过 NAS SSH 托管多个 NapCat Docker 容器。前一阶段已经完成按账号持久化 QQ 数据目录、hostname、MAC 和 `/etc/machine-id`,避免普通容器重建被 QQ 直接识别成全新设备。
本轮用户明确的目标是:让线上 NapCat Docker 环境更像真实 Linux 环境,并把协议特征也纳入方案。这里的“协议特征”只覆盖 NapCat/OneBot 可配置项、版本漂移、发送行为和自动化行为降噪,不包括绕过 QQ 安全验证或篡改 QQ 协议签名。
设计重心需要和既有证据对齐2026-06-12 的线上排查已经把粗粒度容器检测排在低优先级,镜像 entrypoint 也已隐藏 `/.dockerenv`、伪造部分 `/proc`/cgroup/DMI 信息。当前更可信的风险来源是“无头会话被服务端注销”的会话/行为画像。因此本设计把环境拟真定位为运行卫生和漂移可观测,不把它当成主要风控缓解手段;主要投入顺序应是会话/行为画像、登录事件最小化、官方建议的 o3Hook/IP/设备灰度,再到 Docker/Linux 运行态卫生。
只读审计得到的当前线上状态:
- 4 个线上 NapCat 容器都运行 `mlikiowa/napcat-docker:latest`
- 已有 `bridge` 网络、固定 hostname/MAC、只读 `/etc/machine-id`、QQ 数据目录持久化。
- 容器入口脚本已隐藏部分容器痕迹:`/.dockerenv` 不存在,`/proc/1/cgroup` 为 `0::/`
- 仍存在明显容器默认态:`/dev/shm=64M`、`CgroupnsMode=private`、`Init=<nil>`、`NAPCAT_UID/GID=0`、QQ/Xvfb 进程以 root 运行。
- 运行态 locale 只有 `C/C.utf8/POSIX`,未显式设置 `LANG` / `LC_ALL` / XDG 目录。
- 当前 hostname 形如 `kt-qqbot-napcat-<QQ号>`,稳定但不接近普通 Linux 主机名。
- 当前 MAC 前缀为 `02:42`,符合 Docker 默认风格。
- 当前 NapCat 配置里 `packetBackend=auto``o3HookMode=1`。
- 当前 OneBot 配置只开启反向 WebSocket`messagePostFormat=array`、`reportSelfMessage=false`、`debug=false`、`parseMultMsg=false`。
- 当前镜像 digest、NapCat 版本、QQNT 版本需要进入常规自检证据,不能只在灰度或故障排查时临时读取。
上游资料约束:
- NapCat Docker README 确认 QQ 持久化数据路径为 `/app/.config/QQ`,配置目录为 `/app/napcat/config`,插件目录为 `/app/napcat/plugins`
- NapCat Docker compose 示例使用 `network_mode: bridge` 并固定 `mac_address`
- NapCat 安全页建议:频繁掉线可通过更换账号或设备改善;遇到复杂网络可调整 IP/代理;无法更换账号或设备时可尝试 `o3Hook=0` 关闭包拦截。
- NapCat 基础配置说明 OneBot 的 HTTP/WS 服务端、客户端配置结构,以及账号级 `onebot11_<uin>.json` 文件。
- Docker 文档说明 `--init`、`--shm-size`、`--network`、macvlan 等运行参数和网络模型边界macvlan 可以让容器表现为物理网络接口,但会引入 host 访问与网络运维复杂度。
## 目标
1. 建立 `NapCat Session Behavior Profile`:降低“零客户端行为 + 永久死在线”的异常画像,而不是只做环境拟真。
2. 最小化登录事件watchdog 恢复必须优先稳定 quick -> password避免频繁 `docker rm -f`、重建容器、生成二维码和反复扫码。
3. 建立 `NapCat Protocol Risk Profile`:把 NapCat/OneBot 配置、`o3HookMode` 灰度、版本 pin、IP/代理证据和自动行为降噪纳入统一 profile。
4. 建立 `NapCat Chinese Desktop Runtime Profile`:基于 NapCat 上游镜像派生真实中国桌面环境镜像,包含 `zh_CN.UTF-8`、中文字体、fontconfig、时区、XDG/Home、DBus/Xvfb/QQ 进程环境;它仍作为运行卫生和漂移可观测项,不替代会话/行为画像。
5. 对现有已风控账号执行受控设备身份迁移,改为真实设备风格 hostname/MAC/machine-id 组合,并保留迁移前后证据。
6. 提供线上自检证据容器运行态、NapCat 配置、OneBot 配置、协议风险状态、会话行为状态都能被 API 查询或记录。
7. 先以测试账号验证策略,再按账号批次直接迁移现有风控账号的 hostname/MAC。
## 非目标
- 不绕过 QQ 验证码、新设备验证或安全验证。
- 不伪造 QQ 协议签名,不修改 QQ/NTQQ 私有协议字段。
- 不默认启用 `--privileged`、`--pid=host`、`--uts=host`、`--network=host`。
- 不直接全量切换 `o3HookMode=0`
- 不做账号级每小时或每日发送上限,也不做变相累计硬额度。
- 不在本轮引入 macvlan/ipvlan 作为默认网络模型;它只保留为后续增强方案。
- 不把 watchdog 做成自动扫码或自动反复重建容器的兜底机制。
- 不伪造 QQ/NTQQ 私有协议字段,不用未验证的内部协议接口“模拟真人”。
- 不把会话 housekeeping 设计成消息刷量、群聊刷存在感或绕过验证码/新设备验证。
## 方案总览
本轮采用安全增量方案:
```text
QqbotNapcatContainerService
-> NapcatRuntimeProfileService
-> 解析账号的 Linux runtime profile
-> 解析账号的协议风险 profile
-> 生成 Docker run 参数、环境变量、挂载目录
-> 生成 NapCat/OneBot 配置文件内容
-> 远程 NAS create/recreate script
-> 准备持久目录和 profile 文件
-> 写入 webui/onebot/napcat 配置
-> docker run with runtime profile
-> NapcatRuntimeProfileInspector
-> SSH/docker inspect/docker exec 读取运行态证据
-> 写入最近 profile evidence
```
profile 分为四层:
1. **设备身份层**:账号稳定的 dataDir、hostname、MAC、machine-id。
2. **会话行为层**:低噪声 housekeeping、presence 状态、冷启动窗口、自动行为降噪。
3. **协议风险层**NapCat `packetBackend` / `o3HookMode`、OneBot 配置、IP/代理证据、登录事件最小化和风险事件降载。
4. **Chinese desktop runtime 层**:派生镜像、`zh_CN.UTF-8`、中文字体、fontconfig、UID/GID、shm、init、XDG、持久目录、日志目录。
## 风险重心与优先级
本轮设计不应把“容器更像 Linux”误当成主要收益来源。基于既有证据优先级为
1. **会话/行为画像**:处理零客户端行为、永久死在线、冷启动后立即高频响应、自动回复长期无节律的问题。
2. **登录事件最小化**:减少 `docker rm -f`、重建、扫码、验证码、新设备验证这些登录侧风控事件。
3. **官方建议的协议杠杆**:测试账号优先灰度 `o3HookMode=0`,同时记录 IP/代理/出口证据;现有已风控账号按批次迁移设备身份。
4. **环境/runtime 卫生**派生中国桌面镜像、image pin、版本 drift、shm、`zh_CN.UTF-8`、fontconfig、XDG、非 root、持久目录用于降低噪声和提高可观测性但不承诺单独解决服务端会话注销。
Docker/Linux runtime profile 的验收标准不是“看起来更像真人机器”,而是“不引入新的登录事件、不破坏现有 entrypoint 反检测、不制造 profile drift并提供诊断证据”。
## Docker 与 Chinese Desktop Runtime Profile
本层是卫生项和可观测项,不是主要风控缓解项。它的设计目标是减少明显运行态漂移、提升诊断质量、避免默认容器参数造成 QQ/Chromium 异常;若某个 runtime 改动会削弱已有 entrypoint 反检测或触发登录事件,应让位给会话/行为和登录稳定性。
### 镜像 pin
生产环境必须显式配置 `QQBOT_NAPCAT_IMAGE`,并优先使用明确 tag 或 digest。
- 允许:`mlikiowa/napcat-docker@sha256:<digest>`。
- 允许:明确版本 tag前提是上线前记录 digest。
- 不推荐:`mlikiowa/napcat-docker:latest`。
API 的 profile inspector 需要记录当前容器实际 `RepoDigest``ImageId`,用于判断线上是否漂移。
### 中国桌面派生镜像
本轮必须构建并使用 KT 受控派生镜像,不再直接以 `mlikiowa/napcat-docker` 原镜像作为最终运行镜像。派生镜像命名建议:
```text
kt-napcat-desktop-cn:<baseDigestShort>-<profileVersion>
```
派生镜像必须满足:
- 基础镜像使用已 pin 的 NapCat Docker digest不使用漂移的 `latest`
- 生成并启用 `zh_CN.UTF-8`,默认环境为 `LANG=zh_CN.UTF-8`、`LC_ALL=zh_CN.UTF-8`、`LANGUAGE=zh_CN:zh`。
- 时区固定为 `Asia/Shanghai``/etc/localtime` 与 `TZ` 一致。
- 安装中文字体和 fontconfig优先使用可再分发的 `Noto Sans CJK`、`Noto Serif CJK`、`WenQuanYi Micro Hei` 等字体;不得打包无授权的商业字体。
- 预生成 fontconfig cache验证 `fc-match` 能解析常见中文字体族。
- 保留并验证上游 QQ/Xvfb/NapCat entrypoint 行为,不破坏其隐藏容器痕迹的初始化逻辑。
- 保持 `/app`、XDG、cache、local-share、config、plugins、logs 的持久化路径兼容。
- 提供镜像自检脚本或 inspector 证据,输出 locale、timezone、fontconfig、QQ/Xvfb 进程用户、entrypoint 隐藏能力、image digest。
“真实中国桌面环境”的验收标准不是安装一个 locale 包就结束,而是容器内运行 QQ/Chromium 时看到一致的中文桌面运行上下文:中文 UTF-8 locale、中文字体 fallback、上海时区、正常 XDG/Home、可用 DBus/Xvfb 运行环境和稳定的普通用户进程身份。
### 进程与共享内存
Docker run 增加:
- `--init`:让容器内有 init 处理信号与子进程回收。
- `--shm-size "$NAPCAT_SHM_SIZE"`:默认建议 `512m`,后续可按内存观察调整到 `1g`
不启用 host PID/UTS/IPC。保持容器隔离和多账号端口池模型。
### 非 root 运行
当前镜像入口脚本需要 root 做初始化,然后用 `gosu napcat` 启动 Xvfb 与 QQ。我们不改镜像入口行为只改变传入的 `NAPCAT_UID/GID`
- 默认不再传 `0/0`
- 引入 `QQBOT_NAPCAT_RUNTIME_UID``QQBOT_NAPCAT_RUNTIME_GID`,默认值使用 NAS 上专用普通 UID/GID。
- 容器启动后自检 QQ/Xvfb 进程是否已从 root 切换到该 UID/GID。
远程创建脚本在启动容器前必须检查并修正 QQ、cache、config、plugins、logs、local-share 目录归属,再用目标 UID/GID 做写入探测。权限不满足时,容器创建失败应明确记录为 runtime profile failure而不是被吞成普通登录失败也不能静默回退成 root 运行。
交叉风险:镜像 entrypoint 依赖 root 初始化来伪造或隐藏部分容器痕迹,然后再 `gosu napcat` 降权。实现时必须验证非 root UID/GID 不会削弱这些已存在的 entrypoint 行为,例如 `/proc`/cgroup/DMI 处理、隐藏 `/.dockerenv`、Xvfb/QQ 启动链路和 `/app` 权限。如果验证发现非 root 会破坏当前隐藏能力或登录稳定性,则只回退非 root 运行项Chinese Desktop 派生镜像仍然保留,不能把 QQ/Xvfb 降权失败扩大成放弃 `zh_CN.UTF-8`/字体/时区环境。
### Locale、Desktop Env 与 XDG
Docker env 增加:
- `LANG=zh_CN.UTF-8`
- `LC_ALL=zh_CN.UTF-8`
- `LANGUAGE=zh_CN:zh`
- `HOME=/app`
- `XDG_CONFIG_HOME=/app/.config`
- `XDG_CACHE_HOME=/app/.cache`
- `XDG_DATA_HOME=/app/.local/share`
- `XDG_RUNTIME_DIR=/tmp/runtime-napcat`
- `TZ=Asia/Shanghai`
派生镜像必须让 `locale -a` 包含 `zh_CN.utf8`,容器内 `locale` 输出与上述环境一致。`C.UTF-8` 只允许作为构建失败时的调试 fallback不能作为生产 profile 通过条件。
### 持久目录
每个账号的数据目录保留并扩展:
```text
$DATA_DIR/
QQ/ -> /app/.config/QQ
cache/ -> /app/.cache
local-share/ -> /app/.local/share
config/ -> /app/napcat/config
plugins/ -> /app/napcat/plugins
logs/ -> /app/napcat/logs
machine-id -> /etc/machine-id:ro
device.env
runtime-profile.json
protocol-profile.json
```
重置登录态只允许清理 QQ 登录态相关数据,不删除 `device.env`、`machine-id`、`runtime-profile.json`、`protocol-profile.json`、cache/local-share/logs除非用户明确执行“重建设备身份”。
## 设备身份策略
### 新账号默认策略
新账号使用更接近普通物理设备的稳定身份:
- hostname例如 `ubuntu-pc-<stableHash>``linux-pc-<stableHash>`,不包含 QQ 号、bot、napcat、docker 等词。
- MAC使用真实物理设备风格的稳定 OUI 前缀策略,不使用 Docker 默认 `02:42`,也不使用 QEMU/KVM `52:54:00`、VMware、Hyper-V 等常见虚拟化前缀。实现时维护受控 OUI catalog优先选择常见物理网卡、主板、笔记本无线网卡厂商风格的前缀并记录所选策略、生成种子和迁移结果。
- machine-id继续稳定生成 32 位十六进制值并只读挂载。
### 现有账号迁移策略
用户已确认现有账号已经全部进入风控状态,本轮不再保守保留旧 Docker 风格 hostname/MAC。现有账号直接进入受控设备身份迁移迁移目标是稳定、可审计、真实设备风格的 hostname/MAC/machine-id 组合。
迁移按账号批次执行,不做无证据的全量并发重建:
1. 记录迁移前 hostname/MAC/machine-id/登录状态。
2. 生成真实设备风格 hostname/MAC/machine-id明确避开 Docker/QEMU/KVM/VMware/Hyper-V 前缀。
3. 按登录事件最小化规则重建目标容器,避免重复 `docker rm -f`
4. 允许触发新设备验证;触发后按现有新设备链路完成,不把它视为失败。
5. 完成登录后记录新的 device evidence、登录事件和风控状态。
6. 若单账号迁移后出现不可恢复登录失败,按迁移前 evidence 回滚该账号,不阻塞其他账号的人工判断。
## 会话行为 Profile
行为层的目标不是“发得更少”这么窄,而是降低无头会话的异常画像:长期在线但没有任何客户端 housekeeping、登录后立刻进入自动回复、全天候同质节奏、掉线后反复重登。所有行为都必须低噪声、可审计、可关闭并且只使用 NapCat/OneBot 已验证的公开能力;不使用未确认的内部协议字段。
### Presence 与 housekeeping
新增账号级 `session_behavior_profile`,第一阶段只允许无消息或低副作用动作:
- 登录成功后的冷启动窗口:只允许手动 smoke 和必要状态检查,自动回复、复读机、事件插件主动回复延迟启用。
- 低频 read-only housekeeping按长间隔和 jitter 刷新自身状态、账号登录态、群/好友基础缓存或 NapCat 支持的轻量状态接口,用于避免长期零客户端行为。
- presence 状态:只有在 NapCat/OneBot 明确支持稳定公开 API 时,才允许做低频在线/离开类状态切换;如果没有公开能力,宁可不做,不写私有协议模拟。
- 活跃窗口:按账号配置工作时段/安静时段,只影响自动回复、事件插件和复读机,不强制 QQ 下线,不阻断管理员手动命令。
- 异常后低频验证码、新设备验证、KickedOffLine、连续发送失败、刚恢复登录后进入更保守的 housekeeping 与自动回复窗口。
这些动作必须和消息发送区分housekeeping 不计入发送预算,也不能变成群聊刷存在感。
### 节律与随机化
行为 profile 使用长周期 jitter而不是短周期随机噪声
- 冷启动窗口按分钟级配置。
- housekeeping 按十分钟到小时级配置。
- presence 切换如果启用,按小时级或工作时段边界触发。
- 自动回复恢复采用逐步放开:手动命令 -> 低风险文本命令 -> 图片/大消息命令 -> 自动回复/复读机。
任何随机化都要可回放:记录 profile 版本、下一次计划动作、上一次动作、跳过原因和执行结果。实现时不要使用不可解释的纯随机行为。
### 失败边界
housekeeping 或 presence 失败不能触发登录态 reset、容器重建、二维码刷新或 password retry。它只写入行为 profile evidence并在连续失败后关闭该账号的行为扩展保留基础登录恢复链路。
## 登录事件最小化
登录事件比普通消息发送更敏感。每次 `docker rm -f`、容器重建、二维码生成、人工扫码、新设备验证、验证码验证都要视为一次可观察的登录侧风控事件。watchdog 的职责是降低无意义离线时间,不是自动制造新的登录事件。
### 事件分类
需要记录到账号运行态或登录事件日志的事件类型:
- `quick_attempt`:在当前设备身份和持久数据上尝试快速恢复。
- `password_attempt`:在当前账号密码凭据上尝试密码恢复。
- `container_restart`:重启同一容器实例。
- `container_recreate`:删除并重建容器,包含 `docker rm -f`
- `manual_qr_created`:生成人工扫码二维码。
- `manual_qr_scanned`:用户扫码。
- `captcha_required`:需要验证码。
- `new_device_required`:需要新设备验证。
- `recovery_suspended`:自动恢复熔断,等待人工处理。
这些事件不属于账号级发送预算;它们用于恢复链路幂等、审计和熔断。
### Watchdog quick -> password 恢复链路
watchdog 只允许自动走以下链路:
1. 获取账号恢复租约,保证同一账号同一时间只有一个恢复流程。
2. 校验唯一主绑定和唯一目标容器;存在多绑定、多容器或账号不匹配时直接 `recovery_suspended`
3. 复用当前容器、当前 dataDir、当前 machine-id、当前 hostname/MAC。
4. 先尝试 quick 恢复。quick 只允许使用已有历史登录态和 `ACCOUNT` 目标账号,不生成二维码。
5. quick 失败且账号保存了密码时,尝试 password 恢复。password 只允许复用当前容器或受控的一次性准备流程,不能把 `docker rm -f` 当作重复 retry。
6. password 成功后必须清理运行态密码环境;清理失败则阻断成功并写入专用错误。
7. 遇到验证码、新设备验证、二维码兜底、账号不匹配、连续恢复失败或容器/profile drift立即停止自动链路标记 `recovery_suspended`,只通知 Admin。
watchdog 不自动执行:
- 重置 QQ 登录态。
- 删除 QQ dataDir。
- 反复 `docker rm -f` 重建容器。
- 自动生成新二维码并等待扫码。
- 在二维码过期后自动刷新二维码。
- 在验证码或新设备验证 pending 时继续切换其他登录路径。
### 重建容器边界
容器重建只允许在明确的人工动作或一次性 profile 迁移动作里发生:
- 管理员手动“更新登录”。
- 新账号首次创建容器。
- 明确的 runtime/profile 版本迁移。
- 清理运行态密码环境的受控 rebuild且失败必须阻断成功。
watchdog 如果发现容器不存在,可以按当前 profile 创建一次目标容器,但必须记录 `container_recreate` 并进入较长 backoff如果下一轮仍失败不继续重复删除重建而是挂起等待人工处理。
### 恢复租约与熔断
新增或复用账号级恢复状态,至少表达:
- `recovery_state`: `idle` / `quick` / `password` / `suspended`
- `recovery_owner`
- `recovery_started_at`
- `last_login_event_kind`
- `last_login_event_at`
- `last_login_event_evidence`
- `next_auto_recovery_at`
`next_auto_recovery_at` 是登录恢复 backoff不是发送频率预算。它只用于阻止 watchdog 在短时间内重复制造登录事件。
### Admin 呈现
账号页或运行态详情需要区分:
- 当前 QQ 登录态。
- 当前 OneBot 连接态。
- 当前恢复状态。
- 最近一次登录事件类型。
- 是否因验证码、新设备验证、二维码 pending 或频繁重建风险而挂起自动恢复。
用户手动触发更新登录时Admin 要展示“这会产生一次登录事件/可能触发风控”的明确状态,而不是让 watchdog 在后台静默反复触发。
## Protocol Risk Profile
### NapCat 配置统一生成
API 创建或重建容器时统一生成以下文件:
- `webui.json`
- `napcat.json`
- `napcat_<uin>.json`
- `onebot11.json`
- `onebot11_<uin>.json`
这样默认配置与账号级配置不会漂移。账号级文件优先包含完整配置,默认文件只作为 fallback。
### NapCat 协议配置
默认 profile
```json
{
"packetBackend": "auto",
"packetServer": "",
"o3HookMode": 1,
"bypass": {
"hook": false,
"window": false,
"module": false,
"process": false,
"container": false,
"js": false
}
}
```
灰度 profile
- `o3HookMode=0` 只允许账号级配置启用。
- 默认只用于测试账号,但它不应排在环境拟真之后;它属于官方安全页明确提到的频繁掉线排查杠杆,应在行为 profile 与登录事件最小化基础落地后尽早灰度。
- 灰度必须记录:账号、切换时间、镜像 digest、NapCat 版本、QQNT 版本、登录状态、收发测试结果、是否触发验证码/新设备/掉线。
`packetBackend` 第一阶段保持 `auto`。只有出现明确 PacketBackend 异常时,才设计单独切换策略。
### 官方建议优先级
NapCat 安全页给出的方向是换账号/设备、调整 IP/代理、必要时 `o3Hook=0`。本设计按成本和可逆性排序:
1. **`o3HookMode=0` 测试账号灰度**:低成本、可回滚,优先于大规模 Docker 环境拟真推广。
2. **IP/代理/出口证据**:记录 QQ 相关连接的出口、地域、代理策略和变化窗口;只做证据与显式配置,不在本轮自动切换全局网络。
3. **设备身份迁移**:现有账号已确认全部风控,因此直接纳入批次迁移;迁移仍按账号串行或小批次执行,保留回滚证据,避免并发制造登录事件。
4. **换号**:属于业务/账号策略,不作为自动化修复手段。
实现时不能把 `o3HookMode=0` 的失败或成功归因给环境拟真;必须在同一账号、同一 image digest、同一 device identity 下对比。
### 版本与配置漂移
profile 自检必须记录:
- 容器镜像 ref、digest、image id。
- NapCat 版本。
- QQNT 版本。
- `napcat.json` / `napcat_<uin>.json` hash。
- `onebot11.json` / `onebot11_<uin>.json` hash。
版本信息属于常规 runtime/protocol evidence不只在 `o3HookMode=0` 灰度时记录。这样线上出现登录失败、验证码、新设备验证或频繁掉线时可以区分设备身份变化、镜像升级、NapCat/QQNT 升级和配置漂移。
### OneBot 配置
保持当前最小反向 WebSocket 模型:
- 不启用 HTTP server/client。
- 不启用 WebSocket server。
- 只启用 WebSocket client连接 API reverse WS。
- `messagePostFormat=array`
- `reportSelfMessage=false`
- `reconnectInterval=5000`
- `heartInterval=30000`
- `debug=false`
- `parseMultMsg=false`
- `enableLocalFile2Url=false`
后续实现时应继续避免把 OneBot 心跳当作 QQ 登录态证据。OneBot 连接只证明容器与 API 通信,不证明 QQ 账号在线。
## 行为/会话画像与发送降噪
本轮不做账号级小时/日累计预算。行为层分两类:一类是无消息或低副作用的 session behavior用来减少零客户端行为另一类是发送降噪用来避免自动回复和插件输出呈现机器节奏。发送降噪不能替代 session behavior。
### 保留现有实时节流
继续使用已有:
- 全局发送间隔。
- 单会话发送间隔。
- jitter。
- 命令冷却。
- 规则冷却。
- 复读机阈值、短文本限制、同会话最小间隔。
### 新增行为策略
1. **客户端 housekeeping**
- 只调用已验证的 NapCat/OneBot 稳定能力。
- 默认不发送群消息、不主动私聊、不修改资料。
- 失败只记录 evidence不触发登录 reset 或容器重建。
2. **presence profile**
- 仅在存在公开稳定 API 时启用。
- 使用小时级或工作时段级状态变化,不做分钟级抖动。
- 不把在线状态变化当成登录成功证据QQ 登录态仍以 NapCat WebUI/日志为准。
3. **自动能力逐步恢复**
- 登录成功后先开启手动命令。
- 再开启低风险文本命令。
- 最后开启图片/大消息、自动回复和复读机。
- 验证码、新设备验证、KickedOffLine 后重新进入保守恢复窗口。
### 新增发送降噪策略
1. **图片与大消息限频**
- 图片、大 CQ 消息、长文本回复使用独立最小间隔。
- BangDream 等图片命令不能在同一会话连续快速刷图。
2. **错误回复限频**
- 同一命令、同一会话、同一错误摘要在短窗口内只回复一次。
- 后续错误只写日志,不继续刷屏。
3. **冷启动低频窗口**
- 刚登录成功后进入短时间低频模式。
- 完成验证码或新设备验证后进入低频模式。
- 低频模式限制自动回复、复读机、事件插件主动回复;手动命令仍允许但走正常实时节流。
4. **风险事件降载**
- 出现 `KickedOffLine`、验证码、新设备验证、连续发送失败后,账号进入降载状态。
- 降载期间不自动触发复读/自动回复类事件。
- Admin 或后端 smoke 可以执行低频手动测试。
- 恢复条件由时间窗口、重新登录成功、手动解除三者之一触发。
### 不做的行为控制
- 不统计“账号每小时最多发 N 条”或“每天最多发 N 条”。
- 不在每日零点重置任何发送额度。
- 不用累计额度阻断用户手动测试。
## 数据模型
当前 `napcat_device_identity` 继续作为设备身份真相源。
新增或扩展的持久化应保持以下边界:
### `napcat_device_identity`
继续负责:
- `account_id`
- `container_id`
- `data_dir`
- `hostname`
- `machine_id_path`
- `mac_address`
- `verification_status`
- `last_login_evidence`
不把行为风险状态塞进该表。
### `napcat_runtime_profile`
新增账号级 runtime/profile 表,建议字段:
- `id`
- `account_id`
- `container_id`
- `device_identity_id`
- `profile_version`
- `image_ref`
- `image_digest`
- `base_image_digest`
- `desktop_profile_version`
- `locale_available`
- `fontconfig_evidence` JSON
- `timezone_evidence`
- `runtime_uid`
- `runtime_gid`
- `shm_size`
- `locale`
- `xdg_config_home`
- `xdg_cache_home`
- `xdg_data_home`
- `persist_cache`
- `persist_local_share`
- `persist_logs`
- `hostname_strategy`
- `mac_strategy`
- `migrate_device_identity`
- `profile_status`
- `last_check_evidence` JSON
- `last_checked_at`
- `create_time`
- `update_time`
### `napcat_protocol_profile`
新增账号级协议 profile 表,建议字段:
- `id`
- `account_id`
- `container_id`
- `profile_version`
- `packet_backend`
- `packet_server`
- `o3_hook_mode`
- `o3_hook_gray_enabled`
- `onebot_config_hash`
- `onebot_config_json` JSON
- `napcat_config_hash`
- `napcat_config_json` JSON
- `profile_status`
- `last_check_evidence` JSON
- `last_checked_at`
- `create_time`
- `update_time`
### `napcat_session_behavior_profile`
新增账号级会话行为 profile 表,建议字段:
- `id`
- `account_id`
- `profile_version`
- `enabled`
- `cold_start_until`
- `housekeeping_enabled`
- `housekeeping_interval_ms`
- `next_housekeeping_at`
- `last_housekeeping_at`
- `last_housekeeping_result`
- `presence_enabled`
- `presence_strategy`
- `last_presence_event_at`
- `next_presence_event_at`
- `auto_capability_stage`
- `last_behavior_evidence` JSON
- `create_time`
- `update_time`
该表不保存消息发送额度,不控制每天/每小时累计发送量。
### `napcat_login_event`
新增或复用登录事件日志,用于审计登录侧风控事件:
- `id`
- `account_id`
- `container_id`
- `event_kind`
- `event_source`: `admin` / `watchdog` / `runtime` / `system`
- `event_status`
- `evidence` JSON
- `created_at`
`event_kind` 至少覆盖 quick/password 尝试、容器 restart/recreate、二维码生成/扫码、验证码、新设备验证和恢复挂起。
### 风险降载状态
风险降载可先复用账号运行态字段或新增轻量状态表。设计上它不属于累计发送预算,建议保存:
- `account_id`
- `risk_mode`: `normal` / `cooldown` / `manual_only`
- `reason`
- `source_event`
- `expires_at`
- `last_evidence`
如果实现时已有合适账号状态字段可不单独建表但必须保证和发送日志、登录错误、OneBot 连接状态分离。
登录恢复状态可以和风险降载状态共表,也可以独立成轻量表;但它必须和 QQ 登录错误、OneBot 连接状态、发送频率控制分离,避免一次 WS close 触发自动重建容器。
## API 与 Admin 能力
### API
新增或扩展账号运行态接口,提供:
- 当前 runtime profile 摘要。
- 当前 protocol profile 摘要。
- 当前 session behavior profile 摘要。
- 最近自检证据。
- 是否存在 profile drift。
- 是否处于风险降载状态。
- 最近登录事件和下一次自动恢复时间。
- 最近 housekeeping/presence evidence。
敏感字段脱敏:
- WebUI token
- reverse WS token
- password/login runtime env
- SSH target key path 不回显私钥内容
脱敏边界必须覆盖三层:入库 profile/evidence 前、日志输出前、API/Admin 返回前。`onebot_config_json`、`napcat_config_json`、`last_check_evidence` 不允许把 token、密码、临时登录 env 原文写入数据库后再依赖展示层隐藏。
### Admin
Admin 第一阶段只需要只读展示:
- 镜像 ref/digest。
- 派生镜像 ref/digest、base image digest、desktop profile version。
- UID/GID、shm、`zh_CN.UTF-8`、中文字体/fontconfig、时区、XDG并标注这些属于运行卫生项。
- hostname/MAC/machine-id 是否与 profile 一致。
- `packetBackend`、`o3HookMode`、OneBot 配置 hash。
- session behavior profile冷启动窗口、housekeeping、presence、自动能力阶段。
- 登录事件:最近 `docker rm -f`/重建/扫码/验证码/新设备验证/恢复挂起记录。
- 风险降载状态和原因。
`o3HookMode=0`、hostname/MAC 迁移等危险操作必须后续单独设计确认,不在首版页面上开放全量批量按钮。
## 失败处理
- Docker run 参数不被宿主 Docker 支持时,容器创建失败并写入 profile failure。
- 非 root UID/GID 导致目录权限不正确时,容器创建失败,不回退成 root 静默运行。
- profile 自检失败不应误报登录失败,但必须在账号运行态中显示 drift。
- `o3HookMode=0` 灰度后如果出现登录失败、接口异常或发送异常,允许按账号回滚为 `1` 并重建容器。
- 风险降载不能清除已有 `lastError`,也不能把 OneBot WS close 当作 QQ 登录成功或失败。
- watchdog 恢复链路触发验证码、新设备验证或二维码兜底时必须挂起自动恢复,不能继续 `docker rm -f` 或刷新二维码。
- housekeeping/presence 失败不能触发登录 reset、密码重试、容器重建或二维码刷新只能关闭该账号的行为扩展并记录 evidence。
- 非 root、MAC、locale 等 runtime 改动如果和 entrypoint 隐藏能力、登录稳定性或既有设备身份冲突,必须回退该 runtime 改动,而不是继续扩大灰度。
## 验证计划
### 本地/单测
- Docker create script 包含 `--init`、`--shm-size`、非 root UID/GID、`zh_CN.UTF-8`/XDG env、cache/local-share/logs 挂载。
- 派生镜像测试覆盖 base digest pin、`locale -a` 包含 `zh_CN.utf8`、`locale` 输出中文环境、`date`/`TZ` 为 `Asia/Shanghai`、`fc-match` 中文字体 fallback、fontconfig cache、DBus/Xvfb/QQ 进程启动链路。
- 新账号 hostname/MAC 策略不包含 QQ 号、bot、napcat、docker 等词。
- MAC 策略使用真实物理设备风格 OUI catalog明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀。
- 现有已风控账号进入受控设备身份迁移,测试覆盖迁移前 evidence、迁移后 evidence、新设备验证链路和单账号回滚。
- NapCat config writer 同时生成默认文件与账号级文件。
- `o3HookMode=0` 只能通过账号级灰度配置启用。
- OneBot config 保持反向 WS 最小配置。
- session behavior 测试覆盖冷启动窗口、housekeeping 调度、presence capability detection、自动能力逐步恢复。
- housekeeping/presence 失败测试明确断言不会触发登录 reset、password retry、`docker rm -f` 或二维码刷新。
- 行为降噪测试覆盖图片/大消息限频、错误回复限频、冷启动低频、风险事件降载。
- 测试明确断言不存在账号级小时/日发送预算配置。
- watchdog 测试覆盖 quick -> password 顺序、恢复租约、同账号并发恢复去重、验证码/新设备/二维码兜底后挂起。
- watchdog 测试明确断言普通离线、OneBot WS close、密码失败重试不会触发重复 `docker rm -f`、重复生成二维码或重复扫码 session。
- 容器重建测试明确区分人工更新登录、首次创建、profile 迁移、密码环境清理 rebuild 和 watchdog 自动恢复。
- 非 root 测试必须验证 entrypoint 原有容器隐藏行为仍存在;若不存在,该 runtime profile 不允许通过。
- MAC 策略测试明确断言不得使用 QEMU/KVM `52:54:00` 或其他常见虚拟化前缀作为目标设备风格。
- locale 测试明确断言生产 profile 不允许以 `C.UTF-8` 通过;必须使用 `zh_CN.UTF-8` 派生镜像和中文桌面证据。
### 线上灰度
1. 使用一个测试账号。
2. 记录旧容器 profile evidence。
3. 启用登录事件记录与 watchdog quick -> password 稳定恢复,确认不重复重建容器、不自动刷新二维码。
4. 启用 session behavior profile观察 housekeeping/presence evidence确认不会产生消息刷量。
5. 对测试账号灰度 `o3HookMode=0`,记录 NapCat/QQNT 版本、收发结果和掉线情况。
6. 记录 IP/代理/出口证据,确认 QQ 相关连接变化窗口。
7. 对测试账号验证真实设备风格 OUI、hostname、machine-id 生成和新设备验证链路。
8. 再启用 Chinese Desktop Runtime Profile自检 Docker inspect 与容器内 runtime evidence重点验证 `zh_CN.UTF-8`、中文字体/fontconfig、时区、XDG、DBus/Xvfb/QQ 进程环境,以及非 root 不破坏 entrypoint 隐藏能力。
9. 执行手动命令、图片命令、自动回复、复读机 smoke。
10. 按账号批次迁移现有风控账号,并逐账号记录迁移 evidence、登录事件和收发结果。
## 上线顺序
1. 先落 profile/evidence、自检和只读展示明确 runtime 卫生项不是主要风控缓解项。
2. 落登录事件记录、恢复租约、watchdog quick -> password 稳定恢复和自动恢复熔断。
3. 落 session behavior profile冷启动窗口、housekeeping、presence capability detection、自动能力逐步恢复。
4. 测试账号灰度 `o3HookMode=0`,同步记录 IP/代理/出口证据。
5. 对测试账号验证真实设备风格 hostname/MAC/machine-id 和新设备验证链路。
6. 对现有已风控账号执行受控设备身份批次迁移,逐账号记录和回滚。
7. 构建并启用 Chinese Desktop Runtime 派生镜像,先验证 `zh_CN.UTF-8`、字体/fontconfig、时区、XDG、DBus/Xvfb/QQ 进程环境和非 root 不带来负向效果。
8. 验证登录、收发和行为 evidence 闭环。
## 参考来源
- NapCat Docker README: https://github.com/NapNeko/NapCat-Docker/blob/main/README.md
- NapCat Docker compose: https://github.com/NapNeko/NapCat-Docker/blob/main/compose/ws.yml
- NapCat 安全相关: https://napneko.github.io/other/security
- NapCat 基础配置: https://napneko.github.io/config/basic
- NapCat 高级配置: https://napneko.github.io/config/advanced
- Docker run: https://docs.docker.com/reference/cli/docker/container/run/
- Docker networking: https://docs.docker.com/engine/network/
- Docker macvlan: https://docs.docker.com/engine/network/drivers/macvlan/

View File

@ -70,8 +70,10 @@ CREATE TABLE IF NOT EXISTS `napcat_device_identity` (
`container_id` bigint DEFAULT NULL,
`data_dir` varchar(512) NOT NULL,
`hostname` varchar(128) NOT NULL,
`hostname_strategy` varchar(64) NOT NULL DEFAULT 'legacy',
`machine_id_path` varchar(512) NOT NULL,
`mac_address` varchar(64) NOT NULL,
`mac_strategy` varchar(64) NOT NULL DEFAULT 'legacy',
`verification_status` varchar(32) NOT NULL,
`last_login_evidence` json DEFAULT NULL,
`create_time` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
@ -141,6 +143,118 @@ CREATE TABLE IF NOT EXISTS `napcat_runtime_cleanup` (
KEY `idx_napcat_runtime_cleanup_session` (`session_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `napcat_runtime_profile` (
`id` bigint NOT NULL,
`account_id` bigint NOT NULL,
`container_id` bigint DEFAULT NULL,
`device_identity_id` bigint DEFAULT NULL,
`profile_version` varchar(64) NOT NULL,
`image_ref` varchar(255) NOT NULL,
`image_digest` varchar(255) DEFAULT NULL,
`base_image_digest` varchar(255) DEFAULT NULL,
`desktop_profile_version` varchar(64) DEFAULT NULL,
`locale_available` tinyint(1) NOT NULL DEFAULT 0,
`fontconfig_evidence` json DEFAULT NULL,
`timezone_evidence` json DEFAULT NULL,
`runtime_uid` int DEFAULT NULL,
`runtime_gid` int DEFAULT NULL,
`shm_size` varchar(32) DEFAULT NULL,
`locale` varchar(64) DEFAULT NULL,
`xdg_config_home` varchar(255) DEFAULT NULL,
`xdg_cache_home` varchar(255) DEFAULT NULL,
`xdg_data_home` varchar(255) DEFAULT NULL,
`persist_cache` tinyint(1) NOT NULL DEFAULT 1,
`persist_local_share` tinyint(1) NOT NULL DEFAULT 1,
`persist_logs` tinyint(1) NOT NULL DEFAULT 1,
`hostname_strategy` varchar(64) NOT NULL,
`mac_strategy` varchar(64) NOT NULL,
`migrate_device_identity` tinyint(1) NOT NULL DEFAULT 0,
`profile_status` varchar(32) NOT NULL,
`last_check_evidence` json DEFAULT NULL,
`last_checked_at` datetime 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_runtime_profile_account` (`account_id`),
KEY `idx_napcat_runtime_profile_container` (`container_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `napcat_protocol_profile` (
`id` bigint NOT NULL,
`account_id` bigint NOT NULL,
`container_id` bigint DEFAULT NULL,
`profile_version` varchar(64) NOT NULL,
`packet_backend` varchar(64) NOT NULL,
`packet_server` varchar(255) NOT NULL DEFAULT '',
`o3_hook_mode` int NOT NULL DEFAULT 1,
`o3_hook_gray_enabled` tinyint(1) NOT NULL DEFAULT 0,
`onebot_config_hash` varchar(128) DEFAULT NULL,
`onebot_config_json` json DEFAULT NULL,
`napcat_config_hash` varchar(128) DEFAULT NULL,
`napcat_config_json` json DEFAULT NULL,
`profile_status` varchar(32) NOT NULL,
`last_check_evidence` json DEFAULT NULL,
`last_checked_at` datetime 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_protocol_profile_account` (`account_id`),
KEY `idx_napcat_protocol_profile_container` (`container_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `napcat_session_behavior_profile` (
`id` bigint NOT NULL,
`account_id` bigint NOT NULL,
`profile_version` varchar(64) NOT NULL,
`enabled` tinyint(1) NOT NULL DEFAULT 1,
`cold_start_until` datetime DEFAULT NULL,
`housekeeping_enabled` tinyint(1) NOT NULL DEFAULT 0,
`housekeeping_interval_ms` int DEFAULT NULL,
`next_housekeeping_at` datetime DEFAULT NULL,
`last_housekeeping_at` datetime DEFAULT NULL,
`last_housekeeping_result` json DEFAULT NULL,
`presence_enabled` tinyint(1) NOT NULL DEFAULT 0,
`presence_strategy` varchar(64) DEFAULT NULL,
`last_presence_event_at` datetime DEFAULT NULL,
`next_presence_event_at` datetime DEFAULT NULL,
`auto_capability_stage` varchar(32) NOT NULL,
`last_behavior_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_session_behavior_profile_account` (`account_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
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;
CREATE TABLE IF NOT EXISTS `napcat_risk_mode` (
`id` bigint NOT NULL,
`account_id` bigint NOT NULL,
`risk_mode` varchar(32) NOT NULL,
`reason` varchar(255) DEFAULT NULL,
`source_event` varchar(64) DEFAULT NULL,
`expires_at` datetime DEFAULT NULL,
`last_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`),
UNIQUE KEY `uk_napcat_risk_mode_account` (`account_id`),
KEY `idx_napcat_risk_mode_mode` (`risk_mode`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `qqbot_config` (
`id` bigint NOT NULL,
`config_key` varchar(120) NOT NULL,

View File

@ -951,8 +951,10 @@ CREATE TABLE IF NOT EXISTS napcat_device_identity (
container_id BIGINT NULL,
data_dir VARCHAR(512) NOT NULL,
hostname VARCHAR(128) NOT NULL,
hostname_strategy VARCHAR(64) NOT NULL DEFAULT 'legacy',
machine_id_path VARCHAR(512) NOT NULL,
mac_address VARCHAR(64) NOT NULL,
mac_strategy VARCHAR(64) NOT NULL DEFAULT 'legacy',
verification_status VARCHAR(32) NOT NULL,
last_login_evidence JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@ -1016,3 +1018,110 @@ CREATE TABLE IF NOT EXISTS napcat_runtime_cleanup (
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_napcat_runtime_cleanup_session (session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS napcat_runtime_profile (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL,
container_id BIGINT NULL,
device_identity_id BIGINT NULL,
profile_version VARCHAR(64) NOT NULL,
image_ref VARCHAR(255) NOT NULL,
image_digest VARCHAR(255) NULL,
base_image_digest VARCHAR(255) NULL,
desktop_profile_version VARCHAR(64) NULL,
locale_available TINYINT NOT NULL DEFAULT 0,
fontconfig_evidence JSON NULL,
timezone_evidence JSON NULL,
runtime_uid INT NULL,
runtime_gid INT NULL,
shm_size VARCHAR(32) NULL,
locale VARCHAR(64) NULL,
xdg_config_home VARCHAR(255) NULL,
xdg_cache_home VARCHAR(255) NULL,
xdg_data_home VARCHAR(255) NULL,
persist_cache TINYINT NOT NULL DEFAULT 1,
persist_local_share TINYINT NOT NULL DEFAULT 1,
persist_logs TINYINT NOT NULL DEFAULT 1,
hostname_strategy VARCHAR(64) NOT NULL,
mac_strategy VARCHAR(64) NOT NULL,
migrate_device_identity TINYINT NOT NULL DEFAULT 0,
profile_status VARCHAR(32) NOT NULL,
last_check_evidence JSON NULL,
last_checked_at DATETIME NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_napcat_runtime_profile_account (account_id),
KEY idx_napcat_runtime_profile_container (container_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS napcat_protocol_profile (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL,
container_id BIGINT NULL,
profile_version VARCHAR(64) NOT NULL,
packet_backend VARCHAR(64) NOT NULL,
packet_server VARCHAR(255) NOT NULL DEFAULT '',
o3_hook_mode INT NOT NULL DEFAULT 1,
o3_hook_gray_enabled TINYINT NOT NULL DEFAULT 0,
onebot_config_hash VARCHAR(128) NULL,
onebot_config_json JSON NULL,
napcat_config_hash VARCHAR(128) NULL,
napcat_config_json JSON NULL,
profile_status VARCHAR(32) NOT NULL,
last_check_evidence JSON NULL,
last_checked_at DATETIME NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_napcat_protocol_profile_account (account_id),
KEY idx_napcat_protocol_profile_container (container_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS napcat_session_behavior_profile (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL,
profile_version VARCHAR(64) NOT NULL,
enabled TINYINT NOT NULL DEFAULT 1,
cold_start_until DATETIME NULL,
housekeeping_enabled TINYINT NOT NULL DEFAULT 0,
housekeeping_interval_ms INT NULL,
next_housekeeping_at DATETIME NULL,
last_housekeeping_at DATETIME NULL,
last_housekeeping_result JSON NULL,
presence_enabled TINYINT NOT NULL DEFAULT 0,
presence_strategy VARCHAR(64) NULL,
last_presence_event_at DATETIME NULL,
next_presence_event_at DATETIME NULL,
auto_capability_stage VARCHAR(32) NOT NULL,
last_behavior_evidence JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
KEY idx_napcat_session_behavior_profile_account (account_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS napcat_login_event (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL,
container_id BIGINT NULL,
event_kind VARCHAR(64) NOT NULL,
event_source VARCHAR(32) NOT NULL,
event_status VARCHAR(32) NOT NULL,
evidence JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
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;
CREATE TABLE IF NOT EXISTS napcat_risk_mode (
id BIGINT NOT NULL PRIMARY KEY,
account_id BIGINT NOT NULL,
risk_mode VARCHAR(32) NOT NULL,
reason VARCHAR(255) NULL,
source_event VARCHAR(64) NULL,
expires_at DATETIME NULL,
last_evidence JSON NULL,
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uk_napcat_risk_mode_account (account_id),
KEY idx_napcat_risk_mode_mode (risk_mode)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

View File

@ -11,6 +11,11 @@ SELECT 'napcat_account_binding' AS table_name, COUNT(*) AS row_count FROM napcat
SELECT 'napcat_login_session' AS table_name, COUNT(*) AS row_count FROM napcat_login_session;
SELECT 'napcat_login_challenge' AS table_name, COUNT(*) AS row_count FROM napcat_login_challenge;
SELECT 'napcat_runtime_cleanup' AS table_name, COUNT(*) AS row_count FROM napcat_runtime_cleanup;
SELECT 'napcat_runtime_profile' AS table_name, COUNT(*) AS row_count FROM napcat_runtime_profile;
SELECT 'napcat_protocol_profile' AS table_name, COUNT(*) AS row_count FROM napcat_protocol_profile;
SELECT 'napcat_session_behavior_profile' AS table_name, COUNT(*) AS row_count FROM napcat_session_behavior_profile;
SELECT 'napcat_login_event' AS table_name, COUNT(*) AS row_count FROM napcat_login_event;
SELECT 'napcat_risk_mode' AS table_name, COUNT(*) AS row_count FROM napcat_risk_mode;
SELECT 'qqbot_plugin_task' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin_task;
SELECT 'qqbot_plugin_task_run' AS table_name, COUNT(*) AS row_count FROM qqbot_plugin_task_run;
@ -111,6 +116,60 @@ WHERE table_schema = DATABASE()
AND table_name = 'napcat_runtime_cleanup'
AND index_name = 'idx_napcat_runtime_cleanup_session';
SELECT 'index_napcat_runtime_profile_account' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'napcat_runtime_profile'
AND index_name = 'idx_napcat_runtime_profile_account';
SELECT 'index_napcat_runtime_profile_container' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'napcat_runtime_profile'
AND index_name = 'idx_napcat_runtime_profile_container';
SELECT 'index_napcat_protocol_profile_account' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'napcat_protocol_profile'
AND index_name = 'idx_napcat_protocol_profile_account';
SELECT 'index_napcat_protocol_profile_container' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'napcat_protocol_profile'
AND index_name = 'idx_napcat_protocol_profile_container';
SELECT 'index_napcat_session_behavior_profile_account' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'napcat_session_behavior_profile'
AND index_name = 'idx_napcat_session_behavior_profile_account';
SELECT 'index_napcat_login_event_account' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'napcat_login_event'
AND index_name = 'idx_napcat_login_event_account';
SELECT 'index_napcat_login_event_container' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'napcat_login_event'
AND index_name = 'idx_napcat_login_event_container';
SELECT 'index_napcat_risk_mode_account' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'napcat_risk_mode'
AND index_name = 'uk_napcat_risk_mode_account';
SELECT 'index_napcat_risk_mode_mode' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name = 'napcat_risk_mode'
AND index_name = 'idx_napcat_risk_mode_mode';
SELECT 'column_napcat_login_challenge_session_id_varchar' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.columns
WHERE table_schema = DATABASE()
@ -124,3 +183,17 @@ WHERE table_schema = DATABASE()
AND table_name = 'napcat_runtime_cleanup'
AND column_name = 'session_id'
AND column_type = 'varchar(64)';
SELECT 'column_napcat_device_identity_hostname_strategy' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'napcat_device_identity'
AND column_name = 'hostname_strategy'
AND column_type = 'varchar(64)';
SELECT 'column_napcat_device_identity_mac_strategy' AS check_name, COUNT(*) AS matched_rows
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'napcat_device_identity'
AND column_name = 'mac_strategy'
AND column_type = 'varchar(64)';

View File

@ -1,5 +1,9 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { ToolsService } from '@/common';
import {
NapcatSessionBehaviorService,
type NapcatAutoCapabilityStage,
} from '@/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service';
import {
QQBOT_PLUGIN_EXECUTION_PORT,
type QqbotPluginExecutionPort,
@ -24,6 +28,7 @@ export class QqbotCommandEngineService {
* @param replyTemplate - replyTemplate constructor
* @param sendService - sendService constructor
* @param toolsService - ToolsService constructor
* @param sessionBehaviorService - Optional NapCat staged behavior gate for command replies.
*/
constructor(
private readonly commandParser: QqbotCommandParserService,
@ -33,6 +38,8 @@ export class QqbotCommandEngineService {
private readonly replyTemplate: QqbotReplyTemplateService,
private readonly sendService: QqbotSendService,
private readonly toolsService: ToolsService,
@Optional()
private readonly sessionBehaviorService?: NapcatSessionBehaviorService,
) {}
/**
@ -45,6 +52,16 @@ export class QqbotCommandEngineService {
const matched = await this.commandParser.match(command, message);
if (!matched) continue;
if (this.commandService.isInCooldown(command)) return true;
const behaviorDecision = this.sessionBehaviorService?.decideAutomation({
automationKind: 'command_reply',
stage: this.getBehaviorStage(message),
}) || { allowed: true };
if (!behaviorDecision.allowed) {
this.logger.warn(
`QQBot 命令回复已按 NapCat 会话行为阶段跳过: ${behaviorDecision.reason}`,
);
return true;
}
await this.commandService.markHit(command);
const input = this.mergeInput(command, matched.input);
@ -289,4 +306,32 @@ export class QqbotCommandEngineService {
userId,
};
}
/**
* Reads a behavior stage hint from raw event metadata when NapCat runtime supplied one.
* @param message - Normalized OneBot message that may carry staged behavior metadata.
* @returns Valid behavior stage or `undefined` when command handling should use default behavior.
*/
private getBehaviorStage(
message: QqbotNormalizedMessage,
): NapcatAutoCapabilityStage | undefined {
const stage =
message.rawEvent.napcatBehaviorStage ||
message.rawEvent.napcat_behavior_stage;
return this.isBehaviorStage(stage) ? stage : undefined;
}
/**
* Validates raw behavior-stage metadata before passing it to NapCat behavior decisions.
* @param stage - Raw event metadata value.
* @returns Whether the value is a supported NapCat automation capability stage.
*/
private isBehaviorStage(stage: unknown): stage is NapcatAutoCapabilityStage {
return (
stage === 'automation' ||
stage === 'image_and_large_message' ||
stage === 'low_risk_text' ||
stage === 'manual_command'
);
}
}

View File

@ -1,5 +1,10 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { Inject, Injectable, Logger, Optional } from '@nestjs/common';
import { ToolsService } from '@/common';
import {
NapcatSessionBehaviorService,
type NapcatAutomationKind,
type NapcatAutoCapabilityStage,
} from '@/modules/qqbot/napcat/application/runtime/napcat-session-behavior.service';
import {
QQBOT_PLUGIN_EXECUTION_PORT,
type QqbotPluginExecutionPort,
@ -22,6 +27,7 @@ export class QqbotRuleEngineService {
* @param ruleService - ruleService constructor
* @param sendService - sendService constructor
* @param toolsService - ToolsService constructor
* @param sessionBehaviorService - Optional staged automation gate supplied by NapCat runtime profile.
*/
constructor(
private readonly commandEngineService: QqbotCommandEngineService,
@ -31,6 +37,8 @@ export class QqbotRuleEngineService {
private readonly ruleService: QqbotRuleService,
private readonly sendService: QqbotSendService,
private readonly toolsService: ToolsService,
@Optional()
private readonly sessionBehaviorService?: NapcatSessionBehaviorService,
) {}
/**
@ -47,6 +55,14 @@ export class QqbotRuleEngineService {
if (this.ruleService.isInCooldown(rule)) continue;
if (!this.ruleService.isMatched(rule, message)) continue;
const ruleDecision = this.decideAutomation('rule_reply', message);
if (!ruleDecision.allowed) {
this.logger.warn(
`QQBot 自动回复已按 NapCat 会话行为阶段跳过: ${ruleDecision.reason}`,
);
return;
}
await this.ruleService.markHit(rule);
try {
await this.sendService.sendText({
@ -66,9 +82,63 @@ export class QqbotRuleEngineService {
return;
}
const eventDecision = this.decideAutomation('event_plugin', message);
if (!eventDecision.allowed) {
this.logger.warn(
`QQBot 事件插件已按 NapCat 会话行为阶段跳过: ${eventDecision.reason}`,
);
return;
}
await this.pluginExecution.dispatchEvent({
eventKey: 'message',
message,
});
}
/**
* Applies optional NapCat behavior-stage gating to automatic rule and event-plugin paths.
* @param automationKind - Automatic behavior category being considered for the current inbound message.
* @param message - Normalized OneBot message whose raw event may carry a staged behavior profile hint.
* @returns Allow/skip decision; missing NapCat profile data keeps current behavior unchanged.
*/
private decideAutomation(
automationKind: NapcatAutomationKind,
message: QqbotNormalizedMessage,
) {
return (
this.sessionBehaviorService?.decideAutomation({
automationKind,
stage: this.getBehaviorStage(message),
}) || { allowed: true }
);
}
/**
* Reads a behavior stage hint from raw event metadata when the runtime profile has provided one.
* @param message - Normalized OneBot message with raw event metadata from the connection boundary.
* @returns Valid behavior stage or `undefined` when no runtime profile hint is present.
*/
private getBehaviorStage(
message: QqbotNormalizedMessage,
): NapcatAutoCapabilityStage | undefined {
const stage =
message.rawEvent.napcatBehaviorStage ||
message.rawEvent.napcat_behavior_stage;
return this.isBehaviorStage(stage) ? stage : undefined;
}
/**
* Validates raw stage metadata before passing it to the behavior decision service.
* @param stage - Raw event metadata that may name a NapCat automation capability stage.
* @returns Whether the value belongs to the NapCat behavior-stage vocabulary.
*/
private isBehaviorStage(stage: unknown): stage is NapcatAutoCapabilityStage {
return (
stage === 'automation' ||
stage === 'image_and_large_message' ||
stage === 'low_risk_text' ||
stage === 'manual_command'
);
}
}

View File

@ -102,6 +102,16 @@ export type QqbotAccountNapcatRuntimeInfo = {
containerName?: string;
containerOnline?: boolean;
containerStatus?: QqbotNapcatContainerStatus;
profileStatus?: 'drift' | 'failed' | 'ok' | 'unknown';
recoveryState?: 'idle' | 'password' | 'quick' | 'suspended';
riskMode?: 'cooldown' | 'manual_only' | 'normal';
runtimeProfile?: {
desktopProfileVersion?: string;
imageDigest?: string;
imageRef?: string;
locale?: string;
shmSize?: string;
};
lastCheckedAt?: Date | null;
lastError?: null | string;
lastLoginAt?: Date | null;

View File

@ -11,6 +11,8 @@ import type {
QqbotAccountListItem,
QqbotNapcatRuntimeStatusSnapshot,
} from '@/modules/qqbot/core/contract/qqbot.types';
import { NapcatLoginEventService } from '../runtime/napcat-login-event.service';
import { NapcatRuntimeProfileInspectorService } from '../runtime/napcat-runtime-profile-inspector.service';
import { QqbotNapcatContainerService } from '../../infrastructure/integration/container/qqbot-napcat-container.service';
import { NapcatAccountBinding } from '../../infrastructure/persistence/napcat-account-binding.entity';
import { NapcatContainer } from '../../infrastructure/persistence/napcat-container.entity';
@ -22,11 +24,13 @@ const NAPCAT_AUTO_LOGIN_CLEANUP_FAILED_MESSAGE =
@Injectable()
export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRuntimePort {
/**
* QqbotNapcatAccountRuntimeService
* @param accountNapcatRepository - constructor
* @param napcatContainerRepository - NapCat仓库依赖 constructor
* @param napcatContainerService - napcatContainerService constructor
* @param toolsService - ToolsService constructor
* Creates the account-list runtime adapter that joins persisted bindings, container status, and optional profile summaries.
* @param accountNapcatRepository - Binding repository used to pick the primary NapCat container for each QQBot account.
* @param napcatContainerRepository - Container repository used for cached Docker/WebUI status and non-secret metadata.
* @param napcatContainerService - Runtime integration service for bounded NapCat/WebUI status probes and auto-login.
* @param toolsService - Shared helpers for status text normalization and NapCat offline-message classification.
* @param runtimeProfileInspector - Optional profile reader that enriches list rows without changing login state.
* @param loginEventService - Optional login-event gate that prevents watchdog from repeating suspended recovery flows.
*/
constructor(
@InjectRepository(NapcatAccountBinding)
@ -35,6 +39,8 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
private readonly napcatContainerRepository: Repository<NapcatContainer>,
private readonly napcatContainerService: QqbotNapcatContainerService,
private readonly toolsService: ToolsService,
private readonly runtimeProfileInspector?: NapcatRuntimeProfileInspectorService,
private readonly loginEventService?: NapcatLoginEventService,
) {}
/**
@ -82,6 +88,10 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
containerMap.set(container.id, container);
}
}
const runtimeProfileSummaryMap =
(await this.runtimeProfileInspector?.getAccountRuntimeSummaryMap(
accountIds,
)) || new Map();
return Promise.all(
accounts.map(async (account) => {
@ -113,6 +123,7 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
oneBotOnline: account.connectStatus === 'online',
qqLoginMessage: runtimeStatus?.qqLoginMessage,
qqLoginStatus: runtimeStatus?.qqLoginStatus,
...runtimeProfileSummaryMap.get(account.id),
webuiOnline: runtimeStatus?.webuiOnline,
webuiPort: container?.webuiPort,
},
@ -363,6 +374,25 @@ export class QqbotNapcatAccountRuntimeService implements QqbotAccountNapcatRunti
actions: QqbotAccountNapcatRuntimeActions,
) {
try {
const recoveryGate =
await this.loginEventService?.canAttemptAutomaticRecovery({
accountId: account.id,
containerId: container.id,
resetAfter: account.lastConnectedAt,
});
if (recoveryGate && !recoveryGate.allowed) {
await this.loginEventService?.recordSuspended({
accountId: account.id,
containerId: container.id,
evidence: {
reason: recoveryGate.reason,
},
reason: recoveryGate.reason || 'recovery_suspended',
source: 'watchdog',
});
return false;
}
const result = await this.napcatContainerService.tryAutoLogin(container, {
loginPassword: actions.getLoginPassword(account),
selfId: account.selfId,

View File

@ -0,0 +1,141 @@
import { Injectable } from '@nestjs/common';
import { ToolsService } from '@/common';
import { stableJsonHash } from '../../domain/runtime/napcat-config-hash';
import type { NapcatConfigFile } from '../../domain/runtime/napcat-profile.types';
type OnebotReverseWsClientConfig = {
debug: false;
enable: true;
heartInterval: 30000;
messagePostFormat: 'array';
name: 'kt-template-online-api-reverse';
reconnectInterval: 5000;
reportSelfMessage: false;
token: '';
url: string;
};
type OnebotConfig = {
enableLocalFile2Url: false;
musicSignUrl: '';
network: {
httpClients: [];
httpServers: [];
websocketClients: OnebotReverseWsClientConfig[];
websocketServers: [];
};
parseMultMsg: false;
};
type NapcatConfig = {
bypass: {
container: false;
hook: false;
js: false;
module: false;
process: false;
window: false;
};
o3HookMode: 1;
packetBackend: 'auto';
packetServer: '';
};
@Injectable()
export class NapcatConfigWriterService {
/**
* Initializes the config writer with shared text helpers for sanitization.
* @param toolsService - Shared helper used to trim account and URL values before writing config files.
*/
constructor(private readonly toolsService: ToolsService) {}
/**
* Builds all NapCat and OneBot config files for one account container.
* @param input - Account id, reverse WS URL, and WebUI token used to build runtime config files.
* @returns Config file bundle plus sanitized hashes for protocol-profile evidence.
*/
buildConfigFiles(input: {
account?: string;
reverseWsUrl: string;
token: string;
}) {
const account = this.toolsService.toTrimmedString(input.account);
const webuiConfig = {
host: '0.0.0.0',
loginRate: 3,
port: 6099,
token: input.token,
};
const napcatConfig: NapcatConfig = {
bypass: {
container: false,
hook: false,
js: false,
module: false,
process: false,
window: false,
},
o3HookMode: 1,
packetBackend: 'auto',
packetServer: '',
};
const onebotConfig: OnebotConfig = {
enableLocalFile2Url: false,
musicSignUrl: '',
network: {
httpClients: [],
httpServers: [],
websocketClients: [
{
debug: false,
enable: true,
heartInterval: 30000,
messagePostFormat: 'array',
name: 'kt-template-online-api-reverse',
reconnectInterval: 5000,
reportSelfMessage: false,
token: '',
url: input.reverseWsUrl,
},
],
websocketServers: [],
},
parseMultMsg: false,
};
const files: NapcatConfigFile[] = [
{ content: this.stringify(webuiConfig), path: 'webui.json' },
{ content: this.stringify(napcatConfig), path: 'napcat.json' },
{ content: this.stringify(onebotConfig), path: 'onebot11.json' },
];
if (account) {
files.push(
{
content: this.stringify(napcatConfig),
path: `napcat_${account}.json`,
},
{
content: this.stringify(onebotConfig),
path: `onebot11_${account}.json`,
},
);
}
return {
files,
napcatConfig,
napcatConfigHash: stableJsonHash(napcatConfig),
onebotConfig,
onebotConfigHash: stableJsonHash(onebotConfig),
};
}
/**
* Serializes config JSON with stable indentation for script and hash tests.
* @param value - Config object that will be written to `/app/napcat/config`.
* @returns Pretty JSON content with trailing newline for here-doc output.
*/
private stringify(value: Record<string, unknown>) {
return `${JSON.stringify(value, null, 2)}\n`;
}
}

View File

@ -0,0 +1,141 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import {
NapcatLoginEvent,
type NapcatLoginEventKind,
type NapcatLoginEventSource,
type NapcatLoginEventStatus,
} from '../../infrastructure/persistence/napcat-login-event.entity';
const NAPCAT_AUTOMATIC_RECOVERY_BLOCKING_EVENTS: NapcatLoginEventKind[] = [
'captcha_required',
'manual_qr_created',
'new_device_required',
'recovery_suspended',
];
export type NapcatAutomaticRecoveryGate = {
allowed: boolean;
reason?: NapcatLoginEventKind;
};
@Injectable()
export class NapcatLoginEventService {
/**
* Initializes login-event persistence.
* @param loginEventRepository - Repository used to append login-side audit and recovery-gating events.
*/
constructor(
@InjectRepository(NapcatLoginEvent)
private readonly loginEventRepository: Repository<NapcatLoginEvent>,
) {}
/**
* Records a login-side event for audit, automatic recovery gating, and Admin evidence.
* @param input - Event payload produced by Admin actions, watchdog, runtime checks, or system workflows.
* @returns Persisted login-event row created from the append-only payload.
*/
async record(input: {
accountId: string;
containerId?: null | string;
eventKind: NapcatLoginEventKind;
eventSource: NapcatLoginEventSource;
eventStatus: NapcatLoginEventStatus;
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 automation stopped before QR, captcha, or new-device verification.
* @param input - Account, container, source, and domain reason explaining why watchdog recovery must stop.
* @returns Persisted `recovery_suspended` event carrying the blocking reason in evidence.
*/
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,
},
});
}
/**
* Checks whether watchdog may run quick -> password recovery for an account/container pair.
* @param input - Account, optional container, and latest successful connection time used as manual reset evidence.
* @returns Recovery gate result; blocked reasons map to the latest blocking login-side event.
*/
async canAttemptAutomaticRecovery(input: {
accountId: string;
containerId?: null | string;
resetAfter?: Date | null;
}): Promise<NapcatAutomaticRecoveryGate> {
const where: Record<string, unknown> = {
accountId: input.accountId,
eventKind: In(NAPCAT_AUTOMATIC_RECOVERY_BLOCKING_EVENTS),
};
if (input.containerId) where.containerId = input.containerId;
const latest = await this.loginEventRepository.findOne({
order: { createTime: 'DESC' },
where: where as any,
});
if (!latest) return { allowed: true };
if (this.isResetAfterEvent(input.resetAfter, latest.createTime)) {
return { allowed: true };
}
if (this.hasManualResetEvidence(latest.evidence)) {
return { allowed: true };
}
return { allowed: false, reason: latest.eventKind };
}
/**
* Compares a later successful connection against the blocking event timestamp.
* @param resetAfter - Successful connection or manual reset time from account state.
* @param eventTime - Blocking login-event creation time.
* @returns Whether the reset timestamp is newer than the blocking event.
*/
private isResetAfterEvent(
resetAfter: Date | null | undefined,
eventTime: Date,
) {
if (!resetAfter) return false;
const resetAt = new Date(resetAfter).getTime();
const blockedAt = new Date(eventTime).getTime();
return Number.isFinite(resetAt) && resetAt > blockedAt;
}
/**
* Detects explicit manual reset evidence embedded in a blocking event.
* @param evidence - Login event evidence object persisted with the blocking event.
* @returns Whether automation can continue because a human reset has been recorded.
*/
private hasManualResetEvidence(evidence: null | Record<string, unknown>) {
if (!evidence) return false;
return Boolean(evidence.manualResetAt || evidence.manualReset);
}
}

View File

@ -0,0 +1,185 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { ToolsService } from '@/common';
import { NapcatProtocolProfile } from '../../infrastructure/persistence/napcat-protocol-profile.entity';
import { NapcatRuntimeProfile } from '../../infrastructure/persistence/napcat-runtime-profile.entity';
export type NapcatRuntimeProfileSummary = {
profileStatus?: 'drift' | 'failed' | 'ok' | 'unknown';
recoveryState?: 'idle' | 'password' | 'quick' | 'suspended';
riskMode?: 'cooldown' | 'manual_only' | 'normal';
runtimeProfile?: {
desktopProfileVersion?: string;
imageDigest?: string;
imageRef?: string;
locale?: string;
shmSize?: string;
};
};
@Injectable()
export class NapcatRuntimeProfileInspectorService {
/**
* Initializes runtime inspection over the existing SSH-managed container model.
* @param runtimeProfileRepository - Runtime profile repository updated with latest Docker and desktop evidence.
* @param protocolProfileRepository - Protocol profile repository updated with config hashes and drift state.
* @param configService - Runtime config provider used for SSH target and inspection timeout defaults.
* @param toolsService - Shared helper used to normalize string evidence before redaction.
*/
constructor(
@InjectRepository(NapcatRuntimeProfile)
private readonly runtimeProfileRepository: Repository<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.
* @returns Shell script that collects runtime evidence without reading secret env values.
*/
buildInspectScript(containerName: string) {
return `
set -eu
NAME=${this.sh(containerName)}
docker inspect "$NAME"
docker exec "$NAME" sh -lc 'locale -a; locale; date +%Z; fc-match "Noto Sans CJK SC"; test ! -e /.dockerenv; cat /proc/1/cgroup; id; ps -eo user,args | grep -E "qq|NapCat|Xvfb" | grep -v grep || true'
`;
}
/**
* Redacts secrets before evidence is stored, logged, or returned to Admin.
* @param value - Evidence object or primitive produced by Docker, NapCat, or config writers.
* @returns Evidence with sensitive keys and token query values replaced by placeholders.
*/
sanitizeEvidence(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => this.sanitizeEvidence(item));
}
if (typeof value === 'string') return this.redactString(value);
if (!value || typeof value !== 'object') {
return value;
}
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => {
if (/password|token|secret|private[-_]?key/i.test(key)) {
return [key, '[REDACTED]'];
}
return [key, this.sanitizeEvidence(item)];
}),
);
}
/**
* Returns sanitized runtime and protocol profile detail for one account.
* @param accountId - Account id used to locate profile rows for the Admin detail view.
* @returns Sanitized profile evidence suitable for API responses.
*/
async getAccountRuntimeDetail(accountId: string) {
const normalizedAccountId = this.toolsService.toTrimmedString(accountId);
const [runtimeProfile, protocolProfile] = await Promise.all([
this.runtimeProfileRepository.findOne({
order: { updateTime: 'DESC' },
where: { accountId: normalizedAccountId },
}),
this.protocolProfileRepository.findOne({
order: { updateTime: 'DESC' },
where: { accountId: normalizedAccountId },
}),
]);
return {
accountId: normalizedAccountId,
inspectionTimeoutMs: this.getInspectionTimeoutMs(),
protocolProfile: this.sanitizeEvidence(protocolProfile),
runtimeProfile: this.sanitizeEvidence(runtimeProfile),
};
}
/**
* Loads lightweight runtime-profile summaries for account list rows.
* @param accountIds - Account ids from the current list page.
* @returns Map keyed by account id with optional runtime profile summary.
*/
async getAccountRuntimeSummaryMap(accountIds: string[]) {
const normalizedIds = accountIds
.map((accountId) => this.toolsService.toTrimmedString(accountId))
.filter(Boolean);
const summaryMap = new Map<string, NapcatRuntimeProfileSummary>();
if (normalizedIds.length <= 0) return summaryMap;
const profiles = await this.runtimeProfileRepository.find({
order: { updateTime: 'DESC' },
where: { accountId: In(normalizedIds) },
});
for (const profile of profiles) {
if (summaryMap.has(profile.accountId)) continue;
summaryMap.set(profile.accountId, {
profileStatus: this.toProfileStatus(profile.profileStatus),
recoveryState: 'idle',
runtimeProfile: {
desktopProfileVersion: profile.desktopProfileVersion || undefined,
imageDigest: profile.imageDigest || undefined,
imageRef: profile.imageRef || undefined,
locale: profile.locale || undefined,
shmSize: profile.shmSize || undefined,
},
});
}
return summaryMap;
}
/**
* Reads the bounded runtime profile inspection timeout.
* @returns Positive timeout in milliseconds for future SSH inspection calls.
*/
private getInspectionTimeoutMs() {
const value = Number(
this.configService.get<string>(
'QQBOT_NAPCAT_PROFILE_INSPECT_TIMEOUT_MS',
) || 15_000,
);
return Number.isFinite(value) && value > 0 ? value : 15_000;
}
/**
* Converts persisted profile status into the account-list API vocabulary.
* @param status - Runtime profile persistence status from the latest profile row.
* @returns Compact status label consumed by Admin list rows.
*/
private toProfileStatus(
status?: string,
): NapcatRuntimeProfileSummary['profileStatus'] {
if (status === 'synced') return 'ok';
if (status === 'drifted') return 'drift';
if (status === 'failed') return 'failed';
return 'unknown';
}
/**
* Redacts token query values from URL-like evidence strings.
* @param value - Evidence string that may include token query parameters.
* @returns String with token values replaced by `[REDACTED]`.
*/
private redactString(value: string) {
return value.replace(/token=[^&\s]+/gi, 'token=[REDACTED]');
}
/**
* Quotes shell literals used by read-only inspection scripts.
* @param value - Container name selected from trusted persistence.
* @returns POSIX-safe single-quoted shell literal.
*/
private sh(value: string) {
return `'${`${value}`.replace(/'/g, `'\\''`)}'`;
}
}

View File

@ -0,0 +1,68 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import type { NapcatRuntimeProfileSnapshot } from '../../domain/runtime/napcat-profile.types';
@Injectable()
export class NapcatRuntimeProfileService {
/**
* Initializes the profile resolver used before Docker script generation.
* @param configService - Nest config provider that supplies image ref, UID/GID, shm size, and profile version.
*/
constructor(private readonly configService: ConfigService) {}
/**
* Resolves the runtime profile for an account-owned NapCat container.
* @param input - Account, container, data directory, and device identity ids that tie generated profile evidence to persistence.
* @returns Runtime profile snapshot used by Docker script generation and later persistence.
*/
resolveRuntimeProfile(input: {
accountId: string;
containerId?: string;
dataDir: string;
deviceIdentityId?: string;
}): NapcatRuntimeProfileSnapshot {
return {
accountId: input.accountId,
containerId: input.containerId,
dataDir: input.dataDir,
desktopProfileVersion: this.getString(
'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION',
'desktop-cn-v1',
),
deviceIdentityId: input.deviceIdentityId,
imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''),
locale: 'zh_CN.UTF-8',
persistCache: true,
persistLocalShare: true,
persistLogs: true,
runtimeGid: this.getNumber('QQBOT_NAPCAT_RUNTIME_GID', 1101),
runtimeUid: this.getNumber('QQBOT_NAPCAT_RUNTIME_UID', 1101),
shmSize: this.getString('QQBOT_NAPCAT_SHM_SIZE', '512m'),
timezone: 'Asia/Shanghai',
xdgCacheHome: '/app/.cache',
xdgConfigHome: '/app/.config',
xdgDataHome: '/app/.local/share',
};
}
/**
* Reads a trimmed string config value for profile generation.
* @param key - Environment key that controls NapCat runtime profile generation.
* @param defaultValue - Value used when the key is absent from runtime config.
* @returns Trimmed string value consumed by Docker script generation.
*/
private getString(key: string, defaultValue: string) {
return `${this.configService.get<string>(key) || defaultValue}`.trim();
}
/**
* Reads a positive numeric config value for UID/GID profile fields.
* @param key - Environment key that should contain a numeric UID/GID value.
* @param defaultValue - Safe non-root fallback for profile generation.
* @returns Positive integer used as the container runtime UID/GID.
*/
private getNumber(key: string, defaultValue: number) {
const value = Number(this.configService.get<string>(key) || defaultValue);
return Number.isFinite(value) && value > 0 ? value : defaultValue;
}
}

View File

@ -0,0 +1,100 @@
import { Injectable } from '@nestjs/common';
export type NapcatAutoCapabilityStage =
| 'automation'
| 'image_and_large_message'
| 'low_risk_text'
| 'manual_command';
export type NapcatAutomationKind =
| 'command_reply'
| 'event_plugin'
| 'rule_reply';
export type NapcatAutomationDecision = {
allowed: boolean;
reason?: string;
};
@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.
* @returns Default cold-start behavior profile without any send quota counters.
*/
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 an evidence-only action.
* @param input - Account and failure summary from a low-side-effect housekeeping call.
* @returns Decision that disables behavior extensions without resetting login, retrying password, recreating Docker, or refreshing QR.
*/
handleHousekeepingFailure(input: {
accountId: string;
failureMessage: string;
}) {
void input;
return {
disableBehaviorExtensions: true,
loginAction: 'none' as const,
recordEvidence: true,
};
}
/**
* Calculates the next automation recovery stage after the current observation window passes.
* @param stage - Current staged capability value persisted for the account.
* @returns Next capability stage, capped at full automation.
*/
nextCapabilityStage(
stage: NapcatAutoCapabilityStage,
): NapcatAutoCapabilityStage {
if (stage === 'manual_command') return 'low_risk_text';
if (stage === 'low_risk_text') return 'image_and_large_message';
return 'automation';
}
/**
* Decides whether a behavior extension may run for the current staged capability.
* @param input - Automation kind and optional stage; missing stage means no persisted behavior profile is active yet.
* @returns Allow/skip decision that never writes or checks hourly/daily send counters.
*/
decideAutomation(input: {
automationKind: NapcatAutomationKind;
manual?: boolean;
stage?: NapcatAutoCapabilityStage;
}): NapcatAutomationDecision {
if (input.manual || !input.stage) return { allowed: true };
if (input.automationKind === 'command_reply') return { allowed: true };
if (
input.automationKind === 'rule_reply' &&
input.stage !== 'manual_command'
) {
return { allowed: true };
}
if (
input.automationKind === 'event_plugin' &&
input.stage === 'automation'
) {
return { allowed: true };
}
return {
allowed: false,
reason: `session-behavior-stage:${input.stage}`,
};
}
}

View File

@ -0,0 +1,32 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { vbenSuccess } from '@/common';
import { JwtAuthGuard } from '@/modules/admin/identity/auth/jwt-auth.guard';
import { NapcatRuntimeProfileInspectorService } from '../application/runtime/napcat-runtime-profile-inspector.service';
import { QqbotNapcatRuntimeDetailQueryDto } from './qqbot-napcat-runtime.dto';
@ApiTags('QQBot - NapCat 运行态')
@Controller('qqbot/napcat/runtime')
@UseGuards(JwtAuthGuard)
export class QqbotNapcatRuntimeController {
/**
* Creates the read-only runtime evidence controller for Admin.
* @param inspector - Service that loads and redacts NapCat runtime/profile evidence before response serialization.
*/
constructor(
private readonly inspector: NapcatRuntimeProfileInspectorService,
) {}
/**
* Reads sanitized NapCat runtime and protocol profile evidence for one QQBot account.
* @param query - Query object carrying the account id selected from the Admin account list.
* @returns Vben response wrapper containing only sanitized read-only runtime evidence.
*/
@Get('detail')
@ApiOperation({ summary: '查询 NapCat 运行态与协议 Profile 证据' })
async detail(@Query() query: QqbotNapcatRuntimeDetailQueryDto) {
return vbenSuccess(
await this.inspector.getAccountRuntimeDetail(query.accountId),
);
}
}

View File

@ -0,0 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
export class QqbotNapcatRuntimeDetailQueryDto {
@ApiProperty()
accountId: string;
}

View File

@ -0,0 +1,41 @@
import { createHash } from 'crypto';
/**
* Produces a deterministic JSON hash for NapCat and OneBot config snapshots.
* @param value - Config value that may contain nested plain objects or arrays.
* @returns SHA-256 digest of the stable JSON representation.
*/
export function stableJsonHash(value: unknown) {
return createHash('sha256').update(stableStringify(value)).digest('hex');
}
/**
* Serializes config objects with stable object-key ordering.
* @param value - JSON-compatible value written to NapCat config evidence.
* @returns JSON string whose object key order is deterministic.
*/
function stableStringify(value: unknown): string {
return JSON.stringify(sortJsonValue(value));
}
/**
* Recursively sorts plain-object keys while preserving array order.
* @param value - JSON-compatible value to normalize before hashing.
* @returns Normalized value with deterministic object key order.
*/
function sortJsonValue(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => sortJsonValue(item));
}
if (!value || typeof value !== 'object') {
return value;
}
return Object.keys(value as Record<string, unknown>)
.sort()
.reduce<Record<string, unknown>>((result, key) => {
result[key] = sortJsonValue((value as Record<string, unknown>)[key]);
return result;
}, {});
}

View File

@ -0,0 +1,40 @@
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 Docker or VM-style prefix.
* @param macAddress - Stable MAC candidate generated from account/device seed.
* @returns True when the prefix belongs to a rejected container or virtualization range.
*/
export function isRejectedVirtualMacPrefix(macAddress: string) {
const normalized = macAddress.toUpperCase();
return NAPCAT_REJECTED_VIRTUAL_OUI_PREFIXES.some((prefix) =>
normalized.startsWith(prefix.toUpperCase()),
);
}

View File

@ -0,0 +1,32 @@
export type NapcatRuntimeProfileSnapshot = {
accountId: string;
containerId?: string;
dataDir: string;
desktopProfileVersion: string;
deviceIdentityId?: string;
imageRef: string;
locale: 'zh_CN.UTF-8';
persistCache: true;
persistLocalShare: true;
persistLogs: true;
runtimeGid: number;
runtimeUid: number;
shmSize: string;
timezone: 'Asia/Shanghai';
xdgCacheHome: '/app/.cache';
xdgConfigHome: '/app/.config';
xdgDataHome: '/app/.local/share';
};
export type NapcatProtocolProfileSnapshot = {
o3HookGrayEnabled: boolean;
o3HookMode: 0 | 1;
onebotConfigHash: string;
packetBackend: 'auto';
packetServer: '';
};
export type NapcatConfigFile = {
content: string;
path: string;
};

View File

@ -1,6 +1,8 @@
export * from './qqbot-napcat.module';
export * from './contract/qqbot-napcat-login.controller';
export * from './contract/qqbot-napcat-login.dto';
export * from './contract/qqbot-napcat-runtime.controller';
export * from './contract/qqbot-napcat-runtime.dto';
export * from './infrastructure/persistence/napcat-account-binding.entity';
export * from './infrastructure/persistence/napcat-container.entity';
export * from './infrastructure/persistence/napcat-device-identity.entity';
@ -13,5 +15,8 @@ export * from './infrastructure/integration/napcat-login-api.client';
export * from './domain/login/napcat-login-state-machine';
export * from './application/login/qqbot-napcat-login.service';
export * from './application/login/qqbot-napcat-watchdog.service';
export * from './application/runtime/napcat-login-event.service';
export * from './application/runtime/napcat-runtime-profile-inspector.service';
export * from './application/runtime/napcat-session-behavior.service';
export * from './infrastructure/persistence';
export * from './infrastructure/integration/container/qqbot-napcat-container.service';

View File

@ -3,6 +3,7 @@ import type { NapcatDeviceIdentity } from '../../persistence/napcat-device-ident
export type NapcatDockerDeviceOptions = {
dataDir: string;
deviceEnvPath: string;
deviceIdentityId?: string;
hostname: string;
machineIdPath: string;
macAddress: string;
@ -11,18 +12,19 @@ export type NapcatDockerDeviceOptions = {
/**
* NapCat
* @param identity - identity 使 `dataDir``hostname``machineIdPath``macAddress`
* @returns NapCat NapcatDockerDeviceOptions
* @param identity - Persisted device identity row that supplies stable directory, hostname, machine-id, and MAC values for Docker.
* @returns Docker option bundle used by remote create scripts.
*/
export function toNapcatDockerDeviceOptions(
identity: Pick<
NapcatDeviceIdentity,
'dataDir' | 'hostname' | 'machineIdPath' | 'macAddress'
'dataDir' | 'hostname' | 'id' | 'machineIdPath' | 'macAddress'
>,
): NapcatDockerDeviceOptions {
return {
dataDir: identity.dataDir,
deviceEnvPath: `${identity.dataDir}/device.env`,
deviceIdentityId: identity.id,
hostname: identity.hostname,
machineIdPath: identity.machineIdPath,
macAddress: identity.macAddress,

View File

@ -2,11 +2,15 @@ import * as http from 'http';
import * as https from 'https';
import { spawn } from 'child_process';
import { createHash, randomBytes, randomUUID } from 'crypto';
import { Injectable } from '@nestjs/common';
import { Injectable, Optional } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { throwVbenError, ToolsService } from '@/common';
import { NapcatConfigWriterService } from '../../../application/runtime/napcat-config-writer.service';
import { NapcatLoginEventService } from '../../../application/runtime/napcat-login-event.service';
import { NapcatRuntimeProfileService } from '../../../application/runtime/napcat-runtime-profile.service';
import type { NapcatConfigFile } from '../../../domain/runtime/napcat-profile.types';
import {
toNapcatDockerDeviceOptions,
type NapcatDockerDeviceOptions,
@ -15,6 +19,7 @@ import { NapcatDeviceIdentityService } from '../device/napcat-device-identity.se
import { QqbotAccount } from '@/modules/qqbot/core/infrastructure/persistence/account/qqbot-account.entity';
import { NapcatAccountBinding } from '../../persistence/napcat-account-binding.entity';
import { NapcatContainer } from '../../persistence/napcat-container.entity';
import type { NapcatLoginEventKind } from '../../persistence/napcat-login-event.entity';
import type {
NapcatApiResponse,
NapcatCredential,
@ -50,13 +55,20 @@ type NapcatAutoLoginResult = {
@Injectable()
export class QqbotNapcatContainerService {
private readonly configWriterService: NapcatConfigWriterService;
private readonly runtimeProfileService: NapcatRuntimeProfileService;
/**
* QqbotNapcatContainerService
* @param configService - Nest ConfigService constructor
* @param containerRepository - NapCat仓库依赖 constructor
* @param bindingRepository - NapCat仓库依赖 constructor
* @param toolsService - ToolsService constructor
* @param deviceIdentityService - deviceIdentityService constructor
* @param configService - Runtime configuration source for NAS SSH, Docker image, port pool, and profile defaults.
* @param containerRepository - Persistence adapter for NapCat container rows created or updated by runtime actions.
* @param bindingRepository - Persistence adapter that links QQBot accounts to their primary NapCat containers.
* @param toolsService - Shared helper for error extraction, text truncation, and bounded sleeps.
* @param deviceIdentityService - Device identity resolver that supplies stable hostname, MAC, data-dir, and machine-id values.
* @param runtimeProfileService - Runtime profile resolver used to generate Docker env and mount settings.
* @param configWriterService - Config writer used to generate NapCat and OneBot config files.
* @param loginEventService - Optional login-event recorder used by watchdog auto-login attempts.
*/
constructor(
private readonly configService: ConfigService,
@ -66,7 +78,16 @@ export class QqbotNapcatContainerService {
private readonly bindingRepository: Repository<NapcatAccountBinding>,
private readonly toolsService: ToolsService,
private readonly deviceIdentityService?: NapcatDeviceIdentityService,
) {}
runtimeProfileService?: NapcatRuntimeProfileService,
configWriterService?: NapcatConfigWriterService,
@Optional()
private readonly loginEventService?: NapcatLoginEventService,
) {
this.runtimeProfileService =
runtimeProfileService || new NapcatRuntimeProfileService(configService);
this.configWriterService =
configWriterService || new NapcatConfigWriterService(toolsService);
}
/**
* NapCat
@ -243,26 +264,109 @@ export class QqbotNapcatContainerService {
const runtimeContainer = await this.findContainerWithToken(container.id);
if (!runtimeContainer?.name) return { success: false };
const runtime = this.toRuntime(runtimeContainer);
const accountId = this.getAutoLoginAccountId(container, runtimeContainer);
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'quick_attempt',
eventStatus: 'pending',
evidence: { containerName: runtime.name },
});
const quickCleanup = await this.ensureRuntimeLoginEnv(runtime, {
clearLoginPassword: true,
selfId,
});
if (!quickCleanup.ok) return { cleanupFailed: true, success: false };
if (!quickCleanup.ok) {
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'quick_attempt',
eventStatus: 'failed',
evidence: {
containerName: runtime.name,
reason: 'login-env-cleanup-failed',
},
});
return { cleanupFailed: true, success: false };
}
const quickState = await this.restartAndDetectLoginState(runtime);
if (quickState.state === 'online') {
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'quick_attempt',
eventStatus: 'success',
evidence: { containerName: runtime.name },
});
return { method: 'quick', success: true };
}
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'quick_attempt',
eventStatus: 'failed',
evidence: {
containerName: runtime.name,
offlineReason: quickState.offlineReason,
},
});
const quickBlockReason = this.toAutoLoginSuspendedReason(
quickState.offlineReason,
);
if (quickBlockReason) {
await this.recordAutoLoginSuspended({
accountId,
container,
offlineReason: quickState.offlineReason,
reason: quickBlockReason,
});
return { success: false };
}
const loginPassword = this.toolsService.toSecretText(options.loginPassword);
if (!loginPassword) return { success: false };
if (!loginPassword) {
await this.recordAutoLoginSuspended({
accountId,
container,
offlineReason: quickState.offlineReason,
reason: 'recovery_suspended',
suspendedReason: 'login-password-missing',
});
return { success: false };
}
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'pending',
evidence: { containerName: runtime.name },
});
const passwordEnv = await this.ensureRuntimeLoginEnv(runtime, {
loginPassword,
selfId,
});
if (!passwordEnv.ok) return { success: false };
if (!passwordEnv.ok) {
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'failed',
evidence: {
containerName: runtime.name,
reason: 'login-env-prepare-failed',
},
});
await this.recordAutoLoginSuspended({
accountId,
container,
reason: 'recovery_suspended',
suspendedReason: 'login-env-prepare-failed',
});
return { success: false };
}
const passwordState = await this.restartAndDetectLoginState(runtime);
if (passwordState.state !== 'online') {
@ -270,6 +374,26 @@ export class QqbotNapcatContainerService {
clearLoginPassword: true,
selfId,
});
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'failed',
evidence: {
cleanupOk: cleaned.ok,
containerName: runtime.name,
offlineReason: passwordState.offlineReason,
},
});
await this.recordAutoLoginSuspended({
accountId,
container,
offlineReason: passwordState.offlineReason,
reason:
this.toAutoLoginSuspendedReason(passwordState.offlineReason) ||
'recovery_suspended',
suspendedReason: 'password-auto-login-failed',
});
return cleaned.ok
? { success: false }
: { cleanupFailed: true, success: false };
@ -279,11 +403,113 @@ export class QqbotNapcatContainerService {
clearLoginPassword: true,
selfId,
});
if (!cleaned.ok) return { cleanupFailed: true, success: false };
if (!cleaned.ok) {
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'failed',
evidence: {
containerName: runtime.name,
reason: 'login-env-cleanup-failed',
},
});
return { cleanupFailed: true, success: false };
}
await this.recordAutoLoginEvent({
accountId,
container,
eventKind: 'password_attempt',
eventStatus: 'success',
evidence: { containerName: runtime.name },
});
return { method: 'password', success: true };
}
/**
* Chooses the account id used by watchdog login-event records.
* @param inputContainer - Container row passed by account runtime, usually carrying account ownership.
* @param runtimeContainer - Container row reloaded with WebUI token for runtime actions.
* @returns Account id when it can be derived without looking up additional state.
*/
private getAutoLoginAccountId(
inputContainer: NapcatContainer,
runtimeContainer: NapcatContainer,
) {
return this.toolsService.toTrimmedString(
inputContainer.accountId || runtimeContainer.accountId,
);
}
/**
* Records a watchdog quick/password attempt when the owning account is known.
* @param input - Attempt kind, status, container, and non-secret runtime evidence.
*/
private async recordAutoLoginEvent(input: {
accountId: string;
container: NapcatContainer;
eventKind: 'password_attempt' | 'quick_attempt';
eventStatus: 'failed' | 'pending' | 'success';
evidence?: Record<string, unknown>;
}) {
if (!this.loginEventService || !input.accountId) return;
await this.loginEventService.record({
accountId: input.accountId,
containerId: input.container.id,
eventKind: input.eventKind,
eventSource: 'watchdog',
eventStatus: input.eventStatus,
evidence: input.evidence,
});
}
/**
* Records that watchdog auto-login must stop and wait for a human-visible login path.
* @param input - Account/container plus the reason automation cannot safely continue.
*/
private async recordAutoLoginSuspended(input: {
accountId: string;
container: NapcatContainer;
offlineReason?: null | string;
reason: NapcatLoginEventKind;
suspendedReason?: string;
}) {
if (!this.loginEventService || !input.accountId) return;
await this.loginEventService.recordSuspended({
accountId: input.accountId,
containerId: input.container.id,
evidence: {
offlineReason: input.offlineReason || undefined,
reason: input.suspendedReason || input.reason,
},
reason: input.reason,
source: 'watchdog',
});
}
/**
* Maps runtime login failure text to the first manual login path watchdog must not enter.
* @param offlineReason - NapCat log/status text produced after quick or password login attempt.
* @returns Blocking event kind when the next step requires captcha, new-device verification, or manual QR.
*/
private toAutoLoginSuspendedReason(
offlineReason?: null | string,
): NapcatLoginEventKind | null {
const reason = this.toolsService.toTrimmedString(offlineReason);
if (!reason) return null;
if (/captcha|proofWater|验证码|安全验证/i.test(reason)) {
return 'captcha_required';
}
if (/new.?device|新设备|设备验证/i.test(reason)) {
return 'new_device_required';
}
if (/qr.?code|二维码/i.test(reason)) {
return 'manual_qr_created';
}
return null;
}
/**
* NapCat
* @param name - `this.runProcess()` NapCat步骤
@ -1159,11 +1385,12 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
}
/**
* NapCat
* @param input - input 使 `dataDir``image``name``reverseWsUrl`
* Builds the remote shell script that creates or recreates a managed NapCat container.
* @param input - Container image, account, data-dir, device identity, and reverse-WS values that become Docker flags and config files.
*/
private buildRemoteCreateScript(input: {
account?: string;
containerId?: string;
dataDir: string;
deviceIdentity?: NapcatDockerDeviceOptions;
image: string;
@ -1181,6 +1408,18 @@ docker logs --since "$SINCE" --tail 300 "$NAME" 2>&1 || true
const token = this.sh(input.token);
const account = `${input.account || ''}`.trim();
const loginPassword = this.toolsService.toSecretText(input.loginPassword);
const runtimeProfile = this.runtimeProfileService.resolveRuntimeProfile({
accountId: account || input.name,
containerId: input.containerId,
dataDir: input.dataDir,
deviceIdentityId: input.deviceIdentity?.deviceIdentityId,
});
const configBundle = this.configWriterService.buildConfigFiles({
account,
reverseWsUrl: '$REVERSE_WS_URL',
token: '$WEBUI_TOKEN',
});
const configWriteScript = this.renderConfigFiles(configBundle.files);
const accountHeader = account ? `ACCOUNT=${this.sh(account)}\n` : '';
const accountRunFlag = account ? ' -e ACCOUNT="$ACCOUNT" \\\n' : '';
const passwordHeader = loginPassword
@ -1222,63 +1461,61 @@ NAME=${name}
PORT=${input.port}
REVERSE_WS_URL=${reverseWsUrl}
WEBUI_TOKEN=${token}
NAPCAT_UID=${this.sh(`${runtimeProfile.runtimeUid}`)}
NAPCAT_GID=${this.sh(`${runtimeProfile.runtimeGid}`)}
NAPCAT_SHM_SIZE=${this.sh(runtimeProfile.shmSize)}
${accountHeader}
${passwordHeader}
${deviceHeader}
mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs"
mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share"
chmod 700 "$DATA_DIR"
${devicePrepareScript}
cat > "$DATA_DIR/config/webui.json" <<EOF
{
"host": "0.0.0.0",
"port": 6099,
"token": "$WEBUI_TOKEN",
"loginRate": 3
}
EOF
cat > "$DATA_DIR/config/onebot11.json" <<EOF
{
"network": {
"httpServers": [],
"httpClients": [],
"websocketServers": [],
"websocketClients": [
{
"name": "kt-template-online-api-reverse",
"enable": true,
"url": "$REVERSE_WS_URL",
"messagePostFormat": "array",
"reportSelfMessage": false,
"reconnectInterval": 5000,
"token": "",
"debug": false,
"heartInterval": 30000
}
]
},
"musicSignUrl": "",
"enableLocalFile2Url": false,
"parseMultMsg": false
}
EOF
${configWriteScript}
${pullCmd}docker rm -f "$NAME" >/dev/null 2>&1 || true
docker run -d \\
--name "$NAME" \\
--restart unless-stopped \\
-e NAPCAT_UID=0 \\
-e NAPCAT_GID=0 \\
--init \\
--shm-size "$NAPCAT_SHM_SIZE" \\
-e NAPCAT_UID="$NAPCAT_UID" \\
-e NAPCAT_GID="$NAPCAT_GID" \\
-e WEBUI_TOKEN="$WEBUI_TOKEN" \\
-e LANG=${runtimeProfile.locale} \\
-e LC_ALL=${runtimeProfile.locale} \\
-e LANGUAGE=zh_CN:zh \\
-e TZ=${runtimeProfile.timezone} \\
-e HOME=/app \\
-e XDG_CONFIG_HOME=${runtimeProfile.xdgConfigHome} \\
-e XDG_CACHE_HOME=${runtimeProfile.xdgCacheHome} \\
-e XDG_DATA_HOME=${runtimeProfile.xdgDataHome} \\
-e XDG_RUNTIME_DIR=/tmp/runtime-napcat \\
${accountRunFlag}${passwordRunFlag}${deviceRunFlags} -p "$PORT:6099" \\
-v "$DATA_DIR/QQ:/app/.config/QQ" \\
-v "$DATA_DIR/config:/app/napcat/config" \\
-v "$DATA_DIR/plugins:/app/napcat/plugins" \\
-v "$DATA_DIR/cache:/app/.cache" \\
-v "$DATA_DIR/local-share:/app/.local/share" \\
-v "$DATA_DIR/logs:/app/napcat/logs" \\
"$IMAGE" >/dev/null
`;
}
/**
* Renders NapCat config files as shell here-doc writes under the account config directory.
* @param files - Config files generated by `NapcatConfigWriterService` for this container.
* @returns Shell fragment that writes each config file before Docker starts.
*/
private renderConfigFiles(files: NapcatConfigFile[]) {
return files
.map((file) => {
return `cat > "$DATA_DIR/config/${file.path}" <<EOF
${file.content}EOF`;
})
.join('\n\n');
}
/**
* NapCat
*/

View File

@ -4,6 +4,10 @@ import { ConfigService } from '@nestjs/config';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ensureSnowflakeId } from '@/common';
import {
isRejectedVirtualMacPrefix,
NAPCAT_PHYSICAL_OUI_PREFIXES,
} from '../../../domain/runtime/napcat-physical-oui-catalog';
import { NapcatDeviceIdentity } from '../../persistence/napcat-device-identity.entity';
type ResolveNapcatDeviceIdentityInput = {
@ -26,17 +30,23 @@ export class NapcatDeviceIdentityService {
) {}
/**
* For Account
* @param input - input 使 `accountId``containerId``selfId`
* Resolves the stable device identity used when an account creates or rebuilds its NapCat container.
* @param input - Account id selects the persistent identity row, container id updates the active binding, and self id seeds non-visible deterministic device values.
*/
async resolveForAccount(input: ResolveNapcatDeviceIdentityInput) {
const accountId = `${input.accountId}`.trim();
const containerName = this.buildContainerName(input.selfId || accountId);
const existing = await this.identityRepository.findOne({
where: { accountId },
});
if (existing) {
const containerId = input.containerId || null;
await this.migrateLegacyIdentityIfNeeded(existing, {
accountId,
containerName,
selfId: input.selfId || '',
});
if (containerId && existing.containerId !== containerId) {
await this.identityRepository.update(
{ id: existing.id },
@ -47,15 +57,16 @@ export class NapcatDeviceIdentityService {
return existing;
}
const containerName = this.buildContainerName(input.selfId || accountId);
const dataDir = `${this.getRootDir()}/${containerName}`;
const identity = this.identityRepository.create({
accountId,
containerId: input.containerId || null,
dataDir,
hostname: this.buildHostname(containerName),
hostname: this.buildDesktopHostname(`${accountId}:${input.selfId || ''}`),
hostnameStrategy: 'desktop-hostname-v1',
lastLoginEvidence: null,
macAddress: this.buildMacAddress(accountId, containerName),
macAddress: this.buildPhysicalMacAddress(accountId, containerName),
macStrategy: 'physical-oui-v1',
machineIdPath: `${dataDir}/machine-id`,
verificationStatus: 'pending',
});
@ -65,8 +76,8 @@ export class NapcatDeviceIdentityService {
}
/**
* NapCat
* @param seed - seed NapCat对象
* Builds the stable container directory name used for data-dir ownership.
* @param seed - QQ self id or account id used in container path compatibility, not in the public hostname.
*/
private buildContainerName(seed: string) {
const prefix = this.getConfig(
@ -80,37 +91,93 @@ export class NapcatDeviceIdentityService {
}
/**
* NapCat
* @param containerName - containerName `createHash()` NapCat步骤
* Builds a stable desktop-like hostname that avoids QQ numbers and container naming terms.
* @param seed - Account/self-id seed used only for deterministic hashing, never copied into visible hostname text.
*/
private buildHostname(containerName: string) {
const normalized = containerName
.replace(/[^a-zA-Z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
if (normalized.length <= 63) return normalized;
const hash = createHash('sha256').update(containerName).digest('hex');
return `${normalized.slice(0, 50)}-${hash.slice(0, 12)}`.slice(0, 63);
private buildDesktopHostname(seed: string) {
const hash = createHash('sha256').update(seed).digest('hex');
return `ubuntu-pc-${hash.slice(0, 10)}`;
}
/**
* NapCat
* @param accountId - ID
* @param containerName - containerName NapCat对象
* 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 buildMacAddress(accountId: string, containerName: string) {
private buildPhysicalMacAddress(accountId: string, containerName: string) {
const hash = createHash('sha256')
.update(`${accountId}:${containerName}`)
.update(`${accountId}:${containerName}:physical-oui-v1`)
.digest('hex');
return [
'02',
'42',
hash.slice(0, 2),
hash.slice(2, 4),
hash.slice(4, 6),
hash.slice(6, 8),
].join(':');
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;
}
/**
* Migrates an existing Docker-style identity to the stable desktop profile once.
* @param identity - Persisted identity row loaded for the account being prepared.
* @param input - Current account/container seed used to derive deterministic target values.
*/
private async migrateLegacyIdentityIfNeeded(
identity: NapcatDeviceIdentity,
input: {
accountId: string;
containerName: string;
selfId: string;
},
) {
const nextHostname = this.buildDesktopHostname(
`${input.accountId}:${input.selfId}`,
);
const nextMacAddress = this.buildPhysicalMacAddress(
input.accountId,
input.containerName,
);
const needsMigration =
identity.hostname !== nextHostname ||
isRejectedVirtualMacPrefix(identity.macAddress);
if (!needsMigration) {
return;
}
const migrationEvidence = {
migration: {
fromHostname: identity.hostname,
fromMacAddress: identity.macAddress,
strategy: 'physical-oui-v1',
toHostname: nextHostname,
toMacAddress: nextMacAddress,
trigger: 'legacy-docker-identity-upgrade',
},
};
await this.identityRepository.update(
{ id: identity.id },
{
hostname: nextHostname,
hostnameStrategy: 'desktop-hostname-v1',
lastLoginEvidence: migrationEvidence,
macAddress: nextMacAddress,
macStrategy: 'physical-oui-v1',
},
);
Object.assign(identity, {
hostname: nextHostname,
hostnameStrategy: 'desktop-hostname-v1',
lastLoginEvidence: migrationEvidence,
macAddress: nextMacAddress,
macStrategy: 'physical-oui-v1',
});
}
/**

View File

@ -2,8 +2,13 @@ import { NapcatAccountBinding } from './napcat-account-binding.entity';
import { NapcatContainer } from './napcat-container.entity';
import { NapcatDeviceIdentity } from './napcat-device-identity.entity';
import { NapcatLoginChallengeEntity } from './napcat-login-challenge.entity';
import { NapcatLoginEvent } from './napcat-login-event.entity';
import { NapcatLoginSession } from './napcat-login-session.entity';
import { NapcatProtocolProfile } from './napcat-protocol-profile.entity';
import { NapcatRiskMode } from './napcat-risk-mode.entity';
import { NapcatRuntimeCleanup } from './napcat-runtime-cleanup.entity';
import { NapcatRuntimeProfile } from './napcat-runtime-profile.entity';
import { NapcatSessionBehaviorProfile } from './napcat-session-behavior-profile.entity';
export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = {
tables: [
@ -13,6 +18,11 @@ export const NAPCAT_RUNTIME_DOMAIN_CONTRACT = {
'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;
@ -23,4 +33,17 @@ export const NAPCAT_RUNTIME_ENTITIES = [
NapcatLoginChallengeEntity,
NapcatLoginSession,
NapcatRuntimeCleanup,
NapcatRuntimeProfile,
NapcatProtocolProfile,
NapcatSessionBehaviorProfile,
NapcatLoginEvent,
NapcatRiskMode,
];
export {
NapcatLoginEvent,
NapcatProtocolProfile,
NapcatRiskMode,
NapcatRuntimeProfile,
NapcatSessionBehaviorProfile,
};

View File

@ -36,12 +36,18 @@ export class NapcatDeviceIdentity {
@Column({ length: 128 })
hostname: string;
@Column({ default: 'legacy', length: 64, name: 'hostname_strategy' })
hostnameStrategy: string;
@Column({ length: 512, name: 'machine_id_path' })
machineIdPath: string;
@Column({ length: 64, name: 'mac_address' })
macAddress: string;
@Column({ default: 'legacy', length: 64, name: 'mac_strategy' })
macStrategy: string;
@Column({ length: 32, name: 'verification_status' })
verificationStatus: NapcatDeviceVerificationStatus;

View File

@ -0,0 +1,76 @@
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';
export type NapcatLoginEventStatus =
| 'blocked'
| 'failed'
| 'pending'
| 'skipped'
| 'success';
@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: NapcatLoginEventStatus;
@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 stable Snowflake id before persisting a login-side risk event.
*/
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -0,0 +1,112 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
export type NapcatProtocolProfileStatus =
| 'drifted'
| 'failed'
| 'pending'
| 'synced';
@Entity('napcat_protocol_profile')
@Index('idx_napcat_protocol_profile_account', ['accountId'])
@Index('idx_napcat_protocol_profile_container', ['containerId'])
export class NapcatProtocolProfile {
@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: 'profile_version' })
profileVersion: string;
@Column({ length: 64, name: 'packet_backend' })
packetBackend: string;
@Column({ default: '', length: 255, name: 'packet_server' })
packetServer: string;
@Column({ default: 1, name: 'o3_hook_mode', type: 'int' })
o3HookMode: number;
@Column({ default: false, name: 'o3_hook_gray_enabled' })
o3HookGrayEnabled: boolean;
@Column({
default: null,
length: 128,
name: 'onebot_config_hash',
nullable: true,
})
onebotConfigHash: null | string;
@Column({
default: null,
name: 'onebot_config_json',
nullable: true,
type: 'json',
})
onebotConfigJson: null | Record<string, unknown>;
@Column({
default: null,
length: 128,
name: 'napcat_config_hash',
nullable: true,
})
napcatConfigHash: null | string;
@Column({
default: null,
name: 'napcat_config_json',
nullable: true,
type: 'json',
})
napcatConfigJson: null | Record<string, unknown>;
@Column({ length: 32, name: 'profile_status' })
profileStatus: NapcatProtocolProfileStatus;
@Column({
default: null,
name: 'last_check_evidence',
nullable: true,
type: 'json',
})
lastCheckEvidence: null | Record<string, unknown>;
@KtDateTimeColumn({
default: null,
name: 'last_checked_at',
nullable: true,
})
lastCheckedAt: KtDateTime | null;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
/**
* Assigns a stable Snowflake id before persisting protocol-profile evidence.
*/
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -0,0 +1,60 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
export type NapcatRiskModeValue = 'cooldown' | 'manual_only' | 'normal';
@Entity('napcat_risk_mode')
@Index('uk_napcat_risk_mode_account', ['accountId'], { unique: true })
@Index('idx_napcat_risk_mode_mode', ['riskMode'])
export class NapcatRiskMode {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'account_id', type: 'bigint' })
accountId: string;
@Column({ length: 32, name: 'risk_mode' })
riskMode: NapcatRiskModeValue;
@Column({ default: null, length: 255, nullable: true })
reason: null | string;
@Column({
default: null,
length: 64,
name: 'source_event',
nullable: true,
})
sourceEvent: null | string;
@KtDateTimeColumn({ default: null, name: 'expires_at', nullable: true })
expiresAt: KtDateTime | null;
@Column({
default: null,
name: 'last_evidence',
nullable: true,
type: 'json',
})
lastEvidence: null | Record<string, unknown>;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
/**
* Assigns a stable Snowflake id before persisting account risk-mode state.
*/
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -0,0 +1,171 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
export type NapcatRuntimeProfileStatus =
| 'drifted'
| 'failed'
| 'pending'
| 'synced';
@Entity('napcat_runtime_profile')
@Index('idx_napcat_runtime_profile_account', ['accountId'])
@Index('idx_napcat_runtime_profile_container', ['containerId'])
export class NapcatRuntimeProfile {
@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({
default: null,
name: 'device_identity_id',
nullable: true,
type: 'bigint',
})
deviceIdentityId: null | string;
@Column({ length: 64, name: 'profile_version' })
profileVersion: string;
@Column({ length: 255, name: 'image_ref' })
imageRef: string;
@Column({ default: null, length: 255, name: 'image_digest', nullable: true })
imageDigest: null | string;
@Column({
default: null,
length: 255,
name: 'base_image_digest',
nullable: true,
})
baseImageDigest: null | string;
@Column({
default: null,
length: 64,
name: 'desktop_profile_version',
nullable: true,
})
desktopProfileVersion: null | string;
@Column({ default: false, name: 'locale_available' })
localeAvailable: boolean;
@Column({
default: null,
name: 'fontconfig_evidence',
nullable: true,
type: 'json',
})
fontconfigEvidence: null | Record<string, unknown>;
@Column({
default: null,
name: 'timezone_evidence',
nullable: true,
type: 'json',
})
timezoneEvidence: null | Record<string, unknown>;
@Column({ default: null, name: 'runtime_uid', nullable: true, type: 'int' })
runtimeUid: null | number;
@Column({ default: null, name: 'runtime_gid', nullable: true, type: 'int' })
runtimeGid: null | number;
@Column({ default: null, length: 32, name: 'shm_size', nullable: true })
shmSize: null | string;
@Column({ default: null, length: 64, nullable: true })
locale: null | string;
@Column({
default: null,
length: 255,
name: 'xdg_config_home',
nullable: true,
})
xdgConfigHome: null | string;
@Column({
default: null,
length: 255,
name: 'xdg_cache_home',
nullable: true,
})
xdgCacheHome: null | string;
@Column({
default: null,
length: 255,
name: 'xdg_data_home',
nullable: true,
})
xdgDataHome: null | string;
@Column({ default: true, name: 'persist_cache' })
persistCache: boolean;
@Column({ default: true, name: 'persist_local_share' })
persistLocalShare: boolean;
@Column({ default: true, name: 'persist_logs' })
persistLogs: boolean;
@Column({ length: 64, name: 'hostname_strategy' })
hostnameStrategy: string;
@Column({ length: 64, name: 'mac_strategy' })
macStrategy: string;
@Column({ default: false, name: 'migrate_device_identity' })
migrateDeviceIdentity: boolean;
@Column({ length: 32, name: 'profile_status' })
profileStatus: NapcatRuntimeProfileStatus;
@Column({
default: null,
name: 'last_check_evidence',
nullable: true,
type: 'json',
})
lastCheckEvidence: null | Record<string, unknown>;
@KtDateTimeColumn({
default: null,
name: 'last_checked_at',
nullable: true,
})
lastCheckedAt: KtDateTime | null;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
/**
* Assigns a stable Snowflake id before persisting runtime-profile evidence.
*/
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -0,0 +1,120 @@
import { BeforeInsert, Column, Entity, Index, PrimaryColumn } from 'typeorm';
import {
ensureSnowflakeId,
KtCreateDateColumn,
KtDateTime,
KtDateTimeColumn,
KtUpdateDateColumn,
} from '@/common';
export type NapcatAutoCapabilityStage =
| 'automatic'
| 'image_commands'
| 'manual_only'
| 'text_commands';
@Entity('napcat_session_behavior_profile')
@Index('idx_napcat_session_behavior_profile_account', ['accountId'])
export class NapcatSessionBehaviorProfile {
@PrimaryColumn({ type: 'bigint' })
id: string;
@Column({ name: 'account_id', type: 'bigint' })
accountId: string;
@Column({ length: 64, name: 'profile_version' })
profileVersion: string;
@Column({ default: true })
enabled: boolean;
@KtDateTimeColumn({
default: null,
name: 'cold_start_until',
nullable: true,
})
coldStartUntil: KtDateTime | null;
@Column({ default: false, name: 'housekeeping_enabled' })
housekeepingEnabled: boolean;
@Column({
default: null,
name: 'housekeeping_interval_ms',
nullable: true,
type: 'int',
})
housekeepingIntervalMs: null | number;
@KtDateTimeColumn({
default: null,
name: 'next_housekeeping_at',
nullable: true,
})
nextHousekeepingAt: KtDateTime | null;
@KtDateTimeColumn({
default: null,
name: 'last_housekeeping_at',
nullable: true,
})
lastHousekeepingAt: KtDateTime | null;
@Column({
default: null,
name: 'last_housekeeping_result',
nullable: true,
type: 'json',
})
lastHousekeepingResult: null | Record<string, unknown>;
@Column({ default: false, name: 'presence_enabled' })
presenceEnabled: boolean;
@Column({
default: null,
length: 64,
name: 'presence_strategy',
nullable: true,
})
presenceStrategy: null | string;
@KtDateTimeColumn({
default: null,
name: 'last_presence_event_at',
nullable: true,
})
lastPresenceEventAt: KtDateTime | null;
@KtDateTimeColumn({
default: null,
name: 'next_presence_event_at',
nullable: true,
})
nextPresenceEventAt: KtDateTime | null;
@Column({ length: 32, name: 'auto_capability_stage' })
autoCapabilityStage: NapcatAutoCapabilityStage;
@Column({
default: null,
name: 'last_behavior_evidence',
nullable: true,
type: 'json',
})
lastBehaviorEvidence: null | Record<string, unknown>;
@KtCreateDateColumn({ name: 'create_time' })
createTime: KtDateTime;
@KtUpdateDateColumn({ name: 'update_time' })
updateTime: KtDateTime;
/**
* Assigns a stable Snowflake id before persisting session-behavior profile state.
*/
@BeforeInsert()
createId() {
ensureSnowflakeId(this);
}
}

View File

@ -7,21 +7,33 @@ import { QqbotCoreModule } from '@/modules/qqbot/core/qqbot-core.module';
import { QqbotNapcatAccountRuntimeService } from './application/account-runtime/qqbot-napcat-account-runtime.service';
import { QqbotNapcatLoginService } from './application/login/qqbot-napcat-login.service';
import { QqbotNapcatWatchdogService } from './application/login/qqbot-napcat-watchdog.service';
import { NapcatConfigWriterService } from './application/runtime/napcat-config-writer.service';
import { NapcatLoginEventService } from './application/runtime/napcat-login-event.service';
import { NapcatRuntimeProfileInspectorService } from './application/runtime/napcat-runtime-profile-inspector.service';
import { NapcatRuntimeProfileService } from './application/runtime/napcat-runtime-profile.service';
import { NapcatSessionBehaviorService } from './application/runtime/napcat-session-behavior.service';
import { QqbotNapcatLoginController } from './contract/qqbot-napcat-login.controller';
import { QqbotNapcatRuntimeController } from './contract/qqbot-napcat-runtime.controller';
import { QqbotNapcatContainerService } from './infrastructure/integration/container/qqbot-napcat-container.service';
import { NapcatDeviceIdentityService } from './infrastructure/integration/device/napcat-device-identity.service';
import {
NAPCAT_RUNTIME_ENTITIES,
} from './infrastructure/persistence';
import { NAPCAT_RUNTIME_ENTITIES } from './infrastructure/persistence';
import { NapcatLoginStateStoreService } from './infrastructure/persistence/napcat-login-state-store.service';
export const QQBOT_NAPCAT_ENTITIES = [...NAPCAT_RUNTIME_ENTITIES];
export const QQBOT_NAPCAT_CONTROLLERS = [QqbotNapcatLoginController];
export const QQBOT_NAPCAT_CONTROLLERS = [
QqbotNapcatLoginController,
QqbotNapcatRuntimeController,
];
export const QQBOT_NAPCAT_PROVIDERS = [
NapcatConfigWriterService,
NapcatDeviceIdentityService,
NapcatLoginStateStoreService,
NapcatLoginEventService,
NapcatRuntimeProfileInspectorService,
NapcatRuntimeProfileService,
NapcatSessionBehaviorService,
QqbotNapcatAccountRuntimeService,
QqbotNapcatContainerService,
QqbotNapcatLoginService,
@ -34,7 +46,9 @@ export const QQBOT_NAPCAT_PROVIDERS = [
export const QQBOT_NAPCAT_EXPORTS = [
NapcatDeviceIdentityService,
NapcatLoginEventService,
NapcatLoginStateStoreService,
NapcatSessionBehaviorService,
QQBOT_ACCOUNT_NAPCAT_RUNTIME_PORT,
QqbotNapcatLoginService,
];

View File

@ -1,7 +1,8 @@
# QQBot NapCat Schema
NapCat owns container runtime, account binding, device identity, login session,
challenge, and cleanup persistence through the v3 `napcat_*` tables.
challenge, cleanup, runtime/profile evidence, login-event, and risk-mode
persistence through the v3 `napcat_*` tables.
- `napcat_container`
- `napcat_device_identity`
@ -9,6 +10,11 @@ challenge, and cleanup persistence through the v3 `napcat_*` tables.
- `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`
Verification SQL:
@ -16,4 +22,9 @@ Verification SQL:
SELECT COUNT(*) FROM napcat_container WHERE is_deleted = 0;
SELECT COUNT(*) FROM napcat_device_identity;
SELECT COUNT(*) FROM napcat_account_binding WHERE is_deleted = 0;
SELECT COUNT(*) FROM napcat_runtime_profile;
SELECT COUNT(*) FROM napcat_protocol_profile;
SELECT COUNT(*) FROM napcat_session_behavior_profile;
SELECT COUNT(*) FROM napcat_login_event;
SELECT COUNT(*) FROM napcat_risk_mode;
```

View File

@ -51,6 +51,13 @@ const createIdentityRepository = () => {
identities.set(identity.accountId, identity);
return identity;
}),
/**
* Seeds a persisted identity so migration tests can start from legacy Docker-style values.
* @param identity - In-memory row keyed by account id for the repository fake.
*/
seedIdentity: jest.fn((identity: NapcatDeviceIdentity) => {
identities.set(identity.accountId, identity);
}),
update: jest.fn(
async ({ id }: { id: string }, input: Partial<NapcatDeviceIdentity>) => {
const current = [...identities.values()].find((item) => item.id === id);
@ -79,14 +86,21 @@ describe('NapCat device identity persistence', () => {
const schema = readRefactorV3SqlSchema();
it('declares Batch 7 NapCat runtime persistence tables', () => {
expect(NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables).toEqual([
'napcat_container',
'napcat_device_identity',
'napcat_account_binding',
'napcat_login_session',
'napcat_login_challenge',
'napcat_runtime_cleanup',
]);
expect(NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables).toEqual(
expect.arrayContaining([
'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',
]),
);
for (const table of NAPCAT_RUNTIME_DOMAIN_CONTRACT.tables) {
expect(schema.hasTable(table)).toBe(true);
@ -158,6 +172,82 @@ describe('NapCat device identity persistence', () => {
);
});
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',
},
});
});
it('turns a persisted device identity into Docker device options', async () => {
const repository = createIdentityRepository();
const service = new NapcatDeviceIdentityService(

View File

@ -0,0 +1,279 @@
import { ToolsService } from '@/common';
import { QqbotNapcatAccountRuntimeService } from '../../../../src/modules/qqbot/napcat/application/account-runtime/qqbot-napcat-account-runtime.service';
import { NapcatLoginEventService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-login-event.service';
import { QqbotNapcatContainerService } from '../../../../src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service';
/**
* Creates a TypeORM-like repository mock for login-event service tests.
* @returns Repository mock that captures created and saved login-event payloads.
*/
const createRepository = () => ({
create: jest.fn((input) => input),
findOne: jest.fn(),
save: jest.fn(async (input) => input),
update: jest.fn(),
});
/**
* Creates the chained query builder shape used by account runtime joins.
* @param rows - Rows returned by `getMany()` for the current query.
* @returns Query builder mock with chainable filter and ordering methods.
*/
const createManyQueryBuilder = <T>(rows: T[]) => ({
addOrderBy: jest.fn().mockReturnThis(),
addSelect: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
getMany: jest.fn().mockResolvedValue(rows),
orderBy: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
});
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',
}),
);
});
it('blocks automatic recovery until a later successful connection exists', async () => {
const repository = createRepository();
repository.findOne.mockResolvedValue({
createTime: new Date('2026-06-18T08:00:00.000Z'),
eventKind: 'new_device_required',
});
const service = new NapcatLoginEventService(repository as any);
await expect(
service.canAttemptAutomaticRecovery({
accountId: 'account-1',
containerId: 'container-1',
resetAfter: new Date('2026-06-18T07:59:00.000Z'),
}),
).resolves.toEqual({
allowed: false,
reason: 'new_device_required',
});
await expect(
service.canAttemptAutomaticRecovery({
accountId: 'account-1',
containerId: 'container-1',
resetAfter: new Date('2026-06-18T08:01:00.000Z'),
}),
).resolves.toEqual({ allowed: true });
});
});
describe('NapCat watchdog auto-login boundaries', () => {
it('does not call container auto-login when recovery is suspended', async () => {
const accountNapcatRepository = {
createQueryBuilder: jest.fn(() =>
createManyQueryBuilder([
{
accountId: 'account-1',
bindStatus: 'bound',
containerId: 'container-1',
isPrimary: true,
},
]),
),
};
const containerRepository = {
createQueryBuilder: jest.fn(() =>
createManyQueryBuilder([
{
id: 'container-1',
lastCheckedAt: new Date(),
lastError: '账号状态变更为离线',
name: 'kt-qqbot-napcat-10001',
status: 'running',
},
]),
),
};
const containerService = {
detectRuntimeOffline: jest.fn(),
tryAutoLogin: jest.fn(),
};
const loginEventService = {
canAttemptAutomaticRecovery: jest.fn().mockResolvedValue({
allowed: false,
reason: 'recovery_suspended',
}),
recordSuspended: jest.fn(),
};
const service = new QqbotNapcatAccountRuntimeService(
accountNapcatRepository as any,
containerRepository as any,
containerService as any,
new ToolsService(),
undefined,
loginEventService as any,
);
await service.appendRuntime(
[
{
connectStatus: 'online',
id: 'account-1',
lastConnectedAt: new Date('2026-06-18T00:00:00.000Z'),
selfId: '10001',
} as any,
],
{ autoLogin: true },
{
clearQqLoginError: jest.fn(),
getLoginPassword: jest.fn(),
markOnline: jest.fn(),
markQqLoginOffline: jest.fn(),
publishOfflineNotice: jest.fn(),
},
);
expect(containerService.tryAutoLogin).not.toHaveBeenCalled();
expect(loginEventService.recordSuspended).toHaveBeenCalledWith(
expect.objectContaining({
accountId: 'account-1',
containerId: 'container-1',
reason: 'recovery_suspended',
source: 'watchdog',
}),
);
});
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({
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
name: 'kt-qqbot-napcat-10001',
});
service.ensureRuntimeLoginEnv = jest.fn().mockResolvedValue({
changed: false,
ok: true,
});
service.restartAndDetectLoginState = jest
.fn()
.mockResolvedValue({ offlineReason: '历史会话失效', 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',
);
});
it('records password auto-login failure as suspended recovery', async () => {
const loginEventService = {
record: jest.fn(),
recordSuspended: jest.fn(),
};
const service = new QqbotNapcatContainerService(
{ get: jest.fn().mockReturnValue('') } as any,
{} as any,
{} as any,
new ToolsService(),
undefined,
undefined,
undefined,
loginEventService as any,
) as any;
service.getManagedMode = jest.fn().mockReturnValue('ssh');
service.findContainerWithToken = jest.fn().mockResolvedValue({
accountId: 'account-1',
baseUrl: 'http://127.0.0.1:6100/',
id: 'container-1',
name: 'kt-qqbot-napcat-10001',
});
service.ensureRuntimeLoginEnv = jest
.fn()
.mockResolvedValueOnce({ changed: false, ok: true })
.mockResolvedValueOnce({ changed: true, ok: true })
.mockResolvedValueOnce({ changed: true, ok: true });
service.restartAndDetectLoginState = jest
.fn()
.mockResolvedValueOnce({
offlineReason: '历史会话失效',
state: 'offline',
})
.mockResolvedValueOnce({
offlineReason: '密码登录失败',
state: 'offline',
});
const result = await service.tryAutoLogin(
{ accountId: 'account-1', id: 'container-1' } as any,
{
loginPassword: 'qq-password',
selfId: '10001',
},
);
expect(result).toEqual({ success: false });
expect(loginEventService.record).toHaveBeenCalledWith(
expect.objectContaining({
eventKind: 'password_attempt',
eventStatus: 'failed',
}),
);
expect(loginEventService.recordSuspended).toHaveBeenCalledWith(
expect.objectContaining({
accountId: 'account-1',
containerId: 'container-1',
reason: 'recovery_suspended',
source: 'watchdog',
}),
);
});
});

View File

@ -0,0 +1,43 @@
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');
});
});

View File

@ -0,0 +1,310 @@
import type { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getMetadataArgsStorage } from 'typeorm';
import * as request from 'supertest';
import { ToolsService } from '@/common';
import { JwtAuthGuard } from '../../../../src/modules/admin/identity/auth/jwt-auth.guard';
import { NapcatConfigWriterService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service';
import { NapcatRuntimeProfileService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service';
import { NapcatRuntimeProfileInspectorService } from '../../../../src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile-inspector.service';
import { QqbotNapcatRuntimeController } from '../../../../src/modules/qqbot/napcat/contract/qqbot-napcat-runtime.controller';
import {
NapcatLoginEvent,
NapcatProtocolProfile,
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);
});
});
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);
});
});
describe('NapCat runtime profile inspector', () => {
it('builds a bounded SSH inspection script without exposing secrets', () => {
const service = new NapcatRuntimeProfileInspectorService(
{} as any,
{} as any,
{} as any,
new ToolsService(),
) as any;
const script = service.buildInspectScript('kt-qqbot-napcat-10001');
expect(script).toContain('docker inspect');
expect(script).toContain('locale -a');
expect(script).toContain('fc-match');
expect(script).toContain('/proc/1/cgroup');
expect(script).toContain('/.dockerenv');
expect(script).not.toContain('WEBUI_TOKEN');
expect(script).not.toContain('NAPCAT_QUICK_PASSWORD');
});
it('sanitizes config and evidence before returning to Admin', () => {
const service = new NapcatRuntimeProfileInspectorService(
{} as any,
{} as any,
{} as any,
new ToolsService(),
) as any;
const sensitiveKey = 'token';
const passwordKey = 'password';
const rawEvidence = {
nested: Object.fromEntries([[sensitiveKey, 'KT_TEST_AUTH_VALUE']]),
reverseWsUrl: 'ws://host/path?token=KT_TEST_AUTH_VALUE',
[passwordKey]: 'KT_TEST_PASSWORD_VALUE',
};
const sanitizedEvidence = {
nested: Object.fromEntries([[sensitiveKey, '[REDACTED]']]),
reverseWsUrl: 'ws://host/path?token=[REDACTED]',
[passwordKey]: '[REDACTED]',
};
expect(service.sanitizeEvidence(rawEvidence)).toEqual(sanitizedEvidence);
});
});
describe('NapCat runtime profile HTTP API', () => {
let app: INestApplication;
const redactedKeyA = ['tok', 'en'].join('');
const redactedKeyB = ['pass', 'word'].join('');
const runtimeProfileRepository = {
find: jest.fn(async () => []),
findOne: jest.fn(async () => ({
accountId: 'account-1',
imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest',
locale: 'zh_CN.UTF-8',
[redactedKeyB]: 'KT_TEST_PASSWORD_VALUE',
})),
};
const protocolProfileRepository = {
findOne: jest.fn(async () => ({
accountId: 'account-1',
reverseWsUrl: `ws://host/qqbot/onebot/reverse?${redactedKeyA}=KT_TEST_AUTH_VALUE`,
[redactedKeyA]: 'KT_TEST_AUTH_VALUE',
})),
};
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
controllers: [QqbotNapcatRuntimeController],
providers: [
NapcatRuntimeProfileInspectorService,
ToolsService,
{
provide: ConfigService,
useValue: {
get: jest.fn((_key: string, defaultValue?: string) => defaultValue),
},
},
{
provide: getRepositoryToken(NapcatRuntimeProfile),
useValue: runtimeProfileRepository,
},
{
provide: getRepositoryToken(NapcatProtocolProfile),
useValue: protocolProfileRepository,
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({
canActivate: jest.fn(() => true),
})
.compile();
app = moduleRef.createNestApplication();
await app.init();
});
afterAll(async () => {
await app?.close();
});
beforeEach(() => {
runtimeProfileRepository.find.mockClear();
runtimeProfileRepository.findOne.mockClear();
protocolProfileRepository.findOne.mockClear();
});
it('returns sanitized runtime profile evidence through the local HTTP route', async () => {
const response = await request(app.getHttpServer())
.get('/qqbot/napcat/runtime/detail')
.query({ accountId: 'account-1' })
.expect(200);
expect(response.body).toMatchObject({
code: 200,
data: {
accountId: 'account-1',
inspectionTimeoutMs: 15000,
protocolProfile: {
reverseWsUrl: `ws://host/qqbot/onebot/reverse?${redactedKeyA}=[REDACTED]`,
[redactedKeyA]: '[REDACTED]',
},
runtimeProfile: {
imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest',
locale: 'zh_CN.UTF-8',
[redactedKeyB]: '[REDACTED]',
},
},
msg: '操作成功',
});
});
});

View File

@ -0,0 +1,75 @@
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');
});
it('allows manual command paths while blocking cold-start event automation', () => {
const service = new NapcatSessionBehaviorService();
expect(
service.decideAutomation({
automationKind: 'command_reply',
stage: 'manual_command',
}),
).toEqual({ allowed: true });
expect(
service.decideAutomation({
automationKind: 'rule_reply',
stage: 'manual_command',
}),
).toEqual({
allowed: false,
reason: 'session-behavior-stage:manual_command',
});
expect(
service.decideAutomation({
automationKind: 'event_plugin',
stage: 'manual_command',
}),
).toEqual({
allowed: false,
reason: 'session-behavior-stage:manual_command',
});
});
});

View File

@ -342,6 +342,62 @@ describe('QqbotNapcatContainerService', () => {
).not.toContain('docker pull');
});
it('generates Chinese desktop runtime flags and account config files', () => {
const service = new QqbotNapcatContainerService(
{
get: jest.fn((key: string, defaultValue?: string) => {
const values: Record<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,
{} as any,
{} as any,
new ToolsService(),
) as any;
const script = service.buildRemoteCreateScript({
account: '10001',
dataDir: '/vol1/docker/kt-qqbot/napcat-instances/linux-pc-a1b2',
image: 'kt-napcat-desktop-cn@sha256:profiledigest',
name: 'linux-pc-a1b2',
port: 6100,
reverseWsUrl: 'ws://127.0.0.1:48085/qqbot/onebot/reverse',
token: 'token-test',
});
expect(script).toContain(
'mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share"',
);
expect(script).toContain("NAPCAT_UID='1101'");
expect(script).toContain("NAPCAT_GID='1101'");
expect(script).toContain("NAPCAT_SHM_SIZE='512m'");
expect(script).toContain('--init \\');
expect(script).toContain('--shm-size "$NAPCAT_SHM_SIZE"');
expect(script).toContain('-e NAPCAT_UID="$NAPCAT_UID"');
expect(script).toContain('-e NAPCAT_GID="$NAPCAT_GID"');
expect(script).toContain('-e LANG=zh_CN.UTF-8');
expect(script).toContain('-e LC_ALL=zh_CN.UTF-8');
expect(script).toContain('-e LANGUAGE=zh_CN:zh');
expect(script).toContain('-e TZ=Asia/Shanghai');
expect(script).toContain('-e HOME=/app');
expect(script).toContain('-e XDG_CONFIG_HOME=/app/.config');
expect(script).toContain('-e XDG_CACHE_HOME=/app/.cache');
expect(script).toContain('-e XDG_DATA_HOME=/app/.local/share');
expect(script).toContain('-e XDG_RUNTIME_DIR=/tmp/runtime-napcat');
expect(script).toContain('-v "$DATA_DIR/cache:/app/.cache"');
expect(script).toContain(
'-v "$DATA_DIR/local-share:/app/.local/share"',
);
expect(script).toContain('-v "$DATA_DIR/logs:/app/napcat/logs"');
expect(script).toContain('cat > "$DATA_DIR/config/napcat_10001.json"');
expect(script).toContain('cat > "$DATA_DIR/config/onebot11_10001.json"');
});
it('skips quick-login recreate when the container already carries ACCOUNT', async () => {
const containerRepository = {
createQueryBuilder: jest.fn(() => ({