Skip to content

Commit fcb2d1f

Browse files
feat: sync call's "on hold" and "mute" states (#37386)
Co-authored-by: gabriellsh <[email protected]>
1 parent 2b79c70 commit fcb2d1f

File tree

17 files changed

+1050
-336
lines changed

17 files changed

+1050
-336
lines changed

.changeset/six-adults-lie.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@rocket.chat/media-signaling': minor
3+
'@rocket.chat/media-calls': minor
4+
---
5+
6+
Fixes an error where Voice Calls could fail to negotiate webrtc params if both clients requested a renegotiation at the same time

ee/packages/media-calls/src/internal/SignalProcessor.ts

Lines changed: 10 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,20 @@
11
import type { IMediaCall, IUser } from '@rocket.chat/core-typings';
22
import { Emitter } from '@rocket.chat/emitter';
3-
import {
4-
isPendingState,
5-
type ClientMediaSignal,
6-
type ClientMediaSignalRegister,
7-
type ClientMediaSignalRequestCall,
8-
type ServerMediaSignal,
9-
type ServerMediaSignalRejectedCallRequest,
3+
import { isPendingState } from '@rocket.chat/media-signaling';
4+
import type {
5+
ClientMediaSignal,
6+
ClientMediaSignalRegister,
7+
ClientMediaSignalRequestCall,
8+
ServerMediaSignal,
9+
ServerMediaSignalRejectedCallRequest,
1010
} from '@rocket.chat/media-signaling';
1111
import { MediaCalls } from '@rocket.chat/models';
1212

1313
import type { InternalCallParams } from '../definition/common';
1414
import { logger } from '../logger';
1515
import { mediaCallDirector } from '../server/CallDirector';
1616
import { UserActorAgent } from './agents/UserActorAgent';
17-
import { getNewCallTransferredBy } from '../server/getNewCallTransferredBy';
17+
import { buildNewCallSignal } from '../server/buildNewCallSignal';
1818
import { stripSensitiveDataFromSignal } from '../server/stripSensitiveData';
1919

2020
export type SignalProcessorEvents = {
@@ -147,44 +147,7 @@ export class GlobalSignalProcessor {
147147
await mediaCallDirector.renewCallId(call._id);
148148
}
149149

150-
const transferredBy = getNewCallTransferredBy(call);
151-
152-
if (isCaller) {
153-
this.sendSignal(uid, {
154-
callId: call._id,
155-
type: 'new',
156-
service: call.service,
157-
kind: call.kind,
158-
role: 'caller',
159-
self: {
160-
...call.caller,
161-
},
162-
contact: {
163-
...call.callee,
164-
},
165-
...(call.callerRequestedId && { requestedCallId: call.callerRequestedId }),
166-
...(call.parentCallId && { replacingCallId: call.parentCallId }),
167-
...(transferredBy && { transferredBy }),
168-
});
169-
}
170-
171-
if (isCallee) {
172-
this.sendSignal(uid, {
173-
callId: call._id,
174-
type: 'new',
175-
service: call.service,
176-
kind: call.kind,
177-
role: 'callee',
178-
self: {
179-
...call.callee,
180-
},
181-
contact: {
182-
...call.caller,
183-
},
184-
...(call.parentCallId && { replacingCallId: call.parentCallId }),
185-
...(transferredBy && { transferredBy }),
186-
});
187-
}
150+
this.sendSignal(uid, buildNewCallSignal(call, role));
188151

189152
if (call.state === 'active') {
190153
this.sendSignal(uid, {
@@ -278,24 +241,7 @@ export class GlobalSignalProcessor {
278241
this.rejectCallRequest(uid, { ...rejection, reason: 'already-requested' });
279242
}
280243

281-
const transferredBy = getNewCallTransferredBy(call);
282-
283-
this.sendSignal(uid, {
284-
callId: call._id,
285-
type: 'new',
286-
service: call.service,
287-
kind: call.kind,
288-
role: 'caller',
289-
self: {
290-
...call.caller,
291-
},
292-
contact: {
293-
...call.callee,
294-
},
295-
requestedCallId: signal.callId,
296-
...(call.parentCallId && { replacingCallId: call.parentCallId }),
297-
...(transferredBy && { transferredBy }),
298-
});
244+
this.sendSignal(uid, buildNewCallSignal(call, 'caller'));
299245

300246
return call;
301247
}

ee/packages/media-calls/src/internal/agents/CallSignalProcessor.ts

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,13 +177,69 @@ export class UserActorSignalProcessor {
177177
}
178178

179179
private async processNegotiationNeeded(oldNegotiationId: string): Promise<void> {
180+
// Unsigned clients may not request negotiations
181+
if (!this.signed) {
182+
return;
183+
}
184+
180185
logger.debug({ msg: 'UserActorSignalProcessor.processNegotiationNeeded', oldNegotiationId });
181186
const negotiation = await MediaCallNegotiations.findLatestByCallId(this.callId);
182-
// If the negotiation that triggered a request for renegotiation is not the latest negotiation, then a new one must already be happening and we can ignore this request.
183-
if (negotiation?._id !== oldNegotiationId) {
187+
188+
// If the call doesn't even have an initial negotiation yet, the clients shouldn't be requesting new ones.
189+
if (!negotiation) {
184190
return;
185191
}
186192

193+
// If the latest negotiation has an answer, we can accept any request
194+
if (negotiation.answer) {
195+
return this.startNewNegotiation();
196+
}
197+
198+
const comingFromLatest = oldNegotiationId === negotiation._id;
199+
const isRequestImpolite = this.role === 'caller';
200+
const isLatestImpolite = negotiation.offerer === 'caller';
201+
202+
// If the request came from a client who was not yet aware of a newer renegotiation
203+
if (!comingFromLatest) {
204+
// If the client is polite, we can ignore their request in favor of the existing renegotiation
205+
if (!isRequestImpolite) {
206+
logger.debug({ msg: 'Ignoring outdated polite renegotiation request' });
207+
return;
208+
}
209+
210+
// If the latest negotiation is impolite and the impolite client is not aware of it yet, this must be a duplicate request
211+
if (isLatestImpolite) {
212+
// If we already received an offer in this situation then something is very wrong (some proxy interfering with signals, perhaps?)
213+
if (negotiation.offer) {
214+
logger.error({ msg: 'Invalid renegotiation request', requestedBy: this.role, isLatestImpolite });
215+
return;
216+
}
217+
218+
// Resend the offer request to the impolite client
219+
return this.requestWebRTCOffer({ negotiationId: negotiation._id });
220+
}
221+
222+
// The state of polite negotiations is irrelevant for impolite requests, so we can start a new negotiation here.
223+
return this.startNewNegotiation();
224+
}
225+
226+
// The client is up-to-date and requested a renegotiation before the last one was complete
227+
// If the request came from the same side as the last negotiation, the client was in no position to request it
228+
if (this.role === negotiation.offerer) {
229+
logger.error({ msg: 'Invalid state for renegotiation request', requestedBy: this.role, isLatestImpolite });
230+
return;
231+
}
232+
233+
// If the request is from the impolite client, it takes priority over the existing polite negotiation
234+
if (isRequestImpolite) {
235+
return this.startNewNegotiation();
236+
}
237+
238+
// It's a polite negotiation requested while an impolite one was not yet complete
239+
logger.error({ msg: 'Invalid state for renegotiation request', requestedBy: this.role, isLatestImpolite });
240+
}
241+
242+
private async startNewNegotiation(): Promise<void> {
187243
const negotiationId = await mediaCallDirector.startNewNegotiation(this.call, this.role);
188244
if (negotiationId) {
189245
await this.requestWebRTCOffer({ negotiationId });

ee/packages/media-calls/src/internal/agents/UserActorAgent.ts

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import type { IMediaCall, MediaCallSignedContact } from '@rocket.chat/core-typings';
2-
import { isBusyState, type ClientMediaSignal, type ServerMediaSignal, type ServerMediaSignalNewCall } from '@rocket.chat/media-signaling';
2+
import { isBusyState, type ClientMediaSignal, type ServerMediaSignal } from '@rocket.chat/media-signaling';
33
import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models';
44

55
import { UserActorSignalProcessor } from './CallSignalProcessor';
66
import { BaseMediaCallAgent } from '../../base/BaseAgent';
77
import { logger } from '../../logger';
8-
import { getNewCallTransferredBy } from '../../server/getNewCallTransferredBy';
8+
import { buildNewCallSignal } from '../../server/buildNewCallSignal';
99
import { getMediaCallServer } from '../../server/injection';
1010

1111
export class UserActorAgent extends BaseMediaCallAgent {
@@ -77,7 +77,7 @@ export class UserActorAgent extends BaseMediaCallAgent {
7777
await this.getOrCreateChannel(call, call.caller.contractId);
7878
}
7979

80-
await this.sendSignal(this.buildNewCallSignal(call));
80+
await this.sendSignal(buildNewCallSignal(call, this.role));
8181
}
8282

8383
public async onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise<void> {
@@ -166,21 +166,4 @@ export class UserActorAgent extends BaseMediaCallAgent {
166166
logger.debug({ msg: 'UserActorAgent.onDTMF', callId, dtmf, duration });
167167
// internal calls have nothing to do with DTMFs
168168
}
169-
170-
protected buildNewCallSignal(call: IMediaCall): ServerMediaSignalNewCall {
171-
const transferredBy = getNewCallTransferredBy(call);
172-
173-
return {
174-
callId: call._id,
175-
type: 'new',
176-
service: call.service,
177-
kind: call.kind,
178-
role: this.role,
179-
self: this.getMyCallActor(call),
180-
contact: this.getOtherCallActor(call),
181-
...(call.parentCallId && { replacingCallId: call.parentCallId }),
182-
...(transferredBy && { transferredBy }),
183-
...(call.callerRequestedId && this.role === 'caller' && { requestedCallId: call.callerRequestedId }),
184-
};
185-
}
186169
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import type { IMediaCall } from '@rocket.chat/core-typings';
2+
import type { CallFlag, CallRole, ServerMediaSignalNewCall } from '@rocket.chat/media-signaling';
3+
4+
import { getNewCallTransferredBy } from './getNewCallTransferredBy';
5+
6+
function getCallFlags(call: IMediaCall, role: CallRole): CallFlag[] {
7+
const flags: CallFlag[] = [];
8+
9+
const isInternal = call.caller.type === 'user' && call.callee.type === 'user';
10+
const shouldCreateDataChannel = isInternal && role === 'caller';
11+
12+
if (isInternal) {
13+
flags.push('internal');
14+
15+
if (shouldCreateDataChannel) {
16+
flags.push('create-data-channel');
17+
}
18+
}
19+
20+
return flags;
21+
}
22+
23+
export function buildNewCallSignal(call: IMediaCall, role: CallRole): ServerMediaSignalNewCall {
24+
const self = role === 'caller' ? call.caller : call.callee;
25+
const contact = role === 'caller' ? call.callee : call.caller;
26+
const transferredBy = getNewCallTransferredBy(call);
27+
const flags = getCallFlags(call, role);
28+
29+
return {
30+
callId: call._id,
31+
type: 'new',
32+
service: call.service,
33+
kind: call.kind,
34+
role,
35+
self: { ...self },
36+
contact: { ...contact },
37+
flags,
38+
...(call.parentCallId && { replacingCallId: call.parentCallId }),
39+
...(transferredBy && { transferredBy }),
40+
...(call.callerRequestedId && role === 'caller' && { requestedCallId: call.callerRequestedId }),
41+
};
42+
}

packages/media-signaling/src/definition/call/IClientMediaCall.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,13 @@ export type CallRejectedReason =
6464
| 'invalid-call-params' // something is wrong with the params (eg. no valid route between caller and callee)
6565
| 'forbidden'; // one of the actors on the call doesn't have permission for it
6666

67+
export type CallFlag = 'internal' | 'create-data-channel';
68+
6769
export interface IClientMediaCall {
6870
callId: string;
6971
role: CallRole;
7072
service: CallService | null;
73+
flags: readonly CallFlag[];
7174

7275
state: CallState;
7376
ignored: boolean;
@@ -78,6 +81,9 @@ export interface IClientMediaCall {
7881
held: boolean;
7982
/* busy = state >= 'accepted' && state < 'hangup' */
8083
busy: boolean;
84+
/* if the other side has put the call on hold */
85+
remoteHeld: boolean;
86+
remoteMute: boolean;
8187

8288
contact: CallContact;
8389
transferredBy: CallContact | null;

packages/media-signaling/src/definition/client.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,8 @@ export type ClientState =
44
| 'accepting' // The client tried to accept the call and is wating for confirmation from the server
55
| 'accepted' // The call was accepted, but the client doesn't have a webrtc offer yet
66
| 'busy-elsewhere' // The call is happening in a different session/client
7-
| 'has-offer' // The call was accepted and the client has a webrtc offer
8-
| 'has-answer' // The call was accepted and the client has a webrtc offer and answer
97
| 'active' // The webrtc call was established
108
| 'renegotiating' // the webrtc call was established but the client is starting a new negotiation
11-
| 'has-new-offer' // The webrtc call was established but the client has an offer for a new negotiation
12-
| 'has-new-answer' // The webrtc call was established but the client is finishing a renegotiation
139
| 'hangup'; // The call is over, or happening in some other client
1410

1511
export type ClientContractState =

packages/media-signaling/src/definition/services/IServiceProcessor.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,13 @@ export type ServiceStateValue<ServiceStateMap extends DefaultServiceStateMap, K
1414
export type ServiceProcessorEvents<ServiceStateMap extends DefaultServiceStateMap> = {
1515
internalStateChange: keyof ServiceStateMap;
1616
internalError: { critical: boolean; error: string | Error; errorDetails?: string };
17-
negotiationNeeded: void;
1817
};
1918

20-
export interface IServiceProcessor<ServiceStateMap extends DefaultServiceStateMap = DefaultServiceStateMap> {
21-
emitter: Emitter<ServiceProcessorEvents<ServiceStateMap>>;
19+
export interface IServiceProcessor<
20+
ServiceStateMap extends DefaultServiceStateMap = DefaultServiceStateMap,
21+
ServiceUniqueEvents = Record<never, never>,
22+
> {
23+
emitter: Emitter<ServiceProcessorEvents<ServiceStateMap> & ServiceUniqueEvents>;
2224

2325
getInternalState<K extends keyof ServiceStateMap>(stateName: K): ServiceStateValue<ServiceStateMap, K>;
2426
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './webrtc/IWebRTCProcessor';
22
export * from './IServiceProcessorFactoryList';
33
export * from './MediaStreamFactory';
4+
export * from './negotiation';
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { IClientMediaCall } from '../call';
2+
import type { IMediaSignalLogger } from '../logger';
3+
4+
export type NegotiationManagerEvents = {
5+
'error': { errorCode: string; negotiationId: string };
6+
'local-sdp': { negotiationId: string; sdp: RTCSessionDescriptionInit };
7+
'negotiation-needed': { oldNegotiationId: string };
8+
};
9+
10+
export type NegotiationManagerConfig = {
11+
logger?: IMediaSignalLogger | null;
12+
};
13+
14+
export type NegotiationEvents = {
15+
'error': { errorCode: string };
16+
'ended': void;
17+
'local-sdp': { sdp: RTCSessionDescriptionInit };
18+
};
19+
20+
export type NegotiationData = {
21+
negotiationId: string;
22+
sequence: number;
23+
isPolite: boolean;
24+
25+
remoteOffer: RTCSessionDescriptionInit | null;
26+
};
27+
28+
export interface INegotiationCompatibleMediaCall extends IClientMediaCall {
29+
hasInputTrack(): boolean;
30+
}

0 commit comments

Comments
 (0)