@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.
- package/index.js +331 -319
- package/openclaw.plugin.json +11 -14
- 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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 (!
|
|
109
|
-
|
|
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
|
-
//
|
|
117
|
-
const
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
//
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
const
|
|
212
|
-
const
|
|
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
|
-
|
|
215
|
-
|
|
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 (
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
231
|
-
const msg = JSON.parse(decrypted);
|
|
90
|
+
// ── OpenClaw SSE streaming call ──
|
|
232
91
|
|
|
233
|
-
|
|
234
|
-
const
|
|
92
|
+
async function callOpenClawStream(text, sessionId, streamId) {
|
|
93
|
+
const state = streams.get(streamId);
|
|
94
|
+
if (!state) return;
|
|
235
95
|
|
|
236
|
-
|
|
237
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
276
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
291
|
-
const
|
|
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 (
|
|
295
|
-
res.writeHead(
|
|
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
|
-
|
|
301
|
-
const
|
|
302
|
-
const
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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;
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "wecom",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
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
|
},
|