botium-connector-voip 0.0.20 → 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.
|
@@ -8,7 +8,6 @@ var axios = require('axios');
|
|
|
8
8
|
var http = require('http');
|
|
9
9
|
var https = require('https');
|
|
10
10
|
var debug$4 = require('debug');
|
|
11
|
-
var path = require('path');
|
|
12
11
|
var musicMetadata = require('music-metadata');
|
|
13
12
|
|
|
14
13
|
function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }
|
|
@@ -19,10 +18,21 @@ var axios__default = /*#__PURE__*/_interopDefaultLegacy(axios);
|
|
|
19
18
|
var http__default = /*#__PURE__*/_interopDefaultLegacy(http);
|
|
20
19
|
var https__default = /*#__PURE__*/_interopDefaultLegacy(https);
|
|
21
20
|
var debug__default = /*#__PURE__*/_interopDefaultLegacy(debug$4);
|
|
22
|
-
var path__default = /*#__PURE__*/_interopDefaultLegacy(path);
|
|
23
21
|
var musicMetadata__default = /*#__PURE__*/_interopDefaultLegacy(musicMetadata);
|
|
24
22
|
|
|
25
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
|
+
};
|
|
26
36
|
const Capabilities = {
|
|
27
37
|
VOIP_STT_URL_STREAM: 'VOIP_STT_URL_STREAM',
|
|
28
38
|
VOIP_STT_PARAMS_STREAM: 'VOIP_STT_PARAMS_STREAM',
|
|
@@ -70,7 +80,8 @@ const Capabilities = {
|
|
|
70
80
|
VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: 'VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE',
|
|
71
81
|
VOIP_SILENCE_DURATION_TIMEOUT_START: 'VOIP_SILENCE_DURATION_TIMEOUT_START',
|
|
72
82
|
VOIP_STT_CONFIDENCE_THRESHOLD: 'VOIP_STT_CONFIDENCE_THRESHOLD',
|
|
73
|
-
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'
|
|
74
85
|
};
|
|
75
86
|
const Defaults = {
|
|
76
87
|
VOIP_STT_METHOD: 'POST',
|
|
@@ -92,7 +103,8 @@ const Defaults = {
|
|
|
92
103
|
VOIP_SILENCE_DURATION_TIMEOUT_START_ENABLE: false,
|
|
93
104
|
VOIP_STT_CONFIDENCE_THRESHOLD: 0.5,
|
|
94
105
|
VOIP_USE_GLOBAL_VOIP_WORKER: false,
|
|
95
|
-
VOIP_SIP_PROTOCOL: 'TCP'
|
|
106
|
+
VOIP_SIP_PROTOCOL: 'TCP',
|
|
107
|
+
VOIP_USER_INPUT_PREFER_VOICE: true
|
|
96
108
|
};
|
|
97
109
|
const TTS_HTTP_AGENT = new http__default["default"].Agent({
|
|
98
110
|
keepAlive: true
|
|
@@ -179,12 +191,17 @@ class BotiumConnectorVoip {
|
|
|
179
191
|
const botMsg = {};
|
|
180
192
|
botMsg.messageText = botMsgs.map(m => m.messageText).join(this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING_DELIMITER] || '');
|
|
181
193
|
botMsg.sourceData = botMsgs.map(m => m.sourceData);
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
194
|
+
const firstStart = lodash__default["default"].get(botMsgs, '[0].sourceData.data.start', null);
|
|
195
|
+
const lastEnd = lodash__default["default"].get(botMsgs, `[${botMsgs.length - 1}].sourceData.data.end`, null);
|
|
196
|
+
const prevEnd = lodash__default["default"].get(joinLastPrevMsg, 'sourceData.data.end', null);
|
|
197
|
+
// joinLastPrevMsg can be null (first joined message) OR incomplete (in case JOIN timeout fired before we got any final STT info).
|
|
198
|
+
// In those cases, fall back to "start-of-first-chunk" semantics (same as previous behaviour for nil joinLastPrevMsg).
|
|
199
|
+
if (lodash__default["default"].isFinite(firstStart) && lodash__default["default"].isFinite(lastEnd)) {
|
|
200
|
+
botMsg.sourceData[0].silenceDuration = lodash__default["default"].isFinite(prevEnd) ? firstStart - prevEnd : firstStart;
|
|
201
|
+
botMsg.sourceData[0].voiceDuration = lastEnd - firstStart;
|
|
185
202
|
} else {
|
|
186
|
-
botMsg.sourceData[0].silenceDuration =
|
|
187
|
-
botMsg.sourceData[0].voiceDuration =
|
|
203
|
+
botMsg.sourceData[0].silenceDuration = lodash__default["default"].isFinite(firstStart) ? firstStart : null;
|
|
204
|
+
botMsg.sourceData[0].voiceDuration = lodash__default["default"].isFinite(firstStart) && lodash__default["default"].isFinite(lastEnd) ? lastEnd - firstStart : null;
|
|
188
205
|
}
|
|
189
206
|
return botMsg;
|
|
190
207
|
};
|
|
@@ -210,15 +227,18 @@ class BotiumConnectorVoip {
|
|
|
210
227
|
};
|
|
211
228
|
let data = null;
|
|
212
229
|
let headers = null;
|
|
213
|
-
|
|
230
|
+
let httpInitRetries = 0;
|
|
231
|
+
const connectHttp = async retryIndex => {
|
|
214
232
|
retryIndex = retryIndex || 0;
|
|
215
233
|
try {
|
|
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');
|
|
235
|
+
const baseUrl = workerUrl.endsWith('/') ? workerUrl.slice(0, -1) : workerUrl;
|
|
216
236
|
const res = await axios__default["default"]({
|
|
217
237
|
method: 'post',
|
|
218
238
|
data: {
|
|
219
239
|
API_KEY: this.caps[Capabilities.VOIP_USE_GLOBAL_VOIP_WORKER] ? process.env.BOTIUM_VOIP_WORKER_APIKEY : this.caps[Capabilities.VOIP_WORKER_APIKEY]
|
|
220
240
|
},
|
|
221
|
-
url:
|
|
241
|
+
url: `${baseUrl}/initCall`
|
|
222
242
|
});
|
|
223
243
|
if (res) {
|
|
224
244
|
data = res.data;
|
|
@@ -229,17 +249,24 @@ class BotiumConnectorVoip {
|
|
|
229
249
|
if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
|
|
230
250
|
throw new Error(`Connecting to VOIP Worker failed: ${err.message || 'Not reachable'}`);
|
|
231
251
|
}
|
|
252
|
+
httpInitRetries = retryIndex + 1;
|
|
232
253
|
// Retry after the defined timeout
|
|
233
254
|
await new Promise(resolve => setTimeout(resolve, this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]));
|
|
234
255
|
|
|
235
256
|
// Retry the connection
|
|
236
|
-
await
|
|
257
|
+
await connectHttp(retryIndex + 1);
|
|
237
258
|
}
|
|
238
259
|
};
|
|
239
|
-
await
|
|
260
|
+
await connectHttp();
|
|
261
|
+
if (httpInitRetries > 0) {
|
|
262
|
+
_info('connected_after_retries', {
|
|
263
|
+
phase: 'initCall',
|
|
264
|
+
retries: httpInitRetries
|
|
265
|
+
});
|
|
266
|
+
}
|
|
240
267
|
return new Promise((resolve, reject) => {
|
|
241
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}`;
|
|
242
|
-
const
|
|
269
|
+
const connectWs = retryIndex => {
|
|
243
270
|
retryIndex = retryIndex || 0;
|
|
244
271
|
return new Promise((resolve, reject) => {
|
|
245
272
|
if ('set-cookie' in headers) {
|
|
@@ -252,17 +279,23 @@ class BotiumConnectorVoip {
|
|
|
252
279
|
this.ws = new ws__default["default"](wsEndpoint);
|
|
253
280
|
}
|
|
254
281
|
this.ws.on('open', () => {
|
|
255
|
-
resolve();
|
|
282
|
+
resolve(retryIndex);
|
|
256
283
|
});
|
|
257
284
|
this.ws.on('error', () => {
|
|
258
285
|
if (retryIndex === this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_MAXRETRIES]) {
|
|
259
286
|
reject(new Error(`Websocket connection failed to ${wsEndpoint}`));
|
|
260
287
|
}
|
|
261
|
-
setTimeout(() =>
|
|
288
|
+
setTimeout(() => connectWs(retryIndex + 1).then(resolve).catch(reject), this.caps[Capabilities.VOIP_WEBSOCKET_CONNECT_TIMEOUT]);
|
|
262
289
|
});
|
|
263
290
|
});
|
|
264
291
|
};
|
|
265
|
-
|
|
292
|
+
connectWs().then(wsRetries => {
|
|
293
|
+
if (wsRetries > 0) {
|
|
294
|
+
_info('connected_after_retries', {
|
|
295
|
+
phase: 'websocket',
|
|
296
|
+
retries: wsRetries
|
|
297
|
+
});
|
|
298
|
+
}
|
|
266
299
|
if (!lodash__default["default"].isArray(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
|
|
267
300
|
if (lodash__default["default"].isEmpty(this.caps[Capabilities.VOIP_ICE_STUN_SERVERS])) {
|
|
268
301
|
this.caps[Capabilities.VOIP_ICE_STUN_SERVERS] = [];
|
|
@@ -304,6 +337,7 @@ class BotiumConnectorVoip {
|
|
|
304
337
|
});
|
|
305
338
|
this.silence = null;
|
|
306
339
|
this.msgCount = 0;
|
|
340
|
+
this.sttPartialCount = 0;
|
|
307
341
|
this.firstMsg = true;
|
|
308
342
|
this.firstSttInfoReceived = false;
|
|
309
343
|
this.silenceTimeout = null;
|
|
@@ -364,11 +398,94 @@ class BotiumConnectorVoip {
|
|
|
364
398
|
});
|
|
365
399
|
this.ws.on('message', async data => {
|
|
366
400
|
const parsedData = JSON.parse(data);
|
|
401
|
+
// Allow fullRecord delivery even if Stop() was already called.
|
|
402
|
+
// Otherwise the recording can be dropped if the worker streams it late (common on hangup).
|
|
403
|
+
if (this.stopCalled) {
|
|
404
|
+
const allowedTypes = ['fullRecord', 'fullRecordStart', 'fullRecordChunk', 'fullRecordEnd', 'error'];
|
|
405
|
+
if (!parsedData || !parsedData.type || !allowedTypes.includes(parsedData.type)) {
|
|
406
|
+
debug$3(`${this.sessionId} - Stop already called, ignoring incoming message`);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
367
410
|
const parsedDataLog = lodash__default["default"].cloneDeep(parsedData);
|
|
368
|
-
|
|
411
|
+
// Sanitize all potential base64 fields for logging (don't dump huge buffers)
|
|
412
|
+
const sanitizeBase64Fields = (obj, prefix = '') => {
|
|
413
|
+
if (!obj || typeof obj !== 'object') return;
|
|
414
|
+
for (const key of Object.keys(obj)) {
|
|
415
|
+
const val = obj[key];
|
|
416
|
+
if (typeof val === 'string' && val.length > 500) {
|
|
417
|
+
obj[key] = `<base64:${val.length}chars>`;
|
|
418
|
+
} else if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
419
|
+
sanitizeBase64Fields(val, `${prefix}${key}.`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
sanitizeBase64Fields(parsedDataLog);
|
|
369
424
|
debug$3(JSON.stringify(parsedDataLog, null, 2));
|
|
425
|
+
const _extractFullRecordBase64 = pd => {
|
|
426
|
+
if (!pd) return null;
|
|
427
|
+
// Different VOIP workers may put the payload in various fields - search all string fields
|
|
428
|
+
// First try known field names
|
|
429
|
+
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];
|
|
430
|
+
for (const val of knownFields) {
|
|
431
|
+
if (typeof val === 'string' && val.length > 100) return val;
|
|
432
|
+
}
|
|
433
|
+
// Fallback: find any large string field (likely base64)
|
|
434
|
+
const findLargeString = (obj, depth = 0) => {
|
|
435
|
+
if (!obj || typeof obj !== 'object' || depth > 3) return null;
|
|
436
|
+
for (const key of Object.keys(obj)) {
|
|
437
|
+
const val = obj[key];
|
|
438
|
+
if (typeof val === 'string' && val.length > 1000) {
|
|
439
|
+
debug$3(`${this.sessionId} - Found base64 in field '${key}' (len=${val.length})`);
|
|
440
|
+
return val;
|
|
441
|
+
}
|
|
442
|
+
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
443
|
+
const found = findLargeString(val, depth + 1);
|
|
444
|
+
if (found) return found;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
};
|
|
449
|
+
return findLargeString(pd);
|
|
450
|
+
};
|
|
451
|
+
const emitFullRecordAttachment = base64 => {
|
|
452
|
+
if (!base64 || typeof base64 !== 'string' || base64.length === 0) {
|
|
453
|
+
// Log available fields (length only) to help diagnose worker payload shape without dumping base64
|
|
454
|
+
try {
|
|
455
|
+
const candidates = {
|
|
456
|
+
fullRecord: typeof parsedData?.fullRecord === 'string' ? parsedData.fullRecord.length : null,
|
|
457
|
+
full_record: typeof parsedData?.full_record === 'string' ? parsedData.full_record.length : null,
|
|
458
|
+
data_fullRecord: typeof parsedData?.data?.fullRecord === 'string' ? parsedData.data.fullRecord.length : null,
|
|
459
|
+
data_full_record: typeof parsedData?.data?.full_record === 'string' ? parsedData.data.full_record.length : null,
|
|
460
|
+
data_b64_buffer: typeof parsedData?.data?.b64_buffer === 'string' ? parsedData.data.b64_buffer.length : null,
|
|
461
|
+
data_base64: typeof parsedData?.data?.base64 === 'string' ? parsedData.data.base64.length : null,
|
|
462
|
+
data_full_record_buffer: typeof parsedData?.data?.full_record_buffer === 'string' ? parsedData.data.full_record_buffer.length : null,
|
|
463
|
+
data_buffer: typeof parsedData?.data?.buffer === 'string' ? parsedData.data.buffer.length : null
|
|
464
|
+
};
|
|
465
|
+
debug$3(`${this.sessionId} - fullRecordEnd received but base64 is empty, skipping attachment emit. CandidateLengths=${JSON.stringify(candidates)}`);
|
|
466
|
+
} catch (err) {
|
|
467
|
+
debug$3(`${this.sessionId} - fullRecordEnd received but base64 is empty, skipping attachment emit (diag failed: ${err.message || err})`);
|
|
468
|
+
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
debug$3(`${this.sessionId} - Emitting MESSAGE_ATTACHMENT full_record.wav (base64Len=${base64.length}, stopCalled=${!!this.stopCalled})`);
|
|
472
|
+
this.eventEmitter.emit('MESSAGE_ATTACHMENT', this.container, {
|
|
473
|
+
name: 'full_record.wav',
|
|
474
|
+
mimeType: 'audio/wav',
|
|
475
|
+
base64,
|
|
476
|
+
// Session context from capabilities for correlation
|
|
477
|
+
sessionContext: {
|
|
478
|
+
testSessionId: this.caps.VOIP_TEST_SESSION_ID || null,
|
|
479
|
+
testSessionJobId: this.caps.VOIP_TEST_SESSION_JOB_ID || null
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
};
|
|
370
483
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'initialized') {
|
|
371
484
|
this.sessionId = parsedData.voipConfig.sessionId;
|
|
485
|
+
_info('callinfo_initialized', {
|
|
486
|
+
sessionId: this.sessionId,
|
|
487
|
+
sttHandling: this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING]
|
|
488
|
+
});
|
|
372
489
|
}
|
|
373
490
|
|
|
374
491
|
// if sessionId is not the same as the one in the callinfo, return
|
|
@@ -383,9 +500,17 @@ class BotiumConnectorVoip {
|
|
|
383
500
|
reject(new Error('Error: Sip Registration failed'));
|
|
384
501
|
}
|
|
385
502
|
if (parsedData && parsedData.type === 'callinfo' && parsedData.status === 'connected') {
|
|
503
|
+
_info('callinfo_connected', {
|
|
504
|
+
sessionId: this.sessionId
|
|
505
|
+
});
|
|
386
506
|
resolve();
|
|
387
507
|
}
|
|
388
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
|
+
});
|
|
389
514
|
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_STT_BODY));
|
|
390
515
|
if (parsedData.connectDuration && parsedData.connectDuration > 0) {
|
|
391
516
|
this.eventEmitter.emit('CONSUMPTION_METADATA', this, {
|
|
@@ -402,6 +527,28 @@ class BotiumConnectorVoip {
|
|
|
402
527
|
reject(new Error(`Error: ${parsedData.message}`));
|
|
403
528
|
sendBotMsg(new Error(`Error: ${parsedData.message}`));
|
|
404
529
|
}
|
|
530
|
+
|
|
531
|
+
// Full record streaming support:
|
|
532
|
+
// - some VOIP workers send the recording in chunks and an end marker
|
|
533
|
+
if (parsedData && parsedData.type === 'fullRecordStart') {
|
|
534
|
+
this.fullRecord = '';
|
|
535
|
+
}
|
|
536
|
+
if (parsedData && parsedData.type === 'fullRecordChunk') {
|
|
537
|
+
const chunk = _extractFullRecordBase64(parsedData);
|
|
538
|
+
if (chunk) this.fullRecord = (this.fullRecord || '') + chunk;
|
|
539
|
+
}
|
|
540
|
+
if (parsedData && parsedData.type === 'fullRecordEnd') {
|
|
541
|
+
const tail = _extractFullRecordBase64(parsedData);
|
|
542
|
+
if (tail) this.fullRecord = (this.fullRecord || '') + tail;
|
|
543
|
+
const base64Len = (this.fullRecord || tail || '').length;
|
|
544
|
+
emitFullRecordAttachment(this.fullRecord || tail);
|
|
545
|
+
_info('recording_attached', {
|
|
546
|
+
sessionId: this.sessionId,
|
|
547
|
+
source: 'fullRecordEnd',
|
|
548
|
+
base64Len
|
|
549
|
+
});
|
|
550
|
+
this.end = true;
|
|
551
|
+
}
|
|
405
552
|
if (parsedData && parsedData.type === 'silence') {
|
|
406
553
|
if (lodash__default["default"].isNil(this._getIgnoreSilenceDurationAsserterLogicHook(this.convoStep))) {
|
|
407
554
|
if (lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep)) && parsedData.data.silence.length > 0) {
|
|
@@ -411,14 +558,18 @@ class BotiumConnectorVoip {
|
|
|
411
558
|
}
|
|
412
559
|
}
|
|
413
560
|
if (parsedData && parsedData.type === 'fullRecord') {
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
561
|
+
// Non-chunked full record
|
|
562
|
+
const base64 = _extractFullRecordBase64(parsedData);
|
|
563
|
+
emitFullRecordAttachment(base64);
|
|
564
|
+
_info('recording_attached', {
|
|
565
|
+
sessionId: this.sessionId,
|
|
566
|
+
source: 'fullRecord',
|
|
567
|
+
base64Len: (base64 || '').length
|
|
418
568
|
});
|
|
419
569
|
this.end = true;
|
|
420
570
|
}
|
|
421
571
|
if (parsedData && parsedData.data && parsedData.data.final === false) {
|
|
572
|
+
this.sttPartialCount++;
|
|
422
573
|
if (this.silenceTimeout) {
|
|
423
574
|
clearTimeout(this.silenceTimeout);
|
|
424
575
|
}
|
|
@@ -429,7 +580,7 @@ class BotiumConnectorVoip {
|
|
|
429
580
|
}
|
|
430
581
|
}
|
|
431
582
|
this.firstSttInfoReceived = true;
|
|
432
|
-
if (this.prevData) {
|
|
583
|
+
if (this.prevData && this.prevData.data && !lodash__default["default"].isNil(this.prevData.data.end)) {
|
|
433
584
|
if (!lodash__default["default"].isNil(parsedData.data.start)) {
|
|
434
585
|
const silenceDuration = parsedData.data.start - this.prevData.data.end;
|
|
435
586
|
const joinLogicHook = this._getJoinLogicHook(this.convoStep);
|
|
@@ -465,7 +616,18 @@ class BotiumConnectorVoip {
|
|
|
465
616
|
if (parsedData && parsedData.data && parsedData.data.type === 'stt' && parsedData.data.final) {
|
|
466
617
|
const confidenceThreshold = this._getConfidenceScoreLogicHook(this.convoStep) && this._getConfidenceScoreLogicHook(this.convoStep).args[0] || this.caps[Capabilities.VOIP_STT_CONFIDENCE_THRESHOLD];
|
|
467
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) : '';
|
|
468
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
|
+
});
|
|
469
631
|
// ORIGINAL: emit final message immediately, unless join hooks are active.
|
|
470
632
|
if (this.caps[Capabilities.VOIP_STT_MESSAGE_HANDLING] === 'ORIGINAL' && lodash__default["default"].isNil(this._getJoinLogicHook(this.convoStep))) {
|
|
471
633
|
let botMsg = {
|
|
@@ -481,7 +643,9 @@ class BotiumConnectorVoip {
|
|
|
481
643
|
this.firstMsg = false;
|
|
482
644
|
} else {
|
|
483
645
|
const sourceData = parsedData;
|
|
484
|
-
|
|
646
|
+
const start = lodash__default["default"].get(parsedData, 'data.start', null);
|
|
647
|
+
const prevEnd = lodash__default["default"].get(this.prevData, 'data.end', null);
|
|
648
|
+
sourceData.silenceDuration = lodash__default["default"].isFinite(start) && lodash__default["default"].isFinite(prevEnd) ? start - prevEnd : lodash__default["default"].isFinite(start) ? start : null;
|
|
485
649
|
sourceData.voiceDuration = parsedData.data.end - parsedData.data.start;
|
|
486
650
|
botMsg = Object.assign({}, botMsg, {
|
|
487
651
|
sourceData
|
|
@@ -508,7 +672,9 @@ class BotiumConnectorVoip {
|
|
|
508
672
|
this.firstMsg = false;
|
|
509
673
|
} else {
|
|
510
674
|
const sourceData = parsedData;
|
|
511
|
-
|
|
675
|
+
const start = lodash__default["default"].get(parsedData, 'data.start', null);
|
|
676
|
+
const prevEnd = lodash__default["default"].get(this.prevData, 'data.end', null);
|
|
677
|
+
sourceData.silenceDuration = lodash__default["default"].isFinite(start) && lodash__default["default"].isFinite(prevEnd) ? start - prevEnd : lodash__default["default"].isFinite(start) ? start : null;
|
|
512
678
|
sourceData.voiceDuration = parsedData.data.end - parsedData.data.start;
|
|
513
679
|
botMsg = Object.assign({}, botMsg, {
|
|
514
680
|
sourceData
|
|
@@ -562,13 +728,47 @@ class BotiumConnectorVoip {
|
|
|
562
728
|
}
|
|
563
729
|
async UserSays(msg) {
|
|
564
730
|
debug$3('UserSays called');
|
|
565
|
-
|
|
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
|
+
});
|
|
744
|
+
// Avoid logging large buffers/base64 (can break job logs and overwhelm stdout)
|
|
745
|
+
try {
|
|
746
|
+
const safeLog = {
|
|
747
|
+
messageText: msg && msg.messageText ? String(msg.messageText).substring(0, 200) : undefined,
|
|
748
|
+
buttons: msg && Array.isArray(msg.buttons) ? msg.buttons.length : 0,
|
|
749
|
+
hasMedia: !!(msg && msg.media && msg.media.length > 0),
|
|
750
|
+
mediaMimeType: msg && msg.media && msg.media[0] ? msg.media[0].mimeType : undefined,
|
|
751
|
+
mediaSize: msg && msg.media && msg.media[0] && Buffer.isBuffer(msg.media[0].buffer) ? msg.media[0].buffer.length : undefined,
|
|
752
|
+
attachments: msg && Array.isArray(msg.attachments) ? msg.attachments.map(a => ({
|
|
753
|
+
name: a && (a.name || a.title),
|
|
754
|
+
mimeType: a && a.mimeType,
|
|
755
|
+
base64Len: a && a.base64 ? a.base64.length : 0
|
|
756
|
+
})) : []
|
|
757
|
+
};
|
|
758
|
+
debug$3(safeLog);
|
|
759
|
+
} catch (err) {
|
|
760
|
+
debug$3(`UserSays: failed to build safe log: ${err.message || err}`);
|
|
761
|
+
}
|
|
566
762
|
if (!msg.attachments) {
|
|
567
763
|
msg.attachments = [];
|
|
568
764
|
}
|
|
569
765
|
return new Promise((resolve, reject) => {
|
|
570
766
|
setTimeout(async () => {
|
|
571
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}`);
|
|
572
772
|
if (msg && msg.buttons && msg.buttons.length > 0) {
|
|
573
773
|
const request = JSON.stringify({
|
|
574
774
|
METHOD: 'sendDtmf',
|
|
@@ -590,55 +790,61 @@ class BotiumConnectorVoip {
|
|
|
590
790
|
this.ws.send(request);
|
|
591
791
|
return resolve();
|
|
592
792
|
}
|
|
593
|
-
if (!
|
|
594
|
-
if (!
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
} else {
|
|
598
|
-
const ttsRequest = this._buildTtsRequest(msg.messageText);
|
|
599
|
-
if (!ttsRequest) return reject(new Error('TTS not configured, only audio input supported'));
|
|
600
|
-
msg.sourceData = ttsRequest;
|
|
601
|
-
let ttsResult = null;
|
|
602
|
-
try {
|
|
603
|
-
ttsResult = await this._getTtsAudio(ttsRequest, msg.messageText);
|
|
604
|
-
} catch (err) {
|
|
605
|
-
return reject(new Error(`TTS "${msg.messageText}" failed - ${this._getAxiosErrOutput(err)}`));
|
|
606
|
-
}
|
|
607
|
-
if (msg && msg.messageText && msg.messageText.length > 0) {
|
|
608
|
-
const apiKey = this._extractApiKey(this._getBody(Capabilities.VOIP_TTS_BODY));
|
|
609
|
-
this.eventEmitter.emit('CONSUMPTION_METADATA', this.container, {
|
|
610
|
-
type: lodash__default["default"].isNil(apiKey) ? 'INBUILT' : 'THIRD_PARTY',
|
|
611
|
-
metricName: 'consumption.e2e.voip.tts.characters',
|
|
612
|
-
transactions: msg.messageText.length,
|
|
613
|
-
apiKey
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
if (ttsResult && Buffer.isBuffer(ttsResult.buffer)) {
|
|
617
|
-
duration = parseFloat(ttsResult.duration) || 0;
|
|
618
|
-
const b64Buffer = ttsResult.buffer.toString('base64');
|
|
619
|
-
const request = JSON.stringify({
|
|
620
|
-
METHOD: 'sendAudio',
|
|
621
|
-
PESQ: false,
|
|
622
|
-
sessionId: this.sessionId,
|
|
623
|
-
b64_buffer: b64Buffer
|
|
624
|
-
});
|
|
625
|
-
if (this._lastBotSaysQueuedAt) {
|
|
626
|
-
const latencyMs = Date.now() - this._lastBotSaysQueuedAt;
|
|
627
|
-
const botText = this._lastBotSaysText ? this._lastBotSaysText.substring(0, 120) : '<unknown>';
|
|
628
|
-
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'));
|
|
629
797
|
}
|
|
630
|
-
msg.attachments.push({
|
|
631
|
-
name: 'tts.wav',
|
|
632
|
-
mimeType: 'audio/wav',
|
|
633
|
-
base64: b64Buffer
|
|
634
|
-
});
|
|
635
|
-
this.ws.send(request);
|
|
636
798
|
} else {
|
|
637
|
-
|
|
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
|
+
}
|
|
638
841
|
}
|
|
842
|
+
} else {
|
|
843
|
+
debug$3('UserSays routing: skipping TTS for mixed input because VOIP_USER_INPUT_PREFER_VOICE is enabled');
|
|
639
844
|
}
|
|
640
845
|
}
|
|
641
846
|
if (msg && msg.media && msg.media.length > 0 && msg.media[0].buffer) {
|
|
847
|
+
debug$3('UserSays routing: executing MEDIA branch');
|
|
642
848
|
const request = JSON.stringify({
|
|
643
849
|
METHOD: 'sendAudio',
|
|
644
850
|
sessionId: this.sessionId,
|
|
@@ -673,6 +879,11 @@ class BotiumConnectorVoip {
|
|
|
673
879
|
}
|
|
674
880
|
async Stop() {
|
|
675
881
|
debug$3(`${this.sessionId} - Stop called`);
|
|
882
|
+
this.stopCalled = true;
|
|
883
|
+
if (this.silenceTimeout) {
|
|
884
|
+
clearTimeout(this.silenceTimeout);
|
|
885
|
+
this.silenceTimeout = null;
|
|
886
|
+
}
|
|
676
887
|
if (this.ws && this.ws.readyState !== ws__default["default"].CLOSED) {
|
|
677
888
|
const request = JSON.stringify({
|
|
678
889
|
METHOD: 'stopCall',
|
|
@@ -680,9 +891,12 @@ class BotiumConnectorVoip {
|
|
|
680
891
|
});
|
|
681
892
|
this.ws.send(request);
|
|
682
893
|
await new Promise(resolve => {
|
|
683
|
-
setTimeout(resolve, 100000);
|
|
684
|
-
setInterval(() => {
|
|
894
|
+
const stopTimeout = setTimeout(resolve, 100000);
|
|
895
|
+
this._stopWaitInterval = setInterval(() => {
|
|
685
896
|
if (this.end) {
|
|
897
|
+
clearTimeout(stopTimeout);
|
|
898
|
+
clearInterval(this._stopWaitInterval);
|
|
899
|
+
this._stopWaitInterval = null;
|
|
686
900
|
this.wsOpened = false;
|
|
687
901
|
this.ws = null;
|
|
688
902
|
this.end = false;
|
|
@@ -949,6 +1163,12 @@ var botiumConnectorVoip = {
|
|
|
949
1163
|
label: 'Speech Synthesis Profile',
|
|
950
1164
|
type: 'speechsynthesisprofile',
|
|
951
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
|
|
952
1172
|
}]
|
|
953
1173
|
},
|
|
954
1174
|
PluginLogicHooks: {
|