UNPKG

@onereach/step-voice

Version:
348 lines (347 loc) 14.6 kB
"use strict"; /* eslint-disable @typescript-eslint/strict-boolean-expressions, @typescript-eslint/explicit-function-return-type */ Object.defineProperty(exports, "__esModule", { value: true }); const tslib_1 = require("tslib"); const types_1 = require("@onereach/flow-sdk/dst/types"); const lodash_1 = tslib_1.__importDefault(require("lodash")); const voice_1 = tslib_1.__importDefault(require("./voice")); const CALL_QUALITY_RATINGS = { EXCELLENT: { mos: [4.34, Infinity], jitter: [0, 20], pktLost: [0, 2] }, GOOD: { mos: [4.03, 4.34], jitter: [20, 100], pktLost: [2, 4] }, FAIR: { mos: [3.6, 4.03], jitter: [100, 150], pktLost: [4, 5] }, POOR: { mos: [3.1, 3.6], jitter: [150, 200], pktLost: [5, 6] }, BAD: { mos: [-Infinity, 3.1], jitter: [200, Infinity], pktLost: [6, Infinity] } }; var CallQualityWeights; (function (CallQualityWeights) { CallQualityWeights[CallQualityWeights["EXCELLENT"] = 5] = "EXCELLENT"; CallQualityWeights[CallQualityWeights["GOOD"] = 4] = "GOOD"; CallQualityWeights[CallQualityWeights["FAIR"] = 3] = "FAIR"; CallQualityWeights[CallQualityWeights["POOR"] = 2] = "POOR"; CallQualityWeights[CallQualityWeights["BAD"] = 1] = "BAD"; })(CallQualityWeights || (CallQualityWeights = {})); const getRequiredDataFromCallStats = (rawData) => { const [mos, jitter, skipPackets, mediaPackets] = [ 'variable_rtp_audio_in_mos', 'variable_rtp_audio_in_jitter_max_variance', 'variable_rtp_audio_in_skip_packet_count', 'variable_rtp_audio_in_media_packet_count' ].map(prop => lodash_1.default.toNumber(lodash_1.default.get(rawData, prop))); const pktLostPercent = lodash_1.default.chain(skipPackets) .divide(mediaPackets) .multiply(100) .round(2) .value(); return { mos, jitter, pktLostPercent }; }; const getCallDataRatings = (qualityRatings, { mos, jitter, pktLostPercent }) => lodash_1.default.reduce(qualityRatings, (acc, value, key) => ({ ...acc, ...lodash_1.default.inRange(mos, ...value.mos) && { mosRating: key }, ...lodash_1.default.inRange(jitter, ...value.jitter) && { jitterRating: key }, ...lodash_1.default.inRange(pktLostPercent, ...value.pktLost) && { pktlostPercentRating: key }, }), { mosRating: 'UNKNOWN', jitterRating: 'UNKNOWN', pktlostPercentRating: 'UNKNOWN' }); const getLowestCallRating = (qualityWeights, ratings) => lodash_1.default.reduce(ratings, (result, value) => result && (qualityWeights[result] <= qualityWeights[value]) ? result : value); function getAnalizedCallData(rawData, qualityRatings, qualityWeights) { const { mos, jitter, pktLostPercent } = getRequiredDataFromCallStats(rawData); if (lodash_1.default.isNaN(mos) || lodash_1.default.isNaN(jitter) || lodash_1.default.isNaN(pktLostPercent)) { // @ts-ignore this.log.error('Missing call quality data'); return; } const ratings = getCallDataRatings(qualityRatings, { mos, jitter, pktLostPercent }); return { mosValue: mos, mosQualification: ratings.mosRating, jitterValue: jitter, jitterQualification: ratings.jitterRating, pktLostValue: pktLostPercent, pktLostQualification: ratings.pktlostPercentRating, callQualification: getLowestCallRating(qualityWeights, ratings) }; } class GlobalCommand extends voice_1.default { get isGlobal() { return true; } get useQueue() { return this.isWorker; } async runStep() { const call = await this.fetchData(); const allowAck = this.canVoicerAck(call); if (this.thread.id === this.workerThreadId) { throw new Error('gc worker should not use runStep, is another gc on hangup leg?'); } const worker = this.process.newThread(this.workerThreadId, thread => { thread.state = { name: this.worker.name, step: this.step.id, thread: this.dataThreadId, acktrd: allowAck ? this.thread.id : undefined }; }); if (worker.isNewThread) { worker.activate(); await worker.run(); await this._refreshCache(); } else if (worker.state.acktrd == null) { worker.state.step = this.step.id; worker.state.acktrd = allowAck ? this.thread.id : undefined; await this.initGrammar(call); } if (allowAck) { this.triggers.local('ack', ({ params: { ack } }) => { delete worker.state.acktrd; if (ack) this.exitStep('no commands'); else this.end(); }); } else { this.exitStep('no commands'); } } async initGrammar(call) { const choices = this.buildChoices({ choices: this.data.choices }); const grammar = await this.buildGrammar(call, choices); // set grammar into voice conversation // this was required by nexmo integrations, could be skipped for now // channel.global = grammar; const recordSession = this.data.recordCall && !call.recordCall; if (recordSession) { call.recordCall = true; await this.updateData(); } // set grammar on voicer await this.sendCommands(call, [ { name: 'grammar', params: { ack: this.canVoicerAck(call), grammar } }, ...recordSession ? [{ name: 'record-session', params: { followTransfer: this.data.recordAfterTransfer, fileTtl: this.data.recordFileTtl, linkTtl: this.data.recordLinkTtl } }] : [], ...(lodash_1.default.isUndefined(this.data.qualityOfService) ? [] : [{ name: 'set_monitoring_settings', params: call.vv >= 4 ? { qualityOfService: this.data.qualityOfService } : { addCallQuality: lodash_1.default.includes(['enabled', 'enabledWithRaw'], this.data.qualityOfService) } }]) ]); } async worker() { const call = await this.fetchData(); this.triggers.otherwise(async () => { await this.initGrammar(call); }); this.triggers.hook({ name: "waitEnd" /* ACTION.waitEnd */, thread: types_1.MAIN_THREAD_ID, sync: true, times: 1 }, async () => { // TODO: this is not required in newer versions, delete in v6.1+ const allowAck = this.canVoicerAck(call); if (!allowAck) { delete this.waits['@waitEnd']; delete this.waits.waitEnd; } if (!call.ended) { this.state.passive = true; await this.sendCommands(call, [{ name: 'grammar', params: {} }]); } if (this.thread.background) this.end(); }); this.triggers.local(`in/voice/${call.id}`, async (event) => { switch (event.params.type) { case 'ack': { this.acceptAck({ ack: true }); return; } case 'hangup': this.acceptAck({ ack: false }); await this.hangup(call); return; case 'avm-detected': await this.exitThread(event, 'AMD', 'AMD'); return; case 'digit': case 'digits': { const params = event.params; const exitId = params.exitId; if (exitId && this.getExitStepId(exitId, false)) { await this.transcript(call, { previousTranscriptId: call.lastTranscriptId, keyPress: params.digit, message: lodash_1.default.replace(this.getExitStepLabel(exitId) ?? '', /^(\d+\. )?(~ )?/, ''), action: 'Call DTMF', reportingSettingsKey: 'transcript', actionFromBot: false }); await this.resumeRecording(call, { muteStep: true, muteUser: false, muteBot: false }); return await this.exitThread(event, 'digit', exitId); } else { this.log.debug('exitId missmatch', { exitId, exits: this.step.exits }); } break; } case 'recognition': { const params = event.params; const exitId = params.exitId; if (exitId && this.getExitStepId(exitId, false)) { const voiceProcessResult = lodash_1.default.chain(params.phrases) .map((p) => p.lexical) .join(' | ') .value(); await this.transcript(call, { previousTranscriptId: call.lastTranscriptId, message: lodash_1.default.replace(this.getExitStepLabel(exitId) ?? '', /^(\d+\. )?(~ )?/, ''), action: 'Call Recognition', voiceProcessResult, reportingSettingsKey: 'transcript', actionFromBot: false }); await this.resumeRecording(call, { muteStep: true, muteUser: false, muteBot: false }); return await this.exitThread(event, 'voice', exitId); } else { this.log.debug('exitId missmatch', { exitId, exits: this.step.exits }); } break; } case 'error': { if (event.params.error) { this.log.error('gc.error', event.params.error); // TODO throw it? } break; } case 'cancel': { return this.handleCancel(); } case 'background': { this.thread.background = event.params.background ?? false; await this.handleHeartbeat(call); break; } default: break; } }); } getQualityOfServiceResult(call) { if (!lodash_1.default.includes(['enabled', 'enabledWithRaw'], this.data.qualityOfService)) return {}; if (call.vv >= 4) return this.event.params.cause?.qualityOfServiceResult; return { qos: getAnalizedCallData.call(this, this.event.params.cause?.callQualityVariables, CALL_QUALITY_RATINGS, CallQualityWeights), ...(this.data.qualityOfService === 'enabledWithRaw' && { qosRaw: this.event.params.cause?.callQualityVariables }) }; } async hangup(call) { if (this.event.params.zombie) { if (this.state.passive) { this.log.debug('ignore zombie hangup waiting for full hangup'); return this.exitFlow(); } return this.end(); } await this.handleHangup(call); const isHangedUpByBot = call.sessionEndedBy === 'Bot'; let hangUpType = isHangedUpByBot ? 'bot hang up' : 'user hang up'; if (this.event.params.error != null) { hangUpType = 'unexpected hang up'; } let processHangUp = this.data.processHangUp; // process call recording in hangup event if (call.recordCall && this.event.params.callRecording != null) { processHangUp = 'both'; } switch (processHangUp) { case 'user': if (isHangedUpByBot) return this.end(); break; case 'bot': if (!isHangedUpByBot) return this.end(); break; case 'both': break; // case 'none': default: return this.end(); } return this.exitStep('hang up', { type: hangUpType, zombie: this.event.params.zombie, callRecording: this.event.params.callRecording, conversation: this.conversation, conversationThreadId: this.dataThreadId, ...this.getQualityOfServiceResult(call) }); } async exitThread(event, type, stepExit) { const params = event.params; const result = { type }; if (!lodash_1.default.isEmpty(params.tags)) { result.tags = params.tags; } if (!lodash_1.default.isEmpty(params.out)) { result.out = params.out; } const digits = params.digits ?? params.digit; if (!lodash_1.default.isEmpty(digits)) { result.digit = digits; result.value = digits; } if (!lodash_1.default.isEmpty(params.language)) { result.language = params.language; } if (!lodash_1.default.isEmpty(params.phrases)) { const phrases = params.phrases; result.value = phrases[0].text; result.interpretation = phrases; } // add information about call recording to a merge field if (!lodash_1.default.isEmpty(params.callRecording)) { result.callRecording = params.callRecording; } await this.exitStepByThread(stepExit, result); } async buildGrammar(call, choices) { const { asr } = this.data; return { id: this.currentStepId, choices, asr: asr.getSettings(call.asr) }; } acceptAck(eventParams) { if (this.state.acktrd) { this.process.enqueue({ thread: this.state.acktrd, name: 'ack', params: eventParams }); } } } exports.default = GlobalCommand;