initial checkin

This commit is contained in:
Dave Horton
2020-01-07 10:34:03 -05:00
commit 523e2a308b
61 changed files with 7876 additions and 0 deletions

38
lib/call-session.js Normal file
View File

@@ -0,0 +1,38 @@
const Emitter = require('events');
/*
const config = require('config');
const {forwardInDialogRequests} = require('drachtio-fn-b2b-sugar');
const {parseUri, SipError} = require('drachtio-srf');
const debug = require('debug')('jambonz:sbc-inbound');
const assert = require('assert');
*/
class CallSession extends Emitter {
constructor(req, res) {
super();
this.req = req;
this.res = res;
this.srf = req.srf;
this.logger = req.locals.logger;
this.application = req.locals.application;
}
async exec() {
let idx = 0;
for (const task of this.application.tasks) {
try {
this.logger.debug(`CallSession: executing task #${++idx}: ${task.name}`);
const continueOn = await task.exec(this);
if (!continueOn) break;
} catch (err) {
this.logger.error({err, task}, 'Error executing task');
}
this.logger.info('finished all tasks');
if (!this.res.finalResponseSent) {
this.logger.info('auto-generating non-success response to invite');
this.res.send(603);
}
}
}
}
module.exports = CallSession;

112
lib/middleware.js Normal file
View File

@@ -0,0 +1,112 @@
const debug = require('debug')('jambonz:feature-server');
const assert = require('assert');
const request = require('request');
//require('request-debug')(request);
const uuidv4 = require('uuid/v4');
const makeTask = require('./tasks/make_task');
module.exports = function(srf, logger) {
const {lookupAppByPhoneNumber} = srf.locals.dbHelpers;
function initLocals(req, res, next) {
req.locals = req.locals || {};
req.locals.logger = logger.child({callId: req.get('Call-ID')});
next();
}
/**
* Within the system, we deal with E.164 numbers _without_ the leading '+
*/
function normalizeNumbers(req, res, next) {
const logger = req.locals.logger;
Object.assign(req.locals, {
calledNumber: req.calledNumber,
callingNumber: req.callingNumber
});
try {
const regex = /^\+(\d+)$/;
let arr = regex.exec(req.calledNumber);
if (arr) req.locals.calledNumber = arr[1];
arr = regex.exec(req.callingNumber);
if (arr) req.locals.callingNumber = arr[1];
} catch (err) {
logger.error(err, `${req.get('Call-ID')} Error performing regex`);
}
next();
}
/**
* Given the dialed DID/phone number, retrieve the application to invoke
*/
async function retrieveApplication(req, res, next) {
const logger = req.locals.logger;
try {
const app = req.locals.application = await lookupAppByPhoneNumber(req.locals.calledNumber);
if (!app) {
logger.info(`rejecting call to DID ${req.locals.calledNumber}: no application associated`);
return res.send(480, {
headers: {
'X-Reason': 'no configured application'
}
});
}
logger.debug(app, `retrieved application for ${req.locals.calledNumber}`);
next();
} catch (err) {
logger.error(err, `${req.get('Call-ID')} Error looking up application for ${req.calledNumber}`);
res.send(500);
}
}
/**
* Invoke the application callback and get the initial set of instructions
*/
async function invokeWebCallback(req, res, next) {
const logger = req.locals.logger;
const call_sid = uuidv4();
const account_sid = req.locals.application.account_sid;
const application_sid = req.locals.application.application_sid;
try {
const app = req.locals.application;
assert(app && app.call_hook);
request.post({
url: app.call_hook,
json: true,
body: req.msg
}, (err, response, body) => {
if (err) {
logger.error(err, `Error invoking callback ${app.call_hook}`);
return res.send(603, 'Bad webhook');
}
logger.debug(body, 'application payload');
const taskData = Array.isArray(body) ? body : [body];
app.tasks = [];
for (const t in taskData) {
try {
const task = makeTask(logger, taskData[t]);
app.tasks.push(task);
} catch (err) {
logger.info({data: taskData[t]}, `invalid web callback payload: ${err.message}`);
res.send(500, 'Application Error', {
headers: {
'X-Reason': err.message
}
});
break;
}
}
if (!res.finalResponseSent) next();
});
} catch (err) {
logger.error(err, 'Error invoking web callback');
res.send(500);
}
}
return {
initLocals,
normalizeNumbers,
retrieveApplication,
invokeWebCallback
};
};

26
lib/tasks/dial.js Normal file
View File

@@ -0,0 +1,26 @@
const Task = require('./task');
const name = 'dial';
class TaskDial extends Task {
constructor(logger, opts) {
super(logger, opts);
this.name = name;
this.headers = this.data.headers || {};
}
static get name() { return name; }
/**
* Reject an incoming call attempt with a provided status code and (optionally) reason
*/
async exec(cs) {
if (!cs.res.finalResponseSent) {
cs.res.send(this.data.status, this.data.reason, {
headers: this.headers
});
}
return false;
}
}
module.exports = TaskDial;

22
lib/tasks/make_task.js Normal file
View File

