@dingxiang-me/openclaw-wechat 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,28 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [2.1.0] - 2026-03-14
8
+
9
+ ### Added
10
+ - 新增 `dm.mode=pairing`,首次私聊可接入 OpenClaw 原生配对审批流,不再只能依赖静态 `allowFrom`
11
+ - 新增 WeCom 渠道 pairing metadata,支持 `quickstartAllowFrom`、`wecomUserId` 归一化以及审批通过后的默认通知回执
12
+ - `wecom:selfcheck`、`wecom:agent:selfcheck`、`wecom:bot:selfcheck` 新增接入摘要,直接输出 `readiness` 与 `routing`
13
+
14
+ ### Changed
15
+ - WeCom 渠道 metadata 补齐 `detailLabel`、`systemImage` 与 quickstart 提示,接入路径更贴近 OpenClaw 原生渠道
16
+ - `/status` 输出优先展示“收消息 / 回消息 / 主动发送 / 媒体 / 语音 / 文档”准备情况,并显式展示 `bindings` / `dynamicAgent` 路由来源
17
+ - WeCom Bot 长连接正常连接日志默认降到 `debug`,减少 `connect/opened/subscribed` 噪音
18
+ - README(中英文)改成 `2.1.0` 接入口径,补充 `pairing`、selfcheck 摘要和推荐接入顺序
19
+
20
+ ## [2.0.1] - 2026-03-14
21
+
22
+ ### Fixed
23
+ - 修复 WeCom Agent 回复 `/workspace/...` 文件路径时因动态会话工作区缺少目标文件,最终只回显路径文本而不实际发送图片/文件的问题
24
+ - 修复 WeCom Bot 长连接对附件回调 payload 形态兼容不足,导致 PDF/文件消息被当成普通文本继续处理的问题;补充 `msg_type`、嵌套 `message`、`attachments/items`、`file_url/download_url/file_name/aes_key` 等兼容解析
25
+ - 修复 WeCom Bot 入站文件/图片处理未完整透传媒体级 AES Key 的问题,降低 PDF/图片解密失败和内容损坏风险
26
+ - 修复本地 `whisper-cli` 转写时临时音频文件在子进程真正读取前被提前清理,导致语音识别报错 `input file not found`
27
+ - 补充 WeCom Bot 长连接未解析 callback 的运行日志,便于继续定位企业微信新回调形态
28
+
7
29
  ## [2.0.0] - 2026-03-13
8
30
 
9
31
  ### Changed
package/README.en.md CHANGED
@@ -10,7 +10,7 @@ OpenClaw-Wechat is an OpenClaw channel plugin for Enterprise WeChat (WeCom), wit
10
10
 
11
11
  ## Table of Contents
12
12
 
