botium-connector-voip 0.0.20 → 0.0.21

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
@@ -4,7 +4,6 @@ const axios = require('axios')
4
4
  const http = require('http')
5
5
  const https = require('https')
6
6
  const debug = require('debug')('botium-connector-voip')
7
- const path = require('path')
8
7
  const mm = require('music-metadata')
9
8
 
10
9
  const Capabilities = {
@@ -163,12 +162,17 @@ class BotiumConnectorVoip {
163
162
  const botMsg = {}
164
163
  botMsg.messageText = botMsgs.map(m => m.messageText).join(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_DELIMITER] || '')
165
164
  botMsg.sourceData = botMsgs.map(m => m.sourceData)
166
- if (_.isNil(joinLastPrevMsg)) {
167
- botMsg.sourceData[0].silenceDuration = botMsgs[0].sourceData.data.start
168
- botMsg.sourceData[0].voiceDuration = botMsgs[botMsgs.length - 1].sourceData.data.end - botMsgs[0].sourceData.data.start
165
+ const firstStart = _.get(botMsgs, '[0].sourceData.data.start', null)
166
+ const lastEnd = _.get(botMsgs, `[${botMsgs.length - 1}].sourceData.data.end`, null)
167
+ const prevEnd = _.get(joinLastPrevMsg, 'sourceData.data.end', null)
168
+ // joinLastPrevMsg can be null (first joined message) OR incomplete (in case JOIN timeout fired before we got any final STT info).
169
+ // In those cases, fall back to "start-of-first-chunk" semantics (same as previous behaviour for nil joinLastPrevMsg).
170
+ if (_.isFinite(firstStart) && _.isFinite(lastEnd)) {
171
+ botMsg.sourceData[0].silenceDuration = (_.isFinite(prevEnd) ? (firstStart - prevEnd) : firstStart)
172
+ botMsg.sourceData[0].voiceDuration = lastEnd - firstStart
169
173
  } else {
170
- botMsg.sourceData[0].silenceDuration = botMsgs[0].sourceData.data.start - joinLastPrevMsg.sourceData.data.end
171
- botMsg.sourceData[0].voiceDuration = botMsgs[botMsgs.length - 1].sourceData.data.end - botMsgs[0].sourceData.data.start
174
+ botMsg.sourceData[0].silenceDuration = _.isFinite(firstStart) ? firstStart : null
175
+ botMsg.sourceData[0].voiceDuration = (_.isFinite(firstStart) && _.isFinite(lastEnd)) ? (lastEnd - firstStart) : null
172
176
  }
173
177
  return botMsg
174
178
  }
@@ -199,12 +203,16 @@ class BotiumConnectorVoip {
199
203
  const connect = async (retryIndex) => {
200
204
  retryIndex = retryIndex || 0
201
205
  try {
206
+ const workerUrl = this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER]
207
+ ? process.env.BOTIUM_VOIP_WORKER_URL
208
+ : this.caps[Capabilities.VOIP_WORKER_URL].replace('wss', 'https').replace('ws', 'http')
209
+ const baseUrl = workerUrl.endsWith('/') ? workerUrl.slice(0, -1) : workerUrl
202
210
  const res = await axios({
203
211
  method: 'post',
204
212
  data: {
205
213
  API_KEY: this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_APIKEY : this.caps[Capabilities.VOIP_WORKER_APIKEY]
206
214
  },
207
- url: new URL(path.join(`${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')}`, 'initCall')).toString()
215
+ url: `${baseUrl}/initCall`
208
216
  })
209
217
  if (res) {
210
218
  data = res.data
@@ -356,12 +364,108 @@ class BotiumConnectorVoip {
356
364
  })
357
365
  this.ws.on('message', async (data) => {
358
366
  const parsedData = JSON.parse(data)
367
+ // Allow fullRecord delivery even if Stop() was already called.
368
+ // Otherwise the recording can be dropped if the worker streams it late (common on hangup).
369
+ if (this.stopCalled) {
370
+ const allowedTypes = ['fullRecord', 'fullRecordStart', 'fullRecordChunk', 'fullRecordEnd', 'error']
371
+ if (!parsedData || !parsedData.type || !allowedTypes.includes(parsedData.type)) {
372
+ debug(`${this.sessionId} - Stop already called, ignoring incoming message`)
373
+ return
374
+ }
375
+ }
359
376
 
360
377
  const parsedDataLog = _.cloneDeep(parsedData)
361
- parsedDataLog.fullRecord = '<full_record_buffer>'
378
+ // Sanitize all potential base64 fields for logging (don't dump huge buffers)
379
+ const sanitizeBase64Fields = (obj, prefix = '') => {
380
+ if (!obj || typeof obj !== 'object') return
381
+ for (const key of Object.keys(obj)) {
382
+ const val = obj[key]
383
+ if (typeof val === 'string' && val.length > 500) {
384
+ obj[key] = `<base64:${val.length}chars>`
385
+ } else if (val && typeof val === 'object' && !Array.isArray(val)) {
386
+ sanitizeBase64Fields(val, `${prefix}${key}.`)
387
+ }
388
+ }
389
+ }
390
+ sanitizeBase64Fields(parsedDataLog)
362
391
 
363
392
  debug(JSON.stringify(parsedDataLog, null, 2))
364
393
 
394
+ const _extractFullRecordBase64 = (pd) => {
395
+ if (!pd) return null
396
+ // Different VOIP workers may put the payload in various fields - search all string fields
397
+ // First try known field names
398
+ const knownFields = [
399
+ pd.fullRecord,
400
+ pd.full_record,
401
+ pd.data?.fullRecord,
402
+ pd.data?.full_record,
403
+ pd.data?.b64_buffer,
404
+ pd.data?.base64,
405
+ pd.data?.full_record_buffer,
406
+ pd.data?.buffer,
407
+ pd.buffer,
408
+ pd.base64,
409
+ pd.audio,
410
+ pd.audioData,
411
+ pd.data?.audio,
412
+ pd.data?.audioData
413
+ ]
414
+ for (const val of knownFields) {
415
+ if (typeof val === 'string' && val.length > 100) return val
416
+ }
417
+ // Fallback: find any large string field (likely base64)
418
+ const findLargeString = (obj, depth = 0) => {
419
+ if (!obj || typeof obj !== 'object' || depth > 3) return null
420
+ for (const key of Object.keys(obj)) {
421
+ const val = obj[key]
422
+ if (typeof val === 'string' && val.length > 1000) {
423
+ debug(`${this.sessionId} - Found base64 in field '${key}' (len=${val.length})`)
424
+ return val
425
+ }
426
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
427
+ const found = findLargeString(val, depth + 1)
428
+ if (found) return found
429
+ }
430
+ }
431
+ return null
432
+ }
433
+ return findLargeString(pd)
434
+ }
435
+
436
+ const emitFullRecordAttachment = (base64) => {
437
+ if (!base64 || typeof base64 !== 'string' || base64.length === 0) {
438
+ // Log available fields (length only) to help diagnose worker payload shape without dumping base64
439
+ try {
440
+ const candidates = {
441
+ fullRecord: typeof parsedData?.fullRecord === 'string' ? parsedData.fullRecord.length : null,
442
+ full_record: typeof parsedData?.full_record === 'string' ? parsedData.full_record.length : null,
443
+ data_fullRecord: typeof parsedData?.data?.fullRecord === 'string' ? parsedData.data.fullRecord.length : null,
444
+ data_full_record: typeof parsedData?.data?.full_record === 'string' ? parsedData.data.full_record.length : null,
445
+ data_b64_buffer: typeof parsedData?.data?.b64_buffer === 'string' ? parsedData.data.b64_buffer.length : null,
446
+ data_base64: typeof parsedData?.data?.base64 === 'string' ? parsedData.data.base64.length : null,
447
+ data_full_record_buffer: typeof parsedData?.data?.full_record_buffer === 'string' ? parsedData.data.full_record_buffer.length : null,
448
+ data_buffer: typeof parsedData?.data?.buffer === 'string' ? parsedData.data.buffer.length : null
449
+ }
450
+ debug(`${this.sessionId} - fullRecordEnd received but base64 is empty, skipping attachment emit. CandidateLengths=${JSON.stringify(candidates)}`)
451
+ } catch (err) {
452
+ debug(`${this.sessionId} - fullRecordEnd received but base64 is empty, skipping attachment emit (diag failed: ${err.message || err})`)
453
+ }
454
+ return
455
+ }
456
+ debug(`${this.sessionId} - Emitting MESSAGE_ATTACHMENT full_record.wav (base64Len=${base64.length}, stopCalled=${!!this.stopCalled})`)
457
+ this.eventEmitter.emit('MESSAGE_ATTACHMENT', this.container, {
458
+ name: 'full_record.wav',
459
+ mimeType: 'audio/wav',
460
+ base64,
461
+ // Session context from capabilities for correlation
462
+ sessionContext: {
463
+ testSessionId: this.caps.VOIP_TEST_SESSION_ID || null,
464
+ testSessionJobId: this.caps.VOIP_TEST_SESSION_JOB_ID || null
465
+ }
466
+ })
467
+ }
468
+
365
469
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
366
470
  this.sessionId = parsedData.voipConfig.sessionId
367
471
  }
@@ -403,6 +507,22 @@ class BotiumConnectorVoip {
403
507
  sendBotMsg(new Error(`Error: ${parsedData.message}`))
404
508
  }
405
509
 
510
+ // Full record streaming support:
511
+ // - some VOIP workers send the recording in chunks and an end marker
512
+ if (parsedData && parsedData.type === 'fullRecordStart') {
513
+ this.fullRecord = ''
514
+ }
515
+ if (parsedData && parsedData.type === 'fullRecordChunk') {
516
+ const chunk = _extractFullRecordBase64(parsedData)
517
+ if (chunk) this.fullRecord = (this.fullRecord || '') + chunk
518
+ }
519
+ if (parsedData && parsedData.type === 'fullRecordEnd') {
520
+ const tail = _extractFullRecordBase64(parsedData)
521
+ if (tail) this.fullRecord = (this.fullRecord || '') + tail
522
+ emitFullRecordAttachment(this.fullRecord || tail)
523
+ this.end = true
524
+ }
525
+
406
526
  if (parsedData && parsedData.type === 'silence') {
407
527
  if (_.isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
408
528
  if (_.isNil(this._getJoinLogicHook(this.convoStep)) && parsedData.data.silence.length > 0) {
@@ -413,11 +533,8 @@ class BotiumConnectorVoip {
413
533
  }
414
534
 
415
535
  if (parsedData && parsedData.type === 'fullRecord') {
416
- this.eventEmitter.emit('MESSAGE_ATTACHMENT', this.container, {
417
- name: 'full_record.wav',
418
- mimeType: 'audio/wav',
419
- base64: parsedData.fullRecord
420
- })
536
+ // Non-chunked full record
537
+ emitFullRecordAttachment(_extractFullRecordBase64(parsedData))
421
538
  this.end = true
422
539
  }
423
540
 
@@ -432,7 +549,7 @@ class BotiumConnectorVoip {
432
549
  }
433
550
  }
434
551
  this.firstSttInfoReceived = true
435
- if (this.prevData) {
552
+ if (this.prevData && this.prevData.data && !_.isNil(this.prevData.data.end)) {
436
553
  if (!_.isNil(parsedData.data.start)) {
437
554
  const silenceDuration = parsedData.data.start - this.prevData.data.end
438
555
  const joinLogicHook = this._getJoinLogicHook(this.convoStep)
@@ -483,7 +600,9 @@ class BotiumConnectorVoip {
483
600
  this.firstMsg = false
484
601
  } else {
485
602
  const sourceData = parsedData
486
- sourceData.silenceDuration = parsedData.data.start - this.prevData.data.end
603
+ const start = _.get(parsedData, 'data.start', null)
604
+ const prevEnd = _.get(this.prevData, 'data.end', null)
605
+ sourceData.silenceDuration = (_.isFinite(start) && _.isFinite(prevEnd)) ? (start - prevEnd) : (_.isFinite(start) ? start : null)
487
606
  sourceData.voiceDuration = parsedData.data.end - parsedData.data.start
488
607
  botMsg = Object.assign({}, botMsg, { sourceData })
489
608
  }
@@ -504,7 +623,9 @@ class BotiumConnectorVoip {
504
623
  this.firstMsg = false
505
624
  } else {
506
625
  const sourceData = parsedData
507
- sourceData.silenceDuration = parsedData.data.start - this.prevData.data.end
626
+ const start = _.get(parsedData, 'data.start', null)
627
+ const prevEnd = _.get(this.prevData, 'data.end', null)
628
+ sourceData.silenceDuration = (_.isFinite(start) && _.isFinite(prevEnd)) ? (start - prevEnd) : (_.isFinite(start) ? start : null)
508
629
  sourceData.voiceDuration = parsedData.data.end - parsedData.data.start
509
630
  botMsg = Object.assign({}, botMsg, { sourceData })
510
631
  }
@@ -554,8 +675,24 @@ class BotiumConnectorVoip {
554
675
 
555
676
  async UserSays (msg) {
556
677
  debug('UserSays called')
557
-
558
- debug(msg)
678
+ // Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
679
+ try {
680
+ const safeLog = {
681
+ messageText: msg && msg.messageText ? String(msg.messageText).substring(0, 200) : undefined,
682
+ buttons: msg && Array.isArray(msg.buttons) ? msg.buttons.length : 0,
683
+ hasMedia: !!(msg && msg.media && msg.media.length > 0),
684
+ mediaMimeType: msg && msg.media && msg.media[0] ? msg.media[0].mimeType : undefined,
685
+ mediaSize: msg && msg.media && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined,
686
+ attachments: msg && Array.isArray(msg.attachments) ? msg.attachments.map(a => ({
687
+ name: a && (a.name || a.title),
688
+ mimeType: a && a.mimeType,
689
+ base64Len: a && a.base64 ? a.base64.length : 0
690
+ })) : []
691
+ }
692
+ debug(safeLog)
693
+ } catch (err) {
694
+ debug(`UserSays: failed to build safe log: ${err.message || err}`)
695
+ }
559
696
 
560
697
  if (!msg.attachments) {
561
698
  msg.attachments = []
@@ -670,6 +807,11 @@ class BotiumConnectorVoip {
670
807
 
671
808
  async Stop () {
672
809
  debug(`${this.sessionId} - Stop called`)
810
+ this.stopCalled = true
811
+ if (this.silenceTimeout) {
812
+ clearTimeout(this.silenceTimeout)
813
+ this.silenceTimeout = null
814
+ }
673
815
  if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
674
816
  const request = JSON.stringify({
675
817
  METHOD: 'stopCall',
@@ -677,9 +819,12 @@ class BotiumConnectorVoip {
677
819
  })
678
820
  this.ws.send(request)
679
821
  await new Promise(resolve => {
680
- setTimeout(resolve, 100000)
681
- setInterval(() => {
822
+ const stopTimeout = setTimeout(resolve, 100000)
823
+ this._stopWaitInterval = setInterval(() => {
682
824
  if (this.end) {
825
+ clearTimeout(stopTimeout)
826
+ clearInterval(this._stopWaitInterval)
827
+ this._stopWaitInterval = null
683
828
  this.wsOpened = false
684
829
  this.ws = null
685
830
  this.end = false