botium-connector-voip 0.0.21 → 0.0.22

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,18 @@ 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
+ };
10
22
  const Capabilities = {
11
23
  VOIP_STT_URL_STREAM: 'VOIP_STT_URL_STREAM',
12
24
  VOIP_STT_PARAMS_STREAM: 'VOIP_STT_PARAMS_STREAM',
@@ -54,7 +66,8 @@ const Capabilities = {
54
66
  VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: 'VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE',
55
67
  VOIP_SILENCE_DURATION_TIMEOUT_START: 'VOIP_SILENCE_DURATION_TIMEOUT_START',
56
68
  VOIP_STT_CONFIDENCE_THRESHOLD: 'VOIP_STT_CONFIDENCE_THRESHOLD',
57
- VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER'
69
+ VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER',
70
+ VOIP_USER_INPUT_PREFER_VOICE: 'VOIP_USER_INPUT_PREFER_VOICE'
58
71
  };
59
72
  const Defaults = {
60
73
  VOIP_STT_METHOD: 'POST',
@@ -76,7 +89,8 @@ const Defaults = {
76
89
  VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: false,
77
90
  VOIP_STT_CONFIDENCE_THRESHOLD: 0.5,
78
91
  VOIP_USE_GLOBAL_VOIP_WORKER: false,
79
- VOIP_SIP_PROTOCOL: 'TCP'
92
+ VOIP_SIP_PROTOCOL: 'TCP',
93
+ VOIP_USER_INPUT_PREFER_VOICE: true
80
94
  };
81
95
  const TTS_HTTP_AGENT = new http.Agent({
82
96
  keepAlive: true
@@ -199,7 +213,8 @@ class BotiumConnectorVoip {
199
213
  };
200
214
  let data = null;
201
215
  let headers = null;
202
- const connect = async retryIndex => {
216
+ let httpInitRetries = 0;
217
+ const connectHttp = async retryIndex => {
203
218
  retryIndex = retryIndex || 0;
204
219
  try {
205
220
  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 +235,24 @@ class BotiumConnectorVoip {
220
235
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
221
236
  throw new Error(`Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`);
222
237
  }
238
+ httpInitRetries = retryIndex + 1;
223
239
  // Retry after the defined timeout
224
240
  await new Promise(resolve => setTimeout(resolve, this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]));
225
241
 
226
242
  // Retry the connection
227
- await connect(retryIndex + 1);
243
+ await connectHttp(retryIndex + 1);
228
244
  }
229
245
  };
230
- await connect();
246
+ await connectHttp();
247
+ if (httpInitRetries > 0) {
248
+ _info('connected_after_retries', {
249
+ phase: 'initCall',
250
+ retries: httpInitRetries
251
+ });
252
+ }
231
253
  return new Promise((resolve, reject) => {
232
254
  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 => {
255
+ const connectWs = retryIndex => {
234
256
  retryIndex = retryIndex || 0;
235
257
  return new Promise((resolve, reject) => {
236
258
  if ('set-cookie' in headers) {
@@ -243,17 +265,23 @@ class BotiumConnectorVoip {
243
265
  this.ws = new ws(wsEndpoint);
244
266
  }
245
267
  this.ws.on('open', () => {
246
- resolve();
268
+ resolve(retryIndex);
247
269
  });
248
270
  this.ws.on('error', () => {
249
271
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
250
272
  reject(new Error(`Websocket connection failed to ${wsEndpoint}`));
251
273
  }
252
- setTimeout(() => connect(retryIndex + 1).then(resolve).catch(reject), this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]);
274
+ setTimeout(() => connectWs(retryIndex + 1).then(resolve).catch(reject), this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]);
253
275
  });
254
276
  });
255
277
  };
256
- connect().then(() => {
278
+ connectWs().then(wsRetries => {
279
+ if (wsRetries > 0) {
280
+ _info('connected_after_retries', {
281
+ phase: 'websocket',
282
+ retries: wsRetries
283
+ });
284
+ }
257
285
  if (!lodash.isArray(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
258
286
  if (lodash.isEmpty(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
259
287
  this.caps[Capabilities.VOIP_ICE_STUN_SERVERS] = [];
@@ -295,6 +323,7 @@ class BotiumConnectorVoip {
295
323
  });
296
324
  this.silence = null;
297
325
  this.msgCount = 0;
326
+ this.sttPartialCount = 0;
298
327
  this.firstMsg = true;
299
328
  this.firstSttInfoReceived = false;
300
329
  this.silenceTimeout = null;
@@ -439,6 +468,10 @@ class BotiumConnectorVoip {
439
468
  };
440
469
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
441
470
  this.sessionId = parsedData.voipConfig.sessionId;
471
+ _info('callinfo_initialized', {
472
+ sessionId: this.sessionId,
473
+ sttHandling: this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING]
474
+ });
442
475
  }
443
476
 
444
477
  // if sessionId is not the same as the one in the callinfo, return
@@ -453,9 +486,17 @@ class BotiumConnectorVoip {
453
486
  reject(new Error('Error: Sip Registration failed'));
454
487
  }
455
488
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'connected') {
489
+ _info('callinfo_connected', {
490
+ sessionId: this.sessionId
491
+ });
456
492
  resolve();
457
493
  }
458
494
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'disconnected') {
495
+ _info('callinfo_disconnected', {
496
+ sessionId: this.sessionId,
497
+ connectDurationSec: parsedData.connectDuration || null,
498
+ sttPartialCount: this.sttPartialCount
499
+ });
459
500
  const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY));
460
501
  if (parsedData.connectDuration && parsedData.connectDuration > 0) {
461
502
  this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
@@ -485,7 +526,13 @@ class BotiumConnectorVoip {
485
526
  if (parsedData && parsedData.type === 'fullRecordEnd') {
486
527
  const tail = _extractFullRecordBase64(parsedData);
487
528
  if (tail) this.fullRecord = (this.fullRecord || '') + tail;
529
+ const base64Len = (this.fullRecord || tail || '').length;
488
530
  emitFullRecordAttachment(this.fullRecord || tail);
531
+ _info('recording_attached', {
532
+ sessionId: this.sessionId,
533
+ source: 'fullRecordEnd',
534
+ base64Len
535
+ });
489
536
  this.end = true;
490
537
  }
491
538
  if (parsedData && parsedData.type === 'silence') {
@@ -498,10 +545,17 @@ class BotiumConnectorVoip {
498
545
  }
499
546
  if (parsedData && parsedData.type === 'fullRecord') {
500
547
  // Non-chunked full record
501
- emitFullRecordAttachment(_extractFullRecordBase64(parsedData));
548
+ const base64 = _extractFullRecordBase64(parsedData);
549
+ emitFullRecordAttachment(base64);
550
+ _info('recording_attached', {
551
+ sessionId: this.sessionId,
552
+ source: 'fullRecord',
553
+ base64Len: (base64 || '').length
554
+ });
502
555
  this.end = true;
503
556
  }
504
557
  if (parsedData && parsedData.data && parsedData.data.final === false) {
558
+ this.sttPartialCount++;
505
559
  if (this.silenceTimeout) {
506
560
  clearTimeout(this.silenceTimeout);
507
561
  }
@@ -548,7 +602,18 @@ class BotiumConnectorVoip {
548
602
  if (parsedData && parsedData.data && parsedData.data.type === 'stt' && parsedData.data.final) {
549
603
  const confidenceThreshold = this._getConfidenceScoreLogicHook(this.convoStep) && this._getConfidenceScoreLogicHook(this.convoStep).args[0] || this.caps[Capabilities.VOIP_STT_CONFIDENCE_THRESHOLD];
550
604
  const successfulConfidenceScore = this._getConfidenceScore(parsedData) >= confidenceThreshold;
605
+ const msgText = parsedData.data.message || '';
606
+ const msgLen = typeof msgText === 'string' ? msgText.length : 0;
607
+ const msgPreview = typeof msgText === 'string' ? msgText.trim().substring(0, 80) : '';
551
608
  debug$3(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`);
609
+ _info('stt_final', {
610
+ sessionId: this.sessionId,
611
+ message: msgPreview,
612
+ messageLength: msgLen,
613
+ confidence: this._getConfidenceScore(parsedData),
614
+ threshold: confidenceThreshold,
615
+ accepted: successfulConfidenceScore
616
+ });
552
617
  // ORIGINAL: emit final message immediately, unless join hooks are active.
553
618
  if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && lodash.isNil(this._getJoinLogicHook(this.convoStep))) {
554
619
  let botMsg = {
@@ -649,6 +714,19 @@ class BotiumConnectorVoip {
649
714
  }
650
715
  async UserSays(msg) {
651
716
  debug$3('UserSays called');
717
+ const hasText = !!(msg && msg.messageText);
718
+ const hasVoiceMedia = !!(msg && msg.media && msg.media.length > 0 && msg.media[0].buffer);
719
+ const hasDtmf = !!(msg && msg.buttons && msg.buttons.length > 0);
720
+ const dtmfMatch = msg && msg.messageText && msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
721
+ const inputType = hasDtmf || dtmfMatch ? 'dtmf' : hasText && hasVoiceMedia ? 'mixed' : hasText ? 'text' : hasVoiceMedia ? 'media' : 'unknown';
722
+ const msgPreview = hasText && msg.messageText ? String(msg.messageText).trim().substring(0, 80) : '';
723
+ _info('user_says', {
724
+ sessionId: this.sessionId,
725
+ inputType,
726
+ message: msgPreview || undefined,
727
+ messageLength: hasText && msg.messageText ? msg.messageText.length : undefined,
728
+ mediaSize: hasVoiceMedia && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined
729
+ });
652
730
  // Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
653
731
  try {
654
732
  const safeLog = {
@@ -673,6 +751,10 @@ class BotiumConnectorVoip {
673
751
  return new Promise((resolve, reject) => {
674
752
  setTimeout(async () => {
675
753
  let duration = 0;
754
+ const preferVoiceCapRaw = this.caps[Capabilities.VOIP_USER_INPUT_PREFER_VOICE];
755
+ const preferVoice = !!preferVoiceCapRaw;
756
+ const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
757
+ debug$3(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`);
676
758
  if (msg && msg.buttons && msg.buttons.length > 0) {
677
759
  const request = JSON.stringify({
678
760
  METHOD: 'sendDtmf',
@@ -694,55 +776,61 @@ class BotiumConnectorVoip {
694
776
  this.ws.send(request);
695
777
  return resolve();
696
778
  }
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})`);
779
+ if (!skipTtsForMixedInput) {
780
+ if (!this.axiosTtsParams) {
781
+ if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
782
+ return reject(new Error('TTS not configured, only audio input supported'));
733
783
  }
734
- msg.attachments.push({
735
- name: 'tts.wav',
736
- mimeType: 'audio/wav',
737
- base64: b64Buffer
738
- });
739
- this.ws.send(request);
740
784
  } else {
741
- return reject(new Error('TTS failed, response is empty'));
785
+ debug$3('UserSays routing: executing TTS branch');
786
+ const ttsRequest = this._buildTtsRequest(msg.messageText);
787
+ if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
788
+ msg.sourceData = ttsRequest;
789
+ let ttsResult = null;
790
+ try {
791
+ ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
792
+ } catch (err) {
793
+ return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
794
+ }
795
+ if (msg && msg.messageText && msg.messageText.length > 0) {
796
+ const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
797
+ this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
798
+ type: lodash.isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
799
+ metricName: 'consumption.e2e.voip.tts.characters',
800
+ transactions: msg.messageText.length,
801
+ apiKey
802
+ });
803
+ }
804
+ if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
805
+ duration = parseFloat(ttsResult.duration) || 0;
806
+ const b64Buffer = ttsResult.buffer.toString('base64');
807
+ const request = JSON.stringify({
808
+ METHOD: 'sendAudio',
809
+ PESQ: false,
810
+ sessionId: this.sessionId,
811
+ b64_buffer: b64Buffer
812
+ });
813
+ if (this._lastBotSaysQueuedAt) {
814
+ const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
815
+ const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
816
+ debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
817
+ }
818
+ msg.attachments.push({
819
+ name: 'tts.wav',
820
+ mimeType: 'audio/wav',
821
+ base64: b64Buffer
822
+ });
823
+ this.ws.send(request);
824
+ } else {
825
+ return reject(new Error('TTS failed, response is empty'));
826
+ }
742
827
  }
828
+ } else {
829
+ debug$3('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled');
743
830
  }
744
831
  }
745
832
  if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
833
+ debug$3('UserSays routing: executing MEDIA branch');
746
834
  const request = JSON.stringify({
747
835
  METHOD: 'sendAudio',
748
836
  sessionId: this.sessionId,
@@ -1061,6 +1149,12 @@ var botiumConnectorVoip = {
1061
1149
  label: 'Speech Synthesis Profile',
1062
1150
  type: 'speechsynthesisprofile',
1063
1151
  required: true
1152
+ }, {
1153
+ name: 'VOIP_USER_INPUT_PREFER_VOICE',
1154
+ label: 'Prefer voice media when both text and voice present',
1155
+ type: 'boolean',
1156
+ required: false,
1157
+ advanced: true
1064
1158
  }]
1065
1159
  },
1066
1160
  PluginLogicHooks: {