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 +18 -0
- package/README_ZH.md +18 -0
- package/image-processor.js +179 -0
- package/index.js +67 -11
- package/package.json +3 -2
- package/stream-manager.js +104 -4
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:
|
|
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` : ``;
|
|
268
|
-
logger.debug("Appending outbound media to stream", {
|
|
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
|
|
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
|
-
"
|
|
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
|
-
|
|
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;
|