Compare commits

..

16 Commits

Author SHA1 Message Date
Dave Horton
c412554c6b WsRequestor: reconnect if socket dropped from far end 2022-05-09 12:14:13 -04:00
Dave Horton
34fe22f6e1 minor 2022-05-08 16:34:42 -04:00
Dave Horton
182ad8c716 expose model and singleUtterance to gather/transcribe when using google 2022-05-08 12:29:55 -04:00
Dave Horton
036accab44 dial: transcribe and listen should be based on the caller (A leg) endpoint 2022-05-07 18:36:49 -04:00
Dave Horton
b37881a059 bugfix: second part of outbound dial fix over wss 2022-05-07 11:52:29 -04:00
Dave Horton
258e4b5434 bugfix: outbound rest dial over websocket api needs to send session:new 2022-05-07 11:51:21 -04:00
Dave Horton
aa4d72c80a allow call status to be sent before killing rest dial on failure 2022-05-02 14:05:24 -04:00
Dave Horton
5c38ace5ba bugfix: rest dial should exit upon call failure, not after call timeout is reached 2022-05-02 13:50:42 -04:00
Dave Horton
dea58c2605 more work on wss race condition 2022-05-02 13:32:07 -04:00
Dave Horton
eb0f55e0e3 ws-requestor: queue outgoing messages if we are in the process of connecting to the remote wss server 2022-05-02 13:09:23 -04:00
Dave Horton
944b8a29ca Use lts version of node instead of latest 2022-05-02 11:17:29 -04:00
Dave Horton
daa02ac55a logging 2022-05-02 11:12:39 -04:00
Dave Horton
5134d5dbc6 update to latest realtimedb-helpers 2022-05-02 10:55:42 -04:00
Dave Horton
a755e25568 minor logging 2022-05-02 10:21:17 -04:00
Dave Horton
13549286db bugfix: createCall needs to work with wss url 2022-05-02 09:42:04 -04:00
Dave Horton
72aaf80335 add support for multiple languages when using Azure STT 2022-04-26 15:07:55 -04:00
10 changed files with 134 additions and 53 deletions

View File

@@ -1,4 +1,4 @@
FROM node:slim
FROM node:lts-slim
WORKDIR /opt/app/
COPY package.json package-lock.json ./
RUN npm ci

View File

