From 63020949ebbe18decd22ca4d4d7e34a477404835 Mon Sep 17 00:00:00 2001 From: Filip Nowakowski Date: Wed, 4 Sep 2024 15:06:22 +0200 Subject: [PATCH 01/22] fix(plugin-meetings): clientSignallingProtocol stat (#3803) Co-authored-by: Filip Nowakowski --- .../src/call-diagnostic/call-diagnostic-metrics.ts | 3 +++ .../unit/spec/call-diagnostic/call-diagnostic-metrics.ts | 9 +++++++++ 2 files changed, 12 insertions(+) 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', From d354100b91c0f9dbf84b2ba65f8e762c05648b63 Mon Sep 17 00:00:00 2001 From: Charles Burkett Date: Wed, 4 Sep 2024 13:01:18 -0400 Subject: [PATCH 02/22] fix(audio-mute): fix sometimes out-of-sync remote audio mute (#3797) --- .../src/locus-info/selfUtils.ts | 5 -- .../plugin-meetings/src/meeting/muteState.ts | 7 ++- .../test/unit/spec/locus-info/selfUtils.js | 48 ++++++++++--------- .../test/unit/spec/meeting/muteState.js | 24 ++++++++++ 4 files changed, 55 insertions(+), 29 deletions(-) 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/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/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/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; From 73e75b76d046f18d805ae1d5652abb23431ea0a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edmond=20Vuji=C4=87i?= <67634227+edvujic@users.noreply.github.com> Date: Thu, 5 Sep 2024 09:50:01 +0200 Subject: [PATCH 03/22] fix: update internal media core version (#3806) Co-authored-by: evujici --- packages/@webex/media-helpers/package.json | 2 +- packages/@webex/plugin-meetings/package.json | 2 +- packages/calling/package.json | 2 +- yarn.lock | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/@webex/media-helpers/package.json b/packages/@webex/media-helpers/package.json index b95c6dd1c99..512e8e6fe42 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.9.2", "@webex/ts-events": "^1.1.0", "@webex/web-media-effects": "2.18.0" }, diff --git a/packages/@webex/plugin-meetings/package.json b/packages/@webex/plugin-meetings/package.json index 02c7f99e073..31a709e3185 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.9.2", "@webex/internal-plugin-conversation": "workspace:*", "@webex/internal-plugin-device": "workspace:*", "@webex/internal-plugin-llm": "workspace:*", diff --git a/packages/calling/package.json b/packages/calling/package.json index d149f8f9b62..9ab50e04dff 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.9.2", "@webex/media-helpers": "workspace:*", "async-mutex": "0.4.0", "buffer": "6.0.3", diff --git a/yarn.lock b/yarn.lock index e2d039cfc0c..f789ccf87b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7419,7 +7419,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.9.2 "@webex/media-helpers": "workspace:*" async-mutex: 0.4.0 buffer: 6.0.3 @@ -7710,9 +7710,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.9.2": + version: 2.9.2 + resolution: "@webex/internal-media-core@npm:2.9.2" dependencies: "@babel/runtime": ^7.18.9 "@babel/runtime-corejs2": ^7.25.0 @@ -7724,7 +7724,7 @@ __metadata: uuid: ^8.3.2 webrtc-adapter: ^8.1.2 xstate: ^4.30.6 - checksum: 358a8b43ae55d6272fa07ca6e075cceb07a29d464c7e494ec130cd918848595eecd8e52ff12407790051af92b5e7d9612fdb4d7c6cf7a11231ce3bb390bd10f3 + checksum: 429ccc2a73ae226fb721f6fcd72f50653ded0fd54f8682542321d2b3dd7504a79d46c8746e2abf24d83c17401c0302c82bde4c73d232036afbbbcb544fd6639e languageName: node linkType: hard @@ -8483,7 +8483,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.9.2 "@webex/jest-config-legacy": "workspace:*" "@webex/legacy-tools": "workspace:*" "@webex/test-helper-chai": "workspace:*" @@ -8719,7 +8719,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.9.2 "@webex/internal-plugin-conversation": "workspace:*" "@webex/internal-plugin-device": "workspace:*" "@webex/internal-plugin-llm": "workspace:*" From 90cc9c20116ddf29b9ca68c2c4554d28b259ae64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Wa=C5=9Bniowski?= Date: Thu, 5 Sep 2024 10:58:54 +0200 Subject: [PATCH 04/22] fix(plugin-meetings): add to peer connection bare turn tcp server (#3808) --- .../@webex/plugin-meetings/src/media/index.ts | 13 + .../test/unit/spec/media/index.ts | 52 ++-- .../test/unit/spec/meeting/index.js | 222 ++++++++++++------ 3 files changed, 196 insertions(+), 91 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/media/index.ts b/packages/@webex/plugin-meetings/src/media/index.ts index 15d2376106e..a18040cdff8 100644 --- a/packages/@webex/plugin-meetings/src/media/index.ts +++ b/packages/@webex/plugin-meetings/src/media/index.ts @@ -163,6 +163,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 || '', 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..8a28348a348 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/media/index.ts @@ -16,39 +16,39 @@ describe('createMediaConnection', () => { 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', () => { @@ -80,7 +80,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 +91,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', }, @@ -146,7 +151,7 @@ describe('createMediaConnection', () => { }, }, turnServerInfo: { - url: 'turn server url', + url: 'turns:turn-server-url:443?transport=tcp', username: 'turn username', password: 'turn password', }, @@ -158,7 +163,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', }, @@ -171,7 +181,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 MultistreamRoapMediaConnection if ${testCase} (multistream enabled)`, () => { const multistreamRoapMediaConnectionConstructorStub = sinon @@ -198,7 +211,7 @@ describe('createMediaConnection', () => { iceServers: [], }, 'meeting id' - ); + ); }); }); @@ -232,7 +245,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 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..ea5fca28e99 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -625,7 +625,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 +658,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 +692,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 +728,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 +781,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 +826,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 +862,6 @@ describe('plugin-meetings', () => { reason: 'joinWithMedia failure', }); - // Behavioral metric is sent on both calls of joinWithMedia assert.calledTwice(Metrics.sendBehavioralMetric); assert.calledWith( @@ -1068,12 +1101,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 +1375,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 +1388,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 +1543,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 +1605,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 +1928,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 +1944,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), }; }); @@ -2485,7 +2514,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 +2718,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)); @@ -3445,9 +3480,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 +3597,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 +3621,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 +3717,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 +3751,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 +4199,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 +6640,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 +6729,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 +6740,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 +7534,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 +7560,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 +7625,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 +8335,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"', () => { From d5da303ff866c0fa3769f092696277537e82ca8c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Wa=C5=9Bniowski?= Date: Fri, 6 Sep 2024 16:52:07 +0200 Subject: [PATCH 05/22] fix: update internal media core (#3814) Co-authored-by: kwasniow --- packages/@webex/media-helpers/package.json | 2 +- packages/@webex/plugin-meetings/package.json | 2 +- packages/calling/package.json | 2 +- yarn.lock | 14 +++++++------- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/@webex/media-helpers/package.json b/packages/@webex/media-helpers/package.json index 512e8e6fe42..f0970bfb4d9 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.2", + "@webex/internal-media-core": "2.9.3", "@webex/ts-events": "^1.1.0", "@webex/web-media-effects": "2.18.0" }, diff --git a/packages/@webex/plugin-meetings/package.json b/packages/@webex/plugin-meetings/package.json index 31a709e3185..cabb064b969 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.2", + "@webex/internal-media-core": "2.9.3", "@webex/internal-plugin-conversation": "workspace:*", "@webex/internal-plugin-device": "workspace:*", "@webex/internal-plugin-llm": "workspace:*", diff --git a/packages/calling/package.json b/packages/calling/package.json index 9ab50e04dff..64df00bfc0b 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.2", + "@webex/internal-media-core": "2.9.3", "@webex/media-helpers": "workspace:*", "async-mutex": "0.4.0", "buffer": "6.0.3", diff --git a/yarn.lock b/yarn.lock index f789ccf87b5..1e007670e5f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7419,7 +7419,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.2 + "@webex/internal-media-core": 2.9.3 "@webex/media-helpers": "workspace:*" async-mutex: 0.4.0 buffer: 6.0.3 @@ -7710,9 +7710,9 @@ __metadata: languageName: unknown linkType: soft -"@webex/internal-media-core@npm:2.9.2": - version: 2.9.2 - resolution: "@webex/internal-media-core@npm:2.9.2" +"@webex/internal-media-core@npm:2.9.3": + version: 2.9.3 + resolution: "@webex/internal-media-core@npm:2.9.3" dependencies: "@babel/runtime": ^7.18.9 "@babel/runtime-corejs2": ^7.25.0 @@ -7724,7 +7724,7 @@ __metadata: uuid: ^8.3.2 webrtc-adapter: ^8.1.2 xstate: ^4.30.6 - checksum: 429ccc2a73ae226fb721f6fcd72f50653ded0fd54f8682542321d2b3dd7504a79d46c8746e2abf24d83c17401c0302c82bde4c73d232036afbbbcb544fd6639e + checksum: 568e54be4afa7f5a93a843f927c5af12828b781deebeb651f89e7f4639f149b7b1e1642a9f3c75d8d20a065838d170853f3ea90a0f57aed0601481d584e321b6 languageName: node linkType: hard @@ -8483,7 +8483,7 @@ __metadata: "@babel/preset-typescript": 7.22.11 "@webex/babel-config-legacy": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.9.2 + "@webex/internal-media-core": 2.9.3 "@webex/jest-config-legacy": "workspace:*" "@webex/legacy-tools": "workspace:*" "@webex/test-helper-chai": "workspace:*" @@ -8719,7 +8719,7 @@ __metadata: "@webex/babel-config-legacy": "workspace:*" "@webex/common": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.9.2 + "@webex/internal-media-core": 2.9.3 "@webex/internal-plugin-conversation": "workspace:*" "@webex/internal-plugin-device": "workspace:*" "@webex/internal-plugin-llm": "workspace:*" From 7e6755b407b5d08a41916f8f2f263ee6802ec6a4 Mon Sep 17 00:00:00 2001 From: Adhwaith Menon <111346225+adhmenon@users.noreply.github.com> Date: Mon, 9 Sep 2024 14:15:55 +0530 Subject: [PATCH 06/22] feat:(munge-sdp): updated-sdk-with-latest-media-core (#3815) --- packages/@webex/media-helpers/package.json | 2 +- packages/@webex/plugin-meetings/package.json | 2 +- packages/calling/package.json | 2 +- .../src/CallingClient/calling/call.test.ts | 27 +------------------ .../calling/src/CallingClient/calling/call.ts | 23 +--------------- yarn.lock | 14 +++++----- 6 files changed, 12 insertions(+), 58 deletions(-) diff --git a/packages/@webex/media-helpers/package.json b/packages/@webex/media-helpers/package.json index f0970bfb4d9..2c8eae9c839 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.3", + "@webex/internal-media-core": "2.10.0", "@webex/ts-events": "^1.1.0", "@webex/web-media-effects": "2.18.0" }, diff --git a/packages/@webex/plugin-meetings/package.json b/packages/@webex/plugin-meetings/package.json index cabb064b969..e8f6d152c15 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.3", + "@webex/internal-media-core": "2.10.0", "@webex/internal-plugin-conversation": "workspace:*", "@webex/internal-plugin-device": "workspace:*", "@webex/internal-plugin-llm": "workspace:*", diff --git a/packages/calling/package.json b/packages/calling/package.json index 64df00bfc0b..3638a90ad9b 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.3", + "@webex/internal-media-core": "2.10.0", "@webex/media-helpers": "workspace:*", "async-mutex": "0.4.0", "buffer": "6.0.3", 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/yarn.lock b/yarn.lock index 1e007670e5f..01fd898c89c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7419,7 +7419,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.3 + "@webex/internal-media-core": 2.10.0 "@webex/media-helpers": "workspace:*" async-mutex: 0.4.0 buffer: 6.0.3 @@ -7710,9 +7710,9 @@ __metadata: languageName: unknown linkType: soft -"@webex/internal-media-core@npm:2.9.3": - version: 2.9.3 - resolution: "@webex/internal-media-core@npm:2.9.3" +"@webex/internal-media-core@npm:2.10.0": + version: 2.10.0 + resolution: "@webex/internal-media-core@npm:2.10.0" dependencies: "@babel/runtime": ^7.18.9 "@babel/runtime-corejs2": ^7.25.0 @@ -7724,7 +7724,7 @@ __metadata: uuid: ^8.3.2 webrtc-adapter: ^8.1.2 xstate: ^4.30.6 - checksum: 568e54be4afa7f5a93a843f927c5af12828b781deebeb651f89e7f4639f149b7b1e1642a9f3c75d8d20a065838d170853f3ea90a0f57aed0601481d584e321b6 + checksum: 4191b75de30a22c131dba7cbc4922cf5da52ad799ab059391539355f50dcdf8aa02fb2fd738a5bbf4938a54bc869e00ba7f74fdd58e04f231825665fd7c05ce2 languageName: node linkType: hard @@ -8483,7 +8483,7 @@ __metadata: "@babel/preset-typescript": 7.22.11 "@webex/babel-config-legacy": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.9.3 + "@webex/internal-media-core": 2.10.0 "@webex/jest-config-legacy": "workspace:*" "@webex/legacy-tools": "workspace:*" "@webex/test-helper-chai": "workspace:*" @@ -8719,7 +8719,7 @@ __metadata: "@webex/babel-config-legacy": "workspace:*" "@webex/common": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.9.3 + "@webex/internal-media-core": 2.10.0 "@webex/internal-plugin-conversation": "workspace:*" "@webex/internal-plugin-device": "workspace:*" "@webex/internal-plugin-llm": "workspace:*" From 77ac358eb87bc52fe4a228e18650fb94cdf88e07 Mon Sep 17 00:00:00 2001 From: Coread Date: Mon, 9 Sep 2024 16:14:35 +0100 Subject: [PATCH 07/22] fix(plugin-authorization): use orgId if emailhash unavailable (#3788) --- .../src/authorization.js | 28 +++++++- .../test/unit/spec/authorization.js | 65 ++++++++++++++++++- 2 files changed, 89 insertions(+), 4 deletions(-) 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); + }) + }); }); }); From 6a34e63a2111b75b152448ac789eba0cd1f4c486 Mon Sep 17 00:00:00 2001 From: Coread Date: Tue, 10 Sep 2024 13:36:08 +0100 Subject: [PATCH 08/22] feat(webex-core): hostmap interceptor (#3789) --- packages/@webex/webex-core/src/index.js | 1 + .../webex-core/src/lib/services/index.js | 1 + .../src/lib/services/interceptors/hostmap.js | 36 +++++++++ .../webex-core/src/lib/services/services.js | 27 +++++++ packages/@webex/webex-core/src/webex-core.js | 2 + .../spec/services/interceptors/hostmap.js | 79 +++++++++++++++++++ .../test/unit/spec/services/services.js | 55 +++++++++++++ 7 files changed, 201 insertions(+) create mode 100644 packages/@webex/webex-core/src/lib/services/interceptors/hostmap.js create mode 100644 packages/@webex/webex-core/test/unit/spec/services/interceptors/hostmap.js 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; From de1524898654ea4b4edceb8a7251cae1fa4ae49a Mon Sep 17 00:00:00 2001 From: Sreekanth Narayanan <131740035+sreenara@users.noreply.github.com> Date: Wed, 11 Sep 2024 14:25:23 +0530 Subject: [PATCH 09/22] feat(contacts): move contacts to scim from dss (#3819) --- docs/samples/calling/app.js | 4 + .../CallingClient/calling/CallerId/types.ts | 27 +-- .../calling/src/CallingClient/constants.ts | 4 - .../src/Contacts/ContactsClient.test.ts | 69 +++++- .../calling/src/Contacts/ContactsClient.ts | 154 ++++++------- packages/calling/src/Contacts/constants.ts | 2 + .../calling/src/Contacts/contactFixtures.ts | 204 +++++++++++++++--- packages/calling/src/Contacts/types.ts | 18 +- packages/calling/src/SDKConnector/types.ts | 14 +- packages/calling/src/common/Utils.test.ts | 17 +- packages/calling/src/common/Utils.ts | 14 +- packages/calling/src/common/constants.ts | 5 + packages/calling/src/common/testUtil.ts | 5 - packages/calling/src/common/types.ts | 90 +++++--- packages/webex/src/calling.js | 1 - 15 files changed, 405 insertions(+), 223 deletions(-) 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/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/constants.ts b/packages/calling/src/CallingClient/constants.ts index b395a335a5d..3e325e2c454 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; diff --git a/packages/calling/src/Contacts/ContactsClient.test.ts b/packages/calling/src/Contacts/ContactsClient.test.ts index ee1068f02eb..304ab3c5bf4 100644 --- a/packages/calling/src/Contacts/ContactsClient.test.ts +++ b/packages/calling/src/Contacts/ContactsClient.test.ts @@ -1,9 +1,15 @@ -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, +} from '../common/constants'; import log from '../Logger'; import { CONTACTS_FILE, @@ -24,12 +30,12 @@ import { mockContactResponseBodyOne, mockCountry, mockDisplayNameOne, - mockDSSResponse, mockEmail, mockFirstName, mockLastName, mockNumber1, mockNumber2, + mockSCIMListResponse, mockSipAddress, mockState, mockStreet, @@ -51,6 +57,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.internal.services._serviceUrls.identity}/${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 +97,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 +127,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 +213,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 +225,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 +601,8 @@ describe('ContactClient Tests', () => { webex.request .mockResolvedValueOnce(successResponsePayloadGroup) - .mockResolvedValueOnce(successResponsePayload); + .mockResolvedValueOnce(successResponsePayload) + .mockResolvedValueOnce(mockSCIMListResponse); webex.internal.encryption.encryptText.mockResolvedValueOnce('Encrypted group name'); @@ -581,14 +620,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 +648,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 () => { diff --git a/packages/calling/src/Contacts/ContactsClient.ts b/packages/calling/src/Contacts/ContactsClient.ts index d701f7d8f0e..28bf3c1a934 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,7 +35,7 @@ import { GroupType, } from './types'; -import {serviceErrorCodeHandler} from '../common/Utils'; +import {scimQuery, serviceErrorCodeHandler} from '../common/Utils'; import Logger from '../Logger'; import ExtendedError from '../Errors/catalog/ExtendedError'; import {ERROR_TYPE} from '../Errors/types'; @@ -66,7 +74,6 @@ export class ContactsClient implements IContacts { } this.webex = this.sdkConnector.getWebex(); - this.webex.internal.dss.register(); this.encryptionKeyUrl = ''; this.groups = undefined; @@ -253,75 +260,59 @@ 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; + inputList: SCIMListResponse + ): Contact[] | null { + const finalContactList: Contact[] = []; - if (addedPhoneNumbers) { - const decryptedPhoneNumbers = await this.decryptContactDetail( - encryptionKeyUrl, - addedPhoneNumbers - ); - - decryptedPhoneNumbers.forEach((number) => phoneNumbers.push(number)); - } - - const addedSipAddresses = contactsDataMap[contactId].sipAddresses; + try { + const contactList = Object.keys(contactsDataMap); + for (let n = 0; n < contactList.length; n += 1) { + const filteredContact = inputList.Resources.filter((item) => item.id === contactList[n])[0]; + + const {displayName, emails, phoneNumbers, photos} = filteredContact; + const {sipAddresses} = filteredContact[SCIM_WEBEXIDENTITY_USER]; + 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; + + let avatarURL = ''; + if (photos?.length) { + avatarURL = photos[0].value; + } - if (addedSipAddresses) { - const decryptedSipAddresses = await this.decryptContactDetail( + const {contactType, avatarUrlDomain, encryptionKeyUrl, ownerId, groups} = + contactsDataMap[contactList[n]]; + + const cloudContact = { + avatarUrlDomain, + avatarURL, + contactId: contactList[n], + 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), {}); return null; } + + return finalContactList; } /** @@ -334,7 +325,7 @@ export class ContactsClient implements IContacts { }; const contactList: Contact[] = []; - const contactsDataMap: ContactIdContactInfo = {}; + const cloudContactsMap: ContactIdContactInfo = {}; try { const response = await this.webex.request({ @@ -351,19 +342,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 +698,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..605925e7010 100644 --- a/packages/calling/src/Contacts/contactFixtures.ts +++ b/packages/calling/src/Contacts/contactFixtures.ts @@ -272,39 +272,187 @@ 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', +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, }, - jobTitle: 'Software Engineer', - lastName: 'Nagakawa', - modified: '2023-03-03T13:37:03.196Z', - nickName: 'Emily', - userName: 'emikawa2@cisco.com', + ], + 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', + }, + }, + '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', }, - displayName: 'Emily Nagakawa', - emails: [{value: 'emikawa2@cisco.com'}], - entityProviderType: 'CI_USER', - identity: '801bb994-343b-4f6b-97ae-d13c91d4b877', - orgId: '1eb65fdf-9643-417f-9974-ad72cae0e10f', +}; + +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: 11, + itemsPerPage: 11, + startIndex: 1, + Resources: [scimUser1, scimUser2NoPhoto], + }, }; export const mockKmsKey = { diff --git a/packages/calling/src/Contacts/types.ts b/packages/calling/src/Contacts/types.ts index 2c9d2e16fbf..7c4484117a2 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. */ @@ -53,7 +45,7 @@ export type Contact = { /** * 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/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..3014240caa8 100644 --- a/packages/calling/src/common/Utils.test.ts +++ b/packages/calling/src/common/Utils.test.ts @@ -20,15 +20,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 +46,12 @@ 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, +} from './constants'; import {CALL_EVENT_KEYS} from '../Events/types'; const mockSubmitRegistrationMetric = jest.fn(); diff --git a/packages/calling/src/common/Utils.ts b/packages/calling/src/common/Utils.ts index d2a3d5b4763..68b7fb0211b 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, @@ -113,9 +110,11 @@ import { NATIVE_WEBEX_TEAMS_CALLING, NATIVE_SIP_CALL_TO_UCM, BW_XSI_ENDPOINT_VERSION, + IDENTITY_ENDPOINT_RESOURCE, + SCIM_ENDPOINT_RESOURCE, + SCIM_USER_FILTER, } 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'; @@ -1177,7 +1176,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', @@ -1195,7 +1194,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 +1209,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, diff --git a/packages/calling/src/common/constants.ts b/packages/calling/src/common/constants.ts index 92d37654651..8166e6e25aa 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,5 @@ 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'; diff --git a/packages/calling/src/common/testUtil.ts b/packages/calling/src/common/testUtil.ts index 8dd11ab0909..0a8f2195f72 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(), }, diff --git a/packages/calling/src/common/types.ts b/packages/calling/src/common/types.ts index 5a592b6b959..0e708ccd3e8 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; From eaa8f489a3cfd314fa35030d876f4e34c5661b2b Mon Sep 17 00:00:00 2001 From: Marcin Date: Thu, 12 Sep 2024 08:24:29 +0100 Subject: [PATCH 10/22] fix(meetings): sending ice candidate errors with add media success metric (#3823) --- packages/@webex/plugin-meetings/src/meeting/index.ts | 2 ++ .../@webex/plugin-meetings/test/unit/spec/meeting/index.js | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index e255e3f633b..35fd4e3a04f 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -7022,6 +7022,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 +7034,7 @@ export default class Meeting extends StatelessWebexPlugin { retriedWithTurnServer: this.addMediaData.retriedWithTurnServer, isJoinWithMediaRetry: this.joinWithMediaRetryInfo.isRetry, ...reachabilityStats, + ...iceCandidateErrors, iceCandidatesCount: this.iceCandidatesCount, }); // @ts-ignore 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 ea5fca28e99..2a7e9eff420 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -3023,6 +3023,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: {}, @@ -3044,6 +3046,8 @@ describe('plugin-meetings', () => { someReachabilityMetric1: 'some value1', someReachabilityMetric2: 'some value2', iceCandidatesCount: 3, + '701_error': 3, + '701_turn_host_lookup_received_error': 1, } ); From 3f6f3752b38ecfe1c5d726f1742a43cc791809b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edmond=20Vuji=C4=87i?= <67634227+edvujic@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:20:42 +0200 Subject: [PATCH 11/22] refactor: remove network quality monitor (#3817) Co-authored-by: evujici Co-authored-by: Anna Tsukanova --- packages/@webex/media-helpers/package.json | 2 +- packages/@webex/plugin-meetings/package.json | 2 +- .../plugin-meetings/src/meeting/index.ts | 7 +- .../src/networkQualityMonitor/index.ts | 211 ------------------ .../unit/spec/networkQualityMonitor/index.js | 99 -------- packages/calling/package.json | 2 +- yarn.lock | 96 ++++---- 7 files changed, 55 insertions(+), 364 deletions(-) delete mode 100644 packages/@webex/plugin-meetings/src/networkQualityMonitor/index.ts delete mode 100644 packages/@webex/plugin-meetings/test/unit/spec/networkQualityMonitor/index.js diff --git a/packages/@webex/media-helpers/package.json b/packages/@webex/media-helpers/package.json index 2c8eae9c839..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.10.0", + "@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-meetings/package.json b/packages/@webex/plugin-meetings/package.json index e8f6d152c15..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.10.0", + "@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/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index 35fd4e3a04f..1b7b9099c4d 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'; @@ -6500,7 +6501,7 @@ export default class Meeting extends StatelessWebexPlugin { }); this.setupStatsAnalyzerEventHandlers(); this.networkQualityMonitor.on( - EVENT_TRIGGERS.NETWORK_QUALITY, + NetworkQualityEventNames.NETWORK_QUALITY, this.sendNetworkQualityEvent.bind(this) ); } @@ -8193,7 +8194,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/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/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/calling/package.json b/packages/calling/package.json index 3638a90ad9b..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.10.0", + "@webex/internal-media-core": "2.10.2", "@webex/media-helpers": "workspace:*", "async-mutex": "0.4.0", "buffer": "6.0.3", diff --git a/yarn.lock b/yarn.lock index 01fd898c89c..081ac9f3761 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 @@ -5921,11 +5921,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 @@ -7419,7 +7419,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.10.0 + "@webex/internal-media-core": 2.10.2 "@webex/media-helpers": "workspace:*" async-mutex: 0.4.0 buffer: 6.0.3 @@ -7710,9 +7710,9 @@ __metadata: languageName: unknown linkType: soft -"@webex/internal-media-core@npm:2.10.0": - version: 2.10.0 - resolution: "@webex/internal-media-core@npm:2.10.0" +"@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 @@ -7724,7 +7724,7 @@ __metadata: uuid: ^8.3.2 webrtc-adapter: ^8.1.2 xstate: ^4.30.6 - checksum: 4191b75de30a22c131dba7cbc4922cf5da52ad799ab059391539355f50dcdf8aa02fb2fd738a5bbf4938a54bc869e00ba7f74fdd58e04f231825665fd7c05ce2 + checksum: 76fe4feba27b6734fde24dd0b84f0a43d24b6faf9110ced0ab0c8122746ffa27b50cd5862dde1192750432f2daff618c6ca0cb4ca9496e1b9a2ba47788a58174 languageName: node linkType: hard @@ -8483,7 +8483,7 @@ __metadata: "@babel/preset-typescript": 7.22.11 "@webex/babel-config-legacy": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.10.0 + "@webex/internal-media-core": 2.10.2 "@webex/jest-config-legacy": "workspace:*" "@webex/legacy-tools": "workspace:*" "@webex/test-helper-chai": "workspace:*" @@ -8719,7 +8719,7 @@ __metadata: "@webex/babel-config-legacy": "workspace:*" "@webex/common": "workspace:*" "@webex/eslint-config-legacy": "workspace:*" - "@webex/internal-media-core": 2.10.0 + "@webex/internal-media-core": 2.10.2 "@webex/internal-plugin-conversation": "workspace:*" "@webex/internal-plugin-device": "workspace:*" "@webex/internal-plugin-llm": "workspace:*" @@ -9012,12 +9012,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 @@ -16976,13 +16976,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 @@ -31144,7 +31144,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 @@ -33307,37 +33307,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 From 8573ece085707036204d4ae11f46f5d76c9b4a5d Mon Sep 17 00:00:00 2001 From: Sreekanth Narayanan <131740035+sreenara@users.noreply.github.com> Date: Fri, 13 Sep 2024 13:51:37 +0530 Subject: [PATCH 12/22] fix(contacts): handle optional scim fields (#3827) --- .../src/Contacts/ContactsClient.test.ts | 38 +++++++++++++++++++ .../calling/src/Contacts/ContactsClient.ts | 26 ++++++------- .../calling/src/Contacts/contactFixtures.ts | 30 ++++++++++++++- packages/calling/src/Contacts/types.ts | 4 +- packages/calling/src/common/Utils.test.ts | 24 ++++++++++++ packages/calling/src/common/Utils.ts | 6 +-- packages/calling/src/common/testUtil.ts | 26 +++++++++++++ packages/calling/src/common/types.ts | 24 ++++++------ 8 files changed, 146 insertions(+), 32 deletions(-) diff --git a/packages/calling/src/Contacts/ContactsClient.test.ts b/packages/calling/src/Contacts/ContactsClient.test.ts index 304ab3c5bf4..01b6fc6b4a7 100644 --- a/packages/calling/src/Contacts/ContactsClient.test.ts +++ b/packages/calling/src/Contacts/ContactsClient.test.ts @@ -48,6 +48,8 @@ import { mockContactGroupListOne, mockContactGroupListTwo, mockAvatarURL, + mockSCIMMinListResponse, + mockContactMinimum, } from './contactFixtures'; describe('ContactClient Tests', () => { @@ -724,4 +726,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 28bf3c1a934..5bb05d67899 100644 --- a/packages/calling/src/Contacts/ContactsClient.ts +++ b/packages/calling/src/Contacts/ContactsClient.ts @@ -36,9 +36,6 @@ import { } from './types'; import {scimQuery, serviceErrorCodeHandler} from '../common/Utils'; -import Logger from '../Logger'; -import ExtendedError from '../Errors/catalog/ExtendedError'; -import {ERROR_TYPE} from '../Errors/types'; /** * `ContactsClient` module is designed to offer a set of APIs for retrieving and updating contacts and groups from the contacts-service. @@ -264,6 +261,10 @@ export class ContactsClient implements IContacts { contactsDataMap: ContactIdContactInfo, inputList: SCIMListResponse ): Contact[] | null { + const loggerContext = { + file: CONTACTS_FILE, + method: 'resolveCloudContacts', + }; const finalContactList: Contact[] = []; try { @@ -272,16 +273,15 @@ export class ContactsClient implements IContacts { const filteredContact = inputList.Resources.filter((item) => item.id === contactList[n])[0]; const {displayName, emails, phoneNumbers, photos} = filteredContact; - const {sipAddresses} = filteredContact[SCIM_WEBEXIDENTITY_USER]; - 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; - - let avatarURL = ''; - if (photos?.length) { - avatarURL = photos[0].value; + 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[contactList[n]]; @@ -307,7 +307,7 @@ export class ContactsClient implements IContacts { finalContactList.push(cloudContact); } } catch (error: any) { - Logger.error(new ExtendedError(error.message, {}, ERROR_TYPE.DEFAULT), {}); + log.warn('Error occurred while parsing resolved contacts', loggerContext); return null; } diff --git a/packages/calling/src/Contacts/contactFixtures.ts b/packages/calling/src/Contacts/contactFixtures.ts index 605925e7010..4295a607411 100644 --- a/packages/calling/src/Contacts/contactFixtures.ts +++ b/packages/calling/src/Contacts/contactFixtures.ts @@ -272,6 +272,21 @@ export const mockContactGroupListTwo = [ }, ]; +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', @@ -448,13 +463,24 @@ export const mockSCIMListResponse = { statusCode: 200, body: { schemas: ['urn:ietf:params:scim:api:messages:2.0:ListResponse'], - totalResults: 11, - itemsPerPage: 11, + 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 = { uri: 'kms://kms-cisco.wbx2.com/keys/16095024-612d-4424-ba51-57cad2402e14', }; diff --git a/packages/calling/src/Contacts/types.ts b/packages/calling/src/Contacts/types.ts index 7c4484117a2..443f0149034 100644 --- a/packages/calling/src/Contacts/types.ts +++ b/packages/calling/src/Contacts/types.ts @@ -29,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`. */ @@ -41,7 +41,7 @@ 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. */ diff --git a/packages/calling/src/common/Utils.test.ts b/packages/calling/src/common/Utils.test.ts index 3014240caa8..f35d45ef89b 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, @@ -1048,6 +1049,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 68b7fb0211b..44e4577d902 100644 --- a/packages/calling/src/common/Utils.ts +++ b/packages/calling/src/common/Utils.ts @@ -1234,12 +1234,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/testUtil.ts b/packages/calling/src/common/testUtil.ts index 0a8f2195f72..dff60f0e25f 100644 --- a/packages/calling/src/common/testUtil.ts +++ b/packages/calling/src/common/testUtil.ts @@ -260,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 0e708ccd3e8..02605e6a1f0 100644 --- a/packages/calling/src/common/types.ts +++ b/packages/calling/src/common/types.ts @@ -220,8 +220,8 @@ interface WebexIdentityMeta { organizationId: string; } interface WebexIdentityUser { - sipAddresses: URIAddress[]; - meta: WebexIdentityMeta; + sipAddresses?: URIAddress[]; + meta?: WebexIdentityMeta; } interface Manager { @@ -231,24 +231,24 @@ interface Manager { } interface EnterpriseUser { - department: string; - manager: Manager; + department?: string; + manager?: Manager; } interface Resource { schemas: string[]; id: string; userName: string; - active: boolean; - name: Name; - displayName: string; - emails: URIAddress[]; + active?: boolean; + name?: Name; + displayName?: string; + emails?: URIAddress[]; userType: string; - phoneNumbers: PhoneNumber[]; + phoneNumbers?: PhoneNumber[]; photos?: ContactDetail[]; - addresses: Address[]; - [SCIM_WEBEXIDENTITY_USER]: WebexIdentityUser; - [SCIM_ENTERPRISE_USER]: EnterpriseUser; + addresses?: Address[]; + [SCIM_WEBEXIDENTITY_USER]?: WebexIdentityUser; + [SCIM_ENTERPRISE_USER]?: EnterpriseUser; } export interface SCIMListResponse { From 523ae6d0cea6f208794e87bc6622e728a7bf73a1 Mon Sep 17 00:00:00 2001 From: Sreekanth Narayanan <131740035+sreenara@users.noreply.github.com> Date: Fri, 13 Sep 2024 17:09:33 +0530 Subject: [PATCH 13/22] fix(contacts): fix loop and use only resolved contacts from scim (#3829) --- packages/calling/src/Contacts/ContactsClient.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/calling/src/Contacts/ContactsClient.ts b/packages/calling/src/Contacts/ContactsClient.ts index 5bb05d67899..efb2add327e 100644 --- a/packages/calling/src/Contacts/ContactsClient.ts +++ b/packages/calling/src/Contacts/ContactsClient.ts @@ -268,10 +268,8 @@ export class ContactsClient implements IContacts { const finalContactList: Contact[] = []; try { - const contactList = Object.keys(contactsDataMap); - for (let n = 0; n < contactList.length; n += 1) { - const filteredContact = inputList.Resources.filter((item) => item.id === contactList[n])[0]; - + 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]) { @@ -284,12 +282,12 @@ export class ContactsClient implements IContacts { const avatarURL = photos?.length ? photos[0].value : ''; const {contactType, avatarUrlDomain, encryptionKeyUrl, ownerId, groups} = - contactsDataMap[contactList[n]]; + contactsDataMap[inputList.Resources[n].id]; const cloudContact = { avatarUrlDomain, avatarURL, - contactId: contactList[n], + contactId: inputList.Resources[n].id, contactType, department, displayName, From 1de73b3c9083efefdd823361db5648e030e9bb10 Mon Sep 17 00:00:00 2001 From: Sreekanth Narayanan <131740035+sreenara@users.noreply.github.com> Date: Sat, 14 Sep 2024 18:33:51 +0530 Subject: [PATCH 14/22] fix(contacts): use webexapis.com url to avoid cors (#3834) --- .../src/CallSettings/UcmBackendConnector.test.ts | 11 ++++++++--- .../src/CallSettings/UcmBackendConnector.ts | 14 ++++++-------- packages/calling/src/CallSettings/constants.ts | 2 -- .../calling/src/Contacts/ContactsClient.test.ts | 3 ++- packages/calling/src/common/Utils.test.ts | 3 ++- packages/calling/src/common/Utils.ts | 7 ++++++- packages/calling/src/common/constants.ts | 4 ++++ 7 files changed, 28 insertions(+), 16 deletions(-) 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/Contacts/ContactsClient.test.ts b/packages/calling/src/Contacts/ContactsClient.test.ts index 01b6fc6b4a7..404023aaba4 100644 --- a/packages/calling/src/Contacts/ContactsClient.test.ts +++ b/packages/calling/src/Contacts/ContactsClient.test.ts @@ -9,6 +9,7 @@ import { SCIM_ENDPOINT_RESOURCE, SCIM_USER_FILTER, SUCCESS_MESSAGE, + WEBEX_API_BTS, } from '../common/constants'; import log from '../Logger'; import { @@ -59,7 +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.internal.services._serviceUrls.identity}/${IDENTITY_ENDPOINT_RESOURCE}/${SCIM_ENDPOINT_RESOURCE}/${webex.internal.device.orgId}/${SCIM_USER_FILTER}id%20eq%20%22801bb994-343b-4f6b-97ae-d13c91d4b877%22`; + 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'); diff --git a/packages/calling/src/common/Utils.test.ts b/packages/calling/src/common/Utils.test.ts index f35d45ef89b..06b70c2c098 100644 --- a/packages/calling/src/common/Utils.test.ts +++ b/packages/calling/src/common/Utils.test.ts @@ -52,6 +52,7 @@ import { INFER_ID_CONSTANT, SCIM_ENDPOINT_RESOURCE, SCIM_USER_FILTER, + WEBEX_API_BTS, } from './constants'; import {CALL_EVENT_KEYS} from '../Events/types'; @@ -913,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; diff --git a/packages/calling/src/common/Utils.ts b/packages/calling/src/common/Utils.ts index 44e4577d902..5c5ee2434ff 100644 --- a/packages/calling/src/common/Utils.ts +++ b/packages/calling/src/common/Utils.ts @@ -113,6 +113,8 @@ import { IDENTITY_ENDPOINT_RESOURCE, SCIM_ENDPOINT_RESOURCE, SCIM_USER_FILTER, + WEBEX_API_PROD, + WEBEX_API_BTS, } from './constants'; import {Model, WebexSDK} from '../SDKConnector/types'; import SDKConnector from '../SDKConnector'; @@ -1184,7 +1186,10 @@ export 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({ diff --git a/packages/calling/src/common/constants.ts b/packages/calling/src/common/constants.ts index 8166e6e25aa..a71f3de888a 100644 --- a/packages/calling/src/common/constants.ts +++ b/packages/calling/src/common/constants.ts @@ -44,3 +44,7 @@ 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`; From 8fa3283f9fabaf84624794a8fe4f9838f77a1edf Mon Sep 17 00:00:00 2001 From: Marcin Date: Mon, 16 Sep 2024 10:40:29 +0100 Subject: [PATCH 15/22] fix(meetings): empty webrtc dumps when user closes the browser early (#3830) --- .../plugin-meetings/src/rtcMetrics/index.ts | 18 ++++++++--- .../test/unit/spec/rtcMetrics/index.ts | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/rtcMetrics/index.ts b/packages/@webex/plugin-meetings/src/rtcMetrics/index.ts index a86c3eb9f33..5206306842d 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; + initialMetricsSent: 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(); } /** @@ -79,6 +79,13 @@ export default class RtcMetrics { this.metricsQueue.push(data); + if (!this.initialMetricsSent && 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.initialMetricsSent = true; + } + 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 +95,7 @@ export default class RtcMetrics { parsedPayload.value === 'failed' ) { this.sendMetricsInQueue(); - this.setNewConnectionId(); + this.resetConnection(); } } catch (e) { console.error(e); @@ -130,8 +137,9 @@ export default class RtcMetrics { * * @returns {void} */ - private setNewConnectionId() { + private resetConnection() { this.connectionId = uuid.v4(); + this.initialMetricsSent = false; } /** 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); + }); }); From cd652adc63e66562f4dfd5747508bc9dcaa3d3ce Mon Sep 17 00:00:00 2001 From: RAM1232 <46831391+RAM1232@users.noreply.github.com> Date: Mon, 16 Sep 2024 16:38:44 +0530 Subject: [PATCH 16/22] docs(samples): add support for locus calls and resolve join button issue (#3804) --- docs/samples/browser-plugin-meetings/app.js | 21 ++++++++++++++++++- .../browser-plugin-meetings/index.html | 4 ++-- 2 files changed, 22 insertions(+), 3 deletions(-) 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 @@

+ From 9bb81a2629d35642e4f177676263b1f5ba3beb20 Mon Sep 17 00:00:00 2001 From: Marcin Date: Mon, 16 Sep 2024 16:04:42 +0100 Subject: [PATCH 17/22] fix(meetings): webrtc dumps are sometimes empty when users join a meeting via lobby (#3836) --- .../@webex/plugin-meetings/src/media/index.ts | 14 ++--- .../plugin-meetings/src/meeting/index.ts | 12 +++-- .../plugin-meetings/src/rtcMetrics/index.ts | 20 ++++++-- .../test/unit/spec/media/index.ts | 40 ++++++++++++--- .../test/unit/spec/meeting/index.js | 51 ++++++++++++++++--- 5 files changed, 108 insertions(+), 29 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/media/index.ts b/packages/@webex/plugin-meetings/src/media/index.ts index a18040cdff8..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, @@ -192,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 1b7b9099c4d..8a5d0ed05f2 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -155,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'; @@ -696,6 +697,7 @@ export default class Meeting extends StatelessWebexPlugin { private connectionStateHandler?: ConnectionStateHandler; private iceCandidateErrors: Map; private iceCandidatesCount: number; + private rtcMetrics?: RtcMetrics; /** * @param {Object} attrs @@ -3156,6 +3158,7 @@ export default class Meeting extends StatelessWebexPlugin { options: {meetingId: this.id}, }); } + this.rtcMetrics?.sendNextMetrics(); this.updateLLMConnection(); }); @@ -6307,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 diff --git a/packages/@webex/plugin-meetings/src/rtcMetrics/index.ts b/packages/@webex/plugin-meetings/src/rtcMetrics/index.ts index 5206306842d..6e9f6790011 100644 --- a/packages/@webex/plugin-meetings/src/rtcMetrics/index.ts +++ b/packages/@webex/plugin-meetings/src/rtcMetrics/index.ts @@ -34,7 +34,7 @@ export default class RtcMetrics { connectionId: string; - initialMetricsSent: boolean; + shouldSendMetricsOnNextStatsReport: boolean; /** * Initialize the interval. @@ -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,11 +91,11 @@ export default class RtcMetrics { this.metricsQueue.push(data); - if (!this.initialMetricsSent && data.name === 'stats-report') { + 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.initialMetricsSent = true; + this.shouldSendMetricsOnNextStatsReport = false; } try { @@ -139,7 +151,7 @@ export default class RtcMetrics { */ private resetConnection() { this.connectionId = uuid.v4(); - this.initialMetricsSent = false; + this.shouldSendMetricsOnNextStatsReport = 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 8a28348a348..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,14 +3,12 @@ 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', @@ -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, @@ -139,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, @@ -150,6 +154,7 @@ describe('createMediaConnection', () => { receiveShare: true, }, }, + rtcMetrics, turnServerInfo: { url: 'turns:turn-server-url:443?transport=tcp', username: 'turn username', @@ -177,6 +182,27 @@ 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); + }); [ @@ -191,7 +217,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, @@ -220,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, @@ -260,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 2a7e9eff420..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'; @@ -2405,9 +2407,7 @@ describe('plugin-meetings', () => { Media.createMediaConnection, false, meeting.getMediaConnectionDebugId(), - webex, meeting.id, - meeting.correlationId, sinon.match({turnServerInfo: undefined}) ); assert.calledOnce(meeting.setMercuryListener); @@ -2449,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'; @@ -2478,9 +2516,7 @@ describe('plugin-meetings', () => { Media.createMediaConnection, false, meeting.getMediaConnectionDebugId(), - webex, meeting.id, - meeting.correlationId, sinon.match({ turnServerInfo: { url: FAKE_TURN_URL, @@ -3401,9 +3437,7 @@ describe('plugin-meetings', () => { Media.createMediaConnection, false, meeting.getMediaConnectionDebugId(), - webex, meeting.id, - meeting.correlationId, sinon.match({ turnServerInfo: { url: FAKE_TURN_URL, @@ -8454,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); @@ -8465,6 +8502,8 @@ describe('plugin-meetings', () => { {payload: test1} ); assert.calledOnce(meeting.updateLLMConnection); + assert.calledOnceWithExactly(meeting.rtcMetrics.sendNextMetrics); + done(); }); From 4c321d2e115c722d18713946b7565161f6aed3d4 Mon Sep 17 00:00:00 2001 From: chrisadubois Date: Mon, 16 Sep 2024 09:33:21 -0700 Subject: [PATCH 18/22] Upstreamsvcs (#3832) --- .../internal-plugin-device/src/config.js | 6 ++ .../internal-plugin-device/src/device.js | 27 ++++++++ .../test/unit/spec/device.js | 68 ++++++++++++++++++- 3 files changed, 99 insertions(+), 2 deletions(-) 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()', () => { From d927e6f6a0c46a8d2ece9100f5a5f57366d1125b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kacper=20Wa=C5=9Bniowski?= Date: Tue, 17 Sep 2024 10:07:26 +0200 Subject: [PATCH 19/22] fix(plugin-meetings): add retry mechanism to fetching clusters (#3813) --- .../plugin-meetings/src/reachability/index.ts | 30 ++++++++- .../test/unit/spec/reachability/index.ts | 67 ++++++++++++++++++- 2 files changed, 92 insertions(+), 5 deletions(-) diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts index 7f67e44ff74..c51243e0024 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,7 @@ 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) - ); + const {clusters, joinCookie} = await this.getClusters(); // @ts-ignore await this.webex.boundedStorage.put( 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..e243f4c5199 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; @@ -1466,6 +1465,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', () => { From 11f97f5b80888b8c6eb6aea3f72b33c14d620bc2 Mon Sep 17 00:00:00 2001 From: Marcin Date: Tue, 17 Sep 2024 16:24:40 +0100 Subject: [PATCH 20/22] feat(meetings): do ip version detection in parallel with reachability (#3838) --- .../plugin-meetings/src/reachability/index.ts | 15 +++ .../test/unit/spec/reachability/index.ts | 99 ++++++++++++++++++- 2 files changed, 113 insertions(+), 1 deletion(-) diff --git a/packages/@webex/plugin-meetings/src/reachability/index.ts b/packages/@webex/plugin-meetings/src/reachability/index.ts index c51243e0024..17b60313ab0 100644 --- a/packages/@webex/plugin-meetings/src/reachability/index.ts +++ b/packages/@webex/plugin-meetings/src/reachability/index.ts @@ -149,6 +149,11 @@ export default class Reachability extends EventsScope { public async gatherReachability(): Promise { // Fetch clusters and measure latency try { + // 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 @@ -537,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/test/unit/spec/reachability/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts index e243f4c5199..2e400817b5e 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/reachability/index.ts @@ -485,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 = {}; @@ -1034,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: { @@ -1118,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, From c04a29f0ef5fe4f2980a443391fae59244692f55 Mon Sep 17 00:00:00 2001 From: Rajesh Kumar <131742425+rarajes2@users.noreply.github.com> Date: Wed, 18 Sep 2024 10:01:47 +0530 Subject: [PATCH 21/22] feat(calling): update failover logic for cc (#3805) --- .../calling/src/CallingClient/constants.ts | 1 + .../registration/register.test.ts | 107 +++++++++++++++++- .../CallingClient/registration/register.ts | 19 +++- 3 files changed, 118 insertions(+), 9 deletions(-) diff --git a/packages/calling/src/CallingClient/constants.ts b/packages/calling/src/CallingClient/constants.ts index 3e325e2c454..c3b8cc87fc4 100644 --- a/packages/calling/src/CallingClient/constants.ts +++ b/packages/calling/src/CallingClient/constants.ts @@ -107,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(); From e42b39053f93f453f497aac0c6b1bdf16fc0e672 Mon Sep 17 00:00:00 2001 From: akulakum <74420487+akulakum@users.noreply.github.com> Date: Wed, 18 Sep 2024 12:13:24 +0530 Subject: [PATCH 22/22] feat(callhistory): add-support-for-multiline-in-callhistory (#3816) --- .../src/CallHistory/CallHistory.test.ts | 115 ++++++++- .../calling/src/CallHistory/CallHistory.ts | 95 ++++++- .../src/CallHistory/callHistoryFixtures.ts | 236 +++++++++++++++++- packages/calling/src/CallHistory/constants.ts | 8 +- packages/calling/src/CallHistory/types.ts | 16 +- packages/calling/src/Events/types.ts | 30 ++- packages/calling/src/common/Utils.ts | 7 +- 7 files changed, 497 insertions(+), 10 deletions(-) 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/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/common/Utils.ts b/packages/calling/src/common/Utils.ts index 5c5ee2434ff..4c2d8441dfe 100644 --- a/packages/calling/src/common/Utils.ts +++ b/packages/calling/src/common/Utils.ts @@ -85,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, @@ -691,6 +695,7 @@ export async function serviceErrorCodeHandler( | CallSettingResponse | ContactResponse | UpdateMissedCallsResponse + | UCMLinesResponse > { const errorCode = Number(err.statusCode); const failureMessage = 'FAILURE';