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.
@@ -5,8 +5,9 @@ Object.defineProperty(exports, '__esModule', { value: true });
5
5
  var ws = require('ws');
6
6
  var lodash = require('lodash');
7
7
  var axios = require('axios');
8
+ var http = require('http');
9
+ var https = require('https');
8
10
  var debug$4 = require('debug');
9
- var path = require('path');
10
11
  var musicMetadata = require('music-metadata');
11
12
 
12
13
  function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
@@ -14,8 +15,9 @@ function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'defau
14
15
  var ws__default = /*#__PURE__*/_interopDefaultLegacy(ws);
15
16
  var lodash__default = /*#__PURE__*/_interopDefaultLegacy(lodash);
16
17
  var axios__default = /*#__PURE__*/_interopDefaultLegacy(axios);
18
+ var http__default = /*#__PURE__*/_interopDefaultLegacy(http);
19
+ var https__default = /*#__PURE__*/_interopDefaultLegacy(https);
17
20
  var debug__default = /*#__PURE__*/_interopDefaultLegacy(debug$4);
18
- var path__default = /*#__PURE__*/_interopDefaultLegacy(path);
19
21
  var musicMetadata__default = /*#__PURE__*/_interopDefaultLegacy(musicMetadata);
20
22
 
21
23
  const debug$3 = debug__default["default"]('botium-connector-voip');
