From 4a1ea4e091d1a5bae8175b37ba8eddc2e038e60e Mon Sep 17 00:00:00 2001 From: Dave Horton Date: Sat, 25 Jan 2020 11:47:33 -0500 Subject: [PATCH] major refactoring --- app.js | 6 +- lib/call-session.js | 339 -------------------- lib/middleware.js | 76 ++--- lib/session/call-info.js | 22 ++ lib/session/call-session.js | 191 +++++++++++ lib/session/confirm-call-session.js | 26 ++ lib/session/inbound-call-session.js | 119 +++++++ lib/task-list.js | 17 - lib/tasks/dial.js | 474 ++++++++++------------------ lib/tasks/gather.js | 64 ++-- lib/tasks/hangup.js | 1 + lib/tasks/listen.js | 31 +- lib/tasks/play.js | 10 +- lib/tasks/say.js | 28 +- lib/tasks/sip_decline.js | 7 +- lib/tasks/specs.json | 15 +- lib/tasks/task.js | 34 +- lib/tasks/transcribe.js | 67 ++-- lib/utils/notifiers.js | 37 ++- lib/utils/place-outdial.js | 217 +++++++++++-- lib/utils/resources.js | 50 --- lib/utils/retrieve-app.js | 30 ++ package.json | 13 +- test/data/good/dial-transcribe.json | 4 +- test/unit-tests.js | 2 + 25 files changed, 947 insertions(+), 933 deletions(-) delete mode 100644 lib/call-session.js create mode 100644 lib/session/call-info.js create mode 100644 lib/session/call-session.js create mode 100644 lib/session/confirm-call-session.js create mode 100644 lib/session/inbound-call-session.js delete mode 100644 lib/task-list.js delete mode 100644 lib/utils/resources.js create mode 100644 lib/utils/retrieve-app.js diff --git a/app.js b/app.js index 4751a7cf..3c8a3fcf 100644 --- a/app.js +++ b/app.js @@ -3,7 +3,7 @@ const srf = new Srf(); const Mrf = require('drachtio-fsmrf'); srf.locals.mrf = new Mrf(srf); const config = require('config'); -const logger = require('pino')(config.get('logging')); +const logger = srf.locals.parentLogger = require('pino')(config.get('logging')); const {lookupAppByPhoneNumber} = require('jambonz-db-helpers')(config.get('mysql'), logger); srf.locals.dbHelpers = {lookupAppByPhoneNumber}; const { @@ -13,7 +13,7 @@ const { invokeWebCallback } = require('./lib/middleware')(srf, logger); -const CallSession = require('./lib/call-session'); +const InboundCallSession = require('./lib/session/inbound-call-session'); // disable logging in test mode if (process.env.NODE_ENV === 'test') { @@ -42,7 +42,7 @@ if (process.env.NODE_ENV === 'test') { srf.use('invite', [initLocals, normalizeNumbers, retrieveApplication, invokeWebCallback]); srf.invite((req, res) => { - const session = new CallSession(req, res); + const session = new InboundCallSession(req, res); session.exec(); }); diff --git a/lib/call-session.js b/lib/call-session.js deleted file mode 100644 index 7a23a099..00000000 --- a/lib/call-session.js +++ /dev/null @@ -1,339 +0,0 @@ -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 Dialog = require('drachtio-srf').Dialog; -const BADPRECONDITIONS = 'preconditions not met'; - -class CallSession extends Emitter { - constructor(req, res) { - super(); - this.req = req; - this.res = res; - this.srf = req.srf; - this.logger = req.locals.logger; - this.application = req.locals.application; - 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); - - this.callGone = false; - - req.on('cancel', this._onCallerHangup.bind(this, req)); - 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; } - get callingNumber() { return this.req.callingNumber; } - get calledNumber() { return this.req.calledNumber; } - - async exec() { - let idx = 0; - 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 && !this.callGone) { - const {task, callSid} = taskList.shift(); - this.logger.debug(`CallSession:exec starting task #${++idx}: ${task.name}`); - try { - const resources = await this._evaluatePreconditions(task, callSid); - this.currentTask = task; - await task.exec(this, resources); - this.currentTask = null; - this.logger.debug(`CallSession:exec completed task #${idx}: ${task.name}`); - } catch (err) { - this.currentTask = null; - 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; - } - } - } - } - - // 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}`); - } - } - - async _evalEndpointPrecondition(task, callSid) { - if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`); - 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}); - ep.cs = this; - 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}); - uas.on('destroy', this._onCallerHangup.bind(this, uas)); - uas.callSid = callSid; - resources.dlg = uas; - this.logger.debug(`CallSession:_evalEndpointPrecondition - call was answered with callSid ${callSid}`); - 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}`); - } - } - - _evalStableCallPrecondition(task, callSid) { - if (this.callGone) throw new Error(`${BADPRECONDITIONS}: call gone`); - 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; - } - - _evalUnansweredCallPrecondition(task, callSid) { - if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`); - 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}; - } - - _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(); - } - - /** - * These below methods are needed mainly by the dial verb, which - * deals with a variety of scenarios that can't simply be - * described by the precondition concept as other verbs can - */ - - /** - * 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'); - if (ms && ep) return {ms, ep}; - - // get a media server - if (!ms) { - ms = await mrf.connect(config.get('freeswitch')); - this.addResource('ms', ms); - } - if (!ep) { - ep = await ms.createEndpoint({remoteSdp: this.req.body}); - this.addResource('epIn', ep); - } - return {ms, ep}; - } - - 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.logger.debug(`CallSession:connectInboundCallToIvr - answered callSid ${this.parentCallSid}`); - 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 or BYE from inbound leg - */ - _onCallerHangup(obj, evt) { - this.callGone = true; - if (obj instanceof Dialog) { - this.logger.debug('CallSession: caller hung up'); - /* cant destroy endpoint as current task may need to get final transcription - const resources = this.calls.get(obj.callSid); - if (resources.ep && resources.ep.connected) { - resources.ep.destroy(); - resources.ep = null; - this.calls.set(obj.callSid, resources); - } - */ - } - else { - this.logger.debug('CallSession: caller hung before answer'); - } - if (this.currentTask) this.currentTask.kill(); - } - - /** - * 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 c0136eb8..383e37a6 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -1,18 +1,19 @@ //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 normalizeJamones = require('./utils/normalize-jamones'); const {CallStatus, CallDirection} = require('./utils/constants'); +const CallInfo = require('./session/call-info'); +const retrieveApp = require('./utils/retrieve-app'); module.exports = function(srf, logger) { const {lookupAppByPhoneNumber} = srf.locals.dbHelpers; function initLocals(req, res, next) { - req.locals = req.locals || {}; - req.locals.logger = logger.child({callId: req.get('Call-ID')}); + const callSid = uuidv4(); + req.locals = { + callSid, + logger: logger.child({callId: req.get('Call-ID'), callSid}) + }; next(); } @@ -43,7 +44,7 @@ module.exports = function(srf, logger) { async function retrieveApplication(req, res, next) { const logger = req.locals.logger; try { - const app = req.locals.application = await lookupAppByPhoneNumber(req.locals.calledNumber); + const app = await lookupAppByPhoneNumber(req.locals.calledNumber); if (!app) { logger.info(`rejecting call to DID ${req.locals.calledNumber}: no application associated`); return res.send(480, { @@ -52,7 +53,23 @@ module.exports = function(srf, logger) { } }); } + + //TODO: temp hack pre-refactoring to latest db schema: bang the data into expected shape + + req.locals.application = app; + //end hack logger.debug(app, `retrieved application for ${req.locals.calledNumber}`); + const from = req.getParsedHeader('From'); + req.locals.callInfo = new CallInfo({ + callSid: req.locals.callSid, + accountSid: app.account_sid, + applicationSid: app.application_sid, + from: req.callingNumber, + to: req.calledNumber, + direction: CallDirection.Inbound, + callerName: from.name || req.callingNumber, + callId: req.get('Call-ID') + }); next(); } catch (err) { logger.error(err, `${req.get('Call-ID')} Error looking up application for ${req.calledNumber}`); @@ -66,52 +83,23 @@ module.exports = function(srf, logger) { async function invokeWebCallback(req, res, next) { const logger = req.locals.logger; const app = req.locals.application; - const call_sid = uuidv4(); const method = (app.hook_http_method || 'POST').toUpperCase(); - const from = req.getParsedHeader('From'); - req.locals.callAttributes = { - CallSid: call_sid, - AccountSid: app.account_sid, - From: req.callingNumber, - To: req.calledNumber, - Direction: CallDirection.Inbound, - CallerName: from.name || req.callingNumber, - SipCallID: req.get('Call-ID') - }; - const qs = Object.assign({}, req.locals.callAttributes, { - CallStatus: CallStatus.Trying, - SipStatus: 100, + const qs = Object.assign({}, req.locals.callInfo, { + sipStatus: 100, + callStatus: CallStatus.Trying, RequestorIP: req.get('X-Forwarded-For'), RequestorName: req.get('X-Originating-Carrier') }); - const opts = { - url: app.call_hook, - method, - json: true, - qs - }; + let auth; if (app.hook_basic_auth_user && app.hook_basic_auth_password) { logger.debug(`using basic auth with ${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}}); + auth = Object.assign({}, {user: app.hook_basic_auth_user, password: app.hook_basic_auth_password}); } - if (method === 'POST') Object.assign(opts, {body: req.msg}); try { - request(opts, (err, response, body) => { - if (err) { - logger.error(err, `Error invoking callback ${app.call_hook}`); - return res.send(500, 'Webhook Failure'); - } - logger.debug(body, `application payload: ${body}`); - try { - app.tasks = normalizeJamones(logger, body).map((tdata) => makeTask(logger, tdata)); - next(); - } catch (err) { - logger.error(err, 'Invalid Webhook Response'); - res.send(500); - } - }); + app.tasks = await retrieveApp(logger, app.call_hook, method, auth, qs, method === 'POST' ? req.msg : null); + next(); } catch (err) { - logger.error(err, 'Error invoking web callback'); + logger.error(err, 'Error retrieving or parsing application'); res.send(500); } } diff --git a/lib/session/call-info.js b/lib/session/call-info.js new file mode 100644 index 00000000..42d63f1d --- /dev/null +++ b/lib/session/call-info.js @@ -0,0 +1,22 @@ +class CallInfo { + constructor(opts) { + this.callSid = opts.callSid; + this.parentCallSid = opts.parentCallSid; + this.direction = opts.direction; + this.from = opts.from; + this.to = opts.to; + this.callId = opts.callId; + this.sipStatus = opts.sipStatus; + this.callStatus = opts.callStatus; + this.callerId = opts.callerId; + this.accountSid = opts.accountSid; + this.applicationSid = opts.applicationSid; + } + + updateCallStatus(callStatus, sipStatus) { + this.callStatus = callStatus; + if (sipStatus) this.sipStatus = sipStatus; + } +} + +module.exports = CallInfo; diff --git a/lib/session/call-session.js b/lib/session/call-session.js new file mode 100644 index 00000000..fd3d333a --- /dev/null +++ b/lib/session/call-session.js @@ -0,0 +1,191 @@ +const Emitter = require('events'); +const config = require('config'); +const {CallDirection, TaskPreconditions, CallStatus} = require('../utils/constants'); +const moment = require('moment'); +const assert = require('assert'); +const BADPRECONDITIONS = 'preconditions not met'; + +class CallSession extends Emitter { + constructor({logger, application, srf, tasks, callSid}) { + super(); + this.logger = logger; + this.application = application; + this.srf = srf; + this.callSid = callSid; + this.tasks = tasks; + + this.callGone = false; + } + + async exec() { + let idx = 0; + this.logger.info(`CallSession:exec starting task list with ${this.tasks.length} tasks`); + while (this.tasks.length && !this.callGone) { + const task = this.tasks.shift(); + this.logger.debug(`CallSession:exec starting task #${++idx}: ${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 #${idx}: ${task.name}`); + } catch (err) { + this.currentTask = null; + 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; + } + } + } + + // all done - cleanup + this.logger.info('CallSession:exec all tasks complete'); + this._onTasksDone(); + this._clearCalls(); + this.ms && this.ms.destroy(); + } + + _onTasksDone() { + // meant to be implemented by subclass if needed + } + + _callReleased() { + this.logger.debug('CallSession:_callReleased - caller hung up'); + this.callGone = true; + if (this.currentTask) this.currentTask.kill(); + } + + /** + * Replace the currently-executing application with a new application + * NB: any tasks in the current stack that have not been executed are flushed + */ + replaceApplication(tasks) { + this.tasks = tasks; + this.logger.info(`CallSession:replaceApplication - set ${tasks.length} new tasks`); + } + _evaluatePreconditions(task) { + switch (task.preconditions) { + case TaskPreconditions.None: + return; + case TaskPreconditions.Endpoint: + return this._evalEndpointPrecondition(task); + case TaskPreconditions.StableCall: + return this._evalStableCallPrecondition(task); + case TaskPreconditions.UnansweredCall: + return this._evalUnansweredCallPrecondition(task); + default: + assert(0, `invalid/unknown or missing precondition type ${task.preconditions} for task ${task.name}`); + } + } + + async _evalEndpointPrecondition(task) { + if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`); + + const answerCall = async() => { + const uas = await this.srf.createUAS(this.req, this.res, {localSdp: this.ep.local.sdp}); + uas.on('destroy', this._callerHungup.bind(this)); + uas.callSid = this.callSid; + uas.connectTime = moment(); + this.dlg = uas; + this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress}); + this.logger.debug('CallSession:_evalEndpointPrecondition - answered call'); + }; + + if (this.ep) { + if (!task.earlyMedia || this.dlg) return this.ep; + + // we are going from an early media connection to answer + await answerCall(); + } + + 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; + this.ep = ep; + + if (this.direction === CallDirection.Inbound) { + if (task.earlyMedia && !this.req.finalResponseSent) { + this.res.send(183, {body: ep.local.sdp}); + return ep; + } + answerCall(); + } + else { + // outbound call TODO + } + + 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`); + } + } + + _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; + } + + _evalUnansweredCallPrecondition(task, callSid) { + if (!this.req) throw new Error('invalid precondition unanswered_call for outbound call'); + if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`); + if (this.req.finalResponseSent) { + throw new Error(`${BADPRECONDITIONS}: final sip status already sent`); + } + 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(); + } + + _callerHungup() { + assert(false, 'subclass responsibility to override this method'); + } + + async getMS() { + if (!this.ms) { + const mrf = this.srf.locals.mrf; + this.ms = await mrf.connect(config.get('freeswitch')); + } + return this.ms; + } + + async createOrRetrieveEpAndMs() { + const mrf = this.srf.locals.mrf; + if (this.ms && this.ep) return {ms: this.ms, ep: this.ep}; + + // get a media server + if (!this.ms) { + this.ms = await mrf.connect(config.get('freeswitch')); + } + if (!this.ep) { + this.ep = await this.ms.createEndpoint({remoteSdp: this.req.body}); + } + return {ms: this.ms, ep: this.ep}; + } + _notifyCallStatusChange(callStatus) { + this.logger.debug({app: this.application}, `CallSession:_notifyCallStatusChange: ${JSON.stringify(callStatus)}`); + try { + const auth = {}; + if (this.application.hook_basic_auth_user && this.application.hook_basic_auth_password) { + Object.assign(auth, {user: this.application.hook_basic_auth_user, password: this.hook_basic_auth_password}); + } + this.notifyHook(this.application.call_status_hook, + this.application.hook_http_method, + auth, + callStatus); + } catch (err) { + this.logger.info(err, `CallSession:_notifyCallStatusChange error sending ${JSON.stringify(callStatus)}`); + } + } +} + +module.exports = CallSession; diff --git a/lib/session/confirm-call-session.js b/lib/session/confirm-call-session.js new file mode 100644 index 00000000..5c1d0c68 --- /dev/null +++ b/lib/session/confirm-call-session.js @@ -0,0 +1,26 @@ +const CallSession = require('./call-session'); +const {CallDirection} = require('../utils/constants'); + +class ConfirmCallSession extends CallSession { + constructor({logger, application, dlg, ep, tasks}) { + super({ + logger, + application, + srf: dlg.srf, + callSid: dlg.callSid, + tasks + }); + this.dlg = dlg; + this.ep = ep; + this.direction = CallDirection.Outbound; + } + + /** + * empty implementation to override superclass so we do not delete dlg and ep + */ + _clearCalls() { + } + +} + +module.exports = ConfirmCallSession; diff --git a/lib/session/inbound-call-session.js b/lib/session/inbound-call-session.js new file mode 100644 index 00000000..3addd581 --- /dev/null +++ b/lib/session/inbound-call-session.js @@ -0,0 +1,119 @@ +const CallSession = require('./call-session'); +const {CallDirection, CallStatus} = require('../utils/constants'); +const hooks = require('../utils/notifiers'); +const moment = require('moment'); +const assert = require('assert'); + +class InboundCallSession extends CallSession { + constructor(req, res) { + super({ + logger: req.locals.logger, + srf: req.srf, + application: req.locals.application, + callSid: req.locals.callInfo.callSid, + tasks: req.locals.application.tasks + }); + this.req = req; + this.res = res; + this.srf = req.srf; + this.logger = req.locals.logger; + this.callInfo = req.locals.callInfo; + this.direction = CallDirection.Inbound; + const {notifyHook} = hooks(this.logger, this.callInfo); + this.notifyHook = notifyHook; + + req.on('cancel', this._callReleased.bind(this)); + + this.on('callStatusChange', this._notifyCallStatusChange.bind(this)); + this._notifyCallStatusChange({callStatus: CallStatus.Trying, sipStatus: 100}); + } + + get speechSynthesisVendor() { + return this.application.speech_synthesis_vendor; + } + get speechSynthesisVoice() { + return this.application.speech_synthesis_voice; + } + + get speechRecognizerVendor() { + return this.application.speech_recognizer_vendor; + } + get speechRecognizerLanguage() { + return this.application.speech_recognizer_language; + } + + _onTasksDone() { + if (!this.res.finalResponseSent) { + this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite'); + this.res.send(603); + } + else if (this.dlg.connected) { + assert(this.dlg.connectTime); + const duration = moment().diff(this.dlg.connectTime, 'seconds'); + this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration}); + this.logger.debug('InboundCallSession:_onTasksDone hanging up call since all tasks are done'); + } + } + + 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; + } + } + + async propagateAnswer() { + if (!this.dlg) { + assert(this.ep); + this.dlg = await this.srf.createUAS(this.req, this.res, {localSdp: this.ep.local.sdp}); + this.dlg.connectTime = moment(); + this.dlg.on('destroy', this._callerHungup.bind(this)); + this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress}); + this.logger.debug(`CallSession:propagateAnswer - answered callSid ${this.callSid}`); + } + } + + _callerHungup() { + assert(this.dlg.connectTime); + const duration = moment().diff(this.dlg.connectTime, 'seconds'); + this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration}); + this.logger.debug('InboundCallSession: caller hung up'); + this._callReleased(); + } + +} + +module.exports = InboundCallSession; diff --git a/lib/task-list.js b/lib/task-list.js deleted file mode 100644 index 5b874129..00000000 --- a/lib/task-list.js +++ /dev/null @@ -1,17 +0,0 @@ -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 975a6ba6..ff30bcf3 100644 --- a/lib/tasks/dial.js +++ b/lib/tasks/dial.js @@ -1,356 +1,228 @@ const Task = require('./task'); 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 placeCall = require('../utils/place-outdial'); +const config = require('config'); const moment = require('moment'); +const debug = require('debug')('jambonz:feature-server'); -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.preconditions = TaskPreconditions.None; - this.action = opts.action; - this.earlyMedia = opts.answerOnBridge === true; - this.callerId = opts.callerId; - this.dialMusic = opts.dialMusic; + this.earlyMedia = this.data.answerOnBridge === true; + this.callerId = this.data.callerId; + this.dialMusic = this.data.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; + this.method = this.data.method || 'POST'; + this.statusCallback = this.data.statusCallback; + this.statusCallbackMethod = this.data.statusCallbackMethod || 'POST'; + this.target = this.data.target; + this.timeout = this.data.timeout || 60; + this.timeLimit = this.data.timeLimit; + this.url = this.data.url; - if (opts.listen) { - this.listenTask = makeTask(logger, {'listen': opts.listen}); + if (this.data.listen) { + this.listenTask = makeTask(logger, {'listen': this.data.listen}); } - if (opts.transcribe) { - this.transcribeTask = makeTask(logger, {'transcribe' : opts.transcribe}); + if (this.data.transcribe) { + this.transcribeTask = makeTask(logger, {'transcribe' : this.data.transcribe}); } - this.canceled = false; - this.callAttributes = {}; - this.dialCallStatus = CallStatus.Failed; - this.dialCallSid = null; - this.dialCallDuration = null; - - this.on('callStatusChange', this._onCallStatusChange.bind(this)); + this.results = {}; + this.bridged = false; + this.dials = new Map(); } get name() { return TaskName.Dial; } async exec(cs) { + super.exec(cs); try { - this._initializeCallData(cs); - await this._initializeInbound(cs); + if (cs.direction === CallDirection.Inbound) { + await this._initializeInbound(cs); + } await this._attemptCalls(cs); - await this._waitForCompletion(cs); + await this.awaitTaskDone(); + this.performAction(this.method, this.results); } catch (err) { this.logger.error(`TaskDial:exec terminating with error ${err.message}`); + this.kill(); } - 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 kill() { + super.kill(); + if (this.connectTime) { + const duration = moment().diff(this.connectTime, 'seconds'); + this.results.dialCallDuration = duration; + this.logger.debug(`Dial:kill call ended after ${duration} seconds`); + } + + this._killOutdials(); + if (this.dlg) { + assert(this.ep); + if (this.dlg.connected) this.dlg.destroy(); + debug(`Dial:kill deleting endpoint ${this.ep.uuid}`); + this.ep.destroy(); + } + if (this.listenTask) await this.listenTask.kill(); + if (this.transcribeTask) await this.transcribeTask.kill(); + this.notifyTaskDone(); } async _initializeInbound(cs) { - const {req} = cs; + const {ep} = await cs.connectInboundCallToIvr(this.earlyMedia); + this.epOther = ep; + debug(`Dial:__initializeInbound allocated ep for incoming call: ${ep.uuid}`); - // the caller could hangup in the middle of all this.. - req.on('cancel', this._onCancel.bind(this, cs)); - - try { - 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'); - throw err; + if (this.dialMusic) { + // play dial music to caller while we outdial + ep.play(this.dialMusic).catch((err) => { + this.logger.error(err, `TaskDial:_initializeInbound - error playing ${this.dialMusic}`); + }); } } 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; - } - } - - _prepareOutdialAttempt(target, sbcAddress, callerId, sdp) { + const sbcAddress = cs.direction === CallDirection.Inbound ? + `${req.source_address}:${req.source_port}` : + config.get('sbcAddress'); const opts = { headers: this.headers, proxy: `sip:${sbcAddress}`, - callingNumber: callerId, - localSdp: sdp + callingNumber: this.callerId || req.callingNumber }; - 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}; - } + // construct bare-bones callInfo for the new outbound call attempt + const callInfo = Object.assign({}, cs.callInfo); + callInfo.parentCallSid = cs.callSid; + callInfo.direction = CallDirection.Outbound; + ['callSid', 'callID', 'from', 'to', 'callerId', 'sipStatus', 'callStatus'].forEach((k) => delete callInfo[k]); - _onCancel(cs) { - this.logger.info('TaskDial: caller hung up before connecting'); - this.canceled = true; - cs.emit('callStatusChange', {status: CallStatus.NoAnswer}); - } + const ms = await cs.getMS(); + this.target.forEach((t) => { + try { + t.url = t.url || this.url; + const sd = placeCall({ + logger: this.logger, + application: cs.application, + srf, + ms, + sbcAddress, + target: t, + opts, + callInfo + }); + this.dials.set(sd.callSid, sd); - _onCallerHangup(cs, dlg) { - 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 either party hangs up - */ - _waitForCompletion(cs) { - - return new Promise((resolve) => { - const dlgOut = this.getResource('dlgOut'); - assert(this.dlgIn && dlgOut); - assert(this.dlgIn.connected && dlgOut.connected); - - [this.dlgIn, dlgOut].forEach((dlg) => dlg.on('destroy', () => resolve())); + sd + .on('callStatusChange', (obj) => { + switch (obj.callStatus) { + case CallStatus.Trying: + break; + case CallStatus.EarlyMedia: + if (this.target.length === 1 && !this.target[0].url && !this.dialMusic) { + this._bridgeEarlyMedia(sd); + } + break; + case CallStatus.InProgress: + this.logger.debug('Dial:_attemptCall -- call was answered'); + break; + case CallStatus.Failed: + case CallStatus.Busy: + case CallStatus.NoAnswer: + this.dials.delete(sd.callSid); + if (this.dials.size === 0 && !this.connectTime) { + this.logger.debug('Dial:_attemptCalls - all calls failed after call failure, ending task'); + this.kill(); + } + break; + } + if (this.results.dialCallStatus !== CallStatus.Completed) { + Object.assign(this.results, { + dialCallStatus: obj.callStatus, + dialCallSid: sd.callSid, + }); + } + }) + .on('accept', () => { + this.logger.debug(`Dial:_attemptCalls - we have a winner: ${sd.callSid}`); + this._connectSingleDial(cs, sd); + }) + .on('decline', () => { + this.logger.debug(`Dial:_attemptCalls - declined: ${sd.callSid}`); + this.dials.delete(sd.callSid); + if (this.dials.size === 0 && !this.connectTime) { + this.logger.debug('Dial:_attemptCalls - all calls failed after decline, ending task'); + this.kill(); + } + }); + } catch (err) { + this.logger.error(err, 'Dial:_attemptCalls'); + } }); } - _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; + _connectSingleDial(cs, sd) { + if (!this.bridged) { + this.logger.debug('Dial:_connectSingleDial bridging endpoints'); + this.epOther.api('uuid_break', this.epOther.uuid); + this.epOther.bridge(sd.ep); + this.bridged = true; } - 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}`); - }); + + // ding! ding! ding! we have a winner + this._selectSingleDial(cs, sd); + this._killOutdials(); // NB: order is important + } + + _selectSingleDial(cs, sd) { + this.connectTime = moment(); + this.dials.delete(sd.callSid); + debug(`Dial:_selectSingleDial ep for outbound call: ${sd.ep.uuid}`); + this.ep = sd.ep; + this.dlg = sd.dlg; + this.callSid = sd.callSid; + if (this.earlyMedia) { + debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected'); + cs.propagateAnswer(); + } + this.dlg.on('destroy', () => { + this.logger.debug('Dial:_selectSingleDial called party hungup, ending dial operation'); + this.ep.unbridge(); + this.kill(); + }); + + Object.assign(this.results, { + dialCallStatus: CallStatus.Completed, + dialCallSid: sd.callSid, + }); + + if (this.transcribeTask) this.transcribeTask.exec(cs, this.ep, this); + if (this.listenTask) this.listenTask.exec(cs, this.ep, this); + } + + _killOutdials() { + for (const [callSid, sd] of Array.from(this.dials)) { + this.logger.debug(`Dial:_killOutdials killing callSid ${callSid}`); + sd.kill().catch((err) => this.logger.info(err, `Dial:_killOutdials Error killing ${callSid}`)); + } + this.dials.clear(); + } + + _bridgeEarlyMedia(sd) { + if (this.epOther && !this.bridged) { + this.epOther.api('uuid_break', this.epOther.uuid); + this.epOther.bridge(sd.ep); + this.bridged = true; } } - 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 index 41cf3ffa..894392af 100644 --- a/lib/tasks/gather.js +++ b/lib/tasks/gather.js @@ -9,7 +9,7 @@ class TaskGather extends Task { this.preconditions = TaskPreconditions.Endpoint; [ - 'action', 'finishOnKey', 'hints', 'input', 'language', 'method', 'numDigits', + 'action', 'finishOnKey', 'hints', 'input', 'method', 'numDigits', 'partialResultCallback', 'partialResultCallbackMethod', 'profanityFilter', 'speechTimeout', 'timeout', 'say' ].forEach((k) => this[k] = this.data[k]); @@ -17,13 +17,17 @@ class TaskGather extends Task { this.partialResultCallbackMethod = this.partialResultCallbackMethod || 'POST'; this.method = this.method || 'POST'; this.timeout = (this.timeout || 5) * 1000; - this.language = this.language || 'en-US'; - this.digitBuffer = ''; - //this._earlyMedia = this.data.earlyMedia === true; - - if (this.say) { - this.sayTask = makeTask(this.logger, {say: this.say}); + this.interim = this.partialResultCallback; + if (this.data.recognizer) { + this.language = this.data.recognizer.language || 'en-US'; + this.vendor = this.data.recognizer.vendor; } + + + this.digitBuffer = ''; + this._earlyMedia = this.data.earlyMedia === true; + + if (this.say) this.sayTask = makeTask(this.logger, {say: this.say}, this); } get name() { return TaskName.Gather; } @@ -34,15 +38,14 @@ class TaskGather extends Task { } async exec(cs, ep) { + super.exec(cs); 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', (err) => { - if (this.taskInProgress) this._startTimer(); + if (!this.killed) this._startTimer(); }); } else this._startTimer(); @@ -56,11 +59,10 @@ class TaskGather extends Task { ep.on('dtmf', this._onDtmf.bind(this, ep)); } - await this._waitForCompletion(); + await this.awaitTaskDone(); } catch (err) { this.logger.error(err, 'TaskGather:exec error'); } - this.taskInProgress = false; ep.removeCustomEventListener(TranscriptionEvents.Transcription); ep.removeCustomEventListener(TranscriptionEvents.EndOfUtterance); } @@ -71,10 +73,6 @@ class TaskGather extends Task { this._resolve('killed'); } - async _waitForCompletion() { - return new Promise((resolve) => this.resolver = resolve); - } - _onDtmf(ep, evt) { this.logger.debug(evt, 'TaskGather:_onDtmf'); if (evt.dtmf === this.finishOnKey) this._resolve('dtmf-terminator-key'); @@ -89,7 +87,7 @@ class TaskGather extends Task { const opts = { GOOGLE_SPEECH_USE_ENHANCED: true, GOOGLE_SPEECH_SINGLE_UTTERANCE: true, - GOOGLE_SPEECH_MODEL: 'phone_call' + GOOGLE_SPEECH_MODEL: 'command_and_search' }; if (this.hints) { Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')}); @@ -107,7 +105,7 @@ class TaskGather extends Task { _startTranscribing(ep) { ep.startTranscription({ interim: this.partialResultCallback ? true : false, - language: this.language + language: this.language || this.callSession.speechRecognizerLanguage }).catch((err) => this.logger.error(err, 'TaskGather:_startTranscribing error')); } @@ -124,46 +122,30 @@ class TaskGather extends Task { } _killAudio() { - if (this.sayTask) { - this.sayTask.kill(); - this.sayTask = null; - } + this.sayTask.kill(); } _onTranscription(ep, evt) { this.logger.debug(evt, 'TaskGather:_onTranscription'); - if (evt.is_final) { - ep.removeCustomEventListener(TranscriptionEvents.Transcription); - ep.removeCustomEventListener(TranscriptionEvents.EndOfUtterance); - this._resolve('speech', evt); - } - else if (this.partialResultCallback) { - this.actionHook(this.partialResultCallback, 'POST', { - Speech: evt - }); - } + if (evt.is_final) this._resolve('speech', evt); + else if (this.partialResultCallback) this.notifyHook(this.partialResultCallback, 'POST', null, {speech: evt}); } _onEndOfUtterance(ep, evt) { this.logger.info(evt, 'TaskGather:_onEndOfUtterance'); this._startTranscribing(ep); } - _resolve(reason, evt) { + async _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 - }); + this.performAction(this.method, null, {digits: this.digitBuffer}); } else if (reason.startsWith('speech')) { - this.actionHook(this.action, this.method, { - Speech: evt - }); + this.performAction(this.method, null, {speech: evt}); } this._clearTimer(); - this.resolver(); + this.notifyTaskDone(); } } diff --git a/lib/tasks/hangup.js b/lib/tasks/hangup.js index 0480e144..56ddf37c 100644 --- a/lib/tasks/hangup.js +++ b/lib/tasks/hangup.js @@ -15,6 +15,7 @@ class TaskHangup extends Task { * Hangup the call */ async exec(cs, dlg) { + super.exec(cs); try { await dlg.destroy({headers: this.headers}); } catch (err) { diff --git a/lib/tasks/listen.js b/lib/tasks/listen.js index 37f51435..380c933b 100644 --- a/lib/tasks/listen.js +++ b/lib/tasks/listen.js @@ -1,7 +1,6 @@ const Task = require('./task'); const {TaskName, TaskPreconditions, ListenEvents} = require('../utils/constants'); const makeTask = require('./make_task'); -const assert = require('assert'); class TaskListen extends Task { constructor(logger, opts) { @@ -9,57 +8,49 @@ class TaskListen extends Task { this.preconditions = TaskPreconditions.Endpoint; [ - 'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep', + 'action', 'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep', 'sampleRate', 'timeout', 'transcribe' ].forEach((k) => this[k] = this.data[k]); this.mixType = this.mixType || 'mono'; this.sampleRate = this.sampleRate || 8000; this.earlyMedia = this.data.earlyMedia === true; + this.results = {}; - if (this.transcribe) { - this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}); - } + if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this); this._dtmfHandler = this._onDtmf.bind(this); - - this._completionPromise = new Promise((resolve) => this._completionResolver = resolve); } get name() { return TaskName.Listen; } async exec(cs, ep) { + super.exec(cs); this.ep = ep; try { if (this.playBeep) await this._playBeep(ep); if (this.transcribeTask) { this.logger.debug('TaskListen:exec - starting nested transcribe task'); - this.transcribeTask.exec(cs, ep, this); // kicked off, _not_ waiting for it to complete + this.transcribeTask.exec(cs, ep, this); } await this._startListening(ep); - await this._completionPromise; + await this.awaitTaskDone(); } catch (err) { this.logger.info(err, `TaskListen:exec - error ${this.url}`); } if (this.transcribeTask) this.transcribeTask.kill(); this._removeListeners(ep); - this.listenComplete = true; - this.emit('listenDone'); } async kill() { super.kill(); this._clearTimer(); - if (this.ep.connected && !this.listenComplete) { - this.listenComplete = true; - if (this.transcribeTask) { - await this.transcribeTask.kill(); - this.transcribeTask = null; - } + if (this.transcribeTask) await this.transcribeTask.kill(); + if (this.ep.connected) { await this.ep.forkAudioStop() .catch((err) => this.logger.info(err, 'TaskListen:kill')); } - this._completionResolver(); + this.notifyTaskDone(); } async _playBeep(ep) { @@ -119,11 +110,11 @@ class TaskListen extends Task { } _onConnectFailure(ep, evt) { this.logger.info(evt, 'TaskListen:_onConnectFailure'); - this._completionResolver(); + this.notifyTaskDone(); } _onError(ep, evt) { this.logger.info(evt, 'TaskListen:_onError'); - this._completionResolver(); + this.notifyTaskDone(); } } diff --git a/lib/tasks/play.js b/lib/tasks/play.js index 12c02f83..51f95565 100644 --- a/lib/tasks/play.js +++ b/lib/tasks/play.js @@ -9,30 +9,28 @@ class TaskPlay extends Task { this.url = this.data.url; this.loop = this.data.loop || 1; this.earlyMedia = this.data.earlyMedia === true; - this.playComplete = false; } get name() { return TaskName.Play; } async exec(cs, ep) { + super.exec(cs); this.ep = ep; try { - while (!this.playComplete && this.loop--) { + while (!this.killed && this.loop--) { await ep.play(this.url); } } catch (err) { this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`); } - this.playComplete = true; this.emit('playDone'); } - kill() { + async kill() { super.kill(); if (this.ep.connected && !this.playComplete) { this.logger.debug('TaskPlay:kill - killing audio'); - this.playComplete = true; - this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio')); + await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio')); } } } diff --git a/lib/tasks/say.js b/lib/tasks/say.js index 2f60e082..8eb9eb04 100644 --- a/lib/tasks/say.js +++ b/lib/tasks/say.js @@ -2,44 +2,44 @@ const Task = require('./task'); const {TaskName, TaskPreconditions} = require('../utils/constants'); class TaskSay extends Task { - constructor(logger, opts) { + constructor(logger, opts, parentTask) { 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.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia); + if (this.data.synthesizer) { + this.voice = this.data.synthesizer.voice; + 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) { + super.exec(cs); this.ep = ep; try { await ep.speak({ ttsEngine: 'google_tts', - voice: this.voice, + voice: this.voice || this.callSession.speechSynthesisVoice, text: this.text }); } catch (err) { this.logger.info(err, 'TaskSay:exec error'); } this.emit('playDone'); - this.sayComplete = true; } kill() { super.kill(); - if (this.ep.connected && !this.sayComplete) { + if (this.ep.connected) { this.logger.debug('TaskSay:kill - killing audio'); this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio')); } diff --git a/lib/tasks/sip_decline.js b/lib/tasks/sip_decline.js index b083c12d..57251f7e 100644 --- a/lib/tasks/sip_decline.js +++ b/lib/tasks/sip_decline.js @@ -1,6 +1,9 @@ const Task = require('./task'); const {TaskName, TaskPreconditions} = require('../utils/constants'); +/** + * Rejects an incoming call with user-specified status code and reason + */ class TaskSipDecline extends Task { constructor(logger, opts) { super(logger, opts); @@ -11,10 +14,8 @@ class TaskSipDecline extends Task { get name() { return TaskName.SipDecline; } - /** - * Reject an incoming call attempt with a provided status code and (optionally) reason - */ async exec(cs, {res}) { + super.exec(cs); res.send(this.data.status, this.data.reason, { headers: this.headers }); diff --git a/lib/tasks/specs.json b/lib/tasks/specs.json index 0ae058cf..fe6d4ce8 100644 --- a/lib/tasks/specs.json +++ b/lib/tasks/specs.json @@ -34,8 +34,7 @@ "earlyMedia": "boolean" }, "required": [ - "text", - "synthesizer" + "text" ] }, "gather": { @@ -72,6 +71,7 @@ "enum": ["GET", "POST"] }, "target": ["#target"], + "url": "string", "timeLimit": "number", "timeout": "number", "transcribe": "#transcribe" @@ -82,6 +82,7 @@ }, "listen": { "properties": { + "action": "string", "finishOnKey": "string", "maxLength": "number", "metadata": "object", @@ -103,13 +104,12 @@ }, "transcribe": { "properties": { - "action": "string", + "transcriptionCallback": "string", "recognizer": "#recognizer", "earlyMedia": "boolean" }, "required": [ - "action", - "recognizer" + "transcriptionCallback" ] }, "target": { @@ -164,10 +164,7 @@ "hints": "array", "profanityFilter": "boolean", "interim": "boolean", - "mixType": { - "type": "string", - "enum": ["mono", "stereo", "mixed"] - } + "dualChannel": "boolean" }, "required": [ "vendor" diff --git a/lib/tasks/task.js b/lib/tasks/task.js index b6b5f4c7..599eb6d8 100644 --- a/lib/tasks/task.js +++ b/lib/tasks/task.js @@ -1,8 +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 hooks = require('../utils/notifiers'); const specs = new Map(); const _specData = require('./specs'); for (const key in _specData) {specs.set(key, _specData[key]);} @@ -15,12 +15,24 @@ class Task extends Emitter { this.data = data; this._killInProgress = false; + this._completionPromise = new Promise((resolve) => this._completionResolver = resolve); } get killed() { return this._killInProgress; } + get callSession() { + return this.cs; + } + + async exec(cs) { + this.cs = cs; + const {actionHook, notifyHook} = hooks(this.logger, cs.callInfo); + this.actionHook = actionHook; + this.notifyHook = notifyHook; + } + /** * called to kill (/stop) a running task * what to do is up to each type of task @@ -31,6 +43,24 @@ class Task extends Emitter { // no-op } + notifyTaskDone() { + this._completionResolver(); + } + + awaitTaskDone() { + return this._completionPromise; + } + + async performAction(method, auth, results) { + if (this.action) { + const tasks = await this.actionHook(this.action, method, auth, results); + if (tasks && Array.isArray(tasks)) { + this.logger.debug(`${this.name} replacing application with ${tasks.length} tasks`); + this.callSession.replaceApplication(tasks); + } + } + } + static validate(name, data) { debug(`validating ${name} with data ${JSON.stringify(data)}`); // validate the instruction is supported @@ -88,7 +118,5 @@ class Task extends Emitter { } } -Object.assign(Task.prototype, resourcesMixin); - module.exports = Task; diff --git a/lib/tasks/transcribe.js b/lib/tasks/transcribe.js index e605f192..e7a9a77e 100644 --- a/lib/tasks/transcribe.js +++ b/lib/tasks/transcribe.js @@ -1,54 +1,50 @@ const Task = require('./task'); const {TaskName, TaskPreconditions, TranscriptionEvents} = require('../utils/constants'); -const assert = require('assert'); class TaskTranscribe extends Task { - constructor(logger, opts) { + constructor(logger, opts, parentTask) { super(logger, opts); this.preconditions = TaskPreconditions.Endpoint; - this.action = this.data.action; - this.language = this.data.language || 'en-US'; - this.vendor = this.data.vendor; - this.interim = this.data.interim === true; - this.mixType = this.data.mixType; - this.earlyMedia = this.data.earlyMedia === true; - - this._completionPromise = new Promise((resolve) => this._completionResolver = resolve); + this.transcriptionCallback = this.data.transcriptionCallback; + this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia); + if (this.data.recognizer) { + this.language = this.data.recognizer.language || 'en-US'; + this.vendor = this.data.recognizer.vendor; + this.interim = this.data.recognizer.interim === true; + this.dualChannel = this.data.recognizer.dualChannel === true; + } } get name() { return TaskName.Transcribe; } async exec(cs, ep, parentTask) { + super.exec(cs); this.ep = ep; - this.actionHook = ep.cs.actionHook; - this.transcribeInProgress = true; try { - await this._initSpeech(ep); await this._startTranscribing(ep); - await this._completionPromise; + await this.awaitTaskDone(); } catch (err) { this.logger.info(err, 'TaskTranscribe:exec - error'); } - this.transcribeInProgress = true; ep.removeCustomEventListener(TranscriptionEvents.Transcription); + ep.removeCustomEventListener(TranscriptionEvents.NoAudioDetected); + ep.removeCustomEventListener(TranscriptionEvents.MaxDurationExceeded); } async kill() { super.kill(); - if (this.ep.connected && this.transcribeInProgress) { + if (this.ep.connected) { this.ep.stopTranscription().catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill')); // hangup after 1 sec if we don't get a final transcription - this._timer = setTimeout(() => this._completionResolver(), 1000); + this._timer = setTimeout(() => this.notifyTaskDone(), 1000); } - else { - this._completionResolver(); - } - await this._completionPromise; + else this.notifyTaskDone(); + await this.awaitTaskDone(); } - async _initSpeech(ep) { + async _startTranscribing(ep) { const opts = { GOOGLE_SPEECH_USE_ENHANCED: true, GOOGLE_SPEECH_MODEL: 'phone_call' @@ -56,43 +52,48 @@ class TaskTranscribe extends Task { if (this.hints) { Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')}); } - if (this.profanityFilter === true) { + if (this.profanityFilter) { Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true}); } + if (this.dualChannel) { + Object.assign(opts, {'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL': true}); + } await ep.set(opts) - .catch((err) => this.logger.info(err, 'TaskTranscribe:_initSpeech error setting fs vars')); + .catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing')); + ep.addCustomEventListener(TranscriptionEvents.Transcription, this._onTranscription.bind(this, ep)); ep.addCustomEventListener(TranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, ep)); ep.addCustomEventListener(TranscriptionEvents.MaxDurationExceeded, this._onMaxDurationExceeded.bind(this, ep)); + + await this._transcribe(ep); } - async _startTranscribing(ep) { - await ep.startTranscription({ + async _transcribe(ep) { + await this.ep.startTranscription({ interim: this.interim ? true : false, - language: this.language + language: this.language || this.callSession.speechRecognizerLanguage, + channels: this.dualChannel ? 2 : 1 }); } _onTranscription(ep, evt) { this.logger.debug(evt, 'TaskTranscribe:_onTranscription'); - this.actionHook(this.action, 'POST', { - Speech: evt - }); + this.notifyHook(this.transcriptionCallback, 'POST', {speech: evt}); if (this.killed) { this.logger.debug('TaskTranscribe:_onTranscription exiting after receiving final transcription'); this._clearTimer(); - this._completionResolver(); + this.notifyTaskDone(); } } _onNoAudio(ep) { this.logger.debug('TaskTranscribe:_onNoAudio restarting transcription'); - this._startTranscribing(ep); + this._transcribe(ep); } _onMaxDurationExceeded(ep) { this.logger.debug('TaskTranscribe:_onMaxDurationExceeded restarting transcription'); - this._startTranscribing(ep); + this._transcribe(ep); } _clearTimer() { diff --git a/lib/utils/notifiers.js b/lib/utils/notifiers.js index 45bd5df6..0f43ab26 100644 --- a/lib/utils/notifiers.js +++ b/lib/utils/notifiers.js @@ -1,36 +1,45 @@ const request = require('request'); -require('request-debug')(request); +//require('request-debug')(request); +const makeTask = require('../tasks/make_task'); +const normalizeJamones = require('./normalize-jamones'); + 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)}`); + function actionHook(url, method, auth, opts, expectResponse = false) { const params = Object.assign({}, callAttributes, opts); - const obj = { - url, - method, - json: true, - qs: 'GET' === method ? params : callAttributes, - body: 'POST' === method ? opts : null - }; - logger.debug(`${method} ${url} sending ${JSON.stringify(obj)}`); + let basicauth, qs, body; + if (auth && typeof auth === 'object' && Object.keys(auth) === 2) basicauth = auth; + if ('GET' === method.toUpperCase()) qs = params; + else body = params; + const obj = {url, method, auth: basicauth, json: expectResponse || body, qs, body}; + logger.debug({opts: obj}, 'actionHook'); return new Promise((resolve, reject) => { request(obj, (err, response, body) => { if (err) { - this.logger.info(`TaskDial:_actionHook error ${method} ${url}: ${err.message}`); + logger.info(`actionHook error ${method} ${url}: ${err.message}`); return reject(err); } if (body) { - this.logger.debug(body, `TaskDial:_actionHook response ${method} ${url}`); + logger.debug(body, `actionHook response ${method} ${url}`); + if (expectResponse) { + const tasks = normalizeJamones(logger, body).map((tdata) => makeTask(logger, tdata)); + return resolve(tasks); + } } resolve(body); }); }); } + function notifyHook(url, method, auth, opts) { + return actionHook(url, method, auth, opts, false); + } + return { - actionHook + actionHook, + notifyHook }; } diff --git a/lib/utils/place-outdial.js b/lib/utils/place-outdial.js index 1ca82165..a6e0e70c 100644 --- a/lib/utils/place-outdial.js +++ b/lib/utils/place-outdial.js @@ -1,54 +1,215 @@ const Emitter = require('events'); const {CallStatus} = require('./constants'); +const uuidv4 = require('uuid/v4'); +const SipError = require('drachtio-srf').SipError; +const {TaskPreconditions} = require('../utils/constants'); +const assert = require('assert'); +const ConfirmCallSession = require('../session/confirm-call-session'); +const hooks = require('./notifiers'); +const moment = require('moment'); class SingleDialer extends Emitter { - constructor(logger, opts) { + constructor({logger, sbcAddress, target, opts, application, callInfo}) { super(); + assert(target.type); + this.logger = logger; - this.cs = opts.cs; - this.ms = opts.ms; + this.target = target; + this.sbcAddress = sbcAddress; + this.opts = opts; + this.application = application; + this.url = opts.url; + this.method = opts.method; + + this._callSid = uuidv4(); + this.bindings = logger.bindings(); + this.callInfo = Object.assign({}, callInfo, {callSid: this._callSid}); + this.sipStatus; + this.callGone = false; + + this.on('callStatusChange', this._notifyCallStatusChange.bind(this)); } - get callState() { - return this._callState; + get callSid() { + return this._callSid; + } + get callStatus() { + return this.callInfo.callStatus; + } + + async exec(srf, ms, opts) { + let uri, to; + switch (this.target.type) { + case 'phone': + assert(this.target.number); + uri = `sip:${this.opts.number}@${this.sbcAddress}`; + to = this.target.number; + break; + case 'user': + assert(this.target.name); + uri = `sip:${this.target.name}`; + to = this.target.name; + break; + case 'sip': + assert(this.target.uri); + uri = this.target.uri; + to = this.target.name; + break; + default: + // should have been caught by parser + assert(false, `invalid dial type ${this.target.type}: must be phone, user, or sip`); + } + + try { + this.ep = await ms.createEndpoint(); + this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`); + let sdp; + const connectStream = async(remoteSdp) => { + if (remoteSdp !== sdp) { + this.ep.modify(sdp = remoteSdp); + return true; + } + return false; + }; + + Object.assign(opts, { + proxy: `sip:${this.sbcAddress}`, + localSdp: this.ep.local.sdp + }); + if (this.target.auth) opts.auth = this.target.auth; + this.dlg = await srf.createUAC(uri, opts, { + cbRequest: (err, req) => { + if (err) return this.logger.error(err, 'SingleDialer:exec Error creating call'); + + /** + * INVITE has been sent out + * (a) create a logger for this call + * (b) augment this.callInfo with additional call info + */ + this.logger = srf.locals.parentLogger.child({ + callSid: this.callSid, + parentCallSid: this.bindings.callSid, + callId: req.get('Call-ID') + }); + this.inviteInProgress = req; + const status = {callStatus: CallStatus.Trying, sipStatus: 100}; + Object.assign(this.callInfo, {callId: req.get('Call-ID'), from: req.callingNumber, to}); + const {actionHook, notifyHook} = hooks(this.logger, this.callInfo); + this.actionHook = actionHook; + this.notifyHook = notifyHook; + this.emit('callStatusChange', status); + }, + cbProvisional: (prov) => { + const status = {sipStatus: prov.status}; + if ([180, 183].includes(prov.status) && prov.body) { + status.callStatus = CallStatus.EarlyMedia; + if (connectStream(prov.body)) this.emit('earlyMedia'); + } + else status.callStatus = CallStatus.Ringing; + this.emit('callStatusChange', status); + } + }); + connectStream(this.dlg.remote.sdp); + this.dlg.callSid = this.callSid; + this.inviteInProgress = null; + this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress}); + this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`); + const connectTime = this.dlg.connectTime = moment(); + + this.dlg.on('destroy', () => { + const duration = moment().diff(connectTime, 'seconds'); + this.logger.debug('SingleDialer:exec called party hung up'); + this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration}); + this.ep.destroy(); + }); + + if (this.url) this._executeApp(this.url); + else this.emit('accept'); + } catch (err) { + const status = {callStatus: CallStatus.Failed}; + if (err instanceof SipError) { + status.sipStatus = err.status; + if (err.status === 487) status.callStatus = CallStatus.NoAnswer; + else if ([486, 600].includes(err.status)) status.callStatus = CallStatus.Busy; + this.logger.debug(`SingleDialer:exec outdial failure ${err.status}`); + } + else { + this.logger.error(err, 'SingleDialer:exec'); + status.sipStatus = 500; + } + this.emit('callStatusChange', status); + if (this.ep) this.ep.destroy(); + } } /** - * launch the outdial - */ - exec() { - - } - - /** - * kill the call in progress, or stable dialog, whichever + * kill the call in progress or the stable dialog, whichever we have */ async kill() { - + if (this.inviteInProgress) await this.inviteInProgress.cancel(); + else if (this.dlg && this.dlg.connected) { + const duration = moment().diff(this.dlg.connectTime, 'seconds'); + this.logger.debug('SingleDialer:kill hanging up called party'); + this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration}); + } + if (this.ep) { + this.logger.debug(`SingleDialer:kill - deleting endpoint ${this.ep.uuid}`); + await this.ep.destroy(); + } } /** - * execute a jambones application on this call / endpoint - * @param {*} jambones document + * Run an application on the call after answer, e.g. call screening. + * Once the application completes in some fashion, emit an 'accepted' event + * if the call is still up/connected, a 'decline' otherwise. + * Note: the application to run may not include a dial or sip:decline verb + * @param {*} url - url for application */ - async runApp(document) { + async _executeApp(url) { + this.logger.debug(`SingleDialer:_executeApp: executing ${url} after connect`); + try { + const tasks = await this.actionHook(this.url, this.method); + const allowedTasks = tasks.filter((task) => { + return [ + TaskPreconditions.StableCall, + TaskPreconditions.Endpoint + ].includes(task.preconditions); + }); + if (tasks.length !== allowedTasks.length) { + throw new Error('unsupported verb in dial url'); + } + this.logger.debug(`SingleDialer:_executeApp: executing ${tasks.length} tasks`); + const cs = new ConfirmCallSession(this.logger, this.application, this.dlg, this.ep, tasks); + await cs.exec(); + this.emit(this.dlg.connected ? 'accept' : 'decline'); + } catch (err) { + this.logger.debug(err, 'SingleDialer:_executeApp: error'); + this.emit('decline'); + if (this.dlg.connected) this.dlg.destroy(); + } } - async _createEndpoint() { - + _notifyCallStatusChange(callStatus) { + try { + const auth = {}; + if (this.application.hook_basic_auth_user && this.application.hook_basic_auth_password) { + Object.assign(auth, {user: this.application.hook_basic_auth_user, password: this.hook_basic_auth_password}); + } + this.notifyHook(this.application.call_status_hook, + this.application.hook_http_method, + auth, + callStatus); + } catch (err) { + this.logger.info(err, `SingleDialer:_notifyCallStatusChange: error sending ${JSON.stringify(callStatus)}`); + } } - - async _outdial() { - - } - } -function placeOutdial(logger, opts) { - const singleDialer = new SingleDialer(logger, opts); - singleDialer.exec(); - return singleDialer; +function placeOutdial({logger, srf, ms, sbcAddress, target, opts, application, callInfo}) { + const sd = new SingleDialer({logger, sbcAddress, target, opts, application, callInfo}); + sd.exec(srf, ms, opts); + return sd; } module.exports = placeOutdial; diff --git a/lib/utils/resources.js b/lib/utils/resources.js deleted file mode 100644 index 9631a2f2..00000000 --- a/lib/utils/resources.js +++ /dev/null @@ -1,50 +0,0 @@ -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/lib/utils/retrieve-app.js b/lib/utils/retrieve-app.js new file mode 100644 index 00000000..66d5a632 --- /dev/null +++ b/lib/utils/retrieve-app.js @@ -0,0 +1,30 @@ +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, qs, body) { + logger.debug(`body: ${body}`); + const opts = {url, method, auth, qs, json: true}; + if (body) { + logger.debug('adding body'); + Object.assign(opts, {body}); + } + return new Promise((resolve, reject) => { + request(opts, (err, response, body) => { + if (err) throw err; + resolve(body); + }); + }); +} + +async function retrieveApp(logger, url, method, auth, qs, body) { + let json; + + if (typeof url === 'object') json = url; + else json = await retrieveUrl(logger, url, method, auth, qs, body); + return normalizeJamones(logger, json).map((tdata) => makeTask(logger, tdata)); +} + +module.exports = retrieveApp; diff --git a/package.json b/package.json index 20adf4b0..647035b2 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "jambones-feature-server", + "name": "jambonz-feature-server", "version": "0.0.1", "main": "app.js", "engines": { @@ -7,16 +7,17 @@ }, "keywords": [ "sip", - "drachtio" + "drachtio", + "jambonz" ], "author": "Dave Horton", "license": "MIT", "repository": { "type": "git", - "url": "https://github.com/jambonz/jambones-feature-server.git" + "url": "https://github.com/jambonz/jambonz-feature-server.git" }, "bugs": { - "url": "https://github.com/jambonz/jambones-feature-server/issues" + "url": "https://github.com/jambonz/jambonz-feature-server/issues" }, "scripts": { "start": "node app", @@ -28,9 +29,9 @@ "config": "^3.2.4", "debug": "^4.1.1", "drachtio-fn-b2b-sugar": "0.0.12", - "drachtio-fsmrf": "^1.5.11", + "drachtio-fsmrf": "^1.5.12", "drachtio-srf": "^4.4.27", - "jambonz-db-helpers": "^0.1.7", + "jambonz-db-helpers": "^0.1.8", "moment": "^2.24.0", "pino": "^5.14.0", "request": "^2.88.0", diff --git a/test/data/good/dial-transcribe.json b/test/data/good/dial-transcribe.json index 1d82f660..3ed2be7d 100644 --- a/test/data/good/dial-transcribe.json +++ b/test/data/good/dial-transcribe.json @@ -9,11 +9,11 @@ } ], "transcribe": { - "action": "http://example.com/transcribe", + "transcriptionCallback": "http://example.com/transcribe", "recognizer": { "vendor": "google", "language" : "en-US", - "mixType" : "stereo", + "dualChannel" : true, "interim": true } } diff --git a/test/unit-tests.js b/test/unit-tests.js index 6dd1ff47..ca35acc9 100644 --- a/test/unit-tests.js +++ b/test/unit-tests.js @@ -46,6 +46,8 @@ test('app payload parsing tests', (t) => { t.end(); }); +console.log('exiting'); + const errInvalidInstruction = () => makeTask(logger, require('./data/bad/unknown-instruction')); const errUnknownProperty = () => makeTask(logger, require('./data/bad/unknown-property'));