@creatoraris/openclaw-wecom 0.2.2 → 0.3.1

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/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import http from 'node:http';
2
2
  import crypto from 'node:crypto';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
3
5
  import { URL } from 'node:url';
4
6
  import { WeComCrypto } from './crypto.js';
5
7
 
@@ -9,6 +11,7 @@ const plugin = {
9
11
  const token = cfg.token || process.env.WECOM_TOKEN;
10
12
  const encodingAESKey = cfg.encodingAESKey || process.env.WECOM_ENCODING_AES_KEY;
11
13
  const corpId = cfg.corpId || process.env.WECOM_CORP_ID || '';
14
+ const corpSecret = cfg.corpSecret || process.env.WECOM_CORPSECRET || '';
12
15
  const port = Number(cfg.port || process.env.PORT || 8788);
13
16
 
14
17
  if (!token || !encodingAESKey) {
@@ -73,17 +76,154 @@ const plugin = {
73
76
  return JSON.stringify({ encrypt: encrypted, msgsignature, timestamp: Number(timestamp), nonce });
74
77
  }
75
78
 
76
- function extractUserText(msg, isGroup = false) {
79
+ // ── Image handling ──
80
+
81
+ const IMAGE_CACHE_DIR = '/tmp/openclaw-wecom-images';
82
+ const IMAGE_MAX_AGE_MS = 60 * 60 * 1000;
83
+
84
+ let wecomAccessToken = null;
85
+ let tokenExpireTime = 0;
86
+
87
+ async function getWeComAccessToken() {
88
+ if (!corpId || !corpSecret) return null;
89
+ const now = Date.now();
90
+ if (wecomAccessToken && now < tokenExpireTime) return wecomAccessToken;
91
+ try {
92
+ const url = `https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${corpId}&corpsecret=${corpSecret}`;
93
+ const data = await (await fetch(url)).json();
94
+ if (data.errcode === 0 && data.access_token) {
95
+ wecomAccessToken = data.access_token;
96
+ tokenExpireTime = now + (data.expires_in - 300) * 1000;
97
+ log.info('[WeCom] access_token obtained');
98
+ return wecomAccessToken;
99
+ }
100
+ log.error(`[WeCom] access_token failed: ${data.errmsg}`);
101
+ return null;
102
+ } catch (err) {
103
+ log.error(`[WeCom] access_token error: ${err.message}`);
104
+ return null;
105
+ }
106
+ }
107
+
108
+ async function downloadWeComMedia(mediaId) {
109
+ try {
110
+ const accessToken = await getWeComAccessToken();
111
+ if (!accessToken) return null;
112
+ const url = `https://qyapi.weixin.qq.com/cgi-bin/media/get?access_token=${accessToken}&media_id=${mediaId}`;
113
+ log.info(`[WeCom Media] downloading media_id=${mediaId}`);
114
+ const response = await fetch(url);
115
+ if (!response.ok) return null;
116
+ const contentType = response.headers.get('content-type') || '';
117
+ if (contentType.includes('application/json')) return null;
118
+ const buffer = await response.arrayBuffer();
119
+ if (buffer.byteLength > 10 * 1024 * 1024) return null;
120
+ await fs.mkdir(IMAGE_CACHE_DIR, { recursive: true });
121
+ const filename = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}-media.jpg`;
122
+ const filepath = path.join(IMAGE_CACHE_DIR, filename);
123
+ await fs.writeFile(filepath, Buffer.from(buffer));
124
+ log.info(`[WeCom Media] saved ${filename} (${(buffer.byteLength / 1024 / 1024).toFixed(2)}MB)`);
125
+ return filepath;
126
+ } catch (err) {
127
+ log.error(`[WeCom Media] download failed: ${err.message}`);
128
+ return null;
129
+ }
130
+ }
131
+
132
+ function decryptImageIfNeeded(buf) {
133
+ // Check if already a known image format
134
+ if ((buf[0] === 0xff && buf[1] === 0xd8) || (buf[0] === 0x89 && buf[1] === 0x50)) return buf;
135
+ // Try AES-256-CBC decryption with WeCom encodingAESKey
136
+ if (buf.length % 16 !== 0) return buf;
137
+ try {
138
+ const decipher = crypto.createDecipheriv('aes-256-cbc', botCrypto.aesKey, botCrypto.iv);
139
+ decipher.setAutoPadding(false);
140
+ let decrypted = Buffer.concat([decipher.update(buf), decipher.final()]);
141
+ // Remove PKCS7 padding
142
+ const pad = decrypted[decrypted.length - 1];
143
+ if (pad > 0 && pad <= 32) decrypted = decrypted.subarray(0, decrypted.length - pad);
144
+ // Verify it's a real image now
145
+ if ((decrypted[0] === 0xff && decrypted[1] === 0xd8) || (decrypted[0] === 0x89 && decrypted[1] === 0x50)) {
146
+ log.info('[Image] decrypted successfully');
147
+ return decrypted;
148
+ }
149
+ return buf;
150
+ } catch { return buf; }
151
+ }
152
+
153
+ async function downloadImage(imageUrl) {
154
+ try {
155
+ log.info(`[Image] downloading ${imageUrl.slice(0, 100)}`);
156
+ const response = await fetch(imageUrl);
157
+ if (!response.ok) return null;
158
+ let buffer = Buffer.from(await response.arrayBuffer());
159
+ if (buffer.byteLength > 10 * 1024 * 1024) return null;
160
+ buffer = decryptImageIfNeeded(buffer);
161
+ await fs.mkdir(IMAGE_CACHE_DIR, { recursive: true });
162
+ // Detect actual format from magic bytes
163
+ const ext = (buffer[0] === 0x89 && buffer[1] === 0x50) ? '.png' : '.jpg';
164
+ const filename = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
165
+ const filepath = path.join(IMAGE_CACHE_DIR, filename);
166
+ await fs.writeFile(filepath, buffer);
167
+ log.info(`[Image] saved ${filename} (${(buffer.byteLength / 1024 / 1024).toFixed(2)}MB)`);
168
+ return filepath;
169
+ } catch (err) {
170
+ log.error(`[Image] download failed: ${err.message}`);
171
+ return null;
172
+ }
173
+ }
174
+
175
+ async function resolveImage(imageObj) {
176
+ log.info(`[Image] raw fields: ${JSON.stringify(imageObj)}`);
177
+ const mediaId = imageObj?.media_id;
178
+ const imageUrl = imageObj?.url || imageObj?.pic_url;
179
+ let localPath = null;
180
+ if (mediaId) localPath = await downloadWeComMedia(mediaId);
181
+ if (!localPath && imageUrl) localPath = await downloadImage(imageUrl);
182
+ if (localPath) return `[用户发送了一张图片]\n本地路径: ${localPath}\n请使用image工具分析这张图片并回复用户。`;
183
+ if (imageUrl) return `[用户发送了一张图片]\n图片URL: ${imageUrl}`;
184
+ return '[用户发送了一张图片,但无法获取]';
185
+ }
186
+
187
+ async function cleanupImageCache() {
188
+ try {
189
+ const files = await fs.readdir(IMAGE_CACHE_DIR).catch(() => []);
190
+ const cutoff = Date.now() - IMAGE_MAX_AGE_MS;
191
+ for (const file of files) {
192
+ const filepath = path.join(IMAGE_CACHE_DIR, file);
193
+ const stat = await fs.stat(filepath).catch(() => null);
194
+ if (stat && stat.mtimeMs < cutoff) await fs.unlink(filepath).catch(() => {});
195
+ }
196
+ } catch {}
197
+ }
198
+
199
+ async function extractUserText(msg, isGroup = false) {
77
200
  const cleanMention = (text) => isGroup ? text.replace(/@[^\s@]+\s*/g, '').trim() : text.trim();
78
201
 
79
202
  if (msg.msgtype === 'text') return cleanMention(msg.text?.content || '');
80
203
  if (msg.msgtype === 'voice') return msg.voice?.content?.trim() || '';
204
+
81
205
  if (msg.msgtype === 'mixed') {
82
206
  const parts = msg.mixed?.msg_item || [];
83
- const text = parts.filter(p => p.msgtype === 'text').map(p => p.text?.content || '').join(' ');
84
- return cleanMention(text);
207
+ const textParts = [];
208
+ const imagePrompts = [];
209
+ for (const part of parts) {
210
+ if (part.msgtype === 'text') {
211
+ textParts.push(part.text?.content || '');
212
+ } else if (part.msgtype === 'image') {
213
+ imagePrompts.push(await resolveImage(part.image));
214
+ }
215
+ }
216
+ let result = cleanMention(textParts.join(' '));
217
+ if (imagePrompts.length > 0) {
218
+ result = result ? `${result}\n\n${imagePrompts.join('\n\n')}` : imagePrompts.join('\n\n');
219
+ }
220
+ return result;
221
+ }
222
+
223
+ if (msg.msgtype === 'image') {
224
+ return await resolveImage(msg.image);
85
225
  }
86
- if (msg.msgtype === 'image') return '[图片消息] 暂不支持图片回复';
226
+
87
227
  return '';
88
228
  }
89
229
 
@@ -280,7 +420,7 @@ const plugin = {
280
420
  }
281
421
 
282
422
  const isGroup = chattype === 'group';
283
- const text = extractUserText(msg, isGroup);
423
+ const text = await extractUserText(msg, isGroup);
284
424
  log.info(`[<- ${source}] (${msgtype}) ${text.slice(0, 100)}`);
285
425
 
286
426
  if (!text) {
@@ -325,6 +465,7 @@ const plugin = {
325
465
  });
326
466
 
327
467
  cleanupStreams();
468
+ cleanupImageCache();
328
469
  return;
329
470
  }
330
471
 
@@ -354,7 +495,7 @@ const plugin = {
354
495
  });
355
496
 
356
497
  server.listen(port, '127.0.0.1', () => {
357
- log.info(`wecom-bridge listening on 127.0.0.1:${port}`);
498
+ log.info(`openclaw-wecom plugin listening on 127.0.0.1:${port}`);
358
499
  log.info(` Callback: /callback`);
359
500
  log.info(` OpenClaw: ${openclawApi}`);
360
501
  resolve();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "openclaw-wecom",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "name": "企业微信(WeCom)",
5
5
  "description": "企业微信智能助手机器人桥接,支持流式回复、加密消息、单聊和群聊",
6
6
  "author": "CreatorAris",
@@ -38,7 +38,11 @@
38
38
  },
39
39
  "corpId": {
40
40
  "type": "string",
41
- "description": "企业微信 CorpID(可选,用于消息加密校验)"
41
+ "description": "企业微信 CorpID(可选,用于图片下载)"
42
+ },
43
+ "corpSecret": {
44
+ "type": "string",
45
+ "description": "企业微信 CorpSecret(可选,用于图片下载)"
42
46
  },
43
47
  "port": {
44
48
  "type": "number",
@@ -60,7 +64,12 @@
60
64
  },
61
65
  "corpId": {
62
66
  "label": "CorpID",
63
- "description": "企业微信企业 ID,可选"
67
+ "description": "企业微信企业 ID,配合 CorpSecret 可下载图片"
68
+ },
69
+ "corpSecret": {
70
+ "label": "CorpSecret",
71
+ "sensitive": true,
72
+ "description": "企业微信应用密钥,配合 CorpID 可下载图片"
64
73
  },
65
74
  "port": {
66
75
  "label": "端口",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@creatoraris/openclaw-wecom",
3
- "version": "0.2.2",
3
+ "version": "0.3.1",
4
4
  "description": "Enterprise WeChat (WeCom) channel plugin for OpenClaw - 企业微信智能助手机器人桥接",
5
5
  "main": "index.js",
6
6
  "type": "module",