Compare commits

...

5 Commits

14 changed files with 2696 additions and 123 deletions

2
API.md
View File

@ -377,6 +377,8 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT
该接口只返回脱敏后的运行态证据,供 Admin 排查镜像、locale、shm、配置 hash、漂移状态、风险模式和 watchdog 巡检告警状态;不会返回 WebUI token、reverse WS token、QQ 登录密码、SSH 私钥或运行态密码环境。账号列表只挂载 `napcat.profileStatus`、`napcat.runtimeProfile` 等摘要字段不触发登录、重建或修复动作。watchdog 不执行登录恢复:遇到 QQ 登录态离线只记录离线原因并通知 `super`,登录恢复统一由 Admin 手动「更新登录」触发session behavior profile 只做冷启动、housekeeping、presence 和自动能力分阶段降载,不实现账号级每小时/每日累计发送预算。
NapCat Chinese Desktop Runtime v3 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v3` digest。
`napcat_login_event` 实体和表仅作为历史 schema 兼容保留watchdog 不再写入 quick/password 恢复事件,也不再依赖该表判断是否恢复登录。
外发消息不直接抢发:后端会按 `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 分钟内只复读一次,并限制普通文本长度,减少自动行为被风控识别的概率。

View File

@ -77,7 +77,7 @@ QQBot 插件定时任务由 manifest 的 `tasks` 声明,平台持久化到 `qq
Admin 环境总览面板使用 `ENV_DASHBOARD_*` 只读配置聚合 local-dev、NAS 线上、腾讯云和 r4se 状态。`ENV_DASHBOARD_ADMIN_LOCAL_URL` / `ENV_DASHBOARD_ADMIN_PUBLIC_URL` 只用于展示 Admin 本机与线上入口证据。HTTP 快照提供当前拓扑,后端 local/MQTT 事件总线通过 SSE 推送增量事件给 Admin前端不直连 MQTT也不轮询刷新。Jenkins、K8s、Tencent Cloud、Caddy、WireGuard、Mihomo/OpenClash 未配置时会显示 `unwired` 证据,不能渲染成健康假象;第一版不暴露重启、部署、迁移、容器重建、插件启停或代理切换等写操作。
NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MACNapCat/OneBot 配置 hashKT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network也不做账号级每小时/每日累计发送预算。
NapCat Runtime/Protocol Profile 已完成本地 API/Admin 实施,线上发布和账号闭环按 `docs/superpowers/plans/2026-06-18-qqbot-napcat-runtime-protocol-profile-implementation-plan.md` 的 Task 10 执行。当前实现覆盖运行态/协议/会话行为/历史登录事件兼容表/风险模式表,真实物理设备风格 hostname/MACNapCat/OneBot 配置 hashKT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v3 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v3` digest。
## 启动
@ -165,7 +165,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow
- QQBot 插件平台统一使用 `plugin.json` manifest 描述插件 key、版本、操作、事件、权限、运行预算和包入口CLI 负责 create/validate/pack/install-local后端只暴露受控 SDK 能力并通过插件维度记录安装、配置、账号绑定和运行事件。
- Bilibili Card 是事件型内置插件:`bilibili-card.message` 只在账号绑定后监听 QQ/NapCat `share/json/xml/lightapp` 卡片或文本里的 Bilibili 链接,`b23.tv` 短链通过平台 `resolveRedirect` 受控 host 能力解析,视频信息从 Bilibili `x/web-interface/view` 获取后回复纯文本摘要。
- QQBot 同一账号只允许一个有效 NapCat 主容器;绑定新容器时会释放旧绑定和不再共享的旧容器,机器人下线 notice、`isOnline:false` 和 NapCat 容器最新离线日志都会写入账号 `lastError`,普通群成员 kick 不属于账号离线信号;写入 `last_error` 前按 500 字符截断,后续无错误的普通断连不能清空该原因;账号列表拆开展示 OneBot、容器、WebUI 和 QQ 登录态,心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态;`qqLoginMessage` 只展示 QQ 登录态消息WebUI 配置或请求错误留在 `lastError`
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。
- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v3` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch。
- NapCat 账号新增/编辑支持可选 QQ 登录密码Admin 只提交 RSA-OAEP 加密后的 `encryptedLoginPassword`,后端解密后必须用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密保存到 `qqbot_account.napcat_login_password_secret`;空值、`change-me` 和历史公开默认值会被拒绝;列表和详情不回显密码,日志会脱敏密码字段。
- NapCat 容器为已知 `selfId` 创建/重建时会一次性注入 `ACCOUNT` 等必要 env容器重启崩溃/重启策略/宿主重启)可复用持久化会话,但硬踢 `登录已失效` 仍需人工登录。Admin「更新登录」不通过 Docker 重建、重启或补 env 刷新登录态:只要源容器在线,就保持同一容器并通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后如果账号保存了登录密码后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。验证码和新设备验证保持同一会话 pending腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。Admin SSE 步骤顺序按实际路径为 `quick-login-*` -> `password-login-*` / `password-login-captcha` / 新设备验证 -> `qrcode/waiting-scan` -> `login-success|login-failed`SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。
- NapCat 设备身份按账号持久化到 `napcat_device_identity`:同一账号重建容器会复用数据目录、`pc-<8hex>` hostname、machine-id 和实体 OUI 风格 MAC明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀;新增账号首次扫码会先用预留容器 id 创建临时设备身份并应用到第一次 Docker run扫码成功后归属到真实账号并同步 runtime/protocol profileDocker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id`,并同步写入 QQNT Linux `machine-info`,使 `/etc/machine-id`、Docker MAC 和 QQNT 本地设备缓存保持一致。当前策略名为 `qqnt-visible-hostname-v1` / `physical-oui-mac-v1`

View File

