# dsh-remote-qqbot 架构与实现说明 > 这份文档写给**要改这个插件的人**(未来的我、别的会话、别的机器)。 > 面向使用者的安装/配置说明在 [../README.md](../README.md),本文只讲**内部怎么跑的**。 > > 文档描述的是当前源码状态。最后核对:2026-10-07(版本 **1.0.26**;1.0.26 只动**提问那块按钮的长相**: > 从「一行 4~5 个按钮」改成**一行一个**,按钮文字剥掉模型自带的「A.」「(推荐)」这类尾巴, > 而正文里那行重复的选项文字**改由按钮承担**(主人 2026-10-07 23:0x 原话:「你一行放 4 个按钮, > 我根本看不见是什么东西」「反正你按钮里面也要写 A. 一行一个按钮 这样的文字,那不如直接用按钮 > 替代掉正文里面的这个文字,然后让它独立占一行」)。现场取证与改法见 6.8b。 > 1.0.25 是一次**安全收口**, > 四件事:①设置路由把 `cloudUrl` / `hubUrl` / `qqUpdateSource` 三个「改一下就能把内容或代码引到 > 别处」的键摘成**只能手改配置文件**(服务端硬拒 + `rejected` 如实回报 + 前端只读渲染,见 5.4b); > ②装包前**在本机重算 sha256**(索引自报的哈希不算证据)、索引与包地址只认 https(回环除外); > ③闲聊只读从"单向棘轮 + fail-open"改成**可逆 + 失败可见**(3.6c);④`cloud_unbind` 之后 > **不再回落公开中枢**(见 3.1 上方那段路线判定)。 > 1.0.24 把入站路由砍到只剩三条 > ——**`/` 指令、引用、其余一律闲聊**。以前有三条"抢消息"的规则(有提问在等就自动当作答、刚看过 > 名单的裸数字就是选择、别的裸数字拦下来问一句),主人当天明确要求「只要我不引用信息或者信息 > 前边不带 / 的命令就是闲聊,**无论任何情况**」,三条全删;"作答"与"选名单"改成显式:提问下面的 > 按钮发 `/answer N`、名单按钮发 `/pick N` `/open N` `/use N`。见 3.6k。 > 1.0.23 让新版本提醒里 > 多一行「这版改了什么」—— 索引里的 `notes` 跟着查新结果回来,主人对着「立即更新 / 忽略本次」 > 才不是盲选;起因是主人 2026-10-07 17:34 那句「有新版本了还是没有直接给我推送是否更新啊…… > 我现在需要有提醒。而且下边有按键可以选择立即更新」(他当时**已经**在自己发 `/update` 时 > 装上了 1.0.22 并重启,跑的就是最新版,所以没有更新可提醒)。🔴 与 1.0.22 一样**只发布、 > 不预装**,好让"手动查一次"拿到的是带按钮的提醒而不是"装了没重启"的回执。见 3.6j。 > 1.0.22 让 > 「已经是最新版」这句话**自带证据**(跑的是哪一版、磁盘装的是哪一版一起说),并让 QQ 里的 > `/status` 直接问得出插件版本、磁盘比内存新时明确告警「重启 DSH 才会换过去」;起因是主人 > 2026-10-07 那句「现在每次我发更新的命令,它都会提示目前是最新版本,但是你又告诉我什么版本」。 > 🔴 这一版**只发布、不预装**:旧代码靠比版本号大小决定装不装,磁盘上要是已经是最新,它照样 > 不动 —— 留出"线上比本机新"这一格,主人发一次 `/update` 才会真的装并自动重启。见 3.6i。 > 1.0.21 修的是 > **「装上了」不等于「在跑」**:版本号改成**模块加载时读一次**(旧代码是"第一次用到时现读", > 于是"装完还没重启"的窗口里会读成新版,`/update` 回「已经是最新版 X,不用更新」、 > **不装也不重启** —— 主人 2026-10-07 15:2x 就是被这句话卡住的);`/update` 新增 > 「磁盘比内存新」这一格(能自动重启就重启,不能就说清"关掉再打开");更新轮询从 > `if (qqEnabled)` 里挪出来**无条件启动**,配置没就绪时 30 秒后补查(最多 3 次); > 运行状态新增一行「插件版本:跑的是 X,装的是 Y」。取证与推理见 3.6h。 > 1.0.20 修的是**新版本提醒**: > `qqUpdateCheckHours` 默认 6 小时 → **1 小时**,并且 **`/update check` 现在把带「忽略本次 / > 立即更新」两个按钮的提醒直接回给你**(原来只回一句干话)。同时把设置页里那个推送开关 > (`qqNotifyEnabled`)提成**最上面一张卡「接收 QQ 推送」**,分组表里不再重复渲染。起因是主人 > 2026-10-07 那句「有新版本,并没有给我的 QQ 机器人推送通知,我也没办法选择立即更新,或者是忽略 > 此版本」—— 根因不是提醒坏了,是**时机**,取证与推理见 3.6g。1.0.19 给派活加了**第二步**: > 挑完工作区之后列出**这个工作区里的会话**,让你挑一个或者开一个新对话(新增 `/open N` > 与 `/new`,名单下面挂数字按钮 + 一个「新对话」按钮)—— 见 3.6f。1.0.18 修的是**派活**: > 工作区名单的有效期 5 分钟 → **24 小时**,而且一个光秃秃的数字**再也不会被当成派活正文注入 > 会话** —— 没有新鲜名单能解释它就回一句「我没把它当派活内容」(新分支 `stray_number`)。 > 名单下面挂数字按钮(点了发 `/pick 3`,走显式指令、不查时间窗),新增 `/pick N`; > `/pick` 时名单不存在不再"现拉一份顶上"。事故现场与推理见 3.6e。1.0.17 把按钮铺到了 > 几处消息上:新版本提醒下面挂「忽略本次 / 立即更新」(新增 `/skip` 与状态字段 > `updateSkipped`),`/help` 的回复底下挂两行常用命令按钮;QQ 底部的自定义菜单砍到四项 > (去掉「会话」「更新」—— 前者发 `/sessions` 就行,后者现在有按钮了)。 > 见 6.2 与 6.8。1.0.16 加的是 > **消息按钮**:提问(单选、≤5 个选项)底下挂一排可点的蓝色按钮,点一下就把序号发回来 > 作答 —— 走既有的「有提问在等 → 当作答」规则,不需要任何新事件订阅。顺带实测确认 > `keyboard` 在**主动消息**上也能发(HTTP 200,文档标注的「内邀开通」没挡住), > 并修掉「指令按钮点出来带 `@机器人 ` 前缀」这个会让按钮白做的坑;`/answer` 因此从 > QQ 菜单与指令面板撤掉。见 6.8。1.0.15 加的是 > **按工作区派活**:`/task` 不带内容 → 列出工作区(会话的 `cwd`)→ 回个数字选中 → > 之后你发的话就派进那个工作区的专属会话;顺带把 `/update` 补进 QQ 的**指令面板** > (打 `/` 弹出的那份列表,之前只有 4 条、从没同步过),并修正了"两套配置"这个坑。 > 见 3.6d 与 6.2。1.0.14 修的是 > **「QQ 给我发送了三次一样的信息」**:完成通知的摘要**没有按轮配对** —— 主人发新消息打断正在跑的 > 一轮时,会话事件末尾只剩本轮的新 `user/message`,旧的 `extractTurnSummary` 却继续往前捞到 > **上一轮**的 `assistant/message` 当成本轮结果;而上一轮回复一直不变,于是**每打断一次就把同一段 > 旧文字再推一次**(数量只会涨:两条 → 三条)。现在摘要按轮配对(回复必须晚于本轮用户消息), > 且「有用户消息、没有回复」时不再推「跑完了」。取证与反向校验见 3.1c。1.0.13 修的是 > **「这一轮任务做完了,QQ 机器人一条提醒都没有」**:完成推送靠 `running → idle` 这个**边**, > 而那张"上次状态"表原先只在内存里 —— 插件在回合中途被重装(宿主没重启)就把它清空了, > 于是轮末的 idle 进来时 `prev` 不是 `running`,**推送、上传、agentmd 自动日志三件事一起静默跳过**。 > 现在状态落盘(`qq-bot-state.json` 的 `agentStatus`)并在加载时读回,且"没看见起点、只看见 idle" > 过了宽限期也照常收尾。取证与反向校验见 3.1b。1.0.12 修的是 > **「我引用的消息,QQ 那边不是能拿到吗?」—— 能,而且不用调接口**:官方「单聊消息事件」在 > `message_type=103`(引用消息)时**事件里就带着被引用那条的正文**(`msg_elements[0]` 的 > `msg_idx` / `content` / `author.bot`),我们以前**只读索引、把正文扔了**。 > 现在引用反查多了一条**完全不依赖状态文件**的路:我发出去的每条消息里都写着会话名 > (通知「✅ main 跑完了」、回执「✅ 收到…(main)」),于是从正文就能认出会话; > 认出来时会在回复里说清「我是按引用正文认的」。三种认法按确定程度排序、收敛在一个纯函数里 > (`resolveQuoteTarget`),见 3.6c。1.0.7 新增 > **远程更新**:启动时查一次、之后每 `qqUpdateCheckHours` 小时查一次(1.0.20 起默认 **1 小时**), > 发现新版本就在 QQ 里提醒一次 > (同一个版本只提一次、远端更旧或相同都**不吭声**);QQ 里发 `/update`(同义词 `/更新`, > `/update check` 只查不装、**有新版本时把带按钮的同一条提醒回给你**)就装新版, > 装完**自动重启 DSH**(可关),重启后回一条「已生效」确认。 > 新增 `src/update.js`(纯逻辑,零外部依赖)与 `tests/update.test.mjs`,见 3.10。 > 1.0.6 修的是 > **「引用一条 QQ 消息发过去,机器人回『认不出这是哪次通知』,而那句话哪儿都没进去」**: > ①机器人自己的**回执**(✅ 收到 / ❌ 没送进)以前从不登记 `ref_idx` → 引用回执必然反查落空; > ②兜底只有 5 分钟窗口、且只看 `recent[]`(不看 `sentRefs`); > ③两路落空就回一句「认不出」并把消息**整条丢掉**。 > 现在:回执也登记 `ref_idx`;兜底窗口放宽到 12 小时并合并两个数据源(回复里明说是多久前那条); > 真的一条都对不上时改投**闲聊会话(只读)**并说清去了哪 —— 绝不静默丢消息。见 3.6b。 > 1.0.5 修的是 > **「AI 提问后隔久了再答,系统却说没有在等你回答的问题」**:`ASK_TTL_MS` 从 30 分钟放宽到 > **24 小时**(提问是 agent 在阻塞等的东西,清理依据该是"这轮提问结不结束",不是"过了多久"), > 且 `request.signal` 一中止就立刻摘掉 QQ 条目、真过期必须打日志、提问已结束时把话说明白。 > 见 3.7。1.0.4 只动**文案**: > QQ 侧档位名统一成「免费版 / 付费版」。 > 1.0.3 只动**文案**: > 设置面板的字段说明改成短句、去掉 emoji 与「体积旋钮」这类术语;顺手把中枢地址的示例 > 从已下线的 `cyanovo.top:8444` 换成占位符。逻辑一行没动,`npm test` 与两个安装校验脚本全绿。 > 1.0.2 修的是 > **「协作文档把上下文顶爆」**:注入腿原本每步把 `main.md` **全文**塞进 prompt(文件多大、 > prompt 就多大),文档涨到 30 万字符后上游直接回 `413`;现在过 `buildContextDoc()` 压缩, > 注入正文**永不超过** `agentmdInjectMaxChars`(默认 8000),且省略处明写省略了多少字符; > 追加腿也给单条日志行封了顶(`agentmdRowMaxChars`,默认 600)。见 3.3。 > 1.0.1 修的是 > **「引用消息没办法回答」**:桌面版把 `~/.dsh/.credentials.yaml` 里的 > `client-connection/browser-session` 记录弄丢后,`dshApi` 的 cookie 换不出来、 > 入站注入全线 401,而出站推送不受影响 ⇒「通知正常、引用回复全死」。现在 > `ensureApi()` 每次要用都重读密钥、记录缺失时自己补回来,并把结果写进报错文案与 `/status`, > 见 3.9;1.0.0 把这个插件正式公开: > **包名从 `dsh-notify-memory` 改为 `dsh-remote-qqbot`**,并把 `lib/` 改成**必须提交进版本库** > —— `dsh plugin add github:/` 装的是 git 里的文件,git 没有"安装时构建"这一步, > 不提交 `lib/` 用户就装到一个空壳,见第九节「发布到 GitHub」; > 0.9.0 把输入框下方的小开关**从 4 个收回 2 个** —— `qqFulltextMode` / `cloudEnabled` 搬进设置面板,见 4.1「UI 挂点」; > 0.8.6 新增**三档全文模式** `chat` / `note` / `note-link` 与**默认关闭的云上传总闸** `cloudEnabled`, > 见 3.8 与 6.7;0.8.4 新增**插件自有覆盖存储** `src/overrides.js`,见 5.3b)。 --- ## 一、它是什么 一个 **DSH(DeepSeek Harness,cordis 架构)插件**,纯 ESM JavaScript,**无 TypeScript、无构建期转译**。 包名与仓库同名:**`dsh-remote-qqbot`**;安装方式是 `dsh plugin --profile desktop add github:/dsh-remote-qqbot` (仓库里带着构建产物 `lib/`,装完即可用,见第九节)。 它靠两条腿工作: | 侧 | 文件 | 运行位置 | 能干什么 | |---|---|---|---| | **host 侧** | `src/index.js` 等 11 个模块 | DSH 的 Node 主进程 | 订阅 agent 事件、注册工具、发 HTTP、连 QQ 长连接 | | **client 侧** | `src/client.js` | DSH 的浏览器前端 | 设置页那一项、「输入框下方」的两个小开关 | 两侧**没有共享代码**,靠一条同源 HTTP 路由(`/remote-qqbot/api`)通信: ``` 浏览器 (client.js) ──fetch──▶ /remote-qqbot/api/* (config-api.js 挂的路由) │ └──▶ host 的 liveConfig() / 写配置(原生 settings 优先,被拒则落覆盖文件) ``` **外部依赖只有两个裸包**(构建脚本会拦住其它裸包): - `@deepseek-ai/dsh-tools` —— 只为了 `defineTool` - `@deepseek-ai/schemastery` —— 只为了 `z.object` 定义配置 schema > ⚠️ 不要 import `@deepseek-ai/dsh-settings` 之类的 workspace 包:它们只存在于 DSH 源码 > checkout,用户的 profile 的 `node_modules` 里没有,会在**加载期** `ERR_MODULE_NOT_FOUND` > (v0.1.0 就是这么挂的)。 核心不变量:**`src/` 里没有一个文件读环境变量、没有一个文件写死生产地址。** 所有真实配置只从 `~/.dsh/settings.yaml`(及其 profile 补丁层)来,见 [第六节](#六配置系统为什么这么绕)。 --- ## 二、目录结构 ``` src/ 源码(唯一可编辑的真相来源) index.js 1274 行 · 插件入口 qqbot.js QQ 官方机器人客户端(取 token / WS 长连接 / REST 发送) qqbridge.js QQ 纯逻辑:文案、引用索引、入站路由、本机 /api 客户端、/status 排版 qqruntime.js 把 qqbot + qqbridge 接到 DSH 生命周期:通知、提问中继、作答 collab.js 协作模式:会话注册表 / 文件占用 / 留言 / 注入渲染 / 面板落盘 notes.js 完整回答:渲染 markdown + 上传中枢换短链接 summary.js 从会话事件里提取「本轮做了什么」 agentmd.js 操作日志表格定位与追加 config-api.js 浏览器侧配置路由 + 字段表 + 白名单写入 overrides.js 插件自有覆盖存储:原生写入被拒时的落盘 / 原子写 / 失败必抛 client.js 浏览器侧 bundle(DSH loader 的闭包工厂格式) lib/ 构建产物(npm run build 由 src/ 生成,不手工编辑、不进版本库) tests/ 单元测试,全部进 npm test 链,零外部依赖 tests/live/ 要真实环境(真中枢 / 真 profile / 真 QQ)才跑得起来的一次性脚本 scripts/ 正式工具:build / pack / link-dev / verify-installed / verify-ui-installed / which-version scripts/oneoff/ 运维一次性脚本 docs/ 本目录 cordis.patch.yml DSH bundle 补丁:把插件插进 desktop profile ``` --- ## 三、六个数据流 ### 3.1 出站推送(任务完成 / 提问 / 出错) ``` agent 事件 │ ├─ agent/status (running → idle) ─┐ ├─ tools/pre-execute(ask_user_question) ─┐ └─ agent/error ────────────────────┐ │ ▼ ▼ push(kind, summary, agent, extra) │ ┌───────────────┼────────────────┐ ▼ ▼ ▼ 中枢 POST /api/events QQ 主动私聊 agentmd 追加日志 (收件箱 + ntfy) (qqruntime) ``` `push()` 是唯一的出口,它在这里做**噪声过滤**(顺序就是判定顺序): | 过滤 | 条件 | 效果 | |---|---|---| | 子智能体 | 会话头 `origin` 表明是 subagent,且 `notifySubagents=false` | 静默 | | 闲聊会话 | 是该会话,且 `notifyChatSession=false` | 静默(回答另走 3.6) | | 去重 | 同内容在 `dedupeMs` 内已推过 | 静默 | 三类事件各有开关(`onTurnComplete` / `onQuestion` / `onError`)。 提问**显示在哪里**另有一个开关 `qqAskDesktop`(1.0.27 起默认 `both`:桌面卡片和 QQ 同时弹, 谁先答算谁;改成 `qq-first` 就回到「先 QQ、桌面 90 秒兜底」)—— 详见 3.7。 **推送里怎么出现「查看完整回答」链接:** 长回答会先走 `notes.publishTurnNote()` (渲染 markdown → 上传中枢 → 换回 ``),推送正文只留摘要 + 一个链接。 链接必须过 `qqPreviewUrl()` 加工(见 [第七节](#七qq-三个容易踩死的点))。 🔴 **解绑后不回落中枢(1.0.25)**:`publishTurnNote` 的路线判定是三岔,不是两岔: ``` cloudUrl && cloudToken → 云端(记录挂账号下,登录才能看) cloudUrl && !cloudToken → 什么都不发 + 一行日志 (= 刚 cloud_unbind:中枢是**公开可读**的,回落等于把 "已解绑、不再上传"偷偷变成"改发到更公开的地方") !cloudUrl → 中枢(老路径,从来没配过云端的人不受影响) ``` 云端失败时同样**不改发**中枢(两处取向一致:宁可少一个链接,也不做用户没同意的转发)。 ### 3.1b 「跑完了」由一个**边**触发 —— 所以它必须能跨重启(1.0.13 修的真事故) 触发条件是 `agent/status` 的 **`running → idle`**,而"上次状态"那张表原先**只在内存里**。 2026-10-05 的事故:主人在 QQ 里派的一轮任务做完了,**移动端一条通知都没有**, `agentmd/main.md` §四 也没有对应的自动日志行。三件独立取证互相印证,说明**不是"推送失败", 而是整段完成逻辑压根没执行**: | 量具 | 现场 | |---|---| | 线上 nginx `access.log` | 那一轮**没有任何 `/api/publish` 请求**(`note-link` 档一定是先上传再推送) | | `~/.dsh/qq-bot-state.json` | 最后一条 turn-complete 停在 12:58;那一轮什么都没记 | | `agentmd/main.md` §四 | 没有那一轮的行 —— 它和推送在**同一个分支**里 | 根因:那一轮跑到一半时,desktop profile 里的插件包被换过(宿主**没有**重启)⇒ 插件实例重建、 内存里的表清空 ⇒ 轮末 idle 进来时 `prev` 不是 `running`,那个 `if` 直接不成立: **没有日志、没有推送、没有 agentmd 行**,三个症状同一个原因。 修法两条,缺一不可: 1. **状态落盘**:每次 `agent/status` 变化就把 `{ agentId → { s, at } }` 写进 `qq-bot-state.json` (`BotState.markAgentStatus`:只认 `running` / `idle`,50 条上限、24 小时 TTL), 插件加载时读回来(`qq.agentStatusMap()`)⇒ 中途重启过的那一轮,轮末照样认得出"它刚才在跑"。 2. **没看见起点也要收尾**:过了「加载后 2 分钟」的宽限期,收到无起点的 `idle` 时照常走完整收尾 (上传 + 推送 + agentmd 日志),并在日志里写清为什么。宽限期是为了挡住"宿主在插件加载时 补发的状态快照"—— 那种 idle 不是一轮跑完,照推会在每次启动时凭空发一条「跑完了」。 回归:`tests/plugin.test.mjs`(4 条:跨重载收尾、启动快照留痕、过宽限期照常收尾、 子智能体走补救路径也写日志)+ `tests/qq.test.mjs` 的 `[15]` 组(5 条:落盘/读回、 老状态文件平滑升级、只认 `running`/`idle`、TTL、条数上限)。 **反向校验**:把 `src/` 与 `lib/` 一起回退到 1.0.12 ⇒ 这 9 条全红,其余仍绿(`plugin 33/4`、`qq 200/5`)。 ### 3.1c 「跑完了」的摘要必须**按轮配对**,否则会把上一轮的回复重复推(1.0.14 修的真事故) 2026-10-07 主人报「QQ 给我发送了三次一样的信息」。三次的**文字完全相同,且与当轮问题毫无关系**: 推的是几小时前那轮 Cloudflare 人机验证的调研结论,而 attach 的三轮问题分别是「安全审查」「优化前端页面」 「为什么你给我发了两次一样的信息」。 取证(两条互相独立的量具): | 量具 | 现场 | |---|---| | `~/.dsh/qq-bot-state.json` 的 `recent` | 三条 `turn-complete` 分别落在 **14:56:40 / 22:22:53 / 04:46:24**,`sessionId` 全是同一个会话 | | `agentmd/main.md` §四 | 对应三行的「操作」列各不相同(三个不同的问题),「结果」列**逐字相同** | | 跑函数(合成事件) | 见下:`extractTurnSummary` 直接复现出那行一模一样的日志 | 根因在 `extractTurnSummary`(`src/summary.js`):它**倒着**扫 `session.events`, `assistant/message` 与 `user/message` **各取"第一条找到的"**,再在两者都非空时 break。 而主人**发新消息把正在跑的一轮打断**时,事件末尾的形状是: ``` [ …, 上一轮的 assistant/message, 本轮新来的 user/message ] ← 本轮没有 assistant/message ``` 于是它配出「**本轮问题 → 上一轮回复**」。更要命的是上一轮回复一直不变 ⇒ **每打断一次就把同一段旧文字再推一次**,所以主人先看到两条、再看到三条(数量只会涨)。 而 `push()` 的去重窗口只有 4 秒、去重键又含 summary,跨小时的重复它挡不住。 修法两条: 1. **按轮配对**(`src/summary.js`):两个事件都记下标,最后校验 `assistantIdx < userIdx` ⇒ 回复属于上一轮,本轮没产出,`assistantText` 置空。 顺带把"还没找到"的判据从 `assistantText === ''` 换成**下标**:一条正文为空 (只调工具、没说话)的 `assistant/message` 原样会继续往前捞,同样张冠李戴。 2. **没产出就不推**(`src/index.js` `handleTurnEnd`):判据刻意收窄成 「有真人用户消息 + 没有回复」⇒ 跳过 `turn-complete` 通知(不发卡片、不发正文), 但仍追加一行操作日志,结果列写「已中断(无回复)」(`inferResult` 新增第三态;**出错仍然优先**)。 会话结构完全陌生(两边都空)时不改老行为 —— 只认这一种确定情形。 回归:`tests/summary.test.mjs`(18 条,覆盖配对、流式分片、空正文回复、插件注入、 陌生结构、结果列三态)+ `tests/plugin.test.mjs` 新增 1 条端到端 (打断形状 ⇒ **0 条推送** + 日志说明 + 日志行结果列为「已中断(无回复)」)。 **反向校验**:①拿掉配对校验 ⇒ `summary 14/4`(红的正是 4 条配对断言);②拿掉推送闸 ⇒ `plugin 37/1`(红的正是新增那条)。两处都只红对应的断言,其余全绿。 > 🔴 这条修的是**内容错**,不是"重复推送"。以后再遇到"收到和问题对不上的通知/日志行", > 先怀疑摘要配对,别先去调 `dedupeMs`。 ### 3.2 跨会话记忆 三个工具直接读写中枢: | 工具 | 动作 | |---|---| | `memory_write` | `POST /api/memory` | | `memory_read` | `GET /api/memory` | | `memory_forget` | `DELETE /api/memory/:key` | `hubUrl` 留空时**只记日志、不发请求**(默认值就是空的,所以单元测试不会误打线上)。 ### 3.3 agentmd 跨会话上下文 两个方向,分别由两个开关控制: - **写入**(`agentmdAppendLog`,默认开):会话转 idle 时,把「本轮做了什么」追加进 `agentmdDir/main.md` 第 4 节的表格里。表格定位在 `agentmd.js`,是纯函数。 单行还会过 `buildLogRow({maxChars})` 封顶(`agentmdRowMaxChars`,默认 600): 超长只裁「操作」列,时间列与三列骨架不动 —— 否则一条超长摘要会把表格越撑越大。 - **读取**(`agentmdInject`,默认关):`agent/created` 时把 `main.md` 注入这个会话的 system prompt(注入 id 形如 `remote-qqbot:agentmd`)。`text` 是**函数**、每步重读盘, 所以**注入前必须先压缩**:`buildContextDoc(text, {maxChars, tailRows})` 保证返回 `text.length ≤ agentmdInjectMaxChars`(默认 8000),压缩策略是「§四 小节 + 最近 `agentmdInjectTailRows` 行日志」优先、剩余预算给开头正文,被丢掉的部分在正文里 明写 `…(此处省略约 N 个字符…)…`。**绝不能静默截断** —— 模型会把「没看到」当成 「不存在」;也绝不能只靠「把文件写小」——没人能保证下一个人不往里贴长文。 ### 3.4 协作模式 **前提事实**:同一个工作区里的所有会话**跑在同一个 DSH 进程里**。所以协作不需要轮询、 不需要外部服务,就是一个进程内注册表。 ``` collab_claim / 写文件工具前置钩子 │ ▼ collab.js 注册表(sessionId → 占用的文件 + 留言 + 心跳) │ ├─▶ 撞车:collabClaimGuard = warn(默认,提醒)| block(拒绝写入)| off ├─▶ 注入:有新情况时把「谁在写什么、谁给你留言了」塞进会话上下文 └─▶ 落盘:写进 agentmdDir/plugin-collab.md(给人看的手工版,自动维护) ``` 会话转空闲时**立即释放**占用;没释放的按 `collabClaimTtlMs`(默认 30 分钟)过期。 单人单会话时这套的代价是零——没有可说的就**一个字都不注入**。 四个工具(`collab_status` / `collab_post` / `collab_claim` / `collab_release`)**即使 `collabEnabled=false` 也仍然注册**,只是回一句「协作模式已关闭」——刻意避免「工具突然消失 / 报错」这种惊吓。 ### 3.5 QQ 出站:从事件到手机 ``` push(event) ──▶ qqruntime.notify() ──▶ qqbot.sendC2C(openId, markdown 文本) ``` - 通道是官方机器人 **WebSocket 长连接**(`qqbot.js` 取 access token → `/gateway` → 心跳); - 消息类型由 `qqMarkdown` 决定:`true` = `msg_type=2`(markdown), **只有 markdown 才能把链接折叠成一句「查看完整回答」**;客户端显示异常时设 `false` 退回纯文本; - 摘要长度受 `qqSummaryChars` 限制,0 = 不附摘要。 ### 3.6 QQ 入站:三种消息、三条路 ``` QQ 消息(C2C_MESSAGE_CREATE) │ ┌──────────┼───────────────────────┐ ▼ ▼ ▼ 以 / 开头的指令 引用了机器人的消息 什么都没引用 (/status /help (ref_idx 反查 → (先看有没有「当前会话」) /task /answer 消息属于哪个会话) │ /sessions /use) │ ┌─────────┴─────────┐ /screen ── 截图直接回图 │ 指定过 → 投进它 没指定 → 闲聊 │ │ (不在了会明说) (只读 + 回答直发 QQ) ▼ ▼ 本地直接回复 投进那个会话 继续对话 ``` **裸数字的归属(0.8.5 起,顺序不能反)**:`引用 > 有提问在等 > 选会话 > 闲聊`。 agent 卡在提问上等你回一个 `1` 时,那个 `1` 是在**回答它**;只有在「没引用 + 没有提问在等 + 名单刚发出去 5 分钟内」这三个条件同时成立时,裸数字才被当作「切到第 N 个会话」。设计理由见 6.6。 > `/screen` 是唯一一条**回图片**的入站路径:它不注入任何会话,抓一张屏就往回发。 > 权限上走 `qqScreenEnabled`(默认开)。实现见 `src/screenshot.js`, > 它踩过的 AMSI 坑记在下面 6.4。 **引用是怎么反查到会话的**:插件发出去每条通知时,都在消息里埋了一个引用索引 `ref_idx` (自己的短 id)。你引用那条消息回复,QQ 会把被引用消息的原文回传,插件据此解析出它当时属于 哪个会话 —— 所以「引用谁 → 回到谁」是**确定性**的,不是猜的。 **但「发出去的每条都要埋」这条以前没做到 —— 回执漏了**(1.0.6 修,见 3.6b)。 **而且事件里本来就带着被引用那条的正文 —— 我们以前也扔了**(1.0.12 修,见 3.6c)。 ### 3.6b 引用反查的四级兜底:认不出会话也**不许丢消息**(1.0.6 起,1.0.12 补第②级) 真事故(2026-10-05 02:31,主人报「机器人说认不出这是哪次通知」):QQ 里收到 4 条消息, 只有 1 条进了会话,另外 3 条**哪儿都没进去** —— `session-edc80d93` 的转录里连 `user/message` 和 `agent/inbox/spliced` 都没有,等于主人的话凭空消失。 | 级 | 判据 | 以前的行为 | 现在的行为 | |---|---|---|---| | ① 精确 | `sentRefs[ref_idx]` 里有这条 | 只登记**通知/提问/全文/截图**;**回执不登记** → 引用「✅ 收到」必然落空 | 回执(`replyPassive`,带 target 时)**也登记** `ref_idx` → 引用它回到同一个会话 | | ② 引用正文 | `ref_idx` 没中,但事件里带着**被引用那条的正文** | 这条信息**根本没读**(见 3.6c) | 从正文里认出会话名 → 回到那个会话,回复里明说「按引用正文认的」 | | ③ 最近一条 | 前两级都没中 | 只看 `recent[]`、窗口 **5 分钟** | `recentTarget()` 合并 `recent[]` + `sentRefs{}` 取**真正最新**一条,窗口 **12 小时**;回复里明说「按最近那条的会话送进去了,那条是 N 分钟前发的」 | | ④ 落底 | 一条带会话的记录都没有(引用自己发的、或好几天前的) | 回一句「你引用的这条我认不出是哪次通知了」→ **消息整条丢掉** | 投进**闲聊会话**(`qqChatReadOnly`,只读、动不了手)并说清「这句话我放进了闲聊」 | 四级的设计取向都是同一句话:**落错一个只读会话,也比让主人的话消失强**。 ②③都会在回复里说清"我凭什么认的",他一眼就能判断认没认错,错了立刻 `/sessions` 重挑再发。 窗口为什么是 12 小时(第③级):QQ 的引用没有有效期,隔一顿饭再引用一条通知回话是正常用法; 再往前的(隔天、隔几天)就不敢猜了,那时宁可按④落到只读的闲聊会话。 ### 3.6c 被引用那条消息的**正文**,事件里本来就有(1.0.12) 主人 2026-10-05 问了一句把我说醒的话:**「我引用的消息,QQ 机器人那里不是可以正确获取吗? 应该有对应的接口吧?」** —— 对,而且**不用调任何接口**:官方 [单聊消息事件](https://bot.q.qq.com/wiki/develop/api-v2/autogen/event/c2c_message_create.html) 在 `message_type=103`(引用消息)时,事件里带 `msg_elements`,第一个元素就是被引用那条: | 字段 | 是什么 | |---|---| | `msg_elements[0].msg_idx` | 被引用消息的索引(与 `message_scene.ext` 里的 `ref_msg_idx` 同值) | | `msg_elements[0].content` | 被引用消息的**正文** | | `msg_elements[0].author.bot` | 那条是不是机器人自己发的 | 我们以前**只读索引、把正文扔了**(`extractRefIdx` 是唯一入口)。于是引用反查把全部希望压在 状态文件上:文件被清、登记被 300 条上限挤掉、消息被撤回,就只剩"猜"。现在多了 `extractQuoted()`,第②级判据就是它 —— 而且**我发出去的每条消息里都写着会话名**: | 我方消息 | 会话名在哪 | |---|---| | 通知 | 首行:`✅ main 跑完了` / `❓ main 想问你个事` / `⚠️ main 出错了` | | 回执 | 括号:`✅ 收到,我这就开始(main)` / `❌ 这句没送进 main:…` | `inferSessionFromQuote()` 两条判据、从严到宽: 1. **我方格式里的会话名**(首行 / 括号)—— 只认**与现有会话标题完全相等**的候选。 猜错会话比认不出更糟(话会进错地方),所以不相等就不认。 2. 退一步:**只有我发的消息**才按首行猜(你随口提到某个会话名 ≠ 话题就是它), 同一行有多个标题时取**最长**的那个(「前端重构」比「前端」具体)。 只认第一行 —— 通知正文的摘要里提到别的会话名是常事,不能跟着它跑。 **认出会话之后还有一件语义上的事**:如果认出来的那个会话**正卡在提问上等你回答**, 这条引用必须按**作答**处理(`kind: 'question'` + `askId`),否则它会被当普通消息注入会话, 而 agent 还在等那个答案 —— 两边一起僵住。这条逻辑写在纯函数 `resolveQuoteTarget({ exact, quoted, sessions, pending, recent })` 里(①→②→③ 的顺序、 提问优先都在它一处),`onC2cMessage` 只负责备料(拉会话列表、算③),好测也好读。 ⚠️ **`msg_idx` 只观测、不据此去重**:官方建议"结合 `msg_idx` 去重",但万一它不是每条消息唯一, 按它去重会把正常消息**永久**丢掉 —— 那比偶尔重复进一次严重得多。所以现在只在内存里记最近 200 个 `msg_idx`,撞上时**打一行日志**("平台确实会重投")而**不丢消息**;真观测到了再决定要不要动真格。 **`qqPromptMode` 决定投递方式**(这是渲染差异,不是审美问题): | 值 | 行为 | 在 DSH 里长什么样 | |---|---|---| | `queue`(默认) | 排队,等当前这轮跑完 | `user` 节点 = **正常的用户消息气泡**,和你在输入框打字一样 | | `steer` | 插话,立刻打断当前这轮 | `steering` 节点 = 「插话」样式,不占正常对话位 | **闲聊会话的两条硬规则**(用户明确要求): 1. `qqChatReadOnly`(默认开):每次发消息前把这个会话的 DSH 权限预设切成 `read-only`。 实现方式是**切权限**而不是禁工具——这样它照样能查状态、读文件、跑查看类命令, 只是改不动。⚠️ 已知边界:沙箱管的是**本机文件系统**,拦不住 `shell` 里 SSH 去改远端主机。 **1.0.25 补了两个洞**(都出自同一次审查): - **可逆**:切换前先读并记住该会话的原 preset(模块级 `chatReadOnlyState.remembered`); `qqChatReadOnly` 关掉后,下次投递前 `restore` 回去(只恢复一次),插件停止时(`ctx.effect` 清理) 也尽力恢复。以前是单向棘轮:开关关了会话还停在只读,界面却显示"关"。 - **失败可见**:四种失败(预设服务不可用 / 没有 `read-only` 这个名字 / 拿不到会话对象 / `set` 抛错) 一律记进 `chatReadOnlyState.failure` → 设置页「运行状态」多一行 `warn: true`,QQ 回执里也加一句。 以前只写 console 日志,而那一轮闲聊实际是按会话默认权限(本机是 `danger-full-access`)跑的 ⇒ 又一次"失败被当成成功"。判定逻辑抽成纯函数 `planChatReadOnly`(好单测), 钩子契约:返回 `false` = 「要求只读但没能生效」,`true` = 已就绪或压根没要求。 2. `qqChatReply`(默认开):回答正文**直接发回 QQ 聊天框**,不推通知卡片、**不上传笔记** (所以服务器上零残留),代价是超过 `qqChatAnswerChars`(默认 1500)会被截断。 ### 3.6d 按工作区派活:`/task` 两步(1.0.15) 主人的原话:「我希望我输入 /task 的时候可以选择工作区再进行输入派活」。 之前 `/task 内容` 只有一个落点:配置里 `qqCwd` 上那个固定的「专属会话」。 一个 DSH 用户手上有一堆项目目录(会话列表里的 `cwd`),派活时没法选。 现在的流程: ``` /task → 列出工作区(同一个目录归成一个,按最近活动倒序),回个数字 回 「2」 → 选中第 2 个:取/建该工作区的专属派活会话,并设为「当前会话」 发「把测试跑一遍」 → 不是指令、没有引用 ⇒ 走"当前会话"这条路,进那个工作区的会话 ``` 几个刻意的决定: - **工作区 = 会话的 `cwd`**,不另立一套配置。名单由 `session/list` 归并而来 (`listWorkspaces()`),所以"你在哪个目录下开过会话,它就能被派活"。为此 `summarizeSession()` 多带了一个**完整 `cwd`** —— `project` 只是给人看的短名, 建会话必须用完整路径。 - **一个工作区一个专属会话**(状态文件 `taskSessions: {cwd → sessionId}`), 不是每次都新建:重复派活能接着上下文,也不会在 DSH 里堆一串同名空会话。 - **默认工作区沿用老的 `sessionId` 槽位**:`cwd === qqCwd` 时直接认下老的「专属会话」, 老状态文件与老习惯都不用迁移。 - **选中 = 设为「当前会话」**(`activeSessionId`)。这样"再进行输入派活"不需要再记一条 命令 —— 你发的那句话就是普通消息,走既有的"当前会话"投递路径。 - 🔴 **两份名单都用裸数字**(`/sessions` 的 `pickerIds` 与 `/task` 的 `taskPickerCwds`), 所以 `routeMessage` 的第 ④ 步必须**只认后发的那份**:调用方按时间戳比较, 把更晚的那份置 true、另一份置 false。两份都新鲜时数字就有歧义了。 「有提问在等」仍然压过两份名单(回「1」是在作答,不是在选择 —— 顺序反了 agent 会卡死)。 - **选中的工作区不在了要明说**(`formatWorkspacePickAck('gone')`), 绝不静默换一个目录 —— 那等于把你的活派进了你不知道的地方。 回归:`tests/qq.test.mjs` 的 `[7b]` 组(10 条:路由三写法、数字选工作区、两份名单优先级、 提问优先于名单、名单文案、三支回执、`cwd` 带出、老状态文件平滑升级、帮助文案)。 **反向校验**:拿掉工作区名单的数字分支 ⇒ `qq 213/2`(红的正是那两条路由断言); 拿掉 `summarizeSession` 的 `cwd` ⇒ `qq 214/1`。两处都只红对应断言。 ### 3.6e 一个光秃秃的数字,绝不注入会话(1.0.18) **现场(2026-10-07,主人报「派活的逻辑问题很大」)**: ``` 08:45:15 /task → 列出 6 个工作区,taskPickerAt 落盘 09:09:18 回「2」 → 距名单 24 分钟 > PICK_WINDOW_MS(5 分钟) ⇒ workspacePickerActive = false ⇒ routeMessage 走到「其余」那一档 = chat ⇒ 「2」被当成一句派活正文,注入 session-609a29a2 ``` 转录里的实证(`~/.dsh/...` 会话 jsonl): | 现场 | 内容 | |---|---| | `user/message` 的正文 | `content: [{ type: 'text', text: '2' }]`,`time 1791335357478` | | 之后 | `turn/start`(turn 2)→ `assistant/attempt` → 403 订阅额度不足 → `turn/end` reason=error | **两层伤害**:① 选择**没生效**,那条「2」什么也没选中;② 那句「2」成了会话里的一句 无意义用户消息,白跑一轮、还污染了上下文。(附带那个 403 是上游额度问题,与插件无关。) **两处修复,缺一不可:** 1. **工作区名单的有效期 5 分钟 → 24 小时**(`WORKSPACE_PICK_WINDOW_MS`)。 手机上的真实节奏是「看到名单 → 想好选哪个 → 回一句」,24 分钟是那个 5 分钟窗口的 4.8 倍。 会话名单仍保持 5 分钟(`PICK_WINDOW_MS`)—— 会话的活动顺序变得快,窗口长了反而更容易选错。 2. **裸数字不再有「降级成普通消息」这条出路**:没有任何新鲜名单能解释它时, `routeMessage` 返回 `stray_number`,运行时只回一句人话 (`formatStrayNumber`:**没被当派活内容送出去** + 怎么挑 + 真想派这句怎么写), **一句都不注入任何会话**。 **新规矩一句话**:一个光秃秃的数字,要么是选名单,要么就多问一句。 配套的三件事: - **名单下面挂数字按钮**(`buildNumberButtons('/pick ', n)` / `buildNumberButtons('/use ', n)`)。 按钮的 `action.data` 是**显式指令**(`/pick 3`),走 `routeIncoming` 那条在最前面的路, **不查时间窗** —— 手打数字才查窗口。这就是"过期了也还能点"的出路。 按钮标签只能是编号(会话名/路径都超 10 字符),每行 5 个、最多 5 行。 - **`/pick N` 显式指令**(`/选` / `/choose` 同义)。同样跳过时间窗,但它**要求名单真的存在** (`taskPickerCwds` 非空):编号只对你看过的那份名单有意义。 - 🔴 **`/pick N` 且名单不存在时,删掉了"现拉一份顶上"的旧行为**。 以前 `handlePickWorkspace` 在 `cwds` 为空时会去拉当前列表充数 —— 可那个编号是主人**没看过**的 一份列表里的,`/pick 3` 很可能选中另一个目录。**选错工作区 = 把活派进别的项目**, 比让他重发一次 `/task` 贵得多。现在直接回 `formatWorkspacePickAck('no-list')`。 ⚠️ 这里是**故意**和 `/use N` 不对称的:`handlePickSession` 在 `pickerIds` 为空时**仍然** 现拉一份会话列表来解释 `/use 3`。理由是代价不同 —— 会话选中只影响**下一条**消息, 而且回执里写着切到了哪个会话(选错一眼能看见、一句话能改);工作区选中是个**常驻指针** (`activeTaskCwd`),之后每一句话都进那个目录,错了要自己发现、再发一次 `/task` 才能拨回来。 改这一处之前先想清这个差别,别为了"看起来一致"把工作区那条守卫拆掉。 **不变量(改这块之前先读这条)**:优先级 ③「有提问在等 → 当作答」必须排在 `stray_number` **前面**。 agent 卡在 `AskUserQuestion` 上等你回「1」时,那个「1」必须去作答;顺序反了 agent 会卡死。 回归:`tests/qq.test.mjs` 的 `[7e]` 组(14 条:`/pick` 三写法、24 小时与 5 分钟两个窗口的关系、 **当天现场的直接复现**、数字按钮的 data 真的能被路由认出来、按钮超限返回 null、两类回执文案、 两种情况的说法必须不一样、源码级守卫)+ `tests/session-picker.test.mjs` 的 `[2]`/`[3]` 组 (`pickerAge` 的 null/毫秒之分、拦数字 vs 拦截优先级)。 **反向校验(5 条,全部报红后才算数)**: | 把坏改动放回去 | 结果 | |---|---| | 拆掉 `routeMessage` 的 `stray_number` 分支 | `qq 242/1` + `session-picker 35/2` | | 工作区窗口改回 5 分钟 | `qq 241/2` | | 判新鲜时不传 24 小时窗口(只在注释里留名字) | `qq 242/1` | | 工作区名单不挂数字按钮 | `qq 242/1` | | 没名单时改回"现拉一份顶上" | `qq 242/1` | ### 3.6f 派活第二步:在工作区里挑会话,或者开一个新对话(1.0.19) **主人 2026-10-07 的原话**:「我不仅需要挑工作区,还需要在工作区里挑选会话或者新对话」。 改之前 `/task` 是**一个工作区一个固定专属会话**(`taskSessions[cwd]`):挑完工作区就直接 落到那个会话上。两个后果:① 同一个工作区里别的会话接不上(想接着上次那条线索聊,只能去 DSH 桌面端切);② 没有"从零开一个新对话"这个动作 —— `/task` 永远把你送回同一个会话。 **现在的流程**: ``` /task → ① 工作区名单(24 小时有效 + 数字按钮 /pick N) 回 2 / 点按钮 → ② 这个工作区里的会话名单(24 小时有效 + 数字按钮 /open N +「新对话」) 回 1 / 点按钮 → 落到那个会话 回 0 / 点「新对话」/ /new → 在这个工作区新建一个会话,设为当前 ``` 第二步的名单有两条**跟会话名单不一样**的规矩,都是刻意的: 1. **编号 0 = 开新对话**(会话名单里 0 是"取消指定")。这里不存在"不指定"—— 你既然挑了工作区,下一步总要落到某个会话上,0 留给最常用的那件事。 2. **只列这个工作区里的会话**(按 `cwd` 过滤)。`/sessions` 那份是全局最近会话, 混进别的目录就白挑了工作区。 **新指令两条**(都是显式指令,**不查时间窗** —— 和其它名单按钮同一个理由): | 指令 | 作用 | 别名 | |---|---|---| | `/open N` | 选当前工作区里第 N 个会话(`0` = 新对话) | `/开` `/打开` | | `/new` | 在当前工作区开一个新对话 | `/新` `/新对话` | **为什么新增 `/open` 而不是复用 `/use`**:`/use` 是**全局**最近会话(`pickerIds`), `/open` 只认**本工作区**那份名单(`taskSessionIds`)。作用域不同的两条指令不能合并 —— 合并之后一个数字要么按全局算、要么按本工作区算,两种都会在某一半场景里选错东西。 **🔴 两条守卫(都是为了"别把话送到你不知道的地方"):** - **名单不存在就拒绝**(`formatTaskSessionPickAck('no-list')`)。和 `/pick` 同一个道理: 编号只对着你看过的那份名单才成立,拿"此刻的列表"顶上等于让你选一个你没见过的会话。 - **不知道工作区就拒绝**(`'no-workspace'`)。`/new` 在工作区都没挑过时**绝不猜一个目录** 去建会话 —— 建出来的是一个你不知道在哪儿的会话,比让你补一句 `/task` 贵得多。 **状态字段**(`~/.dsh/qq-bot-state.json`):新增 `taskSessionAt` / `taskSessionIds` / `taskSessionCwd`,与 `taskPickerAt` / `taskPickerCwds`(工作区)、`pickerAt` / `pickerIds` (会话)并列。三份名单都用裸数字,所以运行时按时间戳比**谁后发听谁的**,只把最新那份传给 `routeMessage`(见 6.6 的优先级表);三份都新鲜时(本不该发生)按"更具体"的算: 工作区会话 > 工作区 > 会话。 **一个工作区没有任何会话时**:跳过第二步,直接开一个新对话并在回执里说是新建的 —— 信息上等价(你没得选),少一次来往。 **回归与反向校验**:`tests/qq.test.mjs` 的 `[7f]` 组(11 条)+ `tests/session-picker.test.mjs` 的优先级组 + `scripts/verify-installed.mjs` 对**装好的那一份**再验一遍(新增「五之二」段)。 反向校验 7 条全部报红:拆掉 `taskSession` 路由分支 ⇒ `qq 252/2` + `session-picker 36/1`; 按钮不挂额外那一行 ⇒ `qq 252/2`;0 条名单也挂按钮 ⇒ `qq 253/1`;回执不带目录路径 ⇒ `qq 253/1`;"上一份名单"说错是哪份 ⇒ `qq 253/1`;第二步不传 24 小时窗口 ⇒ `qq 253/1`; 拆掉 `no-workspace` 守卫 ⇒ `qq 253/1`。 ⚠️ **装好之后才验得出来**:`verify-installed.mjs` 跑的是 profile 里**安装的那一份**, 所以那几条断言要等 1.0.19 真的装进 desktop profile 才会绿 —— 装之前跑必然红 (这一次实测就是这样;红了反而说明量具真的在读安装产物,而不是在读源码)。 ### 3.6g 新版本提醒没送到、也点不到按钮(1.0.20) **主人 2026-10-07 的原话**:「有新版本,并没有给我的 QQ 机器人推送通知,我也没办法选择立即更新, 或者是忽略此版本。而且我希望在 DSH 的设置里面,也有插件是否接收推送功能的开关」 #### 一、取证:提醒代码没坏,是**时机**错了 两条互相独立的量具: | 量具 | 现场 | |---|---| | `~/.dsh/qq-bot-state.json` | `updateNotified = "1.0.11"`、`updateSkipped = ""` —— 从 1.0.11 之后**一次主动提醒都没成功记过账** | | 进程 / 文件时间 | 宿主进程启动 `10:22:31`(1.0.18 装好后那一次自动重启);1.0.19 是 `11:12:06` 才装进 profile 的 | 把这两条拼起来只有一种解释:**1.0.19 是在启动之后 50 分钟才发布的**。`checkUpdate()` 只在 两个时刻跑 —— `startUpdateCheck()` 里的「启动时一次」与「之后每 `qqUpdateCheckHours` 小时一次」。 10:22 那次跑的时候线上还是 1.0.18(**相等 ⇒ 按 0.8.8 的规矩静默**),下一次排在 6 小时后的 16:22。 于是「有新版本」这件事主人只能自己从别处知道,QQ 里一个字都没有。 ⚠️ 关键认识:**「同一个版本只提醒一次」这个去重是对的,问题在"什么时候去查"** —— 去重只让提醒不吵,不会让它迟到。而人是按"我刚听说有新版本"这个时刻去期待提醒的, 所以间隔必须短到人觉得"推迟了"之前。 #### 二、改了什么 1. **默认间隔 6 小时 → 1 小时**(`DEFAULTS.qqUpdateCheckHours` 与 `startUpdateCheck()` 里的 兜底值**两处一起改**,漏一处就等于配置丢了又退回 6 小时)。去重仍在,查勤不会变吵。 2. **`/update check` 发现新版本时,回的就是那条带两个按钮的提醒** (`formatUpdateAvailable` + `buildUpdateNoticeKeyboard()`),并且 `state.set({ updateNotified })` —— 手动看过就算提醒过,几小时后自动检查不会再重复推一遍。 被 `/skip` 忽略过的版本手动查时照样给看,并在正文里加一句「这是你手动查的,所以照样给你看」: 「忽略本次」的意思是**别再主动提醒我**,不是"我永远不许看这一版"。 3. **设置页最上面一张卡「接收 QQ 推送」**(`src/client.js` 的 `push-switch`)。这个开关本来就在 字段表里(`qqNotifyEnabled`,旧标签叫「远程提醒」),但排在「QQ 机器人通道」后面、混在二十多个 字段中间 —— 主人翻不到。现在卡片标题/说明都取自 `FIELD_SPECS`(单一真源), 分组表里按 `field.key !== 'qqNotifyEnabled'` 排掉它(同一个开关一屏两次最容易让人以为"没生效"), 关着时卡片上多一行警示文字。显示口径改成 `valueOf('qqNotifyEnabled') !== false`: host 侧没给值时 `configView` 回 `null`,若按 `=== true` 渲染就会出现"界面显示关着、插件其实在推"。 #### 三、回归与反向校验 - `tests/update.test.mjs` 新增 `[7]` 组 5 条(间隔 1 小时、`/update check` 的调用形状、 按钮真的可点、帮助里有这条路);`tests/config-view.test.mjs` 新增 3 条(卡片在字段表里、 缺省视为开、分组表不重复渲染);**新增 `tests/client-render.test.mjs`(8 条)把设置页真渲染出来** —— 到 1.0.19 为止浏览器侧只有"正则扫源码"这一种护栏,渲染时抛错的话主人看到的是整页空白, 扫源码一个 ❌ 都不会报。 - 反向校验(把坏改动放回临时副本,断言必须报红,且还原后必须回绿;8 条全中): DEFAULTS 的间隔改回 6 ⇒ `update 95/2`;`startUpdateCheck` 的兜底改回 6 ⇒ `update 96/1`; `/update check` 那一支去掉 `buildUpdateNoticeKeyboard()` ⇒ `update 96/1`; 手动看过不记 `updateNotified` ⇒ `update 96/1`;帮助里删掉 `/update check` 那行 ⇒ `update 96/1`; 字段表标签改回「远程提醒」⇒ `config-view 26/1`;分组表不排 `qqNotifyEnabled` ⇒ `config-view 26/1`; 开关显示写成 `=== true` ⇒ `config-view 26/1`。`client-render` 三处也同样验过: 分组表不排键 ⇒ `7/1`("整页只有一个同名开关"报红)、卡片不渲染 ⇒ `3/5`、显示写成 `=== true` ⇒ `7/1`。 ⚠️ 第一次跑时"标签改回远程提醒"这条**没报红** —— 因为改的是 `src/config-api.js`, 而 `config-view.test.mjs` 读的是 `lib/config-api.js`:**量具读哪一份,就得改哪一份**。 ### 3.6h 「装上了」不等于「在跑」:版本号必须钉在加载那一刻(1.0.21) 主人 2026-10-07 15:22 报「挑选完工作区,还是没有让我挑选会话或者新会话」,我回的是 「**不是功能坏了,是你那台 DSH 里跑的插件还是 1.0.18** —— 两步派活是 1.0.19 才有的,它只在硬盘上」。 他照做之后回了一句更关键的:**「已经是最新版 1.0.20,不用更新。可是它显示现在是最新版本」**。 #### 一、这句话本身就把案子破了 `formatAlreadyLatest` 的原文是 `已经是最新版 ${current},不用更新` —— 一字不差,说明 `handleUpdate` 走的是 `cmp === 0` 那一支,也就是 **`env.version === "1.0.20"`**。 可内存里跑的代码明明还是 1.0.18(三条独立量具:进程启动时间 10:22:31 < 安装时间 12:17、 线上路由的字段标签还是「远程提醒」、状态文件里没有 1.0.19 起必写的 `taskSessionAt`)。 两条结论互相矛盾 ⇒ 矛盾只可能出在**版本号是从哪儿读的**。 旧代码(1.0.9 起就是这个形状): ```js let ownVersionCache = '' // 空 = 还没读过 function ownVersion() { if (ownVersionCache) return ownVersionCache ownVersionCache = readPackageVersion(<磁盘上的 package.json>) // ← 用到的时候才现读 return ownVersionCache } ``` 于是只要**第一次**调用发生在 `pnpm add` 之后,它读到的就是**新版**:插件以为自己在跑新版, `/update` 回「已经是最新版 1.0.20,不用更新」、**不装也不重启**,而主人手机那头永远是旧功能。 这一次正是这样:`updateEnv()`(它的唯一调用者)第一次被调用,就是 15:2x 主人手动发的那条 `/update`。 顺带解释了另一个现象:启动那次自动检查会**先**过三道闸(`qqUpdateEnabled` / `qqEnabled` / `qqNotifyEnabled`),而 `qqEnabled` 的来源是 profile 补丁层或 settings,插件 `apply` 那一刻 **可能还没就绪**;`startUpdateCheck()` 原来写在 `if (liveConfig().qqEnabled) {...}` 里面, 一次跳过就再没有第二次机会 —— 没有启动检查、没有定时器、版本号也从未被读过。 「新版本永远不提醒」和「`/update` 说谎」是同一个根因的两个面。 #### 二、改了什么 | 改动 | 为什么必须这样 | |---|---| | `RUNNING_VERSION`:在**模块加载时**读一次版本号(`const RUNNING_VERSION = (() => …)()`) | 文件是这一刻从磁盘读进来的,版本号必然对得上"内存里的这份代码";`ownVersion()` 直接返回它,**永不现读** | | `installedVersionOnDisk(profileDir)`:磁盘上装的是哪一版(单独一个函数) | 要能同时说出"跑的是 Y、装的是 X",才谈得上判断"该不该重启" | | `/update` 里新增 ⓪ 号分支:**磁盘比内存新**就走 `replyPendingRestart()`,排在"去查线上版本"之前 | 磁盘上那份才是"将要生效"的那份;能自动重启就顺手重启(复用同一套重启脚本),不能就说清"关掉再打开" | | `formatPendingRestart()`:把「装的是 X」「跑的是 Y」「重启才生效」三件事一起说清 | 只说「已经是最新版」字面上没错,但主人会以为没事了 —— 这是本轮真正骗到人的那句话 | | `startUpdateCheck()` 里加补查:`checkUpdate()` 回 `'qq-disabled'` 时 30 秒后再补,最多 3 次 | 配置没就绪时不必白等一整个轮询周期;`UPDATE_LATE_RETRY_MS` / `_MAX` 是常量,好测 | | `index.js`:`qq.startUpdateCheck()` **挪到 `if (qqEnabled)` 外面**,无条件启动 | 一次跳过就没有定时器(见上);它本身是纯出站、可失败的,配置没就绪时 `checkUpdate()` 会自己退出来 | | 运行状态新增一行「插件版本:跑的是 X,装的是 Y」 | 这是主人在界面上**唯一**能看见"装完没重启"的地方;不一致时标 `warn` | #### 三、回归与反向校验 - `tests/update.test.mjs` 新增 `[8]` 组 4 条(97 → 101);`tests/settings.test.mjs` 新增 1 条(状态行)。 - **核心那条是真的跑起来验的,不是扫源码**:在临时目录里造一个 `/profiles/desktop/node_modules/dsh-remote-qqbot/{package.json, lib/}`,拷真实 `lib/`、 把 `package.json` 写成 `9.9.9`,`import` 它(= "9.9.9 这一版被加载了")→ 断言 `runningVersion() === '9.9.9'`;**再把磁盘上的 `package.json` 改成 `9.9.10`**(= "CLI 装完、进程没重启") → `runningVersion()` 必须**仍然**是 `9.9.9`,而 `installedVersion()` 变成 `9.9.10`。 旧代码(现读)在这条上必红 —— 这就是本轮的反向校验。 - `handleUpdate` 里那一段要真联网才跑得动,所以另外钉**调用形状 + 顺序**: `installedVersionOnDisk(...)` 的位置必须早于 `fetchLatestVersion(`,且 `ownVersion()` 里 不许再出现 `readPackageVersion`。 ### 3.7 提问中继:桌面和 QQ **同时**问,谁先答算谁(1.0.27 起;0.7.7~1.0.26 是「先 QQ、桌面兜底」) ``` agent 要提问 ──▶ svc.ask(request) │ ├─ 立刻挂一张桌面卡片(original(request),带「影子 signal」) │ └─ 你在 DSH 里答了 → 答案算数 │ + 摘掉 QQ 的待答条目 │ + QQ 回一句「已经在电脑上答过了」 │ └─ 同时发 QQ ├─ 发出去 → 在 QQ 里等你答 │ └─ 你答了 → 中止影子 signal │ ⇒ api-proxy 自己 claimQuestion(…,'cancelled') │ 卡片在**所有已连上的客户端**上消失 └─ 发不出去 → 桌面那张已经在,什么都不用补 ``` 「提问显示在哪」由一个配置项决定:`qqAskDesktop`(默认 `both`,1.0.27 起)。 | 取值 | 行为 | 适合谁 | |---|---|---| | `both`(默认) | 卡片**立刻**出现,**且**推 QQ;谁先答算谁 | 人可能坐在电脑前、也可能只看手机 | | `qq-first` | 先只发 QQ;发不出去、或 90 秒(`ASK_DESKTOP_FALLBACK_MS`)没人答,才补桌面卡片 | 人基本不在电脑前;0.7.7~1.0.26 的行为 | **为什么 1.0.27 要把默认从 qq-first 改成 both(主人报的真事故):** 他的原话是「插件出现bug,提问弹窗不会显示了,目前只能通过qq机器人会话」。 **弹窗没坏,那是 qq-first 的设计后果**:他随手在手机上把问题答了,90 秒的兜底窗口 **永远等不到**,桌面上一次都没弹过卡片。只要「人在电脑前」和「只看手机」两种情况混着出现, qq-first 就必然制造"弹窗不见了"的观感 —— 而提问恰恰是最不能靠猜的一件事。 那 0.7.7 为什么不敢两边同时等?因为老写法 `Promise.race([viaQq, original(request)])` 会留下一张永远收不掉的卡片(见下)。1.0.27 把那个理由消掉了:卡片是 api-proxy 自己的 `pendingQuestions` 条目,而它会**监听 `request.signal`**(`api-proxy.ts` 的 `ask()` 记下 `request.signal` 并挂 `abort` 回调),abort 时它自己 `claimQuestion(pending, 'cancelled')` —— **删条目 + 广播 `question/resolved`**。所以插件递一个**影子 signal** 进 `original()`, QQ 那边一答完就中止它,卡片立刻消失,新连上的客户端也不会被 mux 重放。 「两边同时等」于是既没有残影,也不用再赌你在不在电脑前。 **为什么绝不能退回 `Promise.race`(0.7.7 修的那个真 bug):** 桌面那张提问卡片**只能由 DSH 自己收起**。旧写法一开头就在桌面创建 pending 记录, 于是「QQ 里答完了、agent 继续跑,卡片却永远挂着」。当年的解法是"一开始就别创建那条记录", 现在的解法是"创建了也能让 DSH 自己收掉"(影子 signal)—— **两个方向都不许退回 race**。 配套细节(两种模式共用): - 桌面那条 Promise 落定后**删掉** `pendingAsks` 里对应条目(否则在更长的 TTL 内全局 FIFO 可能把下一条消息投错); - QQ 先答完 → 中止影子 signal;桌面 Promise 随后以 `ASK_ABORTED` 落定,但外层已经 resolve (Promise 只认第一次落定)⇒ **QQ 的答案总是赢**,也不会产生 unhandledRejection; - QQ 先答完时**必撤**兜底闹钟(`qq-first` 专用)—— 否则 90 秒后一个**已经答过**的提问 会突然弹到桌面上(写测试时抓到的第二个 bug); - `both` 模式下 QQ 只多收一句「已经在电脑上答过了」,且只在**真的发到过 QQ** (`deliveredToQq`)且这轮不是被取消时才发 —— 发失败时压根没有"待答的那条消息",回它纯属噪音; - 配置写坏(未知取值)**一律按 both** 处理:宁可多弹一张卡片,也不能让提问在桌面上静默消失。 **回归与反向校验(1.0.27)**:`tests/qq.test.mjs` 新增 6 条(4 条行为 + 2 条源码/默认值), 原有 12 个 `relayAsk` 用例**全部显式**改成 `config: { qqAskDesktop: 'qq-first' }` (这样"默认是哪一档"本身也有人盯着)。反向校验:删掉 both 模式那行 `showDesktop(...)` 并把 `DEFAULTS` 改回 `qq-first` → 6 条**全红**;恢复后全绿。 **提问的「待答条目」跟提问同生共死(2026-10-05 修的 bug):** 主人报的原话是「AI 想对我提问,我长时间不回答,等我回答的时候就会提示我没有对我进行提问」。 现场时间线(`~/.dsh/qq-bot-state.json` + 会话快照,不是推测): | 时刻 | 发生了什么 | |---|---| | 23:51:38 | 提问发到 QQ(`ask-…-j65zsj`,会话 `session-edc80d93…`),agent 阻塞等待 | | 00:21:38 | **`ASK_TTL_MS` 到期**,条目被静默摘掉(当时日志里一个字都没有) | | 00:45:13 | 主人引用那条提问作答 → 查不到待答提问 → 回「这会儿没有等你回答的问题」,回答被丢掉 | 根因是**把"过了多久"当成了清理依据**:`ASK_TTL_MS` 原本只有 30 分钟,一个人出门/睡一觉回来 再答就超时了;而提问是 agent 在**阻塞等待**的东西,清理依据应该是「这轮提问结不结束」。 现在两条都钉住了: | 位置 | 行为 | |---|---| | `ASK_TTL_MS` | 30 分钟 → **24 小时**,只当兜底(`tests/qq.test.mjs` 里有一条"等了 54 分钟仍算数"的行为测试 + 反向校验) | | `relayAsk` 的 `cancelAsk()` | `request.signal` 一中止(turn 被取消)就**立刻摘掉** QQ 条目,并撤掉兜底闹钟;早中止的 signal 干脆一个字都不发到 QQ | | `oldestPending()` / 兜底闹钟 | 真过期时必须**打日志**(这次故障最难查的就是"提问静默消失") | | 已结束的提问收到回答 | 文案改成说清「提问结束了 / 你这句没送进去 / 下一步怎么办」,而不是一句「没有在等你回答的问题」 | --- ### 3.6i 「已经是最新版」这句话必须自带证据(1.0.22) 主人 2026-10-07 的原话: > 现在每次我发更新的命令,它都会提示目前是最新版本,但是你又告诉我什么版本。 1.0.21 已经把"版本号从哪儿读"修对了(钉在加载那一刻),但**回执本身还是只有一个结论**: `已经是最新版 X,不用更新`。这句孤立的话没法自证 —— 主人手上唯一能对照的东西就是它, 于是他只能在"它说最新"和"我说你跑的是旧版"之间反复摇摆。 这一版把"证据"塞进他手边能问到的两个地方: | 位置 | 行为 | |---|---| | `formatAlreadyLatest()` | 改成 `已经是最新版 1.0.22,不用更新(现在跑的是 1.0.22,磁盘上装的也是 1.0.22)`;两边不一致时逐字区分:`(现在跑的是 1.0.21,但磁盘上装的是 1.0.22)` | | `formatStatusText()` | 新增 `runningVersion` / `installedVersion` 两个可选入参:一致时一行 `🧩 插件 1.0.22(磁盘上装的也是 1.0.22)`,不一致时 `⚠️ 插件:跑的是 1.0.21,磁盘上装的已经是 1.0.22 —— 重启 DSH 才会换过去`;**不传就不多一行噪音**(老调用点零影响) | | `statusText()` 调用点 | 传 `ownVersion()` / `installedVersionOnDisk()` —— 于是 QQ 里发 `/status` 就能问出"我在跑哪一版",不必再翻设置页或找日志 | > 🔴 这一版**故意没有预装**进 desktop profile(`1.0.22.tgz` 只发布、不 `plugin add`)。 > 因为主人这台跑着的还是 1.0.18,而旧代码判断"要不要装"的依据是**版本号比大小**: > 只要磁盘上已经是 1.0.22,它就会回一句「已经是最新版 1.0.22」继续不动 —— 又是同一个坑。 > 让线上(1.0.22)比磁盘(1.0.21)新一格,他发一次 `/update` 就真的会装上并自动重启; > 那一装完,磁盘与线上重新一致。顺带把 profile 的依赖从"本地 tgz 路径"改成**线上 URL** > (`https://cyanovo.top/.../dsh-remote-qqbot-1.0.21.tgz`),这样 `pack.mjs` 删掉旧 tgz 之后 > 依赖里不会留下一个指向已删除文件的 `file:` 路径 —— 那是另一个能让人"插件凭空消失"的坑。 **回归**:`npm test` 19 组全绿;`tests/status.test.mjs` 24 → 25(新增"版本号问得到 + 磁盘比内存新要告警 + 不传不多噪音"), `tests/update.test.mjs` 的「已是最新」那条改成四个逐字断言(含"没给磁盘版本也要说清跑的是哪一版"); 反向校验两条坏改动全部报红(只报一个数字 → `update` 100/1;删掉 `/status` 那行 → `status` 24/1)。 产物自检不打折扣:这一版没装,所以 `verify-installed.mjs` 验的是**已经装在盘上的 1.0.21**; 1.0.22 的 tgz 另行解到"假 profile"布局里 import 它自己的 `lib`,7 项逐条核对通过 (包内版本、代码自认的"跑着的版本"、两处新文案、/status 告警与不吓人)。 --- ### 3.6j 提醒里要写清「这版改了什么」(1.0.23) 主人 2026-10-07 17:34 的原话: > 有新版本了还是没有直接给我推送是否更新啊,我记得之前的某个版本是有新版本提醒的。 > 我现在需要有提醒。而且下边有按键可以选择立即更新 先说清他没收到提醒的原因:他在 **17:31:28** 自己发的那条 `/update` 已经成功把 1.0.22 装上、并在 17:31:42 重启了 DSH(`~/.dsh/dsh-remote-update.log` 里那三行是插件自己写的)。也就是说, 他那一刻**跑的就是线上最新版**,没有任何更新可提醒 —— 提醒的判据是「线上版本 > 现在跑着的版本」, 相等就什么都不发(不刷屏),这是设计如此。 但他说对了一件事:那条提醒**只有版本号**。对着「立即更新 / 忽略本次」两个按钮, 他不知道这一版改了什么,只能盲选。所以这一版把发布说明接进提醒: | 位置 | 行为 | |---|---| | `fetchLatestVersion()` | 索引里的 `notes`(发布脚本 `--notes` 写进去的那段话)跟着查新结果返回;不是字符串就当没有,取不到就是空串 —— **绝不影响更新本身**(否则就是 `[object Object]` 进文案) | | `formatUpdateNotes()` | 压平空白、按**码点**截断到 `UPDATE_NOTES_MAX_CHARS`(120)并加省略号;空串表示"别提这一句" | | `formatUpdateAvailable()` | 有 notes 时多一行 `这版改了什么:…`;没有则与 1.0.17 起的正文**逐字一致**(老索引、GitHub / npm 两种源都不会多字) | | `formatUpdateCheck()` | 手动查一次走同一口径(同一句话、同一对按钮) | > 🔴 与 1.0.22 同一手法的第二次:这一版**同样只发布、不预装**。因为 `/update check` 里 > "磁盘比内存新"那一格(1.0.21 加的)排在查线上版本**之前** —— 磁盘要是已经 1.0.23, > 他手动查一次拿到的会是"装了还没重启"的回执,**而不是带两个按钮的提醒**。 > 让线上(1.0.23)比磁盘(1.0.22)新一格,他手动查一次就能看到完整的提醒 + 两个按钮; > 点「立即更新」它会装 1.0.23 并自动重启,两边随后重新一致。 > 顺带确认:`publish-update.mjs` 只上传、**不删**服务器上的旧包,所以 profile 里那条指向 > 1.0.22 线上地址的依赖不会因为发新版而悬空(实测 HTTP 200)。 **回归**:`npm test` 19 组全绿;`tests/update.test.mjs` 101 → 103(+「索引里的 notes 要跟着回来」 的行为测试,+「提醒里写清这版改了什么」的逐字与边界测试:换行压平、超长截断、表情符号不切半个码点、 老索引不带 `notes` 时正文与旧版逐字一致)。 --- ### 3.6k 不引用、不带 `/` 就是闲聊 —— **无论任何情况**(1.0.24) 主人 2026-10-07 18:1x 的原话: > 我需要只要我不引用信息或者信息前边不带/的命令就是闲聊,无论任何情况。有没有和其他的逻辑撞上,如何优化 **撞上了四处**(这就是他要问的"有没有和其他逻辑撞上"): | # | 旧规则 | 旧去向 | 与"不引用不带 / 就是闲聊"的关系 | |---|---|---|---| | ① | 有提问在等(24 小时内) | 不引用也当成**作答** | 直接冲突:一句话是不是回答,取决于 agent 此刻在不在等 | | ② | 三份名单(工作区 / 工作区里的会话 / 会话)新鲜 + 纯数字 | `pick_*`(选名单) | 冲突:同一句「2」的去向取决于他刚看过什么 | | ③ | 纯数字、没有任何名单能解释 | `stray_number`:**拦下不投递**,回一句"我没把它当派活内容" | 冲突:既不闲聊、也没送出去 | | ④ | 提问下面的选项按钮(`data` 是裸数字 `1`/`2`) | 靠 ① 才生效 | 不是冲突而是**依赖**:规则一删,按钮就白发一句闲聊 | **改法**(`routeMessage` 只剩三条分支,见 [qqbridge.js](../src/qqbridge.js) 顶部的注释): 1. `① / 开头的指令`(含 `@机器人 ` 前缀)→ `routeIncoming` 2. `② 引用` → 那条消息对应的会话;引用提问 = 作答(精确到 `askId`) 3. `③ 其余一律 chat` → 进 `/sessions` 指定的会话,没指定就是闲聊会话 连带删掉:三个"名单新鲜"参数(`pickerActive` / `workspacePickerActive` / `taskSessionPickerActive`)、整个 `stray_number` 分支、`formatStrayNumber()`, 以及 qqruntime 里为了算它们而维护的 `winner` / `listAges` / `newestList`。 现在只剩一个"刚发过哪份名单"的判断,**用途只有一个**:决定提示里说哪一份。 4. 显式化"作答"与"选名单"(否则按钮会哑): - 选项按钮 `data`:裸数字 → **`/answer N`**(解析答案那条路本来只认数字,所以照样一次点中) - 提问正文改写:`要回答:点下面的按钮;或者**引用这条消息**回我一句…`;没选项的题写 `引用这条消息回我一句,直接写你的答案就行`(不再说"回数字比如 1") - 名单正文改写:`点下面的按钮,或者发 /pick 数字` / `/open 数字` / `/use 数字` - `/answer 你的回答` 重新写进 `/help`(1.0.16 撤过菜单项,但这条路现在是"自由作答的提问" 唯一入口) 5. **两条提示,不改投递**(都在 `case 'chat'` 里、都在 `handlePrompt` **之后**): - 有提问在等:`(说一声:这句我当闲聊发了 —— 有个提问在等你回答(来自「X」)。要回答它: 引用那条提问再发一次,或者点它下面的按钮,或者发 /answer 你的回答。)` **同一条提问只提示一次**(`answerHinted` 集合按 `askId` 去重),否则他每说一句都被念一遍。 - 刚发过名单却手打了一个数字:`(说一声:这个数字我当闲聊发了。要选刚才那份工作区名单: 点上面的按钮,或者发 /pick 2。)` **知情代价**(写在这里,免得以后有人当 bug 修): - agent 卡在提问上时,一句普通消息**不再**当答案 ⇒ 它得等主人引用作答或点按钮。 这就是"无论任何情况"的价格;提示兜住了"主人不知道自己在干吗"的那一半。 - 2026-10-07 09:09 那个「2」(名单过期后回的数字)现在会**真的进会话**。 1.0.18 当初是为此加的拦截;主人当晚选择"可预测"优先于"帮我猜",所以拦截撤了。 **回归**:`npm test` 19 组全绿;`tests/qq.test.mjs` 254 → 256、`tests/session-picker.test.mjs` 35 → 36。两个文件里原先断言旧优先级的用例**反向重写**(裸数字必须 chat、`stray_number` 必须 不存在、按钮必须发 `/answer N`),并新增源码守卫:`routeMessage` 签名里不许再出现那三个开关。 --- ### 3.8 完整回答的三档去向:正文到底会不会离开本机(0.8.6) `agent/status` 的 `idle && prev==='running'` 分支里,非闲聊会话发起一次**三选一**: ``` cfg = liveConfig() // 现读,热更新立即生效 mode = normalizeFulltextMode(cfg.qqFulltextMode) // 非法值 → 'chat'(永不误上传) upload = cfg.cloudEnabled === true && uploadsFulltext(mode) !upload ─┬─ 若 mode 本来想要上传 → log(说清是哪条闸挡住的) └─ push(turn-complete, summary, { fulltext: composeChatAnswer(parts, qqFulltextMaxChars) }) └─ push() 里:kind==='turn-complete' 且 fulltext 非空 → qq.sendQqFulltext({ text }) ← 零字节出网到"我的服务器" upload ──→ publishTurnNote(agent, parts) → notes.js → POST /api/notes └─ push(turn-complete, summary, showsFulltextLink(mode) ? { noteUrl } : {}) ``` 三条硬约束(都在 `src/fulltext.js` 里做成纯函数,所以能单测、不用起 DSH): | 函数 | 语义 | |---|---| | `normalizeFulltextMode(v)` | 只认 `chat` / `note` / `note-link`,**其它任何值 → `chat`**。宁可少一个功能,不可因拼错配置就把正文传出去 | | `uploadsFulltext(mode)` | `note` / `note-link` 才是 true;`chat` 永远 false | | `showsFulltextLink(mode)` | 只有 `note-link` 为 true | **为什么把"总闸"和"模式"拆成两个开关**:模式表达**意图**(我想怎么用正文), 总闸表达**授权**(我同意这台服务器接住我的正文)。只有两者都满足才发生上传; 于是"默认不发"这件事**不依赖任何人的自觉**,而是合并逻辑的必然结果 —— 探针实测:`chat` + `cloudEnabled:false` 时 `/api/notes` 请求数为 **0**。 **分条发送**(`chat` 档):`planQqFulltext` = `splitForQq`(段落 → 行 → 硬切,不丢字符) + `markQqChunks`(给每条加 `(i/n)` 前缀)。两个必须记住的点: 1. **`planQqFulltext` 已经加过序号,调用方不许再加一次**(`markQqChunks` 在 `splitForQq` 之后); 2. **序号算在长度预算里**:`MARKER_RESERVE = 9`(`(1/15)\n` 是 8 码点,留 1 余量), 所以 `bodyLimit = max(1, limit - MARKER_RESERVE)`。 🔴 这里原来写的是 `Math.max(MIN_CHUNK_CHARS, …)`(下限 100), **当 `limit ≤ 109` 时 `bodyLimit` 被抬到 100 以上,加序号后反而超限** —— 实测 `limit=100` 时产出 106 字的 chunk(超限 6)。取消这个下限后才真正满足"带序号不超限"。 **`maxChars` 的两种含义别混**:`qqFulltextMaxChars`(默认 6000)管的是**正文总量** (`composeChatAnswer` 按码点截断),`QQ_TEXT_SAFE_CHARS`(900)管的是**每条 QQ 消息**。 所以 `push()` 调 `qq.sendQqFulltext({text})` 时**故意不传 `maxChars`**(代码里留了 🔴 注释)。 ### 3.9 入站注入的前置条件:桌面版的会话签名记录(1.0.1 真事故) **这条是 2026-10-04「引用消息没办法回答」的根因,改这个插件之前必须知道。** `127.0.0.1:19387/api` 的 cookie(`dsh-auth-`)用 `~/.dsh/.credentials.yaml` 里 `client-connection/browser-session` 这条 grant 记录的 `payload.secret` 做 HMAC 签名(桌面端 app.asar 里 `credentialKey("client-connection", "browser-session")` + `storedSecret()`)。**这条记录不在文件里 ⇒ 插件换不出 cookie ⇒ 入站注入全线 401。** 三条要记住的事实: 1. **出站与入站是两条独立的腿。** 推送只走 QQ 长连接,不需要这个密钥。所以故障形态是 「通知一切正常、引用回复全死」—— 只看「QQ 连着」会误诊(本次就是这么误诊的)。 2. **桌面端是「Connection 激活时加载**或创建**」这条记录,并把 secret 留在内存里。** 它内存里还留着旧 secret 时,桌面上一切正常,而文件里已经没有这条记录了 —— 插件怎么读都读不到,只有**桌面端重启**才会把记录重新落到文件里。 实测:`.credentials.yaml.bak` / `.credentials.yaml.preflat` 里那把旧 secret 拿去打 `session/list` 得到 `HTTP 401 unauthorized`,反向对照(故意错的 secret)同样 401 —— 探针可信, 旧密钥确实**救不回来**。 3. **所以插件做了三件事(`src/qqruntime.js` 的 `ensureApi()`)**: - 每次要用时**重读**密钥,而不是启动时读一次就永久放弃(`api = null` 的旧写法 让插件一直到重启前都没有第二次机会); - 记录**完全不存在**时自己补一条:`healBrowserSessionRecord()` (`src/qqbridge.js`)先整份备份到 `.credentials.yaml.bak-remote-qqbot-<时间戳>`, 再用 tmp + rename 原子写入 `kind: grant` / `payload.version: 1` / `secret` = base64url(32 随机字节),**权限位照抄原文件**。 形状与桌面端 `storedSecret()` 的三条校验、以及桌面端自己写过的记录完全一致 (`tests/qq.test.mjs` 的 `[2b]` 组把这三条校验钉住了); - 补过之后把「重启一次 DSH 桌面版即可恢复」写进**报错文案**与 QQ `/status` (`formatStatusText({injectError, injectNote})` 新增的两行)。补写只在记录 **完全不存在**时发生;存在但格式怪 → 直接报错,绝不覆盖(那是别人家产品的凭据文件)。 `onEvent` 的 catch 也补了最后一道兜底:任何没被各路由接住的异常,也要在 QQ 里回一句, 不再出现「你什么都收不到」的静默失败(`chat` / `task` 两条路过去就会这样)。 ### 3.10 远程更新:怎么装、装哪儿、怎么重启(1.0.11) 整条链是**四段**,每段都可能失败,所以每段的失败都被收敛成「日志 + 一句人话」, **绝不允许把异常抛进 agent 主流程**(更新是顺手做的事,不能因为它把会话搞崩)。 **① 查版本(`fetchLatestVersion`)** 源有**三种**写法,`parseUpdateSource` 解析: - **版本索引(默认)**:一个 **https** 地址,返回 JSON(`{version, url, sha256, size?}`)。默认值 `https://cyanovo.top/plugins/dsh-remote-qqbot/update.json` —— 自己服务器上的静态文件(nginx 直出)。 `pnpm add` 用的是**索引里的 `url`**,不是索引地址本身;请求带 `cache-control: no-cache`。 🔴 **索引地址与索引里的 `url` 都只认 https**(1.0.25 起),`http://` 仅放行**本机回环** (`127.0.0.1` / `localhost` / `[::1]`,留给本地调试):明文通道上,能改索引的人顺手就能换包。 校验必须严:索引地址打错时宿主的单页应用会回**首页 HTML 且状态码 200**(实测), 所以只认「对象 + 非空 `version` + https 的 `url`」,缺字段逐条说清(`versionFromManifestResponse`)。 - `github:作者/仓库[#ref]` → `https://api.github.com/repos/<仓库>/contents/package.json` (`raw.githubusercontent.com` 在本机不可达,所以走 contents API,`content` 是 base64) - npm 包名 → `https://registry.npmjs.org/<包名>/latest` 带 UA、20 秒超时。 **② 比版本 —— 这里必须三分支(`compareVersions`)** `>` 才提醒/安装;`===` 回「已经是最新版」并静默;`<` 回「远端还旧」并静默。 实测过:GitHub 上还是 1.0.5,而本机已经 1.0.6 —— 只判「不一样就装」等于**把用户降级**。 版本号比较按数字段(`1.0.10 > 1.0.9`)、认 `v` 前缀、pre-release 排在同号正式版之前、忽略 `+build`。 **③ 装(`buildAddCommand` + `runProcess`)—— 通道选择是这一版最重要的硬事实** 🔴 **装之前的本机校验(1.0.25,`fetchAndVerifyPackage`)**:索引里的 `sha256` 只是"发布方说它是什么", 索引与压缩包可以**一起**被换掉,所以凡是从 http(s) 直接取回的包都要在本机重算摘要: ``` spec 是 http(s) → fetchAndVerifyPackage():sha256 缺失/不是 64 位 hex → 拒绝(连请求都不发) → 落临时文件 → 从磁盘读回 → createHash('sha256') 比对(size 给了也比) → 不符:删文件 + 拒绝(回执给出期望/实际摘要) → 相符:把 spec 换成**本地文件路径**再交给 pnpm(不再去网上取那份没验过的包) → finally 删临时包 spec 是 github: / npm → 不插手,交给 pnpm 自己的完整性校验 ``` PATH 上的 `dsh` 在开发机上指向一份**源码检出**(`node --import tsx/esm .../bin.ts`), 拿它更新会装到别的地方;所以顺序是: 1. **桌面版自带运行时**(首选):`spawn(process.execPath, ['--expose-internals', , 'add', spec])`, `cwd` = profile 目录、`env` 带 `ELECTRON_RUN_AS_NODE=1`(同一个 exe 当 node 用,官方 `node.cmd` 就是这么干的); 这些路径全部从**本进程自己的 `process.argv`** 里认(`parseDesktopRuntime`),不猜安装位置; 2. `%DSH_DESKTOP_NODE_EXECUTABLE%` + 同一个 pnpm.mjs; 3. PATH 上的 `dsh`(Windows 上 `.cmd` 不能直接 spawn,要 `cmd.exe /c`,路径带空格必须加引号); 4. profile 目录里的 `pnpm add`。 profile 目录的判定是「`package.json` 里有 `dsh.profile`」,不是目录名(`isProfileDir`)。 子进程输出**必须重定向到临时文件**(`stdio: ['ignore', fd, fd]`)—— Windows 沙箱下用管道收子进程输出会 `EPERM`,这条踩过。安装超时 180 秒,超时就 kill。 **④ 重启 + 回执** - 装的版本号从**安装目录的 `node_modules/dsh-remote-qqbot/package.json` 读回来**, 不靠「命令成功」推断(pnpm 可能打印 `Already up to date` 却什么都没换); 版本没变就老实回「版本还是 x.y.z,远端可能还没发这一版」。 - 重启靠一个**临时目录里的 PowerShell 脚本**:等 10 秒(让那条回执先发出去)→ 按**可执行文件路径**杀进程(同名进程可能不止一个)→ 等它真的消失(最多 20 秒)→ 再稳 3 秒 → 启动 → 最多 3 次、每次隔 5 秒 → 全程追加写 `~/.dsh/dsh-remote-update.log`。脚本文件**带 BOM**(PowerShell 5.1 才认中文)。 - 🔴 **启动新进程前必须清掉 `ELECTRON_RUN_AS_NODE`** —— 这是 2026-10-05 那次 「`/update` 装好了、DSH 却没自己回来,主人手动打开」的根因,现场取证如下: 插件跑在桌面版的 host 子进程里,host 是**把 Electron 当 node 用**跑起来的,所以插件进程 (以及它派生的一切:`cmd` → 启动器 → PowerShell 脚本)都带着 `ELECTRON_RUN_AS_NODE=1`; `Start-Process` 把这个环境原样传给了新进程,而带着这个变量的 `DeepSeek Harness.exe` **只会当 node 跑一下就退出**(实测 `--version` 输出 `v24.18.1`、退出码 0) —— 桌面版根本没启动:没有进程、没有 `lockfile`、没有崩溃日志,脚本只看到「启动后没看到进程」, 三次重试全一样;主人双击能起来,是因为 explorer 的环境里没有这个变量。 修法是脚本开头 `Remove-Item Env:<名单>`,名单常量 `RESTART_POISON_ENV` 是唯一来源 (`ELECTRON_RUN_AS_NODE`、`ELECTRON_NO_ATTACH_CONSOLE`、`NODE_OPTIONS`, 加上这次会话自己的标记 `DSH_SHELL` / `DSH_SESSION_ID` / `DSH_WEB_URL`)。 - 启动有**三种方式依次兜底**:`Start-Process -WorkingDirectory ` → `explorer.exe ""`(和主人双击同一条路,环境天然干净)→ `Invoke-CimMethod Win32_Process Create`(由 `WmiPrvSE` 服务建进程,完全不沾我们的进程树)。 前一种失败要把原因写进日志再换下一种,不能默默跳过。 - **认进程用两套量具**:`Get-Process` 的 `.Path` **常常读不到**(刚创建的进程实测就是空串), 所以读不到时用 `Get-CimInstance Win32_Process` 的 `ExecutablePath` 兜底 —— 只看前者会把「其实已经起来了」判成失败。 - **"看到进程"也算不上成功**:抢单实例锁失败会「起来又秒退」,所以看到进程后还要再盯 4 秒; 盯不住就换下一种启动方式。**新进程"起了又没"时日志会带上它的退出码**(`Get-ExitCodeText`, 需要 `-PassThru` 拿到的那个进程对象)—— 秒退这类故障看一眼退出码就能定性(当 node 跑那次就是 退出码 0、零点几秒结束)。全部失败时把桌面版最新的 `crash-*.log` 尾巴抄进同一份日志 (下次不用满硬盘找线索)。 - **"进程建出来了"不等于"脚本在跑"**:脚本第一件事就是写日志,所以拉起之后要用 `verifyRestartLaunched()` 等日志长出来(最多 4 秒,落在脚本自己的 10 秒延迟之内); 没长出来就回「自动重启没能排上,手动重启 DSH 后生效」,而不是让主人干等一个不会发生的重启。 - ⚠️ **插件自己活不到重启那一刻**(它会被自己拉起的脚本杀掉),所以「重启成功没有」它当场 验证不了,只能事后靠状态文件里的标记回执。因此装好那条回复必须**提前把兜底动作说清楚**: 「要是过了一分钟还没回来,手动打开 DSH 就行」—— 2026-10-05 就是没说这句,主人对着 QQ 等 一条永远不会来的确认。 - 🔴 **怎么把脚本拉起来,这一版踩过坑**(2026-10-05 四种配方差分实测): 直接 `spawn('powershell.exe', …, {detached:true, stdio:'ignore'})` 时**脚本一行都不执行** (进程建出来了、`unref()` 也正常,就是没动静);同一个 spawn **不分离**时脚本能跑、 但活不过父进程结束;`spawn(process.execPath, …, {detached:true})` 反而正常 —— 所以不是"分离"本身的问题,是"分离 + powershell.exe"这个组合。 最终方案:多写一个一行的 `.cmd` 启动器(`start "" /min "powershell.exe" … -File 脚本`, 内容保持全 ASCII),再用 `spawn(comspec, ['/c', 启动器], {detached:true, stdio:'ignore', windowsHide:true})` + `unref()` 拉起 —— 实测这是唯一"既跑得起来、又活得过父进程"的配方。 - **"进程建出来了"不等于"脚本在跑"**:脚本第一件事就是写日志,所以拉起之后要用 `verifyRestartLaunched()` 等日志长出来(最多 4 秒,落在脚本自己的 10 秒延迟之内); 没长出来就回「自动重启没能排上,手动重启 DSH 后生效」,而不是让主人干等一个不会发生的重启。 - 重启目标 exe **不写死路径**:优先环境变量 `DSH_DESKTOP_EXE`,否则从本进程的 `process.argv` 里认(`parseDesktopRuntime`)。认不出来(web 版 / 纯 node 版)就**不自动重启** —— 杀掉别人的服务是灾难,而且拿 node 也起不回一个服务。 - **重启后的确认**靠状态文件里的 `updateRestart` 标记:插件下次启动时 `reportRestartIfPending()` 先**清标记再发**(发失败也不会每次启动都重发),文案带上新版本号与用时。 **⑤ 提醒的闸门** 自动提醒要求 `qqUpdateEnabled !== false` **且** `qqEnabled === true` **且** `qqNotifyEnabled !== false`; 手动 `/update` 不受这三个开关限制(用户主动要求的事不该被静默吞掉)。 状态文件里 `updateNotified` 记住「已经提醒过哪个版本」,同一个版本只提一次 —— `/update check` 手动看到过带按钮的提醒时**也记这一笔**(1.0.20), 否则几小时后的自动检查会把同一条再推一遍。 **什么时候去查**同样重要(1.0.20 的教训):只在"启动时 + 每 `qqUpdateCheckHours` 小时"查, 间隔太长就会出现"新版本已经发布、QQ 里却一直没有提醒" —— 见 3.6g。 离线测试在 `tests/update.test.mjs`(101 条):版本比较、三种源解析(含"索引地址打错拿到首页 HTML 也必须判失败")、三分支、**用本机实测 argv** 跑通道选择、注入假 `spawn`/`fs` 跑 `runProcess` (含超时,并断言 `stdio` 里不许出现 `pipe`)、重启脚本的形状与 BOM(含"清环境变量的动作必须排在 任何启动动作之前"、退出码诊断)、启动器的形状与 `cmd /c` 配方、"日志长出来才算脚本在跑"的正反两向、 **新版本提醒的间隔与"手动要一次也带两个按钮"(`[7]` 组)**, 以及每一条用户可见文案的逐字断言。 另有一次性端到端探针(跑完即删):`.tmp-restart-e2e.mjs` 用 `cmd.exe` 的副本当假 DSH, 让它把**自己继承到的环境**导出到文件并靠 `ping` 撑住不退出,从而真实走一遍 「写脚本 → 写 `.cmd` 启动器 → `cmd /c` 拉起 → 杀进程 → 清环境 → 启动 → 4 秒复检 → 写日志」, 再用"直接起同一个假 DSH"作反向对照,证明量具本身认得出 `ELECTRON_RUN_AS_NODE`; `.tmp-restart-exitcode.mjs` 用 `whoami.exe` 的副本(永远秒退)走失败路径, 断言日志里出现「已经退出,退出码 0」、三种启动方式都试过、最后落到「重启失败」。 --- ## 四、模块职责与关键导出 | 文件 | 允许碰 DSH? | 关键导出 | 说明 | |---|---|---|---| | `index.js` | ✅ | `apply(ctx, config)`、`Config`、`inject` | 事件订阅、工具注册、`liveConfig()`、启动自检、挂 config 路由 | | `qqbot.js` | 只碰网络 | QQ 客户端类 | 取 token、WS 长连接、心跳、REST 发送 | | `qqbridge.js` | ❌ **零裸导入** | `formatQuestionBody`、`formatStatusText`、`summarizeSession`、`qqPreviewUrl`、`formatSessionPickerText`、`isPickerFresh`、`formatPickAck`、入站路由 | 所有排版与路由纯逻辑,**可以被单元测试直接跑** | | `qqruntime.js` | ✅ | `wrapUserQuestions`、`relayAsk` | 生命周期接线、中继、作答 | | `collab.js` | ❌ 零裸导入 | 注册表操作、注入渲染、`DISABLED_NOTE` | 协作模式全部纯逻辑 | | `notes.js` | 只碰 `fetch` | `publishTurnNote` | 渲染 markdown + 上传(测试注入假 fetch,不出网) | | `screenshot.js` | 只碰 `child_process` | `buildPowerShellScript`、`parseShotOutput`、**`parseRawOutput`**、`captureCommand`、`captureScreen`、`isAvBlocked` | 抓屏;脚本是**纯 ASCII**、**先认领 DPI 感知再读 `VirtualScreen`**(否则只截左上角,见 6.5)、且**不许含 `GetImageEncoders`/`EncoderParameter`**(会触发 AMSI);临时目录 `finally` 必删 | | `summary.js` | ❌ 零裸导入 | `blocksToText` 等 | 从会话事件提取摘要 | | `agentmd.js` | ❌ 零裸导入 | 表格定位与追加 | 纯字符串处理 | | `config-api.js` | 只碰 webServer | `configView`、`mountConfigApi`、`FIELD_SPECS`、`isTrustedApiRequest` | 字段表 + 白名单 + 信任校验;**失败时 `ok:false` 必须写在 `...configView(...)` 之后**(否则被 `ok:true` 掩盖) | | `overrides.js` | ❌ 零裸导入 | `OVERRIDE_FILE_NAME`、`overrideFilePath`、`filterPatch`、`readOverrides`、`writeOverrides`、`persistConfigPatch` | 原生写入被拒时的落盘(原子写:临时文件 + rename);**写不进去必须抛**,不许静默 | | `client.js` | 浏览器侧 | 设置页组件、`InputDockControls` | 见下 | **分层的硬规则:纯逻辑必须能脱离 DSH 单测。** 所以上面标 ❌ 的五个文件一个裸包都不 import; 只有 `index.js` / `qqruntime.js` / `config-api.js` / `qqbot.js` 允许接触 DSH 服务或网络, 它们的护栏放在 `tests/plugin.test.mjs` 里用 **mock ctx** 跑。 ### `client.js` 的三条限制 1. **不能用 npm 依赖**:前端只冻结共享了 `react` 等约 10 个平台模块。 所以样式全是内联 style、图标用字符、不做 animate 库。 2. **配置读写只能走同源路由** `/remote-qqbot/api`(`config-api.js`)。 3. **它必须被 DSH 的 client-modules 找到**:靠 `package.json` 的 `exports["./client"]` + `dsh.client.platform: "web"`。 UI 上目前有两个挂点: | 挂点 | 组件 | 内容 | |---|---|---| | 设置页 | 独立一项「QQ 提醒与记忆」 | **最上面一张卡「接收 QQ 推送」**(键 `qqNotifyEnabled`,1.0.20)+ 按 `FIELD_GROUPS` 分组的表单(QQ 远程提醒 / 通知中枢 / agentmd / 协作模式);分组表里按 `field.key !== 'qqNotifyEnabled'` 排掉那个键,同屏不重复 | | 输入框下方 | `InputDockControls` → `MiniToggle` | **只有两个**:「QQ 提醒」「协作模式」。**24px 高、12px 字、无边框底色阴影**,悬停才浮出极淡底色 | | 设置面板 | 插件配置卡片(`configView.fields`) | 其余全部配置,含 `qqFulltextMode`(三档全文去向)与 `cloudEnabled`(云上传总闸) | > 设计约定(用户明确要求过「太显眼」):**状态是"知道就好"的信息,不该占据视觉重心。** > 加新开关请复用 `MiniToggle`,不要新造一个更抢眼的控件。 > > ⚠️ **但不要往输入框下面再加开关。** 2026-10-03 用户原话: > 「除了 QQ 提醒已开、协作已开,剩下的放到设置里就行」—— > `qqFulltextMode` / `cloudEnabled` 曾经也是这里的小开关(2026-10-03 上午才有),现已**撤回设置面板**。 > 判据是「抬手就要按」:某项**开了没开必须一眼看到**才配待在输入框旁; > 三档全文去向属于"配置",一年也改不了几次。 > `scripts/verify-ui-installed.mjs` 会**数** `InputDockControls` 里 MiniToggle 的个数(必须恰为 2), > 谁加回去谁报红;同时另有两条断言要求设置字段表里**必须还有** `qqFulltextMode` / `cloudEnabled` > —— 合起来守住"只搬位置、不砍能力"。 --- ## 五、配置系统为什么这么绕 ### 5.1 `DEFAULTS` 是 40 个键,且**一律留空 = 功能关闭** ```js const DEFAULTS = { hubUrl: '', token: '', qqAppId: '', qqClientSecret: '', ... } ``` 🔴 **不要往 `DEFAULTS` 里填真实的生产配置。** 它同时被单元测试使用——一旦里面有真实的 `hubUrl` / `qqAppId`,跑测试就会**真的向线上中枢发请求、真的给用户推 QQ 通知** (2026-10-02 踩过:测试套直接挂死)。 ### 5.2 五个必须记住的加载/配置期约束 | # | 约束 | 违反后的现象 | |---|---|---| | 1 | `Config` 必须是 **schemastery schema**,不能是普通 JSON Schema | `TypeError: Cannot read properties of undefined (reading 'validate')`,**整个插件树加载失败**(不只坏这一个插件) | | 2 | 读配置必须 `ctx.settings.register(ns, Config, {base})` 再 `scope.get()` | `settings.get(ns)` 未注册一律 `undefined`,`?? {}` 兜底 = **静默降级**:日志说「已就绪」,用户配置全失效 | | 3 | **不要把 cordis 传进 `apply` 的 `config` 合并到 settings 之上** | 那个 `config` 已被 schema 用默认值**填满**(含空串),合并会把真实值全盖成空 | | 4 | 不要 `import` DSH 的 workspace 包 | 加载期 `ERR_MODULE_NOT_FOUND`(profile 里没有那些包) | | 5 | **配置写入不能只信原生通道;失败更不许被吞** | 桌面版对第三方 namespace 的 `update()` 必抛;若把 `ok:false` 写在响应展开之前,前端会看到 `ok:true` ⇒ **点了没反应也不报错**(0.8.4 修的正是这条) | 第 3 条的正确写法(源码在 `index.js` 的 `liveConfig()`): **只把 `config` 里与默认值不同的键当显式覆盖**,其余以 settings 为准。 ### 5.3 为什么有 `settings.yaml` 和 `cordis.patch.yml` 两份 - `cordis.patch.yml`(随插件包发出去)只负责一件事:**把插件插进 profile 的插件树**; - 真实配置(含 QQ 凭证)在 `~/.dsh/settings.yaml` 的 `dsh-remote-qqbot` 段, 或 profile 的补丁层里。 ⚠️ 已知环境设计:**桌面版启动时会把 `~/.dsh/settings.yaml` 改名成 `settings.yaml.imported`** (`SettingsForms.importLegacyDocument()`),用户层因此可能长期为空。所以: - host 侧的 `liveConfig()` **自己合并了补丁层**,一直是准的; - 而 `settings.describe().value` **不含 profile 补丁层** ⇒ 早期 UI 读它 → 界面显示「QQ 未连接」, 而 QQ 其实一切正常(骗过两轮修复的 bug)。 **修法(照做,别再改回去)**:让 UI 去读**插件真正在用的数据源**, 即 `mountConfigApi(ctx, { getLiveConfig: liveConfig })`,由 `configView()` 以 `describe()` 为底、 再用 host 真值覆盖。来源会写进响应的 `diag.source` 便于排障。 ### 5.3b 写配置:原生被拒 → 落插件自有覆盖文件(0.8.4) 「读」修好之后,「写」还有一个独立的坑,症状正是用户报的 **「点 QQ 提醒 / 协作模式这两个开关,点了没用,永远保持开启」**。 三个缺陷叠在一起(都在 0.8.4 修掉): | # | 缺陷 | 后果 | |---|---|---| | 1 | namespace 名对不上:插件注册 `dsh-remote-qqbot`,profile 里 entry id 是 `remote-qqbot` | `settings.update()` 抛 `No configurable plugin entry "dsh-remote-qqbot"` | | 2 | `describe()` 会**跳过没有 volatile 字段的条目** | 原生通道连「可写」都认定不了 | | 3 | `config.set` 的成功响应里 `...configView(...)`(自带 `ok:true`)**排在** `{ok:false, error:{code:'settings-rejected'}}` **之后** | 失败被覆盖成成功 ⇒ 前端把拒绝当成功、界面弹回原样且不报错 | 写入顺序(`overrides.js` 的 `persistConfigPatch`,返回 `via` 供 UI 区分): ``` 1. 先试原生 settingsScope.update() → 成功则 via='dsh-settings'(DSH 若放开第三方写入即自动走官方通道) 2. 抛错 → 原子写 ~/.dsh/remote-qqbot-overrides.json → via='override-file' 3. 覆盖文件也写不进去 → 抛错;路由回 ok:false + error.code='settings-rejected' (此时 ok:false 必须出现在 ...configView(...) 之后) ``` 合并优先级(`liveConfig()`,后者盖前者;覆盖文件**故意排最后**): ``` DEFAULTS < settings 用户层 < cordis.patch.yml 显式键 < remote-qqbot-overrides.json ``` 否则 `cordis.patch.yml` 里写死的 `collabEnabled: true` 会让「关掉协作」永远关不掉。 > ⚠️ 覆盖文件是**明文 JSON**,`qqClientSecret` / `token` 同样明文落盘 —— 与 `cordis.patch.yml` > 同级别的暴露面,文档必须写明(README 已写)。UI/API 侧仍只回报 `secretsSet`,绝不出明文。 > > ⚠️ 删掉这个文件 = 开关回到 profile 配置里的旧值,没有别的暗桩。 ### 5.4 secret 永不回显 `configView()` 对 `qqClientSecret` / `token` / `dshSecret` **只回报 `secretsSet: true|false`**, 绝不返回明文。UI 也据此显示「已设置 / 未设置」。写回时才接受新值。 ### 5.4b 三个键「只能手改配置文件」(1.0.25) `isTrustedApiRequest` 挡的是**浏览器跨站**与 DNS-rebinding(Host 必须回环、拒 `sec-fetch-site: cross-site`), **它不是身份认证** —— 本机任何进程、页面里任何同源脚本都能带一个合法 Host 打这个路由。 所以「改一下就能把内容或代码引到别处」的三个键在服务端**硬拒**: | 键 | 为什么要摘出去 | |---|---| | `cloudUrl` | 决定完整回答正文发去哪台服务器(配合 `cloudEnabled` / `qqFulltextMode`) | | `hubUrl` | 决定摘要与会话标题发去哪台服务器 | | `qqUpdateSource` | 决定装哪份代码:索引里的 `url` 直接进 `pnpm add`,配合 `autoRestart` 默认开 = 任意包执行 | 实现要点(改这里之前先读 `src/config-api.js` 文件头): - `FIELD_SPECS` 里标 `manualOnly: true`;`MANUAL_ONLY_KEYS` **从它派生**(单一真源,不存在漏一个)。 - `config.set` 的过滤循环里 `continue` 掉这些键,并把它们收进 `rejected[]` 一起回给调用方 —— **不静默丢弃**:静默丢弃正是本项目反复出现的「失败被当成成功」。 - `configView()` 原样透传 `fields`,所以 `manualOnly` 会到前端;前端把输入框 `readOnly`、 标「只能手改配置文件」,并且保存回执里把 `rejected` 显示出来。 - 改这三个键的正道:profile 的 `cordis.patch.yml` 或 `~/.dsh/remote-qqbot-overrides.json` (都需要能写本机文件,攻击面比 HTTP 面小)。 ### 5.5 配置项清单(按分组) **通知中枢** | 键 | 默认 | 说明 | |---|---|---| | `hubUrl` | `''` | 中枢地址;留空 = 只记日志不发请求。**只能手改配置文件**(1.0.25,见 5.4b) | | `token` | `''` | Bearer 令牌 | | `timeoutMs` | `10000` | 单次请求超时 | | `onTurnComplete` | `true` | 任务完成推送 | | `onQuestion` | `true` | 提问推送 | | `qqAskDesktop` | `'both'` | 提问显示在哪:`both` 桌面卡片与 QQ **同时**(默认,谁先答算谁)/ `qq-first` 先只发 QQ、桌面 90 秒兜底。**只认 `qq-first` 这个字面值,其余一律当 `both`** —— 配置写坏时宁可多弹一张卡片,也不能让提问在桌面上静默消失(1.0.27,见 3.7) | | `onError` | `true` | 出错推送 | | `notifySubagents` | `false` | 子智能体完成是否推送 | | `notifyChatSession` | `false` | 闲聊会话完成是否推送 | | `dedupeMs` | `4000` | 同内容去重窗口 | | `errorSummaryChars` | `300` | 错误摘要长度上限 | **agentmd 跨会话上下文** | 键 | 默认 | 说明 | |---|---|---| | `agentmdDir` | `''` | 文档目录绝对路径;留空 = 关闭自动日志 | | `agentmdMainFile` | `'main.md'` | 主文档文件名 | | `agentmdAppendLog` | `true` | 会话结束自动追加日志 | | `agentmdInject` | `false` | 是否把主文档注入会话上下文(超上限时只注入压缩后的一部分) | | `agentmdSummaryChars` | `200` | 「本轮做了什么」的截断长度 | | `agentmdInjectMaxChars` | `8000` | 🔴 注入**硬上限**(字符);`buildContextDoc` 的契约就是返回值永不超它,0/负数/NaN 一律回落默认 | | `agentmdInjectTailRows` | `20` | 注入时保留日志表最后 N 行 | | `agentmdRowMaxChars` | `600` | 单条日志行上限(只裁「操作」列) | **QQ 远程提醒** | 键 | 默认 | 说明 | |---|---|---| | `qqEnabled` | `false` | 通道总开关(关掉连长连接都不建) | | `qqNotifyEnabled` | `true` | **提醒**开关(输入框下方那个)。通道开着但先别推我 | | `qqSummaryChars` | `150` | 通知里的摘要长度,0 = 不附摘要 | | `qqChatReadOnly` | `true` | 闲聊会话强制只读(可逆:关掉后恢复原预设;失败会上报,见 3.6c) | | `qqChatReply` | `true` | 闲聊回答直发 QQ(不推通知、不留笔记) | | `qqChatAnswerChars` | `1500` | 直发回答的长度上限 | | `qqScreenEnabled` | `true` | 允许 `/screen` 抓屏回图(关掉只回一句"被我关掉了"+ 去哪儿打开) | | `qqScreenMaxWidth` | `1600` | 截图最大宽度,0 = 不缩放。**截图唯一的体积旋钮**(JPEG 质量固定系统默认,见 6.4) | | `qqMarkdown` | `true` | 用 markdown 消息(唯一能折叠链接的方式) | | `qqPromptMode` | `'queue'` | 投递方式:`queue` 排队 / `steer` 插话 | | `qqAppId` | `''` | 开放平台 AppID | | `qqClientSecret` | `''` | 开放平台 ClientSecret(secret,不回显) | | `qqStateFile` | `''` | 状态文件;留空用 `~/.dsh/qq-bot-state.json` | | `qqCwd` | `''` | 专属会话工作目录;留空用进程 cwd | | `qqRecentCount` | `6` | **QQ 菜单「会话」(`/sessions`)列几个最近会话**,上限 20;顺序按最近活动倒序 | | `dshApiUrl` | `'http://127.0.0.1:19387'` | 本机 /api 地址 | | `dshApiAuthority` | `'127.0.0.1:19387'` | **必须与桌面版看到的 Host 完全一致**(cookie 的 authority 靠它) | | `dshSecret` | `''` | 浏览器会话密钥;留空从 `~/.dsh/.credentials.yaml` 自动读(先老写法 `browser-session.secret:`,再新版 `records:` 段里的 `client-connection/browser-session:`;都读不到就自己补一条,见 3.9) | **协作模式** | 键 | 默认 | 说明 | |---|---|---| | `collabEnabled` | `true` | 总开关 | | `collabPanelFile` | `'plugin-collab.md'` | 落盘文件名(写进 `agentmdDir`) | | `collabScope` | `'workspace'` | `workspace` 同工作目录 / `global` 全局一个池 | | `collabInject` | `true` | 是否注入协作现状(没别的会话就不注入) | | `collabClaimGuard` | `'warn'` | 撞车处理:`warn` / `block` / `off` | | `collabClaimTtlMs` | `1800000` | 占用过期时间(30 分钟) | | `collabIncludeSubagents` | `false` | 子智能体是否参与协作 | **完整回答(笔记)与三档全文模式** | 键 | 默认 | 说明 | |---|---|---| | `cloudEnabled` | **`false`** | **云上传总闸**:关着时 `note`/`note-link` 也不传正文,退回「直接发 QQ」(**默认零上传**) | | `qqFulltextMode` | `'chat'` | `chat` 正文切条发 QQ / `note` 存服务器不给链接 / `note-link` 存服务器 + 短链接;非法值 → `chat` | | `qqFulltextMaxChars` | `6000` | 正文写进分条前的字符上限(按码点截断) | | `notesEnabled` | `true` | 存完整回答到服务器(**还需 `cloudEnabled` 打开才生效**) | | `notesIdLength` | `5` | 短链 id 长度(36^len 种可能) | | `notesMaxChars` | `20000` | 单篇字符上限 | > 字段表 `FIELD_SPECS` 在 `config-api.js`,是设置页与**写入白名单**的唯一来源 > (`SPEC_BY_KEY` 从它派生)⇒ **字段在表里就可写,不在就写不进去**。 > 加配置项的正确顺序:`DEFAULTS` → `Config` schema → `FIELD_SPECS`(含分组)→ 测试。 --- ## 六、QQ:四个容易踩死的点 ### 6.1 域名必须大写才能绕过平台检测 QQ 平台会拦截官方机器人消息里的 URL,命中后把链接包进中转壳页、弹「如需预览请使用浏览器访问」。 实测把 **host 全大写**就不被拦(路径一个字都不能动,路径区分大小写)。 ```js // src/qqbridge.js export function qqPreviewUrl(raw) { return `http://${host.toUpperCase()}${pathname}${search}${hash}` // 手工拼字符串 } ``` ⚠️ **不能用 `u.hostname = u.hostname.toUpperCase()`**:WHATWG URL 的 special scheme 会走 domain-to-ASCII,**静默把域名转回小写**——赋值后 `u.href` 一个字符都没变,还不报错。 测试里有一条 `assert.notEqual(u.href, out)` 专门钉住这个陷阱。 ### 6.2 两套配置都要维护:**指令面板**管打 `/`,**自定义菜单**管底部的按钮 官方有两套东西([官方文档](https://bot.qq.com/wiki/develop/api-v2/server-inter/menu-panel/)),很容易只改一个: | | 出现在哪 | 接口 | 点一下会怎样 | |---|---|---|---| | **自定义菜单** menu | 单聊窗口**底部**的菜单按钮 | `GET/PUT /v2/menu`(**整体覆盖**) | 把 `send_message` 原样填进输入框 | | **指令面板** panels | 你在聊天框**打 `/`** 时弹出的那份列表 | `GET /v2/panels?scope=`、`POST /v2/panels`、`PUT /v2/panels/{id}` | 把 `PanelItem.name` 填进输入框 | 2026-10-07 主人报「我输入 / 不会出现 /update 的命令」——查下来打 `/` 弹的就是**指令面板**, 而那份面板是 2026-10-02 建的、只有 `status / help / task / answer`,从没同步过新指令。 **只加菜单不加面板,就会出现"点按钮能用、打 / 看不见"。** 实测踩到的几条: - **面板会把 `PanelItem.name` 的前导斜杠剥掉**(传 `/status` 回读成 `status`), 而**菜单的 `send_message` 保留斜杠** ⇒ 面板里 `name` 一律写不带斜杠的命令名; - 点击**只是把文本填进输入框,不会自动发送**,你还要按一下发送 ⇒ 走的是普通 C2C 消息事件,**插件不需要 `INTERACTION_CREATE` 处理器**; - 🔴 **面板元素有"文字总量"上限,不是个数上限**:6 项 / 名字+描述共 58 字 → `PUT 200`; 把描述写长到 84 字 → `400 40030013 超出数量限制`(当时仍只有 5 项)。 官方文档写"最多 20 个元素",实际先撞到的是**字数**。**描述要短** (当前这份留档在 `work/panel-1.0.15.json`)。 - `GET /v2/panels` 必须带 `?scope=c2c|group|channel|dm`,不带直接 400; - 写入前先 GET 备份(`work/qq-menu.mjs` 会自动存 `work/qq-menu-backup-*.json`), 因为 `PUT /v2/menu` 是**整体覆盖**;面板的 `PUT` 只覆盖 items,不动已关联的用户/群。 当前线上状态(2026-10-07 回读): - **自定义菜单 `version 46`,四个一级项**:`看状态 /status`、 `派活`(子菜单:**挑工作区 `/task`**、新任务 `/task `)、`屏幕 /screen`、`帮助 /help`。 「会话」与「更新」**已从底部菜单撤掉**(1.0.17):前者发 `/sessions` 就行, 后者现在由新版本提醒上的「立即更新」按钮承担。6 个一级项平台也接受过 (version 43 → 45 实测)—— 「一级项最多 3 个」是**未实测的传闻**,这条由实测闭合。 - **c2c 指令面板 `p_Y0eSxcb5nlZSh4gF7H4bRA` `version 5`,六项**: `status`、`sessions`、`task `、`screen`、**`update`**、`help`。 ⚠️ 面板**故意保留** `sessions` / `update`:底部菜单可以砍(占地方),但打 `/` 弹出的 那份列表是**命令发现处** —— 点了「忽略本次」之后还想装,就得靠它找到 `/update`。 - **`作答 /answer ` 两边都撤了**(1.0.16):提问现在带蓝色按钮,点一下就是作答, `/answer` 只剩兼容价值。命令本身仍然认,只是不再占位置。 写入工具:`node work/qq-menu.mjs get|set|panels|panel-set …` (`panel-set` 可省略 panel_id,默认取 c2c 场景下第一个面板)。 > 前导斜杠会被面板剥掉,而截图这条必须靠 `/screen` 才进得了路由 —— 所以菜单里保留斜杠、 > 面板里不带斜杠,两边**内容一致**才是要守的东西。 ### 6.3 `/status` 的排版是纯函数 `formatStatusText({channelOn, pending, sessions, taskId, chatId, activeId, listError})` 在 `qqbridge.js`; `qqruntime.statusText()` 只负责取数据,而取数据本身被抽成 `listSessions()` (`/sessions` 与 `/status` 共用同一份,**每次现拉、从不缓存**)。 这样做是为了**可测**:早期版本只能正则扫源码(「源码里出现了某个字符串」), 而**量具本身会骗人**(本项目两次被注释里的字面量骗过)。现在测试是**真的把函数跑起来看输出**。 几条刻意的取舍: - **纯文本**:回复不带 `markdown: true`,所以正文里不能出现 markdown 记号(有断言守着); - 标题按**码点**截断到 22 字(不是 `slice`,否则 emoji/中文会被切成半个字); - `turns` 缺失(老会话没有 `sessionStats`)时说「没有轮次」,不吐 `undefined`; - 超过 10 个在跑折叠成「… 还有 N 个在跑」; - 列表读不到**不崩**,正文里明说 `⚠️ 会话列表读不到:<原因>`。 ### 6.4 抓屏脚本:不许出现 `GetImageEncoders` / `EncoderParameter`(AMSI) **症状**:抓屏一律失败,报 `ParserError … ScriptContainedMaliciousContent` (「This script contains malicious content and has been blocked by your antivirus software.」), **报错位置指向脚本第 1 行第 1 字符** —— 也就是第一行注释。这是假象:AMSI 在整份脚本送进 解析器**之前**就把它毙了,行号只是"解析器拿到空内容"的副作用。 **二分实测**(每份脚本单独落盘后真跑 `powershell.exe -File`): | 脚本内容 | 结果 | |---|---| | 抓屏 + `Save($Out)`(不带格式) | 通过 | | 抓屏 + `GetImageEncoders()` 存 JPEG | **拦** | | 抓屏 + `New-Object EncoderParameters(1)` | **拦** | | 抓屏 + `EncoderParameter(Quality)` | **拦** | | 抓屏 + `ImageFormat::Jpeg` 直接存 | 通过 | | 单独 `GetImageEncoders()`(不抓屏) | 通过 | | 单独 `New-Object EncoderParameters(1)` | 通过 | ⇒ 被拦的是**组合特征**:「抓屏 + 枚举/构造 JPEG 编码器参数」= 屏幕窃取类脚本的经典指纹。 不是中文注释、不是缩进、不是 sandbox、不是 `-File`,也不是 `child_process` 的问题 (命令行手工跑同样被拦)。 **结论与代价**: - 只允许 `$target.Save($Out, [System.Drawing.Imaging.ImageFormat]::Jpeg)`(GDI+ 默认质量 ≈ 75); - 因此 `qqScreenQuality` **被删除**(自定义质量非得用 `EncoderParameters`); 留一个点了没用的开关比没有这个开关更糟; - `isAvBlocked()` 把这类失败翻成**中文提示 + 放行办法**(含 `Windows 安全中心 → 排除项`), 不让手机端收到英文报错; - `tests/screen.test.mjs` 有一条专门的墓碑断言(把老写法塞回去会立刻报红,已反向验证过), `scripts/verify-installed.mjs` 第 [3] 节每次装完**真抓一张屏**,被拦就整节失败。 --- ### 6.5 抓屏必须先认领 DPI 感知,否则只截到左上角(**顺序就是修复的全部**) **症状**(用户 2026-10-02 报的):截图能用、能发到 QQ,但**永远只有屏幕左上角那一块**。 **根因**(像素级取证,不是推断): `Graphics.CopyFromScreen` 是**按物理像素**搬运的,而一个 **DPI unaware** 的进程看到的 `SystemInformation.VirtualScreen` 是**被系统缩放后的逻辑尺寸**。 本机是 2880x1800 物理屏 + **200% 缩放** ⇒ 未认领时 `VirtualScreen` 报 **1440x900**, 于是"搬 1440x900 个物理像素"= 真实桌面的**左上四分之一**,右下 3/4 整块丢掉。 **决定性实验**(同一份脚本,只差 `SetProcessDPIAware()` 的位置): | 步骤 | 输出 | |---|---| | 认领前抓图 | `GRAB small.png = 1440x900` | | 认领后抓图 | `GRAB big.png = 2880x1800` | | 网格采样比对(每 10px 一点,共 12960 点) | **`meanDiff 0.00`,仅 1 点超过容差 24,maxDiff 38** | ⇒ 小图确实就是大图的左上角**同一画面**,不是"另一张图"——这条排除了"两次抓屏内容本来就不一样"的解释。 **修复**(`src/screenshot.js` 的脚本模板,两条语句的相对顺序是关键): ```powershell Add-Type -Namespace DshShot -Name Dpi -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();' [void][DshShot.Dpi]::SetProcessDPIAware() # ← 必须在下一行之前 $b = [System.Windows.Forms.SystemInformation]::VirtualScreen ``` - **为什么不用 `Application.SetHighDpiMode`**:脚本跑在 `powershell.exe`(Windows PowerShell 5.1 / .NET Framework)上,**.NET Framework 没有这个 API**;`SetProcessDPIAware()`(Win7+ 的 user32) 才是对的,返回值即便为 false 也容忍(进程可能已被 manifest 标为 DPI aware)。 - **为什么不用 manifest**:脚本是每次抓屏临时落盘的 `.ps1`,没有可注入 manifest 的宿主。 **回执与验证链**(让"没被虚拟化"可被机器判定,而不是靠眼睛): 1. 脚本额外打印一行 `RAW 2880x1800`(缩放前的原始尺寸); 2. `parseRawOutput(stdout)` 把它解析成 `{width, height}`,`captureScreen()` 透出 `rawWidth` / `rawHeight`(**新增导出**,`tests/screen.test.mjs` 有 3 项专门测它); 3. `scripts/verify-installed.mjs` §三之二 用 `Get-CimInstance Win32_VideoController` 取**所有**非空分辨率(本机 2 块),断言 `raw` 命中其中**任意一块** —— ⚠️ **不能只取第一条**:本机第一条是虚拟适配器的 `3840x2160`, 单值断言会给出**假红**。量具本身也会骗人。 **护栏不是空断言**:把这套检查拿去跑**旧版 0.8.2** 的安装目录,**9 项全红** (脚本没有 DPI 认领、没有 RAW 行、没有 `parseRawOutput`、没有 `rawWidth`…), 且旧版真实抓屏结果是 `1440x900 / 144,442 B` —— 与"左上角"症状逐字吻合。 **实测数据(0.8.3 生产代码)**:原始 **2880x1800** → 缩放 **1600x1000**, **216,196–217,204 字节**,**521–533 ms**,文件头 `ffd8ff`。 --- ### 6.6 挑会话(`/sessions`):为什么是「指针」而不是「菜单里塞 6 个会话名」 用户的原话:*「我希望在机器人菜单栏这里可以选择最近的六条会话进行对话」*。 **第一个决定:菜单里不放会话名。** QQ 的自定义菜单是**平台侧的一份静态配置**,而且客户端有缓存、刷新有延迟(延迟多长**没有测过**)。 把「最近 6 个会话」写进菜单,等于把一份随时会变的数据烙进缓存里: 你点第 2 项时,平台填进输入框的可能是**一个已经不存在、或者已经不是第 2 个**的会话。 「点 2 却切到别的会话」是这个功能**最贵的失败方式**,所以菜单只放一个**永远正确**的入口:`/sessions`。 **第二个决定:选会话是状态,不是一次性动作。** 挑会话天生带状态 —— 记住一个指针,之后每句话都省掉一次选择;换会话时才再说一句。 指针存在 `~/.dsh/qq-bot-state.json` 的 `activeSessionId`(和 `chatSessionId` 并列),重启 DSH 也不丢。 **第三个决定:列表每次现拉,`pickerIds` 只用来解释数字。** `listSessions()` → `summarizeSession` → 按 `updatedAt` 倒序 → `formatSessionPickerText`。 名单发出去时,把那一份 id 顺序与时刻也记进状态(`pickerIds` / `pickerAt`), 于是「回 2」解析的是**你当时看到的那份名单**,而不是「再拉一次列表、按新顺序数第 2 个」 (会话的活动顺序随时会变,重新排序会让你的 2 变成另一个会话)。 **第四个决定:裸数字只在窗口内算数(会话 5 分钟、工作区与工作区会话 24 小时),而且"解释不了就不猜"。** 顺序写死在 `routeMessage` 里,由 `tests/session-picker.test.mjs` 与 `tests/qq.test.mjs` 的 `[7e]`/`[7f]` 组钉住: | 优先级 | 条件 | 结果 | |---|---|---| | 1 | 有 `ref_idx`(引用了我某条消息) | 回到那条消息所属的会话 | | 2 | 没有引用 + 有提问在等 | **当作答**(`1` 就是答案,绝不能拿去切会话) | | 3 | 没有引用 + 没有提问在等 + 名单还在窗口内(会话 5 分钟;工作区、工作区里的会话 24 小时) | `pick_session` / `pick_workspace` / `pick_task_session`(**谁后发听谁的**,三份都新鲜时按 工作区会话 > 工作区 > 会话) | | 4 | 没有引用 + 没有提问 + **纯数字**,但没有任何名单能解释它 | `stray_number`:回一句「我没把它当派活内容」,**一句都不注入会话** | | 5 | 其余 | 闲聊,或进当前指针指向的会话 | 第 3 步现在是**三份名单**(1.0.19 加了"某个工作区里的会话",见 3.6f); 第 4 步是 1.0.18 加的,起因见 [3.6e](#36e-一个光秃秃的数字绝不注入会话10018)。 **第五个决定:指定的会话不在了,绝不静默回落。** 投递前用 `api.hasSession` 校验:不在了就清掉指针、并且**明确回一句** `⚠️ 第 N 个(X)已经不在了 —— 发 /sessions 重新看一次吧。`; 若是在"投递那一瞬间"才发现不见了,那句话会进闲聊会话,并**额外补一句说明**。 悄悄把话投进另一个会话,是这个功能最坏的失败方式 —— 宁可多说一句。 ### 6.7 QQ 单条消息的长度上限:官方没给数字,只能实测(2026-10-03) 官方《发送单聊消息》文档只列了两个错误码 —— `40054007 消息长度超限`、`40054018 消息过长或异常` —— **没有任何字数**。而 `chat` 档(默认档)把整篇正文切条发出去,全靠这个数字: 猜大了 → 整条发送失败、正文白丢;猜小了 → 一条回答被炸成一串碎消息。 实测方法(**3 条封顶**,走插件真实发送路径 `QqBotClient.sendC2C(openId, text, {markdown:false})`, 即 `msg_type=0` 纯文本,与 `sendQqFulltext` 完全同一条路): | 实发长度 | 服务端回执 | |---|---| | 900 字 | ✅ 接受,返回 `id` / `timestamp` | | 1500 字 | ✅ 接受 | | 3000 字 | ✅ 接受 | ⇒ 服务端上限 **≥ 3000 字**,而插件用的是 `QQ_TEXT_SAFE_CHARS = 900`, 安全边际 3 倍以上。**结论:900 保持不动**(保守方向是"多发两条",而不是"丢正文")。 ⚠️ 两个诚实边界:**①** 无法从回执判断**客户端**会不会折叠/截断长消息(那要看手机); **②** 平台随时可能改规则,所以这个数字**不该被当成更激进的依据**。 ⚠️ 探针踩到的坑:`cordis.patch.yml` 里 `qqAppId` / `qqClientSecret` 是**带引号**的, 直接正则取值不去引号 ⇒ `getAppAccessToken 失败 code=100007: appid invalid`, 三条全部被挡在 token 环节(**一条都没真的发出去**)。**读凭据必须去引号。** ### 6.8 消息按钮:QQ 里那些蓝色的可点文字(1.0.16) 官方叫「[消息按钮](https://bot.q.qq.com/wiki/develop/api-v2/server-inter/message/trans/msg-btn.html)」: **在 markdown 消息的基础上**,底部挂一行/多行按钮(最多 5 行 × 每行 5 个)。 `render_data.style`:`0` 灰色线框、**`1` 蓝色线框**、`3` 白底红字、`4` 蓝底白字 —— 主人说的"蓝色的字"就是它。`render_data.label` **最多 10 字符**。 三种点击行为(`action.type`)差别很大: | type | 名字 | 点了会怎样 | 插件要不要接新东西 | |---|---|---|---| | 0 | 跳转 | 打开一个 https 链接 | 不要 | | 1 | 回调 | 触发 `INTERACTION_CREATE` 事件 | **要**:还得 `PUT /interactions/{id}` 回应,否则客户端一直转圈 | | 2 | **指令** | 把 `data` 当一条消息发回来 | **不要** —— 走普通 C2C 消息事件 | 所以插件只用 **type=2**:点一下等于替你发了 `data` 里的那几个字, 之后就是既有的入站路由在跑。 **实测到的三件事(都不在文档的字面里):** 1. 🔴 **主动消息也能带按钮**。文档把"自定义按钮"标成「内邀开通」,但 `POST /v2/users/{openid}/messages` 带 `keyboard.content.rows` 直接 **HTTP 200** (`work/qq-btn-probe.mjs` 就是干这个的;它也用 `--text` 做不带按钮的对照组)。 被动回复当然也行 —— 但我们的通知/提问都是主动消息,这条决定了功能做不做得成。 2. 🔴 **指令按钮点出来带 `@机器人 ` 前缀**。官方原话是「自动在输入框插入 `@bot data`」, 所以点「看状态」发过来的可能是 `@DSH助手 /status`。**不剥掉这段前缀,按钮等于白做** (会被当成闲聊内容)。`qqbridge.stripBotMention()` 只剥「`@某段` 后面紧跟 `/`」的情形, 以 `@` 开头的正常内容一个字都不动。 3. **按钮只能挂在 markdown 消息上**(`msg_type=2`)。所以 `sendC2C` 传了 `keyboard` 就强制走 markdown 通路;markdown 那条路失败退回纯文本时**按钮一起丢**并在日志里写明 —— **宁可少两个按钮,也不能少一条通知**。 插件拿它做什么:**单选提问的选项按钮**(`qqbridge.buildOptionKeyboard()`)。 `action.data` 只放**序号**,因为正文按同一个编号列了说明, 而 `buildOneAnswer` 本来就把数字解析成对应选项 —— 点按钮与"直接回一个数字" 走**同一条**解析路,不需要新逻辑。多选、一次问好几个问题、选项超过 5 个时不装按钮 (一次点击答不完,按钮反而更乱)。 回归:`tests/qq.test.mjs` 的 `[4b]` 组(发出去的形状:带按钮必须 msg_type=2 + keyboard、 不带时不变、markdown 失败退回纯文本且丢按钮)与 `[7c]` 组(按钮构造:序号即 data、 label ≤10 字符、一行一个、多选/多问题/超 5 个不装、`@机器人` 前缀剥离与不误伤)。 **反向校验**:拿掉 `stripBotMention` ⇒ `qq 222/1`;把 `wantKeyboard` 钉成 false ⇒ `qq 221/2`。 ### 6.8b 提问按钮:一行一个 + 正文不再重复选项文字(1.0.26) **现场**(主人 2026-10-07 23:00 前后,就是真事发过来的那条消息):正文按 `1./2./3./4.` 列了四个选项, 下面挂一排 4 个按钮,每个按钮只分到 1/4 宽度、文字还被 10 字符上限硬切。他看到的原话: > 按钮里的字还是挺多的,你一行放 4 个按钮,我根本看不见是什么东西 […] 反正你按钮里面也要写 > A. 一行一个按钮 这样的文字,那不如直接用按钮替代掉正文里面的这个文字,然后让它独立占一行? **改了三处**: | # | 改之前 | 改之后 | 为什么 | |---|---|---|---| | ① | `buildKeyboard([buttons])` —— 最多 5 个挤**一行** | 一个按钮**一行**(`rows = options.map(o => [btn])`) | 每个按钮占满整行,10 字符才真的读得出来;行数上限仍是 5 ⇒ 选项上限还是 5,没有变少 | | ② | 按钮文字 = `N.` + 前 8 个字**硬切**(`1.A. 一行一个按钮(推`) | `optionButtonLabel()`:剥掉模型自带的 `A.` / `1、` 前缀与结尾的「(推荐)」,再按码点截断 | 前缀与编号重复、装饰性括号在 10 字里最容易切成半截;「推荐」移到正文那行标回来,信息不丢 | | ③ | 正文列「选项名 + 缩进说明」,与按钮**同一条信息出现两次** | 装了按钮时正文只留「编号 + 说明」(`formatQuestionBody({ buttonsShown })`) | 主人明确要求"用按钮替代掉正文里那行文字" | 🔴 ③ 有个**唯一例外**:某个选项既没说明、名字又会被按钮切成半截(长度 > 按钮余量)时,正文把那行 **补回完整选项名** —— 那时它是唯一的答案依据。判断"会不会被切"用的是与按钮**同一个函数** (`optionLabelRoom()`),不另写一套口径。 🔴 还有一种情况**一个字都不省**:按钮没装上时(多选 / 多问 / 超过 5 个选项 / 没有选项), 正文里的选项文字是唯一的答案依据。`formatQuestionBody` 的省字条件与 `buildOptionKeyboard` 的装按钮条件**必须同步改**,否则会出现"正文不写、按钮也没有"的信息真空。 回归:`tests/qq.test.mjs` 259 项。新增的 5 条钉住:一行一个(2 个选项 = 2 行)、标签剥离与 无半截括号、`optionLabelRoom` 口径、正文不重复选项名、按钮没装上时不省字。 **反向校验**(2026-10-07 实测,跑完按字节还原):把 ① 改回 `buildKeyboard([buttons])` ⇒ **6 条**报红(253/6);把标签清洗去掉 ⇒ **2 条**报红(257/2);把 `buttonsShown` 传成 false ⇒ **4 条**报红(255/4)。三种坏改动都不会连带打红别的组。 ### 6.9 按钮铺到哪些消息上(1.0.17 / 1.0.18 / 1.0.19 / 1.0.20) 1.0.16 只给「单选提问」装了按钮。1.0.17 把另外两处也接上,1.0.18 / 1.0.19 又给三份名单加了数字行, 1.0.20 把**新版本提醒的两个按钮也挂到 `/update check` 的回执上**(同一份文案、同一对按钮): | 消息 | 按钮 | data | 为什么 | |---|---|---|---| | 单选提问 | 选项序号(**一行一个**,1.0.26 起) | `/answer 1` / `/answer 2` … | 一键作答(1.0.16);1.0.24 起 data 从裸数字改成显式指令,1.0.26 起改一行一个(见 6.8b) | | **新版本提醒** | 忽略本次 / **立即更新** | `/skip` / `/update` | 「立即更新」用 `style=4`(蓝底白字)比「忽略本次」显眼;两件事原来都要手打命令 | | **`/update check` 的回执(有新版本时)** | 同上,同一对 | `/skip` / `/update` | 等不到自动提醒时**手动要一次**也能点按钮(1.0.20,见 3.6g);已是最新版时仍只回一句干话、不挂按钮 | | **`/help` 回复** | 看状态 / 挑工作区 / 会话 / 屏幕 / **新对话** / 更新 / 帮助 | 对应命令 | 帮助里列了命令,能点就不用照着抄(「新对话」是 1.0.19 加的) | | **`/task` 的工作区名单** | 数字 1…N | `/pick 3` | **绕过 24 小时时间窗**:名单过期了点按钮照样能选(1.0.18) | | **`/sessions` 的会话名单** | 数字 1…N | `/use 3` | 同上,绕过 5 分钟窗口(1.0.18) | | **派活第二步:工作区里的会话名单** | 数字 1…N + 单独一行的「新对话」 | `/open 3` / `/new` | 同上(1.0.19,见 3.6f) | 名单那几排按钮由 `buildNumberButtons(prefix, count, { extra })` 生成:**标签只能是编号** (会话名、目录名都超 10 字符的硬限制),每行 5 个、最多 5 行,装不下就整个不装 —— 宁可没按钮,也不要发一条平台会拒的消息。`extra` 是"单独一行"的额外按钮(只有派活第二步用), 算上它超了 5 行也一样整个不装。**count = 0 时一个都不装**(连 extra 也不装): 没有编号行的「新对话」会让人以为刚发过一份名单。 按钮的 `action.data` 是**显式指令**,所以在 `routeIncoming` 就被认掉了, 不经过"裸数字 + 时间窗"那条路。回归见 3.6e / 3.6f。 `/skip` 是 1.0.17 新增的指令(也认 `/update skip`、`/忽略`): - 处理器 `handleUpdateSkip` 把 `updateNotified`(刚提醒过的那一版)记进状态字段 **`updateSkipped`**,之后更新检查碰到同一个版本直接安静(日志写明是"人说的别提醒")。 - 它**只影响提醒,不影响 `/update`** —— 忽略过之后随时还能装。 - 故意**不再查一次远端**:忽略就针对眼前这条提醒;真查出一个更新的版本反而让"忽略"变含糊。 - 没有待处理的版本时点它,回一句「这会儿没有等你处理的新版本」,不假装成功。 两个字段为什么不合并成一个:`updateNotified` 记的是"**提醒过**哪版"(防重复打扰), `updateSkipped` 记的是"主人**明确说过**别提醒哪版"。分开存,日志能说清是哪种静默, 以后想改口径(比如忽略过的版本隔很久再提一次)也不用猜历史状态。 同时把底部菜单砍到四项(去掉「会话」「更新」)——**菜单是占地方的地方,面板才是发现命令的地方**。 ⚠️ 两条守则(1.0.17 的守卫):`updateSkipped` 与 `handleUpdateSkip` 的接线写在 `tests/qq.test.mjs` 的 `[7d]` 组里,其中最后一条是**源码级守卫**(不是行为测试): 更新检查跑在定时器里、要假 fetch 才能端到端跑,而这里要守的只是"接线被没被拆掉"。 **反向校验**:拿掉 `/skip` 路由 ⇒ `qq 227/2`;拿掉新版本提醒上的按钮 ⇒ `qq 228/1`。 --- ## 七、状态文件与落盘位置 | 内容 | 位置 | 说明 | |---|---|---| | QQ 状态(open_id / 专属会话 id / 去重表) | `qqStateFile`,默认 `~/.dsh/qq-bot-state.json` | 换机器要重新授权 | | agentmd 主文档 + 协作面板 | `agentmdDir/` | `collabPanelFile` 默认 `plugin-collab.md` | | 完整回答笔记 | 中枢服务器 `/var/www/dsh-notes/.md` | 由服务器侧定时清理(1 天) | | 插件配置(读) | `~/.dsh/settings.yaml` / profile 补丁层 / `~/.dsh/remote-qqbot-overrides.json` | 见第五、5.3b 节 | | 插件配置(写:原生被拒后落这里) | `~/.dsh/remote-qqbot-overrides.json` | 明文 JSON;**里面的键优先级最高**(盖过 `cordis.patch.yml`);删掉即回落到 profile 值 | | 浏览器会话密钥 | `~/.dsh/.credentials.yaml` | `dshSecret` 留空时自动读 | --- ## 八、测试体系 ```bash npm test # 19 组,全部零外部依赖(cloud-integration 会顺手做一次真实 HTTP 往返) ``` | 文件 | 断言数 | 守什么 | |---|---|---| | `tests/summary.test.mjs` | 18 | **本轮摘要提取**:回复必须与**本轮**用户消息配对(打断后不许拿上一轮回复顶)、流式分片取最新、空正文回复不往前捞、插件注入不算真人输入、陌生结构不抛错、结果列三态(完成 / 出错 / 已中断(无回复)) | | `tests/agentmd.test.mjs` | 27 | 表格定位与追加 + **注入/单行预算**(上限单调性、非法上限回落、压缩不静默) | | `tests/settings.test.mjs` | 10 | settings 注册/覆盖语义 + 运行状态里有「插件版本:跑的是/装的是」一行(1.0.21) | | `tests/config-view.test.mjs` | 27 | 设置页读数来源(`describe` 为空时必须靠 host 兜底、secret 绝不回显、**提示里点名的字段必须真的存在**、**「接收 QQ 推送」开关与顶部那张卡(1.0.20)**、**「运行状态」只读块与告警位**) | | `tests/update.test.mjs` | 113 | **远程更新**:版本比较三分支、三种源解析(索引 / GitHub / npm)、从安装路径反推 profile、更新通道选择(拿本机实测 argv 跑)、`runProcess` 超时与 `stdio` 不许 `pipe`、重启脚本 / 启动器 / 日志确认、每一条用户可见文案逐字断言、**新版本提醒的间隔 1 小时与"`/update check` 也带两个按钮"(`[7]` 组,1.0.20)**、**版本号钉在"加载那一刻"+"磁盘比内存新"那一格+轮询挪出 `if (qqEnabled)`(`[8]` 组,1.0.21)**、**索引里的 `notes` 跟着回来 + 提醒里写清"这版改了什么"(1.0.23)**、**`[9]` 组装包前的本机校验(1.0.25):摘要对/不对/缺失/非 64 位 hex/size 不符/临时文件必须删掉/永不抛 + 索引与包地址只认 https(回环除外)+ `fetchLatestVersion` 带回 `size`** | | `tests/client-render.test.mjs` | 8 | **浏览器侧设置页的真渲染**(1.0.20 新增:假 `window.__ModuleLoader__` + 假 React + 假 `fetch` 返回真 `configView()` 产物,把 `NotifyMemorySettings` 渲染出来遍历元素树):最上面那张「接收 QQ 推送」卡画出来了、开关跟着 host 走、缺省按"开"显示、整页只有一个同名开关、loading 与"服务不可用"都不白屏 | | `tests/fulltext.test.mjs` | 49 | **三档全文模式**:`normalizeFulltextMode` 非法值必落 `chat`、`uploadsFulltext`/`showsFulltextLink` 语义、切分**不丢字符**、**带序号不超限**(★`limit=100` 边界)、`maxChars` 下限 100 的契约 | | `tests/ui-persist.test.mjs` | 34 | 开关落盘:桌面拒绝原生写入 → 落插件自有覆盖文件;写失败必抛;`ok:false` 不被 success spread 掩盖;secret 不回显;**`[C2]` 安全护栏(1.0.25):`cloudUrl` / `hubUrl` / `qqUpdateSource` 从这个无认证面写不动(服务端硬拒 + `rejected` 如实回报 + 不落盘 + 前端只读渲染)** | | `tests/qq.test.mjs` | 265 | QQ 文案、引用索引、入站路由、`qqPreviewUrl`、提问中继行为(**1.0.27 起含 both/qq-first 两档:卡片立刻出现、QQ 先答中止影子 signal、桌面先答摘条目并回一句「已经在电脑上答过了」、QQ 发不出去只有一张卡片、未知配置一律当 both**)、**引用反查四级兜底(回执登记 `ref_idx` / 用官方 `msg_elements` 的正文认会话 / 12 小时兜底窗口 / 认不出也必须投递)**、**工作区派活(`/task` 两步、两份名单的优先级、`cwd` 带出、老状态文件升级)**、**消息按钮(发出去的形状、选项按钮构造、`@机器人` 前缀剥离、更新提醒与帮助的按钮、`/skip`)**、**1.0.24 的硬要求:不引用不带 / 一律闲聊(`stray_number` 被删干净、三个"名单新鲜"开关传了也不改道、`/pick` `/open` `/use` 三写法、24 小时 vs 5 分钟两个窗口、名单数字按钮的 data 能被路由认出来、按钮超限返回 null、提问按钮发 `/answer N`、提示发生在投递之后且按 `askId` 去重)**、**派活第二步(`/open` 与 `/new`、三种别名、只认本工作区那份名单、数字按钮 +「新对话」按钮、0 条名单不装按钮、六种回执互不相同)**、**1.0.26 提问按钮的可读性(一行一个、标签剥前缀与「(推荐)」、`optionLabelRoom` 口径、装了按钮时正文不重复选项名、按钮装不上时一个字都不省、选项名会被切又没说明时正文补全)** | | `tests/status.test.mjs` | 25 | `/status` 排版(真跑 `formatStatusText`),含「当前会话看得见」、**插件版本问得到、磁盘比内存新要告警、不传不多噪音(1.0.22)** | | `tests/session-picker.test.mjs` | 36 | **挑会话**:列表编号与纯文本、`isPickerFresh` 时效、`pickerAge`(区分"没有名单"与"名单太老"、时钟回拨不出现负数)、**1.0.24 的路由三分支(`/` 指令 / 引用 / 其余一律闲聊:裸数字、带 `@机器人` 的、奇怪的话都必须是 chat;老的三个开关传了也不许改道;有引用仍然回原会话)**、`formatPickAck` 四种结果、指针真的落盘(含老状态文件) | | `tests/screen.test.mjs` | 59 | 抓屏脚本内容(**不含 AMSI 触发构造**、**DPI 认领必须在读 VirtualScreen 之前**)、`parseRawOutput`、隐私清理、`/screen` 路由、图片上行形状、AMSI 报错翻译 | | `tests/plugin.test.mjs` | 47 | mock ctx 驱动的整插件装配,含协作接线回归(**工具名逐个点名**,少一个就红)+ **244KB 文档注入仍 ≤8000、上限调小注入跟着变短**(端到端防爆)+ **打断形状不推「跑完了」**(3.1c)+ **1.0.25:`planChatReadOnly` 五态真跑、失败后「运行状态」确实多出 warn 行、停止时把记过的会话恢复回原预设(可逆)、`cloud_unbind` 之后正文一个字节都不发去公开中枢** | | `tests/collab.test.mjs` | 44 | 协作注册表 / 占用 / 留言 / 总开关关闭后的行为 | | `tests/notes.test.mjs` | 19 | 笔记渲染与上传(注入假 fetch) | | `tests/cloud.test.mjs` | 29 | **云端客户端**:地址规整(非 http(s) 判为未配)、`/n/` 形状**必须被 dsh-web 前端深链正则接受**(跨项目读真源码验证)、请求逐字(Bearer、**不带 username**)、设备码三态、`qqLinkWarning` | | `tests/cloud-integration.test.mjs` | 22 | **真 cloud.js × 真 dsh-web**(进程内起服务 + 真 HTTP):设备码绑定 → 上传 → 网页端打开(计 1 次)→ 换账号看不到 → 吊销后上传明确失败 | | `tests/cloud-routing.test.mjs` | 7 | **路由**(mock ctx 驱动 lib/index.js):配了云端就**只**发云端、云端失败**绝不**改发中枢、总闸关着两边都不发、非法地址退化成"没配" | | `tests/load-verify.mjs` | 3 层 | import → bundle 解析 → 真实装配(最后一道防线) | > 断言数是 **0.9.0 实测值**(每组跑完自己打印的「通过 N 项」就是权威来源)。 > 改了测试请顺手更新本表——但不要用本表去核测试,**以实际输出为准**。 **写测试的三条纪律(都是踩出来的):** 1. **mock 必须复刻真实契约的失败模式**,不能只让 happy path 通过。 旧的 settings mock 只有 `get()`、没有 `register()`,17 项测试全绿却测不出 「未注册 → get 恒为空」;补上 `register()` 后**8 项立刻转红**。 2. **验行为要跑函数,不要正则扫源码。** 源码里的注释会命中正则—— 「不要用 setter」这句说明文字曾被当成「残留 setter 写法」报出来。 3. **新护栏要反向验证**:临时把源码改回旧写法,确认断言真的会红,再改回来。 否则你只是加了一条永远为真的断言。 另外:`tests/live/` 里的脚本**不在 `npm test` 链里**(要真环境), `scripts/verify-installed.mjs` / `verify-ui-installed.mjs` 是**装完后的正式验收**, 它们直接 import **安装目录**里的产物做真实调用与哈希比对。 ### 8.5 ⚠️ 「装好了」不等于「在跑」——5 秒问清楚 插件**只在 DSH 启动时加载一次**,没有热重载。所以 `add` 成功之后,**进程里跑的还是上次启动时 读进内存的那份代码**,`verify-*` 全绿也只证明"磁盘上那份对"。本项目已经因为这个反复误会: > 2026-10-07 主人报「挑选完工作区,还是没有让我挑选会话」。查到最后不是功能坏了 —— > 主进程启动于 `10:22:31`(当时加载的是 **1.0.18**,`/task` 只有"选工作区"一步), > 而两步派活是 `11:12:06` 装进来的 **1.0.19**、设置卡片是 `12:17:08` 装进来的 **1.0.20**。 > 三条互相独立的量具:① 进程启动时间早于安装时间;② 直接问运行中的插件 > (`GET /remote-qqbot/api/config.get`)它自报的字段标签还是「远程提醒」、间隔还是 `6`; > ③ `qq-bot-state.json` 里**没有 `taskSessionAt`** —— 1.0.19+ 的两步流程一定会写这个键。 ```bash node scripts/which-version.mjs # 磁盘上是哪一版 vs 进程里跑的是不是这一版 ``` 判据不是版本号(运行中的插件不自报版本,它就是内存里那份代码),而是**字段标签集合**: 老版本会返回它自己那份 `FIELD_SPECS`,跟本地 `lib/config-api.js` 对一下,有差异就说明 进程里跑的不是本地这一版,并按差异列出是哪一代。**报告结论前先跑它**,别把"没加载"当成"没修好"。 --- ## 九、开发与发布流程 ```bash node scripts/link-dev.mjs # 建 node_modules junction(首次必需) node scripts/build.mjs # src/ → lib/,含语法与依赖检查 npm test # 19 组全绿(exit 0) # 改 package.json 的 version node scripts/pack.mjs # 生成 dsh-remote-qqbot-.tgz dsh plugin --profile desktop add ./dsh-remote-qqbot-.tgz node scripts/verify-installed.mjs # 验加载 + 真实函数行为 node scripts/verify-ui-installed.mjs # 验 lib 全部哈希 + 版本 + client 字面 node scripts/which-version.mjs # ⚠️ 装完仍要主人重启,这里确认进程里换没换 ``` > ⚠️ **`add` 的参数必须带 `./`(或写成绝对 `file:` 路径)。** > 2026-10-03 实测:`dsh plugin --profile desktop add dsh-remote-qqbot-0.8.6.tgz` > ⇒ `[ERR_PNPM_FETCH_404] GET https://registry.npmjs.org/dsh-remote-qqbot-0.8.6.tgz: Not Found`。 > 裸文件名被 pnpm 当成**registry 包名**了;加 `./` 才是本地 tarball。 > ⚠️ **profile 里残留指向"已删除 tgz"的依赖,会让 `add` 在解析阶段就 ENOENT 死掉** —— > 报错还是指向**旧版本的文件名**(`dsh-remote-qqbot-0.8.5.tgz`),看起来像"我打错了包名"。 > 顺序必须是:**`remove` → `add`**。 > ⚠️ 另有一条实测:`remove` 会把 `dsh.profile.bundles` 里的 `dsh-remote-qqbot` **一起摘掉**, > `add` 成功后会自动加回来 —— 装完**必须回读 `bundles` 确认它还在**,否则插件根本不会被加载。 > desktop profile 的 `package.json` 里依赖指向**具体 tgz 路径**。旧 tgz 被新 `pack` 覆盖后, > 不改依赖会 `ENOENT`——所以「bump 版本」和「改 profile 依赖」必须一起做。 > ⚠️ **同一个版本号 + 同一条 `file:` 依赖 ⇒ pnpm 直接说 `Already up to date`,根本不重新解包。** > 2026-10-02 实测:改完 `src/config-api.js` 再 `pack`(同名 `0.8.1.tgz`,内容变了)→ > `dsh plugin --profile desktop add` 输出 `Already up to date / added 0`,退出码 0, > 而安装目录里的 `config-api.js` **仍是旧的**(哈希 DIFF、缺字段)。 > 规矩:**只要产物内容变了就 bump 版本号**(让依赖字符串跟着变),或者先 `remove` 再 `add`; > 事后必须用 `scripts/verify-ui-installed.mjs` 比对哈希 —— > 这次就是它抓出来的(**`add` 成功 ≠ 文件被替换,退出码 0 会骗过所有人**)。 ### 发布到 GitHub(当作社区插件分发) ```bash # 1) 先保证构建产物是最新的,而且**它必须进这次提交** npm run build && npm test git add -A && git commit -m "…" # lib/ 必须在这次提交里 # 2) 用户这样装(DSH CLI 原生认 github: 前缀) dsh plugin --profile desktop add github:/dsh-remote-qqbot ``` - 🔴 **`lib/` 必须提交**:`.gitignore` 里**不许再出现 `lib/`**。 git 依赖没有"安装时构建"这一步(pnpm 默认还会拦掉 `prepare` 脚本), 不提交 `lib/` ⇒ 用户装到一个没有代码的包,`main: lib/index.js` 直接找不到。 **纪律:改完 `src/` 必须 `npm run build`,且 build 与 commit 要在同一次完成。** - **仓库名 = 包名 = `dsh-remote-qqbot`**。DSH CLI 对 git 规格的判定见 `apps/cli/src/plugin.ts:150`(`/^git\+|^github:|\.git(?:#|$)/`)。 - **想被社区目录收录**:给仓库加 GitHub topic **`dsh-plugin`**(生态目录主要抓这个标签), 再去 [dshbase](https://github.com/ylwl1997/dshbase) 提 plugin-submission Issue, 官方社区在 [deepseek-ai/deepseek-harness Discussions](https://github.com/deepseek-ai/deepseek-harness/discussions)。 **不要求发 npm** —— 绝大多数 DSH 插件就是 `dsh plugin add github:/`。 ### 🔴 铁律:不重启 = 没生效 桌面版是**长驻进程**,任何插件改动都必须重启 DSH。判定方法固定为**比时间**: ``` 安装目录 package.json 的 mtime < 桌面版进程 StartTime ⇒ 才真正生效 ``` ⚠️ **不要用 `lib/*.js` 的 mtime 判**:pnpm 解包**不保留安装时刻**,安装目录里 `lib/index.js` 的 mtime 可能是一小时前(甚至比本地 `lib/` 还晚、却远早于真实安装时刻)。 本项目已经因此差点把"没生效"看成"生效了"。**只看 `package.json` 的 mtime,或直接比对哈希。** ### 并发写入纪律 本项目出过一次「看起来装好了、其实是半成品」的版本 —— 两个会话同时写 `src/index.js`。 所以: - 改任何文件前先登记(协作面板 / 协作工具),**同一个文件同一时刻只允许一个写入者**; - **只有一方跑 build/pack/install**,另一方等信号; - 装完必须核对「**安装目录的 lib 哈希 == 本地 lib 哈希**」,再谈验证。 --- ## 十、已解决与未解决 ### 已经修掉、且**不要改回去**的设计 | 事项 | 结论 | |---|---| | 用户配置从不生效 | 根因是「未注册就 `get`」+「把填满默认值的 config 合并到 settings 之上」,见 5.2 | | UI 显示「QQ 未连接」 | 让 UI 读 `liveConfig()`,不要让数据源迁就 UI,见 5.3 | | 询问卡片永远挂着 | 0.7.7 起**一开始就不创建桌面 pending**(QQ 优先);1.0.27 起改为「桌面与 QQ 同时问」+ **影子 signal**:QQ 答完就 abort 它,api-proxy 自己 `claimQuestion(…,'cancelled')` 收卡片。**两条都不要再改回 `Promise.race`**,见 3.7 | | 提问推送没有选项 | `userQuestions` 服务提供者 fiber 未 active 时 `ctx.get` 返回 `undefined`,靠三层补接(`inject` / `tools/pre-execute` 时刻 / 兜底文案) | | 链接被 QQ 拦 | host 全大写,手工拼字符串(不能用 setter),见 6.1 | | 收到确认后复述用户原话 | 回执只留一行,但**保留 `(会话名)`** | | 抓屏被 Defender/AMSI 整份拦下 | **不许再碰 `GetImageEncoders()` / `EncoderParameters`**,只用 `ImageFormat::Jpeg` 存;`qqScreenQuality` 已删(见 6.4) | | 抓屏失败甩英文报错给手机 | `isAvBlocked()` 翻成中文 + 放行办法(Windows 安全中心 → 排除项) | | **截图只剩左上角** | DPI unaware 进程看到的是**逻辑尺寸**(1440x900),`CopyFromScreen` 却搬物理像素 ⇒ 只存了左上四分之一。修法:`SetProcessDPIAware()` 必须在读 `VirtualScreen` **之前**;顺序就是全部(见 6.5) | | **输入框下方两个开关「点了没用、永远显示已开」** | 三个缺陷叠加:命名空间与 profile 条目 id 不一致(桌面 `update()` 必抛)/ 没有 volatile 字段(`describe()` 直接跳过)/ `ok:false` 写在 success spread **之前**被掩盖。修法:写配置走「原生优先 → 插件自有覆盖文件」两条路,失败必抛且 UI 显示(见 5.3b) | | **裸数字被误当成"切会话"** | 优先级写死为 `引用 > 有提问在等 > 选会话 > 闲聊`,且裸数字只在名单发出后 5 分钟内算数(见 6.6) | | **指定的会话没了却静默投进别处** | 投递前 `api.hasSession` 校验;不在了就明确回一句并清指针,**绝不悄悄换一个会话** | | **`blocksToText()` 把换行压平**(0.8.6 修) | 原来是 `parts.join(' ').replace(/\s+/g,' ')` ⇒ 上传的正文从源头就不是 markdown(实测 `hhfwa.md` 单行 1290 字符里塞 5 个 `##` 标题)。现在只统一换行、压行内空格、折叠多余空行,**段落/标题/表格结构保住**;需要单行摘要的调用方(`composeSummary` / `composeChatSummary`)自己压平 | | **「默认不发正文」只是句承诺**(0.8.6) | 拆成「模式(意图)+ `cloudEnabled` 总闸(授权)」两道,只有两者都满足才上传。`chat` 档 + 总闸关闭 ⇒ **对服务器 0 字节**,探针实测 `/api/notes` 请求数为 0 | | **`MARKER_RESERVE` 下限缺陷**(0.8.6) | `bodyLimit` 原来写 `Math.max(MIN_CHUNK_CHARS, …)`,`limit ≤ 109` 时反而超限(实测 `limit=100` 产出 106 字)。改成 `Math.max(1, limit - MARKER_RESERVE)`;★边界用例钉在 `tests/fulltext.test.mjs` | | **引用一条 QQ 消息 → 回「认不出这是哪次通知」且消息消失**(1.0.6) | 三级兜底缺了两级:①回执(`replyPassive`)从不登记 `ref_idx`;②兜底只看 `recent[]` 且只有 5 分钟。现在回执登记 `ref_idx`、兜底合并 `sentRefs{}` 并放宽到 12 小时、真认不出**也必须投进闲聊会话(只读)**。**任何一级都不许再退回"只回一句就丢掉"**,见 3.6b | | **事件里带着被引用那条的正文,却只读索引**(1.0.12) | 官方 `message_type=103` 的事件带 `msg_elements[0].content`(被引用那条的正文)与 `.author.bot`,我们以前只用 `ref_msg_idx`。现在 `extractQuoted()` 读它、`inferSessionFromQuote()` 从正文里认会话(只认**与现有会话标题完全相等**的候选;放宽到"首行猜"时**只对我方消息**生效)。**不要再把 `msg_elements` 扔掉**,见 3.6c | | **引用反查的优先级顺序**(1.0.12) | 必须写死在纯函数 `resolveQuoteTarget()` 里:精确 `ref_idx` > 引用正文 > 最近一条;且认出来的会话**正在等回答**时按**作答**处理(否则 agent 卡在提问上、你在另一个会话里说话)。改顺序前先看 `tests/qq.test.mjs` 第 `[14]` 组 | | **按 `msg_idx` 去重**(1.0.12 特意**没有**做) | 官方建议"结合 `msg_idx` 去重",但万一它不是每条唯一,按它去重会把正常消息**永久**丢掉。现在只在内存记 200 个、撞上打日志、**绝不 return**;`[14]` 组有一条断言盯着"重复也返回"这件事 | | **「跑完了」的判定只看内存里的 `running → idle` 边**(1.0.13 修的真事故) | 插件在回合中途被重装(宿主没重启)会把那张表清空 ⇒ 轮末 idle 进来时 `prev` 不是 `running`,**推送 / 上传 / agentmd 日志一起静默跳过**(2026-10-05 主人在 QQ 里派的那一轮就这么丢了)。现在状态落盘进 `qq-bot-state.json` 的 `agentStatus`、加载时读回;"没看见起点、只看见 idle"过 2 分钟宽限期也照常收尾。**不要再把这张表改回纯内存**,改这块前先看 3.1b,回归在 `tests/plugin.test.mjs`(4 条)与 `tests/qq.test.mjs` 的 `[15]` 组 | | **完成通知的摘要把上一轮回复当本轮结果**(1.0.14 修的真事故) | 主人发新消息打断正在跑的一轮时,会话事件末尾只剩本轮的新 `user/message`;旧的 `extractTurnSummary` 与 `user/message` **各取"第一条找到的"**,于是配出「本轮问题 → 上一轮回复」,每打断一次就把同一段旧文字再推一次(实测:14:56 / 22:22 / 04:46 三条完全相同的通知)。现在**按轮配对**(`assistantIdx < userIdx` ⇒ 本轮没回复),且「有用户消息、没有回复」时**不推** turn-complete、日志行结果列写「已中断(无回复)」。**不要再把两个事件各取一条**,改这块前先看 3.1c,回归在 `tests/summary.test.mjs`(18 条)与 `tests/plugin.test.mjs`(1 条端到端) | ### 已知未解决 / 未验证 | 事项 | 状态 | |---|---| | QQ 单条**客户端**渲染上限 | 服务端实测接受 900/1500/3000 字(见 6.7),但**手机端会不会折叠/截断长消息没观测过**;因此 `QQ_TEXT_SAFE_CHARS` 保持 900 | | `note` / `note-link` 档的真实端到端 | 本轮只保证「默认档 `chat` 零上传」;上传路径本身(P1 服务端账号/额度/保留期)尚未改,`note*` 档仍指向现有中枢 `/api/notes` | | `src/summary.js` 换行压平 | **已修(0.8.6)** —— 见上表;但**服务器上那 14 篇历史笔记仍是被压平的旧文本**,渲染器只能尽力还原(`repairFlattened`),新笔记才会是正常 markdown | | 闲聊只读拦不住 `ssh` 到远端改文件 | 已知边界。沙箱只管本机文件系统 | | nginx 的 `Cache-Control` 被 `add_header` 加了两次 | 无害,低优先级 | | 「QQ 菜单点击 → 输入框 → 按发送」的完整真机链路 | 逻辑上已通(菜单保留前导斜杠、线上菜单已含 `屏幕 /screen`),但尚未亲眼见到 | | 截图在 macOS 上的路径(`screencapture`) | **未在任何 mac 上跑过**,属于"顺手写上";Linux 故意不支持(明确报错,不假装成功) | | Defender 的 AMSI 规则将来可能改 | 现在这条绕过依赖启发式规则;再被拦时 `verify-installed.mjs` 第 [3] 节会红,QQ 里也会收到中文提示 | | 大小写绕过依赖平台检测的漏洞 | 腾讯若修掉就失效,届时要走官方申诉 | | QQ 菜单的**客户端缓存与刷新延迟** | **完全没测过**(这也是"会话名不写进菜单"的决定性理由)。菜单只放固定入口,会话名单永远现算 | | 「菜单点「会话」→ 输入框出现 `/sessions` → 按发送」的真机链路 | 逻辑与线上配置都已就位(回读 version 43),但"用户手点那一下"仍未亲眼观测 | --- ## 十一、给下一个改这个插件的人 1. **先读 [../README.md](../README.md) 的「五条必须遵守的加载/配置期约束」**,再看本文件。 2. 加配置项:`DEFAULTS` → `Config` → `FIELD_SPECS` → 测试,四步缺一不可。 3. 改文案/排版:**把规则放进导出的纯函数**,让测试真跑它,别写只匹配字符串的断言。 4. 改完必须:`build` → `npm test` → `pack` → `install` → `verify-*` → **让用户重启** → 比时间戳。 5. 任何「看起来应该可以」的结论都不算完成;`verified` 和 `unverified` 分开写清楚。