snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
68
ee/packages/media-calls/src/base/BaseAgent.ts
Normal file
68
ee/packages/media-calls/src/base/BaseAgent.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { IMediaCall, MediaCallActor, MediaCallActorType, MediaCallContact, MediaCallSignedActor } from '@rocket.chat/core-typings';
|
||||
import type { CallRole } from '@rocket.chat/media-signaling';
|
||||
|
||||
import type { IMediaCallAgent } from '../definition/IMediaCallAgent';
|
||||
|
||||
export abstract class BaseMediaCallAgent implements IMediaCallAgent {
|
||||
public readonly actorType: MediaCallActorType;
|
||||
|
||||
public readonly actorId: string;
|
||||
|
||||
public oppositeAgent: IMediaCallAgent | null;
|
||||
|
||||
public get actor(): MediaCallActor {
|
||||
return {
|
||||
type: this.actorType,
|
||||
id: this.actorId,
|
||||
};
|
||||
}
|
||||
|
||||
public get oppositeRole(): CallRole {
|
||||
return ({ callee: 'caller', caller: 'callee' } as const)[this.role];
|
||||
}
|
||||
|
||||
protected localDescription: RTCSessionDescriptionInit | null;
|
||||
|
||||
constructor(
|
||||
protected readonly contact: MediaCallContact,
|
||||
public readonly role: CallRole,
|
||||
) {
|
||||
this.actorType = contact.type;
|
||||
this.actorId = contact.id;
|
||||
this.localDescription = null;
|
||||
this.oppositeAgent = null;
|
||||
}
|
||||
|
||||
public isRepresentingActor(actor: MediaCallActor): boolean {
|
||||
return actor.type === this.actorType && actor.id === this.actorId;
|
||||
}
|
||||
|
||||
public getMyCallActor(call: IMediaCall): MediaCallContact {
|
||||
return call[this.role];
|
||||
}
|
||||
|
||||
public getOtherCallActor(call: IMediaCall): MediaCallContact {
|
||||
return call[this.oppositeRole];
|
||||
}
|
||||
|
||||
public getSignedActor(contractId: string): MediaCallSignedActor {
|
||||
return {
|
||||
...this.actor,
|
||||
contractId,
|
||||
};
|
||||
}
|
||||
|
||||
public abstract onCallAccepted(call: IMediaCall): Promise<void>;
|
||||
|
||||
public abstract onCallActive(callId: string): Promise<void>;
|
||||
|
||||
public abstract onCallEnded(callId: string): Promise<void>;
|
||||
|
||||
public abstract onCallCreated(call: IMediaCall): Promise<void>;
|
||||
|
||||
public abstract onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise<void>;
|
||||
|
||||
public abstract onCallTransferred(callId: string): Promise<void>;
|
||||
|
||||
public abstract onDTMF(callId: string, dtmf: string, duration: number): Promise<void>;
|
||||
}
|
||||
16
ee/packages/media-calls/src/base/BaseCallProvider.ts
Normal file
16
ee/packages/media-calls/src/base/BaseCallProvider.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { IMediaCall } from '@rocket.chat/core-typings';
|
||||
import type { ClientMediaSignalBody } from '@rocket.chat/media-signaling';
|
||||
|
||||
import { logger } from '../logger';
|
||||
|
||||
export class BaseCallProvider {
|
||||
public get callId(): string {
|
||||
return this.call._id;
|
||||
}
|
||||
|
||||
constructor(public readonly call: IMediaCall) {}
|
||||
|
||||
public async reactToCallChanges(params: { dtmf?: ClientMediaSignalBody<'dtmf'> }): Promise<void> {
|
||||
logger.debug({ msg: 'BaseCallProvider.reactToCallChanges', callId: this.callId, params });
|
||||
}
|
||||
}
|
||||
4
ee/packages/media-calls/src/constants.ts
Normal file
4
ee/packages/media-calls/src/constants.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
import type { CallFeature } from '@rocket.chat/media-signaling';
|
||||
|
||||
export const DEFAULT_CALL_FEATURES: CallFeature[] = ['audio'];
|
||||
export const SIP_CALL_FEATURES: CallFeature[] = ['audio', 'transfer', 'hold'];
|
||||
26
ee/packages/media-calls/src/definition/IMediaCallAgent.ts
Normal file
26
ee/packages/media-calls/src/definition/IMediaCallAgent.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { IMediaCall, MediaCallActor, MediaCallActorType, MediaCallContact } from '@rocket.chat/core-typings';
|
||||
import type { CallRole } from '@rocket.chat/media-signaling';
|
||||
|
||||
export interface IMediaCallAgent {
|
||||
readonly actorType: MediaCallActorType;
|
||||
readonly actorId: string;
|
||||
readonly actor: MediaCallActor;
|
||||
readonly role: CallRole;
|
||||
readonly oppositeRole: CallRole;
|
||||
|
||||
oppositeAgent: IMediaCallAgent | null;
|
||||
|
||||
onCallEnded(callId: string): Promise<void>;
|
||||
/* Called when the call was accepted, even if the webrtc negotiation is pending */
|
||||
onCallAccepted(call: IMediaCall): Promise<void>;
|
||||
onCallActive(callId: string): Promise<void>;
|
||||
onCallCreated(call: IMediaCall): Promise<void>;
|
||||
/* Called when the sdp of the other actor is available, regardless of call state, or when this actor must provide an offer */
|
||||
onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise<void>;
|
||||
|
||||
onDTMF(callId: string, tone: string, duration: number): Promise<void>;
|
||||
|
||||
onCallTransferred(callId: string): Promise<void>;
|
||||
|
||||
getMyCallActor(call: IMediaCall): MediaCallContact;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { MediaCallActor, MediaCallContact, MediaCallContactInformation } from '@rocket.chat/core-typings';
|
||||
import type { CallRole } from '@rocket.chat/media-signaling';
|
||||
|
||||
import type { IMediaCallAgent } from './IMediaCallAgent';
|
||||
import type { GetActorContactOptions, MediaCallHeader, MinimalUserData } from './common';
|
||||
|
||||
export interface IMediaCallCastDirector {
|
||||
getAgentsFromCall(call: MediaCallHeader): Promise<{ caller: IMediaCallAgent; callee: IMediaCallAgent }>;
|
||||
getAgentFromCall(call: MediaCallHeader, role: CallRole): Promise<IMediaCallAgent | null>;
|
||||
getContactForActor(
|
||||
actor: MediaCallActor,
|
||||
options: GetActorContactOptions,
|
||||
defaultContactInfo?: MediaCallContactInformation,
|
||||
): Promise<MediaCallContact | null>;
|
||||
getContactForUser(
|
||||
user: MinimalUserData,
|
||||
options: GetActorContactOptions,
|
||||
defaultContactInfo?: MediaCallContactInformation,
|
||||
): MediaCallContact | null;
|
||||
getContactForUserId(
|
||||
userId: string,
|
||||
options: GetActorContactOptions,
|
||||
defaultContactInfo?: MediaCallContactInformation,
|
||||
): Promise<MediaCallContact | null>;
|
||||
getContactForExtensionNumber(
|
||||
sipExtension: string,
|
||||
options: GetActorContactOptions,
|
||||
defaultContactInfo?: MediaCallContactInformation,
|
||||
): Promise<MediaCallContact | null>;
|
||||
|
||||
getAgentForActorAndRole(actor: MediaCallContact, role: CallRole): Promise<IMediaCallAgent | null>;
|
||||
}
|
||||
66
ee/packages/media-calls/src/definition/IMediaCallServer.ts
Normal file
66
ee/packages/media-calls/src/definition/IMediaCallServer.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import type { Emitter } from '@rocket.chat/emitter';
|
||||
import type { CallFeature, ClientMediaSignal, ClientMediaSignalBody, ServerMediaSignal } from '@rocket.chat/media-signaling';
|
||||
|
||||
import type { InternalCallParams, SignalProcessingOptions } from './common';
|
||||
|
||||
export type VoipPushNotificationType = 'incoming_call' | 'remoteEnded' | 'answeredElsewhere' | 'declinedElsewhere' | 'unanswered';
|
||||
export type VoipPushNotificationEventType = 'new' | 'answer' | 'end';
|
||||
|
||||
export type MediaCallServerEvents = {
|
||||
callUpdated: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> };
|
||||
callActivated: { callId: string; uids: IUser['_id'][] };
|
||||
callEnded: { callId: string; uids: IUser['_id'][] };
|
||||
signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal };
|
||||
historyUpdate: { callId: string };
|
||||
pushNotificationRequest: { callId: string; event: VoipPushNotificationEventType };
|
||||
};
|
||||
|
||||
export interface IMediaCallServerSettings {
|
||||
internalCalls: {
|
||||
requireExtensions: boolean;
|
||||
routeExternally: 'never' | 'preferably' | 'always';
|
||||
};
|
||||
|
||||
sip: {
|
||||
enabled: boolean;
|
||||
drachtio: {
|
||||
host: string;
|
||||
port: number;
|
||||
secret: string;
|
||||
};
|
||||
sipServer: {
|
||||
host: string;
|
||||
port: number;
|
||||
};
|
||||
};
|
||||
|
||||
mobileRinging: boolean;
|
||||
|
||||
permissionCheck: (uid: IUser['_id'], callType: 'internal' | 'external' | 'any') => Promise<boolean>;
|
||||
isFeatureAvailableForUser: (uid: IUser['_id'], feature: CallFeature) => boolean;
|
||||
}
|
||||
|
||||
export interface IMediaCallServer {
|
||||
emitter: Emitter<MediaCallServerEvents>;
|
||||
|
||||
// functions that trigger events
|
||||
sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void;
|
||||
reportCallUpdate(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void;
|
||||
updateCallHistory(params: { callId: string }): void;
|
||||
sendPushNotification(params: { callId: string; event: VoipPushNotificationEventType }): void;
|
||||
|
||||
// functions that are run on events
|
||||
receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal, options?: SignalProcessingOptions): Promise<void>;
|
||||
receiveCallUpdate(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void;
|
||||
|
||||
// extra functions available to the service
|
||||
hangupExpiredCalls(): Promise<void>;
|
||||
scheduleExpirationCheck(): void;
|
||||
configure(settings: IMediaCallServerSettings): void;
|
||||
|
||||
requestCall(params: InternalCallParams): Promise<void>;
|
||||
|
||||
permissionCheck(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise<boolean>;
|
||||
isFeatureAvailableForUser(uid: IUser['_id'], feature: CallFeature): boolean;
|
||||
}
|
||||
37
ee/packages/media-calls/src/definition/common.ts
Normal file
37
ee/packages/media-calls/src/definition/common.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { AtLeast, IMediaCall, IUser, MediaCallActorType, MediaCallContact, MediaCallSignedContact } from '@rocket.chat/core-typings';
|
||||
import type { CallFeature, CallRejectedReason, CallService } from '@rocket.chat/media-signaling';
|
||||
|
||||
export type MinimalUserData = Pick<IUser, '_id' | 'username' | 'name' | 'freeSwitchExtension'>;
|
||||
|
||||
export type GetActorContactOptions = {
|
||||
requiredType?: MediaCallActorType;
|
||||
preferredType?: MediaCallActorType;
|
||||
};
|
||||
|
||||
export type InternalCallParams = {
|
||||
caller: MediaCallSignedContact;
|
||||
callee: MediaCallContact;
|
||||
requestedCallId?: string;
|
||||
requestedService?: CallService;
|
||||
parentCallId?: string;
|
||||
requestedBy?: MediaCallSignedContact;
|
||||
features: CallFeature[];
|
||||
divertedBy?: MediaCallContact;
|
||||
};
|
||||
|
||||
export type MediaCallHeader = AtLeast<IMediaCall, '_id' | 'caller' | 'callee'>;
|
||||
|
||||
export class CallRejectedError extends Error {
|
||||
constructor(
|
||||
public readonly callRejectedReason: CallRejectedReason,
|
||||
message?: string,
|
||||
) {
|
||||
super(message || 'call-rejected');
|
||||
}
|
||||
}
|
||||
|
||||
export type SignalProcessingOptions = {
|
||||
// Some signals can be safely skipped when they are not relevant to the current call state, but
|
||||
// if the signal was received via REST, we shouldn't return success without processing anything, so we throw an error instead
|
||||
throwIfSkipped?: boolean;
|
||||
};
|
||||
4
ee/packages/media-calls/src/index.ts
Normal file
4
ee/packages/media-calls/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export type * from './definition/IMediaCallServer';
|
||||
|
||||
export { callServer } from './server/configuration';
|
||||
export { getSignalsForExistingCall } from './server/signals/getSignalsForExistingCall';
|
||||
63
ee/packages/media-calls/src/internal/InternalCallProvider.ts
Normal file
63
ee/packages/media-calls/src/internal/InternalCallProvider.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import type { IMediaCall } from '@rocket.chat/core-typings';
|
||||
import { MediaCalls } from '@rocket.chat/models';
|
||||
|
||||
import { BaseCallProvider } from '../base/BaseCallProvider';
|
||||
import { CallRejectedError, type InternalCallParams } from '../definition/common';
|
||||
import { logger } from '../logger';
|
||||
import { mediaCallDirector } from '../server/CallDirector';
|
||||
|
||||
export class InternalCallProvider extends BaseCallProvider {
|
||||
public static async createCall(params: InternalCallParams): Promise<IMediaCall> {
|
||||
logger.debug({ msg: 'InternalCallProvider.createCall', params });
|
||||
if (params.caller.type !== 'user' || params.callee.type !== 'user') {
|
||||
throw new CallRejectedError('unsupported');
|
||||
}
|
||||
|
||||
if (params.caller.id === params.callee.id) {
|
||||
throw new CallRejectedError('unsupported');
|
||||
}
|
||||
|
||||
if (await MediaCalls.hasUnfinishedCallsByUid(params.caller.id, params.parentCallId)) {
|
||||
throw new CallRejectedError('busy');
|
||||
}
|
||||
if (await MediaCalls.hasUnfinishedCallsByUid(params.callee.id)) {
|
||||
throw new CallRejectedError('unavailable');
|
||||
}
|
||||
|
||||
const callerAgent = await mediaCallDirector.cast.getAgentForActorAndRole(params.caller, 'caller');
|
||||
const calleeAgent = await mediaCallDirector.cast.getAgentForActorAndRole(params.callee, 'callee');
|
||||
|
||||
if (!callerAgent) {
|
||||
throw new Error('invalid-caller');
|
||||
}
|
||||
if (!calleeAgent) {
|
||||
throw new Error('invalid-callee');
|
||||
}
|
||||
|
||||
callerAgent.oppositeAgent = calleeAgent;
|
||||
calleeAgent.oppositeAgent = callerAgent;
|
||||
|
||||
const call = await mediaCallDirector.createCall({
|
||||
...params,
|
||||
callerAgent,
|
||||
calleeAgent,
|
||||
});
|
||||
|
||||
// If the caller agent fails, this will automatically hangup the call and throw an error
|
||||
await mediaCallDirector.runOnCallCreatedForAgent(call, callerAgent);
|
||||
|
||||
// If the callee agent fails, this will automatically hangup the call, notify the caller agent and then throw an error
|
||||
await mediaCallDirector.runOnCallCreatedForAgent(call, calleeAgent, callerAgent);
|
||||
|
||||
if (params.parentCallId) {
|
||||
logger.info({
|
||||
msg: 'Transferred call was created, so the old one will be terminated',
|
||||
newCallId: call._id,
|
||||
oldCallId: params.parentCallId,
|
||||
});
|
||||
mediaCallDirector.hangupTransferredCallById(params.parentCallId).catch(() => null);
|
||||
}
|
||||
|
||||
return call;
|
||||
}
|
||||
}
|
||||
280
ee/packages/media-calls/src/internal/SignalProcessor.ts
Normal file
280
ee/packages/media-calls/src/internal/SignalProcessor.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import type { IMediaCall, IUser } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import { isPendingState } from '@rocket.chat/media-signaling';
|
||||
import type {
|
||||
ClientMediaSignal,
|
||||
ClientMediaSignalRegister,
|
||||
ClientMediaSignalRequestCall,
|
||||
ServerMediaSignal,
|
||||
ServerMediaSignalRejectedCallRequest,
|
||||
} from '@rocket.chat/media-signaling';
|
||||
import { MediaCalls } from '@rocket.chat/models';
|
||||
|
||||
import { DEFAULT_CALL_FEATURES } from '../constants';
|
||||
import type { InternalCallParams, SignalProcessingOptions } from '../definition/common';
|
||||
import { logger } from '../logger';
|
||||
import { mediaCallDirector } from '../server/CallDirector';
|
||||
import { UserActorAgent } from './agents/UserActorAgent';
|
||||
import { getCallRoleForUser } from '../server/getCallRoleForUser';
|
||||
import { getNewCallSignal } from '../server/signals/getNewCallSignal';
|
||||
import { getSignalsForExistingCall } from '../server/signals/getSignalsForExistingCall';
|
||||
import { stripSensitiveDataFromSignal } from '../server/stripSensitiveData';
|
||||
|
||||
export type SignalProcessorEvents = {
|
||||
signalRequest: { toUid: IUser['_id']; signal: ServerMediaSignal };
|
||||
callRequest: { params: InternalCallParams };
|
||||
};
|
||||
|
||||
export class GlobalSignalProcessor {
|
||||
public readonly emitter: Emitter<SignalProcessorEvents>;
|
||||
|
||||
constructor() {
|
||||
this.emitter = new Emitter();
|
||||
}
|
||||
|
||||
public async processSignal(uid: IUser['_id'], signal: ClientMediaSignal, options: SignalProcessingOptions): Promise<void> {
|
||||
switch (signal.type) {
|
||||
case 'register':
|
||||
return this.processRegisterSignal(uid, signal);
|
||||
case 'request-call':
|
||||
return this.processRequestCallSignal(uid, signal);
|
||||
}
|
||||
|
||||
if ('callId' in signal) {
|
||||
return this.processCallSignal(uid, signal, options);
|
||||
}
|
||||
|
||||
logger.error({ msg: 'Unrecognized media signal', signal: stripSensitiveDataFromSignal(signal) });
|
||||
}
|
||||
|
||||
protected sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void {
|
||||
this.emitter.emit('signalRequest', { toUid, signal });
|
||||
}
|
||||
|
||||
protected createCall(params: InternalCallParams): void {
|
||||
this.emitter.emit('callRequest', { params });
|
||||
}
|
||||
|
||||
private async processCallSignal(
|
||||
uid: IUser['_id'],
|
||||
signal: Exclude<ClientMediaSignal, ClientMediaSignalRegister | ClientMediaSignalRequestCall>,
|
||||
{ throwIfSkipped }: SignalProcessingOptions,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const call = await MediaCalls.findOneById(signal.callId);
|
||||
if (!call) {
|
||||
logger.error({
|
||||
msg: 'call not found',
|
||||
method: 'GlobalSignalProcessor.processCallSignal',
|
||||
signal: stripSensitiveDataFromSignal(signal),
|
||||
});
|
||||
throw new Error('invalid-call');
|
||||
}
|
||||
|
||||
const isCaller = call.caller.type === 'user' && call.caller.id === uid;
|
||||
const isCallee = call.callee.type === 'user' && call.callee.id === uid;
|
||||
|
||||
// The user must be either the caller or the callee, if its none or both, we can't process it
|
||||
if (isCaller === isCallee) {
|
||||
logger.error({
|
||||
msg: 'failed to identify actor role in the call',
|
||||
method: 'processSignal',
|
||||
signal: stripSensitiveDataFromSignal(signal),
|
||||
isCaller,
|
||||
isCallee,
|
||||
});
|
||||
throw new Error('invalid-call');
|
||||
}
|
||||
|
||||
const role = isCaller ? 'caller' : 'callee';
|
||||
const callActor = call[role];
|
||||
|
||||
// Hangup requests from different clients won't be coming from the signed client
|
||||
const skipContractCheck = signal.type === 'hangup' && signal.reason === 'another-client';
|
||||
|
||||
// Ignore signals from different sessions if the actor is already signed
|
||||
if (!skipContractCheck && callActor.contractId && callActor.contractId !== signal.contractId) {
|
||||
if (throwIfSkipped) {
|
||||
throw new Error('invalid-contract');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await mediaCallDirector.renewCallId(call._id);
|
||||
|
||||
const agents = await mediaCallDirector.cast.getAgentsFromCall(call);
|
||||
const { [role]: agent } = agents;
|
||||
|
||||
if (!(agent instanceof UserActorAgent)) {
|
||||
logger.error({
|
||||
msg: 'Actor agent is not prepared to process signals',
|
||||
method: 'processSignal',
|
||||
signal: stripSensitiveDataFromSignal(signal),
|
||||
isCaller,
|
||||
isCallee,
|
||||
});
|
||||
throw new Error('internal-error');
|
||||
}
|
||||
|
||||
await agent.processSignal(call, signal, { throwIfSkipped });
|
||||
} catch (e) {
|
||||
logger.error({ err: e });
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private async processRegisterSignal(uid: IUser['_id'], signal: ClientMediaSignalRegister): Promise<void> {
|
||||
logger.debug({ msg: 'GlobalSignalProcessor.processRegisterSignal', signal: stripSensitiveDataFromSignal(signal), uid });
|
||||
|
||||
const calls = await MediaCalls.findAllNotOverByUid(uid).toArray();
|
||||
const activeCalls = calls.filter(
|
||||
({ callee, caller }) =>
|
||||
(callee.type === 'user' && callee.id === uid && callee.contractId === signal.contractId) ||
|
||||
(caller.type === 'user' && caller.id === uid && caller.contractId === signal.contractId),
|
||||
);
|
||||
|
||||
this.sendSignal(uid, {
|
||||
type: 'registered',
|
||||
toContractId: signal.contractId,
|
||||
calls: calls.map(({ _id }) => _id),
|
||||
activeCalls: activeCalls.map(({ _id }) => _id),
|
||||
});
|
||||
|
||||
if (!calls.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.allSettled(calls.map((call) => this.reactToUnknownCall(uid, call, signal)));
|
||||
}
|
||||
|
||||
private async reactToUnknownCall(uid: IUser['_id'], call: IMediaCall, signal: ClientMediaSignalRegister): Promise<void> {
|
||||
if (call.state === 'hangup') {
|
||||
return;
|
||||
}
|
||||
|
||||
const role = getCallRoleForUser(call, uid);
|
||||
if (!role) {
|
||||
return;
|
||||
}
|
||||
const actor = call[role];
|
||||
// If this user's side of the call has already been signed
|
||||
if (actor.contractId) {
|
||||
// If it was signed by a session that the current session is replacing (as in a browser refresh)
|
||||
if (actor.contractId === signal.oldContractId) {
|
||||
logger.info({ msg: 'Server detected a client refresh for a session with an active call.', callId: call._id });
|
||||
await mediaCallDirector.hangupDetachedCall(call, { endedBy: { ...actor, contractId: signal.contractId }, reason: 'unknown' });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
await mediaCallDirector.renewCallId(call._id);
|
||||
}
|
||||
|
||||
if (call.state !== 'active') {
|
||||
const otherActor = role === 'caller' ? call.callee : call.caller;
|
||||
if (otherActor.type === 'user') {
|
||||
this.sendSignal(otherActor.id, {
|
||||
callId: call._id,
|
||||
type: 'notification',
|
||||
notification: 'trying',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!signal.requestSignals) {
|
||||
return;
|
||||
}
|
||||
|
||||
const signals = await getSignalsForExistingCall(call, uid, signal.contractId);
|
||||
|
||||
for (const signal of signals) {
|
||||
this.sendSignal(uid, signal);
|
||||
}
|
||||
}
|
||||
|
||||
private async processRequestCallSignal(uid: IUser['_id'], signal: ClientMediaSignalRequestCall): Promise<void> {
|
||||
logger.debug({ msg: 'GlobalSignalProcessor.processRequestCallSignal', signal, uid });
|
||||
const existingCall = await this.getExistingRequestedCall(uid, signal);
|
||||
if (existingCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hasCalls = await MediaCalls.hasUnfinishedCallsByUid(uid);
|
||||
if (hasCalls) {
|
||||
this.rejectCallRequest(uid, { callId: signal.callId, toContractId: signal.contractId, reason: 'busy' });
|
||||
}
|
||||
|
||||
const services = signal.supportedServices ?? [];
|
||||
const requestedService = services.includes('webrtc') ? 'webrtc' : services[0];
|
||||
const features = signal.supportedFeatures ?? DEFAULT_CALL_FEATURES;
|
||||
|
||||
const params: InternalCallParams = {
|
||||
caller: {
|
||||
type: 'user',
|
||||
id: uid,
|
||||
contractId: signal.contractId,
|
||||
},
|
||||
callee: signal.callee,
|
||||
requestedBy: {
|
||||
type: 'user',
|
||||
id: uid,
|
||||
contractId: signal.contractId,
|
||||
},
|
||||
requestedCallId: signal.callId,
|
||||
...(requestedService && { requestedService }),
|
||||
features,
|
||||
};
|
||||
|
||||
this.createCall(params);
|
||||
}
|
||||
|
||||
private async getExistingRequestedCall(uid: IUser['_id'], signal: ClientMediaSignalRequestCall): Promise<IMediaCall | null> {
|
||||
const { callId: requestedCallId } = signal;
|
||||
|
||||
if (!requestedCallId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const caller = { type: 'user', id: uid } as const;
|
||||
const rejection = { callId: requestedCallId, toContractId: signal.contractId, reason: 'invalid-call-id' } as const;
|
||||
|
||||
// The requestedCallId must never match a real call id
|
||||
const matchingValidCall = await MediaCalls.findOneById(requestedCallId, { projection: { _id: 1 } });
|
||||
if (matchingValidCall) {
|
||||
this.rejectCallRequest(uid, rejection);
|
||||
}
|
||||
|
||||
const call = await MediaCalls.findOneByCallerRequestedId(requestedCallId, caller);
|
||||
if (!call) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// if the call is already over, we treat it as an invalid id since it can't be reused
|
||||
if (call.state === 'hangup') {
|
||||
this.rejectCallRequest(uid, rejection);
|
||||
}
|
||||
|
||||
if (call.caller.contractId !== signal.contractId) {
|
||||
this.rejectCallRequest(uid, { ...rejection, reason: 'existing-call-id' });
|
||||
}
|
||||
|
||||
if (signal.supportedServices?.length && !signal.supportedServices.includes(call.service)) {
|
||||
this.rejectCallRequest(uid, { ...rejection, reason: 'unsupported' });
|
||||
}
|
||||
|
||||
// if the call is already accepted, we won't send its signals again
|
||||
if (!isPendingState(call.state)) {
|
||||
this.rejectCallRequest(uid, { ...rejection, reason: 'already-requested' });
|
||||
}
|
||||
|
||||
this.sendSignal(uid, getNewCallSignal(call, 'caller'));
|
||||
|
||||
return call;
|
||||
}
|
||||
|
||||
private rejectCallRequest(uid: IUser['_id'], rejection: Omit<ServerMediaSignalRejectedCallRequest, 'type'>): never {
|
||||
logger.info({ msg: 'Call Request Rejected', uid, rejection });
|
||||
this.sendSignal(uid, { type: 'rejected-call-request', ...rejection });
|
||||
|
||||
throw new Error(rejection.reason);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import type {
|
||||
IMediaCall,
|
||||
MediaCallActorType,
|
||||
MediaCallNegotiationStream,
|
||||
MediaCallSignedActor,
|
||||
MediaCallSignedContact,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import { isPendingState, isBusyState } from '@rocket.chat/media-signaling';
|
||||
import type {
|
||||
ClientMediaSignalTransfer,
|
||||
CallHangupReason,
|
||||
CallRole,
|
||||
ClientMediaSignal,
|
||||
ClientMediaSignalError,
|
||||
ClientMediaSignalLocalState,
|
||||
ServerMediaSignal,
|
||||
ClientMediaSignalAnswer,
|
||||
CallFeature,
|
||||
} from '@rocket.chat/media-signaling';
|
||||
import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models';
|
||||
|
||||
import { DEFAULT_CALL_FEATURES } from '../../constants';
|
||||
import type { IMediaCallAgent } from '../../definition/IMediaCallAgent';
|
||||
import type { SignalProcessingOptions } from '../../definition/common';
|
||||
import { logger } from '../../logger';
|
||||
import { mediaCallDirector } from '../../server/CallDirector';
|
||||
import { getMediaCallServer } from '../../server/injection';
|
||||
import { stripSensitiveDataFromSignal } from '../../server/stripSensitiveData';
|
||||
|
||||
export class UserActorSignalProcessor {
|
||||
public get callId(): string {
|
||||
return this.call._id;
|
||||
}
|
||||
|
||||
public get actorId(): string {
|
||||
return this.agent.actorId;
|
||||
}
|
||||
|
||||
public get actorType(): MediaCallActorType {
|
||||
return this.agent.actorType;
|
||||
}
|
||||
|
||||
public get role(): CallRole {
|
||||
return this.agent.role;
|
||||
}
|
||||
|
||||
public get actor(): MediaCallSignedActor {
|
||||
return {
|
||||
type: this.actorType,
|
||||
id: this.actorId,
|
||||
contractId: this.contractId,
|
||||
};
|
||||
}
|
||||
|
||||
public readonly signed: boolean;
|
||||
|
||||
public readonly ignored: boolean;
|
||||
|
||||
private throwIfSkipped: boolean;
|
||||
|
||||
constructor(
|
||||
protected readonly agent: IMediaCallAgent,
|
||||
protected readonly call: IMediaCall,
|
||||
public readonly contractId: string,
|
||||
) {
|
||||
const actor = call[this.role];
|
||||
|
||||
this.signed = Boolean(actor.contractId && actor.contractId === contractId);
|
||||
this.ignored = Boolean(actor.contractId && actor.contractId !== contractId);
|
||||
this.throwIfSkipped = false;
|
||||
}
|
||||
|
||||
public async requestWebRTCOffer(params: { negotiationId: string }): Promise<void> {
|
||||
logger.debug({ msg: 'UserActorSignalProcessor.requestWebRTCOffer', params });
|
||||
|
||||
await this.sendSignal({
|
||||
callId: this.callId,
|
||||
toContractId: this.contractId,
|
||||
type: 'request-offer',
|
||||
...params,
|
||||
});
|
||||
}
|
||||
|
||||
public async processSignal(signal: ClientMediaSignal, options: SignalProcessingOptions = {}): Promise<void> {
|
||||
if (signal.type !== 'local-state') {
|
||||
logger.debug({
|
||||
msg: 'UserActorSignalProcessor.processSignal',
|
||||
signal: stripSensitiveDataFromSignal(signal),
|
||||
role: this.role,
|
||||
signed: this.signed,
|
||||
});
|
||||
}
|
||||
|
||||
this.throwIfSkipped = options.throwIfSkipped || false;
|
||||
|
||||
// The code will only reach this point if one of the following conditions are true:
|
||||
// 1. the signal came from the exact user session where the caller initiated the call
|
||||
// 2. the signal came from the exact user session where the callee accepted the call
|
||||
// 3. the call has not been accepted yet and the signal came from a valid session from the callee
|
||||
// 4. It's a hangup request with reason = 'another-client' and the request came from any valid client of either user
|
||||
switch (signal.type) {
|
||||
case 'local-sdp':
|
||||
return this.saveLocalDescription(signal.sdp, signal.negotiationId, signal.streams);
|
||||
case 'answer':
|
||||
return this.processAnswer(signal);
|
||||
case 'hangup':
|
||||
return this.hangup(signal.reason);
|
||||
case 'local-state':
|
||||
return this.reviewLocalState(signal);
|
||||
case 'error':
|
||||
return this.processError(signal);
|
||||
case 'negotiation-needed':
|
||||
return this.processNegotiationNeeded(signal.oldNegotiationId);
|
||||
case 'transfer':
|
||||
return this.processCallTransfer(signal.to);
|
||||
case 'dtmf':
|
||||
return this.processDTMF(signal.dtmf, signal.duration);
|
||||
}
|
||||
}
|
||||
|
||||
protected async hangup(reason: CallHangupReason): Promise<void> {
|
||||
return mediaCallDirector.hangup(this.call, this.agent, reason);
|
||||
}
|
||||
|
||||
protected async saveLocalDescription(
|
||||
sdp: RTCSessionDescriptionInit,
|
||||
negotiationId: string,
|
||||
streams?: MediaCallNegotiationStream[],
|
||||
): Promise<void> {
|
||||
if (!this.signed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mediaCallDirector.saveWebrtcSession(this.call, this.agent, { sdp, negotiationId, streams }, this.contractId);
|
||||
}
|
||||
|
||||
private async processAnswer(signal: ClientMediaSignalAnswer): Promise<void> {
|
||||
switch (signal.answer) {
|
||||
case 'ack':
|
||||
return this.clientIsReachable();
|
||||
case 'accept':
|
||||
return this.clientHasAccepted(signal.supportedFeatures || DEFAULT_CALL_FEATURES);
|
||||
case 'unavailable':
|
||||
return this.clientIsUnavailable();
|
||||
case 'reject':
|
||||
return this.clientHasRejected();
|
||||
}
|
||||
}
|
||||
|
||||
private async processError(signal: ClientMediaSignalError): Promise<void> {
|
||||
if (!this.signed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { errorType = 'other', errorCode, critical = false, negotiationId, errorDetails } = signal;
|
||||
logger.error({
|
||||
msg: 'Client reported an error',
|
||||
errorType,
|
||||
errorCode,
|
||||
critical,
|
||||
errorDetails,
|
||||
negotiationId,
|
||||
callId: this.callId,
|
||||
role: this.role,
|
||||
state: this.call.state,
|
||||
});
|
||||
|
||||
let hangupReason: CallHangupReason = 'error';
|
||||
if (errorType === 'service') {
|
||||
hangupReason = 'service-error';
|
||||
|
||||
// Do not hangup on service errors after the call is already active;
|
||||
// if the error happened on a renegotiation, then the service may still be able to rollback to a valid state
|
||||
if (this.isPastNegotiation()) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!critical) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorType === 'signaling') {
|
||||
hangupReason = 'signaling-error';
|
||||
}
|
||||
|
||||
await mediaCallDirector.hangup(this.call, this.agent, hangupReason);
|
||||
}
|
||||
|
||||
private async processNegotiationNeeded(oldNegotiationId: string): Promise<void> {
|
||||
// Unsigned clients may not request negotiations
|
||||
if (!this.signed) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug({ msg: 'UserActorSignalProcessor.processNegotiationNeeded', oldNegotiationId });
|
||||
const negotiation = await MediaCallNegotiations.findLatestByCallId(this.callId);
|
||||
|
||||
// If the call doesn't even have an initial negotiation yet, the clients shouldn't be requesting new ones.
|
||||
if (!negotiation) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the latest negotiation has an answer, we can accept any request
|
||||
if (negotiation.answer) {
|
||||
return this.startNewNegotiation();
|
||||
}
|
||||
|
||||
const comingFromLatest = oldNegotiationId === negotiation._id;
|
||||
const isRequestImpolite = this.role === 'caller';
|
||||
const isLatestImpolite = negotiation.offerer === 'caller';
|
||||
|
||||
// If the request came from a client who was not yet aware of a newer renegotiation
|
||||
if (!comingFromLatest) {
|
||||
// If the client is polite, we can ignore their request in favor of the existing renegotiation
|
||||
if (!isRequestImpolite) {
|
||||
logger.debug({ msg: 'Ignoring outdated polite renegotiation request' });
|
||||
return;
|
||||
}
|
||||
|
||||
// If the latest negotiation is impolite and the impolite client is not aware of it yet, this must be a duplicate request
|
||||
if (isLatestImpolite) {
|
||||
// If we already received an offer in this situation then something is very wrong (some proxy interfering with signals, perhaps?)
|
||||
if (negotiation.offer) {
|
||||
logger.error({ msg: 'Invalid renegotiation request', requestedBy: this.role, isLatestImpolite });
|
||||
return;
|
||||
}
|
||||
|
||||
// Resend the offer request to the impolite client
|
||||
return this.requestWebRTCOffer({ negotiationId: negotiation._id });
|
||||
}
|
||||
|
||||
// The state of polite negotiations is irrelevant for impolite requests, so we can start a new negotiation here.
|
||||
return this.startNewNegotiation();
|
||||
}
|
||||
|
||||
// The client is up-to-date and requested a renegotiation before the last one was complete
|
||||
// If the request came from the same side as the last negotiation, the client was in no position to request it
|
||||
if (this.role === negotiation.offerer) {
|
||||
logger.error({ msg: 'Invalid state for renegotiation request', requestedBy: this.role, isLatestImpolite });
|
||||
return;
|
||||
}
|
||||
|
||||
// If the request is from the impolite client, it takes priority over the existing polite negotiation
|
||||
if (isRequestImpolite) {
|
||||
return this.startNewNegotiation();
|
||||
}
|
||||
|
||||
// It's a polite negotiation requested while an impolite one was not yet complete
|
||||
logger.error({ msg: 'Invalid state for renegotiation request', requestedBy: this.role, isLatestImpolite });
|
||||
}
|
||||
|
||||
private async startNewNegotiation(): Promise<void> {
|
||||
const negotiationId = await mediaCallDirector.startNewNegotiation(this.call, this.role);
|
||||
if (negotiationId) {
|
||||
await this.requestWebRTCOffer({ negotiationId });
|
||||
}
|
||||
}
|
||||
|
||||
private async processCallTransfer(to: ClientMediaSignalTransfer['to']): Promise<void> {
|
||||
logger.debug({ msg: 'UserActorSignalProcessor.processCallTransfer', to });
|
||||
if (!isBusyState(this.call.state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const self: MediaCallSignedContact = {
|
||||
...this.agent.getMyCallActor(this.call),
|
||||
...this.actor,
|
||||
};
|
||||
|
||||
return mediaCallDirector.transferCall(this.call, to, self, this.agent);
|
||||
}
|
||||
|
||||
private async processDTMF(dtmf: string, duration?: number): Promise<void> {
|
||||
logger.debug({ msg: 'UserActorSignalProcessor.processDTMF', dtmf, duration });
|
||||
|
||||
void this.agent.oppositeAgent?.onDTMF(this.call._id, dtmf, duration || 2000);
|
||||
}
|
||||
|
||||
protected async clientIsReachable(): Promise<void> {
|
||||
if (this.role === 'callee' && this.call.state === 'none') {
|
||||
// Change the call state from 'none' to 'ringing' when any callee session is found
|
||||
const ringUpdateResult = await MediaCalls.startRingingById(this.callId, mediaCallDirector.getNewExpirationTime());
|
||||
if (ringUpdateResult.modifiedCount) {
|
||||
mediaCallDirector.scheduleExpirationCheckByCallId(this.callId);
|
||||
}
|
||||
}
|
||||
|
||||
// The caller contract should be signed before the call even starts, so if this one isn't, ignore its state
|
||||
if (this.role === 'caller' && this.signed) {
|
||||
// When the signed caller's client is reached, we immediatelly start the first negotiation
|
||||
const negotiationId = await mediaCallDirector.startFirstNegotiation(this.call);
|
||||
if (negotiationId) {
|
||||
await this.requestWebRTCOffer({ negotiationId });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected async clientHasRejected(): Promise<void> {
|
||||
if (!this.validatePendingCallee()) {
|
||||
return;
|
||||
}
|
||||
|
||||
return mediaCallDirector.hangup(this.call, this.agent, 'rejected');
|
||||
}
|
||||
|
||||
protected async clientIsUnavailable(): Promise<void> {
|
||||
// Ignore 'unavailable' responses from unsigned clients as some other client session may have a different answer
|
||||
if (!this.signed) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mediaCallDirector.hangup(this.call, this.agent, 'unavailable');
|
||||
}
|
||||
|
||||
protected async clientHasAccepted(supportedFeatures: CallFeature[]): Promise<void> {
|
||||
if (!this.validatePendingCallee()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mediaCallDirector.acceptCall(this.call, this.agent, { calleeContractId: this.contractId, supportedFeatures });
|
||||
}
|
||||
|
||||
protected async sendSignal(signal: ServerMediaSignal): Promise<void> {
|
||||
getMediaCallServer().sendSignal(this.actorId, signal);
|
||||
}
|
||||
|
||||
protected isCallPending(): boolean {
|
||||
return isPendingState(this.call.state);
|
||||
}
|
||||
|
||||
protected isPastNegotiation(): boolean {
|
||||
return ['active', 'hangup'].includes(this.call.state);
|
||||
}
|
||||
|
||||
protected validatePendingCallee(): boolean {
|
||||
if (this.role !== 'callee') {
|
||||
if (this.throwIfSkipped) {
|
||||
throw new Error('invalid-call-role');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!this.isCallPending()) {
|
||||
if (this.throwIfSkipped) {
|
||||
throw new Error('invalid-call-state');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private async reviewLocalState(signal: ClientMediaSignalLocalState): Promise<void> {
|
||||
if (!this.signed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (signal.clientState === 'active') {
|
||||
if (signal.negotiationId) {
|
||||
void MediaCallNegotiations.setStableById(signal.negotiationId)
|
||||
.then((result) => {
|
||||
if (result.modifiedCount) {
|
||||
logger.info({ msg: 'Negotiation is stable', callId: signal.callId, role: this.role });
|
||||
}
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
if (!this.call.activatedAt) {
|
||||
await mediaCallDirector.activate(this.call, this.agent);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
186
ee/packages/media-calls/src/internal/agents/UserActorAgent.ts
Normal file
186
ee/packages/media-calls/src/internal/agents/UserActorAgent.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import type { IMediaCall, MediaCallSignedContact } from '@rocket.chat/core-typings';
|
||||
import { isBusyState, isCallHangupReason } from '@rocket.chat/media-signaling';
|
||||
import type { ClientMediaSignal, ServerMediaSignal, CallFeature, CallHangupReason } from '@rocket.chat/media-signaling';
|
||||
import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models';
|
||||
|
||||
import { UserActorSignalProcessor } from './CallSignalProcessor';
|
||||
import { BaseMediaCallAgent } from '../../base/BaseAgent';
|
||||
import type { VoipPushNotificationEventType } from '../../definition/IMediaCallServer';
|
||||
import type { SignalProcessingOptions } from '../../definition/common';
|
||||
import { logger } from '../../logger';
|
||||
import { getMediaCallServer } from '../../server/injection';
|
||||
import { getInitialOfferSignal } from '../../server/signals/getInitialOfferSignal';
|
||||
import { getNewCallSignal } from '../../server/signals/getNewCallSignal';
|
||||
import { getStateNotification } from '../../server/signals/getStateNotification';
|
||||
|
||||
export class UserActorAgent extends BaseMediaCallAgent {
|
||||
public async processSignal(call: IMediaCall, signal: ClientMediaSignal, options?: SignalProcessingOptions): Promise<void> {
|
||||
const signalProcessor = new UserActorSignalProcessor(this, call, signal.contractId);
|
||||
return signalProcessor.processSignal(signal, options);
|
||||
}
|
||||
|
||||
public async sendSignal(signal: ServerMediaSignal): Promise<void> {
|
||||
getMediaCallServer().sendSignal(this.actorId, signal);
|
||||
}
|
||||
|
||||
public async onCallAccepted(call: IMediaCall): Promise<void> {
|
||||
const stateSignal = getStateNotification(call, this.role);
|
||||
if (stateSignal?.notification !== 'accepted') {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendSignal(stateSignal);
|
||||
|
||||
if (this.role !== 'callee') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.sendPushNotification({ callId: call._id, event: 'answer' });
|
||||
|
||||
const initialOfferSignal = await getInitialOfferSignal(call, this.role);
|
||||
if (!initialOfferSignal) {
|
||||
logger.debug('The call was accepted but the webrtc offer is not yet available.');
|
||||
return;
|
||||
}
|
||||
await this.sendSignal(initialOfferSignal);
|
||||
}
|
||||
|
||||
public async onCallEnded(callId: string): Promise<void> {
|
||||
if (this.role === 'callee') {
|
||||
this.sendPushNotification({ callId, event: 'end' });
|
||||
}
|
||||
|
||||
return this.sendSignal({
|
||||
callId,
|
||||
type: 'notification',
|
||||
notification: 'hangup',
|
||||
hangupReason: await this.getCallHangupReasonForClient(callId).catch(() => 'remote' as const),
|
||||
});
|
||||
}
|
||||
|
||||
private async getCallHangupReasonForClient(callId: string): Promise<CallHangupReason> {
|
||||
const call = await MediaCalls.findOneById<Pick<IMediaCall, '_id' | 'endedBy' | 'hangupReason'>>(callId, {
|
||||
projection: { endedBy: 1, hangupReason: 1 },
|
||||
});
|
||||
if (!call) {
|
||||
return 'remote';
|
||||
}
|
||||
|
||||
const { endedBy, hangupReason } = call;
|
||||
|
||||
if (endedBy?.type !== this.actorType || endedBy?.id !== this.actorId) {
|
||||
return 'remote';
|
||||
}
|
||||
|
||||
if (hangupReason && isCallHangupReason(hangupReason)) {
|
||||
return hangupReason;
|
||||
}
|
||||
|
||||
return 'remote';
|
||||
}
|
||||
|
||||
public async onCallActive(callId: string): Promise<void> {
|
||||
return this.sendSignal({
|
||||
callId,
|
||||
type: 'notification',
|
||||
notification: 'active',
|
||||
});
|
||||
}
|
||||
|
||||
public async onCallCreated(call: IMediaCall): Promise<void> {
|
||||
await this.sendSignal(getNewCallSignal(call, this.role));
|
||||
if (this.role === 'callee') {
|
||||
this.sendPushNotification({ callId: call._id, event: 'new' });
|
||||
}
|
||||
}
|
||||
|
||||
public async onRemoteDescriptionChanged(callId: string, negotiationId: string): Promise<void> {
|
||||
const call = await MediaCalls.findOneById(callId);
|
||||
if (!call || !isBusyState(call.state)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = this.getMyCallActor(call);
|
||||
|
||||
const toContractId = actor.contractId;
|
||||
// Do not send any sdp to an actor until they have a signed contract
|
||||
if (!toContractId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const negotiation = await MediaCallNegotiations.findOneById(negotiationId);
|
||||
if (!negotiation) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (negotiation.offerer === this.role) {
|
||||
if (!negotiation.offer) {
|
||||
await this.sendSignal({
|
||||
callId,
|
||||
toContractId,
|
||||
type: 'request-offer',
|
||||
negotiationId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!negotiation.answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendSignal({
|
||||
callId,
|
||||
toContractId,
|
||||
type: 'remote-sdp',
|
||||
sdp: negotiation.answer,
|
||||
negotiationId,
|
||||
streams: negotiation.answerStreams,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!negotiation.offer) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.sendSignal({
|
||||
callId,
|
||||
toContractId,
|
||||
type: 'remote-sdp',
|
||||
sdp: negotiation.offer,
|
||||
negotiationId,
|
||||
streams: negotiation.offerStreams,
|
||||
});
|
||||
}
|
||||
|
||||
public async onCallTransferred(callId: string): Promise<void> {
|
||||
const call = await MediaCalls.findOneById(callId);
|
||||
if (!call?.transferredBy || !call?.transferredTo) {
|
||||
return;
|
||||
}
|
||||
|
||||
const actor = this.getMyCallActor(call);
|
||||
// If we haven't signed yet, we can't be transferred
|
||||
if (!actor.contractId) {
|
||||
return;
|
||||
}
|
||||
|
||||
await getMediaCallServer().requestCall({
|
||||
caller: actor as MediaCallSignedContact,
|
||||
callee: call.transferredTo,
|
||||
requestedService: call.service,
|
||||
requestedBy: call.transferredBy,
|
||||
parentCallId: call._id,
|
||||
features: call.features as CallFeature[],
|
||||
});
|
||||
}
|
||||
|
||||
public async onDTMF(callId: string, dtmf: string, duration: number): Promise<void> {
|
||||
logger.debug({ msg: 'UserActorAgent.onDTMF', callId, dtmf, duration, role: this.role });
|
||||
// internal calls have nothing to do with DTMFs
|
||||
}
|
||||
|
||||
private sendPushNotification(params: { callId: string; event: VoipPushNotificationEventType }): void {
|
||||
getMediaCallServer().sendPushNotification(params);
|
||||
}
|
||||
}
|
||||
3
ee/packages/media-calls/src/logger.ts
Normal file
3
ee/packages/media-calls/src/logger.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { Logger } from '@rocket.chat/logger';
|
||||
|
||||
export const logger = new Logger('MediaCallsServer');
|
||||
58
ee/packages/media-calls/src/server/BroadcastAgent.ts
Normal file
58
ee/packages/media-calls/src/server/BroadcastAgent.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { IMediaCall } from '@rocket.chat/core-typings';
|
||||
import type { ClientMediaSignalBody } from '@rocket.chat/media-signaling';
|
||||
|
||||
import { BaseMediaCallAgent } from '../base/BaseAgent';
|
||||
import { logger } from '../logger';
|
||||
import { getMediaCallServer } from './injection';
|
||||
import type { BaseCallProvider } from '../base/BaseCallProvider';
|
||||
|
||||
/**
|
||||
* This agent doesn't implement any logic
|
||||
* What it does is send a notification to other instances reporting that a call has been updated;
|
||||
* Then if any server instance is keeping track of this call, it'll load its data from mongo and check what changed
|
||||
*/
|
||||
export class BroadcastActorAgent extends BaseMediaCallAgent {
|
||||
public provider: BaseCallProvider | null = null;
|
||||
|
||||
public async onCallAccepted(call: IMediaCall): Promise<void> {
|
||||
this.reportCallUpdated({ callId: call._id });
|
||||
}
|
||||
|
||||
public async onCallEnded(callId: string): Promise<void> {
|
||||
this.reportCallUpdated({ callId });
|
||||
}
|
||||
|
||||
public async onCallActive(callId: string): Promise<void> {
|
||||
this.reportCallUpdated({ callId });
|
||||
}
|
||||
|
||||
public async onCallCreated(call: IMediaCall): Promise<void> {
|
||||
logger.debug({ msg: 'BroadcastActorAgent.onCallCreated', call, role: this.role });
|
||||
// there's no point in broadcasting onCallCreated as it can only be called from within the call provider
|
||||
}
|
||||
|
||||
public async onRemoteDescriptionChanged(callId: string, _negotiationId: string): Promise<void> {
|
||||
this.reportCallUpdated({ callId });
|
||||
}
|
||||
|
||||
public async onCallTransferred(callId: string): Promise<void> {
|
||||
this.reportCallUpdated({ callId });
|
||||
}
|
||||
|
||||
public async onDTMF(callId: string, dtmf: string, duration: number): Promise<void> {
|
||||
this.reportCallUpdated({ callId, dtmf: { dtmf, duration } });
|
||||
}
|
||||
|
||||
protected reportCallUpdated(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void {
|
||||
const { callId, ...otherParams } = params;
|
||||
|
||||
if (this.provider?.callId === callId) {
|
||||
this.provider.reactToCallChanges(otherParams).catch(() => {
|
||||
getMediaCallServer().reportCallUpdate(params);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
getMediaCallServer().reportCallUpdate(params);
|
||||
}
|
||||
}
|
||||
483
ee/packages/media-calls/src/server/CallDirector.ts
Normal file
483
ee/packages/media-calls/src/server/CallDirector.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type {
|
||||
IMediaCall,
|
||||
IMediaCallNegotiation,
|
||||
MediaCallContact,
|
||||
MediaCallSignedContact,
|
||||
ServerActor,
|
||||
MediaCallNegotiationStream,
|
||||
} from '@rocket.chat/core-typings';
|
||||
import type { CallFeature, CallHangupReason, CallRole } from '@rocket.chat/media-signaling';
|
||||
import type { InsertionModel } from '@rocket.chat/model-typings';
|
||||
import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models';
|
||||
|
||||
import { getCastDirector, getMediaCallServer } from './injection';
|
||||
import type { IMediaCallAgent } from '../definition/IMediaCallAgent';
|
||||
import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector';
|
||||
import type { InternalCallParams, MediaCallHeader } from '../definition/common';
|
||||
import { logger } from '../logger';
|
||||
|
||||
const EXPIRATION_TIME = 120000;
|
||||
const EXPIRATION_CHECK_TIMEOUT = EXPIRATION_TIME + 1000;
|
||||
|
||||
export type CreateCallParams = InternalCallParams & {
|
||||
callerAgent: IMediaCallAgent;
|
||||
calleeAgent: IMediaCallAgent;
|
||||
sipCallId?: string;
|
||||
};
|
||||
|
||||
// expiration checks by call id
|
||||
const scheduledExpirationChecks = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
class MediaCallDirector {
|
||||
public async hangup(call: IMediaCall, actorAgent: IMediaCallAgent, reason: CallHangupReason): Promise<void> {
|
||||
const { actor: endedBy } = actorAgent;
|
||||
logger.debug({ msg: 'MediaCallDirector.hangup', callId: call._id, reason, endedBy });
|
||||
|
||||
const modified = await this.hangupCallById(call._id, { endedBy, reason });
|
||||
if (modified) {
|
||||
await this.triggerOnCallEnded(call, actorAgent);
|
||||
}
|
||||
}
|
||||
|
||||
public async hangupByServer(call: MediaCallHeader, serverErrorCode: string): Promise<boolean> {
|
||||
return this.hangupDetachedCall(call, { reason: serverErrorCode });
|
||||
}
|
||||
|
||||
public async activate(call: IMediaCall, actorAgent: IMediaCallAgent): Promise<void> {
|
||||
logger.debug({ msg: 'MediaCallDirector.activateCall', role: actorAgent.role });
|
||||
|
||||
const stateResult = await MediaCalls.activateCallById(call._id, this.getNewExpirationTime());
|
||||
if (!stateResult.modifiedCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ msg: 'Call was flagged as active', callId: call._id });
|
||||
this.scheduleExpirationCheckByCallId(call._id);
|
||||
getMediaCallServer().emitter.emit('callActivated', { callId: call._id, uids: call.uids });
|
||||
return actorAgent.oppositeAgent?.onCallActive(call._id);
|
||||
}
|
||||
|
||||
public async acceptCall(
|
||||
call: MediaCallHeader,
|
||||
calleeAgent: IMediaCallAgent,
|
||||
data: {
|
||||
calleeContractId: string;
|
||||
webrtcAnswer?: RTCSessionDescriptionInit;
|
||||
supportedFeatures: CallFeature[];
|
||||
sipCallId?: string;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
logger.debug({ msg: 'MediaCallDirector.acceptCall' });
|
||||
|
||||
// To avoid race conditions, load the negotiation before changing the call state
|
||||
// Once the state changes, negotiations need to be referred by id.
|
||||
const negotiation = await MediaCallNegotiations.findLatestByCallId(call._id);
|
||||
|
||||
const { webrtcAnswer, ...acceptData } = data;
|
||||
|
||||
const stateResult = await MediaCalls.acceptCallById(call._id, acceptData, this.getNewExpirationTime());
|
||||
// If nothing changed, the call was no longer ringing
|
||||
if (!stateResult.modifiedCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info({ msg: 'Call was flagged as accepted', callId: call._id });
|
||||
this.scheduleExpirationCheckByCallId(call._id);
|
||||
|
||||
const updatedCall = await MediaCalls.findOneById(call._id);
|
||||
if (!updatedCall) {
|
||||
logger.error({ msg: 'Unable to find up to date call data', callId: call._id });
|
||||
return false;
|
||||
}
|
||||
|
||||
await calleeAgent.onCallAccepted(updatedCall);
|
||||
await calleeAgent.oppositeAgent?.onCallAccepted(updatedCall);
|
||||
|
||||
if (data.webrtcAnswer && negotiation) {
|
||||
const negotiationResult = await MediaCallNegotiations.setAnswerById(negotiation._id, data.webrtcAnswer);
|
||||
if (negotiationResult.modifiedCount) {
|
||||
logger.info({ msg: 'Negotiation answer was saved', callId: call._id, negotiationId: negotiation._id });
|
||||
}
|
||||
await calleeAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, negotiation._id);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public async startFirstNegotiation(
|
||||
call: MediaCallHeader,
|
||||
offer?: RTCSessionDescriptionInit,
|
||||
): Promise<IMediaCallNegotiation['_id'] | null> {
|
||||
const negotiation = await MediaCallNegotiations.findLatestByCallId(call._id);
|
||||
// If the call already has a negotiation, do nothing
|
||||
if (negotiation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.startNewNegotiation(call, 'caller', offer);
|
||||
}
|
||||
|
||||
public async startNewNegotiation(
|
||||
call: MediaCallHeader,
|
||||
offerer: CallRole,
|
||||
offer?: RTCSessionDescriptionInit,
|
||||
): Promise<IMediaCallNegotiation['_id']> {
|
||||
logger.debug({ msg: 'Adding new negotiation', callId: call._id, offerer, hasOffer: Boolean(offer) });
|
||||
const newNegotiation: InsertionModel<IMediaCallNegotiation> = {
|
||||
callId: call._id,
|
||||
offerer,
|
||||
requestTimestamp: new Date(),
|
||||
...(offer && {
|
||||
offer,
|
||||
offerTimestamp: new Date(),
|
||||
}),
|
||||
};
|
||||
|
||||
const result = await MediaCallNegotiations.insertOne(newNegotiation);
|
||||
logger.info({ msg: 'New Negotiation started', callId: call._id, negotiationId: result.insertedId, hasOffer: Boolean(offer) });
|
||||
return result.insertedId;
|
||||
}
|
||||
|
||||
public get cast(): IMediaCallCastDirector {
|
||||
try {
|
||||
return getCastDirector();
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Failed to access castDirector', err });
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async saveWebrtcSession(
|
||||
call: IMediaCall,
|
||||
fromAgent: IMediaCallAgent,
|
||||
session: { sdp: RTCSessionDescriptionInit; negotiationId: string; streams?: MediaCallNegotiationStream[] },
|
||||
contractId: string,
|
||||
): Promise<void> {
|
||||
logger.debug({ msg: 'MediaCallDirector.saveWebrtcSession', callId: call?._id });
|
||||
const negotiation = await MediaCallNegotiations.findOneById(session.negotiationId);
|
||||
if (!negotiation) {
|
||||
throw new Error('invalid-negotiation');
|
||||
}
|
||||
|
||||
const actor = fromAgent.getMyCallActor(call);
|
||||
if (!actor.contractId || actor.contractId !== contractId) {
|
||||
throw new Error('invalid-contract');
|
||||
}
|
||||
|
||||
const isOfferer = fromAgent.role === negotiation.offerer;
|
||||
const isOffer = session.sdp.type === 'offer';
|
||||
|
||||
if (isOffer !== isOfferer) {
|
||||
throw new Error('invalid-sdp');
|
||||
}
|
||||
|
||||
const updater = isOffer
|
||||
? MediaCallNegotiations.setOfferById(negotiation._id, session.sdp, session.streams)
|
||||
: MediaCallNegotiations.setAnswerById(negotiation._id, session.sdp, session.streams);
|
||||
const updateResult = await updater;
|
||||
|
||||
if (!updateResult.modifiedCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ msg: `Negotiation's ${session.sdp.type} was saved`, callId: call._id, negotiationId: negotiation._id });
|
||||
|
||||
await fromAgent.oppositeAgent?.onRemoteDescriptionChanged(call._id, negotiation._id);
|
||||
}
|
||||
|
||||
public async createCall(params: CreateCallParams): Promise<IMediaCall> {
|
||||
const { caller, callee, requestedCallId, requestedService, callerAgent, calleeAgent, parentCallId, requestedBy, features, divertedBy } =
|
||||
params;
|
||||
|
||||
// The caller must always have a contract to create the call
|
||||
if (!caller.contractId) {
|
||||
throw new Error('invalid-caller');
|
||||
}
|
||||
|
||||
const service = requestedService || 'webrtc';
|
||||
|
||||
// webrtc is our only known service right now, but if the call was requested by a client that doesn't also implement it, we don't need to even create a call
|
||||
if (service !== 'webrtc') {
|
||||
throw new Error('invalid-call-service');
|
||||
}
|
||||
|
||||
if (!callerAgent) {
|
||||
throw new Error('invalid-caller');
|
||||
}
|
||||
|
||||
if (!calleeAgent) {
|
||||
throw new Error('invalid-callee');
|
||||
}
|
||||
|
||||
callerAgent.oppositeAgent = calleeAgent;
|
||||
calleeAgent.oppositeAgent = callerAgent;
|
||||
|
||||
const allowedFeatures = features.filter((feature) => getMediaCallServer().isFeatureAvailableForUser(caller.id, feature));
|
||||
const call: Omit<IMediaCall, '_updatedAt'> = {
|
||||
// Use UUIDs to identify all media calls, for better compatibility with libs that require it (such as React Native's CallKit)
|
||||
_id: randomUUID(),
|
||||
service,
|
||||
kind: 'direct',
|
||||
state: 'none',
|
||||
|
||||
createdBy: requestedBy || caller,
|
||||
createdAt: new Date(),
|
||||
|
||||
caller,
|
||||
callee,
|
||||
|
||||
expiresAt: this.getNewExpirationTime(),
|
||||
uids: [
|
||||
// add actor ids to uids field if their type is 'user', to make it easy to identify any call an user was part of
|
||||
...(caller.type === 'user' ? [caller.id] : []),
|
||||
...(callee.type === 'user' ? [callee.id] : []),
|
||||
],
|
||||
ended: false,
|
||||
|
||||
...(requestedCallId && { callerRequestedId: requestedCallId }),
|
||||
...(parentCallId && { parentCallId }),
|
||||
...(divertedBy && { divertedBy }),
|
||||
|
||||
features: allowedFeatures,
|
||||
...(params.sipCallId && { sipCallId: params.sipCallId }),
|
||||
};
|
||||
|
||||
logger.debug({ msg: 'creating call', call });
|
||||
|
||||
const insertResult = await MediaCalls.insertOne(call);
|
||||
if (!insertResult.insertedId) {
|
||||
throw new Error('failed-to-create-call');
|
||||
}
|
||||
|
||||
const newCall = await MediaCalls.findOneById(insertResult.insertedId);
|
||||
if (!newCall) {
|
||||
throw new Error('failed-to-retrieve-call');
|
||||
}
|
||||
|
||||
logger.info({ msg: `New call was created`, callId: newCall._id });
|
||||
this.scheduleExpirationCheckByCallId(newCall._id);
|
||||
|
||||
return newCall;
|
||||
}
|
||||
|
||||
public async transferCall(
|
||||
call: MediaCallHeader,
|
||||
to: MediaCallContact,
|
||||
by: MediaCallSignedContact,
|
||||
agent: IMediaCallAgent,
|
||||
): Promise<void> {
|
||||
if (!agent.oppositeAgent) {
|
||||
logger.error({ msg: 'Unable to transfer calls without a reference to the opposite agent.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updateResult = await MediaCalls.transferCallById(call._id, { by, to });
|
||||
if (!updateResult.modifiedCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info({ msg: `Call was flagged as transferred`, callId: call._id });
|
||||
|
||||
await agent.oppositeAgent.onCallTransferred(call._id);
|
||||
}
|
||||
|
||||
public async hangupTransferredCallById(callId: string): Promise<boolean> {
|
||||
logger.debug({ msg: 'MediaCallDirector.hangupTransferredCallById', callId });
|
||||
const call = await MediaCalls.findOneById(callId);
|
||||
if (!call?.transferredBy) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.hangupDetachedCall(call, { endedBy: call.transferredBy, reason: 'transfer' });
|
||||
}
|
||||
|
||||
public async hangupExpiredCalls(): Promise<void>;
|
||||
|
||||
public async hangupExpiredCalls(expectedCallId: string): Promise<boolean>;
|
||||
|
||||
public async hangupExpiredCalls(expectedCallId?: string): Promise<boolean | void> {
|
||||
logger.debug('MediaCallDirector.hangupExpiredCalls');
|
||||
|
||||
let expectedCallWasExpired = false;
|
||||
|
||||
const result = MediaCalls.findAllExpiredCalls<MediaCallHeader>({
|
||||
projection: { _id: 1, caller: 1, callee: 1 },
|
||||
});
|
||||
|
||||
for await (const call of result) {
|
||||
if (expectedCallId && call._id === expectedCallId) {
|
||||
expectedCallWasExpired = true;
|
||||
}
|
||||
|
||||
await this.hangupByServer(call, 'expired');
|
||||
}
|
||||
|
||||
if (typeof expectedCallId === 'string') {
|
||||
return expectedCallWasExpired;
|
||||
}
|
||||
}
|
||||
|
||||
public getNewExpirationTime(): Date {
|
||||
return new Date(Date.now() + EXPIRATION_TIME);
|
||||
}
|
||||
|
||||
public async renewCallId(callId: string): Promise<void> {
|
||||
await MediaCalls.setExpiresAtById(callId, this.getNewExpirationTime());
|
||||
this.scheduleExpirationCheckByCallId(callId);
|
||||
}
|
||||
|
||||
public scheduleExpirationCheckByCallId(callId: string): void {
|
||||
const oldHandler = scheduledExpirationChecks.get(callId);
|
||||
if (oldHandler) {
|
||||
clearTimeout(oldHandler);
|
||||
scheduledExpirationChecks.delete(callId);
|
||||
}
|
||||
|
||||
const handler = setTimeout(async () => {
|
||||
logger.debug({ msg: 'MediaCallDirector.scheduleExpirationCheckByCallId.timeout', callId });
|
||||
scheduledExpirationChecks.delete(callId);
|
||||
|
||||
const expectedCallWasExpired = await this.hangupExpiredCalls(callId).catch((err) =>
|
||||
logger.error({ msg: 'Media Call Monitor failed to hangup expired calls', err }),
|
||||
);
|
||||
|
||||
if (!expectedCallWasExpired) {
|
||||
const call = await MediaCalls.findOneById(callId, { projection: { ended: 1 } });
|
||||
if (call && !call.ended) {
|
||||
this.scheduleExpirationCheckByCallId(callId);
|
||||
}
|
||||
}
|
||||
}, EXPIRATION_CHECK_TIMEOUT);
|
||||
|
||||
scheduledExpirationChecks.set(callId, handler);
|
||||
}
|
||||
|
||||
public scheduleExpirationCheck(): void {
|
||||
setTimeout(async () => {
|
||||
logger.debug({ msg: 'MediaCallDirector.scheduleExpirationCheck.timeout' });
|
||||
await this.hangupExpiredCalls().catch((err) => logger.error({ msg: 'Media Call Monitor failed to hangup expired calls', err }));
|
||||
}, EXPIRATION_CHECK_TIMEOUT);
|
||||
}
|
||||
|
||||
public async runOnCallCreatedForAgent(call: IMediaCall, agent: IMediaCallAgent, agentToNotifyIfItFails?: IMediaCallAgent): Promise<void> {
|
||||
try {
|
||||
await agent.onCallCreated(call);
|
||||
} catch (err) {
|
||||
// If the agent failed, we assume they cleaned up after themselves and just hangup the call
|
||||
// We then notify the other agent that the call has ended, but only if it the agent was already notified about this call in the first place
|
||||
logger.error({
|
||||
msg: 'Agent failed to process a new call.',
|
||||
err,
|
||||
agentRole: agent.role,
|
||||
callerType: call.caller.type,
|
||||
calleeType: call.callee.type,
|
||||
});
|
||||
await this.hangupCallByIdAndNotifyAgents(call._id, agentToNotifyIfItFails ? [agentToNotifyIfItFails] : [], {
|
||||
endedBy: agent.getMyCallActor(call),
|
||||
reason: 'error',
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
public async hangupCallById(callId: string, params?: { endedBy?: IMediaCall['endedBy']; reason?: string }): Promise<boolean> {
|
||||
// Ensure we don't pass along anything more than the three basic actor attributes
|
||||
const endedBy =
|
||||
params?.endedBy?.type === 'server'
|
||||
? { type: params.endedBy.type, id: params.endedBy.id }
|
||||
: params?.endedBy && { type: params.endedBy.type, id: params.endedBy.id, contractId: params.endedBy.contractId };
|
||||
|
||||
const cleanedParams = params && {
|
||||
...params,
|
||||
...(endedBy && { endedBy }),
|
||||
};
|
||||
|
||||
const result = await MediaCalls.hangupCallById(callId, cleanedParams).catch((err) => {
|
||||
logger.error({
|
||||
msg: 'Failed to hangup a call.',
|
||||
callId,
|
||||
err,
|
||||
hangupReason: params?.reason,
|
||||
hangupActor: params?.endedBy,
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
const ended = Boolean(result.modifiedCount);
|
||||
if (ended) {
|
||||
logger.info({ msg: 'Call was flagged as ended', callId, reason: params?.reason });
|
||||
getMediaCallServer().updateCallHistory({ callId });
|
||||
const call = await MediaCalls.findOneById<Pick<IMediaCall, '_id' | 'uids'>>(callId, { projection: { uids: 1 } });
|
||||
if (call) {
|
||||
getMediaCallServer().emitter.emit('callEnded', { callId, uids: call.uids });
|
||||
}
|
||||
}
|
||||
|
||||
return ended;
|
||||
}
|
||||
|
||||
public async hangupCallByIdAndNotifyAgents(
|
||||
callId: string,
|
||||
agents: IMediaCallAgent[],
|
||||
params?: { endedBy?: IMediaCall['endedBy']; reason?: string },
|
||||
): Promise<boolean> {
|
||||
const modified = await this.hangupCallById(callId, params).catch(() => false);
|
||||
if (!modified || !agents?.length) {
|
||||
return modified;
|
||||
}
|
||||
|
||||
await Promise.allSettled(
|
||||
agents.map(async (agent) =>
|
||||
agent.onCallEnded(callId).catch((err) => logger.error({ msg: 'Failed to notify agent of a hangup', err, actor: agent.actor })),
|
||||
),
|
||||
);
|
||||
return modified;
|
||||
}
|
||||
|
||||
public async hangupDetachedCall(call: MediaCallHeader, params?: { endedBy?: IMediaCall['endedBy']; reason?: string }): Promise<boolean> {
|
||||
logger.debug({ msg: 'MediaCallDirector.hangupDetachedCall', callId: call._id, params });
|
||||
let modified = false;
|
||||
try {
|
||||
const endedBy = params?.endedBy || ({ type: 'server', id: 'server' } as ServerActor);
|
||||
|
||||
modified = await this.hangupCallById(call._id, { ...params, endedBy });
|
||||
if (!modified) {
|
||||
return modified;
|
||||
}
|
||||
|
||||
// Try to notify the agents but there's no guarantee they are reachable
|
||||
try {
|
||||
const agents = await this.cast.getAgentsFromCall(call);
|
||||
for (const agent of Object.values(agents)) {
|
||||
agent?.onCallEnded(call._id).catch(() => null);
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors on the ended event
|
||||
}
|
||||
return modified;
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Failed to terminate call.', err, callId: call._id, params });
|
||||
return modified;
|
||||
}
|
||||
}
|
||||
|
||||
private async getAgentFromCall(call: IMediaCall, role: CallRole): Promise<IMediaCallAgent | null> {
|
||||
return this.cast.getAgentFromCall(call, role).catch(() => null);
|
||||
}
|
||||
|
||||
private async triggerOnCallEnded(call: IMediaCall, agent: IMediaCallAgent): Promise<void> {
|
||||
await agent.onCallEnded(call._id);
|
||||
if (agent.oppositeAgent) {
|
||||
return agent.oppositeAgent.onCallEnded(call._id);
|
||||
}
|
||||
|
||||
const oppositeAgent = await this.getAgentFromCall(call, agent.oppositeRole);
|
||||
if (oppositeAgent) {
|
||||
await oppositeAgent?.onCallEnded(call._id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const mediaCallDirector = new MediaCallDirector();
|
||||
168
ee/packages/media-calls/src/server/CastDirector.ts
Normal file
168
ee/packages/media-calls/src/server/CastDirector.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import type { IUser, MediaCallActor, MediaCallActorType, MediaCallContact, MediaCallContactInformation } from '@rocket.chat/core-typings';
|
||||
import type { CallRole } from '@rocket.chat/media-signaling';
|
||||
import { Users } from '@rocket.chat/models';
|
||||
|
||||
import type { IMediaCallAgent } from '../definition/IMediaCallAgent';
|
||||
import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector';
|
||||
import type { GetActorContactOptions, MinimalUserData, MediaCallHeader } from '../definition/common';
|
||||
import { UserActorAgent } from '../internal/agents/UserActorAgent';
|
||||
import { logger } from '../logger';
|
||||
import { BroadcastActorAgent } from './BroadcastAgent';
|
||||
|
||||
type ContactList = Record<MediaCallActorType, MediaCallContact | null>;
|
||||
|
||||
export class MediaCallCastDirector implements IMediaCallCastDirector {
|
||||
public async getAgentsFromCall(call: MediaCallHeader): Promise<{ caller: IMediaCallAgent; callee: IMediaCallAgent }> {
|
||||
const callerAgent = await this.getAgentFromCall(call, 'caller');
|
||||
if (!callerAgent) {
|
||||
throw new Error('Unable to find caller agent');
|
||||
}
|
||||
|
||||
const calleeAgent = await this.getAgentFromCall(call, 'callee');
|
||||
if (!calleeAgent) {
|
||||
throw new Error('Unable to find callee agent');
|
||||
}
|
||||
|
||||
callerAgent.oppositeAgent = calleeAgent;
|
||||
calleeAgent.oppositeAgent = callerAgent;
|
||||
|
||||
return {
|
||||
caller: callerAgent,
|
||||
callee: calleeAgent,
|
||||
};
|
||||
}
|
||||
|
||||
public async getAgentFromCall(call: MediaCallHeader, role: CallRole): Promise<IMediaCallAgent | null> {
|
||||
const { [role]: actor } = call;
|
||||
|
||||
return this.getAgentForActorAndRole(actor, role);
|
||||
}
|
||||
|
||||
public async getContactForActor(
|
||||
actor: MediaCallActor,
|
||||
options: GetActorContactOptions,
|
||||
defaultContactInfo?: MediaCallContactInformation,
|
||||
): Promise<MediaCallContact | null> {
|
||||
if (actor.type === 'user') {
|
||||
return this.getContactForUserId(actor.id, options, { ...actor, ...defaultContactInfo });
|
||||
}
|
||||
|
||||
if (actor.type === 'sip') {
|
||||
return this.getContactForExtensionNumber(actor.id, options, { ...actor, ...defaultContactInfo });
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public getContactForUser(
|
||||
user: MinimalUserData,
|
||||
options: GetActorContactOptions,
|
||||
defaultContactInfo?: MediaCallContactInformation,
|
||||
): MediaCallContact | null {
|
||||
const actors = this.buildContactListForUser(user, defaultContactInfo);
|
||||
return this.getContactFromList(actors, options);
|
||||
}
|
||||
|
||||
public async getContactForUserId(
|
||||
userId: string,
|
||||
options: GetActorContactOptions,
|
||||
defaultContactInfo?: MediaCallContactInformation,
|
||||
): Promise<MediaCallContact | null> {
|
||||
const user = await Users.findOneById<Pick<IUser, '_id' | 'name' | 'username' | 'freeSwitchExtension'>>(userId, {
|
||||
projection: { name: 1, username: 1, freeSwitchExtension: 1 },
|
||||
});
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.getContactForUser(user, options, defaultContactInfo);
|
||||
}
|
||||
|
||||
public async getContactForExtensionNumber(
|
||||
sipExtension: string,
|
||||
options: GetActorContactOptions,
|
||||
defaultContactInfo?: MediaCallContactInformation,
|
||||
): Promise<MediaCallContact | null> {
|
||||
const user = await Users.findOneByFreeSwitchExtension<Pick<IUser, '_id' | 'name' | 'username' | 'freeSwitchExtension'>>(sipExtension, {
|
||||
projection: { name: 1, username: 1, freeSwitchExtension: 1 },
|
||||
});
|
||||
|
||||
const list = user
|
||||
? this.buildContactListForUser(user, defaultContactInfo)
|
||||
: this.buildContactListForExtension(sipExtension, defaultContactInfo);
|
||||
|
||||
return this.getContactFromList(list, options);
|
||||
}
|
||||
|
||||
public async getAgentForActorAndRole(actor: MediaCallContact, role: CallRole): Promise<IMediaCallAgent | null> {
|
||||
if (actor.type === 'user') {
|
||||
return this.getAgentForUserActorAndRole(actor, role);
|
||||
}
|
||||
|
||||
if (actor.type === 'sip') {
|
||||
return this.getAgentForSipActorAndRole(actor, role);
|
||||
}
|
||||
|
||||
logger.warn({ msg: 'Invalid actor type', actor });
|
||||
return null;
|
||||
}
|
||||
|
||||
protected async getAgentForUserActorAndRole(actor: MediaCallContact, role: CallRole): Promise<UserActorAgent | null> {
|
||||
return new UserActorAgent(actor, role);
|
||||
}
|
||||
|
||||
protected async getAgentForSipActorAndRole(actor: MediaCallContact, role: CallRole): Promise<BroadcastActorAgent | null> {
|
||||
return new BroadcastActorAgent(actor, role);
|
||||
}
|
||||
|
||||
protected buildContactListForUser(user: MinimalUserData, defaultContactInfo?: MediaCallContactInformation): ContactList {
|
||||
const { name: displayName, username, freeSwitchExtension: sipExtension, _id: id } = user;
|
||||
|
||||
const data: Partial<MediaCallContact> = {
|
||||
...defaultContactInfo,
|
||||
...(displayName && { displayName }),
|
||||
...(username && { username }),
|
||||
...(sipExtension && { sipExtension }),
|
||||
};
|
||||
|
||||
return {
|
||||
user: {
|
||||
...data,
|
||||
type: 'user',
|
||||
id,
|
||||
},
|
||||
sip: sipExtension
|
||||
? {
|
||||
...data,
|
||||
type: 'sip',
|
||||
id: sipExtension,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
protected buildContactListForExtension(sipExtension: string, defaultContactInfo?: MediaCallContactInformation): ContactList {
|
||||
const data: Partial<MediaCallContact> = {
|
||||
...defaultContactInfo,
|
||||
...(sipExtension && { sipExtension }),
|
||||
};
|
||||
|
||||
return {
|
||||
user: null,
|
||||
sip: {
|
||||
...data,
|
||||
type: 'sip',
|
||||
id: sipExtension,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
protected getContactFromList(list: ContactList, options: GetActorContactOptions): MediaCallContact | null {
|
||||
if (options.requiredType) {
|
||||
return list[options.requiredType] ?? null;
|
||||
}
|
||||
|
||||
const preferredActor = options.preferredType && list[options.preferredType];
|
||||
return preferredActor || list.user || list.sip || null;
|
||||
}
|
||||
}
|
||||
262
ee/packages/media-calls/src/server/MediaCallServer.ts
Normal file
262
ee/packages/media-calls/src/server/MediaCallServer.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import type { IUser } from '@rocket.chat/core-typings';
|
||||
import { Emitter } from '@rocket.chat/emitter';
|
||||
import type {
|
||||
CallFeature,
|
||||
CallRejectedReason,
|
||||
ClientMediaSignal,
|
||||
ClientMediaSignalBody,
|
||||
ServerMediaSignal,
|
||||
} from '@rocket.chat/media-signaling';
|
||||
|
||||
import { mediaCallDirector } from './CallDirector';
|
||||
import { getDefaultSettings } from './getDefaultSettings';
|
||||
import { stripSensitiveDataFromSignal } from './stripSensitiveData';
|
||||
import type {
|
||||
IMediaCallServer,
|
||||
IMediaCallServerSettings,
|
||||
MediaCallServerEvents,
|
||||
VoipPushNotificationEventType,
|
||||
} from '../definition/IMediaCallServer';
|
||||
import { CallRejectedError } from '../definition/common';
|
||||
import type { SignalProcessingOptions, GetActorContactOptions, InternalCallParams } from '../definition/common';
|
||||
import { InternalCallProvider } from '../internal/InternalCallProvider';
|
||||
import { GlobalSignalProcessor } from '../internal/SignalProcessor';
|
||||
import { logger } from '../logger';
|
||||
import { SipServerSession } from '../sip/Session';
|
||||
|
||||
/**
|
||||
* Class used as gateway to send and receive signals to/from clients
|
||||
* The actual function used to send the signals needs to be set by the server
|
||||
*/
|
||||
export class MediaCallServer implements IMediaCallServer {
|
||||
private session: SipServerSession;
|
||||
|
||||
private signalProcessor: GlobalSignalProcessor;
|
||||
|
||||
private settings: IMediaCallServerSettings;
|
||||
|
||||
public emitter: Emitter<MediaCallServerEvents>;
|
||||
|
||||
constructor() {
|
||||
this.emitter = new Emitter();
|
||||
this.session = new SipServerSession();
|
||||
this.settings = getDefaultSettings();
|
||||
this.signalProcessor = new GlobalSignalProcessor();
|
||||
|
||||
this.signalProcessor.emitter.on('signalRequest', ({ toUid, signal }) => {
|
||||
// Forward signal requests from the signal processor to the server
|
||||
this.sendSignal(toUid, signal);
|
||||
});
|
||||
this.signalProcessor.emitter.on('callRequest', ({ params }) => {
|
||||
this.requestCall(params).catch(() => null);
|
||||
});
|
||||
}
|
||||
|
||||
public async receiveSignal(fromUid: IUser['_id'], signal: ClientMediaSignal, options: SignalProcessingOptions = {}): Promise<void> {
|
||||
return this.signalProcessor.processSignal(fromUid, signal, options);
|
||||
}
|
||||
|
||||
public sendSignal(toUid: IUser['_id'], signal: ServerMediaSignal): void {
|
||||
logger.debug({ msg: 'MediaCallServer.sendSignal', toUid, signal: stripSensitiveDataFromSignal(signal) });
|
||||
|
||||
this.emitter.emit('signalRequest', { toUid, signal });
|
||||
}
|
||||
|
||||
public reportCallUpdate(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void {
|
||||
logger.debug({ msg: 'MediaCallServer.reportCallUpdate', params });
|
||||
|
||||
this.emitter.emit('callUpdated', params);
|
||||
}
|
||||
|
||||
public updateCallHistory(params: { callId: string }): void {
|
||||
logger.debug({ msg: 'MediaCallServer.updateCallHistory', params });
|
||||
|
||||
this.emitter.emit('historyUpdate', params);
|
||||
}
|
||||
|
||||
public sendPushNotification(params: { callId: string; event: VoipPushNotificationEventType }): void {
|
||||
if (!this.settings.mobileRinging) {
|
||||
return;
|
||||
}
|
||||
logger.debug({ msg: 'MediaCallServer.sendPushNotification', params });
|
||||
|
||||
this.emitter.emit('pushNotificationRequest', params);
|
||||
}
|
||||
|
||||
public async requestCall(params: InternalCallParams): Promise<void> {
|
||||
try {
|
||||
const fullParams = await this.parseCallContacts(params);
|
||||
|
||||
await this.createCall(fullParams);
|
||||
} catch (error) {
|
||||
let rejectionReason: CallRejectedReason = 'unsupported';
|
||||
if (error && typeof error === 'object' && error instanceof CallRejectedError) {
|
||||
rejectionReason = error.callRejectedReason;
|
||||
} else {
|
||||
logger.error({ msg: 'Failed to create a requested call', params, err: error });
|
||||
}
|
||||
|
||||
const originalId = params.requestedCallId || params.parentCallId;
|
||||
|
||||
if (originalId && params.requestedBy?.type === 'user') {
|
||||
logger.info({ msg: 'Call Request Rejected', uid: params.requestedBy.id, rejectionReason });
|
||||
|
||||
this.sendSignal(params.requestedBy.id, {
|
||||
type: 'rejected-call-request',
|
||||
callId: originalId,
|
||||
toContractId: params.requestedBy.contractId,
|
||||
reason: rejectionReason,
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async createCall(params: InternalCallParams): Promise<void> {
|
||||
logger.debug({ msg: 'MediaCallServer.createCall', params });
|
||||
|
||||
if (params.callee.type === 'sip') {
|
||||
await this.session.createOutgoingCall(params);
|
||||
return;
|
||||
}
|
||||
|
||||
await InternalCallProvider.createCall(params);
|
||||
}
|
||||
|
||||
public receiveCallUpdate(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void {
|
||||
this.session.reactToCallUpdate(params);
|
||||
}
|
||||
|
||||
public async hangupExpiredCalls(): Promise<void> {
|
||||
return mediaCallDirector.hangupExpiredCalls();
|
||||
}
|
||||
|
||||
public scheduleExpirationCheck(): void {
|
||||
mediaCallDirector.scheduleExpirationCheck();
|
||||
}
|
||||
|
||||
public configure(settings: IMediaCallServerSettings): void {
|
||||
logger.debug({ msg: 'Media Server Configuration' });
|
||||
this.session.configure(settings);
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
public async permissionCheck(uid: IUser['_id'], callType: 'internal' | 'external' | 'any'): Promise<boolean> {
|
||||
return this.settings.permissionCheck(uid, callType);
|
||||
}
|
||||
|
||||
public isFeatureAvailableForUser(uid: IUser['_id'], feature: CallFeature): boolean {
|
||||
return this.settings.isFeatureAvailableForUser(uid, feature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Receives params for a call a client wishes to do, with actors needing only their basic identification
|
||||
* Returns params for a call that should actually be done, according to server routing rules
|
||||
* Returned value also include full contact information for the actors, when such information is available on the server
|
||||
*
|
||||
* Will throw if a call can't be routed or if one of the user lacks permission for it.
|
||||
* Blocked permissions do not affect the routing rules, meaning a call may be blocked even if it would have been allowed through a different route.
|
||||
* */
|
||||
private async parseCallContacts(params: InternalCallParams): Promise<InternalCallParams> {
|
||||
// On call transfers, do not mutate the caller
|
||||
// On new calls, force the caller type to be 'user' (since the call is being created in rocket.chat first)
|
||||
const isTransfer = Boolean(params.parentCallId);
|
||||
const callerRequiredType = isTransfer ? params.caller.type : 'user';
|
||||
|
||||
const requester = params.requestedBy || params.caller;
|
||||
|
||||
// Internal and outgoing calls must have been requested by an internal user;
|
||||
// Incoming calls should not be passing through this function.
|
||||
if (requester.type !== 'user') {
|
||||
logger.warn('Invalid call requester');
|
||||
throw new CallRejectedError('invalid-call-params');
|
||||
}
|
||||
// If this user can't make any call at all, fail early to avoid leaking if the callee is valid.
|
||||
if (!(await this.settings.permissionCheck(requester.id, 'any'))) {
|
||||
logger.debug({ msg: 'User with no permission requested a call.', uid: requester.id });
|
||||
throw new CallRejectedError('forbidden');
|
||||
}
|
||||
|
||||
const caller = await mediaCallDirector.cast.getContactForActor(params.caller, { requiredType: callerRequiredType });
|
||||
if (!caller) {
|
||||
logger.debug('Failed to load caller contact information');
|
||||
throw new CallRejectedError('invalid-call-params');
|
||||
}
|
||||
|
||||
// The callee contact type will determine if the call is going to go through SIP or directly to another rocket.chat user
|
||||
const callee = await mediaCallDirector.cast.getContactForActor(params.callee, this.getCalleeContactOptions());
|
||||
if (!callee) {
|
||||
logger.debug('Failed to load callee contact information');
|
||||
throw new CallRejectedError('invalid-call-params');
|
||||
}
|
||||
|
||||
if (this.settings.internalCalls.requireExtensions && !callee.sipExtension) {
|
||||
logger.debug('Invalid target user');
|
||||
throw new CallRejectedError('invalid-call-params');
|
||||
}
|
||||
|
||||
if (callee.type === 'user') {
|
||||
if (!(await this.settings.permissionCheck(requester.id, 'internal'))) {
|
||||
logger.debug('Requester lacks permission');
|
||||
throw new CallRejectedError('forbidden');
|
||||
}
|
||||
if (!(await this.settings.permissionCheck(callee.id, 'internal'))) {
|
||||
logger.debug('Callee lacks permission');
|
||||
throw new CallRejectedError('forbidden');
|
||||
}
|
||||
if (caller.type === 'user' && caller.id !== requester.id && !(await this.settings.permissionCheck(caller.id, 'internal'))) {
|
||||
logger.debug('Caller lacks permission');
|
||||
throw new CallRejectedError('forbidden');
|
||||
}
|
||||
} else {
|
||||
if (caller.type !== 'user') {
|
||||
logger.debug('Invalid call direction: user initiating a sip->user call');
|
||||
throw new CallRejectedError('invalid-call-params');
|
||||
}
|
||||
|
||||
if (!(await this.settings.permissionCheck(requester.id, 'external'))) {
|
||||
logger.debug('Requester lacks permission');
|
||||
throw new CallRejectedError('forbidden');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...params,
|
||||
caller: {
|
||||
...caller,
|
||||
contractId: params.caller.contractId,
|
||||
},
|
||||
callee,
|
||||
};
|
||||
}
|
||||
|
||||
private getCalleeContactOptions(): GetActorContactOptions {
|
||||
if (!this.settings.sip.enabled) {
|
||||
return {
|
||||
requiredType: 'user',
|
||||
};
|
||||
}
|
||||
|
||||
switch (this.settings.internalCalls.routeExternally) {
|
||||
case 'always':
|
||||
// Will only make sip calls
|
||||
return {
|
||||
requiredType: 'sip',
|
||||
};
|
||||
case 'never':
|
||||
// Will not use sip when calling an user or an extension assigned to an user, but will use sip when calling an unassigned extension
|
||||
return {
|
||||
preferredType: 'user',
|
||||
};
|
||||
case 'preferably':
|
||||
// Will only skip sip for users that don't have an assigned extension (or not call at all if `requireExtensions` is true)
|
||||
return {
|
||||
preferredType: 'sip',
|
||||
};
|
||||
|
||||
default:
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
9
ee/packages/media-calls/src/server/configuration.ts
Normal file
9
ee/packages/media-calls/src/server/configuration.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { MediaCallCastDirector } from './CastDirector';
|
||||
import { MediaCallServer } from './MediaCallServer';
|
||||
import { setCastDirector, setMediaCallServer } from './injection';
|
||||
|
||||
export const castDirector = new MediaCallCastDirector();
|
||||
export const callServer = new MediaCallServer();
|
||||
|
||||
setCastDirector(castDirector);
|
||||
setMediaCallServer(callServer);
|
||||
13
ee/packages/media-calls/src/server/getCallRoleForUser.ts
Normal file
13
ee/packages/media-calls/src/server/getCallRoleForUser.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { IMediaCall, IUser } from '@rocket.chat/core-typings';
|
||||
import type { CallRole } from '@rocket.chat/media-signaling';
|
||||
|
||||
export function getCallRoleForUser(call: IMediaCall, uid: IUser['_id']): CallRole | null {
|
||||
if (call.caller.type === 'user' && call.caller.id === uid) {
|
||||
return 'caller';
|
||||
}
|
||||
if (call.callee.type === 'user' && call.callee.id === uid) {
|
||||
return 'callee';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
26
ee/packages/media-calls/src/server/getDefaultSettings.ts
Normal file
26
ee/packages/media-calls/src/server/getDefaultSettings.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { IMediaCallServerSettings } from '../definition/IMediaCallServer';
|
||||
|
||||
export function getDefaultSettings(): IMediaCallServerSettings {
|
||||
return {
|
||||
internalCalls: {
|
||||
requireExtensions: false,
|
||||
routeExternally: 'never',
|
||||
},
|
||||
sip: {
|
||||
enabled: false,
|
||||
drachtio: {
|
||||
host: '',
|
||||
port: 9022,
|
||||
secret: '',
|
||||
},
|
||||
sipServer: {
|
||||
host: '',
|
||||
port: 5080,
|
||||
},
|
||||
},
|
||||
mobileRinging: false,
|
||||
|
||||
permissionCheck: async () => false,
|
||||
isFeatureAvailableForUser: () => false,
|
||||
};
|
||||
}
|
||||
32
ee/packages/media-calls/src/server/injection.ts
Normal file
32
ee/packages/media-calls/src/server/injection.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { IMediaCallCastDirector } from '../definition/IMediaCallCastDirector';
|
||||
import type { IMediaCallServer } from '../definition/IMediaCallServer';
|
||||
import { logger } from '../logger';
|
||||
|
||||
let castDirectorInstance: IMediaCallCastDirector | null = null;
|
||||
let mediaCallServerInstance: IMediaCallServer | null = null;
|
||||
|
||||
export function setCastDirector(director: IMediaCallCastDirector): void {
|
||||
castDirectorInstance = director;
|
||||
}
|
||||
|
||||
export function getCastDirector(): IMediaCallCastDirector {
|
||||
if (!castDirectorInstance) {
|
||||
logger.error({ msg: 'cast director instance was not set.', stack: new Error().stack });
|
||||
throw new Error('cast director instance was not set.');
|
||||
}
|
||||
|
||||
return castDirectorInstance;
|
||||
}
|
||||
|
||||
export function setMediaCallServer(server: IMediaCallServer): void {
|
||||
mediaCallServerInstance = server;
|
||||
}
|
||||
|
||||
export function getMediaCallServer(): IMediaCallServer {
|
||||
if (!mediaCallServerInstance) {
|
||||
logger.error({ msg: 'media call server instance was not set.', stack: new Error().stack });
|
||||
throw new Error('media call server instance was not set.');
|
||||
}
|
||||
|
||||
return mediaCallServerInstance;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { IMediaCall } from '@rocket.chat/core-typings';
|
||||
import type { CallRole, ServerMediaSignalRemoteSDP } from '@rocket.chat/media-signaling';
|
||||
import { MediaCallNegotiations } from '@rocket.chat/models';
|
||||
|
||||
export async function getInitialOfferSignal(call: IMediaCall, role: CallRole): Promise<ServerMediaSignalRemoteSDP | null> {
|
||||
const { [role]: actor } = call;
|
||||
if (!actor.contractId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const negotiation = await MediaCallNegotiations.findLatestByCallId(call._id);
|
||||
if (!negotiation?.offer || negotiation.offerer === role) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
callId: call._id,
|
||||
toContractId: actor.contractId,
|
||||
type: 'remote-sdp',
|
||||
sdp: negotiation.offer,
|
||||
negotiationId: negotiation._id,
|
||||
streams: negotiation.offerStreams,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { IMediaCall } from '@rocket.chat/core-typings';
|
||||
import type { CallFlag, CallRole, ServerMediaSignalNewCall } from '@rocket.chat/media-signaling';
|
||||
|
||||
import { getNewCallTransferredBy } from './getNewCallTransferredBy';
|
||||
|
||||
function getCallFlags(call: IMediaCall, role: CallRole): CallFlag[] {
|
||||
const flags: CallFlag[] = [];
|
||||
|
||||
const isInternal = call.caller.type === 'user' && call.callee.type === 'user';
|
||||
const shouldCreateDataChannel = isInternal && role === 'caller';
|
||||
|
||||
if (isInternal) {
|
||||
flags.push('internal');
|
||||
|
||||
if (shouldCreateDataChannel) {
|
||||
flags.push('create-data-channel');
|
||||
}
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
export function getNewCallSignal(call: IMediaCall, role: CallRole): ServerMediaSignalNewCall {
|
||||
const self = role === 'caller' ? call.caller : call.callee;
|
||||
const contact = role === 'caller' ? call.callee : call.caller;
|
||||
const transferredBy = getNewCallTransferredBy(call);
|
||||
const flags = getCallFlags(call, role);
|
||||
|
||||
return {
|
||||
callId: call._id,
|
||||
type: 'new',
|
||||
service: call.service,
|
||||
kind: call.kind,
|
||||
role,
|
||||
self: { ...self },
|
||||
contact: { ...contact },
|
||||
flags,
|
||||
...(call.parentCallId && { replacingCallId: call.parentCallId }),
|
||||
...(transferredBy && { transferredBy }),
|
||||
...(call.callerRequestedId && role === 'caller' && { requestedCallId: call.callerRequestedId }),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { IMediaCall } from '@rocket.chat/core-typings';
|
||||
import type { CallContact } from '@rocket.chat/media-signaling';
|
||||
|
||||
export function getNewCallTransferredBy(call: IMediaCall): CallContact | null {
|
||||
const { createdBy, parentCallId, caller, callee, divertedBy } = call;
|
||||
|
||||
if (divertedBy) {
|
||||
return divertedBy;
|
||||
}
|
||||
|
||||
if (!createdBy || !parentCallId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (createdBy.type === caller.type && createdBy.id === caller.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (createdBy.type === callee.type && createdBy.id === callee.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createdBy;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { IMediaCall, IUser } from '@rocket.chat/core-typings';
|
||||
import type { ServerMediaCallSignal } from '@rocket.chat/media-signaling';
|
||||
|
||||
import { getNewCallSignal } from './getNewCallSignal';
|
||||
import { getCallRoleForUser } from '../getCallRoleForUser';
|
||||
import { getInitialOfferSignal } from './getInitialOfferSignal';
|
||||
import { getStateNotification } from './getStateNotification';
|
||||
|
||||
export async function getSignalsForExistingCall(call: IMediaCall, uid: IUser['_id'], contractId: string): Promise<ServerMediaCallSignal[]> {
|
||||
if (call.state === 'hangup') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const role = getCallRoleForUser(call, uid);
|
||||
if (!role) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const signals: ServerMediaCallSignal[] = [];
|
||||
signals.push(getNewCallSignal(call, role));
|
||||
|
||||
const stateSignal = getStateNotification(call, role);
|
||||
if (stateSignal) {
|
||||
signals.push(stateSignal);
|
||||
}
|
||||
|
||||
if (role === 'callee' && call.callee.contractId === contractId) {
|
||||
const initialOfferSignal = await getInitialOfferSignal(call, role);
|
||||
if (initialOfferSignal) {
|
||||
signals.push(initialOfferSignal);
|
||||
}
|
||||
}
|
||||
|
||||
return signals;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { IMediaCall } from '@rocket.chat/core-typings';
|
||||
import { isPendingState } from '@rocket.chat/media-signaling';
|
||||
import type { CallFeature, CallNotification, CallRole, ServerMediaSignalNotification } from '@rocket.chat/media-signaling';
|
||||
|
||||
function getStateForNotification(call: IMediaCall): CallNotification | null {
|
||||
if (call.ended || call.state === 'hangup') {
|
||||
return 'hangup';
|
||||
}
|
||||
|
||||
if (call.state === 'active') {
|
||||
return 'active';
|
||||
}
|
||||
|
||||
if (isPendingState(call.state) || !call.callee.contractId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return 'accepted';
|
||||
}
|
||||
|
||||
export function getStateNotification(call: IMediaCall, role: CallRole): ServerMediaSignalNotification | null {
|
||||
const state = getStateForNotification(call);
|
||||
if (!state) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const actor = call[role];
|
||||
|
||||
return {
|
||||
callId: call._id,
|
||||
type: 'notification',
|
||||
notification: state,
|
||||
...(actor.contractId && { signedContractId: actor.contractId }),
|
||||
features: call.features as CallFeature[],
|
||||
};
|
||||
}
|
||||
24
ee/packages/media-calls/src/server/stripSensitiveData.ts
Normal file
24
ee/packages/media-calls/src/server/stripSensitiveData.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ClientMediaSignal, ServerMediaSignal } from '@rocket.chat/media-signaling';
|
||||
|
||||
export function stripSensitiveDataFromSdp<T extends RTCSessionDescriptionInit | null>(sdp: T): T {
|
||||
if (!sdp?.sdp) {
|
||||
return sdp;
|
||||
}
|
||||
|
||||
const { ...data } = sdp;
|
||||
|
||||
return {
|
||||
...data,
|
||||
sdp: `length=${sdp.sdp.length}`,
|
||||
};
|
||||
}
|
||||
|
||||
export function stripSensitiveDataFromSignal<T extends ClientMediaSignal | ServerMediaSignal>(signal: T): Partial<T> {
|
||||
const { ...data } = signal;
|
||||
|
||||
if ('sdp' in data) {
|
||||
data.sdp = stripSensitiveDataFromSdp(data.sdp);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
237
ee/packages/media-calls/src/sip/Session.ts
Normal file
237
ee/packages/media-calls/src/sip/Session.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import type { Socket } from 'node:net';
|
||||
|
||||
import type { IMediaCall, MediaCallContact } from '@rocket.chat/core-typings';
|
||||
import type { ClientMediaSignalBody } from '@rocket.chat/media-signaling';
|
||||
import { Random } from '@rocket.chat/random';
|
||||
import Srf, { type SrfResponse, type SrfRequest } from 'drachtio-srf';
|
||||
|
||||
import { SipError, SipErrorCodes } from './errorCodes';
|
||||
import { logger } from '../logger';
|
||||
import type { BaseSipCall } from './providers/BaseSipCall';
|
||||
import { IncomingSipCall } from './providers/IncomingSipCall';
|
||||
import { OutgoingSipCall } from './providers/OutgoingSipCall';
|
||||
import type { IMediaCallServerSettings } from '../definition/IMediaCallServer';
|
||||
import type { InternalCallParams } from '../definition/common';
|
||||
import { mediaCallDirector } from '../server/CallDirector';
|
||||
import { getDefaultSettings } from '../server/getDefaultSettings';
|
||||
|
||||
export class SipServerSession {
|
||||
private readonly _sessionId: string;
|
||||
|
||||
private srf: Srf;
|
||||
|
||||
private knownCalls: Map<string, BaseSipCall>;
|
||||
|
||||
private settings: IMediaCallServerSettings;
|
||||
|
||||
private wasEverEnabled = false;
|
||||
|
||||
public get sessionId(): string {
|
||||
return this._sessionId;
|
||||
}
|
||||
|
||||
constructor() {
|
||||
this._sessionId = Random.id();
|
||||
this.knownCalls = new Map();
|
||||
this.srf = new Srf();
|
||||
// Always instantiate it with the default settings as it stays disconnected until explicitly configured
|
||||
this.settings = getDefaultSettings();
|
||||
this.initializeDrachtio();
|
||||
}
|
||||
|
||||
public reactToCallUpdate(params: { callId: string; dtmf?: ClientMediaSignalBody<'dtmf'> }): void {
|
||||
const { callId, ...otherParams } = params;
|
||||
|
||||
const sipCall = this.knownCalls.get(callId);
|
||||
if (!sipCall) {
|
||||
// If we don't know this call, then it's probably being handled by a session in some other server instance
|
||||
return;
|
||||
}
|
||||
|
||||
sipCall.reactToCallChanges(otherParams).catch((err) => {
|
||||
logger.error({ msg: 'Failed to react to call changes', err, call: sipCall.call, otherParams });
|
||||
});
|
||||
}
|
||||
|
||||
public reportInternalCallUpdate(callId: string): void {
|
||||
logger.debug({ msg: 'SipServerSession.reportInternalCallUpdate', callId });
|
||||
}
|
||||
|
||||
public registerCall(call: BaseSipCall): void {
|
||||
this.knownCalls.set(call.callId, call);
|
||||
}
|
||||
|
||||
public configure(settings: IMediaCallServerSettings): void {
|
||||
this.settings = settings;
|
||||
|
||||
if (!this.isEnabledOnSettings(settings)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.wasEverEnabled) {
|
||||
this.connectDrachtio();
|
||||
}
|
||||
}
|
||||
|
||||
public async createOutgoingCall(params: InternalCallParams): Promise<IMediaCall> {
|
||||
return OutgoingSipCall.createCall(this, params);
|
||||
}
|
||||
|
||||
public async createSipDialog(
|
||||
sipExtension: string,
|
||||
opts: Srf.CreateUACOptions,
|
||||
progressCallbacks?: {
|
||||
cbRequest?: (error: unknown, req: Srf.SrfRequest) => void;
|
||||
cbProvisional?: (provisionalRes: Srf.SrfResponse) => void;
|
||||
},
|
||||
): Promise<Srf.Dialog> {
|
||||
const uri = this.getExtensionUri(sipExtension);
|
||||
|
||||
return this.srf.createUAC(uri, opts, progressCallbacks);
|
||||
}
|
||||
|
||||
public geContactUri(contact: MediaCallContact): string {
|
||||
const sipExtension = contact.sipExtension || (contact.type === 'sip' && contact.id) || null;
|
||||
const username = contact.username && `user-${contact.username}`;
|
||||
const userId = contact.id && `user-${contact.id}`;
|
||||
|
||||
const sipUsername = sipExtension || username || userId;
|
||||
|
||||
return this.getExtensionUri(sipUsername || 'unknown');
|
||||
}
|
||||
|
||||
public getExtensionUri(extension: string): string {
|
||||
const { host, port } = this.settings.sip.sipServer;
|
||||
if (!host) {
|
||||
throw new Error('Sip Server Host is not configured');
|
||||
}
|
||||
|
||||
const portStr = port ? `:${port}` : '';
|
||||
return `sip:${extension}@${host}${portStr}`;
|
||||
}
|
||||
|
||||
public async sendReferRequest(
|
||||
sipDialog: Srf.Dialog,
|
||||
params: { transferredTo?: MediaCallContact; transferredBy?: MediaCallContact },
|
||||
): Promise<void> {
|
||||
const { transferredBy, transferredTo } = params;
|
||||
if (!transferredTo) {
|
||||
throw new Error('Missing refer destination');
|
||||
}
|
||||
|
||||
// Sip targets can only be referred to other sip users
|
||||
const referToActor = await mediaCallDirector.cast.getContactForActor(transferredTo, { requiredType: 'sip' });
|
||||
const referredBy = transferredBy && this.geContactUri(transferredBy);
|
||||
|
||||
const referTo = referToActor && this.geContactUri(referToActor);
|
||||
if (!referTo) {
|
||||
throw new Error('invalid-transfer');
|
||||
}
|
||||
|
||||
const res = await sipDialog.request({
|
||||
method: 'REFER',
|
||||
headers: {
|
||||
'Refer-To': referTo,
|
||||
...(referredBy && { 'Referred-By': referredBy }),
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 202) {
|
||||
logger.debug({ msg: 'REFER was accepted', method: 'SipServerSession.sendReferRequest', ...params });
|
||||
}
|
||||
}
|
||||
|
||||
public stripDrachtioServerDetails(reqOrRes: Srf.SipMessage): Record<string, any> {
|
||||
const { _agent, socket: _socket, _req, _res, ...data } = reqOrRes as Record<string, any>;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
private isEnabledOnSettings(settings: IMediaCallServerSettings): boolean {
|
||||
return Boolean(settings.sip.enabled && settings.sip.drachtio.host && settings.sip.drachtio.secret);
|
||||
}
|
||||
|
||||
private initializeDrachtio(): void {
|
||||
logger.debug('Initializing Drachtio');
|
||||
this.srf.on('connect', (err, hostport) => {
|
||||
if (err) {
|
||||
logger.error({ msg: 'Drachtio Connection Failed', err });
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug({ msg: 'Connected to a drachtio server', hostport });
|
||||
});
|
||||
|
||||
this.srf.on('error', (err: unknown, socket?: Socket) => this.onDrachtioError(err, socket));
|
||||
|
||||
this.srf.use((req, _res, next) => {
|
||||
logger.debug({ msg: 'Incoming message from Drachtio', method: req.method, source: req.source_address });
|
||||
next();
|
||||
});
|
||||
|
||||
this.srf.invite((req, res) => {
|
||||
logger.debug('Received a call on drachtio.');
|
||||
|
||||
void this.processInvite(req, res).catch((err) => {
|
||||
logger.error({ msg: 'Error processing Drachtio Invite', err });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private connectDrachtio(): void {
|
||||
if (this.wasEverEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { host, port = 9022, secret } = this.settings.sip.drachtio;
|
||||
|
||||
logger.info({ msg: 'Connecting to drachtio', host, port });
|
||||
|
||||
this.wasEverEnabled = true;
|
||||
void this.srf.connect({
|
||||
host,
|
||||
port,
|
||||
secret,
|
||||
});
|
||||
}
|
||||
|
||||
private async processInvite(req: SrfRequest, res: SrfResponse): Promise<void> {
|
||||
if (!this.isEnabledOnSettings(this.settings)) {
|
||||
res.send(SipErrorCodes.SERVICE_NOT_AVAILABLE);
|
||||
return;
|
||||
}
|
||||
|
||||
const sipCall = await IncomingSipCall.processInvite(this, this.srf, req, res).catch((e) => {
|
||||
this.forwardSipExceptionToResponse(e, res);
|
||||
throw e;
|
||||
});
|
||||
|
||||
this.registerCall(sipCall);
|
||||
}
|
||||
|
||||
private forwardSipExceptionToResponse(exception: unknown, res: SrfResponse): void {
|
||||
if (!exception || typeof exception !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!(exception instanceof SipError)) {
|
||||
return;
|
||||
}
|
||||
|
||||
res.send(exception.sipErrorCode);
|
||||
}
|
||||
|
||||
private onDrachtioError(err: unknown, socket?: Socket): void {
|
||||
logger.error({ msg: 'Drachtio Service Error', err });
|
||||
|
||||
if (this.isEnabledOnSettings(this.settings)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.srf.disconnect(socket);
|
||||
} catch {
|
||||
// Supress errors on socket disconnection
|
||||
}
|
||||
}
|
||||
}
|
||||
33
ee/packages/media-calls/src/sip/errorCodes.ts
Normal file
33
ee/packages/media-calls/src/sip/errorCodes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export const SipErrorCodes = {
|
||||
BAD_REQUEST: 400,
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
METHOD_NOT_ALLOWED: 405,
|
||||
NOT_ACCEPTABLE: 406,
|
||||
TIMEOUT: 408,
|
||||
TEMPORARILY_UNAVAILABLE: 480,
|
||||
INVALID_CALL: 481,
|
||||
ADDRESS_INCOMPLETE: 484,
|
||||
AMBIGUOUS: 485,
|
||||
BUSY_HERE: 486,
|
||||
REQUEST_TERMINATED: 487,
|
||||
NOT_ACCEPTABLE_HERE: 488,
|
||||
BAD_EVENT: 489,
|
||||
|
||||
INTERNAL_SERVER_ERROR: 500,
|
||||
NOT_IMPLEMENTED: 501,
|
||||
SERVICE_NOT_AVAILABLE: 503,
|
||||
|
||||
DECLINED: 603,
|
||||
UNWANTED: 607,
|
||||
} as const;
|
||||
|
||||
export class SipError extends Error {
|
||||
constructor(
|
||||
public readonly sipErrorCode: number,
|
||||
message?: string,
|
||||
) {
|
||||
super(message || 'sip-error');
|
||||
}
|
||||
}
|
||||
229
ee/packages/media-calls/src/sip/providers/BaseSipCall.ts
Normal file
229
ee/packages/media-calls/src/sip/providers/BaseSipCall.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import type { IMediaCall } from '@rocket.chat/core-typings';
|
||||
import type { CallHangupReason, ClientMediaSignalBody } from '@rocket.chat/media-signaling';
|
||||
import { MediaCalls, MediaCallNegotiations } from '@rocket.chat/models';
|
||||
import type Srf from 'drachtio-srf';
|
||||
import type { SrfRequest, SrfResponse } from 'drachtio-srf';
|
||||
|
||||
import { BaseCallProvider } from '../../base/BaseCallProvider';
|
||||
import { logger } from '../../logger';
|
||||
import type { BroadcastActorAgent } from '../../server/BroadcastAgent';
|
||||
import { mediaCallDirector } from '../../server/CallDirector';
|
||||
import type { SipServerSession } from '../Session';
|
||||
import { SipErrorCodes } from '../errorCodes';
|
||||
|
||||
export type SipCallNegotiation = {
|
||||
id: string;
|
||||
req: SrfRequest;
|
||||
res: SrfResponse;
|
||||
isFirst: boolean;
|
||||
offer: RTCSessionDescriptionInit | null;
|
||||
answer: RTCSessionDescriptionInit | null;
|
||||
};
|
||||
|
||||
export abstract class BaseSipCall extends BaseCallProvider {
|
||||
protected lastCallState: IMediaCall['state'];
|
||||
|
||||
protected inboundRenegotiations: Map<string, SipCallNegotiation>;
|
||||
|
||||
protected sipDialog: Srf.Dialog | null;
|
||||
|
||||
protected processedTransfer: boolean;
|
||||
|
||||
constructor(
|
||||
protected readonly session: SipServerSession,
|
||||
call: IMediaCall,
|
||||
protected readonly agent: BroadcastActorAgent,
|
||||
) {
|
||||
super(call);
|
||||
this.lastCallState = 'none';
|
||||
this.sipDialog = null;
|
||||
this.inboundRenegotiations = new Map();
|
||||
this.processedTransfer = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a 'modify' event (a re-INVITE) on the SIP dialog by registering a new inbound renegotiation
|
||||
* initiated by the SIP actor and notifying the opposite agent that a remote description is available.
|
||||
*/
|
||||
protected async handleDialogModify(req: SrfRequest, res: SrfResponse): Promise<void> {
|
||||
const webrtcOffer: RTCSessionDescriptionInit = { type: 'offer', sdp: req.body };
|
||||
let negotiationId: string | null = null;
|
||||
|
||||
logger.debug({
|
||||
msg: `Sip call received a renegotiation`,
|
||||
type: this.constructor.name,
|
||||
callingNumber: req.callingNumber,
|
||||
calledNumber: req.calledNumber,
|
||||
callId: this.call._id,
|
||||
});
|
||||
|
||||
try {
|
||||
// The SIP actor is the one offering this renegotiation, so its own agent role is the offerer
|
||||
negotiationId = await mediaCallDirector.startNewNegotiation(this.call, this.agent.role, webrtcOffer);
|
||||
|
||||
const oppositeActor = this.agent.getOtherCallActor(this.call);
|
||||
const oppositeAgent = await mediaCallDirector.cast.getAgentForActorAndRole(oppositeActor, this.agent.oppositeRole);
|
||||
if (!oppositeAgent) {
|
||||
logger.error({
|
||||
msg: 'Failed to retrieve opposite agent',
|
||||
method: 'handleDialogModify',
|
||||
type: this.constructor.name,
|
||||
actor: oppositeActor,
|
||||
callId: this.call._id,
|
||||
});
|
||||
res.send(SipErrorCodes.TEMPORARILY_UNAVAILABLE);
|
||||
return;
|
||||
}
|
||||
|
||||
this.inboundRenegotiations.set(negotiationId, {
|
||||
id: negotiationId,
|
||||
req,
|
||||
res,
|
||||
isFirst: false,
|
||||
offer: webrtcOffer,
|
||||
answer: null,
|
||||
});
|
||||
|
||||
void oppositeAgent.onRemoteDescriptionChanged(this.call._id, negotiationId).catch(() => null);
|
||||
|
||||
logger.debug({
|
||||
msg: 'Modified SIP Call',
|
||||
method: 'handleDialogModify',
|
||||
type: this.constructor.name,
|
||||
req: this.session.stripDrachtioServerDetails(req),
|
||||
callId: this.call._id,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'An unexpected error occured while processing a modify event on a SIP call dialog', err });
|
||||
|
||||
try {
|
||||
res.send(SipErrorCodes.INTERNAL_SERVER_ERROR);
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
|
||||
if (!negotiationId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we got an error after the negotiation was registered on our side, the state is unpredictable.
|
||||
this.inboundRenegotiations.delete(negotiationId);
|
||||
this.onDialogModifyError(negotiationId);
|
||||
}
|
||||
}
|
||||
|
||||
/** Hook for subclasses to react to a failed 'modify' event after the negotiation was already registered. */
|
||||
protected onDialogModifyError(_negotiationId: string): void {
|
||||
// no extra handling by default
|
||||
}
|
||||
|
||||
protected onDialogDestroyed(): void {
|
||||
logger.debug({
|
||||
msg: 'SIP Dialog Destroyed',
|
||||
type: this.constructor.name,
|
||||
callId: this.call._id,
|
||||
});
|
||||
|
||||
this.sipDialog = null;
|
||||
this.hangupCall('remote');
|
||||
}
|
||||
|
||||
public override async reactToCallChanges(params: { dtmf?: ClientMediaSignalBody<'dtmf'> }): Promise<void> {
|
||||
// If we already knew this call was over, there's nothing more to reflect
|
||||
if (this.lastCallState === 'hangup') {
|
||||
return;
|
||||
}
|
||||
|
||||
const freshCall = await MediaCalls.findOneById(this.call._id);
|
||||
if (!freshCall) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't do anything unless our agent has one of the call's signed actors
|
||||
const callActor = this.agent.getMyCallActor(freshCall);
|
||||
|
||||
if (!this.agent.isRepresentingActor(callActor) || callActor.contractId !== this.session.sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return this.reflectCall(freshCall, params);
|
||||
}
|
||||
|
||||
protected abstract reflectCall(call: IMediaCall, params: { dtmf?: ClientMediaSignalBody<'dtmf'> }): Promise<void>;
|
||||
|
||||
protected abstract processEndedCall(call: IMediaCall): Promise<void>;
|
||||
|
||||
protected async sendDTMF(dialog: Srf.Dialog, dtmf: string, duration: number): Promise<void> {
|
||||
logger.debug({ msg: 'BaseSipCall.sendDTMF' });
|
||||
await dialog.request({
|
||||
method: 'INFO',
|
||||
headers: {
|
||||
'Content-Type': 'application/dtmf-relay',
|
||||
},
|
||||
body: `Signal=${dtmf}\r\nDuration=${duration}`,
|
||||
});
|
||||
}
|
||||
|
||||
protected hangupCall(hangupReason: CallHangupReason): void {
|
||||
void mediaCallDirector.hangup(this.call, this.agent, hangupReason).catch((err) => {
|
||||
logger.debug({ msg: 'Unexpected error ending call', err, type: this.constructor.name, hangupReason });
|
||||
});
|
||||
}
|
||||
|
||||
protected async processTransferredCall(call: IMediaCall): Promise<void> {
|
||||
if (this.lastCallState === 'hangup' || !call.transferredTo || !call.transferredBy) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.sipDialog || this.processedTransfer) {
|
||||
if (call.ended) {
|
||||
return this.processEndedCall(call);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug({ msg: 'processTransferredCall', callId: call._id, lastCallState: this.lastCallState, type: this.constructor.name });
|
||||
this.processedTransfer = true;
|
||||
|
||||
try {
|
||||
await this.session.sendReferRequest(this.sipDialog, {
|
||||
transferredTo: call.transferredTo,
|
||||
transferredBy: call.transferredBy,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'REFER failed', method: 'processTransferredCall', err, callId: call._id, type: this.constructor.name });
|
||||
if (!call.ended) {
|
||||
this.hangupCall('signaling-error');
|
||||
}
|
||||
return this.processEndedCall(call);
|
||||
}
|
||||
}
|
||||
|
||||
protected async getPendingInboundNegotiation(): Promise<SipCallNegotiation | null> {
|
||||
for (const localNegotiation of this.inboundRenegotiations.values()) {
|
||||
if (localNegotiation.answer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the negotiation does not exist, remove it from the list
|
||||
const negotiation = await MediaCallNegotiations.findOneById(localNegotiation.id);
|
||||
// Negotiation will always exist; This is just a safe guard
|
||||
if (!negotiation) {
|
||||
logger.error({ msg: 'Invalid Negotiation reference.', localNegotiation: localNegotiation.id, type: this.constructor.name });
|
||||
this.inboundRenegotiations.delete(localNegotiation.id);
|
||||
if (localNegotiation.res) {
|
||||
localNegotiation.res.send(SipErrorCodes.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (negotiation.answer) {
|
||||
localNegotiation.answer = negotiation.answer;
|
||||
}
|
||||
|
||||
return localNegotiation;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
363
ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts
Normal file
363
ee/packages/media-calls/src/sip/providers/IncomingSipCall.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import type { MediaCallSignedContact, IMediaCall, MediaCallContactInformation, MediaCallContact } from '@rocket.chat/core-typings';
|
||||
import { isBusyState, type ClientMediaSignalBody } from '@rocket.chat/media-signaling';
|
||||
import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models';
|
||||
import type { SipMessage, SrfRequest, SrfResponse } from 'drachtio-srf';
|
||||
import type Srf from 'drachtio-srf';
|
||||
|
||||
import { BaseSipCall } from './BaseSipCall';
|
||||
import { SIP_CALL_FEATURES } from '../../constants';
|
||||
import { logger } from '../../logger';
|
||||
import { BroadcastActorAgent } from '../../server/BroadcastAgent';
|
||||
import { mediaCallDirector } from '../../server/CallDirector';
|
||||
import { getMediaCallServer } from '../../server/injection';
|
||||
import type { SipServerSession } from '../Session';
|
||||
import { SipError, SipErrorCodes } from '../errorCodes';
|
||||
import { parseDiversionHeader } from '../utils/parseDiversionHeader';
|
||||
|
||||
export class IncomingSipCall extends BaseSipCall {
|
||||
constructor(
|
||||
session: SipServerSession,
|
||||
call: IMediaCall,
|
||||
protected override readonly agent: BroadcastActorAgent,
|
||||
private readonly srf: Srf,
|
||||
private readonly req: SrfRequest,
|
||||
private readonly res: SrfResponse,
|
||||
) {
|
||||
super(session, call, agent);
|
||||
}
|
||||
|
||||
public static async processInvite(session: SipServerSession, srf: Srf, req: SrfRequest, res: SrfResponse): Promise<IncomingSipCall> {
|
||||
logger.debug({ msg: 'IncomingSipCall.processInvite' });
|
||||
if (!req.isNewInvite) {
|
||||
logger.error({ msg: 'IncomingSipCall.processInvite received a request that is not a new invite.' });
|
||||
throw new SipError(SipErrorCodes.NOT_IMPLEMENTED, 'not-a-new-invite');
|
||||
}
|
||||
|
||||
let sipCall: IncomingSipCall | null = null;
|
||||
|
||||
req.on('cancel', (message) => {
|
||||
sipCall?.cancel(message);
|
||||
});
|
||||
|
||||
const callee = await this.getCalleeFromInvite(req);
|
||||
logger.debug({ msg: 'incoming call to', callee });
|
||||
|
||||
// getCalleeFromInvite already ensures it, but let's safeguard that the callee is an internal user
|
||||
if (callee.type !== 'user' || !callee.id) {
|
||||
throw new SipError(SipErrorCodes.TEMPORARILY_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// User is literally busy
|
||||
if (await MediaCalls.hasUnfinishedCallsByUid(callee.id)) {
|
||||
throw new SipError(SipErrorCodes.TEMPORARILY_UNAVAILABLE);
|
||||
}
|
||||
|
||||
if (!(await getMediaCallServer().permissionCheck(callee.id, 'external'))) {
|
||||
logger.debug({ msg: 'User with no permission received a sip call.', uid: callee.id });
|
||||
throw new SipError(SipErrorCodes.TEMPORARILY_UNAVAILABLE);
|
||||
}
|
||||
|
||||
const caller = await this.getCallerContactFromInvite(session.sessionId, req);
|
||||
logger.debug({ msg: 'incoming call from', callerContact: caller });
|
||||
|
||||
const divertedBy = await this.getDiversionContactFromInvite(req);
|
||||
if (divertedBy) {
|
||||
logger.debug({ msg: 'incoming call diverted by', divertedBy });
|
||||
}
|
||||
|
||||
const webrtcOffer = { type: 'offer', sdp: req.body } as const;
|
||||
|
||||
const callerAgent = await mediaCallDirector.cast.getAgentForActorAndRole(caller, 'caller');
|
||||
if (!callerAgent) {
|
||||
throw new SipError(SipErrorCodes.NOT_FOUND, 'Caller agent not found');
|
||||
}
|
||||
|
||||
if (!(callerAgent instanceof BroadcastActorAgent)) {
|
||||
throw new SipError(SipErrorCodes.INTERNAL_SERVER_ERROR, 'Caller agent not valid');
|
||||
}
|
||||
|
||||
const calleeAgent = await mediaCallDirector.cast.getAgentForActorAndRole(callee, 'callee');
|
||||
if (!calleeAgent) {
|
||||
throw new SipError(SipErrorCodes.NOT_FOUND, 'Callee agent not found');
|
||||
}
|
||||
|
||||
const call = await mediaCallDirector.createCall({
|
||||
caller,
|
||||
callee,
|
||||
callerAgent,
|
||||
calleeAgent,
|
||||
features: SIP_CALL_FEATURES,
|
||||
...(divertedBy && { divertedBy }),
|
||||
sipCallId: req.get('Call-ID'),
|
||||
});
|
||||
|
||||
const negotiationId = await mediaCallDirector.startNewNegotiation(call, 'caller', webrtcOffer);
|
||||
|
||||
sipCall = new IncomingSipCall(session, call, callerAgent, srf, req, res);
|
||||
|
||||
callerAgent.provider = sipCall;
|
||||
|
||||
sipCall.inboundRenegotiations.set(negotiationId, {
|
||||
id: negotiationId,
|
||||
req,
|
||||
res,
|
||||
isFirst: true,
|
||||
offer: webrtcOffer,
|
||||
answer: null,
|
||||
});
|
||||
|
||||
// Send the call to the callee client
|
||||
await calleeAgent.onCallCreated(call);
|
||||
|
||||
return sipCall;
|
||||
}
|
||||
|
||||
public async createDialog(localSdp: string): Promise<void> {
|
||||
logger.debug({ msg: 'IncomingSipCall.createDialog' });
|
||||
|
||||
const uas = await this.srf.createUAS(this.req, this.res, {
|
||||
localSdp,
|
||||
});
|
||||
|
||||
if (!uas) {
|
||||
logger.error({ msg: 'IncomingSipCall.createDialog - dialog creation failed', callId: this.callId });
|
||||
this.hangupCall('signaling-error');
|
||||
return;
|
||||
}
|
||||
|
||||
uas.on('modify', (req, res) => {
|
||||
void this.handleDialogModify(req, res);
|
||||
});
|
||||
|
||||
uas.on('destroy', () => this.onDialogDestroyed());
|
||||
|
||||
this.sipDialog = uas;
|
||||
}
|
||||
|
||||
protected cancel(res: SipMessage): void {
|
||||
logger.debug({ msg: 'IncomingSipCall.cancel', res: this.session.stripDrachtioServerDetails(res) });
|
||||
|
||||
logger.info({ msg: 'The incoming SIP call was canceled by the caller', callId: this.callId });
|
||||
this.hangupCall('remote');
|
||||
}
|
||||
|
||||
protected async reflectCall(call: IMediaCall, params: { dtmf?: ClientMediaSignalBody<'dtmf'> }): Promise<void> {
|
||||
if (params.dtmf && this.sipDialog) {
|
||||
return this.sendDTMF(this.sipDialog, params.dtmf.dtmf, params.dtmf.duration || 2000);
|
||||
}
|
||||
|
||||
if (call.transferredTo && call.transferredBy) {
|
||||
return this.processTransferredCall(call);
|
||||
}
|
||||
|
||||
if (call.ended) {
|
||||
return this.processEndedCall(call);
|
||||
}
|
||||
|
||||
if (isBusyState(call.state)) {
|
||||
return this.processNegotiations(call);
|
||||
}
|
||||
|
||||
logger.debug({ msg: 'no changes detected', method: 'IncomingSipCall.reflectCall' });
|
||||
}
|
||||
|
||||
protected async processEndedCall(call: IMediaCall): Promise<void> {
|
||||
logger.debug({ msg: 'IncomingSipCall.processEndedCall', lastCallState: this.lastCallState, hangupReason: call.hangupReason });
|
||||
|
||||
switch (call.hangupReason) {
|
||||
case 'service-error':
|
||||
this.cancelPendingInvites(SipErrorCodes.NOT_ACCEPTABLE_HERE);
|
||||
break;
|
||||
case 'rejected':
|
||||
this.cancelPendingInvites(SipErrorCodes.DECLINED);
|
||||
break;
|
||||
default:
|
||||
this.cancelPendingInvites(SipErrorCodes.TEMPORARILY_UNAVAILABLE);
|
||||
break;
|
||||
}
|
||||
|
||||
if (this.lastCallState === 'hangup') {
|
||||
return;
|
||||
}
|
||||
|
||||
const { sipDialog } = this;
|
||||
this.sipDialog = null;
|
||||
this.lastCallState = 'hangup';
|
||||
|
||||
if (sipDialog) {
|
||||
try {
|
||||
await sipDialog.destroy();
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Failed to destroy SIP dialog', err, method: 'IncomingSipCall.processEndedCall' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override onDialogModifyError(_negotiationId: string): void {
|
||||
// On an incoming call the SIP caller drives the call, so an unrecoverable renegotiation must end it.
|
||||
this.hangupPendingCall(SipErrorCodes.INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
|
||||
private async processNegotiations(call: IMediaCall): Promise<void> {
|
||||
const localNegotiation = await this.getPendingInboundNegotiation();
|
||||
if (!localNegotiation) {
|
||||
// Callee-initiated renegotiations are only processed if there's none initiated by the caller
|
||||
return this.processCalleeNegotiation(call);
|
||||
}
|
||||
|
||||
// If we don't have an sdp, we can't respond to it yet
|
||||
if (!localNegotiation?.answer?.sdp) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('IncomingSipCall.processNegotiations');
|
||||
if (localNegotiation.isFirst) {
|
||||
return this.createDialog(localNegotiation.answer.sdp).catch(() => {
|
||||
logger.error('Failed to create incoming call dialog.');
|
||||
this.hangupPendingCall(SipErrorCodes.INTERNAL_SERVER_ERROR);
|
||||
});
|
||||
}
|
||||
|
||||
localNegotiation.res.send(200, {
|
||||
body: localNegotiation.answer.sdp,
|
||||
});
|
||||
}
|
||||
|
||||
private async processCalleeNegotiation(call: IMediaCall): Promise<void> {
|
||||
if (!this.sipDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
const negotiation = await MediaCallNegotiations.findLatestByCallId(call._id);
|
||||
if (negotiation?.offerer !== 'callee' || !negotiation.offer?.sdp || negotiation.answer) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('IncomingSipCall.processCalleeNegotiation');
|
||||
let answer: string | void = undefined;
|
||||
try {
|
||||
answer = await this.sipDialog.modify(negotiation.offer.sdp).catch(() => {
|
||||
logger.debug('modify failed');
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error on IncomingSipCall.processCalleeNegotiation', err });
|
||||
}
|
||||
|
||||
if (!answer) {
|
||||
logger.error({ msg: 'No answer from callee initiated negotiation' });
|
||||
return;
|
||||
}
|
||||
|
||||
await mediaCallDirector.saveWebrtcSession(
|
||||
call,
|
||||
this.agent,
|
||||
{ sdp: { sdp: answer, type: 'answer' }, negotiationId: negotiation._id },
|
||||
this.session.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
private cancelPendingInvites(errorCode: number): void {
|
||||
logger.debug({
|
||||
msg: 'IncomingSipCall.cancelPendingInvites',
|
||||
errorCode,
|
||||
hasDialog: Boolean(this.sipDialog),
|
||||
negotiations: this.inboundRenegotiations.size,
|
||||
});
|
||||
|
||||
for (const localNegotiation of this.inboundRenegotiations.values()) {
|
||||
// if it has an answer sdp, we already responded to it
|
||||
if (localNegotiation.answer?.sdp) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
localNegotiation.res.send(errorCode);
|
||||
} catch {
|
||||
//
|
||||
}
|
||||
}
|
||||
this.inboundRenegotiations.clear();
|
||||
}
|
||||
|
||||
private hangupPendingCall(errorCode: number): void {
|
||||
logger.debug('IncomingSipCall.hangupPendingCall');
|
||||
|
||||
this.cancelPendingInvites(errorCode);
|
||||
this.hangupCall('signaling-error');
|
||||
}
|
||||
|
||||
private static async getCalleeFromInvite(req: SrfRequest): Promise<MediaCallContact> {
|
||||
logger.debug({ msg: 'IncomingSipCall.getCalleeFromInvite', callingNumber: req.callingNumber, calledNumber: req.calledNumber });
|
||||
|
||||
if (req.calledNumber && typeof req.calledNumber === 'string') {
|
||||
const userContact = await mediaCallDirector.cast.getContactForExtensionNumber(req.calledNumber, { requiredType: 'user' });
|
||||
if (userContact) {
|
||||
return userContact;
|
||||
}
|
||||
|
||||
// If the invite had an id/extension but we couldn't match it to an user, respond with unavailable
|
||||
throw new SipError(SipErrorCodes.TEMPORARILY_UNAVAILABLE);
|
||||
}
|
||||
|
||||
// If we couldn't even identify an id/extension from the invite, respond with not found
|
||||
throw new SipError(SipErrorCodes.NOT_FOUND);
|
||||
}
|
||||
|
||||
private static async getDiversionContactFromInvite(req: SrfRequest): Promise<MediaCallContact | null> {
|
||||
if (!req.has('diversion')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.debug({ msg: 'IncomingSipCall.getDiversionContactFromInvite' });
|
||||
|
||||
const parsed = parseDiversionHeader(req.get('diversion'));
|
||||
if (!parsed) {
|
||||
logger.warn({ msg: 'IncomingSipCall.getDiversionContactFromInvite: failed to parse Diversion header', raw: req.get('diversion') });
|
||||
return null;
|
||||
}
|
||||
|
||||
const { extension, displayName } = parsed;
|
||||
|
||||
const defaultContactInfo: MediaCallContactInformation = {
|
||||
sipExtension: extension,
|
||||
displayName: displayName || extension,
|
||||
};
|
||||
|
||||
const contact = await mediaCallDirector.cast.getContactForExtensionNumber(extension, { requiredType: 'sip' }, defaultContactInfo);
|
||||
|
||||
return {
|
||||
...defaultContactInfo,
|
||||
...contact,
|
||||
type: 'sip',
|
||||
id: extension,
|
||||
};
|
||||
}
|
||||
|
||||
private static async getCallerContactFromInvite(sessionId: string, req: SrfRequest): Promise<MediaCallSignedContact<'sip'>> {
|
||||
logger.debug({ msg: 'IncomingSipCall.getCallerContactFromInvite' });
|
||||
|
||||
const displayName = req.callingName || undefined;
|
||||
const sipExtension = req.callingNumber;
|
||||
|
||||
const defaultContactInfo: MediaCallContactInformation = {
|
||||
sipExtension,
|
||||
displayName: displayName || sipExtension,
|
||||
};
|
||||
|
||||
const contact = await mediaCallDirector.cast.getContactForExtensionNumber(sipExtension, { requiredType: 'sip' }, defaultContactInfo);
|
||||
|
||||
if (contact) {
|
||||
return {
|
||||
...contact,
|
||||
contractId: sessionId,
|
||||
} as MediaCallSignedContact<'sip'>;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'sip',
|
||||
id: sipExtension,
|
||||
contractId: sessionId,
|
||||
...defaultContactInfo,
|
||||
};
|
||||
}
|
||||
}
|
||||
322
ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts
Normal file
322
ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
import type { IMediaCall, MediaCallSignedContact } from '@rocket.chat/core-typings';
|
||||
import { isBusyState, type ClientMediaSignalBody, type CallHangupReason } from '@rocket.chat/media-signaling';
|
||||
import { MediaCallNegotiations, MediaCalls } from '@rocket.chat/models';
|
||||
import type { SrfRequest } from 'drachtio-srf';
|
||||
|
||||
import { BaseSipCall } from './BaseSipCall';
|
||||
import { SIP_CALL_FEATURES } from '../../constants';
|
||||
import type { InternalCallParams } from '../../definition/common';
|
||||
import { logger } from '../../logger';
|
||||
import { BroadcastActorAgent } from '../../server/BroadcastAgent';
|
||||
import { mediaCallDirector } from '../../server/CallDirector';
|
||||
import type { SipServerSession } from '../Session';
|
||||
import { SipError, SipErrorCodes } from '../errorCodes';
|
||||
|
||||
export class OutgoingSipCall extends BaseSipCall {
|
||||
private sipDialogReq: SrfRequest | null;
|
||||
|
||||
constructor(
|
||||
session: SipServerSession,
|
||||
call: IMediaCall,
|
||||
protected override readonly agent: BroadcastActorAgent,
|
||||
) {
|
||||
super(session, call, agent);
|
||||
this.sipDialogReq = null;
|
||||
}
|
||||
|
||||
public static async createCall(session: SipServerSession, params: InternalCallParams): Promise<IMediaCall> {
|
||||
logger.debug({ msg: 'OutgoingSipCall.createCall', sessionId: session.sessionId });
|
||||
const { callee, features: requestedFeatures, ...extraParams } = params;
|
||||
|
||||
// pre-sign the callee to this session
|
||||
const signedCallee: MediaCallSignedContact = {
|
||||
...callee,
|
||||
contractId: session.sessionId,
|
||||
};
|
||||
|
||||
const calleeAgent = await mediaCallDirector.cast.getAgentForActorAndRole(signedCallee, 'callee');
|
||||
if (!calleeAgent) {
|
||||
throw new SipError(SipErrorCodes.NOT_FOUND, 'Callee agent not found');
|
||||
}
|
||||
|
||||
if (!(calleeAgent instanceof BroadcastActorAgent)) {
|
||||
throw new SipError(SipErrorCodes.INTERNAL_SERVER_ERROR, 'Caller agent not valid');
|
||||
}
|
||||
|
||||
const callerAgent = await mediaCallDirector.cast.getAgentForActorAndRole(params.caller, 'caller');
|
||||
if (!callerAgent) {
|
||||
throw new SipError(SipErrorCodes.NOT_FOUND, 'Caller agent not found');
|
||||
}
|
||||
|
||||
const features = requestedFeatures.filter((feature) => SIP_CALL_FEATURES.includes(feature));
|
||||
const call = await mediaCallDirector.createCall({
|
||||
...extraParams,
|
||||
callee: signedCallee,
|
||||
calleeAgent,
|
||||
callerAgent,
|
||||
features,
|
||||
});
|
||||
|
||||
const sipCall = new OutgoingSipCall(session, call, calleeAgent);
|
||||
session.registerCall(sipCall);
|
||||
calleeAgent.provider = sipCall;
|
||||
|
||||
// report to the caller that the call was created
|
||||
await callerAgent.onCallCreated(call);
|
||||
|
||||
return call;
|
||||
}
|
||||
|
||||
protected async reflectCall(call: IMediaCall, params: { dtmf?: ClientMediaSignalBody<'dtmf'> }): Promise<void> {
|
||||
if (params.dtmf && this.sipDialog) {
|
||||
return this.sendDTMF(this.sipDialog, params.dtmf.dtmf, params.dtmf.duration || 2000);
|
||||
}
|
||||
|
||||
if (call.transferredTo && call.transferredBy) {
|
||||
return this.processTransferredCall(call);
|
||||
}
|
||||
|
||||
if (call.state === 'hangup') {
|
||||
return this.processEndedCall(call);
|
||||
}
|
||||
|
||||
if (this.lastCallState === 'none') {
|
||||
return this.createDialog(call);
|
||||
}
|
||||
|
||||
if (isBusyState(call.state)) {
|
||||
return this.processNegotiations(call);
|
||||
}
|
||||
|
||||
logger.debug({ msg: 'no changes detected', method: 'OutgoingSipCall.reflectCall' });
|
||||
}
|
||||
|
||||
protected async createDialog(call: IMediaCall): Promise<void> {
|
||||
logger.debug({ msg: 'OutgoingSipCall.createDialog', call });
|
||||
if (this.lastCallState !== 'none' || this.sipDialog) {
|
||||
logger.debug({ msg: 'invalid state to create an outgoing dialog' });
|
||||
return;
|
||||
}
|
||||
|
||||
const negotiation = await MediaCallNegotiations.findLatestByCallId(call._id);
|
||||
if (!negotiation?.offer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateResult = await MediaCalls.startRingingById(call._id, mediaCallDirector.getNewExpirationTime());
|
||||
if (!updateResult.modifiedCount) {
|
||||
logger.debug({ msg: 'unable to set call state to ringing; skipping dialog creation.' });
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastCallState = 'ringing';
|
||||
const referredBy = call.parentCallId && this.session.geContactUri(call.createdBy);
|
||||
|
||||
let hangupReason: CallHangupReason | null = null;
|
||||
try {
|
||||
this.sipDialog = await this.session.createSipDialog(
|
||||
this.call.callee.id,
|
||||
{
|
||||
localSdp: negotiation.offer.sdp,
|
||||
callingName: call.caller.displayName,
|
||||
callingNumber: call.caller.sipExtension,
|
||||
...(referredBy && {
|
||||
headers: {
|
||||
'Referred-By': referredBy,
|
||||
},
|
||||
}),
|
||||
},
|
||||
{
|
||||
cbProvisional: (provRes) => {
|
||||
logger.debug({
|
||||
msg: 'OutgoingSipCall.createDialog - got provisional response',
|
||||
provRes: provRes && this.session.stripDrachtioServerDetails(provRes),
|
||||
});
|
||||
},
|
||||
cbRequest: (err: unknown, req: SrfRequest) => {
|
||||
if (err) {
|
||||
logger.error({
|
||||
msg: 'OutgoingSipCall.createDialog - request failed',
|
||||
err,
|
||||
});
|
||||
this.hangupCall('signaling-error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (req) {
|
||||
logger.debug({
|
||||
msg: 'OutgoingSipCall.createDialog - request initiated',
|
||||
req: this.session.stripDrachtioServerDetails(req),
|
||||
});
|
||||
|
||||
this.sipDialogReq = req;
|
||||
req.on('response', (res, ack) => {
|
||||
logger.debug({
|
||||
msg: 'OutgoingSipCall - request got a response',
|
||||
req: this.session.stripDrachtioServerDetails(req),
|
||||
res: res && this.session.stripDrachtioServerDetails(res),
|
||||
ack,
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (!this.sipDialog) {
|
||||
logger.debug({ msg: 'OutgoingSipCall.createDialog - no dialog' });
|
||||
}
|
||||
} catch (error) {
|
||||
this.sipDialog = null;
|
||||
const errorCode = this.getSipErrorCode(error);
|
||||
hangupReason = this.getHangupReasonForSipErrorCode(errorCode);
|
||||
|
||||
if (!hangupReason || hangupReason.includes('error')) {
|
||||
logger.error({ msg: 'OutgoingSipCall.createDialog - failed to create sip dialog', err: error, callId: call._id });
|
||||
} else {
|
||||
logger.info({ msg: 'SIP call failed with a natural reason', hangupReason, callId: call._id, err: error });
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.sipDialog) {
|
||||
this.cancelAnyPendingRequest();
|
||||
this.hangupCall(hangupReason || 'signaling-error');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug({ msg: 'OutgoingSipCall.createDialog - dialog created', callId: this.sipDialog.sip?.callId });
|
||||
this.sipDialog.on('destroy', () => this.onDialogDestroyed());
|
||||
|
||||
this.sipDialog.on('modify', (req, res) => {
|
||||
void this.handleDialogModify(req, res);
|
||||
});
|
||||
|
||||
logger.debug({ msg: 'OutgoingSipCall.createDialog - remote data', data: this.sipDialog.remote });
|
||||
|
||||
// This will not do anything if the call is no longer waiting to be accepted.
|
||||
await mediaCallDirector.acceptCall(call, this.agent, {
|
||||
calleeContractId: this.session.sessionId,
|
||||
webrtcAnswer: { type: 'answer', sdp: this.sipDialog.remote.sdp },
|
||||
supportedFeatures: SIP_CALL_FEATURES,
|
||||
sipCallId: this.sipDialog.sip?.callId,
|
||||
});
|
||||
}
|
||||
|
||||
protected async processNegotiations(call: IMediaCall): Promise<void> {
|
||||
const negotiation = await MediaCallNegotiations.findLatestByCallId(call._id);
|
||||
if (negotiation?.offerer !== 'caller' || !negotiation.offer?.sdp || negotiation.answer) {
|
||||
return this.processCalleeNegotiations();
|
||||
}
|
||||
|
||||
if (!this.sipDialog) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('OutgoingSipCall.processNegotiations');
|
||||
let answer: string | void = undefined;
|
||||
try {
|
||||
answer = await this.sipDialog.modify(negotiation.offer.sdp).catch(() => {
|
||||
logger.debug('modify failed');
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Error on OutgoingSipCall.processNegotiations', err });
|
||||
}
|
||||
|
||||
if (!answer) {
|
||||
logger.error({ msg: 'No answer from callee initiated negotiation' });
|
||||
// If a caller initiated negotiation fails, we need to hangup
|
||||
void mediaCallDirector.hangupDetachedCall(call, { reason: 'renegotiation-failed', endedBy: call.caller });
|
||||
return;
|
||||
}
|
||||
|
||||
await mediaCallDirector.saveWebrtcSession(
|
||||
call,
|
||||
this.agent,
|
||||
{ sdp: { sdp: answer, type: 'answer' }, negotiationId: negotiation._id },
|
||||
this.session.sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
protected async processCalleeNegotiations(): Promise<void> {
|
||||
const localNegotiation = await this.getPendingInboundNegotiation();
|
||||
// If we don't have an sdp, we can't respond to it yet
|
||||
if (!localNegotiation?.answer?.sdp) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug('OutgoingSipCall.processCalleeNegotiations');
|
||||
localNegotiation.res.send(200, {
|
||||
body: localNegotiation.answer.sdp,
|
||||
});
|
||||
}
|
||||
|
||||
protected async processEndedCall(_call: IMediaCall): Promise<void> {
|
||||
if (this.lastCallState === 'hangup') {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.debug({ msg: 'OutgoingSipCall.processEndedCall', lastCallState: this.lastCallState });
|
||||
|
||||
this.cancelAnyPendingRequest();
|
||||
|
||||
const { sipDialog } = this;
|
||||
this.sipDialog = null;
|
||||
this.lastCallState = 'hangup';
|
||||
|
||||
if (sipDialog) {
|
||||
try {
|
||||
await sipDialog.destroy();
|
||||
} catch (err) {
|
||||
logger.error({ msg: 'Failed to destroy SIP dialog', err, method: 'OutgoingSipCall.processEndedCall' });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getSipErrorCode(error: unknown): number | null {
|
||||
if (!error || typeof error !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!('name' in error) || error.name !== 'SipError') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!('status' in error) || typeof error.status !== 'number') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return error.status;
|
||||
}
|
||||
|
||||
private getHangupReasonForSipErrorCode(errorCode: number | null): CallHangupReason | null {
|
||||
if (!errorCode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
switch (errorCode) {
|
||||
case SipErrorCodes.DECLINED:
|
||||
return 'rejected';
|
||||
case SipErrorCodes.UNWANTED:
|
||||
return 'rejected';
|
||||
case SipErrorCodes.NOT_ACCEPTABLE_HERE:
|
||||
return 'service-error';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private cancelAnyPendingRequest(): void {
|
||||
if (!this.sipDialogReq) {
|
||||
return;
|
||||
}
|
||||
|
||||
const req = this.sipDialogReq;
|
||||
this.sipDialogReq = null;
|
||||
|
||||
try {
|
||||
req.cancel(() => null);
|
||||
} catch {
|
||||
// ignore errors on cancel
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { parseDiversionHeader } from './parseDiversionHeader';
|
||||
|
||||
describe('parseDiversionHeader', () => {
|
||||
describe('unquoted display name', () => {
|
||||
it('parses a single-word unquoted display name', () => {
|
||||
expect(parseDiversionHeader('Alice <sip:1234@pbx.example.com>;reason=unconditional')).toEqual({
|
||||
extension: '1234',
|
||||
displayName: 'Alice',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a multi-word unquoted display name', () => {
|
||||
expect(parseDiversionHeader('Alice Smith <sip:1234@pbx.example.com>')).toEqual({
|
||||
extension: '1234',
|
||||
displayName: 'Alice Smith',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('quoted display name', () => {
|
||||
it('parses a single-word quoted display name', () => {
|
||||
expect(parseDiversionHeader('"Alice" <sip:1234@pbx.example.com>;reason=unconditional')).toEqual({
|
||||
extension: '1234',
|
||||
displayName: 'Alice',
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a multi-word quoted display name', () => {
|
||||
expect(parseDiversionHeader('"Alice Smith" <sip:1234@pbx.example.com>')).toEqual({
|
||||
extension: '1234',
|
||||
displayName: 'Alice Smith',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns undefined displayName for an empty quoted string', () => {
|
||||
expect(parseDiversionHeader('"" <sip:1234@pbx.example.com>')).toEqual({
|
||||
extension: '1234',
|
||||
displayName: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('no display name', () => {
|
||||
it('parses the bare angle-bracket form', () => {
|
||||
expect(parseDiversionHeader('<sip:1234@pbx.example.com>;reason=unconditional')).toEqual({
|
||||
extension: '1234',
|
||||
displayName: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URI variants', () => {
|
||||
it('parses a sips: URI', () => {
|
||||
expect(parseDiversionHeader('<sips:5678@pbx.example.com>')).toEqual({
|
||||
extension: '5678',
|
||||
displayName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles URI parameters after the host', () => {
|
||||
expect(parseDiversionHeader('<sip:1234@pbx.example.com;transport=tcp>;reason=unconditional')).toEqual({
|
||||
extension: '1234',
|
||||
displayName: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('whitespace handling', () => {
|
||||
it('trims leading and trailing whitespace from the raw value', () => {
|
||||
expect(parseDiversionHeader(' <sip:9000@pbx.example.com> ')).toEqual({
|
||||
extension: '9000',
|
||||
displayName: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('trims extra whitespace between an unquoted display name and the angle bracket', () => {
|
||||
expect(parseDiversionHeader('Alice <sip:1234@pbx.example.com>')).toEqual({
|
||||
extension: '1234',
|
||||
displayName: 'Alice',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid inputs', () => {
|
||||
it('returns null for a bare addr-spec without angle brackets (not valid per RFC 5806)', () => {
|
||||
expect(parseDiversionHeader('sip:1234@pbx.example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a URI with no user part', () => {
|
||||
expect(parseDiversionHeader('<sip:pbx.example.com>')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when there is a display name but no number in the URI', () => {
|
||||
expect(parseDiversionHeader('Alice <sip:pbx.example.com>')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a non-SIP URI inside brackets', () => {
|
||||
expect(parseDiversionHeader('<tel:+1234567890>')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for an empty string', () => {
|
||||
expect(parseDiversionHeader('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for a non-string value', () => {
|
||||
expect(parseDiversionHeader(null as unknown as string)).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
export type ParsedDiversion = {
|
||||
extension: string;
|
||||
displayName?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses the value of a single SIP Diversion header field (RFC 5806).
|
||||
*
|
||||
* RFC 5806 mandates the name-addr form (angle brackets are always required):
|
||||
* Alice <sip:1234@pbx.example.com>;reason=unconditional
|
||||
* "Display Name" <sip:1234@pbx.example.com>;reason=unconditional
|
||||
* <sip:1234@pbx.example.com>;reason=unconditional
|
||||
*
|
||||
* Returns the SIP URI user part as `extension` and the display name when present.
|
||||
* Returns null if the value cannot be parsed into a usable extension.
|
||||
*/
|
||||
export function parseDiversionHeader(raw: string): ParsedDiversion | null {
|
||||
if (!raw || typeof raw !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = raw.trim();
|
||||
|
||||
// name-addr: optional display name (quoted or unquoted) followed by angle-bracket URI
|
||||
// Group 1: quoted display name, Group 2: unquoted display name, Group 3: SIP URI
|
||||
const nameAddrMatch = value.match(/^(?:"([^"]*)"|([^<"]+?))?\s*<([^>]+)>/);
|
||||
if (!nameAddrMatch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rawDisplayName = nameAddrMatch[1] ?? nameAddrMatch[2];
|
||||
const displayName = rawDisplayName?.trim() || undefined;
|
||||
const sipUri = nameAddrMatch[3];
|
||||
|
||||
// Extract the user part from sip:user@host or sips:user@host
|
||||
const uriMatch = sipUri?.match(/^sips?:([^@;>\s]+)@/i);
|
||||
if (!uriMatch?.[1]) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { extension: uriMatch[1], displayName };
|
||||
}
|
||||
Reference in New Issue
Block a user