@@ -37,6 +39,9 @@ const Capabilities = {
37
39
  VOIP_TTS_BODY: 'VOIP_TTS_BODY',
38
40
  VOIP_TTS_HEADERS: 'VOIP_TTS_HEADERS',
39
41
  VOIP_TTS_TIMEOUT: 'VOIP_TTS_TIMEOUT',
42
+ VOIP_TTS_CACHE_ENABLE: 'VOIP_TTS_CACHE_ENABLE',
43
+ VOIP_TTS_CACHE_SIZE: 'VOIP_TTS_CACHE_SIZE',
44
+ VOIP_TTS_PREFETCH_ENABLE: 'VOIP_TTS_PREFETCH_ENABLE',
40
45
  VOIP_WORKER_URL: 'VOIP_WORKER_URL',
41
46
  VOIP_WORKER_APIKEY: 'VOIP_WORKER_APIKEY',
42
47
  VOIP_SIP_POOL_CALLER_ENABLE: 'VOIP_SIP_POOL_CALLER_ENABLE',
@@ -70,6 +75,9 @@ const Defaults = {
70
75
  VOIP_STT_TIMEOUT: 10000,
71
76
  VOIP_TTS_METHOD: 'GET',
72
77
  VOIP_TTS_TIMEOUT: 10000,
78
+ VOIP_TTS_CACHE_ENABLE: true,
79
+ VOIP_TTS_CACHE_SIZE: 50,
80
+ VOIP_TTS_PREFETCH_ENABLE: true,
73
81
  VOIP_STT_MESSAGE_HANDLING: 'ORIGINAL',
74
82
  VOIP_STT_MESSAGE_HANDLING_TIMEOUT: 2500,
75
83
  VOIP_STT_MESSAGE_HANDLING_DELIMITER: '. ',
@@ -84,6 +92,12 @@ const Defaults = {
84
92
  VOIP_USE_GLOBAL_VOIP_WORKER: false,
85
93
  VOIP_SIP_PROTOCOL: 'TCP'
86
94
  };
95
+ const TTS_HTTP_AGENT = new http__default["default"].Agent({
96
+ keepAlive: true
97
+ });
98
+ const TTS_HTTPS_AGENT = new https__default["default"].Agent({
99
+ keepAlive: true
100
+ });
87
101
  class BotiumConnectorVoip {
88
102
  constructor({
89
103
  queueBotSays,
@@ -97,9 +111,18 @@ class BotiumConnectorVoip {
97
111
  this.sentencesBuilding = 0;
98
112
  this.sentencesFinal = 0;
99
113
  this.sentenceBuilding = false;
114
+ this.ttsCache = new Map();
115
+ this.ttsCacheEnabled = false;
116
+ this.ttsCacheMaxEntries = 0;
117
+ this.ttsPrefetchEnabled = false;
118
+
119
+ // For debugging latency between incoming STT (bot says) and outgoing audio (sendAudio)
120
+ this._lastBotSaysQueuedAt = null;
121
+ this._lastBotSaysText = null;
100
122
  }
101
123
  async Validate() {
102
124
  debug$3('Validate called');
125
+ this.caps = Object.assign({}, Defaults, this.caps);
103
126
  debug$3(this.caps.VOIP_STT_MESSAGE_HANDLING);
104
127
  if (this.caps.VOIP_TTS_URL) {
105
128
  this.axiosTtsParams = {
@@ -107,7 +130,9 @@ class BotiumConnectorVoip {
107
130
  params: this._getParams(Capabilities.VOIP_TTS_PARAMS),
108
131
  method: this.caps.VOIP_TTS_METHOD,
109
132
  timeout: this.caps.VOIP_TTS_TIMEOUT,
110
- headers: this._getHeaders(Capabilities.VOIP_TTS_HEADERS)
133
+ headers: this._getHeaders(Capabilities.VOIP_TTS_HEADERS),
134
+ httpAgent: TTS_HTTP_AGENT,
135
+ httpsAgent: TTS_HTTPS_AGENT
111
136
  };
112
137
  try {
113
138
  const {
@@ -125,7 +150,10 @@ class BotiumConnectorVoip {
125
150
  throw new Error(`Checking TTS Status failed - ${this._getAxiosErrOutput(err)}`);
126
151
  }
127
152
  }
128
- this.caps = Object.assign({}, Defaults, this.caps);
153
+ const cacheSize = parseInt(this.caps[Capabilities.VOIP_TTS_CACHE_SIZE], 10);
154
+ this.ttsCacheEnabled = !!this.caps[Capabilities.VOIP_TTS_CACHE_ENABLE];
155
+ this.ttsCacheMaxEntries = lodash__default["default"].isFinite(cacheSize) && cacheSize > 0 ? cacheSize : 0;
156
+ this.ttsPrefetchEnabled = !!this.caps[Capabilities.VOIP_TTS_PREFETCH_ENABLE];
129
157
  }
130
158
  async Start() {
131
159
  debug$3('Start called');
@@ -135,19 +163,31 @@ class BotiumConnectorVoip {
135
163
  this.end = false;
136
164
  this.connected = false;
137
165
  this.convoStep = null;
166
+ this._lastBotSaysQueuedAt = null;
167
+ this._lastBotSaysText = null;
168
+ if (this.ttsCache) {
169
+ this.ttsCache.clear();
170
+ }
138
171
  const sendBotMsg = botMsg => {
172
+ this._lastBotSaysQueuedAt = Date.now();
173
+ this._lastBotSaysText = botMsg && botMsg.messageText ? String(botMsg.messageText) : null;
139
174
  setTimeout(() => this.queueBotSays(botMsg), 0);
140
175
  };
141
176
  const joinBotMsg = (botMsgs, joinLastPrevMsg) => {
142
177
  const botMsg = {};
143
178
  botMsg.messageText = botMsgs.map(m => m.messageText).join(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_DELIMITER] || '');
144
179
  botMsg.sourceData = botMsgs.map(m => m.sourceData);
145
- if (lodash__default["default"].isNil(joinLastPrevMsg)) {
146
- botMsg.sourceData[0].silenceDuration = botMsgs[0].sourceData.data.start;
147
- botMsg.sourceData[0].voiceDuration = botMsgs[botMsgs.length - 1].sourceData.data.end - botMsgs[0].sourceData.data.start;
180
+ const firstStart = lodash__default["default"].get(botMsgs, '[0].sourceData.data.start', null);
181
+ const lastEnd = lodash__default["default"].get(botMsgs, `[${botMsgs.length - 1}].sourceData.data.end`, null);
182
+ const prevEnd = lodash__default["default"].get(joinLastPrevMsg, 'sourceData.data.end', null);
183
+ // joinLastPrevMsg can be null (first joined message) OR incomplete (in case JOIN timeout fired before we got any final STT info).
184
+ // In those cases, fall back to "start-of-first-chunk" semantics (same as previous behaviour for nil joinLastPrevMsg).
185
+ if (lodash__default["default"].isFinite(firstStart) && lodash__default["default"].isFinite(lastEnd)) {
186
+ botMsg.sourceData[0].silenceDuration = lodash__default["default"].isFinite(prevEnd) ? firstStart - prevEnd : firstStart;
187
+ botMsg.sourceData[0].voiceDuration = lastEnd - firstStart;
148
188
  } else {
149
- botMsg.sourceData[0].silenceDuration = botMsgs[0].sourceData.data.start - joinLastPrevMsg.sourceData.data.end;
150
- botMsg.sourceData[0].voiceDuration = botMsgs[botMsgs.length - 1].sourceData.data.end - botMsgs[0].sourceData.data.start;
189
+ botMsg.sourceData[0].silenceDuration = lodash__default["default"].isFinite(firstStart) ? firstStart : null;
190
+ botMsg.sourceData[0].voiceDuration = lodash__default["default"].isFinite(firstStart) && lodash__default["default"].isFinite(lastEnd) ? lastEnd - firstStart : null;
151
191
  }
152
192
  return botMsg;
153
193
  };
@@ -176,12 +216,14 @@ class BotiumConnectorVoip {
176
216
  const connect = async retryIndex => {
177
217
  retryIndex = retryIndex || 0;
178
218
  try {
219
+ 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');
220
+ const baseUrl = workerUrl.endsWith('/') ? workerUrl.slice(0, -1) : workerUrl;
179
221
  const res = await axios__default["default"]({
180
222
  method: 'post',
181
223
  data: {
182
224
  API_KEY: this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_APIKEY : this.caps[Capabilities.VOIP_WORKER_APIKEY]
183
225
  },
184
- 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()
226
+ url: `${baseUrl}/initCall`
185
227
  });
186
228
  if (res) {
187
229
  data = res.data;
@@ -235,6 +277,35 @@ class BotiumConnectorVoip {
235
277
  }
236
278
  this.eventEmitter.on('CONVO_STEP_NEXT', (container, convoStep) => {
237
279
  this.convoStep = convoStep;
280
+ this._maybePrefetchTts(convoStep);
281
+ // For PSST: send join silence duration per step to VOIP worker (controls PSST silence trigger)
282
+ try {
283
+ if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'PSST' && this.ws && this.ws.readyState === ws__default["default"].OPEN) {
284
+ const joinLogicHook = this._getJoinLogicHook(convoStep);
285
+ let silenceMs = null;
286
+ if (joinLogicHook && joinLogicHook.args && joinLogicHook.args.length > 0) {
287
+ silenceMs = parseInt(joinLogicHook.args[0], 10);
288
+ }
289
+ // Fallback to global timeout if no per-step hook is set
290
+ if (!lodash__default["default"].isFinite(silenceMs) || silenceMs <= 0) {
291
+ silenceMs = parseInt(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT], 10);
292
+ }
293
+ // PSST: treat like JOIN, but subtract 500ms from timeout
294
+ if (lodash__default["default"].isFinite(silenceMs) && silenceMs > 0) {
295
+ silenceMs = Math.max(0, silenceMs - 500);
296
+ }
297
+ if (lodash__default["default"].isFinite(silenceMs) && silenceMs > 0 && this.sessionId) {
298
+ debug$3(`PSST: sending silenceDurationMs=${silenceMs} for sessionId=${this.sessionId}`);
299
+ this.ws.send(JSON.stringify({
300
+ METHOD: 'setSttSilenceDuration',
301
+ sessionId: this.sessionId,
302
+ silenceDurationMs: silenceMs
303
+ }));
304
+ }
305
+ }
306
+ } catch (err) {
307
+ debug$3(`Failed sending PSST silence duration to VOIP worker: ${err.message || err}`);
308
+ }
238
309
  });
239
310
  this.silence = null;
240
311
  this.msgCount = 0;
@@ -243,6 +314,18 @@ class BotiumConnectorVoip {
243
314
  this.silenceTimeout = null;
244
315
  this.wsOpened = true;
245
316
  debug$3(`Websocket connection to ${wsEndpoint} opened.`);
317
+ const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
318
+ const isPsst = sttHandling === 'PSST';
319
+ const sttLegacy = true;
320
+ let sttUrl = this.caps[Capabilities.VOIP_STT_URL_STREAM];
321
+ if (isPsst && lodash__default["default"].isString(sttUrl)) {
322
+ const replaced = sttUrl.replace('/api/sttstream/', '/api/stt/');
323
+ if (replaced !== sttUrl) {
324
+ sttUrl = replaced;
325
+ } else {
326
+ debug$3(`PSST: Could not derive /api/stt url from ${sttUrl}, using as-is`);
327
+ }
328
+ }
246
329
  const request = {
247
330
  METHOD: 'initCall',
248
331
  SIP_CALLER_AUTO: this.caps[Capabilities.VOIP_SIP_POOL_CALLER_ENABLE],
@@ -263,8 +346,9 @@ class BotiumConnectorVoip {
263
346
  ICE_TURN_PASSWORD: this.caps[Capabilities.VOIP_ICE_TURN_PASSWORD],
264
347
  ICE_TURN_PROTOCOL: this.caps[Capabilities.VOIP_ICE_TURN_PROTOCOL] || 'TCP',
265
348
  MIN_SILENCE_DURATION: this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT_ENABLE] ? this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT] : null,
349
+ STT_LEGACY: sttLegacy,
266
350
  STT_CONFIG: {
267
- stt_url: this.caps[Capabilities.VOIP_STT_URL_STREAM],
351
+ stt_url: sttUrl,
268
352
  stt_params: this.caps[Capabilities.VOIP_STT_PARAMS_STREAM],
269
353
  stt_body: this.caps[Capabilities.VOIP_STT_BODY_STREAM] || null
270
354
  },
@@ -285,9 +369,88 @@ class BotiumConnectorVoip {
285
369
  });
286
370
  this.ws.on('message', async data => {
287
371
  const parsedData = JSON.parse(data);
372
+ // Allow fullRecord delivery even if Stop() was already called.
373
+ // Otherwise the recording can be dropped if the worker streams it late (common on hangup).
374
+ if (this.stopCalled) {
375
+ const allowedTypes = ['fullRecord', 'fullRecordStart', 'fullRecordChunk', 'fullRecordEnd', 'error'];
376
+ if (!parsedData || !parsedData.type || !allowedTypes.includes(parsedData.type)) {
377
+ debug$3(`${this.sessionId} - Stop already called, ignoring incoming message`);
378
+ return;
379
+ }
380
+ }
288
381
  const parsedDataLog = lodash__default["default"].cloneDeep(parsedData);
289
- parsedDataLog.fullRecord = '<full_record_buffer>';
382
+ // Sanitize all potential base64 fields for logging (don't dump huge buffers)
383
+ const sanitizeBase64Fields = (obj, prefix = '') => {
384
+ if (!obj || typeof obj !== 'object') return;
385
+ for (const key of Object.keys(obj)) {
386
+ const val = obj[key];
387
+ if (typeof val === 'string' && val.length > 500) {
388
+ obj[key] = `<base64:${val.length}chars>`;
389
+ } else if (val && typeof val === 'object' && !Array.isArray(val)) {
390
+ sanitizeBase64Fields(val, `${prefix}${key}.`);
391
+ }
392
+ }
393
+ };
394
+ sanitizeBase64Fields(parsedDataLog);
290
395
  debug$3(JSON.stringify(parsedDataLog, null, 2));
396
+ const _extractFullRecordBase64 = pd => {
397
+ if (!pd) return null;
398
+ // Different VOIP workers may put the payload in various fields - search all string fields
399
+ // First try known field names
400
+ 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];
401
+ for (const val of knownFields) {
402
+ if (typeof val === 'string' && val.length > 100) return val;
403
+ }
404
+ // Fallback: find any large string field (likely base64)
405
+ const findLargeString = (obj, depth = 0) => {
406
+ if (!obj || typeof obj !== 'object' || depth > 3) return null;
407
+ for (const key of Object.keys(obj)) {
408
+ const val = obj[key];
409
+ if (typeof val === 'string' && val.length > 1000) {
410
+ debug$3(`${this.sessionId} - Found base64 in field '${key}' (len=${val.length})`);
411
+ return val;
412
+ }
413
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
414
+ const found = findLargeString(val, depth + 1);
415
+ if (found) return found;
416
+ }
417
+ }
418
+ return null;
419
+ };
420
+ return findLargeString(pd);
421
+ };
422
+ const emitFullRecordAttachment = base64 => {
423
+ if (!base64 || typeof base64 !== 'string' || base64.length === 0) {
424
+ // Log available fields (length only) to help diagnose worker payload shape without dumping base64
425
+ try {
426
+ const candidates = {
427
+ fullRecord: typeof parsedData?.fullRecord === 'string' ? parsedData.fullRecord.length : null,
428
+ full_record: typeof parsedData?.full_record === 'string' ? parsedData.full_record.length : null,
429
+ data_fullRecord: typeof parsedData?.data?.fullRecord === 'string' ? parsedData.data.fullRecord.length : null,
430
+ data_full_record: typeof parsedData?.data?.full_record === 'string' ? parsedData.data.full_record.length : null,
431
+ data_b64_buffer: typeof parsedData?.data?.b64_buffer === 'string' ? parsedData.data.b64_buffer.length : null,
432
+ data_base64: typeof parsedData?.data?.base64 === 'string' ? parsedData.data.base64.length : null,
433
+ data_full_record_buffer: typeof parsedData?.data?.full_record_buffer === 'string' ? parsedData.data.full_record_buffer.length : null,
434
+ data_buffer: typeof parsedData?.data?.buffer === 'string' ? parsedData.data.buffer.length : null
435
+ };
436
+ debug$3(`${this.sessionId} - fullRecordEnd received but base64 is empty, skipping attachment emit. CandidateLengths=${JSON.stringify(candidates)}`);
437
+ } catch (err) {
438
+ debug$3(`${this.sessionId} - fullRecordEnd received but base64 is empty, skipping attachment emit (diag failed: ${err.message || err})`);
439
+ }
440
+ return;
441
+ }
442
+ debug$3(`${this.sessionId} - Emitting MESSAGE_ATTACHMENT full_record.wav (base64Len=${base64.length}, stopCalled=${!!this.stopCalled})`);
443
+ this.eventEmitter.emit('MESSAGE_ATTACHMENT', this.container, {
444
+ name: 'full_record.wav',
445
+ mimeType: 'audio/wav',
446
+ base64,
447
+ // Session context from capabilities for correlation
448
+ sessionContext: {
449
+ testSessionId: this.caps.VOIP_TEST_SESSION_ID || null,
450
+ testSessionJobId: this.caps.VOIP_TEST_SESSION_JOB_ID || null
451
+ }
452
+ });
453
+ };
291
454
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
292
455
  this.sessionId = parsedData.voipConfig.sessionId;
293
456
  }
@@ -323,6 +486,22 @@ class BotiumConnectorVoip {
323
486
  reject(new Error(`Error: ${parsedData.message}`));
324
487
  sendBotMsg(new Error(`Error: ${parsedData.message}`));
325
488
  }
489
+
490
+ // Full record streaming support:
491
+ // - some VOIP workers send the recording in chunks and an end marker
492
+ if (parsedData && parsedData.type === 'fullRecordStart') {
493
+ this.fullRecord = '';
494
+ }
495
+ if (parsedData && parsedData.type === 'fullRecordChunk') {
496
+ const chunk = _extractFullRecordBase64(parsedData);
497
+ if (chunk) this.fullRecord = (this.fullRecord || '') + chunk;
498
+ }
499
+ if (parsedData && parsedData.type === 'fullRecordEnd') {
500
+ const tail = _extractFullRecordBase64(parsedData);
501
+ if (tail) this.fullRecord = (this.fullRecord || '') + tail;
502
+ emitFullRecordAttachment(this.fullRecord || tail);
503
+ this.end = true;
504
+ }
326
505
  if (parsedData && parsedData.type === 'silence') {
327
506
  if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
328
507
  if (lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep)) && parsedData.data.silence.length > 0) {
@@ -332,11 +511,8 @@ class BotiumConnectorVoip {
332
511
  }
333
512
  }
334
513
  if (parsedData && parsedData.type === 'fullRecord') {
335
- this.eventEmitter.emit('MESSAGE_ATTACHMENT', this.container, {
336
- name: 'full_record.wav',
337
- mimeType: 'audio/wav',
338
- base64: parsedData.fullRecord
339
- });
514
+ // Non-chunked full record
515
+ emitFullRecordAttachment(_extractFullRecordBase64(parsedData));
340
516
  this.end = true;
341
517
  }
342
518
  if (parsedData && parsedData.data && parsedData.data.final === false) {
@@ -350,16 +526,29 @@ class BotiumConnectorVoip {
350
526
  }
351
527
  }
352
528
  this.firstSttInfoReceived = true;
353
- if (this.prevData) {
529
+ if (this.prevData && this.prevData.data && !lodash__default["default"].isNil(this.prevData.data.end)) {
354
530
  if (!lodash__default["default"].isNil(parsedData.data.start)) {
355
531
  const silenceDuration = parsedData.data.start - this.prevData.data.end;
356
532
  const joinLogicHook = this._getJoinLogicHook(this.convoStep);
357
- const isJoinMethod = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'JOIN';
533
+ const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
534
+ const isPsst = sttHandling === 'PSST';
535
+ const isJoinMethod = sttHandling === 'JOIN' || isPsst;
536
+ const toJoinTimeoutMs = ms => {
537
+ const parsed = parseInt(ms, 10);
538
+ if (!lodash__default["default"].isFinite(parsed) || parsed <= 0) return null;
539
+ return isPsst ? Math.max(0, parsed - 500) : parsed;
540
+ };
358
541
  let matched = false;
359
- if ((!lodash__default["default"].isNil(joinLogicHook) && isJoinMethod && silenceDuration > parseInt(joinLogicHook.args[0])) / 1000) {
360
- matched = true;
361
- } else if (lodash__default["default"].isNil(joinLogicHook) && isJoinMethod && silenceDuration > parseInt(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]) / 1000) {
362
- matched = true;
542
+ if (!lodash__default["default"].isNil(joinLogicHook) && isJoinMethod) {
543
+ const joinTimeoutMs = toJoinTimeoutMs(joinLogicHook && joinLogicHook.args && joinLogicHook.args[0]);
544
+ if (lodash__default["default"].isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > joinTimeoutMs / 1000) {
545
+ matched = true;
546
+ }
547
+ } else if (lodash__default["default"].isNil(joinLogicHook) && isJoinMethod) {
548
+ const joinTimeoutMs = toJoinTimeoutMs(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]);
549
+ if (lodash__default["default"].isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > joinTimeoutMs / 1000) {
550
+ matched = true;
551
+ }
363
552
  }
364
553
  if (matched && this.botMsgs.length > 0) {
365
554
  sendBotMsg(joinBotMsg(this.botMsgs, this.joinLastPrevMsg));
@@ -374,6 +563,7 @@ class BotiumConnectorVoip {
374
563
  const confidenceThreshold = this._getConfidenceScoreLogicHook(this.convoStep) && this._getConfidenceScoreLogicHook(this.convoStep).args[0] || this.caps[Capabilities.VOIP_STT_CONFIDENCE_THRESHOLD];
375
564
  const successfulConfidenceScore = this._getConfidenceScore(parsedData) >= confidenceThreshold;
376
565
  debug$3(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`);
566
+ // ORIGINAL: emit final message immediately, unless join hooks are active.
377
567
  if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep))) {
378
568
  let botMsg = {
379
569
  messageText: parsedData.data.message
@@ -388,7 +578,9 @@ class BotiumConnectorVoip {
388
578
  this.firstMsg = false;
389
579
  } else {
390
580
  const sourceData = parsedData;
391
- sourceData.silenceDuration = parsedData.data.start - this.prevData.data.end;
581
+ const start = lodash__default["default"].get(parsedData, 'data.start', null);
582
+ const prevEnd = lodash__default["default"].get(this.prevData, 'data.end', null);
583
+ sourceData.silenceDuration = lodash__default["default"].isFinite(start) && lodash__default["default"].isFinite(prevEnd) ? start - prevEnd : lodash__default["default"].isFinite(start) ? start : null;
392
584
  sourceData.voiceDuration = parsedData.data.end - parsedData.data.start;
393
585
  botMsg = Object.assign({}, botMsg, {
394
586
  sourceData
@@ -415,7 +607,9 @@ class BotiumConnectorVoip {
415
607
  this.firstMsg = false;
416
608
  } else {
417
609
  const sourceData = parsedData;
418
- sourceData.silenceDuration = parsedData.data.start - this.prevData.data.end;
610
+ const start = lodash__default["default"].get(parsedData, 'data.start', null);
611
+ const prevEnd = lodash__default["default"].get(this.prevData, 'data.end', null);
612
+ sourceData.silenceDuration = lodash__default["default"].isFinite(start) && lodash__default["default"].isFinite(prevEnd) ? start - prevEnd : lodash__default["default"].isFinite(start) ? start : null;
419
613
  sourceData.voiceDuration = parsedData.data.end - parsedData.data.start;
420
614
  botMsg = Object.assign({}, botMsg, {
421
615
  sourceData
@@ -429,7 +623,7 @@ class BotiumConnectorVoip {
429
623
  }
430
624
  this.botMsgs = [];
431
625
  }
432
- if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'JOIN' || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'CONCAT' || !lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep))) {
626
+ 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__default["default"].isNil(this._getJoinLogicHook(this.convoStep))) {
433
627
  const botMsg = {
434
628
  messageText: parsedData.data.message,
435
629
  sourceData: parsedData
@@ -437,15 +631,27 @@ class BotiumConnectorVoip {
437
631
  this.prevData = parsedData;
438
632
  if (successfulConfidenceScore) {
439
633
  this.botMsgs.push(botMsg);
634
+ const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING];
635
+ const isPsst = sttHandling === 'PSST';
636
+ const toJoinTimeoutMs = ms => {
637
+ const parsed = parseInt(ms, 10);
638
+ if (!lodash__default["default"].isFinite(parsed) || parsed <= 0) return null;
639
+ return isPsst ? Math.max(0, parsed - 500) : parsed;
640
+ };
641
+ const joinLogicHook = this._getJoinLogicHook(this.convoStep);
642
+ let joinTimeoutMs = toJoinTimeoutMs(lodash__default["default"].get(joinLogicHook, 'args[0]'));
643
+ if (!lodash__default["default"].isFinite(joinTimeoutMs) || joinTimeoutMs <= 0) {
644
+ joinTimeoutMs = toJoinTimeoutMs(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]);
645
+ }
440
646
  this.silenceTimeout = setTimeout(() => {
441
647
  if (this.botMsgs.length > 0) {
442
- 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');
648
+ debug$3('Silence Duration Timeout (JOIN/PSST):', joinTimeoutMs, 'ms');
443
649
  sendBotMsg(joinBotMsg(this.botMsgs, this.joinLastPrevMsg));
444
650
  this.firstMsg = false;
445
651
  this.joinLastPrevMsg = this.botMsgs[this.botMsgs.length - 1];
446
652
  this.botMsgs = [];
447
653
  }
448
- }, this._getJoinLogicHook(this.convoStep) && parseInt(this._getJoinLogicHook(this.convoStep).args[0]) || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]);
654
+ }, joinTimeoutMs || 0);
449
655
  }
450
656
  }
451
657
  }
@@ -457,7 +663,24 @@ class BotiumConnectorVoip {
457
663
  }
458
664
  async UserSays(msg) {
459
665
  debug$3('UserSays called');
460
- debug$3(msg);
666
+ // Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
667
+ try {
668
+ const safeLog = {
669
+ messageText: msg && msg.messageText ? String(msg.messageText).substring(0, 200) : undefined,
670
+ buttons: msg && Array.isArray(msg.buttons) ? msg.buttons.length : 0,
671
+ hasMedia: !!(msg && msg.media && msg.media.length > 0),
672
+ mediaMimeType: msg && msg.media && msg.media[0] ? msg.media[0].mimeType : undefined,
673
+ mediaSize: msg && msg.media && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined,
674
+ attachments: msg && Array.isArray(msg.attachments) ? msg.attachments.map(a => ({
675
+ name: a && (a.name || a.title),
676
+ mimeType: a && a.mimeType,
677
+ base64Len: a && a.base64 ? a.base64.length : 0
678
+ })) : []
679
+ };
680
+ debug$3(safeLog);
681
+ } catch (err) {
682
+ debug$3(`UserSays: failed to build safe log: ${err.message || err}`);
683
+ }
461
684
  if (!msg.attachments) {
462
685
  msg.attachments = [];
463
686
  }
@@ -472,23 +695,32 @@ class BotiumConnectorVoip {
472
695
  });
473
696
  this.ws.send(request);
474
697
  } else if (msg && msg.messageText) {
475
- if (!this.axiosTtsParams) reject(new Error('TTS not configured, only audio input supported'));
476
- if (this.axiosTtsParams) {
477
- const ttsRequest = {
478
- ...this.axiosTtsParams,
479
- params: {
480
- ...(this.axiosTtsParams.params || {}),
481
- text: msg.messageText
482
- },
483
- data: this._getBody(Capabilities.VOIP_TTS_BODY),
484
- responseType: 'arraybuffer'
485
- };
698
+ // Check for DTMF tag in messageText: <DTMF>1234</DTMF>
699
+ const dtmfMatch = msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
700
+ if (dtmfMatch && dtmfMatch[1]) {
701
+ const digits = dtmfMatch[1];
702
+ debug$3(`Sending DTMF from messageText: ${digits}`);
703
+ const request = JSON.stringify({
704
+ METHOD: 'sendDtmf',
705
+ digits,
706
+ sessionId: this.sessionId
707
+ });
708
+ this.ws.send(request);
709
+ return resolve();
710
+ }
711
+ if (!this.axiosTtsParams) {
712
+ if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
713
+ return reject(new Error('TTS not configured, only audio input supported'));
714
+ }
715
+ } else {
716
+ const ttsRequest = this._buildTtsRequest(msg.messageText);
717
+ if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
486
718
  msg.sourceData = ttsRequest;
487
- let ttsResponse = null;
719
+ let ttsResult = null;
488
720
  try {
489
- ttsResponse = await axios__default["default"](ttsRequest);
721
+ ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
490
722
  } catch (err) {
491
- reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
723
+ return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
492
724
  }
493
725
  if (msg && msg.messageText && msg.messageText.length > 0) {
494
726
  const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
@@ -499,22 +731,28 @@ class BotiumConnectorVoip {
499
731
  apiKey
500
732
  });
501
733
  }
502
- if (Buffer.isBuffer(ttsResponse.data)) {
503
- duration = ttsResponse.headers['content-duration'];
734
+ if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
735
+ duration = parseFloat(ttsResult.duration) || 0;
736
+ const b64Buffer = ttsResult.buffer.toString('base64');
504
737
  const request = JSON.stringify({
505
738
  METHOD: 'sendAudio',
506
739
  PESQ: false,
507
740
  sessionId: this.sessionId,
508
- b64_buffer: ttsResponse.data.toString('base64')
741
+ b64_buffer: b64Buffer
509
742
  });
743
+ if (this._lastBotSaysQueuedAt) {
744
+ const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
745
+ const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
746
+ debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
747
+ }
510
748
  msg.attachments.push({
511
749
  name: 'tts.wav',
512
750
  mimeType: 'audio/wav',
513
- base64: ttsResponse.data.toString('base64')
751
+ base64: b64Buffer
514
752
  });
515
753
  this.ws.send(request);
516
754
  } else {
517
- reject(new Error(`TTS failed, response is: ${this._getAxiosShortenedOutput(ttsResponse.data)}`));
755
+ return reject(new Error('TTS failed, response is empty'));
518
756
  }
519
757
  }
520
758
  }
@@ -524,6 +762,11 @@ class BotiumConnectorVoip {
524
762
  sessionId: this.sessionId,
525
763
  b64_buffer: msg.media[0].buffer.toString('base64')
526
764
  });
765
+ if (this._lastBotSaysQueuedAt) {
766
+ const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
767
+ const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
768
+ debug$3(`Latency (bot says -> sendAudio/MEDIA): ${latencyMs} ms (last bot: ${botText})`);
769
+ }
527
770
  this.ws.send(request);
528
771
  msg.attachments.push({
529
772
  name: msg.media[0].mediaUri,
@@ -548,6 +791,11 @@ class BotiumConnectorVoip {
548
791
  }
549
792
  async Stop() {
550
793
  debug$3(`${this.sessionId} - Stop called`);
794
+ this.stopCalled = true;
795
+ if (this.silenceTimeout) {
796
+ clearTimeout(this.silenceTimeout);
797
+ this.silenceTimeout = null;
798
+ }
551
799
  if (this.ws && this.ws.readyState !== ws__default["default"].CLOSED) {
552
800
  const request = JSON.stringify({
553
801
  METHOD: 'stopCall',
@@ -555,9 +803,12 @@ class BotiumConnectorVoip {
555
803
  });
556
804
  this.ws.send(request);
557
805
  await new Promise(resolve => {
558
- setTimeout(resolve, 100000);
559
- setInterval(() => {
806
+ const stopTimeout = setTimeout(resolve, 100000);
807
+ this._stopWaitInterval = setInterval(() => {
560
808
  if (this.end) {
809
+ clearTimeout(stopTimeout);
810
+ clearInterval(this._stopWaitInterval);
811
+ this._stopWaitInterval = null;
561
812
  this.wsOpened = false;
562
813
  this.ws = null;
563
814
  this.end = false;
@@ -575,6 +826,126 @@ class BotiumConnectorVoip {
575
826
  this.firstSttInfoReceived = false;
576
827
  }
577
828
  }
829
+ _isTtsCacheEnabled() {
830
+ return this.ttsCacheEnabled && this.ttsCacheMaxEntries > 0;
831
+ }
832
+ _touchTtsCache(cacheKey, entry) {
833
+ if (!this._isTtsCacheEnabled()) return;
834
+ this.ttsCache.delete(cacheKey);
835
+ this.ttsCache.set(cacheKey, entry);
836
+ }
837
+ _setTtsCacheEntry(cacheKey, entry) {
838
+ if (!this._isTtsCacheEnabled()) return;
839
+ this.ttsCache.set(cacheKey, entry);
840
+ this._trimTtsCache();
841
+ }
842
+ _deleteTtsCacheEntry(cacheKey) {
843
+ if (this.ttsCache) {
844
+ this.ttsCache.delete(cacheKey);
845
+ }
846
+ }
847
+ _trimTtsCache() {
848
+ if (!this._isTtsCacheEnabled()) return;
849
+ while (this.ttsCache.size > this.ttsCacheMaxEntries) {
850
+ const oldestKey = this.ttsCache.keys().next().value;
851
+ this.ttsCache.delete(oldestKey);
852
+ }
853
+ }
854
+ _shouldPrefetchTtsText(text) {
855
+ if (!lodash__default["default"].isString(text)) return false;
856
+ if (!text.trim()) return false;
857
+ return !/\$[A-Za-z]\w*/.test(text);
858
+ }
859
+ _maybePrefetchTts(convoStep) {
860
+ if (!this.ttsPrefetchEnabled || !this._isTtsCacheEnabled()) return;
861
+ if (!convoStep || convoStep.sender !== 'me') return;
862
+ if (!this._shouldPrefetchTtsText(convoStep.messageText)) return;
863
+ const ttsRequest = this._buildTtsRequest(convoStep.messageText);
864
+ if (!ttsRequest) return;
865
+ this._getTtsAudio(ttsRequest, convoStep.messageText).catch(err => {
866
+ debug$3(`TTS prefetch failed - ${this._getAxiosErrOutput(err)}`);
867
+ });
868
+ }
869
+ _buildTtsRequest(text) {
870
+ if (!this.axiosTtsParams) return null;
871
+ return {
872
+ ...this.axiosTtsParams,
873
+ params: {
874
+ ...(this.axiosTtsParams.params || {}),
875
+ text
876
+ },
877
+ data: this._getBody(Capabilities.VOIP_TTS_BODY),
878
+ responseType: 'arraybuffer'
879
+ };
880
+ }
881
+ _getTtsCacheKey(ttsRequest) {
882
+ const cacheKeyObj = {
883
+ url: ttsRequest.url,
884
+ method: ttsRequest.method,
885
+ params: ttsRequest.params,
886
+ data: ttsRequest.data
887
+ };
888
+ try {
889
+ return JSON.stringify(cacheKeyObj);
890
+ } catch (err) {
891
+ return `${ttsRequest.url}|${ttsRequest.method}|${lodash__default["default"].get(ttsRequest, 'params.text', '')}`;
892
+ }
893
+ }
894
+ async _fetchTts(ttsRequest, text) {
895
+ const ttsStart = Date.now();
896
+ const ttsResponse = await axios__default["default"](ttsRequest);
897
+ const ttsMs = Date.now() - ttsStart;
898
+ const textLen = lodash__default["default"].isString(text) ? text.length : 0;
899
+ debug$3(`TTS response ${ttsMs} ms (chars: ${textLen})`);
900
+ if (!ttsResponse || !Buffer.isBuffer(ttsResponse.data)) {
901
+ throw new Error(`TTS failed, response is: ${this._getAxiosShortenedOutput(ttsResponse && ttsResponse.data)}`);
902
+ }
903
+ const durationHeader = ttsResponse.headers && ttsResponse.headers['content-duration'];
904
+ return {
905
+ buffer: ttsResponse.data,
906
+ duration: durationHeader
907
+ };
908
+ }
909
+ async _getTtsAudio(ttsRequest, text) {
910
+ if (!ttsRequest) throw new Error('TTS request not configured');
911
+ const cacheKey = this._getTtsCacheKey(ttsRequest);
912
+ if (this._isTtsCacheEnabled()) {
913
+ const cached = this.ttsCache.get(cacheKey);
914
+ if (cached) {
915
+ if (cached.state === 'ready') {
916
+ this._touchTtsCache(cacheKey, cached);
917
+ debug$3(`TTS cache hit (chars: ${lodash__default["default"].isString(text) ? text.length : 0})`);
918
+ return {
919
+ buffer: cached.buffer,
920
+ duration: cached.duration
921
+ };
922
+ }
923
+ if (cached.state === 'pending') {
924
+ return cached.promise;
925
+ }
926
+ }
927
+ }
928
+ const fetchPromise = this._fetchTts(ttsRequest, text).then(result => {
929
+ if (this._isTtsCacheEnabled()) {
930
+ this._setTtsCacheEntry(cacheKey, {
931
+ state: 'ready',
932
+ buffer: result.buffer,
933
+ duration: result.duration
934
+ });
935
+ }
936
+ return result;
937
+ }).catch(err => {
938
+ this._deleteTtsCacheEntry(cacheKey);
939
+ throw err;
940
+ });
941
+ if (this._isTtsCacheEnabled()) {
942
+ this._setTtsCacheEntry(cacheKey, {
943
+ state: 'pending',
944
+ promise: fetchPromise
945
+ });
946
+ }
947
+ return fetchPromise;
948
+ }
578
949
  _getParams(capParams) {
579
950
  if (this.caps[capParams]) {
580
951
  if (lodash__default["default"].isString(this.caps[capParams])) return JSON.parse(this.caps[capParams]);else return this.caps[capParams];