aicq-chat-plugin 3.8.1 → 3.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -132,7 +132,7 @@ async function handleGatewayMethod(method, kwargs = {}) {
132
132
  return {
133
133
  state: _serverClient.connected ? "connected" : "disconnected",
134
134
  agent_id: currentAgentId,
135
- version: "3.8.0",
135
+ version: "3.9.0",
136
136
  architecture: "channel",
137
137
  };
138
138
  case "aicq.friends.list":
@@ -278,6 +278,14 @@ async function handleGatewayMethod(method, kwargs = {}) {
278
278
  );
279
279
  return { success: true, file: b64Result };
280
280
  }
281
+ case "aicq.userfiles.list": {
282
+ if (!_chat) return { error: "Chat not initialized" };
283
+ return { files: _chat.listUserfiles(), directory: _chat.getUserfilesDir() };
284
+ }
285
+ case "aicq.userfiles.getPath": {
286
+ if (!_chat) return { error: "Chat not initialized" };
287
+ return { directory: _chat.getUserfilesDir() };
288
+ }
281
289
  case "aicq.sessions.list":
282
290
  return { sessions: [] };
283
291
  default:
@@ -332,6 +340,8 @@ async function registerFull(api) {
332
340
  "aicq.chat.sendFile",
333
341
  "aicq.chat.sendImage",
334
342
  "aicq.chat.sendFileFromBase64",
343
+ "aicq.userfiles.list",
344
+ "aicq.userfiles.getPath",
335
345
  "aicq.groups.list",
336
346
  "aicq.groups.create",
337
347
  "aicq.groups.join",
package/lib/chat.js CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * AICQ Chat Manager — Send/receive messages, group chat, file/image handling
3
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.
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.
8
9
  */
9
10
  const { encryptMessage, decryptMessage } = require('./crypto');
10
11
  const fs = require('fs');
@@ -22,10 +23,16 @@ class ChatManager {
22
23
  this.uploadsDir = uploadsDir;
23
24
  this._onNewMessage = null;
24
25
 
25
- // Ensure uploads directory exists
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
26
30
  if (!fs.existsSync(uploadsDir)) {
27
31
  fs.mkdirSync(uploadsDir, { recursive: true });
28
32
  }
33
+ if (!fs.existsSync(this.userfilesDir)) {
34
+ fs.mkdirSync(this.userfilesDir, { recursive: true });
35
+ }
29
36
 
30
37
  // Listen for incoming messages via WS
31
38
  this.server.onMessage('relay', (data) => this._handleIncoming(data));
@@ -350,22 +357,10 @@ class ChatManager {
350
357
 
351
358
  // Check if this is a file/image message in the new format
352
359
  if (typeof data === 'object' && data.data && typeof data.data === 'object' && data.data.file_info) {
353
- // This is a structured message with file info
360
+ // This is a structured message with file info — save to userfiles and notify agent
354
361
  const fileInfo = data.data.file_info;
355
362
  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);
363
+ this._saveToUserfilesAndNotify(agentId, fromId, fileInfo, data.data, { isGroup: false });
369
364
  return;
370
365
  }
371
366
 
@@ -405,26 +400,14 @@ class ChatManager {
405
400
  const msgType = data.msgType || data.msg_type || 'text';
406
401
 
407
402
  if (msgType === 'file' || msgType === 'image') {
408
- // File/image in group message
403
+ // File/image in group message — save to userfiles and notify agent
409
404
  let fileInfo = {};
410
405
  try {
411
406
  fileInfo = typeof content === 'string' ? JSON.parse(content) : content;
412
407
  } catch (e) {}
413
408
 
414
409
  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);
410
+ this._saveToUserfilesAndNotify(agentId, groupId, fileInfo.file_info, fileInfo, { isGroup: true, fromId });
428
411
  return;
429
412
  }
430
413
  }
@@ -532,8 +515,18 @@ class ChatManager {
532
515
  const isImage = meta.isImage || this._isImageExt(ext);
533
516
  const msgType = isImage ? 'image' : 'file';
534
517
 
535
- // Save message to chat history
518
+ // Save the assembled file to userfiles/ and notify the agent
536
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
537
530
  const msg = this.db.saveMessage({
538
531
  agent_id: agentId,
539
532
  target_id: fromId || '',
@@ -546,14 +539,45 @@ class ChatManager {
546
539
  is_group: 0,
547
540
  status: 'delivered',
548
541
  });
549
- if (this._onNewMessage) this._onNewMessage(msg);
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
+ }
550
548
  }
551
549
 
552
- console.log(`[Chat] File assembled: ${meta.fileName} (${fileBuffer.length} bytes)`);
550
+ console.log(`[Chat] File assembled and saved to userfiles: ${meta.fileName} (${fileBuffer.length} bytes)`);
553
551
  } catch (e) {
554
552
  console.error(`[Chat] File assembly failed for ${fileId}:`, e.message);
555
553
  } finally {
556
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
+ }
557
581
  }
