From 699457b4397547450264d89e119d8e47d0124063 Mon Sep 17 00:00:00 2001 From: Michelle Mulcair Date: Wed, 23 Oct 2024 09:13:18 +0100 Subject: [PATCH] feat(internal-plugin-usersub): add usersub plugin to allow the publishing of cross-client-state --- jest.config.js | 1 + package.json | 2 +- .../internal-plugin-usersub/.eslintrc.js | 6 + .../@webex/internal-plugin-usersub/README.md | 42 +++++ .../internal-plugin-usersub/babel.config.js | 3 + .../internal-plugin-usersub/jest.config.js | 3 + .../internal-plugin-usersub/package.json | 52 ++++++ .../@webex/internal-plugin-usersub/process | 1 + .../internal-plugin-usersub/src/config.ts | 7 + .../internal-plugin-usersub/src/constants.ts | 2 + .../internal-plugin-usersub/src/index.ts | 12 ++ .../internal-plugin-usersub/src/usersub.ts | 151 +++++++++++++++ .../test/unit/spec/usersub.ts | 173 ++++++++++++++++++ yarn.lock | 25 +++ 14 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 packages/@webex/internal-plugin-usersub/.eslintrc.js create mode 100644 packages/@webex/internal-plugin-usersub/README.md create mode 100644 packages/@webex/internal-plugin-usersub/babel.config.js create mode 100644 packages/@webex/internal-plugin-usersub/jest.config.js create mode 100644 packages/@webex/internal-plugin-usersub/package.json create mode 100644 packages/@webex/internal-plugin-usersub/process create mode 100644 packages/@webex/internal-plugin-usersub/src/config.ts create mode 100644 packages/@webex/internal-plugin-usersub/src/constants.ts create mode 100644 packages/@webex/internal-plugin-usersub/src/index.ts create mode 100644 packages/@webex/internal-plugin-usersub/src/usersub.ts create mode 100644 packages/@webex/internal-plugin-usersub/test/unit/spec/usersub.ts diff --git a/jest.config.js b/jest.config.js index 6259050487d..f1f1c99265d 100644 --- a/jest.config.js +++ b/jest.config.js @@ -26,6 +26,7 @@ const packages = [ '@webex/internal-plugin-support', '@webex/internal-plugin-team', '@webex/internal-plugin-user', + '@webex/internal-plugin-usersub', '@webex/internal-plugin-voicea', '@webex/internal-plugin-wdm', '@webex/jsdoctrinetest', diff --git a/package.json b/package.json index 775d5e4f22a..33591641454 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ }, "scripts": { "@all": "yarn @workspaces run", - "@legacy": "yarn @workspaces --from \"{@webex/common,@webex/common-evented,@webex/common-timers,@webex/helper-html,@webex/helper-image,@webex/http-core,@webex/internal-plugin-avatar,@webex/internal-plugin-board,@webex/internal-plugin-calendar,@webex/internal-plugin-conversation,@webex/internal-plugin-device,@webex/internal-plugin-dss,@webex/internal-plugin-ediscovery,@webex/internal-plugin-encryption,@webex/internal-plugin-feature,@webex/internal-plugin-flag,@webex/internal-plugin-llm,@webex/internal-plugin-locus,@webex/internal-plugin-lyra,@webex/internal-plugin-mercury,@webex/internal-plugin-metrics,@webex/internal-plugin-presence,@webex/internal-plugin-scheduler,@webex/internal-plugin-search,@webex/internal-plugin-support,@webex/internal-plugin-team,@webex/internal-plugin-user,@webex/internal-plugin-voicea,@webex/internal-plugin-wdm,@webex/jsdoctrinetest,@webex/media-helpers,@webex/plugin-attachment-actions,@webex/plugin-authorization,@webex/plugin-authorization-browser,@webex/plugin-authorization-browser-first-party,@webex/plugin-authorization-node,@webex/plugin-device-manager,@webex/plugin-logger,@webex/plugin-meetings,@webex/plugin-memberships,@webex/plugin-messages,@webex/plugin-people,@webex/plugin-presence,@webex/plugin-rooms,@webex/plugin-team-memberships,@webex/plugin-teams,@webex/plugin-webhooks,@webex/recipe-private-web-client,@webex/storage-adapter-local-forage,@webex/storage-adapter-local-storage,@webex/storage-adapter-session-storage,@webex/storage-adapter-spec,@webex/test-helper-appid,@webex/test-helper-automation,@webex/test-helper-chai,@webex/test-helper-file,@webex/test-helper-make-local-url,@webex/test-helper-mocha,@webex/test-helper-mock-web-socket,@webex/test-helper-mock-webex,@webex/test-helper-refresh-callback,@webex/test-helper-retry,@webex/test-helper-server,@webex/test-helper-test-users,@webex/test-users,@webex/webex-core,@webex/webex-server,@webex/webrtc,@webex/xunit-with-logs,webex}\" run", + "@legacy": "yarn @workspaces --from \"{@webex/common,@webex/common-evented,@webex/common-timers,@webex/helper-html,@webex/helper-image,@webex/http-core,@webex/internal-plugin-avatar,@webex/internal-plugin-board,@webex/internal-plugin-calendar,@webex/internal-plugin-conversation,@webex/internal-plugin-device,@webex/internal-plugin-dss,@webex/internal-plugin-ediscovery,@webex/internal-plugin-encryption,@webex/internal-plugin-feature,@webex/internal-plugin-flag,@webex/internal-plugin-llm,@webex/internal-plugin-locus,@webex/internal-plugin-lyra,@webex/internal-plugin-mercury,@webex/internal-plugin-metrics,@webex/internal-plugin-presence,@webex/internal-plugin-scheduler,@webex/internal-plugin-search,@webex/internal-plugin-support,@webex/internal-plugin-team,@webex/internal-plugin-user,@webex/internal-plugin-usersub,@webex/internal-plugin-voicea,@webex/internal-plugin-wdm,@webex/jsdoctrinetest,@webex/media-helpers,@webex/plugin-attachment-actions,@webex/plugin-authorization,@webex/plugin-authorization-browser,@webex/plugin-authorization-browser-first-party,@webex/plugin-authorization-node,@webex/plugin-device-manager,@webex/plugin-logger,@webex/plugin-meetings,@webex/plugin-memberships,@webex/plugin-messages,@webex/plugin-people,@webex/plugin-presence,@webex/plugin-rooms,@webex/plugin-team-memberships,@webex/plugin-teams,@webex/plugin-webhooks,@webex/recipe-private-web-client,@webex/storage-adapter-local-forage,@webex/storage-adapter-local-storage,@webex/storage-adapter-session-storage,@webex/storage-adapter-spec,@webex/test-helper-appid,@webex/test-helper-automation,@webex/test-helper-chai,@webex/test-helper-file,@webex/test-helper-make-local-url,@webex/test-helper-mocha,@webex/test-helper-mock-web-socket,@webex/test-helper-mock-webex,@webex/test-helper-refresh-callback,@webex/test-helper-retry,@webex/test-helper-server,@webex/test-helper-test-users,@webex/test-users,@webex/webex-core,@webex/webex-server,@webex/webrtc,@webex/xunit-with-logs,webex}\" run", "@legacy-tools": "yarn @workspaces --topological-dev --from \"{@webex/babel-config-legacy,@webex/env-config-legacy,@webex/eslint-config-legacy,@webex/jest-config-legacy,@webex/legacy-tools}\" run", "@tools": "yarn @workspaces --topological-dev --from \"{@webex/cli-tools,@webex/package-tools}\" run", "@workspaces": "yarn workspaces foreach --parallel --verbose", diff --git a/packages/@webex/internal-plugin-usersub/.eslintrc.js b/packages/@webex/internal-plugin-usersub/.eslintrc.js new file mode 100644 index 00000000000..a4fc83c6f44 --- /dev/null +++ b/packages/@webex/internal-plugin-usersub/.eslintrc.js @@ -0,0 +1,6 @@ +const config = { + root: true, + extends: ['@webex/eslint-config-legacy'], +}; + +module.exports = config; diff --git a/packages/@webex/internal-plugin-usersub/README.md b/packages/@webex/internal-plugin-usersub/README.md new file mode 100644 index 00000000000..2779ac024bb --- /dev/null +++ b/packages/@webex/internal-plugin-usersub/README.md @@ -0,0 +1,42 @@ +# @webex/internal-plugin-usersub + +[![standard-readme compliant](https://img.shields.io/badge/readme%20style-standard-brightgreen.svg?style=flat-square)](https://github.com/RichardLitt/standard-readme) + +> Plugin for the UserSub services + +This is an internal Cisco Webex plugin. As such, it does not strictly adhere to semantic versioning. Use at your own risk. If you're not working on one of our first party clients, please look at our [developer api](https://developer.webex.com/) and stick to our public plugins. + +- [Install](#install) +- [Usage](#usage) +- [Contribute](#contribute) +- [Maintainers](#maintainers) +- [License](#license) + +## Install + +```bash +npm install --save @webex/internal-plugin-usersub +``` + +## Usage + +```js +import '@webex/internal-plugin-usersub'; + +import WebexCore from '@webex/webex-core'; + +const webex = new WebexCore(); +webex.internal.usersub.WHATEVER; +``` + +## Maintainers + +This package is maintained by [Cisco Webex for Developers](https://developer.webex.com/). + +## Contribute + +Pull requests welcome. Please see [CONTRIBUTING.md](https://github.com/webex/webex-js-sdk/blob/master/CONTRIBUTING.md) for more details. + +## License + +© 2016-2020 Cisco and/or its affiliates. All Rights Reserved. diff --git a/packages/@webex/internal-plugin-usersub/babel.config.js b/packages/@webex/internal-plugin-usersub/babel.config.js new file mode 100644 index 00000000000..71a8b034b1f --- /dev/null +++ b/packages/@webex/internal-plugin-usersub/babel.config.js @@ -0,0 +1,3 @@ +const babelConfigLegacy = require('@webex/babel-config-legacy'); + +module.exports = babelConfigLegacy; diff --git a/packages/@webex/internal-plugin-usersub/jest.config.js b/packages/@webex/internal-plugin-usersub/jest.config.js new file mode 100644 index 00000000000..0e9d38b401c --- /dev/null +++ b/packages/@webex/internal-plugin-usersub/jest.config.js @@ -0,0 +1,3 @@ +const config = require('@webex/jest-config-legacy'); + +module.exports = config; diff --git a/packages/@webex/internal-plugin-usersub/package.json b/packages/@webex/internal-plugin-usersub/package.json new file mode 100644 index 00000000000..95125c1d1db --- /dev/null +++ b/packages/@webex/internal-plugin-usersub/package.json @@ -0,0 +1,52 @@ +{ + "name": "@webex/internal-plugin-usersub", + "description": "Internal plugin for managing user subscriptions in Webex SDK", + "license": "MIT", + "main": "dist/index.js", + "devMain": "src/index.js", + "repository": { + "type": "git", + "url": "https://github.com/webex/webex-js-sdk.git", + "directory": "packages/@webex/internal-plugin-usersub" + }, + "engines": { + "node": ">=16" + }, + "browserify": { + "transform": [ + "babelify", + "envify" + ] + }, + "devDependencies": { + "@babel/core": "^7.17.10", + "@webex/babel-config-legacy": "workspace:*", + "@webex/eslint-config-legacy": "workspace:*", + "@webex/jest-config-legacy": "workspace:*", + "@webex/legacy-tools": "workspace:*", + "@webex/test-helper-chai": "workspace:*", + "@webex/test-helper-mocha": "workspace:*", + "@webex/test-helper-mock-webex": "workspace:*", + "@webex/test-helper-test-users": "workspace:*", + "eslint": "^8.24.0", + "prettier": "^2.7.1", + "sinon": "^9.2.4" + }, + "dependencies": { + "@webex/common": "workspace:*", + "@webex/common-timers": "workspace:*", + "@webex/internal-plugin-device": "workspace:*", + "@webex/webex-core": "workspace:*", + "lodash": "^4.17.21", + "uuid": "^3.3.2" + }, + "scripts": { + "build": "yarn build:src", + "build:src": "webex-legacy-tools build -dest \"./dist\" -src \"./src\" -js -ts -maps", + "deploy:npm": "yarn npm publish", + "test": "yarn test:style && yarn test:unit && yarn test:integration && yarn test:browser", + "test:browser:broken": "webex-legacy-tools test --integration --runner karma", + "test:style": "eslint ./src/**/*.*", + "test:unit": "webex-legacy-tools test --unit --runner jest" + } +} diff --git a/packages/@webex/internal-plugin-usersub/process b/packages/@webex/internal-plugin-usersub/process new file mode 100644 index 00000000000..94119b99dbf --- /dev/null +++ b/packages/@webex/internal-plugin-usersub/process @@ -0,0 +1 @@ +module.exports = {browser: true}; diff --git a/packages/@webex/internal-plugin-usersub/src/config.ts b/packages/@webex/internal-plugin-usersub/src/config.ts new file mode 100644 index 00000000000..46559f291b1 --- /dev/null +++ b/packages/@webex/internal-plugin-usersub/src/config.ts @@ -0,0 +1,7 @@ +/*! + * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file. + */ + +export default { + userSub: {}, +}; diff --git a/packages/@webex/internal-plugin-usersub/src/constants.ts b/packages/@webex/internal-plugin-usersub/src/constants.ts new file mode 100644 index 00000000000..edac0475d44 --- /dev/null +++ b/packages/@webex/internal-plugin-usersub/src/constants.ts @@ -0,0 +1,2 @@ +export const EXPIRATION_OFFSET = 60 * 1000; // 1 minute in ms +export const USERSUB_SERVICE_NAME = 'usersub'; diff --git a/packages/@webex/internal-plugin-usersub/src/index.ts b/packages/@webex/internal-plugin-usersub/src/index.ts new file mode 100644 index 00000000000..530e6eed5e1 --- /dev/null +++ b/packages/@webex/internal-plugin-usersub/src/index.ts @@ -0,0 +1,12 @@ +/*! + * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file. + */ + +import {registerInternalPlugin} from '@webex/webex-core'; + +import Usersub from './usersub'; +import config from './config'; + +registerInternalPlugin('usersub', Usersub, {config}); + +export {default} from './usersub'; diff --git a/packages/@webex/internal-plugin-usersub/src/usersub.ts b/packages/@webex/internal-plugin-usersub/src/usersub.ts new file mode 100644 index 00000000000..1c0d6fae6b1 --- /dev/null +++ b/packages/@webex/internal-plugin-usersub/src/usersub.ts @@ -0,0 +1,151 @@ +/*! + * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file. + */ + +import '@webex/internal-plugin-device'; + +import {WebexPlugin} from '@webex/webex-core'; +import {safeSetTimeout} from '@webex/common-timers'; +import {EXPIRATION_OFFSET, USERSUB_SERVICE_NAME} from './constants'; + +/** + * @class + * @extends WebexPlugin + */ +const Usersub = WebexPlugin.extend({ + namespace: 'Usersub', + + session: { + crossClientState: { + type: 'object', + /** + * Returns a new Map instance as the default value for crossClientState. + * @returns {Map} + */ + default() { + return new Map(); + }, + }, + + refreshTimer: { + default: undefined, + type: 'any', + }, + }, + + /** + * Sets the value for answer-calls-on-wxcc for the calling application. + * @param {boolean} enable - The state will be enabled/disabled. + * @param {string} appName - The app setting the state. + * @param {number} ttl - Time To Live for the event in seconds. + * @returns {Promise} + */ + updateAnswerCallsCrossClient(enable: boolean, appName: string, ttl: number) { + if (typeof enable !== 'boolean') { + return Promise.reject(new Error('Enable parameter must be a boolean')); + } + + if (!appName) { + return Promise.reject(new Error('An appName is required')); + } + + if (ttl <= 0) { + return Promise.reject(new Error('A positive ttl is required')); + } + + const jsonData = { + users: [this.webex.internal.device.userId], + compositions: [ + { + type: 'cross-client-state', + ttl, + composition: { + devices: [ + { + deviceId: this._extractIdFromUrl(this.webex.internal.device.url), + appName, + state: { + 'answer-calls-on-wxcc': enable, + }, + }, + ], + }, + }, + ], + }; + + return this.webex + .request({ + method: 'POST', + service: USERSUB_SERVICE_NAME, + resource: 'publish', + body: jsonData, + }) + .then((response) => { + this.crossClientState.set(appName, enable); + this._startRefreshTimer(appName, ttl); + + return response.body; + }) + .catch((error) => { + this.logger.error( + `userSub: updateAnswerCallsCrossClient failed with error: ${error.message}` + ); + + return Promise.reject(error); + }); + }, + + /** + * Starts the refresh timer for the cross-client state. + * @private + * @param {string} appName - The app setting the state. + * @param {number} ttl - Time To Live for the event in seconds. + * @returns {void} + */ + _startRefreshTimer(appName: string, ttl: number): void { + this._stopRefreshTimer(); + const answerCallsState = this.crossClientState.get(appName); + if (answerCallsState) { + const refreshTime = ttl * 1000 - EXPIRATION_OFFSET; + if (refreshTime <= 0) { + this.logger.warn('Refresh time is non-positive, skipping timer setup.'); + + return; + } + this.refreshTimer = safeSetTimeout( + () => this.updateAnswerCallsCrossClient(answerCallsState, appName, ttl), + refreshTime + ); + } + }, + + /** + * Stops the refresh timer for the cross-client state. + * @private + * @returns {void} + */ + _stopRefreshTimer(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + }, + + /** + * Extracts the device ID from a given URL. + * @param {string} url - The URL to extract the device ID from. + * @returns {string | null} The extracted device ID or null if not found. + */ + _extractIdFromUrl(url: string): string { + if (url) { + const regex = /\/devices\/([^/?]+)/; + const match = url.match(regex); + + return match ? match[1] : ''; + } + + return ''; + }, +}); + +export default Usersub; diff --git a/packages/@webex/internal-plugin-usersub/test/unit/spec/usersub.ts b/packages/@webex/internal-plugin-usersub/test/unit/spec/usersub.ts new file mode 100644 index 00000000000..744f38b6ca1 --- /dev/null +++ b/packages/@webex/internal-plugin-usersub/test/unit/spec/usersub.ts @@ -0,0 +1,173 @@ +/*! + * Copyright (c) 2015-2020 Cisco Systems, Inc. See LICENSE file. + */ + +import {assert} from '@webex/test-helper-chai'; +import sinon from 'sinon'; +import Usersub from '@webex/internal-plugin-usersub'; +import MockWebex from '@webex/test-helper-mock-webex'; + +describe('plugin-usersub', () => { + describe('Usersub', () => { + let webex; + let clock; + + const testGuid = 'test-guid'; + const deviceId = 'dc5c6ab1-1feb-4a6e-be4e-88c48aaee7da'; + const testDeviceUrl = `https://wdm-a.wbx2.com/wdm/api/v1/devices/${deviceId}`; + const testAppName = 'wxcc'; + const ttl = 120; + + beforeEach(() => { + webex = new MockWebex({ + children: { + usersub: Usersub, + }, + }); + clock = sinon.useFakeTimers(); + webex.internal.device.userId = testGuid; + webex.internal.device.url = testDeviceUrl; + }); + + afterEach(() => { + clock.restore(); + }); + + describe('#updateAnswerCallsCrossClient()', () => { + it('requires a boolean to enable or disable composition to be passed', () => + assert.isRejected( + webex.internal.usersub.updateAnswerCallsCrossClient('true'), + /Enable parameter must be a boolean/ + )); + + it('requires an app name to be passed in the parameters', () => + assert.isRejected( + webex.internal.usersub.updateAnswerCallsCrossClient(true, ''), + /An appName is required/ + )); + + it('requires a ttl to be passed in the parameters', () => + assert.isRejected( + webex.internal.usersub.updateAnswerCallsCrossClient(true, 'wxcc', 0), + /A positive ttl is required/ + )); + + const testCases = [ + {enable: true, state: {'answer-calls-on-wxcc': true}}, + {enable: false, state: {'answer-calls-on-wxcc': false}}, + ]; + + testCases.forEach(({enable, state}) => { + it(`should set cross-client-state with answer-calls-on-wxcc: ${enable}`, async () => { + webex.request = function (options) { + return Promise.resolve({ + statusCode: 204, + body: [], + options, + }); + }; + sinon.spy(webex, 'request'); + + await webex.internal.usersub.updateAnswerCallsCrossClient(enable, testAppName, ttl); + + assert.calledOnce(webex.request); + + const request = webex.request.getCall(0); + + assert.equal(request.args[0].resource, 'publish'); + assert.equal(request.args[0].body.users.length, 1); + assert.equal(request.args[0].body.users[0], 'test-guid'); + assert.equal(request.args[0].body.compositions.length, 1); + assert.equal(request.args[0].body.compositions[0].type, 'cross-client-state'); + assert.equal(request.args[0].body.compositions[0].ttl, ttl); + assert.equal(request.args[0].body.compositions[0].composition.devices.length, 1); + const devices = request.args[0].body.compositions[0].composition.devices; + assert.deepEqual(devices, [ + { + appName: testAppName, + deviceId, + state, + }, + ]); + }); + }); + + it('should auto refresh cross-client-state', async () => { + webex.request = function (options) { + return Promise.resolve({ + statusCode: 204, + body: [], + options, + }); + }; + + sinon.spy(webex, 'request'); + + await webex.internal.usersub.updateAnswerCallsCrossClient(true, testAppName, ttl); + assert.calledOnce(webex.request); + assert.equal(webex.internal.usersub.crossClientState.get(testAppName), true); + + const time = 61 * 1000; + clock.tick(time); + await Promise.resolve(); + assert.calledTwice(webex.request); + + clock.tick(time); + await Promise.resolve(); + assert.calledThrice(webex.request); + }); + + it('should not refresh cross-client-state after setting answer-calls-on-wxcc to false', async () => { + webex.request = function (options) { + return Promise.resolve({ + statusCode: 204, + body: [], + options, + }); + }; + + sinon.spy(webex, 'request'); + await webex.internal.usersub.updateAnswerCallsCrossClient(true, testAppName, ttl); + assert.calledOnce(webex.request); + assert.equal(webex.internal.usersub.crossClientState.get(testAppName), true); + + const time = 61 * 1000; + clock.tick(time); + assert.calledTwice(webex.request); + + await webex.internal.usersub.updateAnswerCallsCrossClient(false, testAppName, ttl); + assert.calledThrice(webex.request); + assert.equal(webex.internal.usersub.crossClientState.get(testAppName), false); + + clock.tick(time); + assert.calledThrice(webex.request); + }); + it('request to update cross-client-state fails', async () => { + const errorMessage = 'Request failed'; + webex.request.rejects(new Error(errorMessage)); + + await assert.isRejected( + webex.internal.usersub.updateAnswerCallsCrossClient(true, testAppName, ttl), + Error, + errorMessage + ); + }); + it('deviceId is empty', async () => { + webex.internal.device.url = ''; + webex.request = function (options) { + return Promise.resolve({ + statusCode: 204, + body: [], + options, + }); + }; + sinon.spy(webex, 'request'); + + await webex.internal.usersub.updateAnswerCallsCrossClient(true, testAppName, ttl); + assert.calledOnce(webex.request); + const request = webex.request.getCall(0); + assert.isEmpty(request.args[0].body.compositions[0].composition.devices[0].deviceId); + }); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 081ac9f3761..04496435c11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8298,6 +8298,31 @@ __metadata: languageName: unknown linkType: soft +"@webex/internal-plugin-usersub@workspace:packages/@webex/internal-plugin-usersub": + version: 0.0.0-use.local + resolution: "@webex/internal-plugin-usersub@workspace:packages/@webex/internal-plugin-usersub" + dependencies: + "@babel/core": ^7.17.10 + "@webex/babel-config-legacy": "workspace:*" + "@webex/common": "workspace:*" + "@webex/common-timers": "workspace:*" + "@webex/eslint-config-legacy": "workspace:*" + "@webex/internal-plugin-device": "workspace:*" + "@webex/jest-config-legacy": "workspace:*" + "@webex/legacy-tools": "workspace:*" + "@webex/test-helper-chai": "workspace:*" + "@webex/test-helper-mocha": "workspace:*" + "@webex/test-helper-mock-webex": "workspace:*" + "@webex/test-helper-test-users": "workspace:*" + "@webex/webex-core": "workspace:*" + eslint: ^8.24.0 + lodash: ^4.17.21 + prettier: ^2.7.1 + sinon: ^9.2.4 + uuid: ^3.3.2 + languageName: unknown + linkType: soft + "@webex/internal-plugin-voicea@workspace:*, @webex/internal-plugin-voicea@workspace:packages/@webex/internal-plugin-voicea": version: 0.0.0-use.local resolution: "@webex/internal-plugin-voicea@workspace:packages/@webex/internal-plugin-voicea"