diff --git a/docs/samples/browser-plugin-meetings/app.js b/docs/samples/browser-plugin-meetings/app.js
index 2e4db88b0eb..c3a0671785a 100644
--- a/docs/samples/browser-plugin-meetings/app.js
+++ b/docs/samples/browser-plugin-meetings/app.js
@@ -299,6 +299,7 @@ const passwordCaptchaStatusElm = document.querySelector('#password-captcha-statu
const refreshCaptchaElm = document.querySelector('#meetings-join-captcha-refresh');
const verifyPasswordElm = document.querySelector('#btn-verify-password');
const displayMeetingStatusElm = document.querySelector('#display-meeting-status');
+const notes=document.querySelector('#notes');
const spaceIDError = `Using the space ID as a destination is no longer supported. Please refer to the migration guide to migrate to use the meeting ID or SIP address.`;
const BNR = 'BNR';
const VBG = 'VBG';
@@ -381,6 +382,17 @@ createMeetingSelectElm.addEventListener('change', (event) => {
}
});
+createMeetingSelectElm.addEventListener('change', (event) => {
+ if (event.target.value === 'Others') {
+ notes.classList.remove('hidden');
+ }
+ else {
+ notes.classList.add('hidden');
+
+ }
+});
+
+
function createMeeting(e) {
e.preventDefault();
@@ -427,6 +439,7 @@ function refreshCaptcha() {
meetingsListElm.onclick = (e) => {
selectedMeetingId = e.target.value;
const meeting = webex.meetings.getAllMeetings()[selectedMeetingId];
+ const selectedMeetingType = createMeetingSelectElm.options[createMeetingSelectElm.selectedIndex].innerText;
if (meeting && meeting.passwordStatus === 'REQUIRED') {
meetingsJoinPinElm.disabled = false;
@@ -434,12 +447,18 @@ meetingsListElm.onclick = (e) => {
document.getElementById('btn-join').disabled = true;
document.getElementById('btn-join-media').disabled = true;
}
- else if (meeting && meeting.passwordStatus === 'UNKNOWN') {
+ else if (meeting && (meeting.passwordStatus === 'UNKNOWN' && selectedMeetingType === 'SIP URI')) {
meetingsJoinPinElm.disabled = true;
verifyPasswordElm.disabled = true;
document.getElementById('btn-join').disabled = true;
document.getElementById('btn-join-media').disabled = true;
}
+ else if(meeting && (meeting.passwordStatus === 'UNKNOWN' && selectedMeetingType != 'SIP URI')) {
+ meetingsJoinPinElm.disabled = true;
+ verifyPasswordElm.disabled = true;
+ document.getElementById('btn-join').disabled = false;
+ document.getElementById('btn-join-media').disabled = false;
+ }
else {
meetingsJoinPinElm.disabled = true;
verifyPasswordElm.disabled = true;
diff --git a/docs/samples/browser-plugin-meetings/index.html b/docs/samples/browser-plugin-meetings/index.html
index 1df78750757..a994bf11ed5 100644
--- a/docs/samples/browser-plugin-meetings/index.html
+++ b/docs/samples/browser-plugin-meetings/index.html
@@ -338,14 +338,14 @@
+
NOTE: The destination shall be "Person ID" or "Email".
diff --git a/docs/samples/calling/app.js b/docs/samples/calling/app.js
index 1702f10f894..2555f593779 100644
--- a/docs/samples/calling/app.js
+++ b/docs/samples/calling/app.js
@@ -1301,6 +1301,10 @@ async function createCustomContact() {
type: 'work',
value: formData.get('phone')
}],
+ emails: [{
+ type: 'work',
+ value: formData.get('email')
+ }],
contactType: 'CUSTOM',
};
const res = await contacts.createContact(contact);
diff --git a/packages/@webex/internal-plugin-device/src/config.js b/packages/@webex/internal-plugin-device/src/config.js
index 8f37c6dc6f8..598c604231e 100644
--- a/packages/@webex/internal-plugin-device/src/config.js
+++ b/packages/@webex/internal-plugin-device/src/config.js
@@ -56,6 +56,12 @@ export default {
* @type {boolean}
*/
ephemeralDeviceTTL: 30 * 60,
+
+ /**
+ * energyForcast
+ * @type {boolean}
+ */
+ energyForecast: false,
},
/**
diff --git a/packages/@webex/internal-plugin-device/src/device.js b/packages/@webex/internal-plugin-device/src/device.js
index 65a28673b7c..da8ba42d7ea 100644
--- a/packages/@webex/internal-plugin-device/src/device.js
+++ b/packages/@webex/internal-plugin-device/src/device.js
@@ -306,6 +306,14 @@ const Device = WebexPlugin.extend({
*/
isReachabilityChecked: ['boolean', false, false],
+ /**
+ * This property stores whether or not the next refresh or register request should request energy forecast data
+ * in order to prevent over fetching energy forecasts
+ *
+ * @type {boolean}
+ */
+ energyForecastConfig: 'boolean',
+
/**
* This property stores whether or not the current device is in a meeting
* to prevent an unneeded timeout of a meeting due to inactivity.
@@ -344,6 +352,15 @@ const Device = WebexPlugin.extend({
this.webex.trigger('meeting ended');
},
+ /**
+ * Set the value of energy forecast config for the current registered device.
+ * @param {boolean} [energyForecastConfig=false] - fetch an energy forecast on the next refresh/register
+ * @returns {void}
+ */
+ setEnergyForecastConfig(energyForecastConfig = false) {
+ this.energyForecastConfig = energyForecastConfig;
+ },
+
// Registration method members
/* eslint-disable require-jsdoc */
@@ -395,6 +412,11 @@ const Device = WebexPlugin.extend({
uri: this.url,
body,
headers,
+ qs: {
+ includeUpstreamServices: `all${
+ this.config.energyForecast && this.energyForecastConfig ? ',energyforecast' : ''
+ }`,
+ },
})
.then((response) => this.processRegistrationSuccess(response))
.catch((reason) => {
@@ -464,6 +486,11 @@ const Device = WebexPlugin.extend({
resource: 'devices',
body,
headers,
+ qs: {
+ includeUpstreamServices: `all${
+ this.config.energyForecast && this.energyForecastConfig ? ',energyforecast' : ''
+ }`,
+ },
})
.catch((error) => {
this.webex.internal.newMetrics.submitInternalEvent({
diff --git a/packages/@webex/internal-plugin-device/test/unit/spec/device.js b/packages/@webex/internal-plugin-device/test/unit/spec/device.js
index 9c00a8b18c1..53d781c268c 100644
--- a/packages/@webex/internal-plugin-device/test/unit/spec/device.js
+++ b/packages/@webex/internal-plugin-device/test/unit/spec/device.js
@@ -176,11 +176,14 @@ describe('plugin-device', () => {
describe('#refresh()', () => {
let requestSpy;
- const setup = () => {
+ const setup = (config = {}) => {
sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve());
sinon.stub(device, 'processRegistrationSuccess').callsFake(() => {});
requestSpy = sinon.spy(device, 'request');
device.config.defaults = {};
+ Object.keys((config)).forEach((key) => {
+ device.config[key] = config[key];
+ });
device.set('registered', true);
};
@@ -207,15 +210,40 @@ describe('plugin-device', () => {
assert.deepEqual(requestSpy.args[0][0].headers, {});
});
+
+ it('uses the energy forecast config to append upstream services to the outgoing call', async () => {
+ setup({energyForecast: true});
+ device.setEnergyForecastConfig(true);
+
+ await device.register();
+
+ assert.calledWith(requestSpy, sinon.match({
+ qs: { includeUpstreamServices: 'all,energyforecast' }
+ }))
+ });
+
+ it('uses the energy forecast config to not append upstream services to the outgoing call', async () => {
+ setup({energyForecast: true});
+ device.setEnergyForecastConfig(false);
+
+ await device.register();
+
+ assert.calledWith(requestSpy, sinon.match({
+ qs: { includeUpstreamServices: 'all' }
+ }))
+ });
});
describe('#register()', () => {
- const setup = () => {
+ const setup = (config = {}) => {
webex.internal.metrics.submitClientMetrics = sinon.stub();
sinon.stub(device, 'processRegistrationSuccess').callsFake(() => {});
device.config.defaults = {};
+ Object.keys(config).forEach((key) => {
+ device.config[key] = config[key];
+ });
device.set('registered', false);
};
@@ -284,6 +312,42 @@ describe('plugin-device', () => {
});
+ it('uses the energy forecast config to append upstream services to the outgoing call', async () => {
+ setup({energyForecast: true});
+ sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve());
+ const spy = sinon.spy(device, 'request');
+ device.setEnergyForecastConfig(true);
+
+ await device.register();
+
+ assert.calledWith(spy, {
+ method: 'POST',
+ service: 'wdm',
+ resource: 'devices',
+ body: {},
+ headers: {},
+ qs: { includeUpstreamServices: 'all,energyforecast' }
+ } )
+ });
+
+ it('uses the energy forecast config to not append upstream services to the outgoing call', async () => {
+ setup({energyForecast: true});
+ sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve());
+ const spy = sinon.spy(device, 'request');
+ device.setEnergyForecastConfig(false);
+
+ await device.register();
+
+ assert.calledWith(spy, {
+ method: 'POST',
+ service: 'wdm',
+ resource: 'devices',
+ body: {},
+ headers: {},
+ qs: { includeUpstreamServices: 'all' }
+ } )
+ });
+
});
describe('#processRegistrationSuccess()', () => {
diff --git a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts
index 2da21fa487a..d1f0e1721eb 100644
--- a/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts
+++ b/packages/@webex/internal-plugin-metrics/src/call-diagnostic/call-diagnostic-metrics.ts
@@ -455,6 +455,9 @@ export default class CallDiagnosticMetrics extends StatelessWebexPlugin {
},
intervals: payload.intervals,
callingServiceType: 'LOCUS',
+ meetingJoinInfo: {
+ clientSignallingProtocol: 'WebRTC',
+ },
sourceMetadata: {
applicationSoftwareType: CLIENT_NAME,
// @ts-ignore
diff --git a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts
index ea534d02cca..dd2bcebb648 100644
--- a/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts
+++ b/packages/@webex/internal-plugin-metrics/test/unit/spec/call-diagnostic/call-diagnostic-metrics.ts
@@ -1454,6 +1454,9 @@ describe('internal-plugin-metrics', () => {
eventData: {webClientDomain: 'whatever'},
intervals: [{}],
callingServiceType: 'LOCUS',
+ meetingJoinInfo: {
+ clientSignallingProtocol: 'WebRTC',
+ },
sourceMetadata: {
applicationSoftwareType: 'webex-js-sdk',
applicationSoftwareVersion: 'webex-version',
@@ -1490,6 +1493,9 @@ describe('internal-plugin-metrics', () => {
eventData: {webClientDomain: 'whatever'},
intervals: [{}],
callingServiceType: 'LOCUS',
+ meetingJoinInfo: {
+ clientSignallingProtocol: 'WebRTC',
+ },
sourceMetadata: {
applicationSoftwareType: 'webex-js-sdk',
applicationSoftwareVersion: 'webex-version',
@@ -1524,6 +1530,9 @@ describe('internal-plugin-metrics', () => {
eventData: {webClientDomain: 'whatever'},
intervals: [{}],
callingServiceType: 'LOCUS',
+ meetingJoinInfo: {
+ clientSignallingProtocol: 'WebRTC',
+ },
sourceMetadata: {
applicationSoftwareType: 'webex-js-sdk',
applicationSoftwareVersion: 'webex-version',
diff --git a/packages/@webex/media-helpers/package.json b/packages/@webex/media-helpers/package.json
index b95c6dd1c99..b086980d52d 100644
--- a/packages/@webex/media-helpers/package.json
+++ b/packages/@webex/media-helpers/package.json
@@ -22,7 +22,7 @@
"deploy:npm": "yarn npm publish"
},
"dependencies": {
- "@webex/internal-media-core": "2.9.1",
+ "@webex/internal-media-core": "2.10.2",
"@webex/ts-events": "^1.1.0",
"@webex/web-media-effects": "2.18.0"
},
diff --git a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js
index 916287f34d8..477b64e751d 100644
--- a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js
+++ b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js
@@ -103,14 +103,24 @@ const Authorization = WebexPlugin.extend({
this._verifySecurityToken(location.query);
this._cleanUrl(location);
+ let preauthCatalogParams;
+
+ const orgId = this._extractOrgIdFromCode(code);
+
+ if (emailhash) {
+ preauthCatalogParams = {emailhash};
+ } else if (orgId) {
+ preauthCatalogParams = {orgId};
+ }
+
// Wait until nextTick in case `credentials` hasn't initialized yet
process.nextTick(() => {
this.webex.internal.services
- .collectPreauthCatalog(emailhash ? {emailhash}: undefined)
+ .collectPreauthCatalog(preauthCatalogParams)
.catch(() => Promise.resolve())
.then(() => this.requestAuthorizationCodeGrant({code, codeVerifier}))
.catch((error) => {
- this.logger.warn('authorization: failed initial authorization code grant request', error)
+ this.logger.warn('authorization: failed initial authorization code grant request', error);
})
.then(() => {
this.ready = true;
@@ -230,6 +240,20 @@ const Authorization = WebexPlugin.extend({
});
},
+ /**
+ * Extracts the orgId from the returned code from idbroker
+ * Description of how to parse the code can be found here:
+ * https://wiki.cisco.com/display/IDENTITY/Federated+Token+Validation
+ * @instance
+ * @memberof AuthorizationBrowserFirstParty
+ * @param {String} code
+ * @private
+ * @returns {String}
+ */
+ _extractOrgIdFromCode(code) {
+ return code?.split('_')[2] || undefined;
+ },
+
/**
* Checks if the result of the login redirect contains an error string
* @instance
diff --git a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js
index fbc07485412..6069b45ac15 100644
--- a/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js
+++ b/packages/@webex/plugin-authorization-browser-first-party/test/unit/spec/authorization.js
@@ -183,7 +183,7 @@ describe('plugin-authorization-browser-first-party', () => {
});
it('collects the preauth catalog when emailhash is present in the state', async () => {
- const code = 'auth code';
+ const code = 'authcode_clusterid_theOrgId';
const webex = makeWebex(
`http://example.com/?code=${code}&state=${base64.encode(
JSON.stringify({emailhash: 'someemailhash'})
@@ -205,7 +205,7 @@ describe('plugin-authorization-browser-first-party', () => {
});
it('collects the preauth catalog no emailhash is present in the state', async () => {
- const code = 'auth code';
+ const code = 'authcode_clusterid_theOrgId';
const webex = makeWebex(
`http://example.com/?code=${code}`
);
@@ -220,6 +220,26 @@ describe('plugin-authorization-browser-first-party', () => {
await webex.authorization.when('change:ready');
+ assert.calledOnce(requestAuthorizationCodeGrantStub);
+ assert.calledWith(requestAuthorizationCodeGrantStub, {code, codeVerifier: undefined});
+ assert.calledOnce(collectPreauthCatalogStub);
+ assert.calledWith(collectPreauthCatalogStub, {orgId: 'theOrgId'});
+ });
+
+ it('collects the preauth catalog with no emailhash and no orgId', async () => {
+ const code = 'authcode_clusterid';
+ const webex = makeWebex(`http://example.com/?code=${code}`);
+
+ const requestAuthorizationCodeGrantStub = sinon.stub(
+ Authorization.prototype,
+ 'requestAuthorizationCodeGrant'
+ );
+ const collectPreauthCatalogStub = sinon
+ .stub(Services.prototype, 'collectPreauthCatalog')
+ .resolves();
+
+ await webex.authorization.when('change:ready');
+
assert.calledOnce(requestAuthorizationCodeGrantStub);
assert.calledWith(requestAuthorizationCodeGrantStub, {code, codeVerifier: undefined});
assert.calledOnce(collectPreauthCatalogStub);
@@ -503,5 +523,46 @@ describe('plugin-authorization-browser-first-party', () => {
assert.notInclude(href, 'csrf_token');
});
});
+
+ describe('#_extractOrgIdFromCode', () => {
+ it('extracts the orgId from the code', () => {
+ const webex = makeWebex(undefined, undefined, {
+ credentials: {
+ clientType: 'confidential',
+ },
+ });
+
+ const code = 'authcode_clusterid_theOrgId';
+ const orgId = webex.authorization._extractOrgIdFromCode(code);
+
+ assert.equal(orgId, 'theOrgId');
+ });
+
+ it('handles an invalid code', () => {
+ const webex = makeWebex(undefined, undefined, {
+ credentials: {
+ clientType: 'confidential',
+ },
+ });
+
+ const code = 'authcode_clusterid_';
+ const orgId = webex.authorization._extractOrgIdFromCode(code);
+
+ assert.isUndefined(orgId);
+ });
+
+ it('handles an completely invalid code', () => {
+ const webex = makeWebex(undefined, undefined, {
+ credentials: {
+ clientType: 'confidential',
+ },
+ });
+
+ const code = 'authcode';
+ const orgId = webex.authorization._extractOrgIdFromCode(code);
+
+ assert.isUndefined(orgId);
+ })
+ });
});
});
diff --git a/packages/@webex/plugin-meetings/package.json b/packages/@webex/plugin-meetings/package.json
index 02c7f99e073..e3f65d30687 100644
--- a/packages/@webex/plugin-meetings/package.json
+++ b/packages/@webex/plugin-meetings/package.json
@@ -62,7 +62,7 @@
},
"dependencies": {
"@webex/common": "workspace:*",
- "@webex/internal-media-core": "2.9.1",
+ "@webex/internal-media-core": "2.10.2",
"@webex/internal-plugin-conversation": "workspace:*",
"@webex/internal-plugin-device": "workspace:*",
"@webex/internal-plugin-llm": "workspace:*",
diff --git a/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts b/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts
index 27079d8221b..0ba3e4d8178 100644
--- a/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts
+++ b/packages/@webex/plugin-meetings/src/locus-info/selfUtils.ts
@@ -428,11 +428,6 @@ SelfUtils.mutedByOthersChanged = (oldSelf, changedSelf) => {
return false;
}
- // there is no need to trigger user update if no one muted user
- if (changedSelf.selfIdentity === changedSelf.modifiedBy) {
- return false;
- }
-
return (
changedSelf.remoteMuted !== null &&
(oldSelf.remoteMuted !== changedSelf.remoteMuted ||
diff --git a/packages/@webex/plugin-meetings/src/media/index.ts b/packages/@webex/plugin-meetings/src/media/index.ts
index 15d2376106e..eac32f8e533 100644
--- a/packages/@webex/plugin-meetings/src/media/index.ts
+++ b/packages/@webex/plugin-meetings/src/media/index.ts
@@ -104,9 +104,7 @@ Media.getDirection = (forceSendRecv: boolean, receive: boolean, send: boolean) =
*
* @param {boolean} isMultistream
* @param {string} debugId string useful for debugging (will appear in media connection logs)
- * @param {object} webex main `webex` object.
* @param {string} meetingId id for the meeting using this connection
- * @param {string} correlationId id used in requests to correlate to this session
* @param {Object} options
* @param {Object} [options.mediaProperties] contains mediaDirection and local tracks:
* audioTrack, videoTrack, shareVideoTrack, and shareAudioTrack
@@ -120,10 +118,9 @@ Media.getDirection = (forceSendRecv: boolean, receive: boolean, send: boolean) =
Media.createMediaConnection = (
isMultistream: boolean,
debugId: string,
- webex: object,
meetingId: string,
- correlationId: string,
options: {
+ rtcMetrics?: RtcMetrics;
mediaProperties: {
mediaDirection?: {
receiveAudio: boolean;
@@ -150,6 +147,7 @@ Media.createMediaConnection = (
}
) => {
const {
+ rtcMetrics,
mediaProperties,
remoteQualityLevel,
enableRtx,
@@ -163,6 +161,19 @@ Media.createMediaConnection = (
// we might not have any TURN server if TURN discovery failed or wasn't done or
// we might get an empty TURN url if we land on a video mesh node
if (turnServerInfo?.url) {
+ if (!isBrowser('firefox')) {
+ let bareTurnServer = turnServerInfo.url;
+ bareTurnServer = bareTurnServer.replace('turns:', 'turn:');
+ bareTurnServer = bareTurnServer.replace('443', '5004');
+
+ iceServers.push({
+ urls: bareTurnServer,
+ username: turnServerInfo.username || '',
+ credential: turnServerInfo.password || '',
+ });
+ }
+
+ // TURN-TLS server
iceServers.push({
urls: turnServerInfo.url,
username: turnServerInfo.username || '',
@@ -179,15 +190,13 @@ Media.createMediaConnection = (
config.bundlePolicy = bundlePolicy;
}
- const rtcMetrics = new RtcMetrics(webex, meetingId, correlationId);
-
return new MultistreamRoapMediaConnection(
config,
meetingId,
/* the rtc metrics objects callbacks */
- (data) => rtcMetrics.addMetrics(data),
- () => rtcMetrics.closeMetrics(),
- () => rtcMetrics.sendMetricsInQueue()
+ (data) => rtcMetrics?.addMetrics(data),
+ () => rtcMetrics?.closeMetrics(),
+ () => rtcMetrics?.sendMetricsInQueue()
);
}
diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts
index e255e3f633b..8a5d0ed05f2 100644
--- a/packages/@webex/plugin-meetings/src/meeting/index.ts
+++ b/packages/@webex/plugin-meetings/src/meeting/index.ts
@@ -24,6 +24,8 @@ import {
RoapMessage,
StatsAnalyzer,
StatsAnalyzerEventNames,
+ NetworkQualityEventNames,
+ NetworkQualityMonitor,
} from '@webex/internal-media-core';
import {
@@ -54,7 +56,6 @@ import {
AddMediaFailed,
} from '../common/errors/webex-errors';
-import NetworkQualityMonitor from '../networkQualityMonitor';
import LoggerProxy from '../common/logs/logger-proxy';
import EventsUtil from '../common/events/util';
import Trigger from '../common/events/trigger-proxy';
@@ -154,6 +155,7 @@ import ControlsOptionsManager from '../controls-options-manager';
import PermissionError from '../common/errors/permission';
import {LocusMediaRequest} from './locusMediaRequest';
import {ConnectionStateHandler, ConnectionStateEvent} from './connectionStateHandler';
+import RtcMetrics from '../rtcMetrics';
// default callback so we don't call an undefined function, but in practice it should never be used
const DEFAULT_ICE_PHASE_CALLBACK = () => 'JOIN_MEETING_FINAL';
@@ -695,6 +697,7 @@ export default class Meeting extends StatelessWebexPlugin {
private connectionStateHandler?: ConnectionStateHandler;
private iceCandidateErrors: Map;
private iceCandidatesCount: number;
+ private rtcMetrics?: RtcMetrics;
/**
* @param {Object} attrs
@@ -3155,6 +3158,7 @@ export default class Meeting extends StatelessWebexPlugin {
options: {meetingId: this.id},
});
}
+ this.rtcMetrics?.sendNextMetrics();
this.updateLLMConnection();
});
@@ -6306,14 +6310,17 @@ export default class Meeting extends StatelessWebexPlugin {
* @returns {RoapMediaConnection | MultistreamRoapMediaConnection}
*/
private async createMediaConnection(turnServerInfo, bundlePolicy?: BundlePolicy) {
+ this.rtcMetrics = this.isMultistream
+ ? // @ts-ignore
+ new RtcMetrics(this.webex, this.id, this.correlationId)
+ : undefined;
+
const mc = Media.createMediaConnection(
this.isMultistream,
this.getMediaConnectionDebugId(),
- // @ts-ignore
- this.webex,
this.id,
- this.correlationId,
{
+ rtcMetrics: this.rtcMetrics,
mediaProperties: this.mediaProperties,
remoteQualityLevel: this.mediaProperties.remoteQualityLevel,
// @ts-ignore - config coming from registerPlugin
@@ -6500,7 +6507,7 @@ export default class Meeting extends StatelessWebexPlugin {
});
this.setupStatsAnalyzerEventHandlers();
this.networkQualityMonitor.on(
- EVENT_TRIGGERS.NETWORK_QUALITY,
+ NetworkQualityEventNames.NETWORK_QUALITY,
this.sendNetworkQualityEvent.bind(this)
);
}
@@ -7022,6 +7029,7 @@ export default class Meeting extends StatelessWebexPlugin {
await this.mediaProperties.getCurrentConnectionInfo();
// @ts-ignore
const reachabilityStats = await this.webex.meetings.reachability.getReachabilityMetrics();
+ const iceCandidateErrors = Object.fromEntries(this.iceCandidateErrors);
Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.ADD_MEDIA_SUCCESS, {
correlation_id: this.correlationId,
@@ -7033,6 +7041,7 @@ export default class Meeting extends StatelessWebexPlugin {
retriedWithTurnServer: this.addMediaData.retriedWithTurnServer,
isJoinWithMediaRetry: this.joinWithMediaRetryInfo.isRetry,
...reachabilityStats,
+ ...iceCandidateErrors,
iceCandidatesCount: this.iceCandidatesCount,
});
// @ts-ignore
@@ -8191,7 +8200,7 @@ export default class Meeting extends StatelessWebexPlugin {
* @private
* @memberof Meeting
*/
- private sendNetworkQualityEvent(res: any) {
+ private sendNetworkQualityEvent(res: {networkQualityScore: number; mediaType: string}) {
Trigger.trigger(
this,
{
diff --git a/packages/@webex/plugin-meetings/src/meeting/muteState.ts b/packages/@webex/plugin-meetings/src/meeting/muteState.ts
index 1c3ed6ccbc2..e834514e732 100644
--- a/packages/@webex/plugin-meetings/src/meeting/muteState.ts
+++ b/packages/@webex/plugin-meetings/src/meeting/muteState.ts
@@ -379,7 +379,12 @@ export class MuteState {
}
if (muted !== undefined) {
this.state.server.remoteMute = muted;
- this.muteLocalStream(meeting, muted, 'remotelyMuted');
+
+ // We never want to unmute the local stream from a server remote mute update.
+ // Moderated unmute is handled by a different function.
+ if (muted) {
+ this.muteLocalStream(meeting, muted, 'remotelyMuted');
+ }
}
}
diff --git a/packages/@webex/plugin-meetings/src/networkQualityMonitor/index.ts b/packages/@webex/plugin-meetings/src/networkQualityMonitor/index.ts
deleted file mode 100644
index ee77feeb153..00000000000
--- a/packages/@webex/plugin-meetings/src/networkQualityMonitor/index.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-import EventsScope from '../common/events/events-scope';
-import {EVENT_TRIGGERS} from '../constants';
-
-/**
- * Meeting - network quality event
- * Emitted on each interval of retrieving stats Analyzer data
- * @event network:quality
- * @type {Object}
- * @property {string} mediaType {video|audio}
- * @property {number} networkQualityScore - value determined in determineUplinkNetworkQuality
- * @memberof NetworkQualityMonitor
- */
-/**
- * NetworkQualityMonitor class that will emit events based on detected quality
- *
- * @class NetworkQualityMonitor
- * @extends {EventsScope}
- */
-export default class NetworkQualityMonitor extends EventsScope {
- config: any;
- frequencyTypes: any;
- indicatorTypes: any;
- mediaType: any;
- networkQualityScore: any;
- networkQualityStatus: any;
-
- /**
- * Creates a new instance of NetworkQualityMonitor
- * @constructor
- * @public
- * @param {Object} config
- * @property {Object} indicatorTypes - network properties used to evaluate network quality used as constants
- * @property {Object} frequencyTypes - frequency properties used as constants {uplink|send} {downlink|receive}
- * @property {number} networkQualityScore - 0|1 1 is acceptable 0 is bad/unknown
- * @property {Object} networkQualityStatus - hash object based on indicatorTypes and frequencyTypes
- * @property {string} mediaType - audio|video
- */
- constructor(config: any) {
- super();
- this.config = config;
- this.indicatorTypes = Object.freeze({
- PACKETLOSS: 'packetLoss',
- LATENCY: 'latency',
- JITTER: 'jitter',
- });
- this.frequencyTypes = Object.freeze({
- UPLINK: 'uplink',
- DOWNLINK: 'downlink',
- });
- this.networkQualityScore = 1;
- this.networkQualityStatus = {
- [this.frequencyTypes.UPLINK]: {},
- };
- this.mediaType = null;
- }
-
- /**
- * emits NETWORK_QUALITY event on meeting with payload of media type and uplinkNetworkQuality score
- *
- * @memberof NetworkQualityMonitor
- * @returns {void}
- */
- emitNetworkQuality() {
- this.emit(
- {
- file: 'networkQualityMonitor',
- function: 'emitNetworkQuality',
- },
- EVENT_TRIGGERS.NETWORK_QUALITY,
- {
- mediaType: this.mediaType,
- networkQualityScore: this.networkQualityScore,
- }
- );
- }
-
- /**
- * invokes emitNetworkQuality method resets values back to default
- * @returns {void}
- * @memberof NetworkQualityMonitor
- */
- updateNetworkQualityStatus() {
- this.emitNetworkQuality();
-
- // reset values
- this.networkQualityScore = 1;
- this.mediaType = null;
- }
-
- /**
- * filter data to determine uplink network quality, invoked on same interval as stats analyzer remote-inbout-rtp
- * @param {Object} configObj
- * @param {string} configObj.mediaType {audio|video}
- * @param {RTCStats} configObj.remoteRtpResults RTC stats remote obj
- * @param {Object} configObj.statsAnalyzerCurrentStats statsResults
- * @returns {void}
- * @public
- * @memberof NetworkQualityMonitor
- */
- public determineUplinkNetworkQuality({
- mediaType,
- remoteRtpResults,
- statsAnalyzerCurrentStats,
- }: {
- mediaType: string;
- remoteRtpResults: any;
- statsAnalyzerCurrentStats: object;
- }) {
- const roundTripTimeInMilliseconds = remoteRtpResults.roundTripTime * 1000;
- const jitterInMilliseconds = remoteRtpResults.jitter * 1000;
- const {currentPacketLossRatio} = statsAnalyzerCurrentStats[mediaType].send;
-
- this.mediaType = mediaType;
-
- const {JITTER, PACKETLOSS, LATENCY} = this.indicatorTypes;
- const {UPLINK} = this.frequencyTypes;
-
- /**
- * determines if packetLoss ratio is over threshold set in config
- * sets networkQualityScore to 0 if over threshold
- * @returns {boolean}
- */
- const determinePacketLoss = () => {
- if (currentPacketLossRatio > this.config.videoPacketLossRatioThreshold) {
- this.networkQualityScore = 0;
-
- return false;
- }
-
- return true;
- };
-
- /**
- * determines if round trip time value is over threshold set in config
- * sets networkQualityScore to 0 if over threshold
- * @returns {boolean}
- */
- const determineLatency = () => {
- if (roundTripTimeInMilliseconds > this.config.rttThreshold) {
- this.networkQualityScore = 0;
-
- return false;
- }
-
- return true;
- };
-
- /**
- * determines if jitter value is over threshold in config
- * sets networkQualityScore to 0 if over threshold
- * @returns {boolean}
- */
- const deterMineJitter = () => {
- if (jitterInMilliseconds > this.config.jitterThreshold) {
- this.networkQualityScore = 0;
-
- return false;
- }
-
- return true;
- };
-
- /**
- * returns null if val is specifically undefined
- * @param {(number|undefined)} value
- * @returns {(number|null)}
- */
- const determineIfUndefined = (value: number | undefined) =>
- typeof value === 'undefined' ? null : value;
-
- if (!this.networkQualityStatus[UPLINK][mediaType]) {
- this.networkQualityStatus[UPLINK][mediaType] = {};
- }
-
- /**
- * Values for some browsers specifically Safari will be undefined we explicitly set to null
- * https://bugs.webkit.org/show_bug.cgi?id=206645
- * https://bugs.webkit.org/show_bug.cgi?id=212668
- */
- // PACKET LOSS
- this.networkQualityStatus[UPLINK][mediaType][PACKETLOSS] = {
- acceptable: determinePacketLoss(),
- value: determineIfUndefined(currentPacketLossRatio),
- };
-
- // LATENCY measured in Round trip time
- this.networkQualityStatus[UPLINK][mediaType][LATENCY] = {
- acceptable: determineLatency(),
- value: determineIfUndefined(remoteRtpResults.roundTripTime),
- };
-
- // JITTER
- this.networkQualityStatus[UPLINK][mediaType][JITTER] = {
- acceptable: deterMineJitter(),
- value: determineIfUndefined(remoteRtpResults.jitter),
- };
-
- this.updateNetworkQualityStatus();
- }
-
- /**
- * Get the current status of network quaility object - networkQualityStatus
- * @returns {Object}
- * @public
- */
- get networkQualityStats() {
- const {UPLINK} = this.frequencyTypes;
-
- return this.networkQualityStatus[UPLINK];
- }
-}
diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts
index 7f67e44ff74..17b60313ab0 100644
--- a/packages/@webex/plugin-meetings/src/reachability/index.ts
+++ b/packages/@webex/plugin-meetings/src/reachability/index.ts
@@ -114,6 +114,32 @@ export default class Reachability extends EventsScope {
this.clusterReachability = {};
}
+ /**
+ * Fetches the list of media clusters from the backend
+ * @param {boolean} isRetry
+ * @private
+ * @returns {Promise<{clusters: ClusterList, joinCookie: any}>}
+ */
+ async getClusters(isRetry = false): Promise<{clusters: ClusterList; joinCookie: any}> {
+ try {
+ const {clusters, joinCookie} = await this.reachabilityRequest.getClusters(
+ MeetingUtil.getIpVersion(this.webex)
+ );
+
+ return {clusters, joinCookie};
+ } catch (error) {
+ if (isRetry) {
+ throw error;
+ }
+
+ LoggerProxy.logger.error(
+ `Reachability:index#getClusters --> Failed with error: ${error}, retrying...`
+ );
+
+ return this.getClusters(true);
+ }
+ }
+
/**
* Gets a list of media clusters from the backend and performs reachability checks on all the clusters
* @returns {Promise} reachability results
@@ -123,9 +149,12 @@ export default class Reachability extends EventsScope {
public async gatherReachability(): Promise {
// Fetch clusters and measure latency
try {
- const {clusters, joinCookie} = await this.reachabilityRequest.getClusters(
- MeetingUtil.getIpVersion(this.webex)
- );
+ // kick off ip version detection. For now we don't await it, as we're doing it
+ // to gather the timings and send them with our reachability metrics
+ // @ts-ignore
+ this.webex.internal.device.ipNetworkDetector.detect();
+
+ const {clusters, joinCookie} = await this.getClusters();
// @ts-ignore
await this.webex.boundedStorage.put(
@@ -513,6 +542,16 @@ export default class Reachability extends EventsScope {
tcp: this.getStatistics(results, 'tcp', false),
xtls: this.getStatistics(results, 'xtls', false),
},
+ ipver: {
+ // @ts-ignore
+ firstIpV4: this.webex.internal.device.ipNetworkDetector.firstIpV4,
+ // @ts-ignore
+ firstIpV6: this.webex.internal.device.ipNetworkDetector.firstIpV6,
+ // @ts-ignore
+ firstMdns: this.webex.internal.device.ipNetworkDetector.firstMdns,
+ // @ts-ignore
+ totalTime: this.webex.internal.device.ipNetworkDetector.totalTime,
+ },
};
Metrics.sendBehavioralMetric(
BEHAVIORAL_METRICS.REACHABILITY_COMPLETED,
diff --git a/packages/@webex/plugin-meetings/src/rtcMetrics/index.ts b/packages/@webex/plugin-meetings/src/rtcMetrics/index.ts
index a86c3eb9f33..6e9f6790011 100644
--- a/packages/@webex/plugin-meetings/src/rtcMetrics/index.ts
+++ b/packages/@webex/plugin-meetings/src/rtcMetrics/index.ts
@@ -34,6 +34,8 @@ export default class RtcMetrics {
connectionId: string;
+ shouldSendMetricsOnNextStatsReport: boolean;
+
/**
* Initialize the interval.
*
@@ -47,9 +49,7 @@ export default class RtcMetrics {
this.meetingId = meetingId;
this.webex = webex;
this.correlationId = correlationId;
- this.setNewConnectionId();
- // Send the first set of metrics at 5 seconds in the case of a user leaving the call shortly after joining.
- setTimeout(this.sendMetricsInQueue.bind(this), 5 * 1000);
+ this.resetConnection();
}
/**
@@ -64,6 +64,18 @@ export default class RtcMetrics {
}
}
+ /**
+ * Forces sending metrics when we get the next stats-report
+ *
+ * This is useful for cases when something important happens that affects the media connection,
+ * for example when we move from lobby into the meeting.
+ *
+ * @returns {void}
+ */
+ public sendNextMetrics() {
+ this.shouldSendMetricsOnNextStatsReport = true;
+ }
+
/**
* Add metrics items to the metrics queue.
*
@@ -79,6 +91,13 @@ export default class RtcMetrics {
this.metricsQueue.push(data);
+ if (this.shouldSendMetricsOnNextStatsReport && data.name === 'stats-report') {
+ // this is the first useful set of data (WCME gives it to us after 5s), send it out immediately
+ // in case the user is unhappy and closes the browser early
+ this.sendMetricsInQueue();
+ this.shouldSendMetricsOnNextStatsReport = false;
+ }
+
try {
// If a connection fails, send the rest of the metrics in queue and get a new connection id.
const parsedPayload = parseJsonPayload(data.payload);
@@ -88,7 +107,7 @@ export default class RtcMetrics {
parsedPayload.value === 'failed'
) {
this.sendMetricsInQueue();
- this.setNewConnectionId();
+ this.resetConnection();
}
} catch (e) {
console.error(e);
@@ -130,8 +149,9 @@ export default class RtcMetrics {
*
* @returns {void}
*/
- private setNewConnectionId() {
+ private resetConnection() {
this.connectionId = uuid.v4();
+ this.shouldSendMetricsOnNextStatsReport = true;
}
/**
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/selfUtils.js b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/selfUtils.js
index c0656946711..9ad22a69acb 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/locus-info/selfUtils.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/locus-info/selfUtils.js
@@ -345,37 +345,39 @@ describe('plugin-meetings', () => {
});
describe('mutedByOthersChanged', () => {
- it('throws an error if changedSelf is not provided', function() {
- assert.throws(() => SelfUtils.mutedByOthersChanged({}, null), 'New self must be defined to determine if self was muted by others.');
- });
-
- it('return false when oldSelf is not defined', function() {
- assert.equal(SelfUtils.mutedByOthersChanged(null, { remoteMuted: false }), false);
+ it('throws an error if changedSelf is not provided', function () {
+ assert.throws(
+ () => SelfUtils.mutedByOthersChanged({}, null),
+ 'New self must be defined to determine if self was muted by others.'
+ );
});
- it('should return true when remoteMuted is true on entry', function() {
- assert.equal(SelfUtils.mutedByOthersChanged(null, { remoteMuted: true }), true);
+ it('return false when oldSelf is not defined', function () {
+ assert.equal(SelfUtils.mutedByOthersChanged(null, {remoteMuted: false}), false);
});
- it('should return false when selfIdentity and modifiedBy are the same', function() {
- assert.equal(SelfUtils.mutedByOthersChanged(
- { remoteMuted: false },
- { remoteMuted: true, selfIdentity: 'user1', modifiedBy: 'user1' }
- ), false);
+ it('should return true when remoteMuted is true on entry', function () {
+ assert.equal(SelfUtils.mutedByOthersChanged(null, {remoteMuted: true}), true);
});
- it('should return true when remoteMuted values are different', function() {
- assert.equal(SelfUtils.mutedByOthersChanged(
- { remoteMuted: false },
- { remoteMuted: true, selfIdentity: 'user1', modifiedBy: 'user2' }
- ), true);
+ it('should return true when remoteMuted values are different', function () {
+ assert.equal(
+ SelfUtils.mutedByOthersChanged(
+ {remoteMuted: false},
+ {remoteMuted: true, selfIdentity: 'user1', modifiedBy: 'user2'}
+ ),
+ true
+ );
});
- it('should return true when remoteMuted is true and unmuteAllowed has changed', function() {
- assert.equal(SelfUtils.mutedByOthersChanged(
- { remoteMuted: true, unmuteAllowed: false },
- { remoteMuted: true, unmuteAllowed: true, selfIdentity: 'user1', modifiedBy: 'user2' }
- ), true);
+ it('should return true when remoteMuted is true and unmuteAllowed has changed', function () {
+ assert.equal(
+ SelfUtils.mutedByOthersChanged(
+ {remoteMuted: true, unmuteAllowed: false},
+ {remoteMuted: true, unmuteAllowed: true, selfIdentity: 'user1', modifiedBy: 'user2'}
+ ),
+ true
+ );
});
});
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts
index 4c5e7acea08..096577011de 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts
+++ b/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts
@@ -3,52 +3,50 @@ import Media from '@webex/plugin-meetings/src/media/index';
import {assert} from '@webex/test-helper-chai';
import sinon from 'sinon';
import StaticConfig from '@webex/plugin-meetings/src/common/config';
-import MockWebex from '@webex/test-helper-mock-webex';
describe('createMediaConnection', () => {
let clock;
beforeEach(() => {
clock = sinon.useFakeTimers();
});
- const webex = MockWebex();
const fakeRoapMediaConnection = {
id: 'roap media connection',
};
const fakeTrack = {
- id: 'any fake track'
- }
+ id: 'any fake track',
+ };
const fakeAudioStream = {
outputStream: {
getTracks: () => {
return [fakeTrack];
- }
- }
+ },
+ },
};
const fakeVideoStream = {
outputStream: {
getTracks: () => {
return [fakeTrack];
- }
- }
+ },
+ },
};
const fakeShareVideoStream = {
outputStream: {
getTracks: () => {
return [fakeTrack];
- }
- }
+ },
+ },
};
const fakeShareAudioStream = {
outputStream: {
getTracks: () => {
return [fakeTrack];
- }
- }
+ },
+ },
};
afterEach(() => {
sinon.restore();
- clock.uninstall()
+ clock.uninstall();
});
it('creates a RoapMediaConnection when multistream is disabled', () => {
@@ -61,7 +59,7 @@ describe('createMediaConnection', () => {
const ENABLE_EXTMAP = false;
const ENABLE_RTX = true;
- Media.createMediaConnection(false, 'some debug id', webex, 'meetingId', 'correlationId', {
+ Media.createMediaConnection(false, 'some debug id', 'meetingId', {
mediaProperties: {
mediaDirection: {
sendAudio: false,
@@ -80,7 +78,7 @@ describe('createMediaConnection', () => {
enableRtx: ENABLE_RTX,
enableExtmap: ENABLE_EXTMAP,
turnServerInfo: {
- url: 'turn server url',
+ url: 'turns:turn-server-url:443?transport=tcp',
username: 'turn username',
password: 'turn password',
},
@@ -91,7 +89,12 @@ describe('createMediaConnection', () => {
{
iceServers: [
{
- urls: 'turn server url',
+ urls: 'turn:turn-server-url:5004?transport=tcp',
+ username: 'turn username',
+ credential: 'turn password',
+ },
+ {
+ urls: 'turns:turn-server-url:443?transport=tcp',
username: 'turn username',
credential: 'turn password',
},
@@ -134,7 +137,13 @@ describe('createMediaConnection', () => {
.stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection')
.returns(fakeRoapMediaConnection);
- Media.createMediaConnection(true, 'some debug id', webex, 'meeting id', 'correlationId', {
+ const rtcMetrics = {
+ addMetrics: sinon.stub(),
+ closeMetrics: sinon.stub(),
+ sendMetricsInQueue: sinon.stub(),
+ };
+
+ Media.createMediaConnection(true, 'some debug id', 'meeting id', {
mediaProperties: {
mediaDirection: {
sendAudio: true,
@@ -145,8 +154,9 @@ describe('createMediaConnection', () => {
receiveShare: true,
},
},
+ rtcMetrics,
turnServerInfo: {
- url: 'turn server url',
+ url: 'turns:turn-server-url:443?transport=tcp',
username: 'turn username',
password: 'turn password',
},
@@ -158,7 +168,12 @@ describe('createMediaConnection', () => {
{
iceServers: [
{
- urls: 'turn server url',
+ urls: 'turn:turn-server-url:5004?transport=tcp',
+ username: 'turn username',
+ credential: 'turn password',
+ },
+ {
+ urls: 'turns:turn-server-url:443?transport=tcp',
username: 'turn username',
credential: 'turn password',
},
@@ -167,18 +182,42 @@ describe('createMediaConnection', () => {
},
'meeting id'
);
+
+ // check if rtcMetrics callbacks are configured correctly
+ const addMetricsCallback = multistreamRoapMediaConnectionConstructorStub.getCalls()[0].args[2];
+ const closeMetricsCallback = multistreamRoapMediaConnectionConstructorStub.getCalls()[0].args[3];
+ const sendMetricsInQueueCallback = multistreamRoapMediaConnectionConstructorStub.getCalls()[0].args[4];
+
+ assert.isFunction(addMetricsCallback);
+ assert.isFunction(closeMetricsCallback);
+ assert.isFunction(sendMetricsInQueueCallback);
+
+ const fakeMetricsData = {id: 'metrics data'};
+
+ addMetricsCallback(fakeMetricsData);
+ assert.calledOnceWithExactly(rtcMetrics.addMetrics, fakeMetricsData);
+
+ closeMetricsCallback();
+ assert.calledOnce(rtcMetrics.closeMetrics);
+
+ sendMetricsInQueueCallback();
+ assert.calledOnce(rtcMetrics.sendMetricsInQueue);
+
});
[
{testCase: 'turnServerInfo is undefined', turnServerInfo: undefined},
- {testCase: 'turnServerInfo.url is empty string', turnServerInfo: {url: '', username: 'turn username', password: 'turn password'}},
+ {
+ testCase: 'turnServerInfo.url is empty string',
+ turnServerInfo: {url: '', username: 'turn username', password: 'turn password'},
+ },
].forEach(({testCase, turnServerInfo}) => {
it(`passes empty ICE servers array to MultistreamRoapMediaConnection if ${testCase} (multistream enabled)`, () => {
const multistreamRoapMediaConnectionConstructorStub = sinon
.stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection')
.returns(fakeRoapMediaConnection);
- Media.createMediaConnection(true, 'debug string', webex, 'meeting id', 'correlationId', {
+ Media.createMediaConnection(true, 'debug string', 'meeting id', {
mediaProperties: {
mediaDirection: {
sendAudio: true,
@@ -198,7 +237,7 @@ describe('createMediaConnection', () => {
iceServers: [],
},
'meeting id'
- );
+ );
});
});
@@ -207,7 +246,7 @@ describe('createMediaConnection', () => {
.stub(InternalMediaCoreModule, 'MultistreamRoapMediaConnection')
.returns(fakeRoapMediaConnection);
- Media.createMediaConnection(true, 'debug string', webex, 'meeting id', 'correlationId', {
+ Media.createMediaConnection(true, 'debug string', 'meeting id', {
mediaProperties: {
mediaDirection: {
sendAudio: true,
@@ -232,7 +271,10 @@ describe('createMediaConnection', () => {
[
{testCase: 'turnServerInfo is undefined', turnServerInfo: undefined},
- {testCase: 'turnServerInfo.url is empty string', turnServerInfo: {url: '', username: 'turn username', password: 'turn password'}},
+ {
+ testCase: 'turnServerInfo.url is empty string',
+ turnServerInfo: {url: '', username: 'turn username', password: 'turn password'},
+ },
].forEach(({testCase, turnServerInfo}) => {
it(`passes empty ICE servers array to RoapMediaConnection if ${testCase} (multistream disabled)`, () => {
const roapMediaConnectionConstructorStub = sinon
@@ -244,7 +286,7 @@ describe('createMediaConnection', () => {
const ENABLE_EXTMAP = false;
const ENABLE_RTX = true;
- Media.createMediaConnection(false, 'some debug id', webex, 'meeting id', 'correlationId', {
+ Media.createMediaConnection(false, 'some debug id', 'meeting id', {
mediaProperties: {
mediaDirection: {
sendAudio: true,
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js
index 24e352c815f..85b5bd30df5 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js
@@ -5,6 +5,8 @@ import 'jsdom-global/register';
import {cloneDeep, forEach, isEqual, isUndefined} from 'lodash';
import sinon from 'sinon';
import * as InternalMediaCoreModule from '@webex/internal-media-core';
+import * as RtcMetricsModule from '@webex/plugin-meetings/src/rtcMetrics';
+import * as RemoteMediaManagerModule from '@webex/plugin-meetings/src/multistream/remoteMediaManager';
import StateMachine from 'javascript-state-machine';
import uuid from 'uuid';
import {assert, expect} from '@webex/test-helper-chai';
@@ -625,7 +627,9 @@ describe('plugin-meetings', () => {
beforeEach(() => {
meeting.join = sinon.stub().returns(Promise.resolve(fakeJoinResult));
- addMediaInternalStub = sinon.stub(meeting, 'addMediaInternal').returns(Promise.resolve(test4));
+ addMediaInternalStub = sinon
+ .stub(meeting, 'addMediaInternal')
+ .returns(Promise.resolve(test4));
webex.meetings.reachability.getReachabilityResults.resolves(fakeReachabilityResults);
@@ -656,12 +660,21 @@ describe('plugin-meetings', () => {
meeting,
fakeJoinResult
);
- assert.calledOnceWithExactly(meeting.addMediaInternal, sinon.match.any, fakeTurnServerInfo, false, mediaOptions);
+ assert.calledOnceWithExactly(
+ meeting.addMediaInternal,
+ sinon.match.any,
+ fakeTurnServerInfo,
+ false,
+ mediaOptions
+ );
assert.deepEqual(result, {join: fakeJoinResult, media: test4});
// resets joinWithMediaRetryInfo
- assert.deepEqual(meeting.joinWithMediaRetryInfo, {isRetry: false, prevJoinResponse: undefined});
+ assert.deepEqual(meeting.joinWithMediaRetryInfo, {
+ isRetry: false,
+ prevJoinResponse: undefined,
+ });
});
it("should not call handleTurnDiscoveryHttpResponse if we don't send a TURN discovery request with join", async () => {
@@ -681,7 +694,13 @@ describe('plugin-meetings', () => {
assert.calledOnceWithExactly(generateTurnDiscoveryRequestMessageStub, meeting, true);
assert.notCalled(handleTurnDiscoveryHttpResponseStub);
assert.notCalled(abortTurnDiscoveryStub);
- assert.calledOnceWithExactly(meeting.addMediaInternal, sinon.match.any, undefined, false, mediaOptions);
+ assert.calledOnceWithExactly(
+ meeting.addMediaInternal,
+ sinon.match.any,
+ undefined,
+ false,
+ mediaOptions
+ );
assert.deepEqual(result, {join: fakeJoinResult, media: test4});
assert.equal(meeting.turnServerUsed, false);
@@ -711,7 +730,13 @@ describe('plugin-meetings', () => {
fakeJoinResult
);
assert.calledOnceWithExactly(abortTurnDiscoveryStub);
- assert.calledOnceWithExactly(meeting.addMediaInternal, sinon.match.any, undefined, false, mediaOptions);
+ assert.calledOnceWithExactly(
+ meeting.addMediaInternal,
+ sinon.match.any,
+ undefined,
+ false,
+ mediaOptions
+ );
assert.deepEqual(result, {join: fakeJoinResult, media: test4});
});
@@ -758,12 +783,20 @@ describe('plugin-meetings', () => {
);
// resets joinWithMediaRetryInfo
- assert.deepEqual(meeting.joinWithMediaRetryInfo, {isRetry: false, prevJoinResponse: undefined});
+ assert.deepEqual(meeting.joinWithMediaRetryInfo, {
+ isRetry: false,
+ prevJoinResponse: undefined,
+ });
});
it('should resolve if join() fails the first time but succeeds the second time', async () => {
const error = new Error('fake');
- meeting.join = sinon.stub().onFirstCall().returns(Promise.reject(error)).onSecondCall().returns(Promise.resolve(fakeJoinResult));
+ meeting.join = sinon
+ .stub()
+ .onFirstCall()
+ .returns(Promise.reject(error))
+ .onSecondCall()
+ .returns(Promise.resolve(fakeJoinResult));
const leaveStub = sinon.stub(meeting, 'leave').resolves();
const result = await meeting.joinWithMedia({
@@ -795,7 +828,10 @@ describe('plugin-meetings', () => {
assert.deepEqual(result, {join: fakeJoinResult, media: test4});
// resets joinWithMediaRetryInfo
- assert.deepEqual(meeting.joinWithMediaRetryInfo, {isRetry: false, prevJoinResponse: undefined});
+ assert.deepEqual(meeting.joinWithMediaRetryInfo, {
+ isRetry: false,
+ prevJoinResponse: undefined,
+ });
});
it('should fail if called with allowMediaInLobby:false', async () => {
@@ -828,7 +864,6 @@ describe('plugin-meetings', () => {
reason: 'joinWithMedia failure',
});
-
// Behavioral metric is sent on both calls of joinWithMedia
assert.calledTwice(Metrics.sendBehavioralMetric);
assert.calledWith(
@@ -1068,12 +1103,15 @@ describe('plugin-meetings', () => {
const addMediaError = new Error('fake addMedia error');
addMediaError.name = 'SdpOfferCreationError';
- meeting.addMediaInternal.rejects(addMediaError)
+ meeting.addMediaInternal.rejects(addMediaError);
- await assert.isRejected(meeting.joinWithMedia({
- joinOptions,
- mediaOptions,
- }), addMediaError);
+ await assert.isRejected(
+ meeting.joinWithMedia({
+ joinOptions,
+ mediaOptions,
+ }),
+ addMediaError
+ );
// check that only 1 attempt was done
assert.calledOnce(meeting.join);
@@ -1339,10 +1377,7 @@ describe('plugin-meetings', () => {
it('should trigger meeting:caption-received event', () => {
meeting.voiceaListenerCallbacks[VOICEAEVENTS.NEW_CAPTION]({});
- assert.calledWith(
- meeting.trigger,
- EVENT_TRIGGERS.MEETING_CAPTION_RECEIVED
- );
+ assert.calledWith(meeting.trigger, EVENT_TRIGGERS.MEETING_CAPTION_RECEIVED);
});
it('should trigger meeting:receiveTranscription:started event', () => {
@@ -1355,10 +1390,7 @@ describe('plugin-meetings', () => {
it('should trigger meeting:caption-received event', () => {
meeting.voiceaListenerCallbacks[VOICEAEVENTS.NEW_CAPTION]({});
- assert.calledWith(
- meeting.trigger,
- EVENT_TRIGGERS.MEETING_CAPTION_RECEIVED
- );
+ assert.calledWith(meeting.trigger, EVENT_TRIGGERS.MEETING_CAPTION_RECEIVED);
});
});
@@ -1513,11 +1545,7 @@ describe('plugin-meetings', () => {
it('turns off llm online, emits transcription connected events', () => {
meeting.handleLLMOnline();
- assert.calledOnceWithExactly(
- webex.internal.llm.off,
- 'online',
- meeting.handleLLMOnline
- );
+ assert.calledOnceWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
assert.calledWith(
TriggerProxy.trigger,
sinon.match.instanceOf(Meeting),
@@ -1579,11 +1607,7 @@ describe('plugin-meetings', () => {
assert.calledOnce(MeetingUtil.joinMeeting);
assert.calledOnce(meeting.setLocus);
assert.equal(result, joinMeetingResult);
- assert.calledWith(
- webex.internal.llm.on,
- 'online',
- meeting.handleLLMOnline
- );
+ assert.calledWith(webex.internal.llm.on, 'online', meeting.handleLLMOnline);
});
[true, false].forEach((enableMultistream) => {
@@ -1906,7 +1930,9 @@ describe('plugin-meetings', () => {
};
meeting.mediaProperties.setMediaDirection = sinon.stub().returns(true);
meeting.mediaProperties.waitForMediaConnectionConnected = sinon.stub().resolves();
- meeting.mediaProperties.getCurrentConnectionInfo = sinon.stub().resolves({connectionType: 'udp', selectedCandidatePairChanges: 2, numTransports: 1});
+ meeting.mediaProperties.getCurrentConnectionInfo = sinon
+ .stub()
+ .resolves({connectionType: 'udp', selectedCandidatePairChanges: 2, numTransports: 1});
meeting.audio = muteStateStub;
meeting.video = muteStateStub;
sinon.stub(Media, 'createMediaConnection').returns(fakeMediaConnection);
@@ -1920,7 +1946,12 @@ describe('plugin-meetings', () => {
// normally the first Roap message we send is creating confluence, so mock LocusMediaRequest.isConfluenceCreated()
// to return false the first time it's called and true the 2nd time, to simulate how it would happen for real
meeting.locusMediaRequest = {
- isConfluenceCreated: sinon.stub().onFirstCall().returns(false).onSecondCall().returns(true)
+ isConfluenceCreated: sinon
+ .stub()
+ .onFirstCall()
+ .returns(false)
+ .onSecondCall()
+ .returns(true),
};
});
@@ -2376,9 +2407,7 @@ describe('plugin-meetings', () => {
Media.createMediaConnection,
false,
meeting.getMediaConnectionDebugId(),
- webex,
meeting.id,
- meeting.correlationId,
sinon.match({turnServerInfo: undefined})
);
assert.calledOnce(meeting.setMercuryListener);
@@ -2420,6 +2449,44 @@ describe('plugin-meetings', () => {
checkWorking({allowMediaInLobby: true});
});
+ it('should create rtcMetrics and pass them to Media.createMediaConnection()', async () => {
+ const fakeRtcMetrics = {id: 'fake rtc metrics object'};
+ const rtcMetricsCtor = sinon.stub(RtcMetricsModule, 'default').returns(fakeRtcMetrics);
+
+ // setup the minimum mocks required for multistream connection
+ fakeMediaConnection.createSendSlot = sinon.stub().returns({
+ publishStream: sinon.stub(),
+ unpublishStream: sinon.stub(),
+ setNamedMediaGroups: sinon.stub(),
+ });
+ sinon.stub(RemoteMediaManagerModule, 'RemoteMediaManager').returns({
+ start: sinon.stub().resolves(),
+ on: sinon.stub(),
+ logAllReceiveSlots: sinon.stub(),
+ });
+
+ meeting.meetingState = 'ACTIVE';
+ meeting.isMultistream = true;
+
+ await meeting.addMedia({
+ mediaSettings: {},
+ });
+
+ assert.calledOnceWithExactly(rtcMetricsCtor, webex, meeting.id, meeting.correlationId);
+
+ // check that rtcMetrics was passed to Media.createMediaConnection
+ assert.calledOnce(Media.createMediaConnection);
+ assert.calledWith(
+ Media.createMediaConnection,
+ true,
+ meeting.getMediaConnectionDebugId(),
+ meeting.id,
+ sinon.match({
+ rtcMetrics: fakeRtcMetrics,
+ })
+ );
+ });
+
it('should pass the turn server info to the peer connection', async () => {
const FAKE_TURN_URL = 'turns:webex.com:3478';
const FAKE_TURN_USER = 'some-turn-username';
@@ -2449,9 +2516,7 @@ describe('plugin-meetings', () => {
Media.createMediaConnection,
false,
meeting.getMediaConnectionDebugId(),
- webex,
meeting.id,
- meeting.correlationId,
sinon.match({
turnServerInfo: {
url: FAKE_TURN_URL,
@@ -2485,7 +2550,6 @@ describe('plugin-meetings', () => {
it('should reject if waitForMediaConnectionConnected() rejects after turn server retry', async () => {
const FAKE_ERROR = {fatal: true};
const getErrorPayloadForClientErrorCodeStub =
-
(webex.internal.newMetrics.callDiagnosticMetrics.getErrorPayloadForClientErrorCode =
sinon.stub().returns(FAKE_ERROR));
webex.meetings.reachability = {
@@ -2690,9 +2754,16 @@ describe('plugin-meetings', () => {
it('should resolve if waitForMediaConnectionConnected() rejects the first time but resolves the second time', async () => {
const FAKE_ERROR = {fatal: true};
webex.meetings.reachability = {
- isWebexMediaBackendUnreachable: sinon.stub().onCall(0).rejects().onCall(1).resolves(true).onCall(2).resolves(false),
+ isWebexMediaBackendUnreachable: sinon
+ .stub()
+ .onCall(0)
+ .rejects()
+ .onCall(1)
+ .resolves(true)
+ .onCall(2)
+ .resolves(false),
getReachabilityMetrics: sinon.stub().resolves({}),
- }
+ };
const getErrorPayloadForClientErrorCodeStub =
(webex.internal.newMetrics.callDiagnosticMetrics.getErrorPayloadForClientErrorCode =
sinon.stub().returns(FAKE_ERROR));
@@ -2988,6 +3059,8 @@ describe('plugin-meetings', () => {
}),
};
meeting.iceCandidatesCount = 3;
+ meeting.iceCandidateErrors.set('701_error', 3);
+ meeting.iceCandidateErrors.set('701_turn_host_lookup_received_error', 1);
await meeting.addMedia({
mediaSettings: {},
@@ -3009,6 +3082,8 @@ describe('plugin-meetings', () => {
someReachabilityMetric1: 'some value1',
someReachabilityMetric2: 'some value2',
iceCandidatesCount: 3,
+ '701_error': 3,
+ '701_turn_host_lookup_received_error': 1,
}
);
@@ -3362,9 +3437,7 @@ describe('plugin-meetings', () => {
Media.createMediaConnection,
false,
meeting.getMediaConnectionDebugId(),
- webex,
meeting.id,
- meeting.correlationId,
sinon.match({
turnServerInfo: {
url: FAKE_TURN_URL,
@@ -3445,9 +3518,9 @@ describe('plugin-meetings', () => {
.returns(clientErrorCode);
meeting.meetingState = 'ACTIVE';
- meeting.mediaProperties.waitForMediaConnectionConnected.rejects(
- {iceConnected: false}
- );
+ meeting.mediaProperties.waitForMediaConnectionConnected.rejects({
+ iceConnected: false,
+ });
let errorThrown = false;
@@ -3562,12 +3635,18 @@ describe('plugin-meetings', () => {
meeting.meetingState = 'ACTIVE';
meeting.selfUrl = 'selfUrl';
meeting.mediaProperties.waitForMediaConnectionConnected = sinon.stub().resolves();
- meeting.mediaProperties.getCurrentConnectionInfo = sinon.stub().resolves({connectionType: 'udp', selectedCandidatePairChanges: 2, numTransports: 1});
+ meeting.mediaProperties.getCurrentConnectionInfo = sinon
+ .stub()
+ .resolves({connectionType: 'udp', selectedCandidatePairChanges: 2, numTransports: 1});
meeting.setMercuryListener = sinon.stub();
meeting.locusInfo.onFullLocus = sinon.stub();
meeting.webex.meetings.geoHintInfo = {regionCode: 'EU', countryCode: 'UK'};
meeting.roap.doTurnDiscovery = sinon.stub().resolves({
- turnServerInfo: {url: 'turn-url', username: 'turn user', password: 'turn password'},
+ turnServerInfo: {
+ url: 'turns:turn-server-url:443?transport=tcp',
+ username: 'turn user',
+ password: 'turn password',
+ },
turnDiscoverySkippedReason: 'reachability',
});
meeting.deferSDPAnswer = new Defer();
@@ -3580,7 +3659,18 @@ describe('plugin-meetings', () => {
// setup things that are expected to be the same across all the tests and are actually irrelevant for these tests
expectedDebugId = `MC-${meeting.id.substring(0, 4)}`;
expectedMediaConnectionConfig = {
- iceServers: [{urls: 'turn-url', username: 'turn user', credential: 'turn password'}],
+ iceServers: [
+ {
+ urls: 'turn:turn-server-url:5004?transport=tcp',
+ username: 'turn user',
+ credential: 'turn password',
+ },
+ {
+ urls: 'turns:turn-server-url:443?transport=tcp',
+ username: 'turn user',
+ credential: 'turn password',
+ },
+ ],
skipInactiveTransceivers: false,
requireH264: true,
sdpMunging: {
@@ -3665,13 +3755,11 @@ describe('plugin-meetings', () => {
// that's being tested in these tests)
meeting.webex.meetings.registered = true;
meeting.webex.internal.device.config = {};
- sinon
- .stub(MeetingUtil, 'joinMeeting')
- .resolves({
- id: 'fake locus from mocked join request',
- locusUrl: 'fake locus url',
- mediaId: 'fake media id',
- });
+ sinon.stub(MeetingUtil, 'joinMeeting').resolves({
+ id: 'fake locus from mocked join request',
+ locusUrl: 'fake locus url',
+ mediaId: 'fake media id',
+ });
await meeting.join({enableMultistream: isMultistream});
});
@@ -3701,7 +3789,8 @@ describe('plugin-meetings', () => {
for (let idx = 0; idx < roapMediaConnectionToCheck.on.callCount; idx += 1) {
if (
- roapMediaConnectionToCheck.on.getCall(idx).args[0] === MediaConnectionEventNames.ROAP_MESSAGE_TO_SEND
+ roapMediaConnectionToCheck.on.getCall(idx).args[0] ===
+ MediaConnectionEventNames.ROAP_MESSAGE_TO_SEND
) {
return roapMediaConnectionToCheck.on.getCall(idx).args[1];
}
@@ -4148,13 +4237,13 @@ describe('plugin-meetings', () => {
await meeting.addMedia({
localStreams: {microphone: fakeMicrophoneStream},
audioEnabled: false,
- videoEnabled: false
+ videoEnabled: false,
});
await simulateRoapOffer();
await simulateRoapOk();
assert.notCalled(handleDeviceLoggingSpy);
- })
+ });
it('addMedia() works correctly when media is disabled with no streams to publish', async () => {
await meeting.addMedia({audioEnabled: false});
@@ -6589,14 +6678,14 @@ describe('plugin-meetings', () => {
beforeEach(() => {
sandbox = sinon.createSandbox();
meeting.statsAnalyzer = {
- stopAnalyzer: sinon.stub().returns(Promise.resolve())
+ stopAnalyzer: sinon.stub().returns(Promise.resolve()),
};
meeting.reconnectionManager = {
- cleanUp: sinon.stub()
+ cleanUp: sinon.stub(),
};
- meeting.cleanupLocalStreams=sinon.stub();
+ meeting.cleanupLocalStreams = sinon.stub();
meeting.closeRemoteStreams = sinon.stub().returns(Promise.resolve());
meeting.closePeerConnections = sinon.stub().returns(Promise.resolve());
meeting.unsetRemoteStreams = sinon.stub();
@@ -6678,7 +6767,6 @@ describe('plugin-meetings', () => {
'SELF_OBSERVING'
);
-
// Verify that the event handler behaves as expected
expect(meeting.statsAnalyzer.stopAnalyzer.calledOnce).to.be.true;
expect(meeting.closeRemoteStreams.calledOnce).to.be.true;
@@ -6690,11 +6778,13 @@ describe('plugin-meetings', () => {
expect(meeting.unsetPeerConnections.calledOnce).to.be.true;
expect(meeting.reconnectionManager.cleanUp.calledOnce).to.be.true;
expect(meeting.mediaProperties.setMediaDirection.calledOnce).to.be.true;
- expect(meeting.addMedia.calledOnceWithExactly({
- audioEnabled: false,
- videoEnabled: false,
- shareVideoEnabled: true
- })).to.be.true;
+ expect(
+ meeting.addMedia.calledOnceWithExactly({
+ audioEnabled: false,
+ videoEnabled: false,
+ shareVideoEnabled: true,
+ })
+ ).to.be.true;
await testUtils.flushPromises();
assert.equal(meeting.isMoveToInProgress, false);
});
@@ -7482,9 +7572,11 @@ describe('plugin-meetings', () => {
getTracks: () => [{id: 'track', addEventListener: sinon.stub()}],
};
const simulateConnectionStateChange = (newState) => {
- meeting.mediaProperties.webrtcMediaConnection.getConnectionState = sinon.stub().returns(newState);
+ meeting.mediaProperties.webrtcMediaConnection.getConnectionState = sinon
+ .stub()
+ .returns(newState);
eventListeners[MediaConnectionEventNames.PEER_CONNECTION_STATE_CHANGED]();
- }
+ };
beforeEach(() => {
eventListeners = {};
@@ -7506,7 +7598,9 @@ describe('plugin-meetings', () => {
assert.isFunction(eventListeners[MediaConnectionEventNames.ROAP_FAILURE]);
assert.isFunction(eventListeners[MediaConnectionEventNames.ROAP_MESSAGE_TO_SEND]);
assert.isFunction(eventListeners[MediaConnectionEventNames.REMOTE_TRACK_ADDED]);
- assert.isFunction(eventListeners[MediaConnectionEventNames.PEER_CONNECTION_STATE_CHANGED]);
+ assert.isFunction(
+ eventListeners[MediaConnectionEventNames.PEER_CONNECTION_STATE_CHANGED]
+ );
assert.isFunction(eventListeners[MediaConnectionEventNames.ICE_CONNECTION_STATE_CHANGED]);
assert.isFunction(eventListeners[MediaConnectionEventNames.ICE_CANDIDATE]);
assert.isFunction(eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]);
@@ -7569,28 +7663,44 @@ describe('plugin-meetings', () => {
});
it('should not collect skipped ice candidates error', () => {
- eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({error: { errorCode: 600, errorText: 'Address not associated with the desired network interface.' }});
+ eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({
+ error: {
+ errorCode: 600,
+ errorText: 'Address not associated with the desired network interface.',
+ },
+ });
assert.equal(meeting.iceCandidateErrors.size, 0);
});
it('should collect valid ice candidates error', () => {
- eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({error: { errorCode: 701, errorText: '' }});
+ eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({
+ error: {errorCode: 701, errorText: ''},
+ });
assert.equal(meeting.iceCandidateErrors.size, 1);
assert.equal(meeting.iceCandidateErrors.has('701_'), true);
});
it('should increment counter if same valid ice candidates error collected', () => {
- eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({error: { errorCode: 701, errorText: '' }});
+ eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({
+ error: {errorCode: 701, errorText: ''},
+ });
- eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({error: { errorCode: 701, errorText: 'STUN host lookup received error.' }});
- eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({error: { errorCode: 701, errorText: 'STUN host lookup received error.' }});
+ eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({
+ error: {errorCode: 701, errorText: 'STUN host lookup received error.'},
+ });
+ eventListeners[MediaConnectionEventNames.ICE_CANDIDATE_ERROR]({
+ error: {errorCode: 701, errorText: 'STUN host lookup received error.'},
+ });
assert.equal(meeting.iceCandidateErrors.size, 2);
assert.equal(meeting.iceCandidateErrors.has('701_'), true);
assert.equal(meeting.iceCandidateErrors.get('701_'), 1);
- assert.equal(meeting.iceCandidateErrors.has('701_stun_host_lookup_received_error'), true);
+ assert.equal(
+ meeting.iceCandidateErrors.has('701_stun_host_lookup_received_error'),
+ true
+ );
assert.equal(meeting.iceCandidateErrors.get('701_stun_host_lookup_received_error'), 2);
});
});
@@ -8263,8 +8373,12 @@ describe('plugin-meetings', () => {
});
it('registers for audio and video source count changed', () => {
- assert.isFunction(eventListeners[MediaConnectionEventNames.VIDEO_SOURCES_COUNT_CHANGED]);
- assert.isFunction(eventListeners[MediaConnectionEventNames.AUDIO_SOURCES_COUNT_CHANGED]);
+ assert.isFunction(
+ eventListeners[MediaConnectionEventNames.VIDEO_SOURCES_COUNT_CHANGED]
+ );
+ assert.isFunction(
+ eventListeners[MediaConnectionEventNames.AUDIO_SOURCES_COUNT_CHANGED]
+ );
});
it('forwards the VIDEO_SOURCES_COUNT_CHANGED event as "media:remoteVideoSourceCountChanged"', () => {
@@ -8374,6 +8488,9 @@ describe('plugin-meetings', () => {
it('listens to the self admitted guest event', (done) => {
meeting.stopKeepAlive = sinon.stub();
meeting.updateLLMConnection = sinon.stub();
+ meeting.rtcMetrics = {
+ sendNextMetrics: sinon.stub(),
+ };
meeting.locusInfo.emit({function: 'test', file: 'test'}, 'SELF_ADMITTED_GUEST', test1);
assert.calledOnceWithExactly(meeting.stopKeepAlive);
assert.calledThrice(TriggerProxy.trigger);
@@ -8385,6 +8502,8 @@ describe('plugin-meetings', () => {
{payload: test1}
);
assert.calledOnce(meeting.updateLLMConnection);
+ assert.calledOnceWithExactly(meeting.rtcMetrics.sendNextMetrics);
+
done();
});
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/muteState.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/muteState.js
index 217a265a708..7af0df9e88b 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/muteState.js
+++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/muteState.js
@@ -113,6 +113,30 @@ describe('plugin-meetings', () => {
assert.isTrue(audio.isRemotelyMuted());
});
+ it('does not locally unmute on a server unmute', async () => {
+ const setServerMutedSpy = meeting.mediaProperties.audioStream.setServerMuted;
+
+ // simulate remote mute
+ audio.handleServerRemoteMuteUpdate(meeting, true, true);
+
+ assert.isTrue(audio.isRemotelyMuted());
+ assert.isTrue(audio.isLocallyMuted());
+
+ // mutes local
+ assert.calledOnceWithExactly(setServerMutedSpy, true, 'remotelyMuted');
+
+ setServerMutedSpy.resetHistory();
+
+ // simulate remote unmute
+ audio.handleServerRemoteMuteUpdate(meeting, false, true);
+
+ assert.isFalse(audio.isRemotelyMuted());
+ assert.isTrue(audio.isLocallyMuted());
+
+ // does not unmute local
+ assert.notCalled(setServerMutedSpy);
+ });
+
it('does local audio unmute if localAudioUnmuteRequired is received', async () => {
// first we need to have the local stream user muted
meeting.mediaProperties.audioStream.userMuted = true;
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/networkQualityMonitor/index.js b/packages/@webex/plugin-meetings/test/unit/spec/networkQualityMonitor/index.js
deleted file mode 100644
index 762effbb645..00000000000
--- a/packages/@webex/plugin-meetings/test/unit/spec/networkQualityMonitor/index.js
+++ /dev/null
@@ -1,99 +0,0 @@
-import 'jsdom-global/register';
-import chai from 'chai';
-import chaiAsPromised from 'chai-as-promised';
-import sinon from 'sinon';
-
-import NetworkQualityMonitor from '../../../../src/networkQualityMonitor';
-import {EVENT_TRIGGERS} from '../../../../src/constants';
-
-const {assert} = chai;
-
-chai.use(chaiAsPromised);
-sinon.assert.expose(chai.assert, {prefix: ''});
-
-// eslint-disable-next-line mocha/no-exclusive-tests
-describe('plugin-meetings', () => {
- describe('NetworkQualityMonitor', () => {
- let networkQualityMonitor;
- let sandBoxEmitSpy;
-
- const initialConfig = {
- videoPacketLossRatioThreshold: 9,
- rttThreshold: 500,
- jitterThreshold: 500,
- };
-
- const configObject = {
- mediaType: 'video-send',
- remoteRtpResults: {
- id: 'RTCRemoteInboundRtpVideoStream_2411086660',
- timestamp: 1624472676193.79,
- type: 'remote-inbound-rtp',
- ssrc: 2411086660,
- kind: 'video',
- transportId: 'RTCTransport_1_1',
- codecId: 'RTCCodec_1_Outbound_102',
- jitter: 0.004,
- packetsLost: 8,
- localId: 'RTCOutboundRTPVideoStream_2411086660',
- roundTripTime: 0.648,
- fractionLost: 0,
- totalRoundTripTime: 3.554,
- roundTripTimeMeasurements: 14,
- },
- statsAnalyzerCurrentStats: {
- 'audio-send': {
- send: {
- currentPacketLossRatio: 8,
- },
- },
- 'video-send': {
- send: {
- currentPacketLossRatio: 10,
- },
- },
- },
- };
-
- const sandbox = sinon.createSandbox();
-
- beforeEach(() => {
- networkQualityMonitor = new NetworkQualityMonitor(initialConfig);
- sandbox.spy(networkQualityMonitor, 'updateNetworkQualityStatus');
- sandBoxEmitSpy = sandbox.spy(networkQualityMonitor, 'emit');
- });
-
- afterEach(() => {
- sandbox.restore();
- });
-
- it('should trigger updateNetworkQualityStatus when determineUplinkNetworkQuality has finished', async () => {
- await networkQualityMonitor.determineUplinkNetworkQuality(configObject);
-
- assert.calledOnce(networkQualityMonitor.updateNetworkQualityStatus);
- });
-
- it('should emit a network quality judgement event with the proper payload', async () => {
- await networkQualityMonitor.determineUplinkNetworkQuality(configObject);
- assert(
- sandBoxEmitSpy.calledWith(
- sinon.match({
- file: 'networkQualityMonitor',
- function: 'emitNetworkQuality',
- }),
- sinon.match(EVENT_TRIGGERS.NETWORK_QUALITY),
- sinon.match({
- mediaType: 'video-send',
- networkQualityScore: 0,
- })
- )
- );
- });
-
- it('should reset to default values after determineUplinkNetworkQuality call stack is complete', async () => {
- await networkQualityMonitor.determineUplinkNetworkQuality(configObject);
- assert.isNull(networkQualityMonitor.mediaType);
- assert.deepEqual(networkQualityMonitor.networkQualityScore, 1);
- });
- });
-});
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts
index 8be4ce92bb2..2e400817b5e 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts
+++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts
@@ -7,7 +7,7 @@ import Reachability, {
ReachabilityResults,
ReachabilityResultsForBackend,
} from '@webex/plugin-meetings/src/reachability/';
-import { ClusterNode } from '../../../../src/reachability/request';
+import {ClusterNode} from '../../../../src/reachability/request';
import MeetingUtil from '@webex/plugin-meetings/src/meeting/util';
import * as ClusterReachabilityModule from '@webex/plugin-meetings/src/reachability/clusterReachability';
import Metrics from '@webex/plugin-meetings/src/metrics';
@@ -145,7 +145,6 @@ describe('isAnyPublicClusterReachable', () => {
});
});
-
describe('isWebexMediaBackendUnreachable', () => {
let webex;
@@ -486,6 +485,16 @@ describe('gatherReachability', () => {
JSON.stringify({old: 'joinCookie'})
);
+ webex.internal.device.ipNetworkDetector = {
+ supportsIpV4: false,
+ supportsIpV6: false,
+ firstIpV4: -1,
+ firstIpV6: -1,
+ firstMdns: -1,
+ totalTime: -1,
+ detect: sinon.stub().resolves(),
+ };
+
clock = sinon.useFakeTimers();
mockClusterReachabilityInstances = {};
@@ -1035,6 +1044,15 @@ describe('gatherReachability', () => {
enableTlsReachability: true,
};
+ // the metrics related to ipver are not tested in these tests and are all the same, so setting them up here
+ const expectedMetricsFull = {
+ ...expectedMetrics,
+ ipver_firstIpV4: -1,
+ ipver_firstIpV6: -1,
+ ipver_firstMdns: -1,
+ ipver_totalTime: -1,
+ };
+
const receivedEvents = {
done: 0,
firstResultAvailable: {
@@ -1119,11 +1137,89 @@ describe('gatherReachability', () => {
assert.calledWith(
Metrics.sendBehavioralMetric,
'js_sdk_reachability_completed',
- expectedMetrics
+ expectedMetricsFull
);
})
);
+ it(`starts ip network version detection and includes the results in the metrics`, async () => {
+ webex.config.meetings.experimental = {
+ enableTcpReachability: true,
+ enableTlsReachability: true,
+ };
+ webex.internal.device.ipNetworkDetector = {
+ supportsIpV4: true,
+ supportsIpV6: true,
+ firstIpV4: 10,
+ firstIpV6: 20,
+ firstMdns: 30,
+ totalTime: 40,
+ detect: sinon.stub().resolves(),
+ };
+
+ const receivedEvents = {
+ done: 0,
+ };
+
+ const reachability = new Reachability(webex);
+
+ reachability.on('reachability:done', () => {
+ receivedEvents.done += 1;
+ });
+
+ // simulate having just 1 cluster, we don't need more for this test
+ reachability.reachabilityRequest.getClusters = sinon.stub().returns({
+ clusters: {
+ publicCluster: {
+ udp: ['udp-url'],
+ tcp: [],
+ xtls: [],
+ isVideoMesh: false,
+ },
+ },
+ joinCookie: {id: 'id'},
+ });
+
+ const resultPromise = reachability.gatherReachability();
+
+ await testUtils.flushPromises();
+
+ // trigger mock result events from ClusterReachability instance
+ mockClusterReachabilityInstances['publicCluster'].emitFakeResult('udp', {
+ result: 'reachable',
+ clientMediaIPs: ['1.2.3.4'],
+ latencyInMilliseconds: 100,
+ });
+
+ await resultPromise;
+
+ // check events emitted by Reachability class
+ assert.equal(receivedEvents['done'], 1);
+
+ // and that ip network detection was started
+ assert.calledOnceWithExactly(webex.internal.device.ipNetworkDetector.detect);
+
+ // finally, check the metrics - they should contain values from ipNetworkDetector
+ assert.calledWith(Metrics.sendBehavioralMetric, 'js_sdk_reachability_completed', {
+ vmn_udp_min: -1,
+ vmn_udp_max: -1,
+ vmn_udp_average: -1,
+ public_udp_min: 100,
+ public_udp_max: 100,
+ public_udp_average: 100,
+ public_tcp_min: -1,
+ public_tcp_max: -1,
+ public_tcp_average: -1,
+ public_xtls_min: -1,
+ public_xtls_max: -1,
+ public_xtls_average: -1,
+ ipver_firstIpV4: webex.internal.device.ipNetworkDetector.firstIpV4,
+ ipver_firstIpV6: webex.internal.device.ipNetworkDetector.firstIpV6,
+ ipver_firstMdns: webex.internal.device.ipNetworkDetector.firstMdns,
+ ipver_totalTime: webex.internal.device.ipNetworkDetector.totalTime,
+ });
+ });
+
it('keeps updating reachability results after the 3s public cloud timeout expires', async () => {
webex.config.meetings.experimental = {
enableTcpReachability: true,
@@ -1466,6 +1562,70 @@ describe('gatherReachability', () => {
xtls: [], // empty list because TLS is disabled in config
});
});
+
+ it('retry of getClusters is succesfull', async () => {
+ webex.config.meetings.experimental = {
+ enableTcpReachability: true,
+ enableTlsReachability: false,
+ };
+
+ const getClustersResult = {
+ clusters: {
+ 'cluster name': {
+ udp: ['testUDP1', 'testUDP2'],
+ tcp: ['testTCP1', 'testTCP2'],
+ xtls: ['testXTLS1', 'testXTLS2'],
+ isVideoMesh: false,
+ },
+ },
+ joinCookie: {id: 'id'},
+ };
+
+ const reachability = new Reachability(webex);
+
+ let getClustersCallCount = 0;
+
+ reachability.reachabilityRequest.getClusters = sinon.stub().callsFake(() => {
+ getClustersCallCount++;
+
+ if (getClustersCallCount == 1) {
+ throw new Error('fake error');
+ }
+
+ return getClustersResult;
+ });
+
+ const promise = reachability.gatherReachability();
+
+ await simulateTimeout();
+ await promise;
+
+ assert.equal(getClustersCallCount, 2);
+
+ assert.calledOnce(clusterReachabilityCtorStub);
+ });
+
+ it('two failed calls to getClusters', async () => {
+ const reachability = new Reachability(webex);
+
+ let getClustersCallCount = 0;
+
+ reachability.reachabilityRequest.getClusters = sinon.stub().callsFake(() => {
+ getClustersCallCount++;
+
+ throw new Error('fake error');
+ });
+
+ const promise = reachability.gatherReachability();
+
+ await simulateTimeout();
+
+ await promise;
+
+ assert.equal(getClustersCallCount, 2);
+
+ assert.neverCalledWith(clusterReachabilityCtorStub);
+ });
});
describe('getReachabilityResults', () => {
diff --git a/packages/@webex/plugin-meetings/test/unit/spec/rtcMetrics/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/rtcMetrics/index.ts
index 7a0ff4eb3c4..cdcc25f3430 100644
--- a/packages/@webex/plugin-meetings/test/unit/spec/rtcMetrics/index.ts
+++ b/packages/@webex/plugin-meetings/test/unit/spec/rtcMetrics/index.ts
@@ -120,4 +120,35 @@ describe('RtcMetrics', () => {
metrics.addMetrics({ name: 'stats-report', payload: [STATS_WITH_IP] });
assert.calledOnce(anonymizeIpSpy);
})
+
+ it('should send metrics on first stats-report', () => {
+ assert.callCount(webex.request, 0);
+
+ metrics.addMetrics(FAKE_METRICS_ITEM);
+ assert.callCount(webex.request, 0);
+
+ // first stats-report should trigger a call to webex.request
+ metrics.addMetrics({ name: 'stats-report', payload: [STATS_WITH_IP] });
+ assert.callCount(webex.request, 1);
+ });
+
+ it('should send metrics on first stats-report after a new connection', () => {
+ assert.callCount(webex.request, 0);
+
+ // first stats-report should trigger a call to webex.request
+ metrics.addMetrics({ name: 'stats-report', payload: [STATS_WITH_IP] });
+ assert.callCount(webex.request, 1);
+
+ // subsequent stats-report doesn't trigger it
+ metrics.addMetrics({ name: 'stats-report', payload: [STATS_WITH_IP] });
+ assert.callCount(webex.request, 1);
+
+ // now, simulate a failure - that triggers a new connection and upload of the metrics
+ metrics.addMetrics(FAILURE_METRICS_ITEM);
+ assert.callCount(webex.request, 2);
+
+ // and another stats-report should trigger another upload of the metrics
+ metrics.addMetrics({ name: 'stats-report', payload: [STATS_WITH_IP] });
+ assert.callCount(webex.request, 3);
+ });
});
diff --git a/packages/@webex/webex-core/src/index.js b/packages/@webex/webex-core/src/index.js
index a3da486372a..7d4c1b9f6bc 100644
--- a/packages/@webex/webex-core/src/index.js
+++ b/packages/@webex/webex-core/src/index.js
@@ -25,6 +25,7 @@ export {
Services,
ServiceHost,
ServiceUrl,
+ HostMapInterceptor,
} from './lib/services';
export {
diff --git a/packages/@webex/webex-core/src/lib/services/index.js b/packages/@webex/webex-core/src/lib/services/index.js
index d18760f11c8..8f73c306e7e 100644
--- a/packages/@webex/webex-core/src/lib/services/index.js
+++ b/packages/@webex/webex-core/src/lib/services/index.js
@@ -18,6 +18,7 @@ registerInternalPlugin('services', Services, {
export {constants};
export {default as ServiceInterceptor} from './interceptors/service';
export {default as ServerErrorInterceptor} from './interceptors/server-error';
+export {default as HostMapInterceptor} from './interceptors/hostmap';
export {default as Services} from './services';
export {default as ServiceCatalog} from './service-catalog';
export {default as ServiceRegistry} from './service-registry';
diff --git a/packages/@webex/webex-core/src/lib/services/interceptors/hostmap.js b/packages/@webex/webex-core/src/lib/services/interceptors/hostmap.js
new file mode 100644
index 00000000000..0cf4370b7cb
--- /dev/null
+++ b/packages/@webex/webex-core/src/lib/services/interceptors/hostmap.js
@@ -0,0 +1,36 @@
+/*!
+ * Copyright (c) 2015-2024 Cisco Systems, Inc. See LICENSE file.
+ */
+
+import {Interceptor} from '@webex/http-core';
+
+/**
+ * This interceptor replaces the host in the request uri with the host from the hostmap
+ * It will attempt to do this for every request, but not all URIs will be in the hostmap
+ * URIs with hosts that are not in the hostmap will be left unchanged
+ */
+export default class HostMapInterceptor extends Interceptor {
+ /**
+ * @returns {HostMapInterceptor}
+ */
+ static create() {
+ return new HostMapInterceptor({webex: this});
+ }
+
+ /**
+ * @see Interceptor#onRequest
+ * @param {Object} options
+ * @returns {Object}
+ */
+ onRequest(options) {
+ if (options.uri) {
+ try {
+ options.uri = this.webex.internal.services.replaceHostFromHostmap(options.uri);
+ } catch (error) {
+ /* empty */
+ }
+ }
+
+ return options;
+ }
+}
diff --git a/packages/@webex/webex-core/src/lib/services/services.js b/packages/@webex/webex-core/src/lib/services/services.js
index cbc2972bc6f..bc3dc67e4c6 100644
--- a/packages/@webex/webex-core/src/lib/services/services.js
+++ b/packages/@webex/webex-core/src/lib/services/services.js
@@ -675,6 +675,33 @@ const Services = WebexPlugin.extend({
});
},
+ /**
+ * Looks up the hostname in the host catalog
+ * and replaces it with the first host if it finds it
+ * @param {string} uri
+ * @returns {string} uri with the host replaced
+ */
+ replaceHostFromHostmap(uri) {
+ const url = new URL(uri);
+ const hostCatalog = this._hostCatalog;
+
+ if (!hostCatalog) {
+ return uri;
+ }
+
+ const host = hostCatalog[url.host];
+
+ if (host && host[0]) {
+ const newHost = host[0].host;
+
+ url.host = newHost;
+
+ return url.toString();
+ }
+
+ return uri;
+ },
+
/**
* @private
* Organize a received hostmap from a service
diff --git a/packages/@webex/webex-core/src/webex-core.js b/packages/@webex/webex-core/src/webex-core.js
index c9f33cdeda6..a8c52cf56ca 100644
--- a/packages/@webex/webex-core/src/webex-core.js
+++ b/packages/@webex/webex-core/src/webex-core.js
@@ -31,6 +31,7 @@ import WebexUserAgentInterceptor from './interceptors/webex-user-agent';
import RateLimitInterceptor from './interceptors/rate-limit';
import EmbargoInterceptor from './interceptors/embargo';
import DefaultOptionsInterceptor from './interceptors/default-options';
+import HostMapInterceptor from './lib/services/interceptors/hostmap';
import config from './config';
import {makeWebexStore} from './lib/storage';
import mixinWebexCorePlugins from './lib/webex-core-plugin-mixin';
@@ -71,6 +72,7 @@ const interceptors = {
NetworkTimingInterceptor: NetworkTimingInterceptor.create,
EmbargoInterceptor: EmbargoInterceptor.create,
DefaultOptionsInterceptor: DefaultOptionsInterceptor.create,
+ HostMapInterceptor: HostMapInterceptor.create,
};
const preInterceptors = [
diff --git a/packages/@webex/webex-core/test/unit/spec/services/interceptors/hostmap.js b/packages/@webex/webex-core/test/unit/spec/services/interceptors/hostmap.js
new file mode 100644
index 00000000000..46ff05fd178
--- /dev/null
+++ b/packages/@webex/webex-core/test/unit/spec/services/interceptors/hostmap.js
@@ -0,0 +1,79 @@
+/*!
+ * Copyright (c) 2015-2024 Cisco Systems, Inc. See LICENSE file.
+ */
+
+/* eslint-disable camelcase */
+
+import sinon from 'sinon';
+import {assert} from '@webex/test-helper-chai';
+import MockWebex from '@webex/test-helper-mock-webex';
+import {HostMapInterceptor, config, Credentials} from '@webex/webex-core';
+import {cloneDeep} from 'lodash';
+
+describe('webex-core', () => {
+ describe('Interceptors', () => {
+ describe('HostMapInterceptor', () => {
+ let interceptor, webex;
+
+ beforeEach(() => {
+ webex = new MockWebex({
+ children: {
+ credentials: Credentials,
+ },
+ config: cloneDeep(config),
+ request: sinon.spy(),
+ });
+
+ webex.internal.services = {
+ replaceHostFromHostmap: sinon.stub().returns('http://replaceduri.com'),
+ }
+
+ interceptor = Reflect.apply(HostMapInterceptor.create, webex, []);
+ });
+
+ describe('#onRequest', () => {
+ it('calls replaceHostFromHostmap if options.uri is defined', () => {
+ const options = {
+ uri: 'http://example.com',
+ };
+
+ interceptor.onRequest(options);
+
+ sinon.assert.calledWith(
+ webex.internal.services.replaceHostFromHostmap,
+ 'http://example.com'
+ );
+
+ assert.equal(options.uri, 'http://replaceduri.com');
+ });
+
+ it('does not call replaceHostFromHostmap if options.uri is not defined', () => {
+ const options = {};
+
+ interceptor.onRequest(options);
+
+ sinon.assert.notCalled(webex.internal.services.replaceHostFromHostmap);
+
+ assert.isUndefined(options.uri);
+ });
+
+ it('does not modify options.uri if replaceHostFromHostmap throws an error', () => {
+ const options = {
+ uri: 'http://example.com',
+ };
+
+ webex.internal.services.replaceHostFromHostmap.throws(new Error('replaceHostFromHostmap error'));
+
+ interceptor.onRequest(options);
+
+ sinon.assert.calledWith(
+ webex.internal.services.replaceHostFromHostmap,
+ 'http://example.com'
+ );
+
+ assert.equal(options.uri, 'http://example.com');
+ });
+ });
+ });
+ });
+});
diff --git a/packages/@webex/webex-core/test/unit/spec/services/services.js b/packages/@webex/webex-core/test/unit/spec/services/services.js
index 80f63b5c5ba..9e13ee09310 100644
--- a/packages/@webex/webex-core/test/unit/spec/services/services.js
+++ b/packages/@webex/webex-core/test/unit/spec/services/services.js
@@ -290,6 +290,61 @@ describe('webex-core', () => {
});
});
+ describe('replaceHostFromHostmap', () => {
+ it('returns the same uri if the hostmap is not set', () => {
+ services._hostCatalog = null;
+
+ const uri = 'http://example.com';
+
+ assert.equal(services.replaceHostFromHostmap(uri), uri);
+ });
+
+ it('returns the same uri if the hostmap does not contain the host', () => {
+ services._hostCatalog = {
+ 'not-example.com': [
+ {
+ host: 'example-1.com',
+ ttl: -1,
+ priority: 5,
+ id: '0:0:0:example',
+ },
+ ],
+ };
+
+ const uri = 'http://example.com';
+
+ assert.equal(services.replaceHostFromHostmap(uri), uri);
+ });
+
+ it('returns the original uri if the hostmap has no hosts for the host', () => {
+
+ services._hostCatalog = {
+ 'example.com': [],
+ };
+
+ const uri = 'http://example.com';
+
+ assert.equal(services.replaceHostFromHostmap(uri), uri);
+ });
+
+ it('returns the replaces the host in the uri with the host from the hostmap', () => {
+ services._hostCatalog = {
+ 'example.com': [
+ {
+ host: 'example-1.com',
+ ttl: -1,
+ priority: 5,
+ id: '0:0:0:example',
+ },
+ ],
+ };
+
+ const uri = 'http://example.com/somepath';
+
+ assert.equal(services.replaceHostFromHostmap(uri), 'http://example-1.com/somepath');
+ });
+ });
+
describe('#_formatReceivedHostmap()', () => {
let serviceHostmap;
let formattedHM;
diff --git a/packages/calling/package.json b/packages/calling/package.json
index d149f8f9b62..0d975f4a253 100644
--- a/packages/calling/package.json
+++ b/packages/calling/package.json
@@ -37,7 +37,7 @@
},
"dependencies": {
"@types/platform": "1.3.4",
- "@webex/internal-media-core": "2.9.1",
+ "@webex/internal-media-core": "2.10.2",
"@webex/media-helpers": "workspace:*",
"async-mutex": "0.4.0",
"buffer": "6.0.3",
diff --git a/packages/calling/src/CallHistory/CallHistory.test.ts b/packages/calling/src/CallHistory/CallHistory.test.ts
index 5efa59d50b8..dadfbf0541a 100644
--- a/packages/calling/src/CallHistory/CallHistory.test.ts
+++ b/packages/calling/src/CallHistory/CallHistory.test.ts
@@ -3,7 +3,7 @@
/* eslint-disable @typescript-eslint/no-shadow */
import {LOGGER} from '../Logger/types';
import {getTestUtilsWebex} from '../common/testUtil';
-import {HTTP_METHODS, SORT, SORT_BY, WebexRequestPayload} from '../common/types';
+import {CALLING_BACKEND, HTTP_METHODS, SORT, SORT_BY, WebexRequestPayload} from '../common/types';
import {CallHistory, createCallHistoryClient} from './CallHistory';
import {ICallHistory} from './types';
import {
@@ -16,6 +16,10 @@ import {
janusSetReadStateUrl,
ERROR_DETAILS_401,
ERROR_DETAILS_400,
+ MOCK_LINES_API_CALL_RESPONSE,
+ MOCK_LINES_API_CALL_RESPONSE_WITH_NO_LINEDATA,
+ MOCK_CALL_HISTORY_WITH_UCM_LINE_NUMBER,
+ MOCK_CALL_HISTORY_WITHOUT_UCM_LINE_NUMBER,
} from './callHistoryFixtures';
import {
COMMON_EVENT_KEYS,
@@ -247,4 +251,113 @@ describe('Call history tests', () => {
);
});
});
+
+ describe('fetchUCMLinesData test', () => {
+ it('verify successful UCM lines API case', async () => {
+ const ucmLinesAPIPayload = (MOCK_LINES_API_CALL_RESPONSE);
+
+ webex.request.mockResolvedValue(ucmLinesAPIPayload);
+ const response = await callHistory['fetchUCMLinesData']();
+
+ expect(response.statusCode).toBe(200);
+ expect(response.message).toBe('SUCCESS');
+ });
+
+ it('verify bad request failed UCM lines API case', async () => {
+ const failurePayload = {
+ statusCode: 400,
+ };
+ const ucmLinesAPIPayload = (failurePayload);
+
+ webex.request.mockRejectedValue(ucmLinesAPIPayload);
+ const response = await callHistory['fetchUCMLinesData']();
+
+ expect(response).toStrictEqual(ERROR_DETAILS_400);
+ expect(response.data.error).toEqual(ERROR_DETAILS_400.data.error);
+ expect(response.statusCode).toBe(400);
+ expect(response.message).toBe('FAILURE');
+ expect(serviceErrorCodeHandlerSpy).toHaveBeenCalledWith(
+ {statusCode: 400},
+ {file: 'CallHistory', method: 'fetchLinesData'}
+ );
+ });
+
+ it('should call fetchUCMLinesData when calling backend is UCM and userSessions contain valid cucmDN', async () => {
+ jest.spyOn(utils, 'getCallingBackEnd').mockReturnValue(CALLING_BACKEND.UCM);
+ // Since fetchUCMLinesData is a private method, TypeScript restricts direct access to it.
+ // To bypass this restriction, we are using 'as any' to access and invoke the method for testing purposes.
+ const fetchUCMLinesDataSpy = jest
+ .spyOn(callHistory as any, 'fetchUCMLinesData')
+ .mockResolvedValue(MOCK_LINES_API_CALL_RESPONSE);
+
+ const mockCallHistoryPayload = (
+ (MOCK_CALL_HISTORY_WITH_UCM_LINE_NUMBER)
+ );
+ webex.request.mockResolvedValue(mockCallHistoryPayload);
+
+ const response = await callHistory.getCallHistoryData(7, 10, SORT.DEFAULT, SORT_BY.DEFAULT);
+
+ expect(fetchUCMLinesDataSpy).toHaveBeenCalledTimes(1);
+
+ expect(response.statusCode).toBe(200);
+ expect(
+ response.data.userSessions && response.data.userSessions[0].self.ucmLineNumber
+ ).toEqual(1);
+ });
+
+ it('should fetchUCMLinesData but not assign ucmLineNumber when UCM backend has no line data', async () => {
+ jest.spyOn(utils, 'getCallingBackEnd').mockReturnValue(CALLING_BACKEND.UCM);
+
+ // Since fetchUCMLinesData is a private method, TypeScript restricts direct access to it.
+ // To bypass this restriction, we are using 'as any' to access and invoke the method for testing purposes.
+ const fetchUCMLinesDataSpy = jest
+ .spyOn(callHistory as any, 'fetchUCMLinesData')
+ .mockResolvedValue(MOCK_LINES_API_CALL_RESPONSE_WITH_NO_LINEDATA);
+
+ const mockCallHistoryPayload = (
+ (MOCK_CALL_HISTORY_WITHOUT_UCM_LINE_NUMBER)
+ );
+ webex.request.mockResolvedValue(mockCallHistoryPayload);
+
+ const response = await callHistory.getCallHistoryData(7, 10, SORT.DEFAULT, SORT_BY.DEFAULT);
+
+ expect(fetchUCMLinesDataSpy).toHaveBeenCalledTimes(1);
+
+ expect(response.statusCode).toBe(200);
+ expect(response.data.userSessions && response.data.userSessions[0].self.cucmDN).toBeDefined();
+ expect(
+ response.data.userSessions && response.data.userSessions[0].self.ucmLineNumber
+ ).toEqual(undefined);
+ });
+
+ it('should not call fetchUCMLinesData when calling backend is UCM but no valid cucmDN is present', async () => {
+ jest.spyOn(utils, 'getCallingBackEnd').mockReturnValue(CALLING_BACKEND.UCM);
+ // Since fetchUCMLinesData is a private method, TypeScript restricts direct access to it.
+ // To bypass this restriction, we are using 'as any' to access and invoke the method for testing purposes.
+ const fetchUCMLinesDataSpy = jest
+ .spyOn(callHistory as any, 'fetchUCMLinesData')
+ .mockResolvedValue({});
+
+ const callHistoryPayload = (mockCallHistoryBody);
+ webex.request.mockResolvedValue(callHistoryPayload);
+
+ await callHistory.getCallHistoryData(7, 10, SORT.DEFAULT, SORT_BY.DEFAULT);
+
+ expect(fetchUCMLinesDataSpy).not.toHaveBeenCalled();
+ });
+
+ it('should not call fetchUCMLinesData when calling backend is not UCM', async () => {
+ jest.spyOn(utils, 'getCallingBackEnd').mockReturnValue(CALLING_BACKEND.WXC);
+ // Since fetchUCMLinesData is a private method, TypeScript restricts direct access to it.
+ // To bypass this restriction, we are using 'as any' to access and invoke the method for testing purposes.
+ const fetchUCMLinesDataSpy = jest
+ .spyOn(callHistory as any, 'fetchUCMLinesData')
+ .mockResolvedValue({});
+
+ const callHistoryPayload = (mockCallHistoryBody);
+ webex.request.mockResolvedValue(callHistoryPayload);
+ await callHistory.getCallHistoryData(7, 10, SORT.DEFAULT, SORT_BY.DEFAULT);
+ expect(fetchUCMLinesDataSpy).not.toHaveBeenCalled(); // Check that fetchUCMLinesData was not called
+ });
+ });
});
diff --git a/packages/calling/src/CallHistory/CallHistory.ts b/packages/calling/src/CallHistory/CallHistory.ts
index 4afab6d6722..dc9f6fa551b 100644
--- a/packages/calling/src/CallHistory/CallHistory.ts
+++ b/packages/calling/src/CallHistory/CallHistory.ts
@@ -2,15 +2,23 @@
/* eslint-disable no-underscore-dangle */
import SDKConnector from '../SDKConnector';
import {ISDKConnector, WebexSDK} from '../SDKConnector/types';
-import {ALLOWED_SERVICES, HTTP_METHODS, WebexRequestPayload, SORT, SORT_BY} from '../common/types';
+import {
+ ALLOWED_SERVICES,
+ HTTP_METHODS,
+ WebexRequestPayload,
+ SORT,
+ SORT_BY,
+ CALLING_BACKEND,
+} from '../common/types';
import {
ICallHistory,
JanusResponseEvent,
LoggerInterface,
UpdateMissedCallsResponse,
+ UCMLinesResponse,
} from './types';
import log from '../Logger';
-import {serviceErrorCodeHandler} from '../common/Utils';
+import {serviceErrorCodeHandler, getVgActionEndpoint, getCallingBackEnd} from '../common/Utils';
import {
APPLICATION_JSON,
CALL_HISTORY_FILE,
@@ -21,6 +29,12 @@ import {
NUMBER_OF_DAYS,
UPDATE_MISSED_CALLS_ENDPOINT,
SET_READ_STATE_SUCCESS_MESSAGE,
+ VERSION_1,
+ UNIFIED_COMMUNICATIONS,
+ CONFIG,
+ PEOPLE,
+ LINES,
+ ORG_ID,
} from './constants';
import {STATUS_CODE, SUCCESS_MESSAGE, USER_SESSIONS} from '../common/constants';
import {
@@ -32,6 +46,7 @@ import {
EndTimeSessionId,
CallSessionViewedEvent,
SanitizedEndTimeAndSessionId,
+ UCMLinesApiResponse,
} from '../Events/types';
import {Eventing} from '../Events/impl';
/**
@@ -128,6 +143,43 @@ export class CallHistory extends Eventing implements ICal
);
}
}
+ // Check the calling backend
+ const callingBackend = getCallingBackEnd(this.webex);
+ if (callingBackend === CALLING_BACKEND.UCM) {
+ // Check if userSessions exist and the length is greater than 0
+ if (this.userSessions[USER_SESSIONS] && this.userSessions[USER_SESSIONS].length > 0) {
+ // Check if cucmDN exists and is valid in any of the userSessions
+ const hasCucmDN = this.userSessions[USER_SESSIONS].some(
+ (session: UserSession) => session.self.cucmDN && session.self.cucmDN.length > 0
+ );
+ // If any user session has cucmDN, proceed to fetch line data
+ if (hasCucmDN) {
+ // Fetch the Lines data
+ const ucmLinesResponse = await this.fetchUCMLinesData();
+
+ // Check if the Lines API response was successful
+ if (ucmLinesResponse.statusCode === 200 && ucmLinesResponse.data.lines?.devices) {
+ const ucmLinesData = ucmLinesResponse.data.lines.devices;
+
+ // Iterate over user sessions and match with Lines data
+ this.userSessions[USER_SESSIONS].forEach((session: UserSession) => {
+ const cucmDN = session.self.cucmDN;
+
+ if (cucmDN) {
+ ucmLinesData.forEach((device) => {
+ device.lines.forEach((line) => {
+ if (line.dnorpattern === cucmDN) {
+ session.self.ucmLineNumber = line.index; // Assign the ucmLineNumber
+ }
+ });
+ });
+ }
+ });
+ }
+ }
+ }
+ }
+
const responseDetails = {
statusCode: this.userSessions[STATUS_CODE],
data: {
@@ -202,6 +254,45 @@ export class CallHistory extends Eventing implements ICal
}
}
+ /**
+ * Function to display the UCM Lines API response.
+ * @returns {Promise} Resolves to an object of type {@link UCMLinesResponse}.Response details with success or error status.
+ */
+ private async fetchUCMLinesData(): Promise {
+ const loggerContext = {
+ file: CALL_HISTORY_FILE,
+ method: 'fetchLinesData',
+ };
+ const vgEndpoint = getVgActionEndpoint(this.webex, CALLING_BACKEND.UCM);
+ const userId = this.webex.internal.device.userId;
+ const orgId = this.webex.internal.device.orgId;
+ const linesURIForUCM = `${vgEndpoint}/${VERSION_1}/${UNIFIED_COMMUNICATIONS}/${CONFIG}/${PEOPLE}/${userId}/${LINES}?${ORG_ID}=${orgId}`;
+
+ try {
+ const response = await this.webex.request({
+ uri: `${linesURIForUCM}`,
+ method: HTTP_METHODS.GET,
+ });
+
+ const ucmLineDetails: UCMLinesResponse = {
+ statusCode: Number(response.statusCode),
+ data: {
+ lines: response.body as UCMLinesApiResponse,
+ },
+ message: SUCCESS_MESSAGE,
+ };
+
+ log.info(`Line details fetched successfully`, loggerContext);
+
+ return ucmLineDetails;
+ } catch (err: unknown) {
+ const errorInfo = err as WebexRequestPayload;
+ const errorStatus = serviceErrorCodeHandler(errorInfo, loggerContext);
+
+ return errorStatus;
+ }
+ }
+
handleSessionEvents = async (event?: CallSessionEvent) => {
if (event && event.data.userSessions.userSessions) {
this.emit(COMMON_EVENT_KEYS.CALL_HISTORY_USER_SESSION_INFO, event as CallSessionEvent);
diff --git a/packages/calling/src/CallHistory/callHistoryFixtures.ts b/packages/calling/src/CallHistory/callHistoryFixtures.ts
index ef30a1e8219..b7901a6dcc6 100644
--- a/packages/calling/src/CallHistory/callHistoryFixtures.ts
+++ b/packages/calling/src/CallHistory/callHistoryFixtures.ts
@@ -5,7 +5,7 @@ import {
SessionType,
CallSessionViewedEvent,
} from '../Events/types';
-import {UpdateMissedCallsResponse} from './types';
+import {UCMLinesResponse, UpdateMissedCallsResponse} from './types';
export const sortedCallHistory = {
body: {
@@ -287,6 +287,200 @@ export const mockCallHistoryBody = {
},
};
+/**
+ * MOCK_CALL_HISTORY_WITH_UCM_LINE_NUMBER simulates a call history response where the session contains
+ * both cucmDN and ucmLineNumber data. This implies that the cucmDN was successfully matched with the UCM lines data.
+ */
+export const MOCK_CALL_HISTORY_WITH_UCM_LINE_NUMBER = {
+ body: {
+ statusCode: 200,
+ userSessions: [
+ {
+ id: '123456',
+ durationSecs: 438,
+ self: {
+ id: 'fd2e1234',
+ name: 'Mark',
+ cucmDN: '1001',
+ ucmLineNumber: 1,
+ incomingCallProtocols: [],
+ callbackInfo: {
+ callbackAddress: 'test@cisco.com',
+ callbackType: 'EMAIL',
+ },
+ lookUpInfo: {
+ lookupLink: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/98765',
+ type: 'CONVERSATION',
+ },
+ },
+ url: 'https://janus-a.wbx2.com/janus/api/v1/history/userSessions/654321',
+ sessionId: '123456',
+ sessionType: 'SPARK',
+ startTime: '2022-08-22T10:45:21.565Z',
+ endTime: '2022-08-22T10:53:01.624Z',
+ direction: 'OUTGOING',
+ disposition: 'INITIATED',
+ other: {
+ id: '100001',
+ name: 'test',
+ isPrivate: false,
+ callbackAddress: '89998888',
+ },
+ durationSeconds: 438,
+ joinedDurationSeconds: 457,
+ participantCount: 2,
+ links: {
+ locusUrl: 'https://locus-a.wbx2.com/locus/api/v1/loci/786765',
+ conversationUrl: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/55443322',
+ callbackAddress: '01010101',
+ },
+ isDeleted: false,
+ isPMR: false,
+ correlationIds: ['008899'],
+ },
+ {
+ id: '20191817',
+ durationSecs: 438,
+ self: {
+ id: '12131415',
+ name: 'Mark',
+ cucmDN: '1002',
+ ucmLineNumber: 2,
+ incomingCallProtocols: [],
+ callbackInfo: {
+ callbackAddress: 'test@cisco.com',
+ callbackType: 'EMAIL',
+ },
+ lookUpInfo: {
+ lookupLink: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/21314151',
+ type: 'CONVERSATION',
+ },
+ },
+ url: 'https://janus-a.wbx2.com/janus/api/v1/history/userSessions/100101102',
+ sessionId: '20191817',
+ sessionType: 'SPARK',
+ startTime: '2022-08-30T10:45:21.565Z',
+ endTime: '2022-08-30T10:53:01.624Z',
+ direction: 'OUTGOING',
+ disposition: 'INITIATED',
+ other: {
+ id: '301302303',
+ name: 'test',
+ isPrivate: false,
+ callbackAddress: '401402403',
+ },
+ durationSeconds: 438,
+ joinedDurationSeconds: 457,
+ participantCount: 2,
+ links: {
+ locusUrl: 'https://locus-a.wbx2.com/locus/api/v1/loci/501502503',
+ conversationUrl: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/601602603',
+ callbackAddress: '801802803',
+ },
+ isDeleted: false,
+ isPMR: false,
+ correlationIds: ['901902903'],
+ },
+ ],
+ },
+};
+
+/**
+ * MOCK_CALL_HISTORY_WITHOUT_UCM_LINE_NUMBER simulates a call history response where the session contains
+ * cucmDN, but no ucmLineNumber is present. This implies that the cucmDN was not matched with any UCM lines data.
+ */
+export const MOCK_CALL_HISTORY_WITHOUT_UCM_LINE_NUMBER = {
+ body: {
+ statusCode: 200,
+ userSessions: [
+ {
+ id: '123456',
+ durationSecs: 438,
+ self: {
+ id: 'fd2e1234',
+ name: 'Mark',
+ cucmDN: '1001',
+ incomingCallProtocols: [],
+ callbackInfo: {
+ callbackAddress: 'test@cisco.com',
+ callbackType: 'EMAIL',
+ },
+ lookUpInfo: {
+ lookupLink: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/98765',
+ type: 'CONVERSATION',
+ },
+ },
+ url: 'https://janus-a.wbx2.com/janus/api/v1/history/userSessions/654321',
+ sessionId: '123456',
+ sessionType: 'SPARK',
+ startTime: '2022-08-22T10:45:21.565Z',
+ endTime: '2022-08-22T10:53:01.624Z',
+ direction: 'OUTGOING',
+ disposition: 'INITIATED',
+ other: {
+ id: '100001',
+ name: 'test',
+ isPrivate: false,
+ callbackAddress: '89998888',
+ },
+ durationSeconds: 438,
+ joinedDurationSeconds: 457,
+ participantCount: 2,
+ links: {
+ locusUrl: 'https://locus-a.wbx2.com/locus/api/v1/loci/786765',
+ conversationUrl: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/55443322',
+ callbackAddress: '01010101',
+ },
+ isDeleted: false,
+ isPMR: false,
+ correlationIds: ['008899'],
+ },
+ {
+ id: '20191817',
+ durationSecs: 438,
+ self: {
+ id: '12131415',
+ name: 'Mark',
+ cucmDN: '1002',
+ incomingCallProtocols: [],
+ callbackInfo: {
+ callbackAddress: 'test@cisco.com',
+ callbackType: 'EMAIL',
+ },
+ lookUpInfo: {
+ lookupLink: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/21314151',
+ type: 'CONVERSATION',
+ },
+ },
+ url: 'https://janus-a.wbx2.com/janus/api/v1/history/userSessions/100101102',
+ sessionId: '20191817',
+ sessionType: 'SPARK',
+ startTime: '2022-08-30T10:45:21.565Z',
+ endTime: '2022-08-30T10:53:01.624Z',
+ direction: 'OUTGOING',
+ disposition: 'INITIATED',
+ other: {
+ id: '301302303',
+ name: 'test',
+ isPrivate: false,
+ callbackAddress: '401402403',
+ },
+ durationSeconds: 438,
+ joinedDurationSeconds: 457,
+ participantCount: 2,
+ links: {
+ locusUrl: 'https://locus-a.wbx2.com/locus/api/v1/loci/501502503',
+ conversationUrl: 'https://conv-a.wbx2.com/conversation/api/v1/conversations/601602603',
+ callbackAddress: '801802803',
+ },
+ isDeleted: false,
+ isPMR: false,
+ correlationIds: ['901902903'],
+ },
+ ],
+ },
+};
+
const WEBEX_CALL_SESSION = {
id: 'd74d19cc-6aa7-f341-6012-aec433cc6f8d',
durationSecs: 438,
@@ -439,3 +633,43 @@ export const ERROR_DETAILS_400 = {
},
message: 'FAILURE',
};
+
+/*
+ * MOCK_LINES_API_CALL_RESPONSE simulates a successful response from the UCM lines API.
+ */
+export const MOCK_LINES_API_CALL_RESPONSE: UCMLinesResponse = {
+ statusCode: 200,
+ data: {
+ lines: {
+ devices: [
+ {
+ name: 'CSFheliosucm01',
+ model: 503,
+ lines: [
+ {
+ dnorpattern: '+14928000001',
+ index: 1,
+ label: '',
+ },
+ {
+ dnorpattern: '+14928000003',
+ index: 2,
+ label: '',
+ },
+ ],
+ },
+ ],
+ },
+ },
+ message: 'SUCCESS',
+};
+
+/**
+ * MOCK_LINES_API_CALL_RESPONSE_WITH_NO_LINEDATA simulates a successful UCM lines API response
+ * where no line data is present. The `lines` field is empty, indicating no devices or lines available.
+ */
+export const MOCK_LINES_API_CALL_RESPONSE_WITH_NO_LINEDATA: UCMLinesResponse = {
+ statusCode: 200,
+ data: {},
+ message: 'SUCCESS',
+};
diff --git a/packages/calling/src/CallHistory/constants.ts b/packages/calling/src/CallHistory/constants.ts
index ac222e6edc3..f22c1d04e86 100644
--- a/packages/calling/src/CallHistory/constants.ts
+++ b/packages/calling/src/CallHistory/constants.ts
@@ -1,13 +1,19 @@
export const APPLICATION_JSON = 'application/json';
export const CALL_HISTORY_FILE = 'CallHistory';
export const CONTENT_TYPE = 'Content-Type';
+export const CONFIG = 'config';
export const FROM_DATE = '?from';
export const HISTORY = 'history';
export const LIMIT = 50;
+export const LINES = 'lines';
export const NUMBER_OF_DAYS = 10;
+export const ORG_ID = 'orgId';
+export const PEOPLE = 'people';
export const RESPONSE_MESSAGE = 'responseMessage';
-export const UPDATE_MISSED_CALLS_ENDPOINT = 'setReadState';
export const SET_READ_STATE_SUCCESS_MESSAGE = 'Missed calls are read by the user.';
export const SUCCESS_MESSAGE = 'SUCCESS';
export const STATUS_CODE = 'statusCode';
export const USER_SESSIONS = 'userSessions';
+export const UPDATE_MISSED_CALLS_ENDPOINT = 'setReadState';
+export const UNIFIED_COMMUNICATIONS = 'uc';
+export const VERSION_1 = 'v1';
diff --git a/packages/calling/src/CallHistory/types.ts b/packages/calling/src/CallHistory/types.ts
index ca0ca3f333e..60be740f665 100644
--- a/packages/calling/src/CallHistory/types.ts
+++ b/packages/calling/src/CallHistory/types.ts
@@ -1,5 +1,10 @@
import {Eventing} from '../Events/impl';
-import {CallHistoryEventTypes, EndTimeSessionId, UserSession} from '../Events/types';
+import {
+ CallHistoryEventTypes,
+ EndTimeSessionId,
+ UserSession,
+ UCMLinesApiResponse,
+} from '../Events/types';
import {LOGGER} from '../Logger/types';
import {SORT, SORT_BY} from '../common/types';
@@ -25,6 +30,15 @@ export type UpdateMissedCallsResponse = {
message: string | null;
};
+export type UCMLinesResponse = {
+ statusCode: number;
+ data: {
+ lines?: UCMLinesApiResponse;
+ error?: string;
+ };
+ message: string | null;
+};
+
/**
* Interface for CallHistory Client.
* This encompasses a set of APIs designed to facilitate the retrieval of recent Call History Record.
diff --git a/packages/calling/src/CallSettings/UcmBackendConnector.test.ts b/packages/calling/src/CallSettings/UcmBackendConnector.test.ts
index 45df2f651c0..7b3409e57b1 100644
--- a/packages/calling/src/CallSettings/UcmBackendConnector.test.ts
+++ b/packages/calling/src/CallSettings/UcmBackendConnector.test.ts
@@ -1,10 +1,15 @@
import {LOGGER} from '../Logger/types';
import * as utils from '../common/Utils';
-import {FAILURE_MESSAGE, SUCCESS_MESSAGE, UCM_CONNECTOR_FILE} from '../common/constants';
+import {
+ FAILURE_MESSAGE,
+ SUCCESS_MESSAGE,
+ UCM_CONNECTOR_FILE,
+ WEBEX_API_CONFIG_INT_URL,
+} from '../common/constants';
import {getTestUtilsWebex} from '../common/testUtil';
import {HTTP_METHODS, WebexRequestPayload} from '../common/types';
import {UcmBackendConnector} from './UcmBackendConnector';
-import {CF_ENDPOINT, ORG_ENDPOINT, PEOPLE_ENDPOINT, WEBEX_APIS_INT_URL} from './constants';
+import {CF_ENDPOINT, ORG_ENDPOINT, PEOPLE_ENDPOINT} from './constants';
import {CallForwardAlwaysSetting, CallForwardingSettingsUCM, IUcmBackendConnector} from './types';
describe('Call Settings Client Tests for UcmBackendConnector', () => {
@@ -44,7 +49,7 @@ describe('Call Settings Client Tests for UcmBackendConnector', () => {
},
};
- const callForwardingUri = `${WEBEX_APIS_INT_URL}/${PEOPLE_ENDPOINT}/${userId}/${CF_ENDPOINT.toLowerCase()}?${ORG_ENDPOINT}=${orgId}`;
+ const callForwardingUri = `${WEBEX_API_CONFIG_INT_URL}/${PEOPLE_ENDPOINT}/${userId}/${CF_ENDPOINT.toLowerCase()}?${ORG_ENDPOINT}=${orgId}`;
beforeAll(() => {
callSettingsClient = new UcmBackendConnector(webex, {level: LOGGER.INFO}, false);
diff --git a/packages/calling/src/CallSettings/UcmBackendConnector.ts b/packages/calling/src/CallSettings/UcmBackendConnector.ts
index cbf6fd7e8f2..3aab3e44ee6 100644
--- a/packages/calling/src/CallSettings/UcmBackendConnector.ts
+++ b/packages/calling/src/CallSettings/UcmBackendConnector.ts
@@ -8,15 +8,11 @@ import {
SUCCESS_MESSAGE,
UCM_CONNECTOR_FILE,
VOICEMAIL,
+ WEBEX_API_CONFIG_INT_URL,
+ WEBEX_API_CONFIG_PROD_URL,
} from '../common/constants';
import {HTTP_METHODS, WebexRequestPayload} from '../common/types';
-import {
- CF_ENDPOINT,
- ORG_ENDPOINT,
- PEOPLE_ENDPOINT,
- WEBEX_APIS_INT_URL,
- WEBEX_APIS_PROD_URL,
-} from './constants';
+import {CF_ENDPOINT, ORG_ENDPOINT, PEOPLE_ENDPOINT} from './constants';
import {
CallForwardAlwaysSetting,
CallForwardingSettingsUCM,
@@ -132,7 +128,9 @@ export class UcmBackendConnector implements IUcmBackendConnector {
method: this.getCallForwardAlwaysSetting.name,
};
- const webexApisUrl = this.useProdWebexApis ? WEBEX_APIS_PROD_URL : WEBEX_APIS_INT_URL;
+ const webexApisUrl = this.useProdWebexApis
+ ? WEBEX_API_CONFIG_PROD_URL
+ : WEBEX_API_CONFIG_INT_URL;
try {
if (directoryNumber) {
diff --git a/packages/calling/src/CallSettings/constants.ts b/packages/calling/src/CallSettings/constants.ts
index a869a5703ce..95912f9fac4 100644
--- a/packages/calling/src/CallSettings/constants.ts
+++ b/packages/calling/src/CallSettings/constants.ts
@@ -7,5 +7,3 @@ export const CF_ENDPOINT = 'features/callForwarding';
export const VM_ENDPOINT = 'features/voicemail';
export const CALL_WAITING_ENDPOINT = 'CallWaiting';
export const XSI_VERSION = 'v2.0';
-export const WEBEX_APIS_INT_URL = 'https://integration.webexapis.com/v1/uc/config';
-export const WEBEX_APIS_PROD_URL = 'https://webexapis.com/v1/uc/config';
diff --git a/packages/calling/src/CallingClient/calling/CallerId/types.ts b/packages/calling/src/CallingClient/calling/CallerId/types.ts
index aefdfd25298..7fcf796f514 100644
--- a/packages/calling/src/CallingClient/calling/CallerId/types.ts
+++ b/packages/calling/src/CallingClient/calling/CallerId/types.ts
@@ -1,5 +1,5 @@
import {CallerIdInfo} from '../../../Events/types';
-import {DisplayInformation, PhoneNumber} from '../../../common/types';
+import {DisplayInformation} from '../../../common/types';
export type EmailType = {
primary: boolean;
@@ -18,31 +18,6 @@ export type PhotoType = {
value: string;
};
-export type ResourceType = {
- userName: string;
- emails: Array;
- name: {
- givenName: string;
- familyName: string;
- };
- phoneNumbers: Array;
- entitlements: Array;
- id: string;
- photos: Array;
- displayName: string;
- active: boolean;
- sipAddresses: Array;
-};
-
-/* The scim response has many fields , dropping few of them as they are not to be consumed by us */
-export type scimResponseBody = {
- totalResults: number;
- itemsPerPage: number;
- startIndex: number;
- schemas: Array;
- Resources: Array;
-};
-
/**
* Represents the interface for fetching caller ID details.
*/
diff --git a/packages/calling/src/CallingClient/calling/call.test.ts b/packages/calling/src/CallingClient/calling/call.test.ts
index ce43512f806..b15c416981f 100644
--- a/packages/calling/src/CallingClient/calling/call.test.ts
+++ b/packages/calling/src/CallingClient/calling/call.test.ts
@@ -109,6 +109,7 @@ describe('Call Tests', () => {
sdpMunging: {
convertPort9to0: true,
addContentSlides: false,
+ copyClineToSessionLevel: true,
},
};
@@ -717,32 +718,6 @@ describe('Call Tests', () => {
{file: 'call', method: 'updateMedia'}
);
});
-
- describe('#addSessionConnection', () => {
- let call;
-
- beforeEach(() => {
- call = callManager.createCall(dest, CallDirection.INBOUND, deviceId, mockLineId);
- });
-
- it('should copy the c-line from media level to the session level', () => {
- const sdp = `v=0\r\no=- 2890844526 2890842807 IN IP4 192.0.2.3\r\ns=-\r\nt=0 0\r\nm=audio 49170 RTP/AVP 0\r\nc=IN IP4 203.0.113.1\r\na=rtpmap:0 PCMU/8000`;
-
- const expectedSdp = `v=0\r\no=- 2890844526 2890842807 IN IP4 192.0.2.3\r\ns=-\r\nc=IN IP4 203.0.113.1\r\nt=0 0\r\nm=audio 49170 RTP/AVP 0\r\nc=IN IP4 203.0.113.1\r\na=rtpmap:0 PCMU/8000`;
-
- const result = call.addSessionConnection(sdp);
- expect(result).toBe(expectedSdp);
- });
-
- it('should handle multiple media sections correctly', () => {
- const sdp = `v=0\r\no=- 2890844526 2890842807 IN IP4 192.0.2.3\r\ns=-\r\nt=0 0\r\nm=audio 49170 RTP/AVP 0\r\nc=IN IP4 203.0.113.1\r\na=rtpmap:0 PCMU/8000\r\nm=video 51372 RTP/AVP 31\r\nc=IN IP4 203.0.113.2\r\na=rtpmap:31 H261/90000`;
-
- const expectedSdp = `v=0\r\no=- 2890844526 2890842807 IN IP4 192.0.2.3\r\ns=-\r\nc=IN IP4 203.0.113.1\r\nt=0 0\r\nm=audio 49170 RTP/AVP 0\r\nc=IN IP4 203.0.113.1\r\na=rtpmap:0 PCMU/8000\r\nm=video 51372 RTP/AVP 31\r\nc=IN IP4 203.0.113.2\r\na=rtpmap:31 H261/90000`;
-
- const result = call.addSessionConnection(sdp);
- expect(result).toBe(expectedSdp);
- });
- });
});
describe('State Machine handler tests', () => {
diff --git a/packages/calling/src/CallingClient/calling/call.ts b/packages/calling/src/CallingClient/calling/call.ts
index 9fc68949cba..fd7e64718e1 100644
--- a/packages/calling/src/CallingClient/calling/call.ts
+++ b/packages/calling/src/CallingClient/calling/call.ts
@@ -1904,6 +1904,7 @@ export class Call extends Eventing implements ICall {
sdpMunging: {
convertPort9to0: true,
addContentSlides: false,
+ copyClineToSessionLevel: true,
},
},
{
@@ -2375,24 +2376,6 @@ export class Call extends Eventing implements ICall {
});
}
- /* istanbul ignore next */
- /**
- * Copy SDP's c-line to session level from media level.
- * SPARK-522437
- */
- private addSessionConnection(sdp: string): string {
- const lines: string[] = sdp.split(/\r\n|\r|\n/);
- const mIndex: number = lines.findIndex((line) => line.startsWith('m='));
- const tIndex: number = lines.findIndex((line) => line.startsWith('t='));
-
- if (mIndex !== -1 && mIndex < lines.length - 1 && lines[mIndex + 1].startsWith('c=')) {
- const cLine: string = lines[mIndex + 1];
- lines.splice(tIndex, 0, cLine);
- }
-
- return lines.join('\r\n');
- }
-
/* istanbul ignore next */
/**
* Setup a listener for roap events emitted by the media sdk.
@@ -2413,10 +2396,6 @@ export class Call extends Eventing implements ICall {
method: this.mediaRoapEventsListener.name,
});
- if (event.roapMessage?.sdp) {
- event.roapMessage.sdp = this.addSessionConnection(event.roapMessage.sdp);
- }
-
switch (event.roapMessage.messageType) {
case RoapScenario.OK: {
const mediaOk = {
diff --git a/packages/calling/src/CallingClient/constants.ts b/packages/calling/src/CallingClient/constants.ts
index b395a335a5d..c3b8cc87fc4 100644
--- a/packages/calling/src/CallingClient/constants.ts
+++ b/packages/calling/src/CallingClient/constants.ts
@@ -43,9 +43,7 @@ export const DUMMY_METRICS = {
};
export const DUMMY_MOBIUS_URL = 'https://mobius.aintgen-a-1.int.infra.webex.com/api/v1';
export const FETCH_NAME = /^[a-zA-Z ]+/;
-export const IDENTITY_BROKER = 'https://identitybts.webex.com/';
export const IP_ENDPOINT = 'myip';
-export const IDENTITY_ENDPOINT_RESOURCE = 'identity';
export const INITIAL_SEQ_NUMBER = 1;
export const MEDIA_ENDPOINT_RESOURCE = 'media';
export const NETWORK_FLAP_TIMEOUT = 2000;
@@ -54,8 +52,6 @@ export const CALL_TRANSFER_SERVICE = 'calltransfer';
export const HOLD_ENDPOINT = 'hold';
export const TRANSFER_ENDPOINT = 'commit';
export const RESUME_ENDPOINT = 'resume';
-export const SCIM_ENDPOINT_RESOURCE = 'scim';
-export const SCIM_USER_FILTER = 'v1/Users?filter=';
export const SPARK_USER_AGENT = 'spark-user-agent';
export const REGISTER_RETRY_TIMEOUT = 10000;
export const SUPPLEMENTARY_SERVICES_TIMEOUT = 10000;
@@ -111,6 +107,7 @@ export const SEC_TO_MSEC_MFACTOR = 1000;
export const MINUTES_TO_SEC_MFACTOR = 60;
export const REG_RANDOM_T_FACTOR_UPPER_LIMIT = 10000;
export const REG_TRY_BACKUP_TIMER_VAL_IN_SEC = 1200;
+export const REG_TRY_BACKUP_TIMER_VAL_FOR_CC_IN_SEC = 114;
export const REG_FAILBACK_429_MAX_RETRIES = 5;
export const REGISTER_UTIL = 'registerDevice';
export const GET_MOBIUS_SERVERS_UTIL = 'getMobiusServers';
diff --git a/packages/calling/src/CallingClient/registration/register.test.ts b/packages/calling/src/CallingClient/registration/register.test.ts
index 0f0da477562..8283b073b86 100644
--- a/packages/calling/src/CallingClient/registration/register.test.ts
+++ b/packages/calling/src/CallingClient/registration/register.test.ts
@@ -22,6 +22,7 @@ import {
KEEPALIVE_UTIL,
MINUTES_TO_SEC_MFACTOR,
REGISTRATION_FILE,
+ REG_TRY_BACKUP_TIMER_VAL_FOR_CC_IN_SEC,
REG_TRY_BACKUP_TIMER_VAL_IN_SEC,
SEC_TO_MSEC_MFACTOR,
} from '../constants';
@@ -64,6 +65,17 @@ describe('Registration Tests', () => {
},
};
+ const ccMockResponse = {
+ ...mockResponse,
+ body: {
+ ...mockResponse.body,
+ serviceData: {
+ domain: '',
+ indicator: 'contactcenter',
+ },
+ },
+ };
+
const failurePayload = ({
statusCode: 500,
body: mockPostResponse,
@@ -85,15 +97,19 @@ describe('Registration Tests', () => {
let restoreSpy;
let postRegistrationSpy;
- beforeEach(() => {
+ const setupRegistration = (mockServiceData) => {
const mutex = new Mutex();
- reg = createRegistration(webex, MockServiceData, mutex, lineEmitter, LOGGER.INFO);
+ reg = createRegistration(webex, mockServiceData, mutex, lineEmitter, LOGGER.INFO);
reg.setMobiusServers(mobiusUris.primary, mobiusUris.backup);
jest.clearAllMocks();
restartSpy = jest.spyOn(reg, 'restartRegistration');
failbackRetry429Spy = jest.spyOn(reg, FAILBACK_429_RETRY_UTIL);
restoreSpy = jest.spyOn(reg, 'restorePreviousRegistration');
postRegistrationSpy = jest.spyOn(reg, 'postRegistration');
+ };
+
+ beforeEach(() => {
+ setupRegistration(MockServiceData);
});
afterEach(() => {
@@ -218,6 +234,36 @@ describe('Registration Tests', () => {
expect(reg.getActiveMobiusUrl()).toEqual(mobiusUris.backup[0]);
});
+ it('cc: verify unreachable primary with reachable backup server', async () => {
+ setupRegistration({...MockServiceData, indicator: ServiceIndicator.CONTACT_CENTER});
+
+ jest.useFakeTimers();
+ webex.request
+ .mockRejectedValueOnce(failurePayload)
+ .mockRejectedValueOnce(failurePayload)
+ .mockResolvedValueOnce(successPayload);
+
+ expect(reg.getStatus()).toEqual(RegistrationStatus.IDLE);
+ await reg.triggerRegistration();
+ jest.advanceTimersByTime(REG_TRY_BACKUP_TIMER_VAL_FOR_CC_IN_SEC * SEC_TO_MSEC_MFACTOR);
+ await flushPromises();
+
+ expect(webex.request).toBeCalledTimes(3);
+ expect(webex.request).toBeCalledWith({
+ ...ccMockResponse,
+ method: 'POST',
+ uri: `${mobiusUris.primary[0]}device`,
+ });
+ expect(webex.request).toBeCalledWith({
+ ...ccMockResponse,
+ method: 'POST',
+ uri: `${mobiusUris.backup[0]}device`,
+ });
+ expect(reg.getStatus()).toEqual(RegistrationStatus.ACTIVE);
+ /* Active Url must match with the backup url as per the test */
+ expect(reg.getActiveMobiusUrl()).toEqual(mobiusUris.backup[0]);
+ });
+
it('verify unreachable primary and backup servers', async () => {
jest.useFakeTimers();
// try the primary twice and register successfully with backup servers
@@ -444,15 +490,14 @@ describe('Registration Tests', () => {
file: REGISTRATION_FILE,
method: 'startKeepaliveTimer',
};
-
const mockKeepAliveBody = {device: mockPostResponse.device};
- beforeEach(async () => {
+ const beforeEachSetupForKeepalive = async () => {
postRegistrationSpy.mockResolvedValueOnce(successPayload);
jest.useFakeTimers();
await reg.triggerRegistration();
expect(reg.getStatus()).toBe(RegistrationStatus.ACTIVE);
- });
+ };
afterEach(() => {
jest.clearAllTimers();
@@ -471,6 +516,7 @@ describe('Registration Tests', () => {
});
it('verify successful keep-alive cases', async () => {
+ await beforeEachSetupForKeepalive();
const keepAlivePayload = ({
statusCode: 200,
body: mockKeepAliveBody,
@@ -487,6 +533,7 @@ describe('Registration Tests', () => {
});
it('verify failure keep-alive cases: Retry Success', async () => {
+ await beforeEachSetupForKeepalive();
const failurePayload = ({
statusCode: 503,
body: mockKeepAliveBody,
@@ -517,6 +564,7 @@ describe('Registration Tests', () => {
});
it('verify failure keep-alive cases: Restore failure', async () => {
+ await beforeEachSetupForKeepalive();
const restoreSpy = jest.spyOn(reg, 'restorePreviousRegistration');
const restartRegSpy = jest.spyOn(reg, 'restartRegistration');
const reconnectSpy = jest.spyOn(reg, 'reconnectOnFailure');
@@ -565,6 +613,7 @@ describe('Registration Tests', () => {
});
it('verify failure keep-alive cases: Restore Success', async () => {
+ await beforeEachSetupForKeepalive();
const restoreSpy = jest.spyOn(reg, 'restorePreviousRegistration');
const restartRegSpy = jest.spyOn(reg, 'restartRegistration');
const reconnectSpy = jest.spyOn(reg, 'reconnectOnFailure');
@@ -616,6 +665,7 @@ describe('Registration Tests', () => {
});
it('verify failure followed by recovery of keepalive', async () => {
+ await beforeEachSetupForKeepalive();
const failurePayload = ({
statusCode: 503,
body: mockKeepAliveBody,
@@ -647,7 +697,53 @@ describe('Registration Tests', () => {
expect(reg.keepaliveTimer).toBe(timer);
});
+ it('cc: verify failover to backup server after 4 keep alive failure with primary server', async () => {
+ // Register with contact center service
+ setupRegistration({...MockServiceData, indicator: ServiceIndicator.CONTACT_CENTER});
+ await beforeEachSetupForKeepalive();
+
+ const failurePayload = ({
+ statusCode: 503,
+ body: mockKeepAliveBody,
+ });
+ const successPayload = ({
+ statusCode: 200,
+ body: mockKeepAliveBody,
+ });
+
+ const clearIntervalSpy = jest.spyOn(global, 'clearInterval');
+
+ jest
+ .spyOn(reg, 'postKeepAlive')
+ .mockRejectedValueOnce(failurePayload)
+ .mockRejectedValueOnce(failurePayload)
+ .mockRejectedValueOnce(failurePayload)
+ .mockRejectedValueOnce(failurePayload)
+ .mockResolvedValue(successPayload);
+
+ expect(reg.getStatus()).toBe(RegistrationStatus.ACTIVE);
+
+ const timer = reg.keepaliveTimer;
+
+ jest.advanceTimersByTime(5 * mockPostResponse.keepaliveInterval * SEC_TO_MSEC_MFACTOR);
+ await flushPromises();
+
+ expect(clearIntervalSpy).toBeCalledOnceWith(timer);
+ expect(reg.getStatus()).toBe(RegistrationStatus.INACTIVE);
+ expect(reg.keepaliveTimer).not.toBe(timer);
+
+ webex.request.mockRejectedValueOnce(failurePayload).mockResolvedValue(successPayload);
+
+ jest.advanceTimersByTime(REG_TRY_BACKUP_TIMER_VAL_FOR_CC_IN_SEC * SEC_TO_MSEC_MFACTOR);
+ await flushPromises();
+
+ /* Active Url must match with the backup url as per the test */
+ expect(reg.getActiveMobiusUrl()).toEqual(mobiusUris.backup[0]);
+ expect(reg.getStatus()).toBe(RegistrationStatus.ACTIVE);
+ });
+
it('verify final error for keep-alive', async () => {
+ await beforeEachSetupForKeepalive();
const restoreSpy = jest.spyOn(reg, 'restorePreviousRegistration');
const restartRegSpy = jest.spyOn(reg, 'restartRegistration');
const reconnectSpy = jest.spyOn(reg, 'reconnectOnFailure');
@@ -686,6 +782,7 @@ describe('Registration Tests', () => {
});
it('verify failure keep-alive case with active call present: Restore Success after call ends', async () => {
+ await beforeEachSetupForKeepalive();
const restoreSpy = jest.spyOn(reg, 'restorePreviousRegistration');
const restartRegSpy = jest.spyOn(reg, 'restartRegistration');
const reconnectSpy = jest.spyOn(reg, 'reconnectOnFailure');
diff --git a/packages/calling/src/CallingClient/registration/register.ts b/packages/calling/src/CallingClient/registration/register.ts
index 862f827300e..297c8fef62e 100644
--- a/packages/calling/src/CallingClient/registration/register.ts
+++ b/packages/calling/src/CallingClient/registration/register.ts
@@ -17,6 +17,7 @@ import {
IDeviceInfo,
RegistrationStatus,
ServiceData,
+ ServiceIndicator,
WebexRequestPayload,
} from '../../common/types';
import {ISDKConnector, WebexSDK} from '../../SDKConnector/types';
@@ -39,6 +40,7 @@ import {
DEFAULT_REHOMING_INTERVAL_MIN,
DEFAULT_REHOMING_INTERVAL_MAX,
DEFAULT_KEEPALIVE_INTERVAL,
+ REG_TRY_BACKUP_TIMER_VAL_FOR_CC_IN_SEC,
} from '../constants';
import {LINE_EVENTS, LineEmitterCallback} from '../line/types';
import {LineError} from '../../Errors/catalog/LineError';
@@ -73,6 +75,7 @@ export class Registration implements IRegistration {
private backupMobiusUris: string[];
private registerRetry = false;
private reconnectPending = false;
+ private isCCFlow = false;
/**
*/
@@ -85,6 +88,8 @@ export class Registration implements IRegistration {
) {
this.sdkConnector = SDKConnector;
this.serviceData = serviceData;
+ this.isCCFlow = serviceData.indicator === ServiceIndicator.CONTACT_CENTER;
+
if (!this.sdkConnector.getWebex()) {
SDKConnector.setWebex(webex);
}
@@ -257,8 +262,12 @@ export class Registration implements IRegistration {
let interval = this.getRegRetryInterval(attempt);
- if (timeElapsed + interval > REG_TRY_BACKUP_TIMER_VAL_IN_SEC) {
- const excessVal = timeElapsed + interval - REG_TRY_BACKUP_TIMER_VAL_IN_SEC;
+ const TIMER_THRESHOLD = this.isCCFlow
+ ? REG_TRY_BACKUP_TIMER_VAL_FOR_CC_IN_SEC
+ : REG_TRY_BACKUP_TIMER_VAL_IN_SEC;
+
+ if (timeElapsed + interval > TIMER_THRESHOLD) {
+ const excessVal = timeElapsed + interval - TIMER_THRESHOLD;
interval -= excessVal;
}
@@ -681,13 +690,15 @@ export class Registration implements IRegistration {
private startKeepaliveTimer(url: string, interval: number) {
let keepAliveRetryCount = 0;
this.clearKeepaliveTimer();
+ const RETRY_COUNT_THRESHOLD = this.isCCFlow ? 4 : 5;
+
this.keepaliveTimer = setInterval(async () => {
const logContext = {
file: REGISTRATION_FILE,
method: this.startKeepaliveTimer.name,
};
await this.mutex.runExclusive(async () => {
- if (this.isDeviceRegistered() && keepAliveRetryCount < 5) {
+ if (this.isDeviceRegistered() && keepAliveRetryCount < RETRY_COUNT_THRESHOLD) {
try {
const res = await this.postKeepAlive(url);
log.info(`Sent Keepalive, status: ${res.statusCode}`, logContext);
@@ -720,7 +731,7 @@ export class Registration implements IRegistration {
{method: this.startKeepaliveTimer.name, file: REGISTRATION_FILE}
);
- if (abort || keepAliveRetryCount >= 5) {
+ if (abort || keepAliveRetryCount >= RETRY_COUNT_THRESHOLD) {
this.setStatus(RegistrationStatus.INACTIVE);
this.clearKeepaliveTimer();
this.clearFailbackTimer();
diff --git a/packages/calling/src/Contacts/ContactsClient.test.ts b/packages/calling/src/Contacts/ContactsClient.test.ts
index ee1068f02eb..404023aaba4 100644
--- a/packages/calling/src/Contacts/ContactsClient.test.ts
+++ b/packages/calling/src/Contacts/ContactsClient.test.ts
@@ -1,9 +1,16 @@
-import {HTTP_METHODS, WebexRequestPayload} from '../common/types';
+import {HTTP_METHODS, SCIMListResponse, WebexRequestPayload} from '../common/types';
import {getTestUtilsWebex} from '../common/testUtil';
import {LOGGER} from '../Logger/types';
import {Contact, ContactResponse, IContacts} from './types';
import {createContactsClient} from './ContactsClient';
-import {FAILURE_MESSAGE, SUCCESS_MESSAGE} from '../common/constants';
+import {
+ FAILURE_MESSAGE,
+ IDENTITY_ENDPOINT_RESOURCE,
+ SCIM_ENDPOINT_RESOURCE,
+ SCIM_USER_FILTER,
+ SUCCESS_MESSAGE,
+ WEBEX_API_BTS,
+} from '../common/constants';
import log from '../Logger';
import {
CONTACTS_FILE,
@@ -24,12 +31,12 @@ import {
mockContactResponseBodyOne,
mockCountry,
mockDisplayNameOne,
- mockDSSResponse,
mockEmail,
mockFirstName,
mockLastName,
mockNumber1,
mockNumber2,
+ mockSCIMListResponse,
mockSipAddress,
mockState,
mockStreet,
@@ -42,6 +49,8 @@ import {
mockContactGroupListOne,
mockContactGroupListTwo,
mockAvatarURL,
+ mockSCIMMinListResponse,
+ mockContactMinimum,
} from './contactFixtures';
describe('ContactClient Tests', () => {
@@ -51,6 +60,7 @@ describe('ContactClient Tests', () => {
// eslint-disable-next-line no-underscore-dangle
const contactServiceUrl = `${webex.internal.services._serviceUrls.contactsService}/${ENCRYPT_FILTER}/${USERS}/${CONTACT_FILTER}`;
+ const scimUrl = `${WEBEX_API_BTS}/${IDENTITY_ENDPOINT_RESOURCE}/${SCIM_ENDPOINT_RESOURCE}/${webex.internal.device.orgId}/${SCIM_USER_FILTER}id%20eq%20%22801bb994-343b-4f6b-97ae-d13c91d4b877%22`;
// eslint-disable-next-line no-underscore-dangle
const contactServiceGroupUrl = `${webex.internal.services._serviceUrls.contactsService}/${ENCRYPT_FILTER}/${USERS}/${GROUP_FILTER}`;
const serviceErrorCodeHandlerSpy = jest.spyOn(utils, 'serviceErrorCodeHandler');
@@ -90,6 +100,8 @@ describe('ContactClient Tests', () => {
expectedMessage: string;
expectedStatusCode: number;
decryptTextList: Array;
+ cloudContactPresent?: boolean;
+ scimResponse?: SCIMListResponse;
}[] = [
{
name: 'Success case 1: fetch contacts using get contacts api, custom and cloud contact present',
@@ -118,6 +130,8 @@ describe('ContactClient Tests', () => {
mockSipAddress,
mockGroupName,
],
+ cloudContactPresent: true,
+ scimResponse: mockSCIMListResponse,
},
{
name: 'Success case 2: fetch contacts using get contacts api, single custom contact with mandatory details present',
@@ -202,7 +216,10 @@ describe('ContactClient Tests', () => {
codeObj.decryptTextList.forEach((text) => {
webex.internal.encryption.decryptText.mockResolvedValueOnce(text);
});
- webex.internal.dss.lookup.mockResolvedValueOnce(mockDSSResponse);
+
+ if (codeObj.scimResponse) {
+ webex.request.mockResolvedValueOnce(mockSCIMListResponse);
+ }
} else {
respPayload['message'] = FAILURE_MESSAGE;
respPayload['data'] = codeObj.payloadData;
@@ -211,10 +228,34 @@ describe('ContactClient Tests', () => {
const contactsResponse = await contactClient.getContacts();
- expect(webex.request).toBeCalledOnceWith({
- uri: contactServiceUrl,
- method: HTTP_METHODS.GET,
- });
+ if (codeObj.inputStatusCode === 200) {
+ if (codeObj.cloudContactPresent) {
+ expect(webex.request).toBeCalledTimes(2);
+ } else {
+ expect(webex.request).toBeCalledTimes(1);
+ }
+ expect(webex.request).toHaveBeenNthCalledWith(1, {
+ uri: contactServiceUrl,
+ method: HTTP_METHODS.GET,
+ });
+
+ if (codeObj.cloudContactPresent) {
+ expect(webex.request).toHaveBeenNthCalledWith(2, {
+ uri: scimUrl,
+ method: HTTP_METHODS.GET,
+ headers: {
+ 'cisco-device-url':
+ 'https://wdm-intb.ciscospark.com/wdm/api/v1/devices/c5ae3b86-1bb7-40f1-a6a9-c296ee7e61d5',
+ 'spark-user-agent': 'webex-calling/beta',
+ },
+ });
+ }
+ } else {
+ expect(webex.request).toBeCalledOnceWith({
+ uri: contactServiceUrl,
+ method: HTTP_METHODS.GET,
+ });
+ }
expect(contactsResponse).toEqual({
data: expect.any(Object),
@@ -563,7 +604,8 @@ describe('ContactClient Tests', () => {
webex.request
.mockResolvedValueOnce(successResponsePayloadGroup)
- .mockResolvedValueOnce(successResponsePayload);
+ .mockResolvedValueOnce(successResponsePayload)
+ .mockResolvedValueOnce(mockSCIMListResponse);
webex.internal.encryption.encryptText.mockResolvedValueOnce('Encrypted group name');
@@ -581,14 +623,13 @@ describe('ContactClient Tests', () => {
expect(res.statusCode).toEqual(400);
expect(res.data.error).toEqual('contactId is required for contactType:CLOUD.');
- webex.internal.dss.lookup.mockResolvedValueOnce(mockDSSResponse);
contact.contactId = mockContactResponse.contactId;
res = await contactClient.createContact(contact);
expect(res.statusCode).toEqual(201);
expect(res.data.contact?.contactId).toBe(mockContactResponse.contactId);
- expect(webex.request).toBeCalledTimes(2);
+ expect(webex.request).toBeCalledTimes(3);
expect(webex.request).toHaveBeenNthCalledWith(1, {
method: HTTP_METHODS.POST,
uri: contactServiceGroupUrl,
@@ -610,6 +651,15 @@ describe('ContactClient Tests', () => {
groups: ['1561977e-3443-4ccf-a591-69686275d7d2'],
},
});
+ expect(webex.request).toHaveBeenNthCalledWith(3, {
+ uri: scimUrl,
+ method: HTTP_METHODS.GET,
+ headers: {
+ 'cisco-device-url':
+ 'https://wdm-intb.ciscospark.com/wdm/api/v1/devices/c5ae3b86-1bb7-40f1-a6a9-c296ee7e61d5',
+ 'spark-user-agent': 'webex-calling/beta',
+ },
+ });
});
it('create a contact - service unavailable', async () => {
@@ -677,4 +727,40 @@ describe('ContactClient Tests', () => {
expect(contactClient['contacts']).toEqual(mockContactListOne);
});
+
+ it('test resolveContacts function for a minimal contact with few details', () => {
+ const contact = contactClient['resolveCloudContacts'](
+ {userId: mockContactMinimum},
+ mockSCIMMinListResponse.body
+ );
+
+ expect(contact).toEqual([
+ {
+ avatarURL: '',
+ avatarUrlDomain: undefined,
+ contactId: 'userId',
+ contactType: 'CLOUD',
+ department: undefined,
+ displayName: undefined,
+ emails: undefined,
+ encryptionKeyUrl: 'kms://cisco.com/keys/dcf18f9d-155e-44ff-ad61-c8a69b7103ab',
+ firstName: undefined,
+ groups: ['1561977e-3443-4ccf-a591-69686275d7d2'],
+ lastName: undefined,
+ manager: undefined,
+ ownerId: 'ownerId',
+ phoneNumbers: undefined,
+ sipAddresses: undefined,
+ },
+ ]);
+ });
+
+ it('test resolveContacts function encountering an error', () => {
+ const contact = contactClient['resolveCloudContacts'](
+ {userId: mockContactMinimum},
+ mockSCIMMinListResponse
+ );
+
+ expect(contact).toEqual(null);
+ });
});
diff --git a/packages/calling/src/Contacts/ContactsClient.ts b/packages/calling/src/Contacts/ContactsClient.ts
index d701f7d8f0e..efb2add327e 100644
--- a/packages/calling/src/Contacts/ContactsClient.ts
+++ b/packages/calling/src/Contacts/ContactsClient.ts
@@ -1,6 +1,12 @@
/* eslint-disable no-await-in-loop */
-import {FAILURE_MESSAGE, STATUS_CODE, SUCCESS_MESSAGE} from '../common/constants';
-import {HTTP_METHODS, WebexRequestPayload, ContactDetail} from '../common/types';
+import {
+ FAILURE_MESSAGE,
+ SCIM_ENTERPRISE_USER,
+ SCIM_WEBEXIDENTITY_USER,
+ STATUS_CODE,
+ SUCCESS_MESSAGE,
+} from '../common/constants';
+import {HTTP_METHODS, WebexRequestPayload, ContactDetail, SCIMListResponse} from '../common/types';
import {LoggerInterface} from '../Voicemail/types';
import {ISDKConnector, WebexSDK} from '../SDKConnector/types';
import SDKConnector from '../SDKConnector';
@@ -13,6 +19,8 @@ import {
DEFAULT_GROUP_NAME,
ENCRYPT_FILTER,
GROUP_FILTER,
+ OR,
+ SCIM_ID_FILTER,
USERS,
encryptedFields,
} from './constants';
@@ -27,10 +35,7 @@ import {
GroupType,
} from './types';
-import {serviceErrorCodeHandler} from '../common/Utils';
-import Logger from '../Logger';
-import ExtendedError from '../Errors/catalog/ExtendedError';
-import {ERROR_TYPE} from '../Errors/types';
+import {scimQuery, serviceErrorCodeHandler} from '../common/Utils';
/**
* `ContactsClient` module is designed to offer a set of APIs for retrieving and updating contacts and groups from the contacts-service.
@@ -66,7 +71,6 @@ export class ContactsClient implements IContacts {
}
this.webex = this.sdkConnector.getWebex();
- this.webex.internal.dss.register();
this.encryptionKeyUrl = '';
this.groups = undefined;
@@ -253,75 +257,60 @@ export class ContactsClient implements IContacts {
return decryptedContact;
}
- /**
- * Fetches contacts from DSS.
- */
- private async fetchContactFromDSS(
+ private resolveCloudContacts(
contactsDataMap: ContactIdContactInfo,
- id: string
- ): Promise {
- try {
- const contact = await this.webex.internal.dss.lookup({id, shouldBatch: true});
-
- const contactId = contact.identity;
- const {displayName, emails, phoneNumbers, sipAddresses, photos} = contact;
- const {department, firstName, identityManager, jobTitle, lastName} = contact.additionalInfo;
- const manager =
- identityManager && identityManager.displayName ? identityManager.displayName : undefined;
- const {contactType, avatarUrlDomain, encryptionKeyUrl, ownerId, groups} =
- contactsDataMap[contactId];
- let avatarURL = '';
-
- if (photos.length) {
- avatarURL = photos[0].value;
- }
- const addedPhoneNumbers = contactsDataMap[contactId].phoneNumbers;
-
- if (addedPhoneNumbers) {
- const decryptedPhoneNumbers = await this.decryptContactDetail(
- encryptionKeyUrl,
- addedPhoneNumbers
- );
-
- decryptedPhoneNumbers.forEach((number) => phoneNumbers.push(number));
- }
-
- const addedSipAddresses = contactsDataMap[contactId].sipAddresses;
+ inputList: SCIMListResponse
+ ): Contact[] | null {
+ const loggerContext = {
+ file: CONTACTS_FILE,
+ method: 'resolveCloudContacts',
+ };
+ const finalContactList: Contact[] = [];
- if (addedSipAddresses) {
- const decryptedSipAddresses = await this.decryptContactDetail(
+ try {
+ for (let n = 0; n < inputList.Resources.length; n += 1) {
+ const filteredContact = inputList.Resources[n];
+ const {displayName, emails, phoneNumbers, photos} = filteredContact;
+ let sipAddresses;
+ if (filteredContact[SCIM_WEBEXIDENTITY_USER]) {
+ sipAddresses = filteredContact[SCIM_WEBEXIDENTITY_USER].sipAddresses;
+ }
+ const firstName = filteredContact.name?.givenName;
+ const lastName = filteredContact.name?.familyName;
+ const manager = filteredContact[SCIM_ENTERPRISE_USER]?.manager?.displayName;
+ const department = filteredContact[SCIM_ENTERPRISE_USER]?.department;
+ const avatarURL = photos?.length ? photos[0].value : '';
+
+ const {contactType, avatarUrlDomain, encryptionKeyUrl, ownerId, groups} =
+ contactsDataMap[inputList.Resources[n].id];
+
+ const cloudContact = {
+ avatarUrlDomain,
+ avatarURL,
+ contactId: inputList.Resources[n].id,
+ contactType,
+ department,
+ displayName,
+ emails,
encryptionKeyUrl,
- addedSipAddresses
- );
-
- decryptedSipAddresses.forEach((address) => sipAddresses.push(address));
+ firstName,
+ groups,
+ lastName,
+ manager,
+ ownerId,
+ phoneNumbers,
+ sipAddresses,
+ };
+
+ finalContactList.push(cloudContact);
}
-
- const cloudContact = {
- avatarUrlDomain,
- avatarURL,
- contactId,
- contactType,
- department,
- displayName,
- emails,
- encryptionKeyUrl,
- firstName,
- groups,
- lastName,
- manager,
- ownerId,
- phoneNumbers,
- sipAddresses,
- title: jobTitle,
- };
-
- return cloudContact;
} catch (error: any) {
- Logger.error(new ExtendedError(error.message, {}, ERROR_TYPE.DEFAULT), {});
+ log.warn('Error occurred while parsing resolved contacts', loggerContext);
return null;
}
+
+ return finalContactList;
}
/**
@@ -334,7 +323,7 @@ export class ContactsClient implements IContacts {
};
const contactList: Contact[] = [];
- const contactsDataMap: ContactIdContactInfo = {};
+ const cloudContactsMap: ContactIdContactInfo = {};
try {
const response = await this.webex.request({
@@ -351,19 +340,27 @@ export class ContactsClient implements IContacts {
const {contacts, groups} = responseBody;
- for (let i = 0; i < contacts.length; i += 1) {
- const contact = contacts[i];
-
+ contacts.map(async (contact) => {
if (contact.contactType === ContactType.CUSTOM) {
const decryptedContact = await this.decryptContact(contact);
contactList.push(decryptedContact);
} else if (contact.contactType === ContactType.CLOUD && contact.contactId) {
- contactsDataMap[contact.contactId] = contact;
- const contactDetails = await this.fetchContactFromDSS(contactsDataMap, contact.contactId);
- if (contactDetails) {
- contactList.push(contactDetails);
- }
+ cloudContactsMap[contact.contactId] = contact;
+ }
+ });
+
+ // Resolve cloud contacts
+ if (Object.keys(cloudContactsMap).length) {
+ const contactIdList = Object.keys(cloudContactsMap);
+ const query = contactIdList.map((item) => `${SCIM_ID_FILTER} "${item}"`).join(OR);
+ const result = await scimQuery(query);
+ const resolvedContacts = this.resolveCloudContacts(
+ cloudContactsMap,
+ result.body as SCIMListResponse
+ );
+ if (resolvedContacts) {
+ resolvedContacts.map((item) => contactList.push(item));
}
}
@@ -699,13 +696,14 @@ export class ContactsClient implements IContacts {
};
if (contact.contactType === ContactType.CLOUD && newContact.contactId) {
- const decryptedContact = await this.fetchContactFromDSS(
+ const query = `${SCIM_ID_FILTER} "${newContact.contactId}"`;
+ const res = await scimQuery(query);
+ const resolvedContact = this.resolveCloudContacts(
Object.fromEntries([[newContact.contactId, newContact]]) as ContactIdContactInfo,
- newContact.contactId
+ res.body as SCIMListResponse
);
-
- if (decryptedContact) {
- this.contacts?.push(decryptedContact);
+ if (resolvedContact) {
+ this.contacts?.push(resolvedContact[0]);
}
} else {
this.contacts?.push(contact);
diff --git a/packages/calling/src/Contacts/constants.ts b/packages/calling/src/Contacts/constants.ts
index 8b69f44686b..705ceda4381 100644
--- a/packages/calling/src/Contacts/constants.ts
+++ b/packages/calling/src/Contacts/constants.ts
@@ -5,6 +5,8 @@ export const ENCRYPT_FILTER = 'encrypt';
export const USERS = 'Users';
export const DEFAULT_GROUP_NAME = 'Other contacts';
export const CONTACTS_SCHEMA = 'urn:cisco:codev:identity:contact:core:1.0';
+export const SCIM_ID_FILTER = 'id eq';
+export const OR = ' or ';
export enum encryptedFields {
ADDRESS_INFO = 'addressInfo',
diff --git a/packages/calling/src/Contacts/contactFixtures.ts b/packages/calling/src/Contacts/contactFixtures.ts
index afe591a8f83..4295a607411 100644
--- a/packages/calling/src/Contacts/contactFixtures.ts
+++ b/packages/calling/src/Contacts/contactFixtures.ts
@@ -272,39 +272,213 @@ export const mockContactGroupListTwo = [
},
];
-export const mockDSSResponse = {
- additionalInfo: {
- created: '2022-08-05T02:51:46.055Z',
- department: '123029217',
- extLinkedAccts: [{providerID: 'cisco.webex.com', accountGUID: '500802287', status: 'active'}],
- firstName: 'Emily',
- identityManager: {
- displayName: 'Robert Langdon',
- managerId: '9d0fce00-95b2-435f-99d1-b6b44759fbdc',
+export const mockContactMinimum = {
+ contactId: 'userId',
+ contactType: 'CLOUD',
+ encryptionKeyUrl: 'kms://cisco.com/keys/dcf18f9d-155e-44ff-ad61-c8a69b7103ab',
+ groups: ['1561977e-3443-4ccf-a591-69686275d7d2'],
+ ownerId: 'ownerId',
+};
+
+export const scimUserMinimum = {
+ schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
+ id: 'userId',
+ userName: 'userName',
+ userType: 'user',
+};
+
+const scimUser1 = {
+ schemas: [
+ 'urn:ietf:params:scim:schemas:core:2.0:User',
+ 'urn:scim:schemas:extension:cisco:webexidentity:2.0:User',
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User',
+ ],
+ id: 'userId',
+ userName: 'johndoe@cisco.com',
+ active: true,
+ name: {
+ familyName: 'Doe',
+ givenName: 'John',
+ },
+ displayName: 'John Doe',
+ emails: [
+ {
+ value: 'johndoe@cisco.com',
+ type: 'work',
+ primary: true,
+ },
+ ],
+ userType: 'user',
+ phoneNumbers: [
+ {
+ value: '+91 22 1234 5678',
+ type: 'work',
+ },
+ ],
+ photos: [
+ {
+ value: 'photoUrl',
+ type: 'photo',
+ },
+ {
+ value: 'thumbnailURL',
+ type: 'thumbnail',
+ },
+ ],
+ addresses: [
+ {
+ type: 'work',
+ streetAddress: 'Street',
+ locality: 'BANGALORE',
+ region: 'KARNATAKA',
+ postalCode: '560103',
+ country: 'IN',
+ },
+ ],
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User': {
+ department: '123029016',
+ manager: {
+ value: 'userId',
+ displayName: 'Jane Smith',
+ $ref: 'scimUrl',
},
- jobTitle: 'Software Engineer',
- lastName: 'Nagakawa',
- modified: '2023-03-03T13:37:03.196Z',
- nickName: 'Emily',
- userName: 'emikawa2@cisco.com',
},
- displayName: 'Emily Nagakawa',
- emails: [{value: 'emikawa2@cisco.com'}],
- entityProviderType: 'CI_USER',
- identity: '801bb994-343b-4f6b-97ae-d13c91d4b877',
- orgId: '1eb65fdf-9643-417f-9974-ad72cae0e10f',
+ 'urn:scim:schemas:extension:cisco:webexidentity:2.0:User': {
+ isTeamsOnJabberEnabled: false,
+ isUCCallOnJabberEnabled: false,
+ licenseID: ['license'],
+ userSettings: ['setting'],
+ userPreferences: ['preferences'],
+ sipAddresses: [
+ {
+ value: 'johndoe@cisco.call.ciscospark.com',
+ type: 'cloud-calling',
+ },
+ {
+ value: 'johndoe@cisco.calls.webex.com',
+ type: 'cloud-calling',
+ primary: true,
+ },
+ {
+ value: 'johndoe@cisco.webex.com',
+ type: 'personal-room',
+ },
+ ],
+ meta: {
+ organizationId: 'orgId',
+ },
+ userNameType: 'email',
+ },
+ meta: {
+ resourceType: 'User',
+ location: 'scimUrl',
+ version: 'W/"16629124099"',
+ created: '2019-12-24T02:01:42.803Z',
+ lastModified: '2024-08-21T14:22:55.987Z',
+ },
+};
+
+const scimUser2NoPhoto = {
+ schemas: [
+ 'urn:ietf:params:scim:schemas:core:2.0:User',
+ 'urn:scim:schemas:extension:cisco:webexidentity:2.0:User',
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User',
+ ],
+ id: 'userId',
+ userName: 'janedoe@cisco.com',
+ active: true,
+ name: {
+ familyName: 'Doe',
+ givenName: 'Jane',
+ },
+ displayName: 'Jane Doe',
+ emails: [
+ {
+ value: 'janedoe@cisco.com',
+ type: 'work',
+ primary: true,
+ },
+ ],
+ userType: 'user',
phoneNumbers: [
- {type: 'mobile', value: '+1 835 648 8750'},
- {type: 'work', value: '+1 791 723 8825'},
+ {
+ value: '+91 22 1234 5678',
+ type: 'work',
+ },
],
- photos: [{value: 'avatar-prod-us-east-2.webexcontent.com'}],
- sipAddresses: [
- {type: 'cloud-calling', value: 'emikawa2@cisco.call.ciscospark.com', primary: true},
- {type: 'personal-room', value: 'emikawa2@cisco.webex.com', primary: false},
- {type: 'enterprise', value: 'emikawa2@cisco.com', primary: true},
- {type: 'personal-room', value: '25762555827@cisco.webex.com', primary: false},
+ addresses: [
+ {
+ type: 'work',
+ streetAddress: 'Street',
+ locality: 'BANGALORE',
+ region: 'KARNATAKA',
+ postalCode: '560103',
+ country: 'IN',
+ },
],
- type: 'PERSON',
+ 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User': {
+ department: '123029016',
+ manager: {
+ value: 'userId',
+ displayName: 'John Smith',
+ $ref: 'scimUrl',
+ },
+ },
+ 'urn:scim:schemas:extension:cisco:webexidentity:2.0:User': {
+ isTeamsOnJabberEnabled: false,
+ isUCCallOnJabberEnabled: false,
+ licenseID: ['license'],
+ userSettings: ['setting'],
+ userPreferences: ['preferences'],
+ sipAddresses: [
+ {
+ value: 'janedoe@cisco.call.ciscospark.com',
+ type: 'cloud-calling',
+ },
+ {
+ value: 'janedoe@cisco.calls.webex.com',
+ type: 'cloud-calling',
+ primary: true,
+ },
+ {
+ value: 'janedoe@cisco.webex.com',
+ type: 'personal-room',
+ },
+ ],
+ meta: {
+ organizationId: 'orgId',
+ },
+ userNameType: 'email',
+ },
+ meta: {
+ resourceType: 'User',
+ location: 'scimUrl',
+ version: 'W/"16629124099"',
+ created: '2019-12-24T02:01:42.803Z',
+ lastModified: '2024-08-21T14:22:55.987Z',
+ },
+};
+
+export const mockSCIMListResponse = {
+ statusCode: 200,
+ body: {
+ schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+ totalResults: 2,
+ itemsPerPage: 2,
+ startIndex: 1,
+ Resources: [scimUser1, scimUser2NoPhoto],
+ },
+};
+
+export const mockSCIMMinListResponse = {
+ statusCode: 200,
+ body: {
+ schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'],
+ totalResults: 1,
+ itemsPerPage: 1,
+ startIndex: 1,
+ Resources: [scimUserMinimum],
+ },
};
export const mockKmsKey = {
diff --git a/packages/calling/src/Contacts/types.ts b/packages/calling/src/Contacts/types.ts
index 2c9d2e16fbf..443f0149034 100644
--- a/packages/calling/src/Contacts/types.ts
+++ b/packages/calling/src/Contacts/types.ts
@@ -1,19 +1,11 @@
import {ISDKConnector} from '../SDKConnector/types';
-import {ContactDetail} from '../common/types';
+import {Address, PhoneNumber, URIAddress} from '../common/types';
export enum ContactType {
CUSTOM = 'CUSTOM',
CLOUD = 'CLOUD',
}
-export type AddressType = {
- city?: string;
- country?: string;
- state?: string;
- street?: string;
- zipCode?: string;
-};
-
/**
* `Contact` object is used to represent a contact.
*/
@@ -21,7 +13,7 @@ export type Contact = {
/**
* This represents the complete address of the contact.
*/
- addressInfo?: AddressType;
+ addressInfo?: Address;
/**
* This represents the URL of the avatar of the contact.
*/
@@ -37,7 +29,7 @@ export type Contact = {
/**
* Unique identifier of the contact.
*/
- contactId?: string;
+ contactId: string;
/**
* Indicates the type of the contact, can be `CLOUD` or `CUSTOM`.
*/
@@ -49,11 +41,11 @@ export type Contact = {
/**
* This represents the display name of the contact.
*/
- displayName: string;
+ displayName?: string;
/**
* This represents the array of different email addresses of the contact.
*/
- emails?: ContactDetail[];
+ emails?: URIAddress[];
/**
* This is encrypted key url of the contact used for encryption.
*/
@@ -85,7 +77,7 @@ export type Contact = {
/**
* This represents the array of different phone numbers of the contact.
*/
- phoneNumbers?: ContactDetail[];
+ phoneNumbers?: PhoneNumber[];
/**
* Primary contact method as set by the contact.
*/
@@ -97,7 +89,7 @@ export type Contact = {
/**
* This represents the array of different sip addresses of the contact.
*/
- sipAddresses?: ContactDetail[];
+ sipAddresses?: URIAddress[];
/**
* This represents the job title of the contact.
*/
diff --git a/packages/calling/src/Events/types.ts b/packages/calling/src/Events/types.ts
index 17c8c51bf5d..c0ed077a021 100644
--- a/packages/calling/src/Events/types.ts
+++ b/packages/calling/src/Events/types.ts
@@ -79,6 +79,8 @@ export type CallRecordSelf = {
id: string;
name?: string;
phoneNumber?: string;
+ cucmDN?: string;
+ ucmLineNumber?: number;
};
export type CallRecordListOther = {
@@ -242,32 +244,38 @@ enum CALL_STATE {
REMOTE_HELD = 'remoteheld',
CONNECTED = 'connected',
}
+
type eventType = string;
+
type callProgressData = {
alerting: boolean;
inbandROAP: boolean;
};
+
export type CallerIdInfo = {
'x-broadworks-remote-party-info'?: string;
'p-asserted-identity'?: string;
from?: string;
};
+
type callId = string;
type deviceId = string;
type correlationId = string;
type callUrl = string;
type causecode = number;
type cause = string;
+
type eventData = {
callerId: CallerIdInfo;
callState: CALL_STATE;
};
+
type midCallServiceData = {
eventType: eventType;
eventData: eventData;
};
-type midCallService = Array;
+type midCallService = Array;
interface BaseMessage {
eventType: eventType;
correlationId: correlationId;
@@ -275,19 +283,18 @@ interface BaseMessage {
callId: callId;
callUrl: callUrl;
}
-
export interface CallSetupMessage extends BaseMessage {
callerId: CallerIdInfo;
trackingId: string;
alertType: string;
}
-
interface CallProgressMessage extends BaseMessage {
callProgressData: callProgressData;
callerId: CallerIdInfo;
}
export const WEBSOCKET_SCOPE = 'mobius';
+
export enum WEBSOCKET_KEYS {
CALL_PROGRESS = 'callprogress',
CALL_CONNECTED = 'callconnected',
@@ -368,7 +375,24 @@ export type EndTimeSessionId = {
endTime: string;
sessionId: string;
};
+
export type SanitizedEndTimeAndSessionId = {
endTime: number;
sessionId: string;
};
+
+export type UCMLine = {
+ dnorpattern: string;
+ index: number;
+ label: string | null;
+};
+
+export type UCMDevice = {
+ name: string;
+ model: number;
+ lines: UCMLine[];
+};
+
+export type UCMLinesApiResponse = {
+ devices: UCMDevice[];
+};
diff --git a/packages/calling/src/SDKConnector/types.ts b/packages/calling/src/SDKConnector/types.ts
index 3f5760d518b..68f92869c5c 100644
--- a/packages/calling/src/SDKConnector/types.ts
+++ b/packages/calling/src/SDKConnector/types.ts
@@ -1,11 +1,4 @@
-import {
- DSSLookupResponse,
- KmsKey,
- KmsResourceObject,
- LookupOptions,
- PeopleListResponse,
- WebexRequestPayload,
-} from '../common/types';
+import {KmsKey, KmsResourceObject, PeopleListResponse, WebexRequestPayload} from '../common/types';
/* eslint-disable no-shadow */
type Listener = (e: string, data?: unknown) => void;
@@ -82,10 +75,6 @@ export interface WebexSDK {
};
};
};
- dss: {
- lookup: (options: LookupOptions) => Promise;
- register: () => Promise;
- };
encryption: {
decryptText: (encryptionKeyUrl: string, encryptedData?: string) => Promise;
encryptText: (encryptionKeyUrl: string, text?: string) => Promise;
@@ -109,7 +98,6 @@ export interface WebexSDK {
mercuryApi: string;
'ucmgmt-gateway': string;
contactsService: string;
- directorySearch: string;
};
fetchClientRegionInfo: () => Promise;
};
diff --git a/packages/calling/src/common/Utils.test.ts b/packages/calling/src/common/Utils.test.ts
index e480b3497b9..06b70c2c098 100644
--- a/packages/calling/src/common/Utils.test.ts
+++ b/packages/calling/src/common/Utils.test.ts
@@ -8,6 +8,7 @@ import {
getSamplePeopleListResponse,
getSampleRawAndParsedMediaStats,
getMobiusDiscoveryResponse,
+ getSampleMinimumScimResponse,
} from './testUtil';
import {
CallDirection,
@@ -20,15 +21,7 @@ import {
RegistrationStatus,
} from './types';
import log from '../Logger';
-import {
- CALL_FILE,
- DUMMY_METRICS,
- UTILS_FILE,
- IDENTITY_ENDPOINT_RESOURCE,
- SCIM_ENDPOINT_RESOURCE,
- SCIM_USER_FILTER,
- REGISTER_UTIL,
-} from '../CallingClient/constants';
+import {CALL_FILE, DUMMY_METRICS, UTILS_FILE, REGISTER_UTIL} from '../CallingClient/constants';
import {
CALL_ERROR_CODE,
ERROR_CODE,
@@ -54,7 +47,13 @@ import {
getAscVoicemailListJsonWXC,
getDescVoicemailListJsonWXC,
} from '../Voicemail/voicemailFixture';
-import {INFER_ID_CONSTANT} from './constants';
+import {
+ IDENTITY_ENDPOINT_RESOURCE,
+ INFER_ID_CONSTANT,
+ SCIM_ENDPOINT_RESOURCE,
+ SCIM_USER_FILTER,
+ WEBEX_API_BTS,
+} from './constants';
import {CALL_EVENT_KEYS} from '../Events/types';
const mockSubmitRegistrationMetric = jest.fn();
@@ -915,7 +914,7 @@ describe('Voicemail Sorting Tests', () => {
});
describe('resolveContact tests', () => {
- const scimUrl = `${webex.internal.services._serviceUrls.identity}/${IDENTITY_ENDPOINT_RESOURCE}/${SCIM_ENDPOINT_RESOURCE}/${webex.internal.device.orgId}/${SCIM_USER_FILTER}`;
+ const scimUrl = `${WEBEX_API_BTS}/${IDENTITY_ENDPOINT_RESOURCE}/${SCIM_ENDPOINT_RESOURCE}/${webex.internal.device.orgId}/${SCIM_USER_FILTER}`;
it('Invalid CallingPartyInfo', () => {
const callingPartyInfo = {} as CallingPartyInfo;
@@ -1051,6 +1050,29 @@ describe('resolveContact tests', () => {
});
});
+ it('Resolve with minimal response from SCIM', () => {
+ const callingPartyInfo = {} as CallingPartyInfo;
+ const scimResponse = getSampleMinimumScimResponse();
+
+ // scimResponse.Resources[0].photos = [];
+ const webexSpy = jest.spyOn(webex, 'request').mockResolvedValue({
+ statusCode: 200,
+ body: scimResponse,
+ });
+
+ callingPartyInfo.userExternalId = {$: 'userExternalId'};
+ resolveContact(callingPartyInfo).then((displayInfo) => {
+ expect(displayInfo?.name).toBeUndefined();
+ expect(displayInfo?.num).toBeUndefined();
+ expect(displayInfo?.avatarSrc).toStrictEqual('unknown');
+ expect(displayInfo?.id).toStrictEqual(getSampleMinimumScimResponse().Resources[0].id);
+
+ const query = scimUrl + encodeURIComponent(`id eq "${callingPartyInfo.userExternalId?.$}"`);
+
+ expect(webexSpy).toBeCalledOnceWith(expect.objectContaining({uri: query}));
+ });
+ });
+
it('Resolve by name', () => {
const callingPartyInfo = {} as CallingPartyInfo;
const webexSpy = jest
diff --git a/packages/calling/src/common/Utils.ts b/packages/calling/src/common/Utils.ts
index d2a3d5b4763..4c2d8441dfe 100644
--- a/packages/calling/src/common/Utils.ts
+++ b/packages/calling/src/common/Utils.ts
@@ -27,7 +27,6 @@ import {
LineErrorObject,
} from '../Errors/types';
import {
- ALLOWED_SERVICES,
CALLING_BACKEND,
CorrelationId,
DecodeType,
@@ -36,6 +35,7 @@ import {
IDeviceInfo,
MobiusServers,
RegistrationStatus,
+ SCIMListResponse,
SORT,
ServiceData,
ServiceIndicator,
@@ -52,7 +52,6 @@ import {
CISCO_DEVICE_URL,
CODEC_ID,
DUMMY_METRICS,
- IDENTITY_ENDPOINT_RESOURCE,
INBOUND_CODEC_MATCH,
INBOUND_RTP,
JITTER_BUFFER_DELAY,
@@ -75,8 +74,6 @@ import {
RTC_ICE_CANDIDATE_PAIR,
RTP_RX_STAT,
RTP_TX_STAT,
- SCIM_ENDPOINT_RESOURCE,
- SCIM_USER_FILTER,
SELECTED_CANDIDATE_PAIR_ID,
SPARK_USER_AGENT,
TARGET_BIT_RATE,
@@ -88,7 +85,11 @@ import {
URL_ENDPOINT,
UTILS_FILE,
} from '../CallingClient/constants';
-import {JanusResponseEvent, UpdateMissedCallsResponse} from '../CallHistory/types';
+import {
+ JanusResponseEvent,
+ UCMLinesResponse,
+ UpdateMissedCallsResponse,
+} from '../CallHistory/types';
import {
VoicemailResponseEvent,
MessageInfo,
@@ -113,9 +114,13 @@ import {
NATIVE_WEBEX_TEAMS_CALLING,
NATIVE_SIP_CALL_TO_UCM,
BW_XSI_ENDPOINT_VERSION,
+ IDENTITY_ENDPOINT_RESOURCE,
+ SCIM_ENDPOINT_RESOURCE,
+ SCIM_USER_FILTER,
+ WEBEX_API_PROD,
+ WEBEX_API_BTS,
} from './constants';
import {Model, WebexSDK} from '../SDKConnector/types';
-import {scimResponseBody} from '../CallingClient/calling/CallerId/types';
import SDKConnector from '../SDKConnector';
import {CallSettingResponse} from '../CallSettings/types';
import {ContactResponse} from '../Contacts/types';
@@ -690,6 +695,7 @@ export async function serviceErrorCodeHandler(
| CallSettingResponse
| ContactResponse
| UpdateMissedCallsResponse
+ | UCMLinesResponse
> {
const errorCode = Number(err.statusCode);
const failureMessage = 'FAILURE';
@@ -1177,7 +1183,7 @@ export function getSortedVoicemailList(
* @param filter - A filter for the query.
* @returns - Promise.
*/
-async function scimQuery(filter: string) {
+export async function scimQuery(filter: string) {
log.info(`Starting resolution for filter:- ${filter}`, {
file: UTILS_FILE,
method: 'scimQuery',
@@ -1185,7 +1191,10 @@ async function scimQuery(filter: string) {
const sdkConnector = SDKConnector;
const webex = sdkConnector.getWebex();
- const scimUrl = `${webex.internal.services._serviceUrls.identity}/${IDENTITY_ENDPOINT_RESOURCE}/${SCIM_ENDPOINT_RESOURCE}/${webex.internal.device.orgId}/${SCIM_USER_FILTER}`;
+ const isProd = !webex.internal.device.url.includes('-int');
+ const webexHost = isProd ? WEBEX_API_PROD : WEBEX_API_BTS;
+
+ const scimUrl = `${webexHost}/${IDENTITY_ENDPOINT_RESOURCE}/${SCIM_ENDPOINT_RESOURCE}/${webex.internal.device.orgId}/${SCIM_USER_FILTER}`;
const query = scimUrl + encodeURIComponent(filter);
return (webex.request({
@@ -1195,7 +1204,6 @@ async function scimQuery(filter: string) {
[CISCO_DEVICE_URL]: webex.internal.device.url,
[SPARK_USER_AGENT]: CALLING_USER_AGENT,
},
- service: ALLOWED_SERVICES.MOBIUS,
}));
}
@@ -1211,7 +1219,7 @@ export async function resolveCallerIdDisplay(filter: string) {
try {
const response = await scimQuery(filter);
- resolution = response.body as scimResponseBody;
+ resolution = response.body as SCIMListResponse;
log.info(`Number of records found for this user :- ${resolution.totalResults}`, {
file: UTILS_FILE,
@@ -1236,12 +1244,12 @@ export async function resolveCallerIdDisplay(filter: string) {
/* Pick only the primary number OR 2nd preference Work */
const numberObj =
- scimResource.phoneNumbers.find((num) => num.primary) ||
- scimResource.phoneNumbers.find((num) => num.type.toLowerCase() === 'work');
+ scimResource.phoneNumbers?.find((num) => num.primary) ||
+ scimResource.phoneNumbers?.find((num) => num.type.toLowerCase() === 'work');
if (numberObj) {
displayResult.num = numberObj.value;
- } else if (scimResource.phoneNumbers.length > 0) {
+ } else if (scimResource.phoneNumbers && scimResource.phoneNumbers.length > 0) {
/* When no primary number exists OR PA-ID/From failed to populate, we take the first number */
log.info('Failure to resolve caller information. Setting number as caller ID', {
file: UTILS_FILE,
diff --git a/packages/calling/src/common/constants.ts b/packages/calling/src/common/constants.ts
index 92d37654651..a71f3de888a 100644
--- a/packages/calling/src/common/constants.ts
+++ b/packages/calling/src/common/constants.ts
@@ -4,6 +4,7 @@ export const BINARY = 'binary';
export const CONTENT = 'content';
export const DEVICES = 'devices';
export const FAILURE_MESSAGE = 'FAILURE';
+export const IDENTITY_ENDPOINT_RESOURCE = 'identity';
export const ITEMS = 'items';
export const KEY = 'key';
export const OBJECT = 'object';
@@ -12,6 +13,8 @@ export const RAW_REQUEST = 'rawRequest';
export const RESPONSE = 'response';
export const RESPONSE_DATA = 'responseData';
export const RESPONSE_MESSAGE = 'responseMessage';
+export const SCIM_ENDPOINT_RESOURCE = 'scim';
+export const SCIM_USER_FILTER = 'v2/Users?filter=';
export const SETTINGS = 'settings';
export const STATUS_CODE = 'statusCode';
export const SUCCESS_MESSAGE = 'SUCCESS';
@@ -39,3 +42,9 @@ export const BW_XSI_URL = 'broadworksXsiActionsUrl';
export const WEBEX_CALLING_CONNECTOR_FILE = 'WxCallBackendConnector';
export const UCM_CONNECTOR_FILE = 'UcmBackendConnector';
export const VOICEMAIL = 'VOICEMAIL';
+export const SCIM_WEBEXIDENTITY_USER = 'urn:scim:schemas:extension:cisco:webexidentity:2.0:User';
+export const SCIM_ENTERPRISE_USER = 'urn:ietf:params:scim:schemas:extension:enterprise:2.0:User';
+export const WEBEX_API_PROD = 'https://webexapis.com';
+export const WEBEX_API_BTS = 'https://integration.webexapis.com';
+export const WEBEX_API_CONFIG_INT_URL = `https://${WEBEX_API_BTS}/v1/uc/config`;
+export const WEBEX_API_CONFIG_PROD_URL = `https://${WEBEX_API_PROD}/v1/uc/config`;
diff --git a/packages/calling/src/common/testUtil.ts b/packages/calling/src/common/testUtil.ts
index 8dd11ab0909..dff60f0e25f 100644
--- a/packages/calling/src/common/testUtil.ts
+++ b/packages/calling/src/common/testUtil.ts
@@ -51,10 +51,6 @@ export function getTestUtilsWebex() {
},
},
},
- dss: {
- lookup: jest.fn(),
- register: jest.fn(),
- },
encryption: {
decryptText: jest.fn(),
encryptText: jest.fn(),
@@ -78,7 +74,6 @@ export function getTestUtilsWebex() {
mercuryApi: 'https://mercury-api-intb.ciscospark.com/v1',
'ucmgmt-gateway': 'https://gw.telemetry.int-ucmgmt.cisco.com',
contactsService: 'https://contacts-service-a.wbx2.com/contact/api/v1',
- directorySearch: 'https://directory-search-a.wbx2.com/direcory-search/api/v1/',
},
fetchClientRegionInfo: jest.fn(),
},
@@ -265,6 +260,32 @@ export const getSampleScimResponse = () => {
};
};
+export const getSampleMinimumScimResponse = () => {
+ return {
+ totalResults: '1',
+ itemsPerPage: '1',
+ startIndex: '1',
+ schemas: ['urn:scim:schemas:core:1.0'],
+ Resources: [
+ {
+ userName: 'atlas.test.wxcwebrtc+user8@gmail.com',
+ id: 'userExternalId',
+ meta: {
+ created: '2022-03-16T16:13:53.847Z',
+ lastModified: '2022-05-31T14:39:12.782Z',
+ lastLoginTime: '2022-05-31T14:39:12.780Z',
+ version: 'W/"66025591113"',
+ location:
+ 'https://identitybts.webex.com/identity/scim/1704d30d-a131-4bc7-9449-948487643793/v1/Users/652fe0c7-05ce-4acd-8bda-9a080830187f',
+ organizationID: '1704d30d-a131-4bc7-9449-948487643793',
+ creator: '97fe25e3-d3e8-400e-856b-5b0cd5b0c790',
+ modifier: '8c7abf2f-0c8e-49cf-b8e4-693d4ec7daee',
+ },
+ },
+ ],
+ };
+};
+
/**
* Returns a sample people list response object.
*/
diff --git a/packages/calling/src/common/types.ts b/packages/calling/src/common/types.ts
index 5a592b6b959..02605e6a1f0 100644
--- a/packages/calling/src/common/types.ts
+++ b/packages/calling/src/common/types.ts
@@ -1,3 +1,5 @@
+import {SCIM_ENTERPRISE_USER, SCIM_WEBEXIDENTITY_USER} from './constants';
+
export type MobiusDeviceId = string;
export type MobiusDeviceUri = string;
export type SettingEnabled = boolean;
@@ -30,14 +32,13 @@ export enum CALLING_BACKEND {
export type DeviceList = unknown;
export type CallId = string; // guid;
export type CorrelationId = string;
-export type SipAddress = string;
export enum CallType {
URI = 'uri',
TEL = 'tel',
}
export type CallDetails = {
type: CallType;
- address: SipAddress; // sip address
+ address: string; // sip address
};
export type CallDestination = CallDetails;
@@ -181,32 +182,11 @@ export type ContactDetail = {
value: string;
};
-export interface LookupOptions {
- id: string;
- shouldBatch: boolean;
-}
-
-export type DSSLookupResponse = {
- additionalInfo: {
- department: string;
- firstName: string;
- identityManager: {
- managerId: string;
- displayName: string;
- };
- jobTitle: string;
- lastName: string;
- };
- displayName: string;
- emails: ContactDetail[];
- entityProviderType: string;
- identity: string;
- orgId: string;
- phoneNumbers: ContactDetail[];
- photos: ContactDetail[];
- sipAddresses: ContactDetail[];
+export interface URIAddress {
+ value: string;
type: string;
-};
+ primary?: boolean;
+}
export type KmsKey = {
uri: string;
@@ -222,3 +202,59 @@ export type KmsResourceObject = {
keyUris: string[];
authorizationUris: string[];
};
+
+export interface Name {
+ familyName: string;
+ givenName: string;
+}
+
+export interface Address {
+ city?: string;
+ country?: string;
+ state?: string;
+ street?: string;
+ zipCode?: string;
+}
+
+interface WebexIdentityMeta {
+ organizationId: string;
+}
+interface WebexIdentityUser {
+ sipAddresses?: URIAddress[];
+ meta?: WebexIdentityMeta;
+}
+
+interface Manager {
+ value: string;
+ displayName: string;
+ $ref: string;
+}
+
+interface EnterpriseUser {
+ department?: string;
+ manager?: Manager;
+}
+
+interface Resource {
+ schemas: string[];
+ id: string;
+ userName: string;
+ active?: boolean;
+ name?: Name;
+ displayName?: string;
+ emails?: URIAddress[];
+ userType: string;
+ phoneNumbers?: PhoneNumber[];
+ photos?: ContactDetail[];
+ addresses?: Address[];
+ [SCIM_WEBEXIDENTITY_USER]?: WebexIdentityUser;
+ [SCIM_ENTERPRISE_USER]?: EnterpriseUser;
+}
+
+export interface SCIMListResponse {
+ schemas: string[];
+ totalResults: number;
+ itemsPerPage: number;
+ startIndex: number;
+ Resources: Resource[];
+}
diff --git a/packages/webex/src/calling.js b/packages/webex/src/calling.js
index c7af8455cfd..fb91d3b466d 100644
--- a/packages/webex/src/calling.js
+++ b/packages/webex/src/calling.js
@@ -5,7 +5,6 @@ import EventEmitter from 'events';
require('@webex/internal-plugin-device');
require('@webex/internal-plugin-mercury');
require('@webex/internal-plugin-encryption');
-require('@webex/internal-plugin-dss');
const merge = require('lodash/merge');
const WebexCore = require('@webex/webex-core').default;
diff --git a/yarn.lock b/yarn.lock
index 2527d5aca6e..d4a1914ac96 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -2035,21 +2035,21 @@ __metadata:
linkType: hard
"@babel/runtime-corejs2@npm:^7.25.0":
- version: 7.25.4
- resolution: "@babel/runtime-corejs2@npm:7.25.4"
+ version: 7.25.6
+ resolution: "@babel/runtime-corejs2@npm:7.25.6"
dependencies:
core-js: ^2.6.12
regenerator-runtime: ^0.14.0
- checksum: 5c543575e3eef559a81657e696ee7c2b71da159905b59eaf1b9b9a2efbd8c5259edb4f79d5a86c97326fa47a7dc447f60133e00ca4977ac5244fefdf34fc5921
+ checksum: afd5406391d7e41ac291ae40c549dc8da734ec8d160b560081b4153b4063409b7402e5c469bcebd0789aaeaca740d3fe088cbe45be3a4c7341e3a2b3f48fdd98
languageName: node
linkType: hard
-"@babel/runtime@npm:^7.18.9, @babel/runtime@npm:^7.25.0":
- version: 7.25.4
- resolution: "@babel/runtime@npm:7.25.4"
+"@babel/runtime@npm:^7.18.9, @babel/runtime@npm:^7.25.6":
+ version: 7.25.6
+ resolution: "@babel/runtime@npm:7.25.6"
dependencies:
regenerator-runtime: ^0.14.0
- checksum: 5c2aab03788e77f1f959d7e6ce714c299adfc9b14fb6295c2a17eb7cad0dd9c2ebfb2d25265f507f68c43d5055c5cd6f71df02feb6502cea44b68432d78bcbbe
+ checksum: ee1a69d3ac7802803f5ee6a96e652b78b8addc28c6a38c725a4ad7d61a059d9e6cb9f6550ed2f63cce67a1bd82e0b1ef66a1079d895be6bfb536a5cfbd9ccc32
languageName: node
linkType: hard
@@ -5931,11 +5931,11 @@ __metadata:
linkType: hard
"@types/node@npm:^20.14.1":
- version: 20.16.1
- resolution: "@types/node@npm:20.16.1"
+ version: 20.16.5
+ resolution: "@types/node@npm:20.16.5"
dependencies:
undici-types: ~6.19.2
- checksum: 2b8f30f416f5c1851ffa8a13ef6c464a5e355edfd763713c22813a7839f6419a64e27925f9e89c972513d78432263179332f0bffb273d16498233bfdf495d096
+ checksum: f38b7bd8c4993dcf38943afa2ffdd7dfd18fc94f8f3f28d0c1045a10d39871a6cc1b8f8d3bf0c7ed848457d0e1d283482f6ca125579c13fed1b7575d23e8e8f5
languageName: node
linkType: hard
@@ -7493,7 +7493,7 @@ __metadata:
"@typescript-eslint/eslint-plugin": 5.38.1
"@typescript-eslint/parser": 5.38.1
"@web/dev-server": 0.4.5
- "@webex/internal-media-core": 2.9.1
+ "@webex/internal-media-core": 2.10.2
"@webex/media-helpers": "workspace:*"
async-mutex: 0.4.0
buffer: 6.0.3
@@ -7784,9 +7784,9 @@ __metadata:
languageName: unknown
linkType: soft
-"@webex/internal-media-core@npm:2.9.1":
- version: 2.9.1
- resolution: "@webex/internal-media-core@npm:2.9.1"
+"@webex/internal-media-core@npm:2.10.2":
+ version: 2.10.2
+ resolution: "@webex/internal-media-core@npm:2.10.2"
dependencies:
"@babel/runtime": ^7.18.9
"@babel/runtime-corejs2": ^7.25.0
@@ -7798,7 +7798,7 @@ __metadata:
uuid: ^8.3.2
webrtc-adapter: ^8.1.2
xstate: ^4.30.6
- checksum: 358a8b43ae55d6272fa07ca6e075cceb07a29d464c7e494ec130cd918848595eecd8e52ff12407790051af92b5e7d9612fdb4d7c6cf7a11231ce3bb390bd10f3
+ checksum: 76fe4feba27b6734fde24dd0b84f0a43d24b6faf9110ced0ab0c8122746ffa27b50cd5862dde1192750432f2daff618c6ca0cb4ca9496e1b9a2ba47788a58174
languageName: node
linkType: hard
@@ -8557,7 +8557,7 @@ __metadata:
"@babel/preset-typescript": 7.22.11
"@webex/babel-config-legacy": "workspace:*"
"@webex/eslint-config-legacy": "workspace:*"
- "@webex/internal-media-core": 2.9.1
+ "@webex/internal-media-core": 2.10.2
"@webex/jest-config-legacy": "workspace:*"
"@webex/legacy-tools": "workspace:*"
"@webex/test-helper-chai": "workspace:*"
@@ -8793,7 +8793,7 @@ __metadata:
"@webex/babel-config-legacy": "workspace:*"
"@webex/common": "workspace:*"
"@webex/eslint-config-legacy": "workspace:*"
- "@webex/internal-media-core": 2.9.1
+ "@webex/internal-media-core": 2.10.2
"@webex/internal-plugin-conversation": "workspace:*"
"@webex/internal-plugin-device": "workspace:*"
"@webex/internal-plugin-llm": "workspace:*"
@@ -9086,12 +9086,12 @@ __metadata:
linkType: soft
"@webex/rtcstats@npm:^1.3.2":
- version: 1.3.3
- resolution: "@webex/rtcstats@npm:1.3.3"
+ version: 1.4.0
+ resolution: "@webex/rtcstats@npm:1.4.0"
dependencies:
"@types/node": ^20.14.1
uuid: ^8.3.2
- checksum: 7ac73b2f6bf8bf44bfaff7a5e904b175509632b80bf4cbdb09c5570c940d7e02445ba0c95f713067b03770eee49162d964b98a8d1e05f42e3f80b5bfd503352c
+ checksum: a83455d93f66b39e4c2f694d7665fca5d7da9eab33431d9c09262f438971085fecb664f7f8bb6775fec63b62af01612f3eb6b48125619e3a532a8b764019520b
languageName: node
linkType: hard
@@ -17050,13 +17050,13 @@ __metadata:
languageName: node
linkType: hard
-"fast-unique-numbers@npm:^9.0.8":
- version: 9.0.8
- resolution: "fast-unique-numbers@npm:9.0.8"
+"fast-unique-numbers@npm:^9.0.9":
+ version: 9.0.9
+ resolution: "fast-unique-numbers@npm:9.0.9"
dependencies:
- "@babel/runtime": ^7.25.0
- tslib: ^2.6.3
- checksum: 27840ed4ada274f6391cc81a977ab068ef0931a835711415794010ff8775f1ba2ffc4ae890b00b28fdee2e54a929efec6cf9e154ae958f55d16d55cc4023f6cc
+ "@babel/runtime": ^7.25.6
+ tslib: ^2.7.0
+ checksum: 58481531260a91d57859a631378609368a5c3828a36da2e833f1b82f205eec7fe698df76151af84a4bbb1bf911ed9659b79e646d418462d7981e0e24e48f6394
languageName: node
linkType: hard
@@ -31225,7 +31225,7 @@ __metadata:
languageName: node
linkType: hard
-"tslib@npm:^2.6.3":
+"tslib@npm:^2.7.0":
version: 2.7.0
resolution: "tslib@npm:2.7.0"
checksum: 1606d5c89f88d466889def78653f3aab0f88692e80bb2066d090ca6112ae250ec1cfa9dbfaab0d17b60da15a4186e8ec4d893801c67896b277c17374e36e1d28
@@ -33388,37 +33388,37 @@ __metadata:
languageName: node
linkType: hard
-"worker-timers-broker@npm:^7.1.1":
- version: 7.1.1
- resolution: "worker-timers-broker@npm:7.1.1"
+"worker-timers-broker@npm:^7.1.2":
+ version: 7.1.2
+ resolution: "worker-timers-broker@npm:7.1.2"
dependencies:
- "@babel/runtime": ^7.25.0
- fast-unique-numbers: ^9.0.8
- tslib: ^2.6.3
- worker-timers-worker: ^8.0.3
- checksum: baa4a7bf49efb8ade9be7cb7865ac27e8030b278d7698ce70a09972ab92a5a7a3e6aaf5dbb3ebecc2d9f94562f10643dada43159382b40a2b9d257f82f473071
+ "@babel/runtime": ^7.25.6
+ fast-unique-numbers: ^9.0.9
+ tslib: ^2.7.0
+ worker-timers-worker: ^8.0.4
+ checksum: ec2deb097662ef2331cdf4681023fe970504cd30b58cbf13ceab0741d05fd21a49e518c73f03b20a01e2684d9b8d6d59787aed82d05e615be99c8dc0229623c4
languageName: node
linkType: hard
-"worker-timers-worker@npm:^8.0.3":
- version: 8.0.3
- resolution: "worker-timers-worker@npm:8.0.3"
+"worker-timers-worker@npm:^8.0.4":
+ version: 8.0.4
+ resolution: "worker-timers-worker@npm:8.0.4"
dependencies:
- "@babel/runtime": ^7.25.0
- tslib: ^2.6.3
- checksum: af06dc1df2eb5b45ed5d0b5d56de621411f9354633eb9d4368effa00c856301d02c83c632c77645f2753167f0168136d17c2b8cbf986b041ba5b08cf8c10ad83
+ "@babel/runtime": ^7.25.6
+ tslib: ^2.7.0
+ checksum: fd59d4c947895efd036e46cd8d4c288b228256f7bac24ff5b83c682ef44e53584ce8bc4f0525eee469be00dbf1f89e9266682d0297df7478704996087a7553b2
languageName: node
linkType: hard
"worker-timers@npm:^8.0.2":
- version: 8.0.4
- resolution: "worker-timers@npm:8.0.4"
+ version: 8.0.5
+ resolution: "worker-timers@npm:8.0.5"
dependencies:
- "@babel/runtime": ^7.25.0
- tslib: ^2.6.3
- worker-timers-broker: ^7.1.1
- worker-timers-worker: ^8.0.3
- checksum: 51bcc64f01ea143ac644b88d10b211afd0dd05991ae2a747066f25456f9e84eaa48e530c01d71998a1bc2a00303d8ea40ea97f070bea5e7ec84890140f7f1cb6
+ "@babel/runtime": ^7.25.6
+ tslib: ^2.7.0
+ worker-timers-broker: ^7.1.2
+ worker-timers-worker: ^8.0.4
+ checksum: e8c00e33e5af252a37472c0fc1bc3c3b26cdd174e179ab1b301364b880db7e560f6f3eb82a4438970fe113c1e9a4855569df0ef61edc6d5de613a51789587ef4
languageName: node
linkType: hard