@vibexnpm/talkx 8.4.1 → 8.6.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.
@@ -9,6 +9,15 @@ import MediaStreamManager from './MediaStreamManager.js';
9
9
  import PeerConnectionManager from './PeerConnectionManager.js';
10
10
  import { WebSocketPaths, SignalTypes, ErrorTypes, DefaultConfig, LogLevel } from '../constants.js';
11
11
 
12
+ /** 1:1 통화 제어 신호. 받는 쪽은 roomId 가 없어야 1:1 신호로 받는다(_acceptsSignal). */
13
+ const DIRECT_CONTROL_SIGNALS = new Set([
14
+ SignalTypes.CALL_REQUEST, SignalTypes.CALL_ACCEPT, SignalTypes.CALL_REJECT,
15
+ SignalTypes.CALL_CANCEL, SignalTypes.CALL_BUSY
16
+ ]);
17
+
18
+ /** 받은 초대·내가 연 방은 응답 전까지만 의미가 있다. 오래된 것부터 잊는다. */
19
+ const MAX_REMEMBERED_CALL_ROOMS = 32;
20
+
12
21
  class WebRTCClient extends EventEmitter {
13
22
  /**
14
23
  * @param {Object} options
@@ -39,6 +48,25 @@ class WebRTCClient extends EventEmitter {
39
48
  this.currentRoom = null;
40
49
  this.isGroupCall = false;
41
50
  this.participants = new Map();
51
+ this._callActive = false;
52
+ this._callGeneration = 0;
53
+ this._deferredGroupPeers = new Map();
54
+ this._joinedGroupRoom = null;
55
+ this._groupJoin = null;
56
+ this._directCallPeer = null;
57
+ this._incomingCallPeer = null;
58
+ // 서버(REST)가 roomId 를 붙여 보내는 그룹 초대 취소·거절을 받을 방. callRoomId -> host / 나
59
+ this._receivedInvitations = new Map();
60
+ this._hostedCallRooms = new Map();
61
+ // 서버는 생성 응답 전에 초대를 보낸다(CreateCallRoomService). 응답보다 먼저 온 거절은 응답을 받을 때까지 보관한다.
62
+ this._pendingRoomCreations = 0;
63
+ this._earlyRoomRejects = [];
64
+ this._groupRecoveryTimer = null;
65
+ this._groupRecoveryAttempts = 0;
66
+ this._onReconnected = () => {
67
+ this._recoverGroupParticipation().catch(error => this.logger.error('Call recovery failed:', error));
68
+ };
69
+ this.connectionManager.on?.('reconnected', this._onReconnected);
42
70
 
43
71
  // ICE 서버 초기화 상태
44
72
  this._iceServersFetched = false;
@@ -152,14 +180,26 @@ class WebRTCClient extends EventEmitter {
152
180
  * 통화방 생성
153
181
  * @public
154
182
  * @param {Object} data
155
- * @param {string} data.roomName - 방 이름
156
- * @param {boolean} data.isGroup - 그룹 통화 여부
157
- * @param {string} [data.chatRoomId] - 연결할 채팅방 ID
183
+ * @param {string[]} [data.invitedUsers] - 초대할 사용자 ID
184
+ * @param {string} [data.title] - 통화방 제목
185
+ * @param {number} [data.maxParticipants] - 최대 참가자 수 (생략 시 서버 기본값)
158
186
  * @returns {Promise<Object>}
159
187
  */
160
188
  // noinspection JSUnusedGlobalSymbols
161
189
  async createCallRoom(data) {
162
- return this.apiClient.post('/api/v1/webrtc/rooms', data);
190
+ this._pendingRoomCreations++;
191
+ try {
192
+ const response = await this.apiClient.post('/api/v1/webrtc/rooms', data);
193
+ const callRoomId = response?.data?.callRoomId;
194
+ if (callRoomId) this._rememberCallRoom(this._hostedCallRooms, callRoomId, this.userId);
195
+ return response;
196
+ } finally {
197
+ this._pendingRoomCreations--;
198
+ // 보관한 거절을 같은 경로로 다시 판정한다. 연 방의 것은 전달되고, 진행 중인 생성이 남았으면 다시 보관되며, 아니면 버려진다.
199
+ for (const message of this._earlyRoomRejects.splice(0)) {
200
+ this._handleSignal(message).catch(error => this.logger.error('Early rejection handling failed:', error));
201
+ }
202
+ }
163
203
  }
164
204
 
165
205
  /**
@@ -181,7 +221,20 @@ class WebRTCClient extends EventEmitter {
181
221
  */
182
222
  // noinspection JSUnusedGlobalSymbols
183
223
  async joinCallRoomApi(roomId) {
184
- return this.apiClient.post(`/api/v1/webrtc/rooms/${roomId}/join`);
224
+ // REST 응답보다 PEER_JOINED에 대한 offer가 먼저 도착할 수 있다.
225
+ // 로컬에서 요청한 방만 허용하고, 실패·종료 뒤에는 이 권한을 남기지 않는다.
226
+ const join = { roomId, generation: this._callGeneration };
227
+ this._groupJoin = join;
228
+ try {
229
+ const response = await this.apiClient.post(`/api/v1/webrtc/rooms/${roomId}/join`);
230
+ if (this._groupJoin === join && this._callGeneration === join.generation) this._joinedGroupRoom = roomId;
231
+ return response;
232
+ } catch (error) {
233
+ if (this._groupJoin === join && this.currentRoom !== roomId) this._clearDeferredGroup(roomId);
234
+ throw error;
235
+ } finally {
236
+ if (this._groupJoin === join) this._groupJoin = null;
237
+ }
185
238
  }
186
239
 
187
240
  /**
@@ -192,7 +245,11 @@ class WebRTCClient extends EventEmitter {
192
245
  */
193
246
  // noinspection JSUnusedGlobalSymbols
194
247
  async leaveCallRoomApi(roomId) {
195
- return this.apiClient.delete(`/api/v1/webrtc/rooms/${roomId}/leave`);
248
+ const response = await this.apiClient.post(`/api/v1/webrtc/rooms/${roomId}/leave`);
249
+ if (this._joinedGroupRoom === roomId) this._joinedGroupRoom = null;
250
+ if (this._groupJoin?.roomId === roomId) this._groupJoin = null;
251
+ if (this.currentRoom !== roomId) this._clearDeferredGroup(roomId);
252
+ return response;
196
253
  }
197
254
 
198
255
  // ==================== 통화 수신 대기 ====================
@@ -251,21 +308,40 @@ class WebRTCClient extends EventEmitter {
251
308
  */
252
309
  async startCall(options) {
253
310
  const { roomId, isGroup = false, mediaConstraints = { video: true, audio: true } } = options;
311
+ if (this._pendingStart) throw new Error('A call is already starting');
312
+ // 조기 offer의 peer 존재만으로 막지 않는다. 이미 소유한 통화만 보호한다.
313
+ if (this.currentRoom !== null || this._callActive || this._waitingForCallAccept) {
314
+ throw new Error('A call is already active');
315
+ }
316
+ this._callGeneration++;
317
+ this._cancelGroupRecoveryRetry();
318
+ this._groupRecoveryAttempts = 0;
319
+ this._groupRecovery = null;
320
+ const start = { cancelled: false };
321
+ this._pendingStart = start;
322
+ const checkCancelled = () => { if (start.cancelled) throw new Error('Call start cancelled'); };
254
323
 
255
324
  try {
256
- // TURN 서버 설정 초기화
257
- await this.initializeIceServers();
258
-
259
- // 로컬 미디어 획득
260
- const localStream = await this.mediaManager.getUserMedia(mediaConstraints);
261
- this.peerManager.setLocalStream(localStream);
262
-
263
- // 현재 방 설정
264
325
  this.currentRoom = roomId;
265
326
  this.isGroupCall = isGroup;
266
-
267
- // WebSocket 구독
327
+ for (const deferredRoom of new Set(this._deferredGroupPeers.values())) {
328
+ if (!isGroup || deferredRoom !== roomId) this._clearDeferredGroup(deferredRoom);
329
+ }
330
+ // 그룹 참가 임대를 먼저 연결한다. 권한 팝업/TURN 조회가 90초를 넘어도 갱신된다.
268
331
  await this._subscribeToSignaling(roomId, isGroup);
332
+ checkCancelled();
333
+ await this.initializeIceServers();
334
+ checkCancelled();
335
+ const localStream = await this.mediaManager.getUserMedia(mediaConstraints);
336
+ checkCancelled();
337
+ // 먼저 수신한 offer의 peer에도 트랙을 추가하고 재협상한다.
338
+ // 권한 대기 중 시그널을 버리면 상대의 기존 peer가 offer를 다시 보내지 않을 수 있다.
339
+ this.peerManager.setLocalStream(localStream);
340
+ for (const peerId of this._deferredGroupPeers.keys()) {
341
+ await this._resumeGroupNegotiation(peerId);
342
+ checkCancelled();
343
+ }
344
+ this._callActive = true;
269
345
 
270
346
  // 입장 시그널 전송
271
347
  this._sendSignal({
@@ -279,6 +355,19 @@ class WebRTCClient extends EventEmitter {
279
355
  return localStream;
280
356
 
281
357
  } catch (error) {
358
+ if (isGroup) this.connectionManager.unsubscribe(WebSocketPaths.getWebRTCDestination(roomId));
359
+ this._unsubscribeFromSignaling();
360
+ this.mediaManager.stopAll();
361
+ this.peerManager.closeAllPeerConnections();
362
+ this.peerManager.setLocalStream(null);
363
+ this.participants.clear();
364
+ this._pendingIceCandidates.clear();
365
+ this._deferredGroupPeers.clear();
366
+ this._callActive = false;
367
+ this.currentRoom = null;
368
+ this.isGroupCall = false;
369
+ this._joinedGroupRoom = null;
370
+ this._groupJoin = null;
282
371
  this.logger.error('Failed to start call:', error);
283
372
  this.emit('error', {
284
373
  type: ErrorTypes.PEER_CONNECTION_FAILED,
@@ -286,6 +375,8 @@ class WebRTCClient extends EventEmitter {
286
375
  error
287
376
  });
288
377
  throw error;
378
+ } finally {
379
+ this._pendingStart = null;
289
380
  }
290
381
  }
291
382
 
@@ -308,8 +399,10 @@ class WebRTCClient extends EventEmitter {
308
399
 
309
400
  // CALL_ACCEPT 대기 상태 설정
310
401
  // PeerConnection은 CALL_ACCEPT를 받은 후에 생성하여 자동 negotiation 방지
402
+ this._callActive = true;
311
403
  this._waitingForCallAccept = true;
312
404
  this._pendingCallTarget = targetUserId;
405
+ this._incomingCallPeer = null;
313
406
 
314
407
  // 통화 요청 시그널
315
408
  this._sendSignal({
@@ -347,6 +440,8 @@ class WebRTCClient extends EventEmitter {
347
440
  // 피어 연결 생성 (Polite - 수신자)
348
441
  // skipAutoNegotiation: true - 수신자는 offer를 받아서 answer를 보내므로 자동 negotiation 불필요
349
442
  this.peerManager.createPeerConnection(callerId, true, { skipAutoNegotiation: true });
443
+ this._directCallPeer = callerId;
444
+ this._incomingCallPeer = null;
350
445
 
351
446
  // 수락 시그널
352
447
  this._sendSignal({
@@ -354,6 +449,7 @@ class WebRTCClient extends EventEmitter {
354
449
  receiverId: callerId
355
450
  });
356
451
 
452
+ this._callActive = true;
357
453
  this.emit('callAccepted', { callerId });
358
454
 
359
455
  } catch (error) {
@@ -367,6 +463,7 @@ class WebRTCClient extends EventEmitter {
367
463
  * @param {string} callerId - 발신자 ID
368
464
  */
369
465
  rejectCall(callerId) {
466
+ if (this._incomingCallPeer === callerId) this._incomingCallPeer = null;
370
467
  this._sendSignal({
371
468
  type: SignalTypes.CALL_REJECT,
372
469
  receiverId: callerId
@@ -392,6 +489,7 @@ class WebRTCClient extends EventEmitter {
392
489
  this._pendingCallTarget = null;
393
490
 
394
491
  // 미디어 정리
492
+ this._callActive = false;
395
493
  this.mediaManager.stopAll();
396
494
  }
397
495
  }
@@ -400,8 +498,22 @@ class WebRTCClient extends EventEmitter {
400
498
  * 통화 종료
401
499
  */
402
500
  endCall() {
501
+ this._endCall(true);
502
+ }
503
+
504
+ _endCall(notifyPeers) {
505
+ this._callGeneration++;
506
+ this._cancelGroupRecoveryRetry();
507
+ this._groupRecoveryAttempts = 0;
508
+ this._groupRecovery = null;
509
+ this._callActive = false;
510
+ this._deferredGroupPeers.clear();
511
+ this._joinedGroupRoom = null;
512
+ this._groupJoin = null;
513
+ this._incomingCallPeer = null;
514
+ if (this._pendingStart) this._pendingStart.cancelled = true;
403
515
  // 대기 중인 통화가 있으면 취소 처리
404
- if (this._waitingForCallAccept && this._pendingCallTarget) {
516
+ if (notifyPeers && this._waitingForCallAccept && this._pendingCallTarget) {
405
517
  this._sendSignal({
406
518
  type: SignalTypes.CALL_CANCEL,
407
519
  receiverId: this._pendingCallTarget
@@ -414,7 +526,7 @@ class WebRTCClient extends EventEmitter {
414
526
 
415
527
  if (this.currentRoom) {
416
528
  // 퇴장 시그널 전송
417
- this._sendSignal({
529
+ if (notifyPeers) this._sendSignal({
418
530
  type: SignalTypes.LEAVE_ROOM,
419
531
  roomId: this.currentRoom
420
532
  });
@@ -424,15 +536,18 @@ class WebRTCClient extends EventEmitter {
424
536
  }
425
537
 
426
538
  // 모든 피어에게 종료 시그널
427
- this.peerManager.getPeerIds().forEach(peerId => {
539
+ if (notifyPeers) this.peerManager.getPeerIds().forEach(peerId => {
428
540
  this._sendSignal({
429
541
  type: SignalTypes.CALL_END,
430
542
  receiverId: peerId
431
543
  });
432
544
  });
545
+ // 1:1 상대에게 가는 CALL_END 에 그룹 roomId 가 붙지 않도록 보낸 뒤에 지운다(_groupRoomFor).
546
+ this._directCallPeer = null;
433
547
 
434
548
  // 정리
435
549
  this.peerManager.closeAllPeerConnections();
550
+ this.peerManager.setLocalStream(null);
436
551
  this.mediaManager.stopAll();
437
552
  this.participants.clear();
438
553
  this._pendingIceCandidates.clear();
@@ -664,7 +779,7 @@ class WebRTCClient extends EventEmitter {
664
779
  _sendSignal(signal) {
665
780
  const payload = {
666
781
  type: signal.type,
667
- roomId: signal.roomId || this.currentRoom,
782
+ roomId: signal.roomId || this._groupRoomFor(signal),
668
783
  receiverId: signal.receiverId,
669
784
  data: signal.data
670
785
  };
@@ -673,6 +788,17 @@ class WebRTCClient extends EventEmitter {
673
788
  this.logger.debug('Signal sent:', payload);
674
789
  }
675
790
 
791
+ /**
792
+ * 그룹 roomId 는 그룹 상대·그룹 방 신호에만 붙인다.
793
+ * 1:1 신호에 붙으면 상대가 방 신호로 보고 버린다(그룹 통화 중 자동 BUSY 유실).
794
+ * @private
795
+ */
796
+ _groupRoomFor({ type, receiverId }) {
797
+ if (DIRECT_CONTROL_SIGNALS.has(type)) return undefined;
798
+ if (receiverId && receiverId === this._directCallPeer) return undefined;
799
+ return this.currentRoom || this._deferredGroupPeers.get(receiverId);
800
+ }
801
+
676
802
  /**
677
803
  * 미디어 상태 전송
678
804
  * @private
@@ -707,12 +833,15 @@ class WebRTCClient extends EventEmitter {
707
833
  return;
708
834
  }
709
835
 
836
+ if (this._holdsEarlyRoomReject(message)) return;
837
+ if (!this._acceptsSignal(message)) return;
838
+
710
839
  this.logger.debug(`Signal received: ${type} from ${senderId}`);
711
840
 
712
841
  switch (type) {
713
842
  case SignalTypes.JOIN_ROOM:
714
843
  case SignalTypes.PEER_JOINED:
715
- await this._handleUserJoined(senderId);
844
+ await this._handleUserJoined(senderId, message.roomId);
716
845
  break;
717
846
 
718
847
  case SignalTypes.LEAVE_ROOM:
@@ -721,7 +850,7 @@ class WebRTCClient extends EventEmitter {
721
850
  break;
722
851
 
723
852
  case SignalTypes.CALL_OFFER:
724
- await this._handleOffer(senderId, data.sdp);
853
+ await this._handleOffer(senderId, data.sdp, message.roomId);
725
854
  break;
726
855
 
727
856
  case SignalTypes.CALL_ANSWER:
@@ -746,6 +875,7 @@ class WebRTCClient extends EventEmitter {
746
875
  });
747
876
  this.emit('incomingCallWhileBusy', { callerId: senderId });
748
877
  } else {
878
+ this._incomingCallPeer = senderId;
749
879
  this.emit('incomingCall', { callerId: senderId });
750
880
  }
751
881
  break;
@@ -754,35 +884,53 @@ class WebRTCClient extends EventEmitter {
754
884
  // 상대방이 통화 중
755
885
  this._waitingForCallAccept = false;
756
886
  this._pendingCallTarget = null;
887
+ this._callActive = false;
757
888
  this.mediaManager.stopAll();
758
889
  this.emit('callBusy', { userId: senderId });
759
890
  break;
760
891
 
761
- case SignalTypes.CALL_INVITATION:
892
+ case SignalTypes.CALL_INVITATION: {
762
893
  // 그룹 통화 초대 (createCallRoom으로 초대받은 경우)
894
+ const callRoomId = data?.callRoomId || message.roomId;
895
+ // 취소 수락 기준은 서버가 채운 roomId·senderId(host)다. data 는 보낸 쪽이 정한 값이라 기억 키로 쓰지 않는다.
896
+ if (message.roomId) this._rememberCallRoom(this._receivedInvitations, message.roomId, senderId);
763
897
  this.emit('callInvitation', {
764
- callRoomId: data?.callRoomId || message.roomId,
898
+ callRoomId,
765
899
  title: data?.title,
766
900
  hostUserId: data?.hostUserId || senderId,
767
901
  maxParticipants: data?.maxParticipants,
768
902
  createdAt: data?.createdAt
769
903
  });
770
904
  break;
905
+ }
771
906
 
772
907
  case SignalTypes.CALL_ACCEPT:
773
908
  await this._handleCallAccepted(senderId);
774
909
  break;
775
910
 
776
911
  case SignalTypes.CALL_REJECT:
912
+ if (message.roomId) {
913
+ // 그룹 초대 거절(서버 REST 발신). host 의 통화·미디어는 그대로 둔다.
914
+ this.emit('callRejected', { userId: senderId, roomId: message.roomId });
915
+ break;
916
+ }
777
917
  // 대기 상태 초기화
778
918
  this._waitingForCallAccept = false;
779
919
  this._pendingCallTarget = null;
920
+ this._callActive = false;
780
921
  // 발신자 미디어 정리 (거절당했으므로 통화 불가)
781
922
  this.mediaManager.stopAll();
782
923
  this.emit('callRejected', { userId: senderId });
783
924
  break;
784
925
 
785
926
  case SignalTypes.CALL_CANCEL:
927
+ if (message.roomId) {
928
+ // 그룹 초대 취소(서버 REST 발신). 1:1 대기 상태는 건드리지 않는다.
929
+ this._receivedInvitations.delete(message.roomId);
930
+ this.emit('callCancelled', { userId: senderId, roomId: message.roomId });
931
+ break;
932
+ }
933
+ this._incomingCallPeer = null;
786
934
  // 대기 상태 초기화
787
935
  this._waitingForCallAccept = false;
788
936
  this._pendingCallTarget = null;
@@ -790,7 +938,11 @@ class WebRTCClient extends EventEmitter {
790
938
  break;
791
939
 
792
940
  case SignalTypes.CALL_END:
793
- this._handleCallEnded(senderId);
941
+ if (data?.callEndScope === 'ROOM') {
942
+ this._endCall(false); // _acceptsSignal에서 현재/참가 요청한 그룹 방 일치를 확인했다.
943
+ } else {
944
+ this._handleCallEnded(senderId);
945
+ }
794
946
  break;
795
947
 
796
948
  default:
@@ -798,17 +950,65 @@ class WebRTCClient extends EventEmitter {
798
950
  }
799
951
  }
800
952
 
953
+ /** 미디어를 공유할 상대/방은 로컬 참가·발신·수락으로만 결정한다. */
954
+ _acceptsSignal({ type, senderId, roomId }) {
955
+ if (!senderId) return false;
956
+ if (type === SignalTypes.CALL_INVITATION) return true;
957
+ if (type === SignalTypes.CALL_REQUEST) return !roomId;
958
+ // 그룹 초대 거절·취소는 서버가 roomId 를 붙여 보낸다. 내가 연 방·받은 초대의 host 것만 받는다.
959
+ if (roomId && type === SignalTypes.CALL_REJECT) return this._hostedCallRooms.has(roomId);
960
+ if (roomId && type === SignalTypes.CALL_CANCEL) return this._receivedInvitations.get(roomId) === senderId;
961
+ if ([SignalTypes.CALL_ACCEPT, SignalTypes.CALL_BUSY, SignalTypes.CALL_REJECT].includes(type)) {
962
+ return !roomId && this._waitingForCallAccept && senderId === this._pendingCallTarget;
963
+ }
964
+ if (type === SignalTypes.CALL_CANCEL) return !roomId && senderId === this._incomingCallPeer;
965
+ // 수락한 1:1 상대의 신호는 roomId 없이 온다. 그룹 통화 중에 수락했어도 받는다.
966
+ if (!roomId && senderId === this._directCallPeer) return true;
967
+ const groupRoom = this.isGroupCall ? this.currentRoom
968
+ : (!this._callActive && !this._waitingForCallAccept ? this._groupJoin?.roomId || this._joinedGroupRoom : null);
969
+ if (groupRoom) return roomId === groupRoom;
970
+ return !roomId && senderId === this._pendingCallTarget;
971
+ }
972
+
973
+ /** 방 생성 응답 전에 온 그룹 거절은 응답의 방 ID 로 판정할 수 있을 때까지 보관한다. */
974
+ _holdsEarlyRoomReject(message) {
975
+ const { type, roomId } = message;
976
+ if (type !== SignalTypes.CALL_REJECT || !roomId || this._pendingRoomCreations === 0
977
+ || this._hostedCallRooms.has(roomId)) return false;
978
+ this._earlyRoomRejects.push(message);
979
+ if (this._earlyRoomRejects.length > MAX_REMEMBERED_CALL_ROOMS) this._earlyRoomRejects.shift();
980
+ return true;
981
+ }
982
+
983
+ _rememberCallRoom(rooms, roomId, userId) {
984
+ rooms.delete(roomId);
985
+ rooms.set(roomId, userId);
986
+ if (rooms.size > MAX_REMEMBERED_CALL_ROOMS) rooms.delete(rooms.keys().next().value);
987
+ }
988
+
989
+ _clearDeferredGroup(roomId) {
990
+ for (const [peerId, peerRoom] of this._deferredGroupPeers) {
991
+ if (peerRoom !== roomId) continue;
992
+ this.peerManager.closePeerConnection(peerId);
993
+ this.participants.delete(peerId);
994
+ this._pendingIceCandidates.delete(peerId);
995
+ this._deferredGroupPeers.delete(peerId);
996
+ }
997
+ }
998
+
801
999
  /**
802
1000
  * 사용자 입장 처리
803
1001
  * @private
804
1002
  */
805
- async _handleUserJoined(userId) {
1003
+ async _handleUserJoined(userId, roomId) {
806
1004
  this.participants.set(userId, { joinedAt: new Date() });
807
1005
 
808
1006
  // userId 비교로 Polite/Impolite 결정 (작은 쪽이 Polite)
809
1007
  // Perfect Negotiation에서 충돌 시 한 쪽만 양보하도록 보장
810
1008
  const polite = this.userId < userId;
811
- this.peerManager.createPeerConnection(userId, polite);
1009
+ const defer = !!roomId && !this.peerManager.localStream;
1010
+ this.peerManager.createPeerConnection(userId, polite, { skipAutoNegotiation: defer });
1011
+ if (defer) this._deferredGroupPeers.set(userId, roomId);
812
1012
 
813
1013
  this.emit('userJoined', { userId });
814
1014
  this.logger.info(`User joined: ${userId} (I am ${polite ? 'polite' : 'impolite'})`);
@@ -830,7 +1030,7 @@ class WebRTCClient extends EventEmitter {
830
1030
  * Offer 처리
831
1031
  * @private
832
1032
  */
833
- async _handleOffer(senderId, sdp) {
1033
+ async _handleOffer(senderId, sdp, roomId = this.isGroupCall ? this.currentRoom : null) {
834
1034
  // 피어 연결이 없으면 생성
835
1035
  if (!this.peerManager.getPeerConnection(senderId)) {
836
1036
  // userId 비교로 Polite/Impolite 결정
@@ -838,14 +1038,69 @@ class WebRTCClient extends EventEmitter {
838
1038
  // skipAutoNegotiation: true - offer를 먼저 처리한 후 answer 전송
839
1039
  // 트랙 추가 시 자동 negotiation이 발생하면 offer 충돌 발생
840
1040
  this.peerManager.createPeerConnection(senderId, polite, { skipAutoNegotiation: true });
1041
+ // REST join의 PEER_JOINED는 startCall보다 먼저 올 수 있다.
1042
+ // 메시지의 roomId로 그룹을 식별하되, 1:1 수동 협상에는 적용하지 않는다.
1043
+ if (roomId && !this.peerManager.localStream) this._deferredGroupPeers.set(senderId, roomId);
841
1044
  }
842
1045
 
843
1046
  await this.peerManager.handleRemoteDescription(senderId, sdp);
1047
+ await this._resumeGroupNegotiation(senderId);
844
1048
 
845
1049
  // remoteDescription 설정 후 대기 중인 ICE candidates 처리
846
1050
  await this._processPendingIceCandidates(senderId);
847
1051
  }
848
1052
 
1053
+ async _resumeGroupNegotiation(peerId) {
1054
+ if (!this.isGroupCall || this._deferredGroupPeers.get(peerId) !== this.currentRoom
1055
+ || !this.peerManager.localStream) return;
1056
+ if (await this.peerManager.resumeAutoNegotiation(peerId)) this._deferredGroupPeers.delete(peerId);
1057
+ }
1058
+
1059
+ /** 재구독은 참가 이력을 복구하지 않는다. 동일 사용자의 다른 기기 참가도 이 조회에 반영된다. */
1060
+ async _recoverGroupParticipation() {
1061
+ if (!this.isGroupCall || !this.currentRoom || this._groupRecovery) return;
1062
+ this._cancelGroupRecoveryRetry();
1063
+ const roomId = this.currentRoom;
1064
+ const generation = this._callGeneration;
1065
+ const stillCurrent = () => this.currentRoom === roomId && this._callGeneration === generation;
1066
+ const recovery = (async () => {
1067
+ try {
1068
+ const response = await this.apiClient.get(`/api/v1/webrtc/rooms/${roomId}/participation`);
1069
+ if (!stillCurrent()) return;
1070
+ if (!response.data.participating) {
1071
+ await this.joinCallRoomApi(roomId);
1072
+ if (!stillCurrent()) return;
1073
+ this._sendSignal({ type: SignalTypes.JOIN_ROOM, roomId });
1074
+ }
1075
+ this._groupRecoveryAttempts = 0;
1076
+ } catch (error) {
1077
+ if (!stillCurrent()) return;
1078
+ if ([403, 404, 409].includes(error?.status ?? error?.response?.status)) {
1079
+ // 확정 실패만 로컬 종료한다. 다른 기기 참가를 지우는 LEAVE는 보내지 않는다.
1080
+ this._endCall(false);
1081
+ this.emit('callRecoveryFailed', { roomId, error });
1082
+ } else {
1083
+ // 일시 오류 동안 미디어·peer·구독을 유지하고 1s→2s→…→30s 간격으로 재시도한다.
1084
+ const delay = Math.min(30000, 1000 * 2 ** Math.min(this._groupRecoveryAttempts++, 5));
1085
+ this._groupRecoveryTimer = setTimeout(() => {
1086
+ this._groupRecoveryTimer = null;
1087
+ if (stillCurrent()) this._onReconnected();
1088
+ }, delay);
1089
+ this._groupRecoveryTimer.unref?.();
1090
+ this.logger.warn('Call participation recovery will retry:', error);
1091
+ }
1092
+ }
1093
+ })();
1094
+ this._groupRecovery = recovery;
1095
+ try { await recovery; }
1096
+ finally { if (this._groupRecovery === recovery) this._groupRecovery = null; }
1097
+ }
1098
+
1099
+ _cancelGroupRecoveryRetry() {
1100
+ clearTimeout(this._groupRecoveryTimer);
1101
+ this._groupRecoveryTimer = null;
1102
+ }
1103
+
849
1104
  /**
850
1105
  * Answer 처리
851
1106
  * @private
@@ -922,6 +1177,7 @@ class WebRTCClient extends EventEmitter {
922
1177
  * @private
923
1178
  */
924
1179
  async _handleCallAccepted(userId) {
1180
+ this._directCallPeer = userId;
925
1181
  // 대기 상태 초기화
926
1182
  this._waitingForCallAccept = false;
927
1183
  this._pendingCallTarget = null;
@@ -948,6 +1204,8 @@ class WebRTCClient extends EventEmitter {
948
1204
  _handleCallEnded(userId) {
949
1205
  this.peerManager.closePeerConnection(userId);
950
1206
  this.participants.delete(userId);
1207
+ // 끝난 1:1 상대는 더 이상 선택한 상대가 아니다. 그룹 통화가 남아도 그의 roomId 없는 신호를 받지 않는다.
1208
+ if (userId === this._directCallPeer) this._directCallPeer = null;
951
1209
 
952
1210
  this.emit('participantLeft', { userId });
953
1211
 
@@ -1030,6 +1288,7 @@ class WebRTCClient extends EventEmitter {
1030
1288
  * 리소스 정리
1031
1289
  */
1032
1290
  destroy() {
1291
+ this.connectionManager.off?.('reconnected', this._onReconnected);
1033
1292
  this.endCall();
1034
1293
  this.mediaManager.destroy();
1035
1294
  this.peerManager.destroy();