@onereach/step-voice
Version:
Onereach.ai Voice Steps
356 lines (355 loc) • 15 kB
JavaScript
"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 process_1 = require("@onereach/flow-sdk/dst/types/process");
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?');
}
this.triggers.otherwise(async () => {
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);
}
});
// Set loop prevention settings on call object
if (this.data.loopPrevention_enabled) {
this.session.data.loopPrevention = {
enabled: this.data.loopPrevention_enabled,
maxLoops: this.data.loopPrevention_maxLoops
};
await this.updateData();
}
if (allowAck) {
this.triggers.local('ack', ({ params: { ack } }) => {
const worker = this.process.getThread(this.workerThreadId);
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);
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: process_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;