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
323 lines
9.5 KiB
TypeScript
323 lines
9.5 KiB
TypeScript
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
|
|
}
|
|
}
|
|
}
|