@@ -0,0 +1,22 @@
const Task = require('./task');
const TaskSipDecline = require('./sip_decline');
const TaskDial = require('./dial');
const errBadInstruction = new Error('invalid instruction payload');
function makeTask(logger, opts) {
if (typeof opts !== 'object' || Array.isArray(opts)) throw errBadInstruction;
const keys = Object.keys(opts);
if (keys.length !== 1) throw errBadInstruction;
const name = keys[0];
const data = opts[name];
Task.validate(name, data);
switch (name) {
case TaskSipDecline.name: return new TaskSipDecline(logger, data);
case TaskDial.name: return new TaskDial(logger, data);
}
// should never reach
throw new Error(`invalid task ${name} (please update specs.json and make_task.js)`);
}
module.exports = makeTask;

26
lib/tasks/sip_decline.js Normal file
View File

@@ -0,0 +1,26 @@
const Task = require('./task');
const name = 'sip:decline';
class TaskSipDecline extends Task {
constructor(logger, opts) {
super(logger, opts);
this.name = name;
this.headers = this.data.headers || {};
}
static get name() { return name; }
/**
* Reject an incoming call attempt with a provided status code and (optionally) reason
*/
async exec(cs) {
if (!cs.res.finalResponseSent) {
cs.res.send(this.data.status, this.data.reason, {
headers: this.headers
});
}
return false;
}
}
module.exports = TaskSipDecline;

97
lib/tasks/specs.json Normal file
View File

@@ -0,0 +1,97 @@
{
"sip:decline": {
"properties": {
"status": "number",
"reason": "string",
"headers": "object"
},
"required": [
"status"
]
},
"dial": {
"properties": {
"action": "string",
"answerOnBridge": "boolean",
"callerId": "string",
"dialMusic": "string",
"method": {
"type": "string",
"enum": ["GET", "POST"]
},
"target": ["#target"],
"timeLimit": "number",
"timeout": "number",
"headers": "object",
"strategy": {
"type": "string",
"enum": ["hunt", "simring"]
},
"transcribe": "#transcribe",
"listen": "#listen"
},
"required": [
"target"
]
},
"listen": {
"properties": {
"metadata": "object",
"mixType": {
"type": "string",
"enum": ["mono", "stereo", "mixed"]
},
"passDtmf": "boolean",
"sampleRate": "number",
"source": {
"type": "string",
"enum": ["parent", "child", "both"]
},
"wsUrl": "string"
},
"required": [
"wsUrl",
"sampleRate"
]
},
"transcribe": {
"properties": {
"action": "string",
"interim": "boolean",
"jsonKey": "string",
"language": "string",
"source": "string",
"vendor": "string"
},
"required": [
"action",
"jsonKey",
"language"
]
},
"target": {
"properties": {
"type": {
"type": "string",
"enum": ["phone", "sip", "user"]
},
"number": "string",
"uri": "string",
"auth": "#auth",
"name": "string"
},
"required": [
"type"
]
},
"auth": {
"properties": {
"user": "string",
"password": "string"
},
"required": [
"user",
"password"
]
}
}

66
lib/tasks/task.js Normal file
View File

@@ -0,0 +1,66 @@
const debug = require('debug')('jambonz:feature-server');
const assert = require('assert');
const specs = new Map();
const _specData = require('./specs');
for (const key in _specData) {specs.set(key, _specData[key]);}
class Task {
constructor(logger, data) {
this.logger = logger;
this.data = data;
}
static validate(name, data) {
debug(`validating ${name} with data ${JSON.stringify(data)}`);
// validate the instruction is supported
if (!specs.has(name)) throw new Error(`invalid instruction: ${name}`);
// check type of each element and make sure required elements are present
const specData = specs.get(name);
let required = specData.required || [];
for (const dKey in data) {
if (dKey in specData.properties) {
const dVal = data[dKey];
const dSpec = specData.properties[dKey];
if (typeof dSpec === 'string' && ['number', 'string', 'object', 'boolean'].includes(dSpec)) {
// simple types
if (typeof dVal !== specData.properties[dKey]) {
throw new Error(`${name}: property ${dKey} has invalid data type`);
}
}
else if (Array.isArray(dSpec) && dSpec[0].startsWith('#')) {
const name = dSpec[0].slice(1);
for (const item of dVal) {
Task.validate(name, item);
}
}
else if (typeof dSpec === 'object') {
// complex types
const type = dSpec.type;
assert.ok(['number', 'string', 'object', 'boolean'].includes(type),
`invalid or missing type in spec ${JSON.stringify(dSpec)}`);
if (type === 'string' && dSpec.enum) {
assert.ok(Array.isArray(dSpec.enum), `enum must be an array ${JSON.stringify(dSpec.enum)}`);
if (!dSpec.enum.includes(dVal)) throw new Error(`invalid value ${dVal} must be one of ${dSpec.enum}`);
}
}
else if (typeof dSpec === 'string' && dSpec.startsWith('#')) {
// reference to another datatype (i.e. nested type)
// TODO: validate recursively
const name = dSpec.slice(1);
Task.validate(name, dVal);
}
else {
assert.ok(0, `invalid spec ${JSON.stringify(dSpec)}`);
}
required = required.filter((item) => item !== dKey);
}
else throw new Error(`${name}: unknown property ${dKey}`);
}
if (required.length > 0) throw new Error(`${name}: missing value for ${required}`);
}
}
module.exports = Task;