- Following options allow to set the type of registration, service domain (e.g. cisco.webex.com), server region (e.g. east) and the country (e.g. us).
+ Following options allow to set the type of registration, service domain (only needed for contactcenter - rtw.prod-us1.rtmsprod.net ), server region (e.g. US-EAST) and the country (e.g. US).
- Note: Please update these before Initialize Calling if want to use different values.
+ Note: Please set these fields before Initialize Calling to customize the registration behavior.
diff --git a/packages/@webex/internal-plugin-device/src/device.js b/packages/@webex/internal-plugin-device/src/device.js
index 585827006e8..14eb5635acb 100644
--- a/packages/@webex/internal-plugin-device/src/device.js
+++ b/packages/@webex/internal-plugin-device/src/device.js
@@ -2,6 +2,7 @@
import {deprecated, oneFlight} from '@webex/common';
import {persist, waitForValue, WebexPlugin} from '@webex/webex-core';
import {safeSetTimeout} from '@webex/common-timers';
+import {orderBy} from 'lodash';
import METRICS from './metrics';
import {FEATURE_COLLECTION_NAMES, DEVICE_EVENT_REGISTRATION_SUCCESS} from './constants';
@@ -439,6 +440,74 @@ const Device = WebexPlugin.extend({
});
});
},
+ /**
+ * Fetches the web devices and deletes the third of them which are not recent devices in use
+ * @returns {Promise}
+ */
+ deleteDevices() {
+ // Fetch devices with a GET request
+ return this.request({
+ method: 'GET',
+ service: 'wdm',
+ resource: 'devices',
+ })
+ .then((response) => {
+ const {devices} = response.body;
+
+ const {deviceType} = this._getBody();
+
+ // Filter devices of type deviceType
+ const webDevices = devices.filter((item) => item.deviceType === deviceType);
+
+ const sortedDevices = orderBy(webDevices, [(item) => new Date(item.modificationTime)]);
+
+ // If there are more than two devices, delete the last third
+ if (sortedDevices.length > 2) {
+ const totalItems = sortedDevices.length;
+ const countToDelete = Math.ceil(totalItems / 3);
+ const urlsToDelete = sortedDevices.slice(0, countToDelete).map((item) => item.url);
+
+ return Promise.race(
+ urlsToDelete.map((url) => {
+ return this.request({
+ uri: url,
+ method: 'DELETE',
+ });
+ })
+ );
+ }
+
+ return Promise.resolve();
+ })
+ .catch((error) => {
+ this.logger.error('Failed to retrieve devices:', error);
+
+ return Promise.reject(error);
+ });
+ },
+
+ /**
+ * Registers and when fails deletes devices
+ */
+ @oneFlight
+ @waitForValue('@')
+ register(deviceRegistrationOptions = {}) {
+ return this._registerInternal(deviceRegistrationOptions).catch((error) => {
+ if (error?.body?.message === 'User has excessive device registrations') {
+ return this.deleteDevices().then(() => {
+ return this._registerInternal(deviceRegistrationOptions);
+ });
+ }
+ throw error;
+ });
+ },
+
+ _getBody() {
+ return {
+ ...(this.config.defaults.body ? this.config.defaults.body : {}),
+ ...(this.config.body ? this.config.body : {}),
+ };
+ },
/**
* Register or refresh a device depending on the current device state. Device
@@ -451,7 +520,7 @@ const Device = WebexPlugin.extend({
*/
@oneFlight
@waitForValue('@')
- register(deviceRegistrationOptions = {}) {
+ _registerInternal(deviceRegistrationOptions = {}) {
this.logger.info('device: registering');
this.webex.internal.newMetrics.callDiagnosticMetrics.setDeviceInfo(this);
@@ -466,10 +535,7 @@ const Device = WebexPlugin.extend({
}
// Merge body configurations, overriding defaults.
- const body = {
- ...(this.config.defaults.body ? this.config.defaults.body : {}),
- ...(this.config.body ? this.config.body : {}),
- };
+ const body = this._getBody();
// Merge header configurations, overriding defaults.
const headers = {
@@ -527,7 +593,6 @@ const Device = WebexPlugin.extend({
});
});
},
-
/**
* Unregister the current registered device if available. Unregistering a
* device utilizes the services plugin to send the request to the **WDM**
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 754a48706f1..b2da5312b1a 100644
--- a/packages/@webex/internal-plugin-device/test/unit/spec/device.js
+++ b/packages/@webex/internal-plugin-device/test/unit/spec/device.js
@@ -357,6 +357,68 @@ describe('plugin-device', () => {
});
});
+ describe('deleteDevices()', () => {
+ const setup = (deviceType) => {
+ device.config.defaults = {body: {deviceType}};
+ };
+ ['WEB', 'WEBCLIENT'].forEach(deviceType => {
+ it(`should delete correct number of devices for ${deviceType}`, async () => {
+ setup(deviceType);
+ const response = {
+ body: {
+ devices: [
+ {url: 'url3', modificationTime: '2023-10-03T10:00:00Z', deviceType},
+ {url: 'url4', modificationTime: '2023-10-04T10:00:00Z', deviceType: 'notweb'},
+ {url: 'url1', modificationTime: '2023-10-01T10:00:00Z', deviceType},
+ {url: 'url2', modificationTime: '2023-10-02T10:00:00Z', deviceType},
+ {url: 'url5', modificationTime: '2023-10-00T10:00:00Z', deviceType},
+ {url: 'url6', modificationTime: '2023-09-50T10:00:00Z', deviceType},
+ {url: 'url7', modificationTime: '2023-09-30T10:00:00Z', deviceType},
+ {url: 'url8', modificationTime: '2023-08-30T10:00:00Z', deviceType},
+ ]
+ }
+ };
+ const requestStub = sinon.stub(device, 'request');
+ requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response);
+ requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
+
+ await device.deleteDevices();
+
+ const expectedDeletions = ['url8', 'url7', 'url1'];
+
+ expectedDeletions.forEach(url => {
+ assert(requestStub.calledWith(sinon.match({uri: url, method: 'DELETE'})));
+ });
+
+ const notDeletedUrls = ['url2', 'url3', 'url5', 'url6', 'url4'];
+ notDeletedUrls.forEach(url => {
+ assert(requestStub.neverCalledWith(sinon.match({uri: url, method: 'DELETE'})));
+ });
+ });});
+
+ it('does not delete when there are just 2 devices', async () => {
+ setup('WEB');
+ const response = {
+ body: {
+ devices: [
+ {url: 'url1', modificationTime: '2023-10-01T10:00:00Z', deviceType: 'WEB'},
+ {url: 'url2', modificationTime: '2023-10-02T10:00:00Z', deviceType: 'WEB'},
+ ]
+ }
+ };
+
+ const requestStub = sinon.stub(device, 'request');
+ requestStub.withArgs(sinon.match({method: 'GET'})).resolves(response);
+ requestStub.withArgs(sinon.match({method: 'DELETE'})).resolves();
+
+ await device.deleteDevices();
+ const notDeletedUrls = ['url1', 'url2'];
+ notDeletedUrls.forEach(url => {
+ assert(requestStub.neverCalledWith(sinon.match({uri: url, method: 'DELETE'})));
+ });
+ });
+ });
+
describe('#register()', () => {
const setup = (config = {}) => {
webex.internal.metrics.submitClientMetrics = sinon.stub();
@@ -386,6 +448,40 @@ describe('plugin-device', () => {
});
});
+ it('calls delete devices when errors with User has excessive device registrations', async () => {
+ setup();
+ const deleteDeviceSpy = sinon.stub(device, 'deleteDevices').callsFake(() => Promise.resolve());
+ const registerStub = sinon.stub(device, '_registerInternal');
+
+ registerStub.onFirstCall().rejects({body: {message: 'User has excessive device registrations'}});
+ registerStub.onSecondCall().callsFake(() => Promise.resolve({exampleKey: 'example response value',}));
+
+ const result = await device.register();
+
+ assert.calledOnce(deleteDeviceSpy);
+
+ assert.equal(registerStub.callCount, 2);
+
+ assert.deepEqual(result, {exampleKey: 'example response value'});
+ });
+
+ it('does not call delete devices when some other error', async () => {
+ setup();
+
+ const deleteDeviceSpy = sinon.stub(device, 'deleteDevices').callsFake(() => Promise.resolve());
+ const registerStub = sinon.stub(device, '_registerInternal').rejects(new Error('some error'));
+
+ try {
+ await device.register({deleteFlag: true});
+ } catch (error) {
+ assert.notCalled(deleteDeviceSpy);
+
+ assert.equal(registerStub.callCount, 1);
+
+ assert.match(error.message, /some error/, 'Expected error message not matched');
+ }
+ });
+
it('checks that submitInternalEvent gets called with internal.register.device.response on error', async () => {
setup();
sinon.stub(device, 'canRegister').callsFake(() => Promise.resolve());
diff --git a/packages/@webex/internal-plugin-support/src/config.js b/packages/@webex/internal-plugin-support/src/config.js
index c85a0aa5cca..8fcd1226b2a 100644
--- a/packages/@webex/internal-plugin-support/src/config.js
+++ b/packages/@webex/internal-plugin-support/src/config.js
@@ -17,5 +17,6 @@ export default {
appType: '',
appVersion: '',
languageCode: '',
+ incrementalLogs: false,
},
};
diff --git a/packages/@webex/internal-plugin-support/src/support.js b/packages/@webex/internal-plugin-support/src/support.js
index f41d380637e..b5cb0824f19 100644
--- a/packages/@webex/internal-plugin-support/src/support.js
+++ b/packages/@webex/internal-plugin-support/src/support.js
@@ -107,6 +107,10 @@ const Support = WebexPlugin.extend({
return this.webex.upload(options);
})
.then((body) => {
+ if (this.config.incrementalLogs) {
+ this.webex.logger.clearBuffers();
+ }
+
if (userId && !body.userId) {
body.userId = userId;
}
diff --git a/packages/@webex/media-helpers/package.json b/packages/@webex/media-helpers/package.json
index 8a91c6266bf..2f74b136b3a 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.11.3",
+ "@webex/internal-media-core": "2.12.2",
"@webex/ts-events": "^1.1.0",
"@webex/web-media-effects": "2.19.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 cc65a772edd..91be8a7ce34 100644
--- a/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js
+++ b/packages/@webex/plugin-authorization-browser-first-party/src/authorization.js
@@ -22,6 +22,16 @@ const lodash = require('lodash');
const OAUTH2_CSRF_TOKEN = 'oauth2-csrf-token';
const OAUTH2_CODE_VERIFIER = 'oauth2-code-verifier';
+/**
+ * Authorization plugin events
+ */
+export const Events = {
+ /**
+ * QR code login events
+ */
+ qRCodeLogin: 'qRCodeLogin',
+};
+
/**
* Browser support for OAuth2. Automatically parses the URL query for an
* authorization code
@@ -67,17 +77,49 @@ const Authorization = WebexPlugin.extend({
namespace: 'Credentials',
+ /**
+ * EventEmitter for authorization events
+ * @instance
+ * @memberof AuthorizationBrowserFirstParty
+ * @type {EventEmitter}
+ * @public
+ */
+ eventEmitter: new EventEmitter(),
/**
- * Stores the interval ID for QR code polling
+ * Stores the timer ID for QR code polling
* @instance
* @memberof AuthorizationBrowserFirstParty
* @type {?number}
* @private
*/
- pollingRequest: null,
+ pollingTimer: null,
+ /**
+ * Stores the expiration timer ID for QR code polling
+ * @instance
+ * @memberof AuthorizationBrowserFirstParty
+ * @type {?number}
+ * @private
+ */
+ pollingExpirationTimer: null,
- eventEmitter: new EventEmitter(),
+ /**
+ * Monotonically increasing id to identify the current polling request
+ * @instance
+ * @memberof AuthorizationBrowserFirstParty
+ * @type {number}
+ * @private
+ */
+ pollingId: 0,
+
+ /**
+ * Identifier for the current polling request
+ * @instance
+ * @memberof AuthorizationBrowserFirstParty
+ * @type {?number}
+ * @private
+ */
+ currentPollingId: null,
/**
* Initializer
@@ -253,6 +295,30 @@ const Authorization = WebexPlugin.extend({
});
},
+ /**
+ * Generate a QR code URL to launch the Webex app when scanning with the camera
+ * @instance
+ * @memberof AuthorizationBrowserFirstParty
+ * @param {String} verificationUrl
+ * @returns {String}
+ */
+ _generateQRCodeVerificationUrl(verificationUrl) {
+ const baseUrl = 'https://web.webex.com/deviceAuth';
+ const urlParams = new URLSearchParams(new URL(verificationUrl).search);
+ const userCode = urlParams.get('userCode');
+
+ if (userCode) {
+ const {services} = this.webex.internal;
+ const oauthHelperUrl = services.get('oauth-helper');
+ const newVerificationUrl = new URL(baseUrl);
+ newVerificationUrl.searchParams.set('usercode', userCode);
+ newVerificationUrl.searchParams.set('oauthhelper', oauthHelperUrl);
+ return newVerificationUrl.toString();
+ } else {
+ return verificationUrl;
+ }
+ },
+
/**
* Get an OAuth Login URL for QRCode. Generate QR code based on the returned URL.
* @instance
@@ -260,8 +326,8 @@ const Authorization = WebexPlugin.extend({
* @emits #qRCodeLogin
*/
initQRCodeLogin() {
- if (this.pollingRequest) {
- this.eventEmitter.emit('qRCodeLogin', {
+ if (this.pollingTimer) {
+ this.eventEmitter.emit(Events.qRCodeLogin, {
eventType: 'getUserCodeFailure',
data: {message: 'There is already a polling request'},
});
@@ -285,19 +351,20 @@ const Authorization = WebexPlugin.extend({
})
.then((res) => {
const {user_code, verification_uri, verification_uri_complete} = res.body;
- this.eventEmitter.emit('qRCodeLogin', {
+ const verificationUriComplete = this._generateQRCodeVerificationUrl(verification_uri_complete);
+ this.eventEmitter.emit(Events.qRCodeLogin, {
eventType: 'getUserCodeSuccess',
userData: {
userCode: user_code,
verificationUri: verification_uri,
- verificationUriComplete: verification_uri_complete,
- }
+ verificationUriComplete,
+ },
});
// if device authorization success, then start to poll server to check whether the user has completed authorization
this._startQRCodePolling(res.body);
})
.catch((res) => {
- this.eventEmitter.emit('qRCodeLogin', {
+ this.eventEmitter.emit(Events.qRCodeLogin, {
eventType: 'getUserCodeFailure',
data: res.body,
});
@@ -313,30 +380,36 @@ const Authorization = WebexPlugin.extend({
*/
_startQRCodePolling(options = {}) {
if (!options.device_code) {
- this.eventEmitter.emit('qRCodeLogin', {
+ this.eventEmitter.emit(Events.qRCodeLogin, {
eventType: 'authorizationFailure',
data: {message: 'A deviceCode is required'},
});
return;
}
- if (this.pollingRequest) {
- this.eventEmitter.emit('qRCodeLogin', {
+ if (this.pollingTimer) {
+ this.eventEmitter.emit(Events.qRCodeLogin, {
eventType: 'authorizationFailure',
data: {message: 'There is already a polling request'},
});
return;
}
- const {device_code: deviceCode, interval = 2, expires_in: expiresIn = 300} = options;
+ const {device_code: deviceCode, expires_in: expiresIn = 300} = options;
+ let interval = options.interval ?? 2;
- let attempts = 0;
- const maxAttempts = expiresIn / interval;
+ this.pollingExpirationTimer = setTimeout(() => {
+ this.cancelQRCodePolling(false);
+ this.eventEmitter.emit(Events.qRCodeLogin, {
+ eventType: 'authorizationFailure',
+ data: {message: 'Authorization timed out'},
+ });
+ }, expiresIn * 1000);
- this.pollingRequest = setInterval(() => {
- attempts += 1;
+ const polling = () => {
+ this.pollingId += 1;
+ this.currentPollingId = this.pollingId;
- const currentAttempts = attempts;
this.webex
.request({
method: 'POST',
@@ -354,43 +427,50 @@ const Authorization = WebexPlugin.extend({
},
})
.then((res) => {
- if (this.pollingRequest === null) return;
+ // if the pollingId has changed, it means that the polling request has been canceled
+ if (this.currentPollingId !== this.pollingId) return;
- this.eventEmitter.emit('qRCodeLogin', {
+ this.eventEmitter.emit(Events.qRCodeLogin, {
eventType: 'authorizationSuccess',
data: res.body,
});
this.cancelQRCodePolling();
})
.catch((res) => {
- if (this.pollingRequest === null) return;
+ // if the pollingId has changed, it means that the polling request has been canceled
+ if (this.currentPollingId !== this.pollingId) return;
- if (currentAttempts >= maxAttempts) {
- this.eventEmitter.emit('qRCodeLogin', {
- eventType: 'authorizationFailure',
- data: {message: 'Authorization timed out'}
- });
- this.cancelQRCodePolling();
+ // When server sends 400 status code with message 'slow_down', it means that last request happened too soon.
+ // So, skip one interval and then poll again.
+ if (res.statusCode === 400 && res.body.message === 'slow_down') {
+ schedulePolling(interval * 2);
return;
}
+
// if the statusCode is 428 which means that the authorization request is still pending
// as the end user hasn't yet completed the user-interaction steps. So keep polling.
if (res.statusCode === 428) {
- this.eventEmitter.emit('qRCodeLogin', {
+ this.eventEmitter.emit(Events.qRCodeLogin, {
eventType: 'authorizationPending',
- data: res.body
+ data: res.body,
});
+ schedulePolling(interval);
return;
}
this.cancelQRCodePolling();
- this.eventEmitter.emit('qRCodeLogin', {
+ this.eventEmitter.emit(Events.qRCodeLogin, {
eventType: 'authorizationFailure',
- data: res.body
+ data: res.body,
});
});
- }, interval * 1000);
+ };
+
+ const schedulePolling = (interval) =>
+ (this.pollingTimer = setTimeout(polling, interval * 1000));
+
+ schedulePolling(interval);
},
/**
@@ -399,14 +479,19 @@ const Authorization = WebexPlugin.extend({
* @memberof AuthorizationBrowserFirstParty
* @returns {void}
*/
- cancelQRCodePolling() {
- if (this.pollingRequest) {
- clearInterval(this.pollingRequest);
- this.eventEmitter.emit('qRCodeLogin', {
+ cancelQRCodePolling(withCancelEvent = true) {
+ if (this.pollingTimer && withCancelEvent) {
+ this.eventEmitter.emit(Events.qRCodeLogin, {
eventType: 'pollingCanceled',
});
- this.pollingRequest = null;
}
+
+ this.currentPollingId = null;
+
+ clearTimeout(this.pollingExpirationTimer);
+ this.pollingExpirationTimer = null;
+ clearTimeout(this.pollingTimer);
+ this.pollingTimer = null;
},
/**
diff --git a/packages/@webex/plugin-authorization-browser-first-party/src/index.js b/packages/@webex/plugin-authorization-browser-first-party/src/index.js
index 02ca4c2bbe8..4870be6d0ed 100644
--- a/packages/@webex/plugin-authorization-browser-first-party/src/index.js
+++ b/packages/@webex/plugin-authorization-browser-first-party/src/index.js
@@ -14,5 +14,5 @@ registerPlugin('authorization', Authorization, {
proxies,
});
-export {default} from './authorization';
+export {default, Events} from './authorization';
export {default as config} from './config';
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 5bf294485fb..b08af627998 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
@@ -18,7 +18,6 @@ import Authorization from '@webex/plugin-authorization-browser-first-party';
// Necessary to require lodash this way in order to stub the method
const lodash = require('lodash');
-
describe('plugin-authorization-browser-first-party', () => {
describe('Authorization', () => {
function makeWebex(
@@ -187,14 +186,16 @@ describe('plugin-authorization-browser-first-party', () => {
const webex = makeWebex(
`http://example.com/?code=${code}&state=${base64.encode(
JSON.stringify({emailhash: 'someemailhash'})
- )}`,
+ )}`
);
const requestAuthorizationCodeGrantStub = sinon.stub(
Authorization.prototype,
'requestAuthorizationCodeGrant'
);
- const collectPreauthCatalogStub = sinon.stub(Services.prototype, 'collectPreauthCatalog').resolves();
+ const collectPreauthCatalogStub = sinon
+ .stub(Services.prototype, 'collectPreauthCatalog')
+ .resolves();
await webex.authorization.when('change:ready');
@@ -206,9 +207,7 @@ describe('plugin-authorization-browser-first-party', () => {
it('collects the preauth catalog no emailhash is present in the state', async () => {
const code = 'authcode_clusterid_theOrgId';
- const webex = makeWebex(
- `http://example.com/?code=${code}`
- );
+ const webex = makeWebex(`http://example.com/?code=${code}`);
const requestAuthorizationCodeGrantStub = sinon.stub(
Authorization.prototype,
@@ -271,12 +270,13 @@ describe('plugin-authorization-browser-first-party', () => {
it('throws a grant error', () => {
let err = null;
try {
- makeWebex('http://127.0.0.1:8000/?error=invalid_scope&error_description=The%20requested%20scope%20is%20invalid.');
- }
- catch (e) {
+ makeWebex(
+ 'http://127.0.0.1:8000/?error=invalid_scope&error_description=The%20requested%20scope%20is%20invalid.'
+ );
+ } catch (e) {
err = e;
}
- expect(err?.message).toBe('Cannot convert object to primitive value')
+ expect(err?.message).toBe('Cannot convert object to primitive value');
});
});
@@ -443,17 +443,49 @@ describe('plugin-authorization-browser-first-party', () => {
});
});
+ describe('#_generateQRCodeVerificationUrl()', () => {
+ it('should generate a QR code URL when a userCode is present', () => {
+ const verificationUrl = 'https://example.com/verify?userCode=123456';
+ const oauthHelperUrl = 'https://oauth-helper-a.wbx2.com/helperservice/v1';
+ const expectedUrl = 'https://web.webex.com/deviceAuth?usercode=123456&oauthhelper=https%3A%2F%2Foauth-helper-a.wbx2.com%2Fhelperservice%2Fv1';
+
+ const webex = makeWebex('http://example.com');
+
+ const oauthHelperSpy = sinon.stub(webex.internal.services, 'get').returns(oauthHelperUrl);
+ const result = webex.authorization._generateQRCodeVerificationUrl(verificationUrl);
+
+ assert.calledOnce(oauthHelperSpy);
+ assert.calledWithExactly(oauthHelperSpy, 'oauth-helper');
+ assert.equal(result, expectedUrl);
+
+ oauthHelperSpy.restore();
+ });
+
+ it('should return the original verificationUrl when userCode is not present', () => {
+ const verificationUrl = 'https://example.com/verify';
+ const webex = makeWebex('http://example.com');
+
+ const oauthHelperSpy = sinon.stub(webex.internal.services, 'get');
+ const result = webex.authorization._generateQRCodeVerificationUrl(verificationUrl);
+
+ assert.notCalled(oauthHelperSpy);
+ assert.equal(result, verificationUrl);
+
+ oauthHelperSpy.restore();
+ });
+ });
+
describe('#initQRCodeLogin()', () => {
it('should prevent concurrent request if there is already a polling request', async () => {
const webex = makeWebex('http://example.com');
-
- webex.authorization.pollingRequest = 1;
+
+ webex.authorization.pollingTimer = 1;
const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
webex.authorization.initQRCodeLogin();
-
+
assert.calledOnce(emitSpy);
- assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeFailure');
- webex.authorization.pollingRequest = null;
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeFailure');
+ webex.authorization.pollingTimer = null;
});
it('should send correct request parameters to the API', async () => {
@@ -461,31 +493,33 @@ describe('plugin-authorization-browser-first-party', () => {
const testClientId = 'test-client-id';
const testScope = 'test-scope';
const sampleData = {
- device_code: "test123",
+ device_code: 'test123',
expires_in: 300,
- user_code: "421175",
- verification_uri: "http://example.com",
- verification_uri_complete: "http://example.com",
- interval: 2
+ user_code: '421175',
+ verification_uri: 'http://example.com',
+ verification_uri_complete: 'http://example.com',
+ interval: 2,
};
const webex = makeWebex('http://example.com', undefined, undefined, {
credentials: {
client_id: testClientId,
scope: testScope,
- }
+ },
});
webex.request.onFirstCall().resolves({statusCode: 200, body: sampleData});
sinon.spy(webex.authorization, '_startQRCodePolling');
+ sinon.spy(webex.authorization, '_generateQRCodeVerificationUrl');
const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
webex.authorization.initQRCodeLogin();
clock.tick(2000);
- await clock.runAllAsync()
+ await clock.runAllAsync();
assert.calledTwice(webex.request);
assert.calledOnce(webex.authorization._startQRCodePolling);
- assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeSuccess');
+ assert.calledOnce(webex.authorization._generateQRCodeVerificationUrl);
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeSuccess');
const request = webex.request.getCall(0);
@@ -498,18 +532,18 @@ describe('plugin-authorization-browser-first-party', () => {
const clock = sinon.useFakeTimers();
const webex = makeWebex('http://example.com');
const sampleData = {
- device_code: "test123",
+ device_code: 'test123',
expires_in: 300,
- user_code: "421175",
- verification_uri: "http://example.com",
- verification_uri_complete: "http://example.com",
- interval: 2
+ user_code: '421175',
+ verification_uri: 'http://example.com',
+ verification_uri_complete: 'http://example.com',
+ interval: 2,
};
webex.request.resolves().resolves({statusCode: 200, body: sampleData});
webex.authorization.initQRCodeLogin();
clock.tick(2000);
- await clock.runAllAsync()
+ await clock.runAllAsync();
const request = webex.request.getCall(0);
assert.equal(request.args[0].method, 'POST');
@@ -517,7 +551,7 @@ describe('plugin-authorization-browser-first-party', () => {
assert.equal(request.args[0].resource, '/actions/device/authorize');
clock.restore();
});
-
+
it('should emit getUserCodeFailure event', async () => {
const clock = sinon.useFakeTimers();
const webex = makeWebex('http://example.com');
@@ -526,7 +560,7 @@ describe('plugin-authorization-browser-first-party', () => {
webex.authorization.initQRCodeLogin();
- await clock.runAllAsync()
+ await clock.runAllAsync();
assert.calledOnce(emitSpy);
assert.equal(emitSpy.getCall(0).args[1].eventType, 'getUserCodeFailure');
@@ -537,15 +571,15 @@ describe('plugin-authorization-browser-first-party', () => {
describe('#_startQRCodePolling()', () => {
it('requires a deviceCode', () => {
const webex = makeWebex('http://example.com');
-
+
const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
webex.authorization._startQRCodePolling({});
assert.calledOnce(emitSpy);
- assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure');
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure');
});
-
+
it('should send correct request parameters to the API', async () => {
const clock = sinon.useFakeTimers();
const testClientId = 'test-client-id';
@@ -560,7 +594,7 @@ describe('plugin-authorization-browser-first-party', () => {
const webex = makeWebex('http://example.com', undefined, undefined, {
credentials: {
client_id: testClientId,
- }
+ },
});
webex.request.onFirstCall().resolves({statusCode: 200, body: {access_token: 'token'}});
@@ -569,7 +603,7 @@ describe('plugin-authorization-browser-first-party', () => {
webex.authorization._startQRCodePolling(options);
clock.tick(2000);
- await clock.runAllAsync()
+ await clock.runAllAsync();
assert.calledOnce(webex.request);
@@ -577,7 +611,10 @@ describe('plugin-authorization-browser-first-party', () => {
assert.equal(request.args[0].form.client_id, testClientId);
assert.equal(request.args[0].form.device_code, testDeviceCode);
- assert.equal(request.args[0].form.grant_type, 'urn:ietf:params:oauth:grant-type:device_code');
+ assert.equal(
+ request.args[0].form.grant_type,
+ 'urn:ietf:params:oauth:grant-type:device_code'
+ );
assert.calledOnce(webex.authorization.cancelQRCodePolling);
assert.calledTwice(emitSpy);
@@ -593,23 +630,26 @@ describe('plugin-authorization-browser-first-party', () => {
const options = {
device_code: 'test-device-code',
interval: 2,
- expires_in: 300
+ expires_in: 300,
};
-
- webex.request.onFirstCall().rejects({statusCode: 428, body: {message: 'authorization_pending'}});
+
+ webex.request
+ .onFirstCall()
+ .rejects({statusCode: 428, body: {message: 'authorization_pending'}});
webex.request.onSecondCall().resolves({statusCode: 200, body: {access_token: 'token'}});
sinon.spy(webex.authorization, 'cancelQRCodePolling');
const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
-
+
webex.authorization._startQRCodePolling(options);
- clock.tick(4000);
- await clock.runAllAsync()
-
+ await clock.tickAsync(4000);
+ //await clock.runAllAsync()
+
assert.calledTwice(webex.request);
assert.calledOnce(webex.authorization.cancelQRCodePolling);
- assert.calledTwice(emitSpy);
- assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationSuccess');
- assert.equal(emitSpy.getCall(1).args[1].eventType, 'pollingCanceled');
+ assert.calledThrice(emitSpy);
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationPending');
+ assert.equal(emitSpy.getCall(1).args[1].eventType, 'authorizationSuccess');
+ assert.equal(emitSpy.getCall(2).args[1].eventType, 'pollingCanceled');
clock.restore();
});
@@ -619,23 +659,21 @@ describe('plugin-authorization-browser-first-party', () => {
const options = {
device_code: 'test-device-code',
interval: 5,
- expires_in: 10
+ expires_in: 9,
};
-
+
webex.request.rejects({statusCode: 428, body: {message: 'authorizationPending'}});
sinon.spy(webex.authorization, 'cancelQRCodePolling');
const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
webex.authorization._startQRCodePolling(options);
- clock.tick(10000);
- await clock.runAllAsync()
-
- assert.calledTwice(webex.request);
+ await clock.tickAsync(10_000);
+
+ assert.calledOnce(webex.request);
assert.calledOnce(webex.authorization.cancelQRCodePolling);
- assert.calledThrice(emitSpy);
- assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationPending');
+ assert.calledTwice(emitSpy);
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationPending');
assert.equal(emitSpy.getCall(1).args[1].eventType, 'authorizationFailure');
- assert.equal(emitSpy.getCall(2).args[1].eventType, 'pollingCanceled');
clock.restore();
});
@@ -644,54 +682,129 @@ describe('plugin-authorization-browser-first-party', () => {
const options = {
device_code: 'test-device-code',
interval: 2,
- expires_in: 300
+ expires_in: 300,
};
-
- webex.authorization.pollingRequest = 1;
+
+ webex.authorization.pollingTimer = 1;
const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
webex.authorization._startQRCodePolling(options);
assert.calledOnce(emitSpy);
- assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure');
- webex.authorization.pollingRequest = null;
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationFailure');
+ webex.authorization.pollingTimer = null;
+ });
+
+ it('should skip a interval when server ask for slow_down', async () => {
+ const clock = sinon.useFakeTimers();
+ const webex = makeWebex('http://example.com');
+ const options = {
+ device_code: 'test-device-code',
+ interval: 2,
+ expires_in: 300,
+ };
+
+ webex.request.onFirstCall().rejects({statusCode: 400, body: {message: 'slow_down'}});
+ webex.request.onSecondCall().resolves({statusCode: 200, body: {access_token: 'token'}});
+ sinon.spy(webex.authorization, 'cancelQRCodePolling');
+ const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
+
+ webex.authorization._startQRCodePolling(options);
+ await clock.tickAsync(4000);
+
+ // Request only once because of slow_down
+ assert.calledOnce(webex.request);
+
+ // Wait for next interval
+ await clock.tickAsync(2000);
+
+ assert.calledTwice(webex.request);
+ assert.calledOnce(webex.authorization.cancelQRCodePolling);
+ assert.calledTwice(emitSpy);
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'authorizationSuccess');
+ assert.equal(emitSpy.getCall(1).args[1].eventType, 'pollingCanceled');
+ clock.restore();
+ });
+
+ it('should ignore the response from the previous polling', async () => {
+ const clock = sinon.useFakeTimers();
+ const webex = makeWebex('http://example.com');
+ const options = {
+ device_code: 'test-device-code',
+ interval: 2,
+ expires_in: 300,
+ };
+
+ webex.request.onFirstCall().callsFake(() => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve({statusCode: 200, body: {access_token: 'token'}});
+ }, 1000);
+ });
+ });
+
+ webex.request
+ .onSecondCall()
+ .rejects({statusCode: 428, body: {message: 'authorizationPending'}});
+ sinon.spy(webex.authorization, 'cancelQRCodePolling');
+ const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
+
+ webex.authorization._startQRCodePolling(options);
+ await clock.tickAsync(2500);
+
+ webex.authorization.cancelQRCodePolling();
+
+ // Start new polling
+
+ webex.authorization._startQRCodePolling(options);
+
+ // Wait for next interval
+ await clock.tickAsync(3000);
+
+ assert.calledTwice(webex.request);
+ assert.calledOnce(webex.authorization.cancelQRCodePolling);
+ assert.calledTwice(emitSpy);
+ // authorizationSuccess event should not be emitted
+ assert.equal(emitSpy.getCall(0).args[1].eventType, 'pollingCanceled');
+ assert.equal(emitSpy.getCall(1).args[1].eventType, 'authorizationPending');
+ clock.restore();
});
});
describe('#cancelQRCodePolling()', () => {
it('should stop polling after cancellation', async () => {
- const clock = sinon.useFakeTimers();
- const webex = makeWebex('http://example.com');
- const options = {
- device_code: 'test-device-code',
- interval: 2,
- expires_in: 300
- };
-
- webex.request.rejects({statusCode: 428, body: {message: 'authorizationPending'}});
- const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
-
- webex.authorization._startQRCodePolling(options);
- // First poll
- clock.tick(2000);
- assert.calledOnce(webex.request);
-
- webex.authorization.cancelQRCodePolling();
- // Wait for next interval
- clock.tick(2000);
-
- const eventArgs = emitSpy.getCall(0).args;
-
- // Verify no additional requests were made
- assert.calledOnce(webex.request);
- assert.calledOnce(emitSpy);
- assert.equal(eventArgs[1].eventType, 'pollingCanceled');
- clock.restore();
- });
+ const clock = sinon.useFakeTimers();
+ const webex = makeWebex('http://example.com');
+ const options = {
+ device_code: 'test-device-code',
+ interval: 2,
+ expires_in: 300,
+ };
+
+ webex.request.rejects({statusCode: 428, body: {message: 'authorizationPending'}});
+ const emitSpy = sinon.spy(webex.authorization.eventEmitter, 'emit');
+
+ webex.authorization._startQRCodePolling(options);
+ // First poll
+ clock.tick(2000);
+ assert.calledOnce(webex.request);
+
+ webex.authorization.cancelQRCodePolling();
+ // Wait for next interval
+ clock.tick(2000);
+
+ const eventArgs = emitSpy.getCall(0).args;
+
+ // Verify no additional requests were made
+ assert.calledOnce(webex.request);
+ assert.calledOnce(emitSpy);
+ assert.equal(eventArgs[1].eventType, 'pollingCanceled');
+ clock.restore();
+ });
it('should clear interval and reset polling request', () => {
const clock = sinon.useFakeTimers();
const webex = makeWebex('http://example.com');
-
+
const options = {
device_code: 'test_device_code',
interval: 2,
@@ -699,22 +812,21 @@ describe('plugin-authorization-browser-first-party', () => {
};
webex.authorization._startQRCodePolling(options);
- assert.isDefined(webex.authorization.pollingRequest);
-
+ assert.isDefined(webex.authorization.pollingTimer);
+
webex.authorization.cancelQRCodePolling();
- assert.isNull(webex.authorization.pollingRequest);
-
+ assert.isNull(webex.authorization.pollingTimer);
+
clock.restore();
});
-
+
it('should handle cancellation when no polling is in progress', () => {
const webex = makeWebex('http://example.com');
- assert.isNull(webex.authorization.pollingRequest);
-
+ assert.isNull(webex.authorization.pollingTimer);
+
webex.authorization.cancelQRCodePolling();
- assert.isNull(webex.authorization.pollingRequest);
+ assert.isNull(webex.authorization.pollingTimer);
});
-
});
describe('#_generateCodeChallenge', () => {
@@ -836,7 +948,7 @@ describe('plugin-authorization-browser-first-party', () => {
const orgId = webex.authorization._extractOrgIdFromCode(code);
assert.isUndefined(orgId);
- })
+ });
});
});
});
diff --git a/packages/@webex/plugin-logger/src/logger.js b/packages/@webex/plugin-logger/src/logger.js
index 7ef5a925fa9..183602dbe50 100644
--- a/packages/@webex/plugin-logger/src/logger.js
+++ b/packages/@webex/plugin-logger/src/logger.js
@@ -252,6 +252,20 @@ const Logger = WebexPlugin.extend({
return this.getCurrentLevel();
},
+ /**
+ * Clears the log buffers
+ *
+ * @instance
+ * @memberof Logger
+ * @public
+ * @returns {undefined}
+ */
+ clearBuffers() {
+ this.clientBuffer = [];
+ this.sdkBuffer = [];
+ this.buffer = [];
+ },
+
/**
* Format logs (for upload)
*
diff --git a/packages/@webex/plugin-meetings/package.json b/packages/@webex/plugin-meetings/package.json
index 9772e87d059..09bcb56df67 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.11.3",
+ "@webex/internal-media-core": "2.12.2",
"@webex/internal-plugin-conversation": "workspace:*",
"@webex/internal-plugin-device": "workspace:*",
"@webex/internal-plugin-llm": "workspace:*",
diff --git a/packages/@webex/plugin-meetings/src/config.ts b/packages/@webex/plugin-meetings/src/config.ts
index c3d82c71146..28c451ab152 100644
--- a/packages/@webex/plugin-meetings/src/config.ts
+++ b/packages/@webex/plugin-meetings/src/config.ts
@@ -95,5 +95,7 @@ export default {
// This only applies to non-multistream meetings
iceCandidatesGatheringTimeout: undefined,
backendIpv6NativeSupport: false,
+ reachabilityGetClusterTimeout: 5000,
+ logUploadIntervalMultiplicationFactor: 0, // if set to 0 or undefined, logs won't be uploaded periodically
},
};
diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts
index 6fa1a188e31..9ab2f03b185 100644
--- a/packages/@webex/plugin-meetings/src/meeting/index.ts
+++ b/packages/@webex/plugin-meetings/src/meeting/index.ts
@@ -5,6 +5,7 @@ import jwtDecode from 'jwt-decode';
import {StatelessWebexPlugin} from '@webex/webex-core';
// @ts-ignore - Types not available for @webex/common
import {Defer} from '@webex/common';
+import {safeSetTimeout, safeSetInterval} from '@webex/common-timers';
import {
ClientEvent,
ClientEventLeaveReason,
@@ -702,6 +703,8 @@ export default class Meeting extends StatelessWebexPlugin {
private iceCandidateErrors: Map;
private iceCandidatesCount: number;
private rtcMetrics?: RtcMetrics;
+ private uploadLogsTimer?: ReturnType;
+ private logUploadIntervalIndex: number;
/**
* @param {Object} attrs
@@ -770,6 +773,8 @@ export default class Meeting extends StatelessWebexPlugin {
);
this.callStateForMetrics.correlationId = this.id;
}
+ this.logUploadIntervalIndex = 0;
+
/**
* @instance
* @type {String}
@@ -3249,6 +3254,9 @@ export default class Meeting extends StatelessWebexPlugin {
options: {meetingId: this.id},
});
}
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.GUEST_ENTERED_LOBBY, {
+ correlation_id: this.correlationId,
+ });
this.updateLLMConnection();
});
this.locusInfo.on(LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, async (payload) => {
@@ -3272,6 +3280,9 @@ export default class Meeting extends StatelessWebexPlugin {
name: 'client.lobby.exited',
options: {meetingId: this.id},
});
+ Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.GUEST_EXITED_LOBBY, {
+ correlation_id: this.correlationId,
+ });
}
this.rtcMetrics?.sendNextMetrics();
this.updateLLMConnection();
@@ -4060,6 +4071,65 @@ export default class Meeting extends StatelessWebexPlugin {
Trigger.trigger(this, options, EVENTS.REQUEST_UPLOAD_LOGS, this);
}
+ /**
+ * sets the timer for periodic log upload
+ * @returns {void}
+ */
+ private setLogUploadTimer() {
+ // start with short timeouts and increase them later on so in case users have very long multi-hour meetings we don't get too fragmented logs
+ const LOG_UPLOAD_INTERVALS = [0.1, 1, 15, 15, 30, 30, 30, 60];
+
+ const delay =
+ 1000 *
+ // @ts-ignore - config coming from registerPlugin
+ this.config.logUploadIntervalMultiplicationFactor *
+ LOG_UPLOAD_INTERVALS[this.logUploadIntervalIndex];
+
+ if (this.logUploadIntervalIndex < LOG_UPLOAD_INTERVALS.length - 1) {
+ this.logUploadIntervalIndex += 1;
+ }
+
+ this.uploadLogsTimer = safeSetTimeout(() => {
+ this.uploadLogsTimer = undefined;
+
+ this.uploadLogs();
+
+ // just as an extra precaution, to avoid uploading logs forever in case something goes wrong
+ // and the page remains opened, we stop it if there is no media connection
+ if (!this.mediaProperties.webrtcMediaConnection) {
+ return;
+ }
+
+ this.setLogUploadTimer();
+ }, delay);
+ }
+
+ /**
+ * Starts a periodic upload of logs
+ *
+ * @returns {undefined}
+ */
+ public startPeriodicLogUpload() {
+ // @ts-ignore - config coming from registerPlugin
+ if (this.config.logUploadIntervalMultiplicationFactor && !this.uploadLogsTimer) {
+ this.logUploadIntervalIndex = 0;
+
+ this.setLogUploadTimer();
+ }
+ }
+
+ /**
+ * Stops the periodic upload of logs
+ *
+ * @returns {undefined}
+ */
+ public stopPeriodicLogUpload() {
+ if (this.uploadLogsTimer) {
+ clearTimeout(this.uploadLogsTimer);
+ this.uploadLogsTimer = undefined;
+ }
+ }
+
/**
* Removes remote audio, video and share streams from class instance's mediaProperties
* @returns {undefined}
@@ -4771,8 +4841,6 @@ export default class Meeting extends StatelessWebexPlugin {
if (!joinResponse) {
// This is the 1st attempt or a retry after join request failed -> we need to do a join with TURN discovery
- // @ts-ignore
- joinOptions.reachability = await this.webex.meetings.reachability.getReachabilityResults();
const turnDiscoveryRequest = await this.roap.generateTurnDiscoveryRequestMessage(
this,
true
@@ -4904,6 +4972,8 @@ export default class Meeting extends StatelessWebexPlugin {
);
}
+ this.cleanUpBeforeReconnection();
+
return this.reconnectionManager
.reconnect(options, async () => {
await this.waitForRemoteSDPAnswer();
@@ -6321,7 +6391,7 @@ export default class Meeting extends StatelessWebexPlugin {
this.mediaProperties.webrtcMediaConnection.on(
MediaConnectionEventNames.ICE_CANDIDATE,
(event) => {
- if (event.candidate) {
+ if (event.candidate && event.candidate.candidate && event.candidate.candidate.length > 0) {
this.iceCandidatesCount += 1;
}
}
@@ -7032,6 +7102,23 @@ export default class Meeting extends StatelessWebexPlugin {
}
}
+ private async cleanUpBeforeReconnection(): Promise {
+ try {
+ // when media fails, we want to upload a webrtc dump to see whats going on
+ // this function is async, but returns once the stats have been gathered
+ await this.forceSendStatsReport({callFrom: 'cleanUpBeforeReconnection'});
+
+ if (this.statsAnalyzer) {
+ await this.statsAnalyzer.stopAnalyzer();
+ }
+ } catch (error) {
+ LoggerProxy.logger.error(
+ 'Meeting:index#cleanUpBeforeReconnection --> Error during cleanup: ',
+ error
+ );
+ }
+ }
+
/**
* Creates an instance of LocusMediaRequest for this meeting - it is needed for doing any calls
* to Locus /media API (these are used for sending Roap messages and updating audio/video mute status).
@@ -7228,6 +7315,7 @@ export default class Meeting extends StatelessWebexPlugin {
// We can log ReceiveSlot SSRCs only after the SDP exchange, so doing it here:
this.remoteMediaManager?.logAllReceiveSlots();
+ this.startPeriodicLogUpload();
} catch (error) {
LoggerProxy.logger.error(`${LOG_HEADER} failed to establish media connection: `, error);
diff --git a/packages/@webex/plugin-meetings/src/meeting/locusMediaRequest.ts b/packages/@webex/plugin-meetings/src/meeting/locusMediaRequest.ts
index 488d5836dd6..0d56f77a796 100644
--- a/packages/@webex/plugin-meetings/src/meeting/locusMediaRequest.ts
+++ b/packages/@webex/plugin-meetings/src/meeting/locusMediaRequest.ts
@@ -2,8 +2,9 @@
import {defer} from 'lodash';
import {Defer} from '@webex/common';
import {WebexPlugin} from '@webex/webex-core';
-import {MEDIA, HTTP_VERBS, ROAP, IP_VERSION} from '../constants';
+import {MEDIA, HTTP_VERBS, ROAP} from '../constants';
import LoggerProxy from '../common/logs/logger-proxy';
+import {ClientMediaPreferences} from '../reachability/reachability.types';
export type MediaRequestType = 'RoapMessage' | 'LocalMute';
export type RequestResult = any;
@@ -14,9 +15,8 @@ export type RoapRequest = {
mediaId: string;
roapMessage: any;
reachability: any;
+ clientMediaPreferences: ClientMediaPreferences;
sequence?: any;
- joinCookie: any; // any, because this is opaque to the client, we pass whatever object we got from one backend component (Orpheus) to the other (Locus)
- ipVersion?: IP_VERSION;
};
export type LocalMuteRequest = {
@@ -202,10 +202,6 @@ export class LocusMediaRequest extends WebexPlugin {
const body: any = {
device: this.config.device,
correlationId: this.config.correlationId,
- clientMediaPreferences: {
- preferTranscoding: this.config.preferTranscoding,
- ipver: request.type === 'RoapMessage' ? request.ipVersion : undefined,
- },
};
const localMedias: any = {
@@ -223,7 +219,7 @@ export class LocusMediaRequest extends WebexPlugin {
case 'RoapMessage':
localMedias.roapMessage = request.roapMessage;
localMedias.reachability = request.reachability;
- body.clientMediaPreferences.joinCookie = request.joinCookie;
+ body.clientMediaPreferences = request.clientMediaPreferences;
// @ts-ignore
this.webex.internal.newMetrics.submitClientEvent({
diff --git a/packages/@webex/plugin-meetings/src/meeting/request.ts b/packages/@webex/plugin-meetings/src/meeting/request.ts
index 89714139bef..c8b34564d65 100644
--- a/packages/@webex/plugin-meetings/src/meeting/request.ts
+++ b/packages/@webex/plugin-meetings/src/meeting/request.ts
@@ -26,11 +26,11 @@ import {
SEND_DTMF_ENDPOINT,
_SLIDES_,
ANNOTATION,
- IP_VERSION,
} from '../constants';
import {SendReactionOptions, ToggleReactionsOptions} from './request.type';
import MeetingUtil from './util';
import {AnnotationInfo} from '../annotation/annotation.types';
+import {ClientMediaPreferences} from '../reachability/reachability.types';
/**
* @class MeetingRequest
@@ -128,8 +128,8 @@ export default class MeetingRequest extends StatelessWebexPlugin {
locale?: string;
deviceCapabilities?: Array;
liveAnnotationSupported: boolean;
- ipVersion?: IP_VERSION;
alias?: string;
+ clientMediaPreferences: ClientMediaPreferences;
}) {
const {
asResourceOccupant,
@@ -147,12 +147,11 @@ export default class MeetingRequest extends StatelessWebexPlugin {
moveToResource,
roapMessage,
reachability,
- preferTranscoding,
breakoutsSupported,
locale,
deviceCapabilities = [],
liveAnnotationSupported,
- ipVersion,
+ clientMediaPreferences,
alias,
} = options;
@@ -160,8 +159,6 @@ export default class MeetingRequest extends StatelessWebexPlugin {
let url = '';
- const joinCookie = await this.getJoinCookie();
-
const body: any = {
asResourceOccupant,
device: {
@@ -176,11 +173,7 @@ export default class MeetingRequest extends StatelessWebexPlugin {
allowMultiDevice: true,
ensureConversation: ensureConversation || false,
supportsNativeLobby: 1,
- clientMediaPreferences: {
- preferTranscoding: preferTranscoding ?? true,
- joinCookie,
- ipver: ipVersion,
- },
+ clientMediaPreferences,
};
if (alias) {
diff --git a/packages/@webex/plugin-meetings/src/meeting/util.ts b/packages/@webex/plugin-meetings/src/meeting/util.ts
index 8fa62d26d8c..0b727d67874 100644
--- a/packages/@webex/plugin-meetings/src/meeting/util.ts
+++ b/packages/@webex/plugin-meetings/src/meeting/util.ts
@@ -115,7 +115,7 @@ const MeetingUtil = {
return IP_VERSION.unknown;
},
- joinMeeting: (meeting, options) => {
+ joinMeeting: async (meeting, options) => {
if (!meeting) {
return Promise.reject(new ParameterError('You need a meeting object.'));
}
@@ -127,6 +127,27 @@ const MeetingUtil = {
options: {meetingId: meeting.id},
});
+ let reachability;
+ let clientMediaPreferences = {
+ // bare minimum fallback value that should allow us to join
+ ipver: IP_VERSION.unknown,
+ joinCookie: undefined,
+ preferTranscoding: !meeting.isMultistream,
+ };
+
+ try {
+ clientMediaPreferences = await webex.meetings.reachability.getClientMediaPreferences(
+ meeting.isMultistream,
+ MeetingUtil.getIpVersion(webex)
+ );
+ reachability = await webex.meetings.reachability.getReachabilityReportToAttachToRoap();
+ } catch (e) {
+ LoggerProxy.logger.error(
+ 'Meeting:util#joinMeeting --> Error getting reachability or clientMediaPreferences:',
+ e
+ );
+ }
+
// eslint-disable-next-line no-warning-comments
// TODO: check if the meeting is in JOINING state
// if Joining state termintate the request as user might click multiple times
@@ -138,20 +159,19 @@ const MeetingUtil = {
locusUrl: meeting.locusUrl,
locusClusterUrl: meeting.meetingInfo?.locusClusterUrl,
correlationId: meeting.correlationId,
- reachability: options.reachability,
+ reachability,
roapMessage: options.roapMessage,
permissionToken: meeting.permissionToken,
resourceId: options.resourceId || null,
moderator: options.moderator,
pin: options.pin,
moveToResource: options.moveToResource,
- preferTranscoding: !meeting.isMultistream,
asResourceOccupant: options.asResourceOccupant,
breakoutsSupported: options.breakoutsSupported,
locale: options.locale,
deviceCapabilities: options.deviceCapabilities,
liveAnnotationSupported: options.liveAnnotationSupported,
- ipVersion: MeetingUtil.getIpVersion(meeting.getWebexObject()),
+ clientMediaPreferences,
})
.then((res) => {
const parsed = MeetingUtil.parseLocusJoin(res);
@@ -177,6 +197,7 @@ const MeetingUtil = {
cleanUp: (meeting) => {
meeting.getWebexObject().internal.device.meetingEnded();
+ meeting.stopPeriodicLogUpload();
meeting.breakouts.cleanUp();
meeting.simultaneousInterpretation.cleanUp();
diff --git a/packages/@webex/plugin-meetings/src/meetings/index.ts b/packages/@webex/plugin-meetings/src/meetings/index.ts
index 6229462791b..dd0ade33a9f 100644
--- a/packages/@webex/plugin-meetings/src/meetings/index.ts
+++ b/packages/@webex/plugin-meetings/src/meetings/index.ts
@@ -1044,48 +1044,55 @@ export default class Meetings extends WebexPlugin {
*/
fetchUserPreferredWebexSite() {
// @ts-ignore
- return this.webex.people._getMe().then((me) => {
- const isGuestUser = me.type === 'appuser';
- if (!isGuestUser) {
- return this.request.getMeetingPreferences().then((res) => {
- if (res) {
- const preferredWebexSite = MeetingsUtil.parseDefaultSiteFromMeetingPreferences(res);
- this.preferredWebexSite = preferredWebexSite;
- // @ts-ignore
- this.webex.internal.services._getCatalog().addAllowedDomains([preferredWebexSite]);
- }
+ return this.webex.people
+ ._getMe()
+ .then((me) => {
+ const isGuestUser = me.type === 'appuser';
+ if (!isGuestUser) {
+ return this.request.getMeetingPreferences().then((res) => {
+ if (res) {
+ const preferredWebexSite = MeetingsUtil.parseDefaultSiteFromMeetingPreferences(res);
+ this.preferredWebexSite = preferredWebexSite;
+ // @ts-ignore
+ this.webex.internal.services._getCatalog().addAllowedDomains([preferredWebexSite]);
+ }
- // fall back to getting the preferred site from the user information
- if (!this.preferredWebexSite) {
- // @ts-ignore
- return this.webex.internal.user
- .get()
- .then((user) => {
- const preferredWebexSite =
- user?.userPreferences?.userPreferencesItems?.preferredWebExSite;
- if (preferredWebexSite) {
- this.preferredWebexSite = preferredWebexSite;
- // @ts-ignore
- this.webex.internal.services
- ._getCatalog()
- .addAllowedDomains([preferredWebexSite]);
- } else {
- throw new Error('site not found');
- }
- })
- .catch(() => {
- LoggerProxy.logger.error(
- 'Failed to fetch preferred site from user - no site will be set'
- );
- });
- }
+ // fall back to getting the preferred site from the user information
+ if (!this.preferredWebexSite) {
+ // @ts-ignore
+ return this.webex.internal.user
+ .get()
+ .then((user) => {
+ const preferredWebexSite =
+ user?.userPreferences?.userPreferencesItems?.preferredWebExSite;
+ if (preferredWebexSite) {
+ this.preferredWebexSite = preferredWebexSite;
+ // @ts-ignore
+ this.webex.internal.services
+ ._getCatalog()
+ .addAllowedDomains([preferredWebexSite]);
+ } else {
+ throw new Error('site not found');
+ }
+ })
+ .catch(() => {
+ LoggerProxy.logger.error(
+ 'Failed to fetch preferred site from user - no site will be set'
+ );
+ });
+ }
- return Promise.resolve();
- });
- }
+ return Promise.resolve();
+ });
+ }
- return Promise.resolve();
- });
+ return Promise.resolve();
+ })
+ .catch(() => {
+ LoggerProxy.logger.error(
+ 'Failed to retrieve user information. No preferredWebexSite will be set'
+ );
+ });
}
/**
diff --git a/packages/@webex/plugin-meetings/src/metrics/constants.ts b/packages/@webex/plugin-meetings/src/metrics/constants.ts
index 3cc6cb108fd..65b82975ba4 100644
--- a/packages/@webex/plugin-meetings/src/metrics/constants.ts
+++ b/packages/@webex/plugin-meetings/src/metrics/constants.ts
@@ -71,6 +71,8 @@ const BEHAVIORAL_METRICS = {
TURN_DISCOVERY_REQUIRES_OK: 'js_sdk_turn_discovery_requires_ok',
REACHABILITY_COMPLETED: 'js_sdk_reachability_completed',
WEBINAR_REGISTRATION_ERROR: 'js_sdk_webinar_registration_error',
+ GUEST_ENTERED_LOBBY: 'js_sdk_guest_entered_lobby',
+ GUEST_EXITED_LOBBY: 'js_sdk_guest_exited_lobby',
};
export {BEHAVIORAL_METRICS as default};
diff --git a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
index 3803f95e1df..cc3674ac169 100644
--- a/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
+++ b/packages/@webex/plugin-meetings/src/reachability/clusterReachability.ts
@@ -6,20 +6,7 @@ import {convertStunUrlToTurn, convertStunUrlToTurnTls} from './util';
import EventsScope from '../common/events/events-scope';
import {CONNECTION_STATE, Enum, ICE_GATHERING_STATE} from '../constants';
-
-// result for a specific transport protocol (like udp or tcp)
-export type TransportResult = {
- result: 'reachable' | 'unreachable' | 'untested';
- latencyInMilliseconds?: number; // amount of time it took to get the first ICE candidate
- clientMediaIPs?: string[];
-};
-
-// reachability result for a specific media cluster
-export type ClusterReachabilityResult = {
- udp: TransportResult;
- tcp: TransportResult;
- xtls: TransportResult;
-};
+import {ClusterReachabilityResult} from './reachability.types';
// data for the Events.resultReady event
export type ResultEventData = {
diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts
index ff6d47a14b7..a0903d9decc 100644
--- a/packages/@webex/plugin-meetings/src/reachability/index.ts
+++ b/packages/@webex/plugin-meetings/src/reachability/index.ts
@@ -9,64 +9,31 @@ import {Defer} from '@webex/common';
import LoggerProxy from '../common/logs/logger-proxy';
import MeetingUtil from '../meeting/util';
-import {REACHABILITY} from '../constants';
+import {IP_VERSION, REACHABILITY} from '../constants';
import ReachabilityRequest, {ClusterList} from './request';
+import {
+ ClusterReachabilityResult,
+ TransportResult,
+ ClientMediaPreferences,
+ ReachabilityMetrics,
+ ReachabilityReportV0,
+ ReachabilityReportV1,
+ ReachabilityResults,
+ ReachabilityResultsForBackend,
+ TransportResultForBackend,
+ GetClustersTrigger,
+} from './reachability.types';
import {
ClientMediaIpsUpdatedEventData,
ClusterReachability,
- ClusterReachabilityResult,
Events,
ResultEventData,
- TransportResult,
} from './clusterReachability';
import EventsScope from '../common/events/events-scope';
import BEHAVIORAL_METRICS from '../metrics/constants';
import Metrics from '../metrics';
-export type ReachabilityMetrics = {
- reachability_public_udp_success: number;
- reachability_public_udp_failed: number;
- reachability_public_tcp_success: number;
- reachability_public_tcp_failed: number;
- reachability_public_xtls_success: number;
- reachability_public_xtls_failed: number;
- reachability_vmn_udp_success: number;
- reachability_vmn_udp_failed: number;
- reachability_vmn_tcp_success: number;
- reachability_vmn_tcp_failed: number;
- reachability_vmn_xtls_success: number;
- reachability_vmn_xtls_failed: number;
-};
-
-/**
- * This is the type that matches what backend expects us to send to them. It is a bit weird, because
- * it uses strings instead of booleans and numbers, but that's what they require.
- */
-export type TransportResultForBackend = {
- reachable?: 'true' | 'false';
- latencyInMilliseconds?: string;
- clientMediaIPs?: string[];
- untested?: 'true';
-};
-
-export type ReachabilityResultForBackend = {
- udp: TransportResultForBackend;
- tcp: TransportResultForBackend;
- xtls: TransportResultForBackend;
-};
-
-// this is the type that is required by the backend when we send them reachability results
-export type ReachabilityResultsForBackend = Record;
-
-// this is the type used by Reachability class internally and stored in local storage
-export type ReachabilityResults = Record<
- string,
- ClusterReachabilityResult & {
- isVideoMesh?: boolean;
- }
->;
-
// timeouts in seconds
const DEFAULT_TIMEOUT = 3;
const VIDEO_MESH_TIMEOUT = 1;
@@ -84,6 +51,9 @@ export default class Reachability extends EventsScope {
[key: string]: ClusterReachability;
};
+ minRequiredClusters?: number;
+ orpheusApiVersion?: number;
+
reachabilityDefer?: Defer;
vmnTimer?: ReturnType;
@@ -92,6 +62,8 @@ export default class Reachability extends EventsScope {
expectedResultsCount = {videoMesh: {udp: 0}, public: {udp: 0, tcp: 0, xtls: 0}};
resultsCount = {videoMesh: {udp: 0}, public: {udp: 0, tcp: 0, xtls: 0}};
+ startTime = undefined;
+ totalDuration = undefined;
protected lastTrigger?: string;
@@ -118,14 +90,35 @@ export default class Reachability extends EventsScope {
/**
* Fetches the list of media clusters from the backend
+ * @param {string} trigger - explains the reason for starting reachability, used by Orpheus
+ * @param {Object} previousReport - last reachability report
* @param {boolean} isRetry
* @private
* @returns {Promise<{clusters: ClusterList, joinCookie: any}>}
*/
- async getClusters(isRetry = false): Promise<{clusters: ClusterList; joinCookie: any}> {
+ async getClusters(
+ trigger: GetClustersTrigger,
+ previousReport?: any,
+ isRetry = false
+ ): Promise<{
+ clusters: ClusterList;
+ joinCookie: any;
+ }> {
try {
- const {clusters, joinCookie} = await this.reachabilityRequest.getClusters(
- MeetingUtil.getIpVersion(this.webex)
+ const {clusters, joinCookie, discoveryOptions} = await this.reachabilityRequest.getClusters(
+ trigger,
+ MeetingUtil.getIpVersion(this.webex),
+ previousReport
+ );
+
+ this.minRequiredClusters = discoveryOptions?.['early-call-min-clusters'];
+ this.orpheusApiVersion = discoveryOptions?.['report-version'];
+
+ // @ts-ignore
+ await this.webex.boundedStorage.put(
+ this.namespace,
+ REACHABILITY.localStorageJoinCookie,
+ JSON.stringify(joinCookie)
);
return {clusters, joinCookie};
@@ -138,7 +131,7 @@ export default class Reachability extends EventsScope {
`Reachability:index#getClusters --> Failed with error: ${error}, retrying...`
);
- return this.getClusters(true);
+ return this.getClusters(trigger, previousReport, true);
}
}
@@ -159,14 +152,7 @@ export default class Reachability extends EventsScope {
// @ts-ignore
this.webex.internal.device.ipNetworkDetector.detect(true);
- const {clusters, joinCookie} = await this.getClusters();
-
- // @ts-ignore
- await this.webex.boundedStorage.put(
- this.namespace,
- REACHABILITY.localStorageJoinCookie,
- JSON.stringify(joinCookie)
- );
+ const {clusters} = await this.getClusters('startup');
this.reachabilityDefer = new Defer();
@@ -181,6 +167,98 @@ export default class Reachability extends EventsScope {
}
}
+ /**
+ * Gets the last join cookie we got from Orpheus
+ *
+ * @returns {Promise