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

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View 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>;
}

View 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 });
}
}

View 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'];

View 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;
}

View File

@@ -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>;
}

View 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;
}

View 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;
};

View File

@@ -0,0 +1,4 @@
export type * from './definition/IMediaCallServer';
export { callServer } from './server/configuration';
export { getSignalsForExistingCall } from './server/signals/getSignalsForExistingCall';

View 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;
}
}

View 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);
}
}

View File

@@ -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);
}
}
}
}

View 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);
}
}

View File

@@ -0,0 +1,3 @@
import { Logger } from '@rocket.chat/logger';
export const logger = new Logger('MediaCallsServer');

View 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);
}
}

View 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();

View 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;
}
}

View 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 {};
}
}
}

View 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);

View 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;
}

View 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,
};
}

View 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;
}

View File

@@ -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,
};
}

View File

@@ -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 }),
};
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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[],
};
}

View 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;
}

View 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
}
}
}

View 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');
}
}

View 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;
}
}

View 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,
};
}
}

View 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
}
}
}

View File

@@ -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();
});
});
});

View File

@@ -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 };
}