openclaw-plugin-wecom 1.0.2 → 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
@@ -9,6 +9,7 @@
9
9
  - 🌊 **Streaming Output**: Built on WeCom's latest AI bot streaming mechanism for smooth typewriter-style responses.
10
10
  - 🤖 **Dynamic Agent Management**: Automatically creates isolated agents per direct message user or group chat, with independent workspaces and conversation contexts.
11
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.
12
13
  - 🛠️ **Command Enhancement**: Built-in commands (e.g., `/new` for new sessions, `/status` for status) with allowlist configuration.
13
14
  - 🔒 **Security & Authentication**: Full support for WeCom message encryption/decryption, URL verification, and sender validation.
14
15
  - ⚡ **High-Performance Async Processing**: Asynchronous message architecture ensures responsive gateway even during long AI inference.
@@ -211,6 +212,23 @@ Prevent regular users from executing sensitive Gateway management commands throu
211
212
 
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.
213
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
+
214
232
  ### Q: How to configure auth token for public-facing OpenClaw with WeCom callbacks?
215
233
 
216
234
  **A:** WeCom bot **does not need** OpenClaw's Gateway Auth Token.
package/README_ZH.md CHANGED
@@ -9,6 +9,7 @@
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 推理过程中,企业微信网关也能保持高响应性。
@@ -211,6 +212,23 @@ npm install openclaw-plugin-wecom
211
212
 
212
213
  **原因:** OpenClaw 会尝试自动启用 channel id 为 `wecom` 的内置插件配置,添加 `deny` 可以防止这种自动启用,确保只使用 `openclaw-plugin-wecom` 插件。
213
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
+
214
232
  ### Q: OpenClaw 开放公网需要 auth token,企业微信回调如何配置?
215
233
 
216
234
  **A:** 企业微信机器人**不需要**配置 OpenClaw 的 Gateway Auth Token。
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-plugin-wecom",
3
- "version": "1.0.2",
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;