@ -22,15 +22,13 @@ RUN set -eux; \
fc-cache -fv; \
rm -rf /var/lib/apt/lists/*
COPY ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh /tmp/qq-login-real-online-guard.sh
COPY NapCat.Shell /tmp/NapCat.Shell
COPY ci/napcat-desktop-cn/fork-artifact.json /ci/napcat-desktop-cn/fork-artifact.json
RUN set -eux; \
sed -i 's/\r$//' /tmp/qq-login-real-online-guard.sh; \
rm -rf /tmp/NapCat.Shell; \
unzip -q /app/NapCat.Shell.zip -d /tmp/NapCat.Shell; \
NAPCAT_PATCH_ROOT=/tmp/NapCat.Shell sh /tmp/qq-login-real-online-guard.sh; \
cd /tmp/NapCat.Shell; \
zip -qr /app/NapCat.Shell.zip .; \
rm -rf /tmp/NapCat.Shell /tmp/qq-login-real-online-guard.sh
sha256sum /app/NapCat.Shell.zip | awk '{print $1}' > /ci/napcat-desktop-cn/NapCat.Shell.zip.sha256; \
rm -rf /tmp/NapCat.Shell
COPY ci/napcat-desktop-cn/verify.sh /ci/napcat-desktop-cn/verify.sh
RUN sed -i 's/\r$//' /ci/napcat-desktop-cn/verify.sh && chmod +x /ci/napcat-desktop-cn/verify.sh

View File

@ -1,24 +1,38 @@
# NapCat Chinese Desktop Runtime Image
Build from the locally inspected upstream digest:
This image consumes a source-built NapCatQQ Shell artifact staged from `D:\MyFiles\KT\GitHub\NapCatQQ`.
Build NapCatQQ first:
```powershell
corepack pnpm --dir D:\MyFiles\KT\GitHub\NapCatQQ install --frozen-lockfile
corepack pnpm --dir D:\MyFiles\KT\GitHub\NapCatQQ --filter napcat-webui-frontend run build
corepack pnpm --dir D:\MyFiles\KT\GitHub\NapCatQQ run build:shell
```
Stage Docker build context:
```powershell
node scripts/napcat-desktop-cn-stage-build.mjs `
--napcat-root D:\MyFiles\KT\GitHub\NapCatQQ `
--out .kt-workspace\napcat-desktop-cn-build
```
Build and verify:
```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-v2 `
-f ci/napcat-desktop-cn/Dockerfile .
-t kt-napcat-desktop-cn:desktop-cn-v3 `
-f .kt-workspace/napcat-desktop-cn-build/ci/napcat-desktop-cn/Dockerfile `
.kt-workspace/napcat-desktop-cn-build
$name = "kt-napcat-v3-verify-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())"
docker run -d --name $name kt-napcat-desktop-cn:desktop-cn-v3
docker exec $name sh /ci/napcat-desktop-cn/verify.sh
docker rm -f $name
```
Verify:
```bash
name="kt-napcat-verify-$(date +%s)"
trap 'docker rm -f "$name" >/dev/null 2>&1 || true' EXIT
docker run -d --name "$name" kt-napcat-desktop-cn:desktop-cn-v2 >/dev/null
sleep 3
docker exec "$name" sh /ci/napcat-desktop-cn/verify.sh
```
Record the final digest in `QQBOT_NAPCAT_IMAGE`.
Record the final image digest in `QQBOT_NAPCAT_IMAGE`.

View File

@ -1,65 +0,0 @@
#!/bin/sh
set -eu
ROOT="${NAPCAT_PATCH_ROOT:-/app/napcat}"
PATCHED_LIST="$(mktemp)"
PERL_PATCH="$(mktemp)"
trap 'rm -f "$PATCHED_LIST" "$PERL_PATCH"' EXIT
cat > "$PERL_PATCH" <<'PERL'
use strict;
use warnings;
my ($file) = @ARGV;
open my $in, '<', $file or die "read $file failed: $!";
local $/;
my $source = <$in>;
close $in;
exit 0 if index($source, 'QQ Is Logined') < 0;
exit 0 if index($source, 'RefreshQRcode') < 0;
exit 0 if index($source, 'getQQLoginStatus') < 0;
my ($runtime) = $source =~ /([A-Za-z_\$][\w\$]*)\.getOneBotContext\(\)\?\.core\?\.selfInfo\?\.online,\s*[A-Za-z_\$][\w\$]*\s*=\s*\1\.getQQLoginStatus\(\)/;
die "Unable to identify NapCat WebUI runtime guard symbols in $file\n" unless $runtime;
my ($send_error) = $source =~ /return\s+([A-Za-z_\$][\w\$]*)\(e,\s*"QQ Is Logined"\)/;
die "Unable to identify NapCat sendError helper in $file\n" unless $send_error;
my $count = 0;
$count += $source =~ s/if \(\Q$runtime\E\.getQQLoginStatus\(\)\)\n(\s*)return \Q$send_error\E\(e, "QQ Is Logined"\);/
"if ($runtime.getQQLoginStatus() && $runtime.getOneBotContext()?.core?.selfInfo?.online !== false)\n"
. "$1return $send_error(e, \"QQ Is Logined\");\n"
. " if ($runtime.getQQLoginStatus() && $runtime.getOneBotContext()?.core?.selfInfo?.online === false)\n"
. "$1$runtime.setQQLoginStatus(false);"
/ge;
my $refresh_pattern = qr/\Q$runtime\E\.getQQLoginStatus\(\) \? \Q$send_error\E\(e, "QQ Is Logined"\) : \(await \Q$runtime\E\.refreshQRCode\(\),/;
my $refresh_replacement =
"$runtime.getQQLoginStatus() && $runtime.getOneBotContext()?.core?.selfInfo?.online !== false "
. "? $send_error(e, \"QQ Is Logined\") "
. ": ($runtime.getQQLoginStatus() && $runtime.getOneBotContext()?.core?.selfInfo?.online === false && $runtime.setQQLoginStatus(false), await $runtime.refreshQRCode(),";
$count += $source =~ s/$refresh_pattern/$refresh_replacement/g;
exit 0 if $count == 0;
open my $out, '>', $file or die "write $file failed: $!";
print {$out} $source;
close $out;
print "$file\n";
PERL
find "$ROOT" \
-type f \( -name '*.js' -o -name '*.mjs' \) \
! -path '*/node_modules/*' \
! -path '*/static/*' \
-size -20M \
-print | while IFS= read -r file; do
perl "$PERL_PATCH" "$file" >> "$PATCHED_LIST"
done
if [ ! -s "$PATCHED_LIST" ]; then
echo 'No NapCat WebUI login guard file was patched' >&2
exit 1
fi
printf 'Patched NapCat WebUI real-online login guard in %s file(s).\n' "$(wc -l < "$PATCHED_LIST" | tr -d ' ')"

View File

@ -1,6 +1,10 @@
#!/bin/sh
set -eu
MARKER=/ci/napcat-desktop-cn/fork-artifact.json
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "$TMP_DIR"' EXIT
locale -a | grep -i '^zh_CN.utf8$'
locale | grep 'LANG=zh_CN.UTF-8'
test "$(cat /etc/timezone)" = "Asia/Shanghai"
@ -10,5 +14,20 @@ 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
unzip -p /app/NapCat.Shell.zip napcat.mjs | grep -q 'selfInfo?.online !== false'
unzip -p /app/NapCat.Shell.zip napcat.mjs | grep -q 'setQQLoginStatus(false)'
test -s "$MARKER"
grep -q '"upstreamBaseCommit"' "$MARKER"
grep -q '"forkCommit"' "$MARKER"
grep -q '"napcatMjsSha256"' "$MARKER"
test -s /app/NapCat.Shell.zip
test -s /ci/napcat-desktop-cn/NapCat.Shell.zip.sha256
unzip -q /app/NapCat.Shell.zip -d "$TMP_DIR"
test -s "$TMP_DIR/napcat.mjs"
EXPECTED_MJS_SHA="$(sed -n 's/.*"napcatMjsSha256"[[:space:]]*:[[:space:]]*"\([a-f0-9]\{64\}\)".*/\1/p' "$MARKER")"
ACTUAL_MJS_SHA="$(sha256sum "$TMP_DIR/napcat.mjs" | awk '{print $1}')"
test "$EXPECTED_MJS_SHA" = "$ACTUAL_MJS_SHA"
grep -R -q 'getQQLoginRuntimeState' "$TMP_DIR"
grep -R -q 'qrcodeRevision' "$TMP_DIR"

View File

