add tag task and varioius cleanup

This commit is contained in:
Dave Horton
2020-01-29 15:27:20 -05:00
parent bed4fa1f42
commit 92acd50595
17 changed files with 278 additions and 111 deletions

View File

@@ -3,6 +3,7 @@ const uuidv4 = require('uuid/v4');
const {CallStatus, CallDirection} = require('./utils/constants');
const CallInfo = require('./session/call-info');
const retrieveApp = require('./utils/retrieve-app');
const parseUrl = require('parse-url');
module.exports = function(srf, logger) {
const {lookupAppByPhoneNumber} = srf.locals.dbHelpers;
@@ -44,8 +45,8 @@ module.exports = function(srf, logger) {
const logger = req.locals.logger;
try {
const app = await lookupAppByPhoneNumber(req.locals.calledNumber);
if (!app) {
logger.info(`rejecting call to DID ${req.locals.calledNumber}: no application associated`);
if (!app || !app.call_hook || !app.call_hook.url) {
logger.info(`rejecting call to ${req.locals.calledNumber}: no application or webhook url`);
return res.send(480, {
headers: {
'X-Reason': 'no configured application'
@@ -53,22 +54,9 @@ module.exports = function(srf, logger) {
});
}
//TODO: temp hack pre-refactoring to latest db schema: bang the data into expected shape
req.locals.application = app;
//end hack
logger.debug(app, `retrieved application for ${req.locals.calledNumber}`);
const from = req.getParsedHeader('From');
req.locals.callInfo = new CallInfo({
callSid: req.locals.callSid,
accountSid: app.account_sid,
applicationSid: app.application_sid,
from: req.callingNumber,
to: req.calledNumber,
direction: CallDirection.Inbound,
callerName: from.name || req.callingNumber,
callId: req.get('Call-ID')
});
req.locals.callInfo = new CallInfo({req, app, direction: CallDirection.Inbound});
next();
} catch (err) {
logger.error(err, `${req.get('Call-ID')} Error looking up application for ${req.calledNumber}`);
@@ -82,20 +70,25 @@ module.exports = function(srf, logger) {
async function invokeWebCallback(req, res, next) {
const logger = req.locals.logger;
const app = req.locals.application;
const method = (app.hook_http_method || 'POST').toUpperCase();
const qs = Object.assign({}, req.locals.callInfo, {
sipStatus: 100,
callStatus: CallStatus.Trying,
originatingSipIP: req.get('X-Forwarded-For'),
originatingSipTrunkName: req.get('X-Originating-Carrier')
});
const call_hook = app.call_hook;
const method = (call_hook.method || 'POST').toUpperCase();
let auth;
if (app.hook_basic_auth_user && app.hook_basic_auth_password) {
logger.debug(`using basic auth with ${app.hook_basic_auth_user}:${app.hook_basic_auth_password}`);
auth = Object.assign({}, {user: app.hook_basic_auth_user, password: app.hook_basic_auth_password});
if (call_hook.username && call_hook.password) {
auth = {username: call_hook.username, password: call_hook.password};
}
try {
app.tasks = await retrieveApp(logger, app.call_hook, method, auth, qs, method === 'POST' ? req.msg : null);
const u = parseUrl(call_hook.url);
const myPort = u.port ? `:${u.port}` : '';
app.originalRequest = {
baseUrl: `${u.protocol}://${u.resource}${myPort}`,
auth
};
logger.debug({originalRequest: app.originalRequest}, 'invokeWebCallback');
const obj = req.locals.callInfo;
// if the call hook is a POST add the entire SIP message to the payload
if (method === 'POST') Object.assign(obj, {sip: req.msg});
app.tasks = await retrieveApp(logger, app.call_hook, method, auth, obj);
next();
} catch (err) {
logger.error(err, 'Error retrieving or parsing application');

View File

@@ -1,22 +1,69 @@
const {CallDirection, CallStatus} = require('../lib/constants');
const uuidv4 = require('uuid/v4');
class CallInfo {
constructor(opts) {
this.callSid = opts.callSid;
this.parentCallSid = opts.parentCallSid;
this.direction = opts.direction;
this.from = opts.from;
this.to = opts.to;
this.callId = opts.callId;
this.sipStatus = opts.sipStatus;
this.callStatus = opts.callStatus;
this.callerId = opts.callerId;
this.accountSid = opts.accountSid;
this.applicationSid = opts.applicationSid;
if (this.direction === CallDirection.Inbound) {
const {app, req} = opts;
this.callSid = req.locals.callSid,
this.accountSid = app.account_sid,
this.applicationSid = app.application_sid;
this.from = req.callingNumber;
this.to = req.calledNumber;
this.callerName = this.from.name || req.callingNumber;
this.callId = req.get('Call-ID');
this.sipStatus = 100;
this.callStatus = CallStatus.Trying;
this.originatingSipIP = req.get('X-Forwarded-For');
this.originatingSipTrunkName = req.get('X-Originating-Carrier');
}
else if (opts.parentCallInfo instanceof CallInfo) {
const {req, parentCallInfo} = opts;
this.callSid = uuidv4();
this.parentCallSid = parentCallInfo.callSid;
this.accountSid = parentCallInfo.accountSid;
this.applicationSid = parentCallInfo.applicationSid;
this.from = req.callingNumber;
this.to = req.calledNumber;
this.callerName = this.from.name || req.callingNumber;
this.callId = req.get('Call-ID');
this.callStatus = CallStatus.Trying,
this.sipStatus = 100;
}
}
updateCallStatus(callStatus, sipStatus) {
this.callStatus = callStatus;
if (sipStatus) this.sipStatus = sipStatus;
}
set customerData(obj) {
this._customerData = obj;
}
toJSON() {
const obj = {
callSid: this.callSid,
direction: this.direction,
from: this.from,
to: this.to,
callId: this.callId,
sipStatus: this.sipStatus,
callStatus: this.callStatus,
callerId: this.callId,
accountSid: this.accountSid,
applicationSid: this.applicationSid
};
['parentCallSid', 'originatingSipIP', 'originatingSipTrunkName'].forEach((prop) => {
if (this[prop]) obj[prop] = this[prop];
});
if (this._customerData && Object.keys(this._customerData).length) {
obj.customerData = this._customerData;
}
return obj;
}
}
module.exports = CallInfo;

View File

@@ -1,24 +1,44 @@
const Emitter = require('events');
const config = require('config');
const {CallDirection, TaskPreconditions, CallStatus} = require('../utils/constants');
const hooks = require('../utils/notifiers');
const moment = require('moment');
const assert = require('assert');
const BADPRECONDITIONS = 'preconditions not met';
class CallSession extends Emitter {
constructor({logger, application, srf, tasks, callSid}) {
constructor({logger, application, srf, tasks, callInfo}) {
super();
this.logger = logger;
this.application = application;
this.srf = srf;
this.callSid = callSid;
this.callInfo = callInfo;
this.tasks = tasks;
const {notifyHook} = hooks(this.logger, this.callInfo);
this.notifyHook = notifyHook;
this.taskIdx = 0;
this.stackIdx = 0;
this.callGone = false;
}
get callSid() {
return this.callInfo.callSid;
}
get originalRequest() {
return this.application.originalRequest;
}
get direction() {
return this.callInfo.direction;
}
get callId() {
return this.callInfo.direction;
}
async exec() {
this.logger.info(`CallSession:exec starting task list with ${this.tasks.length} tasks`);
while (this.tasks.length && !this.callGone) {
@@ -176,19 +196,13 @@ class CallSession extends Emitter {
}
return {ms: this.ms, ep: this.ep};
}
_notifyCallStatusChange(callStatus) {
this.logger.debug({app: this.application}, `CallSession:_notifyCallStatusChange: ${JSON.stringify(callStatus)}`);
_notifyCallStatusChange({callStatus, sipStatus}) {
this.logger.debug(`CallSession:_notifyCallStatusChange: ${callStatus} ${sipStatus}`);
this.callInfo.updateStatus(callStatus, sipStatus);
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);
this.notifyHook(this.application.call_status_hook);
} catch (err) {
this.logger.info(err, `CallSession:_notifyCallStatusChange error sending ${JSON.stringify(callStatus)}`);
this.logger.info(err, `CallSession:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
}
}
}

View File

@@ -1,6 +1,5 @@
const CallSession = require('./call-session');
const {CallDirection, CallStatus} = require('../utils/constants');
const hooks = require('../utils/notifiers');
const {CallStatus} = require('../utils/constants');
const moment = require('moment');
const assert = require('assert');
@@ -10,17 +9,11 @@ class InboundCallSession extends CallSession {
logger: req.locals.logger,
srf: req.srf,
application: req.locals.application,
callSid: req.locals.callInfo.callSid,
callInfo: req.locals.callInfo,
tasks: req.locals.application.tasks
});
this.req = req;
this.res = res;
this.srf = req.srf;
this.logger = req.locals.logger;
this.callInfo = req.locals.callInfo;
this.direction = CallDirection.Inbound;
const {notifyHook} = hooks(this.logger, this.callInfo);
this.notifyHook = notifyHook;
req.on('cancel', this._callReleased.bind(this));

View File

@@ -145,7 +145,7 @@ class TaskDial extends Task {
this.target.forEach((t) => {
try {
t.url = t.url || this.confirmUrl;
t.method = t.method || this.confirmMethod;
t.method = t.method || this.confirmMethod || 'POST';
const sd = placeCall({
logger: this.logger,
application: cs.application,

View File

@@ -62,7 +62,7 @@ class TaskGather extends Task {
this._startTranscribing(ep);
}
if (this.input.includes('dtmf')) {
if (this.input.includes('digits')) {
ep.on('dtmf', this._onDtmf.bind(this, ep));
}
@@ -129,8 +129,14 @@ class TaskGather extends Task {
}
_killAudio() {
if (this.sayTask && !this.sayTask.killed) this.sayTask.kill();
if (this.playTask && !this.playTask.killed) this.playTask.kill();
if (this.sayTask && !this.sayTask.killed) {
this.sayTask.removeAllListeners('playDone');
this.sayTask.kill();
}
if (this.playTask && !this.playTask.killed) {
this.playTask.removeAllListeners('playDone');
this.playTask.kill();
}
}
_onTranscription(ep, evt) {

View File

@@ -42,6 +42,9 @@ function makeTask(logger, obj) {
case TaskName.Redirect:
const TaskRedirect = require('./redirect');
return new TaskRedirect(logger, data);
case TaskName.Tag:
const TaskTag = require('./tag');
return new TaskTag(logger, data);
}
// should never reach

View File

@@ -10,6 +10,7 @@ class TaskRedirect extends Task {
this.action = this.data.action;
this.method = this.data.method || 'POST';
this.auth = this.data.auth;
}
get name() { return TaskName.Redirect; }

View File

@@ -115,6 +115,14 @@
"action"
]
},
"tag": {
"properties": {
"data": "object"
},
"required": [
"data"
]
},
"transcribe": {
"properties": {
"transcriptionCallback": "string",

19
lib/tasks/tag.js Normal file
View File

@@ -0,0 +1,19 @@
const Task = require('./task');
const {TaskName} = require('../utils/constants');
class TaskTag extends Task {
constructor(logger, opts) {
super(logger, opts);
this.data = this.data.data;
}
get name() { return TaskName.Tag; }
async exec(cs) {
super.exec(cs);
cs.callInfo.customerData = this.data;
this.logger.debug({customerData: cs.callInfo.customerData}, 'TaskTag:exec set customer data');
}
}
module.exports = TaskTag;

View File

@@ -58,7 +58,14 @@ class Task extends Emitter {
async performAction(method, auth, results) {
if (this.action) {
const tasks = await this.actionHook(this.action, method, auth, results);
let action = this.action;
if (action.startsWith('/')) {
const or = this.callSession.originalRequest;
action = `${or.baseUrl}${this.action}`;
this.logger.debug({originalUrl: this.action, normalizedUrl: action}, 'Task:performAction normalized url');
if (!auth && or.auth) auth = or.auth;
}
const tasks = await this.actionHook(action, method, auth, results);
if (tasks && Array.isArray(tasks)) {
this.logger.debug({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.callSession.replaceApplication(tasks);

View File

@@ -10,6 +10,7 @@
"SipNotify": "sip:notify",
"SipRedirect": "sip:redirect",
"Say": "say",
"Tag": "tag",
"Transcribe": "transcribe"
},
"CallStatus": {

View File

@@ -1,23 +1,36 @@
const request = require('request');
require('request-debug')(request);
const retrieveApp = require('./retrieve-app');
function hooks(logger, callAttributes) {
function actionHook(url, method, auth, opts, expectResponse = true) {
const params = Object.assign({}, callAttributes, opts);
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');
function hooks(logger, callInfo) {
function actionHook(hook, obj, expectResponse = true) {
const method = hook.method.toUpperCase();
const auth = (hook.username && hook.password) ?
{username: hook.username, password: hook.password} :
null;
const data = Object.assign({}, obj, callInfo);
if ('GET' === method) {
// remove customer data - only for POSTs since it might be quite complex
delete data.customerData;
}
const opts = {
url: hook.url,
method,
json: 'POST' === method || expectResponse
};
if (auth) obj.auth = auth;
if ('POST' === method) obj.body = data;
else obj.qs = data;
return new Promise((resolve, reject) => {
request(obj, (err, response, body) => {
request(opts, (err, response, body) => {
if (err) {
logger.info(`actionHook error ${method} ${url}: ${err.message}`);
logger.info(`actionHook error ${method} ${hook.url}: ${err.message}`);
return reject(err);
}
if (body && expectResponse) {
logger.debug(body, `actionHook response ${method} ${url}`);
logger.debug(body, `actionHook response ${method} ${hook.url}`);
return resolve(retrieveApp(logger, body));
}
resolve(body);
@@ -25,7 +38,7 @@ function hooks(logger, callAttributes) {
});
}
function notifyHook(url, method, auth, opts) {
function notifyHook(url, method, auth, opts = {}) {
return actionHook(url, method, auth, opts, false);
}

View File

@@ -1,12 +1,13 @@
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 {TaskPreconditions, CallDirection} = require('../utils/constants');
const CallInfo = require('../session/call-info');
const assert = require('assert');
const ConfirmCallSession = require('../session/confirm-call-session');
const hooks = require('./notifiers');
const moment = require('moment');
const parseUrl = require('parse-url');
class SingleDialer extends Emitter {
constructor({logger, sbcAddress, target, opts, application, callInfo}) {
@@ -18,13 +19,21 @@ class SingleDialer extends Emitter {
this.sbcAddress = sbcAddress;
this.opts = opts;
this.application = application;
this.url = opts.url;
this.method = opts.method;
this.url = target.url;
this.method = target.method;
this._callSid = uuidv4();
this.bindings = logger.bindings();
this.callInfo = Object.assign({}, callInfo, {callSid: this._callSid});
this.sipStatus;
this.parentCallInfo = callInfo;
/*
this.callInfo = Object.assign({}, callInfo, {
callSid: this._callSid,
parentCallSid: callInfo.callSid,
direction: CallDirection.Outbound,
callStatus: CallStatus.Trying,
sipStatus: 100
});
*/
this.callGone = false;
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
@@ -86,18 +95,21 @@ class SingleDialer extends Emitter {
* (a) create a logger for this call
* (b) augment this.callInfo with additional call info
*/
this.callInfo = new CallInfo({
direction: CallDirection.Outbound,
parentCallInfo: this.parentCallInfo,
req
});
this.logger = srf.locals.parentLogger.child({
callSid: this.callSid,
parentCallSid: this.bindings.callSid,
callId: req.get('Call-ID')
callSid: this.callInfo.callSid,
parentCallSid: this.parentCallInfo.callSid,
callId: this.callInfo.callId
});
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);
this.emit('callStatusChange', {callStatus: CallStatus.Trying, sipStatus: 100});
},
cbProvisional: (prov) => {
const status = {sipStatus: prov.status};
@@ -168,7 +180,24 @@ class SingleDialer extends Emitter {
async _executeApp(url) {
this.logger.debug(`SingleDialer:_executeApp: executing ${url} after connect`);
try {
const tasks = await this.actionHook(this.url, this.method);
let auth;
const app = Object.assign({}, this.application);
if (url.startsWith('/')) {
const savedUrl = url;
const or = app.originalRequest;
url = `${or.baseUrl}${url}`;
auth = or.auth;
this.logger.debug({originalUrl: savedUrl, normalizedUrl: url}, 'SingleDialer:_executeApp normalized url');
}
else {
const u = parseUrl(url);
const myPort = u.port ? `:${u.port}` : '';
app.originalRequest = {
baseUrl: `${u.protocol}://${u.resource}${myPort}`
};
}
const tasks = await this.actionHook(url, this.method, auth);
const allowedTasks = tasks.filter((task) => {
return [
TaskPreconditions.StableCall,
@@ -180,7 +209,7 @@ class SingleDialer extends Emitter {
}
this.logger.debug(`SingleDialer:_executeApp: executing ${tasks.length} tasks`);
const cs = new ConfirmCallSession(this.logger, this.application, this.dlg, this.ep, tasks);
const cs = new ConfirmCallSession({logger: this.logger, application: app, dlg: this.dlg, ep: this.ep, tasks});
await cs.exec();
this.emit(this.dlg.connected ? 'accept' : 'decline');
} catch (err) {
@@ -190,18 +219,13 @@ class SingleDialer extends Emitter {
}
}
_notifyCallStatusChange(callStatus) {
_notifyCallStatusChange({callStatus, sipStatus}) {
this.logger.debug(`SingleDialer:_notifyCallStatusChange: ${callStatus} ${sipStatus}`);
this.callInfo.updateStatus(callStatus, sipStatus);
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);
this.notifyHook(this.application.call_status_hook);
} catch (err) {
this.logger.info(err, `SingleDialer:_notifyCallStatusChange: error sending ${JSON.stringify(callStatus)}`);
this.logger.info(err, `SingleDialer:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
}
}
}

View File

@@ -4,26 +4,25 @@ 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});
}
function retrieveUrl(logger, url, method, auth, obj) {
const opts = {url, method, auth, json: true};
if (method === 'GET') Object.assign(opts, {qs: obj});
else Object.assign(opts, {body: obj});
return new Promise((resolve, reject) => {
request(opts, (err, response, body) => {
if (err) throw err;
if (body) logger.debug({body}, 'retrieveUrl: customer returned an application');
resolve(body);
});
});
}
async function retrieveApp(logger, url, method, auth, qs, body) {
async function retrieveApp(logger, url, method, auth, obj) {
let json;
if (typeof url === 'object') json = url;
else json = await retrieveUrl(logger, url, method, auth, qs, body);
else json = await retrieveUrl(logger, url, method, auth, obj);
return normalizeJamones(logger, json).map((tdata) => makeTask(logger, tdata));
}

38
package-lock.json generated
View File

@@ -1561,6 +1561,14 @@
"has": "^1.0.1"
}
},
"is-ssh": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.3.1.tgz",
"integrity": "sha512-0eRIASHZt1E68/ixClI8bp2YK2wmBPVWEismTs6M+M099jKgrzl/3E976zIbImSIob48N2/XGe9y7ZiYdImSlg==",
"requires": {
"protocols": "^1.1.0"
}
},
"is-stream": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz",
@@ -2198,6 +2206,11 @@
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true
},
"normalize-url": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-3.3.0.tgz",
"integrity": "sha512-U+JJi7duF1o+u2pynbp2zXDW2/PADgC30f0GsHZtRh+HOcXHnw137TrNlyxxRvWW5fjKd3bcLHPxofWuCjaeZg=="
},
"number-is-nan": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz",
@@ -2417,6 +2430,26 @@
"integrity": "sha1-VjRtR0nXjyNDDKDHE4UK75GqNh0=",
"dev": true
},
"parse-path": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/parse-path/-/parse-path-4.0.1.tgz",
"integrity": "sha512-d7yhga0Oc+PwNXDvQ0Jv1BuWkLVPXcAoQ/WREgd6vNNoKYaW52KI+RdOFjI63wjkmps9yUE8VS4veP+AgpQ/hA==",
"requires": {
"is-ssh": "^1.3.0",
"protocols": "^1.4.0"
}
},
"parse-url": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/parse-url/-/parse-url-5.0.1.tgz",
"integrity": "sha512-flNUPP27r3vJpROi0/R3/2efgKkyXqnXwyP1KQ2U0SfFRgdizOdWfvrrvJg1LuOoxs7GQhmxJlq23IpQ/BkByg==",
"requires": {
"is-ssh": "^1.3.0",
"normalize-url": "^3.3.0",
"parse-path": "^4.0.0",
"protocols": "^1.4.0"
}
},
"path-exists": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz",
@@ -2557,6 +2590,11 @@
"react-is": "^16.8.1"
}
},
"protocols": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/protocols/-/protocols-1.4.7.tgz",
"integrity": "sha512-Fx65lf9/YDn3hUX08XUc0J8rSux36rEsyiv21ZGUC1mOyeM3lTRpZLcrm8aAolzS4itwVfm7TAPyxC2E5zd6xg=="
},
"pseudomap": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz",

View File

@@ -33,6 +33,7 @@
"drachtio-srf": "^4.4.27",
"jambonz-db-helpers": "^0.1.8",
"moment": "^2.24.0",
"parse-url": "^5.0.1",
"pino": "^5.14.0",
"request": "^2.88.0",
"request-debug": "^0.2.0"