Skip to content
dsh.fish
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

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source