@ -0,0 +1,486 @@
# QQBot NapCat 源码 Fork 二维码刷新修复实施计划(中文)
> **给执行代理的要求:** 实施时必须使用 `superpowers:subagent-driven-development`(推荐)或 `superpowers:executing-plans`,按任务逐项执行并用复选框追踪。英文计划 `2026-06-23-qqbot-napcat-source-fork-qr-refresh-implementation-plan.md` 是精确执行版,包含完整测试代码和源码片段;本文件是同任务、同顺序、同验收标准的中文审核版,供用户确认范围和节奏。
**目标:** fork NapCatQQ 源码,在源代码层修复 WebUI 登录态 stale 导致二维码无法刷新的问题,并通过 KT `desktop-cn-v3` 派生镜像上线验证。
**架构:** 先在 NapCatQQ fork 内修复登录态模型和 QR refresh 可观测性,再让 KT API 的中文桌面镜像消费源码构建产物,替换原来的 bundled JS 字符串补丁。API 侧继续保留旧二维码防护,作为 NapCat/QQ 内核异常时的第二道保护。
**技术栈:** NapCatQQ monorepo、TypeScript、Vitest、Vite Shell build、NestJS API、Jest、Docker、NAS SSH、Jenkins/K8s。
---
## 文件结构
### NapCatQQ Fork
目标仓库:`D:\MyFiles\KT\GitHub\NapCatQQ`
- 修改 `packages/napcat-webui-backend/src/types/index.ts`
- 增加登录运行态、QR revision、QR refresh 结果类型。
- 修改 `packages/napcat-webui-backend/src/helper/Data.ts`
- 登录运行态真相源:负责 `QQLoginStatus`、`selfInfo.online`、二维码缓存、revision、stale reconcile。
- 修改 `packages/napcat-webui-backend/src/api/QQLogin.ts`
- 所有登录 HTTP handler 改用统一真实在线态判断。
- 修改 `packages/napcat-shell/base.ts`
- Shell 模式 QR refresh callback 返回 `loginService.getQRCodePicture()` 的 accepted 状态。
- 修改 `packages/napcat-framework/napcat.ts`
- Framework 模式 QR refresh callback 同步返回 accepted 状态。
- 修改 `packages/napcat-test/vitest.config.ts`
- 增加 WebUI backend 测试 alias。
- 新增 `packages/napcat-test/webuiLoginRuntime.test.ts`
- 覆盖 stale 登录态 reconcile、QR revision、QR refresh accepted/updated。
- 新增 `packages/napcat-test/webuiQQLoginHandlers.test.ts`
- 覆盖 stale 登录态下 handler 不再返回 `QQ Is Logined`
- 新增 `packages/napcat-test/webuiLoginSourceWiring.test.ts`
- 静态检查 Shell/Framework callback 是否返回内核 accepted 状态。
### KT API 仓库
目标仓库:`D:\MyFiles\KT\Node\kt-template-online-api`
- 新增 `scripts/napcat-desktop-cn-stage-build.mjs`
- 把 NapCatQQ fork 的 Shell dist staged 成 Docker build context。
- 修改 `ci/napcat-desktop-cn/Dockerfile`
- 从 staged context 复制源码构建产物,生成 `/app/NapCat.Shell.zip`
- 修改 `ci/napcat-desktop-cn/verify.sh`
- 校验 fork marker、artifact hash、locale、timezone、XDG、fontconfig 和源码修复 marker。
- 删除 `ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh`
- 不再做 bundled JS 字符串 patch。
- 修改 `ci/napcat-desktop-cn/README.md`
- 写清 v3 staging/build/verify 命令。
- 修改 `src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts`
- 默认 desktop profile 升级到 `desktop-cn-v3`
- 修改 `test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts`
- 旧 patch 断言改成源码 artifact 断言。
- 修改 `test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts`
- 更新默认 runtime profile 版本断言。
- 修改 `README.md`、`API.md`
- 记录 v3 镜像和 `QQBOT_NAPCAT_IMAGE` 要求。
### KT Root 文档
目标仓库:`D:\MyFiles\KT`
- 修改 `docs/qqbot-nas-runtime.md`
- 记录 NapCat 源码 fork 镜像、canary 证据和上线边界。
- 修改 `TASKS.md`
- 增加本轮双语计划和后续实施上下文。
## 执行规则
- 不创建 `.worktree`,按用户要求使用普通 dev 分支。
- canary 任务之前不改生产容器。
- 不删除 API 侧旧二维码防护。
- 不推送,除非用户明确要求。
- KT 自有新增/触达函数必须有 JSDoc参数说明要写用途不复读变量名。
- 每个代码任务必须先跑 RED再实现再跑 GREEN。
## Task 1准备 NapCatQQ Fork 分支
**文件:**
- 创建或更新 `D:\MyFiles\KT\GitHub\NapCatQQ`
- [ ] 拉取或克隆上游 `NapNeko/NapCatQQ`
- [ ] 从 `main` 创建 `codex/qr-refresh-login-state`
- [ ] 确认基线 commit 当前为 `5c18a62530d87dbadf53d267002894faa6ca7e90`;如上游前进,则记录实际 commit。
- [ ] 确认 `package.json``typecheck`、`test`、`build:shell`。
验证命令:
```powershell
git -C 'D:\MyFiles\KT\GitHub\NapCatQQ' rev-parse HEAD
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' --version
```
预期:分支存在,脚本入口可用。
## Task 2新增 NapCat 登录运行态失败测试
**文件:**
- 修改 `D:\MyFiles\KT\GitHub\NapCatQQ\packages\napcat-test\vitest.config.ts`
- 新增 `D:\MyFiles\KT\GitHub\NapCatQQ\packages\napcat-test\webuiLoginRuntime.test.ts`
- [ ] 在 Vitest config 增加 `napcat-webui-backend` alias。
- [ ] 新增运行态测试,覆盖:
- `QQLoginStatus=true``selfInfo.online=false` 时 reconcile 为离线。
- `selfInfo.online=undefined` 时不误判离线。
- QR URL 变化时 revision 增加,重复写同 URL 不增加。
- refresh callback 返回 `true` 且 QR callback 更新 URL 时,结果是 `accepted=true, updated=true`
- refresh callback 返回 `false` 时,结果是 rejected。
- [ ] 运行 RED 测试。
验证命令:
```powershell
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' --filter napcat-test exec vitest run webuiLoginRuntime.test.ts
```
预期:第一次失败,原因是 `__resetForTest`、`getQQLoginRuntimeState`、`clearQQLoginQrcodeURL` 和带返回值的 `refreshQRCode` 还不存在。
## Task 3实现 NapCat WebUI 登录运行态模型
**文件:**
- 修改 `packages/napcat-webui-backend/src/types/index.ts`
- 修改 `packages/napcat-webui-backend/src/helper/Data.ts`
- 测试 `packages/napcat-test/webuiLoginRuntime.test.ts`
- [ ] 增加类型:
- `QQLoginRuntimeStateOptions`
- `QQLoginRuntimeState`
- `QQRefreshQRCodeOptions`
- `QQRefreshQRCodeResult`
- [ ] `LoginRuntimeType` 增加:
- `QQQRCodeRevision`
- `QQQRCodeUpdatedAt`
- `onRefreshQRCode: () => Promise<boolean>`
- [ ] `Data.ts` 增加 helper
- `delay`
- `getCoreOnlineState`
- `buildQQLoginRuntimeState`
- `clearQQLoginQrcodeURL`
- [ ] `setQQLoginQrcodeURL` 只在 URL 变化时递增 revision。
- [ ] `getQQLoginRuntimeState({ reconcile, clearStaleQRCode })` 统一表达真实在线态。
- [ ] `refreshQRCode(options)` 返回 `accepted`、`updated`、`qrcodeRevision`、`qrcodeurl`。
- [ ] 增加 `__resetForTest()` 测试辅助。
验证命令:
```powershell
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' --filter napcat-test exec vitest run webuiLoginRuntime.test.ts
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' --filter napcat-webui-backend run typecheck
```
预期:测试和 typecheck 通过。
提交:
```powershell
git -C 'D:\MyFiles\KT\GitHub\NapCatQQ' add packages/napcat-webui-backend/src/types/index.ts packages/napcat-webui-backend/src/helper/Data.ts packages/napcat-test/vitest.config.ts packages/napcat-test/webuiLoginRuntime.test.ts
git -C 'D:\MyFiles\KT\GitHub\NapCatQQ' commit -m "fix: 修复WebUI登录态与二维码刷新状态"
```
## Task 4修复 QQLogin handler 登录态短路
**文件:**
- 新增 `packages/napcat-test/webuiQQLoginHandlers.test.ts`
- 修改 `packages/napcat-webui-backend/src/api/QQLogin.ts`
- [ ] 新增 handler 测试,覆盖:
- `CheckLoginStatus` 会清理 stale login state不返回旧 QR。
- `RefreshQRcode` 在 stale login state 下允许执行。
- core 明确在线时 quick login 仍返回 `QQ Is Logined`
- stale 后 `GetQRcode` 不返回旧二维码。
- [ ] 运行 RED 测试。
- [ ] 在 `QQLogin.ts` 增加 helper
- `isActuallyLoggedIn(clearStaleQRCode = true)`
- `buildLoginStatusPayload()`
- [ ] 将以下 handler 的直接 `getQQLoginStatus()` 判断改为真实在线态判断:
- `QQGetQRcodeHandler`
- `QQSetQuickLoginHandler`
- `QQRefreshQRcodeHandler`
- `QQPasswordLoginHandler`
- `QQCaptchaLoginHandler`
- `QQNewDeviceLoginHandler`
- [ ] `QQCheckLoginStatusHandler` 统一返回 helper payload。
- [ ] `QQRefreshQRcodeHandler` 返回 refresh result若 rejected则返回明确错误。
- [ ] 给触达的 exported handler 补 JSDoc。
验证命令:
```powershell
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' --filter napcat-test exec vitest run webuiQQLoginHandlers.test.ts
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' --filter napcat-test exec vitest run webuiLoginRuntime.test.ts webuiQQLoginHandlers.test.ts
```
预期:测试通过。
提交:
```powershell
git -C 'D:\MyFiles\KT\GitHub\NapCatQQ' add packages/napcat-webui-backend/src/api/QQLogin.ts packages/napcat-test/webuiQQLoginHandlers.test.ts
git -C 'D:\MyFiles\KT\GitHub\NapCatQQ' commit -m "fix: 收敛QQ登录接口真实在线态判断"
```
## Task 5打通 Shell/Framework QR refresh callback 返回值
**文件:**
- 新增 `packages/napcat-test/webuiLoginSourceWiring.test.ts`
- 修改 `packages/napcat-shell/base.ts`
- 修改 `packages/napcat-framework/napcat.ts`
- [ ] 新增静态源码测试,确认 Shell/Framework 都包含 `return loginService.getQRCodePicture();`
- [ ] 运行 RED 测试。
- [ ] Shell 模式的 `setRefreshQRCodeCallback` 返回 `loginService.getQRCodePicture()`
- [ ] Framework 模式同样返回 `loginService.getQRCodePicture()`
- [ ] 运行源码 wiring 测试和两包 typecheck。
验证命令:
```powershell
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' --filter napcat-test exec vitest run webuiLoginSourceWiring.test.ts
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' --filter napcat-shell run typecheck
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' --filter napcat-framework run typecheck
```
预期:测试和 typecheck 通过。
提交:
```powershell
git -C 'D:\MyFiles\KT\GitHub\NapCatQQ' add packages/napcat-shell/base.ts packages/napcat-framework/napcat.ts packages/napcat-test/webuiLoginSourceWiring.test.ts
git -C 'D:\MyFiles\KT\GitHub\NapCatQQ' commit -m "fix: 返回二维码刷新请求接收状态"
```
## Task 6构建并验证 NapCatQQ Fork Artifact
**文件:**
- 无新增文件;除非验证暴露明确编译或测试问题。
- [ ] 安装依赖。
- [ ] 跑新增 WebUI 相关 Vitest。
- [ ] 跑 NapCatQQ 现有测试。
- [ ] 跑 monorepo typecheck。
- [ ] 构建 WebUI frontend。
- [ ] 构建 Shell artifact。
- [ ] 记录 fork commit 与 `napcat.mjs` SHA256。
验证命令:
```powershell
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' install --frozen-lockfile
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' --filter napcat-test exec vitest run webuiLoginRuntime.test.ts webuiQQLoginHandlers.test.ts webuiLoginSourceWiring.test.ts
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' --filter napcat-test exec vitest run
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' run typecheck
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' --filter napcat-webui-frontend run build
corepack pnpm --dir 'D:\MyFiles\KT\GitHub\NapCatQQ' run build:shell
Get-FileHash -Algorithm SHA256 'D:\MyFiles\KT\GitHub\NapCatQQ\packages\napcat-shell\dist\napcat.mjs'
```
预期:`packages/napcat-shell/dist/napcat.mjs` 存在,所有测试/typecheck/build 通过。
## Task 7新增 API 镜像 build-context staging 脚本
**文件:**
- 新增 `scripts/napcat-desktop-cn-stage-build.mjs`
- 修改 `test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts`
- [ ] 在 API image test 中新增 staging script 静态断言,覆盖:
- `napcatMjsSha256`
- `forkCommit`
- `upstreamBaseCommit`
- `packages/napcat-shell/dist`
- `fork-artifact.json`
- `.kt-workspace/napcat-desktop-cn-build`
- [ ] 运行 RED Jest。
- [ ] 新增 staging 脚本:
- 参数 `--napcat-root`
- 参数 `--out`
- 复制 `Dockerfile`、`verify.sh`、`NapCat.Shell` dist
- 写入 `fork-artifact.json`
- 计算 dist 与 `napcat.mjs` SHA256
- 输出 marker 和 output root
- [ ] 跑 Jest确认新测试通过旧 patch 测试会在 Task 8 更新。
验证命令:
```powershell
corepack pnpm --dir 'D:\MyFiles\KT\Node\kt-template-online-api' exec jest test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts --runTestsByPath --runInBand
```
预期:新增 staging 断言通过。
## Task 8用源码 artifact 替换 bundled JS patch
**文件:**
- 修改 `ci/napcat-desktop-cn/Dockerfile`
- 修改 `ci/napcat-desktop-cn/verify.sh`
- 删除 `ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh`
- 修改 `test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts`
- [ ] 把旧 patch 断言替换为源码 artifact 断言:
- Dockerfile 包含 `COPY NapCat.Shell /tmp/NapCat.Shell`
- Dockerfile 包含 `fork-artifact.json`
- Dockerfile 仍生成 `/app/NapCat.Shell.zip`
- Dockerfile 不再包含 `qq-login-real-online-guard.sh`
- verify 包含 `napcatMjsSha256`
- verify 包含 `getQQLoginRuntimeState`
- verify 包含 `qrcodeRevision`
- [ ] 运行 RED Jest。
- [ ] Dockerfile 删除 patch block改为复制 staged Shell dist 并 zip 到 `/app/NapCat.Shell.zip`
- [ ] verify.sh 校验:
- locale/timezone/font/XDG
- `/.dockerenv` 隐藏与 cgroup evidence
- fork marker 存在
- `NapCat.Shell.zip` 存在
- 解压后 `napcat.mjs` hash 与 marker 一致
- bundle 内存在 `getQQLoginRuntimeState``qrcodeRevision`
- [ ] 删除旧 patch 脚本。
- [ ] 跑 image Jest。
验证命令:
```powershell
git -C 'D:\MyFiles\KT\Node\kt-template-online-api' rm -- 'ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh'
corepack pnpm --dir 'D:\MyFiles\KT\Node\kt-template-online-api' exec jest test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts --runTestsByPath --runInBand
```
预期Jest 通过,旧 patch 脚本被删除。
## Task 9更新 Runtime Profile 版本与文档
**文件:**
- 修改 `src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts`
- 修改 `test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts`
- 修改 `ci/napcat-desktop-cn/README.md`
- 修改 `README.md`
- 修改 `API.md`
- 修改 `D:\MyFiles\KT\docs\qqbot-nas-runtime.md`
- [ ] 将测试预期从 `desktop-cn-v2` 改为 `desktop-cn-v3`,先跑 RED。
- [ ] 将 service 默认值改为 `desktop-cn-v3`
- [ ] README 写清:
- 先构建 NapCatQQ fork。
- 再运行 staging 脚本。
- 再 build `kt-napcat-desktop-cn:desktop-cn-v3`
- 再执行容器内 `verify.sh`
- [ ] API/README/root docs 写明:生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 v3 digest。
验证命令:
```powershell
corepack pnpm --dir 'D:\MyFiles\KT\Node\kt-template-online-api' exec jest test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts --runTestsByPath --runInBand --testNamePattern "resolves Chinese Desktop Runtime defaults"
corepack pnpm --dir 'D:\MyFiles\KT\Node\kt-template-online-api' exec jest test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts --runTestsByPath --runInBand
```
预期RED 后 GREENprofile 默认版本为 `desktop-cn-v3`
## Task 10验证 API 镜像集成并提交
**文件:**
- Task 7 到 Task 9 的所有 API/root 改动。
- [ ] 跑 API 聚焦 Jest。
- [ ] 跑 API typecheck。
- [ ] 跑 API/root diff check。
- [ ] 跑 global-review。
- [ ] API 仓库提交。
- [ ] 根仓库提交 docs/TASKS。
验证命令:
```powershell
corepack pnpm --dir 'D:\MyFiles\KT\Node\kt-template-online-api' exec jest test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts --runTestsByPath --runInBand
corepack pnpm --dir 'D:\MyFiles\KT\Node\kt-template-online-api' run typecheck
git -C 'D:\MyFiles\KT\Node\kt-template-online-api' diff --check
git -C 'D:\MyFiles\KT' diff --check -- TASKS.md docs/qqbot-nas-runtime.md
pnpm --dir 'D:\MyFiles\KT\mcp\ktWorkflow' run global-review
```
预期:全部通过,`global-review findings=[]`。
提交命令:
```powershell
git -C 'D:\MyFiles\KT\Node\kt-template-online-api' add ci/napcat-desktop-cn scripts/napcat-desktop-cn-stage-build.mjs src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts test/modules/qqbot/napcat README.md API.md
git -C 'D:\MyFiles\KT\Node\kt-template-online-api' commit -m "feat: 接入NapCat源码构建镜像"
git -C 'D:\MyFiles\KT' add docs/qqbot-nas-runtime.md TASKS.md
git -C 'D:\MyFiles\KT' commit -m "docs: 记录NapCat源码构建运行态"
```
## Task 11构建镜像并执行本地或 NAS verify
**文件:**
- 无源码文件;只有验证证据。
- [ ] 从 API 仓库运行 staging 脚本。
- [ ] 用 pinned base image 构建 `kt-napcat-desktop-cn:desktop-cn-v3`
- [ ] 启动一次 verify 容器。
- [ ] 容器内执行 `/ci/napcat-desktop-cn/verify.sh`
- [ ] 删除 verify 容器。
- [ ] 记录 image id/digest。
验证命令:
```powershell
node scripts/napcat-desktop-cn-stage-build.mjs `
--napcat-root D:\MyFiles\KT\GitHub\NapCatQQ `
--out .kt-workspace\napcat-desktop-cn-build
$baseImage = docker image inspect mlikiowa/napcat-docker:latest --format '{{index .RepoDigests 0}}'
if (-not $baseImage) { docker pull mlikiowa/napcat-docker:latest; $baseImage = docker image inspect mlikiowa/napcat-docker:latest --format '{{index .RepoDigests 0}}' }
docker build `
--build-arg NAPCAT_BASE_IMAGE=$baseImage `
-t kt-napcat-desktop-cn:desktop-cn-v3 `
-f .kt-workspace/napcat-desktop-cn-build/ci/napcat-desktop-cn/Dockerfile `
.kt-workspace/napcat-desktop-cn-build
$name = "kt-napcat-v3-verify-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())"
docker run -d --name $name kt-napcat-desktop-cn:desktop-cn-v3
docker exec $name sh /ci/napcat-desktop-cn/verify.sh
docker rm -f $name
docker image inspect kt-napcat-desktop-cn:desktop-cn-v3 --format '{{.Id}}'
```
预期镜像构建成功verify 退出码为 0。
## Task 12部署并进行线上 canary
**文件:**
- 无源码文件;除非线上验证暴露明确 bug。
- [ ] 用户确认后再 push。
- [ ] push NapCatQQ fork 分支。
- [ ] push API main。
- [ ] 观察 Jenkins/K8s。
- [ ] 配置生产环境:
- `QQBOT_NAPCAT_IMAGE=kt-napcat-desktop-cn:desktop-cn-v3`
- `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION=desktop-cn-v3`
- [ ] 只对一个用户确认账号做 canary。
- [ ] 捕获完整证据:
- NapCat fork commit
- NapCat image id
- scan session id
- old QR hash prefix
- new QR hash prefix
- qrcode.png old/new mtime
- `RefreshQRcode.accepted`
- `RefreshQRcode.updated`
- SSE 最终状态
部署观测命令:
```powershell
pnpm --dir 'D:\MyFiles\KT\mcp\ktWorkflow' run deploy-observation -- --project api --job KT-Template/KT-Template-API/main --execute
```
canary 通过标准:
- stale `QQLoginStatus=true + online=false` 不再返回 `QQ Is Logined`
- NapCat 生成新 QR或返回 `accepted=true, updated=false` 且 API 不展示旧码。
- `/app/napcat/cache/qrcode.png` hash/mtime 能证明是否真的产码。
- API `scan/status` 不返回旧 QR hash。
- Admin/SSE 显示新 QR 或明确 pending 原因。
## 自检清单
- 源码 forkTask 1-6 覆盖。
- stale 登录态修复Task 2-4 覆盖。
- QR refresh 可观测Task 2、3、5、12 覆盖。
- WebUI handler 不回旧码Task 4 覆盖。
- API 防旧码保留Task 7-10 不改 API 登录 service。
- 源码 artifact 镜像Task 7-11 覆盖。
- 本地与线上验证Task 6、10、11、12 覆盖。
- 本中文计划已完成自检。

