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.
|
|
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) => {
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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 =
|
|
138
|
-
botMsg.sourceData[0].voiceDuration =
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
342
|
-
|
|
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
|
|
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 (
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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):',
|
|
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
|
-
},
|
|
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
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
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
|
|
733
|
+
let ttsResult = null
|
|
485
734
|
try {
|
|
486
|
-
|
|
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(
|
|
500
|
-
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:
|
|
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:
|
|
765
|
+
base64: b64Buffer
|
|
511
766
|
})
|
|
512
767
|
this.ws.send(request)
|
|
513
768
|
} else {
|
|
514
|
-
reject(new Error(
|
|
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])
|