@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.
- package/README.md +391 -845
- package/image-processor.js +30 -27
- package/index.js +10 -43
- package/package.json +5 -5
- package/think-parser.js +51 -11
- package/wecom/accounts.js +323 -189
- package/wecom/channel-plugin.js +543 -750
- package/wecom/constants.js +57 -47
- package/wecom/dm-policy.js +91 -0
- package/wecom/group-policy.js +85 -0
- package/wecom/onboarding.js +117 -0
- package/wecom/runtime-telemetry.js +330 -0
- package/wecom/sandbox.js +60 -0
- package/wecom/state.js +33 -35
- package/wecom/workspace-template.js +62 -5
- package/wecom/ws-monitor.js +1487 -0
- package/wecom/ws-state.js +160 -0
- package/crypto.js +0 -135
- package/stream-manager.js +0 -358
- package/webhook.js +0 -469
- package/wecom/agent-inbound.js +0 -541
- package/wecom/http-handler-state.js +0 -23
- package/wecom/http-handler.js +0 -395
- package/wecom/inbound-processor.js +0 -562
- package/wecom/media.js +0 -192
- package/wecom/outbound-delivery.js +0 -435
- package/wecom/response-url.js +0 -33
- package/wecom/stream-utils.js +0 -163
- package/wecom/webhook-targets.js +0 -28
- package/wecom/xml-parser.js +0 -126
|
@@ -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();
|