@sunnoy/wecom 1.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/CONTRIBUTING.md +25 -0
- package/LICENSE +7 -0
- package/README.md +284 -0
- package/README_ZH.md +284 -0
- package/client.js +127 -0
- package/crypto.js +108 -0
- package/dynamic-agent.js +120 -0
- package/image-processor.js +179 -0
- package/index.js +889 -0
- package/logger.js +64 -0
- package/openclaw.plugin.json +13 -0
- package/package.json +60 -0
- package/stream-manager.js +307 -0
- package/utils.js +251 -0
- package/webhook.js +273 -0
package/logger.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured logging for WeCom plugin
|
|
3
|
+
*/
|
|
4
|
+
const LEVELS = {
|
|
5
|
+
debug: 10,
|
|
6
|
+
info: 20,
|
|
7
|
+
warn: 30,
|
|
8
|
+
error: 40,
|
|
9
|
+
silent: 100,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
function getEnvLogLevel() {
|
|
13
|
+
const raw = (process.env.WECOM_LOG_LEVEL || process.env.LOG_LEVEL || "info").toLowerCase();
|
|
14
|
+
return Object.prototype.hasOwnProperty.call(LEVELS, raw) ? raw : "info";
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class Logger {
|
|
18
|
+
prefix;
|
|
19
|
+
level;
|
|
20
|
+
constructor(prefix = "[wecom]", level = getEnvLogLevel()) {
|
|
21
|
+
this.prefix = prefix;
|
|
22
|
+
this.level = level;
|
|
23
|
+
}
|
|
24
|
+
log(level, message, context) {
|
|
25
|
+
if (LEVELS[level] < LEVELS[this.level]) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
const timestamp = new Date().toISOString();
|
|
29
|
+
const contextStr = context ? ` ${JSON.stringify(context)}` : "";
|
|
30
|
+
const logMessage = `${timestamp} ${level.toUpperCase()} ${this.prefix} ${message}${contextStr}`;
|
|
31
|
+
switch (level) {
|
|
32
|
+
case "debug":
|
|
33
|
+
console.debug(logMessage);
|
|
34
|
+
break;
|
|
35
|
+
case "info":
|
|
36
|
+
console.info(logMessage);
|
|
37
|
+
break;
|
|
38
|
+
case "warn":
|
|
39
|
+
console.warn(logMessage);
|
|
40
|
+
break;
|
|
41
|
+
case "error":
|
|
42
|
+
console.error(logMessage);
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
debug(message, context) {
|
|
47
|
+
this.log("debug", message, context);
|
|
48
|
+
}
|
|
49
|
+
info(message, context) {
|
|
50
|
+
this.log("info", message, context);
|
|
51
|
+
}
|
|
52
|
+
warn(message, context) {
|
|
53
|
+
this.log("warn", message, context);
|
|
54
|
+
}
|
|
55
|
+
error(message, context) {
|
|
56
|
+
this.log("error", message, context);
|
|
57
|
+
}
|
|
58
|
+
child(subPrefix) {
|
|
59
|
+
return new Logger(`${this.prefix}:${subPrefix}`, this.level);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
// Default logger instance
|
|
63
|
+
export const logger = new Logger();
|
|
64
|
+
//# sourceMappingURL=logger.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "wecom",
|
|
3
|
+
"name": "OpenClaw WeCom",
|
|
4
|
+
"description": "Enterprise WeChat (WeCom) messaging channel plugin for OpenClaw",
|
|
5
|
+
"channels": [
|
|
6
|
+
"wecom"
|
|
7
|
+
],
|
|
8
|
+
"configSchema": {
|
|
9
|
+
"type": "object",
|
|
10
|
+
"additionalProperties": false,
|
|
11
|
+
"properties": {}
|
|
12
|
+
}
|
|
13
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@sunnoy/wecom",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Enterprise WeChat AI Bot channel plugin for OpenClaw",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"index.js",
|
|
9
|
+
"client.js",
|
|
10
|
+
"crypto.js",
|
|
11
|
+
"dynamic-agent.js",
|
|
12
|
+
"image-processor.js",
|
|
13
|
+
"logger.js",
|
|
14
|
+
"README.md",
|
|
15
|
+
"README_ZH.md",
|
|
16
|
+
"LICENSE",
|
|
17
|
+
"CONTRIBUTING.md",
|
|
18
|
+
"stream-manager.js",
|
|
19
|
+
"utils.js",
|
|
20
|
+
"webhook.js",
|
|
21
|
+
"openclaw.plugin.json"
|
|
22
|
+
],
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"fast-xml-parser": "^4.2.0"
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"openclaw": "*"
|
|
28
|
+
},
|
|
29
|
+
"openclaw": {
|
|
30
|
+
"extensions": [
|
|
31
|
+
"./index.js"
|
|
32
|
+
],
|
|
33
|
+
"channel": {
|
|
34
|
+
"id": "wecom",
|
|
35
|
+
"label": "Enterprise WeChat",
|
|
36
|
+
"selectionLabel": "Enterprise WeChat (AI Bot)",
|
|
37
|
+
"docsPath": "/channels/wecom",
|
|
38
|
+
"docsLabel": "wecom",
|
|
39
|
+
"blurb": "Support for Enterprise WeChat (WeCom) AI Bot integration",
|
|
40
|
+
"order": 90,
|
|
41
|
+
"aliases": [
|
|
42
|
+
"wecom",
|
|
43
|
+
"wework"
|
|
44
|
+
]
|
|
45
|
+
},
|
|
46
|
+
"install": {
|
|
47
|
+
"defaultChoice": "npm",
|
|
48
|
+
"npmSpec": "@sunnoy/wecom"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"keywords": [
|
|
52
|
+
"openclaw",
|
|
53
|
+
"wecom",
|
|
54
|
+
"enterprise-wechat",
|
|
55
|
+
"chat",
|
|
56
|
+
"plugin"
|
|
57
|
+
],
|
|
58
|
+
"author": "",
|
|
59
|
+
"license": "ISC"
|
|
60
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
import { prepareImageForMsgItem } from "./image-processor.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 流式消息状态管理器
|
|
6
|
+
* 管理所有活跃的流式消息会话,支持企业微信的流式刷新机制
|
|
7
|
+
*/
|
|
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
|
+
|
|
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
|
+
}
|
|
24
|
+
|
|
25
|
+
stopCleanup() {
|
|
26
|
+
if (!this._cleanupInterval) return;
|
|
27
|
+
clearInterval(this._cleanupInterval);
|
|
28
|
+
this._cleanupInterval = null;
|
|
29
|
+
}
|
|
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;
|
|
49
|
+
}
|
|
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;
|
|
96
|
+
}
|
|
97
|
+
|
|
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();
|
|
111
|
+
|
|
112
|
+
logger.debug("Stream appended", {
|
|
113
|
+
streamId,
|
|
114
|
+
chunkLength: chunk.length,
|
|
115
|
+
totalLength: stream.content.length
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
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
|
+
}
|
|
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) {
|
|
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);
|
|
227
|
+
}
|
|
228
|
+
|
|
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
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* 获取流的当前状态
|
|
243
|
+
*/
|
|
244
|
+
getStream(streamId) {
|
|
245
|
+
return this.streams.get(streamId);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 检查流是否存在
|
|
250
|
+
*/
|
|
251
|
+
hasStream(streamId) {
|
|
252
|
+
return this.streams.has(streamId);
|
|
253
|
+
}
|
|
254
|
+
|
|
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;
|
|
264
|
+
}
|
|
265
|
+
|
|
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
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
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 };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// 单例实例
|
|
307
|
+
export const streamManager = new StreamManager();
|
package/utils.js
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Utility functions and helpers for WeCom plugin
|
|
3
|
+
*/
|
|
4
|
+
export class TTLCache {
|
|
5
|
+
options;
|
|
6
|
+
cache = new Map();
|
|
7
|
+
checkPeriod;
|
|
8
|
+
cleanupTimer;
|
|
9
|
+
constructor(options) {
|
|
10
|
+
this.options = options;
|
|
11
|
+
this.checkPeriod = options.checkPeriod || options.ttl;
|
|
12
|
+
this.startCleanup();
|
|
13
|
+
}
|
|
14
|
+
set(key, value, ttl) {
|
|
15
|
+
const expiresAt = Date.now() + (ttl || this.options.ttl);
|
|
16
|
+
this.cache.set(key, { value, expiresAt });
|
|
17
|
+
}
|
|
18
|
+
get(key) {
|
|
19
|
+
const entry = this.cache.get(key);
|
|
20
|
+
if (!entry)
|
|
21
|
+
return undefined;
|
|
22
|
+
if (Date.now() > entry.expiresAt) {
|
|
23
|
+
this.cache.delete(key);
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
return entry.value;
|
|
27
|
+
}
|
|
28
|
+
has(key) {
|
|
29
|
+
return this.get(key) !== undefined;
|
|
30
|
+
}
|
|
31
|
+
delete(key) {
|
|
32
|
+
return this.cache.delete(key);
|
|
33
|
+
}
|
|
34
|
+
clear() {
|
|
35
|
+
this.cache.clear();
|
|
36
|
+
}
|
|
37
|
+
size() {
|
|
38
|
+
this.cleanup();
|
|
39
|
+
return this.cache.size;
|
|
40
|
+
}
|
|
41
|
+
cleanup() {
|
|
42
|
+
const now = Date.now();
|
|
43
|
+
for (const [key, entry] of this.cache.entries()) {
|
|
44
|
+
if (now > entry.expiresAt) {
|
|
45
|
+
this.cache.delete(key);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
startCleanup() {
|
|
50
|
+
this.cleanupTimer = setInterval(() => {
|
|
51
|
+
this.cleanup();
|
|
52
|
+
}, this.checkPeriod);
|
|
53
|
+
// Don't prevent process from exiting
|
|
54
|
+
if (this.cleanupTimer.unref) {
|
|
55
|
+
this.cleanupTimer.unref();
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
destroy() {
|
|
59
|
+
if (this.cleanupTimer) {
|
|
60
|
+
clearInterval(this.cleanupTimer);
|
|
61
|
+
}
|
|
62
|
+
this.cache.clear();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
// ============================================================================
|
|
66
|
+
// Promise Lock (for preventing race conditions)
|
|
67
|
+
// ============================================================================
|
|
68
|
+
export class PromiseLock {
|
|
69
|
+
pending = new Map();
|
|
70
|
+
async acquire(key, fn) {
|
|
71
|
+
// If there's already a pending operation, wait for it
|
|
72
|
+
const existing = this.pending.get(key);
|
|
73
|
+
if (existing) {
|
|
74
|
+
return existing;
|
|
75
|
+
}
|
|
76
|
+
// Start new operation
|
|
77
|
+
const promise = fn();
|
|
78
|
+
this.pending.set(key, promise);
|
|
79
|
+
try {
|
|
80
|
+
const result = await promise;
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
this.pending.delete(key);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
clear(key) {
|
|
88
|
+
if (key) {
|
|
89
|
+
this.pending.delete(key);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
this.pending.clear();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
export async function withRetry(fn, options = {}) {
|
|
97
|
+
const { retries = 3, minTimeout = 1000, maxTimeout = 10000, factor = 2, randomize = true, onRetry, } = options;
|
|
98
|
+
let lastError;
|
|
99
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
100
|
+
try {
|
|
101
|
+
return await fn();
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
lastError = error;
|
|
105
|
+
if (attempt === retries) {
|
|
106
|
+
throw lastError;
|
|
107
|
+
}
|
|
108
|
+
// Calculate backoff delay
|
|
109
|
+
let delay = Math.min(minTimeout * Math.pow(factor, attempt), maxTimeout);
|
|
110
|
+
if (randomize) {
|
|
111
|
+
delay = delay * (0.5 + Math.random() * 0.5);
|
|
112
|
+
}
|
|
113
|
+
if (onRetry) {
|
|
114
|
+
onRetry(lastError, attempt + 1);
|
|
115
|
+
}
|
|
116
|
+
await sleep(delay);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
throw lastError;
|
|
120
|
+
}
|
|
121
|
+
export function sleep(ms) {
|
|
122
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
123
|
+
}
|
|
124
|
+
// ============================================================================
|
|
125
|
+
// Message Deduplication
|
|
126
|
+
// ============================================================================
|
|
127
|
+
export class MessageDeduplicator {
|
|
128
|
+
seen = new TTLCache({ ttl: 300000 }); // 5 minutes
|
|
129
|
+
isDuplicate(msgId) {
|
|
130
|
+
if (this.seen.has(msgId)) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
this.seen.set(msgId, true);
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
markAsSeen(msgId) {
|
|
137
|
+
this.seen.set(msgId, true);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
export function parseWecomError(errcode, errmsg) {
|
|
141
|
+
// Reference: https://developer.work.weixin.qq.com/document/path/96213
|
|
142
|
+
switch (errcode) {
|
|
143
|
+
case -1:
|
|
144
|
+
return {
|
|
145
|
+
code: errcode,
|
|
146
|
+
message: "System busy, retry later",
|
|
147
|
+
retryable: true,
|
|
148
|
+
category: "system",
|
|
149
|
+
};
|
|
150
|
+
case 0:
|
|
151
|
+
return {
|
|
152
|
+
code: errcode,
|
|
153
|
+
message: "Success",
|
|
154
|
+
retryable: false,
|
|
155
|
+
category: "system",
|
|
156
|
+
};
|
|
157
|
+
case 40001:
|
|
158
|
+
case 40014:
|
|
159
|
+
case 42001:
|
|
160
|
+
case 42007:
|
|
161
|
+
case 42009:
|
|
162
|
+
return {
|
|
163
|
+
code: errcode,
|
|
164
|
+
message: `Invalid or expired access_token: ${errmsg}`,
|
|
165
|
+
retryable: true,
|
|
166
|
+
category: "auth",
|
|
167
|
+
};
|
|
168
|
+
case 45009:
|
|
169
|
+
return {
|
|
170
|
+
code: errcode,
|
|
171
|
+
message: "API rate limit exceeded",
|
|
172
|
+
retryable: true,
|
|
173
|
+
category: "rate_limit",
|
|
174
|
+
};
|
|
175
|
+
case 48002:
|
|
176
|
+
return {
|
|
177
|
+
code: errcode,
|
|
178
|
+
message: "API concurrent call limit exceeded",
|
|
179
|
+
retryable: true,
|
|
180
|
+
category: "rate_limit",
|
|
181
|
+
};
|
|
182
|
+
case 40003:
|
|
183
|
+
case 40013:
|
|
184
|
+
case 40035:
|
|
185
|
+
return {
|
|
186
|
+
code: errcode,
|
|
187
|
+
message: `Invalid parameter: ${errmsg}`,
|
|
188
|
+
retryable: false,
|
|
189
|
+
category: "invalid_input",
|
|
190
|
+
};
|
|
191
|
+
default:
|
|
192
|
+
return {
|
|
193
|
+
code: errcode,
|
|
194
|
+
message: errmsg || "Unknown error",
|
|
195
|
+
retryable: errcode >= 50000 && errcode < 60000, // System errors are retryable
|
|
196
|
+
category: "unknown",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
export function shouldRetryError(errcode) {
|
|
201
|
+
const info = parseWecomError(errcode, "");
|
|
202
|
+
return info.retryable;
|
|
203
|
+
}
|
|
204
|
+
// ============================================================================
|
|
205
|
+
// URL Helpers
|
|
206
|
+
// ============================================================================
|
|
207
|
+
export function buildApiUrl(path, params) {
|
|
208
|
+
const base = `https://qyapi.weixin.qq.com${path}`;
|
|
209
|
+
if (!params || Object.keys(params).length === 0) {
|
|
210
|
+
return base;
|
|
211
|
+
}
|
|
212
|
+
const query = new URLSearchParams(params).toString();
|
|
213
|
+
return `${base}?${query}`;
|
|
214
|
+
}
|
|
215
|
+
// ============================================================================
|
|
216
|
+
// Random String Generation
|
|
217
|
+
// ============================================================================
|
|
218
|
+
export function randomString(length) {
|
|
219
|
+
const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
220
|
+
let result = "";
|
|
221
|
+
for (let i = 0; i < length; i++) {
|
|
222
|
+
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
|
223
|
+
}
|
|
224
|
+
return result;
|
|
225
|
+
}
|
|
226
|
+
// ============================================================================
|
|
227
|
+
// Constants
|
|
228
|
+
// ============================================================================
|
|
229
|
+
export const CONSTANTS = {
|
|
230
|
+
// Token settings
|
|
231
|
+
TOKEN_REFRESH_MARGIN_MS: 300000, // 5 minutes before expiry
|
|
232
|
+
TOKEN_CACHE_KEY: "access_token",
|
|
233
|
+
// Response URL settings
|
|
234
|
+
RESPONSE_URL_TTL_MS: 3600000, // 1 hour
|
|
235
|
+
RESPONSE_URL_MAX_USES: 1,
|
|
236
|
+
// Media settings
|
|
237
|
+
MEDIA_ID_TTL_MS: 259200000, // 3 days
|
|
238
|
+
// Rate limiting
|
|
239
|
+
MESSAGE_RATE_LIMIT_PER_MINUTE: 20,
|
|
240
|
+
// Timeouts
|
|
241
|
+
API_TIMEOUT_MS: 10000, // 10 seconds
|
|
242
|
+
WEBHOOK_RESPONSE_TIMEOUT_MS: 5000, // 5 seconds
|
|
243
|
+
// Retry settings
|
|
244
|
+
DEFAULT_RETRY_COUNT: 3,
|
|
245
|
+
DEFAULT_RETRY_MIN_DELAY_MS: 1000,
|
|
246
|
+
DEFAULT_RETRY_MAX_DELAY_MS: 10000,
|
|
247
|
+
// AES/Crypto
|
|
248
|
+
AES_BLOCK_SIZE: 32,
|
|
249
|
+
AES_KEY_LENGTH: 43,
|
|
250
|
+
};
|
|
251
|
+
//# sourceMappingURL=utils.js.map
|