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,36 +1,45 @@
const request = require('request');
require('request-debug')(request);
//require('request-debug')(request);
const makeTask = require('../tasks/make_task');
const normalizeJamones = require('./normalize-jamones');
const debug = require('debug')('jambonz:feature-server');
function hooks(logger, callAttributes) {
debug(`notifiers: callAttributes ${JSON.stringify(callAttributes)}`);
function actionHook(url, method, opts) {
debug(`notifiers: opts ${JSON.stringify(opts)}`);
function actionHook(url, method, auth, opts, expectResponse = false) {
const params = Object.assign({}, callAttributes, opts);
const obj = {
url,
method,
json: true,
qs: 'GET' === method ? params : callAttributes,
body: 'POST' === method ? opts : null
};
logger.debug(`${method} ${url} sending ${JSON.stringify(obj)}`);
let basicauth, qs, body;
if (auth && typeof auth === 'object' && Object.keys(auth) === 2) basicauth = auth;
if ('GET' === method.toUpperCase()) qs = params;
else body = params;
const obj = {url, method, auth: basicauth, json: expectResponse || body, qs, body};
logger.debug({opts: obj}, 'actionHook');
return new Promise((resolve, reject) => {
request(obj, (err, response, body) => {
if (err) {
this.logger.info(`TaskDial:_actionHook error ${method} ${url}: ${err.message}`);
logger.info(`actionHook error ${method} ${url}: ${err.message}`);
return reject(err);
}
if (body) {
this.logger.debug(body, `TaskDial:_actionHook response ${method} ${url}`);
logger.debug(body, `actionHook response ${method} ${url}`);
if (expectResponse) {
const tasks = normalizeJamones(logger, body).map((tdata) => makeTask(logger, tdata));
return resolve(tasks);
}
}
resolve(body);
});
});
}
function notifyHook(url, method, auth, opts) {
return actionHook(url, method, auth, opts, false);
}
return {
actionHook
actionHook,
notifyHook
};
}

View File

@@ -1,54 +1,215 @@
const Emitter = require('events');
const {CallStatus} = require('./constants');
const uuidv4 = require('uuid/v4');
const SipError = require('drachtio-srf').SipError;
const {TaskPreconditions} = require('../utils/constants');
const assert = require('assert');
const ConfirmCallSession = require('../session/confirm-call-session');
const hooks = require('./notifiers');
const moment = require('moment');
class SingleDialer extends Emitter {
constructor(logger, opts) {
constructor({logger, sbcAddress, target, opts, application, callInfo}) {
super();
assert(target.type);
this.logger = logger;
this.cs = opts.cs;
this.ms = opts.ms;
this.target = target;
this.sbcAddress = sbcAddress;
this.opts = opts;
this.application = application;
this.url = opts.url;
this.method = opts.method;
this._callSid = uuidv4();
this.bindings = logger.bindings();
this.callInfo = Object.assign({}, callInfo, {callSid: this._callSid});
this.sipStatus;
this.callGone = false;
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
}
get callState() {
return this._callState;
get callSid() {
return this._callSid;
}
get callStatus() {
return this.callInfo.callStatus;
}
async exec(srf, ms, opts) {
let uri, to;
switch (this.target.type) {
case 'phone':
assert(this.target.number);
uri = `sip:${this.opts.number}@${this.sbcAddress}`;
to = this.target.number;
break;
case 'user':
assert(this.target.name);
uri = `sip:${this.target.name}`;
to = this.target.name;
break;
case 'sip':
assert(this.target.uri);
uri = this.target.uri;
to = this.target.name;
break;
default:
// should have been caught by parser
assert(false, `invalid dial type ${this.target.type}: must be phone, user, or sip`);
}
try {
this.ep = await ms.createEndpoint();
this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`);
let sdp;
const connectStream = async(remoteSdp) => {
if (remoteSdp !== sdp) {
this.ep.modify(sdp = remoteSdp);
return true;
}
return false;
};
Object.assign(opts, {
proxy: `sip:${this.sbcAddress}`,
localSdp: this.ep.local.sdp
});
if (this.target.auth) opts.auth = this.target.auth;
this.dlg = await srf.createUAC(uri, opts, {
cbRequest: (err, req) => {
if (err) return this.logger.error(err, 'SingleDialer:exec Error creating call');
/**
* INVITE has been sent out
* (a) create a logger for this call
* (b) augment this.callInfo with additional call info
*/
this.logger = srf.locals.parentLogger.child({
callSid: this.callSid,
parentCallSid: this.bindings.callSid,
callId: req.get('Call-ID')
});
this.inviteInProgress = req;
const status = {callStatus: CallStatus.Trying, sipStatus: 100};
Object.assign(this.callInfo, {callId: req.get('Call-ID'), from: req.callingNumber, to});
const {actionHook, notifyHook} = hooks(this.logger, this.callInfo);
this.actionHook = actionHook;
this.notifyHook = notifyHook;
this.emit('callStatusChange', status);
},
cbProvisional: (prov) => {
const status = {sipStatus: prov.status};
if ([180, 183].includes(prov.status) && prov.body) {
status.callStatus = CallStatus.EarlyMedia;
if (connectStream(prov.body)) this.emit('earlyMedia');
}
else status.callStatus = CallStatus.Ringing;
this.emit('callStatusChange', status);
}
});
connectStream(this.dlg.remote.sdp);
this.dlg.callSid = this.callSid;
this.inviteInProgress = null;
this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress});
this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`);
const connectTime = this.dlg.connectTime = moment();
this.dlg.on('destroy', () => {
const duration = moment().diff(connectTime, 'seconds');
this.logger.debug('SingleDialer:exec called party hung up');
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
this.ep.destroy();
});
if (this.url) this._executeApp(this.url);
else this.emit('accept');
} catch (err) {
const status = {callStatus: CallStatus.Failed};
if (err instanceof SipError) {
status.sipStatus = err.status;
if (err.status === 487) status.callStatus = CallStatus.NoAnswer;
else if ([486, 600].includes(err.status)) status.callStatus = CallStatus.Busy;
this.logger.debug(`SingleDialer:exec outdial failure ${err.status}`);
}
else {
this.logger.error(err, 'SingleDialer:exec');
status.sipStatus = 500;
}
this.emit('callStatusChange', status);
if (this.ep) this.ep.destroy();
}
}
/**
* launch the outdial
*/
exec() {
}
/**
* kill the call in progress, or stable dialog, whichever
* kill the call in progress or the stable dialog, whichever we have
*/
async kill() {
if (this.inviteInProgress) await this.inviteInProgress.cancel();
else if (this.dlg && this.dlg.connected) {
const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.logger.debug('SingleDialer:kill hanging up called party');
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
}
if (this.ep) {
this.logger.debug(`SingleDialer:kill - deleting endpoint ${this.ep.uuid}`);
await this.ep.destroy();
}
}
/**
* execute a jambones application on this call / endpoint
* @param {*} jambones document
* Run an application on the call after answer, e.g. call screening.
* Once the application completes in some fashion, emit an 'accepted' event
* if the call is still up/connected, a 'decline' otherwise.
* Note: the application to run may not include a dial or sip:decline verb
* @param {*} url - url for application
*/
async runApp(document) {
async _executeApp(url) {
this.logger.debug(`SingleDialer:_executeApp: executing ${url} after connect`);
try {
const tasks = await this.actionHook(this.url, this.method);
const allowedTasks = tasks.filter((task) => {
return [
TaskPreconditions.StableCall,
TaskPreconditions.Endpoint
].includes(task.preconditions);
});
if (tasks.length !== allowedTasks.length) {
throw new Error('unsupported verb in dial url');
}
this.logger.debug(`SingleDialer:_executeApp: executing ${tasks.length} tasks`);
const cs = new ConfirmCallSession(this.logger, this.application, this.dlg, this.ep, tasks);
await cs.exec();
this.emit(this.dlg.connected ? 'accept' : 'decline');
} catch (err) {
this.logger.debug(err, 'SingleDialer:_executeApp: error');
this.emit('decline');
if (this.dlg.connected) this.dlg.destroy();
}
}
async _createEndpoint() {
_notifyCallStatusChange(callStatus) {
try {
const auth = {};
if (this.application.hook_basic_auth_user && this.application.hook_basic_auth_password) {
Object.assign(auth, {user: this.application.hook_basic_auth_user, password: this.hook_basic_auth_password});
}
this.notifyHook(this.application.call_status_hook,
this.application.hook_http_method,
auth,
callStatus);
} catch (err) {
this.logger.info(err, `SingleDialer:_notifyCallStatusChange: error sending ${JSON.stringify(callStatus)}`);
}
}
async _outdial() {
}
}
function placeOutdial(logger, opts) {
const singleDialer = new SingleDialer(logger, opts);
singleDialer.exec();
return singleDialer;
function placeOutdial({logger, srf, ms, sbcAddress, target, opts, application, callInfo}) {
const sd = new SingleDialer({logger, sbcAddress, target, opts, application, callInfo});
sd.exec(srf, ms, opts);
return sd;
}
module.exports = placeOutdial;

View File

@@ -1,50 +0,0 @@
const assert = require('assert');
//this obj is meant to be mixed in into another class
//NB: it is required that the class have a 'logger' property
module.exports = {
resources: new Map(),
addResource(name, resource) {
this.logger.debug(`addResource: adding ${name}`);
// duck-typing: resources must have a destroy function and a 'connected' proerty
assert(typeof resource.destroy === 'function');
assert('connected' in resource);
this.resources.set(name, resource);
},
getResource(name) {
return this.resources.get(name);
},
hasResource(name) {
return this.resources.has(name);
},
removeResource(name) {
this.logger.debug(`removeResource: removing ${name}`);
this.resources.delete(name);
},
async clearResource(name) {
const r = this.resources.get(name);
if (r) {
this.logger.debug(`clearResource deleting ${name}`);
try {
if (r.connected) r.destroy();
}
catch (err) {
this.logger.error(err, `clearResource error deleting ${name}`);
}
this.resources.delete(r);
}
},
async clearResources() {
for (const [name, resource] of Array.from(this.resources).reverse()) {
try {
this.logger.info(`deleting ${name}`);
if (resource.connected) await resource.destroy();
} catch (err) {
this.logger.error(err, `clearResources: error deleting ${name}`);
}
}
this.resources.clear();
}
};

30
lib/utils/retrieve-app.js Normal file
View File

@@ -0,0 +1,30 @@
const request = require('request');
//require('request-debug')(request);
const makeTask = require('../tasks/make_task');
const normalizeJamones = require('./normalize-jamones');
function retrieveUrl(logger, url, method, auth, qs, body) {
logger.debug(`body: ${body}`);
const opts = {url, method, auth, qs, json: true};
if (body) {
logger.debug('adding body');
Object.assign(opts, {body});
}
return new Promise((resolve, reject) => {
request(opts, (err, response, body) => {
if (err) throw err;
resolve(body);
});
});
}
async function retrieveApp(logger, url, method, auth, qs, body) {
let json;
if (typeof url === 'object') json = url;
else json = await retrieveUrl(logger, url, method, auth, qs, body);
return normalizeJamones(logger, json).map((tdata) => makeTask(logger, tdata));
}
module.exports = retrieveApp;