Compare commits

...

25 Commits

Author SHA1 Message Date
Dave Horton
7e4654692d bugfix #30 - outdial race condition for quick caller cancel scenario 2021-01-22 10:38:49 -05:00
Dave Horton
d255339dac support for X-Override-To 2020-12-09 18:05:59 -05:00
Dave Horton
c25df2ad7e bugfix for REST outdial to teams 2020-11-24 10:06:58 -05:00
Dave Horton
348fa4f654 bugfix: sip:decline was not sending a callstatus Failed webhook 2020-11-23 09:09:34 -05:00
Dave Horton
fff52c930c fix two B2B race conditions: overlapping requests to freeswitch, and simultaneous answer on B with Cancel on A 2020-10-20 11:56:09 -04:00
Dave Horton
950f1c83b7 bugfix for race condition where incoming call canceled quickly leading to potential endless loop 2020-10-01 12:46:58 -04:00
Dave Horton
e642e13946 bugfix for #22: headers were being incorrectly applied to follow-on INVITEs 2020-09-21 08:29:54 -04:00
Dave Horton
8f65b0de2f bugfix #21: multiple teams target 2020-09-18 09:13:30 -04:00
Dave Horton
868427216f bump deps 2020-08-18 14:57:34 -04:00
Dave Horton
2c8c161954 fix overlapping requests to freeswitch on outdial 2020-08-03 11:51:58 -04:00
Dave Horton
884e63e0ef allow proxy in dial verb 2020-07-24 15:33:06 -04:00
Dave Horton
3624b05eb6 bugfix: gather can only resolve once 2020-07-20 08:58:37 -04:00
Dave Horton
b739737c29 deps 2020-07-16 16:16:37 -04:00
Dave Horton
15517828d2 typo 2020-07-13 09:58:42 -04:00
Dave Horton
490472ca68 bugfix dialogflow no input timeout 2020-07-10 12:02:11 -04:00
Dave Horton
565ad2948c bugfix dialogflow no input event 2020-07-10 11:50:21 -04:00
Dave Horton
31bed8afbd dialogflow: allow app to specify event to send in case on no input 2020-07-10 11:33:48 -04:00
Dave Horton
6e78b46674 more dialogflow changes 2020-07-08 16:07:49 -04:00
Dave Horton
a4bcfca9e6 added initial support for dialogflow 2020-07-08 14:16:37 -04:00
Dave Horton
c1112ea477 bugfix: synthesizer properties in the say verb were being ignored 2020-06-18 10:00:31 -04:00
Dave Horton
4e4ce0914e bugfix: say text continued to play after task was killed 2020-06-16 14:39:46 -04:00
Dave Horton
1dc4728574 update to use @jambonz modules 2020-06-10 18:33:39 -04:00
Dave Horton
d7eeb52a84 bugfix: tts was only being cached when same prompt played in a single call 2020-06-10 10:23:32 -04:00
Dave Horton
f3fcdfc481 update to drachtio-srf@4.4.34 2020-06-08 14:26:30 -04:00
Dave Horton
cd58d4a4f0 additional teams changes 2020-06-06 08:28:26 -04:00
26 changed files with 1824 additions and 1404 deletions

3
.gitignore vendored
View File

@@ -37,4 +37,5 @@ node_modules
examples/*
ecosystem.config.js
ecosystem.config.js
.vscode

View File

@@ -26,8 +26,19 @@ router.post('/', async(req, res) => {
switch (target.type) {
case 'phone':
case 'teams':
uri = `sip:${target.number}@${sbcAddress}`;
to = target.number;
if ('teams' === target.type) {
const {lookupTeamsByAccount} = srf.locals.dbHelpers;
const obj = await lookupTeamsByAccount(req.body.account_sid);
if (!obj) throw new Error('dial to ms teams not allowed; account must first be configured with teams info');
Object.assign(opts.headers, {
'X-MS-Teams-FQDN': obj.ms_teams_fqdn,
'X-MS-Teams-Tenant-FQDN': target.tenant || obj.tenant_fqdn
});
if (target.vmail === true) uri = `${uri};opaque=app:voicemail`;
}
break;
case 'user':
uri = `sip:${target.name}`;
@@ -124,12 +135,14 @@ router.post('/', async(req, res) => {
if (err instanceof SipError) {
if ([486, 603].includes(err.status)) callStatus = CallStatus.Busy;
else if (487 === err.status) callStatus = CallStatus.NoAnswer;
sipLogger.info(`REST outdial failed with ${err.status}`);
cs.emit('callStatusChange', {callStatus, sipStatus: err.status});
if (sipLogger) sipLogger.info(`REST outdial failed with ${err.status}`);
else console.log(`REST outdial failed with ${err.status}`);
if (cs) cs.emit('callStatusChange', {callStatus, sipStatus: err.status});
}
else {
cs.emit('callStatusChange', {callStatus, sipStatus: 500});
sipLogger.error({err}, 'REST outdial failed');
if (cs) cs.emit('callStatusChange', {callStatus, sipStatus: 500});
if (sipLogger) sipLogger.error({err}, 'REST outdial failed');
else console.error(err);
}
ep.destroy();
}

View File

@@ -138,7 +138,7 @@ module.exports = function(srf, logger) {
if (0 === app.tasks.length) throw new Error('no application provided');
next();
} catch (err) {
logger.info(`Error retrieving or parsing application: ${err.message}`);
logger.info({err}, `Error retrieving or parsing application: ${err.message}`);
res.send(480, {headers: {'X-Reason': err.message}});
}
}

View File

@@ -8,6 +8,7 @@ const makeTask = require('../tasks/make_task');
const normalizeJambones = require('../utils/normalize-jambones');
const listTaskNames = require('../utils/summarize-tasks');
const BADPRECONDITIONS = 'preconditions not met';
const CALLER_CANCELLED_ERR_MSG = 'Response not sent due to unknown transaction';
/**
* @classdesc Represents the execution context for a call.
@@ -462,11 +463,11 @@ class CallSession extends Emitter {
* @param {Task} task - task to be executed
*/
async _evalEndpointPrecondition(task) {
this.logger.debug('_evalEndpointPrecondition');
this.logger.debug('CallSession:_evalEndpointPrecondition');
if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`);
if (this.ep) {
if (!task.earlyMedia || this.dlg) return this.ep;
if (task.earlyMedia === true || this.dlg) return this.ep;
// we are going from an early media connection to answer
await this.propagateAnswer();
@@ -497,8 +498,15 @@ class CallSession extends Emitter {
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`);
if (err === CALLER_CANCELLED_ERR_MSG) {
this.logger.error(err, 'caller canceled quickly before we could respond, ending call');
this._notifyCallStatusChange({callStatus: CallStatus.NoAnswer, sipStatus: 487});
this._callReleased();
}
else {
this.logger.error(err, `Error attempting to allocate endpoint for for task ${task.name}`);
throw new Error(`${BADPRECONDITIONS}: unable to allocate endpoint`);
}
}
}

View File

