mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2025-12-20 08:40:38 +00:00
wip: implemented listen, transcribe, play
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
131
lib/tasks/listen.js
Normal file
131
lib/tasks/listen.js
Normal file
@@ -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;
|
||||
@@ -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
|
||||
|
||||
40
lib/tasks/play.js
Normal file
40
lib/tasks/play.js
Normal file
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
106
lib/tasks/transcribe.js
Normal file
106
lib/tasks/transcribe.js
Normal file
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
33
lib/utils/normalize-jamones.js
Normal file
33
lib/utils/normalize-jamones.js
Normal file
@@ -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;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
55
lib/utils/place-outdial.js
Normal file
55
lib/utils/place-outdial.js
Normal file
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user