@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 +147 -6
- package/openclaw.plugin.json +12 -3
- package/package.json +1 -1
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
|
-
|
|
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
|
|
84
|
-
|
|
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
|
-
|
|
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
|
|
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();
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-wecom",
|
|
3
|
-
"version": "0.
|
|
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": "端口",
|