aicq-chat-plugin 3.7.1 → 3.8.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 +43 -1
- package/lib/chat.js +476 -13
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/channel.js +3 -0
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.
|
|
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:
|
|
177
|
-
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
|
-
//
|
|
209
|
-
|
|
210
|
-
|
|
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 =
|
|
619
|
+
const isImage = this._isImageExt(ext);
|
|
620
|
+
const msgType = isImage ? 'image' : 'file';
|
|
273
621
|
|
|
274
|
-
//
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
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;
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"kind": "channel",
|
|
3
3
|
"id": "aicq-chat",
|
|
4
4
|
"name": "AICQ Encrypted Chat",
|
|
5
|
-
"version": "3.
|
|
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.
|
|
3
|
+
"version": "3.8.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
|
@@ -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",
|