diff --git a/lib/tasks/dialogflow/index.js b/lib/tasks/dialogflow/index.js index 8240a069..decffcf6 100644 --- a/lib/tasks/dialogflow/index.js +++ b/lib/tasks/dialogflow/index.js @@ -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); diff --git a/lib/tasks/dialogflow/intent.js b/lib/tasks/dialogflow/intent.js index d09936bd..869a7c99 100644 --- a/lib/tasks/dialogflow/intent.js +++ b/lib/tasks/dialogflow/intent.js @@ -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 - }; - } - } -};