Notifications & Integrations
dsh-wechat-bridge
350Lxin/dsh-wechat-bridge · ★ 0
Scan a QR code to reach your DeepSeek Harness agent from WeChat (Tencent iLink bot channel).
README body is shown in Chinese.
Manifest details
安装
dsh plugin --profile web add 'github:350Lxin/dsh-wechat-bridge#d35b2bf02cd06803c9ae19e263aa5253b1538a02'GitHub 源码安装:首次需按提示配置 allowBuilds 构建授权后重试
For install and environment setup, plus plugin development tutorials, see DSH community docs ↗
README
Table of contents
扫一个二维码,就能从微信里跟你这台机器上的 Harness AI 对话。
底层走的是腾讯官方的 iLink 机器人通道(ilinkai.weixin.qq.com),不是 Wechaty / WeChatFerry 那套
个人号 hook。不需要公网 IP、不需要 Docker、不需要自建服务器。
安装
# 从 GitHub 装(推荐先克隆,路径安装更便于改配置)
git clone https://github.com/350Lxin/dsh-wechat-bridge.git
dsh --profile web plugin install /absolute/path/to/dsh-wechat-bridge
装完必须重启一次 Harness,然后按下面三步配置。
要求:Node.js ≥ 20。
装上之后先做三件事
1. 重启 Harness
插件第一次装载要重启一次(之后改配置不用):
# 停掉正在跑的 dsh,然后
dsh --profile web web --port 3080
2. 配置工作目录(必做)
编辑 ~/.dsh/profiles/web/cordis.patch.yml,加一节覆盖:
- id: wechat-bridge
name: 'dsh-wechat-bridge/host/bridge.js'
disabled: false # 打开通道;默认是关的
config:
workspaceDir: 'C:\Users\you\Desktop\dsh-wechat-workspace'
allowFrom: [] # 白名单:留空 = 谁都不回
allowGroups: [] # 群聊白名单:留空 = 群里不回
permissionPreset: 'read-only'
wechat-bridge默认disabled: true。这是故意的:打开通道等于允许白名单里的人花你的 token。
3. 打开面板扫码
在 Harness Web UI 左侧图标栏点「微信」→「显示二维码」→ 用手机微信扫。 扫码时微信客户端会弹出授权页,请读一下上面的文案再确认。
扫完在「最近消息」里能看到对方 ID(形如 xxxx@im.wechat),点「加入白名单」,然后就能在微信里跟它说话了。
三个插件的分工
| 行 id | 作用 | 默认 |
|---|---|---|
wechat-ilink |
iLink 协议层:扫码登录、长轮询收消息、发消息、媒体下载解密 | 开 |
wechat-bridge |
会话桥:白名单、权限、每人一个只读会话、回复提取与分片 | 关 |
wechat-web |
面板路由:/dsh-wechat/* |
开 |
wechat-web 的每个路由都会先问 connection.requestRejection(),走官方的 Host/Origin 栅栏
和浏览器鉴权;未通过的一律 401/403。服务缺失时失败即关闭(503),不会裸奔。
配置项(wechat-bridge)
| 字段 | 默认 | 说明 |
|---|---|---|
workspaceDir |
(空) | 每个联系人一个子目录。空 = 不工作,必须配 |
allowFrom |
[] |
允许的私聊联系人 ID 列表。空 = 全拒 |
allowGroups |
[] |
允许的群 ID 列表。空 = 群里不回 |
groupRequireMention |
true |
群里必须 @ 机器人才回 |
replyToSenderInGroups |
true |
群里回复发给发信人而不是群。见下方「群聊的未验证之处」 |
permissionPreset |
read-only |
会话锁定的权限预设 |
maxInboundChars |
4000 |
入站消息字数上限;超限直接回绝,不会建会话、不消耗额度 |
maxRepliesPerPeerPerDay |
50 |
每人每天回复上限(超限每天只提醒一次) |
maxReplyChars |
200 |
单条微信消息字数上限 |
replyTimeoutMs |
180000 |
单次回复等待上限 |
historyTurns |
12 |
预留(会话上下文由 Harness 会话日志自己维护) |
群聊的未验证之处(请读)
WeixinMessage 的 schema 里有 group_id 字段,但腾讯官方那个客户端在全部 20 多个源文件里
一次都没读过它,而且把每条消息的 ChatType 硬编码成 'direct',回复一律发给 from_user_id。
这有两种可能,我无法从接口本身判断:
- 群消息根本不会被投递到这个机器人通道;
- 会投递,但官方客户端选择只当作私聊处理。
因此「在群里 @ 机器人」这条路我没有验证过,而且群内回复很可能发不出去(因为
sendmessage 用的是 to_user_id,把群 ID 填进去是否被接受未知)。
我的处理:
- 默认
replyToSenderInGroups: true—— 群里被 @ 后,回复私聊发给发信人。 这与唯一存在的参考实现一致,是已知能工作的方向。 - 群聊仍然有独立的会话(按群 ID 隔离上下文)和独立的白名单条目。
- 如果你实测出群内回复可行,把它设成
false即可,不用改代码。
如果你不打算用群聊,这条完全不影响你。
安全边界(请读完)
- 白名单默认全拒。 不在名单里的消息会被记录为「忽略」但绝不回复。
- 只读。 会话被钉在只读权限预设上:能读文件、能搜索、能联网,不能改盘、不能执行命令。
- 每个联系人一个独立目录,
workspaceDir/<peer-id>/,互相看不到对方的文件。 - 微信侧输入不可信。 回复会做 Markdown 降级(去表格/代码围栏/强调符)、截断、分片;不返回本地路径。
- 只读 ≠ 零权限。 只读沙箱仍然能读工作目录里的东西。所以
workspaceDir要选一个干净的目录。 - 内容会离开这台机器。 微信里收到的内容会进入本地会话日志,并发给模型服务商。
关于协议本身
iLink 目前挂在腾讯自家的 OpenClaw 产品线下。腾讯是否正式允许第三方调用这条通道,我无法从接口 本身证实——这也是为什么扫码时客户端会弹授权页。请以那个页面上的文案为准。
它比个人号 hook 方案安全得多(不是逆向、不是外挂),但不保证零风险。第一轮建议别用你最看重的主号。
实现的协议细节
来源:腾讯自己发布的 npm 包 @tencent-weixin/openclaw-weixin
(author: Tencent,MIT)。以下端点已在本机实测通过:
| 用途 | 请求 |
|---|---|
| 取二维码 | POST /ilink/bot/get_bot_qrcode?bot_type=3,body {local_token_list} |
| 轮询扫码状态 | GET /ilink/bot/get_qrcode_status?qrcode=<key>[&verify_code=<码>] |
| 收消息 | POST /ilink/bot/getupdates,body {get_updates_buf, base_info} |
| 发消息 | POST /ilink/bot/sendmessage,body {msg:{...}} |
公共请求头:iLink-App-Id: bot、iLink-App-ClientVersion(uint32)。
带鉴权的请求另有:AuthorizationType: ilink_bot_token、Authorization: Bearer <token>、X-WECHAT-UIN。
登录状态机完整处理了 8 种回包:wait / scaned / confirmed / expired(自动刷新,最多 3 次)/
scaned_but_redirect(切 IDC 主机)/ need_verifycode(回填手机上的数字)/ verify_code_blocked /
binded_redirect(已绑定,不是失败)。
两个必须处理的坑:
message_id/msg_id/svr_id是超出Number.MAX_SAFE_INTEGER的 uint64。 直接JSON.parse会精度丢失,所以收包前先给这几个字段加引号(见host/ilink.js的parseWeixinJson)。get_updates_buf必须落盘。 它是长轮询的断点,丢了会漏消息。同时errcode: -14表示会话 超时,需要重新扫码。
开发这个插件时踩到的坑(留给以后改它的人)
Cordis 拒绝访问没在
inject里声明的服务。web.js一开始只声明了'webServer',却用Reflect.get(ctx, 'connection')去读信任栅栏。 在真实 Harness 里七个路由全部返回 400,错误是cannot get property "connection" without inject; 而所有离线测试全绿——因为手写的 context double 对任何名字都照答不误。 修法:inject = ['webServer', 'connection'],并且用ctx.connection而不是Reflect.get。 诊断它唯一有效的手段是在 handler 里把真实堆栈写进文件,ctx.logger的输出看不到。 回归保护在tests/cordis.integration.test.mjs,做法是源码级检查(列出inject声明、 扫出所有ctx.<service>读取、确认没有未声明的),不会因 Cordis 版本差异而失效。可选服务用
ctx.get(name),必需服务用ctx.<name>+inject。ctx.get()对未知服务返回undefined(wechatBridge、wechatSessionBridge就是这么读的); 裸属性访问在没声明时会失败。这个 checkout 是
tsx直跑源码的 dev 环境:改插件文件不会热重载。 每次改完都要重启 Harness 才生效。磁盘上是新代码、进程里跑旧模块时, 症状是「新加的路由 404、旧路由报错」,极易误判成代码写错。二维码不用微信那个页面,自己画。 微信返回的
qrcode_img_content是一个客户端渲染的 SPA(外链 JS +#app占位), 它读localStorage。所以:- 放进
sandboxiframe(不带allow-same-origin)→ 储能被禁 → 页面崩成白框; - 带上
allow-same-origin→ 同源内容可以自己摘掉沙箱 → 等于没有隔离,腾讯的 JS 就跑在你的源上。
正解是不嵌它:那个 URL 本身就是可扫内容,所以
host/qr.js自己把它编码成 SVG。 零第三方脚本在本源执行,而且二维码全对比度渲染、不依赖外链。写 QR 编码器时踩到的两个坑(都由测试抓到):
- 格式信息必须从最高位开始写到那串格子里。写反了符号看起来完全正常 (定位符、数据区、纠错码全对),但任何扫描器都读不出来。
- 第二份格式信息是 7 格 + 8 格,且必须跳过暗模块
(size-8, 8)。 占用暗模块会把后 7 位格式信息写坏。 两个坑都是靠「和第三方编码器逐格对比」定位的;tests/qr.test.mjs把这两个不变式固化成了断言。
- 放进
测试参数的陷阱(我自己踩的)。 写登录状态机测试时,我把轮询间隔设成 5 秒、超时设成 4 秒, 于是循环第一轮就到期退出,表现为「提交验证码后不再轮询」。 看起来像产品 bug,实际是测试参数自相矛盾。 现在
makeTransport用 20ms 间隔,并在注释里写明了原因。扫码成功后必须立刻启动接收循环。
index.js只在启动时已有凭据的情况下调transport.start()。 所以如果只在那边启动,刚扫完码的账号要再重启一次才开始收消息—— 也就是「扫完了但发消息没反应」。 修法:wechat-web的登录流程在result.connected时也调一次start()(幂等)。 回归保护在tests/web.test.mjs的「a successful scan starts the receive loop」, 它同时断言登录失败时不会启动(避免无凭据空转)。这条曾经是真实的缺口:我写这部分时只想到「配好凭据后重启」, 没想到「扫码就是产生凭据的那一刻」。它是靠通读调用链发现的,不是靠测试。
文案不要拿协议状态当显示条件——它会把人引向错误的排查方向。 二维码卡片的标题原本写成:
qr.status === 'wait' ? T.waitingQr : T.connection而
qr.status在整个"等你扫"期间一直是'wait'(那是网关的状态值)。 于是二维码已经画在屏幕上、正等着被扫的时候,标题却写着「正在获取二维码…」。实际后果:操作者报告「一直显示正在获取二维码」,我据此去查网络、超时、服务可用性—— 全是错的方向。真相是功能完全正常,只是标签在骗人。
教训:显示文案要描述卡片在做什么(
phase === 'requesting-qr'),不要复用协议字段。 回归保护在tests/web.test.mjs的「the panel copy describes the card」, 它是源码级断言:禁止qr.status === 'wait' ? T.waitingQr这种写法再次出现。「加入白名单」按钮依赖被忽略的那条流水记录。 面板要从
entry.group判断该把 ID 加进allowFrom还是allowGroups, 而它操作的对象是被忽略的记录(还没被授权的那个群)。 我最初只在「已接受」的记录上写了group标记,导致: 群里点「加入白名单」会把群 ID 加进私聊白名单,群永远无法从面板授权。 修法:record(entry, group)统一兜住,所有记录都带标记。 回归保护在tests/bridge.test.mjs的 case 17,它回放面板的判断逻辑并断言归类正确。conversation(会话归属)和deliveryTarget(回复发往谁)是两个不同的东西。 群聊里出错时,我的失败提示原先发给conversation(群 ID)——又踩回了「群内回复」这条未验证的路。 凡是发消息的地方都必须用deliveryTarget。回归保护在 case 18。状态目录的位置取决于启动方式——别假设只有一个。
DSH_PROFILE_DIR有值时凭据落在$DSH_PROFILE_DIR/dsh-wechat; 没有时落在$DSH_HOME/dsh-wechat(即~/.dsh/dsh-wechat)。 真实的 Harness 用哪种方式启动,决定登录写在哪里。我在这上面栽了跟头:你扫码成功、凭据已写入
~/.dsh/dsh-wechat/account.json, 而我连续几轮在~/.dsh/profiles/web/dsh-wechat/下查,得出「面板从未被点击」的 错误结论,还据此把目标标记为受阻。教训:排查状态类问题前,先确认代码实际写到哪个路径, 而不是假设路径。同时这也暴露了一个真实风险:如果启动方式变了,原路径的登录会凭空失效。 现在
index.js会计算两个候选目录,restore()在主目录为空时回退采用另一个, 并把后续写入落到采用的那个文件上。测试绝不能读真实状态。 加上状态目录回退之后,Cordis 集成测试里的
index.js读到了你真实的账号并开始轮询真实网关, 表现为「the transport starts signed out」断言失败——测试在改动它本该只观察的状态。 修法:测试把DSH_HOME与DSH_PROFILE_DIR都指向临时目录。 教训:任何会读磁盘或联网的插件测试,都必须先把环境隔离,否则测试会污染运维现场。同一个
inject缺陷我犯了两次——第二次才被真实堆栈抓住。web.js因为没声明connection让 7 个路由全 400;后来bridge.js又因为没声明agents,让每一条真实微信消息都失败:inbound: from=<peer>@im.wechat group= id=751... items=1 turn FAILED: Error: cannot get property "agents" without inject at ensureAgent (host/bridge.js:265) at answer (host/bridge.js:361) at handle (host/bridge.js:567)为什么两套离线测试都没抓到:手写的 context double 对任何服务名都照答不误, 而 Cordis 在使用时刻才拒绝。症状是「消息收到了,但每一条都回『我这边出错了』」。
修法有三层,缺一不可:
inject声明全部必需服务(agents/sessionPersistence/permissionPresets), 可选服务(sessionTitle/workspaceRegistry/agentDefaultModel)改用ctx.get();tests/bridge.test.mjs的 context double 改为遵循inject清单:未声明的属性访问直接抛cannot get property "<name>" without inject,与 Cordis 行为一致;tests/cordis.integration.test.mjs加源码级审查:剥离注释后枚举每个 Host 半边里的 所有ctx.<service>读取,逐一核对是否在inject中,并断言桥接层必须声明wechatBridge/agents/sessionPersistence/permissionPresets。
最讽刺的一层:我早就写下过「每个
ctx.<service>都必须在inject里」这条教训, 却没有机械地执行它。现在的源码审查就是为了让「靠记性」不再成为必要条件。浏览器侧的路由必须是「文档相对」,不能是「原点绝对」。 Harness 的 index 带
<base href="./">,而且前端路由的既定约定就是 document-relative (见.agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md: 「a document-relativefetch('api/session/create')still succeeds beneath it」)。我原先写的是
const API = '/dsh-wechat'。在根路径挂载下它侥幸能用,但一旦部署挂到 子路径(例如/some/mount/),代理会剥掉前缀、而浏览器却按原点根去解析—— 面板的 6 个请求和二维码图片全部 404。修法:
const API = 'dsh-wechat'(不带前导斜杠),由浏览器按<base>解析。 验证:new URL('dsh-wechat/status', 'http://h/some/mount/')→/some/mount/dsh-wechat/status, 而绝对形式 →/dsh-wechat/status(错)。 回归保护在tests/web.test.mjs的「the panel copy describes the card」段末两条断言。
测试
node tests/run-all.mjs # 八套全跑(含打真实腾讯网关)
node tests/run-all.mjs --offline # 跳过联网那套
# 可选:装上独立的解码器/编码器,多两层交叉验证
npm install --no-save jsqr qrcode
汇总行区分通过 / 跳过 / 失败:
passed 8 skipped 0 failed 0 (of 8)
三套 real-Cordis 套件(ilink.boot / cordis.integration / e2e.wiring)需要本机有
Harness 安装来借用 @deepseek-ai/cordis。找不到时它们以退出码 77 结束、显示为
SKIPPED,不会被算作通过——否则 CI 会在一片绿里隐藏掉最该跑的那几套
(inject 那类缺陷正是它们抓的)。在 GitHub Actions 里没有 Harness,所以你会看到
passed 4 skipped 3。
| 套件 | 覆盖 |
|---|---|
tests/qr.test.mjs |
QR 编码器:用 jsqr 独立解码回原串、自带解析器复核纠错码、SVG 路径与模块矩阵逐格一致、超长载荷拒绝 |
tests/ilink.login.test.mjs |
登录状态机 8 个分支全部覆盖:wait/scaned/confirmed/expired(含刷新上限)/scaned_but_redirect/need_verifycode/verify_code_blocked/binded_redirect,外加网关不可达与超时 |
tests/bridge.test.mjs |
会话映射、白名单、群 @ 门禁、限速、回复提取、分片、超时、媒体落盘 |
tests/web.test.mjs |
7 个路由、信任栅栏(401/403/503 失败即关闭)、二维码 SVG 渲染与解码回环、兄弟行缺失时的退化 |
tests/cordis.integration.test.mjs |
用真实 Cordis 激活三个插件;源码级守 inject 声明 |
tests/e2e.wiring.test.mjs |
用真实 Cordis 服务图把传输层与桥接层接起来跑完整收发(网关用替身) |
tests/ilink.live.test.mjs |
打真实腾讯网关取二维码;uint64 精度、AES-128-ECB、消息展平 |
诊断日志:两个文件都写在状态目录下。
<状态目录>/login-probe.log # 登录:取码、每次轮询的状态转换、失败原因
<状态目录>/bridge-probe.log # 桥接:建会话的每一步,以及回合失败的完整堆栈
状态目录是这两个之一,取决于启动方式(见下方第 11 条):
$DSH_PROFILE_DIR/dsh-wechat/ # 例如 ~/.dsh/profiles/web/dsh-wechat/
$DSH_HOME/dsh-wechat/ # 例如 ~/.dsh/dsh-wechat/
两个都不含 token,且不需要浏览器鉴权就能读——面板报错时先看它们。
bridge-probe.log 是排查「微信里显示『我这边出错了』」的第一现场:它记录
ensureAgent 的每一步(工作目录、模型选项、建/恢复会话、权限预置是否生效、提示是否注入成功)
以及失败时的完整堆栈。这条路径以前只把一句短消息发到微信、把堆栈丢进 Harness 日志,
维护者从文件系统看不到——所以这个文件是专门补上的。
测试通过 DSH_WECHAT_PROBE_DIR 把这两个文件改到临时目录,所以跑测试不会污染你自己的诊断记录。
还有个预览脚本,它做的是「扫码之前能做的最后一步证明」:
npm install --no-save jsqr
node tests/qr.preview.mjs
它向真实网关要一张登录码 → 用面板路由所调的同一个渲染函数画成 SVG →
光栅化 → 交给独立解码器读回。任何环节(协议、编码器、路由载荷)出问题都会在这里暴露。
同时写出 qr-preview.png,可以直接用手机扫。
文件
index.js Host:iLink 传输服务(ctx.wechatBridge)
host/ilink.js 协议实现:登录、长轮询、发送、媒体解密
host/bridge.js 会话桥:白名单、权限、会话映射、回复提取
host/markdown.js 微信文本降级与分片
host/qr.js 自带 QR 编码器(字节模式 / 纠错等级 M / 版本 1-10)
host/web.js /dsh-wechat/* 路由
client.js 侧栏面板:二维码、状态、消息流水、白名单
locale/{en,zh}.json 插件元数据与文案
tests/ 七套测试