View File

@ -0,0 +1,352 @@
# QQBot NapCat 源码 Fork 二维码刷新修复设计
## 背景
线上账号 `1914728559` 的更新登录链路已经证明 API 侧旧二维码回传被挡住:刷新会话不再把旧 QR 传给 Admin/SSE接口会保持 `pending` 并清空二维码。但同一个容器内的 NapCat WebUI 仍存在源头问题:
- 正确通过 `/api/auth/login` 换取 `Credential` 后,`CheckLoginStatus` 与 `GetQQLoginQrcode` 仍返回旧二维码 hash `47fb616d3f27c93a`
- `/app/napcat/cache/qrcode.png` 的 mtime、size、sha256 不变化。
- Docker 日志只有“当前账号(1914728559)已登录,无法重复登录”,没有二维码生成日志。
- NapCat WebUI 内部 `QQLoginStatus=true` 与实际 `selfInfo.online=false` 发生分裂,导致 `RefreshQRcode` / `GetQQLoginQrcode` 被旧登录态短路。
上一轮 `kt-napcat-desktop-cn:desktop-cn-v2` 是在派生镜像里解压 `NapCat.Shell.zip` 并用脚本 patch bundled JS。这条路验证了方向但它仍是脆弱的产物补丁上游构建压缩、文件结构或字符串变化都会让补丁静默失效。本轮选择 fork NapCat 源码,在源代码层修复登录态与二维码刷新语义,再把源码构建产物注入 KT 受控中文桌面派生镜像。
## 目标
1. 建立 KT 可维护的 `NapCatQQ` 源码 fork不再依赖对 `napcat.mjs` 的字符串补丁。
2. 在 NapCat WebUI 源码层修复 `QQLoginStatus=true` 但真实账号离线时的登录态误判。
3. 让 `RefreshQRcode` 的结果可观测:区分“刷新请求已被 QQ 内核接受”和“新的 QR URL 已经到达 WebUI 缓存”。
4. 让 `GetQQLoginQrcode` / `CheckLoginStatus` 不再对 stale QR 做成功式返回。
5. 保留 API 侧 fresh QR 护栏,避免未来 NapCat 或 QQ 内核异常时 Admin/SSE 再展示旧码。
6. 生成 KT 受控 `NapCat.Shell.zip` artifact并接入 `kt-napcat-desktop-cn:desktop-cn-v3` 镜像。
7. 完成本地构建验证、镜像验证、线上单账号 canary 验证,再决定是否替换所有账号容器。
## 非目标
- 不绕过 QQ/Tencent 验证码、新设备验证或安全验证。
- 不修改 QQ/NTQQ 私有协议字段,不伪造协议签名。
- 不重写 NapCat Docker 上游镜像链路;首版继续复用 KT 的中文桌面派生镜像。
- 不删除 API 侧已完成的旧二维码防护。
- 不在登录 SSE 流程里恢复 Docker 重建、重启或运行态补 env。
- 不把 OneBot 反向 WebSocket 在线当作 QQ 账号登录成功证据。
## 源码仓库与基线
新增一个长期维护的独立源码 fork
```text
D:\MyFiles\KT\GitHub\NapCatQQ
```
基线使用上游 `NapNeko/NapCatQQ``origin/main`,当前已审计的上游基线 commit 为:
```text
5c18a62530d87dbadf53d267002894faa6ca7e90
```
实现阶段必须重新拉取并确认实际基线。如果上游已经前进,先把本设计的源改动 rebased 到新的 `origin/main`,并记录实际基线 commit。`D:\MyFiles\KT\.kt-workspace\upstream\NapCatQQ` 只允许作为临时只读审计目录,不作为最终 fork 或构建真相源。
fork 分支建议:
```text
codex/qr-refresh-login-state
```
KT API 仓库只保存构建脚本、镜像集成和验证逻辑,不提交 `NapCat.Shell.zip` 二进制 artifact。
## 源码改动设计
### 登录态统一 helper
`packages/napcat-webui-backend/src/helper/Data.ts` 增加 WebUI 登录运行态 helper。它读取三类状态
- WebUI 缓存态:`QQLoginStatus`。
- 实际核心态:`OneBotContext?.core?.selfInfo?.online`。
- 当前二维码缓存:`QQQRCodeURL`。
helper 的语义:
- `QQLoginStatus=true``online=true`:真实在线,登录类接口继续返回已登录。
- `QQLoginStatus=true``online=false`stale 登录态,必须把 `QQLoginStatus` reconcile 为 `false`,并允许重新走 quick/password/manual QR。
- `online``undefined`:启动早期或上下文未初始化,不能直接判定离线;保留原行为,避免登录成功到 adapter 初始化之间的短窗口误伤。
- stale 登录态被 reconcile 时,允许清空旧 QR 或标记旧 QR 为不可用,避免 `GetQQLoginQrcode` 对外返回历史二维码。
helper 返回结构建议包含:
```ts
{
webuiLoginStatus: boolean;
online?: boolean;
isActuallyLogin: boolean;
isStaleLoginStatus: boolean;
canStartLoginFlow: boolean;
qrcodeurl: string;
qrcodeRevision: number;
qrcodeUpdatedAt: number;
}
```
字段命名可按上游风格调整,但语义必须完整保留。
### QQLogin handlers 收敛
`packages/napcat-webui-backend/src/api/QQLogin.ts` 中所有直接用 `WebUiDataRuntime.getQQLoginStatus()` 短路登录流程的 handler都改为使用统一 helper
- `QQGetQRcodeHandler`
- `QQSetQuickLoginHandler`
- `QQRefreshQRcodeHandler`
- `QQPasswordLoginHandler`
- `QQCaptchaLoginHandler`
- `QQNewDeviceLoginHandler`
`QQCheckLoginStatusHandler` 当前已经读取 `selfInfo.online`,但仍可能附带旧 `qrcodeurl`。它也必须切到同一个 helper保证返回的 `isLogin`、`qrcodeurl`、错误信息和 revision 一致。
关键约束:
- 只有 `isActuallyLogin=true` 时才返回 `QQ Is Logined`
- stale 登录态不能阻止 `RefreshQRcode`
- stale 登录态不能把旧 QR 作为新二维码返回。
- quick 登录报“当前账号已登录”后仍要由真实在线态决定是否完成登录,而不是由 WebUI 缓存态决定。
### 二维码 revision 与刷新可观测性
`Data.ts` 中给二维码缓存增加 revision/更新时间:
- `setQQLoginQrcodeURL(url)` 只有在 URL 变化时递增 `qrcodeRevision`
- 每次 QR URL 变化记录 `qrcodeUpdatedAt`
- 清空 stale QR 时也更新 revision让调用方能知道旧码已失效。
`refreshQRCode()` 不再只返回 `void`。它应返回刷新结果,例如:
```ts
{
accepted: boolean;
updated: boolean;
qrcodeurl: string;
qrcodeRevision: number;
error?: string;
}
```
实现语义:
1. 记录刷新前的 QR URL/revision。
2. 调用 `onRefreshQRCode`
3. 如果底层返回 `false`,则 `accepted=false`
4. 如果底层接受请求,则等待一次 `onQRCodeGetPicture` 写入新 QR或在短等待窗口后返回 `accepted=true, updated=false`
5. 只有 QR URL/revision 变化,才返回 `updated=true`
等待窗口必须短且有上限,不能把 WebUI HTTP 请求变成长期阻塞。首版建议只在 WebUI 内做短等待API 侧仍保持 SSE/polling pending。
### Shell 与 Framework callback
二维码真正进入 WebUI 缓存的位置在:
- `packages/napcat-shell/base.ts`
- `packages/napcat-framework/napcat.ts`
这两个入口里:
- `onQRCodeGetPicture` 继续调用 `setQQLoginQrcodeURL`,并触发 revision 更新。
- `setRefreshQRCodeCallback` 不再吞掉底层返回值,应把 `loginService.getQRCodePicture()``boolean` 返回给 `refreshQRCode()`
这样 WebUI 能知道 QQ 内核是否接受了取 QR 请求API 也能通过 WebUI 响应判断“待产码”和“已有新码”。
## KT 镜像集成
继续使用 `Node/kt-template-online-api/ci/napcat-desktop-cn` 作为 KT 中文桌面派生镜像入口,但移除 bundled JS patch。
### 构建输入
实现阶段新增构建脚本,把 fork 仓库构建出的 `NapCat.Shell.zip` staged 到 `.kt-workspace` 下的 Docker build context。API 仓库不直接提交二进制 zip。
推荐流程:
```text
NapCatQQ fork
-> pnpm build shell artifact
-> .kt-workspace/napcat-desktop-cn-build/NapCat.Shell.zip
-> ci/napcat-desktop-cn/Dockerfile COPY into /app/NapCat.Shell.zip
-> kt-napcat-desktop-cn:desktop-cn-v3
```
Docker `COPY` 不能引用 build context 之外的文件,所以实现脚本必须显式 staging context而不是在 Dockerfile 里直接读 `D:\MyFiles\KT\GitHub\NapCatQQ`
staging context 必须同时包含 Dockerfile、`verify.sh`、fork artifact marker 和 `NapCat.Shell.zip`,避免 Dockerfile 在仓库目录、`COPY` 源却在另一个 context 的路径错位。
### Dockerfile 变化
`ci/napcat-desktop-cn/Dockerfile` 保留:
- `zh_CN.UTF-8`
- `Asia/Shanghai`
- 中文字体与 fontconfig
- XDG/Home 环境
- `verify.sh`
删除:
- `ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh`
- 解压 `/app/NapCat.Shell.zip` 后用 Perl/字符串改 `napcat.mjs` 的步骤
新增:
- 从 staged context 复制源码构建的 `NapCat.Shell.zip``/app/NapCat.Shell.zip`
- 写入 fork artifact marker例如 `/ci/napcat-desktop-cn/fork-artifact.json`,包含 upstream base commit、fork commit、build time、artifact sha256
### verify.sh 变化
`ci/napcat-desktop-cn/verify.sh` 不再 grep JS patch 字符串,而是验证:
- `/app/NapCat.Shell.zip` 存在且能解压。
- artifact sha256 与 marker 一致。
- marker 中存在 upstream base commit 和 fork commit。
- locale/fontconfig/timezone/XDG 仍通过。
- 能在解压后的 WebUI backend bundle 中找到 QR revision 或 runtime helper 的可观测 marker。
## API 侧边界
API 不再承担 NapCat 内部登录态修复职责,但继续保留防旧码护栏:
- `scan/refresh` 继续要求 fresh QR。
- `scan/status` 继续拒绝把旧 QR 当成新码返回。
- `scan/qrcode/refresh` 继续在未拿到新 QR 时保持 `pending`
- QQ 登录态仍以 NapCat WebUI/login logs 为准,不以 OneBot heartbeat 兜底。
需要更新的 API 文件范围:
- `ci/napcat-desktop-cn/Dockerfile`
- `ci/napcat-desktop-cn/verify.sh`
- `ci/napcat-desktop-cn/README.md`
- `ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh` 删除
- `src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts`
- `test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts`
- `test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts`
- 可能涉及 `test/qqbot/napcat/qqbot-napcat-container.service.spec.ts`
- `docs/qqbot-nas-runtime.md`
- `README.md` / `API.md` 中涉及镜像 tag 或 NapCat runtime 的说明
默认 profile 版本升级到:
```text
desktop-cn-v3
```
生产仍通过 `QQBOT_NAPCAT_IMAGE` 控制实际镜像,不在 API 代码里硬编码生产 digest。
## 数据流
```mermaid
sequenceDiagram
participant Admin
participant API
participant NapCatWebUI
participant QQKernel
Admin->>API: 更新登录 / scan/refresh
API->>NapCatWebUI: CheckLoginStatus / GetQQLoginInfo
NapCatWebUI->>NapCatWebUI: helper reconcile QQLoginStatus + selfInfo.online
API->>NapCatWebUI: RefreshQRcode
NapCatWebUI->>QQKernel: getQRCodePicture()
QQKernel-->>NapCatWebUI: accepted / rejected
QQKernel-->>NapCatWebUI: onQRCodeGetPicture(new QR)
NapCatWebUI->>NapCatWebUI: setQQLoginQrcodeURL + revision++
NapCatWebUI-->>API: accepted, updated, qrcodeRevision, qrcodeurl
API-->>Admin: pending + new QR 或 pending + 正在生成
```
若 QQ 内核接受刷新但未产码API/SSE 显示“正在生成二维码/等待 NapCat 返回二维码”,不展示旧码。
## 错误处理
- `online=undefined` 不做 stale reconcile避免启动过程误判。
- `online=false``QQLoginStatus=true` 时 reconcile 失败必须写 WebUI 错误信息,并允许后续刷新重试。
- `refreshQRCode.accepted=false` 时返回明确错误,不继续使用旧 QR。
- `accepted=true, updated=false` 时保持 pendingAPI 继续轮询或允许手动刷新。
- QR revision 变化但 URL 为空,表示旧码已清空,不能被 API 当成可展示二维码。
- 源码 fork 构建失败时不得回退到 bundled JS patch 镜像;构建阶段直接失败。
- 线上 canary 失败时回滚 `QQBOT_NAPCAT_IMAGE` 到上一版镜像,并保留 API 旧码防护。
## 验证计划
### NapCatQQ fork
需要在 fork 仓库运行:
```powershell
corepack pnpm install --frozen-lockfile
corepack pnpm run typecheck
corepack pnpm test
corepack pnpm run build:shell
```
若上游 monorepo 的测试脚本或 package 名称变化,以 `package.json` 为准,但必须覆盖:
- WebUI backend 登录态 helper。
- `QQLogin.ts` handler 对 stale login status 的行为。
- `refreshQRCode()` accepted/updated/revision。
- shell/framework callback 返回值。
### KT API 镜像集成
需要在 API 仓库运行:
```powershell
corepack pnpm exec jest test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts --runTestsByPath --runInBand
corepack pnpm run typecheck
git diff --check
```
如果改到容器创建逻辑,再追加:
```powershell
corepack pnpm exec jest test/qqbot/napcat/qqbot-napcat-container.service.spec.ts --runTestsByPath --runInBand
```
### 镜像验证
在 NAS 或本地 Docker 构建:
```bash
docker build \
--build-arg NAPCAT_BASE_IMAGE="$NAPCAT_BASE_IMAGE" \
-t kt-napcat-desktop-cn:desktop-cn-v3 \
-f .kt-workspace/napcat-desktop-cn-build/ci/napcat-desktop-cn/Dockerfile \
.kt-workspace/napcat-desktop-cn-build
docker run -d --name kt-napcat-v3-verify kt-napcat-desktop-cn:desktop-cn-v3
docker exec kt-napcat-v3-verify sh /ci/napcat-desktop-cn/verify.sh
docker rm -f kt-napcat-v3-verify
```
记录镜像 digest、fork commit、artifact sha256。
### 线上 canary
1. 只对一个测试账号或用户指定账号切换 `QQBOT_NAPCAT_IMAGE=kt-napcat-desktop-cn:desktop-cn-v3`
2. 不在 SSE 流程中重建在线容器;只有用户明确执行容器迁移/重建时才创建 v3 容器。
3. 触发更新登录,观察:
- quick 已登录错误后 WebUI 会 reconcile stale 登录态。
- `RefreshQRcode` 触发 NapCat 产码日志。
- `/app/napcat/cache/qrcode.png` mtime/hash 变化。
- API `scan/status` 返回新 QR不返回旧 QR。
- SSE/Admin 从“扫码”推进到扫码后状态或明确 pending 原因。
4. 若扫码后触发新设备或验证码,按既有链路继续,不把它当作 fork 失败。
## 上线顺序
1. 在 API 仓库提交本设计文档。
2. 进入 `superpowers:writing-plans`,把源码 fork、KT 镜像集成、测试和线上 canary 拆成实施任务。
3. 创建或更新 `D:\MyFiles\KT\GitHub\NapCatQQ` fork 分支。
4. 先实现并验证 NapCatQQ 源码修复。
5. 再修改 API 仓库的派生镜像构建脚本和测试。
6. 构建 `desktop-cn-v3`,在 NAS 上验证镜像。
7. 推送 API 仓库并观察 Jenkins/K8s。
8. 线上单账号 canary通过后再迁移剩余账号。
## 完成标准
- NapCatQQ fork 有明确 base commit、fork commit 和可重复构建命令。
- `desktop-cn-v3` 镜像不再包含 bundled JS 字符串 patch。
- stale `QQLoginStatus=true + online=false` 不再阻止刷新二维码。
- `RefreshQRcode` 能表达 accepted/updated/revision。
- API/Admin/SSE 不展示旧 QR线上 canary 能看到新 QR 或明确的未产码 pending 原因。
- 相关测试、镜像 verify、线上 canary 证据完整。

