major revamp of http client functionalit

This commit is contained in:
Dave Horton
2020-02-14 12:45:28 -05:00
parent ff531e6964
commit 446000ee97
35 changed files with 906 additions and 433 deletions

1
app.js
View File

@@ -59,7 +59,6 @@ srf.invite((req, res) => {
session.exec();
});
// HTTP
app.use(express.urlencoded({ extended: true }));
app.use(express.json());

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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}`);

View File

@@ -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;

View File

@@ -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.<br/><br/>
* 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}`);
}

View File

@@ -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() {
}
}

View File

@@ -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;

View File

@@ -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});

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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);

View File

@@ -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": {

View File

@@ -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) {

View File

@@ -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();

10
lib/utils/basic-auth.js Normal file
View File

@@ -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};
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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 {

64
lib/utils/requestor.js Normal file
View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
module.exports = function(tasks) {
return `[${tasks.map((t) => t.name).join(',')}]`;
};

229
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"dial": {
"action": "http://example.com",
"actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{

View File

@@ -1,6 +1,6 @@
{
"dial": {
"action": "http://example.com",
"actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{

View File

@@ -1,7 +1,7 @@
[
{
"verb": "gather",
"action": "https://00dd977a.ngrok.io/gather",
"actionHook": "https://00dd977a.ngrok.io/gather",
"input": ["speech"],
"timeout": 12,
"recognizer": {

View File

@@ -1,6 +1,6 @@
{
"dial": {
"action": "http://example.com",
"actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{

View File

@@ -1,6 +1,6 @@
{
"dial": {
"action": "http://example.com",
"actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{

View File

@@ -1,6 +1,6 @@
{
"dial": {
"action": "http://example.com",
"actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{

View File

@@ -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",

View File

@@ -1,6 +1,6 @@
{
"dial": {
"action": "http://example.com",
"actionHook": "http://example.com",
"callerId": "+1312888899",
"target": [
{