@dingxiang-me/openclaw-wechat 2.0.1 → 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,19 @@ 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
+
7
20
  ## [2.0.1] - 2026-03-14
8
21
 
9
22
  ### Fixed
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.1",
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.1",
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}`,
@@ -125,7 +125,7 @@ export async function executeWecomBotInboundFlow(payload = {}) {
125
125
  }
126
126
  state.commandBody = groupGuardResult.commandBody;
127
127
 
128
- const commandGuardResult = applyWecomBotCommandAndSenderGuard({
128
+ const commandGuardResult = await applyWecomBotCommandAndSenderGuard({
129
129
  api,
130
130
  accountId: state.accountId,
131
131
  fromUser,
@@ -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(`bot-inbound-guards: ${name} is required`);
@@ -50,7 +55,7 @@ export function applyWecomBotGroupChatGuard({
50
55
  };
51
56
  }
52
57
 
53
- export function applyWecomBotCommandAndSenderGuard({
58
+ export async function applyWecomBotCommandAndSenderGuard({
54
59
  api,
55
60
  accountId = "default",
56
61
  fromUser,
@@ -77,37 +82,57 @@ export function applyWecomBotCommandAndSenderGuard({
77
82
  const commandPolicy = resolveWecomCommandPolicy(api);
78
83
  const isAdminUser = commandPolicy.adminUsers.includes(String(normalizedFromUser ?? "").trim().toLowerCase());
79
84
  const dmPolicy = resolveWecomDmPolicy(api, accountId, {});
85
+ const allowFromPolicy = resolveWecomAllowFromPolicy(api, accountId, {});
86
+
80
87
  if (!isGroupChat) {
81
- if (dmPolicy.mode === "deny") {
88
+ const dmAccess = await resolveWecomDirectMessageAccess({
89
+ api,
90
+ accountId,
91
+ dmPolicy,
92
+ allowFromPolicy,
93
+ normalizedFromUser,
94
+ isAdminUser,
95
+ isWecomSenderAllowed,
96
+ });
97
+ if (dmAccess.decision === "pairing") {
98
+ const pairing = await issueWecomPairingChallenge({
99
+ api,
100
+ accountId,
101
+ fromUser,
102
+ normalizedFromUser,
103
+ sendPairingReply: async (text) => text,
104
+ });
82
105
  return {
83
106
  ok: false,
84
- finishText: dmPolicy.rejectMessage || "当前渠道私聊已关闭,请联系管理员。",
107
+ finishText:
108
+ pairing.created
109
+ ? pairing.replyText || ""
110
+ : !pairing.unsupported
111
+ ? ""
112
+ : dmPolicy.rejectMessage || "当前私聊需先完成配对审批。",
85
113
  commandBody: String(commandBody ?? ""),
86
114
  isAdminUser,
87
115
  commandPolicy,
88
116
  };
89
117
  }
90
- if (dmPolicy.mode === "allowlist") {
91
- const dmSenderAllowed = isAdminUser || isWecomSenderAllowed({
92
- senderId: normalizedFromUser,
93
- allowFrom: dmPolicy.allowFrom,
94
- });
95
- if (!dmSenderAllowed) {
96
- return {
97
- ok: false,
98
- finishText: dmPolicy.rejectMessage || "当前私聊账号未授权,请联系管理员。",
99
- commandBody: String(commandBody ?? ""),
100
- isAdminUser,
101
- commandPolicy,
102
- };
103
- }
118
+ if (dmAccess.decision !== "allow") {
119
+ return {
120
+ ok: false,
121
+ finishText: dmAccess.rejectText || dmPolicy.rejectMessage || "当前私聊账号未授权,请联系管理员。",
122
+ commandBody: String(commandBody ?? ""),
123
+ isAdminUser,
124
+ commandPolicy,
125
+ };
104
126
  }
105
127
  }
106
- const allowFromPolicy = resolveWecomAllowFromPolicy(api, accountId, {});
107
- const senderAllowed = isAdminUser || isWecomSenderAllowed({
108
- senderId: normalizedFromUser,
109
- allowFrom: allowFromPolicy.allowFrom,
110
- });
128
+
129
+ const senderAllowed =
130
+ isAdminUser ||
131
+ allowFromPolicy.allowFrom.includes("*") ||
132
+ isWecomSenderAllowed({
133
+ senderId: normalizedFromUser,
134
+ allowFrom: allowFromPolicy.allowFrom,
135
+ });
111
136
  if (!senderAllowed) {
112
137
  return {
113
138
  ok: false,
@@ -260,7 +260,7 @@ export function createWecomBotLongConnectionManager({
260
260
  if (!agent) {
261
261
  agent = new wsProxyAgentCtor(proxyUrl);
262
262
  wsProxyAgentCache.set(proxyUrl, agent);
263
- api?.logger?.info?.(
263
+ api?.logger?.debug?.(
264
264
  `wecom(bot-longconn): websocket proxy enabled account=${client.accountId} proxy=${printableProxy}`,
265
265
  );
266
266
  }
@@ -729,7 +729,7 @@ export function createWecomBotLongConnectionManager({
729
729
  transport: "bot.longConnection",
730
730
  });
731
731
  startPingLoop(client, api);
732
- api?.logger?.info?.(`wecom(bot-longconn): subscribed account=${client.accountId}`);
732
+ api?.logger?.debug?.(`wecom(bot-longconn): subscribed account=${client.accountId}`);
733
733
  } else {
734
734
  api?.logger?.warn?.(
735
735
  `wecom(bot-longconn): subscribe failed account=${client.accountId} errcode=${errcode} errmsg=${errmsg}`,
@@ -799,7 +799,7 @@ export function createWecomBotLongConnectionManager({
799
799
  `wecom(bot-longconn): websocket proxy init failed account=${client.accountId}: ${String(err?.message || err)}`,
800
800
  );
801
801
  }
802
- api?.logger?.info?.(
802
+ api?.logger?.debug?.(
803
803
  `wecom(bot-longconn): connect attempt account=${client.accountId} marker=${LONG_CONNECTION_RUNTIME_MARKER} url=${wsUrl} proxy=${proxyUrl || "direct"} wsCtor=${String(webSocketCtor?.name || "unknown")}`,
804
804
  );
805
805
  client.ws = new webSocketCtor(wsUrl, [], wsOptions);
@@ -822,7 +822,7 @@ export function createWecomBotLongConnectionManager({
822
822
  secret: client.config.longConnection.secret,
823
823
  },
824
824
  }) || "";
825
- api?.logger?.info?.(`wecom(bot-longconn): socket opened account=${client.accountId} url=${wsUrl}`);
825
+ api?.logger?.debug?.(`wecom(bot-longconn): socket opened account=${client.accountId} url=${wsUrl}`);
826
826
  });
827
827
  bindSocketListener(client.ws, "message", (event) => {
828
828
  void handleSocketMessage(client, api, event);
@@ -3,6 +3,7 @@ import {
3
3
  getWecomChannelInboundActivity,
4
4
  getWecomInboundActivity,
5
5
  } from "./channel-status-state.js";
6
+ import { normalizeWecomAllowFromEntry } from "../core.js";
6
7
 
7
8
  function assertFunction(name, fn) {
8
9
  if (typeof fn !== "function") {
@@ -160,9 +161,30 @@ export function createWecomChannelPlugin({
160
161
  id: "wecom",
161
162
  label: "企业微信 WeCom",
162
163
  selectionLabel: "企业微信 WeCom(自建应用/Bot)",
164
+ detailLabel: "企业微信自建应用 / Bot",
163
165
  docsPath: "/channels/wecom",
164
166
  blurb: "企业微信消息通道(自建应用回调 + Bot 回调 + 发送 API)。",
165
167
  aliases: ["wework", "qiwei", "wxwork"],
168
+ systemImage: "building.2.crop.circle",
169
+ quickstartAllowFrom: true,
170
+ },
171
+ pairing: {
172
+ idLabel: "wecomUserId",
173
+ normalizeAllowEntry: (entry) => normalizeWecomAllowFromEntry(entry),
174
+ notifyApproval: async ({ cfg, id }) => {
175
+ const normalizedUserId = normalizeWecomAllowFromEntry(id);
176
+ if (!normalizedUserId) return;
177
+ const config = getWecomConfig({ config: cfg }, "default");
178
+ if (!config?.corpId || !config?.corpSecret || !config?.agentId) return;
179
+ await sendWecomText({
180
+ corpId: config.corpId,
181
+ corpSecret: config.corpSecret,
182
+ agentId: config.agentId,
183
+ toUser: normalizedUserId,
184
+ text: "OpenClaw: your access has been approved.",
185
+ proxyUrl: config.outboundProxy,
186
+ });
187
+ },
166
188
  },
167
189
  configSchema: {
168
190
  schema: wecomChannelConfigSchema,
@@ -98,6 +98,7 @@ export function createWecomCommandHandlers({
98
98
  async function handleStatusCommand({ api, fromUser, corpId, corpSecret, agentId, accountId, proxyUrl }) {
99
99
  const config = getWecomConfig(api, accountId);
100
100
  const accountIds = listWecomAccountIds(api);
101
+ const bindingsCount = Array.isArray(api?.config?.bindings) ? api.config.bindings.length : 0;
101
102
  const webhookTargetAliases = listWebhookTargetAliases(config);
102
103
  const voiceConfig = resolveWecomVoiceTranscriptionConfig(api);
103
104
  const voiceRuntimeInfo = await inspectWecomVoiceTranscriptionRuntime({ api, voiceConfig });
@@ -134,6 +135,7 @@ export function createWecomCommandHandlers({
134
135
  webhookBotPolicy,
135
136
  dynamicAgentPolicy,
136
137
  observabilityMetrics,
138
+ bindingsCount,
137
139
  });
138
140
 
139
141
  await sendWecomText({
@@ -150,6 +152,8 @@ export function createWecomCommandHandlers({
150
152
 
151
153
  function buildBotStatus(api, fromUser) {
152
154
  const allWebhookTargetAliases = listAllWebhookTargetAliases(api);
155
+ const config = getWecomConfig(api, "default");
156
+ const bindingsCount = Array.isArray(api?.config?.bindings) ? api.config.bindings.length : 0;
153
157
  const commandPolicy = resolveWecomCommandPolicy(api);
154
158
  const allowFromPolicy = resolveWecomAllowFromPolicy(api, "default", {});
155
159
  const dmPolicy = resolveWecomDmPolicy(api, "default", {});
@@ -176,6 +180,8 @@ export function createWecomCommandHandlers({
176
180
  webhookBotPolicy,
177
181
  dynamicAgentPolicy,
178
182
  observabilityMetrics,
183
+ config,
184
+ bindingsCount,
179
185
  });
180
186
  }
181
187
 
@@ -17,9 +17,29 @@ function buildDmPolicyStatusLine(dmPolicy = {}) {
17
17
  const count = Array.isArray(dmPolicy?.allowFrom) ? dmPolicy.allowFrom.length : 0;
18
18
  return `✅ 私聊策略:白名单(${count} 个用户)`;
19
19
  }
20
+ if (mode === "pairing") {
21
+ const count = Array.isArray(dmPolicy?.allowFrom) ? dmPolicy.allowFrom.length : 0;
22
+ return count > 0
23
+ ? `✅ 私聊策略:配对审批(pairing,显式放行 ${count} 个用户)`
24
+ : "✅ 私聊策略:配对审批(pairing,首次私聊需审批)";
25
+ }
20
26
  return "ℹ️ 私聊策略:开放(open)";
21
27
  }
22
28
 
29
+ function buildRoutePolicyStatusLine({ bindingsCount = 0, dynamicAgentPolicy = {} } = {}) {
30
+ const count = Math.max(0, Number(bindingsCount) || 0);
31
+ if (count > 0 && dynamicAgentPolicy?.enabled) {
32
+ return `✅ 路由策略:OpenClaw bindings 优先(${count} 条),动态 Agent 补充(mode=${dynamicAgentPolicy.mode})`;
33
+ }
34
+ if (count > 0) {
35
+ return `✅ 路由策略:OpenClaw bindings(${count} 条)`;
36
+ }
37
+ if (dynamicAgentPolicy?.enabled) {
38
+ return `✅ 路由策略:动态 Agent(mode=${dynamicAgentPolicy.mode})`;
39
+ }
40
+ return "ℹ️ 路由策略:使用 OpenClaw 默认 Agent 路由";
41
+ }
42
+
23
43
  function buildEventPolicyStatusLine(eventPolicy = {}) {
24
44
  if (eventPolicy?.enabled === false) {
25
45
  return "⚠️ 事件策略:已关闭";
@@ -80,6 +100,43 @@ function buildVoiceStatusLine(voiceConfig = {}, voiceRuntimeInfo = null) {
80
100
  return `${baseLine}(${commandState},${ffmpegState})${issueSuffix}`;
81
101
  }
82
102
 
103
+ function buildAgentReadinessLines({ config = {}, voiceConfig = {} } = {}) {
104
+ const canReceive = Boolean(config?.callbackToken && config?.callbackAesKey && config?.webhookPath);
105
+ const canReply = Boolean(config?.corpId && config?.corpSecret && config?.agentId);
106
+ const docEnabled = config?.tools?.doc !== false;
107
+ const voiceEnabled = voiceConfig?.enabled === true;
108
+ return [
109
+ `${canReceive ? "✅" : "⚠️"} 收消息:${canReceive ? "Agent 回调已配置" : "缺少 callbackToken / callbackAesKey / webhookPath"}`,
110
+ `${canReply ? "✅" : "⚠️"} 回消息:${canReply ? "Agent API 可用" : "缺少 corpId / corpSecret / agentId"}`,
111
+ `${canReply ? "✅" : "⚠️"} 主动发送:${canReply ? "文本/图片/文件可主动发送" : "主动发送依赖 Agent API 配置"}`,
112
+ `${canReply ? "✅" : "⚠️"} 媒体链路:${canReply ? "图片/文件/语音回退链路可用" : "需先完成 Agent API 配置"}`,
113
+ `${voiceEnabled ? "✅" : "ℹ️"} 语音能力:${voiceEnabled ? "已启用本地转写回退" : "仅使用企业微信 Recognition"}`,
114
+ `${docEnabled ? "✅" : "ℹ️"} 文档工具:${docEnabled ? "wecom_doc 已启用" : "未启用"}`,
115
+ ];
116
+ }
117
+
118
+ function buildBotReadinessLines({ botConfig = {}, config = {} } = {}) {
119
+ const longConnectionEnabled =
120
+ botConfig?.longConnection?.enabled === true &&
121
+ Boolean(String(botConfig?.longConnection?.botId ?? "").trim()) &&
122
+ Boolean(String(botConfig?.longConnection?.secret ?? "").trim());
123
+ const webhookEnabled =
124
+ botConfig?.enabled === true &&
125
+ Boolean(String(botConfig?.token ?? "").trim()) &&
126
+ Boolean(String(botConfig?.encodingAesKey ?? "").trim()) &&
127
+ Boolean(String(botConfig?.webhookPath ?? "").trim());
128
+ const canReceive = longConnectionEnabled || webhookEnabled;
129
+ const canReply = canReceive;
130
+ const docEnabled = config?.tools?.doc !== false;
131
+ return [
132
+ `${canReceive ? "✅" : "⚠️"} 收消息:${canReceive ? (longConnectionEnabled ? "Bot 长连接/回调已配置" : "Bot webhook 已配置") : "缺少 Bot webhook 或长连接凭证"}`,
133
+ `${canReply ? "✅" : "⚠️"} 回消息:${canReply ? "原生 stream + fallback 可用" : "需先完成 Bot 配置"}`,
134
+ `${canReceive ? "ℹ️" : "⚠️"} 主动发送:${canReceive ? "Bot 以会话回包为主;跨会话主动发送建议配合 Agent/Webhook 目标" : "需先完成 Bot 配置"}`,
135
+ `${canReceive ? "✅" : "⚠️"} 媒体链路:${canReceive ? "Bot 图片/文件/PDF 入站理解已启用" : "需先完成 Bot 配置"}`,
136
+ `${docEnabled ? "✅" : "ℹ️"} 文档工具:${docEnabled ? "wecom_doc 已启用" : "未启用"}`,
137
+ ];
138
+ }
139
+
83
140
  export function buildAgentStatusText({
84
141
  fromUser,
85
142
  config,
@@ -100,9 +157,11 @@ export function buildAgentStatusText({
100
157
  webhookBotPolicy,
101
158
  dynamicAgentPolicy,
102
159
  observabilityMetrics,
160
+ bindingsCount = 0,
103
161
  } = {}) {
104
162
  const proxyEnabled = Boolean(config?.outboundProxy);
105
163
  const voiceStatusLine = buildVoiceStatusLine(voiceConfig, voiceRuntimeInfo);
164
+ const readinessLines = buildAgentReadinessLines({ config, voiceConfig });
106
165
  const commandPolicyLine = commandPolicy.enabled
107
166
  ? `✅ 指令白名单已启用(${commandPolicy.allowlist.length} 条,管理员 ${commandPolicy.adminUsers.length} 人)`
108
167
  : "ℹ️ 指令白名单未启用";
@@ -141,6 +200,7 @@ export function buildAgentStatusText({
141
200
  const dynamicAgentPolicyLine = dynamicAgentPolicy.enabled
142
201
  ? `✅ 动态 Agent 路由已启用(mode=${dynamicAgentPolicy.mode},用户映射 ${Object.keys(dynamicAgentPolicy.userMap || {}).length},群映射 ${Object.keys(dynamicAgentPolicy.groupMap || {}).length})`
143
202
  : "ℹ️ 动态 Agent 路由未启用";
203
+ const routePolicyLine = buildRoutePolicyStatusLine({ bindingsCount, dynamicAgentPolicy });
144
204
  const entryVisibilityLine = "✅ 微信插件入口联系人:Agent 模式可见(自建应用)";
145
205
  const observabilityLines = buildObservabilityStatusLines(observabilityMetrics);
146
206
 
@@ -153,8 +213,8 @@ export function buildAgentStatusText({
153
213
  插件版本:${pluginVersion}
154
214
 
155
215
  功能状态:
216
+ ${readinessLines.join("\n")}
156
217
  ✅ 文本消息
157
- ✅ 图片发送/接收
158
218
  ✅ 消息分段 (2048字符)
159
219
  ✅ 命令系统
160
220
  ✅ Markdown 转换
@@ -172,6 +232,7 @@ ${streamManagerPolicyLine}
172
232
  ${webhookBotPolicyLine}
173
233
  ${webhookTargetsLine}
174
234
  ${dynamicAgentPolicyLine}
235
+ ${routePolicyLine}
175
236
  ${entryVisibilityLine}
176
237
  ${proxyEnabled ? "✅ WeCom 出站代理已启用" : "ℹ️ WeCom 出站代理未启用"}
177
238
  ${voiceStatusLine}
@@ -194,7 +255,10 @@ export function buildBotStatusText({
194
255
  webhookBotPolicy,
195
256
  dynamicAgentPolicy,
196
257
  observabilityMetrics,
258
+ config = {},
259
+ bindingsCount = 0,
197
260
  } = {}) {
261
+ const readinessLines = buildBotReadinessLines({ botConfig, config });
198
262
  const commandPolicyLine = commandPolicy.enabled
199
263
  ? `✅ 指令白名单已启用(${commandPolicy.allowlist.length} 条,管理员 ${commandPolicy.adminUsers.length} 人)`
200
264
  : "ℹ️ 指令白名单未启用";
@@ -227,6 +291,7 @@ export function buildBotStatusText({
227
291
  const dynamicAgentPolicyLine = dynamicAgentPolicy.enabled
228
292
  ? `✅ 动态 Agent 路由已启用(mode=${dynamicAgentPolicy.mode},用户映射 ${Object.keys(dynamicAgentPolicy.userMap || {}).length},群映射 ${Object.keys(dynamicAgentPolicy.groupMap || {}).length})`
229
293
  : "ℹ️ 动态 Agent 路由未启用";
294
+ const routePolicyLine = buildRoutePolicyStatusLine({ bindingsCount, dynamicAgentPolicy });
230
295
  const entryVisibilityLine = "ℹ️ 微信插件入口联系人:Bot 模式通常不显示(请通过机器人会话/群聊入口触发)";
231
296
  const observabilityLines = buildObservabilityStatusLines(observabilityMetrics);
232
297
  return `📊 系统状态
