botium-connector-voip 0.0.20 → 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.
@@ -8,7 +8,6 @@ var axios = require('axios');
8
8
  var http = require('http');
9
9
  var https = require('https');
10
10
  var debug$4 = require('debug');
11
- var path = require('path');
12
11
  var musicMetadata = require('music-metadata');
13
12
 
14
13
  function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
@@ -19,10 +18,21 @@ var axios__default = /*#__PURE__*/_interopDefaultLegacy(axios);
19
18
  var http__default = /*#__PURE__*/_interopDefaultLegacy(http);
20
19
  var https__default = /*#__PURE__*/_interopDefaultLegacy(https);
21
20
  var debug__default = /*#__PURE__*/_interopDefaultLegacy(debug$4);
22
- var path__default = /*#__PURE__*/_interopDefaultLegacy(path);
23
21
  var musicMetadata__default = /*#__PURE__*/_interopDefaultLegacy(musicMetadata);
24
22
 
25
23
  const debug$3 = debug__default["default"]('botium-connector-voip');
24
+
25
+ // Logging policy: info = rare, business-relevant lifecycle events (always visible).
26
+ // debug = high-frequency diagnostics (DEBUG=botium-connector-voip). warn = degraded but continuing.
27
+ // error = abort/failure. No secrets in info; STT text only as length or truncated in info.
28
+
29
+ const _info = (event, data) => {
30
+ const parts = Object.entries({
31
+ event,
32
+ ...data
33
+ }).filter(([, v]) => v != null && v !== '').map(([k, v]) => `${k}=${typeof v === 'string' && v.length > 80 ? `"${v.substring(0, 77)}..."` : JSON.stringify(v)}`);
34
+ console.info(`[botium-connector-voip] ${parts.join(' ')}`);
35
+ };
26
36
  const Capabilities = {
27
37
  VOIP_STT_URL_STREAM: 'VOIP_STT_URL_STREAM',
28
38
  VOIP_STT_PARAMS_STREAM: 'VOIP_STT_PARAMS_STREAM',
@@ -70,7 +80,8 @@ const Capabilities = {
70
80
  VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: 'VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE',
71
81
  VOIP_SILENCE_DURATION_TIMEOUT_START: 'VOIP_SILENCE_DURATION_TIMEOUT_START',
72
82
  VOIP_STT_CONFIDENCE_THRESHOLD: 'VOIP_STT_CONFIDENCE_THRESHOLD',
73
- VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER'
83
+ VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER',
84
+ VOIP_USER_INPUT_PREFER_VOICE: 'VOIP_USER_INPUT_PREFER_VOICE'
74
85
  };
75
86
  const Defaults = {
76
87
  VOIP_STT_METHOD: 'POST',
@@ -92,7 +103,8 @@ const Defaults = {
92
103
  VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: false,
93
104
  VOIP_STT_CONFIDENCE_THRESHOLD: 0.5,
94
105
  VOIP_USE_GLOBAL_VOIP_WORKER: false,
95
- VOIP_SIP_PROTOCOL: 'TCP'
106
+ VOIP_SIP_PROTOCOL: 'TCP',
107
+ VOIP_USER_INPUT_PREFER_VOICE: true
96
108
  };
97
109
  const TTS_HTTP_AGENT = new http__default["default"].Agent({
98
110
  keepAlive: true
@@ -179,12 +191,17 @@ class BotiumConnectorVoip {
179
191
  const botMsg = {};
180
192
  botMsg.messageText = botMsgs.map(m => m.messageText).join(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_DELIMITER] || '');
181
193
  botMsg.sourceData = botMsgs.map(m => m.sourceData);
182
- if (lodash__default["default"].isNil(joinLastPrevMsg)) {
183
- botMsg.sourceData[0].silenceDuration = botMsgs[0].sourceData.data.start;
184
- botMsg.sourceData[0].voiceDuration = botMsgs[botMsgs.length - 1].sourceData.data.end - botMsgs[0].sourceData.data.start;
194
+ const firstStart = lodash__default["default"].get(botMsgs, '[0].sourceData.data.start', null);
195
+ const lastEnd = lodash__default["default"].get(botMsgs, `[${botMsgs.length - 1}].sourceData.data.end`, null);
196
+ const prevEnd = lodash__default["default"].get(joinLastPrevMsg, 'sourceData.data.end', null);
197
+ // joinLastPrevMsg can be null (first joined message) OR incomplete (in case JOIN timeout fired before we got any final STT info).
198
+ // In those cases, fall back to "start-of-first-chunk" semantics (same as previous behaviour for nil joinLastPrevMsg).
199
+ if (lodash__default["default"].isFinite(firstStart) && lodash__default["default"].isFinite(lastEnd)) {
200
+ botMsg.sourceData[0].silenceDuration = lodash__default["default"].isFinite(prevEnd) ? firstStart - prevEnd : firstStart;
201
+ botMsg.sourceData[0].voiceDuration = lastEnd - firstStart;
185
202
  } else {
186
- botMsg.sourceData[0].silenceDuration = botMsgs[0].sourceData.data.start - joinLastPrevMsg.sourceData.data.end;
187
- botMsg.sourceData[0].voiceDuration = botMsgs[botMsgs.length - 1].sourceData.data.end - botMsgs[0].sourceData.data.start;
203
+ botMsg.sourceData[0].silenceDuration = lodash__default["default"].isFinite(firstStart) ? firstStart : null;
204
+ botMsg.sourceData[0].voiceDuration = lodash__default["default"].isFinite(firstStart) && lodash__default["default"].isFinite(lastEnd) ? lastEnd - firstStart : null;
188
205
  }
189
206
  return botMsg;
190
207
  };
@@ -210,15 +227,18 @@ class BotiumConnectorVoip {
210
227
  };
211
228
  let data = null;
212
229
  let headers = null;
213
- const connect = async retryIndex => {
230
+ let httpInitRetries = 0;
231
+ const connectHttp = async retryIndex => {
214
232
  retryIndex = retryIndex || 0;
215
233
  try {
234
+ 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');
235
+ const baseUrl = workerUrl.endsWith('/') ? workerUrl.slice(0, -1) : workerUrl;
216
236
  const res = await axios__default["default"]({
217
237
  method: 'post',
218
238
  data: {
219
239
  API_KEY: this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_APIKEY : this.caps[Capabilities.VOIP_WORKER_APIKEY]
220
240
  },
221
- url: new URL(path__default["default"].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()
241
+ url: `${baseUrl}/initCall`
222
242
  });
223
243
  if (res) {
224
244
  data = res.data;
@@ -229,17 +249,24 @@ class BotiumConnectorVoip {
229
249
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
230
250
  throw new Error(`Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`);
231
251
  }
252
+ httpInitRetries = retryIndex + 1;
232
253
  // Retry after the defined timeout
233
254
  await new Promise(resolve => setTimeout(resolve, this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]));
234
255
 
235
256
  // Retry the connection
236
- await connect(retryIndex + 1);
257
+ await connectHttp(retryIndex + 1);
237
258
  }
238
259
  };
239
- await connect();
260
+ await connectHttp();
261
+ if (httpInitRetries > 0) {
262
+ _info('connected_after_retries', {
263
+ phase: 'initCall',
264
+ retries: httpInitRetries
265
+ });
266
+ }
240
267
  return new Promise((resolve, reject) => {
241
268
  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}`;
242
- const connect = retryIndex => {
269
+ const connectWs = retryIndex => {
243
270
  retryIndex = retryIndex || 0;
244
271
  return new Promise((resolve, reject) => {
245
272
  if ('set-cookie' in headers) {
@@ -252,17 +279,23 @@ class BotiumConnectorVoip {
252
279
  this.ws = new ws__default["default"](wsEndpoint);
253
280
  }
254
281
  this.ws.on('open', () => {
255
- resolve();
282
+ resolve(retryIndex);
256
283
  });
257
284
  this.ws.on('error', () => {
258
285
  if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
259
286
  reject(new Error(`Websocket connection failed to ${wsEndpoint}`));
260
287
  }
261
- setTimeout(() => connect(retryIndex + 1).then(resolve).catch(reject), this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]);
288
+ setTimeout(() => connectWs(retryIndex + 1).then(resolve).catch(reject), this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]);
262
289
  });
263
290
  });
264
291
  };
265
- connect().then(() => {
292
+ connectWs().then(wsRetries => {
293
+ if (wsRetries > 0) {
294
+ _info('connected_after_retries', {
295
+ phase: 'websocket',
296
+ retries: wsRetries
297
+ });
298
+ }
266
299
  if (!lodash__default["default"].isArray(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
267
300
  if (lodash__default["default"].isEmpty(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
268
301
  this.caps[Capabilities.VOIP_ICE_STUN_SERVERS] = [];
@@ -304,6 +337,7 @@ class BotiumConnectorVoip {
304
337
  });
305
338
  this.silence = null;
306
339
  this.msgCount = 0;
340
+ this.sttPartialCount = 0;
307
341
  this.firstMsg = true;
308
342
  this.firstSttInfoReceived = false;
309
343
  this.silenceTimeout = null;
@@ -364,11 +398,94 @@ class BotiumConnectorVoip {
364
398
  });
365
399
  this.ws.on('message', async data => {
366
400
  const parsedData = JSON.parse(data);
401
+ // Allow fullRecord delivery even if Stop() was already called.
402
+ // Otherwise the recording can be dropped if the worker streams it late (common on hangup).
403
+ if (this.stopCalled) {
404
+ const allowedTypes = ['fullRecord', 'fullRecordStart', 'fullRecordChunk', 'fullRecordEnd', 'error'];
405
+ if (!parsedData || !parsedData.type || !allowedTypes.includes(parsedData.type)) {
406
+ debug$3(`${this.sessionId} - Stop already called, ignoring incoming message`);
407
+ return;
408
+ }
409
+ }
367
410
  const parsedDataLog = lodash__default["default"].cloneDeep(parsedData);
368
- parsedDataLog.fullRecord = '<full_record_buffer>';
411
+ // Sanitize all potential base64 fields for logging (don't dump huge buffers)
412
+ const sanitizeBase64Fields = (obj, prefix = '') => {
413
+ if (!obj || typeof obj !== 'object') return;
414
+ for (const key of Object.keys(obj)) {
415
+ const val = obj[key];
416
+ if (typeof val === 'string' && val.length > 500) {
417
+ obj[key] = `<base64:${val.length}chars>`;
418
+ } else if (val && typeof val === 'object' && !Array.isArray(val)) {
419
+ sanitizeBase64Fields(val, `${prefix}${key}.`);
420
+ }
421
+ }
422
+ };
423
+ sanitizeBase64Fields(parsedDataLog);
369
424
  debug$3(JSON.stringify(parsedDataLog, null, 2));
425
+ const _extractFullRecordBase64 = pd => {
426
+ if (!pd) return null;
427
+ // Different VOIP workers may put the payload in various fields - search all string fields
428
+ // First try known field names
429
+ 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];
430
+ for (const val of knownFields) {
431
+ if (typeof val === 'string' && val.length > 100) return val;
432
+ }
433
+ // Fallback: find any large string field (likely base64)
434
+ const findLargeString = (obj, depth = 0) => {
435
+ if (!obj || typeof obj !== 'object' || depth > 3) return null;
436
+ for (const key of Object.keys(obj)) {
437
+ const val = obj[key];
438
+ if (typeof val === 'string' && val.length > 1000) {
439
+ debug$3(`${this.sessionId} - Found base64 in field '${key}' (len=${val.length})`);
440
+ return val;
441
+ }
442
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
443
+ const found = findLargeString(val, depth + 1);
444
+ if (found) return found;
445
+ }
446
+ }
447
+ return null;
448
+ };
449
+ return findLargeString(pd);
450
+ };
451
+ const emitFullRecordAttachment = base64 => {
452
+ if (!base64 || typeof base64 !== 'string' || base64.length === 0) {
453
+ // Log available fields (length only) to help diagnose worker payload shape without dumping base64
454
+ try {
455
+ const candidates = {
456
+ fullRecord: typeof parsedData?.fullRecord === 'string' ? parsedData.fullRecord.length : null,
457
+ full_record: typeof parsedData?.full_record === 'string' ? parsedData.full_record.length : null,
458
+ data_fullRecord: typeof parsedData?.data?.fullRecord === 'string' ? parsedData.data.fullRecord.length : null,
459
+ data_full_record: typeof parsedData?.data?.full_record === 'string' ? parsedData.data.full_record.length : null,
460
+ data_b64_buffer: typeof parsedData?.data?.b64_buffer === 'string' ? parsedData.data.b64_buffer.length : null,
461
+ data_base64: typeof parsedData?.data?.base64 === 'string' ? parsedData.data.base64.length : null,
462
+ data_full_record_buffer: typeof parsedData?.data?.full_record_buffer === 'string' ? parsedData.data.full_record_buffer.length : null,
463
+ data_buffer: typeof parsedData?.data?.buffer === 'string' ? parsedData.data.buffer.length : null
464
+ };
465
+ debug$3(`${this.sessionId} - fullRecordEnd received but base64 is empty, skipping attachment emit. CandidateLengths=${JSON.stringify(candidates)}`);
466
+ } catch (err) {
467
+ debug$3(`${this.sessionId} - fullRecordEnd received but base64 is empty, skipping attachment emit (diag failed: ${err.message || err})`);
468
+ }
469
+ return;
470
+ }
471
+ debug$3(`${this.sessionId} - Emitting MESSAGE_ATTACHMENT full_record.wav (base64Len=${base64.length}, stopCalled=${!!this.stopCalled})`);
472
+ this.eventEmitter.emit('MESSAGE_ATTACHMENT', this.container, {
473
+ name: 'full_record.wav',
474
+ mimeType: 'audio/wav',
475
+ base64,
476
+ // Session context from capabilities for correlation
477
+ sessionContext: {
478
+ testSessionId: this.caps.VOIP_TEST_SESSION_ID || null,
479
+ testSessionJobId: this.caps.VOIP_TEST_SESSION_JOB_ID || null
480
+ }
481
+ });
482
+ };
370
483
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
371
484
  this.sessionId = parsedData.voipConfig.sessionId;
485
+ _info('callinfo_initialized', {
486
+ sessionId: this.sessionId,
487
+ sttHandling: this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING]
488
+ });
372
489
  }
373
490
 
374
491
  // if sessionId is not the same as the one in the callinfo, return
@@ -383,9 +500,17 @@ class BotiumConnectorVoip {
383
500
  reject(new Error('Error: Sip Registration failed'));
384
501
  }
385
502
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'connected') {
503
+ _info('callinfo_connected', {
504
+ sessionId: this.sessionId
505
+ });
386
506
  resolve();
387
507
  }
388
508
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'disconnected') {
509
+ _info('callinfo_disconnected', {
510
+ sessionId: this.sessionId,
511
+ connectDurationSec: parsedData.connectDuration || null,
512
+ sttPartialCount: this.sttPartialCount
513
+ });
389
514
  const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY));
390
515
  if (parsedData.connectDuration && parsedData.connectDuration > 0) {
391
516
  this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
@@ -402,6 +527,28 @@ class BotiumConnectorVoip {
402
527
  reject(new Error(`Error: ${parsedData.message}`));
403
528
  sendBotMsg(new Error(`Error: ${parsedData.message}`));
404
529
  }
530
+
531
+ // Full record streaming support:
532
+ // - some VOIP workers send the recording in chunks and an end marker
533
+ if (parsedData && parsedData.type === 'fullRecordStart') {
534
+ this.fullRecord = '';
535
+ }
536
+ if (parsedData && parsedData.type === 'fullRecordChunk') {
537
+ const chunk = _extractFullRecordBase64(parsedData);
538
+ if (chunk) this.fullRecord = (this.fullRecord || '') + chunk;
539
+ }
540
+ if (parsedData && parsedData.type === 'fullRecordEnd') {
541
+ const tail = _extractFullRecordBase64(parsedData);
542
+ if (tail) this.fullRecord = (this.fullRecord || '') + tail;
543
+ const base64Len = (this.fullRecord || tail || '').length;
544
+ emitFullRecordAttachment(this.fullRecord || tail);
545
+ _info('recording_attached', {
546
+ sessionId: this.sessionId,
547
+ source: 'fullRecordEnd',
548
+ base64Len
549
+ });
550
+ this.end = true;
551
+ }
405
552
  if (parsedData && parsedData.type === 'silence') {
406
553
  if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
407
554
  if (lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep)) && parsedData.data.silence.length > 0) {
@@ -411,14 +558,18 @@ class BotiumConnectorVoip {
411
558
  }
412
559
  }
413
560
  if (parsedData && parsedData.type === 'fullRecord') {
414
- this.eventEmitter.emit('MESSAGE_ATTACHMENT', this.container, {
415
- name: 'full_record.wav',
416
- mimeType: 'audio/wav',
417
- base64: parsedData.fullRecord
561
+ // Non-chunked full record
562
+ const base64 = _extractFullRecordBase64(parsedData);
563
+ emitFullRecordAttachment(base64);
564
+ _info('recording_attached', {
565
+ sessionId: this.sessionId,
566
+ source: 'fullRecord',
567
+ base64Len: (base64 || '').length
418
568
  });
419
569
  this.end = true;
420
570
  }
421
571
  if (parsedData && parsedData.data && parsedData.data.final === false) {
572
+ this.sttPartialCount++;
422
573
  if (this.silenceTimeout) {
423
574
  clearTimeout(this.silenceTimeout);
424
575
  }
@@ -429,7 +580,7 @@ class BotiumConnectorVoip {
429
580
  }
430
581
  }
431
582
  this.firstSttInfoReceived = true;
432
- if (this.prevData) {
583
+ if (this.prevData && this.prevData.data && !lodash__default["default"].isNil(this.prevData.data.end)) {
433
584
  if (!lodash__default["default"].isNil(parsedData.data.start)) {
434
585
  const silenceDuration = parsedData.data.start - this.prevData.data.end;
435
586
  const joinLogicHook = this._getJoinLogicHook(this.convoStep);
@@ -465,7 +616,18 @@ class BotiumConnectorVoip {
465
616
  if (parsedData && parsedData.data && parsedData.data.type === 'stt' && parsedData.data.final) {
466
617
  const confidenceThreshold = this._getConfidenceScoreLogicHook(this.convoStep) && this._getConfidenceScoreLogicHook(this.convoStep).args[0] || this.caps[Capabilities.VOIP_STT_CONFIDENCE_THRESHOLD];
467
618
  const successfulConfidenceScore = this._getConfidenceScore(parsedData) >= confidenceThreshold;
619
+ const msgText = parsedData.data.message || '';
620
+ const msgLen = typeof msgText === 'string' ? msgText.length : 0;
621
+ const msgPreview = typeof msgText === 'string' ? msgText.trim().substring(0, 80) : '';
468
622
  debug$3(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`);
623
+ _info('stt_final', {
624
+ sessionId: this.sessionId,
625
+ message: msgPreview,
626
+ messageLength: msgLen,
627
+ confidence: this._getConfidenceScore(parsedData),
628
+ threshold: confidenceThreshold,
629
+ accepted: successfulConfidenceScore
630
+ });
469
631
  // ORIGINAL: emit final message immediately, unless join hooks are active.
470
632
  if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep))) {
471
633
  let botMsg = {
@@ -481,7 +643,9 @@ class BotiumConnectorVoip {
481
643
  this.firstMsg = false;
482
644
  } else {
483
645
  const sourceData = parsedData;
484
- sourceData.silenceDuration = parsedData.data.start - this.prevData.data.end;
646
+ const start = lodash__default["default"].get(parsedData, 'data.start', null);
647
+ const prevEnd = lodash__default["default"].get(this.prevData, 'data.end', null);
648
+ sourceData.silenceDuration = lodash__default["default"].isFinite(start) && lodash__default["default"].isFinite(prevEnd) ? start - prevEnd : lodash__default["default"].isFinite(start) ? start : null;
485
649
  sourceData.voiceDuration = parsedData.data.end - parsedData.data.start;
486
650
  botMsg = Object.assign({}, botMsg, {
487
651
  sourceData
@@ -508,7 +672,9 @@ class BotiumConnectorVoip {
508
672
  this.firstMsg = false;
509
673
  } else {
510
674
  const sourceData = parsedData;
511
- sourceData.silenceDuration = parsedData.data.start - this.prevData.data.end;
675
+ const start = lodash__default["default"].get(parsedData, 'data.start', null);
676
+ const prevEnd = lodash__default["default"].get(this.prevData, 'data.end', null);
677
+ sourceData.silenceDuration = lodash__default["default"].isFinite(start) && lodash__default["default"].isFinite(prevEnd) ? start - prevEnd : lodash__default["default"].isFinite(start) ? start : null;
512
678
  sourceData.voiceDuration = parsedData.data.end - parsedData.data.start;
513
679
  botMsg = Object.assign({}, botMsg, {
514
680
  sourceData
@@ -562,13 +728,47 @@ class BotiumConnectorVoip {
562
728
  }
563
729
  async UserSays(msg) {
564
730
  debug$3('UserSays called');
565
- debug$3(msg);
731
+ const hasText = !!(msg && msg.messageText);
732
+ const hasVoiceMedia = !!(msg && msg.media && msg.media.length > 0 && msg.media[0].buffer);
733
+ const hasDtmf = !!(msg && msg.buttons && msg.buttons.length > 0);
734
+ const dtmfMatch = msg && msg.messageText && msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
735
+ const inputType = hasDtmf || dtmfMatch ? 'dtmf' : hasText && hasVoiceMedia ? 'mixed' : hasText ? 'text' : hasVoiceMedia ? 'media' : 'unknown';
736
+ const msgPreview = hasText && msg.messageText ? String(msg.messageText).trim().substring(0, 80) : '';
737
+ _info('user_says', {
738
+ sessionId: this.sessionId,
739
+ inputType,
740
+ message: msgPreview || undefined,
741
+ messageLength: hasText && msg.messageText ? msg.messageText.length : undefined,
742
+ mediaSize: hasVoiceMedia && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined
743
+ });
744
+ // Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
745
+ try {
746
+ const safeLog = {
747
+ messageText: msg && msg.messageText ? String(msg.messageText).substring(0, 200) : undefined,
748
+ buttons: msg && Array.isArray(msg.buttons) ? msg.buttons.length : 0,
749
+ hasMedia: !!(msg && msg.media && msg.media.length > 0),
750
+ mediaMimeType: msg && msg.media && msg.media[0] ? msg.media[0].mimeType : undefined,
751
+ mediaSize: msg && msg.media && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined,
752
+ attachments: msg && Array.isArray(msg.attachments) ? msg.attachments.map(a => ({
753
+ name: a && (a.name || a.title),
754
+ mimeType: a && a.mimeType,
755
+ base64Len: a && a.base64 ? a.base64.length : 0
756
+ })) : []
757
+ };
758
+ debug$3(safeLog);
759
+ } catch (err) {
760
+ debug$3(`UserSays: failed to build safe log: ${err.message || err}`);
761
+ }
566
762
  if (!msg.attachments) {
567
763
  msg.attachments = [];
568
764
  }
569
765
  return new Promise((resolve, reject) => {
570
766
  setTimeout(async () => {
571
767
  let duration = 0;
768
+ const preferVoiceCapRaw = this.caps[Capabilities.VOIP_USER_INPUT_PREFER_VOICE];
769
+ const preferVoice = !!preferVoiceCapRaw;
770
+ const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
771
+ debug$3(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`);
572
772
  if (msg && msg.buttons && msg.buttons.length > 0) {
573
773
  const request = JSON.stringify({
574
774
  METHOD: 'sendDtmf',
@@ -590,55 +790,61 @@ class BotiumConnectorVoip {
590
790
  this.ws.send(request);
591
791
  return resolve();
592
792
  }
593
- if (!this.axiosTtsParams) {
594
- if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
595
- return reject(new Error('TTS not configured, only audio input supported'));
596
- }
597
- } else {
598
- const ttsRequest = this._buildTtsRequest(msg.messageText);
599
- if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
600
- msg.sourceData = ttsRequest;
601
- let ttsResult = null;
602
- try {
603
- ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
604
- } catch (err) {
605
- return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
606
- }
607
- if (msg && msg.messageText && msg.messageText.length > 0) {
608
- const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
609
- this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
610
- type: lodash__default["default"].isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
611
- metricName: 'consumption.e2e.voip.tts.characters',
612
- transactions: msg.messageText.length,
613
- apiKey
614
- });
615
- }
616
- if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
617
- duration = parseFloat(ttsResult.duration) || 0;
618
- const b64Buffer = ttsResult.buffer.toString('base64');
619
- const request = JSON.stringify({
620
- METHOD: 'sendAudio',
621
- PESQ: false,
622
- sessionId: this.sessionId,
623
- b64_buffer: b64Buffer
624
- });
625
- if (this._lastBotSaysQueuedAt) {
626
- const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
627
- const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
628
- debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
793
+ if (!skipTtsForMixedInput) {
794
+ if (!this.axiosTtsParams) {
795
+ if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
796
+ return reject(new Error('TTS not configured, only audio input supported'));
629
797
  }
630
- msg.attachments.push({
631
- name: 'tts.wav',
632
- mimeType: 'audio/wav',
633
- base64: b64Buffer
634
- });
635
- this.ws.send(request);
636
798
  } else {
637
- return reject(new Error('TTS failed, response is empty'));
799
+ debug$3('UserSays routing: executing TTS branch');
800
+ const ttsRequest = this._buildTtsRequest(msg.messageText);
801
+ if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
802
+ msg.sourceData = ttsRequest;
803
+ let ttsResult = null;
804
+ try {
805
+ ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
806
+ } catch (err) {
807
+ return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
808
+ }
809
+ if (msg && msg.messageText && msg.messageText.length > 0) {
810
+ const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
811
+ this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
812
+ type: lodash__default["default"].isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
813
+ metricName: 'consumption.e2e.voip.tts.characters',
814
+ transactions: msg.messageText.length,
815
+ apiKey
816
+ });
817
+ }
818
+ if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
819
+ duration = parseFloat(ttsResult.duration) || 0;
820
+ const b64Buffer = ttsResult.buffer.toString('base64');
821
+ const request = JSON.stringify({
822
+ METHOD: 'sendAudio',
823
+ PESQ: false,
824
+ sessionId: this.sessionId,
825
+ b64_buffer: b64Buffer
826
+ });
827
+ if (this._lastBotSaysQueuedAt) {
828
+ const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
829
+ const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
830
+ debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
831
+ }
832
+ msg.attachments.push({
833
+ name: 'tts.wav',
834
+ mimeType: 'audio/wav',
835
+ base64: b64Buffer
836
+ });
837
+ this.ws.send(request);
838
+ } else {
839
+ return reject(new Error('TTS failed, response is empty'));
840
+ }
638
841
  }
842
+ } else {
843
+ debug$3('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled');
639
844
  }
640
845
  }
641
846
  if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
847
+ debug$3('UserSays routing: executing MEDIA branch');
642
848
  const request = JSON.stringify({
643
849
  METHOD: 'sendAudio',
644
850
  sessionId: this.sessionId,
@@ -673,6 +879,11 @@ class BotiumConnectorVoip {
673
879
  }
674
880
  async Stop() {
675
881
  debug$3(`${this.sessionId} - Stop called`);
882
+ this.stopCalled = true;
883
+ if (this.silenceTimeout) {
884
+ clearTimeout(this.silenceTimeout);
885
+ this.silenceTimeout = null;
886
+ }
676
887
  if (this.ws && this.ws.readyState !== ws__default["default"].CLOSED) {
677
888
  const request = JSON.stringify({
678
889
  METHOD: 'stopCall',
@@ -680,9 +891,12 @@ class BotiumConnectorVoip {
680
891
  });
681
892
  this.ws.send(request);
682
893
  await new Promise(resolve => {
683
- setTimeout(resolve, 100000);
684
- setInterval(() => {
894
+ const stopTimeout = setTimeout(resolve, 100000);
895
+ this._stopWaitInterval = setInterval(() => {
685
896
  if (this.end) {
897
+ clearTimeout(stopTimeout);
898
+ clearInterval(this._stopWaitInterval);
899
+ this._stopWaitInterval = null;
686
900
  this.wsOpened = false;
687
901
  this.ws = null;
688
902
  this.end = false;
@@ -949,6 +1163,12 @@ var botiumConnectorVoip = {
949
1163
  label: 'Speech Synthesis Profile',
950
1164
  type: 'speechsynthesisprofile',
951
1165
  required: true
1166
+ }, {
1167
+ name: 'VOIP_USER_INPUT_PREFER_VOICE',
1168
+ label: 'Prefer voice media when both text and voice present',
1169
+ type: 'boolean',
1170
+ required: false,
1171
+ advanced: true
952
1172
  }]
953
1173
  },
954
1174
  PluginLogicHooks: {