From e3ab00c00fa56972fb17cabecac6f02e1d646e3d Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 26 Jun 2026 11:25:03 +0800 Subject: [PATCH 01/25] =?UTF-8?q?fix:=20=E7=BC=93=E5=AD=98NapCat=20WebUI?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=87=AD=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../qqbot-napcat-container.service.ts | 127 +++++++++- ...ot-napcat-container-runtime-status.spec.ts | 236 ++++++++++++++++++ 2 files changed, 355 insertions(+), 8 deletions(-) diff --git a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts index 67db6c9..87d14d0 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts @@ -49,12 +49,29 @@ type CreateManagedContainerOptions = { startRemote?: boolean; }; +type NapcatWebuiCredentialCacheEntry = { + credential: string; + expiresAt: number; +}; + +const NAPCAT_WEBUI_CREDENTIAL_TTL_MS = 50 * 60 * 1000; + @Injectable() export class QqbotNapcatContainerService { private readonly configWriterService: NapcatConfigWriterService; private readonly runtimeProfileService: NapcatRuntimeProfileService; + private readonly webuiCredentials: Record< + string, + NapcatWebuiCredentialCacheEntry | undefined + > = {}; + + private readonly webuiCredentialRequests: Record< + string, + Promise | undefined + > = {}; + /** * 初始化 QqbotNapcatContainerService 实例。 * @param configService - Runtime configuration source for NAS SSH, Docker image, port pool, and profile defaults. @@ -894,13 +911,7 @@ docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' "$NAME" try { const runtime = this.toRuntime(container); - const credential = await this.getNapcatCredential(runtime); - const status = await this.requestNapcat( - runtime, - '/api/QQLogin/CheckLoginStatus', - {}, - credential, - ); + const status = await this.requestNapcatLoginStatus(runtime); const snapshot = this.toRuntimeStatusSnapshot( status, containerOnline, @@ -1693,9 +1704,40 @@ ${file.content}EOF`; /** * 查询 NapCat 登录运行态数据。 - * @param runtime - runtime 输入;使用 `webuiToken` 字段生成结果。 + * @param runtime - NapCat runtime;使用容器身份、WebUI 地址和 token 边界复用短期 Credential。 + * @returns 可用于 NapCat WebUI Bearer 鉴权的 Credential。 */ private async getNapcatCredential(runtime: QqbotNapcatRuntime) { + const cacheKey = this.getNapcatCredentialCacheKey(runtime); + const cached = this.webuiCredentials[cacheKey]; + if (cached && Date.now() < cached.expiresAt) { + return cached.credential; + } + + const pending = this.webuiCredentialRequests[cacheKey]; + if (pending) { + return pending; + } + + const request = this.fetchNapcatCredential(runtime, cacheKey); + this.webuiCredentialRequests[cacheKey] = request; + try { + return await request; + } finally { + delete this.webuiCredentialRequests[cacheKey]; + } + } + + /** + * 从 NapCat WebUI 换取并缓存新的 Bearer Credential。 + * @param runtime - NapCat runtime;提供 WebUI 地址和 token 以调用 `/api/auth/login`。 + * @param cacheKey - 已由调用方按容器身份和 token 生成的缓存键,用于落入同一 single-flight 槽位。 + * @returns 可用于后续 NapCat WebUI 请求的 Credential。 + */ + private async fetchNapcatCredential( + runtime: QqbotNapcatRuntime, + cacheKey: string, + ) { const token = runtime.webuiToken || ''; const hash = createHash('sha256').update(`${token}.napcat`).digest('hex'); const data = await this.requestNapcat( @@ -1706,9 +1748,78 @@ ${file.content}EOF`; if (!data.Credential) { throwVbenError('NapCat WebUI 登录失败'); } + this.webuiCredentials[cacheKey] = { + credential: data.Credential, + expiresAt: Date.now() + NAPCAT_WEBUI_CREDENTIAL_TTL_MS, + }; return data.Credential; } + /** + * 请求 NapCat QQ 登录状态,并在 WebUI Credential 失效时刷新一次。 + * @param runtime - NapCat runtime;提供 WebUI 地址、token 和容器身份以完成鉴权与状态读取。 + * @returns NapCat WebUI 返回的 QQ 登录状态。 + */ + private async requestNapcatLoginStatus(runtime: QqbotNapcatRuntime) { + const credential = await this.getNapcatCredential(runtime); + try { + return await this.requestNapcat( + runtime, + '/api/QQLogin/CheckLoginStatus', + {}, + credential, + ); + } catch (err) { + if (!this.isNapcatCredentialRejected(err)) { + throw err; + } + this.clearNapcatCredential(runtime, credential); + const refreshedCredential = await this.getNapcatCredential(runtime); + return this.requestNapcat( + runtime, + '/api/QQLogin/CheckLoginStatus', + {}, + refreshedCredential, + ); + } + } + + /** + * 清理当前容器的 NapCat WebUI Credential 缓存。 + * @param runtime - NapCat runtime;`id/baseUrl/token` 决定需要失效的进程内缓存条目。 + * @param rejectedCredential - NapCat 刚拒绝的 Credential;有值时只清理仍等于它的缓存,避免旧请求删除已刷新的新缓存。 + */ + private clearNapcatCredential( + runtime: QqbotNapcatRuntime, + rejectedCredential?: string, + ) { + const cacheKey = this.getNapcatCredentialCacheKey(runtime); + const cached = this.webuiCredentials[cacheKey]; + if (rejectedCredential && cached?.credential !== rejectedCredential) { + return; + } + delete this.webuiCredentials[cacheKey]; + } + + /** + * 判断 NapCat WebUI 是否拒绝了当前 Credential。 + * @param err - NapCat 请求异常;来源可能是 WebUI `Unauthorized` 或旧 token 失效提示。 + * @returns 为 true 时调用方可安全刷新一次 Credential 后重试状态请求。 + */ + private isNapcatCredentialRejected(err: unknown) { + const message = this.toolsService.getErrorMessage(err); + return /Unauthorized|token is invalid/i.test(message); + } + + /** + * 生成 NapCat WebUI Credential 缓存键。 + * @param runtime - NapCat runtime;`id/baseUrl` 定位容器实例,`webuiToken` 区分重建或换密钥后的鉴权边界。 + * @returns 当前 API 进程内 credential 缓存使用的稳定键。 + */ + private getNapcatCredentialCacheKey(runtime: QqbotNapcatRuntime) { + return [runtime.id || runtime.baseUrl, runtime.webuiToken || ''].join('\n'); + } + /** * 执行 NapCat 登录运行态流程。 * @param status - NapCat列表;使用 `loginError` 字段生成结果。 diff --git a/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts b/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts index c82a9c6..64742d1 100644 --- a/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container-runtime-status.spec.ts @@ -82,4 +82,240 @@ describe('QqbotNapcatContainerService runtime status', () => { }), ); }); + + it('reuses WebUI credential when checking the same running container repeatedly', async () => { + const repository = { + update: jest.fn(), + }; + const service = createService(repository); + const requestNapcat = jest + .spyOn(service as any, 'requestNapcat') + .mockImplementation(async (_runtime, path: string) => { + if (path === '/api/auth/login') { + return { Credential: 'credential-1' }; + } + if (path === '/api/QQLogin/CheckLoginStatus') { + return { + isLogin: true, + isOffline: false, + loginError: '', + online: true, + qrcodeurl: '', + }; + } + throw new Error(`unexpected path ${path}`); + }); + + const container = { + baseUrl: 'http://127.0.0.1:6100/', + id: 'container-1', + lastError: null, + name: 'napcat-1', + status: 'running', + webuiPort: 6100, + webuiToken: 'token', + } as any; + + await service.inspectRuntimeStatus(container); + await service.inspectRuntimeStatus(container); + + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/auth/login', + ), + ).toHaveLength(1); + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/QQLogin/CheckLoginStatus', + ), + ).toHaveLength(2); + }); + + it('deduplicates concurrent WebUI credential requests for the same container', async () => { + const repository = { + update: jest.fn(), + }; + const service = createService(repository); + const requestNapcat = jest + .spyOn(service as any, 'requestNapcat') + .mockImplementation(async (_runtime, path: string) => { + if (path === '/api/auth/login') { + await new Promise((resolve) => setTimeout(resolve, 1)); + return { Credential: 'credential-1' }; + } + if (path === '/api/QQLogin/CheckLoginStatus') { + return { + isLogin: true, + isOffline: false, + loginError: '', + online: true, + qrcodeurl: '', + }; + } + throw new Error(`unexpected path ${path}`); + }); + + const container = { + baseUrl: 'http://127.0.0.1:6100/', + id: 'container-1', + lastError: null, + name: 'napcat-1', + status: 'running', + webuiPort: 6100, + webuiToken: 'token', + } as any; + + await Promise.all([ + service.inspectRuntimeStatus(container), + service.inspectRuntimeStatus(container), + ]); + + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/auth/login', + ), + ).toHaveLength(1); + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/QQLogin/CheckLoginStatus', + ), + ).toHaveLength(2); + }); + + it('refreshes cached WebUI credential once when NapCat rejects status auth', async () => { + const repository = { + update: jest.fn(), + }; + const service = createService(repository); + let credentialIndex = 0; + let statusChecks = 0; + const requestNapcat = jest + .spyOn(service as any, 'requestNapcat') + .mockImplementation( + async (_runtime, path: string, _body, credential?: string) => { + if (path === '/api/auth/login') { + credentialIndex += 1; + return { Credential: `credential-${credentialIndex}` }; + } + if (path === '/api/QQLogin/CheckLoginStatus') { + statusChecks += 1; + if (statusChecks > 1 && credential === 'credential-1') { + throw new Error('Unauthorized'); + } + return { + isLogin: true, + isOffline: false, + loginError: '', + online: true, + qrcodeurl: '', + }; + } + throw new Error(`unexpected path ${path}`); + }, + ); + + const container = { + baseUrl: 'http://127.0.0.1:6100/', + id: 'container-1', + lastError: null, + name: 'napcat-1', + status: 'running', + webuiPort: 6100, + webuiToken: 'token', + } as any; + + await service.inspectRuntimeStatus(container); + const snapshot = await service.inspectRuntimeStatus(container); + + expect(snapshot).toEqual( + expect.objectContaining({ + qqLoginStatus: 'online', + webuiOnline: true, + }), + ); + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/auth/login', + ), + ).toHaveLength(2); + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/QQLogin/CheckLoginStatus', + ), + ).toHaveLength(3); + }); + + it('keeps a refreshed credential when a later stale status request is rejected', async () => { + const repository = { + update: jest.fn(), + }; + const service = createService(repository); + let credentialIndex = 0; + let staleMode = false; + let staleStatusChecks = 0; + let releaseSecondStaleReject: (() => void) | undefined; + const secondStaleReject = new Promise((resolve) => { + releaseSecondStaleReject = resolve; + }); + const requestNapcat = jest + .spyOn(service as any, 'requestNapcat') + .mockImplementation( + async (_runtime, path: string, _body, credential?: string) => { + if (path === '/api/auth/login') { + credentialIndex += 1; + return { + Credential: + credentialIndex === 1 + ? 'credential-old' + : 'credential-new', + }; + } + if (path === '/api/QQLogin/CheckLoginStatus') { + if (staleMode && credential === 'credential-old') { + staleStatusChecks += 1; + if (staleStatusChecks === 1) { + throw new Error('Unauthorized'); + } + await secondStaleReject; + throw new Error('Unauthorized'); + } + if (staleMode && credential === 'credential-new') { + releaseSecondStaleReject?.(); + } + return { + isLogin: true, + isOffline: false, + loginError: '', + online: true, + qrcodeurl: '', + }; + } + throw new Error(`unexpected path ${path}`); + }, + ); + + const container = { + baseUrl: 'http://127.0.0.1:6100/', + id: 'container-1', + lastError: null, + name: 'napcat-1', + status: 'running', + webuiPort: 6100, + webuiToken: 'token', + } as any; + + await service.inspectRuntimeStatus(container); + staleMode = true; + + await Promise.all([ + service.inspectRuntimeStatus(container), + service.inspectRuntimeStatus(container), + ]); + + expect( + requestNapcat.mock.calls.filter( + ([, path]: unknown[]) => path === '/api/auth/login', + ), + ).toHaveLength(2); + }); }); From 074bee1d4b351a6b40f0727dd696ff790082c4f0 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 26 Jun 2026 17:21:18 +0800 Subject: [PATCH 02/25] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90NapCat=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E6=8B=9F=E7=9C=9F=E6=8E=A2=E9=92=88=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 4 +- ci/napcat-desktop-cn/Dockerfile | 6 +- .../entrypoint-device-profile.patch.sh | 159 ++++++++++++++++++ ci/napcat-desktop-cn/verify.sh | 12 ++ scripts/napcat-desktop-cn-stage-build.mjs | 4 + .../runtime/napcat-config-writer.service.ts | 4 +- .../runtime/napcat-runtime-profile.service.ts | 2 +- .../qqbot-napcat-container.service.ts | 73 +++++++- .../qqbot/napcat/device-identity.spec.ts | 18 ++ .../napcat/napcat-desktop-cn-image.spec.ts | 38 +++++ .../napcat/runtime-protocol-profile.spec.ts | 5 +- .../qqbot-napcat-container.service.spec.ts | 23 +++ 12 files changed, 339 insertions(+), 9 deletions(-) create mode 100644 ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh diff --git a/API.md b/API.md index dc993f0..e379e28 100644 --- a/API.md +++ b/API.md @@ -367,7 +367,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 同一 QQ 账号只保留一个有效 NapCat 主容器。扫码后如果已有账号绑定到新容器,后端会释放旧绑定和未共享的旧容器,避免同账号多实例互相挤下线。OneBot notice 只有机器人下线、登录失效、`KickedOffLine` 等账号级信号才会记录 QQ 登录态异常并生成 `qqbot.account.offline` 站内信,普通群成员 kick 不属于账号离线信号。下线原因写入 `lastError` 前按 `last_error` 500 字符列宽截断;后续无错误的普通断连只更新 OneBot 连接状态,不清空该原因。账号列表会按近期缓存检查绑定 NapCat 容器的最新登录状态日志,日志检测默认 5 秒超时;`isOnline:false` 属于 QQ 登录态离线信号;心跳只代表 OneBot/容器通信,不能推导 QQ 登录态;近期连接只用于避免重连瞬间被旧缓存误伤,后续仍必须以 NapCat WebUI/日志检查判断 QQ 登录态。托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像。 -托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用 `pc-<8hex>` hostname、实体 OUI 风格 MAC 和 machine-id,并明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀;新增账号创建期在真实 QQ selfId 未知时使用预留容器 id 创建临时设备身份,第一次 Docker run 就注入完整拟真参数,扫码成功后再把该身份和 runtime/protocol profile 归属到真实账号。Docker run 会注入 `--hostname`、`--mac-address` 和只读 `/etc/machine-id` 挂载;后端还会同步写入 QQNT Linux `machine-info`,让 QQNT 计算 GUID 时使用的 MAC 与 Docker 网卡一致。当前策略名为 `qqnt-visible-hostname-v1` / `physical-oui-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id`。 +托管 NapCat 容器按账号持久化设备身份,`napcat_device_identity` 保存账号对应的数据目录、hostname、machine-id 路径、MAC 地址、验证状态和最近登录证据。重建同一账号容器时会复用 `pc-<8hex>` hostname、实体 OUI 风格 MAC 和 machine-id,并明确排除 Docker `02:42`、QEMU/KVM `52:54:00`、VMware、Hyper-V 等虚拟化前缀;新增账号创建期在真实 QQ selfId 未知时使用预留容器 id 创建临时设备身份,第一次 Docker run 就注入完整拟真参数,扫码成功后再把该身份和 runtime/protocol profile 归属到真实账号。Docker run 会注入 `--hostname`、`--mac-address`、只读 `/etc/machine-id` 挂载、`SYS_ADMIN`、`apparmor=unconfined`、`seccomp=unconfined` 和 `NAPCAT_REQUIRE_DEVICE_PROFILE=1`;后端还会同步写入 QQNT Linux `machine-info`,让 QQNT 计算 GUID 时使用的 MAC 与 Docker 网卡一致。派生镜像 entrypoint 会用同一设备 profile 覆盖 QQCore 实际打开的 DMI、boot_id、kernel release/version/proc version、CPU model、uptime、TTY active、mountinfo、`/etc/hosts` 和 `/proc/devices` 等探针;NapCat fork native login 和 core session config 的 `machineId` 与 `systemVersion` 也从该 profile 读取,避免 QQ native 入参和 Docker 可见探针不一致。当前策略名为 `qqnt-visible-hostname-v1` / `physical-oui-mac-v1`,绑定关系会回填 `napcat_account_binding.device_identity_id`。 ### NapCat Runtime Profile @@ -377,7 +377,7 @@ 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 v8 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,且只有明确二维码过期或扫码确认窗口失效的 QR session failure 才自动换码,避免扫码确认期间被 `ErrType=1/ErrCode=1` 抢刷新打断。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v8` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v8` / `desktop-cn-v8` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 +NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,且只有明确二维码过期或扫码确认窗口失效的 QR session failure 才自动换码,避免扫码确认期间被 `ErrType=1/ErrCode=1` 抢刷新打断。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v8` / `desktop-cn-v8` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadata:upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。 diff --git a/ci/napcat-desktop-cn/Dockerfile b/ci/napcat-desktop-cn/Dockerfile index e85a088..728433f 100644 --- a/ci/napcat-desktop-cn/Dockerfile +++ b/ci/napcat-desktop-cn/Dockerfile @@ -30,7 +30,11 @@ RUN set -eux; \ 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 +COPY ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh /ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh +RUN set -eux; \ + sed -i 's/\r$//' /ci/napcat-desktop-cn/verify.sh /ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh; \ + chmod +x /ci/napcat-desktop-cn/verify.sh /ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh; \ + /ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh ENV LANG=zh_CN.UTF-8 \ LC_ALL=zh_CN.UTF-8 \ diff --git a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh new file mode 100644 index 0000000..b139362 --- /dev/null +++ b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh @@ -0,0 +1,159 @@ +#!/bin/sh +set -eu + +ENTRYPOINT=/app/entrypoint.sh + +if grep -q 'KT device profile patch defaults' "$ENTRYPOINT"; then + exit 0 +fi + +grep -q 'mount --bind "$FAKE_CMDLINE" /proc/1/cmdline' "$ENTRYPOINT" + +cat >/tmp/kt-device-profile-defaults.sh <<'EOF' + +# KT device profile patch defaults. +: "${NAPCAT_REQUIRE_DEVICE_PROFILE:=0}" +: "${NAPCAT_DMI_PRODUCT_NAME:=imini Pro}" +: "${NAPCAT_DMI_PRODUCT_UUID:=00000000-0000-0000-0000-000000000000}" +: "${NAPCAT_DMI_SYS_VENDOR:=MECHREVO}" +: "${NAPCAT_DMI_BOARD_VENDOR:=MECHREVO}" +: "${NAPCAT_DMI_BOARD_NAME:=imini Pro}" +: "${NAPCAT_DMI_BIOS_VENDOR:=American Megatrends International, LLC.}" +: "${NAPCAT_DMI_BIOS_VERSION:=imini Pro 1.10}" +: "${NAPCAT_DMI_MODALIAS:=dmi:bvnAmericanMegatrendsInternational,LLC.:bvriminiPro1.10:bd03/31/2024:br1.10:efr1.10:svnMECHREVO:pniminiPro:pvrStandard:rvnMECHREVO:rniminiPro:rvrStandard:cvnMECHREVO:ct3:cvrDefaultstring:skuStandard:}" +: "${NAPCAT_DEVICE_BOOT_ID:=00000000-0000-4000-8000-000000000000}" +: "${NAPCAT_DEVICE_KERNEL_RELEASE:=6.8.0-60-generic}" +: "${NAPCAT_DEVICE_KERNEL_VERSION:=#63-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 15 19:04:15 UTC 2025}" +: "${NAPCAT_DEVICE_PROC_VERSION:=Linux version 6.8.0-60-generic (buildd@lcy02-amd64-001) (x86_64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0, GNU ld (GNU Binutils for Ubuntu) 2.42) #63-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 15 19:04:15 UTC 2025}" +: "${NAPCAT_DEVICE_CPU_MODEL:=AMD Ryzen 7 8845H w/ Radeon 780M Graphics}" +: "${NAPCAT_DEVICE_UPTIME:=7200.00 14400.00}" +: "${NAPCAT_DEVICE_TTY_ACTIVE:=tty1}" + +kt_fake_file() { + kt_target="$1" + kt_content="$2" + kt_name="$(echo "$kt_target" | tr '/' '_')" + kt_fake="$FAKE_CGROUP_DIR/kt_device_profile_$kt_name" + printf '%s\n' "$kt_content" > "$kt_fake" + mount --bind "$kt_fake" "$kt_target" 2>/dev/null || true +} +EOF + +cat >/tmp/kt-device-profile-dmi.sh <<'EOF' + +# KT device profile DMI overrides for files QQCore opens. +kt_fake_file /sys/class/dmi/id/product_name "$NAPCAT_DMI_PRODUCT_NAME" +kt_fake_file /sys/class/dmi/id/product_uuid "$NAPCAT_DMI_PRODUCT_UUID" +kt_fake_file /sys/class/dmi/id/sys_vendor "$NAPCAT_DMI_SYS_VENDOR" +kt_fake_file /sys/class/dmi/id/board_vendor "$NAPCAT_DMI_BOARD_VENDOR" +kt_fake_file /sys/class/dmi/id/board_name "$NAPCAT_DMI_BOARD_NAME" +kt_fake_file /sys/class/dmi/id/bios_vendor "$NAPCAT_DMI_BIOS_VENDOR" +kt_fake_file /sys/class/dmi/id/bios_version "$NAPCAT_DMI_BIOS_VERSION" +kt_fake_file /sys/class/dmi/id/modalias "$NAPCAT_DMI_MODALIAS" +EOF + +cat >/tmp/kt-device-profile-proc.sh <<'EOF' + +# KT device profile proc/sys files QQCore opens. +kt_fake_file /proc/sys/kernel/random/boot_id "$NAPCAT_DEVICE_BOOT_ID" +kt_fake_file /proc/sys/kernel/version "$NAPCAT_DEVICE_KERNEL_VERSION" +kt_fake_file /proc/version "$NAPCAT_DEVICE_PROC_VERSION" +kt_fake_file /proc/uptime "$NAPCAT_DEVICE_UPTIME" +if [ -f /proc/cpuinfo ]; then + KT_FAKE_CPUINFO="$FAKE_CGROUP_DIR/kt_device_profile_proc_cpuinfo" + awk -v model="$NAPCAT_DEVICE_CPU_MODEL" ' + /^model name[[:space:]]*:/ { $0 = "model name\t: " model } + /^flags[[:space:]]*:/ { + gsub(/(^| )hypervisor( |$)/, " ") + gsub(/[[:space:]]+/, " ") + } + { print } + ' /proc/cpuinfo > "$KT_FAKE_CPUINFO" 2>/dev/null || true + mount --bind "$KT_FAKE_CPUINFO" /proc/cpuinfo 2>/dev/null || true +fi +if [ -f /proc/devices ]; then + KT_FAKE_DEVICES="$FAKE_CGROUP_DIR/kt_device_profile_proc_devices" + grep -Ev 'trim-trashbin|zvol' /proc/devices > "$KT_FAKE_DEVICES" 2>/dev/null || true + mount --bind "$KT_FAKE_DEVICES" /proc/devices 2>/dev/null || true +fi +if [ -f /sys/devices/virtual/tty/tty0/active ]; then + kt_fake_file /sys/devices/virtual/tty/tty0/active "$NAPCAT_DEVICE_TTY_ACTIVE" +fi +if [ -f /etc/hosts ]; then + KT_FAKE_HOSTS="$FAKE_CGROUP_DIR/kt_device_profile_hosts" + { + printf '127.0.0.1\tlocalhost\n' + printf '::1\tlocalhost ip6-localhost ip6-loopback\n' + printf 'fe00::\tip6-localnet\n' + printf 'ff00::\tip6-mcastprefix\n' + printf 'ff02::1\tip6-allnodes\n' + printf 'ff02::2\tip6-allrouters\n' + } > "$KT_FAKE_HOSTS" + mount --bind "$KT_FAKE_HOSTS" /etc/hosts 2>/dev/null || true +fi +EOF + +cat >/tmp/kt-device-profile-verify.sh <<'EOF' + +if [ "${NAPCAT_REQUIRE_DEVICE_PROFILE:-0}" = "1" ]; then + kt_require_device_profile() { + kt_device_profile_label="$1" + shift + "$@" || { + echo "NapCat device profile check failed: $kt_device_profile_label" >&2 + exit 78 + } + } + + kt_require_device_profile proc1-cmdline sh -c "tr '\\000' ' ' &2 + exit 78 + fi + if [ -f /sys/devices/virtual/tty/tty0/active ]; then + kt_require_device_profile tty-active grep -q "$NAPCAT_DEVICE_TTY_ACTIVE" /sys/devices/virtual/tty/tty0/active + else + echo "NapCat device profile check failed: tty0-active-missing" >&2 + exit 78 + fi + for kt_mountinfo in /proc/self/mountinfo /proc/1/mountinfo; do + if grep -E 'docker|containerd|overlay|\.dockerenv' "$kt_mountinfo" >/dev/null 2>&1; then + echo "NapCat device profile check failed: mountinfo-host-leak:$kt_mountinfo" >&2 + exit 78 + fi + done + if grep -q "$(hostname)" /etc/hosts; then + echo "NapCat device profile check failed: hosts-still-contains-hostname" >&2 + exit 78 + fi + if grep -E 'trim-trashbin|zvol' /proc/devices >/dev/null 2>&1; then + echo "NapCat device profile check failed: proc-devices-host-leak" >&2 + exit 78 + fi +fi +EOF + +sed -i '/FAKE_CGROUP_DIR="\/tmp\/fake_cgroup"/r /tmp/kt-device-profile-defaults.sh' "$ENTRYPOINT" +sed -i '/# Docker Socket/r /tmp/kt-device-profile-dmi.sh' "$ENTRYPOINT" +sed -i '/# \/proc\/1\/sched/r /tmp/kt-device-profile-proc.sh' "$ENTRYPOINT" +sed -i '/: ${NAPCAT_UID:=0}/r /tmp/kt-device-profile-verify.sh' "$ENTRYPOINT" + +rm -f \ + /tmp/kt-device-profile-defaults.sh \ + /tmp/kt-device-profile-dmi.sh \ + /tmp/kt-device-profile-proc.sh \ + /tmp/kt-device-profile-verify.sh diff --git a/ci/napcat-desktop-cn/verify.sh b/ci/napcat-desktop-cn/verify.sh index 511f7f2..8d79b74 100644 --- a/ci/napcat-desktop-cn/verify.sh +++ b/ci/napcat-desktop-cn/verify.sh @@ -44,3 +44,15 @@ grep -R -q 'getQQLoginRuntimeState' "$TMP_DIR" grep -R -q 'qrcodeRevision' "$TMP_DIR" grep -R -q 'needsLoginServiceReset' "$TMP_DIR" grep -R -q '重置已失效登录服务后重新生成二维码' "$TMP_DIR" + +grep -q 'KT device profile patch defaults' /app/entrypoint.sh +grep -q 'NAPCAT_REQUIRE_DEVICE_PROFILE' /app/entrypoint.sh +grep -q '/proc/1/cmdline' /app/entrypoint.sh +grep -q '/proc/1/sched' /app/entrypoint.sh +grep -q '/proc/1/status' /app/entrypoint.sh +grep -q '/proc/1/stat' /app/entrypoint.sh +grep -q '/proc/self/mountinfo' /app/entrypoint.sh +grep -q '/sys/class/dmi/id/product_name' /app/entrypoint.sh +grep -q '/sys/class/dmi/id/bios_vendor' /app/entrypoint.sh +grep -q '/sys/class/dmi/id/bios_version' /app/entrypoint.sh +grep -q '/etc/hosts' /app/entrypoint.sh diff --git a/scripts/napcat-desktop-cn-stage-build.mjs b/scripts/napcat-desktop-cn-stage-build.mjs index 13d150e..2d8d57c 100644 --- a/scripts/napcat-desktop-cn-stage-build.mjs +++ b/scripts/napcat-desktop-cn-stage-build.mjs @@ -161,6 +161,10 @@ copyIntoContext( resolve(apiRoot, 'ci/napcat-desktop-cn/verify.sh'), resolve(outputRoot, 'ci/napcat-desktop-cn/verify.sh'), ); +copyIntoContext( + resolve(apiRoot, 'ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh'), + resolve(outputRoot, 'ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh'), +); copyIntoContext(shellDist, resolve(outputRoot, 'NapCat.Shell')); const marker = { diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts index b263973..e80ce4e 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts @@ -36,7 +36,7 @@ type NapcatConfig = { process: false; window: false; }; - o3HookMode: 1; + o3HookMode: 0; packetBackend: 'auto'; packetServer: ''; }; @@ -75,7 +75,7 @@ export class NapcatConfigWriterService { process: false, window: false, }, - o3HookMode: 1, + o3HookMode: 0, packetBackend: 'auto', packetServer: '', }; diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index 9dba3bd..d1c2c9c 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -162,7 +162,7 @@ export class NapcatRuntimeProfileService { napcatConfigHash: input.protocolProfile.napcatConfigHash || null, napcatConfigJson: input.protocolProfile.napcatConfigJson || null, o3HookGrayEnabled: !!input.protocolProfile.o3HookGrayEnabled, - o3HookMode: input.protocolProfile.o3HookMode ?? 1, + o3HookMode: input.protocolProfile.o3HookMode ?? 0, onebotConfigHash: input.protocolProfile.onebotConfigHash || null, onebotConfigJson: input.protocolProfile.onebotConfigJson || null, packetBackend: input.protocolProfile.packetBackend || 'auto', diff --git a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts index 87d14d0..86337b3 100644 --- a/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts @@ -1438,6 +1438,31 @@ fi `DEVICE_ENV_PATH=${this.sh(input.deviceIdentity.deviceEnvPath)}`, ].join('\n') + '\n' : ''; + const deviceProfileHeader = [ + `NAPCAT_DMI_PRODUCT_NAME=${this.sh('imini Pro')}`, + `NAPCAT_DMI_SYS_VENDOR=${this.sh('MECHREVO')}`, + `NAPCAT_DMI_BOARD_VENDOR=${this.sh('MECHREVO')}`, + `NAPCAT_DMI_BOARD_NAME=${this.sh('imini Pro')}`, + `NAPCAT_DMI_BIOS_VENDOR=${this.sh( + 'American Megatrends International, LLC.', + )}`, + `NAPCAT_DMI_BIOS_VERSION=${this.sh('imini Pro 1.10')}`, + `NAPCAT_DMI_MODALIAS=${this.sh( + 'dmi:bvnAmericanMegatrendsInternational,LLC.:bvriminiPro1.10:bd03/31/2024:br1.10:efr1.10:svnMECHREVO:pniminiPro:pvrStandard:rvnMECHREVO:rniminiPro:rvrStandard:cvnMECHREVO:ct3:cvrDefaultstring:skuStandard:', + )}`, + `NAPCAT_DEVICE_KERNEL_RELEASE=${this.sh('6.8.0-60-generic')}`, + `NAPCAT_DEVICE_KERNEL_VERSION=${this.sh( + '#63-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 15 19:04:15 UTC 2025', + )}`, + `NAPCAT_DEVICE_PROC_VERSION=${this.sh( + 'Linux version 6.8.0-60-generic (buildd@lcy02-amd64-001) (x86_64-linux-gnu-gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0, GNU ld (GNU Binutils for Ubuntu) 2.42) #63-Ubuntu SMP PREEMPT_DYNAMIC Tue Apr 15 19:04:15 UTC 2025', + )}`, + `NAPCAT_DEVICE_CPU_MODEL=${this.sh( + 'AMD Ryzen 7 8845H w/ Radeon 780M Graphics', + )}`, + `NAPCAT_DEVICE_UPTIME=${this.sh('7200.00 14400.00')}`, + `NAPCAT_DEVICE_TTY_ACTIVE=${this.sh('tty1')}`, + ].join('\n'); const devicePrepareScript = input.deviceIdentity ? ` mkdir -p "$(dirname "$DEVICE_ENV_PATH")" "$(dirname "$MACHINE_INFO_PATH")" @@ -1464,9 +1489,33 @@ mv "$MACHINE_INFO_TMP" "$MACHINE_INFO_PATH" chmod 644 "$MACHINE_INFO_PATH" ` : ''; + const deviceProfilePrepareScript = ` +if [ -z "\${MACHINE_ID_PATH:-}" ]; then + MACHINE_ID_PATH="$DATA_DIR/machine-id" +fi +mkdir -p "$(dirname "$MACHINE_ID_PATH")" +if [ ! -s "$MACHINE_ID_PATH" ]; then + printf '%s' "$NAME" | sha256sum | cut -c 1-32 > "$MACHINE_ID_PATH" +fi +NAPCAT_DEVICE_MACHINE_ID="$(tr -d '\\r\\n' < "$MACHINE_ID_PATH" | cut -c 1-64)" +format_uuid_from_seed() { + uuid_hash="$(printf '%s' "$1" | sha256sum | awk '{print $1}')" + printf '%s-%s-%s-%s-%s' \\ + "$(printf '%s' "$uuid_hash" | cut -c 1-8)" \\ + "$(printf '%s' "$uuid_hash" | cut -c 9-12)" \\ + "$(printf '%s' "$uuid_hash" | cut -c 13-16)" \\ + "$(printf '%s' "$uuid_hash" | cut -c 17-20)" \\ + "$(printf '%s' "$uuid_hash" | cut -c 21-32)" +} +if [ ! -s "$DATA_DIR/device-boot-id" ]; then + format_uuid_from_seed "$NAPCAT_DEVICE_MACHINE_ID:boot" > "$DATA_DIR/device-boot-id" +fi +NAPCAT_DEVICE_BOOT_ID="$(tr -d '\\r\\n' < "$DATA_DIR/device-boot-id" | cut -c 1-36)" +NAPCAT_DMI_PRODUCT_UUID="$(format_uuid_from_seed "$NAPCAT_DEVICE_MACHINE_ID:dmi")" +`; const deviceRunFlags = input.deviceIdentity ? ' --hostname "$NAPCAT_HOSTNAME" \\\n --mac-address "$NAPCAT_MAC_ADDRESS" \\\n -v "$MACHINE_ID_PATH:/etc/machine-id:ro" \\\n' - : ''; + : ' -v "$MACHINE_ID_PATH:/etc/machine-id:ro" \\\n'; return ` set -eu @@ -1482,10 +1531,12 @@ NAPCAT_SHM_SIZE=${this.sh(runtimeProfile.shmSize)} ${accountHeader} ${passwordHeader} ${deviceHeader} +${deviceProfileHeader} mkdir -p "$DATA_DIR/QQ" "$DATA_DIR/config" "$DATA_DIR/plugins" "$DATA_DIR/logs" "$DATA_DIR/cache" "$DATA_DIR/local-share" "$DATA_DIR/runtime" chmod 700 "$DATA_DIR" chmod 700 "$DATA_DIR/runtime" ${devicePrepareScript} +${deviceProfilePrepareScript} ${configWriteScript} @@ -1494,9 +1545,29 @@ docker run -d \\ --name "$NAME" \\ --restart unless-stopped \\ --init \\ + --cap-add SYS_ADMIN \\ + --security-opt apparmor=unconfined \\ + --security-opt seccomp=unconfined \\ --shm-size "$NAPCAT_SHM_SIZE" \\ -e NAPCAT_UID="$NAPCAT_UID" \\ -e NAPCAT_GID="$NAPCAT_GID" \\ + -e NAPCAT_REQUIRE_DEVICE_PROFILE=1 \\ + -e NAPCAT_DEVICE_MACHINE_ID="$NAPCAT_DEVICE_MACHINE_ID" \\ + -e NAPCAT_DEVICE_BOOT_ID="$NAPCAT_DEVICE_BOOT_ID" \\ + -e NAPCAT_DEVICE_KERNEL_RELEASE="$NAPCAT_DEVICE_KERNEL_RELEASE" \\ + -e NAPCAT_DEVICE_KERNEL_VERSION="$NAPCAT_DEVICE_KERNEL_VERSION" \\ + -e NAPCAT_DEVICE_PROC_VERSION="$NAPCAT_DEVICE_PROC_VERSION" \\ + -e NAPCAT_DEVICE_CPU_MODEL="$NAPCAT_DEVICE_CPU_MODEL" \\ + -e NAPCAT_DEVICE_UPTIME="$NAPCAT_DEVICE_UPTIME" \\ + -e NAPCAT_DEVICE_TTY_ACTIVE="$NAPCAT_DEVICE_TTY_ACTIVE" \\ + -e NAPCAT_DMI_PRODUCT_NAME="$NAPCAT_DMI_PRODUCT_NAME" \\ + -e NAPCAT_DMI_PRODUCT_UUID="$NAPCAT_DMI_PRODUCT_UUID" \\ + -e NAPCAT_DMI_SYS_VENDOR="$NAPCAT_DMI_SYS_VENDOR" \\ + -e NAPCAT_DMI_BOARD_VENDOR="$NAPCAT_DMI_BOARD_VENDOR" \\ + -e NAPCAT_DMI_BOARD_NAME="$NAPCAT_DMI_BOARD_NAME" \\ + -e NAPCAT_DMI_BIOS_VENDOR="$NAPCAT_DMI_BIOS_VENDOR" \\ + -e NAPCAT_DMI_BIOS_VERSION="$NAPCAT_DMI_BIOS_VERSION" \\ + -e NAPCAT_DMI_MODALIAS="$NAPCAT_DMI_MODALIAS" \\ -e WEBUI_TOKEN="$WEBUI_TOKEN" \\ -e LANG=${runtimeProfile.locale} \\ -e LC_ALL=${runtimeProfile.locale} \\ diff --git a/test/modules/qqbot/napcat/device-identity.spec.ts b/test/modules/qqbot/napcat/device-identity.spec.ts index c875c92..93d5510 100644 --- a/test/modules/qqbot/napcat/device-identity.spec.ts +++ b/test/modules/qqbot/napcat/device-identity.spec.ts @@ -436,6 +436,15 @@ describe('NapCat device identity persistence', () => { expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"'); expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"'); expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"'); + expect(createScript).toContain('--cap-add SYS_ADMIN \\'); + expect(createScript).toContain('--security-opt apparmor=unconfined \\'); + expect(createScript).toContain('--security-opt seccomp=unconfined \\'); + expect(createScript).toContain( + '-e NAPCAT_DEVICE_MACHINE_ID="$NAPCAT_DEVICE_MACHINE_ID"', + ); + expect(createScript).toContain( + '-e NAPCAT_DEVICE_CPU_MODEL="$NAPCAT_DEVICE_CPU_MODEL"', + ); expect(createScript).toContain( '-v "$DATA_DIR/runtime:/tmp/runtime-napcat"', ); @@ -518,6 +527,15 @@ describe('NapCat device identity persistence', () => { expect(createScript).toContain('--hostname "$NAPCAT_HOSTNAME"'); expect(createScript).toContain('--mac-address "$NAPCAT_MAC_ADDRESS"'); expect(createScript).toContain('-v "$MACHINE_ID_PATH:/etc/machine-id:ro"'); + expect(createScript).toContain('--cap-add SYS_ADMIN \\'); + expect(createScript).toContain('--security-opt apparmor=unconfined \\'); + expect(createScript).toContain('--security-opt seccomp=unconfined \\'); + expect(createScript).toContain( + '-e NAPCAT_DMI_PRODUCT_NAME="$NAPCAT_DMI_PRODUCT_NAME"', + ); + expect(createScript).toContain( + '-e NAPCAT_DEVICE_TTY_ACTIVE="$NAPCAT_DEVICE_TTY_ACTIVE"', + ); expect(createScript).toContain('-e LANG=zh_CN.UTF-8'); expect(createScript).toContain('-e LC_ALL=zh_CN.UTF-8'); expect(createScript).toContain('-e TZ=Asia/Shanghai'); diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index f40fd0c..0928dc7 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -40,10 +40,47 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).toContain('/.dockerenv'); expect(verify).toContain('wait_for_absent'); expect(verify).toContain('/proc/1/cgroup'); + expect(verify).toContain('/proc/1/cmdline'); + expect(verify).toContain('/proc/1/sched'); + expect(verify).toContain('/proc/1/status'); + expect(verify).toContain('/proc/1/stat'); + expect(verify).toContain('/proc/self/mountinfo'); + expect(verify).toContain('/sys/class/dmi/id/product_name'); + expect(verify).toContain('/sys/class/dmi/id/bios_vendor'); + expect(verify).toContain('/sys/class/dmi/id/bios_version'); + expect(verify).toContain('/etc/hosts'); expect(verify).toContain('XDG_CONFIG_HOME=/app/.config'); expect(verify).toContain('Asia/Shanghai'); }); + it('patches entrypoint device profile probes that QQCore opens at runtime', () => { + const dockerfile = readSource('ci/napcat-desktop-cn/Dockerfile'); + const entrypointPatch = readSource( + 'ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh', + ); + const source = `${dockerfile}\n${entrypointPatch}`; + + expect(source).toContain('entrypoint-device-profile.patch.sh'); + expect(source).toContain('NAPCAT_REQUIRE_DEVICE_PROFILE'); + expect(source).toContain('NAPCAT_DMI_BIOS_VENDOR'); + expect(source).toContain('NAPCAT_DMI_BIOS_VERSION'); + expect(source).toContain('NAPCAT_DMI_MODALIAS'); + expect(source).toContain('NAPCAT_DEVICE_BOOT_ID'); + expect(source).toContain('NAPCAT_DEVICE_KERNEL_RELEASE'); + expect(source).toContain('NAPCAT_DEVICE_CPU_MODEL'); + expect(source).toContain('NAPCAT_DEVICE_UPTIME'); + expect(source).toContain('NAPCAT_DEVICE_TTY_ACTIVE'); + expect(source).toContain('/proc/uptime'); + expect(source).toContain('/proc/cpuinfo'); + expect(source).toContain('/sys/devices/virtual/tty/tty0/active'); + expect(source).toContain('/proc/self/mountinfo'); + expect(source).toContain('mount --bind "$FAKE_CMDLINE" /proc/1/cmdline'); + expect(source).toContain('kt_require_device_profile'); + expect(source).toContain('exit 78'); + expect(source).toContain('dmi-modalias'); + expect(source).toContain('proc-devices-host-leak'); + }); + it('stages source-built NapCat Shell artifacts for Docker build context', () => { const script = readSource('scripts/napcat-desktop-cn-stage-build.mjs'); @@ -56,6 +93,7 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(script).toContain('jenkinsBuildUrl'); expect(script).toContain('packages/napcat-shell/dist'); expect(script).toContain('fork-artifact.json'); + expect(script).toContain('entrypoint-device-profile.patch.sh'); expect(script).toContain('.kt-workspace/napcat-desktop-cn-build'); expect(script).toContain('assertSafeOutputRoot'); expect(script).toContain('workspaceRoot'); diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index cd68d42..ebb1752 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -181,9 +181,9 @@ describe('NapCat runtime profile generation', () => { }, protocolProfile: { napcatConfigHash: 'napcat-hash', - napcatConfigJson: { o3HookMode: 1 }, + napcatConfigJson: { o3HookMode: 0 }, o3HookGrayEnabled: false, - o3HookMode: 1, + o3HookMode: 0, onebotConfigHash: 'onebot-hash', onebotConfigJson: { network: { websocketClients: [] } }, packetBackend: 'auto', @@ -253,6 +253,7 @@ describe('NapCat runtime profile generation', () => { reconnectInterval: 5000, reportSelfMessage: false, }); + expect(result.napcatConfig.o3HookMode).toBe(0); expect( result.files.find((file) => file.path === 'webui.json')?.content, ).toContain(webuiAuthValue); diff --git a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts index b3e0c5e..9234a1c 100644 --- a/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts +++ b/test/qqbot/napcat/qqbot-napcat-container.service.spec.ts @@ -505,9 +505,32 @@ describe('QqbotNapcatContainerService', () => { expect(script).toContain("NAPCAT_GID='1101'"); expect(script).toContain("NAPCAT_SHM_SIZE='512m'"); expect(script).toContain('--init \\'); + expect(script).toContain('--cap-add SYS_ADMIN \\'); + expect(script).toContain('--security-opt apparmor=unconfined \\'); + expect(script).toContain('--security-opt seccomp=unconfined \\'); 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 NAPCAT_REQUIRE_DEVICE_PROFILE=1'); + expect(script).toContain('-e NAPCAT_DMI_SYS_VENDOR="$NAPCAT_DMI_SYS_VENDOR"'); + expect(script).toContain( + '-e NAPCAT_DMI_PRODUCT_UUID="$NAPCAT_DMI_PRODUCT_UUID"', + ); + expect(script).toContain( + '-e NAPCAT_DEVICE_BOOT_ID="$NAPCAT_DEVICE_BOOT_ID"', + ); + expect(script).toContain( + '-e NAPCAT_DEVICE_KERNEL_RELEASE="$NAPCAT_DEVICE_KERNEL_RELEASE"', + ); + expect(script).toContain( + '-e NAPCAT_DEVICE_CPU_MODEL="$NAPCAT_DEVICE_CPU_MODEL"', + ); + expect(script).toContain( + '-e NAPCAT_DEVICE_UPTIME="$NAPCAT_DEVICE_UPTIME"', + ); + expect(script).toContain( + '-e NAPCAT_DEVICE_TTY_ACTIVE="$NAPCAT_DEVICE_TTY_ACTIVE"', + ); 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'); From 7942ec351b991925313686c028e06477913dcf4a Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 26 Jun 2026 18:05:19 +0800 Subject: [PATCH 03/25] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E6=8B=9F=E7=9C=9F=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ci/napcat-desktop-cn/README.md | 7 ++++++- .../entrypoint-device-profile.patch.sh | 10 ++++------ ci/napcat-desktop-cn/verify.sh | 1 + .../qqbot/napcat/napcat-desktop-cn-image.spec.ts | 4 +++- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/ci/napcat-desktop-cn/README.md b/ci/napcat-desktop-cn/README.md index 52e15cb..550dc60 100644 --- a/ci/napcat-desktop-cn/README.md +++ b/ci/napcat-desktop-cn/README.md @@ -48,7 +48,12 @@ docker build ` .kt-workspace/napcat-desktop-cn-build $name = "kt-napcat-v8-verify-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" -docker run -d --name $name kt-napcat-desktop-cn:desktop-cn-v8 +docker run -d --name $name ` + --cap-add SYS_ADMIN ` + --security-opt apparmor=unconfined ` + --security-opt seccomp=unconfined ` + -e NAPCAT_REQUIRE_DEVICE_PROFILE=1 ` + kt-napcat-desktop-cn:desktop-cn-v8 docker exec $name sh /ci/napcat-desktop-cn/verify.sh docker rm -f $name ``` diff --git a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh index b139362..06be13b 100644 --- a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh +++ b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh @@ -130,12 +130,10 @@ if [ "${NAPCAT_REQUIRE_DEVICE_PROFILE:-0}" = "1" ]; then echo "NapCat device profile check failed: tty0-active-missing" >&2 exit 78 fi - for kt_mountinfo in /proc/self/mountinfo /proc/1/mountinfo; do - if grep -E 'docker|containerd|overlay|\.dockerenv' "$kt_mountinfo" >/dev/null 2>&1; then - echo "NapCat device profile check failed: mountinfo-host-leak:$kt_mountinfo" >&2 - exit 78 - fi - done + if grep -E 'docker|containerd|overlay|\.dockerenv' /proc/1/mountinfo >/dev/null 2>&1; then + echo "NapCat device profile check failed: mountinfo-host-leak:/proc/1/mountinfo" >&2 + exit 78 + fi if grep -q "$(hostname)" /etc/hosts; then echo "NapCat device profile check failed: hosts-still-contains-hostname" >&2 exit 78 diff --git a/ci/napcat-desktop-cn/verify.sh b/ci/napcat-desktop-cn/verify.sh index 8d79b74..a6e2f13 100644 --- a/ci/napcat-desktop-cn/verify.sh +++ b/ci/napcat-desktop-cn/verify.sh @@ -51,6 +51,7 @@ grep -q '/proc/1/cmdline' /app/entrypoint.sh grep -q '/proc/1/sched' /app/entrypoint.sh grep -q '/proc/1/status' /app/entrypoint.sh grep -q '/proc/1/stat' /app/entrypoint.sh +grep -q '/proc/1/mountinfo' /app/entrypoint.sh grep -q '/proc/self/mountinfo' /app/entrypoint.sh grep -q '/sys/class/dmi/id/product_name' /app/entrypoint.sh grep -q '/sys/class/dmi/id/bios_vendor' /app/entrypoint.sh diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index 0928dc7..b4e67c8 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -44,6 +44,7 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).toContain('/proc/1/sched'); expect(verify).toContain('/proc/1/status'); expect(verify).toContain('/proc/1/stat'); + expect(verify).toContain('/proc/1/mountinfo'); expect(verify).toContain('/proc/self/mountinfo'); expect(verify).toContain('/sys/class/dmi/id/product_name'); expect(verify).toContain('/sys/class/dmi/id/bios_vendor'); @@ -73,7 +74,8 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(source).toContain('/proc/uptime'); expect(source).toContain('/proc/cpuinfo'); expect(source).toContain('/sys/devices/virtual/tty/tty0/active'); - expect(source).toContain('/proc/self/mountinfo'); + expect(source).toContain('mountinfo-host-leak:/proc/1/mountinfo'); + expect(source).not.toContain('for kt_mountinfo in /proc/self/mountinfo /proc/1/mountinfo'); expect(source).toContain('mount --bind "$FAKE_CMDLINE" /proc/1/cmdline'); expect(source).toContain('kt_require_device_profile'); expect(source).toContain('exit 78'); From dd53147127dbb13f4df4ffa574c7011de40324e6 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 26 Jun 2026 18:11:49 +0800 Subject: [PATCH 04/25] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E6=8B=9F=E7=9C=9Fhosts=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh index 06be13b..f06cd74 100644 --- a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh +++ b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh @@ -134,7 +134,7 @@ if [ "${NAPCAT_REQUIRE_DEVICE_PROFILE:-0}" = "1" ]; then echo "NapCat device profile check failed: mountinfo-host-leak:/proc/1/mountinfo" >&2 exit 78 fi - if grep -q "$(hostname)" /etc/hosts; then + if [ "$(hostname)" != "localhost" ] && grep -q "$(hostname)" /etc/hosts; then echo "NapCat device profile check failed: hosts-still-contains-hostname" >&2 exit 78 fi From 5c00b08f6b325deb9672c6e1de7c2e28789f0565 Mon Sep 17 00:00:00 2001 From: sunlei Date: Fri, 26 Jun 2026 18:22:29 +0800 Subject: [PATCH 05/25] =?UTF-8?q?fix:=20=E6=94=BE=E5=AE=BDNapCat=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=95=9C=E5=83=8F=E6=96=87=E6=9C=AC=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ci/napcat-desktop-cn/verify.sh | 1 - test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/ci/napcat-desktop-cn/verify.sh b/ci/napcat-desktop-cn/verify.sh index a6e2f13..89b5adf 100644 --- a/ci/napcat-desktop-cn/verify.sh +++ b/ci/napcat-desktop-cn/verify.sh @@ -43,7 +43,6 @@ test "$EXPECTED_MJS_SHA" = "$ACTUAL_MJS_SHA" grep -R -q 'getQQLoginRuntimeState' "$TMP_DIR" grep -R -q 'qrcodeRevision' "$TMP_DIR" grep -R -q 'needsLoginServiceReset' "$TMP_DIR" -grep -R -q '重置已失效登录服务后重新生成二维码' "$TMP_DIR" grep -q 'KT device profile patch defaults' /app/entrypoint.sh grep -q 'NAPCAT_REQUIRE_DEVICE_PROFILE' /app/entrypoint.sh diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index b4e67c8..9465ee4 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -131,7 +131,6 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).toContain('getQQLoginRuntimeState'); expect(verify).toContain('qrcodeRevision'); expect(verify).toContain('needsLoginServiceReset'); - expect(verify).toContain('重置已失效登录服务后重新生成二维码'); expect(verify).not.toContain('selfInfo?.online !== false'); }); From 313ace971dd3c1517aade2b0cad4eac4b93bc589 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 27 Jun 2026 01:11:40 +0800 Subject: [PATCH 06/25] =?UTF-8?q?fix:=20=E5=AF=B9=E9=BD=90NapCat=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E9=BB=98=E8=AE=A4=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ci/napcat-desktop-cn/README.md | 6 +++--- ...am-sync-runtime-release-pipeline-implementation-plan.md | 4 ++-- k8s/prod/api.yaml | 4 ++-- .../application/runtime/napcat-runtime-profile.service.ts | 2 +- test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts | 7 ++++--- test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts | 4 ++-- test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts | 2 +- 7 files changed, 15 insertions(+), 14 deletions(-) diff --git a/ci/napcat-desktop-cn/README.md b/ci/napcat-desktop-cn/README.md index 550dc60..10e75ab 100644 --- a/ci/napcat-desktop-cn/README.md +++ b/ci/napcat-desktop-cn/README.md @@ -43,17 +43,17 @@ Build and verify: ```powershell docker build ` --build-arg NAPCAT_BASE_IMAGE=$napcatBaseImageDigest ` - -t kt-napcat-desktop-cn:desktop-cn-v8 ` + -t kt-napcat-desktop-cn:desktop-cn-v10 ` -f .kt-workspace/napcat-desktop-cn-build/ci/napcat-desktop-cn/Dockerfile ` .kt-workspace/napcat-desktop-cn-build -$name = "kt-napcat-v8-verify-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" +$name = "kt-napcat-v10-verify-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" docker run -d --name $name ` --cap-add SYS_ADMIN ` --security-opt apparmor=unconfined ` --security-opt seccomp=unconfined ` -e NAPCAT_REQUIRE_DEVICE_PROFILE=1 ` - kt-napcat-desktop-cn:desktop-cn-v8 + kt-napcat-desktop-cn:desktop-cn-v10 docker exec $name sh /ci/napcat-desktop-cn/verify.sh docker rm -f $name ``` diff --git a/docs/superpowers/plans/2026-06-24-napcatqq-upstream-sync-runtime-release-pipeline-implementation-plan.md b/docs/superpowers/plans/2026-06-24-napcatqq-upstream-sync-runtime-release-pipeline-implementation-plan.md index 33588ab..6425b0b 100644 --- a/docs/superpowers/plans/2026-06-24-napcatqq-upstream-sync-runtime-release-pipeline-implementation-plan.md +++ b/docs/superpowers/plans/2026-06-24-napcatqq-upstream-sync-runtime-release-pipeline-implementation-plan.md @@ -843,8 +843,8 @@ describe('NapCat runtime promotion contract', () => { it('keeps a safe default runtime profile in the K8s manifest', () => { const manifest = readSource('k8s/prod/api.yaml'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v8'); - expect(manifest).toContain('value: desktop-cn-v8'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v10'); + expect(manifest).toContain('value: desktop-cn-v10'); }); }); ``` diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index fa2000e..33872c1 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -33,9 +33,9 @@ spec: - name: DB_TIMEZONE value: "+08:00" - name: QQBOT_NAPCAT_IMAGE - value: kt-napcat-desktop-cn:desktop-cn-v8 + value: kt-napcat-desktop-cn:desktop-cn-v10 - name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION - value: desktop-cn-v8 + value: desktop-cn-v10 - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index d1c2c9c..032283f 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService { dataDir: input.dataDir, desktopProfileVersion: this.getString( 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', - 'desktop-cn-v8', + 'desktop-cn-v10', ), deviceIdentityId: input.deviceIdentityId, imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index 9465ee4..308e847 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -134,13 +134,14 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).not.toContain('selfInfo?.online !== false'); }); - it('deploys the production API with the verified desktop-cn-v8 runtime profile', () => { + it('deploys the production API with the verified desktop-cn-v10 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-v8'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v10'); expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION'); - expect(manifest).toContain('value: desktop-cn-v8'); + expect(manifest).toContain('value: desktop-cn-v10'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v8'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v7'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v4'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v3'); diff --git a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts index 5bc19b3..bda6cf6 100644 --- a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts +++ b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts @@ -33,7 +33,7 @@ describe('NapCat runtime promotion contract', () => { it('keeps a safe default runtime profile in the K8s manifest', () => { const manifest = readSource('k8s/prod/api.yaml'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v8'); - expect(manifest).toContain('value: desktop-cn-v8'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v10'); + expect(manifest).toContain('value: desktop-cn-v10'); }); }); diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index ebb1752..3ff8a95 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => { }); expect(profile).toMatchObject({ - desktopProfileVersion: 'desktop-cn-v8', + desktopProfileVersion: 'desktop-cn-v10', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', locale: 'zh_CN.UTF-8', runtimeGid: 1101, From bbb4bc1c406561427a8ee926e9b17e4ade17f4d5 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 27 Jun 2026 02:25:44 +0800 Subject: [PATCH 07/25] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E4=BA=8C=E7=BB=B4=E7=A0=81=E8=87=AA=E5=8A=A8=E8=A1=A5?= =?UTF-8?q?=E5=88=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 4 +- README.md | 6 +- .../qqbot/core/contract/qqbot.types.ts | 1 + .../login/qqbot-napcat-login.service.ts | 91 +++++++++++++++++++ .../qqbot-napcat-login.service.spec.ts | 49 ++++++++++ 5 files changed, 146 insertions(+), 5 deletions(-) diff --git a/API.md b/API.md index e379e28..e029bb5 100644 --- a/API.md +++ b/API.md @@ -361,7 +361,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。账号列表里的 `connectStatus` 只表示 OneBot 反向 WS;`napcat.oneBotOnline`、`napcat.containerOnline`、`napcat.webuiOnline`、`napcat.qqLoginStatus`、`napcat.qqLoginMessage` 分别表示 OneBot、容器、WebUI 和 QQ 登录态,`webuiOnline=null` 表示本次使用缓存且未重新探测 WebUI;`qqLoginMessage` 只承载真实 QQ 登录态消息,WebUI 配置缺失或请求异常只放在 `lastError`。 -扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending,会话后台再启动远端 Docker 和生成二维码。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 依次调用 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode` 推进原有弹窗流程。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env;已在线的源容器不补 env。快速登录失败后,如果账号保存了登录密码,后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env,也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 会返回原 `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 +扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending,会话后台再启动远端 Docker 和生成二维码。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 依次调用 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode` 推进原有弹窗流程。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env;已在线的源容器不补 env。快速登录失败后,如果账号保存了登录密码,后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env,也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 会返回原 `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。pending refresh 会话如果没有二维码、验证码或新设备挑战,`/qqbot/account/scan/status` 会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试 `RefreshQRcode/GetQQLoginQrcode`,避免 SSE 长时间卡在“二维码生成中”。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 密码验证码通过后如果 NapCat 返回 `needNewDevice`,后端不会只把 `jumpUrl` 透给 Admin,而是在同一会话中继续调用 `/api/QQLogin/GetNewDeviceQRCode` 生成新设备验证二维码;`/qqbot/account/scan/status` 后续轮询会代理 `/api/QQLogin/PollNewDeviceQR`,状态映射为 `newDeviceStatus=qr-pending|scanned|confirming|verified|expired|failed`,进入确认态后再调用 `/api/QQLogin/NewDeviceLogin` 并回到密码登录完成检查。扫码会话结果新增 `newDeviceQrcode`、`newDeviceStatus`、`deviceVerifyUrl` 字段;`captchaUrl` 和 `newDeviceQrcode` 分别表示腾讯安全验证码和 QQ 新设备验证二维码,前端必须分开展示。SSE 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、二维码兜底、登录成功/失败。 @@ -377,7 +377,7 @@ 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 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,且只有明确二维码过期或扫码确认窗口失效的 QR session failure 才自动换码,避免扫码确认期间被 `ErrType=1/ErrCode=1` 抢刷新打断。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v8` / `desktop-cn-v8` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 +NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v10` / `desktop-cn-v10` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadata:upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。 diff --git a/README.md b/README.md index 4f0582f..bde8276 100644 --- a/README.md +++ b/README.md @@ -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/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v8 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,且只有明确二维码过期或扫码确认窗口失效的 QR session failure 才自动换码,避免扫码确认期间被 `ErrType=1/ErrCode=1` 抢刷新打断。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v8` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v8` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 +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 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v10 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v10` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v10` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 @@ -178,9 +178,9 @@ 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 并单账号观察。`desktop-cn-v8` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置以及扫码确认期间非过期 QR failure 抢刷新打断登录的问题。 +- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v10` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置以及非自动重试 QR failure 后下次 WebUI 登录动作不重置的问题。 - 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 容器为已知 `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 refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 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 profile;Docker 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`。 - NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。 - NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后只写入离线原因并复用 `super` 站内信告警,恢复登录必须由管理员在 Admin 手动触发「更新登录」。 diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts index 5e64479..f79f078 100644 --- a/src/modules/qqbot/core/contract/qqbot.types.ts +++ b/src/modules/qqbot/core/contract/qqbot.types.ts @@ -337,6 +337,7 @@ export type QqbotLoginScanSession = { expectedSelfId?: string; id: string; lastCaptchaLookupAt?: number; + lastQrcodeRefreshAt?: number; lastRestartedAt?: number; mode: QqbotLoginScanMode; newDeviceBytesToken?: string; diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index cfe26ad..b0a0f35 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -439,6 +439,10 @@ export class QqbotNapcatLoginService { return this.refreshNearlyExpiredQrcode(session, container, status); } + if (this.shouldAutoRefreshPendingQrcode(session, status)) { + return this.refreshPendingQrcodeFromStatus(session, container, status); + } + session.errorMessage = status.loginError || undefined; if ( status.qrcodeurl && @@ -2348,6 +2352,82 @@ export class QqbotNapcatLoginService { return ageMs >= this.getNativeQrcodeTtlMs() - this.getQrcodeSafeScanMs(); } + /** + * Decides whether a pending scan status poll should actively ask NapCat for a QR again. + * @param session - Pending scan session; challenge states and recent refresh attempts suppress automatic retries. + * @param status - Latest WebUI status; existing, expired, or successful QR/login states are handled elsewhere. + * @returns True when the status poll should call the same-container RefreshQRcode path once per cooldown window. + */ + private shouldAutoRefreshPendingQrcode( + session: QqbotLoginScanSession, + status: NapcatLoginStatus, + ) { + if (session.preparingContainer || session.preparingRelogin) return false; + if (session.captchaUrl || session.newDeviceStatus) return false; + if (session.qrcode || status.qrcodeurl || status.isLogin) return false; + if (this.toolsService.isNapcatExpiredQrcodeStatus(status)) return false; + + const lastRefreshAt = Number(session.lastQrcodeRefreshAt || 0); + if (!Number.isFinite(lastRefreshAt) || lastRefreshAt <= 0) return true; + return Date.now() - lastRefreshAt >= this.getQrcodeAutoRefreshCooldownMs(); + } + + /** + * Requests a fresh QR during status polling so SSE can recover from a previous accepted-but-not-updated refresh. + * @param session - Pending scan session whose result is returned to Admin/SSE. + * @param container - Current NapCat WebUI runtime; the method never rebuilds or restarts it. + * @param status - Latest WebUI status used as fallback metadata for QR freshness checks. + * @returns Updated scan result, either with a fresh QR or a bounded pending message. + */ + private async refreshPendingQrcodeFromStatus( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + status: NapcatLoginStatus, + ) { + session.lastQrcodeRefreshAt = Date.now(); + this.persistLoginSession(session); + this.publishScanResultEvent( + session, + 'qrcode-fetch', + 'processing', + '正在重新生成登录二维码', + ); + + try { + session.qrcode = await this.refreshOrGetQrcode(container, false, { + fallbackStatus: status, + requireFresh: + session.mode === 'refresh' || + !!session.qrcode || + this.toolsService.isNapcatExpiredQrcodeStatus(status), + staleQrcode: session.qrcode || status.qrcodeurl, + }); + session.errorMessage = undefined; + session.expiresAt = Date.now() + this.getSessionTtlMs(); + this.persistLoginSession(session); + this.publishScanResultEvent( + session, + 'qrcode-ready', + 'success', + '登录二维码已生成', + ); + this.publishScanResultEvent( + session, + 'waiting-scan', + 'processing', + '等待扫码确认', + ); + return this.toResult(session); + } catch (err) { + if (!this.toolsService.isNapcatTemporaryError(err)) throw err; + session.qrcode = undefined; + session.errorMessage = + 'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新'; + this.persistLoginSession(session); + return this.toResult(session); + } + } + /** * Reads the expected native QQ QR lifetime used only for safe-display decisions. * @returns Milliseconds before a QR is considered too old to show without refreshing. @@ -2370,6 +2450,17 @@ export class QqbotNapcatLoginService { ); } + /** + * Reads the cooldown between automatic QR refresh attempts during status polling. + * @returns Milliseconds to wait before SSE/status may request another QR regeneration. + */ + private getQrcodeAutoRefreshCooldownMs() { + return this.getPositiveConfigNumber( + 'NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS', + Math.max(5000, this.getLoginPollIntervalMs() * 2), + ); + } + /** * 查询 NapCat 登录运行态数据。 * @param container - container 输入;限定 NapCat查询范围。 diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index f35d74b..44966fc 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -4052,6 +4052,55 @@ describe('QqbotNapcatLoginService', () => { expect(getQrcode).not.toHaveBeenCalled(); }); + it('auto refreshes a pending refresh session when WebUI has no cached qrcode', async () => { + (service as any).sessions.set('session-auto-refresh-qrcode', { + accountId: 'account-1', + containerId: 'container-auto-refresh-qrcode', + containerName: 'napcat-auto-refresh-qrcode', + createdAt: Date.now(), + errorMessage: 'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新', + expiresAt: Date.now() + 60_000, + expectedSelfId: '10001', + id: 'session-auto-refresh-qrcode', + mode: 'refresh', + status: 'pending', + webuiPort: 6106, + }); + const container = { id: 'container-auto-refresh-qrcode' }; + const loginStatus = { + isLogin: false, + loginError: '网络连接异常!', + }; + jest + .spyOn(service as any, 'getSessionContainer') + .mockResolvedValue(container); + jest + .spyOn(service as any, 'getLoginStatus') + .mockResolvedValue(loginStatus); + const getQrcode = jest + .spyOn(service as any, 'getQrcode') + .mockRejectedValue(new Error('NapCat 未返回登录二维码')); + const refreshOrGetQrcode = jest + .spyOn(service as any, 'refreshOrGetQrcode') + .mockResolvedValue('auto-refresh-qrcode'); + + const result = await service.status('session-auto-refresh-qrcode'); + + expect(result.status).toBe('pending'); + expect(result.qrcode).toBe('auto-refresh-qrcode'); + expect(result.errorMessage).toBeUndefined(); + expect(getQrcode).not.toHaveBeenCalled(); + expect(refreshOrGetQrcode).toHaveBeenCalledWith(container, false, { + fallbackStatus: loginStatus, + requireFresh: true, + staleQrcode: undefined, + }); + expect( + (service as any).sessions.get('session-auto-refresh-qrcode') + .lastQrcodeRefreshAt, + ).toEqual(expect.any(Number)); + }); + it('normalizes login status to offline when login info reports offline', async () => { jest .spyOn(service as any, 'postNapcat') From 603d6d3cbd3ae6bc7fcc4c7b0d8bdbab9f22c07d Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 27 Jun 2026 09:47:15 +0800 Subject: [PATCH 08/25] =?UTF-8?q?fix:=20=E8=A1=A5=E9=BD=90NapCat=20mountin?= =?UTF-8?q?fo=E8=BF=9B=E7=A8=8B=E7=BA=A7=E9=81=AE=E8=94=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- README.md | 4 +- .../entrypoint-device-profile.patch.sh | 62 +++++++++++++++++-- ci/napcat-desktop-cn/verify.sh | 25 ++++++++ k8s/prod/api.yaml | 4 +- .../runtime/napcat-runtime-profile.service.ts | 2 +- .../napcat/napcat-desktop-cn-image.spec.ts | 26 ++++++-- .../napcat/napcat-runtime-promotion.spec.ts | 4 +- .../napcat/runtime-protocol-profile.spec.ts | 2 +- 9 files changed, 113 insertions(+), 18 deletions(-) diff --git a/API.md b/API.md index e029bb5..284adc0 100644 --- a/API.md +++ b/API.md @@ -377,7 +377,7 @@ 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 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v10` / `desktop-cn-v10` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 +NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v12 运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v12` / `desktop-cn-v12` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadata:upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。 diff --git a/README.md b/README.md index bde8276..d02ef4b 100644 --- a/README.md +++ b/README.md @@ -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/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v10 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v10` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v10` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 +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 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v12 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service;v12 还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v12` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v12` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 @@ -178,7 +178,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 并单账号观察。`desktop-cn-v10` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置以及非自动重试 QR failure 后下次 WebUI 登录动作不重置的问题。 +- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v12` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置、非自动重试 QR failure 后下次 WebUI 登录动作不重置,以及 QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径的问题。 - 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 refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 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 profile;Docker 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`。 diff --git a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh index f06cd74..e1ed969 100644 --- a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh +++ b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh @@ -28,6 +28,8 @@ cat >/tmp/kt-device-profile-defaults.sh <<'EOF' : "${NAPCAT_DEVICE_CPU_MODEL:=AMD Ryzen 7 8845H w/ Radeon 780M Graphics}" : "${NAPCAT_DEVICE_UPTIME:=7200.00 14400.00}" : "${NAPCAT_DEVICE_TTY_ACTIVE:=tty1}" +: "${NAPCAT_DEVICE_MOUNTINFO_GUARD_ENABLED:=1}" +: "${NAPCAT_DEVICE_MOUNTINFO_GUARD_INTERVAL:=1}" kt_fake_file() { kt_target="$1" @@ -91,6 +93,55 @@ if [ -f /etc/hosts ]; then } > "$KT_FAKE_HOSTS" mount --bind "$KT_FAKE_HOSTS" /etc/hosts 2>/dev/null || true fi + +# KT device profile mountinfo guard for long-lived QQ/NapCat processes. +KT_MOUNTINFO_HOST_LEAK_PATTERN='overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim' +KT_FAKE_MOUNTINFO="$FAKE_CGROUP_DIR/kt_device_profile_mountinfo" +cat > "$KT_FAKE_MOUNTINFO" <<'KT_MOUNTINFO_EOF' +22 1 259:2 / / rw,relatime - ext4 /dev/nvme0n1p2 rw,errors=remount-ro +23 22 0:6 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw +24 22 0:22 / /dev rw,nosuid,relatime - tmpfs udev rw,size=4096000k,nr_inodes=1024000,mode=755,inode64 +25 24 0:23 / /dev/pts rw,nosuid,noexec,relatime - devpts devpts rw,gid=5,mode=620,ptmxmode=000 +26 22 0:24 / /sys rw,nosuid,nodev,noexec,relatime - sysfs sysfs rw +27 22 0:25 / /run rw,nosuid,nodev,relatime - tmpfs tmpfs rw,mode=755,inode64 +28 22 259:2 /home/napcat/.config /app/.config rw,relatime - ext4 /dev/nvme0n1p2 rw,errors=remount-ro +29 22 259:2 /home/napcat/.cache /app/.cache rw,relatime - ext4 /dev/nvme0n1p2 rw,errors=remount-ro +30 22 259:2 /home/napcat/.local/share /app/.local/share rw,relatime - ext4 /dev/nvme0n1p2 rw,errors=remount-ro +KT_MOUNTINFO_EOF + +kt_mask_mountinfo_target() { + kt_mountinfo="$1" + if [ -f "$kt_mountinfo" ] && grep -E "$KT_MOUNTINFO_HOST_LEAK_PATTERN" "$kt_mountinfo" >/dev/null 2>&1; then + mount --bind "$KT_FAKE_MOUNTINFO" "$kt_mountinfo" 2>/dev/null || true + fi +} + +kt_mountinfo_guard_once() { + for kt_mountinfo_pid_dir in /proc/[0-9]*; do + [ -d "$kt_mountinfo_pid_dir" ] || continue + kt_mountinfo_pid="${kt_mountinfo_pid_dir#/proc/}" + kt_mountinfo_comm="$(cat "$kt_mountinfo_pid_dir/comm" 2>/dev/null || true)" + kt_mountinfo_cmdline="$(tr '\000' ' ' < "$kt_mountinfo_pid_dir/cmdline" 2>/dev/null || true)" + case "$kt_mountinfo_comm $kt_mountinfo_cmdline" in + *qq*|*QQ*|*napcat*|*NapCat*|*Xvfb*) + kt_mask_mountinfo_target "/proc/$kt_mountinfo_pid/mountinfo" + ;; + esac + done +} + +kt_mountinfo_guard_loop() { + while :; do + kt_mountinfo_guard_once + sleep "${NAPCAT_DEVICE_MOUNTINFO_GUARD_INTERVAL:-1}" + done +} + +kt_mask_mountinfo_target /proc/1/mountinfo +kt_mask_mountinfo_target /proc/self/mountinfo +if [ "${NAPCAT_DEVICE_MOUNTINFO_GUARD_ENABLED:-1}" = "1" ]; then + kt_mountinfo_guard_loop >/dev/null 2>&1 & +fi EOF cat >/tmp/kt-device-profile-verify.sh <<'EOF' @@ -130,10 +181,13 @@ if [ "${NAPCAT_REQUIRE_DEVICE_PROFILE:-0}" = "1" ]; then echo "NapCat device profile check failed: tty0-active-missing" >&2 exit 78 fi - if grep -E 'docker|containerd|overlay|\.dockerenv' /proc/1/mountinfo >/dev/null 2>&1; then - echo "NapCat device profile check failed: mountinfo-host-leak:/proc/1/mountinfo" >&2 - exit 78 - fi + KT_MOUNTINFO_HOST_LEAK_PATTERN='overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim' + for kt_mountinfo in /proc/1/mountinfo; do + if grep -E "$KT_MOUNTINFO_HOST_LEAK_PATTERN" "$kt_mountinfo" >/dev/null 2>&1; then + echo "NapCat device profile check failed: mountinfo-host-leak:$kt_mountinfo" >&2 + exit 78 + fi + done if [ "$(hostname)" != "localhost" ] && grep -q "$(hostname)" /etc/hosts; then echo "NapCat device profile check failed: hosts-still-contains-hostname" >&2 exit 78 diff --git a/ci/napcat-desktop-cn/verify.sh b/ci/napcat-desktop-cn/verify.sh index 89b5adf..1f30cee 100644 --- a/ci/napcat-desktop-cn/verify.sh +++ b/ci/napcat-desktop-cn/verify.sh @@ -52,7 +52,32 @@ grep -q '/proc/1/status' /app/entrypoint.sh grep -q '/proc/1/stat' /app/entrypoint.sh grep -q '/proc/1/mountinfo' /app/entrypoint.sh grep -q '/proc/self/mountinfo' /app/entrypoint.sh +grep -q '/proc/$kt_mountinfo_pid/mountinfo' /app/entrypoint.sh +grep -q 'kt_mountinfo_guard_loop' /app/entrypoint.sh +grep -q 'overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim' /app/entrypoint.sh grep -q '/sys/class/dmi/id/product_name' /app/entrypoint.sh grep -q '/sys/class/dmi/id/bios_vendor' /app/entrypoint.sh grep -q '/sys/class/dmi/id/bios_version' /app/entrypoint.sh grep -q '/etc/hosts' /app/entrypoint.sh + +MOUNTINFO_HOST_LEAK_PATTERN='overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim' +assert_no_mountinfo_host_leak() { + kt_mountinfo="$1" + if [ -r "$kt_mountinfo" ] && grep -E "$MOUNTINFO_HOST_LEAK_PATTERN" "$kt_mountinfo" >/dev/null 2>&1; then + echo "NapCat desktop-cn verify failed: mountinfo-host-leak:$kt_mountinfo" >&2 + exit 78 + fi +} + +assert_no_mountinfo_host_leak /proc/1/mountinfo +for kt_mountinfo_pid_dir in /proc/[0-9]*; do + [ -d "$kt_mountinfo_pid_dir" ] || continue + kt_mountinfo_pid="${kt_mountinfo_pid_dir#/proc/}" + kt_mountinfo_comm="$(cat "$kt_mountinfo_pid_dir/comm" 2>/dev/null || true)" + kt_mountinfo_cmdline="$(tr '\000' ' ' < "$kt_mountinfo_pid_dir/cmdline" 2>/dev/null || true)" + case "$kt_mountinfo_comm $kt_mountinfo_cmdline" in + *qq*|*QQ*|*napcat*|*NapCat*|*Xvfb*) + assert_no_mountinfo_host_leak "/proc/$kt_mountinfo_pid/mountinfo" + ;; + esac +done diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index 33872c1..ae8599f 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -33,9 +33,9 @@ spec: - name: DB_TIMEZONE value: "+08:00" - name: QQBOT_NAPCAT_IMAGE - value: kt-napcat-desktop-cn:desktop-cn-v10 + value: kt-napcat-desktop-cn:desktop-cn-v12 - name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION - value: desktop-cn-v10 + value: desktop-cn-v12 - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index 032283f..9d3b277 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService { dataDir: input.dataDir, desktopProfileVersion: this.getString( 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', - 'desktop-cn-v10', + 'desktop-cn-v12', ), deviceIdentityId: input.deviceIdentityId, imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index 308e847..44237aa 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -46,6 +46,11 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).toContain('/proc/1/stat'); expect(verify).toContain('/proc/1/mountinfo'); expect(verify).toContain('/proc/self/mountinfo'); + expect(verify).toContain('/proc/$kt_mountinfo_pid/mountinfo'); + expect(verify).toContain('/proc/[0-9]*'); + expect(verify).toContain( + 'overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim', + ); expect(verify).toContain('/sys/class/dmi/id/product_name'); expect(verify).toContain('/sys/class/dmi/id/bios_vendor'); expect(verify).toContain('/sys/class/dmi/id/bios_version'); @@ -74,8 +79,19 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(source).toContain('/proc/uptime'); expect(source).toContain('/proc/cpuinfo'); expect(source).toContain('/sys/devices/virtual/tty/tty0/active'); - expect(source).toContain('mountinfo-host-leak:/proc/1/mountinfo'); - expect(source).not.toContain('for kt_mountinfo in /proc/self/mountinfo /proc/1/mountinfo'); + expect(source).toContain('kt_mountinfo_guard_loop'); + expect(source).toContain('KT_FAKE_MOUNTINFO'); + expect(source).toContain('/proc/$kt_mountinfo_pid/mountinfo'); + expect(source).toContain( + 'mount --bind "$KT_FAKE_MOUNTINFO" "$kt_mountinfo"', + ); + expect(source).toContain('mountinfo-host-leak:$kt_mountinfo'); + expect(source).toContain( + 'overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim', + ); + expect(source).not.toContain( + 'for mfile in /proc/self/mountinfo /proc/1/mountinfo', + ); expect(source).toContain('mount --bind "$FAKE_CMDLINE" /proc/1/cmdline'); expect(source).toContain('kt_require_device_profile'); expect(source).toContain('exit 78'); @@ -134,13 +150,13 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).not.toContain('selfInfo?.online !== false'); }); - it('deploys the production API with the verified desktop-cn-v10 runtime profile', () => { + it('deploys the production API with the verified desktop-cn-v12 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-v10'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v12'); expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION'); - expect(manifest).toContain('value: desktop-cn-v10'); + expect(manifest).toContain('value: desktop-cn-v12'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v8'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v7'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v4'); diff --git a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts index bda6cf6..a29b29a 100644 --- a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts +++ b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts @@ -33,7 +33,7 @@ describe('NapCat runtime promotion contract', () => { it('keeps a safe default runtime profile in the K8s manifest', () => { const manifest = readSource('k8s/prod/api.yaml'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v10'); - expect(manifest).toContain('value: desktop-cn-v10'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v12'); + expect(manifest).toContain('value: desktop-cn-v12'); }); }); diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index 3ff8a95..f705e6f 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => { }); expect(profile).toMatchObject({ - desktopProfileVersion: 'desktop-cn-v10', + desktopProfileVersion: 'desktop-cn-v12', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', locale: 'zh_CN.UTF-8', runtimeGid: 1101, From 0e249609f30ea7951bf04dd3bec18f957900a745 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 27 Jun 2026 10:04:38 +0800 Subject: [PATCH 09/25] =?UTF-8?q?fix:=20=E6=94=B6=E6=95=9BNapCat=20mountin?= =?UTF-8?q?fo=E7=9B=AE=E6=A0=87=E8=BF=9B=E7=A8=8B=E5=8C=B9=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- README.md | 4 +-- .../entrypoint-device-profile.patch.sh | 32 +++++++++++++++--- ci/napcat-desktop-cn/verify.sh | 33 ++++++++++++++++--- k8s/prod/api.yaml | 4 +-- .../runtime/napcat-runtime-profile.service.ts | 2 +- .../napcat/napcat-desktop-cn-image.spec.ts | 10 ++++-- .../napcat/napcat-runtime-promotion.spec.ts | 4 +-- .../napcat/runtime-protocol-profile.spec.ts | 2 +- 9 files changed, 71 insertions(+), 22 deletions(-) diff --git a/API.md b/API.md index 284adc0..7c5c160 100644 --- a/API.md +++ b/API.md @@ -377,7 +377,7 @@ 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 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v12 运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v12` / `desktop-cn-v12` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 +NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v13 运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v13` / `desktop-cn-v13` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadata:upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。 diff --git a/README.md b/README.md index d02ef4b..92846ab 100644 --- a/README.md +++ b/README.md @@ -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/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v12 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service;v12 还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v12` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v12` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 +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 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v13 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service;v13 还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v13` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v13` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 @@ -178,7 +178,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 并单账号观察。`desktop-cn-v12` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置、非自动重试 QR failure 后下次 WebUI 登录动作不重置,以及 QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径的问题。 +- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v13` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置、非自动重试 QR failure 后下次 WebUI 登录动作不重置,以及 QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径的问题。 - 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 refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 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 profile;Docker 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`。 diff --git a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh index e1ed969..96c2f13 100644 --- a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh +++ b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh @@ -116,17 +116,39 @@ kt_mask_mountinfo_target() { fi } +kt_is_mountinfo_target() { + kt_target_comm="$1" + kt_target_cmdline="$2" + kt_target_argv0="${kt_target_cmdline%% *}" + kt_target_argv0_base="${kt_target_argv0##*/}" + + case "$kt_target_comm" in + qq|QQ|NapCat|napcat|Xvfb) + return 0 + ;; + esac + case "$kt_target_argv0_base" in + qq|QQ|NapCat|napcat|Xvfb) + return 0 + ;; + esac + case "$kt_target_cmdline" in + /opt/QQ/*|/app/napcat/*|/app/NapCat*|*/NapCat.Shell*|*/napcat.mjs*) + return 0 + ;; + esac + return 1 +} + kt_mountinfo_guard_once() { for kt_mountinfo_pid_dir in /proc/[0-9]*; do [ -d "$kt_mountinfo_pid_dir" ] || continue kt_mountinfo_pid="${kt_mountinfo_pid_dir#/proc/}" kt_mountinfo_comm="$(cat "$kt_mountinfo_pid_dir/comm" 2>/dev/null || true)" kt_mountinfo_cmdline="$(tr '\000' ' ' < "$kt_mountinfo_pid_dir/cmdline" 2>/dev/null || true)" - case "$kt_mountinfo_comm $kt_mountinfo_cmdline" in - *qq*|*QQ*|*napcat*|*NapCat*|*Xvfb*) - kt_mask_mountinfo_target "/proc/$kt_mountinfo_pid/mountinfo" - ;; - esac + if kt_is_mountinfo_target "$kt_mountinfo_comm" "$kt_mountinfo_cmdline"; then + kt_mask_mountinfo_target "/proc/$kt_mountinfo_pid/mountinfo" + fi done } diff --git a/ci/napcat-desktop-cn/verify.sh b/ci/napcat-desktop-cn/verify.sh index 1f30cee..93d4155 100644 --- a/ci/napcat-desktop-cn/verify.sh +++ b/ci/napcat-desktop-cn/verify.sh @@ -53,6 +53,7 @@ grep -q '/proc/1/stat' /app/entrypoint.sh grep -q '/proc/1/mountinfo' /app/entrypoint.sh grep -q '/proc/self/mountinfo' /app/entrypoint.sh grep -q '/proc/$kt_mountinfo_pid/mountinfo' /app/entrypoint.sh +grep -q 'kt_is_mountinfo_target' /app/entrypoint.sh grep -q 'kt_mountinfo_guard_loop' /app/entrypoint.sh grep -q 'overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim' /app/entrypoint.sh grep -q '/sys/class/dmi/id/product_name' /app/entrypoint.sh @@ -69,15 +70,37 @@ assert_no_mountinfo_host_leak() { fi } +kt_is_mountinfo_target() { + kt_target_comm="$1" + kt_target_cmdline="$2" + kt_target_argv0="${kt_target_cmdline%% *}" + kt_target_argv0_base="${kt_target_argv0##*/}" + + case "$kt_target_comm" in + qq|QQ|NapCat|napcat|Xvfb) + return 0 + ;; + esac + case "$kt_target_argv0_base" in + qq|QQ|NapCat|napcat|Xvfb) + return 0 + ;; + esac + case "$kt_target_cmdline" in + /opt/QQ/*|/app/napcat/*|/app/NapCat*|*/NapCat.Shell*|*/napcat.mjs*) + return 0 + ;; + esac + return 1 +} + assert_no_mountinfo_host_leak /proc/1/mountinfo for kt_mountinfo_pid_dir in /proc/[0-9]*; do [ -d "$kt_mountinfo_pid_dir" ] || continue kt_mountinfo_pid="${kt_mountinfo_pid_dir#/proc/}" kt_mountinfo_comm="$(cat "$kt_mountinfo_pid_dir/comm" 2>/dev/null || true)" kt_mountinfo_cmdline="$(tr '\000' ' ' < "$kt_mountinfo_pid_dir/cmdline" 2>/dev/null || true)" - case "$kt_mountinfo_comm $kt_mountinfo_cmdline" in - *qq*|*QQ*|*napcat*|*NapCat*|*Xvfb*) - assert_no_mountinfo_host_leak "/proc/$kt_mountinfo_pid/mountinfo" - ;; - esac + if kt_is_mountinfo_target "$kt_mountinfo_comm" "$kt_mountinfo_cmdline"; then + assert_no_mountinfo_host_leak "/proc/$kt_mountinfo_pid/mountinfo" + fi done diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index ae8599f..2558862 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -33,9 +33,9 @@ spec: - name: DB_TIMEZONE value: "+08:00" - name: QQBOT_NAPCAT_IMAGE - value: kt-napcat-desktop-cn:desktop-cn-v12 + value: kt-napcat-desktop-cn:desktop-cn-v13 - name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION - value: desktop-cn-v12 + value: desktop-cn-v13 - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index 9d3b277..2eb75c5 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService { dataDir: input.dataDir, desktopProfileVersion: this.getString( 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', - 'desktop-cn-v12', + 'desktop-cn-v13', ), deviceIdentityId: input.deviceIdentityId, imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index 44237aa..fa273e0 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -48,9 +48,11 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).toContain('/proc/self/mountinfo'); expect(verify).toContain('/proc/$kt_mountinfo_pid/mountinfo'); expect(verify).toContain('/proc/[0-9]*'); + expect(verify).toContain('kt_is_mountinfo_target'); expect(verify).toContain( 'overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim', ); + expect(verify).not.toContain('*qq*|*QQ*|*napcat*|*NapCat*|*Xvfb*'); expect(verify).toContain('/sys/class/dmi/id/product_name'); expect(verify).toContain('/sys/class/dmi/id/bios_vendor'); expect(verify).toContain('/sys/class/dmi/id/bios_version'); @@ -80,6 +82,7 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(source).toContain('/proc/cpuinfo'); expect(source).toContain('/sys/devices/virtual/tty/tty0/active'); expect(source).toContain('kt_mountinfo_guard_loop'); + expect(source).toContain('kt_is_mountinfo_target'); expect(source).toContain('KT_FAKE_MOUNTINFO'); expect(source).toContain('/proc/$kt_mountinfo_pid/mountinfo'); expect(source).toContain( @@ -92,6 +95,7 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(source).not.toContain( 'for mfile in /proc/self/mountinfo /proc/1/mountinfo', ); + expect(source).not.toContain('*qq*|*QQ*|*napcat*|*NapCat*|*Xvfb*'); expect(source).toContain('mount --bind "$FAKE_CMDLINE" /proc/1/cmdline'); expect(source).toContain('kt_require_device_profile'); expect(source).toContain('exit 78'); @@ -150,13 +154,13 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).not.toContain('selfInfo?.online !== false'); }); - it('deploys the production API with the verified desktop-cn-v12 runtime profile', () => { + it('deploys the production API with the verified desktop-cn-v13 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-v12'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v13'); expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION'); - expect(manifest).toContain('value: desktop-cn-v12'); + expect(manifest).toContain('value: desktop-cn-v13'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v8'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v7'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v4'); diff --git a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts index a29b29a..f9fb8a8 100644 --- a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts +++ b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts @@ -33,7 +33,7 @@ describe('NapCat runtime promotion contract', () => { it('keeps a safe default runtime profile in the K8s manifest', () => { const manifest = readSource('k8s/prod/api.yaml'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v12'); - expect(manifest).toContain('value: desktop-cn-v12'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v13'); + expect(manifest).toContain('value: desktop-cn-v13'); }); }); diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index f705e6f..c5b14ff 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => { }); expect(profile).toMatchObject({ - desktopProfileVersion: 'desktop-cn-v12', + desktopProfileVersion: 'desktop-cn-v13', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', locale: 'zh_CN.UTF-8', runtimeGid: 1101, From bb2d799efb0d8abc14a50f0c3b25f46696b56f28 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 27 Jun 2026 10:16:21 +0800 Subject: [PATCH 10/25] =?UTF-8?q?fix:=20=E9=94=9A=E5=AE=9ANapCat=20mountin?= =?UTF-8?q?fo=E7=9B=AE=E6=A0=87=E8=BF=9B=E7=A8=8B=E5=88=A4=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- README.md | 4 ++-- .../entrypoint-device-profile.patch.sh | 14 ++++++++++++-- ci/napcat-desktop-cn/verify.sh | 14 ++++++++++++-- k8s/prod/api.yaml | 4 ++-- .../runtime/napcat-runtime-profile.service.ts | 2 +- .../qqbot/napcat/napcat-desktop-cn-image.spec.ts | 10 +++++++--- .../qqbot/napcat/napcat-runtime-promotion.spec.ts | 4 ++-- .../qqbot/napcat/runtime-protocol-profile.spec.ts | 2 +- 9 files changed, 40 insertions(+), 16 deletions(-) diff --git a/API.md b/API.md index 7c5c160..499f582 100644 --- a/API.md +++ b/API.md @@ -377,7 +377,7 @@ 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 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v13 运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v13` / `desktop-cn-v13` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 +NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v14 运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v14` / `desktop-cn-v14` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadata:upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。 diff --git a/README.md b/README.md index 92846ab..9af1d42 100644 --- a/README.md +++ b/README.md @@ -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/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v13 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service;v13 还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v13` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v13` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 +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 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v14 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service;v14 还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v14` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v14` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 @@ -178,7 +178,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 并单账号观察。`desktop-cn-v13` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置、非自动重试 QR failure 后下次 WebUI 登录动作不重置,以及 QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径的问题。 +- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v14` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置、非自动重试 QR failure 后下次 WebUI 登录动作不重置,以及 QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径的问题。 - 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 refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 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 profile;Docker 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`。 diff --git a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh index 96c2f13..fb7b4e3 100644 --- a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh +++ b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh @@ -120,6 +120,11 @@ kt_is_mountinfo_target() { kt_target_comm="$1" kt_target_cmdline="$2" kt_target_argv0="${kt_target_cmdline%% *}" + kt_target_rest="${kt_target_cmdline#* }" + if [ "$kt_target_rest" = "$kt_target_cmdline" ]; then + kt_target_rest="" + fi + kt_target_argv1="${kt_target_rest%% *}" kt_target_argv0_base="${kt_target_argv0##*/}" case "$kt_target_comm" in @@ -132,8 +137,13 @@ kt_is_mountinfo_target() { return 0 ;; esac - case "$kt_target_cmdline" in - /opt/QQ/*|/app/napcat/*|/app/NapCat*|*/NapCat.Shell*|*/napcat.mjs*) + case "$kt_target_argv0" in + /opt/QQ/*|/app/napcat/*|/app/NapCat*) + return 0 + ;; + esac + case "$kt_target_argv1" in + /app/napcat/*|/app/NapCat*|*/NapCat.Shell*|*/napcat.mjs*) return 0 ;; esac diff --git a/ci/napcat-desktop-cn/verify.sh b/ci/napcat-desktop-cn/verify.sh index 93d4155..66eaf88 100644 --- a/ci/napcat-desktop-cn/verify.sh +++ b/ci/napcat-desktop-cn/verify.sh @@ -74,6 +74,11 @@ kt_is_mountinfo_target() { kt_target_comm="$1" kt_target_cmdline="$2" kt_target_argv0="${kt_target_cmdline%% *}" + kt_target_rest="${kt_target_cmdline#* }" + if [ "$kt_target_rest" = "$kt_target_cmdline" ]; then + kt_target_rest="" + fi + kt_target_argv1="${kt_target_rest%% *}" kt_target_argv0_base="${kt_target_argv0##*/}" case "$kt_target_comm" in @@ -86,8 +91,13 @@ kt_is_mountinfo_target() { return 0 ;; esac - case "$kt_target_cmdline" in - /opt/QQ/*|/app/napcat/*|/app/NapCat*|*/NapCat.Shell*|*/napcat.mjs*) + case "$kt_target_argv0" in + /opt/QQ/*|/app/napcat/*|/app/NapCat*) + return 0 + ;; + esac + case "$kt_target_argv1" in + /app/napcat/*|/app/NapCat*|*/NapCat.Shell*|*/napcat.mjs*) return 0 ;; esac diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index 2558862..cff086d 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -33,9 +33,9 @@ spec: - name: DB_TIMEZONE value: "+08:00" - name: QQBOT_NAPCAT_IMAGE - value: kt-napcat-desktop-cn:desktop-cn-v13 + value: kt-napcat-desktop-cn:desktop-cn-v14 - name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION - value: desktop-cn-v13 + value: desktop-cn-v14 - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index 2eb75c5..3d4edce 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService { dataDir: input.dataDir, desktopProfileVersion: this.getString( 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', - 'desktop-cn-v13', + 'desktop-cn-v14', ), deviceIdentityId: input.deviceIdentityId, imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index fa273e0..f64f320 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -49,10 +49,12 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).toContain('/proc/$kt_mountinfo_pid/mountinfo'); expect(verify).toContain('/proc/[0-9]*'); expect(verify).toContain('kt_is_mountinfo_target'); + expect(verify).toContain('kt_target_argv1'); expect(verify).toContain( 'overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim', ); expect(verify).not.toContain('*qq*|*QQ*|*napcat*|*NapCat*|*Xvfb*'); + expect(verify).not.toContain('case "$kt_target_cmdline" in'); expect(verify).toContain('/sys/class/dmi/id/product_name'); expect(verify).toContain('/sys/class/dmi/id/bios_vendor'); expect(verify).toContain('/sys/class/dmi/id/bios_version'); @@ -83,6 +85,7 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(source).toContain('/sys/devices/virtual/tty/tty0/active'); expect(source).toContain('kt_mountinfo_guard_loop'); expect(source).toContain('kt_is_mountinfo_target'); + expect(source).toContain('kt_target_argv1'); expect(source).toContain('KT_FAKE_MOUNTINFO'); expect(source).toContain('/proc/$kt_mountinfo_pid/mountinfo'); expect(source).toContain( @@ -96,6 +99,7 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { 'for mfile in /proc/self/mountinfo /proc/1/mountinfo', ); expect(source).not.toContain('*qq*|*QQ*|*napcat*|*NapCat*|*Xvfb*'); + expect(source).not.toContain('case "$kt_target_cmdline" in'); expect(source).toContain('mount --bind "$FAKE_CMDLINE" /proc/1/cmdline'); expect(source).toContain('kt_require_device_profile'); expect(source).toContain('exit 78'); @@ -154,13 +158,13 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).not.toContain('selfInfo?.online !== false'); }); - it('deploys the production API with the verified desktop-cn-v13 runtime profile', () => { + it('deploys the production API with the verified desktop-cn-v14 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-v13'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v14'); expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION'); - expect(manifest).toContain('value: desktop-cn-v13'); + expect(manifest).toContain('value: desktop-cn-v14'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v8'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v7'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v4'); diff --git a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts index f9fb8a8..6b447f2 100644 --- a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts +++ b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts @@ -33,7 +33,7 @@ describe('NapCat runtime promotion contract', () => { it('keeps a safe default runtime profile in the K8s manifest', () => { const manifest = readSource('k8s/prod/api.yaml'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v13'); - expect(manifest).toContain('value: desktop-cn-v13'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v14'); + expect(manifest).toContain('value: desktop-cn-v14'); }); }); diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index c5b14ff..37c0ac1 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => { }); expect(profile).toMatchObject({ - desktopProfileVersion: 'desktop-cn-v13', + desktopProfileVersion: 'desktop-cn-v14', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', locale: 'zh_CN.UTF-8', runtimeGid: 1101, From 28506e64a058b2f48cca58f8304eaf486a643e72 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 27 Jun 2026 12:15:37 +0800 Subject: [PATCH 11/25] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E6=89=AB?= =?UTF-8?q?=E7=A0=81=E7=99=BB=E5=BD=95QQ=E5=8F=B7=E5=9B=9E=E8=AF=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 4 +- README.md | 8 +- k8s/prod/api.yaml | 4 +- .../qqbot/core/contract/qqbot.types.ts | 1 + .../login/qqbot-napcat-login.service.ts | 49 +++++- .../runtime/napcat-runtime-profile.service.ts | 2 +- .../napcat/napcat-desktop-cn-image.spec.ts | 6 +- .../napcat/napcat-runtime-promotion.spec.ts | 4 +- .../napcat/runtime-protocol-profile.spec.ts | 2 +- .../qqbot-napcat-login.service.spec.ts | 166 ++++++++++++++++++ 10 files changed, 230 insertions(+), 16 deletions(-) diff --git a/API.md b/API.md index 499f582..1a1fa2a 100644 --- a/API.md +++ b/API.md @@ -361,7 +361,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。账号列表里的 `connectStatus` 只表示 OneBot 反向 WS;`napcat.oneBotOnline`、`napcat.containerOnline`、`napcat.webuiOnline`、`napcat.qqLoginStatus`、`napcat.qqLoginMessage` 分别表示 OneBot、容器、WebUI 和 QQ 登录态,`webuiOnline=null` 表示本次使用缓存且未重新探测 WebUI;`qqLoginMessage` 只承载真实 QQ 登录态消息,WebUI 配置缺失或请求异常只放在 `lastError`。 -扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending,会话后台再启动远端 Docker 和生成二维码。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 依次调用 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode` 推进原有弹窗流程。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env;已在线的源容器不补 env。快速登录失败后,如果账号保存了登录密码,后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env,也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 会返回原 `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。pending refresh 会话如果没有二维码、验证码或新设备挑战,`/qqbot/account/scan/status` 会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试 `RefreshQRcode/GetQQLoginQrcode`,避免 SSE 长时间卡在“二维码生成中”。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 +扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending,会话后台再启动远端 Docker 和生成二维码。`CheckLoginStatus.isLogin=true` 只表示 NapCat 登录阳性,新增账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才允许创建和绑定真实 QQ 号;短暂缺号时 `/qqbot/account/scan/status` 保持同一会话 pending 并显示正在读取 QQ 号,等待 `NAPCAT_LOGIN_SELF_ID_WAIT_MS`,不得重建容器、补 env 或从容器元数据猜号。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 依次调用 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode` 推进原有弹窗流程。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env;已在线的源容器不补 env。快速登录失败后,如果账号保存了登录密码,后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env,也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 会返回原 `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。pending refresh 会话如果没有二维码、验证码或新设备挑战,`/qqbot/account/scan/status` 会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试 `RefreshQRcode/GetQQLoginQrcode`,避免 SSE 长时间卡在“二维码生成中”。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 密码验证码通过后如果 NapCat 返回 `needNewDevice`,后端不会只把 `jumpUrl` 透给 Admin,而是在同一会话中继续调用 `/api/QQLogin/GetNewDeviceQRCode` 生成新设备验证二维码;`/qqbot/account/scan/status` 后续轮询会代理 `/api/QQLogin/PollNewDeviceQR`,状态映射为 `newDeviceStatus=qr-pending|scanned|confirming|verified|expired|failed`,进入确认态后再调用 `/api/QQLogin/NewDeviceLogin` 并回到密码登录完成检查。扫码会话结果新增 `newDeviceQrcode`、`newDeviceStatus`、`deviceVerifyUrl` 字段;`captchaUrl` 和 `newDeviceQrcode` 分别表示腾讯安全验证码和 QQ 新设备验证二维码,前端必须分开展示。SSE 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、二维码兜底、登录成功/失败。 @@ -377,7 +377,7 @@ 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 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v14 运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v14` / `desktop-cn-v14` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 +NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v14 起运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径;v15 在扫码登录成功回调中先写入 `QQLoginInfo` 再写登录态,避免 API 读到 `isLogin=true` 但 QQ 号为空的短暂不一致。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v15` / `desktop-cn-v15` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadata:upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。 diff --git a/README.md b/README.md index 9af1d42..593794d 100644 --- a/README.md +++ b/README.md @@ -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/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v14 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service;v14 还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v14` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v14` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 +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 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v15 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 还修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v15` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v15` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 @@ -171,16 +171,16 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - WordPress 自动登录失败不会阻断 Admin 主登录,会通过菜单和权限码过滤不可用的 Blog 管理入口。 - 系统日志由 pino 输出,Loki 查询统一通过后端 `/system/logs/*` 代理,前端不直连 Loki。 - 日志级站内信只承接运行期事件:接口 5xx、QQBot 下线 notice、NapCat 容器最新离线日志会自动聚合通知 `super` 角色;服务端强制 `super` 访问,Admin 不再暴露人工新增/编辑入口;长路径接口错误会压缩 `dedupeKey/title` 到表字段长度内,避免通知入库失败。 -- QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应;新增账号扫码会先返回 pending `sessionId`,后台再创建 NapCat 容器并生成二维码。 +- QQBot 扫码登录通过 SSE `/qqbot/account/scan/events` 暴露进度,耗时链路不应阻塞普通 HTTP 响应;新增账号扫码会先返回 pending `sessionId`,后台再创建 NapCat 容器并生成二维码。`CheckLoginStatus.isLogin=true` 只代表 NapCat 登录阳性,创建账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才绑定真实 QQ 号;短暂缺号时会话保持 pending 并显示正在读取 QQ 号,不能重建容器或猜号。 - QQBot 外发统一走发送排队:默认全局间隔 `2500ms`、同会话间隔 `8000ms`、排队抖动 `0-800ms`,超过 `QQBOT_SEND_MAX_QUEUE_WAIT_MS` 时拒绝本次发送,避免高频自动回复形成突发流量。 - QQBot 在线命令和自动回复规则都有运行时保底冷却:默认命令 `5000ms`、规则 `30000ms`;即使数据库里旧数据冷却值更低,也按保底值判定,降低频繁触发风控的概率。 - QQBot 复读机默认阈值为 4,同一会话默认 10 分钟只复读一次,默认只复读 120 字以内普通文本,避免群聊重复内容导致机器人过于频繁地模拟真人发言。 - 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 并单账号观察。`desktop-cn-v14` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置、非自动重试 QR failure 后下次 WebUI 登录动作不重置,以及 QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径的问题。 +- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v15` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置、非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径,以及扫码登录成功后 API 立即读不到 QQ 号的问题。 - 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 refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 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 容器为已知 `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 refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 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 profile;Docker 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`。 - NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。 - NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后只写入离线原因并复用 `super` 站内信告警,恢复登录必须由管理员在 Admin 手动触发「更新登录」。 diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index cff086d..5640c7f 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -33,9 +33,9 @@ spec: - name: DB_TIMEZONE value: "+08:00" - name: QQBOT_NAPCAT_IMAGE - value: kt-napcat-desktop-cn:desktop-cn-v14 + value: kt-napcat-desktop-cn:desktop-cn-v15 - name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION - value: desktop-cn-v14 + value: desktop-cn-v15 - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts index f79f078..7b66ca8 100644 --- a/src/modules/qqbot/core/contract/qqbot.types.ts +++ b/src/modules/qqbot/core/contract/qqbot.types.ts @@ -337,6 +337,7 @@ export type QqbotLoginScanSession = { expectedSelfId?: string; id: string; lastCaptchaLookupAt?: number; + loginSelfIdMissingSince?: number; lastQrcodeRefreshAt?: number; lastRestartedAt?: number; mode: QqbotLoginScanMode; diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index b0a0f35..ce173d4 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -863,8 +863,9 @@ export class QqbotNapcatLoginService { const selfId = this.toolsService.pickNapcatSelfId(loginInfo); if (!selfId) { - return this.failSession(session, 'NapCat 已登录但未返回 QQ 号'); + return this.keepLoginSelfIdPending(session); } + session.loginSelfIdMissingSince = undefined; if (session.expectedSelfId && session.expectedSelfId !== selfId) { return this.failSession( session, @@ -2078,6 +2079,39 @@ export class QqbotNapcatLoginService { return this.toResult(session); } + /** + * Keeps a login-positive session alive while NapCat finishes exposing the logged-in QQ number. + * @param session - Scan session whose WebUI status is already logged in but whose `GetQQLoginInfo` payload lacks `uin`/`selfId`. + * @returns Pending result during the bounded wait window, or a terminal failure once the missing-self-id window is exhausted. + */ + private async keepLoginSelfIdPending(session: QqbotLoginScanSession) { + const now = Date.now(); + session.loginSelfIdMissingSince ??= now; + if (now - session.loginSelfIdMissingSince > this.getLoginSelfIdWaitMs()) { + return this.failSession(session, 'NapCat 已登录但未返回 QQ 号'); + } + + const message = 'NapCat 已登录,正在读取 QQ 号'; + const shouldPublish = session.errorMessage !== message; + session.status = 'pending'; + session.captchaUrl = undefined; + session.errorMessage = message; + session.preparingContainer = false; + session.preparingRelogin = false; + session.qrcode = undefined; + this.renewSessionExpiry(session); + this.persistLoginSession(session); + if (shouldPublish) { + this.publishScanResultEvent( + session, + 'login-self-id-wait', + 'processing', + message, + ); + } + return this.toResult(session); + } + /** * Stops delayed background login work from mutating a pending session after it expired or was replaced. * @param session - Pending scan session captured by an async relogin task; its cache identity and TTL decide whether writes are still valid. @@ -2109,6 +2143,8 @@ export class QqbotNapcatLoginService { session.errorMessage = errorMessage; session.passwordMd5 = undefined; session.preparingRelogin = false; + this.persistLoginSession(session); + await this.loginSessionStore.flushSessionWrites(session.id); this.publishScanResultEvent(session, 'login-error', 'error', errorMessage); this.loginSessionStore.delete(session.id); await this.closeSession(session); @@ -2461,6 +2497,17 @@ export class QqbotNapcatLoginService { ); } + /** + * Reads the bounded wait window for NapCat to expose QQ number after WebUI already reports login-positive. + * @returns Milliseconds allowed for `GetQQLoginInfo` to start returning `uin`/`selfId` before treating the state as inconsistent. + */ + private getLoginSelfIdWaitMs() { + return this.getPositiveConfigNumber( + 'NAPCAT_LOGIN_SELF_ID_WAIT_MS', + 30_000, + ); + } + /** * 查询 NapCat 登录运行态数据。 * @param container - container 输入;限定 NapCat查询范围。 diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index 3d4edce..892c249 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService { dataDir: input.dataDir, desktopProfileVersion: this.getString( 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', - 'desktop-cn-v14', + 'desktop-cn-v15', ), deviceIdentityId: input.deviceIdentityId, imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index f64f320..f17007c 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -158,13 +158,13 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).not.toContain('selfInfo?.online !== false'); }); - it('deploys the production API with the verified desktop-cn-v14 runtime profile', () => { + it('deploys the production API with the verified desktop-cn-v15 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-v14'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v15'); expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION'); - expect(manifest).toContain('value: desktop-cn-v14'); + expect(manifest).toContain('value: desktop-cn-v15'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v8'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v7'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v4'); diff --git a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts index 6b447f2..5aa7764 100644 --- a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts +++ b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts @@ -33,7 +33,7 @@ describe('NapCat runtime promotion contract', () => { it('keeps a safe default runtime profile in the K8s manifest', () => { const manifest = readSource('k8s/prod/api.yaml'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v14'); - expect(manifest).toContain('value: desktop-cn-v14'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v15'); + expect(manifest).toContain('value: desktop-cn-v15'); }); }); diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index 37c0ac1..80473fa 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => { }); expect(profile).toMatchObject({ - desktopProfileVersion: 'desktop-cn-v14', + desktopProfileVersion: 'desktop-cn-v15', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', locale: 'zh_CN.UTF-8', runtimeGid: 1101, diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 44966fc..36e5a16 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -1234,6 +1234,116 @@ describe('QqbotNapcatLoginService', () => { ); }); + it('keeps scan pending when NapCat is login-positive before QQ number is readable', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-login-wait-self-id', + name: 'napcat-login-wait-self-id', + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + }; + const loginService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (loginService as any).createSession({ + container, + mode: 'create', + qrcode: 'https://txz.qq.com/p?k=ready&f=1600001615', + status: 'pending', + }); + (loginService as any).sessions.set(session.id, session); + jest + .spyOn(loginService as any, 'getSessionContainer') + .mockResolvedValue(container); + jest + .spyOn(loginService as any, 'getLoginStatus') + .mockResolvedValue({ isLogin: true }); + jest.spyOn(loginService as any, 'getLoginInfo').mockResolvedValue({ + nick: 'Mirror', + online: true, + }); + + const result = await loginService.status(session.id); + + expect(result.status).toBe('pending'); + expect(result.errorMessage).toBe('NapCat 已登录,正在读取 QQ 号'); + expect((loginService as any).sessions.get(session.id)).toEqual( + expect.objectContaining({ + errorMessage: 'NapCat 已登录,正在读取 QQ 号', + status: 'pending', + }), + ); + expect(accountService.ensureScannedAccount).not.toHaveBeenCalled(); + expect(containerService.bindAccount).not.toHaveBeenCalled(); + }); + + it('completes scan after a login-positive session later exposes QQ number', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-login-late-self-id', + name: 'napcat-login-late-self-id', + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const containerService = { + bindAccount: jest.fn().mockResolvedValue(undefined), + }; + const loginService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (loginService as any).createSession({ + container, + mode: 'create', + qrcode: 'https://txz.qq.com/p?k=ready&f=1600001615', + status: 'pending', + }); + (loginService as any).sessions.set(session.id, session); + jest + .spyOn(loginService as any, 'getSessionContainer') + .mockResolvedValue(container); + jest + .spyOn(loginService as any, 'getLoginStatus') + .mockResolvedValue({ isLogin: true }); + jest + .spyOn(loginService as any, 'getLoginInfo') + .mockResolvedValueOnce({ + nick: 'Mirror', + online: true, + }) + .mockResolvedValueOnce({ + nick: 'Mirror', + online: true, + uin: '1914728559', + }); + + const pending = await loginService.status(session.id); + const completed = await loginService.status(session.id); + + expect(pending.status).toBe('pending'); + expect(completed.status).toBe('success'); + expect(accountService.ensureScannedAccount).toHaveBeenCalledWith({ + name: 'Mirror', + selfId: '1914728559', + }); + expect(containerService.bindAccount).toHaveBeenCalledWith( + 'account-1', + 'container-login-late-self-id', + '1914728559', + ); + expect(session.loginSelfIdMissingSince).toBeUndefined(); + }); + it('does not let the server-side qrcode monitor extend the original qrcode deadline on temporary errors', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', @@ -3629,6 +3739,62 @@ describe('QqbotNapcatLoginService', () => { ); }); + it('persists failed scan sessions before cleaning runtime state', async () => { + const loginSessionRepository = createLoginSessionRepository(); + const loginStateStore = new NapcatLoginStateStoreService( + loginSessionRepository as any, + ); + const failService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + { + ensureScannedAccount: jest.fn(), + } as unknown as QqbotAccountService, + { + removeUnboundCreateContainer: jest.fn().mockResolvedValue(undefined), + } as unknown as QqbotNapcatContainerService, + new ToolsService(), + loginStateStore, + ); + const session = (failService as any).createSession({ + container: { + id: 'container-fail-persist', + name: 'napcat-fail-persist', + webuiPort: 6110, + }, + mode: 'create', + qrcode: 'login-qrcode', + status: 'pending', + }); + (failService as any).sessions.set(session.id, session); + await loginStateStore.flushSessionWrites(session.id); + const flushSessionWrites = jest.spyOn( + loginStateStore, + 'flushSessionWrites', + ); + + const result = await (failService as any).failSession( + session, + 'NapCat 已登录但未返回 QQ 号', + ); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toBe('NapCat 已登录但未返回 QQ 号'); + expect(flushSessionWrites).toHaveBeenCalledWith(session.id); + expect(loginSessionRepository.rows[0]).toEqual( + expect.objectContaining({ + progressMessage: 'NapCat 已登录但未返回 QQ 号', + sessionKey: session.id, + status: 'error', + }), + ); + expect(loginSessionRepository.rows[0].completedAt).toEqual( + expect.any(Date), + ); + expect(loginSessionRepository.rows[0].sessionPayload?.status).toBe( + 'error', + ); + }); + it('keeps an API-expired refresh session pending while NapCat still exposes the same live qrcode', async () => { const loginSessionRepository = createLoginSessionRepository(); const loginStateStore = new NapcatLoginStateStoreService( From 32ecf2a2bd1e2e577db6e8a4cd0122a98779c9a7 Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 29 Jun 2026 08:34:54 +0800 Subject: [PATCH 12/25] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E5=9C=A8?= =?UTF-8?q?=E7=BA=BF=E5=AE=B9=E5=99=A8=E5=8D=8A=E7=99=BB=E5=BD=95=E6=80=81?= =?UTF-8?q?=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- README.md | 6 +- .../qqbot/core/contract/qqbot.types.ts | 2 + .../login/qqbot-napcat-login.service.ts | 86 ++++++++- .../qqbot-napcat-login.service.spec.ts | 173 +++++++++++++++++- 5 files changed, 256 insertions(+), 13 deletions(-) diff --git a/API.md b/API.md index 1a1fa2a..44c7870 100644 --- a/API.md +++ b/API.md @@ -361,7 +361,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。账号列表里的 `connectStatus` 只表示 OneBot 反向 WS;`napcat.oneBotOnline`、`napcat.containerOnline`、`napcat.webuiOnline`、`napcat.qqLoginStatus`、`napcat.qqLoginMessage` 分别表示 OneBot、容器、WebUI 和 QQ 登录态,`webuiOnline=null` 表示本次使用缓存且未重新探测 WebUI;`qqLoginMessage` 只承载真实 QQ 登录态消息,WebUI 配置缺失或请求异常只放在 `lastError`。 -扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending,会话后台再启动远端 Docker 和生成二维码。`CheckLoginStatus.isLogin=true` 只表示 NapCat 登录阳性,新增账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才允许创建和绑定真实 QQ 号;短暂缺号时 `/qqbot/account/scan/status` 保持同一会话 pending 并显示正在读取 QQ 号,等待 `NAPCAT_LOGIN_SELF_ID_WAIT_MS`,不得重建容器、补 env 或从容器元数据猜号。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 依次调用 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode` 推进原有弹窗流程。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env;已在线的源容器不补 env。快速登录失败后,如果账号保存了登录密码,后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env,也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 会返回原 `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。pending refresh 会话如果没有二维码、验证码或新设备挑战,`/qqbot/account/scan/status` 会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试 `RefreshQRcode/GetQQLoginQrcode`,避免 SSE 长时间卡在“二维码生成中”。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 +扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending,会话后台再启动远端 Docker 和生成二维码。`CheckLoginStatus.isLogin=true` 只表示 NapCat 登录阳性,新增账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才允许创建和绑定真实 QQ 号;短暂缺号时 `/qqbot/account/scan/status` 保持同一会话 pending 并显示正在读取 QQ 号,等待 `NAPCAT_LOGIN_SELF_ID_WAIT_MS`,不得重建容器、补 env 或从容器元数据猜号。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 推进原有弹窗流程。若 WebUI 明确返回 QQ 离线,API 会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker 以重建 QQCore login service,再继续 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode`;这不是 Docker 容器重建/重启,设备身份、env 和 dataDir 不变,同一个更新登录 session 只消费一次 worker restart 预算,后续轮询继续刷新二维码但不得反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env;已在线的源容器不补 env。快速登录失败后,如果账号保存了登录密码,后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env,也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 会返回原 `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。pending refresh 会话如果没有二维码、验证码或新设备挑战,`/qqbot/account/scan/status` 会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试 `RefreshQRcode/GetQQLoginQrcode`,避免 SSE 长时间卡在“二维码生成中”。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 密码验证码通过后如果 NapCat 返回 `needNewDevice`,后端不会只把 `jumpUrl` 透给 Admin,而是在同一会话中继续调用 `/api/QQLogin/GetNewDeviceQRCode` 生成新设备验证二维码;`/qqbot/account/scan/status` 后续轮询会代理 `/api/QQLogin/PollNewDeviceQR`,状态映射为 `newDeviceStatus=qr-pending|scanned|confirming|verified|expired|failed`,进入确认态后再调用 `/api/QQLogin/NewDeviceLogin` 并回到密码登录完成检查。扫码会话结果新增 `newDeviceQrcode`、`newDeviceStatus`、`deviceVerifyUrl` 字段;`captchaUrl` 和 `newDeviceQrcode` 分别表示腾讯安全验证码和 QQ 新设备验证二维码,前端必须分开展示。SSE 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、二维码兜底、登录成功/失败。 diff --git a/README.md b/README.md index 593794d..ac3b627 100644 --- a/README.md +++ b/README.md @@ -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/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v15 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 还修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v15` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v15` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 +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 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v15 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 还修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v15` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v15` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 @@ -178,9 +178,9 @@ 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 并单账号观察。`desktop-cn-v15` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复踢下线后原 native login service 不产新二维码、失败回调反复重置、非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径,以及扫码登录成功后 API 立即读不到 QQ 号的问题。 +- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v15` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径,以及扫码登录成功后 API 立即读不到 QQ 号的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker,再继续登录流程,同一个更新登录 session 不能反复重启 worker。 - 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 refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。Admin SSE 步骤顺序按实际路径为 `quick-login-*` -> `password-login-*` / `password-login-captcha` / 新设备验证 -> `qrcode/waiting-scan` -> `login-success|login-failed`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。 +- NapCat 容器为已知 `selfId` 创建/重建时会一次性注入 `ACCOUNT` 等必要 env;容器重启(崩溃/重启策略/宿主重启)可复用持久化会话,但硬踢 `登录已失效` 仍需人工登录。Admin「更新登录」不通过 Docker 重建、重启或补 env 刷新登录态:只要源容器在线,就保持同一 Docker 容器;若 WebUI 明确返回 QQ 离线,会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker,随后通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;同一个更新登录 session 只允许一次 worker restart,后续状态轮询只能继续 WebUI 登录/二维码刷新,不能反复重启 worker。只有 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 refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 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 profile;Docker 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`。 - NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。 - NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后只写入离线原因并复用 `super` 站内信告警,恢复登录必须由管理员在 Admin 手动触发「更新登录」。 diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts index 7b66ca8..8de4cb9 100644 --- a/src/modules/qqbot/core/contract/qqbot.types.ts +++ b/src/modules/qqbot/core/contract/qqbot.types.ts @@ -49,6 +49,7 @@ export type NapcatQrcode = { }; export type NapcatRestartOptions = { + processOnly?: boolean; waitForReady?: boolean; }; @@ -345,6 +346,7 @@ export type QqbotLoginScanSession = { newDevicePullQrCodeSig?: unknown; newDeviceQrcode?: string; newDeviceStatus?: QqbotLoginNewDeviceStatus; + onlineSourceWorkerRestartAttempted?: boolean; passwordMd5?: string; preparingContainer?: boolean; preparingRelogin?: boolean; diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index ce173d4..c0893e5 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -303,7 +303,16 @@ export class QqbotNapcatLoginService { await this.syncSessionQqLoginStatus(session, loginStatus); } - if (loginStatus.isOffline && session.mode !== 'refresh') { + if ( + loginStatus.isOffline && + this.shouldRestartNapcatWorkerForOnlineRefresh(session) + ) { + loginStatus = await this.restartNapcatWorkerForOnlineRefresh( + session, + container, + loginStatus.loginError || 'NapCat 账号已离线,正在重启登录服务', + ); + } else if (loginStatus.isOffline && session.mode !== 'refresh') { await this.restartNapcatForLogin(container, { waitForReady: false }); session.lastRestartedAt = Date.now(); return this.keepSessionPending( @@ -435,6 +444,21 @@ export class QqbotNapcatLoginService { ); } + if ( + status.isOffline && + this.shouldRestartNapcatWorkerForOnlineRefresh(session) + ) { + status = await this.restartNapcatWorkerForOnlineRefresh( + session, + container, + status.loginError || 'NapCat 账号已离线,正在重启登录服务', + ); + if (status.isLogin) { + return this.completeLogin(session, container); + } + await this.syncSessionQqLoginStatus(session, status); + } + if (this.shouldRefreshNearlyExpiredQrcode(status)) { return this.refreshNearlyExpiredQrcode(session, container, status); } @@ -2770,6 +2794,17 @@ export class QqbotNapcatLoginService { container: QqbotNapcatRuntime, ) { const loginStatus = await this.getLoginStatus(container, true); + if ( + loginStatus.isOffline && + this.shouldRestartNapcatWorkerForOnlineRefresh(session) + ) { + await this.restartNapcatWorkerForOnlineRefresh( + session, + container, + loginStatus.loginError || 'NapCat 账号已离线,正在重启登录服务', + ); + return false; + } if (!loginStatus.isLogin) return false; const loginInfo = await this.getLoginInfo(container); @@ -2888,6 +2923,50 @@ export class QqbotNapcatLoginService { return true; } + /** + * Decides whether this refresh session may spend its single same-container worker restart budget. + * @param session - Login refresh session; its source-container flag proves Docker is already alive and the attempt flag prevents restart storms. + * @returns True only before the first NapCat worker restart attempt in this online-source refresh session. + */ + private shouldRestartNapcatWorkerForOnlineRefresh( + session: QqbotLoginScanSession, + ) { + return ( + session.mode === 'refresh' && + session.sourceContainerOnline === true && + session.onlineSourceWorkerRestartAttempted !== true + ); + } + + /** + * Restarts only the NapCat worker when the Docker container is alive but QQCore login service is stale. + * @param session - Refresh login session that owns progress messages and retry timestamps. + * @param container - Current online Docker/WebUI runtime; its device identity and environment must be preserved. + * @param reason - Latest QQ login-state evidence shown to Admin before the worker restart. + * @returns Fresh WebUI login status after the worker restart completes. + */ + private async restartNapcatWorkerForOnlineRefresh( + session: QqbotLoginScanSession, + container: QqbotNapcatRuntime, + reason: string, + ) { + session.lastRestartedAt = Date.now(); + session.onlineSourceWorkerRestartAttempted = true; + session.errorMessage = reason; + this.persistLoginSession(session); + this.publishScanResultEvent( + session, + 'napcat-worker-restart', + 'processing', + reason, + ); + await this.restartNapcatForLogin(container, { + processOnly: true, + waitForReady: true, + }); + return this.getLoginStatus(container, true); + } + /** * 在 NapCat WebUI 拒绝重复 quick 登录时读取真实 QQ 在线态。 * @param session - 当前更新登录会话;成功时直接完成会话,失败时写入 fallback 进度。 @@ -3438,8 +3517,9 @@ export class QqbotNapcatLoginService { container: QqbotNapcatRuntime, options: NapcatRestartOptions = {}, ) { - const restartedByContainer = - await this.containerService.restartRuntimeContainer(container); + const restartedByContainer = options.processOnly + ? false + : await this.containerService.restartRuntimeContainer(container); if (!restartedByContainer) { try { await this.postNapcat | null>( diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 36e5a16..53e09b2 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -37,6 +37,8 @@ describe('QqbotNapcatLoginService', () => { passwordResult?: null | Record; quickError?: string; quickResult?: null | Record; + restartError?: string; + restartResult?: null | Record; } = {}, ) => jest @@ -50,6 +52,10 @@ describe('QqbotNapcatLoginService', () => { if (options.passwordError) throw new Error(options.passwordError); return options.passwordResult ?? null; } + if (path === '/api/QQLogin/RestartNapCat') { + if (options.restartError) throw new Error(options.restartError); + return options.restartResult ?? null; + } return null; }); @@ -278,7 +284,11 @@ describe('QqbotNapcatLoginService', () => { it('does not return a stale qrcode when reusing an active refresh session', async () => { const refreshService = new QqbotNapcatLoginService( - { get: jest.fn() } as unknown as ConfigService, + { + get: jest.fn((key: string) => + key === 'NAPCAT_WEBUI_RESTART_DELAY_MS' ? '1' : undefined, + ), + } as unknown as ConfigService, {} as QqbotAccountService, {} as QqbotNapcatContainerService, new ToolsService(), @@ -1529,7 +1539,7 @@ describe('QqbotNapcatLoginService', () => { expect(session.qrcode).toBe('budget-qrcode'); }); - it('refreshes qrcode through WebUI when the source Docker container is online but QQ is offline', async () => { + it('restarts the NapCat worker before refreshing qrcode when source Docker is online but QQ is offline', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', id: 'container-online-source', @@ -1548,7 +1558,11 @@ describe('QqbotNapcatLoginService', () => { ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), }; const refreshService = new QqbotNapcatLoginService( - { get: jest.fn() } as unknown as ConfigService, + { + get: jest.fn((key: string) => + key === 'NAPCAT_WEBUI_RESTART_DELAY_MS' ? '1' : undefined, + ), + } as unknown as ConfigService, accountService as unknown as QqbotAccountService, containerService as unknown as QqbotNapcatContainerService, new ToolsService(), @@ -1563,6 +1577,9 @@ describe('QqbotNapcatLoginService', () => { status: 'pending', }); (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ isOffline: true, isLogin: false, @@ -1571,11 +1588,16 @@ describe('QqbotNapcatLoginService', () => { const refreshOrGetQrcode = jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('online-source-qrcode'); + const postNapcat = mockWebuiLoginPost(refreshService); const result = await refreshService.refreshQrcode(session.id); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect(postNapcat).toHaveBeenCalledWith( + container, + '/api/QQLogin/RestartNapCat', + ); expect(refreshOrGetQrcode).toHaveBeenCalledWith(container, false, { fallbackStatus: expect.objectContaining({ isOffline: true }), requireFresh: true, @@ -1585,6 +1607,68 @@ describe('QqbotNapcatLoginService', () => { expect(result.qrcode).toBe('online-source-qrcode'); }); + it('does not restart the NapCat worker repeatedly for the same online-source refresh session', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6103/', + id: 'container-online-source', + name: 'napcat-10001', + sourceContainerOnline: true, + }; + const containerService = { + ensureRuntimeLoginEnv: jest + .fn() + .mockResolvedValue({ changed: false, ok: true }), + findRuntimeById: jest.fn().mockResolvedValue(container), + resetRuntimeLoginState: jest.fn().mockResolvedValue(true), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const accountService = { + ensureScannedAccount: jest.fn().mockResolvedValue('account-1'), + }; + const refreshService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => + key === 'NAPCAT_WEBUI_RESTART_DELAY_MS' ? '1' : undefined, + ), + } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + const session = (refreshService as any).createSession({ + accountId: 'account-1', + container, + expectedSelfId: '10001', + mode: 'refresh', + preparingRelogin: false, + sourceContainerOnline: true, + status: 'pending', + }); + (refreshService as any).sessions.set(session.id, session); + jest + .spyOn((refreshService as any).toolsService, 'sleep') + .mockResolvedValue(undefined); + jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isOffline: true, + isLogin: false, + loginError: 'NapCat 账号状态变更为离线', + }); + jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('online-source-qrcode'); + const postNapcat = mockWebuiLoginPost(refreshService); + + await refreshService.refreshQrcode(session.id); + await refreshService.refreshQrcode(session.id); + + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect( + postNapcat.mock.calls.filter( + ([, path]) => path === '/api/QQLogin/RestartNapCat', + ), + ).toHaveLength(1); + }); + it('does not perform runtime env rebuild inside the refresh session', async () => { const container = { baseUrl: 'http://127.0.0.1:6103/', @@ -1683,12 +1767,13 @@ describe('QqbotNapcatLoginService', () => { }); (refreshService as any).sessions.set(session.id, session); jest.spyOn(refreshService as any, 'getLoginStatus').mockResolvedValue({ + isOffline: true, isLogin: false, loginError: 'Docker 容器在线但 QQ 账号离线', }); - jest - .spyOn(refreshService as any, 'postNapcat') - .mockRejectedValue(new Error('快速登录未成功')); + const postNapcat = mockWebuiLoginPost(refreshService, { + quickError: '快速登录未成功', + }); jest .spyOn(refreshService as any, 'refreshOrGetQrcode') .mockResolvedValue('online-source-qrcode'); @@ -1699,6 +1784,10 @@ describe('QqbotNapcatLoginService', () => { expect(containerService.runtimeLoginEnvMatches).not.toHaveBeenCalled(); expect(containerService.resetRuntimeLoginState).not.toHaveBeenCalled(); expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect(postNapcat.mock.calls.map(([, path]) => path)).toEqual([ + '/api/QQLogin/RestartNapCat', + '/api/QQLogin/SetQuickLogin', + ]); expect(session.status).toBe('pending'); expect(session.qrcode).toBe('online-source-qrcode'); }); @@ -4267,6 +4356,78 @@ describe('QqbotNapcatLoginService', () => { ).toEqual(expect.any(Number)); }); + it('restarts the NapCat worker once before status auto-refresh for a restored online-source refresh session', async () => { + const container = { + baseUrl: 'http://127.0.0.1:6106/', + id: 'container-status-worker-restart', + name: 'napcat-10001', + sourceContainerOnline: true, + }; + const containerService = { + findRuntimeById: jest.fn().mockResolvedValue(container), + restartRuntimeContainer: jest.fn().mockResolvedValue(true), + }; + const accountService = { + markQqLoginStatus: jest.fn().mockResolvedValue(undefined), + }; + const refreshService = new QqbotNapcatLoginService( + { + get: jest.fn((key: string) => + key === 'NAPCAT_WEBUI_RESTART_DELAY_MS' ? '1' : undefined, + ), + } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + (refreshService as any).sessions.set('session-status-worker-restart', { + accountId: 'account-1', + containerId: container.id, + containerName: container.name, + createdAt: Date.now(), + errorMessage: 'NapCat 正在重新生成二维码,请稍后刷新或等待自动更新', + expiresAt: Date.now() + 60_000, + expectedSelfId: '10001', + id: 'session-status-worker-restart', + mode: 'refresh', + sourceContainerOnline: true, + status: 'pending', + webuiPort: 6106, + }); + const loginStatus = { + isLogin: false, + isOffline: true, + loginError: 'NapCat 账号状态变更为离线', + }; + jest + .spyOn(refreshService as any, 'getSessionContainer') + .mockResolvedValue(container); + jest + .spyOn(refreshService as any, 'getLoginStatus') + .mockResolvedValue(loginStatus); + jest + .spyOn(refreshService as any, 'refreshOrGetQrcode') + .mockResolvedValue('status-worker-restart-qrcode'); + const postNapcat = mockWebuiLoginPost(refreshService); + + const firstResult = await refreshService.status( + 'session-status-worker-restart', + ); + const secondResult = await refreshService.status( + 'session-status-worker-restart', + ); + + expect(firstResult.status).toBe('pending'); + expect(firstResult.qrcode).toBe('status-worker-restart-qrcode'); + expect(secondResult.status).toBe('pending'); + expect(containerService.restartRuntimeContainer).not.toHaveBeenCalled(); + expect( + postNapcat.mock.calls.filter( + ([, path]) => path === '/api/QQLogin/RestartNapCat', + ), + ).toHaveLength(1); + }); + it('normalizes login status to offline when login info reports offline', async () => { jest .spyOn(service as any, 'postNapcat') From 6aa6eac21d314e249c8640920757c71a1e04a415 Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 29 Jun 2026 08:39:54 +0800 Subject: [PATCH 13/25] =?UTF-8?q?test:=20=E4=BF=AE=E5=A4=8DNapCat=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E6=9E=B6=E6=9E=84=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../napcat/application/login/qqbot-napcat-login.service.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index c0893e5..cbdff2c 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -2925,7 +2925,7 @@ export class QqbotNapcatLoginService { /** * Decides whether this refresh session may spend its single same-container worker restart budget. - * @param session - Login refresh session; its source-container flag proves Docker is already alive and the attempt flag prevents restart storms. + * @param session - Login refresh session; its source-runtime flag proves the managed runtime is already alive and the attempt flag prevents restart storms. * @returns True only before the first NapCat worker restart attempt in this online-source refresh session. */ private shouldRestartNapcatWorkerForOnlineRefresh( @@ -2939,9 +2939,9 @@ export class QqbotNapcatLoginService { } /** - * Restarts only the NapCat worker when the Docker container is alive but QQCore login service is stale. + * Restarts only the NapCat worker when the managed runtime is alive but QQCore login service is stale. * @param session - Refresh login session that owns progress messages and retry timestamps. - * @param container - Current online Docker/WebUI runtime; its device identity and environment must be preserved. + * @param container - Current online WebUI runtime; its device identity and environment must be preserved. * @param reason - Latest QQ login-state evidence shown to Admin before the worker restart. * @returns Fresh WebUI login status after the worker restart completes. */ From b5112006b8a98e9385420727d43b5b88507be22c Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 29 Jun 2026 09:08:35 +0800 Subject: [PATCH 14/25] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E7=99=BB=E5=BD=95=E4=BC=9A=E8=AF=9D=E6=8C=81=E4=B9=85?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- README.md | 2 +- .../napcat-login-state-store.service.ts | 25 +++++++++++++++++-- .../napcat-persistent-login-state.spec.ts | 14 ++++++++--- 4 files changed, 36 insertions(+), 7 deletions(-) diff --git a/API.md b/API.md index 44c7870..c025555 100644 --- a/API.md +++ b/API.md @@ -361,7 +361,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。账号列表里的 `connectStatus` 只表示 OneBot 反向 WS;`napcat.oneBotOnline`、`napcat.containerOnline`、`napcat.webuiOnline`、`napcat.qqLoginStatus`、`napcat.qqLoginMessage` 分别表示 OneBot、容器、WebUI 和 QQ 登录态,`webuiOnline=null` 表示本次使用缓存且未重新探测 WebUI;`qqLoginMessage` 只承载真实 QQ 登录态消息,WebUI 配置缺失或请求异常只放在 `lastError`。 -扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending,会话后台再启动远端 Docker 和生成二维码。`CheckLoginStatus.isLogin=true` 只表示 NapCat 登录阳性,新增账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才允许创建和绑定真实 QQ 号;短暂缺号时 `/qqbot/account/scan/status` 保持同一会话 pending 并显示正在读取 QQ 号,等待 `NAPCAT_LOGIN_SELF_ID_WAIT_MS`,不得重建容器、补 env 或从容器元数据猜号。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 推进原有弹窗流程。若 WebUI 明确返回 QQ 离线,API 会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker 以重建 QQCore login service,再继续 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode`;这不是 Docker 容器重建/重启,设备身份、env 和 dataDir 不变,同一个更新登录 session 只消费一次 worker restart 预算,后续轮询继续刷新二维码但不得反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env;已在线的源容器不补 env。快速登录失败后,如果账号保存了登录密码,后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env,也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 会返回原 `sessionId`,不会再次启动 quick/password/二维码准备。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。pending refresh 会话如果没有二维码、验证码或新设备挑战,`/qqbot/account/scan/status` 会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试 `RefreshQRcode/GetQQLoginQrcode`,避免 SSE 长时间卡在“二维码生成中”。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 +扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending,会话后台再启动远端 Docker 和生成二维码。`CheckLoginStatus.isLogin=true` 只表示 NapCat 登录阳性,新增账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才允许创建和绑定真实 QQ 号;短暂缺号时 `/qqbot/account/scan/status` 保持同一会话 pending 并显示正在读取 QQ 号,等待 `NAPCAT_LOGIN_SELF_ID_WAIT_MS`,不得重建容器、补 env 或从容器元数据猜号。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 推进原有弹窗流程。若 WebUI 明确返回 QQ 离线,API 会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker 以重建 QQCore login service,再继续 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode`;这不是 Docker 容器重建/重启,设备身份、env 和 dataDir 不变,同一个更新登录 session 只消费一次 worker restart 预算,后续轮询继续刷新二维码但不得反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env;已在线的源容器不补 env。快速登录失败后,如果账号保存了登录密码,后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env,也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 会返回原 `sessionId`,不会再次启动 quick/password/二维码准备;取消扫码会话必须把持久化 `napcat_login_session` 落到非 pending 终态并写入完成时间,避免已取消的测试二维码从 DB 恢复成可轮询会话。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。pending refresh 会话如果没有二维码、验证码或新设备挑战,`/qqbot/account/scan/status` 会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试 `RefreshQRcode/GetQQLoginQrcode`,避免 SSE 长时间卡在“二维码生成中”。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 密码验证码通过后如果 NapCat 返回 `needNewDevice`,后端不会只把 `jumpUrl` 透给 Admin,而是在同一会话中继续调用 `/api/QQLogin/GetNewDeviceQRCode` 生成新设备验证二维码;`/qqbot/account/scan/status` 后续轮询会代理 `/api/QQLogin/PollNewDeviceQR`,状态映射为 `newDeviceStatus=qr-pending|scanned|confirming|verified|expired|failed`,进入确认态后再调用 `/api/QQLogin/NewDeviceLogin` 并回到密码登录完成检查。扫码会话结果新增 `newDeviceQrcode`、`newDeviceStatus`、`deviceVerifyUrl` 字段;`captchaUrl` 和 `newDeviceQrcode` 分别表示腾讯安全验证码和 QQ 新设备验证二维码,前端必须分开展示。SSE 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、二维码兜底、登录成功/失败。 diff --git a/README.md b/README.md index ac3b627..167223c 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - 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 并单账号观察。`desktop-cn-v15` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径,以及扫码登录成功后 API 立即读不到 QQ 号的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker,再继续登录流程,同一个更新登录 session 不能反复重启 worker。 - 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 刷新登录态:只要源容器在线,就保持同一 Docker 容器;若 WebUI 明确返回 QQ 离线,会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker,随后通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;同一个更新登录 session 只允许一次 worker restart,后续状态轮询只能继续 WebUI 登录/二维码刷新,不能反复重启 worker。只有 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 refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。Admin SSE 步骤顺序按实际路径为 `quick-login-*` -> `password-login-*` / `password-login-captcha` / 新设备验证 -> `qrcode/waiting-scan` -> `login-success|login-failed`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。 +- NapCat 容器为已知 `selfId` 创建/重建时会一次性注入 `ACCOUNT` 等必要 env;容器重启(崩溃/重启策略/宿主重启)可复用持久化会话,但硬踢 `登录已失效` 仍需人工登录。Admin「更新登录」不通过 Docker 重建、重启或补 env 刷新登录态:只要源容器在线,就保持同一 Docker 容器;若 WebUI 明确返回 QQ 离线,会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker,随后通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;同一个更新登录 session 只允许一次 worker restart,后续状态轮询只能继续 WebUI 登录/二维码刷新,不能反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备;取消扫码会话会把持久化 session 标成 `error/cancelled` 并写完成时间,避免已取消二维码从 DB 恢复为 active pending。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。pending refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 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 profile;Docker 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`。 - NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。 - NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后只写入离线原因并复用 `super` 站内信告警,恢复登录必须由管理员在 Admin 手动触发「更新登录」。 diff --git a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts index 2d7e3f7..80e361c 100644 --- a/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts +++ b/src/modules/qqbot/napcat/infrastructure/persistence/napcat-login-state-store.service.ts @@ -299,12 +299,33 @@ export class NapcatLoginStateStoreService { const current = await this.loginSessionRepository.findOne({ where: { sessionKey: sessionId }, }); - if (!current || current.status === 'pending') return; + if (!current) return; + + const completedAt = new Date() as NapcatLoginSession['completedAt']; + if (current.status === 'pending') { + await this.loginSessionRepository.update( + { sessionKey: sessionId }, + { + completedAt, + loginStage: 'cancelled', + progressMessage: '扫码会话已取消', + sessionPayload: current.sessionPayload + ? { + ...current.sessionPayload, + errorMessage: '扫码会话已取消', + status: 'error', + } + : current.sessionPayload, + status: 'error', + }, + ); + return; + } await this.loginSessionRepository.update( { sessionKey: sessionId }, { - completedAt: new Date() as NapcatLoginSession['completedAt'], + completedAt, }, ); } diff --git a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts index f391261..34b1cc1 100644 --- a/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts +++ b/test/modules/qqbot/napcat/napcat-persistent-login-state.spec.ts @@ -264,7 +264,7 @@ describe('NapCat persistent login state contract', () => { ); }); - it('does not mark a still-pending scan session completed when it is removed from the runtime cache', async () => { + it('marks a cancelled pending scan session completed instead of restoring it as active', async () => { const loginSessionRepository = createRepository(); const store = new NapcatLoginStateStoreService( loginSessionRepository as any, @@ -288,9 +288,17 @@ describe('NapCat persistent login state contract', () => { expect(loginSessionRepository.rows[0]).toEqual( expect.objectContaining({ - completedAt: null, + completedAt: expect.any(Date), + loginStage: 'cancelled', + progressMessage: '扫码会话已取消', sessionKey: 'pending-delete-session', - status: 'pending', + status: 'error', + }), + ); + expect(loginSessionRepository.rows[0].sessionPayload).toEqual( + expect.objectContaining({ + errorMessage: '扫码会话已取消', + status: 'error', }), ); }); From 487d80274e905f42b92a1dcb15b07732bf5bee54 Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 29 Jun 2026 09:20:12 +0800 Subject: [PATCH 15/25] =?UTF-8?q?fix:=20=E7=AD=89=E5=BE=85NapCat=E5=8F=96?= =?UTF-8?q?=E6=B6=88=E4=BC=9A=E8=AF=9D=E8=90=BD=E5=BA=93=E5=AE=8C=E6=88=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- README.md | 2 +- .../napcat/application/login/qqbot-napcat-login.service.ts | 1 + test/qqbot/account/qqbot-napcat-login.service.spec.ts | 5 +++++ 4 files changed, 8 insertions(+), 2 deletions(-) diff --git a/API.md b/API.md index c025555..1b6fa65 100644 --- a/API.md +++ b/API.md @@ -361,7 +361,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。账号列表里的 `connectStatus` 只表示 OneBot 反向 WS;`napcat.oneBotOnline`、`napcat.containerOnline`、`napcat.webuiOnline`、`napcat.qqLoginStatus`、`napcat.qqLoginMessage` 分别表示 OneBot、容器、WebUI 和 QQ 登录态,`webuiOnline=null` 表示本次使用缓存且未重新探测 WebUI;`qqLoginMessage` 只承载真实 QQ 登录态消息,WebUI 配置缺失或请求异常只放在 `lastError`。 -扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending,会话后台再启动远端 Docker 和生成二维码。`CheckLoginStatus.isLogin=true` 只表示 NapCat 登录阳性,新增账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才允许创建和绑定真实 QQ 号;短暂缺号时 `/qqbot/account/scan/status` 保持同一会话 pending 并显示正在读取 QQ 号,等待 `NAPCAT_LOGIN_SELF_ID_WAIT_MS`,不得重建容器、补 env 或从容器元数据猜号。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 推进原有弹窗流程。若 WebUI 明确返回 QQ 离线,API 会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker 以重建 QQCore login service,再继续 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode`;这不是 Docker 容器重建/重启,设备身份、env 和 dataDir 不变,同一个更新登录 session 只消费一次 worker restart 预算,后续轮询继续刷新二维码但不得反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env;已在线的源容器不补 env。快速登录失败后,如果账号保存了登录密码,后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env,也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 会返回原 `sessionId`,不会再次启动 quick/password/二维码准备;取消扫码会话必须把持久化 `napcat_login_session` 落到非 pending 终态并写入完成时间,避免已取消的测试二维码从 DB 恢复成可轮询会话。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。pending refresh 会话如果没有二维码、验证码或新设备挑战,`/qqbot/account/scan/status` 会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试 `RefreshQRcode/GetQQLoginQrcode`,避免 SSE 长时间卡在“二维码生成中”。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 +扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending,会话后台再启动远端 Docker 和生成二维码。`CheckLoginStatus.isLogin=true` 只表示 NapCat 登录阳性,新增账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才允许创建和绑定真实 QQ 号;短暂缺号时 `/qqbot/account/scan/status` 保持同一会话 pending 并显示正在读取 QQ 号,等待 `NAPCAT_LOGIN_SELF_ID_WAIT_MS`,不得重建容器、补 env 或从容器元数据猜号。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 推进原有弹窗流程。若 WebUI 明确返回 QQ 离线,API 会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker 以重建 QQCore login service,再继续 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode`;这不是 Docker 容器重建/重启,设备身份、env 和 dataDir 不变,同一个更新登录 session 只消费一次 worker restart 预算,后续轮询继续刷新二维码但不得反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env;已在线的源容器不补 env。快速登录失败后,如果账号保存了登录密码,后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env,也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 会返回原 `sessionId`,不会再次启动 quick/password/二维码准备;取消扫码会话必须在接口返回前把持久化 `napcat_login_session` 落到非 pending 终态并写入完成时间,避免已取消的测试二维码从 DB 恢复成可轮询会话。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。pending refresh 会话如果没有二维码、验证码或新设备挑战,`/qqbot/account/scan/status` 会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试 `RefreshQRcode/GetQQLoginQrcode`,避免 SSE 长时间卡在“二维码生成中”。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 密码验证码通过后如果 NapCat 返回 `needNewDevice`,后端不会只把 `jumpUrl` 透给 Admin,而是在同一会话中继续调用 `/api/QQLogin/GetNewDeviceQRCode` 生成新设备验证二维码;`/qqbot/account/scan/status` 后续轮询会代理 `/api/QQLogin/PollNewDeviceQR`,状态映射为 `newDeviceStatus=qr-pending|scanned|confirming|verified|expired|failed`,进入确认态后再调用 `/api/QQLogin/NewDeviceLogin` 并回到密码登录完成检查。扫码会话结果新增 `newDeviceQrcode`、`newDeviceStatus`、`deviceVerifyUrl` 字段;`captchaUrl` 和 `newDeviceQrcode` 分别表示腾讯安全验证码和 QQ 新设备验证二维码,前端必须分开展示。SSE 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、二维码兜底、登录成功/失败。 diff --git a/README.md b/README.md index 167223c..aff2b73 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - 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 并单账号观察。`desktop-cn-v15` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径,以及扫码登录成功后 API 立即读不到 QQ 号的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker,再继续登录流程,同一个更新登录 session 不能反复重启 worker。 - 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 刷新登录态:只要源容器在线,就保持同一 Docker 容器;若 WebUI 明确返回 QQ 离线,会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker,随后通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;同一个更新登录 session 只允许一次 worker restart,后续状态轮询只能继续 WebUI 登录/二维码刷新,不能反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备;取消扫码会话会把持久化 session 标成 `error/cancelled` 并写完成时间,避免已取消二维码从 DB 恢复为 active pending。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。pending refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。Admin SSE 步骤顺序按实际路径为 `quick-login-*` -> `password-login-*` / `password-login-captcha` / 新设备验证 -> `qrcode/waiting-scan` -> `login-success|login-failed`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。 +- NapCat 容器为已知 `selfId` 创建/重建时会一次性注入 `ACCOUNT` 等必要 env;容器重启(崩溃/重启策略/宿主重启)可复用持久化会话,但硬踢 `登录已失效` 仍需人工登录。Admin「更新登录」不通过 Docker 重建、重启或补 env 刷新登录态:只要源容器在线,就保持同一 Docker 容器;若 WebUI 明确返回 QQ 离线,会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker,随后通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;同一个更新登录 session 只允许一次 worker restart,后续状态轮询只能继续 WebUI 登录/二维码刷新,不能反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备;取消扫码会话会在接口返回前把持久化 session 标成 `error/cancelled` 并写完成时间,避免已取消二维码从 DB 恢复为 active pending。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。pending refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 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 profile;Docker 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`。 - NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。 - NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后只写入离线原因并复用 `super` 站内信告警,恢复登录必须由管理员在 Admin 手动触发「更新登录」。 diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index cbdff2c..7745bd4 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -622,6 +622,7 @@ export class QqbotNapcatLoginService { step: 'session-cancelled', }); this.loginSessionStore.delete(sessionId); + await this.loginSessionStore.flushSessionWrites(sessionId); await this.cleanupSessionContainer(session); this.cleanupSessionEvents(sessionId); } diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 53e09b2..41eabe9 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -3074,10 +3074,15 @@ describe('QqbotNapcatLoginService', () => { 'https://ti.qq.com/safe/tools/captcha/sms-verify-login?uin=10001'; session.passwordMd5 = '0123456789abcdef0123456789abcdef'; (refreshService as any).sessions.set(session.id, session); + const flushSessionWrites = jest.spyOn( + (refreshService as any).loginSessionStore, + 'flushSessionWrites', + ); await refreshService.cancel(session.id); expect(containerService.ensureRuntimeLoginEnv).not.toHaveBeenCalled(); + expect(flushSessionWrites).toHaveBeenCalledWith(session.id); expect(session.passwordMd5).toBeUndefined(); expect(session.captchaUrl).toBeUndefined(); }); From 55c3882971125d6cfd4b510d64c7d669f660e8b9 Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 29 Jun 2026 09:59:37 +0800 Subject: [PATCH 16/25] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DNapCat=E9=95=9C?= =?UTF-8?q?=E5=83=8F=E9=AA=8C=E8=AF=81=E7=AB=9E=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ci/napcat-desktop-cn/README.md | 2 + ci/napcat-desktop-cn/verify.sh | 43 ++++++++++++++++++- .../napcat/napcat-desktop-cn-image.spec.ts | 15 +++++++ 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/ci/napcat-desktop-cn/README.md b/ci/napcat-desktop-cn/README.md index 10e75ab..be9d36d 100644 --- a/ci/napcat-desktop-cn/README.md +++ b/ci/napcat-desktop-cn/README.md @@ -58,4 +58,6 @@ docker exec $name sh /ci/napcat-desktop-cn/verify.sh docker rm -f $name ``` +`verify.sh` checks long-lived QQ/NapCat/Xvfb process `/proc//mountinfo` after the entrypoint guard has had a bounded convergence window. If a target process still exposes host paths after `MOUNTINFO_GUARD_TIMEOUT_SECONDS`, verification exits with 78 and prints the target PID, `comm`, `cmdline`, and first leaked mountinfo line. + Record the final image digest in `QQBOT_NAPCAT_IMAGE`, and keep the matching `fork-artifact.json` with the release evidence. For local Windows rehearsal, placeholder `$upstreamReleaseTag`, `$upstreamReleaseCommit`, and `$jenkinsBuildUrl` values are acceptable only if the image is not promoted. diff --git a/ci/napcat-desktop-cn/verify.sh b/ci/napcat-desktop-cn/verify.sh index 66eaf88..d99f8eb 100644 --- a/ci/napcat-desktop-cn/verify.sh +++ b/ci/napcat-desktop-cn/verify.sh @@ -62,14 +62,53 @@ grep -q '/sys/class/dmi/id/bios_version' /app/entrypoint.sh grep -q '/etc/hosts' /app/entrypoint.sh MOUNTINFO_HOST_LEAK_PATTERN='overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim' -assert_no_mountinfo_host_leak() { +MOUNTINFO_GUARD_TIMEOUT_SECONDS="${MOUNTINFO_GUARD_TIMEOUT_SECONDS:-12}" + +mountinfo_has_host_leak() { kt_mountinfo="$1" if [ -r "$kt_mountinfo" ] && grep -E "$MOUNTINFO_HOST_LEAK_PATTERN" "$kt_mountinfo" >/dev/null 2>&1; then + return 0 + fi + return 1 +} + +assert_no_mountinfo_host_leak() { + kt_mountinfo="$1" + if mountinfo_has_host_leak "$kt_mountinfo"; then echo "NapCat desktop-cn verify failed: mountinfo-host-leak:$kt_mountinfo" >&2 exit 78 fi } +wait_for_mountinfo_guard_converged() { + kt_mountinfo_pid="$1" + kt_mountinfo_guard_deadline="$MOUNTINFO_GUARD_TIMEOUT_SECONDS" + kt_mountinfo_guard_elapsed=0 + while [ "$kt_mountinfo_guard_elapsed" -le "$kt_mountinfo_guard_deadline" ]; do + if [ ! -d "/proc/$kt_mountinfo_pid" ]; then + return 0 + fi + if ! mountinfo_has_host_leak "/proc/$kt_mountinfo_pid/mountinfo"; then + return 0 + fi + sleep 1 + kt_mountinfo_guard_elapsed=$((kt_mountinfo_guard_elapsed + 1)) + done + return 1 +} + +assert_target_mountinfo_guard_converged() { + kt_mountinfo_pid="$1" + if wait_for_mountinfo_guard_converged "$kt_mountinfo_pid"; then + return 0 + fi + kt_mountinfo_comm="$(cat "/proc/$kt_mountinfo_pid/comm" 2>/dev/null || true)" + kt_mountinfo_cmdline="$(tr '\000' ' ' < "/proc/$kt_mountinfo_pid/cmdline" 2>/dev/null || true)" + echo "NapCat desktop-cn verify failed: mountinfo-host-leak:/proc/$kt_mountinfo_pid/mountinfo comm=$kt_mountinfo_comm cmdline=$kt_mountinfo_cmdline" >&2 + grep -E "$MOUNTINFO_HOST_LEAK_PATTERN" "/proc/$kt_mountinfo_pid/mountinfo" 2>/dev/null | head -n 1 >&2 || true + exit 78 +} + kt_is_mountinfo_target() { kt_target_comm="$1" kt_target_cmdline="$2" @@ -111,6 +150,6 @@ for kt_mountinfo_pid_dir in /proc/[0-9]*; do kt_mountinfo_comm="$(cat "$kt_mountinfo_pid_dir/comm" 2>/dev/null || true)" kt_mountinfo_cmdline="$(tr '\000' ' ' < "$kt_mountinfo_pid_dir/cmdline" 2>/dev/null || true)" if kt_is_mountinfo_target "$kt_mountinfo_comm" "$kt_mountinfo_cmdline"; then - assert_no_mountinfo_host_leak "/proc/$kt_mountinfo_pid/mountinfo" + assert_target_mountinfo_guard_converged "$kt_mountinfo_pid" fi done diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index f17007c..d7a209b 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -63,6 +63,21 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).toContain('Asia/Shanghai'); }); + it('waits for the mountinfo guard to converge before failing target process leaks', () => { + const verify = readSource('ci/napcat-desktop-cn/verify.sh'); + + expect(verify).toContain('MOUNTINFO_GUARD_TIMEOUT_SECONDS'); + expect(verify).toContain('wait_for_mountinfo_guard_converged'); + expect(verify).toContain('kt_mountinfo_guard_deadline='); + expect(verify).toContain('sleep 1'); + expect(verify).toContain( + 'assert_target_mountinfo_guard_converged "$kt_mountinfo_pid"', + ); + expect(verify).not.toContain( + 'assert_no_mountinfo_host_leak "/proc/$kt_mountinfo_pid/mountinfo"', + ); + }); + it('patches entrypoint device profile probes that QQCore opens at runtime', () => { const dockerfile = readSource('ci/napcat-desktop-cn/Dockerfile'); const entrypointPatch = readSource( From 6fdac0f82d1a11ef792d73a618dd32b57e7f33f3 Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 29 Jun 2026 10:29:44 +0800 Subject: [PATCH 17/25] =?UTF-8?q?chore:=20=E5=9B=BA=E5=8C=96NapCat=20v16?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E9=BB=98=E8=AE=A4=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- README.md | 4 ++-- k8s/prod/api.yaml | 4 ++-- .../application/runtime/napcat-runtime-profile.service.ts | 2 +- test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts | 8 +++++--- .../modules/qqbot/napcat/napcat-runtime-promotion.spec.ts | 8 +++++--- .../modules/qqbot/napcat/runtime-protocol-profile.spec.ts | 2 +- 7 files changed, 17 insertions(+), 13 deletions(-) diff --git a/API.md b/API.md index 1b6fa65..20a4c77 100644 --- a/API.md +++ b/API.md @@ -377,7 +377,7 @@ 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 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v14 起运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径;v15 在扫码登录成功回调中先写入 `QQLoginInfo` 再写登录态,避免 API 读到 `isLogin=true` 但 QQ 号为空的短暂不一致。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v15` / `desktop-cn-v15` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 +NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v14 起运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径;v15 在扫码登录成功回调中先写入 `QQLoginInfo` 再写登录态,避免 API 读到 `isLogin=true` 但 QQ 号为空的短暂不一致;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v16` / `desktop-cn-v16` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadata:upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。 diff --git a/README.md b/README.md index aff2b73..42e06f7 100644 --- a/README.md +++ b/README.md @@ -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/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v15 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 还修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v15` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v15` 安全默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 +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 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v16 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v16` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v16` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 @@ -178,7 +178,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 并单账号观察。`desktop-cn-v15` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径,以及扫码登录成功后 API 立即读不到 QQ 号的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker,再继续登录流程,同一个更新登录 session 不能反复重启 worker。 +- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v16` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径、扫码登录成功后 API 立即读不到 QQ 号,以及生产 native reset 缺少 `offline()` 时半登录态无法清理的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker,再继续登录流程,同一个更新登录 session 不能反复重启 worker。 - 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 刷新登录态:只要源容器在线,就保持同一 Docker 容器;若 WebUI 明确返回 QQ 离线,会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker,随后通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;同一个更新登录 session 只允许一次 worker restart,后续状态轮询只能继续 WebUI 登录/二维码刷新,不能反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备;取消扫码会话会在接口返回前把持久化 session 标成 `error/cancelled` 并写完成时间,避免已取消二维码从 DB 恢复为 active pending。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。pending refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 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 profile;Docker 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`。 diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index 5640c7f..33877d3 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -33,9 +33,9 @@ spec: - name: DB_TIMEZONE value: "+08:00" - name: QQBOT_NAPCAT_IMAGE - value: kt-napcat-desktop-cn:desktop-cn-v15 + value: kt-napcat-desktop-cn:desktop-cn-v16 - name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION - value: desktop-cn-v15 + value: desktop-cn-v16 - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index 892c249..3e33267 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService { dataDir: input.dataDir, desktopProfileVersion: this.getString( 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', - 'desktop-cn-v15', + 'desktop-cn-v16', ), deviceIdentityId: input.deviceIdentityId, imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index d7a209b..180c1d3 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -173,13 +173,15 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).not.toContain('selfInfo?.online !== false'); }); - it('deploys the production API with the verified desktop-cn-v15 runtime profile', () => { + it('deploys the production API with the verified desktop-cn-v16 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-v15'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v16'); expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION'); - expect(manifest).toContain('value: desktop-cn-v15'); + expect(manifest).toContain('value: desktop-cn-v16'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v15'); + expect(manifest).not.toContain('value: desktop-cn-v15'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v8'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v7'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v4'); diff --git a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts index 5aa7764..90fa1d7 100644 --- a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts +++ b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts @@ -30,10 +30,12 @@ describe('NapCat runtime promotion contract', () => { ); }); - it('keeps a safe default runtime profile in the K8s manifest', () => { + it('keeps the verified v16 runtime profile as the K8s manifest default', () => { const manifest = readSource('k8s/prod/api.yaml'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v15'); - expect(manifest).toContain('value: desktop-cn-v15'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v16'); + expect(manifest).toContain('value: desktop-cn-v16'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v15'); + expect(manifest).not.toContain('value: desktop-cn-v15'); }); }); diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index 80473fa..9e63a85 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => { }); expect(profile).toMatchObject({ - desktopProfileVersion: 'desktop-cn-v15', + desktopProfileVersion: 'desktop-cn-v16', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', locale: 'zh_CN.UTF-8', runtimeGid: 1101, From e3567848362534d131ab886aa39165fbf2f9cb6f Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 29 Jun 2026 10:56:14 +0800 Subject: [PATCH 18/25] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0NapCat=20v16?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E7=A4=BA=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ci/napcat-desktop-cn/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ci/napcat-desktop-cn/README.md b/ci/napcat-desktop-cn/README.md index be9d36d..2297e6a 100644 --- a/ci/napcat-desktop-cn/README.md +++ b/ci/napcat-desktop-cn/README.md @@ -43,17 +43,17 @@ Build and verify: ```powershell docker build ` --build-arg NAPCAT_BASE_IMAGE=$napcatBaseImageDigest ` - -t kt-napcat-desktop-cn:desktop-cn-v10 ` + -t kt-napcat-desktop-cn:desktop-cn-v16 ` -f .kt-workspace/napcat-desktop-cn-build/ci/napcat-desktop-cn/Dockerfile ` .kt-workspace/napcat-desktop-cn-build -$name = "kt-napcat-v10-verify-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" +$name = "kt-napcat-v16-verify-$([DateTimeOffset]::UtcNow.ToUnixTimeSeconds())" docker run -d --name $name ` --cap-add SYS_ADMIN ` --security-opt apparmor=unconfined ` --security-opt seccomp=unconfined ` -e NAPCAT_REQUIRE_DEVICE_PROFILE=1 ` - kt-napcat-desktop-cn:desktop-cn-v10 + kt-napcat-desktop-cn:desktop-cn-v16 docker exec $name sh /ci/napcat-desktop-cn/verify.sh docker rm -f $name ``` From 8d96e3dc486c332d34b8f4f20d9682cd2e96ba03 Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 29 Jun 2026 12:26:28 +0800 Subject: [PATCH 19/25] =?UTF-8?q?fix:=20=E9=BB=98=E8=AE=A4=E5=90=AF?= =?UTF-8?q?=E7=94=A8NapCat=E5=8F=8D=E6=A3=80=E6=B5=8B=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runtime/napcat-config-writer.service.ts | 24 +++++++++---------- .../napcat/runtime-protocol-profile.spec.ts | 8 +++++++ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts index e80ce4e..903ce69 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-config-writer.service.ts @@ -29,12 +29,12 @@ type OnebotConfig = { type NapcatConfig = { bypass: { - container: false; - hook: false; - js: false; - module: false; - process: false; - window: false; + container: true; + hook: true; + js: true; + module: true; + process: true; + window: true; }; o3HookMode: 0; packetBackend: 'auto'; @@ -68,12 +68,12 @@ export class NapcatConfigWriterService { }; const napcatConfig: NapcatConfig = { bypass: { - container: false, - hook: false, - js: false, - module: false, - process: false, - window: false, + container: true, + hook: true, + js: true, + module: true, + process: true, + window: true, }, o3HookMode: 0, packetBackend: 'auto', diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index 9e63a85..b8cd698 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -253,6 +253,14 @@ describe('NapCat runtime profile generation', () => { reconnectInterval: 5000, reportSelfMessage: false, }); + expect(result.napcatConfig.bypass).toEqual({ + container: true, + hook: true, + js: true, + module: true, + process: true, + window: true, + }); expect(result.napcatConfig.o3HookMode).toBe(0); expect( result.files.find((file) => file.path === 'webui.json')?.content, From 857468785023b3f72ed551ef7bcdda3b48cbfb84 Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 29 Jun 2026 12:58:14 +0800 Subject: [PATCH 20/25] =?UTF-8?q?fix:=20=E5=9B=BA=E5=8C=96NapCat=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6v18=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- README.md | 4 ++-- k8s/prod/api.yaml | 4 ++-- .../application/runtime/napcat-runtime-profile.service.ts | 2 +- test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts | 8 +++++--- .../modules/qqbot/napcat/napcat-runtime-promotion.spec.ts | 8 +++++--- .../modules/qqbot/napcat/runtime-protocol-profile.spec.ts | 2 +- 7 files changed, 17 insertions(+), 13 deletions(-) diff --git a/API.md b/API.md index 20a4c77..5d5e37e 100644 --- a/API.md +++ b/API.md @@ -377,7 +377,7 @@ 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 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v14 起运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径;v15 在扫码登录成功回调中先写入 `QQLoginInfo` 再写登录态,避免 API 读到 `isLogin=true` 但 QQ 号为空的短暂不一致;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v16` / `desktop-cn-v16` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 +NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v14 起运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径;v15 在扫码登录成功回调中先写入 `QQLoginInfo` 再写登录态,避免 API 读到 `isLogin=true` 但 QQ 号为空的短暂不一致;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v18` / `desktop-cn-v18` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadata:upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。 diff --git a/README.md b/README.md index 42e06f7..8e0c3a9 100644 --- a/README.md +++ b/README.md @@ -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/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v16 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v16` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v16` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 +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 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v18 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v18` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v18` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 @@ -178,7 +178,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 并单账号观察。`desktop-cn-v16` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径、扫码登录成功后 API 立即读不到 QQ 号,以及生产 native reset 缺少 `offline()` 时半登录态无法清理的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker,再继续登录流程,同一个更新登录 session 不能反复重启 worker。 +- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v18` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径、扫码登录成功后 API 立即读不到 QQ 号、生产 native reset 缺少 `offline()` 时半登录态无法清理,以及 runtime view native maps 取证假阴性的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker,再继续登录流程,同一个更新登录 session 不能反复重启 worker。 - 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 刷新登录态:只要源容器在线,就保持同一 Docker 容器;若 WebUI 明确返回 QQ 离线,会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker,随后通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;同一个更新登录 session 只允许一次 worker restart,后续状态轮询只能继续 WebUI 登录/二维码刷新,不能反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备;取消扫码会话会在接口返回前把持久化 session 标成 `error/cancelled` 并写完成时间,避免已取消二维码从 DB 恢复为 active pending。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。pending refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 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 profile;Docker 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`。 diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index 33877d3..5f6017b 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -33,9 +33,9 @@ spec: - name: DB_TIMEZONE value: "+08:00" - name: QQBOT_NAPCAT_IMAGE - value: kt-napcat-desktop-cn:desktop-cn-v16 + value: kt-napcat-desktop-cn:desktop-cn-v18 - name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION - value: desktop-cn-v16 + value: desktop-cn-v18 - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index 3e33267..ec3f097 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService { dataDir: input.dataDir, desktopProfileVersion: this.getString( 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', - 'desktop-cn-v16', + 'desktop-cn-v18', ), deviceIdentityId: input.deviceIdentityId, imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index 180c1d3..7cd9fba 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -173,13 +173,15 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).not.toContain('selfInfo?.online !== false'); }); - it('deploys the production API with the verified desktop-cn-v16 runtime profile', () => { + it('deploys the production API with the verified desktop-cn-v18 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-v16'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v18'); expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION'); - expect(manifest).toContain('value: desktop-cn-v16'); + expect(manifest).toContain('value: desktop-cn-v18'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v16'); + expect(manifest).not.toContain('value: desktop-cn-v16'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v15'); expect(manifest).not.toContain('value: desktop-cn-v15'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v8'); diff --git a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts index 90fa1d7..5866bec 100644 --- a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts +++ b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts @@ -30,11 +30,13 @@ describe('NapCat runtime promotion contract', () => { ); }); - it('keeps the verified v16 runtime profile as the K8s manifest default', () => { + it('keeps the verified v18 runtime profile as the K8s manifest default', () => { const manifest = readSource('k8s/prod/api.yaml'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v16'); - expect(manifest).toContain('value: desktop-cn-v16'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v18'); + expect(manifest).toContain('value: desktop-cn-v18'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v16'); + expect(manifest).not.toContain('value: desktop-cn-v16'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v15'); expect(manifest).not.toContain('value: desktop-cn-v15'); }); diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index b8cd698..e690e38 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => { }); expect(profile).toMatchObject({ - desktopProfileVersion: 'desktop-cn-v16', + desktopProfileVersion: 'desktop-cn-v18', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', locale: 'zh_CN.UTF-8', runtimeGid: 1101, From 6ef08f618d5bb3ea10b9fd340d251bfd51d83151 Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 29 Jun 2026 13:22:06 +0800 Subject: [PATCH 21/25] =?UTF-8?q?fix:=20=E5=9B=BA=E5=8C=96NapCat=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6v19=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- README.md | 4 ++-- k8s/prod/api.yaml | 4 ++-- .../application/runtime/napcat-runtime-profile.service.ts | 2 +- test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts | 8 +++++--- .../modules/qqbot/napcat/napcat-runtime-promotion.spec.ts | 8 +++++--- .../modules/qqbot/napcat/runtime-protocol-profile.spec.ts | 2 +- 7 files changed, 17 insertions(+), 13 deletions(-) diff --git a/API.md b/API.md index 5d5e37e..82f8d55 100644 --- a/API.md +++ b/API.md @@ -377,7 +377,7 @@ 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 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v14 起运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径;v15 在扫码登录成功回调中先写入 `QQLoginInfo` 再写登录态,避免 API 读到 `isLogin=true` 但 QQ 号为空的短暂不一致;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v18` / `desktop-cn-v18` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 +NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v14 起运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径;v15 在扫码登录成功回调中先写入 `QQLoginInfo` 再写登录态,避免 API 读到 `isLogin=true` 但 QQ 号为空的短暂不一致;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性;v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q ` 快速登录参数,避免重启后退回无账号扫码。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v19` / `desktop-cn-v19` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadata:upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。 diff --git a/README.md b/README.md index 8e0c3a9..ba2aa8f 100644 --- a/README.md +++ b/README.md @@ -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/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v18 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v18` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v18` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 +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 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v19 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性;v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q ` 快速登录参数,避免重启后退回无账号扫码。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v19` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v19` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 @@ -178,7 +178,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 并单账号观察。`desktop-cn-v18` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径、扫码登录成功后 API 立即读不到 QQ 号、生产 native reset 缺少 `offline()` 时半登录态无法清理,以及 runtime view native maps 取证假阴性的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker,再继续登录流程,同一个更新登录 session 不能反复重启 worker。 +- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v19` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径、扫码登录成功后 API 立即读不到 QQ 号、生产 native reset 缺少 `offline()` 时半登录态无法清理、runtime view native maps 取证假阴性,以及 WebUI `RestartNapCat` 重启 worker 丢失快速登录账号参数的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker,再继续登录流程,同一个更新登录 session 不能反复重启 worker。 - 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 刷新登录态:只要源容器在线,就保持同一 Docker 容器;若 WebUI 明确返回 QQ 离线,会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker,随后通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;同一个更新登录 session 只允许一次 worker restart,后续状态轮询只能继续 WebUI 登录/二维码刷新,不能反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备;取消扫码会话会在接口返回前把持久化 session 标成 `error/cancelled` 并写完成时间,避免已取消二维码从 DB 恢复为 active pending。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。pending refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 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 profile;Docker 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`。 diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index 5f6017b..cfaf5e7 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -33,9 +33,9 @@ spec: - name: DB_TIMEZONE value: "+08:00" - name: QQBOT_NAPCAT_IMAGE - value: kt-napcat-desktop-cn:desktop-cn-v18 + value: kt-napcat-desktop-cn:desktop-cn-v19 - name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION - value: desktop-cn-v18 + value: desktop-cn-v19 - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index ec3f097..ca8f140 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService { dataDir: input.dataDir, desktopProfileVersion: this.getString( 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', - 'desktop-cn-v18', + 'desktop-cn-v19', ), deviceIdentityId: input.deviceIdentityId, imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index 7cd9fba..fed8f81 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -173,13 +173,15 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).not.toContain('selfInfo?.online !== false'); }); - it('deploys the production API with the verified desktop-cn-v18 runtime profile', () => { + it('deploys the production API with the verified desktop-cn-v19 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-v18'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v19'); expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION'); - expect(manifest).toContain('value: desktop-cn-v18'); + expect(manifest).toContain('value: desktop-cn-v19'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v18'); + expect(manifest).not.toContain('value: desktop-cn-v18'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v16'); expect(manifest).not.toContain('value: desktop-cn-v16'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v15'); diff --git a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts index 5866bec..4298095 100644 --- a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts +++ b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts @@ -30,11 +30,13 @@ describe('NapCat runtime promotion contract', () => { ); }); - it('keeps the verified v18 runtime profile as the K8s manifest default', () => { + it('keeps the verified v19 runtime profile as the K8s manifest default', () => { const manifest = readSource('k8s/prod/api.yaml'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v18'); - expect(manifest).toContain('value: desktop-cn-v18'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v19'); + expect(manifest).toContain('value: desktop-cn-v19'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v18'); + expect(manifest).not.toContain('value: desktop-cn-v18'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v16'); expect(manifest).not.toContain('value: desktop-cn-v16'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v15'); diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index e690e38..47a7b6c 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => { }); expect(profile).toMatchObject({ - desktopProfileVersion: 'desktop-cn-v18', + desktopProfileVersion: 'desktop-cn-v19', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', locale: 'zh_CN.UTF-8', runtimeGid: 1101, From 5b0cf2214b77cf20ac8776d61446c9399aebe72f Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 29 Jun 2026 14:47:56 +0800 Subject: [PATCH 22/25] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8DQQBot=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E7=99=BB=E5=BD=95=E5=AF=86=E7=A0=81=E4=BC=9A=E8=AF=9D?= =?UTF-8?q?=E5=A4=8D=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- README.md | 2 +- .../qqbot/core/contract/qqbot.types.ts | 1 + .../login/qqbot-napcat-login.service.ts | 90 ++++++++++++++++++- .../qqbot-napcat-login.service.spec.ts | 64 ++++++++++++- 5 files changed, 153 insertions(+), 6 deletions(-) diff --git a/API.md b/API.md index 82f8d55..057a2d5 100644 --- a/API.md +++ b/API.md @@ -361,7 +361,7 @@ QQBot 运行态包括 NapCat 容器登录、OneBot v11 反向 WebSocket、MQTT 账号保存支持可选 `encryptedLoginPassword`,用于 NapCat 密码登录。前端必须先通过 `/auth/password-public-key` 获取公钥并使用 RSA-OAEP 加密,不传明文 `loginPassword`;后端必须使用显式配置的 `QQBOT_ACCOUNT_SECRET_KEY`(或非默认 `ADMIN_TOKEN_SECRET`)二次加密落库,空值和公开默认值会被拒绝,不在列表/详情中返回。账号列表里的 `connectStatus` 只表示 OneBot 反向 WS;`napcat.oneBotOnline`、`napcat.containerOnline`、`napcat.webuiOnline`、`napcat.qqLoginStatus`、`napcat.qqLoginMessage` 分别表示 OneBot、容器、WebUI 和 QQ 登录态,`webuiOnline=null` 表示本次使用缓存且未重新探测 WebUI;`qqLoginMessage` 只承载真实 QQ 登录态消息,WebUI 配置缺失或请求异常只放在 `lastError`。 -扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending,会话后台再启动远端 Docker 和生成二维码。`CheckLoginStatus.isLogin=true` 只表示 NapCat 登录阳性,新增账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才允许创建和绑定真实 QQ 号;短暂缺号时 `/qqbot/account/scan/status` 保持同一会话 pending 并显示正在读取 QQ 号,等待 `NAPCAT_LOGIN_SELF_ID_WAIT_MS`,不得重建容器、补 env 或从容器元数据猜号。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 推进原有弹窗流程。若 WebUI 明确返回 QQ 离线,API 会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker 以重建 QQCore login service,再继续 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode`;这不是 Docker 容器重建/重启,设备身份、env 和 dataDir 不变,同一个更新登录 session 只消费一次 worker restart 预算,后续轮询继续刷新二维码但不得反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env;已在线的源容器不补 env。快速登录失败后,如果账号保存了登录密码,后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env,也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 会返回原 `sessionId`,不会再次启动 quick/password/二维码准备;取消扫码会话必须在接口返回前把持久化 `napcat_login_session` 落到非 pending 终态并写入完成时间,避免已取消的测试二维码从 DB 恢复成可轮询会话。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。pending refresh 会话如果没有二维码、验证码或新设备挑战,`/qqbot/account/scan/status` 会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试 `RefreshQRcode/GetQQLoginQrcode`,避免 SSE 长时间卡在“二维码生成中”。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 +扫码链路返回 `sessionId`,前端应使用 SSE 查看步骤进度,而不是等待长 HTTP 请求完成;新增账号扫码会先预留容器和临时设备身份后立即返回 pending,会话后台再启动远端 Docker 和生成二维码。`CheckLoginStatus.isLogin=true` 只表示 NapCat 登录阳性,新增账号必须继续等 `GetQQLoginInfo` 返回 `uin/selfId` 后才允许创建和绑定真实 QQ 号;短暂缺号时 `/qqbot/account/scan/status` 保持同一会话 pending 并显示正在读取 QQ 号,等待 `NAPCAT_LOGIN_SELF_ID_WAIT_MS`,不得重建容器、补 env 或从容器元数据猜号。已有账号的更新登录不会通过 Docker 重建、重启或补 env 来刷新 QQ 登录态;如果目标容器仍在线,即使 QQ 账号已离线,也会保持同一容器并通过 NapCat WebUI 推进原有弹窗流程。若 WebUI 明确返回 QQ 离线,API 会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker 以重建 QQCore login service,再继续 `SetQuickLogin`、`PasswordLogin`、`RefreshQRcode` / `GetQQLoginQrcode`;这不是 Docker 容器重建/重启,设备身份、env 和 dataDir 不变,同一个更新登录 session 只消费一次 worker restart 预算,后续轮询继续刷新二维码但不得反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才会创建/重建容器,并在创建时一次性注入 `ACCOUNT` 和必要登录 env;已在线的源容器不补 env。快速登录失败后,如果账号保存了登录密码,后端使用解密后的密码计算 MD5 调用 `/api/QQLogin/PasswordLogin`,不会把密码写回运行态 env,也没有成功后的 env 清理步骤;密码登录结果按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询。准备阶段的扫码会话会持续续期,避免后台登录未完成时前端先判过期。同一账号已有 pending 更新登录会话时,重复调用 `/qqbot/account/scan/refresh` 通常会返回原 `sessionId`,不会再次启动 quick/password/二维码准备;但当这条 pending 会话创建时账号还没有保存登录密码、且会话尚未进入密码验证码或新设备验证上下文,而账号后来通过编辑维护了登录密码时,API 必须退役旧无密码会话并新建 refresh session,重新读取最新密码后进入 `PasswordLogin`。取消扫码会话必须在接口返回前把持久化 `napcat_login_session` 落到非 pending 终态并写入完成时间,避免已取消的测试二维码从 DB 恢复成可轮询会话。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS`(留空使用密码等待窗口加缓冲)后,`/qqbot/account/scan/status` 会自动恢复普通登录态检测,不再永久停留在“正在尝试密码登录”;`/qqbot/account/scan/events` 在进程内事件缓存丢失时会先推送当前会话快照。pending refresh 会话如果没有二维码、验证码或新设备挑战,`/qqbot/account/scan/status` 会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试 `RefreshQRcode/GetQQLoginQrcode`,避免 SSE 长时间卡在“二维码生成中”。密码登录触发 QQ 安全验证时,接口返回的 `captchaUrl` 只用于前端拉起腾讯验证码;前端必须把腾讯验证码返回的 `ticket`、`randstr`、`sid` 连同 `sessionId` 提交到 `/qqbot/account/scan/captcha/submit`,后端再代理到同一 NapCat 容器的 `/api/QQLogin/CaptchaLogin` 继续密码登录第二步。验证码和新设备验证这类真人交互态使用 `NAPCAT_LOGIN_HUMAN_VERIFY_EXPIRE_MS`(默认 15 分钟,且至少不短于普通二维码 TTL)续期;普通登录二维码仍使用 `NAPCAT_LOGIN_QR_EXPIRE_MS`。`/qqbot/account/scan/status` 遇到 NapCat 只返回“需要验证码/继续完成验证/安全验证”但不带 URL 时,会先从当前容器日志提取 `proofWaterUrl`,提取不到则保持验证码处理中而不切到二维码兜底;会话已有 `captchaUrl` 后,同类状态仍保持 `pending` 和原 `captchaUrl`。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。看门狗只做离线巡检、账号错误写入和 `super` 站内信告警,不会触发 quick/password 登录或扫码登录。 密码验证码通过后如果 NapCat 返回 `needNewDevice`,后端不会只把 `jumpUrl` 透给 Admin,而是在同一会话中继续调用 `/api/QQLogin/GetNewDeviceQRCode` 生成新设备验证二维码;`/qqbot/account/scan/status` 后续轮询会代理 `/api/QQLogin/PollNewDeviceQR`,状态映射为 `newDeviceStatus=qr-pending|scanned|confirming|verified|expired|failed`,进入确认态后再调用 `/api/QQLogin/NewDeviceLogin` 并回到密码登录完成检查。扫码会话结果新增 `newDeviceQrcode`、`newDeviceStatus`、`deviceVerifyUrl` 字段;`captchaUrl` 和 `newDeviceQrcode` 分别表示腾讯安全验证码和 QQ 新设备验证二维码,前端必须分开展示。SSE 进度文案包含快速登录、密码登录、验证码、新设备二维码、已扫码、确认中、二维码兜底、登录成功/失败。 diff --git a/README.md b/README.md index ba2aa8f..1375d17 100644 --- a/README.md +++ b/README.md @@ -180,7 +180,7 @@ API 暴露 `GET /health/runtime` 作为本地 smoke、Jenkins/K8s 和 ktWorkflow - 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 并单账号观察。`desktop-cn-v19` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径、扫码登录成功后 API 立即读不到 QQ 号、生产 native reset 缺少 `offline()` 时半登录态无法清理、runtime view native maps 取证假阴性,以及 WebUI `RestartNapCat` 重启 worker 丢失快速登录账号参数的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker,再继续登录流程,同一个更新登录 session 不能反复重启 worker。 - 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 刷新登录态:只要源容器在线,就保持同一 Docker 容器;若 WebUI 明确返回 QQ 离线,会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker,随后通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;同一个更新登录 session 只允许一次 worker restart,后续状态轮询只能继续 WebUI 登录/二维码刷新,不能反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备;取消扫码会话会在接口返回前把持久化 session 标成 `error/cancelled` 并写完成时间,避免已取消二维码从 DB 恢复为 active pending。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。pending refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 QQ 号时,直接通过 WebUI 二维码接口进入扫码兜底,不 reset 登录态。Admin SSE 步骤顺序按实际路径为 `quick-login-*` -> `password-login-*` / `password-login-captcha` / 新设备验证 -> `qrcode/waiting-scan` -> `login-success|login-failed`;SSE 事件缓存因 Pod 重启丢失时,新订阅会收到当前会话快照。 +- NapCat 容器为已知 `selfId` 创建/重建时会一次性注入 `ACCOUNT` 等必要 env;容器重启(崩溃/重启策略/宿主重启)可复用持久化会话,但硬踢 `登录已失效` 仍需人工登录。Admin「更新登录」不通过 Docker 重建、重启或补 env 刷新登录态:只要源容器在线,就保持同一 Docker 容器;若 WebUI 明确返回 QQ 离线,会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker,随后通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;同一个更新登录 session 只允许一次 worker restart,后续状态轮询只能继续 WebUI 登录/二维码刷新,不能反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备;但如果该 pending 会话是在账号维护登录密码前创建、且尚未进入密码/验证码/新设备上下文,后续更新登录必须退役这条无密码会话并新建 refresh session,以重新读取账号表中的最新密码。取消扫码会话会在接口返回前把持久化 session 标成 `error/cancelled` 并写完成时间,避免已取消二维码从 DB 恢复为 active pending。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。pending refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 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 profile;Docker 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`。 - NapCat 新设备验证走同一 scan session:`CaptchaLogin` 返回 `needNewDevice` 后,后端继续调用 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin`,Admin/SSE 分开展示 `captchaUrl`、`newDeviceQrcode`、已扫码、确认中、验证成功、登录成功/失败等中文进度,不把 `jumpUrl` 当作唯一完成入口。 - NapCat 离线看门狗按 `QQBOT_NAPCAT_WATCHDOG_INTERVAL_MS`(默认 `120000`,最小 `30000`,`QQBOT_NAPCAT_WATCHDOG_ENABLED=false` 关闭)定时巡检在线账号,使掉线/被踢无需管理员打开列表页即可及时发现;检测到离线后只写入离线原因并复用 `super` 站内信告警,恢复登录必须由管理员在 Admin 手动触发「更新登录」。 diff --git a/src/modules/qqbot/core/contract/qqbot.types.ts b/src/modules/qqbot/core/contract/qqbot.types.ts index 8de4cb9..7dbe48e 100644 --- a/src/modules/qqbot/core/contract/qqbot.types.ts +++ b/src/modules/qqbot/core/contract/qqbot.types.ts @@ -341,6 +341,7 @@ export type QqbotLoginScanSession = { loginSelfIdMissingSince?: number; lastQrcodeRefreshAt?: number; lastRestartedAt?: number; + loginPasswordAvailable?: boolean; mode: QqbotLoginScanMode; newDeviceBytesToken?: string; newDevicePullQrCodeSig?: unknown; diff --git a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts index 7745bd4..3da00d1 100644 --- a/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts +++ b/src/modules/qqbot/napcat/application/login/qqbot-napcat-login.service.ts @@ -176,8 +176,15 @@ export class QqbotNapcatLoginService { async startRefresh(accountId: string) { const activeSession = this.findActiveRefreshSession(accountId); if (activeSession) { - if (activeSession.qrcode) return this.refreshQrcode(activeSession.id); - return this.toResult(activeSession); + if ( + !(await this.invalidatePasswordlessRefreshSessionWhenPasswordExists( + accountId, + activeSession, + )) + ) { + if (activeSession.qrcode) return this.refreshQrcode(activeSession.id); + return this.toResult(activeSession); + } } const runningTask = this.refreshStartTasks[accountId]; @@ -216,6 +223,7 @@ export class QqbotNapcatLoginService { expectedSelfId: string; forceRelogin: true; hasExistingPrimaryBinding?: boolean; + loginPasswordAvailable?: boolean; loginPassword?: string; mode: 'refresh'; sourceContainerOnline?: boolean; @@ -224,6 +232,7 @@ export class QqbotNapcatLoginService { expectedSelfId: account.selfId, forceRelogin: true, hasExistingPrimaryBinding: container.hasExistingPrimaryBinding, + loginPasswordAvailable: !!this.toolsService.toSecretText(loginPassword), loginPassword, mode: 'refresh', }; @@ -256,6 +265,80 @@ export class QqbotNapcatLoginService { return activeSession; } + /** + * 当账号后续维护了 QQ 登录密码时,废弃旧的无密码更新登录会话。 + * @param accountId - 正在更新登录态的账号 ID,用于重新读取账号表里的最新登录密码密文。 + * @param session - 可能早于密码维护动作创建的 pending 更新登录会话。 + * @returns 旧会话已退役且调用方应重新创建更新登录会话时返回 true。 + */ + private async invalidatePasswordlessRefreshSessionWhenPasswordExists( + accountId: string, + session: QqbotLoginScanSession, + ) { + if (this.hasRefreshPasswordContext(session)) return false; + + const accountService = this.accountService as Partial< + Pick< + QqbotAccountService, + 'findByIdWithNapcatLoginSecret' | 'getNapcatLoginPassword' + > + >; + if ( + !accountService.findByIdWithNapcatLoginSecret || + !accountService.getNapcatLoginPassword + ) { + return false; + } + + const account = + await accountService.findByIdWithNapcatLoginSecret(accountId); + if (!account) return false; + const loginPassword = accountService.getNapcatLoginPassword(account); + if (!this.toolsService.toSecretText(loginPassword)) return false; + + await this.retireRefreshSessionForPasswordReload(session); + return true; + } + + /** + * 判断更新登录会话是否已经携带或进入过密码登录相关上下文。 + * @param session - 待复用的 pending 更新登录会话。 + * @returns 复用该会话不会忽略账号已维护登录密码时返回 true。 + */ + private hasRefreshPasswordContext(session: QqbotLoginScanSession) { + return !!( + session.loginPasswordAvailable || + session.passwordMd5 || + session.captchaUrl || + session.deviceVerifyUrl || + session.newDeviceQrcode || + session.newDeviceStatus + ); + } + + /** + * 仅退役过期的更新登录会话记录,不移除已绑定的 NapCat 容器。 + * @param session - 不应继续提供旧二维码或旧 pending 状态的无密码更新登录会话。 + */ + private async retireRefreshSessionForPasswordReload( + session: QqbotLoginScanSession, + ) { + session.errorMessage = '账号登录密码已更新,重新创建更新登录会话'; + session.preparingRelogin = false; + session.qrcode = undefined; + session.status = 'expired'; + this.persistLoginSession(session); + this.publishScanResultEvent( + session, + 'session-recreated', + 'processing', + session.errorMessage, + ); + await this.loginSessionStore.flushSessionWrites(session.id); + this.loginSessionStore.delete(session.id); + this.cleanupSessionEvents(session.id); + } + /** * 执行 NapCat 登录运行态流程。 * @param sessionId - NapCat ID;定位本次读取、更新、删除或关联的NapCat。 @@ -641,6 +724,7 @@ export class QqbotNapcatLoginService { expectedSelfId?: string; forceRelogin?: boolean; hasExistingPrimaryBinding?: boolean; + loginPasswordAvailable?: boolean; loginPassword?: string; mode: QqbotLoginScanMode; sourceContainerOnline?: boolean; @@ -940,6 +1024,7 @@ export class QqbotNapcatLoginService { container: QqbotNapcatRuntime; expectedSelfId?: string; mode: QqbotLoginScanMode; + loginPasswordAvailable?: boolean; preparingContainer?: boolean; preparingRelogin?: boolean; qrcode?: string; @@ -956,6 +1041,7 @@ export class QqbotNapcatLoginService { expectedSelfId: input.expectedSelfId, expiresAt: now + this.getSessionTtlMs(), id: randomUUID(), + loginPasswordAvailable: input.loginPasswordAvailable, mode: input.mode, preparingContainer: input.preparingContainer, preparingRelogin: input.preparingRelogin, diff --git a/test/qqbot/account/qqbot-napcat-login.service.spec.ts b/test/qqbot/account/qqbot-napcat-login.service.spec.ts index 41eabe9..118d058 100644 --- a/test/qqbot/account/qqbot-napcat-login.service.spec.ts +++ b/test/qqbot/account/qqbot-napcat-login.service.spec.ts @@ -187,13 +187,14 @@ describe('QqbotNapcatLoginService', () => { 'qq-password', ); expect(startScan).toHaveBeenCalledWith( - { + expect.objectContaining({ accountId: 'account-1', expectedSelfId: '10001', forceRelogin: true, + loginPasswordAvailable: true, loginPassword: 'qq-password', mode: 'refresh', - }, + }), existingContainer, ); }); @@ -282,6 +283,65 @@ describe('QqbotNapcatLoginService', () => { expect(prepareReloginQrcode).toHaveBeenCalledTimes(1); }); + it('starts a new refresh session after password is maintained on an account with an old pending session', async () => { + const account = { + id: 'account-1', + selfId: '10001', + }; + const existingContainer = { + baseUrl: 'http://127.0.0.1:6103/', + hasExistingPrimaryBinding: true, + id: 'container-current', + name: 'napcat-10001', + }; + const accountService = { + findByIdWithNapcatLoginSecret: jest.fn().mockResolvedValue(account), + getNapcatLoginPassword: jest.fn().mockReturnValue('qq-password'), + }; + const containerService = { + prepareAccountContainer: jest.fn().mockResolvedValue(existingContainer), + }; + const refreshService = new QqbotNapcatLoginService( + { get: jest.fn() } as unknown as ConfigService, + accountService as unknown as QqbotAccountService, + containerService as unknown as QqbotNapcatContainerService, + new ToolsService(), + ); + (refreshService as any).sessions.set('session-without-password', { + accountId: 'account-1', + containerId: 'container-current', + containerName: 'napcat-10001', + createdAt: Date.now(), + errorMessage: '密码登录未完成:未配置 QQ 登录密码,开始生成二维码', + expiresAt: Date.now() + 60_000, + id: 'session-without-password', + mode: 'refresh', + status: 'pending', + webuiPort: 6103, + }); + const prepareReloginQrcode = jest + .spyOn(refreshService as any, 'prepareReloginQrcode') + .mockResolvedValue(undefined); + + const result = await refreshService.startRefresh('account-1'); + + expect(result.sessionId).not.toBe('session-without-password'); + expect(accountService.findByIdWithNapcatLoginSecret).toHaveBeenCalledWith( + 'account-1', + ); + expect(accountService.getNapcatLoginPassword).toHaveBeenCalledWith(account); + expect(prepareReloginQrcode).toHaveBeenCalledWith( + expect.objectContaining({ + accountId: 'account-1', + expectedSelfId: '10001', + status: 'pending', + }), + existingContainer, + 'qq-password', + existingContainer.hasExistingPrimaryBinding, + ); + }); + it('does not return a stale qrcode when reusing an active refresh session', async () => { const refreshService = new QqbotNapcatLoginService( { From 828a321bdbe8b073a2b4320e565e9224f8aa0b7f Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 29 Jun 2026 15:49:43 +0800 Subject: [PATCH 23/25] =?UTF-8?q?fix:=20=E4=BF=9D=E6=8A=A4NapCat=E9=A2=84?= =?UTF-8?q?=E5=86=99=E5=8F=8D=E6=A3=80=E6=B5=8B=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 +- README.md | 4 +-- .../entrypoint-device-profile.patch.sh | 23 +++++++++++++ ci/napcat-desktop-cn/verify.sh | 3 ++ k8s/prod/api.yaml | 4 +-- .../runtime/napcat-runtime-profile.service.ts | 2 +- .../napcat/napcat-desktop-cn-image.spec.ts | 32 ++++++++++++++++--- .../napcat/napcat-runtime-promotion.spec.ts | 8 +++-- .../napcat/runtime-protocol-profile.spec.ts | 2 +- 9 files changed, 66 insertions(+), 14 deletions(-) diff --git a/API.md b/API.md index 057a2d5..82dcab3 100644 --- a/API.md +++ b/API.md @@ -377,7 +377,7 @@ 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 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v14 起运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径;v15 在扫码登录成功回调中先写入 `QQLoginInfo` 再写登录态,避免 API 读到 `isLogin=true` 但 QQ 号为空的短暂不一致;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性;v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q ` 快速登录参数,避免重启后退回无账号扫码。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v19` / `desktop-cn-v19` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 +NapCat Chinese Desktop Runtime 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后重置 native login service 再请求二维码;同一次踢下线事件只消费一次 reset,明确二维码过期或扫码确认窗口失效的 QR session failure 会自动换码,其他非自动重试 QR failure 会标记下次 WebUI 登录动作先重置 native login service。v14 起运行时会为 QQ/NapCat/Xvfb 长期进程做 PID 级 `/proc//mountinfo` 遮蔽,避免 QQCore 通过 `/proc/self/mountinfo` 看到 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances`、`btrfs`、`/dev/mapper/trim` 等宿主路径;v15 在扫码登录成功回调中先写入 `QQLoginInfo` 再写登录态,避免 API 读到 `isLogin=true` 但 QQ 号为空的短暂不一致;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性;v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q ` 快速登录参数,避免重启后退回无账号扫码;v20 保护 API 预写的 `/app/napcat/config`,避免上游首次解包 `NapCat.Shell/*` 覆盖 `bypass.*=true` 与 `o3HookMode=0`。构建前必须运行 `scripts/napcat-desktop-cn-stage-build.mjs` 生成 Docker build context;生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:*` digest。生产 K8s manifest 保留 `kt-napcat-desktop-cn:desktop-cn-v20` / `desktop-cn-v20` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 与 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 只有非空时才覆盖 API Deployment env,空值会保持 manifest/default env。运行时回滚应重新运行 Jenkins 并填入上一版镜像 digest/profile,或清空两个 override 后重新部署回 manifest 默认值。 API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,且 `fork-artifact.json` 必须包含完整 marker metadata:upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。NapCat base image 在 release evidence 中必须 pin 到 digest。API Jenkins 只做显式参数推广,不负责自动合并上游、自动构建运行时镜像或在 override 为空时隐式改写 NapCat env。 diff --git a/README.md b/README.md index 1375d17..e0b30aa 100644 --- a/README.md +++ b/README.md @@ -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/MAC,NapCat/OneBot 配置 hash,KT `zh_CN.UTF-8` 中国桌面派生镜像资产,只读 `/qqbot/napcat/runtime/detail` 证据接口,watchdog 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v19 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性;v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q ` 快速登录参数,避免重启后退回无账号扫码。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v19` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v19` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 +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 离线巡检告警,以及 Admin 账号页“运行态”抽屉;不绕过 QQ/Tencent 验证码、不修改 QQ/NTQQ 签名协议、不启用 privileged/host network,也不做账号级每小时/每日累计发送预算。NapCat Chinese Desktop Runtime v20 使用 KT `NapCatQQ` fork 源码构建出的 `NapCat.Shell` artifact,并在 QQ `KickedOffLine` 后标记 native login service stale;API 在源 Docker 容器在线但 WebUI 明确 QQ 离线时会同容器调用 `RestartNapCat` 重启 NapCat worker,重建 QQCore login service 后再推进 quick/password/qrcode,不做 Docker 重建、补 env 或设备身份迁移,且同一个更新登录 session 只消费一次 worker restart 预算;v14 起还会对 QQ/NapCat/Xvfb 长期进程的 `/proc//mountinfo` 做 PID 级遮蔽,防止 `overlay`、`/vol1/docker`、`docker-init`、`/docker/containers`、`napcat-instances` 等宿主路径泄露;v15 修复扫码成功时 `QQLoginInfo` 晚于登录态写入造成的 QQ 号回读空窗;v16 在 native reset 缺少 `offline()` 时改用 `destroy()` 硬重置半登录服务,并让镜像 verify 等待 mountinfo guard 收敛;v17/v18 增加 WebUI 鉴权的 `/api/Debug/RuntimeViewProbe` 同进程诊断并修正 native maps 截断导致的 hook 证据假阴性;v19 保留 WebUI `RestartNapCat` 重启 worker 时的 `-q ` 快速登录参数,避免重启后退回无账号扫码;v20 保护 API 预写的 `/app/napcat/config`,避免上游首次解包 `NapCat.Shell/*` 覆盖 `bypass.*=true` 与 `o3HookMode=0`。镜像必须先用 `scripts/napcat-desktop-cn-stage-build.mjs` staged build context,生产 `QQBOT_NAPCAT_IMAGE` 应指向验证过的 `kt-napcat-desktop-cn:desktop-cn-v20` digest。`k8s/prod/api.yaml` 保留 `desktop-cn-v20` 稳定默认值;Jenkins `QQBOT_NAPCAT_IMAGE_OVERRIDE` 和 `QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE` 仅在填写时通过 `kubectl set env` 推广已验证运行时镜像/profile,空值会继续使用 manifest/default env。回滚时重新运行 Jenkins 并填入上一版 digest/profile,或清空两个 override 后重新部署 manifest 默认值。 运行时发布时,API 仓库不提交 `NapCat.Shell.zip`;生产镜像必须从 staged context 构建,`fork-artifact.json` 必须带完整 marker metadata,包括 upstream release tag/commit、fork commit、base image digest、Jenkins URL 和 artifact hashes。release evidence 里的 NapCat base image 必须用 digest pin。API Jenkins 只消费人工确认后的运行时推广参数,不自动合并上游、不自动构建隐藏镜像,也不在 override 为空时覆盖 K8s manifest 中的默认 env。 @@ -178,7 +178,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 并单账号观察。`desktop-cn-v19` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径、扫码登录成功后 API 立即读不到 QQ 号、生产 native reset 缺少 `offline()` 时半登录态无法清理、runtime view native maps 取证假阴性,以及 WebUI `RestartNapCat` 重启 worker 丢失快速登录账号参数的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker,再继续登录流程,同一个更新登录 session 不能反复重启 worker。 +- NapCat 托管容器必须显式配置 `QQBOT_NAPCAT_IMAGE`,不要依赖 `latest` 默认镜像;生产切换镜像前先 pin 明确版本或 digest 并单账号观察。`desktop-cn-v20` 镜像从 KT `NapCatQQ` fork 的 source-built `NapCat.Shell` 构建,不再在镜像内对上游 bundle 做字符串 patch,并修复非自动重试 QR failure 后下次 WebUI 登录动作不重置、QQCore 通过进程级 mountinfo 探针看到 Docker/宿主路径、扫码登录成功后 API 立即读不到 QQ 号、生产 native reset 缺少 `offline()` 时半登录态无法清理、runtime view native maps 取证假阴性、WebUI `RestartNapCat` 重启 worker 丢失快速登录账号参数,以及首次解包覆盖 API 预写 NapCat config 导致 bypass 开关回落默认关闭的问题。踢下线后的半登录态不能只靠旧 native reset 兜底;源 Docker 容器在线时 API 会先同容器 `RestartNapCat` 重建 NapCat worker,再继续登录流程,同一个更新登录 session 不能反复重启 worker。 - 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 刷新登录态:只要源容器在线,就保持同一 Docker 容器;若 WebUI 明确返回 QQ 离线,会先调用同容器 `/api/QQLogin/RestartNapCat` 重启 NapCat worker,随后通过 NapCat WebUI `SetQuickLogin -> PasswordLogin -> RefreshQRcode/GetQQLoginQrcode` 推进原弹窗流程;同一个更新登录 session 只允许一次 worker restart,后续状态轮询只能继续 WebUI 登录/二维码刷新,不能反复重启 worker。只有 Docker 容器离线或缺失时,容器准备阶段才创建/重建并一次性注入 env。快速登录失败后,如果账号保存了登录密码,后端使用解密密码计算 MD5 调 `/api/QQLogin/PasswordLogin`,不会把密码写入运行态 env,也没有成功后的 env 清理步骤;密码登录按 `QQBOT_NAPCAT_PASSWORD_LOGIN_WAIT_MS` / `QQBOT_NAPCAT_LOGIN_POLL_INTERVAL_MS` 轮询结果。准备中的扫码会话会续期,重复调用更新登录会复用同一 pending `sessionId`,不会再次启动 quick/password/二维码准备;但如果该 pending 会话是在账号维护登录密码前创建、且尚未进入密码/验证码/新设备上下文,后续更新登录必须退役这条无密码会话并新建 refresh session,以重新读取账号表中的最新密码。取消扫码会话会在接口返回前把持久化 session 标成 `error/cancelled` 并写完成时间,避免已取消二维码从 DB 恢复为 active pending。若 API Pod 在准备阶段重启,持久化的 `preparingRelogin` 超过 `QQBOT_NAPCAT_RELOGIN_PREPARING_STALE_MS` 后会自动恢复为普通状态检测。pending refresh 会话没有二维码、验证码或新设备挑战时,状态轮询会按 `NAPCAT_LOGIN_QR_AUTO_REFRESH_COOLDOWN_MS` 冷却在同一容器自动重试二维码刷新,避免 SSE 长时间停在生成中。验证码和新设备验证保持同一会话 pending:腾讯验证码结果 `ticket`/`randstr`/`sid` 通过 `/qqbot/account/scan/captcha/submit` 回交到同一容器的 `/api/QQLogin/CaptchaLogin`;状态轮询遇到验证码文案但缺少 URL 时会先从当前容器日志恢复 `proofWaterUrl`,没有 URL 也保持验证码处理中而不切到二维码兜底。扫码或密码链路拿到 NapCat 登录阳性但 QQ 号暂不可读时,会按 `NAPCAT_LOGIN_SELF_ID_WAIT_MS` 保持 pending,超过窗口才失败。密码登录仍失败、验证码未完成、离线、账号不匹配或缺少 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 profile;Docker 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`。 diff --git a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh index fb7b4e3..bc7afd0 100644 --- a/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh +++ b/ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh @@ -3,6 +3,29 @@ set -eu ENTRYPOINT=/app/entrypoint.sh +if grep -q 'KT device profile patch defaults' "$ENTRYPOINT" \ + && grep -q 'KT preserve mounted NapCat config' "$ENTRYPOINT"; then + exit 0 +fi + +if ! grep -q 'KT preserve mounted NapCat config' "$ENTRYPOINT"; then + grep -Fq 'cp -rf NapCat.Shell/* napcat/' "$ENTRYPOINT" + awk ' + /cp -rf NapCat\.Shell\/\* napcat\// { + print " # KT preserve mounted NapCat config." + print " find NapCat.Shell -mindepth 1 -maxdepth 1 ! -name config -exec cp -rf {} napcat/ \\;" + print " if [ ! -f \"napcat/config/napcat.json\" ]; then" + print " mkdir -p napcat/config" + print " cp -rf NapCat.Shell/config/* napcat/config/" + print " fi" + next + } + { print } + ' "$ENTRYPOINT" > "$ENTRYPOINT.tmp" + mv "$ENTRYPOINT.tmp" "$ENTRYPOINT" + chmod +x "$ENTRYPOINT" +fi + if grep -q 'KT device profile patch defaults' "$ENTRYPOINT"; then exit 0 fi diff --git a/ci/napcat-desktop-cn/verify.sh b/ci/napcat-desktop-cn/verify.sh index d99f8eb..d4aba09 100644 --- a/ci/napcat-desktop-cn/verify.sh +++ b/ci/napcat-desktop-cn/verify.sh @@ -60,6 +60,9 @@ grep -q '/sys/class/dmi/id/product_name' /app/entrypoint.sh grep -q '/sys/class/dmi/id/bios_vendor' /app/entrypoint.sh grep -q '/sys/class/dmi/id/bios_version' /app/entrypoint.sh grep -q '/etc/hosts' /app/entrypoint.sh +grep -q 'KT preserve mounted NapCat config' /app/entrypoint.sh +grep -q 'find NapCat.Shell -mindepth 1 -maxdepth 1 ! -name config' /app/entrypoint.sh +! grep -q 'cp -rf NapCat.Shell/\* napcat/' /app/entrypoint.sh MOUNTINFO_HOST_LEAK_PATTERN='overlay|/vol1/docker|docker-init|/docker/containers|napcat-instances|btrfs|/dev/mapper/trim' MOUNTINFO_GUARD_TIMEOUT_SECONDS="${MOUNTINFO_GUARD_TIMEOUT_SECONDS:-12}" diff --git a/k8s/prod/api.yaml b/k8s/prod/api.yaml index cfaf5e7..6f3625f 100644 --- a/k8s/prod/api.yaml +++ b/k8s/prod/api.yaml @@ -33,9 +33,9 @@ spec: - name: DB_TIMEZONE value: "+08:00" - name: QQBOT_NAPCAT_IMAGE - value: kt-napcat-desktop-cn:desktop-cn-v19 + value: kt-napcat-desktop-cn:desktop-cn-v20 - name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION - value: desktop-cn-v19 + value: desktop-cn-v20 - name: QQBOT_NAPCAT_SSH_KEY_PATH value: /app/secrets/napcat-ssh/id_rsa - name: QQBOT_PLUGIN_TASK_QUEUE_REDIS_PREFIX diff --git a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts index ca8f140..b771d82 100644 --- a/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts +++ b/src/modules/qqbot/napcat/application/runtime/napcat-runtime-profile.service.ts @@ -71,7 +71,7 @@ export class NapcatRuntimeProfileService { dataDir: input.dataDir, desktopProfileVersion: this.getString( 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION', - 'desktop-cn-v19', + 'desktop-cn-v20', ), deviceIdentityId: input.deviceIdentityId, imageRef: this.getString('QQBOT_NAPCAT_IMAGE', ''), diff --git a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts index fed8f81..bddb5b1 100644 --- a/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts +++ b/test/modules/qqbot/napcat/napcat-desktop-cn-image.spec.ts @@ -122,6 +122,28 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(source).toContain('proc-devices-host-leak'); }); + it('preserves API-written NapCat config during the first shell install', () => { + const entrypointPatch = readSource( + 'ci/napcat-desktop-cn/entrypoint-device-profile.patch.sh', + ); + const verify = readSource('ci/napcat-desktop-cn/verify.sh'); + + expect(entrypointPatch).toContain('KT preserve mounted NapCat config'); + expect(entrypointPatch).toContain( + "find NapCat.Shell -mindepth 1 -maxdepth 1 ! -name config", + ); + expect(entrypointPatch).toContain( + 'if [ ! -f \\"napcat/config/napcat.json\\" ]; then', + ); + expect(entrypointPatch).toContain( + 'cp -rf NapCat.Shell/config/* napcat/config/', + ); + expect(verify).toContain('KT preserve mounted NapCat config'); + expect(verify).toContain( + "! grep -q 'cp -rf NapCat.Shell/\\* napcat/' /app/entrypoint.sh", + ); + }); + it('stages source-built NapCat Shell artifacts for Docker build context', () => { const script = readSource('scripts/napcat-desktop-cn-stage-build.mjs'); @@ -173,13 +195,15 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(verify).not.toContain('selfInfo?.online !== false'); }); - it('deploys the production API with the verified desktop-cn-v19 runtime profile', () => { + it('deploys the production API with the verified desktop-cn-v20 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-v19'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v20'); expect(manifest).toContain('name: QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION'); - expect(manifest).toContain('value: desktop-cn-v19'); + expect(manifest).toContain('value: desktop-cn-v20'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v19'); + expect(manifest).not.toContain('value: desktop-cn-v19'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v18'); expect(manifest).not.toContain('value: desktop-cn-v18'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v16'); @@ -190,6 +214,6 @@ describe('NapCat Chinese Desktop Runtime image assets', () => { expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v7'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v4'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v3'); - expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v2'); + expect(manifest).not.toMatch(/kt-napcat-desktop-cn:desktop-cn-v2(?!\d)/); }); }); diff --git a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts index 4298095..09c0b1d 100644 --- a/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts +++ b/test/modules/qqbot/napcat/napcat-runtime-promotion.spec.ts @@ -30,11 +30,13 @@ describe('NapCat runtime promotion contract', () => { ); }); - it('keeps the verified v19 runtime profile as the K8s manifest default', () => { + it('keeps the verified v20 runtime profile as the K8s manifest default', () => { const manifest = readSource('k8s/prod/api.yaml'); - expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v19'); - expect(manifest).toContain('value: desktop-cn-v19'); + expect(manifest).toContain('value: kt-napcat-desktop-cn:desktop-cn-v20'); + expect(manifest).toContain('value: desktop-cn-v20'); + expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v19'); + expect(manifest).not.toContain('value: desktop-cn-v19'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v18'); expect(manifest).not.toContain('value: desktop-cn-v18'); expect(manifest).not.toContain('kt-napcat-desktop-cn:desktop-cn-v16'); diff --git a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts index 47a7b6c..68df625 100644 --- a/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts +++ b/test/modules/qqbot/napcat/runtime-protocol-profile.spec.ts @@ -123,7 +123,7 @@ describe('NapCat runtime profile generation', () => { }); expect(profile).toMatchObject({ - desktopProfileVersion: 'desktop-cn-v19', + desktopProfileVersion: 'desktop-cn-v20', imageRef: 'kt-napcat-desktop-cn@sha256:profiledigest', locale: 'zh_CN.UTF-8', runtimeGid: 1101, From 7056663fe08fb7041034d27f682a4a7332a1afa2 Mon Sep 17 00:00:00 2001 From: sunlei Date: Mon, 29 Jun 2026 17:01:20 +0800 Subject: [PATCH 24/25] =?UTF-8?q?feat:=20=E5=AF=B9=E9=BD=90=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=91=98=E8=8F=9C=E5=8D=95=E6=8E=92=E5=BA=8F=E4=B8=8E?= =?UTF-8?q?=E9=BB=98=E8=AE=A4=E9=A6=96=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- API.md | 2 + sql/fix-admin-menu-meta.sql | 2 - sql/refactor-v3/01-seed-core.sql | 20 +++--- sql/vben-admin-init.sql | 17 ++--- .../admin/identity/menu/admin-menu.service.ts | 2 + .../admin/identity/user/admin-user.service.ts | 14 +++- test/admin/admin-menu.service.spec.ts | 69 ++++++++++++++++++ test/admin/admin-user-avatar-sql.spec.ts | 17 +++++ test/admin/admin-user-profile.service.spec.ts | 71 ++++++++++++++++++- 9 files changed, 184 insertions(+), 30 deletions(-) diff --git a/API.md b/API.md index 82dcab3..d3dd9fc 100644 --- a/API.md +++ b/API.md @@ -149,6 +149,8 @@ QQBot 插件 worker 队列依赖 Redis。K8s 生产清单提供内部 Redis Serv | `PUT` | `/system/user/:id` | 更新用户 | | `DELETE` | `/system/user/:id` | 删除用户 | +系统菜单实体包含 `sort` 字段;菜单树输出按 `meta.order` 优先,其次按 `sort` 升序排列。Admin 菜单管理页面维护 `sort`,不要把普通菜单排序写进隐藏的 route meta。 + ### Dict | 方法 | 路径 | 说明 | diff --git a/sql/fix-admin-menu-meta.sql b/sql/fix-admin-menu-meta.sql index db12944..533c263 100644 --- a/sql/fix-admin-menu-meta.sql +++ b/sql/fix-admin-menu-meta.sql @@ -7,7 +7,6 @@ UPDATE `admin_menu` SET `meta` = CASE `name` WHEN 'Dashboard' THEN '{"order":-1,"title":"page.dashboard.title"}' WHEN 'Analytics' THEN '{"affixTab":true,"title":"page.dashboard.analytics"}' - WHEN 'Workspace' THEN '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}' WHEN 'System' THEN '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}' WHEN 'SystemRole' THEN '{"icon":"carbon:user-role","title":"system.role.title"}' WHEN 'SystemRoleCreate' THEN '{"title":"common.create"}' @@ -34,7 +33,6 @@ END WHERE `name` IN ( 'Dashboard', 'Analytics', - 'Workspace', 'System', 'SystemRole', 'SystemRoleCreate', diff --git a/sql/refactor-v3/01-seed-core.sql b/sql/refactor-v3/01-seed-core.sql index fa5fd62..f731a84 100644 --- a/sql/refactor-v3/01-seed-core.sql +++ b/sql/refactor-v3/01-seed-core.sql @@ -47,16 +47,16 @@ INSERT INTO admin_user ( status ) VALUES ( 2041700000000000002, - 'admin', + 'kwitsukasa', '123456', - 'Admin', + 'KwiTsukasa', '', 2041700000000200001, - '/workspace', + '/analytics', 'Asia/Shanghai', 1 ) ON DUPLICATE KEY UPDATE - password = VALUES(password), + username = VALUES(username), real_name = VALUES(real_name), avatar = VALUES(avatar), dept_id = VALUES(dept_id), @@ -84,7 +84,7 @@ INSERT INTO admin_menu ( 'Dashboard', '/dashboard', NULL, - '/workspace', + '/analytics', NULL, 'catalog', '{"order":-1,"title":"page.dashboard.title"}', @@ -92,15 +92,15 @@ INSERT INTO admin_menu ( 0 ), ( - 2041700000000100102, + 2041700000000100101, 2041700000000100001, - 'Workspace', - '/workspace', - '/dashboard/workspace/index', + 'Analytics', + '/analytics', + '/dashboard/analytics/index', NULL, NULL, 'menu', - '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}', + '{"affixTab":true,"title":"page.dashboard.analytics"}', 1, 0 ), diff --git a/sql/vben-admin-init.sql b/sql/vben-admin-init.sql index 4a9e559..07b1693 100644 --- a/sql/vben-admin-init.sql +++ b/sql/vben-admin-init.sql @@ -210,7 +210,6 @@ INSERT INTO `admin_menu` (`id`, `pid`, `name`, `path`, `component`, `redirect`, VALUES (2041700000000100001, 0, 'Dashboard', '/dashboard', NULL, '/analytics', NULL, 'catalog', '{"order":-1,"title":"page.dashboard.title"}', 1, 0), (2041700000000100101, 2041700000000100001, 'Analytics', '/analytics', '/dashboard/analytics/index', NULL, NULL, 'menu', '{"affixTab":true,"title":"page.dashboard.analytics"}', 1, 0), - (2041700000000100102, 2041700000000100001, 'Workspace', '/workspace', '/dashboard/workspace/index', NULL, NULL, 'menu', '{"icon":"carbon:workspace","title":"page.dashboard.workspace"}', 1, 0), (2041700000000100002, 0, 'System', '/system', NULL, NULL, NULL, 'catalog', '{"badge":"new","badgeType":"normal","badgeVariants":"primary","icon":"carbon:settings","order":9997,"title":"system.title"}', 1, 9997), (2041700000000100199, 2041700000000100002, 'SystemUser', '/system/user', '/system/user/list', NULL, 'System:User:List', 'menu', '{"icon":"carbon:user-profile","title":"system.user.title"}', 1, 0), (2041700000000120191, 2041700000000100199, 'SystemUserCreate', NULL, NULL, NULL, 'System:User:Create', 'button', '{"title":"common.create"}', 1, 0), @@ -277,7 +276,7 @@ ON DUPLICATE KEY UPDATE INSERT INTO `admin_role` (`id`, `role_code`, `name`, `remark`, `status`) VALUES (2041700000000010001, 'super', '超级管理员', '拥有所有后台权限', 1), - (2041700000000010002, 'admin', '管理员', '拥有系统管理与工作台权限', 1), + (2041700000000010002, 'admin', '管理员', '拥有系统管理与环境总览权限', 1), (2041700000000010003, 'user', '普通用户', '仅拥有基础查看权限', 1) ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), @@ -287,11 +286,9 @@ ON DUPLICATE KEY UPDATE INSERT INTO `admin_user` (`id`, `username`, `password`, `real_name`, `avatar`, `dept_id`, `home_path`, `timezone`, `status`) VALUES - (2041700000000000001, 'vben', '123456', 'Vben', '', 2041700000000200002, '/workspace', 'Asia/Shanghai', 1), - (2041700000000000002, 'admin', '123456', 'Admin', '', 2041700000000200001, '/workspace', 'Asia/Shanghai', 1), - (2041700000000000003, 'jack', '123456', 'Jack', '', 2041700000000200003, '/analytics', 'Asia/Shanghai', 1) + (2041700000000000002, 'kwitsukasa', '123456', 'KwiTsukasa', '', 2041700000000200001, '/analytics', 'Asia/Shanghai', 1) ON DUPLICATE KEY UPDATE - `password` = VALUES(`password`), + `username` = VALUES(`username`), `real_name` = VALUES(`real_name`), `dept_id` = VALUES(`dept_id`), `home_path` = VALUES(`home_path`), @@ -331,16 +328,12 @@ ON DUPLICATE KEY UPDATE DELETE FROM `admin_user_role` WHERE `user_id` IN ( - 2041700000000000001, - 2041700000000000002, - 2041700000000000003 + 2041700000000000002 ); INSERT INTO `admin_user_role` (`user_id`, `role_id`) VALUES - (2041700000000000001, 2041700000000010001), - (2041700000000000002, 2041700000000010002), - (2041700000000000003, 2041700000000010003); + (2041700000000000002, 2041700000000010002); DELETE FROM `admin_role_menu` WHERE `role_id` IN ( diff --git a/src/modules/admin/identity/menu/admin-menu.service.ts b/src/modules/admin/identity/menu/admin-menu.service.ts index c0c6826..07fdf8b 100644 --- a/src/modules/admin/identity/menu/admin-menu.service.ts +++ b/src/modules/admin/identity/menu/admin-menu.service.ts @@ -210,6 +210,7 @@ export class AdminMenuService { pid: data.pid || '0', redirect: data.redirect || null, status: data.status ?? 1, + sort: Number(data.sort ?? 0), type: data.type || 'menu', }; if (includeEmptyMeta || Object.keys(meta).length > 0) { @@ -321,6 +322,7 @@ export class AdminMenuService { pid: menu.pid || '0', redirect: menu.redirect, status: menu.status, + sort: menu.sort, type: menu.type, } as any; diff --git a/src/modules/admin/identity/user/admin-user.service.ts b/src/modules/admin/identity/user/admin-user.service.ts index 3056f81..0d8f014 100644 --- a/src/modules/admin/identity/user/admin-user.service.ts +++ b/src/modules/admin/identity/user/admin-user.service.ts @@ -10,6 +10,9 @@ import type { AdminUserListQuery, } from '../../contract/admin.types'; +const BUILTIN_ADMIN_USER_ID = '2041700000000000002'; +const DEFAULT_ADMIN_HOME_PATH = '/analytics'; + @Injectable() export class AdminUserService { /** @@ -97,7 +100,7 @@ export class AdminUserService { const user = this.userRepository.create({ deptId: data.deptId || null, - homePath: data.homePath || '/workspace', + homePath: data.homePath || DEFAULT_ADMIN_HOME_PATH, password: data.password || '123456', realName: data.realName, status: data.status ?? 1, @@ -156,7 +159,11 @@ export class AdminUserService { if (id === currentUserId) { throwVbenError('不能删除当前登录用户', HttpStatus.BAD_REQUEST); } - if (user.username === 'admin') { + if ( + user.id === BUILTIN_ADMIN_USER_ID || + user.username === 'admin' || + user.username === 'kwitsukasa' + ) { throwVbenError('不能删除内置管理员账号', HttpStatus.BAD_REQUEST); } @@ -183,7 +190,8 @@ export class AdminUserService { user.realName = realName; } if (data.homePath !== undefined) { - user.homePath = String(data.homePath || '').trim() || '/workspace'; + user.homePath = + String(data.homePath || '').trim() || DEFAULT_ADMIN_HOME_PATH; } if (data.avatar !== undefined) { user.avatar = String(data.avatar || '').trim(); diff --git a/test/admin/admin-menu.service.spec.ts b/test/admin/admin-menu.service.spec.ts index e02e410..59161a5 100644 --- a/test/admin/admin-menu.service.spec.ts +++ b/test/admin/admin-menu.service.spec.ts @@ -65,6 +65,75 @@ describe('AdminMenuService', () => { expect.arrayContaining(['Blog:Theme:List', 'Blog:Theme:Import']), ); }); + + it('returns and orders menus by the persisted sort field', async () => { + const service = new AdminMenuService({ + find: jest.fn(async () => [ + createMenu({ + id: '2', + meta: { title: 'Second' }, + name: 'Second', + path: '/second', + sort: 20, + type: 'menu', + }), + createMenu({ + id: '1', + meta: { title: 'First' }, + name: 'First', + path: '/first', + sort: 10, + type: 'menu', + }), + ]), + } as any); + const user = { + roles: [ + { + isDeleted: false, + roleCode: 'super', + status: 1, + }, + ], + } as AdminUser; + + await expect(service.getRouteMenus(user)).resolves.toEqual([ + expect.objectContaining({ name: 'First', sort: 10 }), + expect.objectContaining({ name: 'Second', sort: 20 }), + ]); + }); + + it('persists menu sort values from create and update inputs', async () => { + const repository = { + create: jest.fn((input) => input), + save: jest.fn(), + update: jest.fn(), + }; + const service = new AdminMenuService(repository as any); + + await service.createMenu({ + name: 'SortedMenu', + path: '/sorted', + sort: 8, + type: 'menu', + } as any); + await service.updateMenu('menu-1', { + name: 'SortedMenu', + path: '/sorted', + sort: 9, + type: 'menu', + } as any); + + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ sort: 8 }), + ); + expect(repository.update).toHaveBeenCalledWith( + { + id: 'menu-1', + }, + expect.objectContaining({ sort: 9 }), + ); + }); }); /** diff --git a/test/admin/admin-user-avatar-sql.spec.ts b/test/admin/admin-user-avatar-sql.spec.ts index 887581b..d483b15 100644 --- a/test/admin/admin-user-avatar-sql.spec.ts +++ b/test/admin/admin-user-avatar-sql.spec.ts @@ -10,6 +10,10 @@ describe('admin user avatar sql', () => { join(process.cwd(), 'sql/vben-admin-init.sql'), 'utf8', ); + const refactorSeedSql = readFileSync( + join(process.cwd(), 'sql/refactor-v3/01-seed-core.sql'), + 'utf8', + ); it('adds avatar column for existing databases', () => { expect(avatarSql).toContain("COLUMN_NAME = 'avatar'"); @@ -25,4 +29,17 @@ describe('admin user avatar sql', () => { ); expect(initSql).not.toContain('`avatar` = VALUES(`avatar`)'); }); + + it('seeds the renamed administrator and removes the fake workspace menu', () => { + expect(initSql).toContain("'kwitsukasa', '123456', 'KwiTsukasa'"); + expect(initSql).toContain("'/analytics', 'Asia/Shanghai'"); + expect(initSql).toContain("'Analytics', '/analytics'"); + expect(initSql).not.toContain("'vben', '123456'"); + expect(initSql).not.toContain("'jack', '123456'"); + expect(initSql).not.toContain("'Workspace'"); + expect(initSql).not.toContain("'/workspace'"); + expect(initSql).toContain('`username` = VALUES(`username`)'); + expect(initSql).not.toContain('`password` = VALUES(`password`)'); + expect(refactorSeedSql).not.toContain('password = VALUES(password)'); + }); }); diff --git a/test/admin/admin-user-profile.service.spec.ts b/test/admin/admin-user-profile.service.spec.ts index b1a02ab..d50e942 100644 --- a/test/admin/admin-user-profile.service.spec.ts +++ b/test/admin/admin-user-profile.service.spec.ts @@ -2,8 +2,10 @@ import { AdminUserService } from '../../src/modules/admin/identity/user/admin-us describe('AdminUserService profile', () => { const userRepository = { + create: jest.fn((input) => ({ ...input })), findOne: jest.fn(), save: jest.fn(), + update: jest.fn(), }; const roleRepository = { find: jest.fn(), @@ -22,10 +24,33 @@ describe('AdminUserService profile', () => { jest.clearAllMocks(); }); + it('uses environment overview as the default home path for created users', async () => { + userRepository.findOne.mockResolvedValue(null); + roleRepository.find.mockResolvedValue([]); + + await service.createUser({ + realName: '新用户', + roleIds: [], + username: 'new-user', + }); + + expect(userRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + homePath: '/analytics', + username: 'new-user', + }), + ); + expect(userRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + homePath: '/analytics', + }), + ); + }); + it('updates current profile fields including uploaded avatar url', async () => { const user = { avatar: '', - homePath: '/workspace', + homePath: '/analytics', id: '2041700000000000001', realName: '旧姓名', roles: [], @@ -61,11 +86,51 @@ describe('AdminUserService profile', () => { expect(result).toEqual(user); }); + it('falls back to environment overview when current profile clears home path', async () => { + const user = { + avatar: '', + homePath: '/profile', + id: '2041700000000000002', + realName: 'KwiTsukasa', + roles: [], + timezone: 'Asia/Shanghai', + username: 'kwitsukasa', + }; + userRepository.findOne.mockResolvedValue(user); + + await service.updateCurrentProfile(user.id, { + homePath: '', + }); + + expect(userRepository.save).toHaveBeenCalledWith( + expect.objectContaining({ + homePath: '/analytics', + }), + ); + }); + + it('prevents deleting the renamed built-in administrator account', async () => { + userRepository.findOne.mockResolvedValue({ + id: '2041700000000000002', + isDeleted: false, + username: 'kwitsukasa', + }); + + await expect(service.deleteUser('2041700000000000002')).rejects.toMatchObject( + { + response: expect.objectContaining({ + msg: '不能删除内置管理员账号', + }), + }, + ); + expect(userRepository.update).not.toHaveBeenCalled(); + }); + it('serializes avatar and userId for Admin frontend user store', () => { expect( service.serializeUser({ avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg', - homePath: '/workspace', + homePath: '/analytics', id: '2041700000000000001', realName: '管理员', roles: [], @@ -74,7 +139,7 @@ describe('AdminUserService profile', () => { } as any), ).toEqual({ avatar: '/api/minio/download?objectName=avatars%2Favatar.jpg', - homePath: '/workspace', + homePath: '/analytics', id: '2041700000000000001', realName: '管理员', roles: [], From 87cd120ee95d0a9cc20221620ff69b1176a14187 Mon Sep 17 00:00:00 2001 From: sunlei Date: Wed, 1 Jul 2026 16:42:49 +0800 Subject: [PATCH 25/25] =?UTF-8?q?feat:=20=E8=BD=AC=E6=8D=A2Markdown?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=9D=97=E4=B8=BAArgon=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/common/services/markdown.service.ts | 172 ++++++++++++++++++++++-- test/common/markdown.service.spec.ts | 165 ++++++++++++++++++++--- 2 files changed, 314 insertions(+), 23 deletions(-) diff --git a/src/common/services/markdown.service.ts b/src/common/services/markdown.service.ts index 4deffb0..5070b85 100644 --- a/src/common/services/markdown.service.ts +++ b/src/common/services/markdown.service.ts @@ -4,6 +4,13 @@ type MarkdownProcessor = { process: (value: string) => Promise; }; +type HastNode = { + children?: HastNode[]; + properties?: Record; + tagName?: string; + type?: string; +}; + type UnifiedModule = typeof import('unified'); type RemarkParseModule = typeof import('remark-parse'); type RemarkGfmModule = typeof import('remark-gfm'); @@ -22,9 +29,40 @@ const importEsm = new Function('specifier', 'return import(specifier)') as ( const MARKDOWN_SOURCE_PATTERN = //; const CONTENT_CLASS_PATTERN = - /^(kt-md-|wp-|align|size-|is-|has-|language-|attachment-)/; + /^(kt-md-|wp-|align|size-|is-|has-|language-|attachment-|hljs(?:-|$)|fa(?:-|$)|fancybox-|lazyload(?:-|$)|collapse-block|bash$|coffeescript$|css$|dockerfile$|html$|java$|javascript$|json$|markdown$|md$|nginx$|php$|plaintext$|python$|scss$|shell$|sql$|text$|typescript$|xml$|yaml$|yml$)/; const CONTENT_CLASS_ATTRIBUTE = ['className', CONTENT_CLASS_PATTERN]; -const EXTRA_TAG_NAMES = ['figcaption', 'figure']; +const CONTENT_HTML_ATTRIBUTES = [ + 'dataLineNumber', + 'data-line-number', + 'hljsCodeblockInner', + 'hljs-codeblock-inner', + 'id', + 'tooltip', + 'tooltipDisableBreakline', + 'tooltip-disable-breakline', + 'tooltipEnableBreakline', + 'tooltip-enable-breakline', + 'tooltipExitFullscreen', + 'tooltip-exit-fullscreen', + 'tooltipFullscreen', + 'tooltip-fullscreen', + 'tooltipHideLinenumber', + 'tooltip-hide-linenumber', + 'tooltipShowLinenumber', + 'tooltip-show-linenumber', +]; +const EXTRA_TAG_NAMES = [ + 'div', + 'figcaption', + 'figure', + 'i', + 'table', + 'tbody', + 'td', + 'th', + 'thead', + 'tr', +]; const CONTENT_CLASS_TAG_NAMES = [ 'a', 'blockquote', @@ -38,6 +76,7 @@ const CONTENT_CLASS_TAG_NAMES = [ 'h4', 'h5', 'h6', + 'i', 'li', 'ol', 'p', @@ -199,6 +238,7 @@ export class MarkdownService { footnoteLabel: '脚注', }) .use(rehypeRaw) + .use(this.createArgonCodeblockPlugin()) .use(rehypeSanitize, schema) .use(rehypeStringify) as MarkdownProcessor; } @@ -264,21 +304,137 @@ export class MarkdownService { const schema = defaultSchema as Record; const attributes = (schema.attributes || {}) as Record; const classAttributes = Object.fromEntries( - CONTENT_CLASS_TAG_NAMES.map((tagName) => [ - tagName, - [...(attributes[tagName] || []), CONTENT_CLASS_ATTRIBUTE], - ]), + CONTENT_CLASS_TAG_NAMES.map((tagName) => { + const tagAttributes = (attributes[tagName] || []).filter( + (attribute) => + !Array.isArray(attribute) || attribute[0] !== 'className', + ); + + return [ + tagName, + [...tagAttributes, ...CONTENT_HTML_ATTRIBUTES, CONTENT_CLASS_ATTRIBUTE], + ]; + }), ); return { ...schema, + clobberPrefix: '', tagNames: [...new Set([...(schema.tagNames || []), ...EXTRA_TAG_NAMES])], attributes: { ...attributes, ...classAttributes, - a: [...(attributes.a || []), 'target', 'rel', CONTENT_CLASS_ATTRIBUTE], - img: [...(attributes.img || []), 'loading', CONTENT_CLASS_ATTRIBUTE], + a: [ + ...(attributes.a || []).filter( + (attribute) => + !Array.isArray(attribute) || attribute[0] !== 'className', + ), + 'target', + 'rel', + ...CONTENT_HTML_ATTRIBUTES, + CONTENT_CLASS_ATTRIBUTE, + ], + img: [ + ...(attributes.img || []).filter( + (attribute) => + !Array.isArray(attribute) || attribute[0] !== 'className', + ), + 'loading', + ...CONTENT_HTML_ATTRIBUTES, + CONTENT_CLASS_ATTRIBUTE, + ], }, }; } + + /** + * 创建 Markdown code fence 到 Argon 代码块 DOM 的 rehype 转换插件。 + * @returns rehype transformer;会原地补齐 pre/code class,最终仍由 sanitizer 过滤。 + */ + private createArgonCodeblockPlugin() { + return () => (tree: HastNode) => { + this.visitHast(tree, (node) => { + if (node.type !== 'element' || node.tagName !== 'pre') return; + + const codeNode = node.children?.find( + (child) => child.type === 'element' && child.tagName === 'code', + ); + if (!codeNode) return; + + const language = this.normalizeCodeLanguage( + codeNode.properties?.className, + ); + const codeClassNames = this.toClassList( + codeNode.properties?.className, + ).filter((className) => !className.startsWith('language-')); + + node.properties = { + ...node.properties, + className: this.mergeClassNames(node.properties?.className, [ + 'wp-block-code', + 'hljs-codeblock', + ]), + }; + codeNode.properties = { + ...codeNode.properties, + className: this.mergeClassNames(codeClassNames, ['hljs', language]), + }; + }); + }; + } + + /** + * 深度遍历 HAST 节点树,供无额外依赖的小型 rehype 转换使用。 + * @param node - 当前 HAST 节点;缺少 children 时只访问当前节点。 + * @param visitor - 对每个节点执行的同步访问函数。 + */ + private visitHast(node: HastNode, visitor: (node: HastNode) => void) { + visitor(node); + node.children?.forEach((child) => this.visitHast(child, visitor)); + } + + /** + * 将 remark-rehype 生成的 language-* class 规范化为 Blog Argon 使用的 hljs 语言名。 + * @param className - HAST className 属性,可能是数组、字符串或空值。 + * @returns sanitizer 白名单允许的标准语言名;无语言时返回 plaintext。 + */ + private normalizeCodeLanguage(className: unknown) { + const language = this.toClassList(className) + .find((className) => className.startsWith('language-')) + ?.replace(/^language-/, '') + .toLowerCase(); + + if (!language) return 'plaintext'; + if (language === 'ts') return 'typescript'; + if (language === 'js') return 'javascript'; + if (language === 'bash' || language === 'sh') return 'shell'; + + return language; + } + + /** + * 合并 className 属性并保持必需 class 在前,避免重复 class 影响 Argon 选择器。 + * @param current - 现有 className 属性或 class 数组。 + * @param required - 当前转换必须注入的 Argon/hljs class。 + * @returns 去重后的 className 数组,交给 rehype-stringify 输出。 + */ + private mergeClassNames(current: unknown, required: string[]) { + return [...new Set([...required, ...this.toClassList(current)])]; + } + + /** + * 将 HAST className 属性转换成可安全合并的字符串数组。 + * @param value - HAST className 属性,支持字符串、字符串数组和空值。 + * @returns 过滤空白后的 class 名列表。 + */ + private toClassList(value: unknown) { + if (Array.isArray(value)) { + return value.flatMap((item) => this.toClassList(item)); + } + if (typeof value === 'string') { + return value.split(/\s+/).filter(Boolean); + } + + return []; + } } diff --git a/test/common/markdown.service.spec.ts b/test/common/markdown.service.spec.ts index 73a1083..4a55807 100644 --- a/test/common/markdown.service.spec.ts +++ b/test/common/markdown.service.spec.ts @@ -1,3 +1,5 @@ +import { execFileSync } from 'node:child_process'; + import { MarkdownService } from '../../src/common'; describe('MarkdownService', () => { @@ -36,6 +38,107 @@ describe('MarkdownService', () => { expect(html).not.toContain(''; + const script = ` + const { MarkdownService } = require('./src/common'); + (async () => { + const html = await new MarkdownService().sanitizeHtml(${JSON.stringify(argonHtml)}); + process.stdout.write(JSON.stringify(html)); + })().catch((error) => { + process.exit(1); + }); + `; + const output = execFileSync( + process.execPath, + [ + '--experimental-vm-modules', + '-r', + 'ts-node/register', + '-r', + 'tsconfig-paths/register', + '-e', + script, + ], + { + cwd: process.cwd(), + encoding: 'utf8', + env: { + ...process.env, + TS_NODE_TRANSPILE_ONLY: 'true', + }, + }, + ); + const html = JSON.parse(output) as string; + + expect(html).toContain('class="wp-block-code hljs-codeblock"'); + expect(html).toContain('id="demo"'); + expect(html).toContain(''); + expect(html).toContain('data-line-number="1"'); + expect(html).toContain('class="fa fa-copy"'); + expect(html).toContain('tooltip="复制"'); + expect(html).not.toContain('onclick'); + expect(html).not.toContain('