diff --git a/lib/call-session.js b/lib/call-session.js index d09dd9db..7a23a099 100644 --- a/lib/call-session.js +++ b/lib/call-session.js @@ -9,6 +9,7 @@ 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 { @@ -34,27 +35,34 @@ class CallSession extends Emitter { this.hooks = notifiers(this.logger, this.callAttributes); - req.on('cancel', this._onCallerHangup.bind(this)); + 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) { + 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}`); } @@ -92,6 +100,7 @@ class CallSession extends Emitter { } 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; @@ -105,6 +114,7 @@ class CallSession extends Emitter { 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}); @@ -112,7 +122,10 @@ class CallSession extends Emitter { 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) { @@ -122,13 +135,15 @@ class CallSession extends Emitter { } _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}`); + 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}`); } @@ -152,6 +167,11 @@ class CallSession extends Emitter { 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 @@ -208,6 +228,7 @@ class CallSession extends Emitter { 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) { @@ -265,10 +286,25 @@ class CallSession extends Emitter { } /** - * got CANCEL from inbound leg + * got CANCEL or BYE from inbound leg */ - _onCallerHangup(evt) { - this.logger.debug('CallSession: caller hung before connection'); + _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(); } /** diff --git a/lib/middleware.js b/lib/middleware.js index 94309205..c0136eb8 100644 --- a/lib/middleware.js +++ b/lib/middleware.js @@ -4,6 +4,7 @@ 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'); module.exports = function(srf, logger) { @@ -68,19 +69,21 @@ module.exports = function(srf, logger) { const call_sid = uuidv4(); const method = (app.hook_http_method || 'POST').toUpperCase(); const from = req.getParsedHeader('From'); - const qs = req.locals.callAttributes = { + 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'), + SipCallID: req.get('Call-ID') + }; + const qs = Object.assign({}, req.locals.callAttributes, { + CallStatus: CallStatus.Trying, + SipStatus: 100, RequestorIP: req.get('X-Forwarded-For'), RequestorName: req.get('X-Originating-Carrier') - }; + }); const opts = { url: app.call_hook, method, @@ -88,6 +91,7 @@ module.exports = function(srf, logger) { qs }; 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}}); } if (method === 'POST') Object.assign(opts, {body: req.msg}); @@ -95,26 +99,16 @@ module.exports = function(srf, logger) { request(opts, (err, response, body) => { if (err) { logger.error(err, `Error invoking callback ${app.call_hook}`); - return res.send(603, 'Bad webhook'); + return res.send(500, 'Webhook Failure'); } - logger.debug(body, 'application payload'); - const taskData = Array.isArray(body) ? body : [body]; - app.tasks = []; - for (const t in taskData) { - try { - const task = makeTask(logger, taskData[t]); - app.tasks.push(task); - } catch (err) { - logger.error({err, data: taskData[t]}, `invalid web callback payload: ${err.message}`); - res.send(500, 'Application Error', { - headers: { - 'X-Reason': err.message - } - }); - break; - } + 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); } - if (!res.finalResponseSent) next(); }); } catch (err) { logger.error(err, 'Error invoking web callback'); diff --git a/lib/tasks/dial.js b/lib/tasks/dial.js index e4be8dda..975a6ba6 100644 --- a/lib/tasks/dial.js +++ b/lib/tasks/dial.js @@ -28,10 +28,10 @@ class TaskDial extends Task { this.timeLimit = opts.timeLimit; if (opts.listen) { - this.listenTask = makeTask(logger, {'listen': opts.transcribe}); + this.listenTask = makeTask(logger, {'listen': opts.listen}); } if (opts.transcribe) { - this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}); + this.transcribeTask = makeTask(logger, {'transcribe' : opts.transcribe}); } this.canceled = false; diff --git a/lib/tasks/gather.js b/lib/tasks/gather.js index 89fe0fba..41cf3ffa 100644 --- a/lib/tasks/gather.js +++ b/lib/tasks/gather.js @@ -1,5 +1,5 @@ const Task = require('./task'); -const {TaskName, TaskPreconditions} = require('../utils/constants'); +const {TaskName, TaskPreconditions, TranscriptionEvents} = require('../utils/constants'); const makeTask = require('./make_task'); const assert = require('assert'); @@ -19,6 +19,7 @@ class TaskGather extends Task { 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}); @@ -27,6 +28,11 @@ class TaskGather extends Task { get name() { return TaskName.Gather; } + get earlyMedia() { + return (this.sayTask && this.sayTask.earlyMedia) || + (this.playTask && this.playTask.earlyMedia); + } + async exec(cs, ep) { this.ep = ep; this.actionHook = cs.actionHook; @@ -35,33 +41,15 @@ class TaskGather extends Task { 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)); + this.sayTask.on('playDone', (err) => { + if (this.taskInProgress) this._startTimer(); + }); } 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')); + await this._initSpeech(ep); + this._startTranscribing(ep); } if (this.input.includes('dtmf')) { @@ -73,10 +61,12 @@ class TaskGather extends Task { this.logger.error(err, 'TaskGather:exec error'); } this.taskInProgress = false; - ep.removeAllListeners(); + ep.removeCustomEventListener(TranscriptionEvents.Transcription); + ep.removeCustomEventListener(TranscriptionEvents.EndOfUtterance); } kill() { + super.kill(); this._killAudio(); this._resolve('killed'); } @@ -85,12 +75,6 @@ class TaskGather extends Task { 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'); @@ -101,6 +85,32 @@ class TaskGather extends Task { this._killAudio(); } + async _initSpeech(ep) { + 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(TranscriptionEvents.Transcription, this._onTranscription.bind(this, ep)); + ep.addCustomEventListener(TranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, ep)); + } + + _startTranscribing(ep) { + ep.startTranscription({ + interim: this.partialResultCallback ? true : false, + language: this.language + }).catch((err) => this.logger.error(err, 'TaskGather:_startTranscribing error')); + } + _startTimer() { assert(!this._timeoutTimer); this._timeoutTimer = setTimeout(() => this._resolve('timeout'), this.timeout); @@ -123,6 +133,8 @@ class TaskGather extends Task { _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) { @@ -131,11 +143,9 @@ class TaskGather extends Task { }); } } - _onNoAudioDetected(ep, evt) { - this.logger.info(evt, 'TaskGather:_onNoAudioDetected'); - } - _onMaxDuration(ep, evt) { - this.logger.info(evt, 'TaskGather:_onMaxDuration'); + _onEndOfUtterance(ep, evt) { + this.logger.info(evt, 'TaskGather:_onEndOfUtterance'); + this._startTranscribing(ep); } _resolve(reason, evt) { diff --git a/lib/tasks/hangup.js b/lib/tasks/hangup.js index 8065d608..0480e144 100644 --- a/lib/tasks/hangup.js +++ b/lib/tasks/hangup.js @@ -1,10 +1,12 @@ const Task = require('./task'); -const {TaskName} = require('../utils/constants'); +const {TaskName, TaskPreconditions} = require('../utils/constants'); class TaskHangup extends Task { constructor(logger, opts) { super(logger, opts); this.headers = this.data.headers || {}; + + this.preconditions = TaskPreconditions.StableCall; } get name() { return TaskName.Hangup; } @@ -16,7 +18,7 @@ class TaskHangup extends Task { 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}`); + this.logger.error(err, 'TaskHangup:exec - Error hanging up call'); } } } diff --git a/lib/tasks/listen.js b/lib/tasks/listen.js new file mode 100644 index 00000000..37f51435 --- /dev/null +++ b/lib/tasks/listen.js @@ -0,0 +1,131 @@ +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) { + super(logger, opts); + this.preconditions = TaskPreconditions.Endpoint; + + [ + '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; + + if (this.transcribe) { + this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}); + } + + this._dtmfHandler = this._onDtmf.bind(this); + + this._completionPromise = new Promise((resolve) => this._completionResolver = resolve); + } + + get name() { return TaskName.Listen; } + + async exec(cs, ep) { + 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 + } + await this._startListening(ep); + await this._completionPromise; + } 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; + } + await this.ep.forkAudioStop() + .catch((err) => this.logger.info(err, 'TaskListen:kill')); + } + this._completionResolver(); + } + + async _playBeep(ep) { + await ep.play('tone_stream://L=1;%(500, 0, 1500)') + .catch((err) => this.logger.info(err, 'TaskListen:_playBeep Error playing beep')); + } + + async _startListening(ep) { + this._initListeners(ep); + await ep.forkAudioStart({ + wsUrl: this.url, + mixType: this.mixType, + sampling: this.sampleRate, + metadata: this.metadata + }); + if (this.timeout) { + this._timer = setTimeout(() => { + this.logger.debug(`TaskListen:_startListening terminating task due to timeout of ${this.timeout} reached`); + this.kill(); + }, this.timeout * 1000); + } + } + + _initListeners(ep) { + ep.addCustomEventListener(ListenEvents.Connect, this._onConnect.bind(this, ep)); + ep.addCustomEventListener(ListenEvents.ConnectFailure, this._onConnectFailure.bind(this, ep)); + ep.addCustomEventListener(ListenEvents.Error, this._onError.bind(this, ep)); + if (this.finishOnKey || this.passDtmf) { + ep.on('dtmf', this._dtmfHandler); + } + } + + _removeListeners(ep) { + ep.removeCustomEventListener(ListenEvents.Connect); + ep.removeCustomEventListener(ListenEvents.ConnectFailure); + ep.removeCustomEventListener(ListenEvents.Error); + if (this.finishOnKey || this.passDtmf) { + ep.removeListener('dtmf', this._dtmfHandler); + } + } + + _onDtmf(evt) { + if (evt.dtmf === this.finishOnKey) { + this.logger.info(`TaskListen:_onDtmf terminating task due to dtmf ${evt.dtmf}`); + this.kill(); + } + } + + _clearTimer() { + if (this._timer) { + clearTimeout(this._timer); + this._timer = null; + } + } + _onConnect(ep) { + this.logger.debug('TaskListen:_onConnect'); + } + _onConnectFailure(ep, evt) { + this.logger.info(evt, 'TaskListen:_onConnectFailure'); + this._completionResolver(); + } + _onError(ep, evt) { + this.logger.info(evt, 'TaskListen:_onError'); + this._completionResolver(); + } + +} + +module.exports = TaskListen; diff --git a/lib/tasks/make_task.js b/lib/tasks/make_task.js index 68cc9871..f9b725f6 100644 --- a/lib/tasks/make_task.js +++ b/lib/tasks/make_task.js @@ -2,13 +2,15 @@ const Task = require('./task'); 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; +function makeTask(logger, obj) { + const keys = Object.keys(obj); + if (!keys || keys.length !== 1) { + throw errBadInstruction; + } const name = keys[0]; - const data = opts[name]; + const data = obj[name]; + logger.debug(data, `makeTask: ${name}`); + if (typeof data !== 'object') throw errBadInstruction; Task.validate(name, data); switch (name) { case TaskName.SipDecline: @@ -26,6 +28,12 @@ function makeTask(logger, opts) { case TaskName.Gather: const TaskGather = require('./gather'); return new TaskGather(logger, data); + case TaskName.Transcribe: + const TaskTranscribe = require('./transcribe'); + return new TaskTranscribe(logger, data); + case TaskName.Listen: + const TaskListen = require('./listen'); + return new TaskListen(logger, data); } // should never reach diff --git a/lib/tasks/play.js b/lib/tasks/play.js new file mode 100644 index 00000000..12c02f83 --- /dev/null +++ b/lib/tasks/play.js @@ -0,0 +1,40 @@ +const Task = require('./task'); +const {TaskName, TaskPreconditions} = require('../utils/constants'); + +class TaskPlay extends Task { + constructor(logger, opts) { + super(logger, opts); + this.preconditions = TaskPreconditions.Endpoint; + + 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) { + this.ep = ep; + try { + while (!this.playComplete && 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() { + 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')); + } + } +} + +module.exports = TaskPlay; diff --git a/lib/tasks/say.js b/lib/tasks/say.js index 749f55d1..40df8a22 100644 --- a/lib/tasks/say.js +++ b/lib/tasks/say.js @@ -31,7 +31,7 @@ class TaskSay extends Task { text: this.text }); } catch (err) { - if (err.message !== 'hangup') this.logger.info(err, 'TaskSay:exec error'); + this.logger.info(err, 'TaskSay:exec error'); } this.emit('playDone'); this.sayComplete = true; diff --git a/lib/tasks/specs.json b/lib/tasks/specs.json index 9f7e7176..0ae058cf 100644 --- a/lib/tasks/specs.json +++ b/lib/tasks/specs.json @@ -16,11 +16,22 @@ "required": [ ] }, + "play": { + "properties": { + "url": "string", + "loop": "number", + "earlyMedia": "boolean" + }, + "required": [ + "url" + ] + }, "say": { "properties": { "text": "string", "loop": "number", - "synthesizer": "#synthesizer" + "synthesizer": "#synthesizer", + "earlyMedia": "boolean" }, "required": [ "text", @@ -31,14 +42,12 @@ "properties": { "action": "string", "finishOnKey": "string", - "hints": "array", "input": "array", - "language": "string", "numDigits": "number", "partialResultCallback": "string", - "profanityFilter": "boolean", "speechTimeout": "number", "timeout": "number", + "recognizer": "#recognizer", "say": "#say" }, "required": [ @@ -73,37 +82,34 @@ }, "listen": { "properties": { + "finishOnKey": "string", + "maxLength": "number", "metadata": "object", "mixType": { "type": "string", "enum": ["mono", "stereo", "mixed"] }, "passDtmf": "boolean", + "playBeep": "boolean", "sampleRate": "number", - "source": { - "type": "string", - "enum": ["parent", "child", "both"] - }, - "wsUrl": "string" + "timeout": "number", + "transcribe": "#transcribe", + "url": "string", + "earlyMedia": "boolean" }, "required": [ - "wsUrl", - "sampleRate" + "url" ] }, "transcribe": { "properties": { "action": "string", - "interim": "boolean", - "jsonKey": "string", - "language": "string", - "source": "string", - "vendor": "string" + "recognizer": "#recognizer", + "earlyMedia": "boolean" }, "required": [ "action", - "jsonKey", - "language" + "recognizer" ] }, "target": { @@ -143,6 +149,28 @@ "enum": ["google"] }, "voice": "string" - } + }, + "required": [ + "vendor" + ] + }, + "recognizer": { + "properties": { + "vendor": { + "type": "string", + "enum": ["google"] + }, + "language": "string", + "hints": "array", + "profanityFilter": "boolean", + "interim": "boolean", + "mixType": { + "type": "string", + "enum": ["mono", "stereo", "mixed"] + } + }, + "required": [ + "vendor" + ] } } diff --git a/lib/tasks/task.js b/lib/tasks/task.js index 13d600f6..b6b5f4c7 100644 --- a/lib/tasks/task.js +++ b/lib/tasks/task.js @@ -13,6 +13,12 @@ class Task extends Emitter { this.preconditions = TaskPreconditions.None; this.logger = logger; this.data = data; + + this._killInProgress = false; + } + + get killed() { + return this._killInProgress; } /** @@ -20,6 +26,8 @@ class Task extends Emitter { * what to do is up to each type of task */ kill() { + this.logger.debug(`${this.name} is being killed`); + this._killInProgress = true; // no-op } diff --git a/lib/tasks/transcribe.js b/lib/tasks/transcribe.js new file mode 100644 index 00000000..e605f192 --- /dev/null +++ b/lib/tasks/transcribe.js @@ -0,0 +1,106 @@ +const Task = require('./task'); +const {TaskName, TaskPreconditions, TranscriptionEvents} = require('../utils/constants'); +const assert = require('assert'); + +class TaskTranscribe extends Task { + constructor(logger, opts) { + 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); + } + + get name() { return TaskName.Transcribe; } + + async exec(cs, ep, parentTask) { + this.ep = ep; + this.actionHook = ep.cs.actionHook; + this.transcribeInProgress = true; + try { + await this._initSpeech(ep); + await this._startTranscribing(ep); + await this._completionPromise; + } catch (err) { + this.logger.info(err, 'TaskTranscribe:exec - error'); + } + this.transcribeInProgress = true; + ep.removeCustomEventListener(TranscriptionEvents.Transcription); + } + + async kill() { + super.kill(); + if (this.ep.connected && this.transcribeInProgress) { + 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); + } + else { + this._completionResolver(); + } + await this._completionPromise; + } + + async _initSpeech(ep) { + const opts = { + GOOGLE_SPEECH_USE_ENHANCED: 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}); + } + await ep.set(opts) + .catch((err) => this.logger.info(err, 'TaskTranscribe:_initSpeech error setting fs vars')); + 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)); + } + + async _startTranscribing(ep) { + await ep.startTranscription({ + interim: this.interim ? true : false, + language: this.language + }); + } + + _onTranscription(ep, evt) { + this.logger.debug(evt, 'TaskTranscribe:_onTranscription'); + this.actionHook(this.action, 'POST', { + Speech: evt + }); + if (this.killed) { + this.logger.debug('TaskTranscribe:_onTranscription exiting after receiving final transcription'); + this._clearTimer(); + this._completionResolver(); + } + } + + _onNoAudio(ep) { + this.logger.debug('TaskTranscribe:_onNoAudio restarting transcription'); + this._startTranscribing(ep); + } + + _onMaxDurationExceeded(ep) { + this.logger.debug('TaskTranscribe:_onMaxDurationExceeded restarting transcription'); + this._startTranscribing(ep); + } + + _clearTimer() { + if (this._timer) { + clearTimeout(this._timer); + this._timer = null; + } + } +} + +module.exports = TaskTranscribe; diff --git a/lib/utils/constants.json b/lib/utils/constants.json index 1309ea30..eb2fc27c 100644 --- a/lib/utils/constants.json +++ b/lib/utils/constants.json @@ -32,5 +32,23 @@ "Endpoint": "endpoint", "StableCall": "stable-call", "UnansweredCall": "unanswered-call" + }, + "TranscriptionEvents": { + "Transcription": "google_transcribe::transcription", + "EndOfUtterance": "google_transcribe::end_of_utterance", + "NoAudioDetected": "google_transcribe::no_audio_detected", + "MaxDurationExceeded": "google_transcribe::max_duration_exceeded" + }, + "ListenEvents": { + "Connect": "mod_audio_fork::connect", + "ConnectFailure": "mod_audio_fork::connect_failed", + "Transcription": "mod_audio_fork::transcription", + "Transfer": "mod_audio_fork::transcription", + "PlayAudio": "mod_audio_fork::play_audio", + "KillAudio": "mod_audio_fork::kill_audio", + "Disconnect": "mod_audio_fork::disconnect", + "Error": "mod_audio_fork::error", + "BufferOverrun": "mod_audio_fork::buffer_overrun", + "JsonMessage": "mod_audio_fork::json" } } diff --git a/lib/utils/normalize-jamones.js b/lib/utils/normalize-jamones.js new file mode 100644 index 00000000..73ecd367 --- /dev/null +++ b/lib/utils/normalize-jamones.js @@ -0,0 +1,33 @@ +function normalizeJambones(logger, obj) { + logger.debug(`normalizeJambones: ${JSON.stringify(obj)}`); + if (!Array.isArray(obj)) throw new Error('invalid JSON: jambones docs must be array'); + const document = []; + for (const tdata of obj) { + if (typeof tdata !== 'object') throw new Error('invalid JSON: jambones docs must be array of objects'); + if (Object.keys(tdata).length === 1) { + // {'say': {..}} + logger.debug(`pushing ${JSON.stringify(tdata)}`); + document.push(tdata); + } + else if ('verb' in tdata) { + // {verb: 'say', text: 'foo..bar'..} + const name = tdata.verb; + const o = {}; + Object.keys(tdata) + .filter((k) => k !== 'verb') + .forEach((k) => o[k] = tdata[k]); + const o2 = {}; + o2[name] = o; + document.push(o2); + } + else { + logger.info(tdata, `invalid JSON: invalid verb form, numkeys ${Object.keys(tdata).length}`); + throw new Error('invalid JSON: invalid verb form'); + } + } + logger.debug(`returning document with ${document.length} tasks`); + return document; +} + +module.exports = normalizeJambones; + diff --git a/lib/utils/notifiers.js b/lib/utils/notifiers.js index 74036a12..45bd5df6 100644 --- a/lib/utils/notifiers.js +++ b/lib/utils/notifiers.js @@ -11,8 +11,8 @@ function hooks(logger, callAttributes) { url, method, json: true, - qs: 'GET' === method ? params : null, - body: 'POST' === method ? params : null + qs: 'GET' === method ? params : callAttributes, + body: 'POST' === method ? opts : null }; logger.debug(`${method} ${url} sending ${JSON.stringify(obj)}`); return new Promise((resolve, reject) => { diff --git a/lib/utils/place-outdial.js b/lib/utils/place-outdial.js new file mode 100644 index 00000000..1ca82165 --- /dev/null +++ b/lib/utils/place-outdial.js @@ -0,0 +1,55 @@ +const Emitter = require('events'); +const {CallStatus} = require('./constants'); + +class SingleDialer extends Emitter { + constructor(logger, opts) { + super(); + this.logger = logger; + this.cs = opts.cs; + this.ms = opts.ms; + } + + get callState() { + return this._callState; + } + + /** + * launch the outdial + */ + exec() { + + } + + /** + * kill the call in progress, or stable dialog, whichever + */ + async kill() { + + } + + /** + * execute a jambones application on this call / endpoint + * @param {*} jambones document + */ + async runApp(document) { + + } + + async _createEndpoint() { + + } + + async _outdial() { + + } + +} + +function placeOutdial(logger, opts) { + const singleDialer = new SingleDialer(logger, opts); + singleDialer.exec(); + return singleDialer; +} + +module.exports = placeOutdial; + diff --git a/package-lock.json b/package-lock.json index 1c20f620..2835728d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -706,6 +706,50 @@ "resolved": "https://registry.npmjs.org/drachtio-fn-b2b-sugar/-/drachtio-fn-b2b-sugar-0.0.12.tgz", "integrity": "sha512-FKPAcEMJTYKDrd9DJUCc4VHnY/c65HOO9k8XqVNognF9T02hKEjGuBCM4Da9ipyfiHmVRuECwj0XNvZ361mkVQ==" }, + "drachtio-fsmrf": { + "version": "1.5.11", + "resolved": "https://registry.npmjs.org/drachtio-fsmrf/-/drachtio-fsmrf-1.5.11.tgz", + "integrity": "sha512-4DQ5N0jCQIHYVn06zPw0lH4sq+nCP11NiC0Y680eC2biFeK2UREFzqJg+/tFKeK4yuj89sshMcVx+0cG+x1E8Q==", + "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", @@ -959,6 +1003,11 @@ "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", @@ -2762,6 +2811,16 @@ "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", @@ -4887,6 +4946,20 @@ "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 4bdabd19..20adf4b0 100644 --- a/package.json +++ b/package.json @@ -28,9 +28,9 @@ "config": "^3.2.4", "debug": "^4.1.1", "drachtio-fn-b2b-sugar": "0.0.12", - "drachtio-fsmrf": "1.5.10", + "drachtio-fsmrf": "^1.5.11", "drachtio-srf": "^4.4.27", - "jambonz-db-helpers": "^0.1.6", + "jambonz-db-helpers": "^0.1.7", "moment": "^2.24.0", "pino": "^5.14.0", "request": "^2.88.0", diff --git a/test/data/bad/invalid-type.json b/test/data/bad/invalid-type.json index b400a1b5..7e28d615 100644 --- a/test/data/bad/invalid-type.json +++ b/test/data/bad/invalid-type.json @@ -2,4 +2,4 @@ "sip:decline": { "status": "hello" } -} \ No newline at end of file +} diff --git a/test/data/good/alternate-syntax.json b/test/data/good/alternate-syntax.json new file mode 100644 index 00000000..62b8053b --- /dev/null +++ b/test/data/good/alternate-syntax.json @@ -0,0 +1,31 @@ +[ + { + "verb": "gather", + "action": "https://00dd977a.ngrok.io/gather", + "input": ["speech"], + "timeout": 12, + "recognizer": { + "vendor": "google", + "language": "en-US", + "hints": ["sales", "support", "engineering", "human resources", "HR", "operator", "agent"] + }, + "say": { + "text": "Please say the name of the department that you would like to speak with. To speak to an operator, just say operator.", + "synthesizer": { + "vendor": "google", + "voice": "en-US-Wavenet-C" + } + } + }, + { + "verb": "say", + "text": "I'm sorry, I did not hear a response. Goodbye.", + "synthesizer": { + "vendor": "google", + "voice": "en-US-Wavenet-C" + } + }, + { + "verb": "hangup" + } +] \ No newline at end of file diff --git a/test/data/good/dial-listen.json b/test/data/good/dial-listen.json index 31bca8ee..ccb3e363 100644 --- a/test/data/good/dial-listen.json +++ b/test/data/good/dial-listen.json @@ -9,11 +9,10 @@ } ], "listen": { - "wsUrl": "wss://myrecorder.example.com:4433", + "url": "wss://myrecorder.example.com:4433", "mixType" : "stereo", "sampleRate": 8000, "passDtmf": true, - "source": "parent", "metadata": { "clientId": "12udih" } diff --git a/test/data/good/dial-sip.json b/test/data/good/dial-sip.json index cde1a088..b0e1f88e 100644 --- a/test/data/good/dial-sip.json +++ b/test/data/good/dial-sip.json @@ -13,4 +13,4 @@ } ] } -} \ No newline at end of file +} diff --git a/test/data/good/dial-transcribe.json b/test/data/good/dial-transcribe.json index fb5f7d4b..1d82f660 100644 --- a/test/data/good/dial-transcribe.json +++ b/test/data/good/dial-transcribe.json @@ -10,11 +10,12 @@ ], "transcribe": { "action": "http://example.com/transcribe", - "language" : "en-US", - "source" : "both", - "interim": true, - "vendor": "google", - "jsonKey": "--json service key--" + "recognizer": { + "vendor": "google", + "language" : "en-US", + "mixType" : "stereo", + "interim": true + } } } } \ No newline at end of file diff --git a/test/unit-tests.js b/test/unit-tests.js index a4c66833..6dd1ff47 100644 --- a/test/unit-tests.js +++ b/test/unit-tests.js @@ -36,6 +36,13 @@ test('app payload parsing tests', (t) => { task = makeTask(logger, require('./data/good/dial-listen')); t.ok(task.name === 'dial', 'parsed dial w/ listen'); + const alt = require('./data/good/alternate-syntax'); + const normalize = require('../lib/utils/normalize-jamones'); + normalize(logger, alt).forEach((t) => { + const task = makeTask(logger, t); + }); + t.pass('alternate syntax works'); + t.end(); });