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.
@@ -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
- module.exports = function registerSocket(server, historyService, batchDownloadService) {
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 chatRoom = 'group-chat';
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 getClientIp = (socket) => {
28
- const address = socket.handshake.address || socket.request.connection.remoteAddress;
53
+ const emitPresenceChanged = (type, user, roomId, sourceSocket = null) => {
54
+ const payload = { type, user, roomId, at: Date.now() };
29
55
 
30
- return address ? address.replace(/^::ffff:/, '') : '';
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 broadcastUserList = () => {
34
- io.to(chatRoom).emit('user_list_updated', {
35
- users: roomManager.listUsers()
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(chatRoom).emit('history_cleared', payload);
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
- io.to(receiver.socketId).emit('history_cleared', payload);
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.username = username;
84
- socket.join(chatRoom);
85
- roomManager.setOnline({
86
- username,
87
- socketId: socket.id,
88
- ip: getClientIp(socket)
89
- });
90
- broadcastUserList();
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
- socket.to(receiver.socketId).emit('reply_private_chat', savedMessage);
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.join(chatRoom);
125
- socket.username = username;
126
- roomManager.setOnline({
508
+ registerUserOnline(socket, {
509
+ roomId: DEFAULT_ROOM_ID,
127
510
  username,
128
- socketId: socket.id,
129
- nickname,
130
- ip: getClientIp(socket)
131
- });
132
- socket.emit('group_name_updated', {
133
- groupName: roomManager.getGroupName(),
134
- updatedBy: ''
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(sender, nickname);
141
- broadcastUserList();
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
- roomManager.setGroupName(groupName);
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(params, roomManager.getUser(params.sender));
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(chatRoom).emit('reply_group_chat', savedMessage);
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
- chatType,
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: conversation.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
- roomManager.setOffline(socket.username);
291
- broadcastUserList();
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
  };