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.
@@ -7,6 +7,24 @@ import debug$4 from 'debug';
7
7
  import musicMetadata from 'music-metadata';
8
8
 
9
9
  const debug$3 = debug$4('botium-connector-voip');
10
+
11
+ // Logging policy: info = rare, business-relevant lifecycle events (always visible).
12
+ // debug = high-frequency diagnostics (DEBUG=botium-connector-voip). warn = degraded but continuing.
13
+ // error = abort/failure. No secrets in info; STT text only as length or truncated in info.
14
+
15
+ const _info = (event, data) => {
16
+ const parts = Object.entries({
17
+ event,
18
+ ...data
19
+ }).filter(([, v]) => v != null && v !== '').map(([k, v]) => `${k}=${typeof v === 'string' && v.length > 80 ? `"${v.substring(0, 77)}..."` : JSON.stringify(v)}`);
20
+ console.info(`[botium-connector-voip] ${parts.join(' ')}`);
21
+ };
22
+
23
+ /** DTMF for RTP/PJSIP: 0–9, *, #, A–D. Strips spaces and other separators so the worker gets one compact string. */
24
+ const sanitizeDtmfDigits = raw => {
25
+ if (raw == null) return '';
26
+ return String(raw).replace(/[^0-9*#ABCDabcd]/g, '').toUpperCase();
27
+ };
10
28
  const Capabilities = {
11
29
  VOIP_STT_URL_STREAM: 'VOIP_STT_URL_STREAM',
12
30
  VOIP_STT_PARAMS_STREAM: 'VOIP_STT_PARAMS_STREAM',
@@ -54,7 +72,8 @@ const Capabilities = {
54
72
  VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: 'VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE',
55
73
  VOIP_SILENCE_DURATION_TIMEOUT_START: 'VOIP_SILENCE_DURATION_TIMEOUT_START',
56
74
  VOIP_STT_CONFIDENCE_THRESHOLD: 'VOIP_STT_CONFIDENCE_THRESHOLD',
57
- VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER'
75
+ VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER',
76
+ VOIP_USER_INPUT_PREFER_VOICE: 'VOIP_USER_INPUT_PREFER_VOICE'
58
77
  };
59
78
  const Defaults = {
60
79
  VOIP_STT_METHOD: 'POST',
@@ -76,7 +95,8 @@ const Defaults = {
76
95
  VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: false,
77
96
  VOIP_STT_CONFIDENCE_THRESHOLD: 0.5,
78
97
  VOIP_USE_GLOBAL_VOIP_WORKER: false,
79
- VOIP_SIP_PROTOCOL: 'TCP'
98
+ VOIP_SIP_PROTOCOL: 'TCP',
99
+ VOIP_USER_INPUT_PREFER_VOICE: true
80
100
  };
81
101
  const TTS_HTTP_AGENT = new http.Agent({
82
102
  keepAlive: true
@@ -199,7 +219,8 @@ class BotiumConnectorVoip {
199
219
  };
200
220
  let data = null;
201
221
  let headers = null;
202
- const connect = async retryIndex => {
222
+ let httpInitRetries = 0;
223
+ const connectHttp = async retryIndex => {
203
224
  retryIndex = retryIndex || 0;
204
225
  try {
205
226
  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');
@@ -220,17 +241,24 @@ class BotiumConnectorVoip {
220
241
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
221
242
  throw new Error(`Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`);
222
243
  }
244
+ httpInitRetries = retryIndex + 1;
223
245
  // Retry after the defined timeout
224
246
  await new Promise(resolve => setTimeout(resolve, this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]));
225
247
 
226
248
  // Retry the connection
227
- await connect(retryIndex + 1);
249
+ await connectHttp(retryIndex + 1);
228
250
  }
229
251
  };
230
- await connect();
252
+ await connectHttp();
253
+ if (httpInitRetries > 0) {
254
+ _info('connected_after_retries', {
255
+ phase: 'initCall',
256
+ retries: httpInitRetries
257
+ });
258
+ }
231
259
  return new Promise((resolve, reject) => {
232
260
  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}`;
233
- const connect = retryIndex => {
261
+ const connectWs = retryIndex => {
234
262
  retryIndex = retryIndex || 0;
235
263
  return new Promise((resolve, reject) => {
236
264
  if ('set-cookie' in headers) {
@@ -243,17 +271,23 @@ class BotiumConnectorVoip {
243
271
  this.ws = new ws(wsEndpoint);
244
272
  }
245
273
  this.ws.on('open', () => {
246
- resolve();
274
+ resolve(retryIndex);
247
275
  });
248
276
  this.ws.on('error', () => {
249
277
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
250
278
  reject(new Error(`Websocket connection failed to ${wsEndpoint}`));
251
279
  }
252
- setTimeout(() => connect(retryIndex + 1).then(resolve).catch(reject), this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]);
280
+ setTimeout(() => connectWs(retryIndex + 1).then(resolve).catch(reject), this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]);
253
281
  });
254
282
  });
255
283
  };
256
- connect().then(() => {
284
+ connectWs().then(wsRetries => {
285
+ if (wsRetries > 0) {
286
+ _info('connected_after_retries', {
287
+ phase: 'websocket',
288
+ retries: wsRetries
289
+ });
290
+ }
257
291
  if (!lodash.isArray(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
258
292
  if (lodash.isEmpty(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
259
293
  this.caps[Capabilities.VOIP_ICE_STUN_SERVERS] = [];
@@ -295,6 +329,7 @@ class BotiumConnectorVoip {
295
329
  });
296
330
  this.silence = null;
297
331
  this.msgCount = 0;
332
+ this.sttPartialCount = 0;
298
333
  this.firstMsg = true;
299
334
  this.firstSttInfoReceived = false;
300
335
  this.silenceTimeout = null;
@@ -439,6 +474,10 @@ class BotiumConnectorVoip {
439
474
  };
440
475
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
441
476
  this.sessionId = parsedData.voipConfig.sessionId;
477
+ _info('callinfo_initialized', {
478
+ sessionId: this.sessionId,
479
+ sttHandling: this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING]
480
+ });
442
481
  }
443
482
 
444
483
  // if sessionId is not the same as the one in the callinfo, return
@@ -453,9 +492,17 @@ class BotiumConnectorVoip {
453
492
  reject(new Error('Error: Sip Registration failed'));
454
493
  }
455
494
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'connected') {
495
+ _info('callinfo_connected', {
496
+ sessionId: this.sessionId
497
+ });
456
498
  resolve();
457
499
  }
458
500
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'disconnected') {
501
+ _info('callinfo_disconnected', {
502
+ sessionId: this.sessionId,
503
+ connectDurationSec: parsedData.connectDuration || null,
504
+ sttPartialCount: this.sttPartialCount
505
+ });
459
506
  const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY));
460
507
  if (parsedData.connectDuration && parsedData.connectDuration > 0) {
461
508
  this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
@@ -485,7 +532,13 @@ class BotiumConnectorVoip {
485
532
  if (parsedData && parsedData.type === 'fullRecordEnd') {
486
533
  const tail = _extractFullRecordBase64(parsedData);
487
534
  if (tail) this.fullRecord = (this.fullRecord || '') + tail;
535
+ const base64Len = (this.fullRecord || tail || '').length;
488
536
  emitFullRecordAttachment(this.fullRecord || tail);
537
+ _info('recording_attached', {
538
+ sessionId: this.sessionId,
539
+ source: 'fullRecordEnd',
540
+ base64Len
541
+ });
489
542
  this.end = true;
490
543
  }
491
544
  if (parsedData && parsedData.type === 'silence') {
@@ -498,10 +551,17 @@ class BotiumConnectorVoip {
498
551
  }
499
552
  if (parsedData && parsedData.type === 'fullRecord') {
500
553
  // Non-chunked full record
501
- emitFullRecordAttachment(_extractFullRecordBase64(parsedData));
554
+ const base64 = _extractFullRecordBase64(parsedData);
555
+ emitFullRecordAttachment(base64);
556
+ _info('recording_attached', {
557
+ sessionId: this.sessionId,
558
+ source: 'fullRecord',
559
+ base64Len: (base64 || '').length
560
+ });
502
561
  this.end = true;
503
562
  }
504
563
  if (parsedData && parsedData.data && parsedData.data.final === false) {
564
+ this.sttPartialCount++;
505
565
  if (this.silenceTimeout) {
506
566
  clearTimeout(this.silenceTimeout);
507
567
  }
@@ -548,7 +608,18 @@ class BotiumConnectorVoip {
548
608
  if (parsedData && parsedData.data && parsedData.data.type === 'stt' && parsedData.data.final) {
549
609
  const confidenceThreshold = this._getConfidenceScoreLogicHook(this.convoStep) && this._getConfidenceScoreLogicHook(this.convoStep).args[0] || this.caps[Capabilities.VOIP_STT_CONFIDENCE_THRESHOLD];
550
610
  const successfulConfidenceScore = this._getConfidenceScore(parsedData) >= confidenceThreshold;
611
+ const msgText = parsedData.data.message || '';
612
+ const msgLen = typeof msgText === 'string' ? msgText.length : 0;
613
+ const msgPreview = typeof msgText === 'string' ? msgText.trim().substring(0, 80) : '';
551
614
  debug$3(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`);
615
+ _info('stt_final', {
616
+ sessionId: this.sessionId,
617
+ message: msgPreview,
618
+ messageLength: msgLen,
619
+ confidence: this._getConfidenceScore(parsedData),
620
+ threshold: confidenceThreshold,
621
+ accepted: successfulConfidenceScore
622
+ });
552
623
  // ORIGINAL: emit final message immediately, unless join hooks are active.
553
624
  if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && lodash.isNil(this._getJoinLogicHook(this.convoStep))) {
554
625
  let botMsg = {
@@ -649,6 +720,19 @@ class BotiumConnectorVoip {
649
720
  }
650
721
  async UserSays(msg) {
651
722
  debug$3('UserSays called');
723
+ const hasText = !!(msg && msg.messageText);
724
+ const hasVoiceMedia = !!(msg && msg.media && msg.media.length > 0 && msg.media[0].buffer);
725
+ const hasDtmf = !!(msg && msg.buttons && msg.buttons.length > 0);
726
+ const dtmfMatch = msg && msg.messageText && msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
727
+ const inputType = hasDtmf || dtmfMatch ? 'dtmf' : hasText && hasVoiceMedia ? 'mixed' : hasText ? 'text' : hasVoiceMedia ? 'media' : 'unknown';
728
+ const msgPreview = hasText && msg.messageText ? String(msg.messageText).trim().substring(0, 80) : '';
729
+ _info('user_says', {
730
+ sessionId: this.sessionId,
731
+ inputType,
732
+ message: msgPreview || undefined,
733
+ messageLength: hasText && msg.messageText ? msg.messageText.length : undefined,
734
+ mediaSize: hasVoiceMedia && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined
735
+ });
652
736
  // Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
653
737
  try {
654
738
  const safeLog = {
@@ -673,10 +757,20 @@ class BotiumConnectorVoip {
673
757
  return new Promise((resolve, reject) => {
674
758
  setTimeout(async () => {
675
759
  let duration = 0;
760
+ const preferVoiceCapRaw = this.caps[Capabilities.VOIP_USER_INPUT_PREFER_VOICE];
761
+ const preferVoice = !!preferVoiceCapRaw;
762
+ const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
763
+ debug$3(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`);
676
764
  if (msg && msg.buttons && msg.buttons.length > 0) {
765
+ const digits = sanitizeDtmfDigits(msg.buttons[0].payload);
766
+ if (!digits) {
767
+ debug$3('sendDtmf skipped: no valid DTMF digits after sanitizing button payload');
768
+ return resolve();
769
+ }
770
+ debug$3(`Sending DTMF digits: ${digits}`);
677
771
  const request = JSON.stringify({
678
772
  METHOD: 'sendDtmf',
679
- digits: msg.buttons[0].payload,
773
+ digits,
680
774
  sessionId: this.sessionId
681
775
  });
682
776
  this.ws.send(request);
@@ -684,8 +778,17 @@ class BotiumConnectorVoip {
684
778
  // Check for DTMF tag in messageText: <DTMF>1234</DTMF>
685
779
  const dtmfMatch = msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
686
780
  if (dtmfMatch && dtmfMatch[1]) {
687
- const digits = dtmfMatch[1];
688
- debug$3(`Sending DTMF from messageText: ${digits}`);
781
+ const rawDigits = dtmfMatch[1];
782
+ const digits = sanitizeDtmfDigits(rawDigits);
783
+ if (!digits) {
784
+ debug$3(`sendDtmf skipped: no valid DTMF digits after sanitizing <DTMF> content (raw length=${String(rawDigits).length})`);
785
+ return resolve();
786
+ }
787
+ if (digits !== String(rawDigits).replace(/\s/g, '')) {
788
+ debug$3(`Sending DTMF from messageText (sanitized): "${rawDigits}" -> "${digits}"`);
789
+ } else {
790
+ debug$3(`Sending DTMF from messageText: ${digits}`);
791
+ }
689
792
  const request = JSON.stringify({
690
793
  METHOD: 'sendDtmf',
691
794
  digits,
@@ -694,55 +797,61 @@ class BotiumConnectorVoip {
694
797
  this.ws.send(request);
695
798
  return resolve();
696
799
  }
697
- if (!this.axiosTtsParams) {
698
- if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
699
- return reject(new Error('TTS not configured, only audio input supported'));
700
- }
701
- } else {
702
- const ttsRequest = this._buildTtsRequest(msg.messageText);
703
- if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
704
- msg.sourceData = ttsRequest;
705
- let ttsResult = null;
706
- try {
707
- ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
708
- } catch (err) {
709
- return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
710
- }
711
- if (msg && msg.messageText && msg.messageText.length > 0) {
712
- const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
713
- this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
714
- type: lodash.isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
715
- metricName: 'consumption.e2e.voip.tts.characters',
716
- transactions: msg.messageText.length,
717
- apiKey
718
- });
719
- }
720
- if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
721
- duration = parseFloat(ttsResult.duration) || 0;
722
- const b64Buffer = ttsResult.buffer.toString('base64');
723
- const request = JSON.stringify({
724
- METHOD: 'sendAudio',
725
- PESQ: false,
726
- sessionId: this.sessionId,
727
- b64_buffer: b64Buffer
728
- });
729
- if (this._lastBotSaysQueuedAt) {
730
- const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
731
- const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
732
- debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
800
+ if (!skipTtsForMixedInput) {
801
+ if (!this.axiosTtsParams) {
802
+ if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
803
+ return reject(new Error('TTS not configured, only audio input supported'));
733
804
  }
734
- msg.attachments.push({
735
- name: 'tts.wav',
736
- mimeType: 'audio/wav',
737
- base64: b64Buffer
738
- });
739
- this.ws.send(request);
740
805
  } else {
741
- return reject(new Error('TTS failed, response is empty'));
806
+ debug$3('UserSays routing: executing TTS branch');
807
+ const ttsRequest = this._buildTtsRequest(msg.messageText);
808
+ if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
809
+ msg.sourceData = ttsRequest;
810
+ let ttsResult = null;
811
+ try {
812
+ ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
813
+ } catch (err) {
814
+ return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
815
+ }
816
+ if (msg && msg.messageText && msg.messageText.length > 0) {
817
+ const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
818
+ this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
819
+ type: lodash.isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
820
+ metricName: 'consumption.e2e.voip.tts.characters',
821
+ transactions: msg.messageText.length,
822
+ apiKey
823
+ });
824
+ }
825
+ if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
826
+ duration = parseFloat(ttsResult.duration) || 0;
827
+ const b64Buffer = ttsResult.buffer.toString('base64');
828
+ const request = JSON.stringify({
829
+ METHOD: 'sendAudio',
830
+ PESQ: false,
831
+ sessionId: this.sessionId,
832
+ b64_buffer: b64Buffer
833
+ });
834
+ if (this._lastBotSaysQueuedAt) {
835
+ const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
836
+ const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
837
+ debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
838
+ }
839
+ msg.attachments.push({
840
+ name: 'tts.wav',
841
+ mimeType: 'audio/wav',
842
+ base64: b64Buffer
843
+ });
844
+ this.ws.send(request);
845
+ } else {
846
+ return reject(new Error('TTS failed, response is empty'));
847
+ }
742
848
  }
849
+ } else {
850
+ debug$3('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled');
743
851
  }
744
852
  }
745
853
  if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
854
+ debug$3('UserSays routing: executing MEDIA branch');
746
855
  const request = JSON.stringify({
747
856
  METHOD: 'sendAudio',
748
857
  sessionId: this.sessionId,
@@ -1061,6 +1170,12 @@ var botiumConnectorVoip = {
1061
1170
  label: 'Speech Synthesis Profile',
1062
1171
  type: 'speechsynthesisprofile',
1063
1172
  required: true
1173
+ }, {
1174
+ name: 'VOIP_USER_INPUT_PREFER_VOICE',
1175
+ label: 'Prefer voice media when both text and voice present',
1176
+ type: 'boolean',
1177
+ required: false,
1178
+ advanced: true
1064
1179
  }]
1065
1180
  },
1066
1181
  PluginLogicHooks: {