mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-02-10 08:21:33 +00:00
Compare commits
48 Commits
v0.7.6-rc9
...
v0.7.7-17
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c728417581 | ||
|
|
8853f84f01 | ||
|
|
665d26b6fb | ||
|
|
d69c773de0 | ||
|
|
21eaa442b2 | ||
|
|
6484086222 | ||
|
|
01645df920 | ||
|
|
b2363b09c1 | ||
|
|
c11d892f0a | ||
|
|
9fd116b05f | ||
|
|
19098aee98 | ||
|
|
d15dbf7f5a | ||
|
|
824f983955 | ||
|
|
7c76bc52f6 | ||
|
|
bfc8a99950 | ||
|
|
9097c6d6ac | ||
|
|
15b2fdd5a8 | ||
|
|
979e17c814 | ||
|
|
70caf00dd1 | ||
|
|
f044cdd150 | ||
|
|
c3d39f0970 | ||
|
|
9c69a2c79f | ||
|
|
e0607b9c2e | ||
|
|
dc378cd065 | ||
|
|
138950c534 | ||
|
|
215a28b615 | ||
|
|
3a5efa37b9 | ||
|
|
917b8f332c | ||
|
|
17848ea22c | ||
|
|
43af27e802 | ||
|
|
b25f92e17a | ||
|
|
90cb5e1348 | ||
|
|
cf821569b3 | ||
|
|
218f2d6c67 | ||
|
|
c2c8f00978 | ||
|
|
32714d73f3 | ||
|
|
8da85ebd5a | ||
|
|
dcedf68264 | ||
|
|
05c5d2211f | ||
|
|
0c089e2380 | ||
|
|
099f33857c | ||
|
|
bd49dacac4 | ||
|
|
876824abde | ||
|
|
468a9e6d6b | ||
|
|
c88163fe11 | ||
|
|
bf7ece8f17 | ||
|
|
e90ef6bc70 | ||
|
|
a59f6097d7 |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-node@v1
|
||||
with:
|
||||
node-version: 14
|
||||
node-version: 16
|
||||
- run: npm ci
|
||||
- run: npm run jslint
|
||||
- run: docker pull drachtio/sipp
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM --platform=linux/amd64 node:18.6.0-alpine as base
|
||||
FROM --platform=linux/amd64 node:18.12.1-alpine3.16 as base
|
||||
|
||||
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
|
||||
|
||||
|
||||
47
app.js
47
app.js
@@ -15,7 +15,6 @@ const tracer = require('./tracer')(process.env.JAMBONES_OTEL_SERVICE_NAME || 'ja
|
||||
const api = require('@opentelemetry/api');
|
||||
srf.locals = {...srf.locals, otel: {tracer, api}};
|
||||
|
||||
const PORT = process.env.HTTP_PORT || 3000;
|
||||
const opts = {level: process.env.JAMBONES_LOGLEVEL || 'info'};
|
||||
const pino = require('pino');
|
||||
const logger = pino(opts, pino.destination({sync: false}));
|
||||
@@ -33,17 +32,6 @@ const {
|
||||
invokeWebCallback
|
||||
} = require('./lib/middleware')(srf, logger);
|
||||
|
||||
// HTTP
|
||||
const express = require('express');
|
||||
const helmet = require('helmet');
|
||||
const app = express();
|
||||
Object.assign(app.locals, {
|
||||
logger,
|
||||
srf
|
||||
});
|
||||
|
||||
const httpRoutes = require('./lib/http-routes');
|
||||
|
||||
const InboundCallSession = require('./lib/session/inbound-call-session');
|
||||
const SipRecCallSession = require('./lib/session/siprec-call-session');
|
||||
|
||||
@@ -82,20 +70,6 @@ srf.invite(async(req, res) => {
|
||||
session.exec();
|
||||
});
|
||||
|
||||
// HTTP
|
||||
app.use(helmet());
|
||||
app.use(helmet.hidePoweredBy());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/', httpRoutes);
|
||||
app.use((err, req, res, next) => {
|
||||
logger.error(err, 'burped error');
|
||||
res.status(err.status || 500).json({msg: err.message});
|
||||
});
|
||||
const httpServer = app.listen(PORT);
|
||||
|
||||
logger.info(`listening for HTTP requests on port ${PORT}, serviceUrl is ${srf.locals.serviceUrl}`);
|
||||
|
||||
const sessionTracker = srf.locals.sessionTracker = require('./lib/session/session-tracker');
|
||||
sessionTracker.on('idle', () => {
|
||||
if (srf.locals.lifecycleEmitter.operationalState === LifeCycleEvents.ScaleIn) {
|
||||
@@ -103,19 +77,30 @@ sessionTracker.on('idle', () => {
|
||||
srf.locals.lifecycleEmitter.scaleIn();
|
||||
}
|
||||
});
|
||||
|
||||
const getCount = () => sessionTracker.count;
|
||||
const healthCheck = require('@jambonz/http-health-check');
|
||||
healthCheck({app, logger, path: '/', fn: getCount});
|
||||
let httpServer;
|
||||
|
||||
const createHttpListener = require('./lib/utils/http-listener');
|
||||
createHttpListener(logger, srf)
|
||||
.then(({server, app}) => {
|
||||
httpServer = server;
|
||||
healthCheck({app, logger, path: '/', fn: getCount});
|
||||
return {server, app};
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(err, 'Error creating http listener');
|
||||
});
|
||||
|
||||
|
||||
setInterval(() => {
|
||||
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
|
||||
}, 5000);
|
||||
}, 20000);
|
||||
|
||||
const disconnect = () => {
|
||||
return new Promise ((resolve) => {
|
||||
httpServer.on('close', resolve);
|
||||
httpServer.close();
|
||||
httpServer?.on('close', resolve);
|
||||
httpServer?.close();
|
||||
srf.disconnect();
|
||||
srf.locals.mediaservers.forEach((ms) => ms.disconnect());
|
||||
});
|
||||
|
||||
@@ -48,5 +48,5 @@
|
||||
"ens posarem en contacto",
|
||||
"ara no estem disponibles",
|
||||
"no hi som"
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ router.post('/', async(req, res) => {
|
||||
'X-Jambonz-Routing': target.type,
|
||||
'X-Jambonz-FS-UUID': srf.locals.fsUUID,
|
||||
'X-Call-Sid': callSid,
|
||||
'X-Account-Sid': accountSid
|
||||
'X-Account-Sid': accountSid,
|
||||
...(restDial.fromHost && {'X-Preferred-From-Host': restDial.fromHost})
|
||||
};
|
||||
|
||||
switch (target.type) {
|
||||
|
||||
@@ -34,6 +34,7 @@ router.post('/:partner', async(req, res) => {
|
||||
carrier: req.params.partner,
|
||||
messageSid: app.messageSid,
|
||||
accountSid: app.accountSid,
|
||||
serviceProviderSid: account.service_provider_sid,
|
||||
applicationSid: app.applicationSid,
|
||||
from: req.body.from,
|
||||
to: req.body.to,
|
||||
|
||||
@@ -118,6 +118,7 @@ module.exports = function(srf, logger) {
|
||||
const {span} = rootSpan.startChildSpan('lookupAccountDetails');
|
||||
try {
|
||||
req.locals.accountInfo = await lookupAccountDetails(account_sid);
|
||||
req.locals.service_provider_sid = req.locals.accountInfo?.account?.service_provider_sid;
|
||||
span.end();
|
||||
if (!req.locals.accountInfo.account.is_active) {
|
||||
logger.info(`Account is inactive or suspended ${account_sid}`);
|
||||
@@ -273,7 +274,9 @@ module.exports = function(srf, logger) {
|
||||
}
|
||||
/* retrieve the application to execute for this inbound call */
|
||||
const params = Object.assign(['POST', 'WS'].includes(app.call_hook.method) ? {sip: req.msg} : {},
|
||||
req.locals.callInfo, {
|
||||
req.locals.callInfo,
|
||||
{service_provider_sid: req.locals.service_provider_sid},
|
||||
{
|
||||
defaults: {
|
||||
synthesizer: {
|
||||
vendor: app.speech_synthesis_vendor,
|
||||
|
||||
@@ -11,6 +11,7 @@ class CallInfo {
|
||||
let srf;
|
||||
this.direction = opts.direction;
|
||||
this.traceId = opts.traceId;
|
||||
this.callTerminationBy = undefined;
|
||||
if (opts.req) {
|
||||
const u = opts.req.getParsedHeader('from');
|
||||
const uri = parseUri(u.uri);
|
||||
@@ -119,7 +120,7 @@ class CallInfo {
|
||||
applicationSid: this.applicationSid,
|
||||
fsSipAddress: this.localSipAddress
|
||||
};
|
||||
['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName'].forEach((prop) => {
|
||||
['parentCallSid', 'originatingSipIp', 'originatingSipTrunkName', 'callTerminationBy'].forEach((prop) => {
|
||||
if (this[prop]) obj[prop] = this[prop];
|
||||
});
|
||||
if (typeof this.duration === 'number') obj.duration = this.duration;
|
||||
|
||||
@@ -286,6 +286,18 @@ class CallSession extends Emitter {
|
||||
return this._globalAltLanguages;
|
||||
}
|
||||
|
||||
set globalSttPunctuation(punctuate) {
|
||||
this._globalSttPunctuation = punctuate;
|
||||
}
|
||||
|
||||
get globalSttPunctuation() {
|
||||
return this._globalSttPunctuation;
|
||||
}
|
||||
|
||||
hasGlobalSttPunctuation() {
|
||||
return this._globalSttPunctuation !== undefined;
|
||||
}
|
||||
|
||||
async notifyRecordOptions(opts) {
|
||||
const {action} = opts;
|
||||
this.logger.debug({opts}, 'CallSession:notifyRecordOptions');
|
||||
@@ -537,7 +549,11 @@ class CallSession extends Emitter {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
api_key: credential.api_key,
|
||||
region: credential.region
|
||||
region: credential.region,
|
||||
use_custom_stt: credential.use_custom_stt,
|
||||
custom_stt_endpoint: credential.custom_stt_endpoint,
|
||||
use_custom_tts: credential.use_custom_tts,
|
||||
custom_tts_endpoint: credential.custom_tts_endpoint
|
||||
};
|
||||
}
|
||||
else if ('wellsaid' === vendor) {
|
||||
@@ -572,13 +588,19 @@ class CallSession extends Emitter {
|
||||
this.logger.info(`CallSession:exec starting task #${stackNum}:${taskNum}: ${task.name}`);
|
||||
try {
|
||||
const resources = await this._evaluatePreconditions(task);
|
||||
let skip = false;
|
||||
this.currentTask = task;
|
||||
if (TaskName.Gather === task.name && this.isBotModeEnabled) {
|
||||
const timeout = task.timeout;
|
||||
this.logger.info(`CallSession:exec skipping #${stackNum}:${taskNum}: ${task.name}`);
|
||||
this.backgroundGatherTask.updateTimeout(timeout);
|
||||
if (this.backgroundGatherTask.updateTaskInProgress(task)) {
|
||||
this.logger.info(`CallSession:exec skipping #${stackNum}:${taskNum}: ${task.name}`);
|
||||
skip = true;
|
||||
}
|
||||
else {
|
||||
this.logger.info('CallSession:exec disabling bot mode to start gather with new options');
|
||||
this.disableBotMode();
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (!skip) {
|
||||
const {span, ctx} = this.rootSpan.startChildSpan(`verb:${task.summary}`);
|
||||
task.span = span;
|
||||
task.ctx = ctx;
|
||||
@@ -626,6 +648,7 @@ class CallSession extends Emitter {
|
||||
this._onTasksDone();
|
||||
this._clearResources();
|
||||
|
||||
|
||||
if (!this.isConfirmCallSession && !this.isSmsCallSession) sessionTracker.remove(this.callSid);
|
||||
}
|
||||
|
||||
@@ -1146,7 +1169,12 @@ class CallSession extends Emitter {
|
||||
// need to allocate an endpoint
|
||||
try {
|
||||
if (!this.ms) this.ms = this.getMS();
|
||||
const ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
|
||||
const ep = await this.ms.createEndpoint({
|
||||
headers: {
|
||||
'X-Jambones-Call-ID': this.callId,
|
||||
},
|
||||
remoteSdp: this.req.body
|
||||
});
|
||||
//ep.cs = this;
|
||||
this.ep = ep;
|
||||
ep.set({
|
||||
@@ -1176,6 +1204,7 @@ class CallSession extends Emitter {
|
||||
} catch (err) {
|
||||
if (err === CALLER_CANCELLED_ERR_MSG) {
|
||||
this.logger.error(err, 'caller canceled quickly before we could respond, ending call');
|
||||
this.callInfo.callTerminationBy = 'caller';
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.NoAnswer,
|
||||
sipStatus: 487,
|
||||
@@ -1290,7 +1319,8 @@ class CallSession extends Emitter {
|
||||
this.dlg = await this.srf.createUAS(this.req, this.res, {
|
||||
headers: {
|
||||
'X-Trace-ID': this.req.locals.traceId,
|
||||
'X-Call-Sid': this.req.locals.callSid
|
||||
'X-Call-Sid': this.req.locals.callSid,
|
||||
...(this.applicationSid && {'X-Application-Sid': this.applicationSid})
|
||||
},
|
||||
localSdp: this.ep.local.sdp
|
||||
});
|
||||
@@ -1444,7 +1474,8 @@ class CallSession extends Emitter {
|
||||
headers: {
|
||||
'Refer-To': referTo,
|
||||
'Referred-By': `sip:${this.srf.locals.localSipAddress}`,
|
||||
'X-Retain-Call-Sid': this.callSid
|
||||
'X-Retain-Call-Sid': this.callSid,
|
||||
'X-Account-Sid': this.accountSid
|
||||
}
|
||||
});
|
||||
if ([200, 202].includes(res.status)) {
|
||||
@@ -1482,8 +1513,9 @@ class CallSession extends Emitter {
|
||||
dlg.connected = false;
|
||||
dlg.destroy = origDestroy;
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.callInfo.callTerminationBy = 'jambonz';
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.logger.debug('CallSession: call terminated by jambones');
|
||||
this.logger.debug('CallSession: call terminated by jambonz');
|
||||
this.rootSpan.setAttributes({'call.termination': 'hangup by jambonz'});
|
||||
origDestroy().catch((err) => this.logger.info({err}, 'CallSession - error destroying dialog'));
|
||||
if (this.wakeupResolver) {
|
||||
|
||||
@@ -34,6 +34,7 @@ class InboundCallSession extends CallSession {
|
||||
|
||||
_onCancel() {
|
||||
this.rootSpan.setAttributes({'call.termination': 'caller abandoned'});
|
||||
this.callInfo.callTerminationBy = 'caller';
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.NoAnswer,
|
||||
sipStatus: 487,
|
||||
@@ -69,6 +70,7 @@ class InboundCallSession extends CallSession {
|
||||
assert(this.dlg.connectTime);
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.rootSpan.setAttributes({'call.termination': 'hangup by caller'});
|
||||
this.callInfo.callTerminationBy = 'caller';
|
||||
this.emit('callStatusChange', {
|
||||
callStatus: CallStatus.Completed,
|
||||
duration
|
||||
|
||||
@@ -44,6 +44,7 @@ class RestCallSession extends CallSession {
|
||||
* This is invoked when the called party hangs up, in order to calculate the call duration.
|
||||
*/
|
||||
_callerHungup() {
|
||||
this.callInfo.callTerminationBy = 'caller';
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.logger.debug('RestCallSession: called party hung up');
|
||||
|
||||
@@ -36,7 +36,8 @@ class SipRecCallSession extends InboundCallSession {
|
||||
headers: {
|
||||
'Content-Type': 'application/sdp',
|
||||
'X-Trace-ID': this.req.locals.traceId,
|
||||
'X-Call-Sid': this.req.locals.callSid
|
||||
'X-Call-Sid': this.req.locals.callSid,
|
||||
...(this.applicationSid && {'X-Application-Sid': this.applicationSid})
|
||||
},
|
||||
localSdp: combinedSdp
|
||||
});
|
||||
|
||||
@@ -26,7 +26,7 @@ class TaskConfig extends Task {
|
||||
});
|
||||
}
|
||||
if (this.bargeIn.sticky) this.autoEnable = true;
|
||||
this.preconditions = (this.bargeIn.enable || this.record?.action) ?
|
||||
this.preconditions = (this.bargeIn.enable || this.record?.action || this.data.amd) ?
|
||||
TaskPreconditions.Endpoint :
|
||||
TaskPreconditions.None;
|
||||
}
|
||||
@@ -54,13 +54,20 @@ class TaskConfig extends Task {
|
||||
return `${this.name}{${phrase.join(',')}`;
|
||||
}
|
||||
|
||||
async exec(cs) {
|
||||
async exec(cs, {ep} = {}) {
|
||||
await super.exec(cs);
|
||||
|
||||
if (this.data.amd) {
|
||||
this.startAmd = cs.startAmd;
|
||||
this.stopAmd = cs.stopAmd;
|
||||
this.on('amd', this._onAmdEvent.bind(this, cs));
|
||||
|
||||
try {
|
||||
this.ep = ep;
|
||||
this.startAmd(cs, ep, this, this.data.amd);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Config:exec - Error calling startAmd');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasSynthesizer) {
|
||||
@@ -98,6 +105,9 @@ class TaskConfig extends Task {
|
||||
this.logger.info({altLanguages: this.recognizer.altLanguages}, 'Config: updated altLanguages');
|
||||
cs.altLanguages = this.recognizer.altLanguages;
|
||||
}
|
||||
if ('punctuation' in this.recognizer) {
|
||||
cs.globalSttPunctuation = this.recognizer.punctuation;
|
||||
}
|
||||
this.logger.info({
|
||||
recognizer: this.recognizer,
|
||||
isContinuousAsr: cs.isContinuousAsr
|
||||
@@ -130,12 +140,17 @@ class TaskConfig extends Task {
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
if (this.ep && this.stopAmd) this.stopAmd(this.ep, this);
|
||||
}
|
||||
|
||||
_onAmdEvent(cs, evt) {
|
||||
this.logger.info({evt}, 'Config:_onAmdEvent');
|
||||
const {actionHook} = this.data.amd;
|
||||
this.performHook(cs, actionHook, evt);
|
||||
this.performHook(cs, actionHook, evt)
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, 'Config:_onAmdEvent - error calling actionHook');
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -689,7 +689,10 @@ class TaskDial extends Task {
|
||||
_onAmdEvent(cs, evt) {
|
||||
this.logger.info({evt}, 'Dial:_onAmdEvent');
|
||||
const {actionHook} = this.data.amd;
|
||||
this.performHook(cs, actionHook, evt);
|
||||
this.performHook(cs, actionHook, evt)
|
||||
.catch((err) => {
|
||||
this.logger.error({err}, 'Dial:_onAmdEvent - error calling actionHook');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,8 @@ class TaskGather extends Task {
|
||||
this.requestSnr = recognizer.requestSnr || false;
|
||||
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
|
||||
this.azureServiceEndpoint = recognizer.azureServiceEndpoint;
|
||||
this.azureSttEndpointId = recognizer.azureSttEndpointId;
|
||||
this.azureAudioLogging = recognizer.audioLogging;
|
||||
}
|
||||
else {
|
||||
this.hints = [];
|
||||
@@ -141,11 +143,22 @@ class TaskGather extends Task {
|
||||
this.logger.debug({hints: this.hints, hintsBoost: this.hintsBoost},
|
||||
'Gather:exec - applying global sttHints');
|
||||
}
|
||||
if (process.env.JAMBONZ_GATHER_EARLY_HINTS_MATCH &&
|
||||
!this.isContinuousAsr &&
|
||||
this.hints.length > 0 && this.hints.length <= 10) {
|
||||
this.earlyHintsMatch = true;
|
||||
this.interim = true;
|
||||
this.logger.debug('Gather:exec - early hints match enabled');
|
||||
}
|
||||
|
||||
if (cs.hasAltLanguages) {
|
||||
this.altLanguages = this.altLanguages.concat(cs.altLanguages);
|
||||
this.logger.debug({altLanguages: this.altLanguages},
|
||||
'Gather:exec - applying altLanguages');
|
||||
}
|
||||
if (cs.hasGlobalSttPunctuation) {
|
||||
this.punctuation = cs.globalSttPunctuation;
|
||||
}
|
||||
if (!this.isContinuousAsr && cs.isContinuousAsr) {
|
||||
this.isContinuousAsr = true;
|
||||
this.asrTimeout = cs.asrTimeout * 1000;
|
||||
@@ -173,7 +186,8 @@ class TaskGather extends Task {
|
||||
|
||||
const startListening = (cs, ep) => {
|
||||
this._startTimer();
|
||||
if (this.isContinuousAsr && 0 === this.timeout) this._startAsrTimer();
|
||||
// dont start asr timer until we have a transcription
|
||||
//if (this.isContinuousAsr && 0 === this.timeout) this._startAsrTimer();
|
||||
if (this.input.includes('speech') && !this.listenDuringPrompt) {
|
||||
this._initSpeech(cs, ep)
|
||||
.then(() => {
|
||||
@@ -246,8 +260,12 @@ class TaskGather extends Task {
|
||||
this._resolve('killed');
|
||||
}
|
||||
|
||||
updateTimeout(timeout) {
|
||||
this.logger.info(`TaskGather:updateTimeout - updating timeout to ${timeout}`);
|
||||
updateTaskInProgress(opts) {
|
||||
if (!this.needsStt && opts.input.includes('speech')) {
|
||||
this.logger.info('TaskGather:updateTaskInProgress - adding speech to a background gather');
|
||||
return false; // this needs be handled by killing the background gather and starting a new one
|
||||
}
|
||||
const {timeout} = opts;
|
||||
this.timeout = timeout;
|
||||
this._startTimer();
|
||||
}
|
||||
@@ -298,7 +316,7 @@ class TaskGather extends Task {
|
||||
}
|
||||
|
||||
if ('google' === this.vendor) {
|
||||
this.bugname = 'google_trancribe';
|
||||
this.bugname = 'google_transcribe';
|
||||
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
|
||||
[
|
||||
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
|
||||
@@ -310,6 +328,7 @@ class TaskGather extends Task {
|
||||
['diarization', 'GOOGLE_SPEECH_PROFANITY_FILTER']
|
||||
].forEach((arr) => {
|
||||
if (this[arr[0]]) opts[arr[1]] = true;
|
||||
else if (this[arr[0]] === false) opts[arr[1]] = false;
|
||||
});
|
||||
if (this.hints.length > 0) {
|
||||
opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
|
||||
@@ -335,7 +354,7 @@ class TaskGather extends Task {
|
||||
ep.addCustomEventListener(GoogleTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
||||
}
|
||||
else if (['aws', 'polly'].includes(this.vendor)) {
|
||||
this.bugname = 'aws_trancribe';
|
||||
this.bugname = 'aws_transcribe';
|
||||
if (this.vocabularyName) opts.AWS_VOCABULARY_NAME = this.vocabularyName;
|
||||
if (this.vocabularyFilterName) {
|
||||
opts.AWS_VOCABULARY_NAME = this.vocabularyFilterName;
|
||||
@@ -352,12 +371,20 @@ class TaskGather extends Task {
|
||||
ep.addCustomEventListener(AwsTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
||||
}
|
||||
else if ('microsoft' === this.vendor) {
|
||||
this.bugname = 'azure_trancribe';
|
||||
this.bugname = 'azure_transcribe';
|
||||
if (this.sttCredentials) {
|
||||
const {api_key, region, use_custom_stt, custom_stt_endpoint} = this.sttCredentials;
|
||||
|
||||
Object.assign(opts, {
|
||||
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
|
||||
'AZURE_REGION': this.sttCredentials.region
|
||||
'AZURE_SUBSCRIPTION_KEY': api_key,
|
||||
'AZURE_REGION': region
|
||||
});
|
||||
if (this.azureSttEndpointId) {
|
||||
Object.assign(opts, {'AZURE_SERVICE_ENDPOINT_ID': this.azureSttEndpointId});
|
||||
}
|
||||
else if (use_custom_stt && custom_stt_endpoint) {
|
||||
Object.assign(opts, {'AZURE_SERVICE_ENDPOINT_ID': custom_stt_endpoint});
|
||||
}
|
||||
}
|
||||
if (this.hints && this.hints.length > 0) {
|
||||
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
|
||||
@@ -368,6 +395,7 @@ class TaskGather extends Task {
|
||||
else {
|
||||
opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = '';
|
||||
}
|
||||
if (this.azureAudioLogging) opts.AZURE_AUDIO_LOGGING = 1;
|
||||
if (this.requestSnr) opts.AZURE_REQUEST_SNR = 1;
|
||||
if (this.profanityOption && this.profanityOption !== 'raw') opts.AZURE_PROFANITY_OPTION = this.profanityOption;
|
||||
if (this.azureServiceEndpoint) opts.AZURE_SERVICE_ENDPOINT = this.azureServiceEndpoint;
|
||||
@@ -511,6 +539,15 @@ class TaskGather extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
if (this.earlyHintsMatch && evt.is_final === false) {
|
||||
const transcript = evt.alternatives[0].transcript?.toLowerCase();
|
||||
if (this.hints.find((h) => h.toLowerCase() === transcript)) {
|
||||
this.logger.debug({evt}, 'Gather:_onTranscription: early hint match');
|
||||
this._resolve('speech', evt);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/* count words for bargein feature */
|
||||
const words = evt.alternatives[0].transcript.split(' ').length;
|
||||
const bufferedWords = this._bufferedTranscripts.reduce((count, e) => {
|
||||
@@ -637,7 +674,7 @@ class TaskGather extends Task {
|
||||
}
|
||||
|
||||
this.span.setAttributes({'stt.resolve': reason, 'stt.result': JSON.stringify(evt)});
|
||||
if (this.ep && this.ep.connected) {
|
||||
if (this.needsStt && this.ep && this.ep.connected) {
|
||||
this.ep.stopTranscription({vendor: this.vendor})
|
||||
.catch((err) => this.logger.error({err}, 'Error stopping transcription'));
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ class TaskPlay extends Task {
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.url = this.data.url;
|
||||
this.seekOffset = this.data.seekOffset || -1;
|
||||
this.timeoutSecs = this.data.timeoutSecs || -1;
|
||||
this.loop = this.data.loop || 1;
|
||||
this.earlyMedia = this.data.earlyMedia === true;
|
||||
}
|
||||
@@ -31,8 +33,13 @@ class TaskPlay extends Task {
|
||||
} else {
|
||||
await this.playToConfMember(this.ep, memberId, confName, confUuid, this.url);
|
||||
}
|
||||
} else {
|
||||
const file = (this.timeoutSecs >= 0 || this.seekOffset >= 0) ?
|
||||
{file: this.url, seekOffset: this.seekOffset, timeoutSecs: this.timeoutSecs} : this.url;
|
||||
const result = await ep.play(file);
|
||||
await this.performAction(Object.assign(result, {reason: 'playCompleted'}),
|
||||
!(this.parentTask || cs.isConfirmCallSession));
|
||||
}
|
||||
else await ep.play(this.url);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`);
|
||||
|
||||
@@ -11,6 +11,7 @@ class TaskRestDial extends Task {
|
||||
super(logger, opts);
|
||||
|
||||
this.from = this.data.from;
|
||||
this.fromHost = this.data.fromHost;
|
||||
this.to = this.data.to;
|
||||
this.call_hook = this.data.call_hook;
|
||||
this.timeout = this.data.timeout || 60;
|
||||
|
||||
101
lib/tasks/say.js
101
lib/tasks/say.js
@@ -1,12 +1,107 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
|
||||
const breakLengthyTextIfNeeded = (logger, text) => {
|
||||
const chunkSize = 1000;
|
||||
if (text.length <= chunkSize) return [text];
|
||||
|
||||
const result = [];
|
||||
const isSSML = text.startsWith('<speak>');
|
||||
let startPos = 0;
|
||||
let charPos = isSSML ? 7 : 0; // skip <speak>
|
||||
let tag;
|
||||
//logger.debug({isSSML}, `breakLengthyTextIfNeeded: handling text of length ${text.length}`);
|
||||
while (startPos + charPos < text.length) {
|
||||
if (isSSML && !tag && text[startPos + charPos] === '<') {
|
||||
const tagStartPos = ++charPos;
|
||||
while (startPos + charPos < text.length) {
|
||||
if (text[startPos + charPos] === '>') {
|
||||
if (text[startPos + charPos - 1] === '\\') tag = null;
|
||||
else if (!tag) tag = text.substring(startPos + tagStartPos, startPos + charPos - 1);
|
||||
break;
|
||||
}
|
||||
if (!tag) {
|
||||
const c = text[startPos + charPos];
|
||||
if (c === ' ') {
|
||||
tag = text.substring(startPos + tagStartPos, startPos + charPos);
|
||||
//logger.debug(`breakLengthyTextIfNeeded: enter tag ${tag} (space)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
charPos++;
|
||||
}
|
||||
if (tag) {
|
||||
//search for end of tag
|
||||
//logger.debug(`breakLengthyTextIfNeeded: searching forward for </${tag}>`);
|
||||
const e1 = text.indexOf(`</${tag}>`, startPos + charPos);
|
||||
const e2 = text.indexOf('/>', startPos + charPos);
|
||||
const tagEndPos = e1 === -1 ? e2 : e2 === -1 ? e1 : Math.min(e1, e2);
|
||||
if (tagEndPos === -1) {
|
||||
//logger.debug(`breakLengthyTextIfNeeded: exit tag ${tag} not found, exiting`);
|
||||
} else {
|
||||
//logger.debug(`breakLengthyTextIfNeeded: exit tag ${tag} found at ${tagEndPos}`);
|
||||
charPos = tagEndPos + 1;
|
||||
}
|
||||
tag = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (charPos < chunkSize) {
|
||||
charPos++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// start looking for a good break point
|
||||
let chunkIt = false;
|
||||
const a = text[startPos + charPos];
|
||||
const b = text[startPos + charPos + 1];
|
||||
if (/[\.!\?]/.test(a) && /\s/.test(b)) {
|
||||
//logger.debug('breakLengthyTextIfNeeded: breaking at sentence end');
|
||||
chunkIt = true;
|
||||
}
|
||||
if (chunkIt) {
|
||||
charPos++;
|
||||
const chunk = text.substr(startPos, charPos);
|
||||
if (isSSML) {
|
||||
result.push(0 === startPos ? `${chunk}</speak>` : `<speak>${chunk}</speak>`);
|
||||
}
|
||||
else result.push(chunk);
|
||||
charPos = 0;
|
||||
startPos += chunk.length;
|
||||
|
||||
//logger.debug({chunk: result[result.length - 1]},
|
||||
// `breakLengthyTextIfNeeded: chunked; new starting pos ${startPos}`);
|
||||
|
||||
}
|
||||
else charPos++;
|
||||
}
|
||||
|
||||
// final chunk
|
||||
if (startPos < text.length) {
|
||||
const chunk = text.substr(startPos);
|
||||
if (isSSML) {
|
||||
result.push(0 === startPos ? `${chunk}</speak>` : `<speak>${chunk}`);
|
||||
}
|
||||
else result.push(chunk);
|
||||
|
||||
//logger.debug({chunk: result[result.length - 1]},
|
||||
// `breakLengthyTextIfNeeded: final chunk; starting pos ${startPos} length ${chunk.length}`);
|
||||
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
class TaskSay extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.text = Array.isArray(this.data.text) ? this.data.text : [this.data.text];
|
||||
this.text = (Array.isArray(this.data.text) ? this.data.text : [this.data.text])
|
||||
.map((t) => breakLengthyTextIfNeeded(this.logger, t))
|
||||
.flat();
|
||||
|
||||
this.loop = this.data.loop || 1;
|
||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||
this.synthesizer = this.data.synthesizer || {};
|
||||
@@ -69,6 +164,10 @@ class TaskSay extends Task {
|
||||
'tts.voice': voice
|
||||
});
|
||||
try {
|
||||
if (vendor === 'microsoft' && this.synthesizer.azureServiceEndpoint) {
|
||||
credentials.use_custom_tts = true;
|
||||
credentials.custom_tts_endpoint = this.synthesizer.azureServiceEndpoint;
|
||||
}
|
||||
const {filePath, servedFromCache} = await synthAudio(stats, {
|
||||
text,
|
||||
vendor,
|
||||
|
||||
@@ -36,6 +36,7 @@ class TaskSipRefer extends Task {
|
||||
method: 'REFER',
|
||||
headers: {
|
||||
...this.headers,
|
||||
...(this.referToIsUri && {'X-Refer-To-Leave-Untouched': true}),
|
||||
'Refer-To': referTo,
|
||||
'Referred-By': referredBy
|
||||
}
|
||||
@@ -100,6 +101,7 @@ class TaskSipRefer extends Task {
|
||||
/* they may have only provided a phone number/user */
|
||||
referTo = `sip:${referTo}@${host}`;
|
||||
}
|
||||
else this.referToIsUri = true;
|
||||
if (!referredBy) {
|
||||
/* default */
|
||||
referredBy = cs.req?.callingNumber || dlg.local.uri;
|
||||
|
||||
@@ -98,7 +98,10 @@
|
||||
"properties": {
|
||||
"url": "string|array",
|
||||
"loop": "number|string",
|
||||
"earlyMedia": "boolean"
|
||||
"earlyMedia": "boolean",
|
||||
"seekOffset": "number|string",
|
||||
"timeoutSecs": "number|string",
|
||||
"actionHook": "object|string"
|
||||
},
|
||||
"required": [
|
||||
"url"
|
||||
@@ -338,6 +341,7 @@
|
||||
"call_hook": "object|string",
|
||||
"call_status_hook": "object|string",
|
||||
"from": "string",
|
||||
"fromHost": "string",
|
||||
"speech_synthesis_vendor": "string",
|
||||
"speech_synthesis_voice": "string",
|
||||
"speech_synthesis_language": "string",
|
||||
@@ -384,6 +388,7 @@
|
||||
"enum": ["GET", "POST"]
|
||||
},
|
||||
"headers": "object",
|
||||
"from": "#dialFrom",
|
||||
"name": "string",
|
||||
"number": "string",
|
||||
"sipUri": "string",
|
||||
@@ -397,6 +402,14 @@
|
||||
"type"
|
||||
]
|
||||
},
|
||||
"dialFrom": {
|
||||
"properties": {
|
||||
"user": "string",
|
||||
"host": "string"
|
||||
},
|
||||
"required": [
|
||||
]
|
||||
},
|
||||
"auth": {
|
||||
"properties": {
|
||||
"username": "string",
|
||||
@@ -422,7 +435,8 @@
|
||||
"gender": {
|
||||
"type": "string",
|
||||
"enum": ["MALE", "FEMALE", "NEUTRAL"]
|
||||
}
|
||||
},
|
||||
"azureServiceEndpoint": "string"
|
||||
},
|
||||
"required": [
|
||||
"vendor"
|
||||
@@ -494,8 +508,10 @@
|
||||
"requestSnr": "boolean",
|
||||
"initialSpeechTimeoutMs": "number",
|
||||
"azureServiceEndpoint": "string",
|
||||
"azureSttEndpointId": "string",
|
||||
"asrDtmfTerminationDigit": "string",
|
||||
"asrTimeout": "number"
|
||||
"asrTimeout": "number",
|
||||
"audioLogging": "boolean"
|
||||
},
|
||||
"required": [
|
||||
"vendor"
|
||||
|
||||
@@ -336,6 +336,9 @@ class Task extends Emitter {
|
||||
}
|
||||
required = required.filter((item) => item !== dKey);
|
||||
}
|
||||
else if (dKey === '_') {
|
||||
/* no op: allow arbitrary info to be carried here, used by conference e.g in transfer */
|
||||
}
|
||||
else throw new Error(`${name}: unknown property ${dKey}`);
|
||||
}
|
||||
if (required.length > 0) throw new Error(`${name}: missing value for ${required}`);
|
||||
|
||||
@@ -54,6 +54,8 @@ class TaskTranscribe extends Task {
|
||||
this.requestSnr = recognizer.requestSnr || false;
|
||||
this.initialSpeechTimeoutMs = recognizer.initialSpeechTimeoutMs || 0;
|
||||
this.azureServiceEndpoint = recognizer.azureServiceEndpoint;
|
||||
this.azureSttEndpointId = recognizer.azureSttEndpointId;
|
||||
this.azureAudioLogging = recognizer.audioLogging;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Transcribe; }
|
||||
@@ -74,6 +76,9 @@ class TaskTranscribe extends Task {
|
||||
this.logger.debug({altLanguages: this.altLanguages},
|
||||
'Gather:exec - applying altLanguages');
|
||||
}
|
||||
if (cs.hasGlobalSttPunctuation) {
|
||||
this.punctuation = cs.globalSttPunctuation;
|
||||
}
|
||||
|
||||
this.ep = ep;
|
||||
this.ep2 = ep2;
|
||||
@@ -158,7 +163,7 @@ class TaskTranscribe extends Task {
|
||||
ep.addCustomEventListener(AzureTranscriptionEvents.NoSpeechDetected, this._onNoAudio.bind(this, cs, ep, channel));
|
||||
|
||||
if (this.vendor === 'google') {
|
||||
this.bugname = 'google_trancribe';
|
||||
this.bugname = 'google_transcribe';
|
||||
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
|
||||
[
|
||||
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
|
||||
@@ -170,6 +175,7 @@ class TaskTranscribe extends Task {
|
||||
['diarization', 'GOOGLE_SPEECH_PROFANITY_FILTER']
|
||||
].forEach((arr) => {
|
||||
if (this[arr[0]]) opts[arr[1]] = true;
|
||||
else if (this[arr[0]] === false) opts[arr[1]] = false;
|
||||
});
|
||||
if (this.hints.length > 0) {
|
||||
opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
|
||||
@@ -195,7 +201,7 @@ class TaskTranscribe extends Task {
|
||||
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with google'));
|
||||
}
|
||||
else if (this.vendor === 'aws') {
|
||||
this.bugname = 'aws_trancribe';
|
||||
this.bugname = 'aws_transcribe';
|
||||
[
|
||||
['diarization', 'AWS_SHOW_SPEAKER_LABEL'],
|
||||
['identifyChannels', 'AWS_ENABLE_CHANNEL_IDENTIFICATION']
|
||||
@@ -227,16 +233,24 @@ class TaskTranscribe extends Task {
|
||||
.catch((err) => this.logger.info(err, 'TaskTranscribe:_startTranscribing with aws'));
|
||||
}
|
||||
else if (this.vendor === 'microsoft') {
|
||||
this.bugname = 'azure_trancribe';
|
||||
this.bugname = 'azure_transcribe';
|
||||
const {api_key, region, use_custom_stt, custom_stt_endpoint} = this.sttCredentials;
|
||||
Object.assign(opts, {
|
||||
'AZURE_SUBSCRIPTION_KEY': this.sttCredentials.api_key,
|
||||
'AZURE_REGION': this.sttCredentials.region
|
||||
'AZURE_SUBSCRIPTION_KEY': api_key,
|
||||
'AZURE_REGION': region
|
||||
});
|
||||
if (this.azureSttEndpointId) {
|
||||
Object.assign(opts, {'AZURE_SERVICE_ENDPOINT_ID': this.azureSttEndpointId});
|
||||
}
|
||||
else if (use_custom_stt && custom_stt_endpoint) {
|
||||
Object.assign(opts, {'AZURE_SERVICE_ENDPOINT_ID': custom_stt_endpoint});
|
||||
}
|
||||
if (this.hints && this.hints.length > 0) {
|
||||
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
|
||||
}
|
||||
if (this.altLanguages.length > 0) opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
|
||||
else opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = '';
|
||||
if (this.azureAudioLogging) opts.AZURE_AUDIO_LOGGING = 1;
|
||||
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;
|
||||
|
||||
@@ -273,26 +273,46 @@ module.exports = (logger) => {
|
||||
amd
|
||||
.on(AmdEvents.NoSpeechDetected, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.NoSpeechDetected, ...evt});
|
||||
ep.stopTranscription({vendor, bugname});
|
||||
try {
|
||||
ep.connected && ep.stopTranscription({vendor, bugname});
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping transcription');
|
||||
}
|
||||
})
|
||||
.on(AmdEvents.HumanDetected, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.HumanDetected, ...evt});
|
||||
ep.stopTranscription({vendor, bugname});
|
||||
try {
|
||||
ep.connected && ep.stopTranscription({vendor, bugname});
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping transcription');
|
||||
}
|
||||
})
|
||||
.on(AmdEvents.MachineDetected, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.MachineDetected, ...evt});
|
||||
})
|
||||
.on(AmdEvents.DecisionTimeout, (evt) => {
|
||||
task.emit('amd', {type: AmdEvents.DecisionTimeout, ...evt});
|
||||
ep.stopTranscription({vendor, bugname});
|
||||
try {
|
||||
ep.connected && ep.stopTranscription({vendor, bugname});
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping transcription');
|
||||
}
|
||||
})
|
||||
.on(AmdEvents.ToneTimeout, (evt) => {
|
||||
//task.emit('amd', {type: AmdEvents.ToneTimeout, ...evt});
|
||||
ep.execute('avmd_stop').catch((err) => logger.info(err, 'Error stopping avmd'));
|
||||
try {
|
||||
ep.connected && ep.execute('avmd_stop').catch((err) => logger.info(err, 'Error stopping avmd'));
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping avmd');
|
||||
}
|
||||
})
|
||||
.on(AmdEvents.MachineStoppedSpeaking, () => {
|
||||
task.emit('amd', {type: AmdEvents.MachineStoppedSpeaking});
|
||||
ep.stopTranscription({vendor, bugname});
|
||||
try {
|
||||
ep.connected && ep.stopTranscription({vendor, bugname});
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error stopping transcription');
|
||||
}
|
||||
});
|
||||
|
||||
/* start transcribing, and also listening for beep */
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const Emitter = require('events');
|
||||
const bent = require('bent');
|
||||
const assert = require('assert');
|
||||
const PORT = process.env.AWS_SNS_PORT || 3001;
|
||||
const PORT = process.env.AWS_SNS_PORT || 3010;
|
||||
const {LifeCycleEvents} = require('./constants');
|
||||
const express = require('express');
|
||||
const app = express();
|
||||
@@ -21,6 +21,26 @@ class SnsNotifier extends Emitter {
|
||||
|
||||
this.logger = logger;
|
||||
}
|
||||
_doListen(logger, app, port, resolve) {
|
||||
return app.listen(port, () => {
|
||||
this.snsEndpoint = `http://${this.publicIp}:${port}`;
|
||||
logger.info(`SNS lifecycle server listening on http://localhost:${port}`);
|
||||
resolve(app);
|
||||
});
|
||||
}
|
||||
|
||||
_handleErrors(logger, app, resolve, reject, e) {
|
||||
if (e.code === 'EADDRINUSE' &&
|
||||
process.env.AWS_SNS_PORT_MAX &&
|
||||
e.port < process.env.AWS_SNS_PORT_MAX) {
|
||||
|
||||
logger.info(`SNS lifecycle server failed to bind port on ${e.port}, will try next port`);
|
||||
const server = this._doListen(logger, app, ++e.port, resolve);
|
||||
server.on('error', this._handleErrors.bind(this, logger, app, resolve, reject));
|
||||
return;
|
||||
}
|
||||
reject(e);
|
||||
}
|
||||
|
||||
async _handlePost(req, res) {
|
||||
try {
|
||||
@@ -84,11 +104,9 @@ class SnsNotifier extends Emitter {
|
||||
this.logger.debug('SnsNotifier: retrieving instance data');
|
||||
this.instanceId = await getString('http://169.254.169.254/latest/meta-data/instance-id');
|
||||
this.publicIp = await getString('http://169.254.169.254/latest/meta-data/public-ipv4');
|
||||
this.snsEndpoint = `http://${this.publicIp}:${PORT}`;
|
||||
this.logger.info({
|
||||
instanceId: this.instanceId,
|
||||
publicIp: this.publicIp,
|
||||
snsEndpoint: this.snsEndpoint
|
||||
publicIp: this.publicIp
|
||||
}, 'retrieved AWS instance data');
|
||||
|
||||
// start listening
|
||||
@@ -100,7 +118,10 @@ class SnsNotifier extends Emitter {
|
||||
this.logger.error(err, 'burped error');
|
||||
res.status(err.status || 500).json({msg: err.message});
|
||||
});
|
||||
app.listen(PORT);
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = this._doListen(this.logger, app, PORT, resolve);
|
||||
server.on('error', this._handleErrors.bind(this, this.logger, app, resolve, reject));
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Error retrieving AWS instance metadata');
|
||||
|
||||
@@ -42,9 +42,9 @@ const clearChannels = () => {
|
||||
};
|
||||
|
||||
const clearFiles = () => {
|
||||
const {logger} = require('../..');
|
||||
const out = execSync('find /tmp -name "*.mp3" -mtime +2 -exec rm {} \\;');
|
||||
logger.debug({out}, 'clearFiles: command output');
|
||||
//const {logger} = require('../..');
|
||||
/*const out = */ execSync('find /tmp -name "*.mp3" -mtime +2 -exec rm {} \\;');
|
||||
//logger.debug({out}, 'clearFiles: command output');
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -23,23 +23,30 @@ AND vc.name = ?`;
|
||||
|
||||
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;
|
||||
obj.aws_region = o.aws_region;
|
||||
}
|
||||
else if ('microsoft' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.region = o.region;
|
||||
}
|
||||
else if ('wellsaid' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
try {
|
||||
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;
|
||||
obj.aws_region = o.aws_region;
|
||||
}
|
||||
else if ('microsoft' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.region = o.region;
|
||||
obj.use_custom_stt = o.use_custom_stt;
|
||||
obj.custom_stt_endpoint = o.custom_stt_endpoint;
|
||||
obj.use_custom_tts = o.use_custom_tts;
|
||||
obj.custom_tts_endpoint = o.custom_tts_endpoint;
|
||||
}
|
||||
else if ('wellsaid' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
}
|
||||
} catch (err) {
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
45
lib/utils/http-listener.js
Normal file
45
lib/utils/http-listener.js
Normal file
@@ -0,0 +1,45 @@
|
||||
|
||||
const express = require('express');
|
||||
const httpRoutes = require('../http-routes');
|
||||
const PORT = process.env.HTTP_PORT || 3000;
|
||||
|
||||
const doListen = (logger, app, port, resolve) => {
|
||||
const server = app.listen(port, () => {
|
||||
const {srf} = app.locals;
|
||||
logger.info(`listening for HTTP requests on port ${PORT}, serviceUrl is ${srf.locals.serviceUrl}`);
|
||||
resolve({server, app});
|
||||
});
|
||||
return server;
|
||||
};
|
||||
const handleErrors = (logger, app, resolve, reject, e) => {
|
||||
if (e.code === 'EADDRINUSE' &&
|
||||
process.env.HTTP_PORT_MAX &&
|
||||
e.port < process.env.HTTP_PORT_MAX) {
|
||||
|
||||
logger.info(`HTTP server failed to bind port on ${e.port}, will try next port`);
|
||||
const server = doListen(logger, app, ++e.port, resolve);
|
||||
server.on('error', handleErrors.bind(null, logger, app, resolve, reject));
|
||||
return;
|
||||
}
|
||||
logger.info({err: e, port: PORT}, 'httpListener error');
|
||||
reject(e);
|
||||
};
|
||||
|
||||
const createHttpListener = (logger, srf) => {
|
||||
const app = express();
|
||||
app.locals = {...app.locals, logger, srf};
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/', httpRoutes);
|
||||
app.use((err, _req, res, _next) => {
|
||||
logger.error(err, 'burped error');
|
||||
res.status(err.status || 500).json({msg: err.message});
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = doListen(logger, app, PORT, resolve);
|
||||
server.on('error', handleErrors.bind(null, logger, app, resolve, reject));
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
module.exports = createHttpListener;
|
||||
@@ -28,8 +28,8 @@ class HttpRequestor extends BaseRequestor {
|
||||
assert(['GET', 'POST'].includes(this.method));
|
||||
|
||||
const u = this._parsedUrl = parseUrl(this.url);
|
||||
this._baseUrl = `${u.protocol}://${u.resource}`;
|
||||
this._resource = u.resource;
|
||||
if (u.port) this._baseUrl = `${u.protocol}://${u.resource}:${u.port}`;
|
||||
else this._baseUrl = `${u.protocol}://${u.resource}`;
|
||||
this._protocol = u.protocol;
|
||||
this._search = u.search;
|
||||
this._usePools = process.env.HTTP_POOL && parseInt(process.env.HTTP_POOL);
|
||||
@@ -49,7 +49,10 @@ class HttpRequestor extends BaseRequestor {
|
||||
this.logger.debug(`HttpRequestor:created pool for ${this._baseUrl}`);
|
||||
}
|
||||
}
|
||||
else this.client = new Client(`${u.protocol}://${u.resource}`);
|
||||
else {
|
||||
if (u.port) this.client = new Client(`${u.protocol}://${u.resource}:${u.port}`);
|
||||
else this.client = new Client(`${u.protocol}://${u.resource}`);
|
||||
}
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
@@ -101,7 +104,8 @@ class HttpRequestor extends BaseRequestor {
|
||||
query = u.query;
|
||||
}
|
||||
else {
|
||||
client = newClient = new Client(`${u.protocol}://${u.resource}`);
|
||||
if (u.port) client = newClient = new Client(`${u.protocol}://${u.resource}:${u.port}`);
|
||||
else client = newClient = new Client(`${u.protocol}://${u.resource}`);
|
||||
path = u.pathname;
|
||||
query = u.query;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ class SingleDialer extends Emitter {
|
||||
|
||||
this.logger = logger;
|
||||
this.target = target;
|
||||
this.from = target.from || {};
|
||||
this.sbcAddress = sbcAddress;
|
||||
this.opts = opts;
|
||||
this.application = application;
|
||||
@@ -66,8 +67,11 @@ class SingleDialer extends Emitter {
|
||||
opts.headers = {
|
||||
...opts.headers,
|
||||
...(this.target.headers || {}),
|
||||
...(this.from.user && {'X-Preferred-From-User': this.from.user}),
|
||||
...(this.from.host && {'X-Preferred-From-Host': this.from.host}),
|
||||
'X-Jambonz-Routing': this.target.type,
|
||||
'X-Call-Sid': this.callSid
|
||||
'X-Call-Sid': this.callSid,
|
||||
...(this.applicationSid && {'X-Application-Sid': this.applicationSid})
|
||||
};
|
||||
if (srf.locals.fsUUID) {
|
||||
opts.headers = {
|
||||
|
||||
@@ -1,42 +1,7 @@
|
||||
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 || ''}`;
|
||||
const header = `Basic ${toBase64(creds)}`;
|
||||
return {Authorization: header};
|
||||
}
|
||||
|
||||
function isRelativeUrl(u) {
|
||||
return typeof u === 'string' && u.startsWith('/');
|
||||
}
|
||||
|
||||
function isAbsoluteUrl(u) {
|
||||
return typeof u === 'string' &&
|
||||
u.startsWith('https://') || u.startsWith('http://');
|
||||
@@ -49,14 +14,6 @@ class Requestor {
|
||||
this.logger = logger;
|
||||
this.url = hook.url;
|
||||
this.method = hook.method || 'POST';
|
||||
this.authHeader = basicAuth(hook.username, hook.password);
|
||||
|
||||
const u = parseUrl(this.url);
|
||||
const myPort = u.port ? `:${u.port}` : '';
|
||||
const baseUrl = this._baseUrl = `${u.protocol}://${u.resource}${myPort}`;
|
||||
|
||||
this.get = bent(baseUrl, 'GET', 'buffer', 200, 201);
|
||||
this.post = bent(baseUrl, 'POST', 'buffer', 200, 201);
|
||||
|
||||
this.username = hook.username;
|
||||
this.password = hook.password;
|
||||
@@ -78,72 +35,15 @@ class Requestor {
|
||||
}
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return this._baseUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP request.
|
||||
* All requests use json bodies.
|
||||
* All requests expect a 200 statusCode on success
|
||||
* @param {object|string} hook - may be a absolute or relative url, or an object
|
||||
* @param {string} [hook.url] - an absolute or relative url
|
||||
* @param {string} [hook.method] - 'GET' or 'POST'
|
||||
* @param {string} [hook.username] - if basic auth is protecting the endpoint
|
||||
* @param {string} [hook.password] - if basic auth is protecting the endpoint
|
||||
* @param {object} [params] - request parameters
|
||||
*/
|
||||
async request(hook, params) {
|
||||
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
|
||||
const url = hook.url || hook;
|
||||
const method = hook.method || 'POST';
|
||||
|
||||
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}`);
|
||||
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');
|
||||
buf = isRelativeUrl(url) ?
|
||||
await this.post(url, payload, headers) :
|
||||
await bent(method, 'buffer', 200, 201, 202)(url, payload, headers);
|
||||
} catch (err) {
|
||||
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);
|
||||
const time = diff[0] * 1e3 + diff[1] * 1e-6;
|
||||
const rtt = time.toFixed(0);
|
||||
if (buf) this.stats.histogram('app.hook.response_time', rtt, ['hook_type:app']);
|
||||
|
||||
if (buf && buf.toString().length > 0) {
|
||||
try {
|
||||
const json = JSON.parse(buf.toString());
|
||||
this.logger.info({response: json}, `Requestor:request ${method} ${url} succeeded in ${rtt}ms`);
|
||||
return json;
|
||||
}
|
||||
catch (err) {
|
||||
//this.logger.debug({err, url, method}, `Requestor:request returned non-JSON content: '${buf.toString()}'`);
|
||||
}
|
||||
get Alerter() {
|
||||
if (!alerter) {
|
||||
alerter = timeSeries(this.logger, {
|
||||
host: process.env.JAMBONES_TIME_SERIES_HOST,
|
||||
commitSize: 50,
|
||||
commitInterval: 'test' === process.env.NODE_ENV ? 7 : 20
|
||||
});
|
||||
}
|
||||
return alerter;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ class WsRequestor extends BaseRequestor {
|
||||
this.connectInProgress = true;
|
||||
this.logger.debug(`WsRequestor:request(${this.id}) - connecting since we do not have a connection`);
|
||||
if (this.connections >= MAX_RECONNECTS) {
|
||||
throw new Error(`max attempts connecting to ${this.url}`);
|
||||
return Promise.reject(`max attempts connecting to ${this.url}`);
|
||||
}
|
||||
try {
|
||||
const startAt = process.hrtime();
|
||||
@@ -79,7 +79,7 @@ class WsRequestor extends BaseRequestor {
|
||||
} catch (err) {
|
||||
this.logger.info({url, err}, 'WsRequestor:request - failed connecting');
|
||||
this.connectInProgress = false;
|
||||
throw err;
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
assert(this.ws);
|
||||
@@ -138,7 +138,7 @@ class WsRequestor extends BaseRequestor {
|
||||
success: (response) => {
|
||||
clearTimeout(timer);
|
||||
const rtt = this._roundTrip(startAt);
|
||||
this.logger.info({response}, `WsRequestor:request ${url} succeeded in ${rtt}ms`);
|
||||
this.logger.debug({response}, `WsRequestor:request ${url} succeeded in ${rtt}ms`);
|
||||
this.stats.histogram('app.hook.ws_response_time', rtt, ['hook_type:app']);
|
||||
resolve(response);
|
||||
},
|
||||
@@ -187,7 +187,7 @@ class WsRequestor extends BaseRequestor {
|
||||
followRedirects: true,
|
||||
maxRedirects: 2,
|
||||
handshakeTimeout,
|
||||
maxPayload: 8096,
|
||||
maxPayload: process.env.JAMBONES_WS_MAX_PAYLOAD ? parseInt(process.env.JAMBONES_WS_MAX_PAYLOAD) : 24 * 1024,
|
||||
};
|
||||
if (this.username && this.password) opts = {...opts, auth: `${this.username}:${this.password}`};
|
||||
|
||||
@@ -286,7 +286,7 @@ class WsRequestor extends BaseRequestor {
|
||||
const obj = JSON.parse(content);
|
||||
const {type, msgid, command, call_sid = this.call_sid, queueCommand = false, data} = obj;
|
||||
|
||||
this.logger.debug({obj}, 'WsRequestor:request websocket: received');
|
||||
//this.logger.debug({obj}, 'WsRequestor:request websocket: received');
|
||||
assert.ok(type, 'type property not supplied');
|
||||
|
||||
switch (type) {
|
||||
@@ -323,7 +323,7 @@ class WsRequestor extends BaseRequestor {
|
||||
|
||||
_recvCommand(msgid, command, call_sid, queueCommand, data) {
|
||||
// TODO: validate command
|
||||
this.logger.info({msgid, command, call_sid, queueCommand, data}, 'received command');
|
||||
this.logger.debug({msgid, command, call_sid, queueCommand, data}, 'received command');
|
||||
this.emit('command', {msgid, command, call_sid, queueCommand, data});
|
||||
}
|
||||
}
|
||||
|
||||
5442
package-lock.json
generated
5442
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jambonz-feature-server",
|
||||
"version": "v0.7.5",
|
||||
"version": "v0.7.7",
|
||||
"main": "app.js",
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
@@ -16,9 +16,7 @@
|
||||
"type": "git",
|
||||
"url": "https://github.com/jambonz/jambonz-feature-server.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/jambonz/jambonz-feature-server/issues"
|
||||
},
|
||||
"bugs": {},
|
||||
"scripts": {
|
||||
"start": "node app",
|
||||
"test": "NODE_ENV=test JAMBONES_HOSTING=1 HTTP_POOL=1 DRACHTIO_HOST=127.0.0.1 DRACHTIO_PORT=9060 DRACHTIO_SECRET=cymru JAMBONES_MYSQL_HOST=127.0.0.1 JAMBONES_MYSQL_PORT=3360 JAMBONES_MYSQL_USER=jambones_test JAMBONES_MYSQL_PASSWORD=jambones_test JAMBONES_MYSQL_DATABASE=jambones_test JAMBONES_REDIS_HOST=127.0.0.1 JAMBONES_REDIS_PORT=16379 JAMBONES_LOGLEVEL=error ENABLE_METRICS=0 HTTP_PORT=3000 JAMBONES_SBCS=172.38.0.10 JAMBONES_FREESWITCH=127.0.0.1:8022:ClueCon:docker-host JAMBONES_TIME_SERIES_HOST=127.0.0.1 JAMBONES_NETWORK_CIDR=172.38.0.0/16 node test/ ",
|
||||
@@ -26,11 +24,11 @@
|
||||
"jslint": "eslint app.js lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jambonz/db-helpers": "^0.6.18",
|
||||
"@jambonz/http-health-check": "^0.0.1",
|
||||
"@jambonz/realtimedb-helpers": "^0.4.29",
|
||||
"@jambonz/db-helpers": "^0.7.3",
|
||||
"@jambonz/realtimedb-helpers": "^0.5.9",
|
||||
"@jambonz/stats-collector": "^0.1.6",
|
||||
"@jambonz/time-series": "^0.1.12",
|
||||
"@jambonz/time-series": "^0.2.5",
|
||||
"@opentelemetry/api": "^1.1.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.3.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.27.0",
|
||||
@@ -44,20 +42,20 @@
|
||||
"bent": "^7.3.12",
|
||||
"debug": "^4.3.4",
|
||||
"deepcopy": "^2.1.0",
|
||||
"drachtio-fsmrf": "^3.0.2",
|
||||
"drachtio-fsmrf": "^3.0.3",
|
||||
"drachtio-srf": "^4.5.1",
|
||||
"express": "^4.18.1",
|
||||
"helmet": "^5.1.0",
|
||||
"ip": "^1.1.8",
|
||||
"moment": "^2.29.3",
|
||||
"parse-url": "^7.0.2",
|
||||
"moment": "^2.29.4",
|
||||
"parse-url": "^8.1.0",
|
||||
"pino": "^6.14.0",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"short-uuid": "^4.2.0",
|
||||
"to-snake-case": "^1.0.0",
|
||||
"undici": "^5.8.2",
|
||||
"uuid": "^8.3.2",
|
||||
"verify-aws-sns-signature": "^0.0.7",
|
||||
"verify-aws-sns-signature": "^0.1.0",
|
||||
"ws": "^8.8.0",
|
||||
"xml2js": "^0.4.23"
|
||||
},
|
||||
|
||||
@@ -156,3 +156,43 @@ test('\'play\' tests multi links in array in conference', async(t) => {
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'play\' tests with seekOffset and actionHook', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const verbs = [
|
||||
{
|
||||
verb: 'play',
|
||||
url: 'silence_stream://5000',
|
||||
seekOffset: 8000,
|
||||
timeoutSecs: 2,
|
||||
actionHook: '/customHook'
|
||||
}
|
||||
];
|
||||
|
||||
const waitHookVerbs = [];
|
||||
|
||||
const from = 'play_action_hook';
|
||||
provisionCallHook(from, verbs)
|
||||
provisionCustomHook(from, waitHookVerbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-success-received-bye.xml', '172.38.0.10', from);
|
||||
t.pass('play: succeeds');
|
||||
const obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_customHook`)
|
||||
t.ok(obj.body.reason === "playCompleted", "play: actionHook success received")
|
||||
t.ok(obj.body.playback_seconds === "2", "playback_seconds: actionHook success received")
|
||||
t.ok(obj.body.playback_milliseconds === "2048", "playback_milliseconds: actionHook success received")
|
||||
t.ok(obj.body.playback_last_offset_pos === "16000", "playback_last_offset_pos: actionHook success received")
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user