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.
@@ -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 joinLogicHook = this._getJoinLogicHook(this.convoStep);
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 isPsst = sttHandling === 'PSST';
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 joinLogicHook = this._getJoinLogicHook(convoStep);
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 (lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep)) && parsedData.data.silence.length > 0) {
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 && lodash__default["default"].isNil(this._getJoinLogicHook(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]) {
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 isPsst = sttHandling === 'PSST';
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 (!lodash__default["default"].isNil(joinLogicHook) && isJoinMethod) {
895
- const joinTimeoutMs = toJoinTimeoutMs(joinLogicHook && joinLogicHook.args && joinLogicHook.args[0]);
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, unless join hooks are active.
932
- if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep))) {
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' || !lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep))) {
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
- let duration = 0;
1071
- const preferVoiceCapRaw = this.caps[Capabilities.VOIP_USER_INPUT_PREFER_VOICE];
1072
- const preferVoice = !!preferVoiceCapRaw;
1073
- const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
1074
- debug$3(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`);
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
- // Stamp `msg.voipAgent` at the moment bytes leave the WebSocket so
1077
- // the coach can place the agent turn on the recording timeline.
1078
- // `requestedDurationMs` is the best estimate of on-wire playback
1079
- // length (DTMF tones × digits, TTS synth output, parsed media duration).
1080
- const stampAgentWire = (wireKind, requestedDurationMs, extras = {}) => {
1081
- msg.voipAgent = {
1082
- wireSentAtMs: Date.now(),
1083
- inputType,
1084
- wireKind,
1085
- requestedDurationMs: Math.max(0, Math.round(requestedDurationMs || 0)),
1086
- ...extras
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
- // Twilio default: 100 ms tone + 100 ms gap per digit. Drives agent-bar width only.
1090
- const DTMF_MS_PER_DIGIT = 200;
1091
- if (msg && msg.buttons && msg.buttons.length > 0) {
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(`sendDtmf skipped: no valid DTMF digits after sanitizing <DTMF> content (raw length=${String(rawDigits).length})`);
1097
+ debug$3('sendDtmf skipped: no valid DTMF digits after sanitizing button payload');
1115
1098
  return resolve();
1116
1099
  }
1117
- if (digits !== String(rawDigits).replace(/\s/g, '')) {
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.ws.send(request);
1131
- return resolve();
1132
- }
1133
- if (!skipTtsForMixedInput) {
1134
- if (!this.axiosTtsParams) {
1135
- if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
1136
- return reject(new Error('TTS not configured, only audio input supported'));
1137
- }
1138
- } else {
1139
- debug$3('UserSays routing: executing TTS branch');
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 (msg && msg.messageText && msg.messageText.length > 0) {
1153
- const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
1154
- this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
1155
- type: lodash__default["default"].isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
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
- if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
1162
- duration = parseFloat(ttsResult.duration) || 0;
1163
- const b64Buffer = ttsResult.buffer.toString('base64');
1164
- const request = JSON.stringify({
1165
- METHOD: 'sendAudio',
1166
- PESQ: false,
1167
- sessionId: this.sessionId,
1168
- b64_buffer: b64Buffer
1169
- });
1170
- if (this._lastBotSaysQueuedAt) {
1171
- const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
1172
- const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
1173
- debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
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
- return reject(new Error('TTS failed, response is empty'));
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
- if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
1194
- debug$3('UserSays routing: executing MEDIA branch');
1195
- const request = JSON.stringify({
1196
- METHOD: 'sendAudio',
1197
- sessionId: this.sessionId,
1198
- b64_buffer: msg.media[0].buffer.toString('base64')
1199
- });
1200
- if (this._lastBotSaysQueuedAt) {
1201
- const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
1202
- const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
1203
- debug$3(`Latency (bot says -> sendAudio/MEDIA): ${latencyMs} ms (last bot: ${botText})`);
1204
- }
1205
- // Stamp now; `requestedDurationMs` is backfilled once media metadata is parsed.
1206
- stampAgentWire('media', 0, {
1207
- mediaUri: msg.media[0].mediaUri || null
1208
- });
1209
- this.ws.send(request);
1210
- msg.attachments.push({
1211
- name: msg.media[0].mediaUri,
1212
- mimeType: msg.media[0].mimeType,
1213
- base64: msg.media[0].buffer.toString('base64')
1214
- });
1215
- try {
1216
- const metadata = await musicMetadata__default["default"].parseBuffer(msg.media[0].buffer);
1217
- if (metadata && metadata.format && metadata.format.duration) {
1218
- debug$3('Audio duration of user audio:', metadata.format.duration, 'seconds');
1219
- duration = Math.round(metadata.format.duration);
1220
- if (msg.voipAgent) {
1221
- msg.voipAgent.requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
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
- } else {
1224
- reject(new Error('Could not determine audio duration from metadata'));
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: {