chattransfer 2.0.17 → 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,11 +5,17 @@ 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) {
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
  }
16
+ if (!batchDownloadService) {
17
+ throw new Error('batchDownloadService is required');
18
+ }
13
19
 
14
20
  const io = socketIo(server, {
15
21
  cors: {
@@ -19,18 +25,168 @@ module.exports = function registerSocket(server, historyService) {
19
25
  pingTimeout: 30000,
20
26
  pingInterval: 25000
21
27
  });
22
- 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
+ };
23
52
 
24
- const getClientIp = (socket) => {
25
- 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() };
26
55
 
27
- 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);
28
62
  };
29
63
 
30
- const broadcastUserList = () => {
31
- io.to(chatRoom).emit('user_list_updated', {
32
- users: roomManager.listUsers()
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
33
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
+ }
128
+ };
129
+
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)
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:/, '') : '';
34
190
  };
35
191
 
36
192
  const trackMessage = (type, direction, params, socket) => {
@@ -53,18 +209,76 @@ module.exports = function registerSocket(server, historyService) {
53
209
  }
54
210
  };
55
211
 
56
- 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
+
57
222
  const payload = { chatType, targetUser, sessionId };
58
223
 
59
224
  if (chatType === 'group') {
60
- io.to(chatRoom).emit('history_cleared', payload);
225
+ io.to(socketRoomName(DEFAULT_ROOM_ID)).emit('history_cleared', payload);
61
226
  return;
62
227
  }
63
228
 
64
229
  sourceSocket.emit('history_cleared', payload);
65
- const receiver = roomManager.getUser(targetUser);
230
+ const receiver = roomManager.getUser(DEFAULT_ROOM_ID, targetUser);
66
231
  if (receiver && receiver.status === roomManager.getOnline()) {
67
- 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);
68
282
  }
69
283
  };
70
284
 
