@creatoraris/openclaw-wecom 0.1.1 → 0.2.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.
Files changed (3) hide show
  1. package/index.js +331 -319
  2. package/openclaw.plugin.json +11 -14
  3. package/package.json +2 -4
package/index.js CHANGED
@@ -1,362 +1,374 @@
1
- import 'dotenv/config';
2
1
  import http from 'node:http';
3
2
  import crypto from 'node:crypto';
4
3
  import { URL } from 'node:url';
5
4
  import { WeComCrypto } from './crypto.js';
6
5
 
7
- const {
8
- WECOM_TOKEN,
9
- WECOM_ENCODING_AES_KEY,
10
- OPENCLAW_API,
11
- OPENCLAW_TOKEN,
12
- PORT = '8788',
13
- } = process.env;
14
-
15
- const botCrypto = new WeComCrypto(WECOM_TOKEN, WECOM_ENCODING_AES_KEY, '');
16
-
17
- // ── Stream state management ──
18
- // Map<streamId, { content: string, finished: boolean, responseUrl: string }>
19
- const streams = new Map();
20
-
21
- function cleanupStreams() {
22
- const cutoff = Date.now() - 10 * 60 * 1000; // 10 min
23
- for (const [k, v] of streams) {
24
- if (v.createdAt < cutoff) streams.delete(k);
25
- }
26
- }
27
-
28
- // ── Dedup ──
29
- const processedMsgIds = new Map();
30
- function isDuplicate(msgid) {
31
- if (!msgid) return false;
32
- if (processedMsgIds.has(msgid)) return true;
33
- processedMsgIds.set(msgid, Date.now());
34
- if (processedMsgIds.size > 1000) {
35
- const cutoff = Date.now() - 600000;
36
- for (const [k, v] of processedMsgIds) {
37
- if (v < cutoff) processedMsgIds.delete(k);
38
- }
39
- }
40
- return false;
41
- }
42
-
43
- // ── Helpers ──
44
-
45
- function readBody(req) {
46
- return new Promise((resolve, reject) => {
47
- const chunks = [];
48
- req.on('data', (c) => chunks.push(c));
49
- req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
50
- req.on('error', reject);
51
- });
52
- }
53
-
54
- function makeStreamId() {
55
- return crypto.randomBytes(12).toString('hex');
56
- }
57
-
58
- function encryptReply(jsonObj, nonce) {
59
- const plaintext = JSON.stringify(jsonObj);
60
- const encrypted = botCrypto.encrypt(plaintext);
61
- const timestamp = Math.floor(Date.now() / 1000).toString();
62
- const msgsignature = botCrypto.sign(timestamp, nonce, encrypted);
63
- return JSON.stringify({ encrypt: encrypted, msgsignature, timestamp: Number(timestamp), nonce });
64
- }
65
-
66
- function extractUserText(msg, isGroup = false) {
67
- // 群聊需要删除 @ 提及,单聊保留所有内容(包括 @)
68
- const cleanMention = (text) => isGroup ? text.replace(/@[^\s@]+\s*/g, '').trim() : text.trim();
69
-
70
- if (msg.msgtype === 'text') {
71
- return cleanMention(msg.text?.content || '');
72
- }
73
- if (msg.msgtype === 'voice') {
74
- return msg.voice?.content?.trim() || '';
75
- }
76
- if (msg.msgtype === 'mixed') {
77
- const parts = msg.mixed?.msg_item || [];
78
- const text = parts
79
- .filter(p => p.msgtype === 'text')
80
- .map(p => p.text?.content || '')
81
- .join(' ');
82
- return cleanMention(text);
83
- }
84
- if (msg.msgtype === 'image') {
85
- return '[图片消息] 暂不支持图片回复';
86
- }
87
- return '';
88
- }
89
-
90
- // ── OpenClaw SSE streaming call ──
91
-
92
- async function callOpenClawStream(text, sessionId, streamId) {
93
- const state = streams.get(streamId);
94
- if (!state) return;
95
-
96
- const headers = { 'Content-Type': 'application/json' };
97
- if (OPENCLAW_TOKEN) headers['Authorization'] = `Bearer ${OPENCLAW_TOKEN}`;
98
-
99
- console.log(`[OpenClaw →] session=${sessionId} text=${text.slice(0, 100)}`);
100
-
101
- try {
102
- const res = await fetch(OPENCLAW_API, {
103
- method: 'POST',
104
- headers,
105
- body: JSON.stringify({ model: 'openclaw', input: text, user: sessionId, stream: true }),
106
- });
6
+ const plugin = {
7
+ register(api) {
8
+ const cfg = api.pluginConfig || {};
9
+ const token = cfg.token || process.env.WECOM_TOKEN;
10
+ const encodingAESKey = cfg.encodingAESKey || process.env.WECOM_ENCODING_AES_KEY;
11
+ const corpId = cfg.corpId || process.env.WECOM_CORP_ID || '';
12
+ const port = Number(cfg.port || process.env.PORT || 8788);
107
13
 
108
- if (!res.ok) {
109
- const err = await res.text();
110
- console.error(`[OpenClaw] HTTP ${res.status}: ${err.slice(0, 200)}`);
111
- state.content = '⚠️ 服务暂时不可用,请稍后再试';
112
- state.finished = true;
14
+ if (!token || !encodingAESKey) {
15
+ api.logger.warn('wecom: missing token or encodingAESKey, plugin disabled');
113
16
  return;
114
17
  }
115
18
 
116
- // Parse SSE stream
117
- const reader = res.body.getReader();
118
- const decoder = new TextDecoder();
119
- let buffer = '';
120
-
121
- while (true) {
122
- const { done, value } = await reader.read();
123
- if (done) break;
124
-
125
- buffer += decoder.decode(value, { stream: true });
126
- const lines = buffer.split('\n');
127
- buffer = lines.pop(); // keep incomplete line
128
-
129
- for (const line of lines) {
130
- if (!line.startsWith('data: ')) continue;
131
- const data = line.slice(6).trim();
132
- if (data === '[DONE]') continue;
133
-
134
- try {
135
- const event = JSON.parse(data);
136
-
137
- // OpenResponses streaming: response.output_text.delta / response.completed
138
- if (event.type === 'response.output_text.delta') {
139
- state.content += event.delta || '';
140
- } else if (event.type === 'response.completed') {
141
- // Extract final text from completed response
142
- const output = event.response?.output;
143
- if (output && Array.isArray(output)) {
144
- const texts = [];
145
- for (const item of output) {
146
- if (item.type === 'message' && Array.isArray(item.content)) {
147
- for (const part of item.content) {
148
- if (part.type === 'output_text' && part.text) texts.push(part.text);
149
- }
150
- }
151
- }
152
- const fullText = texts.join('\n').trim();
153
- if (fullText) state.content = fullText;
154
- }
155
- state.finished = true;
156
- }
157
- } catch {}
19
+ // Derive OpenClaw gateway URL from config
20
+ const gwCfg = api.config?.gateway || {};
21
+ const gwPort = gwCfg.port || 18789;
22
+ const gwToken = gwCfg.auth?.token || process.env.OPENCLAW_TOKEN;
23
+ const openclawApi = `http://127.0.0.1:${gwPort}/v1/responses`;
24
+
25
+ const botCrypto = new WeComCrypto(token, encodingAESKey, corpId);
26
+ const log = api.logger;
27
+
28
+ // ── Stream state management ──
29
+ const streams = new Map();
30
+
31
+ function cleanupStreams() {
32
+ const cutoff = Date.now() - 10 * 60 * 1000;
33
+ for (const [k, v] of streams) {
34
+ if (v.createdAt < cutoff) streams.delete(k);
158
35
  }
159
36
  }
160
37
 
161
- // If stream ended without explicit completion
162
- if (!state.finished) {
163
- state.finished = true;
38
+ // ── Dedup ──
39
+ const processedMsgIds = new Map();
40
+ function isDuplicate(msgid) {
41
+ if (!msgid) return false;
42
+ if (processedMsgIds.has(msgid)) return true;
43
+ processedMsgIds.set(msgid, Date.now());
44
+ if (processedMsgIds.size > 1000) {
45
+ const cutoff = Date.now() - 600000;
46
+ for (const [k, v] of processedMsgIds) {
47
+ if (v < cutoff) processedMsgIds.delete(k);
48
+ }
49
+ }
50
+ return false;
164
51
  }
165
52
 
166
- if (!state.content) {
167
- state.content = '(无回复)';
53
+ // ── Helpers ──
54
+
55
+ function readBody(req) {
56
+ return new Promise((resolve, reject) => {
57
+ const chunks = [];
58
+ req.on('data', (c) => chunks.push(c));
59
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
60
+ req.on('error', reject);
61
+ });
168
62
  }
169
63
 
170
- console.log(`[OpenClaw ←] stream=${streamId} len=${state.content.length} done`);
171
- } catch (err) {
172
- console.error(`[OpenClaw] error: ${err.message}`);
173
- state.content = '⚠️ 请求失败,请稍后再试';
174
- state.finished = true;
175
- }
176
- }
177
-
178
- // ── HTTP Server ──
179
-
180
- const server = http.createServer(async (req, res) => {
181
- const url = new URL(req.url, `http://localhost:${PORT}`);
182
-
183
- if (url.pathname !== '/callback') {
184
- res.writeHead(404);
185
- res.end('not found');
186
- return;
187
- }
188
-
189
- const msgSignature = url.searchParams.get('msg_signature');
190
- const timestamp = url.searchParams.get('timestamp');
191
- const nonce = url.searchParams.get('nonce');
192
-
193
- try {
194
- // GET: URL verification
195
- if (req.method === 'GET') {
196
- const echostr = url.searchParams.get('echostr');
197
- if (!botCrypto.verifySignature(msgSignature, timestamp, nonce, echostr)) {
198
- res.writeHead(403);
199
- res.end('signature mismatch');
200
- return;
201
- }
202
- const { message } = botCrypto.decrypt(echostr);
203
- console.log('[Verify] OK');
204
- res.writeHead(200, { 'Content-Type': 'text/plain' });
205
- res.end(message);
206
- return;
64
+ function makeStreamId() {
65
+ return crypto.randomBytes(12).toString('hex');
207
66
  }
208
67
 
209
- // POST: message callback
210
- if (req.method === 'POST') {
211
- const body = await readBody(req);
212
- const trimmed = body.trim();
68
+ function encryptReply(jsonObj, nonce) {
69
+ const plaintext = JSON.stringify(jsonObj);
70
+ const encrypted = botCrypto.encrypt(plaintext);
71
+ const timestamp = Math.floor(Date.now() / 1000).toString();
72
+ const msgsignature = botCrypto.sign(timestamp, nonce, encrypted);
73
+ return JSON.stringify({ encrypt: encrypted, msgsignature, timestamp: Number(timestamp), nonce });
74
+ }
213
75
 
214
- let encrypt;
215
- if (trimmed.startsWith('{')) {
216
- try { encrypt = JSON.parse(trimmed).encrypt; } catch {}
217
- }
218
- if (!encrypt) {
219
- res.writeHead(400);
220
- res.end('bad request');
221
- return;
222
- }
76
+ function extractUserText(msg, isGroup = false) {
77
+ const cleanMention = (text) => isGroup ? text.replace(/@[^\s@]+\s*/g, '').trim() : text.trim();
223
78
 
224
- if (!botCrypto.verifySignature(msgSignature, timestamp, nonce, encrypt)) {
225
- res.writeHead(403);
226
- res.end('signature mismatch');
227
- return;
79
+ if (msg.msgtype === 'text') return cleanMention(msg.text?.content || '');
80
+ if (msg.msgtype === 'voice') return msg.voice?.content?.trim() || '';
81
+ if (msg.msgtype === 'mixed') {
82
+ 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);
228
85
  }
86
+ if (msg.msgtype === 'image') return '[图片消息] 暂不支持图片回复';
87
+ return '';
88
+ }
229
89
 
230
- const { message: decrypted } = botCrypto.decrypt(encrypt);
231
- const msg = JSON.parse(decrypted);
90
+ // ── OpenClaw SSE streaming call ──
232
91
 
233
- const { msgid, chatid, chattype, from, response_url, msgtype } = msg;
234
- const source = chattype === 'group' ? `group:${chatid}` : `user:${from?.userid}`;
92
+ async function callOpenClawStream(text, sessionId, streamId) {
93
+ const state = streams.get(streamId);
94
+ if (!state) return;
235
95
 
236
- // ── Stream refresh callback ──
237
- if (msgtype === 'stream') {
238
- const sid = msg.stream?.id;
239
- const state = streams.get(sid);
96
+ const headers = { 'Content-Type': 'application/json' };
97
+ if (gwToken) headers['Authorization'] = `Bearer ${gwToken}`;
240
98
 
241
- if (!state) {
242
- console.log(`[Stream] unknown id=${sid}, reply empty`);
243
- res.writeHead(200);
244
- res.end('');
245
- return;
246
- }
99
+ log.info(`[OpenClaw ->] session=${sessionId} text=${text.slice(0, 100)}`);
247
100
 
248
- if (isDuplicate(msgid)) {
249
- res.writeHead(200);
250
- res.end('');
101
+ try {
102
+ const res = await fetch(openclawApi, {
103
+ method: 'POST',
104
+ headers,
105
+ body: JSON.stringify({ model: 'openclaw', input: text, user: sessionId, stream: true }),
106
+ });
107
+
108
+ if (!res.ok) {
109
+ const err = await res.text();
110
+ log.error(`[OpenClaw] HTTP ${res.status}: ${err.slice(0, 200)}`);
111
+ state.content = '\u26a0\ufe0f \u670d\u52a1\u6682\u65f6\u4e0d\u53ef\u7528\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5';
112
+ state.finished = true;
251
113
  return;
252
114
  }
253
115
 
254
- const replyObj = {
255
- msgtype: 'stream',
256
- stream: {
257
- id: sid,
258
- finish: state.finished,
259
- content: state.content || '思考中...',
260
- },
261
- };
262
-
263
- const encrypted = encryptReply(replyObj, nonce);
264
- console.log(`[Stream ↻] id=${sid} finish=${state.finished} len=${(state.content || '').length}`);
265
- res.writeHead(200, { 'Content-Type': 'application/json' });
266
- res.end(encrypted);
267
-
268
- // Cleanup finished streams after final reply
269
- if (state.finished) {
270
- setTimeout(() => streams.delete(sid), 30000);
116
+ const reader = res.body.getReader();
117
+ const decoder = new TextDecoder();
118
+ let buffer = '';
119
+
120
+ while (true) {
121
+ const { done, value } = await reader.read();
122
+ if (done) break;
123
+
124
+ buffer += decoder.decode(value, { stream: true });
125
+ const lines = buffer.split('\n');
126
+ buffer = lines.pop();
127
+
128
+ for (const line of lines) {
129
+ if (!line.startsWith('data: ')) continue;
130
+ const data = line.slice(6).trim();
131
+ if (data === '[DONE]') continue;
132
+
133
+ try {
134
+ const event = JSON.parse(data);
135
+
136
+ if (event.type === 'response.output_text.delta') {
137
+ state.content += event.delta || '';
138
+ } else if (event.type === 'response.completed') {
139
+ const output = event.response?.output;
140
+ if (output && Array.isArray(output)) {
141
+ const texts = [];
142
+ for (const item of output) {
143
+ if (item.type === 'message' && Array.isArray(item.content)) {
144
+ for (const part of item.content) {
145
+ if (part.type === 'output_text' && part.text) texts.push(part.text);
146
+ }
147
+ }
148
+ }
149
+ const fullText = texts.join('\n').trim();
150
+ if (fullText) state.content = fullText;
151
+ }
152
+ state.finished = true;
153
+ }
154
+ } catch {}
155
+ }
271
156
  }
272
- return;
273
- }
274
157
 
275
- // ── User message callback ──
276
- if (isDuplicate(msgid)) {
277
- res.writeHead(200);
278
- res.end('');
279
- return;
280
- }
158
+ if (!state.finished) state.finished = true;
159
+ if (!state.content) state.content = '(\u65e0\u56de\u590d)';
281
160
 
282
- // Handle enter_chat event
283
- if (msgtype === 'event') {
284
- console.log(`[Event] ${msg.event?.eventtype} from=${from?.userid}`);
285
- res.writeHead(200);
286
- res.end('');
287
- return;
161
+ log.info(`[OpenClaw <-] stream=${streamId} len=${state.content.length} done`);
162
+ } catch (err) {
163
+ log.error(`[OpenClaw] error: ${err.message}`);
164
+ state.content = '\u26a0\ufe0f \u8bf7\u6c42\u5931\u8d25\uff0c\u8bf7\u7a0d\u540e\u518d\u8bd5';
165
+ state.finished = true;
288
166
  }
167
+ }
168
+
169
+ // ── HTTP Server (managed as OpenClaw service) ──
170
+
171
+ let server;
289
172
 
290
- const isGroup = chattype === 'group';
291
- const text = extractUserText(msg, isGroup);
292
- console.log(`[← ${source}] (${msgtype}) ${text.slice(0, 100)}`);
173
+ const requestHandler = async (req, res) => {
174
+ const url = new URL(req.url, `http://localhost:${port}`);
293
175
 
294
- if (!text) {
295
- res.writeHead(200);
296
- res.end('');
176
+ if (url.pathname !== '/callback') {
177
+ res.writeHead(404);
178
+ res.end('not found');
297
179
  return;
298
180
  }
299
181
 
300
- // Create stream state and start OpenClaw request
301
- const streamId = makeStreamId();
302
- const sessionId = chattype === 'group' ? `wecom_group_${chatid}` : `wecom_bot_${from?.userid}`;
182
+ const msgSignature = url.searchParams.get('msg_signature');
183
+ const timestamp = url.searchParams.get('timestamp');
184
+ const nonce = url.searchParams.get('nonce');
185
+
186
+ try {
187
+ // GET: URL verification
188
+ if (req.method === 'GET') {
189
+ const echostr = url.searchParams.get('echostr');
190
+ if (!botCrypto.verifySignature(msgSignature, timestamp, nonce, echostr)) {
191
+ res.writeHead(403);
192
+ res.end('signature mismatch');
193
+ return;
194
+ }
195
+ const { message } = botCrypto.decrypt(echostr);
196
+ log.info('[Verify] OK');
197
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
198
+ res.end(message);
199
+ return;
200
+ }
303
201
 
304
- streams.set(streamId, {
305
- content: '',
306
- finished: false,
307
- responseUrl: response_url,
308
- createdAt: Date.now(),
309
- });
202
+ // POST: message callback
203
+ if (req.method === 'POST') {
204
+ const body = await readBody(req);
205
+ const trimmed = body.trim();
310
206
 
311
- // Reply with initial stream message (encrypted)
312
- const replyObj = {
313
- msgtype: 'stream',
314
- stream: {
315
- id: streamId,
316
- finish: false,
317
- content: '思考中...',
318
- },
319
- };
320
-
321
- const encrypted = encryptReply(replyObj, nonce);
322
- res.writeHead(200, { 'Content-Type': 'application/json' });
323
- res.end(encrypted);
324
-
325
- console.log(`[Stream →] id=${streamId} started`);
326
-
327
- // Fire off OpenClaw request asynchronously
328
- callOpenClawStream(text, sessionId, streamId).catch(err => {
329
- console.error(`[OpenClaw] unhandled: ${err.message}`);
330
- const state = streams.get(streamId);
331
- if (state && !state.finished) {
332
- state.content = '⚠️ 内部错误';
333
- state.finished = true;
334
- }
335
- });
207
+ let encrypt;
208
+ if (trimmed.startsWith('{')) {
209
+ try { encrypt = JSON.parse(trimmed).encrypt; } catch {}
210
+ }
211
+ if (!encrypt) {
212
+ res.writeHead(400);
213
+ res.end('bad request');
214
+ return;
215
+ }
336
216
 
337
- cleanupStreams();
338
- return;
339
- }
217
+ if (!botCrypto.verifySignature(msgSignature, timestamp, nonce, encrypt)) {
218
+ res.writeHead(403);
219
+ res.end('signature mismatch');
220
+ return;
221
+ }
222
+
223
+ const { message: decrypted } = botCrypto.decrypt(encrypt);
224
+ const msg = JSON.parse(decrypted);
225
+
226
+ const { msgid, chatid, chattype, from, response_url, msgtype } = msg;
227
+ const source = chattype === 'group' ? `group:${chatid}` : `user:${from?.userid}`;
228
+
229
+ // ── Stream refresh callback ──
230
+ if (msgtype === 'stream') {
231
+ const sid = msg.stream?.id;
232
+ const state = streams.get(sid);
233
+
234
+ if (!state) {
235
+ log.debug(`[Stream] unknown id=${sid}, reply empty`);
236
+ res.writeHead(200);
237
+ res.end('');
238
+ return;
239
+ }
240
+
241
+ if (isDuplicate(msgid)) {
242
+ res.writeHead(200);
243
+ res.end('');
244
+ return;
245
+ }
340
246
 
341
- res.writeHead(405);
342
- res.end('method not allowed');
343
- } catch (err) {
344
- console.error('[Server] error:', err.message);
345
- res.writeHead(500);
346
- res.end('internal error');
347
- }
348
- });
349
-
350
- server.listen(Number(PORT), '127.0.0.1', () => {
351
- console.log(`wecom-bridge v1.0.0 listening on 127.0.0.1:${PORT}`);
352
- console.log(` Mode: 智能机器人 (流式回复)`);
353
- console.log(` Callback: /callback`);
354
- console.log(` OpenClaw: ${OPENCLAW_API}`);
355
- });
356
-
357
- process.on('SIGINT', () => {
358
- console.log('\nshutting down...');
359
- server.close();
360
- process.exit(0);
361
- });
247
+ const replyObj = {
248
+ msgtype: 'stream',
249
+ stream: {
250
+ id: sid,
251
+ finish: state.finished,
252
+ content: state.content || '\u601d\u8003\u4e2d...',
253
+ },
254
+ };
255
+
256
+ const encrypted = encryptReply(replyObj, nonce);
257
+ log.debug(`[Stream ~] id=${sid} finish=${state.finished} len=${(state.content || '').length}`);
258
+ res.writeHead(200, { 'Content-Type': 'application/json' });
259
+ res.end(encrypted);
260
+
261
+ if (state.finished) {
262
+ setTimeout(() => streams.delete(sid), 30000);
263
+ }
264
+ return;
265
+ }
266
+
267
+ // ── User message callback ──
268
+ if (isDuplicate(msgid)) {
269
+ res.writeHead(200);
270
+ res.end('');
271
+ return;
272
+ }
273
+
274
+ // Handle enter_chat event
275
+ if (msgtype === 'event') {
276
+ log.debug(`[Event] ${msg.event?.eventtype} from=${from?.userid}`);
277
+ res.writeHead(200);
278
+ res.end('');
279
+ return;
280
+ }
281
+
282
+ const isGroup = chattype === 'group';
283
+ const text = extractUserText(msg, isGroup);
284
+ log.info(`[<- ${source}] (${msgtype}) ${text.slice(0, 100)}`);
285
+
286
+ if (!text) {
287
+ res.writeHead(200);
288
+ res.end('');
289
+ return;
290
+ }
291
+
292
+ // Create stream state and start OpenClaw request
293
+ const streamId = makeStreamId();
294
+ const sessionId = chattype === 'group' ? `wecom_group_${chatid}` : `wecom_bot_${from?.userid}`;
295
+
296
+ streams.set(streamId, {
297
+ content: '',
298
+ finished: false,
299
+ responseUrl: response_url,
300
+ createdAt: Date.now(),
301
+ });
302
+
303
+ const replyObj = {
304
+ msgtype: 'stream',
305
+ stream: {
306
+ id: streamId,
307
+ finish: false,
308
+ content: '\u601d\u8003\u4e2d...',
309
+ },
310
+ };
311
+
312
+ const encrypted = encryptReply(replyObj, nonce);
313
+ res.writeHead(200, { 'Content-Type': 'application/json' });
314
+ res.end(encrypted);
315
+
316
+ log.info(`[Stream ->] id=${streamId} started`);
317
+
318
+ callOpenClawStream(text, sessionId, streamId).catch(err => {
319
+ log.error(`[OpenClaw] unhandled: ${err.message}`);
320
+ const state = streams.get(streamId);
321
+ if (state && !state.finished) {
322
+ state.content = '\u26a0\ufe0f \u5185\u90e8\u9519\u8bef';
323
+ state.finished = true;
324
+ }
325
+ });
326
+
327
+ cleanupStreams();
328
+ return;
329
+ }
330
+
331
+ res.writeHead(405);
332
+ res.end('method not allowed');
333
+ } catch (err) {
334
+ log.error(`[Server] error: ${err.message}`);
335
+ res.writeHead(500);
336
+ res.end('internal error');
337
+ }
338
+ };
339
+
340
+ api.registerService({
341
+ id: 'wecom-server',
342
+ async start() {
343
+ server = http.createServer(requestHandler);
344
+
345
+ return new Promise((resolve) => {
346
+ server.on('error', (err) => {
347
+ if (err.code === 'EADDRINUSE') {
348
+ log.error(`Port ${port} already in use. Change port in plugins.entries.wecom.config.port or stop the conflicting process.`);
349
+ } else {
350
+ log.error(`Server error: ${err.message}`);
351
+ }
352
+ // Resolve instead of reject — don't crash OpenClaw
353
+ resolve();
354
+ });
355
+
356
+ server.listen(port, '127.0.0.1', () => {
357
+ log.info(`wecom-bridge listening on 127.0.0.1:${port}`);
358
+ log.info(` Callback: /callback`);
359
+ log.info(` OpenClaw: ${openclawApi}`);
360
+ resolve();
361
+ });
362
+ });
363
+ },
364
+
365
+ async stop() {
366
+ if (server) {
367
+ return new Promise((resolve) => server.close(resolve));
368
+ }
369
+ },
370
+ });
371
+ },
372
+ };
362
373
 
374
+ export default plugin;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "wecom",
3
- "version": "0.1.0-beta",
3
+ "version": "0.2.0",
4
4
  "name": "企业微信(WeCom)",
5
5
  "description": "企业微信智能助手机器人桥接,支持流式回复、加密消息、单聊和群聊",
6
6
  "author": "CreatorAris",
@@ -28,11 +28,6 @@
28
28
  "type": "boolean",
29
29
  "description": "启用企业微信通道"
30
30
  },
31
- "webhookUrl": {
32
- "type": "string",
33
- "format": "uri",
34
- "description": "Webhook 回调 URL"
35
- },
36
31
  "token": {
37
32
  "type": "string",
38
33
  "description": "企业微信机器人 Token"
@@ -41,20 +36,18 @@
41
36
  "type": "string",
42
37
  "description": "企业微信消息加密密钥"
43
38
  },
39
+ "corpId": {
40
+ "type": "string",
41
+ "description": "企业微信 CorpID(可选,用于消息加密校验)"
42
+ },
44
43
  "port": {
45
44
  "type": "number",
46
45
  "default": 8788,
47
46
  "description": "Webhook 服务器监听端口"
48
47
  }
49
- },
50
- "required": ["webhookUrl", "token", "encodingAESKey"]
48
+ }
51
49
  },
52
50
  "uiHints": {
53
- "webhookUrl": {
54
- "label": "Webhook URL",
55
- "placeholder": "https://your-domain.com/wecom/callback",
56
- "description": "企业微信回调 URL,需要公网可访问"
57
- },
58
51
  "token": {
59
52
  "label": "Token",
60
53
  "sensitive": true,
@@ -65,9 +58,13 @@
65
58
  "sensitive": true,
66
59
  "description": "用于消息加密解密"
67
60
  },
61
+ "corpId": {
62
+ "label": "CorpID",
63
+ "description": "企业微信企业 ID,可选"
64
+ },
68
65
  "port": {
69
66
  "label": "端口",
70
- "description": "Webhook 服务器监听端口"
67
+ "description": "Webhook 服务器监听端口,默认 8788"
71
68
  }
72
69
  }
73
70
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@creatoraris/openclaw-wecom",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Enterprise WeChat (WeCom) channel plugin for OpenClaw - 企业微信智能助手机器人桥接",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -26,9 +26,7 @@
26
26
  "scripts": {
27
27
  "start": "node index.js"
28
28
  },
29
- "dependencies": {
30
- "dotenv": "^16.4.0"
31
- },
29
+ "dependencies": {},
32
30
  "engines": {
33
31
  "node": ">=18.0.0"
34
32
  },