Bundle
dsh-wecom-plugin
Minimal feasibility demo: bridge WeCom (企业微信智能机器人 aibot WebSocket) to DeepSeek Harness (DSH) — no public endpoint needed.
- Source
- zhengmz
- License
- MIT
- Updated
- Updated yesterday
Readme
# dsh-wecom-plugin
把 **企业微信(智能机器人 aibot WebSocket)** 与 **DeepSeek Harness(DSH)** 双向连通的
最小可行性插件(demo)。无公网入口,长连接常驻,每个企微会话对应一个持久的 DSH agent 会话。
> 状态:**可行性验证 demo**。企微 wire 协议已按官方 `@wecom/aibot-node-sdk` 对齐(流式回复、
> 回执/心跳 ack、事件回调、846608 降级),协议 + DSH 会话打通已在本仓库自动化测试验证;
> 接入真实企业微信只需按下文创建智能机器人并填入凭据。
>
## 架构
```
企业微信 App ──WS──> openws.work.weixin.qq.com ──WS──> dsh-wecom-plugin 插件(本机 dsh web 内)
aibot_subscribe / aibot_msg_callback / aibot_event_callback
aibot_respond_msg(stream) / aibot_send_msg
│
┌────────────┴────────────┐
│ Bridge(每个 chat 一个 agent)│
│ inbound → followup() │
│ outbound ← session/event │
└────────────┬────────────┘
│
DSH agent(LLM)
```
- `src/aibot-client.js` — 企微智能机器人 WebSocket 客户端(官方协议对齐:
流式回复 thinking→finish、回执等待、心跳 ack 健康检查、认证失败/网络断线分离重试、
disconnected_event 防互踢、enter_check_update 版本应答、引用消息兜底)
- `src/bridge.js` — 会话路由:一条企微消息 → 一个 DSH agent 会话,回复采集+分块+流式回推
- `src/index.js` — Cordis 插件入口(`apply(ctx)` + Config schema)
- `test/mock-wecom.js` — 本地模拟企微网关(仅测试用,无 cmd 的 ack 帧,与真实网关一致)
## 验证结果
| 测试 | 内容 | 结果 |
|---|---|---|
| `npm test`(test/smoke.mjs) | 协议(订阅/流式回复/quote/鉴权拒绝/事件回调/防互踢/踢后重连)+ 桥(建会话/多轮/dedup/白名单/审批应答/发图工具/846608 降级/分块) | ✅ 42 项全过(含媒体) |
| `node test/live-demo.mjs` | 真机隔离 DSH 实例 + 插件 + mock 企微 + 真 LLM | ✅ 端到端通过 |
## 版本兼容
| DSH 版本 | 状态 |
|---|---|
| `0.1.2-rc.1` | ✅ 声明基准版本(peerDependencies 对齐);`DEPLOYMENT_PERSONA` 精确匹配 |
| `0.1.5-rc.2` | ✅ 兼容运行;隔离真机端到端验证通过(含真实 LLM,2026-09),靠 `?? 0` 兜底 |
- **peerDependencies**:`@deepseek-ai/dsh-agent` / `dsh-llm` / `dsh-session` 声明为 `^0.1.2-rc.1`
(与插件代码精确匹配的版本对齐)。在 `0.1.5-rc.2` 上运行时,由于 `-rc.N` 预发布的严格 semver
元组规则,该范围不会"严格匹配" `0.1.5-rc.2`;但 peer 均为 optional、由 DSH 模块回退提供,运行
不受影响(实测端到端通过)。
- **`systemPrompt` 章节次序**:`0.1.2-rc.1` 用键 `DEPLOYMENT_PERSONA`(插件精确匹配);`0.1.5-rc.2`
起改名 `DEPLOYMENT_PERSONA_PREFIX` / `DEPLOYMENT_PERSONA_SUFFIX`,插件靠
`getSectionOrder('DEPLOYMENT_PERSONA') ?? 0` 兜底得到 order 0(语义等价)。**若正式升级到
`0.1.5+`,建议把代码改成精确使用 `DEPLOYMENT_PERSONA_PREFIX`(不再依赖兜底),并同步更新 peer 范围。**
- **安装**:生产用 `file:`,开发用裸路径(见「接入真实企业微信」)。
## 快速验证(无需企微凭据、无需 LLM)
```sh
node test/smoke.mjs
```
## 真机端到端(需要本机 DSH 和一个可用的 LLM 提供方)
`test/live-demo.mjs` 会在一个**隔离的 DSH 环境**里加载插件并连接本地 mock 企微网关,
验证「企微消息 → 插件 → 真实 agent → 真实 LLM → 回复」的完整链路,不影响你正在运行的 profile。
```sh
# 1. 确保本机 DSH 已配置一个可用的 LLM 提供方(即你的 DSH 默认模型),
# 并导出该提供方所需的环境变量(你的 settings 里 provider 对应的 key):
export <你的LLM_API_KEY环境变量名>=... # 例:DEEPSEEK_API_KEY=sk-...
# 2. 运行端到端(隔离环境位置可用 DSH_DEMO_HOME 覆盖):
node test/live-demo.mjs
```
## 接入真实企业微信
1. 打开 [企业微信管理后台](https://work.weixin.qq.com) → 应用管理 → 创建**智能机器人**
(或复用已有机器人),拿到 `bot_id` 和 `secret`。
2. 把插件装进你的 dsh web profile:
```sh
# 生产 / 正式部署:用 file: 前缀,把插件拷贝进 profile(真实路径在 profile 树内,模块回退可达)
dsh plugin --profile web add "file:<本插件仓库路径>"
# 开发 / 迭代:直接裸路径,link: 到源码 checkout,改代码后重启 dsh web 即生效
dsh plugin --profile web add <本插件仓库路径>
```
> ⚠️ 为什么生产必须 `file:`、开发裸路径有条件:裸路径会被 pnpm 做成 `link:` 符号链接,插件真实路径
> 落在 DSH home 之外,Node ESM 从源码路径向上解析 `@deepseek-ai/*` 时够不到 DSH 的模块回退
> (`$DSH_HOME/profiles/node_modules` 只对 profile 目录内的真实路径可见),会报
> `ERR_MODULE_NOT_FOUND`(如 `Cannot find package '@deepseek-ai/schemastery'`)直接崩溃。
> - **生产**:`file:` 让 pnpm 把包放进 profile 内 `.pnpm` 虚拟仓库(自包含、任何机器都能起);
> 已发布到 registry 时直接 `dsh plugin --profile web add <包名>` 即可。
> - **开发**:裸路径要想能跑,仓库根目录的 `node_modules` 必须已链到 DSH 安装
> (含 `@deepseek-ai/schemastery` 等,见本仓库 `.gitignore` 注释),否则同样报错。
或在 `~/.dsh/profiles/web/cordis.patch.yml` 里插入(插件需先按上面装好):
```yaml
- insert:
- id: dsh-wecom-plugin
name: 'dsh-wecom-plugin'
config:
botId: '你的-bot-id'
secret: '你的-secret'
allowedUserIds: [] # 企微 userid 白名单;空 = 所有人
agent:
preset: '' # 留空用 profile 默认
cwd: /你的/工作目录
```
3. 重启 dsh web,把插件行 `disabled: false`(或在 profile patch 里启用)。
4. 在企微里给机器人发消息即可对话;支持 `/help`、`/reset`、`/status`。
## 配置项
| 键 | 含义 | 默认 |
|---|---|---|
| `botId` / `secret` | 企微智能机器人凭据 | `''` |
| `websocketUrl` | 网关地址(本地 mock 测试时改) | `wss://openws.work.weixin.qq.com` |
| `allowedUserIds` | 允许对话的企微 userid,空=放行所有 | `[]` |
| `workspaces` | 工作区别名表(`/cd <名字>` 用),如 `{ web: /path/a, api: /path/b }` | `{}` |
| `stateFile` | 会话路由状态文件(chatId→sessionId,热重载不丢路由) | `$DSH_HOME/dsh-wecom-plugin-state.json` |
| `logFile` | 插件诊断日志文件(DSH journal 不承载插件日志,连接/发送失败都写这里) | `$DSH_HOME/dsh-wecom-plugin.log` |
| `reconnectAfterKick` | 被网关踢下线后**自动重连**(见"连接韧性");连续被踢 3 次才放弃 | `true` |
| `kickReconnectDelayMs` | 被踢后的重连等待毫秒数 | `10000` |
| `sendRetryMs` | 最终回复发送在连接掉线时的**有界重试**时长(自动重连通常 10s 内完成,此值需更大) | `30000` |
| `syncUserPrefix` | GUI 消息同步到企微时的标注前缀(协议只能以机器人身份发送,标签标明是你发的;空=不标注) | `📱 你在 Web GUI 发送:` |
| `approval.enabled` | **企微内授权**总开关:DSH 权限请求(如沙箱升级)转发到企微会话里,聊天内批准/拒绝,无需回 Web 后端 | `true` |
| `approval.timeoutMs` | 授权提示等待回复的毫秒数,超时按拒绝处理(fail-closed) | `60000` |
| `approval.approveWords` | 视为"同意"的回复关键词(需带一次性授权码才生效) | `['同意','批准','允许','yes','approve','allow']` |
| `approval.rejectWords` | 视为"拒绝"的回复关键词(**优先匹配**,避免"不同意"误命中"同意") | `['拒绝','不同意','不允许','不批准','no','reject','deny']` |
| `pluginVersion` | `enter_check_update` 版本探针应答 | `0.1.0` |
| `subscribeExtra` | 订阅帧额外字段(如 `scene`/`plug_version`),原样合并进 `aibot_subscribe` body | `{}` |
| `welcomeText` | `enter_chat` 事件欢迎语,空=不发 | `''` |
| `agent.preset` | agent preset | `''`(profile 默认) |
| `agent.cwd` | **默认**工作目录(可用 `/cd` 切换) | dsh 进程 cwd |
| `agent.provider`/`model` | 覆盖模型路由,空=部署默认 | `''` |
| `agent.reasoningEffort` | 企微 agent 推理等级;空=跟随部署默认(settings 的 `agent-default-model.reasoningEffort`,与 GUI 一致) | `''` |
| `agent.conciseOutput` | 企微 agent 注册"只输出结论" system-prompt section | `true` |
| `agent.cdAllowPaths` | 允许 `/cd <裸路径>`;默认 `false` 只允许配置的 `workspaces` 别名(防 IM 用户把 agent 指向任意目录) | `false` |
| `agent.sessionScope` | 会话隔离:`chat`(每群一个,成员共享)/ `chat-user` 或 `per-channel-peer`(OpenClaw `session.dmScope` 同义,每群每成员一个,推荐共享用)/ `user`(每成员一个跨群) | `chat` |
| `agent.groupMention` | 群聊提及门控:`none`(每条都回)/ `at`(仅 @机器人 时回)/ `at-or-quote`(@ 或 引用/回复消息时回);斜杠命令始终放行 | `none` |
| `agent.botName` | 机器人显示名,用于 @提及 检测(如 `你的机器人名`) | `''` |
| `agent.maxMessageLength` | 单条回复上限,超出分块 | `4000` |
| `agent.idleTimeoutMs` | 会话闲置回收,0=不回收 | `30min` |
## 多用户 / 群共享
把助手分享给群和其他人时:
- **会话隔离**:设 `agent.sessionScope: 'chat-user'`(等价 OpenClaw `session.dmScope: "per-channel-peer"`),每个(群,成员)独立会话与上下文,群里不同成员互不串扰、互不可见对方的对话历史。
- **群聊 @提及 门控**:设 `agent.groupMention: 'at'` 或 `'at-or-quote'` + `agent.botName`(机器人显示名),群里只有 **@机器人**(或引用/回复消息)才触发回复,其余消息忽略,避免刷屏。检测基于文本 `@机器人名`,提及标记会在送给 agent 前剥离。斜杠命令(`/help` 等)不受门控,始终响应。语义对齐 OpenClaw 的 requireMention(@提及 / 引用机器人 / 控制命令放行)。
- **白名单**:分享时 `allowedUserIds` 通常留空(放行所有人)——**这意味着任何能给机器人发消息的人都能驱动你本机 agent**(跑代码、读文件)。务必想清楚风险:只分享给可信的群,或考虑给 `agent.cwd` 指向受限工作区。
- **切换 `sessionScope` 会改变会话 id**:旧会话保留为历史记录,新消息会创建新会话(可用 GUI 归档旧的)。
## 企微内授权(approval)
DSH 的权限机制(如 sandbox 升级:`sandbox_permissions` + `justification`)默认只在 Web GUI 里弹
审批框;只通过企微使用时**没有应答者会 fail-closed**。本插件实现了**企微内授权**:DSH 为
企微专属 agent 发起的权限请求,会以机器人身份推送到对应会话,你在聊天里直接批准/拒绝即可,
完全不需要回 Web 后端。
工作方式(与 DSH 官方 ACP 应答器同一机制,注册为 `approval/request` waterfall 参与方):
1. 企微 agent 执行需要授权的操作时,DSH 触发审批请求;
2. 插件向该会话推一条授权提示,带**一次性授权码**(如 `同意 3F9A`):
```
🔐 需要你的授权(60 秒内有效)
操作:bash
说明:escalate sandbox to danger-full-access: 需要写 /etc 下的配置
回复「同意 3F9A」允许这一次;
回复「拒绝 3F9A」拒绝。
```
3. 在企微里回复「同意 3F9A」→ 本次操作放行(outcome `allowed-once`);回复「拒绝 3F9A」→
终止(`rejected`);
4. 超时未回复 → fail-closed 按拒绝处理(`unavailable`);请求被取消 → `cancelled`。
设计要点:
- **一次性授权码**:每条请求带随机码(4 位大写十六进制),必须连同关键词一起回复才生效,
防止群聊里随手一句"同意"误批准高危操作。
- **拒绝词优先匹配**:`rejectWords` 在 `approveWords` 之前检查,`不同意` 不会被当成 `同意`。
- **只接管企微会话**:仅应答 `sessionChat` 里有路由(即插件创建的 `wecom-*` 会话)的请求;
GUI 会话的审批仍由 Web GUI 应答(本插件 `next()` 让路)。
- **白名单仍生效**:`allowedUserIds` 之外的用户发来的审批回复不会被消费(在 `isAllowed`
之后才进入审批分支)。
配置示例(全部可选,默认即开启):
```yaml
config:
approval:
enabled: true # 总开关
timeoutMs: 60000 # 60 秒内有效
approveWords: ['同意', '批准', '允许', 'yes', 'approve', 'allow']
rejectWords: ['拒绝', '不同意', '不允许', '不批准', 'no', 'reject', 'deny']
```
> 安全提示:授权会放行真实的高危操作(如沙箱升级到 `danger-full-access`)。在**共享群**里使用
> 本功能时,任何能回复的成员都能批准——建议配合 `allowedUserIds` 白名单使用,或仅在私聊/可信群
> 中开启。
## 企微发图与连接韧性(重要)
**为什么要 `wecom_send_image` 工具**:agent 截屏后如果自己写脚本、开第二条 aibot 连接去发图,
网关会把插件的**常驻连接踢下线**(新连接接管同一 botId),之后插件所有发送都静默失败——
表现就是你看到的"图片收到了、文字没收到"。这是实测踩过的坑。
修复后,插件注册了原生工具 **`wecom_send_image`**:
- agent 只需调用 `wecom_send_image(path)`(本地图片文件路径),插件用**自己的常驻连接**
上传并发图,绝不产生第二条连接;
- 工具按调用者 agent 路由到对应企微会话;只接受 PNG/JPEG/WebP/GIF 魔数;非企微会话拒绝;
- 截图流程现在应是:bash 截屏存工作区 → `wecom_send_image` 发图 → 文字结论照常走 stream 回复。
- 每个企微专属 agent 还会注入一条**系统提示纪律**(`wecom-image-send-discipline` section):
明确禁止自写 aibot 脚本/开第二条连接,强制要求发图走 `wecom_send_image`——因为模型有
"写脚本也能发出图"的历史习惯,仅注册工具不够,必须从提示源头约束。
**连接韧性**(三层防御,全部默认开启):
1. **被踢自动重连**(`reconnectAfterKick: true`):网关踢掉常驻连接后,等待
`kickReconnectDelayMs`(默认 10s)自动重连,不再永久离线;连续被踢 3 次(说明有真正
的第二个实例在抢)才放弃,需重启服务恢复。
2. **文字发送兜底 + 有界重试**:最终回复的 stream 发送**任何失败**都会降级为主动 markdown
发送(`aibot_send_msg`);若连接掉线则每 3s 重试,最长 `sendRetryMs`(默认 30s),等
自动重连落地后把文字补发出去,不再静默丢失。
3. **诊断日志文件**:`$DSH_HOME/dsh-wecom-plugin.log`(可用 `logFile` 覆盖)记录连接生命周期、
发送成败、工具调用与可见性,DSH journal 里看不到插件日志时的排障入口。
> 排查"企微只收到图/只收到字"这类问题,先看 `~/.dsh/dsh-wecom-plugin.log`:如果出现
> `kicked by server` 或 `final stream reply failed`,原因和上面的机制一一对应。
## 多工作区(不同项目目录)
`agent.cwd` 只是**默认**工作目录。每个企微会话可以随时用 `/cd` 切换工作目录:
```
/cd web # 切到配置别名 workspaces.web 对应的目录
/cd /path/to/other # 切到任意绝对路径(也支持 ~ 和相对路径)
/cd # 查看当前工作目录
/status # 状态里也会显示当前工作目录
```
配置示例:
```yaml
config:
agent:
cwd: /home/you/project-a # 默认工作区
workspaces:
web: /home/you/project-b # /cd web → 切到 project-b
api: /home/you/project-c # /cd api → 切到 project-c
```
切换后,该会话的 agent 会用新目录作为工作区(bash 默认 cwd、相对路径、文件工具范围等),
并**清空上下文重新开始**(相当于先 `/reset` 再换目录)。切换前后是两个独立的 DSH 会话,
各自持久化,可随时切回。
## 会话稳定性与 Web GUI 双向同步
- **会话稳定**:每个企微会话的 DSH session id 由 `chatId + cwd + reset 纪元` 确定性派生,插件
热重载/重启后同一对话继续使用**同一个** session(自动 `agents.resume`),不会重复创建;路由表
持久化在 `stateFile`。
- **`/reset` 真正清空上下文**:发送 `/reset` 会递增该会话的 reset 纪元,下一条消息生成**全新的
空会话**(新 session id),旧会话保留在 GUI 作为历史。适用于想让 agent"忘掉旧习惯"的场景
(例如它学会了某个不想要的工具调用模式)。
- **企微 → GUI**:企微里发的消息以 `kind:'user'` 中继进 `wecom-*` 会话,在 Web GUI 里显示为
**你自己的用户气泡**(而非灰色上下文注记),可点开查看完整轨迹。
- **GUI → 企微**:在 Web GUI 里打开某个 `wecom-*` 会话继续对话,你发送的消息和 agent 的回复会
自动镜像回企微。由于 aibot 协议只能以机器人身份发送,你发的那条会带 `syncUserPrefix` 标注
(默认 `📱 你在 Web GUI 发送:`),回复则正常以机器人身份出现。
- **只同步结论**:两条原则,不做输出解析:
- **结构纪律**:只取每轮**最后一条 assistant 消息**的 `text` 块(DSH 原生区分
`reasoning`=思考 / `text`=回答,中间步骤与思考块天然被排除)。
- **源头约束**:插件为企微专属 agent 注册一个 scoped system-prompt section
(`agent.conciseOutput`,默认开启),要求模型把分析放在 `reasoning`、可见文本只写结论。
该 section 只作用于企微 agent,不影响 GUI 普通会话。
- 桥自己转发的消息(`source.plugin: 'dsh-wecom-plugin'`)不会被再次回显,避免回环。
## 安全注意
- `allowedUserIds` 默认空 = 放行所有企微用户(含群聊成员)——任何能给机器人发消息的人都能驱动
你本机 agent,生产必须配白名单。官方插件还有独立的群组策略(groupPolicy)与私聊策略
(dmPolicy/pairing),本 demo 未实现,群聊受同一 `allowedUserIds` 约束。
- 插件在 DSH 进程内运行,拥有 dsh 的权限;不要给不信任的会话开全权限 preset。
- 媒体下载仅接受来自企微网关的签名 URL(5 分钟有效),不解析用户提供的任意 URL。
## 媒体支持
- **入站图片(视觉)**:用户发图 → 下载 + AES-256-CBC 解密 → 经 DSH attachment 服务准入 →
agent 以 `image` 内容块看到图片(模型需支持 image 输入)。
- **入站文件/视频**:下载解密后保存到工作区 `.dsh-wecom-media/`,并把路径以附件说明交给 agent 读取。
- **语音输入**:企微自动转写,`voice.content` 文本直接进入对话(无需额外处理)。
- **出站媒体**:agent 输出的图片(assistant 消息中的 `image` 块)→ 三步分片上传
(`aibot_upload_media_init/chunk/finish`)→ `aibot_send_msg` 发回企微。
- 大小限制:图片/视频 10MB、语音 2MB、文件 20MB(超出拒绝)。
## 部署与迭代注意
- **配置变更热更新**:改 `cordis.patch.yml`(如 botId/secret/白名单)会自动热重载,无需重启。
- **源码变更必须重启**:DSH 的 loader 只在插件行 `name`/`inject`/`group` 变化时才重新 import
模块;修改 `src/*.js` 后仅靠热重载不会生效,需**重启 dsh web**。这是 DSH 的设计(配置热更新、
代码需重启),迭代插件时务必记住。
- `agent.reasoningEffort` 建议显式设置(如 `max`),避免依赖上游默认导致行为不一致。
## 参考
- 官方 DSH 插件开发文档:`docs/user/develop/basic`、`docs/cookbook/extension-cookbook.md`
- 企微智能机器人官方 SDK/文档:https://open.work.weixin.qq.com
Install
dsh plugin --profile web add github:zhengmz/dsh-wecom-plugin
Profile: web
With the hub plugin installed, ask your agent to install it by name — it resolves the same plan shown here.
dsh plugin --profile web add github:stvlynn/dsh.fish#path:packages/dsh-plugin-hub
install dsh-wecom-plugin from the hub
- This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.