@@ -237,6 +302,7 @@ export function buildBotStatusText({
237
302
  Bot Webhook:${botConfig.webhookPath}
238
303
 
239
304
  功能状态:
305
+ ${readinessLines.join("\n")}
240
306
  ✅ 原生流式回复(stream)
241
307
  ${commandPolicyLine}
242
308
  ${allowFromPolicyLine}
@@ -246,8 +312,10 @@ ${groupPolicyLine}
246
312
  ${fallbackPolicyLine}
247
313
  ${streamManagerPolicyLine}
248
314
  ${webhookBotPolicyLine}
315
+ ${longConnectionLine}
249
316
  ${webhookTargetsLine}
250
317
  ${dynamicAgentPolicyLine}
318
+ ${routePolicyLine}
251
319
  ${entryVisibilityLine}
252
320
  ${observabilityLines.status}
253
321
  ${observabilityLines.recent}`;
@@ -0,0 +1,188 @@
1
+ import { normalizeWecomAllowFromEntry } from "../core.js";
2
+
3
+ function normalizeAccountId(accountId) {
4
+ const normalized = String(accountId ?? "default").trim().toLowerCase();
5
+ return normalized || "default";
6
+ }
7
+
8
+ function uniqueAllowFrom(values = []) {
9
+ const out = [];
10
+ const seen = new Set();
11
+ for (const value of Array.isArray(values) ? values : []) {
12
+ const normalized = normalizeWecomAllowFromEntry(value);
13
+ if (!normalized || seen.has(normalized)) continue;
14
+ seen.add(normalized);
15
+ out.push(normalized);
16
+ }
17
+ return out;
18
+ }
19
+
20
+ function getPairingRuntime(api) {
21
+ const pairing = api?.runtime?.channel?.pairing;
22
+ if (!pairing || typeof pairing !== "object") return null;
23
+ if (typeof pairing.readAllowFromStore !== "function") return null;
24
+ if (typeof pairing.upsertPairingRequest !== "function") return null;
25
+ if (typeof pairing.buildPairingReply !== "function") return null;
26
+ return pairing;
27
+ }
28
+
29
+ function matchesAllowFrom({ allowFrom = [], senderId = "", isWecomSenderAllowed } = {}) {
30
+ const normalizedAllowFrom = Array.isArray(allowFrom) ? allowFrom : [];
31
+ if (normalizedAllowFrom.includes("*")) return true;
32
+ if (typeof isWecomSenderAllowed !== "function") return false;
33
+ return isWecomSenderAllowed({
34
+ senderId,
35
+ allowFrom: normalizedAllowFrom,
36
+ });
37
+ }
38
+
39
+ export async function readWecomPairingAllowFromStore({ api, accountId = "default" } = {}) {
40
+ const pairing = getPairingRuntime(api);
41
+ if (!pairing) return [];
42
+ try {
43
+ const storeEntries = await pairing.readAllowFromStore({
44
+ channel: "wecom",
45
+ accountId: normalizeAccountId(accountId),
46
+ });
47
+ return uniqueAllowFrom(storeEntries);
48
+ } catch (err) {
49
+ api?.logger?.warn?.(`wecom: failed to read pairing store: ${String(err?.message || err)}`);
50
+ return [];
51
+ }
52
+ }
53
+
54
+ export async function resolveWecomDirectMessageAccess({
55
+ api,
56
+ accountId = "default",
57
+ dmPolicy = {},
58
+ allowFromPolicy = {},
59
+ normalizedFromUser = "",
60
+ isAdminUser = false,
61
+ isWecomSenderAllowed,
62
+ } = {}) {
63
+ const mode = String(dmPolicy?.mode ?? "open").trim().toLowerCase() || "open";
64
+ const normalizedSender = normalizeWecomAllowFromEntry(normalizedFromUser);
65
+ const configuredAllowFrom = uniqueAllowFrom(dmPolicy?.allowFrom);
66
+ const baseAllowFrom = Array.isArray(allowFromPolicy?.allowFrom) ? allowFromPolicy.allowFrom : [];
67
+ const senderAllowedByBasePolicy =
68
+ isAdminUser ||
69
+ matchesAllowFrom({
70
+ senderId: normalizedSender,
71
+ allowFrom: baseAllowFrom,
72
+ isWecomSenderAllowed,
73
+ });
74
+
75
+ if (mode === "deny") {
76
+ return {
77
+ decision: "block",
78
+ reason: "dm-deny",
79
+ rejectText: dmPolicy?.rejectMessage || "当前渠道私聊已关闭,请联系管理员。",
80
+ configuredAllowFrom,
81
+ effectiveAllowFrom: configuredAllowFrom,
82
+ storeAllowFrom: [],
83
+ };
84
+ }
85
+
86
+ if (mode === "open") {
87
+ return {
88
+ decision: senderAllowedByBasePolicy ? "allow" : "block",
89
+ reason: senderAllowedByBasePolicy ? "dm-open" : "allowFrom",
90
+ rejectText: senderAllowedByBasePolicy
91
+ ? ""
92
+ : allowFromPolicy?.rejectMessage || "当前账号未授权,请联系管理员。",
93
+ configuredAllowFrom,
94
+ effectiveAllowFrom: configuredAllowFrom,
95
+ storeAllowFrom: [],
96
+ };
97
+ }
98
+
99
+ if (mode === "allowlist") {
100
+ const allowed =
101
+ isAdminUser ||
102
+ matchesAllowFrom({
103
+ senderId: normalizedSender,
104
+ allowFrom: configuredAllowFrom,
105
+ isWecomSenderAllowed,
106
+ });
107
+ return {
108
+ decision: senderAllowedByBasePolicy && allowed ? "allow" : "block",
109
+ reason: !senderAllowedByBasePolicy ? "allowFrom" : allowed ? "dm-allowlist" : "dm-allowlist-blocked",
110
+ rejectText: !senderAllowedByBasePolicy
111
+ ? allowFromPolicy?.rejectMessage || "当前账号未授权,请联系管理员。"
112
+ : dmPolicy?.rejectMessage || "当前私聊账号未授权,请联系管理员。",
113
+ configuredAllowFrom,
114
+ effectiveAllowFrom: configuredAllowFrom,
115
+ storeAllowFrom: [],
116
+ };
117
+ }
118
+
119
+ if (!senderAllowedByBasePolicy) {
120
+ return {
121
+ decision: "block",
122
+ reason: "allowFrom",
123
+ rejectText: allowFromPolicy?.rejectMessage || "当前账号未授权,请联系管理员。",
124
+ configuredAllowFrom,
125
+ effectiveAllowFrom: configuredAllowFrom,
126
+ storeAllowFrom: [],
127
+ };
128
+ }
129
+
130
+ const storeAllowFrom = await readWecomPairingAllowFromStore({
131
+ api,
132
+ accountId,
133
+ });
134
+ const effectiveAllowFrom = uniqueAllowFrom([...configuredAllowFrom, ...storeAllowFrom]);
135
+ const allowed =
136
+ isAdminUser ||
137
+ matchesAllowFrom({
138
+ senderId: normalizedSender,
139
+ allowFrom: effectiveAllowFrom,
140
+ isWecomSenderAllowed,
141
+ });
142
+ return {
143
+ decision: allowed ? "allow" : "pairing",
144
+ reason: allowed ? "dm-pairing-approved" : "dm-pairing",
145
+ rejectText: allowed ? "" : dmPolicy?.rejectMessage || "当前私聊需先完成配对审批。",
146
+ configuredAllowFrom,
147
+ effectiveAllowFrom,
148
+ storeAllowFrom,
149
+ };
150
+ }
151
+
152
+ export async function issueWecomPairingChallenge({
153
+ api,
154
+ accountId = "default",
155
+ fromUser = "",
156
+ normalizedFromUser = "",
157
+ sendPairingReply,
158
+ } = {}) {
159
+ const pairing = getPairingRuntime(api);
160
+ const senderId = normalizeWecomAllowFromEntry(normalizedFromUser || fromUser);
161
+ if (!pairing || !senderId || typeof sendPairingReply !== "function") {
162
+ return { created: false, unsupported: true };
163
+ }
164
+
165
+ const { code, created } = await pairing.upsertPairingRequest({
166
+ channel: "wecom",
167
+ accountId: normalizeAccountId(accountId),
168
+ id: senderId,
169
+ meta: {
170
+ name: String(fromUser ?? "").trim() || undefined,
171
+ },
172
+ });
173
+ if (!created) {
174
+ return { created: false, code };
175
+ }
176
+
177
+ const replyText = pairing.buildPairingReply({
178
+ channel: "wecom",
179
+ idLine: `Your WeCom user id: ${senderId}`,
180
+ code,
181
+ });
182
+ try {
183
+ await sendPairingReply(replyText);
184
+ } catch (err) {
185
+ api?.logger?.warn?.(`wecom: pairing reply failed: ${String(err?.message || err)}`);
186
+ }
187
+ return { created: true, code, replyText };
188
+ }
@@ -1,5 +1,5 @@
1
1
  export const MAX_REQUEST_BODY_SIZE = 1024 * 1024;
2
- export const PLUGIN_VERSION = "2.0.1";
2
+ export const PLUGIN_VERSION = "2.1.0";
3
3
  export const WECOM_TEMP_DIR_NAME = "openclaw-wechat";
4
4
  export const WECOM_TEMP_FILE_RETENTION_MS = 30 * 60 * 1000;
5
5
  export const WECOM_MIN_FILE_SIZE = 5;