Compare commits

..

40 Commits

Author SHA1 Message Date
Dave Horton
4f373d2fbc bump version 2026-03-31 07:43:27 -04:00
Sam Machin
24d9740618 add x-reason to sip-decline (#1518)
* add x-ver to sip-decline

* lint

* Update sip_decline.js

---------

Co-authored-by: Dave Horton <daveh@beachdognet.com>
2026-03-29 16:19:15 -04:00
rhondahollis
39746598b5 add null check for eventHook (#1534)
* add null check for eventHook

* move guard to superclass, remove logging that adds no value

---------

Co-authored-by: rhonda hollis <rhonda@jambonz.org>
Co-authored-by: Dave Horton <daveh@beachdognet.com>
2026-03-29 16:13:35 -04:00
Sam Machin
315eb98d86 add sp_sid to alerts (#1533)
* add sp_sid to alerts

* bump time-series

---------

Co-authored-by: Dave Horton <daveh@beachdognet.com>
2026-03-29 16:07:08 -04:00
Dave Horton
df30496dac fix uncaught exception referencing this.ep in freeswitch hangup scenario (#1532) 2026-03-27 08:31:32 -04:00
Sam Machin
5d6751782a Fix/hangup call (#1530)
* Update error.js

* Update error.js
2026-03-26 08:20:28 -04:00
rhondahollis
6147ec3f6a ensure sbcCallId is added to callInfo (#1529)
Co-authored-by: rhonda hollis <rhonda@jambonz.org>
2026-03-25 17:00:05 -04:00
Ed Robbins
18a13971ca respond to re-INVITE during race condition. (#1527) 2026-03-20 10:41:07 -04:00
Dave Horton
5bd1c53f7d fixed faulty commit https://github.com/jambonz/jambonz-feature-server/pull/1528 2026-03-20 09:17:00 -04:00
Anton Voylenko
5a759791f9 chore: bump node (#1528) 2026-03-20 08:57:16 -04:00
Sam Machin
1f5fa8d49e Update gather.js (#1526) 2026-03-19 15:29:03 -04:00
Anton Voylenko
cf0b392c99 Append sip realm for rest dial (#1525)
* feat: append sip realm for rest dial

* fix: check account sip realm
2026-03-17 07:30:21 -04:00
Anton Voylenko
68339ced0b fix: conference mute and mute status (#1218) 2026-03-12 07:55:49 -04:00
Ed Robbins
1560efaf03 remove sensitive data from log statement (#1522) 2026-03-05 10:04:20 -05:00
Dave Horton
782ce8154e update drachtio-srf (#1521) 2026-03-05 09:33:59 -05:00
Dave Horton
0267acf9e1 anchor media on dial if we are recording (#1520) 2026-02-23 18:13:25 -05:00
rhondahollis
9090006703 update drachtio-srf to v5.0.19 (#1519)
Co-authored-by: rhonda hollis <rhonda@jambonz.org>
2026-02-20 16:19:28 -05:00
Hoan Luu Huu
d7beaa1b7b Feat/dialogflow cx (#7) (#1516)
* wip

* wip

* wip

* wip

* logging

* wip

* wip

* wip

* update docker env to latest freeswitch

* wip

* lint

* wip

* support dialogflowcx

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

---------

Co-authored-by: Dave Horton <daveh@beachdognet.com>
2026-02-12 07:46:36 -05:00
Dave Horton
45d0ca87af update drachtio-srf (#1515) 2026-02-09 10:31:43 -05:00
Sam Machin
bd435dfff9 add deep copy (#1511)
* escape tag data in listen

* deep copy call data for listen
2026-02-03 10:44:26 -05:00
Sam Machin
b598cd94ae escape tag data in listen (#1510) 2026-02-03 07:35:15 -05:00
Matt Hertogs
ceb9a7a3bd Fix boostAudioSignal parameter in Update Call REST API (#1490)
Corrects the parameter passed to _lccBoostAudioSignal to use
opts.boostAudioSignal instead of the entire opts object, ensuring
the boostAudioSignal option works correctly.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-29 13:58:14 -05:00
Dave Horton
ff5f9acaf8 on dial do not reinvite A leg on answer if already answered and we are anchoring media (#1508) 2026-01-29 13:47:53 -05:00
Sam Machin
96cdc2936b invert default (#1507) 2026-01-29 09:22:00 -05:00
Hoan Luu Huu
6120dcbe96 support openai transcribe turn_detection.eagerness (#1496) 2026-01-28 08:09:01 -05:00
Hoan Luu Huu
96d72216e2 support google s2s host, version, sessionResumption (#1498) 2026-01-28 08:01:53 -05:00
Hoan Luu Huu
faee30278b support mod_google_tts_streaming (#1409)
* support mod_google_tts_streaming

* wip

* wip
2026-01-27 08:18:47 -05:00
Hoan Luu Huu
325af42946 speechmatics support end_of_utterance_silence_trigger (#1499)
* speechmatics support end_of_utterance_silence_trigger

* wip
2026-01-23 10:11:58 -05:00
Hoan Luu Huu
9848152d5b support google gemini tts (#1491)
* support google gemini tts

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* update speech utils version

* wip
2026-01-22 10:12:06 -05:00
Sam Machin
2468557aef add statusHook to redirect verb (#1500)
* add statusHook to redirect

* fix url import

* Update redirect.js

* logging

* constructor statusHook

* lint n logging

* debug

* update call_status_hook

* use notifier to test url

* remove require url as its global since node 10

* update verb specs dep

* update verb specs
2026-01-21 09:20:47 -05:00
Dave Horton
3c3dfa81d3 fix issue where call hangs up and actionhook delay triggered (#1497) 2026-01-19 16:42:43 -05:00
Vinod Dharashive
961c2589ac freeswitch capture sip error and propagate the same error (#1489)
* fix: propagate SIP 488 error to SBC on endpoint allocation failure

When FreeSWITCH returns a SIP 488 'Not Acceptable Here' error during
endpoint allocation (e.g., codec incompatibility), this error was not
being propagated back to the SBC/client. Instead, the call would wait
indefinitely for websocket commands or return a generic 603 response.

Implementation:
- In _evalEndpointPrecondition(), detect SipError by checking
  err.type === 'SipError' or err.name === 'SipError'
- Extract the SIP status code (e.g., 488), reason, and the Reason
  header from the error response (e.g., Q.850;cause=88;text=INCOMPATIBLE_DESTINATION)
- Send the SIP error response immediately to the SBC with:
  - X-Reason header: endpoint allocation failure details
  - Reason header: original Q.850 cause from FreeSWITCH
- Notify call status change as Failed with proper SIP status
- Release the call immediately instead of waiting for commands

Also added fallback handling in InboundCallSession._onTasksDone() to
propagate the stored error if immediate send was not possible.

* wip

* Simplify SipError check to only use err.name
2026-01-13 08:58:37 -05:00
Hoan Luu Huu
e4ec0025c3 Fix/gladia multi sessions (#1487)
* support gladia transcribe multi channels

* wip
2026-01-07 10:46:33 -05:00
Ed Robbins
ba275ef547 #1485 remove deprecated call to URL.parse (#1488) 2026-01-05 15:53:32 -05:00
Dave Horton
83a8cf6d25 SIGUSR1 should cause fs to commence drying up calls but do not exit when call count reaches zero (#1486) 2026-01-04 12:23:26 -05:00
Sam Machin
09220872ae Update recording (#1483)
* refactor recording

removed the test of `(this.cs.accountInfo.account.record_all_calls || this.cs.application.record_all_calls` from backround-task-manager.jsL138 as this check is already done in call-session.js at Line 3007, also allows us to start the record from update or config verbs

* handle start recording for a call that is not yet answered

* return false if not changing recording state

* different check for status

* set hasRecording flag on callInfo when starting

* update redis on recording start

* lint

* update dependency
2026-01-02 11:05:38 -05:00
Dave Horton
fdce05fa40 add handler for SIGUSR1 to start drying up calls, useful as a generic mechanism on non-AWS deployments (#1482) 2025-12-30 13:31:42 -05:00
Sam Machin
3bd1dd6323 put removeListner in a try/catch (#1479)
* put removeListner in a try/catch

* typo
2025-12-19 13:31:06 -05:00
Ed Robbins
54dc172ebd Allow defining an ENV for specific webhook error return SIP code (#1476) 2025-12-16 17:14:42 -05:00
Hoan Luu Huu
e007e0e2d3 fixed callsession cannot close tts streaming (#1472) 2025-12-16 07:58:54 -05:00
37 changed files with 2149 additions and 1326 deletions

View File

@@ -1,10 +1,10 @@
FROM --platform=linux/amd64 node:20-alpine as base
FROM --platform=linux/amd64 node:24-alpine AS base
RUN apk --update --no-cache add --virtual .builds-deps build-base python3
WORKDIR /opt/app/
FROM base as build
FROM base AS build
COPY package.json package-lock.json ./

View File

@@ -139,6 +139,7 @@ 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;
const JAMBONES_WEBHOOK_ERROR_RETURN = parseInt(process.env.JAMBONES_WEBHOOK_ERROR_RETURN, 10) || 480;
/* say / tts */
const JAMBONES_SAY_CHUNK_SIZE = parseInt(process.env.JAMBONES_SAY_CHUNK_SIZE, 10) || 900;
@@ -237,4 +238,5 @@ module.exports = {
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
JAMBONES_SAY_CHUNK_SIZE,
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS,
JAMBONES_WEBHOOK_ERROR_RETURN
};

View File

@@ -18,6 +18,7 @@ const { createCallSchema, customSanitizeFunction } = require('../schemas/create-
const { selectHostPort } = require('../../utils/network');
const { JAMBONES_DIAL_SBC_FOR_REGISTERED_USER } = require('../../config');
const { createMediaEndpoint } = require('../../utils/media-endpoint');
const { DbErrorBadRequest } = require('../utils/errors');
const removeNullProperties = (obj) => (Object.keys(obj).forEach((key) => obj[key] === null && delete obj[key]), obj);
const removeNulls = (req, res, next) => {
@@ -122,8 +123,16 @@ router.post('/',
}
break;
case 'user':
uri = `sip:${target.name}`;
to = target.name;
let targetName = target.name;
if (!targetName.includes('@')) {
if (!account.sip_realm) {
throw new DbErrorBadRequest('no sip realm configured for account');
}
logger.debug(`appending sip realm ${account.sip_realm} to target name ${targetName}`);
targetName = `${targetName}@${account.sip_realm}`;
}
uri = `sip:${targetName}`;
to = targetName;
if (target.overrideTo) {
Object.assign(opts.headers, {
'X-Override-To': target.overrideTo
@@ -237,6 +246,7 @@ 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) ;
app.requestor.service_provider_sid = account.service_provider_sid;
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;
@@ -246,9 +256,11 @@ router.post('/',
else {
logger.debug({call_hook: app.call_hook}, 'creating http client for call hook');
app.requestor = new HttpRequestor(logger, account.account_sid, app.call_hook, account.webhook_secret);
app.requestor.service_provider_sid = account.service_provider_sid;
}
if (!app.notifier && app.call_status_hook) {
app.notifier = new HttpRequestor(logger, account.account_sid, app.call_status_hook, account.webhook_secret);
app.notifier.service_provider_sid = account.service_provider_sid;
logger.debug({call_status_hook: app.call_status_hook}, 'creating http client for call status hook');
}
else if (!app.notifier) {
@@ -331,6 +343,12 @@ router.post('/',
}
});
connectStream(dlg.remote.sdp);
/* ensure sbcCallid is set even if no provisional response was received */
if (!cs.callInfo.sbcCallid && dlg.res.has('X-CID')) {
cs.callInfo.sbcCallid = dlg.res.get('X-CID');
}
cs.emit('callStatusChange', {
callStatus: CallStatus.InProgress,
sipStatus: 200,

View File

@@ -6,8 +6,8 @@ function sysError(logger, res, err) {
return res.status(400).json({msg: err.message});
}
if (err instanceof DbErrorUnprocessableRequest) {
logger.info(err, 'unprocessable request');
return res.status(422).json({msg: err.message});
logger.info({message: err.message}, 'unprocessable request');
return res.status(422).send(err.message);
}
if (err.code === 'ER_DUP_ENTRY') {
logger.info(err, 'duplicate entry on insert');

View File

@@ -23,10 +23,12 @@ router.post('/:partner', async(req, res) => {
if ('WS' === hook?.method) {
app.requestor = new WsRequestor(logger, account.account_sid, hook, account.webhook_secret) ;
app.requestor.service_provider_sid = account.service_provider_sid;
app.notifier = app.requestor;
}
else {
app.requestor = new HttpRequestor(logger, account.account_sid, hook, account.webhook_secret);
app.requestor.service_provider_sid = account.service_provider_sid;
app.notifier = {request: () => {}};
}

View File

@@ -12,7 +12,8 @@ const RootSpan = require('./utils/call-tracer');
const listTaskNames = require('./utils/summarize-tasks');
const {
JAMBONES_MYSQL_REFRESH_TTL,
JAMBONES_DISABLE_DIRECT_P2P_CALL
JAMBONES_DISABLE_DIRECT_P2P_CALL,
JAMBONES_WEBHOOK_ERROR_RETURN
} = require('./config');
const { createJambonzApp } = require('./dynamic-apps');
const { decrypt } = require('./utils/encrypt-decrypt');
@@ -210,7 +211,9 @@ module.exports = function(srf, logger) {
if (account?.enable_debug_log) {
req.locals.logger.level = 'debug';
}
logger.debug({accountInfo: req.locals?.accountInfo?.account}, `retrieved account info for ${account_sid}`);
// eslint-disable-next-line no-unused-vars
const {bucket_credential, ...safeAccount} = req.locals?.accountInfo?.account || {};
logger.debug({accountInfo: safeAccount}, `retrieved account info for ${account_sid}`);
next();
} catch (err) {
span.end();
@@ -328,14 +331,19 @@ module.exports = function(srf, logger) {
if ('WS' === app.call_hook?.method ||
app.call_hook?.url.startsWith('ws://') || app.call_hook?.url.startsWith('wss://')) {
const requestor = new WsRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret) ;
requestor.service_provider_sid = accountInfo.account.service_provider_sid;
app2.requestor = requestor;
app2.notifier = requestor;
app2.call_hook.method = 'WS';
}
else {
app2.requestor = new HttpRequestor(logger, account_sid, app.call_hook, accountInfo.account.webhook_secret);
if (app.call_status_hook) app2.notifier = new HttpRequestor(logger, account_sid, app.call_status_hook,
accountInfo.account.webhook_secret);
app2.requestor.service_provider_sid = accountInfo.account.service_provider_sid;
if (app.call_status_hook) {
app2.notifier = new HttpRequestor(logger, account_sid, app.call_status_hook,
accountInfo.account.webhook_secret);
app2.notifier.service_provider_sid = accountInfo.account.service_provider_sid;
}
else app2.notifier = {request: () => {}, close: () => {}};
}
@@ -475,12 +483,13 @@ module.exports = function(srf, logger) {
span?.end();
writeAlerts({
account_sid: req.locals.account_sid,
service_provider_sid: req.locals.service_provider_sid,
target_sid: req.locals.callSid,
alert_type: AlertType.INVALID_APP_PAYLOAD,
message: `${err?.message}`.trim()
}).catch((err) => this.logger.info({err}, 'Error generating alert for parsing application'));
logger.info({err}, `Error retrieving or parsing application: ${err?.message}`);
res.send(480, {headers: {'X-Reason': err?.message || 'unknown'}});
res.send(JAMBONES_WEBHOOK_ERROR_RETURN, {headers: {'X-Reason': err?.message || 'unknown'}});
app.requestor.close(WS_CLOSE_CODES.GoingAway);
}
}

View File

@@ -12,6 +12,7 @@ class CallInfo {
let srf;
this.direction = opts.direction;
this.traceId = opts.traceId;
this.hasRecording = false;
this.callTerminationBy = undefined;
if (opts.req) {
const u = opts.req.getParsedHeader('from');

View File

@@ -200,6 +200,10 @@ class CallSession extends Emitter {
return this.backgroundTaskManager.isTaskRunning('listen');
}
get isBackGroundRecord() {
return this.backgroundTaskManager.isTaskRunning('record');
}
/**
* SIP call-id for the call
*/
@@ -448,6 +452,10 @@ class CallSession extends Emitter {
return this.callInfo.accountSid;
}
get serviceProviderSid() {
return this.accountInfo?.account?.service_provider_sid;
}
/**
* returns true if this session was transferred from another server
*/
@@ -504,7 +512,12 @@ class CallSession extends Emitter {
}
get isTtsStreamEnabled() {
return this.backgroundTaskManager.isTaskRunning('ttsStream');
// 1st background tts stream
return this.backgroundTaskManager.isTaskRunning('ttsStream') ||
// 2nd current task streaming tts
TaskName.Say === this.currentTask?.name && this.currentTask?.isStreamingTts ||
// 3rd nested verb is streaming tts
TaskName.Gather === this.currentTask?.name && this.currentTask.sayTask?.isStreamingTts;
}
get isListenEnabled() {
@@ -628,6 +641,7 @@ class CallSession extends Emitter {
writeAlerts({
alert_type: 'bot-action-delay-giveup',
account_sid: this.accountSid,
service_provider_sid: this.serviceProviderSid,
message: 'Call terminated due to bot action delay timeout',
target_sid: this.callSid
});
@@ -751,69 +765,101 @@ 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, silence = false, type = 'siprec'} = 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') {
this.recordOptions = opts;
return true;
if (type == 'cloud') {
switch (action) {
case 'pauseCallRecording':
if (this.backgroundTaskManager.isTaskRunning('record')) {
this.logger.debug({action, silence, type}, 'CallSession:cloudRecording');
const backgroundListenTask = this.backgroundTaskManager.getTask('record');
backgroundListenTask.updateListen(
ListenStatus.Pause,
silence
);
return true;
} else { return false; }
case 'resumeCallRecording':
if (this.backgroundTaskManager.isTaskRunning('record')) {
this.logger.debug({action, silence, type}, 'CallSession:cloudRecording');
const backgroundListenTask = this.backgroundTaskManager.getTask('record');
backgroundListenTask.updateListen(
ListenStatus.Resume,
silence
);
return true;
} else { return false; }
case 'startCallRecording':
if (!this.backgroundTaskManager.isTaskRunning('record')) {
this.logger.debug({action, silence, type}, 'CallSession:cloudRecording');
this.callInfo.hasRecording = true;
this.updateCallStatus(Object.assign({}, this.callInfo.toJSON()), this.serviceUrl)
.catch((err) => this.logger.error(err, 'redis error'));
if (!this.dlg) {
// Call not yet answered so set flag to record on status change
this.application.record_all_calls = true;
} else {
this.backgroundTaskManager.newTask('record');
}
return true;
} else { return false; }
case 'stopCallRecording':
if (this.backgroundTaskManager.isTaskRunning('record')) {
this.logger.debug({action, silence, type}, 'CallSession:cloudRecording');
this.backgroundTaskManager.stop('record');
return true;
} else { return false; }
}
} else {
// SIPREC
/* if we have not answered yet, just save the details for later */
if (!this.dlg) {
if (action === 'startCallRecording') {
this.recordOptions = opts;
return true;
}
return false;
}
return false;
}
/* check validity of request */
if (action == 'startCallRecording' && this.recordState !== RecordState.RecordingOff) {
this.logger.info({recordState: this.recordState},
'CallSession:notifyRecordOptions: recording is already started, ignoring request');
return false;
}
if (action == 'stopCallRecording' && this.recordState === RecordState.RecordingOff) {
this.logger.info({recordState: this.recordState},
'CallSession:notifyRecordOptions: recording is already stopped, ignoring request');
return false;
}
if (action == 'pauseCallRecording' && this.recordState !== RecordState.RecordingOn) {
this.logger.info({recordState: this.recordState},
'CallSession:notifyRecordOptions: cannot pause recording, ignoring request ');
return false;
}
if (action == 'resumeCallRecording' && this.recordState !== RecordState.RecordingPaused) {
this.logger.info({recordState: this.recordState},
'CallSession:notifyRecordOptions: cannot resume recording, ignoring request ');
return false;
}
/* check validity of request */
if (action == 'startCallRecording' && this.recordState !== RecordState.RecordingOff) {
this.logger.info({recordState: this.recordState},
'CallSession:notifyRecordOptions: recording is already started, ignoring request');
return false;
}
if (action == 'stopCallRecording' && this.recordState === RecordState.RecordingOff) {
this.logger.info({recordState: this.recordState},
'CallSession:notifyRecordOptions: recording is already stopped, ignoring request');
return false;
}
if (action == 'pauseCallRecording' && this.recordState !== RecordState.RecordingOn) {
this.logger.info({recordState: this.recordState},
'CallSession:notifyRecordOptions: cannot pause recording, ignoring request ');
return false;
}
if (action == 'resumeCallRecording' && this.recordState !== RecordState.RecordingPaused) {
this.logger.info({recordState: this.recordState},
'CallSession:notifyRecordOptions: cannot resume recording, ignoring request ');
return false;
}
this.recordOptions = opts;
this.recordOptions = opts;
switch (action) {
case 'startCallRecording':
return await this.startRecording();
case 'stopCallRecording':
return await this.stopRecording();
case 'pauseCallRecording':
return await this.pauseRecording();
case 'resumeCallRecording':
return await this.resumeRecording();
default:
throw new Error(`invalid record action ${action}`);
switch (action) {
case 'startCallRecording':
return await this.startRecording();
case 'stopCallRecording':
return await this.stopRecording();
case 'pauseCallRecording':
return await this.pauseRecording();
case 'resumeCallRecording':
return await this.resumeRecording();
default:
throw new Error(`invalid record action ${action}`);
}
}
}
@@ -1045,7 +1091,7 @@ class CallSession extends Emitter {
const cred = JSON.parse(credential.service_key.replace(/\n/g, '\\n'));
return {
speech_credential_sid: credential.speech_credential_sid,
credentials: cred
credentials: cred,
};
} catch (err) {
const sid = this.accountInfo.account.account_sid;
@@ -1053,6 +1099,7 @@ class CallSession extends Emitter {
writeAlerts({
alert_type: AlertType.TTS_FAILURE,
account_sid: this.accountSid,
service_provider_sid: this.serviceProviderSid,
vendor,
target_sid: this.callSid
}).catch((err) => this.logger.error({err}, 'Error writing tts alert'));
@@ -1252,6 +1299,7 @@ class CallSession extends Emitter {
writeAlerts({
alert_type: type === 'tts' ? AlertType.TTS_NOT_PROVISIONED : AlertType.STT_NOT_PROVISIONED,
account_sid: this.accountSid,
service_provider_sid: this.serviceProviderSid,
vendor,
label,
target_sid: this.callSid
@@ -1991,7 +2039,7 @@ Duration=${duration} `
return this._lccDub(opts.dub, callSid);
}
else if (opts.boostAudioSignal) {
return this._lccBoostAudioSignal(opts, callSid);
return this._lccBoostAudioSignal(opts.boostAudioSignal, callSid);
}
else if (opts.media_path) {
return this._lccMediaPath(opts.media_path, callSid);
@@ -2015,6 +2063,7 @@ Duration=${duration} `
writeAlerts({
alert_type: 'error-updating-call',
account_sid: this.accountSid,
service_provider_sid: this.serviceProviderSid,
message: err.message,
target_sid: callSid
});
@@ -2212,6 +2261,7 @@ Duration=${duration} `
await writeAlerts({
alert_type: AlertType.WEBHOOK_CONNECTION_FAILURE,
account_sid: this.accountSid,
service_provider_sid: this.serviceProviderSid,
detail: `Session:reconnect error ${err}`,
url: this.application.call_hook.url,
});
@@ -2274,7 +2324,8 @@ Duration=${duration} `
break;
case 'mute:status':
this._lccMuteStatus(data, call_sid);
const status = typeof (data) === 'string' ? data : data.mute_status;
this._lccMuteStatus(status === 'mute', call_sid);
break;
case 'conf:mute-status':
@@ -2434,7 +2485,7 @@ Duration=${duration} `
this.logger.info(`allocated endpoint ${ep.uuid}`);
this.ep.on('destroy', () => {
this.logger.debug(`endpoint was destroyed!! ${this.ep.uuid}`);
this.logger.debug(`endpoint was destroyed!! ${this.ep?.uuid}`);
});
if (this.direction === CallDirection.Inbound || this.application?.transferredCall) {
@@ -2463,6 +2514,36 @@ Duration=${duration} `
}
else {
this.logger.error(err, `Error attempting to allocate endpoint for for task ${task.name}`);
// Check for SipError type (e.g., 488 codec incompatibility)
const isSipError = err.name === 'SipError';
if (isSipError && err.status) {
// Extract Reason header from SIP response if available (e.g., Q.850;cause=88;text="INCOMPATIBLE_DESTINATION")
const sipReasonHeader = err.res?.msg?.headers?.reason;
this._endpointAllocationError = {
status: err.status,
reason: err.reason || 'Endpoint Allocation Failed',
sipReasonHeader
};
this.logger.info({endpointAllocationError: this._endpointAllocationError},
'Captured SipError for propagation to SBC');
// Send SIP error response immediately for inbound calls
if (this.res && !this.res.finalResponseSent) {
this.logger.info(`Sending ${err.status} response to SBC due to SipError`);
this.res.send(err.status, {
headers: {
'X-Reason': `endpoint allocation failure: ${err.reason || 'Endpoint Allocation Failed'}`,
...(sipReasonHeader && {'Reason': sipReasonHeader})
}
});
this._notifyCallStatusChange({
callStatus: CallStatus.Failed,
sipStatus: err.status,
sipReason: err.reason || 'Endpoint Allocation Failed'
});
this._callReleased();
}
}
throw new Error(`${BADPRECONDITIONS}: unable to allocate endpoint`);
}
}
@@ -2713,6 +2794,7 @@ Duration=${duration} `
}
} catch (err) {
this.logger.error(err, 'Error handling reinvite');
res.send(err.status || 500);
}
}
@@ -2812,6 +2894,7 @@ Duration=${duration} `
this.logger.debug({accountSid: this.accountSid, webhook: r[0]}, 'performQueueWebhook: webhook found');
this.queueEventHookRequestor = new HttpRequestor(this.logger, this.accountSid,
r[0], this.webhook_secret);
this.queueEventHookRequestor.service_provider_sid = this.serviceProviderSid;
this.queueEventHook = r[0];
}
} catch (err) {
@@ -2975,8 +3058,7 @@ Duration=${duration} `
// manage record all call.
if (callStatus === CallStatus.InProgress) {
if (this.accountInfo.account.record_all_calls ||
this.application.record_all_calls) {
if (this.accountInfo.account.record_all_calls || this.application.record_all_calls) {
this.backgroundTaskManager.newTask('record');
}
} else if (callStatus == CallStatus.Completed) {
@@ -3149,6 +3231,7 @@ Duration=${duration} `
await writeAlerts({
alert_type: AlertType.TTS_STREAMING_CONNECTION_FAILURE,
account_sid: this.accountSid,
service_provider_sid: this.serviceProviderSid,
vendor
});
} catch (error) {

View File

@@ -60,6 +60,19 @@ class InboundCallSession extends CallSession {
}
});
}
else if (this._endpointAllocationError) {
// Propagate SIP error from endpoint allocation failure back to the client
const {status, reason, sipReasonHeader} = this._endpointAllocationError;
this.rootSpan.setAttributes({'call.termination': `endpoint allocation SIP error ${status}`});
this.logger.info({endpointAllocationError: this._endpointAllocationError},
`InboundCallSession:_onTasksDone generating ${status} due to endpoint allocation failure`);
this.res.send(status, {
headers: {
'X-Reason': `endpoint allocation failure: ${reason}`,
...(sipReasonHeader && {'Reason': sipReasonHeader})
}
});
}
else {
this.rootSpan.setAttributes({'call.termination': 'tasks completed without answering call'});
this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite');

View File

@@ -15,6 +15,7 @@ class TaskAlert extends Task {
await super.exec(cs);
writeAlerts({
account_sid,
service_provider_sid: cs.serviceProviderSid,
alert_type: AlertType.APPLICATION,
detail: `Application SID ${application_sid}`,
message: this.message,

View File

@@ -469,6 +469,7 @@ class Conference extends Task {
assert (cs.isInConference);
const mute = opts.conf_mute_status === 'mute';
this.logger.info(`Conference:doConferenceMute ${mute ? 'muting' : 'unmuting'} member`);
this.ep.api(`conference ${this.confName} ${mute ? 'mute' : 'unmute'} ${this.memberId}`)
.catch((err) => this.logger.info({err}, 'Error muting or unmuting participant'));
}
@@ -570,8 +571,8 @@ class Conference extends Task {
/**
* mute or unmute side of the call
*/
mute(callSid, doMute) {
this.doConferenceMute(this.callSession, {conf_mute_status: doMute});
async mute(callSid, doMute) {
this.doConferenceMute(this.callSession, {conf_mute_status: doMute ? 'mute' : 'unmute'});
}
/**

View File

@@ -160,6 +160,7 @@ class TaskDial extends Task {
const keepAnchor = this.data.anchorMedia ||
this.isTranscoding ||
this.cs.isBackGroundListen ||
this.cs.isBackGroundRecord ||
this.cs.onHoldMusic ||
ANCHOR_MEDIA_ALWAYS ||
this.listenTask ||
@@ -195,6 +196,9 @@ class TaskDial extends Task {
async exec(cs) {
await super.exec(cs);
/* capture whether A leg was already answered before this dial task started */
this._aLegAlreadyAnswered = !!cs.dlg;
if (this.data.anchorMedia && this.data.exitMediaPath) {
this.logger.info('Dial:exec - incompatible anchorMedia and exitMediaPath are both set, will obey anchorMedia');
delete this.data.exitMediaPath;
@@ -550,7 +554,7 @@ 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
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 = {
@@ -872,8 +876,12 @@ class TaskDial extends Task {
this.sd = sd;
this.callSid = sd.callSid;
if (this.earlyMedia) {
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
await cs.propagateAnswer();
if (this._aLegAlreadyAnswered) {
debug('Dial:_selectSingleDial A leg was already answered, skipping propagateAnswer');
} else {
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
await cs.propagateAnswer();
}
}
if (this.timeLimit) {
this.timerMaxCallDuration = setTimeout(this._onMaxCallDuration.bind(this, cs), this.timeLimit * 1000);

View File

@@ -1,3 +1,4 @@
const assert = require('assert');
const Task = require('../task');
const {TaskName, TaskPreconditions} = require('../../utils/constants');
const Intent = require('./intent');
@@ -10,19 +11,27 @@ class Dialogflow extends Task {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
this.credentials = this.data.credentials;
this.project = this.data.project;
this.agent = this.data.agent;
this.region = this.data.region || 'us-central1';
this.model = this.data.model || 'es';
/* set project id with environment and region (optionally) */
if (this.data.environment && this.data.region) {
this.project = `${this.data.project}:${this.data.environment}:${this.data.region}`;
}
else if (this.data.environment) {
this.project = `${this.data.project}:${this.data.environment}`;
}
else if (this.data.region) {
this.project = `${this.data.project}::${this.data.region}`;
assert(this.agent || !this.isCX, 'agent is required for dialogflow cx');
assert(this.credentials, 'dialogflow credentials are required');
if (this.isCX) {
this.environment = this.data.environment || 'none';
}
else {
this.project = this.data.project;
if (this.data.environment && this.data.region) {
this.project = `${this.data.project}:${this.data.environment}:${this.data.region}`;
}
else if (this.data.environment) {
this.project = `${this.data.project}:${this.data.environment}`;
}
else if (this.data.region) {
this.project = `${this.data.project}::${this.data.region}`;
}
}
this.lang = this.data.lang || 'en-US';
@@ -39,7 +48,6 @@ class Dialogflow extends Task {
this.events = this.data.events;
}
else if (this.eventHook) {
// send all events by default - except interim transcripts
this.events = [
'intent',
'transcription',
@@ -60,38 +68,33 @@ class Dialogflow extends Task {
this.voice = this.data.tts.voice || 'default';
this.speechSynthesisLabel = this.data.tts.label;
// fallback tts
this.fallbackVendor = this.data.tts.fallbackVendor || 'default';
this.fallbackLanguage = this.data.tts.fallbackLanguage || 'default';
this.fallbackVoice = this.data.tts.fallbackLanguage || 'default';
this.fallbackVoice = this.data.tts.fallbackVoice || 'default';
this.fallbackLabel = this.data.tts.fallbackLabel;
}
this.bargein = this.data.bargein;
this.cmd = this.isCX ? 'dialogflow_cx_start' : 'dialogflow_start';
this.cmdStop = this.isCX ? 'dialogflow_cx_stop' : 'dialogflow_stop';
// CX-specific state
this._suppressNextCXAudio = false;
this._cxAudioHandled = false;
}
get name() { return TaskName.Dialogflow; }
get isCX() { return this.model === 'cx'; }
get isES() { return !this.isCX; }
async exec(cs, {ep}) {
await super.exec(cs);
try {
await this.init(cs, ep);
this.logger.debug(`starting dialogflow bot ${this.project}`);
// kick it off
const baseArgs = `${this.ep.uuid} ${this.project} ${this.lang} ${this.welcomeEvent}`;
if (this.welcomeEventParams) {
this.ep.api('dialogflow_start', `${baseArgs} '${JSON.stringify(this.welcomeEventParams)}'`);
}
else if (this.welcomeEvent.length) {
this.ep.api('dialogflow_start', baseArgs);
}
else {
this.ep.api('dialogflow_start', `${this.ep.uuid} ${this.project} ${this.lang}`);
}
this.logger.debug(`started dialogflow bot ${this.project}`);
await this.startBot('default');
await this.awaitTaskDone();
} catch (err) {
this.logger.error({err}, 'Dialogflow:exec error');
@@ -108,6 +111,12 @@ class Dialogflow extends Task {
this.ep.removeCustomEventListener('dialogflow::end_of_utterance');
this.ep.removeCustomEventListener('dialogflow::error');
this.ep.removeCustomEventListener('dialogflow_cx::intent');
this.ep.removeCustomEventListener('dialogflow_cx::transcription');
this.ep.removeCustomEventListener('dialogflow_cx::audio_provided');
this.ep.removeCustomEventListener('dialogflow_cx::end_of_utterance');
this.ep.removeCustomEventListener('dialogflow_cx::error');
this._clearNoinputTimer();
if (!this.reportedFinalAction) this.performAction({dialogflowResult: 'caller hungup'})
@@ -141,6 +150,12 @@ class Dialogflow extends Task {
this.ep.addCustomEventListener('dialogflow::end_of_utterance', this._onEndOfUtterance.bind(this, ep, cs));
this.ep.addCustomEventListener('dialogflow::error', this._onError.bind(this, ep, cs));
this.ep.addCustomEventListener('dialogflow_cx::intent', this._onIntent.bind(this, ep, cs));
this.ep.addCustomEventListener('dialogflow_cx::transcription', this._onTranscription.bind(this, ep, cs));
this.ep.addCustomEventListener('dialogflow_cx::audio_provided', this._onAudioProvided.bind(this, ep, cs));
this.ep.addCustomEventListener('dialogflow_cx::end_of_utterance', this._onEndOfUtterance.bind(this, ep, cs));
this.ep.addCustomEventListener('dialogflow_cx::error', this._onError.bind(this, ep, cs));
const obj = typeof this.credentials === 'string' ? JSON.parse(this.credentials) : this.credentials;
const creds = JSON.stringify(obj);
await this.ep.set('GOOGLE_APPLICATION_CREDENTIALS', creds);
@@ -151,56 +166,113 @@ class Dialogflow extends Task {
}
}
async startBot(intent) {
if (this.isCX) {
const event = this.welcomeEvent || intent;
const args = this._buildStartArgs({
event: event && event !== 'default' ? event : undefined
});
this.logger.info({args}, 'starting dialogflow CX bot');
await this.ep.api(this.cmd, args);
}
else {
await this._startBotES();
}
}
async _startBotES() {
this.logger.info('starting dialogflow ES bot');
const baseArgs = `${this.ep.uuid} ${this.project} ${this.lang} ${this.welcomeEvent}`;
if (this.welcomeEventParams) {
await this.ep.api(this.cmd, `${baseArgs} '${JSON.stringify(this.welcomeEventParams)}'`);
}
else if (this.welcomeEvent.length) {
await this.ep.api(this.cmd, baseArgs);
}
else {
await this.ep.api(this.cmd, `${this.ep.uuid} ${this.project} ${this.lang}`);
}
}
/**
* Build the start command args string for either ES or CX.
* @param {object} opts - options
* @param {string} opts.event - optional event to send
* @param {string} opts.text - optional text to send
* @param {number} opts.singleUtterance - 1 or 0 (CX only, default 1)
* @returns {string} command args string
*/
_buildStartArgs({event, text, singleUtterance = 1} = {}) {
if (this.isCX) {
const args = [
this.ep.uuid,
this.project,
this.region,
this.agent,
this.environment || 'none',
this.lang,
event || 'none',
text ? `'${text}'` : 'none',
singleUtterance ? '1' : '0',
];
return args.join(' ');
}
// ES
const args = [this.ep.uuid, this.project, this.lang];
if (event) {
args.push(event);
}
if (text) {
if (!event) args.push('none');
args.push(`'${text}'`);
}
return args.join(' ');
}
/**
* An intent has been returned. Since we are using SINGLE_UTTERANCE on the dialogflow side,
* we may get an empty intent, signified by the lack of a 'response_id' attribute.
* In such a case, we just start another StreamingIntentDetectionRequest.
* @param {*} ep - media server endpoint
* @param {*} cs - call session
* @param {*} evt - event data
*/
async _onIntent(ep, cs, evt) {
const intent = new Intent(this.logger, evt);
if (intent.isEmpty) {
/**
* An empty intent is returned in 3 conditions:
* 1. Our no-input timer fired
* 2. We collected dtmf that needs to be fed to dialogflow
* 3. A normal dialogflow timeout
*/
if (this.noinput && this.greetingPlayed) {
this.logger.info('no input timer fired, reprompting..');
this.noinput = false;
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang} ${this.noInputEvent}`);
ep.api(this.cmd, this._buildStartArgs({event: this.noInputEvent}));
}
else if (this.dtmfEntry && this.greetingPlayed) {
this.logger.info('dtmf detected, reprompting..');
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang} none \'${this.dtmfEntry}\'`);
ep.api(this.cmd, this._buildStartArgs({text: this.dtmfEntry}));
this.dtmfEntry = null;
}
else if (this.greetingPlayed) {
this.logger.info('starting another intent');
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
}
else {
this.logger.info('got empty intent');
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
this.logger.info('got empty intent, restarting');
ep.api(this.cmd, this._buildStartArgs());
}
return;
}
// For CX: suppress NO_INPUT "I didn't get that" audio and silently restart
if (this.isCX && intent.isNoInput && this.greetingPlayed) {
this.logger.info('CX returned NO_INPUT after greeting, suppressing and restarting');
this._suppressNextCXAudio = true;
return;
}
if (this.events.includes('intent')) {
this._performHook(cs, this.eventHook, {event: 'intent', data: evt});
}
// clear the no-input timer and the digit buffer
this._clearNoinputTimer();
if (this.digitBuffer) this.digitBuffer.flush();
/* hang up (or tranfer call) after playing next audio file? */
if (intent.saysEndInteraction) {
// if 'end_interaction' is true, end the dialog after playing the final prompt
// (or in 1 second if there is no final prompt)
this.hangupAfterPlayDone = true;
this.waitingForPlayStart = true;
setTimeout(() => {
@@ -211,8 +283,6 @@ class Dialogflow extends Task {
}
}, 1000);
}
/* collect digits? */
else if (intent.saysCollectDtmf || this.enableDtmfAlways) {
const opts = Object.assign({
idt: this.opts.interDigitTimeout
@@ -221,68 +291,44 @@ class Dialogflow extends Task {
this.digitBuffer.once('fulfilled', this._onDtmfEntryComplete.bind(this, ep));
}
/* if we are using tts and a message was provided, play it out */
// If we have a TTS vendor and fulfillment text, synthesize and play
if (this.vendor && intent.fulfillmentText && intent.fulfillmentText.length > 0) {
const {srf} = cs;
const {stats} = srf.locals;
const {synthAudio} = srf.locals.dbHelpers;
this.waitingForPlayStart = false;
// start a new intent, (we want to continue to listen during the audio playback)
// _unless_ we are transferring or ending the session
if (!this.hangupAfterPlayDone) {
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
// ES: start a new intent during playback so we continue to listen
if (!this.hangupAfterPlayDone && this.isES) {
ep.api(this.cmd, this._buildStartArgs());
}
try {
const {srf} = cs;
const {stats} = srf.locals;
const {synthAudio} = srf.locals.dbHelpers;
const {filePath} = await this._fallbackSynthAudio(cs, intent, stats, synthAudio);
if (filePath) cs.trackTmpFile(filePath);
if (this.playInProgress) {
await ep.api('uuid_break', ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
}
this.playInProgress = true;
this.curentAudioFile = filePath;
this.logger.debug(`starting to play tts ${filePath}`);
if (this.events.includes('start-play')) {
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: filePath}});
}
await ep.play(filePath);
if (this.events.includes('stop-play')) {
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: filePath}});
}
this.logger.debug(`finished ${filePath}`);
if (this.curentAudioFile === filePath) {
this.playInProgress = false;
if (this.queuedTasks) {
this.logger.debug('finished playing audio and we have queued tasks');
this._redirect(cs, this.queuedTasks);
return;
}
}
this.greetingPlayed = true;
if (this.hangupAfterPlayDone) {
this.logger.info('hanging up since intent was marked end interaction and we completed final prompt');
this.performAction({dialogflowResult: 'completed'});
this.notifyTaskDone();
}
else {
// every time we finish playing a prompt, start the no-input timer
this._startNoinputTimer(ep, cs);
}
await this._playAndHandlePostPlay(ep, cs, filePath);
} catch (err) {
this.logger.error({err}, 'Dialogflow:_onIntent - error playing tts');
}
}
else if (this.isCX && !this.hangupAfterPlayDone) {
// CX intent with no TTS — _onAudioProvided may handle playback.
// If not, restart CX after a short delay.
this.greetingPlayed = true;
this._cxAudioHandled = false;
setTimeout(() => {
if (!this._cxAudioHandled && !this.playInProgress) {
this.logger.info('CX: no TTS and no audio provided, restarting to listen');
ep.api(this.cmd, this._buildStartArgs());
this._startNoinputTimer(ep, cs);
}
}, 500);
}
}
async _fallbackSynthAudio(cs, intent, stats, synthAudio) {
try {
const obj = {
return await synthAudio(stats, {
account_sid: cs.accountSid,
text: intent.fulfillmentText,
vendor: this.vendor,
@@ -290,17 +336,13 @@ class Dialogflow extends Task {
voice: this.voice,
salt: cs.callSid,
credentials: this.ttsCredentials
};
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via tts');
return await synthAudio(stats, obj);
});
} catch (error) {
this.logger.info({error}, 'Failed to synthesize audio from primary vendor');
try {
if (this.fallbackVendor) {
if (this.fallbackVendor) {
try {
const credentials = cs.getSpeechCredentials(this.fallbackVendor, 'tts', this.fallbackLabel);
const obj = {
return await synthAudio(stats, {
account_sid: cs.accountSid,
text: intent.fulfillmentText,
vendor: this.fallbackVendor,
@@ -308,24 +350,20 @@ class Dialogflow extends Task {
voice: this.fallbackVoice,
salt: cs.callSid,
credentials
};
this.logger.debug({obj}, 'Dialogflow:_onIntent - playing message via fallback tts');
return await synthAudio(stats, obj);
});
} catch (err) {
this.logger.info({err}, 'Failed to synthesize audio from fallback vendor');
throw err;
}
} catch (err) {
this.logger.info({err}, 'Failed to synthesize audio from falllback vendor');
throw err;
}
throw error;
}
}
/**
* A transcription - either interim or final - has been returned.
* If we are doing barge-in based on hotword detection, check for the hotword or phrase.
* If we are playing a filler sound, like typing, during the fullfillment phase, start that
* if this is a final transcript.
* @param {*} ep - media server endpoint
* A transcription has been returned.
* @param {*} ep - media server endpoint
* @param {*} cs - call session
* @param {*} evt - event data
*/
async _onTranscription(ep, cs, evt) {
@@ -338,13 +376,11 @@ class Dialogflow extends Task {
this._performHook(cs, this.eventHook, {event: 'transcription', data: evt});
}
// if a final transcription, start a typing sound
if (this.thinkingMusic && !transcription.isEmpty && transcription.isFinal &&
transcription.confidence > 0.8) {
ep.play(this.data.thinkingMusic).catch((err) => this.logger.info(err, 'Error playing typing sound'));
}
// interrupt playback on speaking if bargein = true
if (this.bargein && this.playInProgress) {
this.logger.debug('terminating playback due to speech bargein');
this.playInProgress = false;
@@ -353,17 +389,21 @@ class Dialogflow extends Task {
}
/**
* The caller has just finished speaking. No action currently taken.
* The caller has just finished speaking.
* @param {*} ep - media server endpoint
* @param {*} cs - call session
* @param {*} evt - event data
*/
_onEndOfUtterance(cs, evt) {
_onEndOfUtterance(ep, cs, evt) {
if (this.events.includes('end-utterance')) {
this._performHook(cs, this.eventHook, {event: 'end-utterance'});
}
}
/**
* Dialogflow has returned an error of some kind.
* Dialogflow has returned an error.
* @param {*} ep - media server endpoint
* @param {*} cs - call session
* @param {*} evt - event data
*/
_onError(ep, cs, evt) {
@@ -372,70 +412,87 @@ class Dialogflow extends Task {
/**
* Audio has been received from dialogflow and written to a temporary disk file.
* Start playing the audio, after killing any filler sound that might be playing.
* When the audio completes, start the no-input timer.
* Play the audio, then restart or hang up as appropriate.
* @param {*} ep - media server endpoint
* @param {*} cs - call session
* @param {*} evt - event data
*/
async _onAudioProvided(ep, cs, evt) {
if (this.vendor) return;
this.waitingForPlayStart = false;
// kill filler audio
await ep.api('uuid_break', ep.uuid);
// start a new intent, (we want to continue to listen during the audio playback)
// _unless_ we are transferring or ending the session
if (/*this.greetingPlayed &&*/ !this.hangupAfterPlayDone) {
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
// For CX: suppress NO_INPUT reprompt audio and silently restart
if (this._suppressNextCXAudio) {
this._suppressNextCXAudio = false;
ep.api(this.cmd, this._buildStartArgs());
return;
}
this.playInProgress = true;
this.curentAudioFile = evt.path;
this.logger.info(`starting to play ${evt.path}`);
if (this.events.includes('start-play')) {
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: evt.path}});
}
await ep.play(evt.path);
if (this.events.includes('stop-play')) {
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: evt.path}});
}
this.logger.info(`finished ${evt.path}, queued tasks: ${(this.queuedTasks || []).length}`);
if (this.curentAudioFile === evt.path) {
this.playInProgress = false;
if (this.queuedTasks) {
this.logger.debug('finished playing audio and we have queued tasks');
this._redirect(cs, this.queuedTasks);
this.queuedTasks.length = 0;
if (this.vendor) {
if (this.isCX && !this.playInProgress) {
// CX audio arrived but TTS didn't play — fall through to use CX audio
this.logger.info('CX audio provided, TTS vendor did not play - using CX audio');
} else {
return;
}
}
/*
if (!this.inbound && !this.greetingPlayed) {
this.logger.info('finished greeting on outbound call, starting new intent');
this.ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang}`);
this._cxAudioHandled = true;
this.waitingForPlayStart = false;
await ep.api('uuid_break', ep.uuid);
// ES: start a new intent during playback so we continue to listen
if (!this.hangupAfterPlayDone && this.isES) {
ep.api(this.cmd, this._buildStartArgs());
}
*/
await this._playAndHandlePostPlay(ep, cs, evt.path);
}
/**
* Shared post-play logic for both TTS (_onIntent) and CX audio (_onAudioProvided).
* Plays audio, then either hangs up, redirects, or restarts the dialog.
*/
async _playAndHandlePostPlay(ep, cs, filePath) {
if (this.playInProgress) {
await ep.api('uuid_break', ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
}
this.playInProgress = true;
this.curentAudioFile = filePath;
if (this.events.includes('start-play')) {
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: filePath}});
}
await ep.play(filePath);
if (this.events.includes('stop-play')) {
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: filePath}});
}
if (this.curentAudioFile === filePath) {
this.playInProgress = false;
if (this.queuedTasks) {
this._redirect(cs, this.queuedTasks);
this.queuedTasks = null;
return;
}
}
this.greetingPlayed = true;
if (this.hangupAfterPlayDone) {
this.logger.info('hanging up since intent was marked end interaction and we completed final prompt');
this.logger.info('hanging up after end interaction prompt');
this.performAction({dialogflowResult: 'completed'});
this.notifyTaskDone();
}
else {
// every time we finish playing a prompt, start the no-input timer
// CX: restart to listen for the next utterance
if (this.isCX) {
ep.api(this.cmd, this._buildStartArgs());
}
this._startNoinputTimer(ep, cs);
}
}
/**
* receive a dmtf entry from the caller.
* If we have active dtmf instructions, collect and process accordingly.
* Receive a DTMF entry from the caller.
*/
_onDtmf(ep, cs, evt) {
if (this.digitBuffer) this.digitBuffer.process(evt.dtmf);
@@ -444,41 +501,48 @@ class Dialogflow extends Task {
}
}
_onDtmfEntryComplete(ep, dtmfEntry) {
async _onDtmfEntryComplete(ep, dtmfEntry) {
this.logger.info(`collected dtmf entry: ${dtmfEntry}`);
this.dtmfEntry = dtmfEntry;
this.digitBuffer = null;
// if a final transcription, start a typing sound
if (this.thinkingMusic) {
ep.play(this.thinkingMusic).catch((err) => this.logger.info(err, 'Error playing typing sound'));
}
// kill the current dialogflow, which will result in us getting an immediate intent
ep.api('dialogflow_stop', `${ep.uuid}`)
.catch((err) => this.logger.info(`dialogflow_stop failed: ${err.message}`));
if (this.isCX) {
try {
await ep.api(this.cmdStop, ep.uuid);
} catch (err) {
this.logger.info(err, 'dialogflow_cx_stop failed');
}
ep.api(this.cmd, this._buildStartArgs({text: dtmfEntry}));
} else {
this.dtmfEntry = dtmfEntry;
ep.api(this.cmdStop, `${ep.uuid}`)
.catch((err) => this.logger.info(`dialogflow_stop failed: ${err.message}`));
}
}
/**
* The user has not provided any input for some time.
* Set the 'noinput' member to true and kill the current dialogflow.
* This will result in us re-prompting with an event indicating no input.
* @param {*} ep
*/
_onNoInput(ep, cs) {
this.noinput = true;
async _onNoInput(ep, cs) {
this.logger.info('no-input timer fired');
if (this.events.includes('no-input')) {
this._performHook(cs, this.eventHook, {event: 'no-input'});
this._performHook(cs, this.eventHook, {event: 'no-input'});
}
// kill the current dialogflow, which will result in us getting an immediate intent
ep.api('dialogflow_stop', `${ep.uuid}`)
.catch((err) => this.logger.info(`dialogflow_stop failed: ${err.message}`));
if (this.isCX) {
try {
await ep.api(this.cmdStop, ep.uuid);
} catch (err) {
this.logger.info(err, 'dialogflow_cx_stop failed');
}
ep.api(this.cmd, this._buildStartArgs({event: this.noInputEvent}));
} else {
this.noinput = true;
ep.api(this.cmdStop, `${ep.uuid}`)
.catch((err) => this.logger.info(`dialogflow_stop failed: ${err.message}`));
}
}
/**
* Stop the no-input timer, if it is running
*/
_clearNoinputTimer() {
if (this.noinputTimer) {
clearTimeout(this.noinputTimer);
@@ -486,10 +550,6 @@ class Dialogflow extends Task {
}
}
/**
* Start the no-input timer. The duration is set in the configuration file.
* @param {*} ep
*/
_startNoinputTimer(ep, cs) {
if (!this.noInputTimeout) return;
this._clearNoinputTimer();
@@ -507,7 +567,7 @@ class Dialogflow extends Task {
if (tasks && tasks.length > 0) {
if (this.playInProgress) {
this.queuedTasks = tasks;
this.logger.info({tasks: tasks},
this.logger.info({tasks},
`${this.name} replacing application with ${tasks.length} tasks after play completes`);
return;
}
@@ -517,7 +577,7 @@ class Dialogflow extends Task {
}
_redirect(cs, tasks) {
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.logger.info({tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.performAction({dialogflowResult: 'redirect'}, false);
this.reportedFinalAction = true;
cs.replaceApplication(tasks);

View File

@@ -3,20 +3,44 @@ class Intent {
this.logger = logger;
this.evt = evt;
this.logger.debug({evt}, 'intent');
this.dtmfRequest = checkIntentForDtmfEntry(logger, evt);
this.qr = this.isCX ? evt.detect_intent_response.query_result : evt.query_result;
this.dtmfRequest = this._checkIntentForDtmfEntry();
}
get response_id() {
return this.isCX ? this.evt.detect_intent_response.response_id : this.evt.response_id;
}
get isEmpty() {
return this.evt.response_id.length === 0;
return !(this.response_id?.length > 0);
}
get fulfillmentText() {
return this.evt.query_result.fulfillment_text;
if (this.isCX) {
if (this.qr && this.qr.response_messages) {
for (const msg of this.qr.response_messages) {
if (msg.text && msg.text.text && msg.text.text.length > 0) {
return msg.text.text.join('\n');
}
if (msg.output_audio_text) {
if (msg.output_audio_text.text) return msg.output_audio_text.text;
if (msg.output_audio_text.ssml) return msg.output_audio_text.ssml;
}
}
}
return undefined;
}
return this.qr.fulfillment_text;
}
get saysEndInteraction() {
return this.evt.query_result.intent.end_interaction ;
if (this.isCX) {
if (!this.qr || !this.qr.response_messages) return false;
const end_interaction = this.qr.response_messages
.find((m) => typeof m === 'object' && 'end_interaction' in m)?.end_interaction;
return end_interaction && Object.keys(end_interaction).length > 0;
}
return this.qr.intent.end_interaction;
}
get saysCollectDtmf() {
@@ -28,7 +52,23 @@ class Intent {
}
get name() {
if (!this.isEmpty) return this.evt.query_result.intent.display_name;
if (!this.isEmpty) {
if (this.isCX) {
return this.qr.match?.intent?.display_name;
}
return this.qr.intent.display_name;
}
}
get isCX() {
return typeof this.evt.detect_intent_response === 'object';
}
get isNoInput() {
if (this.isCX && this.qr && this.qr.match) {
return this.qr.match.match_type === 'NO_INPUT';
}
return false;
}
toJSON() {
@@ -38,52 +78,48 @@ class Intent {
};
}
/**
* Parse a returned intent for DTMF entry information (ES only).
* CX does not use fulfillment_messages or output_contexts.
*
* allow-dtmf-x-y-z
* x = min number of digits
* y = optional, max number of digits
* z = optional, terminating character
*/
_checkIntentForDtmfEntry() {
if (this.isCX) return;
const qr = this.qr;
if (!qr || !qr.fulfillment_messages || !qr.output_contexts) {
return;
}
// check for custom payloads with a gather verb
const custom = qr.fulfillment_messages.find((f) => f.payload && f.payload.verb === 'gather');
if (custom) {
this.logger.info({custom}, 'found dtmf custom payload');
return {
max: custom.payload.numDigits,
term: custom.payload.finishOnKey,
template: custom.payload.responseTemplate
};
}
// check for an output context with a specific naming convention
const context = qr.output_contexts.find((oc) => oc.name.includes('/contexts/allow-dtmf-'));
if (context) {
const arr = /allow-dtmf-(\d+)(?:-(\d+))?(?:-(.*))?/.exec(context.name);
if (arr) {
this.logger.info('found dtmf output context');
return {
min: parseInt(arr[1]),
max: arr.length > 2 ? parseInt(arr[2]) : null,
term: arr.length > 3 ? arr[3] : null
};
}
}
}
}
module.exports = Intent;
/**
* Parse a returned intent for DTMF entry information
* i.e.
* allow-dtmf-x-y-z
* x = min number of digits
* y = optional, max number of digits
* z = optional, terminating character
* e.g.
* allow-dtmf-5 : collect 5 digits
* allow-dtmf-1-4 : collect between 1 to 4 (inclusive) digits
* allow-dtmf-1-4-# : collect 1-4 digits, terminating if '#' is entered
* @param {*} intent - dialogflow intent
*/
const checkIntentForDtmfEntry = (logger, intent) => {
const qr = intent.query_result;
if (!qr || !qr.fulfillment_messages || !qr.output_contexts) {
logger.info({f: qr.fulfillment_messages, o: qr.output_contexts}, 'no dtmfs');
return;
}
// check for custom payloads with a gather verb
const custom = qr.fulfillment_messages.find((f) => f.payload && f.payload.verb === 'gather');
if (custom && custom.payload && custom.payload.verb === 'gather') {
logger.info({custom}, 'found dtmf custom payload');
return {
max: custom.payload.numDigits,
term: custom.payload.finishOnKey,
template: custom.payload.responseTemplate
};
}
// check for an output context with a specific naming convention
const context = qr.output_contexts.find((oc) => oc.name.includes('/contexts/allow-dtmf-'));
if (context) {
const arr = /allow-dtmf-(\d+)(?:-(\d+))?(?:-(.*))?/.exec(context.name);
if (arr) {
logger.info({custom}, 'found dtmf output context');
return {
min: parseInt(arr[1]),
max: arr.length > 2 ? parseInt(arr[2]) : null,
term: arr.length > 3 ? arr[3] : null
};
}
}
};

View File

@@ -500,6 +500,10 @@ class TaskGather extends SttTask {
this.addCustomEventListener(ep, GladiaTranscriptionEvents.ConnectFailure,
this._onVendorConnectFailure.bind(this, cs, ep));
this.addCustomEventListener(ep, GladiaTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
// gladia require unique url for each session
const {host, path} = await this.createGladiaLiveSession();
opts.GLADIA_SPEECH_HOST = host;
opts.GLADIA_SPEECH_PATH = path;
break;
case 'soniox':
@@ -722,6 +726,7 @@ class TaskGather extends SttTask {
this.logger.error(err, 'TaskGather:_startTranscribing error');
writeAlerts({
account_sid: this.cs.accountSid,
service_provider_sid: this.cs.serviceProviderSid,
alert_type: AlertType.STT_FAILURE,
vendor: this.vendor,
detail: err.message,
@@ -1201,6 +1206,7 @@ class TaskGather extends SttTask {
const errMessage = evt.error || evt.Message;
writeAlerts({
account_sid: cs.accountSid,
service_provider_sid: cs.serviceProviderSid,
alert_type: AlertType.STT_FAILURE,
message: `Custom speech vendor ${this.vendor} error: ${errMessage}`,
vendor: this.vendor,
@@ -1420,7 +1426,27 @@ class TaskGather extends SttTask {
returnedVerbs = await this.performAction({speech:evt, reason: 'stt-low-confidence', ...latencies});
}
}
} catch (err) { /*already logged error*/ }
} catch (err) {
this.logger.info({err}, 'TaskGather:_resolve - error performing action');
this.notifyError({msg: 'invalid actionHook response', details: err.message});
const {writeAlerts, AlertType} = this.cs.srf.locals;
writeAlerts({
account_sid: this.cs.accountSid,
service_provider_sid: this.cs.serviceProviderSid,
alert_type: AlertType.INVALID_APP_PAYLOAD,
target_sid: this.cs.callSid,
message: `actionHook returned invalid verb syntax: ${err.message}`
}).catch((err) => this.logger.info({err}, 'TaskGather:_resolve - error generating alert'));
try {
const obj = Object.assign({}, this.cs.callInfo.toJSON(), {
error: 'invalid actionHook response',
reason: err.message
});
await this.cs.notifier.request('call:status', this.cs.call_status_hook, obj);
} catch (statusErr) {
this.logger.info({statusErr}, 'TaskGather:_resolve - error sending statusHook');
}
}
// Gather got response from hook, cancel actionHookDelay processing
if (this.cs.actionHookDelayProcessor) {

View File

@@ -152,9 +152,17 @@ class TaskListen extends Task {
async _startListening(cs, ep) {
this._initListeners(ep);
const ci = this.nested ? this.parentTask.sd.callInfo : cs.callInfo.toJSON();
const tempci = this.nested ? this.parentTask.sd.callInfo : cs.callInfo.toJSON();
const ci = structuredClone(tempci);
if (this._ignoreCustomerData) {
delete ci.customerData;
} else {
for (const key in ci.customerData) {
if (ci.customerData.hasOwnProperty(key)) {
const value = ci.customerData[key];
ci.customerData[key] = typeof value === 'string' ? escapeString(value) : value;
}
}
}
const metadata = Object.assign(
{sampleRate: this.sampleRate, mixType: this.mixType},

View File

@@ -105,6 +105,7 @@ class TaskLlm extends Task {
}
async sendEventHook(data) {
if (!this.eventHook) return;
await this.cs?.requestor.request('llm:event', this.eventHook, data);
}

View File

@@ -36,6 +36,9 @@ class TaskLlmGoogle_S2S extends Task {
this.model = this.parent.model || 'models/gemini-2.0-flash-live-001';
this.auth = this.parent.auth;
this.connectionOptions = this.parent.connectOptions;
const {host, version} = this.connectionOptions || {};
this.host = host;
this.version = version;
const {apiKey} = this.auth || {};
if (!apiKey) throw new Error('auth.apiKey is required for Google S2S');
@@ -46,7 +49,7 @@ class TaskLlmGoogle_S2S extends Task {
this.eventHook = this.data.eventHook;
this.toolHook = this.data.toolHook;
const {setup} = this.data.llmOptions;
const {setup, sessionResumption} = this.data.llmOptions;
if (typeof setup !== 'object') {
throw new Error('llmOptions with an initial setup is required for Google S2S');
@@ -54,6 +57,7 @@ class TaskLlmGoogle_S2S extends Task {
this.setup = {
...setup,
model: this.model,
...(sessionResumption && {sessionResumption}),
// make sure output is always audio
generationConfig: {
...(setup.generationConfig || {}),
@@ -138,6 +142,10 @@ class TaskLlmGoogle_S2S extends Task {
try {
const args = [ep.uuid, 'session.create', this.apiKey];
if (this.host) {
args.push(this.host);
if (this.version) args.push(this.version);
}
await this._api(ep, args);
} catch (err) {
this.logger.error({err}, 'TaskLlmGoogle_S2S:_startListening');

View File

@@ -105,6 +105,7 @@ class TaskPlay extends Task {
this.emit('playDone');
writeAlerts({
account_sid: cs.accountSid,
service_provider_sid: cs.serviceProviderSid,
alert_type: AlertType.PLAY_FILENOTFOUND,
url: this.url,
target_sid: cs.callSid

View File

@@ -1,7 +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');
/**
@@ -10,6 +9,7 @@ const HttpRequestor = require('../utils/http-requestor');
class TaskRedirect extends Task {
constructor(logger, opts) {
super(logger, opts);
this.statusHook = opts.statusHook || false;
}
get name() { return TaskName.Redirect; }
@@ -26,6 +26,7 @@ class TaskRedirect extends Task {
try {
const requestor = new WsRequestor(this.logger, cs.accountSid, {url: this.actionHook},
cs.accountInfo.account.webhook_secret) ;
requestor.service_provider_sid = cs.serviceProviderSid;
cs.requestor.emit('handover', requestor);
} catch (err) {
this.logger.info(err, `TaskRedirect error redirecting to ${this.actionHook}`);
@@ -33,13 +34,14 @@ class TaskRedirect extends Task {
}
else {
const baseUrl = this.cs.application.requestor.baseUrl;
const newUrl = URL.parse(this.actionHook);
const newUrl = new URL(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);
newRequestor.service_provider_sid = cs.serviceProviderSid;
cs.requestor.emit('handover', newRequestor);
} catch (err) {
this.logger.info(err, `TaskRedirect error updating base url to ${this.actionHook}`);
@@ -47,6 +49,34 @@ class TaskRedirect extends Task {
}
}
}
/* update the notifier if a new statusHook was provided */
if (this.statusHook) {
this.logger.info(`TaskRedirect updating statusHook to ${this.statusHook}`);
try {
const oldNotifier = cs.application.notifier;
const isStatusHookAbsolute = cs.notifier?._isAbsoluteUrl(this.statusHook);
if (isStatusHookAbsolute) {
if (cs.notifier instanceof WsRequestor) {
const notifier = new WsRequestor(this.logger, cs.accountSid, {url: this.statusHook},
cs.accountInfo.account.webhook_secret);
notifier.service_provider_sid = cs.serviceProviderSid;
cs.application.notifier = notifier;
} else {
const notifier = new HttpRequestor(this.logger, cs.accountSid, {url: this.statusHook},
cs.accountInfo.account.webhook_secret);
notifier.service_provider_sid = cs.serviceProviderSid;
cs.application.notifier = notifier;
}
if (oldNotifier?.close) oldNotifier.close();
}
/* update the call_status_hook URL that gets passed to the notifier */
cs.application.call_status_hook = this.statusHook;
} catch (err) {
this.logger.info(err, `TaskRedirect error updating statusHook to ${this.statusHook}`);
}
}
await this.performAction();
}
}

View File

@@ -326,6 +326,7 @@ class TaskSay extends TtsTask {
response_code <= 199 || response_code >= 300) {
writeAlerts({
account_sid,
service_provider_sid: cs.serviceProviderSid,
alert_type: AlertType.TTS_FAILURE,
vendor,
detail: evt.variable_tts_error || `TTS playback failed with response code ${response_code}`,

View File

@@ -17,7 +17,7 @@ class TaskSipDecline extends Task {
async exec(cs, {res}) {
super.exec(cs);
res.send(this.data.status, this.data.reason, {
headers: this.headers
headers: {'X-Reason': 'SIP Decline Verb', ...this.headers}
}, (err) => {
if (!err) {
// Call was successfully declined

View File

@@ -203,26 +203,14 @@ class SttTask extends Task {
if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) {
this.data.recognizer.punctuation = cs.globalSttPunctuation;
}
if (this.vendor === 'gladia') {
const { api_key, region } = this.sttCredentials;
const {url} = await this.createGladiaLiveSession({
api_key, region,
model: this.data.recognizer.model || 'solaria-1',
options: this.data.recognizer.gladiaOptions || {}
});
const {host, pathname, search} = new URL(url);
this.sttCredentials.host = host;
this.sttCredentials.path = `${pathname}${search}`;
}
}
async createGladiaLiveSession({
api_key,
region = 'us-west',
model = 'solaria-1',
options = {},
}) {
async createGladiaLiveSession() {
const { api_key, region = 'us-west' } = this.sttCredentials;
const model = this.data.recognizer.model || 'solaria-1';
const options = this.data.recognizer.gladiaOptions || {};
const url = `https://api.gladia.io/v2/live?region=${region}`;
const response = await fetch(url, {
method: 'POST',
@@ -252,7 +240,9 @@ class SttTask extends Task {
const data = await response.json();
this.logger.debug({url: data.url}, 'Gladia Call registered');
return data;
const {host, pathname, search} = new URL(data.url);
return {host, path: `${pathname}${search}`};
}
addCustomEventListener(ep, event, handler) {
@@ -284,6 +274,7 @@ class SttTask extends Task {
this.logger.info(`ERROR stt using ${vendor} requested but creds not supplied`);
writeAlerts({
account_sid: cs.accountSid,
service_provider_sid: cs.serviceProviderSid,
alert_type: AlertType.STT_NOT_PROVISIONED,
vendor,
label,
@@ -483,6 +474,7 @@ class SttTask extends Task {
const {writeAlerts, AlertType} = cs.srf.locals;
writeAlerts({
account_sid: cs.accountSid,
service_provider_sid: cs.serviceProviderSid,
alert_type: AlertType.STT_FAILURE,
message: 'STT failure reported by vendor',
detail: evt.error,
@@ -498,6 +490,7 @@ class SttTask extends Task {
this.logger.info({evt}, `${this.name}:_on${this.vendor}ConnectFailure`);
writeAlerts({
account_sid: cs.accountSid,
service_provider_sid: cs.serviceProviderSid,
alert_type: AlertType.STT_FAILURE,
message: `Failed connecting to ${this.vendor} speech recognizer: ${reason}`,
vendor: this.vendor,

View File

@@ -459,6 +459,14 @@ class TaskTranscribe extends SttTask {
else if (this.data.recognizer?.hints?.length > 0) {
prompt = this.data.recognizer?.hints.join(', ');
}
} else if (this.vendor === 'gladia') {
// gladia require unique url for each session
const {host, path} = await this.createGladiaLiveSession();
await ep.set({
GLADIA_SPEECH_HOST: host,
GLADIA_SPEECH_PATH: path,
})
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
}
await ep.startTranscription({
@@ -835,6 +843,7 @@ class TaskTranscribe extends SttTask {
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
writeAlerts({
account_sid: cs.accountSid,
service_provider_sid: cs.serviceProviderSid,
alert_type: AlertType.STT_FAILURE,
message: `Custom speech vendor ${this.vendor} error: ${evt.error}`,
vendor: this.vendor,

View File

@@ -31,8 +31,9 @@ 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.instructions = this.data.instructions || this.options.instructions;
this.playbackIds = [];
this.useGeminiTts = this.options.useGeminiTts;
}
getPlaybackId(offset) {
@@ -156,6 +157,13 @@ class TtsTask extends Task {
...(reduceLatency && {RIMELABS_TTS_STREAMING_REDUCE_LATENCY: reduceLatency})
};
break;
case 'google':
obj = {
GOOGLE_TTS_LANGUAGE_CODE: language,
GOOGLE_TTS_VOICE_NAME: voice,
GOOGLE_APPLICATION_CREDENTIALS: JSON.stringify(credentials.credentials)
};
break;
default:
if (vendor.startsWith('custom:')) {
const use_tls = custom_tts_streaming_url.startsWith('wss://');
@@ -242,6 +250,8 @@ class TtsTask extends Task {
}
} else if (vendor === 'cartesia') {
credentials.model_id = this.options.model_id || credentials.model_id;
} else if (vendor === 'google') {
this.model = this.options.model || credentials.credentials.model_id;
}
this.model_id = credentials.model_id;
@@ -272,6 +282,7 @@ class TtsTask extends Task {
if (!credentials) {
writeAlerts({
account_sid,
service_provider_sid: cs.serviceProviderSid,
alert_type: AlertType.TTS_NOT_PROVISIONED,
vendor,
label,
@@ -359,6 +370,7 @@ class TtsTask extends Task {
if (this.otelSpan) this.otelSpan.end();
writeAlerts({
account_sid: cs.accountSid,
service_provider_sid: cs.serviceProviderSid,
alert_type: AlertType.TTS_FAILURE,
vendor,
label,

View File

@@ -118,6 +118,13 @@ class ActionHookDelayProcessor extends Emitter {
this.logger.debug('ActionHookDelayProcessor#_onNoResponseTimer');
this._noResponseTimer = null;
/* check if endpoint is still available (call may have ended) */
if (!this.ep) {
this.logger.debug('ActionHookDelayProcessor#_onNoResponseTimer: endpoint is null, call may have ended');
this._active = false;
return;
}
/* get the next play or say action */
const verb = this.actions[this._retryCount % this.actions.length];
@@ -129,8 +136,8 @@ class ActionHookDelayProcessor extends Emitter {
this._taskInProgress.exec(this.cs, {ep: this.ep}).catch((err) => {
this.logger.info(`ActionHookDelayProcessor#_onNoResponseTimer: error playing file: ${err.message}`);
this._taskInProgress = null;
this.ep.removeAllListeners('playback-start');
this.ep.removeAllListeners('playback-stop');
this.ep?.removeAllListeners('playback-start');
this.ep?.removeAllListeners('playback-stop');
});
} catch (err) {
this.logger.info(err, 'ActionHookDelayProcessor#_onNoResponseTimer: error starting action');

View File

@@ -219,6 +219,7 @@ module.exports = (logger) => {
logger.error(err, 'amd:_startTranscribing error');
writeAlerts({
account_sid: cs.accountSid,
service_provider_sid: cs.serviceProviderSid,
alert_type: AlertType.STT_FAILURE,
vendor: vendor,
detail: err.message,
@@ -405,19 +406,21 @@ module.exports = (logger) => {
if (ep.amd) {
vendor = ep.amd.vendor;
ep.amd.stopAllTimers();
ep.removeListener(GoogleTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(GoogleTranscriptionEvents.EndOfUtterance, ep.amd.EndOfUtteranceHandler);
ep.removeListener(AwsTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(AzureTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(AzureTranscriptionEvents.NoSpeechDetected, ep.amd.noSpeechHandler);
ep.removeListener(NuanceTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(DeepgramTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(SonioxTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(IbmTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(NvidiaTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(JambonzTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
try {
ep.removeListener(GoogleTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(GoogleTranscriptionEvents.EndOfUtterance, ep.amd.EndOfUtteranceHandler);
ep.removeListener(AwsTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(AzureTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(AzureTranscriptionEvents.NoSpeechDetected, ep.amd.noSpeechHandler);
ep.removeListener(NuanceTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(DeepgramTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(SonioxTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(IbmTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(NvidiaTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
ep.removeListener(JambonzTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
} catch (error) {
logger.error('Unable to Remove AMD Listener', error);
}
ep.amd = null;
}

View File

@@ -135,26 +135,24 @@ class BackgroundTaskManager extends Emitter {
// Initiate Record
async _initRecord() {
if (this.cs.accountInfo.account.record_all_calls || this.cs.application.record_all_calls) {
if (!JAMBONZ_RECORD_WS_BASE_URL || !this.cs.accountInfo.account.bucket_credential) {
this.logger.error('_initRecord: invalid cfg - missing JAMBONZ_RECORD_WS_BASE_URL or bucket config');
return undefined;
}
const listenOpts = {
url: `${JAMBONZ_RECORD_WS_BASE_URL}/record/${this.cs.accountInfo.account.bucket_credential.vendor}`,
disableBidirectionalAudio: true,
mixType : 'stereo',
passDtmf: true
};
if (JAMBONZ_RECORD_WS_USERNAME && JAMBONZ_RECORD_WS_PASSWORD) {
listenOpts.wsAuth = {
username: JAMBONZ_RECORD_WS_USERNAME,
password: JAMBONZ_RECORD_WS_PASSWORD
};
}
this.logger.debug({listenOpts}, '_initRecord: enabling listen');
return await this._initListen({verb: 'listen', ...listenOpts}, 'jambonz-session-record', true, 'record');
if (!JAMBONZ_RECORD_WS_BASE_URL || !this.cs.accountInfo.account.bucket_credential) {
this.logger.error('_initRecord: invalid cfg - missing JAMBONZ_RECORD_WS_BASE_URL or bucket config');
return undefined;
}
const listenOpts = {
url: `${JAMBONZ_RECORD_WS_BASE_URL}/record/${this.cs.accountInfo.account.bucket_credential.vendor}`,
disableBidirectionalAudio: true,
mixType : 'stereo',
passDtmf: true
};
if (JAMBONZ_RECORD_WS_USERNAME && JAMBONZ_RECORD_WS_PASSWORD) {
listenOpts.wsAuth = {
username: JAMBONZ_RECORD_WS_USERNAME,
password: JAMBONZ_RECORD_WS_PASSWORD
};
}
this.logger.debug({listenOpts}, '_initRecord: enabling listen');
return await this._initListen({verb: 'listen', ...listenOpts}, 'jambonz-session-record', true, 'record');
}
// Initiate Transcribe

View File

@@ -311,6 +311,11 @@
"ConnectFailure": "deepgram_tts_streaming::connect_failed",
"Connect": "deepgram_tts_streaming::connect"
},
"GoogleTtsStreamingEvents": {
"Empty": "google_tts_streaming::empty",
"ConnectFailure": "google_tts_streaming::connect_failed",
"Connect": "google_tts_streaming::connect"
},
"CartesiaTtsStreamingEvents": {
"Empty": "cartesia_tts_streaming::empty",
"ConnectFailure": "cartesia_tts_streaming::connect_failed",

View File

@@ -128,6 +128,7 @@ class HttpRequestor extends BaseRequestor {
this.logger.debug({hook}, 'HttpRequestor: switching to websocket connection');
const h = typeof hook === 'object' ? hook : {url: hook};
const requestor = new WsRequestor(this.logger, this.account_sid, h, this.secret);
requestor.service_provider_sid = this.service_provider_sid;
if (type === 'session:redirect') {
this.close();
this.emit('handover', requestor);
@@ -248,7 +249,7 @@ class HttpRequestor extends BaseRequestor {
this.logger.error({err, baseUrl: this.baseUrl, url},
'web callback returned unexpected error');
}
let opts = {account_sid: this.account_sid};
let opts = {account_sid: this.account_sid, service_provider_sid: this.service_provider_sid};
if (err.code === 'ECONNREFUSED') {
opts = {...opts, alert_type: this.Alerter.AlertType.WEBHOOK_CONNECTION_FAILURE, url};
}

View File

@@ -234,6 +234,11 @@ class SingleDialer extends Emitter {
await connectStream(this.dlg.remote.sdp, opts.isVideoCall);
this.dlg.callSid = this.callSid;
this.inviteInProgress = null;
/* ensure sbcCallid is set even if no provisional response was received */
if (!this.callInfo.sbcCallid && this.dlg.res.has('X-CID')) {
this.callInfo.sbcCallid = this.dlg.res.get('X-CID');
}
this.emit('callStatusChange', {
sipStatus: 200,
sipReason: 'OK',
@@ -290,6 +295,7 @@ class SingleDialer extends Emitter {
}
} catch (err) {
this.logger.error(err, 'Error handling reinvite');
res.send(err.status || 500);
}
})
.on('refer', (req, res) => {
@@ -449,6 +455,7 @@ class SingleDialer extends Emitter {
if (app.call_hook?.url) app.call_hook.url += '/adulting';
const requestor = new WsRequestor(logger, this.accountInfo.account.account_sid,
app.call_hook, this.accountInfo.account.webhook_secret);
requestor.service_provider_sid = this.accountInfo.account.service_provider_sid;
app.requestor = requestor;
app.notifier = requestor;
app.call_hook.method = 'WS';
@@ -456,9 +463,13 @@ class SingleDialer extends Emitter {
else {
app.requestor = new HttpRequestor(logger, this.accountInfo.account.account_sid,
app.call_hook, this.accountInfo.account.webhook_secret);
if (app.call_status_hook) app.notifier = new HttpRequestor(logger,
this.accountInfo.account.account_sid, app.call_status_hook,
this.accountInfo.account.webhook_secret);
app.requestor.service_provider_sid = this.accountInfo.account.service_provider_sid;
if (app.call_status_hook) {
app.notifier = new HttpRequestor(logger,
this.accountInfo.account.account_sid, app.call_status_hook,
this.accountInfo.account.webhook_secret);
app.notifier.service_provider_sid = this.accountInfo.account.service_provider_sid;
}
else app.notifier = {request: () => {}, close: () => {}};
}
// Replace old application with new application.

View File

@@ -100,6 +100,30 @@ module.exports = (logger) => {
else if (K8S) {
lifecycleEmitter.scaleIn = () => process.exit(0);
}
else {
process.on('SIGUSR1', () => {
logger.info('received SIGUSR1: begin drying up calls for scale-in');
dryUpCalls = true;
const {srf} = require('../..');
const {writeSystemAlerts} = srf.locals;
if (writeSystemAlerts) {
const {SystemState, FEATURE_SERVER} = require('./constants');
writeSystemAlerts({
system_component: FEATURE_SERVER,
state : SystemState.GracefulShutdownInProgress,
fields : {
detail: `feature-server with process_id ${process.pid} shutdown in progress`,
host: srf.locals?.ipv4
}
});
}
pingProxies(srf);
// Note: in response to SIGUSR1 we start drying up but do not exit when calls reach zero.
// This is to allow external scripts that sent the signal to manage the lifecycle.
});
}
async function pingProxies(srf) {

View File

@@ -1085,13 +1085,6 @@ module.exports = (logger) => {
...(keyterms && keyterms.length > 0 && {DEEPGRAMFLUX_SPEECH_KEYTERMS: keyterms.join(',')}),
};
}
else if ('gladia' === vendor) {
const {host, path} = sttCredentials;
opts = {
GLADIA_SPEECH_HOST: host,
GLADIA_SPEECH_PATH: path,
};
}
else if ('soniox' === vendor) {
const {sonioxOptions = {}} = rOpts;
const {storage = {}} = sonioxOptions;
@@ -1317,6 +1310,9 @@ module.exports = (logger) => {
...(openaiOptions.turn_detection.silence_duration_ms && {
OPENAI_TURN_DETECTION_SILENCE_DURATION_MS: openaiOptions.turn_detection.silence_duration_ms
}),
...(openaiOptions.turn_detection.eagerness && {
OPENAI_TURN_DETECTION_EAGERNESS: openaiOptions.turn_detection.eagerness
})
};
}
}
@@ -1382,7 +1378,9 @@ module.exports = (logger) => {
speechmaticsOptions.transcription_config.audio_filtering_config.volume_threshold}),
...(speechmaticsOptions.transcription_config?.transcript_filtering_config?.remove_disfluencies &&
{SPEECHMATICS_REMOVE_DISFLUENCIES:
speechmaticsOptions.transcription_config.transcript_filtering_config.remove_disfluencies})
speechmaticsOptions.transcription_config.transcript_filtering_config.remove_disfluencies}),
SPEECHMATICS_END_OF_UTTERANCE_SILENCE_TRIGGER:
speechmaticsOptions.transcription_config?.conversation_config?.end_of_utterance_silence_trigger || 0.5
};
}
else if (vendor.startsWith('custom:')) {

View File

@@ -421,6 +421,7 @@ class TtsStreamingBuffer extends Emitter {
'cartesia',
'elevenlabs',
'rimelabs',
'google',
'custom'
].forEach((vendor) => {
const eventClassName = `${vendor.charAt(0).toUpperCase() + vendor.slice(1)}TtsStreamingEvents`;

View File

@@ -97,6 +97,7 @@ class WsRequestor extends BaseRequestor {
this.logger.debug({hook}, 'WsRequestor: sending a webhook (HTTP)');
const h = typeof hook === 'object' ? hook : {url: hook};
const requestor = new HttpRequestor(this.logger, this.account_sid, h, this.secret);
requestor.service_provider_sid = this.service_provider_sid;
if (type === 'session:redirect') {
this.close();
this.emit('handover', requestor);
@@ -522,6 +523,7 @@ class WsRequestor extends BaseRequestor {
const {writeAlerts, AlertType} = this.Alerter;
writeAlerts({
account_sid: this.account_sid,
service_provider_sid: this.service_provider_sid,
alert_type: AlertType.INVALID_APP_PAYLOAD,
target_sid: this.call_sid,
message: err.message,

2304
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
{
"name": "jambonz-feature-server",
"version": "0.9.5",
"version": "0.9.6",
"main": "app.js",
"engines": {
"node": ">= 18.x"
"node": ">= 20.x"
},
"keywords": [
"sip",
@@ -31,10 +31,10 @@
"@jambonz/http-health-check": "^0.0.1",
"@jambonz/mw-registrar": "^0.2.7",
"@jambonz/realtimedb-helpers": "^0.8.15",
"@jambonz/speech-utils": "^0.2.26",
"@jambonz/speech-utils": "^0.2.30",
"@jambonz/stats-collector": "^0.1.10",
"@jambonz/time-series": "^0.2.15",
"@jambonz/verb-specifications": "^0.0.122",
"@jambonz/time-series": "^0.2.17",
"@jambonz/verb-specifications": "^0.0.125",
"@modelcontextprotocol/sdk": "^1.9.0",
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/exporter-jaeger": "^1.23.0",
@@ -46,21 +46,21 @@
"@opentelemetry/sdk-trace-node": "^1.23.0",
"@opentelemetry/semantic-conventions": "^1.23.0",
"bent": "^7.3.12",
"debug": "^4.3.4",
"debug": "^4.4.3",
"deepcopy": "^2.1.0",
"drachtio-fsmrf": "^4.1.2",
"drachtio-srf": "^5.0.14",
"express": "^4.19.2",
"drachtio-srf": "^5.0.20",
"express": "^4.22.1",
"express-validator": "^7.0.1",
"moment": "^2.30.1",
"parse-url": "^9.2.0",
"pino": "^10.1.0",
"pino": "^10.3.1",
"polly-ssml-split": "^0.1.0",
"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": "^7.24.5",
"verify-aws-sns-signature": "^0.1.0",
"ws": "^8.18.0",
"xml2js": "^0.6.2"