From fbcf46516e979aa85e4f0b6b847f3e3e15f8ec30 Mon Sep 17 00:00:00 2001 From: sunlei Date: Sat, 13 Jun 2026 11:40:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BA=E5=8C=96QQBot=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E7=A0=81=E5=9B=9E=E5=BD=92=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- src/selfTest.ts | 65 ++++++++++++++++ src/tools/review.ts | 175 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 242 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index feedd3b..39d3bc7 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ - 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。 - 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮,模板目录永久保留;CLI 默认 dry-run,真实清理必须显式传 `--execute`。 - 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`,提醒锁文件、核心表格组件、API 时间序列化 KtDateTime 列/DTO 装饰器入口、部署链路和 Vue TSX 插槽写法等高风险改动。 -- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、根目录生成产物、`TASKS.md` 最近记录字段结构和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签和明显测试占位 token,避免历史误报污染上下文;任何文件改动后都要跑一遍。 +- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、QQBot OneBot/QQ 登录态边界回归、NapCat 密码登录验证码清理前 pending 回归、根目录生成产物、`TASKS.md` 最近记录字段结构和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签和明显测试占位 token,避免历史误报污染上下文;任何文件改动后都要跑一遍。 - 生成或写入 `TASKS.md` 最近记录:默认 `dryRun=true`,确认后再落盘。 - 生成提交前检查清单:校验 KT commit message 约定。 @@ -106,7 +106,7 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login | 脚本 | 用途 | | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `pnpm run admin-login` | 使用可见 Edge 打开 Admin 登录页,填写账号密码,拖动滑块,保存登录态和截图。默认账号来自初始化数据 `admin/123456`,生产或个人账号用 `KT_ADMIN_USERNAME` / `KT_ADMIN_PASSWORD` 或 CLI 参数覆盖。 | -| `pnpm run global-review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描,默认仅对变更文件做内容深扫,输出 JSON 复审报告,并校验 `TASKS.md` 最近记录只保留范围、关键词、验证字段、NapCat `latest` 漂移和 QQBot 重型服务单测导入风险;明显测试占位 token 不报凭据风险,确认误报时优先升级 `src/tools/review.ts`,不要把误报沉积到上下文。 | +| `pnpm run global-review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描,默认仅对变更文件做内容深扫,输出 JSON 复审报告,并校验 `TASKS.md` 最近记录只保留范围、关键词、验证字段、NapCat `latest` 漂移、QQBot 重型服务单测导入风险、OneBot/QQ 登录态边界和 NapCat 密码登录验证码清理前 pending 回归;明显测试占位 token 不报凭据风险,确认误报时优先升级 `src/tools/review.ts`,不要把误报沉积到上下文。 | | `pnpm run obsidian-context` | 输出 Obsidian 索引上下文;可传 `--module Admin`、`--query QQBot`、`--max-documents 10`。 | | `pnpm run obsidian-validate` | 校验 KT Obsidian vault 结构和链接;默认 warning 不让脚本失败,需要严格模式时传 `--fail-on-warnings`。 | | `pnpm run obsidian-sync` | 审计 Obsidian 工作流入口是否连通,并联动执行 validate。 | diff --git a/src/selfTest.ts b/src/selfTest.ts index 48604fe..c061c88 100644 --- a/src/selfTest.ts +++ b/src/selfTest.ts @@ -26,7 +26,9 @@ import { prepareTask, readWorkflowContext } from "./tools/task.js"; import { buildGlobalCodeReview, findNapcatImageGovernanceFindings, + findQqbotNapcatCaptchaFlowFindings, findQqbotCommandServiceTestImportFindings, + findQqbotStatusBoundaryFindings, findTaskRecordGovernanceFindings, isBenignCredentialReviewValue, } from "./tools/review.js"; @@ -107,6 +109,69 @@ export async function runSelfTest(): Promise { ); } + const qqbotStatusBoundaryFindings = [ + ...findQqbotStatusBoundaryFindings( + [ + "async markOnline(", + " selfId: string,", + " clientRole: QqbotConnectionRole,", + " lastError: null | string = null,", + ") {}", + "if (account.connectStatus === 'online') return 'online';", + "return this.toTime(account.lastHeartbeatAt) > checkedAt;", + ], + { + file: "src/qqbot/account/qqbot-account.service.ts", + project: "Node/kt-template-online-api", + }, + ), + ...findQqbotStatusBoundaryFindings( + [ + "ws.on('close', async () => {", + " this.connections.delete(key);", + " await this.accountService.markOffline(context.selfId);", + "});", + ], + { + file: "src/qqbot/connection/qqbot-reverse-ws.service.ts", + project: "Node/kt-template-online-api", + }, + ), + ]; + for (const category of [ + "qqbot-mark-online-clears-login-error", + "qqbot-connect-status-drives-login-status", + "qqbot-heartbeat-bypasses-login-check", + "qqbot-stale-ws-close-marks-offline", + ]) { + if (!qqbotStatusBoundaryFindings.some((item) => item.category === category)) { + throw new Error(`QQBot status boundary self-check failed: ${category}`); + } + } + + const qqbotCaptchaFlowFindings = findQqbotNapcatCaptchaFlowFindings( + [ + "private async resolvePasswordCaptchaUrl(container, loginStatus, sinceMs) {", + " return await this.containerService.detectRuntimeCaptchaUrl(container, sinceMs);", + "}", + "private async waitForPasswordLoginStatus(container) {", + " latestStatus = await this.getLoginStatus(container, true);", + "}", + ], + { + file: "src/qqbot/account/qqbot-napcat-login.service.ts", + project: "Node/kt-template-online-api", + }, + ); + for (const category of [ + "qqbot-napcat-captcha-error-throws-before-pending", + "qqbot-napcat-captcha-log-single-read", + ]) { + if (!qqbotCaptchaFlowFindings.some((item) => item.category === category)) { + throw new Error(`QQBot captcha flow self-check failed: ${category}`); + } + } + const reviewCliParser = { dashedAll: parseGlobalReviewCliArgs([ "node", diff --git a/src/tools/review.ts b/src/tools/review.ts index 7243f45..ddd4476 100644 --- a/src/tools/review.ts +++ b/src/tools/review.ts @@ -230,6 +230,173 @@ export function findQqbotCommandServiceTestImportFindings( }); } +export function findQqbotStatusBoundaryFindings( + lines: string[], + context: { + file: string; + project: string; + }, +): ReviewFinding[] { + const normalized = context.file.replaceAll("\\", "/"); + if ( + context.project !== "Node/kt-template-online-api" || + !normalized.startsWith("src/qqbot/") + ) { + return []; + } + + const findings: ReviewFinding[] = []; + + if (normalized === "src/qqbot/account/qqbot-account.service.ts") { + let markOnlineWindow = 0; + lines.forEach((line, index) => { + if (/async\s+markOnline\s*\(/.test(line)) { + markOnlineWindow = 20; + } + + if (markOnlineWindow > 0) { + if (/\blastError\b.*=\s*null/.test(line)) { + findings.push({ + category: "qqbot-mark-online-clears-login-error", + file: context.file, + line: index + 1, + level: "P2", + message: + "markOnline 默认清理 lastError,会把 OneBot 重连误当成 QQ 登录恢复。", + project: context.project, + suggestion: + "让 markOnline 默认只更新 OneBot 连接字段;只有 WebUI/自动登录确认 QQ 在线时显式清理 QQ 登录态错误。", + }); + } + markOnlineWindow -= 1; + } + + if (/account\.connectStatus\s*===\s*['"]online['"].*return\s*['"]online['"]/.test(line)) { + findings.push({ + category: "qqbot-connect-status-drives-login-status", + file: context.file, + line: index + 1, + level: "P2", + message: + "QQ 登录态不能从 connectStatus/OneBot 连接态推导为 online。", + project: context.project, + suggestion: + "qqLoginStatus 只根据 NapCat WebUI 登录状态或明确登录日志判断;connectStatus 只代表 OneBot 连接。", + }); + } + + if (/toTime\(account\.lastHeartbeatAt\)/.test(line)) { + findings.push({ + category: "qqbot-heartbeat-bypasses-login-check", + file: context.file, + line: index + 1, + level: "P2", + message: + "OneBot heartbeat 不能绕过 NapCat QQ 登录态检查。", + project: context.project, + suggestion: + "心跳只证明容器/OneBot 通信活着;陈旧登录态仍要通过 NapCat WebUI/日志刷新。", + }); + } + }); + } + + if (normalized === "src/qqbot/connection/qqbot-reverse-ws.service.ts") { + lines.forEach((line, index) => { + if (!/ws\.on\(['"]close['"]/.test(line)) return; + + const body = lines.slice(index, index + 8).join("\n"); + if ( + body.includes("this.connections.delete(key)") && + !body.includes("isCurrentConnection") + ) { + findings.push({ + category: "qqbot-stale-ws-close-marks-offline", + file: context.file, + line: index + 1, + level: "P2", + message: + "WS close 处理没有校验当前连接身份,陈旧连接可能误删新连接并标记 OneBot offline。", + project: context.project, + suggestion: + "close/error/timeout 清理前先确认 map 中的当前连接仍是触发事件的 ws。", + }); + } + }); + } + + return findings; +} + +export function findQqbotNapcatCaptchaFlowFindings( + lines: string[], + context: { + file: string; + project: string; + }, +): ReviewFinding[] { + const normalized = context.file.replaceAll("\\", "/"); + if ( + context.project !== "Node/kt-template-online-api" || + normalized !== "src/qqbot/account/qqbot-napcat-login.service.ts" + ) { + return []; + } + + const content = lines.join("\n"); + const findings: ReviewFinding[] = []; + const waitLine = lines.findIndex((line) => + /waitForPasswordLoginStatus\s*\(/.test(line), + ); + const resolveLine = lines.findIndex((line) => + /resolvePasswordCaptchaUrl\s*\(/.test(line), + ); + + if ( + content.includes("waitForPasswordLoginStatus") && + !content.includes("isNapcatCaptchaRequiredMessage(errorMessage)") + ) { + findings.push({ + category: "qqbot-napcat-captcha-error-throws-before-pending", + file: context.file, + line: waitLine >= 0 ? waitLine + 1 : undefined, + level: "P2", + message: + "NapCat 密码登录验证码错误没有在清理容器前转换为 pending 状态。", + project: context.project, + suggestion: + "捕获 CheckLoginStatus 的验证码错误,转成 loginError 状态,再从当前密码登录容器日志提取 proofWaterUrl。", + }); + } + + const resolveBody = + resolveLine >= 0 ? lines.slice(resolveLine, resolveLine + 35).join("\n") : ""; + const runtimeLogReads = ( + resolveBody.match(/detectRuntimeCaptchaUrl\s*\(/g) || [] + ).length; + if ( + resolveBody && + (runtimeLogReads < 2 || + !resolveBody.includes( + "isNapcatCaptchaRequiredMessage(loginStatus.loginError)", + )) + ) { + findings.push({ + category: "qqbot-napcat-captcha-log-single-read", + file: context.file, + line: resolveLine + 1, + level: "P2", + message: + "验证码 URL 日志只读一次,时间窗偏差时可能在清理前拿不到 proofWaterUrl。", + project: context.project, + suggestion: + "先按本次重启时间窗读取日志,确认是验证码场景但未取到 URL 时,再读取当前容器 tail 兜底。", + }); + } + + return findings; +} + async function listGitTrackedFiles(projectPath: string): Promise { const tracked = await tryExecFile("git", ["ls-files"], projectPath); if (!tracked.ok) return []; @@ -448,6 +615,14 @@ function collectProjectContentFindings( file, project: project.relativePath, }), + ...findQqbotStatusBoundaryFindings(lines, { + file, + project: project.relativePath, + }), + ...findQqbotNapcatCaptchaFlowFindings(lines, { + file, + project: project.relativePath, + }), ); }