botium-connector-voip 0.0.25 → 0.0.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/botium-connector-voip-cjs.js +276 -186
- package/dist/botium-connector-voip-cjs.js.map +1 -1
- package/dist/botium-connector-voip-es.js +276 -186
- package/dist/botium-connector-voip-es.js.map +1 -1
- package/index.js +7 -0
- package/package.json +1 -1
- package/src/connector.js +262 -186
|
@@ -50,6 +50,7 @@ const Capabilities = {
|
|
|
50
50
|
VOIP_STT_MESSAGE_HANDLING: 'VOIP_STT_MESSAGE_HANDLING',
|
|
51
51
|
VOIP_STT_MESSAGE_HANDLING_TIMEOUT: 'VOIP_STT_MESSAGE_HANDLING_TIMEOUT',
|
|
52
52
|
VOIP_STT_MESSAGE_HANDLING_TIMEOUT_SUBSEQUENT: 'VOIP_STT_MESSAGE_HANDLING_TIMEOUT_SUBSEQUENT',
|
|
53
|
+
VOIP_JOIN_SILENCE_DURATION_BY_SUBSTRING: 'VOIP_JOIN_SILENCE_DURATION_BY_SUBSTRING',
|
|
53
54
|
VOIP_STT_MESSAGE_HANDLING_DELIMITER: 'VOIP_STT_MESSAGE_HANDLING_DELIMITER',
|
|
54
55
|
VOIP_STT_MESSAGE_HANDLING_PUNCTUATION: 'VOIP_STT_MESSAGE_HANDLING_PUNCTUATION',
|
|
55
56
|
VOIP_TTS_URL: 'VOIP_TTS_URL',
|
|
@@ -250,19 +251,9 @@ class BotiumConnectorVoip {
|
|
|
250
251
|
const armJoinSilenceTimer = () => {
|
|
251
252
|
if (!this.botMsgs || this.botMsgs.length === 0) return;
|
|
252
253
|
const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
|
|
253
|
-
const
|
|
254
|
-
const isJoinMethod = sttHandling === 'JOIN' || sttHandling === 'PSST' || sttHandling === 'CONCAT' || !lodash__default["default"].isNil(joinLogicHook);
|
|
254
|
+
const isJoinMethod = sttHandling === 'JOIN' || sttHandling === 'PSST' || sttHandling === 'CONCAT' || this._hasJoinLogicHookOrRule(this.convoStep);
|
|
255
255
|
if (!isJoinMethod) return;
|
|
256
|
-
const
|
|
257
|
-
const toJoinTimeoutMs = ms => {
|
|
258
|
-
const parsed = parseInt(ms, 10);
|
|
259
|
-
if (!lodash__default["default"].isFinite(parsed) || parsed <= 0) return null;
|
|
260
|
-
return isPsst ? Math.max(0, parsed - 500) : parsed;
|
|
261
|
-
};
|
|
262
|
-
let joinTimeoutMs = toJoinTimeoutMs(lodash__default["default"].get(joinLogicHook, 'args[0]'));
|
|
263
|
-
if (!lodash__default["default"].isFinite(joinTimeoutMs) || joinTimeoutMs <= 0) {
|
|
264
|
-
joinTimeoutMs = toJoinTimeoutMs(this._getEffectiveMessageHandlingTimeout());
|
|
265
|
-
}
|
|
256
|
+
const joinTimeoutMs = this._getEffectiveJoinTimeoutMs(this.convoStep, this.botMsgs);
|
|
266
257
|
if (this.silenceTimeout) {
|
|
267
258
|
clearTimeout(this.silenceTimeout);
|
|
268
259
|
this.silenceTimeout = null;
|
|
@@ -505,19 +496,7 @@ class BotiumConnectorVoip {
|
|
|
505
496
|
// For PSST: send join silence duration per step to VOIP worker (controls PSST silence trigger)
|
|
506
497
|
try {
|
|
507
498
|
if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'PSST' && this.ws && this.ws.readyState === ws__default["default"].OPEN) {
|
|
508
|
-
const
|
|
509
|
-
let silenceMs = null;
|
|
510
|
-
if (joinLogicHook && joinLogicHook.args && joinLogicHook.args.length > 0) {
|
|
511
|
-
silenceMs = parseInt(joinLogicHook.args[0], 10);
|
|
512
|
-
}
|
|
513
|
-
// Fallback to effective timeout (subsequent if past first flush, else global)
|
|
514
|
-
if (!lodash__default["default"].isFinite(silenceMs) || silenceMs <= 0) {
|
|
515
|
-
silenceMs = parseInt(this._getEffectiveMessageHandlingTimeout(), 10);
|
|
516
|
-
}
|
|
517
|
-
// PSST: treat like JOIN, but subtract 500ms from timeout
|
|
518
|
-
if (lodash__default["default"].isFinite(silenceMs) && silenceMs > 0) {
|
|
519
|
-
silenceMs = Math.max(0, silenceMs - 500);
|
|
520
|
-
}
|
|
499
|
+
const silenceMs = this._getEffectiveJoinTimeoutMs(convoStep, this.botMsgs);
|
|
521
500
|
if (lodash__default["default"].isFinite(silenceMs) && silenceMs > 0 && this.sessionId) {
|
|
522
501
|
debug$3(`PSST: sending silenceDurationMs=${silenceMs} for sessionId=${this.sessionId}`);
|
|
523
502
|
this.ws.send(JSON.stringify({
|
|
@@ -589,6 +568,16 @@ class BotiumConnectorVoip {
|
|
|
589
568
|
tts_body: this.caps[Capabilities.VOIP_TTS_BODY] || null
|
|
590
569
|
}
|
|
591
570
|
};
|
|
571
|
+
_info('initcall_sent', {
|
|
572
|
+
sipCallerAddress: request.SIP_CALLER_ADDRESS,
|
|
573
|
+
sipRegistrarUri: request.SIP_CALLER_REGISTRAR_URI,
|
|
574
|
+
sipCalleeUri: request.SIP_CALLEE_URI,
|
|
575
|
+
sipCallerUsername: request.SIP_CALLER_USERNAME,
|
|
576
|
+
sipProxy: request.SIP_PROXY || null,
|
|
577
|
+
iceEnable: request.ICE_ENABLE,
|
|
578
|
+
sttUrl: request.STT_CONFIG && request.STT_CONFIG.stt_url,
|
|
579
|
+
ttsUrl: request.TTS_CONFIG && request.TTS_CONFIG.tts_url
|
|
580
|
+
});
|
|
592
581
|
debug$3(JSON.stringify(request, null, 2));
|
|
593
582
|
this.ws.send(JSON.stringify(request));
|
|
594
583
|
this.ws.on('error', err => {
|
|
@@ -729,9 +718,22 @@ class BotiumConnectorVoip {
|
|
|
729
718
|
return;
|
|
730
719
|
}
|
|
731
720
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'unauthorized') {
|
|
721
|
+
_info('callinfo_sip_unauthorized', {
|
|
722
|
+
sessionId: this.sessionId,
|
|
723
|
+
event: parsedData.event || null,
|
|
724
|
+
sipCallerUsername: this.caps[Capabilities.VOIP_SIP_CALLER_USERNAME],
|
|
725
|
+
sipRegistrarUri: this.caps[Capabilities.VOIP_SIP_CALLER_REGISTRAR_URI]
|
|
726
|
+
});
|
|
732
727
|
reject(new Error('Error: Cannot open a call: SIP Authorization failed'));
|
|
733
728
|
}
|
|
734
729
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'forbidden' && parsedData.event === 'onCallRegState') {
|
|
730
|
+
_info('callinfo_sip_reg_failed', {
|
|
731
|
+
sessionId: this.sessionId,
|
|
732
|
+
event: parsedData.event || null,
|
|
733
|
+
sipCallerAddress: this.caps[Capabilities.VOIP_SIP_CALLER_ADDRESS],
|
|
734
|
+
sipCallerUsername: this.caps[Capabilities.VOIP_SIP_CALLER_USERNAME],
|
|
735
|
+
sipRegistrarUri: this.caps[Capabilities.VOIP_SIP_CALLER_REGISTRAR_URI]
|
|
736
|
+
});
|
|
735
737
|
reject(new Error('Error: Sip Registration failed'));
|
|
736
738
|
}
|
|
737
739
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'connected') {
|
|
@@ -758,8 +760,20 @@ class BotiumConnectorVoip {
|
|
|
758
760
|
});
|
|
759
761
|
}
|
|
760
762
|
}
|
|
763
|
+
if (parsedData && parsedData.type === 'callinfo' && !['initialized', 'unauthorized', 'forbidden', 'connected', 'disconnected'].includes(parsedData.status)) {
|
|
764
|
+
_info('callinfo_unknown_status', {
|
|
765
|
+
sessionId: this.sessionId,
|
|
766
|
+
status: parsedData.status || null,
|
|
767
|
+
event: parsedData.event || null
|
|
768
|
+
});
|
|
769
|
+
}
|
|
761
770
|
if (parsedData && parsedData.type === 'error') {
|
|
762
771
|
flushPendingBotMsgs('error');
|
|
772
|
+
_info('ws_error_msg', {
|
|
773
|
+
sessionId: this.sessionId,
|
|
774
|
+
message: parsedData.message || null,
|
|
775
|
+
code: parsedData.code || null
|
|
776
|
+
});
|
|
763
777
|
this.end = true;
|
|
764
778
|
reject(new Error(`Error: ${parsedData.message}`));
|
|
765
779
|
sendBotMsg(new Error(`Error: ${parsedData.message}`));
|
|
@@ -791,7 +805,7 @@ class BotiumConnectorVoip {
|
|
|
791
805
|
}
|
|
792
806
|
if (parsedData && parsedData.type === 'silence') {
|
|
793
807
|
if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
|
|
794
|
-
if (
|
|
808
|
+
if (!this._hasJoinLogicHookOrRule(this.convoStep) && parsedData.data.silence.length > 0) {
|
|
795
809
|
flushPendingBotMsgs('silence_exceeded');
|
|
796
810
|
this.end = true;
|
|
797
811
|
sendBotMsg(new Error(`Silence Duration of ${parsedData.data.silence[0][2].toFixed(2)}s exceeded General Silence Duration Timeout of ${this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT] / 1000}s`));
|
|
@@ -872,7 +886,7 @@ class BotiumConnectorVoip {
|
|
|
872
886
|
}
|
|
873
887
|
}
|
|
874
888
|
if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
|
|
875
|
-
if (!this.firstSttInfoReceived &&
|
|
889
|
+
if (!this.firstSttInfoReceived && !this._hasJoinLogicHookOrRule(this.convoStep) && this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE] && parsedData.data.start && parsedData.data.start * 1000 > this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT_START]) {
|
|
876
890
|
this.end = true;
|
|
877
891
|
sendBotMsg(new Error(`Silence Duration of ${parsedData.data.start}s exceeded Initial Silence Duration Timeout of ${this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT_START] / 1000}s`));
|
|
878
892
|
}
|
|
@@ -881,23 +895,11 @@ class BotiumConnectorVoip {
|
|
|
881
895
|
if (this.prevData && this.prevData.data && !lodash__default["default"].isNil(this.prevData.data.end)) {
|
|
882
896
|
if (!lodash__default["default"].isNil(parsedData.data.start)) {
|
|
883
897
|
const silenceDuration = parsedData.data.start - this.prevData.data.end;
|
|
884
|
-
const joinLogicHook = this._getJoinLogicHook(this.convoStep);
|
|
885
898
|
const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
|
|
886
|
-
const
|
|
887
|
-
const isJoinMethod = sttHandling === 'JOIN' || isPsst;
|
|
888
|
-
const toJoinTimeoutMs = ms => {
|
|
889
|
-
const parsed = parseInt(ms, 10);
|
|
890
|
-
if (!lodash__default["default"].isFinite(parsed) || parsed <= 0) return null;
|
|
891
|
-
return isPsst ? Math.max(0, parsed - 500) : parsed;
|
|
892
|
-
};
|
|
899
|
+
const isJoinMethod = sttHandling === 'JOIN' || sttHandling === 'PSST';
|
|
893
900
|
let matched = false;
|
|
894
|
-
if (
|
|
895
|
-
const joinTimeoutMs =
|
|
896
|
-
if (lodash__default["default"].isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > joinTimeoutMs / 1000) {
|
|
897
|
-
matched = true;
|
|
898
|
-
}
|
|
899
|
-
} else if (lodash__default["default"].isNil(joinLogicHook) && isJoinMethod) {
|
|
900
|
-
const joinTimeoutMs = toJoinTimeoutMs(this._getEffectiveMessageHandlingTimeout());
|
|
901
|
+
if (isJoinMethod) {
|
|
902
|
+
const joinTimeoutMs = this._getEffectiveJoinTimeoutMs(this.convoStep, this.botMsgs);
|
|
901
903
|
if (lodash__default["default"].isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > joinTimeoutMs / 1000) {
|
|
902
904
|
matched = true;
|
|
903
905
|
}
|
|
@@ -928,8 +930,8 @@ class BotiumConnectorVoip {
|
|
|
928
930
|
threshold: confidenceThreshold,
|
|
929
931
|
accepted: successfulConfidenceScore
|
|
930
932
|
});
|
|
931
|
-
// ORIGINAL: emit final message immediately
|
|
932
|
-
if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL'
|
|
933
|
+
// ORIGINAL: always emit final message immediately (ignore JOIN hooks/rules).
|
|
934
|
+
if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL') {
|
|
933
935
|
let botMsg = {
|
|
934
936
|
messageText: parsedData.data.message
|
|
935
937
|
};
|
|
@@ -988,7 +990,7 @@ class BotiumConnectorVoip {
|
|
|
988
990
|
}
|
|
989
991
|
this.botMsgs = [];
|
|
990
992
|
}
|
|
991
|
-
if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'JOIN' || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'PSST' || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'CONCAT'
|
|
993
|
+
if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'JOIN' || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'PSST' || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'CONCAT') {
|
|
992
994
|
const botMsg = {
|
|
993
995
|
messageText: parsedData.data.message,
|
|
994
996
|
sourceData: parsedData
|
|
@@ -1067,58 +1069,35 @@ class BotiumConnectorVoip {
|
|
|
1067
1069
|
}
|
|
1068
1070
|
return new Promise((resolve, reject) => {
|
|
1069
1071
|
setTimeout(async () => {
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1072
|
+
try {
|
|
1073
|
+
let duration = 0;
|
|
1074
|
+
const preferVoiceCapRaw = this.caps[Capabilities.VOIP_USER_INPUT_PREFER_VOICE];
|
|
1075
|
+
const preferVoice = !!preferVoiceCapRaw;
|
|
1076
|
+
const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
|
|
1077
|
+
debug$3(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`);
|
|
1075
1078
|
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1079
|
+
// Stamp `msg.voipAgent` at the moment bytes leave the WebSocket so
|
|
1080
|
+
// the coach can place the agent turn on the recording timeline.
|
|
1081
|
+
// `requestedDurationMs` is the best estimate of on-wire playback
|
|
1082
|
+
// length (DTMF tones × digits, TTS synth output, parsed media duration).
|
|
1083
|
+
const stampAgentWire = (wireKind, requestedDurationMs, extras = {}) => {
|
|
1084
|
+
msg.voipAgent = {
|
|
1085
|
+
wireSentAtMs: Date.now(),
|
|
1086
|
+
inputType,
|
|
1087
|
+
wireKind,
|
|
1088
|
+
requestedDurationMs: Math.max(0, Math.round(requestedDurationMs || 0)),
|
|
1089
|
+
...extras
|
|
1090
|
+
};
|
|
1087
1091
|
};
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
const digits = sanitizeDtmfDigits(msg.buttons[0].payload);
|
|
1093
|
-
if (!digits) {
|
|
1094
|
-
debug$3('sendDtmf skipped: no valid DTMF digits after sanitizing button payload');
|
|
1095
|
-
return resolve();
|
|
1096
|
-
}
|
|
1097
|
-
debug$3(`Sending DTMF digits: ${digits}`);
|
|
1098
|
-
const request = JSON.stringify({
|
|
1099
|
-
METHOD: 'sendDtmf',
|
|
1100
|
-
digits,
|
|
1101
|
-
sessionId: this.sessionId
|
|
1102
|
-
});
|
|
1103
|
-
stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
|
|
1104
|
-
digitCount: digits.length
|
|
1105
|
-
});
|
|
1106
|
-
this.ws.send(request);
|
|
1107
|
-
} else if (msg && msg.messageText) {
|
|
1108
|
-
// Check for DTMF tag in messageText: <DTMF>1234</DTMF>
|
|
1109
|
-
const dtmfMatch = msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
|
|
1110
|
-
if (dtmfMatch && dtmfMatch[1]) {
|
|
1111
|
-
const rawDigits = dtmfMatch[1];
|
|
1112
|
-
const digits = sanitizeDtmfDigits(rawDigits);
|
|
1092
|
+
// Twilio default: 100 ms tone + 100 ms gap per digit. Drives agent-bar width only.
|
|
1093
|
+
const DTMF_MS_PER_DIGIT = 200;
|
|
1094
|
+
if (msg && msg.buttons && msg.buttons.length > 0) {
|
|
1095
|
+
const digits = sanitizeDtmfDigits(msg.buttons[0].payload);
|
|
1113
1096
|
if (!digits) {
|
|
1114
|
-
debug$3(
|
|
1097
|
+
debug$3('sendDtmf skipped: no valid DTMF digits after sanitizing button payload');
|
|
1115
1098
|
return resolve();
|
|
1116
1099
|
}
|
|
1117
|
-
|
|
1118
|
-
debug$3(`Sending DTMF from messageText (sanitized): "${rawDigits}" -> "${digits}"`);
|
|
1119
|
-
} else {
|
|
1120
|
-
debug$3(`Sending DTMF from messageText: ${digits}`);
|
|
1121
|
-
}
|
|
1100
|
+
debug$3(`Sending DTMF digits: ${digits}`);
|
|
1122
1101
|
const request = JSON.stringify({
|
|
1123
1102
|
METHOD: 'sendDtmf',
|
|
1124
1103
|
digits,
|
|
@@ -1127,114 +1106,150 @@ class BotiumConnectorVoip {
|
|
|
1127
1106
|
stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
|
|
1128
1107
|
digitCount: digits.length
|
|
1129
1108
|
});
|
|
1130
|
-
this.
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
if (
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
const ttsRequest = this._buildTtsRequest(msg.messageText);
|
|
1141
|
-
if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
|
|
1142
|
-
msg.sourceData = ttsRequest;
|
|
1143
|
-
let ttsResult = null;
|
|
1144
|
-
const ttsStartedAt = Date.now();
|
|
1145
|
-
let ttsSynthMs = 0;
|
|
1146
|
-
try {
|
|
1147
|
-
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
|
|
1148
|
-
ttsSynthMs = Date.now() - ttsStartedAt;
|
|
1149
|
-
} catch (err) {
|
|
1150
|
-
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
|
|
1109
|
+
this._sendUserSaysWs(request);
|
|
1110
|
+
} else if (msg && msg.messageText) {
|
|
1111
|
+
// Check for DTMF tag in messageText: <DTMF>1234</DTMF>
|
|
1112
|
+
const dtmfMatch = msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
|
|
1113
|
+
if (dtmfMatch && dtmfMatch[1]) {
|
|
1114
|
+
const rawDigits = dtmfMatch[1];
|
|
1115
|
+
const digits = sanitizeDtmfDigits(rawDigits);
|
|
1116
|
+
if (!digits) {
|
|
1117
|
+
debug$3(`sendDtmf skipped: no valid DTMF digits after sanitizing <DTMF> content (raw length=${String(rawDigits).length})`);
|
|
1118
|
+
return resolve();
|
|
1151
1119
|
}
|
|
1152
|
-
if (
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
metricName: 'consumption.e2e.voip.tts.characters',
|
|
1157
|
-
transactions: msg.messageText.length,
|
|
1158
|
-
apiKey
|
|
1159
|
-
});
|
|
1120
|
+
if (digits !== String(rawDigits).replace(/\s/g, '')) {
|
|
1121
|
+
debug$3(`Sending DTMF from messageText (sanitized): "${rawDigits}" -> "${digits}"`);
|
|
1122
|
+
} else {
|
|
1123
|
+
debug$3(`Sending DTMF from messageText: ${digits}`);
|
|
1160
1124
|
}
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1125
|
+
const request = JSON.stringify({
|
|
1126
|
+
METHOD: 'sendDtmf',
|
|
1127
|
+
digits,
|
|
1128
|
+
sessionId: this.sessionId
|
|
1129
|
+
});
|
|
1130
|
+
stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
|
|
1131
|
+
digitCount: digits.length
|
|
1132
|
+
});
|
|
1133
|
+
this._sendUserSaysWs(request);
|
|
1134
|
+
return resolve();
|
|
1135
|
+
}
|
|
1136
|
+
if (!skipTtsForMixedInput) {
|
|
1137
|
+
if (!this.axiosTtsParams) {
|
|
1138
|
+
if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
|
|
1139
|
+
return reject(new Error('TTS not configured, only audio input supported'));
|
|
1174
1140
|
}
|
|
1175
|
-
msg.attachments.push({
|
|
1176
|
-
name: 'tts.wav',
|
|
1177
|
-
mimeType: 'audio/wav',
|
|
1178
|
-
base64: b64Buffer
|
|
1179
|
-
});
|
|
1180
|
-
stampAgentWire('tts', (duration || 0) * 1000, {
|
|
1181
|
-
ttsSynthMs,
|
|
1182
|
-
textLength: msg.messageText ? msg.messageText.length : 0
|
|
1183
|
-
});
|
|
1184
|
-
this.ws.send(request);
|
|
1185
1141
|
} else {
|
|
1186
|
-
|
|
1142
|
+
debug$3('UserSays routing: executing TTS branch');
|
|
1143
|
+
const ttsRequest = this._buildTtsRequest(msg.messageText);
|
|
1144
|
+
if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
|
|
1145
|
+
msg.sourceData = ttsRequest;
|
|
1146
|
+
let ttsResult = null;
|
|
1147
|
+
const ttsStartedAt = Date.now();
|
|
1148
|
+
let ttsSynthMs = 0;
|
|
1149
|
+
try {
|
|
1150
|
+
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
|
|
1151
|
+
ttsSynthMs = Date.now() - ttsStartedAt;
|
|
1152
|
+
} catch (err) {
|
|
1153
|
+
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
|
|
1154
|
+
}
|
|
1155
|
+
if (msg && msg.messageText && msg.messageText.length > 0) {
|
|
1156
|
+
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
|
|
1157
|
+
this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
|
|
1158
|
+
type: lodash__default["default"].isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
|
|
1159
|
+
metricName: 'consumption.e2e.voip.tts.characters',
|
|
1160
|
+
transactions: msg.messageText.length,
|
|
1161
|
+
apiKey
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
|
|
1165
|
+
duration = parseFloat(ttsResult.duration) || 0;
|
|
1166
|
+
const b64Buffer = ttsResult.buffer.toString('base64');
|
|
1167
|
+
const request = JSON.stringify({
|
|
1168
|
+
METHOD: 'sendAudio',
|
|
1169
|
+
PESQ: false,
|
|
1170
|
+
sessionId: this.sessionId,
|
|
1171
|
+
b64_buffer: b64Buffer
|
|
1172
|
+
});
|
|
1173
|
+
if (this._lastBotSaysQueuedAt) {
|
|
1174
|
+
const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
|
|
1175
|
+
const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
|
|
1176
|
+
debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
|
|
1177
|
+
}
|
|
1178
|
+
msg.attachments.push({
|
|
1179
|
+
name: 'tts.wav',
|
|
1180
|
+
mimeType: 'audio/wav',
|
|
1181
|
+
base64: b64Buffer
|
|
1182
|
+
});
|
|
1183
|
+
stampAgentWire('tts', (duration || 0) * 1000, {
|
|
1184
|
+
ttsSynthMs,
|
|
1185
|
+
textLength: msg.messageText ? msg.messageText.length : 0
|
|
1186
|
+
});
|
|
1187
|
+
this._sendUserSaysWs(request);
|
|
1188
|
+
} else {
|
|
1189
|
+
return reject(new Error('TTS failed, response is empty'));
|
|
1190
|
+
}
|
|
1187
1191
|
}
|
|
1192
|
+
} else {
|
|
1193
|
+
debug$3('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled');
|
|
1188
1194
|
}
|
|
1189
|
-
} else {
|
|
1190
|
-
debug$3('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled');
|
|
1191
1195
|
}
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1196
|
+
if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
|
|
1197
|
+
debug$3('UserSays routing: executing MEDIA branch');
|
|
1198
|
+
const request = JSON.stringify({
|
|
1199
|
+
METHOD: 'sendAudio',
|
|
1200
|
+
sessionId: this.sessionId,
|
|
1201
|
+
b64_buffer: msg.media[0].buffer.toString('base64')
|
|
1202
|
+
});
|
|
1203
|
+
if (this._lastBotSaysQueuedAt) {
|
|
1204
|
+
const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
|
|
1205
|
+
const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
|
|
1206
|
+
debug$3(`Latency (bot says -> sendAudio/MEDIA): ${latencyMs} ms (last bot: ${botText})`);
|
|
1207
|
+
}
|
|
1208
|
+
// Stamp now; `requestedDurationMs` is backfilled once media metadata is parsed.
|
|
1209
|
+
stampAgentWire('media', 0, {
|
|
1210
|
+
mediaUri: msg.media[0].mediaUri || null
|
|
1211
|
+
});
|
|
1212
|
+
this._sendUserSaysWs(request);
|
|
1213
|
+
msg.attachments.push({
|
|
1214
|
+
name: msg.media[0].mediaUri,
|
|
1215
|
+
mimeType: msg.media[0].mimeType,
|
|
1216
|
+
base64: msg.media[0].buffer.toString('base64')
|
|
1217
|
+
});
|
|
1218
|
+
try {
|
|
1219
|
+
const metadata = await musicMetadata__default["default"].parseBuffer(msg.media[0].buffer);
|
|
1220
|
+
if (metadata && metadata.format && metadata.format.duration) {
|
|
1221
|
+
debug$3('Audio duration of user audio:', metadata.format.duration, 'seconds');
|
|
1222
|
+
duration = Math.round(metadata.format.duration);
|
|
1223
|
+
if (msg.voipAgent) {
|
|
1224
|
+
msg.voipAgent.requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
|
|
1225
|
+
}
|
|
1226
|
+
} else {
|
|
1227
|
+
reject(new Error('Could not determine audio duration from metadata'));
|
|
1222
1228
|
}
|
|
1223
|
-
}
|
|
1224
|
-
reject(new Error(
|
|
1229
|
+
} catch (err) {
|
|
1230
|
+
reject(new Error(`Getting audio duration failed: ${err.message}`));
|
|
1225
1231
|
}
|
|
1226
|
-
} catch (err) {
|
|
1227
|
-
reject(new Error(`Getting audio duration failed: ${err.message}`));
|
|
1228
1232
|
}
|
|
1233
|
+
const requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
|
|
1234
|
+
if (requestedDurationMs <= 0) {
|
|
1235
|
+
return resolve();
|
|
1236
|
+
}
|
|
1237
|
+
setTimeout(resolve, requestedDurationMs);
|
|
1238
|
+
} catch (err) {
|
|
1239
|
+
reject(err);
|
|
1229
1240
|
}
|
|
1230
|
-
const requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
|
|
1231
|
-
if (requestedDurationMs <= 0) {
|
|
1232
|
-
return resolve();
|
|
1233
|
-
}
|
|
1234
|
-
setTimeout(resolve, requestedDurationMs);
|
|
1235
1241
|
}, 0);
|
|
1236
1242
|
});
|
|
1237
1243
|
}
|
|
1244
|
+
_voipWsCanSend() {
|
|
1245
|
+
return !this.stopCalled && this.ws && this.ws.readyState === ws__default["default"].OPEN;
|
|
1246
|
+
}
|
|
1247
|
+
_sendUserSaysWs(request) {
|
|
1248
|
+
if (!this._voipWsCanSend()) {
|
|
1249
|
+
throw new Error('VoIP session stopped');
|
|
1250
|
+
}
|
|
1251
|
+
this.ws.send(request);
|
|
1252
|
+
}
|
|
1238
1253
|
async Stop() {
|
|
1239
1254
|
debug$3(`${this.sessionId} - Stop called`);
|
|
1240
1255
|
this.stopCalled = true;
|
|
@@ -1452,6 +1467,75 @@ class BotiumConnectorVoip {
|
|
|
1452
1467
|
if (lodash__default["default"].isNil(convoStep.logicHooks)) return null;
|
|
1453
1468
|
return convoStep && convoStep.logicHooks && convoStep.logicHooks.find(lh => lh.name === 'VOIP_JOIN_SILENCE_DURATION');
|
|
1454
1469
|
}
|
|
1470
|
+
_normalizeJoinRulesBySubstring() {
|
|
1471
|
+
const rawRules = this.caps[Capabilities.VOIP_JOIN_SILENCE_DURATION_BY_SUBSTRING];
|
|
1472
|
+
if (lodash__default["default"].isNil(rawRules) || rawRules === '') return [];
|
|
1473
|
+
let parsedRules = rawRules;
|
|
1474
|
+
if (lodash__default["default"].isString(rawRules)) {
|
|
1475
|
+
try {
|
|
1476
|
+
parsedRules = JSON.parse(rawRules);
|
|
1477
|
+
} catch (err) {
|
|
1478
|
+
debug$3(`Invalid ${Capabilities.VOIP_JOIN_SILENCE_DURATION_BY_SUBSTRING} JSON: ${err.message || err}`);
|
|
1479
|
+
return [];
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
if (!lodash__default["default"].isArray(parsedRules)) {
|
|
1483
|
+
debug$3(`Invalid ${Capabilities.VOIP_JOIN_SILENCE_DURATION_BY_SUBSTRING}: expected array`);
|
|
1484
|
+
return [];
|
|
1485
|
+
}
|
|
1486
|
+
return parsedRules.map(rule => {
|
|
1487
|
+
if (!rule || typeof rule !== 'object') return null;
|
|
1488
|
+
const substring = typeof rule.substring === 'string' ? rule.substring.trim() : '';
|
|
1489
|
+
const timeoutMs = parseInt(rule.timeoutMs, 10);
|
|
1490
|
+
if (!substring || !lodash__default["default"].isFinite(timeoutMs) || timeoutMs <= 0) return null;
|
|
1491
|
+
return {
|
|
1492
|
+
substring,
|
|
1493
|
+
timeoutMs
|
|
1494
|
+
};
|
|
1495
|
+
}).filter(Boolean);
|
|
1496
|
+
}
|
|
1497
|
+
_getJoinRuleBySubstring(convoStep, botMsgs) {
|
|
1498
|
+
const texts = [];
|
|
1499
|
+
const stepMessageText = lodash__default["default"].get(convoStep, 'messageText', '');
|
|
1500
|
+
if (typeof stepMessageText === 'string' && stepMessageText.length > 0) {
|
|
1501
|
+
texts.push(stepMessageText);
|
|
1502
|
+
}
|
|
1503
|
+
if (lodash__default["default"].isArray(botMsgs) && botMsgs.length > 0) {
|
|
1504
|
+
const bufferedText = botMsgs.map(m => m && typeof m.messageText === 'string' ? m.messageText : '').filter(Boolean).join(' ');
|
|
1505
|
+
if (bufferedText.length > 0) texts.push(bufferedText);
|
|
1506
|
+
}
|
|
1507
|
+
if (texts.length === 0) return null;
|
|
1508
|
+
const rules = this._normalizeJoinRulesBySubstring();
|
|
1509
|
+
for (const text of texts) {
|
|
1510
|
+
const loweredText = text.toLowerCase();
|
|
1511
|
+
const match = rules.find(rule => loweredText.includes(rule.substring.toLowerCase()));
|
|
1512
|
+
if (match) return match;
|
|
1513
|
+
}
|
|
1514
|
+
return null;
|
|
1515
|
+
}
|
|
1516
|
+
_hasJoinLogicHookOrRule(convoStep) {
|
|
1517
|
+
return !lodash__default["default"].isNil(this._getJoinLogicHook(convoStep)) || !lodash__default["default"].isNil(this._getJoinRuleBySubstring(convoStep, this.botMsgs));
|
|
1518
|
+
}
|
|
1519
|
+
_toJoinTimeoutMs(ms, isPsst) {
|
|
1520
|
+
const parsed = parseInt(ms, 10);
|
|
1521
|
+
if (!lodash__default["default"].isFinite(parsed) || parsed <= 0) return null;
|
|
1522
|
+
return isPsst ? Math.max(0, parsed - 500) : parsed;
|
|
1523
|
+
}
|
|
1524
|
+
_getEffectiveJoinTimeoutMs(convoStep, botMsgs) {
|
|
1525
|
+
const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
|
|
1526
|
+
const isPsst = sttHandling === 'PSST';
|
|
1527
|
+
const joinLogicHook = this._getJoinLogicHook(convoStep);
|
|
1528
|
+
if (joinLogicHook && joinLogicHook.args && joinLogicHook.args.length > 0) {
|
|
1529
|
+
const joinHookTimeoutMs = this._toJoinTimeoutMs(joinLogicHook.args[0], isPsst);
|
|
1530
|
+
if (lodash__default["default"].isFinite(joinHookTimeoutMs) && joinHookTimeoutMs > 0) return joinHookTimeoutMs;
|
|
1531
|
+
}
|
|
1532
|
+
const joinRule = this._getJoinRuleBySubstring(convoStep, botMsgs);
|
|
1533
|
+
if (joinRule) {
|
|
1534
|
+
const joinRuleTimeoutMs = this._toJoinTimeoutMs(joinRule.timeoutMs, isPsst);
|
|
1535
|
+
if (lodash__default["default"].isFinite(joinRuleTimeoutMs) && joinRuleTimeoutMs > 0) return joinRuleTimeoutMs;
|
|
1536
|
+
}
|
|
1537
|
+
return this._toJoinTimeoutMs(this._getEffectiveMessageHandlingTimeout(), isPsst);
|
|
1538
|
+
}
|
|
1455
1539
|
_getIgnoreSilenceDurationAsserterLogicHook(convoStep) {
|
|
1456
1540
|
if (lodash__default["default"].isNil(convoStep)) return null;
|
|
1457
1541
|
if (lodash__default["default"].isNil(convoStep.logicHooks)) return null;
|
|
@@ -1537,6 +1621,12 @@ var botiumConnectorVoip = {
|
|
|
1537
1621
|
type: 'boolean',
|
|
1538
1622
|
required: false,
|
|
1539
1623
|
advanced: true
|
|
1624
|
+
}, {
|
|
1625
|
+
name: 'VOIP_JOIN_SILENCE_DURATION_BY_SUBSTRING',
|
|
1626
|
+
label: 'Join silence timeout rules by bot message substring',
|
|
1627
|
+
type: 'json',
|
|
1628
|
+
required: false,
|
|
1629
|
+
advanced: true
|
|
1540
1630
|
}]
|
|
1541
1631
|
},
|
|
1542
1632
|
PluginLogicHooks: {
|