evolclaw 2.3.0 → 2.5.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.
@@ -0,0 +1,549 @@
1
+ import crypto from 'node:crypto';
2
+ import { logger } from '../utils/logger.js';
3
+ import { normalizeChannelInstances, getChannelShowActivities } from '../config.js';
4
+ // ── WecomChannel ───────────────────────────────────────────────────────────────
5
+ export class WecomChannel {
6
+ config;
7
+ client = null;
8
+ connected = false;
9
+ messageHandler = null;
10
+ seenMessages = new Map();
11
+ cleanupInterval = null;
12
+ projectPathProvider = null;
13
+ // Stream reply state: reqId → { streamId, frame }
14
+ activeStreams = new Map();
15
+ constructor(config) {
16
+ this.config = config;
17
+ }
18
+ // ── Public helpers (testable) ─────────────────────────────────────────────
19
+ isDuplicate(msgId) {
20
+ if (this.seenMessages.has(msgId))
21
+ return true;
22
+ this.seenMessages.set(msgId, Date.now());
23
+ return false;
24
+ }
25
+ resolveChatId(chattype, chatid, userid) {
26
+ return chattype === 'group' && chatid ? chatid : userid;
27
+ }
28
+ // ── Lifecycle ─────────────────────────────────────────────────────────────
29
+ async connect() {
30
+ const { botId, secret } = this.config;
31
+ if (!botId || !secret) {
32
+ throw new Error('WeCom botId/secret not configured');
33
+ }
34
+ const { WSClient } = await import('@wecom/aibot-node-sdk');
35
+ this.client = new WSClient({ botId, secret });
36
+ // Message events
37
+ this.client.on('message', (frame) => {
38
+ this.handleIncoming(frame).catch((err) => {
39
+ logger.error('[WeCom] Failed to process incoming message:', err);
40
+ });
41
+ });
42
+ // Event callbacks (enter_chat, etc.)
43
+ this.client.on('event.enter_chat', (frame) => {
44
+ const body = frame?.body;
45
+ if (body) {
46
+ logger.debug(`[WeCom] User entered chat: userid=${body.from?.userid} chattype=${body.chattype}`);
47
+ }
48
+ });
49
+ // Lifecycle events
50
+ this.client.on('authenticated', () => {
51
+ logger.info('[WeCom] WebSocket authenticated');
52
+ });
53
+ this.client.on('disconnected', (reason) => {
54
+ logger.warn(`[WeCom] WebSocket disconnected: ${reason}`);
55
+ this.connected = false;
56
+ });
57
+ this.client.on('reconnecting', (attempt) => {
58
+ logger.info(`[WeCom] Reconnecting (attempt ${attempt})...`);
59
+ });
60
+ this.client.on('error', (error) => {
61
+ logger.error('[WeCom] WebSocket error:', error);
62
+ });
63
+ this.client.connect();
64
+ this.connected = true;
65
+ // Hourly cleanup of old dedup entries
66
+ this.cleanupInterval = setInterval(() => {
67
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
68
+ for (const [id, ts] of this.seenMessages) {
69
+ if (ts < cutoff)
70
+ this.seenMessages.delete(id);
71
+ }
72
+ }, 60 * 60 * 1000);
73
+ logger.info('[WeCom] Channel connected');
74
+ }
75
+ async disconnect() {
76
+ this.connected = false;
77
+ if (this.cleanupInterval) {
78
+ clearInterval(this.cleanupInterval);
79
+ this.cleanupInterval = null;
80
+ }
81
+ if (this.client) {
82
+ try {
83
+ this.client.disconnect();
84
+ }
85
+ catch { /* ignore */ }
86
+ this.client = null;
87
+ }
88
+ logger.info('[WeCom] Channel disconnected');
89
+ }
90
+ onMessage(handler) {
91
+ this.messageHandler = handler;
92
+ }
93
+ getStatus() {
94
+ return { connected: this.connected };
95
+ }
96
+ async reconnect() {
97
+ await this.disconnect();
98
+ try {
99
+ await this.connect();
100
+ return '重连成功';
101
+ }
102
+ catch (err) {
103
+ return `重连失败: ${err instanceof Error ? err.message : String(err)}`;
104
+ }
105
+ }
106
+ // ── Inbound message handling ──────────────────────────────────────────────
107
+ async handleIncoming(frame) {
108
+ const body = frame?.body;
109
+ if (!body)
110
+ return;
111
+ const msgId = body.msgid;
112
+ const chattype = body.chattype || 'single';
113
+ const chatid = body.chatid;
114
+ const userid = body.from?.userid || '';
115
+ const msgtype = body.msgtype;
116
+ // Dedup
117
+ if (msgId && this.isDuplicate(msgId)) {
118
+ logger.debug(`[WeCom] Duplicate message skipped: ${msgId}`);
119
+ return;
120
+ }
121
+ const channelId = this.resolveChatId(chattype, chatid, userid);
122
+ const chatTypeNorm = chattype === 'group' ? 'group' : 'private';
123
+ // Store frame for stream replies
124
+ this.activeStreams.set(channelId, {
125
+ streamId: crypto.randomUUID(),
126
+ frame: { headers: frame.headers },
127
+ });
128
+ if (!this.messageHandler)
129
+ return;
130
+ if (msgtype === 'text') {
131
+ const text = body.text?.content?.trim();
132
+ if (!text)
133
+ return;
134
+ // Handle quote/reference
135
+ let content = text;
136
+ if (body.quote) {
137
+ const quoteText = body.quote.text?.content || '';
138
+ if (quoteText) {
139
+ content = `[引用: ${quoteText}]\n${text}`;
140
+ }
141
+ }
142
+ await this.messageHandler({
143
+ channelId, content, chatType: chatTypeNorm,
144
+ peerId: userid, messageId: msgId,
145
+ });
146
+ }
147
+ else if (msgtype === 'image') {
148
+ await this.handleImageMessage(body, channelId, chatTypeNorm, userid, msgId, frame);
149
+ }
150
+ else if (msgtype === 'voice') {
151
+ const voiceText = body.voice?.content?.trim();
152
+ if (voiceText) {
153
+ await this.messageHandler({
154
+ channelId, content: voiceText, chatType: chatTypeNorm,
155
+ peerId: userid, messageId: msgId,
156
+ });
157
+ }
158
+ }
159
+ else if (msgtype === 'file') {
160
+ await this.handleFileMessage(body, channelId, chatTypeNorm, userid, msgId, frame);
161
+ }
162
+ else if (msgtype === 'video') {
163
+ await this.handleVideoMessage(body, channelId, chatTypeNorm, userid, msgId, frame);
164
+ }
165
+ else if (msgtype === 'mixed') {
166
+ await this.handleMixedMessage(body, channelId, chatTypeNorm, userid, msgId, frame);
167
+ }
168
+ else {
169
+ await this.messageHandler({
170
+ channelId,
171
+ content: `[不支持的消息类型: ${msgtype}]`,
172
+ chatType: chatTypeNorm, peerId: userid, messageId: msgId,
173
+ });
174
+ }
175
+ }
176
+ // ── Inbound media handling ────────────────────────────────────────────────
177
+ async handleImageMessage(body, channelId, chatType, peerId, msgId, frame) {
178
+ const imageUrl = body.image?.url;
179
+ const aeskey = body.image?.aeskey;
180
+ if (!imageUrl) {
181
+ logger.warn('[WeCom] Image message without url');
182
+ await this.messageHandler({
183
+ channelId, content: '[图片下载失败:缺少下载链接]',
184
+ chatType, peerId, messageId: msgId,
185
+ });
186
+ return;
187
+ }
188
+ try {
189
+ let buffer;
190
+ if (this.client && aeskey) {
191
+ const result = await this.client.downloadFile(imageUrl, aeskey);
192
+ buffer = result.buffer;
193
+ }
194
+ else {
195
+ const { safeFetch } = await import('../utils/media-cache.js');
196
+ buffer = await safeFetch(imageUrl, { skipSsrfCheck: true });
197
+ }
198
+ const { validateImage } = await import('../utils/media-cache.js');
199
+ const result = await validateImage(buffer);
200
+ if (result.mime) {
201
+ await this.messageHandler({
202
+ channelId,
203
+ content: '用户发送了一张图片,请分析这张图片的内容。',
204
+ chatType, peerId, messageId: msgId,
205
+ images: [{ data: buffer.toString('base64'), mimeType: result.mime }],
206
+ });
207
+ }
208
+ else {
209
+ logger.warn(`[WeCom] Image validation failed`);
210
+ await this.messageHandler({
211
+ channelId, content: '[图片验证失败]',
212
+ chatType, peerId, messageId: msgId,
213
+ });
214
+ }
215
+ }
216
+ catch (error) {
217
+ logger.error('[WeCom] Failed to download image:', error);
218
+ await this.messageHandler({
219
+ channelId, content: '[图片下载失败]',
220
+ chatType, peerId, messageId: msgId,
221
+ });
222
+ }
223
+ }
224
+ async handleFileMessage(body, channelId, chatType, peerId, msgId, frame) {
225
+ const fileUrl = body.file?.url;
226
+ const aeskey = body.file?.aeskey;
227
+ const fileName = body.file?.filename || 'unknown';
228
+ if (!fileUrl) {
229
+ logger.warn('[WeCom] File message without url');
230
+ await this.messageHandler({
231
+ channelId, content: `[文件下载失败:缺少下载链接] ${fileName}`,
232
+ chatType, peerId, messageId: msgId,
233
+ });
234
+ return;
235
+ }
236
+ try {
237
+ let buffer;
238
+ if (this.client && aeskey) {
239
+ const result = await this.client.downloadFile(fileUrl, aeskey);
240
+ buffer = result.buffer;
241
+ }
242
+ else {
243
+ const { safeFetch } = await import('../utils/media-cache.js');
244
+ buffer = await safeFetch(fileUrl, { skipSsrfCheck: true });
245
+ }
246
+ const { saveToUploads, sanitizeFileName } = await import('../utils/media-cache.js');
247
+ const projectPath = this.projectPathProvider
248
+ ? await this.projectPathProvider(channelId)
249
+ : process.cwd();
250
+ const { filePath } = saveToUploads(buffer, sanitizeFileName(fileName), projectPath);
251
+ await this.messageHandler({
252
+ channelId,
253
+ content: `用户发送了文件:${fileName}\n文件已保存到:${filePath}\n请使用 Read 工具读取并分析文件内容。`,
254
+ chatType, peerId, messageId: msgId,
255
+ });
256
+ }
257
+ catch (error) {
258
+ logger.error('[WeCom] Failed to download file:', error);
259
+ await this.messageHandler({
260
+ channelId, content: `[文件下载失败] ${fileName}`,
261
+ chatType, peerId, messageId: msgId,
262
+ });
263
+ }
264
+ }
265
+ async handleVideoMessage(body, channelId, chatType, peerId, msgId, frame) {
266
+ const videoUrl = body.video?.url;
267
+ const aeskey = body.video?.aeskey;
268
+ if (!videoUrl) {
269
+ await this.messageHandler({
270
+ channelId, content: '[视频下载失败:缺少下载链接]',
271
+ chatType, peerId, messageId: msgId,
272
+ });
273
+ return;
274
+ }
275
+ try {
276
+ let buffer;
277
+ if (this.client && aeskey) {
278
+ const result = await this.client.downloadFile(videoUrl, aeskey);
279
+ buffer = result.buffer;
280
+ }
281
+ else {
282
+ const { safeFetch } = await import('../utils/media-cache.js');
283
+ buffer = await safeFetch(videoUrl, { skipSsrfCheck: true });
284
+ }
285
+ const { saveToUploads } = await import('../utils/media-cache.js');
286
+ const projectPath = this.projectPathProvider
287
+ ? await this.projectPathProvider(channelId)
288
+ : process.cwd();
289
+ const fileName = `video_${Date.now()}.mp4`;
290
+ const { filePath } = saveToUploads(buffer, fileName, projectPath);
291
+ await this.messageHandler({
292
+ channelId,
293
+ content: `用户发送了视频:${fileName}\n文件已保存到:${filePath}`,
294
+ chatType, peerId, messageId: msgId,
295
+ });
296
+ }
297
+ catch (error) {
298
+ logger.error('[WeCom] Failed to download video:', error);
299
+ await this.messageHandler({
300
+ channelId, content: '[视频下载失败]',
301
+ chatType, peerId, messageId: msgId,
302
+ });
303
+ }
304
+ }
305
+ async handleMixedMessage(body, channelId, chatType, peerId, msgId, frame) {
306
+ const msgItems = body.mixed?.msg_item;
307
+ if (!Array.isArray(msgItems)) {
308
+ await this.messageHandler({
309
+ channelId, content: '[不支持的图文混排格式]',
310
+ chatType, peerId, messageId: msgId,
311
+ });
312
+ return;
313
+ }
314
+ let text = '';
315
+ const images = [];
316
+ for (const item of msgItems) {
317
+ if (item.msgtype === 'text' && item.text?.content) {
318
+ text += item.text.content;
319
+ }
320
+ else if (item.msgtype === 'image' && item.image?.url) {
321
+ try {
322
+ let buffer;
323
+ if (this.client && item.image.aeskey) {
324
+ const result = await this.client.downloadFile(item.image.url, item.image.aeskey);
325
+ buffer = result.buffer;
326
+ }
327
+ else {
328
+ const { safeFetch } = await import('../utils/media-cache.js');
329
+ buffer = await safeFetch(item.image.url, { skipSsrfCheck: true });
330
+ }
331
+ const { validateImage } = await import('../utils/media-cache.js');
332
+ const result = await validateImage(buffer);
333
+ if (result.mime) {
334
+ images.push({ data: buffer.toString('base64'), mimeType: result.mime });
335
+ }
336
+ }
337
+ catch (error) {
338
+ logger.warn('[WeCom] Failed to download mixed image:', error);
339
+ }
340
+ }
341
+ }
342
+ const prompt = text.trim() || (images.length > 0 ? '用户发送了一张图片,请分析这张图片的内容。' : '[空消息]');
343
+ await this.messageHandler({
344
+ channelId, content: prompt, chatType,
345
+ peerId, messageId: msgId,
346
+ images: images.length > 0 ? images : undefined,
347
+ });
348
+ }
349
+ // ── Outbound: text ────────────────────────────────────────────────────────
350
+ async sendMessage(chatId, content) {
351
+ if (!content || content.trim() === '') {
352
+ logger.warn('[WeCom] Attempted to send empty message, skipping');
353
+ return;
354
+ }
355
+ if (!this.client) {
356
+ logger.error('[WeCom] Client not connected, cannot send message');
357
+ return;
358
+ }
359
+ try {
360
+ // Try stream reply first (responds to a specific user message)
361
+ const stream = this.activeStreams.get(chatId);
362
+ if (stream) {
363
+ await this.client.replyStream(stream.frame, stream.streamId, content, true);
364
+ this.activeStreams.delete(chatId);
365
+ logger.debug(`[WeCom] Sent stream reply to ${chatId}`);
366
+ return;
367
+ }
368
+ // Fallback: proactive send (markdown)
369
+ await this.client.sendMessage(chatId, {
370
+ msgtype: 'markdown',
371
+ markdown: { content },
372
+ });
373
+ logger.debug(`[WeCom] Sent proactive message to ${chatId}`);
374
+ }
375
+ catch (error) {
376
+ logger.error(`[WeCom] sendMessage failed for ${chatId}:`, error.message);
377
+ }
378
+ }
379
+ // ── Outbound: image ───────────────────────────────────────────────────────
380
+ async sendImage(chatId, png) {
381
+ if (!this.client) {
382
+ logger.warn('[WeCom] Client not connected for sendImage');
383
+ return;
384
+ }
385
+ try {
386
+ const result = await this.client.uploadMedia(png, {
387
+ type: 'image',
388
+ filename: 'image.png',
389
+ });
390
+ const mediaId = result?.media_id;
391
+ if (!mediaId) {
392
+ logger.error('[WeCom] Media upload failed: no media_id');
393
+ return;
394
+ }
395
+ // Try reply media if we have an active frame, else proactive send
396
+ const stream = this.activeStreams.get(chatId);
397
+ if (stream) {
398
+ await this.client.replyMedia(stream.frame, 'image', mediaId);
399
+ this.activeStreams.delete(chatId);
400
+ }
401
+ else {
402
+ await this.client.sendMediaMessage(chatId, 'image', mediaId);
403
+ }
404
+ logger.debug(`[WeCom] Sent image to ${chatId}`);
405
+ }
406
+ catch (error) {
407
+ logger.error(`[WeCom] sendImage failed for ${chatId}:`, error.message);
408
+ }
409
+ }
410
+ // ── Outbound: file ────────────────────────────────────────────────────────
411
+ async sendFile(chatId, filePath) {
412
+ if (!this.client) {
413
+ logger.warn('[WeCom] Client not connected for sendFile');
414
+ return;
415
+ }
416
+ try {
417
+ const fs = await import('fs');
418
+ const path = await import('path');
419
+ if (!fs.existsSync(filePath)) {
420
+ logger.error(`[WeCom] File not found: ${filePath}`);
421
+ return;
422
+ }
423
+ // Detect image files → route to sendImage
424
+ const header = Buffer.alloc(12);
425
+ const fd = fs.openSync(filePath, 'r');
426
+ fs.readSync(fd, header, 0, 12, 0);
427
+ fs.closeSync(fd);
428
+ const { fileTypeFromBuffer } = await import('file-type');
429
+ const ftype = await fileTypeFromBuffer(header);
430
+ if (ftype && ftype.mime.startsWith('image/')) {
431
+ const buf = fs.readFileSync(filePath);
432
+ return this.sendImage(chatId, buf);
433
+ }
434
+ const buf = fs.readFileSync(filePath);
435
+ const fileName = path.basename(filePath);
436
+ const result = await this.client.uploadMedia(buf, {
437
+ type: 'file',
438
+ filename: fileName,
439
+ });
440
+ const mediaId = result?.media_id;
441
+ if (!mediaId) {
442
+ logger.error('[WeCom] File upload failed: no media_id');
443
+ return;
444
+ }
445
+ const stream = this.activeStreams.get(chatId);
446
+ if (stream) {
447
+ await this.client.replyMedia(stream.frame, 'file', mediaId);
448
+ this.activeStreams.delete(chatId);
449
+ }
450
+ else {
451
+ await this.client.sendMediaMessage(chatId, 'file', mediaId);
452
+ }
453
+ logger.debug(`[WeCom] Sent file ${fileName} to ${chatId}`);
454
+ }
455
+ catch (error) {
456
+ logger.error(`[WeCom] sendFile failed for ${chatId}:`, error.message);
457
+ }
458
+ }
459
+ }
460
+ // ── Plugin ─────────────────────────────────────────────────────────────────────
461
+ function isValidCredential(value) {
462
+ return !!value && !value.includes('your-') && !value.includes('placeholder');
463
+ }
464
+ export class WecomChannelPlugin {
465
+ name = 'wecom';
466
+ isEnabled(config) {
467
+ const raw = config.channels?.wecom;
468
+ if (!raw)
469
+ return false;
470
+ if (Array.isArray(raw)) {
471
+ return raw.some(inst => inst.enabled !== false && isValidCredential(inst.botId) && isValidCredential(inst.secret));
472
+ }
473
+ if (raw.enabled === false)
474
+ return false;
475
+ return isValidCredential(raw.botId) && isValidCredential(raw.secret);
476
+ }
477
+ async createChannels(config) {
478
+ const instances = normalizeChannelInstances(config.channels?.wecom, 'wecom');
479
+ const result = [];
480
+ for (const inst of instances) {
481
+ if (inst.enabled === false)
482
+ continue;
483
+ if (!isValidCredential(inst.botId) || !isValidCredential(inst.secret))
484
+ continue;
485
+ const channel = new WecomChannel({
486
+ botId: inst.botId,
487
+ secret: inst.secret,
488
+ });
489
+ const adapter = {
490
+ channelName: inst.name,
491
+ sendText: (id, text) => channel.sendMessage(id, text),
492
+ sendFile: (id, filePath) => channel.sendFile(id, filePath),
493
+ sendImage: (id, png) => channel.sendImage(id, png),
494
+ };
495
+ const policy = {
496
+ canSwitchProject: (_chatType, identity) => identity === 'owner' || identity === 'admin',
497
+ canListProjects: (_chatType, identity) => identity === 'owner' || identity === 'admin',
498
+ canCreateSession: () => true,
499
+ canDeleteSession: () => true,
500
+ canImportCliSession: (_chatType, identity) => identity === 'owner' || identity === 'admin',
501
+ messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
502
+ showMiddleResult: (chatType, identity) => {
503
+ const mode = getChannelShowActivities(config, inst.name);
504
+ if (mode === 'none')
505
+ return false;
506
+ if (mode === 'dm-only')
507
+ return chatType === 'private';
508
+ if (mode === 'owner-dm-only')
509
+ return chatType === 'private' && identity === 'owner';
510
+ return true;
511
+ },
512
+ showIdleMonitor: (chatType, identity) => {
513
+ const mode = getChannelShowActivities(config, inst.name);
514
+ if (mode === 'none')
515
+ return false;
516
+ if (mode === 'dm-only')
517
+ return chatType === 'private';
518
+ if (mode === 'owner-dm-only')
519
+ return chatType === 'private' && identity === 'owner';
520
+ return true;
521
+ },
522
+ accumulateErrors: () => true,
523
+ };
524
+ const options = {
525
+ fileMarkerPattern: /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g,
526
+ supportsImages: true,
527
+ flushDelay: inst.flushDelay,
528
+ };
529
+ result.push({
530
+ channelType: 'wecom',
531
+ adapter,
532
+ channel,
533
+ policy,
534
+ options,
535
+ connect: () => channel.connect(),
536
+ disconnect: () => channel.disconnect(),
537
+ onProjectPathRequest: () => Promise.resolve(config.projects?.defaultPath || process.cwd()),
538
+ });
539
+ }
540
+ return result;
541
+ }
542
+ async createChannel(config) {
543
+ const instances = await this.createChannels(config);
544
+ if (instances.length === 0) {
545
+ throw new Error('WeCom config missing or invalid');
546
+ }
547
+ return instances[0];
548
+ }
549
+ }