diff --git a/app.js b/app.js
index 747f7319..1cd6001d 100644
--- a/app.js
+++ b/app.js
@@ -59,7 +59,6 @@ srf.invite((req, res) => {
session.exec();
});
-
// HTTP
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
diff --git a/lib/http-routes/api/create-call.js b/lib/http-routes/api/create-call.js
index 049999c0..4f226109 100644
--- a/lib/http-routes/api/create-call.js
+++ b/lib/http-routes/api/create-call.js
@@ -1,17 +1,18 @@
const config = require('config');
const router = require('express').Router();
-const sysError = require('./error');
const makeTask = require('../../tasks/make_task');
const RestCallSession = require('../../session/rest-call-session');
const CallInfo = require('../../session/call-info');
const {CallDirection, CallStatus} = require('../../utils/constants');
-const parseUrl = require('parse-url');
const SipError = require('drachtio-srf').SipError;
const Srf = require('drachtio-srf');
+const sysError = require('./error');
const drachtio = config.get('outdials.drachtio');
const sbcs = config.get('outdials.sbc');
const Mrf = require('drachtio-fsmrf');
const installSrfLocals = require('../../utils/install-srf-locals');
+const Requestor = require('./utils/requestor');
+
let idxDrachtio = 0;
let idxSbc = 0;
let srfs = [];
@@ -57,41 +58,16 @@ function getSrfForOutdial(logger) {
});
}
-async function validate(logger, payload) {
- const data = Object.assign({}, {
- from: payload.from,
- to: payload.to,
- call_hook: payload.call_hook
- });
-
- const u = parseUrl(payload.call_hook.url);
- const myPort = u.port ? `:${u.port}` : '';
- payload.originalRequest = {
- baseUrl: `${u.protocol}://${u.resource}${myPort}`,
- method: payload.call_hook.method
- };
- if (payload.call_hook.username && payload.call_hook.password) {
- payload.originalRequest.auth = {
- username: payload.call_hook.username,
- password: payload.call_hook.password
- };
- }
-
- return makeTask(logger, {'rest:dial': data});
-}
-
router.post('/', async(req, res) => {
const logger = req.app.locals.logger;
logger.debug({body: req.body}, 'got createCall request');
try {
let uri, cs, to;
- const restDial = await validate(logger, req.body);
+ const restDial = makeTask(logger, {'rest:dial': req.body});
const sbcAddress = sbcs[idxSbc++ % sbcs.length];
const srf = await getSrfForOutdial(logger);
const target = restDial.to;
- const opts = {
- 'callingNumber': restDial.from
- };
+ const opts = { callingNumber: restDial.from };
switch (target.type) {
case 'phone':
@@ -130,8 +106,24 @@ router.post('/', async(req, res) => {
localSdp: ep.local.sdp
});
if (target.auth) opts.auth = this.target.auth;
- const application = req.body;
+
+ /**
+ * create our application object -
+ * not from the database as per an inbound call,
+ * but from the provided params in the request
+ */
+ const app = req.body;
+
+ /**
+ * attach our requestor and notifier objects
+ * these will be used for all http requests we make during this call
+ */
+ app.requestor = new Requestor(this.logger, app.call_hook);
+ if (app.call_status_hook) app.notifier = new Requestor(this.logger, app.call_status_hook);
+ else app.notifier = {request: () => {}};
+
+ /* now launch the outdial */
try {
const dlg = await srf.createUAC(uri, opts, {
cbRequest: (err, inviteReq) => {
@@ -140,18 +132,18 @@ router.post('/', async(req, res) => {
res.status(500).send('Call Failure');
ep.destroy();
}
+ /* ok our outbound NVITE is in flight */
- /* call is in flight */
const tasks = [restDial];
const callInfo = new CallInfo({
direction: CallDirection.Outbound,
req: inviteReq,
to,
- tag: req.body.tag,
+ tag: app.tag,
accountSid: req.body.account_sid,
- applicationSid: req.body.application_sid
+ applicationSid: app.application_sid
});
- cs = new RestCallSession({logger, application, srf, req: inviteReq, ep, tasks, callInfo});
+ cs = new RestCallSession({logger, application: app, srf, req: inviteReq, ep, tasks, callInfo});
cs.exec(req);
res.status(201).json({sid: cs.callSid});
@@ -191,7 +183,6 @@ router.post('/', async(req, res) => {
} catch (err) {
sysError(logger, res, err);
}
-
});
module.exports = router;
diff --git a/lib/http-routes/api/update-call.js b/lib/http-routes/api/update-call.js
index ffd42caf..d4bfc2a7 100644
--- a/lib/http-routes/api/update-call.js
+++ b/lib/http-routes/api/update-call.js
@@ -31,6 +31,10 @@ function retrieveCallSession(callSid, opts) {
return cs;
}
+
+/**
+ * update a call
+ */
router.post('/:callSid', async(req, res) => {
const logger = req.app.locals.logger;
const callSid = req.params.callSid;
@@ -42,7 +46,7 @@ router.post('/:callSid', async(req, res) => {
return res.sendStatus(404);
}
res.sendStatus(202);
- cs.updateCall(req.body);
+ cs.updateCall(req.body, callSid);
} catch (err) {
sysError(logger, res, err);
}
diff --git a/lib/middleware.js b/lib/middleware.js
index 02614c3e..ddd2b326 100644
--- a/lib/middleware.js
+++ b/lib/middleware.js
@@ -1,9 +1,9 @@
-//const debug = require('debug')('jambonz:feature-server');
const uuidv4 = require('uuid/v4');
const {CallDirection} = require('./utils/constants');
const CallInfo = require('./session/call-info');
-const retrieveApp = require('./utils/retrieve-app');
-const parseUrl = require('parse-url');
+const Requestor = require('./utils/requestor');
+const makeTask = require('./tasks/make_task');
+const normalizeJamones = require('./utils/normalize-jamones');
module.exports = function(srf, logger) {
const {lookupAppByPhoneNumber, lookupApplicationBySid} = srf.locals.dbHelpers;
@@ -50,12 +50,9 @@ module.exports = function(srf, logger) {
const logger = req.locals.logger;
try {
let app;
- if (req.locals.application_sid) {
- app = await lookupApplicationBySid(req.locals.application_sid);
- }
- else {
- app = await lookupAppByPhoneNumber(req.locals.calledNumber);
- }
+ if (req.locals.application_sid) app = await lookupApplicationBySid(req.locals.application_sid);
+ else app = await lookupAppByPhoneNumber(req.locals.calledNumber);
+
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, {
@@ -65,6 +62,14 @@ module.exports = function(srf, logger) {
});
}
+ /**
+ * create a requestor that we will use for all http requests we make during the call.
+ * also create a notifier for call status events (if not needed, its a no-op).
+ */
+ app.requestor = new Requestor(this.logger, app.call_hook);
+ if (app.call_status_hook) app.notifier = new Requestor(this.logger, app.call_status_hook);
+ else app.notifier = {request: () => {}};
+
req.locals.application = app;
logger.debug(app, `retrieved application for ${req.locals.calledNumber}`);
req.locals.callInfo = new CallInfo({req, app, direction: CallDirection.Inbound});
@@ -81,26 +86,13 @@ module.exports = function(srf, logger) {
async function invokeWebCallback(req, res, next) {
const logger = req.locals.logger;
const app = req.locals.application;
- const call_hook = app.call_hook;
- const method = call_hook.method.toUpperCase();
- let auth;
- if (call_hook.username && call_hook.password) {
- auth = {username: call_hook.username, password: call_hook.password};
- }
try {
- const u = parseUrl(call_hook.url);
- const myPort = u.port ? `:${u.port}` : '';
- app.originalRequest = {
- baseUrl: `${u.protocol}://${u.resource}${myPort}`,
- auth,
- method
- };
- logger.debug({url: call_hook.url, method}, 'invokeWebCallback');
- const obj = Object.assign({}, req.locals.callInfo);
-
- // if the call hook is a POST add the entire SIP message to the payload
- if (method === 'POST') obj.sip = req.msg;
- app.tasks = await retrieveApp(logger, call_hook.url, method, auth, obj);
+ /* retrieve the application to execute for this inbound call */
+ const params = Object.assign(app.call_hook.method === 'POST' ? {sip: req.msg} : {},
+ req.locals.callInfo);
+ const json = await app.requestor.request(app.call_hook, params);
+ app.tasks = normalizeJamones(logger, json).map((tdata) => makeTask(logger, tdata));
+ if (0 === app.tasks.length) throw new Error('no application provided');
next();
} catch (err) {
logger.info(`Error retrieving or parsing application: ${err.message}`);
diff --git a/lib/session/call-info.js b/lib/session/call-info.js
index 459b9488..713bbe4d 100644
--- a/lib/session/call-info.js
+++ b/lib/session/call-info.js
@@ -1,6 +1,10 @@
const {CallDirection, CallStatus} = require('../utils/constants');
const uuidv4 = require('uuid/v4');
+/**
+ * @classdesc Represents the common information for all calls
+ * that is provided in call status webhooks
+ */
class CallInfo {
constructor(opts) {
this.direction = opts.direction;
@@ -48,11 +52,20 @@ class CallInfo {
}
}
+ /**
+ * update the status of the call
+ * @param {string} callStatus - current call status
+ * @param {number} sipStatus - current sip status
+ */
updateCallStatus(callStatus, sipStatus) {
this.callStatus = callStatus;
if (sipStatus) this.sipStatus = sipStatus;
}
+ /**
+ * associate customer-provided data with the call information.
+ * this information will be provided with every call status callhook
+ */
set customerData(obj) {
this._customerData = obj;
}
@@ -84,6 +97,7 @@ class CallInfo {
}
return obj;
}
+
}
module.exports = CallInfo;
diff --git a/lib/session/call-session.js b/lib/session/call-session.js
index 22dfb7b1..3abfd79c 100644
--- a/lib/session/call-session.js
+++ b/lib/session/call-session.js
@@ -1,13 +1,31 @@
const Emitter = require('events');
const config = require('config');
const {CallDirection, TaskPreconditions, CallStatus, TaskName} = require('../utils/constants');
-const hooks = require('../utils/notifiers');
const moment = require('moment');
const assert = require('assert');
const sessionTracker = require('./session-tracker');
+const makeTask = require('../tasks/make_task');
+const normalizeJamones = require('../utils/normalize-jamones');
+const list = require('../utils/summarize-tasks');
const BADPRECONDITIONS = 'preconditions not met';
+/**
+ * @classdesc Represents the execution context for a call.
+ * It holds the resources, such as the sip dialog and media server endpoint
+ * that are needed by Tasks that are operating on the call.
+ * CallSession is a superclass object that is extended by specific types
+ * of sessions, such as InboundCallSession, RestCallSession and others.
+ */
class CallSession extends Emitter {
+ /**
+ *
+ * @param {object} opts
+ * @param {logger} opts.logger - a pino logger
+ * @param {object} opts.application - the application to execute
+ * @param {Srf} opts.srf - the Srf instance
+ * @param {array} opts.tasks - tasks we are to execute
+ * @param {callInfo} opts.callInfo - information about the call
+ */
constructor({logger, application, srf, tasks, callInfo}) {
super();
this.logger = logger;
@@ -16,9 +34,6 @@ class CallSession extends Emitter {
this.callInfo = callInfo;
this.tasks = tasks;
- const {notifyHook} = hooks(this.logger, this.callInfo);
- this.notifyHook = notifyHook;
-
this.updateCallStatus = srf.locals.dbHelpers.updateCallStatus;
this.serviceUrl = srf.locals.serviceUrl;
@@ -29,65 +44,115 @@ class CallSession extends Emitter {
sessionTracker.add(this.callSid, this);
}
+ /**
+ * callSid for the call being handled by the session
+ */
get callSid() {
return this.callInfo.callSid;
}
- get originalRequest() {
- return this.application.originalRequest;
- }
-
+ /**
+ * direction of the call: inbound or outbound
+ */
get direction() {
return this.callInfo.direction;
}
+ /**
+ * SIP call-id for the call
+ */
get callId() {
return this.callInfo.direction;
}
+ /**
+ * http endpoint to send call status updates to
+ */
get call_status_hook() {
return this.application.call_status_hook;
}
+ /**
+ * can be used for all http requests within this session
+ */
+ get requestor() {
+ assert(this.application.requestor);
+ return this.application.requestor;
+ }
+
+ /**
+ * can be used for all http call status notifications within this session
+ */
+ get notifier() {
+ assert(this.application.notifier);
+ return this.application.notifier;
+ }
+
+ /**
+ * default vendor to use for speech synthesis if not provided in the app
+ */
get speechSynthesisVendor() {
return this.application.speech_synthesis_vendor;
}
+ /**
+ * default voice to use for speech synthesis if not provided in the app
+ */
get speechSynthesisVoice() {
return this.application.speech_synthesis_voice;
}
+ /**
+ * default vendor to use for speech recognition if not provided in the app
+ */
get speechRecognizerVendor() {
return this.application.speech_recognizer_vendor;
}
+ /**
+ * default language to use for speech recognition if not provided in the app
+ */
get speechRecognizerLanguage() {
return this.application.speech_recognizer_language;
}
+ /**
+ * indicates whether the call currently in progress
+ */
get hasStableDialog() {
return this.dlg && this.dlg.connected;
}
+ /**
+ * indicates whether call is currently in a ringing state (ie not yet answered)
+ */
get isOutboundCallRinging() {
return this.direction === CallDirection.Outbound && this.req && !this.dlg;
}
+ /**
+ * returns true if the call is an inbound call and a final sip response has been sent
+ */
get isInboundCallAnswered() {
return this.direction === CallDirection.Inbound && this.res.finalResponseSent;
}
+ /**
+ * execute the tasks in the CallSession. The tasks are executed in sequence until
+ * they complete, or the caller hangs up.
+ * @async
+ */
async exec() {
- this.logger.info(`CallSession:exec starting task list with ${this.tasks.length} tasks`);
+ this.logger.info({tasks: list(this.tasks)}, `CallSession:exec starting ${this.tasks.length} tasks`);
while (this.tasks.length && !this.callGone) {
const taskNum = ++this.taskIdx;
const stackNum = this.stackIdx;
const task = this.tasks.shift();
- this.logger.debug({task}, `CallSession:exec starting task #${stackNum}:${taskNum}: ${task.name}`);
+ this.logger.info(`CallSession:exec starting task #${stackNum}:${taskNum}: ${task.name}`);
try {
const resources = await this._evaluatePreconditions(task);
this.currentTask = task;
await task.exec(this, resources);
this.currentTask = null;
- this.logger.debug(`CallSession:exec completed task #${stackNum}:${taskNum}: ${task.name}`);
+ this.logger.info(`CallSession:exec completed task #${stackNum}:${taskNum}: ${task.name}`);
} catch (err) {
this.currentTask = null;
if (err.message.includes(BADPRECONDITIONS)) {
@@ -103,16 +168,23 @@ class CallSession extends Emitter {
// all done - cleanup
this.logger.info('CallSession:exec all tasks complete');
this._onTasksDone();
- this._clearCalls();
- this.ms && this.ms.destroy();
+ this._clearResources();
sessionTracker.remove(this.callSid);
}
+ /**
+ * This is called when all tasks have completed. It is not implemented in the superclass
+ * but provided as a convenience for subclasses that need to do cleanup at the end of
+ * the call session.
+ */
_onTasksDone() {
// meant to be implemented by subclass if needed
}
+ /**
+ * this is called to clean up when the call is released from one side or another
+ */
_callReleased() {
this.logger.debug('CallSession:_callReleased - caller hung up');
this.callGone = true;
@@ -122,25 +194,12 @@ class CallSession extends Emitter {
}
}
- normalizeUrl(url, method, auth) {
- const hook = {url, method};
- if (auth && auth.username && auth.password) Object.assign(hook, auth);
-
- if (url.startsWith('/')) {
- const or = this.originalRequest;
- if (or) {
- hook.url = `${or.baseUrl}${url}`;
- hook.method = hook.method || or.method || 'POST';
- if (!hook.auth && or.auth) Object.assign(hook, or.auth);
- }
- }
- this.logger.debug({hook}, 'Task:normalizeUrl');
- return hook;
- }
-
- async updateCall(opts) {
- this.logger.debug(opts, 'CallSession:updateCall');
-
+ /**
+ * perform live call control - update call status
+ * @param {obj} opts
+ * @param {string} opts.call_status - 'complete' or 'no-answer'
+ */
+ _lccCallStatus(opts) {
if (opts.call_status === CallStatus.Completed && this.dlg) {
this.logger.info('CallSession:updateCall hanging up call due to request from api');
this._callerHungup();
@@ -159,25 +218,139 @@ class CallSession extends Emitter {
}
}
}
- else if (opts.call_hook && opts.call_hook.url) {
- const hook = this.normalizeUrl(opts.call_hook.url, opts.call_hook.method, opts.call_hook.auth);
- this.logger.info({hook}, 'CallSession:updateCall replacing application due to request from api');
- const {actionHook} = hooks(this.logger, this.callInfo);
- if (opts.call_status_hook) this.call_status_hook = opts.call_status_hook;
- const tasks = await actionHook(hook);
- this.logger.info({tasks}, 'CallSession:updateCall new task list');
- this.replaceApplication(tasks);
+ }
+
+ /**
+ * perform live call control -- set a new call_hook
+ * @param {object} opts
+ * @param {object} opts.call_hook - new call_hook to transfer to
+ * @param {object} [opts.call_hook] - new call_status_hook
+ */
+ async _lccCallHook(opts) {
+ const tasks = await this.requestor(opts.call_hook, this.callInfo);
+
+ //TODO: if they gave us a call status hook, we should replace
+ //the existing one (or just remove this option altogether?)
+
+ this.logger.info({tasks}, 'CallSession:updateCall new task list');
+ this.replaceApplication(tasks);
+ }
+
+ /**
+ * perform live call control -- change listen status
+ * @param {object} opts
+ * @param {string} opts.listen_status - 'pause' or 'resume'
+ */
+ async _lccListenStatus(opts) {
+ const task = this.currentTask;
+ if (!task || ![TaskName.Dial, TaskName.Listen].includes(task.name)) {
+ return this.logger.info(`CallSession:updateCall - invalid listen_status in task ${task.name}`);
}
- else if (opts.listen_status) {
- const task = this.currentTask;
- if (!task || ![TaskName.Dial, TaskName.Listen].includes(task.name)) {
- return this.logger.info(`CallSession:updateCall - disregarding listen_status in task ${task.name}`);
- }
- const listenTask = task.name === TaskName.Listen ? task : task.listenTask;
- if (!listenTask) {
- return this.logger.info('CallSession:updateCall - disregarding listen_status as Dial does not have a listen');
- }
- listenTask.updateListen(opts.listen_status);
+ const listenTask = task.name === TaskName.Listen ? task : task.listenTask;
+ if (!listenTask) {
+ return this.logger.info('CallSession:updateCall - invalid listen_status: Dial does not have a listen');
+ }
+ listenTask.updateListen(opts.listen_status);
+ }
+
+ async _lccMuteStatus(callSid, mute) {
+ // this whole thing requires us to be in a Dial verb
+ const task = this.currentTask;
+ if (!task || TaskName.Dial !== task.name) {
+ return this.logger.info('CallSession:_lccMute - invalid command as dial is not active');
+ }
+ // now do the whisper
+ task.mute(callSid, mute).catch((err) => this.logger.error(err, 'CallSession:_lccMute'));
+ }
+
+ /**
+ * perform live call control -- whisper to one party or the other on a call
+ * @param {array} opts - array of play or say tasks
+ */
+ async _lccWhisper(opts, callSid) {
+ const {whisper} = opts;
+ let tasks;
+
+ // this whole thing requires us to be in a Dial verb
+ const task = this.currentTask;
+ if (!task || TaskName.Dial !== task.name) {
+ return this.logger.info('CallSession:_lccWhisper - invalid command since we are not in a dial');
+ }
+
+ // allow user to provide a url object, a url string, an array of tasks, or a single task
+ if (typeof whisper === 'string' || (typeof whisper === 'object' && whisper.url)) {
+ // retrieve a url
+ const json = await this.requestor(opts.call_hook, this.callInfo);
+ tasks = normalizeJamones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
+ }
+ else if (Array.isArray(whisper)) {
+ // an inline array of tasks
+ tasks = normalizeJamones(this.logger, whisper).map((tdata) => makeTask(this.logger, tdata));
+ }
+ else if (typeof whisper === 'object') {
+ // a single task
+ tasks = normalizeJamones(this.logger, [whisper]).map((tdata) => makeTask(this.logger, tdata));
+ }
+ else {
+ this.logger.info({opts}, 'CallSession:_lccWhisper invalid options were provided');
+ return;
+ }
+ this.logger.debug(`CallSession:_lccWhisper got ${tasks.length} tasks`);
+
+ // only say or play allowed
+ if (tasks.find((t) => ![TaskName.Say, TaskName.Play].includes(t.name))) {
+ this.logger.info('CallSession:_lccWhisper invalid options where provided');
+ return;
+ }
+
+ //multiple loops not allowed
+ tasks.forEach((t) => t.loop = 1);
+
+ // now do the whisper
+ this.logger.debug(`CallSession:_lccWhisper executing ${tasks.length} tasks`);
+ task.whisper(tasks, callSid).catch((err) => this.logger.error(err, 'CallSession:_lccWhisper'));
+ }
+
+ /**
+ * perform live call control -- mute or unmute an endpoint
+ * @param {array} opts - array of play or say tasks
+ */
+ async _lccMute(callSid, mute) {
+
+ // this whole thing requires us to be in a Dial verb
+ const task = this.currentTask;
+ if (!task || TaskName.Dial !== task.name) {
+ return this.logger.info('CallSession:_lccMute - not possible since we are not in a dial');
+ }
+
+ task.mute(callSid, mute).catch((err) => this.logger.error(err, 'CallSession:_lccMute'));
+ }
+
+ /**
+ * perform live call control
+ * @param {object} opts - update instructions
+ * @param {string} callSid - identifies call toupdate
+ */
+ async updateCall(opts, callSid) {
+ this.logger.debug(opts, 'CallSession:updateCall');
+
+ if (opts.call_status) {
+ return this._lccCallStatus(opts);
+ }
+ if (opts.call_hook) {
+ return await this._lccCallHook(opts);
+ }
+ if (opts.listen_status) {
+ await this._lccListenStatus(opts);
+ }
+ else if (opts.mute_status) {
+ await this._lccMuteStatus(callSid, opts.mute_status === 'mute');
+ }
+
+ // whisper may be the only thing we are asked to do, or it may that
+ // we are doing a whisper after having muted, paused reccording etc..
+ if (opts.whisper) {
+ return this._lccWhisper(opts, callSid);
}
}
@@ -186,6 +359,10 @@ class CallSession extends Emitter {
* NB: any tasks in the current stack that have not been executed are flushed
*/
replaceApplication(tasks) {
+ if (this.callGone) {
+ this.logger.debug('CallSession:replaceApplication - ignoring because call is gone');
+ return;
+ }
this.tasks = tasks;
this.taskIdx = 0;
this.stackIdx++;
@@ -211,6 +388,10 @@ class CallSession extends Emitter {
}
}
+ /**
+ * Configure call state so as to make a media endpoint available
+ * @param {Task} task - task to be executed
+ */
async _evalEndpointPrecondition(task) {
if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`);
@@ -229,10 +410,11 @@ class CallSession extends Emitter {
// we are going from an early media connection to answer
await answerCall();
+ return this.ep;
}
+ // need to allocate an endpoint
try {
- // need to allocate an endpoint
if (!this.ms) this.ms = await this.getMS();
const ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
ep.cs = this;
@@ -256,12 +438,20 @@ class CallSession extends Emitter {
}
}
+ /**
+ * Configure call state so as to make a sip dialog available
+ * @param {Task} task - task to be executed
+ */
_evalStableCallPrecondition(task) {
if (this.callGone) throw new Error(`${BADPRECONDITIONS}: call gone`);
if (!this.dlg) throw new Error(`${BADPRECONDITIONS}: call was not answered`);
return this.dlg;
}
+ /**
+ * Throws an error if call has already been answered
+ * @param {Task} task - task to be executed
+ */
_evalUnansweredCallPrecondition(task, callSid) {
if (!this.req) throw new Error('invalid precondition unanswered_call for outbound call');
if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`);
@@ -271,15 +461,30 @@ class CallSession extends Emitter {
return {req: this.req, res: this.res};
}
- _clearCalls() {
- if (this.dlg && this.dlg.connected) this.dlg.destroy();
- if (this.ep && this.ep.connected) this.ep.destroy();
+ /**
+ * Hang up the call and free the media endpoint
+ */
+ async _clearResources() {
+ for (const resource of [this.dlg, this.ep, this.ms]) {
+ try {
+ if (resource && resource.connected) await resource.destroy();
+ } catch (err) {
+ this.logger.error(err, 'CallSession:_clearResources error');
+ }
+ }
}
+ /**
+ * called when the caller has hung up. Provided for subclasses to override
+ * in order to apply logic at this point if needed.
+ */
_callerHungup() {
assert(false, 'subclass responsibility to override this method');
}
+ /**
+ * get a media server to use for this call
+ */
async getMS() {
if (!this.ms) {
const mrf = this.srf.locals.mrf;
@@ -288,6 +493,10 @@ class CallSession extends Emitter {
return this.ms;
}
+ /**
+ * create and endpoint if we don't have one; otherwise simply return
+ * the current media server and endpoint that are associated with this call
+ */
async createOrRetrieveEpAndMs() {
const mrf = this.srf.locals.mrf;
if (this.ms && this.ep) return {ms: this.ms, ep: this.ep};
@@ -302,16 +511,24 @@ class CallSession extends Emitter {
return {ms: this.ms, ep: this.ep};
}
+ /**
+ * Called any time call status changes. This method both invokes the
+ * call_status_hook callback as well as updates the realtime database
+ * with latest call status
+ * @param {object} opts
+ * @param {string} callStatus - current call status
+ * @param {number} sipStatus - current sip status
+ * @param {number} [duration] - duration of a completed call, in seconds
+ */
_notifyCallStatusChange({callStatus, sipStatus, duration}) {
assert((typeof duration === 'number' && callStatus === CallStatus.Completed) ||
(!duration && callStatus !== CallStatus.Completed),
'duration MUST be supplied when call completed AND ONLY when call completed');
- const call_status_hook = this.call_status_hook;
this.callInfo.updateCallStatus(callStatus, sipStatus);
if (typeof duration === 'number') this.callInfo.duration = duration;
try {
- if (call_status_hook) this.notifyHook(call_status_hook);
+ this.notifier.request(this.call_status_hook, this.callInfo);
} catch (err) {
this.logger.info(err, `CallSession:_notifyCallStatusChange error sending ${callStatus} ${sipStatus}`);
}
diff --git a/lib/session/confirm-call-session.js b/lib/session/confirm-call-session.js
index 8ab8f871..5f76e8bc 100644
--- a/lib/session/confirm-call-session.js
+++ b/lib/session/confirm-call-session.js
@@ -1,5 +1,12 @@
const CallSession = require('./call-session');
+/**
+ * @classdesc Subclass of CallSession. Represents a CallSession
+ * that is established for a dial verb that has a
+ * 'confirmUrl' application that is executed upon call answer.
+ * @extends CallSession
+
+ */
class ConfirmCallSession extends CallSession {
constructor({logger, application, dlg, ep, tasks, callInfo}) {
super({
@@ -17,7 +24,7 @@ class ConfirmCallSession extends CallSession {
/**
* empty implementation to override superclass so we do not delete dlg and ep
*/
- _clearCalls() {
+ _clearResources() {
}
}
diff --git a/lib/session/inbound-call-session.js b/lib/session/inbound-call-session.js
index f8f2e7fc..a063ffdf 100644
--- a/lib/session/inbound-call-session.js
+++ b/lib/session/inbound-call-session.js
@@ -3,6 +3,11 @@ const {CallStatus} = require('../utils/constants');
const moment = require('moment');
const assert = require('assert');
+/**
+ * @classdesc Subclass of CallSession. This represents a CallSession that is
+ * established for an inbound call.
+ * @extends CallSession
+ */
class InboundCallSession extends CallSession {
constructor(req, res) {
super({
@@ -34,46 +39,9 @@ class InboundCallSession extends CallSession {
}
}
- async connectInboundCallToIvr(earlyMedia = false) {
-
- // check for a stable inbound call already connected to the ivr
- if (this.ep && this.dlg) {
- this.logger.debug('CallSession:connectInboundCallToIvr - inbound call already connected to IVR');
- return {ep: this.ep, dlg: this.dlg};
- }
-
- // check for an early media connection, where caller wants same
- if (this.ep && earlyMedia) {
- this.logger.debug('CallSession:connectInboundCallToIvr - inbound call already has early media connection');
- return {ep: this.ep};
- }
-
- // ok, we need to connect the inbound call to the ivr
- try {
- assert(!this.req.finalResponseSent);
- this.logger.debug('CallSession:connectInboundCallToIvr - creating endpoint for inbound call');
- const {ep} = await this.createOrRetrieveEpAndMs();
- this.ep = ep;
-
- if (earlyMedia) {
- this.res.send(183, {body: ep.local.sdp});
- this.emit('callStatusChange', {sipStatus: 183, callStatus: CallStatus.EarlyMedia});
- return {ep, res: this.res};
- }
- const dlg = await this.srf.createUAS(this.req, this.res, {localSdp: ep.local.sdp});
- dlg.on('destroy', this._callerHungup.bind(this));
- dlg.connectTime = moment();
- this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress});
- this.logger.debug(`CallSession:connectInboundCallToIvr - answered callSid ${this.callSid}`);
- this.ep = ep;
- this.dlg = dlg;
- return {ep, dlg};
- } catch (err) {
- this.logger.error(err, 'CallSession:connectInboundCallToIvr error');
- throw err;
- }
- }
-
+ /**
+ * Answer the call, if it has not already been answered.
+ */
async propagateAnswer() {
if (!this.dlg) {
assert(this.ep);
@@ -85,6 +53,9 @@ class InboundCallSession extends CallSession {
}
}
+ /**
+ * This is invoked when the caller hangs up, in order to calculate the call duration.
+ */
_callerHungup() {
assert(this.dlg.connectTime);
const duration = moment().diff(this.dlg.connectTime, 'seconds');
@@ -92,7 +63,6 @@ class InboundCallSession extends CallSession {
this.logger.debug('InboundCallSession: caller hung up');
this._callReleased();
}
-
}
module.exports = InboundCallSession;
diff --git a/lib/session/rest-call-session.js b/lib/session/rest-call-session.js
index 9cafd49d..fdb122bf 100644
--- a/lib/session/rest-call-session.js
+++ b/lib/session/rest-call-session.js
@@ -2,6 +2,11 @@ const CallSession = require('./call-session');
const {CallStatus} = require('../utils/constants');
const moment = require('moment');
+/**
+ * @classdesc Subclass of CallSession. This represents a CallSession that is
+ * created for an outbound call that is initiated via the REST API.
+ * @extends CallSession
+ */
class RestCallSession extends CallSession {
constructor({logger, application, srf, req, ep, tasks, callInfo}) {
super({
@@ -19,12 +24,19 @@ class RestCallSession extends CallSession {
this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100});
}
+ /**
+ * Stores the sip dialog that is created when the far end answers.
+ * @param {Dialog} dlg - sip dialog
+ */
setDialog(dlg) {
this.dlg = dlg;
dlg.on('destroy', this._callerHungup.bind(this));
dlg.connectTime = moment();
}
+ /**
+ * This is invoked when the called party hangs up, in order to calculate the call duration.
+ */
_callerHungup() {
const duration = moment().diff(this.dlg.connectTime, 'seconds');
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
diff --git a/lib/session/session-tracker.js b/lib/session/session-tracker.js
index 11a0dfe5..b7943d1d 100644
--- a/lib/session/session-tracker.js
+++ b/lib/session/session-tracker.js
@@ -1,6 +1,11 @@
const Emitter = require('events');
const assert = require('assert');
+/**
+ * @classdesc This is a singleton class that tracks active sessions in a Map indexed
+ * by callSid. Its function is to allow us to accept inbound REST callUpdate requests
+ * for a callSid and to be able to retrieve and operate on the corresponding CallSession.
+ */
class SessionTracker extends Emitter {
constructor() {
super();
@@ -16,22 +21,39 @@ class SessionTracker extends Emitter {
return this._logger;
}
+ /**
+ * Adds a new CallSession to the Map
+ * @param {string} callSid
+ * @param {CallSession} callSession
+ */
add(callSid, callSession) {
assert(callSid);
this.sessions.set(callSid, callSession);
this.logger.info(`SessionTracker:add callSid ${callSid}, we have ${this.sessions.size} session being tracked`);
}
+ /**
+ * Removes a CallSession from the Map
+ * @param {string} callSid
+ */
remove(callSid) {
assert(callSid);
this.sessions.delete(callSid);
this.logger.info(`SessionTracker:remove callSid ${callSid}, we have ${this.sessions.size} being tracked`);
}
+ /**
+ * Checks if a given callSid is in the Map
+ * @param {string} callSid
+ */
has(callSid) {
return this.sessions.has(callSid);
}
+ /**
+ * Retrieves the active CallSession for a given callSid
+ * @param {string} callSid
+ */
get(callSid) {
return this.sessions.get(callSid);
}
diff --git a/lib/tasks/dial.js b/lib/tasks/dial.js
index 8aefb036..f6351931 100644
--- a/lib/tasks/dial.js
+++ b/lib/tasks/dial.js
@@ -3,9 +3,37 @@ const makeTask = require('./make_task');
const {CallStatus, CallDirection, TaskName, TaskPreconditions, MAX_SIMRINGS} = require('../utils/constants');
const assert = require('assert');
const placeCall = require('../utils/place-outdial');
+const sessionTracker = require('../session/session-tracker');
+const DtmfCollector = require('../utils/dtmf-collector');
const config = require('config');
const debug = require('debug')('jambonz:feature-server');
+function parseDtmfOptions(logger, dtmfCapture) {
+ let parentDtmfCollector, childDtmfCollector;
+ const parentKeys = [], childKeys = [];
+
+ if (Array.isArray(dtmfCapture)) {
+ Array.prototype.push.apply(parentKeys, dtmfCapture);
+ Array.prototype.push.apply(childKeys, dtmfCapture);
+ }
+ else if (dtmfCapture.childCall || dtmfCapture.parentCall) {
+ if (dtmfCapture.childCall && Array.isArray(dtmfCapture.childCall)) {
+ Array.prototype.push.apply(childKeys, dtmfCapture.childCall);
+ }
+ if (dtmfCapture.parentCall && Array.isArray(dtmfCapture.parentCall)) {
+ Array.prototype.push.apply(childKeys, dtmfCapture.parentCall);
+ }
+ }
+ if (childKeys.length) {
+ childDtmfCollector = new DtmfCollector({logger, patterns: childKeys});
+ }
+ if (parentKeys.length) {
+ parentDtmfCollector = new DtmfCollector({logger, patterns: parentKeys});
+ }
+
+ return {childDtmfCollector, parentDtmfCollector};
+}
+
function compareTasks(t1, t2) {
if (t1.type !== t2.type) return false;
switch (t1.type) {
@@ -44,6 +72,7 @@ class TaskDial extends Task {
super(logger, opts);
this.preconditions = TaskPreconditions.None;
+ this.actionHook = this.data.actionHook;
this.earlyMedia = this.data.answerOnBridge === true;
this.callerId = this.data.callerId;
this.dialMusic = this.data.dialMusic;
@@ -52,8 +81,19 @@ class TaskDial extends Task {
this.target = filterAndLimit(this.logger, this.data.target);
this.timeout = this.data.timeout || 60;
this.timeLimit = this.data.timeLimit;
- this.confirmUrl = this.data.confirmUrl;
+ this.confirmHook = this.data.confirmHook;
this.confirmMethod = this.data.confirmMethod;
+ this.dtmfHook = this.data.dtmfHook;
+
+ if (this.dtmfHook) {
+ const {parentDtmfCollector, childDtmfCollector} = parseDtmfOptions(logger, this.data.dtmfCapture || {});
+ if (parentDtmfCollector) {
+ this.parentDtmfCollector = parentDtmfCollector;
+ }
+ if (childDtmfCollector) {
+ this.childDtmfCollector = childDtmfCollector;
+ }
+ }
if (this.data.listen) {
this.listenTask = makeTask(logger, {'listen': this.data.listen}, this);
@@ -83,9 +123,15 @@ class TaskDial extends Task {
if (cs.direction === CallDirection.Inbound) {
await this._initializeInbound(cs);
}
+ else {
+ this.epOther = cs.ep;
+ }
+ this._installDtmfDetection(cs, this.epOther, this.parentDtmfCollector);
await this._attemptCalls(cs);
await this.awaitTaskDone();
- await this.performAction(this.method, null, this.results);
+ await cs.requestor.request(this.actionHook, Object.assign({}, cs.callInfo, this.results));
+ this._removeDtmfDetection(cs, this.epOther);
+ this._removeDtmfDetection(cs, this.ep);
} catch (err) {
this.logger.error(`TaskDial:exec terminating with error ${err.message}`);
this.kill();
@@ -94,17 +140,57 @@ class TaskDial extends Task {
async kill() {
super.kill();
+ this._removeDtmfDetection(this.cs, this.epOther);
+ this._removeDtmfDetection(this.cs, this.ep);
this._killOutdials();
if (this.sd) {
this.sd.kill();
this.sd = null;
}
+ sessionTracker.remove(this.callSid);
if (this.listenTask) await this.listenTask.kill();
if (this.transcribeTask) await this.transcribeTask.kill();
if (this.timerMaxCallDuration) clearTimeout(this.timerMaxCallDuration);
this.notifyTaskDone();
}
+ /**
+ * whisper a prompt to one side of the call
+ * @param {*} tasks - array of play/say tasks to execute
+ */
+ async whisper(tasks, callSid) {
+ if (!this.epOther || !this.ep) return this.logger.info('Dial:whisper: no paired endpoint found');
+ try {
+ const cs = this.callSession;
+ this.logger.debug('Dial:whisper unbridging endpoints');
+ await this.epOther.unbridge();
+ this.logger.debug('Dial:whisper executing tasks');
+ while (tasks.length && !cs.callGone) {
+ const task = tasks.shift();
+ await task.exec(cs, callSid === this.callSid ? this.ep : this.epOther);
+ }
+ this.logger.debug('Dial:whisper tasks complete');
+ if (!cs.callGone) this.epOther.bridge(this.ep);
+ } catch (err) {
+ this.logger.error(err, 'Dial:whisper error');
+ }
+ }
+
+ /**
+ * mute or unmute one side of the call
+ */
+ async mute(callSid, doMute) {
+ if (!this.epOther || !this.ep) return this.logger.info('Dial:mute: no paired endpoint found');
+ try {
+ const parentCall = callSid !== this.callSid;
+ const ep = parentCall ? this.epOther : this.ep;
+ await ep[doMute ? 'mute' : 'unmute']();
+ this.logger.debug(`Dial:mute ${doMute ? 'muted' : 'unmuted'} ${parentCall ? 'parentCall' : 'childCall'}`);
+ } catch (err) {
+ this.logger.error(err, 'Dial:mute error');
+ }
+ }
+
_killOutdials() {
for (const [callSid, sd] of Array.from(this.dials)) {
this.logger.debug(`Dial:_killOutdials killing callSid ${callSid}`);
@@ -113,8 +199,33 @@ class TaskDial extends Task {
this.dials.clear();
}
+ _installDtmfDetection(cs, ep, dtmfDetector) {
+ if (ep && this.dtmfHook && !ep.dtmfDetector) {
+ ep.dtmfDetector = dtmfDetector;
+ ep.on('dtmf', this._onDtmf.bind(this, cs, ep));
+ }
+ }
+ _removeDtmfDetection(cs, ep) {
+ if (ep) {
+ delete ep.dtmfDetector;
+ ep.removeListener('dtmf', this._onDtmf.bind(this, cs, ep));
+ }
+ }
+
+ _onDtmf(cs, ep, evt) {
+ const match = ep.dtmfDetector.keyPress(evt.dtmf);
+ const requestor = ep.dtmfDetector === this.parentDtmfCollector ?
+ cs.requestor :
+ this.sd.requestor;
+ if (match) {
+ this.logger.debug(`parentCall triggered dtmf match: ${match}`);
+ requestor.request(this.dtmfHook, Object.assign({dtmf: match}, cs.callInfo))
+ .catch((err) => this.logger.info(err, 'Dial:_onDtmf - error'));
+ }
+ }
+
async _initializeInbound(cs) {
- const {ep} = await cs.connectInboundCallToIvr(this.earlyMedia);
+ const ep = await cs._evalEndpointPrecondition(this);
this.epOther = ep;
debug(`Dial:__initializeInbound allocated ep for incoming call: ${ep.uuid}`);
@@ -256,8 +367,10 @@ class TaskDial extends Task {
this.kill();
}, this.timeLimit * 1000);
}
+ sessionTracker.add(this.callSid, cs);
this.dlg.on('destroy', () => {
this.logger.debug('Dial:_selectSingleDial called party hungup, ending dial operation');
+ sessionTracker.remove(this.callSid);
if (this.timerMaxCallDuration) clearTimeout(this.timerMaxCallDuration);
this.ep.unbridge();
this.kill();
@@ -268,6 +381,8 @@ class TaskDial extends Task {
dialCallSid: sd.callSid,
});
+ if (this.childDtmfCollector) this._installDtmfDetection(cs, this.ep, this.childDtmfCollector);
+
if (this.transcribeTask) this.transcribeTask.exec(cs, this.ep);
if (this.listenTask) this.listenTask.exec(cs, this.ep);
}
diff --git a/lib/tasks/gather.js b/lib/tasks/gather.js
index 4ca71f8a..ba2aef6b 100644
--- a/lib/tasks/gather.js
+++ b/lib/tasks/gather.js
@@ -9,13 +9,11 @@ class TaskGather extends Task {
this.preconditions = TaskPreconditions.Endpoint;
[
- 'action', 'finishOnKey', 'hints', 'input', 'method', 'numDigits',
- 'partialResultCallback', 'partialResultCallbackMethod', 'profanityFilter',
+ 'finishOnKey', 'hints', 'input', 'numDigits',
+ 'partialResultHook', 'profanityFilter',
'speechTimeout', 'timeout', 'say', 'play'
].forEach((k) => this[k] = this.data[k]);
- this.partialResultCallbackMethod = this.partialResultCallbackMethod || 'POST';
- this.method = this.method || 'POST';
this.timeout = (this.timeout || 5) * 1000;
this.interim = this.partialResultCallback;
if (this.data.recognizer) {
@@ -23,7 +21,6 @@ class TaskGather extends Task {
this.vendor = this.data.recognizer.vendor;
}
-
this.digitBuffer = '';
this._earlyMedia = this.data.earlyMedia === true;
@@ -142,7 +139,10 @@ class TaskGather extends Task {
_onTranscription(ep, evt) {
this.logger.debug(evt, 'TaskGather:_onTranscription');
if (evt.is_final) this._resolve('speech', evt);
- else if (this.partialResultCallback) this.notifyHook(this.partialResultCallback, 'POST', null, {speech: evt});
+ else if (this.partialResultHook) {
+ this.cs.requestor.request(this.partialResultHook, Object.assign({speech: evt}, this.cs.callInfo))
+ .catch((err) => this.logger.info(err, 'GatherTask:_onTranscription error'));
+ }
}
_onEndOfUtterance(ep, evt) {
this.logger.info(evt, 'TaskGather:_onEndOfUtterance');
@@ -154,10 +154,10 @@ class TaskGather extends Task {
this._clearTimer();
if (reason.startsWith('dtmf')) {
- await this.performAction(this.method, null, {digits: this.digitBuffer});
+ await this.performAction({digits: this.digitBuffer});
}
else if (reason.startsWith('speech')) {
- await this.performAction(this.method, null, {speech: evt});
+ await this.performAction({speech: evt});
}
this.notifyTaskDone();
}
diff --git a/lib/tasks/listen.js b/lib/tasks/listen.js
index d36342a8..cb6b196b 100644
--- a/lib/tasks/listen.js
+++ b/lib/tasks/listen.js
@@ -20,7 +20,6 @@ class TaskListen extends Task {
this.nested = parentTask instanceof Task;
this.results = {};
- this.ranToCompletion = false;
if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this);
@@ -42,8 +41,7 @@ class TaskListen extends Task {
}
await this._startListening(cs, ep);
await this.awaitTaskDone();
- const acceptNewApp = !this.nested && this.ranToCompletion;
- if (this.action) await this.performAction(this.method, this.auth, this.results, acceptNewApp);
+ await this.performAction(this.results, !this.nested);
} catch (err) {
this.logger.info(err, `TaskListen:exec - error ${this.url}`);
}
@@ -68,18 +66,15 @@ class TaskListen extends Task {
this.notifyTaskDone();
}
- updateListen(status) {
+ async updateListen(status) {
if (!this.killed && this.ep && this.ep.connected) {
this.logger.info(`TaskListen:updateListen status ${status}`);
switch (status) {
case ListenStatus.Pause:
- this.ep.forkAudioPause().catch((err) => this.logger.info(err, 'TaskListen: error pausing audio'));
- break;
- case ListenStatus.Silence:
- this.ep.forkAudioPause().catch((err) => this.logger.info(err, 'TaskListen: error pausing audio'));
+ await this.ep.forkAudioPause().catch((err) => this.logger.info(err, 'TaskListen: error pausing audio'));
break;
case ListenStatus.Resume:
- this.ep.forkAudioResume().catch((err) => this.logger.info(err, 'TaskListen: error resuming audio'));
+ await this.ep.forkAudioResume().catch((err) => this.logger.info(err, 'TaskListen: error resuming audio'));
break;
}
}
@@ -114,7 +109,6 @@ class TaskListen extends Task {
if (this.maxLength) {
this._timer = setTimeout(() => {
this.logger.debug(`TaskListen terminating task due to timeout of ${this.timeout}s reached`);
- this.ranToCompletion = true;
this.kill();
}, this.maxLength * 1000);
}
@@ -142,7 +136,6 @@ class TaskListen extends Task {
if (evt.dtmf === this.finishOnKey) {
this.logger.info(`TaskListen:_onDtmf terminating task due to dtmf ${evt.dtmf}`);
this.results.digits = evt.dtmf;
- this.ranToCompletion = true;
this.kill();
}
}
diff --git a/lib/tasks/redirect.js b/lib/tasks/redirect.js
index fb377729..a14ec2dc 100644
--- a/lib/tasks/redirect.js
+++ b/lib/tasks/redirect.js
@@ -7,17 +7,13 @@ const {TaskName} = require('../utils/constants');
class TaskRedirect extends Task {
constructor(logger, opts) {
super(logger, opts);
-
- this.action = this.data.action;
- this.method = (this.data.method || 'POST').toUpperCase();
- this.auth = this.data.auth;
}
get name() { return TaskName.Redirect; }
async exec(cs) {
super.exec(cs);
- await this.performAction(this.method, this.auth);
+ await this.performAction();
}
}
diff --git a/lib/tasks/rest_dial.js b/lib/tasks/rest_dial.js
index a78aaaa4..fdb02f35 100644
--- a/lib/tasks/rest_dial.js
+++ b/lib/tasks/rest_dial.js
@@ -44,9 +44,7 @@ class TaskRestDial extends Task {
this.req = null;
const cs = this.callSession;
cs.setDialog(dlg);
- const obj = Object.assign({}, cs.callInfo);
-
- const tasks = await this.actionHook(this.call_hook, obj);
+ const tasks = await cs.requestor.request(this.call_hook, cs.callInfo);
if (tasks && Array.isArray(tasks)) {
this.logger.debug({tasks: tasks}, `TaskRestDial: replacing application with ${tasks.length} tasks`);
cs.replaceApplication(tasks);
diff --git a/lib/tasks/specs.json b/lib/tasks/specs.json
index 9320e0f1..ce61a1c6 100644
--- a/lib/tasks/specs.json
+++ b/lib/tasks/specs.json
@@ -39,11 +39,11 @@
},
"gather": {
"properties": {
- "action": "string",
+ "actionHook": "object|string",
"finishOnKey": "string",
"input": "array",
"numDigits": "number",
- "partialResultCallback": "string",
+ "partialResultHook": "object|string",
"speechTimeout": "number",
"timeout": "number",
"recognizer": "#recognizer",
@@ -51,26 +51,20 @@
"say": "#say"
},
"required": [
- "action"
+ "actionHook"
]
},
"dial": {
"properties": {
- "action": "string",
+ "actionHook": "object|string",
"answerOnBridge": "boolean",
"callerId": "string",
- "confirmUrl": "string",
- "confirmMethod": {
- "type": "string",
- "enum": ["GET", "POST"]
- },
+ "confirmHook": "object|string",
"dialMusic": "string",
+ "dtmfCapture": "object",
+ "dtmfHook": "object|string",
"headers": "object",
"listen": "#listen",
- "method": {
- "type": "string",
- "enum": ["GET", "POST"]
- },
"target": ["#target"],
"timeLimit": "number",
"timeout": "number",
@@ -82,15 +76,11 @@
},
"listen": {
"properties": {
- "action": "string",
+ "actionHook": "object|string",
"auth": "#auth",
"finishOnKey": "string",
"maxLength": "number",
"metadata": "object",
- "method": {
- "type": "string",
- "enum": ["GET", "POST"]
- },
"mixType": {
"type": "string",
"enum": ["mono", "stereo", "mixed"]
@@ -118,21 +108,17 @@
},
"redirect": {
"properties": {
- "action": "string",
- "method": {
- "type": "string",
- "enum": ["GET", "POST"]
- },
- "auth": "#auth"
+ "actionHook": "object|string"
},
"required": [
- "action"
+ "actionHook"
]
},
"rest:dial": {
"properties": {
- "call_hook": "object",
+ "call_hook": "object|string",
"from": "string",
+ "tag": "object",
"to": "#target",
"timeout": "number"
},
@@ -152,12 +138,12 @@
},
"transcribe": {
"properties": {
- "transcriptionCallback": "string",
+ "transcriptionHook": "string",
"recognizer": "#recognizer",
"earlyMedia": "boolean"
},
"required": [
- "transcriptionCallback"
+ "transcriptionHook"
]
},
"target": {
diff --git a/lib/tasks/task.js b/lib/tasks/task.js
index 65853757..948a9829 100644
--- a/lib/tasks/task.js
+++ b/lib/tasks/task.js
@@ -2,25 +2,39 @@ const Emitter = require('events');
const debug = require('debug')('jambonz:feature-server');
const assert = require('assert');
const {TaskPreconditions} = require('../utils/constants');
+const normalizeJamones = require('../utils/normalize-jamones');
+const makeTask = require('./make_task');
const specs = new Map();
const _specData = require('./specs');
for (const key in _specData) {specs.set(key, _specData[key]);}
+/**
+ * @classdesc Represents a jambonz verb. This is a superclass that is extended
+ * by a subclass for each verb.
+ * @extends Emitter
+ */
class Task extends Emitter {
constructor(logger, data) {
super();
this.preconditions = TaskPreconditions.None;
this.logger = logger;
this.data = data;
+ this.actionHook = this.data.actionHook;
this._killInProgress = false;
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
}
+ /**
+ * @property {boolean} killed - true if the task has been killed
+ */
get killed() {
return this._killInProgress;
}
+ /**
+ * @property {CallSession} callSession - the CallSession this task is executing within
+ */
get callSession() {
return this.cs;
}
@@ -29,13 +43,13 @@ class Task extends Emitter {
return this.data;
}
+ /**
+ * Execute the task. Subclasses must implement this method, but should always call
+ * the superclass implementation first.
+ * @param {CallSession} cs - the CallSession that the Task will be executing within.
+ */
async exec(cs) {
this.cs = cs;
-
- // N.B. need to require it down here rather than at top to avoid recursion in require of this module
- const {actionHook, notifyHook} = require('../utils/notifiers')(this.logger, cs.callInfo);
- this.actionHook = actionHook;
- this.notifyHook = notifyHook;
}
/**
@@ -48,29 +62,47 @@ class Task extends Emitter {
// no-op
}
+ /**
+ * when a subclass Task has completed its work, it should call this method
+ */
notifyTaskDone() {
this._completionResolver();
}
+ /**
+ * when a subclass task has launched various async activities and is now simply waiting
+ * for them to complete it should call this method to block until that happens
+ */
awaitTaskDone() {
return this._completionPromise;
}
+ /**
+ * provided as a convenience for tasks, this simply calls CallSession#normalizeUrl
+ */
normalizeUrl(url, method, auth) {
return this.callSession.normalizeUrl(url, method, auth);
}
- async performAction(method, auth, results, expectResponse = true) {
- if (this.action) {
- const hook = this.normalizeUrl(this.action, method, auth);
- const tasks = await this.actionHook(hook, results, expectResponse);
- if (expectResponse && tasks && Array.isArray(tasks)) {
- this.logger.debug({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
- this.callSession.replaceApplication(tasks);
+ async performAction(results, expectResponse = true) {
+ if (this.actionHook) {
+ const params = results ? Object.assign(results, this.cs.callInfo) : this.cs.callInfo;
+ const json = await this.cs.requestor.request(this.actionHook, params);
+ if (expectResponse && json && Array.isArray(json)) {
+ const tasks = normalizeJamones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
+ if (tasks && tasks.length > 0) {
+ this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
+ this.callSession.replaceApplication(tasks);
+ }
}
}
}
+ /**
+ * validate that the JSON task description is valid
+ * @param {string} name - verb name
+ * @param {object} data - verb properties
+ */
static validate(name, data) {
debug(`validating ${name} with data ${JSON.stringify(data)}`);
// validate the instruction is supported
@@ -94,6 +126,12 @@ class Task extends Emitter {
else if (typeof dSpec === 'string' && dSpec === 'array') {
if (!Array.isArray(dVal)) throw new Error(`${name}: property ${dKey} is not an array`);
}
+ else if (typeof dSpec === 'string' && dSpec.includes('|')) {
+ const types = dSpec.split('|').map((t) => t.trim());
+ if (!types.includes(typeof dVal)) {
+ throw new Error(`${name}: property ${dKey} has invalid data type, must be one of ${types}`);
+ }
+ }
else if (Array.isArray(dSpec) && dSpec[0].startsWith('#')) {
const name = dSpec[0].slice(1);
for (const item of dVal) {
diff --git a/lib/tasks/transcribe.js b/lib/tasks/transcribe.js
index 9d234107..ba37123f 100644
--- a/lib/tasks/transcribe.js
+++ b/lib/tasks/transcribe.js
@@ -6,7 +6,7 @@ class TaskTranscribe extends Task {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
- this.transcriptionCallback = this.data.transcriptionCallback;
+ this.transcriptionHook = this.data.transcriptionHook;
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
if (this.data.recognizer) {
this.language = this.data.recognizer.language || 'en-US';
@@ -78,7 +78,8 @@ class TaskTranscribe extends Task {
_onTranscription(ep, evt) {
this.logger.debug(evt, 'TaskTranscribe:_onTranscription');
- this.notifyHook(this.transcriptionCallback, 'POST', null, {speech: evt});
+ this.cs.requestor.request(this.transcriptionHook, Object.assign({speech: evt}, this.cs.callInfo))
+ .catch((err) => this.logger.info(err, 'TranscribeTask:_onTranscription error'));
if (this.killed) {
this.logger.debug('TaskTranscribe:_onTranscription exiting after receiving final transcription');
this._clearTimer();
diff --git a/lib/utils/basic-auth.js b/lib/utils/basic-auth.js
new file mode 100644
index 00000000..1d489860
--- /dev/null
+++ b/lib/utils/basic-auth.js
@@ -0,0 +1,10 @@
+const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
+
+module.exports = (auth) => {
+ if (!auth || !auth.username ||
+ typeof auth.username !== 'string' ||
+ (auth.password && typeof auth.password !== 'string')) return {};
+ const creds = `${auth.username}:${auth.password || ''}`;
+ const header = `Basic ${toBase64(creds)}`;
+ return {Authorization: header};
+};
diff --git a/lib/utils/dtmf-collector.js b/lib/utils/dtmf-collector.js
new file mode 100644
index 00000000..38c0f584
--- /dev/null
+++ b/lib/utils/dtmf-collector.js
@@ -0,0 +1,42 @@
+class DtmfEntry {
+ constructor(key, time) {
+ this.key = key;
+ this.time = time;
+ }
+}
+
+/**
+ * @classdesc Represents an object that collects dtmf key entries and
+ * reports when a match is detected
+ */
+class DtmfCollector {
+ constructor({logger, patterns, interDigitTimeout}) {
+ this.logger = logger;
+ this.patterns = patterns;
+ this.idt = interDigitTimeout || 3000;
+ this.buffer = [];
+ }
+
+ keyPress(key) {
+ const now = Date.now();
+
+ // age out previous entries if interdigit timer has elapsed
+ const lastDtmf = this.buffer.pop();
+ if (lastDtmf) {
+ if (now - lastDtmf.time < this.idt) this.buffer.push(lastDtmf);
+ else {
+ this.buffer = [];
+ }
+ }
+ // add new entry
+ this.buffer.push(new DtmfEntry(key, now));
+
+ // check for a match
+ const collectedDigits = this.buffer
+ .map((entry) => entry.key)
+ .join('');
+ return this.patterns.find((pattern) => collectedDigits.endsWith(pattern));
+ }
+}
+
+module.exports = DtmfCollector;
diff --git a/lib/utils/notifiers.js b/lib/utils/notifiers.js
deleted file mode 100644
index ee06280f..00000000
--- a/lib/utils/notifiers.js
+++ /dev/null
@@ -1,52 +0,0 @@
-const request = require('request');
-//require('request-debug')(request);
-const retrieveApp = require('./retrieve-app');
-
-function hooks(logger, callInfo) {
- function actionHook(hook, obj = {}, expectResponse = true) {
- const method = (hook.method || 'POST').toUpperCase();
- const auth = (hook.username && hook.password) ?
- {username: hook.username, password: hook.password} :
- null;
-
- const data = Object.assign({}, obj, callInfo.toJSON());
- logger.debug({hook, data, auth}, 'actionhook');
-
- /* customer data only on POSTs */
- if ('GET' === method) delete data.customerData;
-
- const opts = {
- url: hook.url,
- method,
- json: 'POST' === method || expectResponse
- };
- if (auth) opts.auth = auth;
- if ('POST' === method) opts.body = data;
- else opts.qs = data;
-
- return new Promise((resolve, reject) => {
- request(opts, (err, response, body) => {
- if (err) {
- logger.info(`actionHook error ${method} ${hook.url}: ${err.message}`);
- return reject(err);
- }
- if (body && expectResponse) {
- logger.debug(body, `actionHook response ${method} ${hook.url}`);
- return resolve(retrieveApp(logger, body));
- }
- resolve(body);
- });
- });
- }
-
- function notifyHook(hook, opts = {}) {
- return actionHook(hook, opts, false);
- }
-
- return {
- actionHook,
- notifyHook
- };
-}
-
-module.exports = hooks;
diff --git a/lib/utils/place-outdial.js b/lib/utils/place-outdial.js
index 87329cdb..830a30c1 100644
--- a/lib/utils/place-outdial.js
+++ b/lib/utils/place-outdial.js
@@ -5,9 +5,7 @@ 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');
const uuidv4 = require('uuid/v4');
class SingleDialer extends Emitter {
@@ -20,8 +18,7 @@ class SingleDialer extends Emitter {
this.sbcAddress = sbcAddress;
this.opts = opts;
this.application = application;
- this.url = target.url;
- this.method = target.method;
+ this.confirmHook = target.confirmHook;
this.bindings = logger.bindings();
@@ -37,6 +34,22 @@ class SingleDialer extends Emitter {
return this.callInfo.callStatus;
}
+ /**
+ * can be used for all http requests within this session
+ */
+ get requestor() {
+ assert(this.application.requestor);
+ return this.application.requestor;
+ }
+
+ /**
+ * can be used for all http call status notifications within this session
+ */
+ get notifier() {
+ assert(this.application.notifier);
+ return this.application.notifier;
+ }
+
async exec(srf, ms, opts) {
let uri, to;
switch (this.target.type) {
@@ -106,9 +119,6 @@ class SingleDialer extends Emitter {
callId: this.callInfo.callId
});
this.inviteInProgress = req;
- const {actionHook, notifyHook} = hooks(this.logger, this.callInfo);
- this.actionHook = actionHook;
- this.notifyHook = notifyHook;
this.emit('callStatusChange', {callStatus: CallStatus.Trying, sipStatus: 100});
},
cbProvisional: (prov) => {
@@ -135,7 +145,7 @@ class SingleDialer extends Emitter {
this.ep.destroy();
});
- if (this.url) this._executeApp(this.url);
+ if (this.confirmHook) this._executeApp(this.confirmHook);
else this.emit('accept');
} catch (err) {
const status = {callStatus: CallStatus.Failed};
@@ -178,29 +188,12 @@ class SingleDialer extends Emitter {
* Note: the application to run may not include a dial or sip:decline verb
* @param {*} url - url for application
*/
- async _executeApp(url) {
- this.logger.debug(`SingleDialer:_executeApp: executing ${url} after connect`);
+ async _executeApp(confirmHook) {
try {
- let auth, method;
- const app = Object.assign({}, this.application);
- if (url.startsWith('/')) {
- const savedUrl = url;
- const or = app.originalRequest;
- url = `${or.baseUrl}${url}`;
- auth = or.auth;
- method = this.method || or.method || 'POST';
- 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}`
- };
- method = this.method || 'POST';
- }
+ // retrieve set of tasks
+ const tasks = await this.requestor.request(confirmHook, this.callInfo);
- const tasks = await this.actionHook({url, method, auth});
+ // verify it contains only allowed verbs
const allowedTasks = tasks.filter((task) => {
return [
TaskPreconditions.StableCall,
@@ -211,16 +204,19 @@ class SingleDialer extends Emitter {
throw new Error('unsupported verb in dial url');
}
+ // now execute it in a new ConfirmCallSession
this.logger.debug(`SingleDialer:_executeApp: executing ${tasks.length} tasks`);
const cs = new ConfirmCallSession({
logger: this.logger,
- application: app,
+ application: this.application,
dlg: this.dlg,
ep: this.ep,
callInfo: this.callInfo,
tasks
});
await cs.exec();
+
+ // still connected after app is completed? Signal parent call we are good
this.emit(this.dlg.connected ? 'accept' : 'decline');
} catch (err) {
this.logger.debug(err, 'SingleDialer:_executeApp: error');
@@ -233,6 +229,7 @@ class SingleDialer extends Emitter {
assert((typeof duration === 'number' && callStatus === CallStatus.Completed) ||
(!duration && callStatus !== CallStatus.Completed),
'duration MUST be supplied when call completed AND ONLY when call completed');
+
this.callInfo.updateCallStatus(callStatus, sipStatus);
if (typeof duration === 'number') this.callInfo.duration = duration;
try {
diff --git a/lib/utils/requestor.js b/lib/utils/requestor.js
new file mode 100644
index 00000000..54eb244b
--- /dev/null
+++ b/lib/utils/requestor.js
@@ -0,0 +1,64 @@
+const bent = require('bent');
+const parseUrl = require('parse-url');
+const basicAuth = require('./basic-auth');
+const assert = require('assert');
+
+function isRelativeUrl(u) {
+ return typeof u === 'string' && u.startsWith('/');
+}
+
+function isAbsoluteUrl(u) {
+ return typeof u === 'string' &&
+ u.startsWith('https://') || u.startsWith('http://');
+}
+
+class Requestor {
+ constructor(logger, hook) {
+ this.logger = logger;
+ this.url = hook.url;
+ this.method = hook.method || 'POST';
+ this.authHeader = basicAuth(hook.auth);
+
+ const u = parseUrl(this.url);
+ const myPort = u.port ? `:${u.port}` : '';
+ const baseUrl = `${u.protocol}://${u.resource}${myPort}`;
+
+ this.get = bent(baseUrl, 'GET', 'json', 200);
+ this.post = bent(baseUrl, 'POST', 'json', 200);
+
+ assert(isAbsoluteUrl(this.url));
+ assert(['GET', 'POST'].includes(this.method));
+ assert(!this.auth || typeof auth == 'object');
+ }
+
+ get hasAuth() {
+ return 'Authorization' in this.authHeader;
+ }
+
+ /**
+ * Make an HTTP request.
+ * All requests use json bodies.
+ * All requests expect a 200 statusCode on success
+ * @param {object|string} hook - may be a absolute or relative url, or an object
+ * @param {string} [hook.url] - an absolute or relative url
+ * @param {string} [hook.method] - 'GET' or 'POST'
+ * @param {object} [params] - request parameters
+ */
+ async request(hook, params) {
+ params = params || null;
+ if (isRelativeUrl(hook)) {
+ this.logger.debug({params}, `Requestor:request relative url ${hook}`);
+ return await this.post(hook, params, this.authHeader);
+ }
+ const url = hook.url;
+ const method = hook.method || 'POST';
+ const authHeader = isRelativeUrl(url) ? this.authHeader : basicAuth(hook.auth);
+
+ assert(url);
+ assert(['GET', 'POST'].includes(method));
+ return await this[method.toLowerCase()](url, params, authHeader);
+ }
+
+}
+
+module.exports = Requestor;
diff --git a/lib/utils/retrieve-app.js b/lib/utils/retrieve-app.js
deleted file mode 100644
index 7a1e6297..00000000
--- a/lib/utils/retrieve-app.js
+++ /dev/null
@@ -1,31 +0,0 @@
-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, 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 (response.statusCode === 401) return reject(new Error('HTTP request failed: Unauthorized'));
- else if (response.statusCode !== 200) return reject(new Error(`HTTP request failed: ${response.statusCode}`));
- if (body) logger.debug({body}, 'retrieveUrl: ${method} ${url} returned an application');
- resolve(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, obj);
- return normalizeJamones(logger, json).map((tdata) => makeTask(logger, tdata));
-}
-
-module.exports = retrieveApp;
diff --git a/lib/utils/summarize-tasks.js b/lib/utils/summarize-tasks.js
new file mode 100644
index 00000000..d1e4a0a5
--- /dev/null
+++ b/lib/utils/summarize-tasks.js
@@ -0,0 +1,3 @@
+module.exports = function(tasks) {
+ return `[${tasks.map((t) => t.name).join(',')}]`;
+};
diff --git a/package-lock.json b/package-lock.json
index 3e480543..0b713ced 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -295,6 +295,23 @@
"tweetnacl": "^0.14.3"
}
},
+ "bent": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/bent/-/bent-7.0.6.tgz",
+ "integrity": "sha512-lDSus5exz6HwJIpqf+aQxqYqA0Xrrca9w3INpqduP1ojlI9bHAURvSn/tzK4cQ0TRB8tt6rvOzIBNFyJvBRqnw==",
+ "requires": {
+ "bytesish": "^0.4.1",
+ "caseless": "~0.12.0",
+ "is-stream": "^2.0.0"
+ },
+ "dependencies": {
+ "is-stream": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.0.tgz",
+ "integrity": "sha512-XCoy+WlUr7d1+Z8GgSuXmpuUFC9fOhRXglJMx+dwLKTkL44Cjd4W1Z5P+BQZpr+cR93aGP4S/s7Ftw6Nd/kiEw=="
+ }
+ }
+ },
"binary-extensions": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.0.0.tgz",
@@ -400,6 +417,11 @@
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
"integrity": "sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg=="
},
+ "bytesish": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/bytesish/-/bytesish-0.4.1.tgz",
+ "integrity": "sha512-j3l5QmnAbpOfcN/Z2Jcv4poQYfefs8rDdcbc6iEKm+OolvUXAE2APodpWj+DOzqX6Bl5Ys1cQkcIV2/doGvQxg=="
+ },
"caching-transform": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-3.0.2.tgz",
@@ -438,6 +460,15 @@
"resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz",
"integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw="
},
+ "catharsis": {
+ "version": "0.8.11",
+ "resolved": "https://registry.npmjs.org/catharsis/-/catharsis-0.8.11.tgz",
+ "integrity": "sha512-a+xUyMV7hD1BrDQA/3iPV7oc+6W26BgVJO05PGEoatMyIuPScQKsde6i3YorWX1qs+AZjnJ18NqdKoCtKiNh1g==",
+ "dev": true,
+ "requires": {
+ "lodash": "^4.17.14"
+ }
+ },
"chalk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz",
@@ -812,50 +843,6 @@
"resolved": "https://registry.npmjs.org/drachtio-fn-b2b-sugar/-/drachtio-fn-b2b-sugar-0.0.12.tgz",
"integrity": "sha512-FKPAcEMJTYKDrd9DJUCc4VHnY/c65HOO9k8XqVNognF9T02hKEjGuBCM4Da9ipyfiHmVRuECwj0XNvZ361mkVQ=="
},
- "drachtio-fsmrf": {
- "version": "1.5.13",
- "resolved": "https://registry.npmjs.org/drachtio-fsmrf/-/drachtio-fsmrf-1.5.13.tgz",
- "integrity": "sha512-FC/Xifua4ut5tZ9cDRCaRoEIo7LEevh5gdqgzTyKo685gm10tO//Ln7Q6ZnVnbwpFOH4TxaIf+al25z/t0v6Cg==",
- "requires": {
- "async": "^1.4.2",
- "debug": "^2.2.0",
- "delegates": "^0.1.0",
- "drachtio-modesl": "^1.2.0",
- "drachtio-srf": "^4.4.15",
- "lodash": "^4.17.15",
- "minimist": "^1.2.0",
- "moment": "^2.13.0",
- "only": "0.0.2",
- "sdp-transform": "^2.7.0",
- "uuid": "^3.0.0"
- },
- "dependencies": {
- "debug": {
- "version": "2.6.9",
- "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
- "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
- "requires": {
- "ms": "2.0.0"
- }
- },
- "ms": {
- "version": "2.0.0",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
- "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
- }
- }
- },
- "drachtio-modesl": {
- "version": "1.2.0",
- "resolved": "https://registry.npmjs.org/drachtio-modesl/-/drachtio-modesl-1.2.0.tgz",
- "integrity": "sha512-nkua3RfYnT32OvglERO2zWzJZAfQooZIarZVVAye+iGqTwYJ69X7bU7du5SBHz/jBl+LgeWITMP2fMe2TelxmA==",
- "requires": {
- "debug": "^4.1.1",
- "eventemitter2": "^4.1",
- "uuid": "^3.1.0",
- "xml2js": "^0.4.19"
- }
- },
"drachtio-mw-registration-parser": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/drachtio-mw-registration-parser/-/drachtio-mw-registration-parser-0.0.2.tgz",
@@ -933,6 +920,12 @@
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
"integrity": "sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k="
},
+ "entities": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-1.1.2.tgz",
+ "integrity": "sha512-f2LZMYl1Fzu7YSBKg+RoROelpOaNrcGmE9AZubeDfrCEia483oW4MI4VyFd5VNHIgQ/7qm1I0wUHK1eJnn2y2w==",
+ "dev": true
+ },
"error-ex": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz",
@@ -1129,11 +1122,6 @@
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
"integrity": "sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc="
},
- "eventemitter2": {
- "version": "4.1.2",
- "resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-4.1.2.tgz",
- "integrity": "sha1-DhqEd6+CGm7zmVsxG/dMI6UkfxU="
- },
"events-to-array": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/events-to-array/-/events-to-array-1.1.2.tgz",
@@ -2096,11 +2084,50 @@
"esprima": "^4.0.0"
}
},
+ "js2xmlparser": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/js2xmlparser/-/js2xmlparser-4.0.1.tgz",
+ "integrity": "sha512-KrPTolcw6RocpYjdC7pL7v62e55q7qOMHvLX1UCLc5AAS8qeJ6nukarEJAF2KL2PZxlbGueEbINqZR2bDe/gUw==",
+ "dev": true,
+ "requires": {
+ "xmlcreate": "^2.0.3"
+ }
+ },
"jsbn": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz",
"integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM="
},
+ "jsdoc": {
+ "version": "3.6.3",
+ "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-3.6.3.tgz",
+ "integrity": "sha512-Yf1ZKA3r9nvtMWHO1kEuMZTlHOF8uoQ0vyo5eH7SQy5YeIiHM+B0DgKnn+X6y6KDYZcF7G2SPkKF+JORCXWE/A==",
+ "dev": true,
+ "requires": {
+ "@babel/parser": "^7.4.4",
+ "bluebird": "^3.5.4",
+ "catharsis": "^0.8.11",
+ "escape-string-regexp": "^2.0.0",
+ "js2xmlparser": "^4.0.0",
+ "klaw": "^3.0.0",
+ "markdown-it": "^8.4.2",
+ "markdown-it-anchor": "^5.0.2",
+ "marked": "^0.7.0",
+ "mkdirp": "^0.5.1",
+ "requizzle": "^0.2.3",
+ "strip-json-comments": "^3.0.1",
+ "taffydb": "2.6.2",
+ "underscore": "~1.9.1"
+ },
+ "dependencies": {
+ "escape-string-regexp": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz",
+ "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==",
+ "dev": true
+ }
+ }
+ },
"jsesc": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz",
@@ -2153,6 +2180,15 @@
"verror": "1.10.0"
}
},
+ "klaw": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/klaw/-/klaw-3.0.0.tgz",
+ "integrity": "sha512-0Fo5oir+O9jnXu5EefYbVK+mHMBeEVEy2cmctR1O1NECcCkPRreJKrS6Qt/j3KC2C148Dfo9i3pCmCMsdqGr0g==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.9"
+ }
+ },
"lcov-parse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-1.0.0.tgz",
@@ -2169,6 +2205,15 @@
"type-check": "~0.3.2"
}
},
+ "linkify-it": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-2.2.0.tgz",
+ "integrity": "sha512-GnAl/knGn+i1U/wjBz3akz2stz+HrHLsxMwHQGofCDfPvlf+gDKN58UtfmUquTY4/MXeE2x7k19KQmeoZi94Iw==",
+ "dev": true,
+ "requires": {
+ "uc.micro": "^1.0.1"
+ }
+ },
"load-json-file": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-4.0.0.tgz",
@@ -2263,6 +2308,37 @@
"integrity": "sha512-c3sIjNUow0+8swNwVpqoH4YCShKNFkMaw6oH1mNS2haDZQqkeZFlHS3dhoeEbKKmJB4vXpJucU6oH75aDYeE9g==",
"dev": true
},
+ "markdown-it": {
+ "version": "8.4.2",
+ "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-8.4.2.tgz",
+ "integrity": "sha512-GcRz3AWTqSUphY3vsUqQSFMbgR38a4Lh3GWlHRh/7MRwz8mcu9n2IO7HOh+bXHrR9kOPDl5RNCaEsrneb+xhHQ==",
+ "dev": true,
+ "requires": {
+ "argparse": "^1.0.7",
+ "entities": "~1.1.1",
+ "linkify-it": "^2.0.0",
+ "mdurl": "^1.0.1",
+ "uc.micro": "^1.0.5"
+ }
+ },
+ "markdown-it-anchor": {
+ "version": "5.2.5",
+ "resolved": "https://registry.npmjs.org/markdown-it-anchor/-/markdown-it-anchor-5.2.5.tgz",
+ "integrity": "sha512-xLIjLQmtym3QpoY9llBgApknl7pxAcN3WDRc2d3rwpl+/YvDZHPmKscGs+L6E05xf2KrCXPBvosWt7MZukwSpQ==",
+ "dev": true
+ },
+ "marked": {
+ "version": "0.7.0",
+ "resolved": "https://registry.npmjs.org/marked/-/marked-0.7.0.tgz",
+ "integrity": "sha512-c+yYdCZJQrsRjTPhUx7VKkApw9bwDkNbHUKo1ovgcfDjb2kc8rLuRbIFyXL5WOEUwzSSKo3IXpph2K6DqB/KZg==",
+ "dev": true
+ },
+ "mdurl": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz",
+ "integrity": "sha1-/oWy7HWlkDfyrf7BAP1sYBdhFS4=",
+ "dev": true
+ },
"media-typer": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
@@ -3136,6 +3212,15 @@
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"dev": true
},
+ "requizzle": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.3.tgz",
+ "integrity": "sha512-YanoyJjykPxGHii0fZP0uUPEXpvqfBDxWV7s6GKAiiOsiqhX6vHNyW3Qzdmqp/iq/ExbhaGbVrjB4ruEVSM4GQ==",
+ "dev": true,
+ "requires": {
+ "lodash": "^4.17.14"
+ }
+ },
"resolve": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.13.1.tgz",
@@ -3207,16 +3292,6 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
- "sax": {
- "version": "1.2.4",
- "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
- "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
- },
- "sdp-transform": {
- "version": "2.14.0",
- "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.14.0.tgz",
- "integrity": "sha512-8ZYOau/o9PzRhY0aMuRzvmiM6/YVQR8yjnBScvZHSdBnywK5oZzAJK+412ZKkDq29naBmR3bRw8MFu0C01Gehg=="
- },
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
@@ -3622,6 +3697,12 @@
}
}
},
+ "taffydb": {
+ "version": "2.6.2",
+ "resolved": "https://registry.npmjs.org/taffydb/-/taffydb-2.6.2.tgz",
+ "integrity": "sha1-fLy2S1oUG2ou/CxdLGe04VCyomg=",
+ "dev": true
+ },
"tap": {
"version": "14.10.2",
"resolved": "https://registry.npmjs.org/tap/-/tap-14.10.2.tgz",
@@ -5221,6 +5302,12 @@
"integrity": "sha512-Mcr/Qk7hXqFBXMN7p7Lusj1ktCBydylfQM/FZCk5glCNQJrCUKPkMHdo9R0MTFWsC/4kPFvDS0fDPvukfCkFsw==",
"dev": true
},
+ "uc.micro": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-1.0.6.tgz",
+ "integrity": "sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==",
+ "dev": true
+ },
"uglify-js": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.7.2.tgz",
@@ -5241,6 +5328,12 @@
}
}
},
+ "underscore": {
+ "version": "1.9.2",
+ "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.9.2.tgz",
+ "integrity": "sha512-D39qtimx0c1fI3ya1Lnhk3E9nONswSKhnffBI0gME9C99fYOkNi04xs8K6pePLhvl1frbDemkaBQ5ikWllR2HQ==",
+ "dev": true
+ },
"unicode-length": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/unicode-length/-/unicode-length-1.0.3.tgz",
@@ -5429,19 +5522,11 @@
"signal-exit": "^3.0.2"
}
},
- "xml2js": {
- "version": "0.4.23",
- "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
- "integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
- "requires": {
- "sax": ">=0.6.0",
- "xmlbuilder": "~11.0.0"
- }
- },
- "xmlbuilder": {
- "version": "11.0.1",
- "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
- "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
+ "xmlcreate": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/xmlcreate/-/xmlcreate-2.0.3.tgz",
+ "integrity": "sha512-HgS+X6zAztGa9zIK3Y3LXuJes33Lz9x+YyTxgrkIdabu2vqcGOWwdfCpf1hWLRrd553wd4QCDf6BBO6FfdsRiQ==",
+ "dev": true
},
"xtend": {
"version": "4.0.2",
diff --git a/package.json b/package.json
index 80b2aaf7..00547b50 100644
--- a/package.json
+++ b/package.json
@@ -26,10 +26,11 @@
"jslint": "eslint app.js lib"
},
"dependencies": {
+ "bent": "^7.0.6",
"config": "^3.2.4",
"debug": "^4.1.1",
"drachtio-fn-b2b-sugar": "0.0.12",
- "drachtio-fsmrf": "^1.5.13",
+ "drachtio-fsmrf": "^1.5.14xs",
"drachtio-srf": "^4.4.27",
"express": "^4.17.1",
"ip": "^1.1.5",
@@ -37,15 +38,14 @@
"jambonz-realtimedb-helpers": "0.1.6",
"moment": "^2.24.0",
"parse-url": "^5.0.1",
- "pino": "^5.14.0",
- "request": "^2.88.0",
- "request-debug": "^0.2.0"
+ "pino": "^5.14.0"
},
"devDependencies": {
"blue-tape": "^1.0.0",
"clear-module": "^4.0.0",
"eslint": "^6.7.2",
"eslint-plugin-promise": "^4.2.1",
+ "jsdoc": "^3.6.3",
"nyc": "^14.1.1",
"tap": "^14.10.2",
"tap-dot": "^2.0.0",
diff --git a/test/data/bad/bad-enum.json b/test/data/bad/bad-enum.json
index 47063e04..13341294 100644
--- a/test/data/bad/bad-enum.json
+++ b/test/data/bad/bad-enum.json
@@ -1,6 +1,6 @@
{
"dial": {
- "action": "http://example.com",
+ "actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{
diff --git a/test/data/bad/missing-required-property.json b/test/data/bad/missing-required-property.json
index 2741a384..a865a1e5 100644
--- a/test/data/bad/missing-required-property.json
+++ b/test/data/bad/missing-required-property.json
@@ -1,6 +1,6 @@
{
"dial": {
- "action": "http://example.com",
+ "actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{
diff --git a/test/data/good/alternate-syntax.json b/test/data/good/alternate-syntax.json
index 62b8053b..ba70b3d0 100644
--- a/test/data/good/alternate-syntax.json
+++ b/test/data/good/alternate-syntax.json
@@ -1,7 +1,7 @@
[
{
"verb": "gather",
- "action": "https://00dd977a.ngrok.io/gather",
+ "actionHook": "https://00dd977a.ngrok.io/gather",
"input": ["speech"],
"timeout": 12,
"recognizer": {
diff --git a/test/data/good/dial-listen.json b/test/data/good/dial-listen.json
index ccb3e363..d7006092 100644
--- a/test/data/good/dial-listen.json
+++ b/test/data/good/dial-listen.json
@@ -1,6 +1,6 @@
{
"dial": {
- "action": "http://example.com",
+ "actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{
diff --git a/test/data/good/dial-phone.json b/test/data/good/dial-phone.json
index fa131ce6..645b469c 100644
--- a/test/data/good/dial-phone.json
+++ b/test/data/good/dial-phone.json
@@ -1,6 +1,6 @@
{
"dial": {
- "action": "http://example.com",
+ "actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{
diff --git a/test/data/good/dial-sip.json b/test/data/good/dial-sip.json
index 7782934a..f359be47 100644
--- a/test/data/good/dial-sip.json
+++ b/test/data/good/dial-sip.json
@@ -1,6 +1,6 @@
{
"dial": {
- "action": "http://example.com",
+ "actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{
diff --git a/test/data/good/dial-transcribe.json b/test/data/good/dial-transcribe.json
index 3ed2be7d..9af21502 100644
--- a/test/data/good/dial-transcribe.json
+++ b/test/data/good/dial-transcribe.json
@@ -1,6 +1,6 @@
{
"dial": {
- "action": "http://example.com",
+ "actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{
@@ -9,7 +9,7 @@
}
],
"transcribe": {
- "transcriptionCallback": "http://example.com/transcribe",
+ "transcriptionHook": "/transcribe",
"recognizer": {
"vendor": "google",
"language" : "en-US",
diff --git a/test/data/good/dial-user.json b/test/data/good/dial-user.json
index b256d848..fde54a6c 100644
--- a/test/data/good/dial-user.json
+++ b/test/data/good/dial-user.json
@@ -1,6 +1,6 @@
{
"dial": {
- "action": "http://example.com",
+ "actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{