13
- - [Major Update (v2.0.0)](#major-update-v200)
13
+ - [Onboarding Update (v2.1.0)](#onboarding-update-v210)
14
14
  - [Highlights](#highlights)
15
15
  - [Mode Comparison](#mode-comparison)
16
16
  - [5-Minute Quick Start](#5-minute-quick-start)
@@ -28,41 +28,27 @@ OpenClaw-Wechat is an OpenClaw channel plugin for Enterprise WeChat (WeCom), wit
28
28
  - [Development](#development)
29
29
  - [FAQ](#faq)
30
30
 
31
- ## Major Update (v2.0.0)
31
+ ## Onboarding Update (v2.1.0)
32
32
 
33
- This is a real capability update, not a minor patch.
34
- `OpenClaw-Wechat` now has **working WeCom Bot long-connection support** in real gateway runtime.
33
+ This release focuses on setup quality, not feature sprawl. The goal is to make `OpenClaw-Wechat` behave more like a native OpenClaw channel during install and first-run.
35
34
 
36
- ### WeCom Bot long connection
35
+ ### What changed
37
36
 
38
37
  | Item | Result |
39
38
  |---|---|
40
- | Official endpoint | `wss://openws.work.weixin.qq.com` |
41
- | Inbound commands | `aibot_msg_callback` / `aibot_event_callback` |
42
- | Outbound command | `aibot_respond_msg` |
43
- | Runtime | switched to `ws`, no longer blocked by built-in Node WebSocket `1006` failures |
44
- | Public Bot callback required | no, not in long-connection mode |
45
- | Verification command | `npm run wecom:bot:longconn:probe -- --json` |
46
-
47
- ### Control UI and operations
48
-
49
- This release also keeps the earlier operations improvements: **WeCom supports visual configuration in Control UI**, so you no longer need to rely on manual JSON edits only.
50
-
51
- ### Visual config in Control UI
52
-
53
- | Item | Status | Notes |
54
- |---|---|---|
55
- | WeCom config form | ✅ | `channels.wecom` can be edited/saved directly from UI |
56
- | Localized labels | ✅ | Common fields are now clearly labeled in Chinese for ops teams |
57
- | Sensitive-field hints | ✅ | secret/token/aesKey fields are marked as sensitive |
58
- | Runtime status clarity | ✅ | `Connected` is no longer stuck at `n/a`; default account display name is localized |
59
- | Inbound activity tracking | ✅ | `Last inbound` is updated after webhook callbacks (`n/a` before first inbound after restart is expected) |
39
+ | DM pairing approval | Added `dm.mode=pairing`, backed by OpenClaw native pairing flow |
40
+ | Quickstart metadata | Added `detailLabel`, `systemImage`, and `quickstartAllowFrom` |
41
+ | `/status` readability | Status now leads with `receive / reply / send / media / voice / doc` readiness |
42
+ | Selfcheck summaries | `wecom:selfcheck`, `wecom:agent:selfcheck`, and `wecom:bot:selfcheck` now print `readiness` and `routing` summaries |
43
+ | Route visibility | Status and selfcheck now show whether routing comes from `bindings` or `dynamicAgent` |
44
+ | Long-connection log noise | Normal connect / opened / subscribed logs moved to `debug` |
60
45
 
61
46
  ### Practical impact
62
47
 
63
- - Configure WeCom directly in `Channels -> WeCom` without hand-editing large config files.
64
- - Better readability for day-to-day ops and handover.
65
- - More accurate runtime status display reduces false alarm on “plugin not working”.
48
+ - New users can start from the smallest working config, then add multi-account or dynamic routing later.
49
+ - Teams that want controlled DM access can now use `pairing` instead of maintaining `allowFrom` only.
50
+ - `/status` and selfcheck answer “can it receive, reply, and send?” before lower-level transport details.
51
+ - Bot long-connection is quieter by default while still keeping useful warnings and errors.
66
52
 
67
53
  ## Highlights
68
54
 
@@ -75,7 +61,7 @@ This release also keeps the earlier operations improvements: **WeCom supports vi
75
61
  | Bot card replies | ✅ | `markdown/template_card` with automatic text fallback |
76
62
  | Multi-account support | ✅ | `channels.wecom.accounts.<id>` |
77
63
  | Sender allowlist and admin bypass | ✅ | `allowFrom` + `adminUsers` |
78
- | Direct-message policy | ✅ | `dm.mode=open/allowlist/deny` + account overrides |
64
+ | Direct-message policy | ✅ | `dm.mode=open/allowlist/pairing/deny` + account overrides |
79
65
  | Event welcome reply (`enter_agent`) | ✅ | configurable via `events.enterAgentWelcome*` |
80
66
  | Command allowlist | ✅ | `/help`, `/status`, `/clear`, `/new`, etc. |
81
67
  | Group trigger policy | ✅ | mention-required or direct-trigger |
@@ -103,7 +89,7 @@ This release also keeps the earlier operations improvements: **WeCom supports vi
103
89
  openclaw plugins install @dingxiang-me/openclaw-wechat
104
90
  ```
105
91
 
106
- Recommended minimum package version: `2.0.0`. If `plugins.installs.openclaw-wechat` in `openclaw.json` still reports `1.7.x`, upgrade or reinstall first; those older npm packages do not expose the current WeCom channel metadata.
92
+ Recommended minimum package version: `2.1.0`. If `plugins.installs.openclaw-wechat` in `openclaw.json` still reports `1.7.x`, upgrade or reinstall first; those older npm packages do not expose the current WeCom channel metadata.
107
93
 
108
94
  For local development or direct source-path loading, use:
109
95
 
@@ -159,6 +145,20 @@ If you are loading from source path instead, use the version below with `load.pa
159
145
 
160
146
  > Agent-mode note (important): configure **Trusted IP** in the WeCom self-built app settings and include the real egress IP of your OpenClaw gateway. Otherwise you may see "messages received but no reply".
161
147
 
148
+ If you want first-contact DM approval, add:
149
+
150
+ ```json
151
+ {
152
+ "channels": {
153
+ "wecom": {
154
+ "dm": {
155
+ "mode": "pairing"
156
+ }
157
+ }
158
+ }
159
+ }
160
+ ```
161
+
162
162
  ### 4) Restart and verify
163
163
 
164
164
  ```bash
@@ -169,6 +169,11 @@ npm run wecom:agent:selfcheck -- --all-accounts
169
169
  npm run wecom:bot:selfcheck -- --all-accounts
170
170
  ```
171
171
 
172
+ Selfcheck now starts with two summary lines:
173
+
174
+ - `readiness`: whether receive / reply / send are currently usable
175
+ - `routing`: whether `bindings` and `dynamicAgent` are active
176
+
172
177
  ## Requirements
173
178
 
174
179
  | Item | Description |
@@ -310,7 +315,7 @@ When multi-account is enabled, each account can override Bot callback credential
310
315
  | Sender ACL | `allowFrom`, `allowFromRejectMessage` |
311
316
  | Command ACL | `commands.enabled`, `commands.allowlist`, `commands.rejectMessage` |
312
317
  | Admin bypass | `adminUsers` |
313
- | Direct-message policy | `dm.mode`, `dm.allowFrom`, `dm.rejectMessage` |
318
+ | Direct-message policy | `dm.mode`, `dm.allowFrom`, `dm.rejectMessage` (`open / allowlist / pairing / deny`) |
314
319
  | Event policy | `events.enabled`, `events.enterAgentWelcomeEnabled`, `events.enterAgentWelcomeText` |
315
320
  | Group trigger | `groupChat.enabled`, `groupChat.triggerMode`, `groupChat.mentionPatterns`, `groupChat.triggerKeywords` |
316
321
  | Dynamic route | `dynamicAgent.*` (compatible with `dynamicAgents.*`, `dm.createAgentOnFirstMessage`) |
@@ -433,7 +438,7 @@ Outbound target formats:
433
438
  |---|---|
434
439
  | `WECOM_ALLOW_FROM*` | sender authorization |
435
440
  | `WECOM_COMMANDS_*` | command ACL |
436
- | `WECOM_DM_*`, `WECOM_<ACCOUNT>_DM_*` | DM policy + allowlist |
441
+ | `WECOM_DM_*`, `WECOM_<ACCOUNT>_DM_*` | DM policy + allowlist / pairing controls |
437
442
  | `WECOM_EVENTS_*`, `WECOM_<ACCOUNT>_EVENTS_*` | event handling + enter_agent welcome text |
438
443
  | `WECOM_GROUP_CHAT_*` | group trigger policy |
439
444
  | `WECOM_DEBOUNCE_*` | text debounce |
package/README.md CHANGED
@@ -12,7 +12,7 @@ OpenClaw-Wechat 是一个面向 OpenClaw 的企业微信渠道插件,支持两
12
12
 
13
13
  ## 目录
14
14
 
15
- - [重大更新(v2.0.0)](#重大更新v200)
15
+ - [接入更新(v2.1.0)](#接入更新v210)
16
16
  - [功能概览](#功能概览)
17
17
  - [模式对比](#模式对比)
18
18
  - [5 分钟极速上手](#5-分钟极速上手)
@@ -33,75 +33,42 @@ OpenClaw-Wechat 是一个面向 OpenClaw 的企业微信渠道插件,支持两
33
33
  - [FAQ](#faq)
34
34
  - [版本与贡献](#版本与贡献)
35
35
 
36
- ## 重大更新(v2.0.0)
36
+ ## 接入更新(v2.1.0)
37
37
 
38
- 这次是一次真正的能力级更新,不是小修补。
39
- `OpenClaw-Wechat` 现在已经把 **企业微信智能机器人长连接** 正式做成可用能力,并且已经在真实网关环境下完成:
38
+ 这版不是继续堆新平台能力,而是把接入体验收口到“更像 OpenClaw 原生渠道”的状态。
40
39
 
41
- - WebSocket 握手成功
42
- - `aibot_subscribe` 鉴权成功
43
- - 心跳 `ping` / 回执成功
44
- - 网关日志出现 `socket opened` 和 `subscribed`
45
-
46
- ### 企业微信 Bot 长连接已正式可用
40
+ ### 这版新增了什么
47
41
 
48
42
  | 项目 | 结果 |
49
43
  |---|---|
50
- | 官方地址 | `wss://openws.work.weixin.qq.com` |
51
- | 入站命令 | `aibot_msg_callback` / `aibot_event_callback` |
52
- | 出站命令 | `aibot_respond_msg` |
53
- | 运行时实现 | 已切换为 `ws`,不再使用会导致 `1006` Node 内置 WebSocket |
54
- | Bot 回调公网依赖 | 长连接模式下不需要 |
55
- | 自检命令 | `npm run wecom:bot:longconn:probe -- --json` |
56
-
57
- 这次版本同时还保留并增强了原有的 **WeCom Doc 工具链**。
58
- 现在 `OpenClaw-Wechat` 已经同时具备:
59
-
60
- - 后台可视化配置
61
- - WeCom Doc 文档/表格/收集表工具
62
- - 文档权限与协作者管理
63
- - 分享链接可用性诊断
64
- - 文档权限打不开问题的直接诊断
65
- - 链接文本输出完整性修复(URL 下划线不再丢失)
66
-
67
- ### 这次版本解决了什么
68
-
69
- | 场景 | 旧问题 | 现在的处理方式 |
70
- |---|---|---|
71
- | 企业微信 Bot 长连接 | 旧版地址、命令字和运行时实现不对,连接阶段直接 `1006` | 已对齐官方地址、协议命令和 `ws` 实现,真实网关已订阅成功 |
72
- | 想验证“到底连上没有” | 只能看零散日志,难以区分握手、鉴权还是心跳失败 | `wecom:bot:longconn:probe` 会直接验证握手、鉴权与心跳 |
73
- | 创建文档后自己打不开 | 文档创建成功,但发起人没被自动授权 | `create` 默认自动把当前企微请求人加入协作者 |
74
- | 分享链接能发出来,但别人打开像“文档不存在” | 无法快速判断是权限、分享码还是 guest 访问问题 | `validate_share_link` 直接诊断 `guest / blankpage / scode / 路径资源 ID` |
75
- | 只知道“更新成员权限成功”,但还是打不开 | 成员权限、查看规则、外部分享是三套概念,容易混淆 | `diagnose_auth` 直接输出企业内/企业外访问、查看成员、协作者与请求人角色 |
76
- | 把分享链接路径当成 `docId` 使用 | 后续 API 调用报 `invalid docid` | `create/share/get_auth` 结果显式返回真实 `docId`,并给出使用提示 |
77
- | 链接里有下划线,发出去后被改坏 | Markdown 清洗误伤 URL | 文本格式化已修复,URL 下划线完整保留 |
78
-
79
- ### 可视化配置能力(Control UI)
80
-
81
- | 项目 | 现状 | 说明 |
82
- |---|---|---|
83
- | WeCom 配置表单 | ✅ | `channels.wecom` 支持在后台页面直接编辑与保存 |
84
- | 中文字段标签 | ✅ | 常用字段(如 `corpId`、`callbackToken`、`accounts.*`)均已中文化 |
85
- | 敏感项标记 | ✅ | `secret/token/aesKey` 字段按敏感项展示 |
86
- | 状态展示 | ✅ | `Connected` 不再长期 `n/a`,默认账号显示名中文化为“默认账号” |
87
- | 入站状态追踪 | ✅ | 收到回调后自动更新 `Last inbound`(重启后首次入站前为 `n/a` 属正常) |
88
- | 文档工具开关 | ✅ | `tools.doc` 等文档相关配置可被 schema 正确识别 |
89
-
90
- ### 文档工具升级摘要
91
-
92
- | 能力层 | 新增内容 |
93
- |---|---|
94
- | 文档基础 | `create`、`rename`、`get_info`、`share`、`delete` |
95
- | 权限管理 | `grant_access`、`add_collaborators`、`set_join_rule`、`set_member_auth`、`set_safety_setting` |
96
- | 运维诊断 | `get_auth`、`diagnose_auth`、`validate_share_link` |
97
- | 表格/收集表 | `get_sheet_properties`、`create_collect`、`modify_collect`、`get_form_info`、`get_form_answer`、`get_form_statistic` |
98
-
99
- ### 你升级后能直接获得的变化
100
-
101
- - 在 OpenClaw 后台的 `Channels -> WeCom` 页面可直接配置并保存核心参数。
102
- - 在同一个插件里直接调用 `wecom_doc`,不需要额外安装第二个 WeCom Doc 插件。
103
- - 现在排查“为什么这个链接打不开”不需要再手翻日志和原始 JSON,工具会直接给出结论。
104
- - 文档创建、协作者授权、分享链接诊断已经形成闭环,适合直接上线给真实企微用户使用。
44
+ | 私聊配对审批 | 新增 `dm.mode=pairing`,首次私聊会生成 OpenClaw 原生配对审批 |
45
+ | Quickstart 元信息 | 渠道 metadata 补齐 `detailLabel`、`systemImage`、`quickstartAllowFrom` |
46
+ | `/status` 可读性 | 优先展示 `收消息 / 回消息 / 主动发送 / 媒体 / 语音 / 文档` 准备情况 |
47
+ | Selfcheck 摘要 | `wecom:selfcheck` / `wecom:agent:selfcheck` / `wecom:bot:selfcheck` 新增 `readiness` `routing` 摘要 |
48
+ | 路由可见性 | 明确显示当前是否命中 `bindings` 或 `dynamicAgent` |
49
+ | 长连接日志降噪 | 正常的 connect / opened / subscribed 改为 `debug`,减少默认噪音 |
50
+
51
+ ### 这版对接入有什么实际影响
52
+
53
+ - 新用户现在可以按最小配置先跑通,再决定是否启用多账号、动态路由和文档工具。
54
+ - 需要私聊审批时,不再只能靠手写 `allowFrom`,可以直接用 `pairing` 模式接到 OpenClaw 审批流。
55
+ - `/status` selfcheck 先回答“能不能收、能不能回、能不能发”,再展开工程细节。
56
+ - Bot 长连接默认日志更安静,排障时再开 `debug` 看正常心跳和订阅细节。
57
+
58
+ ### 当前推荐的接入顺序
59
+
60
+ 1. 先选一个主入口:
61
+ - Bot 长连接:适合最快跑通对话
62
+ - Agent:适合需要主动发送、菜单、自建应用能力
63
+ 2. 再决定私聊准入:
64
+ - `open`:直接可聊
65
+ - `allowlist`:仅显式白名单
66
+ - `pairing`:首次私聊先审批
67
+ 3. 最后再加增强项:
68
+ - `bindings`
69
+ - `dynamicAgent`
70
+ - `wecom_doc`
71
+ - 本地语音转写
105
72
 
106
73
  ## 5 分钟极速上手
107
74
 
@@ -113,7 +80,7 @@ OpenClaw-Wechat 是一个面向 OpenClaw 的企业微信渠道插件,支持两
113
80
  openclaw plugins install @dingxiang-me/openclaw-wechat
114
81
  ```
115
82
 
116
- > 最低建议版本:`2.0.0`。如果 `openclaw.json` 里的 `plugins.installs.openclaw-wechat` 仍显示 `1.7.x`,请先升级或重装;旧包不会正确注册 `wecom` channel 元数据。
83
+ > 最低建议版本:`2.1.0`。如果 `openclaw.json` 里的 `plugins.installs.openclaw-wechat` 仍显示 `1.7.x`,请先升级或重装;旧包不会正确注册 `wecom` channel 元数据。
117
84
 
118
85
  如果你是在本地开发或要直接跑仓库源码,再用下面这套:
119
86
 
@@ -170,6 +137,20 @@ npm install
170
137
 
171
138
  > Agent 模式补充(重要):在企业微信自建应用后台请配置**可信 IP**,把 OpenClaw 网关实际出网 IP 加入白名单。否则可能出现“能收到消息但不回复”。
172
139
 
140
+ 如果你希望私聊先审批,再开放会话,可以直接加:
141
+
142
+ ```json
143
+ {
144
+ "channels": {
145
+ "wecom": {
146
+ "dm": {
147
+ "mode": "pairing"
148
+ }
149
+ }
150
+ }
151
+ }
152
+ ```
153
+
173
154
  ### Step 4. 重启并自检
174
155
 
175
156
  ```bash
@@ -180,6 +161,11 @@ npm run wecom:agent:selfcheck -- --all-accounts
180
161
  npm run wecom:bot:selfcheck -- --all-accounts
181
162
  ```
182
163
 
164
+ 现在自检输出会先给出两行摘要:
165
+
166
+ - `readiness`: 当前是否能收、能回、能发
167
+ - `routing`: 当前是否启用 `bindings` 和 `dynamicAgent`
168
+
183
169
  ### Step 5. 发一条消息验证
184
170
 
185
171
  | 验证项 | 预期结果 |
@@ -201,7 +187,7 @@ npm run wecom:bot:selfcheck -- --all-accounts
201
187
  | Bot 卡片回包 | ✅ | 支持 `markdown/template_card`,失败自动降级文本 |
202
188
  | 多账户 | ✅ | `channels.wecom.accounts.<id>` |
203
189
  | 发送者授权控制 | ✅ | `allowFrom` + 账户级覆盖 |
204
- | 私聊策略(DM) | ✅ | `dm.mode=open/allowlist/deny` + 账户级覆盖 |
190
+ | 私聊策略(DM) | ✅ | `dm.mode=open/allowlist/pairing/deny` + 账户级覆盖 |
205
191
  | 事件欢迎语(enter_agent) | ✅ | `events.enterAgentWelcome*` 可配置 |
206
192
  | 命令白名单 | ✅ | `/help` `/status` `/clear` `/new` 等 |
207
193
  | 群聊触发策略 | ✅ | 支持 `direct/mention/keyword` 三种模式 |
@@ -662,7 +648,7 @@ node ./scripts/wecom-bot-selfcheck.mjs --help
662
648
  | 拒绝文案 | `allowFromRejectMessage` | 未授权提示 |
663
649
  | 管理员 | `adminUsers` | 绕过命令白名单 |
664
650
  | 命令白名单 | `commands.enabled` + `commands.allowlist` | 限制 `/` 指令 |
665
- | 私聊策略 | `dm.mode` + `dm.allowFrom` + `dm.rejectMessage` | 控制私聊开放/白名单/拒绝 |
651
+ | 私聊策略 | `dm.mode` + `dm.allowFrom` + `dm.rejectMessage` | 控制私聊开放/白名单/配对审批/拒绝 |
666
652
  | 事件策略 | `events.enabled` + `events.enterAgentWelcomeEnabled` + `events.enterAgentWelcomeText` | 控制事件处理与 enter_agent 欢迎语 |
667
653
  | 群聊触发 | `groupChat.enabled` + `triggerMode` + `mentionPatterns` + `triggerKeywords` | 控制群消息触发条件(自建应用支持 `direct/mention/keyword`;Bot 模式按平台限制固定 `mention`) |
668
654
  | 动态路由 | `dynamicAgent.*`(兼容 `dynamicAgents.*`、`dm.createAgentOnFirstMessage`) | 动态 Agent + workspace bootstrap 播种 |
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-wechat",
3
3
  "name": "OpenClaw-Wechat",
4
- "version": "2.0.0",
4
+ "version": "2.1.0",
5
5
  "description": "WeCom (企业微信) channel plugin for OpenClaw (自建应用回调 + 发送 API).",
6
6
  "channels": [
7
7
  "wecom"
@@ -672,10 +672,11 @@
672
672
  "enum": [
673
673
  "open",
674
674
  "allowlist",
675
+ "pairing",
675
676
  "deny"
676
677
  ],
677
678
  "default": "open",
678
- "description": "私聊策略:open=开放,allowlist=白名单,deny=关闭"
679
+ "description": "私聊策略:open=开放,allowlist=白名单,pairing=首次私聊需审批,deny=关闭"
679
680
  },
680
681
  "rejectMessage": {
681
682
  "type": "string",
@@ -688,7 +689,7 @@
688
689
  },
689
690
  "allowFrom": {
690
691
  "type": "array",
691
- "description": "私聊白名单(mode=allowlist 时生效)",
692
+ "description": "私聊白名单(mode=allowlist/pairing 时作为显式放行名单)",
692
693
  "items": {
693
694
  "type": "string",
694
695
  "minLength": 1
@@ -1194,14 +1195,15 @@
1194
1195
  "enum": [
1195
1196
  "open",
1196
1197
  "allowlist",
1198
+ "pairing",
1197
1199
  "deny"
1198
1200
  ],
1199
1201
  "default": "open",
1200
- "description": "私聊策略:open=开放,allowlist=白名单,deny=关闭"
1202
+ "description": "私聊策略:open=开放,allowlist=白名单,pairing=首次私聊需审批,deny=关闭"
1201
1203
  },
1202
1204
  "allowFrom": {
1203
1205
  "type": "array",
1204
- "description": "私聊白名单(mode=allowlist 时生效)",
1206
+ "description": "私聊白名单(mode=allowlist/pairing 时作为显式放行名单)",
1205
1207
  "items": {
1206
1208
  "type": "string",
1207
1209
  "minLength": 1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dingxiang-me/openclaw-wechat",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "description": "WeCom (企业微信) channel plugin for OpenClaw (自建应用回调 + 发送 API).",
6
6
  "license": "MIT",
@@ -24,10 +24,13 @@
24
24
  "id": "wecom",
25
25
  "label": "企业微信 WeCom",
26
26
  "selectionLabel": "企业微信 WeCom(自建应用/Bot)",
27
+ "detailLabel": "企业微信自建应用 / Bot",
27
28
  "docsPath": "/channels/wecom",
28
29
  "docsLabel": "wecom",
29
30
  "blurb": "企业微信消息通道(自建应用回调 + Bot 回调 + 发送 API)。",
30
31
  "order": 80,
32
+ "systemImage": "building.2.crop.circle",
33
+ "quickstartAllowFrom": true,
31
34
  "aliases": [
32
35
  "wework",
33
36
  "qiwei",
package/src/core.js CHANGED
@@ -55,7 +55,7 @@ const BOT_CARD_MODE_SET = new Set(["markdown", "template_card"]);
55
55
  const DELIVERY_FALLBACK_LAYER_SET = new Set(DEFAULT_DELIVERY_FALLBACK_ORDER);
56
56
  const DYNAMIC_AGENT_MAP_SPLITTER = /[,\n]/;
57
57
  const GROUP_CHAT_TRIGGER_MODE_SET = new Set(["direct", "mention", "keyword"]);
58
- const DM_POLICY_MODE_SET = new Set(["open", "allowlist", "deny"]);
58
+ const DM_POLICY_MODE_SET = new Set(["open", "allowlist", "pairing", "deny"]);
59
59
  const DYNAMIC_AGENT_MODE_SET = new Set(["mapping", "deterministic", "hybrid"]);
60
60
  const DYNAMIC_AGENT_ID_STRATEGY_SET = new Set(["readable-hash"]);
61
61
  const LEGACY_INLINE_ACCOUNT_RESERVED_KEYS = new Set([
@@ -582,6 +582,7 @@ function normalizeWecomDmPolicyMode(value, fallback = "open") {
582
582
  .toLowerCase();
583
583
  if (!normalized) return fallback;
584
584
  if (normalized === "closed" || normalized === "close") return "deny";
585
+ if (normalized === "disabled") return "deny";
585
586
  if (normalized === "whitelist") return "allowlist";
586
587
  if (DM_POLICY_MODE_SET.has(normalized)) return normalized;
587
588
  return fallback;
@@ -836,9 +837,16 @@ export function resolveWecomDmPolicyConfig({
836
837
  channelDmConfig.rejectMessage,
837
838
  channelDmConfig.blockMessage,
838
839
  readDmRejectMessageEnv(envVars, processEnv, accountId),
839
- mode === "deny" ? "当前渠道私聊已关闭,请联系管理员。" : "当前私聊账号未授权,请联系管理员。",
840
- );
841
- const effectiveMode = mode === "allowlist" && allowFrom.length === 0 ? "deny" : mode;
840
+ mode === "deny"
841
+ ? "当前渠道私聊已关闭,请联系管理员。"
842
+ : mode === "pairing"
843
+ ? "当前私聊需先完成配对审批。"
844
+ : "当前私聊账号未授权,请联系管理员。",
845
+ );
846
+ const effectiveMode =
847
+ mode === "allowlist" && allowFrom.length === 0
848
+ ? "deny"
849
+ : mode;
842
850
  return {
843
851
  mode: effectiveMode,
844
852
  allowFrom,
@@ -1,3 +1,8 @@
1
+ import {
2
+ issueWecomPairingChallenge,
3
+ resolveWecomDirectMessageAccess,
4
+ } from "./pairing.js";
5
+
1
6
  function assertFunction(name, value) {
2
7
  if (typeof value !== "function") {
3
8
  throw new Error(`agent-inbound-guards: ${name} is required`);
@@ -61,28 +66,46 @@ export async function applyWecomAgentInboundGuards({
61
66
 
62
67
  const commandPolicy = resolveWecomCommandPolicy(api);
63
68
  const isAdminUser = commandPolicy.adminUsers.includes(normalizedFromUser);
64
- const dmPolicy = resolveWecomDmPolicy(api, config?.accountId || accountId || "default", config);
69
+ const resolvedAccountId = config?.accountId || accountId || "default";
70
+ const dmPolicy = resolveWecomDmPolicy(api, resolvedAccountId, config);
71
+ const allowFromPolicy = resolveWecomAllowFromPolicy(api, resolvedAccountId, config);
72
+
65
73
  if (!isGroupChat) {
66
- if (dmPolicy.mode === "deny") {
67
- await sendTextToUser(dmPolicy.rejectMessage || "当前渠道私聊已关闭,请联系管理员。");
68
- return { ok: false, commandBody: nextCommandBody, isAdminUser };
69
- }
70
- if (dmPolicy.mode === "allowlist") {
71
- const dmSenderAllowed = isAdminUser || isWecomSenderAllowed({
72
- senderId: normalizedFromUser,
73
- allowFrom: dmPolicy.allowFrom,
74
+ const dmAccess = await resolveWecomDirectMessageAccess({
75
+ api,
76
+ accountId: resolvedAccountId,
77
+ dmPolicy,
78
+ allowFromPolicy,
79
+ normalizedFromUser,
80
+ isAdminUser,
81
+ isWecomSenderAllowed,
82
+ });
83
+ if (dmAccess.decision === "pairing") {
84
+ const pairing = await issueWecomPairingChallenge({
85
+ api,
86
+ accountId: resolvedAccountId,
87
+ fromUser,
88
+ normalizedFromUser,
89
+ sendPairingReply: sendTextToUser,
74
90
  });
75
- if (!dmSenderAllowed) {
76
- await sendTextToUser(dmPolicy.rejectMessage || "当前私聊账号未授权,请联系管理员。");
77
- return { ok: false, commandBody: nextCommandBody, isAdminUser };
91
+ if (!pairing.created && pairing.unsupported) {
92
+ await sendTextToUser(dmPolicy.rejectMessage || "当前私聊需先完成配对审批。");
78
93
  }
94
+ return { ok: false, commandBody: nextCommandBody, isAdminUser };
95
+ }
96
+ if (dmAccess.decision !== "allow") {
97
+ await sendTextToUser(dmAccess.rejectText || dmPolicy.rejectMessage || "当前私聊账号未授权,请联系管理员。");
98
+ return { ok: false, commandBody: nextCommandBody, isAdminUser };
79
99
  }
80
100
  }
81
- const allowFromPolicy = resolveWecomAllowFromPolicy(api, config?.accountId || accountId || "default", config);
82
- const senderAllowed = isAdminUser || isWecomSenderAllowed({
83
- senderId: normalizedFromUser,
84
- allowFrom: allowFromPolicy.allowFrom,
85
- });
101
+
102
+ const senderAllowed =
103
+ isAdminUser ||
104
+ allowFromPolicy.allowFrom.includes("*") ||
105
+ isWecomSenderAllowed({
106
+ senderId: normalizedFromUser,
107
+ allowFrom: allowFromPolicy.allowFrom,
108
+ });
86
109
  if (!senderAllowed) {
87
110
  api?.logger?.warn?.(
88
111
  `wecom: sender blocked by allowFrom account=${config?.accountId || "default"} user=${normalizedFromUser}`,
@@ -11,6 +11,26 @@ export function createWecomTextSender({
11
11
  throw new Error("createWecomTextSender: sendWecomTypedMessage is required");
12
12
  }
13
13
 
14
+ const targetSendChains = new Map();
15
+
16
+ function buildTargetKey({ corpId, agentId, toUser, toParty, toTag, chatId } = {}) {
17
+ const accountKey = `${corpId || "corp:unknown"}:${agentId || "agent:unknown"}`;
18
+ if (chatId) return `${accountKey}:chat:${chatId}`;
19
+ return `${accountKey}:direct:${[toUser, toParty, toTag].filter(Boolean).join("|") || "unknown"}`;
20
+ }
21
+
22
+ async function enqueueTargetSend(targetKey, task) {
23
+ const previous = targetSendChains.get(targetKey) || Promise.resolve();
24
+ const run = previous.catch(() => {}).then(task);
25
+ const tracked = run.finally(() => {
26
+ if (targetSendChains.get(targetKey) === tracked) {
27
+ targetSendChains.delete(targetKey);
28
+ }
29
+ });
30
+ targetSendChains.set(targetKey, tracked);
31
+ return run;
32
+ }
33
+
14
34
  async function sendWecomTextSingle({
15
35
  corpId,
16
36
  corpSecret,
@@ -57,29 +77,32 @@ export function createWecomTextSender({
57
77
  logger,
58
78
  proxyUrl,
59
79
  }) {
60
- const chunks = splitWecomText(text);
61
- logger?.info?.(`wecom: splitting message into ${chunks.length} chunks, total bytes=${getByteLength(text)}`);
80
+ const targetKey = buildTargetKey({ corpId, agentId, toUser, toParty, toTag, chatId });
81
+ return enqueueTargetSend(targetKey, async () => {
82
+ const chunks = splitWecomText(text);
83
+ logger?.info?.(`wecom: splitting message into ${chunks.length} chunks, total bytes=${getByteLength(text)}`);
62
84
 
63
- for (let i = 0; i < chunks.length; i += 1) {
64
- logger?.info?.(`wecom: sending chunk ${i + 1}/${chunks.length}, bytes=${getByteLength(chunks[i])}`);
65
- // eslint-disable-next-line no-await-in-loop
66
- await sendWecomTextSingle({
67
- corpId,
68
- corpSecret,
69
- agentId,
70
- toUser,
71
- toParty,
72
- toTag,
73
- chatId,
74
- text: chunks[i],
75
- logger,
76
- proxyUrl,
77
- });
78
- if (i < chunks.length - 1) {
85
+ for (let i = 0; i < chunks.length; i += 1) {
86
+ logger?.info?.(`wecom: sending chunk ${i + 1}/${chunks.length}, bytes=${getByteLength(chunks[i])}`);
79
87
  // eslint-disable-next-line no-await-in-loop
80
- await sleep(300);
88
+ await sendWecomTextSingle({
89
+ corpId,
90
+ corpSecret,
91
+ agentId,
92
+ toUser,
93
+ toParty,
94
+ toTag,
95
+ chatId,
96
+ text: chunks[i],
97
+ logger,
98
+ proxyUrl,
99
+ });
100
+ if (i < chunks.length - 1) {
101
+ // eslint-disable-next-line no-await-in-loop
102
+ await sleep(300);
103
+ }
81
104
  }
82
- }
105
+ });
83
106
  }
84
107
 
85
108
  return {
@@ -40,9 +40,11 @@ export function createWecomBotInboundContentBuilder({
40
40
  botProxyUrl,
41
41
  msgType = "text",
42
42
  commandBody = "",
43
+ normalizedImageEntries = [],
43
44
  normalizedImageUrls = [],
44
45
  normalizedFileUrl = "",
45
46
  normalizedFileName = "",
47
+ normalizedFileAesKey = "",
46
48
  normalizedVoiceUrl = "",
47
49
  normalizedVoiceMediaId = "",
48
50
  normalizedVoiceContentType = "",
@@ -52,12 +54,17 @@ export function createWecomBotInboundContentBuilder({
52
54
  const tempPathsToCleanup = [];
53
55
  let messageText = String(commandBody ?? "").trim();
54
56
 
55
- if (normalizedImageUrls.length > 0) {
57
+ if (normalizedImageUrls.length > 0 || normalizedImageEntries.length > 0) {
56
58
  const fetchedImagePaths = [];
57
- const imageUrlsToFetch = normalizedImageUrls.slice(0, 3);
59
+ const imageEntriesToFetch =
60
+ Array.isArray(normalizedImageEntries) && normalizedImageEntries.length > 0
61
+ ? normalizedImageEntries.slice(0, 3)
62
+ : normalizedImageUrls.slice(0, 3).map((url) => ({ url, aesKey: "" }));
58
63
  const tempDir = join(tmpdir(), WECOM_TEMP_DIR_NAME);
59
64
  await mkdir(tempDir, { recursive: true });
60
- for (const imageUrl of imageUrlsToFetch) {
65
+ for (const imageEntry of imageEntriesToFetch) {
66
+ const imageUrl = String(imageEntry?.url ?? "").trim();
67
+ const imageAesKey = String(imageEntry?.aesKey ?? "").trim();
61
68
  try {
62
69
  const { buffer, contentType } = await fetchMediaFromUrl(imageUrl, {
63
70
  proxyUrl: botProxyUrl,
@@ -73,10 +80,11 @@ export function createWecomBotInboundContentBuilder({
73
80
  let effectiveBuffer = buffer;
74
81
  let effectiveImageType =
75
82
  normalizedType.startsWith("image/") ? normalizedType : detectImageContentTypeFromBuffer(buffer);
76
- if (!effectiveImageType && botModeConfig?.encodingAesKey) {
83
+ const decryptAesKey = imageAesKey || String(botModeConfig?.encodingAesKey ?? "").trim();
84
+ if (!effectiveImageType && decryptAesKey) {
77
85
  try {
78
86
  const decryptedBuffer = decryptWecomMediaBuffer({
79
- aesKey: botModeConfig.encodingAesKey,
87
+ aesKey: decryptAesKey,
80
88
  encryptedBuffer: buffer,
81
89
  });
82
90
  const decryptedImageType = detectImageContentTypeFromBuffer(decryptedBuffer);
@@ -158,7 +166,7 @@ export function createWecomBotInboundContentBuilder({
158
166
  });
159
167
  const decrypted = smartDecryptWecomFileBuffer({
160
168
  buffer: downloaded.buffer,
161
- aesKey: botModeConfig?.encodingAesKey,
169
+ aesKey: normalizedFileAesKey || botModeConfig?.encodingAesKey,
162
170
  contentType: downloaded.contentType,
163
171
  sourceUrl: downloaded.finalUrl || normalizedFileUrl,
164
172
  decryptFn: decryptWecomMediaBuffer,