aicq-chat-plugin 2.1.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/README.md +74 -0
- package/bin/aicq-plugin.js +118 -0
- package/bin/postinstall.js +27 -0
- package/index.js +499 -0
- package/lib/chat.js +244 -0
- package/lib/crypto.js +156 -0
- package/lib/database.js +319 -0
- package/lib/file-transfer.js +266 -0
- package/lib/handshake.js +147 -0
- package/lib/identity.js +154 -0
- package/lib/server-client.js +322 -0
- package/openclaw.plugin.json +45 -0
- package/package.json +58 -0
- package/public/index.html +921 -0
package/lib/database.js
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AICQ Plugin Database — SQLite via better-sqlite3
|
|
3
|
+
*/
|
|
4
|
+
const Database = require('better-sqlite3');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
|
|
9
|
+
class PluginDatabase {
|
|
10
|
+
constructor(dataDir) {
|
|
11
|
+
this.dataDir = dataDir;
|
|
12
|
+
fs.mkdirSync(dataDir, { recursive: true });
|
|
13
|
+
const dbPath = path.join(dataDir, 'aicq-plugin.db');
|
|
14
|
+
this.db = new Database(dbPath);
|
|
15
|
+
this.db.pragma('journal_mode = WAL');
|
|
16
|
+
this.db.pragma('synchronous = NORMAL');
|
|
17
|
+
this.db.pragma('foreign_keys = ON');
|
|
18
|
+
this.db.pragma('busy_timeout = 5000');
|
|
19
|
+
this._initSchema();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
_initSchema() {
|
|
23
|
+
this.db.exec(`
|
|
24
|
+
CREATE TABLE IF NOT EXISTS identity (
|
|
25
|
+
agent_id TEXT PRIMARY KEY,
|
|
26
|
+
nickname TEXT NOT NULL DEFAULT '',
|
|
27
|
+
signing_public_key TEXT NOT NULL,
|
|
28
|
+
signing_secret_key TEXT NOT NULL,
|
|
29
|
+
exchange_public_key TEXT NOT NULL,
|
|
30
|
+
exchange_secret_key TEXT NOT NULL,
|
|
31
|
+
created_at TEXT NOT NULL,
|
|
32
|
+
updated_at TEXT
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
CREATE TABLE IF NOT EXISTS friends (
|
|
36
|
+
id TEXT PRIMARY KEY,
|
|
37
|
+
agent_id TEXT NOT NULL,
|
|
38
|
+
public_key TEXT NOT NULL,
|
|
39
|
+
fingerprint TEXT NOT NULL,
|
|
40
|
+
added_at TEXT NOT NULL,
|
|
41
|
+
last_seen TEXT,
|
|
42
|
+
is_online INTEGER NOT NULL DEFAULT 0,
|
|
43
|
+
permissions TEXT NOT NULL DEFAULT '["chat"]',
|
|
44
|
+
friend_type TEXT NOT NULL DEFAULT 'ai',
|
|
45
|
+
ai_name TEXT NOT NULL DEFAULT '',
|
|
46
|
+
ai_avatar TEXT NOT NULL DEFAULT ''
|
|
47
|
+
);
|
|
48
|
+
CREATE INDEX IF NOT EXISTS idx_friends_agent ON friends(agent_id);
|
|
49
|
+
|
|
50
|
+
CREATE TABLE IF NOT EXISTS groups (
|
|
51
|
+
id TEXT PRIMARY KEY,
|
|
52
|
+
agent_id TEXT NOT NULL,
|
|
53
|
+
name TEXT NOT NULL,
|
|
54
|
+
owner_id TEXT NOT NULL,
|
|
55
|
+
members_json TEXT NOT NULL DEFAULT '[]',
|
|
56
|
+
description TEXT,
|
|
57
|
+
silent_mode INTEGER NOT NULL DEFAULT 0,
|
|
58
|
+
created_at TEXT NOT NULL,
|
|
59
|
+
updated_at TEXT
|
|
60
|
+
);
|
|
61
|
+
CREATE INDEX IF NOT EXISTS idx_groups_agent ON groups(agent_id);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS sessions (
|
|
64
|
+
peer_id TEXT PRIMARY KEY,
|
|
65
|
+
agent_id TEXT NOT NULL,
|
|
66
|
+
session_key TEXT NOT NULL,
|
|
67
|
+
created_at TEXT NOT NULL,
|
|
68
|
+
message_count INTEGER NOT NULL DEFAULT 0,
|
|
69
|
+
last_rotation TEXT NOT NULL
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
CREATE TABLE IF NOT EXISTS chat_history (
|
|
73
|
+
id TEXT PRIMARY KEY,
|
|
74
|
+
agent_id TEXT NOT NULL,
|
|
75
|
+
target_id TEXT NOT NULL,
|
|
76
|
+
from_id TEXT NOT NULL,
|
|
77
|
+
to_id TEXT NOT NULL,
|
|
78
|
+
type TEXT NOT NULL DEFAULT 'text',
|
|
79
|
+
content TEXT NOT NULL DEFAULT '',
|
|
80
|
+
file_url TEXT,
|
|
81
|
+
file_name TEXT,
|
|
82
|
+
is_group INTEGER NOT NULL DEFAULT 0,
|
|
83
|
+
mentions TEXT NOT NULL DEFAULT '[]',
|
|
84
|
+
timestamp TEXT NOT NULL,
|
|
85
|
+
status TEXT NOT NULL DEFAULT 'pending'
|
|
86
|
+
);
|
|
87
|
+
CREATE INDEX IF NOT EXISTS idx_chat_agent_target ON chat_history(agent_id, target_id);
|
|
88
|
+
CREATE INDEX IF NOT EXISTS idx_chat_timestamp ON chat_history(agent_id, target_id, timestamp);
|
|
89
|
+
|
|
90
|
+
CREATE TABLE IF NOT EXISTS pending_requests (
|
|
91
|
+
session_id TEXT PRIMARY KEY,
|
|
92
|
+
agent_id TEXT NOT NULL,
|
|
93
|
+
requester_id TEXT NOT NULL,
|
|
94
|
+
requester_public_key TEXT NOT NULL,
|
|
95
|
+
timestamp TEXT NOT NULL
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
CREATE TABLE IF NOT EXISTS temp_numbers (
|
|
99
|
+
number TEXT PRIMARY KEY,
|
|
100
|
+
agent_id TEXT NOT NULL,
|
|
101
|
+
expires_at TEXT NOT NULL,
|
|
102
|
+
created_at TEXT NOT NULL
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
CREATE TABLE IF NOT EXISTS offline_queue (
|
|
106
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
107
|
+
agent_id TEXT NOT NULL,
|
|
108
|
+
target_id TEXT NOT NULL,
|
|
109
|
+
data TEXT NOT NULL,
|
|
110
|
+
created_at TEXT NOT NULL
|
|
111
|
+
);
|
|
112
|
+
CREATE INDEX IF NOT EXISTS idx_offline_target ON offline_queue(agent_id, target_id);
|
|
113
|
+
|
|
114
|
+
CREATE TABLE IF NOT EXISTS group_settings (
|
|
115
|
+
group_id TEXT PRIMARY KEY,
|
|
116
|
+
agent_id TEXT NOT NULL,
|
|
117
|
+
silent_mode INTEGER NOT NULL DEFAULT 0
|
|
118
|
+
);
|
|
119
|
+
`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Identity ──────────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
saveIdentity({ agent_id, nickname, signing_public_key, signing_secret_key, exchange_public_key, exchange_secret_key }) {
|
|
125
|
+
const now = new Date().toISOString();
|
|
126
|
+
this.db.prepare(`
|
|
127
|
+
INSERT OR REPLACE INTO identity (agent_id, nickname, signing_public_key, signing_secret_key, exchange_public_key, exchange_secret_key, created_at, updated_at)
|
|
128
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
129
|
+
`).run(agent_id, nickname || '', signing_public_key, signing_secret_key, exchange_public_key, exchange_secret_key, now, now);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
loadIdentity(agentId) {
|
|
133
|
+
return this.db.prepare('SELECT * FROM identity WHERE agent_id = ?').get(agentId);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
listIdentities() {
|
|
137
|
+
return this.db.prepare('SELECT agent_id, nickname, signing_public_key, exchange_public_key, created_at FROM identity').all();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
deleteIdentity(agentId) {
|
|
141
|
+
this.db.prepare('DELETE FROM identity WHERE agent_id = ?').run(agentId);
|
|
142
|
+
this.db.prepare('DELETE FROM friends WHERE agent_id = ?').run(agentId);
|
|
143
|
+
this.db.prepare('DELETE FROM groups WHERE agent_id = ?').run(agentId);
|
|
144
|
+
this.db.prepare('DELETE FROM chat_history WHERE agent_id = ?').run(agentId);
|
|
145
|
+
this.db.prepare('DELETE FROM sessions WHERE agent_id = ?').run(agentId);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
updateNickname(agentId, nickname) {
|
|
149
|
+
const now = new Date().toISOString();
|
|
150
|
+
this.db.prepare('UPDATE identity SET nickname = ?, updated_at = ? WHERE agent_id = ?').run(nickname, now, agentId);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── Friends ───────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
addFriend({ agent_id, id, public_key, fingerprint, friend_type = 'ai', ai_name = '', permissions = ['chat'] }) {
|
|
156
|
+
const now = new Date().toISOString();
|
|
157
|
+
this.db.prepare(`
|
|
158
|
+
INSERT OR REPLACE INTO friends (id, agent_id, public_key, fingerprint, added_at, is_online, permissions, friend_type, ai_name, ai_avatar)
|
|
159
|
+
VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, '')
|
|
160
|
+
`).run(id, agent_id, public_key, fingerprint, now, JSON.stringify(permissions), friend_type, ai_name);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
removeFriend(agentId, friendId) {
|
|
164
|
+
this.db.prepare('DELETE FROM friends WHERE agent_id = ? AND id = ?').run(agentId, friendId);
|
|
165
|
+
this.db.prepare('DELETE FROM sessions WHERE agent_id = ? AND peer_id = ?').run(agentId, friendId);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
getFriend(agentId, friendId) {
|
|
169
|
+
return this.db.prepare('SELECT * FROM friends WHERE agent_id = ? AND id = ?').get(agentId, friendId);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
listFriends(agentId) {
|
|
173
|
+
return this.db.prepare('SELECT * FROM friends WHERE agent_id = ? ORDER BY added_at DESC').all(agentId);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
updateFriendOnline(agentId, friendId, isOnline) {
|
|
177
|
+
const now = isOnline ? new Date().toISOString() : null;
|
|
178
|
+
this.db.prepare('UPDATE friends SET is_online = ?, last_seen = COALESCE(?, last_seen) WHERE agent_id = ? AND id = ?')
|
|
179
|
+
.run(isOnline ? 1 : 0, now, agentId, friendId);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ─── Groups ────────────────────────────────────────────────────────
|
|
183
|
+
|
|
184
|
+
addGroup({ agent_id, id, name, owner_id, members_json = '[]', description = '' }) {
|
|
185
|
+
const now = new Date().toISOString();
|
|
186
|
+
this.db.prepare(`
|
|
187
|
+
INSERT OR REPLACE INTO groups (id, agent_id, name, owner_id, members_json, description, silent_mode, created_at, updated_at)
|
|
188
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
|
|
189
|
+
`).run(id, agent_id, name, owner_id, typeof members_json === 'string' ? members_json : JSON.stringify(members_json), description, now, now);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
listGroups(agentId) {
|
|
193
|
+
return this.db.prepare('SELECT * FROM groups WHERE agent_id = ? ORDER BY created_at DESC').all(agentId);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
getGroup(agentId, groupId) {
|
|
197
|
+
return this.db.prepare('SELECT * FROM groups WHERE agent_id = ? AND id = ?').get(agentId, groupId);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
setGroupSilentMode(agentId, groupId, silent) {
|
|
201
|
+
this.db.prepare(`
|
|
202
|
+
INSERT OR REPLACE INTO group_settings (group_id, agent_id, silent_mode) VALUES (?, ?, ?)
|
|
203
|
+
`).run(groupId, agentId, silent ? 1 : 0);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
getGroupSilentMode(agentId, groupId) {
|
|
207
|
+
const row = this.db.prepare('SELECT silent_mode FROM group_settings WHERE group_id = ? AND agent_id = ?').get(groupId, agentId);
|
|
208
|
+
return row ? !!row.silent_mode : false;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ─── Sessions ──────────────────────────────────────────────────────
|
|
212
|
+
|
|
213
|
+
saveSession({ agent_id, peer_id, session_key }) {
|
|
214
|
+
const now = new Date().toISOString();
|
|
215
|
+
this.db.prepare(`
|
|
216
|
+
INSERT OR REPLACE INTO sessions (peer_id, agent_id, session_key, created_at, message_count, last_rotation)
|
|
217
|
+
VALUES (?, ?, ?, ?, 0, ?)
|
|
218
|
+
`).run(peer_id, agent_id, session_key, now, now);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
loadSession(agentId, peerId) {
|
|
222
|
+
return this.db.prepare('SELECT * FROM sessions WHERE agent_id = ? AND peer_id = ?').get(agentId, peerId);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
incrementSessionMessageCount(agentId, peerId) {
|
|
226
|
+
this.db.prepare('UPDATE sessions SET message_count = message_count + 1 WHERE agent_id = ? AND peer_id = ?').run(agentId, peerId);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ─── Chat History ──────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
saveMessage({ agent_id, target_id, from_id, to_id, type = 'text', content = '', file_url = null, file_name = null, is_group = 0, mentions = [], status = 'pending' }) {
|
|
232
|
+
const id = crypto.randomUUID();
|
|
233
|
+
const now = new Date().toISOString();
|
|
234
|
+
this.db.prepare(`
|
|
235
|
+
INSERT INTO chat_history (id, agent_id, target_id, from_id, to_id, type, content, file_url, file_name, is_group, mentions, timestamp, status)
|
|
236
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
237
|
+
`).run(id, agent_id, target_id, from_id, to_id, type, content, file_url, file_name, is_group, JSON.stringify(mentions), now, status);
|
|
238
|
+
return { id, timestamp: now };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
getChatHistory(agentId, targetId, { limit = 50, before = null } = {}) {
|
|
242
|
+
if (before) {
|
|
243
|
+
return this.db.prepare(
|
|
244
|
+
'SELECT * FROM chat_history WHERE agent_id = ? AND target_id = ? AND timestamp < ? ORDER BY timestamp DESC LIMIT ?'
|
|
245
|
+
).all(agentId, targetId, before, limit);
|
|
246
|
+
}
|
|
247
|
+
return this.db.prepare(
|
|
248
|
+
'SELECT * FROM chat_history WHERE agent_id = ? AND target_id = ? ORDER BY timestamp DESC LIMIT ?'
|
|
249
|
+
).all(agentId, targetId, limit);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
deleteMessage(agentId, messageId) {
|
|
253
|
+
this.db.prepare('DELETE FROM chat_history WHERE agent_id = ? AND id = ?').run(agentId, messageId);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
updateMessageStatus(agentId, messageId, status) {
|
|
257
|
+
this.db.prepare('UPDATE chat_history SET status = ? WHERE agent_id = ? AND id = ?').run(status, agentId, messageId);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Pending Requests ──────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
savePendingRequest({ agent_id, session_id, requester_id, requester_public_key }) {
|
|
263
|
+
const now = new Date().toISOString();
|
|
264
|
+
this.db.prepare(`
|
|
265
|
+
INSERT OR REPLACE INTO pending_requests (session_id, agent_id, requester_id, requester_public_key, timestamp)
|
|
266
|
+
VALUES (?, ?, ?, ?, ?)
|
|
267
|
+
`).run(session_id, agent_id, requester_id, requester_public_key, now);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
getPendingRequests(agentId) {
|
|
271
|
+
return this.db.prepare('SELECT * FROM pending_requests WHERE agent_id = ? ORDER BY timestamp DESC').all(agentId);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
removePendingRequest(agentId, sessionId) {
|
|
275
|
+
this.db.prepare('DELETE FROM pending_requests WHERE agent_id = ? AND session_id = ?').run(agentId, sessionId);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// ─── Temp Numbers ──────────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
saveTempNumber({ agent_id, number, expires_at }) {
|
|
281
|
+
const now = new Date().toISOString();
|
|
282
|
+
this.db.prepare('INSERT OR REPLACE INTO temp_numbers (number, agent_id, expires_at, created_at) VALUES (?, ?, ?, ?)')
|
|
283
|
+
.run(number, agent_id, expires_at, now);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Offline Queue ─────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
enqueueOffline({ agent_id, target_id, data }) {
|
|
289
|
+
const now = new Date().toISOString();
|
|
290
|
+
this.db.prepare('INSERT INTO offline_queue (agent_id, target_id, data, created_at) VALUES (?, ?, ?, ?)')
|
|
291
|
+
.run(agent_id, target_id, data, now);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
dequeueOffline(agentId, targetId, limit = 100) {
|
|
295
|
+
const rows = this.db.prepare('SELECT * FROM offline_queue WHERE agent_id = ? AND target_id = ? ORDER BY created_at ASC LIMIT ?')
|
|
296
|
+
.all(agentId, targetId, limit);
|
|
297
|
+
if (rows.length > 0) {
|
|
298
|
+
const ids = rows.map(r => r.id);
|
|
299
|
+
const placeholders = ids.map(() => '?').join(',');
|
|
300
|
+
this.db.prepare(`DELETE FROM offline_queue WHERE id IN (${placeholders})`).run(...ids);
|
|
301
|
+
}
|
|
302
|
+
return rows;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── Cleanup ───────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
cleanup() {
|
|
308
|
+
const now = new Date().toISOString();
|
|
309
|
+
this.db.prepare("DELETE FROM temp_numbers WHERE expires_at < ?").run(now);
|
|
310
|
+
this.db.prepare("DELETE FROM pending_requests WHERE timestamp < datetime(?, '-48 hours')").run(now);
|
|
311
|
+
this.db.prepare("DELETE FROM offline_queue WHERE created_at < datetime(?, '-7 days')").run(now);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
close() {
|
|
315
|
+
this.db.close();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
module.exports = PluginDatabase;
|
|
@@ -0,0 +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;
|