aicq-chat-plugin 3.7.1 → 3.8.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/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.7.0",
135
+ version: "3.8.0",
136
136
  architecture: "channel",
137
137
  };
138
138
  case "aicq.friends.list":
@@ -239,6 +239,45 @@ async function handleGatewayMethod(method, kwargs = {}) {
239
239
  case "aicq.groups.silent":
240
240
  _db.setGroupSilentMode(currentAgentId, kwargs.group_id, !!kwargs.silent);
241
241
  return { success: true, silent: !!kwargs.silent };
242
+ case "aicq.chat.sendFile": {
243
+ if (!kwargs.targetId) return { error: "targetId is required" };
244
+ if (!kwargs.filePath && !kwargs.file_path) return { error: "filePath is required" };
245
+ const sendFilePath = kwargs.filePath || kwargs.file_path;
246
+ const sendResult = await _chat.sendFile(currentAgentId, kwargs.targetId, sendFilePath, {
247
+ isGroup: !!kwargs.isGroup,
248
+ caption: kwargs.caption || "",
249
+ });
250
+ return { success: true, file: sendResult };
251
+ }
252
+ case "aicq.chat.sendImage": {
253
+ if (!kwargs.targetId) return { error: "targetId is required" };
254
+ if (!kwargs.filePath && !kwargs.file_path && !kwargs.base64) return { error: "filePath or base64 is required" };
255
+ if (kwargs.base64) {
256
+ const imgResult = await _chat.sendFileFromBase64(
257
+ currentAgentId, kwargs.targetId, kwargs.base64,
258
+ kwargs.fileName || kwargs.file_name || "image.png",
259
+ { isGroup: !!kwargs.isGroup, caption: kwargs.caption || "", mimeType: kwargs.mimeType || "" }
260
+ );
261
+ return { success: true, file: imgResult };
262
+ }
263
+ const imgFilePath = kwargs.filePath || kwargs.file_path;
264
+ const imgResult = await _chat.sendFile(currentAgentId, kwargs.targetId, imgFilePath, {
265
+ isGroup: !!kwargs.isGroup,
266
+ caption: kwargs.caption || "",
267
+ });
268
+ return { success: true, file: imgResult };
269
+ }
270
+ case "aicq.chat.sendFileFromBase64": {
271
+ if (!kwargs.targetId) return { error: "targetId is required" };
272
+ if (!kwargs.base64) return { error: "base64 is required" };
273
+ if (!kwargs.fileName && !kwargs.file_name) return { error: "fileName is required" };
274
+ const b64Result = await _chat.sendFileFromBase64(
275
+ currentAgentId, kwargs.targetId, kwargs.base64,
276
+ kwargs.fileName || kwargs.file_name,
277
+ { isGroup: !!kwargs.isGroup, caption: kwargs.caption || "", mimeType: kwargs.mimeType || "" }
278
+ );
279
+ return { success: true, file: b64Result };
280
+ }
242
281
  case "aicq.sessions.list":
243
282
  return { sessions: [] };
244
283
  default:
@@ -290,6 +329,9 @@ async function registerFull(api) {
290
329
  "aicq.chat.delete",
291
330
  "aicq.chat.streamChunk",
292
331
  "aicq.chat.streamEnd",
332
+ "aicq.chat.sendFile",
333
+ "aicq.chat.sendImage",
334
+ "aicq.chat.sendFileFromBase64",
293
335
  "aicq.groups.list",
294
336
  "aicq.groups.create",
295
337
  "aicq.groups.join",
package/lib/chat.js CHANGED
@@ -1,11 +1,19 @@
1
1
  /**
2
- * AICQ Chat Manager — Send/receive messages, group chat, file handling
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.
3
8
  */
4
9
  const { encryptMessage, decryptMessage } = require('./crypto');
5
10
  const fs = require('fs');
6
11
  const path = require('path');
7
12
  const crypto = require('crypto');
8
13
 
14
+ const FILE_CHUNK_SIZE = 512 * 1024; // 512KB per WS chunk
15
+ const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB limit
16
+
9
17
  class ChatManager {
10
18
  constructor(identityManager, serverClient, db, uploadsDir) {
11
19
  this.identity = identityManager;
@@ -14,6 +22,11 @@ class ChatManager {
14
22
  this.uploadsDir = uploadsDir;
15
23
  this._onNewMessage = null;
16
24
 
25
+ // Ensure uploads directory exists
26
+ if (!fs.existsSync(uploadsDir)) {
27
+ fs.mkdirSync(uploadsDir, { recursive: true });
28
+ }
29
+
17
30
  // Listen for incoming messages via WS
18
31
  this.server.onMessage('relay', (data) => this._handleIncoming(data));
19
32
  this.server.onMessage('message', (data) => this._handleIncoming(data));
@@ -23,6 +36,9 @@ class ChatManager {
23
36
  this.server.onMessage('file_chunk', (data) => this._handleFileChunk(data));
24
37
  this.server.onMessage('stream_chunk', (data) => this._handleStreamChunk(data));
25
38
  this.server.onMessage('stream_end', (data) => this._handleStreamEnd(data));
39
+
40
+ // Incoming file transfer state: fileId -> { meta, chunks }
41
+ this._incomingFiles = new Map();
26
42
  }
27
43
 
28
44
  setOnNewMessage(callback) {
@@ -123,6 +139,206 @@ class ChatManager {
123
139
  return msg;
124
140
  }
125
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
+
126
342
  // ─── Receive Messages ─────────────────────────────────────────────
127
343
 
128
344
  _handleIncoming(data) {
@@ -132,6 +348,27 @@ class ChatManager {
132
348
  const fromId = data.fromId || data.from;
133
349
  let content = data.payload || data.data || '';
134
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
+
135
372
  // Try to decrypt if we have a session key
136
373
  const session = this.db.loadSession(agentId, fromId);
137
374
  if (session && session.session_key && typeof content === 'string') {
@@ -160,9 +397,38 @@ class ChatManager {
160
397
  const agentId = this.server.currentAgentId;
161
398
  if (!agentId) return;
162
399
 
163
- const fromId = data.fromId;
400
+ const fromId = data.fromId || data.from;
164
401
  const groupId = data.groupId;
165
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
+
166
432
  // Check silent mode
167
433
  const silent = this.db.getGroupSilentMode(agentId, groupId);
168
434
  const mentions = data.mentions || [];
@@ -173,8 +439,8 @@ class ChatManager {
173
439
  target_id: groupId,
174
440
  from_id: fromId,
175
441
  to_id: groupId,
176
- type: data.msgType || 'text',
177
- content: data.content || '',
442
+ type: msgType,
443
+ content,
178
444
  is_group: 1,
179
445
  mentions,
180
446
  status: (silent && !isMentioned) ? 'silent' : 'delivered',
@@ -205,9 +471,90 @@ class ChatManager {
205
471
  }
206
472
 
207
473
  _handleFileChunk(data) {
208
- // File chunk handling — assemble in uploads dir
209
- // For now, just log
210
- console.log('[Chat] File chunk from', data.from);
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
+ }
211
558
  }
212
559
 
213
560
  _handleStreamChunk(data) {
@@ -260,7 +607,7 @@ class ChatManager {
260
607
  this.db.deleteMessage(agentId, messageId);
261
608
  }
262
609
 
263
- // ─── File Upload ──────────────────────────────────────────────────
610
+ // ─── File Upload (from HTTP) ────────────────────────────────────
264
611
 
265
612
  async handleFileUpload(agentId, targetId, file, isGroup = false) {
266
613
  const fileId = crypto.randomUUID();
@@ -269,18 +616,134 @@ class ChatManager {
269
616
  const filePath = path.join(this.uploadsDir, fileName);
270
617
  fs.writeFileSync(filePath, file.buffer);
271
618
 
272
- const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(ext);
619
+ const isImage = this._isImageExt(ext);
620
+ const msgType = isImage ? 'image' : 'file';
273
621
 
274
- // Send message with file reference
275
- const msg = await this.sendMessage(agentId, targetId, isImage ? '[图片]' : `[文件] ${file.originalname}`, {
276
- type: isImage ? 'image' : 'file',
277
- isGroup,
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
+ },
278
637
  file_url: `/api/files/${fileName}`,
279
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',
280
707
  });
281
708
 
709
+ if (this._onNewMessage) this._onNewMessage(msg);
282
710
  return msg;
283
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
+ }
284
747
  }
285
748
 
286
749
  module.exports = ChatManager;
@@ -2,7 +2,7 @@
2
2
  "kind": "channel",
3
3
  "id": "aicq-chat",
4
4
  "name": "AICQ Encrypted Chat",
5
- "version": "3.7.1",
5
+ "version": "3.8.1",
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.7.1",
3
+ "version": "3.8.1",
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
@@ -183,6 +183,9 @@ const _plugin = createChatChannelPlugin({
183
183
  "aicq.chat.delete",
184
184
  "aicq.chat.streamChunk",
185
185
  "aicq.chat.streamEnd",
186
+ "aicq.chat.sendFile",
187
+ "aicq.chat.sendImage",
188
+ "aicq.chat.sendFileFromBase64",
186
189
  "aicq.groups.list",
187
190
  "aicq.groups.create",
188
191
  "aicq.groups.join",