botium-connector-voip 0.0.19 → 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.
@@ -1,8 +1,9 @@
1
1
  import ws from 'ws';
2
2
  import lodash from 'lodash';
3
3
  import axios from 'axios';
4
+ import http from 'http';
5
+ import https from 'https';
4
6
  import debug$4 from 'debug';
5
- import path from 'path';
6
7
  import musicMetadata from 'music-metadata';
7
8
 
8
9
  const debug$3 = debug$4('botium-connector-voip');
@@ -24,6 +25,9 @@ const Capabilities = {
24
25
  VOIP_TTS_BODY: 'VOIP_TTS_BODY',
25
26
  VOIP_TTS_HEADERS: 'VOIP_TTS_HEADERS',
26
27
  VOIP_TTS_TIMEOUT: 'VOIP_TTS_TIMEOUT',
28
+ VOIP_TTS_CACHE_ENABLE: 'VOIP_TTS_CACHE_ENABLE',
29
+ VOIP_TTS_CACHE_SIZE: 'VOIP_TTS_CACHE_SIZE',
30
+ VOIP_TTS_PREFETCH_ENABLE: 'VOIP_TTS_PREFETCH_ENABLE',
27
31
  VOIP_WORKER_URL: 'VOIP_WORKER_URL',
28
32
  VOIP_WORKER_APIKEY: 'VOIP_WORKER_APIKEY',
29
33
  VOIP_SIP_POOL_CALLER_ENABLE: 'VOIP_SIP_POOL_CALLER_ENABLE',
@@ -57,6 +61,9 @@ const Defaults = {
57
61
  VOIP_STT_TIMEOUT: 10000,
58
62
  VOIP_TTS_METHOD: 'GET',
59
63
  VOIP_TTS_TIMEOUT: 10000,
64
+ VOIP_TTS_CACHE_ENABLE: true,
65
+ VOIP_TTS_CACHE_SIZE: 50,
66
+ VOIP_TTS_PREFETCH_ENABLE: true,
60
67
  VOIP_STT_MESSAGE_HANDLING: 'ORIGINAL',
61
68
  VOIP_STT_MESSAGE_HANDLING_TIMEOUT: 2500,
62
69
  VOIP_STT_MESSAGE_HANDLING_DELIMITER: '. ',
@@ -71,6 +78,12 @@ const Defaults = {
71
78
  VOIP_USE_GLOBAL_VOIP_WORKER: false,
72
79
  VOIP_SIP_PROTOCOL: 'TCP'
73
80
  };
81
+ const TTS_HTTP_AGENT = new http.Agent({
82
+ keepAlive: true
83
+ });
84
+ const TTS_HTTPS_AGENT = new https.Agent({
85
+ keepAlive: true
86
+ });
74
87
  class BotiumConnectorVoip {
75
88
  constructor({
76
89
  queueBotSays,
@@ -84,9 +97,18 @@ class BotiumConnectorVoip {
84
97
  this.sentencesBuilding = 0;
85
98
  this.sentencesFinal = 0;
86
99
  this.sentenceBuilding = false;
100
+ this.ttsCache = new Map();
101
+ this.ttsCacheEnabled = false;
102
+ this.ttsCacheMaxEntries = 0;
103
+ this.ttsPrefetchEnabled = false;
104
+
105
+ // For debugging latency between incoming STT (bot says) and outgoing audio (sendAudio)
106
+ this._lastBotSaysQueuedAt = null;
107
+ this._lastBotSaysText = null;
87
108
  }
88
109
  async Validate() {
89
110
  debug$3('Validate called');
111
+ this.caps = Object.assign({}, Defaults, this.caps);
90
112
  debug$3(this.caps.VOIP_STT_MESSAGE_HANDLING);
91
113
  if (this.caps.VOIP_TTS_URL) {
92
114
  this.axiosTtsParams = {
@@ -94,7 +116,9 @@ class BotiumConnectorVoip {
94
116
  params: this._getParams(Capabilities.VOIP_TTS_PARAMS),
95
117
  method: this.caps.VOIP_TTS_METHOD,
96
118
  timeout: this.caps.VOIP_TTS_TIMEOUT,
97
- headers: this._getHeaders(Capabilities.VOIP_TTS_HEADERS)
119
+ headers: this._getHeaders(Capabilities.VOIP_TTS_HEADERS),
120
+ httpAgent: TTS_HTTP_AGENT,
121
+ httpsAgent: TTS_HTTPS_AGENT
98
122
  };
99
123
  try {
100
124
  const {
@@ -112,7 +136,10 @@ class BotiumConnectorVoip {
112
136
  throw new Error(`Checking TTS Status failed - ${this._getAxiosErrOutput(err)}`);
113
137
  }
114
138
  }
115
- this.caps = Object.assign({}, Defaults, this.caps);
139
+ const cacheSize = parseInt(this.caps[Capabilities.VOIP_TTS_CACHE_SIZE], 10);
140
+ this.ttsCacheEnabled = !!this.caps[Capabilities.VOIP_TTS_CACHE_ENABLE];
141
+ this.ttsCacheMaxEntries = lodash.isFinite(cacheSize) && cacheSize > 0 ? cacheSize : 0;
142
+ this.ttsPrefetchEnabled = !!this.caps[Capabilities.VOIP_TTS_PREFETCH_ENABLE];
116
143
  }
117
144
  async Start() {
118
145
  debug$3('Start called');
@@ -122,19 +149,31 @@ class BotiumConnectorVoip {
122
149
  this.end = false;
123
150
  this.connected = false;
124
151
  this.convoStep = null;
152
+ this._lastBotSaysQueuedAt = null;
153
+ this._lastBotSaysText = null;
154
+ if (this.ttsCache) {
155
+ this.ttsCache.clear();
156
+ }
125
157
  const sendBotMsg = botMsg => {
158
+ this._lastBotSaysQueuedAt = Date.now();
159
+ this._lastBotSaysText = botMsg && botMsg.messageText ? String(botMsg.messageText) : null;
126
160
  setTimeout(() => this.queueBotSays(botMsg), 0);
127
161
  };
128
162
  const joinBotMsg = (botMsgs, joinLastPrevMsg) => {
129
163
  const botMsg = {};
130
164
  botMsg.messageText = botMsgs.map(m => m.messageText).join(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_DELIMITER] || '');
131
165
  botMsg.sourceData = botMsgs.map(m => m.sourceData);
132
- if (lodash.isNil(joinLastPrevMsg)) {
133
- botMsg.sourceData[0].silenceDuration = botMsgs[0].sourceData.data.start;
134
- 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;
135
174
  } else {
136
- botMsg.sourceData[0].silenceDuration = botMsgs[0].sourceData.data.start - joinLastPrevMsg.sourceData.data.end;
137
- 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;
138
177
  }
139
178
  return botMsg;
140
179
  };
@@ -163,12 +202,14 @@ class BotiumConnectorVoip {
163
202
  const connect = async retryIndex => {
164
203
  retryIndex = retryIndex || 0;
165
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;
166
207
  const res = await axios({
167
208
  method: 'post',
168
209
  data: {
169
210
  API_KEY: this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_APIKEY : this.caps[Capabilities.VOIP_WORKER_APIKEY]
170
211
  },
171
- 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`
172
213
  });
173
214
  if (res) {
174
215
  data = res.data;
@@ -222,6 +263,35 @@ class BotiumConnectorVoip {
222
263
  }
223
264
  this.eventEmitter.on('CONVO_STEP_NEXT', (container, convoStep) => {
224
265
  this.convoStep = convoStep;
266
+ this._maybePrefetchTts(convoStep);
267
+ // For PSST: send join silence duration per step to VOIP worker (controls PSST silence trigger)
268
+ try {
269
+ if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'PSST' && this.ws && this.ws.readyState === ws.OPEN) {
270
+ const joinLogicHook = this._getJoinLogicHook(convoStep);
271
+ let silenceMs = null;
272
+ if (joinLogicHook && joinLogicHook.args && joinLogicHook.args.length > 0) {
273
+ silenceMs = parseInt(joinLogicHook.args[0], 10);
274
+ }
275
+ // Fallback to global timeout if no per-step hook is set
276
+ if (!lodash.isFinite(silenceMs) || silenceMs <= 0) {
277
+ silenceMs = parseInt(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT], 10);
278
+ }
279
+ // PSST: treat like JOIN, but subtract 500ms from timeout
280
+ if (lodash.isFinite(silenceMs) && silenceMs > 0) {
281
+ silenceMs = Math.max(0, silenceMs - 500);
282
+ }
283
+ if (lodash.isFinite(silenceMs) && silenceMs > 0 && this.sessionId) {
284
+ debug$3(`PSST: sending silenceDurationMs=${silenceMs} for sessionId=${this.sessionId}`);
285
+ this.ws.send(JSON.stringify({
286
+ METHOD: 'setSttSilenceDuration',
287
+ sessionId: this.sessionId,
288
+ silenceDurationMs: silenceMs
289
+ }));
290
+ }
291
+ }
292
+ } catch (err) {
293
+ debug$3(`Failed sending PSST silence duration to VOIP worker: ${err.message || err}`);
294
+ }
225
295
  });
226
296
  this.silence = null;
227
297
  this.msgCount = 0;
@@ -230,6 +300,18 @@ class BotiumConnectorVoip {
230
300
  this.silenceTimeout = null;
231
301
  this.wsOpened = true;
232
302
  debug$3(`Websocket connection to ${wsEndpoint} opened.`);
303
+ const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
304
+ const isPsst = sttHandling === 'PSST';
305
+ const sttLegacy = true;
306
+ let sttUrl = this.caps[Capabilities.VOIP_STT_URL_STREAM];
307
+ if (isPsst && lodash.isString(sttUrl)) {
308
+ const replaced = sttUrl.replace('/api/sttstream/', '/api/stt/');
309
+ if (replaced !== sttUrl) {
310
+ sttUrl = replaced;
311
+ } else {
312
+ debug$3(`PSST: Could not derive /api/stt url from ${sttUrl}, using as-is`);
313
+ }
314
+ }
233
315
  const request = {
234
316
  METHOD: 'initCall',
235
317
  SIP_CALLER_AUTO: this.caps[Capabilities.VOIP_SIP_POOL_CALLER_ENABLE],
@@ -250,8 +332,9 @@ class BotiumConnectorVoip {
250
332
  ICE_TURN_PASSWORD: this.caps[Capabilities.VOIP_ICE_TURN_PASSWORD],
251
333
  ICE_TURN_PROTOCOL: this.caps[Capabilities.VOIP_ICE_TURN_PROTOCOL] || 'TCP',
252
334
  MIN_SILENCE_DURATION: this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT_ENABLE] ? this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT] : null,
335
+ STT_LEGACY: sttLegacy,
253
336
  STT_CONFIG: {
254
- stt_url: this.caps[Capabilities.VOIP_STT_URL_STREAM],
337
+ stt_url: sttUrl,
255
338
  stt_params: this.caps[Capabilities.VOIP_STT_PARAMS_STREAM],
256
339
  stt_body: this.caps[Capabilities.VOIP_STT_BODY_STREAM] || null
257
340
  },
@@ -272,9 +355,88 @@ class BotiumConnectorVoip {
272
355
  });
273
356
  this.ws.on('message', async data => {
274
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
+ }
275
367
  const parsedDataLog = lodash.cloneDeep(parsedData);
276
- 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);
277
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
+ };
278
440
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
279
441
  this.sessionId = parsedData.voipConfig.sessionId;
280
442
  }
@@ -310,6 +472,22 @@ class BotiumConnectorVoip {
310
472
  reject(new Error(`Error: ${parsedData.message}`));
311
473
  sendBotMsg(new Error(`Error: ${parsedData.message}`));
312
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
+ }
313
491
  if (parsedData && parsedData.type === 'silence') {
314
492
  if (lodash.isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
315
493
  if (lodash.isNil(this._getJoinLogicHook(this.convoStep)) && parsedData.data.silence.length > 0) {
@@ -319,11 +497,8 @@ class BotiumConnectorVoip {
319
497
  }
320
498
  }
321
499
  if (parsedData && parsedData.type === 'fullRecord') {
322
- this.eventEmitter.emit('MESSAGE_ATTACHMENT', this.container, {
323
- name: 'full_record.wav',
324
- mimeType: 'audio/wav',
325
- base64: parsedData.fullRecord
326
- });
500
+ // Non-chunked full record
501
+ emitFullRecordAttachment(_extractFullRecordBase64(parsedData));
327
502
  this.end = true;
328
503
  }
329
504
  if (parsedData && parsedData.data && parsedData.data.final === false) {
@@ -337,16 +512,29 @@ class BotiumConnectorVoip {
337
512
  }
338
513
  }
339
514
  this.firstSttInfoReceived = true;
340
- if (this.prevData) {
515
+ if (this.prevData && this.prevData.data && !lodash.isNil(this.prevData.data.end)) {
341
516
  if (!lodash.isNil(parsedData.data.start)) {
342
517
  const silenceDuration = parsedData.data.start - this.prevData.data.end;
343
518
  const joinLogicHook = this._getJoinLogicHook(this.convoStep);
344
- const isJoinMethod = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'JOIN';
519
+ const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
520
+ const isPsst = sttHandling === 'PSST';
521
+ const isJoinMethod = sttHandling === 'JOIN' || isPsst;
522
+ const toJoinTimeoutMs = ms => {
523
+ const parsed = parseInt(ms, 10);
524
+ if (!lodash.isFinite(parsed) || parsed <= 0) return null;
525
+ return isPsst ? Math.max(0, parsed - 500) : parsed;
526
+ };
345
527
  let matched = false;
346
- if ((!lodash.isNil(joinLogicHook) && isJoinMethod && silenceDuration > parseInt(joinLogicHook.args[0])) / 1000) {
347
- matched = true;
348
- } else if (lodash.isNil(joinLogicHook) && isJoinMethod && silenceDuration > parseInt(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]) / 1000) {
349
- matched = true;
528
+ if (!lodash.isNil(joinLogicHook) && isJoinMethod) {
529
+ const joinTimeoutMs = toJoinTimeoutMs(joinLogicHook && joinLogicHook.args && joinLogicHook.args[0]);
530
+ if (lodash.isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > joinTimeoutMs / 1000) {
531
+ matched = true;
532
+ }
533
+ } else if (lodash.isNil(joinLogicHook) && isJoinMethod) {
534
+ const joinTimeoutMs = toJoinTimeoutMs(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]);
535
+ if (lodash.isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > joinTimeoutMs / 1000) {
536
+ matched = true;
537
+ }
350
538
  }
351
539
  if (matched && this.botMsgs.length > 0) {
352
540
  sendBotMsg(joinBotMsg(this.botMsgs, this.joinLastPrevMsg));
@@ -361,6 +549,7 @@ class BotiumConnectorVoip {
361
549
  const confidenceThreshold = this._getConfidenceScoreLogicHook(this.convoStep) && this._getConfidenceScoreLogicHook(this.convoStep).args[0] || this.caps[Capabilities.VOIP_STT_CONFIDENCE_THRESHOLD];
362
550
  const successfulConfidenceScore = this._getConfidenceScore(parsedData) >= confidenceThreshold;
363
551
  debug$3(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`);
552
+ // ORIGINAL: emit final message immediately, unless join hooks are active.
364
553
  if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && lodash.isNil(this._getJoinLogicHook(this.convoStep))) {
365
554
  let botMsg = {
366
555
  messageText: parsedData.data.message
@@ -375,7 +564,9 @@ class BotiumConnectorVoip {
375
564
  this.firstMsg = false;
376
565
  } else {
377
566
  const sourceData = parsedData;
378
- 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;
379
570
  sourceData.voiceDuration = parsedData.data.end - parsedData.data.start;
380
571
  botMsg = Object.assign({}, botMsg, {
381
572
  sourceData
@@ -402,7 +593,9 @@ class BotiumConnectorVoip {
402
593
  this.firstMsg = false;
403
594
  } else {
404
595
  const sourceData = parsedData;
405
- 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;
406
599
  sourceData.voiceDuration = parsedData.data.end - parsedData.data.start;
407
600
  botMsg = Object.assign({}, botMsg, {
408
601
  sourceData
@@ -416,7 +609,7 @@ class BotiumConnectorVoip {
416
609
  }
417
610
  this.botMsgs = [];
418
611
  }
419
- if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'JOIN' || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'CONCAT' || !lodash.isNil(this._getJoinLogicHook(this.convoStep))) {
612
+ if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'JOIN' || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'PSST' || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'CONCAT' || !lodash.isNil(this._getJoinLogicHook(this.convoStep))) {
420
613
  const botMsg = {
421
614
  messageText: parsedData.data.message,
422
615
  sourceData: parsedData
@@ -424,15 +617,27 @@ class BotiumConnectorVoip {
424
617
  this.prevData = parsedData;
425
618
  if (successfulConfidenceScore) {
426
619
  this.botMsgs.push(botMsg);
620
+ const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
621
+ const isPsst = sttHandling === 'PSST';
622
+ const toJoinTimeoutMs = ms => {
623
+ const parsed = parseInt(ms, 10);
624
+ if (!lodash.isFinite(parsed) || parsed <= 0) return null;
625
+ return isPsst ? Math.max(0, parsed - 500) : parsed;
626
+ };
627
+ const joinLogicHook = this._getJoinLogicHook(this.convoStep);
628
+ let joinTimeoutMs = toJoinTimeoutMs(lodash.get(joinLogicHook, 'args[0]'));
629
+ if (!lodash.isFinite(joinTimeoutMs) || joinTimeoutMs <= 0) {
630
+ joinTimeoutMs = toJoinTimeoutMs(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]);
631
+ }
427
632
  this.silenceTimeout = setTimeout(() => {
428
633
  if (this.botMsgs.length > 0) {
429
- debug$3('Silence Duration Timeout (PSST):', this._getJoinLogicHook(this.convoStep) && parseInt(this._getJoinLogicHook(this.convoStep).args[0]) || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT], 'ms');
634
+ debug$3('Silence Duration Timeout (JOIN/PSST):', joinTimeoutMs, 'ms');
430
635
  sendBotMsg(joinBotMsg(this.botMsgs, this.joinLastPrevMsg));
431
636
  this.firstMsg = false;
432
637
  this.joinLastPrevMsg = this.botMsgs[this.botMsgs.length - 1];
433
638
  this.botMsgs = [];
434
639
  }
435
- }, this._getJoinLogicHook(this.convoStep) && parseInt(this._getJoinLogicHook(this.convoStep).args[0]) || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]);
640
+ }, joinTimeoutMs || 0);
436
641
  }
437
642
  }
438
643
  }
@@ -444,7 +649,24 @@ class BotiumConnectorVoip {
444
649
  }
445
650
  async UserSays(msg) {
446
651
  debug$3('UserSays called');
447
- 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
+ }
448
670
  if (!msg.attachments) {
449
671
  msg.attachments = [];
450
672
  }
@@ -459,23 +681,32 @@ class BotiumConnectorVoip {
459
681
  });
460
682
  this.ws.send(request);
461
683
  } else if (msg && msg.messageText) {
462
- if (!this.axiosTtsParams) reject(new Error('TTS not configured, only audio input supported'));
463
- if (this.axiosTtsParams) {
464
- const ttsRequest = {
465
- ...this.axiosTtsParams,
466
- params: {
467
- ...(this.axiosTtsParams.params || {}),
468
- text: msg.messageText
469
- },
470
- data: this._getBody(Capabilities.VOIP_TTS_BODY),
471
- responseType: 'arraybuffer'
472
- };
684
+ // Check for DTMF tag in messageText: <DTMF>1234</DTMF>
685
+ const dtmfMatch = msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
686
+ if (dtmfMatch && dtmfMatch[1]) {
687
+ const digits = dtmfMatch[1];
688
+ debug$3(`Sending DTMF from messageText: ${digits}`);
689
+ const request = JSON.stringify({
690
+ METHOD: 'sendDtmf',
691
+ digits,
692
+ sessionId: this.sessionId
693
+ });
694
+ this.ws.send(request);
695
+ return resolve();
696
+ }
697
+ if (!this.axiosTtsParams) {
698
+ if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
699
+ return reject(new Error('TTS not configured, only audio input supported'));
700
+ }
701
+ } else {
702
+ const ttsRequest = this._buildTtsRequest(msg.messageText);
703
+ if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
473
704
  msg.sourceData = ttsRequest;
474
- let ttsResponse = null;
705
+ let ttsResult = null;
475
706
  try {
476
- ttsResponse = await axios(ttsRequest);
707
+ ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
477
708
  } catch (err) {
478
- reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
709
+ return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
479
710
  }
480
711
  if (msg && msg.messageText && msg.messageText.length > 0) {
481
712
  const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
@@ -486,22 +717,28 @@ class BotiumConnectorVoip {
486
717
  apiKey
487
718
  });
488
719
  }
489
- if (Buffer.isBuffer(ttsResponse.data)) {
490
- duration = ttsResponse.headers['content-duration'];
720
+ if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
721
+ duration = parseFloat(ttsResult.duration) || 0;
722
+ const b64Buffer = ttsResult.buffer.toString('base64');
491
723
  const request = JSON.stringify({
492
724
  METHOD: 'sendAudio',
493
725
  PESQ: false,
494
726
  sessionId: this.sessionId,
495
- b64_buffer: ttsResponse.data.toString('base64')
727
+ b64_buffer: b64Buffer
496
728
  });
729
+ if (this._lastBotSaysQueuedAt) {
730
+ const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
731
+ const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
732
+ debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
733
+ }
497
734
  msg.attachments.push({
498
735
  name: 'tts.wav',
499
736
  mimeType: 'audio/wav',
500
- base64: ttsResponse.data.toString('base64')
737
+ base64: b64Buffer
501
738
  });
502
739
  this.ws.send(request);
503
740
  } else {
504
- reject(new Error(`TTS failed, response is: ${this._getAxiosShortenedOutput(ttsResponse.data)}`));
741
+ return reject(new Error('TTS failed, response is empty'));
505
742
  }
506
743
  }
507
744
  }
@@ -511,6 +748,11 @@ class BotiumConnectorVoip {
511
748
  sessionId: this.sessionId,
512
749
  b64_buffer: msg.media[0].buffer.toString('base64')
513
750
  });
751
+ if (this._lastBotSaysQueuedAt) {
752
+ const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
753
+ const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
754
+ debug$3(`Latency (bot says -> sendAudio/MEDIA): ${latencyMs} ms (last bot: ${botText})`);
755
+ }
514
756
  this.ws.send(request);
515
757
  msg.attachments.push({
516
758
  name: msg.media[0].mediaUri,
@@ -535,6 +777,11 @@ class BotiumConnectorVoip {
535
777
  }
536
778
  async Stop() {
537
779
  debug$3(`${this.sessionId} - Stop called`);
780
+ this.stopCalled = true;
781
+ if (this.silenceTimeout) {
782
+ clearTimeout(this.silenceTimeout);
783
+ this.silenceTimeout = null;
784
+ }
538
785
  if (this.ws && this.ws.readyState !== ws.CLOSED) {
539
786
  const request = JSON.stringify({
540
787
  METHOD: 'stopCall',
@@ -542,9 +789,12 @@ class BotiumConnectorVoip {
542
789
  });
543
790
  this.ws.send(request);
544
791
  await new Promise(resolve => {
545
- setTimeout(resolve, 100000);
546
- setInterval(() => {
792
+ const stopTimeout = setTimeout(resolve, 100000);
793
+ this._stopWaitInterval = setInterval(() => {
547
794
  if (this.end) {
795
+ clearTimeout(stopTimeout);
796
+ clearInterval(this._stopWaitInterval);
797
+ this._stopWaitInterval = null;
548
798
  this.wsOpened = false;
549
799
  this.ws = null;
550
800
  this.end = false;
@@ -562,6 +812,126 @@ class BotiumConnectorVoip {
562
812
  this.firstSttInfoReceived = false;
563
813
  }
564
814
  }
815
+ _isTtsCacheEnabled() {
816
+ return this.ttsCacheEnabled && this.ttsCacheMaxEntries > 0;
817
+ }
818
+ _touchTtsCache(cacheKey, entry) {
819
+ if (!this._isTtsCacheEnabled()) return;
820
+ this.ttsCache.delete(cacheKey);
821
+ this.ttsCache.set(cacheKey, entry);
822
+ }
823
+ _setTtsCacheEntry(cacheKey, entry) {
824
+ if (!this._isTtsCacheEnabled()) return;
825
+ this.ttsCache.set(cacheKey, entry);
826
+ this._trimTtsCache();
827
+ }
828
+ _deleteTtsCacheEntry(cacheKey) {
829
+ if (this.ttsCache) {
830
+ this.ttsCache.delete(cacheKey);
831
+ }
832
+ }
833
+ _trimTtsCache() {
834
+ if (!this._isTtsCacheEnabled()) return;
835
+ while (this.ttsCache.size > this.ttsCacheMaxEntries) {
836
+ const oldestKey = this.ttsCache.keys().next().value;
837
+ this.ttsCache.delete(oldestKey);
838
+ }
839
+ }
840
+ _shouldPrefetchTtsText(text) {
841
+ if (!lodash.isString(text)) return false;
842
+ if (!text.trim()) return false;
843
+ return !/\$[A-Za-z]\w*/.test(text);
844
+ }
845
+ _maybePrefetchTts(convoStep) {
846
+ if (!this.ttsPrefetchEnabled || !this._isTtsCacheEnabled()) return;
847
+ if (!convoStep || convoStep.sender !== 'me') return;
848
+ if (!this._shouldPrefetchTtsText(convoStep.messageText)) return;
849
+ const ttsRequest = this._buildTtsRequest(convoStep.messageText);
850
+ if (!ttsRequest) return;
851
+ this._getTtsAudio(ttsRequest, convoStep.messageText).catch(err => {
852
+ debug$3(`TTS prefetch failed - ${this._getAxiosErrOutput(err)}`);
853
+ });
854
+ }
855
+ _buildTtsRequest(text) {
856
+ if (!this.axiosTtsParams) return null;
857
+ return {
858
+ ...this.axiosTtsParams,
859
+ params: {
860
+ ...(this.axiosTtsParams.params || {}),
861
+ text
862
+ },
863
+ data: this._getBody(Capabilities.VOIP_TTS_BODY),
864
+ responseType: 'arraybuffer'
865
+ };
866
+ }
867
+ _getTtsCacheKey(ttsRequest) {
868
+ const cacheKeyObj = {
869
+ url: ttsRequest.url,
870
+ method: ttsRequest.method,
871
+ params: ttsRequest.params,
872
+ data: ttsRequest.data
873
+ };
874
+ try {
875
+ return JSON.stringify(cacheKeyObj);
876
+ } catch (err) {
877
+ return `${ttsRequest.url}|${ttsRequest.method}|${lodash.get(ttsRequest, 'params.text', '')}`;
878
+ }
879
+ }
880
+ async _fetchTts(ttsRequest, text) {
881
+ const ttsStart = Date.now();
882
+ const ttsResponse = await axios(ttsRequest);
883
+ const ttsMs = Date.now() - ttsStart;
884
+ const textLen = lodash.isString(text) ? text.length : 0;
885
+ debug$3(`TTS response ${ttsMs} ms (chars: ${textLen})`);
886
+ if (!ttsResponse || !Buffer.isBuffer(ttsResponse.data)) {
887
+ throw new Error(`TTS failed, response is: ${this._getAxiosShortenedOutput(ttsResponse && ttsResponse.data)}`);
888
+ }
889
+ const durationHeader = ttsResponse.headers && ttsResponse.headers['content-duration'];
890
+ return {
891
+ buffer: ttsResponse.data,
892
+ duration: durationHeader
893
+ };
894
+ }
895
+ async _getTtsAudio(ttsRequest, text) {
896
+ if (!ttsRequest) throw new Error('TTS request not configured');
897
+ const cacheKey = this._getTtsCacheKey(ttsRequest);
898
+ if (this._isTtsCacheEnabled()) {
899
+ const cached = this.ttsCache.get(cacheKey);
900
+ if (cached) {
901
+ if (cached.state === 'ready') {
902
+ this._touchTtsCache(cacheKey, cached);
903
+ debug$3(`TTS cache hit (chars: ${lodash.isString(text) ? text.length : 0})`);
904
+ return {
905
+ buffer: cached.buffer,
906
+ duration: cached.duration
907
+ };
908
+ }
909
+ if (cached.state === 'pending') {
910
+ return cached.promise;
911
+ }
912
+ }
913
+ }
914
+ const fetchPromise = this._fetchTts(ttsRequest, text).then(result => {
915
+ if (this._isTtsCacheEnabled()) {
916
+ this._setTtsCacheEntry(cacheKey, {
917
+ state: 'ready',
918
+ buffer: result.buffer,
919
+ duration: result.duration
920
+ });
921
+ }
922
+ return result;
923
+ }).catch(err => {
924
+ this._deleteTtsCacheEntry(cacheKey);
925
+ throw err;
926
+ });
927
+ if (this._isTtsCacheEnabled()) {
928
+ this._setTtsCacheEntry(cacheKey, {
929
+ state: 'pending',
930
+ promise: fetchPromise
931
+ });
932
+ }
933
+ return fetchPromise;
934
+ }
565
935
  _getParams(capParams) {
566
936
  if (this.caps[capParams]) {
567
937
  if (lodash.isString(this.caps[capParams])) return JSON.parse(this.caps[capParams]);else return this.caps[capParams];