botium-connector-voip 0.0.21 → 0.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/connector.js
CHANGED
|
@@ -6,6 +6,17 @@ const https = require('https')
|
|
|
6
6
|
const debug = require('debug')('botium-connector-voip')
|
|
7
7
|
const mm = require('music-metadata')
|
|
8
8
|
|
|
9
|
+
// Logging policy: info = rare, business-relevant lifecycle events (always visible).
|
|
10
|
+
// debug = high-frequency diagnostics (DEBUG=botium-connector-voip). warn = degraded but continuing.
|
|
11
|
+
// error = abort/failure. No secrets in info; STT text only as length or truncated in info.
|
|
12
|
+
|
|
13
|
+
const _info = (event, data) => {
|
|
14
|
+
const parts = Object.entries({ event, ...data })
|
|
15
|
+
.filter(([, v]) => v != null && v !== '')
|
|
16
|
+
.map(([k, v]) => `${k}=${typeof v === 'string' && v.length > 80 ? `"${v.substring(0, 77)}..."` : JSON.stringify(v)}`)
|
|
17
|
+
console.info(`[botium-connector-voip] ${parts.join(' ')}`)
|
|
18
|
+
}
|
|
19
|
+
|
|
9
20
|
const Capabilities = {
|
|
10
21
|
VOIP_STT_URL_STREAM: 'VOIP_STT_URL_STREAM',
|
|
11
22
|
VOIP_STT_PARAMS_STREAM: 'VOIP_STT_PARAMS_STREAM',
|
|
@@ -53,7 +64,8 @@ const Capabilities = {
|
|
|
53
64
|
VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: 'VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE',
|
|
54
65
|
VOIP_SILENCE_DURATION_TIMEOUT_START: 'VOIP_SILENCE_DURATION_TIMEOUT_START',
|
|
55
66
|
VOIP_STT_CONFIDENCE_THRESHOLD: 'VOIP_STT_CONFIDENCE_THRESHOLD',
|
|
56
|
-
VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER'
|
|
67
|
+
VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER',
|
|
68
|
+
VOIP_USER_INPUT_PREFER_VOICE: 'VOIP_USER_INPUT_PREFER_VOICE'
|
|
57
69
|
}
|
|
58
70
|
|
|
59
71
|
const Defaults = {
|
|
@@ -76,7 +88,8 @@ const Defaults = {
|
|
|
76
88
|
VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: false,
|
|
77
89
|
VOIP_STT_CONFIDENCE_THRESHOLD: 0.5,
|
|
78
90
|
VOIP_USE_GLOBAL_VOIP_WORKER: false,
|
|
79
|
-
VOIP_SIP_PROTOCOL: 'TCP'
|
|
91
|
+
VOIP_SIP_PROTOCOL: 'TCP',
|
|
92
|
+
VOIP_USER_INPUT_PREFER_VOICE: true
|
|
80
93
|
}
|
|
81
94
|
|
|
82
95
|
const TTS_HTTP_AGENT = new http.Agent({ keepAlive: true })
|
|
@@ -200,7 +213,8 @@ class BotiumConnectorVoip {
|
|
|
200
213
|
|
|
201
214
|
let data = null
|
|
202
215
|
let headers = null
|
|
203
|
-
|
|
216
|
+
let httpInitRetries = 0
|
|
217
|
+
const connectHttp = async (retryIndex) => {
|
|
204
218
|
retryIndex = retryIndex || 0
|
|
205
219
|
try {
|
|
206
220
|
const workerUrl = this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER]
|
|
@@ -223,18 +237,22 @@ class BotiumConnectorVoip {
|
|
|
223
237
|
if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
|
|
224
238
|
throw new Error(`Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`)
|
|
225
239
|
}
|
|
240
|
+
httpInitRetries = retryIndex + 1
|
|
226
241
|
// Retry after the defined timeout
|
|
227
242
|
await new Promise(resolve => setTimeout(resolve, this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]))
|
|
228
243
|
|
|
229
244
|
// Retry the connection
|
|
230
|
-
await
|
|
245
|
+
await connectHttp(retryIndex + 1)
|
|
231
246
|
}
|
|
232
247
|
}
|
|
233
|
-
await
|
|
248
|
+
await connectHttp()
|
|
249
|
+
if (httpInitRetries > 0) {
|
|
250
|
+
_info('connected_after_retries', { phase: 'initCall', retries: httpInitRetries })
|
|
251
|
+
}
|
|
234
252
|
|
|
235
253
|
return new Promise((resolve, reject) => {
|
|
236
254
|
const wsEndpoint = `${this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_URL : this.caps[Capabilities.VOIP_WORKER_URL]}/ws/${data.port}`
|
|
237
|
-
const
|
|
255
|
+
const connectWs = (retryIndex) => {
|
|
238
256
|
retryIndex = retryIndex || 0
|
|
239
257
|
return new Promise((resolve, reject) => {
|
|
240
258
|
if ('set-cookie' in headers) {
|
|
@@ -247,17 +265,20 @@ class BotiumConnectorVoip {
|
|
|
247
265
|
this.ws = new WebSocket(wsEndpoint)
|
|
248
266
|
}
|
|
249
267
|
this.ws.on('open', () => {
|
|
250
|
-
resolve()
|
|
268
|
+
resolve(retryIndex)
|
|
251
269
|
})
|
|
252
270
|
this.ws.on('error', () => {
|
|
253
271
|
if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
|
|
254
272
|
reject(new Error(`Websocket connection failed to ${wsEndpoint}`))
|
|
255
273
|
}
|
|
256
|
-
setTimeout(() =>
|
|
274
|
+
setTimeout(() => connectWs(retryIndex + 1).then(resolve).catch(reject), this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT])
|
|
257
275
|
})
|
|
258
276
|
})
|
|
259
277
|
}
|
|
260
|
-
|
|
278
|
+
connectWs().then((wsRetries) => {
|
|
279
|
+
if (wsRetries > 0) {
|
|
280
|
+
_info('connected_after_retries', { phase: 'websocket', retries: wsRetries })
|
|
281
|
+
}
|
|
261
282
|
if (!_.isArray(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
|
|
262
283
|
if (_.isEmpty(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
|
|
263
284
|
this.caps[Capabilities.VOIP_ICE_STUN_SERVERS] = []
|
|
@@ -301,6 +322,7 @@ class BotiumConnectorVoip {
|
|
|
301
322
|
|
|
302
323
|
this.silence = null
|
|
303
324
|
this.msgCount = 0
|
|
325
|
+
this.sttPartialCount = 0
|
|
304
326
|
this.firstMsg = true
|
|
305
327
|
this.firstSttInfoReceived = false
|
|
306
328
|
this.silenceTimeout = null
|
|
@@ -468,6 +490,10 @@ class BotiumConnectorVoip {
|
|
|
468
490
|
|
|
469
491
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
|
|
470
492
|
this.sessionId = parsedData.voipConfig.sessionId
|
|
493
|
+
_info('callinfo_initialized', {
|
|
494
|
+
sessionId: this.sessionId,
|
|
495
|
+
sttHandling: this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING]
|
|
496
|
+
})
|
|
471
497
|
}
|
|
472
498
|
|
|
473
499
|
// if sessionId is not the same as the one in the callinfo, return
|
|
@@ -485,10 +511,16 @@ class BotiumConnectorVoip {
|
|
|
485
511
|
}
|
|
486
512
|
|
|
487
513
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'connected') {
|
|
514
|
+
_info('callinfo_connected', { sessionId: this.sessionId })
|
|
488
515
|
resolve()
|
|
489
516
|
}
|
|
490
517
|
|
|
491
518
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'disconnected') {
|
|
519
|
+
_info('callinfo_disconnected', {
|
|
520
|
+
sessionId: this.sessionId,
|
|
521
|
+
connectDurationSec: parsedData.connectDuration || null,
|
|
522
|
+
sttPartialCount: this.sttPartialCount
|
|
523
|
+
})
|
|
492
524
|
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY))
|
|
493
525
|
if (parsedData.connectDuration && parsedData.connectDuration > 0) {
|
|
494
526
|
this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
|
|
@@ -519,7 +551,9 @@ class BotiumConnectorVoip {
|
|
|
519
551
|
if (parsedData && parsedData.type === 'fullRecordEnd') {
|
|
520
552
|
const tail = _extractFullRecordBase64(parsedData)
|
|
521
553
|
if (tail) this.fullRecord = (this.fullRecord || '') + tail
|
|
554
|
+
const base64Len = (this.fullRecord || tail || '').length
|
|
522
555
|
emitFullRecordAttachment(this.fullRecord || tail)
|
|
556
|
+
_info('recording_attached', { sessionId: this.sessionId, source: 'fullRecordEnd', base64Len })
|
|
523
557
|
this.end = true
|
|
524
558
|
}
|
|
525
559
|
|
|
@@ -534,11 +568,14 @@ class BotiumConnectorVoip {
|
|
|
534
568
|
|
|
535
569
|
if (parsedData && parsedData.type === 'fullRecord') {
|
|
536
570
|
// Non-chunked full record
|
|
537
|
-
|
|
571
|
+
const base64 = _extractFullRecordBase64(parsedData)
|
|
572
|
+
emitFullRecordAttachment(base64)
|
|
573
|
+
_info('recording_attached', { sessionId: this.sessionId, source: 'fullRecord', base64Len: (base64 || '').length })
|
|
538
574
|
this.end = true
|
|
539
575
|
}
|
|
540
576
|
|
|
541
577
|
if (parsedData && parsedData.data && parsedData.data.final === false) {
|
|
578
|
+
this.sttPartialCount++
|
|
542
579
|
if (this.silenceTimeout) {
|
|
543
580
|
clearTimeout(this.silenceTimeout)
|
|
544
581
|
}
|
|
@@ -586,7 +623,18 @@ class BotiumConnectorVoip {
|
|
|
586
623
|
if (parsedData && parsedData.data && parsedData.data.type === 'stt' && parsedData.data.final) {
|
|
587
624
|
const confidenceThreshold = ((this._getConfidenceScoreLogicHook(this.convoStep) && this._getConfidenceScoreLogicHook(this.convoStep).args[0]) || this.caps[Capabilities.VOIP_STT_CONFIDENCE_THRESHOLD])
|
|
588
625
|
const successfulConfidenceScore = this._getConfidenceScore(parsedData) >= confidenceThreshold
|
|
626
|
+
const msgText = parsedData.data.message || ''
|
|
627
|
+
const msgLen = typeof msgText === 'string' ? msgText.length : 0
|
|
628
|
+
const msgPreview = typeof msgText === 'string' ? msgText.trim().substring(0, 80) : ''
|
|
589
629
|
debug(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`)
|
|
630
|
+
_info('stt_final', {
|
|
631
|
+
sessionId: this.sessionId,
|
|
632
|
+
message: msgPreview,
|
|
633
|
+
messageLength: msgLen,
|
|
634
|
+
confidence: this._getConfidenceScore(parsedData),
|
|
635
|
+
threshold: confidenceThreshold,
|
|
636
|
+
accepted: successfulConfidenceScore
|
|
637
|
+
})
|
|
590
638
|
// ORIGINAL: emit final message immediately, unless join hooks are active.
|
|
591
639
|
if (
|
|
592
640
|
(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && (_.isNil(this._getJoinLogicHook(this.convoStep))))
|
|
@@ -675,6 +723,19 @@ class BotiumConnectorVoip {
|
|
|
675
723
|
|
|
676
724
|
async UserSays (msg) {
|
|
677
725
|
debug('UserSays called')
|
|
726
|
+
const hasText = !!(msg && msg.messageText)
|
|
727
|
+
const hasVoiceMedia = !!(msg && msg.media && msg.media.length > 0 && msg.media[0].buffer)
|
|
728
|
+
const hasDtmf = !!(msg && msg.buttons && msg.buttons.length > 0)
|
|
729
|
+
const dtmfMatch = msg && msg.messageText && msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i)
|
|
730
|
+
const inputType = hasDtmf || dtmfMatch ? 'dtmf' : (hasText && hasVoiceMedia ? 'mixed' : hasText ? 'text' : hasVoiceMedia ? 'media' : 'unknown')
|
|
731
|
+
const msgPreview = hasText && msg.messageText ? String(msg.messageText).trim().substring(0, 80) : ''
|
|
732
|
+
_info('user_says', {
|
|
733
|
+
sessionId: this.sessionId,
|
|
734
|
+
inputType,
|
|
735
|
+
message: msgPreview || undefined,
|
|
736
|
+
messageLength: hasText && msg.messageText ? msg.messageText.length : undefined,
|
|
737
|
+
mediaSize: hasVoiceMedia && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined
|
|
738
|
+
})
|
|
678
739
|
// Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
|
|
679
740
|
try {
|
|
680
741
|
const safeLog = {
|
|
@@ -700,6 +761,11 @@ class BotiumConnectorVoip {
|
|
|
700
761
|
return new Promise((resolve, reject) => {
|
|
701
762
|
setTimeout(async () => {
|
|
702
763
|
let duration = 0
|
|
764
|
+
const preferVoiceCapRaw = this.caps[Capabilities.VOIP_USER_INPUT_PREFER_VOICE]
|
|
765
|
+
const preferVoice = !!preferVoiceCapRaw
|
|
766
|
+
const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia
|
|
767
|
+
debug(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`)
|
|
768
|
+
|
|
703
769
|
if (msg && msg.buttons && msg.buttons.length > 0) {
|
|
704
770
|
const request = JSON.stringify({
|
|
705
771
|
METHOD: 'sendDtmf',
|
|
@@ -721,56 +787,62 @@ class BotiumConnectorVoip {
|
|
|
721
787
|
this.ws.send(request)
|
|
722
788
|
return resolve()
|
|
723
789
|
}
|
|
724
|
-
if (!
|
|
725
|
-
if (!
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
} else {
|
|
729
|
-
const ttsRequest = this._buildTtsRequest(msg.messageText)
|
|
730
|
-
if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'))
|
|
731
|
-
msg.sourceData = ttsRequest
|
|
732
|
-
|
|
733
|
-
let ttsResult = null
|
|
734
|
-
try {
|
|
735
|
-
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText)
|
|
736
|
-
} catch (err) {
|
|
737
|
-
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`))
|
|
738
|
-
}
|
|
739
|
-
if (msg && msg.messageText && msg.messageText.length > 0) {
|
|
740
|
-
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY))
|
|
741
|
-
this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
|
|
742
|
-
type: _.isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
|
|
743
|
-
metricName: 'consumption.e2e.voip.tts.characters',
|
|
744
|
-
transactions: msg.messageText.length,
|
|
745
|
-
apiKey
|
|
746
|
-
})
|
|
747
|
-
}
|
|
748
|
-
if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
|
|
749
|
-
duration = parseFloat(ttsResult.duration) || 0
|
|
750
|
-
const b64Buffer = ttsResult.buffer.toString('base64')
|
|
751
|
-
const request = JSON.stringify({
|
|
752
|
-
METHOD: 'sendAudio',
|
|
753
|
-
PESQ: false,
|
|
754
|
-
sessionId: this.sessionId,
|
|
755
|
-
b64_buffer: b64Buffer
|
|
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})`)
|
|
790
|
+
if (!skipTtsForMixedInput) {
|
|
791
|
+
if (!this.axiosTtsParams) {
|
|
792
|
+
if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
|
|
793
|
+
return reject(new Error('TTS not configured, only audio input supported'))
|
|
761
794
|
}
|
|
762
|
-
msg.attachments.push({
|
|
763
|
-
name: 'tts.wav',
|
|
764
|
-
mimeType: 'audio/wav',
|
|
765
|
-
base64: b64Buffer
|
|
766
|
-
})
|
|
767
|
-
this.ws.send(request)
|
|
768
795
|
} else {
|
|
769
|
-
|
|
796
|
+
debug('UserSays routing: executing TTS branch')
|
|
797
|
+
const ttsRequest = this._buildTtsRequest(msg.messageText)
|
|
798
|
+
if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'))
|
|
799
|
+
msg.sourceData = ttsRequest
|
|
800
|
+
|
|
801
|
+
let ttsResult = null
|
|
802
|
+
try {
|
|
803
|
+
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText)
|
|
804
|
+
} catch (err) {
|
|
805
|
+
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`))
|
|
806
|
+
}
|
|
807
|
+
if (msg && msg.messageText && msg.messageText.length > 0) {
|
|
808
|
+
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY))
|
|
809
|
+
this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
|
|
810
|
+
type: _.isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
|
|
811
|
+
metricName: 'consumption.e2e.voip.tts.characters',
|
|
812
|
+
transactions: msg.messageText.length,
|
|
813
|
+
apiKey
|
|
814
|
+
})
|
|
815
|
+
}
|
|
816
|
+
if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
|
|
817
|
+
duration = parseFloat(ttsResult.duration) || 0
|
|
818
|
+
const b64Buffer = ttsResult.buffer.toString('base64')
|
|
819
|
+
const request = JSON.stringify({
|
|
820
|
+
METHOD: 'sendAudio',
|
|
821
|
+
PESQ: false,
|
|
822
|
+
sessionId: this.sessionId,
|
|
823
|
+
b64_buffer: b64Buffer
|
|
824
|
+
})
|
|
825
|
+
if (this._lastBotSaysQueuedAt) {
|
|
826
|
+
const latencyMs = Date.now() - this._lastBotSaysQueuedAt
|
|
827
|
+
const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>'
|
|
828
|
+
debug(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`)
|
|
829
|
+
}
|
|
830
|
+
msg.attachments.push({
|
|
831
|
+
name: 'tts.wav',
|
|
832
|
+
mimeType: 'audio/wav',
|
|
833
|
+
base64: b64Buffer
|
|
834
|
+
})
|
|
835
|
+
this.ws.send(request)
|
|
836
|
+
} else {
|
|
837
|
+
return reject(new Error('TTS failed, response is empty'))
|
|
838
|
+
}
|
|
770
839
|
}
|
|
840
|
+
} else {
|
|
841
|
+
debug('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled')
|
|
771
842
|
}
|
|
772
843
|
}
|
|
773
844
|
if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
|
|
845
|
+
debug('UserSays routing: executing MEDIA branch')
|
|
774
846
|
const request = JSON.stringify({
|
|
775
847
|
METHOD: 'sendAudio',
|
|
776
848
|
sessionId: this.sessionId,
|