@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/README.md +465 -144
- package/crypto.js +110 -83
- package/dynamic-agent.js +70 -87
- package/image-processor.js +86 -93
- package/index.js +16 -1068
- package/logger.js +48 -49
- package/package.json +5 -6
- package/stream-manager.js +316 -265
- package/utils.js +76 -238
- package/webhook.js +434 -287
- package/wecom/agent-api.js +251 -0
- package/wecom/agent-inbound.js +433 -0
- package/wecom/allow-from.js +58 -0
- package/wecom/channel-plugin.js +638 -0
- package/wecom/commands.js +85 -0
- package/wecom/constants.js +58 -0
- package/wecom/http-handler.js +315 -0
- package/wecom/inbound-processor.js +519 -0
- package/wecom/media.js +118 -0
- package/wecom/outbound-delivery.js +175 -0
- package/wecom/response-url.js +33 -0
- package/wecom/state.js +82 -0
- package/wecom/stream-utils.js +124 -0
- package/wecom/target.js +57 -0
- package/wecom/webhook-bot.js +155 -0
- package/wecom/webhook-targets.js +28 -0
- package/wecom/workspace-template.js +165 -0
- package/wecom/xml-parser.js +126 -0
- package/README_ZH.md +0 -289
- package/client.js +0 -127
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
totalLength: stream.content.length
|
|
116
|
-
});
|
|
88
|
+
stream.content = content;
|
|
89
|
+
stream.finished = finished;
|
|
90
|
+
stream.updatedAt = Date.now();
|
|
117
91
|
|
|
118
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
206
|
+
logger.debug("Processing pending images", {
|
|
207
|
+
streamId,
|
|
208
|
+
count: stream.pendingImages.length,
|
|
209
|
+
});
|
|
164
210
|
|
|
165
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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();
|