mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-01-25 02:07:56 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8b1c429e0 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,9 +2,6 @@
|
||||
logs
|
||||
*.log
|
||||
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
|
||||
@@ -13,7 +13,7 @@ Configuration is provided via environment variables:
|
||||
|AWS_ACCESS_KEY_ID| aws access key id, used for TTS/STT as well SNS notifications|no|
|
||||
|AWS_REGION| aws region| no|
|
||||
|AWS_SECRET_ACCESS_KEY| aws secret access key, used per above|no|
|
||||
|AWS_SNS_TOPIC_ARN| aws sns topic arn that scale-in lifecycle notifications will be published to|no|
|
||||
|AWS_SNS_TOPIC_ARM| aws sns topic arn that scale-in lifecycle notifications will be published to|no|
|
||||
|DRACHTIO_HOST| ip address of drachtio server (typically '127.0.0.1')|yes|
|
||||
|DRACHTIO_PORT| listening port of drachtio server for control connections (typically 9022)|yes|
|
||||
|DRACHTIO_SECRET| shared secret|yes|
|
||||
@@ -72,7 +72,7 @@ module.exports = {
|
||||
STATS_PORT: 8125,
|
||||
STATS_PROTOCOL: 'tcp',
|
||||
STATS_TELEGRAF: 1,
|
||||
AWS_SNS_TOPIC_ARN: 'arn:aws:sns:us-west-1:xxxxxxxxxxx:terraform-20201107200347128600000002',
|
||||
AWS_SNS_TOPIC_ARM: 'arn:aws:sns:us-west-1:xxxxxxxxxxx:terraform-20201107200347128600000002',
|
||||
JAMBONES_NETWORK_CIDR: '172.31.0.0/16',
|
||||
JAMBONES_MYSQL_HOST: 'aurora-cluster-jambonz.cluster-yyyyyyyyyyy.us-west-1.rds.amazonaws.com',
|
||||
JAMBONES_MYSQL_USER: 'admin',
|
||||
|
||||
101
app.js
101
app.js
@@ -27,67 +27,8 @@ const pino = require('pino');
|
||||
const logger = pino(opts, pino.destination({sync: false}));
|
||||
const {LifeCycleEvents, FS_UUID_SET_NAME, SystemState, FEATURE_SERVER} = require('./lib/utils/constants');
|
||||
const installSrfLocals = require('./lib/utils/install-srf-locals');
|
||||
const createHttpListener = require('./lib/utils/http-listener');
|
||||
const healthCheck = require('@jambonz/http-health-check');
|
||||
const ProcessMonitor = require('./lib/utils/process-monitor');
|
||||
const monitor = new ProcessMonitor(logger);
|
||||
installSrfLocals(srf, logger);
|
||||
|
||||
// Log startup
|
||||
monitor.logStartup();
|
||||
monitor.setupSignalHandlers();
|
||||
|
||||
logger.on('level-change', (lvl, _val, prevLvl, _prevVal, instance) => {
|
||||
if (logger !== instance) {
|
||||
return;
|
||||
}
|
||||
logger.info('system log level %s was changed to %s', prevLvl, lvl);
|
||||
});
|
||||
|
||||
// Install the srf locals
|
||||
installSrfLocals(srf, logger, {
|
||||
onFreeswitchConnect: (wraper) => {
|
||||
// Only connect to drachtio if freeswitch is connected
|
||||
logger.info(`connected to freeswitch at ${wraper.ms.address}, start drachtio server`);
|
||||
if (DRACHTIO_HOST) {
|
||||
srf.connect({host: DRACHTIO_HOST, port: DRACHTIO_PORT, secret: DRACHTIO_SECRET });
|
||||
srf.on('connect', (err, hp) => {
|
||||
const arr = /^(.*)\/(.*)$/.exec(hp.split(',').pop());
|
||||
srf.locals.localSipAddress = `${arr[2]}`;
|
||||
logger.info(`connected to drachtio listening on ${hp}, local sip address is ${srf.locals.localSipAddress}`);
|
||||
});
|
||||
}
|
||||
else {
|
||||
logger.info(`listening for drachtio requests on port ${DRACHTIO_PORT}`);
|
||||
srf.listen({port: DRACHTIO_PORT, secret: DRACHTIO_SECRET});
|
||||
}
|
||||
// Start Http server
|
||||
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');
|
||||
});
|
||||
},
|
||||
onFreeswitchDisconnect: (wraper) => {
|
||||
// check if all freeswitch connections are lost, disconnect drachtio server
|
||||
logger.info(`lost connection to freeswitch at ${wraper.ms.address}`);
|
||||
const ms = srf.locals.getFreeswitch();
|
||||
if (!ms) {
|
||||
logger.info('no freeswitch connections, stopping drachtio server');
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
if (NODE_ENV === 'test') {
|
||||
srf.on('error', (err) => {
|
||||
logger.info(err, 'Error connecting to drachtio');
|
||||
});
|
||||
}
|
||||
|
||||
// Init services
|
||||
const writeSystemAlerts = srf.locals?.writeSystemAlerts;
|
||||
if (writeSystemAlerts) {
|
||||
writeSystemAlerts({
|
||||
@@ -113,6 +54,24 @@ const {
|
||||
const InboundCallSession = require('./lib/session/inbound-call-session');
|
||||
const SipRecCallSession = require('./lib/session/siprec-call-session');
|
||||
|
||||
if (DRACHTIO_HOST) {
|
||||
srf.connect({host: DRACHTIO_HOST, port: DRACHTIO_PORT, secret: DRACHTIO_SECRET });
|
||||
srf.on('connect', (err, hp) => {
|
||||
const arr = /^(.*)\/(.*)$/.exec(hp.split(',').pop());
|
||||
srf.locals.localSipAddress = `${arr[2]}`;
|
||||
logger.info(`connected to drachtio listening on ${hp}, local sip address is ${srf.locals.localSipAddress}`);
|
||||
});
|
||||
}
|
||||
else {
|
||||
logger.info(`listening for drachtio requests on port ${DRACHTIO_PORT}`);
|
||||
srf.listen({port: DRACHTIO_PORT, secret: DRACHTIO_SECRET});
|
||||
}
|
||||
if (NODE_ENV === 'test') {
|
||||
srf.on('error', (err) => {
|
||||
logger.info(err, 'Error connecting to drachtio');
|
||||
});
|
||||
}
|
||||
|
||||
srf.use('invite', [
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
@@ -138,20 +97,27 @@ sessionTracker.on('idle', () => {
|
||||
}
|
||||
});
|
||||
const getCount = () => sessionTracker.count;
|
||||
|
||||
const healthCheck = require('@jambonz/http-health-check');
|
||||
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');
|
||||
});
|
||||
|
||||
|
||||
const monInterval = setInterval(async() => {
|
||||
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
|
||||
try {
|
||||
const systemInformation = await srf.locals.dbHelpers.lookupSystemInformation();
|
||||
if (systemInformation && systemInformation.log_level) {
|
||||
const envLogLevel = logger.levels.values[JAMBONES_LOGLEVEL.toLowerCase()];
|
||||
const dbLogLevel = logger.levels.values[systemInformation.log_level];
|
||||
const appliedLogLevel = Math.min(envLogLevel, dbLogLevel);
|
||||
if (logger.levelVal !== appliedLogLevel) {
|
||||
logger.level = logger.levels.labels[Math.min(envLogLevel, dbLogLevel)];
|
||||
}
|
||||
logger.level = systemInformation.log_level;
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
@@ -167,7 +133,6 @@ const disconnect = () => {
|
||||
httpServer?.on('close', resolve);
|
||||
httpServer?.close();
|
||||
srf.disconnect();
|
||||
srf.removeAllListeners();
|
||||
srf.locals.mediaservers?.forEach((ms) => ms.disconnect());
|
||||
});
|
||||
};
|
||||
|
||||
@@ -163,16 +163,5 @@
|
||||
"wird sich bei Ihnen melden",
|
||||
"ich melde mich bei dir",
|
||||
"wir können nicht"
|
||||
],
|
||||
"it-IT": [
|
||||
"segreteria telefonica",
|
||||
"risponde la segreteria telefonica",
|
||||
"lascia un messaggio",
|
||||
"puoi lasciare un messaggio dopo il segnale",
|
||||
"dopo il segnale acustico",
|
||||
"il numero chiamato non è raggiungibile",
|
||||
"non è raggiungibile",
|
||||
"lascia pure un messaggio",
|
||||
"puoi lasciare un messaggio"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ const AWS_REGION = process.env.AWS_REGION;
|
||||
const AWS_ACCESS_KEY_ID = process.env.AWS_ACCESS_KEY_ID;
|
||||
const AWS_SECRET_ACCESS_KEY = process.env.AWS_SECRET_ACCESS_KEY;
|
||||
const AWS_SNS_PORT = parseInt(process.env.AWS_SNS_PORT, 10) || 3001;
|
||||
const AWS_SNS_TOPIC_ARN = process.env.AWS_SNS_TOPIC_ARN;
|
||||
const AWS_SNS_TOPIC_ARM = process.env.AWS_SNS_TOPIC_ARM;
|
||||
const AWS_SNS_PORT_MAX = parseInt(process.env.AWS_SNS_PORT_MAX, 10) || 3005;
|
||||
|
||||
const GCP_JSON_KEY = process.env.GCP_JSON_KEY;
|
||||
@@ -130,7 +130,7 @@ const OPTIONS_PING_INTERVAL = parseInt(process.env.OPTIONS_PING_INTERVAL, 10) ||
|
||||
const JAMBONZ_RECORD_WS_BASE_URL = process.env.JAMBONZ_RECORD_WS_BASE_URL || process.env.JAMBONES_RECORD_WS_BASE_URL;
|
||||
const JAMBONZ_RECORD_WS_USERNAME = process.env.JAMBONZ_RECORD_WS_USERNAME || process.env.JAMBONES_RECORD_WS_USERNAME;
|
||||
const JAMBONZ_RECORD_WS_PASSWORD = process.env.JAMBONZ_RECORD_WS_PASSWORD || process.env.JAMBONES_RECORD_WS_PASSWORD;
|
||||
const JAMBONZ_DIAL_PAI_HEADER = process.env.JAMBONZ_DIAL_PAI_HEADER || false;
|
||||
const JAMBONZ_DISABLE_DIAL_PAI_HEADER = process.env.JAMBONZ_DISABLE_DIAL_PAI_HEADER || false;
|
||||
const JAMBONES_DISABLE_DIRECT_P2P_CALL = process.env.JAMBONES_DISABLE_DIRECT_P2P_CALL || false;
|
||||
|
||||
const JAMBONES_EAGERLY_PRE_CACHE_AUDIO = parseInt(process.env.JAMBONES_EAGERLY_PRE_CACHE_AUDIO, 10) || 0;
|
||||
@@ -139,9 +139,6 @@ const JAMBONES_USE_FREESWITCH_TIMER_FD = process.env.JAMBONES_USE_FREESWITCH_TIM
|
||||
const JAMBONES_DIAL_SBC_FOR_REGISTERED_USER = process.env.JAMBONES_DIAL_SBC_FOR_REGISTERED_USER || false;
|
||||
const JAMBONES_MEDIA_TIMEOUT_MS = process.env.JAMBONES_MEDIA_TIMEOUT_MS || 0;
|
||||
const JAMBONES_MEDIA_HOLD_TIMEOUT_MS = process.env.JAMBONES_MEDIA_HOLD_TIMEOUT_MS || 0;
|
||||
// jambonz
|
||||
const JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS =
|
||||
process.env.JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS;
|
||||
|
||||
module.exports = {
|
||||
JAMBONES_MYSQL_HOST,
|
||||
@@ -192,7 +189,7 @@ module.exports = {
|
||||
AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY,
|
||||
AWS_SNS_PORT,
|
||||
AWS_SNS_TOPIC_ARN,
|
||||
AWS_SNS_TOPIC_ARM,
|
||||
AWS_SNS_PORT_MAX,
|
||||
|
||||
ANCHOR_MEDIA_ALWAYS,
|
||||
@@ -225,11 +222,10 @@ module.exports = {
|
||||
JAMBONZ_RECORD_WS_BASE_URL,
|
||||
JAMBONZ_RECORD_WS_USERNAME,
|
||||
JAMBONZ_RECORD_WS_PASSWORD,
|
||||
JAMBONZ_DIAL_PAI_HEADER,
|
||||
JAMBONZ_DISABLE_DIAL_PAI_HEADER,
|
||||
JAMBONES_DISABLE_DIRECT_P2P_CALL,
|
||||
JAMBONES_USE_FREESWITCH_TIMER_FD,
|
||||
JAMBONES_DIAL_SBC_FOR_REGISTERED_USER,
|
||||
JAMBONES_MEDIA_TIMEOUT_MS,
|
||||
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
|
||||
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS,
|
||||
JAMBONES_MEDIA_HOLD_TIMEOUT_MS
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 crypto = require('crypto');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const SipError = require('drachtio-srf').SipError;
|
||||
const { validationResult, body } = require('express-validator');
|
||||
const { validate } = require('@jambonz/verb-specifications');
|
||||
@@ -12,12 +12,10 @@ const HttpRequestor = require('../../utils/http-requestor');
|
||||
const WsRequestor = require('../../utils/ws-requestor');
|
||||
const RootSpan = require('../../utils/call-tracer');
|
||||
const dbUtils = require('../../utils/db-utils');
|
||||
const { decrypt } = require('../../utils/encrypt-decrypt');
|
||||
const { mergeSdpMedia, extractSdpMedia, removeVideoSdp } = require('../../utils/sdp-utils');
|
||||
const { mergeSdpMedia, extractSdpMedia } = require('../../utils/sdp-utils');
|
||||
const { createCallSchema, customSanitizeFunction } = require('../schemas/create-call');
|
||||
const { selectHostPort } = require('../../utils/network');
|
||||
const { JAMBONES_DIAL_SBC_FOR_REGISTERED_USER } = require('../../config');
|
||||
const { createMediaEndpoint } = require('../../utils/media-endpoint');
|
||||
|
||||
const removeNullProperties = (obj) => (Object.keys(obj).forEach((key) => obj[key] === null && delete obj[key]), obj);
|
||||
const removeNulls = (req, res, next) => {
|
||||
@@ -68,7 +66,7 @@ router.post('/',
|
||||
const {
|
||||
lookupAppBySid
|
||||
} = srf.locals.dbHelpers;
|
||||
const {getSBC} = srf.locals;
|
||||
const {getSBC, getFreeswitch} = srf.locals;
|
||||
let sbcAddress = getSBC();
|
||||
if (!sbcAddress) throw new Error('no available SBCs for outbound call creation');
|
||||
const target = restDial.to;
|
||||
@@ -82,7 +80,7 @@ router.post('/',
|
||||
const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers;
|
||||
const account = await lookupAccountBySid(req.body.account_sid);
|
||||
const accountInfo = await lookupAccountDetails(req.body.account_sid);
|
||||
const callSid = crypto.randomUUID();
|
||||
const callSid = uuidv4();
|
||||
const application = req.body.application_sid ? await lookupAppBySid(req.body.application_sid) : null;
|
||||
const record_all_calls = account.record_all_calls || (application && application.record_all_calls);
|
||||
const recordOutputFormat = account.record_format || 'mp3';
|
||||
@@ -102,7 +100,6 @@ router.post('/',
|
||||
...(req.body?.application_sid && {'X-Application-Sid': req.body.application_sid}),
|
||||
...(restDial.fromHost && {'X-Preferred-From-Host': restDial.fromHost}),
|
||||
...(record_all_calls && {'X-Record-All-Calls': recordOutputFormat}),
|
||||
...(target.proxy && {'X-SIP-Proxy': target.proxy}),
|
||||
...target.headers
|
||||
};
|
||||
|
||||
@@ -171,7 +168,9 @@ router.post('/',
|
||||
}
|
||||
|
||||
/* create endpoint for outdial */
|
||||
const ep = await createMediaEndpoint(srf, logger);
|
||||
const ms = getFreeswitch();
|
||||
if (!ms) throw new Error('no available Freeswitch for outbound call creation');
|
||||
const ep = await ms.createEndpoint();
|
||||
logger.debug(`createCall: successfully allocated endpoint, sending INVITE to ${sbcAddress}`);
|
||||
|
||||
/* launch outdial */
|
||||
@@ -180,14 +179,10 @@ router.post('/',
|
||||
let localSdp = ep.local.sdp;
|
||||
|
||||
if (req.body.dual_streams) {
|
||||
dualEp = await createMediaEndpoint(srf, logger);
|
||||
dualEp = await ms.createEndpoint();
|
||||
localSdp = mergeSdpMedia(localSdp, dualEp.local.sdp);
|
||||
}
|
||||
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS) {
|
||||
logger.debug('createCall: removing video sdp');
|
||||
localSdp = removeVideoSdp(localSdp);
|
||||
ep.modify(localSdp);
|
||||
}
|
||||
|
||||
const connectStream = async(remoteSdp) => {
|
||||
if (remoteSdp !== sdp) {
|
||||
sdp = remoteSdp;
|
||||
@@ -216,13 +211,6 @@ router.post('/',
|
||||
* we merge the inbound call application,
|
||||
* with the provided app params from the request body
|
||||
*/
|
||||
try {
|
||||
if (application?.env_vars && Object.keys(application.env_vars).length > 0) {
|
||||
restDial.env_vars = JSON.parse(decrypt(application.env_vars));
|
||||
}
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Unable to set env_vars');
|
||||
}
|
||||
const app = {
|
||||
...application,
|
||||
...req.body
|
||||
@@ -235,10 +223,9 @@ router.post('/',
|
||||
if ('WS' === app.call_hook?.method || /^wss?:/.test(app.call_hook.url)) {
|
||||
logger.debug({call_hook: app.call_hook}, 'creating websocket for call hook');
|
||||
app.requestor = new WsRequestor(logger, account.account_sid, app.call_hook, account.webhook_secret) ;
|
||||
if (app.call_hook.url === app.call_status_hook?.url || !app.call_status_hook?.url) {
|
||||
if (app.call_hook.url === app.call_status_hook.url || !app.call_status_hook?.url) {
|
||||
logger.debug('reusing websocket for call status hook');
|
||||
app.notifier = app.requestor;
|
||||
app.call_status_hook = app.call_hook;
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -116,8 +116,8 @@ const customSanitizeFunction = (value) => {
|
||||
/* trims characters at the beginning and at the end of a string */
|
||||
value = value.trim();
|
||||
|
||||
// Only attempt to parse if the whole string is a URL
|
||||
if (/^https?:\/\/\S+$/.test(value)) {
|
||||
/* Verify strings including 'http' via new URL */
|
||||
if (value.includes('http')) {
|
||||
value = new URL(value).toString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const crypto = require('crypto');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const {CallDirection, AllowedSipRecVerbs, WS_CLOSE_CODES} = require('./utils/constants');
|
||||
const {parseSiprecPayload} = require('./utils/siprec-utils');
|
||||
const CallInfo = require('./session/call-info');
|
||||
@@ -15,7 +15,6 @@ const {
|
||||
JAMBONES_DISABLE_DIRECT_P2P_CALL
|
||||
} = require('./config');
|
||||
const { createJambonzApp } = require('./dynamic-apps');
|
||||
const { decrypt } = require('./utils/encrypt-decrypt');
|
||||
|
||||
module.exports = function(srf, logger) {
|
||||
const {
|
||||
@@ -46,7 +45,7 @@ module.exports = function(srf, logger) {
|
||||
logger.info('getAccountDetails - rejecting call due to missing X-Account-Sid header');
|
||||
return res.send(500);
|
||||
}
|
||||
const callSid = req.has('X-Retain-Call-Sid') ? req.get('X-Retain-Call-Sid') : crypto.randomUUID();
|
||||
const callSid = req.has('X-Retain-Call-Sid') ? req.get('X-Retain-Call-Sid') : uuidv4();
|
||||
const account_sid = req.get('X-Account-Sid');
|
||||
req.locals = {callSid, account_sid, callId};
|
||||
|
||||
@@ -332,7 +331,7 @@ module.exports = function(srf, logger) {
|
||||
}
|
||||
|
||||
// Resolve application.speech_synthesis_voice if it's custom voice
|
||||
if (app2.speech_synthesis_vendor === 'google' && app2.speech_synthesis_voice?.startsWith('custom_')) {
|
||||
if (app2.speech_synthesis_vendor === 'google' && app2.speech_synthesis_voice.startsWith('custom_')) {
|
||||
const arr = /custom_(.*)/.exec(app2.speech_synthesis_voice);
|
||||
if (arr) {
|
||||
const google_custom_voice_sid = arr[1];
|
||||
@@ -349,10 +348,11 @@ module.exports = function(srf, logger) {
|
||||
}
|
||||
|
||||
req.locals.application = app2;
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {call_hook, call_status_hook, ...appInfo} = app; // mask sensitive data like user/pass on webhook
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {requestor, notifier, env_vars, ...loggable} = appInfo;
|
||||
const {requestor, notifier, ...loggable} = appInfo;
|
||||
logger.info({app: loggable}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
|
||||
req.locals.callInfo = new CallInfo({
|
||||
req,
|
||||
@@ -417,28 +417,16 @@ module.exports = function(srf, logger) {
|
||||
...(app.fallback_speech_recognizer_language && {fallback_language: app.fallback_speech_recognizer_language})
|
||||
}
|
||||
};
|
||||
let env_vars;
|
||||
try {
|
||||
if (app.env_vars) {
|
||||
const d_env_vars = JSON.parse(decrypt(app.env_vars));
|
||||
logger.info(`Setting env_vars: ${Object.keys(d_env_vars)}`); // Only log the keys not the values
|
||||
env_vars = d_env_vars;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Unable to set env_vars');
|
||||
}
|
||||
const params = Object.assign(['POST', 'WS'].includes(app.call_hook.method) ? { sip: req.msg } : {},
|
||||
req.locals.callInfo,
|
||||
{ service_provider_sid: req.locals.service_provider_sid },
|
||||
{ defaults },
|
||||
{ env_vars }
|
||||
);
|
||||
{ defaults });
|
||||
logger.debug({ params }, 'sending initial webhook');
|
||||
const obj = rootSpan.startChildSpan('performAppWebhook');
|
||||
span = obj.span;
|
||||
const b3 = rootSpan.getTracingPropagation();
|
||||
const httpHeaders = b3 && { b3 };
|
||||
json = await app.requestor.request('session:new', app.call_hook, params, httpHeaders, span);
|
||||
json = await app.requestor.request('session:new', app.call_hook, params, httpHeaders);
|
||||
}
|
||||
|
||||
app.tasks = normalizeJambones(logger, json).map((tdata) => makeTask(logger, tdata));
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const {CallDirection, CallStatus} = require('../utils/constants');
|
||||
const parseUri = require('drachtio-srf').parseUri;
|
||||
const crypto = require('crypto');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const {JAMBONES_API_BASE_URL} = require('../config');
|
||||
/**
|
||||
* @classdesc Represents the common information for all calls
|
||||
@@ -57,7 +57,7 @@ class CallInfo {
|
||||
// outbound call that is a child of an existing call
|
||||
const {req, parentCallInfo, to, callSid} = opts;
|
||||
srf = req.srf;
|
||||
this.callSid = callSid || crypto.randomUUID();
|
||||
this.callSid = callSid || uuidv4();
|
||||
this.parentCallSid = parentCallInfo.callSid;
|
||||
this.accountSid = parentCallInfo.accountSid;
|
||||
this.applicationSid = parentCallInfo.applicationSid;
|
||||
|
||||
@@ -10,9 +10,8 @@ const {
|
||||
RecordState,
|
||||
AllowedSipRecVerbs,
|
||||
AllowedConfirmSessionVerbs,
|
||||
TtsStreamingEvents,
|
||||
ListenStatus
|
||||
} = require('../utils/constants.json');
|
||||
TtsStreamingEvents
|
||||
} = require('../utils/constants');
|
||||
const moment = require('moment');
|
||||
const assert = require('assert');
|
||||
const sessionTracker = require('./session-tracker');
|
||||
@@ -24,12 +23,14 @@ const HttpRequestor = require('../utils/http-requestor');
|
||||
const WsRequestor = require('../utils/ws-requestor');
|
||||
const ActionHookDelayProcessor = require('../utils/action-hook-delay');
|
||||
const TtsStreamingBuffer = require('../utils/tts-streaming-buffer');
|
||||
const StickyEventEmitter = require('../utils/sticky-event-emitter');
|
||||
const {parseUri} = require('drachtio-srf');
|
||||
const {
|
||||
JAMBONES_INJECT_CONTENT,
|
||||
JAMBONES_EAGERLY_PRE_CACHE_AUDIO,
|
||||
AWS_REGION,
|
||||
JAMBONES_USE_FREESWITCH_TIMER_FD,
|
||||
JAMBONES_MEDIA_TIMEOUT_MS,
|
||||
JAMBONES_MEDIA_HOLD_TIMEOUT_MS
|
||||
} = require('../config');
|
||||
const bent = require('bent');
|
||||
const BackgroundTaskManager = require('../utils/background-task-manager');
|
||||
@@ -37,8 +38,6 @@ const dbUtils = require('../utils/db-utils');
|
||||
const BADPRECONDITIONS = 'preconditions not met';
|
||||
const CALLER_CANCELLED_ERR_MSG = 'Response not sent due to unknown transaction';
|
||||
const { NonFatalTaskError} = require('../utils/error');
|
||||
const { createMediaEndpoint } = require('../utils/media-endpoint');
|
||||
const SttLatencyCalculator = require('../utils/stt-latency-calculator');
|
||||
const sqlRetrieveQueueEventHook = `SELECT * FROM webhooks
|
||||
WHERE webhook_sid =
|
||||
(
|
||||
@@ -78,11 +77,6 @@ class CallSession extends Emitter {
|
||||
this.callGone = false;
|
||||
this.notifiedComplete = false;
|
||||
this.rootSpan = rootSpan;
|
||||
this.stickyEventEmitter = new StickyEventEmitter();
|
||||
this.stickyEventEmitter.onSuccess = () => {
|
||||
this.taskInProgress = null;
|
||||
this.stickyEventEmitter.destroy();
|
||||
};
|
||||
this.backgroundTaskManager = new BackgroundTaskManager({
|
||||
cs: this,
|
||||
logger,
|
||||
@@ -139,39 +133,6 @@ class CallSession extends Emitter {
|
||||
this.requestor.on('handover', handover.bind(this));
|
||||
this.requestor.on('reconnect-error', this._onSessionReconnectError.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Currently this is used for openai STT, which has a prompt paramater and
|
||||
* we have an experimental feature where you can send the conversation
|
||||
* history in the prompt
|
||||
*/
|
||||
this.conversationTurns = [];
|
||||
this.on('userSaid', this._onUserSaid.bind(this));
|
||||
this.on('botSaid', this._onBotSaid.bind(this));
|
||||
/**
|
||||
* Support STT latency
|
||||
*/
|
||||
this.sttLatencyCalculator = new SttLatencyCalculator({
|
||||
logger,
|
||||
cs: this
|
||||
});
|
||||
this.on('transcribe-start', () => {
|
||||
this.sttLatencyCalculator.resetTime();
|
||||
});
|
||||
this.on('on-transcription', () => {
|
||||
this.sttLatencyCalculator.onTranscriptionReceived();
|
||||
});
|
||||
this.on('transcribe-stop', () => {
|
||||
this.sttLatencyCalculator.onTranscribeStop();
|
||||
});
|
||||
}
|
||||
|
||||
get notifySttLatencyEnabled() {
|
||||
return this._notifySttLatencyEnabled || false;
|
||||
}
|
||||
|
||||
set notifySttLatencyEnabled(enabled) {
|
||||
this._notifySttLatencyEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,18 +203,6 @@ class CallSession extends Emitter {
|
||||
this._synthesizer = synth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Say stream enabled
|
||||
*/
|
||||
|
||||
get autoStreamTts() {
|
||||
return this._autoStreamTts || false;
|
||||
}
|
||||
|
||||
set autoStreamTts(i) {
|
||||
this._autoStreamTts = i;
|
||||
}
|
||||
|
||||
/**
|
||||
* ASR TTS fallback
|
||||
*/
|
||||
@@ -742,25 +691,10 @@ class CallSession extends Emitter {
|
||||
return this._fillerNoise;
|
||||
}
|
||||
|
||||
async pauseOrResumeBackgroundListenIfRequired(action, silence = false) {
|
||||
if ((action == 'pauseCallRecording' || action == 'resumeCallRecording') &&
|
||||
this.backgroundTaskManager.isTaskRunning('record')) {
|
||||
this.logger.debug({action, silence}, 'CallSession:pauseOrResumeBackgroundListenIfRequired');
|
||||
const backgroundListenTask = this.backgroundTaskManager.getTask('record');
|
||||
const status = action === 'pauseCallRecording' ? ListenStatus.Pause : ListenStatus.Resume;
|
||||
backgroundListenTask.updateListen(
|
||||
status,
|
||||
silence
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async notifyRecordOptions(opts) {
|
||||
const {action, silence} = opts;
|
||||
const {action} = opts;
|
||||
this.logger.debug({opts}, 'CallSession:notifyRecordOptions');
|
||||
|
||||
this.pauseOrResumeBackgroundListenIfRequired(action, silence);
|
||||
|
||||
/* if we have not answered yet, just save the details for later */
|
||||
if (!this.dlg) {
|
||||
if (action === 'startCallRecording') {
|
||||
@@ -1017,7 +951,7 @@ class CallSession extends Emitter {
|
||||
(type === 'tts' && credential.use_for_tts) ||
|
||||
(type === 'stt' && credential.use_for_stt)
|
||||
)) {
|
||||
this.logger.debug(
|
||||
this.logger.info(
|
||||
`${type}: ${credential.vendor} ${credential.label ? `, label: ${credential.label}` : ''} `);
|
||||
if ('google' === vendor) {
|
||||
if (type === 'tts' && !credential.tts_tested_ok ||
|
||||
@@ -1082,7 +1016,6 @@ class CallSession extends Emitter {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
api_key: credential.api_key,
|
||||
model_id: credential.model_id,
|
||||
deepgram_stt_uri: credential.deepgram_stt_uri,
|
||||
deepgram_tts_uri: credential.deepgram_tts_uri,
|
||||
deepgram_stt_use_tls: credential.deepgram_stt_use_tls
|
||||
@@ -1134,7 +1067,6 @@ class CallSession extends Emitter {
|
||||
return {
|
||||
api_key: credential.api_key,
|
||||
model_id: credential.model_id,
|
||||
stt_model_id: credential.stt_model_id,
|
||||
embedding: credential.embedding,
|
||||
options: credential.options
|
||||
};
|
||||
@@ -1146,34 +1078,7 @@ class CallSession extends Emitter {
|
||||
options: credential.options
|
||||
};
|
||||
}
|
||||
else if ('resemble' === vendor) {
|
||||
return {
|
||||
api_key: credential.api_key,
|
||||
resemble_tts_use_tls: credential.resemble_tts_use_tls,
|
||||
resemble_tts_uri: credential.resemble_tts_uri,
|
||||
};
|
||||
}
|
||||
else if ('inworld' === vendor) {
|
||||
return {
|
||||
api_key: credential.api_key,
|
||||
model_id: credential.model_id,
|
||||
options: credential.options
|
||||
};
|
||||
}
|
||||
else if ('assemblyai' === vendor) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
api_key: credential.api_key,
|
||||
service_version: credential.service_version
|
||||
};
|
||||
}
|
||||
else if ('deepgramriver' === vendor) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
api_key: credential.api_key,
|
||||
};
|
||||
}
|
||||
else if ('voxist' === vendor) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
api_key: credential.api_key
|
||||
@@ -1193,17 +1098,12 @@ class CallSession extends Emitter {
|
||||
};
|
||||
}
|
||||
else if ('speechmatics' === vendor) {
|
||||
this.logger.info({credential}, 'CallSession:getSpeechCredentials - speechmatics credential');
|
||||
return {
|
||||
api_key: credential.api_key,
|
||||
speechmatics_stt_uri: credential.speechmatics_stt_uri,
|
||||
};
|
||||
}
|
||||
else if ('openai' === vendor) {
|
||||
return {
|
||||
api_key: credential.api_key,
|
||||
model_id: credential.model_id,
|
||||
};
|
||||
}
|
||||
else if (vendor.startsWith('custom:')) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
@@ -1258,10 +1158,7 @@ class CallSession extends Emitter {
|
||||
const taskNum = ++this.taskIdx;
|
||||
const stackNum = this.stackIdx;
|
||||
const task = this.tasks.shift();
|
||||
this.isCurTaskPlay = TaskName.Play === task.name;
|
||||
this.taskInProgress = task;
|
||||
this.logger.info(
|
||||
`CallSession:exec starting task #${stackNum}:${taskNum}: ${task.name} (task id: ${task.taskId})`);
|
||||
this.logger.info(`CallSession:exec starting task #${stackNum}:${taskNum}: ${task.name}`);
|
||||
this._notifyTaskStatus(task, {event: 'starting'});
|
||||
// Register verbhook span wait for end
|
||||
task.on('VerbHookSpanWaitForEnd', ({span}) => {
|
||||
@@ -1335,7 +1232,7 @@ class CallSession extends Emitter {
|
||||
this.logger.info('CallSession:exec all tasks complete');
|
||||
this._stopping = true;
|
||||
this._onTasksDone();
|
||||
await this._clearResources();
|
||||
this._clearResources();
|
||||
|
||||
|
||||
if (!this.isConfirmCallSession && !this.isSmsCallSession) sessionTracker.remove(this.callSid);
|
||||
@@ -1406,7 +1303,7 @@ class CallSession extends Emitter {
|
||||
_lccCallStatus(opts) {
|
||||
if (opts.call_status === CallStatus.Completed && this.dlg) {
|
||||
this.logger.info('CallSession:_lccCallStatus hanging up call due to request from api');
|
||||
this._jambonzHangup();
|
||||
this._callerHungup();
|
||||
}
|
||||
else if (opts.call_status === CallStatus.NoAnswer) {
|
||||
if (this.direction === CallDirection.Inbound) {
|
||||
@@ -1480,14 +1377,6 @@ class CallSession extends Emitter {
|
||||
method: 'POST'
|
||||
};
|
||||
}
|
||||
|
||||
/* if given a relative url then send to same base url as current session */
|
||||
if (this.requestor._isRelativeUrl(opts.call_hook?.url)) {
|
||||
opts.call_hook.url = `${this.requestor.baseUrl}${opts.call_hook?.url}`;
|
||||
}
|
||||
if (this.requestor._isRelativeUrl(opts.call_status_hook?.url)) {
|
||||
opts.call_status_hook.url = `${this.requestor.baseUrl}${opts.call_status_hook?.url}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1548,7 +1437,7 @@ class CallSession extends Emitter {
|
||||
}
|
||||
if (tasks) {
|
||||
const t = normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata));
|
||||
this.logger.debug({tasks: listTaskNames(t)}, 'CallSession:_lccCallHook new task list');
|
||||
this.logger.info({tasks: listTaskNames(t)}, 'CallSession:_lccCallHook new task list');
|
||||
this.replaceApplication(t);
|
||||
if (this.wakeupResolver) {
|
||||
//this.logger.debug({resolution}, 'CallSession:_onCommand - got commands, waking up..');
|
||||
@@ -1592,7 +1481,7 @@ class CallSession extends Emitter {
|
||||
this.backgroundTaskManager.getTask('transcribe').updateTranscribe(opts.transcribe_status);
|
||||
}
|
||||
const task = this.currentTask;
|
||||
if (!task || ![TaskName.Dial, TaskName.Transcribe, TaskName.Listen].includes(task.name)) {
|
||||
if (!task || ![TaskName.Dial, TaskName.Transcribe].includes(task.name)) {
|
||||
return this.logger.info(`CallSession:_lccTranscribeStatus - invalid transcribe_status in task ${task.name}`);
|
||||
}
|
||||
const transcribeTask = task.name === TaskName.Transcribe ? task : task.transcribeTask;
|
||||
@@ -1715,8 +1604,8 @@ Duration=${duration} `
|
||||
async _lccWhisper(opts, callSid) {
|
||||
const {whisper} = opts;
|
||||
let tasks;
|
||||
//const b3 = this.b3;
|
||||
//const httpHeaders = b3 && {b3};
|
||||
const b3 = this.b3;
|
||||
const httpHeaders = b3 && {b3};
|
||||
|
||||
// this whole thing requires us to be in a Dial verb
|
||||
const task = this.currentTask;
|
||||
@@ -1725,15 +1614,12 @@ Duration=${duration} `
|
||||
}
|
||||
|
||||
// allow user to provide a url object, a url string, an array of tasks, or a single task
|
||||
// Disable passing a URL as not functional Sam Machin - 17/07/2025
|
||||
//if (typeof whisper === 'string' || (typeof whisper === 'object' && whisper.url && !whisper.verb)) {
|
||||
// // retrieve a url
|
||||
// const json = await this.requestor(opts.call_hook, this.callInfo.toJSON(), httpHeaders);
|
||||
// tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
//}
|
||||
//else if (Array.isArray(whisper)) {
|
||||
|
||||
if (Array.isArray(whisper)) {
|
||||
if (typeof whisper === 'string' || (typeof whisper === 'object' && whisper.url)) {
|
||||
// retrieve a url
|
||||
const json = await this.requestor(opts.call_hook, this.callInfo.toJSON(), httpHeaders);
|
||||
tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
}
|
||||
else if (Array.isArray(whisper)) {
|
||||
// an inline array of tasks
|
||||
tasks = normalizeJambones(this.logger, whisper).map((tdata) => makeTask(this.logger, tdata));
|
||||
}
|
||||
@@ -1818,10 +1704,10 @@ Duration=${duration} `
|
||||
this.currentTask.ep :
|
||||
this.ep;
|
||||
const db = parseDecibels(opts);
|
||||
this.logger.debug(`_lccBoostAudioSignal: boosting audio signal by ${db} dB`);
|
||||
this.logger.info(`_lccBoostAudioSignal: boosting audio signal by ${db} dB`);
|
||||
const args = [ep.uuid, 'setGain', db];
|
||||
const response = await ep.api('uuid_dub', args);
|
||||
this.logger.debug({response}, '_lccBoostAudioSignal: response from freeswitch');
|
||||
this.logger.info({response}, '_lccBoostAudioSignal: response from freeswitch');
|
||||
}
|
||||
|
||||
async _lccMediaPath(desiredPath) {
|
||||
@@ -1874,6 +1760,7 @@ Duration=${duration} `
|
||||
let res;
|
||||
try {
|
||||
res = await this.ttsStreamingBuffer?.bufferTokens(tokens);
|
||||
this.logger.info({id, res}, 'CallSession:_lccTtsTokens - tts:tokens-result');
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'CallSession:_lccTtsTokens');
|
||||
}
|
||||
@@ -1881,10 +1768,6 @@ Duration=${duration} `
|
||||
.catch((err) => this.logger.debug({err}, 'CallSession:_notifyTaskStatus - Error sending'));
|
||||
}
|
||||
|
||||
async _internalTtsStreamingBufferTokens(tokens) {
|
||||
return await this.ttsStreamingBuffer?.bufferTokens(tokens) || {status: 'failed', reason: 'no tts streaming buffer'};
|
||||
}
|
||||
|
||||
_lccTtsFlush(opts) {
|
||||
this.ttsStreamingBuffer?.flush(opts);
|
||||
}
|
||||
@@ -1910,77 +1793,62 @@ Duration=${duration} `
|
||||
async updateCall(opts, callSid) {
|
||||
this.logger.debug(opts, 'CallSession:updateCall');
|
||||
|
||||
try {
|
||||
if (opts.call_status) {
|
||||
return this._lccCallStatus(opts);
|
||||
}
|
||||
if (opts.call_hook || opts.child_call_hook) {
|
||||
return await this._lccCallHook(opts);
|
||||
}
|
||||
if (opts.listen_status || opts.stream_status) {
|
||||
await this._lccListenStatus(opts);
|
||||
}
|
||||
if (opts.transcribe_status) {
|
||||
await this._lccTranscribeStatus(opts);
|
||||
}
|
||||
else if (opts.mute_status) {
|
||||
await this._lccMuteStatus(opts.mute_status === 'mute', callSid);
|
||||
}
|
||||
else if (opts.conf_hold_status) {
|
||||
await this._lccConfHoldStatus(opts);
|
||||
}
|
||||
else if (opts.conf_mute_status) {
|
||||
await this._lccConfMuteStatus(opts);
|
||||
}
|
||||
else if (opts.sip_request) {
|
||||
const res = await this._lccSipRequest(opts, callSid);
|
||||
return {status: res.status, reason: res.reason};
|
||||
} else if (opts.dtmf) {
|
||||
await this._lccDtmf(opts, callSid);
|
||||
}
|
||||
else if (opts.record) {
|
||||
await this.notifyRecordOptions(opts.record);
|
||||
}
|
||||
else if (opts.tag) {
|
||||
return this._lccTag(opts);
|
||||
}
|
||||
else if (opts.conferenceParticipantAction) {
|
||||
return this._lccConferenceParticipantAction(opts.conferenceParticipantAction);
|
||||
}
|
||||
else if (opts.dub) {
|
||||
return this._lccDub(opts.dub, callSid);
|
||||
}
|
||||
else if (opts.boostAudioSignal) {
|
||||
return this._lccBoostAudioSignal(opts, callSid);
|
||||
}
|
||||
else if (opts.media_path) {
|
||||
return this._lccMediaPath(opts.media_path, callSid);
|
||||
}
|
||||
else if (opts.llm_tool_output) {
|
||||
return this._lccToolOutput(opts.tool_call_id, opts.llm_tool_output, callSid);
|
||||
}
|
||||
else if (opts.llm_update) {
|
||||
return this._lccLlmUpdate(opts.llm_update, callSid);
|
||||
}
|
||||
if (opts.call_status) {
|
||||
return this._lccCallStatus(opts);
|
||||
}
|
||||
if (opts.call_hook || opts.child_call_hook) {
|
||||
return await this._lccCallHook(opts);
|
||||
}
|
||||
if (opts.listen_status || opts.stream_status) {
|
||||
await this._lccListenStatus(opts);
|
||||
}
|
||||
if (opts.transcribe_status) {
|
||||
await this._lccTranscribeStatus(opts);
|
||||
}
|
||||
else if (opts.mute_status) {
|
||||
await this._lccMuteStatus(opts.mute_status === 'mute', callSid);
|
||||
}
|
||||
else if (opts.conf_hold_status) {
|
||||
await this._lccConfHoldStatus(opts);
|
||||
}
|
||||
else if (opts.conf_mute_status) {
|
||||
await this._lccConfMuteStatus(opts);
|
||||
}
|
||||
else if (opts.sip_request) {
|
||||
const res = await this._lccSipRequest(opts, callSid);
|
||||
return {status: res.status, reason: res.reason};
|
||||
} else if (opts.dtmf) {
|
||||
await this._lccDtmf(opts, callSid);
|
||||
}
|
||||
else if (opts.record) {
|
||||
await this.notifyRecordOptions(opts.record);
|
||||
}
|
||||
else if (opts.tag) {
|
||||
return this._lccTag(opts);
|
||||
}
|
||||
else if (opts.conferenceParticipantAction) {
|
||||
return this._lccConferenceParticipantAction(opts.conferenceParticipantAction);
|
||||
}
|
||||
else if (opts.dub) {
|
||||
return this._lccDub(opts.dub, callSid);
|
||||
}
|
||||
else if (opts.boostAudioSignal) {
|
||||
return this._lccBoostAudioSignal(opts, callSid);
|
||||
}
|
||||
else if (opts.media_path) {
|
||||
return this._lccMediaPath(opts.media_path, callSid);
|
||||
}
|
||||
else if (opts.llm_tool_output) {
|
||||
return this._lccToolOutput(opts.tool_call_id, opts.llm_tool_output, callSid);
|
||||
}
|
||||
else if (opts.llm_update) {
|
||||
return this._lccLlmUpdate(opts.llm_update, callSid);
|
||||
}
|
||||
|
||||
// whisper may be the only thing we are asked to do, or it may that
|
||||
// we are doing a whisper after having muted, paused recording etc..
|
||||
if (opts.whisper) {
|
||||
return this._lccWhisper(opts, callSid);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err, opts, callSid}, 'CallSession:updateCall - error updating call');
|
||||
const {writeAlerts} = this.srf.locals;
|
||||
try {
|
||||
writeAlerts({
|
||||
alert_type: 'error-updating-call',
|
||||
account_sid: this.accountSid,
|
||||
message: err.message,
|
||||
target_sid: callSid
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Error writing error-updating-call alert');
|
||||
}
|
||||
// whisper may be the only thing we are asked to do, or it may that
|
||||
// we are doing a whisper after having muted, paused recording etc..
|
||||
if (opts.whisper) {
|
||||
return this._lccWhisper(opts, callSid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2023,14 +1891,10 @@ Duration=${duration} `
|
||||
this.tasks = tasks;
|
||||
this.taskIdx = 0;
|
||||
this.stackIdx++;
|
||||
this.logger.debug({tasks: listTaskNames(tasks)},
|
||||
this.logger.info({tasks: listTaskNames(tasks)},
|
||||
`CallSession:replaceApplication reset with ${tasks.length} new tasks, stack depth is ${this.stackIdx}`);
|
||||
let curTaskKilled = false;
|
||||
if (this.currentTask) {
|
||||
this.logger.debug('CallSession:replaceApplication - killing current task ' +
|
||||
this.currentTask?.name + ', taskId: ' + this.currentTask.taskId);
|
||||
this.currentTask.kill(this, KillReason.Replaced);
|
||||
curTaskKilled = true;
|
||||
this.currentTask = null;
|
||||
}
|
||||
else if (this.wakeupResolver) {
|
||||
@@ -2038,16 +1902,11 @@ Duration=${duration} `
|
||||
this.wakeupResolver({reason: 'new tasks'});
|
||||
this.wakeupResolver = null;
|
||||
}
|
||||
// if currentTask which is play, already got killed, no need to call uuid_break
|
||||
if (!curTaskKilled && (!this.currentTask || this.currentTask === undefined) && this.isCurTaskPlay) {
|
||||
this.logger.debug(`CallSession:replaceApplication - emitting uuid_break, taskId: ${this.taskInProgress?.taskId}`);
|
||||
this.stickyEventEmitter.emit('uuid_break', this.taskInProgress);
|
||||
}
|
||||
}
|
||||
|
||||
kill(onBackgroundGatherBargein = false) {
|
||||
if (this.isConfirmCallSession) this.logger.debug('CallSession:kill (ConfirmSession)');
|
||||
else this.logger.debug('CallSession:kill');
|
||||
else this.logger.info('CallSession:kill');
|
||||
this._endVerbHookSpan();
|
||||
if (this.currentTask) {
|
||||
this.currentTask.kill(this);
|
||||
@@ -2112,7 +1971,7 @@ Duration=${duration} `
|
||||
task.synthesizer.label :
|
||||
this.speechSynthesisLabel;
|
||||
|
||||
this.logger.debug({vendor, language, voice, label},
|
||||
this.logger.info({vendor, language, voice, label},
|
||||
'CallSession:_preCacheAudio - precaching audio for future prompt');
|
||||
task._synthesizeWithSpecificVendor(this, this.ep, {vendor, language, voice, label, preCache: true})
|
||||
.catch((err) => this.logger.error(err, 'CallSession:_preCacheAudio - error precaching audio'));
|
||||
@@ -2182,7 +2041,7 @@ Duration=${duration} `
|
||||
}
|
||||
|
||||
async _onCommand({msgid, command, call_sid, queueCommand, tool_call_id, data}) {
|
||||
this.logger.debug({msgid, command, queueCommand, data}, 'CallSession:_onCommand - received command');
|
||||
this.logger.info({msgid, command, queueCommand, data}, 'CallSession:_onCommand - received command');
|
||||
let resolution;
|
||||
switch (command) {
|
||||
case 'redirect':
|
||||
@@ -2191,18 +2050,18 @@ Duration=${duration} `
|
||||
const t = normalizeJambones(this.logger, data)
|
||||
.map((tdata) => makeTask(this.logger, tdata));
|
||||
if (!queueCommand) {
|
||||
this.logger.debug({tasks: listTaskNames(t)}, 'CallSession:_onCommand new task list');
|
||||
this.logger.info({tasks: listTaskNames(t)}, 'CallSession:_onCommand new task list');
|
||||
this.replaceApplication(t);
|
||||
}
|
||||
else if (JAMBONES_INJECT_CONTENT) {
|
||||
if (JAMBONES_EAGERLY_PRE_CACHE_AUDIO) this._preCacheAudio(t);
|
||||
this._injectTasks(t);
|
||||
this.logger.debug({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
|
||||
this.logger.info({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
|
||||
}
|
||||
else {
|
||||
if (JAMBONES_EAGERLY_PRE_CACHE_AUDIO) this._preCacheAudio(t);
|
||||
this.tasks.push(...t);
|
||||
this.logger.debug({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
|
||||
this.logger.info({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
|
||||
}
|
||||
resolution = {reason: 'received command, new tasks', queue: queueCommand, command};
|
||||
resolution.command = listTaskNames(t);
|
||||
@@ -2382,16 +2241,17 @@ Duration=${duration} `
|
||||
|
||||
// need to allocate an endpoint
|
||||
try {
|
||||
const ep = await this._createMediaEndpoint({
|
||||
if (!this.ms) this.ms = this.getMS();
|
||||
const ep = await this.ms.createEndpoint({
|
||||
headers: {
|
||||
'X-Jambones-Call-ID': this.callId,
|
||||
'X-Call-Sid': this.callSid,
|
||||
},
|
||||
remoteSdp: this.req.body
|
||||
});
|
||||
//ep.cs = this;
|
||||
this.ep = ep;
|
||||
this.logger.info(`allocated endpoint ${ep.uuid}`);
|
||||
this._configMsEndpoint();
|
||||
|
||||
this.ep.on('destroy', () => {
|
||||
this.logger.debug(`endpoint was destroyed!! ${this.ep.uuid}`);
|
||||
@@ -2462,34 +2322,16 @@ Duration=${duration} `
|
||||
this.logger.error('CallSession:replaceEndpoint cannot be called without stable dlg');
|
||||
return;
|
||||
}
|
||||
// When this call kicked out from conference, session need to replace endpoint
|
||||
// but this.ms might be undefined/null at this case.
|
||||
this.ms = this.ms || this.getMS();
|
||||
// Destroy previous ep if it's still running.
|
||||
if (this.ep?.connected) this.ep.destroy();
|
||||
|
||||
/* Codec negotiation issue explanation:
|
||||
*
|
||||
* Problem scenario:
|
||||
* 1. Initial negotiation:
|
||||
* - FreeSWITCH → SBC: offers multiple codecs (PCMU, PCMA, G722)
|
||||
* - SBC → Callee: passes all codecs (PCMU, PCMA, G722)
|
||||
* - Callee → SBC: responds with PCMA (its supported codec)
|
||||
* - SBC → FreeSWITCH: responds with PCMU (after transcoding)
|
||||
*
|
||||
* 2. After endpoint replacement:
|
||||
* - If we only offer PCMU in the new endpoint
|
||||
* - FreeSWITCH → SBC: offers only PCMU
|
||||
* - SBC → Callee: offers only PCMU
|
||||
* - Call fails: Callee rejects since it only supports PCMA
|
||||
*
|
||||
* Solution:
|
||||
* Always have FreeSWITCH offer multiple codecs to the SBC, don't pass remote sdp here to ensure
|
||||
* the SBC can reoffer the same codecs that the callee originally accepted.
|
||||
* This prevents call failures during media renegotiation.
|
||||
*/
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||
this._configMsEndpoint();
|
||||
|
||||
this.ep = await this._createMediaEndpoint();
|
||||
|
||||
const sdp = await this.dlg.modify(this.ep.local.sdp);
|
||||
await this.ep.modify(sdp);
|
||||
await this.dlg.modify(this.ep.local.sdp);
|
||||
this.logger.debug('CallSession:replaceEndpoint completed');
|
||||
return this.ep;
|
||||
}
|
||||
@@ -2497,16 +2339,9 @@ Duration=${duration} `
|
||||
/**
|
||||
* Hang up the call and free the media endpoint
|
||||
*/
|
||||
async _clearResources() {
|
||||
this.stickyEventEmitter.destroy();
|
||||
this.stickyEventEmitter = null;
|
||||
this.taskInProgress = null;
|
||||
_clearResources() {
|
||||
for (const resource of [this.dlg, this.ep, this.ep2]) {
|
||||
try {
|
||||
if (resource && resource.connected) await resource.destroy();
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'CallSession:_clearResources - error clearing resources');
|
||||
}
|
||||
if (resource && resource.connected) resource.destroy();
|
||||
}
|
||||
this.dlg = null;
|
||||
this.ep = null;
|
||||
@@ -2530,8 +2365,6 @@ Duration=${duration} `
|
||||
this.clearOrRestoreActionHookDelayProcessor().catch((err) => {});
|
||||
|
||||
this.ttsStreamingBuffer?.stop();
|
||||
|
||||
this.sttLatencyCalculator?.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2635,14 +2468,12 @@ Duration=${duration} `
|
||||
} else if (sip_method === 'MESSAGE') {
|
||||
res.send(202);
|
||||
} else {
|
||||
this.logger.warn(`CallSession:_onRequestWithinDialog unsupported method: ${req.method}`);
|
||||
this.logger.info(`CallSession:_onRequestWithinDialog unsported method: ${req.method}`);
|
||||
res.send(501);
|
||||
return;
|
||||
}
|
||||
const params = {sip_method, sip_body: req.body, sip_headers: req.headers};
|
||||
this.currentTask.performHook(this, this.sipRequestWithinDialogHook, params).catch((err) => {
|
||||
this.logger.error({err}, 'CallSession:_onRequestWithinDialog - error calling sipRequestWithinDialogHook');
|
||||
});
|
||||
this.currentTask.performHook(this, this.sipRequestWithinDialogHook, params);
|
||||
}
|
||||
|
||||
async _onReinvite(req, res) {
|
||||
@@ -2653,7 +2484,7 @@ Duration=${duration} `
|
||||
res.send(200, {body: this.ep.local.sdp});
|
||||
}
|
||||
else {
|
||||
if (this.currentTask && this.currentTask.name === TaskName.Dial && this.currentTask.isOnHoldEnabled) {
|
||||
if (this.currentTask.name === TaskName.Dial && this.currentTask.isOnHoldEnabled) {
|
||||
this.logger.info('onholdMusic reINVITE after media has been released');
|
||||
await this.currentTask.handleReinviteAfterMediaReleased(req, res);
|
||||
} else {
|
||||
@@ -2722,7 +2553,7 @@ Duration=${duration} `
|
||||
if (json && Array.isArray(json)) {
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
if (tasks && tasks.length > 0) {
|
||||
this.logger.debug('CallSession:handleRefer received REFER, get new tasks');
|
||||
this.logger.info('CallSession:handleRefer received REFER, get new tasks');
|
||||
this.replaceApplication(tasks);
|
||||
if (this.wakeupResolver) {
|
||||
this.wakeupResolver({reason: 'CallSession: referHook new taks'});
|
||||
@@ -2748,8 +2579,15 @@ Duration=${duration} `
|
||||
async createOrRetrieveEpAndMs() {
|
||||
if (this.ms && this.ep) return {ms: this.ms, ep: this.ep};
|
||||
|
||||
// get a media server
|
||||
if (!this.ms) {
|
||||
const ms = this.srf.locals.getFreeswitch();
|
||||
if (!ms) throw new Error('no available freeswitch');
|
||||
this.ms = ms;
|
||||
}
|
||||
if (!this.ep) {
|
||||
this.ep = await this._createMediaEndpoint({remoteSdp: this.req.body});
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
|
||||
this._configMsEndpoint();
|
||||
}
|
||||
return {ms: this.ms, ep: this.ep};
|
||||
}
|
||||
@@ -2762,14 +2600,14 @@ Duration=${duration} `
|
||||
if (typeof this.queueEventHookRequestor === 'undefined') {
|
||||
const pp = this._pool.promise();
|
||||
try {
|
||||
this.logger.debug({accountSid: this.accountSid}, 'performQueueWebhook: looking up account');
|
||||
this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: looking up account');
|
||||
const [r] = await pp.query(sqlRetrieveQueueEventHook, [this.accountSid]);
|
||||
if (0 === r.length) {
|
||||
this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: no webhook provisioned');
|
||||
this.queueEventHookRequestor = null;
|
||||
}
|
||||
else {
|
||||
this.logger.debug({accountSid: this.accountSid, webhook: r[0]}, 'performQueueWebhook: webhook found');
|
||||
this.logger.info({accountSid: this.accountSid, webhook: r[0]}, 'performQueueWebhook: webhook found');
|
||||
this.queueEventHookRequestor = new HttpRequestor(this.logger, this.accountSid,
|
||||
r[0], this.webhook_secret);
|
||||
this.queueEventHook = r[0];
|
||||
@@ -2783,7 +2621,7 @@ Duration=${duration} `
|
||||
|
||||
/* send webhook */
|
||||
const params = {...obj, ...this.callInfo.toJSON()};
|
||||
this.logger.debug({accountSid: this.accountSid, params}, 'performQueueWebhook: sending webhook');
|
||||
this.logger.info({accountSid: this.accountSid, params}, 'performQueueWebhook: sending webhook');
|
||||
this.queueEventHookRequestor.request('queue:status', this.queueEventHook, params)
|
||||
.catch((err) => {
|
||||
this.logger.info({err, accountSid: this.accountSid, obj}, 'Error sending queue notification event');
|
||||
@@ -2902,7 +2740,8 @@ Duration=${duration} `
|
||||
async reAnchorMedia(currentMediaRoute = MediaPath.PartialMedia) {
|
||||
assert(this.dlg && this.dlg.connected && !this.ep);
|
||||
|
||||
this.ep = await this._createMediaEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||
this._configMsEndpoint();
|
||||
await this.dlg.modify(this.ep.local.sdp, {
|
||||
headers: {
|
||||
'X-Reason': 'anchor-media'
|
||||
@@ -2917,7 +2756,7 @@ Duration=${duration} `
|
||||
async handleReinviteAfterMediaReleased(req, res) {
|
||||
assert(this.dlg && this.dlg.connected && !this.ep);
|
||||
const sdp = await this.dlg.modify(req.body);
|
||||
this.logger.debug({sdp}, 'CallSession:handleReinviteAfterMediaReleased - reinvite to A leg returned sdp');
|
||||
this.logger.info({sdp}, 'CallSession:handleReinviteAfterMediaReleased - reinvite to A leg returned sdp');
|
||||
res.send(200, {body: sdp});
|
||||
}
|
||||
|
||||
@@ -2977,13 +2816,41 @@ Duration=${duration} `
|
||||
}
|
||||
}
|
||||
|
||||
_configMsEndpoint() {
|
||||
this._enableInbandDtmfIfRequired(this.ep);
|
||||
this.ep.once('destroy', this._handleMediaTimeout.bind(this));
|
||||
const opts = {
|
||||
...(this.onHoldMusic && {holdMusic: `shout://${this.onHoldMusic.replace(/^https?:\/\//, '')}`}),
|
||||
...(JAMBONES_USE_FREESWITCH_TIMER_FD && {timer_name: 'timerfd'}),
|
||||
...(JAMBONES_MEDIA_TIMEOUT_MS && {media_timeout: JAMBONES_MEDIA_TIMEOUT_MS}),
|
||||
...(JAMBONES_MEDIA_HOLD_TIMEOUT_MS && {media_hold_timeout: JAMBONES_MEDIA_HOLD_TIMEOUT_MS})
|
||||
};
|
||||
if (Object.keys(opts).length > 0) {
|
||||
this.ep.set(opts);
|
||||
}
|
||||
}
|
||||
|
||||
async _handleMediaTimeout(evt) {
|
||||
if (evt?.reason === 'MEDIA_TIMEOUT' && !this.callGone) {
|
||||
if (evt.reason === 'MEDIA_TIMEOUT' && !this.callGone) {
|
||||
this.logger.info('CallSession:_handleMediaTimeout: received MEDIA_TIMEOUT, hangup the call');
|
||||
this._jambonzHangup('Media Timeout');
|
||||
}
|
||||
}
|
||||
|
||||
async _enableInbandDtmfIfRequired(ep) {
|
||||
if (ep.inbandDtmfEnabled) return;
|
||||
// only enable inband dtmf detection if voip carrier dtmf_type === tones
|
||||
if (this.inbandDtmfEnabled) {
|
||||
// https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod-dptools/6587132/#0-about
|
||||
try {
|
||||
ep.execute('start_dtmf');
|
||||
ep.inbandDtmfEnabled = true;
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'CallSession:_enableInbandDtmf - error enable inband DTMF');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* notifyTaskError - only used when websocket connection is used instead of webhooks
|
||||
*/
|
||||
@@ -3006,7 +2873,7 @@ Duration=${duration} `
|
||||
_awaitCommandsOrHangup() {
|
||||
assert(!this.wakeupResolver);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.logger.debug('_awaitCommandsOrHangup - waiting...');
|
||||
this.logger.info('_awaitCommandsOrHangup - waiting...');
|
||||
this.wakeupResolver = resolve;
|
||||
|
||||
if (this._actionHookDelayProcessor) {
|
||||
@@ -3026,7 +2893,7 @@ Duration=${duration} `
|
||||
this.ep.play(this.fillerNoise.url);
|
||||
this.ep.once('playback-start', (evt) => {
|
||||
if (evt.file === this.fillerNoise.url && !this._isPlayingFillerNoise) {
|
||||
this.logger.debug('CallSession:_awaitCommandsOrHangup - filler noise started');
|
||||
this.logger.info('CallSession:_awaitCommandsOrHangup - filler noise started');
|
||||
this.ep.api('uuid_break', this.ep.uuid)
|
||||
.catch((err) => this.logger.info(err, 'Error killing filler noise'));
|
||||
}
|
||||
@@ -3035,21 +2902,9 @@ Duration=${duration} `
|
||||
});
|
||||
}
|
||||
|
||||
async _enableInbandDtmfIfRequired(ep) {
|
||||
if (ep.inbandDtmfEnabled) return;
|
||||
// only enable inband dtmf detection if voip carrier dtmf_type === tones
|
||||
if (this.inbandDtmfEnabled) {
|
||||
// https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod-dptools/6587132/#0-about
|
||||
ep.execute('start_dtmf').catch((err) => {
|
||||
this.logger.info({err}, 'CallSession:_enableInbandDtmfIfRequired - error starting DTMF');
|
||||
});
|
||||
ep.inbandDtmfEnabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
_clearTasks(backgroundGather, evt) {
|
||||
if (this.requestor instanceof WsRequestor && !backgroundGather.cleared) {
|
||||
this.logger.debug({evt}, 'CallSession:_clearTasks on event from background gather');
|
||||
this.logger.info({evt}, 'CallSession:_clearTasks on event from background gather');
|
||||
try {
|
||||
backgroundGather.cleared = true;
|
||||
this.kill(true);
|
||||
@@ -3080,11 +2935,6 @@ Duration=${duration} `
|
||||
const task = this.currentTask;
|
||||
if (task && TaskName.Say === task.name) {
|
||||
task.notifyTtsStreamIsEmpty();
|
||||
} else if (
|
||||
// If Gather nested say task is streaming
|
||||
TaskName.Gather === task.name && task.sayTask && task.sayTask.isStreamingTts) {
|
||||
const sayTask = task.sayTask;
|
||||
sayTask.notifyTtsStreamIsEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3132,67 +2982,6 @@ Duration=${duration} `
|
||||
this._jambonzHangup('Max Call Duration');
|
||||
this._maxCallDurationTimer = null;
|
||||
}
|
||||
|
||||
_onUserSaid(transcript) {
|
||||
const count = this.conversationTurns.length;
|
||||
if (count === 0 || this.conversationTurns[count - 1].type === 'assistant') {
|
||||
this.conversationTurns.push({
|
||||
type: 'user',
|
||||
text: transcript
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.conversationTurns[count - 1].text += ` ${transcript}`;
|
||||
}
|
||||
}
|
||||
|
||||
_onBotSaid(transcript) {
|
||||
const count = this.conversationTurns.length;
|
||||
if (count === 0 || this.conversationTurns[count - 1].type === 'user') {
|
||||
this.conversationTurns.push({
|
||||
type: 'assistant',
|
||||
text: transcript
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.conversationTurns[count - 1].text += ` ${transcript}`;
|
||||
}
|
||||
}
|
||||
|
||||
async _createMediaEndpoint(drachtioFsmrfOptions = {}) {
|
||||
return await createMediaEndpoint(this.srf, this.logger, {
|
||||
activeMs: this.getMS(),
|
||||
drachtioFsmrfOptions,
|
||||
onHoldMusic: this.onHoldMusic,
|
||||
inbandDtmfEnabled: this.inbandDtmfEnabled,
|
||||
mediaTimeoutHandler: this._handleMediaTimeout.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
getFormattedConversation(numTurns) {
|
||||
const turns = this.conversationTurns.slice(-numTurns);
|
||||
if (turns.length === 0) return null;
|
||||
return turns.map((t) => {
|
||||
if (t.type === 'user') {
|
||||
return `user: ${t.text}`;
|
||||
}
|
||||
return `assistant: ${t.text}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
startSttLatencyVad() {
|
||||
if (this.notifySttLatencyEnabled) {
|
||||
this.sttLatencyCalculator.start();
|
||||
}
|
||||
}
|
||||
|
||||
stopSttLatencyVad() {
|
||||
this.sttLatencyCalculator.stop();
|
||||
}
|
||||
|
||||
calculateSttLatency() {
|
||||
return this.sttLatencyCalculator.calculateLatency();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CallSession;
|
||||
|
||||
@@ -8,8 +8,7 @@ const CallSession = require('./call-session');
|
||||
|
||||
*/
|
||||
class ConfirmCallSession extends CallSession {
|
||||
// eslint-disable-next-line max-len
|
||||
constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName, rootSpan, req, tmpFiles}) {
|
||||
constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName, rootSpan, req}) {
|
||||
super({
|
||||
logger,
|
||||
application,
|
||||
@@ -25,7 +24,6 @@ class ConfirmCallSession extends CallSession {
|
||||
this.dlg = dlg;
|
||||
this.ep = ep;
|
||||
this.req = req;
|
||||
this.tmpFiles = tmpFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -27,13 +27,10 @@ class RestCallSession extends CallSession {
|
||||
}
|
||||
|
||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||
|
||||
setImmediate(() => {
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.Trying,
|
||||
sipStatus: 100,
|
||||
sipReason: 'Trying'
|
||||
});
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.Trying,
|
||||
sipStatus: 100,
|
||||
sipReason: 'Trying'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -66,7 +63,7 @@ class RestCallSession extends CallSession {
|
||||
this.callInfo.callTerminationBy = terminatedBy;
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.logger.info(`RestCallSession: called party hung up by ${terminatedBy}`);
|
||||
this.logger.debug(`RestCallSession: called party hung up by ${terminatedBy}`);
|
||||
this._callReleased();
|
||||
}
|
||||
|
||||
|
||||
@@ -45,11 +45,12 @@ class SipRecCallSession extends InboundCallSession {
|
||||
|
||||
async answerSipRecCall() {
|
||||
try {
|
||||
this.ms = this.getMS();
|
||||
let remoteSdp = this.sdp1.replace(/sendonly/, 'sendrecv');
|
||||
this.ep = await this._createMediaEndpoint({remoteSdp});
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp});
|
||||
//this.logger.debug({remoteSdp, localSdp: this.ep.local.sdp}, 'SipRecCallSession - allocated first endpoint');
|
||||
remoteSdp = this.sdp2.replace(/sendonly/, 'sendrecv');
|
||||
this.ep2 = await this._createMediaEndpoint({remoteSdp});
|
||||
this.ep2 = await this.ms.createEndpoint({remoteSdp});
|
||||
//this.logger.debug({remoteSdp, localSdp: this.ep2.local.sdp}, 'SipRecCallSession - allocated second endpoint');
|
||||
await this.ep.bridge(this.ep2);
|
||||
const combinedSdp = await createSipRecPayload(this.ep.local.sdp, this.ep2.local.sdp, this.logger);
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
|
||||
class TaskAlert extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
this.message = this.data.message;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Alert; }
|
||||
|
||||
async exec(cs) {
|
||||
const {srf, accountSid:account_sid, callSid:target_sid, applicationSid:application_sid} = cs;
|
||||
const {writeAlerts, AlertType} = srf.locals;
|
||||
await super.exec(cs);
|
||||
writeAlerts({
|
||||
account_sid,
|
||||
alert_type: AlertType.APPLICATION,
|
||||
detail: `Application SID ${application_sid}`,
|
||||
message: this.message,
|
||||
target_sid
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert application'));
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskAlert;
|
||||
@@ -83,11 +83,7 @@ class Conference extends Task {
|
||||
// reset answer time if we were transferred from another feature server
|
||||
if (this.connectTime) dlg.connectTime = this.connectTime;
|
||||
|
||||
if (cs.sipRequestWithinDialogHook) {
|
||||
/* remove any existing listener to escape from duplicating events */
|
||||
this._removeSipIndialogRequestListener(this.dlg);
|
||||
this._initSipIndialogRequestListener(cs, dlg);
|
||||
}
|
||||
|
||||
this.ep.on('destroy', this._kicked.bind(this, cs, dlg));
|
||||
|
||||
try {
|
||||
@@ -107,7 +103,6 @@ class Conference extends Task {
|
||||
|
||||
this.logger.debug(`Conference:exec - conference ${this.confName} is over`);
|
||||
if (this.callMoved !== false) await this.performAction(this.results);
|
||||
this._removeSipIndialogRequestListener(dlg);
|
||||
} catch (err) {
|
||||
this.logger.info(err, `TaskConference:exec - error in conference ${this.confName}`);
|
||||
}
|
||||
@@ -421,20 +416,6 @@ class Conference extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
_initSipIndialogRequestListener(cs, dlg) {
|
||||
dlg.on('info', this._onRequestWithinDialog.bind(this, cs));
|
||||
dlg.on('message', this._onRequestWithinDialog.bind(this, cs));
|
||||
}
|
||||
|
||||
_removeSipIndialogRequestListener(dlg) {
|
||||
dlg && dlg.removeAllListeners('message');
|
||||
dlg && dlg.removeAllListeners('info');
|
||||
}
|
||||
|
||||
_onRequestWithinDialog(cs, req, res) {
|
||||
cs._onRequestWithinDialog(req, res);
|
||||
}
|
||||
|
||||
/**
|
||||
* The conference we have been waiting for has started.
|
||||
* It may be on this server or a different one, and we are
|
||||
@@ -673,8 +654,7 @@ class Conference extends Task {
|
||||
confName: this.confName,
|
||||
tasks,
|
||||
rootSpan: cs.rootSpan,
|
||||
req: cs.req,
|
||||
tmpFiles: cs.tmpFiles,
|
||||
req: cs.req
|
||||
});
|
||||
await this._playSession.exec();
|
||||
this._playSession = null;
|
||||
|
||||
@@ -17,16 +17,12 @@ class TaskConfig extends Task {
|
||||
'actionHookDelayAction',
|
||||
'boostAudioSignal',
|
||||
'vad',
|
||||
'ttsStream',
|
||||
'autoStreamTts'
|
||||
'ttsStream'
|
||||
].forEach((k) => this[k] = this.data[k] || {});
|
||||
|
||||
if ('notifyEvents' in this.data) {
|
||||
this.notifyEvents = !!this.data.notifyEvents;
|
||||
}
|
||||
if (this.hasNotifySttLatency) {
|
||||
this.notifySttLatency = !!this.data.notifySttLatency;
|
||||
}
|
||||
|
||||
if (this.bargeIn.enable) {
|
||||
this.gatherOpts = {
|
||||
@@ -86,7 +82,6 @@ class TaskConfig extends Task {
|
||||
get hasVad() { return Object.keys(this.vad).length; }
|
||||
get hasFillerNoise() { return Object.keys(this.fillerNoise).length; }
|
||||
get hasReferHook() { return Object.keys(this.data).includes('referHook'); }
|
||||
get hasNotifySttLatency() { return Object.keys(this.data).includes('notifySttLatency'); }
|
||||
get hasTtsStream() { return Object.keys(this.ttsStream).length; }
|
||||
|
||||
get summary() {
|
||||
@@ -116,15 +111,12 @@ class TaskConfig extends Task {
|
||||
if (this.hasFillerNoise) phrase.push(`fillerNoise ${this.fillerNoise.enable ? 'on' : 'off'}`);
|
||||
if (this.data.amd) phrase.push('enable amd');
|
||||
if (this.notifyEvents) phrase.push(`event notification ${this.notifyEvents ? 'on' : 'off'}`);
|
||||
if (this.hasNotifySttLatency) phrase.push(
|
||||
`notifySttLatency ${this.notifySttLatency ? 'on' : 'off'}`);
|
||||
if (this.onHoldMusic) phrase.push(`onHoldMusic: ${this.onHoldMusic}`);
|
||||
if ('boostAudioSignal' in this.data) phrase.push(`setGain ${this.data.boostAudioSignal}`);
|
||||
if (this.hasReferHook) phrase.push('set referHook');
|
||||
if (this.hasTtsStream) {
|
||||
phrase.push(`${this.ttsStream.enable ? 'enable' : 'disable'} ttsStream`);
|
||||
}
|
||||
if ('autoStreamTts' in this.data) phrase.push(`enable Say.stream value ${this.data.autoStreamTts ? 'on' : 'off'}`);
|
||||
return `${this.name}{${phrase.join(',')}}`;
|
||||
}
|
||||
|
||||
@@ -136,11 +128,6 @@ class TaskConfig extends Task {
|
||||
cs.notifyEvents = !!this.data.notifyEvents;
|
||||
}
|
||||
|
||||
if (this.hasNotifySttLatency) {
|
||||
this.logger.debug(`turning notifySttLatency ${this.notifySttLatency ? 'on' : 'off'}`);
|
||||
cs.notifySttLatencyEnabled = this.notifySttLatency;
|
||||
}
|
||||
|
||||
if (this.onHoldMusic) {
|
||||
cs.onHoldMusic = this.onHoldMusic;
|
||||
}
|
||||
@@ -152,7 +139,7 @@ class TaskConfig extends Task {
|
||||
|
||||
try {
|
||||
this.ep = ep;
|
||||
await this.startAmd(cs, ep, this, this.data.amd);
|
||||
this.startAmd(cs, ep, this, this.data.amd);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Config:exec - Error calling startAmd');
|
||||
}
|
||||
@@ -200,20 +187,18 @@ class TaskConfig extends Task {
|
||||
: cs.speechRecognizerVendor;
|
||||
cs.speechRecognizerLabel = this.recognizer.label === 'default'
|
||||
? cs.speechRecognizerLabel : this.recognizer.label;
|
||||
cs.speechRecognizerLanguage = this.recognizer.language !== undefined && this.recognizer.language !== 'default'
|
||||
cs.speechRecognizerLanguage = this.recognizer.language !== 'default'
|
||||
? this.recognizer.language
|
||||
: cs.speechRecognizerLanguage;
|
||||
|
||||
//fallback
|
||||
cs.fallbackSpeechRecognizerVendor = this.recognizer.fallbackVendor !== undefined &&
|
||||
this.recognizer.fallbackVendor !== 'default'
|
||||
cs.fallbackSpeechRecognizerVendor = this.recognizer.fallbackVendor !== 'default'
|
||||
? this.recognizer.fallbackVendor
|
||||
: cs.fallbackSpeechRecognizerVendor;
|
||||
cs.fallbackSpeechRecognizerLabel = this.recognizer.fallbackLabel === 'default' ?
|
||||
cs.fallbackSpeechRecognizerLabel :
|
||||
this.recognizer.fallbackLabel;
|
||||
cs.fallbackSpeechRecognizerLanguage = this.recognizer.fallbackLanguage !== undefined &&
|
||||
this.recognizer.fallbackLanguage !== 'default'
|
||||
cs.fallbackSpeechRecognizerLanguage = this.recognizer.fallbackLanguage !== 'default'
|
||||
? this.recognizer.fallbackLanguage
|
||||
: cs.fallbackSpeechRecognizerLanguage;
|
||||
|
||||
@@ -309,11 +294,6 @@ class TaskConfig extends Task {
|
||||
});
|
||||
}
|
||||
|
||||
if ('autoStreamTts' in this.data) {
|
||||
this.logger.info(`Config: autoStreamTts set to ${this.data.autoStreamTts}`);
|
||||
cs.autoStreamTts = this.data.autoStreamTts;
|
||||
}
|
||||
|
||||
if (this.hasFillerNoise) {
|
||||
const {enable, ...opts} = this.fillerNoise;
|
||||
this.logger.info({fillerNoise: this.fillerNoise}, 'Config: fillerNoise');
|
||||
@@ -329,10 +309,7 @@ class TaskConfig extends Task {
|
||||
voiceMs: this.vad.voiceMs || 250,
|
||||
silenceMs: this.vad.silenceMs || 150,
|
||||
strategy: this.vad.strategy || 'one-shot',
|
||||
mode: (this.vad.mode !== undefined && this.vad.mode !== null) ? this.vad.mode : 2,
|
||||
vendor: this.vad.vendor || 'silero',
|
||||
threshold: this.vad.threshold || 0.5,
|
||||
speechPadMs: this.vad.speechPadMs || 30,
|
||||
mode: (this.vad.mode !== undefined && this.vad.mode !== null) ? this.vad.mode : 2
|
||||
};
|
||||
}
|
||||
|
||||
@@ -351,9 +328,7 @@ class TaskConfig extends Task {
|
||||
};
|
||||
this.logger.info({opts: this.gatherOpts}, 'Config: enabling ttsStream');
|
||||
cs.enableBackgroundTtsStream(this.sayOpts);
|
||||
}
|
||||
// only disable ttsStream if it specifically set to false
|
||||
else if (this.ttsStream.enable === false) {
|
||||
} else if (!this.ttsStream.enable) {
|
||||
this.logger.info('Config: disabling ttsStream');
|
||||
cs.disableTtsStream();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@ const {TaskName, TaskPreconditions, DequeueResults, BONG_TONE} = require('../uti
|
||||
const Emitter = require('events');
|
||||
const bent = require('bent');
|
||||
const assert = require('assert');
|
||||
const { sleepFor } = require('../utils/helpers');
|
||||
|
||||
const sleepFor = (ms) => new Promise((resolve) => setTimeout(() => resolve(), ms));
|
||||
|
||||
const getUrl = (cs) => `${cs.srf.locals.serviceUrl}/v1/dequeue/${cs.callSid}`;
|
||||
|
||||
|
||||
@@ -19,12 +19,11 @@ const parseDecibels = require('../utils/parse-decibels');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
const {parseUri} = require('drachtio-srf');
|
||||
const {ANCHOR_MEDIA_ALWAYS,
|
||||
JAMBONZ_DIAL_PAI_HEADER,
|
||||
JAMBONZ_DISABLE_DIAL_PAI_HEADER,
|
||||
JAMBONES_DIAL_SBC_FOR_REGISTERED_USER} = require('../config');
|
||||
const { isOnhold, isOpusFirst } = require('../utils/sdp-utils');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const { selectHostPort } = require('../utils/network');
|
||||
const { sleepFor } = require('../utils/helpers');
|
||||
|
||||
function parseDtmfOptions(logger, dtmfCapture) {
|
||||
let parentDtmfCollector, childDtmfCollector;
|
||||
@@ -87,6 +86,8 @@ function filterAndLimit(logger, tasks) {
|
||||
return unique;
|
||||
}
|
||||
|
||||
const sleepFor = (ms) => new Promise((resolve) => setTimeout(() => resolve(), ms));
|
||||
|
||||
class TaskDial extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
@@ -109,7 +110,6 @@ class TaskDial extends Task {
|
||||
this.tag = this.data.tag;
|
||||
this.boostAudioSignal = this.data.boostAudioSignal;
|
||||
this._mediaPath = MediaPath.FullMedia;
|
||||
this.forwardPAI = this.data.forwardPAI;
|
||||
|
||||
if (this.dtmfHook) {
|
||||
const {parentDtmfCollector, childDtmfCollector} = parseDtmfOptions(logger, this.data.dtmfCapture || {});
|
||||
@@ -230,10 +230,10 @@ class TaskDial extends Task {
|
||||
try {
|
||||
await this.epOther.play(this.dialMusic);
|
||||
} catch (err) {
|
||||
this.logger.error(err, `TaskDial:exec error playing dialMusic ${this.dialMusic}`);
|
||||
this.logger.error(err, `TaskDial:exec error playing ${this.dialMusic}`);
|
||||
await sleepFor(1000);
|
||||
}
|
||||
} while (!this.killed && !this.bridged && this._mediaPath === MediaPath.FullMedia);
|
||||
} while (!this.killed || !this.bridged);
|
||||
})();
|
||||
}
|
||||
}
|
||||
@@ -272,12 +272,7 @@ class TaskDial extends Task {
|
||||
}
|
||||
this._removeDtmfDetection(cs.dlg);
|
||||
this._removeDtmfDetection(this.dlg);
|
||||
try {
|
||||
await this._killOutdials();
|
||||
}
|
||||
catch (err) {
|
||||
this.logger.info({err}, 'Dial:kill - error killing outdials');
|
||||
}
|
||||
await this._killOutdials();
|
||||
if (this.sd) {
|
||||
const byeReasonHeader = this.killReason === KillReason.MediaTimeout ? 'Media Timeout' : undefined;
|
||||
this.sd.kill(byeReasonHeader);
|
||||
@@ -287,22 +282,13 @@ class TaskDial extends Task {
|
||||
}
|
||||
if (this.callSid) sessionTracker.remove(this.callSid);
|
||||
if (this.listenTask) {
|
||||
try {
|
||||
await this.listenTask.kill(cs);
|
||||
this.listenTask?.span?.end();
|
||||
}
|
||||
catch (err) {
|
||||
this.logger.error({err}, 'Dial:kill - error killing listen task');
|
||||
}
|
||||
await this.listenTask.kill(cs);
|
||||
this.listenTask.span.end();
|
||||
this.listenTask = null;
|
||||
}
|
||||
if (this.transcribeTask) {
|
||||
try {
|
||||
await this.transcribeTask.kill(cs);
|
||||
this.transcribeTask?.span?.end();
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Dial:kill - error killing transcribe task');
|
||||
}
|
||||
await this.transcribeTask.kill(cs);
|
||||
this.transcribeTask.span.end();
|
||||
this.transcribeTask = null;
|
||||
}
|
||||
this.notifyTaskDone();
|
||||
@@ -398,11 +384,7 @@ class TaskDial extends Task {
|
||||
...customHeaders
|
||||
}
|
||||
}, httpHeaders);
|
||||
res.send(202);
|
||||
this.logger.info('DialTask:handleRefer - sent 202 Accepted');
|
||||
|
||||
const returnedInstructions = !!json && Array.isArray(json);
|
||||
if (returnedInstructions) {
|
||||
if (json && Array.isArray(json)) {
|
||||
try {
|
||||
const logger = isChild ? this.logger : this.sd.logger;
|
||||
const tasks = normalizeJambones(logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
@@ -420,21 +402,16 @@ class TaskDial extends Task {
|
||||
/* need to update the callSid of the child with its own (new) AdultingCallSession */
|
||||
sessionTracker.add(adultingSession.callSid, adultingSession);
|
||||
}
|
||||
if (this.ep) this.ep.unbridge();
|
||||
|
||||
/* if we got the REFER on the parent leg, end the dial task after completing the refer */
|
||||
if (!isChild) {
|
||||
logger.info('DialTask:handleRefer - killing dial task after processing REFER on parent leg');
|
||||
cs.currentTask?.kill(cs, KillReason.ReferComplete);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'Dial:handleRefer - error setting new application after receiving REFER');
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.logger.info('DialTask:handleRefer - no tasks returned from referHook, not setting new application');
|
||||
}
|
||||
//caller and callee legs are briged together, accept refer with 202 will release callee leg endpoint
|
||||
//that makes freeswitch release endpoint for caller leg.
|
||||
if (this.ep) this.ep.unbridge();
|
||||
res.send(202);
|
||||
this.logger.info('DialTask:handleRefer - sent 202 Accepted');
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'DialTask:handleRefer - error processing incoming REFER');
|
||||
res.send(err.statusCode || 501);
|
||||
@@ -522,7 +499,7 @@ class TaskDial extends Task {
|
||||
dlg && dlg.removeAllListeners('info');
|
||||
}
|
||||
|
||||
_onRequestWithinDialog(cs, req, res) {
|
||||
async _onRequestWithinDialog(cs, req, res) {
|
||||
cs._onRequestWithinDialog(req, res);
|
||||
}
|
||||
|
||||
@@ -549,14 +526,13 @@ class TaskDial extends Task {
|
||||
let sbcAddress = this.proxy || getSBC();
|
||||
const teamsInfo = {};
|
||||
let fqdn;
|
||||
const forwardPAI = this.forwardPAI ?? JAMBONZ_DIAL_PAI_HEADER; // dial verb overides env var
|
||||
this.logger.debug(forwardPAI, 'forwardPAI value');
|
||||
|
||||
if (!sbcAddress) throw new Error('no SBC found for outbound call');
|
||||
this.headers = {
|
||||
'X-Account-Sid': cs.accountSid,
|
||||
...(req && req.has('X-CID') && {'X-CID': req.get('X-CID')}),
|
||||
...(direction === 'outbound' && callInfo.sbcCallid && {'X-CID': callInfo.sbcCallid}),
|
||||
...(req && forwardPAI && {
|
||||
...(!JAMBONZ_DISABLE_DIAL_PAI_HEADER && req && {
|
||||
...(req.has('P-Asserted-Identity') && {'P-Asserted-Identity': req.get('P-Asserted-Identity')}),
|
||||
...(req.has('Privacy') && {'Privacy': req.get('Privacy')}),
|
||||
}),
|
||||
@@ -575,8 +551,7 @@ class TaskDial extends Task {
|
||||
proxy: `sip:${sbcAddress}`,
|
||||
callingNumber: this.callerId || fromUri.user,
|
||||
...(this.callerName && {callingName: this.callerName}),
|
||||
opusFirst: isOpusFirst(this.cs.ep.remote.sdp),
|
||||
isVideoCall: this.cs.ep.remote.sdp.includes('m=video')
|
||||
opusFirst: isOpusFirst(this.cs.ep.remote.sdp)
|
||||
};
|
||||
|
||||
const t = this.target.find((t) => t.type === 'teams');
|
||||
@@ -896,11 +871,7 @@ class TaskDial extends Task {
|
||||
|
||||
if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg);
|
||||
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg);
|
||||
if (cs.sipRequestWithinDialogHook) {
|
||||
/* remove any existing listener to escape from duplicating events */
|
||||
this._removeSipIndialogRequestListener(this.dlg);
|
||||
this._initSipIndialogRequestListener(cs, this.dlg);
|
||||
}
|
||||
if (cs.sipRequestWithinDialogHook) this._initSipIndialogRequestListener(cs, this.dlg);
|
||||
|
||||
if (this.transcribeTask) this.transcribeTask.exec(cs, {ep: this.epOther, ep2:this.ep});
|
||||
if (this.listenTask) this.listenTask.exec(cs, {ep: this.listenTask.channel === 2 ? this.ep : this.epOther});
|
||||
@@ -933,7 +904,7 @@ class TaskDial extends Task {
|
||||
}
|
||||
|
||||
_handleMediaTimeout(evt) {
|
||||
if (evt?.reason === 'MEDIA_TIMEOUT' && this.sd && this.bridged) {
|
||||
if (evt.reason === 'MEDIA_TIMEOUT' && this.sd && this.bridged) {
|
||||
this.kill(this.cs, KillReason.MediaTimeout);
|
||||
}
|
||||
}
|
||||
@@ -997,7 +968,7 @@ class TaskDial extends Task {
|
||||
this.epOther = null;
|
||||
this._mediaPath = releaseEntirely ? MediaPath.NoMedia : MediaPath.PartialMedia;
|
||||
this.logger.info(
|
||||
`Dial:_releaseMedia - successfully released media from freeswitch, media path is now ${this._mediaPath}`);
|
||||
`Dial:_releaseMedia - successfully released media from freewitch, media path is now ${this._mediaPath}`);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Dial:_releaseMedia error');
|
||||
}
|
||||
@@ -1006,7 +977,7 @@ class TaskDial extends Task {
|
||||
async reAnchorMedia(cs, sd) {
|
||||
if (cs.ep && sd.ep) return;
|
||||
|
||||
this.logger.info('Dial:reAnchorMedia - re-anchoring media to freeswitch');
|
||||
this.logger.info('Dial:reAnchorMedia - re-anchoring media to freewitch');
|
||||
await Promise.all([sd.reAnchorMedia(this._mediaPath), cs.reAnchorMedia(this._mediaPath)]);
|
||||
this.epOther = cs.ep;
|
||||
|
||||
@@ -1014,7 +985,7 @@ class TaskDial extends Task {
|
||||
|
||||
this._mediaPath = MediaPath.FullMedia;
|
||||
this.logger.info(
|
||||
`Dial:_releaseMedia - successfully re-anchored media to freeswitch, media path is now ${this._mediaPath}`);
|
||||
`Dial:_releaseMedia - successfully re-anchored media to freewitch, media path is now ${this._mediaPath}`);
|
||||
}
|
||||
|
||||
// Handle RE-INVITE hold from caller leg.
|
||||
@@ -1098,8 +1069,7 @@ class TaskDial extends Task {
|
||||
accountInfo: this.cs.accountInfo,
|
||||
tasks,
|
||||
rootSpan: this.cs.rootSpan,
|
||||
req: this.cs.req,
|
||||
tmpFiles: this.cs.tmpFiles,
|
||||
req: this.cs.req
|
||||
});
|
||||
await this._onHoldSession.exec();
|
||||
this._onHoldSession = null;
|
||||
|
||||
@@ -83,8 +83,7 @@ class TaskDub extends TtsTask {
|
||||
action: 'playOnTrack',
|
||||
track: this.track,
|
||||
play: this.play,
|
||||
// drachtio-fsmrf will convert loop from boolean to 'loop' or 'once'
|
||||
loop: this.loop,
|
||||
loop: this.loop ? 'loop' : 'once',
|
||||
gain: this.gain
|
||||
});
|
||||
}
|
||||
|
||||
@@ -370,8 +370,7 @@ class TaskEnqueue extends Task {
|
||||
accountInfo: cs.accountInfo,
|
||||
tasks: tasksToRun,
|
||||
rootSpan: cs.rootSpan,
|
||||
req: cs.req,
|
||||
tmpFiles: cs.tmpFiles,
|
||||
req: cs.req
|
||||
});
|
||||
await this._playSession.exec();
|
||||
this._playSession = null;
|
||||
|
||||
@@ -11,10 +11,6 @@ const {
|
||||
NvidiaTranscriptionEvents,
|
||||
JambonzTranscriptionEvents,
|
||||
AssemblyAiTranscriptionEvents,
|
||||
DeepgramRiverTranscriptionEvents,
|
||||
VoxistTranscriptionEvents,
|
||||
CartesiaTranscriptionEvents,
|
||||
OpenAITranscriptionEvents,
|
||||
VadDetection,
|
||||
VerbioTranscriptionEvents,
|
||||
SpeechmaticsTranscriptionEvents
|
||||
@@ -86,7 +82,6 @@ class TaskGather extends SttTask {
|
||||
this._bufferedTranscripts = [];
|
||||
this.partialTranscriptsCount = 0;
|
||||
this.bugname_prefix = 'gather_';
|
||||
|
||||
}
|
||||
|
||||
get name() { return TaskName.Gather; }
|
||||
@@ -114,12 +109,6 @@ class TaskGather extends SttTask {
|
||||
return this.fillerNoise.startDelaySecs;
|
||||
}
|
||||
|
||||
get isStreamingTts() { return this.sayTask && this.sayTask.isStreamingTts; }
|
||||
|
||||
getTtsVendorData() {
|
||||
if (this.sayTask) return this.sayTask.getTtsVendorData(this.cs);
|
||||
}
|
||||
|
||||
get summary() {
|
||||
let s = `${this.name}{`;
|
||||
if (this.input.length === 2) s += 'inputs=[speech,digits],';
|
||||
@@ -140,11 +129,7 @@ class TaskGather extends SttTask {
|
||||
try {
|
||||
await this.handling(cs, obj);
|
||||
} catch (error) {
|
||||
if (
|
||||
// avoid bargein task with sticky will restart forever
|
||||
// throw exception to stop the loop.
|
||||
!this.sticky &&
|
||||
error instanceof SpeechCredentialError) {
|
||||
if (error instanceof SpeechCredentialError) {
|
||||
this.logger.info('Gather failed due to SpeechCredentialError, finished!');
|
||||
this.notifyTaskDone();
|
||||
return;
|
||||
@@ -247,7 +232,6 @@ class TaskGather extends SttTask {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.sayTask.summary}`);
|
||||
const process = () => {
|
||||
this.logger.debug('Gather: nested say task completed');
|
||||
this.playComplete = true;
|
||||
if (!this.listenDuringPrompt) {
|
||||
startDtmfListener();
|
||||
}
|
||||
@@ -268,22 +252,16 @@ class TaskGather extends SttTask {
|
||||
.catch((err) => {
|
||||
process();
|
||||
});
|
||||
if (this.sayTask.isStreamingTts && !this.sayTask.closeOnStreamEmpty) {
|
||||
// if streaming tts, we do not wait for it to complete if it is not closing the stream automatically
|
||||
this.sayTask.on('playDone', (err) => {
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:exec Error playing tts');
|
||||
process();
|
||||
} else {
|
||||
this.sayTask.on('playDone', (err) => {
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:exec Error playing tts');
|
||||
process();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (this.playTask) {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.playTask.summary}`);
|
||||
const process = () => {
|
||||
this.logger.debug('Gather: nested play task completed');
|
||||
this.playComplete = true;
|
||||
if (!this.listenDuringPrompt) {
|
||||
startDtmfListener();
|
||||
}
|
||||
@@ -352,7 +330,6 @@ class TaskGather extends SttTask {
|
||||
this.sayTask?.span.end();
|
||||
this._stopVad();
|
||||
this._resolve('killed');
|
||||
cs.stopSttLatencyVad();
|
||||
}
|
||||
|
||||
updateTaskInProgress(opts) {
|
||||
@@ -368,9 +345,6 @@ class TaskGather extends SttTask {
|
||||
|
||||
_onDtmf(cs, ep, evt) {
|
||||
this.logger.debug(evt, 'TaskGather:_onDtmf');
|
||||
if (!this._timeoutTimer && this.timeout > 0) {
|
||||
this._startTimer();
|
||||
}
|
||||
clearTimeout(this.interDigitTimer);
|
||||
let resolved = false;
|
||||
if (this.dtmfBargein) {
|
||||
@@ -395,7 +369,12 @@ class TaskGather extends SttTask {
|
||||
if (this.digitBuffer.length === 0 && this.needsStt) {
|
||||
// DTMF is higher priority than STT.
|
||||
this.removeCustomEventListeners();
|
||||
this._stopTranscribing(ep);
|
||||
ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname,
|
||||
})
|
||||
.catch((err) => this.logger.error({err},
|
||||
` Received DTMF, Error stopping transcription for vendor ${this.vendor}`));
|
||||
}
|
||||
this.digitBuffer += evt.dtmf;
|
||||
const len = this.digitBuffer.length;
|
||||
@@ -468,16 +447,6 @@ class TaskGather extends SttTask {
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'deepgramriver':
|
||||
this.bugname = `${this.bugname_prefix}deepgramriver_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
ep, DeepgramRiverTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
this.addCustomEventListener(
|
||||
ep, DeepgramRiverTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, DeepgramRiverTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'soniox':
|
||||
this.bugname = `${this.bugname_prefix}soniox_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
@@ -555,28 +524,6 @@ class TaskGather extends SttTask {
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'voxist':
|
||||
this.bugname = `${this.bugname_prefix}voxist_transcribe`;
|
||||
this.addCustomEventListener(ep, VoxistTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep));
|
||||
this.addCustomEventListener(
|
||||
ep, VoxistTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, VoxistTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, VoxistTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'cartesia':
|
||||
this.bugname = `${this.bugname_prefix}cartesia_transcribe`;
|
||||
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep));
|
||||
this.addCustomEventListener(
|
||||
ep, CartesiaTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'speechmatics':
|
||||
this.bugname = `${this.bugname_prefix}speechmatics_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
@@ -594,31 +541,6 @@ class TaskGather extends SttTask {
|
||||
|
||||
break;
|
||||
|
||||
case 'openai':
|
||||
this.bugname = `${this.bugname_prefix}openai_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
ep, OpenAITranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
this.addCustomEventListener(
|
||||
ep, OpenAITranscriptionEvents.SpeechStarted, this._onOpenAISpeechStarted.bind(this, cs, ep));
|
||||
this.addCustomEventListener(
|
||||
ep, OpenAITranscriptionEvents.SpeechStopped, this._onOpenAISpeechStopped.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, OpenAITranscriptionEvents.Connect,
|
||||
this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, OpenAITranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, OpenAITranscriptionEvents.Error,
|
||||
this._onOpenAIErrror.bind(this, cs, ep));
|
||||
|
||||
/* openai delta transcripts are useful only for minBargeinWordCount eval */
|
||||
if (this.minBargeinWordCount > 1) {
|
||||
this.openaiPartials = [];
|
||||
opts.OPENAI_WANT_PARTIALS = 1;
|
||||
this.addCustomEventListener(
|
||||
ep, OpenAITranscriptionEvents.PartialTranscript, this._onOpenAIPartialTranscript.bind(this, cs, ep));
|
||||
}
|
||||
this.modelSupportsConversationTracking = opts.OPENAI_MODEL !== 'whisper-1';
|
||||
break;
|
||||
|
||||
default:
|
||||
if (this.vendor.startsWith('custom:')) {
|
||||
this.bugname = `${this.bugname_prefix}${this.vendor}_transcribe`;
|
||||
@@ -643,32 +565,13 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
|
||||
_startTranscribing(ep) {
|
||||
this.logger.info({
|
||||
this.logger.debug({
|
||||
vendor: this.vendor,
|
||||
locale: this.language,
|
||||
interim: this.interim,
|
||||
bugname: this.bugname
|
||||
}, 'Gather:_startTranscribing');
|
||||
|
||||
|
||||
/* special feature for openai: we can provide a prompt that includes recent conversation history */
|
||||
let prompt;
|
||||
if (this.vendor === 'openai') {
|
||||
if (this.modelSupportsConversationTracking) {
|
||||
prompt = this.formatOpenAIPrompt(this.cs, {
|
||||
prompt: this.data.recognizer?.openaiOptions?.prompt,
|
||||
hintsTemplate: this.data.recognizer?.openaiOptions?.promptTemplates?.hintsTemplate,
|
||||
// eslint-disable-next-line max-len
|
||||
conversationHistoryTemplate: this.data.recognizer?.openaiOptions?.promptTemplates?.conversationHistoryTemplate,
|
||||
hints: this.data.recognizer?.hints,
|
||||
});
|
||||
this.logger.debug({prompt}, 'Gather:_startTranscribing - created an openai prompt');
|
||||
}
|
||||
else if (this.data.recognizer?.hints?.length > 0) {
|
||||
prompt = this.data.recognizer?.hints.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: we don't need to ask deepgram for interim results, because they
|
||||
* already send us words as they are finalized (is_final=true) even before
|
||||
@@ -680,7 +583,6 @@ class TaskGather extends SttTask {
|
||||
interim: this.interim,
|
||||
bugname: this.bugname,
|
||||
hostport: this.hostport,
|
||||
prompt
|
||||
}).catch((err) => {
|
||||
const {writeAlerts, AlertType} = this.cs.srf.locals;
|
||||
this.logger.error(err, 'TaskGather:_startTranscribing error');
|
||||
@@ -692,14 +594,10 @@ class TaskGather extends SttTask {
|
||||
target_sid: this.cs.callSid
|
||||
});
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
||||
|
||||
// Some vendor use single connection, that we cannot use onConnect event to track transcription start
|
||||
this.cs.emit('transcribe-start');
|
||||
}
|
||||
|
||||
_startTimer() {
|
||||
if (0 === this.timeout) return;
|
||||
this.logger.debug(`Starting timoutTimer of ${this.timeout}ms`);
|
||||
this._clearTimer();
|
||||
this._timeoutTimer = setTimeout(() => {
|
||||
if (this.isContinuousAsr) this._startAsrTimer();
|
||||
@@ -720,24 +618,15 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
|
||||
_startAsrTimer() {
|
||||
// Deepgram has a case that UtteranceEnd is not sent to cover the last word end time.
|
||||
// So we need to wait for the asrTimeout to be sure that the last word is sent.
|
||||
// if (this.vendor === 'deepgram') return; // no need
|
||||
if (this.vendor === 'deepgram') return; // no need
|
||||
assert(this.isContinuousAsr);
|
||||
this._clearAsrTimer();
|
||||
this._asrTimer = setTimeout(() => {
|
||||
this.logger.info('_startAsrTimer - asr timer went off');
|
||||
this.logger.debug('_startAsrTimer - asr timer went off');
|
||||
const evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language, this.vendor);
|
||||
|
||||
/* special case for speechmatics - keep listening if we dont have any transcripts */
|
||||
if (this.vendor === 'speechmatics' && this._bufferedTranscripts.length === 0) {
|
||||
this.logger.debug('Gather:_startAsrTimer - speechmatics, no transcripts yet, keep listening');
|
||||
this._startAsrTimer();
|
||||
return;
|
||||
}
|
||||
this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout', evt);
|
||||
}, this.asrTimeout);
|
||||
this.logger.info(`_startAsrTimer: set for ${this.asrTimeout}ms`);
|
||||
this.logger.debug(`_startAsrTimer: set for ${this.asrTimeout}ms`);
|
||||
}
|
||||
|
||||
_clearAsrTimer() {
|
||||
@@ -749,7 +638,7 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
|
||||
_hangupCall() {
|
||||
this.logger.debug('TaskGather:_hangupCall');
|
||||
this.logger.debug('_hangupCall');
|
||||
this.cs.hangup();
|
||||
}
|
||||
|
||||
@@ -772,7 +661,7 @@ class TaskGather extends SttTask {
|
||||
_startFinalAsrTimer() {
|
||||
this._clearFinalAsrTimer();
|
||||
this._finalAsrTimer = setTimeout(() => {
|
||||
this.logger.info('_startFinalAsrTimer - final asr timer went off');
|
||||
this.logger.debug('_startFinalAsrTimer - final asr timer went off');
|
||||
const evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language, this.vendor);
|
||||
this._resolve(this._bufferedTranscripts.length > 0 ? 'speech' : 'timeout', evt);
|
||||
}, 1000);
|
||||
@@ -863,43 +752,24 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
|
||||
_onTranscription(cs, ep, evt, fsEvent) {
|
||||
// check if we are in graceful shutdown mode
|
||||
if (ep.gracefulShutdownResolver) {
|
||||
ep.gracefulShutdownResolver();
|
||||
}
|
||||
// make sure this is not a transcript from answering machine detection
|
||||
const bugname = fsEvent.getHeader('media-bugname');
|
||||
const finished = fsEvent.getHeader('transcription-session-finished');
|
||||
this.logger.debug({evt, bugname, finished, vendor: this.vendor}, 'Gather:_onTranscription raw transcript');
|
||||
if (bugname && this.bugname !== bugname) {
|
||||
this.logger.info(
|
||||
`Gather:_onTranscription - ignoring transcript from ${bugname} because our bug is ${this.bugname}`);
|
||||
return;
|
||||
}
|
||||
if (bugname && this.bugname !== bugname) return;
|
||||
if (finished === 'true') return;
|
||||
|
||||
if (this.vendor === 'ibm' && evt?.state === 'listening') return;
|
||||
|
||||
// emit an event to the call session to track the time transcription is received
|
||||
cs.emit('on-transcription');
|
||||
|
||||
if (this.vendor === 'deepgram' && evt.type === 'UtteranceEnd') {
|
||||
/* we will only get this when we have set utterance_end_ms */
|
||||
if (this._bufferedTranscripts.length === 0) {
|
||||
this.logger.info('Gather:_onTranscription - got UtteranceEnd event from deepgram but no buffered transcripts');
|
||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram but no buffered transcripts');
|
||||
}
|
||||
else {
|
||||
const utteranceTime = evt.last_word_end;
|
||||
// eslint-disable-next-line max-len
|
||||
if (utteranceTime && this._dgTimeOfLastUnprocessedWord && utteranceTime < this._dgTimeOfLastUnprocessedWord && utteranceTime != -1) {
|
||||
this.logger.info('Gather:_onTranscription - got UtteranceEnd with unprocessed words, continue listening');
|
||||
}
|
||||
else {
|
||||
this.logger.info('Gather:_onTranscription - got UtteranceEnd from deepgram, return buffered transcript');
|
||||
evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language, this.vendor);
|
||||
this._bufferedTranscripts = [];
|
||||
this._resolve('speech', evt);
|
||||
}
|
||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram, return buffered transcript');
|
||||
evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language, this.vendor);
|
||||
this._bufferedTranscripts = [];
|
||||
this._resolve('speech', evt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -910,7 +780,7 @@ class TaskGather extends SttTask {
|
||||
|
||||
evt = this.normalizeTranscription(evt, this.vendor, 1, this.language,
|
||||
this.shortUtterance, this.data.recognizer.punctuation);
|
||||
//this.logger.debug({evt, bugname, finished, vendor: this.vendor}, 'Gather:_onTranscription normalized transcript');
|
||||
this.logger.debug({evt, bugname, finished, vendor: this.vendor}, 'Gather:_onTranscription normalized transcript');
|
||||
|
||||
if (evt.alternatives.length === 0) {
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, continue listening');
|
||||
@@ -918,6 +788,8 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
const confidence = evt.alternatives[0].confidence;
|
||||
const minConfidence = this.data.recognizer?.minConfidence;
|
||||
this.logger.debug({evt},
|
||||
`TaskGather:_onTranscription - confidence (${confidence}), minConfidence (${minConfidence})`);
|
||||
if (confidence && minConfidence && confidence < minConfidence) {
|
||||
this.logger.info({evt},
|
||||
'TaskGather:_onTranscription - Transcript confidence ' +
|
||||
@@ -966,12 +838,6 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
}
|
||||
|
||||
// receive a final transcript, calculate the stt latency for this transcript
|
||||
const sttLatency = this.cs.calculateSttLatency();
|
||||
if (!emptyTranscript && sttLatency) {
|
||||
this.stt_latency_ms += `${sttLatency.stt_latency_ms},`;
|
||||
}
|
||||
|
||||
if (this.isContinuousAsr) {
|
||||
/* append the transcript and start listening again for asrTimeout */
|
||||
const t = evt.alternatives[0].transcript;
|
||||
@@ -1039,21 +905,8 @@ class TaskGather extends SttTask {
|
||||
if (originalEvent.is_final && evt.alternatives[0].transcript !== '') {
|
||||
this.logger.debug({evt}, 'Gather:_onTranscription - buffering a completed (partial) deepgram transcript');
|
||||
this._bufferedTranscripts.push(evt);
|
||||
this._dgTimeOfLastUnprocessedWord = null;
|
||||
}
|
||||
if (evt.alternatives[0].transcript === '') {
|
||||
emptyTranscript = true;
|
||||
}
|
||||
else if (!originalEvent.is_final) {
|
||||
/* Deepgram: we have unprocessed words-save last word end time so we can later compare to UtteranceEnd */
|
||||
const words = originalEvent.channel.alternatives[0].words;
|
||||
if (words?.length > 0) {
|
||||
this._dgTimeOfLastUnprocessedWord = words.slice(-1)[0].end;
|
||||
this.logger.debug(
|
||||
`TaskGather:_onTranscription - saving word end time: ${this._dgTimeOfLastUnprocessedWord}`);
|
||||
}
|
||||
|
||||
}
|
||||
if (evt.alternatives[0].transcript === '') emptyTranscript = true;
|
||||
}
|
||||
|
||||
if (!emptyTranscript) {
|
||||
@@ -1123,7 +976,11 @@ class TaskGather extends SttTask {
|
||||
|
||||
async _startFallback(cs, ep, evt) {
|
||||
if (this.canFallback) {
|
||||
this._stopTranscribing(ep);
|
||||
ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname
|
||||
})
|
||||
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
|
||||
try {
|
||||
this.logger.debug('gather:_startFallback');
|
||||
this.notifyError({ msg: 'ASR error',
|
||||
@@ -1185,33 +1042,6 @@ class TaskGather extends SttTask {
|
||||
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
|
||||
}
|
||||
|
||||
async _onOpenAIErrror(cs, _ep, evt) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {message, ...e} = evt;
|
||||
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
|
||||
}
|
||||
|
||||
async _onOpenAISpeechStarted(cs, _ep, evt) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onOpenAISpeechStarted');
|
||||
}
|
||||
|
||||
async _onOpenAISpeechStopped(cs, _ep, evt) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onOpenAISpeechStopped');
|
||||
}
|
||||
|
||||
async _onOpenAIPartialTranscript(cs, _ep, evt) {
|
||||
if (!this.playComplete) {
|
||||
const words = evt.delta.split(' ').filter((w) => /[A-Za-z0-0]/.test(w));
|
||||
this.openaiPartials.push(...words);
|
||||
this.logger.debug({words, partials: this.openaiPartials, evt}, 'TaskGather:_onOpenAIPartialTranscript - words');
|
||||
if (this.openaiPartials.length >= this.minBargeinWordCount) {
|
||||
this.logger.debug({partials: this.openaiPartials}, 'killing audio due to speech (openai)');
|
||||
this._killAudio(cs);
|
||||
this.notifyStatus({event: 'speech-bargein-detected', words: this.openaiPartials});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _onVendorError(cs, _ep, evt) {
|
||||
super._onVendorError(cs, _ep, evt);
|
||||
if (!(await this._startFallback(cs, _ep, evt))) {
|
||||
@@ -1252,26 +1082,17 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
}
|
||||
|
||||
async _stopTranscribing(ep) {
|
||||
// Fix for https://github.com/jambonz/jambonz-feature-server/issues/1281
|
||||
// We should immediately call stop transcription from gather
|
||||
// so that next gather can start transcription immediately
|
||||
ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname,
|
||||
gracefulShutdown: false
|
||||
})
|
||||
.catch((err) => {
|
||||
if (this.resolved) return;
|
||||
this.logger.error({err}, 'Error stopping transcription');
|
||||
});
|
||||
this.cs.emit('transcribe-stop');
|
||||
}
|
||||
|
||||
async _resolve(reason, evt) {
|
||||
this.logger.info({evt}, `TaskGather:resolve with reason ${reason}`);
|
||||
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
|
||||
if (this.needsStt && this.ep && this.ep.connected) {
|
||||
this._stopTranscribing(this.ep);
|
||||
this.ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname
|
||||
})
|
||||
.catch((err) => {
|
||||
if (this.resolved) return;
|
||||
this.logger.error({err}, 'Error stopping transcription');
|
||||
});
|
||||
}
|
||||
if (this.resolved) {
|
||||
this.logger.debug('TaskGather:_resolve - already resolved');
|
||||
@@ -1290,32 +1111,15 @@ class TaskGather extends SttTask {
|
||||
this._clearAsrTimer();
|
||||
this._clearFinalAsrTimer();
|
||||
|
||||
let sttLatencyMetrics = {};
|
||||
if (this.needsStt) {
|
||||
const sttLatency = this.cs.calculateSttLatency();
|
||||
if (sttLatency) {
|
||||
this.stt_latency_ms = this.stt_latency_ms.endsWith(',') ?
|
||||
this.stt_latency_ms.slice(0, -1) : this.stt_latency_ms;
|
||||
sttLatencyMetrics = {
|
||||
'stt.latency_ms': this.stt_latency_ms,
|
||||
'stt.talkspurts': JSON.stringify(sttLatency.talkspurts),
|
||||
'stt.start_time': sttLatency.stt_start_time,
|
||||
'stt.stop_time': sttLatency.stt_stop_time,
|
||||
'stt.usage': sttLatency.stt_usage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.span.setAttributes({
|
||||
channel: 1,
|
||||
'stt.label': this.label || 'None',
|
||||
'stt.resolve': reason,
|
||||
'stt.result': JSON.stringify(evt),
|
||||
...sttLatencyMetrics
|
||||
'stt.result': JSON.stringify(evt)
|
||||
});
|
||||
|
||||
if (this.callSession && this.callSession.callGone) {
|
||||
this.logger.info('TaskGather:_resolve - call is gone, not invoking web callback');
|
||||
this.logger.debug('TaskGather:_resolve - call is gone, not invoking web callback');
|
||||
this.notifyTaskDone();
|
||||
return;
|
||||
}
|
||||
@@ -1339,9 +1143,6 @@ class TaskGather extends SttTask {
|
||||
|
||||
let returnedVerbs = false;
|
||||
try {
|
||||
const latencies = Object.fromEntries(
|
||||
Object.entries(sttLatencyMetrics).map(([key, value]) => [key.replace('stt.', 'stt_'), value])
|
||||
);
|
||||
if (reason.startsWith('dtmf')) {
|
||||
if (this.parentTask) this.parentTask.emit('dtmf', evt);
|
||||
else {
|
||||
@@ -1350,12 +1151,11 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
}
|
||||
else if (reason.startsWith('speech')) {
|
||||
this.cs.emit('userSaid', evt.alternatives[0].transcript);
|
||||
if (this.parentTask) this.parentTask.emit('transcription', evt);
|
||||
else {
|
||||
this.emit('transcription', evt);
|
||||
this.logger.debug('TaskGather:_resolve - invoking performAction');
|
||||
returnedVerbs = await this.performAction({speech: evt, reason: 'speechDetected', ...latencies});
|
||||
returnedVerbs = await this.performAction({speech: evt, reason: 'speechDetected'});
|
||||
this.logger.debug({returnedVerbs}, 'TaskGather:_resolve - back from performAction');
|
||||
}
|
||||
}
|
||||
@@ -1363,20 +1163,20 @@ class TaskGather extends SttTask {
|
||||
if (this.parentTask) this.parentTask.emit('timeout', evt);
|
||||
else {
|
||||
this.emit('timeout', evt);
|
||||
returnedVerbs = await this.performAction({reason: 'timeout', ...latencies});
|
||||
returnedVerbs = await this.performAction({reason: 'timeout'});
|
||||
}
|
||||
}
|
||||
else if (reason.startsWith('stt-error')) {
|
||||
if (this.parentTask) this.parentTask.emit('stt-error', evt);
|
||||
else {
|
||||
this.emit('stt-error', evt);
|
||||
returnedVerbs = await this.performAction({reason: 'error', details: evt.error, ...latencies});
|
||||
returnedVerbs = await this.performAction({reason: 'error', details: evt.error});
|
||||
}
|
||||
} else if (reason.startsWith('stt-low-confidence')) {
|
||||
if (this.parentTask) this.parentTask.emit('stt-low-confidence', evt);
|
||||
else {
|
||||
this.emit('stt-low-confidence', evt);
|
||||
returnedVerbs = await this.performAction({speech:evt, reason: 'stt-low-confidence', ...latencies});
|
||||
returnedVerbs = await this.performAction({reason: 'stt-low-confidence'});
|
||||
}
|
||||
}
|
||||
} catch (err) { /*already logged error*/ }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions, ListenEvents, ListenStatus} = require('../utils/constants.json');
|
||||
const {TaskName, TaskPreconditions, ListenEvents, ListenStatus} = require('../utils/constants');
|
||||
const makeTask = require('./make_task');
|
||||
const moment = require('moment');
|
||||
const MAX_PLAY_AUDIO_QUEUE_SIZE = 10;
|
||||
@@ -72,7 +72,7 @@ class TaskListen extends Task {
|
||||
} catch (err) {
|
||||
this.logger.info(err, `TaskListen:exec - error ${this.url}`);
|
||||
}
|
||||
if (this.transcribeTask) this.transcribeTask.kill(cs);
|
||||
if (this.transcribeTask) this.transcribeTask.kill();
|
||||
this._removeListeners(ep);
|
||||
}
|
||||
|
||||
@@ -103,12 +103,9 @@ class TaskListen extends Task {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async updateListen(status, silence = false) {
|
||||
async updateListen(status) {
|
||||
if (!this.killed && this.ep && this.ep.connected) {
|
||||
const args = [
|
||||
...(this._bugname ? [this._bugname] : []),
|
||||
...(status === ListenStatus.Pause ? ([silence]) : []),
|
||||
];
|
||||
const args = this._bugname ? [this._bugname] : [];
|
||||
this.logger.info(`TaskListen:updateListen status ${status}`);
|
||||
switch (status) {
|
||||
case ListenStatus.Pause:
|
||||
@@ -224,7 +221,7 @@ class TaskListen extends Task {
|
||||
}
|
||||
}
|
||||
_onConnect(ep) {
|
||||
this.logger.info('TaskListen:_onConnect');
|
||||
this.logger.debug('TaskListen:_onConnect');
|
||||
}
|
||||
_onConnectFailure(ep, evt) {
|
||||
this.logger.info(evt, 'TaskListen:_onConnectFailure');
|
||||
|
||||
@@ -3,9 +3,6 @@ const {TaskPreconditions} = require('../../utils/constants');
|
||||
const TaskLlmOpenAI_S2S = require('./llms/openai_s2s');
|
||||
const TaskLlmVoiceAgent_S2S = require('./llms/voice_agent_s2s');
|
||||
const TaskLlmUltravox_S2S = require('./llms/ultravox_s2s');
|
||||
const TaskLlmElevenlabs_S2S = require('./llms/elevenlabs_s2s');
|
||||
const TaskLlmGoogle_S2S = require('./llms/google_s2s');
|
||||
const LlmMcpService = require('../../utils/llm-mcp');
|
||||
|
||||
class TaskLlm extends Task {
|
||||
constructor(logger, opts) {
|
||||
@@ -20,8 +17,6 @@ class TaskLlm extends Task {
|
||||
|
||||
// delegate to the specific llm model
|
||||
this.llm = this.createSpecificLlm();
|
||||
// MCP
|
||||
this.mcpServers = this.data.mcpServers || [];
|
||||
}
|
||||
|
||||
get name() { return this.llm.name ; }
|
||||
@@ -32,32 +27,14 @@ class TaskLlm extends Task {
|
||||
|
||||
get ep() { return this.cs.ep; }
|
||||
|
||||
get mcpService() {
|
||||
return this.llmMcpService;
|
||||
}
|
||||
|
||||
get isMcpEnabled() {
|
||||
return this.mcpServers.length > 0;
|
||||
}
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs, {ep});
|
||||
|
||||
// create the MCP service if we have MCP servers
|
||||
if (this.isMcpEnabled) {
|
||||
this.llmMcpService = new LlmMcpService(this.logger, this.mcpServers);
|
||||
await this.llmMcpService.init();
|
||||
}
|
||||
await this.llm.exec(cs, {ep});
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
await this.llm.kill(cs);
|
||||
// clean up MCP clients
|
||||
if (this.isMcpEnabled) {
|
||||
await this.mcpService.close();
|
||||
}
|
||||
}
|
||||
|
||||
createSpecificLlm() {
|
||||
@@ -77,14 +54,6 @@ class TaskLlm extends Task {
|
||||
llm = new TaskLlmUltravox_S2S(this.logger, this.data, this);
|
||||
break;
|
||||
|
||||
case 'elevenlabs':
|
||||
llm = new TaskLlmElevenlabs_S2S(this.logger, this.data, this);
|
||||
break;
|
||||
|
||||
case 'google':
|
||||
llm = new TaskLlmGoogle_S2S(this.logger, this.data, this);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported vendor ${this.vendor} for LLM`);
|
||||
}
|
||||
@@ -108,15 +77,8 @@ class TaskLlm extends Task {
|
||||
await this.cs?.requestor.request('llm:event', this.eventHook, data);
|
||||
}
|
||||
|
||||
|
||||
async sendToolHook(tool_call_id, data) {
|
||||
const tool_response = await this.cs?.requestor.request('llm:tool-call', this.toolHook, {tool_call_id, ...data});
|
||||
// if the toolHook was a websocket it will return undefined, otherwise it should return an object
|
||||
if (typeof tool_response != 'undefined') {
|
||||
tool_response.type = 'client_tool_result';
|
||||
tool_response.invocation_id = tool_call_id;
|
||||
this.processToolOutput(tool_call_id, tool_response);
|
||||
}
|
||||
await this.cs?.requestor.request('llm:tool-call', this.toolHook, {tool_call_id, ...data});
|
||||
}
|
||||
|
||||
async processToolOutput(tool_call_id, data) {
|
||||
|
||||
@@ -1,327 +0,0 @@
|
||||
const Task = require('../../task');
|
||||
const TaskName = 'Llm_Elevenlabs_s2s';
|
||||
const {LlmEvents_Elevenlabs} = require('../../../utils/constants');
|
||||
const {request} = require('undici');
|
||||
const ClientEvent = 'client.event';
|
||||
const SessionDelete = 'session.delete';
|
||||
|
||||
const elevenlabs_server_events = [
|
||||
'conversation_initiation_metadata',
|
||||
'user_transcript',
|
||||
'agent_response',
|
||||
'client_tool_call'
|
||||
];
|
||||
|
||||
const expandWildcards = (events) => {
|
||||
const expandedEvents = [];
|
||||
|
||||
events.forEach((evt) => {
|
||||
if (evt.endsWith('.*')) {
|
||||
const prefix = evt.slice(0, -2); // Remove the wildcard ".*"
|
||||
const matchingEvents = elevenlabs_server_events.filter((e) => e.startsWith(prefix));
|
||||
expandedEvents.push(...matchingEvents);
|
||||
} else {
|
||||
expandedEvents.push(evt);
|
||||
}
|
||||
});
|
||||
|
||||
return expandedEvents;
|
||||
};
|
||||
|
||||
class TaskLlmElevenlabs_S2S extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts, parentTask);
|
||||
this.parent = parentTask;
|
||||
|
||||
this.vendor = this.parent.vendor;
|
||||
this.auth = this.parent.auth;
|
||||
|
||||
const {agent_id, api_key} = this.auth || {};
|
||||
if (!agent_id) throw new Error('auth.agent_id is required for Elevenlabs S2S');
|
||||
|
||||
this.agent_id = agent_id;
|
||||
this.api_key = api_key;
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
const {
|
||||
conversation_initiation_client_data,
|
||||
input_sample_rate = 16000,
|
||||
output_sample_rate = 16000
|
||||
} = this.data.llmOptions;
|
||||
this.conversation_initiation_client_data = conversation_initiation_client_data;
|
||||
this.input_sample_rate = input_sample_rate;
|
||||
this.output_sample_rate = output_sample_rate;
|
||||
this.results = {
|
||||
completionReason: 'normal conversation end'
|
||||
};
|
||||
|
||||
/**
|
||||
* only one of these will have items,
|
||||
* if includeEvents, then these are the events to include
|
||||
* if excludeEvents, then these are the events to exclude
|
||||
*/
|
||||
this.includeEvents = [];
|
||||
this.excludeEvents = [];
|
||||
|
||||
/* default to all events if user did not specify */
|
||||
this._populateEvents(this.data.events || elevenlabs_server_events);
|
||||
|
||||
this.addCustomEventListener = parentTask.addCustomEventListener.bind(parentTask);
|
||||
this.removeCustomEventListeners = parentTask.removeCustomEventListeners.bind(parentTask);
|
||||
}
|
||||
|
||||
get name() { return TaskName; }
|
||||
|
||||
async getSignedUrl() {
|
||||
if (!this.api_key) {
|
||||
return {
|
||||
host: 'api.elevenlabs.io',
|
||||
path: `/v1/convai/conversation?agent_id=${this.agent_id}`,
|
||||
};
|
||||
}
|
||||
|
||||
const {statusCode, body} = await request(
|
||||
`https://api.elevenlabs.io/v1/convai/conversation/get_signed_url?agent_id=${this.agent_id}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'xi-api-key': this.api_key
|
||||
},
|
||||
}
|
||||
);
|
||||
const data = await body.json();
|
||||
if (statusCode !== 200 || !data?.signed_url) {
|
||||
this.logger.error({statusCode, data}, 'Elevenlabs Error registering call');
|
||||
throw new Error(`Elevenlabs Error registering call: ${data.message}`);
|
||||
}
|
||||
|
||||
const url = new URL(data.signed_url);
|
||||
return {
|
||||
host: url.hostname,
|
||||
path: url.pathname + url.search,
|
||||
};
|
||||
}
|
||||
|
||||
async _api(ep, args) {
|
||||
const res = await ep.api('uuid_elevenlabs_s2s', `^^|${args.join('|')}`);
|
||||
if (!res.body?.startsWith('+OK')) {
|
||||
throw new Error({args}, `Error calling uuid_elevenlabs_s2s: ${res.body}`);
|
||||
}
|
||||
}
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
await this._startListening(cs, ep);
|
||||
|
||||
await this.awaitTaskDone();
|
||||
|
||||
/* note: the parent llm verb started the span, which is why this is necessary */
|
||||
await this.parent.performAction(this.results);
|
||||
|
||||
this._unregisterHandlers();
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
|
||||
this._api(cs.ep, [cs.ep.uuid, SessionDelete])
|
||||
.catch((err) => this.logger.info({err}, 'TaskLlmElevenlabs_S2S:kill - error deleting session'));
|
||||
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send function call output to the Elevenlabs server in the form of conversation.item.create
|
||||
* per https://elevenlabs.io/docs/conversational-ai/api-reference/conversational-ai/websocket
|
||||
*/
|
||||
async processToolOutput(ep, tool_call_id, rawData) {
|
||||
try {
|
||||
const {data} = rawData;
|
||||
this.logger.debug({tool_call_id, data}, 'TaskLlmElevenlabs_S2S:processToolOutput');
|
||||
|
||||
if (!data.type || data.type !== 'client_tool_result') {
|
||||
this.logger.info({data},
|
||||
'TaskLlmElevenlabs_S2S:processToolOutput - invalid tool output, must be client_tool_result');
|
||||
}
|
||||
else {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskLlmElevenlabs_S2S:processToolOutput');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a session.update to the Elevenlabs server
|
||||
* Note: creating and deleting conversation items also supported as well as interrupting the assistant
|
||||
*/
|
||||
async processLlmUpdate(ep, data, _callSid) {
|
||||
this.logger.debug({data, _callSid}, 'TaskLlmElevenlabs_S2S:processLlmUpdate, ignored');
|
||||
}
|
||||
|
||||
async _startListening(cs, ep) {
|
||||
this._registerHandlers(ep);
|
||||
|
||||
try {
|
||||
const {host, path} = await this.getSignedUrl();
|
||||
const args = this.conversation_initiation_client_data ?
|
||||
[ep.uuid, 'session.create', this.input_sample_rate, this.output_sample_rate, host, path] :
|
||||
[ep.uuid, 'session.create', this.input_sample_rate, this.output_sample_rate, host, path, 'no_initial_config'];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'TaskLlmElevenlabs_S2S:_startListening');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
async _sendClientEvent(ep, obj) {
|
||||
let ok = true;
|
||||
this.logger.debug({obj}, 'TaskLlmElevenlabs_S2S:_sendClientEvent');
|
||||
try {
|
||||
const args = [ep.uuid, ClientEvent, JSON.stringify(obj)];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
this.logger.error({err}, 'TaskLlmElevenlabs_S2S:_sendClientEvent - Error');
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
async _sendInitialMessage(ep) {
|
||||
if (this.conversation_initiation_client_data) {
|
||||
if (!await this._sendClientEvent(ep, {
|
||||
type: 'conversation_initiation_client_data',
|
||||
...this.conversation_initiation_client_data
|
||||
})) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_registerHandlers(ep) {
|
||||
this.addCustomEventListener(ep, LlmEvents_Elevenlabs.Connect, this._onConnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Elevenlabs.ConnectFailure, this._onConnectFailure.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Elevenlabs.Disconnect, this._onDisconnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Elevenlabs.ServerEvent, this._onServerEvent.bind(this, ep));
|
||||
}
|
||||
|
||||
_unregisterHandlers() {
|
||||
this.removeCustomEventListeners();
|
||||
}
|
||||
|
||||
_onError(ep, evt) {
|
||||
this.logger.info({evt}, 'TaskLlmElevenlabs_S2S:_onError');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_onConnect(ep) {
|
||||
this.logger.debug('TaskLlmElevenlabs_S2S:_onConnect');
|
||||
this._sendInitialMessage(ep);
|
||||
}
|
||||
_onConnectFailure(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmElevenlabs_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'connection failure'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
_onDisconnect(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmElevenlabs_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'disconnect from remote end'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
async _onServerEvent(ep, evt) {
|
||||
let endConversation = false;
|
||||
const type = evt.type;
|
||||
this.logger.info({evt}, 'TaskLlmElevenlabs_S2S:_onServerEvent');
|
||||
|
||||
if (type === 'error') {
|
||||
endConversation = true;
|
||||
this.results = {
|
||||
completionReason: 'server error',
|
||||
error: evt.error
|
||||
};
|
||||
}
|
||||
|
||||
/* tool calls */
|
||||
else if (type === 'client_tool_call') {
|
||||
this.logger.debug({evt}, 'TaskLlmElevenlabs_S2S:_onServerEvent - function_call');
|
||||
const {tool_name: name, tool_call_id: call_id, parameters: args} = evt.client_tool_call;
|
||||
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (mcpTools.some((tool) => tool.name === name)) {
|
||||
this.logger.debug({name, args}, 'TaskLlmElevenlabs_S2S:_onServerEvent - calling mcp tool');
|
||||
try {
|
||||
const res = await this.parent.mcpService.callMcpTool(name, args);
|
||||
this.logger.debug({res}, 'TaskLlmElevenlabs_S2S:_onServerEvent - function_call - mcp result');
|
||||
this.processToolOutput(ep, call_id, {
|
||||
data: {
|
||||
type: 'client_tool_result',
|
||||
tool_call_id: call_id,
|
||||
result: res.content?.length ? res.content[0] : res.content,
|
||||
is_error: false
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmElevenlabs_S2S - error calling mcp tool');
|
||||
this.results = {
|
||||
completionReason: 'client error calling mcp function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
} else if (!this.toolHook) {
|
||||
this.logger.warn({evt}, 'TaskLlmElevenlabs_S2S:_onServerEvent - no toolHook defined!');
|
||||
}
|
||||
else {
|
||||
try {
|
||||
await this.parent.sendToolHook(call_id, {name, args});
|
||||
} catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmElevenlabs_S2S - error calling function');
|
||||
this.results = {
|
||||
completionReason: 'client error calling function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* check whether we should notify on this event */
|
||||
if (this.includeEvents.length > 0 ? this.includeEvents.includes(type) : !this.excludeEvents.includes(type)) {
|
||||
this.parent.sendEventHook(evt)
|
||||
.catch((err) => this.logger.info({err},
|
||||
'TaskLlmElevenlabs_S2S:_onServerEvent - error sending event hook'));
|
||||
}
|
||||
|
||||
if (endConversation) {
|
||||
this.logger.info({results: this.results},
|
||||
'TaskLlmElevenlabs_S2S:_onServerEvent - ending conversation due to error');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
_populateEvents(events) {
|
||||
if (events.includes('all')) {
|
||||
/* work by excluding specific events */
|
||||
const exclude = events
|
||||
.filter((evt) => evt.startsWith('-'))
|
||||
.map((evt) => evt.slice(1));
|
||||
if (exclude.length === 0) this.includeEvents = elevenlabs_server_events;
|
||||
else this.excludeEvents = expandWildcards(exclude);
|
||||
}
|
||||
else {
|
||||
/* work by including specific events */
|
||||
const include = events
|
||||
.filter((evt) => !evt.startsWith('-'));
|
||||
this.includeEvents = expandWildcards(include);
|
||||
}
|
||||
|
||||
this.logger.debug({
|
||||
includeEvents: this.includeEvents,
|
||||
excludeEvents: this.excludeEvents
|
||||
}, 'TaskLlmElevenlabs_S2S:_populateEvents');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskLlmElevenlabs_S2S;
|
||||
@@ -1,319 +0,0 @@
|
||||
const Task = require('../../task');
|
||||
const TaskName = 'Llm_Google_s2s';
|
||||
const {LlmEvents_Google} = require('../../../utils/constants');
|
||||
const ClientEvent = 'client.event';
|
||||
const SessionDelete = 'session.delete';
|
||||
|
||||
const google_server_events = [
|
||||
'error',
|
||||
'session.created',
|
||||
'session.updated',
|
||||
];
|
||||
|
||||
const expandWildcards = (events) => {
|
||||
const expandedEvents = [];
|
||||
|
||||
events.forEach((evt) => {
|
||||
if (evt.endsWith('.*')) {
|
||||
const prefix = evt.slice(0, -2); // Remove the wildcard ".*"
|
||||
const matchingEvents = google_server_events.filter((e) => e.startsWith(prefix));
|
||||
expandedEvents.push(...matchingEvents);
|
||||
} else {
|
||||
expandedEvents.push(evt);
|
||||
}
|
||||
});
|
||||
|
||||
return expandedEvents;
|
||||
};
|
||||
|
||||
class TaskLlmGoogle_S2S extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts, parentTask);
|
||||
this.parent = parentTask;
|
||||
|
||||
this.vendor = this.parent.vendor;
|
||||
this.vendor = this.parent.vendor;
|
||||
this.model = this.parent.model || 'models/gemini-2.0-flash-live-001';
|
||||
this.auth = this.parent.auth;
|
||||
this.connectionOptions = this.parent.connectOptions;
|
||||
|
||||
const {apiKey} = this.auth || {};
|
||||
if (!apiKey) throw new Error('auth.apiKey is required for Google S2S');
|
||||
|
||||
this.apiKey = apiKey;
|
||||
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
|
||||
const {setup} = this.data.llmOptions;
|
||||
|
||||
if (typeof setup !== 'object') {
|
||||
throw new Error('llmOptions with an initial setup is required for Google S2S');
|
||||
}
|
||||
this.setup = {
|
||||
...setup,
|
||||
model: this.model,
|
||||
// make sure output is always audio
|
||||
generationConfig: {
|
||||
...(setup.generationConfig || {}),
|
||||
responseModalities: 'audio'
|
||||
}
|
||||
};
|
||||
|
||||
this.results = {
|
||||
completionReason: 'normal conversation end'
|
||||
};
|
||||
|
||||
/**
|
||||
* only one of these will have items,
|
||||
* if includeEvents, then these are the events to include
|
||||
* if excludeEvents, then these are the events to exclude
|
||||
*/
|
||||
this.includeEvents = [];
|
||||
this.excludeEvents = [];
|
||||
|
||||
/* default to all events if user did not specify */
|
||||
this._populateEvents(this.data.events || google_server_events);
|
||||
|
||||
this.addCustomEventListener = parentTask.addCustomEventListener.bind(parentTask);
|
||||
this.removeCustomEventListeners = parentTask.removeCustomEventListeners.bind(parentTask);
|
||||
}
|
||||
|
||||
get name() { return TaskName; }
|
||||
|
||||
async _api(ep, args) {
|
||||
const res = await ep.api('uuid_google_s2s', `^^|${args.join('|')}`);
|
||||
if (!res.body?.startsWith('+OK')) {
|
||||
throw new Error({args}, `Error calling uuid_openai_s2s: ${res.body}`);
|
||||
}
|
||||
}
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
|
||||
await this._startListening(cs, ep);
|
||||
|
||||
await this.awaitTaskDone();
|
||||
|
||||
/* note: the parent llm verb started the span, which is why this is necessary */
|
||||
await this.parent.performAction(this.results);
|
||||
|
||||
this._unregisterHandlers();
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
|
||||
this._api(cs.ep, [cs.ep.uuid, SessionDelete])
|
||||
.catch((err) => this.logger.info({err}, 'TaskLlmGoogle_S2S:kill - error deleting session'));
|
||||
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_populateEvents(events) {
|
||||
if (events.includes('all')) {
|
||||
/* work by excluding specific events */
|
||||
const exclude = events
|
||||
.filter((evt) => evt.startsWith('-'))
|
||||
.map((evt) => evt.slice(1));
|
||||
if (exclude.length === 0) this.includeEvents = google_server_events;
|
||||
else this.excludeEvents = expandWildcards(exclude);
|
||||
}
|
||||
else {
|
||||
/* work by including specific events */
|
||||
const include = events
|
||||
.filter((evt) => !evt.startsWith('-'));
|
||||
this.includeEvents = expandWildcards(include);
|
||||
}
|
||||
|
||||
this.logger.debug({
|
||||
includeEvents: this.includeEvents,
|
||||
excludeEvents: this.excludeEvents
|
||||
}, 'TaskLlmGoogle_S2S:_populateEvents');
|
||||
}
|
||||
|
||||
async _startListening(cs, ep) {
|
||||
this._registerHandlers(ep);
|
||||
|
||||
try {
|
||||
const args = [ep.uuid, 'session.create', this.apiKey];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'TaskLlmGoogle_S2S:_startListening');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
async _sendClientEvent(ep, obj) {
|
||||
let ok = true;
|
||||
this.logger.debug({obj}, 'TaskLlmGoogle_S2S:_sendClientEvent');
|
||||
try {
|
||||
const args = [ep.uuid, ClientEvent, JSON.stringify(obj)];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
this.logger.error({err}, 'TaskLlmGoogle_S2S:_sendClientEvent - Error');
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
async _sendInitialMessage(ep) {
|
||||
const setup = this.setup;
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (mcpTools && mcpTools.length > 0) {
|
||||
const convertedTools = [
|
||||
{
|
||||
functionDeclarations: mcpTools.map((tool) => {
|
||||
if (tool.inputSchema) {
|
||||
delete tool.inputSchema.additionalProperties;
|
||||
delete tool.inputSchema['$schema'];
|
||||
}
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
};
|
||||
})
|
||||
}
|
||||
];
|
||||
// merge with any existing tools
|
||||
setup.tools = [...convertedTools, ...(this.setup.tools || [])];
|
||||
}
|
||||
if (!await this._sendClientEvent(ep, {
|
||||
setup,
|
||||
})) {
|
||||
this.logger.debug(this.setup, 'TaskLlmGoogle_S2S:_sendInitialMessage - sending session.update');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
_registerHandlers(ep) {
|
||||
this.addCustomEventListener(ep, LlmEvents_Google.Connect, this._onConnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Google.ConnectFailure, this._onConnectFailure.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Google.Disconnect, this._onDisconnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Google.ServerEvent, this._onServerEvent.bind(this, ep));
|
||||
}
|
||||
|
||||
_unregisterHandlers() {
|
||||
this.removeCustomEventListeners();
|
||||
}
|
||||
|
||||
_onError(ep, evt) {
|
||||
this.logger.info({evt}, 'TaskLlmGoogle_S2S:_onError');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_onConnect(ep) {
|
||||
this.logger.debug('TaskLlmGoogle_S2S:_onConnect');
|
||||
this._sendInitialMessage(ep);
|
||||
}
|
||||
_onConnectFailure(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmGoogle_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'connection failure'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
_onDisconnect(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmGoogle_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'disconnect from remote end'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async _onServerEvent(ep, evt) {
|
||||
let endConversation = false;
|
||||
this.logger.debug({evt}, 'TaskLlmGoogle_S2S:_onServerEvent');
|
||||
const {toolCall /**toolCallCancellation*/} = evt;
|
||||
|
||||
if (toolCall) {
|
||||
this.logger.debug({toolCall}, 'TaskLlmGoogle_S2S:_onServerEvent - toolCall');
|
||||
if (!this.toolHook) {
|
||||
this.logger.info({evt}, 'TaskLlmGoogle_S2S:_onServerEvent - no toolHook defined!');
|
||||
}
|
||||
else {
|
||||
const {functionCalls} = toolCall;
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
const functionResponses = [];
|
||||
if (mcpTools && mcpTools.length > 0) {
|
||||
for (const functionCall of functionCalls) {
|
||||
const {name, args, id} = functionCall;
|
||||
const tool = mcpTools.find((tool) => tool.name === name);
|
||||
if (tool) {
|
||||
const response = await this.parent.mcpService.callMcpTool(name, args);
|
||||
functionResponses.push({
|
||||
response: {
|
||||
output: response,
|
||||
},
|
||||
id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (functionResponses && functionResponses.length > 0) {
|
||||
this.logger.debug({functionResponses}, 'TaskLlmGoogle_S2S:_onServerEvent - function_call - mcp result');
|
||||
this.processToolOutput(ep, 'tool_call_id', {
|
||||
toolResponse: {
|
||||
functionResponses
|
||||
}
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await this.parent.sendToolHook('function_call_id', {type: 'toolCall', functionCalls});
|
||||
} catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmGoogle_S2S - error calling function');
|
||||
this.results = {
|
||||
completionReason: 'client error calling function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._sendLlmEvent('llm_event', evt);
|
||||
|
||||
if (endConversation) {
|
||||
this.logger.info({results: this.results},
|
||||
'TaskLlmGoogle_S2S:_onServerEvent - ending conversation due to error');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
_sendLlmEvent(type, evt) {
|
||||
/* check whether we should notify on this event */
|
||||
if (this.includeEvents.length > 0 ? this.includeEvents.includes(type) : !this.excludeEvents.includes(type)) {
|
||||
this.parent.sendEventHook(evt)
|
||||
.catch((err) => this.logger.info({err}, 'TaskLlmGoogle_S2S:_onServerEvent - error sending event hook'));
|
||||
}
|
||||
}
|
||||
|
||||
async processLlmUpdate(ep, data, _callSid) {
|
||||
try {
|
||||
this.logger.debug({data, _callSid}, 'TaskLlmGoogle_S2S:processLlmUpdate');
|
||||
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
} catch (err) {
|
||||
this.logger.info({err, data}, 'TaskLlmGoogle_S2S:processLlmUpdate - Error processing LLM update');
|
||||
}
|
||||
}
|
||||
|
||||
async processToolOutput(ep, tool_call_id, data) {
|
||||
try {
|
||||
this.logger.debug({tool_call_id, data}, 'TaskLlmGoogle_S2S:processToolOutput');
|
||||
const {toolResponse} = data;
|
||||
|
||||
if (!toolResponse) {
|
||||
this.logger.info({data},
|
||||
'TaskLlmGoogle_S2S:processToolOutput - invalid tool output, must be functionResponses');
|
||||
}
|
||||
else {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err, data}, 'TaskLlmGoogle_S2S:processToolOutput - Error processing tool output');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskLlmGoogle_S2S;
|
||||
@@ -235,23 +235,6 @@ class TaskLlmOpenAI_S2S extends Task {
|
||||
|
||||
/* send immediate session.update if present */
|
||||
else if (this.session_update) {
|
||||
if (this.parent.isMcpEnabled) {
|
||||
this.logger.debug('TaskLlmOpenAI_S2S:_sendInitialMessage - mcp enabled');
|
||||
const tools = await this.parent.mcpService.getAvailableMcpTools();
|
||||
if (tools && tools.length > 0 && this.session_update) {
|
||||
const convertedTools = tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
type: 'function',
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema
|
||||
}));
|
||||
|
||||
this.session_update.tools = [
|
||||
...convertedTools,
|
||||
...(this.session_update.tools || [])
|
||||
];
|
||||
}
|
||||
}
|
||||
obj = {type: 'session.update', session: this.session_update};
|
||||
this.logger.debug({obj}, 'TaskLlmOpenAI_S2S:_sendInitialMessage - sending session.update');
|
||||
if (!await this._sendClientEvent(ep, obj)) {
|
||||
@@ -316,37 +299,13 @@ class TaskLlmOpenAI_S2S extends Task {
|
||||
/* tool calls */
|
||||
else if (type === 'response.output_item.done' && evt.item?.type === 'function_call') {
|
||||
this.logger.debug({evt}, 'TaskLlmOpenAI_S2S:_onServerEvent - function_call');
|
||||
const {name, call_id} = evt.item;
|
||||
const args = JSON.parse(evt.item.arguments);
|
||||
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (mcpTools.some((tool) => tool.name === name)) {
|
||||
this.logger.debug({call_id, name, args}, 'TaskLlmOpenAI_S2S:_onServerEvent - calling mcp tool');
|
||||
try {
|
||||
const res = await this.parent.mcpService.callMcpTool(name, args);
|
||||
this.logger.debug({res}, 'TaskLlmOpenAI_S2S:_onServerEvent - function_call - mcp result');
|
||||
this.processToolOutput(ep, call_id, {
|
||||
type: 'conversation.item.create',
|
||||
item: {
|
||||
type: 'function_call_output',
|
||||
call_id,
|
||||
output: res.content[0]?.text || 'There is no output from the function call',
|
||||
}
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmOpenAI_S2S - error calling function');
|
||||
this.results = {
|
||||
completionReason: 'client error calling mcp function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
}
|
||||
else if (!this.toolHook) {
|
||||
if (!this.toolHook) {
|
||||
this.logger.warn({evt}, 'TaskLlmOpenAI_S2S:_onServerEvent - no toolHook defined!');
|
||||
}
|
||||
else {
|
||||
const {name, call_id} = evt.item;
|
||||
const args = JSON.parse(evt.item.arguments);
|
||||
|
||||
try {
|
||||
await this.parent.sendToolHook(call_id, {name, args});
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,7 +4,6 @@ const {request} = require('undici');
|
||||
const {LlmEvents_Ultravox} = require('../../../utils/constants');
|
||||
|
||||
const ultravox_server_events = [
|
||||
'createCall',
|
||||
'pong',
|
||||
'state',
|
||||
'transcript',
|
||||
@@ -32,18 +31,12 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
this.auth = this.parent.auth;
|
||||
this.connectionOptions = this.parent.connectOptions;
|
||||
|
||||
const {apiKey, agent_id} = this.auth || {};
|
||||
const {apiKey} = this.auth || {};
|
||||
if (!apiKey) throw new Error('auth.apiKey is required for Vendor: Ultravox');
|
||||
this.apiKey = apiKey;
|
||||
this.agentId = agent_id;
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
this.llmOptions = this.data.llmOptions || {};
|
||||
|
||||
this.results = {
|
||||
completionReason: 'normal conversation end'
|
||||
};
|
||||
|
||||
/**
|
||||
* only one of these will have items,
|
||||
@@ -69,67 +62,19 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a JSON Schema to the dynamic parameters format used in the Ultravox API
|
||||
* @param {Object} jsonSchema - A JSON Schema object defining parameters
|
||||
* @param {string} locationDefault - Default location value for parameters (default: 'PARAMETER_LOCATION_BODY')
|
||||
* @returns {Array} Array of dynamic parameters objects
|
||||
*/
|
||||
transformSchemaToParameters(jsonSchema, locationDefault = 'PARAMETER_LOCATION_BODY') {
|
||||
if (jsonSchema.properties) {
|
||||
const required = jsonSchema.required || [];
|
||||
|
||||
return Object.entries(jsonSchema.properties).map(([name]) => {
|
||||
return {
|
||||
name,
|
||||
location: locationDefault,
|
||||
required: required.includes(name)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async createCall() {
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (mcpTools && mcpTools.length > 0) {
|
||||
const convertedTools = mcpTools.map((tool) => {
|
||||
return {
|
||||
temporaryTool: {
|
||||
modelToolName: tool.name,
|
||||
description: tool.description,
|
||||
dynamicParameters: this.transformSchemaToParameters(tool.inputSchema),
|
||||
// use client tool that ultravox call tool via freeswitch module.
|
||||
client: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
// merge with any existing tools
|
||||
this.llmOptions.selectedTools = [
|
||||
...convertedTools,
|
||||
...(this.llmOptions.selectedTools || [])
|
||||
];
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...this.llmOptions,
|
||||
...(!this.agentId && {
|
||||
model: this.model,
|
||||
}),
|
||||
...this.data.llmOptions,
|
||||
model: this.model,
|
||||
medium: {
|
||||
...(this.llmOptions.medium || {}),
|
||||
...(this.data.llmOptions.medium || {}),
|
||||
serverWebSocket: {
|
||||
inputSampleRate: 8000,
|
||||
outputSampleRate: 8000,
|
||||
}
|
||||
}
|
||||
};
|
||||
const baseUrl = 'https://api.ultravox.ai';
|
||||
const url = this.agentId ?
|
||||
`${baseUrl}/api/agents/${this.agentId}/calls` : `${baseUrl}/api/calls`;
|
||||
const {statusCode, body} = await request(url, {
|
||||
const {statusCode, body} = await request('https://api.ultravox.ai/api/calls', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -139,11 +84,11 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
});
|
||||
const data = await body.json();
|
||||
if (statusCode !== 201 || !data?.joinUrl) {
|
||||
this.logger.info({statusCode, data}, 'Ultravox Error registering call');
|
||||
throw new Error(`Ultravox Error registering call:${statusCode} - ${data.detail}`);
|
||||
this.logger.error({statusCode, data}, 'Ultravox Error registering call');
|
||||
throw new Error(`Ultravox Error registering call: ${data.message}`);
|
||||
}
|
||||
this.logger.debug({joinUrl: data.joinUrl}, 'Ultravox Call registered');
|
||||
return data;
|
||||
this.logger.info({joinUrl: data.joinUrl}, 'Ultravox Call registered');
|
||||
return data.joinUrl;
|
||||
}
|
||||
|
||||
_unregisterHandlers() {
|
||||
@@ -160,21 +105,15 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
async _startListening(cs, ep) {
|
||||
this._registerHandlers(ep);
|
||||
|
||||
const joinUrl = await this.createCall();
|
||||
// split the joinUrl into host and path
|
||||
const {host, pathname, search} = new URL(joinUrl);
|
||||
|
||||
try {
|
||||
const data = await this.createCall();
|
||||
const {joinUrl} = data;
|
||||
// split the joinUrl into host and path
|
||||
const {host, pathname, search} = new URL(joinUrl);
|
||||
const args = [ep.uuid, 'session.create', host, pathname + search];
|
||||
await this._api(ep, args);
|
||||
// Notify the application that the session has been created with detail information
|
||||
this._sendLlmEvent('createCall', {
|
||||
type: 'createCall',
|
||||
...data
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskLlmUltraVox_S2S:_startListening - Error sending createCall');
|
||||
this.results = {completionReason: `connection failure - ${err}`};
|
||||
this.logger.error({err}, 'TaskLlmUltraVox_S2S:_startListening');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
@@ -202,7 +141,7 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
}
|
||||
|
||||
_onConnect(ep) {
|
||||
this.logger.info('TaskLlmUltravox_S2S:_onConnect');
|
||||
this.logger.debug('TaskLlmUltravox_S2S:_onConnect');
|
||||
}
|
||||
_onConnectFailure(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmUltravox_S2S:_onConnectFailure');
|
||||
@@ -218,7 +157,7 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
async _onServerEvent(_ep, evt) {
|
||||
let endConversation = false;
|
||||
const type = evt.type;
|
||||
//this.logger.debug({evt}, 'TaskLlmUltravox_S2S:_onServerEvent');
|
||||
this.logger.info({evt}, 'TaskLlmUltravox_S2S:_onServerEvent');
|
||||
|
||||
/* server errors of some sort */
|
||||
if (type === 'error') {
|
||||
@@ -232,35 +171,12 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
/* tool calls */
|
||||
else if (type === 'client_tool_invocation') {
|
||||
this.logger.debug({evt}, 'TaskLlmUltravox_S2S:_onServerEvent - function_call');
|
||||
const {toolName: name, invocationId: call_id, parameters: args} = evt;
|
||||
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (mcpTools.some((tool) => tool.name === name)) {
|
||||
this.logger.debug({
|
||||
name,
|
||||
input: args
|
||||
}, 'TaskLlmUltravox_S2S:_onServerEvent - function_call - mcp tool');
|
||||
try {
|
||||
const res = await this.parent.mcpService.callMcpTool(name, args);
|
||||
this.logger.debug({res}, 'TaskLlmUltravox_S2S:_onServerEvent - function_call - mcp result');
|
||||
this.processToolOutput(_ep, call_id, {
|
||||
type: 'client_tool_result',
|
||||
invocation_id: call_id,
|
||||
result: res.content
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmUltravox_S2S - error calling mcp tool');
|
||||
this.results = {
|
||||
completionReason: 'client error calling mcp function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
} else if (!this.toolHook) {
|
||||
this.logger.info({evt}, 'TaskLlmUltravox_S2S:_onServerEvent - no toolHook defined!');
|
||||
if (!this.toolHook) {
|
||||
this.logger.warn({evt}, 'TaskLlmUltravox_S2S:_onServerEvent - no toolHook defined!');
|
||||
}
|
||||
else {
|
||||
const {toolName: name, invocationId: call_id, parameters: args} = evt;
|
||||
|
||||
try {
|
||||
await this.parent.sendToolHook(call_id, {name, args});
|
||||
} catch (err) {
|
||||
@@ -274,38 +190,16 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
this._sendLlmEvent(type, evt);
|
||||
|
||||
if (endConversation) {
|
||||
this.logger.info({results: this.results},
|
||||
'TaskLlmUltravox_S2S:_onServerEvent - ending conversation due to error');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
_sendLlmEvent(type, evt) {
|
||||
/* check whether we should notify on this event */
|
||||
if (this.includeEvents.length > 0 ? this.includeEvents.includes(type) : !this.excludeEvents.includes(type)) {
|
||||
this.parent.sendEventHook(evt)
|
||||
.catch((err) => this.logger.info({err}, 'TaskLlmUltravox_S2S:_onServerEvent - error sending event hook'));
|
||||
}
|
||||
}
|
||||
|
||||
async processLlmUpdate(ep, data, _callSid) {
|
||||
try {
|
||||
this.logger.debug({data, _callSid}, 'TaskLlmUltravox_S2S:processLlmUpdate');
|
||||
|
||||
if (!data.type || ![
|
||||
'input_text_message'
|
||||
].includes(data.type)) {
|
||||
this.logger.info({data},
|
||||
'TaskLlmUltravox_S2S:processLlmUpdate - invalid mid-call request, only input_text_message supported');
|
||||
}
|
||||
else {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err, data}, 'TaskLlmUltravox_S2S:processLlmUpdate - Error processing LLM update');
|
||||
if (endConversation) {
|
||||
this.logger.info({results: this.results},
|
||||
'TaskLlmUltravox_S2S:_onServerEvent - ending conversation due to error');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -321,7 +215,7 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err, data}, 'TaskLlmUltravox_S2S:processToolOutput - Error processing tool output');
|
||||
this.logger.info({err}, 'TaskLlmUltravox_S2S:processToolOutput');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class TaskLlmVoiceAgent_S2S extends Task {
|
||||
this.parent = parentTask;
|
||||
|
||||
this.vendor = this.parent.vendor;
|
||||
this.model = this.parent.model || 'voice-agent';
|
||||
this.model = this.parent.model;
|
||||
this.auth = this.parent.auth;
|
||||
this.connectionOptions = this.parent.connectOptions;
|
||||
|
||||
@@ -41,25 +41,25 @@ class TaskLlmVoiceAgent_S2S extends Task {
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
const {Settings} = this.data.llmOptions;
|
||||
const {settingsConfiguration} = this.data.llmOptions;
|
||||
|
||||
if (typeof Settings !== 'object') {
|
||||
throw new Error('llmOptions with an initial Settings is required for VoiceAgent S2S');
|
||||
if (typeof settingsConfiguration !== 'object') {
|
||||
throw new Error('llmOptions with an initial settingsConfiguration is required for VoiceAgent S2S');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {audio, ...rest} = Settings;
|
||||
const cfg = this.Settings = rest;
|
||||
const {audio, ...rest} = settingsConfiguration;
|
||||
const cfg = this.settingsConfiguration = rest;
|
||||
|
||||
if (!cfg.agent) throw new Error('llmOptions.Settings.agent is required for VoiceAgent S2S');
|
||||
if (!cfg.agent) throw new Error('llmOptions.settingsConfiguration.agent is required for VoiceAgent S2S');
|
||||
if (!cfg.agent.think) {
|
||||
throw new Error('llmOptions.Settings.agent.think is required for VoiceAgent S2S');
|
||||
throw new Error('llmOptions.settingsConfiguration.agent.think is required for VoiceAgent S2S');
|
||||
}
|
||||
if (!cfg.agent.think.provider?.model) {
|
||||
throw new Error('llmOptions.Settings.agent.think.provider.model is required for VoiceAgent S2S');
|
||||
if (!cfg.agent.think.model) {
|
||||
throw new Error('llmOptions.settingsConfiguration.agent.think.model is required for VoiceAgent S2S');
|
||||
}
|
||||
if (!cfg.agent.think.provider?.type) {
|
||||
throw new Error('llmOptions.Settings.agent.think.provider.type is required for VoiceAgent S2S');
|
||||
throw new Error('llmOptions.settingsConfiguration.agent.think.provider.type is required for VoiceAgent S2S');
|
||||
}
|
||||
|
||||
this.results = {
|
||||
@@ -92,7 +92,7 @@ class TaskLlmVoiceAgent_S2S extends Task {
|
||||
const {path} = this.connectionOptions || {};
|
||||
if (path) return path;
|
||||
|
||||
return '/v1/agent/converse';
|
||||
return '/agent';
|
||||
}
|
||||
|
||||
async _api(ep, args) {
|
||||
@@ -193,20 +193,7 @@ class TaskLlmVoiceAgent_S2S extends Task {
|
||||
}
|
||||
|
||||
async _sendInitialMessage(ep) {
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (mcpTools && mcpTools.length > 0 && this.Settings.agent?.think) {
|
||||
const convertedTools = mcpTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema
|
||||
}));
|
||||
|
||||
this.Settings.agent.think.functions = [
|
||||
...convertedTools,
|
||||
...(this.Settings.agent.think?.functions || [])
|
||||
];
|
||||
}
|
||||
if (!await this._sendClientEvent(ep, this.Settings)) {
|
||||
if (!await this._sendClientEvent(ep, this.settingsConfiguration)) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
@@ -267,43 +254,17 @@ class TaskLlmVoiceAgent_S2S extends Task {
|
||||
/* tool calls */
|
||||
else if (type === 'FunctionCallRequest') {
|
||||
this.logger.debug({evt}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - function_call');
|
||||
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (!this.toolHook && mcpTools.length === 0) {
|
||||
if (!this.toolHook) {
|
||||
this.logger.warn({evt}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - no toolHook defined!');
|
||||
} else {
|
||||
const {functions} = evt;
|
||||
const handledFunctions = [];
|
||||
}
|
||||
else {
|
||||
const {function_name:name, function_call_id:call_id} = evt;
|
||||
const args = evt.input;
|
||||
|
||||
try {
|
||||
if (mcpTools && mcpTools.length > 0) {
|
||||
for (const func of functions) {
|
||||
const {name, arguments: args, id} = func;
|
||||
const tool = mcpTools.find((tool) => tool.name === name);
|
||||
if (tool) {
|
||||
handledFunctions.push(name);
|
||||
const response = await this.parent.mcpService.callMcpTool(name, JSON.parse(args));
|
||||
this.logger.debug({response}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - function_call - mcp result');
|
||||
this.processToolOutput(_ep, id, {
|
||||
data: {
|
||||
type: 'FunctionCallResponse',
|
||||
id,
|
||||
name,
|
||||
content: response.length > 0 ? response[0].text : 'There is no output from the function call'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const func of functions) {
|
||||
const {name, arguments: args, id} = func;
|
||||
if (!handledFunctions.includes(name)) {
|
||||
await this.parent.sendToolHook(id, {name, args: JSON.parse(args)});
|
||||
}
|
||||
}
|
||||
await this.parent.sendToolHook(call_id, {name, args});
|
||||
} catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - error calling function');
|
||||
this.logger.info({err, evt}, 'TaskLlmVoiceAgent - error calling function');
|
||||
this.results = {
|
||||
completionReason: 'client error calling function',
|
||||
error: err
|
||||
|
||||
@@ -96,9 +96,6 @@ function makeTask(logger, obj, parent) {
|
||||
case TaskName.Tag:
|
||||
const TaskTag = require('./tag');
|
||||
return new TaskTag(logger, data, parent);
|
||||
case TaskName.Alert:
|
||||
const TaskAlert = require('./alert');
|
||||
return new TaskAlert(logger, data, parent);
|
||||
}
|
||||
|
||||
// should never reach
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const bent = require('bent');
|
||||
const crypto = require('crypto');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const {K8S} = require('../config');
|
||||
class TaskMessage extends Task {
|
||||
constructor(logger, opts) {
|
||||
@@ -9,7 +9,7 @@ class TaskMessage extends Task {
|
||||
this.preconditions = TaskPreconditions.None;
|
||||
|
||||
this.payload = {
|
||||
message_sid: this.data.message_sid || crypto.randomUUID(),
|
||||
message_sid: this.data.message_sid || uuidv4(),
|
||||
carrier: this.data.carrier,
|
||||
to: this.data.to,
|
||||
from: this.data.from,
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const { PlayFileNotFoundError } = require('../utils/error');
|
||||
|
||||
class TaskPlay extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.url = this.data.url.includes('?')
|
||||
? this.data.url.split('?')[0] + '?' + this.data.url.split('?')[1].replaceAll('.', '%2E')
|
||||
: this.data.url;
|
||||
this.url = this.data.url;
|
||||
this.seekOffset = this.data.seekOffset || -1;
|
||||
this.timeoutSecs = this.data.timeoutSecs || -1;
|
||||
this.loop = this.data.loop || 1;
|
||||
@@ -28,7 +27,6 @@ class TaskPlay extends Task {
|
||||
let playbackSeconds = 0;
|
||||
let playbackMilliseconds = 0;
|
||||
let completed = !(this.timeoutSecs > 0 || this.loop);
|
||||
cs.playingAudio = true;
|
||||
if (this.timeoutSecs > 0) {
|
||||
timeout = setTimeout(async() => {
|
||||
completed = true;
|
||||
@@ -42,22 +40,6 @@ class TaskPlay extends Task {
|
||||
try {
|
||||
this.notifyStatus({event: 'start-playback'});
|
||||
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) {
|
||||
/* Listen for playback-start event and set up a one-time listener for uuid_break
|
||||
* that will kill the audio playback if the taskIds match. This ensures that
|
||||
* we only kill the currently playing audio and not audio from other tasks.
|
||||
* As we are using stickyEventEmitter, even if the event is emitted before the listener is registered,
|
||||
* the listener will receive the most recent event.
|
||||
*/
|
||||
ep.once('playback-start', (evt) => {
|
||||
this.logger.debug({evt}, 'Play got playback-start');
|
||||
this.cs.stickyEventEmitter?.once('uuid_break', (t) => {
|
||||
if (t?.taskId === this.taskId) {
|
||||
this.logger.debug(`Play got kill-playback, executing uuid_break, taskId: ${t?.taskId}`);
|
||||
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
this.notifyStatus({event: 'kill-playback'});
|
||||
}
|
||||
});
|
||||
});
|
||||
if (cs.isInConference) {
|
||||
const {memberId, confName, confUuid} = cs;
|
||||
if (Array.isArray(this.url)) {
|
||||
@@ -105,15 +87,15 @@ class TaskPlay extends Task {
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
if (this.ep?.connected && !this.playComplete) {
|
||||
if (this.ep.connected && !this.playComplete) {
|
||||
this.logger.debug('TaskPlay:kill - killing audio');
|
||||
if (cs.isInConference) {
|
||||
const {memberId, confName} = cs;
|
||||
this.killPlayToConfMember(this.ep, memberId, confName);
|
||||
}
|
||||
else {
|
||||
//this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
cs.stickyEventEmitter.emit('uuid_break', this);
|
||||
this.notifyStatus({event: 'kill-playback'});
|
||||
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
const WsRequestor = require('../utils/ws-requestor');
|
||||
const URL = require('url');
|
||||
const HttpRequestor = require('../utils/http-requestor');
|
||||
|
||||
/**
|
||||
* Redirects to a new application
|
||||
@@ -17,34 +15,14 @@ class TaskRedirect extends Task {
|
||||
async exec(cs) {
|
||||
await super.exec(cs);
|
||||
|
||||
const isAbsoluteUrl = cs.application?.requestor?._isAbsoluteUrl(this.actionHook);
|
||||
|
||||
if (isAbsoluteUrl) {
|
||||
this.logger.info(`TaskRedirect redirecting to new absolute URL ${this.actionHook}, requires new requestor`);
|
||||
|
||||
if (cs.requestor instanceof WsRequestor) {
|
||||
try {
|
||||
const requestor = new WsRequestor(this.logger, cs.accountSid, {url: this.actionHook},
|
||||
cs.accountInfo.account.webhook_secret) ;
|
||||
cs.requestor.emit('handover', requestor);
|
||||
} catch (err) {
|
||||
this.logger.info(err, `TaskRedirect error redirecting to ${this.actionHook}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
const baseUrl = this.cs.application.requestor.baseUrl;
|
||||
const newUrl = URL.parse(this.actionHook);
|
||||
const newBaseUrl = newUrl.protocol + '//' + newUrl.host;
|
||||
if (baseUrl != newBaseUrl) {
|
||||
try {
|
||||
this.logger.info(`Task:redirect updating base url to ${newBaseUrl}`);
|
||||
const newRequestor = new HttpRequestor(this.logger, cs.accountSid, {url: this.actionHook},
|
||||
cs.accountInfo.account.webhook_secret);
|
||||
cs.requestor.emit('handover', newRequestor);
|
||||
} catch (err) {
|
||||
this.logger.info(err, `TaskRedirect error updating base url to ${this.actionHook}`);
|
||||
}
|
||||
}
|
||||
if (cs.requestor instanceof WsRequestor && cs.application.requestor._isAbsoluteUrl(this.actionHook)) {
|
||||
this.logger.info(`Task:performAction redirecting to ${this.actionHook}, requires new ws connection`);
|
||||
try {
|
||||
this.cs.requestor.close();
|
||||
const requestor = new WsRequestor(this.logger, cs.accountSid, {url: this.actionHook}, this.webhook_secret) ;
|
||||
this.cs.application.requestor = requestor;
|
||||
} catch (err) {
|
||||
this.logger.info(err, `Task:performAction error redirecting to ${this.actionHook}`);
|
||||
}
|
||||
}
|
||||
await this.performAction();
|
||||
|
||||
@@ -77,7 +77,6 @@ class TaskRestDial extends Task {
|
||||
const httpHeaders = b3 && {b3};
|
||||
const params = {
|
||||
...(cs.callInfo.toJSON()),
|
||||
...(this.env_vars && {env_vars: this.env_vars}),
|
||||
defaults: {
|
||||
synthesizer: {
|
||||
vendor: cs.speechSynthesisVendor,
|
||||
|
||||
261
lib/tasks/say.js
261
lib/tasks/say.js
@@ -3,32 +3,24 @@ const TtsTask = require('./tts-task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const pollySSMLSplit = require('polly-ssml-split');
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
const { sleepFor } = require('../utils/helpers');
|
||||
|
||||
const breakLengthyTextIfNeeded = (logger, text) => {
|
||||
// As The text can be used for tts streaming, we need to break lengthy text into smaller chunks
|
||||
// HIGH_WATER_BUFFER_SIZE defined in tts-streaming-buffer.js
|
||||
const chunkSize = 900;
|
||||
const breakLengthyTextIfNeeded = (logger, text) => {
|
||||
const chunkSize = 1000;
|
||||
const isSSML = text.startsWith('<speak>');
|
||||
if (text.length <= chunkSize || !isSSML) return [text];
|
||||
const options = {
|
||||
// MIN length
|
||||
softLimit: 100,
|
||||
// MAX length, exclude 15 characters <speak></speak>
|
||||
hardLimit: chunkSize - 15,
|
||||
// Set of extra split characters (Optional property)
|
||||
extraSplitChars: ',;!?',
|
||||
};
|
||||
pollySSMLSplit.configure(options);
|
||||
try {
|
||||
if (text.length <= chunkSize) return [text];
|
||||
if (isSSML) {
|
||||
return pollySSMLSplit.split(text);
|
||||
} else {
|
||||
// Wrap with <speak> and split
|
||||
const wrapped = `<speak>${text}</speak>`;
|
||||
const splitArr = pollySSMLSplit.split(wrapped);
|
||||
// Remove <speak> and </speak> from each chunk
|
||||
return splitArr.map((str) => str.replace(/^<speak>/, '').replace(/<\/speak>$/, ''));
|
||||
}
|
||||
return pollySSMLSplit.split(text);
|
||||
} catch (err) {
|
||||
logger.info({err}, 'Error splitting SSML long text');
|
||||
logger.info({err}, 'Error spliting SSML long text');
|
||||
return [text];
|
||||
}
|
||||
};
|
||||
@@ -47,9 +39,6 @@ class TaskSay extends TtsTask {
|
||||
assert.ok((typeof this.data.text === 'string' || Array.isArray(this.data.text)) || this.data.stream === true,
|
||||
'Say: either text or stream:true is required');
|
||||
|
||||
this.text = this.data.text ? (Array.isArray(this.data.text) ? this.data.text : [this.data.text])
|
||||
.map((t) => breakLengthyTextIfNeeded(this.logger, t))
|
||||
.flat() : [];
|
||||
|
||||
if (this.data.stream === true) {
|
||||
this._isStreamingTts = true;
|
||||
@@ -57,6 +46,10 @@ class TaskSay extends TtsTask {
|
||||
}
|
||||
else {
|
||||
this._isStreamingTts = false;
|
||||
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.isHandledByPrimaryProvider = true;
|
||||
}
|
||||
@@ -92,10 +85,6 @@ class TaskSay extends TtsTask {
|
||||
}
|
||||
|
||||
try {
|
||||
this._isStreamingTts = this._isStreamingTts || cs.autoStreamTts;
|
||||
if (this.isStreamingTts) {
|
||||
this.closeOnStreamEmpty = this.closeOnStreamEmpty || this.text.length !== 0;
|
||||
}
|
||||
if (this.isStreamingTts) await this.handlingStreaming(cs, obj);
|
||||
else await this.handling(cs, obj);
|
||||
this.emit('playDone');
|
||||
@@ -118,7 +107,7 @@ class TaskSay extends TtsTask {
|
||||
throw new SpeechCredentialError(
|
||||
`No text-to-speech service credentials for ${vendor} with labels: ${label} have been configured`);
|
||||
}
|
||||
this.ep = ep;
|
||||
|
||||
try {
|
||||
|
||||
await this.setTtsStreamingChannelVars(vendor, language, voice, credentials, ep);
|
||||
@@ -127,54 +116,6 @@ class TaskSay extends TtsTask {
|
||||
|
||||
cs.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'stream_open'})
|
||||
.catch((err) => this.logger.info({err}, 'TaskSay:handlingStreaming - Error sending'));
|
||||
|
||||
if (this.text.length !== 0) {
|
||||
this.logger.info('TaskSay:handlingStreaming - sending text to TTS stream');
|
||||
for (const t of this.text) {
|
||||
const result = await cs._internalTtsStreamingBufferTokens(t);
|
||||
if (result?.status === 'failed') {
|
||||
if (result.reason === 'full') {
|
||||
// Retry logic for full buffer
|
||||
const maxRetries = 5;
|
||||
let backoffMs = 1000;
|
||||
for (let retryCount = 0; retryCount < maxRetries && !this.killed; retryCount++) {
|
||||
this.logger.info(
|
||||
`TaskSay:handlingStreaming - retry ${retryCount + 1}/${maxRetries} after ${backoffMs}ms`);
|
||||
await sleepFor(backoffMs);
|
||||
|
||||
const retryResult = await cs._internalTtsStreamingBufferTokens(t);
|
||||
|
||||
// Exit retry loop on success
|
||||
if (retryResult?.status !== 'failed') {
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle failure for reason other than full buffer
|
||||
if (retryResult.reason !== 'full') {
|
||||
this.logger.info(
|
||||
{result: retryResult}, 'TaskSay:handlingStreaming - TTS stream failed to buffer tokens');
|
||||
throw new Error(`TTS stream failed to buffer tokens: ${retryResult.reason}`);
|
||||
}
|
||||
|
||||
// Last retry attempt failed
|
||||
if (retryCount === maxRetries - 1) {
|
||||
this.logger.info('TaskSay:handlingStreaming - Maximum retries exceeded for full buffer');
|
||||
throw new Error('TTS stream buffer full - maximum retries exceeded');
|
||||
}
|
||||
|
||||
// Increase backoff for next retry
|
||||
backoffMs = Math.min(backoffMs * 1.5, 10000);
|
||||
}
|
||||
} else {
|
||||
// Immediate failure for non-full buffer issues
|
||||
this.logger.info({result}, 'TaskSay:handlingStreaming - TTS stream failed to buffer tokens');
|
||||
throw new Error(`TTS stream failed to buffer tokens: ${result.reason}`);
|
||||
}
|
||||
} else {
|
||||
await cs._lccTtsFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskSay:handlingStreaming - Error setting channel vars');
|
||||
cs.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'stream_closed'})
|
||||
@@ -247,7 +188,6 @@ class TaskSay extends TtsTask {
|
||||
throw new SpeechCredentialError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
let filepath;
|
||||
try {
|
||||
filepath = await this._synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label});
|
||||
@@ -267,125 +207,47 @@ class TaskSay extends TtsTask {
|
||||
const isStreaming = filepath[segment].startsWith('say:{');
|
||||
if (isStreaming) {
|
||||
const arr = /^say:\{.*\}\s*(.*)$/.exec(filepath[segment]);
|
||||
if (arr) this.logger.debug(`Say:exec sending streaming tts request ${arr[1].substring(0, 64)}..`);
|
||||
else this.logger.debug(`Say:exec sending ${filepath[segment].substring(0, 64)}`);
|
||||
if (arr) this.logger.debug(`Say:exec sending streaming tts request: ${arr[1].substring(0, 64)}..`);
|
||||
}
|
||||
|
||||
const onPlaybackStop = (evt) => {
|
||||
try {
|
||||
this.logger.debug({evt},
|
||||
`Say got playback-stop ${evt.variable_tts_playback_id ? evt.variable_tts_playback_id : ''}`);
|
||||
|
||||
/**
|
||||
* If we got a playback id on both the start and stop events, and they don't match,
|
||||
* then we must have received a playback-stop event for an earlier play request.
|
||||
*/
|
||||
const playbackId = this.getPlaybackId(segment);
|
||||
// eslint-disable-next-line max-len
|
||||
const unmatchedResponse = (!!playbackId && !!evt.variable_tts_playback_id) && evt.variable_tts_playback_id !== playbackId;
|
||||
if (unmatchedResponse) {
|
||||
this.logger.info({currentPlaybackId: playbackId, stopPlaybackId: evt.variable_tts_playback_id},
|
||||
'Say:exec discarding playback-stop for earlier play');
|
||||
ep.once('playback-stop', this._boundOnPlaybackStop);
|
||||
|
||||
return;
|
||||
else this.logger.debug(`Say:exec sending ${filepath[segment].substring(0, 64)}`);
|
||||
ep.once('playback-start', (evt) => {
|
||||
this.logger.debug({evt}, 'Say got playback-start');
|
||||
if (this.otelSpan) {
|
||||
this._addStreamingTtsAttributes(this.otelSpan, evt);
|
||||
this.otelSpan.end();
|
||||
this.otelSpan = null;
|
||||
if (evt.variable_tts_cache_filename) {
|
||||
cs.trackTmpFile(evt.variable_tts_cache_filename);
|
||||
}
|
||||
|
||||
this.notifyStatus({event: 'stop-playback'});
|
||||
this.notifiedPlayBackStop = true;
|
||||
const tts_error = evt.variable_tts_error;
|
||||
// some tts vendor may not provide response code, so we default to 200
|
||||
let response_code = 200;
|
||||
// Check if any property ends with _response_code
|
||||
for (const [key, value] of Object.entries(evt)) {
|
||||
if (key.endsWith('_response_code')) {
|
||||
response_code = parseInt(value, 10);
|
||||
if (isNaN(response_code)) {
|
||||
this.logger.info(`Say:exec playback-stop - Invalid response code: ${value}`);
|
||||
response_code = 0;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tts_error ||
|
||||
// error response codes indicate failure
|
||||
response_code <= 199 || response_code >= 300) {
|
||||
writeAlerts({
|
||||
account_sid,
|
||||
alert_type: AlertType.TTS_FAILURE,
|
||||
vendor,
|
||||
detail: evt.variable_tts_error || `TTS playback failed with response code ${response_code}`,
|
||||
target_sid
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
||||
}
|
||||
if (
|
||||
!tts_error &&
|
||||
//2xx response codes indicate success
|
||||
199 < response_code && response_code < 300 &&
|
||||
evt.variable_tts_cache_filename &&
|
||||
!this.killed &&
|
||||
// if tts cache is not disabled, add the file to cache
|
||||
!this.disableTtsCache
|
||||
) {
|
||||
const text = parseTextFromSayString(this.text[segment]);
|
||||
addFileToCache(evt.variable_tts_cache_filename, {
|
||||
account_sid,
|
||||
vendor,
|
||||
language,
|
||||
voice,
|
||||
engine,
|
||||
model: this.model || this.model_id,
|
||||
text,
|
||||
instructions: this.instructions
|
||||
}).catch((err) => this.logger.info({err}, 'Error adding file to cache'));
|
||||
}
|
||||
|
||||
if (this._playResolve) {
|
||||
(tts_error ||
|
||||
// error response codes indicate failure
|
||||
response_code <= 199 || response_code >= 300
|
||||
) ?
|
||||
this._playReject(
|
||||
new Error(evt.variable_tts_error || `TTS playback failed with response code ${response_code}`)
|
||||
) : this._playResolve();
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Error handling playback-stop event');
|
||||
}
|
||||
};
|
||||
this._boundOnPlaybackStop = onPlaybackStop.bind(this);
|
||||
|
||||
const onPlaybackStart = (evt) => {
|
||||
try {
|
||||
const playbackId = this.getPlaybackId(segment);
|
||||
// eslint-disable-next-line max-len
|
||||
const unmatchedResponse = (!!playbackId && !!evt.variable_tts_playback_id) && evt.variable_tts_playback_id !== playbackId;
|
||||
if (unmatchedResponse) {
|
||||
this.logger.info({currentPlaybackId: playbackId, stopPlaybackId: evt.variable_tts_playback_id},
|
||||
'Say:exec playback-start - unmatched playback_id');
|
||||
ep.once('playback-start', this._boundOnPlaybackStart);
|
||||
return;
|
||||
}
|
||||
this.logger.debug({evt},
|
||||
`Say got playback-start ${evt.variable_tts_playback_id ? evt.variable_tts_playback_id : ''}`);
|
||||
if (this.otelSpan) {
|
||||
this._addStreamingTtsAttributes(this.otelSpan, evt, vendor);
|
||||
this.otelSpan.end();
|
||||
this.otelSpan = null;
|
||||
if (evt.variable_tts_cache_filename) {
|
||||
cs.trackTmpFile(evt.variable_tts_cache_filename);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Error handling playback-start event');
|
||||
});
|
||||
ep.once('playback-stop', (evt) => {
|
||||
this.logger.debug({evt}, 'Say got playback-stop');
|
||||
if (evt.variable_tts_error) {
|
||||
writeAlerts({
|
||||
account_sid,
|
||||
alert_type: AlertType.TTS_FAILURE,
|
||||
vendor,
|
||||
detail: evt.variable_tts_error,
|
||||
target_sid
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
||||
}
|
||||
if (evt.variable_tts_cache_filename && !this.killed) {
|
||||
const text = parseTextFromSayString(this.text[segment]);
|
||||
addFileToCache(evt.variable_tts_cache_filename, {
|
||||
account_sid,
|
||||
vendor,
|
||||
language,
|
||||
voice,
|
||||
engine,
|
||||
text
|
||||
}).catch((err) => this.logger.info({err}, 'Error adding file to cache'));
|
||||
}
|
||||
};
|
||||
this._boundOnPlaybackStart = onPlaybackStart.bind(this);
|
||||
|
||||
ep.once('playback-stop', this._boundOnPlaybackStop);
|
||||
ep.once('playback-start', this._boundOnPlaybackStart);
|
||||
|
||||
if (this._playResolve) {
|
||||
evt.variable_tts_error ? this._playReject(new Error(evt.variable_tts_error)) : this._playResolve();
|
||||
}
|
||||
});
|
||||
// wait for playback-stop event received to confirm if the playback is successful
|
||||
this._playPromise = new Promise((resolve, reject) => {
|
||||
this._playResolve = resolve;
|
||||
@@ -429,13 +291,8 @@ class TaskSay extends TtsTask {
|
||||
if (cs.isInConference) {
|
||||
const {memberId, confName} = cs;
|
||||
this.killPlayToConfMember(this.ep, memberId, confName);
|
||||
} else if (this.isStreamingTts) {
|
||||
this.logger.debug('TaskSay:kill - clearing TTS stream for streaming audio');
|
||||
cs.clearTtsStream();
|
||||
} else {
|
||||
if (!this.notifiedPlayBackStop) {
|
||||
this.notifyStatus({event: 'stop-playback'});
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.notifyStatus({event: 'kill-playback'});
|
||||
this.ep.api('uuid_break', this.ep.uuid);
|
||||
}
|
||||
@@ -450,26 +307,20 @@ class TaskSay extends TtsTask {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_addStreamingTtsAttributes(span, evt, vendor) {
|
||||
_addStreamingTtsAttributes(span, evt) {
|
||||
const attrs = {'tts.cached': false};
|
||||
for (const [key, value] of Object.entries(evt)) {
|
||||
if (key.startsWith('variable_tts_')) {
|
||||
let newKey = key.substring('variable_tts_'.length)
|
||||
.replace('whisper_', 'whisper.')
|
||||
.replace('nvidia_', 'nvidia.')
|
||||
.replace('deepgram_', 'deepgram.')
|
||||
.replace('playht_', 'playht.')
|
||||
.replace('cartesia_', 'cartesia.')
|
||||
.replace('rimelabs_', 'rimelabs.')
|
||||
.replace('resemble_', 'resemble.')
|
||||
.replace('inworld_', 'inworld.')
|
||||
.replace('verbio_', 'verbio.')
|
||||
.replace('elevenlabs_', 'elevenlabs.');
|
||||
if (spanMapping[newKey]) newKey = spanMapping[newKey];
|
||||
attrs[newKey] = value;
|
||||
if (key === 'variable_tts_time_to_first_byte_ms' && value) {
|
||||
this.cs.srf.locals.stats.histogram('tts.response_time', value, [`vendor:${vendor}`]);
|
||||
}
|
||||
}
|
||||
}
|
||||
delete attrs['cache_filename']; //no value in adding this to the span
|
||||
@@ -527,14 +378,6 @@ const spanMapping = {
|
||||
'rimelabs.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'rimelabs.connect_time_ms': 'connect_ms',
|
||||
'rimelabs.final_response_time_ms': 'final_response_ms',
|
||||
// Resemble
|
||||
'resemble.connect_time_ms': 'connect_ms',
|
||||
'resemble.final_response_time_ms': 'final_response_ms',
|
||||
// inworld
|
||||
'inworld.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'inworld.connect_time_ms': 'connect_ms',
|
||||
'inworld.final_response_time_ms': 'final_response_ms',
|
||||
'inworld.x_envoy_upstream_service_time': 'upstream_service_time',
|
||||
// verbio
|
||||
'verbio.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'verbio.connect_time_ms': 'connect_ms',
|
||||
|
||||
@@ -18,11 +18,6 @@ class TaskSipDecline extends Task {
|
||||
super.exec(cs);
|
||||
res.send(this.data.status, this.data.reason, {
|
||||
headers: this.headers
|
||||
}, (err) => {
|
||||
if (!err) {
|
||||
// Call was successfully declined
|
||||
cs._callReleased();
|
||||
}
|
||||
});
|
||||
cs.emit('callStatusChange', {
|
||||
callStatus: CallStatus.Failed,
|
||||
|
||||
@@ -111,12 +111,7 @@ class TaskSipRefer extends Task {
|
||||
/* get IP address of the SBC to use as hostname if needed */
|
||||
const {host} = parseUri(dlg.remote.uri);
|
||||
|
||||
if (
|
||||
!referTo.startsWith('<') &&
|
||||
!referTo.startsWith('sip:') &&
|
||||
!referTo.startsWith('"') &&
|
||||
!referTo.startsWith('tel:')
|
||||
) {
|
||||
if (!referTo.startsWith('<') && !referTo.startsWith('sip') && !referTo.startsWith('"')) {
|
||||
/* they may have only provided a phone number/user */
|
||||
referTo = `sip:${referTo}@${host}`;
|
||||
}
|
||||
@@ -129,12 +124,7 @@ class TaskSipRefer extends Task {
|
||||
if (!referredByDisplayName) {
|
||||
referredByDisplayName = cs.req?.callingName;
|
||||
}
|
||||
if (
|
||||
!referredBy.startsWith('<') &&
|
||||
!referredBy.startsWith('sip:') &&
|
||||
!referredBy.startsWith('"') &&
|
||||
!referredBy.startsWith('tel:')
|
||||
) {
|
||||
if (!referredBy.startsWith('<') && !referredBy.startsWith('sip') && !referredBy.startsWith('"')) {
|
||||
/* they may have only provided a phone number/user */
|
||||
referredBy = `${referredByDisplayName ? `"${referredByDisplayName}"` : ''}<sip:${referredBy}@${host}>`;
|
||||
}
|
||||
|
||||
@@ -4,31 +4,6 @@ const crypto = require('crypto');
|
||||
const { TaskPreconditions, CobaltTranscriptionEvents } = require('../utils/constants');
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
const {JAMBONES_AWS_TRANSCRIBE_USE_GRPC} = require('../config');
|
||||
const {TaskName} = require('../utils/constants.json');
|
||||
|
||||
/**
|
||||
* "Please insert turns here: {{turns:4}}"
|
||||
// -> { processed: 'Please insert turns here: {{turns}}', turns: 4 }
|
||||
|
||||
processTurnString("Please insert turns here: {{turns}}"));
|
||||
// -> { processed: 'Please insert turns here: {{turns}}', turns: null }
|
||||
*/
|
||||
const processTurnString = (input) => {
|
||||
const regex = /\{\{turns(?::(\d+))?\}\}/;
|
||||
const match = input.match(regex);
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
processed: input,
|
||||
turns: null
|
||||
};
|
||||
}
|
||||
|
||||
const turns = match[1] ? parseInt(match[1], 10) : null;
|
||||
const processed = input.replace(regex, '{{turns}}');
|
||||
|
||||
return { processed, turns };
|
||||
};
|
||||
|
||||
class SttTask extends Task {
|
||||
|
||||
@@ -85,9 +60,6 @@ class SttTask extends Task {
|
||||
/*bug name prefix */
|
||||
this.bugname_prefix = '';
|
||||
|
||||
// stt latency calculator
|
||||
this.stt_latency_ms = '';
|
||||
|
||||
}
|
||||
|
||||
async exec(cs, {ep, ep2}) {
|
||||
@@ -95,12 +67,6 @@ class SttTask extends Task {
|
||||
this.ep = ep;
|
||||
this.ep2 = ep2;
|
||||
|
||||
// start vad from stt latency calculator
|
||||
if (this.name !== TaskName.Gather ||
|
||||
this.name === TaskName.Gather && this.needsStt) {
|
||||
cs.startSttLatencyVad();
|
||||
}
|
||||
|
||||
// use session preferences if we don't have specific verb-level settings.
|
||||
if (cs.recognizer) {
|
||||
for (const k in cs.recognizer) {
|
||||
@@ -324,57 +290,6 @@ class SttTask extends Task {
|
||||
});
|
||||
}
|
||||
|
||||
formatOpenAIPrompt(cs, {prompt, hintsTemplate, conversationHistoryTemplate, hints}) {
|
||||
let conversationHistoryPrompt, hintsPrompt;
|
||||
|
||||
/* generate conversation history from template */
|
||||
if (conversationHistoryTemplate) {
|
||||
const {processed, turns} = processTurnString(conversationHistoryTemplate);
|
||||
this.logger.debug({processed, turns}, 'SttTask: processed conversation history template');
|
||||
conversationHistoryPrompt = cs.getFormattedConversation(turns || 4);
|
||||
//this.logger.debug({conversationHistoryPrompt}, 'SttTask: conversation history');
|
||||
if (conversationHistoryPrompt) {
|
||||
conversationHistoryPrompt = processed.replace('{{turns}}', `\n${conversationHistoryPrompt}\nuser: `);
|
||||
}
|
||||
}
|
||||
|
||||
/* generate hints from template */
|
||||
if (hintsTemplate && Array.isArray(hints) && hints.length > 0) {
|
||||
hintsPrompt = hintsTemplate.replace('{{hints}}', hints);
|
||||
}
|
||||
|
||||
/* combine into final prompt */
|
||||
let finalPrompt = prompt || '';
|
||||
if (hintsPrompt) {
|
||||
finalPrompt = `${finalPrompt}\n${hintsPrompt}`;
|
||||
}
|
||||
if (conversationHistoryPrompt) {
|
||||
finalPrompt = `${finalPrompt}\n${conversationHistoryPrompt}`;
|
||||
}
|
||||
|
||||
this.logger.debug({
|
||||
finalPrompt,
|
||||
hints,
|
||||
hintsPrompt,
|
||||
conversationHistoryTemplate,
|
||||
conversationHistoryPrompt
|
||||
}, 'SttTask: formatted OpenAI prompt');
|
||||
return finalPrompt?.trimStart();
|
||||
}
|
||||
|
||||
/* some STT engines will keep listening after a final response, so no need to restart */
|
||||
doesVendorContinueListeningAfterFinalTranscript(vendor) {
|
||||
return (vendor.startsWith('custom:') || [
|
||||
'soniox',
|
||||
'aws',
|
||||
'microsoft',
|
||||
'deepgram',
|
||||
'google',
|
||||
'speechmatics',
|
||||
'openai',
|
||||
].includes(vendor));
|
||||
}
|
||||
|
||||
_onCompileContext(ep, key, evt) {
|
||||
const {addKey} = this.cs.srf.locals.dbHelpers;
|
||||
this.logger.debug({evt}, `received cobalt compile context event, will cache under ${key}`);
|
||||
@@ -410,7 +325,7 @@ class SttTask extends Task {
|
||||
dgOptions.utteranceEndMs = dgOptions.utteranceEndMs || asrTimeout;
|
||||
}
|
||||
|
||||
_onVendorConnect(cs, _ep) {
|
||||
_onVendorConnect(_cs, _ep) {
|
||||
this.logger.debug(`TaskGather:_on${this.vendor}Connect`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const Emitter = require('events');
|
||||
const crypto = require('crypto');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const {TaskPreconditions} = require('../utils/constants');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const WsRequestor = require('../utils/ws-requestor');
|
||||
@@ -19,7 +19,6 @@ class Task extends Emitter {
|
||||
this.data = data;
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.id = data.id;
|
||||
this.taskId = crypto.randomUUID();
|
||||
|
||||
this._killInProgress = false;
|
||||
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
|
||||
@@ -166,7 +165,7 @@ class Task extends Emitter {
|
||||
span.setAttributes({'http.body': JSON.stringify(params)});
|
||||
try {
|
||||
if (this.id) params.verb_id = this.id;
|
||||
const json = await this.cs.requestor.request(type, this.actionHook, params, httpHeaders, span);
|
||||
const json = await this.cs.requestor.request(type, this.actionHook, params, httpHeaders);
|
||||
span.setAttributes({'http.statusCode': 200});
|
||||
const isWsConnection = this.cs.requestor instanceof WsRequestor;
|
||||
if (!isWsConnection || (expectResponse && json && Array.isArray(json) && json.length)) {
|
||||
@@ -210,7 +209,7 @@ class Task extends Emitter {
|
||||
const httpHeaders = b3 && {b3};
|
||||
span.setAttributes({'http.body': JSON.stringify(params)});
|
||||
try {
|
||||
const json = await cs.requestor.request('verb:hook', hook, params, httpHeaders, span);
|
||||
const json = await cs.requestor.request('verb:hook', hook, params, httpHeaders);
|
||||
span.setAttributes({'http.statusCode': 200});
|
||||
span.end();
|
||||
if (json && Array.isArray(json)) {
|
||||
@@ -273,7 +272,7 @@ class Task extends Emitter {
|
||||
}
|
||||
|
||||
async transferCallToFeatureServer(cs, sipAddress, opts) {
|
||||
const uuid = crypto.randomUUID();
|
||||
const uuid = uuidv4();
|
||||
const {addKey} = cs.srf.locals.dbHelpers;
|
||||
const obj = Object.assign({}, cs.application);
|
||||
delete obj.requestor;
|
||||
|
||||
@@ -6,7 +6,6 @@ const {
|
||||
AwsTranscriptionEvents,
|
||||
AzureTranscriptionEvents,
|
||||
DeepgramTranscriptionEvents,
|
||||
DeepgramRiverTranscriptionEvents,
|
||||
SonioxTranscriptionEvents,
|
||||
CobaltTranscriptionEvents,
|
||||
IbmTranscriptionEvents,
|
||||
@@ -14,9 +13,6 @@ const {
|
||||
JambonzTranscriptionEvents,
|
||||
TranscribeStatus,
|
||||
AssemblyAiTranscriptionEvents,
|
||||
VoxistTranscriptionEvents,
|
||||
CartesiaTranscriptionEvents,
|
||||
OpenAITranscriptionEvents,
|
||||
VerbioTranscriptionEvents,
|
||||
SpeechmaticsTranscriptionEvents
|
||||
} = require('../utils/constants.json');
|
||||
@@ -33,6 +29,7 @@ class TaskTranscribe extends SttTask {
|
||||
this.transcriptionHook = this.data.transcriptionHook;
|
||||
this.translationHook = this.data.translationHook;
|
||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||
|
||||
if (this.data.recognizer) {
|
||||
this.interim = !!this.data.recognizer.interim;
|
||||
this.separateRecognitionPerChannel = this.data.recognizer.separateRecognitionPerChannel;
|
||||
@@ -107,7 +104,7 @@ class TaskTranscribe extends SttTask {
|
||||
|
||||
if (cs.hasGlobalSttHints) {
|
||||
const {hints, hintsBoost} = cs.globalSttHints;
|
||||
this.data.recognizer.hints = this.data.recognizer?.hints?.concat(hints);
|
||||
this.data.recognizer.hints = this.data.recognizer.hints.concat(hints);
|
||||
if (!this.data.recognizer.hintsBoost && hintsBoost) this.data.recognizer.hintsBoost = hintsBoost;
|
||||
this.logger.debug({hints: this.data.recognizer.hints, hintsBoost: this.data.recognizer.hintsBoost},
|
||||
'Transcribe:exec - applying global sttHints');
|
||||
@@ -138,30 +135,22 @@ class TaskTranscribe extends SttTask {
|
||||
stopTranscription = true;
|
||||
this.ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname,
|
||||
gracefulShutdown: this.paused ? false : true
|
||||
bugname: this.bugname
|
||||
})
|
||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||
}
|
||||
if (this.transcribing2 && this.ep2?.connected) {
|
||||
stopTranscription = true;
|
||||
this.ep2.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname,
|
||||
gracefulShutdown: this.paused ? false : true
|
||||
})
|
||||
this.ep2.stopTranscription({vendor: this.vendor, bugname: this.bugname})
|
||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||
}
|
||||
|
||||
this.cs.emit('transcribe-stop');
|
||||
|
||||
return stopTranscription;
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
const stopTranscription = this._stopTranscription();
|
||||
cs.stopSttLatencyVad();
|
||||
// hangup after 1 sec if we don't get a final transcription
|
||||
if (stopTranscription) this._timer = setTimeout(() => this.notifyTaskDone(), 1500);
|
||||
else this.notifyTaskDone();
|
||||
@@ -242,15 +231,6 @@ class TaskTranscribe extends SttTask {
|
||||
//if (opts.DEEPGRAM_SPEECH_UTTERANCE_END_MS) this.isContinuousAsr = true;
|
||||
|
||||
break;
|
||||
case 'deepgramriver':
|
||||
this.bugname = `${this.bugname_prefix}deepgramriver_transcribe`;
|
||||
this.addCustomEventListener(ep, DeepgramRiverTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep, DeepgramRiverTranscriptionEvents.Connect,
|
||||
this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, DeepgramRiverTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||
break;
|
||||
case 'soniox':
|
||||
this.bugname = `${this.bugname_prefix}soniox_transcribe`;
|
||||
this.addCustomEventListener(ep, SonioxTranscriptionEvents.Transcription,
|
||||
@@ -320,28 +300,6 @@ class TaskTranscribe extends SttTask {
|
||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||
break;
|
||||
|
||||
case 'voxist':
|
||||
this.bugname = `${this.bugname_prefix}voxist_transcribe`;
|
||||
this.addCustomEventListener(ep, VoxistTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep,
|
||||
VoxistTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, VoxistTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, VoxistTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||
break;
|
||||
|
||||
case 'cartesia':
|
||||
this.bugname = `${this.bugname_prefix}cartesia_transcribe`;
|
||||
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep,
|
||||
CartesiaTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||
break;
|
||||
|
||||
case 'speechmatics':
|
||||
this.bugname = `${this.bugname_prefix}speechmatics_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
@@ -360,20 +318,6 @@ class TaskTranscribe extends SttTask {
|
||||
this._onSpeechmaticsError.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'openai':
|
||||
this.bugname = `${this.bugname_prefix}openai_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
ep, OpenAITranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep, OpenAITranscriptionEvents.Connect,
|
||||
this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, OpenAITranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, OpenAITranscriptionEvents.Error,
|
||||
this._onOpenAIErrror.bind(this, cs, ep));
|
||||
|
||||
this.modelSupportsConversationTracking = opts.OPENAI_MODEL !== 'whisper-1';
|
||||
break;
|
||||
|
||||
default:
|
||||
if (this.vendor.startsWith('custom:')) {
|
||||
this.bugname = `${this.bugname_prefix}${this.vendor}_transcribe`;
|
||||
@@ -409,25 +353,6 @@ class TaskTranscribe extends SttTask {
|
||||
async _transcribe(ep) {
|
||||
this.logger.debug(
|
||||
`TaskTranscribe:_transcribe - starting transcription vendor ${this.vendor} bugname ${this.bugname}`);
|
||||
|
||||
/* special feature for openai: we can provide a prompt that includes recent conversation history */
|
||||
let prompt;
|
||||
if (this.vendor === 'openai') {
|
||||
if (this.modelSupportsConversationTracking) {
|
||||
prompt = this.formatOpenAIPrompt(this.cs, {
|
||||
prompt: this.data.recognizer?.openaiOptions?.prompt,
|
||||
hintsTemplate: this.data.recognizer?.openaiOptions?.promptTemplates?.hintsTemplate,
|
||||
// eslint-disable-next-line max-len
|
||||
conversationHistoryTemplate: this.data.recognizer?.openaiOptions?.promptTemplates?.conversationHistoryTemplate,
|
||||
hints: this.data.recognizer?.hints,
|
||||
});
|
||||
this.logger.debug({prompt}, 'Gather:_startTranscribing - created an openai prompt');
|
||||
}
|
||||
else if (this.data.recognizer?.hints?.length > 0) {
|
||||
prompt = this.data.recognizer?.hints.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
await ep.startTranscription({
|
||||
vendor: this.vendor,
|
||||
interim: this.interim ? true : false,
|
||||
@@ -436,16 +361,9 @@ class TaskTranscribe extends SttTask {
|
||||
bugname: this.bugname,
|
||||
hostport: this.hostport
|
||||
});
|
||||
|
||||
// Some vendor use single connection, that we cannot use onConnect event to track transcription start
|
||||
this.cs.emit('transcribe-start');
|
||||
}
|
||||
|
||||
async _onTranscription(cs, ep, channel, evt, fsEvent) {
|
||||
// check if we are in graceful shutdown mode
|
||||
if (ep.gracefulShutdownResolver) {
|
||||
ep.gracefulShutdownResolver();
|
||||
}
|
||||
// make sure this is not a transcript from answering machine detection
|
||||
const bugname = fsEvent.getHeader('media-bugname');
|
||||
const finished = fsEvent.getHeader('transcription-session-finished');
|
||||
@@ -457,9 +375,6 @@ class TaskTranscribe extends SttTask {
|
||||
|
||||
if (this.vendor === 'ibm' && evt?.state === 'listening') return;
|
||||
|
||||
// emit an event to the call session to track the time transcription is received
|
||||
cs.emit('on-transcription');
|
||||
|
||||
if (this.vendor === 'deepgram' && evt.type === 'UtteranceEnd') {
|
||||
/* we will only get this when we have set utterance_end_ms */
|
||||
|
||||
@@ -529,9 +444,8 @@ class TaskTranscribe extends SttTask {
|
||||
this._startAsrTimer(channel);
|
||||
|
||||
/* some STT engines will keep listening after a final response, so no need to restart */
|
||||
if (!this.doesVendorContinueListeningAfterFinalTranscript(this.vendor)) {
|
||||
this._startTranscribing(cs, ep, channel);
|
||||
}
|
||||
if (!['soniox', 'aws', 'microsoft', 'deepgram', 'google', 'speechmatics']
|
||||
.includes(this.vendor)) this._startTranscribing(cs, ep, channel);
|
||||
}
|
||||
else {
|
||||
if (this.vendor === 'soniox') {
|
||||
@@ -554,7 +468,9 @@ class TaskTranscribe extends SttTask {
|
||||
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription - sending final transcript');
|
||||
this._resolve(channel, evt);
|
||||
|
||||
if (!this.doesVendorContinueListeningAfterFinalTranscript(this.vendor)) {
|
||||
/* some STT engines will keep listening after a final response, so no need to restart */
|
||||
if (!['soniox', 'aws', 'microsoft', 'deepgram', 'google', 'speechmatics'].includes(this.vendor) &&
|
||||
!this.vendor.startsWith('custom:')) {
|
||||
this.logger.debug('TaskTranscribe:_onTranscription - restarting transcribe');
|
||||
this._startTranscribing(cs, ep, channel);
|
||||
}
|
||||
@@ -621,28 +537,14 @@ class TaskTranscribe extends SttTask {
|
||||
}
|
||||
|
||||
async _resolve(channel, evt) {
|
||||
let sttLatencyMetrics = {};
|
||||
if (evt.is_final) {
|
||||
const sttLatency = this.cs.calculateSttLatency();
|
||||
if (sttLatency) {
|
||||
sttLatencyMetrics = {
|
||||
'stt.latency_ms': `${sttLatency.stt_latency_ms}`,
|
||||
'stt.talkspurts': JSON.stringify(sttLatency.talkspurts),
|
||||
'stt.start_time': sttLatency.stt_start_time,
|
||||
'stt.stop_time': sttLatency.stt_stop_time,
|
||||
'stt.usage': sttLatency.stt_usage,
|
||||
};
|
||||
}
|
||||
// time to reset the stt latency
|
||||
this.cs.emit('transcribe-start');
|
||||
/* we've got a final transcript, so end the otel child span for this channel */
|
||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
||||
this.childSpan[channel - 1].span.setAttributes({
|
||||
channel,
|
||||
'stt.label': this.label || 'None',
|
||||
'stt.resolve': 'transcript',
|
||||
'stt.result': JSON.stringify(evt),
|
||||
...sttLatencyMetrics
|
||||
'stt.result': JSON.stringify(evt)
|
||||
});
|
||||
this.childSpan[channel - 1].span.end();
|
||||
}
|
||||
@@ -651,13 +553,9 @@ class TaskTranscribe extends SttTask {
|
||||
if (this.transcriptionHook) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
const latencies = Object.fromEntries(
|
||||
Object.entries(sttLatencyMetrics).map(([key, value]) => [key.replace('stt.', 'stt_'), value])
|
||||
);
|
||||
const payload = {
|
||||
...this.cs.callInfo,
|
||||
...httpHeaders,
|
||||
...latencies,
|
||||
...(evt.alternatives && {speech: evt}),
|
||||
...(evt.type && {speechEvent: evt})
|
||||
};
|
||||
@@ -711,21 +609,12 @@ class TaskTranscribe extends SttTask {
|
||||
}
|
||||
|
||||
_onMaxDurationExceeded(cs, ep, channel) {
|
||||
this.restartDueToError(ep, channel, 'Max duration exceeded');
|
||||
}
|
||||
|
||||
_onMaxBufferExceeded(cs, ep, channel) {
|
||||
this.restartDueToError(ep, channel, 'Max buffer exceeded');
|
||||
}
|
||||
|
||||
restartDueToError(ep, channel, reason) {
|
||||
this.logger.debug(`TaskTranscribe:${reason} on channel ${channel}`);
|
||||
this.logger.debug(`TaskTranscribe:_onMaxDurationExceeded on channel ${channel}`);
|
||||
if (this.paused) return;
|
||||
|
||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
||||
this.childSpan[channel - 1].span.setAttributes({
|
||||
channel,
|
||||
'stt.resolve': reason,
|
||||
'stt.resolve': 'max duration exceeded',
|
||||
'stt.label': this.label || 'None',
|
||||
});
|
||||
this.childSpan[channel - 1].span.end();
|
||||
@@ -749,8 +638,7 @@ class TaskTranscribe extends SttTask {
|
||||
if (this.canFallback) {
|
||||
_ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname,
|
||||
gracefulShutdown: false
|
||||
bugname: this.bugname
|
||||
})
|
||||
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
|
||||
try {
|
||||
@@ -783,14 +671,6 @@ class TaskTranscribe extends SttTask {
|
||||
return;
|
||||
}
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
|
||||
if (this.vendor === 'microsoft' &&
|
||||
evt.error?.includes('Due to service inactivity, the client buffer exceeded maximum size. Resetting the buffer')) {
|
||||
let channel = 1;
|
||||
if (this.ep !== _ep) {
|
||||
channel = 2;
|
||||
}
|
||||
return this._onMaxBufferExceeded(cs, _ep, channel);
|
||||
}
|
||||
if (this.paused) return;
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
|
||||
@@ -841,12 +721,6 @@ class TaskTranscribe extends SttTask {
|
||||
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
|
||||
}
|
||||
|
||||
async _onOpenAIErrror(cs, _ep, evt) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {message, ...e} = evt;
|
||||
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
|
||||
}
|
||||
|
||||
_startAsrTimer(channel) {
|
||||
if (this.vendor === 'deepgram') return; // no need
|
||||
assert(this.isContinuousAsr);
|
||||
|
||||
@@ -3,16 +3,6 @@ const { TaskPreconditions } = require('../utils/constants');
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
const dbUtils = require('../utils/db-utils');
|
||||
|
||||
const extractPlaybackId = (str) => {
|
||||
// Match say:{...} and capture the content inside braces
|
||||
const match = str.match(/say:\{([^}]*)\}/);
|
||||
if (!match) return null;
|
||||
|
||||
// Look for playback_id=value within the captured content
|
||||
const playbackMatch = match[1].match(/playback_id=([^,]*)/);
|
||||
return playbackMatch ? playbackMatch[1] : null;
|
||||
};
|
||||
|
||||
class TtsTask extends Task {
|
||||
|
||||
constructor(logger, data, parentTask) {
|
||||
@@ -31,12 +21,6 @@ class TtsTask extends Task {
|
||||
this.synthesizer = this.data.synthesizer || {};
|
||||
this.disableTtsCache = this.data.disableTtsCache;
|
||||
this.options = this.synthesizer.options || {};
|
||||
this.instructions = this.data.instructions;
|
||||
this.playbackIds = [];
|
||||
}
|
||||
|
||||
getPlaybackId(offset) {
|
||||
return this.playbackIds[offset];
|
||||
}
|
||||
|
||||
async exec(cs) {
|
||||
@@ -58,12 +42,6 @@ class TtsTask extends Task {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fullText = Array.isArray(this.text) ? this.text.join(' ') : this.text;
|
||||
// in case dub verb, text might not be set.
|
||||
if (fullText?.length > 0) {
|
||||
cs.emit('botSaid', fullText);
|
||||
}
|
||||
}
|
||||
|
||||
getTtsVendorData(cs) {
|
||||
@@ -82,6 +60,7 @@ class TtsTask extends Task {
|
||||
|
||||
async setTtsStreamingChannelVars(vendor, language, voice, credentials, ep) {
|
||||
const {api_key, model_id, custom_tts_streaming_url, auth_token} = credentials;
|
||||
const {stability, similarity_boost, use_speaker_boost, style} = this.options;
|
||||
let obj;
|
||||
|
||||
this.logger.debug({credentials},
|
||||
@@ -103,7 +82,6 @@ class TtsTask extends Task {
|
||||
};
|
||||
break;
|
||||
case 'elevenlabs':
|
||||
const {stability, similarity_boost, use_speaker_boost, style, speed} = this.options.voice_settings || {};
|
||||
obj = {
|
||||
ELEVENLABS_API_KEY: api_key,
|
||||
ELEVENLABS_TTS_STREAMING_MODEL_ID: model_id,
|
||||
@@ -113,14 +91,7 @@ class TtsTask extends Task {
|
||||
...(stability && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_STABILITY: stability}),
|
||||
...(similarity_boost && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_SIMILARITY_BOOST: similarity_boost}),
|
||||
...(use_speaker_boost && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_USE_SPEAKER_BOOST: use_speaker_boost}),
|
||||
...(style && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_STYLE: style}),
|
||||
// speed has value 0.7 to 1.2, 1.0 is default, make sure we send the value event it's 0
|
||||
...(speed !== null && speed !== undefined && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_SPEED: `${speed}`}),
|
||||
...(this.options.pronunciation_dictionary_locators &&
|
||||
Array.isArray(this.options.pronunciation_dictionary_locators) && {
|
||||
ELEVENLABS_TTS_STREAMING_PRONUNCIATION_DICTIONARY_LOCATORS:
|
||||
JSON.stringify(this.options.pronunciation_dictionary_locators)
|
||||
}),
|
||||
...(style && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_STYLE: style})
|
||||
};
|
||||
break;
|
||||
case 'rimelabs':
|
||||
@@ -154,7 +125,7 @@ class TtsTask extends Task {
|
||||
throw new Error(`vendor ${vendor} is not supported for tts streaming yet`);
|
||||
}
|
||||
}
|
||||
this.logger.debug({vendor, credentials, obj}, 'setTtsStreamingChannelVars');
|
||||
this.logger.info({vendor, credentials, obj}, 'setTtsStreamingChannelVars');
|
||||
|
||||
await ep.set(obj);
|
||||
}
|
||||
@@ -172,14 +143,15 @@ class TtsTask extends Task {
|
||||
`No text-to-speech service credentials for ${vendor} with labels: ${label} have been configured`);
|
||||
}
|
||||
/* parse Nuance voices into name and model */
|
||||
let model;
|
||||
if (vendor === 'nuance' && voice) {
|
||||
const arr = /([A-Za-z-]*)\s+-\s+(enhanced|standard)/.exec(voice);
|
||||
if (arr) {
|
||||
voice = arr[1];
|
||||
this.model = arr[2];
|
||||
model = arr[2];
|
||||
}
|
||||
} else if (vendor === 'deepgram') {
|
||||
this.model = voice;
|
||||
model = voice;
|
||||
}
|
||||
|
||||
/* allow for microsoft custom region voice and api_key to be specified as an override */
|
||||
@@ -200,9 +172,6 @@ class TtsTask extends Task {
|
||||
} else if (vendor === 'rimelabs') {
|
||||
credentials = credentials || {};
|
||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||
} else if (vendor === 'inworld') {
|
||||
credentials = credentials || {};
|
||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||
} else if (vendor === 'whisper') {
|
||||
credentials = credentials || {};
|
||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||
@@ -224,12 +193,8 @@ class TtsTask extends Task {
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (vendor === 'cartesia') {
|
||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||
}
|
||||
|
||||
this.model_id = credentials.model_id;
|
||||
|
||||
/**
|
||||
* note on cache_speech_handles. This was found to be risky.
|
||||
* It can cause a crash in the following sequence on a single call:
|
||||
@@ -250,8 +215,7 @@ class TtsTask extends Task {
|
||||
// If vendor is changed from the previous one, then reset the cache_speech_handles flag
|
||||
//cs.currentTtsVendor = vendor;
|
||||
|
||||
if (!preCache && !this._disableTracing)
|
||||
this.logger.debug({vendor, language, voice, model: this.model}, 'TaskSay:exec');
|
||||
if (!preCache && !this._disableTracing) this.logger.info({vendor, language, voice, model}, 'TaskSay:exec');
|
||||
try {
|
||||
if (!credentials) {
|
||||
writeAlerts({
|
||||
@@ -282,12 +246,11 @@ class TtsTask extends Task {
|
||||
const {filePath, servedFromCache, rtt} = await synthAudio(stats, {
|
||||
account_sid,
|
||||
text,
|
||||
instructions: this.instructions,
|
||||
vendor,
|
||||
language,
|
||||
voice,
|
||||
engine,
|
||||
model: this.model,
|
||||
model,
|
||||
salt,
|
||||
credentials,
|
||||
options: this.options,
|
||||
@@ -295,7 +258,6 @@ class TtsTask extends Task {
|
||||
renderForCaching: preCache
|
||||
});
|
||||
if (!filePath.startsWith('say:')) {
|
||||
this.playbackIds.push(null);
|
||||
this.logger.debug(`Say: file ${filePath}, served from cache ${servedFromCache}`);
|
||||
if (filePath) cs.trackTmpFile(filePath);
|
||||
if (this.otelSpan) {
|
||||
@@ -309,24 +271,12 @@ class TtsTask extends Task {
|
||||
vendor,
|
||||
language,
|
||||
characters: text.length,
|
||||
elapsedTime: rtt,
|
||||
servedFromCache,
|
||||
'id': this.id
|
||||
});
|
||||
}
|
||||
if (servedFromCache) {
|
||||
this.notifyStatus({
|
||||
event: 'synthesized-audio',
|
||||
vendor,
|
||||
language,
|
||||
servedFromCache,
|
||||
'id': this.id
|
||||
elapsedTime: rtt
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.playbackIds.push(extractPlaybackId(filePath));
|
||||
this.logger.debug({playbackIds: this.playbackIds}, 'Say: a streaming tts api will be used');
|
||||
this.logger.debug('Say: a streaming tts api will be used');
|
||||
const modifiedPath = filePath.replace('say:{', `say:{session-uuid=${ep.uuid},`);
|
||||
return modifiedPath;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ if (VMD_HINTS_FILE) {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
class Amd extends Emitter {
|
||||
constructor(logger, cs, opts) {
|
||||
super();
|
||||
@@ -69,8 +68,6 @@ class Amd extends Emitter {
|
||||
this.getIbmAccessToken = getIbmAccessToken;
|
||||
const {setChannelVarsForStt} = require('./transcription-utils')(logger);
|
||||
this.setChannelVarsForStt = setChannelVarsForStt;
|
||||
this.digitCount = opts.digitCount || 0;
|
||||
this.numberRegEx = RegExp(`[0-9]{${this.digitCount}}`);
|
||||
|
||||
const {
|
||||
noSpeechTimeoutMs = 5000,
|
||||
@@ -166,14 +163,6 @@ class Amd extends Emitter {
|
||||
language: t.language_code
|
||||
});
|
||||
}
|
||||
else if (this.digitCount != 0 && this.numberRegEx.test(t.alternatives[0].transcript)) {
|
||||
/* a string of numbers is typically a machine */
|
||||
this.emit(this.decision = AmdEvents.MachineDetected, {
|
||||
reason: 'digit count',
|
||||
greeting: t.alternatives[0].transcript,
|
||||
language: t.language_code
|
||||
});
|
||||
}
|
||||
else if (final && wordCount < this.thresholdWordCount) {
|
||||
/* a short greeting is typically a human */
|
||||
this.emit(this.decision = AmdEvents.HumanDetected, {
|
||||
@@ -281,17 +270,13 @@ module.exports = (logger) => {
|
||||
|
||||
/* set stt options */
|
||||
logger.info(`starting amd for vendor ${vendor} and language ${language}`);
|
||||
/* if opts contains recognizer object use that config for stt, otherwise use defaults */
|
||||
const rOpts = opts.recognizer ?
|
||||
opts.recognizer :
|
||||
{
|
||||
vendor,
|
||||
hints,
|
||||
enhancedModel: true,
|
||||
altLanguages: opts.recognizer?.altLanguages || [],
|
||||
initialSpeechTimeoutMs: opts.resolveTimeoutMs,
|
||||
};
|
||||
const sttOpts = amd.setChannelVarsForStt({name: TaskName.Gather}, sttCredentials, language, rOpts);
|
||||
const sttOpts = amd.setChannelVarsForStt({name: TaskName.Gather}, sttCredentials, language, {
|
||||
vendor,
|
||||
hints,
|
||||
enhancedModel: true,
|
||||
altLanguages: opts.recognizer?.altLanguages || [],
|
||||
initialSpeechTimeoutMs: opts.resolveTimeoutMs,
|
||||
});
|
||||
|
||||
await ep.set(sttOpts).catch((err) => logger.info(err, 'Error setting channel variables'));
|
||||
|
||||
@@ -422,11 +407,7 @@ module.exports = (logger) => {
|
||||
}
|
||||
|
||||
if (ep.connected) {
|
||||
ep.stopTranscription({
|
||||
vendor,
|
||||
bugname,
|
||||
gracefulShutdown: false
|
||||
})
|
||||
ep.stopTranscription({vendor, bugname})
|
||||
.catch((err) => logger.info(err, 'stopAmd: Error stopping transcription'));
|
||||
task.emit('amd', {type: AmdEvents.Stopped});
|
||||
ep.execute('avmd_stop').catch((err) => this.logger.info(err, 'Error stopping avmd'));
|
||||
|
||||
@@ -4,7 +4,7 @@ const assert = require('assert');
|
||||
const {
|
||||
AWS_REGION,
|
||||
AWS_SNS_PORT: PORT,
|
||||
AWS_SNS_TOPIC_ARN,
|
||||
AWS_SNS_TOPIC_ARM,
|
||||
AWS_SNS_PORT_MAX,
|
||||
} = require('../config');
|
||||
const {LifeCycleEvents} = require('./constants');
|
||||
@@ -55,12 +55,12 @@ class SnsNotifier extends Emitter {
|
||||
async _handlePost(req, res) {
|
||||
try {
|
||||
const parsedBody = JSON.parse(req.body);
|
||||
this.logger.info({headers: req.headers, body: parsedBody}, 'Received HTTP POST from AWS');
|
||||
this.logger.debug({headers: req.headers, body: parsedBody}, 'Received HTTP POST from AWS');
|
||||
if (!validatePayload(parsedBody)) {
|
||||
this.logger.info('incoming AWS SNS HTTP POST failed signature validation');
|
||||
return res.sendStatus(403);
|
||||
}
|
||||
this.logger.info('incoming HTTP POST passed validation');
|
||||
this.logger.debug('incoming HTTP POST passed validation');
|
||||
res.sendStatus(200);
|
||||
|
||||
switch (parsedBody.Type) {
|
||||
@@ -74,18 +74,7 @@ class SnsNotifier extends Emitter {
|
||||
subscriptionRequestId: this.subscriptionRequestId
|
||||
}, 'response from SNS SubscribeURL');
|
||||
const data = await this.describeInstance();
|
||||
|
||||
const group = data.AutoScalingGroups.find((group) =>
|
||||
group.Instances && group.Instances.some((instance) => instance.InstanceId === this.instanceId)
|
||||
);
|
||||
if (!group) {
|
||||
this.logger.error('Current instance not found in any Auto Scaling group', data);
|
||||
} else {
|
||||
const instance = group.Instances.find((instance) => instance.InstanceId === this.instanceId);
|
||||
this.lifecycleState = instance.LifecycleState;
|
||||
}
|
||||
|
||||
//this.lifecycleState = data.AutoScalingGroups[0].Instances[0].LifecycleState;
|
||||
this.lifecycleState = data.AutoScalingGroups[0].Instances[0].LifecycleState;
|
||||
this.emit('SubscriptionConfirmation', {publicIp: this.publicIp});
|
||||
break;
|
||||
|
||||
@@ -105,7 +94,7 @@ class SnsNotifier extends Emitter {
|
||||
this.unsubscribe();
|
||||
}
|
||||
else {
|
||||
this.logger.info(`SnsNotifier - instance ${msg.EC2InstanceId} is scaling in (not us)`);
|
||||
this.logger.debug(`SnsNotifier - instance ${msg.EC2InstanceId} is scaling in (not us)`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
@@ -122,7 +111,7 @@ class SnsNotifier extends Emitter {
|
||||
|
||||
async init() {
|
||||
try {
|
||||
this.logger.info('SnsNotifier: retrieving instance data');
|
||||
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.logger.info({
|
||||
@@ -153,13 +142,13 @@ class SnsNotifier extends Emitter {
|
||||
try {
|
||||
const params = {
|
||||
Protocol: 'http',
|
||||
TopicArn: AWS_SNS_TOPIC_ARN,
|
||||
TopicArn: AWS_SNS_TOPIC_ARM,
|
||||
Endpoint: this.snsEndpoint
|
||||
};
|
||||
const response = await snsClient.send(new SubscribeCommand(params));
|
||||
this.logger.info({response}, `response to SNS subscribe to ${AWS_SNS_TOPIC_ARN}`);
|
||||
this.logger.info({response}, `response to SNS subscribe to ${AWS_SNS_TOPIC_ARM}`);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Error subscribing to SNS topic arn ${AWS_SNS_TOPIC_ARN}`);
|
||||
this.logger.error({err}, `Error subscribing to SNS topic arn ${AWS_SNS_TOPIC_ARM}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -170,9 +159,9 @@ class SnsNotifier extends Emitter {
|
||||
SubscriptionArn: this.subscriptionArn
|
||||
};
|
||||
const response = await snsClient.send(new UnsubscribeCommand(params));
|
||||
this.logger.info({response}, `response to SNS unsubscribe to ${AWS_SNS_TOPIC_ARN}`);
|
||||
this.logger.info({response}, `response to SNS unsubscribe to ${AWS_SNS_TOPIC_ARM}`);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Error unsubscribing to SNS topic arn ${AWS_SNS_TOPIC_ARN}`);
|
||||
this.logger.error({err}, `Error unsubscribing to SNS topic arn ${AWS_SNS_TOPIC_ARM}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ class BackgroundTaskManager extends Emitter {
|
||||
this.logger.info(`stopping background task: ${type}`);
|
||||
task.removeAllListeners();
|
||||
task.span.end();
|
||||
task.kill(this.cs);
|
||||
task.kill();
|
||||
// Remove task from managed List
|
||||
this.tasks.delete(type);
|
||||
}
|
||||
@@ -79,7 +79,7 @@ class BackgroundTaskManager extends Emitter {
|
||||
}
|
||||
|
||||
// Initiate Listen
|
||||
async _initListen(opts, bugname = 'jambonz-background-listen', ignoreCustomerData = false, type = 'listen') {
|
||||
async _initListen(opts, bugname = 'jambonz-background-listen', ignoreCustomerData = true, type = 'listen') {
|
||||
let task;
|
||||
try {
|
||||
const t = normalizeJambones(this.logger, [opts]);
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const assert = require('assert');
|
||||
const Emitter = require('events');
|
||||
const crypto = require('crypto');
|
||||
const parseUrl = require('parse-url');
|
||||
const timeSeries = require('@jambonz/time-series');
|
||||
const {NODE_ENV, JAMBONES_TIME_SERIES_HOST} = require('../config');
|
||||
let alerter ;
|
||||
@@ -22,10 +21,6 @@ class BaseRequestor extends Emitter {
|
||||
const {stats} = require('../../').srf.locals;
|
||||
this.stats = stats;
|
||||
|
||||
const u = this._parsedUrl = parseUrl(this.url);
|
||||
if (u.port) this._baseUrl = `${u.protocol}://${u.resource}:${u.port}`;
|
||||
else this._baseUrl = `${u.protocol}://${u.resource}`;
|
||||
|
||||
if (!alerter) {
|
||||
alerter = timeSeries(logger, {
|
||||
host: JAMBONES_TIME_SERIES_HOST,
|
||||
@@ -35,10 +30,6 @@ class BaseRequestor extends Emitter {
|
||||
}
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return this._baseUrl;
|
||||
}
|
||||
|
||||
get Alerter() {
|
||||
return alerter;
|
||||
}
|
||||
@@ -79,44 +70,7 @@ class BaseRequestor extends Emitter {
|
||||
return time.toFixed(0);
|
||||
}
|
||||
|
||||
_parseHashParams(hash) {
|
||||
// Remove the leading # if present
|
||||
const hashString = hash.startsWith('#') ? hash.substring(1) : hash;
|
||||
// Use URLSearchParams for parsing
|
||||
const params = new URLSearchParams(hashString);
|
||||
// Convert to a regular object
|
||||
const result = {};
|
||||
for (const [key, value] of params.entries()) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the error should be retried based on retry policy
|
||||
* @param {Error} err - The error that occurred
|
||||
* @param {string[]} rpValues - Array of retry policy values
|
||||
* @returns {boolean} True if the error should be retried
|
||||
*/
|
||||
_shouldRetry(err, rpValues) {
|
||||
// ct = connection timeout (ECONNREFUSED, ETIMEDOUT, etc)
|
||||
const isCt = err.code === 'ECONNREFUSED' ||
|
||||
err.code === 'ETIMEDOUT' ||
|
||||
err.code === 'ECONNRESET' ||
|
||||
err.code === 'ECONNABORTED';
|
||||
// rt = request timeout
|
||||
const isRt = err.name === 'TimeoutError';
|
||||
// 4xx = client errors
|
||||
const is4xx = err.statusCode >= 400 && err.statusCode < 500;
|
||||
// 5xx = server errors
|
||||
const is5xx = err.statusCode >= 500 && err.statusCode < 600;
|
||||
// Check if error type is included in retry policy
|
||||
return rpValues.includes('all') ||
|
||||
(isCt && rpValues.includes('ct')) ||
|
||||
(isRt && rpValues.includes('rt')) ||
|
||||
(is4xx && rpValues.includes('4xx')) ||
|
||||
(is5xx && rpValues.includes('5xx'));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = BaseRequestor;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"TaskName": {
|
||||
"Alert": "alert",
|
||||
"Answer": "answer",
|
||||
"Conference": "conference",
|
||||
"Config": "config",
|
||||
@@ -33,7 +32,7 @@
|
||||
"Tag": "tag",
|
||||
"Transcribe": "transcribe"
|
||||
},
|
||||
"AllowedSipRecVerbs": ["answer", "config", "gather", "transcribe", "listen", "tag", "hangup", "sip:decline"],
|
||||
"AllowedSipRecVerbs": ["answer", "config", "gather", "transcribe", "listen", "tag"],
|
||||
"AllowedConfirmSessionVerbs": ["config", "gather", "plays", "say", "tag"],
|
||||
"CallStatus": {
|
||||
"Trying": "trying",
|
||||
@@ -96,11 +95,6 @@
|
||||
"ConnectFailure": "deepgram_transcribe::connect_failed",
|
||||
"Connect": "deepgram_transcribe::connect"
|
||||
},
|
||||
"DeepgramRiverTranscriptionEvents": {
|
||||
"Transcription": "deepgramriver_transcribe::transcription",
|
||||
"ConnectFailure": "deepgramriver_transcribe::connect_failed",
|
||||
"Connect": "deepgramriver_transcribe::connect"
|
||||
},
|
||||
"SonioxTranscriptionEvents": {
|
||||
"Transcription": "soniox_transcribe::transcription",
|
||||
"Error": "soniox_transcribe::error"
|
||||
@@ -143,18 +137,6 @@
|
||||
"Connect": "speechmatics_transcribe::connect",
|
||||
"Error": "speechmatics_transcribe::error"
|
||||
},
|
||||
"OpenAITranscriptionEvents": {
|
||||
"Transcription": "openai_transcribe::transcription",
|
||||
"Translation": "openai_transcribe::translation",
|
||||
"SpeechStarted": "openai_transcribe::speech_started",
|
||||
"SpeechStopped": "openai_transcribe::speech_stopped",
|
||||
"PartialTranscript": "openai_transcribe::partial_transcript",
|
||||
"Info": "openai_transcribe::info",
|
||||
"RecognitionStarted": "openai_transcribe::recognition_started",
|
||||
"ConnectFailure": "openai_transcribe::connect_failed",
|
||||
"Connect": "openai_transcribe::connect",
|
||||
"Error": "openai_transcribe::error"
|
||||
},
|
||||
"JambonzTranscriptionEvents": {
|
||||
"Transcription": "jambonz_transcribe::transcription",
|
||||
"ConnectFailure": "jambonz_transcribe::connect_failed",
|
||||
@@ -167,24 +149,9 @@
|
||||
"ConnectFailure": "assemblyai_transcribe::connect_failed",
|
||||
"Connect": "assemblyai_transcribe::connect"
|
||||
},
|
||||
"VoxistTranscriptionEvents": {
|
||||
"Transcription": "voxist_transcribe::transcription",
|
||||
"Error": "voxist_transcribe::error",
|
||||
"ConnectFailure": "voxist_transcribe::connect_failed",
|
||||
"Connect": "voxist_transcribe::connect"
|
||||
},
|
||||
"CartesiaTranscriptionEvents": {
|
||||
"Transcription": "cartesia_transcribe::transcription",
|
||||
"Error": "cartesia_transcribe::error",
|
||||
"ConnectFailure": "cartesia_transcribe::connect_failed",
|
||||
"Connect": "cartesia_transcribe::connect"
|
||||
},
|
||||
"VadDetection": {
|
||||
"Detection": "vad_detect:detection"
|
||||
},
|
||||
"SileroVadDetection": {
|
||||
"Detection": "vad_silero:detect"
|
||||
},
|
||||
"ListenEvents": {
|
||||
"Connect": "mod_audio_fork::connect",
|
||||
"ConnectFailure": "mod_audio_fork::connect_failed",
|
||||
@@ -209,20 +176,6 @@
|
||||
"Disconnect": "openai_s2s::disconnect",
|
||||
"ServerEvent": "openai_s2s::server_event"
|
||||
},
|
||||
"LlmEvents_Google": {
|
||||
"Error": "error",
|
||||
"Connect": "google_s2s::connect",
|
||||
"ConnectFailure": "google_s2s::connect_failed",
|
||||
"Disconnect": "google_s2s::disconnect",
|
||||
"ServerEvent": "google_s2s::server_event"
|
||||
},
|
||||
"LlmEvents_Elevenlabs": {
|
||||
"Error": "error",
|
||||
"Connect": "elevenlabs_s2s::connect",
|
||||
"ConnectFailure": "elevenlabs_s2s::connect_failed",
|
||||
"Disconnect": "elevenlabs_s2s::disconnect",
|
||||
"ServerEvent": "elevenlabs_s2s::server_event"
|
||||
},
|
||||
"LlmEvents_VoiceAgent": {
|
||||
"Error": "error",
|
||||
"Connect": "voice_agent_s2s::connect",
|
||||
@@ -252,7 +205,6 @@
|
||||
"KillReason": {
|
||||
"Hangup": "hangup",
|
||||
"Replaced": "replaced",
|
||||
"ReferComplete": "refer-complete",
|
||||
"MediaTimeout": "media_timeout"
|
||||
},
|
||||
"HookMsgTypes": [
|
||||
|
||||
@@ -76,15 +76,10 @@ const speechMapper = (cred) => {
|
||||
else if ('deepgram' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.model_id = o.model_id;
|
||||
obj.deepgram_stt_uri = o.deepgram_stt_uri;
|
||||
obj.deepgram_tts_uri = o.deepgram_tts_uri;
|
||||
obj.deepgram_stt_use_tls = o.deepgram_stt_use_tls;
|
||||
}
|
||||
else if ('deepgramriver' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
}
|
||||
else if ('soniox' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
@@ -114,7 +109,6 @@ const speechMapper = (cred) => {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.model_id = o.model_id;
|
||||
obj.stt_model_id = o.stt_model_id;
|
||||
obj.embedding = o.embedding;
|
||||
obj.options = o.options;
|
||||
}
|
||||
@@ -124,26 +118,9 @@ const speechMapper = (cred) => {
|
||||
obj.model_id = o.model_id;
|
||||
obj.options = o.options;
|
||||
}
|
||||
else if ('resemble' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.resemble_tts_use_tls = o.resemble_tts_use_tls;
|
||||
obj.resemble_tts_uri = o.resemble_tts_uri;
|
||||
}
|
||||
else if ('inworld' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.model_id = o.model_id;
|
||||
obj.options = o.options;
|
||||
}
|
||||
else if ('assemblyai' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.service_version = o.service_version;
|
||||
}
|
||||
else if ('voxist' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
}
|
||||
else if ('whisper' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
@@ -161,11 +138,6 @@ const speechMapper = (cred) => {
|
||||
obj.api_key = o.api_key;
|
||||
obj.speechmatics_stt_uri = o.speechmatics_stt_uri;
|
||||
}
|
||||
else if ('openai' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.model_id = o.model_id;
|
||||
}
|
||||
else if (obj.vendor.startsWith('custom:')) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.auth_token = o.auth_token;
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
|
||||
const sleepFor = (ms) => new Promise((resolve) => setTimeout(() => resolve(), ms));
|
||||
module.exports = {
|
||||
sleepFor
|
||||
};
|
||||
@@ -41,14 +41,15 @@ class HttpRequestor extends BaseRequestor {
|
||||
constructor(logger, account_sid, hook, secret) {
|
||||
super(logger, account_sid, hook, secret);
|
||||
|
||||
this.method = hook.method?.toUpperCase() || 'POST';
|
||||
this.method = hook.method || 'POST';
|
||||
this.authHeader = basicAuth(hook.username, hook.password);
|
||||
this.backoffMs = 500;
|
||||
|
||||
assert(this._isAbsoluteUrl(this.url));
|
||||
assert(['GET', 'POST'].includes(this.method));
|
||||
|
||||
const u = this._parsedUrl = parseUrl(this.url);
|
||||
if (u.port) this._baseUrl = `${u.protocol}://${u.resource}:${u.port}`;
|
||||
else this._baseUrl = `${u.protocol}://${u.resource}`;
|
||||
this._protocol = u.protocol;
|
||||
this._resource = u.resource;
|
||||
this._port = u.port;
|
||||
@@ -56,18 +57,18 @@ class HttpRequestor extends BaseRequestor {
|
||||
this._usePools = HTTP_POOL && parseInt(HTTP_POOL);
|
||||
|
||||
if (this._usePools) {
|
||||
if (pools.has(this.baseUrl)) {
|
||||
this.client = pools.get(this.baseUrl);
|
||||
if (pools.has(this._baseUrl)) {
|
||||
this.client = pools.get(this._baseUrl);
|
||||
}
|
||||
else {
|
||||
const connections = HTTP_POOLSIZE ? parseInt(HTTP_POOLSIZE) : 10;
|
||||
const pipelining = HTTP_PIPELINING ? parseInt(HTTP_PIPELINING) : 1;
|
||||
const pool = this.client = new Pool(this.baseUrl, {
|
||||
const pool = this.client = new Pool(this._baseUrl, {
|
||||
connections,
|
||||
pipelining
|
||||
});
|
||||
pools.set(this.baseUrl, pool);
|
||||
this.logger.debug(`HttpRequestor:created pool for ${this.baseUrl}`);
|
||||
pools.set(this._baseUrl, pool);
|
||||
this.logger.debug(`HttpRequestor:created pool for ${this._baseUrl}`);
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -88,6 +89,10 @@ class HttpRequestor extends BaseRequestor {
|
||||
}
|
||||
}
|
||||
|
||||
get baseUrl() {
|
||||
return this._baseUrl;
|
||||
}
|
||||
|
||||
close() {
|
||||
if (!this._usePools && !this.client?.closed) this.client.close();
|
||||
}
|
||||
@@ -103,15 +108,15 @@ class HttpRequestor extends BaseRequestor {
|
||||
* @param {string} [hook.password] - if basic auth is protecting the endpoint
|
||||
* @param {object} [params] - request parameters
|
||||
*/
|
||||
async request(type, hook, params, httpHeaders = {}, span) {
|
||||
async request(type, hook, params, httpHeaders = {}) {
|
||||
/* jambonz:error only sent over ws */
|
||||
if (type === 'jambonz:error') return;
|
||||
|
||||
assert(HookMsgTypes.includes(type));
|
||||
|
||||
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip', 'env_vars', 'args']) : null;
|
||||
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
|
||||
const url = hook.url || hook;
|
||||
const method = hook.method?.toUpperCase() || 'POST';
|
||||
const method = hook.method || 'POST';
|
||||
let buf = '';
|
||||
httpHeaders = {
|
||||
...httpHeaders,
|
||||
@@ -119,7 +124,7 @@ class HttpRequestor extends BaseRequestor {
|
||||
};
|
||||
|
||||
assert.ok(url, 'HttpRequestor:request url was not provided');
|
||||
assert.ok(['GET', 'POST'].includes(method), `HttpRequestor:request method must be 'GET' or 'POST' not ${method}`);
|
||||
assert.ok, (['GET', 'POST'].includes(method), `HttpRequestor:request method must be 'GET' or 'POST' not ${method}`);
|
||||
const startAt = process.hrtime();
|
||||
|
||||
/* if we have an absolute url, and it is ws then do a websocket connection */
|
||||
@@ -132,51 +137,30 @@ class HttpRequestor extends BaseRequestor {
|
||||
this.close();
|
||||
this.emit('handover', requestor);
|
||||
}
|
||||
return requestor.request('session:new', hook, params, httpHeaders, span);
|
||||
return requestor.request('session:new', hook, params, httpHeaders);
|
||||
}
|
||||
|
||||
let newClient;
|
||||
try {
|
||||
this.backoffMs = 500;
|
||||
// Parse URL and extract hash parameters for retry configuration
|
||||
// Prepare request options - only do this once
|
||||
const absUrl = this._isRelativeUrl(url) ? `${this.baseUrl}${url}` : url;
|
||||
const parsedUrl = parseUrl(absUrl);
|
||||
const hash = parsedUrl.hash || '';
|
||||
const hashObj = hash ? this._parseHashParams(hash) : {};
|
||||
|
||||
// Retry policy: rp valid values: 4xx, 5xx, ct, rt, all, default is ct
|
||||
// Retry count: rc valid values: 1-5, default is 0
|
||||
// rc is the number of attempts we'll make AFTER the initial try
|
||||
const rc = hash ? Math.min(Math.abs(parseInt(hashObj.rc || '0')), 5) : 0;
|
||||
const rp = hashObj.rp || 'ct';
|
||||
const rpValues = rp.split(',').map((v) => v.trim());
|
||||
let retryCount = 0;
|
||||
|
||||
// Set up client, path and query parameters - only do this once
|
||||
let client, path, query;
|
||||
if (this._isRelativeUrl(url)) {
|
||||
client = this.client;
|
||||
path = url;
|
||||
}
|
||||
else {
|
||||
if (parsedUrl.resource === this._resource &&
|
||||
parsedUrl.port === this._port &&
|
||||
parsedUrl.protocol === this._protocol) {
|
||||
const u = parseUrl(url);
|
||||
if (u.resource === this._resource && u.port === this._port && u.protocol === this._protocol) {
|
||||
client = this.client;
|
||||
path = parsedUrl.pathname;
|
||||
query = parsedUrl.query;
|
||||
path = u.pathname;
|
||||
query = u.query;
|
||||
}
|
||||
else {
|
||||
if (parsedUrl.port) {
|
||||
client = newClient = new Client(`${parsedUrl.protocol}://${parsedUrl.resource}:${parsedUrl.port}`);
|
||||
}
|
||||
else client = newClient = new Client(`${parsedUrl.protocol}://${parsedUrl.resource}`);
|
||||
path = parsedUrl.pathname;
|
||||
query = parsedUrl.query;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const sigHeader = this._generateSigHeader(payload, this.secret);
|
||||
const hdrs = {
|
||||
...sigHeader,
|
||||
@@ -184,8 +168,20 @@ class HttpRequestor extends BaseRequestor {
|
||||
...httpHeaders,
|
||||
...('POST' === method && {'Content-Type': 'application/json'})
|
||||
};
|
||||
|
||||
const requestOptions = {
|
||||
const absUrl = this._isRelativeUrl(url) ? `${this.baseUrl}${url}` : url;
|
||||
this.logger.debug({url, absUrl, hdrs}, 'send webhook');
|
||||
const {statusCode, headers, body} = HTTP_PROXY_IP ? await request(
|
||||
this.baseUrl,
|
||||
{
|
||||
path,
|
||||
query,
|
||||
method,
|
||||
headers: hdrs,
|
||||
...('POST' === method && {body: JSON.stringify(payload)}),
|
||||
timeout: HTTP_TIMEOUT,
|
||||
followRedirects: false
|
||||
}
|
||||
) : await client.request({
|
||||
path,
|
||||
query,
|
||||
method,
|
||||
@@ -193,51 +189,14 @@ class HttpRequestor extends BaseRequestor {
|
||||
...('POST' === method && {body: JSON.stringify(payload)}),
|
||||
timeout: HTTP_TIMEOUT,
|
||||
followRedirects: false
|
||||
};
|
||||
|
||||
// Simplified makeRequest function that just executes the HTTP request
|
||||
const makeRequest = async() => {
|
||||
this.logger.debug({url, absUrl, hdrs, retryCount},
|
||||
`send webhook${retryCount > 0 ? ' (retry ' + retryCount + ')' : ''}`);
|
||||
|
||||
const {statusCode, headers, body} = HTTP_PROXY_IP ? await request(
|
||||
this.baseUrl,
|
||||
requestOptions
|
||||
) : await client.request(requestOptions);
|
||||
|
||||
if (![200, 202, 204].includes(statusCode)) {
|
||||
const err = new HTTPResponseError(statusCode);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (headers['content-type']?.includes('application/json')) {
|
||||
return await body.json();
|
||||
}
|
||||
return '';
|
||||
};
|
||||
|
||||
while (true) {
|
||||
try {
|
||||
buf = await makeRequest();
|
||||
break; // Success, exit the retry loop
|
||||
} catch (err) {
|
||||
retryCount++;
|
||||
|
||||
// Check if we should retry
|
||||
if (retryCount <= rc && this._shouldRetry(err, rpValues)) {
|
||||
this.logger.info(
|
||||
{err, baseUrl: this.baseUrl, url, retryCount, maxRetries: rc},
|
||||
`Retrying request (${retryCount}/${rc})`
|
||||
);
|
||||
const delay = this.backoffMs;
|
||||
this.backoffMs = this.backoffMs < 2000 ? this.backoffMs * 2 : (this.backoffMs + 2000);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
if (![200, 202, 204].includes(statusCode)) {
|
||||
const err = new HTTPResponseError(statusCode);
|
||||
throw err;
|
||||
}
|
||||
if (headers['content-type']?.includes('application/json')) {
|
||||
buf = await body.json();
|
||||
}
|
||||
|
||||
if (newClient) newClient.close();
|
||||
} catch (err) {
|
||||
if (err.statusCode) {
|
||||
@@ -266,10 +225,10 @@ class HttpRequestor extends BaseRequestor {
|
||||
const rtt = this._roundTrip(startAt);
|
||||
if (buf) this.stats.histogram('app.hook.response_time', rtt, ['hook_type:app']);
|
||||
|
||||
if (buf && (Array.isArray(buf) || type == 'llm:tool-call')) {
|
||||
if (buf && Array.isArray(buf)) {
|
||||
this.logger.info({response: buf}, `HttpRequestor:request ${method} ${url} succeeded in ${rtt}ms`);
|
||||
return buf;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,26 +31,18 @@ function getLocalIp() {
|
||||
return '127.0.0.1'; // Fallback to localhost if no suitable interface found
|
||||
}
|
||||
|
||||
function initMS(logger, wrapper, ms, {
|
||||
onFreeswitchConnect,
|
||||
onFreeswitchDisconnect
|
||||
}) {
|
||||
function initMS(logger, wrapper, ms) {
|
||||
Object.assign(wrapper, {ms, active: true, connects: 1});
|
||||
logger.info(`connected to freeswitch at ${ms.address}`);
|
||||
|
||||
onFreeswitchConnect(wrapper);
|
||||
|
||||
ms.conn
|
||||
.on('esl::end', () => {
|
||||
wrapper.active = false;
|
||||
wrapper.connects = 0;
|
||||
logger.info(`lost connection to freeswitch at ${ms.address}`);
|
||||
onFreeswitchDisconnect(wrapper);
|
||||
ms.removeAllListeners();
|
||||
})
|
||||
.on('esl::ready', () => {
|
||||
if (wrapper.connects > 0) {
|
||||
logger.info(`esl::ready connected to freeswitch at ${ms.address}`);
|
||||
logger.info(`connected to freeswitch at ${ms.address}`);
|
||||
}
|
||||
wrapper.connects = 1;
|
||||
wrapper.active = true;
|
||||
@@ -64,10 +56,7 @@ function initMS(logger, wrapper, ms, {
|
||||
});
|
||||
}
|
||||
|
||||
function installSrfLocals(srf, logger, {
|
||||
onFreeswitchConnect = () => {},
|
||||
onFreeswitchDisconnect = () => {}
|
||||
}) {
|
||||
function installSrfLocals(srf, logger) {
|
||||
logger.debug('installing srf locals');
|
||||
assert(!srf.locals.dbHelpers);
|
||||
const {tracer} = srf.locals.otel;
|
||||
@@ -102,10 +91,7 @@ function installSrfLocals(srf, logger, {
|
||||
mediaservers.push(val);
|
||||
try {
|
||||
const ms = await mrf.connect(fs);
|
||||
initMS(logger, val, ms, {
|
||||
onFreeswitchConnect,
|
||||
onFreeswitchDisconnect
|
||||
});
|
||||
initMS(logger, val, ms);
|
||||
}
|
||||
catch (err) {
|
||||
logger.info({err}, `failed connecting to freeswitch at ${fs.address}, will retry shortly: ${err.message}`);
|
||||
@@ -116,15 +102,9 @@ function installSrfLocals(srf, logger, {
|
||||
for (const val of mediaservers) {
|
||||
if (val.connects === 0) {
|
||||
try {
|
||||
// make sure all listeners are removed before reconnecting
|
||||
val.ms?.disconnect();
|
||||
val.ms = null;
|
||||
logger.info({mediaserver: val.opts}, 'Retrying initial connection to media server');
|
||||
const ms = await mrf.connect(val.opts);
|
||||
initMS(logger, val, ms, {
|
||||
onFreeswitchConnect,
|
||||
onFreeswitchDisconnect
|
||||
});
|
||||
initMS(logger, val, ms);
|
||||
} catch (err) {
|
||||
logger.info({err}, `failed connecting to freeswitch at ${val.opts.address}, will retry shortly`);
|
||||
}
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
|
||||
|
||||
class LlmMcpService {
|
||||
|
||||
constructor(logger, mcpServers) {
|
||||
this.logger = logger;
|
||||
this.mcpServers = mcpServers || [];
|
||||
this.mcpClients = [];
|
||||
}
|
||||
|
||||
// make sure we call init() before using any of the mcp clients
|
||||
// this is to ensure that we have a valid connection to the MCP server
|
||||
// and that we have collected the available tools.
|
||||
async init() {
|
||||
if (this.mcpClients.length > 0) {
|
||||
return;
|
||||
}
|
||||
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
|
||||
for (const server of this.mcpServers) {
|
||||
const { url } = server;
|
||||
if (url) {
|
||||
try {
|
||||
const transport = new SSEClientTransport(new URL(url), {});
|
||||
const client = new Client({ name: 'Jambonz MCP Client', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
// collect available tools
|
||||
const { tools } = await client.listTools();
|
||||
this.mcpClients.push({
|
||||
url,
|
||||
client,
|
||||
tools
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`LlmMcpService: Failed to connect to MCP server at ${url}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableMcpTools() {
|
||||
// returns a list of available tools from all MCP clients
|
||||
const tools = [];
|
||||
for (const mcpClient of this.mcpClients) {
|
||||
const {tools: availableTools} = mcpClient;
|
||||
if (availableTools) {
|
||||
tools.push(...availableTools);
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
async getMcpClientByToolName(name) {
|
||||
for (const mcpClient of this.mcpClients) {
|
||||
const { tools } = mcpClient;
|
||||
if (tools && tools.some((tool) => tool.name === name)) {
|
||||
return mcpClient.client;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getMcpClientByToolId(id) {
|
||||
for (const mcpClient of this.mcpClients) {
|
||||
const { tools } = mcpClient;
|
||||
if (tools && tools.some((tool) => tool.id === id)) {
|
||||
return mcpClient.client;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async callMcpTool(name, input) {
|
||||
const client = await this.getMcpClientByToolName(name);
|
||||
if (client) {
|
||||
try {
|
||||
const result = await client.callTool({
|
||||
name,
|
||||
arguments: input,
|
||||
});
|
||||
this.logger.debug({result}, 'LlmMcpService - result');
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'LlmMcpService - error calling tool');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
for (const mcpClient of this.mcpClients) {
|
||||
const { client } = mcpClient;
|
||||
if (client) {
|
||||
await client.close();
|
||||
this.logger.debug({url: mcpClient.url}, 'LlmMcpService - mcp client closed');
|
||||
}
|
||||
}
|
||||
this.mcpClients = [];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = LlmMcpService;
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
const {
|
||||
JAMBONES_USE_FREESWITCH_TIMER_FD,
|
||||
JAMBONES_MEDIA_TIMEOUT_MS,
|
||||
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
|
||||
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS,
|
||||
} = require('../config');
|
||||
const { sleepFor } = require('./helpers');
|
||||
|
||||
const createMediaEndpoint = async(srf, logger, {
|
||||
activeMs,
|
||||
drachtioFsmrfOptions = {},
|
||||
onHoldMusic,
|
||||
inbandDtmfEnabled,
|
||||
mediaTimeoutHandler,
|
||||
} = {}) => {
|
||||
const { getFreeswitch } = srf.locals;
|
||||
const ms = activeMs || getFreeswitch();
|
||||
if (!ms)
|
||||
throw new Error('no available Freeswitch for creating media endpoint');
|
||||
|
||||
const ep = await ms.createEndpoint(drachtioFsmrfOptions);
|
||||
|
||||
// Configure the endpoint
|
||||
const opts = {
|
||||
...(onHoldMusic && {holdMusic: `shout://${onHoldMusic.replace(/^https?:\/\//, '')}`}),
|
||||
...(JAMBONES_USE_FREESWITCH_TIMER_FD && {timer_name: 'timerfd'}),
|
||||
...(JAMBONES_MEDIA_TIMEOUT_MS && {media_timeout: JAMBONES_MEDIA_TIMEOUT_MS}),
|
||||
...(JAMBONES_MEDIA_HOLD_TIMEOUT_MS && {media_hold_timeout: JAMBONES_MEDIA_HOLD_TIMEOUT_MS})
|
||||
};
|
||||
if (Object.keys(opts).length > 0) {
|
||||
ep.set(opts);
|
||||
}
|
||||
// inbandDtmfEnabled
|
||||
if (inbandDtmfEnabled) {
|
||||
// https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod-dptools/6587132/#0-about
|
||||
ep.execute('start_dtmf').catch((err) => {
|
||||
logger.error('Error starting inband DTMF', { error: err });
|
||||
});
|
||||
ep.inbandDtmfEnabled = true;
|
||||
}
|
||||
// Handle Media Timeout
|
||||
if (mediaTimeoutHandler) {
|
||||
ep.once('destroy', (evt) => {
|
||||
mediaTimeoutHandler(evt, ep);
|
||||
});
|
||||
}
|
||||
// Handle graceful shutdown for endpoint if required
|
||||
if (JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS > 0) {
|
||||
const getEpGracefulShutdownPromise = () => {
|
||||
if (!ep.gracefulShutdownPromise) {
|
||||
ep.gracefulShutdownPromise = new Promise((resolve) => {
|
||||
// this resolver will be called when stt task received transcription.
|
||||
ep.gracefulShutdownResolver = () => {
|
||||
resolve();
|
||||
ep.gracefulShutdownPromise = null;
|
||||
};
|
||||
});
|
||||
}
|
||||
return ep.gracefulShutdownPromise;
|
||||
};
|
||||
|
||||
const gracefulShutdownHandler = async() => {
|
||||
// resolve when one of the following happens:
|
||||
// 1. stt task received transcription
|
||||
// 2. JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS passed
|
||||
await Promise.race([
|
||||
getEpGracefulShutdownPromise(),
|
||||
sleepFor(JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS)
|
||||
]);
|
||||
};
|
||||
|
||||
const origStartTranscription = ep.startTranscription.bind(ep);
|
||||
ep.startTranscription = async(...args) => {
|
||||
try {
|
||||
const result = await origStartTranscription(...args);
|
||||
ep.isTranscribeActive = true;
|
||||
return result;
|
||||
} catch (err) {
|
||||
ep.isTranscribeActive = false;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const origStopTranscription = ep.stopTranscription.bind(ep);
|
||||
ep.stopTranscription = async(opts = {}, ...args) => {
|
||||
const { gracefulShutdown = true, ...others } = opts;
|
||||
if (ep.isTranscribeActive && gracefulShutdown) {
|
||||
// only wait for graceful shutdown if transcription is active
|
||||
await gracefulShutdownHandler();
|
||||
}
|
||||
try {
|
||||
const result = await origStopTranscription({...others}, ...args);
|
||||
ep.isTranscribeActive = false;
|
||||
return result;
|
||||
} catch (err) {
|
||||
ep.isTranscribeActive = false;
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const origDestroy = ep.destroy.bind(ep);
|
||||
ep.destroy = async() => {
|
||||
if (ep.isTranscribeActive) {
|
||||
await gracefulShutdownHandler();
|
||||
}
|
||||
return await origDestroy();
|
||||
};
|
||||
}
|
||||
|
||||
return ep;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createMediaEndpoint,
|
||||
};
|
||||
@@ -12,11 +12,15 @@ const deepcopy = require('deepcopy');
|
||||
const moment = require('moment');
|
||||
const stripCodecs = require('./strip-ancillary-codecs');
|
||||
const RootSpan = require('./call-tracer');
|
||||
const crypto = require('crypto');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const HttpRequestor = require('./http-requestor');
|
||||
const WsRequestor = require('./ws-requestor');
|
||||
const {makeOpusFirst, removeVideoSdp} = require('./sdp-utils');
|
||||
const { createMediaEndpoint } = require('./media-endpoint');
|
||||
const {makeOpusFirst} = require('./sdp-utils');
|
||||
const {
|
||||
JAMBONES_USE_FREESWITCH_TIMER_FD,
|
||||
JAMBONES_MEDIA_TIMEOUT_MS,
|
||||
JAMBONES_MEDIA_HOLD_TIMEOUT_MS
|
||||
} = require('../config');
|
||||
|
||||
class SingleDialer extends Emitter {
|
||||
constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask,
|
||||
@@ -41,7 +45,7 @@ class SingleDialer extends Emitter {
|
||||
|
||||
this.callGone = false;
|
||||
|
||||
this.callSid = crypto.randomUUID();
|
||||
this.callSid = uuidv4();
|
||||
this.dialTask = dialTask;
|
||||
this.onHoldMusic = onHoldMusic;
|
||||
|
||||
@@ -92,7 +96,6 @@ class SingleDialer extends Emitter {
|
||||
};
|
||||
}
|
||||
this.ms = ms;
|
||||
this.srf = srf;
|
||||
let uri, to, inviteSpan;
|
||||
try {
|
||||
switch (this.target.type) {
|
||||
@@ -134,7 +137,8 @@ class SingleDialer extends Emitter {
|
||||
this.updateCallStatus = srf.locals.dbHelpers.updateCallStatus;
|
||||
this.serviceUrl = srf.locals.serviceUrl;
|
||||
|
||||
this.ep = await this._createMediaEndpoint();
|
||||
this.ep = await ms.createEndpoint();
|
||||
this._configMsEndpoint();
|
||||
this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`);
|
||||
|
||||
/**
|
||||
@@ -148,21 +152,15 @@ class SingleDialer extends Emitter {
|
||||
return;
|
||||
}
|
||||
let lastSdp;
|
||||
const connectStream = async(remoteSdp, isVideoCall) => {
|
||||
const connectStream = async(remoteSdp) => {
|
||||
if (remoteSdp === lastSdp) return;
|
||||
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS && !isVideoCall) {
|
||||
remoteSdp = removeVideoSdp(remoteSdp);
|
||||
}
|
||||
lastSdp = remoteSdp;
|
||||
return this.ep.modify(remoteSdp);
|
||||
};
|
||||
let localSdp = this.ep.local.sdp;
|
||||
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS && !opts.isVideoCall) {
|
||||
localSdp = removeVideoSdp(localSdp);
|
||||
}
|
||||
|
||||
Object.assign(opts, {
|
||||
proxy: `sip:${this.sbcAddress}`,
|
||||
localSdp: opts.opusFirst ? makeOpusFirst(localSdp) : localSdp
|
||||
localSdp: opts.opusFirst ? makeOpusFirst(this.ep.local.sdp) : this.ep.local.sdp
|
||||
});
|
||||
if (this.target.auth) opts.auth = this.target.auth;
|
||||
inviteSpan = this.startSpan('invite', {
|
||||
@@ -224,13 +222,13 @@ class SingleDialer extends Emitter {
|
||||
status.callStatus = CallStatus.EarlyMedia;
|
||||
this.emit('earlyMedia');
|
||||
}
|
||||
connectStream(prov.body, opts.isVideoCall);
|
||||
connectStream(prov.body);
|
||||
}
|
||||
else status.callStatus = CallStatus.Ringing;
|
||||
this.emit('callStatusChange', status);
|
||||
}
|
||||
});
|
||||
await connectStream(this.dlg.remote.sdp, opts.isVideoCall);
|
||||
await connectStream(this.dlg.remote.sdp);
|
||||
this.dlg.callSid = this.callSid;
|
||||
this.inviteInProgress = null;
|
||||
this.emit('callStatusChange', {
|
||||
@@ -273,12 +271,7 @@ class SingleDialer extends Emitter {
|
||||
this.logger.info('dial is onhold, emit event');
|
||||
this.emit('reinvite', req, res);
|
||||
} else {
|
||||
let newSdp = await this.ep.modify(req.body);
|
||||
// in case of reINVITE if video call is enabled in FS and the call is not a video call,
|
||||
// remove video media from the SDP
|
||||
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS && !this.opts?.isVideoCall) {
|
||||
newSdp = removeVideoSdp(newSdp);
|
||||
}
|
||||
const newSdp = await this.ep.modify(req.body);
|
||||
res.send(200, {body: newSdp});
|
||||
this.logger.info({offer: req.body, answer: newSdp}, 'SingleDialer:exec: handling reINVITE');
|
||||
}
|
||||
@@ -346,19 +339,25 @@ class SingleDialer extends Emitter {
|
||||
}
|
||||
}
|
||||
|
||||
async _handleMediaTimeout(evt, ep) {
|
||||
this.logger.info({evt}, 'SingleDialer:_handleMediaTimeout - media timeout event received');
|
||||
this.dialTask.kill(this.dialTask.cs, 'media-timeout');
|
||||
}
|
||||
|
||||
async _createMediaEndpoint(drachtioFsmrfOptions = {}) {
|
||||
return await createMediaEndpoint(this.srf, this.logger, {
|
||||
acactiveMs: this.ms,
|
||||
drachtioFsmrfOptions,
|
||||
onHoldMusic: this.onHoldMusic,
|
||||
inbandDtmfEnabled: this.dialTask?.inbandDtmfEnabled,
|
||||
mediaTimeoutHandler: this._handleMediaTimeout.bind(this),
|
||||
});
|
||||
_configMsEndpoint() {
|
||||
const opts = {
|
||||
...(this.onHoldMusic && {holdMusic: `shout://${this.onHoldMusic.replace(/^https?:\/\//, '')}`}),
|
||||
...(JAMBONES_USE_FREESWITCH_TIMER_FD && {timer_name: 'timerfd'}),
|
||||
...(JAMBONES_MEDIA_TIMEOUT_MS && {media_timeout: JAMBONES_MEDIA_TIMEOUT_MS}),
|
||||
...(JAMBONES_MEDIA_HOLD_TIMEOUT_MS && {media_hold_timeout: JAMBONES_MEDIA_HOLD_TIMEOUT_MS})
|
||||
};
|
||||
if (Object.keys(opts).length > 0) {
|
||||
this.ep.set(opts);
|
||||
}
|
||||
if (this.dialTask?.inbandDtmfEnabled && !this.ep.inbandDtmfEnabled) {
|
||||
// https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod-dptools/6587132/#0-about
|
||||
try {
|
||||
this.ep.execute('start_dtmf');
|
||||
this.ep.inbandDtmfEnabled = true;
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'place-outdial:_configMsEndpoint - error enable inband DTMF');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -401,8 +400,7 @@ class SingleDialer extends Emitter {
|
||||
accountInfo: this.accountInfo,
|
||||
tasks,
|
||||
rootSpan: this.rootSpan,
|
||||
req: this.req,
|
||||
tmpFiles: cs.tmpFiles,
|
||||
req: this.req
|
||||
});
|
||||
await cs.exec();
|
||||
|
||||
@@ -411,10 +409,7 @@ class SingleDialer extends Emitter {
|
||||
} catch (err) {
|
||||
this.logger.debug(err, 'SingleDialer:_executeApp: error');
|
||||
this.emit('decline');
|
||||
if (this.dlg.connected) {
|
||||
this.dlg.destroy();
|
||||
this.ep.destroy();
|
||||
}
|
||||
if (this.dlg.connected) this.dlg.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,7 +494,8 @@ class SingleDialer extends Emitter {
|
||||
assert(this.dlg && this.dlg.connected && !this.ep);
|
||||
|
||||
this.logger.debug('SingleDialer:reAnchorMedia: re-anchoring media after partial media');
|
||||
this.ep = await this._createMediaEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||
this._configMsEndpoint();
|
||||
await this.dlg.modify(this.ep.local.sdp, {
|
||||
headers: {
|
||||
'X-Reason': 'anchor-media'
|
||||
@@ -540,8 +536,7 @@ function placeOutdial({
|
||||
}) {
|
||||
const myOpts = deepcopy(opts);
|
||||
const sd = new SingleDialer({
|
||||
logger, sbcAddress, target, opts: myOpts, application, callInfo,
|
||||
accountInfo, rootSpan, startSpan, dialTask, onHoldMusic
|
||||
logger, sbcAddress, target, myOpts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask, onHoldMusic
|
||||
});
|
||||
sd.exec(srf, ms, myOpts);
|
||||
return sd;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const {LifeCycleEvents, FS_UUID_SET_NAME} = require('./constants');
|
||||
const Emitter = require('events');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
@@ -8,7 +8,7 @@ const {
|
||||
JAMBONES_SBCS,
|
||||
K8S,
|
||||
K8S_SBC_SIP_SERVICE_NAME,
|
||||
AWS_SNS_TOPIC_ARN,
|
||||
AWS_SNS_TOPIC_ARM,
|
||||
OPTIONS_PING_INTERVAL,
|
||||
AWS_REGION,
|
||||
NODE_ENV,
|
||||
@@ -35,7 +35,7 @@ module.exports = (logger) => {
|
||||
// listen for SNS lifecycle changes
|
||||
let lifecycleEmitter = new Emitter();
|
||||
let dryUpCalls = false;
|
||||
if (AWS_SNS_TOPIC_ARN && AWS_REGION) {
|
||||
if (AWS_SNS_TOPIC_ARM && AWS_REGION) {
|
||||
|
||||
(async function() {
|
||||
try {
|
||||
@@ -130,7 +130,7 @@ module.exports = (logger) => {
|
||||
logger.info('disabling OPTIONS pings since we are running as a kubernetes service');
|
||||
const {srf} = require('../..');
|
||||
const {addToSet} = srf.locals.dbHelpers;
|
||||
const uuid = srf.locals.fsUUID = crypto.randomUUID();
|
||||
const uuid = srf.locals.fsUUID = uuidv4();
|
||||
|
||||
/* in case redis is restarted, re-insert our key every so often */
|
||||
setInterval(() => {
|
||||
|
||||
@@ -35,12 +35,6 @@ const makeOpusFirst = (sdp) => {
|
||||
}
|
||||
return sdpTransform.write(parsedSdp);
|
||||
};
|
||||
const removeVideoSdp = (sdp) => {
|
||||
const parsedSdp = sdpTransform.parse(sdp);
|
||||
// Filter out video media sections, keeping only non-video media
|
||||
parsedSdp.media = parsedSdp.media.filter((media) => media.type !== 'video');
|
||||
return sdpTransform.write(parsedSdp);
|
||||
};
|
||||
|
||||
const extractSdpMedia = (sdp) => {
|
||||
const parsedSdp1 = sdpTransform.parse(sdp);
|
||||
@@ -60,6 +54,5 @@ module.exports = {
|
||||
mergeSdpMedia,
|
||||
extractSdpMedia,
|
||||
isOpusFirst,
|
||||
makeOpusFirst,
|
||||
removeVideoSdp
|
||||
makeOpusFirst
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const xmlParser = require('xml2js').parseString;
|
||||
const crypto = require('crypto');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const parseUri = require('drachtio-srf').parseUri;
|
||||
const transform = require('sdp-transform');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
@@ -52,7 +52,7 @@ const parseSiprecPayload = (req, logger) => {
|
||||
const arr = /^([^]+)(m=[^]+?)(m=[^]+?)$/.exec(sdp);
|
||||
opts.sdp1 = `${arr[1]}${arr[2]}`;
|
||||
opts.sdp2 = `${arr[1]}${arr[3]}\r\n`;
|
||||
opts.sessionId = crypto.randomUUID();
|
||||
opts.sessionId = uuidv4();
|
||||
logger.info({ payload: req.payload }, 'SIPREC payload with no metadata (e.g. Cisco NBR)');
|
||||
resolve(opts);
|
||||
} else if (!sdp || !meta) {
|
||||
@@ -64,7 +64,7 @@ const parseSiprecPayload = (req, logger) => {
|
||||
if (err) { throw err; }
|
||||
|
||||
opts.recordingData = result ;
|
||||
opts.sessionId = crypto.randomUUID();
|
||||
opts.sessionId = uuidv4() ;
|
||||
|
||||
const arr = /^([^]+)(m=[^]+?)(m=[^]+?)$/.exec(sdp) ;
|
||||
opts.sdp1 = `${arr[1]}${arr[2]}` ;
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
const EventEmitter = require('events');
|
||||
|
||||
/**
|
||||
* A specialized EventEmitter that caches the most recent event emissions.
|
||||
* When new listeners are added, they immediately receive the most recent
|
||||
* event if it was previously emitted. This is useful for handling state
|
||||
* changes where late subscribers need to know the current state.
|
||||
*
|
||||
* Features:
|
||||
* - Caches the most recent emission for each event type
|
||||
* - New listeners immediately receive the cached event if available
|
||||
* - Supports both regular (on) and one-time (once) listeners
|
||||
* - Maintains compatibility with Node's EventEmitter interface
|
||||
*/
|
||||
class StickyEventEmitter extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this._eventCache = new Map();
|
||||
this._onceListeners = new Map(); // For storing once listeners if needed
|
||||
}
|
||||
destroy() {
|
||||
this._eventCache.clear();
|
||||
this._onceListeners.clear();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
emit(event, ...args) {
|
||||
// Store the event and its args
|
||||
this._eventCache.set(event, args);
|
||||
|
||||
// If there are any 'once' listeners waiting, call them
|
||||
if (this._onceListeners.has(event)) {
|
||||
const listeners = this._onceListeners.get(event);
|
||||
for (const listener of listeners) {
|
||||
listener(...args);
|
||||
}
|
||||
if (this.onSuccess) {
|
||||
this.onSuccess();
|
||||
}
|
||||
this._onceListeners.delete(event);
|
||||
// return from here as the event listener is already called
|
||||
// this is to avoid calling the native emit method which
|
||||
// will call the event listener again
|
||||
return true;
|
||||
}
|
||||
|
||||
return super.emit(event, ...args);
|
||||
}
|
||||
|
||||
on(event, listener) {
|
||||
if (this._eventCache.has(event)) {
|
||||
listener(...this._eventCache.get(event));
|
||||
}
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
once(event, listener) {
|
||||
if (this._eventCache.has(event)) {
|
||||
listener(...this._eventCache.get(event));
|
||||
if (this.onSuccess) {
|
||||
this.onSuccess();
|
||||
}
|
||||
} else {
|
||||
// Store listener in case emit comes before
|
||||
if (!this._onceListeners.has(event)) {
|
||||
this._onceListeners.set(event, []);
|
||||
}
|
||||
this._onceListeners.get(event).push(listener);
|
||||
super.once(event, listener); // Also attach to native once
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StickyEventEmitter;
|
||||
@@ -1,197 +0,0 @@
|
||||
const { assert } = require('console');
|
||||
const Emitter = require('events');
|
||||
const {
|
||||
VadDetection,
|
||||
SileroVadDetection
|
||||
} = require('../utils/constants.json');
|
||||
|
||||
class SttLatencyCalculator extends Emitter {
|
||||
constructor({ logger, cs}) {
|
||||
super();
|
||||
this.logger = logger;
|
||||
this.cs = cs;
|
||||
this.isRunning = false;
|
||||
this.isInTalkSpurt = false;
|
||||
this.start_talking_time = 0;
|
||||
this.talkspurts = [];
|
||||
this.vendor = this.cs.vad?.vendor || 'silero';
|
||||
this.stt_start_time = 0;
|
||||
this.stt_stop_time = 0;
|
||||
this.stt_on_transcription_time = 0;
|
||||
}
|
||||
|
||||
set sttStartTime(time) {
|
||||
this.stt_start_time = time;
|
||||
}
|
||||
|
||||
get sttStartTime() {
|
||||
return this.stt_start_time || 0;
|
||||
}
|
||||
|
||||
set sttStopTime(time) {
|
||||
this.stt_stop_time = time;
|
||||
}
|
||||
|
||||
get sttStopTime() {
|
||||
return this.stt_stop_time || 0;
|
||||
}
|
||||
|
||||
set sttOnTranscriptionTime(time) {
|
||||
this.stt_on_transcription_time = time;
|
||||
}
|
||||
|
||||
get sttOnTranscriptionTime() {
|
||||
return this.stt_on_transcription_time || 0;
|
||||
}
|
||||
|
||||
_onVadDetected(_ep, _evt, fsEvent) {
|
||||
if (fsEvent.getHeader('detected-event') === 'stop_talking') {
|
||||
if (this.isInTalkSpurt) {
|
||||
this.talkspurts.push({
|
||||
start: this.start_talking_time,
|
||||
stop: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
this.start_talking_time = 0;
|
||||
this.isInTalkSpurt = false;
|
||||
} else if (fsEvent.getHeader('detected-event') === 'start_talking') {
|
||||
this.start_talking_time = Date.now();
|
||||
this.isInTalkSpurt = true;
|
||||
}
|
||||
}
|
||||
|
||||
_startVad() {
|
||||
assert(!this.isRunning, 'Latency calculator is already running');
|
||||
assert(this.cs.ep, 'Callsession has no endpoint to start the latency calculator');
|
||||
const ep = this.cs.ep;
|
||||
if (!ep.sttLatencyVadHandler) {
|
||||
ep.sttLatencyVadHandler = this._onVadDetected.bind(this, ep);
|
||||
if (this.vendor === 'silero') {
|
||||
ep.addCustomEventListener(SileroVadDetection.Detection, ep.sttLatencyVadHandler);
|
||||
} else {
|
||||
ep.addCustomEventListener(VadDetection.Detection, ep.sttLatencyVadHandler);
|
||||
}
|
||||
}
|
||||
this.stop_talking_time = 0;
|
||||
this.start_talking_time = 0;
|
||||
this.vad = {
|
||||
...(this.cs.vad || {}),
|
||||
strategy: 'continuous',
|
||||
bugname: 'stt-latency-calculator-vad',
|
||||
vendor: this.vendor
|
||||
};
|
||||
|
||||
ep.startVadDetection(this.vad);
|
||||
this.isRunning = true;
|
||||
}
|
||||
|
||||
_stopVad() {
|
||||
if (this.isRunning) {
|
||||
this.logger.warn('Latency calculator is still running, stopping VAD detection');
|
||||
const ep = this.cs.ep;
|
||||
ep.stopVadDetection(this.vad);
|
||||
if (ep.sttLatencyVadHandler) {
|
||||
if (this.vendor === 'silero') {
|
||||
this.ep?.removeCustomEventListener(SileroVadDetection.Detection, ep.sttLatencyVadHandler);
|
||||
} else {
|
||||
this.ep?.removeCustomEventListener(VadDetection.Detection, ep.sttLatencyVadHandler);
|
||||
}
|
||||
ep.sttLatencyVadHandler = null;
|
||||
}
|
||||
this.isRunning = false;
|
||||
this.logger.info('STT Latency Calculator stopped');
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.isRunning) {
|
||||
this.logger.warn('Latency calculator is already running');
|
||||
return;
|
||||
}
|
||||
if (!this.cs.ep) {
|
||||
this.logger.error('Callsession has no endpoint to start the latency calculator');
|
||||
return;
|
||||
}
|
||||
this._startVad();
|
||||
this.logger.debug('STT Latency Calculator started');
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._stopVad();
|
||||
}
|
||||
|
||||
toUnixTimestamp(date) {
|
||||
return Math.floor(date / 1000);
|
||||
}
|
||||
|
||||
calculateLatency() {
|
||||
if (!this.isRunning) {
|
||||
this.logger.debug('Latency calculator is not running, cannot calculate latency, returning default values');
|
||||
return null;
|
||||
}
|
||||
|
||||
const stt_stop_time = this.stt_stop_time || Date.now();
|
||||
if (this.isInTalkSpurt) {
|
||||
this.talkspurts.push({
|
||||
start: this.start_talking_time,
|
||||
stop: stt_stop_time
|
||||
});
|
||||
this.isInTalkSpurt = false;
|
||||
this.start_talking_time = 0;
|
||||
}
|
||||
const stt_on_transcription_time = this.stt_on_transcription_time || stt_stop_time;
|
||||
const start_talking_time = this.talkspurts[0]?.start;
|
||||
let lastIdx = this.talkspurts.length - 1;
|
||||
lastIdx = lastIdx < 0 ? 0 : lastIdx;
|
||||
const stop_talking_time = this.talkspurts[lastIdx]?.stop || stt_stop_time;
|
||||
|
||||
return {
|
||||
stt_start_time: this.toUnixTimestamp(this.stt_start_time),
|
||||
stt_stop_time: this.toUnixTimestamp(stt_stop_time),
|
||||
start_talking_time: this.toUnixTimestamp(start_talking_time),
|
||||
stop_talking_time: this.toUnixTimestamp(stop_talking_time),
|
||||
stt_latency: parseFloat((Math.abs(stt_on_transcription_time - stop_talking_time)) / 1000).toFixed(2),
|
||||
stt_latency_ms: Math.abs(stt_on_transcription_time - stop_talking_time),
|
||||
stt_usage: parseFloat((stt_stop_time - this.stt_start_time) / 1000).toFixed(2),
|
||||
talkspurts: this.talkspurts.map((ts) =>
|
||||
([this.toUnixTimestamp(ts.start || 0), this.toUnixTimestamp(ts.stop || 0)]))
|
||||
};
|
||||
}
|
||||
|
||||
resetTime() {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
this.stt_start_time = Date.now();
|
||||
this.stt_stop_time = 0;
|
||||
this.stt_on_transcription_time = 0;
|
||||
this.clearTalkspurts();
|
||||
this.logger.info('STT Latency Calculator reset');
|
||||
}
|
||||
|
||||
onTranscriptionReceived() {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
this.stt_on_transcription_time = Date.now();
|
||||
this.logger.debug(`CallSession:on-transcription set to ${this.stt_on_transcription_time}`);
|
||||
}
|
||||
|
||||
onTranscribeStop() {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
this.stt_stop_time = Date.now();
|
||||
this.logger.debug(`CallSession:transcribe-stop set to ${this.stt_stop_time}`);
|
||||
}
|
||||
|
||||
clearTalkspurts() {
|
||||
this.talkspurts = [];
|
||||
if (!this.isInTalkSpurt) {
|
||||
this.start_talking_time = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SttLatencyCalculator;
|
||||
@@ -30,7 +30,6 @@ const stickyVars = {
|
||||
'DEEPGRAM_SPEECH_TIER',
|
||||
'DEEPGRAM_SPEECH_MODEL',
|
||||
'DEEPGRAM_SPEECH_ENABLE_SMART_FORMAT',
|
||||
'DEEPGRAM_SPEECH_ENABLE_NO_DELAY',
|
||||
'DEEPGRAM_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION',
|
||||
'DEEPGRAM_SPEECH_PROFANITY_FILTER',
|
||||
'DEEPGRAM_SPEECH_REDACT',
|
||||
@@ -45,8 +44,7 @@ const stickyVars = {
|
||||
'DEEPGRAM_SPEECH_VAD_TURNOFF',
|
||||
'DEEPGRAM_SPEECH_TAG',
|
||||
'DEEPGRAM_SPEECH_MODEL_VERSION',
|
||||
'DEEPGRAM_SPEECH_FILLER_WORDS',
|
||||
'DEEPGRAM_SPEECH_KEYTERMS',
|
||||
'DEEPGRAM_SPEECH_FILLER_WORDS'
|
||||
],
|
||||
aws: [
|
||||
'AWS_VOCABULARY_NAME',
|
||||
@@ -107,13 +105,6 @@ const stickyVars = {
|
||||
'ASSEMBLYAI_API_KEY',
|
||||
'ASSEMBLYAI_WORD_BOOST'
|
||||
],
|
||||
voxist: [
|
||||
'VOXIST_API_KEY',
|
||||
],
|
||||
cartesia: [
|
||||
'CARTESIA_API_KEY',
|
||||
'CARTESIA_MODEL_ID'
|
||||
],
|
||||
speechmatics: [
|
||||
'SPEECHMATICS_API_KEY',
|
||||
'SPEECHMATICS_HOST',
|
||||
@@ -121,16 +112,7 @@ const stickyVars = {
|
||||
'SPEECHMATICS_SPEECH_HINTS',
|
||||
'SPEECHMATICS_TRANSLATION_LANGUAGES',
|
||||
'SPEECHMATICS_TRANSLATION_PARTIALS'
|
||||
],
|
||||
openai: [
|
||||
'OPENAI_API_KEY',
|
||||
'OPENAI_MODEL',
|
||||
'OPENAI_INPUT_AUDIO_NOISE_REDUCTION',
|
||||
'OPENAI_TURN_DETECTION_TYPE',
|
||||
'OPENAI_TURN_DETECTION_THRESHOLD',
|
||||
'OPENAI_TURN_DETECTION_PREFIX_PADDING_MS',
|
||||
'OPENAI_TURN_DETECTION_SILENCE_DURATION_MS',
|
||||
],
|
||||
]
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -317,18 +299,13 @@ const normalizeDeepgram = (evt, channel, language, shortUtterance) => {
|
||||
confidence: alt.confidence,
|
||||
transcript: alt.transcript,
|
||||
}));
|
||||
/**
|
||||
* Some models (nova-2-general) return the detected language in the
|
||||
* alternatives.languages array if the language is set as multi.
|
||||
* If the language is detected, we use it as the language_code.
|
||||
*/
|
||||
const detectedLanguage = evt.channel?.alternatives?.[0]?.languages?.[0];
|
||||
|
||||
/**
|
||||
* note difference between is_final and speech_final in Deepgram:
|
||||
* https://developers.deepgram.com/docs/understand-endpointing-interim-results
|
||||
*/
|
||||
return {
|
||||
language_code: detectedLanguage || language,
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: shortUtterance ? evt.is_final : evt.speech_final,
|
||||
alternatives: alternatives.length ? [alternatives[0]] : [],
|
||||
@@ -339,25 +316,6 @@ const normalizeDeepgram = (evt, channel, language, shortUtterance) => {
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeDeepgramRiver = (evt, channel, language, shortUtterance) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt.event === 'EndOfTurn',
|
||||
alternatives: [
|
||||
{
|
||||
confidence: evt.end_of_turn_confidence,
|
||||
transcript: evt.transcript,
|
||||
}
|
||||
],
|
||||
vendor: {
|
||||
name: 'deepgramriver',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeNvidia = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
const alternatives = (evt.alternatives || [])
|
||||
@@ -542,27 +500,16 @@ const normalizeAws = (evt, channel, language) => {
|
||||
|
||||
const normalizeAssemblyAi = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
const alternatives = [];
|
||||
let is_final = false;
|
||||
if (evt.type && evt.type === 'Turn') {
|
||||
// v3 is here
|
||||
alternatives.push({
|
||||
confidence: evt.end_of_turn_confidence,
|
||||
transcript: evt.transcript,
|
||||
});
|
||||
is_final = evt.end_of_turn;
|
||||
} else {
|
||||
alternatives.push({
|
||||
confidence: evt.confidence,
|
||||
transcript: evt.text,
|
||||
});
|
||||
is_final = evt.message_type === 'FinalTranscript';
|
||||
}
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final,
|
||||
alternatives,
|
||||
is_final: evt.message_type === 'FinalTranscript',
|
||||
alternatives: [
|
||||
{
|
||||
confidence: evt.confidence,
|
||||
transcript: evt.text,
|
||||
}
|
||||
],
|
||||
vendor: {
|
||||
name: 'assemblyai',
|
||||
evt: copy
|
||||
@@ -570,44 +517,6 @@ const normalizeAssemblyAi = (evt, channel, language) => {
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeVoxist = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt.type === 'final',
|
||||
alternatives: [
|
||||
{
|
||||
confidence: 1.00,
|
||||
transcript: evt.text,
|
||||
}
|
||||
],
|
||||
vendor: {
|
||||
name: 'voxist',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeCartesia = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt.is_final,
|
||||
alternatives: [
|
||||
{
|
||||
confidence: 1.00,
|
||||
transcript: evt.text,
|
||||
}
|
||||
],
|
||||
vendor: {
|
||||
name: 'cartesia',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeSpeechmatics = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
const is_final = evt.message === 'AddTranscript';
|
||||
@@ -633,35 +542,6 @@ const normalizeSpeechmatics = (evt, channel, language) => {
|
||||
return obj;
|
||||
};
|
||||
|
||||
const calculateConfidence = (logprobsArray) => {
|
||||
// Sum the individual log probabilities
|
||||
const totalLogProb = logprobsArray.reduce((sum, tokenInfo) => sum + tokenInfo.logprob, 0);
|
||||
|
||||
// Convert the total log probability back to a regular probability
|
||||
const confidence = Math.exp(totalLogProb);
|
||||
return confidence;
|
||||
};
|
||||
|
||||
const normalizeOpenAI = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
const obj = {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: true,
|
||||
alternatives: [
|
||||
{
|
||||
transcript: evt.transcript,
|
||||
confidence: evt.logprobs ? calculateConfidence(evt.logprobs) : 1.0,
|
||||
}
|
||||
],
|
||||
vendor: {
|
||||
name: 'openai',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
return obj;
|
||||
};
|
||||
|
||||
module.exports = (logger) => {
|
||||
const normalizeTranscription = (evt, vendor, channel, language, shortUtterance, punctuation) => {
|
||||
|
||||
@@ -669,8 +549,6 @@ module.exports = (logger) => {
|
||||
switch (vendor) {
|
||||
case 'deepgram':
|
||||
return normalizeDeepgram(evt, channel, language, shortUtterance);
|
||||
case 'deepgramriver':
|
||||
return normalizeDeepgramRiver(evt, channel, language, shortUtterance);
|
||||
case 'microsoft':
|
||||
return normalizeMicrosoft(evt, channel, language, punctuation);
|
||||
case 'google':
|
||||
@@ -689,16 +567,10 @@ module.exports = (logger) => {
|
||||
return normalizeCobalt(evt, channel, language);
|
||||
case 'assemblyai':
|
||||
return normalizeAssemblyAi(evt, channel, language, shortUtterance);
|
||||
case 'voxist':
|
||||
return normalizeVoxist(evt, channel, language);
|
||||
case 'cartesia':
|
||||
return normalizeCartesia(evt, channel, language);
|
||||
case 'verbio':
|
||||
return normalizeVerbio(evt, channel, language);
|
||||
case 'speechmatics':
|
||||
return normalizeSpeechmatics(evt, channel, language);
|
||||
case 'openai':
|
||||
return normalizeOpenAI(evt, channel, language);
|
||||
default:
|
||||
if (vendor.startsWith('custom:')) {
|
||||
return normalizeCustom(evt, channel, language, vendor);
|
||||
@@ -785,15 +657,12 @@ module.exports = (logger) => {
|
||||
AWS_ACCESS_KEY_ID: sttCredentials.accessKeyId,
|
||||
AWS_SECRET_ACCESS_KEY: sttCredentials.secretAccessKey,
|
||||
AWS_REGION: sttCredentials.region,
|
||||
AWS_SECURITY_TOKEN: sttCredentials.securityToken,
|
||||
AWS_SESSION_TOKEN: sttCredentials.sessionToken ? sttCredentials.sessionToken : sttCredentials.securityToken
|
||||
AWS_SECURITY_TOKEN: sttCredentials.securityToken
|
||||
}),
|
||||
...(awsOptions.accessKey && {AWS_ACCESS_KEY_ID: awsOptions.accessKey}),
|
||||
...(awsOptions.secretKey && {AWS_SECRET_ACCESS_KEY: awsOptions.secretKey}),
|
||||
...(awsOptions.region && {AWS_REGION: awsOptions.region}),
|
||||
...(awsOptions.securityToken && {AWS_SECURITY_TOKEN: awsOptions.securityToken}),
|
||||
...(awsOptions.sessionToken && {AWS_SESSION_TOKEN: awsOptions.sessionToken ?
|
||||
awsOptions.sessionToken : awsOptions.securityToken}),
|
||||
...(awsOptions.languageModelName && {AWS_LANGUAGE_MODEL_NAME: awsOptions.languageModelName}),
|
||||
...(awsOptions.piiEntityTypes?.length && {AWS_PII_ENTITY_TYPES: awsOptions.piiEntityTypes.join(',')}),
|
||||
...(awsOptions.piiIdentifyEntities && {AWS_PII_IDENTIFY_ENTITIES: true}),
|
||||
@@ -836,8 +705,6 @@ module.exports = (logger) => {
|
||||
//azureSttEndpointId overrides sttCredentials.custom_stt_endpoint
|
||||
...(rOpts.azureSttEndpointId &&
|
||||
{AZURE_SERVICE_ENDPOINT_ID: rOpts.azureSttEndpointId}),
|
||||
...(azureOptions.speechRecognitionMode &&
|
||||
{AZURE_RECOGNITION_MODE: azureOptions.speechRecognitionMode}),
|
||||
};
|
||||
}
|
||||
else if ('nuance' === vendor) {
|
||||
@@ -889,19 +756,11 @@ module.exports = (logger) => {
|
||||
};
|
||||
}
|
||||
else if ('deepgram' === vendor) {
|
||||
let model = rOpts.deepgramOptions?.model || rOpts.model || sttCredentials.model_id;
|
||||
let {model} = rOpts;
|
||||
const {deepgramOptions = {}} = rOpts;
|
||||
const deepgramUri = deepgramOptions.deepgramSttUri || sttCredentials.deepgram_stt_uri;
|
||||
const useTls = deepgramOptions.deepgramSttUseTls || sttCredentials.deepgram_stt_use_tls;
|
||||
|
||||
// DH (2025-08-11) entity_prompt is currently limited to 100 words
|
||||
const entityPrompt = deepgramOptions.entityPrompt ?
|
||||
deepgramOptions.entityPrompt
|
||||
.split(/\s+/)
|
||||
.slice(0, 100)
|
||||
.join(' ')
|
||||
: undefined;
|
||||
|
||||
/* default to a sensible model if not supplied */
|
||||
if (!model) {
|
||||
model = selectDefaultDeepgramModel(task, language);
|
||||
@@ -919,8 +778,6 @@ module.exports = (logger) => {
|
||||
{DEEPGRAM_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION: 1},
|
||||
...(deepgramOptions.smartFormatting) &&
|
||||
{DEEPGRAM_SPEECH_ENABLE_SMART_FORMAT: 1},
|
||||
...(deepgramOptions.noDelay) &&
|
||||
{DEEPGRAM_SPEECH_ENABLE_NO_DELAY: 1},
|
||||
...(deepgramOptions.profanityFilter) &&
|
||||
{DEEPGRAM_SPEECH_PROFANITY_FILTER: 1},
|
||||
...(deepgramOptions.redact) &&
|
||||
@@ -958,26 +815,7 @@ module.exports = (logger) => {
|
||||
...(deepgramOptions.version) &&
|
||||
{DEEPGRAM_SPEECH_MODEL_VERSION: deepgramOptions.version},
|
||||
...(deepgramOptions.fillerWords) &&
|
||||
{DEEPGRAM_SPEECH_FILLER_WORDS: deepgramOptions.fillerWords},
|
||||
...((Array.isArray(deepgramOptions.keyterms) && deepgramOptions.keyterms.length > 0) &&
|
||||
{DEEPGRAM_SPEECH_KEYTERMS: deepgramOptions.keyterms.join(',')}),
|
||||
...(deepgramOptions.mipOptOut && {DEEPGRAM_SPEECH_MIP_OPT_OUT: deepgramOptions.mipOptOut}),
|
||||
...(entityPrompt && {DEEPGRAM_SPEECH_ENTITY_PROMPT: entityPrompt}),
|
||||
};
|
||||
}
|
||||
else if ('deepgramriver' === vendor) {
|
||||
const {
|
||||
preflightThreshold,
|
||||
eotThreshold,
|
||||
eotTimeoutMs,
|
||||
mipOptOut
|
||||
} = rOpts.deepgramOptions || {};
|
||||
opts = {
|
||||
DEEPGRAM_API_KEY: sttCredentials.api_key,
|
||||
...(preflightThreshold && {DEEPGRAM_SPEECH_PRELIGHT_THRESHOLD: preflightThreshold}),
|
||||
...(eotThreshold && {DEEPGRAM_SPEECH_EOT_THRESHOLD: eotThreshold}),
|
||||
...(eotTimeoutMs && {DEEPGRAM_SPEECH_EOT_TIMEOUT_MS: eotTimeoutMs}),
|
||||
...(mipOptOut && {DEEPGRAM_SPEECH_MIP_OPT_OUT: mipOptOut}),
|
||||
{DEEPGRAM_SPEECH_FILLER_WORDS: deepgramOptions.fillerWords}
|
||||
};
|
||||
}
|
||||
else if ('soniox' === vendor) {
|
||||
@@ -1078,81 +916,14 @@ module.exports = (logger) => {
|
||||
};
|
||||
}
|
||||
else if ('assemblyai' === vendor) {
|
||||
const serviceVersion = rOpts.assemblyAiOptions?.serviceVersion || sttCredentials.service_version || 'v2';
|
||||
const {
|
||||
formatTurns,
|
||||
endOfTurnConfidenceThreshold,
|
||||
minEndOfTurnSilenceWhenConfident,
|
||||
maxTurnSilence
|
||||
} = rOpts.assemblyAiOptions || {};
|
||||
opts = {
|
||||
...opts,
|
||||
ASSEMBLYAI_API_VERSION: serviceVersion,
|
||||
...(serviceVersion === 'v3' && {
|
||||
...(formatTurns && {
|
||||
ASSEMBLYAI_FORMAT_TURNS: formatTurns
|
||||
}),
|
||||
...(endOfTurnConfidenceThreshold && {
|
||||
ASSEMBLYAI_END_OF_TURN_CONFIDENCE_THRESHOLD: endOfTurnConfidenceThreshold
|
||||
}),
|
||||
ASSEMBLYAI_MIN_END_OF_TURN_SILENCE_WHEN_CONFIDENT: minEndOfTurnSilenceWhenConfident || 500,
|
||||
...(maxTurnSilence && {
|
||||
ASSEMBLYAI_MAX_TURN_SILENCE: maxTurnSilence
|
||||
}),
|
||||
}),
|
||||
...(sttCredentials.api_key) &&
|
||||
{ASSEMBLYAI_API_KEY: sttCredentials.api_key},
|
||||
...(rOpts.hints?.length > 0 &&
|
||||
{ASSEMBLYAI_WORD_BOOST: JSON.stringify(rOpts.hints)})
|
||||
};
|
||||
}
|
||||
else if ('voxist' === vendor) {
|
||||
opts = {
|
||||
...opts,
|
||||
...(sttCredentials.api_key) &&
|
||||
{VOXIST_API_KEY: sttCredentials.api_key},
|
||||
};
|
||||
}
|
||||
else if ('cartesia' === vendor) {
|
||||
opts = {
|
||||
...opts,
|
||||
...(sttCredentials.api_key &&
|
||||
{CARTESIA_API_KEY: sttCredentials.api_key}),
|
||||
...(sttCredentials.stt_model_id && {
|
||||
CARTESIA_MODEL_ID: sttCredentials.stt_model_id
|
||||
})
|
||||
};
|
||||
}
|
||||
else if ('openai' === vendor) {
|
||||
const {openaiOptions = {}} = rOpts;
|
||||
const model = openaiOptions.model || rOpts.model || sttCredentials.model_id || 'whisper-1';
|
||||
const apiKey = openaiOptions.apiKey || sttCredentials.api_key;
|
||||
|
||||
opts = {
|
||||
OPENAI_MODEL: model,
|
||||
OPENAI_API_KEY: apiKey,
|
||||
...opts,
|
||||
...(openaiOptions.prompt && {OPENAI_PROMPT: openaiOptions.prompt}),
|
||||
...(openaiOptions.input_audio_noise_reduction &&
|
||||
{OPENAI_INPUT_AUDIO_NOISE_REDUCTION: openaiOptions.input_audio_noise_reduction}),
|
||||
};
|
||||
|
||||
if (openaiOptions.turn_detection) {
|
||||
opts = {
|
||||
...opts,
|
||||
OPENAI_TURN_DETECTION_TYPE: openaiOptions.turn_detection.type,
|
||||
...(openaiOptions.turn_detection.threshold && {
|
||||
OPENAI_TURN_DETECTION_THRESHOLD: openaiOptions.turn_detection.threshold
|
||||
}),
|
||||
...(openaiOptions.turn_detection.prefix_padding_ms && {
|
||||
OPENAI_TURN_DETECTION_PREFIX_PADDING_MS: openaiOptions.turn_detection.prefix_padding_ms
|
||||
}),
|
||||
...(openaiOptions.turn_detection.silence_duration_ms && {
|
||||
OPENAI_TURN_DETECTION_SILENCE_DURATION_MS: openaiOptions.turn_detection.silence_duration_ms
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
else if ('verbio' === vendor) {
|
||||
const {verbioOptions = {}} = rOpts;
|
||||
opts = {
|
||||
|
||||
@@ -4,50 +4,37 @@ const {
|
||||
TtsStreamingEvents,
|
||||
TtsStreamingConnectionStatus
|
||||
} = require('../utils/constants');
|
||||
|
||||
const MAX_CHUNK_SIZE = 1800;
|
||||
const HIGH_WATER_BUFFER_SIZE = 1000;
|
||||
const LOW_WATER_BUFFER_SIZE = 200;
|
||||
const TIMEOUT_RETRY_MSECS = 1000; // 1 second
|
||||
const TIMEOUT_RETRY_MSECS = 3000;
|
||||
|
||||
|
||||
const isWhitespace = (str) => /^\s*$/.test(str);
|
||||
|
||||
/**
|
||||
* Each queue item is an object:
|
||||
* - { type: 'text', value: '…' } for text tokens.
|
||||
* - { type: 'flush' } for a flush command.
|
||||
*/
|
||||
class TtsStreamingBuffer extends Emitter {
|
||||
constructor(cs) {
|
||||
super();
|
||||
this.cs = cs;
|
||||
this.logger = cs.logger;
|
||||
|
||||
// Use an array to hold our structured items.
|
||||
this.queue = [];
|
||||
// Track total number of characters in text items.
|
||||
this.bufferedLength = 0;
|
||||
this.tokens = '';
|
||||
this.eventHandlers = [];
|
||||
this._isFull = false;
|
||||
this._connectionStatus = TtsStreamingConnectionStatus.NotConnected;
|
||||
this._flushPending = false;
|
||||
this.timer = null;
|
||||
// Record the last time the text buffer was updated.
|
||||
this.lastUpdateTime = 0;
|
||||
}
|
||||
|
||||
get isEmpty() {
|
||||
return this.queue.length === 0;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.bufferedLength;
|
||||
return this.tokens.length === 0;
|
||||
}
|
||||
|
||||
get isFull() {
|
||||
return this._isFull;
|
||||
}
|
||||
|
||||
get size() {
|
||||
return this.tokens.length;
|
||||
}
|
||||
|
||||
get ep() {
|
||||
return this.cs?.ep;
|
||||
}
|
||||
@@ -55,8 +42,7 @@ class TtsStreamingBuffer extends Emitter {
|
||||
async start() {
|
||||
assert.ok(
|
||||
this._connectionStatus === TtsStreamingConnectionStatus.NotConnected,
|
||||
'TtsStreamingBuffer:start already started, or has failed'
|
||||
);
|
||||
'TtsStreamingBuffer:start already started, or has failed');
|
||||
|
||||
this.vendor = this.cs.getTsStreamingVendor();
|
||||
if (!this.vendor) {
|
||||
@@ -69,9 +55,9 @@ class TtsStreamingBuffer extends Emitter {
|
||||
this._connectionStatus = TtsStreamingConnectionStatus.Connecting;
|
||||
try {
|
||||
if (this.eventHandlers.length === 0) this._initHandlers(this.ep);
|
||||
await this._api(this.ep, [this.ep.uuid, 'connect']);
|
||||
await this._api(this.ep, [this.ep.uuid, 'connect']);
|
||||
} catch (err) {
|
||||
this.logger.info({ err }, 'TtsStreamingBuffer:start Error connecting to TTS streaming');
|
||||
this.logger.info({err}, 'TtsStreamingBuffer:start Error connecting to TTS streaming');
|
||||
this._connectionStatus = TtsStreamingConnectionStatus.Failed;
|
||||
}
|
||||
}
|
||||
@@ -81,319 +67,204 @@ class TtsStreamingBuffer extends Emitter {
|
||||
this.removeCustomEventListeners();
|
||||
if (this.ep) {
|
||||
this._api(this.ep, [this.ep.uuid, 'close'])
|
||||
.catch((err) =>
|
||||
this.logger.info({ err }, 'TtsStreamingBuffer:stop Error closing TTS streaming')
|
||||
);
|
||||
.catch((err) => this.logger.info({err}, 'TtsStreamingBuffer:kill Error closing TTS streaming'));
|
||||
}
|
||||
this.timer = null;
|
||||
this.queue = [];
|
||||
this.bufferedLength = 0;
|
||||
this.tokens = '';
|
||||
this._connectionStatus = TtsStreamingConnectionStatus.NotConnected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer new text tokens.
|
||||
* Add tokens to the buffer and start feeding them to the endpoint if necessary.
|
||||
*/
|
||||
async bufferTokens(tokens) {
|
||||
|
||||
if (this._connectionStatus === TtsStreamingConnectionStatus.Failed) {
|
||||
this.logger.info('TtsStreamingBuffer:bufferTokens TTS streaming connection failed, rejecting request');
|
||||
return { status: 'failed', reason: `connection to ${this.vendor} failed` };
|
||||
}
|
||||
|
||||
if (0 === this.bufferedLength && isWhitespace(tokens)) {
|
||||
this.logger.debug({tokens}, 'TtsStreamingBuffer:bufferTokens discarded whitespace tokens');
|
||||
return { status: 'ok' };
|
||||
return {status: 'failed', reason: `connection to ${this.vendor} failed`};
|
||||
}
|
||||
|
||||
const displayedTokens = tokens.length <= 40 ? tokens : tokens.substring(0, 40);
|
||||
const totalLength = tokens.length;
|
||||
|
||||
if (this.bufferedLength + totalLength > HIGH_WATER_BUFFER_SIZE) {
|
||||
/* if we crossed the high water mark, reject the request */
|
||||
if (this.tokens.length + totalLength > HIGH_WATER_BUFFER_SIZE) {
|
||||
this.logger.info(
|
||||
`TtsStreamingBuffer throttling: buffer is full, rejecting request to buffer ${totalLength} tokens`
|
||||
);
|
||||
`TtsStreamingBuffer throttling: buffer is full, rejecting request to buffer ${totalLength} tokens`);
|
||||
|
||||
if (!this._isFull) {
|
||||
this._isFull = true;
|
||||
this.emit(TtsStreamingEvents.Pause);
|
||||
}
|
||||
return { status: 'failed', reason: 'full' };
|
||||
return {status: 'failed', reason: 'full'};
|
||||
}
|
||||
|
||||
this.logger.debug(
|
||||
`TtsStreamingBuffer:bufferTokens "${displayedTokens}" (length: ${totalLength})`
|
||||
`TtsStreamingBuffer:bufferTokens "${displayedTokens}" (length: ${totalLength}), starting? ${this.isEmpty}`
|
||||
);
|
||||
this.queue.push({ type: 'text', value: tokens });
|
||||
this.bufferedLength += totalLength;
|
||||
// Update the last update time each time new text is buffered.
|
||||
this.lastUpdateTime = Date.now();
|
||||
this.tokens += (tokens || '');
|
||||
|
||||
await this._feedQueue();
|
||||
return { status: 'ok' };
|
||||
await this._feedTokens();
|
||||
|
||||
return {status: 'ok'};
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a flush command. If no text is queued, flush immediately.
|
||||
* Otherwise, append a flush marker so that all text preceding it will be sent
|
||||
* (regardless of sentence boundaries) before the flush is issued.
|
||||
*/
|
||||
flush() {
|
||||
this.logger.debug('TtsStreamingBuffer:flush');
|
||||
if (this._connectionStatus === TtsStreamingConnectionStatus.Connecting) {
|
||||
this.logger.debug('TtsStreamingBuffer:flush TTS stream is not quite ready - wait for connect');
|
||||
if (this.queue.length === 0 || this.queue[this.queue.length - 1].type !== 'flush') {
|
||||
this.queue.push({ type: 'flush' });
|
||||
}
|
||||
this._flushPending = true;
|
||||
return;
|
||||
}
|
||||
else if (this._connectionStatus === TtsStreamingConnectionStatus.Connected) {
|
||||
if (this.isEmpty) {
|
||||
|
||||
if (this.size === 0) {
|
||||
this._doFlush();
|
||||
}
|
||||
else {
|
||||
if (this.queue[this.queue.length - 1].type !== 'flush') {
|
||||
this.queue.push({ type: 'flush' });
|
||||
this.logger.debug('TtsStreamingBuffer:flush added flush marker to queue');
|
||||
}
|
||||
/* we have tokens queued, so flush after they have been sent */
|
||||
this._pendingFlush = true;
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.logger.debug(
|
||||
`TtsStreamingBuffer:flush TTS stream is not connected, status: ${this._connectionStatus}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.logger.debug('TtsStreamingBuffer:clear');
|
||||
|
||||
if (this._connectionStatus !== TtsStreamingConnectionStatus.Connected) return;
|
||||
clearTimeout(this.timer);
|
||||
this._api(this.ep, [this.ep.uuid, 'clear']).catch((err) =>
|
||||
this.logger.info({ err }, 'TtsStreamingBuffer:clear Error clearing TTS streaming')
|
||||
);
|
||||
this.queue = [];
|
||||
this.bufferedLength = 0;
|
||||
this._api(this.ep, [this.ep.uuid, 'clear'])
|
||||
.catch((err) => this.logger.info({err}, 'TtsStreamingBuffer:clear Error clearing TTS streaming'));
|
||||
this.tokens = '';
|
||||
this.timer = null;
|
||||
this._isFull = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the queue in two phases.
|
||||
*
|
||||
* Phase 1: Look for flush markers. When a flush marker is found (even if not at the very front),
|
||||
* send all text tokens that came before it immediately (ignoring sentence boundaries)
|
||||
* and then send the flush command. Repeat until there are no flush markers left.
|
||||
*
|
||||
* Phase 2: With the remaining queue (now containing only text items), accumulate text
|
||||
* up to MAX_CHUNK_SIZE and use sentence-boundary logic to determine a chunk.
|
||||
* Then, remove the exact tokens (or portions thereof) that were consumed.
|
||||
* Send tokens to the TTS engine in sentence chunks for best playout
|
||||
*/
|
||||
async _feedQueue(handlingTimeout = false) {
|
||||
this.logger.debug({ queue: this.queue }, 'TtsStreamingBuffer:_feedQueue');
|
||||
async _feedTokens(handlingTimeout = false) {
|
||||
this.logger.debug({tokens: this.tokens}, '_feedTokens');
|
||||
|
||||
try {
|
||||
if (!this.cs.isTtsStreamOpen || !this.ep) {
|
||||
this.logger.debug('TtsStreamingBuffer:_feedQueue TTS stream is not open or no endpoint available');
|
||||
return;
|
||||
|
||||
/* are we in a state where we can feed tokens to the TTS? */
|
||||
if (!this.cs.isTtsStreamOpen || !this.ep || !this.tokens) {
|
||||
this.logger.debug('TTS stream is not open or no tokens to send');
|
||||
return this.tokens?.length || 0;
|
||||
}
|
||||
if (
|
||||
this._connectionStatus === TtsStreamingConnectionStatus.NotConnected ||
|
||||
this._connectionStatus === TtsStreamingConnectionStatus.Failed
|
||||
) {
|
||||
this.logger.debug('TtsStreamingBuffer:_feedQueue TTS stream is not connected');
|
||||
|
||||
if (this._connectionStatus === TtsStreamingConnectionStatus.NotConnected ||
|
||||
this._connectionStatus === TtsStreamingConnectionStatus.Failed) {
|
||||
this.logger.debug('TtsStreamingBuffer:_feedTokens TTS stream is not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Phase 1: Process flush markers ---
|
||||
// Process any flush marker that isn’t in the very first position.
|
||||
let flushIndex = this.queue.findIndex((item, idx) => item.type === 'flush' && idx > 0);
|
||||
while (flushIndex !== -1) {
|
||||
let flushText = '';
|
||||
// Accumulate all text tokens preceding the flush marker.
|
||||
for (let i = 0; i < flushIndex; i++) {
|
||||
if (this.queue[i].type === 'text') {
|
||||
flushText += this.queue[i].value;
|
||||
}
|
||||
}
|
||||
// Remove those text items.
|
||||
for (let i = 0; i < flushIndex; i++) {
|
||||
const item = this.queue.shift();
|
||||
if (item.type === 'text') {
|
||||
this.bufferedLength -= item.value.length;
|
||||
}
|
||||
}
|
||||
// Remove the flush marker (now at the front).
|
||||
if (this.queue.length > 0 && this.queue[0].type === 'flush') {
|
||||
this.queue.shift();
|
||||
}
|
||||
// Immediately send all accumulated text (ignoring sentence boundaries).
|
||||
if (flushText.length > 0) {
|
||||
const modifiedFlushText = flushText.replace(/\n\n/g, '\n \n');
|
||||
try {
|
||||
await this._api(this.ep, [this.ep.uuid, 'send', modifiedFlushText]);
|
||||
} catch (err) {
|
||||
this.logger.info({ err, flushText }, 'TtsStreamingBuffer:_feedQueue Error sending TTS chunk');
|
||||
}
|
||||
}
|
||||
// Send the flush command.
|
||||
await this._doFlush();
|
||||
|
||||
flushIndex = this.queue.findIndex((item, idx) => item.type === 'flush' && idx > 0);
|
||||
}
|
||||
|
||||
// If a flush marker is at the very front, process it.
|
||||
while (this.queue.length > 0 && this.queue[0].type === 'flush') {
|
||||
this.queue.shift();
|
||||
await this._doFlush();
|
||||
}
|
||||
|
||||
// --- Phase 2: Process remaining text tokens ---
|
||||
if (this.queue.length === 0) {
|
||||
this._removeTimer();
|
||||
if (this._connectionStatus === TtsStreamingConnectionStatus.Connecting) {
|
||||
this.logger.debug('TtsStreamingBuffer:_feedTokens TTS stream is not ready, waiting for connect');
|
||||
return;
|
||||
}
|
||||
|
||||
// Accumulate contiguous text tokens (from the front) up to MAX_CHUNK_SIZE.
|
||||
let combinedText = '';
|
||||
for (const item of this.queue) {
|
||||
if (item.type !== 'text') break;
|
||||
combinedText += item.value;
|
||||
if (combinedText.length >= MAX_CHUNK_SIZE) break;
|
||||
}
|
||||
if (combinedText.length === 0) {
|
||||
this._removeTimer();
|
||||
return;
|
||||
}
|
||||
/* must send at least one sentence */
|
||||
const limit = Math.min(MAX_CHUNK_SIZE, this.tokens.length);
|
||||
let chunkEnd = findSentenceBoundary(this.tokens, limit);
|
||||
|
||||
const limit = Math.min(MAX_CHUNK_SIZE, combinedText.length);
|
||||
let chunkEnd = findSentenceBoundary(combinedText, limit);
|
||||
if (chunkEnd <= 0) {
|
||||
if (handlingTimeout) {
|
||||
chunkEnd = findWordBoundary(combinedText, limit);
|
||||
/* on a timeout we've left some tokens sitting around, so be more aggressive now in sending them */
|
||||
chunkEnd = findWordBoundary(this.tokens, limit);
|
||||
if (chunkEnd <= 0) {
|
||||
this.logger.debug('TtsStreamingBuffer:_feedTokens: no word boundary found');
|
||||
this._setTimerIfNeeded();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
}
|
||||
else {
|
||||
/* if we just received tokens, we wont send unless we have at least a full sentence */
|
||||
this.logger.debug('TtsStreamingBuffer:_feedTokens: no sentence boundary found');
|
||||
this._setTimerIfNeeded();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const chunk = combinedText.slice(0, chunkEnd);
|
||||
|
||||
// Now we iterate over the queue items
|
||||
// and deduct their lengths until we've accounted for chunkEnd characters.
|
||||
let remaining = chunkEnd;
|
||||
let tokensProcessed = 0;
|
||||
for (let i = 0; i < this.queue.length; i++) {
|
||||
const token = this.queue[i];
|
||||
if (token.type !== 'text') break;
|
||||
if (remaining >= token.value.length) {
|
||||
remaining -= token.value.length;
|
||||
tokensProcessed = i + 1;
|
||||
} else {
|
||||
// Partially consumed token: update its value to remove the consumed part.
|
||||
token.value = token.value.slice(remaining);
|
||||
tokensProcessed = i;
|
||||
remaining = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Remove the fully consumed tokens from the front of the queue.
|
||||
this.queue.splice(0, tokensProcessed);
|
||||
this.bufferedLength -= chunkEnd;
|
||||
const chunk = this.tokens.slice(0, chunkEnd);
|
||||
this.tokens = this.tokens.slice(chunkEnd);
|
||||
|
||||
/* freeswitch looks for sequence of 2 newlines to determine end of message, so insert a space */
|
||||
const modifiedChunk = chunk.replace(/\n\n/g, '\n \n');
|
||||
this.logger.debug(`TtsStreamingBuffer:_feedQueue sending chunk to tts: ${modifiedChunk}`);
|
||||
await this._api(this.ep, [this.ep.uuid, 'send', modifiedChunk]);
|
||||
this.logger.debug(`TtsStreamingBuffer:_feedTokens: sent ${chunk.length}, remaining: ${this.tokens.length}`);
|
||||
|
||||
try {
|
||||
await this._api(this.ep, [this.ep.uuid, 'send', modifiedChunk]);
|
||||
} catch (err) {
|
||||
this.logger.info({ err, chunk }, 'TtsStreamingBuffer:_feedQueue Error sending TTS chunk');
|
||||
if (this._pendingFlush) {
|
||||
this._doFlush();
|
||||
this._pendingFlush = false;
|
||||
}
|
||||
|
||||
if (this._isFull && this.bufferedLength <= LOW_WATER_BUFFER_SIZE) {
|
||||
this.logger.info('TtsStreamingBuffer throttling: buffer is no longer full - resuming');
|
||||
if (this.isFull && this.tokens.length <= LOW_WATER_BUFFER_SIZE) {
|
||||
this.logger.info('TtsStreamingBuffer throttling: TTS streaming buffer is no longer full - resuming');
|
||||
this._isFull = false;
|
||||
this.emit(TtsStreamingEvents.Resume);
|
||||
}
|
||||
|
||||
return this._feedQueue();
|
||||
} catch (err) {
|
||||
this.logger.info({ err }, 'TtsStreamingBuffer:_feedQueue Error sending TTS chunk');
|
||||
this.queue = [];
|
||||
this.bufferedLength = 0;
|
||||
this.logger.info({err}, 'TtsStreamingBuffer:_feedTokens Error sending TTS chunk');
|
||||
this.tokens = '';
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
async _api(ep, args) {
|
||||
const apiCmd = `uuid_${this.vendor.startsWith('custom:') ? 'custom' : this.vendor}_tts_streaming`;
|
||||
const res = await ep.api(apiCmd, `^^|${args.join('|')}`);
|
||||
if (!res.body?.startsWith('+OK')) {
|
||||
this.logger.info({ args }, `Error calling ${apiCmd}: ${res.body}`);
|
||||
this.logger.info({args}, `Error calling ${apiCmd}: ${res.body}`);
|
||||
throw new Error(`Error calling ${apiCmd}: ${res.body}`);
|
||||
}
|
||||
}
|
||||
|
||||
_onConnectFailure(vendor) {
|
||||
this.logger.info(`streaming tts connection failed to ${vendor}`);
|
||||
this._connectionStatus = TtsStreamingConnectionStatus.Failed;
|
||||
this.tokens = '';
|
||||
this.emit(TtsStreamingEvents.ConnectFailure, {vendor});
|
||||
}
|
||||
|
||||
_doFlush() {
|
||||
return this._api(this.ep, [this.ep.uuid, 'flush'])
|
||||
.then(() => this.logger.debug('TtsStreamingBuffer:_doFlush sent flush command'))
|
||||
.catch((err) =>
|
||||
this.logger.info(
|
||||
{ err },
|
||||
`TtsStreamingBuffer:_doFlush Error flushing TTS streaming: ${JSON.stringify(err)}`
|
||||
)
|
||||
);
|
||||
this._api(this.ep, [this.ep.uuid, 'flush'])
|
||||
.catch((err) => this.logger.info({err},
|
||||
`TtsStreamingBuffer:_doFlush Error flushing TTS streaming: ${JSON.stringify(err)}`));
|
||||
}
|
||||
|
||||
async _onConnect(vendor) {
|
||||
this.logger.info(`TtsStreamingBuffer:_onConnect streaming tts connection made to ${vendor} successful`);
|
||||
this.logger.info(`streaming tts connection made to ${vendor}`);
|
||||
this._connectionStatus = TtsStreamingConnectionStatus.Connected;
|
||||
if (this.queue.length > 0) {
|
||||
await this._feedQueue();
|
||||
if (this.tokens.length > 0) {
|
||||
await this._feedTokens();
|
||||
}
|
||||
if (this._flushPending) {
|
||||
this.flush();
|
||||
this._flushPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
_onConnectFailure(vendor) {
|
||||
this.logger.info(`TtsStreamingBuffer:_onConnectFailure streaming tts connection failed to ${vendor}`);
|
||||
this._connectionStatus = TtsStreamingConnectionStatus.Failed;
|
||||
this.queue = [];
|
||||
this.bufferedLength = 0;
|
||||
this.emit(TtsStreamingEvents.ConnectFailure, { vendor });
|
||||
}
|
||||
|
||||
_setTimerIfNeeded() {
|
||||
if (this.bufferedLength > 0 && !this.timer) {
|
||||
this.logger.debug({queue: this.queue},
|
||||
`TtsStreamingBuffer:_setTimerIfNeeded setting timer because ${this.bufferedLength} buffered`);
|
||||
if (this.tokens.length > 0 && !this.timer) {
|
||||
this.timer = setTimeout(this._onTimeout.bind(this), TIMEOUT_RETRY_MSECS);
|
||||
}
|
||||
}
|
||||
|
||||
_removeTimer() {
|
||||
if (this.timer) {
|
||||
this.logger.debug('TtsStreamingBuffer:_removeTimer clearing timer');
|
||||
clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
_onTimeout() {
|
||||
this.logger.debug('TtsStreamingBuffer:_onTimeout Timeout waiting for sentence boundary');
|
||||
this.logger.info('TtsStreamingBuffer:_onTimeout');
|
||||
this.timer = null;
|
||||
// Check if new text has been added since the timer was set.
|
||||
const now = Date.now();
|
||||
if (now - this.lastUpdateTime < TIMEOUT_RETRY_MSECS) {
|
||||
this.logger.debug('TtsStreamingBuffer:_onTimeout New text received recently; postponing flush.');
|
||||
this._setTimerIfNeeded();
|
||||
return;
|
||||
}
|
||||
this._feedQueue(true);
|
||||
this._feedTokens(true);
|
||||
}
|
||||
|
||||
_onTtsEmpty(vendor) {
|
||||
this.emit(TtsStreamingEvents.Empty, { vendor });
|
||||
this.emit(TtsStreamingEvents.Empty, {vendor});
|
||||
}
|
||||
|
||||
addCustomEventListener(ep, event, handler) {
|
||||
this.eventHandlers.push({ ep, event, handler });
|
||||
this.eventHandlers.push({ep, event, handler});
|
||||
ep.addCustomEventListener(event, handler);
|
||||
}
|
||||
|
||||
@@ -403,6 +274,7 @@ class TtsStreamingBuffer extends Emitter {
|
||||
|
||||
_initHandlers(ep) {
|
||||
[
|
||||
// DH: add other vendors here as modules are added
|
||||
'deepgram',
|
||||
'cartesia',
|
||||
'elevenlabs',
|
||||
@@ -421,21 +293,23 @@ class TtsStreamingBuffer extends Emitter {
|
||||
}
|
||||
|
||||
const findSentenceBoundary = (text, limit) => {
|
||||
// Look for punctuation or double newline that signals sentence end.
|
||||
// Match traditional sentence boundaries or double newlines
|
||||
const sentenceEndRegex = /[.!?](?=\s|$)|\n\n/g;
|
||||
let lastSentenceBoundary = -1;
|
||||
let match;
|
||||
|
||||
while ((match = sentenceEndRegex.exec(text)) && match.index < limit) {
|
||||
const precedingText = text.slice(0, match.index).trim();
|
||||
if (precedingText.length > 0) {
|
||||
const precedingText = text.slice(0, match.index).trim(); // Extract text before the match and trim whitespace
|
||||
if (precedingText.length > 0) { // Check if there's actual content
|
||||
if (
|
||||
match[0] === '\n\n' ||
|
||||
(match.index === 0 || !/\d$/.test(text[match.index - 1]))
|
||||
match[0] === '\n\n' || // It's a double newline
|
||||
(match.index === 0 || !/\d$/.test(text[match.index - 1])) // Standard punctuation rules
|
||||
) {
|
||||
lastSentenceBoundary = match.index + (match[0] === '\n\n' ? 2 : 1);
|
||||
lastSentenceBoundary = match.index + (match[0] === '\n\n' ? 2 : 1); // Include the boundary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastSentenceBoundary;
|
||||
};
|
||||
|
||||
@@ -443,6 +317,7 @@ const findWordBoundary = (text, limit) => {
|
||||
const wordBoundaryRegex = /\s+/g;
|
||||
let lastWordBoundary = -1;
|
||||
let match;
|
||||
|
||||
while ((match = wordBoundaryRegex.exec(text)) && match.index < limit) {
|
||||
lastWordBoundary = match.index;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const assert = require('assert');
|
||||
const BaseRequestor = require('./base-requestor');
|
||||
const short = require('short-uuid');
|
||||
const parseUrl = require('parse-url');
|
||||
const {HookMsgTypes, WS_CLOSE_CODES} = require('./constants.json');
|
||||
const Websocket = require('ws');
|
||||
const snakeCaseKeys = require('./snakecase-keys');
|
||||
@@ -42,19 +41,6 @@ class WsRequestor extends BaseRequestor {
|
||||
|
||||
assert(this._isAbsoluteUrl(this.url));
|
||||
|
||||
const parsedUrl = parseUrl(this.url);
|
||||
const hash = parsedUrl.hash || '';
|
||||
const hashObj = hash ? this._parseHashParams(hash) : {};
|
||||
|
||||
// remove hash
|
||||
this.cleanUrl = hash ? this.url.replace(`#${hash}`, '') : this.url;
|
||||
|
||||
// Retry policy: rp valid values: 4xx, 5xx, ct, rt, all, default is ct
|
||||
// Retry count: rc valid values: 1-5, default is 5 for websockets
|
||||
this.maxReconnects = Math.min(Math.abs(parseInt(hashObj.rc) || MAX_RECONNECTS), 5);
|
||||
this.retryPolicy = hashObj.rp || 'ct';
|
||||
this.retryPolicyValues = this.retryPolicy.split(',').map((v) => v.trim());
|
||||
|
||||
this.on('socket-closed', this._onSocketClosed.bind(this));
|
||||
}
|
||||
|
||||
@@ -69,7 +55,7 @@ class WsRequestor extends BaseRequestor {
|
||||
* @param {string} [hook.password] - if basic auth is protecting the endpoint
|
||||
* @param {object} [params] - request parameters
|
||||
*/
|
||||
async request(type, hook, params, httpHeaders = {}, span) {
|
||||
async request(type, hook, params, httpHeaders = {}) {
|
||||
assert(HookMsgTypes.includes(type));
|
||||
const url = hook.url || hook;
|
||||
const wantsAck = !MTYPE_WANTS_ACK.includes(type);
|
||||
@@ -101,7 +87,7 @@ class WsRequestor extends BaseRequestor {
|
||||
this.close();
|
||||
this.emit('handover', requestor);
|
||||
}
|
||||
return requestor.request(type, hook, params, httpHeaders, span);
|
||||
return requestor.request(type, hook, params, httpHeaders);
|
||||
}
|
||||
|
||||
/* connect if necessary */
|
||||
@@ -125,56 +111,16 @@ class WsRequestor extends BaseRequestor {
|
||||
}
|
||||
this.connectInProgress = true;
|
||||
this.logger.debug(`WsRequestor:request(${this.id}) - connecting since we do not have a connection for ${type}`);
|
||||
|
||||
if (this.connections >= MAX_RECONNECTS) {
|
||||
return Promise.reject(`max attempts connecting to ${this.url}`);
|
||||
}
|
||||
try {
|
||||
let retryCount = 0;
|
||||
let lastError = null;
|
||||
|
||||
while (retryCount <= this.maxReconnects) {
|
||||
try {
|
||||
this.logger.debug({retryCount, maxReconnects: this.maxReconnects},
|
||||
'WsRequestor:request - attempting connection retry');
|
||||
|
||||
// Ensure clean state before each connection attempt
|
||||
if (this.ws) {
|
||||
this.ws.removeAllListeners();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
const startAt = process.hrtime();
|
||||
await this._connect();
|
||||
const rtt = this._roundTrip(startAt);
|
||||
this.stats.histogram('app.hook.connect_time', rtt, ['hook_type:app']);
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
retryCount++;
|
||||
|
||||
if (retryCount <= this.maxReconnects &&
|
||||
this.retryPolicyValues?.length &&
|
||||
this._shouldRetry(error, this.retryPolicyValues)) {
|
||||
|
||||
const delay = this.backoffMs;
|
||||
this.backoffMs = this.backoffMs < 2000 ? this.backoffMs * 2 : (this.backoffMs + 2000);
|
||||
this.logger.debug({delay}, 'WsRequestor:request - waiting before retry');
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
continue;
|
||||
}
|
||||
|
||||
this.logger.error({error: error.message, retryCount, maxReconnects: this.maxReconnects},
|
||||
'WsRequestor:request - all connection attempts failed');
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
// If we exit the loop without success, throw the last error
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
const startAt = process.hrtime();
|
||||
await this._connect();
|
||||
const rtt = this._roundTrip(startAt);
|
||||
this.stats.histogram('app.hook.connect_time', rtt, ['hook_type:app']);
|
||||
} catch (err) {
|
||||
this.logger.info({url, err, retryPolicy: this.retryPolicy},
|
||||
'WsRequestor:request - all connection attempts failed');
|
||||
this.logger.info({url, err}, 'WsRequestor:request - failed connecting');
|
||||
this.connectInProgress = false;
|
||||
return Promise.reject(err);
|
||||
}
|
||||
@@ -186,8 +132,8 @@ class WsRequestor extends BaseRequestor {
|
||||
assert(this.ws);
|
||||
|
||||
/* prepare and send message */
|
||||
let payload = params ? snakeCaseKeys(params, ['customerData', 'sip', 'env_vars', 'args']) : null;
|
||||
if (type === 'session:new' || type === 'session:adulting') this._sessionData = payload;
|
||||
let payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
|
||||
if (type === 'session:new') this._sessionData = payload;
|
||||
if (type === 'session:reconnect') payload = this._sessionData;
|
||||
assert.ok(url, 'WsRequestor:request url was not provided');
|
||||
|
||||
@@ -200,23 +146,17 @@ class WsRequestor extends BaseRequestor {
|
||||
type,
|
||||
msgid,
|
||||
call_sid: this.call_sid,
|
||||
hook: [
|
||||
'verb:hook', 'dial:confirm', 'session:redirect', 'llm:event', 'llm:tool-call'
|
||||
].includes(type) ? url : undefined,
|
||||
hook: ['verb:hook', 'session:redirect', 'llm:event', 'llm:tool-call'].includes(type) ? url : undefined,
|
||||
data: {...payload},
|
||||
...b3
|
||||
};
|
||||
// add msgid to span attributes if it exists
|
||||
if (span) {
|
||||
span.setAttributes({'msgid': msgid});
|
||||
}
|
||||
|
||||
const sendQueuedMsgs = () => {
|
||||
if (this.queuedMsg.length > 0) {
|
||||
for (const {type, hook, params, httpHeaders, promise} of this.queuedMsg) {
|
||||
this.logger.debug(`WsRequestor:request - preparing queued ${type} for sending`);
|
||||
if (promise) {
|
||||
this.request(type, hook, params, httpHeaders, span)
|
||||
this.request(type, hook, params, httpHeaders)
|
||||
.then((res) => promise.resolve(res))
|
||||
.catch((err) => promise.reject(err));
|
||||
}
|
||||
@@ -293,7 +233,7 @@ class WsRequestor extends BaseRequestor {
|
||||
|
||||
/* send the message */
|
||||
this.ws.send(JSON.stringify(obj), async() => {
|
||||
if (obj.type !== 'llm:event') this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
|
||||
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
|
||||
// If session:reconnect is waiting for ack, hold here until ack to send queuedMsgs
|
||||
if (this._reconnectPromise) {
|
||||
try {
|
||||
@@ -355,23 +295,17 @@ class WsRequestor extends BaseRequestor {
|
||||
};
|
||||
if (this.username && this.password) opts = {...opts, auth: `${this.username}:${this.password}`};
|
||||
|
||||
// Clean up any existing connection event listeners to prevent interference between retry attempts
|
||||
this.removeAllListeners('ready');
|
||||
this.removeAllListeners('not-ready');
|
||||
|
||||
this
|
||||
.once('ready', (ws) => {
|
||||
this.logger.debug('WsRequestor:_connect - ready event fired, resolving Promise');
|
||||
this.removeAllListeners('not-ready');
|
||||
if (this.connections > 1) this.request('session:reconnect', this.url);
|
||||
resolve();
|
||||
})
|
||||
.once('not-ready', (err) => {
|
||||
this.logger.error({err: err.message}, 'WsRequestor:_connect - not-ready event fired, rejecting Promise');
|
||||
this.removeAllListeners('ready');
|
||||
reject(err);
|
||||
});
|
||||
const ws = new Websocket(this.cleanUrl, ['ws.jambonz.org'], opts);
|
||||
const ws = new Websocket(this.url, ['ws.jambonz.org'], opts);
|
||||
this._setHandlers(ws);
|
||||
});
|
||||
}
|
||||
@@ -395,13 +329,10 @@ class WsRequestor extends BaseRequestor {
|
||||
}
|
||||
|
||||
_onError(err) {
|
||||
if (this.connectInProgress) {
|
||||
this.logger.info({url: this.url, err}, 'WsRequestor:_onError - emitting not-ready for connection attempt');
|
||||
this.emit('not-ready', err);
|
||||
}
|
||||
else if (this.connections === 0) {
|
||||
this.emit('not-ready', err);
|
||||
if (this.connections > 0) {
|
||||
this.logger.info({url: this.url, err}, 'WsRequestor:_onError');
|
||||
}
|
||||
else this.emit('not-ready', err);
|
||||
}
|
||||
|
||||
_onOpen(ws) {
|
||||
@@ -438,44 +369,30 @@ class WsRequestor extends BaseRequestor {
|
||||
statusMessage: res.statusMessage
|
||||
}, 'WsRequestor - unexpected response');
|
||||
this.emit('connection-failure');
|
||||
|
||||
const error = new Error(`${res.statusCode} ${res.statusMessage}`);
|
||||
error.statusCode = res.statusCode;
|
||||
this.connectInProgress = false;
|
||||
|
||||
this.emit('not-ready', error);
|
||||
this.emit('not-ready', new Error(`${res.statusCode} ${res.statusMessage}`));
|
||||
this.connections++;
|
||||
}
|
||||
|
||||
_onSocketClosed() {
|
||||
this.ws = null;
|
||||
this.emit('connection-dropped');
|
||||
this._stopPingTimer();
|
||||
|
||||
if (this.connections > 0 && this.connections < this.maxReconnects && !this.closedGracefully) {
|
||||
if (this.connections > 0 && this.connections < MAX_RECONNECTS && !this.closedGracefully) {
|
||||
if (!this._initMsgId) this._clearPendingMessages();
|
||||
this.logger.debug(`WsRequestor:_onSocketClosed waiting ${this.backoffMs} to reconnect`);
|
||||
this._scheduleReconnect('_onSocketClosed');
|
||||
}
|
||||
}
|
||||
|
||||
_scheduleReconnect(source) {
|
||||
this.logger.debug(`WsRequestor:_scheduleReconnect waiting ${this.backoffMs} to reconnect (${source})`);
|
||||
setTimeout(() => {
|
||||
this.logger.debug(
|
||||
{haveWs: !!this.ws, connectInProgress: this.connectInProgress},
|
||||
`WsRequestor:_scheduleReconnect time to reconnect (${source})`);
|
||||
if (!this.ws && !this.connectInProgress) {
|
||||
this.connectInProgress = true;
|
||||
return this._connect()
|
||||
.catch((err) => this.logger.error(`WsRequestor:${source} There is error while reconnect`, err))
|
||||
.finally(() => this.connectInProgress = false);
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
this.logger.debug(
|
||||
{haveWs: !!this.ws, connectInProgress: this.connectInProgress},
|
||||
`WsRequestor:_scheduleReconnect skipping reconnect attempt (${source}) - conditions not met`);
|
||||
}
|
||||
}, this.backoffMs);
|
||||
this.backoffMs = this.backoffMs < 2000 ? this.backoffMs * 2 : (this.backoffMs + 2000);
|
||||
'WsRequestor:_onSocketClosed time to reconnect');
|
||||
if (!this.ws && !this.connectInProgress) {
|
||||
this.connectInProgress = true;
|
||||
return this._connect()
|
||||
.catch((err) => this.logger.error('WsRequestor:_onSocketClosed There is error while reconnect', err))
|
||||
.finally(() => this.connectInProgress = false);
|
||||
}
|
||||
}, this.backoffMs);
|
||||
this.backoffMs = this.backoffMs < 2000 ? this.backoffMs * 2 : (this.backoffMs + 2000);
|
||||
}
|
||||
}
|
||||
|
||||
_onMessage(content, isBinary) {
|
||||
@@ -514,21 +431,6 @@ class WsRequestor extends BaseRequestor {
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err, content}, 'WsRequestor:_onMessage - invalid incoming message');
|
||||
const params = {
|
||||
msg: 'InvalidMessage',
|
||||
details: err.message,
|
||||
content: Buffer.from(content).toString('utf-8')
|
||||
};
|
||||
const {writeAlerts, AlertType} = this.Alerter;
|
||||
writeAlerts({
|
||||
account_sid: this.account_sid,
|
||||
alert_type: AlertType.INVALID_APP_PAYLOAD,
|
||||
target_sid: this.call_sid,
|
||||
message: err.message,
|
||||
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for invalid message'));
|
||||
this.request('jambonz:error', '/error', params)
|
||||
.catch((err) => this.logger.debug({err}, 'WsRequestor:_onMessage - Error sending'));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12896
package-lock.json
generated
12896
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jambonz-feature-server",
|
||||
"version": "0.9.5",
|
||||
"version": "0.9.3",
|
||||
"main": "app.js",
|
||||
"engines": {
|
||||
"node": ">= 18.x"
|
||||
@@ -27,15 +27,14 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-auto-scaling": "^3.549.0",
|
||||
"@aws-sdk/client-sns": "^3.549.0",
|
||||
"@jambonz/db-helpers": "^0.9.16",
|
||||
"@jambonz/db-helpers": "^0.9.6",
|
||||
"@jambonz/http-health-check": "^0.0.1",
|
||||
"@jambonz/mw-registrar": "^0.2.7",
|
||||
"@jambonz/realtimedb-helpers": "^0.8.15",
|
||||
"@jambonz/speech-utils": "^0.2.22",
|
||||
"@jambonz/realtimedb-helpers": "^0.8.8",
|
||||
"@jambonz/speech-utils": "^0.2.1",
|
||||
"@jambonz/stats-collector": "^0.1.10",
|
||||
"@jambonz/time-series": "^0.2.14",
|
||||
"@jambonz/verb-specifications": "^0.0.113",
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@jambonz/verb-specifications": "^0.0.94",
|
||||
"@jambonz/time-series": "^0.2.13",
|
||||
"@opentelemetry/api": "^1.8.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.23.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.50.0",
|
||||
@@ -48,19 +47,21 @@
|
||||
"bent": "^7.3.12",
|
||||
"debug": "^4.3.4",
|
||||
"deepcopy": "^2.1.0",
|
||||
"drachtio-fsmrf": "^4.1.2",
|
||||
"drachtio-srf": "^5.0.5",
|
||||
"drachtio-fsmrf": "^4.0.1",
|
||||
"drachtio-srf": "^5.0.1",
|
||||
"express": "^4.19.2",
|
||||
"express-validator": "^7.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"parse-url": "^9.2.0",
|
||||
"pino": "^8.20.0",
|
||||
"polly-ssml-split": "^0.1.0",
|
||||
"proxyquire": "^2.1.3",
|
||||
"sdp-transform": "^2.15.0",
|
||||
"short-uuid": "^5.1.0",
|
||||
"sinon": "^17.0.1",
|
||||
"to-snake-case": "^1.0.0",
|
||||
"undici": "^7.5.0",
|
||||
"undici": "^6.20.0",
|
||||
"uuid-random": "^1.3.2",
|
||||
"verify-aws-sns-signature": "^0.1.0",
|
||||
"ws": "^8.18.0",
|
||||
"xml2js": "^0.6.2"
|
||||
@@ -70,7 +71,6 @@
|
||||
"eslint": "7.32.0",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"nyc": "^15.1.0",
|
||||
"proxyquire": "^2.1.3",
|
||||
"tape": "^5.7.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
|
||||
@@ -3,8 +3,9 @@ const { sippUac } = require('./sipp')('test_fs');
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json')
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook} = require('./utils');
|
||||
const { sleepFor } = require('../lib/utils/helpers');
|
||||
const {provisionCallHook} = require('./utils')
|
||||
|
||||
const sleepFor = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
// Test for HttpRequestor retry functionality
|
||||
const test = require('tape');
|
||||
const sinon = require('sinon');
|
||||
const proxyquire = require('proxyquire').noCallThru();
|
||||
const { createMocks, setupBaseRequestorMocks } = require('./utils/mock-helper');
|
||||
|
||||
// Create mocks
|
||||
const mocks = createMocks();
|
||||
|
||||
// Mock timeSeries module
|
||||
const timeSeriesMock = sinon.stub().returns(mocks.MockAlerter);
|
||||
|
||||
// Mock the config with required properties
|
||||
const configMock = {
|
||||
HTTP_POOL: '0',
|
||||
HTTP_POOLSIZE: '10',
|
||||
HTTP_PIPELINING: '1',
|
||||
HTTP_TIMEOUT: 5000,
|
||||
HTTP_PROXY_IP: null,
|
||||
HTTP_PROXY_PORT: null,
|
||||
HTTP_PROXY_PROTOCOL: null,
|
||||
NODE_ENV: 'test',
|
||||
HTTP_USER_AGENT_HEADER: 'test-agent'
|
||||
};
|
||||
|
||||
// Mock db-helpers
|
||||
const dbHelpersMock = mocks.MockDbHelpers;
|
||||
|
||||
// Require HttpRequestor with mocked dependencies
|
||||
const BaseRequestor = proxyquire('../lib/utils/base-requestor', {
|
||||
'@jambonz/time-series': timeSeriesMock,
|
||||
'../config': configMock,
|
||||
'../../': { srf: { locals: { stats: mocks.MockStats } } }
|
||||
});
|
||||
|
||||
// Setup BaseRequestor mocks
|
||||
setupBaseRequestorMocks(BaseRequestor);
|
||||
|
||||
// Require HttpRequestor with mocked dependencies
|
||||
const HttpRequestor = proxyquire('../lib/utils/http-requestor', {
|
||||
'./base-requestor': BaseRequestor,
|
||||
'../config': configMock,
|
||||
'@jambonz/db-helpers': dbHelpersMock
|
||||
});
|
||||
|
||||
// Setup utility function
|
||||
const setupRequestor = () => {
|
||||
const hook = { url: 'http://localhost/test', method: 'POST' };
|
||||
const requestor = new HttpRequestor(mocks.MockLogger, 'AC123', hook, 'testsecret');
|
||||
requestor.stats = mocks.MockStats;
|
||||
return requestor;
|
||||
};
|
||||
|
||||
// Cleanup function for tests
|
||||
const cleanup = (requestor) => {
|
||||
sinon.restore();
|
||||
if (requestor && requestor.close) requestor.close();
|
||||
};
|
||||
|
||||
test('HttpRequestor: should retry on connection errors when specified in hash', async (t) => {
|
||||
const requestor = setupRequestor();
|
||||
|
||||
// Setup a URL with retry params in the hash
|
||||
const urlWithRetry = 'http://localhost/test#rc=3&rp=ct,5xx';
|
||||
|
||||
// First two calls fail with connection refused, third succeeds
|
||||
const requestStub = sinon.stub(requestor.client, 'request');
|
||||
const error = new Error('Connection refused');
|
||||
error.code = 'ECONNREFUSED';
|
||||
|
||||
// Fail twice, succeed on third try
|
||||
requestStub.onCall(0).rejects(error);
|
||||
requestStub.onCall(1).rejects(error);
|
||||
requestStub.onCall(2).resolves({
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: { json: async () => ({ success: true }) }
|
||||
});
|
||||
|
||||
try {
|
||||
const hook = { url: urlWithRetry, method: 'GET' };
|
||||
const result = await requestor.request('verb:hook', hook, null);
|
||||
|
||||
t.equal(requestStub.callCount, 3, 'Should have retried twice for a total of 3 calls');
|
||||
t.deepEqual(result, { success: true }, 'Should return successful response');
|
||||
} catch (err) {
|
||||
t.fail(`Should not throw an error: ${err.message}`);
|
||||
}
|
||||
|
||||
cleanup(requestor);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('HttpRequestor: should respect retry count (rc) from hash', async (t) => {
|
||||
const requestor = setupRequestor();
|
||||
|
||||
// Setup a URL with retry params in the hash - only retry once
|
||||
const urlWithRetry = 'http://localhost/test#rc=1&rp=ct';
|
||||
|
||||
// All calls fail with connection refused
|
||||
const requestStub = sinon.stub(requestor.client, 'request');
|
||||
const error = new Error('Connection refused');
|
||||
error.code = 'ECONNREFUSED';
|
||||
|
||||
// Always fail
|
||||
requestStub.rejects(error);
|
||||
|
||||
try {
|
||||
const hook = { url: urlWithRetry, method: 'GET' };
|
||||
await requestor.request('verb:hook', hook, null);
|
||||
t.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
t.equal(requestStub.callCount, 2, 'Should have retried once for a total of 2 calls');
|
||||
t.equal(err.code, 'ECONNREFUSED', 'Should throw the original error');
|
||||
}
|
||||
|
||||
cleanup(requestor);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('HttpRequestor: should respect retry policy (rp) from hash', async (t) => {
|
||||
const requestor = setupRequestor();
|
||||
|
||||
// Setup a URL with retry params in hash - only retry on 5xx errors
|
||||
const urlWithRetry = 'http://localhost/test#rc=2&rp=5xx';
|
||||
|
||||
// Fail with 404 (should not retry since rp=5xx)
|
||||
const requestStub = sinon.stub(requestor.client, 'request');
|
||||
requestStub.resolves({
|
||||
statusCode: 404,
|
||||
headers: {},
|
||||
body: {}
|
||||
});
|
||||
|
||||
try {
|
||||
const hook = { url: urlWithRetry, method: 'GET' };
|
||||
await requestor.request('verb:hook', hook, null);
|
||||
t.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
t.equal(requestStub.callCount, 1, 'Should not retry on 404 when rp=5xx');
|
||||
t.equal(err.statusCode, 404, 'Should throw 404 error');
|
||||
}
|
||||
|
||||
cleanup(requestor);
|
||||
t.end();
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
setupRequestor,
|
||||
cleanup
|
||||
};
|
||||
@@ -1,214 +0,0 @@
|
||||
const test = require('tape');
|
||||
const sinon = require('sinon');
|
||||
const { createMockedRequestors } = require('./utils/test-mocks');
|
||||
|
||||
// Use the shared mocks and helpers
|
||||
const {
|
||||
HttpRequestor,
|
||||
setupRequestor,
|
||||
cleanup
|
||||
} = createMockedRequestors();
|
||||
|
||||
// All prototype overrides and setup are now handled in test-mocks.js
|
||||
|
||||
// --- TESTS ---
|
||||
test('HttpRequestor: constructor sets up properties correctly', (t) => {
|
||||
const requestor = setupRequestor();
|
||||
t.equal(requestor.method, 'POST', 'method should be POST');
|
||||
t.equal(requestor.url, 'http://localhost/test', 'url should be set');
|
||||
t.equal(typeof requestor.client, 'object', 'client should be an object');
|
||||
cleanup(requestor);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('HttpRequestor: constructor with username/password sets auth header', (t) => {
|
||||
const { mocks, HttpRequestor } = createMockedRequestors();
|
||||
const logger = mocks.logger;
|
||||
const hook = {
|
||||
url: 'http://localhost/test',
|
||||
method: 'POST',
|
||||
username: 'user',
|
||||
password: 'pass'
|
||||
};
|
||||
const requestor = new HttpRequestor(logger, 'AC123', hook, 'secret');
|
||||
t.ok(requestor.authHeader.Authorization, 'Authorization header should be set');
|
||||
t.ok(requestor.authHeader.Authorization.startsWith('Basic '), 'Should be Basic auth');
|
||||
cleanup(requestor);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('HttpRequestor: request should return JSON on 200 response', async (t) => {
|
||||
const requestor = setupRequestor();
|
||||
const expectedResponse = { success: true, data: [1, 2, 3] };
|
||||
const fakeBody = { json: async () => expectedResponse };
|
||||
sinon.stub(requestor.client, 'request').resolves({
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: fakeBody
|
||||
});
|
||||
try {
|
||||
const hook = { url: 'http://localhost/test', method: 'POST' };
|
||||
const result = await requestor.request('verb:hook', hook, { foo: 'bar' });
|
||||
t.deepEqual(result, expectedResponse, 'Should return parsed JSON');
|
||||
const requestCall = requestor.client.request.getCall(0);
|
||||
const opts = requestCall.args[0];
|
||||
t.equal(opts.method, 'POST', 'method should be POST');
|
||||
t.ok(opts.headers['X-Signature'], 'Should include signature header');
|
||||
t.ok(opts.body, 'Should include request body');
|
||||
} catch (err) {
|
||||
t.fail(err);
|
||||
}
|
||||
cleanup(requestor);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('HttpRequestor: request should handle non-200 responses', async (t) => {
|
||||
const requestor = setupRequestor();
|
||||
sinon.stub(requestor.client, 'request').resolves({
|
||||
statusCode: 404,
|
||||
headers: {},
|
||||
body: {}
|
||||
});
|
||||
try {
|
||||
const hook = { url: 'http://localhost/test', method: 'POST' };
|
||||
await requestor.request('verb:hook', hook, { foo: 'bar' });
|
||||
t.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
t.ok(err, 'Should throw an error');
|
||||
t.equal(err.statusCode, 404, 'Error should contain status code');
|
||||
}
|
||||
cleanup(requestor);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('HttpRequestor: request should handle ECONNREFUSED error', async (t) => {
|
||||
const requestor = setupRequestor();
|
||||
const error = new Error('Connection refused');
|
||||
error.code = 'ECONNREFUSED';
|
||||
sinon.stub(requestor.client, 'request').rejects(error);
|
||||
try {
|
||||
const hook = { url: 'http://localhost/test', method: 'POST' };
|
||||
await requestor.request('verb:hook', hook, { foo: 'bar' });
|
||||
t.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
t.equal(err.code, 'ECONNREFUSED', 'Should pass through the error');
|
||||
}
|
||||
cleanup(requestor);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('HttpRequestor: request should skip jambonz:error type', async (t) => {
|
||||
const requestor = setupRequestor();
|
||||
const spy = sinon.spy(requestor.client, 'request');
|
||||
const hook = { url: 'http://localhost/test', method: 'POST' };
|
||||
const result = await requestor.request('jambonz:error', hook, { foo: 'bar' });
|
||||
t.equal(result, undefined, 'Should return undefined');
|
||||
t.equal(spy.callCount, 0, 'Should not call request method');
|
||||
cleanup(requestor);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('HttpRequestor: request should handle array response', async (t) => {
|
||||
const requestor = setupRequestor();
|
||||
const fakeBody = { json: async () => [{ id: 1 }, { id: 2 }] };
|
||||
sinon.stub(requestor.client, 'request').resolves({
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: fakeBody
|
||||
});
|
||||
try {
|
||||
const hook = { url: 'http://localhost/test', method: 'POST' };
|
||||
const result = await requestor.request('verb:hook', hook, { foo: 'bar' });
|
||||
t.ok(Array.isArray(result), 'Should return an array');
|
||||
t.equal(result.length, 2, 'Array should have 2 items');
|
||||
} catch (err) {
|
||||
t.fail(err);
|
||||
}
|
||||
cleanup(requestor);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('HttpRequestor: request should handle llm:tool-call type', async (t) => {
|
||||
const requestor = setupRequestor();
|
||||
const fakeBody = { json: async () => ({ result: 'tool output' }) };
|
||||
sinon.stub(requestor.client, 'request').resolves({
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: fakeBody
|
||||
});
|
||||
try {
|
||||
const hook = { url: 'http://localhost/test', method: 'POST' };
|
||||
const result = await requestor.request('llm:tool-call', hook, { tool: 'test' });
|
||||
t.deepEqual(result, { result: 'tool output' }, 'Should return the parsed JSON');
|
||||
} catch (err) {
|
||||
t.fail(err);
|
||||
}
|
||||
cleanup(requestor);
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('HttpRequestor: close should close the client if not using pools', (t) => {
|
||||
// Ensure HTTP_POOL is set to false to disable pool usage
|
||||
const oldHttpPool = process.env.HTTP_POOL;
|
||||
process.env.HTTP_POOL = '0';
|
||||
|
||||
const requestor = setupRequestor();
|
||||
// Make sure _usePools is false
|
||||
requestor._usePools = false;
|
||||
|
||||
// Replace the client.close with a spy function
|
||||
const closeSpy = sinon.spy();
|
||||
requestor.client.close = closeSpy;
|
||||
|
||||
// Set client.closed to false to ensure the condition is met
|
||||
requestor.client.closed = false;
|
||||
|
||||
// Call close
|
||||
requestor.close();
|
||||
|
||||
// Check if the spy was called
|
||||
t.ok(closeSpy.calledOnce, 'Should call client.close');
|
||||
|
||||
// Restore HTTP_POOL
|
||||
process.env.HTTP_POOL = oldHttpPool;
|
||||
|
||||
// Don't call cleanup(requestor) as it would try to call client.close again
|
||||
sinon.restore();
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('HttpRequestor: request should handle URLs with fragments', async (t) => {
|
||||
const requestor = setupRequestor();
|
||||
// Use the same host/port as the base client to avoid creating a new client
|
||||
const urlWithFragment = 'http://localhost?param1=value1#rc=5&rp=4xx,5xx,ct';
|
||||
const expectedResponse = { status: 'success' };
|
||||
const fakeBody = { json: async () => expectedResponse };
|
||||
|
||||
// Stub the request method
|
||||
const requestStub = sinon.stub(requestor.client, 'request').callsFake((opts) => {
|
||||
return Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: fakeBody
|
||||
});
|
||||
});
|
||||
try {
|
||||
const hook = { url: urlWithFragment, method: 'GET' };
|
||||
const result = await requestor.request('verb:hook', hook, null);
|
||||
t.deepEqual(result, expectedResponse, 'Should return the parsed JSON response');
|
||||
const requestCall = requestStub.getCall(0);
|
||||
const opts = requestCall.args[0];
|
||||
t.ok(opts.query && opts.query.param1 === 'value1', 'Query parameters should be parsed');
|
||||
t.equal(opts.path, '/', 'Path should be extracted from URL');
|
||||
t.notOk(opts.query && opts.query.rc, 'Fragment should not be included in query parameters');
|
||||
} catch (err) {
|
||||
t.fail(err);
|
||||
}
|
||||
cleanup(requestor);
|
||||
t.end();
|
||||
});
|
||||
|
||||
// test('HttpRequestor: request should handle URLs with query parameters', async (t) => {
|
||||
// t.pass('Restored original require function');
|
||||
// t.end();
|
||||
// });
|
||||
@@ -1,8 +1,4 @@
|
||||
require('./ws-requestor-retry-unit-test');
|
||||
require('./test_ws_retry_comprehensive');
|
||||
require('./ws-requestor-unit-test');
|
||||
require('./http-requestor-retry-test');
|
||||
require('./http-requestor-unit-test');
|
||||
require('./unit-tests');
|
||||
require('./docker_start');
|
||||
require('./create-test-db');
|
||||
@@ -16,7 +12,6 @@ require('./sip-request-tests');
|
||||
require('./create-call-test');
|
||||
require('./play-tests');
|
||||
require('./sip-refer-tests');
|
||||
require('./sip-refer-handler-tests');
|
||||
require('./listen-tests');
|
||||
require('./config-test');
|
||||
require('./queue-test');
|
||||
|
||||
@@ -3,7 +3,6 @@ const { sippUac } = require('./sipp')('test_fs');
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook, provisionActionHook, provisionAnyHook} = require('./utils');
|
||||
const bent = require('bent');
|
||||
const { sleepFor } = require('../lib/utils/helpers');
|
||||
const getJSON = bent('json');
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
@@ -18,6 +17,8 @@ function connect(connectable) {
|
||||
});
|
||||
}
|
||||
|
||||
const sleepFor = (ms) => new Promise((resolve) => setTimeout(() => resolve(), ms));
|
||||
|
||||
test('\'enqueue-dequeue\' tests', async(t) => {
|
||||
|
||||
clearModule.all();
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
<?xml version="1.0" encoding="ISO-8859-1" ?>
|
||||
<!DOCTYPE scenario SYSTEM "sipp.dtd">
|
||||
|
||||
<scenario name="UAS that accepts call and sends REFER">
|
||||
<!-- Receive incoming INVITE -->
|
||||
<recv request="INVITE" crlf="true">
|
||||
<action>
|
||||
<ereg regexp=".*" search_in="hdr" header="Subject:" assign_to="1" />
|
||||
<ereg regexp=".*" search_in="hdr" header="From:" assign_to="2" />
|
||||
</action>
|
||||
</recv>
|
||||
|
||||
<!-- Send 180 Ringing -->
|
||||
<send>
|
||||
<![CDATA[
|
||||
SIP/2.0 180 Ringing
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:];tag=[pid]SIPpTag01[call_number]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
[last_Record-Route:]
|
||||
Subject:[$1]
|
||||
Content-Length: 0
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<!-- Send 200 OK with SDP -->
|
||||
<send>
|
||||
<![CDATA[
|
||||
SIP/2.0 200 OK
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:];tag=[pid]SIPpTag01[call_number]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
[last_Record-Route:]
|
||||
Subject:[$1]
|
||||
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||
Content-Type: application/sdp
|
||||
Content-Length: [len]
|
||||
|
||||
v=0
|
||||
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
|
||||
s=-
|
||||
c=IN IP[media_ip_type] [media_ip]
|
||||
t=0 0
|
||||
m=audio [media_port] RTP/AVP 0
|
||||
a=rtpmap:0 PCMU/8000
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<recv request="ACK" rtd="true" crlf="true">
|
||||
<action>
|
||||
<!-- Check if this is NOT the first call (tag ends with 012 or higher) -->
|
||||
<ereg regexp="tag=1SIPpTag01[2-9]" search_in="hdr" header="To:" assign_to="3" />
|
||||
<log message="Not first call check result: [$3]"/>
|
||||
</action>
|
||||
</recv>
|
||||
|
||||
<!-- Skip REFER if we found a non-first call tag -->
|
||||
<nop next="skip_refer" test="3" value="" compare="not_equal">
|
||||
<action>
|
||||
<log message="Found non-first call tag [$3], skipping REFER"/>
|
||||
</action>
|
||||
</nop>
|
||||
|
||||
<!-- Wait a moment, then send REFER (only on first call) -->
|
||||
<pause milliseconds="1000"/>
|
||||
|
||||
<nop>
|
||||
<action>
|
||||
<log message="Sending REFER for first call"/>
|
||||
</action>
|
||||
</nop>
|
||||
|
||||
<!-- Send REFER (only on first iteration) -->
|
||||
<send retrans="500">
|
||||
<![CDATA[
|
||||
REFER sip:service@[remote_ip]:[remote_port] SIP/2.0
|
||||
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||
From: <sip:[local_ip]:[local_port]>;tag=[pid]SIPpTag01[call_number]
|
||||
To: [$2]
|
||||
[last_Call-ID:]
|
||||
CSeq: 2 REFER
|
||||
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||
Max-Forwards: 70
|
||||
X-Call-Number: [call_number]
|
||||
Refer-To: <sip:+15551234567@example.com>
|
||||
Referred-By: <sip:[local_ip]:[local_port]>
|
||||
Content-Length: 0
|
||||
]]>
|
||||
</send>
|
||||
|
||||
<!-- Expect 202 Accepted (only on first iteration) -->
|
||||
<recv response="202"/>
|
||||
|
||||
<label id="skip_refer"/>
|
||||
|
||||
<!-- Wait for BYE from feature server -->
|
||||
<recv request="BYE"/>
|
||||
|
||||
<!-- Send 200 OK to BYE -->
|
||||
<send>
|
||||
<![CDATA[
|
||||
SIP/2.0 200 OK
|
||||
[last_Via:]
|
||||
[last_From:]
|
||||
[last_To:]
|
||||
[last_Call-ID:]
|
||||
[last_CSeq:]
|
||||
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||
Content-Length: 0
|
||||
]]>
|
||||
</send>
|
||||
|
||||
</scenario>
|
||||
@@ -1,90 +0,0 @@
|
||||
const test = require('tape');
|
||||
const { sippUac } = require('./sipp')('test_fs');
|
||||
const bent = require('bent');
|
||||
const getJSON = bent('json')
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook} = require('./utils');
|
||||
const { sleepFor } = require('../lib/utils/helpers');
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
|
||||
function connect(connectable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
connectable.on('connect', () => {
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
test('when parent leg recvs REFER it should end the dial after adulting child leg', async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
try {
|
||||
await connect(srf);
|
||||
// wait for fs connected to drachtio server.
|
||||
await sleepFor(1000);
|
||||
|
||||
// GIVEN
|
||||
const from = "dial_refer_handler";
|
||||
let verbs = [
|
||||
{
|
||||
"verb": "dial",
|
||||
"callerId": from,
|
||||
"actionHook": "/actionHook",
|
||||
"referHook": "/referHook",
|
||||
"anchorMedia": true,
|
||||
"target": [
|
||||
{
|
||||
"type": "phone",
|
||||
"number": "15083084809"
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
await provisionCallHook(from, verbs);
|
||||
|
||||
// THEN
|
||||
//const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2);
|
||||
const p = sippUac('uas-dial-refer.xml', '172.38.0.10', undefined, undefined, 2);
|
||||
await sleepFor(1000);
|
||||
|
||||
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
|
||||
|
||||
let post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
|
||||
post('v1/createCall', {
|
||||
'account_sid':account_sid,
|
||||
"call_hook": {
|
||||
"url": "http://127.0.0.1:3100/",
|
||||
"method": "POST",
|
||||
},
|
||||
"from": from,
|
||||
"to": {
|
||||
"type": "phone",
|
||||
"number": "15583084808"
|
||||
}});
|
||||
|
||||
await p;
|
||||
|
||||
// Verify that the referHook was called
|
||||
const obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_referHook`);
|
||||
t.ok(obj.body.from === from,
|
||||
'dial-refer-handler: referHook was called with correct from');
|
||||
t.ok(obj.body.refer_details && obj.body.refer_details.sip_refer_to,
|
||||
'dial-refer-handler: refer_details included in referHook');
|
||||
t.ok(obj.body.refer_details.refer_to_user === '+15551234567',
|
||||
'dial-refer-handler: refer_to_user correctly parsed');
|
||||
t.ok(obj.body.refer_details.referring_call_sid,
|
||||
'dial-refer-handler: referring_call_sid included');
|
||||
t.ok(obj.body.refer_details.referred_call_sid,
|
||||
'dial-refer-handler: referred_call_sid included');
|
||||
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
@@ -3,9 +3,10 @@ const { sippUac } = require('./sipp')('test_fs');
|
||||
const clearModule = require('clear-module');
|
||||
const {provisionCallHook, provisionCustomHook, provisionActionHook} = require('./utils')
|
||||
const bent = require('bent');
|
||||
const { sleepFor } = require('../lib/utils/helpers');
|
||||
const getJSON = bent('json')
|
||||
|
||||
const sleepFor = async(ms) => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
process.on('unhandledRejection', (reason, p) => {
|
||||
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||
});
|
||||
@@ -58,46 +59,6 @@ test('\'refer\' tests w/202 and NOTIFY', {timeout: 25000}, async(t) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('\'refer\' tests tel:', {timeout: 25000}, async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const verbs = [
|
||||
{
|
||||
verb: 'say',
|
||||
text: 'silence_stream://100'
|
||||
},
|
||||
{
|
||||
verb: 'sip:refer',
|
||||
referTo: 'tel:+1234567890',
|
||||
actionHook: '/actionHook'
|
||||
}
|
||||
];
|
||||
const noVerbs = [];
|
||||
|
||||
const from = 'refer_with_tel';
|
||||
await provisionCallHook(from, verbs);
|
||||
await provisionActionHook(from, noVerbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-refer-with-notify.xml', '172.38.0.10', from);
|
||||
t.pass('refer: successfully received 202 Accepted');
|
||||
await sleepFor(1000);
|
||||
const obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
t.ok(obj.body.final_referred_call_status === 200, 'refer: successfully received NOTIFY with 200 OK');
|
||||
// console.log(`obj: ${JSON.stringify(obj)}`);
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'refer\' tests w/202 but no NOTIFY', {timeout: 25000}, async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
const test = require('tape');
|
||||
const sinon = require('sinon');
|
||||
const proxyquire = require("proxyquire");
|
||||
proxyquire.noCallThru();
|
||||
|
||||
const {
|
||||
JAMBONES_LOGLEVEL,
|
||||
} = require('../lib/config');
|
||||
const logger = require('pino')({level: JAMBONES_LOGLEVEL});
|
||||
|
||||
// Mock WebSocket specifically for retry testing
|
||||
class RetryMockWebSocket {
|
||||
static retryScenarios = new Map();
|
||||
static connectionAttempts = new Map();
|
||||
static urlMapping = new Map(); // Maps cleanUrl -> originalUrl
|
||||
|
||||
constructor(url, protocols, options) {
|
||||
this.url = url;
|
||||
this.protocols = protocols;
|
||||
this.options = options;
|
||||
this.eventListeners = new Map();
|
||||
|
||||
// Extract scenario key from URL hash or use URL itself
|
||||
this.scenarioKey = this.extractScenarioKey(url);
|
||||
|
||||
// Track connection attempts for this scenario
|
||||
const attempts = RetryMockWebSocket.connectionAttempts.get(this.scenarioKey) || 0;
|
||||
RetryMockWebSocket.connectionAttempts.set(this.scenarioKey, attempts + 1);
|
||||
|
||||
console.log(`RetryMockWebSocket: constructor for URL ${url}, scenarioKey="${this.scenarioKey}", attempt #${attempts + 1}`);
|
||||
|
||||
// Handle connection immediately
|
||||
setImmediate(() => {
|
||||
this.handleConnection();
|
||||
});
|
||||
}
|
||||
|
||||
extractScenarioKey(url) {
|
||||
console.log(`RetryMockWebSocket: extractScenarioKey from URL: ${url}`);
|
||||
|
||||
// Check if we have a mapping from cleanUrl to originalUrl
|
||||
const originalUrl = RetryMockWebSocket.urlMapping.get(url);
|
||||
if (originalUrl && originalUrl.includes('#')) {
|
||||
const hash = originalUrl.split('#')[1];
|
||||
console.log(`RetryMockWebSocket: found mapped URL with hash: ${hash}`);
|
||||
return hash;
|
||||
}
|
||||
|
||||
// For URLs with hash parameters, use the hash as the scenario key
|
||||
if (url.includes('#')) {
|
||||
const hash = url.split('#')[1];
|
||||
console.log(`RetryMockWebSocket: found hash: ${hash}`);
|
||||
return hash; // Use hash as scenario key
|
||||
}
|
||||
|
||||
console.log(`RetryMockWebSocket: using full URL as scenario key: ${url}`);
|
||||
return url; // Fallback to full URL
|
||||
}
|
||||
|
||||
static setRetryScenario(key, scenario) {
|
||||
console.log(`RetryMockWebSocket: setting scenario for key "${key}":`, scenario);
|
||||
RetryMockWebSocket.retryScenarios.set(key, scenario);
|
||||
}
|
||||
|
||||
static setUrlMapping(cleanUrl, originalUrl) {
|
||||
console.log(`RetryMockWebSocket: mapping ${cleanUrl} -> ${originalUrl}`);
|
||||
RetryMockWebSocket.urlMapping.set(cleanUrl, originalUrl);
|
||||
}
|
||||
|
||||
static clearScenarios() {
|
||||
console.log('RetryMockWebSocket: clearing all scenarios');
|
||||
RetryMockWebSocket.retryScenarios.clear();
|
||||
RetryMockWebSocket.connectionAttempts.clear();
|
||||
RetryMockWebSocket.urlMapping.clear();
|
||||
}
|
||||
|
||||
static getConnectionAttempts(key) {
|
||||
return RetryMockWebSocket.connectionAttempts.get(key) || 0;
|
||||
}
|
||||
|
||||
handleConnection() {
|
||||
const scenario = RetryMockWebSocket.retryScenarios.get(this.scenarioKey);
|
||||
console.log(`RetryMockWebSocket: handleConnection for scenarioKey="${this.scenarioKey}", scenario found:`, !!scenario);
|
||||
|
||||
if (!scenario) {
|
||||
// Default successful connection
|
||||
console.log(`RetryMockWebSocket: no scenario found, defaulting to success`);
|
||||
this.simulateOpen();
|
||||
return;
|
||||
}
|
||||
|
||||
const attemptNumber = RetryMockWebSocket.connectionAttempts.get(this.scenarioKey);
|
||||
const behavior = scenario.attempts[attemptNumber - 1] || scenario.attempts[scenario.attempts.length - 1];
|
||||
|
||||
console.log(`RetryMockWebSocket: attempt ${attemptNumber}, behavior:`, behavior);
|
||||
|
||||
if (behavior.type === 'handshake-failure') {
|
||||
// Simulate handshake failure with specific status code
|
||||
setImmediate(() => {
|
||||
console.log(`RetryMockWebSocket: triggering handshake failure with status ${behavior.statusCode}`);
|
||||
if (this.eventListeners.has('unexpected-response')) {
|
||||
const mockResponse = {
|
||||
statusCode: behavior.statusCode || 500,
|
||||
statusMessage: behavior.statusMessage || 'Internal Server Error',
|
||||
headers: {}
|
||||
};
|
||||
const mockRequest = {
|
||||
headers: {}
|
||||
};
|
||||
this.eventListeners.get('unexpected-response')(mockRequest, mockResponse);
|
||||
}
|
||||
});
|
||||
} else if (behavior.type === 'network-error') {
|
||||
// Simulate network error during connection
|
||||
setImmediate(() => {
|
||||
console.log(`RetryMockWebSocket: triggering network error: ${behavior.message}`);
|
||||
if (this.eventListeners.has('error')) {
|
||||
const error = new Error(behavior.message || 'Network error');
|
||||
// Set proper error code for retry policy checking
|
||||
if (behavior.message && behavior.message.includes('Connection refused')) {
|
||||
error.code = 'ECONNREFUSED';
|
||||
} else if (behavior.message && behavior.message.includes('timeout')) {
|
||||
error.code = 'ETIMEDOUT';
|
||||
} else {
|
||||
error.code = 'ECONNREFUSED'; // Default for network errors
|
||||
}
|
||||
this.eventListeners.get('error')(error);
|
||||
}
|
||||
});
|
||||
} else if (behavior.type === 'success') {
|
||||
// Successful connection
|
||||
console.log(`RetryMockWebSocket: triggering success`);
|
||||
this.simulateOpen();
|
||||
}
|
||||
}
|
||||
|
||||
simulateOpen() {
|
||||
setImmediate(() => {
|
||||
if (this.eventListeners.has('open')) {
|
||||
console.log(`RetryMockWebSocket: calling open listener`);
|
||||
this.eventListeners.get('open')();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
once(event, listener) {
|
||||
console.log(`RetryMockWebSocket: registering once listener for ${event}`);
|
||||
this.eventListeners.set(event, listener);
|
||||
return this;
|
||||
}
|
||||
|
||||
on(event, listener) {
|
||||
console.log(`RetryMockWebSocket: registering on listener for ${event}`);
|
||||
this.eventListeners.set(event, listener);
|
||||
return this;
|
||||
}
|
||||
|
||||
removeAllListeners() {
|
||||
this.eventListeners.clear();
|
||||
}
|
||||
|
||||
send(data, callback) {
|
||||
// For successful connections, simulate message response
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
console.log({json}, 'RetryMockWebSocket: got message from ws-requestor');
|
||||
|
||||
// Simulate successful response
|
||||
setTimeout(() => {
|
||||
const msg = {
|
||||
type: 'ack',
|
||||
msgid: json.msgid,
|
||||
command: 'command',
|
||||
call_sid: json.call_sid,
|
||||
queueCommand: false,
|
||||
data: '[{"verb": "play","url": "silence_stream://5000"}]'
|
||||
};
|
||||
console.log({msg}, 'RetryMockWebSocket: sending ack to ws-requestor');
|
||||
this.mockOnMessage(JSON.stringify(msg));
|
||||
}, 50);
|
||||
|
||||
if (callback) callback();
|
||||
} catch (err) {
|
||||
console.error('RetryMockWebSocket: Error processing send', err);
|
||||
if (callback) callback(err);
|
||||
}
|
||||
}
|
||||
|
||||
mockOnMessage(message, isBinary = false) {
|
||||
if (this.eventListeners.has('message')) {
|
||||
this.eventListeners.get('message')(message, isBinary);
|
||||
}
|
||||
}
|
||||
|
||||
close(code) {
|
||||
if (this.eventListeners.has('close')) {
|
||||
this.eventListeners.get('close')(code || 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BaseRequestor = proxyquire('../lib/utils/base-requestor', {
|
||||
'../../': {
|
||||
srf: {
|
||||
locals: {
|
||||
stats: {
|
||||
histogram: () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'@jambonz/time-series': sinon.stub(),
|
||||
});
|
||||
|
||||
const WsRequestor = proxyquire('../lib/utils/ws-requestor', {
|
||||
'./base-requestor': BaseRequestor,
|
||||
ws: RetryMockWebSocket,
|
||||
});
|
||||
|
||||
test('ws retry policy - 4xx error with rp=5xx should not retry', async(t) => {
|
||||
// GIVEN
|
||||
console.log('Starting test setup...');
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const call_sid = 'ws_no_retry_4xx';
|
||||
|
||||
// Set up the URL mapping
|
||||
const cleanUrl = 'ws://localhost:3000';
|
||||
const originalUrl = 'ws://localhost:3000#rc=2&rp=5xx';
|
||||
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
|
||||
|
||||
// Set up the retry scenario for the first attempt to fail with 400, but policy only retries 5xx
|
||||
RetryMockWebSocket.setRetryScenario('rc=2&rp=5xx', {
|
||||
attempts: [
|
||||
{ type: 'handshake-failure', statusCode: 400, statusMessage: 'Bad Request' }
|
||||
]
|
||||
});
|
||||
|
||||
const hook = {
|
||||
url: 'ws://localhost:3000#rc=2&rp=5xx', // Max 2 retries, retry only on 5xx
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: call_sid,
|
||||
};
|
||||
|
||||
// WHEN
|
||||
const requestor = new WsRequestor(
|
||||
logger,
|
||||
'account_sid',
|
||||
hook,
|
||||
'webhook_secret'
|
||||
);
|
||||
try {
|
||||
const result = await requestor.request('session:new', hook, params, {});
|
||||
t.fail('Should have thrown an error');
|
||||
t.end();
|
||||
} catch (err) {
|
||||
// THEN
|
||||
const errorMessage = err.message || err.toString() || String(err);
|
||||
t.ok(
|
||||
errorMessage.includes('400'),
|
||||
`ws properly failed without retry for 4xx when rp=5xx - error: ${errorMessage}`
|
||||
);
|
||||
t.end();
|
||||
}
|
||||
});
|
||||
|
||||
test('ws retry policy - 5xx error with rp=5xx should retry and succeed', async(t) => {
|
||||
// GIVEN
|
||||
console.log('Starting 5xx retry test setup...');
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const call_sid = 'ws_retry_5xx_success';
|
||||
|
||||
// Set up the URL mapping
|
||||
const cleanUrl = 'ws://localhost:3000';
|
||||
const originalUrl = 'ws://localhost:3000#rc=2&rp=5xx';
|
||||
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
|
||||
|
||||
// Set up the retry scenario - first attempt fails with 500, second succeeds
|
||||
RetryMockWebSocket.setRetryScenario('rc=2&rp=5xx', {
|
||||
attempts: [
|
||||
{ type: 'handshake-failure', statusCode: 500, statusMessage: 'Internal Server Error' },
|
||||
{ type: 'success' }
|
||||
]
|
||||
});
|
||||
|
||||
const hook = {
|
||||
url: 'ws://localhost:3000#rc=2&rp=5xx', // Max 2 retries, retry only on 5xx
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: call_sid,
|
||||
};
|
||||
|
||||
// WHEN
|
||||
const requestor = new WsRequestor(
|
||||
logger,
|
||||
'account_sid',
|
||||
hook,
|
||||
'webhook_secret'
|
||||
);
|
||||
try {
|
||||
const result = await requestor.request('session:new', hook, params, {});
|
||||
|
||||
// THEN
|
||||
t.ok(result, 'ws successfully retried and connected after 5xx error');
|
||||
|
||||
// Verify that exactly 2 attempts were made
|
||||
const attempts = RetryMockWebSocket.getConnectionAttempts('rc=2&rp=5xx');
|
||||
t.equal(attempts, 2, 'Should have made exactly 2 connection attempts');
|
||||
|
||||
t.end();
|
||||
} catch (err) {
|
||||
t.fail(`Should have succeeded after retry - error: ${err.message}`);
|
||||
t.end();
|
||||
}
|
||||
});
|
||||
|
||||
test('ws retry policy - network error with rp=ct should retry and succeed', async(t) => {
|
||||
// GIVEN
|
||||
console.log('Starting network error retry test setup...');
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const call_sid = 'ws_retry_network_success';
|
||||
|
||||
// Set up the URL mapping
|
||||
const cleanUrl = 'ws://localhost:3000';
|
||||
const originalUrl = 'ws://localhost:3000#rc=3&rp=ct';
|
||||
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
|
||||
|
||||
// Set up the retry scenario - first two attempts fail with network error, third succeeds
|
||||
RetryMockWebSocket.setRetryScenario('rc=3&rp=ct', {
|
||||
attempts: [
|
||||
{ type: 'network-error', message: 'Connection refused' },
|
||||
{ type: 'network-error', message: 'Connection refused' },
|
||||
{ type: 'success' }
|
||||
]
|
||||
});
|
||||
|
||||
const hook = {
|
||||
url: 'ws://localhost:3000#rc=3&rp=ct', // Max 3 retries, retry on connection errors
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: call_sid,
|
||||
};
|
||||
|
||||
// WHEN
|
||||
const requestor = new WsRequestor(
|
||||
logger,
|
||||
'account_sid',
|
||||
hook,
|
||||
'webhook_secret'
|
||||
);
|
||||
try {
|
||||
const result = await requestor.request('session:new', hook, params, {});
|
||||
|
||||
// THEN
|
||||
t.ok(result, 'ws successfully retried and connected after network errors');
|
||||
|
||||
// Verify that exactly 3 attempts were made
|
||||
const attempts = RetryMockWebSocket.getConnectionAttempts('rc=3&rp=ct');
|
||||
t.equal(attempts, 3, 'Should have made exactly 3 connection attempts');
|
||||
|
||||
t.end();
|
||||
} catch (err) {
|
||||
t.fail(`Should have succeeded after retry - error: ${err.message}`);
|
||||
t.end();
|
||||
}
|
||||
});
|
||||
|
||||
test('ws retry policy - retry exhaustion should fail with last error', async(t) => {
|
||||
// GIVEN
|
||||
console.log('Starting retry exhaustion test setup...');
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const call_sid = 'ws_retry_exhaustion';
|
||||
|
||||
// Set up the URL mapping
|
||||
const cleanUrl = 'ws://localhost:3000';
|
||||
const originalUrl = 'ws://localhost:3000#rc=2&rp=5xx';
|
||||
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
|
||||
|
||||
// Set up the retry scenario - all attempts fail with 500
|
||||
RetryMockWebSocket.setRetryScenario('rc=2&rp=5xx', {
|
||||
attempts: [
|
||||
{ type: 'handshake-failure', statusCode: 500, statusMessage: 'Internal Server Error' },
|
||||
{ type: 'handshake-failure', statusCode: 500, statusMessage: 'Internal Server Error' },
|
||||
{ type: 'handshake-failure', statusCode: 500, statusMessage: 'Internal Server Error' }
|
||||
]
|
||||
});
|
||||
|
||||
const hook = {
|
||||
url: 'ws://localhost:3000#rc=2&rp=5xx', // Max 2 retries, retry only on 5xx
|
||||
username: 'username',
|
||||
password: 'password',
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: call_sid,
|
||||
};
|
||||
|
||||
// WHEN
|
||||
const requestor = new WsRequestor(
|
||||
logger,
|
||||
'account_sid',
|
||||
hook,
|
||||
'webhook_secret'
|
||||
);
|
||||
try {
|
||||
const result = await requestor.request('session:new', hook, params, {});
|
||||
t.fail('Should have thrown an error after exhausting retries');
|
||||
t.end();
|
||||
} catch (err) {
|
||||
// THEN
|
||||
const errorMessage = err.message || err.toString() || String(err);
|
||||
t.ok(
|
||||
errorMessage.includes('500'),
|
||||
`ws properly failed after exhausting retries - error: ${errorMessage}`
|
||||
);
|
||||
|
||||
// Verify that exactly 3 attempts were made (initial + 2 retries)
|
||||
const attempts = RetryMockWebSocket.getConnectionAttempts('rc=2&rp=5xx');
|
||||
t.equal(attempts, 3, 'Should have made exactly 3 connection attempts (initial + 2 retries)');
|
||||
|
||||
t.end();
|
||||
}
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
const sinon = require('sinon');
|
||||
|
||||
/**
|
||||
* Creates mock objects commonly needed for testing HttpRequestor and related classes
|
||||
* @returns {Object} Mock objects
|
||||
*/
|
||||
const createMocks = () => {
|
||||
// Basic logger mock
|
||||
const MockLogger = {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
error: () => {}
|
||||
};
|
||||
|
||||
// Stats mock
|
||||
const MockStats = {
|
||||
histogram: () => {}
|
||||
};
|
||||
|
||||
// Alerter mock
|
||||
const MockAlerter = {
|
||||
AlertType: {
|
||||
WEBHOOK_CONNECTION_FAILURE: 'WEBHOOK_CONNECTION_FAILURE',
|
||||
WEBHOOK_STATUS_FAILURE: 'WEBHOOK_STATUS_FAILURE'
|
||||
},
|
||||
writeAlerts: async () => {}
|
||||
};
|
||||
|
||||
// DB helpers mock
|
||||
const MockDbHelpers = {
|
||||
pool: {
|
||||
getConnection: () => Promise.resolve({
|
||||
connect: () => {},
|
||||
on: () => {},
|
||||
query: (sql, cb) => {
|
||||
if (typeof cb === 'function') cb(null, []);
|
||||
return { stream: () => ({ on: () => {} }) };
|
||||
},
|
||||
end: () => {}
|
||||
}),
|
||||
query: (...args) => {
|
||||
const cb = args[args.length - 1];
|
||||
if (typeof cb === 'function') cb(null, []);
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
},
|
||||
camelize: (obj) => obj
|
||||
};
|
||||
|
||||
// Time series mock
|
||||
const MockTimeSeries = () => ({
|
||||
writeAlerts: async () => {},
|
||||
AlertType: {
|
||||
WEBHOOK_CONNECTION_FAILURE: 'WEBHOOK_CONNECTION_FAILURE',
|
||||
WEBHOOK_STATUS_FAILURE: 'WEBHOOK_STATUS_FAILURE'
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
MockLogger,
|
||||
MockStats,
|
||||
MockAlerter,
|
||||
MockDbHelpers,
|
||||
MockTimeSeries
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Set up mocks on the BaseRequestor class for tests
|
||||
* @param {Object} BaseRequestor - The BaseRequestor class
|
||||
*/
|
||||
const setupBaseRequestorMocks = (BaseRequestor) => {
|
||||
BaseRequestor.prototype._isAbsoluteUrl = function(url) { return url.startsWith('http'); };
|
||||
BaseRequestor.prototype._isRelativeUrl = function(url) { return !url.startsWith('http'); };
|
||||
BaseRequestor.prototype._generateSigHeader = function() { return { 'X-Signature': 'test-signature' }; };
|
||||
BaseRequestor.prototype._roundTrip = function() { return 10; };
|
||||
|
||||
// Define baseUrl property
|
||||
Object.defineProperty(BaseRequestor.prototype, 'baseUrl', {
|
||||
get: function() { return 'http://localhost'; }
|
||||
});
|
||||
|
||||
// Define Alerter property
|
||||
const mocks = createMocks();
|
||||
Object.defineProperty(BaseRequestor.prototype, 'Alerter', {
|
||||
get: function() { return mocks.MockAlerter; }
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Clean up after tests
|
||||
* @param {Object} requestor - The requestor instance to clean up
|
||||
*/
|
||||
const cleanup = (requestor) => {
|
||||
sinon.restore();
|
||||
if (requestor && requestor.close) requestor.close();
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createMocks,
|
||||
setupBaseRequestorMocks,
|
||||
cleanup
|
||||
};
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* Common test mocks for Jambonz tests
|
||||
*/
|
||||
const proxyquire = require('proxyquire').noCallThru();
|
||||
|
||||
// Logger mock
|
||||
class MockLogger {
|
||||
debug() {}
|
||||
info() {}
|
||||
error() {}
|
||||
}
|
||||
|
||||
// Stats mock
|
||||
const statsMock = { histogram: () => {} };
|
||||
|
||||
// Time series mock
|
||||
const timeSeriesMock = () => ({
|
||||
writeAlerts: async () => {},
|
||||
AlertType: {
|
||||
WEBHOOK_CONNECTION_FAILURE: 'WEBHOOK_CONNECTION_FAILURE',
|
||||
WEBHOOK_STATUS_FAILURE: 'WEBHOOK_STATUS_FAILURE'
|
||||
}
|
||||
});
|
||||
|
||||
// DB helpers mock
|
||||
const dbHelpersMock = {
|
||||
pool: {
|
||||
getConnection: () => Promise.resolve({
|
||||
connect: () => {},
|
||||
on: () => {},
|
||||
query: (sql, cb) => {
|
||||
if (typeof cb === 'function') cb(null, []);
|
||||
return { stream: () => ({ on: () => {} }) };
|
||||
},
|
||||
end: () => {}
|
||||
}),
|
||||
query: (...args) => {
|
||||
const cb = args[args.length - 1];
|
||||
if (typeof cb === 'function') cb(null, []);
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
},
|
||||
camelize: (obj) => obj
|
||||
};
|
||||
|
||||
// Config mock
|
||||
const configMock = {
|
||||
HTTP_POOL: '0',
|
||||
HTTP_POOLSIZE: '10',
|
||||
HTTP_PIPELINING: '1',
|
||||
HTTP_TIMEOUT: 5000,
|
||||
HTTP_PROXY_IP: null,
|
||||
HTTP_PROXY_PORT: null,
|
||||
HTTP_PROXY_PROTOCOL: null,
|
||||
NODE_ENV: 'test',
|
||||
HTTP_USER_AGENT_HEADER: 'test-agent',
|
||||
JAMBONES_TIME_SERIES_HOST: 'localhost'
|
||||
};
|
||||
|
||||
// SRF mock
|
||||
const srfMock = {
|
||||
srf: {
|
||||
locals: {
|
||||
stats: statsMock
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Alerter mock
|
||||
const alerterMock = {
|
||||
AlertType: {
|
||||
WEBHOOK_CONNECTION_FAILURE: 'WEBHOOK_CONNECTION_FAILURE',
|
||||
WEBHOOK_STATUS_FAILURE: 'WEBHOOK_STATUS_FAILURE'
|
||||
},
|
||||
writeAlerts: async () => {}
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates mocked BaseRequestor and HttpRequestor classes
|
||||
* @returns {Object} Mocked classes and helper functions
|
||||
*/
|
||||
function createMockedRequestors() {
|
||||
// First, mock BaseRequestor's dependencies
|
||||
const BaseRequestor = proxyquire('../../lib/utils/base-requestor', {
|
||||
'@jambonz/time-series': timeSeriesMock,
|
||||
'../config': configMock,
|
||||
'../../': srfMock
|
||||
});
|
||||
|
||||
// Apply prototype methods and properties
|
||||
BaseRequestor.prototype._isAbsoluteUrl = function(url) { return url.startsWith('http'); };
|
||||
BaseRequestor.prototype._isRelativeUrl = function(url) { return !url.startsWith('http'); };
|
||||
BaseRequestor.prototype._generateSigHeader = function() { return { 'X-Signature': 'test-signature' }; };
|
||||
BaseRequestor.prototype._roundTrip = function() { return 10; };
|
||||
|
||||
// Define baseUrl property
|
||||
Object.defineProperty(BaseRequestor.prototype, 'baseUrl', {
|
||||
get: function() { return 'http://localhost'; }
|
||||
});
|
||||
|
||||
// Define Alerter property
|
||||
Object.defineProperty(BaseRequestor.prototype, 'Alerter', {
|
||||
get: function() { return alerterMock; }
|
||||
});
|
||||
|
||||
// Then mock HttpRequestor with the mocked BaseRequestor
|
||||
const HttpRequestor = proxyquire('../../lib/utils/http-requestor', {
|
||||
'./base-requestor': BaseRequestor,
|
||||
'../config': configMock,
|
||||
'@jambonz/db-helpers': dbHelpersMock
|
||||
});
|
||||
|
||||
// Setup function to create a clean requestor for each test
|
||||
const setupRequestor = () => {
|
||||
const logger = new MockLogger();
|
||||
const hook = { url: 'http://localhost/test', method: 'POST' };
|
||||
const secret = 'testsecret';
|
||||
return new HttpRequestor(logger, 'AC123', hook, secret);
|
||||
};
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = (requestor) => {
|
||||
const sinon = require('sinon');
|
||||
sinon.restore();
|
||||
if (requestor && requestor.close) requestor.close();
|
||||
};
|
||||
|
||||
return {
|
||||
BaseRequestor,
|
||||
HttpRequestor,
|
||||
setupRequestor,
|
||||
cleanup,
|
||||
mocks: {
|
||||
logger: new MockLogger(),
|
||||
stats: statsMock,
|
||||
timeSeries: timeSeriesMock,
|
||||
dbHelpers: dbHelpersMock,
|
||||
config: configMock,
|
||||
srf: srfMock,
|
||||
alerter: alerterMock
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createMockedRequestors,
|
||||
MockLogger,
|
||||
statsMock,
|
||||
timeSeriesMock,
|
||||
dbHelpersMock,
|
||||
configMock,
|
||||
srfMock,
|
||||
alerterMock
|
||||
};
|
||||
@@ -99,24 +99,6 @@ app.post('/actionHook', (req, res) => {
|
||||
return res.sendStatus(200);
|
||||
});
|
||||
|
||||
/*
|
||||
* referHook
|
||||
*/
|
||||
app.post('/referHook', (req, res) => {
|
||||
console.log({payload: req.body}, 'POST /referHook');
|
||||
let key = req.body.from + "_referHook"
|
||||
addRequestToMap(key, req, hook_mapping);
|
||||
return res.json([{"verb": "pause", "length": 2}]);
|
||||
});
|
||||
|
||||
/*
|
||||
* adultingHook
|
||||
*/
|
||||
app.post('/adulting', (req, res) => {
|
||||
console.log({payload: req.body}, 'POST /adulting');
|
||||
return res.sendStatus(200);
|
||||
});
|
||||
|
||||
/*
|
||||
* customHook
|
||||
* For the hook to return
|
||||
|
||||
@@ -1,605 +0,0 @@
|
||||
const test = require('tape');
|
||||
const sinon = require('sinon');
|
||||
const proxyquire = require("proxyquire");
|
||||
proxyquire.noCallThru();
|
||||
|
||||
const {
|
||||
JAMBONES_LOGLEVEL,
|
||||
} = require('../lib/config');
|
||||
const logger = require('pino')({level: JAMBONES_LOGLEVEL});
|
||||
|
||||
// Mock WebSocket specifically for retry testing
|
||||
class RetryMockWebSocket {
|
||||
static retryScenarios = new Map();
|
||||
static connectionAttempts = new Map();
|
||||
static urlMapping = new Map(); // Maps cleanUrl -> originalUrl
|
||||
|
||||
constructor(url, protocols, options) {
|
||||
this.url = url;
|
||||
this.protocols = protocols;
|
||||
this.options = options;
|
||||
this.eventListeners = new Map();
|
||||
|
||||
// Extract scenario key from URL hash or use URL itself
|
||||
this.scenarioKey = this.extractScenarioKey(url);
|
||||
|
||||
// Track connection attempts for this scenario
|
||||
const attempts = RetryMockWebSocket.connectionAttempts.get(this.scenarioKey) || 0;
|
||||
RetryMockWebSocket.connectionAttempts.set(this.scenarioKey, attempts + 1);
|
||||
|
||||
// Handle connection immediately
|
||||
setImmediate(() => {
|
||||
this.handleConnection();
|
||||
});
|
||||
}
|
||||
|
||||
extractScenarioKey(url) {
|
||||
console.log(`RetryMockWebSocket: extractScenarioKey from URL: ${url}`);
|
||||
|
||||
// Check if we have a mapping from cleanUrl to originalUrl
|
||||
const originalUrl = RetryMockWebSocket.urlMapping.get(url);
|
||||
if (originalUrl && originalUrl.includes('#')) {
|
||||
const hash = originalUrl.split('#')[1];
|
||||
console.log(`RetryMockWebSocket: found mapped URL with hash: ${hash}`);
|
||||
return hash;
|
||||
}
|
||||
|
||||
// For URLs with hash parameters, use the hash as the scenario key
|
||||
if (url.includes('#')) {
|
||||
const hash = url.split('#')[1];
|
||||
console.log(`RetryMockWebSocket: found hash: ${hash}`);
|
||||
return hash; // Use hash as scenario key
|
||||
}
|
||||
|
||||
console.log(`RetryMockWebSocket: using full URL as scenario key: ${url}`);
|
||||
return url; // Fallback to full URL
|
||||
}
|
||||
|
||||
static setRetryScenario(key, scenario) {
|
||||
RetryMockWebSocket.retryScenarios.set(key, scenario);
|
||||
}
|
||||
|
||||
static setUrlMapping(cleanUrl, originalUrl) {
|
||||
RetryMockWebSocket.urlMapping.set(cleanUrl, originalUrl);
|
||||
}
|
||||
|
||||
static clearScenarios() {
|
||||
RetryMockWebSocket.retryScenarios.clear();
|
||||
RetryMockWebSocket.connectionAttempts.clear();
|
||||
RetryMockWebSocket.urlMapping.clear();
|
||||
}
|
||||
|
||||
static getConnectionAttempts(key) {
|
||||
return RetryMockWebSocket.connectionAttempts.get(key) || 0;
|
||||
}
|
||||
|
||||
handleConnection() {
|
||||
const scenario = RetryMockWebSocket.retryScenarios.get(this.scenarioKey);
|
||||
console.log(`RetryMockWebSocket: handleConnection for scenarioKey="${this.scenarioKey}", scenario found:`, !!scenario);
|
||||
|
||||
if (!scenario) {
|
||||
// Default successful connection
|
||||
this.simulateOpen();
|
||||
return;
|
||||
}
|
||||
|
||||
const attemptNumber = RetryMockWebSocket.connectionAttempts.get(this.scenarioKey);
|
||||
const behavior = scenario.attempts[attemptNumber - 1] || scenario.attempts[scenario.attempts.length - 1];
|
||||
|
||||
console.log(`RetryMockWebSocket: attempt ${attemptNumber}, behavior:`, behavior);
|
||||
|
||||
if (behavior.type === 'handshake-failure') {
|
||||
// Simulate handshake failure with specific status code
|
||||
setImmediate(() => {
|
||||
console.log(`RetryMockWebSocket: triggering handshake failure with status ${behavior.statusCode}`);
|
||||
if (this.eventListeners.has('unexpected-response')) {
|
||||
const mockResponse = {
|
||||
statusCode: behavior.statusCode || 500,
|
||||
statusMessage: behavior.statusMessage || 'Internal Server Error',
|
||||
headers: {}
|
||||
};
|
||||
const mockRequest = {
|
||||
headers: {}
|
||||
};
|
||||
this.eventListeners.get('unexpected-response')(mockRequest, mockResponse);
|
||||
}
|
||||
});
|
||||
} else if (behavior.type === 'network-error') {
|
||||
// Simulate network error during connection
|
||||
setImmediate(() => {
|
||||
console.log(`RetryMockWebSocket: triggering network error: ${behavior.message}`);
|
||||
if (this.eventListeners.has('error')) {
|
||||
const err = new Error(behavior.message || 'Network error');
|
||||
// Set appropriate error codes based on the message
|
||||
if (behavior.message === 'Connection timeout') {
|
||||
err.code = 'ETIMEDOUT';
|
||||
} else if (behavior.message === 'Connection refused') {
|
||||
err.code = 'ECONNREFUSED';
|
||||
} else if (behavior.message === 'Connection reset') {
|
||||
err.code = 'ECONNRESET';
|
||||
} else {
|
||||
// Default to ECONNREFUSED for generic network errors
|
||||
err.code = 'ECONNREFUSED';
|
||||
}
|
||||
this.eventListeners.get('error')(err);
|
||||
}
|
||||
});
|
||||
} else if (behavior.type === 'success') {
|
||||
// Successful connection
|
||||
console.log(`RetryMockWebSocket: triggering success`);
|
||||
this.simulateOpen();
|
||||
}
|
||||
}
|
||||
|
||||
simulateOpen() {
|
||||
setImmediate(() => {
|
||||
if (this.eventListeners.has('open')) {
|
||||
this.eventListeners.get('open')();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
once(event, listener) {
|
||||
this.eventListeners.set(event, listener);
|
||||
return this;
|
||||
}
|
||||
|
||||
on(event, listener) {
|
||||
this.eventListeners.set(event, listener);
|
||||
return this;
|
||||
}
|
||||
|
||||
removeAllListeners() {
|
||||
this.eventListeners.clear();
|
||||
}
|
||||
|
||||
send(data, callback) {
|
||||
// For successful connections, simulate message response
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
console.log({json}, 'RetryMockWebSocket: got message from ws-requestor');
|
||||
|
||||
// Simulate successful response
|
||||
setTimeout(() => {
|
||||
const msg = {
|
||||
type: 'ack',
|
||||
msgid: json.msgid,
|
||||
command: 'command',
|
||||
call_sid: json.call_sid,
|
||||
queueCommand: false,
|
||||
data: '[{"verb": "play","url": "silence_stream://5000"}]'
|
||||
};
|
||||
console.log({msg}, 'RetryMockWebSocket: sending ack to ws-requestor');
|
||||
this.mockOnMessage(JSON.stringify(msg));
|
||||
}, 50);
|
||||
|
||||
if (callback) callback();
|
||||
} catch (err) {
|
||||
console.error('RetryMockWebSocket: Error processing send', err);
|
||||
if (callback) callback(err);
|
||||
}
|
||||
}
|
||||
|
||||
mockOnMessage(message, isBinary = false) {
|
||||
if (this.eventListeners.has('message')) {
|
||||
this.eventListeners.get('message')(message, isBinary);
|
||||
}
|
||||
}
|
||||
|
||||
close(code) {
|
||||
if (this.eventListeners.has('close')) {
|
||||
this.eventListeners.get('close')(code || 1000);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const BaseRequestor = proxyquire(
|
||||
"../lib/utils/base-requestor",
|
||||
{
|
||||
"../../": {
|
||||
srf: {
|
||||
locals: {
|
||||
stats: {
|
||||
histogram: () => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@jambonz/time-series": sinon.stub()
|
||||
}
|
||||
);
|
||||
|
||||
const WsRequestor = proxyquire(
|
||||
"../lib/utils/ws-requestor",
|
||||
{
|
||||
"./base-requestor": BaseRequestor,
|
||||
"ws": RetryMockWebSocket
|
||||
}
|
||||
);
|
||||
|
||||
test('WS Retry - 4xx error with rp=4xx should retry and succeed', async (t) => {
|
||||
// GIVEN
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const originalUrl = 'ws://localhost:3000#rc=2&rp=4xx';
|
||||
const cleanUrl = 'ws://localhost:3000';
|
||||
|
||||
// Set up URL mapping so mock can find the right scenario
|
||||
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
|
||||
|
||||
const retryScenario = {
|
||||
attempts: [
|
||||
{ type: 'handshake-failure', statusCode: 400, statusMessage: 'Bad Request' },
|
||||
{ type: 'success' }
|
||||
]
|
||||
};
|
||||
RetryMockWebSocket.setRetryScenario('rc=2&rp=4xx', retryScenario);
|
||||
|
||||
const hook = {
|
||||
url: originalUrl,
|
||||
username: 'username',
|
||||
password: 'password'
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: 'test_4xx_retry'
|
||||
};
|
||||
|
||||
// WHEN
|
||||
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
|
||||
const result = await requestor.request('session:new', hook, params, {});
|
||||
|
||||
// THEN
|
||||
t.ok(result, 'ws successfully retried after 4xx error and got response');
|
||||
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=4xx'), 2, 'should have made 2 connection attempts');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('WS Retry - 4xx error with rp=5xx should not retry', async (t) => {
|
||||
// GIVEN
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const originalUrl = 'ws://localhost:3000#rc=2&rp=5xx';
|
||||
const cleanUrl = 'ws://localhost:3000';
|
||||
|
||||
// Set up URL mapping so mock can find the right scenario
|
||||
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
|
||||
|
||||
const retryScenario = {
|
||||
attempts: [
|
||||
{ type: 'handshake-failure', statusCode: 400, statusMessage: 'Bad Request' }
|
||||
]
|
||||
};
|
||||
RetryMockWebSocket.setRetryScenario('rc=2&rp=5xx', retryScenario);
|
||||
|
||||
const hook = {
|
||||
url: originalUrl,
|
||||
username: 'username',
|
||||
password: 'password'
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: 'test_4xx_no_retry'
|
||||
};
|
||||
|
||||
// WHEN & THEN
|
||||
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
|
||||
try {
|
||||
await requestor.request('session:new', hook, params, {});
|
||||
t.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
const errorMessage = err.message || err.toString() || String(err);
|
||||
t.ok(errorMessage.includes('400'), 'ws properly failed without retry for 4xx when rp=5xx');
|
||||
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=5xx'), 1, 'should have made only 1 connection attempt');
|
||||
t.end();
|
||||
}
|
||||
});
|
||||
|
||||
test('WS Retry - 5xx error with rp=5xx should retry and succeed', async (t) => {
|
||||
// GIVEN
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const originalUrl = 'ws://localhost:3000#rc=2&rp=5xx';
|
||||
const cleanUrl = 'ws://localhost:3000';
|
||||
|
||||
// Set up URL mapping so mock can find the right scenario
|
||||
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
|
||||
|
||||
const retryScenario = {
|
||||
attempts: [
|
||||
{ type: 'handshake-failure', statusCode: 503, statusMessage: 'Service Unavailable' },
|
||||
{ type: 'success' }
|
||||
]
|
||||
};
|
||||
RetryMockWebSocket.setRetryScenario('rc=2&rp=5xx', retryScenario);
|
||||
|
||||
const hook = {
|
||||
url: originalUrl,
|
||||
username: 'username',
|
||||
password: 'password'
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: 'test_5xx_retry'
|
||||
};
|
||||
|
||||
// WHEN
|
||||
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
|
||||
const result = await requestor.request('session:new', hook, params, {});
|
||||
|
||||
// THEN
|
||||
t.ok(result, 'ws successfully retried after 5xx error and got response');
|
||||
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=5xx'), 2, 'should have made 2 connection attempts');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('WS Retry - 5xx error with rp=4xx should not retry', async (t) => {
|
||||
// GIVEN
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const originalUrl = 'ws://localhost:3000#rc=2&rp=4xx';
|
||||
const cleanUrl = 'ws://localhost:3000';
|
||||
|
||||
// Set up URL mapping so mock can find the right scenario
|
||||
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
|
||||
|
||||
const retryScenario = {
|
||||
attempts: [
|
||||
{ type: 'handshake-failure', statusCode: 503, statusMessage: 'Service Unavailable' }
|
||||
]
|
||||
};
|
||||
RetryMockWebSocket.setRetryScenario('rc=2&rp=4xx', retryScenario);
|
||||
|
||||
const hook = {
|
||||
url: originalUrl,
|
||||
username: 'username',
|
||||
password: 'password'
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: 'test_5xx_no_retry'
|
||||
};
|
||||
|
||||
// WHEN & THEN
|
||||
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
|
||||
try {
|
||||
await requestor.request('session:new', hook, params, {});
|
||||
t.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
const errorMessage = err.message || err.toString() || String(err);
|
||||
t.ok(errorMessage.includes('503'), 'ws properly failed without retry for 5xx when rp=4xx');
|
||||
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=4xx'), 1, 'should have made only 1 connection attempt');
|
||||
t.end();
|
||||
}
|
||||
});
|
||||
|
||||
test('WS Retry - network error with rp=all should retry and succeed', async (t) => {
|
||||
// GIVEN
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const originalUrl = 'ws://localhost:3000#rc=2&rp=all';
|
||||
const cleanUrl = 'ws://localhost:3000';
|
||||
|
||||
// Set up URL mapping so mock can find the right scenario
|
||||
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
|
||||
|
||||
const retryScenario = {
|
||||
attempts: [
|
||||
{ type: 'network-error', message: 'Connection refused' },
|
||||
{ type: 'success' }
|
||||
]
|
||||
};
|
||||
RetryMockWebSocket.setRetryScenario('rc=2&rp=all', retryScenario);
|
||||
|
||||
const hook = {
|
||||
url: originalUrl,
|
||||
username: 'username',
|
||||
password: 'password'
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: 'test_network_retry'
|
||||
};
|
||||
|
||||
// WHEN
|
||||
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
|
||||
const result = await requestor.request('session:new', hook, params, {});
|
||||
|
||||
// THEN
|
||||
t.ok(result, 'ws successfully retried after network error and got response');
|
||||
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=all'), 2, 'should have made 2 connection attempts');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('WS Retry - network error with rp=4xx should not retry', async (t) => {
|
||||
// GIVEN
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const originalUrl = 'ws://localhost:3000#rc=2&rp=4xx';
|
||||
const cleanUrl = 'ws://localhost:3000';
|
||||
|
||||
// Set up URL mapping so mock can find the right scenario
|
||||
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
|
||||
|
||||
const retryScenario = {
|
||||
attempts: [
|
||||
{ type: 'network-error', message: 'Connection refused' }
|
||||
]
|
||||
};
|
||||
RetryMockWebSocket.setRetryScenario('rc=2&rp=4xx', retryScenario);
|
||||
|
||||
const hook = {
|
||||
url: originalUrl,
|
||||
username: 'username',
|
||||
password: 'password'
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: 'test_network_no_retry'
|
||||
};
|
||||
|
||||
// WHEN & THEN
|
||||
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
|
||||
try {
|
||||
await requestor.request('session:new', hook, params, {});
|
||||
t.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
const errorMessage = err.message || err.toString() || String(err);
|
||||
t.ok(errorMessage.includes('Connection refused') || errorMessage.includes('Error'),
|
||||
'ws properly failed without retry for network error when rp=4xx');
|
||||
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=4xx'), 1, 'should have made only 1 connection attempt');
|
||||
t.end();
|
||||
}
|
||||
});
|
||||
|
||||
test('WS Retry - multiple retries then success', async (t) => {
|
||||
// GIVEN
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const originalUrl = 'ws://localhost:3000#rc=4&rp=all';
|
||||
const cleanUrl = 'ws://localhost:3000';
|
||||
|
||||
// Set up URL mapping so mock can find the right scenario
|
||||
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
|
||||
|
||||
const retryScenario = {
|
||||
attempts: [
|
||||
{ type: 'handshake-failure', statusCode: 503, statusMessage: 'Service Unavailable' },
|
||||
{ type: 'network-error', message: 'Connection timeout' },
|
||||
{ type: 'handshake-failure', statusCode: 502, statusMessage: 'Bad Gateway' },
|
||||
{ type: 'success' }
|
||||
]
|
||||
};
|
||||
RetryMockWebSocket.setRetryScenario('rc=4&rp=all', retryScenario);
|
||||
|
||||
const hook = {
|
||||
url: originalUrl,
|
||||
username: 'username',
|
||||
password: 'password'
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: 'test_multiple_retries'
|
||||
};
|
||||
|
||||
// WHEN
|
||||
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
|
||||
const result = await requestor.request('session:new', hook, params, {});
|
||||
|
||||
// THEN
|
||||
t.ok(result, 'ws successfully retried multiple times and got response');
|
||||
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=4&rp=all'), 4, 'should have made 4 connection attempts');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('WS Retry - exhaust retries and fail', async (t) => {
|
||||
// GIVEN
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const originalUrl = 'ws://localhost:3000#rc=2&rp=5xx';
|
||||
const cleanUrl = 'ws://localhost:3000';
|
||||
|
||||
// Set up URL mapping so mock can find the right scenario
|
||||
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
|
||||
|
||||
const retryScenario = {
|
||||
attempts: [
|
||||
{ type: 'handshake-failure', statusCode: 503, statusMessage: 'Service Unavailable' },
|
||||
{ type: 'handshake-failure', statusCode: 503, statusMessage: 'Service Unavailable' },
|
||||
{ type: 'handshake-failure', statusCode: 503, statusMessage: 'Service Unavailable' }
|
||||
]
|
||||
};
|
||||
RetryMockWebSocket.setRetryScenario('rc=2&rp=5xx', retryScenario);
|
||||
|
||||
const hook = {
|
||||
url: originalUrl,
|
||||
username: 'username',
|
||||
password: 'password'
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: 'test_exhaust_retries'
|
||||
};
|
||||
|
||||
// WHEN & THEN
|
||||
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
|
||||
try {
|
||||
await requestor.request('session:new', hook, params, {});
|
||||
t.fail('Should have thrown an error');
|
||||
} catch (err) {
|
||||
const errorMessage = err.message || err.toString() || String(err);
|
||||
t.ok(errorMessage.includes('503'), 'ws properly failed after exhausting retries');
|
||||
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=5xx'), 3, 'should have made 3 connection attempts (initial + 2 retries)');
|
||||
t.end();
|
||||
}
|
||||
});
|
||||
|
||||
test('WS Retry - rp=ct (connection timeout) should retry network errors', async (t) => {
|
||||
// GIVEN
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const originalUrl = 'ws://localhost:3000#rc=2&rp=ct';
|
||||
const cleanUrl = 'ws://localhost:3000';
|
||||
|
||||
// Set up URL mapping so mock can find the right scenario
|
||||
RetryMockWebSocket.setUrlMapping(cleanUrl, originalUrl);
|
||||
|
||||
const retryScenario = {
|
||||
attempts: [
|
||||
{ type: 'network-error', message: 'Connection timeout' },
|
||||
{ type: 'success' }
|
||||
]
|
||||
};
|
||||
RetryMockWebSocket.setRetryScenario('rc=2&rp=ct', retryScenario);
|
||||
|
||||
const hook = {
|
||||
url: originalUrl,
|
||||
username: 'username',
|
||||
password: 'password'
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: 'test_ct_retry'
|
||||
};
|
||||
|
||||
// WHEN
|
||||
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
|
||||
const result = await requestor.request('session:new', hook, params, {});
|
||||
|
||||
// THEN
|
||||
t.ok(result, 'ws successfully retried connection timeout and got response');
|
||||
t.equal(RetryMockWebSocket.getConnectionAttempts('rc=2&rp=ct'), 2, 'should have made 2 connection attempts');
|
||||
t.end();
|
||||
});
|
||||
|
||||
test('WS Retry - default behavior (no hash params) should use ct policy', async (t) => {
|
||||
// GIVEN
|
||||
RetryMockWebSocket.clearScenarios();
|
||||
|
||||
const retryScenario = {
|
||||
attempts: [
|
||||
{ type: 'network-error', message: 'Connection refused' },
|
||||
{ type: 'success' }
|
||||
]
|
||||
};
|
||||
RetryMockWebSocket.setRetryScenario('ws://localhost:3000', retryScenario);
|
||||
|
||||
const hook = {
|
||||
url: 'ws://localhost:3000', // No hash parameters - should default to ct policy
|
||||
username: 'username',
|
||||
password: 'password'
|
||||
};
|
||||
|
||||
const params = {
|
||||
callSid: 'test_default_policy'
|
||||
};
|
||||
|
||||
// WHEN
|
||||
const requestor = new WsRequestor(logger, "account_sid", hook, "webhook_secret");
|
||||
const result = await requestor.request('session:new', hook, params, {});
|
||||
|
||||
// THEN
|
||||
t.ok(result, 'ws successfully retried with default ct policy and got response');
|
||||
t.equal(RetryMockWebSocket.getConnectionAttempts('ws://localhost:3000'), 2, 'should have made 2 connection attempts');
|
||||
t.end();
|
||||
});
|
||||
@@ -127,8 +127,7 @@ test('ws response error 1000', async (t) => {
|
||||
}
|
||||
catch (err) {
|
||||
// THEN
|
||||
t.ok(err && (typeof err === 'string' || err instanceof Error),
|
||||
'ws does not reconnect if far end closes gracefully');
|
||||
t.ok(err.startsWith('timeout from far end for msgid'), 'ws does not reconnect if far end closes gracefully');
|
||||
t.end();
|
||||
}
|
||||
});
|
||||
@@ -162,8 +161,7 @@ test('ws response error', async (t) => {
|
||||
}
|
||||
catch (err) {
|
||||
// THEN
|
||||
t.ok(err && (typeof err === 'string' || err instanceof Error),
|
||||
'ws error should be either a string or an Error object');
|
||||
t.ok(err.startsWith('timeout from far end for msgid'), 'ws does not reconnect if far end closes gracefully');
|
||||
t.end();
|
||||
}
|
||||
});
|
||||
@@ -197,7 +195,7 @@ test('ws unexpected-response', async (t) => {
|
||||
}
|
||||
catch (err) {
|
||||
// THEN
|
||||
t.ok(err, 'ws properly fails on unexpected response');
|
||||
t.ok(err.code = 'ERR_ASSERTION', 'ws does not reconnect if far end closes gracefully');
|
||||
t.end();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user