botium-connector-voip 0.0.21 → 0.0.23

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.
@@ -21,6 +21,24 @@ var debug__default = /*#__PURE__*/_interopDefaultLegacy(debug$4);
21
21
  var musicMetadata__default = /*#__PURE__*/_interopDefaultLegacy(musicMetadata);
22
22
 
23
23
  const debug$3 = debug__default["default"]('botium-connector-voip');
24
+
25
+ // Logging policy: info = rare, business-relevant lifecycle events (always visible).
26
+ // debug = high-frequency diagnostics (DEBUG=botium-connector-voip). warn = degraded but continuing.
27
+ // error = abort/failure. No secrets in info; STT text only as length or truncated in info.
28
+
29
+ const _info = (event, data) => {
30
+ const parts = Object.entries({
31
+ event,
32
+ ...data
33
+ }).filter(([, v]) => v != null && v !== '').map(([k, v]) => `${k}=${typeof v === 'string' && v.length > 80 ? `"${v.substring(0, 77)}..."` : JSON.stringify(v)}`);
34
+ console.info(`[botium-connector-voip] ${parts.join(' ')}`);
35
+ };
36
+
37
+ /** DTMF for RTP/PJSIP: 0–9, *, #, A–D. Strips spaces and other separators so the worker gets one compact string. */
38
+ const sanitizeDtmfDigits = raw => {
39
+ if (raw == null) return '';
40
+ return String(raw).replace(/[^0-9*#ABCDabcd]/g, '').toUpperCase();
41
+ };
24
42
  const Capabilities = {
25
43
  VOIP_STT_URL_STREAM: 'VOIP_STT_URL_STREAM',
26
44
  VOIP_STT_PARAMS_STREAM: 'VOIP_STT_PARAMS_STREAM',
@@ -68,7 +86,8 @@ const Capabilities = {
68
86
  VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: 'VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE',
69
87
  VOIP_SILENCE_DURATION_TIMEOUT_START: 'VOIP_SILENCE_DURATION_TIMEOUT_START',
70
88
  VOIP_STT_CONFIDENCE_THRESHOLD: 'VOIP_STT_CONFIDENCE_THRESHOLD',
71
- VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER'
89
+ VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER',
90
+ VOIP_USER_INPUT_PREFER_VOICE: 'VOIP_USER_INPUT_PREFER_VOICE'
72
91
  };
73
92
  const Defaults = {
74
93
  VOIP_STT_METHOD: 'POST',
@@ -90,7 +109,8 @@ const Defaults = {
90
109
  VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: false,
91
110
  VOIP_STT_CONFIDENCE_THRESHOLD: 0.5,
92
111
  VOIP_USE_GLOBAL_VOIP_WORKER: false,
93
- VOIP_SIP_PROTOCOL: 'TCP'
112
+ VOIP_SIP_PROTOCOL: 'TCP',
113
+ VOIP_USER_INPUT_PREFER_VOICE: true
94
114
  };
95
115
  const TTS_HTTP_AGENT = new http__default["default"].Agent({
96
116
  keepAlive: true
@@ -213,7 +233,8 @@ class BotiumConnectorVoip {
213
233
  };
214
234
  let data = null;
215
235
  let headers = null;
216
- const connect = async retryIndex => {
236
+ let httpInitRetries = 0;
237
+ const connectHttp = async retryIndex => {
217
238
  retryIndex = retryIndex || 0;
218
239
  try {
219
240
  const workerUrl = this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_URL : this.caps[Capabilities.VOIP_WORKER_URL].replace('wss', 'https').replace('ws', 'http');
@@ -234,17 +255,24 @@ class BotiumConnectorVoip {
234
255
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
235
256
  throw new Error(`Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`);
236
257
  }
258
+ httpInitRetries = retryIndex + 1;
237
259
  // Retry after the defined timeout
238
260
  await new Promise(resolve => setTimeout(resolve, this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]));
239
261
 
240
262
  // Retry the connection
241
- await connect(retryIndex + 1);
263
+ await connectHttp(retryIndex + 1);
242
264
  }
243
265
  };
244
- await connect();
266
+ await connectHttp();
267
+ if (httpInitRetries > 0) {
268
+ _info('connected_after_retries', {
269
+ phase: 'initCall',
270
+ retries: httpInitRetries
271
+ });
272
+ }
245
273
  return new Promise((resolve, reject) => {
246
274
  const wsEndpoint = `${this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_URL : this.caps[Capabilities.VOIP_WORKER_URL]}/ws/${data.port}`;
247
- const connect = retryIndex => {
275
+ const connectWs = retryIndex => {
248
276
  retryIndex = retryIndex || 0;
249
277
  return new Promise((resolve, reject) => {
250
278
  if ('set-cookie' in headers) {
@@ -257,17 +285,23 @@ class BotiumConnectorVoip {
257
285
  this.ws = new ws__default["default"](wsEndpoint);
258
286
  }
259
287
  this.ws.on('open', () => {
260
- resolve();
288
+ resolve(retryIndex);
261
289
  });
262
290
  this.ws.on('error', () => {
263
291
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
264
292
  reject(new Error(`Websocket connection failed to ${wsEndpoint}`));
265
293
  }
266
- setTimeout(() => connect(retryIndex + 1).then(resolve).catch(reject), this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]);
294
+ setTimeout(() => connectWs(retryIndex + 1).then(resolve).catch(reject), this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]);
267
295
  });
268
296
  });
269
297
  };
270
- connect().then(() => {
298
+ connectWs().then(wsRetries => {
299
+ if (wsRetries > 0) {
300
+ _info('connected_after_retries', {
301
+ phase: 'websocket',
302
+ retries: wsRetries
303
+ });
304
+ }
271
305
  if (!lodash__default["default"].isArray(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
272
306
  if (lodash__default["default"].isEmpty(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
273
307
  this.caps[Capabilities.VOIP_ICE_STUN_SERVERS] = [];
@@ -309,6 +343,7 @@ class BotiumConnectorVoip {
309
343
  });
310
344
  this.silence = null;
311
345
  this.msgCount = 0;
346
+ this.sttPartialCount = 0;
312
347
  this.firstMsg = true;
313
348
  this.firstSttInfoReceived = false;
314
349
  this.silenceTimeout = null;
@@ -453,6 +488,10 @@ class BotiumConnectorVoip {
453
488
  };
454
489
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
455
490
  this.sessionId = parsedData.voipConfig.sessionId;
491
+ _info('callinfo_initialized', {
492
+ sessionId: this.sessionId,
493
+ sttHandling: this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING]
494
+ });
456
495
  }
457
496
 
458
497
  // if sessionId is not the same as the one in the callinfo, return
@@ -467,9 +506,17 @@ class BotiumConnectorVoip {
467
506
  reject(new Error('Error: Sip Registration failed'));
468
507
  }
469
508
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'connected') {
509
+ _info('callinfo_connected', {
510
+ sessionId: this.sessionId
511
+ });
470
512
  resolve();
471
513
  }
472
514
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'disconnected') {
515
+ _info('callinfo_disconnected', {
516
+ sessionId: this.sessionId,
517
+ connectDurationSec: parsedData.connectDuration || null,
518
+ sttPartialCount: this.sttPartialCount
519
+ });
473
520
  const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY));
474
521
  if (parsedData.connectDuration && parsedData.connectDuration > 0) {
475
522
  this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
@@ -499,7 +546,13 @@ class BotiumConnectorVoip {
499
546
  if (parsedData && parsedData.type === 'fullRecordEnd') {
500
547
  const tail = _extractFullRecordBase64(parsedData);
501
548
  if (tail) this.fullRecord = (this.fullRecord || '') + tail;
549
+ const base64Len = (this.fullRecord || tail || '').length;
502
550
  emitFullRecordAttachment(this.fullRecord || tail);
551
+ _info('recording_attached', {
552
+ sessionId: this.sessionId,
553
+ source: 'fullRecordEnd',
554
+ base64Len
555
+ });
503
556
  this.end = true;
504
557
  }
505
558
  if (parsedData && parsedData.type === 'silence') {
@@ -512,10 +565,17 @@ class BotiumConnectorVoip {
512
565
  }
513
566
  if (parsedData && parsedData.type === 'fullRecord') {
514
567
  // Non-chunked full record
515
- emitFullRecordAttachment(_extractFullRecordBase64(parsedData));
568
+ const base64 = _extractFullRecordBase64(parsedData);
569
+ emitFullRecordAttachment(base64);
570
+ _info('recording_attached', {
571
+ sessionId: this.sessionId,
572
+ source: 'fullRecord',
573
+ base64Len: (base64 || '').length
574
+ });
516
575
  this.end = true;
517
576
  }
518
577
  if (parsedData && parsedData.data && parsedData.data.final === false) {
578
+ this.sttPartialCount++;
519
579
  if (this.silenceTimeout) {
520
580
  clearTimeout(this.silenceTimeout);
521
581
  }
@@ -562,7 +622,18 @@ class BotiumConnectorVoip {
562
622
  if (parsedData && parsedData.data && parsedData.data.type === 'stt' && parsedData.data.final) {
563
623
  const confidenceThreshold = this._getConfidenceScoreLogicHook(this.convoStep) && this._getConfidenceScoreLogicHook(this.convoStep).args[0] || this.caps[Capabilities.VOIP_STT_CONFIDENCE_THRESHOLD];
564
624
  const successfulConfidenceScore = this._getConfidenceScore(parsedData) >= confidenceThreshold;
625
+ const msgText = parsedData.data.message || '';
626
+ const msgLen = typeof msgText === 'string' ? msgText.length : 0;
627
+ const msgPreview = typeof msgText === 'string' ? msgText.trim().substring(0, 80) : '';
565
628
  debug$3(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`);
629
+ _info('stt_final', {
630
+ sessionId: this.sessionId,
631
+ message: msgPreview,
632
+ messageLength: msgLen,
633
+ confidence: this._getConfidenceScore(parsedData),
634
+ threshold: confidenceThreshold,
635
+ accepted: successfulConfidenceScore
636
+ });
566
637
  // ORIGINAL: emit final message immediately, unless join hooks are active.
567
638
  if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep))) {
568
639
  let botMsg = {
@@ -663,6 +734,19 @@ class BotiumConnectorVoip {
663
734
  }
664
735
  async UserSays(msg) {
665
736
  debug$3('UserSays called');
737
+ const hasText = !!(msg && msg.messageText);
738
+ const hasVoiceMedia = !!(msg && msg.media && msg.media.length > 0 && msg.media[0].buffer);
739
+ const hasDtmf = !!(msg && msg.buttons && msg.buttons.length > 0);
740
+ const dtmfMatch = msg && msg.messageText && msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
741
+ const inputType = hasDtmf || dtmfMatch ? 'dtmf' : hasText && hasVoiceMedia ? 'mixed' : hasText ? 'text' : hasVoiceMedia ? 'media' : 'unknown';
742
+ const msgPreview = hasText && msg.messageText ? String(msg.messageText).trim().substring(0, 80) : '';
743
+ _info('user_says', {
744
+ sessionId: this.sessionId,
745
+ inputType,
746
+ message: msgPreview || undefined,
747
+ messageLength: hasText && msg.messageText ? msg.messageText.length : undefined,
748
+ mediaSize: hasVoiceMedia && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined
749
+ });
666
750
  // Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
667
751
  try {
668
752
  const safeLog = {
@@ -687,10 +771,20 @@ class BotiumConnectorVoip {
687
771
  return new Promise((resolve, reject) => {
688
772
  setTimeout(async () => {
689
773
  let duration = 0;
774
+ const preferVoiceCapRaw = this.caps[Capabilities.VOIP_USER_INPUT_PREFER_VOICE];
775
+ const preferVoice = !!preferVoiceCapRaw;
776
+ const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
777
+ debug$3(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`);
690
778
  if (msg && msg.buttons && msg.buttons.length > 0) {
779
+ const digits = sanitizeDtmfDigits(msg.buttons[0].payload);
780
+ if (!digits) {
781
+ debug$3('sendDtmf skipped: no valid DTMF digits after sanitizing button payload');
782
+ return resolve();
783
+ }
784
+ debug$3(`Sending DTMF digits: ${digits}`);
691
785
  const request = JSON.stringify({
692
786
  METHOD: 'sendDtmf',
693
- digits: msg.buttons[0].payload,
787
+ digits,
694
788
  sessionId: this.sessionId
695
789
  });
696
790
  this.ws.send(request);
@@ -698,8 +792,17 @@ class BotiumConnectorVoip {
698
792
  // Check for DTMF tag in messageText: <DTMF>1234</DTMF>
699
793
  const dtmfMatch = msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
700
794
  if (dtmfMatch && dtmfMatch[1]) {
701
- const digits = dtmfMatch[1];
702
- debug$3(`Sending DTMF from messageText: ${digits}`);
795
+ const rawDigits = dtmfMatch[1];
796
+ const digits = sanitizeDtmfDigits(rawDigits);
797
+ if (!digits) {
798
+ debug$3(`sendDtmf skipped: no valid DTMF digits after sanitizing <DTMF> content (raw length=${String(rawDigits).length})`);
799
+ return resolve();
800
+ }
801
+ if (digits !== String(rawDigits).replace(/\s/g, '')) {
802
+ debug$3(`Sending DTMF from messageText (sanitized): "${rawDigits}" -> "${digits}"`);
803
+ } else {
804
+ debug$3(`Sending DTMF from messageText: ${digits}`);
805
+ }
703
806
  const request = JSON.stringify({
704
807
  METHOD: 'sendDtmf',
705
808
  digits,
@@ -708,55 +811,61 @@ class BotiumConnectorVoip {
708
811
  this.ws.send(request);
709
812
  return resolve();
710
813
  }
711
- if (!this.axiosTtsParams) {
712
- if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
713
- return reject(new Error('TTS not configured, only audio input supported'));
714
- }
715
- } else {
716
- const ttsRequest = this._buildTtsRequest(msg.messageText);
717
- if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
718
- msg.sourceData = ttsRequest;
719
- let ttsResult = null;
720
- try {
721
- ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
722
- } catch (err) {
723
- return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
724
- }
725
- if (msg && msg.messageText && msg.messageText.length > 0) {
726
- const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
727
- this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
728
- type: lodash__default["default"].isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
729
- metricName: 'consumption.e2e.voip.tts.characters',
730
- transactions: msg.messageText.length,
731
- apiKey
732
- });
733
- }
734
- if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
735
- duration = parseFloat(ttsResult.duration) || 0;
736
- const b64Buffer = ttsResult.buffer.toString('base64');
737
- const request = JSON.stringify({
738
- METHOD: 'sendAudio',
739
- PESQ: false,
740
- sessionId: this.sessionId,
741
- b64_buffer: b64Buffer
742
- });
743
- if (this._lastBotSaysQueuedAt) {
744
- const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
745
- const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
746
- debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
814
+ if (!skipTtsForMixedInput) {
815
+ if (!this.axiosTtsParams) {
816
+ if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
817
+ return reject(new Error('TTS not configured, only audio input supported'));
747
818
  }
748
- msg.attachments.push({
749
- name: 'tts.wav',
750
- mimeType: 'audio/wav',
751
- base64: b64Buffer
752
- });
753
- this.ws.send(request);
754
819
  } else {
755
- return reject(new Error('TTS failed, response is empty'));
820
+ debug$3('UserSays routing: executing TTS branch');
821
+ const ttsRequest = this._buildTtsRequest(msg.messageText);
822
+ if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
823
+ msg.sourceData = ttsRequest;
824
+ let ttsResult = null;
825
+ try {
826
+ ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
827
+ } catch (err) {
828
+ return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
829
+ }
830
+ if (msg && msg.messageText && msg.messageText.length > 0) {
831
+ const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
832
+ this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
833
+ type: lodash__default["default"].isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
834
+ metricName: 'consumption.e2e.voip.tts.characters',
835
+ transactions: msg.messageText.length,
836
+ apiKey
837
+ });
838
+ }
839
+ if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
840
+ duration = parseFloat(ttsResult.duration) || 0;
841
+ const b64Buffer = ttsResult.buffer.toString('base64');
842
+ const request = JSON.stringify({
843
+ METHOD: 'sendAudio',
844
+ PESQ: false,
845
+ sessionId: this.sessionId,
846
+ b64_buffer: b64Buffer
847
+ });
848
+ if (this._lastBotSaysQueuedAt) {
849
+ const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
850
+ const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
851
+ debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
852
+ }
853
+ msg.attachments.push({
854
+ name: 'tts.wav',
855
+ mimeType: 'audio/wav',
856
+ base64: b64Buffer
857
+ });
858
+ this.ws.send(request);
859
+ } else {
860
+ return reject(new Error('TTS failed, response is empty'));
861
+ }
756
862
  }
863
+ } else {
864
+ debug$3('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled');
757
865
  }
758
866
  }
759
867
  if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
868
+ debug$3('UserSays routing: executing MEDIA branch');
760
869
  const request = JSON.stringify({
761
870
  METHOD: 'sendAudio',
762
871
  sessionId: this.sessionId,
@@ -1075,6 +1184,12 @@ var botiumConnectorVoip = {
1075
1184
  label: 'Speech Synthesis Profile',
1076
1185
  type: 'speechsynthesisprofile',
1077
1186
  required: true
1187
+ }, {
1188
+ name: 'VOIP_USER_INPUT_PREFER_VOICE',
1189
+ label: 'Prefer voice media when both text and voice present',
1190
+ type: 'boolean',
1191
+ required: false,
1192
+ advanced: true
1078
1193
  }]
1079
1194
  },
1080
1195
  PluginLogicHooks: {