Compare commits

..

3 Commits

Author SHA1 Message Date
Sam Machin
919dafb959 Merge branch 'main' into fix/1509 2026-02-03 15:33:59 +00:00
Sam Machin
85bd21045d deep copy call data for listen 2026-02-03 15:32:27 +00:00
Sam Machin
db4d887efd escape tag data in listen 2026-02-03 11:25:53 +00:00
14 changed files with 906 additions and 1133 deletions

View File

@@ -1,10 +1,10 @@
FROM --platform=linux/amd64 node:24-alpine AS base
FROM --platform=linux/amd64 node:20-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

@@ -18,7 +18,6 @@ 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) => {
@@ -123,16 +122,8 @@ router.post('/',
}
break;
case 'user':
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;
uri = `sip:${target.name}`;
to = target.name;
if (target.overrideTo) {
Object.assign(opts.headers, {
'X-Override-To': target.overrideTo
@@ -340,12 +331,6 @@ 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({message: err.message}, 'unprocessable request');
return res.status(422).send(err.message);
logger.info(err, 'unprocessable request');
return res.status(422).json({msg: err.message});
}
if (err.code === 'ER_DUP_ENTRY') {
logger.info(err, 'duplicate entry on insert');

View File

@@ -28,7 +28,6 @@ module.exports = function(srf, logger) {
registrar,
lookupClientByAccountAndUsername
} = srf.locals.dbHelpers;
const {addKey} = srf.locals.dbHelpers;
const {
writeAlerts,
AlertType
@@ -143,14 +142,6 @@ module.exports = function(srf, logger) {
rootSpan
};
/* write a short-lived Redis key mapping the original Call-ID (X-CID) to the traceId,
so sbc-inbound can look it up for CDRs even when a CANCEL is sent to sbc-inbound */
const externalCallId = req.get('X-CID');
if (externalCallId) {
addKey(`callid:${externalCallId}`, traceId, 300)
.catch((err) => logger.error(err, 'createRootSpan: error writing traceId to Redis'));
}
/**
* end the span on final failure or cancel from caller;
* otherwise it will be closed when sip dialog is destroyed
@@ -220,9 +211,7 @@ module.exports = function(srf, logger) {
if (account?.enable_debug_log) {
req.locals.logger.level = 'debug';
}
// 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}`);
logger.debug({accountInfo: req.locals?.accountInfo?.account}, `retrieved account info for ${account_sid}`);
next();
} catch (err) {
span.end();

View File

@@ -200,10 +200,6 @@ class CallSession extends Emitter {
return this.backgroundTaskManager.isTaskRunning('listen');
}
get isBackGroundRecord() {
return this.backgroundTaskManager.isTaskRunning('record');
}
/**
* SIP call-id for the call
*/
@@ -1488,12 +1484,7 @@ class CallSession extends Emitter {
else if (opts.call_status === CallStatus.NoAnswer) {
if (this.direction === CallDirection.Inbound) {
if (this.res && !this.res.finalResponseSent) {
const traceId = this.req?.locals?.traceId;
this.res.send(503, {
headers: {
...(traceId && {'X-Trace-ID': traceId})
}
});
this.res.send(503);
this._callReleased();
}
}
@@ -2320,8 +2311,7 @@ Duration=${duration} `
break;
case 'mute:status':
const status = typeof (data) === 'string' ? data : data.mute_status;
this._lccMuteStatus(status === 'mute', call_sid);
this._lccMuteStatus(data, call_sid);
break;
case 'conf:mute-status':
@@ -2526,12 +2516,10 @@ Duration=${duration} `
// 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`);
const traceId = this.req?.locals?.traceId;
this.res.send(err.status, {
headers: {
'X-Reason': `endpoint allocation failure: ${err.reason || 'Endpoint Allocation Failed'}`,
...(sipReasonHeader && {'Reason': sipReasonHeader}),
...(traceId && {'X-Trace-ID': traceId})
...(sipReasonHeader && {'Reason': sipReasonHeader})
}
});
this._notifyCallStatusChange({
@@ -2792,7 +2780,6 @@ Duration=${duration} `
}
} catch (err) {
this.logger.error(err, 'Error handling reinvite');
res.send(err.status || 500);
}
}

View File

@@ -51,14 +51,12 @@ class InboundCallSession extends CallSession {
_onTasksDone() {
if (!this.res.finalResponseSent) {
const traceId = this.req.locals.traceId;
if (this._mediaServerFailure) {
this.rootSpan.setAttributes({'call.termination': 'media server failure'});
this.logger.info('InboundCallSession:_onTasksDone generating 480 due to media server failure');
this.res.send(480, {
headers: {
'X-Reason': 'crankback: media server failure',
...(traceId && {'X-Trace-ID': traceId})
'X-Reason': 'crankback: media server failure'
}
});
}
@@ -71,19 +69,14 @@ class InboundCallSession extends CallSession {
this.res.send(status, {
headers: {
'X-Reason': `endpoint allocation failure: ${reason}`,
...(sipReasonHeader && {'Reason': sipReasonHeader}),
...(traceId && {'X-Trace-ID': traceId})
...(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');
this.res.send(603, {
headers: {
...(traceId && {'X-Trace-ID': traceId})
}
});
this.res.send(603);
}
}
this.req.removeAllListeners('cancel');

View File

@@ -469,7 +469,6 @@ 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'));
}
@@ -571,8 +570,8 @@ class Conference extends Task {
/**
* mute or unmute side of the call
*/
async mute(callSid, doMute) {
this.doConferenceMute(this.callSession, {conf_mute_status: doMute ? 'mute' : 'unmute'});
mute(callSid, doMute) {
this.doConferenceMute(this.callSession, {conf_mute_status: doMute});
}
/**

View File

@@ -160,7 +160,6 @@ 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 ||

View File

@@ -1,4 +1,3 @@
const assert = require('assert');
const Task = require('../task');
const {TaskName, TaskPreconditions} = require('../../utils/constants');
const Intent = require('./intent');
@@ -11,27 +10,19 @@ 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';
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';
/* 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}`;
}
else {
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.project = this.data.project;
}
this.lang = this.data.lang || 'en-US';
@@ -48,6 +39,7 @@ 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',
@@ -68,33 +60,38 @@ 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.fallbackVoice || 'default';
this.fallbackVoice = this.data.tts.fallbackLanguage || '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);
await this.startBot('default');
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.awaitTaskDone();
} catch (err) {
this.logger.error({err}, 'Dialogflow:exec error');
@@ -111,12 +108,6 @@ 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'})
@@ -150,12 +141,6 @@ 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);
@@ -166,102 +151,41 @@ 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(this.cmd, this._buildStartArgs({event: this.noInputEvent}));
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang} ${this.noInputEvent}`);
}
else if (this.dtmfEntry && this.greetingPlayed) {
this.logger.info('dtmf detected, reprompting..');
ep.api(this.cmd, this._buildStartArgs({text: this.dtmfEntry}));
ep.api('dialogflow_start', `${ep.uuid} ${this.project} ${this.lang} none \'${this.dtmfEntry}\'`);
this.dtmfEntry = null;
}
else {
this.logger.info('got empty intent, restarting');
ep.api(this.cmd, this._buildStartArgs());
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}`);
}
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;
}
@@ -269,10 +193,14 @@ class Dialogflow extends Task {
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(() => {
@@ -283,6 +211,8 @@ class Dialogflow extends Task {
}
}, 1000);
}
/* collect digits? */
else if (intent.saysCollectDtmf || this.enableDtmfAlways) {
const opts = Object.assign({
idt: this.opts.interDigitTimeout
@@ -291,44 +221,68 @@ class Dialogflow extends Task {
this.digitBuffer.once('fulfilled', this._onDtmfEntryComplete.bind(this, ep));
}
// If we have a TTS vendor and fulfillment text, synthesize and play
/* if we are using tts and a message was provided, play it out */
if (this.vendor && intent.fulfillmentText && intent.fulfillmentText.length > 0) {
const {srf} = cs;
const {stats} = srf.locals;
const {synthAudio} = srf.locals.dbHelpers;
this.waitingForPlayStart = false;
// ES: start a new intent during playback so we continue to listen
if (!this.hangupAfterPlayDone && this.isES) {
ep.api(this.cmd, this._buildStartArgs());
// 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}`);
}
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);
await this._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;
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);
}
} 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 {
return await synthAudio(stats, {
const obj = {
account_sid: cs.accountSid,
text: intent.fulfillmentText,
vendor: this.vendor,
@@ -336,13 +290,17 @@ 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');
if (this.fallbackVendor) {
try {
try {
if (this.fallbackVendor) {
const credentials = cs.getSpeechCredentials(this.fallbackVendor, 'tts', this.fallbackLabel);
return await synthAudio(stats, {
const obj = {
account_sid: cs.accountSid,
text: intent.fulfillmentText,
vendor: this.fallbackVendor,
@@ -350,20 +308,24 @@ class Dialogflow extends Task {
voice: this.fallbackVoice,
salt: cs.callSid,
credentials
});
} catch (err) {
this.logger.info({err}, 'Failed to synthesize audio from fallback vendor');
throw err;
};
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 falllback vendor');
throw err;
}
throw error;
}
}
/**
* A transcription has been returned.
* @param {*} ep - media server endpoint
* @param {*} cs - call session
* 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
* @param {*} evt - event data
*/
async _onTranscription(ep, cs, evt) {
@@ -376,11 +338,13 @@ 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;
@@ -389,21 +353,17 @@ class Dialogflow extends Task {
}
/**
* The caller has just finished speaking.
* @param {*} ep - media server endpoint
* @param {*} cs - call session
* The caller has just finished speaking. No action currently taken.
* @param {*} evt - event data
*/
_onEndOfUtterance(ep, cs, evt) {
_onEndOfUtterance(cs, evt) {
if (this.events.includes('end-utterance')) {
this._performHook(cs, this.eventHook, {event: 'end-utterance'});
}
}
/**
* Dialogflow has returned an error.
* @param {*} ep - media server endpoint
* @param {*} cs - call session
* Dialogflow has returned an error of some kind.
* @param {*} evt - event data
*/
_onError(ep, cs, evt) {
@@ -412,87 +372,70 @@ class Dialogflow extends Task {
/**
* Audio has been received from dialogflow and written to a temporary disk file.
* Play the audio, then restart or hang up as appropriate.
* Start playing the audio, after killing any filler sound that might be playing.
* When the audio completes, start the no-input timer.
* @param {*} ep - media server endpoint
* @param {*} cs - call session
* @param {*} evt - event data
*/
async _onAudioProvided(ep, cs, evt) {
// For CX: suppress NO_INPUT reprompt audio and silently restart
if (this._suppressNextCXAudio) {
this._suppressNextCXAudio = false;
ep.api(this.cmd, this._buildStartArgs());
return;
}
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.vendor) return;
this._cxAudioHandled = true;
this.waitingForPlayStart = false;
// kill filler audio
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());
// 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}`);
}
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;
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: filePath}});
this._performHook(cs, this.eventHook, {event: 'start-play', data: {path: evt.path}});
}
await ep.play(filePath);
await ep.play(evt.path);
if (this.events.includes('stop-play')) {
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: filePath}});
this._performHook(cs, this.eventHook, {event: 'stop-play', data: {path: evt.path}});
}
this.logger.info(`finished ${evt.path}, queued tasks: ${(this.queuedTasks || []).length}`);
if (this.curentAudioFile === filePath) {
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 = null;
this.queuedTasks.length = 0;
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.greetingPlayed = true;
if (this.hangupAfterPlayDone) {
this.logger.info('hanging up after end interaction prompt');
this.logger.info('hanging up since intent was marked end interaction and we completed final prompt');
this.performAction({dialogflowResult: 'completed'});
this.notifyTaskDone();
}
else {
// CX: restart to listen for the next utterance
if (this.isCX) {
ep.api(this.cmd, this._buildStartArgs());
}
// every time we finish playing a prompt, start the no-input timer
this._startNoinputTimer(ep, cs);
}
}
/**
* Receive a DTMF entry from the caller.
* receive a dmtf entry from the caller.
* If we have active dtmf instructions, collect and process accordingly.
*/
_onDtmf(ep, cs, evt) {
if (this.digitBuffer) this.digitBuffer.process(evt.dtmf);
@@ -501,48 +444,41 @@ class Dialogflow extends Task {
}
}
async _onDtmfEntryComplete(ep, dtmfEntry) {
_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'));
}
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}`));
}
// 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}`));
}
async _onNoInput(ep, cs) {
this.logger.info('no-input timer fired');
/**
* 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;
if (this.events.includes('no-input')) {
this._performHook(cs, this.eventHook, {event: 'no-input'});
this._performHook(cs, this.eventHook, {event: 'no-input'});
}
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}`));
}
// 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}`));
}
/**
* Stop the no-input timer, if it is running
*/
_clearNoinputTimer() {
if (this.noinputTimer) {
clearTimeout(this.noinputTimer);
@@ -550,6 +486,10 @@ 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();
@@ -567,7 +507,7 @@ class Dialogflow extends Task {
if (tasks && tasks.length > 0) {
if (this.playInProgress) {
this.queuedTasks = tasks;
this.logger.info({tasks},
this.logger.info({tasks: tasks},
`${this.name} replacing application with ${tasks.length} tasks after play completes`);
return;
}
@@ -577,7 +517,7 @@ class Dialogflow extends Task {
}
_redirect(cs, tasks) {
this.logger.info({tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.logger.info({tasks: tasks}, `${this.name} replacing application with ${tasks.length} tasks`);
this.performAction({dialogflowResult: 'redirect'}, false);
this.reportedFinalAction = true;
cs.replaceApplication(tasks);

View File

@@ -3,44 +3,20 @@ class Intent {
this.logger = logger;
this.evt = 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;
this.logger.debug({evt}, 'intent');
this.dtmfRequest = checkIntentForDtmfEntry(logger, evt);
}
get isEmpty() {
return !(this.response_id?.length > 0);
return this.evt.response_id.length === 0;
}
get fulfillmentText() {
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;
return this.evt.query_result.fulfillment_text;
}
get saysEndInteraction() {
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;
return this.evt.query_result.intent.end_interaction ;
}
get saysCollectDtmf() {
@@ -52,23 +28,7 @@ class Intent {
}
get 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;
if (!this.isEmpty) return this.evt.query_result.intent.display_name;
}
toJSON() {
@@ -78,48 +38,52 @@ 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

@@ -1424,26 +1424,7 @@ class TaskGather extends SttTask {
returnedVerbs = await this.performAction({speech:evt, reason: 'stt-low-confidence', ...latencies});
}
}
} 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,
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');
}
}
} catch (err) { /*already logged error*/ }
// Gather got response from hook, cancel actionHookDelay processing
if (this.cs.actionHookDelayProcessor) {

View File

@@ -234,11 +234,6 @@ 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',
@@ -295,7 +290,6 @@ class SingleDialer extends Emitter {
}
} catch (err) {
this.logger.error(err, 'Error handling reinvite');
res.send(err.status || 500);
}
})
.on('refer', (req, res) => {

1374
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"version": "0.9.5",
"main": "app.js",
"engines": {
"node": ">= 20.x"
"node": ">= 18.x"
},
"keywords": [
"sip",
@@ -46,21 +46,21 @@
"@opentelemetry/sdk-trace-node": "^1.23.0",
"@opentelemetry/semantic-conventions": "^1.23.0",
"bent": "^7.3.12",
"debug": "^4.4.3",
"debug": "^4.3.4",
"deepcopy": "^2.1.0",
"drachtio-fsmrf": "^4.1.2",
"drachtio-srf": "^5.0.20",
"express": "^4.22.1",
"drachtio-srf": "^5.0.14",
"express": "^4.19.2",
"express-validator": "^7.0.1",
"moment": "^2.30.1",
"parse-url": "^9.2.0",
"pino": "^10.3.1",
"pino": "^10.1.0",
"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.24.5",
"undici": "^7.5.0",
"verify-aws-sns-signature": "^0.1.0",
"ws": "^8.18.0",
"xml2js": "^0.6.2"