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.
@@ -4,7 +4,6 @@ import axios from 'axios';
4
4
  import http from 'http';
5
5
  import https from 'https';
6
6
  import debug$4 from 'debug';
7
- import path from 'path';
8
7
  import musicMetadata from 'music-metadata';
9
8
 
10
9
  const debug$3 = debug$4('botium-connector-voip');
@@ -164,12 +163,17 @@ class BotiumConnectorVoip {
164
163
  const botMsg = {};
165
164
  botMsg.messageText = botMsgs.map(m => m.messageText).join(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_DELIMITER] || '');
166
165
  botMsg.sourceData = botMsgs.map(m => m.sourceData);
167
- if (lodash.isNil(joinLastPrevMsg)) {
168
- botMsg.sourceData[0].silenceDuration = botMsgs[0].sourceData.data.start;
169
- botMsg.sourceData[0].voiceDuration = botMsgs[botMsgs.length - 1].sourceData.data.end - botMsgs[0].sourceData.data.start;
166
+ const firstStart = lodash.get(botMsgs, '[0].sourceData.data.start', null);
167
+ const lastEnd = lodash.get(botMsgs, `[${botMsgs.length - 1}].sourceData.data.end`, null);
168
+ const prevEnd = lodash.get(joinLastPrevMsg, 'sourceData.data.end', null);
169
+ // joinLastPrevMsg can be null (first joined message) OR incomplete (in case JOIN timeout fired before we got any final STT info).
170
+ // In those cases, fall back to "start-of-first-chunk" semantics (same as previous behaviour for nil joinLastPrevMsg).
171
+ if (lodash.isFinite(firstStart) && lodash.isFinite(lastEnd)) {
172
+ botMsg.sourceData[0].silenceDuration = lodash.isFinite(prevEnd) ? firstStart - prevEnd : firstStart;
173
+ botMsg.sourceData[0].voiceDuration = lastEnd - firstStart;
170
174
  } else {
171
- botMsg.sourceData[0].silenceDuration = botMsgs[0].sourceData.data.start - joinLastPrevMsg.sourceData.data.end;
172
- botMsg.sourceData[0].voiceDuration = botMsgs[botMsgs.length - 1].sourceData.data.end - botMsgs[0].sourceData.data.start;
175
+ botMsg.sourceData[0].silenceDuration = lodash.isFinite(firstStart) ? firstStart : null;
176
+ botMsg.sourceData[0].voiceDuration = lodash.isFinite(firstStart) && lodash.isFinite(lastEnd) ? lastEnd - firstStart : null;
173
177
  }
174
178
  return botMsg;
175
179
  };
