From 1a656f3f0ee970f600e9352a0ddd6648398b76f6 Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Mon, 13 Jan 2020 14:01:19 -0500 Subject: [PATCH] work on say and gather --- lib/call-session.js | 281 +++++++++++++++++++++++++---- lib/middleware.js | 35 ++-- lib/task-list.js | 17 ++ lib/tasks/dial.js | 374 ++++++++++++++++++++++++++++++--------- lib/tasks/gather.js | 160 +++++++++++++++++ lib/tasks/hangup.js | 24 +++ lib/tasks/make_task.js | 21 ++- lib/tasks/say.js | 48 +++++ lib/tasks/sip_decline.js | 18 +- lib/tasks/specs.json | 67 ++++++- lib/tasks/task.js | 20 ++- lib/utils/constants.json | 36 ++++ lib/utils/notifiers.js | 37 ++++ lib/utils/resources.js | 50 ++++++ package-lock.json | 81 --------- package.json | 1 + 16 files changed, 1034 insertions(+), 236 deletions(-) create mode 100644 lib/task-list.js create mode 100644 lib/tasks/gather.js create mode 100644 lib/tasks/hangup.js create mode 100644 lib/tasks/say.js create mode 100644 lib/utils/constants.json create mode 100644 lib/utils/notifiers.js create mode 100644 lib/utils/resources.js diff --git a/lib/call-session.js b/lib/call-session.js index f24999d5..d09dd9db 100644 --- a/lib/call-session.js +++ b/lib/call-session.js @@ -1,5 +1,15 @@ const Emitter = require('events'); const config = require('config'); +const TaskList = require('./task-list'); +const request = require('request'); +const notifiers = require('./utils/notifiers'); +const {CallStatus, CallDirection, TaskPreconditions} = require('./utils/constants'); +//require('request-debug')(request); +const makeTask = require('./tasks/make_task'); +const resourcesMixin = require('./utils/resources'); +const moment = require('moment'); +const assert = require('assert'); +const BADPRECONDITIONS = 'preconditions not met'; class CallSession extends Emitter { constructor(req, res) { @@ -9,47 +19,144 @@ class CallSession extends Emitter { this.srf = req.srf; this.logger = req.locals.logger; this.application = req.locals.application; + this.statusCallback = this.application.call_status_hook; + this.statusCallbackMethod = this.application.status_hook_http_method || 'POST'; + this.idxTask = 0; this.resources = new Map(); + this.direction = CallDirection.Inbound; + this.callAttributes = req.locals.callAttributes; + + // array of TaskLists, the one currently executing is at the front + this._executionStack = [new TaskList(this.application.tasks, this.callSid)]; + this.childCallSids = []; + this.calls = new Map(); + this.calls.set(this.parentCallSid, {ep: null, dlg: null}); + + this.hooks = notifiers(this.logger, this.callAttributes); req.on('cancel', this._onCallerHangup.bind(this)); - this.on('callStatusChange', this._onCallStatusChange.bind(this)); } + get callSid() { return this.callAttributes.CallSid; } + get parentCallSid() { return this.callAttributes.CallSid; } + get actionHook() { return this.hooks.actionHook; } + async exec() { let idx = 0; - for (const task of this.application.tasks) { - try { - this.logger.debug(`CallSession: executing task #${++idx}: ${task.name}`); - const continueOn = await task.exec(this); - if (!continueOn) break; - } catch (err) { - this.logger.error({err, task}, 'Error executing task'); + while (this._executionStack.length) { + const taskList = this.currentTaskList = this._executionStack.shift(); + this.logger.debug(`CallSession:exec starting task list with ${taskList.tasks.length} tasks`); + while (taskList.length) { + const {task, callSid} = taskList.shift(); + this.logger.debug(`CallSession:exec starting task #${++idx}: ${task.name}`); + try { + const resources = await this._evaluatePreconditions(task, callSid); + await task.exec(this, resources); + this.logger.debug(`CallSession:exec completed task #${idx}: ${task.name}`); + } catch (err) { + if (err.message.includes(BADPRECONDITIONS)) { + this.logger.info(`CallSession:exec task #${idx}: ${task.name}: ${err.message}`); + } + else { + this.logger.error(err, `Error executing task #${idx}: ${task.name}`); + break; + } + } } - this.logger.info('CallSession: finished all tasks'); - if (!this.res.finalResponseSent) { - this.logger.info('CallSession: auto-generating non-success response to invite'); - this.res.send(603); - } - this._clearResources(); + } + + // all done - cleanup + this.logger.info('CallSession:exec finished all tasks'); + if (!this.res.finalResponseSent) { + this.logger.info('CallSession:exec auto-generating non-success response to invite'); + this.res.send(603); + } + this._clearCalls(); + this.clearResources(); // still needed? ms may be only thing in here + } + + _evaluatePreconditions(task, callSid) { + switch (task.preconditions) { + case TaskPreconditions.None: + return; + case TaskPreconditions.Endpoint: + return this._evalEndpointPrecondition(task, callSid); + case TaskPreconditions.StableCall: + return this._evalStableCallPrecondition(task, callSid); + case TaskPreconditions.UnansweredCall: + return this._evalUnansweredCallPrecondition(task, callSid); + default: + assert(0, `invalid/unknown or missing precondition type ${task.preconditions} for task ${task.name}`); } } - addResource(name, resource) { - this.logger.debug(`CallSession:addResource: adding ${name}`); - this.resources.set(name, resource); + async _evalEndpointPrecondition(task, callSid) { + const resources = this.calls.get(callSid); + if (!resources) throw new Error(`task ${task.name} attempting to operate on unknown CallSid ${callSid}`); + if (resources.ep) return resources.ep; + + try { + // need to allocate an endpoint + const mrf = this.srf.locals.mrf; + let ms = this.getResource('ms'); + if (!ms) { + ms = await mrf.connect(config.get('freeswitch')); + this.addResource('ms', ms); + } + const ep = await ms.createEndpoint({remoteSdp: this.req.body}); + resources.ep = ep; + if (task.earlyMedia && callSid === this.parentCallSid && this.req && !this.req.finalResponseSent) { + this.res.send(183, {body: ep.local.sdp}); + this.calls.set(callSid, resources); + return ep; + } + const uas = await this.srf.createUAS(this.req, this.res, {localSdp: ep.local.sdp}); + resources.dlg = uas; + this.calls.set(callSid, resources); + return ep; + } catch (err) { + this.logger.error(err, `Error attempting to allocate endpoint for for task ${task.name}`); + throw new Error(`${BADPRECONDITIONS}: unable to allocate endpoint - callSid ${callSid}`); + } } - getResource(name) { - return this.resources.get(name); + _evalStableCallPrecondition(task, callSid) { + const resources = this.calls.get(callSid); + if (!resources) throw new Error(`task ${task.name} attempting to operate on unknown callSid ${callSid}`); + if (resources.dlg) throw new Error(`${BADPRECONDITIONS}: call was not answered - callSid ${callSid}`); + return resources.dlg; } - removeResource(name) { - this.logger.debug(`CallSession:removeResource: removing ${name}`); - this.resources.delete(name); + _evalUnansweredCallPrecondition(task, callSid) { + if (callSid !== this.parentCallSid || !this.req) { + throw new Error(`${BADPRECONDITIONS}: no inbound call - callSid ${callSid}`); + } + if (this.req.finalResponseSent) { + throw new Error(`${BADPRECONDITIONS}: final sip status already sent - callSid ${callSid}`); + } + return {req: this.req, res: this.res}; } - async createOrRetrieveEpAndMs(remoteSdp) { + _clearCalls() { + for (const [callSid, resources] of Array.from(this.calls).reverse()) { + try { + this.logger.debug(`CallSession:_clearCalls clearing call sid ${callSid}`); + [resources.ep, resources.dlg].forEach((r) => { + if (r && r.connected) r.destroy(); + }); + } catch (err) { + this.logger.error(err, `clearResources: clearing call sid ${callSid}`); + } + } + this.calls.clear(); + } + + + /** + * retrieve the media server and endpoint for this call, allocate them if needed + */ + async createOrRetrieveEpAndMs() { const mrf = this.srf.locals.mrf; let ms = this.getResource('ms'); let ep = this.getResource('epIn'); @@ -61,34 +168,136 @@ class CallSession extends Emitter { this.addResource('ms', ms); } if (!ep) { - ep = await ms.createEndpoint({remoteSdp}); + ep = await ms.createEndpoint({remoteSdp: this.req.body}); this.addResource('epIn', ep); } return {ms, ep}; } - /** - * clear down resources - * (note: we remove in reverse order they were added since mediaserver - * is typically added first and I prefer to destroy it after any resources it holds) - */ - _clearResources() { - for (const [name, resource] of Array.from(this.resources).reverse()) { - try { - this.logger.info(`CallSession:_clearResources: deleting ${name}`); - if (resource.connected) resource.destroy(); - } catch (err) { - this.logger.error(err, `CallSession:_clearResources: error deleting ${name}`); + async connectInboundCallToIvr(earlyMedia = false) { + + // if this is not an inbound call scenario, nothing to do + if (!this.parentCallSid) { + this.logger.debug('CallSession:connectInboundCallToIvr - session was not triggered by an inbound call'); + return; + } + + // check for a stable inbound call already connected to the ivr + const ms = this.getResource('ms'); + const resources = this.calls.get(this.parentCallSid); + if (ms && resources.ep && resources.dlg) { + this.logger.debug('CallSession:connectInboundCallToIvr - inbound call already connected to IVR'); + return {ms, ep: resources.ep, dlg: resources.dlg}; + } + + // check for an early media connection, where caller wants same + if (ms && resources.ep && earlyMedia) { + this.logger.debug('CallSession:connectInboundCallToIvr - inbound call already has early media connection'); + return {ms, ep: resources.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, ms} = await this.createOrRetrieveEpAndMs(); + + if (earlyMedia) { + this.res.send(183, {body: ep.local.sdp}); + this.calls.set(this.parentCallSid, {ep}); + return {ep, ms, res: this.res}; } + const dlg = await this.srf.createUAS(this.req, this.res, {localSdp: ep.local.sdp}); + this.calls.set(this.parentCallSid, {ep, dlg}); + return {ep, ms, dlg}; + } catch (err) { + this.logger.error(err, 'CallSession:connectInboundCallToIvr error'); + throw err; } } + async answerParentCall(remoteSdp) { + assert(this.parentCallSid, 'CallSession:answerParentCall - no parent call sid'); + const resources = this.calls.get(this.parentCallSid); + resources.dlg = await this.srf.createUAS(this.req, this.res, {localSdp: remoteSdp}); + resources.set(this.parentCallSid, resources); + } + + /** + * allocate a new endpoint for this call, caller's responsibility to destroy + */ + async createEndpoint(remoteSdp) { + try { + let ms = this.getResource('ms'); + if (!ms) { + const mrf = this.srf.locals.mrf; + ms = await mrf.connect(config.get('freeswitch')); + this.addResource('ms', ms); + } + const ep = await ms.createEndpoint({remoteSdp}); + return ep; + } catch (err) { + this.logger.error(err, `CallSession:createEndpoint: error creating endpoint for remoteSdp ${remoteSdp}`); + throw err; + } + } + + /** + * Replace the currently-executing application with a new application + * NB: any tasks in the current stack that have not been executed are flushed + * @param {object|array} payload - new application to execute + */ + replaceApplication(payload) { + const taskData = Array.isArray(payload) ? payload : [payload]; + const tasks = []; + for (const t in taskData) { + try { + const task = makeTask(this.logger, taskData[t]); + tasks.push(task); + } catch (err) { + this.logger.info({data: taskData[t]}, `invalid web callback payload: ${err.message}`); + return; + } + } + this.application.tasks = tasks; + this.idxTask = 0; + this.logger.debug(`CallSession:replaceApplication - set ${tasks.length} new tasks`); + } + + /** + * got CANCEL from inbound leg + */ _onCallerHangup(evt) { this.logger.debug('CallSession: caller hung before connection'); } + + /** + * got BYE from inbound leg + */ _onCallStatusChange(evt) { this.logger.debug(evt, 'CallSession:_onCallStatusChange'); + if (this.statusCallback) { + if (evt.status === CallStatus.InProgress) this.connectTime = moment(); + const params = Object.assign(this.callAttributes, {CallStatus: evt.status, SipStatus: evt.sipStatus}); + if (evt.status === CallStatus.Completed) { + const duration = moment().diff(this.connectTime, 'seconds'); + this.logger.debug(`CallSession:_onCallStatusChange duration was ${duration}`); + Object.assign(params, {Duration: duration}); + } + const opts = { + url: this.statusCallback, + method: this.statusCallbackMethod, + json: true, + qs: 'GET' === this.statusCallbackMethod ? params : null, + body: 'POST' === this.statusCallbackMethod ? params : null + }; + request(opts, (err) => { + if (err) this.logger.info(`Error sending call status to ${this.statusCallback}: ${err.message}`); + }); + } } } +Object.assign(CallSession.prototype, resourcesMixin); + module.exports = CallSession; diff --git a/lib/middleware.js b/lib/middleware.js index eda394dc..94309205 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -1,9 +1,10 @@ -const debug = require('debug')('jambonz:feature-server'); +//const debug = require('debug')('jambonz:feature-server'); const assert = require('assert'); const request = require('request'); //require('request-debug')(request); const uuidv4 = require('uuid/v4'); const makeTask = require('./tasks/make_task'); +const {CallStatus, CallDirection} = require('./utils/constants'); module.exports = function(srf, logger) { const {lookupAppByPhoneNumber} = srf.locals.dbHelpers; @@ -65,30 +66,32 @@ module.exports = function(srf, logger) { const logger = req.locals.logger; const app = req.locals.application; const call_sid = uuidv4(); - const method = (app.hook_http_method || 'GET').toUpperCase(); + const method = (app.hook_http_method || 'POST').toUpperCase(); const from = req.getParsedHeader('From'); + const qs = req.locals.callAttributes = { + CallSid: call_sid, + AccountSid: app.account_sid, + From: req.callingNumber, + To: req.calledNumber, + CallStatus: CallStatus.Trying, + SipStatus: 100, + Direction: CallDirection.Inbound, + CallerName: from.name || req.callingNumber, + SipCallID: req.get('Call-ID'), + RequestorIP: req.get('X-Forwarded-For'), + RequestorName: req.get('X-Originating-Carrier') + }; const opts = { url: app.call_hook, method, json: true, - qs: { - CallSid: call_sid, - AccountSid: app.account_sid, - From: req.callingNumber, - To: req.calledNumber, - CallStatus: 'ringing', - Direction: 'inbound', - CallerName: from.name || req.callingNumber - } + qs }; if (app.hook_basic_auth_user && app.hook_basic_auth_password) { Object.assign(opts, {auth: {user: app.hook_basic_auth_user, password: app.hook_basic_auth_password}}); } - if (method === 'POST') { - Object.assign(opts, {json: true, body: req.msg}); - } + if (method === 'POST') Object.assign(opts, {body: req.msg}); try { - assert(app && app.call_hook); request(opts, (err, response, body) => { if (err) { logger.error(err, `Error invoking callback ${app.call_hook}`); @@ -102,7 +105,7 @@ module.exports = function(srf, logger) { const task = makeTask(logger, taskData[t]); app.tasks.push(task); } catch (err) { - logger.info({data: taskData[t]}, `invalid web callback payload: ${err.message}`); + logger.error({err, data: taskData[t]}, `invalid web callback payload: ${err.message}`); res.send(500, 'Application Error', { headers: { 'X-Reason': err.message diff --git a/lib/task-list.js b/lib/task-list.js new file mode 100644 index 00000000..5b874129 --- /dev/null +++ b/lib/task-list.js @@ -0,0 +1,17 @@ +class TaskList { + constructor(tasks, callSid) { + this.tasks = tasks; + this.callSid = callSid; + } + + shift() { + const task = this.tasks.shift(); + if (task) return {task, callSid: this.callSid}; + } + + get length() { + return this.tasks.length; + } +} + +module.exports = TaskList; diff --git a/lib/tasks/dial.js b/lib/tasks/dial.js index cc743ddd..e4be8dda 100644 --- a/lib/tasks/dial.js +++ b/lib/tasks/dial.js @@ -1,142 +1,356 @@ const Task = require('./task'); -const name = 'dial'; const makeTask = require('./make_task'); +const {CallStatus, CallDirection, TaskName, TaskPreconditions} = require('../utils/constants'); +const SipError = require('drachtio-srf').SipError; const assert = require('assert'); +const uuidv4 = require('uuid/v4'); +const request = require('request'); +const moment = require('moment'); +function isFinalCallStatus(status) { + return [CallStatus.Completed, CallStatus.NoAnswer, CallStatus.Failed, CallStatus.Busy].includes(status); +} class TaskDial extends Task { constructor(logger, opts) { super(logger, opts); - this.name = name; - this.headers = this.data.headers || {}; - this.answerOnBridge = opts.answerOnBridge === true; - this.timeout = opts.timeout || 60; - this.method = opts.method || 'GET'; - this.dialMusic = opts.dialMusic; - this.timeLimit = opts.timeLimit; - this.strategy = opts.strategy || 'hunt'; - this.target = opts.target; - this.canceled = false; - this.finished = false; - this.localResources = {}; + this.preconditions = TaskPreconditions.None; + + this.action = opts.action; + this.earlyMedia = opts.answerOnBridge === true; + this.callerId = opts.callerId; + this.dialMusic = opts.dialMusic; + this.headers = this.data.headers || {}; + this.method = opts.method || 'POST'; + this.statusCallback = opts.statusCallback; + this.statusCallbackMethod = opts.statusCallbackMethod || 'POST'; + this.target = opts.target; + this.timeout = opts.timeout || 60; + this.timeLimit = opts.timeLimit; - if (opts.transcribe) { - this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}); - } if (opts.listen) { this.listenTask = makeTask(logger, {'listen': opts.transcribe}); } + if (opts.transcribe) { + this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}); + } + + this.canceled = false; + this.callAttributes = {}; + this.dialCallStatus = CallStatus.Failed; + this.dialCallSid = null; + this.dialCallDuration = null; + + this.on('callStatusChange', this._onCallStatusChange.bind(this)); } - static get name() { return name; } + get name() { return TaskName.Dial; } - /** - * Reject an incoming call attempt with a provided status code and (optionally) reason - */ async exec(cs) { try { + this._initializeCallData(cs); await this._initializeInbound(cs); - //await connectCall(cs); - await this._untilCallEnds(cs); + await this._attemptCalls(cs); + await this._waitForCompletion(cs); } catch (err) { - this.logger.info(`TaskDial:exec terminating with error ${err.message}`); + this.logger.error(`TaskDial:exec terminating with error ${err.message}`); } + await this._actionHook(cs); + this.clearResources(); return true; } + _initializeCallData(cs) { + this.logger.debug(`TaskDial:_initializeCallData parent call sid is ${cs.callSid}`); + Object.assign(this.callAttributes, { + AccountSid: cs.AccountSid, + ParentCallSid: cs.callSid, + Direction: CallDirection.Outbound + }); + } + async _initializeInbound(cs) { - const {req, res} = cs; + const {req} = cs; // the caller could hangup in the middle of all this.. req.on('cancel', this._onCancel.bind(this, cs)); try { - const {ep} = await cs.createOrRetrieveEpAndMs(req.body); - - // caller might have hung up while we were doing that - if (this.canceled) throw new Error('caller hung up'); - - // check if inbound leg has already been answered - let uas = cs.getResource('dlgIn'); - - if (!uas) { - // if answerOnBridge send a 183 (early media), otherwise go ahead and answer the call - if (this.answerOnBridge && !req.finalResponseSent) { - if (!this.dialMusic) res.send(180); - else { - res.send(183, {body: ep.remote.sdp}); - } - } - else { - uas = await cs.srf.createUAS(req, res, {localSdp: ep.local.sdp}); - cs.addResource('dlgIn', uas); - uas.on('destroy', this._onCallerHangup.bind(this, cs, uas)); - } - cs.emit('callStatusChange', {status: 'ringing'}); - } + const result = await cs.connectInboundCallToIvr(this.earlyMedia); + if (!result) throw new Error('outbound dial via API not supported yet'); + const {ep, dlg, res} = result; + assert(ep); // play dial music to caller, if provided if (this.dialMusic) { ep.play(this.dialMusic, (err) => { if (err) this.logger.error(err, `TaskDial:_initializeInbound - error playing ${this.dialMusic}`); }); } + this.epIn = ep; + this.dlgIn = dlg; + this.res = res; } catch (err) { this.logger.error(err, 'TaskDial:_initializeInbound error'); - this.finished = true; - if (!res.finalResponseSent && !this.canceled) res.send(500); - this._clearResources(cs); throw err; } } - _clearResources(cs) { - for (const key in this.localResources) { - this.localResources[key].destroy(); + async _attemptCalls(cs) { + const {req, srf} = cs; + + // send all outbound calls back to originating SBC for simplicity + const sbcAddress = `${req.source_address}:${req.source_port}`; + + const callSid = uuidv4(); + let newCallId, to, from; + try { + // create an endpoint for the outbound call + const epOut = await cs.createEndpoint(); + this.addResource('epOut', epOut); + + const {uri, opts} = this._prepareOutdialAttempt(this.target[0], sbcAddress, + this.callerId || req.callingNumber, epOut.local.sdp); + + let streamConnected = false; + + const connectStreams = async(remoteSdp) => { + streamConnected = true; + epOut.modify(remoteSdp); + this.epIn.bridge(epOut); + if (!this.dlgIn) { + this.dlgIn = await cs.srf.answerParentCall(this.epIn.local.sdp); + } + }; + + // outdial requested destination + const uac = await srf.createUAC(uri, opts, { + cbRequest: (err, reqSent) => { + this.outboundInviteInProgress = reqSent; + newCallId = req.get('Call-ID'); + from = reqSent.callingNumber, + to = reqSent.calledNumber; + this.emit('callStatusChange', { + CallSid: callSid, + SipCallId: newCallId, + CallStatus: CallStatus.Trying, + From: from, + To: to, + SipStatus: 100 + }); + }, + cbProvisional: (prov) => { + if ([180, 183].includes(prov.status)) { + this.emit('callStatusChange', { + CallSid: callSid, + SipCallId: newCallId, + CallStatus: prov.body ? CallStatus.EarlyMedia : CallStatus.Ringing, + From: from, + To: to, + SipStatus: prov.status + }); + if (!streamConnected && prov.body) connectStreams(prov.body); + } + } + }); + + // outbound call was established + uac.connectTime = moment(); + uac.callSid = this.dialCallSid = callSid; + uac.from = from; + uac.to = to; + this.emit('callStatusChange', { + CallSid: callSid, + SipCallId: newCallId, + From: from, + To: to, + CallStatus: CallStatus.InProgress, + SipStatus: 200 + }); + uac.on('destroy', () => { + const duration = this.dialCallDuration = moment().diff(uac.connectTime, 'seconds'); + this.emit('callStatusChange', { + CallSid: callSid, + SipCallId: newCallId, + From: from, + To: to, + CallStatus: CallStatus.Completed, + Duration: duration + }); + }); + if (!streamConnected) connectStreams(uac.remote.sdp); + this.outboundInviteInProgress = null; + this.addResource('dlgOut', uac); + } catch (err) { + if (err instanceof SipError) { + switch (err.status) { + case 487: + this.emit('callStatusChange', { + CallSid: callSid, + SipCallId: newCallId, + From: from, + To: to, + CallStatus: CallStatus.NoAnswer, + SipStatus: err.status + }); + break; + case 486: + case 600: + this.emit('callStatusChange', { + CallSid: callSid, + SipCallId: newCallId, + From: from, + To: to, + CallStatus: CallStatus.Busy, + SipStatus: err.status + }); + break; + default: + this.emit('callStatusChange', {callSid, + CallSid: callSid, + SipCallId: newCallId, + From: from, + To: to, + CallStatus: CallStatus.Failed, + SipStatus: err.status + }); + break; + } + if (err.status !== 487) { + this.logger.info(`TaskDial:_connectCall outdial failed with ${err.status}`); + } + } + else { + this.emit('callStatusChange', { + CallSid: callSid, + SipCallId: newCallId, + From: from, + To: to, + CallStatus: CallStatus.Failed, + SipStatus: 500 + }); + this.logger.error(err, 'TaskDial:_connectCall error'); + } + throw err; } - this.localResources = {}; + } + + _prepareOutdialAttempt(target, sbcAddress, callerId, sdp) { + const opts = { + headers: this.headers, + proxy: `sip:${sbcAddress}`, + callingNumber: callerId, + localSdp: sdp + }; + let uri; + + switch (target.type) { + case 'phone': + uri = `sip:${target.number}@${sbcAddress}`; + break; + case 'sip': + uri = target.uri; + if (target.auth) Object.assign(opts, {auth: target.auth}); + break; + case 'user': + uri = `sip:${target.name}`; + break; + default: + assert(0, `TaskDial:_prepareOutdialAttempt invalid target type ${target.type}; please fix specs.json`); + } + return {uri, opts}; } _onCancel(cs) { this.logger.info('TaskDial: caller hung up before connecting'); - this.canceled = this.finished = true; - this._clearResources(); - cs.emit('callStatusChange', {status: 'canceled'}); + this.canceled = true; + cs.emit('callStatusChange', {status: CallStatus.NoAnswer}); } _onCallerHangup(cs, dlg) { - cs.emit('callStatusChange', {status: 'canceled'}); - this.finished = true; - this._clearResources(); + this.logger.info('TaskDial: caller hung up'); + cs.emit('callStatusChange', {status: CallStatus.Completed}); + if (this.outboundInviteInProgress) this.outboundInviteInProgress.cancel(); + + // we are going to hang up the B leg shortly..so + const dlgOut = this.getResource('dlgOut'); + if (dlgOut) { + const duration = this.dialCallDuration = moment().diff(dlgOut.connectTime, 'seconds'); + this.emit('callStatusChange', { + CallSid: dlgOut.callSid, + SipCallId: dlgOut.sip.callId, + From: dlgOut.from, + To: dlgOut.to, + CallStatus: CallStatus.Completed, + Duration: duration + }); + } } + /** - * returns a Promise that resolves when the call ends + * returns a Promise that resolves when either party hangs up */ - _untilCallEnds(cs) { - const {res} = cs; + _waitForCompletion(cs) { return new Promise((resolve) => { - assert(!this.finished); + const dlgOut = this.getResource('dlgOut'); + assert(this.dlgIn && dlgOut); + assert(this.dlgIn.connected && dlgOut.connected); - //TMP - hang up in 5 secs - setTimeout(() => { - res.send(480); - this._clearResources(); - resolve(); - }, 5000); - //TMP - - /* - const dlgOut = this.localResources.dlgOut; - assert(dlgIn.connected && dlgOut.connected); - - [this.dlgIn, this.dlgOut].forEach((dlg) => { - dlg.on('destroy', () => resolve()); - }); - */ + [this.dlgIn, dlgOut].forEach((dlg) => dlg.on('destroy', () => resolve())); }); } + + _onCallStatusChange(evt) { + this.logger.debug(evt, 'TaskDial:_onCallStatusChange'); + + // save the most recent final call status of a B leg, until we get one that is completed + if (isFinalCallStatus(evt.CallStatus) && this.dialCallStatus !== CallStatus.Completed) { + this.dialCallStatus = evt.CallStatus; + } + if (this.statusCallback) { + const params = Object.assign({}, this.callAttributes, evt); + const opts = { + url: this.statusCallback, + method: this.statusCallbackMethod, + json: true, + qs: 'GET' === this.statusCallbackMethod ? params : null, + body: 'POST' === this.statusCallbackMethod ? params : null + }; + request(opts, (err) => { + if (err) this.logger.info(`TaskDial:Error sending call status to ${this.statusCallback}: ${err.message}`); + }); + } + } + + async _actionHook(cs) { + if (this.action) { + const params = {DialCallStatus: this.dialCallStatus}; + Object.assign(params, { + DialCallSid: this.dialCallSid, + DialCallDuration: this.dialCallDuration + }); + const opts = { + url: this.action, + method: this.method, + json: true, + qs: 'GET' === this.method ? params : null, + body: 'POST' === this.method ? params : null + }; + + return new Promise((resolve, reject) => { + request(opts, (err, response, body) => { + if (err) this.logger.info(`TaskDial:_actionHook sending call status to ${this.action}: ${err.message}`); + if (body) { + this.logger.debug(body, 'got new application payload'); + cs.replaceApplication(body); + } + resolve(); + }); + }); + } + } } module.exports = TaskDial; diff --git a/lib/tasks/gather.js b/lib/tasks/gather.js new file mode 100644 index 00000000..89fe0fba --- /dev/null +++ b/lib/tasks/gather.js @@ -0,0 +1,160 @@ +const Task = require('./task'); +const {TaskName, TaskPreconditions} = require('../utils/constants'); +const makeTask = require('./make_task'); +const assert = require('assert'); + +class TaskGather extends Task { + constructor(logger, opts) { + super(logger, opts); + this.preconditions = TaskPreconditions.Endpoint; + + [ + 'action', 'finishOnKey', 'hints', 'input', 'language', 'method', 'numDigits', + 'partialResultCallback', 'partialResultCallbackMethod', 'profanityFilter', + 'speechTimeout', 'timeout', 'say' + ].forEach((k) => this[k] = this.data[k]); + + this.partialResultCallbackMethod = this.partialResultCallbackMethod || 'POST'; + this.method = this.method || 'POST'; + this.timeout = (this.timeout || 5) * 1000; + this.language = this.language || 'en-US'; + this.digitBuffer = ''; + + if (this.say) { + this.sayTask = makeTask(this.logger, {say: this.say}); + } + } + + get name() { return TaskName.Gather; } + + async exec(cs, ep) { + this.ep = ep; + this.actionHook = cs.actionHook; + + this.taskInProgress = true; + try { + if (this.sayTask) { + this.sayTask.exec(cs, ep); // kicked off, _not_ waiting for it to complete + this.sayTask.on('playDone', this._onPlayDone.bind(this, ep)); + } + else this._startTimer(); + + if (this.input.includes('speech')) { + const opts = { + GOOGLE_SPEECH_USE_ENHANCED: true, + GOOGLE_SPEECH_SINGLE_UTTERANCE: true, + GOOGLE_SPEECH_MODEL: 'phone_call' + }; + if (this.hints) { + Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')}); + } + if (this.profanityFilter === true) { + Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true}); + } + this.logger.debug(`setting freeswitch vars ${JSON.stringify(opts)}`); + await ep.set(opts) + .catch((err) => this.logger.info(err, 'Error set')); + ep.addCustomEventListener('google_transcribe::transcription', this._onTranscription.bind(this, ep)); + ep.addCustomEventListener('google_transcribe::no_audio_detected', this._onNoAudioDetected.bind(this, ep)); + ep.addCustomEventListener('google_transcribe::max_duration_exceeded', this._onMaxDuration.bind(this, ep)); + this.logger.debug('starting transcription'); + ep.startTranscription({ + interim: this.partialResultCallback ? true : false, + language: this.language + }).catch((err) => this.logger.error(err, 'TaskGather:exec error starting transcription')); + } + + if (this.input.includes('dtmf')) { + ep.on('dtmf', this._onDtmf.bind(this, ep)); + } + + await this._waitForCompletion(); + } catch (err) { + this.logger.error(err, 'TaskGather:exec error'); + } + this.taskInProgress = false; + ep.removeAllListeners(); + } + + kill() { + this._killAudio(); + this._resolve('killed'); + } + + async _waitForCompletion() { + return new Promise((resolve) => this.resolver = resolve); + } + + _onPlayDone(ep, err, evt) { + if (err || !this.taskInProgress) return; + this.logger.debug(evt, 'TaskGather:_onPlayDone, starting input timer'); + this._startTimer(); + } + + _onDtmf(ep, evt) { + this.logger.debug(evt, 'TaskGather:_onDtmf'); + if (evt.dtmf === this.finishOnKey) this._resolve('dtmf-terminator-key'); + else { + this.digitBuffer += evt.dtmf; + if (this.digitBuffer.length === this.numDigits) this._resolve('dtmf-num-digits'); + } + this._killAudio(); + } + + _startTimer() { + assert(!this._timeoutTimer); + this._timeoutTimer = setTimeout(() => this._resolve('timeout'), this.timeout); + } + + _clearTimer() { + if (this._timeoutTimer) { + clearTimeout(this._timeoutTimer); + this._timeoutTimer = null; + } + } + + _killAudio() { + if (this.sayTask) { + this.sayTask.kill(); + this.sayTask = null; + } + } + + _onTranscription(ep, evt) { + this.logger.debug(evt, 'TaskGather:_onTranscription'); + if (evt.is_final) { + this._resolve('speech', evt); + } + else if (this.partialResultCallback) { + this.actionHook(this.partialResultCallback, 'POST', { + Speech: evt + }); + } + } + _onNoAudioDetected(ep, evt) { + this.logger.info(evt, 'TaskGather:_onNoAudioDetected'); + } + _onMaxDuration(ep, evt) { + this.logger.info(evt, 'TaskGather:_onMaxDuration'); + } + + _resolve(reason, evt) { + this.logger.debug(`TaskGather:resolve with reason ${reason}`); + assert(this.resolver); + + if (reason.startsWith('dtmf')) { + this.actionHook(this.action, this.method, { + Digits: this.digitBuffer + }); + } + else if (reason.startsWith('speech')) { + this.actionHook(this.action, this.method, { + Speech: evt + }); + } + this._clearTimer(); + this.resolver(); + } +} + +module.exports = TaskGather; diff --git a/lib/tasks/hangup.js b/lib/tasks/hangup.js new file mode 100644 index 00000000..8065d608 --- /dev/null +++ b/lib/tasks/hangup.js @@ -0,0 +1,24 @@ +const Task = require('./task'); +const {TaskName} = require('../utils/constants'); + +class TaskHangup extends Task { + constructor(logger, opts) { + super(logger, opts); + this.headers = this.data.headers || {}; + } + + get name() { return TaskName.Hangup; } + + /** + * Hangup the call + */ + async exec(cs, dlg) { + try { + await dlg.destroy({headers: this.headers}); + } catch (err) { + this.logger.error(err, `TaskHangup:exec - Error hanging up call with sip call id ${dlg.sip.callId}`); + } + } +} + +module.exports = TaskHangup; diff --git a/lib/tasks/make_task.js b/lib/tasks/make_task.js index 0a30fe3f..68cc9871 100644 --- a/lib/tasks/make_task.js +++ b/lib/tasks/make_task.js @@ -1,9 +1,9 @@ const Task = require('./task'); -const TaskSipDecline = require('./sip_decline'); -const TaskDial = require('./dial'); +const {TaskName} = require('../utils/constants'); const errBadInstruction = new Error('invalid instruction payload'); function makeTask(logger, opts) { + logger.debug(opts, 'makeTask'); if (typeof opts !== 'object' || Array.isArray(opts)) throw errBadInstruction; const keys = Object.keys(opts); if (keys.length !== 1) throw errBadInstruction; @@ -11,8 +11,21 @@ function makeTask(logger, opts) { const data = opts[name]; Task.validate(name, data); switch (name) { - case TaskSipDecline.name: return new TaskSipDecline(logger, data); - case TaskDial.name: return new TaskDial(logger, data); + case TaskName.SipDecline: + const TaskSipDecline = require('./sip_decline'); + return new TaskSipDecline(logger, data); + case TaskName.Dial: + const TaskDial = require('./dial'); + return new TaskDial(logger, data); + case TaskName.Hangup: + const TaskHangup = require('./hangup'); + return new TaskHangup(logger, data); + case TaskName.Say: + const TaskSay = require('./say'); + return new TaskSay(logger, data); + case TaskName.Gather: + const TaskGather = require('./gather'); + return new TaskGather(logger, data); } // should never reach diff --git a/lib/tasks/say.js b/lib/tasks/say.js new file mode 100644 index 00000000..749f55d1 --- /dev/null +++ b/lib/tasks/say.js @@ -0,0 +1,48 @@ +const Task = require('./task'); +const {TaskName, TaskPreconditions} = require('../utils/constants'); + +class TaskSay extends Task { + constructor(logger, opts) { + super(logger, opts); + this.preconditions = TaskPreconditions.Endpoint; + + this.text = this.data.text; + this.voice = this.data.synthesizer.voice; + this.earlyMedia = this.data.earlyMedia === true; + + switch (this.data.synthesizer.vendor) { + case 'google': + this.ttsEngine = 'google_tts'; + break; + default: + throw new Error(`unsupported tts vendor ${this.data.synthesizer.vendor}`); + } + this.sayComplete = false; + } + + get name() { return TaskName.Say; } + + async exec(cs, ep) { + this.ep = ep; + try { + await ep.speak({ + ttsEngine: 'google_tts', + voice: this.voice, + text: this.text + }); + } catch (err) { + if (err.message !== 'hangup') this.logger.info(err, 'TaskSay:exec error'); + } + this.emit('playDone'); + this.sayComplete = true; + } + + kill() { + if (this.ep.connected && !this.sayComplete) { + this.logger.debug('TaskSay:kill - killing audio'); + this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio')); + } + } +} + +module.exports = TaskSay; diff --git a/lib/tasks/sip_decline.js b/lib/tasks/sip_decline.js index 2df2ec24..b083c12d 100644 --- a/lib/tasks/sip_decline.js +++ b/lib/tasks/sip_decline.js @@ -1,25 +1,23 @@ const Task = require('./task'); -const name = 'sip:decline'; +const {TaskName, TaskPreconditions} = require('../utils/constants'); class TaskSipDecline extends Task { constructor(logger, opts) { super(logger, opts); - this.name = name; + this.preconditions = TaskPreconditions.UnansweredCall; + this.headers = this.data.headers || {}; } - static get name() { return name; } + get name() { return TaskName.SipDecline; } /** * Reject an incoming call attempt with a provided status code and (optionally) reason */ - async exec(cs) { - if (!cs.res.finalResponseSent) { - cs.res.send(this.data.status, this.data.reason, { - headers: this.headers - }); - } - return false; + async exec(cs, {res}) { + res.send(this.data.status, this.data.reason, { + headers: this.headers + }); } } diff --git a/lib/tasks/specs.json b/lib/tasks/specs.json index d7f65988..9f7e7176 100644 --- a/lib/tasks/specs.json +++ b/lib/tasks/specs.json @@ -9,26 +9,63 @@ "status" ] }, + "hangup": { + "properties": { + "headers": "object" + }, + "required": [ + ] + }, + "say": { + "properties": { + "text": "string", + "loop": "number", + "synthesizer": "#synthesizer" + }, + "required": [ + "text", + "synthesizer" + ] + }, + "gather": { + "properties": { + "action": "string", + "finishOnKey": "string", + "hints": "array", + "input": "array", + "language": "string", + "numDigits": "number", + "partialResultCallback": "string", + "profanityFilter": "boolean", + "speechTimeout": "number", + "timeout": "number", + "say": "#say" + }, + "required": [ + "action" + ] + }, "dial": { "properties": { "action": "string", "answerOnBridge": "boolean", "callerId": "string", "dialMusic": "string", + "headers": "object", + "listen": "#listen", "method": { "type": "string", "enum": ["GET", "POST"] }, + "statusCallback": "string", + "statusCallbackMethod": { + "type": "string", + "enum": ["GET", "POST"] + }, "target": ["#target"], "timeLimit": "number", "timeout": "number", - "headers": "object", - "strategy": { - "type": "string", - "enum": ["hunt", "simring"] - }, - "transcribe": "#transcribe", - "listen": "#listen" + "transcribe": "#transcribe" }, "required": [ "target" @@ -75,8 +112,13 @@ "type": "string", "enum": ["phone", "sip", "user"] }, + "url": "string", + "method": { + "type": "string", + "enum": ["GET", "POST"] + }, "number": "string", - "uri": "string", + "sipUri": "string", "auth": "#auth", "name": "string" }, @@ -93,5 +135,14 @@ "user", "password" ] + }, + "synthesizer": { + "properties": { + "vendor": { + "type": "string", + "enum": ["google"] + }, + "voice": "string" + } } } diff --git a/lib/tasks/task.js b/lib/tasks/task.js index d466cb5b..13d600f6 100644 --- a/lib/tasks/task.js +++ b/lib/tasks/task.js @@ -1,6 +1,8 @@ const Emitter = require('events'); const debug = require('debug')('jambonz:feature-server'); const assert = require('assert'); +const resourcesMixin = require('../utils/resources'); +const {TaskPreconditions} = require('../utils/constants'); const specs = new Map(); const _specData = require('./specs'); for (const key in _specData) {specs.set(key, _specData[key]);} @@ -8,10 +10,19 @@ for (const key in _specData) {specs.set(key, _specData[key]);} class Task extends Emitter { constructor(logger, data) { super(); + this.preconditions = TaskPreconditions.None; this.logger = logger; this.data = data; } + /** + * called to kill (/stop) a running task + * what to do is up to each type of task + */ + kill() { + // no-op + } + static validate(name, data) { debug(`validating ${name} with data ${JSON.stringify(data)}`); // validate the instruction is supported @@ -24,6 +35,7 @@ class Task extends Emitter { if (dKey in specData.properties) { const dVal = data[dKey]; const dSpec = specData.properties[dKey]; + debug(`Task:validate validating property ${dKey} with value ${JSON.stringify(dVal)}`); if (typeof dSpec === 'string' && ['number', 'string', 'object', 'boolean'].includes(dSpec)) { // simple types @@ -31,6 +43,9 @@ class Task extends Emitter { throw new Error(`${name}: property ${dKey} has invalid data type`); } } + else if (typeof dSpec === 'string' && dSpec === 'array') { + if (!Array.isArray(dVal)) throw new Error(`${name}: property ${dKey} is not an array`); + } else if (Array.isArray(dSpec) && dSpec[0].startsWith('#')) { const name = dSpec[0].slice(1); for (const item of dVal) { @@ -49,8 +64,9 @@ class Task extends Emitter { } else if (typeof dSpec === 'string' && dSpec.startsWith('#')) { // reference to another datatype (i.e. nested type) - // TODO: validate recursively const name = dSpec.slice(1); + //const obj = {}; + //obj[name] = dVal; Task.validate(name, dVal); } else { @@ -64,5 +80,7 @@ class Task extends Emitter { } } +Object.assign(Task.prototype, resourcesMixin); + module.exports = Task; diff --git a/lib/utils/constants.json b/lib/utils/constants.json new file mode 100644 index 00000000..1309ea30 --- /dev/null +++ b/lib/utils/constants.json @@ -0,0 +1,36 @@ +{ + "TaskName": { + "Dial": "dial", + "Gather": "gather", + "Hangup": "hangup", + "Listen": "listen", + "Play": "play", + "redirect": "redirect", + "SipDecline": "sip:decline", + "SipNotify": "sip:notify", + "SipRedirect": "sip:redirect", + "Say": "say", + "Transcribe": "transcribe" + }, + "CallStatus": { + "Trying": "trying", + "Ringing": "ringing", + "EarlyMedia": "early-media", + "InProgress": "in-progress", + "Queued": "queued", + "Failed": "failed", + "Busy": "busy", + "NoAnswer": "no-answer", + "Completed": "completed" + }, + "CallDirection": { + "Inbound": "inbound", + "Outbound": "outbound" + }, + "TaskPreconditions": { + "None": "none", + "Endpoint": "endpoint", + "StableCall": "stable-call", + "UnansweredCall": "unanswered-call" + } +} diff --git a/lib/utils/notifiers.js b/lib/utils/notifiers.js new file mode 100644 index 00000000..74036a12 --- /dev/null +++ b/lib/utils/notifiers.js @@ -0,0 +1,37 @@ +const request = require('request'); +require('request-debug')(request); +const debug = require('debug')('jambonz:feature-server'); + +function hooks(logger, callAttributes) { + debug(`notifiers: callAttributes ${JSON.stringify(callAttributes)}`); + function actionHook(url, method, opts) { + debug(`notifiers: opts ${JSON.stringify(opts)}`); + const params = Object.assign({}, callAttributes, opts); + const obj = { + url, + method, + json: true, + qs: 'GET' === method ? params : null, + body: 'POST' === method ? params : null + }; + logger.debug(`${method} ${url} sending ${JSON.stringify(obj)}`); + return new Promise((resolve, reject) => { + request(obj, (err, response, body) => { + if (err) { + this.logger.info(`TaskDial:_actionHook error ${method} ${url}: ${err.message}`); + return reject(err); + } + if (body) { + this.logger.debug(body, `TaskDial:_actionHook response ${method} ${url}`); + } + resolve(body); + }); + }); + } + + return { + actionHook + }; +} + +module.exports = hooks; diff --git a/lib/utils/resources.js b/lib/utils/resources.js new file mode 100644 index 00000000..9631a2f2 --- /dev/null +++ b/lib/utils/resources.js @@ -0,0 +1,50 @@ +const assert = require('assert'); + +//this obj is meant to be mixed in into another class +//NB: it is required that the class have a 'logger' property +module.exports = { + resources: new Map(), + addResource(name, resource) { + this.logger.debug(`addResource: adding ${name}`); + + // duck-typing: resources must have a destroy function and a 'connected' proerty + assert(typeof resource.destroy === 'function'); + assert('connected' in resource); + + this.resources.set(name, resource); + }, + getResource(name) { + return this.resources.get(name); + }, + hasResource(name) { + return this.resources.has(name); + }, + removeResource(name) { + this.logger.debug(`removeResource: removing ${name}`); + this.resources.delete(name); + }, + async clearResource(name) { + const r = this.resources.get(name); + if (r) { + this.logger.debug(`clearResource deleting ${name}`); + try { + if (r.connected) r.destroy(); + } + catch (err) { + this.logger.error(err, `clearResource error deleting ${name}`); + } + this.resources.delete(r); + } + }, + async clearResources() { + for (const [name, resource] of Array.from(this.resources).reverse()) { + try { + this.logger.info(`deleting ${name}`); + if (resource.connected) await resource.destroy(); + } catch (err) { + this.logger.error(err, `clearResources: error deleting ${name}`); + } + } + this.resources.clear(); + } +}; diff --git a/package-lock.json b/package-lock.json index 0450a80d..1c20f620 100644 --- a/package-lock.json +++ b/package-lock.json @@ -706,58 +706,6 @@ "resolved": "https://registry.npmjs.org/drachtio-fn-b2b-sugar/-/drachtio-fn-b2b-sugar-0.0.12.tgz", "integrity": "sha512-FKPAcEMJTYKDrd9DJUCc4VHnY/c65HOO9k8XqVNognF9T02hKEjGuBCM4Da9ipyfiHmVRuECwj0XNvZ361mkVQ==" }, - "drachtio-fn-fsmrf-sugar": { - "version": "0.0.9", - "resolved": "https://registry.npmjs.org/drachtio-fn-fsmrf-sugar/-/drachtio-fn-fsmrf-sugar-0.0.9.tgz", - "integrity": "sha512-X32AUmURLaeXmMQrY05YmkevAYpT0CIuoEsoiKqkZviSSt88e55naJf8xrWsMP7W5rc4UuYBmgCiBqW3qKoR9Q==", - "requires": { - "drachtio-fsmrf": "^1.4.4" - } - }, - "drachtio-fsmrf": { - "version": "1.5.8", - "resolved": "https://registry.npmjs.org/drachtio-fsmrf/-/drachtio-fsmrf-1.5.8.tgz", - "integrity": "sha512-hVSJuGiwX/onshvtmKyjA1p/wiPmezuiblT5Z3YGSz7tPPK5e+m4fIwAjEvarQSfhrMeiXuQHp9Eewp0+z0J0Q==", - "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", @@ -1011,11 +959,6 @@ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "dev": true }, - "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", @@ -2819,16 +2762,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.13.0", - "resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.13.0.tgz", - "integrity": "sha512-3zT7pcjR090E0WCV9eOtFX06iojoNKsyMXqXs7clOs8sy+RoegR0cebmCuCrTKdY2jw1XhT9jkraygJrqAUwzA==" - }, "semver": { "version": "6.3.0", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", @@ -4954,20 +4887,6 @@ "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==" - }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 0ec11a53..4bdabd19 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "drachtio-fsmrf": "1.5.10", "drachtio-srf": "^4.4.27", "jambonz-db-helpers": "^0.1.6", + "moment": "^2.24.0", "pino": "^5.14.0", "request": "^2.88.0", "request-debug": "^0.2.0"