@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.
- package/dist/index.d.ts +108 -2
- package/dist/talkflow-sdk.esm.js +1 -1
- package/dist/talkflow-sdk.esm.js.map +1 -1
- package/dist/talkflow-sdk.standalone.js +1 -1
- package/dist/talkflow-sdk.standalone.js.map +1 -1
- package/dist/talkflow-sdk.umd.js +1 -1
- package/dist/talkflow-sdk.umd.js.map +1 -1
- package/package.json +1 -1
- package/src/TalkFlowClient.js +1 -0
- package/src/chat/ChatClient.js +110 -17
- package/src/constants.js +5 -0
- package/src/core/ConnectionManager.js +166 -63
- package/src/push/PushManager.js +72 -0
- package/src/talkflow/delegates.js +3 -0
- package/src/talkflow/eventForwarding.js +8 -0
- package/src/talkflow/mailNotifications.js +154 -0
- package/src/talkflow/session.js +14 -0
- package/src/webrtc/PeerConnectionManager.js +10 -1
- package/src/webrtc/WebRTCClient.js +287 -28
- package/types/index.d.ts +108 -2
|
@@ -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.
|
|
156
|
-
* @param {
|
|
157
|
-
* @param {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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();
|