aicq-codex 0.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/lib/chat.js ADDED
@@ -0,0 +1,1287 @@
1
+ /**
2
+ * AICQ Chat Manager — Send/receive messages, group chat, file handling
3
+ *
4
+ * Enhanced: File/image messages received from users are saved to the
5
+ * `userfiles` directory. After saving, a synthetic message is injected
6
+ * into the AI dispatch pipeline that tells the agent about the local
7
+ * file path so it can process the file (read, analyze, etc.).
8
+ */
9
+ const { encryptMessage, decryptMessage } = require('./crypto');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const crypto = require('crypto');
13
+
14
+ class ChatManager {
15
+ constructor(identityManager, serverClient, db, uploadsDir, userfilesDir) {
16
+ this.identity = identityManager;
17
+ this.server = serverClient;
18
+ this.db = db;
19
+ this.uploadsDir = uploadsDir;
20
+ this.userfilesDir = userfilesDir || path.join(path.dirname(uploadsDir), 'userfiles');
21
+ this._onNewMessage = null;
22
+
23
+ // Ensure userfiles directory exists
24
+ fs.mkdirSync(this.userfilesDir, { recursive: true });
25
+
26
+ // Incoming file chunk assembly state: fileId -> { meta, chunks }
27
+ this._incomingFiles = new Map();
28
+
29
+ // [AICQ integration standard] Dedup set for inbound messages.
30
+ // Prevents WS reconnect / server re-push from triggering duplicate
31
+ // processing of the same message (which caused the "Leo multi-reply
32
+ // flood" in teambot — fixed in cf67622). Mirrors the same dedup
33
+ // strategy used by hermes-plugin's _processed_ids and zagent's
34
+ // processedMsgs/msgIDSeen maps.
35
+ // Capped at 1000 entries (trimmed to 500 when exceeded).
36
+ this._processedMsgIds = new Set();
37
+
38
+ // Listen for incoming messages via WS
39
+ this.server.onMessage('relay', (data) => this._handleIncoming(data));
40
+ this.server.onMessage('message', (data) => this._handleIncoming(data));
41
+ this.server.onMessage('group_message', (data) => this._handleGroupIncoming(data));
42
+ this.server.onMessage('handshake_initiate', (data) => this._handleHandshakeRequest(data));
43
+ this.server.onMessage('friend_request', (data) => this._handleServerFriendRequest(data));
44
+ this.server.onMessage('friend_request_accepted', (data) => this._handleServerFriendRequestAccepted(data));
45
+ this.server.onMessage('friend_added', (data) => this._handleServerFriendAdded(data));
46
+ this.server.onMessage('presence', (data) => this._handlePresence(data));
47
+ this.server.onMessage('file_chunk', (data) => this._handleFileChunk(data));
48
+ this.server.onMessage('file', (data) => this._handleFileMessage(data));
49
+ this.server.onMessage('image', (data) => this._handleFileMessage(data));
50
+ this.server.onMessage('stream_chunk', (data) => this._handleStreamChunk(data));
51
+ this.server.onMessage('stream_end', (data) => this._handleStreamEnd(data));
52
+ this.server.onMessage('stream_cancel', (data) => this._handleStreamCancel(data));
53
+
54
+ // Map of streamId -> { cancelled: bool } for tracking user-initiated
55
+ // stop requests. channel.js checks this in the deliver loop to stop
56
+ // sending chunks for a cancelled stream.
57
+ this._activeStreams = new Map();
58
+ }
59
+
60
+ /**
61
+ * Register a stream so its cancel state can be tracked.
62
+ * Returns the stream state object (mutable: set .cancelled = true to stop).
63
+ */
64
+ registerStream(streamId) {
65
+ const state = { cancelled: false };
66
+ this._activeStreams.set(streamId, state);
67
+ return state;
68
+ }
69
+
70
+ /**
71
+ * Remove a stream from tracking (called on endStream / cancelStream).
72
+ */
73
+ unregisterStream(streamId) {
74
+ this._activeStreams.delete(streamId);
75
+ }
76
+
77
+ /**
78
+ * Handle incoming stream_cancel from server — the user clicked Stop
79
+ * in the web UI. Mark the stream as cancelled so channel.js's deliver
80
+ * loop stops sending chunks.
81
+ */
82
+ _handleStreamCancel(data) {
83
+ const streamId = data.stream_id;
84
+ console.log('[AICQ Chat] Received stream_cancel for streamId=', streamId?.slice(0, 8));
85
+ const state = this._activeStreams.get(streamId);
86
+ if (state) {
87
+ state.cancelled = true;
88
+ console.log('[AICQ Chat] Marked stream', streamId?.slice(0, 8), 'as cancelled');
89
+ // Abort the OpenClaw agent run (model generation + tool calls)
90
+ // so it stops immediately, not just the chunk delivery.
91
+ if (state.abortController && !state.abortController.signal.aborted) {
92
+ console.log('[AICQ Chat] Aborting agent run for stream', streamId?.slice(0, 8));
93
+ state.abortController.abort('user-cancelled');
94
+ }
95
+ }
96
+ // Send stream_cancel_ack back so server can relay to the UI
97
+ this.server.sendWS({
98
+ type: 'stream_cancel_ack',
99
+ stream_id: streamId,
100
+ to: data.from,
101
+ });
102
+ }
103
+
104
+ setOnNewMessage(callback) {
105
+ this._onNewMessage = callback;
106
+ }
107
+
108
+ /**
109
+ * Register a callback for real-time friend_request events.
110
+ * Called by channel.js to immediately accept incoming friend requests
111
+ * without waiting for the next startAccount cycle.
112
+ */
113
+ setOnAutoAccept(callback) {
114
+ this._onAutoAccept = callback;
115
+ }
116
+
117
+ // ─── Send Messages ────────────────────────────────────────────────
118
+
119
+ async sendMessage(agentId, targetId, content, { type = 'text', isGroup = false, mentions = [], file_url = null, file_name = null, local_path = null } = {}) {
120
+ const identity = this.identity.loadAgent(agentId);
121
+
122
+ if (isGroup) {
123
+ // Group message via WebSocket
124
+ const sent = this.server.sendWS({
125
+ type: 'group_message',
126
+ groupId: targetId,
127
+ content,
128
+ msgType: type,
129
+ mentions,
130
+ });
131
+
132
+ // Save locally
133
+ const msg = this.db.saveMessage({
134
+ agent_id: agentId,
135
+ target_id: targetId,
136
+ from_id: agentId,
137
+ to_id: targetId,
138
+ type,
139
+ content,
140
+ file_url,
141
+ file_name,
142
+ local_path,
143
+ is_group: 1,
144
+ mentions,
145
+ status: sent ? 'sent' : 'pending',
146
+ });
147
+ // Tag outbound messages so the channel.js dispatch callback can
148
+ // skip them (otherwise the agent's own replies get re-dispatched
149
+ // as inbound, causing an infinite echo loop).
150
+ msg._outbound = true;
151
+
152
+ if (this._onNewMessage) this._onNewMessage(msg);
153
+ return msg;
154
+ }
155
+
156
+ // Direct message
157
+ // Try to encrypt if we have a session key
158
+ const session = this.db.loadSession(agentId, targetId);
159
+ let payload = content;
160
+ if (session && session.session_key) {
161
+ try {
162
+ payload = encryptMessage(content, session.session_key);
163
+ } catch (e) {
164
+ console.error('[Chat] Encryption failed, sending plaintext:', e.message);
165
+ }
166
+ }
167
+
168
+ // Send via WebSocket relay — use 'message' type so the aicq.me
169
+ // server-side handleMessage path runs (which persists the message
170
+ // and relays to the recipient via WS). 'relay' is a different code
171
+ // path that doesn't persist HTTP-side.
172
+ const sent = this.server.sendWS({
173
+ type: 'message',
174
+ to: targetId,
175
+ data: {
176
+ to_id: targetId,
177
+ type: type,
178
+ content: content,
179
+ msgType: type,
180
+ },
181
+ });
182
+
183
+ // Also send via HTTP API for guaranteed persistence (the WS path
184
+ // is best-effort; if the recipient is offline the message may be
185
+ // lost without the HTTP save).
186
+ try {
187
+ await this.server._request('POST', '/chat/messages', {
188
+ to_id: targetId,
189
+ type: type,
190
+ content: content,
191
+ });
192
+ } catch (e) {
193
+ console.warn('[Chat] HTTP /chat/messages fallback failed:', e.message);
194
+ // Queue offline if both WS and HTTP failed
195
+ if (!sent) {
196
+ this.db.enqueueOffline({
197
+ agent_id: agentId,
198
+ target_id: targetId,
199
+ data: JSON.stringify({ type: 'message', to: targetId, data: { content, type } }),
200
+ });
201
+ }
202
+ }
203
+
204
+ // Save locally
205
+ const msg = this.db.saveMessage({
206
+ agent_id: agentId,
207
+ target_id: targetId,
208
+ from_id: agentId,
209
+ to_id: targetId,
210
+ type,
211
+ content,
212
+ file_url,
213
+ file_name,
214
+ local_path,
215
+ is_group: 0,
216
+ mentions,
217
+ status: sent ? 'sent' : 'pending',
218
+ });
219
+
220
+ // Update session message count
221
+ if (session) {
222
+ this.db.incrementSessionMessageCount(agentId, targetId);
223
+ }
224
+ // Tag outbound messages so the channel.js dispatch callback can
225
+ // skip them (otherwise the agent's own replies get re-dispatched
226
+ // as inbound, causing an infinite echo loop).
227
+ msg._outbound = true;
228
+
229
+ if (this._onNewMessage) this._onNewMessage(msg);
230
+ return msg;
231
+ }
232
+
233
+ // ─── Streaming Output ─────────────────────────────────────────────
234
+ //
235
+ // The aicq.me server supports a stream protocol (WS messages of type
236
+ // stream_chunk + stream_end) that lets the frontend render text
237
+ // character-by-character as the agent produces it. The server
238
+ // accumulates chunks in a StreamBuffer keyed by stream_id, and on
239
+ // stream_end persists the accumulated text as a single direct_message
240
+ // (so page refresh still shows the full reply).
241
+ //
242
+ // These methods wrap the WS protocol so channel.js can stream replies.
243
+
244
+ /**
245
+ * Send a single stream chunk to a friend.
246
+ * @param {string} agentId - local agent id (unused, kept for API symmetry)
247
+ * @param {string} targetId - recipient account id (e.g. "1000008")
248
+ * @param {string} streamId - uuid identifying this stream
249
+ * @param {string} chunk - text content for this chunk
250
+ * @param {string} chunkType - "text" | "reasoning" | "tool_call" | "tool_result"
251
+ * @param {object} [dataField] - optional object payload for tool_call/tool_result
252
+ */
253
+ async sendStreamChunk(agentId, targetId, streamId, chunk, chunkType = 'text', dataField = null) {
254
+ const msg = {
255
+ type: 'stream_chunk',
256
+ to: targetId,
257
+ stream_id: streamId,
258
+ chunkType,
259
+ data: dataField !== null ? dataField : chunk,
260
+ };
261
+ // For text chunks, the server expects msg.data to be the string.
262
+ // For tool_call/tool_result, msg.data should be the object payload.
263
+ if (chunkType === 'text' || chunkType === 'reasoning' || chunkType === 'thinking') {
264
+ msg.data = chunk;
265
+ } else {
266
+ msg.data = dataField || {};
267
+ if (!msg.msg_id) msg.msg_id = streamId;
268
+ }
269
+ const sent = this.server.sendWS(msg);
270
+ if (!sent) {
271
+ console.warn('[Chat] sendStreamChunk: WS not open, chunk lost');
272
+ }
273
+ return sent;
274
+ }
275
+
276
+ /**
277
+ * End a stream — tells the server to persist the accumulated text
278
+ * as a direct_message and notify the recipient that the stream is
279
+ * complete.
280
+ */
281
+ async endStream(agentId, targetId, streamId, messageId = null) {
282
+ const msgId = messageId || `msg_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
283
+ const sent = this.server.sendWS({
284
+ type: 'stream_end',
285
+ to: targetId,
286
+ stream_id: streamId,
287
+ msg_id: msgId,
288
+ });
289
+ if (!sent) {
290
+ console.warn('[Chat] endStream: WS not open, falling back to HTTP /chat/messages');
291
+ // Fallback: persist via HTTP so the message isn't lost
292
+ // (server's stream buffer would otherwise be orphaned)
293
+ try {
294
+ // We don't have the accumulated text here; the caller should
295
+ // also call sendMessage as a safety net if persistence matters.
296
+ } catch (e) {
297
+ console.error('[Chat] endStream HTTP fallback failed:', e.message);
298
+ }
299
+ }
300
+ return { sent, messageId: msgId };
301
+ }
302
+
303
+ /**
304
+ * Cancel an in-progress stream — used when the user clicks "stop".
305
+ * Tells the server to discard the StreamBuffer (the already-streamed
306
+ * chunks are NOT persisted as a direct_message).
307
+ */
308
+ async cancelStream(agentId, targetId, streamId) {
309
+ const sent = this.server.sendWS({
310
+ type: 'stream_cancel',
311
+ to: targetId,
312
+ stream_id: streamId,
313
+ });
314
+ return sent;
315
+ }
316
+
317
+ // ─── Receive Messages ─────────────────────────────────────────────
318
+
319
+ async _handleIncoming(data) {
320
+ try {
321
+ const agentId = this.server.currentAgentId;
322
+ if (!agentId) return;
323
+
324
+ const fromId = data.fromId || data.from || (data.data && data.data.from_id) || data.from_id;
325
+ // Server pushes {type:'message', from:..., data:{content, from_id, to_id, ...}}
326
+ // Some legacy paths push {type:'relay', from:..., payload:'...'}
327
+ let content;
328
+ if (data.data && typeof data.data === 'object' && data.data.content !== undefined) {
329
+ content = data.data.content;
330
+ } else if (typeof data.payload === 'string') {
331
+ content = data.payload;
332
+ } else if (typeof data.data === 'string') {
333
+ content = data.data;
334
+ } else if (typeof data.content === 'string') {
335
+ content = data.content;
336
+ } else {
337
+ content = '';
338
+ }
339
+ const msgType = data.msgType || (data.data && data.data.type) || data.type || 'text';
340
+
341
+ // Try to decrypt if we have a session key
342
+ const session = this.db.loadSession(agentId, fromId);
343
+ if (session && session.session_key && typeof content === 'string') {
344
+ try {
345
+ content = decryptMessage(content, session.session_key);
346
+ } catch (e) {
347
+ // Might be plaintext, keep as is
348
+ }
349
+ }
350
+
351
+ // Detect if this is a file or image message
352
+ const isFileMessage = this._isFileMessage(msgType, content, data);
353
+ let localFilePath = null;
354
+ let originalFileName = null;
355
+
356
+ if (isFileMessage) {
357
+ const fileResult = await this._saveIncomingFileToUserfiles(agentId, fromId, content, data);
358
+ if (fileResult) {
359
+ localFilePath = fileResult.localPath;
360
+ originalFileName = fileResult.originalName;
361
+ }
362
+ }
363
+
364
+ // Save the original message
365
+ const msg = this.db.saveMessage({
366
+ agent_id: agentId,
367
+ target_id: fromId,
368
+ from_id: fromId,
369
+ to_id: agentId,
370
+ type: isFileMessage ? (this._isImageMessage(msgType, content, data) ? 'image' : 'file') : 'text',
371
+ content: typeof content === 'string' ? content : JSON.stringify(content),
372
+ file_url: data.file_url || data.fileUrl || null,
373
+ file_name: originalFileName || data.file_name || data.fileName || null,
374
+ local_path: localFilePath,
375
+ is_group: 0,
376
+ status: 'delivered',
377
+ });
378
+
379
+ if (this._onNewMessage) {
380
+ try {
381
+ await this._onNewMessage(msg);
382
+ } catch (e) {
383
+ console.error('[AICQ Chat] _onNewMessage error:', e.message, e.stack);
384
+ }
385
+ } else {
386
+ console.warn('[AICQ Chat] _onNewMessage is not registered!');
387
+ }
388
+
389
+ // If this was a file/image message, also inject a synthetic message
390
+ // telling the AI agent about the local file path
391
+ if (isFileMessage && localFilePath && this._onNewMessage) {
392
+ const isImage = this._isImageMessage(msgType, content, data);
393
+ const fileType = isImage ? '图片' : '文件';
394
+ const syntheticMsg = {
395
+ agent_id: agentId,
396
+ target_id: fromId,
397
+ from_id: fromId,
398
+ to_id: agentId,
399
+ type: 'text',
400
+ content: `[用户发送了${fileType}] ${originalFileName || '未知文件名'}\n本地路径: ${localFilePath}\n请处理该${fileType}。`,
401
+ is_group: 0,
402
+ status: 'delivered',
403
+ _synthetic: true, // Mark as synthetic so AI dispatch can handle it
404
+ _original_msg_id: msg.message_id || msg.id,
405
+ };
406
+ this._onNewMessage(syntheticMsg);
407
+ }
408
+ } catch (e) {
409
+ console.error('[AICQ Chat] _handleIncoming error:', e.message, e.stack);
410
+ }
411
+ }
412
+
413
+ async _handleGroupIncoming(data) {
414
+ // [AICQ integration standard] Group message handler.
415
+ // See https://aicq.me/static/integration-guide.html#admin-group-reply
416
+ //
417
+ // Field extraction (3-level fallback, mirrors zagent e8755e9 fix):
418
+ // top-level camelCase → data wrapper snake_case → data wrapper camelCase
419
+ // AICQ server SaveGroupMessage persists with snake_case, but WS broadcast
420
+ // promotes `from` to top level and keeps `sender_name` etc. in data wrapper.
421
+ // Without this fallback, fromId would be undefined for every group message.
422
+ const agentId = this.server.currentAgentId;
423
+ if (!agentId) return;
424
+
425
+ const dataWrapper = (data && data.data && typeof data.data === 'object') ? data.data : {};
426
+ const fromId = data.from || data.fromId || dataWrapper.from || dataWrapper.from_id || dataWrapper.fromId;
427
+ const groupId = data.groupId || data.group_id || dataWrapper.group_id || dataWrapper.groupId;
428
+ const senderName = dataWrapper.sender_name || dataWrapper.senderName || data.senderName || '';
429
+ const groupName = dataWrapper.group_name || dataWrapper.groupName || data.groupName || '';
430
+ const content = data.content || dataWrapper.content || data.text || '';
431
+ const msgType = data.msgType || data.msg_type || dataWrapper.msg_type || dataWrapper.msgType || 'text';
432
+
433
+ // Skip system messages (join/leave notifications)
434
+ if (msgType === 'system') {
435
+ console.log('[AICQ Chat] Skipping system message in group', groupId);
436
+ return;
437
+ }
438
+
439
+ // Skip self messages (anti echo loop)
440
+ if (fromId && fromId === agentId) {
441
+ return;
442
+ }
443
+
444
+ if (!content) return;
445
+
446
+ // [AICQ integration standard] Dedup: msg_id primary + (group_id, from_id,
447
+ // content, 10s ts window) fingerprint fallback. Mirrors teambot cf67622
448
+ // fix for the "Leo multi-reply flood" caused by WS reconnect / server
449
+ // re-push. Without this, the same group message can be processed multiple
450
+ // times, triggering repeated AI replies and AICQ server rate limiting.
451
+ const grpMsgId = dataWrapper.id || dataWrapper.messageId || data.id || data.messageId;
452
+ if (grpMsgId) {
453
+ if (this._processedMsgIds.has(grpMsgId)) {
454
+ console.log('[AICQ Chat] Skipping duplicate group message: msg_id=', String(grpMsgId).slice(0, 12));
455
+ return;
456
+ }
457
+ this._processedMsgIds.add(grpMsgId);
458
+ if (this._processedMsgIds.size > 1000) {
459
+ // Trim to 500 (keep most recent — Set preserves insertion order)
460
+ this._processedMsgIds = new Set(Array.from(this._processedMsgIds).slice(-500));
461
+ }
462
+ } else {
463
+ // Fallback fingerprint dedup (10s window)
464
+ const ts = data.timestamp || dataWrapper.timestamp || data.ts || 0;
465
+ if (typeof ts === 'number' && ts > 0 && content) {
466
+ const tsWindow = Math.floor(ts / 10000);
467
+ const fingerprint = `grp_${groupId}_${fromId}_${String(content).slice(0, 200)}_${tsWindow}`;
468
+ if (this._processedMsgIds.has(fingerprint)) {
469
+ console.log('[AICQ Chat] Skipping duplicate group message (fingerprint): from=', fromId, 'group=', groupId);
470
+ return;
471
+ }
472
+ this._processedMsgIds.add(fingerprint);
473
+ if (this._processedMsgIds.size > 1000) {
474
+ this._processedMsgIds = new Set(Array.from(this._processedMsgIds).slice(-500));
475
+ }
476
+ }
477
+ }
478
+
479
+ // Check silent mode
480
+ const silent = this.db.getGroupSilentMode(agentId, groupId);
481
+ // @mention detection: prefer server-provided mentions field, but also
482
+ // scan content for "@<agentName>" since the AICQ server doesn't always
483
+ // populate the mentions array (mirrors zagent's content-scan approach).
484
+ const mentions = data.mentions || dataWrapper.mentions || [];
485
+ const agentName = (this.identity && this.identity.loadAgent && this.identity.loadAgent(agentId) && this.identity.loadAgent(agentId).name) || '';
486
+ const isMentioned = mentions.includes(agentId) || mentions.includes('all')
487
+ || (agentName && content.includes('@' + agentName))
488
+ || content.includes('@all');
489
+
490
+ // Detect file/image in group message
491
+ const isFileMessage = this._isFileMessage(msgType, content, data);
492
+ let localFilePath = null;
493
+ let originalFileName = null;
494
+
495
+ if (isFileMessage) {
496
+ const fileResult = await this._saveIncomingFileToUserfiles(agentId, fromId, content, data);
497
+ if (fileResult) {
498
+ localFilePath = fileResult.localPath;
499
+ originalFileName = fileResult.originalName;
500
+ }
501
+ }
502
+
503
+ const msg = this.db.saveMessage({
504
+ agent_id: agentId,
505
+ target_id: groupId,
506
+ from_id: fromId,
507
+ to_id: groupId,
508
+ type: isFileMessage ? (this._isImageMessage(msgType, content, data) ? 'image' : 'file') : (msgType || 'text'),
509
+ content,
510
+ file_url: data.file_url || data.fileUrl || dataWrapper.file_url || dataWrapper.fileUrl || null,
511
+ file_name: originalFileName || data.file_name || data.fileName || dataWrapper.file_name || dataWrapper.fileName || null,
512
+ local_path: localFilePath,
513
+ is_group: 1,
514
+ mentions,
515
+ status: (silent && !isMentioned) ? 'silent' : 'delivered',
516
+ });
517
+
518
+ if (this._onNewMessage) this._onNewMessage(msg);
519
+
520
+ // Inject synthetic message for group file messages
521
+ if (isFileMessage && localFilePath && this._onNewMessage && (isMentioned || !silent)) {
522
+ const isImage = this._isImageMessage(msgType, content, data);
523
+ const fileType = isImage ? '图片' : '文件';
524
+ const syntheticMsg = {
525
+ agent_id: agentId,
526
+ target_id: groupId,
527
+ from_id: fromId,
528
+ to_id: groupId,
529
+ type: 'text',
530
+ content: `[群组中用户发送了${fileType}] ${originalFileName || '未知文件'}
531
+ 本地路径: ${localFilePath}
532
+ 请处理该${fileType}。`,
533
+ is_group: 1,
534
+ status: 'delivered',
535
+ _synthetic: true,
536
+ _original_msg_id: msg.message_id || msg.id,
537
+ };
538
+ this._onNewMessage(syntheticMsg);
539
+ }
540
+ }
541
+
542
+ _handleHandshakeRequest(data) {
543
+ const agentId = this.server.currentAgentId;
544
+ if (!agentId) return;
545
+
546
+ this.db.savePendingRequest({
547
+ agent_id: agentId,
548
+ session_id: data.sessionId || crypto.randomUUID(),
549
+ requester_id: data.requesterId || data.from,
550
+ requester_public_key: data.requesterPublicKey || data.exchangePublicKey || '',
551
+ });
552
+ }
553
+
554
+ /**
555
+ * Handle server-pushed `friend_request` WS events.
556
+ *
557
+ * The AICQ server uses a simple HTTP-based friend-request flow
558
+ * (POST /friends/request, POST /friends/requests/:id/accept).
559
+ * When user A sends a friend request to AI agent B, the server pushes
560
+ * a `friend_request` WS message to B. We persist it into the local
561
+ * pending_requests table so that:
562
+ * 1. The OpenClaw dashboard can list pending requests via
563
+ * aicq.friends.requests gateway method.
564
+ * 2. The auto-accept logic in channel.js can pick it up on the
565
+ * next startAccount cycle (or immediately via _tryAutoAccept).
566
+ *
567
+ * The request_id from the server is stored as session_id so that
568
+ * acceptRequest/rejectRequest can call the server API directly.
569
+ */
570
+ _handleServerFriendRequest(data) {
571
+ const agentId = this.server.currentAgentId;
572
+ if (!agentId) return;
573
+
574
+ const requestId = data.request_id || data.id;
575
+ if (!requestId) {
576
+ console.warn('[AICQ Chat] friend_request WS missing request_id', data);
577
+ return;
578
+ }
579
+
580
+ this.db.savePendingRequest({
581
+ agent_id: agentId,
582
+ session_id: requestId,
583
+ requester_id: data.from_id || data.from || '',
584
+ requester_public_key: data.from_public_key || data.public_key || '',
585
+ });
586
+ console.log(`[AICQ Chat] Received friend_request from ${data.from_id || data.from} (request_id=${requestId})`);
587
+
588
+ // Opportunistically try auto-accept if a callback is registered.
589
+ if (typeof this._onAutoAccept === 'function') {
590
+ this._onAutoAccept({
591
+ request_id: requestId,
592
+ from_id: data.from_id || data.from,
593
+ from_public_key: data.from_public_key || '',
594
+ }).catch((e) =>
595
+ console.warn('[AICQ Chat] Auto-accept failed:', e.message)
596
+ );
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Handle server-pushed `friend_request_accepted` WS events.
602
+ *
603
+ * Fired when the OTHER side accepted OUR friend request. We add the
604
+ * friend locally so subsequent messages can be encrypted/sent.
605
+ */
606
+ _handleServerFriendRequestAccepted(data) {
607
+ const agentId = this.server.currentAgentId;
608
+ if (!agentId) return;
609
+
610
+ const friendId = data.friend_id || data.by_id || data.from_id;
611
+ if (!friendId) return;
612
+
613
+ // Avoid double-inserting if already a friend
614
+ const existing = this.db.listFriends(agentId).find((f) => f.id === friendId);
615
+ if (existing) return;
616
+
617
+ this.db.addFriend({
618
+ agent_id: agentId,
619
+ id: friendId,
620
+ public_key: data.friend_public_key || data.public_key || '',
621
+ fingerprint: data.friend_public_key
622
+ ? require('./crypto').computeFingerprint(data.friend_public_key)
623
+ : '',
624
+ friend_type: data.friend_type || 'human',
625
+ ai_name: data.friend_name || data.friend_agent_name || '',
626
+ });
627
+ console.log(`[AICQ Chat] friend_request_accepted — added friend ${friendId}`);
628
+ }
629
+
630
+ /**
631
+ * Handle server-pushed `friend_added` WS events.
632
+ *
633
+ * Fired as a confirmation after we accept a friend request — the
634
+ * server has created the bidirectional friendship. We ensure the
635
+ * friend is in our local DB.
636
+ */
637
+ _handleServerFriendAdded(data) {
638
+ const agentId = this.server.currentAgentId;
639
+ if (!agentId) return;
640
+
641
+ const friendId = data.friend_id || data.id;
642
+ if (!friendId) return;
643
+
644
+ const existing = this.db.listFriends(agentId).find((f) => f.id === friendId);
645
+ if (existing) return;
646
+
647
+ this.db.addFriend({
648
+ agent_id: agentId,
649
+ id: friendId,
650
+ public_key: data.friend_public_key || data.public_key || '',
651
+ fingerprint: data.friend_public_key
652
+ ? require('./crypto').computeFingerprint(data.friend_public_key)
653
+ : '',
654
+ friend_type: data.friend_type || 'human',
655
+ ai_name: data.friend_name || data.friend_agent_name || '',
656
+ });
657
+ console.log(`[AICQ Chat] friend_added — added friend ${friendId}`);
658
+ }
659
+
660
+ _handlePresence(data) {
661
+ const agentId = this.server.currentAgentId;
662
+ if (!agentId) return;
663
+
664
+ const friendId = data.nodeId;
665
+ const isOnline = data.online === true || data.status === 'online';
666
+ this.db.updateFriendOnline(agentId, friendId, isOnline);
667
+ }
668
+
669
+ async _handleFileMessage(data) {
670
+ // Handle explicit file/image type WS messages
671
+ const agentId = this.server.currentAgentId;
672
+ if (!agentId) return;
673
+
674
+ const fromId = data.fromId || data.from;
675
+ const content = data.content || data.data || '';
676
+ const isImage = data.type === 'image' || this._isImageMessage(data.type, content, data);
677
+
678
+ let localFilePath = null;
679
+ let originalFileName = null;
680
+
681
+ // If the file data is inline (base64), save it
682
+ if (data.file_data || data.fileData || data.media_data || data.mediaData || (data.data && this._isBase64Data(data.data))) {
683
+ const fileResult = await this._saveBase64FileToUserfiles(agentId, fromId, data);
684
+ if (fileResult) {
685
+ localFilePath = fileResult.localPath;
686
+ originalFileName = fileResult.originalName;
687
+ }
688
+ } else if (data.file_url || data.fileUrl || data.media_url || data.mediaUrl) {
689
+ // Download file from URL and save locally
690
+ const fileResult = await this._saveUrlFileToUserfiles(agentId, fromId, data);
691
+ if (fileResult) {
692
+ localFilePath = fileResult.localPath;
693
+ originalFileName = fileResult.originalName;
694
+ }
695
+ }
696
+
697
+ // Save message
698
+ const msg = this.db.saveMessage({
699
+ agent_id: agentId,
700
+ target_id: fromId,
701
+ from_id: fromId,
702
+ to_id: agentId,
703
+ type: isImage ? 'image' : 'file',
704
+ content,
705
+ file_url: data.file_url || data.fileUrl || null,
706
+ file_name: originalFileName || data.file_name || data.fileName || null,
707
+ local_path: localFilePath,
708
+ is_group: 0,
709
+ status: 'delivered',
710
+ });
711
+
712
+ if (this._onNewMessage) this._onNewMessage(msg);
713
+
714
+ // Inject synthetic message
715
+ if (localFilePath && this._onNewMessage) {
716
+ const fileType = isImage ? '图片' : '文件';
717
+ const syntheticMsg = {
718
+ agent_id: agentId,
719
+ target_id: fromId,
720
+ from_id: fromId,
721
+ to_id: agentId,
722
+ type: 'text',
723
+ content: `[用户发送了${fileType}] ${originalFileName || '未知文件名'}\n本地路径: ${localFilePath}\n请处理该${fileType}。`,
724
+ is_group: 0,
725
+ status: 'delivered',
726
+ _synthetic: true,
727
+ _original_msg_id: msg.message_id || msg.id,
728
+ };
729
+ this._onNewMessage(syntheticMsg);
730
+ }
731
+ }
732
+
733
+ _handleFileChunk(data) {
734
+ // File chunk handling — assemble in userfiles dir
735
+ const agentId = this.server.currentAgentId;
736
+ if (!agentId) return;
737
+
738
+ const chunkData = data.data || data;
739
+ const fileId = chunkData.fileId || data.fileId;
740
+
741
+ if (!fileId) {
742
+ console.log('[Chat] File chunk without fileId from', data.from);
743
+ return;
744
+ }
745
+
746
+ // Initialize incoming transfer if needed
747
+ if (!this._incomingFiles.has(fileId)) {
748
+ this._incomingFiles.set(fileId, {
749
+ chunks: new Map(),
750
+ meta: null,
751
+ fromId: data.fromId || data.from,
752
+ });
753
+ }
754
+
755
+ const transfer = this._incomingFiles.get(fileId);
756
+
757
+ // If this is a file-info message
758
+ if (chunkData.type === 'file-info') {
759
+ transfer.meta = chunkData;
760
+ return;
761
+ }
762
+
763
+ // Store the chunk
764
+ transfer.chunks.set(chunkData.index, chunkData);
765
+
766
+ // Check if all chunks received
767
+ if (transfer.meta && transfer.chunks.size >= transfer.meta.totalChunks) {
768
+ this._assembleAndNotify(agentId, fileId, transfer);
769
+ }
770
+ }
771
+
772
+ /**
773
+ * Assemble received file chunks into a complete file in userfiles,
774
+ * then notify the AI agent about the local file path.
775
+ */
776
+ _assembleAndNotify(agentId, fileId, transfer) {
777
+ const { meta, chunks, fromId } = transfer;
778
+
779
+ try {
780
+ const sortedChunks = Array.from(chunks.entries())
781
+ .sort((a, b) => a[0] - b[0]);
782
+
783
+ const buffers = [];
784
+ for (const [index, chunk] of sortedChunks) {
785
+ if (chunk.encrypted) {
786
+ // For now, try to use raw data
787
+ buffers.push(Buffer.from(chunk.data, 'base64'));
788
+ } else {
789
+ buffers.push(Buffer.from(chunk.data, 'base64'));
790
+ }
791
+ }
792
+
793
+ const fileBuffer = Buffer.concat(buffers);
794
+ const originalName = meta.fileName || `file_${fileId}`;
795
+ const ext = path.extname(originalName) || '.bin';
796
+
797
+ // Save to userfiles with timestamp prefix for uniqueness
798
+ const timestamp = Date.now();
799
+ const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
800
+ const localPath = path.join(this.userfilesDir, safeName);
801
+ fs.writeFileSync(localPath, fileBuffer);
802
+
803
+ const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(ext);
804
+
805
+ // Save to chat history
806
+ const msg = this.db.saveMessage({
807
+ agent_id: agentId,
808
+ target_id: fromId,
809
+ from_id: fromId,
810
+ to_id: agentId,
811
+ type: isImage ? 'image' : 'file',
812
+ content: JSON.stringify({
813
+ fileId,
814
+ fileName: originalName,
815
+ fileSize: meta.fileSize,
816
+ localPath,
817
+ }),
818
+ file_name: originalName,
819
+ local_path: localPath,
820
+ is_group: 0,
821
+ status: 'delivered',
822
+ });
823
+
824
+ console.log(`[Chat] File assembled: ${originalName} -> ${localPath}`);
825
+
826
+ if (this._onNewMessage) {
827
+ this._onNewMessage(msg);
828
+
829
+ // Inject synthetic message
830
+ const fileType = isImage ? '图片' : '文件';
831
+ const syntheticMsg = {
832
+ agent_id: agentId,
833
+ target_id: fromId,
834
+ from_id: fromId,
835
+ to_id: agentId,
836
+ type: 'text',
837
+ content: `[用户发送了${fileType}] ${originalName}\n本地路径: ${localPath}\n文件大小: ${meta.fileSize} 字节\n请处理该${fileType}。`,
838
+ is_group: 0,
839
+ status: 'delivered',
840
+ _synthetic: true,
841
+ _original_msg_id: msg.message_id || msg.id,
842
+ };
843
+ this._onNewMessage(syntheticMsg);
844
+ }
845
+ } catch (e) {
846
+ console.error(`[Chat] File assembly failed for ${fileId}:`, e.message);
847
+ } finally {
848
+ this._incomingFiles.delete(fileId);
849
+ }
850
+ }
851
+
852
+ _handleStreamChunk(data) {
853
+ // Incoming streaming chunk from another agent
854
+ const agentId = this.server.currentAgentId;
855
+ if (!agentId) return;
856
+
857
+ const fromId = data.from;
858
+ const chunkType = data.chunkType || 'text';
859
+ const chunkData = data.data;
860
+
861
+ // Notify callback so OpenClaw agent can process streaming input
862
+ if (this._onNewMessage) {
863
+ this._onNewMessage({
864
+ type: 'stream_chunk',
865
+ from_id: fromId,
866
+ chunk_type: chunkType,
867
+ data: chunkData,
868
+ });
869
+ }
870
+ console.log('[Chat] Stream chunk from', fromId, 'type:', chunkType);
871
+ }
872
+
873
+ _handleStreamEnd(data) {
874
+ // Incoming stream end signal from another agent
875
+ const agentId = this.server.currentAgentId;
876
+ if (!agentId) return;
877
+
878
+ const fromId = data.from;
879
+ const messageId = data.messageId || '';
880
+
881
+ // Notify callback so OpenClaw agent knows stream is complete
882
+ if (this._onNewMessage) {
883
+ this._onNewMessage({
884
+ type: 'stream_end',
885
+ from_id: fromId,
886
+ message_id: messageId,
887
+ });
888
+ }
889
+ console.log('[Chat] Stream end from', fromId, 'messageId:', messageId);
890
+ }
891
+
892
+ // ─── Chat History ─────────────────────────────────────────────────
893
+
894
+ getHistory(agentId, targetId, { limit = 50, before = null } = {}) {
895
+ return this.db.getChatHistory(agentId, targetId, { limit, before });
896
+ }
897
+
898
+ deleteMessage(agentId, messageId) {
899
+ this.db.deleteMessage(agentId, messageId);
900
+ }
901
+
902
+ // ─── File Upload ──────────────────────────────────────────────────
903
+
904
+ async handleFileUpload(agentId, targetId, file, isGroup = false) {
905
+ const fileId = crypto.randomUUID();
906
+ const ext = path.extname(file.originalname || '.bin');
907
+ const fileName = `${fileId}${ext}`;
908
+ const filePath = path.join(this.uploadsDir, fileName);
909
+ fs.writeFileSync(filePath, file.buffer);
910
+
911
+ const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(ext);
912
+
913
+ // Send message with file reference
914
+ const msg = await this.sendMessage(agentId, targetId, isImage ? '[图片]' : `[文件] ${file.originalname}`, {
915
+ type: isImage ? 'image' : 'file',
916
+ isGroup,
917
+ file_url: `/api/files/${fileName}`,
918
+ file_name: file.originalname,
919
+ local_path: filePath,
920
+ });
921
+
922
+ return msg;
923
+ }
924
+
925
+ // ─── Userfile Management ─────────────────────────────────────────
926
+
927
+ /**
928
+ * Save an uploaded file from a user to the userfiles directory.
929
+ * This is called when files are received via the HTTP upload API
930
+ * and should be processed by the AI agent.
931
+ */
932
+ async handleUserFileUpload(agentId, fromId, file, isGroup = false) {
933
+ const fileId = crypto.randomUUID();
934
+ const ext = path.extname(file.originalname || '.bin');
935
+ const timestamp = Date.now();
936
+ const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
937
+ const localPath = path.join(this.userfilesDir, safeName);
938
+
939
+ fs.writeFileSync(localPath, file.buffer);
940
+
941
+ const isImage = /\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(ext);
942
+ const originalName = file.originalname || safeName;
943
+
944
+ // Save to chat history
945
+ const msg = this.db.saveMessage({
946
+ agent_id: agentId,
947
+ target_id: fromId,
948
+ from_id: fromId,
949
+ to_id: agentId,
950
+ type: isImage ? 'image' : 'file',
951
+ content: `[${isImage ? '图片' : '文件'}] ${originalName}`,
952
+ file_name: originalName,
953
+ local_path: localPath,
954
+ is_group: isGroup ? 1 : 0,
955
+ status: 'delivered',
956
+ });
957
+
958
+ if (this._onNewMessage) {
959
+ this._onNewMessage(msg);
960
+
961
+ // Inject synthetic message for AI agent
962
+ const fileType = isImage ? '图片' : '文件';
963
+ const syntheticMsg = {
964
+ agent_id: agentId,
965
+ target_id: fromId,
966
+ from_id: fromId,
967
+ to_id: agentId,
968
+ type: 'text',
969
+ content: `[用户发送了${fileType}] ${originalName}\n本地路径: ${localPath}\n文件大小: ${file.size || file.buffer?.length || 0} 字节\n请处理该${fileType}。`,
970
+ is_group: isGroup ? 1 : 0,
971
+ status: 'delivered',
972
+ _synthetic: true,
973
+ _original_msg_id: msg.message_id || msg.id,
974
+ };
975
+ this._onNewMessage(syntheticMsg);
976
+ }
977
+
978
+ return { msg, localPath, originalName };
979
+ }
980
+
981
+ // ─── Private Helpers ──────────────────────────────────────────────
982
+
983
+ /**
984
+ * Check if a message represents a file/image based on type and content.
985
+ */
986
+ _isFileMessage(msgType, content, data) {
987
+ // Check explicit message type
988
+ if (['file', 'image', 'file_chunk'].includes(msgType)) return true;
989
+ if (['file', 'image'].includes(data.type)) return true;
990
+ // Also check data.data (server wraps payload in data.data)
991
+ const inner = data.data || {};
992
+ if (['file', 'image'].includes(inner.type)) return true;
993
+
994
+ // Check for file metadata in content
995
+ if (typeof content === 'string') {
996
+ try {
997
+ const parsed = JSON.parse(content);
998
+ if (parsed.type === 'file-info' || parsed.fileId || parsed.fileName || parsed.localPath) {
999
+ return true;
1000
+ }
1001
+ } catch (e) {
1002
+ // Not JSON
1003
+ }
1004
+ }
1005
+
1006
+ // Check for file_url, media_url, or file data
1007
+ // aicq.me web UI uses media_url / media_data for file/image messages
1008
+ if (data.file_url || data.fileUrl || data.file_data || data.fileData) return true;
1009
+ if (data.media_url || data.mediaUrl || data.media_data || data.mediaData) return true;
1010
+ if (inner.file_url || inner.fileUrl || inner.media_url || inner.mediaUrl) return true;
1011
+ if (inner.file_data || inner.fileData || inner.media_data || inner.mediaData) return true;
1012
+
1013
+ // Check for known file markers in text content
1014
+ if (typeof content === 'string' && (
1015
+ content.startsWith('[文件]') ||
1016
+ content.startsWith('[图片]') ||
1017
+ content.startsWith('[File]') ||
1018
+ content.startsWith('[Image]')
1019
+ )) {
1020
+ return true;
1021
+ }
1022
+
1023
+ return false;
1024
+ }
1025
+
1026
+ /**
1027
+ * Check if a message is specifically an image (vs other file types).
1028
+ */
1029
+ _isImageMessage(msgType, content, data) {
1030
+ if (msgType === 'image' || data.type === 'image') return true;
1031
+
1032
+ // Check file extension in filename
1033
+ const fileName = data.file_name || data.fileName || '';
1034
+ if (/\.(jpg|jpeg|png|gif|webp|svg|bmp)$/i.test(fileName)) return true;
1035
+
1036
+ // Check content markers
1037
+ if (typeof content === 'string' && content.startsWith('[图片]')) return true;
1038
+
1039
+ return false;
1040
+ }
1041
+
1042
+ /**
1043
+ * Save an incoming file to the userfiles directory.
1044
+ * Handles various formats: inline base64, URL references, file-info JSON.
1045
+ */
1046
+ async _saveIncomingFileToUserfiles(agentId, fromId, content, data) {
1047
+ try {
1048
+ const fileId = crypto.randomUUID();
1049
+ const timestamp = Date.now();
1050
+
1051
+ // Also check inner data (server wraps payload in data.data)
1052
+ const inner = data.data || {};
1053
+
1054
+ // Try to extract file info from the message
1055
+ let parsed = null;
1056
+ if (typeof content === 'string') {
1057
+ try { parsed = JSON.parse(content); } catch (e) {}
1058
+ }
1059
+
1060
+ // Case 1: file-info with chunked data (already assembled elsewhere)
1061
+ if (parsed && parsed.localPath) {
1062
+ return {
1063
+ localPath: parsed.localPath,
1064
+ originalName: parsed.fileName || path.basename(parsed.localPath),
1065
+ };
1066
+ }
1067
+
1068
+ // Case 2: Base64 data inline
1069
+ // Check both legacy (file_data/fileData) and aicq.me (media_data/mediaData) field names
1070
+ const base64Data = data.file_data || data.fileData || data.media_data || data.mediaData
1071
+ || inner.file_data || inner.fileData || inner.media_data || inner.mediaData
1072
+ || (parsed && parsed.data);
1073
+ if (base64Data) {
1074
+ const fileName = data.file_name || data.fileName || inner.file_name || inner.fileName
1075
+ || (parsed && parsed.fileName) || (inner.file_info && inner.file_info.filename)
1076
+ || (data.file_info && data.file_info.filename) || 'file.bin';
1077
+ return await this._saveBase64FileToUserfiles(agentId, fromId, {
1078
+ ...data,
1079
+ file_data: base64Data,
1080
+ file_name: fileName,
1081
+ });
1082
+ }
1083
+
1084
+ // Case 3: URL reference — download and save
1085
+ // Check both legacy (file_url/fileUrl) and aicq.me (media_url/mediaUrl) field names
1086
+ const fileUrl = data.file_url || data.fileUrl || data.media_url || data.mediaUrl
1087
+ || inner.file_url || inner.fileUrl || inner.media_url || inner.mediaUrl
1088
+ || (parsed && parsed.fileUrl);
1089
+ if (fileUrl) {
1090
+ const fileName = data.file_name || data.fileName || inner.file_name || inner.fileName
1091
+ || (parsed && parsed.fileName) || (inner.file_info && inner.file_info.filename)
1092
+ || (data.file_info && data.file_info.filename) || 'file.bin';
1093
+ return await this._saveUrlFileToUserfiles(agentId, fromId, {
1094
+ ...data,
1095
+ file_url: fileUrl,
1096
+ file_name: fileName,
1097
+ });
1098
+ }
1099
+
1100
+ // Case 4: Text-based file marker like [文件] filename or [图片] filename
1101
+ if (typeof content === 'string' && (
1102
+ content.startsWith('[文件]') ||
1103
+ content.startsWith('[图片]') ||
1104
+ content.startsWith('[File]') ||
1105
+ content.startsWith('[Image]')
1106
+ )) {
1107
+ const originalName = content.replace(/^\[(文件|图片|File|Image)\]\s*/, '').trim() || 'unknown';
1108
+ const ext = path.extname(originalName) || (content.includes('图片') || content.includes('Image') ? '.png' : '.bin');
1109
+ const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
1110
+ const localPath = path.join(this.userfilesDir, safeName);
1111
+
1112
+ // Create a placeholder file — the actual content may come via chunks
1113
+ // or may already be in the uploads dir
1114
+ const uploadsPath = path.join(this.uploadsDir, originalName);
1115
+ if (fs.existsSync(uploadsPath)) {
1116
+ fs.copyFileSync(uploadsPath, localPath);
1117
+ console.log(`[Chat] Copied user file: ${originalName} -> ${localPath}`);
1118
+ return { localPath, originalName };
1119
+ }
1120
+
1121
+ // No actual file data yet — save a placeholder
1122
+ fs.writeFileSync(localPath, Buffer.alloc(0));
1123
+ console.log(`[Chat] Created placeholder for user file: ${localPath}`);
1124
+ return { localPath, originalName };
1125
+ }
1126
+
1127
+ return null;
1128
+ } catch (e) {
1129
+ console.error('[Chat] Failed to save incoming file to userfiles:', e.message);
1130
+ return null;
1131
+ }
1132
+ }
1133
+
1134
+ /**
1135
+ * Save a base64-encoded file to userfiles.
1136
+ */
1137
+ async _saveBase64FileToUserfiles(agentId, fromId, data) {
1138
+ try {
1139
+ const fileId = crypto.randomUUID();
1140
+ const timestamp = Date.now();
1141
+ const base64Data = data.file_data || data.fileData || data.data;
1142
+ const originalName = data.file_name || data.fileName || 'file.bin';
1143
+
1144
+ if (!base64Data) return null;
1145
+
1146
+ // Strip data URL prefix if present (e.g., "data:image/png;base64,")
1147
+ const base64Clean = base64Data.replace(/^data:[^;]+;base64,/, '');
1148
+ const fileBuffer = Buffer.from(base64Clean, 'base64');
1149
+
1150
+ const ext = path.extname(originalName) || this._inferExtFromData(base64Data);
1151
+ const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
1152
+ const localPath = path.join(this.userfilesDir, safeName);
1153
+
1154
+ fs.writeFileSync(localPath, fileBuffer);
1155
+ console.log(`[Chat] Saved base64 user file: ${originalName} -> ${localPath} (${fileBuffer.length} bytes)`);
1156
+
1157
+ return { localPath, originalName };
1158
+ } catch (e) {
1159
+ console.error('[Chat] Failed to save base64 file:', e.message);
1160
+ return null;
1161
+ }
1162
+ }
1163
+
1164
+ /**
1165
+ * Download a file from URL and save to userfiles.
1166
+ */
1167
+ async _saveUrlFileToUserfiles(agentId, fromId, data) {
1168
+ try {
1169
+ const fileId = crypto.randomUUID();
1170
+ const timestamp = Date.now();
1171
+ const fileUrl = data.file_url || data.fileUrl;
1172
+ const originalName = data.file_name || data.fileName || path.basename(fileUrl || 'file.bin');
1173
+
1174
+ // For aicq.me server URLs (/api/v1/chat/files/:id), download the file
1175
+ // using the AI agent's JWT token
1176
+ if (fileUrl && (fileUrl.startsWith('/api/v1/chat/files/') || fileUrl.includes('/api/v1/chat/files/'))) {
1177
+ const downloadUrl = this.server.serverUrl + fileUrl;
1178
+ const token = this.server.jwtToken;
1179
+ const fetch = require('node-fetch');
1180
+ try {
1181
+ const resp = await fetch(downloadUrl, {
1182
+ headers: { 'Authorization': `Bearer ${token}` },
1183
+ });
1184
+ if (resp.ok) {
1185
+ const buffer = await resp.buffer();
1186
+ const contentType = resp.headers.get('content-type') || '';
1187
+ const ext = path.extname(originalName) || this._inferExtFromMime(contentType) || '.bin';
1188
+ const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
1189
+ const localPath = path.join(this.userfilesDir, safeName);
1190
+ fs.writeFileSync(localPath, buffer);
1191
+ console.log(`[Chat] Downloaded aicq.me file: ${fileUrl} -> ${localPath} (${buffer.length} bytes)`);
1192
+ return { localPath, originalName };
1193
+ } else {
1194
+ console.warn(`[Chat] aicq.me file download failed: HTTP ${resp.status}`);
1195
+ }
1196
+ } catch (e) {
1197
+ console.warn('[Chat] Failed to download aicq.me file:', e.message);
1198
+ }
1199
+ }
1200
+
1201
+ // For local server URLs (/api/files/), resolve the local path directly
1202
+ if (fileUrl && fileUrl.startsWith('/api/files/')) {
1203
+ const fileName = path.basename(fileUrl);
1204
+ const uploadsPath = path.join(this.uploadsDir, fileName);
1205
+ if (fs.existsSync(uploadsPath)) {
1206
+ const ext = path.extname(originalName) || path.extname(uploadsPath);
1207
+ const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
1208
+ const localPath = path.join(this.userfilesDir, safeName);
1209
+ fs.copyFileSync(uploadsPath, localPath);
1210
+ console.log(`[Chat] Copied local URL file: ${fileUrl} -> ${localPath}`);
1211
+ return { localPath, originalName };
1212
+ }
1213
+ }
1214
+
1215
+ // For other remote URLs, save a placeholder with the URL reference
1216
+ console.log(`[Chat] Remote file URL (async download not yet supported): ${fileUrl}`);
1217
+ const ext = path.extname(originalName) || '.bin';
1218
+ const safeName = `${timestamp}_${fileId.substring(0, 8)}${ext}`;
1219
+ const localPath = path.join(this.userfilesDir, safeName);
1220
+ fs.writeFileSync(localPath, JSON.stringify({
1221
+ type: 'url_reference',
1222
+ url: fileUrl,
1223
+ originalName,
1224
+ timestamp,
1225
+ }));
1226
+ return { localPath, originalName };
1227
+ } catch (e) {
1228
+ console.error('[Chat] Failed to save URL file:', e.message);
1229
+ return null;
1230
+ }
1231
+ }
1232
+
1233
+ /**
1234
+ * Helper: synchronous-style fetch for downloading files.
1235
+ * Since _saveUrlFileToUserfiles is called from sync context, we use
1236
+ * a child_process execSync to download the file.
1237
+ */
1238
+ _inferExtFromMime(mime) {
1239
+ const map = {
1240
+ 'image/png': '.png', 'image/jpeg': '.jpg', 'image/gif': '.gif',
1241
+ 'image/webp': '.webp', 'image/svg+xml': '.svg',
1242
+ 'application/pdf': '.pdf', 'text/plain': '.txt',
1243
+ 'application/zip': '.zip', 'application/octet-stream': '.bin',
1244
+ };
1245
+ return map[mime] || '.bin';
1246
+ }
1247
+
1248
+ /**
1249
+ * Check if a string looks like base64 data.
1250
+ */
1251
+ _isBase64Data(str) {
1252
+ if (typeof str !== 'string') return false;
1253
+ if (str.startsWith('data:')) return true;
1254
+ // Quick heuristic: long string with only base64 chars
1255
+ if (str.length > 100 && /^[A-Za-z0-9+/=\s]+$/.test(str.substring(0, 200))) return true;
1256
+ return false;
1257
+ }
1258
+
1259
+ /**
1260
+ * Infer file extension from base64 data URL prefix.
1261
+ */
1262
+ _inferExtFromData(data) {
1263
+ if (typeof data !== 'string') return '.bin';
1264
+ const mimeMatch = data.match(/^data:([^;]+);/);
1265
+ if (mimeMatch) {
1266
+ const mime = mimeMatch[1];
1267
+ const mimeToExt = {
1268
+ 'image/png': '.png',
1269
+ 'image/jpeg': '.jpg',
1270
+ 'image/gif': '.gif',
1271
+ 'image/webp': '.webp',
1272
+ 'image/svg+xml': '.svg',
1273
+ 'image/bmp': '.bmp',
1274
+ 'application/pdf': '.pdf',
1275
+ 'text/plain': '.txt',
1276
+ 'application/json': '.json',
1277
+ 'application/zip': '.zip',
1278
+ 'audio/mpeg': '.mp3',
1279
+ 'video/mp4': '.mp4',
1280
+ };
1281
+ return mimeToExt[mime] || '.bin';
1282
+ }
1283
+ return '.bin';
1284
+ }
1285
+ }
1286
+
1287
+ module.exports = ChatManager;