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.
@@ -1,266 +1,266 @@
1
- /**
2
- * AICQ File Transfer
3
- * ====================
4
- * Handles chunked file upload/download via WebSocket.
5
- * Files are encrypted with session keys before transmission.
6
- */
7
-
8
- const fs = require('fs');
9
- const path = require('path');
10
- const crypto = require('./crypto');
11
- const { v4: uuidv4 } = require('uuid');
12
- const { isoNow } = require('./database');
13
-
14
- const CHUNK_SIZE = 65536; // 64KB chunks
15
- const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
16
-
17
- class FileTransferManager {
18
- constructor(db, identity, serverClient) {
19
- this.db = db;
20
- this.identity = identity;
21
- this.serverClient = serverClient;
22
-
23
- // In-progress transfers: fileId -> { chunks, meta }
24
- this._incoming = new Map();
25
-
26
- // Ensure uploads directory exists
27
- if (!fs.existsSync(UPLOADS_DIR)) {
28
- fs.mkdirSync(UPLOADS_DIR, { recursive: true });
29
- }
30
- }
31
-
32
- /**
33
- * Upload a file to a friend.
34
- * Reads the file, encrypts chunks, and sends via WebSocket.
35
- */
36
- uploadFile(targetId, filePath, { isGroup = false } = {}) {
37
- const agentId = this.identity.currentAgentId;
38
- if (!agentId) throw new Error('No agent selected');
39
-
40
- const identity = this.identity.getCurrent();
41
- if (!identity) throw new Error('No identity');
42
-
43
- const fileId = uuidv4();
44
- const fileName = path.basename(filePath);
45
- const fileBuffer = fs.readFileSync(filePath);
46
- const fileSize = fileBuffer.length;
47
- const totalChunks = Math.ceil(fileSize / CHUNK_SIZE);
48
-
49
- // Get session key for encryption
50
- let sessionKey = null;
51
- if (!isGroup) {
52
- const session = this.db.loadSession(agentId, targetId);
53
- if (session) {
54
- sessionKey = session.session_key;
55
- } else {
56
- const friend = this.db.getFriend(agentId, targetId);
57
- if (friend && friend.public_key) {
58
- sessionKey = crypto.sha256Hex(crypto.computeSharedSecret(identity.exchangeSecretKey, friend.public_key));
59
- this.db.saveSession(agentId, targetId, sessionKey);
60
- }
61
- }
62
- }
63
-
64
- // Save file locally
65
- const localPath = path.join(UPLOADS_DIR, fileId);
66
- fs.writeFileSync(localPath, fileBuffer);
67
-
68
- // Send file info message first
69
- const fileInfo = {
70
- fileId,
71
- fileName,
72
- fileSize,
73
- totalChunks,
74
- mimeType: this._getMimeType(fileName),
75
- };
76
-
77
- // Save file-info message to chat history
78
- this.db.saveMessage({
79
- agentId,
80
- targetId,
81
- fromId: identity.agentId,
82
- toId: targetId,
83
- type: 'file',
84
- content: JSON.stringify(fileInfo),
85
- status: 'sent',
86
- isGroup,
87
- });
88
-
89
- // Send file info via WS
90
- if (!isGroup) {
91
- this.serverClient.sendWs({
92
- type: 'message',
93
- to: targetId,
94
- data: JSON.stringify({ type: 'file-info', ...fileInfo }),
95
- });
96
- } else {
97
- this.serverClient.sendWs({
98
- type: 'group_message',
99
- groupId: targetId,
100
- content: JSON.stringify({ type: 'file-info', ...fileInfo }),
101
- msgType: 'file',
102
- });
103
- }
104
-
105
- // Send chunks
106
- for (let i = 0; i < totalChunks; i++) {
107
- const start = i * CHUNK_SIZE;
108
- const end = Math.min(start + CHUNK_SIZE, fileSize);
109
- let chunkData = fileBuffer.slice(start, end);
110
-
111
- const chunk = {
112
- fileId,
113
- index: i,
114
- total: totalChunks,
115
- data: chunkData.toString('base64'),
116
- };
117
-
118
- if (sessionKey && !isGroup) {
119
- // Encrypt chunk
120
- const encrypted = crypto.encryptFileChunk(sessionKey, chunkData, i);
121
- chunk.data = encrypted.ciphertext;
122
- chunk.nonce = encrypted.nonce;
123
- chunk.encrypted = true;
124
- }
125
-
126
- if (!isGroup) {
127
- this.serverClient.sendWs({
128
- type: 'file_chunk',
129
- to: targetId,
130
- data: chunk,
131
- });
132
- } else {
133
- this.serverClient.sendWs({
134
- type: 'group_message',
135
- groupId: targetId,
136
- content: JSON.stringify(chunk),
137
- msgType: 'file_chunk',
138
- });
139
- }
140
- }
141
-
142
- return { fileId, fileName, fileSize, totalChunks };
143
- }
144
-
145
- /**
146
- * Handle incoming file chunk.
147
- */
148
- handleFileChunk(data) {
149
- const chunkData = data.data || data;
150
- const fileId = chunkData.fileId;
151
-
152
- if (!fileId) return;
153
-
154
- // Initialize incoming transfer if needed
155
- if (!this._incoming.has(fileId)) {
156
- this._incoming.set(fileId, {
157
- chunks: new Map(),
158
- meta: null,
159
- });
160
- }
161
-
162
- const transfer = this._incoming.get(fileId);
163
-
164
- // If this is a file-info message
165
- if (chunkData.type === 'file-info') {
166
- transfer.meta = chunkData;
167
- return;
168
- }
169
-
170
- // Store the chunk
171
- transfer.chunks.set(chunkData.index, chunkData);
172
-
173
- // Check if all chunks received
174
- if (transfer.meta && transfer.chunks.size >= transfer.meta.totalChunks) {
175
- this._assembleFile(fileId, transfer);
176
- }
177
- }
178
-
179
- /**
180
- * Assemble received chunks into a complete file.
181
- */
182
- _assembleFile(fileId, transfer) {
183
- const { meta, chunks } = transfer;
184
-
185
- try {
186
- const sortedChunks = Array.from(chunks.entries())
187
- .sort((a, b) => a[0] - b[0]);
188
-
189
- const buffers = [];
190
- for (const [index, chunk] of sortedChunks) {
191
- if (chunk.encrypted) {
192
- // Decrypt chunk
193
- const agentId = this.identity.currentAgentId;
194
- const session = this.db.loadSession(agentId, transfer.fromId);
195
- if (session) {
196
- const decrypted = crypto.decryptFileChunk(
197
- session.session_key, chunk.nonce, chunk.data, index
198
- );
199
- buffers.push(decrypted);
200
- }
201
- } else {
202
- buffers.push(Buffer.from(chunk.data, 'base64'));
203
- }
204
- }
205
-
206
- const fileBuffer = Buffer.concat(buffers);
207
-
208
- // Save to uploads directory
209
- const localPath = path.join(UPLOADS_DIR, fileId);
210
- fs.writeFileSync(localPath, fileBuffer);
211
-
212
- // Save message to chat history
213
- const agentId = this.identity.currentAgentId;
214
- if (agentId) {
215
- this.db.saveMessage({
216
- agentId,
217
- targetId: meta.fromId || '',
218
- fromId: meta.fromId || '',
219
- toId: agentId,
220
- type: 'file',
221
- content: JSON.stringify({
222
- fileId,
223
- fileName: meta.fileName,
224
- fileSize: meta.fileSize,
225
- localPath,
226
- }),
227
- status: 'delivered',
228
- isGroup: false,
229
- });
230
- }
231
-
232
- console.log(`[FileTransfer] Assembled file ${meta.fileName} (${meta.fileSize} bytes)`);
233
- } catch (e) {
234
- console.error(`[FileTransfer] Assembly failed for ${fileId}:`, e.message);
235
- } finally {
236
- this._incoming.delete(fileId);
237
- }
238
- }
239
-
240
- /**
241
- * Serve an uploaded file.
242
- */
243
- serveFile(fileId) {
244
- const filePath = path.join(UPLOADS_DIR, fileId);
245
- if (fs.existsSync(filePath)) {
246
- return fs.readFileSync(filePath);
247
- }
248
- return null;
249
- }
250
-
251
- /**
252
- * Get MIME type from filename.
253
- */
254
- _getMimeType(fileName) {
255
- const ext = path.extname(fileName).toLowerCase();
256
- const mimeTypes = {
257
- '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
258
- '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
259
- '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json',
260
- '.zip': 'application/zip', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4',
261
- };
262
- return mimeTypes[ext] || 'application/octet-stream';
263
- }
264
- }
265
-
266
- module.exports = FileTransferManager;
1
+ /**
2
+ * AICQ File Transfer
3
+ * ====================
4
+ * Handles chunked file upload/download via WebSocket.
5
+ * Files are encrypted with session keys before transmission.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const crypto = require('./crypto');
11
+ const { v4: uuidv4 } = require('uuid');
12
+ const { isoNow } = require('./database');
13
+
14
+ const CHUNK_SIZE = 65536; // 64KB chunks
15
+ const UPLOADS_DIR = path.join(__dirname, '..', 'uploads');
16
+
17
+ class FileTransferManager {
18
+ constructor(db, identity, serverClient) {
19
+ this.db = db;
20
+ this.identity = identity;
21
+ this.serverClient = serverClient;
22
+
23
+ // In-progress transfers: fileId -> { chunks, meta }
24
+ this._incoming = new Map();
25
+
26
+ // Ensure uploads directory exists
27
+ if (!fs.existsSync(UPLOADS_DIR)) {
28
+ fs.mkdirSync(UPLOADS_DIR, { recursive: true });
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Upload a file to a friend.
34
+ * Reads the file, encrypts chunks, and sends via WebSocket.
35
+ */
36
+ uploadFile(targetId, filePath, { isGroup = false } = {}) {
37
+ const agentId = this.identity.currentAgentId;
38
+ if (!agentId) throw new Error('No agent selected');
39
+
40
+ const identity = this.identity.getCurrent();
41
+ if (!identity) throw new Error('No identity');
42
+
43
+ const fileId = uuidv4();
44
+ const fileName = path.basename(filePath);
45
+ const fileBuffer = fs.readFileSync(filePath);
46
+ const fileSize = fileBuffer.length;
47
+ const totalChunks = Math.ceil(fileSize / CHUNK_SIZE);
48
+
49
+ // Get session key for encryption
50
+ let sessionKey = null;
51
+ if (!isGroup) {
52
+ const session = this.db.loadSession(agentId, targetId);
53
+ if (session) {
54
+ sessionKey = session.session_key;
55
+ } else {
56
+ const friend = this.db.getFriend(agentId, targetId);
57
+ if (friend && friend.public_key) {
58
+ sessionKey = crypto.sha256Hex(crypto.computeSharedSecret(identity.exchangeSecretKey, friend.public_key));
59
+ this.db.saveSession(agentId, targetId, sessionKey);
60
+ }
61
+ }
62
+ }
63
+
64
+ // Save file locally
65
+ const localPath = path.join(UPLOADS_DIR, fileId);
66
+ fs.writeFileSync(localPath, fileBuffer);
67
+
68
+ // Send file info message first
69
+ const fileInfo = {
70
+ fileId,
71
+ fileName,
72
+ fileSize,
73
+ totalChunks,
74
+ mimeType: this._getMimeType(fileName),
75
+ };
76
+
77
+ // Save file-info message to chat history
78
+ this.db.saveMessage({
79
+ agentId,
80
+ targetId,
81
+ fromId: identity.agentId,
82
+ toId: targetId,
83
+ type: 'file',
84
+ content: JSON.stringify(fileInfo),
85
+ status: 'sent',
86
+ isGroup,
87
+ });
88
+
89
+ // Send file info via WS
90
+ if (!isGroup) {
91
+ this.serverClient.sendWs({
92
+ type: 'message',
93
+ to: targetId,
94
+ data: JSON.stringify({ type: 'file-info', ...fileInfo }),
95
+ });
96
+ } else {
97
+ this.serverClient.sendWs({
98
+ type: 'group_message',
99
+ groupId: targetId,
100
+ content: JSON.stringify({ type: 'file-info', ...fileInfo }),
101
+ msgType: 'file',
102
+ });
103
+ }
104
+
105
+ // Send chunks
106
+ for (let i = 0; i < totalChunks; i++) {
107
+ const start = i * CHUNK_SIZE;
108
+ const end = Math.min(start + CHUNK_SIZE, fileSize);
109
+ let chunkData = fileBuffer.slice(start, end);
110
+
111
+ const chunk = {
112
+ fileId,
113
+ index: i,
114
+ total: totalChunks,
115
+ data: chunkData.toString('base64'),
116
+ };
117
+
118
+ if (sessionKey && !isGroup) {
119
+ // Encrypt chunk
120
+ const encrypted = crypto.encryptFileChunk(sessionKey, chunkData, i);
121
+ chunk.data = encrypted.ciphertext;
122
+ chunk.nonce = encrypted.nonce;
123
+ chunk.encrypted = true;
124
+ }
125
+
126
+ if (!isGroup) {
127
+ this.serverClient.sendWs({
128
+ type: 'file_chunk',
129
+ to: targetId,
130
+ data: chunk,
131
+ });
132
+ } else {
133
+ this.serverClient.sendWs({
134
+ type: 'group_message',
135
+ groupId: targetId,
136
+ content: JSON.stringify(chunk),
137
+ msgType: 'file_chunk',
138
+ });
139
+ }
140
+ }
141
+
142
+ return { fileId, fileName, fileSize, totalChunks };
143
+ }
144
+
145
+ /**
146
+ * Handle incoming file chunk.
147
+ */
148
+ handleFileChunk(data) {
149
+ const chunkData = data.data || data;
150
+ const fileId = chunkData.fileId;
151
+
152
+ if (!fileId) return;
153
+
154
+ // Initialize incoming transfer if needed
155
+ if (!this._incoming.has(fileId)) {
156
+ this._incoming.set(fileId, {
157
+ chunks: new Map(),
158
+ meta: null,
159
+ });
160
+ }
161
+
162
+ const transfer = this._incoming.get(fileId);
163
+
164
+ // If this is a file-info message
165
+ if (chunkData.type === 'file-info') {
166
+ transfer.meta = chunkData;
167
+ return;
168
+ }
169
+
170
+ // Store the chunk
171
+ transfer.chunks.set(chunkData.index, chunkData);
172
+
173
+ // Check if all chunks received
174
+ if (transfer.meta && transfer.chunks.size >= transfer.meta.totalChunks) {
175
+ this._assembleFile(fileId, transfer);
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Assemble received chunks into a complete file.
181
+ */
182
+ _assembleFile(fileId, transfer) {
183
+ const { meta, chunks } = transfer;
184
+
185
+ try {
186
+ const sortedChunks = Array.from(chunks.entries())
187
+ .sort((a, b) => a[0] - b[0]);
188
+
189
+ const buffers = [];
190
+ for (const [index, chunk] of sortedChunks) {
191
+ if (chunk.encrypted) {
192
+ // Decrypt chunk
193
+ const agentId = this.identity.currentAgentId;
194
+ const session = this.db.loadSession(agentId, transfer.fromId);
195
+ if (session) {
196
+ const decrypted = crypto.decryptFileChunk(
197
+ session.session_key, chunk.nonce, chunk.data, index
198
+ );
199
+ buffers.push(decrypted);
200
+ }
201
+ } else {
202
+ buffers.push(Buffer.from(chunk.data, 'base64'));
203
+ }
204
+ }
205
+
206
+ const fileBuffer = Buffer.concat(buffers);
207
+
208
+ // Save to uploads directory
209
+ const localPath = path.join(UPLOADS_DIR, fileId);
210
+ fs.writeFileSync(localPath, fileBuffer);
211
+
212
+ // Save message to chat history
213
+ const agentId = this.identity.currentAgentId;
214
+ if (agentId) {
215
+ this.db.saveMessage({
216
+ agentId,
217
+ targetId: meta.fromId || '',
218
+ fromId: meta.fromId || '',
219
+ toId: agentId,
220
+ type: 'file',
221
+ content: JSON.stringify({
222
+ fileId,
223
+ fileName: meta.fileName,
224
+ fileSize: meta.fileSize,
225
+ localPath,
226
+ }),
227
+ status: 'delivered',
228
+ isGroup: false,
229
+ });
230
+ }
231
+
232
+ console.log(`[FileTransfer] Assembled file ${meta.fileName} (${meta.fileSize} bytes)`);
233
+ } catch (e) {
234
+ console.error(`[FileTransfer] Assembly failed for ${fileId}:`, e.message);
235
+ } finally {
236
+ this._incoming.delete(fileId);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * Serve an uploaded file.
242
+ */
243
+ serveFile(fileId) {
244
+ const filePath = path.join(UPLOADS_DIR, fileId);
245
+ if (fs.existsSync(filePath)) {
246
+ return fs.readFileSync(filePath);
247
+ }
248
+ return null;
249
+ }
250
+
251
+ /**
252
+ * Get MIME type from filename.
253
+ */
254
+ _getMimeType(fileName) {
255
+ const ext = path.extname(fileName).toLowerCase();
256
+ const mimeTypes = {
257
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg',
258
+ '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml',
259
+ '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json',
260
+ '.zip': 'application/zip', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4',
261
+ };
262
+ return mimeTypes[ext] || 'application/octet-stream';
263
+ }
264
+ }
265
+
266
+ module.exports = FileTransferManager;