Files
upstream-rocketchat/ee/packages/media-calls/src/sip/providers/OutgoingSipCall.ts
upstream-mirror 30810eecd7
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
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

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