@@ -73,7 +73,7 @@ class Conference extends Task {
const dlg = cs.dlg;
// reset answer time if we were transferred from another feature server
if (this.this.connectTime) dlg.connectTime = this.connectTime;
if (this.connectTime) dlg.connectTime = this.connectTime;
this.ep.on('destroy', this._kicked.bind(this, cs, dlg));

View File

@@ -37,12 +37,13 @@ function compareTasks(t1, t2) {
if (t1.type !== t2.type) return false;
switch (t1.type) {
case 'phone':
return t1.number === t1.number;
return t1.number === t2.number;
case 'user':
return t1.name === t2.name;
case 'teams':
return t2.name === t1.name;
return t1.number === t2.number;
case 'sip':
return t2.sipUri === t1.sipUri;
return t1.sipUri === t2.sipUri;
}
}
@@ -83,6 +84,7 @@ class TaskDial extends Task {
this.confirmHook = this.data.confirmHook;
this.confirmMethod = this.data.confirmMethod;
this.dtmfHook = this.data.dtmfHook;
this.proxy = this.data.proxy;
if (this.dtmfHook) {
const {parentDtmfCollector, childDtmfCollector} = parseDtmfOptions(logger, this.data.dtmfCapture || {});
@@ -245,7 +247,7 @@ class TaskDial extends Task {
const {req, srf} = cs;
const {getSBC} = srf.locals;
const {lookupTeamsByAccount} = srf.locals.dbHelpers;
const sbcAddress = getSBC();
const sbcAddress = this.proxy || getSBC();
const teamsInfo = {};
if (!sbcAddress) throw new Error('no SBC found for outbound call');
@@ -255,10 +257,11 @@ class TaskDial extends Task {
callingNumber: this.callerId || req.callingNumber
};
if (this.target.find((t) => t.type === 'teams')) {
const t = this.target.find((t) => t.type === 'teams');
if (t) {
const obj = await lookupTeamsByAccount(cs.accountSid);
if (!obj) throw new Error('dial to ms teams not allowed; account must first be configured with teams info');
Object.assign(teamsInfo, {tenant_fqdn: obj.tenant_fqdn, ms_teams_fqdn: obj.ms_teams_fqdn});
Object.assign(teamsInfo, {tenant_fqdn: t.tenant || obj.tenant_fqdn, ms_teams_fqdn: obj.ms_teams_fqdn});
}
const ms = await cs.getMS();

View File

@@ -0,0 +1,70 @@
const Emitter = require('events');
/**
* A dtmf collector
* @class
*/
class DigitBuffer extends Emitter {
/**
* Creates a DigitBuffer
* @param {*} logger - a pino logger
* @param {*} opts - dtmf collection instructions
*/
constructor(logger, opts) {
super();
this.logger = logger;
this.minDigits = opts.min || 1;
this.maxDigits = opts.max || 99;
this.termDigit = opts.term;
this.interdigitTimeout = opts.idt || 8000;
this.template = opts.template;
this.buffer = '';
this.logger.debug(`digitbuffer min: ${this.minDigits} max: ${this.maxDigits} term digit: ${this.termDigit}`);
}
/**
* process a received dtmf digit
* @param {String} a single digit entered by the caller
*/
process(digit) {
this.logger.debug(`digitbuffer process: ${digit}`);
if (digit === this.termDigit) return this._fulfill();
this.buffer += digit;
if (this.buffer.length === this.maxDigits) return this._fulfill();
if (this.buffer.length >= this.minDigits) this._startInterDigitTimer();
this.logger.debug(`digitbuffer buffer: ${this.buffer}`);
}
/**
* clear the digit buffer
*/
flush() {
if (this.idtimer) clearTimeout(this.idtimer);
this.buffer = '';
}
_fulfill() {
this.logger.debug(`digit buffer fulfilled with ${this.buffer}`);
if (this.template && this.template.includes('${digits}')) {
const text = this.template.replace('${digits}', this.buffer);
this.logger.info(`reporting dtmf as ${text}`);
this.emit('fulfilled', text);
}
else {
this.emit('fulfilled', this.buffer);
}
this.flush();
}
_startInterDigitTimer() {
if (this.idtimer) clearTimeout(this.idtimer);
this.idtimer = setTimeout(this._onInterDigitTimeout.bind(this), this.interdigitTimeout);
}
_onInterDigitTimeout() {
this.logger.debug('digit buffer timeout');
this._fulfill();
}
}
module.exports = DigitBuffer;

View File

@@ -0,0 +1,358 @@
const Task = require('../task');
const {TaskName, TaskPreconditions} = require('../../utils/constants');
const Intent = require('./intent');
const DigitBuffer = require('./digit-buffer');
const Transcription = require('./transcription');
const normalizeJambones = require('../../utils/normalize-jambones');
class Dialogflow extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
this.credentials = this.data.credentials;
this.project = this.data.project;
this.lang = this.data.lang || 'en-US';
this.welcomeEvent = this.data.welcomeEvent || '';
if (this.welcomeEvent.length && this.data.welcomeEventParams && typeof this.data.welcomeEventParams === 'object') {
this.welcomeEventParams = this.data.welcomeEventParams;
}
if (this.data.noInputTimeout) this.noInputTimeout = this.data.noInputTimeout * 1000;
else this.noInputTimeout = 20000;
this.noInputEvent = this.data.noInputEvent || 'actions_intent_NO_INPUT';
this.passDtmfAsInputText = this.passDtmfAsInputText === true;
if (this.data.eventHook) this.eventHook = this.data.eventHook;
if (this.eventHook && Array.isArray(this.data.events)) {
this.events = this.data.events;
}
else if (this.eventHook) {
// send all events by default - except interim transcripts
this.events = [
'intent',
'transcription',
'dtmf',
'start-play',
'stop-play',
'no-input'
];
}
else {
this.events = [];
}
if (this.data.actionHook) this.actionHook = this.data.actionHook;
if (this.data.thinkingMusic) this.thinkingMusic = this.data.thinkingMusic;
}
get name() { return TaskName.Dialogflow; }
async exec(cs, ep) {
await super.exec(cs);
try {
await this.init(cs, ep);
this.logger.debug(`starting dialogflow bot ${this.project}`);
// kick it off
if (this.welcomeEventParams) {
this.ep.api('dialogflow_start',
`${this.ep.uuid} ${this.project} ${this.lang} ${this.welcomeEvent} '${JSON.stringify(this.welcomeEventParams)}'`);
}
else if (this.welcomeEvent.length) {
this.ep.api('dialogflow_start',
`${this.ep.uuid} ${this.project} ${this.lang} ${this.welcomeEvent}`);
}
else {
this.ep.api('dialogflow_start', `${this.ep.uuid} ${this.project} ${this.lang}`);
}
this.logger.debug(`started dialogflow bot ${this.project}`);
await this.awaitTaskDone();
} catch (err) {
this.logger.error({err}, 'Dialogflow:exec error');
}
}
async kill(cs) {
super.kill(cs);
if (this.ep.connected) {
this.logger.debug('TaskDialogFlow:kill');
this.ep.removeCustomEventListener('dialogflow::intent');
this.ep.removeCustomEventListener('dialogflow::transcription');
this.ep.removeCustomEventListener('dialogflow::audio_provided');
this.ep.removeCustomEventListener('dialogflow::end_of_utterance');
this.ep.removeCustomEventListener('dialogflow::error');
this.performAction({dialogflowResult: 'caller hungup'})
.catch((err) => this.logger.error({err}, 'dialogflow - error w/ action webook'));
await this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
}
this.notifyTaskDone();
}
async init(cs, ep) {
this.ep = ep;
try {
this.ep.addCustomEventListener('dialogflow::intent', this._onIntent.bind(this, ep, cs));
this.ep.addCustomEventListener('dialogflow::transcription', this._onTranscription.bind(this, ep, cs));
this.ep.addCustomEventListener('dialogflow::audio_provided', this._onAudioProvided.bind(this, ep, cs));
this.ep.addCustomEventListener('dialogflow::end_of_utterance', this._onEndOfUtterance.bind(this, ep, cs));
this.ep.addCustomEventListener('dialogflow::error', this._onError.bind(this, ep, cs));
const obj = typeof this.credentials === 'string' ? JSON.parse(this.credentials) : this.credentials;
const creds = JSON.stringify(obj);
await this.ep.set('GOOGLE_APPLICATION_CREDENTIALS', creds);
} catch (err) {
this.logger.error({err}, 'Error setting credentials');
throw err;
}
}
/**
* An intent has been returned. Since we are using SINGLE_UTTERANCE on the dialogflow side,
* we may get an empty intent, signified by the lack of a 'response_id' attribute.
* In such a case, we just start another StreamingIntentDetectionRequest.
* @param {*} ep - media server endpoint
* @param {*} evt - event data
*/
_onIntent(ep, cs, evt) {
const intent = new Intent(this.logger, evt);
if (intent.isEmpty) {
/**
* An empty intent is returned in 3 conditions:
* 1. Our no-input timer fired
* 2. We collected dtmf that needs to be fed to dialogflow
* 3. A normal dialogflow timeout
*/
if (this.noinput && this.greetingPlayed) {
this.logger.info('no input timer fired, reprompting..');
this.noinput = false;
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang} ${this.noInputEvent}`);
}
else if (this.dtmfEntry && this.greetingPlayed) {
this.logger.info('dtmf detected, reprompting..');
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang} none \'${this.dtmfEntry}\'`);
this.dtmfEntry = null;
}
else if (this.greetingPlayed) {
this.logger.info('starting another intent');
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
}
else {
this.logger.info('got empty intent');
}
return;
}
if (this.events.includes('intent')) {
this._performHook(cs, this.eventHook, {event: 'intent', data: evt});
}
// clear the no-input timer and the digit buffer
this._clearNoinputTimer();
if (this.digitBuffer) this.digitBuffer.flush();
/* hang up (or tranfer call) after playing next audio file? */
if (intent.saysEndInteraction) {
// if 'end_interaction' is true, end the dialog after playing the final prompt
// (or in 1 second if there is no final prompt)
this.hangupAfterPlayDone = true;
this.waitingForPlayStart = true;
setTimeout(() => {
if (this.waitingForPlayStart) {
this.logger.info('hanging up since intent was marked end interaction');
this.performAction({dialogflowResult: 'completed'});
this.notifyTaskDone();
}
}, 1000);
}
/* collect digits? */
else if (intent.saysCollectDtmf || this.enableDtmfAlways) {
const opts = Object.assign({
idt: this.opts.interDigitTimeout
}, intent.dtmfInstructions || {term: '#'});
this.digitBuffer = new DigitBuffer(this.logger, opts);
this.digitBuffer.once('fulfilled', this._onDtmfEntryComplete.bind(this, ep));
}
}
/**
* A transcription - either interim or final - has been returned.
* If we are doing barge-in based on hotword detection, check for the hotword or phrase.
* If we are playing a filler sound, like typing, during the fullfillment phase, start that
* if this is a final transcript.
* @param {*} ep - media server endpoint
* @param {*} evt - event data
*/
_onTranscription(ep, cs, evt) {
const transcription = new Transcription(this.logger, evt);
if (this.events.includes('transcription') && transcription.isFinal) {
this._performHook(cs, this.eventHook, {event: 'transcription', data: evt});
}
else if (this.events.includes('interim-transcription') && !transcription.isFinal) {
this._performHook(cs, this.eventHook, {event: 'transcription', data: evt});
}
// if a final transcription, start a typing sound
if (this.thinkingSound > 0 && !transcription.isEmpty && transcription.isFinal &&
transcription.confidence > 0.8) {
ep.play(this.data.thinkingSound).catch((err) => this.logger.info(err, 'Error playing typing sound'));
}
}
/**
* The caller has just finished speaking. No action currently taken.
* @param {*} evt - event data
*/
_onEndOfUtterance(cs, evt) {
if (this.events.includes('end-utterance')) {
this._performHook(cs, this.eventHook, {event: 'end-utterance'});
}
}
/**
* Dialogflow has returned an error of some kind.
* @param {*} evt - event data
*/
_onError(ep, cs, evt) {
this.logger.error(`got error: ${JSON.stringify(evt)}`);
}
/**
* Audio has been received from dialogflow and written to a temporary disk file.
* Start playing the audio, after killing any filler sound that might be playing.
* When the audio completes, start the no-input timer.
* @param {*} ep - media server endpoint
* @param {*} evt - event data
*/
async _onAudioProvided(ep, cs, evt) {
this.waitingForPlayStart = false;
// kill filler audio
await ep.api('uuid_break', ep.uuid);
// start a new intent, (we want to continue to listen during the audio playback)
// _unless_ we are transferring or ending the session
if (/*this.greetingPlayed &&*/ !this.hangupAfterPlayDone) {
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
}
this.playInProgress = true;
this.curentAudioFile = evt.path;
this.logger.info(`starting to play ${evt.path}`);
if (this.events.includes('start-play')) {
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: evt.path}});
}
await ep.play(evt.path);
if (this.events.includes('stop-play')) {
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: evt.path}});
}
this.logger.info(`finished ${evt.path}`);
if (this.curentAudioFile === evt.path) {
this.playInProgress = false;
}
/*
if (!this.inbound && !this.greetingPlayed) {
this.logger.info('finished greeting on outbound call, starting new intent');
this.ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
}
*/
this.greetingPlayed = true;
if (this.hangupAfterPlayDone) {
this.logger.info('hanging up since intent was marked end interaction and we completed final prompt');
this.performAction({dialogflowResult: 'completed'});
this.notifyTaskDone();
}
else {
// every time we finish playing a prompt, start the no-input timer
this._startNoinputTimer(ep, cs);
}
}
/**
* receive a dmtf entry from the caller.
* If we have active dtmf instructions, collect and process accordingly.
*/
_onDtmf(ep, cs, evt) {
if (this.digitBuffer) this.digitBuffer.process(evt.dtmf);
if (this.events.includes('dtmf')) {
this._performHook(cs, this.eventHook, {event: 'dtmf', data: evt});
}
}
_onDtmfEntryComplete(ep, dtmfEntry) {
this.logger.info(`collected dtmf entry: ${dtmfEntry}`);
this.dtmfEntry = dtmfEntry;
this.digitBuffer = null;
// if a final transcription, start a typing sound
if (this.thinkingSound > 0) {
ep.play(this.thinkingSound).catch((err) => this.logger.info(err, 'Error playing typing sound'));
}
// kill the current dialogflow, which will result in us getting an immediate intent
ep.api('dialogflow_stop', `${ep.uuid}`)
.catch((err) => this.logger.info(`dialogflow_stop failed: ${err.message}`));
}
/**
* The user has not provided any input for some time.
* Set the 'noinput' member to true and kill the current dialogflow.
* This will result in us re-prompting with an event indicating no input.
* @param {*} ep
*/
_onNoInput(ep, cs) {
this.noinput = true;
if (this.events.includes('no-input')) {
this._performHook(cs, this.eventHook, {event: 'no-input'});
}
// kill the current dialogflow, which will result in us getting an immediate intent
ep.api('dialogflow_stop', `${ep.uuid}`)
.catch((err) => this.logger.info(`dialogflow_stop failed: ${err.message}`));
}
/**
* Stop the no-input timer, if it is running
*/
_clearNoinputTimer() {
if (this.noinputTimer) {
clearTimeout(this.noinputTimer);
this.noinputTimer = null;
}
}
/**
* Start the no-input timer. The duration is set in the configuration file.
* @param {*} ep
*/
_startNoinputTimer(ep, cs) {
if (!this.noInputTimeout) return;
this._clearNoinputTimer();
this.noinputTimer = setTimeout(this._onNoInput.bind(this, ep, cs), this.noInputTimeout);
}
async _performHook(cs, hook, results) {
const json = await this.cs.requestor.request(hook, results);
if (json && Array.isArray(json)) {
const makeTask = require('../make_task');
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
if (tasks && tasks.length > 0) {
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.performAction({dialogflowResult: 'redirect'}, false);
cs.replaceApplication(tasks);
}
}
}
}
module.exports = Dialogflow;

