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.
package/src/connector.js CHANGED
@@ -6,6 +6,23 @@ const https = require('https')
6
6
  const debug = require('debug')('botium-connector-voip')
7
7
  const mm = require('music-metadata')
8
8
 
9
+ // Logging policy: info = rare, business-relevant lifecycle events (always visible).
10
+ // debug = high-frequency diagnostics (DEBUG=botium-connector-voip). warn = degraded but continuing.
11
+ // error = abort/failure. No secrets in info; STT text only as length or truncated in info.
12
+
13
+ const _info = (event, data) => {
14
+ const parts = Object.entries({ event, ...data })
15
+ .filter(([, v]) => v != null && v !== '')
16
+ .map(([k, v]) => `${k}=${typeof v === 'string' && v.length > 80 ? `"${v.substring(0, 77)}..."` : JSON.stringify(v)}`)
17
+ console.info(`[botium-connector-voip] ${parts.join(' ')}`)
18
+ }
19
+
20
+ /** DTMF for RTP/PJSIP: 0–9, *, #, A–D. Strips spaces and other separators so the worker gets one compact string. */
21
+ const sanitizeDtmfDigits = (raw) => {
22
+ if (raw == null) return ''
23
+ return String(raw).replace(/[^0-9*#ABCDabcd]/g, '').toUpperCase()
24
+ }
25
+
9
26
  const Capabilities = {
10
27
  VOIP_STT_URL_STREAM: 'VOIP_STT_URL_STREAM',
11
28
  VOIP_STT_PARAMS_STREAM: 'VOIP_STT_PARAMS_STREAM',
@@ -53,7 +70,8 @@ const Capabilities = {
53
70
  VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: 'VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE',
54
71
  VOIP_SILENCE_DURATION_TIMEOUT_START: 'VOIP_SILENCE_DURATION_TIMEOUT_START',
55
72
  VOIP_STT_CONFIDENCE_THRESHOLD: 'VOIP_STT_CONFIDENCE_THRESHOLD',
56
- VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER'
73
+ VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER',
74
+ VOIP_USER_INPUT_PREFER_VOICE: 'VOIP_USER_INPUT_PREFER_VOICE'
57
75
  }
58
76
 
59
77
  const Defaults = {
@@ -76,7 +94,8 @@ const Defaults = {
76
94
  VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: false,
77
95
  VOIP_STT_CONFIDENCE_THRESHOLD: 0.5,
78
96
  VOIP_USE_GLOBAL_VOIP_WORKER: false,
79
- VOIP_SIP_PROTOCOL: 'TCP'
97
+ VOIP_SIP_PROTOCOL: 'TCP',
98
+ VOIP_USER_INPUT_PREFER_VOICE: true
80
99
  }
81
100
 
82
101
  const TTS_HTTP_AGENT = new http.Agent({ keepAlive: true })
@@ -200,7 +219,8 @@ class BotiumConnectorVoip {
200
219
 
201
220
  let data = null
202
221
  let headers = null
203
- const connect = async (retryIndex) => {
222
+ let httpInitRetries = 0
223
+ const connectHttp = async (retryIndex) => {
204
224
  retryIndex = retryIndex || 0
205
225
  try {
206
226
  const workerUrl = this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER]
@@ -223,18 +243,22 @@ class BotiumConnectorVoip {
223
243
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
224
244
  throw new Error(`Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`)
225
245
  }
246
+ httpInitRetries = retryIndex + 1
226
247
  // Retry after the defined timeout
227
248
  await new Promise(resolve => setTimeout(resolve, this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]))
228
249
 
229
250
  // Retry the connection
230
- await connect(retryIndex + 1)
251
+ await connectHttp(retryIndex + 1)
231
252
  }
232
253
  }
233
- await connect()
254
+ await connectHttp()
255
+ if (httpInitRetries > 0) {
256
+ _info('connected_after_retries', { phase: 'initCall', retries: httpInitRetries })
257
+ }
234
258
 
235
259
  return new Promise((resolve, reject) => {
236
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}`
237
- const connect = (retryIndex) => {
261
+ const connectWs = (retryIndex) => {
238
262
  retryIndex = retryIndex || 0
239
263
  return new Promise((resolve, reject) => {
240
264
  if ('set-cookie' in headers) {
@@ -247,17 +271,20 @@ class BotiumConnectorVoip {
247
271
  this.ws = new WebSocket(wsEndpoint)
248
272
  }
249
273
  this.ws.on('open', () => {
250
- resolve()
274
+ resolve(retryIndex)
251
275
  })
252
276
  this.ws.on('error', () => {
253
277
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
254
278
  reject(new Error(`Websocket connection failed to ${wsEndpoint}`))
255
279
  }
256
- 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])
257
281
  })
258
282
  })
259
283
  }
260
- connect().then(() => {
284
+ connectWs().then((wsRetries) => {
285
+ if (wsRetries > 0) {
286
+ _info('connected_after_retries', { phase: 'websocket', retries: wsRetries })
287
+ }
261
288
  if (!_.isArray(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
262
289
  if (_.isEmpty(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
263
290
  this.caps[Capabilities.VOIP_ICE_STUN_SERVERS] = []
@@ -301,6 +328,7 @@ class BotiumConnectorVoip {
301
328
 
302
329
  this.silence = null
303
330
  this.msgCount = 0
331
+ this.sttPartialCount = 0
304
332
  this.firstMsg = true
305
333
  this.firstSttInfoReceived = false
306
334
  this.silenceTimeout = null
@@ -468,6 +496,10 @@ class BotiumConnectorVoip {
468
496
 
469
497
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
470
498
  this.sessionId = parsedData.voipConfig.sessionId
499
+ _info('callinfo_initialized', {
500
+ sessionId: this.sessionId,
501
+ sttHandling: this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING]
502
+ })
471
503
  }
472
504
 
473
505
  // if sessionId is not the same as the one in the callinfo, return
@@ -485,10 +517,16 @@ class BotiumConnectorVoip {
485
517
  }
486
518
 
487
519
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'connected') {
520
+ _info('callinfo_connected', { sessionId: this.sessionId })
488
521
  resolve()
489
522
  }
490
523
 
491
524
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'disconnected') {
525
+ _info('callinfo_disconnected', {
526
+ sessionId: this.sessionId,
527
+ connectDurationSec: parsedData.connectDuration || null,
528
+ sttPartialCount: this.sttPartialCount
529
+ })
492
530
  const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY))
493
531
  if (parsedData.connectDuration && parsedData.connectDuration > 0) {
494
532
  this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
@@ -519,7 +557,9 @@ class BotiumConnectorVoip {
519
557
  if (parsedData && parsedData.type === 'fullRecordEnd') {
520
558
  const tail = _extractFullRecordBase64(parsedData)
521
559
  if (tail) this.fullRecord = (this.fullRecord || '') + tail
560
+ const base64Len = (this.fullRecord || tail || '').length
522
561
  emitFullRecordAttachment(this.fullRecord || tail)
562
+ _info('recording_attached', { sessionId: this.sessionId, source: 'fullRecordEnd', base64Len })
523
563
  this.end = true
524
564
  }
525
565
 
@@ -534,11 +574,14 @@ class BotiumConnectorVoip {
534
574
 
535
575
  if (parsedData && parsedData.type === 'fullRecord') {
536
576
  // Non-chunked full record
537
- emitFullRecordAttachment(_extractFullRecordBase64(parsedData))
577
+ const base64 = _extractFullRecordBase64(parsedData)
578
+ emitFullRecordAttachment(base64)
579
+ _info('recording_attached', { sessionId: this.sessionId, source: 'fullRecord', base64Len: (base64 || '').length })
538
580
  this.end = true
539
581
  }
540
582
 
541
583
  if (parsedData && parsedData.data && parsedData.data.final === false) {
584
+ this.sttPartialCount++
542
585
  if (this.silenceTimeout) {
543
586
  clearTimeout(this.silenceTimeout)
544
587
  }
@@ -586,7 +629,18 @@ class BotiumConnectorVoip {
586
629
  if (parsedData && parsedData.data && parsedData.data.type === 'stt' && parsedData.data.final) {
587
630
  const confidenceThreshold = ((this._getConfidenceScoreLogicHook(this.convoStep) && this._getConfidenceScoreLogicHook(this.convoStep).args[0]) || this.caps[Capabilities.VOIP_STT_CONFIDENCE_THRESHOLD])
588
631
  const successfulConfidenceScore = this._getConfidenceScore(parsedData) >= confidenceThreshold
632
+ const msgText = parsedData.data.message || ''
633
+ const msgLen = typeof msgText === 'string' ? msgText.length : 0
634
+ const msgPreview = typeof msgText === 'string' ? msgText.trim().substring(0, 80) : ''
589
635
  debug(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`)
636
+ _info('stt_final', {
637
+ sessionId: this.sessionId,
638
+ message: msgPreview,
639
+ messageLength: msgLen,
640
+ confidence: this._getConfidenceScore(parsedData),
641
+ threshold: confidenceThreshold,
642
+ accepted: successfulConfidenceScore
643
+ })
590
644
  // ORIGINAL: emit final message immediately, unless join hooks are active.
591
645
  if (
592
646
  (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && (_.isNil(this._getJoinLogicHook(this.convoStep))))
@@ -675,6 +729,19 @@ class BotiumConnectorVoip {
675
729
 
676
730
  async UserSays (msg) {
677
731
  debug('UserSays called')
732
+ const hasText = !!(msg && msg.messageText)
733
+ const hasVoiceMedia = !!(msg && msg.media && msg.media.length > 0 && msg.media[0].buffer)
734
+ const hasDtmf = !!(msg && msg.buttons && msg.buttons.length > 0)
735
+ const dtmfMatch = msg && msg.messageText && msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i)
736
+ const inputType = hasDtmf || dtmfMatch ? 'dtmf' : (hasText && hasVoiceMedia ? 'mixed' : hasText ? 'text' : hasVoiceMedia ? 'media' : 'unknown')
737
+ const msgPreview = hasText && msg.messageText ? String(msg.messageText).trim().substring(0, 80) : ''
738
+ _info('user_says', {
739
+ sessionId: this.sessionId,
740
+ inputType,
741
+ message: msgPreview || undefined,
742
+ messageLength: hasText && msg.messageText ? msg.messageText.length : undefined,
743
+ mediaSize: hasVoiceMedia && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined
744
+ })
678
745
  // Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
679
746
  try {
680
747
  const safeLog = {
@@ -700,10 +767,21 @@ class BotiumConnectorVoip {
700
767
  return new Promise((resolve, reject) => {
701
768
  setTimeout(async () => {
702
769
  let duration = 0
770
+ const preferVoiceCapRaw = this.caps[Capabilities.VOIP_USER_INPUT_PREFER_VOICE]
771
+ const preferVoice = !!preferVoiceCapRaw
772
+ const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia
773
+ debug(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`)
774
+
703
775
  if (msg && msg.buttons && msg.buttons.length > 0) {
776
+ const digits = sanitizeDtmfDigits(msg.buttons[0].payload)
777
+ if (!digits) {
778
+ debug('sendDtmf skipped: no valid DTMF digits after sanitizing button payload')
779
+ return resolve()
780
+ }
781
+ debug(`Sending DTMF digits: ${digits}`)
704
782
  const request = JSON.stringify({
705
783
  METHOD: 'sendDtmf',
706
- digits: msg.buttons[0].payload,
784
+ digits,
707
785
  sessionId: this.sessionId
708
786
  })
709
787
  this.ws.send(request)
@@ -711,8 +789,17 @@ class BotiumConnectorVoip {
711
789
  // Check for DTMF tag in messageText: <DTMF>1234</DTMF>
712
790
  const dtmfMatch = msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i)
713
791
  if (dtmfMatch && dtmfMatch[1]) {
714
- const digits = dtmfMatch[1]
715
- debug(`Sending DTMF from messageText: ${digits}`)
792
+ const rawDigits = dtmfMatch[1]
793
+ const digits = sanitizeDtmfDigits(rawDigits)
794
+ if (!digits) {
795
+ debug(`sendDtmf skipped: no valid DTMF digits after sanitizing <DTMF> content (raw length=${String(rawDigits).length})`)
796
+ return resolve()
797
+ }
798
+ if (digits !== String(rawDigits).replace(/\s/g, '')) {
799
+ debug(`Sending DTMF from messageText (sanitized): "${rawDigits}" -> "${digits}"`)
800
+ } else {
801
+ debug(`Sending DTMF from messageText: ${digits}`)
802
+ }
716
803
  const request = JSON.stringify({
717
804
  METHOD: 'sendDtmf',
718
805
  digits,
@@ -721,56 +808,62 @@ class BotiumConnectorVoip {
721
808
  this.ws.send(request)
722
809
  return resolve()
723
810
  }
724
- if (!this.axiosTtsParams) {
725
- if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
726
- return reject(new Error('TTS not configured, only audio input supported'))
727
- }
728
- } else {
729
- const ttsRequest = this._buildTtsRequest(msg.messageText)
730
- if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'))
731
- msg.sourceData = ttsRequest
732
-
733
- let ttsResult = null
734
- try {
735
- ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText)
736
- } catch (err) {
737
- return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`))
738
- }
739
- if (msg && msg.messageText && msg.messageText.length > 0) {
740
- const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY))
741
- this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
742
- type: _.isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
743
- metricName: 'consumption.e2e.voip.tts.characters',
744
- transactions: msg.messageText.length,
745
- apiKey
746
- })
747
- }
748
- if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
749
- duration = parseFloat(ttsResult.duration) || 0
750
- const b64Buffer = ttsResult.buffer.toString('base64')
751
- const request = JSON.stringify({
752
- METHOD: 'sendAudio',
753
- PESQ: false,
754
- sessionId: this.sessionId,
755
- b64_buffer: b64Buffer
756
- })
757
- if (this._lastBotSaysQueuedAt) {
758
- const latencyMs = Date.now() - this._lastBotSaysQueuedAt
759
- const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>'
760
- debug(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`)
811
+ if (!skipTtsForMixedInput) {
812
+ if (!this.axiosTtsParams) {
813
+ if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
814
+ return reject(new Error('TTS not configured, only audio input supported'))
761
815
  }
762
- msg.attachments.push({
763
- name: 'tts.wav',
764
- mimeType: 'audio/wav',
765
- base64: b64Buffer
766
- })
767
- this.ws.send(request)
768
816
  } else {
769
- return reject(new Error('TTS failed, response is empty'))
817
+ debug('UserSays routing: executing TTS branch')
818
+ const ttsRequest = this._buildTtsRequest(msg.messageText)
819
+ if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'))
820
+ msg.sourceData = ttsRequest
821
+
822
+ let ttsResult = null
823
+ try {
824
+ ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText)
825
+ } catch (err) {
826
+ return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`))
827
+ }
828
+ if (msg && msg.messageText && msg.messageText.length > 0) {
829
+ const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY))
830
+ this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
831
+ type: _.isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
832
+ metricName: 'consumption.e2e.voip.tts.characters',
833
+ transactions: msg.messageText.length,
834
+ apiKey
835
+ })
836
+ }
837
+ if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
838
+ duration = parseFloat(ttsResult.duration) || 0
839
+ const b64Buffer = ttsResult.buffer.toString('base64')
840
+ const request = JSON.stringify({
841
+ METHOD: 'sendAudio',
842
+ PESQ: false,
843
+ sessionId: this.sessionId,
844
+ b64_buffer: b64Buffer
845
+ })
846
+ if (this._lastBotSaysQueuedAt) {
847
+ const latencyMs = Date.now() - this._lastBotSaysQueuedAt
848
+ const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>'
849
+ debug(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`)
850
+ }
851
+ msg.attachments.push({
852
+ name: 'tts.wav',
853
+ mimeType: 'audio/wav',
854
+ base64: b64Buffer
855
+ })
856
+ this.ws.send(request)
857
+ } else {
858
+ return reject(new Error('TTS failed, response is empty'))
859
+ }
770
860
  }
861
+ } else {
862
+ debug('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled')
771
863
  }
772
864
  }
773
865
  if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
866
+ debug('UserSays routing: executing MEDIA branch')
774
867
  const request = JSON.stringify({
775
868
  METHOD: 'sendAudio',
776
869
  sessionId: this.sessionId,