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.
|
@@ -21,6 +21,18 @@ var debug__default = /*#__PURE__*/_interopDefaultLegacy(debug$4);
|
|
|
21
21
|
var musicMetadata__default = /*#__PURE__*/_interopDefaultLegacy(musicMetadata);
|
|
22
22
|
|
|
23
23
|
const debug$3 = debug__default["default"]('botium-connector-voip');
|
|
24
|
+
|
|
25
|
+
// Logging policy: info = rare, business-relevant lifecycle events (always visible).
|
|
26
|
+
// debug = high-frequency diagnostics (DEBUG=botium-connector-voip). warn = degraded but continuing.
|
|
27
|
+
// error = abort/failure. No secrets in info; STT text only as length or truncated in info.
|
|
28
|
+
|
|
29
|
+
const _info = (event, data) => {
|
|
30
|
+
const parts = Object.entries({
|
|
31
|
+
event,
|
|
32
|
+
...data
|
|
33
|
+
}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `${k}=${typeof v === 'string' && v.length > 80 ? `"${v.substring(0, 77)}..."` : JSON.stringify(v)}`);
|
|
34
|
+
console.info(`[botium-connector-voip] ${parts.join(' ')}`);
|
|
35
|
+
};
|
|
24
36
|
const Capabilities = {
|
|
25
37
|
VOIP_STT_URL_STREAM: 'VOIP_STT_URL_STREAM',
|
|
26
38
|
VOIP_STT_PARAMS_STREAM: 'VOIP_STT_PARAMS_STREAM',
|
|
@@ -68,7 +80,8 @@ const Capabilities = {
|
|
|
68
80
|
VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: 'VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE',
|
|
69
81
|
VOIP_SILENCE_DURATION_TIMEOUT_START: 'VOIP_SILENCE_DURATION_TIMEOUT_START',
|
|
70
82
|
VOIP_STT_CONFIDENCE_THRESHOLD: 'VOIP_STT_CONFIDENCE_THRESHOLD',
|
|
71
|
-
VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER'
|
|
83
|
+
VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER',
|
|
84
|
+
VOIP_USER_INPUT_PREFER_VOICE: 'VOIP_USER_INPUT_PREFER_VOICE'
|
|
72
85
|
};
|
|
73
86
|
const Defaults = {
|
|
74
87
|
VOIP_STT_METHOD: 'POST',
|
|
@@ -90,7 +103,8 @@ const Defaults = {
|
|
|
90
103
|
VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: false,
|
|
91
104
|
VOIP_STT_CONFIDENCE_THRESHOLD: 0.5,
|
|
92
105
|
VOIP_USE_GLOBAL_VOIP_WORKER: false,
|
|
93
|
-
VOIP_SIP_PROTOCOL: 'TCP'
|
|
106
|
+
VOIP_SIP_PROTOCOL: 'TCP',
|
|
107
|
+
VOIP_USER_INPUT_PREFER_VOICE: true
|
|
94
108
|
};
|
|
95
109
|
const TTS_HTTP_AGENT = new http__default["default"].Agent({
|
|
96
110
|
keepAlive: true
|
|
@@ -213,7 +227,8 @@ class BotiumConnectorVoip {
|
|
|
213
227
|
};
|
|
214
228
|
let data = null;
|
|
215
229
|
let headers = null;
|
|
216
|
-
|
|
230
|
+
let httpInitRetries = 0;
|
|
231
|
+
const connectHttp = async retryIndex => {
|
|
217
232
|
retryIndex = retryIndex || 0;
|
|
218
233
|
try {
|
|
219
234
|
const workerUrl = this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_URL : this.caps[Capabilities.VOIP_WORKER_URL].replace('wss', 'https').replace('ws', 'http');
|
|
@@ -234,17 +249,24 @@ class BotiumConnectorVoip {
|
|
|
234
249
|
if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
|
|
235
250
|
throw new Error(`Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`);
|
|
236
251
|
}
|
|
252
|
+
httpInitRetries = retryIndex + 1;
|
|
237
253
|
// Retry after the defined timeout
|
|
238
254
|
await new Promise(resolve => setTimeout(resolve, this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]));
|
|
239
255
|
|
|
240
256
|
// Retry the connection
|
|
241
|
-
await
|
|
257
|
+
await connectHttp(retryIndex + 1);
|
|
242
258
|
}
|
|
243
259
|
};
|
|
244
|
-
await
|
|
260
|
+
await connectHttp();
|
|
261
|
+
if (httpInitRetries > 0) {
|
|
262
|
+
_info('connected_after_retries', {
|
|
263
|
+
phase: 'initCall',
|
|
264
|
+
retries: httpInitRetries
|
|
265
|
+
});
|
|
266
|
+
}
|
|
245
267
|
return new Promise((resolve, reject) => {
|
|
246
268
|
const wsEndpoint = `${this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_URL : this.caps[Capabilities.VOIP_WORKER_URL]}/ws/${data.port}`;
|
|
247
|
-
const
|
|
269
|
+
const connectWs = retryIndex => {
|
|
248
270
|
retryIndex = retryIndex || 0;
|
|
249
271
|
return new Promise((resolve, reject) => {
|
|
250
272
|
if ('set-cookie' in headers) {
|
|
@@ -257,17 +279,23 @@ class BotiumConnectorVoip {
|
|
|
257
279
|
this.ws = new ws__default["default"](wsEndpoint);
|
|
258
280
|
}
|
|
259
281
|
this.ws.on('open', () => {
|
|
260
|
-
resolve();
|
|
282
|
+
resolve(retryIndex);
|
|
261
283
|
});
|
|
262
284
|
this.ws.on('error', () => {
|
|
263
285
|
if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
|
|
264
286
|
reject(new Error(`Websocket connection failed to ${wsEndpoint}`));
|
|
265
287
|
}
|
|
266
|
-
setTimeout(() =>
|
|
288
|
+
setTimeout(() => connectWs(retryIndex + 1).then(resolve).catch(reject), this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]);
|
|
267
289
|
});
|
|
268
290
|
});
|
|
269
291
|
};
|
|
270
|
-
|
|
292
|
+
connectWs().then(wsRetries => {
|
|
293
|
+
if (wsRetries > 0) {
|
|
294
|
+
_info('connected_after_retries', {
|
|
295
|
+
phase: 'websocket',
|
|
296
|
+
retries: wsRetries
|
|
297
|
+
});
|
|
298
|
+
}
|
|
271
299
|
if (!lodash__default["default"].isArray(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
|
|
272
300
|
if (lodash__default["default"].isEmpty(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
|
|
273
301
|
this.caps[Capabilities.VOIP_ICE_STUN_SERVERS] = [];
|
|
@@ -309,6 +337,7 @@ class BotiumConnectorVoip {
|
|
|
309
337
|
});
|
|
310
338
|
this.silence = null;
|
|
311
339
|
this.msgCount = 0;
|
|
340
|
+
this.sttPartialCount = 0;
|
|
312
341
|
this.firstMsg = true;
|
|
313
342
|
this.firstSttInfoReceived = false;
|
|
314
343
|
this.silenceTimeout = null;
|
|
@@ -453,6 +482,10 @@ class BotiumConnectorVoip {
|
|
|
453
482
|
};
|
|
454
483
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
|
|
455
484
|
this.sessionId = parsedData.voipConfig.sessionId;
|
|
485
|
+
_info('callinfo_initialized', {
|
|
486
|
+
sessionId: this.sessionId,
|
|
487
|
+
sttHandling: this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING]
|
|
488
|
+
});
|
|
456
489
|
}
|
|
457
490
|
|
|
458
491
|
// if sessionId is not the same as the one in the callinfo, return
|
|
@@ -467,9 +500,17 @@ class BotiumConnectorVoip {
|
|
|
467
500
|
reject(new Error('Error: Sip Registration failed'));
|
|
468
501
|
}
|
|
469
502
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'connected') {
|
|
503
|
+
_info('callinfo_connected', {
|
|
504
|
+
sessionId: this.sessionId
|
|
505
|
+
});
|
|
470
506
|
resolve();
|
|
471
507
|
}
|
|
472
508
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'disconnected') {
|
|
509
|
+
_info('callinfo_disconnected', {
|
|
510
|
+
sessionId: this.sessionId,
|
|
511
|
+
connectDurationSec: parsedData.connectDuration || null,
|
|
512
|
+
sttPartialCount: this.sttPartialCount
|
|
513
|
+
});
|
|
473
514
|
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY));
|
|
474
515
|
if (parsedData.connectDuration && parsedData.connectDuration > 0) {
|
|
475
516
|
this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
|
|
@@ -499,7 +540,13 @@ class BotiumConnectorVoip {
|
|
|
499
540
|
if (parsedData && parsedData.type === 'fullRecordEnd') {
|
|
500
541
|
const tail = _extractFullRecordBase64(parsedData);
|
|
501
542
|
if (tail) this.fullRecord = (this.fullRecord || '') + tail;
|
|
543
|
+
const base64Len = (this.fullRecord || tail || '').length;
|
|
502
544
|
emitFullRecordAttachment(this.fullRecord || tail);
|
|
545
|
+
_info('recording_attached', {
|
|
546
|
+
sessionId: this.sessionId,
|
|
547
|
+
source: 'fullRecordEnd',
|
|
548
|
+
base64Len
|
|
549
|
+
});
|
|
503
550
|
this.end = true;
|
|
504
551
|
}
|
|
505
552
|
if (parsedData && parsedData.type === 'silence') {
|
|
@@ -512,10 +559,17 @@ class BotiumConnectorVoip {
|
|
|
512
559
|
}
|
|
513
560
|
if (parsedData && parsedData.type === 'fullRecord') {
|
|
514
561
|
// Non-chunked full record
|
|
515
|
-
|
|
562
|
+
const base64 = _extractFullRecordBase64(parsedData);
|
|
563
|
+
emitFullRecordAttachment(base64);
|
|
564
|
+
_info('recording_attached', {
|
|
565
|
+
sessionId: this.sessionId,
|
|
566
|
+
source: 'fullRecord',
|
|
567
|
+
base64Len: (base64 || '').length
|
|
568
|
+
});
|
|
516
569
|
this.end = true;
|
|
517
570
|
}
|
|
518
571
|
if (parsedData && parsedData.data && parsedData.data.final === false) {
|
|
572
|
+
this.sttPartialCount++;
|
|
519
573
|
if (this.silenceTimeout) {
|
|
520
574
|
clearTimeout(this.silenceTimeout);
|
|
521
575
|
}
|
|
@@ -562,7 +616,18 @@ class BotiumConnectorVoip {
|
|
|
562
616
|
if (parsedData && parsedData.data && parsedData.data.type === 'stt' && parsedData.data.final) {
|
|
563
617
|
const confidenceThreshold = this._getConfidenceScoreLogicHook(this.convoStep) && this._getConfidenceScoreLogicHook(this.convoStep).args[0] || this.caps[Capabilities.VOIP_STT_CONFIDENCE_THRESHOLD];
|
|
564
618
|
const successfulConfidenceScore = this._getConfidenceScore(parsedData) >= confidenceThreshold;
|
|
619
|
+
const msgText = parsedData.data.message || '';
|
|
620
|
+
const msgLen = typeof msgText === 'string' ? msgText.length : 0;
|
|
621
|
+
const msgPreview = typeof msgText === 'string' ? msgText.trim().substring(0, 80) : '';
|
|
565
622
|
debug$3(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`);
|
|
623
|
+
_info('stt_final', {
|
|
624
|
+
sessionId: this.sessionId,
|
|
625
|
+
message: msgPreview,
|
|
626
|
+
messageLength: msgLen,
|
|
627
|
+
confidence: this._getConfidenceScore(parsedData),
|
|
628
|
+
threshold: confidenceThreshold,
|
|
629
|
+
accepted: successfulConfidenceScore
|
|
630
|
+
});
|
|
566
631
|
// ORIGINAL: emit final message immediately, unless join hooks are active.
|
|
567
632
|
if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep))) {
|
|
568
633
|
let botMsg = {
|
|
@@ -663,6 +728,19 @@ class BotiumConnectorVoip {
|
|
|
663
728
|
}
|
|
664
729
|
async UserSays(msg) {
|
|
665
730
|
debug$3('UserSays called');
|
|
731
|
+
const hasText = !!(msg && msg.messageText);
|
|
732
|
+
const hasVoiceMedia = !!(msg && msg.media && msg.media.length > 0 && msg.media[0].buffer);
|
|
733
|
+
const hasDtmf = !!(msg && msg.buttons && msg.buttons.length > 0);
|
|
734
|
+
const dtmfMatch = msg && msg.messageText && msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
|
|
735
|
+
const inputType = hasDtmf || dtmfMatch ? 'dtmf' : hasText && hasVoiceMedia ? 'mixed' : hasText ? 'text' : hasVoiceMedia ? 'media' : 'unknown';
|
|
736
|
+
const msgPreview = hasText && msg.messageText ? String(msg.messageText).trim().substring(0, 80) : '';
|
|
737
|
+
_info('user_says', {
|
|
738
|
+
sessionId: this.sessionId,
|
|
739
|
+
inputType,
|
|
740
|
+
message: msgPreview || undefined,
|
|
741
|
+
messageLength: hasText && msg.messageText ? msg.messageText.length : undefined,
|
|
742
|
+
mediaSize: hasVoiceMedia && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined
|
|
743
|
+
});
|
|
666
744
|
// Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
|
|
667
745
|
try {
|
|
668
746
|
const safeLog = {
|
|
@@ -687,6 +765,10 @@ class BotiumConnectorVoip {
|
|
|
687
765
|
return new Promise((resolve, reject) => {
|
|
688
766
|
setTimeout(async () => {
|
|
689
767
|
let duration = 0;
|
|
768
|
+
const preferVoiceCapRaw = this.caps[Capabilities.VOIP_USER_INPUT_PREFER_VOICE];
|
|
769
|
+
const preferVoice = !!preferVoiceCapRaw;
|
|
770
|
+
const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
|
|
771
|
+
debug$3(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`);
|
|
690
772
|
if (msg && msg.buttons && msg.buttons.length > 0) {
|
|
691
773
|
const request = JSON.stringify({
|
|
692
774
|
METHOD: 'sendDtmf',
|
|
@@ -708,55 +790,61 @@ class BotiumConnectorVoip {
|
|
|
708
790
|
this.ws.send(request);
|
|
709
791
|
return resolve();
|
|
710
792
|
}
|
|
711
|
-
if (!
|
|
712
|
-
if (!
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
} else {
|
|
716
|
-
const ttsRequest = this._buildTtsRequest(msg.messageText);
|
|
717
|
-
if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
|
|
718
|
-
msg.sourceData = ttsRequest;
|
|
719
|
-
let ttsResult = null;
|
|
720
|
-
try {
|
|
721
|
-
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
|
|
722
|
-
} catch (err) {
|
|
723
|
-
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
|
|
724
|
-
}
|
|
725
|
-
if (msg && msg.messageText && msg.messageText.length > 0) {
|
|
726
|
-
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
|
|
727
|
-
this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
|
|
728
|
-
type: lodash__default["default"].isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
|
|
729
|
-
metricName: 'consumption.e2e.voip.tts.characters',
|
|
730
|
-
transactions: msg.messageText.length,
|
|
731
|
-
apiKey
|
|
732
|
-
});
|
|
733
|
-
}
|
|
734
|
-
if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
|
|
735
|
-
duration = parseFloat(ttsResult.duration) || 0;
|
|
736
|
-
const b64Buffer = ttsResult.buffer.toString('base64');
|
|
737
|
-
const request = JSON.stringify({
|
|
738
|
-
METHOD: 'sendAudio',
|
|
739
|
-
PESQ: false,
|
|
740
|
-
sessionId: this.sessionId,
|
|
741
|
-
b64_buffer: b64Buffer
|
|
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})`);
|
|
793
|
+
if (!skipTtsForMixedInput) {
|
|
794
|
+
if (!this.axiosTtsParams) {
|
|
795
|
+
if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
|
|
796
|
+
return reject(new Error('TTS not configured, only audio input supported'));
|
|
747
797
|
}
|
|
748
|
-
msg.attachments.push({
|
|
749
|
-
name: 'tts.wav',
|
|
750
|
-
mimeType: 'audio/wav',
|
|
751
|
-
base64: b64Buffer
|
|
752
|
-
});
|
|
753
|
-
this.ws.send(request);
|
|
754
798
|
} else {
|
|
755
|
-
|
|
799
|
+
debug$3('UserSays routing: executing TTS branch');
|
|
800
|
+
const ttsRequest = this._buildTtsRequest(msg.messageText);
|
|
801
|
+
if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
|
|
802
|
+
msg.sourceData = ttsRequest;
|
|
803
|
+
let ttsResult = null;
|
|
804
|
+
try {
|
|
805
|
+
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
|
|
806
|
+
} catch (err) {
|
|
807
|
+
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
|
|
808
|
+
}
|
|
809
|
+
if (msg && msg.messageText && msg.messageText.length > 0) {
|
|
810
|
+
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
|
|
811
|
+
this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
|
|
812
|
+
type: lodash__default["default"].isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
|
|
813
|
+
metricName: 'consumption.e2e.voip.tts.characters',
|
|
814
|
+
transactions: msg.messageText.length,
|
|
815
|
+
apiKey
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
|
|
819
|
+
duration = parseFloat(ttsResult.duration) || 0;
|
|
820
|
+
const b64Buffer = ttsResult.buffer.toString('base64');
|
|
821
|
+
const request = JSON.stringify({
|
|
822
|
+
METHOD: 'sendAudio',
|
|
823
|
+
PESQ: false,
|
|
824
|
+
sessionId: this.sessionId,
|
|
825
|
+
b64_buffer: b64Buffer
|
|
826
|
+
});
|
|
827
|
+
if (this._lastBotSaysQueuedAt) {
|
|
828
|
+
const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
|
|
829
|
+
const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
|
|
830
|
+
debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
|
|
831
|
+
}
|
|
832
|
+
msg.attachments.push({
|
|
833
|
+
name: 'tts.wav',
|
|
834
|
+
mimeType: 'audio/wav',
|
|
835
|
+
base64: b64Buffer
|
|
836
|
+
});
|
|
837
|
+
this.ws.send(request);
|
|
838
|
+
} else {
|
|
839
|
+
return reject(new Error('TTS failed, response is empty'));
|
|
840
|
+
}
|
|
756
841
|
}
|
|
842
|
+
} else {
|
|
843
|
+
debug$3('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled');
|
|
757
844
|
}
|
|
758
845
|
}
|
|
759
846
|
if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
|
|
847
|
+
debug$3('UserSays routing: executing MEDIA branch');
|
|
760
848
|
const request = JSON.stringify({
|
|
761
849
|
METHOD: 'sendAudio',
|
|
762
850
|
sessionId: this.sessionId,
|
|
@@ -1075,6 +1163,12 @@ var botiumConnectorVoip = {
|
|
|
1075
1163
|
label: 'Speech Synthesis Profile',
|
|
1076
1164
|
type: 'speechsynthesisprofile',
|
|
1077
1165
|
required: true
|
|
1166
|
+
}, {
|
|
1167
|
+
name: 'VOIP_USER_INPUT_PREFER_VOICE',
|
|
1168
|
+
label: 'Prefer voice media when both text and voice present',
|
|
1169
|
+
type: 'boolean',
|
|
1170
|
+
required: false,
|
|
1171
|
+
advanced: true
|
|
1078
1172
|
}]
|
|
1079
1173
|
},
|
|
1080
1174
|
PluginLogicHooks: {
|