chattransfer 2.0.18 → 3.0.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/frontend/dist/assets/{browser-CiFpIcIA.js → browser-_rMkfdfG.js} +1 -1
- package/frontend/dist/assets/{index-PrmIL31V.js → index-BTJ_Fb6p.js} +42 -42
- package/frontend/dist/assets/{index-DDcqIagG.css → index-IvWY8A4p.css} +1 -1
- package/frontend/dist/index.html +2 -2
- package/package.json +1 -1
- package/server/cli-options.js +9 -1
- package/server/index.js +15 -1
- package/server/room-manager.js +117 -25
- package/server/routes/qr.js +10 -2
- package/server/services/batch-download-service.js +15 -1
- package/server/services/message-history-service.js +684 -20
- package/server/socket/index.js +536 -59
- package/server/utils/network.js +11 -1
- package/server/utils/runtime-options.js +30 -0
package/server/socket/index.js
CHANGED
|
@@ -5,8 +5,11 @@ const {
|
|
|
5
5
|
decorateGroupMessage,
|
|
6
6
|
decoratePrivateMessage
|
|
7
7
|
} = require('../services/message-service');
|
|
8
|
+
const { isLoopbackAddress } = require('../utils/network');
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
const DEFAULT_ROOM_ID = 'group_chat';
|
|
11
|
+
|
|
12
|
+
module.exports = function registerSocket(server, historyService, batchDownloadService, options = {}) {
|
|
10
13
|
if (!historyService) {
|
|
11
14
|
throw new Error('historyService is required');
|
|
12
15
|
}
|
|
@@ -22,18 +25,168 @@ module.exports = function registerSocket(server, historyService, batchDownloadSe
|
|
|
22
25
|
pingTimeout: 30000,
|
|
23
26
|
pingInterval: 25000
|
|
24
27
|
});
|
|
25
|
-
const
|
|
28
|
+
const presenceOfflineDebounceMs = Number.isFinite(options.presenceOfflineDebounceMs)
|
|
29
|
+
? Math.max(0, options.presenceOfflineDebounceMs)
|
|
30
|
+
: 8000;
|
|
31
|
+
const roomIdleRetentionDays = Number.isFinite(options.roomIdleRetentionDays)
|
|
32
|
+
? Math.max(0, options.roomIdleRetentionDays)
|
|
33
|
+
: 7;
|
|
34
|
+
const roomIdleCleanupIntervalMs = Number.isFinite(options.roomIdleCleanupIntervalMs)
|
|
35
|
+
&& options.roomIdleCleanupIntervalMs > 0
|
|
36
|
+
? options.roomIdleCleanupIntervalMs
|
|
37
|
+
: 6 * 60 * 60 * 1000;
|
|
38
|
+
const offlineTimers = new Map();
|
|
39
|
+
const joinAttempts = new Map();
|
|
40
|
+
let roomIdleCleanupTimer = null;
|
|
41
|
+
|
|
42
|
+
const socketRoomName = (roomId = DEFAULT_ROOM_ID) => (
|
|
43
|
+
roomId === DEFAULT_ROOM_ID ? 'group-chat' : `room:${roomId}`
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
const broadcastRoomUserList = (roomId = DEFAULT_ROOM_ID) => {
|
|
47
|
+
io.to(socketRoomName(roomId)).emit('user_list_updated', {
|
|
48
|
+
roomId,
|
|
49
|
+
users: roomManager.listUsers(roomId)
|
|
50
|
+
});
|
|
51
|
+
};
|
|
26
52
|
|
|
27
|
-
const
|
|
28
|
-
const
|
|
53
|
+
const emitPresenceChanged = (type, user, roomId, sourceSocket = null) => {
|
|
54
|
+
const payload = { type, user, roomId, at: Date.now() };
|
|
29
55
|
|
|
30
|
-
|
|
56
|
+
if (type === 'online' && sourceSocket) {
|
|
57
|
+
sourceSocket.broadcast.to(socketRoomName(roomId)).emit('presence_changed', payload);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
io.to(socketRoomName(roomId)).emit('presence_changed', payload);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const clearOfflineTimer = (roomId, username) => {
|
|
65
|
+
const key = `${roomId}:${username}`;
|
|
66
|
+
const timer = offlineTimers.get(key);
|
|
67
|
+
if (!timer) return;
|
|
68
|
+
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
offlineTimers.delete(key);
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const clearRoomTimers = (roomId) => {
|
|
74
|
+
for (const [key, timer] of offlineTimers.entries()) {
|
|
75
|
+
if (!key.startsWith(`${roomId}:`)) continue;
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
offlineTimers.delete(key);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const requireActiveRoom = (socket, roomId) => {
|
|
82
|
+
if (!roomId || roomId !== socket.activeRoomId) {
|
|
83
|
+
throw new Error('Join the room before managing it');
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const scheduleOffline = ({ roomId, username }) => {
|
|
88
|
+
clearOfflineTimer(roomId, username);
|
|
89
|
+
const key = `${roomId}:${username}`;
|
|
90
|
+
const timer = setTimeout(() => {
|
|
91
|
+
offlineTimers.delete(key);
|
|
92
|
+
const currentUser = roomManager.getUser(roomId, username);
|
|
93
|
+
if (currentUser?.status === roomManager.getOnline() && currentUser.socketIds.size > 0) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const result = roomManager.markOffline({ roomId, username });
|
|
97
|
+
if (!result.becameOffline) return;
|
|
98
|
+
|
|
99
|
+
broadcastRoomUserList(roomId);
|
|
100
|
+
emitPresenceChanged('offline', result.user, roomId);
|
|
101
|
+
}, presenceOfflineDebounceMs);
|
|
102
|
+
|
|
103
|
+
offlineTimers.set(key, timer);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const leaveActiveRoom = (socket, { immediateOffline = false } = {}) => {
|
|
107
|
+
const roomId = socket.activeRoomId;
|
|
108
|
+
if (!roomId) return;
|
|
109
|
+
|
|
110
|
+
socket.leave(socketRoomName(roomId));
|
|
111
|
+
socket.activeRoomId = null;
|
|
112
|
+
const result = roomManager.removeSocket({
|
|
113
|
+
roomId,
|
|
114
|
+
username: socket.username,
|
|
115
|
+
socketId: socket.id
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
if (result.user && !result.hasConnections && immediateOffline) {
|
|
119
|
+
clearOfflineTimer(roomId, socket.username);
|
|
120
|
+
const offline = roomManager.markOffline({ roomId, username: socket.username });
|
|
121
|
+
if (offline.becameOffline) {
|
|
122
|
+
broadcastRoomUserList(roomId);
|
|
123
|
+
emitPresenceChanged('offline', offline.user, roomId);
|
|
124
|
+
}
|
|
125
|
+
} else if (result.user && !result.hasConnections) {
|
|
126
|
+
scheduleOffline({ roomId, username: socket.username });
|
|
127
|
+
}
|
|
31
128
|
};
|
|
32
129
|
|
|
33
|
-
const
|
|
34
|
-
io.
|
|
35
|
-
|
|
130
|
+
const detachRoomSockets = (roomId) => {
|
|
131
|
+
const socketIds = io.sockets.adapter.rooms.get(socketRoomName(roomId));
|
|
132
|
+
if (socketIds) {
|
|
133
|
+
for (const socketId of [...socketIds]) {
|
|
134
|
+
const memberSocket = io.sockets.sockets.get(socketId);
|
|
135
|
+
if (!memberSocket) continue;
|
|
136
|
+
memberSocket.leave(socketRoomName(roomId));
|
|
137
|
+
if (memberSocket.activeRoomId === roomId) {
|
|
138
|
+
memberSocket.activeRoomId = null;
|
|
139
|
+
}
|
|
140
|
+
roomManager.removeSocket({
|
|
141
|
+
roomId,
|
|
142
|
+
username: memberSocket.username,
|
|
143
|
+
socketId: memberSocket.id
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
clearRoomTimers(roomId);
|
|
149
|
+
roomManager.removeRoom(roomId);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const registerUserOnline = async (socket, {
|
|
153
|
+
roomId = DEFAULT_ROOM_ID,
|
|
154
|
+
code = null,
|
|
155
|
+
nickname = '',
|
|
156
|
+
username = null
|
|
157
|
+
} = {}) => {
|
|
158
|
+
const userId = username || socket.username;
|
|
159
|
+
if (!userId) throw new Error('User is required');
|
|
160
|
+
|
|
161
|
+
socket.username = userId;
|
|
162
|
+
const joinedRoom = await historyService.joinRoom({ roomId, code, userId, nickname });
|
|
163
|
+
leaveActiveRoom(socket);
|
|
164
|
+
|
|
165
|
+
socket.join(socketRoomName(roomId));
|
|
166
|
+
socket.activeRoomId = roomId;
|
|
167
|
+
const result = roomManager.setOnline({
|
|
168
|
+
roomId,
|
|
169
|
+
username: userId,
|
|
170
|
+
socketId: socket.id,
|
|
171
|
+
nickname,
|
|
172
|
+
ip: getClientIp(socket)
|
|
36
173
|
});
|
|
174
|
+
|
|
175
|
+
const room = joinedRoom || await historyService.getRoom(roomId);
|
|
176
|
+
if (room) roomManager.setRoomName(roomId, room.name);
|
|
177
|
+
broadcastRoomUserList(roomId);
|
|
178
|
+
|
|
179
|
+
if (result.becameOnline) {
|
|
180
|
+
emitPresenceChanged('online', result.user, roomId, socket);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { ...result, room };
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
const getClientIp = (socket) => {
|
|
187
|
+
const address = socket.handshake.address || socket.request.connection.remoteAddress;
|
|
188
|
+
|
|
189
|
+
return address ? address.replace(/^::ffff:/, '') : '';
|
|
37
190
|
};
|
|
38
191
|
|
|
39
192
|
const trackMessage = (type, direction, params, socket) => {
|
|
@@ -56,18 +209,76 @@ module.exports = function registerSocket(server, historyService, batchDownloadSe
|
|
|
56
209
|
}
|
|
57
210
|
};
|
|
58
211
|
|
|
59
|
-
const broadcastHistoryCleared = ({ chatType, targetUser, sessionId }, sourceSocket) => {
|
|
212
|
+
const broadcastHistoryCleared = ({ roomId = null, chatType = null, targetUser = null, sessionId = null }, sourceSocket) => {
|
|
213
|
+
if (roomId) {
|
|
214
|
+
io.to(socketRoomName(roomId)).emit('history_cleared', {
|
|
215
|
+
roomId,
|
|
216
|
+
chatType: 'room',
|
|
217
|
+
sessionId
|
|
218
|
+
});
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
60
222
|
const payload = { chatType, targetUser, sessionId };
|
|
61
223
|
|
|
62
224
|
if (chatType === 'group') {
|
|
63
|
-
io.to(
|
|
225
|
+
io.to(socketRoomName(DEFAULT_ROOM_ID)).emit('history_cleared', payload);
|
|
64
226
|
return;
|
|
65
227
|
}
|
|
66
228
|
|
|
67
229
|
sourceSocket.emit('history_cleared', payload);
|
|
68
|
-
const receiver = roomManager.getUser(targetUser);
|
|
230
|
+
const receiver = roomManager.getUser(DEFAULT_ROOM_ID, targetUser);
|
|
69
231
|
if (receiver && receiver.status === roomManager.getOnline()) {
|
|
70
|
-
|
|
232
|
+
for (const socketId of receiver.socketIds) {
|
|
233
|
+
io.to(socketId).emit('history_cleared', payload);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const checkJoinRateLimit = (socket) => {
|
|
239
|
+
const key = socket.handshake.address || socket.id;
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
const attempts = (joinAttempts.get(key) || []).filter((at) => now - at < 60000);
|
|
242
|
+
|
|
243
|
+
if (attempts.length >= 10) {
|
|
244
|
+
throw new Error('Too many join attempts');
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
attempts.push(now);
|
|
248
|
+
joinAttempts.set(key, attempts);
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
const runIdleRoomCleanup = async () => {
|
|
252
|
+
if (roomIdleRetentionDays <= 0) return;
|
|
253
|
+
|
|
254
|
+
try {
|
|
255
|
+
const now = Date.now();
|
|
256
|
+
const cutoff = now - (roomIdleRetentionDays * 24 * 60 * 60 * 1000);
|
|
257
|
+
const candidates = await historyService.listInactiveRooms({
|
|
258
|
+
retentionDays: roomIdleRetentionDays,
|
|
259
|
+
now
|
|
260
|
+
});
|
|
261
|
+
for (const room of candidates) {
|
|
262
|
+
const hasOnlineUsers = roomManager.listUsers(room.id)
|
|
263
|
+
.some((user) => user.status === roomManager.getOnline());
|
|
264
|
+
if (hasOnlineUsers) continue;
|
|
265
|
+
|
|
266
|
+
const deleted = await historyService.deleteRoomBySystem({
|
|
267
|
+
roomId: room.id,
|
|
268
|
+
reason: 'inactive',
|
|
269
|
+
cutoff
|
|
270
|
+
});
|
|
271
|
+
if (!deleted) continue;
|
|
272
|
+
|
|
273
|
+
batchDownloadService.revokeRoom(room.id);
|
|
274
|
+
io.to(socketRoomName(room.id)).emit('room_deleted', {
|
|
275
|
+
roomId: room.id,
|
|
276
|
+
reason: 'inactive'
|
|
277
|
+
});
|
|
278
|
+
detachRoomSockets(room.id);
|
|
279
|
+
}
|
|
280
|
+
} catch (error) {
|
|
281
|
+
console.error('[Room] idle cleanup failed:', error.message);
|
|
71
282
|
}
|
|
72
283
|
};
|
|
73
284
|
|
|
@@ -76,22 +287,194 @@ module.exports = function registerSocket(server, historyService, batchDownloadSe
|
|
|
76
287
|
|
|
77
288
|
socket.on('login', ({ uid } = {}) => {
|
|
78
289
|
console.log(`[Socket] login: ${uid}`);
|
|
290
|
+
if (uid) socket.username = uid;
|
|
79
291
|
});
|
|
80
292
|
|
|
81
|
-
socket.on('online', (username) => {
|
|
293
|
+
socket.on('online', async (username) => {
|
|
82
294
|
console.log(`[Socket] online: ${username} ${socket.id}`);
|
|
83
|
-
socket
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
295
|
+
await registerUserOnline(socket, { roomId: DEFAULT_ROOM_ID, username });
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
socket.on('room_online', async (params = {}, ack) => {
|
|
299
|
+
try {
|
|
300
|
+
const result = await registerUserOnline(socket, params);
|
|
301
|
+
ack?.({ code: 0, data: { room: result.room } });
|
|
302
|
+
} catch (error) {
|
|
303
|
+
console.error('[Room] online failed:', error.message);
|
|
304
|
+
ack?.({ code: -1, message: error.message });
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
socket.on('room_create', async (params = {}, ack) => {
|
|
309
|
+
if (typeof ack !== 'function') return;
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
if (!socket.username) throw new Error('User is required');
|
|
313
|
+
const room = await historyService.createRoom({
|
|
314
|
+
name: params.name,
|
|
315
|
+
createdBy: socket.username,
|
|
316
|
+
nickname: params.nickname || ''
|
|
317
|
+
});
|
|
318
|
+
await historyService.joinRoom({
|
|
319
|
+
roomId: room.id,
|
|
320
|
+
code: room.code,
|
|
321
|
+
userId: socket.username,
|
|
322
|
+
nickname: params.nickname || ''
|
|
323
|
+
});
|
|
324
|
+
roomManager.setRoomName(room.id, room.name);
|
|
325
|
+
|
|
326
|
+
ack({ code: 0, data: { room } });
|
|
327
|
+
} catch (error) {
|
|
328
|
+
console.error('[Room] create failed:', error.message);
|
|
329
|
+
ack({ code: -1, message: error.message });
|
|
330
|
+
}
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
socket.on('room_join_by_code', async (params = {}, ack) => {
|
|
334
|
+
if (typeof ack !== 'function') return;
|
|
335
|
+
|
|
336
|
+
try {
|
|
337
|
+
if (!socket.username) throw new Error('User is required');
|
|
338
|
+
checkJoinRateLimit(socket);
|
|
339
|
+
const room = await historyService.getRoomByCode(params.code);
|
|
340
|
+
if (!room) throw new Error('Room not found');
|
|
341
|
+
|
|
342
|
+
await historyService.joinRoom({
|
|
343
|
+
roomId: room.id,
|
|
344
|
+
code: params.code,
|
|
345
|
+
userId: socket.username,
|
|
346
|
+
nickname: params.nickname || ''
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
ack({ code: 0, data: { room } });
|
|
350
|
+
} catch (error) {
|
|
351
|
+
console.error('[Room] join failed:', error.message);
|
|
352
|
+
ack({ code: -1, message: error.message });
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
socket.on('room_list', async (params = {}, ack) => {
|
|
357
|
+
if (typeof ack !== 'function') return;
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
if (!socket.username) throw new Error('User is required');
|
|
361
|
+
const rooms = await historyService.listRoomsForUser(socket.username);
|
|
362
|
+
const data = rooms.map((room) => ({
|
|
363
|
+
...room,
|
|
364
|
+
onlineCount: roomManager.listUsers(room.id)
|
|
365
|
+
.filter((user) => user.status === roomManager.getOnline())
|
|
366
|
+
.length
|
|
367
|
+
}));
|
|
368
|
+
|
|
369
|
+
ack({ code: 0, data });
|
|
370
|
+
} catch (error) {
|
|
371
|
+
console.error('[Room] list failed:', error.message);
|
|
372
|
+
ack({ code: -1, message: error.message });
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
socket.on('room_update_name', async (params = {}, ack) => {
|
|
377
|
+
if (typeof ack !== 'function') return;
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const roomId = params.roomId || socket.activeRoomId;
|
|
381
|
+
requireActiveRoom(socket, roomId);
|
|
382
|
+
const room = await historyService.updateRoomName({
|
|
383
|
+
roomId,
|
|
384
|
+
name: params.name,
|
|
385
|
+
requester: socket.username
|
|
386
|
+
});
|
|
387
|
+
roomManager.setRoomName(roomId, room.name);
|
|
388
|
+
io.to(socketRoomName(roomId)).emit('room_updated', { room });
|
|
389
|
+
|
|
390
|
+
ack({ code: 0, data: { room } });
|
|
391
|
+
} catch (error) {
|
|
392
|
+
console.error('[Room] rename failed:', error.message);
|
|
393
|
+
ack({ code: -1, message: error.message });
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
socket.on('room_regenerate_code', async (params = {}, ack) => {
|
|
398
|
+
if (typeof ack !== 'function') return;
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
const roomId = params.roomId || socket.activeRoomId;
|
|
402
|
+
requireActiveRoom(socket, roomId);
|
|
403
|
+
const room = await historyService.regenerateRoomCode({
|
|
404
|
+
roomId,
|
|
405
|
+
requester: socket.username
|
|
406
|
+
});
|
|
407
|
+
io.to(socketRoomName(roomId)).emit('room_updated', { room });
|
|
408
|
+
|
|
409
|
+
ack({ code: 0, data: { room } });
|
|
410
|
+
} catch (error) {
|
|
411
|
+
console.error('[Room] regenerate code failed:', error.message);
|
|
412
|
+
ack({ code: -1, message: error.message });
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
socket.on('room_leave', async (params = {}, ack) => {
|
|
417
|
+
if (typeof ack !== 'function') return;
|
|
418
|
+
|
|
419
|
+
try {
|
|
420
|
+
const roomId = params.roomId || socket.activeRoomId;
|
|
421
|
+
requireActiveRoom(socket, roomId);
|
|
422
|
+
const data = await historyService.leaveRoom({
|
|
423
|
+
roomId,
|
|
424
|
+
userId: socket.username
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
if (data.dissolved) {
|
|
428
|
+
batchDownloadService.revokeRoom(roomId);
|
|
429
|
+
io.to(socketRoomName(roomId)).emit('room_deleted', {
|
|
430
|
+
roomId,
|
|
431
|
+
reason: 'last_member_left'
|
|
432
|
+
});
|
|
433
|
+
detachRoomSockets(roomId);
|
|
434
|
+
} else {
|
|
435
|
+
if (data.ownerChanged) {
|
|
436
|
+
io.to(socketRoomName(roomId)).emit('room_owner_changed', {
|
|
437
|
+
roomId,
|
|
438
|
+
ownerId: data.newOwnerId
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
leaveActiveRoom(socket, { immediateOffline: true });
|
|
442
|
+
broadcastRoomUserList(roomId);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
ack({ code: 0, data });
|
|
446
|
+
} catch (error) {
|
|
447
|
+
console.error('[Room] leave failed:', error.message);
|
|
448
|
+
ack({ code: -1, message: error.message });
|
|
449
|
+
}
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
socket.on('room_dissolve', async (params = {}, ack) => {
|
|
453
|
+
if (typeof ack !== 'function') return;
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
const roomId = params.roomId || socket.activeRoomId;
|
|
457
|
+
requireActiveRoom(socket, roomId);
|
|
458
|
+
const data = await historyService.dissolveRoom({
|
|
459
|
+
roomId,
|
|
460
|
+
requester: socket.username
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
batchDownloadService.revokeRoom(roomId);
|
|
464
|
+
io.to(socketRoomName(roomId)).emit('room_deleted', {
|
|
465
|
+
roomId,
|
|
466
|
+
reason: 'owner_dissolved'
|
|
467
|
+
});
|
|
468
|
+
detachRoomSockets(roomId);
|
|
469
|
+
ack({ code: 0, data });
|
|
470
|
+
} catch (error) {
|
|
471
|
+
console.error('[Room] dissolve failed:', error.message);
|
|
472
|
+
ack({ code: -1, message: error.message });
|
|
473
|
+
}
|
|
91
474
|
});
|
|
92
475
|
|
|
93
476
|
socket.on('private_chat', async (params, ack) => {
|
|
94
|
-
const decorated = decoratePrivateMessage(params, roomManager.getUser(params.sender));
|
|
477
|
+
const decorated = decoratePrivateMessage(params, roomManager.getUser(DEFAULT_ROOM_ID, params.sender));
|
|
95
478
|
const { message: savedMessage, error } = await persistMessage(decorated);
|
|
96
479
|
|
|
97
480
|
if (typeof ack === 'function') {
|
|
@@ -102,10 +485,11 @@ module.exports = function registerSocket(server, historyService, batchDownloadSe
|
|
|
102
485
|
|
|
103
486
|
trackMessage('private', 'send', decorated, socket);
|
|
104
487
|
|
|
105
|
-
const receiver = roomManager.getUser(savedMessage.receiver);
|
|
106
|
-
|
|
488
|
+
const receiver = roomManager.getUser(DEFAULT_ROOM_ID, savedMessage.receiver);
|
|
107
489
|
if (receiver && receiver.status === roomManager.getOnline()) {
|
|
108
|
-
|
|
490
|
+
for (const socketId of receiver.socketIds) {
|
|
491
|
+
io.to(socketId).emit('reply_private_chat', savedMessage);
|
|
492
|
+
}
|
|
109
493
|
|
|
110
494
|
trackEvent('message_action', {
|
|
111
495
|
chat_type: 'private',
|
|
@@ -121,36 +505,36 @@ module.exports = function registerSocket(server, historyService, batchDownloadSe
|
|
|
121
505
|
|
|
122
506
|
socket.on('group_online', (username, nickname) => {
|
|
123
507
|
console.log(`[Socket] group_online: ${username} ${socket.id} nickname=${nickname}`);
|
|
124
|
-
socket
|
|
125
|
-
|
|
126
|
-
roomManager.setOnline({
|
|
508
|
+
registerUserOnline(socket, {
|
|
509
|
+
roomId: DEFAULT_ROOM_ID,
|
|
127
510
|
username,
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
});
|
|
136
|
-
broadcastUserList();
|
|
511
|
+
nickname
|
|
512
|
+
}).then(() => {
|
|
513
|
+
socket.emit('group_name_updated', {
|
|
514
|
+
groupName: roomManager.getRoomName(DEFAULT_ROOM_ID),
|
|
515
|
+
updatedBy: ''
|
|
516
|
+
});
|
|
517
|
+
}).catch((error) => console.error('[Room] legacy online failed:', error.message));
|
|
137
518
|
});
|
|
138
519
|
|
|
139
520
|
socket.on('update_nickname', ({ sender, nickname }) => {
|
|
140
|
-
roomManager.setNickname(
|
|
141
|
-
|
|
521
|
+
roomManager.setNickname({
|
|
522
|
+
roomId: socket.activeRoomId || DEFAULT_ROOM_ID,
|
|
523
|
+
username: sender,
|
|
524
|
+
nickname
|
|
525
|
+
});
|
|
526
|
+
broadcastRoomUserList(socket.activeRoomId || DEFAULT_ROOM_ID);
|
|
142
527
|
});
|
|
143
528
|
|
|
144
529
|
socket.on('update_group_name', ({ groupName, updatedBy }) => {
|
|
145
|
-
|
|
146
|
-
io.to(chatRoom).emit('group_name_updated', {
|
|
147
|
-
groupName: roomManager.getGroupName(),
|
|
148
|
-
updatedBy: updatedBy || ''
|
|
149
|
-
});
|
|
530
|
+
console.warn('[Room] Public Lobby name cannot be changed');
|
|
150
531
|
});
|
|
151
532
|
|
|
152
533
|
socket.on('group_chat', async (params, ack) => {
|
|
153
|
-
const decorated = decorateGroupMessage(
|
|
534
|
+
const decorated = decorateGroupMessage(
|
|
535
|
+
params,
|
|
536
|
+
roomManager.getUser(DEFAULT_ROOM_ID, params.sender)
|
|
537
|
+
);
|
|
154
538
|
const { message: savedMessage, error } = await persistMessage(decorated);
|
|
155
539
|
|
|
156
540
|
if (typeof ack === 'function') {
|
|
@@ -160,7 +544,7 @@ module.exports = function registerSocket(server, historyService, batchDownloadSe
|
|
|
160
544
|
if (error) return;
|
|
161
545
|
|
|
162
546
|
trackMessage('group', 'send', decorated, socket);
|
|
163
|
-
socket.broadcast.to(
|
|
547
|
+
socket.broadcast.to(socketRoomName(DEFAULT_ROOM_ID)).emit('reply_group_chat', savedMessage);
|
|
164
548
|
|
|
165
549
|
trackEvent('message_action', {
|
|
166
550
|
chat_type: 'group',
|
|
@@ -170,16 +554,57 @@ module.exports = function registerSocket(server, historyService, batchDownloadSe
|
|
|
170
554
|
});
|
|
171
555
|
});
|
|
172
556
|
|
|
557
|
+
socket.on('room_chat', async (params = {}, ack) => {
|
|
558
|
+
try {
|
|
559
|
+
const roomId = params.roomId || socket.activeRoomId;
|
|
560
|
+
if (!roomId || roomId !== socket.activeRoomId) {
|
|
561
|
+
throw new Error('Join the room before sending messages');
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const messageData = {
|
|
565
|
+
...params,
|
|
566
|
+
roomId,
|
|
567
|
+
receiver: roomId
|
|
568
|
+
};
|
|
569
|
+
const decorated = decorateGroupMessage(
|
|
570
|
+
messageData,
|
|
571
|
+
roomManager.getUser(roomId, params.sender)
|
|
572
|
+
);
|
|
573
|
+
const { message: savedMessage, error } = await persistMessage(decorated);
|
|
574
|
+
|
|
575
|
+
if (typeof ack === 'function') {
|
|
576
|
+
ack(error ? { ...decorated, status: 'failed', error: 'Message persistence failed' } : savedMessage);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (error) return;
|
|
580
|
+
|
|
581
|
+
trackMessage('room', 'send', decorated, socket);
|
|
582
|
+
socket.broadcast.to(socketRoomName(roomId)).emit('reply_room_chat', savedMessage);
|
|
583
|
+
|
|
584
|
+
trackEvent('message_action', {
|
|
585
|
+
chat_type: 'room',
|
|
586
|
+
msg_type: decorated.msgType || 'TEXT',
|
|
587
|
+
direction: 'broadcast',
|
|
588
|
+
user_agent: socket.handshake.headers['user-agent']
|
|
589
|
+
});
|
|
590
|
+
} catch (error) {
|
|
591
|
+
console.error('[Room] message failed:', error.message);
|
|
592
|
+
ack?.({ status: 'failed', error: error.message });
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
173
596
|
socket.on('history_query', async (params = {}, ack) => {
|
|
174
597
|
if (typeof ack !== 'function') return;
|
|
175
598
|
|
|
176
599
|
try {
|
|
177
600
|
const requester = socket.username || params.requester;
|
|
601
|
+
const roomId = params.roomId;
|
|
178
602
|
const chatType = params.chatType || (
|
|
179
603
|
params.targetUser === 'group_chat' ? 'group' : 'private'
|
|
180
604
|
);
|
|
181
605
|
const data = await historyService.queryHistory({
|
|
182
606
|
requester,
|
|
607
|
+
roomId,
|
|
183
608
|
chatType,
|
|
184
609
|
targetUser: params.targetUser,
|
|
185
610
|
cursor: params.cursor,
|
|
@@ -198,11 +623,13 @@ module.exports = function registerSocket(server, historyService, batchDownloadSe
|
|
|
198
623
|
|
|
199
624
|
try {
|
|
200
625
|
const requester = socket.username || params.requester;
|
|
626
|
+
const roomId = params.roomId;
|
|
201
627
|
const chatType = params.chatType || (
|
|
202
628
|
params.targetUser === 'group_chat' ? 'group' : 'private'
|
|
203
629
|
);
|
|
204
630
|
const { attachments } = await historyService.queryAttachments({
|
|
205
631
|
requester,
|
|
632
|
+
roomId,
|
|
206
633
|
chatType,
|
|
207
634
|
targetUser: params.targetUser
|
|
208
635
|
});
|
|
@@ -216,7 +643,7 @@ module.exports = function registerSocket(server, historyService, batchDownloadSe
|
|
|
216
643
|
sender: attachment.sender,
|
|
217
644
|
senderNickname: attachment.senderNickname,
|
|
218
645
|
msgType: attachment.msgType,
|
|
219
|
-
createTime: attachment.createTime,
|
|
646
|
+
createTime: attachment.create_time || attachment.createTime,
|
|
220
647
|
createdAt: attachment.createdAt,
|
|
221
648
|
fileName: attachment.fileName,
|
|
222
649
|
fileSize: attachment.fileSize,
|
|
@@ -237,12 +664,10 @@ module.exports = function registerSocket(server, historyService, batchDownloadSe
|
|
|
237
664
|
|
|
238
665
|
try {
|
|
239
666
|
const requester = socket.username || params.requester;
|
|
240
|
-
const chatType = params.chatType || (
|
|
241
|
-
params.targetUser === 'group_chat' ? 'group' : 'private'
|
|
242
|
-
);
|
|
243
667
|
const data = await batchDownloadService.createDownloadToken({
|
|
244
668
|
requester,
|
|
245
|
-
|
|
669
|
+
roomId: params.roomId,
|
|
670
|
+
chatType: params.chatType,
|
|
246
671
|
targetUser: params.targetUser,
|
|
247
672
|
messageIds: params.messageIds
|
|
248
673
|
});
|
|
@@ -259,24 +684,23 @@ module.exports = function registerSocket(server, historyService, batchDownloadSe
|
|
|
259
684
|
|
|
260
685
|
try {
|
|
261
686
|
const requester = socket.username || params.requester;
|
|
687
|
+
const roomId = params.roomId;
|
|
688
|
+
if (roomId) requireActiveRoom(socket, roomId);
|
|
262
689
|
const chatType = params.chatType || (
|
|
263
690
|
params.targetUser === 'group_chat' ? 'group' : 'private'
|
|
264
691
|
);
|
|
265
|
-
const conversation = historyService.getConversation({
|
|
266
|
-
requester,
|
|
267
|
-
chatType,
|
|
268
|
-
targetUser: params.targetUser
|
|
269
|
-
});
|
|
270
692
|
const data = await historyService.clearHistory({
|
|
271
693
|
requester,
|
|
694
|
+
roomId,
|
|
272
695
|
chatType,
|
|
273
696
|
targetUser: params.targetUser
|
|
274
697
|
});
|
|
275
698
|
|
|
276
699
|
broadcastHistoryCleared({
|
|
700
|
+
roomId,
|
|
277
701
|
chatType,
|
|
278
702
|
targetUser: params.targetUser,
|
|
279
|
-
sessionId:
|
|
703
|
+
sessionId: data.sessionId
|
|
280
704
|
}, socket);
|
|
281
705
|
ack({ code: 0, data });
|
|
282
706
|
} catch (error) {
|
|
@@ -285,12 +709,65 @@ module.exports = function registerSocket(server, historyService, batchDownloadSe
|
|
|
285
709
|
}
|
|
286
710
|
});
|
|
287
711
|
|
|
712
|
+
socket.on('public_room_admin_clear', async (params = {}, ack) => {
|
|
713
|
+
if (typeof ack !== 'function') return;
|
|
714
|
+
|
|
715
|
+
try {
|
|
716
|
+
const clientIp = getClientIp(socket);
|
|
717
|
+
if (!isLoopbackAddress(clientIp)) {
|
|
718
|
+
throw new Error('Administrator access is only available from localhost');
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
const data = await historyService.clearPublicRoomHistory();
|
|
722
|
+
broadcastHistoryCleared({
|
|
723
|
+
roomId: DEFAULT_ROOM_ID,
|
|
724
|
+
sessionId: data.sessionId
|
|
725
|
+
}, socket);
|
|
726
|
+
ack({ code: 0, data });
|
|
727
|
+
} catch (error) {
|
|
728
|
+
console.error('[History] public room admin clear failed:', error.message);
|
|
729
|
+
ack({ code: -1, message: error.message });
|
|
730
|
+
}
|
|
731
|
+
});
|
|
732
|
+
|
|
288
733
|
socket.on('disconnect', () => {
|
|
289
734
|
console.log(`[Socket] disconnect: ${socket.username || 'unknown'} ${socket.id}`);
|
|
290
|
-
|
|
291
|
-
|
|
735
|
+
if (!socket.activeRoomId || !socket.username) return;
|
|
736
|
+
|
|
737
|
+
const result = roomManager.removeSocket({
|
|
738
|
+
roomId: socket.activeRoomId,
|
|
739
|
+
username: socket.username,
|
|
740
|
+
socketId: socket.id
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
if (result.user && !result.hasConnections) {
|
|
744
|
+
scheduleOffline({
|
|
745
|
+
roomId: socket.activeRoomId,
|
|
746
|
+
username: socket.username
|
|
747
|
+
});
|
|
748
|
+
}
|
|
292
749
|
});
|
|
293
750
|
});
|
|
294
751
|
|
|
752
|
+
const idleCleanupReady = roomIdleRetentionDays > 0
|
|
753
|
+
? runIdleRoomCleanup()
|
|
754
|
+
: Promise.resolve();
|
|
755
|
+
if (roomIdleRetentionDays > 0) {
|
|
756
|
+
roomIdleCleanupTimer = setInterval(() => {
|
|
757
|
+
void runIdleRoomCleanup();
|
|
758
|
+
}, roomIdleCleanupIntervalMs);
|
|
759
|
+
roomIdleCleanupTimer.unref?.();
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
io.on('close', () => {
|
|
763
|
+
for (const timer of offlineTimers.values()) clearTimeout(timer);
|
|
764
|
+
offlineTimers.clear();
|
|
765
|
+
if (roomIdleCleanupTimer) {
|
|
766
|
+
clearInterval(roomIdleCleanupTimer);
|
|
767
|
+
roomIdleCleanupTimer = null;
|
|
768
|
+
}
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
io.idleCleanupReady = idleCleanupReady;
|
|
295
772
|
return io;
|
|
296
773
|
};
|