@sunnoy/wecom 1.1.2 → 1.3.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/stream-manager.js CHANGED
@@ -1,307 +1,358 @@
1
- import { logger } from "./logger.js";
2
1
  import { prepareImageForMsgItem } from "./image-processor.js";
2
+ import { logger } from "./logger.js";
3
3
 
4
4
  /**
5
- * 流式消息状态管理器
6
- * 管理所有活跃的流式消息会话,支持企业微信的流式刷新机制
5
+ * Streaming state manager for WeCom responses.
7
6
  */
8
- class StreamManager {
9
- constructor() {
10
- // streamId -> { content: string, finished: boolean, updatedAt: number, feedbackId: string|null, msgItem: Array, pendingImages: Array }
11
- this.streams = new Map();
12
- this._cleanupInterval = null;
13
- }
14
7
 
15
- /**
16
- * 启动定时清理(避免在 import 时产生常驻 side-effect)
17
- */
18
- startCleanup() {
19
- if (this._cleanupInterval) return;
20
- this._cleanupInterval = setInterval(() => this.cleanup(), 60 * 1000);
21
- // 不阻止进程退出(例如 npm pack / import smoke test)
22
- this._cleanupInterval.unref?.();
23
- }
8
+ /** WeCom enforces a 20480-byte UTF-8 content limit per stream response. */
9
+ const MAX_STREAM_BYTES = 20480;
10
+
11
+ /** Truncate content to MAX_STREAM_BYTES if it exceeds the limit. */
12
+ function enforceByteLimit(content) {
13
+ const contentBytes = Buffer.byteLength(content, "utf8");
14
+ if (contentBytes <= MAX_STREAM_BYTES) {
15
+ return content;
16
+ }
17
+ logger.warn("Stream content exceeds byte limit, truncating", { bytes: contentBytes });
18
+ // Truncate at byte boundary, then trim any broken trailing multi-byte char.
19
+ const buf = Buffer.from(content, "utf8").subarray(0, MAX_STREAM_BYTES);
20
+ return buf.toString("utf8");
21
+ }
24
22
 
25
- stopCleanup() {
26
- if (!this._cleanupInterval) return;
27
- clearInterval(this._cleanupInterval);
28
- this._cleanupInterval = null;
23
+ class StreamManager {
24
+ constructor() {
25
+ // streamId -> { content, finished, updatedAt, feedbackId, msgItem, pendingImages }
26
+ this.streams = new Map();
27
+ this._cleanupInterval = null;
28
+ }
29
+
30
+ /**
31
+ * Start periodic cleanup lazily to avoid import-time side effects.
32
+ */
33
+ startCleanup() {
34
+ if (this._cleanupInterval) {
35
+ return;
29
36
  }
30
-
31
- /**
32
- * 创建新的流式会话
33
- * @param {string} streamId - 流ID
34
- * @param {object} options - 可选配置
35
- * @param {string} options.feedbackId - 反馈追踪ID (最长256字节)
36
- */
37
- createStream(streamId, options = {}) {
38
- this.startCleanup();
39
- logger.debug("Creating stream", { streamId, feedbackId: options.feedbackId });
40
- this.streams.set(streamId, {
41
- content: "",
42
- finished: false,
43
- updatedAt: Date.now(),
44
- feedbackId: options.feedbackId || null, // 用户反馈追踪
45
- msgItem: [], // 图文混排消息列表
46
- pendingImages: [], // 待处理的图片路径列表
47
- });
48
- return streamId;
37
+ this._cleanupInterval = setInterval(() => this.cleanup(), 60 * 1000);
38
+ // Do not keep the process alive for this timer.
39
+ this._cleanupInterval.unref?.();
40
+ }
41
+
42
+ stopCleanup() {
43
+ if (!this._cleanupInterval) {
44
+ return;
49
45
  }
50
-
51
- /**
52
- * 更新流的内容 (增量或全量)
53
- * @param {string} streamId - 流ID
54
- * @param {string} content - 消息内容 (最长20480字节)
55
- * @param {boolean} finished - 是否完成
56
- * @param {object} options - 可选配置
57
- * @param {Array} options.msgItem - 图文混排列表 (仅finish=true时有效)
58
- */
59
- updateStream(streamId, content, finished = false, options = {}) {
60
- this.startCleanup();
61
- const stream = this.streams.get(streamId);
62
- if (!stream) {
63
- logger.warn("Stream not found for update", { streamId });
64
- return false;
65
- }
66
-
67
- // 检查内容长度 (企业微信限制20480字节)
68
- const contentBytes = Buffer.byteLength(content, 'utf8');
69
- if (contentBytes > 20480) {
70
- logger.warn("Stream content exceeds 20480 bytes, truncating", {
71
- streamId,
72
- bytes: contentBytes
73
- });
74
- // 截断到20480字节
75
- content = Buffer.from(content, 'utf8').slice(0, 20480).toString('utf8');
76
- }
77
-
78
- stream.content = content;
79
- stream.finished = finished;
80
- stream.updatedAt = Date.now();
81
-
82
- // 图文混排仅在完成时支持
83
- if (finished && options.msgItem && options.msgItem.length > 0) {
84
- stream.msgItem = options.msgItem.slice(0, 10); // 最多10个
85
- }
86
-
87
- logger.debug("Stream updated", {
88
- streamId,
89
- contentLength: content.length,
90
- contentBytes,
91
- finished,
92
- hasMsgItem: stream.msgItem.length > 0
93
- });
94
-
95
- return true;
46
+ clearInterval(this._cleanupInterval);
47
+ this._cleanupInterval = null;
48
+ }
49
+
50
+ /**
51
+ * Create a new stream session.
52
+ * @param {string} streamId - Stream id
53
+ * @param {object} options - Optional settings
54
+ * @param {string} options.feedbackId - Optional feedback tracking id
55
+ */
56
+ createStream(streamId, options = {}) {
57
+ this.startCleanup();
58
+ logger.debug("Creating stream", { streamId, feedbackId: options.feedbackId });
59
+ this.streams.set(streamId, {
60
+ content: "",
61
+ finished: false,
62
+ updatedAt: Date.now(),
63
+ feedbackId: options.feedbackId || null,
64
+ msgItem: [],
65
+ pendingImages: [],
66
+ });
67
+ return streamId;
68
+ }
69
+
70
+ /**
71
+ * Update stream content (replace mode).
72
+ * @param {string} streamId - Stream id
73
+ * @param {string} content - Message content (max 20480 bytes in UTF-8)
74
+ * @param {boolean} finished - Whether stream is completed
75
+ * @param {object} options - Optional settings
76
+ * @param {Array} options.msgItem - Mixed media list (supported when finished=true)
77
+ */
78
+ updateStream(streamId, content, finished = false, options = {}) {
79
+ this.startCleanup();
80
+ const stream = this.streams.get(streamId);
81
+ if (!stream) {
82
+ logger.warn("Stream not found for update", { streamId });
83
+ return false;
96
84
  }
97
85
 
98
- /**
99
- * 追加内容到流 (用于流式生成)
100
- */
101
- appendStream(streamId, chunk) {
102
- this.startCleanup();
103
- const stream = this.streams.get(streamId);
104
- if (!stream) {
105
- logger.warn("Stream not found for append", { streamId });
106
- return false;
107
- }
108
-
109
- stream.content += chunk;
110
- stream.updatedAt = Date.now();
86
+ content = enforceByteLimit(content);
111
87
 
112
- logger.debug("Stream appended", {
113
- streamId,
114
- chunkLength: chunk.length,
115
- totalLength: stream.content.length
116
- });
88
+ stream.content = content;
89
+ stream.finished = finished;
90
+ stream.updatedAt = Date.now();
117
91
 
118
- return true;
92
+ // Mixed media items are only valid for finished responses.
93
+ if (finished && options.msgItem && options.msgItem.length > 0) {
94
+ stream.msgItem = options.msgItem.slice(0, 10);
119
95
  }
120
96
 
121
- /**
122
- * Queue image for inclusion when stream finishes
123
- * @param {string} streamId - 流ID
124
- * @param {string} imagePath - 图片绝对路径
125
- * @returns {boolean} 是否成功队列
126
- */
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
- }
97
+ logger.debug("Stream updated", {
98
+ streamId,
99
+ contentLength: content.length,
100
+ finished,
101
+ hasMsgItem: stream.msgItem.length > 0,
102
+ });
103
+
104
+ return true;
105
+ }
106
+
107
+ /**
108
+ * Append content to an existing stream.
109
+ */
110
+ appendStream(streamId, chunk) {
111
+ this.startCleanup();
112
+ const stream = this.streams.get(streamId);
113
+ if (!stream) {
114
+ logger.warn("Stream not found for append", { streamId });
115
+ return false;
116
+ }
134
117
 
135
- stream.pendingImages.push({
136
- path: imagePath,
137
- queuedAt: Date.now()
138
- });
118
+ stream.content = enforceByteLimit(stream.content + chunk);
119
+ stream.updatedAt = Date.now();
120
+
121
+ logger.debug("Stream appended", {
122
+ streamId,
123
+ chunkLength: chunk.length,
124
+ totalLength: stream.content.length,
125
+ });
126
+
127
+ return true;
128
+ }
129
+
130
+ /**
131
+ * Replace stream content if it currently contains only the placeholder,
132
+ * otherwise append normally.
133
+ * @param {string} streamId - Stream id
134
+ * @param {string} chunk - New content to write
135
+ * @param {string} placeholder - The placeholder text to detect and replace
136
+ * @returns {boolean} Whether the operation succeeded
137
+ */
138
+ replaceIfPlaceholder(streamId, chunk, placeholder) {
139
+ this.startCleanup();
140
+ const stream = this.streams.get(streamId);
141
+ if (!stream) {
142
+ logger.warn("Stream not found for replaceIfPlaceholder", { streamId });
143
+ return false;
144
+ }
139
145
 
140
- logger.debug("Image queued for stream", {
141
- streamId,
142
- imagePath,
143
- totalQueued: stream.pendingImages.length
144
- });
146
+ if (stream.content.trim() === placeholder.trim()) {
147
+ stream.content = enforceByteLimit(chunk);
148
+ stream.updatedAt = Date.now();
149
+ logger.debug("Stream placeholder replaced", {
150
+ streamId,
151
+ newContentLength: stream.content.length,
152
+ });
153
+ return true;
154
+ }
145
155
 
146
- return true;
156
+ // Normal append behavior.
157
+ stream.content = enforceByteLimit(stream.content + chunk);
158
+ stream.updatedAt = Date.now();
159
+ logger.debug("Stream appended (no placeholder)", {
160
+ streamId,
161
+ chunkLength: chunk.length,
162
+ totalLength: stream.content.length,
163
+ });
164
+ return true;
165
+ }
166
+
167
+ /**
168
+ * Queue image for inclusion when stream finishes
169
+ * @param {string} streamId - Stream id
170
+ * @param {string} imagePath - Absolute image path
171
+ * @returns {boolean} Whether enqueue succeeded
172
+ */
173
+ queueImage(streamId, imagePath) {
174
+ this.startCleanup();
175
+ const stream = this.streams.get(streamId);
176
+ if (!stream) {
177
+ logger.warn("Stream not found for queueImage", { streamId });
178
+ return false;
147
179
  }
148
180
 
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
- }
181
+ stream.pendingImages.push({
182
+ path: imagePath,
183
+ queuedAt: Date.now(),
184
+ });
185
+
186
+ logger.debug("Image queued for stream", {
187
+ streamId,
188
+ imagePath,
189
+ totalQueued: stream.pendingImages.length,
190
+ });
191
+
192
+ return true;
193
+ }
194
+
195
+ /**
196
+ * Process all pending images and build msgItem array
197
+ * @param {string} streamId - Stream id
198
+ * @returns {Promise<Array>} msg_item entries
199
+ */
200
+ async processPendingImages(streamId) {
201
+ const stream = this.streams.get(streamId);
202
+ if (!stream || stream.pendingImages.length === 0) {
203
+ return [];
204
+ }
159
205
 
160
- logger.debug("Processing pending images", {
161
- streamId,
162
- count: stream.pendingImages.length
163
- });
206
+ logger.debug("Processing pending images", {
207
+ streamId,
208
+ count: stream.pendingImages.length,
209
+ });
164
210
 
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
- }
211
+ const msgItems = [];
203
212
 
204
- logger.info("Completed processing images for stream", {
213
+ for (const img of stream.pendingImages) {
214
+ try {
215
+ // Limit to 10 images per WeCom API spec
216
+ if (msgItems.length >= 10) {
217
+ logger.warn("Stream exceeded 10 image limit, truncating", {
205
218
  streamId,
219
+ total: stream.pendingImages.length,
206
220
  processed: msgItems.length,
207
- pending: stream.pendingImages.length
208
- });
209
-
210
- return msgItems;
211
- }
212
-
213
- /**
214
- * 标记流为完成状态(异步,处理待发送的图片)
215
- */
216
- async finishStream(streamId) {
217
- this.startCleanup();
218
- const stream = this.streams.get(streamId);
219
- if (!stream) {
220
- logger.warn("Stream not found for finish", { streamId });
221
- return false;
222
- }
223
-
224
- // Process pending images before finishing
225
- if (stream.pendingImages.length > 0) {
226
- stream.msgItem = await this.processPendingImages(streamId);
221
+ });
222
+ break;
227
223
  }
228
224
 
229
- stream.finished = true;
230
- stream.updatedAt = Date.now();
231
-
232
- logger.info("Stream finished", {
233
- streamId,
234
- contentLength: stream.content.length,
235
- imageCount: stream.msgItem.length
225
+ const processed = await prepareImageForMsgItem(img.path);
226
+ msgItems.push({
227
+ msgtype: "image",
228
+ image: {
229
+ base64: processed.base64,
230
+ md5: processed.md5,
231
+ },
236
232
  });
237
233
 
238
- return true;
234
+ logger.debug("Image processed successfully", {
235
+ streamId,
236
+ imagePath: img.path,
237
+ format: processed.format,
238
+ size: processed.size,
239
+ });
240
+ } catch (error) {
241
+ logger.error("Failed to process image for stream", {
242
+ streamId,
243
+ imagePath: img.path,
244
+ error: error.message,
245
+ });
246
+ // Keep going even when one image fails.
247
+ }
239
248
  }
240
249
 
241
- /**
242
- * 获取流的当前状态
243
- */
244
- getStream(streamId) {
245
- return this.streams.get(streamId);
250
+ logger.info("Completed processing images for stream", {
251
+ streamId,
252
+ processed: msgItems.length,
253
+ pending: stream.pendingImages.length,
254
+ });
255
+
256
+ return msgItems;
257
+ }
258
+
259
+ /**
260
+ * Mark the stream as finished and process queued images.
261
+ */
262
+ async finishStream(streamId) {
263
+ this.startCleanup();
264
+ const stream = this.streams.get(streamId);
265
+ if (!stream) {
266
+ logger.warn("Stream not found for finish", { streamId });
267
+ return false;
246
268
  }
247
269
 
248
- /**
249
- * 检查流是否存在
250
- */
251
- hasStream(streamId) {
252
- return this.streams.has(streamId);
270
+ if (stream.finished) {
271
+ return true;
253
272
  }
254
273
 
255
- /**
256
- * 删除流
257
- */
258
- deleteStream(streamId) {
259
- const deleted = this.streams.delete(streamId);
260
- if (deleted) {
261
- logger.debug("Stream deleted", { streamId });
262
- }
263
- return deleted;
274
+ // Process pending images before finalizing the stream.
275
+ if (stream.pendingImages.length > 0) {
276
+ stream.msgItem = await this.processPendingImages(streamId);
277
+ stream.pendingImages = [];
264
278
  }
265
279
 
266
- /**
267
- * 清理超过10分钟未更新的流
268
- */
269
- cleanup() {
270
- const now = Date.now();
271
- const timeout = 10 * 60 * 1000; // 10 minutes
272
- let cleaned = 0;
273
-
274
- for (const [streamId, stream] of this.streams.entries()) {
275
- if (now - stream.updatedAt > timeout) {
276
- this.streams.delete(streamId);
277
- cleaned++;
278
- }
279
- }
280
-
281
- if (cleaned > 0) {
282
- logger.info("Cleaned up expired streams", { count: cleaned });
283
- }
280
+ stream.finished = true;
281
+ stream.updatedAt = Date.now();
282
+
283
+ logger.info("Stream finished", {
284
+ streamId,
285
+ contentLength: stream.content.length,
286
+ imageCount: stream.msgItem.length,
287
+ });
288
+
289
+ return true;
290
+ }
291
+
292
+ /**
293
+ * Get current stream state.
294
+ */
295
+ getStream(streamId) {
296
+ return this.streams.get(streamId);
297
+ }
298
+
299
+ /**
300
+ * Check whether a stream exists.
301
+ */
302
+ hasStream(streamId) {
303
+ return this.streams.has(streamId);
304
+ }
305
+
306
+ /**
307
+ * Delete a stream.
308
+ */
309
+ deleteStream(streamId) {
310
+ const deleted = this.streams.delete(streamId);
311
+ if (deleted) {
312
+ logger.debug("Stream deleted", { streamId });
313
+ }
314
+ return deleted;
315
+ }
316
+
317
+ /**
318
+ * Remove streams that were inactive for over 10 minutes.
319
+ */
320
+ cleanup() {
321
+ const now = Date.now();
322
+ const timeout = 10 * 60 * 1000;
323
+ let cleaned = 0;
324
+
325
+ for (const [streamId, stream] of this.streams.entries()) {
326
+ if (now - stream.updatedAt > timeout) {
327
+ this.streams.delete(streamId);
328
+ cleaned++;
329
+ }
284
330
  }
285
331
 
286
- /**
287
- * 获取统计信息
288
- */
289
- getStats() {
290
- const total = this.streams.size;
291
- let finished = 0;
292
- let active = 0;
293
-
294
- for (const stream of this.streams.values()) {
295
- if (stream.finished) {
296
- finished++;
297
- } else {
298
- active++;
299
- }
300
- }
301
-
302
- return { total, finished, active };
332
+ if (cleaned > 0) {
333
+ logger.info("Cleaned up expired streams", { count: cleaned });
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Get in-memory stream stats.
339
+ */
340
+ getStats() {
341
+ const total = this.streams.size;
342
+ let finished = 0;
343
+ let active = 0;
344
+
345
+ for (const stream of this.streams.values()) {
346
+ if (stream.finished) {
347
+ finished++;
348
+ } else {
349
+ active++;
350
+ }
303
351
  }
352
+
353
+ return { total, finished, active };
354
+ }
304
355
  }
305
356
 
306
- // 单例实例
357
+ // Shared singleton instance used by the plugin runtime.
307
358
  export const streamManager = new StreamManager();