botium-connector-voip 0.0.26 → 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({
@@ -826,7 +805,7 @@ class BotiumConnectorVoip {
826
805
  }
827
806
  if (parsedData && parsedData.type === 'silence') {
828
807
  if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
829
- 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) {
830
809
  flushPendingBotMsgs('silence_exceeded');
831
810
  this.end = true;
832
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`));
@@ -907,7 +886,7 @@ class BotiumConnectorVoip {
907
886
  }
908
887
  }
909
888
  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]) {
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]) {
911
890
  this.end = true;
912
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`));
913
892
  }
@@ -916,23 +895,11 @@ class BotiumConnectorVoip {
916
895
  if (this.prevData && this.prevData.data && !lodash__default["default"].isNil(this.prevData.data.end)) {
917
896
  if (!lodash__default["default"].isNil(parsedData.data.start)) {
918
897
  const silenceDuration = parsedData.data.start - this.prevData.data.end;
919
- const joinLogicHook = this._getJoinLogicHook(this.convoStep);
920
898
  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
- };
899
+ const isJoinMethod = sttHandling === 'JOIN' || sttHandling === 'PSST';
928
900
  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());
901
+ if (isJoinMethod) {
902
+ const joinTimeoutMs = this._getEffectiveJoinTimeoutMs(this.convoStep, this.botMsgs);
936
903
  if (lodash__default["default"].isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > joinTimeoutMs / 1000) {
937
904
  matched = true;
938
905
  }
@@ -963,8 +930,8 @@ class BotiumConnectorVoip {
963
930
  threshold: confidenceThreshold,
964
931
  accepted: successfulConfidenceScore
965
932
  });
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))) {
933
+ // ORIGINAL: always emit final message immediately (ignore JOIN hooks/rules).
934
+ if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL') {
968
935
  let botMsg = {
969
936
  messageText: parsedData.data.message
970
937
  };
@@ -1023,7 +990,7 @@ class BotiumConnectorVoip {
1023
990
  }
1024
991
  this.botMsgs = [];
1025
992
  }
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))) {
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') {
1027
994
  const botMsg = {
1028
995
  messageText: parsedData.data.message,
1029
996
  sourceData: parsedData
@@ -1102,58 +1069,35 @@ class BotiumConnectorVoip {
1102
1069
  }
1103
1070
  return new Promise((resolve, reject) => {
1104
1071
  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}`);
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}`);
1110
1078
 
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
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
+ };
1122
1091
  };
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);
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);
1148
1096
  if (!digits) {
1149
- 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');
1150
1098
  return resolve();
1151
1099
  }
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
- }
1100
+ debug$3(`Sending DTMF digits: ${digits}`);
1157
1101
  const request = JSON.stringify({
1158
1102
  METHOD: 'sendDtmf',
1159
1103
  digits,
@@ -1162,114 +1106,150 @@ class BotiumConnectorVoip {
1162
1106
  stampAgentWire('dtmf', digits.length * DTMF_MS_PER_DIGIT, {
1163
1107
  digitCount: digits.length
1164
1108
  });
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'));
1172
- }
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)}`));
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();
1186
1119
  }
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
- });
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}`);
1195
1124
  }
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})`);
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'));
1209
1140
  }
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
1141
  } else {
1221
- 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
+ }
1222
1191
  }
1192
+ } else {
1193
+ debug$3('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled');
1223
1194
  }
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
1195
  }
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));
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'));
1257
1228
  }
1258
- } else {
1259
- reject(new Error('Could not determine audio duration from metadata'));
1229
+ } catch (err) {
1230
+ reject(new Error(`Getting audio duration failed: ${err.message}`));
1260
1231
  }
1261
- } catch (err) {
1262
- reject(new Error(`Getting audio duration failed: ${err.message}`));
1263
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);
1264
1240
  }
1265
- const requestedDurationMs = Math.max(0, Math.round((duration || 0) * 1000));
1266
- if (requestedDurationMs <= 0) {
1267
- return resolve();
1268
- }
1269
- setTimeout(resolve, requestedDurationMs);
1270
1241
  }, 0);
1271
1242
  });
1272
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
+ }
1273
1253
  async Stop() {
1274
1254
  debug$3(`${this.sessionId} - Stop called`);
1275
1255
  this.stopCalled = true;
@@ -1487,6 +1467,75 @@ class BotiumConnectorVoip {
1487
1467
  if (lodash__default["default"].isNil(convoStep.logicHooks)) return null;
1488
1468
  return convoStep && convoStep.logicHooks && convoStep.logicHooks.find(lh => lh.name === 'VOIP_JOIN_SILENCE_DURATION');
1489
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
+ }
1490
1539
  _getIgnoreSilenceDurationAsserterLogicHook(convoStep) {
1491
1540
  if (lodash__default["default"].isNil(convoStep)) return null;
1492
1541
  if (lodash__default["default"].isNil(convoStep.logicHooks)) return null;
@@ -1572,6 +1621,12 @@ var botiumConnectorVoip = {
1572
1621
  type: 'boolean',
1573
1622
  required: false,
1574
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
1575
1630
  }]
1576
1631
  },
1577
1632
  PluginLogicHooks: {