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.
|
@@ -7,6 +7,18 @@ import debug$4 from 'debug';
|
|
|
7
7
|
import musicMetadata from 'music-metadata';
|
|
8
8
|
|
|
9
9
|
const debug$3 = debug$4('botium-connector-voip');
|
|
10
|
+
|
|
11
|
+
// Logging policy: info = rare, business-relevant lifecycle events (always visible).
|
|
12
|
+
// debug = high-frequency diagnostics (DEBUG=botium-connector-voip). warn = degraded but continuing.
|
|
13
|
+
// error = abort/failure. No secrets in info; STT text only as length or truncated in info.
|
|
14
|
+
|
|
15
|
+
const _info = (event, data) => {
|
|
16
|
+
const parts = Object.entries({
|
|
17
|
+
event,
|
|
18
|
+
...data
|
|
19
|
+
}).filter(([, v]) => v != null && v !== '').map(([k, v]) => `${k}=${typeof v === 'string' && v.length > 80 ? `"${v.substring(0, 77)}..."` : JSON.stringify(v)}`);
|
|
20
|
+
console.info(`[botium-connector-voip] ${parts.join(' ')}`);
|
|
21
|
+
};
|
|
10
22
|
const Capabilities = {
|
|
11
23
|
VOIP_STT_URL_STREAM: 'VOIP_STT_URL_STREAM',
|
|
12
24
|
VOIP_STT_PARAMS_STREAM: 'VOIP_STT_PARAMS_STREAM',
|
|
@@ -54,7 +66,8 @@ const Capabilities = {
|
|
|
54
66
|
VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: 'VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE',
|
|
55
67
|
VOIP_SILENCE_DURATION_TIMEOUT_START: 'VOIP_SILENCE_DURATION_TIMEOUT_START',
|
|
56
68
|
VOIP_STT_CONFIDENCE_THRESHOLD: 'VOIP_STT_CONFIDENCE_THRESHOLD',
|
|
57
|
-
VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER'
|
|
69
|
+
VOIP_USE_GLOBAL_VOIP_WORKER: 'VOIP_USE_GLOBAL_VOIP_WORKER',
|
|
70
|
+
VOIP_USER_INPUT_PREFER_VOICE: 'VOIP_USER_INPUT_PREFER_VOICE'
|
|
58
71
|
};
|
|
59
72
|
const Defaults = {
|
|
60
73
|
VOIP_STT_METHOD: 'POST',
|
|
@@ -76,7 +89,8 @@ const Defaults = {
|
|
|
76
89
|
VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: false,
|
|
77
90
|
VOIP_STT_CONFIDENCE_THRESHOLD: 0.5,
|
|
78
91
|
VOIP_USE_GLOBAL_VOIP_WORKER: false,
|
|
79
|
-
VOIP_SIP_PROTOCOL: 'TCP'
|
|
92
|
+
VOIP_SIP_PROTOCOL: 'TCP',
|
|
93
|
+
VOIP_USER_INPUT_PREFER_VOICE: true
|
|
80
94
|
};
|
|
81
95
|
const TTS_HTTP_AGENT = new http.Agent({
|
|
82
96
|
keepAlive: true
|
|
@@ -199,7 +213,8 @@ class BotiumConnectorVoip {
|
|
|
199
213
|
};
|
|
200
214
|
let data = null;
|
|
201
215
|
let headers = null;
|
|
202
|
-
|
|
216
|
+
let httpInitRetries = 0;
|
|
217
|
+
const connectHttp = async retryIndex => {
|
|
203
218
|
retryIndex = retryIndex || 0;
|
|
204
219
|
try {
|
|
205
220
|
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,17 +235,24 @@ class BotiumConnectorVoip {
|
|
|
220
235
|
if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
|
|
221
236
|
throw new Error(`Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`);
|
|
222
237
|
}
|
|
238
|
+
httpInitRetries = retryIndex + 1;
|
|
223
239
|
// Retry after the defined timeout
|
|
224
240
|
await new Promise(resolve => setTimeout(resolve, this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]));
|
|
225
241
|
|
|
226
242
|
// Retry the connection
|
|
227
|
-
await
|
|
243
|
+
await connectHttp(retryIndex + 1);
|
|
228
244
|
}
|
|
229
245
|
};
|
|
230
|
-
await
|
|
246
|
+
await connectHttp();
|
|
247
|
+
if (httpInitRetries > 0) {
|
|
248
|
+
_info('connected_after_retries', {
|
|
249
|
+
phase: 'initCall',
|
|
250
|
+
retries: httpInitRetries
|
|
251
|
+
});
|
|
252
|
+
}
|
|
231
253
|
return new Promise((resolve, reject) => {
|
|
232
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}`;
|
|
233
|
-
const
|
|
255
|
+
const connectWs = retryIndex => {
|
|
234
256
|
retryIndex = retryIndex || 0;
|
|
235
257
|
return new Promise((resolve, reject) => {
|
|
236
258
|
if ('set-cookie' in headers) {
|
|
@@ -243,17 +265,23 @@ class BotiumConnectorVoip {
|
|
|
243
265
|
this.ws = new ws(wsEndpoint);
|
|
244
266
|
}
|
|
245
267
|
this.ws.on('open', () => {
|
|
246
|
-
resolve();
|
|
268
|
+
resolve(retryIndex);
|
|
247
269
|
});
|
|
248
270
|
this.ws.on('error', () => {
|
|
249
271
|
if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
|
|
250
272
|
reject(new Error(`Websocket connection failed to ${wsEndpoint}`));
|
|
251
273
|
}
|
|
252
|
-
setTimeout(() =>
|
|
274
|
+
setTimeout(() => connectWs(retryIndex + 1).then(resolve).catch(reject), this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]);
|
|
253
275
|
});
|
|
254
276
|
});
|
|
255
277
|
};
|
|
256
|
-
|
|
278
|
+
connectWs().then(wsRetries => {
|
|
279
|
+
if (wsRetries > 0) {
|
|
280
|
+
_info('connected_after_retries', {
|
|
281
|
+
phase: 'websocket',
|
|
282
|
+
retries: wsRetries
|
|
283
|
+
});
|
|
284
|
+
}
|
|
257
285
|
if (!lodash.isArray(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
|
|
258
286
|
if (lodash.isEmpty(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
|
|
259
287
|
this.caps[Capabilities.VOIP_ICE_STUN_SERVERS] = [];
|
|
@@ -295,6 +323,7 @@ class BotiumConnectorVoip {
|
|
|
295
323
|
});
|
|
296
324
|
this.silence = null;
|
|
297
325
|
this.msgCount = 0;
|
|
326
|
+
this.sttPartialCount = 0;
|
|
298
327
|
this.firstMsg = true;
|
|
299
328
|
this.firstSttInfoReceived = false;
|
|
300
329
|
this.silenceTimeout = null;
|
|
@@ -439,6 +468,10 @@ class BotiumConnectorVoip {
|
|
|
439
468
|
};
|
|
440
469
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
|
|
441
470
|
this.sessionId = parsedData.voipConfig.sessionId;
|
|
471
|
+
_info('callinfo_initialized', {
|
|
472
|
+
sessionId: this.sessionId,
|
|
473
|
+
sttHandling: this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING]
|
|
474
|
+
});
|
|
442
475
|
}
|
|
443
476
|
|
|
444
477
|
// if sessionId is not the same as the one in the callinfo, return
|
|
@@ -453,9 +486,17 @@ class BotiumConnectorVoip {
|
|
|
453
486
|
reject(new Error('Error: Sip Registration failed'));
|
|
454
487
|
}
|
|
455
488
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'connected') {
|
|
489
|
+
_info('callinfo_connected', {
|
|
490
|
+
sessionId: this.sessionId
|
|
491
|
+
});
|
|
456
492
|
resolve();
|
|
457
493
|
}
|
|
458
494
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'disconnected') {
|
|
495
|
+
_info('callinfo_disconnected', {
|
|
496
|
+
sessionId: this.sessionId,
|
|
497
|
+
connectDurationSec: parsedData.connectDuration || null,
|
|
498
|
+
sttPartialCount: this.sttPartialCount
|
|
499
|
+
});
|
|
459
500
|
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY));
|
|
460
501
|
if (parsedData.connectDuration && parsedData.connectDuration > 0) {
|
|
461
502
|
this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
|
|
@@ -485,7 +526,13 @@ class BotiumConnectorVoip {
|
|
|
485
526
|
if (parsedData && parsedData.type === 'fullRecordEnd') {
|
|
486
527
|
const tail = _extractFullRecordBase64(parsedData);
|
|
487
528
|
if (tail) this.fullRecord = (this.fullRecord || '') + tail;
|
|
529
|
+
const base64Len = (this.fullRecord || tail || '').length;
|
|
488
530
|
emitFullRecordAttachment(this.fullRecord || tail);
|
|
531
|
+
_info('recording_attached', {
|
|
532
|
+
sessionId: this.sessionId,
|
|
533
|
+
source: 'fullRecordEnd',
|
|
534
|
+
base64Len
|
|
535
|
+
});
|
|
489
536
|
this.end = true;
|
|
490
537
|
}
|
|
491
538
|
if (parsedData && parsedData.type === 'silence') {
|
|
@@ -498,10 +545,17 @@ class BotiumConnectorVoip {
|
|
|
498
545
|
}
|
|
499
546
|
if (parsedData && parsedData.type === 'fullRecord') {
|
|
500
547
|
// Non-chunked full record
|
|
501
|
-
|
|
548
|
+
const base64 = _extractFullRecordBase64(parsedData);
|
|
549
|
+
emitFullRecordAttachment(base64);
|
|
550
|
+
_info('recording_attached', {
|
|
551
|
+
sessionId: this.sessionId,
|
|
552
|
+
source: 'fullRecord',
|
|
553
|
+
base64Len: (base64 || '').length
|
|
554
|
+
});
|
|
502
555
|
this.end = true;
|
|
503
556
|
}
|
|
504
557
|
if (parsedData && parsedData.data && parsedData.data.final === false) {
|
|
558
|
+
this.sttPartialCount++;
|
|
505
559
|
if (this.silenceTimeout) {
|
|
506
560
|
clearTimeout(this.silenceTimeout);
|
|
507
561
|
}
|
|
@@ -548,7 +602,18 @@ class BotiumConnectorVoip {
|
|
|
548
602
|
if (parsedData && parsedData.data && parsedData.data.type === 'stt' && parsedData.data.final) {
|
|
549
603
|
const confidenceThreshold = this._getConfidenceScoreLogicHook(this.convoStep) && this._getConfidenceScoreLogicHook(this.convoStep).args[0] || this.caps[Capabilities.VOIP_STT_CONFIDENCE_THRESHOLD];
|
|
550
604
|
const successfulConfidenceScore = this._getConfidenceScore(parsedData) >= confidenceThreshold;
|
|
605
|
+
const msgText = parsedData.data.message || '';
|
|
606
|
+
const msgLen = typeof msgText === 'string' ? msgText.length : 0;
|
|
607
|
+
const msgPreview = typeof msgText === 'string' ? msgText.trim().substring(0, 80) : '';
|
|
551
608
|
debug$3(`Message: ${parsedData.data.message} / Confidence Score: ${this._getConfidenceScore(parsedData)} (Threshold: ${confidenceThreshold})`);
|
|
609
|
+
_info('stt_final', {
|
|
610
|
+
sessionId: this.sessionId,
|
|
611
|
+
message: msgPreview,
|
|
612
|
+
messageLength: msgLen,
|
|
613
|
+
confidence: this._getConfidenceScore(parsedData),
|
|
614
|
+
threshold: confidenceThreshold,
|
|
615
|
+
accepted: successfulConfidenceScore
|
|
616
|
+
});
|
|
552
617
|
// ORIGINAL: emit final message immediately, unless join hooks are active.
|
|
553
618
|
if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && lodash.isNil(this._getJoinLogicHook(this.convoStep))) {
|
|
554
619
|
let botMsg = {
|
|
@@ -649,6 +714,19 @@ class BotiumConnectorVoip {
|
|
|
649
714
|
}
|
|
650
715
|
async UserSays(msg) {
|
|
651
716
|
debug$3('UserSays called');
|
|
717
|
+
const hasText = !!(msg && msg.messageText);
|
|
718
|
+
const hasVoiceMedia = !!(msg && msg.media && msg.media.length > 0 && msg.media[0].buffer);
|
|
719
|
+
const hasDtmf = !!(msg && msg.buttons && msg.buttons.length > 0);
|
|
720
|
+
const dtmfMatch = msg && msg.messageText && msg.messageText.match(/<DTMF>([^<]+)<\/DTMF>/i);
|
|
721
|
+
const inputType = hasDtmf || dtmfMatch ? 'dtmf' : hasText && hasVoiceMedia ? 'mixed' : hasText ? 'text' : hasVoiceMedia ? 'media' : 'unknown';
|
|
722
|
+
const msgPreview = hasText && msg.messageText ? String(msg.messageText).trim().substring(0, 80) : '';
|
|
723
|
+
_info('user_says', {
|
|
724
|
+
sessionId: this.sessionId,
|
|
725
|
+
inputType,
|
|
726
|
+
message: msgPreview || undefined,
|
|
727
|
+
messageLength: hasText && msg.messageText ? msg.messageText.length : undefined,
|
|
728
|
+
mediaSize: hasVoiceMedia && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined
|
|
729
|
+
});
|
|
652
730
|
// Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
|
|
653
731
|
try {
|
|
654
732
|
const safeLog = {
|
|
@@ -673,6 +751,10 @@ class BotiumConnectorVoip {
|
|
|
673
751
|
return new Promise((resolve, reject) => {
|
|
674
752
|
setTimeout(async () => {
|
|
675
753
|
let duration = 0;
|
|
754
|
+
const preferVoiceCapRaw = this.caps[Capabilities.VOIP_USER_INPUT_PREFER_VOICE];
|
|
755
|
+
const preferVoice = !!preferVoiceCapRaw;
|
|
756
|
+
const skipTtsForMixedInput = preferVoice && hasText && hasVoiceMedia;
|
|
757
|
+
debug$3(`UserSays routing: hasText=${hasText} hasVoiceMedia=${hasVoiceMedia} preferVoice=${preferVoice} preferVoiceRaw=${JSON.stringify(preferVoiceCapRaw)} skipTtsForMixedInput=${skipTtsForMixedInput}`);
|
|
676
758
|
if (msg && msg.buttons && msg.buttons.length > 0) {
|
|
677
759
|
const request = JSON.stringify({
|
|
678
760
|
METHOD: 'sendDtmf',
|
|
@@ -694,55 +776,61 @@ class BotiumConnectorVoip {
|
|
|
694
776
|
this.ws.send(request);
|
|
695
777
|
return resolve();
|
|
696
778
|
}
|
|
697
|
-
if (!
|
|
698
|
-
if (!
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
} else {
|
|
702
|
-
const ttsRequest = this._buildTtsRequest(msg.messageText);
|
|
703
|
-
if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
|
|
704
|
-
msg.sourceData = ttsRequest;
|
|
705
|
-
let ttsResult = null;
|
|
706
|
-
try {
|
|
707
|
-
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
|
|
708
|
-
} catch (err) {
|
|
709
|
-
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
|
|
710
|
-
}
|
|
711
|
-
if (msg && msg.messageText && msg.messageText.length > 0) {
|
|
712
|
-
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
|
|
713
|
-
this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
|
|
714
|
-
type: lodash.isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
|
|
715
|
-
metricName: 'consumption.e2e.voip.tts.characters',
|
|
716
|
-
transactions: msg.messageText.length,
|
|
717
|
-
apiKey
|
|
718
|
-
});
|
|
719
|
-
}
|
|
720
|
-
if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
|
|
721
|
-
duration = parseFloat(ttsResult.duration) || 0;
|
|
722
|
-
const b64Buffer = ttsResult.buffer.toString('base64');
|
|
723
|
-
const request = JSON.stringify({
|
|
724
|
-
METHOD: 'sendAudio',
|
|
725
|
-
PESQ: false,
|
|
726
|
-
sessionId: this.sessionId,
|
|
727
|
-
b64_buffer: b64Buffer
|
|
728
|
-
});
|
|
729
|
-
if (this._lastBotSaysQueuedAt) {
|
|
730
|
-
const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
|
|
731
|
-
const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
|
|
732
|
-
debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
|
|
779
|
+
if (!skipTtsForMixedInput) {
|
|
780
|
+
if (!this.axiosTtsParams) {
|
|
781
|
+
if (!(msg.media && msg.media.length > 0 && msg.media[0].buffer)) {
|
|
782
|
+
return reject(new Error('TTS not configured, only audio input supported'));
|
|
733
783
|
}
|
|
734
|
-
msg.attachments.push({
|
|
735
|
-
name: 'tts.wav',
|
|
736
|
-
mimeType: 'audio/wav',
|
|
737
|
-
base64: b64Buffer
|
|
738
|
-
});
|
|
739
|
-
this.ws.send(request);
|
|
740
784
|
} else {
|
|
741
|
-
|
|
785
|
+
debug$3('UserSays routing: executing TTS branch');
|
|
786
|
+
const ttsRequest = this._buildTtsRequest(msg.messageText);
|
|
787
|
+
if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
|
|
788
|
+
msg.sourceData = ttsRequest;
|
|
789
|
+
let ttsResult = null;
|
|
790
|
+
try {
|
|
791
|
+
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
|
|
792
|
+
} catch (err) {
|
|
793
|
+
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
|
|
794
|
+
}
|
|
795
|
+
if (msg && msg.messageText && msg.messageText.length > 0) {
|
|
796
|
+
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
|
|
797
|
+
this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
|
|
798
|
+
type: lodash.isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
|
|
799
|
+
metricName: 'consumption.e2e.voip.tts.characters',
|
|
800
|
+
transactions: msg.messageText.length,
|
|
801
|
+
apiKey
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
|
|
805
|
+
duration = parseFloat(ttsResult.duration) || 0;
|
|
806
|
+
const b64Buffer = ttsResult.buffer.toString('base64');
|
|
807
|
+
const request = JSON.stringify({
|
|
808
|
+
METHOD: 'sendAudio',
|
|
809
|
+
PESQ: false,
|
|
810
|
+
sessionId: this.sessionId,
|
|
811
|
+
b64_buffer: b64Buffer
|
|
812
|
+
});
|
|
813
|
+
if (this._lastBotSaysQueuedAt) {
|
|
814
|
+
const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
|
|
815
|
+
const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
|
|
816
|
+
debug$3(`Latency (bot says -> sendAudio/TTS): ${latencyMs} ms (last bot: ${botText})`);
|
|
817
|
+
}
|
|
818
|
+
msg.attachments.push({
|
|
819
|
+
name: 'tts.wav',
|
|
820
|
+
mimeType: 'audio/wav',
|
|
821
|
+
base64: b64Buffer
|
|
822
|
+
});
|
|
823
|
+
this.ws.send(request);
|
|
824
|
+
} else {
|
|
825
|
+
return reject(new Error('TTS failed, response is empty'));
|
|
826
|
+
}
|
|
742
827
|
}
|
|
828
|
+
} else {
|
|
829
|
+
debug$3('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled');
|
|
743
830
|
}
|
|
744
831
|
}
|
|
745
832
|
if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
|
|
833
|
+
debug$3('UserSays routing: executing MEDIA branch');
|
|
746
834
|
const request = JSON.stringify({
|
|
747
835
|
METHOD: 'sendAudio',
|
|
748
836
|
sessionId: this.sessionId,
|
|
@@ -1061,6 +1149,12 @@ var botiumConnectorVoip = {
|
|
|
1061
1149
|
label: 'Speech Synthesis Profile',
|
|
1062
1150
|
type: 'speechsynthesisprofile',
|
|
1063
1151
|
required: true
|
|
1152
|
+
}, {
|
|
1153
|
+
name: 'VOIP_USER_INPUT_PREFER_VOICE',
|
|
1154
|
+
label: 'Prefer voice media when both text and voice present',
|
|
1155
|
+
type: 'boolean',
|
|
1156
|
+
required: false,
|
|
1157
|
+
advanced: true
|
|
1064
1158
|
}]
|
|
1065
1159
|
},
|
|
1066
1160
|
PluginLogicHooks: {
|