aicq-openclaw 3.16.3

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