openclaw-plugin-wecom 1.0.0 → 1.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/README.md CHANGED
@@ -1,148 +1,132 @@
1
1
  # OpenClaw WeCom (Enterprise WeChat) AI Bot Plugin
2
2
 
3
- [简体中文](https://github.com/sunnoy/openclaw-plugin-wecom/blob/main/README_ZH.md) | [English](https://github.com/sunnoy/openclaw-plugin-wecom/blob/main/README.md)
3
+ [English](https://github.com/sunnoy/openclaw-plugin-wecom/blob/main/README.md) | [简体中文](https://github.com/sunnoy/openclaw-plugin-wecom/blob/main/README_ZH.md)
4
4
 
5
- `openclaw-plugin-wecom` is a WeCom (Enterprise WeChat) integration plugin developed for the [OpenClaw](https://github.com/openclaw/openclaw) framework. It enables seamless integration of powerful AI capabilities into WeCom with advanced features.
5
+ `openclaw-plugin-wecom` is an Enterprise WeChat (WeCom) integration plugin developed for the [OpenClaw](https://github.com/openclaw/openclaw) framework. It enables seamless AI capabilities in Enterprise WeChat with advanced features.
6
6
 
7
7
  ## ✨ Key Features
8
8
 
9
- - 🌊 **Streaming Output**: Smooth typewriter-style responses using WeCom's latest AI bot streaming mechanism.
10
- - 🤖 **Dynamic Agent Management**: Automatically creates independent Agents per user/group chat with isolated workspaces and conversation contexts.
11
- - 👥 **Group Chat Integration**: Full support for group messages with @mention triggering.
12
- - 🛠️ **Command Support**: Built-in commands (`/new`, `/status`, `/help`, `/compact`) with configurable whitelist.
13
- - 🔒 **Security**: Complete support for WeCom message encryption/decryption and sender verification.
14
- - **Async Processing**: High-performance async architecture ensures gateway responsiveness during AI inference.
9
+ - 🌊 **Streaming Output**: Built on WeCom's latest AI bot streaming mechanism for smooth typewriter-style responses.
10
+ - 🤖 **Dynamic Agent Management**: Automatically creates isolated agents per direct message user or group chat, with independent workspaces and conversation contexts.
11
+ - 👥 **Deep Group Chat Integration**: Supports group message parsing with @mention triggering.
12
+ - 🖼️ **Image Support**: Automatic base64 encoding and sending of local images (screenshots, generated images) without requiring additional configuration.
13
+ - 🛠️ **Command Enhancement**: Built-in commands (e.g., `/new` for new sessions, `/status` for status) with allowlist configuration.
14
+ - 🔒 **Security & Authentication**: Full support for WeCom message encryption/decryption, URL verification, and sender validation.
15
+ - ⚡ **High-Performance Async Processing**: Asynchronous message architecture ensures responsive gateway even during long AI inference.
15
16
 
16
- ## 🚀 Quick Start
17
+ ## 📋 Prerequisites
17
18
 
18
- ### Option 1: Docker Deployment (Recommended)
19
+ - [OpenClaw](https://github.com/openclaw/openclaw) installed (version 2026.1.30+)
20
+ - Enterprise WeChat admin access to create intelligent robot applications
21
+ - Server address accessible from Enterprise WeChat (HTTP/HTTPS)
19
22
 
20
- This repository provides a complete Docker deployment solution that **deploys OpenClaw + WeCom plugin in one step**, with automated installation and configuration.
23
+ ## 🚀 Installation
21
24
 
22
- ```bash
23
- # 1. Clone the repository
24
- git clone https://github.com/sunnoy/openclaw-plugin-wecom.git
25
- cd openclaw-plugin-wecom/deploy
26
-
27
- # 2. Copy environment configuration
28
- cp .env.example .env
29
-
30
- # 3. Edit .env file with your settings
31
- vim .env
32
-
33
- # 4. Run deployment script
34
- ./deploy.sh
35
- ```
36
-
37
- The deployment script automatically:
38
- - Creates data directories and sets permissions
39
- - Generates configuration files
40
- - Starts Docker containers
41
- - Installs the WeCom plugin
42
- - Configures and restarts services
43
-
44
- #### 🌟 Deployment Highlights
45
-
46
- **Custom Data Directory & Agent Workspace Paths**
47
-
48
- The core advantage of this deployment is unified data storage in a custom path, effectively utilizing data disks:
49
-
50
- ```bash
51
- # .env configuration example
52
- OPENCLAW_DATA_DIR=/data/openclaw # Custom data directory
53
- ```
54
-
55
- - **OpenClaw State Directory**: `/data/openclaw/`
56
- - **Dynamic Agent Workspace**: `/data/openclaw/.openclaw/`
57
- - **Plugin Directory**: `/data/openclaw/extensions/`
58
- - **Canvas Data**: `/data/openclaw/canvas/`
59
-
60
- Benefits:
61
- - ✅ All Agent workspace data stored on data disk, avoiding system disk usage
62
- - ✅ Independent Agent files for each user/group managed under unified path
63
- - ✅ Easy backup, migration, and expansion
64
- - ✅ Enterprise-ready deployment with independently mountable data disks
65
-
66
- ### Option 2: Manual Plugin Installation
67
-
68
- Install in an existing OpenClaw environment:
25
+ ### Method 1: Using OpenClaw CLI (Recommended)
69
26
 
70
27
  ```bash
71
28
  openclaw plugins install openclaw-plugin-wecom
72
29
  ```
73
30
 
74
- Or via npm:
31
+ ### Method 2: Using npm
75
32
 
76
33
  ```bash
77
34
  npm install openclaw-plugin-wecom
78
35
  ```
79
36
 
80
- Then add to your OpenClaw configuration:
37
+ ## ⚙️ Configuration
38
+
39
+ Add to your OpenClaw configuration file (`~/.openclaw/openclaw.json`):
81
40
 
82
41
  ```json
83
42
  {
84
43
  "plugins": {
44
+ "deny": ["wecom"],
85
45
  "entries": {
86
- "wecom": { "enabled": true }
46
+ "openclaw-plugin-wecom": {
47
+ "enabled": true
48
+ }
87
49
  }
88
50
  },
89
51
  "channels": {
90
52
  "wecom": {
91
53
  "enabled": true,
92
54
  "token": "Your Token",
93
- "encodingAesKey": "Your EncodingAESKey"
55
+ "encodingAesKey": "Your EncodingAESKey",
56
+ "commands": {
57
+ "enabled": true,
58
+ "allowlist": ["/new", "/status", "/help", "/compact"]
59
+ }
94
60
  }
95
61
  }
96
62
  }
97
63
  ```
98
64
 
99
- ### WeCom Backend Setup
65
+ ### Configuration Options
100
66
 
101
- 1. Create an "Intelligent Bot" in WeCom Admin Console.
102
- 2. Set the "Receive Message" URL to your service address (e.g., `https://your-domain.com/webhooks/wecom`).
103
- 3. Enter the corresponding Token and EncodingAESKey.
67
+ | Option | Type | Required | Description |
68
+ |--------|------|----------|-------------|
69
+ | `plugins.deny` | array | Recommended | Add `["wecom"]` to prevent OpenClaw from auto-enabling built-in channel |
70
+ | `plugins.entries.openclaw-plugin-wecom.enabled` | boolean | Yes | Enable the plugin |
71
+ | `channels.wecom.token` | string | Yes | WeCom bot Token |
72
+ | `channels.wecom.encodingAesKey` | string | Yes | WeCom message encryption key (43 chars) |
73
+ | `channels.wecom.commands.allowlist` | array | No | Command allowlist |
104
74
 
105
- ## 📂 Project Structure
75
+ ## 🔌 Enterprise WeChat Configuration
106
76
 
107
- ```
108
- openclaw-plugin-wecom/
109
- ├── deploy/ # Deployment files
110
- ├── deploy.sh # One-click deployment script
111
- ├── docker-compose.yml # Docker Compose configuration
112
- ├── .env.example # Environment variables template
113
- │ ├── openclaw.json.base # Base configuration template
114
- │ └── openclaw.json.template # Full configuration template
115
- ├── Dockerfile # OpenClaw image build file
116
- ├── local.sh # Local image build script
117
- ├── index.js # Plugin entry point
118
- ├── webhook.js # WeCom HTTP communication
119
- ├── dynamic-agent.js # Dynamic Agent routing
120
- ├── stream-manager.js # Streaming response management
121
- ├── crypto.js # WeCom encryption
122
- └── client.js # Client logic
123
- ```
77
+ 1. Log in to [Enterprise WeChat Admin Console](https://work.weixin.qq.com/)
78
+ 2. Navigate to "Application Management" → "Applications" → "Create Application" → Select "Intelligent Robot"
79
+ 3. Configure "Receive Messages":
80
+ - **URL**: `https://your-domain.com/webhooks/wecom`
81
+ - **Token**: Match `channels.wecom.token`
82
+ - **EncodingAESKey**: Match `channels.wecom.encodingAesKey`
83
+ 4. Save and enable message receiving
124
84
 
125
85
  ## 🤖 Dynamic Agent Routing
126
86
 
127
- The plugin implements per-user/per-group isolation:
87
+ The plugin implements per-user/per-group agent isolation:
128
88
 
129
- 1. On message arrival, generates a deterministic `agentId`:
130
- - DM: `wecom-dm-<userId>`
131
- - Group: `wecom-group-<chatId>`
132
- 2. OpenClaw automatically creates/reuses the corresponding Agent workspace.
89
+ ### How It Works
133
90
 
134
- ### Configuration Options
91
+ 1. When a WeCom message arrives, the plugin generates a deterministic `agentId`:
92
+ - **Direct Messages**: `wecom-dm-<userId>`
93
+ - **Group Chats**: `wecom-group-<chatId>`
94
+ 2. OpenClaw automatically creates/reuses the corresponding agent workspace
95
+ 3. Each user/group has independent conversation history and context
135
96
 
136
- Under `channels.wecom`:
97
+ ### Advanced Configuration
98
+
99
+ Configure under `channels.wecom`:
100
+
101
+ ```json
102
+ {
103
+ "channels": {
104
+ "wecom": {
105
+ "dynamicAgents": {
106
+ "enabled": true
107
+ },
108
+ "dm": {
109
+ "createAgentOnFirstMessage": true
110
+ },
111
+ "groupChat": {
112
+ "enabled": true,
113
+ "requireMention": true
114
+ }
115
+ }
116
+ }
117
+ }
118
+ ```
137
119
 
138
120
  | Option | Type | Default | Description |
139
121
  |--------|------|---------|-------------|
140
- | `dynamicAgents.enabled` | boolean | `true` | Enable dynamic Agents |
141
- | `dm.createAgentOnFirstMessage` | boolean | `true` | Use dynamic Agent for DMs |
142
- | `groupChat.enabled` | boolean | `true` | Enable group chat handling |
122
+ | `dynamicAgents.enabled` | boolean | `true` | Enable dynamic agents |
123
+ | `dm.createAgentOnFirstMessage` | boolean | `true` | Use dynamic agents for DMs |
124
+ | `groupChat.enabled` | boolean | `true` | Enable group chat processing |
143
125
  | `groupChat.requireMention` | boolean | `true` | Require @mention in groups |
144
126
 
145
- To route all messages to the default Agent:
127
+ ### Disable Dynamic Agents
128
+
129
+ To route all messages to the default agent:
146
130
 
147
131
  ```json
148
132
  {
@@ -154,11 +138,9 @@ To route all messages to the default Agent:
154
138
  }
155
139
  ```
156
140
 
157
- ## 🛠️ Command Whitelist
158
-
159
- To prevent regular users from executing sensitive Gateway management commands via WeCom messages, this plugin supports a **command whitelist** mechanism. Only commands in the whitelist will be executed; others are ignored.
141
+ ## 🛠️ Command Allowlist
160
142
 
161
- > 💡 **Note**: This configuration is already included in `deploy/openclaw.json.template` and takes effect automatically upon deployment.
143
+ Prevent regular users from executing sensitive Gateway management commands through WeCom messages.
162
144
 
163
145
  ```json
164
146
  {
@@ -173,19 +155,143 @@ To prevent regular users from executing sensitive Gateway management commands vi
173
155
  }
174
156
  ```
175
157
 
176
- | Command | Description | Security Level |
177
- |---------|-------------|----------------|
178
- | `/new` | Reset conversation, start fresh | User-level |
179
- | `/compact` | Compress conversation context | ✅ User-level |
158
+ ### Recommended Allowlist Commands
159
+
160
+ | Command | Description | Safety Level |
161
+ |---------|-------------|--------------|
162
+ | `/new` | Reset conversation, start new session | ✅ User-level |
163
+ | `/compact` | Compress current session context | ✅ User-level |
180
164
  | `/help` | Show help information | ✅ User-level |
181
165
  | `/status` | Show Agent status | ✅ User-level |
182
166
 
183
- > ⚠️ **Security Note**: Do not add `/gateway`, `/plugins`, or other management commands to the whitelist to prevent regular users from gaining Gateway instance admin privileges.
167
+ > ⚠️ **Security Note**: Do not add `/gateway`, `/plugins`, or other management commands to the allowlist to prevent regular users from gaining Gateway instance admin privileges.
168
+
169
+ ## ❓ FAQ
170
+
171
+ ### Q: What plugin ID should I use in the configuration file?
172
+
173
+ **A:** Use the **complete plugin ID** in `plugins.entries`:
174
+
175
+ ```json
176
+ {
177
+ "plugins": {
178
+ "entries": {
179
+ "openclaw-plugin-wecom": { "enabled": true } // ✅ Correct
180
+ }
181
+ }
182
+ }
183
+ ```
184
+
185
+ **Do not** use the channel id:
186
+ ```json
187
+ {
188
+ "plugins": {
189
+ "entries": {
190
+ "wecom": { "enabled": true } // ❌ Incorrect
191
+ }
192
+ }
193
+ }
194
+ ```
195
+
196
+ ### Q: Why does `openclaw doctor` keep reporting "wecom configured, not enabled yet"?
197
+
198
+ **A:** Add `"deny": ["wecom"]` to your `plugins` configuration:
199
+
200
+ ```json
201
+ {
202
+ "plugins": {
203
+ "deny": ["wecom"],
204
+ "entries": {
205
+ "openclaw-plugin-wecom": {
206
+ "enabled": true
207
+ }
208
+ }
209
+ }
210
+ }
211
+ ```
212
+
213
+ **Reason:** OpenClaw tries to auto-enable built-in channel configurations with the id `wecom`. Adding `deny` prevents this auto-enablement, ensuring only the `openclaw-plugin-wecom` plugin is used.
214
+
215
+ ### Q: How does image sending work?
216
+
217
+ **A:** The plugin automatically handles images generated by OpenClaw (such as browser screenshots):
218
+
219
+ - **Local images** (from `~/.openclaw/media/`) are automatically encoded to base64 and sent via WeCom's `msg_item` API
220
+ - **Image constraints**: Max 2MB per image, supports JPG and PNG formats, up to 10 images per message
221
+ - **No configuration needed**: Works out of the box with tools like browser screenshot
222
+ - Images appear when the AI completes its response (streaming doesn't support incremental image sending)
223
+
224
+ **Example:**
225
+ ```
226
+ User: "Take a screenshot of GitHub homepage"
227
+ AI: [Takes screenshot] → Image displays properly in WeCom ✅
228
+ ```
229
+
230
+ If an image fails to process (size limit, invalid format), the text response will still be delivered and an error will be logged.
231
+
232
+ ### Q: How to configure auth token for public-facing OpenClaw with WeCom callbacks?
233
+
234
+ **A:** WeCom bot **does not need** OpenClaw's Gateway Auth Token.
235
+
236
+ - **Gateway Auth Token** (`gateway.auth.token`) is used for:
237
+ - WebUI access authentication
238
+ - WebSocket connection authentication
239
+ - CLI remote connection authentication
240
+
241
+ - **WeCom Webhook** (`/webhooks/wecom`) authentication:
242
+ - Uses WeCom's own signature verification (Token + EncodingAESKey)
243
+ - Does not require Gateway Auth Token
244
+ - OpenClaw plugin system automatically handles webhook routing
245
+
246
+ **Deployment suggestions:**
247
+ 1. If using a reverse proxy (e.g., Nginx), configure authentication exemption for `/webhooks/wecom` path
248
+ 2. Or expose the webhook endpoint on a separate port without Gateway Auth
249
+
250
+ ### Q: How to fix EncodingAESKey length validation failure?
251
+
252
+ **A:** Common causes and solutions:
253
+
254
+ 1. **Check configuration key name**: Ensure correct key name `encodingAesKey` (case-sensitive)
255
+ ```json
256
+ {
257
+ "channels": {
258
+ "wecom": {
259
+ "encodingAesKey": "..." // ✅ Correct
260
+ }
261
+ }
262
+ }
263
+ ```
264
+
265
+ 2. **Check key length**: EncodingAESKey must be exactly 43 characters
266
+ ```bash
267
+ # Check length
268
+ echo -n "your-key" | wc -c
269
+ ```
270
+
271
+ 3. **Check for extra spaces/newlines**: Ensure no leading/trailing whitespace in the key string
272
+
273
+ ## 📂 Project Structure
274
+
275
+ ```
276
+ openclaw-plugin-wecom/
277
+ ├── index.js # Plugin entry point
278
+ ├── webhook.js # WeCom HTTP communication handler
279
+ ├── dynamic-agent.js # Dynamic agent routing logic
280
+ ├── stream-manager.js # Streaming response manager
281
+ ├── crypto.js # WeCom encryption algorithms
282
+ ├── client.js # Client logic
283
+ ├── logger.js # Logging module
284
+ ├── utils.js # Utility functions
285
+ ├── package.json # npm package config
286
+ └── openclaw.plugin.json # OpenClaw plugin manifest
287
+ ```
184
288
 
185
289
  ## 🤝 Contributing
186
290
 
187
291
  We welcome contributions! Please submit Issues or Pull Requests for bugs or feature suggestions.
188
292
 
293
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for details.
294
+
189
295
  ## 📄 License
190
296
 
191
297
  This project is licensed under the [ISC License](./LICENSE).
package/README_ZH.md CHANGED
@@ -9,132 +9,114 @@
9
9
  - 🌊 **流式输出 (Streaming)**: 基于企业微信最新的 AI 机器人流式分片机制,实现流畅的打字机式回复体验。
10
10
  - 🤖 **动态 Agent 管理**: 默认按"每个私聊用户 / 每个群聊"自动创建独立 Agent。每个 Agent 拥有独立的工作区与对话上下文,实现更强的数据隔离。
11
11
  - 👥 **群聊深度集成**: 支持群聊消息解析,可通过 @提及(At-mention)精准触发机器人响应。
12
+ - 🖼️ **图片支持**: 自动将本地图片(截图、生成的图像)进行 base64 编码并发送,无需额外配置。
12
13
  - 🛠️ **指令增强**: 内置常用指令支持(如 `/new` 开启新会话、`/status` 查看状态等),并提供指令白名单配置功能。
13
14
  - 🔒 **安全与认证**: 完整支持企业微信消息加解密、URL 验证及发送者身份校验。
14
15
  - ⚡ **高性能异步处理**: 采用异步消息处理架构,确保即使在长耗时 AI 推理过程中,企业微信网关也能保持高响应性。
15
16
 
16
- ## 🚀 快速开始
17
+ ## 📋 前置要求
17
18
 
18
- ### 方式一:Docker 一键部署(推荐)
19
+ - 已安装 [OpenClaw](https://github.com/openclaw/openclaw) (版本 2026.1.30+)
20
+ - 企业微信管理后台权限,可创建智能机器人应用
21
+ - 可从企业微信访问的服务器地址(HTTP/HTTPS)
19
22
 
20
- 本仓库提供了完整的 Docker 部署方案,**一键部署 OpenClaw + 企业微信插件**,包含自动化安装和配置。
23
+ ## 🚀 安装
21
24
 
22
- ```bash
23
- # 1. 克隆仓库
24
- git clone https://github.com/sunnoy/openclaw-plugin-wecom.git
25
- cd openclaw-plugin-wecom/deploy
26
-
27
- # 2. 复制环境变量配置
28
- cp .env.example .env
29
-
30
- # 3. 编辑 .env 文件,填写实际配置
31
- vim .env
32
-
33
- # 4. 运行部署脚本
34
- ./deploy.sh
35
- ```
36
-
37
- 部署脚本会自动执行:
38
- - 创建数据目录和设置权限
39
- - 生成配置文件
40
- - 启动 Docker 容器
41
- - 安装企业微信插件
42
- - 配置并重启服务
43
-
44
- #### 🌟 部署亮点
45
-
46
- **自定义数据目录 & Agent Workspace 路径**
47
-
48
- 本部署方案的核心优势是将所有数据统一存储到自定义路径,有效利用数据盘:
49
-
50
- ```bash
51
- # .env 配置示例
52
- OPENCLAW_DATA_DIR=/data/openclaw # 自定义数据目录
53
- ```
54
-
55
- - **OpenClaw 状态目录**:`/data/openclaw/`
56
- - **动态 Agent Workspace**:`/data/openclaw/.openclaw/`
57
- - **插件目录**:`/data/openclaw/extensions/`
58
- - **Canvas 数据**:`/data/openclaw/canvas/`
59
-
60
- 这意味着:
61
- - ✅ 所有 Agent 工作区数据存储在数据盘,避免占用系统盘空间
62
- - ✅ 每个用户/群聊的独立 Agent 文件都在统一路径下管理
63
- - ✅ 方便备份、迁移和扩容
64
- - ✅ 适合企业级部署,数据盘可独立挂载和扩展
65
-
66
- ### 方式二:手动安装插件
67
-
68
- 在已有的 OpenClaw 环境中安装:
25
+ ### 方式一:使用 OpenClaw CLI(推荐)
69
26
 
70
27
  ```bash
71
28
  openclaw plugins install openclaw-plugin-wecom
72
29
  ```
73
30
 
74
- 或通过 npm
31
+ ### 方式二:使用 npm
75
32
 
76
33
  ```bash
77
34
  npm install openclaw-plugin-wecom
78
35
  ```
79
36
 
80
- 然后在 OpenClaw 配置文件中添加:
37
+ ## ⚙️ 配置
38
+
39
+ 在 OpenClaw 配置文件(`~/.openclaw/openclaw.json`)中添加:
81
40
 
82
41
  ```json
83
42
  {
84
43
  "plugins": {
44
+ "deny": ["wecom"],
85
45
  "entries": {
86
- "wecom": { "enabled": true }
46
+ "openclaw-plugin-wecom": {
47
+ "enabled": true
48
+ }
87
49
  }
88
50
  },
89
51
  "channels": {
90
52
  "wecom": {
91
53
  "enabled": true,
92
54
  "token": "你的 Token",
93
- "encodingAesKey": "你的 EncodingAESKey"
55
+ "encodingAesKey": "你的 EncodingAESKey",
56
+ "commands": {
57
+ "enabled": true,
58
+ "allowlist": ["/new", "/status", "/help", "/compact"]
59
+ }
94
60
  }
95
61
  }
96
62
  }
97
63
  ```
98
64
 
99
- ### 企业微信后台设置
65
+ ### 配置说明
100
66
 
101
- 1. 在企业微信管理后台创建一个"智能机器人"。
102
- 2. 将机器人的"接收消息配置"中的 URL 设置为你的服务地址(例如:`https://your-domain.com/webhooks/wecom`)。
103
- 3. 填入对应的 Token EncodingAESKey。
67
+ | 配置项 | 类型 | 必填 | 说明 |
68
+ |--------|------|------|------|
69
+ | `plugins.deny` | array | 推荐 | 添加 `["wecom"]` 防止 OpenClaw 自动启用内置 channel |
70
+ | `plugins.entries.openclaw-plugin-wecom.enabled` | boolean | 是 | 启用插件 |
71
+ | `channels.wecom.token` | string | 是 | 企业微信机器人 Token |
72
+ | `channels.wecom.encodingAesKey` | string | 是 | 企业微信消息加密密钥(43 位) |
73
+ | `channels.wecom.commands.allowlist` | array | 否 | 允许的指令白名单 |
104
74
 
105
- ## 📂 项目结构
75
+ ## 🔌 企业微信后台配置
106
76
 
107
- ```
108
- openclaw-plugin-wecom/
109
- ├── deploy/ # 部署相关文件
110
- ├── deploy.sh # 一键部署脚本
111
- ├── docker-compose.yml # Docker Compose 配置
112
- ├── .env.example # 环境变量示例
113
- │ ├── openclaw.json.base # 基础配置模板
114
- │ └── openclaw.json.template # 完整配置模板
115
- ├── Dockerfile # OpenClaw 镜像构建文件
116
- ├── local.sh # 本地镜像构建脚本
117
- ├── index.js # 插件入口
118
- ├── webhook.js # 企业微信 HTTP 通信处理
119
- ├── dynamic-agent.js # 动态 Agent 分配逻辑
120
- ├── stream-manager.js # 流式回复管理
121
- ├── crypto.js # 企业微信加密算法
122
- └── client.js # 客户端逻辑
123
- ```
77
+ 1. 登录[企业微信管理后台](https://work.weixin.qq.com/)
78
+ 2. 进入"应用管理" → "应用" → "创建应用" → 选择"智能机器人"
79
+ 3. 在"接收消息配置"中设置:
80
+ - **URL**: `https://your-domain.com/webhooks/wecom`
81
+ - **Token**: `channels.wecom.token` 一致
82
+ - **EncodingAESKey**: 与 `channels.wecom.encodingAesKey` 一致
83
+ 4. 保存配置并启用消息接收
124
84
 
125
85
  ## 🤖 动态 Agent 路由
126
86
 
127
- OpenClaw 会通过解析 `SessionKey` 来决定本次消息由哪个 Agent 处理。本插件实现"按人/按群隔离":
87
+ 本插件实现"按人/按群隔离"的 Agent 管理:
88
+
89
+ ### 工作原理
128
90
 
129
91
  1. 企业微信消息到达后,插件生成确定性的 `agentId`:
130
- - 私聊:`wecom-dm-<userId>`
131
- - 群聊:`wecom-group-<chatId>`
132
- 2. OpenClaw 自动创建/复用对应的 Agent 工作区。
92
+ - **私聊**: `wecom-dm-<userId>`
93
+ - **群聊**: `wecom-group-<chatId>`
94
+ 2. OpenClaw 自动创建/复用对应的 Agent 工作区
95
+ 3. 每个用户/群聊拥有独立的对话历史和上下文
133
96
 
134
- ### 配置选项
97
+ ### 高级配置
135
98
 
136
99
  配置在 `channels.wecom` 下:
137
100
 
101
+ ```json
102
+ {
103
+ "channels": {
104
+ "wecom": {
105
+ "dynamicAgents": {
106
+ "enabled": true
107
+ },
108
+ "dm": {
109
+ "createAgentOnFirstMessage": true
110
+ },
111
+ "groupChat": {
112
+ "enabled": true,
113
+ "requireMention": true
114
+ }
115
+ }
116
+ }
117
+ }
118
+ ```
119
+
138
120
  | 配置项 | 类型 | 默认值 | 说明 |
139
121
  |--------|------|--------|------|
140
122
  | `dynamicAgents.enabled` | boolean | `true` | 是否启用动态 Agent |
@@ -142,6 +124,8 @@ OpenClaw 会通过解析 `SessionKey` 来决定本次消息由哪个 Agent 处
142
124
  | `groupChat.enabled` | boolean | `true` | 启用群聊处理 |
143
125
  | `groupChat.requireMention` | boolean | `true` | 群聊必须 @ 提及才响应 |
144
126
 
127
+ ### 禁用动态 Agent
128
+
145
129
  如果需要所有消息进入默认 Agent:
146
130
 
147
131
  ```json
@@ -156,9 +140,7 @@ OpenClaw 会通过解析 `SessionKey` 来决定本次消息由哪个 Agent 处
156
140
 
157
141
  ## 🛠️ 指令白名单
158
142
 
159
- 为防止普通用户通过企业微信消息执行敏感的 Gateway 管理指令,本插件支持**指令白名单**机制。只有配置在白名单中的指令才会被执行,其他指令将被忽略。
160
-
161
- > 💡 **提示**:此配置已包含在 `deploy/openclaw.json.template` 中,部署时会自动生效。
143
+ 为防止普通用户通过企业微信消息执行敏感的 Gateway 管理指令,本插件支持**指令白名单**机制。
162
144
 
163
145
  ```json
164
146
  {
@@ -173,6 +155,8 @@ OpenClaw 会通过解析 `SessionKey` 来决定本次消息由哪个 Agent 处
173
155
  }
174
156
  ```
175
157
 
158
+ ### 推荐白名单指令
159
+
176
160
  | 指令 | 说明 | 安全级别 |
177
161
  |------|------|----------|
178
162
  | `/new` | 重置当前对话,开启全新会话 | ✅ 用户级 |
@@ -182,10 +166,132 @@ OpenClaw 会通过解析 `SessionKey` 来决定本次消息由哪个 Agent 处
182
166
 
183
167
  > ⚠️ **安全提示**:不要将 `/gateway`、`/plugins` 等管理指令添加到白名单,避免普通用户获得 Gateway 实例的管理权限。
184
168
 
169
+ ## ❓ 常见问题 (FAQ)
170
+
171
+ ### Q: 配置文件中的插件 ID 应该使用什么?
172
+
173
+ **A:** 在 `plugins.entries` 中,应该使用**完整的插件 ID**:
174
+
175
+ ```json
176
+ {
177
+ "plugins": {
178
+ "entries": {
179
+ "openclaw-plugin-wecom": { "enabled": true } // ✅ 正确
180
+ }
181
+ }
182
+ }
183
+ ```
184
+
185
+ **不要**使用 channel id:
186
+ ```json
187
+ {
188
+ "plugins": {
189
+ "entries": {
190
+ "wecom": { "enabled": true } // ❌ 错误
191
+ }
192
+ }
193
+ }
194
+ ```
195
+
196
+ ### Q: 为什么 `openclaw doctor` 一直报错 "wecom configured, not enabled yet"?
197
+
198
+ **A:** 需要在 `plugins` 配置中添加 `"deny": ["wecom"]`:
199
+
200
+ ```json
201
+ {
202
+ "plugins": {
203
+ "deny": ["wecom"],
204
+ "entries": {
205
+ "openclaw-plugin-wecom": {
206
+ "enabled": true
207
+ }
208
+ }
209
+ }
210
+ }
211
+ ```
212
+
213
+ **原因:** OpenClaw 会尝试自动启用 channel id 为 `wecom` 的内置插件配置,添加 `deny` 可以防止这种自动启用,确保只使用 `openclaw-plugin-wecom` 插件。
214
+
215
+ ### Q: 图片发送是如何工作的?
216
+
217
+ **A:** 插件会自动处理 OpenClaw 生成的图片(如浏览器截图):
218
+
219
+ - **本地图片**(来自 `~/.openclaw/media/`)会自动进行 base64 编码,通过企业微信 `msg_item` API 发送
220
+ - **图片限制**:单张图片最大 2MB,支持 JPG 和 PNG 格式,每条消息最多 10 张图片
221
+ - **无需配置**:开箱即用,配合浏览器截图等工具自动生效
222
+ - 图片会在 AI 完成回复后显示(流式输出不支持增量发送图片)
223
+
224
+ **示例:**
225
+ ```
226
+ 用户:"帮我截个 GitHub 首页的图"
227
+ AI:[执行截图] → 图片在企业微信中正常显示 ✅
228
+ ```
229
+
230
+ 如果图片处理失败(超出大小限制、格式不支持等),文本回复仍会正常发送,错误信息会记录在日志中。
231
+
232
+ ### Q: OpenClaw 开放公网需要 auth token,企业微信回调如何配置?
233
+
234
+ **A:** 企业微信机器人**不需要**配置 OpenClaw 的 Gateway Auth Token。
235
+
236
+ - **Gateway Auth Token** (`gateway.auth.token`) 主要用于:
237
+ - WebUI 访问认证
238
+ - WebSocket 连接认证
239
+ - CLI 远程连接认证
240
+
241
+ - **企业微信 Webhook** (`/webhooks/wecom`) 的认证机制:
242
+ - 使用企业微信自己的签名验证(Token + EncodingAESKey)
243
+ - 不需要 Gateway Auth Token
244
+ - OpenClaw 插件系统会自动处理 webhook 路由
245
+
246
+ **部署建议:**
247
+ 1. 如果使用反向代理(如 Nginx),可以为 `/webhooks/wecom` 路径配置豁免认证
248
+ 2. 或者将 webhook 端点暴露在独立端口,不经过 Gateway Auth
249
+
250
+ ### Q: EncodingAESKey 长度验证失败怎么办?
251
+
252
+ **A:** 常见原因和解决方法:
253
+
254
+ 1. **检查配置键名**:确保使用正确的键名 `encodingAesKey`(注意大小写)
255
+ ```json
256
+ {
257
+ "channels": {
258
+ "wecom": {
259
+ "encodingAesKey": "..." // ✅ 正确
260
+ }
261
+ }
262
+ }
263
+ ```
264
+
265
+ 2. **检查密钥长度**:EncodingAESKey 必须是 43 位字符
266
+ ```bash
267
+ # 检查长度
268
+ echo -n "你的密钥" | wc -c
269
+ ```
270
+
271
+ 3. **检查是否有多余空格/换行**:确保密钥字符串前后没有空格或换行符
272
+
273
+ ## 📂 项目结构
274
+
275
+ ```
276
+ openclaw-plugin-wecom/
277
+ ├── index.js # 插件入口
278
+ ├── webhook.js # 企业微信 HTTP 通信处理
279
+ ├── dynamic-agent.js # 动态 Agent 分配逻辑
280
+ ├── stream-manager.js # 流式回复管理
281
+ ├── crypto.js # 企业微信加密算法
282
+ ├── client.js # 客户端逻辑
283
+ ├── logger.js # 日志模块
284
+ ├── utils.js # 工具函数
285
+ ├── package.json # npm 包配置
286
+ └── openclaw.plugin.json # OpenClaw 插件清单
287
+ ```
288
+
185
289
  ## 🤝 贡献规范
186
290
 
187
291
  我们非常欢迎开发者参与贡献!如果你发现了 Bug 或有更好的功能建议,请提交 Issue 或 Pull Request。
188
292
 
293
+ 详见 [CONTRIBUTING.md](./CONTRIBUTING.md)
294
+
189
295
  ## 📄 开源协议
190
296
 
191
297
  本项目采用 [ISC License](./LICENSE) 协议。
@@ -0,0 +1,179 @@
1
+ import { readFile } from "fs/promises";
2
+ import { createHash } from "crypto";
3
+ import { logger } from "./logger.js";
4
+
5
+ /**
6
+ * Image Processing Module for WeCom
7
+ *
8
+ * Handles loading, validating, and encoding images for WeCom msg_item
9
+ * Supports JPG and PNG formats up to 2MB
10
+ */
11
+
12
+ // Image format signatures (magic bytes)
13
+ const IMAGE_SIGNATURES = {
14
+ JPG: [0xFF, 0xD8, 0xFF],
15
+ PNG: [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A],
16
+ };
17
+
18
+ // 2MB size limit (before base64 encoding)
19
+ const MAX_IMAGE_SIZE = 2 * 1024 * 1024;
20
+
21
+ /**
22
+ * Load image file from filesystem
23
+ * @param {string} filePath - Absolute path to image file
24
+ * @returns {Promise<Buffer>} Image data buffer
25
+ * @throws {Error} If file not found or cannot be read
26
+ */
27
+ export async function loadImageFromPath(filePath) {
28
+ try {
29
+ logger.debug("Loading image from path", { filePath });
30
+ const buffer = await readFile(filePath);
31
+ logger.debug("Image loaded successfully", {
32
+ filePath,
33
+ size: buffer.length
34
+ });
35
+ return buffer;
36
+ } catch (error) {
37
+ if (error.code === "ENOENT") {
38
+ throw new Error(`Image file not found: ${filePath}`);
39
+ } else if (error.code === "EACCES") {
40
+ throw new Error(`Permission denied reading image: ${filePath}`);
41
+ } else {
42
+ throw new Error(`Failed to read image file: ${error.message}`);
43
+ }
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Convert buffer to base64 string
49
+ * @param {Buffer} buffer - Image data buffer
50
+ * @returns {string} Base64-encoded string
51
+ */
52
+ export function encodeImageToBase64(buffer) {
53
+ return buffer.toString("base64");
54
+ }
55
+
56
+ /**
57
+ * Calculate MD5 checksum of buffer
58
+ * @param {Buffer} buffer - Image data buffer
59
+ * @returns {string} MD5 hash in hexadecimal
60
+ */
61
+ export function calculateMD5(buffer) {
62
+ return createHash("md5").update(buffer).digest("hex");
63
+ }
64
+
65
+ /**
66
+ * Validate image size is within limits
67
+ * @param {Buffer} buffer - Image data buffer
68
+ * @throws {Error} If size exceeds 2MB limit
69
+ */
70
+ export function validateImageSize(buffer) {
71
+ const sizeBytes = buffer.length;
72
+ const sizeMB = (sizeBytes / 1024 / 1024).toFixed(2);
73
+
74
+ if (sizeBytes > MAX_IMAGE_SIZE) {
75
+ throw new Error(
76
+ `Image size ${sizeMB}MB exceeds 2MB limit (actual: ${sizeBytes} bytes)`
77
+ );
78
+ }
79
+
80
+ logger.debug("Image size validated", { sizeBytes, sizeMB });
81
+ }
82
+
83
+ /**
84
+ * Detect image format from magic bytes
85
+ * @param {Buffer} buffer - Image data buffer
86
+ * @returns {string} Format: "JPG" or "PNG"
87
+ * @throws {Error} If format is not supported
88
+ */
89
+ export function detectImageFormat(buffer) {
90
+ // Check PNG signature
91
+ if (buffer.length >= IMAGE_SIGNATURES.PNG.length) {
92
+ const isPNG = IMAGE_SIGNATURES.PNG.every(
93
+ (byte, index) => buffer[index] === byte
94
+ );
95
+ if (isPNG) {
96
+ logger.debug("Image format detected: PNG");
97
+ return "PNG";
98
+ }
99
+ }
100
+
101
+ // Check JPG signature
102
+ if (buffer.length >= IMAGE_SIGNATURES.JPG.length) {
103
+ const isJPG = IMAGE_SIGNATURES.JPG.every(
104
+ (byte, index) => buffer[index] === byte
105
+ );
106
+ if (isJPG) {
107
+ logger.debug("Image format detected: JPG");
108
+ return "JPG";
109
+ }
110
+ }
111
+
112
+ // Unknown format
113
+ const header = buffer.slice(0, 16).toString("hex");
114
+ throw new Error(
115
+ `Unsupported image format. Only JPG and PNG are supported. ` +
116
+ `File header: ${header}`
117
+ );
118
+ }
119
+
120
+ /**
121
+ * Complete image processing pipeline
122
+ *
123
+ * Loads image from filesystem, validates format and size,
124
+ * then encodes to base64 and calculates MD5 checksum.
125
+ *
126
+ * @param {string} filePath - Absolute path to image file
127
+ * @returns {Promise<Object>} Processed image data
128
+ * @returns {string} return.base64 - Base64-encoded image data
129
+ * @returns {string} return.md5 - MD5 checksum
130
+ * @returns {string} return.format - Image format (JPG or PNG)
131
+ * @returns {number} return.size - Original size in bytes
132
+ *
133
+ * @throws {Error} If any step fails (file not found, invalid format, size exceeded, etc.)
134
+ *
135
+ * @example
136
+ * const result = await prepareImageForMsgItem('/path/to/image.jpg');
137
+ * // Returns: { base64: "...", md5: "...", format: "JPG", size: 123456 }
138
+ */
139
+ export async function prepareImageForMsgItem(filePath) {
140
+ logger.debug("Starting image processing pipeline", { filePath });
141
+
142
+ try {
143
+ // Step 1: Load image
144
+ const buffer = await loadImageFromPath(filePath);
145
+
146
+ // Step 2: Validate size
147
+ validateImageSize(buffer);
148
+
149
+ // Step 3: Detect format
150
+ const format = detectImageFormat(buffer);
151
+
152
+ // Step 4: Encode to base64
153
+ const base64 = encodeImageToBase64(buffer);
154
+
155
+ // Step 5: Calculate MD5
156
+ const md5 = calculateMD5(buffer);
157
+
158
+ logger.info("Image processed successfully", {
159
+ filePath,
160
+ format,
161
+ size: buffer.length,
162
+ md5,
163
+ base64Length: base64.length
164
+ });
165
+
166
+ return {
167
+ base64,
168
+ md5,
169
+ format,
170
+ size: buffer.length
171
+ };
172
+ } catch (error) {
173
+ logger.error("Image processing failed", {
174
+ filePath,
175
+ error: error.message
176
+ });
177
+ throw error;
178
+ }
179
+ }
package/index.js CHANGED
@@ -184,7 +184,7 @@ const wecomChannelPlugin = {
184
184
  chatTypes: ["direct", "group"], // 支持私聊和群聊
185
185
  reactions: false,
186
186
  threads: false,
187
- media: false,
187
+ media: true, // Supports image sending via base64 encoding
188
188
  nativeCommands: false,
189
189
  blockStreaming: true, // WeCom AI Bot uses stream response format
190
190
  },
@@ -264,8 +264,60 @@ const wecomChannelPlugin = {
264
264
  const streamId = activeStreams.get(userId);
265
265
 
266
266
  if (streamId && streamManager.hasStream(streamId)) {
267
+ // Check if mediaUrl is a local path (sandbox: prefix or absolute path)
268
+ const isLocalPath = mediaUrl.startsWith("sandbox:") || mediaUrl.startsWith("/");
269
+
270
+ if (isLocalPath) {
271
+ // Convert sandbox: URLs to absolute paths
272
+ // Support both sandbox:/ and sandbox:// formats
273
+ const absolutePath = mediaUrl
274
+ .replace(/^sandbox:\/\//, "")
275
+ .replace(/^sandbox:\//, "");
276
+
277
+ logger.debug("Queueing local image for stream", {
278
+ userId,
279
+ streamId,
280
+ mediaUrl,
281
+ absolutePath
282
+ });
283
+
284
+ // Queue the image for processing when stream finishes
285
+ const queued = streamManager.queueImage(streamId, absolutePath);
286
+
287
+ if (queued) {
288
+ // Append text content to stream (without markdown image)
289
+ if (text) {
290
+ const stream = streamManager.getStream(streamId);
291
+ const separator = stream && stream.content.length > 0 ? "\n\n" : "";
292
+ streamManager.appendStream(streamId, separator + text);
293
+ }
294
+
295
+ // Append placeholder indicating image will follow
296
+ const imagePlaceholder = "\n\n[图片]";
297
+ streamManager.appendStream(streamId, imagePlaceholder);
298
+
299
+ return {
300
+ channel: "wecom",
301
+ messageId: `msg_stream_img_${Date.now()}`,
302
+ };
303
+ } else {
304
+ logger.warn("Failed to queue image, falling back to markdown", {
305
+ userId,
306
+ streamId,
307
+ mediaUrl
308
+ });
309
+ // Fallback to old behavior
310
+ }
311
+ }
312
+
313
+ // OLD BEHAVIOR: For external URLs or if queueing failed, use markdown
267
314
  const content = text ? `${text}\n\n![image](${mediaUrl})` : `![image](${mediaUrl})`;
268
- logger.debug("Appending outbound media to stream", { userId, streamId, mediaUrl });
315
+ logger.debug("Appending outbound media to stream (markdown)", {
316
+ userId,
317
+ streamId,
318
+ mediaUrl
319
+ });
320
+
269
321
  // 使用 appendStream 追加内容
270
322
  const stream = streamManager.getStream(streamId);
271
323
  const separator = stream && stream.content.length > 0 ? "\n\n" : "";
@@ -412,10 +464,10 @@ async function wecomHttpHandler(req, res) {
412
464
  nonce,
413
465
  account: target.account,
414
466
  config: target.config,
415
- }).catch((err) => {
467
+ }).catch(async (err) => {
416
468
  logger.error("WeCom message processing failed", { error: err.message });
417
469
  // 即使失败也要标记流为完成
418
- streamManager.finishStream(streamId);
470
+ await streamManager.finishStream(streamId);
419
471
  });
420
472
 
421
473
  return true;
@@ -450,7 +502,11 @@ async function wecomHttpHandler(req, res) {
450
502
  stream.content,
451
503
  stream.finished,
452
504
  timestamp,
453
- nonce
505
+ nonce,
506
+ // Pass msgItem when stream is finished and has images
507
+ stream.finished && stream.msgItem.length > 0
508
+ ? { msgItem: stream.msgItem }
509
+ : {}
454
510
  );
455
511
 
456
512
  res.writeHead(200, { "Content-Type": "application/json" });
@@ -495,7 +551,7 @@ async function wecomHttpHandler(req, res) {
495
551
  const streamId = `welcome_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
496
552
  streamManager.createStream(streamId);
497
553
  streamManager.appendStream(streamId, welcomeMessage);
498
- streamManager.finishStream(streamId);
554
+ await streamManager.finishStream(streamId);
499
555
 
500
556
  const streamResponse = webhook.buildStreamResponse(
501
557
  streamId,
@@ -592,7 +648,7 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
592
648
  // 通过流式响应返回拦截消息
593
649
  if (streamId) {
594
650
  streamManager.appendStream(streamId, cmdConfig.blockMessage);
595
- streamManager.finishStream(streamId);
651
+ await streamManager.finishStream(streamId);
596
652
  activeStreams.delete(streamKey);
597
653
  }
598
654
  return;
@@ -713,15 +769,15 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
713
769
 
714
770
  // 如果是最终回复,标记流为完成
715
771
  if (streamId && info.kind === "final") {
716
- streamManager.finishStream(streamId);
772
+ await streamManager.finishStream(streamId);
717
773
  logger.info("WeCom stream finished", { streamId });
718
774
  }
719
775
  },
720
- onError: (err, info) => {
776
+ onError: async (err, info) => {
721
777
  logger.error("WeCom reply failed", { error: err.message, kind: info.kind });
722
778
  // 发生错误时也标记流为完成
723
779
  if (streamId) {
724
- streamManager.finishStream(streamId);
780
+ await streamManager.finishStream(streamId);
725
781
  }
726
782
  },
727
783
  },
@@ -729,7 +785,7 @@ async function processInboundMessage({ message, streamId, timestamp, nonce, acco
729
785
 
730
786
  // 确保在dispatch完成后标记流为完成(兜底机制)
731
787
  if (streamId) {
732
- streamManager.finishStream(streamId);
788
+ await streamManager.finishStream(streamId);
733
789
  activeStreams.delete(streamKey); // 清理活跃流映射
734
790
  logger.info("WeCom stream finished (dispatch complete)", { streamId });
735
791
  }
@@ -807,6 +863,7 @@ async function deliverWecomReply({ payload, account, responseUrl, senderId, stre
807
863
  // =============================================================================
808
864
 
809
865
  const plugin = {
866
+ // Plugin id should match `openclaw.plugin.json` id (and config.plugins.entries key).
810
867
  id: "openclaw-plugin-wecom",
811
868
  name: "Enterprise WeChat",
812
869
  description: "Enterprise WeChat AI Bot channel plugin for OpenClaw",
package/logger.js CHANGED
@@ -1,12 +1,30 @@
1
1
  /**
2
2
  * Structured logging for WeCom plugin
3
3
  */
4
+ const LEVELS = {
5
+ debug: 10,
6
+ info: 20,
7
+ warn: 30,
8
+ error: 40,
9
+ silent: 100,
10
+ };
11
+
12
+ function getEnvLogLevel() {
13
+ const raw = (process.env.WECOM_LOG_LEVEL || process.env.LOG_LEVEL || "info").toLowerCase();
14
+ return Object.prototype.hasOwnProperty.call(LEVELS, raw) ? raw : "info";
15
+ }
16
+
4
17
  export class Logger {
5
18
  prefix;
6
- constructor(prefix = "[wecom]") {
19
+ level;
20
+ constructor(prefix = "[wecom]", level = getEnvLogLevel()) {
7
21
  this.prefix = prefix;
22
+ this.level = level;
8
23
  }
9
24
  log(level, message, context) {
25
+ if (LEVELS[level] < LEVELS[this.level]) {
26
+ return;
27
+ }
10
28
  const timestamp = new Date().toISOString();
11
29
  const contextStr = context ? ` ${JSON.stringify(context)}` : "";
12
30
  const logMessage = `${timestamp} ${level.toUpperCase()} ${this.prefix} ${message}${contextStr}`;
@@ -38,9 +56,9 @@ export class Logger {
38
56
  this.log("error", message, context);
39
57
  }
40
58
  child(subPrefix) {
41
- return new Logger(`${this.prefix}:${subPrefix}`);
59
+ return new Logger(`${this.prefix}:${subPrefix}`, this.level);
42
60
  }
43
61
  }
44
62
  // Default logger instance
45
63
  export const logger = new Logger();
46
- //# sourceMappingURL=logger.js.map
64
+ //# sourceMappingURL=logger.js.map
@@ -10,4 +10,4 @@
10
10
  "additionalProperties": false,
11
11
  "properties": {}
12
12
  }
13
- }
13
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-plugin-wecom",
3
- "version": "1.0.0",
3
+ "version": "1.1.0",
4
4
  "description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -9,6 +9,7 @@
9
9
  "client.js",
10
10
  "crypto.js",
11
11
  "dynamic-agent.js",
12
+ "image-processor.js",
12
13
  "logger.js",
13
14
  "README.md",
14
15
  "README_ZH.md",
@@ -47,7 +48,7 @@
47
48
  "keywords": [
48
49
  "openclaw",
49
50
  "wecom",
50
- "wecom",
51
+ "enterprise-wechat",
51
52
  "chat",
52
53
  "plugin"
53
54
  ],
package/stream-manager.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { logger } from "./logger.js";
2
+ import { prepareImageForMsgItem } from "./image-processor.js";
2
3
 
3
4
  /**
4
5
  * 流式消息状态管理器
@@ -6,7 +7,7 @@ import { logger } from "./logger.js";
6
7
  */
7
8
  class StreamManager {
8
9
  constructor() {
9
- // streamId -> { content: string, finished: boolean, updatedAt: number, feedbackId: string|null, msgItem: Array }
10
+ // streamId -> { content: string, finished: boolean, updatedAt: number, feedbackId: string|null, msgItem: Array, pendingImages: Array }
10
11
  this.streams = new Map();
11
12
  this._cleanupInterval = null;
12
13
  }
@@ -42,6 +43,7 @@ class StreamManager {
42
43
  updatedAt: Date.now(),
43
44
  feedbackId: options.feedbackId || null, // 用户反馈追踪
44
45
  msgItem: [], // 图文混排消息列表
46
+ pendingImages: [], // 待处理的图片路径列表
45
47
  });
46
48
  return streamId;
47
49
  }
@@ -117,9 +119,101 @@ class StreamManager {
117
119
  }
118
120
 
119
121
  /**
120
- * 标记流为完成状态
122
+ * Queue image for inclusion when stream finishes
123
+ * @param {string} streamId - 流ID
124
+ * @param {string} imagePath - 图片绝对路径
125
+ * @returns {boolean} 是否成功队列
121
126
  */
122
- finishStream(streamId) {
127
+ queueImage(streamId, imagePath) {
128
+ this.startCleanup();
129
+ const stream = this.streams.get(streamId);
130
+ if (!stream) {
131
+ logger.warn("Stream not found for queueImage", { streamId });
132
+ return false;
133
+ }
134
+
135
+ stream.pendingImages.push({
136
+ path: imagePath,
137
+ queuedAt: Date.now()
138
+ });
139
+
140
+ logger.debug("Image queued for stream", {
141
+ streamId,
142
+ imagePath,
143
+ totalQueued: stream.pendingImages.length
144
+ });
145
+
146
+ return true;
147
+ }
148
+
149
+ /**
150
+ * Process all pending images and build msgItem array
151
+ * @param {string} streamId - 流ID
152
+ * @returns {Promise<Array>} msg_item 数组
153
+ */
154
+ async processPendingImages(streamId) {
155
+ const stream = this.streams.get(streamId);
156
+ if (!stream || stream.pendingImages.length === 0) {
157
+ return [];
158
+ }
159
+
160
+ logger.debug("Processing pending images", {
161
+ streamId,
162
+ count: stream.pendingImages.length
163
+ });
164
+
165
+ const msgItems = [];
166
+
167
+ for (const img of stream.pendingImages) {
168
+ try {
169
+ // Limit to 10 images per WeCom API spec
170
+ if (msgItems.length >= 10) {
171
+ logger.warn("Stream exceeded 10 image limit, truncating", {
172
+ streamId,
173
+ total: stream.pendingImages.length,
174
+ processed: msgItems.length
175
+ });
176
+ break;
177
+ }
178
+
179
+ const processed = await prepareImageForMsgItem(img.path);
180
+ msgItems.push({
181
+ msgtype: "image",
182
+ image: {
183
+ base64: processed.base64,
184
+ md5: processed.md5
185
+ }
186
+ });
187
+
188
+ logger.debug("Image processed successfully", {
189
+ streamId,
190
+ imagePath: img.path,
191
+ format: processed.format,
192
+ size: processed.size
193
+ });
194
+ } catch (error) {
195
+ logger.error("Failed to process image for stream", {
196
+ streamId,
197
+ imagePath: img.path,
198
+ error: error.message
199
+ });
200
+ // Continue processing other images even if one fails
201
+ }
202
+ }
203
+
204
+ logger.info("Completed processing images for stream", {
205
+ streamId,
206
+ processed: msgItems.length,
207
+ pending: stream.pendingImages.length
208
+ });
209
+
210
+ return msgItems;
211
+ }
212
+
213
+ /**
214
+ * 标记流为完成状态(异步,处理待发送的图片)
215
+ */
216
+ async finishStream(streamId) {
123
217
  this.startCleanup();
124
218
  const stream = this.streams.get(streamId);
125
219
  if (!stream) {
@@ -127,12 +221,18 @@ class StreamManager {
127
221
  return false;
128
222
  }
129
223
 
224
+ // Process pending images before finishing
225
+ if (stream.pendingImages.length > 0) {
226
+ stream.msgItem = await this.processPendingImages(streamId);
227
+ }
228
+
130
229
  stream.finished = true;
131
230
  stream.updatedAt = Date.now();
132
231
 
133
232
  logger.info("Stream finished", {
134
233
  streamId,
135
- contentLength: stream.content.length
234
+ contentLength: stream.content.length,
235
+ imageCount: stream.msgItem.length
136
236
  });
137
237
 
138
238
  return true;