mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2025-12-19 04:17:44 +00:00
* fixed dial transcribe is not able to received final transcribe when close call. * wip * fix review comment * support call session delay detroy ep when current task is transcribe * wip * wip * fixed review comments * fixed review comments
1092 lines
39 KiB
JavaScript
1092 lines
39 KiB
JavaScript
const Task = require('./task');
|
|
const makeTask = require('./make_task');
|
|
const {
|
|
CallStatus,
|
|
CallDirection,
|
|
TaskName,
|
|
TaskPreconditions,
|
|
MAX_SIMRINGS,
|
|
MediaPath,
|
|
KillReason
|
|
} = require('../utils/constants');
|
|
const assert = require('assert');
|
|
const placeCall = require('../utils/place-outdial');
|
|
const sessionTracker = require('../session/session-tracker');
|
|
const DtmfCollector = require('../utils/dtmf-collector');
|
|
const ConfirmCallSession = require('../session/confirm-call-session');
|
|
const dbUtils = require('../utils/db-utils');
|
|
const parseDecibels = require('../utils/parse-decibels');
|
|
const debug = require('debug')('jambonz:feature-server');
|
|
const {parseUri} = require('drachtio-srf');
|
|
const {ANCHOR_MEDIA_ALWAYS,
|
|
JAMBONZ_DISABLE_DIAL_PAI_HEADER,
|
|
JAMBONES_DIAL_SBC_FOR_REGISTERED_USER} = require('../config');
|
|
const { isOnhold, isOpusFirst } = require('../utils/sdp-utils');
|
|
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
|
const { selectHostPort } = require('../utils/network');
|
|
const { sleepFor } = require('../utils/helpers');
|
|
|
|
function parseDtmfOptions(logger, dtmfCapture) {
|
|
let parentDtmfCollector, childDtmfCollector;
|
|
const parentKeys = [], childKeys = [];
|
|
|
|
if (Array.isArray(dtmfCapture)) {
|
|
Array.prototype.push.apply(parentKeys, dtmfCapture);
|
|
Array.prototype.push.apply(childKeys, dtmfCapture);
|
|
}
|
|
else if (dtmfCapture.childCall || dtmfCapture.parentCall) {
|
|
if (dtmfCapture.childCall && Array.isArray(dtmfCapture.childCall)) {
|
|
Array.prototype.push.apply(childKeys, dtmfCapture.childCall);
|
|
}
|
|
if (dtmfCapture.parentCall && Array.isArray(dtmfCapture.parentCall)) {
|
|
Array.prototype.push.apply(childKeys, dtmfCapture.parentCall);
|
|
}
|
|
}
|
|
if (childKeys.length) {
|
|
childDtmfCollector = new DtmfCollector({logger, patterns: childKeys});
|
|
}
|
|
if (parentKeys.length) {
|
|
parentDtmfCollector = new DtmfCollector({logger, patterns: parentKeys});
|
|
}
|
|
|
|
return {childDtmfCollector, parentDtmfCollector};
|
|
}
|
|
|
|
function compareTasks(t1, t2) {
|
|
if (t1.type !== t2.type) return false;
|
|
switch (t1.type) {
|
|
case 'phone':
|
|
return t1.number === t2.number;
|
|
case 'user':
|
|
return t1.name === t2.name;
|
|
case 'teams':
|
|
return t1.number === t2.number;
|
|
case 'sip':
|
|
return t1.sipUri === t2.sipUri;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Allow at most 10 targets and eliminate duplicates
|
|
*/
|
|
function filterAndLimit(logger, tasks) {
|
|
assert(Array.isArray(tasks));
|
|
const unique = tasks.reduce((acc, t) => {
|
|
if (acc.find((el) => compareTasks(el, t))) return acc;
|
|
return [...acc, t];
|
|
}, []);
|
|
|
|
if (unique.length !== tasks.length) {
|
|
logger.info(`filterAndLimit: removed ${tasks.length - unique.length} duplicate dial targets`);
|
|
}
|
|
|
|
if (unique.length > MAX_SIMRINGS) {
|
|
logger.info(`filterAndLimit: max number of targets exceeded: ${unique.length}; first ${MAX_SIMRINGS} will be used`);
|
|
unique.length = MAX_SIMRINGS;
|
|
}
|
|
return unique;
|
|
}
|
|
|
|
class TaskDial extends Task {
|
|
constructor(logger, opts) {
|
|
super(logger, opts);
|
|
this.preconditions = TaskPreconditions.None;
|
|
|
|
this.earlyMedia = this.data.answerOnBridge === true;
|
|
this.callerId = this.data.callerId;
|
|
this.callerName = this.data.callerName;
|
|
this.dialMusic = this.data.dialMusic;
|
|
this.headers = this.data.headers || {};
|
|
this.method = this.data.method || 'POST';
|
|
this.target = filterAndLimit(this.logger, this.data.target);
|
|
this.timeout = this.data.timeout || 60;
|
|
this.timeLimit = this.data.timeLimit;
|
|
this.confirmHook = this.data.confirmHook;
|
|
this.confirmMethod = this.data.confirmMethod;
|
|
this.referHook = this.data.referHook;
|
|
this.dtmfHook = this.data.dtmfHook;
|
|
this.proxy = this.data.proxy;
|
|
this.tag = this.data.tag;
|
|
this.boostAudioSignal = this.data.boostAudioSignal;
|
|
this._mediaPath = MediaPath.FullMedia;
|
|
|
|
if (this.dtmfHook) {
|
|
const {parentDtmfCollector, childDtmfCollector} = parseDtmfOptions(logger, this.data.dtmfCapture || {});
|
|
if (parentDtmfCollector) {
|
|
this.parentDtmfCollector = parentDtmfCollector;
|
|
}
|
|
if (childDtmfCollector) {
|
|
this.childDtmfCollector = childDtmfCollector;
|
|
}
|
|
}
|
|
|
|
const listenData = this.data.listen || this.data.stream;
|
|
if (listenData) {
|
|
this.listenTask = makeTask(logger, {'listen': listenData }, this);
|
|
}
|
|
if (this.data.transcribe) {
|
|
this.transcribeTask = makeTask(logger, {'transcribe' : this.data.transcribe}, this);
|
|
}
|
|
if (this.data.dub && Array.isArray(this.data.dub) && this.data.dub.length > 0) {
|
|
this.dubTasks = this.data.dub.map((d) => makeTask(logger, {'dub': d}, this));
|
|
}
|
|
|
|
this.results = {};
|
|
this.bridged = false;
|
|
this.dials = new Map();
|
|
}
|
|
|
|
get dlg() {
|
|
if (this.sd) return this.sd.dlg;
|
|
}
|
|
|
|
get ep() {
|
|
/**
|
|
* Note:
|
|
* this.ep is the B leg-facing endpoint
|
|
* this.epOther is the A leg-facing endpoint
|
|
*/
|
|
if (this.sd) return this.sd.ep;
|
|
}
|
|
|
|
get name() { return TaskName.Dial; }
|
|
|
|
get isOnHoldEnabled() {
|
|
return !!this.data.onHoldHook;
|
|
}
|
|
|
|
get canReleaseMedia() {
|
|
const keepAnchor = this.data.anchorMedia ||
|
|
this.cs.isBackGroundListen ||
|
|
this.cs.onHoldMusic ||
|
|
ANCHOR_MEDIA_ALWAYS ||
|
|
this.listenTask ||
|
|
this.dubTasks ||
|
|
this.transcribeTask ||
|
|
this.startAmd;
|
|
|
|
return !keepAnchor;
|
|
}
|
|
|
|
get shouldExitMediaPathEntirely() {
|
|
return this.data.exitMediaPath;
|
|
}
|
|
|
|
get summary() {
|
|
if (this.target.length === 1) {
|
|
const target = this.target[0];
|
|
switch (target.type) {
|
|
case 'phone':
|
|
case 'teams':
|
|
return `${this.name}{type=${target.type},number=${target.number}}`;
|
|
case 'user':
|
|
return `${this.name}{type=${target.type},name=${target.name}}`;
|
|
case 'sip':
|
|
return `${this.name}{type=${target.type},sipUri=${target.sipUri}}`;
|
|
default:
|
|
return `${this.name}`;
|
|
}
|
|
}
|
|
else return `${this.name}{${this.target.length} targets}`;
|
|
}
|
|
|
|
async exec(cs) {
|
|
await super.exec(cs);
|
|
|
|
if (this.data.anchorMedia && this.data.exitMediaPath) {
|
|
this.logger.info('Dial:exec - incompatible anchorMedia and exitMediaPath are both set, will obey anchorMedia');
|
|
delete this.data.exitMediaPath;
|
|
}
|
|
if (!this.canReleaseMedia && this.data.exitMediaPath) {
|
|
this.logger.info(
|
|
'Dial:exec - exitMediaPath is set so features such as transcribe and record will not work on this call');
|
|
}
|
|
|
|
try {
|
|
if (this.listenTask) {
|
|
const {span, ctx} = this.startChildSpan(`nested:${this.listenTask.summary}`);
|
|
this.listenTask.span = span;
|
|
this.listenTask.ctx = ctx;
|
|
}
|
|
if (this.transcribeTask) {
|
|
const {span, ctx} = this.startChildSpan(`nested:${this.transcribeTask.summary}`);
|
|
this.transcribeTask.span = span;
|
|
this.transcribeTask.ctx = ctx;
|
|
}
|
|
if (this.data.amd) {
|
|
this.startAmd = cs.startAmd;
|
|
this.stopAmd = cs.stopAmd;
|
|
this.on('amd', this._onAmdEvent.bind(this, cs));
|
|
}
|
|
if (cs.direction === CallDirection.Inbound) {
|
|
await this._initializeInbound(cs);
|
|
}
|
|
else {
|
|
this.epOther = cs.ep;
|
|
if (this.dialMusic && this.epOther && this.epOther.connected) {
|
|
(async() => {
|
|
do {
|
|
try {
|
|
await this.epOther.play(this.dialMusic);
|
|
} catch (err) {
|
|
this.logger.error(err, `TaskDial:exec error playing dialMusic ${this.dialMusic}`);
|
|
await sleepFor(1000);
|
|
}
|
|
} while (!this.killed && !this.bridged && this._mediaPath === MediaPath.FullMedia);
|
|
})();
|
|
}
|
|
}
|
|
if (!this.killed) await this._attemptCalls(cs);
|
|
await this.awaitTaskDone();
|
|
this.logger.debug({callSid: this.cs.callSid}, 'Dial:exec task is done, sending actionHook if any');
|
|
await this.performAction(this.results, this.killReason !== KillReason.Replaced);
|
|
this._removeDtmfDetection(cs.dlg);
|
|
this._removeDtmfDetection(this.dlg);
|
|
this._removeSipIndialogRequestListener(this.dlg);
|
|
} catch (err) {
|
|
this.logger.error({err}, 'TaskDial:exec terminating with error');
|
|
this.kill(cs);
|
|
}
|
|
}
|
|
|
|
async kill(cs, reason) {
|
|
super.kill(cs);
|
|
try {
|
|
if (this.ep && this.ep.amd) this.stopAmd(this.ep, this);
|
|
} catch (err) {
|
|
this.logger.error({err}, 'DialTask:kill - error stopping answering machine detectin');
|
|
}
|
|
if (this.dialMusic && this.epOther) {
|
|
this.epOther.api('uuid_break', this.epOther.uuid)
|
|
.catch((err) => this.logger.info(err, 'Error killing dialMusic'));
|
|
}
|
|
this.killReason = reason || KillReason.Hangup;
|
|
if (this.timerMaxCallDuration) {
|
|
clearTimeout(this.timerMaxCallDuration);
|
|
this.timerMaxCallDuration = null;
|
|
}
|
|
if (this.timerRing) {
|
|
clearTimeout(this.timerRing);
|
|
this.timerRing = null;
|
|
}
|
|
this._removeDtmfDetection(cs.dlg);
|
|
this._removeDtmfDetection(this.dlg);
|
|
await this._killOutdials();
|
|
if (this.sd) {
|
|
const byeReasonHeader = this.killReason === KillReason.MediaTimeout ? 'Media Timeout' : undefined;
|
|
this.sd.kill(byeReasonHeader);
|
|
this.sd.ep?.removeListener('destroy', this._handleMediaTimeout.bind(this));
|
|
this.sd.removeAllListeners();
|
|
this.sd = null;
|
|
}
|
|
if (this.callSid) sessionTracker.remove(this.callSid);
|
|
if (this.listenTask) {
|
|
await this.listenTask.kill(cs);
|
|
this.listenTask.span.end();
|
|
this.listenTask = null;
|
|
}
|
|
if (this.transcribeTask) {
|
|
await this.transcribeTask.kill(cs);
|
|
this.transcribeTask.span.end();
|
|
this.transcribeTask = null;
|
|
}
|
|
this.notifyTaskDone();
|
|
}
|
|
|
|
/**
|
|
* whisper a prompt to one side of the call
|
|
* @param {*} tasks - array of play/say tasks to execute
|
|
*/
|
|
async whisper(tasks, callSid) {
|
|
try {
|
|
const cs = this.callSession;
|
|
if (!this.ep && !this.epOther) {
|
|
await this.reAnchorMedia(this.callSession, this.sd);
|
|
}
|
|
|
|
if (!this.epOther || !this.ep) return this.logger.info('Dial:whisper: no paired endpoint found');
|
|
|
|
this.logger.debug('Dial:whisper unbridging endpoints');
|
|
await this.epOther.unbridge();
|
|
this.logger.debug('Dial:whisper executing tasks');
|
|
while (tasks.length && !cs.callGone) {
|
|
const task = tasks.shift();
|
|
const {span, ctx} = this.startChildSpan(`whisper:${task.summary}`);
|
|
task.span = span;
|
|
task.ctx = ctx;
|
|
await task.exec(cs, callSid === this.callSid ? {ep: this.ep} : {ep: this.epOther});
|
|
span.end();
|
|
}
|
|
this.logger.debug('Dial:whisper tasks complete');
|
|
if (!cs.callGone && this.epOther) {
|
|
|
|
/* if we can release the media back to the SBC, do so now */
|
|
if (this.canReleaseMedia) this._releaseMedia(cs, this.sd, this.shouldExitMediaPathEntirely);
|
|
else this.epOther.bridge(this.ep);
|
|
}
|
|
} catch (err) {
|
|
this.logger.error(err, 'Dial:whisper error');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* mute or unmute one side of the call
|
|
*/
|
|
async mute(callSid, doMute) {
|
|
const parentCall = callSid !== this.callSid;
|
|
const dlg = parentCall ? this.callSession.dlg : this.dlg;
|
|
const hdr = `${doMute ? 'mute' : 'unmute'} call leg`;
|
|
try {
|
|
/* let rtpengine do the mute / unmute */
|
|
await dlg.request({
|
|
method: 'INFO',
|
|
headers: {
|
|
'X-Reason': hdr
|
|
}
|
|
});
|
|
} catch (err) {
|
|
this.logger.info({err}, `Dial:mute - ${hdr} error`);
|
|
}
|
|
}
|
|
|
|
async handleRefer(cs, req, res, callInfo = cs.callInfo) {
|
|
if (this.referHook) {
|
|
try {
|
|
const isChild = !!callInfo.parentCallSid;
|
|
const referring_call_sid = isChild ? callInfo.callSid : cs.callSid;
|
|
const referred_call_sid = isChild ? callInfo.parentCallSid : this.sd.callSid;
|
|
|
|
const b3 = this.getTracingPropagation();
|
|
const httpHeaders = b3 && {b3};
|
|
|
|
const to = parseUri(req.getParsedHeader('Refer-To').uri);
|
|
const by = parseUri(req.getParsedHeader('Referred-By').uri);
|
|
const referredBy = req.get('Referred-By');
|
|
const userAgent = req.get('User-Agent');
|
|
const customHeaders = Object.keys(req.headers)
|
|
.filter((h) => h.toLowerCase().startsWith('x-'))
|
|
.reduce((acc, h) => {
|
|
acc[h] = req.get(h);
|
|
return acc;
|
|
}, {});
|
|
this.logger.info({to}, 'refer to parsed');
|
|
const json = await cs.requestor.request('verb:hook', this.referHook, {
|
|
...(callInfo.toJSON()),
|
|
refer_details: {
|
|
sip_refer_to: req.get('Refer-To'),
|
|
refer_to_user: to.scheme === 'tel' ? to.number : to.user,
|
|
...(referredBy && {sip_referred_by: referredBy}),
|
|
...(userAgent && {sip_user_agent: userAgent}),
|
|
...(by && {referred_by_user: by.scheme === 'tel' ? by.number : by.user}),
|
|
referring_call_sid,
|
|
referred_call_sid,
|
|
...customHeaders
|
|
}
|
|
}, httpHeaders);
|
|
if (json && Array.isArray(json)) {
|
|
try {
|
|
const logger = isChild ? this.logger : this.sd.logger;
|
|
const tasks = normalizeJambones(logger, json).map((tdata) => makeTask(this.logger, tdata));
|
|
if (tasks && tasks.length > 0) {
|
|
const legs = isChild ? ['child', 'parent'] : ['parent', 'child'];
|
|
logger.info(`Dial:handleRefer received REFER on ${legs[0]} leg, setting new app on ${legs[1]} leg`);
|
|
if (isChild) this.redirect(cs, tasks);
|
|
else {
|
|
logger.info({tasks: json}, 'Dial:handleRefer - new application for for child leg');
|
|
const adultingSession = await this.sd.doAdulting({
|
|
logger,
|
|
application: cs.application,
|
|
tasks
|
|
});
|
|
/* need to update the callSid of the child with its own (new) AdultingCallSession */
|
|
sessionTracker.add(adultingSession.callSid, adultingSession);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
this.logger.info(err, 'Dial:handleRefer - error setting new application after receiving REFER');
|
|
}
|
|
}
|
|
//caller and callee legs are briged together, accept refer with 202 will release callee leg endpoint
|
|
//that makes freeswitch release endpoint for caller leg.
|
|
if (this.ep) this.ep.unbridge();
|
|
res.send(202);
|
|
this.logger.info('DialTask:handleRefer - sent 202 Accepted');
|
|
} catch (err) {
|
|
this.logger.info({err}, 'DialTask:handleRefer - error processing incoming REFER');
|
|
res.send(err.statusCode || 501);
|
|
}
|
|
}
|
|
else {
|
|
this.logger.info('DialTask:handleRefer - got REFER but no referHook, responding 501');
|
|
res.send(501);
|
|
}
|
|
}
|
|
|
|
_removeHandlers(sd) {
|
|
sd.removeAllListeners('accept');
|
|
sd.removeAllListeners('decline');
|
|
sd.removeAllListeners('adulting');
|
|
sd.removeAllListeners('callStatusChange');
|
|
sd.removeAllListeners('callCreateFail');
|
|
}
|
|
|
|
async _killOutdials() {
|
|
for (const [callSid, sd] of Array.from(this.dials)) {
|
|
this.logger.debug(`Dial:_killOutdials killing callSid ${callSid}`);
|
|
try {
|
|
await sd.kill();
|
|
} catch (err) {
|
|
this.logger.info(err, `Dial:_killOutdials Error killing ${callSid}`);
|
|
}
|
|
this._removeHandlers(sd);
|
|
this.logger.debug(`Dial:_killOutdials killed callSid ${callSid}`);
|
|
}
|
|
this.dials.clear();
|
|
}
|
|
|
|
_installDtmfDetection(cs, dlg) {
|
|
dlg.on('info', this._onInfo.bind(this, cs, dlg));
|
|
}
|
|
_removeDtmfDetection(dlg) {
|
|
dlg && dlg.removeAllListeners('info');
|
|
}
|
|
|
|
_onInfo(cs, dlg, req, res) {
|
|
// SIP Indialog will be handled by another handler
|
|
if (cs.sipRequestWithinDialogHook) {
|
|
return;
|
|
}
|
|
res.send(200);
|
|
if (req.get('Content-Type') !== 'application/dtmf-relay') {
|
|
return;
|
|
}
|
|
|
|
const dtmfDetector = dlg === cs.dlg ? this.parentDtmfCollector : this.childDtmfCollector;
|
|
if (!dtmfDetector) return;
|
|
let requestor, callSid, callInfo;
|
|
if (dtmfDetector === this.parentDtmfCollector) {
|
|
requestor = cs.requestor;
|
|
callSid = cs.callSid;
|
|
callInfo = cs.callInfo;
|
|
}
|
|
else {
|
|
requestor = this.sd?.requestor;
|
|
callSid = this.sd?.callSid;
|
|
callInfo = this.sd?.callInfo;
|
|
}
|
|
if (!requestor) return;
|
|
const arr = /Signal=([0-9#*])/.exec(req.body);
|
|
if (!arr) return;
|
|
const key = arr[1];
|
|
const match = dtmfDetector.keyPress(key);
|
|
if (match) {
|
|
const b3 = this.getTracingPropagation();
|
|
const httpHeaders = b3 && {b3};
|
|
this.logger.info({callSid}, `Dial:_onInfo triggered dtmf match: ${match}`);
|
|
requestor.request('verb:hook', this.dtmfHook, {dtmf: match, ...callInfo.toJSON()}, httpHeaders)
|
|
.catch((err) => this.logger.info(err, 'Dial:_onDtmf - error'));
|
|
}
|
|
}
|
|
|
|
_initSipIndialogRequestListener(cs, dlg) {
|
|
dlg.on('info', this._onRequestWithinDialog.bind(this, cs));
|
|
dlg.on('message', this._onRequestWithinDialog.bind(this, cs));
|
|
}
|
|
|
|
_removeSipIndialogRequestListener(dlg) {
|
|
dlg && dlg.removeAllListeners('message');
|
|
dlg && dlg.removeAllListeners('info');
|
|
}
|
|
|
|
_onRequestWithinDialog(cs, req, res) {
|
|
cs._onRequestWithinDialog(req, res);
|
|
}
|
|
|
|
async _initializeInbound(cs) {
|
|
const {ep} = await cs._evalEndpointPrecondition(this);
|
|
this.epOther = ep;
|
|
|
|
/* send outbound legs back to the same SBC (to support static IP feature) */
|
|
if (!this.proxy) this.proxy = `${cs.req.source_address}:${cs.req.source_port}`;
|
|
|
|
if (this.dialMusic) {
|
|
// play dial music to caller while we outdial
|
|
ep.play(this.dialMusic).catch((err) => {
|
|
this.logger.error(err, `TaskDial:_initializeInbound - error playing ${this.dialMusic}`);
|
|
});
|
|
}
|
|
}
|
|
|
|
async _attemptCalls(cs) {
|
|
const {req, callInfo, direction, srf} = cs;
|
|
const {getSBC} = srf.locals;
|
|
const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers;
|
|
const {lookupCarrier, lookupCarrierByPhoneNumber, lookupVoipCarrierBySid} = dbUtils(this.logger, cs.srf);
|
|
let sbcAddress = this.proxy || getSBC();
|
|
const teamsInfo = {};
|
|
let fqdn;
|
|
|
|
if (!sbcAddress) throw new Error('no SBC found for outbound call');
|
|
this.headers = {
|
|
'X-Account-Sid': cs.accountSid,
|
|
...(req && req.has('X-CID') && {'X-CID': req.get('X-CID')}),
|
|
...(direction === 'outbound' && callInfo.sbcCallid && {'X-CID': callInfo.sbcCallid}),
|
|
...(!JAMBONZ_DISABLE_DIAL_PAI_HEADER && req && {
|
|
...(req.has('P-Asserted-Identity') && {'P-Asserted-Identity': req.get('P-Asserted-Identity')}),
|
|
...(req.has('Privacy') && {'Privacy': req.get('Privacy')}),
|
|
}),
|
|
...(req && req.has('X-Voip-Carrier-Sid') && {'X-Voip-Carrier-Sid': req.get('X-Voip-Carrier-Sid')}),
|
|
// Put headers at the end to make sure opt.headers override all default behavior.
|
|
...this.headers
|
|
};
|
|
|
|
// default to inband dtmf if not specified
|
|
this.inbandDtmfEnabled = cs.inbandDtmfEnabled;
|
|
// get calling user from From header
|
|
const parsedFrom = req.getParsedHeader('from');
|
|
const fromUri = parseUri(parsedFrom.uri);
|
|
const opts = {
|
|
headers: this.headers,
|
|
proxy: `sip:${sbcAddress}`,
|
|
callingNumber: this.callerId || fromUri.user,
|
|
...(this.callerName && {callingName: this.callerName}),
|
|
opusFirst: isOpusFirst(this.cs.ep.remote.sdp)
|
|
};
|
|
|
|
const t = this.target.find((t) => t.type === 'teams');
|
|
if (t) {
|
|
const obj = await lookupTeamsByAccount(cs.accountSid);
|
|
if (!obj) throw new Error('dial to ms teams not allowed; account must first be configured with teams info');
|
|
Object.assign(teamsInfo, {tenant_fqdn: t.tenant || obj.tenant_fqdn, ms_teams_fqdn: obj.ms_teams_fqdn});
|
|
}
|
|
|
|
const ms = await cs.getMS();
|
|
this.timerRing = setTimeout(async() => {
|
|
this.logger.info(`Dial:_attemptCall: ring no answer timer ${this.timeout}s exceeded`);
|
|
this.timerRing = null;
|
|
try {
|
|
await this._killOutdials();
|
|
} catch (err) {
|
|
this.logger.info(err, 'Dial:_attemptCall - error killing outdials');
|
|
}
|
|
this.result = {
|
|
dialCallStatus: CallStatus.NoAnswer,
|
|
dialSipStatus: 487
|
|
};
|
|
this.kill(cs);
|
|
}, this.timeout * 1000);
|
|
|
|
this.span.setAttributes({'dial.target': JSON.stringify(this.target)});
|
|
this.target.forEach(async(t) => {
|
|
try {
|
|
t.confirmHook = t.confirmHook || this.confirmHook;
|
|
//t.method = t.method || this.confirmMethod || 'POST';
|
|
if (t.type === 'teams') t.teamsInfo = teamsInfo;
|
|
if (t.type === 'user' && !t.name.includes('@') && !fqdn) {
|
|
const user = t.name;
|
|
try {
|
|
const {sip_realm} = await lookupAccountBySid(cs.accountSid);
|
|
if (sip_realm) {
|
|
t.name = `${user}@${sip_realm}`;
|
|
this.logger.debug(`appending sip realm ${sip_realm} to dial target user ${user}`);
|
|
}
|
|
} catch (err) {
|
|
this.logger.error({err}, 'Error looking up account by sid');
|
|
}
|
|
}
|
|
// find handling sbc sip for called user
|
|
if (JAMBONES_DIAL_SBC_FOR_REGISTERED_USER && t.type === 'user') {
|
|
const { registrar } = srf.locals.dbHelpers;
|
|
const reg = await registrar.query(t.name);
|
|
if (reg) {
|
|
sbcAddress = selectHostPort(this.logger, reg.sbcAddress, 'tcp')[1];
|
|
}
|
|
//sbc outbound return 404 Notfound to handle case called user is not reigstered.
|
|
}
|
|
if (t.type === 'phone' && t.trunk) {
|
|
const voip_carrier_sid = await lookupCarrier(cs.accountSid, t.trunk);
|
|
this.logger.info(`Dial:_attemptCalls: selected ${voip_carrier_sid} for requested carrier: ${t.trunk}`);
|
|
if (voip_carrier_sid) {
|
|
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* trunk isn't specified,
|
|
* check if number matches any existing numbers
|
|
* */
|
|
if (t.type === 'phone' && !t.trunk) {
|
|
const str = this.callerId || req.callingNumber || '';
|
|
const callingNumber = str.startsWith('+') ? str.substring(1) : str;
|
|
const voip_carrier_sid = await lookupCarrierByPhoneNumber(cs.accountSid, callingNumber);
|
|
const req_voip_carrier_sid = req.has('X-Voip-Carrier-Sid') ? req.get('X-Voip-Carrier-Sid') : null;
|
|
if (voip_carrier_sid) {
|
|
this.logger.info(
|
|
`Dial:_attemptCalls: selected voip_carrier_sid ${voip_carrier_sid} for callingNumber: ${callingNumber}`);
|
|
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
|
|
|
|
// Checking if outbound carrier is different from inbound carrier and has dtmf type tones
|
|
if (voip_carrier_sid !== req_voip_carrier_sid) {
|
|
const [voipCarrier] = await lookupVoipCarrierBySid(voip_carrier_sid);
|
|
this.inbandDtmfEnabled = voipCarrier?.dtmf_type === 'tones';
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.killed) return;
|
|
|
|
const sd = placeCall({
|
|
logger: this.logger,
|
|
application: cs.application,
|
|
srf,
|
|
ms,
|
|
sbcAddress,
|
|
target: t,
|
|
opts,
|
|
callInfo: cs.callInfo,
|
|
accountInfo: cs.accountInfo,
|
|
rootSpan: cs.rootSpan,
|
|
startSpan: this.startSpan.bind(this),
|
|
dialTask: this,
|
|
onHoldMusic: this.cs.onHoldMusic
|
|
});
|
|
this.dials.set(sd.callSid, sd);
|
|
|
|
sd
|
|
.on('refer', (callInfo, req, res) => this.handleRefer(cs, req, res, callInfo))
|
|
.on('callCreateFail', () => {
|
|
clearTimeout(this.timerRing);
|
|
this.dials.delete(sd.callSid);
|
|
sd.removeAllListeners();
|
|
if (this.dials.size === 0 && !this.sd) {
|
|
this.logger.debug('Dial:_attemptCalls - all calls failed after call create err, ending task');
|
|
this.kill(cs);
|
|
}
|
|
})
|
|
.on('callStatusChange', (obj) => {
|
|
if (this.results.dialCallStatus !== CallStatus.Completed &&
|
|
this.results.dialCallStatus !== CallStatus.NoAnswer) {
|
|
Object.assign(this.results, {
|
|
dialCallStatus: obj.callStatus,
|
|
dialSipStatus: obj.sipStatus,
|
|
dialCallSid: sd.callSid,
|
|
dialSbcCallid: sd.callInfo.sbcCallid
|
|
});
|
|
}
|
|
switch (obj.callStatus) {
|
|
case CallStatus.Trying:
|
|
break;
|
|
case CallStatus.EarlyMedia:
|
|
if (this.target.length === 1 && !this.target[0].url && !this.dialMusic) {
|
|
this._bridgeEarlyMedia(sd);
|
|
}
|
|
break;
|
|
case CallStatus.InProgress:
|
|
this.logger.debug('Dial:_attemptCall -- call was answered');
|
|
clearTimeout(this.timerRing);
|
|
this.timerRing = null;
|
|
break;
|
|
case CallStatus.Failed:
|
|
case CallStatus.Busy:
|
|
case CallStatus.NoAnswer:
|
|
this.dials.delete(sd.callSid);
|
|
if (this.dials.size === 0 && !this.sd) {
|
|
this.logger.debug('Dial:_attemptCalls - all calls failed after call failure, ending task');
|
|
clearTimeout(this.timerRing);
|
|
this.timerRing = null;
|
|
this.kill(cs);
|
|
}
|
|
break;
|
|
}
|
|
})
|
|
.on('accept', async() => {
|
|
this.logger.debug(`Dial:_attemptCalls - we have a winner: ${sd.callSid}`);
|
|
clearTimeout(this.timerRing);
|
|
try {
|
|
await this._connectSingleDial(cs, sd);
|
|
} catch (err) {
|
|
this.logger.info({err}, 'Dial:_attemptCalls - Error calling _connectSingleDial ');
|
|
sd.removeAllListeners();
|
|
this.kill(cs);
|
|
}
|
|
})
|
|
.on('decline', () => {
|
|
this.logger.debug(`Dial:_attemptCalls - declined: ${sd.callSid}`);
|
|
clearTimeout(this.timerRing);
|
|
this.dials.delete(sd.callSid);
|
|
sd.removeAllListeners();
|
|
if (this.dials.size === 0 && !this.sd) {
|
|
this.logger.debug('Dial:_attemptCalls - all calls failed after decline, ending task');
|
|
this.kill(cs);
|
|
}
|
|
})
|
|
.on('reinvite', (req, res) => {
|
|
this._onReinvite(req, res);
|
|
})
|
|
.on('refer', (callInfo, req, res) => {
|
|
|
|
})
|
|
.once('adulting', () => {
|
|
/* child call just adulted and got its own session */
|
|
this.logger.info('Dial:on_adulting: detaching child call leg');
|
|
if (this.ep) {
|
|
this.logger.debug(`Dial:on_adulting: removing dtmf from ${this.ep.uuid}`);
|
|
this.ep.removeAllListeners('dtmf');
|
|
}
|
|
this.sd = null;
|
|
this.callSid = null;
|
|
});
|
|
} catch (err) {
|
|
this.logger.error(err, 'Dial:_attemptCalls');
|
|
}
|
|
});
|
|
}
|
|
|
|
async _connectSingleDial(cs, sd) {
|
|
if (!this.bridged && !this.canReleaseMedia) {
|
|
this.logger.debug('Dial:_connectSingleDial bridging endpoints');
|
|
if (this.epOther) {
|
|
this.epOther.api('uuid_break', this.epOther.uuid);
|
|
this.epOther.bridge(sd.ep);
|
|
}
|
|
this.bridged = true;
|
|
}
|
|
|
|
// ding! ding! ding! we have a winner
|
|
await this._selectSingleDial(cs, sd);
|
|
this._killOutdials(); // NB: order is important
|
|
}
|
|
|
|
async _onReinvite(req, res) {
|
|
try {
|
|
let isHandled = false;
|
|
if (this.isOnHoldEnabled) {
|
|
if (isOnhold(req.body)) {
|
|
this.logger.debug('Dial: _onReinvite receive hold Request');
|
|
if (!this.epOther && !this.ep) {
|
|
this.logger.debug(`Dial: _onReinvite receive hold Request,
|
|
media already released, reconnect media server`);
|
|
// update caller leg for new SDP from callee.
|
|
await this.cs.handleReinviteAfterMediaReleased(req, res);
|
|
// Freeswitch media is released, reconnect
|
|
await this.reAnchorMedia(this.cs, this.sd);
|
|
this.isOutgoingLegHold = true;
|
|
} else {
|
|
this.logger.debug('Dial: _onReinvite receive hold Request, update SDP');
|
|
const newSdp = await this.ep.modify(req.body);
|
|
res.send(200, {body: newSdp});
|
|
}
|
|
isHandled = true;
|
|
// Media already connected, ask for onHoldHook
|
|
this._onHoldHook(req);
|
|
} else if (!isOnhold(req.body)) {
|
|
this.logger.debug('Dial: _onReinvite receive unhold Request');
|
|
if (this.epOther && this.ep && this.isOutgoingLegHold && this.canReleaseMedia) {
|
|
this.logger.debug('Dial: _onReinvite receive unhold Request, release media');
|
|
// Offhold, time to release media
|
|
const newSdp = await this.ep.modify(req.body);
|
|
await res.send(200, {body: newSdp});
|
|
await this._releaseMedia(this.cs, this.sd, this.shouldExitMediaPathEntirely);
|
|
this.isOutgoingLegHold = false;
|
|
} else {
|
|
this.logger.debug('Dial: _onReinvite receive unhold Request, update media server');
|
|
const newSdp = await this.ep.modify(req.body);
|
|
res.send(200, {body: newSdp});
|
|
}
|
|
if (this._onHoldSession) {
|
|
this._onHoldSession.kill();
|
|
}
|
|
isHandled = true;
|
|
}
|
|
}
|
|
if (!isHandled) {
|
|
this.cs.handleReinviteAfterMediaReleased(req, res);
|
|
}
|
|
} catch (err) {
|
|
this.logger.error(err, 'Error in dial einvite from B leg');
|
|
}
|
|
}
|
|
|
|
_onMaxCallDuration(cs) {
|
|
this.logger.info(`Dial:_onMaxCallDuration tearing down call as it has reached ${this.timeLimit}s`);
|
|
this.ep && this.ep.unbridge();
|
|
this.kill(cs);
|
|
}
|
|
|
|
/**
|
|
* We now have a call leg produced by the Dial action, so
|
|
* - hangup any simrings in progress
|
|
* - save the dialog and endpoint
|
|
* - clock the start time of the call,
|
|
* - start a max call length timer (optionally)
|
|
* - start answering machine detection (optionally)
|
|
* - launch any nested tasks
|
|
* - and establish a handler to clean up if the called party hangs up
|
|
*/
|
|
async _selectSingleDial(cs, sd) {
|
|
debug(`Dial:_selectSingleDial ep for outbound call: ${sd.ep.uuid}`);
|
|
this.dials.delete(sd.callSid);
|
|
|
|
this.sd = sd;
|
|
this.callSid = sd.callSid;
|
|
if (this.earlyMedia) {
|
|
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
|
|
await cs.propagateAnswer();
|
|
}
|
|
if (this.timeLimit) {
|
|
this.timerMaxCallDuration = setTimeout(this._onMaxCallDuration.bind(this, cs), this.timeLimit * 1000);
|
|
}
|
|
sessionTracker.add(this.callSid, cs);
|
|
this.dlg.on('destroy', () => {
|
|
/* if our child is adulting, he's own his own now.. */
|
|
if (this.dlg) {
|
|
this.logger.debug('Dial:_selectSingleDial called party hungup, ending dial operation');
|
|
sessionTracker.remove(this.callSid);
|
|
if (this.timerMaxCallDuration) {
|
|
clearTimeout(this.timerMaxCallDuration);
|
|
this.timerMaxCallDuration = null;
|
|
}
|
|
this.ep && this.ep.unbridge();
|
|
this.kill(cs);
|
|
}
|
|
});
|
|
|
|
Object.assign(this.results, {
|
|
dialCallStatus: CallStatus.Completed,
|
|
dialSipStatus: 200,
|
|
dialCallSid: sd.callSid,
|
|
});
|
|
|
|
if (this.dubTasks) {
|
|
for (const dub of this.dubTasks) {
|
|
try {
|
|
await dub.exec(cs, {ep: sd.ep});
|
|
}
|
|
catch (err) {
|
|
this.logger.error({err}, 'Dial:_selectSingleDial - error executing dubTask');
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg);
|
|
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg);
|
|
if (cs.sipRequestWithinDialogHook) {
|
|
/* remove any existing listener to escape from duplicating events */
|
|
this._removeSipIndialogRequestListener(this.dlg);
|
|
this._initSipIndialogRequestListener(cs, this.dlg);
|
|
}
|
|
|
|
if (this.transcribeTask) this.transcribeTask.exec(cs, {ep: this.epOther, ep2:this.ep});
|
|
if (this.listenTask) this.listenTask.exec(cs, {ep: this.listenTask.channel === 2 ? this.ep : this.epOther});
|
|
if (this.startAmd) {
|
|
try {
|
|
this.startAmd(cs, this.ep, this, this.data.amd);
|
|
} catch (err) {
|
|
this.logger.info({err}, 'Dial:_selectSingleDial - Error calling startAmd');
|
|
}
|
|
}
|
|
|
|
/* boost audio signal if requested */
|
|
if (this.boostAudioSignal) {
|
|
try {
|
|
const db = parseDecibels(this.boostAudioSignal);
|
|
this.logger.info(`Dial: boosting audio signal by ${db} dB`);
|
|
const args = [this.ep.uuid, 'setGain', db];
|
|
await this.ep.api('uuid_dub', args);
|
|
} catch (err) {
|
|
this.logger.info({err}, 'Dial:_selectSingleDial - Error boosting audio signal');
|
|
}
|
|
}
|
|
|
|
/* if we can release the media back to the SBC, do so now */
|
|
if (this.canReleaseMedia || this.shouldExitMediaPathEntirely) {
|
|
setTimeout(this._releaseMedia.bind(this, cs, sd, this.shouldExitMediaPathEntirely), 200);
|
|
}
|
|
|
|
this.sd.ep.once('destroy', this._handleMediaTimeout.bind(this));
|
|
}
|
|
|
|
_handleMediaTimeout(evt) {
|
|
if (evt.reason === 'MEDIA_TIMEOUT' && this.sd && this.bridged) {
|
|
this.kill(this.cs, KillReason.MediaTimeout);
|
|
}
|
|
}
|
|
|
|
_bridgeEarlyMedia(sd) {
|
|
if (this.epOther && !this.bridged) {
|
|
this.epOther.api('uuid_break', this.epOther.uuid);
|
|
this.logger.debug('Dial:_bridgeEarlyMedia: bridging early media');
|
|
this.epOther.bridge(sd.ep);
|
|
this.bridged = true;
|
|
}
|
|
}
|
|
|
|
/* public api */
|
|
async updateMediaPath(desiredPath) {
|
|
this.logger.info(`Dial:updateMediaPath - ${this._mediaPath} => ${desiredPath}`);
|
|
switch (desiredPath) {
|
|
case MediaPath.NoMedia:
|
|
assert(this._mediaPath !== MediaPath.NoMedia, 'updateMediaPath: already no-media');
|
|
await this._releaseMedia(this.cs, this.sd, true);
|
|
break;
|
|
|
|
case MediaPath.PartialMedia:
|
|
assert(this._mediaPath !== MediaPath.PartialMedia, 'updateMediaPath: already partial-media');
|
|
if (this._mediaPath === MediaPath.FullMedia) {
|
|
await this._releaseMedia(this.cs, this.sd, false);
|
|
}
|
|
else {
|
|
// to go from no-media to partial-media we need to go through full-media first
|
|
await this.reAnchorMedia(this.cs, this.sd);
|
|
await this._releaseMedia(this.cs, this.sd, false);
|
|
}
|
|
assert(!this.epOther, 'updateMediaPath: epOther should be null');
|
|
assert(!this.ep, 'updateMediaPath: ep should be null');
|
|
|
|
break;
|
|
case MediaPath.FullMedia:
|
|
assert(this._mediaPath !== MediaPath.FullMedia, 'updateMediaPath: already full-media');
|
|
await this.reAnchorMedia(this.cs, this.sd);
|
|
break;
|
|
|
|
default:
|
|
assert(false, `updateMediaPath: invalid path request ${desiredPath}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Release the media from freeswitch
|
|
* @param {*} cs
|
|
* @param {*} sd
|
|
*/
|
|
async _releaseMedia(cs, sd, releaseEntirely = false) {
|
|
assert(cs.ep && sd.ep);
|
|
|
|
try {
|
|
// Wait until we got new SDP from B leg to ofter to A Leg
|
|
const aLegSdp = cs.ep.remote.sdp;
|
|
await sd.releaseMediaToSBC(aLegSdp, cs.ep.local.sdp, releaseEntirely);
|
|
const bLegSdp = sd.dlg.remote.sdp;
|
|
await cs.releaseMediaToSBC(bLegSdp, releaseEntirely);
|
|
this.epOther = null;
|
|
this._mediaPath = releaseEntirely ? MediaPath.NoMedia : MediaPath.PartialMedia;
|
|
this.logger.info(
|
|
`Dial:_releaseMedia - successfully released media from freewitch, media path is now ${this._mediaPath}`);
|
|
} catch (err) {
|
|
this.logger.info({err}, 'Dial:_releaseMedia error');
|
|
}
|
|
}
|
|
|
|
async reAnchorMedia(cs, sd) {
|
|
if (cs.ep && sd.ep) return;
|
|
|
|
this.logger.info('Dial:reAnchorMedia - re-anchoring media to freewitch');
|
|
await Promise.all([sd.reAnchorMedia(this._mediaPath), cs.reAnchorMedia(this._mediaPath)]);
|
|
this.epOther = cs.ep;
|
|
|
|
this.epOther.bridge(this.ep);
|
|
|
|
this._mediaPath = MediaPath.FullMedia;
|
|
this.logger.info(
|
|
`Dial:_releaseMedia - successfully re-anchored media to freewitch, media path is now ${this._mediaPath}`);
|
|
}
|
|
|
|
// Handle RE-INVITE hold from caller leg.
|
|
async handleReinviteAfterMediaReleased(req, res) {
|
|
let isHandled = false;
|
|
if (this.isOnHoldEnabled) {
|
|
if (isOnhold(req.body)) {
|
|
if (!this.epOther && !this.ep) {
|
|
// update callee leg for new SDP from caller.
|
|
const sdp = await this.dlg.modify(req.body);
|
|
res.send(200, {body: sdp});
|
|
// Onhold but media is already released, reconnect
|
|
await this.reAnchorMedia(this.cs, this.sd);
|
|
isHandled = true;
|
|
this.isIncomingLegHold = true;
|
|
}
|
|
this._onHoldHook(req);
|
|
} else if (!isOnhold(req.body)) {
|
|
if (this.epOther && this.ep && this.isIncomingLegHold &&
|
|
(this.canReleaseMedia || this.shouldExitMediaPathEntirely)) {
|
|
// Offhold, time to release media
|
|
const newSdp = await this.epOther.modify(req.body);
|
|
await res.send(200, {body: newSdp});
|
|
await this._releaseMedia(this.cs, this.sd, this.shouldExitMediaPathEntirely);
|
|
isHandled = true;
|
|
}
|
|
this.isIncomingLegHold = false;
|
|
if (this._onHoldSession) {
|
|
this._onHoldSession.kill();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!isHandled) {
|
|
const sdp = await this.dlg.modify(req.body);
|
|
this.logger.info({sdp}, 'Dial:handleReinviteAfterMediaReleased - sent reinvite to B leg');
|
|
res.send(200, {body: sdp});
|
|
}
|
|
}
|
|
|
|
_onAmdEvent(cs, evt) {
|
|
this.logger.info({evt}, 'Dial:_onAmdEvent');
|
|
const {actionHook} = this.data.amd;
|
|
this.performHook(cs, actionHook, evt)
|
|
.catch((err) => {
|
|
this.logger.error({err}, 'Dial:_onAmdEvent - error calling actionHook');
|
|
});
|
|
}
|
|
|
|
async _onHoldHook(req, allowed = [TaskName.Play, TaskName.Say, TaskName.Pause]) {
|
|
if (this.data.onHoldHook) {
|
|
// send silence for keep Voice quality
|
|
await this.epOther.play('silence_stream://500');
|
|
let allowedTasks;
|
|
do {
|
|
try {
|
|
const b3 = this.getTracingPropagation();
|
|
const httpHeaders = b3 && {b3};
|
|
const json = await this.cs.application.requestor.
|
|
request('verb:hook', this.data.onHoldHook, {
|
|
...this.cs.callInfo.toJSON(),
|
|
hold_detail: {
|
|
from: req.get('From'),
|
|
to: req.get('To')
|
|
}
|
|
}, httpHeaders);
|
|
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
|
allowedTasks = tasks.filter((t) => allowed.includes(t.name));
|
|
if (tasks.length !== allowedTasks.length) {
|
|
this.logger.debug({tasks, allowedTasks}, 'unsupported task');
|
|
throw new Error(`unsupported verb in enqueue waitHook: only ${JSON.stringify(allowed)}`);
|
|
}
|
|
this.logger.debug(`DialTask:_onHoldHook: executing ${tasks.length} tasks`);
|
|
if (tasks.length) {
|
|
this._onHoldSession = new ConfirmCallSession({
|
|
logger: this.logger,
|
|
application: this.cs.application,
|
|
dlg: this.isIncomingLegHold ? this.dlg : this.cs.dlg,
|
|
ep: this.isIncomingLegHold ? this.ep : this.cs.ep,
|
|
callInfo: this.cs.callInfo,
|
|
accountInfo: this.cs.accountInfo,
|
|
tasks,
|
|
rootSpan: this.cs.rootSpan,
|
|
req: this.cs.req
|
|
});
|
|
await this._onHoldSession.exec();
|
|
this._onHoldSession = null;
|
|
}
|
|
} catch (error) {
|
|
this.logger.info(error, 'DialTask:_onHoldHook: failed retrieving waitHook');
|
|
this._onHoldSession = null;
|
|
break;
|
|
}
|
|
} while (allowedTasks && allowedTasks.length > 0 && !this.killed && this.isOnHold);
|
|
this.logger.info('Finish onHoldHook');
|
|
}
|
|
}
|
|
}
|
|
|
|
module.exports = TaskDial;
|