evolclaw 2.4.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,506 @@
1
+ import { logger } from '../utils/logger.js';
2
+ import { normalizeChannelInstances, getChannelShowActivities } from '../config.js';
3
+ // ── Webhook SSRF validation ────────────────────────────────────────────────────
4
+ const WEBHOOK_RE = /^https:\/\/(api|oapi)\.dingtalk\.com\//;
5
+ // ── DingtalkChannel ────────────────────────────────────────────────────────────
6
+ export class DingtalkChannel {
7
+ config;
8
+ client = null;
9
+ connected = false;
10
+ messageHandler = null;
11
+ recallHandler;
12
+ webhookCache = new Map();
13
+ conversationIdCache = new Map();
14
+ senderStaffIdCache = new Map();
15
+ seenMessages = new Map();
16
+ cleanupInterval = null;
17
+ projectPathProvider = null;
18
+ constructor(config) {
19
+ this.config = config;
20
+ }
21
+ // ── Public helpers (testable) ──────────────────────────────────────────────
22
+ isValidWebhook(url) {
23
+ if (!url)
24
+ return false;
25
+ return WEBHOOK_RE.test(url);
26
+ }
27
+ isDuplicate(msgId) {
28
+ if (this.seenMessages.has(msgId))
29
+ return true;
30
+ this.seenMessages.set(msgId, Date.now());
31
+ return false;
32
+ }
33
+ resolveChatId(conversationType, conversationId, senderId) {
34
+ return conversationType === '2' ? conversationId : senderId;
35
+ }
36
+ shouldProcessGroupMessage(conversationId, isInAtList) {
37
+ if (this.config.requireMention === false)
38
+ return true;
39
+ if (this.config.freeResponseChats?.includes(conversationId))
40
+ return true;
41
+ return isInAtList;
42
+ }
43
+ extractText(content) {
44
+ if (!content)
45
+ return '';
46
+ const text = content.text;
47
+ if (typeof text === 'string')
48
+ return text.trim();
49
+ if (text && typeof text === 'object' && typeof text.content === 'string')
50
+ return text.content.trim();
51
+ return '';
52
+ }
53
+ // ── Lifecycle ──────────────────────────────────────────────────────────────
54
+ async connect() {
55
+ const { clientId, clientSecret } = this.config;
56
+ if (!clientId || !clientSecret || clientId.includes('your-') || clientSecret.includes('your-')) {
57
+ throw new Error('DingTalk clientId/clientSecret not configured');
58
+ }
59
+ const { DWClient, TOPIC_ROBOT } = await import('dingtalk-stream');
60
+ this.client = new DWClient({ clientId, clientSecret });
61
+ this.client.registerCallbackListener(TOPIC_ROBOT, async (msg) => {
62
+ await this.handleIncoming(msg);
63
+ });
64
+ await this.client.connect();
65
+ this.connected = true;
66
+ // Hourly cleanup of old dedup entries
67
+ this.cleanupInterval = setInterval(() => {
68
+ const cutoff = Date.now() - 24 * 60 * 60 * 1000;
69
+ for (const [id, ts] of this.seenMessages) {
70
+ if (ts < cutoff)
71
+ this.seenMessages.delete(id);
72
+ }
73
+ }, 60 * 60 * 1000);
74
+ logger.info('[DingTalk] Connected via Stream Mode');
75
+ }
76
+ async disconnect() {
77
+ this.connected = false;
78
+ if (this.cleanupInterval) {
79
+ clearInterval(this.cleanupInterval);
80
+ this.cleanupInterval = null;
81
+ }
82
+ if (this.client) {
83
+ try {
84
+ this.client.disconnect();
85
+ }
86
+ catch { /* ignore */ }
87
+ this.client = null;
88
+ }
89
+ logger.info('[DingTalk] Disconnected');
90
+ }
91
+ onMessage(handler) {
92
+ this.messageHandler = handler;
93
+ }
94
+ onRecall(handler) {
95
+ this.recallHandler = handler;
96
+ }
97
+ // ── Inbound message handling ───────────────────────────────────────────────
98
+ async handleIncoming(msg) {
99
+ try {
100
+ const data = typeof msg.data === 'string' ? JSON.parse(msg.data) : msg.data;
101
+ const msgId = data.msgId;
102
+ const conversationType = data.conversationType;
103
+ const conversationId = data.conversationId;
104
+ const senderId = data.senderStaffId || data.senderId;
105
+ const senderNick = data.senderNick;
106
+ const sessionWebhook = data.sessionWebhook;
107
+ const msgtype = data.msgtype;
108
+ // Dedup
109
+ if (msgId && this.isDuplicate(msgId)) {
110
+ logger.debug(`[DingTalk] Duplicate message skipped: ${msgId}`);
111
+ return;
112
+ }
113
+ const chatId = this.resolveChatId(conversationType, conversationId, senderId);
114
+ const chatType = conversationType === '2' ? 'group' : 'private';
115
+ // Cache sender info for Open API sends
116
+ if (senderId)
117
+ this.senderStaffIdCache.set(chatId, senderId);
118
+ if (conversationId)
119
+ this.conversationIdCache.set(chatId, conversationId);
120
+ // Group gate
121
+ if (conversationType === '2') {
122
+ const isInAtList = !!(data.isInAtList || (data.atUsers && data.atUsers.length > 0));
123
+ if (!this.shouldProcessGroupMessage(conversationId, isInAtList)) {
124
+ logger.debug(`[DingTalk] Group message ignored (not mentioned): ${msgId}`);
125
+ return;
126
+ }
127
+ }
128
+ // Webhook cache (SSRF validated)
129
+ if (sessionWebhook && this.isValidWebhook(sessionWebhook)) {
130
+ this.webhookCache.set(chatId, sessionWebhook);
131
+ }
132
+ // ACK to prevent 60s retry
133
+ if (this.client && msg.headers?.messageId) {
134
+ try {
135
+ this.client.socketCallBackResponse(msg.headers.messageId, { response: JSON.stringify({ status: 'OK' }) });
136
+ }
137
+ catch (e) {
138
+ logger.warn('[DingTalk] ACK failed:', e);
139
+ }
140
+ }
141
+ // Dispatch by msgtype
142
+ if (!this.messageHandler)
143
+ return;
144
+ if (msgtype === 'text' || !msgtype) {
145
+ const text = this.extractText(data);
146
+ if (!text)
147
+ return;
148
+ await this.messageHandler({
149
+ channelId: chatId, content: text, chatType,
150
+ peerId: senderId || '', peerName: senderNick, messageId: msgId,
151
+ });
152
+ }
153
+ else if (msgtype === 'picture' || msgtype === 'image') {
154
+ await this.handleImageMessage(data, chatId, chatType, senderId, senderNick, msgId);
155
+ }
156
+ else if (msgtype === 'file') {
157
+ await this.handleFileMessage(data, chatId, chatType, senderId, senderNick, msgId);
158
+ }
159
+ else if (msgtype === 'richText') {
160
+ await this.handleRichTextMessage(data, chatId, chatType, senderId, senderNick, msgId);
161
+ }
162
+ else {
163
+ await this.messageHandler({
164
+ channelId: chatId,
165
+ content: `[不支持的消息类型: ${msgtype}]`,
166
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
167
+ });
168
+ }
169
+ }
170
+ catch (error) {
171
+ logger.error('[DingTalk] Failed to process incoming message:', error);
172
+ }
173
+ }
174
+ // ── Inbound media handling ─────────────────────────────────────────────────
175
+ async handleImageMessage(data, chatId, chatType, senderId, senderNick, msgId) {
176
+ const content = typeof data.content === 'string' ? JSON.parse(data.content) : (data.content || {});
177
+ const downloadUrl = content.downloadUrl || content.downloadCode;
178
+ if (!downloadUrl) {
179
+ logger.warn('[DingTalk] Image message without downloadUrl');
180
+ await this.messageHandler({
181
+ channelId: chatId, content: '[图片下载失败:缺少下载链接]',
182
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
183
+ });
184
+ return;
185
+ }
186
+ try {
187
+ const { safeFetch, validateImage } = await import('../utils/media-cache.js');
188
+ const buffer = await safeFetch(downloadUrl, { skipSsrfCheck: true });
189
+ const result = await validateImage(buffer);
190
+ if (result.mime) {
191
+ await this.messageHandler({
192
+ channelId: chatId,
193
+ content: '用户发送了一张图片,请分析这张图片的内容。',
194
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
195
+ images: [{ data: buffer.toString('base64'), mimeType: result.mime }],
196
+ });
197
+ }
198
+ else {
199
+ logger.warn(`[DingTalk] Image validation failed: ${!result.mime && 'reason' in result ? result.reason : 'unknown'}`);
200
+ await this.messageHandler({
201
+ channelId: chatId, content: '[图片验证失败]',
202
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
203
+ });
204
+ }
205
+ }
206
+ catch (error) {
207
+ logger.error('[DingTalk] Failed to download image:', error);
208
+ await this.messageHandler({
209
+ channelId: chatId, content: '[图片下载失败]',
210
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
211
+ });
212
+ }
213
+ }
214
+ async handleFileMessage(data, chatId, chatType, senderId, senderNick, msgId) {
215
+ const content = typeof data.content === 'string' ? JSON.parse(data.content) : (data.content || {});
216
+ const downloadUrl = content.downloadUrl || content.downloadCode;
217
+ const fileName = content.fileName || 'unknown';
218
+ if (!downloadUrl) {
219
+ logger.warn('[DingTalk] File message without downloadUrl');
220
+ await this.messageHandler({
221
+ channelId: chatId, content: `[文件下载失败:缺少下载链接] ${fileName}`,
222
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
223
+ });
224
+ return;
225
+ }
226
+ try {
227
+ const { safeFetch, saveToUploads, sanitizeFileName } = await import('../utils/media-cache.js');
228
+ const projectPath = this.projectPathProvider
229
+ ? await this.projectPathProvider(chatId)
230
+ : process.cwd();
231
+ const buffer = await safeFetch(downloadUrl, { skipSsrfCheck: true });
232
+ const { filePath } = saveToUploads(buffer, sanitizeFileName(fileName), projectPath);
233
+ await this.messageHandler({
234
+ channelId: chatId,
235
+ content: `用户发送了文件:${fileName}\n文件已保存到:${filePath}\n请使用 Read 工具读取并分析文件内容。`,
236
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
237
+ });
238
+ }
239
+ catch (error) {
240
+ logger.error('[DingTalk] Failed to download file:', error);
241
+ await this.messageHandler({
242
+ channelId: chatId, content: `[文件下载失败] ${fileName}`,
243
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
244
+ });
245
+ }
246
+ }
247
+ async handleRichTextMessage(data, chatId, chatType, senderId, senderNick, msgId) {
248
+ const content = typeof data.content === 'string' ? JSON.parse(data.content) : (data.content || {});
249
+ const richText = content.richText;
250
+ if (!Array.isArray(richText)) {
251
+ await this.messageHandler({
252
+ channelId: chatId, content: '[不支持的富文本格式]',
253
+ chatType, peerId: senderId || '', peerName: senderNick, messageId: msgId,
254
+ });
255
+ return;
256
+ }
257
+ let text = '';
258
+ const images = [];
259
+ for (const item of richText) {
260
+ if (item.type === 'text' && item.text) {
261
+ text += item.text;
262
+ }
263
+ else if (item.type === 'picture' && item.downloadUrl) {
264
+ try {
265
+ const { safeFetch, validateImage } = await import('../utils/media-cache.js');
266
+ const buffer = await safeFetch(item.downloadUrl, { skipSsrfCheck: true });
267
+ const result = await validateImage(buffer);
268
+ if (result.mime) {
269
+ images.push({ data: buffer.toString('base64'), mimeType: result.mime });
270
+ }
271
+ }
272
+ catch (error) {
273
+ logger.warn('[DingTalk] Failed to download richText image:', error);
274
+ }
275
+ }
276
+ }
277
+ const prompt = text.trim() || (images.length > 0 ? '用户发送了一张图片,请分析这张图片的内容。' : '[空消息]');
278
+ await this.messageHandler({
279
+ channelId: chatId, content: prompt, chatType,
280
+ peerId: senderId || '', peerName: senderNick, messageId: msgId,
281
+ images: images.length > 0 ? images : undefined,
282
+ });
283
+ }
284
+ // ── Outbound: text via sessionWebhook ──────────────────────────────────────
285
+ async sendMessage(chatId, content) {
286
+ const webhook = this.webhookCache.get(chatId);
287
+ if (!webhook) {
288
+ logger.warn(`[DingTalk] No webhook cached for chatId: ${chatId}, message dropped`);
289
+ return;
290
+ }
291
+ try {
292
+ const token = await this.client?.getAccessToken();
293
+ const response = await fetch(webhook, {
294
+ method: 'POST',
295
+ headers: {
296
+ 'Content-Type': 'application/json',
297
+ 'x-acs-dingtalk-access-token': token || '',
298
+ },
299
+ body: JSON.stringify({
300
+ msgtype: 'markdown',
301
+ markdown: { title: 'Bot', text: content },
302
+ }),
303
+ signal: AbortSignal.timeout(15_000),
304
+ });
305
+ if (!response.ok) {
306
+ const body = await response.text().catch(() => '');
307
+ logger.error(`[DingTalk] sendMessage failed for ${chatId}: ${response.status} ${body}`);
308
+ }
309
+ }
310
+ catch (error) {
311
+ logger.error(`[DingTalk] sendMessage failed for ${chatId}:`, error.message);
312
+ }
313
+ }
314
+ // ── Outbound: image via Open API ───────────────────────────────────────────
315
+ async sendImage(chatId, png) {
316
+ try {
317
+ const token = await this.client?.getAccessToken();
318
+ if (!token) {
319
+ logger.warn('[DingTalk] No access token for sendImage');
320
+ return;
321
+ }
322
+ // Step 1: Upload media
323
+ const FormData = (await import('form-data')).default;
324
+ const form = new FormData();
325
+ form.append('type', 'image');
326
+ form.append('media', png, { filename: 'image.png', contentType: 'image/png' });
327
+ const uploadRes = await fetch(`https://oapi.dingtalk.com/media/upload?access_token=${token}`, { method: 'POST', body: form, signal: AbortSignal.timeout(30_000) });
328
+ const uploadData = await uploadRes.json();
329
+ const mediaId = uploadData?.media_id;
330
+ if (!mediaId) {
331
+ logger.error('[DingTalk] Media upload failed:', uploadData);
332
+ return;
333
+ }
334
+ // Step 2: Send via robot API
335
+ await this.sendRobotMessage(chatId, token, 'sampleImageMsg', JSON.stringify({ photoURL: `@${mediaId}` }));
336
+ }
337
+ catch (error) {
338
+ logger.error(`[DingTalk] sendImage failed for ${chatId}:`, error.message);
339
+ }
340
+ }
341
+ // ── Outbound: file via Open API ────────────────────────────────────────────
342
+ async sendFile(chatId, filePath) {
343
+ try {
344
+ // Detect image files → route to sendImage (same pattern as Feishu)
345
+ const fs = await import('fs');
346
+ const path = await import('path');
347
+ const header = Buffer.alloc(12);
348
+ const fd = fs.openSync(filePath, 'r');
349
+ fs.readSync(fd, header, 0, 12, 0);
350
+ fs.closeSync(fd);
351
+ const { fileTypeFromBuffer } = await import('file-type');
352
+ const ftype = await fileTypeFromBuffer(header);
353
+ if (ftype && ftype.mime.startsWith('image/')) {
354
+ const buf = fs.readFileSync(filePath);
355
+ return this.sendImage(chatId, buf);
356
+ }
357
+ const token = await this.client?.getAccessToken();
358
+ if (!token) {
359
+ logger.warn('[DingTalk] No access token for sendFile');
360
+ return;
361
+ }
362
+ // Step 1: Upload media
363
+ const FormData = (await import('form-data')).default;
364
+ const form = new FormData();
365
+ form.append('type', 'file');
366
+ form.append('media', fs.createReadStream(filePath), { filename: path.basename(filePath) });
367
+ const uploadRes = await fetch(`https://oapi.dingtalk.com/media/upload?access_token=${token}`, { method: 'POST', body: form, signal: AbortSignal.timeout(60_000) });
368
+ const uploadData = await uploadRes.json();
369
+ const mediaId = uploadData?.media_id;
370
+ if (!mediaId) {
371
+ logger.error('[DingTalk] File upload failed:', uploadData);
372
+ return;
373
+ }
374
+ // Step 2: Send via robot API
375
+ const fileName = path.basename(filePath);
376
+ const fileType = path.extname(filePath).replace('.', '') || 'file';
377
+ await this.sendRobotMessage(chatId, token, 'sampleFile', JSON.stringify({ mediaId: `@${mediaId}`, fileName, fileType }));
378
+ }
379
+ catch (error) {
380
+ logger.error(`[DingTalk] sendFile failed for ${chatId}:`, error.message);
381
+ }
382
+ }
383
+ // ── Robot message send helper (group vs DM) ────────────────────────────────
384
+ async sendRobotMessage(chatId, token, msgKey, msgParam) {
385
+ const headers = { 'x-acs-dingtalk-access-token': token, 'Content-Type': 'application/json' };
386
+ const { clientId } = this.config;
387
+ // Group chatId = conversationId, DM chatId = senderId
388
+ const cachedConvId = this.conversationIdCache.get(chatId);
389
+ const staffId = this.senderStaffIdCache.get(chatId);
390
+ if (cachedConvId === chatId) {
391
+ // Group: chatId is the conversationId
392
+ const res = await fetch('https://api.dingtalk.com/v1.0/robot/groupMessages/send', {
393
+ method: 'POST', headers,
394
+ body: JSON.stringify({ msgKey, msgParam, openConversationId: chatId, robotCode: clientId }),
395
+ signal: AbortSignal.timeout(15_000),
396
+ });
397
+ if (!res.ok)
398
+ logger.error(`[DingTalk] Group robot send failed: ${res.status}`);
399
+ }
400
+ else if (staffId) {
401
+ // DM: use senderStaffId
402
+ const res = await fetch('https://api.dingtalk.com/v1.0/robot/oToMessages/batchSend', {
403
+ method: 'POST', headers,
404
+ body: JSON.stringify({ msgKey, msgParam, userIds: [staffId], robotCode: clientId }),
405
+ signal: AbortSignal.timeout(15_000),
406
+ });
407
+ if (!res.ok)
408
+ logger.error(`[DingTalk] DM robot send failed: ${res.status}`);
409
+ }
410
+ else {
411
+ logger.warn(`[DingTalk] Cannot send robot message: no conversation/staff ID cached for ${chatId}`);
412
+ }
413
+ }
414
+ }
415
+ // ── Plugin ─────────────────────────────────────────────────────────────────────
416
+ function isValidCredential(value) {
417
+ return !!value && !value.includes('your-') && !value.includes('placeholder');
418
+ }
419
+ export class DingtalkChannelPlugin {
420
+ name = 'dingtalk';
421
+ isEnabled(config) {
422
+ const raw = config.channels?.dingtalk;
423
+ if (!raw)
424
+ return false;
425
+ if (Array.isArray(raw)) {
426
+ return raw.some(inst => inst.enabled !== false && isValidCredential(inst.clientId) && isValidCredential(inst.clientSecret));
427
+ }
428
+ if (raw.enabled === false)
429
+ return false;
430
+ return isValidCredential(raw.clientId) && isValidCredential(raw.clientSecret);
431
+ }
432
+ async createChannels(config) {
433
+ const instances = normalizeChannelInstances(config.channels?.dingtalk, 'dingtalk');
434
+ const result = [];
435
+ for (const inst of instances) {
436
+ if (inst.enabled === false)
437
+ continue;
438
+ if (!isValidCredential(inst.clientId) || !isValidCredential(inst.clientSecret))
439
+ continue;
440
+ const channel = new DingtalkChannel({
441
+ clientId: inst.clientId,
442
+ clientSecret: inst.clientSecret,
443
+ requireMention: inst.requireMention,
444
+ freeResponseChats: inst.freeResponseChats,
445
+ });
446
+ const adapter = {
447
+ channelName: inst.name,
448
+ sendText: (id, text) => channel.sendMessage(id, text),
449
+ sendFile: (id, filePath) => channel.sendFile(id, filePath),
450
+ sendImage: (id, png) => channel.sendImage(id, png),
451
+ };
452
+ const policy = {
453
+ canSwitchProject: (_chatType, identity) => identity === 'owner' || identity === 'admin',
454
+ canListProjects: (_chatType, identity) => identity === 'owner' || identity === 'admin',
455
+ canCreateSession: () => true,
456
+ canDeleteSession: () => true,
457
+ canImportCliSession: (_chatType, identity) => identity === 'owner' || identity === 'admin',
458
+ messagePrefix: (chatType, peerName) => (chatType === 'group' && peerName) ? `[${peerName}] ` : '',
459
+ showMiddleResult: (chatType, identity) => {
460
+ const mode = getChannelShowActivities(config, inst.name);
461
+ if (mode === 'none')
462
+ return false;
463
+ if (mode === 'dm-only')
464
+ return chatType === 'private';
465
+ if (mode === 'owner-dm-only')
466
+ return chatType === 'private' && identity === 'owner';
467
+ return true;
468
+ },
469
+ showIdleMonitor: (chatType, identity) => {
470
+ const mode = getChannelShowActivities(config, inst.name);
471
+ if (mode === 'none')
472
+ return false;
473
+ if (mode === 'dm-only')
474
+ return chatType === 'private';
475
+ if (mode === 'owner-dm-only')
476
+ return chatType === 'private' && identity === 'owner';
477
+ return true;
478
+ },
479
+ accumulateErrors: () => true,
480
+ };
481
+ const options = {
482
+ fileMarkerPattern: /\[SEND_FILE:(?:(\w+):)?([^\]]+)\]/g,
483
+ supportsImages: true,
484
+ flushDelay: inst.flushDelay,
485
+ };
486
+ result.push({
487
+ channelType: 'dingtalk',
488
+ adapter,
489
+ channel,
490
+ policy,
491
+ options,
492
+ connect: () => channel.connect(),
493
+ disconnect: () => channel.disconnect(),
494
+ onProjectPathRequest: () => Promise.resolve(config.projects?.defaultPath || process.cwd()),
495
+ });
496
+ }
497
+ return result;
498
+ }
499
+ async createChannel(config) {
500
+ const instances = await this.createChannels(config);
501
+ if (instances.length === 0) {
502
+ throw new Error('DingTalk config missing or invalid');
503
+ }
504
+ return instances[0];
505
+ }
506
+ }