@@ -73,22 +287,194 @@ module.exports = function registerSocket(server, historyService) {
73
287
 
74
288
  socket.on('login', ({ uid } = {}) => {
75
289
  console.log(`[Socket] login: ${uid}`);
290
+ if (uid) socket.username = uid;
76
291
  });
77
292
 
78
- socket.on('online', (username) => {
293
+ socket.on('online', async (username) => {
79
294
  console.log(`[Socket] online: ${username} ${socket.id}`);
80
- socket.username = username;
81
- socket.join(chatRoom);
82
- roomManager.setOnline({
83
- username,
84
- socketId: socket.id,
85
- ip: getClientIp(socket)
86
- });
87
- 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
+ }
88
474
  });
89
475
 
90
476
  socket.on('private_chat', async (params, ack) => {
91
- const decorated = decoratePrivateMessage(params, roomManager.getUser(params.sender));
477
+ const decorated = decoratePrivateMessage(params, roomManager.getUser(DEFAULT_ROOM_ID, params.sender));
92
478
  const { message: savedMessage, error } = await persistMessage(decorated);
93
479
 
94
480
  if (typeof ack === 'function') {
@@ -99,10 +485,11 @@ module.exports = function registerSocket(server, historyService) {
99
485
 
100
486
  trackMessage('private', 'send', decorated, socket);
101
487
 
102
- const receiver = roomManager.getUser(savedMessage.receiver);
103
-
488
+ const receiver = roomManager.getUser(DEFAULT_ROOM_ID, savedMessage.receiver);
104
489
  if (receiver && receiver.status === roomManager.getOnline()) {
105
- 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
+ }
106
493
 
107
494
  trackEvent('message_action', {
108
495
  chat_type: 'private',
@@ -118,36 +505,36 @@ module.exports = function registerSocket(server, historyService) {
118
505
 
119
506
  socket.on('group_online', (username, nickname) => {
120
507
  console.log(`[Socket] group_online: ${username} ${socket.id} nickname=${nickname}`);
121
- socket.join(chatRoom);
122
- socket.username = username;
123
- roomManager.setOnline({
508
+ registerUserOnline(socket, {
509
+ roomId: DEFAULT_ROOM_ID,
124
510
  username,
125
- socketId: socket.id,
126
- nickname,
127
- ip: getClientIp(socket)
128
- });
129
- socket.emit('group_name_updated', {
130
- groupName: roomManager.getGroupName(),
131
- updatedBy: ''
132
- });
133
- 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));
134
518
  });
135
519
 
136
520
  socket.on('update_nickname', ({ sender, nickname }) => {
137
- roomManager.setNickname(sender, nickname);
138
- broadcastUserList();
521
+ roomManager.setNickname({
522
+ roomId: socket.activeRoomId || DEFAULT_ROOM_ID,
523
+ username: sender,
524
+ nickname
525
+ });
526
+ broadcastRoomUserList(socket.activeRoomId || DEFAULT_ROOM_ID);
139
527
  });
140
528
 
141
529
  socket.on('update_group_name', ({ groupName, updatedBy }) => {
142
- roomManager.setGroupName(groupName);
143
- io.to(chatRoom).emit('group_name_updated', {
144
- groupName: roomManager.getGroupName(),
145
- updatedBy: updatedBy || ''
146
- });
530
+ console.warn('[Room] Public Lobby name cannot be changed');
147
531
  });
148
532
 
149
533
  socket.on('group_chat', async (params, ack) => {
150
- const decorated = decorateGroupMessage(params, roomManager.getUser(params.sender));
534
+ const decorated = decorateGroupMessage(
535
+ params,
536
+ roomManager.getUser(DEFAULT_ROOM_ID, params.sender)
537
+ );
151
538
  const { message: savedMessage, error } = await persistMessage(decorated);
152
539
 
153
540
  if (typeof ack === 'function') {
@@ -157,7 +544,7 @@ module.exports = function registerSocket(server, historyService) {
157
544
  if (error) return;
158
545
 
159
546
  trackMessage('group', 'send', decorated, socket);
160
- socket.broadcast.to(chatRoom).emit('reply_group_chat', savedMessage);
547
+ socket.broadcast.to(socketRoomName(DEFAULT_ROOM_ID)).emit('reply_group_chat', savedMessage);
161
548
 
162
549
  trackEvent('message_action', {
163
550
  chat_type: 'group',
@@ -167,16 +554,57 @@ module.exports = function registerSocket(server, historyService) {
167
554
  });
168
555
  });
169
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
+
170
596
  socket.on('history_query', async (params = {}, ack) => {
171
597
  if (typeof ack !== 'function') return;
172
598
 
173
599
  try {
174
600
  const requester = socket.username || params.requester;
601
+ const roomId = params.roomId;
175
602
  const chatType = params.chatType || (
176
603
  params.targetUser === 'group_chat' ? 'group' : 'private'
177
604
  );
178
605
  const data = await historyService.queryHistory({
179
606
  requester,
607
+ roomId,
180
608
  chatType,
181
609
  targetUser: params.targetUser,
182
610
  cursor: params.cursor,
@@ -190,29 +618,89 @@ module.exports = function registerSocket(server, historyService) {
190
618
  }
191
619
  });
192
620
 
193
- socket.on('history_clear', async (params = {}, ack) => {
621
+ socket.on('attachment_query', async (params = {}, ack) => {
194
622
  if (typeof ack !== 'function') return;
195
623
 
196
624
  try {
197
625
  const requester = socket.username || params.requester;
626
+ const roomId = params.roomId;
198
627
  const chatType = params.chatType || (
199
628
  params.targetUser === 'group_chat' ? 'group' : 'private'
200
629
  );
201
- const conversation = historyService.getConversation({
630
+ const { attachments } = await historyService.queryAttachments({
202
631
  requester,
632
+ roomId,
203
633
  chatType,
204
634
  targetUser: params.targetUser
205
635
  });
636
+
637
+ ack({
638
+ code: 0,
639
+ data: {
640
+ attachments: attachments.map((attachment) => ({
641
+ msgId: attachment.msgId,
642
+ seq: attachment.seq,
643
+ sender: attachment.sender,
644
+ senderNickname: attachment.senderNickname,
645
+ msgType: attachment.msgType,
646
+ createTime: attachment.create_time || attachment.createTime,
647
+ createdAt: attachment.createdAt,
648
+ fileName: attachment.fileName,
649
+ fileSize: attachment.fileSize,
650
+ fileType: attachment.fileType,
651
+ fileUrl: attachment.fileUrl,
652
+ available: attachment.available
653
+ }))
654
+ }
655
+ });
656
+ } catch (error) {
657
+ console.error('[Attachment] query failed:', error.message);
658
+ ack({ code: -1, message: 'Failed to load attachments' });
659
+ }
660
+ });
661
+
662
+ socket.on('attachment_download', async (params = {}, ack) => {
663
+ if (typeof ack !== 'function') return;
664
+
665
+ try {
666
+ const requester = socket.username || params.requester;
667
+ const data = await batchDownloadService.createDownloadToken({
668
+ requester,
669
+ roomId: params.roomId,
670
+ chatType: params.chatType,
671
+ targetUser: params.targetUser,
672
+ messageIds: params.messageIds
673
+ });
674
+
675
+ ack({ code: 0, data });
676
+ } catch (error) {
677
+ console.error('[Attachment] download request failed:', error.message);
678
+ ack({ code: -1, message: error.message });
679
+ }
680
+ });
681
+
682
+ socket.on('history_clear', async (params = {}, ack) => {
683
+ if (typeof ack !== 'function') return;
684
+
685
+ try {
686
+ const requester = socket.username || params.requester;
687
+ const roomId = params.roomId;
688
+ if (roomId) requireActiveRoom(socket, roomId);
689
+ const chatType = params.chatType || (
690
+ params.targetUser === 'group_chat' ? 'group' : 'private'
691
+ );
206
692
  const data = await historyService.clearHistory({
207
693
  requester,
694
+ roomId,
208
695
  chatType,
209
696
  targetUser: params.targetUser
210
697
  });
211
698
 
212
699
  broadcastHistoryCleared({
700
+ roomId,
213
701
  chatType,
214
702
  targetUser: params.targetUser,
215
- sessionId: conversation.sessionId
703
+ sessionId: data.sessionId
216
704
  }, socket);
217
705
  ack({ code: 0, data });
218
706
  } catch (error) {
@@ -221,12 +709,65 @@ module.exports = function registerSocket(server, historyService) {
221
709
  }
222
710
  });
223
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
+
224
733
  socket.on('disconnect', () => {
225
734
  console.log(`[Socket] disconnect: ${socket.username || 'unknown'} ${socket.id}`);
226
- roomManager.setOffline(socket.username);
227
- 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
+ }
228
749
  });
229
750
  });
230
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;
231
772
  return io;
232
773
  };