mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2025-12-20 16:50:39 +00:00
merge features from hosted branch (#32)
major merge of features from the hosted branch that was created temporarily during the initial launch of jambonz.org
This commit is contained in:
@@ -3,9 +3,11 @@ const makeTask = require('../../tasks/make_task');
|
||||
const RestCallSession = require('../../session/rest-call-session');
|
||||
const CallInfo = require('../../session/call-info');
|
||||
const {CallDirection, CallStatus} = require('../../utils/constants');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const SipError = require('drachtio-srf').SipError;
|
||||
const sysError = require('./error');
|
||||
const Requestor = require('../../utils/requestor');
|
||||
const dbUtils = require('../../utils/db-utils');
|
||||
|
||||
router.post('/', async(req, res) => {
|
||||
const {logger} = req.app.locals;
|
||||
@@ -15,6 +17,7 @@ router.post('/', async(req, res) => {
|
||||
let uri, cs, to;
|
||||
const restDial = makeTask(logger, {'rest:dial': req.body});
|
||||
const {srf} = require('../../..');
|
||||
const {lookupAccountDetails} = dbUtils(logger, srf);
|
||||
const {getSBC, getFreeswitch} = srf.locals;
|
||||
const sbcAddress = getSBC();
|
||||
if (!sbcAddress) throw new Error('no available SBCs for outbound call creation');
|
||||
@@ -24,13 +27,24 @@ router.post('/', async(req, res) => {
|
||||
headers: req.body.headers || {}
|
||||
};
|
||||
|
||||
|
||||
const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers;
|
||||
const account = await lookupAccountBySid(req.body.account_sid);
|
||||
const accountInfo = await lookupAccountDetails(req.body.account_sid);
|
||||
const callSid = uuidv4();
|
||||
|
||||
opts.headers = {
|
||||
...opts.headers,
|
||||
'X-Call-Sid': callSid,
|
||||
'X-Account-Sid': req.body.account_sid
|
||||
};
|
||||
|
||||
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, {
|
||||
@@ -83,8 +97,10 @@ router.post('/', async(req, res) => {
|
||||
* attach our requestor and notifier objects
|
||||
* these will be used for all http requests we make during this call
|
||||
*/
|
||||
app.requestor = new Requestor(logger, app.call_hook);
|
||||
if (app.call_status_hook) app.notifier = new Requestor(logger, app.call_status_hook);
|
||||
app.requestor = new Requestor(logger, account.account_sid, app.call_hook, account.webhook_secret);
|
||||
if (app.call_status_hook) {
|
||||
app.notifier = new Requestor(logger, account.account_sid, app.call_status_hook, account.webhook_secret);
|
||||
}
|
||||
else app.notifier = {request: () => {}};
|
||||
|
||||
/* now launch the outdial */
|
||||
@@ -104,10 +120,11 @@ router.post('/', async(req, res) => {
|
||||
req: inviteReq,
|
||||
to,
|
||||
tag: app.tag,
|
||||
callSid,
|
||||
accountSid: req.body.account_sid,
|
||||
applicationSid: app.application_sid
|
||||
});
|
||||
cs = new RestCallSession({logger, application: app, srf, req: inviteReq, ep, tasks, callInfo});
|
||||
cs = new RestCallSession({logger, application: app, srf, req: inviteReq, ep, tasks, callInfo, accountInfo});
|
||||
cs.exec(req);
|
||||
|
||||
res.status(201).json({sid: cs.callSid});
|
||||
|
||||
@@ -8,12 +8,15 @@ const makeTask = require('../../tasks/make_task');
|
||||
router.post('/:sid', async(req, res) => {
|
||||
const {logger} = req.app.locals;
|
||||
const {srf} = req.app.locals;
|
||||
const {messageSid} = req.body;
|
||||
const {message_sid, account_sid} = req.body;
|
||||
|
||||
logger.debug({body: req.body}, 'got createMessage request');
|
||||
|
||||
const data = [Object.assign({verb: 'message'}, req.body)];
|
||||
delete data[0].messageSid;
|
||||
const data = [{
|
||||
verb: 'message',
|
||||
...req.body
|
||||
}];
|
||||
delete data[0].message_sid;
|
||||
|
||||
try {
|
||||
const tasks = normalizeJambones(logger, data)
|
||||
@@ -21,8 +24,8 @@ router.post('/:sid', async(req, res) => {
|
||||
|
||||
const callInfo = new CallInfo({
|
||||
direction: CallDirection.None,
|
||||
messageSid,
|
||||
accountSid: req.params.sid,
|
||||
messageSid: message_sid,
|
||||
accountSid: account_sid,
|
||||
res
|
||||
});
|
||||
const cs = new SmsSession({logger, srf, tasks, callInfo});
|
||||
|
||||
@@ -13,11 +13,14 @@ router.post('/:partner', async(req, res) => {
|
||||
logger.debug({body: req.body}, `got incomingSms request from partner ${req.params.partner}`);
|
||||
|
||||
let tasks;
|
||||
const {srf} = require('../../..');
|
||||
const {lookupAccountBySid} = srf.locals.dbHelpers;
|
||||
const app = req.body.app;
|
||||
const account = await lookupAccountBySid(app.accountSid);
|
||||
const hook = app.messaging_hook;
|
||||
const requestor = new Requestor(logger, hook);
|
||||
const requestor = new Requestor(logger, account.account_sid, hook, account.webhook_secret);
|
||||
const payload = {
|
||||
provider: req.params.partner,
|
||||
carrier: req.params.partner,
|
||||
messageSid: app.messageSid,
|
||||
accountSid: app.accountSid,
|
||||
applicationSid: app.applicationSid,
|
||||
@@ -38,7 +41,7 @@ router.post('/:partner', async(req, res) => {
|
||||
}
|
||||
|
||||
|
||||
// process any versb in response
|
||||
// process any verbs in response
|
||||
if (Array.isArray(tasks) && tasks.length) {
|
||||
const {srf} = req.app.locals;
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@ function retrieveCallSession(callSid, opts) {
|
||||
return cs;
|
||||
}
|
||||
|
||||
const updateCall = async(req, res) => {
|
||||
/**
|
||||
* update a call
|
||||
*/
|
||||
router.post('/:callSid', async(req, res) => {
|
||||
const logger = req.app.locals.logger;
|
||||
const callSid = req.params.callSid;
|
||||
logger.debug({body: req.body}, 'got upateCall request');
|
||||
@@ -42,23 +45,11 @@ const updateCall = async(req, res) => {
|
||||
logger.info(`updateCall: callSid not found ${callSid}`);
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
res.sendStatus(204);
|
||||
res.sendStatus(202);
|
||||
cs.updateCall(req.body, callSid);
|
||||
} catch (err) {
|
||||
sysError(logger, res, err);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* update a call
|
||||
*/
|
||||
|
||||
/* leaving in for legacy; should have been (and now is) a PUT */
|
||||
router.post('/:callSid', async(req, res) => {
|
||||
await updateCall(req, res);
|
||||
});
|
||||
router.put('/:callSid', async(req, res) => {
|
||||
await updateCall(req, res);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -5,10 +5,11 @@ const Requestor = require('./utils/requestor');
|
||||
const makeTask = require('./tasks/make_task');
|
||||
const parseUri = require('drachtio-srf').parseUri;
|
||||
const normalizeJambones = require('./utils/normalize-jambones');
|
||||
const dbUtils = require('./utils/db-utils');
|
||||
|
||||
module.exports = function(srf, logger) {
|
||||
const {lookupAppByPhoneNumber, lookupAppBySid, lookupAppByRealm, lookupAppByTeamsTenant} = srf.locals.dbHelpers;
|
||||
|
||||
const {lookupAccountDetails} = dbUtils(logger, srf);
|
||||
function initLocals(req, res, next) {
|
||||
const callSid = req.has('X-Retain-Call-Sid') ? req.get('X-Retain-Call-Sid') : uuidv4();
|
||||
req.locals = {
|
||||
@@ -26,6 +27,32 @@ module.exports = function(srf, logger) {
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* retrieve account information for the incoming call
|
||||
*/
|
||||
async function getAccountDetails(req, res, next) {
|
||||
|
||||
if (!req.has('X-Account-Sid')) {
|
||||
logger.info('getAccountDetails - rejecting call due to missing X-Account-Sid header');
|
||||
return res.send(500);
|
||||
}
|
||||
const account_sid = req.locals.account_sid = req.get('X-Account-Sid');
|
||||
|
||||
try {
|
||||
req.locals.accountInfo = await lookupAccountDetails(account_sid);
|
||||
if (!req.locals.accountInfo.account.is_active) {
|
||||
logger.info(`Account is inactive or suspended ${account_sid}`);
|
||||
// TODO: alert
|
||||
return res.send(503, {headers: {'X-Reason': 'Account exists but is inactive'}});
|
||||
}
|
||||
logger.debug({accountInfo: req.locals.accountInfo}, `retrieved account info for ${account_sid}`);
|
||||
next();
|
||||
} catch (err) {
|
||||
logger.info({err}, `Error retrieving account details for account ${account_sid}`);
|
||||
res.send(503, {headers: {'X-Reason': `No Account exists for sid ${account_sid}`}});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Within the system, we deal with E.164 numbers _without_ the leading '+
|
||||
*/
|
||||
@@ -52,6 +79,7 @@ module.exports = function(srf, logger) {
|
||||
*/
|
||||
async function retrieveApplication(req, res, next) {
|
||||
const logger = req.locals.logger;
|
||||
const {accountInfo, account_sid} = req.locals;
|
||||
try {
|
||||
let app;
|
||||
if (req.locals.application_sid) app = await lookupAppBySid(req.locals.application_sid);
|
||||
@@ -100,8 +128,9 @@ module.exports = function(srf, logger) {
|
||||
* create a requestor that we will use for all http requests we make during the call.
|
||||
* also create a notifier for call status events (if not needed, its a no-op).
|
||||
*/
|
||||
app.requestor = new Requestor(logger, app.call_hook);
|
||||
if (app.call_status_hook) app.notifier = new Requestor(logger, app.call_status_hook);
|
||||
app.requestor = new Requestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret);
|
||||
if (app.call_status_hook) app.notifier = new Requestor(logger, account_sid, app.call_status_hook,
|
||||
accountInfo.account.webhook_secret);
|
||||
else app.notifier = {request: () => {}};
|
||||
|
||||
req.locals.application = app;
|
||||
@@ -145,6 +174,7 @@ module.exports = function(srf, logger) {
|
||||
|
||||
return {
|
||||
initLocals,
|
||||
getAccountDetails,
|
||||
normalizeNumbers,
|
||||
retrieveApplication,
|
||||
invokeWebCallback
|
||||
|
||||
@@ -54,8 +54,8 @@ class CallInfo {
|
||||
}
|
||||
else {
|
||||
// outbound call triggered by REST
|
||||
const {req, accountSid, applicationSid, to, tag} = opts;
|
||||
this.callSid = uuidv4();
|
||||
const {req, callSid, accountSid, applicationSid, to, tag} = opts;
|
||||
this.callSid = callSid;
|
||||
this.accountSid = accountSid;
|
||||
this.applicationSid = applicationSid;
|
||||
this.callStatus = CallStatus.Trying,
|
||||
|
||||
@@ -27,12 +27,13 @@ class CallSession extends Emitter {
|
||||
* @param {array} opts.tasks - tasks we are to execute
|
||||
* @param {callInfo} opts.callInfo - information about the call
|
||||
*/
|
||||
constructor({logger, application, srf, tasks, callInfo}) {
|
||||
constructor({logger, application, srf, tasks, callInfo, accountInfo}) {
|
||||
super();
|
||||
this.logger = logger;
|
||||
this.application = application;
|
||||
this.srf = srf;
|
||||
this.callInfo = callInfo;
|
||||
this.accountInfo = accountInfo;
|
||||
this.tasks = tasks;
|
||||
this.taskIdx = 0;
|
||||
this.stackIdx = 0;
|
||||
@@ -167,6 +168,7 @@ class CallSession extends Emitter {
|
||||
get isAdultingCallSession() {
|
||||
return this.constructor.name === 'AdultingCallSession';
|
||||
}
|
||||
|
||||
/**
|
||||
* returns true if this session is a ConfirmCallSession
|
||||
*/
|
||||
@@ -181,6 +183,55 @@ class CallSession extends Emitter {
|
||||
return this.constructor.name === 'SmsCallSession';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for speech credentials for the specified vendor
|
||||
* @param {*} vendor - google or aws
|
||||
*/
|
||||
getSpeechCredentials(vendor, type) {
|
||||
const {writeAlerts, AlertType} = this.srf.locals;
|
||||
this.logger.debug({vendor, type, speech: this.accountInfo.speech}, `searching for speech for vendor ${vendor}`);
|
||||
if (this.accountInfo.speech && this.accountInfo.speech.length > 0) {
|
||||
const credential = this.accountInfo.speech.find((s) => s.vendor === vendor);
|
||||
if (credential && (
|
||||
(type === 'tts' && credential.use_for_tts) ||
|
||||
(type === 'stt' && credential.use_for_stt)
|
||||
)) {
|
||||
if ('google' === vendor) {
|
||||
try {
|
||||
const cred = JSON.parse(credential.service_key.replace(/\n/g, '\\n'));
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
credentials: cred
|
||||
};
|
||||
} catch (err) {
|
||||
const sid = this.accountInfo.account.account_sid;
|
||||
this.logger.info({err}, `malformed google service_key provisioned for account ${sid}`);
|
||||
writeAlerts({
|
||||
alert_type: AlertType.TTS_FAILURE,
|
||||
account_sid: this.accountSid,
|
||||
vendor
|
||||
}).catch((err) => this.logger.error({err}, 'Error writing tts alert'));
|
||||
}
|
||||
}
|
||||
else if (['aws', 'polly'].includes(vendor)) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
accessKeyId: credential.access_key_id,
|
||||
secretAccessKey: credential.secret_access_key,
|
||||
region: process.env.AWS_REGION || credential.aws_region
|
||||
};
|
||||
}
|
||||
}
|
||||
else {
|
||||
writeAlerts({
|
||||
alert_type: AlertType.STT_NOT_PROVISIONED,
|
||||
account_sid: this.accountSid,
|
||||
vendor
|
||||
}).catch((err) => this.logger.error({err}, 'Error writing tts alert'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* execute the tasks in the CallSession. The tasks are executed in sequence until
|
||||
* they complete, or the caller hangs up.
|
||||
@@ -537,10 +588,10 @@ class CallSession extends Emitter {
|
||||
park_after_bridge: true
|
||||
}).catch((err) => this.logger.error({err}, 'Error setting park_after_bridge'));
|
||||
|
||||
this.logger.debug(`allocated endpoint ${this.ep.uuid}`);
|
||||
this.logger.debug(`allocated endpoint ${ep.uuid}`);
|
||||
|
||||
this.ep.on('destroy', () => {
|
||||
this.logger.info(`endpoint was destroyed!! ${this.ep.uuid}`);
|
||||
this.logger.debug(`endpoint was destroyed!! ${this.ep.uuid}`);
|
||||
});
|
||||
|
||||
if (this.direction === CallDirection.Inbound) {
|
||||
|
||||
@@ -15,6 +15,7 @@ class InboundCallSession extends CallSession {
|
||||
srf: req.srf,
|
||||
application: req.locals.application,
|
||||
callInfo: req.locals.callInfo,
|
||||
accountInfo: req.locals.accountInfo,
|
||||
tasks: req.locals.application.tasks
|
||||
});
|
||||
this.req = req;
|
||||
|
||||
@@ -8,14 +8,15 @@ const moment = require('moment');
|
||||
* @extends CallSession
|
||||
*/
|
||||
class RestCallSession extends CallSession {
|
||||
constructor({logger, application, srf, req, ep, tasks, callInfo}) {
|
||||
constructor({logger, application, srf, req, ep, tasks, callInfo, accountInfo}) {
|
||||
super({
|
||||
logger,
|
||||
application,
|
||||
srf,
|
||||
callSid: callInfo.callSid,
|
||||
tasks,
|
||||
callInfo
|
||||
callInfo,
|
||||
accountInfo
|
||||
});
|
||||
this.req = req;
|
||||
this.ep = ep;
|
||||
|
||||
@@ -135,13 +135,11 @@ class TaskDial extends Task {
|
||||
this.epOther.play(this.dialMusic).catch((err) => {});
|
||||
}
|
||||
}
|
||||
this._installDtmfDetection(cs, this.epOther, this.parentDtmfCollector);
|
||||
if (this.epOther) this._installDtmfDetection(cs, this.epOther, this.parentDtmfCollector);
|
||||
await this._attemptCalls(cs);
|
||||
await this.awaitTaskDone();
|
||||
|
||||
this.logger.debug({callSid: this.cs.callSid}, 'Dial:exec task is done, sending actionHook if any');
|
||||
await this.performAction(this.results);
|
||||
this._removeDtmfDetection(cs, this.epOther);
|
||||
if (this.epOther) this._removeDtmfDetection(cs, this.epOther);
|
||||
this._removeDtmfDetection(cs, this.ep);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'TaskDial:exec terminating with error');
|
||||
@@ -151,10 +149,8 @@ class TaskDial extends Task {
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
this._removeDtmfDetection(this.cs, this.epOther);
|
||||
if (this.epOther) this._removeDtmfDetection(this.cs, this.epOther);
|
||||
this._removeDtmfDetection(this.cs, this.ep);
|
||||
this.logger.debug({callSid: this.cs.callSid}, 'Dial:kill removed dtmf listeners');
|
||||
|
||||
this._killOutdials();
|
||||
if (this.sd) {
|
||||
this.sd.kill();
|
||||
@@ -183,7 +179,7 @@ class TaskDial extends Task {
|
||||
await task.exec(cs, callSid === this.callSid ? this.ep : this.epOther);
|
||||
}
|
||||
this.logger.debug('Dial:whisper tasks complete');
|
||||
if (!cs.callGone) this.epOther.bridge(this.ep);
|
||||
if (!cs.callGone && this.epOther) this.epOther.bridge(this.ep);
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Dial:whisper error');
|
||||
}
|
||||
@@ -221,7 +217,6 @@ class TaskDial extends Task {
|
||||
_removeDtmfDetection(cs, ep) {
|
||||
if (ep) {
|
||||
delete ep.dtmfDetector;
|
||||
this.logger.debug(`Dial:_removeDtmfDetection endpoint ${ep.uuid}`);
|
||||
ep.removeAllListeners('dtmf');
|
||||
}
|
||||
}
|
||||
@@ -238,7 +233,7 @@ class TaskDial extends Task {
|
||||
this.logger.info(`Dial:_onDtmf got digits on B leg after adulting: ${evt.dtmf}`);
|
||||
}
|
||||
else {
|
||||
requestor.request(this.dtmfHook, Object.assign({dtmf: match}, cs.callInfo))
|
||||
requestor.request(this.dtmfHook, {dtmf: match, ...cs.callInfo})
|
||||
.catch((err) => this.logger.info(err, 'Dial:_onDtmf - error'));
|
||||
}
|
||||
}
|
||||
@@ -250,6 +245,9 @@ class TaskDial extends Task {
|
||||
this.epOther = ep;
|
||||
debug(`Dial:__initializeInbound allocated ep for incoming call: ${ep.uuid}`);
|
||||
|
||||
/* send outbound legs back to the same SBC (to support static IP feature) */
|
||||
if (!this.proxy) this.proxy = `${cs.req.source_address}:${cs.req.source_port};transport=tcp`;
|
||||
|
||||
if (this.dialMusic) {
|
||||
// play dial music to caller while we outdial
|
||||
ep.play(this.dialMusic).catch((err) => {
|
||||
@@ -272,7 +270,10 @@ class TaskDial extends Task {
|
||||
proxy: `sip:${sbcAddress}`,
|
||||
callingNumber: this.callerId || req.callingNumber
|
||||
};
|
||||
Object.assign(opts.headers, {'X-Account-Sid': cs.accountSid});
|
||||
opts.headers = {
|
||||
...opts.headers,
|
||||
'X-Account-Sid': cs.accountSid
|
||||
};
|
||||
|
||||
const t = this.target.find((t) => t.type === 'teams');
|
||||
if (t) {
|
||||
@@ -387,8 +388,10 @@ class TaskDial extends Task {
|
||||
_connectSingleDial(cs, sd) {
|
||||
if (!this.bridged) {
|
||||
this.logger.debug('Dial:_connectSingleDial bridging endpoints');
|
||||
this.epOther.api('uuid_break', this.epOther.uuid);
|
||||
this.epOther.bridge(sd.ep);
|
||||
if (this.epOther) {
|
||||
this.epOther.api('uuid_break', this.epOther.uuid);
|
||||
this.epOther.bridge(sd.ep);
|
||||
}
|
||||
this.bridged = true;
|
||||
}
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ class Dialogflow extends Task {
|
||||
this.language = cs.speechSynthesisLanguage;
|
||||
this.voice = cs.speechSynthesisVoice;
|
||||
}
|
||||
this.ttsCredentials = cs.getSpeechCredentials(this.vendor, 'tts');
|
||||
|
||||
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));
|
||||
@@ -211,11 +213,13 @@ class Dialogflow extends Task {
|
||||
vendor: this.vendor,
|
||||
language: this.language,
|
||||
voice: this.voice,
|
||||
salt: cs.callSid
|
||||
salt: cs.callSid,
|
||||
credentials: this.ttsCredentials
|
||||
};
|
||||
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via tts');
|
||||
const {filePath} = await synthAudio(obj);
|
||||
const {filePath, servedFromCache} = await synthAudio(obj);
|
||||
if (filePath) cs.trackTmpFile(filePath);
|
||||
if (!this.ttsCredentials && !servedFromCache) cs.billForTts(intent.fulfillmentText.length);
|
||||
|
||||
if (this.playInProgress) {
|
||||
await ep.api('uuid_break', ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
|
||||
@@ -51,9 +51,23 @@ class TaskGather extends Task {
|
||||
|
||||
async exec(cs, ep) {
|
||||
await super.exec(cs);
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
||||
|
||||
this.ep = ep;
|
||||
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) {
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
this.logger.info(`TaskGather:exec - ERROR stt using ${this.vendor} requested but not creds 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 speech-to-text service credentials for ${this.vendor} have been configured`);
|
||||
}
|
||||
|
||||
try {
|
||||
if (this.sayTask) {
|
||||
@@ -71,8 +85,10 @@ class TaskGather extends Task {
|
||||
else this._startTimer();
|
||||
|
||||
if (this.input.includes('speech')) {
|
||||
await this._initSpeech(ep);
|
||||
await this._initSpeech(cs, ep);
|
||||
this._startTranscribing(ep);
|
||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
|
||||
.catch(() => {/*already logged error */});
|
||||
}
|
||||
|
||||
if (this.input.includes('digits')) {
|
||||
@@ -105,10 +121,11 @@ class TaskGather extends Task {
|
||||
this._killAudio();
|
||||
}
|
||||
|
||||
async _initSpeech(ep) {
|
||||
async _initSpeech(cs, ep) {
|
||||
const opts = {};
|
||||
|
||||
if ('google' === this.vendor) {
|
||||
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
|
||||
Object.assign(opts, {
|
||||
GOOGLE_SPEECH_USE_ENHANCED: true,
|
||||
GOOGLE_SPEECH_SINGLE_UTTERANCE: true,
|
||||
@@ -121,6 +138,8 @@ class TaskGather extends Task {
|
||||
if (this.profanityFilter === true) {
|
||||
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
|
||||
}
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, cs, ep));
|
||||
}
|
||||
else {
|
||||
if (this.vocabularyName) opts.AWS_VOCABULARY_NAME = this.vocabularyName;
|
||||
@@ -129,18 +148,16 @@ class TaskGather extends Task {
|
||||
opts.AWS_VOCABULARY_FILTER_METHOD = this.filterMethod || 'mask';
|
||||
}
|
||||
Object.assign(opts, {
|
||||
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
AWS_REGION: process.env.AWS_REGION
|
||||
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));
|
||||
}
|
||||
this.logger.debug(`setting freeswitch vars ${JSON.stringify(opts)}`);
|
||||
this.logger.debug({vars: opts}, 'setting freeswitch vars');
|
||||
await ep.set(opts)
|
||||
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
|
||||
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, ep));
|
||||
}
|
||||
|
||||
_startTranscribing(ep) {
|
||||
@@ -148,7 +165,16 @@ class TaskGather extends Task {
|
||||
vendor: this.vendor,
|
||||
locale: this.language,
|
||||
interim: this.partialResultCallback ? true : false,
|
||||
}).catch((err) => this.logger.error(err, 'TaskGather:_startTranscribing error'));
|
||||
}).catch((err) => {
|
||||
const {writeAlerts, AlertType} = this.cs.srf.locals;
|
||||
this.logger.error(err, 'TaskGather:_startTranscribing error');
|
||||
writeAlerts({
|
||||
account_sid: this.cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
vendor: this.vendor,
|
||||
detail: err.message
|
||||
});
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
||||
}
|
||||
|
||||
_startTimer() {
|
||||
@@ -175,19 +201,23 @@ class TaskGather extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
_onTranscription(ep, evt) {
|
||||
_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 (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'));
|
||||
}
|
||||
}
|
||||
_onEndOfUtterance(ep, evt) {
|
||||
this.logger.info(evt, 'TaskGather:_onEndOfUtterance');
|
||||
this._startTranscribing(ep);
|
||||
_onEndOfUtterance(cs, ep) {
|
||||
this.logger.info('TaskGather:_onEndOfUtterance');
|
||||
if (!this.resolved && !this.killed) {
|
||||
this._startTranscribing(ep);
|
||||
}
|
||||
}
|
||||
|
||||
async _resolve(reason, evt) {
|
||||
@@ -202,13 +232,10 @@ class TaskGather extends Task {
|
||||
|
||||
this._clearTimer();
|
||||
if (reason.startsWith('dtmf')) {
|
||||
await this.performAction({reason: 'dtmfDetected', digits: this.digitBuffer});
|
||||
await this.performAction({digits: this.digitBuffer});
|
||||
}
|
||||
else if (reason.startsWith('speech')) {
|
||||
await this.performAction({reason: 'speechDetected', speech: evt});
|
||||
}
|
||||
else if (reason.startsWith('timeout')) {
|
||||
await this.performAction({reason: 'inputTimeout'});
|
||||
await this.performAction({speech: evt});
|
||||
}
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
@@ -103,6 +103,8 @@ class Lex extends Task {
|
||||
this.language = cs.speechSynthesisLanguage;
|
||||
this.voice = cs.speechSynthesisVoice;
|
||||
}
|
||||
this.ttsCredentials = cs.getSpeechCredentials(this.vendor, 'tts');
|
||||
|
||||
this.ep.addCustomEventListener('lex::intent', this._onIntent.bind(this, ep, cs));
|
||||
this.ep.addCustomEventListener('lex::transcription', this._onTranscription.bind(this, ep, cs));
|
||||
this.ep.addCustomEventListener('lex::audio_provided', this._onAudioProvided.bind(this, ep, cs));
|
||||
@@ -184,20 +186,23 @@ class Lex extends Task {
|
||||
|
||||
try {
|
||||
this.logger.debug(`tts with ${this.vendor} ${this.voice}`);
|
||||
const {filepath} = await synthAudio({
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {filePath, servedFromCache} = await synthAudio({
|
||||
text: msg,
|
||||
vendor: this.vendor,
|
||||
language: this.language,
|
||||
voice: this.voice,
|
||||
salt: cs.callSid
|
||||
salt: cs.callSid,
|
||||
credentials: this.ttsCredentials
|
||||
});
|
||||
if (filepath) cs.trackTmpFile(filepath);
|
||||
if (filePath) cs.trackTmpFile(filePath);
|
||||
|
||||
if (this.events.includes('start-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: filepath}});
|
||||
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: filePath}});
|
||||
}
|
||||
await ep.play(filepath);
|
||||
await ep.play(filePath);
|
||||
if (this.events.includes('stop-play')) {
|
||||
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: filepath}});
|
||||
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: filePath}});
|
||||
}
|
||||
this.logger.debug(`finished tts, sending play_done ${this.vendor} ${this.voice}`);
|
||||
this.ep.api('aws_lex_play_done', this.ep.uuid)
|
||||
|
||||
@@ -8,6 +8,7 @@ class TaskMessage extends Task {
|
||||
this.preconditions = TaskPreconditions.None;
|
||||
|
||||
this.payload = {
|
||||
message_sid: this.data.message_sid,
|
||||
provider: this.data.provider,
|
||||
to: this.data.to,
|
||||
from: this.data.from,
|
||||
@@ -23,21 +24,50 @@ class TaskMessage extends Task {
|
||||
/**
|
||||
* Send outbound SMS
|
||||
*/
|
||||
async exec(cs, dlg) {
|
||||
const {srf} = cs;
|
||||
async exec(cs) {
|
||||
const {srf, accountSid} = cs;
|
||||
const {res} = cs.callInfo;
|
||||
let payload = this.payload;
|
||||
await super.exec(cs);
|
||||
try {
|
||||
const {getSBC} = srf.locals;
|
||||
const sbcAddress = getSBC();
|
||||
if (sbcAddress) {
|
||||
const url = `http://${sbcAddress}:3000/`;
|
||||
const post = bent(url, 'POST', 'json', 200);
|
||||
this.logger.info({payload: this.payload, sbcAddress}, 'Message:exec sending outbound SMS');
|
||||
const response = await post('v1/outboundSMS', this.payload);
|
||||
const {getSBC, getSmpp, dbHelpers} = srf.locals;
|
||||
const {lookupSmppGateways} = dbHelpers;
|
||||
|
||||
this.logger.info(`looking up gateways for account_sid: ${accountSid}`);
|
||||
const r = await lookupSmppGateways(accountSid);
|
||||
let gw, url, relativeUrl;
|
||||
if (r.length > 0) {
|
||||
if (this.payload.provider) gw = r.find((o) => o.vc.name === this.payload.provider);
|
||||
else gw = r[0];
|
||||
}
|
||||
if (gw) {
|
||||
this.logger.info({gw, accountSid}, 'Message:exec - using smpp to send message');
|
||||
url = getSmpp();
|
||||
relativeUrl = '/sms';
|
||||
payload = {
|
||||
...payload,
|
||||
...gw.sg,
|
||||
...gw.vc
|
||||
};
|
||||
}
|
||||
else {
|
||||
this.logger.info({gw, accountSid, provider: this.payload.provider},
|
||||
'Message:exec - no smpp gateways found to send message');
|
||||
relativeUrl = 'v1/outboundSMS';
|
||||
const sbcAddress = getSBC();
|
||||
if (sbcAddress) url = `http://${sbcAddress}:3000/`;
|
||||
|
||||
//TMP: smpp only at the moment, need to add http back in
|
||||
return res.sendStatus(404);
|
||||
}
|
||||
if (url) {
|
||||
const post = bent(url, 'POST', 'json', 201);
|
||||
this.logger.info({payload, url}, 'Message:exec sending outbound SMS');
|
||||
const response = await post(relativeUrl, payload);
|
||||
this.logger.info({response}, 'Successfully sent SMS');
|
||||
if (cs.callInfo.res) {
|
||||
this.logger.info('Message:exec sending 200 OK response to HTTP POST from api server');
|
||||
cs.callInfo.res.status(200).json({
|
||||
res.status(200).json({
|
||||
sid: cs.callInfo.messageSid,
|
||||
providerResponse: response
|
||||
});
|
||||
@@ -46,10 +76,12 @@ class TaskMessage extends Task {
|
||||
// TODO: action Hook
|
||||
}
|
||||
else {
|
||||
this.logger.info('Message:exec - unable to send SMS as there are no available SBCs');
|
||||
this.logger.info('Message:exec - unable to send SMS as there are no available SMS gateways');
|
||||
res.status(422).json({message: 'no configured SMS gateways'});
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'TaskMessage:exec - Error sending SMS');
|
||||
res.status(422).json({message: 'no configured SMS gateways'});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,34 +15,64 @@ class TaskSay extends Task {
|
||||
get name() { return TaskName.Say; }
|
||||
|
||||
async exec(cs, ep) {
|
||||
const {srf} = cs;
|
||||
const {synthAudio} = srf.locals.dbHelpers;
|
||||
await super.exec(cs);
|
||||
|
||||
const {srf} = cs;
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, srf);
|
||||
const {writeAlerts, AlertType} = srf.locals;
|
||||
const {synthAudio} = srf.locals.dbHelpers;
|
||||
const vendor = this.synthesizer.vendor || cs.speechSynthesisVendor;
|
||||
const language = this.synthesizer.language || cs.speechSynthesisLanguage;
|
||||
const voice = this.synthesizer.voice || cs.speechSynthesisVoice;
|
||||
const salt = cs.callSid;
|
||||
const credentials = cs.getSpeechCredentials(vendor, 'tts');
|
||||
|
||||
this.ep = ep;
|
||||
try {
|
||||
if (!credentials) {
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.TTS_NOT_PROVISIONED,
|
||||
vendor
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
||||
throw new Error('no provisioned speech credentials for TTS');
|
||||
}
|
||||
// synthesize all of the text elements
|
||||
const files = (await Promise.all(this.text.map(async(text) => {
|
||||
const {filePath} = await synthAudio({
|
||||
let lastUpdated = false;
|
||||
const filepath = (await Promise.all(this.text.map(async(text) => {
|
||||
const {filePath, servedFromCache} = 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'));
|
||||
vendor,
|
||||
language,
|
||||
voice,
|
||||
salt,
|
||||
credentials
|
||||
}).catch((err) => {
|
||||
this.logger.info(err, 'Error synthesizing tts');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.TTS_NOT_PROVISIONED,
|
||||
vendor,
|
||||
detail: err.message
|
||||
});
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
||||
this.logger.debug(`file ${filePath}, served from cache ${servedFromCache}`);
|
||||
if (filePath) cs.trackTmpFile(filePath);
|
||||
if (!servedFromCache && !lastUpdated) {
|
||||
lastUpdated = true;
|
||||
updateSpeechCredentialLastUsed(credentials.speech_credential_sid)
|
||||
.catch(() => {/*already logged error */});
|
||||
}
|
||||
return filePath;
|
||||
})))
|
||||
.filter((fp) => fp && fp.length);
|
||||
}))).filter((fp) => fp && fp.length);
|
||||
|
||||
this.logger.debug({files, loop: this.loop}, 'synthesized files for tts');
|
||||
if (!this.ep.connected) this.logger.debug('say: endpoint is not connected!');
|
||||
this.logger.debug({filepath}, 'synthesized files for tts');
|
||||
|
||||
while (!this.killed && this.loop-- && this.ep.connected) {
|
||||
let segment = 0;
|
||||
do {
|
||||
this.logger.debug(`playing file ${files[segment]}`);
|
||||
await ep.play(files[segment]);
|
||||
} while (!this.killed && ++segment < files.length);
|
||||
await ep.play(filepath[segment]);
|
||||
} while (!this.killed && ++segment < filepath.length);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskSay:exec error');
|
||||
|
||||
@@ -166,10 +166,6 @@
|
||||
"passDtmf": "boolean",
|
||||
"actionHook": "object|string",
|
||||
"eventHook": "object|string",
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"enum": ["lex", "tts"]
|
||||
},
|
||||
"noInputTimeout": "number",
|
||||
"tts": "#synthesizer"
|
||||
},
|
||||
@@ -177,7 +173,7 @@
|
||||
"botId",
|
||||
"botAlias",
|
||||
"region",
|
||||
"prompt"
|
||||
"credentials"
|
||||
]
|
||||
},
|
||||
"listen": {
|
||||
@@ -206,7 +202,9 @@
|
||||
},
|
||||
"message": {
|
||||
"properties": {
|
||||
"provider": "string",
|
||||
"carrier": "string",
|
||||
"account_sid": "string",
|
||||
"message_sid": "string",
|
||||
"to": "string",
|
||||
"from": "string",
|
||||
"text": "string",
|
||||
@@ -292,8 +290,7 @@
|
||||
"sipUri": "string",
|
||||
"auth": "#auth",
|
||||
"vmail": "boolean",
|
||||
"tenant": "string",
|
||||
"overrideTo": "string"
|
||||
"tenant": "string"
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
|
||||
@@ -200,6 +200,7 @@ class Task extends Emitter {
|
||||
}
|
||||
if (required.length > 0) throw new Error(`${name}: missing value for ${required}`);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Task;
|
||||
|
||||
@@ -44,11 +44,22 @@ class TaskTranscribe extends Task {
|
||||
|
||||
async exec(cs, ep, parentTask) {
|
||||
super.exec(cs);
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
||||
|
||||
this.ep = ep;
|
||||
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');
|
||||
|
||||
try {
|
||||
await this._startTranscribing(ep);
|
||||
if (!this.sttCredentials) {
|
||||
// TODO: generate alert (actually should be done by cs.getSpeechCredentials)
|
||||
throw new Error('no provisioned speech credentials for TTS');
|
||||
}
|
||||
await this._startTranscribing(cs, ep);
|
||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
|
||||
.catch(() => {/*already logged error */});
|
||||
|
||||
await this.awaitTaskDone();
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskTranscribe:exec - error');
|
||||
@@ -74,19 +85,20 @@ class TaskTranscribe extends Task {
|
||||
await this.awaitTaskDone();
|
||||
}
|
||||
|
||||
async _startTranscribing(ep) {
|
||||
async _startTranscribing(cs, ep) {
|
||||
const opts = {};
|
||||
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, ep));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.MaxDurationExceeded,
|
||||
this._onMaxDurationExceeded.bind(this, ep));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, ep));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, cs, ep));
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.MaxDurationExceeded,
|
||||
this._onMaxDurationExceeded.bind(this, ep));
|
||||
this._onMaxDurationExceeded.bind(this, cs, ep));
|
||||
|
||||
if (this.vendor === 'google') {
|
||||
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
|
||||
[
|
||||
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
|
||||
['separateRecognitionPerChannel', 'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL'],
|
||||
@@ -134,11 +146,20 @@ class TaskTranscribe extends Task {
|
||||
opts.AWS_VOCABULARY_FILTER_METHOD = this.filterMethod || 'mask';
|
||||
}
|
||||
|
||||
Object.assign(opts, {
|
||||
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
AWS_REGION: process.env.AWS_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
|
||||
});
|
||||
}
|
||||
else {
|
||||
Object.assign(opts, {
|
||||
AWS_ACCESS_KEY_ID: process.env.AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY: process.env.AWS_SECRET_ACCESS_KEY,
|
||||
AWS_REGION: process.env.AWS_REGION
|
||||
});
|
||||
}
|
||||
|
||||
await ep.set(opts)
|
||||
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with aws'));
|
||||
@@ -155,8 +176,10 @@ class TaskTranscribe extends Task {
|
||||
});
|
||||
}
|
||||
|
||||
_onTranscription(ep, evt) {
|
||||
_onTranscription(cs, ep, evt) {
|
||||
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
|
||||
this.logger.debug(evt, 'TaskTranscribe:_onTranscription');
|
||||
|
||||
this.cs.requestor.request(this.transcriptionHook, Object.assign({speech: evt}, this.cs.callInfo))
|
||||
.catch((err) => this.logger.info(err, 'TranscribeTask:_onTranscription error'));
|
||||
if (this.killed) {
|
||||
@@ -166,12 +189,12 @@ class TaskTranscribe extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
_onNoAudio(ep) {
|
||||
_onNoAudio(cs, ep) {
|
||||
this.logger.debug('TaskTranscribe:_onNoAudio restarting transcription');
|
||||
this._transcribe(ep);
|
||||
}
|
||||
|
||||
_onMaxDurationExceeded(ep) {
|
||||
_onMaxDurationExceeded(cs, ep) {
|
||||
this.logger.debug('TaskTranscribe:_onMaxDurationExceeded restarting transcription');
|
||||
this._transcribe(ep);
|
||||
}
|
||||
|
||||
74
lib/utils/db-utils.js
Normal file
74
lib/utils/db-utils.js
Normal file
@@ -0,0 +1,74 @@
|
||||
const {decrypt} = require('./encrypt-decrypt');
|
||||
|
||||
const sqlAccountDetails = `SELECT *
|
||||
FROM accounts account
|
||||
WHERE account.account_sid = ?`;
|
||||
const sqlSpeechCredentials = `SELECT *
|
||||
FROM speech_credentials
|
||||
WHERE account_sid = ? `;
|
||||
const sqlSpeechCredentialsForSP = `SELECT *
|
||||
FROM speech_credentials
|
||||
WHERE service_provider_sid =
|
||||
(SELECT service_provider_sid from accounts where account_sid = ?)`;
|
||||
|
||||
const speechMapper = (cred) => {
|
||||
const {credential, ...obj} = cred;
|
||||
if ('google' === obj.vendor) {
|
||||
obj.service_key = decrypt(credential);
|
||||
}
|
||||
else if ('aws' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.access_key_id = o.access_key_id;
|
||||
obj.secret_access_key = o.secret_access_key;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
module.exports = (logger, srf) => {
|
||||
const {pool} = srf.locals.dbHelpers;
|
||||
const pp = pool.promise();
|
||||
|
||||
const lookupAccountDetails = async(account_sid) => {
|
||||
const [r] = await pp.query({sql: sqlAccountDetails, nestTables: true}, account_sid);
|
||||
if (0 === r.length) throw new Error(`invalid accountSid: ${account_sid}`);
|
||||
const [r2] = await pp.query(sqlSpeechCredentials, account_sid);
|
||||
const speech = r2.map(speechMapper);
|
||||
|
||||
/* 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 [r3] = await pp.query(sqlSpeechCredentialsForSP, account_sid);
|
||||
if (r3.length) {
|
||||
if (!haveGoogle) {
|
||||
const google = r3.find((s) => s.vendor === 'google');
|
||||
if (google) speech.push(speechMapper(google));
|
||||
}
|
||||
if (!haveAws) {
|
||||
const aws = r3.find((s) => s.vendor === 'aws');
|
||||
if (aws) speech.push(speechMapper(aws));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...r[0],
|
||||
speech
|
||||
};
|
||||
};
|
||||
|
||||
const updateSpeechCredentialLastUsed = async(speech_credential_sid) => {
|
||||
const pp = pool.promise();
|
||||
const sql = 'UPDATE speech_credentials SET last_used = NOW() WHERE speech_credential_sid = ?';
|
||||
try {
|
||||
await pp.execute(sql, [speech_credential_sid]);
|
||||
} catch (err) {
|
||||
logger.error({err}, `Error updating last_used for speech_credential_sid ${speech_credential_sid}`);
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
lookupAccountDetails,
|
||||
updateSpeechCredentialLastUsed
|
||||
};
|
||||
};
|
||||
35
lib/utils/encrypt-decrypt.js
Normal file
35
lib/utils/encrypt-decrypt.js
Normal file
@@ -0,0 +1,35 @@
|
||||
const crypto = require('crypto');
|
||||
const algorithm = 'aes-256-ctr';
|
||||
const iv = crypto.randomBytes(16);
|
||||
const secretKey = crypto.createHash('sha256')
|
||||
.update(String(process.env.JWT_SECRET))
|
||||
.digest('base64')
|
||||
.substr(0, 32);
|
||||
|
||||
const encrypt = (text) => {
|
||||
const cipher = crypto.createCipheriv(algorithm, secretKey, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(text), cipher.final()]);
|
||||
const data = {
|
||||
iv: iv.toString('hex'),
|
||||
content: encrypted.toString('hex')
|
||||
};
|
||||
return JSON.stringify(data);
|
||||
};
|
||||
|
||||
const decrypt = (data) => {
|
||||
let hash;
|
||||
try {
|
||||
hash = JSON.parse(data);
|
||||
} catch (err) {
|
||||
console.log(`failed to parse json string ${data}`);
|
||||
throw err;
|
||||
}
|
||||
const decipher = crypto.createDecipheriv(algorithm, secretKey, Buffer.from(hash.iv, 'hex'));
|
||||
const decrpyted = Buffer.concat([decipher.update(Buffer.from(hash.content, 'hex')), decipher.final()]);
|
||||
return decrpyted.toString();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
encrypt,
|
||||
decrypt
|
||||
};
|
||||
@@ -38,9 +38,12 @@ function installSrfLocals(srf, logger) {
|
||||
const fsInventory = process.env.JAMBONES_FREESWITCH
|
||||
.split(',')
|
||||
.map((fs) => {
|
||||
const arr = /^(.*):(.*):(.*)/.exec(fs);
|
||||
const arr = /^([^:]*):([^:]*):([^:]*)(?::([^:]*))?/.exec(fs);
|
||||
assert.ok(arr, `Invalid syntax JAMBONES_FREESWITCH: ${process.env.JAMBONES_FREESWITCH}`);
|
||||
return {address: arr[1], port: arr[2], secret: arr[3]};
|
||||
const opts = {address: arr[1], port: arr[2], secret: arr[3]};
|
||||
if (arr.length > 4) opts.advertisedAddress = arr[4];
|
||||
if (process.env.NODE_ENV === 'test') opts.listenAddress = '0.0.0.0';
|
||||
return opts;
|
||||
});
|
||||
logger.info({fsInventory}, 'freeswitch inventory');
|
||||
|
||||
@@ -72,6 +75,7 @@ function installSrfLocals(srf, logger) {
|
||||
|
||||
// if we have a single freeswitch (as is typical) report stats periodically
|
||||
if (mediaservers.length === 1) {
|
||||
srf.locals.mediaservers = [mediaservers[0].ms];
|
||||
setInterval(() => {
|
||||
try {
|
||||
if (mediaservers[0].ms && mediaservers[0].active) {
|
||||
@@ -99,20 +103,25 @@ function installSrfLocals(srf, logger) {
|
||||
}
|
||||
|
||||
const {
|
||||
pool,
|
||||
lookupAppByPhoneNumber,
|
||||
lookupAppBySid,
|
||||
lookupAppByRealm,
|
||||
lookupAppByTeamsTenant,
|
||||
lookupTeamsByAccount,
|
||||
lookupAccountBySid
|
||||
lookupAccountBySid,
|
||||
lookupAccountCapacitiesBySid,
|
||||
lookupSmppGateways
|
||||
} = require('@jambonz/db-helpers')({
|
||||
host: process.env.JAMBONES_MYSQL_HOST,
|
||||
user: process.env.JAMBONES_MYSQL_USER,
|
||||
port: process.env.JAMBONES_MYSQL_PORT || 3306,
|
||||
password: process.env.JAMBONES_MYSQL_PASSWORD,
|
||||
database: process.env.JAMBONES_MYSQL_DATABASE,
|
||||
connectionLimit: process.env.JAMBONES_MYSQL_CONNECTION_LIMIT || 10
|
||||
}, logger);
|
||||
const {
|
||||
client,
|
||||
updateCallStatus,
|
||||
retrieveCall,
|
||||
listCalls,
|
||||
@@ -135,15 +144,27 @@ function installSrfLocals(srf, logger) {
|
||||
host: process.env.JAMBONES_REDIS_HOST,
|
||||
port: process.env.JAMBONES_REDIS_PORT || 6379
|
||||
}, logger);
|
||||
const {
|
||||
writeAlerts,
|
||||
AlertType
|
||||
} = require('@jambonz/time-series')(logger, {
|
||||
host: process.env.JAMBONES_TIME_SERIES_HOST,
|
||||
commitSize: 50,
|
||||
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20
|
||||
});
|
||||
|
||||
Object.assign(srf.locals, {
|
||||
srf.locals = {...srf.locals,
|
||||
dbHelpers: {
|
||||
client,
|
||||
pool,
|
||||
lookupAppByPhoneNumber,
|
||||
lookupAppBySid,
|
||||
lookupAppByRealm,
|
||||
lookupAppByTeamsTenant,
|
||||
lookupTeamsByAccount,
|
||||
lookupAccountBySid,
|
||||
lookupAccountCapacitiesBySid,
|
||||
lookupSmppGateways,
|
||||
updateCallStatus,
|
||||
retrieveCall,
|
||||
listCalls,
|
||||
@@ -167,10 +188,15 @@ function installSrfLocals(srf, logger) {
|
||||
ipv4: localIp,
|
||||
serviceUrl: `http://${localIp}:${PORT}`,
|
||||
getSBC,
|
||||
getSmpp: () => {
|
||||
return process.env.SMPP_URL;
|
||||
},
|
||||
lifecycleEmitter,
|
||||
getFreeswitch,
|
||||
stats: stats
|
||||
});
|
||||
stats: stats,
|
||||
writeAlerts,
|
||||
AlertType
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = installSrfLocals;
|
||||
|
||||
@@ -61,6 +61,7 @@ class SingleDialer extends Emitter {
|
||||
async exec(srf, ms, opts) {
|
||||
opts = opts || {};
|
||||
opts.headers = opts.headers || {};
|
||||
opts.headers = {...opts.headers, 'X-Call-Sid': this.callSid};
|
||||
let uri, to;
|
||||
try {
|
||||
switch (this.target.type) {
|
||||
@@ -71,10 +72,10 @@ class SingleDialer extends Emitter {
|
||||
to = this.target.number;
|
||||
if ('teams' === this.target.type) {
|
||||
assert(this.target.teamsInfo);
|
||||
Object.assign(opts.headers, {
|
||||
opts.headers = {...opts.headers,
|
||||
'X-MS-Teams-FQDN': this.target.teamsInfo.ms_teams_fqdn,
|
||||
'X-MS-Teams-Tenant-FQDN': this.target.teamsInfo.tenant_fqdn
|
||||
});
|
||||
};
|
||||
if (this.target.vmail === true) uri = `${uri};opaque=app:voicemail`;
|
||||
}
|
||||
break;
|
||||
@@ -84,12 +85,6 @@ 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) {
|
||||
@@ -267,7 +262,7 @@ class SingleDialer extends Emitter {
|
||||
// now execute it in a new ConfirmCallSession
|
||||
this.logger.debug(`SingleDialer:_executeApp: executing ${tasks.length} tasks`);
|
||||
const cs = new ConfirmCallSession({
|
||||
logger: this.baseLogger,
|
||||
logger: this.logger,
|
||||
application: this.application,
|
||||
dlg: this.dlg,
|
||||
ep: this.ep,
|
||||
|
||||
@@ -2,8 +2,30 @@ const bent = require('bent');
|
||||
const parseUrl = require('parse-url');
|
||||
const assert = require('assert');
|
||||
const snakeCaseKeys = require('./snakecase-keys');
|
||||
const crypto = require('crypto');
|
||||
const timeSeries = require('@jambonz/time-series');
|
||||
let alerter ;
|
||||
|
||||
const toBase64 = (str) => Buffer.from(str || '', 'utf8').toString('base64');
|
||||
|
||||
function computeSignature(payload, timestamp, secret) {
|
||||
assert(secret);
|
||||
const data = `${timestamp}.${JSON.stringify(payload)}`;
|
||||
return crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(data, 'utf8')
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
function generateSigHeader(payload, secret) {
|
||||
const timestamp = Math.floor(Date.now() / 1000);
|
||||
const signature = computeSignature(payload, timestamp, secret);
|
||||
const scheme = 'v1';
|
||||
return {
|
||||
'Jambonz-Signature': `t=${timestamp},${scheme}=${signature}`
|
||||
};
|
||||
}
|
||||
|
||||
function basicAuth(username, password) {
|
||||
if (!username || !password) return {};
|
||||
const creds = `${username}:${password || ''}`;
|
||||
@@ -21,7 +43,7 @@ function isAbsoluteUrl(u) {
|
||||
}
|
||||
|
||||
class Requestor {
|
||||
constructor(logger, hook) {
|
||||
constructor(logger, account_sid, hook, secret) {
|
||||
assert(typeof hook === 'object');
|
||||
|
||||
this.logger = logger;
|
||||
@@ -38,12 +60,22 @@ class Requestor {
|
||||
|
||||
this.username = hook.username;
|
||||
this.password = hook.password;
|
||||
this.secret = secret;
|
||||
this.account_sid = account_sid;
|
||||
|
||||
assert(isAbsoluteUrl(this.url));
|
||||
assert(['GET', 'POST'].includes(this.method));
|
||||
|
||||
const {stats} = require('../../').srf.locals;
|
||||
this.stats = stats;
|
||||
|
||||
if (!alerter) {
|
||||
alerter = timeSeries(logger, {
|
||||
host: process.env.JAMBONES_TIME_SERIES_HOST,
|
||||
commitSize: 50,
|
||||
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
@@ -65,7 +97,6 @@ class Requestor {
|
||||
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
|
||||
const url = hook.url || hook;
|
||||
const method = hook.method || 'POST';
|
||||
const {username, password} = typeof hook === 'object' ? hook : {};
|
||||
|
||||
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}`);
|
||||
@@ -75,12 +106,27 @@ class Requestor {
|
||||
|
||||
let buf;
|
||||
try {
|
||||
const sigHeader = generateSigHeader(payload, this.secret);
|
||||
const headers = {...sigHeader, ...this.authHeader};
|
||||
this.logger.info({url, headers}, 'send webhook');
|
||||
buf = isRelativeUrl(url) ?
|
||||
await this.post(url, payload, this.authHeader) :
|
||||
await bent(method, 'buffer', 200, 201, 202)(url, payload, basicAuth(username, password));
|
||||
await this.post(url, payload, headers) :
|
||||
await bent(method, 'buffer', 200, 201, 202)(url, payload, headers);
|
||||
} catch (err) {
|
||||
this.logger.info({baseUrl: this.baseUrl, url, statusCode: err.statusCode},
|
||||
this.logger.error({err, secret: this.secret, baseUrl: this.baseUrl, url, statusCode: err.statusCode},
|
||||
`web callback returned unexpected error code ${err.statusCode}`);
|
||||
let opts = {account_sid: this.account_sid};
|
||||
if (err.code === 'ECONNREFUSED') {
|
||||
opts = {...opts, alert_type: alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url};
|
||||
}
|
||||
else if (err.name === 'StatusError') {
|
||||
opts = {...opts, alert_type: alerter.AlertType.WEBHOOK_STATUS_FAILURE, url, status: err.statusCode};
|
||||
}
|
||||
else {
|
||||
opts = {...opts, alert_type: alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url, detail: err.message};
|
||||
}
|
||||
alerter.writeAlerts(opts).catch((err) => this.logger.info({err, opts}, 'Error writing alert'));
|
||||
|
||||
throw err;
|
||||
}
|
||||
const diff = process.hrtime(startAt);
|
||||
|
||||
@@ -68,6 +68,8 @@ module.exports = (logger) => {
|
||||
|
||||
// send OPTIONS pings to SBCs
|
||||
async function pingProxies(srf) {
|
||||
if (process.env.NODE_ENV === 'test') return;
|
||||
|
||||
for (const sbc of sbcs) {
|
||||
try {
|
||||
const ms = srf.locals.getFreeswitch();
|
||||
|
||||
Reference in New Issue
Block a user