Compare commits
12 Commits
1508c63e5c
...
bfb10c50cb
| Author | SHA1 | Date | |
|---|---|---|---|
| bfb10c50cb | |||
| 170aefcc3e | |||
| 7f4bff4a1a | |||
| 6738dbd3df | |||
| d6c3dbad8f | |||
| 5559c318f2 | |||
| 923a383543 | |||
| 38cc5fb5bf | |||
| d8d722e92a | |||
| 93233c44c9 | |||
| 04032cfdb0 | |||
| 6fb762c6f8 |
9
API.md
9
API.md
@ -338,6 +338,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
|
||||
| `POST` | `/qqbot/account/scan/cancel?sessionId=` | 取消扫码会话 |
|
||||
| `POST` | `/qqbot/account/delete?id=` | 删除账号并断开 WS |
|
||||
| `POST` | `/qqbot/account/kick?selfId=` | 断开反向 WS 会话 |
|
||||
| `GET` | `/qqbot/napcat/runtime/detail?accountId=` | 读取账号 NapCat 运行态证据 |
|
||||
| `POST` | `/qqbot/account/bind/command` | 绑定账号和在线命令 |
|
||||
| `POST` | `/qqbot/account/unbind/command` | 解绑账号和在线命令 |
|
||||
| `POST` | `/qqbot/account/bind/rule` | 绑定账号和自动回复规则 |
|
||||
@ -353,6 +354,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
|
||||
|
||||
@ -21,7 +21,7 @@
|
||||
| `admin` | Vben Admin 认证、用户、菜单、角色、部门、时区、字典、组件模板、系统日志 |
|
||||
| `blog` | 本地博客文章、分类、标签、Argon 主题配置和 WordPress 导入 |
|
||||
| `wordpress` | WordPress REST 代理、登录态透传、文章/分类/标签/主题配置 |
|
||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
||||
| `qqbot` | QQBot 账号、NapCat 扫码登录、运行态 Profile、OneBot 反向 WS、在线命令、规则、权限、发送/接收日志和插件平台 |
|
||||
| `modules/qqbot/plugin-platform` | QQBot 插件 manifest 校验、版本安装、运行事件、定时任务、受控 SDK 和 CLI 脚手架 |
|
||||
| `qqbot/plugins/bangdream` | BanG Dream 查曲、查卡、查活动、试炼、玩家、卡池、抽卡模拟、档线、谱面出图 |
|
||||
| `qqbot/plugins/ff14-market` | XIVAPI + Universalis 物品解析和 FF14 市场查价 |
|
||||
@ -73,6 +73,8 @@ QQBot 插件 worker 使用 BullMQ 队列串行执行同一插件安装实例的
|
||||
|
||||
QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qqbot_plugin_task` / `qqbot_plugin_task_run`,通过 BullMQ Job Scheduler 调度并经插件 worker 的 `executeTask` 边界执行。`sql/qqbot-init.sql` 可为既有环境增量创建任务表和 Admin 菜单。Admin 页面路径为 `/qqbot/plugin-task`。定时任务队列可用 `QQBOT_PLUGIN_TASK_QUEUE_REDIS_*` 单独配置;留空时复用插件 worker 队列的 Redis 连接。BangDream Bestdori 主数据缓存使用 `BANGDREAM_TSUGU_CACHE_ROOT`,生产清单挂载到容器内 `/data/qqbot/plugins/bangdream/cache`,对应 k3d 节点可写 hostPath `/var/lib/rancher/k3s/kt-template-online-api/qqbot-plugins`。
|
||||
|
||||
NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/登录事件/风险模式表,真实物理设备风格 hostname/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog quick -> password 熔断,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
|
||||
30
ci/napcat-desktop-cn/Dockerfile
Normal file
30
ci/napcat-desktop-cn/Dockerfile
Normal 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
|
||||
20
ci/napcat-desktop-cn/README.md
Normal file
20
ci/napcat-desktop-cn/README.md
Normal 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`.
|
||||
12
ci/napcat-desktop-cn/verify.sh
Normal file
12
ci/napcat-desktop-cn/verify.sh
Normal 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
|
||||
File diff suppressed because it is too large
Load Diff
@ -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 4:Session Behavior Profile
|
||||
|
||||
- 实现冷启动窗口。
|
||||
- 实现 housekeeping 调度和 evidence。
|
||||
- 实现 presence capability detection。
|
||||
- 实现自动能力逐步恢复和风险降载。
|
||||
|
||||
### Phase 5:Chinese Desktop Runtime
|
||||
|
||||
- 构建 KT 派生镜像。
|
||||
- 验证 `zh_CN.UTF-8`、中文字体、fontconfig、时区、XDG、DBus/Xvfb/QQ 进程环境。
|
||||
- 引入 `--init`、`--shm-size`、非 root UID/GID。
|
||||
- 验证不破坏 entrypoint 现有隐藏能力。
|
||||
|
||||
### Phase 6:API/Admin 只读闭环
|
||||
|
||||
- 暴露 runtime/protocol/session behavior/login event 只读接口。
|
||||
- Admin 账号页接 Runtime Profile Drawer。
|
||||
- 页面明确区分 QQ 登录态、OneBot 连接态、容器状态、恢复状态。
|
||||
|
||||
### Phase 7:线上灰度和迁移
|
||||
|
||||
- 测试账号先跑完整 profile 自检和收发 smoke。
|
||||
- 测试账号灰度 `o3HookMode=0`。
|
||||
- 测试账号做真实设备身份迁移。
|
||||
- 现有风控账号按批次迁移。
|
||||
- 每个账号记录迁移前后 evidence、登录事件、收发结果和回滚点。
|
||||
|
||||
## 验收标准
|
||||
|
||||
本轮完成后,至少要能证明:
|
||||
|
||||
- API 能查询每个账号的 runtime/protocol/session behavior profile。
|
||||
- 登录事件能区分 quick、password、restart、recreate、QR、captcha、新设备、suspended。
|
||||
- watchdog 不会自动进入 QR、不反复 `docker rm -f`、不自动刷新二维码。
|
||||
- `o3HookMode=0` 只能账号级灰度,且有版本/digest/收发证据。
|
||||
- MAC 策略排除 Docker/QEMU/KVM/VMware/Hyper-V 前缀。
|
||||
- Chinese Desktop 派生镜像通过 `zh_CN.UTF-8`、fontconfig、时区、XDG、QQ/Xvfb 进程、entrypoint 行为验证。
|
||||
- Admin 能只读展示 profile evidence 和风险状态。
|
||||
- 没有账号级小时/日累计发送预算配置。
|
||||
- 敏感字段不会进入日志、API 响应或未脱敏 evidence。
|
||||
- 线上至少完成一个测试账号闭环,再迁移现有账号。
|
||||
|
||||
## 回滚策略
|
||||
|
||||
- 表结构新增不影响旧链路,必要时先停用 profile 服务,不删除数据。
|
||||
- `o3HookMode=0` 可按账号回滚到 `1`。
|
||||
- Chinese Desktop Runtime 失败时可按账号回滚到旧镜像 ref,但保留 profile evidence。
|
||||
- 非 root UID/GID 失败时只回退进程用户策略,不回退中文 locale/字体/时区镜像建设。
|
||||
- 设备身份迁移失败时只回滚单账号的 device identity,不并发重建其他账号。
|
||||
- watchdog 熔断后等待人工处理,不用自动重试掩盖问题。
|
||||
|
||||
## 和英文实施计划的关系
|
||||
|
||||
- 本中文文档用于确认方向、边界、风险和验收。
|
||||
- 英文实施计划用于执行,里面有具体文件、测试、提交节奏和任务拆分。
|
||||
- 后续开始实现时,应按英文计划走 `superpowers:subagent-driven-development` 或 `superpowers:executing-plans`。
|
||||
- 如果实现阶段发现英文计划与本中文方案冲突,以本中文方案的目标和边界为准,再同步修订英文计划。
|
||||
@ -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/
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)';
|
||||
|
||||
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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`;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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, `'\\''`)}'`;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class QqbotNapcatRuntimeDetailQueryDto {
|
||||
@ApiProperty()
|
||||
accountId: string;
|
||||
}
|
||||
@ -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;
|
||||
}, {});
|
||||
}
|
||||
@ -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()),
|
||||
);
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
@ -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';
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 登录运行态流程。
|
||||
*/
|
||||
|
||||
@ -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',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
|
||||
@ -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;
|
||||
```
|
||||
|
||||
@ -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(
|
||||
|
||||
279
test/modules/qqbot/napcat/login-event-watchdog.spec.ts
Normal file
279
test/modules/qqbot/napcat/login-event-watchdog.spec.ts
Normal 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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
43
test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts
Normal file
43
test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
310
test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts
Normal file
310
test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts
Normal 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: '操作成功',
|
||||
});
|
||||
});
|
||||
});
|
||||
75
test/modules/qqbot/napcat/session-behavior-profile.spec.ts
Normal file
75
test/modules/qqbot/napcat/session-behavior-profile.spec.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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(() => ({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user