@creatoraris/openclaw-wecom 0.2.2 → 0.3.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/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,130 @@ 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
+ async function downloadImage(imageUrl) {
133
+ try {
134
+ log.info(`[Image] downloading ${imageUrl.slice(0, 100)}`);
135
+ const response = await fetch(imageUrl);
136
+ if (!response.ok) return null;
137
+ const buffer = await response.arrayBuffer();
138
+ if (buffer.byteLength > 10 * 1024 * 1024) return null;
139
+ await fs.mkdir(IMAGE_CACHE_DIR, { recursive: true });
140
+ const ext = path.extname(new URL(imageUrl).pathname) || '.jpg';
141
+ const filename = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}${ext}`;
142
+ const filepath = path.join(IMAGE_CACHE_DIR, filename);
143
+ await fs.writeFile(filepath, Buffer.from(buffer));
144
+ log.info(`[Image] saved ${filename} (${(buffer.byteLength / 1024 / 1024).toFixed(2)}MB)`);
145
+ return filepath;
146
+ } catch (err) {
147
+ log.error(`[Image] download failed: ${err.message}`);
148
+ return null;
149
+ }
150
+ }
151
+
152
+ async function resolveImage(imageObj) {
153
+ const mediaId = imageObj?.media_id;
154
+ const imageUrl = imageObj?.url || imageObj?.pic_url;
155
+ let localPath = null;
156
+ if (mediaId) localPath = await downloadWeComMedia(mediaId);
157
+ if (!localPath && imageUrl) localPath = await downloadImage(imageUrl);
158
+ if (localPath) return `[用户发送了一张图片]\n本地路径: ${localPath}\n请使用image工具分析这张图片并回复用户。`;
159
+ if (imageUrl) return `[用户发送了一张图片]\n图片URL: ${imageUrl}`;
160
+ return '[用户发送了一张图片,但无法获取]';
161
+ }
162
+
163
+ async function cleanupImageCache() {
164
+ try {
165
+ const files = await fs.readdir(IMAGE_CACHE_DIR).catch(() => []);
166
+ const cutoff = Date.now() - IMAGE_MAX_AGE_MS;
167
+ for (const file of files) {
168
+ const filepath = path.join(IMAGE_CACHE_DIR, file);
169
+ const stat = await fs.stat(filepath).catch(() => null);
170
+ if (stat && stat.mtimeMs < cutoff) await fs.unlink(filepath).catch(() => {});
171
+ }
172
+ } catch {}
173
+ }
174
+
175
+ async function extractUserText(msg, isGroup = false) {
77
176
  const cleanMention = (text) => isGroup ? text.replace(/@[^\s@]+\s*/g, '').trim() : text.trim();
78
177
 
79
178
  if (msg.msgtype === 'text') return cleanMention(msg.text?.content || '');
80
179
  if (msg.msgtype === 'voice') return msg.voice?.content?.trim() || '';
180
+
81
181
  if (msg.msgtype === 'mixed') {
82
182
  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);
183
+ const textParts = [];
184
+ const imagePrompts = [];
185
+ for (const part of parts) {
186
+ if (part.msgtype === 'text') {
187
+ textParts.push(part.text?.content || '');
188
+ } else if (part.msgtype === 'image') {
189
+ imagePrompts.push(await resolveImage(part.image));
190
+ }
191
+ }
192
+ let result = cleanMention(textParts.join(' '));
193
+ if (imagePrompts.length > 0) {
194
+ result = result ? `${result}\n\n${imagePrompts.join('\n\n')}` : imagePrompts.join('\n\n');
195
+ }
196
+ return result;
197
+ }
198
+
199
+ if (msg.msgtype === 'image') {
200
+ return await resolveImage(msg.image);
85
201
  }
86
- if (msg.msgtype === 'image') return '[图片消息] 暂不支持图片回复';
202
+
87
203
  return '';
88
204
  }
89
205
 
@@ -280,7 +396,7 @@ const plugin = {
280
396
  }
281
397
 
282
398
  const isGroup = chattype === 'group';
283
- const text = extractUserText(msg, isGroup);
399
+ const text = await extractUserText(msg, isGroup);
284
400
  log.info(`[<- ${source}] (${msgtype}) ${text.slice(0, 100)}`);
285
401
 
286
402
  if (!text) {
@@ -325,6 +441,7 @@ const plugin = {
325
441
  });
326
442
 
327
443
  cleanupStreams();
444
+ cleanupImageCache();
328
445
  return;
329
446
  }
330
447
 
@@ -354,7 +471,7 @@ const plugin = {
354
471
  });
355
472
 
356
473
  server.listen(port, '127.0.0.1', () => {
357
- log.info(`wecom-bridge listening on 127.0.0.1:${port}`);
474
+ log.info(`openclaw-wecom plugin listening on 127.0.0.1:${port}`);
358
475
  log.info(` Callback: /callback`);
359
476
  log.info(` OpenClaw: ${openclawApi}`);
360
477
  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.0",
4
4
  "description": "Enterprise WeChat (WeCom) channel plugin for OpenClaw - 企业微信智能助手机器人桥接",
5
5
  "main": "index.js",
6
6
  "type": "module",