@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/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