Repository navigation
v0.12.7
0.12.7 —— 稳定性与体验优化(2026-10-28)
原装抓取工具 moli_fetch 进默认 gatedTools(一道验证墙,不叠第二道)
moli_fetch是 dsh-moli 注册的原生工具(能跟随跳转、写文件、eval 页面 JS),我们不改它的
实现,也不再在入口套一层公网校验/守卫代理——那是我们自己的渲染分支(qq_webfetch的 moli
路径)负责的事,访客继续用它、安全由内网拒绝 + 守卫代理保证。- 原装工具只设一道墙:默认
flowAuth.gatedTools加上moli_fetch,开关打开后拥有者私聊
调它要过一次 6 位动态码;没有第二层地址校验,内网地址也由拥有者用自己的码决定要不要抓。 - 名单仍与接管名单同一份(
flowGate.names()),所以不需要新增任何遮蔽代码:flowAuth 的接管
块本来就会按名单把moli_fetch包一层。 - 测试:
test-flow-wiring加 3 条断言(没码被拦且不调原工具 / 内网目标同样被拦 / 拿码后原样转交)。
守卫代理:判定即钉住地址(DNS rebinding TOCTOU)
resolveGuardTarget把判定阶段解析出的公网地址一并返回;CONNECT 直连与明文 HTTP 直连都用
这个 IP 去连,不再让net.connect/http.request按主机名重新解析一次(明文请求保留原
Host头做虚拟主机路由;上游代理路径不动,目标主机名本来就交给代理侧解析)。- 起因:0.12.6 的守卫代理是「先
dns.lookup判公网、再按主机名连接」——两次解析之间的空窗里,
TTL=0 的域名可以先答公网骗过判定、再答127.0.0.1打进内网,与 0.12.4 在publicFetch
里封掉的是同一类 TOCTOU。 - 测试:
test-guard-proxy加 4 条断言(判定返回钉住的地址 / 一个目标只解析一次 / 解析出内网
拒绝 / 解析不了保留旧语义)。该套件行数记 38 → 47;实测改动前就是 43(既有漂移),
多出的 5 与新增的 4 都在 PR 正文里说明,没有在本次一并重算所有套件。
设置页:preset 三个字段标注「如果你不知道你在做什么,最好别动这个」
agentPresetCustom/auth.ownerPreset/auth.guestPreset的 hint 统一加前缀「如果你不知道你在做什么,最好别动这个」,并写清正确用法:加装了不影响安全的第三方扩展插件、却没进任何 preset 的,在自建 preset
的plugins里加插件行、再把 id 填到这里,而不是放宽访客白名单。- 只改文案:字段位置(高级 → 会话与排错)、默认值、分流优先级都没变。
设置页新增「闸门区」:名单改动要一次动态码(填码解锁 → 保存即消费)
- 设置页「高级」拆出第三个二级标签「闸门区」:
flowAuth.gatedTools(哪些工具必须过动态码)与
flowAuth.gateEditCode(改名单用的 6 位码)放一起,不再和 TOTP 绑定/窗口参数混在流程授权里。 - 行为:动态码开着时名单默认锁定;填满 6 位码即解锁编辑,点「保存」才把码一起提交(服务端
用decideGateEdit验一次 TOTP,±1 窗口);保存成功即重新上锁并清空输入框,服务端也会把
gateEditCode写回空串——码不落盘。不保存就不写入;离开设置页,解锁状态与未保存的改动
一并丢弃(都是组件内 state,没有持久化)。 - 开关本身也要码:把「流程授权」从开切到关(或反过来)和改名单一样需要一次码——否则拿到浏览器
的人可以「先关、改名单、再开」,名单保护就白设了;关掉闸门比改名单更狠,不能更容易。 - 服务端兜底:名单或开关变了但码不对(含没码、没配 secret),插件会把两者一起回滚到上一次接受的
值并清空码字段,日志里写明原因——设置页刷新一下就能看到改动静默回退;不会静默接受。 - 测试:新增
test-gate-guard.mjs(18 项:名单比较、关着不需要码、没码/错码/无 secret 拒绝、
对码接受、±1 窗口容差、开关变更要码、名单没变时顺手清码);test-client断言高级页三个二级标签并覆盖锁定文案。 - 套件 30 → 31,总项 1204 → 1224。只防「绕闸门的人」:填错码只会让改动弹回去,不额外加确认步骤。
回复分条:Markdown 感知切分
- 以前长回答只按换行硬切,会把代码块和 GFM 表格拦腰截断;现在
lib/chunk.js把代码块与表格当原子块搬,
优先在空行处断开,单行超长才按字符硬切;被强切的代码块在本段补收尾围栏、下一段补回语言标记。 - 实现按本仓库风格重写(纯函数、无第三方依赖、逐条断言),判据是「不把 Markdown 结构切坏」;
sendReply换用它。 - 测试:新增
test-chunk.mjs(15 项:短文本/长文本/代码块围栏平衡/表格不切行/超长行硬切/未闭合围栏)。 - 套件 31 → 32,总项 1224 → 1239。
入站去重(消息幂等)
- 新增
lib/msgid-cache.js:按message_id记最近见过的消息,有界(512 条)+ TTL(10 分钟),
重复命中不延长窗口;acceptMessage最前面判重,重投事件只计入skipped/duplicates,不再起第二个回合。 - 触发场景:官方 WS 的 resume/重连会重放少量事件、webhook 可能重投;以前会对同一条消息回两次。
官方 Bot 扫码绑定(服务端 + 设置页卡片)
- 新增
lib/qq-bind.js+lib/bind-routes.js:@tencent-connect/qqbot-connector动态 import(装了就可用,
没装退回手动填 AppID/AppSecret),走 SDK 的startQrConnect回调拿二维码 URL 与凭据;凭据经设置服务落盘,
appSecret只在回调里经过一次、不进任何 HTTP 响应。 - 路由挂在 DSH
webServer上(同源):GET /dsh-qq-bot/bind、POST /dsh-qq-bot/bind/refresh;
设置页「连接」里新增「扫码绑定(手机 QQ)」卡片:显示二维码、2 秒轮询状态、可刷新/重新扫码。 - 既有的 qq-official 适配器 webhook / ws 两条路径都保留,扫码只是把 AppID/AppSecret 填进去。
官方 API v2 富消息:markdown / keyboard / 输入中 / 按钮回传
qq-official适配器的send增加三个可选字段:markdown(走 msg_type=2,可带keyboard按钮)、
keyboard(原样透传;只给 keyboard 时正文包成 markdown)、typing(msg_type=6 输入中,只尽力而为)。
被动回复的msg_id/ 递增msg_seq对 markdown 与输入中同样生效。INTERACTION_CREATE现在同时产出notice(原有行为)与一条message帧(sub_type: 'interaction',
text取按钮button_data)——以前用户点按钮只有一条 notice,模型看不见,像石沉大海。- 测试:
test-adapter-qq-official95 → 102 项(markdown/keyboard/msg_seq、msg_type=6、按钮点击回传与身份保留)。 - 套件 34 / 1271 → 1278 项。
官方 v2 私聊流式(特有能力,适配层就绪)
- 开放平台只对 C2C 私聊开放
stream_messages,所以按特有能力做:qq-official适配器新增
stream命令(input_mode=replace+ 同一msg_seq+ 递增index,结束帧input_state=10;
首帧后复用平台返回的stream_msg_id)。 - 新增
lib/stream-bridge.js:把增量合并成「当前全文」并按 600ms 节流推送,结束时必发 done;
发送失败只记一行日志并停用该会话,不刷错误。计时器可注入,纯逻辑单测覆盖。 - 接线:
runTurn里按shouldStream()(官方 v2 + 开关 + 私聊 + 有msg_id)决定是否走流式;
中间发言改为喂给桥(不再单独发消息),回合末补足剩余文本并发 done 帧;桥没发出任何帧时
退化为普通sendReply,保证用户至少拿到一条。开关在设置页「连接 → 私聊流式回复」,默认关。
模型选择:自动检测,优先用宿主自己的能力
- 新增
lib/model-router.js:按顺序探测 —— ① DSH 原生agentDefaultModel(currentSelection/saveSelection,可写,
改的是默认模型);② DSHllm(listProviders/listModels,只读能列);③ 都没有就落到本地
per-chat 偏好文件(QQ_msg/model-prefs.json),并且如实说明「只记录、不会自动应用」。 - 新增拥有者指令
/model:查看当前模型/接口来源/provider 列表;/model set <provider> <model>;
/model models <provider>。写不进去时不假装成功:返回 applied=false 并说明原因。 - 端到端模拟:
test-plugin新增两条场景——同一message_id重投只回一次;群里@机器人 /model
被截获、不进模型回合。 - 解析器
parseModelCommand统一剥掉群里 @ 前缀与前后空格——第一版在群里被 @ 时文本是
"@123456 /model",startsWith('/model')直接漏判、指令进了模型回合(已修)。 /help里补上/model一行。测试:test-model-router.mjs17 项;套件 35 → 36,总项 1296 → 1313。
测试卫生:失败重试一次并显式标注
test/run-all.mjs:任何套件第一次失败会自动重试一次;重试通过标注「(重试通过)」,
连续两次失败才算失败。会起子进程/真实定时器的套件(adapter/plugin/admin/proactive-round)
不再因为一次机器抖动把整条 CI 判红,也不会静默吞掉失败。- 顺手把 runner 头部过时的「30 个套件」改成不写死数量。
完整变更见 CHANGELOG.md。