aicq-chat-plugin 3.9.0 → 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,971 +1,854 @@
1
- /**
2
- * AICQ Chat Manager — Send/receive messages, group chat, file/image handling
3
- *
4
- * v3.9.0: File/image receiving redesigned.
5
- * Incoming files are saved to userfiles/ directory first,
6
- * then a simulated user message is dispatched to the AI agent
7
- * telling it about the uploaded file with full path info.
8
- * The agent can then read and process the file.
9
- */
10
- const { encryptMessage, decryptMessage } = require('./crypto');
11
- const fs = require('fs');
12
- const path = require('path');
13
- const crypto = require('crypto');
14
-
15
- const FILE_CHUNK_SIZE = 512 * 1024; // 512KB per WS chunk
16
- const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB limit
17
-
18
- class ChatManager {
19
- constructor(identityManager, serverClient, db, uploadsDir) {
20
- this.identity = identityManager;
21
- this.server = serverClient;
22
- this.db = db;
23
- this.uploadsDir = uploadsDir;
24
- this._onNewMessage = null;
25
-
26
- // userfiles/ directory where received files are saved for agent processing
27
- this.userfilesDir = path.join(path.dirname(uploadsDir), 'userfiles');
28
-
29
- // Ensure directories exist
30
- if (!fs.existsSync(uploadsDir)) {
31
- fs.mkdirSync(uploadsDir, { recursive: true });
32
- }
33
- if (!fs.existsSync(this.userfilesDir)) {
34
- fs.mkdirSync(this.userfilesDir, { recursive: true });
35
- }
36
-
37
- // Listen for incoming messages via WS
38
- this.server.onMessage('relay', (data) => this._handleIncoming(data));
39
- this.server.onMessage('message', (data) => this._handleIncoming(data));
40
- this.server.onMessage('group_message', (data) => this._handleGroupIncoming(data));
41
- this.server.onMessage('handshake_initiate', (data) => this._handleHandshakeRequest(data));
42
- this.server.onMessage('presence', (data) => this._handlePresence(data));
43
- this.server.onMessage('file_chunk', (data) => this._handleFileChunk(data));
44
- this.server.onMessage('stream_chunk', (data) => this._handleStreamChunk(data));
45
- this.server.onMessage('stream_end', (data) => this._handleStreamEnd(data));
46
-
47
- // Incoming file transfer state: fileId -> { meta, chunks }
48
- this._incomingFiles = new Map();
49
- }
50
-
51
- setOnNewMessage(callback) {
52
- this._onNewMessage = callback;
53
- }
54
-
55
- // ─── Send Messages ────────────────────────────────────────────────
56
-
57
- async sendMessage(agentId, targetId, content, { type = 'text', isGroup = false, mentions = [], file_url = null, file_name = null } = {}) {
58
- const identity = this.identity.loadAgent(agentId);
59
-
60
- if (isGroup) {
61
- // Group message via WebSocket
62
- const sent = this.server.sendWS({
63
- type: 'group_message',
64
- groupId: targetId,
65
- content,
66
- msgType: type,
67
- mentions,
68
- });
69
-
70
- // Save locally
71
- const msg = this.db.saveMessage({
72
- agent_id: agentId,
73
- target_id: targetId,
74
- from_id: agentId,
75
- to_id: targetId,
76
- type,
77
- content,
78
- file_url,
79
- file_name,
80
- is_group: 1,
81
- mentions,
82
- status: sent ? 'sent' : 'pending',
83
- });
84
-
85
- if (this._onNewMessage) this._onNewMessage(msg);
86
- return msg;
87
- }
88
-
89
- // Direct message
90
- // Try to encrypt if we have a session key
91
- const session = this.db.loadSession(agentId, targetId);
92
- let payload = content;
93
- if (session && session.session_key) {
94
- try {
95
- payload = encryptMessage(content, session.session_key);
96
- } catch (e) {
97
- console.error('[Chat] Encryption failed, sending plaintext:', e.message);
98
- }
99
- }
100
-
101
- // Send via WebSocket relay
102
- const sent = this.server.sendWS({
103
- type: 'relay',
104
- targetId: targetId,
105
- payload,
106
- });
107
-
108
- // Also try REST fallback
109
- if (!sent) {
110
- try {
111
- await this.server._request('POST', '/messages/send', {
112
- targetId,
113
- payload,
114
- });
115
- } catch (e) {
116
- // Queue offline
117
- this.db.enqueueOffline({
118
- agent_id: agentId,
119
- target_id: targetId,
120
- data: JSON.stringify({ type: 'relay', targetId, payload }),
121
- });
122
- }
123
- }
124
-
125
- // Save locally
126
- const msg = this.db.saveMessage({
127
- agent_id: agentId,
128
- target_id: targetId,
129
- from_id: agentId,
130
- to_id: targetId,
131
- type,
132
- content,
133
- file_url,
134
- file_name,
135
- is_group: 0,
136
- mentions,
137
- status: sent ? 'sent' : 'pending',
138
- });
139
-
140
- // Update session message count
141
- if (session) {
142
- this.db.incrementSessionMessageCount(agentId, targetId);
143
- }
144
-
145
- if (this._onNewMessage) this._onNewMessage(msg);
146
- return msg;
147
- }
148
-
149
- // ─── Send File ──────────────────────────────────────────────────
150
-
151
- /**
152
- * Send a file to a friend or group.
153
- *
154
- * Reads the file from disk, chunks it, and sends via WebSocket
155
- * using the AICQ 'message' protocol with type='file'.
156
- * The receiver's chat.html client will assemble and display the file.
157
- *
158
- * @param {string} agentId - Sender agent ID
159
- * @param {string} targetId - Recipient (friend ID or group ID)
160
- * @param {string} filePath - Local file path to send
161
- * @param {object} options - { isGroup, caption }
162
- * @returns {object} Send result with fileId, fileName, fileSize
163
- */
164
- async sendFile(agentId, targetId, filePath, { isGroup = false, caption = '' } = {}) {
165
- if (!fs.existsSync(filePath)) {
166
- throw new Error(`File not found: ${filePath}`);
167
- }
168
-
169
- const stat = fs.statSync(filePath);
170
- if (stat.size > MAX_FILE_SIZE) {
171
- throw new Error(`File too large: ${stat.size} bytes (max ${MAX_FILE_SIZE})`);
172
- }
173
-
174
- const fileBuffer = fs.readFileSync(filePath);
175
- const originalName = path.basename(filePath);
176
- const ext = path.extname(originalName).toLowerCase();
177
- const mimeType = this._getMimeType(originalName);
178
- const isImage = this._isImageExt(ext);
179
- const msgType = isImage ? 'image' : 'file';
180
-
181
- // Generate a unique file ID
182
- const fileId = crypto.randomUUID();
183
-
184
- // Save a local copy in uploads dir
185
- const localFileName = `${fileId}${ext}`;
186
- const localPath = path.join(this.uploadsDir, localFileName);
187
- fs.writeFileSync(localPath, fileBuffer);
188
-
189
- // Build the file info message (compatible with chat.html client)
190
- const fileInfo = {
191
- id: `msg_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
192
- from_id: agentId,
193
- to_id: targetId,
194
- type: msgType,
195
- content: caption || (isImage ? '[图片]' : `[文件] ${originalName}`),
196
- file_info: {
197
- fileId,
198
- fileName: originalName,
199
- fileSize: stat.size,
200
- mimeType,
201
- isImage,
202
- chunks: Math.ceil(stat.size / FILE_CHUNK_SIZE),
203
- },
204
- file_url: `/api/files/${localFileName}`,
205
- file_name: originalName,
206
- created_at: new Date().toISOString(),
207
- status: 'sent',
208
- };
209
-
210
- // Send the file-info message first
211
- if (isGroup) {
212
- this.server.sendWS({
213
- type: 'group_message',
214
- groupId: targetId,
215
- from: agentId,
216
- content: JSON.stringify(fileInfo),
217
- msgType: msgType,
218
- timestamp: Date.now(),
219
- });
220
- } else {
221
- this.server.sendWS({
222
- type: 'message',
223
- to: targetId,
224
- data: fileInfo,
225
- });
226
- }
227
-
228
- // Send file data in chunks via file_chunk messages
229
- const totalChunks = Math.ceil(stat.size / FILE_CHUNK_SIZE);
230
- for (let i = 0; i < totalChunks; i++) {
231
- const start = i * FILE_CHUNK_SIZE;
232
- const end = Math.min(start + FILE_CHUNK_SIZE, stat.size);
233
- const chunkBuffer = fileBuffer.slice(start, end);
234
-
235
- const chunkMsg = {
236
- fileId,
237
- index: i,
238
- total: totalChunks,
239
- data: chunkBuffer.toString('base64'),
240
- };
241
-
242
- if (isGroup) {
243
- this.server.sendWS({
244
- type: 'group_message',
245
- groupId: targetId,
246
- from: agentId,
247
- content: JSON.stringify(chunkMsg),
248
- msgType: 'file_chunk',
249
- timestamp: Date.now(),
250
- });
251
- } else {
252
- this.server.sendWS({
253
- type: 'file_chunk',
254
- to: targetId,
255
- data: chunkMsg,
256
- });
257
- }
258
-
259
- // Small delay between chunks to avoid WS flooding
260
- if (i < totalChunks - 1 && totalChunks > 1) {
261
- await new Promise(resolve => setTimeout(resolve, 10));
262
- }
263
- }
264
-
265
- // Save message to local chat history
266
- const msg = this.db.saveMessage({
267
- agent_id: agentId,
268
- target_id: targetId,
269
- from_id: agentId,
270
- to_id: targetId,
271
- type: msgType,
272
- content: caption || (isImage ? `[图片] ${originalName}` : `[文件] ${originalName}`),
273
- file_url: `/api/files/${localFileName}`,
274
- file_name: originalName,
275
- is_group: isGroup ? 1 : 0,
276
- status: 'sent',
277
- });
278
-
279
- if (this._onNewMessage) this._onNewMessage(msg);
280
-
281
- console.log(`[Chat] File sent: ${originalName} (${stat.size} bytes, ${totalChunks} chunks) to ${targetId}`);
282
-
283
- return {
284
- fileId,
285
- fileName: originalName,
286
- fileSize: stat.size,
287
- mimeType,
288
- isImage,
289
- totalChunks,
290
- localPath,
291
- message: msg,
292
- };
293
- }
294
-
295
- /**
296
- * Send an image from a buffer (e.g., generated by AI).
297
- *
298
- * @param {string} agentId - Sender agent ID
299
- * @param {string} targetId - Recipient
300
- * @param {Buffer} imageBuffer - Image data
301
- * @param {string} fileName - File name (e.g., 'image.png')
302
- * @param {object} options - { isGroup, caption }
303
- * @returns {object} Send result
304
- */
305
- async sendImageBuffer(agentId, targetId, imageBuffer, fileName = 'image.png', { isGroup = false, caption = '' } = {}) {
306
- if (!Buffer.isBuffer(imageBuffer)) {
307
- throw new Error('imageBuffer must be a Buffer');
308
- }
309
-
310
- // Save buffer to a temp file, then use sendFile
311
- const tempPath = path.join(this.uploadsDir, `temp_${Date.now()}_${fileName}`);
312
- fs.writeFileSync(tempPath, imageBuffer);
313
-
314
- try {
315
- const result = await this.sendFile(agentId, targetId, tempPath, { isGroup, caption });
316
- return result;
317
- } finally {
318
- // Clean up temp file
319
- try { fs.unlinkSync(tempPath); } catch (e) {}
320
- }
321
- }
322
-
323
- /**
324
- * Send a file from a base64-encoded string.
325
- *
326
- * @param {string} agentId - Sender agent ID
327
- * @param {string} targetId - Recipient
328
- * @param {string} base64Data - Base64-encoded file data
329
- * @param {string} fileName - File name
330
- * @param {object} options - { isGroup, caption, mimeType }
331
- * @returns {object} Send result
332
- */
333
- async sendFileFromBase64(agentId, targetId, base64Data, fileName, { isGroup = false, caption = '', mimeType = '' } = {}) {
334
- const buffer = Buffer.from(base64Data, 'base64');
335
-
336
- // Save to temp file
337
- const ext = path.extname(fileName) || this._extFromMime(mimeType) || '.bin';
338
- const tempPath = path.join(this.uploadsDir, `temp_${Date.now()}_${fileName}`);
339
- fs.writeFileSync(tempPath, buffer);
340
-
341
- try {
342
- const result = await this.sendFile(agentId, targetId, tempPath, { isGroup, caption });
343
- return result;
344
- } finally {
345
- try { fs.unlinkSync(tempPath); } catch (e) {}
346
- }
347
- }
348
-
349
- // ─── Receive Messages ─────────────────────────────────────────────
350
-
351
- _handleIncoming(data) {
352
- const agentId = this.server.currentAgentId;
353
- if (!agentId) return;
354
-
355
- const fromId = data.fromId || data.from;
356
- let content = data.payload || data.data || '';
357
-
358
- // Check if this is a file/image message in the new format
359
- if (typeof data === 'object' && data.data && typeof data.data === 'object' && data.data.file_info) {
360
- // This is a structured message with file info — save to userfiles and notify agent
361
- const fileInfo = data.data.file_info;
362
- const msgType = fileInfo.isImage ? 'image' : 'file';
363
- this._saveToUserfilesAndNotify(agentId, fromId, fileInfo, data.data, { isGroup: false });
364
- return;
365
- }
366
-
367
- // Try to decrypt if we have a session key
368
- const session = this.db.loadSession(agentId, fromId);
369
- if (session && session.session_key && typeof content === 'string') {
370
- try {
371
- content = decryptMessage(content, session.session_key);
372
- } catch (e) {
373
- // Might be plaintext, keep as is
374
- }
375
- }
376
-
377
- const msg = this.db.saveMessage({
378
- agent_id: agentId,
379
- target_id: fromId,
380
- from_id: fromId,
381
- to_id: agentId,
382
- type: 'text',
383
- content: typeof content === 'string' ? content : JSON.stringify(content),
384
- is_group: 0,
385
- status: 'delivered',
386
- });
387
-
388
- if (this._onNewMessage) this._onNewMessage(msg);
389
- }
390
-
391
- _handleGroupIncoming(data) {
392
- const agentId = this.server.currentAgentId;
393
- if (!agentId) return;
394
-
395
- const fromId = data.fromId || data.from;
396
- const groupId = data.groupId;
397
-
398
- // Check if this is a file/image message
399
- let content = data.content || '';
400
- const msgType = data.msgType || data.msg_type || 'text';
401
-
402
- if (msgType === 'file' || msgType === 'image') {
403
- // File/image in group message save to userfiles and notify agent
404
- let fileInfo = {};
405
- try {
406
- fileInfo = typeof content === 'string' ? JSON.parse(content) : content;
407
- } catch (e) {}
408
-
409
- if (fileInfo.file_info) {
410
- this._saveToUserfilesAndNotify(agentId, groupId, fileInfo.file_info, fileInfo, { isGroup: true, fromId });
411
- return;
412
- }
413
- }
414
-
415
- // Check silent mode
416
- const silent = this.db.getGroupSilentMode(agentId, groupId);
417
- const mentions = data.mentions || [];
418
- const isMentioned = mentions.includes(agentId) || mentions.includes('all');
419
-
420
- const msg = this.db.saveMessage({
421
- agent_id: agentId,
422
- target_id: groupId,
423
- from_id: fromId,
424
- to_id: groupId,
425
- type: msgType,
426
- content,
427
- is_group: 1,
428
- mentions,
429
- status: (silent && !isMentioned) ? 'silent' : 'delivered',
430
- });
431
-
432
- if (this._onNewMessage) this._onNewMessage(msg);
433
- }
434
-
435
- _handleHandshakeRequest(data) {
436
- const agentId = this.server.currentAgentId;
437
- if (!agentId) return;
438
-
439
- this.db.savePendingRequest({
440
- agent_id: agentId,
441
- session_id: data.sessionId || crypto.randomUUID(),
442
- requester_id: data.requesterId || data.from,
443
- requester_public_key: data.requesterPublicKey || data.exchangePublicKey || '',
444
- });
445
- }
446
-
447
- _handlePresence(data) {
448
- const agentId = this.server.currentAgentId;
449
- if (!agentId) return;
450
-
451
- const friendId = data.nodeId;
452
- const isOnline = data.online === true || data.status === 'online';
453
- this.db.updateFriendOnline(agentId, friendId, isOnline);
454
- }
455
-
456
- _handleFileChunk(data) {
457
- // Handle incoming file chunks
458
- const chunkData = data.data || data;
459
- const fileId = chunkData.fileId;
460
-
461
- if (!fileId) return;
462
-
463
- // Initialize incoming transfer if needed
464
- if (!this._incomingFiles.has(fileId)) {
465
- this._incomingFiles.set(fileId, {
466
- chunks: new Map(),
467
- meta: null,
468
- fromId: data.from || data.fromId,
469
- });
470
- }
471
-
472
- const transfer = this._incomingFiles.get(fileId);
473
-
474
- // If this is a file-info message
475
- if (chunkData.type === 'file-info' || chunkData.file_info) {
476
- transfer.meta = chunkData.file_info || chunkData;
477
- return;
478
- }
479
-
480
- // Store the chunk
481
- transfer.chunks.set(chunkData.index, chunkData);
482
-
483
- // Check if all chunks received
484
- if (transfer.meta && transfer.chunks.size >= transfer.meta.chunks) {
485
- this._assembleFile(fileId, transfer);
486
- }
487
- }
488
-
489
- /**
490
- * Assemble received file chunks into a complete file.
491
- */
492
- _assembleFile(fileId, transfer) {
493
- const { meta, chunks, fromId } = transfer;
494
-
495
- try {
496
- const sortedChunks = Array.from(chunks.entries())
497
- .sort((a, b) => a[0] - b[0]);
498
-
499
- const buffers = [];
500
- for (const [index, chunk] of sortedChunks) {
501
- buffers.push(Buffer.from(chunk.data, 'base64'));
502
- }
503
-
504
- const fileBuffer = Buffer.concat(buffers);
505
- const agentId = this.server.currentAgentId;
506
-
507
- // Determine file extension
508
- const ext = this._extFromMime(meta.mimeType) || path.extname(meta.fileName) || '.bin';
509
- const localFileName = `${fileId}${ext}`;
510
- const localPath = path.join(this.uploadsDir, localFileName);
511
-
512
- // Save to uploads directory
513
- fs.writeFileSync(localPath, fileBuffer);
514
-
515
- const isImage = meta.isImage || this._isImageExt(ext);
516
- const msgType = isImage ? 'image' : 'file';
517
-
518
- // Save the assembled file to userfiles/ and notify the agent
519
- if (agentId) {
520
- // Move from uploads/ to userfiles/ for agent access
521
- const userfilesPath = path.join(this.userfilesDir, localFileName);
522
- try {
523
- // Copy to userfiles (keep original in uploads for HTTP serving)
524
- fs.copyFileSync(localPath, userfilesPath);
525
- } catch (e) {
526
- console.warn(`[Chat] Could not copy to userfiles: ${e.message}`);
527
- }
528
-
529
- // Save message to chat history
530
- const msg = this.db.saveMessage({
531
- agent_id: agentId,
532
- target_id: fromId || '',
533
- from_id: fromId || '',
534
- to_id: agentId,
535
- type: msgType,
536
- content: isImage ? `[图片] ${meta.fileName}` : `[文件] ${meta.fileName}`,
537
- file_url: `/api/files/${localFileName}`,
538
- file_name: meta.fileName,
539
- is_group: 0,
540
- status: 'delivered',
541
- });
542
-
543
- // Notify agent with file path info
544
- if (this._onNewMessage) {
545
- this._notifyAgentAboutFile(agentId, fromId || '', meta.fileName, userfilesPath, msgType, isImage, { isGroup: false });
546
- this._onNewMessage(msg);
547
- }
548
- }
549
-
550
- console.log(`[Chat] File assembled and saved to userfiles: ${meta.fileName} (${fileBuffer.length} bytes)`);
551
- } catch (e) {
552
- console.error(`[Chat] File assembly failed for ${fileId}:`, e.message);
553
- } finally {
554
- this._incomingFiles.delete(fileId);
555
-
556
- // Check if there's a pending notification for this file
557
- if (this._pendingFileNotifications && this._pendingFileNotifications.has(fileId)) {
558
- const pending = this._pendingFileNotifications.get(fileId);
559
- this._pendingFileNotifications.delete(fileId);
560
-
561
- // The file is now assembled in uploads/ — copy to userfiles/
562
- const ext = this._extFromMime(meta?.mimeType) || path.extname(meta?.fileName || '') || '.bin';
563
- const localFileName = `${fileId}${ext}`;
564
- const localPath = path.join(this.uploadsDir, localFileName);
565
- const userfilesPath = path.join(this.userfilesDir, pending.safeName);
566
-
567
- if (fs.existsSync(localPath)) {
568
- try {
569
- fs.copyFileSync(localPath, userfilesPath);
570
- this._notifyAgentAboutFile(
571
- pending.agentId, pending.fromId, pending.originalName,
572
- userfilesPath, pending.msgType, pending.isImage,
573
- { isGroup: pending.isGroup }
574
- );
575
- console.log(`[Chat] Pending notification sent for assembled file: ${pending.originalName}`);
576
- } catch (e2) {
577
- console.warn(`[Chat] Failed to copy assembled file to userfiles: ${e2.message}`);
578
- }
579
- }
580
- }
581
- }
582
- }
583
-
584
- _handleStreamChunk(data) {
585
- // Incoming streaming chunk from another agent
586
- const agentId = this.server.currentAgentId;
587
- if (!agentId) return;
588
-
589
- const fromId = data.from;
590
- const chunkType = data.chunkType || 'text';
591
- const chunkData = data.data;
592
-
593
- // Notify callback so OpenClaw agent can process streaming input
594
- if (this._onNewMessage) {
595
- this._onNewMessage({
596
- type: 'stream_chunk',
597
- from_id: fromId,
598
- chunk_type: chunkType,
599
- data: chunkData,
600
- });
601
- }
602
- console.log('[Chat] Stream chunk from', fromId, 'type:', chunkType);
603
- }
604
-
605
- _handleStreamEnd(data) {
606
- // Incoming stream end signal from another agent
607
- const agentId = this.server.currentAgentId;
608
- if (!agentId) return;
609
-
610
- const fromId = data.from;
611
- const messageId = data.messageId || '';
612
-
613
- // Notify callback so OpenClaw agent knows stream is complete
614
- if (this._onNewMessage) {
615
- this._onNewMessage({
616
- type: 'stream_end',
617
- from_id: fromId,
618
- message_id: messageId,
619
- });
620
- }
621
- console.log('[Chat] Stream end from', fromId, 'messageId:', messageId);
622
- }
623
-
624
- // ─── Chat History ─────────────────────────────────────────────────
625
-
626
- getHistory(agentId, targetId, { limit = 50, before = null } = {}) {
627
- return this.db.getChatHistory(agentId, targetId, { limit, before });
628
- }
629
-
630
- deleteMessage(agentId, messageId) {
631
- this.db.deleteMessage(agentId, messageId);
632
- }
633
-
634
- // ─── File Upload (from HTTP) ────────────────────────────────────
635
-
636
- async handleFileUpload(agentId, targetId, file, isGroup = false) {
637
- const fileId = crypto.randomUUID();
638
- const ext = path.extname(file.originalname || '.bin');
639
- const fileName = `${fileId}${ext}`;
640
- const filePath = path.join(this.uploadsDir, fileName);
641
- fs.writeFileSync(filePath, file.buffer);
642
-
643
- const isImage = this._isImageExt(ext);
644
- const msgType = isImage ? 'image' : 'file';
645
-
646
- // Build file info for WS message
647
- const fileInfo = {
648
- id: `msg_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`,
649
- from_id: agentId,
650
- to_id: targetId,
651
- type: msgType,
652
- content: isImage ? '[图片]' : `[文件] ${file.originalname}`,
653
- file_info: {
654
- fileId,
655
- fileName: file.originalname,
656
- fileSize: file.size,
657
- mimeType: file.mimetype || this._getMimeType(file.originalname),
658
- isImage,
659
- chunks: 1,
660
- },
661
- file_url: `/api/files/${fileName}`,
662
- file_name: file.originalname,
663
- created_at: new Date().toISOString(),
664
- status: 'sent',
665
- };
666
-
667
- // Send file-info message via WS
668
- if (isGroup) {
669
- this.server.sendWS({
670
- type: 'group_message',
671
- groupId: targetId,
672
- from: agentId,
673
- content: JSON.stringify(fileInfo),
674
- msgType: msgType,
675
- timestamp: Date.now(),
676
- });
677
- } else {
678
- this.server.sendWS({
679
- type: 'message',
680
- to: targetId,
681
- data: fileInfo,
682
- });
683
- }
684
-
685
- // If file is large enough, also send as file_chunk for assembly
686
- if (file.size > 0) {
687
- const totalChunks = Math.ceil(file.size / FILE_CHUNK_SIZE);
688
- for (let i = 0; i < totalChunks; i++) {
689
- const start = i * FILE_CHUNK_SIZE;
690
- const end = Math.min(start + FILE_CHUNK_SIZE, file.size);
691
- const chunkBuffer = file.buffer.slice(start, end);
692
-
693
- const chunkMsg = {
694
- fileId,
695
- index: i,
696
- total: totalChunks,
697
- data: chunkBuffer.toString('base64'),
698
- };
699
-
700
- if (isGroup) {
701
- this.server.sendWS({
702
- type: 'group_message',
703
- groupId: targetId,
704
- from: agentId,
705
- content: JSON.stringify(chunkMsg),
706
- msgType: 'file_chunk',
707
- timestamp: Date.now(),
708
- });
709
- } else {
710
- this.server.sendWS({
711
- type: 'file_chunk',
712
- to: targetId,
713
- data: chunkMsg,
714
- });
715
- }
716
- }
717
- }
718
-
719
- // Save message locally
720
- const msg = this.db.saveMessage({
721
- agent_id: agentId,
722
- target_id: targetId,
723
- from_id: agentId,
724
- to_id: targetId,
725
- type: msgType,
726
- content: isImage ? `[图片] ${file.originalname}` : `[文件] ${file.originalname}`,
727
- file_url: `/api/files/${fileName}`,
728
- file_name: file.originalname,
729
- is_group: isGroup ? 1 : 0,
730
- status: 'sent',
731
- });
732
-
733
- if (this._onNewMessage) this._onNewMessage(msg);
734
- return msg;
735
- }
736
-
737
- // ─── Userfiles: save received file and notify agent ─────────────
738
-
739
- /**
740
- * Save a received file to userfiles/ directory and notify the AI agent
741
- * that a file was uploaded. This creates a simulated user message
742
- * telling the agent about the file with its full path.
743
- *
744
- * @param {string} agentId - The local agent ID
745
- * @param {string} chatId - The chat/session ID (friend or group)
746
- * @param {object} fileInfo - File metadata { fileName, fileSize, mimeType, isImage }
747
- * @param {object} rawData - The raw message data (may contain base64 content)
748
- * @param {object} opts - { isGroup, fromId }
749
- */
750
- _saveToUserfilesAndNotify(agentId, chatId, fileInfo, rawData, opts = {}) {
751
- const { isGroup = false, fromId = chatId } = opts;
752
- const originalName = fileInfo.fileName || 'unknown';
753
- const isImage = fileInfo.isImage || this._isImageExt(path.extname(originalName));
754
- const msgType = isImage ? 'image' : 'file';
755
-
756
- // Generate a safe filename: timestamp_originalname to avoid collisions
757
- const safeName = `${Date.now()}_${originalName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
758
- const userfilesPath = path.join(this.userfilesDir, safeName);
759
-
760
- // Try to extract file data from the message
761
- let saved = false;
762
-
763
- // Case 1: file_info message may include base64 data in rawData.data
764
- if (rawData.data && typeof rawData.data === 'string') {
765
- try {
766
- const buffer = Buffer.from(rawData.data, 'base64');
767
- fs.writeFileSync(userfilesPath, buffer);
768
- saved = true;
769
- console.log(`[Chat] File saved to userfiles from base64 data: ${safeName} (${buffer.length} bytes)`);
770
- } catch (e) {
771
- console.warn(`[Chat] Failed to save file from base64 data: ${e.message}`);
772
- }
773
- }
774
-
775
- // Case 2: file_url might point to a local file in uploads/
776
- if (!saved && rawData.file_url) {
777
- const localFileName = path.basename(rawData.file_url);
778
- const localPath = path.join(this.uploadsDir, localFileName);
779
- if (fs.existsSync(localPath)) {
780
- try {
781
- fs.copyFileSync(localPath, userfilesPath);
782
- saved = true;
783
- console.log(`[Chat] File copied from uploads to userfiles: ${safeName}`);
784
- } catch (e) {
785
- console.warn(`[Chat] Failed to copy file from uploads: ${e.message}`);
786
- }
787
- }
788
- }
789
-
790
- // Case 3: If we have file_info with chunks, the file may still be
791
- // downloading via file_chunk messages. In that case, we set up a
792
- // pending notification that will be sent when _assembleFile completes.
793
- if (!saved && fileInfo.fileId) {
794
- // Store the notification info for when the file is fully assembled
795
- if (!this._pendingFileNotifications) {
796
- this._pendingFileNotifications = new Map();
797
- }
798
- this._pendingFileNotifications.set(fileInfo.fileId, {
799
- agentId, chatId, fromId, originalName, msgType, isImage, isGroup, safeName,
800
- });
801
- console.log(`[Chat] File ${originalName} pending assembly (fileId=${fileInfo.fileId}), notification queued`);
802
- }
803
-
804
- // Save message to chat history
805
- const msg = this.db.saveMessage({
806
- agent_id: agentId,
807
- target_id: chatId,
808
- from_id: fromId,
809
- to_id: agentId,
810
- type: msgType,
811
- content: isImage ? `[图片] ${originalName}` : `[文件] ${originalName}`,
812
- file_url: rawData.file_url || `/userfiles/${safeName}`,
813
- file_name: originalName,
814
- is_group: isGroup ? 1 : 0,
815
- status: saved ? 'delivered' : 'pending',
816
- });
817
-
818
- // Notify the agent about the file
819
- if (saved) {
820
- this._notifyAgentAboutFile(agentId, fromId, originalName, userfilesPath, msgType, isImage, opts);
821
- }
822
-
823
- if (this._onNewMessage) this._onNewMessage(msg);
824
- }
825
-
826
- /**
827
- * Notify the AI agent about a received file by sending a simulated
828
- * user message that describes the file and its location.
829
- *
830
- * @param {string} agentId - The local agent ID
831
- * @param {string} fromId - The sender ID
832
- * @param {string} fileName - Original file name
833
- * @param {string} filePath - Absolute path to the saved file in userfiles/
834
- * @param {string} msgType - 'image' or 'file'
835
- * @param {boolean} isImage - Whether this is an image
836
- * @param {object} opts - { isGroup, caption }
837
- */
838
- _notifyAgentAboutFile(agentId, fromId, fileName, filePath, msgType, isImage, opts = {}) {
839
- const { isGroup = false, caption = '' } = opts;
840
- const fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
841
- const mimeType = this._getMimeType(fileName);
842
-
843
- // Build a descriptive message for the AI agent
844
- let agentMessage;
845
- if (isImage) {
846
- agentMessage = [
847
- `[用户上传了图片]`,
848
- `文件名: ${fileName}`,
849
- `文件路径: ${filePath}`,
850
- `文件大小: ${this._formatFileSize(fileSize)}`,
851
- `文件类型: ${mimeType}`,
852
- caption ? `说明: ${caption}` : '',
853
- `请查看并处理这张图片。`,
854
- ].filter(Boolean).join('\n');
855
- } else {
856
- agentMessage = [
857
- `[用户上传了文件]`,
858
- `文件名: ${fileName}`,
859
- `文件路径: ${filePath}`,
860
- `文件大小: ${this._formatFileSize(fileSize)}`,
861
- `文件类型: ${mimeType}`,
862
- caption ? `说明: ${caption}` : '',
863
- `请读取并处理这个文件。`,
864
- ].filter(Boolean).join('\n');
865
- }
866
-
867
- // Save this as a text message in chat history so the agent sees it
868
- const msg = this.db.saveMessage({
869
- agent_id: agentId,
870
- target_id: fromId,
871
- from_id: fromId,
872
- to_id: agentId,
873
- type: 'text',
874
- content: agentMessage,
875
- file_url: filePath,
876
- file_name: fileName,
877
- is_group: isGroup ? 1 : 0,
878
- status: 'delivered',
879
- });
880
-
881
- // Trigger the onNewMessage callback so the channel.js inbound handler
882
- // picks it up and dispatches it to the AI agent
883
- if (this._onNewMessage) {
884
- this._onNewMessage(msg);
885
- }
886
-
887
- console.log(`[Chat] Agent notification sent: ${msgType} ${fileName} at ${filePath}`);
888
- }
889
-
890
- /**
891
- * Format file size in human-readable format.
892
- */
893
- _formatFileSize(bytes) {
894
- if (bytes === 0) return '0 B';
895
- const units = ['B', 'KB', 'MB', 'GB'];
896
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
897
- return (bytes / Math.pow(1024, i)).toFixed(1) + ' ' + units[i];
898
- }
899
-
900
- /**
901
- * List all files in the userfiles directory.
902
- * @returns {Array} Array of file info objects
903
- */
904
- listUserfiles() {
905
- if (!fs.existsSync(this.userfilesDir)) return [];
906
- return fs.readdirSync(this.userfilesDir)
907
- .filter(name => !name.startsWith('.'))
908
- .map(name => {
909
- const fullPath = path.join(this.userfilesDir, name);
910
- try {
911
- const stat = fs.statSync(fullPath);
912
- return {
913
- name,
914
- path: fullPath,
915
- size: stat.size,
916
- mimeType: this._getMimeType(name),
917
- isImage: this._isImageExt(path.extname(name)),
918
- modifiedAt: stat.mtime.toISOString(),
919
- };
920
- } catch (e) {
921
- return null;
922
- }
923
- })
924
- .filter(Boolean);
925
- }
926
-
927
- /**
928
- * Get the userfiles directory path.
929
- * @returns {string} Absolute path to userfiles directory
930
- */
931
- getUserfilesDir() {
932
- return this.userfilesDir;
933
- }
934
-
935
- // ─── Helpers ────────────────────────────────────────────────────
936
-
937
- _isImageExt(ext) {
938
- return /\.(jpg|jpeg|png|gif|webp|svg|bmp|ico|tiff|tif|avif)$/i.test(ext);
939
- }
940
-
941
- _getMimeType(fileName) {
942
- const ext = path.extname(fileName).toLowerCase();
943
- const mimeTypes = {
944
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
945
- '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
946
- '.bmp': 'image/bmp', '.ico': 'image/x-icon', '.tiff': 'image/tiff',
947
- '.tif': 'image/tiff', '.avif': 'image/avif',
948
- '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json',
949
- '.zip': 'application/zip', '.mp3': 'audio/mpeg', '.wav': 'audio/wav',
950
- '.mp4': 'video/mp4', '.webm': 'video/webm', '.ogg': 'audio/ogg',
951
- '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
952
- '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
953
- '.ppt': 'application/vnd.ms-powerpoint', '.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
954
- };
955
- return mimeTypes[ext] || 'application/octet-stream';
956
- }
957
-
958
- _extFromMime(mimeType) {
959
- if (!mimeType) return '';
960
- const mimeToExt = {
961
- 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif',
962
- 'image/webp': '.webp', 'image/svg+xml': '.svg', 'image/bmp': '.bmp',
963
- 'application/pdf': '.pdf', 'text/plain': '.txt',
964
- 'application/zip': '.zip', 'audio/mpeg': '.mp3',
965
- 'video/mp4': '.mp4', 'audio/wav': '.wav',
966
- };
967
- return mimeToExt[mimeType] || '';
968
- }
969
- }
970
-
971
- 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;