aicq-chat-plugin 3.8.1 → 3.9.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/lib/chat.js CHANGED
@@ -1,749 +1,854 @@
1
- /**
2
- * AICQ Chat Manager — Send/receive messages, group chat, file/image handling
3
- *
4
- * v3.8.0: Added file and image sending via WebSocket.
5
- * Files are sent as base64 chunks through the 'message' WS type
6
- * with type='file' or type='image', compatible with the AICQ
7
- * server relay protocol and chat.html client.
8
- */
9
- const { encryptMessage, decryptMessage } = require('./crypto');
10
- const fs = require('fs');
11
- const path = require('path');
12
- const crypto = require('crypto');
13
-
14
- const FILE_CHUNK_SIZE = 512 * 1024; // 512KB per WS chunk
15
- const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB limit
16
-
17
- class ChatManager {
18
- constructor(identityManager, serverClient, db, uploadsDir) {
19
- this.identity = identityManager;
20
- this.server = serverClient;
21
- this.db = db;
22
- this.uploadsDir = uploadsDir;
23
- this._onNewMessage = null;
24
-
25
- // Ensure uploads directory exists
26
- if (!fs.existsSync(uploadsDir)) {
27
- fs.mkdirSync(uploadsDir, { recursive: true });
28
- }
29
-
30
- // Listen for incoming messages via WS
31
- this.server.onMessage('relay', (data) => this._handleIncoming(data));
32
- this.server.onMessage('message', (data) => this._handleIncoming(data));
33
- this.server.onMessage('group_message', (data) => this._handleGroupIncoming(data));
34
- this.server.onMessage('handshake_initiate', (data) => this._handleHandshakeRequest(data));
35
- this.server.onMessage('presence', (data) => this._handlePresence(data));
36
- this.server.onMessage('file_chunk', (data) => this._handleFileChunk(data));
37
- this.server.onMessage('stream_chunk', (data) => this._handleStreamChunk(data));
38
- this.server.onMessage('stream_end', (data) => this._handleStreamEnd(data));
39
-
40
- // Incoming file transfer state: fileId -> { meta, chunks }
41
- this._incomingFiles = new Map();
42
- }
43
-
44
- setOnNewMessage(callback) {
45
- this._onNewMessage = callback;
46
- }
47
-
48
- // ─── Send Messages ────────────────────────────────────────────────
49
-
50
- async sendMessage(agentId, targetId, content, { type = 'text', isGroup = false, mentions = [], file_url = null, file_name = null } = {}) {
51
- const identity = this.identity.loadAgent(agentId);
52
-
53
- if (isGroup) {
54
- // Group message via WebSocket
55
- const sent = this.server.sendWS({
56
- type: 'group_message',
57
- groupId: targetId,
58
- content,
59
- msgType: type,
60
- mentions,
61
- });
62
-
63
- // Save locally
64
- const msg = this.db.saveMessage({
65
- agent_id: agentId,
66
- target_id: targetId,
67
- from_id: agentId,
68
- to_id: targetId,
69
- type,
70
- content,
71
- file_url,
72
- file_name,
73
- is_group: 1,
74
- mentions,
75
- status: sent ? 'sent' : 'pending',
76
- });
77
-
78
- if (this._onNewMessage) this._onNewMessage(msg);
79
- return msg;
80
- }
81
-
82
- // Direct message
83
- // Try to encrypt if we have a session key
84
- const session = this.db.loadSession(agentId, targetId);
85
- let payload = content;
86
- if (session && session.session_key) {
87
- try {
88
- payload = encryptMessage(content, session.session_key);
89
- } catch (e) {
90
- console.error('[Chat] Encryption failed, sending plaintext:', e.message);
91
- }
92
- }
93
-
94
- // Send via WebSocket relay
95
- const sent = this.server.sendWS({
96
- type: 'relay',
97
- targetId: targetId,
98
- payload,
99
- });
100
-
101
- // Also try REST fallback
102
- if (!sent) {
103
- try {
104
- await this.server._request('POST', '/messages/send', {
105
- targetId,
106
- payload,
107
- });
108
- } catch (e) {
109
- // Queue offline
110
- this.db.enqueueOffline({
111
- agent_id: agentId,
112
- target_id: targetId,
113
- data: JSON.stringify({ type: 'relay', targetId, payload }),
114
- });
115
- }
116
- }
117
-
118
- // Save locally
119
- const msg = this.db.saveMessage({
120
- agent_id: agentId,
121
- target_id: targetId,
122
- from_id: agentId,
123
- to_id: targetId,
124
- type,
125
- content,
126
- file_url,
127
- file_name,
128
- is_group: 0,
129
- mentions,
130
- status: sent ? 'sent' : 'pending',
131
- });
132
-
133
- // Update session message count
134
- if (session) {
135
- this.db.incrementSessionMessageCount(agentId, targetId);
136
- }
137
-
138
- if (this._onNewMessage) this._onNewMessage(msg);
139
- return msg;
140
- }
141
-
142
- // ─── Send File ──────────────────────────────────────────────────
143
-
144
- /**
145
- * Send a file to a friend or group.
146
- *
147
- * Reads the file from disk, chunks it, and sends via WebSocket
148
- * using the AICQ 'message' protocol with type='file'.
149
- * The receiver's chat.html client will assemble and display the file.
150
- *
151
- * @param {string} agentId - Sender agent ID
152
- * @param {string} targetId - Recipient (friend ID or group ID)
153
- * @param {string} filePath - Local file path to send
154
- * @param {object} options - { isGroup, caption }
155
- * @returns {object} Send result with fileId, fileName, fileSize
156
- */
157
- async sendFile(agentId, targetId, filePath, { isGroup = false, caption = '' } = {}) {
158
- if (!fs.existsSync(filePath)) {
159
- throw new Error(`File not found: ${filePath}`);
160
- }
161
-
162
- const stat = fs.statSync(filePath);
163
- if (stat.size > MAX_FILE_SIZE) {
164
- throw new Error(`File too large: ${stat.size} bytes (max ${MAX_FILE_SIZE})`);
165
- }
166
-
167
- const fileBuffer = fs.readFileSync(filePath);
168
- const originalName = path.basename(filePath);
169
- const ext = path.extname(originalName).toLowerCase();
170
- const mimeType = this._getMimeType(originalName);
171
- const isImage = this._isImageExt(ext);
172
- const msgType = isImage ? 'image' : 'file';
173
-
174
- // Generate a unique file ID
175
- const fileId = crypto.randomUUID();
176
-
177
- // Save a local copy in uploads dir
178
- const localFileName = `${fileId}${ext}`;
179
- const localPath = path.join(this.uploadsDir, localFileName);
180
- fs.writeFileSync(localPath, fileBuffer);
181
-
182
- // Build the file info message (compatible with chat.html client)
183
- const fileInfo = {
184
- id: `msg_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
185
- from_id: agentId,
186
- to_id: targetId,
187
- type: msgType,
188
- content: caption || (isImage ? '[图片]' : `[文件] ${originalName}`),
189
- file_info: {
190
- fileId,
191
- fileName: originalName,
192
- fileSize: stat.size,
193
- mimeType,
194
- isImage,
195
- chunks: Math.ceil(stat.size / FILE_CHUNK_SIZE),
196
- },
197
- file_url: `/api/files/${localFileName}`,
198
- file_name: originalName,
199
- created_at: new Date().toISOString(),
200
- status: 'sent',
201
- };
202
-
203
- // Send the file-info message first
204
- if (isGroup) {
205
- this.server.sendWS({
206
- type: 'group_message',
207
- groupId: targetId,
208
- from: agentId,
209
- content: JSON.stringify(fileInfo),
210
- msgType: msgType,
211
- timestamp: Date.now(),
212
- });
213
- } else {
214
- this.server.sendWS({
215
- type: 'message',
216
- to: targetId,
217
- data: fileInfo,
218
- });
219
- }
220
-
221
- // Send file data in chunks via file_chunk messages
222
- const totalChunks = Math.ceil(stat.size / FILE_CHUNK_SIZE);
223
- for (let i = 0; i < totalChunks; i++) {
224
- const start = i * FILE_CHUNK_SIZE;
225
- const end = Math.min(start + FILE_CHUNK_SIZE, stat.size);
226
- const chunkBuffer = fileBuffer.slice(start, end);
227
-
228
- const chunkMsg = {
229
- fileId,
230
- index: i,
231
- total: totalChunks,
232
- data: chunkBuffer.toString('base64'),
233
- };
234
-
235
- if (isGroup) {
236
- this.server.sendWS({
237
- type: 'group_message',
238
- groupId: targetId,
239
- from: agentId,
240
- content: JSON.stringify(chunkMsg),
241
- msgType: 'file_chunk',
242
- timestamp: Date.now(),
243
- });
244
- } else {
245
- this.server.sendWS({
246
- type: 'file_chunk',
247
- to: targetId,
248
- data: chunkMsg,
249
- });
250
- }
251
-
252
- // Small delay between chunks to avoid WS flooding
253
- if (i < totalChunks - 1 && totalChunks > 1) {
254
- await new Promise(resolve => setTimeout(resolve, 10));
255
- }
256
- }
257
-
258
- // Save message to local chat history
259
- const msg = this.db.saveMessage({
260
- agent_id: agentId,
261
- target_id: targetId,
262
- from_id: agentId,
263
- to_id: targetId,
264
- type: msgType,
265
- content: caption || (isImage ? `[图片] ${originalName}` : `[文件] ${originalName}`),
266
- file_url: `/api/files/${localFileName}`,
267
- file_name: originalName,
268
- is_group: isGroup ? 1 : 0,
269
- status: 'sent',
270
- });
271
-
272
- if (this._onNewMessage) this._onNewMessage(msg);
273
-
274
- console.log(`[Chat] File sent: ${originalName} (${stat.size} bytes, ${totalChunks} chunks) to ${targetId}`);
275
-
276
- return {
277
- fileId,
278
- fileName: originalName,
279
- fileSize: stat.size,
280
- mimeType,
281
- isImage,
282
- totalChunks,
283
- localPath,
284
- message: msg,
285
- };
286
- }
287
-
288
- /**
289
- * Send an image from a buffer (e.g., generated by AI).
290
- *
291
- * @param {string} agentId - Sender agent ID
292
- * @param {string} targetId - Recipient
293
- * @param {Buffer} imageBuffer - Image data
294
- * @param {string} fileName - File name (e.g., 'image.png')
295
- * @param {object} options - { isGroup, caption }
296
- * @returns {object} Send result
297
- */
298
- async sendImageBuffer(agentId, targetId, imageBuffer, fileName = 'image.png', { isGroup = false, caption = '' } = {}) {
299
- if (!Buffer.isBuffer(imageBuffer)) {
300
- throw new Error('imageBuffer must be a Buffer');
301
- }
302
-
303
- // Save buffer to a temp file, then use sendFile
304
- const tempPath = path.join(this.uploadsDir, `temp_${Date.now()}_${fileName}`);
305
- fs.writeFileSync(tempPath, imageBuffer);
306
-
307
- try {
308
- const result = await this.sendFile(agentId, targetId, tempPath, { isGroup, caption });
309
- return result;
310
- } finally {
311
- // Clean up temp file
312
- try { fs.unlinkSync(tempPath); } catch (e) {}
313
- }
314
- }
315
-
316
- /**
317
- * Send a file from a base64-encoded string.
318
- *
319
- * @param {string} agentId - Sender agent ID
320
- * @param {string} targetId - Recipient
321
- * @param {string} base64Data - Base64-encoded file data
322
- * @param {string} fileName - File name
323
- * @param {object} options - { isGroup, caption, mimeType }
324
- * @returns {object} Send result
325
- */
326
- async sendFileFromBase64(agentId, targetId, base64Data, fileName, { isGroup = false, caption = '', mimeType = '' } = {}) {
327
- const buffer = Buffer.from(base64Data, 'base64');
328
-
329
- // Save to temp file
330
- const ext = path.extname(fileName) || this._extFromMime(mimeType) || '.bin';
331
- const tempPath = path.join(this.uploadsDir, `temp_${Date.now()}_${fileName}`);
332
- fs.writeFileSync(tempPath, buffer);
333
-
334
- try {
335
- const result = await this.sendFile(agentId, targetId, tempPath, { isGroup, caption });
336
- return result;
337
- } finally {
338
- try { fs.unlinkSync(tempPath); } catch (e) {}
339
- }
340
- }
341
-
342
- // ─── Receive Messages ─────────────────────────────────────────────
343
-
344
- _handleIncoming(data) {
345
- const agentId = this.server.currentAgentId;
346
- if (!agentId) return;
347
-
348
- const fromId = data.fromId || data.from;
349
- let content = data.payload || data.data || '';
350
-
351
- // Check if this is a file/image message in the new format
352
- if (typeof data === 'object' && data.data && typeof data.data === 'object' && data.data.file_info) {
353
- // This is a structured message with file info
354
- const fileInfo = data.data.file_info;
355
- const msgType = fileInfo.isImage ? 'image' : 'file';
356
- const msg = this.db.saveMessage({
357
- agent_id: agentId,
358
- target_id: fromId,
359
- from_id: fromId,
360
- to_id: agentId,
361
- type: msgType,
362
- content: data.data.content || (fileInfo.isImage ? '[图片]' : `[文件] ${fileInfo.fileName}`),
363
- file_url: data.data.file_url || '',
364
- file_name: fileInfo.fileName || '',
365
- is_group: 0,
366
- status: 'delivered',
367
- });
368
- if (this._onNewMessage) this._onNewMessage(msg);
369
- return;
370
- }
371
-
372
- // Try to decrypt if we have a session key
373
- const session = this.db.loadSession(agentId, fromId);
374
- if (session && session.session_key && typeof content === 'string') {
375
- try {
376
- content = decryptMessage(content, session.session_key);
377
- } catch (e) {
378
- // Might be plaintext, keep as is
379
- }
380
- }
381
-
382
- const msg = this.db.saveMessage({
383
- agent_id: agentId,
384
- target_id: fromId,
385
- from_id: fromId,
386
- to_id: agentId,
387
- type: 'text',
388
- content: typeof content === 'string' ? content : JSON.stringify(content),
389
- is_group: 0,
390
- status: 'delivered',
391
- });
392
-
393
- if (this._onNewMessage) this._onNewMessage(msg);
394
- }
395
-
396
- _handleGroupIncoming(data) {
397
- const agentId = this.server.currentAgentId;
398
- if (!agentId) return;
399
-
400
- const fromId = data.fromId || data.from;
401
- const groupId = data.groupId;
402
-
403
- // Check if this is a file/image message
404
- let content = data.content || '';
405
- const msgType = data.msgType || data.msg_type || 'text';
406
-
407
- if (msgType === 'file' || msgType === 'image') {
408
- // File/image in group message
409
- let fileInfo = {};
410
- try {
411
- fileInfo = typeof content === 'string' ? JSON.parse(content) : content;
412
- } catch (e) {}
413
-
414
- if (fileInfo.file_info) {
415
- const msg = this.db.saveMessage({
416
- agent_id: agentId,
417
- target_id: groupId,
418
- from_id: fromId,
419
- to_id: groupId,
420
- type: msgType,
421
- content: fileInfo.content || (fileInfo.file_info.isImage ? '[图片]' : `[文件] ${fileInfo.file_info.fileName}`),
422
- file_url: fileInfo.file_url || '',
423
- file_name: fileInfo.file_info.fileName || '',
424
- is_group: 1,
425
- status: 'delivered',
426
- });
427
- if (this._onNewMessage) this._onNewMessage(msg);
428
- return;
429
- }
430
- }
431
-
432
- // Check silent mode
433
- const silent = this.db.getGroupSilentMode(agentId, groupId);
434
- const mentions = data.mentions || [];
435
- const isMentioned = mentions.includes(agentId) || mentions.includes('all');
436
-
437
- const msg = this.db.saveMessage({
438
- agent_id: agentId,
439
- target_id: groupId,
440
- from_id: fromId,
441
- to_id: groupId,
442
- type: msgType,
443
- content,
444
- is_group: 1,
445
- mentions,
446
- status: (silent && !isMentioned) ? 'silent' : 'delivered',
447
- });
448
-
449
- if (this._onNewMessage) this._onNewMessage(msg);
450
- }
451
-
452
- _handleHandshakeRequest(data) {
453
- const agentId = this.server.currentAgentId;
454
- if (!agentId) return;
455
-
456
- this.db.savePendingRequest({
457
- agent_id: agentId,
458
- session_id: data.sessionId || crypto.randomUUID(),
459
- requester_id: data.requesterId || data.from,
460
- requester_public_key: data.requesterPublicKey || data.exchangePublicKey || '',
461
- });
462
- }
463
-
464
- _handlePresence(data) {
465
- const agentId = this.server.currentAgentId;
466
- if (!agentId) return;
467
-
468
- const friendId = data.nodeId;
469
- const isOnline = data.online === true || data.status === 'online';
470
- this.db.updateFriendOnline(agentId, friendId, isOnline);
471
- }
472
-
473
- _handleFileChunk(data) {
474
- // Handle incoming file chunks
475
- const chunkData = data.data || data;
476
- const fileId = chunkData.fileId;
477
-
478
- if (!fileId) return;
479
-
480
- // Initialize incoming transfer if needed
481
- if (!this._incomingFiles.has(fileId)) {
482
- this._incomingFiles.set(fileId, {
483
- chunks: new Map(),
484
- meta: null,
485
- fromId: data.from || data.fromId,
486
- });
487
- }
488
-
489
- const transfer = this._incomingFiles.get(fileId);
490
-
491
- // If this is a file-info message
492
- if (chunkData.type === 'file-info' || chunkData.file_info) {
493
- transfer.meta = chunkData.file_info || chunkData;
494
- return;
495
- }
496
-
497
- // Store the chunk
498
- transfer.chunks.set(chunkData.index, chunkData);
499
-
500
- // Check if all chunks received
501
- if (transfer.meta && transfer.chunks.size >= transfer.meta.chunks) {
502
- this._assembleFile(fileId, transfer);
503
- }
504
- }
505
-
506
- /**
507
- * Assemble received file chunks into a complete file.
508
- */
509
- _assembleFile(fileId, transfer) {
510
- const { meta, chunks, fromId } = transfer;
511
-
512
- try {
513
- const sortedChunks = Array.from(chunks.entries())
514
- .sort((a, b) => a[0] - b[0]);
515
-
516
- const buffers = [];
517
- for (const [index, chunk] of sortedChunks) {
518
- buffers.push(Buffer.from(chunk.data, 'base64'));
519
- }
520
-
521
- const fileBuffer = Buffer.concat(buffers);
522
- const agentId = this.server.currentAgentId;
523
-
524
- // Determine file extension
525
- const ext = this._extFromMime(meta.mimeType) || path.extname(meta.fileName) || '.bin';
526
- const localFileName = `${fileId}${ext}`;
527
- const localPath = path.join(this.uploadsDir, localFileName);
528
-
529
- // Save to uploads directory
530
- fs.writeFileSync(localPath, fileBuffer);
531
-
532
- const isImage = meta.isImage || this._isImageExt(ext);
533
- const msgType = isImage ? 'image' : 'file';
534
-
535
- // Save message to chat history
536
- if (agentId) {
537
- const msg = this.db.saveMessage({
538
- agent_id: agentId,
539
- target_id: fromId || '',
540
- from_id: fromId || '',
541
- to_id: agentId,
542
- type: msgType,
543
- content: isImage ? `[图片] ${meta.fileName}` : `[文件] ${meta.fileName}`,
544
- file_url: `/api/files/${localFileName}`,
545
- file_name: meta.fileName,
546
- is_group: 0,
547
- status: 'delivered',
548
- });
549
- if (this._onNewMessage) this._onNewMessage(msg);
550
- }
551
-
552
- console.log(`[Chat] File assembled: ${meta.fileName} (${fileBuffer.length} bytes)`);
553
- } catch (e) {
554
- console.error(`[Chat] File assembly failed for ${fileId}:`, e.message);
555
- } finally {
556
- this._incomingFiles.delete(fileId);
557
- }
558
- }
559
-
560
- _handleStreamChunk(data) {
561
- // Incoming streaming chunk from another agent
562
- const agentId = this.server.currentAgentId;
563
- if (!agentId) return;
564
-
565
- const fromId = data.from;
566
- const chunkType = data.chunkType || 'text';
567
- const chunkData = data.data;
568
-
569
- // Notify callback so OpenClaw agent can process streaming input
570
- if (this._onNewMessage) {
571
- this._onNewMessage({
572
- type: 'stream_chunk',
573
- from_id: fromId,
574
- chunk_type: chunkType,
575
- data: chunkData,
576
- });
577
- }
578
- console.log('[Chat] Stream chunk from', fromId, 'type:', chunkType);
579
- }
580
-
581
- _handleStreamEnd(data) {
582
- // Incoming stream end signal from another agent
583
- const agentId = this.server.currentAgentId;
584
- if (!agentId) return;
585
-
586
- const fromId = data.from;
587
- const messageId = data.messageId || '';
588
-
589
- // Notify callback so OpenClaw agent knows stream is complete
590
- if (this._onNewMessage) {
591
- this._onNewMessage({
592
- type: 'stream_end',
593
- from_id: fromId,
594
- message_id: messageId,
595
- });
596
- }
597
- console.log('[Chat] Stream end from', fromId, 'messageId:', messageId);
598
- }
599
-
600
- // ─── Chat History ─────────────────────────────────────────────────
601
-
602
- getHistory(agentId, targetId, { limit = 50, before = null } = {}) {
603
- return this.db.getChatHistory(agentId, targetId, { limit, before });
604
- }
605
-
606
- deleteMessage(agentId, messageId) {
607
- this.db.deleteMessage(agentId, messageId);
608
- }
609
-
610
- // ─── File Upload (from HTTP) ────────────────────────────────────
611
-
612
- async handleFileUpload(agentId, targetId, file, isGroup = false) {
613
- const fileId = crypto.randomUUID();
614
- const ext = path.extname(file.originalname || '.bin');
615
- const fileName = `${fileId}${ext}`;
616
- const filePath = path.join(this.uploadsDir, fileName);
617
- fs.writeFileSync(filePath, file.buffer);
618
-
619
- const isImage = this._isImageExt(ext);
620
- const msgType = isImage ? 'image' : 'file';
621
-
622
- // Build file info for WS message
623
- const fileInfo = {
624
- id: `msg_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
625
- from_id: agentId,
626
- to_id: targetId,
627
- type: msgType,
628
- content: isImage ? '[图片]' : `[文件] ${file.originalname}`,
629
- file_info: {
630
- fileId,
631
- fileName: file.originalname,
632
- fileSize: file.size,
633
- mimeType: file.mimetype || this._getMimeType(file.originalname),
634
- isImage,
635
- chunks: 1,
636
- },
637
- file_url: `/api/files/${fileName}`,
638
- file_name: file.originalname,
639
- created_at: new Date().toISOString(),
640
- status: 'sent',
641
- };
642
-
643
- // Send file-info message via WS
644
- if (isGroup) {
645
- this.server.sendWS({
646
- type: 'group_message',
647
- groupId: targetId,
648
- from: agentId,
649
- content: JSON.stringify(fileInfo),
650
- msgType: msgType,
651
- timestamp: Date.now(),
652
- });
653
- } else {
654
- this.server.sendWS({
655
- type: 'message',
656
- to: targetId,
657
- data: fileInfo,
658
- });
659
- }
660
-
661
- // If file is large enough, also send as file_chunk for assembly
662
- if (file.size > 0) {
663
- const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE);
664
- for (let i = 0; i < totalChunks; i++) {
665
- const start = i * FILE_CHUNK_SIZE;
666
- const end = Math.min(start + FILE_CHUNK_SIZE, file.size);
667
- const chunkBuffer = file.buffer.slice(start, end);
668
-
669
- const chunkMsg = {
670
- fileId,
671
- index: i,
672
- total: totalChunks,
673
- data: chunkBuffer.toString('base64'),
674
- };
675
-
676
- if (isGroup) {
677
- this.server.sendWS({
678
- type: 'group_message',
679
- groupId: targetId,
680
- from: agentId,
681
- content: JSON.stringify(chunkMsg),
682
- msgType: 'file_chunk',
683
- timestamp: Date.now(),
684
- });
685
- } else {
686
- this.server.sendWS({
687
- type: 'file_chunk',
688
- to: targetId,
689
- data: chunkMsg,
690
- });
691
- }
692
- }
693
- }
694
-
695
- // Save message locally
696
- const msg = this.db.saveMessage({
697
- agent_id: agentId,
698
- target_id: targetId,
699
- from_id: agentId,
700
- to_id: targetId,
701
- type: msgType,
702
- content: isImage ? `[图片] ${file.originalname}` : `[文件] ${file.originalname}`,
703
- file_url: `/api/files/${fileName}`,
704
- file_name: file.originalname,
705
- is_group: isGroup ? 1 : 0,
706
- status: 'sent',
707
- });
708
-
709
- if (this._onNewMessage) this._onNewMessage(msg);
710
- return msg;
711
- }
712
-
713
- // ─── Helpers ────────────────────────────────────────────────────
714
-
715
- _isImageExt(ext) {
716
- return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico|tiff|tif|avif)$/i.test(ext);
717
- }
718
-
719
- _getMimeType(fileName) {
720
- const ext = path.extname(fileName).toLowerCase();
721
- const mimeTypes = {
722
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
723
- '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
724
- '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.tiff': 'image/tiff',
725
- '.tif': 'image/tiff', '.avif': 'image/avif',
726
- '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json',
727
- '.zip': 'application/zip', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
728
- '.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'audio/ogg',
729
- '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
730
- '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
731
- '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
732
- };
733
- return mimeTypes[ext] || 'application/octet-stream';
734
- }
735
-
736
- _extFromMime(mimeType) {
737
- if (!mimeType) return '';
738
- const mimeToExt = {
739
- 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif',
740
- 'image/webp': '.webp', 'image/svg+xml': '.svg', 'image/bmp': '.bmp',
741
- 'application/pdf': '.pdf', 'text/plain': '.txt',
742
- 'application/zip': '.zip', 'audio/mpeg': '.mp3',
743
- 'video/mp4': '.mp4', 'audio/wav': '.wav',
744
- };
745
- return mimeToExt[mimeType] || '';
746
- }
747
- }
748
-
749
- module.exports = ChatManager;
1
+ /**
2
+ * AICQ Chat Manager — Send/receive messages, group chat, file handling
3
+ *
4
+ * Enhanced: File/image messages received from users are saved to the
5
+ * `userfiles` directory. After saving, a synthetic message is injected
6
+ * into the AI dispatch pipeline that tells the agent about the local
7
+ * file path so it can process the file (read, analyze, etc.).
8
+ */
9
+ const { encryptMessage, decryptMessage } = require('./crypto');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const crypto = require('crypto');
13
+
14
+ class ChatManager {
15
+ constructor(identityManager, serverClient, db, uploadsDir, userfilesDir) {
16
+ this.identity = identityManager;
17
+ this.server = serverClient;
18
+ this.db = db;
19
+ this.uploadsDir = uploadsDir;
20
+ this.userfilesDir = userfilesDir || path.join(path.dirname(uploadsDir), 'userfiles');
21
+ this._onNewMessage = null;
22
+
23
+ // Ensure userfiles directory exists
24
+ fs.mkdirSync(this.userfilesDir, { recursive: true });
25
+
26
+ // Incoming file chunk assembly state: fileId -> { meta, chunks }
27
+ this._incomingFiles = new Map();
28
+
29
+ // Listen for incoming messages via WS
30
+ this.server.onMessage('relay', (data) => this._handleIncoming(data));
31
+ this.server.onMessage('message', (data) => this._handleIncoming(data));
32
+ this.server.onMessage('group_message', (data) => this._handleGroupIncoming(data));
33
+ this.server.onMessage('handshake_initiate', (data) => this._handleHandshakeRequest(data));
34
+ this.server.onMessage('presence', (data) => this._handlePresence(data));
35
+ this.server.onMessage('file_chunk', (data) => this._handleFileChunk(data));
36
+ this.server.onMessage('file', (data) => this._handleFileMessage(data));
37
+ this.server.onMessage('image', (data) => this._handleFileMessage(data));
38
+ this.server.onMessage('stream_chunk', (data) => this._handleStreamChunk(data));
39
+ this.server.onMessage('stream_end', (data) => this._handleStreamEnd(data));
40
+ }
41
+
42
+ setOnNewMessage(callback) {
43
+ this._onNewMessage = callback;
44
+ }
45
+
46
+ // ─── Send Messages ────────────────────────────────────────────────
47
+
48
+ async sendMessage(agentId, targetId, content, { type = 'text', isGroup = false, mentions = [], file_url = null, file_name = null, local_path = null } = {}) {
49
+ const identity = this.identity.loadAgent(agentId);
50
+
51
+ if (isGroup) {
52
+ // Group message via WebSocket
53
+ const sent = this.server.sendWS({
54
+ type: 'group_message',
55
+ groupId: targetId,
56
+ content,
57
+ msgType: type,
58
+ mentions,
59
+ });
60
+
61
+ // Save locally
62
+ const msg = this.db.saveMessage({
63
+ agent_id: agentId,
64
+ target_id: targetId,
65
+ from_id: agentId,
66
+ to_id: targetId,
67
+ type,
68
+ content,
69
+ file_url,
70
+ file_name,
71
+ local_path,
72
+ is_group: 1,
73
+ mentions,
74
+ status: sent ? 'sent' : 'pending',
75
+ });
76
+
77
+ if (this._onNewMessage) this._onNewMessage(msg);
78
+ return msg;
79
+ }
80
+
81
+ // Direct message
82
+ // Try to encrypt if we have a session key
83
+ const session = this.db.loadSession(agentId, targetId);
84
+ let payload = content;
85
+ if (session && session.session_key) {
86
+ try {
87
+ payload = encryptMessage(content, session.session_key);
88
+ } catch (e) {
89
+ console.error('[Chat] Encryption failed, sending plaintext:', e.message);
90
+ }
91
+ }
92
+
93
+ // Send via WebSocket relay
94
+ const sent = this.server.sendWS({
95
+ type: 'relay',
96
+ targetId: targetId,
97
+ payload,
98
+ });
99
+
100
+ // Also try REST fallback
101
+ if (!sent) {
102
+ try {
103
+ await this.server._request('POST', '/messages/send', {
104
+ targetId,
105
+ payload,
106
+ });
107
+ } catch (e) {
108
+ // Queue offline
109
+ this.db.enqueueOffline({
110
+ agent_id: agentId,
111
+ target_id: targetId,
112
+ data: JSON.stringify({ type: 'relay', targetId, payload }),
113
+ });
114
+ }
115
+ }
116
+
117
+ // Save locally
118
+ const msg = this.db.saveMessage({
119
+ agent_id: agentId,
120
+ target_id: targetId,
121
+ from_id: agentId,
122
+ to_id: targetId,
123
+ type,
124
+ content,
125
+ file_url,
126
+ file_name,
127
+ local_path,
128
+ is_group: 0,
129
+ mentions,
130
+ status: sent ? 'sent' : 'pending',
131
+ });
132
+
133
+ // Update session message count
134
+ if (session) {
135
+ this.db.incrementSessionMessageCount(agentId, targetId);
136
+ }
137
+
138
+ if (this._onNewMessage) this._onNewMessage(msg);
139
+ return msg;
140
+ }
141
+
142
+ // ─── Receive Messages ─────────────────────────────────────────────
143
+
144
+ _handleIncoming(data) {
145
+ const agentId = this.server.currentAgentId;
146
+ if (!agentId) return;
147
+
148
+ const fromId = data.fromId || data.from;
149
+ let content = data.payload || data.data || '';
150
+ const msgType = data.msgType || data.type || 'text';
151
+
152
+ // Try to decrypt if we have a session key
153
+ const session = this.db.loadSession(agentId, fromId);
154
+ if (session && session.session_key && typeof content === 'string') {
155
+ try {
156
+ content = decryptMessage(content, session.session_key);
157
+ } catch (e) {
158
+ // Might be plaintext, keep as is
159
+ }
160
+ }
161
+
162
+ // Detect if this is a file or image message
163
+ const isFileMessage = this._isFileMessage(msgType, content, data);
164
+ let localFilePath = null;
165
+ let originalFileName = null;
166
+
167
+ if (isFileMessage) {
168
+ const fileResult = this._saveIncomingFileToUserfiles(agentId, fromId, content, data);
169
+ if (fileResult) {
170
+ localFilePath = fileResult.localPath;
171
+ originalFileName = fileResult.originalName;
172
+ }
173
+ }
174
+
175
+ // Save the original message
176
+ const msg = this.db.saveMessage({
177
+ agent_id: agentId,
178
+ target_id: fromId,
179
+ from_id: fromId,
180
+ to_id: agentId,
181
+ type: isFileMessage ? (this._isImageMessage(msgType, content, data) ? 'image' : 'file') : 'text',
182
+ content: typeof content === 'string' ? content : JSON.stringify(content),
183
+ file_url: data.file_url || data.fileUrl || null,
184
+ file_name: originalFileName || data.file_name || data.fileName || null,
185
+ local_path: localFilePath,
186
+ is_group: 0,
187
+ status: 'delivered',
188
+ });
189
+
190
+ if (this._onNewMessage) this._onNewMessage(msg);
191
+
192
+ // If this was a file/image message, also inject a synthetic message
193
+ // telling the AI agent about the local file path
194
+ if (isFileMessage && localFilePath && this._onNewMessage) {
195
+ const isImage = this._isImageMessage(msgType, content, data);
196
+ const fileType = isImage ? '图片' : '文件';
197
+ const syntheticMsg = {
198
+ agent_id: agentId,
199
+ target_id: fromId,
200
+ from_id: fromId,
201
+ to_id: agentId,
202
+ type: 'text',
203
+ content: `[用户发送了${fileType}] ${originalFileName || '未知文件名'}\n本地路径: ${localFilePath}\n请处理该${fileType}。`,
204
+ is_group: 0,
205
+ status: 'delivered',
206
+ _synthetic: true, // Mark as synthetic so AI dispatch can handle it
207
+ _original_msg_id: msg.message_id || msg.id,
208
+ };
209
+ this._onNewMessage(syntheticMsg);
210
+ }
211
+ }
212
+
213
+ _handleGroupIncoming(data) {
214
+ const agentId = this.server.currentAgentId;
215
+ if (!agentId) return;
216
+
217
+ const fromId = data.fromId;
218
+ const groupId = data.groupId;
219
+
220
+ // Check silent mode
221
+ const silent = this.db.getGroupSilentMode(agentId, groupId);
222
+ const mentions = data.mentions || [];
223
+ const isMentioned = mentions.includes(agentId) || mentions.includes('all');
224
+
225
+ const content = data.content || '';
226
+ const msgType = data.msgType || 'text';
227
+
228
+ // Detect file/image in group message
229
+ const isFileMessage = this._isFileMessage(msgType, content, data);
230
+ let localFilePath = null;
231
+ let originalFileName = null;
232
+
233
+ if (isFileMessage) {
234
+ const fileResult = this._saveIncomingFileToUserfiles(agentId, fromId, content, data);
235
+ if (fileResult) {
236
+ localFilePath = fileResult.localPath;
237
+ originalFileName = fileResult.originalName;
238
+ }
239
+ }
240
+
241
+ const msg = this.db.saveMessage({
242
+ agent_id: agentId,
243
+ target_id: groupId,
244
+ from_id: fromId,
245
+ to_id: groupId,
246
+ type: isFileMessage ? (this._isImageMessage(msgType, content, data) ? 'image' : 'file') : (data.msgType || 'text'),
247
+ content,
248
+ file_url: data.file_url || data.fileUrl || null,
249
+ file_name: originalFileName || data.file_name || data.fileName || null,
250
+ local_path: localFilePath,
251
+ is_group: 1,
252
+ mentions,
253
+ status: (silent && !isMentioned) ? 'silent' : 'delivered',
254
+ });
255
+
256
+ if (this._onNewMessage) this._onNewMessage(msg);
257
+
258
+ // Inject synthetic message for group file messages
259
+ if (isFileMessage && localFilePath && this._onNewMessage && (isMentioned || !silent)) {
260
+ const isImage = this._isImageMessage(msgType, content, data);
261
+ const fileType = isImage ? '图片' : '文件';
262
+ const syntheticMsg = {
263
+ agent_id: agentId,
264
+ target_id: groupId,
265
+ from_id: fromId,
266
+ to_id: groupId,
267
+ type: 'text',
268
+ content: `[群组中用户发送了${fileType}] ${originalFileName || '未知文件名'}\n本地路径: ${localFilePath}\n请处理该${fileType}。`,
269
+ is_group: 1,
270
+ status: 'delivered',
271
+ _synthetic: true,
272
+ _original_msg_id: msg.message_id || msg.id,
273
+ };
274
+ this._onNewMessage(syntheticMsg);
275
+ }
276
+ }
277
+
278
+ _handleHandshakeRequest(data) {
279
+ const agentId = this.server.currentAgentId;
280
+ if (!agentId) return;
281
+
282
+ this.db.savePendingRequest({
283
+ agent_id: agentId,
284
+ session_id: data.sessionId || crypto.randomUUID(),
285
+ requester_id: data.requesterId || data.from,
286
+ requester_public_key: data.requesterPublicKey || data.exchangePublicKey || '',
287
+ });
288
+ }
289
+
290
+ _handlePresence(data) {
291
+ const agentId = this.server.currentAgentId;
292
+ if (!agentId) return;
293
+
294
+ const friendId = data.nodeId;
295
+ const isOnline = data.online === true || data.status === 'online';
296
+ this.db.updateFriendOnline(agentId, friendId, isOnline);
297
+ }
298
+
299
+ _handleFileMessage(data) {
300
+ // Handle explicit file/image type WS messages
301
+ const agentId = this.server.currentAgentId;
302
+ if (!agentId) return;
303
+
304
+ const fromId = data.fromId || data.from;
305
+ const content = data.content || data.data || '';
306
+ const isImage = data.type === 'image' || this._isImageMessage(data.type, content, data);
307
+
308
+ let localFilePath = null;
309
+ let originalFileName = null;
310
+
311
+ // If the file data is inline (base64), save it
312
+ if (data.file_data || data.data && this._isBase64Data(data.data)) {
313
+ const fileResult = this._saveBase64FileToUserfiles(agentId, fromId, data);
314
+ if (fileResult) {
315
+ localFilePath = fileResult.localPath;
316
+ originalFileName = fileResult.originalName;
317
+ }
318
+ } else if (data.file_url || data.fileUrl) {
319
+ // Download file from URL and save locally
320
+ const fileResult = this._saveUrlFileToUserfiles(agentId, fromId, data);
321
+ if (fileResult) {
322
+ localFilePath = fileResult.localPath;
323
+ originalFileName = fileResult.originalName;
324
+ }
325
+ }
326
+
327
+ // Save message
328
+ const msg = this.db.saveMessage({
329
+ agent_id: agentId,
330
+ target_id: fromId,
331
+ from_id: fromId,
332
+ to_id: agentId,
333
+ type: isImage ? 'image' : 'file',
334
+ content,
335
+ file_url: data.file_url || data.fileUrl || null,
336
+ file_name: originalFileName || data.file_name || data.fileName || null,
337
+ local_path: localFilePath,
338
+ is_group: 0,
339
+ status: 'delivered',
340
+ });
341
+
342
+ if (this._onNewMessage) this._onNewMessage(msg);
343
+
344
+ // Inject synthetic message
345
+ if (localFilePath && this._onNewMessage) {
346
+ const fileType = isImage ? '图片' : '文件';
347
+ const syntheticMsg = {
348
+ agent_id: agentId,
349
+ target_id: fromId,
350
+ from_id: fromId,
351
+ to_id: agentId,
352
+ type: 'text',
353
+ content: `[用户发送了${fileType}] ${originalFileName || '未知文件名'}\n本地路径: ${localFilePath}\n请处理该${fileType}。`,
354
+ is_group: 0,
355
+ status: 'delivered',
356
+ _synthetic: true,
357
+ _original_msg_id: msg.message_id || msg.id,
358
+ };
359
+ this._onNewMessage(syntheticMsg);
360
+ }
361
+ }
362
+
363
+ _handleFileChunk(data) {
364
+ // File chunk handling — assemble in userfiles dir
365
+ const agentId = this.server.currentAgentId;
366
+ if (!agentId) return;
367
+
368
+ const chunkData = data.data || data;
369
+ const fileId = chunkData.fileId || data.fileId;
370
+
371
+ if (!fileId) {
372
+ console.log('[Chat] File chunk without fileId from', data.from);
373
+ return;
374
+ }
375
+
376
+ // Initialize incoming transfer if needed
377
+ if (!this._incomingFiles.has(fileId)) {
378
+ this._incomingFiles.set(fileId, {
379
+ chunks: new Map(),
380
+ meta: null,
381
+ fromId: data.fromId || data.from,
382
+ });
383
+ }
384
+
385
+ const transfer = this._incomingFiles.get(fileId);
386
+
387
+ // If this is a file-info message
388
+ if (chunkData.type === 'file-info') {
389
+ transfer.meta = chunkData;
390
+ return;
391
+ }
392
+
393
+ // Store the chunk
394
+ transfer.chunks.set(chunkData.index, chunkData);
395
+
396
+ // Check if all chunks received
397
+ if (transfer.meta && transfer.chunks.size >= transfer.meta.totalChunks) {
398
+ this._assembleAndNotify(agentId, fileId, transfer);
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Assemble received file chunks into a complete file in userfiles,
404
+ * then notify the AI agent about the local file path.
405
+ */
406
+ _assembleAndNotify(agentId, fileId, transfer) {
407
+ const { meta, chunks, fromId } = transfer;
408
+
409
+ try {
410
+ const sortedChunks = Array.from(chunks.entries())
411
+ .sort((a, b) => a[0] - b[0]);
412
+
413
+ const buffers = [];
414
+ for (const [index, chunk] of sortedChunks) {
415
+ if (chunk.encrypted) {
416
+ // For now, try to use raw data
417
+ buffers.push(Buffer.from(chunk.data, 'base64'));
418
+ } else {
419
+ buffers.push(Buffer.from(chunk.data, 'base64'));
420
+ }
421
+ }
422
+
423
+ const fileBuffer = Buffer.concat(buffers);
424
+ const originalName = meta.fileName || `file_${fileId}`;
425
+ const ext = path.extname(originalName) || '.bin';
426
+
427
+ // Save to userfiles with timestamp prefix for uniqueness
428
+ const timestamp = Date.now();
429
+ const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
430
+ const localPath = path.join(this.userfilesDir, safeName);
431
+ fs.writeFileSync(localPath, fileBuffer);
432
+
433
+ const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(ext);
434
+
435
+ // Save to chat history
436
+ const msg = this.db.saveMessage({
437
+ agent_id: agentId,
438
+ target_id: fromId,
439
+ from_id: fromId,
440
+ to_id: agentId,
441
+ type: isImage ? 'image' : 'file',
442
+ content: JSON.stringify({
443
+ fileId,
444
+ fileName: originalName,
445
+ fileSize: meta.fileSize,
446
+ localPath,
447
+ }),
448
+ file_name: originalName,
449
+ local_path: localPath,
450
+ is_group: 0,
451
+ status: 'delivered',
452
+ });
453
+
454
+ console.log(`[Chat] File assembled: ${originalName} -> ${localPath}`);
455
+
456
+ if (this._onNewMessage) {
457
+ this._onNewMessage(msg);
458
+
459
+ // Inject synthetic message
460
+ const fileType = isImage ? '图片' : '文件';
461
+ const syntheticMsg = {
462
+ agent_id: agentId,
463
+ target_id: fromId,
464
+ from_id: fromId,
465
+ to_id: agentId,
466
+ type: 'text',
467
+ content: `[用户发送了${fileType}] ${originalName}\n本地路径: ${localPath}\n文件大小: ${meta.fileSize} 字节\n请处理该${fileType}。`,
468
+ is_group: 0,
469
+ status: 'delivered',
470
+ _synthetic: true,
471
+ _original_msg_id: msg.message_id || msg.id,
472
+ };
473
+ this._onNewMessage(syntheticMsg);
474
+ }
475
+ } catch (e) {
476
+ console.error(`[Chat] File assembly failed for ${fileId}:`, e.message);
477
+ } finally {
478
+ this._incomingFiles.delete(fileId);
479
+ }
480
+ }
481
+
482
+ _handleStreamChunk(data) {
483
+ // Incoming streaming chunk from another agent
484
+ const agentId = this.server.currentAgentId;
485
+ if (!agentId) return;
486
+
487
+ const fromId = data.from;
488
+ const chunkType = data.chunkType || 'text';
489
+ const chunkData = data.data;
490
+
491
+ // Notify callback so OpenClaw agent can process streaming input
492
+ if (this._onNewMessage) {
493
+ this._onNewMessage({
494
+ type: 'stream_chunk',
495
+ from_id: fromId,
496
+ chunk_type: chunkType,
497
+ data: chunkData,
498
+ });
499
+ }
500
+ console.log('[Chat] Stream chunk from', fromId, 'type:', chunkType);
501
+ }
502
+
503
+ _handleStreamEnd(data) {
504
+ // Incoming stream end signal from another agent
505
+ const agentId = this.server.currentAgentId;
506
+ if (!agentId) return;
507
+
508
+ const fromId = data.from;
509
+ const messageId = data.messageId || '';
510
+
511
+ // Notify callback so OpenClaw agent knows stream is complete
512
+ if (this._onNewMessage) {
513
+ this._onNewMessage({
514
+ type: 'stream_end',
515
+ from_id: fromId,
516
+ message_id: messageId,
517
+ });
518
+ }
519
+ console.log('[Chat] Stream end from', fromId, 'messageId:', messageId);
520
+ }
521
+
522
+ // ─── Chat History ─────────────────────────────────────────────────
523
+
524
+ getHistory(agentId, targetId, { limit = 50, before = null } = {}) {
525
+ return this.db.getChatHistory(agentId, targetId, { limit, before });
526
+ }
527
+
528
+ deleteMessage(agentId, messageId) {
529
+ this.db.deleteMessage(agentId, messageId);
530
+ }
531
+
532
+ // ─── File Upload ──────────────────────────────────────────────────
533
+
534
+ async handleFileUpload(agentId, targetId, file, isGroup = false) {
535
+ const fileId = crypto.randomUUID();
536
+ const ext = path.extname(file.originalname || '.bin');
537
+ const fileName = `${fileId}${ext}`;
538
+ const filePath = path.join(this.uploadsDir, fileName);
539
+ fs.writeFileSync(filePath, file.buffer);
540
+
541
+ const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(ext);
542
+
543
+ // Send message with file reference
544
+ const msg = await this.sendMessage(agentId, targetId, isImage ? '[图片]' : `[文件] ${file.originalname}`, {
545
+ type: isImage ? 'image' : 'file',
546
+ isGroup,
547
+ file_url: `/api/files/${fileName}`,
548
+ file_name: file.originalname,
549
+ local_path: filePath,
550
+ });
551
+
552
+ return msg;
553
+ }
554
+
555
+ // ─── Userfile Management ─────────────────────────────────────────
556
+
557
+ /**
558
+ * Save an uploaded file from a user to the userfiles directory.
559
+ * This is called when files are received via the HTTP upload API
560
+ * and should be processed by the AI agent.
561
+ */
562
+ async handleUserFileUpload(agentId, fromId, file, isGroup = false) {
563
+ const fileId = crypto.randomUUID();
564
+ const ext = path.extname(file.originalname || '.bin');
565
+ const timestamp = Date.now();
566
+ const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
567
+ const localPath = path.join(this.userfilesDir, safeName);
568
+
569
+ fs.writeFileSync(localPath, file.buffer);
570
+
571
+ const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(ext);
572
+ const originalName = file.originalname || safeName;
573
+
574
+ // Save to chat history
575
+ const msg = this.db.saveMessage({
576
+ agent_id: agentId,
577
+ target_id: fromId,
578
+ from_id: fromId,
579
+ to_id: agentId,
580
+ type: isImage ? 'image' : 'file',
581
+ content: `[${isImage ? '图片' : '文件'}] ${originalName}`,
582
+ file_name: originalName,
583
+ local_path: localPath,
584
+ is_group: isGroup ? 1 : 0,
585
+ status: 'delivered',
586
+ });
587
+
588
+ if (this._onNewMessage) {
589
+ this._onNewMessage(msg);
590
+
591
+ // Inject synthetic message for AI agent
592
+ const fileType = isImage ? '图片' : '文件';
593
+ const syntheticMsg = {
594
+ agent_id: agentId,
595
+ target_id: fromId,
596
+ from_id: fromId,
597
+ to_id: agentId,
598
+ type: 'text',
599
+ content: `[用户发送了${fileType}] ${originalName}\n本地路径: ${localPath}\n文件大小: ${file.size || file.buffer?.length || 0} 字节\n请处理该${fileType}。`,
600
+ is_group: isGroup ? 1 : 0,
601
+ status: 'delivered',
602
+ _synthetic: true,
603
+ _original_msg_id: msg.message_id || msg.id,
604
+ };
605
+ this._onNewMessage(syntheticMsg);
606
+ }
607
+
608
+ return { msg, localPath, originalName };
609
+ }
610
+
611
+ // ─── Private Helpers ──────────────────────────────────────────────
612
+
613
+ /**
614
+ * Check if a message represents a file/image based on type and content.
615
+ */
616
+ _isFileMessage(msgType, content, data) {
617
+ // Check explicit message type
618
+ if (['file', 'image', 'file_chunk'].includes(msgType)) return true;
619
+ if (['file', 'image'].includes(data.type)) return true;
620
+
621
+ // Check for file metadata in content
622
+ if (typeof content === 'string') {
623
+ try {
624
+ const parsed = JSON.parse(content);
625
+ if (parsed.type === 'file-info' || parsed.fileId || parsed.fileName || parsed.localPath) {
626
+ return true;
627
+ }
628
+ } catch (e) {
629
+ // Not JSON
630
+ }
631
+ }
632
+
633
+ // Check for file_url or file data
634
+ if (data.file_url || data.fileUrl || data.file_data || data.fileData) return true;
635
+
636
+ // Check for known file markers in text content
637
+ if (typeof content === 'string' && (
638
+ content.startsWith('[文件]') ||
639
+ content.startsWith('[图片]') ||
640
+ content.startsWith('[File]') ||
641
+ content.startsWith('[Image]')
642
+ )) {
643
+ return true;
644
+ }
645
+
646
+ return false;
647
+ }
648
+
649
+ /**
650
+ * Check if a message is specifically an image (vs other file types).
651
+ */
652
+ _isImageMessage(msgType, content, data) {
653
+ if (msgType === 'image' || data.type === 'image') return true;
654
+
655
+ // Check file extension in filename
656
+ const fileName = data.file_name || data.fileName || '';
657
+ if (/\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(fileName)) return true;
658
+
659
+ // Check content markers
660
+ if (typeof content === 'string' && content.startsWith('[图片]')) return true;
661
+
662
+ return false;
663
+ }
664
+
665
+ /**
666
+ * Save an incoming file to the userfiles directory.
667
+ * Handles various formats: inline base64, URL references, file-info JSON.
668
+ */
669
+ _saveIncomingFileToUserfiles(agentId, fromId, content, data) {
670
+ try {
671
+ const fileId = crypto.randomUUID();
672
+ const timestamp = Date.now();
673
+
674
+ // Try to extract file info from the message
675
+ let parsed = null;
676
+ if (typeof content === 'string') {
677
+ try { parsed = JSON.parse(content); } catch (e) {}
678
+ }
679
+
680
+ // Case 1: file-info with chunked data (already assembled elsewhere)
681
+ if (parsed && parsed.localPath) {
682
+ // File is already on disk, just reference it
683
+ return {
684
+ localPath: parsed.localPath,
685
+ originalName: parsed.fileName || path.basename(parsed.localPath),
686
+ };
687
+ }
688
+
689
+ // Case 2: Base64 data inline
690
+ if (data.file_data || data.fileData || (parsed && parsed.data && this._isBase64Data(parsed.data))) {
691
+ return this._saveBase64FileToUserfiles(agentId, fromId, {
692
+ ...data,
693
+ file_data: data.file_data || data.fileData || (parsed && parsed.data),
694
+ file_name: data.file_name || data.fileName || (parsed && parsed.fileName) || 'file.bin',
695
+ });
696
+ }
697
+
698
+ // Case 3: URL reference — download and save
699
+ if (data.file_url || data.fileUrl || (parsed && parsed.fileUrl)) {
700
+ return this._saveUrlFileToUserfiles(agentId, fromId, {
701
+ ...data,
702
+ file_url: data.file_url || data.fileUrl || (parsed && parsed.fileUrl),
703
+ file_name: data.file_name || data.fileName || (parsed && parsed.fileName) || 'file.bin',
704
+ });
705
+ }
706
+
707
+ // Case 4: Text-based file marker like [文件] filename or [图片] filename
708
+ if (typeof content === 'string' && (
709
+ content.startsWith('[文件]') ||
710
+ content.startsWith('[图片]') ||
711
+ content.startsWith('[File]') ||
712
+ content.startsWith('[Image]')
713
+ )) {
714
+ const originalName = content.replace(/^\[(文件|图片|File|Image)\]\s*/, '').trim() || 'unknown';
715
+ const ext = path.extname(originalName) || (content.includes('图片') || content.includes('Image') ? '.png' : '.bin');
716
+ const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
717
+ const localPath = path.join(this.userfilesDir, safeName);
718
+
719
+ // Create a placeholder file — the actual content may come via chunks
720
+ // or may already be in the uploads dir
721
+ const uploadsPath = path.join(this.uploadsDir, originalName);
722
+ if (fs.existsSync(uploadsPath)) {
723
+ fs.copyFileSync(uploadsPath, localPath);
724
+ console.log(`[Chat] Copied user file: ${originalName} -> ${localPath}`);
725
+ return { localPath, originalName };
726
+ }
727
+
728
+ // No actual file data yet — save a placeholder
729
+ fs.writeFileSync(localPath, Buffer.alloc(0));
730
+ console.log(`[Chat] Created placeholder for user file: ${localPath}`);
731
+ return { localPath, originalName };
732
+ }
733
+
734
+ return null;
735
+ } catch (e) {
736
+ console.error('[Chat] Failed to save incoming file to userfiles:', e.message);
737
+ return null;
738
+ }
739
+ }
740
+
741
+ /**
742
+ * Save a base64-encoded file to userfiles.
743
+ */
744
+ _saveBase64FileToUserfiles(agentId, fromId, data) {
745
+ try {
746
+ const fileId = crypto.randomUUID();
747
+ const timestamp = Date.now();
748
+ const base64Data = data.file_data || data.fileData || data.data;
749
+ const originalName = data.file_name || data.fileName || 'file.bin';
750
+
751
+ if (!base64Data) return null;
752
+
753
+ // Strip data URL prefix if present (e.g., "data:image/png;base64,")
754
+ const base64Clean = base64Data.replace(/^data:[^;]+;base64,/, '');
755
+ const fileBuffer = Buffer.from(base64Clean, 'base64');
756
+
757
+ const ext = path.extname(originalName) || this._inferExtFromData(base64Data);
758
+ const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
759
+ const localPath = path.join(this.userfilesDir, safeName);
760
+
761
+ fs.writeFileSync(localPath, fileBuffer);
762
+ console.log(`[Chat] Saved base64 user file: ${originalName} -> ${localPath} (${fileBuffer.length} bytes)`);
763
+
764
+ return { localPath, originalName };
765
+ } catch (e) {
766
+ console.error('[Chat] Failed to save base64 file:', e.message);
767
+ return null;
768
+ }
769
+ }
770
+
771
+ /**
772
+ * Download a file from URL and save to userfiles.
773
+ */
774
+ _saveUrlFileToUserfiles(agentId, fromId, data) {
775
+ try {
776
+ const fileId = crypto.randomUUID();
777
+ const timestamp = Date.now();
778
+ const fileUrl = data.file_url || data.fileUrl;
779
+ const originalName = data.file_name || data.fileName || path.basename(fileUrl || 'file.bin');
780
+
781
+ // For local server URLs, resolve the local path directly
782
+ if (fileUrl && fileUrl.startsWith('/api/files/')) {
783
+ const fileName = path.basename(fileUrl);
784
+ const uploadsPath = path.join(this.uploadsDir, fileName);
785
+ if (fs.existsSync(uploadsPath)) {
786
+ const ext = path.extname(originalName) || path.extname(uploadsPath);
787
+ const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
788
+ const localPath = path.join(this.userfilesDir, safeName);
789
+ fs.copyFileSync(uploadsPath, localPath);
790
+ console.log(`[Chat] Copied local URL file: ${fileUrl} -> ${localPath}`);
791
+ return { localPath, originalName };
792
+ }
793
+ }
794
+
795
+ // For remote URLs, we'd need async download — log and skip for now
796
+ console.log(`[Chat] Remote file URL (async download not yet supported): ${fileUrl}`);
797
+ const ext = path.extname(originalName) || '.bin';
798
+ const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
799
+ const localPath = path.join(this.userfilesDir, safeName);
800
+
801
+ // Save a placeholder with the URL reference
802
+ fs.writeFileSync(localPath, JSON.stringify({
803
+ type: 'url_reference',
804
+ url: fileUrl,
805
+ originalName,
806
+ timestamp,
807
+ }));
808
+ return { localPath, originalName };
809
+ } catch (e) {
810
+ console.error('[Chat] Failed to save URL file:', e.message);
811
+ return null;
812
+ }
813
+ }
814
+
815
+ /**
816
+ * Check if a string looks like base64 data.
817
+ */
818
+ _isBase64Data(str) {
819
+ if (typeof str !== 'string') return false;
820
+ if (str.startsWith('data:')) return true;
821
+ // Quick heuristic: long string with only base64 chars
822
+ if (str.length > 100 && /^[A-Za-z0-9+/=\s]+$/.test(str.substring(0, 200))) return true;
823
+ return false;
824
+ }
825
+
826
+ /**
827
+ * Infer file extension from base64 data URL prefix.
828
+ */
829
+ _inferExtFromData(data) {
830
+ if (typeof data !== 'string') return '.bin';
831
+ const mimeMatch = data.match(/^data:([^;]+);/);
832
+ if (mimeMatch) {
833
+ const mime = mimeMatch[1];
834
+ const mimeToExt = {
835
+ 'image/png': '.png',
836
+ 'image/jpeg': '.jpg',
837
+ 'image/gif': '.gif',
838
+ 'image/webp': '.webp',
839
+ 'image/svg+xml': '.svg',
840
+ 'image/bmp': '.bmp',
841
+ 'application/pdf': '.pdf',
842
+ 'text/plain': '.txt',
843
+ 'application/json': '.json',
844
+ 'application/zip': '.zip',
845
+ 'audio/mpeg': '.mp3',
846
+ 'video/mp4': '.mp4',
847
+ };
848
+ return mimeToExt[mime] || '.bin';
849
+ }
850
+ return '.bin';
851
+ }
852
+ }
853
+
854
+ module.exports = ChatManager;