mirror of
https://github.com/jambonz/sbc-outbound.git
synced 2026-10-03 17:54:14 +00:00
* fix: keep siprec recording alive across a feature server transfer A cross-feature-server move (enqueue/dequeue or conference) re-negotiates the feature-server leg in _onFeatureServerTransfer, but nothing rebuilt the rtpengine subscription the SIPREC recording forks from, so the recorder went silent from the moment the call moved. The fresh destroy handler installed on the new leg also dropped the _stopRecording() call the original handlers have, so the SIPREC dialog was never BYEd and the recorder had to wait out its media timeout. Rebuild the subscription after the transfer re-negotiates media, and stop the recording when the transferred leg ends. The resubscribe call is guarded so this is safe to deploy before @jambonz/siprec-client-utils is bumped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix: drop the siprec re-subscribe, keep the missing teardown The cross-feature-server move was tested on a real two-feature-server cluster (inbound path): the rtpengine subscription survives the REFER on its own, so resubscribe() was fixing a fault that does not exist. rtpengine keeps non-offer-answer subscriptions across an answer. What does fail, and what this branch still fixes, is the teardown: the destroy handler installed on the transferred leg never stopped the recording, so the recorder was left without a BYE. Verified on the inbound path; the outbound transfer path has the identical handler and the same omission. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1312 lines
53 KiB
JavaScript
1312 lines
53 KiB
JavaScript
const Emitter = require('events');
|
|
const SrsClient = require('@jambonz/siprec-client-utils');
|
|
const {
|
|
makeRtpEngineOpts,
|
|
nudgeCallCounts,
|
|
isPrivateVoipNetwork,
|
|
isBlackListedSipGateway,
|
|
makeFullMediaReleaseKey,
|
|
makePartnerFullMediaReleaseKey,
|
|
isValidDomainOrIP,
|
|
removeVideoSdp,
|
|
} = require('./utils');
|
|
const { MediaPath } = require('./constants.json');
|
|
const {forwardInDialogRequests} = require('drachtio-fn-b2b-sugar');
|
|
const {SipError, stringifyUri, parseUri} = require('drachtio-srf');
|
|
const debug = require('debug')('jambonz:sbc-outbound');
|
|
|
|
const makeInviteInProgressKey = (callid) => `sbc-out-iip${callid}`;
|
|
const IMMUTABLE_HEADERS = ['via', 'from', 'to', 'call-id', 'cseq', 'max-forwards', 'content-length'];
|
|
|
|
const createBLegFromHeader = ({
|
|
logger,
|
|
req,
|
|
host,
|
|
fromUser,
|
|
register_from_domain,
|
|
transport,
|
|
teams = false,
|
|
scheme = 'sip'
|
|
}) => {
|
|
const from = req.getParsedHeader('From');
|
|
const uri = parseUri(from.uri);
|
|
const transportParam = transport ? `;transport=${transport}` : '';
|
|
|
|
logger.debug({from, fromUser, uri, host, scheme, transport, teams}, 'createBLegFromHeader');
|
|
/* user */
|
|
const user = fromUser || req.get('X-Preferred-From-User') || uri.user || 'anonymous';
|
|
|
|
/* host */
|
|
if (!host) {
|
|
if (teams) {
|
|
host = req.get('X-MS-Teams-Tenant-FQDN');
|
|
}
|
|
else if (req.has('X-Preferred-From-Host')) {
|
|
host = req.get('X-Preferred-From-Host');
|
|
} else if (register_from_domain) {
|
|
host = register_from_domain;
|
|
}
|
|
else {
|
|
host = 'localhost';
|
|
}
|
|
}
|
|
|
|
if (from.name) {
|
|
return `${from.name} <${scheme}:${user}@${host}${transportParam}>`;
|
|
}
|
|
return `<${scheme}:${user}@${host}${transportParam}>`;
|
|
};
|
|
|
|
const createBLegToHeader = (req, teams) => {
|
|
const to = req.getParsedHeader('To');
|
|
const host = teams ? req.get('X-MS-Teams-Tenant-FQDN') : 'localhost';
|
|
const uri = parseUri(to.uri);
|
|
if (uri && uri.user) return `sip:${uri.user}@${host}`;
|
|
return `sip:anonymous@${host}`;
|
|
};
|
|
|
|
const initCdr = (req, invite) => {
|
|
const {srf} = req;
|
|
const {trace_id} = req.locals;
|
|
const uri = parseUri(invite.uri);
|
|
const regex = /^\+(\d+)$/;
|
|
let arr = regex.exec(invite.calledNumber);
|
|
const to = arr ? arr[1] : invite.calledNumber;
|
|
arr = regex.exec(invite.callingNumber);
|
|
const from = arr ? arr[1] : invite.callingNumber;
|
|
const applicationSid = req.get('X-Application-Sid');
|
|
|
|
return {
|
|
account_sid: req.get('X-Account-Sid'),
|
|
call_sid: req.get('X-Call-Sid'),
|
|
sip_callid: invite.get('Call-ID'),
|
|
...(req.has('X-CID') && {sip_parent_callid: req.get('X-CID')}),
|
|
...(applicationSid && {application_sid: applicationSid}),
|
|
from,
|
|
to,
|
|
duration: 0,
|
|
answered: false,
|
|
attempted_at: Date.now(),
|
|
direction: 'outbound',
|
|
host: srf.locals.sipAddress,
|
|
remote_host: uri.host,
|
|
trace_id: trace_id || '00000000000000000000000000000000'
|
|
};
|
|
};
|
|
|
|
class CallSession extends Emitter {
|
|
constructor(logger, req, res) {
|
|
super();
|
|
this.req = req;
|
|
this.res = res;
|
|
this.srf = req.srf;
|
|
this.logger = logger.child({callId: req.get('Call-ID')});
|
|
this.useWss = req.locals.registration && req.locals.registration.protocol === 'wss';
|
|
this.stats = this.srf.locals.stats;
|
|
this.idleEmitter = this.srf.locals.idleEmitter;
|
|
this.activeCallIds = this.srf.locals.activeCallIds;
|
|
this.writeCdrs = this.srf.locals.writeCdrs;
|
|
|
|
this.decrKey = req.srf.locals.realtimeDbHelpers.decrKey;
|
|
this.addKey = req.srf.locals.realtimeDbHelpers.addKey;
|
|
this.retrieveKey = req.srf.locals.realtimeDbHelpers.retrieveKey;
|
|
this.retrieveHash = req.srf.locals.realtimeDbHelpers.retrieveHash;
|
|
|
|
const {
|
|
lookupOutboundCarrierForAccount,
|
|
lookupCarrierBySid,
|
|
lookupSipGatewaysByCarrier,
|
|
lookupCarrierByAccountLcr
|
|
} = this.srf.locals.dbHelpers;
|
|
this.lookupOutboundCarrierForAccount = lookupOutboundCarrierForAccount;
|
|
this.lookupCarrierBySid = lookupCarrierBySid;
|
|
this.lookupSipGatewaysByCarrier = lookupSipGatewaysByCarrier;
|
|
this.lookupCarrierByAccountLcr = lookupCarrierByAccountLcr;
|
|
|
|
this._mediaPath = MediaPath.FullMedia;
|
|
|
|
this.recordingNoAnswerTimeout = (process.env.JAMBONES_RECORDING_NO_ANSWER_TIMEOUT || 2) * 1000;
|
|
}
|
|
|
|
get service_provider_sid() {
|
|
return this.req.locals.service_provider_sid;
|
|
}
|
|
|
|
get account_sid() {
|
|
return this.req.locals.account_sid;
|
|
}
|
|
|
|
get application_sid() {
|
|
return this.req.locals.application_sid;
|
|
}
|
|
|
|
get privateSipAddress() {
|
|
return this.srf.locals.privateSipAddress;
|
|
}
|
|
|
|
get isMediaReleased() {
|
|
return this._mediaPath !== MediaPath.FullMedia;
|
|
}
|
|
|
|
subscribeForDTMF(dlg) {
|
|
if (!this._subscribedForDTMF) {
|
|
this._subscribedForDTMF = true;
|
|
this.subscribeDTMF(this.logger, this.req.get('Call-ID'), this.rtpEngineOpts.uac.tag,
|
|
this._onDTMF.bind(this, dlg));
|
|
}
|
|
}
|
|
unsubscribeForDTMF() {
|
|
if (this._subscribedForDTMF) {
|
|
this._subscribedForDTMF = false;
|
|
this.unsubscribeDTMF(this.logger, this.req.get('Call-ID'), this.rtpEngineOpts.uac.tag);
|
|
}
|
|
}
|
|
|
|
async connect() {
|
|
const teams = this.teams = this.req.locals.target === 'teams';
|
|
const engine = this.srf.locals.getRtpEngine();
|
|
if (!engine) {
|
|
this.logger.info('No available rtpengines, rejecting call!');
|
|
return this.res.send(480);
|
|
}
|
|
debug(`got engine: ${JSON.stringify(engine)}`);
|
|
const {
|
|
offer,
|
|
answer,
|
|
del,
|
|
blockMedia,
|
|
unblockMedia,
|
|
blockDTMF,
|
|
unblockDTMF,
|
|
playDTMF,
|
|
subscribeDTMF,
|
|
unsubscribeDTMF,
|
|
subscribeRequest,
|
|
subscribeAnswer,
|
|
unsubscribe
|
|
} = engine;
|
|
const {client, createHash, retrieveHash} = this.srf.locals.realtimeDbHelpers;
|
|
this.offer = offer;
|
|
this.answer = answer;
|
|
this.del = del;
|
|
this.blockMedia = blockMedia;
|
|
this.unblockMedia = unblockMedia;
|
|
this.blockDTMF = blockDTMF;
|
|
this.unblockDTMF = unblockDTMF;
|
|
this.playDTMF = playDTMF;
|
|
this.subscribeDTMF = subscribeDTMF;
|
|
this.unsubscribeDTMF = unsubscribeDTMF;
|
|
this.subscribeRequest = subscribeRequest;
|
|
this.subscribeAnswer = subscribeAnswer;
|
|
this.unsubscribe = unsubscribe;
|
|
this.srsClients = [];
|
|
|
|
this.rtpEngineOpts = makeRtpEngineOpts(this.req, false, this.useWss || teams, false, teams);
|
|
this.rtpEngineResource = {destroy: this.del.bind(null, this.rtpEngineOpts.common)};
|
|
let proxy, uris = [];
|
|
const mapGateways = new Map();
|
|
let encryptedMedia = false;
|
|
|
|
try {
|
|
// determine where to send the call
|
|
debug(`connecting call: ${JSON.stringify(this.req.locals)}`);
|
|
const headers = {
|
|
'To': createBLegToHeader(this.req, teams),
|
|
Allow: 'INVITE, ACK, OPTIONS, CANCEL, BYE, NOTIFY, UPDATE, PRACK',
|
|
'X-Account-Sid': this.account_sid
|
|
};
|
|
|
|
if (this.req.locals.registration) {
|
|
debug(`sending call to registered user ${JSON.stringify(this.req.locals.registration)}`);
|
|
const contact = this.req.locals.registration.contact;
|
|
let destUri = contact;
|
|
if (this.req.has('X-Override-To')) {
|
|
const dest = this.req.get('X-Override-To');
|
|
const uri = parseUri(contact);
|
|
uri.user = dest;
|
|
destUri = stringifyUri(uri);
|
|
this.logger.info(`overriding destination user with ${dest}, so final uri is ${destUri}`);
|
|
}
|
|
uris = [{
|
|
private_network: contact.includes('transport=ws') ? false : await isPrivateVoipNetwork(destUri),
|
|
uri: destUri
|
|
}];
|
|
if (!contact.includes('transport=ws')) {
|
|
proxy = this.req.locals.registration.proxy;
|
|
}
|
|
this.logger.info(`sending call to registered user ${destUri}`);
|
|
}
|
|
else if (this.req.locals.target === 'forward') {
|
|
/* the feature-server sets X-Jambonz-SRTP (from the dial verb's srtpEncryption
|
|
option) to request encrypted media on a per-call basis */
|
|
const srtpMode = this.req.get('X-Jambonz-SRTP');
|
|
if (srtpMode) {
|
|
/* SDES (RTP/SAVP): pass teams=true to select the SDES srtp profile, matching
|
|
the proven carrier tls/srtp path above. This offers a=crypto to the target
|
|
(what SIP endpoints such as LiveKit expect), not DTLS-SRTP. */
|
|
this.logger.info({uri: this.req.uri, srtpMode},
|
|
'using SRTP (SDES) for forwarded call per X-Jambonz-SRTP');
|
|
this.rtpEngineOpts = makeRtpEngineOpts(this.req, false, true, false, true);
|
|
encryptedMedia = true;
|
|
}
|
|
else if (process.env.JAMBONES_SIPS_FORWARD_SRTP && this.req.uri.startsWith('sips:')) {
|
|
/* legacy global opt-in for sips: forwards (uses the DTLS srtp profile) */
|
|
this.logger.info({uri: this.req.uri}, 'using SRTP/TLS for forwarded sips: call');
|
|
this.rtpEngineOpts = makeRtpEngineOpts(this.req, false, true, false, false);
|
|
encryptedMedia = true;
|
|
}
|
|
uris = [{
|
|
private_network: await isPrivateVoipNetwork(this.req.uri),
|
|
uri: this.req.uri
|
|
}];
|
|
if (this.req.has('X-SIP-Proxy')) {
|
|
proxy = this.req.get('X-SIP-Proxy');
|
|
if (!proxy.startsWith('sip:') && !proxy.startsWith('sips:')) proxy = `sip:${proxy}`;
|
|
}
|
|
}
|
|
else if (teams) {
|
|
const vmailParam = 'opaque=app:voicemail';
|
|
proxy = `sip:${this.req.calledNumber}@sip.pstnhub.microsoft.com:5061;transport=tls`;
|
|
if (this.req.uri.includes(vmailParam)) {
|
|
uris = [{
|
|
private_network: false,
|
|
uri: `sip:${this.req.calledNumber}@sip.pstnhub.microsoft.com;${vmailParam}`
|
|
}];
|
|
}
|
|
else uris = [{
|
|
private_network: false,
|
|
uri: `sip:${this.req.calledNumber}@sip.pstnhub.microsoft.com`
|
|
}];
|
|
}
|
|
else {
|
|
try {
|
|
/* was a specific carrier requested */
|
|
let voip_carrier_sid = this.req.get('X-Requested-Carrier-Sid');
|
|
const account_sid = this.req.get('X-Account-Sid');
|
|
if (!voip_carrier_sid && account_sid) {
|
|
/* search for an LCR table for this account or service provider */
|
|
voip_carrier_sid = await this.lookupCarrierByAccountLcr(account_sid, this.req.calledNumber);
|
|
}
|
|
if (!voip_carrier_sid) {
|
|
/* no LCR for this account/SP - try with inbound carrier */
|
|
const inbound_carrier_sid = this.req.get('X-Voip-Carrier-Sid');
|
|
if (inbound_carrier_sid) {
|
|
const gateways = await this.lookupSipGatewaysByCarrier(inbound_carrier_sid);
|
|
const gws = (gateways || [])
|
|
.filter((gw) => gw.outbound);
|
|
if (gws.length) {
|
|
voip_carrier_sid = inbound_carrier_sid;
|
|
}
|
|
}
|
|
}
|
|
if (!voip_carrier_sid) {
|
|
/* no LCR/ inbound carrier for this account/SP - at this point its a random shuffle of outbound carriers */
|
|
voip_carrier_sid = await this.lookupOutboundCarrierForAccount(this.account_sid);
|
|
}
|
|
if (!voip_carrier_sid) {
|
|
/* no outbound carriers exist for this account/SP */
|
|
this.logger.info(`no outbound carriers found for account_sid ${account_sid}`);
|
|
this.res.send(603);
|
|
return this.srf.endSession(this.req);
|
|
}
|
|
const vc = await this.lookupCarrierBySid(voip_carrier_sid);
|
|
const gateways = await this.lookupSipGatewaysByCarrier(voip_carrier_sid);
|
|
const goodGateways = [];
|
|
for (const g of gateways) {
|
|
if (!await isBlackListedSipGateway(client, this.logger, g.sip_gateway_sid)) {
|
|
goodGateways.push(g);
|
|
}
|
|
}
|
|
const gws = (goodGateways || [])
|
|
.filter((gw) => gw.outbound);
|
|
if (gws.length) {
|
|
uris = [];
|
|
gws.forEach((o) => {
|
|
const calledNumber = this.req.calledNumber.startsWith('+') ?
|
|
this.req.calledNumber.slice(1) :
|
|
this.req.calledNumber;
|
|
const prefix = vc.tech_prefix || '';
|
|
const transport = o.protocol?.startsWith('tls') ? 'tls' : (o.protocol || 'udp');
|
|
const hostport = !o.port || 5060 === o.port ? o.ipv4 : `${o.ipv4}:${o.port}`;
|
|
const prependPlus = vc.e164_leading_plus && /^[1-9]/.test(calledNumber) ? '+' : '';
|
|
const scheme = transport === 'tls' && !process.env.JAMBONES_USE_BEST_EFFORT_TLS && o.use_sips_scheme ?
|
|
'sips' : 'sip';
|
|
let u = `${scheme}:${prefix}${prependPlus}${calledNumber}@${hostport};transport=${transport}`;
|
|
const obj = {
|
|
name: vc.name,
|
|
diversion: vc.diversion,
|
|
hostport,
|
|
transport,
|
|
scheme,
|
|
register_from_domain: vc.register_from_domain
|
|
};
|
|
if (vc.register_username && vc.register_password) {
|
|
obj.auth = {
|
|
username: vc.register_username,
|
|
password: vc.register_password
|
|
};
|
|
}
|
|
if (vc.requires_register && vc.register_sip_realm?.length > 0) {
|
|
proxy = u;
|
|
u = `${scheme}:${prefix}${prependPlus}${calledNumber}@${vc.register_sip_realm};transport=${transport}`;
|
|
this.logger.debug({uri: u}, `using outbound proxy for this registered trunk: ${proxy}`);
|
|
} else if (vc.outbound_sip_proxy && vc.outbound_sip_proxy.length > 0) {
|
|
if (vc.outbound_sip_proxy.includes('sip:') || vc.outbound_sip_proxy.includes('sips:')) {
|
|
proxy = vc.outbound_sip_proxy;
|
|
} else if (isValidDomainOrIP(vc.outbound_sip_proxy)) {
|
|
proxy =
|
|
`${scheme}:${prefix}${prependPlus}${calledNumber}@${vc.outbound_sip_proxy};transport=${transport}`;
|
|
this.logger.debug({uri: u}, `using outbound proxy for this trunk: ${proxy}`);
|
|
}
|
|
}
|
|
mapGateways.set(u, obj);
|
|
uris.push(u);
|
|
this.logger.debug({gateway: o}, `pushed uri ${u}`);
|
|
if (o.protocol === 'tls/srtp') {
|
|
/** TODO: this is a bit of a hack in the sense that we are not
|
|
* supporting a scenario where you have a carrier with several outbound
|
|
* gateways, some requiring encrypted media and some not. This should be rectified
|
|
* but it will require more significant changes and right now it seems
|
|
* like a rare use case -- encryption is usually an all or nothing requirement.
|
|
*/
|
|
this.logger.info({u}, `using SRTP for outbound call, pad crypto: ${o.pad_crypto ? 'yes' : 'no'}`);
|
|
// eslint-disable-next-line max-len
|
|
this.rtpEngineOpts = makeRtpEngineOpts(this.req, false, true, o.pad_crypto, true, o.remove_ice, o.dtls_off);
|
|
encryptedMedia = true;
|
|
}
|
|
});
|
|
/* Check private network for each gw */
|
|
uris = await Promise.all(uris.map(async(u) => {
|
|
return {
|
|
private_network: await isPrivateVoipNetwork(u),
|
|
uri: u
|
|
};
|
|
}));
|
|
this.logger.debug({uris, voip_carrier_sid}, 'selected outbound gateways for requested carrier');
|
|
}
|
|
else {
|
|
this.logger.info({voip_carrier_sid}, 'no outbound gateways found for requested carrier');
|
|
this.res.send(603);
|
|
}
|
|
} catch (err) {
|
|
debug(err);
|
|
this.logger.error(err, 'Error performing lcr');
|
|
this.res.send(488);
|
|
return this.srf.endSession(this.req);
|
|
}
|
|
debug(`sending call to PSTN ${uris}`);
|
|
}
|
|
|
|
/* private_network should be called at last - try public first */
|
|
uris = uris.sort((a, b) => a.private_network - b.private_network);
|
|
const toPrivate = uris.some((u) => u.private_network === true);
|
|
const toPublic = uris.some((u) => u.private_network === false);
|
|
let isOfferUpdatedToPrivate = toPrivate && !toPublic;
|
|
|
|
const opts = {
|
|
...this.rtpEngineOpts.common,
|
|
...this.rtpEngineOpts.uac.mediaOpts,
|
|
'from-tag': this.rtpEngineOpts.uas.tag,
|
|
direction: ['private', toPublic ? 'public' : 'private'],
|
|
sdp: this.req.body
|
|
};
|
|
let response = await this.offer(opts);
|
|
this.logger.debug({offer: opts, response}, 'initial offer to rtpengine');
|
|
if ('ok' !== response.result) {
|
|
this.logger.error(`rtpengine offer failed with ${JSON.stringify(response)}`);
|
|
throw new Error('rtpengine failed: answer');
|
|
}
|
|
|
|
/* check if call was abandoned */
|
|
if (this.req.canceled) throw new Error('abandoned');
|
|
|
|
// crank through the list of gateways until connected, exhausted or caller hangs up
|
|
let earlyMedia = false;
|
|
while (uris.length) {
|
|
let hdrs = { ...headers };
|
|
const {private_network, uri} = uris.shift();
|
|
|
|
/* if we've exhausted attempts to public endpoints and are switching to trying private, we need new rtp */
|
|
if (private_network && !isOfferUpdatedToPrivate) {
|
|
this.logger.info('switching to attempt to deliver call via private network now..');
|
|
this.rtpEngineResource.destroy()
|
|
.catch((err) => this.logger.info({err}, 'Error destroying rtpe to re-connect to private network'));
|
|
response = await this.offer({
|
|
...opts,
|
|
direction: ['private', 'private']
|
|
});
|
|
isOfferUpdatedToPrivate = true;
|
|
}
|
|
|
|
/* on the second and subsequent attempts, use the same Call-ID and CSeq from the first attempt */
|
|
if (0 === uris.length) {
|
|
try {
|
|
const key = makeInviteInProgressKey(this.req.get('Call-ID'));
|
|
const obj = await retrieveHash(key);
|
|
if (obj && obj.callId && obj.cseq) {
|
|
hdrs = {
|
|
...hdrs,
|
|
'Call-ID': obj.callId,
|
|
'CSeq': `${obj.cseq} INVITE`
|
|
};
|
|
}
|
|
} catch (err) {
|
|
this.logger.info({err}, 'Error retrieving iip key');
|
|
}
|
|
}
|
|
|
|
/* INVITE request line and To header should be the same. */
|
|
hdrs = {...hdrs, 'To': uri};
|
|
|
|
/* only now can we set Contact & From header since they depend on transport and scheme of gw */
|
|
const gw = mapGateways.get(uri);
|
|
if (gw) {
|
|
const {scheme, transport} = gw;
|
|
this.logger.info({gw}, `sending INVITE to ${uri} via carrier ${gw.name}`);
|
|
hdrs = {
|
|
...hdrs,
|
|
From: gw.register_from_domain ?
|
|
createBLegFromHeader({
|
|
logger: this.logger,
|
|
req: this.req,
|
|
register_from_domain: gw.register_from_domain,
|
|
scheme,
|
|
transport,
|
|
...(private_network && {host: this.privateSipAddress})
|
|
}) :
|
|
createBLegFromHeader({
|
|
logger: this.logger,
|
|
req: this.req,
|
|
scheme,
|
|
transport,
|
|
...(private_network && {host: this.privateSipAddress})
|
|
}),
|
|
Contact: createBLegFromHeader({
|
|
logger: this.logger,
|
|
req: this.req,
|
|
...(gw.auth?.username && {fromUser: gw.auth?.username}),
|
|
scheme,
|
|
transport,
|
|
...(private_network && {host: this.privateSipAddress})
|
|
}),
|
|
...(gw.diversion && {
|
|
Diversion: gw.diversion.startsWith('+') ?
|
|
`<sip:${gw.diversion}@${gw.hostport}>;reason=unknown;counter=1;privacy=off` :
|
|
`<sip:+${gw.diversion}@${gw.hostport}>;reason=unknown;counter=1;privacy=off`
|
|
})
|
|
};
|
|
}
|
|
else if (teams) {
|
|
hdrs = {
|
|
...hdrs,
|
|
'From': createBLegFromHeader({logger: this.logger, req: this.req, teams: true, transport: 'tls'}),
|
|
'Contact': `sip:${this.req.calledNumber}@${this.req.get('X-MS-Teams-Tenant-FQDN')}:5061;transport=tls`
|
|
};
|
|
}
|
|
else {
|
|
const uri = parseUri(this.req.uri);
|
|
hdrs = {
|
|
...hdrs,
|
|
'From': createBLegFromHeader({
|
|
logger: this.logger,
|
|
req: this.req,
|
|
transport: uri.params.transport,
|
|
...(private_network && {host: this.privateSipAddress})
|
|
}),
|
|
'Contact': createBLegFromHeader({
|
|
logger: this.logger,
|
|
req: this.req,
|
|
transport: uri.params.transport,
|
|
...(private_network && {host: this.privateSipAddress})
|
|
})
|
|
};
|
|
const p = proxy ? ` via ${proxy}` : '';
|
|
this.logger.info({uri, p}, `sending INVITE${p}`);
|
|
}
|
|
|
|
/* now launch an outbound call attempt */
|
|
const passFailure = 0 === uris.length; // only propagate failure on last attempt
|
|
try {
|
|
const {uas, uac} = await this.srf.createB2BUA(this.req, this.res, uri, {
|
|
proxy,
|
|
passFailure,
|
|
proxyRequestHeaders: [
|
|
'all',
|
|
'-X-MS-Teams-FQDN',
|
|
'-X-MS-Teams-Tenant-FQDN',
|
|
'-X-Trace-ID',
|
|
'-Allow',
|
|
'-Session-Expires',
|
|
'-X-Requested-Carrier-Sid',
|
|
'-X-Jambonz-Routing',
|
|
'-X-Jambonz-FS-UUID',
|
|
'-X-Preferred-From-User',
|
|
'-X-Preferred-From-Host',
|
|
'-X-Jambonz-FS-UUID',
|
|
'-X-Voip-Carrier-Sid',
|
|
'-X-Jambonz-SRTP',
|
|
'-X-SIP-Proxy'
|
|
],
|
|
proxyResponseHeaders: [
|
|
'all',
|
|
'-Allow',
|
|
'-Session-Expires'
|
|
],
|
|
// Add X-CID header to feature server response.
|
|
// to allow calling/status hooks contains sbc_callid.
|
|
responseHeaders: (uacRes, headers) => {
|
|
headers['X-CID'] = uacRes.get('Call-ID');
|
|
},
|
|
headers: hdrs,
|
|
auth: gw ? gw.auth : undefined,
|
|
localSdpB: response.sdp,
|
|
localSdpA: async(sdp, res) => {
|
|
this.rtpEngineOpts.uac.tag = res.getParsedHeader('To').params.tag;
|
|
|
|
const opts = {
|
|
...this.rtpEngineOpts.common,
|
|
...this.rtpEngineOpts.uas.mediaOpts,
|
|
'from-tag': this.rtpEngineOpts.uas.tag,
|
|
'to-tag': this.rtpEngineOpts.uac.tag,
|
|
flags: [
|
|
'single codec',
|
|
'inject DTMF',
|
|
'reuse codecs',
|
|
],
|
|
sdp
|
|
};
|
|
const response = await this.answer(opts);
|
|
this.logger.debug({answer: opts, response}, 'rtpengine answer');
|
|
if ('ok' !== response.result) {
|
|
/* note: this can happen if call was abandoned while we were waiting for B leg to answer */
|
|
this.logger.info(`rtpengine answer failed with ${JSON.stringify(response)}`);
|
|
throw new Error(`rtpengine failed: ${response['error-reason']}`);
|
|
}
|
|
return response.sdp;
|
|
}
|
|
}, {
|
|
cbRequest: async(err, inv) => {
|
|
if (err) return this.logger.info({err}, 'CallSession error sending INVITE');
|
|
let trunk = gw ? gw.name : null;
|
|
if (!trunk) {
|
|
if (teams) trunk = 'Microsoft Teams';
|
|
else if (this.req.locals.registration) trunk = 'user';
|
|
else trunk = 'sipUri';
|
|
}
|
|
if (this.req.locals.account?.disable_cdrs) {
|
|
this.logger.debug('cdrs disabled for this account');
|
|
}
|
|
else {
|
|
this.req.locals.cdr = {
|
|
...initCdr(this.req, inv),
|
|
service_provider_sid: this.req.locals.service_provider_sid,
|
|
account_sid: this.req.locals.account_sid,
|
|
...(this.req.locals.application_sid && {application_sid: this.req.locals.application_sid}),
|
|
trunk
|
|
};
|
|
}
|
|
const opts = {
|
|
callId: inv.get('Call-ID'),
|
|
cseq: ++inv.getParsedHeader('CSeq').seq
|
|
};
|
|
try {
|
|
const key = makeInviteInProgressKey(this.req.get('Call-ID'));
|
|
await createHash(key, opts, 5);
|
|
} catch (err) {
|
|
this.logger.error({err}, 'Error saving Call-ID/CSeq');
|
|
}
|
|
|
|
this.contactHeader = inv.get('Contact');
|
|
this.logger.info(`outbound call attempt to ${uri} has contact header ${this.contactHeader}`);
|
|
},
|
|
cbProvisional: (response) => {
|
|
if (!earlyMedia && [180, 183].includes(response.status) && response.body) earlyMedia = true;
|
|
}
|
|
});
|
|
|
|
// successfully connected
|
|
this.logger.info(`call connected to ${uri}`);
|
|
this.connectedUri = uri;
|
|
debug('call connected');
|
|
this.emit('connected', uri);
|
|
|
|
this._setHandlers({uas, uac});
|
|
return;
|
|
|
|
} catch (err) {
|
|
// these are all final failure scenarios
|
|
if (uris.length === 0 || // exhausted all targets
|
|
earlyMedia || // failure after early media
|
|
encryptedMedia || // cant crank back when using encrypted media as keys have been exchanged
|
|
!(err instanceof SipError) || // unexpected error
|
|
err.status === 487) { // caller hung up
|
|
|
|
const abandoned = err.message && err.message.includes('rtpengine failed: Unknown call-id');
|
|
const status = err.status || (abandoned ? 487 : 500);
|
|
if (err instanceof SipError) this.logger.info(`final call failure ${status}`);
|
|
else if (!abandoned) {
|
|
const {writeCallCount, writeCallCountSP, writeCallCountApp} = this.req.srf.locals;
|
|
this.logger.error({err}, 'unexpected call failure');
|
|
nudgeCallCounts(this.req, 'failure', {
|
|
service_provider_sid: this.service_provider_sid,
|
|
account_sid: this.account_sid,
|
|
application_sid: this.application_sid,
|
|
callId: this.req.locals.callId
|
|
}, this.decrKey, {writeCallCountSP, writeCallCount, writeCallCountApp})
|
|
.catch((err) => this.logger.error(err, 'Error decrementing call counts'));
|
|
}
|
|
this.logger.debug(`got final outdial error: ${err}`);
|
|
if (!passFailure) this.res.send(status);
|
|
this.emit('failed');
|
|
this.rtpEngineResource.destroy()
|
|
.catch((err) => this.logger.info({err}, 'Error destroying rtpe after failure'));
|
|
this.srf.endSession(this.req);
|
|
const tags = ['accepted:no', `sipStatus:${status}`];
|
|
this.stats.increment('sbc.originations', tags);
|
|
|
|
if (this.req.locals.cdr && ![401, 407].includes(status)) {
|
|
this.writeCdrs({...this.req.locals.cdr,
|
|
terminated_at: Date.now(),
|
|
termination_reason: 487 === status ? 'caller abandoned' : 'failed',
|
|
sip_status: status
|
|
}).catch((err) => this.logger.error({err}, 'Error writing cdr for call failure'));
|
|
}
|
|
return;
|
|
}
|
|
else {
|
|
this.logger.info(`got ${err.status}, cranking back to next destination`);
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if ('abandoned' !== err.message) this.logger.error({err}, `Error setting up outbound call to: ${uris}`);
|
|
|
|
/* the call count was incremented on 'init' but we bailed out before connecting (e.g. caller
|
|
hung up before the B leg answered), so we must decrement it here - no failure response is
|
|
sent on this path, so the res.once('end') safety net in middleware would not fire.
|
|
nudgeCallCounts is a no-op if the count was already decremented elsewhere. */
|
|
const {writeCallCount, writeCallCountSP, writeCallCountApp} = this.req.srf.locals;
|
|
nudgeCallCounts(this.req, 'failure', {
|
|
service_provider_sid: this.service_provider_sid,
|
|
account_sid: this.account_sid,
|
|
application_sid: this.application_sid,
|
|
callId: this.req.locals.callId
|
|
}, this.decrKey, {writeCallCountSP, writeCallCount, writeCallCountApp})
|
|
.catch((err) => this.logger.error(err, 'Error decrementing call counts'));
|
|
|
|
this.emit('failed');
|
|
this.srf.endSession(this.req);
|
|
this.rtpEngineResource.destroy();
|
|
}
|
|
}
|
|
|
|
_setHandlers({uas, uac}) {
|
|
const callStart = Date.now();
|
|
const tags = ['accepted:yes', 'sipStatus:200'];
|
|
this.stats.increment('sbc.originations', tags);
|
|
this.activeCallIds.set(this.req.get('Call-ID'), this);
|
|
if (this.req.locals.cdr) {
|
|
this.req.locals.cdr = {
|
|
...this.req.locals.cdr,
|
|
answered: true,
|
|
answered_at: callStart
|
|
};
|
|
}
|
|
|
|
/* save far end SDP for later use if we do a full media release */
|
|
if (process.env.JAMBONES_ENABLE_FULL_MEDIA_RELEASE) {
|
|
const key = makeFullMediaReleaseKey(this.req.get('X-CID'));
|
|
const sdp = uac.remote.sdp;
|
|
this.logger.info({key, sdp}, 'saving far end sdp for full media release feature');
|
|
this.addKey(key, sdp, 3600).catch((err) => this.logger.error(err, 'Error saving far end sdp'));
|
|
}
|
|
|
|
this.uas = uas;
|
|
this.uac = uac;
|
|
[uas, uac].forEach((dlg) => {
|
|
dlg.on('destroy', async(bye) => {
|
|
const other = dlg.other;
|
|
this.rtpEngineResource.destroy();
|
|
this.activeCallIds.delete(this.req.get('Call-ID'));
|
|
this.unsubscribeForDTMF();
|
|
//this.unsubscribeDTMF(this.logger, this.req.get('Call-ID'), this.rtpEngineOpts.uac.tag);
|
|
try {
|
|
const headers = {};
|
|
Object.keys(bye.headers).forEach((h) => {
|
|
if (!IMMUTABLE_HEADERS.includes(h)) headers[h] = bye.headers[h];
|
|
});
|
|
await other.destroy({headers});
|
|
} catch {}
|
|
|
|
const trackingOn = process.env.JAMBONES_TRACK_ACCOUNT_CALLS ||
|
|
process.env.JAMBONES_TRACK_SP_CALLS ||
|
|
process.env.JAMBONES_TRACK_APP_CALLS;
|
|
|
|
if (process.env.JAMBONES_HOSTING || trackingOn) {
|
|
const {writeCallCount, writeCallCountSP, writeCallCountApp} = this.req.srf.locals;
|
|
this.logger.debug('decrementing call count at end of call');
|
|
await nudgeCallCounts(this.req, 'complete', {
|
|
service_provider_sid: this.service_provider_sid,
|
|
account_sid: this.account_sid,
|
|
application_sid: this.application_sid,
|
|
callId: this.req.locals.callId
|
|
}, this.decrKey, {writeCallCountSP, writeCallCount, writeCallCountApp})
|
|
.catch((err) => this.logger.error(err, 'Error decrementing call counts'));
|
|
}
|
|
|
|
/* write cdr for connected call */
|
|
if (this.req.locals.cdr) {
|
|
const now = Date.now();
|
|
const day = new Date();
|
|
// eslint-disable-next-line max-len
|
|
const {hasRecording = false} = await this.retrieveHash(`call:${this.account_sid}:${this.req.locals.cdr.call_sid}`) ?? {};
|
|
const recordAllCalls = this.req.locals.record_all_calls || hasRecording;
|
|
const record_format = this.req.locals.account.record_format || 'mp3';
|
|
let recording_url = `/Accounts/${this.account_sid}/RecentCalls/${this.req.locals.cdr.call_sid}/record`;
|
|
recording_url += `/${day.getFullYear()}/${(day.getMonth() + 1).toString().padStart(2, '0')}`;
|
|
recording_url += `/${day.getDate().toString().padStart(2, '0')}/${record_format}`;
|
|
this.writeCdrs({...this.req.locals.cdr,
|
|
terminated_at: now,
|
|
termination_reason: dlg.type === 'uas' ? 'caller hungup' : 'called party hungup',
|
|
sip_status: 200,
|
|
answered: true,
|
|
duration: Math.floor((now - callStart) / 1000),
|
|
...(recordAllCalls && {recording_url})
|
|
}).catch((err) => this.logger.error({err}, 'Error writing cdr for completed call'));
|
|
}
|
|
/* de-link the 2 Dialogs for GC */
|
|
dlg.removeAllListeners();
|
|
other.removeAllListeners();
|
|
dlg.other = null;
|
|
other.other = null;
|
|
|
|
this._stopRecording();
|
|
|
|
this.logger.info(`call ended with normal termination, there are ${this.activeCallIds.size} active`);
|
|
if (this.activeCallIds.size === 0) this.idleEmitter.emit('idle');
|
|
this.srf.endSession(this.req);
|
|
});
|
|
});
|
|
|
|
this.subscribeForDTMF(uas);
|
|
//this.subscribeDTMF(this.logger, this.req.get('Call-ID'), this.rtpEngineOpts.uac.tag,
|
|
// this._onDTMF.bind(this, uas));
|
|
|
|
uas.on('modify', this._onReinvite.bind(this, uas));
|
|
uac.on('modify', this._onReinvite.bind(this, uac));
|
|
|
|
uas.on('refer', this._onFeatureServerTransfer.bind(this, uas));
|
|
uac.on('refer', this._onRefer.bind(this, uac));
|
|
|
|
uas.on('info', this._onInfo.bind(this, uas));
|
|
uac.on('info', this._onInfo.bind(this, uac));
|
|
|
|
// default forwarding of other request types
|
|
forwardInDialogRequests(uac, ['notify', 'options', 'message']);
|
|
}
|
|
|
|
_startRecordingNoAnswerTimer(res) {
|
|
this._clearRecordingNoAnswerTimer();
|
|
this.recordingNoAnswerTimer = setTimeout(() => {
|
|
this.logger.info('No response from SipRec server, return error to feature server');
|
|
this.isRecordingNoAnswerResponded = true;
|
|
res.send(400);
|
|
}, this.recordingNoAnswerTimeout);
|
|
}
|
|
|
|
_clearRecordingNoAnswerTimer() {
|
|
if (this.recordingNoAnswerTimer) {
|
|
clearTimeout(this.recordingNoAnswerTimer);
|
|
this.recordingNoAnswerTimer = null;
|
|
}
|
|
}
|
|
|
|
_stopRecording() {
|
|
if (this.srsClients.length) {
|
|
this.srsClients.forEach((c) => c.stop());
|
|
this.srsClients = [];
|
|
}
|
|
}
|
|
|
|
async _onRefer(dlg, req, res) {
|
|
/* REFER coming in from a sip device, forward to feature server */
|
|
try {
|
|
const customHeaders = Object.keys(req.headers)
|
|
.filter((h) => h.toLowerCase().startsWith('x-'))
|
|
.reduce((acc, h) => {
|
|
acc[h] = req.get(h);
|
|
return acc;
|
|
}, {});
|
|
const response = await dlg.other.request({
|
|
method: 'REFER',
|
|
headers: {
|
|
'Refer-To': req.get('Refer-To'),
|
|
'Referred-By': req.get('Referred-By'),
|
|
'User-Agent': req.get('User-Agent'),
|
|
...customHeaders
|
|
}
|
|
});
|
|
res.send(response.status, response.reason);
|
|
} catch (err) {
|
|
this.logger.error({err}, 'CallSession:_onRefer: error handling incoming REFER');
|
|
}
|
|
}
|
|
|
|
async _onDTMF(dlg, payload) {
|
|
this.logger.info({payload}, '_onDTMF');
|
|
try {
|
|
let dtmf;
|
|
switch (payload.event) {
|
|
case 10:
|
|
dtmf = '*';
|
|
break;
|
|
case 11:
|
|
dtmf = '#';
|
|
break;
|
|
default:
|
|
dtmf = '' + payload.event;
|
|
break;
|
|
}
|
|
await dlg.request({
|
|
method: 'INFO',
|
|
headers: {
|
|
'Content-Type': 'application/dtmf-relay'
|
|
},
|
|
body: `Signal=${dtmf}
|
|
Duration=${payload.duration} `
|
|
});
|
|
} catch (err) {
|
|
this.logger.info({err}, 'Error sending INFO application/dtmf-relay');
|
|
}
|
|
}
|
|
|
|
async _onReinvite(dlg, req, res) {
|
|
try {
|
|
const reason = req.get('X-Reason');
|
|
const isReleasingMedia = reason && dlg.type === 'uas' && ['release-media', 'anchor-media'].includes(reason);
|
|
const isFullMediaRelease = reason === 'release-media-entirely' && process.env.JAMBONES_ENABLE_FULL_MEDIA_RELEASE;
|
|
const fromTag = dlg.type === 'uas' ? this.rtpEngineOpts.uas.tag : this.rtpEngineOpts.uac.tag;
|
|
const toTag = dlg.type === 'uas' ? this.rtpEngineOpts.uac.tag : this.rtpEngineOpts.uas.tag;
|
|
let offerMedia = dlg.type === 'uas' ? this.rtpEngineOpts.uac.mediaOpts : this.rtpEngineOpts.uas.mediaOpts;
|
|
let answerMedia = dlg.type === 'uas' ? this.rtpEngineOpts.uas.mediaOpts : this.rtpEngineOpts.uac.mediaOpts;
|
|
const direction = dlg.type === 'uas' ? ['private', 'public'] : ['public', 'private'];
|
|
|
|
if (isFullMediaRelease) {
|
|
const a_sdp = await this.retrieveKey(makePartnerFullMediaReleaseKey(this.req.get('X-CID')));
|
|
this.logger.info({a_sdp}, 'reinvite ourselves out of the media path with this reinvite offer');
|
|
const answerSdp = await dlg.other.modify(a_sdp);
|
|
this.logger.info({answerSdp}, 'far end response to full media release');
|
|
res.send(200, {
|
|
body: dlg.local.sdp,
|
|
headers: {
|
|
'Contact': this.contactHeader
|
|
}
|
|
});
|
|
/* no media going through us now we can destroy the rtpengine resource */
|
|
this.rtpEngineResource.destroy().catch((err) => {
|
|
this.logger.info({err}, 'Error destroying rtpengine resource after full media release');
|
|
});
|
|
this._mediaPath = MediaPath.NoMedia;
|
|
return;
|
|
}
|
|
|
|
if (isReleasingMedia) {
|
|
if (!offerMedia.flags.includes('port latching')) offerMedia.flags.push('port latching');
|
|
if (!offerMedia.flags.includes('asymmetric')) offerMedia.flags.push('asymmetric');
|
|
offerMedia.flags = offerMedia.flags.filter((f) => f !== 'media handover');
|
|
}
|
|
const acceptCodecs = process.env.JAMBONES_ACCEPT_AND_TRANSCODE ?
|
|
process.env.JAMBONES_ACCEPT_AND_TRANSCODE :
|
|
process.env.JAMBONES_ACCEPT_G729 ? 'g729' : '';
|
|
offerMedia = { ...offerMedia, ...(acceptCodecs && dlg.type === 'uac' &&
|
|
{ codec: { mask: acceptCodecs, transcode: 'pcmu,pcma' } })};
|
|
answerMedia = { ...answerMedia, ...((process.env.JAMBONES_CODEC_OFFER_WITH_ORDER ||
|
|
process.env.JAMBONES_CODEC_TRANSCODE) && dlg.type === 'uac' &&
|
|
{ codec: {
|
|
...(process.env.JAMBONES_CODEC_OFFER_WITH_ORDER &&
|
|
{ offer: process.env.JAMBONES_CODEC_OFFER_WITH_ORDER.split(','), strip: 'all' }),
|
|
...(process.env.JAMBONES_CODEC_TRANSCODE &&
|
|
{ transcode: process.env.JAMBONES_CODEC_TRANSCODE.split(',') })
|
|
}})};
|
|
let opts = {
|
|
...this.rtpEngineOpts.common,
|
|
...offerMedia,
|
|
'from-tag': fromTag,
|
|
'to-tag': toTag,
|
|
direction,
|
|
sdp: req.body,
|
|
};
|
|
// DH: this was restarting ICE, which we don't want to do
|
|
//if (reason && opts.flags && !opts.flags.includes('reset')) opts.flags.push('reset');
|
|
|
|
let response = await this.offer(opts);
|
|
if ('ok' !== response.result) {
|
|
res.send(488);
|
|
throw new Error(`_onReinvite: rtpengine failed: offer: ${JSON.stringify(response)}`);
|
|
}
|
|
this.logger.debug({opts, response}, 'CallSession:_onReinvite: (offer)');
|
|
|
|
/* if this is a re-invite from the FS to change media anchoring, avoid sending the reinvite out */
|
|
let sdp;
|
|
//HL 2024-11-13: previously forwarded re-invites to webrtc clients but further testing has shown to be unnecessary
|
|
//if (isReleasingMedia && !this.calleeIsUsingSrtp) {
|
|
|
|
//DH 2024-11- 18: if we are going from no-media to either partial or full media, we need reinvite the far end
|
|
if (isReleasingMedia && this._mediaPath !== MediaPath.NoMedia) {
|
|
this.logger.info(`got a reinvite from FS to ${reason}`);
|
|
sdp = dlg.other.remote.sdp;
|
|
if (!answerMedia.flags.includes('port latching')) answerMedia.flags.push('port latching');
|
|
if (!answerMedia.flags.includes('asymmetric')) answerMedia.flags.push('asymmetric');
|
|
answerMedia.flags = answerMedia.flags.filter((f) => f !== 'media handover');
|
|
this._mediaPath = 'release-media' === reason ? MediaPath.PartialMedia : MediaPath.FullMedia;
|
|
}
|
|
else {
|
|
sdp = await dlg.other.modify(response.sdp);
|
|
}
|
|
opts = {
|
|
...this.rtpEngineOpts.common,
|
|
...answerMedia,
|
|
'from-tag': fromTag,
|
|
'to-tag': toTag,
|
|
sdp
|
|
};
|
|
response = await this.answer(opts);
|
|
/* now remove asymmetric as B party (looking at you Genesys ring group) may need port re-learning on invites */
|
|
answerMedia.flags = answerMedia.flags.filter((f) => f !== 'asymmetric');
|
|
if ('ok' !== response.result) {
|
|
res.send(488);
|
|
throw new Error(`_onReinvite: rtpengine failed: ${JSON.stringify(response)}`);
|
|
}
|
|
this.logger.debug({opts, sdp: response.sdp}, 'CallSession:_onReinvite: (answer) sending back upstream');
|
|
let responseSdp = response.sdp;
|
|
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS) {
|
|
responseSdp = removeVideoSdp(responseSdp);
|
|
}
|
|
res.send(200, {
|
|
body: responseSdp,
|
|
headers: {
|
|
'Contact': this.contactHeader
|
|
}
|
|
});
|
|
} catch (err) {
|
|
res.send(err.status || 500);
|
|
this.logger.error(err, 'Error handling reinvite');
|
|
}
|
|
}
|
|
|
|
async _onInfo(dlg, req, res) {
|
|
try {
|
|
const contentType = req.get('Content-Type');
|
|
if (dlg.type === 'uas' && req.has('X-Reason')) {
|
|
const toTag = this.rtpEngineOpts.uac.tag;
|
|
const reason = req.get('X-Reason');
|
|
const opts = {
|
|
...this.rtpEngineOpts.common,
|
|
'from-tag': toTag
|
|
};
|
|
this.logger.info(`_onInfo: got request ${reason}`);
|
|
res.send(200);
|
|
|
|
if (reason.startsWith('mute')) {
|
|
const response = Promise.all([this.blockMedia(opts), this.blockDTMF(opts)]);
|
|
this.logger.info({response}, `_onInfo: response to rtpengine command for ${reason}`);
|
|
}
|
|
else if (reason.startsWith('unmute')) {
|
|
const response = Promise.all([this.unblockMedia(opts), this.unblockDTMF(opts)]);
|
|
this.logger.info({response}, `_onInfo: response to rtpengine command for ${reason}`);
|
|
}
|
|
else if (reason.includes('CallRecording')) {
|
|
let succeeded = false;
|
|
const headers = contentType === 'application/json' && req.body ? JSON.parse(req.body) : {};
|
|
if (reason === 'startCallRecording') {
|
|
const from = this.req.getParsedHeader('From');
|
|
const to = this.req.getParsedHeader('To');
|
|
const aorFrom = from.uri;
|
|
const aorTo = to.uri;
|
|
this.logger.info({to, from}, 'startCallRecording request for an outbound call');
|
|
|
|
const srsUrl = req.get('X-Srs-Url');
|
|
const srsRecordingId = req.get('X-Srs-Recording-ID');
|
|
const callSid = req.get('X-Call-Sid');
|
|
const accountSid = req.get('X-Account-Sid');
|
|
const applicationSid = req.get('X-Application-Sid');
|
|
if (this.srsClients.length) {
|
|
res.send(400);
|
|
this.logger.info('discarding duplicate startCallRecording request for a call');
|
|
return;
|
|
}
|
|
if (!srsUrl) {
|
|
this.logger.info('startCallRecording request is missing X-Srs-Url header');
|
|
res.send(400);
|
|
return;
|
|
}
|
|
const arr = srsUrl.split(',');
|
|
this.srsClients = arr.map((url) => new SrsClient(this.logger, {
|
|
srf: dlg.srf,
|
|
direction: 'outbound',
|
|
originalInvite: this.req,
|
|
callingNumber: this.req.callingNumber,
|
|
calledNumber: this.req.calledNumber,
|
|
srsUrl: url,
|
|
srsRecordingId,
|
|
callSid,
|
|
accountSid,
|
|
applicationSid,
|
|
rtpEngineOpts: this.rtpEngineOpts,
|
|
toTag,
|
|
aorFrom,
|
|
aorTo,
|
|
subscribeRequest: this.subscribeRequest,
|
|
subscribeAnswer: this.subscribeAnswer,
|
|
del: this.del,
|
|
blockMedia: this.blockMedia,
|
|
unblockMedia: this.unblockMedia,
|
|
unsubscribe: this.unsubscribe,
|
|
headers,
|
|
isSipRecCall: Array.isArray(this.req.payload) && this.req.payload.length > 1
|
|
}));
|
|
try {
|
|
this._startRecordingNoAnswerTimer(res);
|
|
await Promise.any(this.srsClients.map((c) => c.start()));
|
|
succeeded = true;
|
|
} catch (err) {
|
|
this.logger.error({err}, 'Error starting SipRec call recording');
|
|
succeeded = false;
|
|
}
|
|
}
|
|
else if (reason === 'stopCallRecording') {
|
|
if (!this.srsClients.length || !this.srsClients.some((c) => c.activated)) {
|
|
res.send(400);
|
|
this.logger.info('discarding stopCallRecording request because we are not recording');
|
|
return;
|
|
}
|
|
try {
|
|
this._startRecordingNoAnswerTimer(res);
|
|
await Promise.any(this.srsClients.map((c) => {
|
|
if (c.activated) {
|
|
c.stop();
|
|
}
|
|
}));
|
|
succeeded = true;
|
|
} catch (err) {
|
|
this.logger.error({err}, 'Error stopping SipRec call recording');
|
|
succeeded = false;
|
|
}
|
|
this.srsClients = [];
|
|
}
|
|
else if (reason === 'pauseCallRecording') {
|
|
if (!this.srsClients.length || !this.srsClients.some((c) => c.activated && !c.paused)) {
|
|
this.logger.info('discarding invalid pauseCallRecording request');
|
|
res.send(400);
|
|
return;
|
|
}
|
|
try {
|
|
this._startRecordingNoAnswerTimer(res);
|
|
await Promise.any(this.srsClients.map((c) => {
|
|
if (c.activated && !c.paused) {
|
|
c.pause({headers});
|
|
}
|
|
}));
|
|
succeeded = true;
|
|
} catch (err) {
|
|
this.logger.error({err}, 'Error pausing SipRec call recording');
|
|
succeeded = false;
|
|
}
|
|
}
|
|
else if (reason === 'resumeCallRecording') {
|
|
if (!this.srsClients.length || !this.srsClients.some((c) => c.activated && c.paused)) {
|
|
res.send(400);
|
|
this.logger.info('discarding invalid resumeCallRecording request');
|
|
return;
|
|
}
|
|
try {
|
|
this._startRecordingNoAnswerTimer(res);
|
|
await Promise.any(this.srsClients.map((c) => {
|
|
if (c.activated && c.paused) {
|
|
c.resume({headers});
|
|
}
|
|
}));
|
|
succeeded = true;
|
|
} catch (err) {
|
|
this.logger.error({err}, 'Error resuming SipRec call recording');
|
|
succeeded = false;
|
|
}
|
|
}
|
|
if (!this.isRecordingNoAnswerResponded) {
|
|
this._clearRecordingNoAnswerTimer();
|
|
res.send(succeeded ? 200 : 503);
|
|
}
|
|
} else if (reason.includes('Dtmf')) {
|
|
const arr = /Signal=\s*([0-9#*])/.exec(req.body);
|
|
if (!arr) {
|
|
this.logger.info({body: req.body}, '_onInfo: invalid INFO Dtmf');
|
|
throw new Error(`_onInfo: no dtmf in body for ${contentType}`);
|
|
}
|
|
const code = arr[1];
|
|
const arr2 = /Duration=\s*(\d+)/.exec(req.body);
|
|
const duration = arr2 ? arr2[1] : 250;
|
|
const volume = 13;
|
|
const dtmfOpts = {
|
|
...this.rtpEngineOpts.common,
|
|
'from-tag': this.rtpEngineOpts.uas.tag,
|
|
code,
|
|
duration,
|
|
volume
|
|
};
|
|
const response = await this.playDTMF(dtmfOpts);
|
|
if ('ok' !== response.result) {
|
|
this.logger.info({response}, `rtpengine play Dtmf failed with ${JSON.stringify(response)}`);
|
|
throw new Error('rtpengine failed: answer');
|
|
}
|
|
}
|
|
}
|
|
else if (dlg.type === 'uac' && ['application/dtmf-relay', 'application/dtmf'].includes(contentType)) {
|
|
const arr = /Signal=\s*([0-9#*])/.exec(req.body);
|
|
if (!arr) {
|
|
this.logger.info({body: req.body}, '_onInfo: invalid INFO dtmf request');
|
|
throw new Error(`_onInfo: no dtmf in body for ${contentType}`);
|
|
}
|
|
const code = arr[1];
|
|
const arr2 = /Duration=\s*(\d+)/.exec(req.body);
|
|
const duration = arr2 ? arr2[1] : 250;
|
|
|
|
if (this.isMediaReleased) {
|
|
/* just relay on to the feature server */
|
|
this.logger.info({code, duration}, 'got SIP INFO DTMF from caller, relaying to feature server');
|
|
this._onDTMF(dlg.other, {event: code, duration})
|
|
.catch((err) => this.logger.info({err}, 'Error relaying DTMF to feature server'));
|
|
res.send(200);
|
|
}
|
|
else {
|
|
/* else convert SIP INFO to RFC 2833 telephony events */
|
|
this.logger.info({code, duration}, 'got SIP INFO DTMF from caller, converting to RFC 2833');
|
|
const opts = {
|
|
...this.rtpEngineOpts.common,
|
|
'from-tag': this.rtpEngineOpts.uac.tag,
|
|
code,
|
|
duration
|
|
};
|
|
const response = await this.playDTMF(opts);
|
|
if ('ok' !== response.result) {
|
|
this.logger.info({response}, `rtpengine playDTMF failed with ${JSON.stringify(response)}`);
|
|
throw new Error('rtpengine failed: answer');
|
|
}
|
|
res.send(200);
|
|
}
|
|
}
|
|
else {
|
|
const headers = {};
|
|
Object.keys(req.headers).forEach((h) => {
|
|
if (!IMMUTABLE_HEADERS.includes(h)) headers[h] = req.headers[h];
|
|
});
|
|
const response = await dlg.other.request({method: 'INFO', headers, body: req.body});
|
|
const responseHeaders = {};
|
|
if (response.has('Content-Type')) {
|
|
Object.assign(responseHeaders, {'Content-Type': response.get('Content-Type')});
|
|
}
|
|
res.send(response.status, {headers: responseHeaders, body: response.body});
|
|
}
|
|
} catch (err) {
|
|
this.logger.info({err}, `Error handing INFO request on ${dlg.type} leg`);
|
|
}
|
|
}
|
|
|
|
async _onFeatureServerTransfer(dlg, req, res) {
|
|
try {
|
|
// the following properties are ignored in the REFER headers
|
|
// eslint-disable-next-line no-unused-vars
|
|
const { via, from, to, 'call-id': callid, cseq, 'max-forwards': maxforwards,
|
|
// eslint-disable-next-line no-unused-vars
|
|
'content-length': _contentlength, 'refer-to': _referto, 'referred-by': referredby,
|
|
// eslint-disable-next-line no-unused-vars
|
|
'X-Refer-To-Leave-Untouched': _leave, contact: _contact,
|
|
...customHeaders
|
|
} = req.headers;
|
|
|
|
const referTo = req.getParsedHeader('Refer-To');
|
|
const uri = parseUri(referTo.uri);
|
|
this.logger.info({uri, referTo}, 'received REFER from feature server');
|
|
const arr = /context-(.*)/.exec(uri.user);
|
|
if (!arr) {
|
|
/* call transfer requested */
|
|
if (!req.has('Referred-By')) return res.send(400);
|
|
|
|
/* delete contact if it was there from feature server */
|
|
delete customHeaders['contact'];
|
|
|
|
const response = await this.uac.request({
|
|
method: 'REFER',
|
|
headers: {
|
|
'Refer-To': `<${stringifyUri(uri)}>`,
|
|
'Referred-By': referredby,
|
|
'Contact': this.contactHeader,
|
|
...customHeaders
|
|
}
|
|
});
|
|
return res.send(response.status);
|
|
}
|
|
res.send(202);
|
|
|
|
// invite to new fs
|
|
const headers = {
|
|
...(req.has('X-Retain-Call-Sid') && {'X-Retain-Call-Sid': req.get('X-Retain-Call-Sid')}),
|
|
...(req.has('X-Account-Sid') && {'X-Account-Sid': req.get('X-Account-Sid')})
|
|
};
|
|
const uac = await this.srf.createUAC(referTo.uri, {localSdp: dlg.local.sdp, headers});
|
|
this.uas = uac;
|
|
uac.type = 'uas';
|
|
uac.other = this.uac;
|
|
this.uac.other = uac;
|
|
uac.on('info', this._onInfo.bind(this, uac));
|
|
uac.on('modify', this._onReinvite.bind(this, uac));
|
|
uac.on('refer', this._onFeatureServerTransfer.bind(this, uac));
|
|
uac.on('destroy', async() => {
|
|
this.logger.info('call ended with normal termination');
|
|
this.rtpEngineResource.destroy();
|
|
this.activeCallIds.delete(this.req.get('Call-ID'));
|
|
if (this.activeCallIds.size === 0) this.idleEmitter.emit('idle');
|
|
uac.other.destroy();
|
|
this._stopRecording();
|
|
if (this.req.locals.cdr) {
|
|
const now = Date.now();
|
|
const day = new Date();
|
|
// eslint-disable-next-line max-len
|
|
const {hasRecording = false} = await this.retrieveHash(`call:${this.account_sid}:${this.req.locals.cdr.call_sid}`) ?? {} ;
|
|
const recordAllCalls = this.req.locals.record_all_calls || hasRecording;
|
|
const record_format = this.req.locals.account.record_format || 'mp3';
|
|
let recording_url = `/Accounts/${this.account_sid}/RecentCalls/${this.req.locals.cdr.call_sid}/record`;
|
|
recording_url += `/${day.getFullYear()}/${(day.getMonth() + 1).toString().padStart(2, '0')}`;
|
|
recording_url += `/${day.getDate().toString().padStart(2, '0')}/${record_format}`;
|
|
this.writeCdrs({...this.req.locals.cdr,
|
|
terminated_at: now,
|
|
termination_reason: 'caller hungup',
|
|
sip_status: 200,
|
|
answered: true,
|
|
duration: Math.floor((now - this.req.locals.cdr.answered_at) / 1000),
|
|
...(recordAllCalls && {recording_url})
|
|
}).catch((err) => this.logger.error({err}, 'Error writing cdr for completed call'));
|
|
}
|
|
this.srf.endSession(this.req);
|
|
});
|
|
|
|
const opts = {
|
|
...this.rtpEngineOpts.common,
|
|
'from-tag': this.rtpEngineOpts.uas.tag,
|
|
sdp: uac.remote.sdp,
|
|
flags: ['port latching']
|
|
};
|
|
const response = await this.offer(opts);
|
|
if ('ok' !== response.result) {
|
|
throw new Error(`_onFeatureServerTransfer: rtpengine offer failed: ${JSON.stringify(response)}`);
|
|
}
|
|
dlg.destroy().catch(() => {});
|
|
this.logger.info('successfully moved call to new feature server');
|
|
} catch (err) {
|
|
res.send(488);
|
|
this.logger.error(err, 'Error handling refer from feature server');
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = CallSession;
|