botium-connector-voip 0.0.26 → 0.0.28
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 +268 -186
- package/dist/botium-connector-voip-cjs.js.map +1 -1
- package/dist/botium-connector-voip-es.js +268 -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 +272 -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',
|
|
@@ -183,6 +184,7 @@ class BotiumConnectorVoip {
|
|
|
183
184
|
debug$3(this.caps[Capabilities.VOIP_TTS_URL]);
|
|
184
185
|
this.stopCalled = false;
|
|
185
186
|
this.fullRecord = '';
|
|
187
|
+
this.fullRecordAttachmentEmitted = false;
|
|
186
188
|
this.end = false;
|
|
187
189
|
this.connected = false;
|
|
188
190
|
this.convoStep = null;
|
|
@@ -250,19 +252,9 @@ class BotiumConnectorVoip {
|
|
|
250
252
|
const armJoinSilenceTimer = () => {
|
|
251
253
|
if (!this.botMsgs || this.botMsgs.length === 0) return;
|
|
252
254
|
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);
|
|
255
|
+
const isJoinMethod = sttHandling === 'JOIN' || sttHandling === 'PSST' || sttHandling === 'CONCAT' || this._hasJoinLogicHookOrRule(this.convoStep);
|
|
255
256
|
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
|
-
}
|
|
257
|
+
const joinTimeoutMs = this._getEffectiveJoinTimeoutMs(this.convoStep, this.botMsgs);
|
|
266
258
|
if (this.silenceTimeout) {
|
|
267
259
|
clearTimeout(this.silenceTimeout);
|
|
268
260
|
this.silenceTimeout = null;
|
|
@@ -505,19 +497,7 @@ class BotiumConnectorVoip {
|
|
|
505
497
|
// For PSST: send join silence duration per step to VOIP worker (controls PSST silence trigger)
|
|
506
498
|
try {
|
|
507
499
|
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
|
-
}
|
|
500
|
+
const silenceMs = this._getEffectiveJoinTimeoutMs(convoStep, this.botMsgs);
|
|
521
501
|
if (lodash__default["default"].isFinite(silenceMs) && silenceMs > 0 && this.sessionId) {
|
|
522
502
|
debug$3(`PSST: sending silenceDurationMs=${silenceMs} for sessionId=${this.sessionId}`);
|
|
523
503
|
this.ws.send(JSON.stringify({
|
|
@@ -724,6 +704,18 @@ class BotiumConnectorVoip {
|
|
|
724
704
|
testSessionJobId: this.caps.VOIP_TEST_SESSION_JOB_ID || null
|
|
725
705
|
}
|
|
726
706
|
});
|
|
707
|
+
this.fullRecordAttachmentEmitted = true;
|
|
708
|
+
};
|
|
709
|
+
this._emitBufferedFullRecordIfAny = reason => {
|
|
710
|
+
if (this.fullRecordAttachmentEmitted) return false;
|
|
711
|
+
if (!this.fullRecord || typeof this.fullRecord !== 'string' || this.fullRecord.length === 0) return false;
|
|
712
|
+
emitFullRecordAttachment(this.fullRecord);
|
|
713
|
+
_info('recording_attached', {
|
|
714
|
+
sessionId: this.sessionId,
|
|
715
|
+
source: reason,
|
|
716
|
+
base64Len: this.fullRecord.length
|
|
717
|
+
});
|
|
718
|
+
return true;
|
|
727
719
|
};
|
|
728
720
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
|
|
729
721
|
this.sessionId = parsedData.voipConfig.sessionId;
|
|
@@ -770,6 +762,9 @@ class BotiumConnectorVoip {
|
|
|
770
762
|
sttPartialCount: this.sttPartialCount
|
|
771
763
|
});
|
|
772
764
|
flushPendingBotMsgs('callinfo_disconnected');
|
|
765
|
+
// Some workers may disconnect without sending fullRecordEnd.
|
|
766
|
+
// If we already buffered full-record chunks, emit them now.
|
|
767
|
+
this._emitBufferedFullRecordIfAny('callinfo_disconnected_buffered');
|
|
773
768
|
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY));
|
|
774
769
|
if (parsedData.connectDuration && parsedData.connectDuration > 0) {
|
|
775
770
|
this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
|
|
@@ -790,6 +785,8 @@ class BotiumConnectorVoip {
|
|
|
790
785
|
}
|
|
791
786
|
if (parsedData && parsedData.type === 'error') {
|
|
792
787
|
flushPendingBotMsgs('error');
|
|
788
|
+
// Ensure buffered recording is not lost on terminal worker errors.
|
|
789
|
+
this._emitBufferedFullRecordIfAny('error_buffered');
|
|
793
790
|
_info('ws_error_msg', {
|
|
794
791
|
sessionId: this.sessionId,
|
|
795
792
|
message: parsedData.message || null,
|
|
@@ -826,7 +823,7 @@ class BotiumConnectorVoip {
|
|
|
826
823
|
}
|
|
827
824
|
if (parsedData && parsedData.type === 'silence') {
|
|
828
825
|
if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
|
|
829
|
-
if (
|
|
826
|
+
if (!this._hasJoinLogicHookOrRule(this.convoStep) && parsedData.data.silence.length > 0) {
|
|
830
827
|
flushPendingBotMsgs('silence_exceeded');
|
|
831
828
|
this.end = true;
|
|
832
829
|
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`));
|
|
@@ -907,7 +904,7 @@ class BotiumConnectorVoip {
|
|
|
907
904
|
}
|
|
908
905
|
}
|
|
909
906
|
if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
|
|
910
|
-
if (!this.firstSttInfoReceived &&
|
|
907
|
+
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]) {
|
|
911
908
|
this.end = true;
|
|
912
909
|
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`));
|
|
913
910
|
}
|
|
@@ -916,23 +913,11 @@ class BotiumConnectorVoip {
|
|
|
916
913
|
if (this.prevData && this.prevData.data && !lodash__default["default"].isNil(this.prevData.data.end)) {
|
|
917
914
|
if (!lodash__default["default"].isNil(parsedData.data.start)) {
|
|
918
915
|
const silenceDuration = parsedData.data.start - this.prevData.data.end;
|
|
919
|
-
const joinLogicHook = this._getJoinLogicHook(this.convoStep);
|
|
920
916
|
const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
|
|
921
|
-
const
|
|
922
|
-
const isJoinMethod = sttHandling === 'JOIN' || isPsst;
|
|
923
|
-
const toJoinTimeoutMs = ms => {
|
|
924
|
-
const parsed = parseInt(ms, 10);
|
|
925
|
-
if (!lodash__default["default"].isFinite(parsed) || parsed <= 0) return null;
|
|
926
|
-
return isPsst ? Math.max(0, parsed - 500) : parsed;
|
|
927
|
-
};
|
|
917
|
+
const isJoinMethod = sttHandling === 'JOIN' || sttHandling === 'PSST';
|
|
928
918
|
let matched = false;
|
|
929
|
-
if (
|
|
930
|
-
const joinTimeoutMs =
|
|
931
|
-
if (lodash__default["default"].isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > joinTimeoutMs / 1000) {
|
|
932
|
-
matched = true;
|
|
933
|
-
}
|
|
934
|
-
} else if (lodash__default["default"].isNil(joinLogicHook) && isJoinMethod) {
|
|
935
|
-
const joinTimeoutMs = toJoinTimeoutMs(this._getEffectiveMessageHandlingTimeout());
|
|
919
|
+
if (isJoinMethod) {
|
|
920
|
+
const joinTimeoutMs = this._getEffectiveJoinTimeoutMs(this.convoStep, this.botMsgs);
|
|
936
921
|
if (lodash__default["default"].isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > joinTimeoutMs / 1000) {
|
|
937
922
|
matched = true;
|
|
938
923
|
}
|
|
@@ -963,8 +948,8 @@ class BotiumConnectorVoip {
|
|
|
963
948
|
threshold: confidenceThreshold,
|
|
964
949
|
accepted: successfulConfidenceScore
|
|
965
950
|
});
|
|
966
|
-
// ORIGINAL: emit final message immediately
|
|
967
|
-
if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL'
|
|
951
|
+
// ORIGINAL: always emit final message immediately (ignore JOIN hooks/rules).
|
|
952
|
+
if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL') {
|
|
968
953
|
let botMsg = {
|
|
969
954
|
messageText: parsedData.data.message
|
|
970
955
|
};
|
|
@@ -1023,7 +1008,7 @@ class BotiumConnectorVoip {
|
|
|
1023
1008
|
}
|
|
1024
1009
|
this.botMsgs = [];
|
|
1025
1010
|
}
|
|
1026
|
-
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'
|
|
1011
|
+
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') {
|
|
1027
1012
|
const botMsg = {
|
|
1028
1013
|
messageText: parsedData.data.message,
|
|
1029
1014
|
sourceData: parsedData
|
|
@@ -1102,58 +1087,35 @@ class BotiumConnectorVoip {
|
|
|
1102
1087
|
}
|
|
1103
1088
|
return new Promise((resolve, reject) => {
|
|
1104
1089
|
setTimeout(async () => {
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1090
|
+
try {
|
|
1091
|
+
let duration = 0;
|
|
1092
|
+
const preferVoiceCapRaw = this.caps[Capabilities.VOIP_USER_INPUT_PREFER_VOICE];
|
|
1093
|
+
const preferVoice = !!preferVoiceCapRaw;
|
|
1094
|
+
const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
|
|
1095
|
+
debug$3(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`);
|
|
1110
1096
|
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1097
|
+
// Stamp `msg.voipAgent` at the moment bytes leave the WebSocket so
|
|
1098
|
+
// the coach can place the agent turn on the recording timeline.
|
|
1099
|
+
// `requestedDurationMs` is the best estimate of on-wire playback
|
|
1100
|
+
// length (DTMF tones × digits, TTS synth output, parsed media duration).
|
|
1101
|
+
const stampAgentWire = (wireKind, requestedDurationMs, extras = {}) => {
|
|
1102
|
+
msg.voipAgent = {
|
|
1103
|
+
wireSentAtMs: Date.now(),
|
|
1104
|
+
inputType,
|
|
1105
|
+
wireKind,
|
|
1106
|
+
requestedDurationMs: Math.max(0, Math.round(requestedDurationMs || 0)),
|
|
1107
|
+
...extras
|
|
1108
|
+
};
|
|
1122
1109
|
};
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
const digits = sanitizeDtmfDigits(msg.buttons[0].payload);
|
|
1128
|
-
if (!digits) {
|
|
1129
|
-
debug$3('sendDtmf skipped: no valid DTMF digits after sanitizing button payload');
|
|
1130
|
-
return resolve();
|
|
1131
|
-
}
|
|
1132
|
-
debug$3(`Sending DTMF digits: ${digits}`);
|
|
1133
|
-
const request = JSON.stringify({
|
|
1134
|
-
METHOD: 'sendDtmf',
|
|
1135
|
-
digits,
|
|
1136
|
-
sessionId: this.sessionId
|
|
1137
|
-
});
|
|
1138
|
-
stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
|
|
1139
|
-
digitCount: digits.length
|
|
1140
|
-
});
|
|
1141
|
-
this.ws.send(request);
|
|
1142
|
-
} else if (msg && msg.messageText) {
|
|
1143
|
-
// Check for DTMF tag in messageText: <DTMF>1234</DTMF>
|
|
1144
|
-
const dtmfMatch = msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
|
|
1145
|
-
if (dtmfMatch && dtmfMatch[1]) {
|
|
1146
|
-
const rawDigits = dtmfMatch[1];
|
|
1147
|
-
const digits = sanitizeDtmfDigits(rawDigits);
|
|
1110
|
+
// Twilio default: 100 ms tone + 100 ms gap per digit. Drives agent-bar width only.
|
|
1111
|
+
const DTMF_MS_PER_DIGIT = 200;
|
|
1112
|
+
if (msg && msg.buttons && msg.buttons.length > 0) {
|
|
1113
|
+
const digits = sanitizeDtmfDigits(msg.buttons[0].payload);
|
|
1148
1114
|
if (!digits) {
|
|
1149
|
-
debug$3(
|
|
1115
|
+
debug$3('sendDtmf skipped: no valid DTMF digits after sanitizing button payload');
|
|
1150
1116
|
return resolve();
|
|
1151
1117
|
}
|
|
1152
|
-
|
|
1153
|
-
debug$3(`Sending DTMF from messageText (sanitized): "${rawDigits}" -> "${digits}"`);
|
|
1154
|
-
} else {
|
|
1155
|
-
debug$3(`Sending DTMF from messageText: ${digits}`);
|
|
1156
|
-
}
|
|
1118
|
+
debug$3(`Sending DTMF digits: ${digits}`);
|
|
1157
1119
|
const request = JSON.stringify({
|
|
1158
1120
|
METHOD: 'sendDtmf',
|
|
1159
1121
|
digits,
|
|
@@ -1162,114 +1124,150 @@ class BotiumConnectorVoip {
|
|
|
1162
1124
|
stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
|
|
1163
1125
|
digitCount: digits.length
|
|
1164
1126
|
});
|
|
1165
|
-
this.
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
if (
|
|
1170
|
-
|
|
1171
|
-
|
|
1127
|
+
this._sendUserSaysWs(request);
|
|
1128
|
+
} else if (msg && msg.messageText) {
|
|
1129
|
+
// Check for DTMF tag in messageText: <DTMF>1234</DTMF>
|
|
1130
|
+
const dtmfMatch = msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
|
|
1131
|
+
if (dtmfMatch && dtmfMatch[1]) {
|
|
1132
|
+
const rawDigits = dtmfMatch[1];
|
|
1133
|
+
const digits = sanitizeDtmfDigits(rawDigits);
|
|
1134
|
+
if (!digits) {
|
|
1135
|
+
debug$3(`sendDtmf skipped: no valid DTMF digits after sanitizing <DTMF> content (raw length=${String(rawDigits).length})`);
|
|
1136
|
+
return resolve();
|
|
1172
1137
|
}
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
msg.sourceData = ttsRequest;
|
|
1178
|
-
let ttsResult = null;
|
|
1179
|
-
const ttsStartedAt = Date.now();
|
|
1180
|
-
let ttsSynthMs = 0;
|
|
1181
|
-
try {
|
|
1182
|
-
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
|
|
1183
|
-
ttsSynthMs = Date.now() - ttsStartedAt;
|
|
1184
|
-
} catch (err) {
|
|
1185
|
-
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
|
|
1186
|
-
}
|
|
1187
|
-
if (msg && msg.messageText && msg.messageText.length > 0) {
|
|
1188
|
-
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
|
|
1189
|
-
this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
|
|
1190
|
-
type: lodash__default["default"].isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
|
|
1191
|
-
metricName: 'consumption.e2e.voip.tts.characters',
|
|
1192
|
-
transactions: msg.messageText.length,
|
|
1193
|
-
apiKey
|
|
1194
|
-
});
|
|
1138
|
+
if (digits !== String(rawDigits).replace(/\s/g, '')) {
|
|
1139
|
+
debug$3(`Sending DTMF from messageText (sanitized): "${rawDigits}" -> "${digits}"`);
|
|
1140
|
+
} else {
|
|
1141
|
+
debug$3(`Sending DTMF from messageText: ${digits}`);
|
|
1195
1142
|
}
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1143
|
+
const request = JSON.stringify({
|
|
1144
|
+
METHOD: 'sendDtmf',
|
|
1145
|
+
digits,
|
|
1146
|
+
sessionId: this.sessionId
|
|
1147
|
+
});
|
|
1148
|
+
stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
|
|
1149
|
+
digitCount: digits.length
|
|
1150
|
+
});
|
|
1151
|
+
this._sendUserSaysWs(request);
|
|
1152
|
+
return resolve();
|
|
1153
|
+
}
|
|
1154
|
+
if (!skipTtsForMixedInput) {
|
|
1155
|
+
if (!this.axiosTtsParams) {
|
|
1156
|
+
if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
|
|
1157
|
+
return reject(new Error('TTS not configured, only audio input supported'));
|
|
1209
1158
|
}
|
|
1210
|
-
msg.attachments.push({
|
|
1211
|
-
name: 'tts.wav',
|
|
1212
|
-
mimeType: 'audio/wav',
|
|
1213
|
-
base64: b64Buffer
|
|
1214
|
-
});
|
|
1215
|
-
stampAgentWire('tts', (duration || 0) * 1000, {
|
|
1216
|
-
ttsSynthMs,
|
|
1217
|
-
textLength: msg.messageText ? msg.messageText.length : 0
|
|
1218
|
-
});
|
|
1219
|
-
this.ws.send(request);
|
|
1220
1159
|
} else {
|
|
1221
|
-
|
|
1160
|
+
debug$3('UserSays routing: executing TTS branch');
|
|
1161
|
+
const ttsRequest = this._buildTtsRequest(msg.messageText);
|
|
1162
|
+
if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
|
|
1163
|
+
msg.sourceData = ttsRequest;
|
|
1164
|
+
let ttsResult = null;
|
|
1165
|
+
const ttsStartedAt = Date.now();
|
|
1166
|
+
let ttsSynthMs = 0;
|
|
1167
|
+
try {
|
|
1168
|
+
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
|
|
1169
|
+
ttsSynthMs = Date.now() - ttsStartedAt;
|
|
1170
|
+
} catch (err) {
|
|
1171
|
+
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
|
|
1172
|
+
}
|
|
1173
|
+
if (msg && msg.messageText && msg.messageText.length > 0) {
|
|
1174
|
+
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
|
|
1175
|
+
this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
|
|
1176
|
+
type: lodash__default["default"].isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
|
|
1177
|
+
metricName: 'consumption.e2e.voip.tts.characters',
|
|
1178
|
+
transactions: msg.messageText.length,
|
|
1179
|
+
apiKey
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
|
|
1183
|
+
duration = parseFloat(ttsResult.duration) || 0;
|
|
1184
|
+
const b64Buffer = ttsResult.buffer.toString('base64');
|
|
1185
|
+
const request = JSON.stringify({
|
|
1186
|
+
METHOD: 'sendAudio',
|
|
1187
|
+
PESQ: false,
|
|
1188
|
+
sessionId: this.sessionId,
|
|
1189
|
+
b64_buffer: b64Buffer
|
|
1190
|
+
});
|
|
1191
|
+
if (this._lastBotSaysQueuedAt) {
|
|
1192
|
+
const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
|
|
1193
|
+
const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
|
|
1194
|
+
debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
|
|
1195
|
+
}
|
|
1196
|
+
msg.attachments.push({
|
|
1197
|
+
name: 'tts.wav',
|
|
1198
|
+
mimeType: 'audio/wav',
|
|
1199
|
+
base64: b64Buffer
|
|
1200
|
+
});
|
|
1201
|
+
stampAgentWire('tts', (duration || 0) * 1000, {
|
|
1202
|
+
ttsSynthMs,
|
|
1203
|
+
textLength: msg.messageText ? msg.messageText.length : 0
|
|
1204
|
+
});
|
|
1205
|
+
this._sendUserSaysWs(request);
|
|
1206
|
+
} else {
|
|
1207
|
+
return reject(new Error('TTS failed, response is empty'));
|
|
1208
|
+
}
|
|
1222
1209
|
}
|
|
1210
|
+
} else {
|
|
1211
|
+
debug$3('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled');
|
|
1223
1212
|
}
|
|
1224
|
-
} else {
|
|
1225
|
-
debug$3('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled');
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
|
|
1229
|
-
debug$3('UserSays routing: executing MEDIA branch');
|
|
1230
|
-
const request = JSON.stringify({
|
|
1231
|
-
METHOD: 'sendAudio',
|
|
1232
|
-
sessionId: this.sessionId,
|
|
1233
|
-
b64_buffer: msg.media[0].buffer.toString('base64')
|
|
1234
|
-
});
|
|
1235
|
-
if (this._lastBotSaysQueuedAt) {
|
|
1236
|
-
const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
|
|
1237
|
-
const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
|
|
1238
|
-
debug$3(`Latency (bot says -> sendAudio/MEDIA): ${latencyMs} ms (last bot: ${botText})`);
|
|
1239
1213
|
}
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1214
|
+
if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
|
|
1215
|
+
debug$3('UserSays routing: executing MEDIA branch');
|
|
1216
|
+
const request = JSON.stringify({
|
|
1217
|
+
METHOD: 'sendAudio',
|
|
1218
|
+
sessionId: this.sessionId,
|
|
1219
|
+
b64_buffer: msg.media[0].buffer.toString('base64')
|
|
1220
|
+
});
|
|
1221
|
+
if (this._lastBotSaysQueuedAt) {
|
|
1222
|
+
const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
|
|
1223
|
+
const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
|
|
1224
|
+
debug$3(`Latency (bot says -> sendAudio/MEDIA): ${latencyMs} ms (last bot: ${botText})`);
|
|
1225
|
+
}
|
|
1226
|
+
// Stamp now; `requestedDurationMs` is backfilled once media metadata is parsed.
|
|
1227
|
+
stampAgentWire('media', 0, {
|
|
1228
|
+
mediaUri: msg.media[0].mediaUri || null
|
|
1229
|
+
});
|
|
1230
|
+
this._sendUserSaysWs(request);
|
|
1231
|
+
msg.attachments.push({
|
|
1232
|
+
name: msg.media[0].mediaUri,
|
|
1233
|
+
mimeType: msg.media[0].mimeType,
|
|
1234
|
+
base64: msg.media[0].buffer.toString('base64')
|
|
1235
|
+
});
|
|
1236
|
+
try {
|
|
1237
|
+
const metadata = await musicMetadata__default["default"].parseBuffer(msg.media[0].buffer);
|
|
1238
|
+
if (metadata && metadata.format && metadata.format.duration) {
|
|
1239
|
+
debug$3('Audio duration of user audio:', metadata.format.duration, 'seconds');
|
|
1240
|
+
duration = Math.round(metadata.format.duration);
|
|
1241
|
+
if (msg.voipAgent) {
|
|
1242
|
+
msg.voipAgent.requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
|
|
1243
|
+
}
|
|
1244
|
+
} else {
|
|
1245
|
+
reject(new Error('Could not determine audio duration from metadata'));
|
|
1257
1246
|
}
|
|
1258
|
-
}
|
|
1259
|
-
reject(new Error(
|
|
1247
|
+
} catch (err) {
|
|
1248
|
+
reject(new Error(`Getting audio duration failed: ${err.message}`));
|
|
1260
1249
|
}
|
|
1261
|
-
} catch (err) {
|
|
1262
|
-
reject(new Error(`Getting audio duration failed: ${err.message}`));
|
|
1263
1250
|
}
|
|
1251
|
+
const requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
|
|
1252
|
+
if (requestedDurationMs <= 0) {
|
|
1253
|
+
return resolve();
|
|
1254
|
+
}
|
|
1255
|
+
setTimeout(resolve, requestedDurationMs);
|
|
1256
|
+
} catch (err) {
|
|
1257
|
+
reject(err);
|
|
1264
1258
|
}
|
|
1265
|
-
const requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
|
|
1266
|
-
if (requestedDurationMs <= 0) {
|
|
1267
|
-
return resolve();
|
|
1268
|
-
}
|
|
1269
|
-
setTimeout(resolve, requestedDurationMs);
|
|
1270
1259
|
}, 0);
|
|
1271
1260
|
});
|
|
1272
1261
|
}
|
|
1262
|
+
_voipWsCanSend() {
|
|
1263
|
+
return !this.stopCalled && this.ws && this.ws.readyState === ws__default["default"].OPEN;
|
|
1264
|
+
}
|
|
1265
|
+
_sendUserSaysWs(request) {
|
|
1266
|
+
if (!this._voipWsCanSend()) {
|
|
1267
|
+
throw new Error('VoIP session stopped');
|
|
1268
|
+
}
|
|
1269
|
+
this.ws.send(request);
|
|
1270
|
+
}
|
|
1273
1271
|
async Stop() {
|
|
1274
1272
|
debug$3(`${this.sessionId} - Stop called`);
|
|
1275
1273
|
this.stopCalled = true;
|
|
@@ -1299,13 +1297,22 @@ class BotiumConnectorVoip {
|
|
|
1299
1297
|
}
|
|
1300
1298
|
}, 100);
|
|
1301
1299
|
});
|
|
1300
|
+
// Final guard: if worker never emitted fullRecordEnd/fullRecord but
|
|
1301
|
+
// chunks were buffered, publish the attachment before teardown.
|
|
1302
|
+
if (typeof this._emitBufferedFullRecordIfAny === 'function') {
|
|
1303
|
+
this._emitBufferedFullRecordIfAny('stop_final_guard');
|
|
1304
|
+
}
|
|
1302
1305
|
} else {
|
|
1303
1306
|
this.wsOpened = false;
|
|
1304
1307
|
this.ws = null;
|
|
1305
1308
|
this.end = false;
|
|
1306
1309
|
this.convoStep = null;
|
|
1307
1310
|
this.firstSttInfoReceived = false;
|
|
1311
|
+
if (typeof this._emitBufferedFullRecordIfAny === 'function') {
|
|
1312
|
+
this._emitBufferedFullRecordIfAny('stop_final_guard');
|
|
1313
|
+
}
|
|
1308
1314
|
}
|
|
1315
|
+
this._emitBufferedFullRecordIfAny = null;
|
|
1309
1316
|
}
|
|
1310
1317
|
_isTtsCacheEnabled() {
|
|
1311
1318
|
return this.ttsCacheEnabled && this.ttsCacheMaxEntries > 0;
|
|
@@ -1487,6 +1494,75 @@ class BotiumConnectorVoip {
|
|
|
1487
1494
|
if (lodash__default["default"].isNil(convoStep.logicHooks)) return null;
|
|
1488
1495
|
return convoStep && convoStep.logicHooks && convoStep.logicHooks.find(lh => lh.name === 'VOIP_JOIN_SILENCE_DURATION');
|
|
1489
1496
|
}
|
|
1497
|
+
_normalizeJoinRulesBySubstring() {
|
|
1498
|
+
const rawRules = this.caps[Capabilities.VOIP_JOIN_SILENCE_DURATION_BY_SUBSTRING];
|
|
1499
|
+
if (lodash__default["default"].isNil(rawRules) || rawRules === '') return [];
|
|
1500
|
+
let parsedRules = rawRules;
|
|
1501
|
+
if (lodash__default["default"].isString(rawRules)) {
|
|
1502
|
+
try {
|
|
1503
|
+
parsedRules = JSON.parse(rawRules);
|
|
1504
|
+
} catch (err) {
|
|
1505
|
+
debug$3(`Invalid ${Capabilities.VOIP_JOIN_SILENCE_DURATION_BY_SUBSTRING} JSON: ${err.message || err}`);
|
|
1506
|
+
return [];
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
if (!lodash__default["default"].isArray(parsedRules)) {
|
|
1510
|
+
debug$3(`Invalid ${Capabilities.VOIP_JOIN_SILENCE_DURATION_BY_SUBSTRING}: expected array`);
|
|
1511
|
+
return [];
|
|
1512
|
+
}
|
|
1513
|
+
return parsedRules.map(rule => {
|
|
1514
|
+
if (!rule || typeof rule !== 'object') return null;
|
|
1515
|
+
const substring = typeof rule.substring === 'string' ? rule.substring.trim() : '';
|
|
1516
|
+
const timeoutMs = parseInt(rule.timeoutMs, 10);
|
|
1517
|
+
if (!substring || !lodash__default["default"].isFinite(timeoutMs) || timeoutMs <= 0) return null;
|
|
1518
|
+
return {
|
|
1519
|
+
substring,
|
|
1520
|
+
timeoutMs
|
|
1521
|
+
};
|
|
1522
|
+
}).filter(Boolean);
|
|
1523
|
+
}
|
|
1524
|
+
_getJoinRuleBySubstring(convoStep, botMsgs) {
|
|
1525
|
+
const texts = [];
|
|
1526
|
+
const stepMessageText = lodash__default["default"].get(convoStep, 'messageText', '');
|
|
1527
|
+
if (typeof stepMessageText === 'string' && stepMessageText.length > 0) {
|
|
1528
|
+
texts.push(stepMessageText);
|
|
1529
|
+
}
|
|
1530
|
+
if (lodash__default["default"].isArray(botMsgs) && botMsgs.length > 0) {
|
|
1531
|
+
const bufferedText = botMsgs.map(m => m && typeof m.messageText === 'string' ? m.messageText : '').filter(Boolean).join(' ');
|
|
1532
|
+
if (bufferedText.length > 0) texts.push(bufferedText);
|
|
1533
|
+
}
|
|
1534
|
+
if (texts.length === 0) return null;
|
|
1535
|
+
const rules = this._normalizeJoinRulesBySubstring();
|
|
1536
|
+
for (const text of texts) {
|
|
1537
|
+
const loweredText = text.toLowerCase();
|
|
1538
|
+
const match = rules.find(rule => loweredText.includes(rule.substring.toLowerCase()));
|
|
1539
|
+
if (match) return match;
|
|
1540
|
+
}
|
|
1541
|
+
return null;
|
|
1542
|
+
}
|
|
1543
|
+
_hasJoinLogicHookOrRule(convoStep) {
|
|
1544
|
+
return !lodash__default["default"].isNil(this._getJoinLogicHook(convoStep)) || !lodash__default["default"].isNil(this._getJoinRuleBySubstring(convoStep, this.botMsgs));
|
|
1545
|
+
}
|
|
1546
|
+
_toJoinTimeoutMs(ms, isPsst) {
|
|
1547
|
+
const parsed = parseInt(ms, 10);
|
|
1548
|
+
if (!lodash__default["default"].isFinite(parsed) || parsed <= 0) return null;
|
|
1549
|
+
return isPsst ? Math.max(0, parsed - 500) : parsed;
|
|
1550
|
+
}
|
|
1551
|
+
_getEffectiveJoinTimeoutMs(convoStep, botMsgs) {
|
|
1552
|
+
const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
|
|
1553
|
+
const isPsst = sttHandling === 'PSST';
|
|
1554
|
+
const joinLogicHook = this._getJoinLogicHook(convoStep);
|
|
1555
|
+
if (joinLogicHook && joinLogicHook.args && joinLogicHook.args.length > 0) {
|
|
1556
|
+
const joinHookTimeoutMs = this._toJoinTimeoutMs(joinLogicHook.args[0], isPsst);
|
|
1557
|
+
if (lodash__default["default"].isFinite(joinHookTimeoutMs) && joinHookTimeoutMs > 0) return joinHookTimeoutMs;
|
|
1558
|
+
}
|
|
1559
|
+
const joinRule = this._getJoinRuleBySubstring(convoStep, botMsgs);
|
|
1560
|
+
if (joinRule) {
|
|
1561
|
+
const joinRuleTimeoutMs = this._toJoinTimeoutMs(joinRule.timeoutMs, isPsst);
|
|
1562
|
+
if (lodash__default["default"].isFinite(joinRuleTimeoutMs) && joinRuleTimeoutMs > 0) return joinRuleTimeoutMs;
|
|
1563
|
+
}
|
|
1564
|
+
return this._toJoinTimeoutMs(this._getEffectiveMessageHandlingTimeout(), isPsst);
|
|
1565
|
+
}
|
|
1490
1566
|
_getIgnoreSilenceDurationAsserterLogicHook(convoStep) {
|
|
1491
1567
|
if (lodash__default["default"].isNil(convoStep)) return null;
|
|
1492
1568
|
if (lodash__default["default"].isNil(convoStep.logicHooks)) return null;
|
|
@@ -1572,6 +1648,12 @@ var botiumConnectorVoip = {
|
|
|
1572
1648
|
type: 'boolean',
|
|
1573
1649
|
required: false,
|
|
1574
1650
|
advanced: true
|
|
1651
|
+
}, {
|
|
1652
|
+
name: 'VOIP_JOIN_SILENCE_DURATION_BY_SUBSTRING',
|
|
1653
|
+
label: 'Join silence timeout rules by bot message substring',
|
|
1654
|
+
type: 'json',
|
|
1655
|
+
required: false,
|
|
1656
|
+
advanced: true
|
|
1575
1657
|
}]
|
|
1576
1658
|
},
|
|
1577
1659
|
PluginLogicHooks: {
|