首页 / 资讯中心 / 文章详情

DSH 与 QQ 桥接|从「收到 0 帧」到端口自发现(完整排障)

DSH 与 QQ 桥接|从「收到 0 帧」到端口自发现(完整排障) ★ FEATURED ARTICLE
一、症状门开了一句话没说就关先说现象因为它几乎决定了后面所有的判断。桥接qq-bridge连上 DSH 桌面版之后连接能被建立但立刻就结束日志反复输出同一行[bridge] DSH 事件流已结束本次收到 0 帧 [bridge] DSH 不可用消息入队 (private:xxxxxxxxxx)同时 QQ 那头发消息给机器人机器人不回。桥接自己的日志给了个提示多半是 DSH 侧事件流协议/版本不匹配可查 issue #34这句话只对了一半而且会把人带偏。先记一条经验「收到 0 帧」不是对端装死也不是模型不干活。它说明握手阶段就被拒绝请求根本没进到会话处理逻辑里。副作用是改预设、改 persona、改白名单、换模型全都没用——那些代码压根没被执行到。先把「修好之后长什么样」放在这里后面所有判断都以这组行为验收签名[bridge] 桥接已启动。按 CtrlC 退出。 [bridge] 连接 DSH 事件流... [bridge] 本地控制台已启动: http://127.0.0.1:3100 [bridge] SnowLuma 已连接: ws://127.0.0.1:3001 [bridge] 机器人昵称: 北斗 [bridge] DSH 已就绪模式reserved2 [bridge] 桥接模式已确定为 reserved2恢复有限沉睡定时器 [bridge] [sticker] 已同步 QQ 收藏表情 0 个本地库 0 条via ws差别不在措辞在于再也没有已结束本次收到 0 帧那一行。所以第一件事不是改配置而是回答一个问题我连的这个端口真的是 DSH 吗二、第一步不是改配置是问「谁在监听这个端口」原配置里写的是dsh:{baseUrl:http://127.0.0.1:18787}18787 是当初配的看着没问题。但端口号本身不携带任何身份信息必须单独验一遍。两个动作就够# 1. 谁在这个端口上 LISTENGet-NetTCPConnection-State Listen-LocalPort 18787|Select-ObjectLocalAddress,LocalPort,OwningProcess|ForEach-Object{$_;Get-CimInstanceWin32_Process-FilterProcessId$($_.OwningProcess)|Select-ObjectProcessId,Name,CommandLine}# 2. 问它一句「你是谁」curl.exe-s-i--max-time 2 http://127.0.0.1:18787/结果是这样状态LISTEN占用进程 node 命令行node ...\billion-context\dist\index.js start --host 127.0.0.1 --port 18787 GET / 的响应 {ok:true,upstream:https://api.anthropic.com}再补一刀随便挑一个它不认识的路径curl.exe-s-i--max-time 2 http://127.0.0.1:18787/json/versionHTTP/1.1 403 Forbidden cf-ray: a4f90c921dbea43c-SJC看到cf-ray就明白了这个端口把未知请求转发给了 api.anthropic.com然后被 Cloudflare 拦了。结论18787 是 billion-context 的 LLM 转发代理不是 DSH 会话 API。它对外提供的是「模型出口」收到 WebSocket 或私有协议握手当然会立刻断开——这正好解释了「门开了、0 帧、门关了」。日志里那句「协议不匹配」确实成立但根本原因是baseUrl 填错了端口。那真身在哪枚举所有监听的进程按进程名筛Get-Process-NameDeepSeek Harness|ForEach-Object{$proc$_Get-NetTCPConnection-State Listen-ErrorAction SilentlyContinue|Where-Object{$_.OwningProcess-eq$proc.Id}|ForEach-Object{[pscustomobject]{PID $proc.Id;Port $_.LocalPort;Addr $_.LocalAddress}}}|Sort-ObjectPort看到一个回环端口当时是 57082再问它一句curl.exe-s-i--max-time 2 http://127.0.0.1:57082/HTTP/1.1 401 Unauthorized dsh web authentication required; reopen the URL printed by dsh web.这才对味。401 加这句文案说明它是 DSH web 的 HTTP 服务只是要鉴权。这行文案在app.asar里也能搜到可以顺手确认$ResE:\AI_Animation\20_Deepseek Harness\resourcesSelect-String-Path$Res\app.asar-Patterndsh web authentication required|listen\(0-Encoding utf8|Select-Object-First 8|%{$($_.LineNumber):$($_.Line.Trim())}421442: res.end(req.method HEAD ? void 0 : dsh web authentication required; reopen the URL printed by dsh web.\n); 118692: server.listen(0, 127.0.0.1);第一行是刚看到的 401 文案第二行是下一节的主角。判定口诀记这三条就够探活结果说明{ok:true,upstream:...}某个 LLM 代理不是DSH401dsh web authentication required这是 DSH web端口对了超时 / 连接被拒进程没起或端口已释放三、找到真身之后DSH web 要的是 Cookie不是 token按直觉下一步是去config.json里填dsh.authToken。config.json里确实预留了这几个字段authToken:,authHeader:authorization,authPrefix:Bearer,allowRemote:false但先别填。翻一眼桥接的实现src/dsh-client.js头部注释写得很直白// 3. 新增浏览器会话鉴权先用 dsh.authToken进程启动 token换取 Cookie再带 Cookie 访问 API/WS// 4. 事件流不再是 events.mux 下行而是 /api/remote.mux 上按 session/follow 打开的流也就是说它走的是浏览器会话那一套而不是 Bearer token。继续看到ensureAuth的两条路径注释里已经把优先级讲清楚了1. 有 launch tokenconfig.json 的 dsh.authToken或从日志发现→ 走 DSH 的官方 token 交换 2. 没有 launch token → 用持久化签名密钥离线铸造 Cookie …这条路不依赖启动器是否把 stdout 记进日志所以 DSH 换启动方式/升级都不需要重新适配 // 优先级默认铸造优先——它不依赖日志也不随 DSH 重启失效铸造那一条读的是~/.dsh/.credentials.yaml └── BROWSER_SESSION_KEY ├── kind: grant └── payload.version 1 └── payload.secretbase64url正好 32 字节本地做一次 HMAC 就得到一个set-cookie全程不需要网络、不需要日志、不需要你手动贴任何东西。于是可以下第二个结论authToken留空反而是推荐状态。手填一个「进程启动 token」只是走了「尊重你的显式意图」那条路DSH 一重启 token 就变401 卷土重来。还有一个容易被忽略的细节Cookie 和host:port绑定authority 进了签名载荷。换端口 → 旧 Cookie 自动失效 → 桥接按当前 baseUrl 重新铸造。这句话是本次排查里最值钱的一句它同时解释了「为什么端口对了就全对」和「为什么端口一换就全坏」。顺手确认密钥在不在不希望打印明文$cred$env:USERPROFILE\.dsh\.credentials.yaml文件存在: (Test-Path$cred)Get-Content$cred-Encoding UTF8|Select-String-Patternkind|version|browser|session|secret|ForEach-Object{$_.Line-replace(:\s*)[A-Za-z0-9_\-]{24,},$1hidden}四、为什么今天修好、明天又坏listen(0)端口改成 57082日志开始出帧QQ 也能回话了。但重开一次桌面版端口变成了 64023。回到第二节搜到的那行server.listen(0,127.0.0.1);listen(0)让操作系统随机分配一个空闲端口。这是桌面应用避免端口冲突的标准做法也意味着没有DSH_WEB_PORT之类的固定入口可配置搜过没有端口每次启动都可能变手上那份baseUrl是一份快照不是事实。所以「手动填端口」这条路在结构上就是不可持续的。要修的不是端口是获取端口的方式。五、把「发现」这件事交给脚本四个动作思路很朴素与其记住端口不如每次启动前现场问一遍。脚本只做四件事找到DeepSeek Harness进程枚举它名下127.0.0.1的 LISTEN 端口逐个curl /命中dsh web authentication required或200就是它改写config.json的dsh.baseUrlConvertFrom-Json校验通过后写盘再调start.bat。完整脚本全英文 带 BOM 写出理由见第六节$BaseE:\AI_Animation\21_deepseek bridge\qq-bridge-0.2.1-r1$ps1 param([string]$Base E:\AI_Animation\21_deepseek bridge\qq-bridge-0.2.1-r1) $ErrorActionPreference Stop $procs (Get-Process -Name DeepSeek Harness -ErrorAction SilentlyContinue) if ($procs.Count -eq 0) { Write-Host DSH desktop is not running; exit 1 } $found $null foreach ($proc in $procs) { $listen Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Where-Object { $_.OwningProcess -eq $proc.Id -and $_.LocalAddress -eq 127.0.0.1 } foreach ($item in $listen) { $probe curl.exe -s -i --max-time 2 (http://127.0.0.1: $item.LocalPort /) if ($probe -match dsh web authentication required -or $probe -match HTTP/1\.1 200) { $found $item.LocalPort; break } } if ($found) { break } } if (-not $found) { Write-Host DSH web port not found; exit 1 } $cfg Join-Path $Base config.json $txt [System.IO.File]::ReadAllText($cfg) $url http://127.0.0.1: $found $new [regex]::Replace($txt, (baseUrl\s*:\s*)[^]*(), (${1} $url ${2})) try {$null ($new| ConvertFrom-Json) } catch { Write-Host (json invalid, not written: $_.Exception.Message); exit 1 } [System.IO.File]::WriteAllText($cfg,$new, (New-Object System.Text.UTF8Encoding($false))) Write-Host (baseUrl - $url) (Join-Path$Basestart.bat) # 注意两种编码是故意的 [System.IO.File]::WriteAllText((Join-Path$Basestart-auto.ps1),$ps1, (New-Object System.Text.UTF8Encoding($true))) # 脚本本身带 BOM$cmd echo off rn powershell -NoProfile -ExecutionPolicy Bypass -File %~dp0start-auto.ps1 rn pause rn[System.IO.File]::WriteAllText((Join-Path$Baserun-auto.cmd),$cmd,[System.Text.Encoding]::ASCII)generated start-auto.ps1 run-auto.cmd跑之前先做语法自检别靠撞错$psE:\AI_Animation\21_deepseek bridge\qq-bridge-0.2.1-r1\start-auto.ps1$errs$null[void][System.Management.Automation.Language.Parser]::ParseFile($ps,[ref]$null,[ref]$errs)if($errs){$errs|ForEach-Object{$_.Message}}else{syntax OK}看到syntax OK再跑powershell-NoProfile-ExecutionPolicy Bypass-FileE:\...\qq-bridge-0.2.1-r1\start-auto.ps1实测输出本机已跑通。脚本自己只打一行baseUrl - http://127.0.0.1:58249随后它拉起start.bat桥接打印的就是第一节那组「DSH 已就绪模式reserved2」。这里有三个容易看错的点本地控制台已启动: http://127.0.0.1:3100——这是桥接自己的控制台端口来自config.json的consolePort和 DSH 端口是两回事别拿它当 baseUrl控制台未配置 consoleToken已自动生成: 537662...——这串是浏览器访问控制台用的和上一节的 DSH 会话 Cookie 无关桥接模式已确定为 reserved2——模式是从 SnowLuma 的网关能力探测出来的出现它就是「事件流真的通了」的最直接证据。从此以后的启动顺序固定为先开 DSH 桌面版 → 等界面出来 → 双击run-auto.cmd。不要直接跑start.bat它不会自己找端口。为什么.cmd还要包一层.ps1双击默认是「用记事本打开」而不是执行还受执行策略限制。.cmd壳负责把「解释器 -ExecutionPolicy Bypass 脚本路径」一次说清双击才真的跑起来。六、七个连坐的坑PowerShell 5.1 的编码地狱这一节是本文真正的「抄作业」部分。上面那段脚本是第七版前六版全部因为下面这些原因挂掉。坑 1无 BOM 的.ps1 中文 语法错误现象长这样看着像脚本写错了其实是解码错了所在位置 ...\start-auto.ps1:16 字符: 42 $new [regex]::Replace($txt, (baseUrl\s*:\s*)[^]*(), { param( ... 表达式中缺少右“)”。 ... 字符串缺少终止符: 原因PowerShell 5.1 读没有 BOM 的文件时按 ANSIGBK解码。脚本里只要有中文某个汉字的 UTF-8 末字节就可能和后面的单引号黏成一个 GBK 双字节字符于是引号被吃掉后面全军覆没。同一现象还会附带出现JSON 鏍獙澶辫触这种乱码——那是同一个原因留下的指纹。修法二选一我两个都用了脚本内容全英文写出时用[System.IO.File]::WriteAllText(..., (New-Object System.Text.UTF8Encoding($true)))即带 BOM。坑 2写config.json时反而不能带 BOMSet-Content -Encoding utf8在 PS 5.1 下写的是带 BOM 的 UTF-8。BOM 对 PowerShell 无害但 Node 的JSON.parse会因为开头多了\uFEFF直接报错JSON 校验: 坏了 - Unexpected token所以同一个脚本里出现两种编码是故意的目标文件编码原因start-auto.ps1UTF-8带BOMPS 5.1 才按 UTF-8 解码config.jsonUTF-8无BOMNodeJSON.parse不接受 BOM坑 3Get-Content -Raw | ... | Set-Content会双重编码Get-Content读无 BOM 的 UTF-8 文件时按 GBK 解码再写回去就变成双重编码。文件里会留下这种痕迹workspaceTitle:QQ 鑱婂ぉ,label:鎴戠殑涓浆妯″瀷,而且它是静默发生的——没有报错只是中文全烂最终在ConvertFrom-Json时炸掉传入的数组无效应为“,”。(1644): {修法读[System.IO.File]::ReadAllText($p)写[System.IO.File]::WriteAllText($p, $text, (New-Object System.Text.UTF8Encoding($false)))或者至少给Get-Content/Set-Content显式加-Encoding UTF8。已经写坏了就别再改字直接Copy-Item config.example.json config.json再用ConvertFrom-Json回填dsh.baseUrl、ownerQQ、allow.private三个字段——比修乱码快得多。坑 4-replace默认不区分大小写会误伤隔壁字段配置里有两个长得几乎一样的键dsh:{baseUrl:http://127.0.0.1:18787},webSearch:{baseURL:}-replace baseUrl\s*:\s*[^]*会把两个都改掉于是出现「两行都是一样的端口」baseUrl:http://127.0.0.1:64023,baseUrl:http://127.0.0.1:64023,修法用[regex]::Replace区分大小写并且用${1}/${2}做替换别用MatchEvaluator闭包$new[regex]::Replace($txt,(baseUrl\s*:\s*)[^]*(),(${1}$url${2}))坑 5PowerShell 变量名不区分大小写$PE:\AI_Animation\20_Deepseek Harness$pGet-ProcessQQMusic# 把 $P 覆盖了Join-Path$pREADME.md# → System.Diagnostics.Process (QQMusic)\README.md习惯变量名里不要只靠大小写区分。坑 6New-Object System.Text.ASCIIEncoding()空括号非法“(”后面应为表达式。UTF8Encoding($false)有参数所以能过ASCIIEncoding()无参数就被解析器判成非法。改用静态属性[System.Text.Encoding]::ASCII坑 7先别动 SnowLuma 和白名单症状在握手层收到 0 帧意味着请求没进会话逻辑。这时候去改机器人白名单、改 persona、改 preset全是无效劳动——先用 401 有没有出现来判断位置有 401 → 端口对了问题在 Cookie 交换连 401 都没有 → 端口或进程就不对别往下折腾鉴权。七、整条链路一张图看尽QQ 客户端 │ 收发消息 ▼ SnowLumaOneBotws://127.0.0.1:3001 / http://127.0.0.1:3000 │ HTTP WS ▼ qq-bridge ────── config.json │ ├── dsh.baseUrl ← 每开机都会变的那个值 │ │ 由 start-auto.ps1 现场发现并改写 │ ├── dsh.authToken ← 留空走铸造 Cookie路径 │ └── allow.private ← 填错会直接拒答 │ │ ① GET /?tokenlaunch token → set-cookie或者本地 HMAC 铸造 │ ② 带 Cookie 打开 /api/remote.mux按 session/follow │ ③ respondPOST /api/respond、POST /api/$events/result ▼ DSH 桌面版 web 服务 └── server.listen(0, 127.0.0.1) ← 端口由系统随机分配 ✗ 曾经指向127.0.0.1:18787 └── billion-context 的 LLM 转发代理upstream: api.anthropic.com → 握手不认门开了就关 → 「本次收到 0 帧」这张图同时解释了四件事为什么「收到 0 帧」和模型、预设、白名单一点关系都没有为什么authToken留空反而最稳为什么重开桌面版就复发——端口是listen(0)为什么修好端口之后连 Cookie 都不用管——它会按新端口自动重铸。八、抄作业清单三分钟版看日志出现已结束本次收到 0 帧→ 先当「握手被拒」处理不要碰预设。验端口Get-NetTCPConnection -State Listen找DeepSeek Harness进程的127.0.0.1端口curl -s -i http://127.0.0.1:port/401dsh web authentication required→ 锁定{ok:true,upstream:...}→ LLM 代理换下一个。别填 token确认~/.dsh/.credentials.yaml里有BROWSER_SESSION_KEYkind: grant/payload.version: 1/ 32 字节payload.secret。装脚本start-auto.ps1全英文、带 BOM 写出run-auto.cmd壳改config.json时无 BOM 写出替换用[regex]::Replace${1}/${2}。验收ParseFile出syntax OK→ 跑脚本看到baseUrl - http://127.0.0.1:xxxxx→ 日志出现DSH 已就绪模式reserved2且不再有已结束本次收到 0 帧→ 去 QQ 里发一条机器人回话。冷启动三次都稳定再算修完。一句话总结「收到 0 帧」不是 DSH 不说话是你敲错了门而门牌号每次开机都会变——所以别记端口去问端口。
阅读完成 · 觉得有帮助?
咨询建站