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/.codex-plugin/plugin.json +4 -0
- package/LICENSE +21 -0
- package/README.md +59 -0
- package/lib/chat.js +1287 -0
- package/lib/crypto.js +129 -0
- package/lib/database.js +470 -0
- package/lib/handshake.js +200 -0
- package/lib/identity.js +165 -0
- package/lib/package.json +3 -0
- package/lib/server-client.js +405 -0
- package/mcp/server.js +455 -0
- package/mcp.json +16 -0
- package/package.json +56 -0
- package/plugin.json +21 -0
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;
|