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