@@ -198,12 +202,14 @@ class BotiumConnectorVoip {
198
202
  const connect = async retryIndex => {
199
203
  retryIndex = retryIndex || 0;
200
204
  try {
205
+ 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');
206
+ const baseUrl = workerUrl.endsWith('/') ? workerUrl.slice(0, -1) : workerUrl;
201
207
  const res = await axios({
202
208
  method: 'post',
203
209
  data: {
204
210
  API_KEY: this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_APIKEY : this.caps[Capabilities.VOIP_WORKER_APIKEY]
205
211
  },
206
- 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()
212
+ url: `${baseUrl}/initCall`
207
213
  });
208
214
  if (res) {
209
215
  data = res.data;
@@ -349,9 +355,88 @@ class BotiumConnectorVoip {
349
355
  });
350
356
  this.ws.on('message', async data => {
351
357
  const parsedData = JSON.parse(data);
358
+ // Allow fullRecord delivery even if Stop() was already called.
359
+ // Otherwise the recording can be dropped if the worker streams it late (common on hangup).
360
+ if (this.stopCalled) {
361
+ const allowedTypes = ['fullRecord', 'fullRecordStart', 'fullRecordChunk', 'fullRecordEnd', 'error'];
362
+ if (!parsedData || !parsedData.type || !allowedTypes.includes(parsedData.type)) {
363
+ debug$3(`${this.sessionId} - Stop already called, ignoring incoming message`);
364
+ return;
365
+ }
366
+ }
352
367
  const parsedDataLog = lodash.cloneDeep(parsedData);
353
- parsedDataLog.fullRecord = '<full_record_buffer>';
368
+ // Sanitize all potential base64 fields for logging (don't dump huge buffers)
369
+ const sanitizeBase64Fields = (obj, prefix = '') => {
370
+ if (!obj || typeof obj !== 'object') return;
371
+ for (const key of Object.keys(obj)) {
372
+ const val = obj[key];
373
+ if (typeof val === 'string' && val.length > 500) {
374
+ obj[key] = `<base64:${val.length}chars>`;
375
+ } else if (val && typeof val === 'object' && !Array.isArray(val)) {
376
+ sanitizeBase64Fields(val, `${prefix}${key}.`);
377
+ }
378
+ }
379
+ };
380
+ sanitizeBase64Fields(parsedDataLog);
354
381
  debug$3(JSON.stringify(parsedDataLog, null, 2));
382
+ const _extractFullRecordBase64 = pd => {
383
+ if (!pd) return null;
384
+ // Different VOIP workers may put the payload in various fields - search all string fields
385
+ // First try known field names
386
+ const knownFields = [pd.fullRecord, pd.full_record, pd.data?.fullRecord, pd.data?.full_record, pd.data?.b64_buffer, pd.data?.base64, pd.data?.full_record_buffer, pd.data?.buffer, pd.buffer, pd.base64, pd.audio, pd.audioData, pd.data?.audio, pd.data?.audioData];
387
+ for (const val of knownFields) {
388
+ if (typeof val === 'string' && val.length > 100) return val;
389
+ }
390
+ // Fallback: find any large string field (likely base64)
391
+ const findLargeString = (obj, depth = 0) => {
392
+ if (!obj || typeof obj !== 'object' || depth > 3) return null;
393
+ for (const key of Object.keys(obj)) {
394
+ const val = obj[key];
395
+ if (typeof val === 'string' && val.length > 1000) {
396
+ debug$3(`${this.sessionId} - Found base64 in field '${key}' (len=${val.length})`);
397
+ return val;
398
+ }
399
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
400
+ const found = findLargeString(val, depth + 1);
401
+ if (found) return found;
402
+ }
403
+ }
404
+ return null;
405
+ };
406
+ return findLargeString(pd);
407
+ };
408
+ const emitFullRecordAttachment = base64 => {
409
+ if (!base64 || typeof base64 !== 'string' || base64.length === 0) {
410
+ // Log available fields (length only) to help diagnose worker payload shape without dumping base64
411
+ try {
412
+ const candidates = {
413
+ fullRecord: typeof parsedData?.fullRecord === 'string' ? parsedData.fullRecord.length : null,
414
+ full_record: typeof parsedData?.full_record === 'string' ? parsedData.full_record.length : null,
415
+ data_fullRecord: typeof parsedData?.data?.fullRecord === 'string' ? parsedData.data.fullRecord.length : null,
416
+ data_full_record: typeof parsedData?.data?.full_record === 'string' ? parsedData.data.full_record.length : null,
417
+ data_b64_buffer: typeof parsedData?.data?.b64_buffer === 'string' ? parsedData.data.b64_buffer.length : null,
418
+ data_base64: typeof parsedData?.data?.base64 === 'string' ? parsedData.data.base64.length : null,
419
+ data_full_record_buffer: typeof parsedData?.data?.full_record_buffer === 'string' ? parsedData.data.full_record_buffer.length : null,
420
+ data_buffer: typeof parsedData?.data?.buffer === 'string' ? parsedData.data.buffer.length : null
421
+ };
422
+ debug$3(`${this.sessionId} - fullRecordEnd received but base64 is empty, skipping attachment emit. CandidateLengths=${JSON.stringify(candidates)}`);
423
+ } catch (err) {
424
+ debug$3(`${this.sessionId} - fullRecordEnd received but base64 is empty, skipping attachment emit (diag failed: ${err.message || err})`);
425
+ }
426
+ return;
427
+ }
428
+ debug$3(`${this.sessionId} - Emitting MESSAGE_ATTACHMENT full_record.wav (base64Len=${base64.length}, stopCalled=${!!this.stopCalled})`);
429
+ this.eventEmitter.emit('MESSAGE_ATTACHMENT', this.container, {
430
+ name: 'full_record.wav',
431
+ mimeType: 'audio/wav',
432
+ base64,
433
+ // Session context from capabilities for correlation
434
+ sessionContext: {
435
+ testSessionId: this.caps.VOIP_TEST_SESSION_ID || null,
436
+ testSessionJobId: this.caps.VOIP_TEST_SESSION_JOB_ID || null
437
+ }
438
+ });
439
+ };
355
440
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
356
441
  this.sessionId = parsedData.voipConfig.sessionId;
357
442
  }
@@ -387,6 +472,22 @@ class BotiumConnectorVoip {
387
472
  reject(new Error(`Error: ${parsedData.message}`));
388
473
  sendBotMsg(new Error(`Error: ${parsedData.message}`));
389
474
  }
475
+
476
+ // Full record streaming support:
477
+ // - some VOIP workers send the recording in chunks and an end marker
478
+ if (parsedData && parsedData.type === 'fullRecordStart') {
479
+ this.fullRecord = '';
480
+ }
481
+ if (parsedData && parsedData.type === 'fullRecordChunk') {
482
+ const chunk = _extractFullRecordBase64(parsedData);
483
+ if (chunk) this.fullRecord = (this.fullRecord || '') + chunk;
484
+ }
485
+ if (parsedData && parsedData.type === 'fullRecordEnd') {
486
+ const tail = _extractFullRecordBase64(parsedData);
487
+ if (tail) this.fullRecord = (this.fullRecord || '') + tail;
488
+ emitFullRecordAttachment(this.fullRecord || tail);
489
+ this.end = true;
490
+ }
390
491
  if (parsedData && parsedData.type === 'silence') {
391
492
  if (lodash.isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
392
493
  if (lodash.isNil(this._getJoinLogicHook(this.convoStep)) && parsedData.data.silence.length > 0) {
@@ -396,11 +497,8 @@ class BotiumConnectorVoip {
396
497
  }
397
498
  }
398
499
  if (parsedData && parsedData.type === 'fullRecord') {
399
- this.eventEmitter.emit('MESSAGE_ATTACHMENT', this.container, {
400
- name: 'full_record.wav',
401
- mimeType: 'audio/wav',
402
- base64: parsedData.fullRecord
403
- });
500
+ // Non-chunked full record
501
+ emitFullRecordAttachment(_extractFullRecordBase64(parsedData));
404
502
  this.end = true;
405
503
  }
406
504
  if (parsedData && parsedData.data && parsedData.data.final === false) {
@@ -414,7 +512,7 @@ class BotiumConnectorVoip {
414
512
  }
415
513
  }
416
514
  this.firstSttInfoReceived = true;
417
- if (this.prevData) {
515
+ if (this.prevData && this.prevData.data && !lodash.isNil(this.prevData.data.end)) {
418
516
  if (!lodash.isNil(parsedData.data.start)) {
419
517
  const silenceDuration = parsedData.data.start - this.prevData.data.end;
420
518
  const joinLogicHook = this._getJoinLogicHook(this.convoStep);
@@ -466,7 +564,9 @@ class BotiumConnectorVoip {
466
564
  this.firstMsg = false;
467
565
  } else {
468
566
  const sourceData = parsedData;
469
- sourceData.silenceDuration = parsedData.data.start - this.prevData.data.end;
567
+ const start = lodash.get(parsedData, 'data.start', null);
568
+ const prevEnd = lodash.get(this.prevData, 'data.end', null);
569
+ sourceData.silenceDuration = lodash.isFinite(start) && lodash.isFinite(prevEnd) ? start - prevEnd : lodash.isFinite(start) ? start : null;
470
570
  sourceData.voiceDuration = parsedData.data.end - parsedData.data.start;
471
571
  botMsg = Object.assign({}, botMsg, {
472
572
  sourceData
@@ -493,7 +593,9 @@ class BotiumConnectorVoip {
493
593
  this.firstMsg = false;
494
594
  } else {
495
595
  const sourceData = parsedData;
496
- sourceData.silenceDuration = parsedData.data.start - this.prevData.data.end;
596
+ const start = lodash.get(parsedData, 'data.start', null);
597
+ const prevEnd = lodash.get(this.prevData, 'data.end', null);
598
+ sourceData.silenceDuration = lodash.isFinite(start) && lodash.isFinite(prevEnd) ? start - prevEnd : lodash.isFinite(start) ? start : null;
497
599
  sourceData.voiceDuration = parsedData.data.end - parsedData.data.start;
498
600
  botMsg = Object.assign({}, botMsg, {
499
601
  sourceData
@@ -547,7 +649,24 @@ class BotiumConnectorVoip {
547
649
  }
548
650
  async UserSays(msg) {
549
651
  debug$3('UserSays called');
550
- debug$3(msg);
652
+ // Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
653
+ try {
654
+ const safeLog = {
655
+ messageText: msg && msg.messageText ? String(msg.messageText).substring(0, 200) : undefined,
656
+ buttons: msg && Array.isArray(msg.buttons) ? msg.buttons.length : 0,
657
+ hasMedia: !!(msg && msg.media && msg.media.length > 0),
658
+ mediaMimeType: msg && msg.media && msg.media[0] ? msg.media[0].mimeType : undefined,
659
+ mediaSize: msg && msg.media && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined,
660
+ attachments: msg && Array.isArray(msg.attachments) ? msg.attachments.map(a => ({
661
+ name: a && (a.name || a.title),
662
+ mimeType: a && a.mimeType,
663
+ base64Len: a && a.base64 ? a.base64.length : 0
664
+ })) : []
665
+ };
666
+ debug$3(safeLog);
667
+ } catch (err) {
668
+ debug$3(`UserSays: failed to build safe log: ${err.message || err}`);
669
+ }
551
670
  if (!msg.attachments) {
552
671
  msg.attachments = [];
553
672
  }
@@ -658,6 +777,11 @@ class BotiumConnectorVoip {
658
777
  }
659
778
  async Stop() {
660
779
  debug$3(`${this.sessionId} - Stop called`);
780
+ this.stopCalled = true;
781
+ if (this.silenceTimeout) {
782
+ clearTimeout(this.silenceTimeout);
783
+ this.silenceTimeout = null;
784
+ }
661
785
  if (this.ws && this.ws.readyState !== ws.CLOSED) {
662
786
  const request = JSON.stringify({
663
787
  METHOD: 'stopCall',
@@ -665,9 +789,12 @@ class BotiumConnectorVoip {
665
789
  });
666
790
  this.ws.send(request);
667
791
  await new Promise(resolve => {
668
- setTimeout(resolve, 100000);
669
- setInterval(() => {
792
+ const stopTimeout = setTimeout(resolve, 100000);
793
+ this._stopWaitInterval = setInterval(() => {
670
794
  if (this.end) {
795
+ clearTimeout(stopTimeout);
796
+ clearInterval(this._stopWaitInterval);
797
+ this._stopWaitInterval = null;
671
798
  this.wsOpened = false;
672
799
  this.ws = null;
673
800
  this.end = false;