@@ -14,12 +14,12 @@ const dbUtils = require('../../utils/db-utils');
router.post('/', async(req, res) => {
const {logger} = req.app.locals;
const accountSid = req.body.account_sid;
const {srf} = require('../../..');
logger.debug({body: req.body}, 'got createCall request');
try {
let uri, cs, to;
const restDial = makeTask(logger, {'rest:dial': req.body});
const {srf} = require('../../..');
const {lookupAccountDetails} = dbUtils(logger, srf);
const {getSBC, getFreeswitch} = srf.locals;
const sbcAddress = getSBC();
@@ -77,7 +77,7 @@ router.post('/', async(req, res) => {
if (target.type === 'phone' && target.trunk) {
const {lookupCarrier} = dbUtils(this.logger, srf);
const voip_carrier_sid = await lookupCarrier(req.body.account_sid, target.trunk);
this.logger.info(
logger.info(
`createCall: selected ${voip_carrier_sid} for requested carrier: ${target.trunk || 'unspecified'})`);
if (voip_carrier_sid) {
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
@@ -118,15 +118,25 @@ router.post('/', async(req, res) => {
* attach our requestor and notifier objects
* these will be used for all http requests we make during this call
*/
if ('WS' === app.call_hook?.method) {
if ('WS' === app.call_hook?.method || /^wss?:/.test(app.call_hook.url)) {
logger.debug({call_hook: app.call_hook}, 'creating websocket for call hook');
app.requestor = new WsRequestor(logger, account.account_sid, app.call_hook, account.webhook_secret) ;
app.notifier = app.requestor;
if (app.call_hook.url === app.call_status_hook.url || !app.call_status_hook?.url) {
logger.debug('reusing websocket for call status hook');
app.notifier = app.requestor;
}
}
else {
logger.debug({call_hook: app.call_hook}, 'creating http client for call hook');
app.requestor = new HttpRequestor(logger, account.account_sid, app.call_hook, account.webhook_secret);
if (app.call_status_hook) app.notifier = new HttpRequestor(logger, account.account_sid, app.call_status_hook,
account.webhook_secret);
else app.notifier = {request: () => {}};
}
if (!app.notifier && app.call_status_hook) {
app.notifier = new HttpRequestor(logger, account.account_sid, app.call_status_hook, account.webhook_secret);
logger.debug({call_hook: app.call_hook}, 'creating http client for call status hook');
}
else if (!app.notifier) {
logger.debug('creating null call status hook');
app.notifier = {request: () => {}};
}
/* now launch the outdial */
@@ -230,6 +240,7 @@ router.post('/', async(req, res) => {
else console.error(err);
}
ep.destroy();
setTimeout(restDial.kill.bind(restDial), 5000);
}
} catch (err) {
sysError(logger, res, err);

View File

@@ -606,8 +606,8 @@ class TaskDial extends Task {
if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg);
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg);
if (this.transcribeTask) this.transcribeTask.exec(cs, this.ep);
if (this.listenTask) this.listenTask.exec(cs, this.ep);
if (this.transcribeTask) this.transcribeTask.exec(cs, this.epOther);
if (this.listenTask) this.listenTask.exec(cs, this.epOther);
/* if we can release the media back to the SBC, do so now */
if (this.canReleaseMedia) this._releaseMedia(cs, sd);

View File

@@ -36,8 +36,18 @@ class TaskGather extends Task {
this.language = recognizer.language;
this.hints = recognizer.hints || [];
this.hintsBoost = recognizer.hintsBoost;
this.altLanguages = recognizer.altLanguages || [];
this.profanityFilter = recognizer.profanityFilter;
this.punctuation = !!recognizer.punctuation;
this.enhancedModel = !!recognizer.enhancedModel;
this.model = recognizer.model || 'command_and_search';
this.words = !!recognizer.words;
this.singleUtterance = recognizer.singleUtterance || true;
this.diarization = !!recognizer.diarization;
this.diarizationMinSpeakers = recognizer.diarizationMinSpeakers || 0;
this.diarizationMaxSpeakers = recognizer.diarizationMaxSpeakers || 0;
this.interactionType = recognizer.interactionType || 'unspecified';
this.naicsCode = recognizer.naicsCode || 0;
this.altLanguages = recognizer.altLanguages || [];
/* vad: if provided, we dont connect to recognizer until voice activity is detected */
const {enable, voiceMs = 0, mode = -1} = recognizer.vad || {};
@@ -232,24 +242,35 @@ class TaskGather extends Task {
if ('google' === this.vendor) {
if (this.sttCredentials) opts.GOOGLE_APPLICATION_CREDENTIALS = JSON.stringify(this.sttCredentials.credentials);
Object.assign(opts, {
GOOGLE_SPEECH_USE_ENHANCED: true,
GOOGLE_SPEECH_SINGLE_UTTERANCE: true,
GOOGLE_SPEECH_MODEL: 'command_and_search',
GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION: !!this.punctuation
[
['enhancedModel', 'GOOGLE_SPEECH_USE_ENHANCED'],
['separateRecognitionPerChannel', 'GOOGLE_SPEECH_SEPARATE_RECOGNITION_PER_CHANNEL'],
['profanityFilter', 'GOOGLE_SPEECH_PROFANITY_FILTER'],
['punctuation', 'GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION'],
['words', 'GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS'],
['singleUtterance', 'GOOGLE_SPEECH_SINGLE_UTTERANCE'],
['diarization', 'GOOGLE_SPEECH_PROFANITY_FILTER']
].forEach((arr) => {
if (this[arr[0]]) opts[arr[1]] = true;
});
if (this.hints && this.hints.length > 1) {
opts.GOOGLE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
if (this.hints.length > 1) {
opts.GOOGLE_SPEECH_HINTS = this.hints.join(',');
if (typeof this.hintsBoost === 'number') {
opts.GOOGLE_SPEECH_HINTS_BOOST = this.hintsBoost;
}
}
if (this.altLanguages && this.altLanguages.length > 0) {
opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
if (this.altLanguages.length > 1) opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
if ('unspecified' !== this.interactionType) {
opts.GOOGLE_SPEECH_METADATA_INTERACTION_TYPE = this.interactionType;
}
if (this.profanityFilter === true) {
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
opts.GOOGLE_SPEECH_MODEL = this.model;
if (this.diarization && this.diarizationMinSpeakers > 0) {
opts.GOOGLE_SPEECH_SPEAKER_DIARIZATION_MIN_SPEAKER_COUNT = this.diarizationMinSpeakers;
}
if (this.diarization && this.diarizationMaxSpeakers > 0) {
opts.GOOGLE_SPEECH_SPEAKER_DIARIZATION_MAX_SPEAKER_COUNT = this.diarizationMaxSpeakers;
}
if (this.naicsCode > 0) opts.GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE = this.naicsCode;
ep.addCustomEventListener(GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, cs, ep));
ep.addCustomEventListener(GoogleTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
@@ -280,6 +301,9 @@ class TaskGather extends Task {
if (this.hints && this.hints.length > 1) {
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
}
if (this.altLanguages && this.altLanguages.length > 0) {
opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
}
if (this.requestSnr) opts.AZURE_REQUEST_SNR = 1;
if (this.profanityOption && this.profanityOption !== 'raw') opts.AZURE_PROFANITY_OPTION = this.profanityOption;
if (this.azureServiceEndpoint) opts.AZURE_SERVICE_ENDPOINT = this.azureServiceEndpoint;
@@ -365,8 +389,10 @@ class TaskGather extends Task {
// don't sort based on confidence: https://github.com/Azure-Samples/cognitive-services-speech-sdk/issues/1463
//const nbest = evt.NBest.sort((a, b) => b.Confidence - a.Confidence);
const nbest = evt.NBest;
const language_code = evt.PrimaryLanguage?.Language || this.language;
evt = {
is_final: true,
language_code,
alternatives: [
{
confidence: nbest[0].Confidence,

View File

@@ -50,7 +50,7 @@ class TaskRestDial extends Task {
try {
const b3 = this.getTracingPropagation();
const httpHeaders = b3 && {b3};
const tasks = await cs.requestor.request('verb:hook', this.call_hook, cs.callInfo, httpHeaders);
const tasks = await cs.requestor.request('session:new', this.call_hook, cs.callInfo, httpHeaders);
if (tasks && Array.isArray(tasks)) {
this.logger.debug({tasks: tasks}, `TaskRestDial: replacing application with ${tasks.length} tasks`);
cs.replaceApplication(normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata)));

View File

@@ -448,6 +448,7 @@
"tag"
]
},
"model": "string",
"outputFormat": {
"type": "string",
"enum": [

View File

@@ -32,7 +32,9 @@ class TaskTranscribe extends Task {
this.profanityFilter = recognizer.profanityFilter;
this.punctuation = !!recognizer.punctuation;
this.enhancedModel = !!recognizer.enhancedModel;
this.model = recognizer.model || 'phone_call';
this.words = !!recognizer.words;
this.singleUtterance = recognizer.singleUtterance || false;
this.diarization = !!recognizer.diarization;
this.diarizationMinSpeakers = recognizer.diarizationMinSpeakers || 0;
this.diarizationMaxSpeakers = recognizer.diarizationMaxSpeakers || 0;
@@ -136,6 +138,7 @@ class TaskTranscribe extends Task {
['profanityFilter', 'GOOGLE_SPEECH_PROFANITY_FILTER'],
['punctuation', 'GOOGLE_SPEECH_ENABLE_AUTOMATIC_PUNCTUATION'],
['words', 'GOOGLE_SPEECH_ENABLE_WORD_TIME_OFFSETS'],
['singleUtterance', 'GOOGLE_SPEECH_SINGLE_UTTERANCE'],
['diarization', 'GOOGLE_SPEECH_PROFANITY_FILTER']
].forEach((arr) => {
if (this[arr[0]]) opts[arr[1]] = true;
@@ -149,15 +152,8 @@ class TaskTranscribe extends Task {
if (this.altLanguages.length > 1) opts.GOOGLE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
if ('unspecified' !== this.interactionType) {
opts.GOOGLE_SPEECH_METADATA_INTERACTION_TYPE = this.interactionType;
// additionally set model if appropriate
if ('phone_call' === this.interactionType) opts.GOOGLE_SPEECH_MODEL = 'phone_call';
else if (['voice_search', 'voice_command'].includes(this.interactionType)) {
opts.GOOGLE_SPEECH_MODEL = 'command_and_search';
}
else opts.GOOGLE_SPEECH_MODEL = 'phone_call';
}
else opts.GOOGLE_SPEECH_MODEL = 'phone_call';
opts.GOOGLE_SPEECH_MODEL = this.model;
if (this.diarization && this.diarizationMinSpeakers > 0) {
opts.GOOGLE_SPEECH_SPEAKER_DIARIZATION_MIN_SPEAKER_COUNT = this.diarizationMinSpeakers;
}
@@ -208,6 +204,7 @@ class TaskTranscribe extends Task {
if (this.hints && this.hints.length > 1) {
opts.AZURE_SPEECH_HINTS = this.hints.map((h) => h.trim()).join(',');
}
if (this.altLanguages.length > 1) opts.AZURE_SPEECH_ALTERNATIVE_LANGUAGE_CODES = this.altLanguages.join(',');
if (this.requestSnr) opts.AZURE_REQUEST_SNR = 1;
if (this.profanityOption !== 'raw') opts.AZURE_PROFANITY_OPTION = this.profanityOption;
if (this.initialSpeechTimeoutMs > 0) opts.AZURE_INITIAL_SPEECH_TIMEOUT_MS = this.initialSpeechTimeoutMs;
@@ -230,10 +227,11 @@ class TaskTranscribe extends Task {
}
_onTranscription(cs, ep, evt) {
this.logger.debug(evt, 'TaskTranscribe:_onTranscription');
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription');
if ('aws' === this.vendor && Array.isArray(evt) && evt.length > 0) evt = evt[0];
if ('microsoft' === this.vendor) {
const nbest = evt.NBest;
const language_code = evt.PrimaryLanguage?.Language || this.language;
const alternatives = nbest ? nbest.map((n) => {
return {
confidence: n.Confidence,
@@ -248,6 +246,7 @@ class TaskTranscribe extends Task {
const newEvent = {
is_final: evt.RecognitionStatus === 'Success',
language_code,
alternatives
};
evt = newEvent;

View File

@@ -14,8 +14,11 @@ class WsRequestor extends BaseRequestor {
this.connections = 0;
this.messagesInFlight = new Map();
this.maliciousClient = false;
this.closedByUs = false;
this.closedGracefully = false;
this.backoffMs = 500;
this.connectInProgress = false;
this.queuedMsg = [];
this.id = short.generate();
assert(this._isAbsoluteUrl(this.url));
@@ -41,6 +44,10 @@ class WsRequestor extends BaseRequestor {
this.logger.info({url: this.url}, 'WsRequestor:request - discarding msg to malicious client');
return;
}
if (this.closedGracefully) {
this.logger.debug(`WsRequestor:request - discarding ${type} because we closed the socket`);
return;
}
if (type === 'session:new') this.call_sid = params.callSid;
@@ -53,6 +60,14 @@ class WsRequestor extends BaseRequestor {
/* connect if necessary */
if (!this.ws) {
if (this.connectInProgress) {
this.logger.debug(
`WsRequestor:request(${this.id}) - queueing ${type} message since we are connecting`);
this.queuedMsg.push({type, hook, params, httpHeaders});
return;
}
this.connectInProgress = true;
this.logger.debug(`WsRequestor:request(${this.id}) - connecting since we do not have a connection`);
if (this.connections >= MAX_RECONNECTS) {
throw new Error(`max attempts connecting to ${this.url}`);
}
@@ -63,13 +78,16 @@ class WsRequestor extends BaseRequestor {
this.stats.histogram('app.hook.connect_time', rtt, ['hook_type:app']);
} catch (err) {
this.logger.info({url, err}, 'WsRequestor:request - failed connecting');
this.connectInProgress = false;
throw err;
}
}
assert(this.ws);
/* prepare and send message */
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
let payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
if (type === 'session:new') this._sessionData = payload;
if (type === 'session:reconnect') payload = this._sessionData;
assert.ok(url, 'WsRequestor:request url was not provided');
const msgid = short.generate();
@@ -83,12 +101,23 @@ class WsRequestor extends BaseRequestor {
...b3
};
const sendQueuedMsgs = () => {
if (this.queuedMsg.length > 0) {
for (const {type, hook, params, httpHeaders} of this.queuedMsg) {
this.logger.debug(`WsRequestor:request - preparing queued ${type} for sending`);
setImmediate(this.request.bind(this, type, hook, params, httpHeaders));
}
this.queuedMsg.length = 0;
}
};
//this.logger.debug({obj}, `websocket: sending (${url})`);
/* simple notifications */
if (['call:status', 'jambonz:error'].includes(type)) {
if (['call:status', 'jambonz:error', 'session:reconnect'].includes(type)) {
this.ws.send(JSON.stringify(obj), () => {
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
sendQueuedMsgs();
});
return;
}
@@ -122,15 +151,16 @@ class WsRequestor extends BaseRequestor {
/* send the message */
this.ws.send(JSON.stringify(obj), () => {
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
sendQueuedMsgs();
});
});
}
close() {
this.closedByUs = true;
this.closedGracefully = true;
this.logger.info('WsRequestor:close closing socket');
try {
if (this.ws) {
this.logger.info('WsRequestor:close closing socket');
this.ws.close();
this.ws.removeAllListeners();
}
@@ -163,9 +193,8 @@ class WsRequestor extends BaseRequestor {
this
.once('ready', (ws) => {
this.ws = ws;
this.removeAllListeners('not-ready');
if (this.connections > 0) this.request('session:reconnect', this.url);
if (this.connections > 1) this.request('session:reconnect', this.url);
resolve();
})
.once('not-ready', (err) => {
@@ -178,6 +207,7 @@ class WsRequestor extends BaseRequestor {
}
_setHandlers(ws) {
this.logger.debug('WsRequestor:_setHandlers');
ws
.once('open', this._onOpen.bind(this, ws))
.once('close', this._onClose.bind(this))
@@ -194,18 +224,23 @@ class WsRequestor extends BaseRequestor {
}
_onOpen(ws) {
this.logger.info({url: this.url}, `WsRequestor(${this.id}) - successfully connected`);
if (this.ws) this.logger.info({old_ws: this.ws._socket.address()}, 'WsRequestor:_onOpen');
assert(!this.ws);
this.ws = ws;
this.connectInProgress = false;
this.connections++;
this.emit('ready', ws);
this.logger.info({url: this.url}, 'WsRequestor - successfully connected');
}
_onClose() {
if (this.connections > 0) {
_onClose(code) {
this.logger.info(`WsRequestor(${this.id}) - closed from far end ${code}`);
if (this.connections > 0 && code !== 1000) {
this.logger.info({url: this.url}, 'WsRequestor - socket closed unexpectedly from remote side');
this.emit('socket-closed');
}
this.ws && this.ws.removeAllListeners();
else if (code === 1000) this.closedGracefully = true;
this.ws?.removeAllListeners();
this.ws = null;
}
@@ -223,8 +258,17 @@ class WsRequestor extends BaseRequestor {
_onSocketClosed() {
this.ws = null;
this.emit('connection-dropped');
if (this.connections++ > 0 && this.connections < MAX_RECONNECTS && !this.closedByUs) {
setTimeout(this._connect.bind(this), this.backoffMs);
if (this.connections > 0 && this.connections < MAX_RECONNECTS && !this.closedGracefully) {
this.logger.debug(`WsRequestor:_onSocketClosed waiting ${this.backoffMs} to reconnect`);
setTimeout(() => {
this.logger.debug(
{haveWs: !!this.ws, connectInProgress: this.connectInProgress},
'WsRequestor:_onSocketClosed time to reconnect');
if (!this.ws && !this.connectInProgress) {
this.connectInProgress = true;
this._connect().catch((err) => this.connectInProgress = false);
}
}, this.backoffMs);
this.backoffMs = this.backoffMs < 2000 ? this.backoffMs * 2 : (this.backoffMs + 2000);
}
}

14
package-lock.json generated
View File

@@ -13,7 +13,7 @@
"@jambonz/db-helpers": "^0.6.16",
"@jambonz/http-health-check": "^0.0.1",
"@jambonz/mw-registrar": "^0.2.1",
"@jambonz/realtimedb-helpers": "^0.4.26",
"@jambonz/realtimedb-helpers": "^0.4.27",
"@jambonz/stats-collector": "^0.1.6",
"@jambonz/time-series": "^0.1.6",
"@opentelemetry/api": "^1.1.0",
@@ -564,9 +564,9 @@
}
},
"node_modules/@jambonz/realtimedb-helpers": {
"version": "0.4.26",
"resolved": "https://registry.npmjs.org/@jambonz/realtimedb-helpers/-/realtimedb-helpers-0.4.26.tgz",
"integrity": "sha512-vGNC5IsYj7Qj7mOfgfti0/ZouYs+9GHEX4v7w3JguPW1NqUbvFayPs3xo49di3NJsHV0NnKWx2rMgrZzvWTgTA==",
"version": "0.4.27",
"resolved": "https://registry.npmjs.org/@jambonz/realtimedb-helpers/-/realtimedb-helpers-0.4.27.tgz",
"integrity": "sha512-t1E5JIaWtPuAIRrLM73X+VpZLIacxzbSNBvjyAXm7DQ5wi/dFXfZDoxd3UJdzHtamHfser7ILXSNQxPchj6itw==",
"dependencies": {
"@google-cloud/text-to-speech": "^3.4.0",
"@jambonz/promisify-redis": "^0.0.6",
@@ -6615,9 +6615,9 @@
}
},
"@jambonz/realtimedb-helpers": {
"version": "0.4.26",
"resolved": "https://registry.npmjs.org/@jambonz/realtimedb-helpers/-/realtimedb-helpers-0.4.26.tgz",
"integrity": "sha512-vGNC5IsYj7Qj7mOfgfti0/ZouYs+9GHEX4v7w3JguPW1NqUbvFayPs3xo49di3NJsHV0NnKWx2rMgrZzvWTgTA==",
"version": "0.4.27",
"resolved": "https://registry.npmjs.org/@jambonz/realtimedb-helpers/-/realtimedb-helpers-0.4.27.tgz",
"integrity": "sha512-t1E5JIaWtPuAIRrLM73X+VpZLIacxzbSNBvjyAXm7DQ5wi/dFXfZDoxd3UJdzHtamHfser7ILXSNQxPchj6itw==",
"requires": {
"@google-cloud/text-to-speech": "^3.4.0",
"@jambonz/promisify-redis": "^0.0.6",

View File

@@ -30,7 +30,7 @@
"@jambonz/db-helpers": "^0.6.16",
"@jambonz/http-health-check": "^0.0.1",
"@jambonz/mw-registrar": "^0.2.1",
"@jambonz/realtimedb-helpers": "^0.4.26",
"@jambonz/realtimedb-helpers": "^0.4.27",
"@jambonz/stats-collector": "^0.1.6",
"@jambonz/time-series": "^0.1.6",
"@opentelemetry/api": "^1.1.0",