Compare commits

..

14 Commits

Author SHA1 Message Date
Dave Horton
c190279927 bugfix: enqueue task was only invoking waitUrl a single time 2021-12-06 21:18:51 -05:00
Dave Horton
114f65b36a add env LEGACY_CRYPTO 2021-11-29 09:03:43 -05:00
Dave Horton
3e49616191 Feature/specify trunk on dial (#47)
* #25: allow application to specify a specific SIP trunk on dial verb

* more fixes
2021-11-28 11:10:53 -05:00
Dave Horton
1e93973419 Feature/azure recognition (#46)
* add support for microsoft speech recognition

* update to drachtio-fsmrf that support microsoft stt

* gather and transcribe now support microsoft
2021-11-26 16:40:25 -06:00
Dave Horton
fe1778e9ae Feature/sip refer (#44)
* changes to support sip REFER

* implement actionhook

* changes from testing

* minor logging
2021-11-20 11:39:10 -05:00
Dave Horton
af15449451 fix tests 2021-11-19 14:17:10 -05:00
Dave Horton
12c34de15c changes for azure tts 2021-11-19 18:28:42 +00:00
Dave Horton
7c77bedd15 linting 2021-11-19 10:25:11 -05:00
Dave Horton
0c5150cb30 add support for recording conference to a file 2021-11-19 10:07:43 -05:00
Dave Horton
2262973f43 bugfix #41: error was thrown about missing speech creds when speech was not enabled 2021-11-16 19:42:16 -05:00
Dave Horton
db78ffffed dial: make sure to clear max call timer when dial ends 2021-11-15 12:00:48 -05:00
Dave Horton
2930cd6aaf Dockerfile 2021-11-04 12:57:56 -04:00
Dave Horton
2a013377cc update to aes-256-cbc algorithm for encryption 2021-11-03 16:17:20 -04:00
Dave Horton
dcf27ba5d3 trim sensitive info from logs 2021-11-03 14:37:57 -04:00
20 changed files with 1273 additions and 433 deletions

View File

@@ -1,16 +1,10 @@
FROM node:alpine as builder
RUN apk update && apk add --no-cache python make g++
FROM node:16
WORKDIR /opt/app/
COPY package.json ./
RUN npm install
RUN npm prune
FROM node:alpine as app
WORKDIR /opt/app
COPY . /opt/app
COPY --from=builder /opt/app/node_modules ./node_modules
ARG NODE_ENV
ENV NODE_ENV $NODE_ENV
CMD [ "npm", "start" ]
CMD [ "npm", "start" ]

View File

@@ -137,7 +137,9 @@ module.exports = function(srf, logger) {
const obj = Object.assign({}, app);
delete obj.requestor;
delete obj.notifier;
logger.info({app: obj}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
// eslint-disable-next-line no-unused-vars
const {call_hook, call_status_hook, ...appInfo} = obj; // mask sensitive data like user/pass on webhook
logger.info({app: appInfo}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
req.locals.callInfo = new CallInfo({req, app, direction: CallDirection.Inbound});
next();
} catch (err) {

View File

@@ -257,6 +257,13 @@ class CallSession extends Emitter {
region: process.env.AWS_REGION || credential.aws_region
};
}
else if ('microsoft' === vendor) {
return {
speech_credential_sid: credential.speech_credential_sid,
api_key: credential.api_key,
region: credential.region
};
}
}
else {
writeAlerts({

View File

@@ -10,6 +10,7 @@ const WAIT = 'wait';
const JOIN = 'join';
const START = 'start';
function confNoMatch(str) {
return str.match(/^No active conferences/) || str.match(/Conference.*not found/);
}
@@ -49,7 +50,7 @@ class Conference extends Task {
'beep', 'startConferenceOnEnter', 'endConferenceOnExit', 'joinMuted',
'maxParticipants', 'waitHook', 'statusHook', 'endHook', 'enterHook'
].forEach((attr) => this[attr] = this.data[attr]);
this.record = this.data.record || {};
this.statusEvents = [];
if (this.statusHook) {
['start', 'end', 'join', 'leave', 'start-talking', 'stop-talking'].forEach((e) => {
@@ -68,6 +69,9 @@ class Conference extends Task {
get name() { return TaskName.Conference; }
get shouldRecord() { return this.record.path; }
get isRecording() { return this.recordingInProgress; }
async exec(cs, ep) {
await super.exec(cs);
this.ep = ep;
@@ -346,6 +350,16 @@ class Conference extends Task {
if (response.body && /\d+/.test(response.body)) this.participantCount = parseInt(response.body);
this._notifyConferenceEvent(cs, 'join');
// start recording if requested and we just started the conference
if (startConf && this.shouldRecord) {
this.logger.info(`recording conference to ${this.record.path}`);
try {
await this.ep.api(`conference ${this.confName} record ${this.record.path}`);
} catch (err) {
this.logger.info({err}, 'Conference:_joinConference - failed to start recording');
}
}
// listen for conference events
this.ep.filter('Conference-Unique-ID', this.confUuid);
this.ep.conn.on('esl::event::CUSTOM::*', this.__onConferenceEvent.bind(this, cs)) ;

View File

@@ -12,6 +12,7 @@ const assert = require('assert');
const placeCall = require('../utils/place-outdial');
const sessionTracker = require('../session/session-tracker');
const DtmfCollector = require('../utils/dtmf-collector');
const dbUtils = require('../utils/db-utils');
const debug = require('debug')('jambonz:feature-server');
function parseDtmfOptions(logger, dtmfCapture) {
@@ -161,6 +162,10 @@ class TaskDial extends Task {
async kill(cs, reason) {
super.kill(cs);
this.killReason = reason || KillReason.Hangup;
if (this.timerMaxCallDuration) {
clearTimeout(this.timerMaxCallDuration);
this.timerMaxCallDuration = null;
}
this._removeDtmfDetection(cs.dlg);
this._removeDtmfDetection(this.dlg);
this._killOutdials();
@@ -171,7 +176,6 @@ class TaskDial extends Task {
if (this.callSid) sessionTracker.remove(this.callSid);
if (this.listenTask) await this.listenTask.kill(cs);
if (this.transcribeTask) await this.transcribeTask.kill(cs);
if (this.timerMaxCallDuration) clearTimeout(this.timerMaxCallDuration);
this.notifyTaskDone();
}
@@ -291,6 +295,7 @@ class TaskDial extends Task {
const {req, srf} = cs;
const {getSBC} = srf.locals;
const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers;
const {lookupCarrier} = dbUtils(this.logger, cs.srf);
const sbcAddress = this.proxy || getSBC();
const teamsInfo = {};
let fqdn;
@@ -336,6 +341,13 @@ class TaskDial extends Task {
this.logger.error({err}, 'Error looking up account by sid');
}
}
if (t.type === 'phone' && t.trunk) {
const voip_carrier_sid = await lookupCarrier(cs.accountSid, t.trunk);
this.logger.info(`Dial:_attemptCalls: selected ${voip_carrier_sid} for requested carrier: ${t.trunk})`);
if (voip_carrier_sid) {
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
}
}
const sd = placeCall({
logger: this.logger,
application: cs.application,
@@ -458,7 +470,7 @@ class TaskDial extends Task {
if (this.timeLimit) {
this.timerMaxCallDuration = setTimeout(() => {
this.logger.info(`Dial:_selectSingleDial tearing down call as it has reached ${this.timeLimit}s`);
this.ep.unbridge();
this.ep && this.ep.unbridge();
this.kill(cs);
}, this.timeLimit * 1000);
}
@@ -468,7 +480,10 @@ class TaskDial extends Task {
if (this.dlg) {
this.logger.debug('Dial:_selectSingleDial called party hungup, ending dial operation');
sessionTracker.remove(this.callSid);
if (this.timerMaxCallDuration) clearTimeout(this.timerMaxCallDuration);
if (this.timerMaxCallDuration) {
clearTimeout(this.timerMaxCallDuration);
this.timerMaxCallDuration = null;
}
this.ep && this.ep.unbridge();
this.kill(cs);
}

View File

@@ -337,7 +337,7 @@ class TaskEnqueue extends Task {
}
tasksToRun.push(o);
}
const cloneTasks = [...tasksToRun];
if (this.killed) return [];
else if (tasksToRun.length > 0) {
this._playSession = new ConfirmCallSession({
@@ -356,7 +356,7 @@ class TaskEnqueue extends Task {
this.state = QueueResults.Leave;
this.kill(cs);
}
return tasksToRun;
return cloneTasks;
}
}

View File

@@ -3,7 +3,8 @@ const {
TaskName,
TaskPreconditions,
GoogleTranscriptionEvents,
AwsTranscriptionEvents
AwsTranscriptionEvents,
AzureTranscriptionEvents
} = require('../utils/constants');
const makeTask = require('./make_task');
@@ -33,6 +34,12 @@ class TaskGather extends Task {
this.vocabularyName = recognizer.vocabularyName;
this.vocabularyFilterName = recognizer.vocabularyFilterName;
this.filterMethod = recognizer.filterMethod;
/* microsoft options */
this.outputFormat = recognizer.outputFormat || 'simple';
this.profanityOption = recognizer.profanityOption || 'raw';
this.requestSnr = recognizer.requestSnr || false;
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
}
this.digitBuffer = '';
@@ -46,6 +53,8 @@ class TaskGather extends Task {
get name() { return TaskName.Gather; }
get needsStt() { return this.input.includes('speech'); }
get earlyMedia() {
return (this.sayTask && this.sayTask.earlyMedia) ||
(this.playTask && this.playTask.earlyMedia);
@@ -59,9 +68,9 @@ class TaskGather extends Task {
if ('default' === this.vendor || !this.vendor) this.vendor = cs.speechRecognizerVendor;
if ('default' === this.language || !this.language) this.language = cs.speechRecognizerLanguage;
this.sttCredentials = cs.getSpeechCredentials(this.vendor, 'stt');
if (!this.sttCredentials) {
if (this.needsStt && !this.sttCredentials) {
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info(`TaskGather:exec - ERROR stt using ${this.vendor} requested but not creds supplied`);
this.logger.info(`TaskGather:exec - ERROR stt using ${this.vendor} requested but creds not supplied`);
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_NOT_PROVISIONED,
@@ -104,6 +113,8 @@ class TaskGather extends Task {
ep.removeCustomEventListener(GoogleTranscriptionEvents.Transcription);
ep.removeCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance);
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AzureTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected);
}
kill(cs) {
@@ -133,7 +144,9 @@ class TaskGather extends Task {
GOOGLE_SPEECH_SINGLE_UTTERANCE: true,
GOOGLE_SPEECH_MODEL: 'command_and_search'
});
if (this.hints && this.hints.length > 1) opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
if (this.hints && this.hints.length > 1) {
opts.GOOGLE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
}
if (this.altLanguages && this.altLanguages.length > 1) {
opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
}
@@ -143,22 +156,41 @@ class TaskGather extends Task {
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, cs, ep));
}
else {
else if (['aws', 'polly'].includes(this.vendor)) {
if (this.vocabularyName) opts.AWS_VOCABULARY_NAME = this.vocabularyName;
if (this.vocabularyFilterName) {
opts.AWS_VOCABULARY_NAME = this.vocabularyFilterName;
opts.AWS_VOCABULARY_FILTER_METHOD = this.filterMethod || 'mask';
}
Object.assign(opts, {
AWS_ACCESS_KEY_ID: this.sttCredentials.accessKeyId,
AWS_SECRET_ACCESS_KEY: this.sttCredentials.secretAccessKey,
AWS_REGION: this.sttCredentials.region
});
if (this.sttCredentials) {
Object.assign(opts, {
AWS_ACCESS_KEY_ID: this.sttCredentials.accessKeyId,
AWS_SECRET_ACCESS_KEY: this.sttCredentials.secretAccessKey,
AWS_REGION: this.sttCredentials.region
});
}
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
}
else if ('microsoft' === this.vendor) {
if (this.sttCredentials) {
Object.assign(opts, {
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
'AZURE_REGION': this.sttCredentials.region
});
}
if (this.hints && this.hints.length > 1) {
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
}
//if (this.requestSnr) opts.AZURE_REQUEST_SNR = 1;
//if (this.profanityOption !== 'raw') opts.AZURE_PROFANITY_OPTION = this.profanityOption;
if (this.initialSpeechTimeoutMs > 0) opts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = this.initialSpeechTimeoutMs;
opts.AZURE_USE_OUTPUT_FORMAT_DETAILED = 1;
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, this._onNoSpeechDetected.bind(this, cs, ep));
}
await ep.set(opts)
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
}
_startTranscribing(ep) {
@@ -206,11 +238,21 @@ class TaskGather extends Task {
_onTranscription(cs, ep, evt) {
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
this.logger.debug(evt, 'TaskGather:_onTranscription');
const final = evt.is_final;
if (final) {
this._resolve('speech', evt);
if ('microsoft' === this.vendor) {
const nbest = evt.NBest;
const newEvent = {
is_final: evt.RecognitionStatus === 'Success',
alternatives: [
{
confidence: nbest[0].Confidence,
transcript: nbest[0].Display
}
]
};
evt = newEvent;
}
this.logger.debug(evt, 'TaskGather:_onTranscription');
if (evt.is_final) this._resolve('speech', evt);
else if (this.partialResultHook) {
this.cs.requestor.request(this.partialResultHook, Object.assign({speech: evt}, this.cs.callInfo))
.catch((err) => this.logger.info(err, 'GatherTask:_onTranscription error'));
@@ -223,6 +265,10 @@ class TaskGather extends Task {
}
}
_onNoSpeechDetected(cs, ep) {
this._resolve('timeout');
}
async _resolve(reason, evt) {
if (this.resolved) return;
this.resolved = true;

View File

@@ -17,8 +17,10 @@ function makeTask(logger, obj, parent) {
case TaskName.SipDecline:
const TaskSipDecline = require('./sip_decline');
return new TaskSipDecline(logger, data, parent);
case TaskName.SipRefer:
const TaskSipRefer = require('./sip_refer');
return new TaskSipRefer(logger, data, parent);
case TaskName.Conference:
logger.debug({data}, 'Conference verb');
const TaskConference = require('./conference');
return new TaskConference(logger, data, parent);
case TaskName.Dial:

View File

@@ -21,19 +21,15 @@ class TaskSay extends Task {
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, srf);
const {writeAlerts, AlertType, stats} = srf.locals;
const {synthAudio} = srf.locals.dbHelpers;
const vendor = ('default' === this.synthesizer.vendor || !this.synthesizer.vendor) ?
cs.speechSynthesisVendor :
this.synthesizer.vendor;
const language = ('default' === this.synthesizer.language || !this.synthesizer.language) ?
cs.speechSynthesisLanguage :
this.synthesizer.language;
const voice = ('default' === this.synthesizer.voice || !this.synthesizer.voice) ?
cs.speechSynthesisVoice :
this.synthesizer.voice;
const hasVerbLevelTts = this.synthesizer.vendor && this.synthesizer.vendor !== 'default';
const vendor = hasVerbLevelTts ? this.synthesizer.vendor : cs.speechSynthesisVendor ;
const language = hasVerbLevelTts ? this.synthesizer.language : cs.speechSynthesisLanguage ;
const voice = hasVerbLevelTts ? this.synthesizer.voice : cs.speechSynthesisVoice ;
const salt = cs.callSid;
const credentials = cs.getSpeechCredentials(vendor, 'tts');
this.logger.info({vendor, credentials}, 'Task:say - using vendor');
this.logger.info({language, voice}, `Task:say - using vendor: ${vendor}`);
this.ep = ep;
try {
if (!credentials) {

101
lib/tasks/sip_refer.js Normal file
View File

@@ -0,0 +1,101 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const {parseUri} = require('drachtio-srf');
/**
* sends a sip REFER to transfer the existing call
*/
class TaskSipRefer extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.StableCall;
this.referTo = this.data.referTo;
this.referredBy = this.data.referredBy;
this.headers = this.data.headers || {};
this.eventHook = this.data.eventHook;
}
get name() { return TaskName.SipRefer; }
async exec(cs) {
super.exec(cs);
const {dlg} = cs;
const {referTo, referredBy} = this._normalizeReferHeaders(cs, dlg);
try {
this.notifyHandler = this._handleNotify.bind(this, cs, dlg);
dlg.on('notify', this.notifyHandler);
const response = await dlg.request({
method: 'REFER',
headers: {
...this.headers,
'Refer-To': referTo,
'Referred-By': referredBy
}
});
this.referStatus = response.status;
this.logger.info(`TaskSipRefer:exec - received ${this.referStatus} to REFER`);
/* if we fail, fall through to next verb. If success, we should get BYE from far end */
if (this.referStatus === 202) {
await this.awaitTaskDone();
}
else await this.performAction({refer_status: this.referStatus});
} catch (err) {
this.logger.info({err}, 'TaskSipRefer:exec - error sending REFER');
}
}
async kill(cs) {
super.kill(cs);
const {dlg} = cs;
dlg.off('notify', this.notifyHandler);
}
async _handleNotify(cs, dlg, req, res) {
res.send(200);
const contentType = req.get('Content-Type');
this.logger.debug({body: req.body}, `TaskSipRefer:_handleNotify got ${contentType}`);
if (contentType === 'message/sipfrag') {
const arr = /SIP\/2\.0\s+(\d+)/.exec(req.body);
if (arr) {
const status = arr[1];
this.logger.debug(`TaskSipRefer:_handleNotify: call got status ${status}`);
if (this.eventHook) {
await cs.requestor.request(this.eventHook, {event: 'transfer-status', call_status: status});
}
if (status >= 200) {
await this.performAction({refer_status: 202, final_referred_call_status: status});
this.notifyTaskDone();
}
}
}
}
_normalizeReferHeaders(cs, dlg) {
let {referTo, referredBy} = this;
/* get IP address of the SBC to use as hostname if needed */
const {host} = parseUri(dlg.remote.uri);
if (!referTo.startsWith('<') && !referTo.startsWith('sip') && !referTo.startsWith('"')) {
/* they may have only provided a phone number/user */
referTo = `sip:${referTo}@${host}`;
}
if (!referredBy) {
/* default */
referredBy = cs.req?.callingNumber || dlg.local.uri;
this.logger.info({referredBy}, 'setting referredby');
}
if (!referredBy.startsWith('<') && !referredBy.startsWith('sip') && !referredBy.startsWith('"')) {
/* they may have only provided a phone number/user */
referredBy = `sip:${referredBy}@${host}`;
}
return {referTo, referredBy};
}
}
module.exports = TaskSipRefer;

View File

@@ -9,6 +9,18 @@
"status"
]
},
"sip:refer": {
"properties": {
"referTo": "string",
"referredBy": "string",
"headers": "object",
"actionHook": "object|string",
"eventHook": "object|string"
},
"required": [
"referTo"
]
},
"dequeue": {
"properties": {
"name": "string",
@@ -92,7 +104,8 @@
"waitHook": "object|string",
"statusEvents": "array",
"statusHook": "object|string",
"enterHook": "object|string"
"enterHook": "object|string",
"record": "#record"
},
"required": [
"name"
@@ -241,6 +254,14 @@
"url"
]
},
"record": {
"properties": {
"path": "string"
},
"required": [
"path"
]
},
"redirect": {
"properties": {
"actionHook": "object|string"
@@ -307,7 +328,8 @@
"sipUri": "string",
"auth": "#auth",
"vmail": "boolean",
"tenant": "string"
"tenant": "string",
"trunk": "string"
},
"required": [
"type"
@@ -327,7 +349,7 @@
"properties": {
"vendor": {
"type": "string",
"enum": ["google", "aws", "polly", "default"]
"enum": ["google", "aws", "polly", "microsoft", "default"]
},
"language": "string",
"voice": "string",
@@ -344,7 +366,7 @@
"properties": {
"vendor": {
"type": "string",
"enum": ["google", "aws", "default"]
"enum": ["google", "aws", "microsoft", "default"]
},
"language": "string",
"hints": "array",
@@ -384,7 +406,24 @@
"mask",
"tag"
]
}
},
"outputFormat": {
"type": "string",
"enum": [
"simple",
"detailed"
]
},
"profanityOption": {
"type": "string",
"enum": [
"masked",
"removed",
"raw"
]
},
"requestSnr": "boolean",
"initialSpeechTimeoutMs": "number"
},
"required": [
"vendor"

View File

@@ -3,6 +3,7 @@ const {
TaskName,
TaskPreconditions,
GoogleTranscriptionEvents,
AzureTranscriptionEvents,
AwsTranscriptionEvents
} = require('../utils/constants');
@@ -38,6 +39,12 @@ class TaskTranscribe extends Task {
this.vocabularyName = recognizer.vocabularyName;
this.vocabularyFilterName = recognizer.vocabularyFilterName;
this.filterMethod = recognizer.filterMethod;
/* microsoft options */
this.outputFormat = recognizer.outputFormat || 'simple';
this.profanityOption = recognizer.profanityOption || 'raw';
this.requestSnr = recognizer.requestSnr || false;
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
}
get name() { return TaskName.Transcribe; }
@@ -53,7 +60,13 @@ class TaskTranscribe extends Task {
try {
if (!this.sttCredentials) {
// TODO: generate alert (actually should be done by cs.getSpeechCredentials)
const {writeAlerts, AlertType} = cs.srf.locals;
this.logger.info(`TaskTranscribe:exec - ERROR stt using ${this.vendor} requested but creds not supplied`);
writeAlerts({
account_sid: cs.accountSid,
alert_type: AlertType.STT_NOT_PROVISIONED,
vendor: this.vendor
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
throw new Error('no provisioned speech credentials for TTS');
}
await this._startTranscribing(cs, ep);
@@ -70,6 +83,8 @@ class TaskTranscribe extends Task {
ep.removeCustomEventListener(AwsTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AwsTranscriptionEvents.NoAudioDetected);
ep.removeCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded);
ep.removeCustomEventListener(AzureTranscriptionEvents.Transcription);
ep.removeCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected);
}
async kill(cs) {
@@ -96,6 +111,8 @@ class TaskTranscribe extends Task {
ep.addCustomEventListener(AwsTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep));
ep.addCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded,
this._onMaxDurationExceeded.bind(this, cs, ep));
ep.addCustomEventListener(AzureTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, this._onNoAudio.bind(this, cs, ep));
if (this.vendor === 'google') {
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
@@ -164,6 +181,22 @@ class TaskTranscribe extends Task {
await ep.set(opts)
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with aws'));
}
else if (this.vendor === 'microsoft') {
Object.assign(opts, {
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
'AZURE_REGION': this.sttCredentials.region
});
if (this.hints && this.hints.length > 1) {
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
}
if (this.requestSnr) opts.AZURE_REQUEST_SNR = 1;
if (this.profanityOption !== 'raw') opts.AZURE_PROFANITY_OPTION = this.profanityOption;
if (this.initialSpeechTimeoutMs > 0) opts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = this.initialSpeechTimeoutMs;
if (this.outputFormat !== 'simple') opts.AZURE_USE_OUTPUT_FORMAT_DETAILED = 1;
await ep.set(opts)
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with azure'));
}
await this._transcribe(ep);
}
@@ -178,6 +211,20 @@ class TaskTranscribe extends Task {
_onTranscription(cs, ep, evt) {
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
if ('microsoft' === this.vendor) {
const nbest = evt.NBest;
const alternatives = nbest.map((n) => {
return {
confidence: n.Confidence,
transcript: n.Display
};
});
const newEvent = {
is_final: evt.RecognitionStatus === 'Success',
alternatives
};
evt = newEvent;
}
this.logger.debug(evt, 'TaskTranscribe:_onTranscription');
this.cs.requestor.request(this.transcriptionHook, Object.assign({speech: evt}, this.cs.callInfo))

View File

@@ -18,6 +18,7 @@
"Redirect": "redirect",
"RestDial": "rest:dial",
"SipDecline": "sip:decline",
"SipRefer": "sip:refer",
"SipNotify": "sip:notify",
"SipRedirect": "sip:redirect",
"Say": "say",
@@ -64,6 +65,12 @@
"NoAudioDetected": "aws_transcribe::no_audio_detected",
"MaxDurationExceeded": "aws_transcribe::max_duration_exceeded"
},
"AzureTranscriptionEvents": {
"Transcription": "azure_transcribe::transcription",
"StartOfUtterance": "azure_transcribe::start_of_utterance",
"EndOfUtterance": "azure_transcribe::end_of_utterance",
"NoSpeechDetected": "azure_transcribe::no_speech_detected"
},
"ListenEvents": {
"Connect": "mod_audio_fork::connect",
"ConnectFailure": "mod_audio_fork::connect_failed",

View File

@@ -10,6 +10,16 @@ const sqlSpeechCredentialsForSP = `SELECT *
FROM speech_credentials
WHERE service_provider_sid =
(SELECT service_provider_sid from accounts where account_sid = ?)`;
const sqlQueryAccountCarrierByName = `SELECT voip_carrier_sid
FROM voip_carriers vc
WHERE vc.account_sid = ?
AND vc.name = ?`;
const sqlQuerySPCarrierByName = `SELECT voip_carrier_sid
FROM voip_carriers vc
WHERE vc.account_sid IS NULL
AND vc.service_provider_sid =
(SELECT service_provider_sid from accounts where account_sid = ?)
AND vc.name = ?`;
const speechMapper = (cred) => {
const {credential, ...obj} = cred;
@@ -21,6 +31,11 @@ const speechMapper = (cred) => {
obj.access_key_id = o.access_key_id;
obj.secret_access_key = o.secret_access_key;
}
else if ('microsoft' === obj.vendor) {
const o = JSON.parse(decrypt(credential));
obj.api_key = o.api_key;
obj.region = o.region;
}
return obj;
};
@@ -37,7 +52,8 @@ module.exports = (logger, srf) => {
/* search at the service provider level if we don't find it at the account level */
const haveGoogle = speech.find((s) => s.vendor === 'google');
const haveAws = speech.find((s) => s.vendor === 'aws');
if (!haveGoogle || !haveAws) {
const haveMicrosoft = speech.find((s) => s.vendor === 'microsoft');
if (!haveGoogle || !haveAws || !haveMicrosoft) {
const [r3] = await pp.query(sqlSpeechCredentialsForSP, account_sid);
if (r3.length) {
if (!haveGoogle) {
@@ -48,6 +64,10 @@ module.exports = (logger, srf) => {
const aws = r3.find((s) => s.vendor === 'aws');
if (aws) speech.push(speechMapper(aws));
}
if (!haveMicrosoft) {
const ms = r3.find((s) => s.vendor === 'microsoft');
if (ms) speech.push(speechMapper(ms));
}
}
}
@@ -67,8 +87,21 @@ module.exports = (logger, srf) => {
}
};
const lookupCarrier = async(account_sid, carrierName) => {
const pp = pool.promise();
try {
const [r] = await pp.query(sqlQueryAccountCarrierByName, [account_sid, carrierName]);
if (r.length) return r[0].voip_carrier_sid;
const [r2] = await pp.query(sqlQuerySPCarrierByName, [account_sid, carrierName]);
if (r2.length) return r2[0].voip_carrier_sid;
} catch (err) {
logger.error({err}, `lookupCarrier: Error ${account_sid}:${carrierName}`);
}
};
return {
lookupAccountDetails,
updateSpeechCredentialLastUsed
updateSpeechCredentialLastUsed,
lookupCarrier
};
};

View File

@@ -1,5 +1,5 @@
const crypto = require('crypto');
const algorithm = 'aes-256-ctr';
const algorithm = process.env.LEGACY_CRYPTO ? 'aes-256-ctr' : 'aes-256-cbc';
const iv = crypto.randomBytes(16);
const secretKey = crypto.createHash('sha256')
.update(String(process.env.JWT_SECRET))

View File

@@ -100,15 +100,15 @@ class Requestor {
assert.ok(url, 'Requestor:request url was not provided');
assert.ok, (['GET', 'POST'].includes(method), `Requestor:request method must be 'GET' or 'POST' not ${method}`);
this.logger.debug({hook, payload}, `Requestor:request ${method} ${url}`);
const {url: urlInfo = hook, method: methodInfo = 'POST'} = hook; // mask user/pass
this.logger.debug({url: urlInfo, method: methodInfo, payload}, `Requestor:request ${method} ${url}`);
const startAt = process.hrtime();
let buf;
try {
const sigHeader = generateSigHeader(payload, this.secret);
const headers = {...sigHeader, ...this.authHeader};
this.logger.info({url, headers}, 'send webhook');
//this.logger.info({url, headers}, 'send webhook');
buf = isRelativeUrl(url) ?
await this.post(url, payload, headers) :
await bent(method, 'buffer', 200, 201, 202)(url, payload, headers);

1255
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.6.7-rc5",
"version": "v0.6.7-rc8",
"main": "app.js",
"engines": {
"node": ">= 10.16.0"
@@ -26,9 +26,9 @@
"jslint": "eslint app.js lib"
},
"dependencies": {
"@jambonz/db-helpers": "^0.6.13",
"@jambonz/db-helpers": "^0.6.14",
"@jambonz/mw-registrar": "^0.2.1",
"@jambonz/realtimedb-helpers": "^0.4.8",
"@jambonz/realtimedb-helpers": "^0.4.10",
"@jambonz/stats-collector": "^0.1.5",
"@jambonz/time-series": "^0.1.5",
"aws-sdk": "^2.846.0",
@@ -36,7 +36,7 @@
"cidr-matcher": "^2.1.1",
"debug": "^4.3.1",
"deepcopy": "^2.1.0",
"drachtio-fsmrf": "^2.0.7",
"drachtio-fsmrf": "^2.0.11",
"drachtio-srf": "^4.4.55",
"express": "^4.17.1",
"ip": "^1.1.5",

View File

@@ -587,7 +587,8 @@ DROP TABLE IF EXISTS `speech_credentials`;
/*!50503 SET character_set_client = utf8mb4 */;
CREATE TABLE `speech_credentials` (
`speech_credential_sid` char(36) NOT NULL,
`account_sid` char(36) NOT NULL,
`service_provider_sid` CHAR(36),
`account_sid` char(36) NOT NULL,
`vendor` varchar(255) NOT NULL,
`credential` VARCHAR(8192) NOT NULL,
`use_for_tts` tinyint(1) DEFAULT '1',
@@ -611,7 +612,7 @@ CREATE TABLE `speech_credentials` (
LOCK TABLES `speech_credentials` WRITE;
/*!40000 ALTER TABLE `speech_credentials` DISABLE KEYS */;
INSERT INTO `speech_credentials` VALUES ('2add163c-34f2-45c6-a016-f955d218ffb6','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','google','credential-goes-here',1,1,NULL,'2021-04-03 15:42:10',1,1),('84154212-5c99-4c94-8993-bc2a46288daa','bb845d4b-83a9-4cde-a6e9-50f3743bab3f','aws','credential-goes-here',0,0,NULL,NULL,NULL,NULL);
INSERT INTO `speech_credentials` VALUES ('2add163c-34f2-45c6-a016-f955d218ffb6',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','google','credential-goes-here',1,1,NULL,'2021-04-03 15:42:10',1,1),('84154212-5c99-4c94-8993-bc2a46288daa',NULL,'bb845d4b-83a9-4cde-a6e9-50f3743bab3f','aws','credential-goes-here',0,0,NULL,NULL,NULL,NULL);
/*!40000 ALTER TABLE `speech_credentials` ENABLE KEYS */;
UNLOCK TABLES;

View File

@@ -20,12 +20,16 @@ DROP TABLE IF EXISTS lcr_routes;
DROP TABLE IF EXISTS predefined_sip_gateways;
DROP TABLE IF EXISTS predefined_smpp_gateways;
DROP TABLE IF EXISTS predefined_carriers;
DROP TABLE IF EXISTS account_offers;
DROP TABLE IF EXISTS products;
DROP TABLE IF EXISTS schema_version;
DROP TABLE IF EXISTS api_keys;
DROP TABLE IF EXISTS sbc_addresses;
@@ -148,6 +152,20 @@ predefined_carrier_sid CHAR(36) NOT NULL,
PRIMARY KEY (predefined_sip_gateway_sid)
);
CREATE TABLE predefined_smpp_gateways
(
predefined_smpp_gateway_sid CHAR(36) NOT NULL UNIQUE ,
ipv4 VARCHAR(128) NOT NULL COMMENT 'ip address or DNS name of the gateway. ',
port INTEGER NOT NULL DEFAULT 2775 COMMENT 'smpp signaling port',
inbound BOOLEAN NOT NULL COMMENT 'if true, whitelist this IP to allow inbound SMS from the gateway',
outbound BOOLEAN NOT NULL COMMENT 'i',
netmask INTEGER NOT NULL DEFAULT 32,
is_primary BOOLEAN NOT NULL DEFAULT 1,
use_tls BOOLEAN DEFAULT 0,
predefined_carrier_sid CHAR(36) NOT NULL,
PRIMARY KEY (predefined_smpp_gateway_sid)
);
CREATE TABLE products
(
product_sid CHAR(36) NOT NULL UNIQUE ,
@@ -174,6 +192,11 @@ stripe_product_id VARCHAR(56) NOT NULL,
PRIMARY KEY (account_offer_sid)
);
CREATE TABLE schema_version
(
version VARCHAR(16)
);
CREATE TABLE api_keys
(
api_key_sid CHAR(36) NOT NULL UNIQUE ,
@@ -420,6 +443,10 @@ CREATE INDEX predefined_sip_gateway_sid_idx ON predefined_sip_gateways (predefin
CREATE INDEX predefined_carrier_sid_idx ON predefined_sip_gateways (predefined_carrier_sid);
ALTER TABLE predefined_sip_gateways ADD FOREIGN KEY predefined_carrier_sid_idxfk (predefined_carrier_sid) REFERENCES predefined_carriers (predefined_carrier_sid);
CREATE INDEX predefined_smpp_gateway_sid_idx ON predefined_smpp_gateways (predefined_smpp_gateway_sid);
CREATE INDEX predefined_carrier_sid_idx ON predefined_smpp_gateways (predefined_carrier_sid);
ALTER TABLE predefined_smpp_gateways ADD FOREIGN KEY predefined_carrier_sid_idxfk_1 (predefined_carrier_sid) REFERENCES predefined_carriers (predefined_carrier_sid);
CREATE INDEX product_sid_idx ON products (product_sid);
CREATE INDEX account_product_sid_idx ON account_products (account_product_sid);
CREATE INDEX account_subscription_sid_idx ON account_products (account_subscription_sid);
@@ -545,4 +572,4 @@ ALTER TABLE accounts ADD FOREIGN KEY queue_event_hook_sid_idxfk (queue_event_hoo
ALTER TABLE accounts ADD FOREIGN KEY device_calling_application_sid_idxfk (device_calling_application_sid) REFERENCES applications (application_sid);
SET FOREIGN_KEY_CHECKS=1;
SET FOREIGN_KEY_CHECKS=0;