mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2025-12-20 08:40:38 +00:00
initial checkin
This commit is contained in:
38
lib/call-session.js
Normal file
38
lib/call-session.js
Normal 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
112
lib/middleware.js
Normal 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
26
lib/tasks/dial.js
Normal 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
22
lib/tasks/make_task.js
Normal 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
26
lib/tasks/sip_decline.js
Normal 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
97
lib/tasks/specs.json
Normal 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
66
lib/tasks/task.js
Normal 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;
|
||||
|
||||
Reference in New Issue
Block a user