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.
@@ -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 joinLogicHook = this._getJoinLogicHook(this.convoStep);
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 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
- }
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 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
- }
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 (lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep)) && parsedData.data.silence.length > 0) {
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 && 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]) {
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 isPsst = sttHandling === 'PSST';
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 (!lodash__default["default"].isNil(joinLogicHook) && isJoinMethod) {
930
- const joinTimeoutMs = toJoinTimeoutMs(joinLogicHook && joinLogicHook.args && joinLogicHook.args[0]);
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, unless join hooks are active.
967
- if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep))) {
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' || !lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep))) {
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
- let duration = 0;
1106
- const preferVoiceCapRaw = this.caps[Capabilities.VOIP_USER_INPUT_PREFER_VOICE];
1107
- const preferVoice = !!preferVoiceCapRaw;
1108
- const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
1109
- debug$3(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`);
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
- // Stamp `msg.voipAgent` at the moment bytes leave the WebSocket so
1112
- // the coach can place the agent turn on the recording timeline.
1113
- // `requestedDurationMs` is the best estimate of on-wire playback
1114
- // length (DTMF tones × digits, TTS synth output, parsed media duration).
1115
- const stampAgentWire = (wireKind, requestedDurationMs, extras = {}) => {
1116
- msg.voipAgent = {
1117
- wireSentAtMs: Date.now(),
1118
- inputType,
1119
- wireKind,
1120
- requestedDurationMs: Math.max(0, Math.round(requestedDurationMs || 0)),
1121
- ...extras
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
- // Twilio default: 100 ms tone + 100 ms gap per digit. Drives agent-bar width only.
1125
- const DTMF_MS_PER_DIGIT = 200;
1126
- if (msg && msg.buttons && msg.buttons.length > 0) {
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(`sendDtmf skipped: no valid DTMF digits after sanitizing <DTMF> content (raw length=${String(rawDigits).length})`);
1115
+ debug$3('sendDtmf skipped: no valid DTMF digits after sanitizing button payload');
1150
1116
  return resolve();
1151
1117
  }
1152
- if (digits !== String(rawDigits).replace(/\s/g, '')) {
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.ws.send(request);
1166
- return resolve();
1167
- }
1168
- if (!skipTtsForMixedInput) {
1169
- if (!this.axiosTtsParams) {
1170
- if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
1171
- return reject(new Error('TTS not configured, only audio input supported'));
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
- } else {
1174
- debug$3('UserSays routing: executing TTS branch');
1175
- const ttsRequest = this._buildTtsRequest(msg.messageText);
1176
- if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
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
- if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
1197
- duration = parseFloat(ttsResult.duration) || 0;
1198
- const b64Buffer = ttsResult.buffer.toString('base64');
1199
- const request = JSON.stringify({
1200
- METHOD: 'sendAudio',
1201
- PESQ: false,
1202
- sessionId: this.sessionId,
1203
- b64_buffer: b64Buffer
1204
- });
1205
- if (this._lastBotSaysQueuedAt) {
1206
- const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
1207
- const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
1208
- debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
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
- return reject(new Error('TTS failed, response is empty'));
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
- // Stamp now; `requestedDurationMs` is backfilled once media metadata is parsed.
1241
- stampAgentWire('media', 0, {
1242
- mediaUri: msg.media[0].mediaUri || null
1243
- });
1244
- this.ws.send(request);
1245
- msg.attachments.push({
1246
- name: msg.media[0].mediaUri,
1247
- mimeType: msg.media[0].mimeType,
1248
- base64: msg.media[0].buffer.toString('base64')
1249
- });
1250
- try {
1251
- const metadata = await musicMetadata__default["default"].parseBuffer(msg.media[0].buffer);
1252
- if (metadata && metadata.format && metadata.format.duration) {
1253
- debug$3('Audio duration of user audio:', metadata.format.duration, 'seconds');
1254
- duration = Math.round(metadata.format.duration);
1255
- if (msg.voipAgent) {
1256
- msg.voipAgent.requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
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
- } else {
1259
- reject(new Error('Could not determine audio duration from metadata'));
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: {