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.
package/src/connector.js CHANGED
@@ -6,6 +6,17 @@ 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
+
9
20
  const Capabilities = {
10
21
  VOIP_STT_URL_STREAM: 'VOIP_STT_URL_STREAM',
11
22
  VOIP_STT_PARAMS_STREAM: 'VOIP_STT_PARAMS_STREAM',
@@ -53,7 +64,8 @@ const Capabilities = {
53
64
  VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: 'VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE',
54
65
  VOIP_SILENCE_DURATION_TIMEOUT_START: 'VOIP_SILENCE_DURATION_TIMEOUT_START',
55
66
  VOIP_STT_CONFIDENCE_THRESHOLD: 'VOIP_STT_CONFIDENCE_THRESHOLD',
56
- VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER'
67
+ VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER',
68
+ VOIP_USER_INPUT_PREFER_VOICE: 'VOIP_USER_INPUT_PREFER_VOICE'
57
69
  }
58
70
 
59
71
  const Defaults = {
@@ -76,7 +88,8 @@ const Defaults = {
76
88
  VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: false,
77
89
  VOIP_STT_CONFIDENCE_THRESHOLD: 0.5,
78
90
  VOIP_USE_GLOBAL_VOIP_WORKER: false,
79
- VOIP_SIP_PROTOCOL: 'TCP'
91
+ VOIP_SIP_PROTOCOL: 'TCP',
92
+ VOIP_USER_INPUT_PREFER_VOICE: true
80
93
  }
81
94
 
82
95
  const TTS_HTTP_AGENT = new http.Agent({ keepAlive: true })
@@ -200,7 +213,8 @@ class BotiumConnectorVoip {
200
213
 
201
214
  let data = null
202
215
  let headers = null
203
- const connect = async (retryIndex) => {
216
+ let httpInitRetries = 0
217
+ const connectHttp = async (retryIndex) => {
204
218
  retryIndex = retryIndex || 0
205
219
  try {
206
220
  const workerUrl = this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER]
@@ -223,18 +237,22 @@ class BotiumConnectorVoip {
223
237
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
224
238
  throw new Error(`Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`)
225
239
  }
240
+ httpInitRetries = retryIndex + 1
226
241
  // Retry after the defined timeout
227
242
  await new Promise(resolve => setTimeout(resolve, this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]))
228
243
 
229
244
  // Retry the connection
230
- await connect(retryIndex + 1)
245
+ await connectHttp(retryIndex + 1)
231
246
  }
232
247
  }
233
- await connect()
248
+ await connectHttp()
249
+ if (httpInitRetries > 0) {
250
+ _info('connected_after_retries', { phase: 'initCall', retries: httpInitRetries })
251
+ }
234
252
 
235
253
  return new Promise((resolve, reject) => {
236
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}`
237
- const connect = (retryIndex) => {
255
+ const connectWs = (retryIndex) => {
238
256
  retryIndex = retryIndex || 0
239
257
  return new Promise((resolve, reject) => {
240
258
  if ('set-cookie' in headers) {
@@ -247,17 +265,20 @@ class BotiumConnectorVoip {
247
265
  this.ws = new WebSocket(wsEndpoint)
248
266
  }
249
267
  this.ws.on('open', () => {
250
- resolve()
268
+ resolve(retryIndex)
251
269
  })
252
270
  this.ws.on('error', () => {
253
271
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
254
272
  reject(new Error(`Websocket connection failed to ${wsEndpoint}`))
255
273
  }
256
- 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])
257
275
  })
258
276
  })
259
277
  }
260
- connect().then(() => {
278
+ connectWs().then((wsRetries) => {
279
+ if (wsRetries > 0) {
280
+ _info('connected_after_retries', { phase: 'websocket', retries: wsRetries })
281
+ }
261
282
  if (!_.isArray(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
262
283
  if (_.isEmpty(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
263
284
  this.caps[Capabilities.VOIP_ICE_STUN_SERVERS] = []
@@ -301,6 +322,7 @@ class BotiumConnectorVoip {
301
322
 
302
323
  this.silence = null
303
324
  this.msgCount = 0
325
+ this.sttPartialCount = 0
304
326
  this.firstMsg = true
305
327
  this.firstSttInfoReceived = false
306
328
  this.silenceTimeout = null
@@ -468,6 +490,10 @@ class BotiumConnectorVoip {
468
490
 
469
491
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
470
492
  this.sessionId = parsedData.voipConfig.sessionId
493
+ _info('callinfo_initialized', {
494
+ sessionId: this.sessionId,
495
+ sttHandling: this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING]
496
+ })
471
497
  }
472
498
 
473
499
  // if sessionId is not the same as the one in the callinfo, return
@@ -485,10 +511,16 @@ class BotiumConnectorVoip {
485
511
  }
486
512
 
487
513
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'connected') {
514
+ _info('callinfo_connected', { sessionId: this.sessionId })
488
515
  resolve()
489
516
  }
490
517
 
491
518
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'disconnected') {
519
+ _info('callinfo_disconnected', {
520
+ sessionId: this.sessionId,
521
+ connectDurationSec: parsedData.connectDuration || null,
522
+ sttPartialCount: this.sttPartialCount
523
+ })
492
524
  const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY))
493
525
  if (parsedData.connectDuration && parsedData.connectDuration > 0) {
494
526
  this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
@@ -519,7 +551,9 @@ class BotiumConnectorVoip {
519
551
  if (parsedData && parsedData.type === 'fullRecordEnd') {
520
552
  const tail = _extractFullRecordBase64(parsedData)
521
553
  if (tail) this.fullRecord = (this.fullRecord || '') + tail
554
+ const base64Len = (this.fullRecord || tail || '').length
522
555
  emitFullRecordAttachment(this.fullRecord || tail)
556
+ _info('recording_attached', { sessionId: this.sessionId, source: 'fullRecordEnd', base64Len })
523
557
  this.end = true
524
558
  }
525
559
 
@@ -534,11 +568,14 @@ class BotiumConnectorVoip {
534
568
 
535
569
  if (parsedData && parsedData.type === 'fullRecord') {
536
570
  // Non-chunked full record
537
- emitFullRecordAttachment(_extractFullRecordBase64(parsedData))
571
+ const base64 = _extractFullRecordBase64(parsedData)
572
+ emitFullRecordAttachment(base64)
573
+ _info('recording_attached', { sessionId: this.sessionId, source: 'fullRecord', base64Len: (base64 || '').length })
538
574
  this.end = true
539
575
  }
540
576
 
541
577
  if (parsedData && parsedData.data && parsedData.data.final === false) {
578
+ this.sttPartialCount++
542
579
  if (this.silenceTimeout) {
543
580
  clearTimeout(this.silenceTimeout)
544
581
  }
@@ -586,7 +623,18 @@ class BotiumConnectorVoip {
586
623
  if (parsedData && parsedData.data && parsedData.data.type === 'stt' && parsedData.data.final) {
587
624
  const confidenceThreshold = ((this._getConfidenceScoreLogicHook(this.convoStep) && this._getConfidenceScoreLogicHook(this.convoStep).args[0]) || this.caps[Capabilities.VOIP_STT_CONFIDENCE_THRESHOLD])
588
625
  const successfulConfidenceScore = this._getConfidenceScore(parsedData) >= confidenceThreshold
626
+ const msgText = parsedData.data.message || ''
627
+ const msgLen = typeof msgText === 'string' ? msgText.length : 0
628
+ const msgPreview = typeof msgText === 'string' ? msgText.trim().substring(0, 80) : ''
589
629
  debug(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`)
630
+ _info('stt_final', {
631
+ sessionId: this.sessionId,
632
+ message: msgPreview,
633
+ messageLength: msgLen,
634
+ confidence: this._getConfidenceScore(parsedData),
635
+ threshold: confidenceThreshold,
636
+ accepted: successfulConfidenceScore
637
+ })
590
638
  // ORIGINAL: emit final message immediately, unless join hooks are active.
591
639
  if (
592
640
  (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && (_.isNil(this._getJoinLogicHook(this.convoStep))))
@@ -675,6 +723,19 @@ class BotiumConnectorVoip {
675
723
 
676
724
  async UserSays (msg) {
677
725
  debug('UserSays called')
726
+ const hasText = !!(msg && msg.messageText)
727
+ const hasVoiceMedia = !!(msg && msg.media && msg.media.length > 0 && msg.media[0].buffer)
728
+ const hasDtmf = !!(msg && msg.buttons && msg.buttons.length > 0)
729
+ const dtmfMatch = msg && msg.messageText && msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i)
730
+ const inputType = hasDtmf || dtmfMatch ? 'dtmf' : (hasText && hasVoiceMedia ? 'mixed' : hasText ? 'text' : hasVoiceMedia ? 'media' : 'unknown')
731
+ const msgPreview = hasText && msg.messageText ? String(msg.messageText).trim().substring(0, 80) : ''
732
+ _info('user_says', {
733
+ sessionId: this.sessionId,
734
+ inputType,
735
+ message: msgPreview || undefined,
736
+ messageLength: hasText && msg.messageText ? msg.messageText.length : undefined,
737
+ mediaSize: hasVoiceMedia && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined
738
+ })
678
739
  // Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
679
740
  try {
680
741
  const safeLog = {
@@ -700,6 +761,11 @@ class BotiumConnectorVoip {
700
761
  return new Promise((resolve, reject) => {
701
762
  setTimeout(async () => {
702
763
  let duration = 0
764
+ const preferVoiceCapRaw = this.caps[Capabilities.VOIP_USER_INPUT_PREFER_VOICE]
765
+ const preferVoice = !!preferVoiceCapRaw
766
+ const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia
767
+ debug(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`)
768
+
703
769
  if (msg && msg.buttons && msg.buttons.length > 0) {
704
770
  const request = JSON.stringify({
705
771
  METHOD: 'sendDtmf',
@@ -721,56 +787,62 @@ class BotiumConnectorVoip {
721
787
  this.ws.send(request)
722
788
  return resolve()
723
789
  }
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})`)
790
+ if (!skipTtsForMixedInput) {
791
+ if (!this.axiosTtsParams) {
792
+ if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
793
+ return reject(new Error('TTS not configured, only audio input supported'))
761
794
  }
762
- msg.attachments.push({
763
- name: 'tts.wav',
764
- mimeType: 'audio/wav',
765
- base64: b64Buffer
766
- })
767
- this.ws.send(request)
768
795
  } else {
769
- return reject(new Error('TTS failed, response is empty'))
796
+ debug('UserSays routing: executing TTS branch')
797
+ const ttsRequest = this._buildTtsRequest(msg.messageText)
798
+ if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'))
799
+ msg.sourceData = ttsRequest
800
+
801
+ let ttsResult = null
802
+ try {
803
+ ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText)
804
+ } catch (err) {
805
+ return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`))
806
+ }
807
+ if (msg && msg.messageText && msg.messageText.length > 0) {
808
+ const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY))
809
+ this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
810
+ type: _.isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
811
+ metricName: 'consumption.e2e.voip.tts.characters',
812
+ transactions: msg.messageText.length,
813
+ apiKey
814
+ })
815
+ }
816
+ if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
817
+ duration = parseFloat(ttsResult.duration) || 0
818
+ const b64Buffer = ttsResult.buffer.toString('base64')
819
+ const request = JSON.stringify({
820
+ METHOD: 'sendAudio',
821
+ PESQ: false,
822
+ sessionId: this.sessionId,
823
+ b64_buffer: b64Buffer
824
+ })
825
+ if (this._lastBotSaysQueuedAt) {
826
+ const latencyMs = Date.now() - this._lastBotSaysQueuedAt
827
+ const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>'
828
+ debug(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`)
829
+ }
830
+ msg.attachments.push({
831
+ name: 'tts.wav',
832
+ mimeType: 'audio/wav',
833
+ base64: b64Buffer
834
+ })
835
+ this.ws.send(request)
836
+ } else {
837
+ return reject(new Error('TTS failed, response is empty'))
838
+ }
770
839
  }
840
+ } else {
841
+ debug('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled')
771
842
  }
772
843
  }
773
844
  if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
845
+ debug('UserSays routing: executing MEDIA branch')
774
846
  const request = JSON.stringify({
775
847
  METHOD: 'sendAudio',
776
848
  sessionId: this.sessionId,