558
582
  }
559
583
 
@@ -710,6 +734,204 @@ class ChatManager {
710
734
  return msg;
711
735
  }
712
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
+
713
935
  // ─── Helpers ────────────────────────────────────────────────────
714
936
 
715
937
  _isImageExt(ext) {
@@ -2,7 +2,7 @@
2
2
  "kind": "channel",
3
3
  "id": "aicq-chat",
4
4
  "name": "AICQ Encrypted Chat",
5
- "version": "3.8.1",
5
+ "version": "3.8.0",
6
6
  "description": "End-to-end encrypted chat channel via AICQ protocol — in-process Channel plugin using OpenClaw Channel SDK",
7
7
  "entry": "index.js",
8
8
  "activation": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aicq-chat-plugin",
3
- "version": "3.8.1",
3
+ "version": "3.9.0",
4
4
  "description": "AICQ End-to-end Encrypted Chat Channel Plugin for OpenClaw — In-process Channel SDK architecture with friend management, group chat, file transfer, and AI agent communication",
5
5
  "type": "module",
6
6
  "main": "index.js",
package/src/channel.js CHANGED
@@ -186,6 +186,8 @@ const _plugin = createChatChannelPlugin({
186
186
  "aicq.chat.sendFile",
187
187
  "aicq.chat.sendImage",
188
188
  "aicq.chat.sendFileFromBase64",
189
+ "aicq.userfiles.list",
190
+ "aicq.userfiles.getPath",
189
191
  "aicq.groups.list",
190
192
  "aicq.groups.create",
191
193
  "aicq.groups.join",
@@ -362,14 +364,36 @@ _plugin.gateway = {
362
364
  const data = msg.data || msg;
363
365
  const fromId = data.from || data.fromId || data.sender_id || msg.from || msg.fromId;
364
366
  const isGroup = !!(data.isGroup || data.groupId || msg.isGroup || msg.groupId);
365
- const text = data.content || data.text || data.payload || msg.content || msg.text || msg.payload || "";
366
367
 
367
- console.log(`[AICQ Channel] Inbound message from=${fromId} isGroup=${isGroup} text=${(text || "").substring(0, 80)}`);
368
+ // Detect file/image messages
369
+ const msgType = data.msgType || data.msg_type || (data.data && data.data.file_info ? (data.data.file_info.isImage ? 'image' : 'file') : 'text');
370
+ const isFileMessage = msgType === 'file' || msgType === 'image';
368
371
 
369
- if (!fromId || !text) {
372
+ // For file/image messages, chat.js already handles saving to userfiles/
373
+ // and creating a notification message via _notifyAgentAboutFile().
374
+ // That notification triggers _onNewMessage which calls this inboundHandler
375
+ // again with the text message. So we just need to handle text here.
376
+ let text = data.content || data.text || data.payload || msg.content || msg.text || msg.payload || "";
377
+
378
+ console.log(`[AICQ Channel] Inbound message from=${fromId} isGroup=${isGroup} type=${msgType} text=${(text || "").substring(0, 80)}`);
379
+
380
+ if (!fromId) {
370
381
  return; // Skip system messages (online_ack, presence, etc.)
371
382
  }
372
383
 
384
+ // For file/image messages from the WS, chat.js _handleIncoming/_handleGroupIncoming
385
+ // will handle the userfiles saving and create a notification text message.
386
+ // The notification will trigger _onNewMessage which calls this handler again
387
+ // as a text message. So we skip dispatching file messages directly to the agent.
388
+ if (isFileMessage && !(data.file_url && data.type === 'text')) {
389
+ console.log(`[AICQ Channel] File/image message received, chat.js will handle userfiles saving and notify agent`);
390
+ return;
391
+ }
392
+
393
+ if (!text) {
394
+ return; // Skip empty messages
395
+ }
396
+
373
397
  // Skip our own messages
374
398
  if (fromId === runtime.serverClient?.serverAccountId || fromId === agentId) {
375
399
  return;