Notifications & Integrations

dsh-wechat-bridge

350Lxin/dsh-wechat-bridge · ★ 0

350Lxin

Scan a QR code to reach your DeepSeek Harness agent from WeChat (Tencent iLink bot channel).

README body is shown in Chinese.

License
MIT
Added
2026-10-30
Maintenance
active
Install kind
github
Manifest details
Manifest status
Declared
Manifest path
package.json#dsh
Manifest checked
2026-10-30T09:38:04.892Z
Lifecycle scripts
None found

Static manifest checks report declarations only; they are not compatibility or security review.

安装

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

Source README · body: zh · Read Chinese README

Table of contents
  1. 安装
  2. 装上之后先做三件事
  3. 1. 重启 Harness
  4. 2. 配置工作目录(必做)
  5. 3. 打开面板扫码
  6. 三个插件的分工
  7. 配置项(wechat-bridge)
  8. 群聊的未验证之处(请读)
  9. 安全边界(请读完)
  10. 关于协议本身
  11. 实现的协议细节
  12. 开发这个插件时踩到的坑(留给以后改它的人)
  13. 测试
  14. 文件

CI License: MIT

扫一个二维码,就能从微信里跟你这台机器上的 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。

这有两种可能,我无法从接口本身判断:

  1. 群消息根本不会被投递到这个机器人通道;
  2. 会投递,但官方客户端选择只当作私聊处理。

因此「在群里 @ 机器人」这条路我没有验证过,而且群内回复很可能发不出去(因为 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(已绑定,不是失败)。

两个必须处理的坑:

  1. message_id / msg_id / svr_id 是超出 Number.MAX_SAFE_INTEGER 的 uint64。 直接 JSON.parse 会精度丢失,所以收包前先给这几个字段加引号(见 host/ilink.js 的 parseWeixinJson)。
  2. get_updates_buf 必须落盘。 它是长轮询的断点,丢了会漏消息。同时 errcode: -14 表示会话 超时,需要重新扫码。

开发这个插件时踩到的坑(留给以后改它的人)

  1. 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 版本差异而失效。

  2. 可选服务用 ctx.get(name),必需服务用 ctx.<name> + inject。 ctx.get() 对未知服务返回 undefined(wechatBridge、wechatSessionBridge 就是这么读的); 裸属性访问在没声明时会失败。

  3. 这个 checkout 是 tsx 直跑源码的 dev 环境:改插件文件不会热重载。 每次改完都要重启 Harness 才生效。磁盘上是新代码、进程里跑旧模块时, 症状是「新加的路由 404、旧路由报错」,极易误判成代码写错。

  4. 二维码不用微信那个页面,自己画。 微信返回的 qrcode_img_content 是一个客户端渲染的 SPA(外链 JS + #app 占位), 它读 localStorage。所以:

    • 放进 sandbox iframe(不带 allow-same-origin)→ 储能被禁 → 页面崩成白框;
    • 带上 allow-same-origin → 同源内容可以自己摘掉沙箱 → 等于没有隔离,腾讯的 JS 就跑在你的源上。

    正解是不嵌它:那个 URL 本身就是可扫内容,所以 host/qr.js 自己把它编码成 SVG。 零第三方脚本在本源执行,而且二维码全对比度渲染、不依赖外链。

    写 QR 编码器时踩到的两个坑(都由测试抓到):

    • 格式信息必须从最高位开始写到那串格子里。写反了符号看起来完全正常 (定位符、数据区、纠错码全对),但任何扫描器都读不出来。
    • 第二份格式信息是 7 格 + 8 格,且必须跳过暗模块 (size-8, 8)。 占用暗模块会把后 7 位格式信息写坏。 两个坑都是靠「和第三方编码器逐格对比」定位的;tests/qr.test.mjs 把这两个不变式固化成了断言。
  5. 测试参数的陷阱(我自己踩的)。 写登录状态机测试时,我把轮询间隔设成 5 秒、超时设成 4 秒, 于是循环第一轮就到期退出,表现为「提交验证码后不再轮询」。 看起来像产品 bug,实际是测试参数自相矛盾。 现在 makeTransport 用 20ms 间隔,并在注释里写明了原因。

  6. 扫码成功后必须立刻启动接收循环。 index.js 只在启动时已有凭据的情况下调 transport.start()。 所以如果只在那边启动,刚扫完码的账号要再重启一次才开始收消息—— 也就是「扫完了但发消息没反应」。 修法:wechat-web 的登录流程在 result.connected 时也调一次 start()(幂等)。 回归保护在 tests/web.test.mjs 的「a successful scan starts the receive loop」, 它同时断言登录失败时不会启动(避免无凭据空转)。

    这条曾经是真实的缺口:我写这部分时只想到「配好凭据后重启」, 没想到「扫码就是产生凭据的那一刻」。它是靠通读调用链发现的,不是靠测试。

  7. 文案不要拿协议状态当显示条件——它会把人引向错误的排查方向。 二维码卡片的标题原本写成:

    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 这种写法再次出现。

  8. 「加入白名单」按钮依赖被忽略的那条流水记录。 面板要从 entry.group 判断该把 ID 加进 allowFrom 还是 allowGroups, 而它操作的对象是被忽略的记录(还没被授权的那个群)。 我最初只在「已接受」的记录上写了 group 标记,导致: 群里点「加入白名单」会把群 ID 加进私聊白名单,群永远无法从面板授权。 修法:record(entry, group) 统一兜住,所有记录都带标记。 回归保护在 tests/bridge.test.mjs 的 case 17,它回放面板的判断逻辑并断言归类正确。

  9. conversation(会话归属)和 deliveryTarget(回复发往谁)是两个不同的东西。 群聊里出错时,我的失败提示原先发给 conversation(群 ID)——又踩回了「群内回复」这条未验证的路。 凡是发消息的地方都必须用 deliveryTarget。回归保护在 case 18。

  10. 状态目录的位置取决于启动方式——别假设只有一个。 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() 在主目录为空时回退采用另一个, 并把后续写入落到采用的那个文件上。

  11. 测试绝不能读真实状态。 加上状态目录回退之后,Cordis 集成测试里的 index.js 读到了你真实的账号并开始轮询真实网关, 表现为「the transport starts signed out」断言失败——测试在改动它本该只观察的状态。 修法:测试把 DSH_HOME 与 DSH_PROFILE_DIR 都指向临时目录。 教训:任何会读磁盘或联网的插件测试,都必须先把环境隔离,否则测试会污染运维现场。

  12. 同一个 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 里」这条教训, 却没有机械地执行它。现在的源码审查就是为了让「靠记性」不再成为必要条件。

  13. 浏览器侧的路由必须是「文档相对」,不能是「原点绝对」。 Harness 的 index 带 <base href="./">,而且前端路由的既定约定就是 document-relative (见 .agents/notes/implemented/architecture/2026-09-14-web-document-relative-app-routes.md: 「a document-relative fetch('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/                   七套测试