View File

@@ -0,0 +1,89 @@
class Intent {
constructor(logger, evt) {
this.logger = logger;
this.evt = evt;
this.logger.debug({evt}, 'intent');
this.dtmfRequest = checkIntentForDtmfEntry(logger, evt);
}
get isEmpty() {
return this.evt.response_id.length === 0;
}
get fulfillmentText() {
return this.evt.query_result.fulfillment_text;
}
get saysEndInteraction() {
return this.evt.query_result.intent.end_interaction ;
}
get saysCollectDtmf() {
return !!this.dtmfRequest;
}
get dtmfInstructions() {
return this.dtmfRequest;
}
get name() {
if (!this.isEmpty) return this.evt.query_result.intent.display_name;
}
toJSON() {
return {
name: this.name,
fulfillmentText: this.fulfillmentText
};
}
}
module.exports = Intent;
/**
* Parse a returned intent for DTMF entry information
* i.e.
* allow-dtmf-x-y-z
* x = min number of digits
* y = optional, max number of digits
* z = optional, terminating character
* e.g.
* allow-dtmf-5 : collect 5 digits
* allow-dtmf-1-4 : collect between 1 to 4 (inclusive) digits
* allow-dtmf-1-4-# : collect 1-4 digits, terminating if '#' is entered
* @param {*} intent - dialogflow intent
*/
const checkIntentForDtmfEntry = (logger, intent) => {
const qr = intent.query_result;
if (!qr || !qr.fulfillment_messages || !qr.output_contexts) {
logger.info({f: qr.fulfillment_messages, o: qr.output_contexts}, 'no dtmfs');
return;
}
// check for custom payloads with a gather verb
const custom = qr.fulfillment_messages.find((f) => f.payload && f.payload.verb === 'gather');
if (custom && custom.payload && custom.payload.verb === 'gather') {
logger.info({custom}, 'found dtmf custom payload');
return {
max: custom.payload.numDigits,
term: custom.payload.finishOnKey,
template: custom.payload.responseTemplate
};
}
// check for an output context with a specific naming convention
const context = qr.output_contexts.find((oc) => oc.name.includes('/contexts/allow-dtmf-'));
if (context) {
const arr = /allow-dtmf-(\d+)(?:-(\d+))?(?:-(.*))?/.exec(context.name);
if (arr) {
logger.info({custom}, 'found dtmf output context');
return {
min: parseInt(arr[1]),
max: arr.length > 2 ? parseInt(arr[2]) : null,
term: arr.length > 3 ? arr[3] : null
};
}
}
};

