major refactoring

This commit is contained in:
Dave Horton
2020-01-25 11:47:33 -05:00
parent 621ea8c0f5
commit 4a1ea4e091
25 changed files with 947 additions and 933 deletions

View File

@@ -1,356 +1,228 @@
const Task = require('./task');
const makeTask = require('./make_task');
const {CallStatus, CallDirection, TaskName, TaskPreconditions} = require('../utils/constants');
const SipError = require('drachtio-srf').SipError;
const assert = require('assert');
const uuidv4 = require('uuid/v4');
const request = require('request');
const placeCall = require('../utils/place-outdial');
const config = require('config');
const moment = require('moment');
const debug = require('debug')('jambonz:feature-server');
function isFinalCallStatus(status) {
return [CallStatus.Completed, CallStatus.NoAnswer, CallStatus.Failed, CallStatus.Busy].includes(status);
}
class TaskDial extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.None;
this.action = opts.action;
this.earlyMedia = opts.answerOnBridge === true;
this.callerId = opts.callerId;
this.dialMusic = opts.dialMusic;
this.earlyMedia = this.data.answerOnBridge === true;
this.callerId = this.data.callerId;
this.dialMusic = this.data.dialMusic;
this.headers = this.data.headers || {};
this.method = opts.method || 'POST';
this.statusCallback = opts.statusCallback;
this.statusCallbackMethod = opts.statusCallbackMethod || 'POST';
this.target = opts.target;
this.timeout = opts.timeout || 60;
this.timeLimit = opts.timeLimit;
this.method = this.data.method || 'POST';
this.statusCallback = this.data.statusCallback;
this.statusCallbackMethod = this.data.statusCallbackMethod || 'POST';
this.target = this.data.target;
this.timeout = this.data.timeout || 60;
this.timeLimit = this.data.timeLimit;
this.url = this.data.url;
if (opts.listen) {
this.listenTask = makeTask(logger, {'listen': opts.listen});
if (this.data.listen) {
this.listenTask = makeTask(logger, {'listen': this.data.listen});
}
if (opts.transcribe) {
this.transcribeTask = makeTask(logger, {'transcribe' : opts.transcribe});
if (this.data.transcribe) {
this.transcribeTask = makeTask(logger, {'transcribe' : this.data.transcribe});
}
this.canceled = false;
this.callAttributes = {};
this.dialCallStatus = CallStatus.Failed;
this.dialCallSid = null;
this.dialCallDuration = null;
this.on('callStatusChange', this._onCallStatusChange.bind(this));
this.results = {};
this.bridged = false;
this.dials = new Map();
}
get name() { return TaskName.Dial; }
async exec(cs) {
super.exec(cs);
try {
this._initializeCallData(cs);
await this._initializeInbound(cs);
if (cs.direction === CallDirection.Inbound) {
await this._initializeInbound(cs);
}
await this._attemptCalls(cs);
await this._waitForCompletion(cs);
await this.awaitTaskDone();
this.performAction(this.method, this.results);
} catch (err) {
this.logger.error(`TaskDial:exec terminating with error ${err.message}`);
this.kill();
}
await this._actionHook(cs);
this.clearResources();
return true;
}
_initializeCallData(cs) {
this.logger.debug(`TaskDial:_initializeCallData parent call sid is ${cs.callSid}`);
Object.assign(this.callAttributes, {
AccountSid: cs.AccountSid,
ParentCallSid: cs.callSid,
Direction: CallDirection.Outbound
});
async kill() {
super.kill();
if (this.connectTime) {
const duration = moment().diff(this.connectTime, 'seconds');
this.results.dialCallDuration = duration;
this.logger.debug(`Dial:kill call ended after ${duration} seconds`);
}
this._killOutdials();
if (this.dlg) {
assert(this.ep);
if (this.dlg.connected) this.dlg.destroy();
debug(`Dial:kill deleting endpoint ${this.ep.uuid}`);
this.ep.destroy();
}
if (this.listenTask) await this.listenTask.kill();
if (this.transcribeTask) await this.transcribeTask.kill();
this.notifyTaskDone();
}
async _initializeInbound(cs) {
const {req} = cs;
const {ep} = await cs.connectInboundCallToIvr(this.earlyMedia);
this.epOther = ep;
debug(`Dial:__initializeInbound allocated ep for incoming call: ${ep.uuid}`);
// the caller could hangup in the middle of all this..
req.on('cancel', this._onCancel.bind(this, cs));
try {
const result = await cs.connectInboundCallToIvr(this.earlyMedia);
if (!result) throw new Error('outbound dial via API not supported yet');
const {ep, dlg, res} = result;
assert(ep);
// play dial music to caller, if provided
if (this.dialMusic) {
ep.play(this.dialMusic, (err) => {
if (err) this.logger.error(err, `TaskDial:_initializeInbound - error playing ${this.dialMusic}`);
});
}
this.epIn = ep;
this.dlgIn = dlg;
this.res = res;
} catch (err) {
this.logger.error(err, 'TaskDial:_initializeInbound error');
throw err;
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, srf} = cs;
// send all outbound calls back to originating SBC for simplicity
const sbcAddress = `${req.source_address}:${req.source_port}`;
const callSid = uuidv4();
let newCallId, to, from;
try {
// create an endpoint for the outbound call
const epOut = await cs.createEndpoint();
this.addResource('epOut', epOut);
const {uri, opts} = this._prepareOutdialAttempt(this.target[0], sbcAddress,
this.callerId || req.callingNumber, epOut.local.sdp);
let streamConnected = false;
const connectStreams = async(remoteSdp) => {
streamConnected = true;
epOut.modify(remoteSdp);
this.epIn.bridge(epOut);
if (!this.dlgIn) {
this.dlgIn = await cs.srf.answerParentCall(this.epIn.local.sdp);
}
};
// outdial requested destination
const uac = await srf.createUAC(uri, opts, {
cbRequest: (err, reqSent) => {
this.outboundInviteInProgress = reqSent;
newCallId = req.get('Call-ID');
from = reqSent.callingNumber,
to = reqSent.calledNumber;
this.emit('callStatusChange', {
CallSid: callSid,
SipCallId: newCallId,
CallStatus: CallStatus.Trying,
From: from,
To: to,
SipStatus: 100
});
},
cbProvisional: (prov) => {
if ([180, 183].includes(prov.status)) {
this.emit('callStatusChange', {
CallSid: callSid,
SipCallId: newCallId,
CallStatus: prov.body ? CallStatus.EarlyMedia : CallStatus.Ringing,
From: from,
To: to,
SipStatus: prov.status
});
if (!streamConnected && prov.body) connectStreams(prov.body);
}
}
});
// outbound call was established
uac.connectTime = moment();
uac.callSid = this.dialCallSid = callSid;
uac.from = from;
uac.to = to;
this.emit('callStatusChange', {
CallSid: callSid,
SipCallId: newCallId,
From: from,
To: to,
CallStatus: CallStatus.InProgress,
SipStatus: 200
});
uac.on('destroy', () => {
const duration = this.dialCallDuration = moment().diff(uac.connectTime, 'seconds');
this.emit('callStatusChange', {
CallSid: callSid,
SipCallId: newCallId,
From: from,
To: to,
CallStatus: CallStatus.Completed,
Duration: duration
});
});
if (!streamConnected) connectStreams(uac.remote.sdp);
this.outboundInviteInProgress = null;
this.addResource('dlgOut', uac);
} catch (err) {
if (err instanceof SipError) {
switch (err.status) {
case 487:
this.emit('callStatusChange', {
CallSid: callSid,
SipCallId: newCallId,
From: from,
To: to,
CallStatus: CallStatus.NoAnswer,
SipStatus: err.status
});
break;
case 486:
case 600:
this.emit('callStatusChange', {
CallSid: callSid,
SipCallId: newCallId,
From: from,
To: to,
CallStatus: CallStatus.Busy,
SipStatus: err.status
});
break;
default:
this.emit('callStatusChange', {callSid,
CallSid: callSid,
SipCallId: newCallId,
From: from,
To: to,
CallStatus: CallStatus.Failed,
SipStatus: err.status
});
break;
}
if (err.status !== 487) {
this.logger.info(`TaskDial:_connectCall outdial failed with ${err.status}`);
}
}
else {
this.emit('callStatusChange', {
CallSid: callSid,
SipCallId: newCallId,
From: from,
To: to,
CallStatus: CallStatus.Failed,
SipStatus: 500
});
this.logger.error(err, 'TaskDial:_connectCall error');
}
throw err;
}
}
_prepareOutdialAttempt(target, sbcAddress, callerId, sdp) {
const sbcAddress = cs.direction === CallDirection.Inbound ?
`${req.source_address}:${req.source_port}` :
config.get('sbcAddress');
const opts = {
headers: this.headers,
proxy: `sip:${sbcAddress}`,
callingNumber: callerId,
localSdp: sdp
callingNumber: this.callerId || req.callingNumber
};
let uri;
switch (target.type) {
case 'phone':
uri = `sip:${target.number}@${sbcAddress}`;
break;
case 'sip':
uri = target.uri;
if (target.auth) Object.assign(opts, {auth: target.auth});
break;
case 'user':
uri = `sip:${target.name}`;
break;
default:
assert(0, `TaskDial:_prepareOutdialAttempt invalid target type ${target.type}; please fix specs.json`);
}
return {uri, opts};
}
// construct bare-bones callInfo for the new outbound call attempt
const callInfo = Object.assign({}, cs.callInfo);
callInfo.parentCallSid = cs.callSid;
callInfo.direction = CallDirection.Outbound;
['callSid', 'callID', 'from', 'to', 'callerId', 'sipStatus', 'callStatus'].forEach((k) => delete callInfo[k]);
_onCancel(cs) {
this.logger.info('TaskDial: caller hung up before connecting');
this.canceled = true;
cs.emit('callStatusChange', {status: CallStatus.NoAnswer});
}
const ms = await cs.getMS();
this.target.forEach((t) => {
try {
t.url = t.url || this.url;
const sd = placeCall({
logger: this.logger,
application: cs.application,
srf,
ms,
sbcAddress,
target: t,
opts,
callInfo
});
this.dials.set(sd.callSid, sd);
_onCallerHangup(cs, dlg) {
this.logger.info('TaskDial: caller hung up');
cs.emit('callStatusChange', {status: CallStatus.Completed});
if (this.outboundInviteInProgress) this.outboundInviteInProgress.cancel();
// we are going to hang up the B leg shortly..so
const dlgOut = this.getResource('dlgOut');
if (dlgOut) {
const duration = this.dialCallDuration = moment().diff(dlgOut.connectTime, 'seconds');
this.emit('callStatusChange', {
CallSid: dlgOut.callSid,
SipCallId: dlgOut.sip.callId,
From: dlgOut.from,
To: dlgOut.to,
CallStatus: CallStatus.Completed,
Duration: duration
});
}
}
/**
* returns a Promise that resolves when either party hangs up
*/
_waitForCompletion(cs) {
return new Promise((resolve) => {
const dlgOut = this.getResource('dlgOut');
assert(this.dlgIn && dlgOut);
assert(this.dlgIn.connected && dlgOut.connected);
[this.dlgIn, dlgOut].forEach((dlg) => dlg.on('destroy', () => resolve()));
sd
.on('callStatusChange', (obj) => {
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');
break;
case CallStatus.Failed:
case CallStatus.Busy:
case CallStatus.NoAnswer:
this.dials.delete(sd.callSid);
if (this.dials.size === 0 && !this.connectTime) {
this.logger.debug('Dial:_attemptCalls - all calls failed after call failure, ending task');
this.kill();
}
break;
}
if (this.results.dialCallStatus !== CallStatus.Completed) {
Object.assign(this.results, {
dialCallStatus: obj.callStatus,
dialCallSid: sd.callSid,
});
}
})
.on('accept', () => {
this.logger.debug(`Dial:_attemptCalls - we have a winner: ${sd.callSid}`);
this._connectSingleDial(cs, sd);
})
.on('decline', () => {
this.logger.debug(`Dial:_attemptCalls - declined: ${sd.callSid}`);
this.dials.delete(sd.callSid);
if (this.dials.size === 0 && !this.connectTime) {
this.logger.debug('Dial:_attemptCalls - all calls failed after decline, ending task');
this.kill();
}
});
} catch (err) {
this.logger.error(err, 'Dial:_attemptCalls');
}
});
}
_onCallStatusChange(evt) {
this.logger.debug(evt, 'TaskDial:_onCallStatusChange');
// save the most recent final call status of a B leg, until we get one that is completed
if (isFinalCallStatus(evt.CallStatus) && this.dialCallStatus !== CallStatus.Completed) {
this.dialCallStatus = evt.CallStatus;
_connectSingleDial(cs, sd) {
if (!this.bridged) {
this.logger.debug('Dial:_connectSingleDial bridging endpoints');
this.epOther.api('uuid_break', this.epOther.uuid);
this.epOther.bridge(sd.ep);
this.bridged = true;
}
if (this.statusCallback) {
const params = Object.assign({}, this.callAttributes, evt);
const opts = {
url: this.statusCallback,
method: this.statusCallbackMethod,
json: true,
qs: 'GET' === this.statusCallbackMethod ? params : null,
body: 'POST' === this.statusCallbackMethod ? params : null
};
request(opts, (err) => {
if (err) this.logger.info(`TaskDial:Error sending call status to ${this.statusCallback}: ${err.message}`);
});
// ding! ding! ding! we have a winner
this._selectSingleDial(cs, sd);
this._killOutdials(); // NB: order is important
}
_selectSingleDial(cs, sd) {
this.connectTime = moment();
this.dials.delete(sd.callSid);
debug(`Dial:_selectSingleDial ep for outbound call: ${sd.ep.uuid}`);
this.ep = sd.ep;
this.dlg = sd.dlg;
this.callSid = sd.callSid;
if (this.earlyMedia) {
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
cs.propagateAnswer();
}
this.dlg.on('destroy', () => {
this.logger.debug('Dial:_selectSingleDial called party hungup, ending dial operation');
this.ep.unbridge();
this.kill();
});
Object.assign(this.results, {
dialCallStatus: CallStatus.Completed,
dialCallSid: sd.callSid,
});
if (this.transcribeTask) this.transcribeTask.exec(cs, this.ep, this);
if (this.listenTask) this.listenTask.exec(cs, this.ep, this);
}
_killOutdials() {
for (const [callSid, sd] of Array.from(this.dials)) {
this.logger.debug(`Dial:_killOutdials killing callSid ${callSid}`);
sd.kill().catch((err) => this.logger.info(err, `Dial:_killOutdials Error killing ${callSid}`));
}
this.dials.clear();
}
_bridgeEarlyMedia(sd) {
if (this.epOther && !this.bridged) {
this.epOther.api('uuid_break', this.epOther.uuid);
this.epOther.bridge(sd.ep);
this.bridged = true;
}
}
async _actionHook(cs) {
if (this.action) {
const params = {DialCallStatus: this.dialCallStatus};
Object.assign(params, {
DialCallSid: this.dialCallSid,
DialCallDuration: this.dialCallDuration
});
const opts = {
url: this.action,
method: this.method,
json: true,
qs: 'GET' === this.method ? params : null,
body: 'POST' === this.method ? params : null
};
return new Promise((resolve, reject) => {
request(opts, (err, response, body) => {
if (err) this.logger.info(`TaskDial:_actionHook sending call status to ${this.action}: ${err.message}`);
if (body) {
this.logger.debug(body, 'got new application payload');
cs.replaceApplication(body);
}
resolve();
});
});
}
}
}
module.exports = TaskDial;