View File

@ -33,7 +33,9 @@ spec:
- name: DB_TIMEZONE
value: "+08:00"
- name: QQBOT_NAPCAT_IMAGE
value: kt-napcat-desktop-cn:desktop-cn-v2
value: kt-napcat-desktop-cn:desktop-cn-v3
- name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION
value: desktop-cn-v3
- name: QQBOT_NAPCAT_SSH_KEY_PATH
value: /app/secrets/napcat-ssh/id_rsa
- name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX

View File

@ -0,0 +1,179 @@
import { execFileSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
cpSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { dirname, isAbsolute, join, parse, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const DEFAULT_OUTPUT = '.kt-workspace/napcat-desktop-cn-build';
const DEFAULT_UPSTREAM_BASE = '5c18a62530d87dbadf53d267002894faa6ca7e90';
/**
* Reads a named CLI argument in `--key value` form.
* @param {string} name - Argument name without the leading dashes.
* @param {string} fallback - Value used when the argument is absent.
* @returns {string} Parsed argument value.
*/
function readArg(name, fallback = '') {
const index = process.argv.indexOf(`--${name}`);
return index >= 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
}
/**
* Computes a SHA256 digest for a file.
* @param {string} filePath - Absolute path to the file being fingerprinted.
* @returns {string} Lowercase hex SHA256 digest.
*/
function sha256File(filePath) {
return createHash('sha256').update(readFileSync(filePath)).digest('hex');
}
/**
* Lists directory entries in stable order.
* @param {string} directory - Directory to list.
* @returns {string[]} Sorted child names.
*/
function listDirectory(directory) {
return readdirSync(directory).sort();
}
/**
* Recursively computes a stable digest for a directory from relative paths and file contents.
* @param {string} directory - Absolute directory path.
* @returns {string} Lowercase hex SHA256 digest.
*/
function sha256Directory(directory) {
const hash = createHash('sha256');
const stack = [directory];
const files = [];
while (stack.length > 0) {
const current = stack.pop();
for (const entry of listDirectory(current)) {
const absolute = join(current, entry);
if (statSync(absolute).isDirectory()) {
stack.push(absolute);
} else {
files.push(absolute);
}
}
}
files.sort();
for (const file of files) {
hash.update(relative(directory, file).split(sep).join('/'));
hash.update('\0');
hash.update(readFileSync(file));
hash.update('\0');
}
return hash.digest('hex');
}
/**
* Reads the current git commit for the NapCat fork.
* @param {string} repoRoot - Absolute repository path.
* @returns {string} Current commit hash.
*/
function gitCommit(repoRoot) {
return execFileSync('git', ['-C', repoRoot, 'rev-parse', 'HEAD'], {
encoding: 'utf8',
}).trim();
}
/**
* Copies one file or directory into the staged Docker context.
* @param {string} source - Source path.
* @param {string} target - Target path.
*/
function copyIntoContext(source, target) {
cpSync(source, target, { recursive: true });
}
/**
* Checks whether a candidate path is inside an expected parent directory.
* @param {string} parent - Absolute parent directory that owns the allowed subtree.
* @param {string} candidate - Absolute path requested by the caller.
* @returns {boolean} Whether candidate is inside parent and is not parent itself.
*/
function isInsideDirectory(parent, candidate) {
const relativePath = relative(parent, candidate);
return Boolean(relativePath) && !relativePath.startsWith('..') && !isAbsolute(relativePath);
}
/**
* Rejects recursive-delete targets outside the API `.kt-workspace` staging area.
* @param {string} outputRootToCheck - Absolute output path that will be cleaned and regenerated.
* @param {string} apiRoot - Absolute API repository root.
* @param {string} napcatRootToCheck - Absolute NapCatQQ fork path passed as source input.
*/
function assertSafeOutputRoot(outputRootToCheck, apiRoot, napcatRootToCheck) {
const workspaceRoot = resolve(apiRoot, '.kt-workspace');
const forbiddenRoots = new Set([
parse(outputRootToCheck).root,
resolve(apiRoot),
resolve(apiRoot, '..', '..'),
workspaceRoot,
resolve(napcatRootToCheck),
dirname(resolve(napcatRootToCheck)),
]);
if (forbiddenRoots.has(resolve(outputRootToCheck))) {
throw new Error(`Refusing to delete unsafe output root: ${outputRootToCheck}`);
}
if (!isInsideDirectory(workspaceRoot, outputRootToCheck)) {
throw new Error(`Output root must stay inside ${workspaceRoot}`);
}
}
const apiRoot = resolve(fileURLToPath(new URL('..', import.meta.url)));
const napcatRootArg = readArg('napcat-root');
const napcatRoot = resolve(napcatRootArg);
const outputRoot = resolve(apiRoot, readArg('out', DEFAULT_OUTPUT));
const upstreamBaseCommit = readArg('upstream-base-commit', DEFAULT_UPSTREAM_BASE);
const shellDist = resolve(napcatRoot, 'packages/napcat-shell/dist');
const napcatMjs = resolve(shellDist, 'napcat.mjs');
if (!napcatRootArg || !existsSync(napcatRoot)) {
throw new Error('--napcat-root must point to the NapCatQQ fork repository');
}
if (!existsSync(napcatMjs)) {
throw new Error(`NapCat shell build output is missing: ${napcatMjs}`);
}
assertSafeOutputRoot(outputRoot, apiRoot, napcatRoot);
rmSync(outputRoot, { force: true, recursive: true });
mkdirSync(resolve(outputRoot, 'ci/napcat-desktop-cn'), { recursive: true });
copyIntoContext(
resolve(apiRoot, 'ci/napcat-desktop-cn/Dockerfile'),
resolve(outputRoot, 'ci/napcat-desktop-cn/Dockerfile'),
);
copyIntoContext(
resolve(apiRoot, 'ci/napcat-desktop-cn/verify.sh'),
resolve(outputRoot, 'ci/napcat-desktop-cn/verify.sh'),
);
copyIntoContext(shellDist, resolve(outputRoot, 'NapCat.Shell'));
const marker = {
builtAt: new Date().toISOString(),
distSha256: sha256Directory(shellDist),
forkCommit: gitCommit(napcatRoot),
napcatMjsSha256: sha256File(napcatMjs),
upstreamBaseCommit,
};
writeFileSync(
resolve(outputRoot, 'ci/napcat-desktop-cn/fork-artifact.json'),
`${JSON.stringify(marker, null, 2)}\n`,
'utf8',
);
process.stdout.write(`${JSON.stringify({
marker,
outputRoot,
}, null, 2)}\n`);

View File

@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService {
dataDir: input.dataDir,
desktopProfileVersion: this.getString(
'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION',
'desktop-cn-v2',
'desktop-cn-v3',
),
deviceIdentityId: input.deviceIdentityId,
imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''),

View File

@ -41,39 +41,45 @@ describe('NapCat Chinese Desktop Runtime image assets', () => {
expect(verify).toContain('/proc/1/cgroup');
expect(verify).toContain('XDG_CONFIG_HOME=/app/.config');
expect(verify).toContain('Asia/Shanghai');
expect(verify).toContain('selfInfo?.online !== false');
expect(verify).toContain('setQQLoginStatus(false)');
});
it('patches NapCat WebUI login guards to allow qrcode refresh after real QQ offline', () => {
it('stages source-built NapCat Shell artifacts for Docker build context', () => {
const script = readSource('scripts/napcat-desktop-cn-stage-build.mjs');
expect(script).toContain('napcatMjsSha256');
expect(script).toContain('forkCommit');
expect(script).toContain('upstreamBaseCommit');
expect(script).toContain('packages/napcat-shell/dist');
expect(script).toContain('fork-artifact.json');
expect(script).toContain('.kt-workspace/napcat-desktop-cn-build');
expect(script).toContain('assertSafeOutputRoot');
expect(script).toContain('workspaceRoot');
expect(script).toContain('Output root must stay inside');
expect(script).toContain('Refusing to delete unsafe output root');
});
it('uses source-built NapCat Shell artifact instead of bundled JS patching', () => {
const dockerfile = readSource('ci/napcat-desktop-cn/Dockerfile');
expect(dockerfile).toContain(
'ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh',
);
expect(dockerfile).toContain(
'sh /tmp/qq-login-real-online-guard.sh',
);
expect(dockerfile).toContain(
"sed -i 's/\\r$//' /tmp/qq-login-real-online-guard.sh",
);
expect(dockerfile).toContain('NAPCAT_PATCH_ROOT=/tmp/NapCat.Shell');
expect(dockerfile).toContain('zip -qr /app/NapCat.Shell.zip .');
expect(dockerfile).toContain(
'COPY ci/napcat-desktop-cn/verify.sh /ci/napcat-desktop-cn/verify.sh',
);
expect(dockerfile).toContain(
"sed -i 's/\\r$//' /ci/napcat-desktop-cn/verify.sh",
);
const verify = readSource('ci/napcat-desktop-cn/verify.sh');
const patch = readSource(
'ci/napcat-desktop-cn/patches/qq-login-real-online-guard.sh',
);
expect(patch).toContain('QQ Is Logined');
expect(patch).toContain('getQQLoginStatus');
expect(patch).toContain('selfInfo?.online');
expect(patch).toContain('setQQLoginStatus(false)');
expect(patch).toContain('RefreshQRcode');
expect(patch).toContain('[A-Za-z_\\$]');
expect(patch).toContain('[\\w\\$]');
expect(dockerfile).toContain('COPY NapCat.Shell /tmp/NapCat.Shell');
expect(dockerfile).toContain('fork-artifact.json');
expect(dockerfile).toContain('zip -qr /app/NapCat.Shell.zip .');
expect(dockerfile).not.toContain('qq-login-real-online-guard.sh');
expect(dockerfile).not.toContain('NAPCAT_PATCH_ROOT');
expect(verify).toContain('napcatMjsSha256');
expect(verify).toContain('getQQLoginRuntimeState');
expect(verify).toContain('qrcodeRevision');
expect(verify).not.toContain('selfInfo?.online !== false');
});
it('deploys the production API with the verified desktop-cn-v3 runtime profile', () => {
const manifest = readSource('k8s/prod/api.yaml');
expect(manifest).toContain('name: QQBOT_NAPCAT_IMAGE');
expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v3');
expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION');
expect(manifest).toContain('value: desktop-cn-v3');
expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v2');
});
});

View File

@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => {
});
expect(profile).toMatchObject({
desktopProfileVersion: 'desktop-cn-v2',
desktopProfileVersion: 'desktop-cn-v3',
imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest',
locale: 'zh_CN.UTF-8',
runtimeGid: 1101,