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