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.
package/src/connector.js CHANGED
@@ -1,8 +1,9 @@
1
1
  const WebSocket = require('ws')
2
2
  const _ = require('lodash')
3
3
  const axios = require('axios')
4
+ const http = require('http')
5
+ const https = require('https')
4
6
  const debug = require('debug')('botium-connector-voip')
5
- const path = require('path')
6
7
  const mm = require('music-metadata')
7
8
 
8
9
  const Capabilities = {
@@ -23,6 +24,9 @@ const Capabilities = {
23
24
  VOIP_TTS_BODY: 'VOIP_TTS_BODY',
24
25
  VOIP_TTS_HEADERS: 'VOIP_TTS_HEADERS',
25
26
  VOIP_TTS_TIMEOUT: 'VOIP_TTS_TIMEOUT',
27
+ VOIP_TTS_CACHE_ENABLE: 'VOIP_TTS_CACHE_ENABLE',
28
+ VOIP_TTS_CACHE_SIZE: 'VOIP_TTS_CACHE_SIZE',
29
+ VOIP_TTS_PREFETCH_ENABLE: 'VOIP_TTS_PREFETCH_ENABLE',
26
30
  VOIP_WORKER_URL: 'VOIP_WORKER_URL',
27
31
  VOIP_WORKER_APIKEY: 'VOIP_WORKER_APIKEY',
28
32
  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: '. ',
@@ -72,6 +79,9 @@ const Defaults = {
72
79
  VOIP_SIP_PROTOCOL: 'TCP'
73
80
  }
74
81
 
82
+ const TTS_HTTP_AGENT = new http.Agent({ keepAlive: true })
83
+ const TTS_HTTPS_AGENT = new https.Agent({ keepAlive: true })
84
+
75
85
  class BotiumConnectorVoip {
76
86
  constructor ({ queueBotSays, eventEmitter, caps }) {
77
87
  this.queueBotSays = queueBotSays
@@ -81,10 +91,19 @@ class BotiumConnectorVoip {
81
91
  this.sentencesBuilding = 0
82
92
  this.sentencesFinal = 0
83
93
  this.sentenceBuilding = false
94
+ this.ttsCache = new Map()
95
+ this.ttsCacheEnabled = false
96
+ this.ttsCacheMaxEntries = 0
97
+ this.ttsPrefetchEnabled = false
98
+
99
+ // For debugging latency between incoming STT (bot says) and outgoing audio (sendAudio)
100
+ this._lastBotSaysQueuedAt = null
101
+ this._lastBotSaysText = null
84
102
  }
85
103
 
86
104
  async Validate () {
87
105
  debug('Validate called')
106
+ this.caps = Object.assign({}, Defaults, this.caps)
88
107
  debug(this.caps.VOIP_STT_MESSAGE_HANDLING)
89
108
 
90
109
  if (this.caps.VOIP_TTS_URL) {
@@ -93,7 +112,9 @@ class BotiumConnectorVoip {
93
112
  params: this._getParams(Capabilities.VOIP_TTS_PARAMS),
94
113
  method: this.caps.VOIP_TTS_METHOD,
95
114
  timeout: this.caps.VOIP_TTS_TIMEOUT,
96
- headers: this._getHeaders(Capabilities.VOIP_TTS_HEADERS)
115
+ headers: this._getHeaders(Capabilities.VOIP_TTS_HEADERS),
116
+ httpAgent: TTS_HTTP_AGENT,
117
+ httpsAgent: TTS_HTTPS_AGENT
97
118
  }
98
119
  try {
99
120
  const { data } = await axios({
@@ -109,8 +130,10 @@ class BotiumConnectorVoip {
109
130
  throw new Error(`Checking TTS Status failed - ${this._getAxiosErrOutput(err)}`)
110
131
  }
111
132
  }
112
-
113
- this.caps = Object.assign({}, Defaults, this.caps)
133
+ const cacheSize = parseInt(this.caps[Capabilities.VOIP_TTS_CACHE_SIZE], 10)
134
+ this.ttsCacheEnabled = !!this.caps[Capabilities.VOIP_TTS_CACHE_ENABLE]
135
+ this.ttsCacheMaxEntries = _.isFinite(cacheSize) && cacheSize > 0 ? cacheSize : 0
136
+ this.ttsPrefetchEnabled = !!this.caps[Capabilities.VOIP_TTS_PREFETCH_ENABLE]
114
137
  }
115
138
 
116
139
  async Start () {
@@ -123,19 +146,33 @@ class BotiumConnectorVoip {
123
146
  this.end = false
124
147
  this.connected = false
125
148
  this.convoStep = null
149
+ this._lastBotSaysQueuedAt = null
150
+ this._lastBotSaysText = null
151
+ if (this.ttsCache) {
152
+ this.ttsCache.clear()
153
+ }
126
154
 
127
- const sendBotMsg = (botMsg) => { setTimeout(() => this.queueBotSays(botMsg), 0) }
155
+ const sendBotMsg = (botMsg) => {
156
+ this._lastBotSaysQueuedAt = Date.now()
157
+ this._lastBotSaysText = botMsg && botMsg.messageText ? String(botMsg.messageText) : null
158
+ setTimeout(() => this.queueBotSays(botMsg), 0)
159
+ }
128
160
 
129
161
  const joinBotMsg = (botMsgs, joinLastPrevMsg) => {
130
162
  const botMsg = {}
131
163
  botMsg.messageText = botMsgs.map(m => m.messageText).join(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_DELIMITER] || '')
132
164
  botMsg.sourceData = botMsgs.map(m => m.sourceData)
133
- if (_.isNil(joinLastPrevMsg)) {
134
- botMsg.sourceData[0].silenceDuration = botMsgs[0].sourceData.data.start
135
- botMsg.sourceData[0].voiceDuration = botMsgs[botMsgs.length - 1].sourceData.data.end - botMsgs[0].sourceData.data.start
165
+ const firstStart = _.get(botMsgs, '[0].sourceData.data.start', null)
166
+ const lastEnd = _.get(botMsgs, `[${botMsgs.length - 1}].sourceData.data.end`, null)
167
+ const prevEnd = _.get(joinLastPrevMsg, 'sourceData.data.end', null)
168
+ // joinLastPrevMsg can be null (first joined message) OR incomplete (in case JOIN timeout fired before we got any final STT info).
169
+ // In those cases, fall back to "start-of-first-chunk" semantics (same as previous behaviour for nil joinLastPrevMsg).
170
+ if (_.isFinite(firstStart) && _.isFinite(lastEnd)) {
171
+ botMsg.sourceData[0].silenceDuration = (_.isFinite(prevEnd) ? (firstStart - prevEnd) : firstStart)
172
+ botMsg.sourceData[0].voiceDuration = lastEnd - firstStart
136
173
  } else {
137
- botMsg.sourceData[0].silenceDuration = botMsgs[0].sourceData.data.start - joinLastPrevMsg.sourceData.data.end
138
- botMsg.sourceData[0].voiceDuration = botMsgs[botMsgs.length - 1].sourceData.data.end - botMsgs[0].sourceData.data.start
174
+ botMsg.sourceData[0].silenceDuration = _.isFinite(firstStart) ? firstStart : null
175
+ botMsg.sourceData[0].voiceDuration = (_.isFinite(firstStart) && _.isFinite(lastEnd)) ? (lastEnd - firstStart) : null
139
176
  }
140
177
  return botMsg
141
178
  }
@@ -166,12 +203,16 @@ class BotiumConnectorVoip {
166
203
  const connect = async (retryIndex) => {
167
204
  retryIndex = retryIndex || 0
168
205
  try {
206
+ const workerUrl = this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER]
207
+ ? process.env.BOTIUM_VOIP_WORKER_URL
208
+ : this.caps[Capabilities.VOIP_WORKER_URL].replace('wss', 'https').replace('ws', 'http')
209
+ const baseUrl = workerUrl.endsWith('/') ? workerUrl.slice(0, -1) : workerUrl
169
210
  const res = await axios({
170
211
  method: 'post',
171
212
  data: {
172
213
  API_KEY: this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_APIKEY : this.caps[Capabilities.VOIP_WORKER_APIKEY]
173
214
  },
174
- url: new URL(path.join(`${this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_URL : this.caps[Capabilities.VOIP_WORKER_URL].replace('wss', 'https').replace('ws', 'http')}`, 'initCall')).toString()
215
+ url: `${baseUrl}/initCall`
175
216
  })
176
217
  if (res) {
177
218
  data = res.data
@@ -227,6 +268,35 @@ class BotiumConnectorVoip {
227
268
 
228
269
  this.eventEmitter.on('CONVO_STEP_NEXT', (container, convoStep) => {
229
270
  this.convoStep = convoStep
271
+ this._maybePrefetchTts(convoStep)
272
+ // For PSST: send join silence duration per step to VOIP worker (controls PSST silence trigger)
273
+ try {
274
+ if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'PSST' && this.ws && this.ws.readyState === WebSocket.OPEN) {
275
+ const joinLogicHook = this._getJoinLogicHook(convoStep)
276
+ let silenceMs = null
277
+ if (joinLogicHook && joinLogicHook.args && joinLogicHook.args.length > 0) {
278
+ silenceMs = parseInt(joinLogicHook.args[0], 10)
279
+ }
280
+ // Fallback to global timeout if no per-step hook is set
281
+ if (!_.isFinite(silenceMs) || silenceMs <= 0) {
282
+ silenceMs = parseInt(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT], 10)
283
+ }
284
+ // PSST: treat like JOIN, but subtract 500ms from timeout
285
+ if (_.isFinite(silenceMs) && silenceMs > 0) {
286
+ silenceMs = Math.max(0, silenceMs - 500)
287
+ }
288
+ if (_.isFinite(silenceMs) && silenceMs > 0 && this.sessionId) {
289
+ debug(`PSST: sending silenceDurationMs=${silenceMs} for sessionId=${this.sessionId}`)
290
+ this.ws.send(JSON.stringify({
291
+ METHOD: 'setSttSilenceDuration',
292
+ sessionId: this.sessionId,
293
+ silenceDurationMs: silenceMs
294
+ }))
295
+ }
296
+ }
297
+ } catch (err) {
298
+ debug(`Failed sending PSST silence duration to VOIP worker: ${err.message || err}`)
299
+ }
230
300
  })
231
301
 
232
302
  this.silence = null
@@ -237,6 +307,18 @@ class BotiumConnectorVoip {
237
307
 
238
308
  this.wsOpened = true
239
309
  debug(`Websocket connection to ${wsEndpoint} opened.`)
310
+ const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING]
311
+ const isPsst = sttHandling === 'PSST'
312
+ const sttLegacy = true
313
+ let sttUrl = this.caps[Capabilities.VOIP_STT_URL_STREAM]
314
+ if (isPsst && _.isString(sttUrl)) {
315
+ const replaced = sttUrl.replace('/api/sttstream/', '/api/stt/')
316
+ if (replaced !== sttUrl) {
317
+ sttUrl = replaced
318
+ } else {
319
+ debug(`PSST: Could not derive /api/stt url from ${sttUrl}, using as-is`)
320
+ }
321
+ }
240
322
  const request = {
241
323
  METHOD: 'initCall',
242
324
  SIP_CALLER_AUTO: this.caps[Capabilities.VOIP_SIP_POOL_CALLER_ENABLE],
@@ -257,8 +339,9 @@ class BotiumConnectorVoip {
257
339
  ICE_TURN_PASSWORD: this.caps[Capabilities.VOIP_ICE_TURN_PASSWORD],
258
340
  ICE_TURN_PROTOCOL: this.caps[Capabilities.VOIP_ICE_TURN_PROTOCOL] || 'TCP',
259
341
  MIN_SILENCE_DURATION: this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT_ENABLE] ? this.caps[Capabilities.VOIP_SILENCE_DURATION_TIMEOUT] : null,
342
+ STT_LEGACY: sttLegacy,
260
343
  STT_CONFIG: {
261
- stt_url: this.caps[Capabilities.VOIP_STT_URL_STREAM],
344
+ stt_url: sttUrl,
262
345
  stt_params: this.caps[Capabilities.VOIP_STT_PARAMS_STREAM],
263
346
  stt_body: this.caps[Capabilities.VOIP_STT_BODY_STREAM] || null
264
347
  },
@@ -281,12 +364,108 @@ class BotiumConnectorVoip {
281
364
  })
282
365
  this.ws.on('message', async (data) => {
283
366
  const parsedData = JSON.parse(data)
367
+ // Allow fullRecord delivery even if Stop() was already called.
368
+ // Otherwise the recording can be dropped if the worker streams it late (common on hangup).
369
+ if (this.stopCalled) {
370
+ const allowedTypes = ['fullRecord', 'fullRecordStart', 'fullRecordChunk', 'fullRecordEnd', 'error']
371
+ if (!parsedData || !parsedData.type || !allowedTypes.includes(parsedData.type)) {
372
+ debug(`${this.sessionId} - Stop already called, ignoring incoming message`)
373
+ return
374
+ }
375
+ }
284
376
 
285
377
  const parsedDataLog = _.cloneDeep(parsedData)
286
- parsedDataLog.fullRecord = '<full_record_buffer>'
378
+ // Sanitize all potential base64 fields for logging (don't dump huge buffers)
379
+ const sanitizeBase64Fields = (obj, prefix = '') => {
380
+ if (!obj || typeof obj !== 'object') return
381
+ for (const key of Object.keys(obj)) {
382
+ const val = obj[key]
383
+ if (typeof val === 'string' && val.length > 500) {
384
+ obj[key] = `<base64:${val.length}chars>`
385
+ } else if (val && typeof val === 'object' && !Array.isArray(val)) {
386
+ sanitizeBase64Fields(val, `${prefix}${key}.`)
387
+ }
388
+ }
389
+ }
390
+ sanitizeBase64Fields(parsedDataLog)
287
391
 
288
392
  debug(JSON.stringify(parsedDataLog, null, 2))
289
393
 
394
+ const _extractFullRecordBase64 = (pd) => {
395
+ if (!pd) return null
396
+ // Different VOIP workers may put the payload in various fields - search all string fields
397
+ // First try known field names
398
+ const knownFields = [
399
+ pd.fullRecord,
400
+ pd.full_record,
401
+ pd.data?.fullRecord,
402
+ pd.data?.full_record,
403
+ pd.data?.b64_buffer,
404
+ pd.data?.base64,
405
+ pd.data?.full_record_buffer,
406
+ pd.data?.buffer,
407
+ pd.buffer,
408
+ pd.base64,
409
+ pd.audio,
410
+ pd.audioData,
411
+ pd.data?.audio,
412
+ pd.data?.audioData
413
+ ]
414
+ for (const val of knownFields) {
415
+ if (typeof val === 'string' && val.length > 100) return val
416
+ }
417
+ // Fallback: find any large string field (likely base64)
418
+ const findLargeString = (obj, depth = 0) => {
419
+ if (!obj || typeof obj !== 'object' || depth > 3) return null
420
+ for (const key of Object.keys(obj)) {
421
+ const val = obj[key]
422
+ if (typeof val === 'string' && val.length > 1000) {
423
+ debug(`${this.sessionId} - Found base64 in field '${key}' (len=${val.length})`)
424
+ return val
425
+ }
426
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
427
+ const found = findLargeString(val, depth + 1)
428
+ if (found) return found
429
+ }
430
+ }
431
+ return null
432
+ }
433
+ return findLargeString(pd)
434
+ }
435
+
436
+ const emitFullRecordAttachment = (base64) => {
437
+ if (!base64 || typeof base64 !== 'string' || base64.length === 0) {
438
+ // Log available fields (length only) to help diagnose worker payload shape without dumping base64
439
+ try {
440
+ const candidates = {
441
+ fullRecord: typeof parsedData?.fullRecord === 'string' ? parsedData.fullRecord.length : null,
442
+ full_record: typeof parsedData?.full_record === 'string' ? parsedData.full_record.length : null,
443
+ data_fullRecord: typeof parsedData?.data?.fullRecord === 'string' ? parsedData.data.fullRecord.length : null,
444
+ data_full_record: typeof parsedData?.data?.full_record === 'string' ? parsedData.data.full_record.length : null,
445
+ data_b64_buffer: typeof parsedData?.data?.b64_buffer === 'string' ? parsedData.data.b64_buffer.length : null,
446
+ data_base64: typeof parsedData?.data?.base64 === 'string' ? parsedData.data.base64.length : null,
447
+ data_full_record_buffer: typeof parsedData?.data?.full_record_buffer === 'string' ? parsedData.data.full_record_buffer.length : null,
448
+ data_buffer: typeof parsedData?.data?.buffer === 'string' ? parsedData.data.buffer.length : null
449
+ }
450
+ debug(`${this.sessionId} - fullRecordEnd received but base64 is empty, skipping attachment emit. CandidateLengths=${JSON.stringify(candidates)}`)
451
+ } catch (err) {
452
+ debug(`${this.sessionId} - fullRecordEnd received but base64 is empty, skipping attachment emit (diag failed: ${err.message || err})`)
453
+ }
454
+ return
455
+ }
456
+ debug(`${this.sessionId} - Emitting MESSAGE_ATTACHMENT full_record.wav (base64Len=${base64.length}, stopCalled=${!!this.stopCalled})`)
457
+ this.eventEmitter.emit('MESSAGE_ATTACHMENT', this.container, {
458
+ name: 'full_record.wav',
459
+ mimeType: 'audio/wav',
460
+ base64,
461
+ // Session context from capabilities for correlation
462
+ sessionContext: {
463
+ testSessionId: this.caps.VOIP_TEST_SESSION_ID || null,
464
+ testSessionJobId: this.caps.VOIP_TEST_SESSION_JOB_ID || null
465
+ }
466
+ })
467
+ }
468
+
290
469
  if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
291
470
  this.sessionId = parsedData.voipConfig.sessionId
292
471
  }
@@ -328,6 +507,22 @@ class BotiumConnectorVoip {
328
507
  sendBotMsg(new Error(`Error: ${parsedData.message}`))
329
508
  }
330
509
 
510
+ // Full record streaming support:
511
+ // - some VOIP workers send the recording in chunks and an end marker
512
+ if (parsedData && parsedData.type === 'fullRecordStart') {
513
+ this.fullRecord = ''
514
+ }
515
+ if (parsedData && parsedData.type === 'fullRecordChunk') {
516
+ const chunk = _extractFullRecordBase64(parsedData)
517
+ if (chunk) this.fullRecord = (this.fullRecord || '') + chunk
518
+ }
519
+ if (parsedData && parsedData.type === 'fullRecordEnd') {
520
+ const tail = _extractFullRecordBase64(parsedData)
521
+ if (tail) this.fullRecord = (this.fullRecord || '') + tail
522
+ emitFullRecordAttachment(this.fullRecord || tail)
523
+ this.end = true
524
+ }
525
+
331
526
  if (parsedData && parsedData.type === 'silence') {
332
527
  if (_.isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
333
528
  if (_.isNil(this._getJoinLogicHook(this.convoStep)) && parsedData.data.silence.length > 0) {
@@ -338,11 +533,8 @@ class BotiumConnectorVoip {
338
533
  }
339
534
 
340
535
  if (parsedData && parsedData.type === 'fullRecord') {
341
- this.eventEmitter.emit('MESSAGE_ATTACHMENT', this.container, {
342
- name: 'full_record.wav',
343
- mimeType: 'audio/wav',
344
- base64: parsedData.fullRecord
345
- })
536
+ // Non-chunked full record
537
+ emitFullRecordAttachment(_extractFullRecordBase64(parsedData))
346
538
  this.end = true
347
539
  }
348
540
 
@@ -357,16 +549,29 @@ class BotiumConnectorVoip {
357
549
  }
358
550
  }
359
551
  this.firstSttInfoReceived = true
360
- if (this.prevData) {
552
+ if (this.prevData && this.prevData.data && !_.isNil(this.prevData.data.end)) {
361
553
  if (!_.isNil(parsedData.data.start)) {
362
554
  const silenceDuration = parsedData.data.start - this.prevData.data.end
363
555
  const joinLogicHook = this._getJoinLogicHook(this.convoStep)
364
- const isJoinMethod = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'JOIN'
556
+ const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING]
557
+ const isPsst = sttHandling === 'PSST'
558
+ const isJoinMethod = sttHandling === 'JOIN' || isPsst
559
+ const toJoinTimeoutMs = (ms) => {
560
+ const parsed = parseInt(ms, 10)
561
+ if (!_.isFinite(parsed) || parsed <= 0) return null
562
+ return isPsst ? Math.max(0, parsed - 500) : parsed
563
+ }
365
564
  let matched = false
366
- if ((!_.isNil(joinLogicHook) && isJoinMethod && silenceDuration > parseInt(joinLogicHook.args[0])) / 1000) {
367
- matched = true
368
- } else if ((_.isNil(joinLogicHook) && isJoinMethod && (silenceDuration > parseInt(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT]) / 1000))) {
369
- matched = true
565
+ if (!_.isNil(joinLogicHook) && isJoinMethod) {
566
+ const joinTimeoutMs = toJoinTimeoutMs(joinLogicHook && joinLogicHook.args && joinLogicHook.args[0])
567
+ if (_.isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > (joinTimeoutMs / 1000)) {
568
+ matched = true
569
+ }
570
+ } else if (_.isNil(joinLogicHook) && isJoinMethod) {
571
+ const joinTimeoutMs = toJoinTimeoutMs(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT])
572
+ if (_.isFinite(joinTimeoutMs) && joinTimeoutMs > 0 && silenceDuration > (joinTimeoutMs / 1000)) {
573
+ matched = true
574
+ }
370
575
  }
371
576
  if (matched && this.botMsgs.length > 0) {
372
577
  sendBotMsg(joinBotMsg(this.botMsgs, this.joinLastPrevMsg))
@@ -382,7 +587,10 @@ class BotiumConnectorVoip {
382
587
  const confidenceThreshold = ((this._getConfidenceScoreLogicHook(this.convoStep) && this._getConfidenceScoreLogicHook(this.convoStep).args[0]) || this.caps[Capabilities.VOIP_STT_CONFIDENCE_THRESHOLD])
383
588
  const successfulConfidenceScore = this._getConfidenceScore(parsedData) >= confidenceThreshold
384
589
  debug(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`)
385
- if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && (_.isNil(this._getJoinLogicHook(this.convoStep)))) {
590
+ // ORIGINAL: emit final message immediately, unless join hooks are active.
591
+ if (
592
+ (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && (_.isNil(this._getJoinLogicHook(this.convoStep))))
593
+ ) {
386
594
  let botMsg = { messageText: parsedData.data.message }
387
595
  if (this.firstMsg) {
388
596
  const sourceData = parsedData
@@ -392,7 +600,9 @@ class BotiumConnectorVoip {
392
600
  this.firstMsg = false
393
601
  } else {
394
602
  const sourceData = parsedData
395
- sourceData.silenceDuration = parsedData.data.start - this.prevData.data.end
603
+ const start = _.get(parsedData, 'data.start', null)
604
+ const prevEnd = _.get(this.prevData, 'data.end', null)
605
+ sourceData.silenceDuration = (_.isFinite(start) && _.isFinite(prevEnd)) ? (start - prevEnd) : (_.isFinite(start) ? start : null)
396
606
  sourceData.voiceDuration = parsedData.data.end - parsedData.data.start
397
607
  botMsg = Object.assign({}, botMsg, { sourceData })
398
608
  }
@@ -413,7 +623,9 @@ class BotiumConnectorVoip {
413
623
  this.firstMsg = false
414
624
  } else {
415
625
  const sourceData = parsedData
416
- sourceData.silenceDuration = parsedData.data.start - this.prevData.data.end
626
+ const start = _.get(parsedData, 'data.start', null)
627
+ const prevEnd = _.get(this.prevData, 'data.end', null)
628
+ sourceData.silenceDuration = (_.isFinite(start) && _.isFinite(prevEnd)) ? (start - prevEnd) : (_.isFinite(start) ? start : null)
417
629
  sourceData.voiceDuration = parsedData.data.end - parsedData.data.start
418
630
  botMsg = Object.assign({}, botMsg, { sourceData })
419
631
  }
@@ -425,20 +637,32 @@ class BotiumConnectorVoip {
425
637
  }
426
638
  this.botMsgs = []
427
639
  }
428
- if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'JOIN' || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'CONCAT' || !_.isNil(this._getJoinLogicHook(this.convoStep))) {
640
+ 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' || (!_.isNil(this._getJoinLogicHook(this.convoStep)))) {
429
641
  const botMsg = { messageText: parsedData.data.message, sourceData: parsedData }
430
642
  this.prevData = parsedData
431
643
  if (successfulConfidenceScore) {
432
644
  this.botMsgs.push(botMsg)
645
+ const sttHandling = this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING]
646
+ const isPsst = sttHandling === 'PSST'
647
+ const toJoinTimeoutMs = (ms) => {
648
+ const parsed = parseInt(ms, 10)
649
+ if (!_.isFinite(parsed) || parsed <= 0) return null
650
+ return isPsst ? Math.max(0, parsed - 500) : parsed
651
+ }
652
+ const joinLogicHook = this._getJoinLogicHook(this.convoStep)
653
+ let joinTimeoutMs = toJoinTimeoutMs(_.get(joinLogicHook, 'args[0]'))
654
+ if (!_.isFinite(joinTimeoutMs) || joinTimeoutMs <= 0) {
655
+ joinTimeoutMs = toJoinTimeoutMs(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT])
656
+ }
433
657
  this.silenceTimeout = setTimeout(() => {
434
658
  if (this.botMsgs.length > 0) {
435
- debug('Silence Duration Timeout (PSST):', (this._getJoinLogicHook(this.convoStep) && parseInt(this._getJoinLogicHook(this.convoStep).args[0])) || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT], 'ms')
659
+ debug('Silence Duration Timeout (JOIN/PSST):', joinTimeoutMs, 'ms')
436
660
  sendBotMsg(joinBotMsg(this.botMsgs, this.joinLastPrevMsg))
437
661
  this.firstMsg = false
438
662
  this.joinLastPrevMsg = this.botMsgs[this.botMsgs.length - 1]
439
663
  this.botMsgs = []
440
664
  }
441
- }, (this._getJoinLogicHook(this.convoStep) && parseInt(this._getJoinLogicHook(this.convoStep).args[0])) || this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_TIMEOUT])
665
+ }, joinTimeoutMs || 0)
442
666
  }
443
667
  }
444
668
  }
@@ -451,8 +675,24 @@ class BotiumConnectorVoip {
451
675
 
452
676
  async UserSays (msg) {
453
677
  debug('UserSays called')
454
-
455
- debug(msg)
678
+ // Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
679
+ try {
680
+ const safeLog = {
681
+ messageText: msg && msg.messageText ? String(msg.messageText).substring(0, 200) : undefined,
682
+ buttons: msg && Array.isArray(msg.buttons) ? msg.buttons.length : 0,
683
+ hasMedia: !!(msg && msg.media && msg.media.length > 0),
684
+ mediaMimeType: msg && msg.media && msg.media[0] ? msg.media[0].mimeType : undefined,
685
+ mediaSize: msg && msg.media && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined,
686
+ attachments: msg && Array.isArray(msg.attachments) ? msg.attachments.map(a => ({
687
+ name: a && (a.name || a.title),
688
+ mimeType: a && a.mimeType,
689
+ base64Len: a && a.base64 ? a.base64.length : 0
690
+ })) : []
691
+ }
692
+ debug(safeLog)
693
+ } catch (err) {
694
+ debug(`UserSays: failed to build safe log: ${err.message || err}`)
695
+ }
456
696
 
457
697
  if (!msg.attachments) {
458
698
  msg.attachments = []
@@ -468,24 +708,33 @@ class BotiumConnectorVoip {
468
708
  })
469
709
  this.ws.send(request)
470
710
  } else if (msg && msg.messageText) {
471
- if (!this.axiosTtsParams) reject(new Error('TTS not configured, only audio input supported'))
472
- if (this.axiosTtsParams) {
473
- const ttsRequest = {
474
- ...this.axiosTtsParams,
475
- params: {
476
- ...(this.axiosTtsParams.params || {}),
477
- text: msg.messageText
478
- },
479
- data: this._getBody(Capabilities.VOIP_TTS_BODY),
480
- responseType: 'arraybuffer'
711
+ // Check for DTMF tag in messageText: <DTMF>1234</DTMF>
712
+ const dtmfMatch = msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i)
713
+ if (dtmfMatch && dtmfMatch[1]) {
714
+ const digits = dtmfMatch[1]
715
+ debug(`Sending DTMF from messageText: ${digits}`)
716
+ const request = JSON.stringify({
717
+ METHOD: 'sendDtmf',
718
+ digits,
719
+ sessionId: this.sessionId
720
+ })
721
+ this.ws.send(request)
722
+ return resolve()
723
+ }
724
+ if (!this.axiosTtsParams) {
725
+ if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
726
+ return reject(new Error('TTS not configured, only audio input supported'))
481
727
  }
728
+ } else {
729
+ const ttsRequest = this._buildTtsRequest(msg.messageText)
730
+ if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'))
482
731
  msg.sourceData = ttsRequest
483
732
 
484
- let ttsResponse = null
733
+ let ttsResult = null
485
734
  try {
486
- ttsResponse = await axios(ttsRequest)
735
+ ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText)
487
736
  } catch (err) {
488
- reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`))
737
+ return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`))
489
738
  }
490
739
  if (msg && msg.messageText && msg.messageText.length > 0) {
491
740
  const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY))
@@ -496,22 +745,28 @@ class BotiumConnectorVoip {
496
745
  apiKey
497
746
  })
498
747
  }
499
- if (Buffer.isBuffer(ttsResponse.data)) {
500
- duration = ttsResponse.headers['content-duration']
748
+ if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
749
+ duration = parseFloat(ttsResult.duration) || 0
750
+ const b64Buffer = ttsResult.buffer.toString('base64')
501
751
  const request = JSON.stringify({
502
752
  METHOD: 'sendAudio',
503
753
  PESQ: false,
504
754
  sessionId: this.sessionId,
505
- b64_buffer: ttsResponse.data.toString('base64')
755
+ b64_buffer: b64Buffer
506
756
  })
757
+ if (this._lastBotSaysQueuedAt) {
758
+ const latencyMs = Date.now() - this._lastBotSaysQueuedAt
759
+ const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>'
760
+ debug(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`)
761
+ }
507
762
  msg.attachments.push({
508
763
  name: 'tts.wav',
509
764
  mimeType: 'audio/wav',
510
- base64: ttsResponse.data.toString('base64')
765
+ base64: b64Buffer
511
766
  })
512
767
  this.ws.send(request)
513
768
  } else {
514
- reject(new Error(`TTS failed, response is: ${this._getAxiosShortenedOutput(ttsResponse.data)}`))
769
+ return reject(new Error('TTS failed, response is empty'))
515
770
  }
516
771
  }
517
772
  }
@@ -521,6 +776,11 @@ class BotiumConnectorVoip {
521
776
  sessionId: this.sessionId,
522
777
  b64_buffer: msg.media[0].buffer.toString('base64')
523
778
  })
779
+ if (this._lastBotSaysQueuedAt) {
780
+ const latencyMs = Date.now() - this._lastBotSaysQueuedAt
781
+ const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>'
782
+ debug(`Latency (bot says -> sendAudio/MEDIA): ${latencyMs} ms (last bot: ${botText})`)
783
+ }
524
784
  this.ws.send(request)
525
785
  msg.attachments.push({
526
786
  name: msg.media[0].mediaUri,
@@ -547,6 +807,11 @@ class BotiumConnectorVoip {
547
807
 
548
808
  async Stop () {
549
809
  debug(`${this.sessionId} - Stop called`)
810
+ this.stopCalled = true
811
+ if (this.silenceTimeout) {
812
+ clearTimeout(this.silenceTimeout)
813
+ this.silenceTimeout = null
814
+ }
550
815
  if (this.ws && this.ws.readyState !== WebSocket.CLOSED) {
551
816
  const request = JSON.stringify({
552
817
  METHOD: 'stopCall',
@@ -554,9 +819,12 @@ class BotiumConnectorVoip {
554
819
  })
555
820
  this.ws.send(request)
556
821
  await new Promise(resolve => {
557
- setTimeout(resolve, 100000)
558
- setInterval(() => {
822
+ const stopTimeout = setTimeout(resolve, 100000)
823
+ this._stopWaitInterval = setInterval(() => {
559
824
  if (this.end) {
825
+ clearTimeout(stopTimeout)
826
+ clearInterval(this._stopWaitInterval)
827
+ this._stopWaitInterval = null
560
828
  this.wsOpened = false
561
829
  this.ws = null
562
830
  this.end = false
@@ -575,6 +843,134 @@ class BotiumConnectorVoip {
575
843
  }
576
844
  }
577
845
 
846
+ _isTtsCacheEnabled () {
847
+ return this.ttsCacheEnabled && this.ttsCacheMaxEntries > 0
848
+ }
849
+
850
+ _touchTtsCache (cacheKey, entry) {
851
+ if (!this._isTtsCacheEnabled()) return
852
+ this.ttsCache.delete(cacheKey)
853
+ this.ttsCache.set(cacheKey, entry)
854
+ }
855
+
856
+ _setTtsCacheEntry (cacheKey, entry) {
857
+ if (!this._isTtsCacheEnabled()) return
858
+ this.ttsCache.set(cacheKey, entry)
859
+ this._trimTtsCache()
860
+ }
861
+
862
+ _deleteTtsCacheEntry (cacheKey) {
863
+ if (this.ttsCache) {
864
+ this.ttsCache.delete(cacheKey)
865
+ }
866
+ }
867
+
868
+ _trimTtsCache () {
869
+ if (!this._isTtsCacheEnabled()) return
870
+ while (this.ttsCache.size > this.ttsCacheMaxEntries) {
871
+ const oldestKey = this.ttsCache.keys().next().value
872
+ this.ttsCache.delete(oldestKey)
873
+ }
874
+ }
875
+
876
+ _shouldPrefetchTtsText (text) {
877
+ if (!_.isString(text)) return false
878
+ if (!text.trim()) return false
879
+ return !/\$[A-Za-z]\w*/.test(text)
880
+ }
881
+
882
+ _maybePrefetchTts (convoStep) {
883
+ if (!this.ttsPrefetchEnabled || !this._isTtsCacheEnabled()) return
884
+ if (!convoStep || convoStep.sender !== 'me') return
885
+ if (!this._shouldPrefetchTtsText(convoStep.messageText)) return
886
+ const ttsRequest = this._buildTtsRequest(convoStep.messageText)
887
+ if (!ttsRequest) return
888
+ this._getTtsAudio(ttsRequest, convoStep.messageText).catch(err => {
889
+ debug(`TTS prefetch failed - ${this._getAxiosErrOutput(err)}`)
890
+ })
891
+ }
892
+
893
+ _buildTtsRequest (text) {
894
+ if (!this.axiosTtsParams) return null
895
+ return {
896
+ ...this.axiosTtsParams,
897
+ params: {
898
+ ...(this.axiosTtsParams.params || {}),
899
+ text
900
+ },
901
+ data: this._getBody(Capabilities.VOIP_TTS_BODY),
902
+ responseType: 'arraybuffer'
903
+ }
904
+ }
905
+
906
+ _getTtsCacheKey (ttsRequest) {
907
+ const cacheKeyObj = {
908
+ url: ttsRequest.url,
909
+ method: ttsRequest.method,
910
+ params: ttsRequest.params,
911
+ data: ttsRequest.data
912
+ }
913
+ try {
914
+ return JSON.stringify(cacheKeyObj)
915
+ } catch (err) {
916
+ return `${ttsRequest.url}|${ttsRequest.method}|${_.get(ttsRequest, 'params.text', '')}`
917
+ }
918
+ }
919
+
920
+ async _fetchTts (ttsRequest, text) {
921
+ const ttsStart = Date.now()
922
+ const ttsResponse = await axios(ttsRequest)
923
+ const ttsMs = Date.now() - ttsStart
924
+ const textLen = _.isString(text) ? text.length : 0
925
+ debug(`TTS response ${ttsMs} ms (chars: ${textLen})`)
926
+
927
+ if (!ttsResponse || !Buffer.isBuffer(ttsResponse.data)) {
928
+ throw new Error(`TTS failed, response is: ${this._getAxiosShortenedOutput(ttsResponse && ttsResponse.data)}`)
929
+ }
930
+ const durationHeader = ttsResponse.headers && ttsResponse.headers['content-duration']
931
+ return {
932
+ buffer: ttsResponse.data,
933
+ duration: durationHeader
934
+ }
935
+ }
936
+
937
+ async _getTtsAudio (ttsRequest, text) {
938
+ if (!ttsRequest) throw new Error('TTS request not configured')
939
+ const cacheKey = this._getTtsCacheKey(ttsRequest)
940
+
941
+ if (this._isTtsCacheEnabled()) {
942
+ const cached = this.ttsCache.get(cacheKey)
943
+ if (cached) {
944
+ if (cached.state === 'ready') {
945
+ this._touchTtsCache(cacheKey, cached)
946
+ debug(`TTS cache hit (chars: ${_.isString(text) ? text.length : 0})`)
947
+ return { buffer: cached.buffer, duration: cached.duration }
948
+ }
949
+ if (cached.state === 'pending') {
950
+ return cached.promise
951
+ }
952
+ }
953
+ }
954
+
955
+ const fetchPromise = this._fetchTts(ttsRequest, text)
956
+ .then(result => {
957
+ if (this._isTtsCacheEnabled()) {
958
+ this._setTtsCacheEntry(cacheKey, { state: 'ready', buffer: result.buffer, duration: result.duration })
959
+ }
960
+ return result
961
+ })
962
+ .catch(err => {
963
+ this._deleteTtsCacheEntry(cacheKey)
964
+ throw err
965
+ })
966
+
967
+ if (this._isTtsCacheEnabled()) {
968
+ this._setTtsCacheEntry(cacheKey, { state: 'pending', promise: fetchPromise })
969
+ }
970
+
971
+ return fetchPromise
972
+ }
973
+
578
974
  _getParams (capParams) {
579
975
  if (this.caps[capParams]) {
580
976
  if (_.isString(this.caps[capParams])) return JSON.parse(this.caps[capParams])