View File

@@ -0,0 +1,41 @@
class Transcription {
constructor(logger, evt) {
this.logger = logger;
this.recognition_result = evt.recognition_result;
}
get isEmpty() {
return !this.recognition_result;
}
get isFinal() {
return this.recognition_result && this.recognition_result.is_final === true;
}
get confidence() {
if (!this.isEmpty) return this.recognition_result.confidence;
}
get text() {
if (!this.isEmpty) return this.recognition_result.transcript;
}
startsWith(str) {
return (this.text.toLowerCase() || '').startsWith(str.toLowerCase());
}
includes(str) {
return (this.text.toLowerCase() || '').includes(str.toLowerCase());
}
toJSON() {
return {
final: this.recognition_result.is_final === true,
text: this.text,
confidence: this.confidence
};
}
}
module.exports = Transcription;

View File

@@ -150,8 +150,14 @@ class TaskGather extends Task {
}
async _resolve(reason, evt) {
if (this.resolved) return;
this.resolved = true;
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
if (this.ep && this.ep.connected) {
this.ep.stopTranscription().catch((err) => this.logger.error({err}, 'Error stopping transcription'));
}
this._clearTimer();
if (reason.startsWith('dtmf')) {
await this.performAction({digits: this.digitBuffer});

View File

@@ -24,6 +24,9 @@ function makeTask(logger, obj, parent) {
case TaskName.Dial:
const TaskDial = require('./dial');
return new TaskDial(logger, data, parent);
case TaskName.Dialogflow:
const TaskDialogflow = require('./dialogflow');
return new TaskDialogflow(logger, data, parent);
case TaskName.Dequeue:
const TaskDequeue = require('./dequeue');
return new TaskDequeue(logger, data, parent);

View File

@@ -20,24 +20,27 @@ class TaskSay extends Task {
await super.exec(cs);
this.ep = ep;
try {
const filepath = [];
while (!this.killed && this.loop--) {
// synthesize all of the text elements
const filepath = (await Promise.all(this.text.map(async(text) => {
const fp = await synthAudio({
text,
vendor: this.synthesizer.vendor || cs.speechSynthesisVendor,
language: this.synthesizer.language || cs.speechSynthesisLanguage,
voice: this.synthesizer.voice || cs.speechSynthesisVoice,
salt: cs.callSid
}).catch((err) => this.logger.error(err, 'Error synthesizing text'));
if (fp) cs.trackTmpFile(fp);
return fp;
})))
.filter((fp) => fp && fp.length);
this.logger.debug({filepath}, 'synthesized files for tts');
while (!this.killed && this.loop-- && this.ep.connected) {
let segment = 0;
do {
if (filepath.length <= segment) {
const opts = Object.assign({
text: this.text[segment],
vendor: cs.speechSynthesisVendor,
language: cs.speechSynthesisLanguage,
voice: cs.speechSynthesisVoice,
salt: cs.callSid
}, this.synthesizer);
const path = await synthAudio(opts);
filepath.push(path);
cs.trackTmpFile(path);
}
await ep.play(filepath[segment]);
} while (++segment < this.text.length);
} while (!this.killed && ++segment < filepath.length);
}
} catch (err) {
this.logger.info(err, 'TaskSay:exec error');

View File

@@ -1,5 +1,5 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const {TaskName, TaskPreconditions, CallStatus} = require('../utils/constants');
/**
* Rejects an incoming call with user-specified status code and reason
@@ -19,6 +19,7 @@ class TaskSipDecline extends Task {
res.send(this.data.status, this.data.reason, {
headers: this.headers
});
cs.emit('callStatusChange', {callStatus: CallStatus.Failed, sipStatus: this.data.status});
}
}

View File

@@ -112,12 +112,34 @@
"target": ["#target"],
"timeLimit": "number",
"timeout": "number",
"proxy": "string",
"transcribe": "#transcribe"
},
"required": [
"target"
]
},
"dialogflow": {
"properties": {
"credentials": "object|string",
"project": "string",
"lang": "string",
"actionHook": "object|string",
"eventHook": "object|string",
"events": "[string]",
"welcomeEvent": "string",
"welcomeEventParams": "object",
"noInputTimeout": "number",
"noInputEvent": "string",
"passDtmfAsTextInput": "boolean",
"thinkingMusic": "string"
},
"required": [
"project",
"credentials",
"lang"
]
},
"listen": {
"properties": {
"actionHook": "object|string",
@@ -167,6 +189,7 @@
"from": "string",
"speech_synthesis_vendor": "string",
"speech_synthesis_voice": "string",
"speech_synthesis_language": "string",
"speech_recognizer_vendor": "string",
"speech_recognizer_language": "string",
"tag": "object",
@@ -204,7 +227,7 @@
"type": "string",
"enum": ["phone", "sip", "user", "teams"]
},
"url": "string",
"confirmHook": "object|string",
"method": {
"type": "string",
"enum": ["GET", "POST"]
@@ -213,7 +236,9 @@
"number": "string",
"sipUri": "string",
"auth": "#auth",
"vmail": "boolean"
"vmail": "boolean",
"tenant": "string",
"overrideTo": "string"
},
"required": [
"type"

View File

@@ -3,6 +3,7 @@
"Conference": "conference",
"Dequeue": "dequeue",
"Dial": "dial",
"Dialogflow": "dialogflow",
"Enqueue": "enqueue",
"Gather": "gather",
"Hangup": "hangup",

View File

@@ -26,7 +26,7 @@ function installSrfLocals(srf, logger) {
logger.debug('installing srf locals');
assert(!srf.locals.dbHelpers);
const {getSBC, lifecycleEmitter} = require('./sbc-pinger')(logger);
const StatsCollector = require('jambonz-stats-collector');
const StatsCollector = require('@jambonz/stats-collector');
const stats = srf.locals.stats = new StatsCollector(logger);
// freeswitch connections (typically we connect to only one)
@@ -130,7 +130,7 @@ function installSrfLocals(srf, logger) {
removeFromList,
lengthOfList,
getListPosition
} = require('jambonz-realtimedb-helpers')({
} = require('@jambonz/realtimedb-helpers')({
host: process.env.JAMBONES_REDIS_HOST,
port: process.env.JAMBONES_REDIS_PORT || 6379
}, logger);

View File

@@ -11,7 +11,7 @@ const registrar = new Registrar({
host: process.env.JAMBONES_REDIS_HOST,
port: process.env.JAMBONES_REDIS_PORT || 6379
});
const deepcopy = require('deepcopy');
const moment = require('moment');
const uuidv4 = require('uuid/v4');
@@ -59,6 +59,7 @@ class SingleDialer extends Emitter {
async exec(srf, ms, opts) {
opts = opts || {};
opts.headers = opts.headers || {};
let uri, to;
try {
switch (this.target.type) {
@@ -69,7 +70,6 @@ class SingleDialer extends Emitter {
to = this.target.number;
if ('teams' === this.target.type) {
assert(this.target.teamsInfo);
opts.headers = opts.headers || {};
Object.assign(opts.headers, {
'X-MS-Teams-FQDN': this.target.teamsInfo.ms_teams_fqdn,
'X-MS-Teams-Tenant-FQDN': this.target.teamsInfo.tenant_fqdn
@@ -83,6 +83,12 @@ class SingleDialer extends Emitter {
uri = `sip:${this.target.name}`;
to = this.target.name;
if (this.target.overrideTo) {
Object.assign(opts.headers, {
'X-Override-To': this.target.overrideTo
});
}
// need to send to the SBC registered on
const reg = await registrar.query(aor);
if (reg) {
@@ -108,13 +114,22 @@ class SingleDialer extends Emitter {
this.ep = await ms.createEndpoint();
this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`);
let sdp;
/**
* were we killed whilst we were off getting an endpoint ?
* https://github.com/jambonz/jambonz-feature-server/issues/30
*/
if (this.killed) {
this.logger.info('SingleDialer:exec got quick CANCEL from caller, abort outdial');
this.ep.destroy()
.catch((err) => this.logger.error({err}, 'Error destroying endpoint'));
return;
}
let lastSdp;
const connectStream = async(remoteSdp) => {
if (remoteSdp !== sdp) {
this.ep.modify(sdp = remoteSdp);
return true;
}
return false;
if (remoteSdp === lastSdp) return;
lastSdp = remoteSdp;
return this.ep.modify(remoteSdp);
};
Object.assign(opts, {
@@ -153,16 +168,19 @@ class SingleDialer extends Emitter {
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');
if (status.callStatus !== CallStatus.EarlyMedia) {
status.callStatus = CallStatus.EarlyMedia;
this.emit('earlyMedia');
}
connectStream(prov.body);
}
else status.callStatus = CallStatus.Ringing;
this.emit('callStatusChange', status);
}
});
connectStream(this.dlg.remote.sdp);
this.dlg.callSid = this.callSid;
this.inviteInProgress = null;
this.dlg.callSid = this.callSid;
await connectStream(this.dlg.remote.sdp);
this.emit('callStatusChange', {sipStatus: 200, callStatus: CallStatus.InProgress});
this.logger.debug(`SingleDialer:exec call connected: ${this.callSid}`);
const connectTime = this.dlg.connectTime = moment();
@@ -208,6 +226,7 @@ class SingleDialer extends Emitter {
* kill the call in progress or the stable dialog, whichever we have
*/
async kill() {
this.killed = true;
if (this.inviteInProgress) await this.inviteInProgress.cancel();
else if (this.dlg && this.dlg.connected) {
const duration = moment().diff(this.dlg.connectTime, 'seconds');
@@ -288,8 +307,9 @@ class SingleDialer extends Emitter {
}
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);
const myOpts = deepcopy(opts);
const sd = new SingleDialer({logger, sbcAddress, target, myOpts, application, callInfo});
sd.exec(srf, ms, myOpts);
return sd;
}

2017
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "jambonz-feature-server",
"version": "0.2.1",
"version": "0.2.5",
"main": "app.js",
"engines": {
"node": ">= 10.16.0"
@@ -26,29 +26,32 @@
"jslint": "eslint app.js lib"
},
"dependencies": {
"bent": "^7.0.6",
"@jambonz/db-helpers": "^0.4.2",
"@jambonz/realtimedb-helpers": "^0.2.16",
"@jambonz/stats-collector": "^0.0.4",
"aws-sdk": "^2.830.0",
"bent": "^7.3.9",
"cidr-matcher": "^2.1.1",
"debug": "^4.1.1",
"deepcopy": "^2.1.0",
"drachtio-fsmrf": "^2.0.1",
"drachtio-srf": "^4.4.28",
"drachtio-srf": "^4.4.39",
"express": "^4.17.1",
"ip": "^1.1.5",
"@jambonz/db-helpers": "^0.3.7",
"jambonz-mw-registrar": "^0.1.3",
"jambonz-realtimedb-helpers": "^0.2.13",
"jambonz-stats-collector": "^0.0.3",
"moment": "^2.24.0",
"parse-url": "^5.0.1",
"pino": "^5.16.0",
"verify-aws-sns-signature": "0.0.5",
"moment": "^2.27.0",
"parse-url": "^5.0.2",
"pino": "^6.5.1",
"uuid": "^3.4.0",
"verify-aws-sns-signature": "^0.0.6",
"xml2js": "^0.4.23"
},
"devDependencies": {
"blue-tape": "^1.0.0",
"clear-module": "^4.1.1",
"eslint": "^6.8.0",
"eslint": "^7.7.0",
"eslint-plugin-promise": "^4.2.1",
"nyc": "^15.0.1",
"nyc": "^15.1.0",
"tap-spec": "^5.0.0"
}
}

View File

@@ -1,4 +1,4 @@
const test = require('tape').test ;
const test = require('blue-tape') ;
const exec = require('child_process').exec ;
const pwd = process.env.TRAVIS ? '' : '-p$MYSQL_ROOT_PASSWORD';

View File

@@ -1,272 +1,257 @@
/* SQLEditor (MySQL (2))*/
SET FOREIGN_KEY_CHECKS=0;
DROP TABLE IF EXISTS `call_routes`;
DROP TABLE IF EXISTS call_routes;
DROP TABLE IF EXISTS `conference_participants`;
DROP TABLE IF EXISTS lcr_carrier_set_entry;
DROP TABLE IF EXISTS `queue_members`;
DROP TABLE IF EXISTS lcr_routes;
DROP TABLE IF EXISTS `calls`;
DROP TABLE IF EXISTS api_keys;
DROP TABLE IF EXISTS `phone_numbers`;
DROP TABLE IF EXISTS ms_teams_tenants;
DROP TABLE IF EXISTS `applications`;
DROP TABLE IF EXISTS sbc_addresses;
DROP TABLE IF EXISTS `conferences`;
DROP TABLE IF EXISTS users;
DROP TABLE IF EXISTS `queues`;
DROP TABLE IF EXISTS phone_numbers;
DROP TABLE IF EXISTS `subscriptions`;
DROP TABLE IF EXISTS sip_gateways;
DROP TABLE IF EXISTS `registrations`;
DROP TABLE IF EXISTS voip_carriers;
DROP TABLE IF EXISTS `api_keys`;
DROP TABLE IF EXISTS accounts;
DROP TABLE IF EXISTS `accounts`;
DROP TABLE IF EXISTS applications;
DROP TABLE IF EXISTS `service_providers`;
DROP TABLE IF EXISTS service_providers;
DROP TABLE IF EXISTS `sip_gateways`;
DROP TABLE IF EXISTS webhooks;
DROP TABLE IF EXISTS `voip_carriers`;
CREATE TABLE IF NOT EXISTS `applications`
CREATE TABLE call_routes
(
`application_sid` CHAR(36) NOT NULL UNIQUE ,
`name` VARCHAR(255) NOT NULL,
`account_sid` CHAR(36) NOT NULL,
`call_hook` VARCHAR(255) NOT NULL,
`call_status_hook` VARCHAR(255) NOT NULL,
PRIMARY KEY (`application_sid`)
) ENGINE=InnoDB COMMENT='A defined set of behaviors to be applied to phone calls with';
call_route_sid CHAR(36) NOT NULL UNIQUE ,
priority INTEGER NOT NULL,
account_sid CHAR(36) NOT NULL,
regex VARCHAR(255) NOT NULL,
application_sid CHAR(36) NOT NULL,
PRIMARY KEY (call_route_sid)
) ENGINE=InnoDB COMMENT='a regex-based pattern match for call routing';
CREATE TABLE IF NOT EXISTS `call_routes`
CREATE TABLE lcr_routes
(
`call_route_sid` CHAR(36) NOT NULL UNIQUE ,
`order` INTEGER NOT NULL,
`account_sid` CHAR(36) NOT NULL,
`regex` VARCHAR(255) NOT NULL,
`application_sid` CHAR(36) NOT NULL,
PRIMARY KEY (`call_route_sid`)
) ENGINE=InnoDB;
lcr_route_sid CHAR(36),
regex VARCHAR(32) NOT NULL COMMENT 'regex-based pattern match against dialed number, used for LCR routing of PSTN calls',
description VARCHAR(1024),
priority INTEGER NOT NULL UNIQUE COMMENT 'lower priority routes are attempted first',
PRIMARY KEY (lcr_route_sid)
) COMMENT='Least cost routing table';
CREATE TABLE IF NOT EXISTS `conferences`
CREATE TABLE api_keys
(
`id` INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT UNIQUE ,
`conference_sid` CHAR(36) NOT NULL UNIQUE ,
`name` VARCHAR(255),
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='An audio conference';
CREATE TABLE IF NOT EXISTS `conference_participants`
(
`conference_participant_sid` CHAR(36) NOT NULL UNIQUE ,
`call_sid` CHAR(36),
`conference_sid` CHAR(36) NOT NULL,
PRIMARY KEY (`conference_participant_sid`)
) ENGINE=InnoDB COMMENT='A relationship between a call and a conference that it is co';
CREATE TABLE IF NOT EXISTS `queues`
(
`id` INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT UNIQUE ,
`queue_sid` CHAR(36) NOT NULL UNIQUE ,
`name` VARCHAR(255),
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='A set of behaviors to be applied to parked calls';
CREATE TABLE IF NOT EXISTS `registrations`
(
`registration_sid` CHAR(36) NOT NULL UNIQUE ,
`username` VARCHAR(255) NOT NULL,
`domain` VARCHAR(255) NOT NULL,
`sip_contact` VARCHAR(255) NOT NULL,
`sip_user_agent` VARCHAR(255),
PRIMARY KEY (`registration_sid`)
) ENGINE=InnoDB COMMENT='An active sip registration';
CREATE TABLE IF NOT EXISTS `queue_members`
(
`queue_member_sid` CHAR(36) NOT NULL UNIQUE ,
`call_sid` CHAR(36),
`queue_sid` CHAR(36) NOT NULL,
`position` INTEGER,
PRIMARY KEY (`queue_member_sid`)
) ENGINE=InnoDB COMMENT='A relationship between a call and a queue that it is waiting';
CREATE TABLE IF NOT EXISTS `calls`
(
`call_sid` CHAR(36) NOT NULL UNIQUE ,
`parent_call_sid` CHAR(36),
`application_sid` CHAR(36),
`status_url` VARCHAR(255),
`time_start` DATETIME NOT NULL,
`time_alerting` DATETIME,
`time_answered` DATETIME,
`time_ended` DATETIME,
`direction` ENUM('inbound','outbound'),
`phone_number_sid` CHAR(36),
`inbound_user_sid` CHAR(36),
`outbound_user_sid` CHAR(36),
`calling_number` VARCHAR(255),
`called_number` VARCHAR(255),
`caller_name` VARCHAR(255),
`status` VARCHAR(255) NOT NULL COMMENT 'Possible values are queued, ringing, in-progress, completed, failed, busy and no-answer',
`sip_uri` VARCHAR(255) NOT NULL,
`sip_call_id` VARCHAR(255) NOT NULL,
`sip_cseq` INTEGER NOT NULL,
`sip_from_tag` VARCHAR(255) NOT NULL,
`sip_via_branch` VARCHAR(255) NOT NULL,
`sip_contact` VARCHAR(255),
`sip_final_status` INTEGER UNSIGNED,
`sdp_offer` VARCHAR(4096),
`sdp_answer` VARCHAR(4096),
`source_address` VARCHAR(255) NOT NULL,
`source_port` INTEGER UNSIGNED NOT NULL,
`dest_address` VARCHAR(255),
`dest_port` INTEGER UNSIGNED,
`url` VARCHAR(255),
PRIMARY KEY (`call_sid`)
) ENGINE=InnoDB COMMENT='A phone call';
CREATE TABLE IF NOT EXISTS `service_providers`
(
`service_provider_sid` CHAR(36) NOT NULL UNIQUE ,
`name` VARCHAR(255) NOT NULL UNIQUE ,
`description` VARCHAR(255),
`root_domain` VARCHAR(255) UNIQUE ,
`registration_hook` VARCHAR(255),
`hook_basic_auth_user` VARCHAR(255),
`hook_basic_auth_password` VARCHAR(255),
PRIMARY KEY (`service_provider_sid`)
) ENGINE=InnoDB COMMENT='An organization that provides communication services to its ';
CREATE TABLE IF NOT EXISTS `api_keys`
(
`api_key_sid` CHAR(36) NOT NULL UNIQUE ,
`token` CHAR(36) NOT NULL UNIQUE ,
`account_sid` CHAR(36),
`service_provider_sid` CHAR(36),
PRIMARY KEY (`api_key_sid`)
api_key_sid CHAR(36) NOT NULL UNIQUE ,
token CHAR(36) NOT NULL UNIQUE ,
account_sid CHAR(36),
service_provider_sid CHAR(36),
expires_at TIMESTAMP,
PRIMARY KEY (api_key_sid)
) ENGINE=InnoDB COMMENT='An authorization token that is used to access the REST api';
CREATE TABLE IF NOT EXISTS `accounts`
CREATE TABLE ms_teams_tenants
(
`account_sid` CHAR(36) NOT NULL UNIQUE ,
`name` VARCHAR(255) NOT NULL,
`sip_realm` VARCHAR(255) UNIQUE ,
`service_provider_sid` CHAR(36) NOT NULL,
`registration_hook` VARCHAR(255),
`hook_basic_auth_user` VARCHAR(255),
`hook_basic_auth_password` VARCHAR(255),
`is_active` BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (`account_sid`)
) ENGINE=InnoDB COMMENT='A single end-user of the platform';
ms_teams_tenant_sid CHAR(36) NOT NULL UNIQUE ,
service_provider_sid CHAR(36) NOT NULL,
account_sid CHAR(36) NOT NULL,
application_sid CHAR(36),
tenant_fqdn VARCHAR(255) NOT NULL UNIQUE ,
PRIMARY KEY (ms_teams_tenant_sid)
) COMMENT='A Microsoft Teams customer tenant';
CREATE TABLE IF NOT EXISTS `subscriptions`
CREATE TABLE sbc_addresses
(
`id` INTEGER(10) UNSIGNED NOT NULL AUTO_INCREMENT UNIQUE ,
`subscription_sid` CHAR(36) NOT NULL UNIQUE ,
`registration_sid` CHAR(36) NOT NULL,
`event` VARCHAR(255),
PRIMARY KEY (`id`)
) ENGINE=InnoDB COMMENT='An active sip subscription';
CREATE TABLE IF NOT EXISTS `voip_carriers`
(
`voip_carrier_sid` CHAR(36) NOT NULL UNIQUE ,
`name` VARCHAR(255) NOT NULL UNIQUE ,
`description` VARCHAR(255),
PRIMARY KEY (`voip_carrier_sid`)
) ENGINE=InnoDB COMMENT='An external organization that can provide sip trunking and D';
CREATE TABLE IF NOT EXISTS `phone_numbers`
(
`phone_number_sid` CHAR(36) UNIQUE ,
`number` VARCHAR(255) NOT NULL UNIQUE ,
`voip_carrier_sid` CHAR(36) NOT NULL,
`account_sid` CHAR(36),
`application_sid` CHAR(36),
PRIMARY KEY (`phone_number_sid`)
) ENGINE=InnoDB COMMENT='A phone number that has been assigned to an account';
CREATE TABLE IF NOT EXISTS `sip_gateways`
(
`sip_gateway_sid` CHAR(36),
`ipv4` VARCHAR(32) NOT NULL,
`port` INTEGER NOT NULL DEFAULT 5060,
`inbound` BOOLEAN NOT NULL,
`outbound` BOOLEAN NOT NULL,
`voip_carrier_sid` CHAR(36) NOT NULL,
`is_active` BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (`sip_gateway_sid`)
sbc_address_sid CHAR(36) NOT NULL UNIQUE ,
ipv4 VARCHAR(255) NOT NULL,
port INTEGER NOT NULL DEFAULT 5060,
service_provider_sid CHAR(36),
PRIMARY KEY (sbc_address_sid)
);
CREATE UNIQUE INDEX `applications_idx_name` ON `applications` (`account_sid`,`name`);
CREATE TABLE users
(
user_sid CHAR(36) NOT NULL UNIQUE ,
name CHAR(36) NOT NULL UNIQUE ,
hashed_password VARCHAR(1024) NOT NULL,
salt CHAR(16) NOT NULL,
force_change BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (user_sid)
);
CREATE INDEX `applications_application_sid_idx` ON `applications` (`application_sid`);
CREATE INDEX `applications_name_idx` ON `applications` (`name`);
CREATE INDEX `applications_account_sid_idx` ON `applications` (`account_sid`);
ALTER TABLE `applications` ADD FOREIGN KEY account_sid_idxfk (`account_sid`) REFERENCES `accounts` (`account_sid`);
CREATE TABLE voip_carriers
(
voip_carrier_sid CHAR(36) NOT NULL UNIQUE ,
name VARCHAR(64) NOT NULL UNIQUE ,
description VARCHAR(255),
account_sid CHAR(36) COMMENT 'if provided, indicates this entity represents a customer PBX that is associated with a specific account',
application_sid CHAR(36) COMMENT 'If provided, all incoming calls from this source will be routed to the associated application',
e164_leading_plus BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (voip_carrier_sid)
) ENGINE=InnoDB COMMENT='A Carrier or customer PBX that can send or receive calls';
CREATE INDEX `call_routes_call_route_sid_idx` ON `call_routes` (`call_route_sid`);
ALTER TABLE `call_routes` ADD FOREIGN KEY account_sid_idxfk_1 (`account_sid`) REFERENCES `accounts` (`account_sid`);
CREATE TABLE phone_numbers
(
phone_number_sid CHAR(36) UNIQUE ,
number VARCHAR(32) NOT NULL UNIQUE ,
voip_carrier_sid CHAR(36) NOT NULL,
account_sid CHAR(36),
application_sid CHAR(36),
PRIMARY KEY (phone_number_sid)
) ENGINE=InnoDB COMMENT='A phone number that has been assigned to an account';
ALTER TABLE `call_routes` ADD FOREIGN KEY application_sid_idxfk (`application_sid`) REFERENCES `applications` (`application_sid`);
CREATE TABLE webhooks
(
webhook_sid CHAR(36) NOT NULL UNIQUE ,
url VARCHAR(1024) NOT NULL,
method ENUM("GET","POST") NOT NULL DEFAULT 'POST',
username VARCHAR(255),
password VARCHAR(255),
PRIMARY KEY (webhook_sid)
) COMMENT='An HTTP callback';
CREATE INDEX `conferences_conference_sid_idx` ON `conferences` (`conference_sid`);
CREATE INDEX `conference_participants_conference_participant_sid_idx` ON `conference_participants` (`conference_participant_sid`);
ALTER TABLE `conference_participants` ADD FOREIGN KEY call_sid_idxfk (`call_sid`) REFERENCES `calls` (`call_sid`);
CREATE TABLE sip_gateways
(
sip_gateway_sid CHAR(36),
ipv4 VARCHAR(128) NOT NULL COMMENT 'ip address or DNS name of the gateway. For gateways providing inbound calling service, ip address is required.',
port INTEGER NOT NULL DEFAULT 5060 COMMENT 'sip signaling port',
inbound BOOLEAN NOT NULL COMMENT 'if true, whitelist this IP to allow inbound calls from the gateway',
outbound BOOLEAN NOT NULL COMMENT 'if true, include in least-cost routing when placing calls to the PSTN',
voip_carrier_sid CHAR(36) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT 1,
PRIMARY KEY (sip_gateway_sid)
) COMMENT='A whitelisted sip gateway used for origination/termination';
ALTER TABLE `conference_participants` ADD FOREIGN KEY conference_sid_idxfk (`conference_sid`) REFERENCES `conferences` (`conference_sid`);
CREATE TABLE lcr_carrier_set_entry
(
lcr_carrier_set_entry_sid CHAR(36),
workload INTEGER NOT NULL DEFAULT 1 COMMENT 'represents a proportion of traffic to send through the associated carrier; can be used for load balancing traffic across carriers with a common priority for a destination',
lcr_route_sid CHAR(36) NOT NULL,
voip_carrier_sid CHAR(36) NOT NULL,
priority INTEGER NOT NULL DEFAULT 0 COMMENT 'lower priority carriers are attempted first',
PRIMARY KEY (lcr_carrier_set_entry_sid)
) COMMENT='An entry in the LCR routing list';
CREATE INDEX `queues_queue_sid_idx` ON `queues` (`queue_sid`);
CREATE INDEX `registrations_registration_sid_idx` ON `registrations` (`registration_sid`);
CREATE INDEX `queue_members_queue_member_sid_idx` ON `queue_members` (`queue_member_sid`);
ALTER TABLE `queue_members` ADD FOREIGN KEY call_sid_idxfk_1 (`call_sid`) REFERENCES `calls` (`call_sid`);
CREATE TABLE applications
(
application_sid CHAR(36) NOT NULL UNIQUE ,
name VARCHAR(64) NOT NULL,
account_sid CHAR(36) NOT NULL COMMENT 'account that this application belongs to',
call_hook_sid CHAR(36) COMMENT 'webhook to call for inbound calls to phone numbers owned by this account',
call_status_hook_sid CHAR(36) COMMENT 'webhook to call for call status events',
speech_synthesis_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
speech_synthesis_language VARCHAR(12) NOT NULL DEFAULT 'en-US',
speech_synthesis_voice VARCHAR(64),
speech_recognizer_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
speech_recognizer_language VARCHAR(64) NOT NULL DEFAULT 'en-US',
PRIMARY KEY (application_sid)
) ENGINE=InnoDB COMMENT='A defined set of behaviors to be applied to phone calls ';
ALTER TABLE `queue_members` ADD FOREIGN KEY queue_sid_idxfk (`queue_sid`) REFERENCES `queues` (`queue_sid`);
CREATE TABLE service_providers
(
service_provider_sid CHAR(36) NOT NULL UNIQUE ,
name VARCHAR(64) NOT NULL UNIQUE ,
description VARCHAR(255),
root_domain VARCHAR(128) UNIQUE ,
registration_hook_sid CHAR(36),
ms_teams_fqdn VARCHAR(255),
PRIMARY KEY (service_provider_sid)
) ENGINE=InnoDB COMMENT='A partition of the platform used by one service provider';
CREATE INDEX `calls_call_sid_idx` ON `calls` (`call_sid`);
ALTER TABLE `calls` ADD FOREIGN KEY parent_call_sid_idxfk (`parent_call_sid`) REFERENCES `calls` (`call_sid`);
CREATE TABLE accounts
(
account_sid CHAR(36) NOT NULL UNIQUE ,
name VARCHAR(64) NOT NULL,
sip_realm VARCHAR(132) UNIQUE COMMENT 'sip domain that will be used for devices registering under this account',
service_provider_sid CHAR(36) NOT NULL COMMENT 'service provider that owns the customer relationship with this account',
registration_hook_sid CHAR(36) COMMENT 'webhook to call when devices underr this account attempt to register',
device_calling_application_sid CHAR(36) COMMENT 'application to use for outbound calling from an account',
is_active BOOLEAN NOT NULL DEFAULT true,
PRIMARY KEY (account_sid)
) ENGINE=InnoDB COMMENT='An enterprise that uses the platform for comm services';
ALTER TABLE `calls` ADD FOREIGN KEY application_sid_idxfk_1 (`application_sid`) REFERENCES `applications` (`application_sid`);
CREATE INDEX call_route_sid_idx ON call_routes (call_route_sid);
ALTER TABLE call_routes ADD FOREIGN KEY account_sid_idxfk (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX `calls_phone_number_sid_idx` ON `calls` (`phone_number_sid`);
ALTER TABLE `calls` ADD FOREIGN KEY phone_number_sid_idxfk (`phone_number_sid`) REFERENCES `phone_numbers` (`phone_number_sid`);
ALTER TABLE call_routes ADD FOREIGN KEY application_sid_idxfk (application_sid) REFERENCES applications (application_sid);
ALTER TABLE `calls` ADD FOREIGN KEY inbound_user_sid_idxfk (`inbound_user_sid`) REFERENCES `registrations` (`registration_sid`);
CREATE INDEX api_key_sid_idx ON api_keys (api_key_sid);
CREATE INDEX account_sid_idx ON api_keys (account_sid);
ALTER TABLE api_keys ADD FOREIGN KEY account_sid_idxfk_1 (account_sid) REFERENCES accounts (account_sid);
ALTER TABLE `calls` ADD FOREIGN KEY outbound_user_sid_idxfk (`outbound_user_sid`) REFERENCES `registrations` (`registration_sid`);
CREATE INDEX service_provider_sid_idx ON api_keys (service_provider_sid);
ALTER TABLE api_keys ADD FOREIGN KEY service_provider_sid_idxfk (service_provider_sid) REFERENCES service_providers (service_provider_sid);
CREATE INDEX `service_providers_service_provider_sid_idx` ON `service_providers` (`service_provider_sid`);
CREATE INDEX `service_providers_name_idx` ON `service_providers` (`name`);
CREATE INDEX `service_providers_root_domain_idx` ON `service_providers` (`root_domain`);
CREATE INDEX `api_keys_api_key_sid_idx` ON `api_keys` (`api_key_sid`);
CREATE INDEX `api_keys_account_sid_idx` ON `api_keys` (`account_sid`);
ALTER TABLE `api_keys` ADD FOREIGN KEY account_sid_idxfk_2 (`account_sid`) REFERENCES `accounts` (`account_sid`);
CREATE INDEX ms_teams_tenant_sid_idx ON ms_teams_tenants (ms_teams_tenant_sid);
ALTER TABLE ms_teams_tenants ADD FOREIGN KEY service_provider_sid_idxfk_1 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
CREATE INDEX `api_keys_service_provider_sid_idx` ON `api_keys` (`service_provider_sid`);
ALTER TABLE `api_keys` ADD FOREIGN KEY service_provider_sid_idxfk (`service_provider_sid`) REFERENCES `service_providers` (`service_provider_sid`);
ALTER TABLE ms_teams_tenants ADD FOREIGN KEY account_sid_idxfk_2 (account_sid) REFERENCES accounts (account_sid);
CREATE INDEX `accounts_account_sid_idx` ON `accounts` (`account_sid`);
CREATE INDEX `accounts_name_idx` ON `accounts` (`name`);
CREATE INDEX `accounts_sip_realm_idx` ON `accounts` (`sip_realm`);
CREATE INDEX `accounts_service_provider_sid_idx` ON `accounts` (`service_provider_sid`);
ALTER TABLE `accounts` ADD FOREIGN KEY service_provider_sid_idxfk_1 (`service_provider_sid`) REFERENCES `service_providers` (`service_provider_sid`);
ALTER TABLE ms_teams_tenants ADD FOREIGN KEY application_sid_idxfk_1 (application_sid) REFERENCES applications (application_sid);
ALTER TABLE `subscriptions` ADD FOREIGN KEY registration_sid_idxfk (`registration_sid`) REFERENCES `registrations` (`registration_sid`);
CREATE INDEX tenant_fqdn_idx ON ms_teams_tenants (tenant_fqdn);
CREATE INDEX sbc_addresses_idx_host_port ON sbc_addresses (ipv4,port);
CREATE INDEX `voip_carriers_voip_carrier_sid_idx` ON `voip_carriers` (`voip_carrier_sid`);
CREATE INDEX `voip_carriers_name_idx` ON `voip_carriers` (`name`);
CREATE INDEX `phone_numbers_phone_number_sid_idx` ON `phone_numbers` (`phone_number_sid`);
CREATE INDEX `phone_numbers_voip_carrier_sid_idx` ON `phone_numbers` (`voip_carrier_sid`);
ALTER TABLE `phone_numbers` ADD FOREIGN KEY voip_carrier_sid_idxfk (`voip_carrier_sid`) REFERENCES `voip_carriers` (`voip_carrier_sid`);
CREATE INDEX sbc_address_sid_idx ON sbc_addresses (sbc_address_sid);
CREATE INDEX service_provider_sid_idx ON sbc_addresses (service_provider_sid);
ALTER TABLE sbc_addresses ADD FOREIGN KEY service_provider_sid_idxfk_2 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
ALTER TABLE `phone_numbers` ADD FOREIGN KEY account_sid_idxfk_3 (`account_sid`) REFERENCES `accounts` (`account_sid`);
CREATE INDEX user_sid_idx ON users (user_sid);
CREATE INDEX name_idx ON users (name);
CREATE INDEX voip_carrier_sid_idx ON voip_carriers (voip_carrier_sid);
CREATE INDEX name_idx ON voip_carriers (name);
ALTER TABLE voip_carriers ADD FOREIGN KEY account_sid_idxfk_3 (account_sid) REFERENCES accounts (account_sid);
ALTER TABLE `phone_numbers` ADD FOREIGN KEY application_sid_idxfk_2 (`application_sid`) REFERENCES `applications` (`application_sid`);
ALTER TABLE voip_carriers ADD FOREIGN KEY application_sid_idxfk_2 (application_sid) REFERENCES applications (application_sid);
CREATE UNIQUE INDEX `sip_gateways_sip_gateway_idx_hostport` ON `sip_gateways` (`ipv4`,`port`);
CREATE INDEX phone_number_sid_idx ON phone_numbers (phone_number_sid);
CREATE INDEX voip_carrier_sid_idx ON phone_numbers (voip_carrier_sid);
ALTER TABLE phone_numbers ADD FOREIGN KEY voip_carrier_sid_idxfk (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid);
ALTER TABLE `sip_gateways` ADD FOREIGN KEY voip_carrier_sid_idxfk_1 (`voip_carrier_sid`) REFERENCES `voip_carriers` (`voip_carrier_sid`);
ALTER TABLE phone_numbers ADD FOREIGN KEY account_sid_idxfk_4 (account_sid) REFERENCES accounts (account_sid);
ALTER TABLE phone_numbers ADD FOREIGN KEY application_sid_idxfk_3 (application_sid) REFERENCES applications (application_sid);
CREATE INDEX webhook_sid_idx ON webhooks (webhook_sid);
CREATE UNIQUE INDEX sip_gateway_idx_hostport ON sip_gateways (ipv4,port);
ALTER TABLE sip_gateways ADD FOREIGN KEY voip_carrier_sid_idxfk_1 (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid);
ALTER TABLE lcr_carrier_set_entry ADD FOREIGN KEY lcr_route_sid_idxfk (lcr_route_sid) REFERENCES lcr_routes (lcr_route_sid);
ALTER TABLE lcr_carrier_set_entry ADD FOREIGN KEY voip_carrier_sid_idxfk_2 (voip_carrier_sid) REFERENCES voip_carriers (voip_carrier_sid);
CREATE UNIQUE INDEX applications_idx_name ON applications (account_sid,name);
CREATE INDEX application_sid_idx ON applications (application_sid);
CREATE INDEX account_sid_idx ON applications (account_sid);
ALTER TABLE applications ADD FOREIGN KEY account_sid_idxfk_5 (account_sid) REFERENCES accounts (account_sid);
ALTER TABLE applications ADD FOREIGN KEY call_hook_sid_idxfk (call_hook_sid) REFERENCES webhooks (webhook_sid);
ALTER TABLE applications ADD FOREIGN KEY call_status_hook_sid_idxfk (call_status_hook_sid) REFERENCES webhooks (webhook_sid);
CREATE INDEX service_provider_sid_idx ON service_providers (service_provider_sid);
CREATE INDEX name_idx ON service_providers (name);
CREATE INDEX root_domain_idx ON service_providers (root_domain);
ALTER TABLE service_providers ADD FOREIGN KEY registration_hook_sid_idxfk (registration_hook_sid) REFERENCES webhooks (webhook_sid);
CREATE INDEX account_sid_idx ON accounts (account_sid);
CREATE INDEX sip_realm_idx ON accounts (sip_realm);
CREATE INDEX service_provider_sid_idx ON accounts (service_provider_sid);
ALTER TABLE accounts ADD FOREIGN KEY service_provider_sid_idxfk_3 (service_provider_sid) REFERENCES service_providers (service_provider_sid);
ALTER TABLE accounts ADD FOREIGN KEY registration_hook_sid_idxfk_1 (registration_hook_sid) REFERENCES webhooks (webhook_sid);
ALTER TABLE accounts ADD FOREIGN KEY device_calling_application_sid_idxfk (device_calling_application_sid) REFERENCES applications (application_sid);
SET FOREIGN_KEY_CHECKS=1;

View File

@@ -1,6 +1,5 @@
const test = require('tape').test ;
const test = require('blue-tape') ;
const exec = require('child_process').exec ;
const async = require('async');
test('starting docker network..', (t) => {
exec(`docker-compose -f ${__dirname}/docker-compose-testbed.yaml up -d`, (err, stdout, stderr) => {

View File

@@ -1,4 +1,4 @@
const test = require('tape').test ;
const test = require('blue-tape') ;
const exec = require('child_process').exec ;
test('stopping docker network..', (t) => {

View File

@@ -1,4 +1,4 @@
const test = require('tape').test ;
const test = require('blue-tape') ;
const exec = require('child_process').exec ;
const pwd = process.env.TRAVIS ? '' : '-p$MYSQL_ROOT_PASSWORD';

View File

@@ -1,4 +1,4 @@
const test = require('tape');
const test = require('blue-tape');
const debug = require('debug')('drachtio:jambonz:test');
const makeTask = require('../lib/tasks/make_task');
const noop = () => {};