@sunnoy/wecom 1.9.0 → 2.0.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.
@@ -0,0 +1,160 @@
1
+ import {
2
+ MESSAGE_STATE_CLEANUP_INTERVAL_MS,
3
+ MESSAGE_STATE_MAX_SIZE,
4
+ MESSAGE_STATE_TTL_MS,
5
+ PENDING_REPLY_MAX_SIZE,
6
+ PENDING_REPLY_TTL_MS,
7
+ } from "./constants.js";
8
+
9
+ const wsClientInstances = new Map();
10
+ const messageStates = new Map();
11
+
12
+ /**
13
+ * Pending final replies that failed to send due to WS disconnection.
14
+ * Keyed by accountId → array of { text, senderId, chatId, isGroupChat, createdAt }.
15
+ * @type {Map<string, Array<{text: string, senderId: string, chatId: string, isGroupChat: boolean, createdAt: number}>>}
16
+ */
17
+ const pendingReplies = new Map();
18
+
19
+ let cleanupTimer = null;
20
+ let cleanupUsers = 0;
21
+
22
+ function pruneMessageStates() {
23
+ const currentTime = Date.now();
24
+ for (const [messageId, entry] of messageStates.entries()) {
25
+ if (currentTime - entry.createdAt >= MESSAGE_STATE_TTL_MS) {
26
+ messageStates.delete(messageId);
27
+ }
28
+ }
29
+
30
+ if (messageStates.size <= MESSAGE_STATE_MAX_SIZE) {
31
+ return;
32
+ }
33
+
34
+ const oldestFirst = [...messageStates.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
35
+ for (const [messageId] of oldestFirst.slice(0, messageStates.size - MESSAGE_STATE_MAX_SIZE)) {
36
+ messageStates.delete(messageId);
37
+ }
38
+ }
39
+
40
+ export function getWsClient(accountId) {
41
+ return wsClientInstances.get(accountId) ?? null;
42
+ }
43
+
44
+ export function setWsClient(accountId, client) {
45
+ wsClientInstances.set(accountId, client);
46
+ }
47
+
48
+ export function removeWsClient(accountId) {
49
+ const client = wsClientInstances.get(accountId);
50
+ if (client) {
51
+ try {
52
+ client.disconnect();
53
+ } catch {
54
+ // Ignore disconnect errors.
55
+ }
56
+ }
57
+ wsClientInstances.delete(accountId);
58
+ }
59
+
60
+ export function startMessageStateCleanup() {
61
+ cleanupUsers += 1;
62
+ if (cleanupTimer) {
63
+ return;
64
+ }
65
+
66
+ cleanupTimer = setInterval(pruneMessageStates, MESSAGE_STATE_CLEANUP_INTERVAL_MS);
67
+ if (typeof cleanupTimer === "object" && "unref" in cleanupTimer) {
68
+ cleanupTimer.unref();
69
+ }
70
+ }
71
+
72
+ export function stopMessageStateCleanup() {
73
+ cleanupUsers = Math.max(0, cleanupUsers - 1);
74
+ if (cleanupUsers > 0 || !cleanupTimer) {
75
+ return;
76
+ }
77
+
78
+ clearInterval(cleanupTimer);
79
+ cleanupTimer = null;
80
+ }
81
+
82
+ export function setMessageState(messageId, state) {
83
+ messageStates.set(messageId, {
84
+ state,
85
+ createdAt: Date.now(),
86
+ });
87
+ }
88
+
89
+ export function getMessageState(messageId) {
90
+ const entry = messageStates.get(messageId);
91
+ if (!entry) {
92
+ return undefined;
93
+ }
94
+ if (Date.now() - entry.createdAt >= MESSAGE_STATE_TTL_MS) {
95
+ messageStates.delete(messageId);
96
+ return undefined;
97
+ }
98
+ return entry.state;
99
+ }
100
+
101
+ export function deleteMessageState(messageId) {
102
+ messageStates.delete(messageId);
103
+ }
104
+
105
+ /**
106
+ * Enqueue a final reply that failed to send due to WS disconnection.
107
+ */
108
+ export function enqueuePendingReply(accountId, entry) {
109
+ let queue = pendingReplies.get(accountId);
110
+ if (!queue) {
111
+ queue = [];
112
+ pendingReplies.set(accountId, queue);
113
+ }
114
+ queue.push({ ...entry, createdAt: Date.now() });
115
+ // Evict oldest if over capacity.
116
+ while (queue.length > PENDING_REPLY_MAX_SIZE) {
117
+ queue.shift();
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Drain all pending replies for an account, removing expired entries.
123
+ * @returns {Array<{text: string, senderId: string, chatId: string, isGroupChat: boolean, createdAt: number}>}
124
+ */
125
+ export function drainPendingReplies(accountId) {
126
+ const queue = pendingReplies.get(accountId);
127
+ if (!queue || queue.length === 0) {
128
+ return [];
129
+ }
130
+ pendingReplies.delete(accountId);
131
+ const now = Date.now();
132
+ return queue.filter((entry) => now - entry.createdAt < PENDING_REPLY_TTL_MS);
133
+ }
134
+
135
+ /**
136
+ * Check if there are pending replies for an account.
137
+ */
138
+ export function hasPendingReplies(accountId) {
139
+ const queue = pendingReplies.get(accountId);
140
+ return Boolean(queue && queue.length > 0);
141
+ }
142
+
143
+ export async function cleanupWsAccount(accountId) {
144
+ removeWsClient(accountId);
145
+ }
146
+
147
+ export async function resetWsStateForTesting() {
148
+ for (const accountId of [...wsClientInstances.keys()]) {
149
+ removeWsClient(accountId);
150
+ }
151
+
152
+ messageStates.clear();
153
+ pendingReplies.clear();
154
+
155
+ cleanupUsers = 0;
156
+ if (cleanupTimer) {
157
+ clearInterval(cleanupTimer);
158
+ cleanupTimer = null;
159
+ }
160
+ }
package/crypto.js DELETED
@@ -1,135 +0,0 @@
1
- import { createCipheriv, createDecipheriv, randomBytes, createHash } from "node:crypto";
2
- import { logger } from "./logger.js";
3
- import { CONSTANTS } from "./utils.js";
4
-
5
- /**
6
- * Enterprise WeChat Intelligent Robot Crypto Implementation
7
- * Simplified for AI Bot mode (no corpId validation)
8
- */
9
- export class WecomCrypto {
10
- token;
11
- encodingAesKey;
12
- aesKey;
13
- iv;
14
-
15
- constructor(token, encodingAesKey) {
16
- if (!encodingAesKey || encodingAesKey.length !== CONSTANTS.AES_KEY_LENGTH) {
17
- throw new Error(`EncodingAESKey invalid: length must be ${CONSTANTS.AES_KEY_LENGTH}`);
18
- }
19
- if (!token) {
20
- throw new Error("Token is required");
21
- }
22
- this.token = token;
23
- this.encodingAesKey = encodingAesKey;
24
- this.aesKey = Buffer.from(encodingAesKey + "=", "base64");
25
- this.iv = this.aesKey.subarray(0, 16);
26
- logger.debug("WecomCrypto initialized (AI Bot mode)");
27
- }
28
-
29
- getSignature(timestamp, nonce, encrypt) {
30
- const shasum = createHash("sha1");
31
- // WeCom requires plain lexicographic sorting before SHA1; localeCompare is locale-sensitive.
32
- const sorted = [this.token, timestamp, nonce, encrypt]
33
- .map((value) => String(value))
34
- .toSorted();
35
- shasum.update(sorted.join(""));
36
- return shasum.digest("hex");
37
- }
38
-
39
- decrypt(text) {
40
- let decipher;
41
- try {
42
- decipher = createDecipheriv("aes-256-cbc", this.aesKey, this.iv);
43
- decipher.setAutoPadding(false);
44
- } catch (e) {
45
- throw new Error(`Decrypt init failed: ${String(e)}`, { cause: e });
46
- }
47
-
48
- let deciphered = Buffer.concat([decipher.update(text, "base64"), decipher.final()]);
49
-
50
- deciphered = this.decodePkcs7(deciphered);
51
-
52
- // Format: 16 random bytes | 4 bytes msg_len | msg_content | appid
53
- const content = deciphered.subarray(16);
54
- const lenList = content.subarray(0, 4);
55
- const xmlLen = lenList.readUInt32BE(0);
56
- const xmlContent = content.subarray(4, 4 + xmlLen).toString("utf-8");
57
- // For AI Bot mode, corpId/appid is empty, skip validation
58
-
59
- return { message: xmlContent };
60
- }
61
-
62
- encrypt(text) {
63
- // For AI Bot mode, corpId is empty
64
- const random16 = randomBytes(16);
65
- const msgBuffer = Buffer.from(text);
66
- const lenBuffer = Buffer.alloc(4);
67
- lenBuffer.writeUInt32BE(msgBuffer.length, 0);
68
-
69
- const rawMsg = Buffer.concat([random16, lenBuffer, msgBuffer]);
70
- const encoded = this.encodePkcs7(rawMsg);
71
-
72
- const cipher = createCipheriv("aes-256-cbc", this.aesKey, this.iv);
73
- cipher.setAutoPadding(false);
74
- const ciphered = Buffer.concat([cipher.update(encoded), cipher.final()]);
75
- return ciphered.toString("base64");
76
- }
77
-
78
- encodePkcs7(buff) {
79
- const blockSize = CONSTANTS.AES_BLOCK_SIZE;
80
- const amountToPad = blockSize - (buff.length % blockSize);
81
- const pad = Buffer.alloc(amountToPad, amountToPad);
82
- return Buffer.concat([buff, pad]);
83
- }
84
-
85
- decodePkcs7(buff) {
86
- const pad = buff[buff.length - 1];
87
- if (pad < 1 || pad > CONSTANTS.AES_BLOCK_SIZE) {
88
- throw new Error(`Invalid PKCS7 padding: ${pad}`);
89
- }
90
- for (let i = buff.length - pad; i < buff.length; i++) {
91
- if (buff[i] !== pad) {
92
- throw new Error("Invalid PKCS7 padding: inconsistent padding bytes");
93
- }
94
- }
95
- return buff.subarray(0, buff.length - pad);
96
- }
97
-
98
- /**
99
- * Decrypt image/media file from Enterprise WeChat.
100
- * Images are encrypted with AES-256-CBC using the same key as messages.
101
- * Note: WeCom uses PKCS7 padding to 32-byte blocks (not standard 16-byte).
102
- * @param {Buffer} encryptedData - The encrypted image data (raw bytes, not base64)
103
- * @returns {Buffer} - Decrypted image data
104
- */
105
- decryptMedia(encryptedData) {
106
- const decipher = createDecipheriv("aes-256-cbc", this.aesKey, this.iv);
107
- decipher.setAutoPadding(false);
108
- const decrypted = Buffer.concat([
109
- decipher.update(encryptedData),
110
- decipher.final(),
111
- ]);
112
-
113
- // Remove PKCS7 padding manually (padded to 32-byte blocks).
114
- const padLen = decrypted[decrypted.length - 1];
115
- let unpadded = decrypted;
116
- if (padLen >= 1 && padLen <= 32) {
117
- let validPadding = true;
118
- for (let i = decrypted.length - padLen; i < decrypted.length; i++) {
119
- if (decrypted[i] !== padLen) {
120
- validPadding = false;
121
- break;
122
- }
123
- }
124
- if (validPadding) {
125
- unpadded = decrypted.subarray(0, decrypted.length - padLen);
126
- }
127
- }
128
-
129
- logger.debug("Media decrypted successfully", {
130
- inputSize: encryptedData.length,
131
- outputSize: unpadded.length,
132
- });
133
- return unpadded;
134
- }
135
- }
package/stream-manager.js DELETED
@@ -1,358 +0,0 @@
1
- import { prepareImageForMsgItem } from "./image-processor.js";
2
- import { logger } from "./logger.js";
3
-
4
- /**
5
- * Streaming state manager for WeCom responses.
6
- */
7
-
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
- }
22
-
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;
36
- }
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;
45
- }
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;
84
- }
85
-
86
- content = enforceByteLimit(content);
87
-
88
- stream.content = content;
89
- stream.finished = finished;
90
- stream.updatedAt = Date.now();
91
-
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);
95
- }
96
-
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
- }
117
-
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
- }
145
-
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
- }
155
-
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;
179
- }
180
-
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
- }
205
-
206
- logger.debug("Processing pending images", {
207
- streamId,
208
- count: stream.pendingImages.length,
209
- });
210
-
211
- const msgItems = [];
212
-
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", {
218
- streamId,
219
- total: stream.pendingImages.length,
220
- processed: msgItems.length,
221
- });
222
- break;
223
- }
224
-
225
- const processed = await prepareImageForMsgItem(img.path);
226
- msgItems.push({
227
- msgtype: "image",
228
- image: {
229
- base64: processed.base64,
230
- md5: processed.md5,
231
- },
232
- });
233
-
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
- }
248
- }
249
-
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;
268
- }
269
-
270
- if (stream.finished) {
271
- return true;
272
- }
273
-
274
- // Process pending images before finalizing the stream.
275
- if (stream.pendingImages.length > 0) {
276
- stream.msgItem = await this.processPendingImages(streamId);
277
- stream.pendingImages = [];
278
- }
279
-
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
- }
330
- }
331
-
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
- }
351
- }
352
-
353
- return { total, finished, active };
354
- }
355
- }
356
-
357
- // Shared singleton instance used by the plugin runtime.
358
- export const streamManager = new StreamManager();