@webex/plugin-meetings 3.11.0-webex-services-ready.1 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (166) hide show
  1. package/dist/aiEnableRequest/index.js +184 -0
  2. package/dist/aiEnableRequest/index.js.map +1 -0
  3. package/dist/aiEnableRequest/utils.js +36 -0
  4. package/dist/aiEnableRequest/utils.js.map +1 -0
  5. package/dist/annotation/index.js +14 -5
  6. package/dist/annotation/index.js.map +1 -1
  7. package/dist/breakouts/breakout.js +1 -1
  8. package/dist/breakouts/index.js +1 -1
  9. package/dist/config.js +5 -1
  10. package/dist/config.js.map +1 -1
  11. package/dist/constants.js +28 -6
  12. package/dist/constants.js.map +1 -1
  13. package/dist/hashTree/constants.js +3 -1
  14. package/dist/hashTree/constants.js.map +1 -1
  15. package/dist/hashTree/hashTree.js +18 -0
  16. package/dist/hashTree/hashTree.js.map +1 -1
  17. package/dist/hashTree/hashTreeParser.js +709 -380
  18. package/dist/hashTree/hashTreeParser.js.map +1 -1
  19. package/dist/hashTree/types.js +4 -2
  20. package/dist/hashTree/types.js.map +1 -1
  21. package/dist/hashTree/utils.js +10 -0
  22. package/dist/hashTree/utils.js.map +1 -1
  23. package/dist/index.js +11 -2
  24. package/dist/index.js.map +1 -1
  25. package/dist/interceptors/constant.js +12 -0
  26. package/dist/interceptors/constant.js.map +1 -0
  27. package/dist/interceptors/dataChannelAuthToken.js +290 -0
  28. package/dist/interceptors/dataChannelAuthToken.js.map +1 -0
  29. package/dist/interceptors/index.js +7 -0
  30. package/dist/interceptors/index.js.map +1 -1
  31. package/dist/interceptors/utils.js +27 -0
  32. package/dist/interceptors/utils.js.map +1 -0
  33. package/dist/interpretation/index.js +2 -2
  34. package/dist/interpretation/index.js.map +1 -1
  35. package/dist/interpretation/siLanguage.js +1 -1
  36. package/dist/locus-info/controlsUtils.js +5 -3
  37. package/dist/locus-info/controlsUtils.js.map +1 -1
  38. package/dist/locus-info/index.js +217 -79
  39. package/dist/locus-info/index.js.map +1 -1
  40. package/dist/locus-info/selfUtils.js +1 -0
  41. package/dist/locus-info/selfUtils.js.map +1 -1
  42. package/dist/locus-info/types.js.map +1 -1
  43. package/dist/media/MediaConnectionAwaiter.js +57 -1
  44. package/dist/media/MediaConnectionAwaiter.js.map +1 -1
  45. package/dist/media/properties.js +4 -2
  46. package/dist/media/properties.js.map +1 -1
  47. package/dist/meeting/in-meeting-actions.js +7 -1
  48. package/dist/meeting/in-meeting-actions.js.map +1 -1
  49. package/dist/meeting/index.js +1071 -862
  50. package/dist/meeting/index.js.map +1 -1
  51. package/dist/meeting/request.js +50 -0
  52. package/dist/meeting/request.js.map +1 -1
  53. package/dist/meeting/request.type.js.map +1 -1
  54. package/dist/meeting/util.js +133 -3
  55. package/dist/meeting/util.js.map +1 -1
  56. package/dist/meetings/index.js +100 -45
  57. package/dist/meetings/index.js.map +1 -1
  58. package/dist/member/index.js +10 -0
  59. package/dist/member/index.js.map +1 -1
  60. package/dist/member/util.js +10 -0
  61. package/dist/member/util.js.map +1 -1
  62. package/dist/metrics/constants.js +2 -1
  63. package/dist/metrics/constants.js.map +1 -1
  64. package/dist/multistream/mediaRequestManager.js +9 -60
  65. package/dist/multistream/mediaRequestManager.js.map +1 -1
  66. package/dist/multistream/remoteMediaManager.js +11 -0
  67. package/dist/multistream/remoteMediaManager.js.map +1 -1
  68. package/dist/reactions/reactions.type.js.map +1 -1
  69. package/dist/reconnection-manager/index.js +0 -1
  70. package/dist/reconnection-manager/index.js.map +1 -1
  71. package/dist/types/aiEnableRequest/index.d.ts +5 -0
  72. package/dist/types/aiEnableRequest/utils.d.ts +2 -0
  73. package/dist/types/config.d.ts +3 -0
  74. package/dist/types/constants.d.ts +23 -1
  75. package/dist/types/hashTree/constants.d.ts +1 -0
  76. package/dist/types/hashTree/hashTree.d.ts +7 -0
  77. package/dist/types/hashTree/hashTreeParser.d.ts +99 -14
  78. package/dist/types/hashTree/types.d.ts +3 -0
  79. package/dist/types/hashTree/utils.d.ts +6 -0
  80. package/dist/types/index.d.ts +1 -0
  81. package/dist/types/interceptors/constant.d.ts +5 -0
  82. package/dist/types/interceptors/dataChannelAuthToken.d.ts +43 -0
  83. package/dist/types/interceptors/index.d.ts +2 -1
  84. package/dist/types/interceptors/utils.d.ts +1 -0
  85. package/dist/types/locus-info/index.d.ts +21 -2
  86. package/dist/types/locus-info/types.d.ts +1 -0
  87. package/dist/types/media/MediaConnectionAwaiter.d.ts +10 -1
  88. package/dist/types/media/properties.d.ts +2 -1
  89. package/dist/types/meeting/in-meeting-actions.d.ts +6 -0
  90. package/dist/types/meeting/index.d.ts +38 -6
  91. package/dist/types/meeting/request.d.ts +16 -1
  92. package/dist/types/meeting/request.type.d.ts +5 -0
  93. package/dist/types/meeting/util.d.ts +31 -0
  94. package/dist/types/meetings/index.d.ts +4 -2
  95. package/dist/types/member/index.d.ts +1 -0
  96. package/dist/types/member/util.d.ts +5 -0
  97. package/dist/types/metrics/constants.d.ts +1 -0
  98. package/dist/types/multistream/mediaRequestManager.d.ts +0 -23
  99. package/dist/types/reactions/reactions.type.d.ts +1 -0
  100. package/dist/types/webinar/utils.d.ts +6 -0
  101. package/dist/webinar/index.js +260 -90
  102. package/dist/webinar/index.js.map +1 -1
  103. package/dist/webinar/utils.js +25 -0
  104. package/dist/webinar/utils.js.map +1 -0
  105. package/package.json +24 -23
  106. package/src/aiEnableRequest/README.md +84 -0
  107. package/src/aiEnableRequest/index.ts +170 -0
  108. package/src/aiEnableRequest/utils.ts +25 -0
  109. package/src/annotation/index.ts +27 -7
  110. package/src/config.ts +3 -0
  111. package/src/constants.ts +29 -1
  112. package/src/hashTree/constants.ts +1 -0
  113. package/src/hashTree/hashTree.ts +17 -0
  114. package/src/hashTree/hashTreeParser.ts +627 -249
  115. package/src/hashTree/types.ts +4 -0
  116. package/src/hashTree/utils.ts +9 -0
  117. package/src/index.ts +8 -1
  118. package/src/interceptors/constant.ts +6 -0
  119. package/src/interceptors/dataChannelAuthToken.ts +170 -0
  120. package/src/interceptors/index.ts +2 -1
  121. package/src/interceptors/utils.ts +16 -0
  122. package/src/interpretation/index.ts +2 -2
  123. package/src/locus-info/controlsUtils.ts +11 -0
  124. package/src/locus-info/index.ts +231 -61
  125. package/src/locus-info/selfUtils.ts +1 -0
  126. package/src/locus-info/types.ts +1 -0
  127. package/src/media/MediaConnectionAwaiter.ts +41 -1
  128. package/src/media/properties.ts +3 -1
  129. package/src/meeting/in-meeting-actions.ts +12 -0
  130. package/src/meeting/index.ts +188 -43
  131. package/src/meeting/request.ts +42 -0
  132. package/src/meeting/request.type.ts +6 -0
  133. package/src/meeting/util.ts +160 -2
  134. package/src/meetings/index.ts +135 -41
  135. package/src/member/index.ts +10 -0
  136. package/src/member/util.ts +12 -0
  137. package/src/metrics/constants.ts +1 -0
  138. package/src/multistream/mediaRequestManager.ts +4 -54
  139. package/src/multistream/remoteMediaManager.ts +13 -0
  140. package/src/reactions/reactions.type.ts +1 -0
  141. package/src/reconnection-manager/index.ts +0 -1
  142. package/src/webinar/index.ts +162 -5
  143. package/src/webinar/utils.ts +16 -0
  144. package/test/unit/spec/aiEnableRequest/index.ts +981 -0
  145. package/test/unit/spec/aiEnableRequest/utils.ts +130 -0
  146. package/test/unit/spec/annotation/index.ts +69 -7
  147. package/test/unit/spec/hashTree/hashTree.ts +66 -0
  148. package/test/unit/spec/hashTree/hashTreeParser.ts +1869 -189
  149. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +210 -0
  150. package/test/unit/spec/interceptors/utils.ts +75 -0
  151. package/test/unit/spec/locus-info/controlsUtils.js +29 -0
  152. package/test/unit/spec/locus-info/index.js +383 -46
  153. package/test/unit/spec/media/MediaConnectionAwaiter.ts +41 -1
  154. package/test/unit/spec/media/properties.ts +12 -3
  155. package/test/unit/spec/meeting/in-meeting-actions.ts +8 -2
  156. package/test/unit/spec/meeting/index.js +662 -85
  157. package/test/unit/spec/meeting/request.js +70 -0
  158. package/test/unit/spec/meeting/utils.js +438 -26
  159. package/test/unit/spec/meetings/index.js +652 -31
  160. package/test/unit/spec/member/index.js +28 -4
  161. package/test/unit/spec/member/util.js +65 -27
  162. package/test/unit/spec/multistream/mediaRequestManager.ts +2 -85
  163. package/test/unit/spec/multistream/remoteMediaManager.ts +30 -0
  164. package/test/unit/spec/reconnection-manager/index.js +4 -8
  165. package/test/unit/spec/webinar/index.ts +348 -36
  166. package/test/unit/spec/webinar/utils.ts +39 -0
@@ -82,6 +82,7 @@ import Mercury from '@webex/internal-plugin-mercury';
82
82
  import Breakouts from '@webex/plugin-meetings/src/breakouts';
83
83
  import SimultaneousInterpretation from '@webex/plugin-meetings/src/interpretation';
84
84
  import Webinar from '@webex/plugin-meetings/src/webinar';
85
+ import AIEnableRequest from '@webex/plugin-meetings/src/aiEnableRequest';
85
86
  import {REACTION_RELAY_TYPES} from '../../../../src/reactions/constants';
86
87
  import locus from '../fixture/locus';
87
88
  import {
@@ -123,7 +124,6 @@ import {EVENT_TRIGGERS as VOICEAEVENTS} from '@webex/internal-plugin-voicea';
123
124
  import {createBrbState} from '@webex/plugin-meetings/src/meeting/brbState';
124
125
  import JoinForbiddenError from '../../../../src/common/errors/join-forbidden-error';
125
126
  import {EventEmitter} from 'stream';
126
-
127
127
  describe('plugin-meetings', () => {
128
128
  const logger = {
129
129
  info: () => {},
@@ -265,7 +265,9 @@ describe('plugin-meetings', () => {
265
265
  stopReachability: sinon.stub(),
266
266
  isSubnetReachable: sinon.stub().returns(true),
267
267
  };
268
+ webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
268
269
  webex.internal.llm.on = sinon.stub();
270
+ webex.internal.voicea.announce = sinon.stub();
269
271
  webex.internal.newMetrics.callDiagnosticLatencies = new CallDiagnosticLatencies(
270
272
  {},
271
273
  {parent: webex}
@@ -376,6 +378,7 @@ describe('plugin-meetings', () => {
376
378
  assert.instanceOf(meeting.breakouts, Breakouts);
377
379
  assert.instanceOf(meeting.simultaneousInterpretation, SimultaneousInterpretation);
378
380
  assert.instanceOf(meeting.webinar, Webinar);
381
+ assert.instanceOf(meeting.aiEnableRequest, AIEnableRequest);
379
382
  });
380
383
 
381
384
  it('should call the callback with the meeting that has id already set', () => {
@@ -738,7 +741,9 @@ describe('plugin-meetings', () => {
738
741
  let supportsRTCPeerConnectionStub;
739
742
 
740
743
  beforeEach(() => {
741
- supportsRTCPeerConnectionStub = sinon.stub(WebCapabilities, 'supportsRTCPeerConnection').returns(CapabilityState.CAPABLE);
744
+ supportsRTCPeerConnectionStub = sinon
745
+ .stub(WebCapabilities, 'supportsRTCPeerConnection')
746
+ .returns(CapabilityState.CAPABLE);
742
747
 
743
748
  meeting.join = sinon.stub().callsFake((joinOptions) => {
744
749
  meeting.isMultistream = joinOptions.enableMultistream;
@@ -1011,33 +1016,53 @@ describe('plugin-meetings', () => {
1011
1016
  );
1012
1017
  });
1013
1018
 
1014
- it('should call leave() if addMediaInternal() fails ', async () => {
1019
+ it('should call leave() if addMediaInternal() fails with a browser media error (TypeError)', async () => {
1015
1020
  const addMediaError = new Error('fake addMedia error');
1016
- addMediaError.name = 'TypeError';
1021
+ addMediaError.name = 'TypeError'; // This makes it a browser media error
1017
1022
 
1018
- const rejectError = {
1019
- error: {
1020
- body: {
1021
- errorCode: 2729,
1022
- message: 'fake addMedia error',
1023
- name: 'TypeError'
1024
- }
1025
- }
1026
- };
1027
- meeting.addMediaInternal.rejects(addMediaError);
1028
- sinon.stub(meeting, 'leave').resolves();
1023
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1024
+ meeting.addMediaInternal = sinon.stub().rejects(addMediaError);
1029
1025
 
1030
- await assert.isRejected(
1026
+ // When a browser media error occurs, it gets transformed into a special structure
1027
+ const rejectedError = await assert.isRejected(
1031
1028
  meeting.joinWithMedia({
1032
1029
  joinOptions,
1033
1030
  mediaOptions,
1034
- }),
1035
- rejectError
1031
+ })
1036
1032
  );
1037
1033
 
1034
+ // Verify the error was transformed with errorCode 2729
1035
+ assert.equal(rejectedError.error.body.errorCode, 2729);
1036
+ assert.equal(rejectedError.error.body.message, 'fake addMedia error');
1037
+ assert.equal(rejectedError.error.body.name, 'TypeError');
1038
+
1038
1039
  assert.calledOnce(meeting.join);
1039
1040
  assert.calledOnce(meeting.addMediaInternal);
1041
+ assert.calledOnce(leaveStub);
1042
+ assert.calledOnceWithExactly(leaveStub, {
1043
+ resourceId: undefined,
1044
+ reason: 'joinWithMedia failure',
1045
+ });
1046
+
1047
+ // Browser media errors don't retry, so behavioral metric is sent only once
1048
+ // NOTE: The error gets transformed, so the metric receives undefined for message/stack/name
1049
+ // because they're now nested in error.body instead of at the top level
1040
1050
  assert.calledOnce(Metrics.sendBehavioralMetric);
1051
+ assert.calledWith(
1052
+ Metrics.sendBehavioralMetric,
1053
+ BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
1054
+ {
1055
+ correlation_id: meeting.correlationId,
1056
+ locus_id: meeting.locusUrl.split('/').pop(),
1057
+ reason: undefined, // transformed error doesn't have .message at top level
1058
+ stack: undefined, // transformed error doesn't have .stack at top level
1059
+ leaveErrorReason: undefined,
1060
+ isRetry: false,
1061
+ },
1062
+ {
1063
+ type: undefined, // transformed error doesn't have .name at top level
1064
+ }
1065
+ );
1041
1066
  });
1042
1067
 
1043
1068
  it('should not call leave() if addMediaInternal() fails the first time and succeeds the second time and should only call join() once', async () => {
@@ -1249,8 +1274,14 @@ describe('plugin-meetings', () => {
1249
1274
  });
1250
1275
 
1251
1276
  [
1252
- {errorName: 'SdpOfferCreationError', description: 'if we fail to create the offer on first attempt'},
1253
- {errorName: 'WebrtcApiNotAvailableError', description: 'if RTCPeerConnection is not available'},
1277
+ {
1278
+ errorName: 'SdpOfferCreationError',
1279
+ description: 'if we fail to create the offer on first attempt',
1280
+ },
1281
+ {
1282
+ errorName: 'WebrtcApiNotAvailableError',
1283
+ description: 'if RTCPeerConnection is not available',
1284
+ },
1254
1285
  ].forEach(({errorName, description}) => {
1255
1286
  it(`should not attempt a retry ${description}`, async () => {
1256
1287
  const addMediaError = new Error('fake addMedia error');
@@ -1290,7 +1321,7 @@ describe('plugin-meetings', () => {
1290
1321
  resourceId: undefined,
1291
1322
  reason: 'joinWithMedia failure',
1292
1323
  });
1293
- })
1324
+ });
1294
1325
  });
1295
1326
 
1296
1327
  it('should ignore sendVideo/receiveVideo when videoEnabled is false', async () => {
@@ -1882,6 +1913,53 @@ describe('plugin-meetings', () => {
1882
1913
  fakeProcessedReaction
1883
1914
  );
1884
1915
  });
1916
+
1917
+ it('should process if participantId does not exist in membersCollection but has displayName in Webinar', () => {
1918
+ LoggerProxy.logger.warn = sinon.stub();
1919
+ meeting.isReactionsSupported = sinon.stub().returns(true);
1920
+ meeting.config.receiveReactions = true;
1921
+ meeting.locusInfo.info = {isWebinar: true};
1922
+ const fakeSendersName = 'Fake reactors name';
1923
+ const fakeReactionPayload = {
1924
+ type: 'fake_type',
1925
+ codepoints: 'fake_codepoints',
1926
+ shortcodes: 'fake_shortcodes',
1927
+ tone: {
1928
+ type: 'fake_tone_type',
1929
+ codepoints: 'fake_tone_codepoints',
1930
+ shortcodes: 'fake_tone_shortcodes',
1931
+ },
1932
+ };
1933
+ const fakeSenderPayload = {
1934
+ displayName: 'Fake reactors name',
1935
+ participantId: 'fake_participant_id',
1936
+ };
1937
+ const fakeProcessedReaction = {
1938
+ reaction: fakeReactionPayload,
1939
+ sender: {
1940
+ id: fakeSenderPayload.participantId,
1941
+ name: fakeSendersName,
1942
+ },
1943
+ };
1944
+ const fakeRelayEvent = {
1945
+ data: {
1946
+ relayType: REACTION_RELAY_TYPES.REACTION,
1947
+ reaction: fakeReactionPayload,
1948
+ sender: fakeSenderPayload,
1949
+ },
1950
+ };
1951
+ meeting.processRelayEvent(fakeRelayEvent);
1952
+ assert.calledWith(
1953
+ TriggerProxy.trigger,
1954
+ sinon.match.instanceOf(Meeting),
1955
+ {
1956
+ file: 'meeting/index',
1957
+ function: 'join',
1958
+ },
1959
+ EVENT_TRIGGERS.MEETING_RECEIVE_REACTIONS,
1960
+ fakeProcessedReaction
1961
+ );
1962
+ });
1885
1963
  });
1886
1964
 
1887
1965
  describe('#handleLLMOnline', () => {
@@ -3028,6 +3106,111 @@ describe('plugin-meetings', () => {
3028
3106
  checkWorking({allowMediaInLobby: true});
3029
3107
  });
3030
3108
 
3109
+ const setupLobbyTest = () => {
3110
+ meeting.roap.doTurnDiscovery = sinon
3111
+ .stub()
3112
+ .resolves({turnServerInfo: undefined, turnDiscoverySkippedReason: undefined});
3113
+
3114
+ meeting.meetingState = 'ACTIVE';
3115
+ meeting.locusInfo.parsedLocus = {self: {state: 'IDLE'}};
3116
+ meeting.isUserUnadmitted = true;
3117
+
3118
+ // Mock locusMediaRequest
3119
+ meeting.locusMediaRequest = {
3120
+ send: sinon.stub().resolves(),
3121
+ isConfluenceCreated: sinon.stub().returns(false),
3122
+ };
3123
+
3124
+ sinon.stub(RemoteMediaManagerModule, 'RemoteMediaManager').returns({
3125
+ start: sinon.stub().resolves(),
3126
+ on: sinon.stub(),
3127
+ logAllReceiveSlots: sinon.stub(),
3128
+ });
3129
+
3130
+ meeting.isMultistream = true;
3131
+
3132
+ const createFakeStream = (id) => ({
3133
+ on: sinon.stub(),
3134
+ off: sinon.stub(),
3135
+ userMuted: false,
3136
+ systemMuted: false,
3137
+ get muted() {
3138
+ return this.userMuted || this.systemMuted;
3139
+ },
3140
+ setUnmuteAllowed: sinon.stub(),
3141
+ setUserMuted: sinon.stub(),
3142
+ outputStream: {
3143
+ getTracks: () => [{id}],
3144
+ },
3145
+ getSettings: sinon.stub().returns({}),
3146
+ });
3147
+
3148
+ return {
3149
+ fakeMicrophoneStream: createFakeStream('fake mic'),
3150
+ fakeCameraStream: createFakeStream('fake camera'),
3151
+ };
3152
+ };
3153
+
3154
+ it('should not publish any local streams when in the lobby and allowPublishMediaInLobby is false', async () => {
3155
+ const {fakeMicrophoneStream, fakeCameraStream} = setupLobbyTest();
3156
+
3157
+ const publishStreamStub = sinon.stub();
3158
+ fakeMediaConnection.createSendSlot = sinon.stub().returns({
3159
+ publishStream: publishStreamStub,
3160
+ unpublishStream: sinon.stub(),
3161
+ setNamedMediaGroups: sinon.stub(),
3162
+ });
3163
+
3164
+ await meeting.addMedia({
3165
+ allowMediaInLobby: true,
3166
+ allowPublishMediaInLobby: false,
3167
+ audioEnabled: true,
3168
+ videoEnabled: true,
3169
+ localStreams: {
3170
+ microphone: fakeMicrophoneStream,
3171
+ camera: fakeCameraStream,
3172
+ },
3173
+ });
3174
+
3175
+ assert.notCalled(publishStreamStub);
3176
+ });
3177
+
3178
+ it('should publish local streams when in the lobby and allowPublishMediaInLobby is true', async () => {
3179
+ const {fakeMicrophoneStream, fakeCameraStream} = setupLobbyTest();
3180
+
3181
+ const audioSlot = {
3182
+ publishStream: sinon.stub(),
3183
+ unpublishStream: sinon.stub(),
3184
+ setNamedMediaGroups: sinon.stub(),
3185
+ };
3186
+ const videoSlot = {
3187
+ publishStream: sinon.stub(),
3188
+ unpublishStream: sinon.stub(),
3189
+ setNamedMediaGroups: sinon.stub(),
3190
+ };
3191
+
3192
+ fakeMediaConnection.createSendSlot = sinon.stub().callsFake((mediaType) => {
3193
+ if (mediaType === 'AUDIO-MAIN') {
3194
+ return audioSlot;
3195
+ }
3196
+ return videoSlot;
3197
+ });
3198
+
3199
+ await meeting.addMedia({
3200
+ allowMediaInLobby: true,
3201
+ allowPublishMediaInLobby: true,
3202
+ audioEnabled: true,
3203
+ videoEnabled: true,
3204
+ localStreams: {
3205
+ microphone: fakeMicrophoneStream,
3206
+ camera: fakeCameraStream,
3207
+ },
3208
+ });
3209
+
3210
+ assert.calledOnceWithExactly(audioSlot.publishStream, fakeMicrophoneStream);
3211
+ assert.calledOnceWithExactly(videoSlot.publishStream, fakeCameraStream);
3212
+ });
3213
+
3031
3214
  it('should create rtcMetrics and pass them to Media.createMediaConnection()', async () => {
3032
3215
  const setIntervalOriginal = window.setInterval;
3033
3216
  window.setInterval = sinon.stub().returns(1);
@@ -6218,7 +6401,10 @@ describe('plugin-meetings', () => {
6218
6401
  meeting.statsAnalyzer = {stopAnalyzer: sinon.stub().resolves()};
6219
6402
  meeting.unsetPeerConnections = sinon.stub().returns(true);
6220
6403
  meeting.logger.error = sinon.stub().returns(true);
6221
- meeting.updateLLMConnection = sinon.stub().returns(Promise.resolve());
6404
+ meeting.clearMeetingData = sinon.stub().callsFake(async () => {
6405
+ meeting.audio = null;
6406
+ meeting.video = null;
6407
+ });
6222
6408
  webex.internal.voicea.off = sinon.stub().returns(true);
6223
6409
  meeting.stopTranscription = sinon.stub();
6224
6410
  meeting.transcription = {};
@@ -6245,9 +6431,7 @@ describe('plugin-meetings', () => {
6245
6431
  assert.calledOnce(meeting.closePeerConnections);
6246
6432
  assert.calledOnce(meeting.unsetRemoteStreams);
6247
6433
  assert.calledOnce(meeting.unsetPeerConnections);
6248
- assert.calledOnce(meeting.stopTranscription);
6249
- assert.calledOnce(meeting.annotation.deregisterEvents);
6250
- assert.calledWith(webex.internal.llm.off, 'event:relay.event', meeting.processRelayEvent);
6434
+ assert.calledOnce(meeting.clearMeetingData);
6251
6435
  });
6252
6436
 
6253
6437
  it('should reset call diagnostic latencies correctly', async () => {
@@ -8248,7 +8432,10 @@ describe('plugin-meetings', () => {
8248
8432
  meeting.statsAnalyzer = {stopAnalyzer: sinon.stub().resolves()};
8249
8433
  meeting.unsetPeerConnections = sinon.stub().returns(true);
8250
8434
  meeting.logger.error = sinon.stub().returns(true);
8251
- meeting.updateLLMConnection = sinon.stub().returns(Promise.resolve());
8435
+ meeting.clearMeetingData = sinon.stub().callsFake(async () => {
8436
+ meeting.audio = null;
8437
+ meeting.video = null;
8438
+ });
8252
8439
  meeting.transcription = {};
8253
8440
  meeting.stopTranscription = sinon.stub();
8254
8441
 
@@ -8274,10 +8461,7 @@ describe('plugin-meetings', () => {
8274
8461
  assert.calledOnce(meeting?.closePeerConnections);
8275
8462
  assert.calledOnce(meeting?.unsetRemoteStreams);
8276
8463
  assert.calledOnce(meeting?.unsetPeerConnections);
8277
- assert.calledOnce(meeting?.stopTranscription);
8278
-
8279
- assert.called(meeting.annotation.deregisterEvents);
8280
- assert.calledWith(webex.internal.llm.off, 'event:relay.event', meeting.processRelayEvent);
8464
+ assert.calledOnce(meeting?.clearMeetingData);
8281
8465
  });
8282
8466
  });
8283
8467
 
@@ -9149,7 +9333,10 @@ describe('plugin-meetings', () => {
9149
9333
 
9150
9334
  // check that the right things were called by the callback
9151
9335
  assert.calledOnceWithExactly(meeting.waitForRemoteSDPAnswer);
9152
- assert.calledOnceWithExactly(meeting.mediaProperties.waitForMediaConnectionConnected);
9336
+ assert.calledOnceWithExactly(
9337
+ meeting.mediaProperties.waitForMediaConnectionConnected,
9338
+ meeting.correlationId
9339
+ );
9153
9340
  });
9154
9341
  });
9155
9342
 
@@ -10323,6 +10510,21 @@ describe('plugin-meetings', () => {
10323
10510
  EVENT_TRIGGERS.MEETING_INTERPRETATION_UPDATE
10324
10511
  );
10325
10512
  });
10513
+
10514
+ it('listens to the self id changed event and updates aiEnableRequest', () => {
10515
+ meeting.aiEnableRequest = {
10516
+ selfParticipantIdUpdate: sinon.stub(),
10517
+ };
10518
+
10519
+ const payload = {selfId: 'participant-test-123'};
10520
+
10521
+ meeting.locusInfo.emit({function: 'test', file: 'test'}, 'SELF_ID_CHANGED', payload);
10522
+
10523
+ assert.calledOnceWithExactly(
10524
+ meeting.aiEnableRequest.selfParticipantIdUpdate,
10525
+ payload.selfId
10526
+ );
10527
+ });
10326
10528
  });
10327
10529
 
10328
10530
  describe('#setUpBreakoutsListener', () => {
@@ -10570,6 +10772,24 @@ describe('plugin-meetings', () => {
10570
10772
  );
10571
10773
  });
10572
10774
 
10775
+ it('listens to MEETING_CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED', async () => {
10776
+ const aiSummaryNotification = {example: 'value'};
10777
+
10778
+ await meeting.locusInfo.emitScoped(
10779
+ {function: 'test', file: 'test'},
10780
+ LOCUSINFO.EVENTS.CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED,
10781
+ {aiSummaryNotification}
10782
+ );
10783
+
10784
+ assert.calledWith(
10785
+ TriggerProxy.trigger,
10786
+ meeting,
10787
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
10788
+ EVENT_TRIGGERS.MEETING_CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED,
10789
+ {aiSummaryNotification}
10790
+ );
10791
+ });
10792
+
10573
10793
  it('listens to MEETING_CONTROLS_MEETING_FULL_UPDATED', async () => {
10574
10794
  const state = {example: 'value'};
10575
10795
 
@@ -10842,6 +11062,9 @@ describe('plugin-meetings', () => {
10842
11062
  meeting.simultaneousInterpretation = {
10843
11063
  approvalUrlUpdate: sinon.stub().returns(undefined),
10844
11064
  };
11065
+ meeting.aiEnableRequest = {
11066
+ approvalUrlUpdate: sinon.stub().returns(undefined),
11067
+ };
10845
11068
 
10846
11069
  meeting.locusInfo.emit(
10847
11070
  {function: 'test', file: 'test'},
@@ -10861,6 +11084,10 @@ describe('plugin-meetings', () => {
10861
11084
  meeting.simultaneousInterpretation.approvalUrlUpdate,
10862
11085
  newLocusServices.services.approval.url
10863
11086
  );
11087
+ assert.calledWith(
11088
+ meeting.aiEnableRequest.approvalUrlUpdate,
11089
+ newLocusServices.services.approval.url
11090
+ );
10864
11091
  assert.calledOnce(meeting.recordingController.setSessionId);
10865
11092
  done();
10866
11093
  });
@@ -11266,6 +11493,41 @@ describe('plugin-meetings', () => {
11266
11493
  });
11267
11494
  });
11268
11495
 
11496
+ describe('localConstraintsChangeHandler', () => {
11497
+ it('calls updatePreferredBitrateKbps when not multistream', () => {
11498
+ meeting.isMultistream = false;
11499
+ meeting.mediaProperties.webrtcMediaConnection = {
11500
+ updatePreferredBitrateKbps: sinon.stub(),
11501
+ };
11502
+
11503
+ meeting.localConstraintsChangeHandler();
11504
+
11505
+ assert.calledOnce(
11506
+ meeting.mediaProperties.webrtcMediaConnection.updatePreferredBitrateKbps
11507
+ );
11508
+ });
11509
+
11510
+ it('does not call updatePreferredBitrateKbps when multistream', () => {
11511
+ meeting.isMultistream = true;
11512
+ meeting.mediaProperties.webrtcMediaConnection = {
11513
+ updatePreferredBitrateKbps: sinon.stub(),
11514
+ };
11515
+
11516
+ meeting.localConstraintsChangeHandler();
11517
+
11518
+ assert.notCalled(
11519
+ meeting.mediaProperties.webrtcMediaConnection.updatePreferredBitrateKbps
11520
+ );
11521
+ });
11522
+
11523
+ it('does not throw when webrtcMediaConnection is undefined', () => {
11524
+ meeting.isMultistream = false;
11525
+ meeting.mediaProperties.webrtcMediaConnection = undefined;
11526
+
11527
+ assert.doesNotThrow(() => meeting.localConstraintsChangeHandler());
11528
+ });
11529
+ });
11530
+
11269
11531
  describe('#parseMeetingInfo', () => {
11270
11532
  const checkParseMeetingInfo = (expectedInfoToParse) => {
11271
11533
  assert.equal(meeting.conversationUrl, expectedInfoToParse.conversationUrl);
@@ -11645,6 +11907,7 @@ describe('plugin-meetings', () => {
11645
11907
  let canUnsetDisallowUnmuteSpy;
11646
11908
  let canUserRaiseHandSpy;
11647
11909
  let bothLeaveAndEndMeetingAvailableSpy;
11910
+ let requireHostEndMeetingBeforeLeaveSpy;
11648
11911
  let canUserLowerAllHandsSpy;
11649
11912
  let canUserLowerSomeoneElsesHandSpy;
11650
11913
  let waitingForOthersToJoinSpy;
@@ -11656,6 +11919,8 @@ describe('plugin-meetings', () => {
11656
11919
  let canMoveToLobbySpy;
11657
11920
  let isSpokenLanguageAutoDetectionEnabledSpy;
11658
11921
  let showAutoEndMeetingWarningSpy;
11922
+ let canAttendeeRequestAiAssistantEnabledSpy;
11923
+ let attendeeRequestAiAssistantDeclinedAllSpy;
11659
11924
  // Due to import tree issues, hasHints must be stubed within the scope of the `it`.
11660
11925
 
11661
11926
  beforeEach(() => {
@@ -11676,6 +11941,10 @@ describe('plugin-meetings', () => {
11676
11941
  MeetingUtil,
11677
11942
  'bothLeaveAndEndMeetingAvailable'
11678
11943
  );
11944
+ requireHostEndMeetingBeforeLeaveSpy = sinon.spy(
11945
+ MeetingUtil,
11946
+ 'requireHostEndMeetingBeforeLeave'
11947
+ );
11679
11948
  canUserLowerSomeoneElsesHandSpy = sinon.spy(MeetingUtil, 'canUserLowerSomeoneElsesHand');
11680
11949
  waitingForOthersToJoinSpy = sinon.spy(MeetingUtil, 'waitingForOthersToJoin');
11681
11950
  canSendReactionsSpy = sinon.spy(MeetingUtil, 'canSendReactions');
@@ -11692,12 +11961,22 @@ describe('plugin-meetings', () => {
11692
11961
  MeetingUtil,
11693
11962
  'isSpokenLanguageAutoDetectionEnabled'
11694
11963
  );
11964
+ canAttendeeRequestAiAssistantEnabledSpy = sinon.spy(
11965
+ MeetingUtil,
11966
+ 'canAttendeeRequestAiAssistantEnabled'
11967
+ );
11968
+ attendeeRequestAiAssistantDeclinedAllSpy = sinon.spy(
11969
+ MeetingUtil,
11970
+ 'attendeeRequestAiAssistantDeclinedAll'
11971
+ );
11695
11972
  });
11696
11973
 
11697
11974
  afterEach(() => {
11698
11975
  inMeetingActionsSetSpy.restore();
11699
11976
  waitingForOthersToJoinSpy.restore();
11700
11977
  showAutoEndMeetingWarningSpy.restore();
11978
+ canAttendeeRequestAiAssistantEnabledSpy.restore();
11979
+ attendeeRequestAiAssistantDeclinedAllSpy.restore();
11701
11980
  });
11702
11981
 
11703
11982
  forEach(
@@ -12221,6 +12500,7 @@ describe('plugin-meetings', () => {
12221
12500
  const userDisplayHints = ['LOCK_CONTROL_UNLOCK'];
12222
12501
  meeting.userDisplayHints = ['LOCK_CONTROL_UNLOCK'];
12223
12502
  meeting.meetingInfo.supportVoIP = true;
12503
+ meeting.roles = [];
12224
12504
 
12225
12505
  meeting.updateMeetingActions();
12226
12506
 
@@ -12236,6 +12516,7 @@ describe('plugin-meetings', () => {
12236
12516
  assert.calledWith(canUnsetDisallowUnmuteSpy, userDisplayHints);
12237
12517
  assert.calledWith(canUserRaiseHandSpy, userDisplayHints);
12238
12518
  assert.calledWith(bothLeaveAndEndMeetingAvailableSpy, userDisplayHints);
12519
+ assert.calledWith(requireHostEndMeetingBeforeLeaveSpy, userDisplayHints);
12239
12520
  assert.calledWith(canUserLowerAllHandsSpy, userDisplayHints);
12240
12521
  assert.calledWith(canUserLowerSomeoneElsesHandSpy, userDisplayHints);
12241
12522
  assert.calledWith(waitingForOthersToJoinSpy, userDisplayHints);
@@ -12247,6 +12528,12 @@ describe('plugin-meetings', () => {
12247
12528
  assert.calledWith(canMoveToLobbySpy, userDisplayHints);
12248
12529
  assert.calledWith(showAutoEndMeetingWarningSpy, userDisplayHints);
12249
12530
  assert.calledWith(isSpokenLanguageAutoDetectionEnabledSpy, userDisplayHints);
12531
+ assert.calledWith(
12532
+ canAttendeeRequestAiAssistantEnabledSpy,
12533
+ userDisplayHints,
12534
+ meeting.roles
12535
+ );
12536
+ assert.calledWith(attendeeRequestAiAssistantDeclinedAllSpy, userDisplayHints);
12250
12537
 
12251
12538
  assert.calledWith(ControlsOptionsUtil.hasHints, {
12252
12539
  requiredHints: [DISPLAY_HINTS.MUTE_ALL],
@@ -12389,33 +12676,72 @@ describe('plugin-meetings', () => {
12389
12676
 
12390
12677
  describe('#handleDataChannelUrlChange', () => {
12391
12678
  let updateLLMConnectionSpy;
12679
+ let updatePSDataChannelSpy;
12392
12680
 
12393
12681
  beforeEach(() => {
12394
12682
  updateLLMConnectionSpy = sinon.spy(meeting, 'updateLLMConnection');
12683
+ updatePSDataChannelSpy = sinon.stub(meeting.webinar, 'updatePSDataChannel').resolves();
12684
+ meeting.webinar.isJoinPracticeSessionDataChannel = sinon.stub().returns(false);
12395
12685
  });
12396
12686
 
12397
- const check = (url, expectedCalled) => {
12398
- meeting.handleDataChannelUrlChange(url);
12687
+ const check = (
12688
+ url,
12689
+ practiceSessionDatachannelUrl,
12690
+ {expectedMainCalled, expectedPracticeCalled}
12691
+ ) => {
12692
+ meeting.handleDataChannelUrlChange(url, practiceSessionDatachannelUrl);
12399
12693
 
12400
- if (expectedCalled) {
12694
+ if (expectedMainCalled) {
12401
12695
  assert.calledWith(updateLLMConnectionSpy);
12402
12696
  } else {
12403
12697
  assert.notCalled(updateLLMConnectionSpy);
12404
12698
  }
12699
+
12700
+ if (expectedPracticeCalled) {
12701
+ assert.calledWith(updatePSDataChannelSpy);
12702
+ } else {
12703
+ assert.notCalled(updatePSDataChannelSpy);
12704
+ }
12405
12705
  };
12406
12706
 
12407
12707
  it('calls deferred updateLLMConnection if datachannelURL is set and the enableAutomaticLLM is true', () => {
12408
12708
  meeting.config.enableAutomaticLLM = true;
12409
- check('some url', true);
12709
+ check('some url', undefined, {expectedMainCalled: true, expectedPracticeCalled: false});
12410
12710
  });
12411
12711
 
12412
12712
  it('does not call updateLLMConnection if datachannelURL is undefined', () => {
12413
12713
  meeting.config.enableAutomaticLLM = true;
12414
- check(undefined, false);
12714
+ check(undefined, undefined, {
12715
+ expectedMainCalled: false,
12716
+ expectedPracticeCalled: false,
12717
+ });
12415
12718
  });
12416
12719
 
12417
12720
  it('does not call updateLLMConnection if enableAutomaticLLM is false', () => {
12418
- check('some url', false);
12721
+ check('some url', 'some practice url', {
12722
+ expectedMainCalled: false,
12723
+ expectedPracticeCalled: false,
12724
+ });
12725
+ });
12726
+
12727
+ it('calls updatePSDataChannel when practice-session routing is active', () => {
12728
+ meeting.config.enableAutomaticLLM = true;
12729
+ meeting.webinar.isJoinPracticeSessionDataChannel.returns(true);
12730
+
12731
+ check('some url', 'some practice url', {
12732
+ expectedMainCalled: true,
12733
+ expectedPracticeCalled: true,
12734
+ });
12735
+ });
12736
+
12737
+ it('does not call updatePSDataChannel when the main datachannelURL is undefined', () => {
12738
+ meeting.config.enableAutomaticLLM = true;
12739
+ meeting.webinar.isJoinPracticeSessionDataChannel.returns(true);
12740
+
12741
+ check(undefined, 'some practice url', {
12742
+ expectedMainCalled: false,
12743
+ expectedPracticeCalled: false,
12744
+ });
12419
12745
  });
12420
12746
  });
12421
12747
 
@@ -12424,16 +12750,20 @@ describe('plugin-meetings', () => {
12424
12750
  webex.internal.llm.isConnected = sinon.stub().returns(false);
12425
12751
  webex.internal.llm.getLocusUrl = sinon.stub();
12426
12752
  webex.internal.llm.getDatachannelUrl = sinon.stub();
12427
- webex.internal.llm.registerAndConnect = sinon
12428
- .stub()
12429
- .returns(Promise.resolve('something'));
12430
- webex.internal.llm.disconnectLLM = sinon.stub().returns(Promise.resolve());
12431
- meeting.webex.internal.llm.on = sinon.stub();
12432
- meeting.webex.internal.llm.off = sinon.stub();
12753
+ webex.internal.llm.registerAndConnect = sinon.stub().resolves('something');
12754
+ webex.internal.llm.disconnectLLM = sinon.stub().resolves();
12755
+ webex.internal.llm.on = sinon.stub();
12756
+ webex.internal.llm.off = sinon.stub();
12757
+ webex.internal.llm.getDatachannelToken = sinon.stub().returns(undefined);
12758
+ webex.internal.llm.setDatachannelToken = sinon.stub();
12759
+
12433
12760
  meeting.processRelayEvent = sinon.stub();
12761
+ meeting.processLocusLLMEvent = sinon.stub();
12762
+ meeting.clearLLMHealthCheckTimer = sinon.stub();
12763
+ meeting.startLLMHealthCheckTimer = sinon.stub();
12764
+
12434
12765
  meeting.webinar.isJoinPracticeSessionDataChannel = sinon.stub().returns(false);
12435
12766
  });
12436
-
12437
12767
  it('does not connect if the call is not joined yet', async () => {
12438
12768
  meeting.joinedWith = {state: 'any other state'};
12439
12769
  webex.internal.llm.getLocusUrl.returns('a url');
@@ -12447,31 +12777,21 @@ describe('plugin-meetings', () => {
12447
12777
  assert.equal(result, undefined);
12448
12778
  assert.notCalled(meeting.webex.internal.llm.on);
12449
12779
  });
12450
-
12451
12780
  it('returns undefined if llm is already connected and the locus url is unchanged', async () => {
12452
12781
  meeting.joinedWith = {state: 'JOINED'};
12453
- webex.internal.llm.isConnected.returns(true);
12454
- webex.internal.llm.getLocusUrl.returns('a url');
12455
- webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
12456
-
12457
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12458
-
12459
- const result = await meeting.updateLLMConnection();
12460
-
12461
- assert.notCalled(webex.internal.llm.registerAndConnect);
12462
- assert.notCalled(webex.internal.llm.disconnectLLM);
12463
- assert.equal(result, undefined);
12464
- assert.notCalled(meeting.webex.internal.llm.on);
12465
- });
12466
-
12467
- it('connects if not already connected', async () => {
12468
- meeting.joinedWith = {state: 'JOINED'};
12469
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12782
+ meeting.locusInfo = {
12783
+ url: 'a url',
12784
+ info: {datachannelUrl: 'a datachannel url'},
12785
+ };
12470
12786
 
12471
12787
  const result = await meeting.updateLLMConnection();
12472
-
12473
12788
  assert.notCalled(webex.internal.llm.disconnectLLM);
12474
- assert.calledWith(webex.internal.llm.registerAndConnect, 'a url', 'a datachannel url');
12789
+ assert.calledWithExactly(
12790
+ webex.internal.llm.registerAndConnect,
12791
+ 'a url',
12792
+ 'a datachannel url',
12793
+ undefined
12794
+ );
12475
12795
  assert.equal(result, 'something');
12476
12796
  assert.calledWithExactly(
12477
12797
  meeting.webex.internal.llm.off,
@@ -12494,27 +12814,49 @@ describe('plugin-meetings', () => {
12494
12814
  meeting.processLocusLLMEvent
12495
12815
  );
12496
12816
  });
12817
+ it('connects if not already connected', async () => {
12818
+ meeting.joinedWith = {state: 'JOINED'};
12819
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12820
+
12821
+ const result = await meeting.updateLLMConnection();
12497
12822
 
12498
- it('disconnects if first if the locus url has changed', async () => {
12823
+ assert.notCalled(webex.internal.llm.disconnectLLM);
12824
+ assert.calledWithExactly(
12825
+ webex.internal.llm.registerAndConnect,
12826
+ 'a url',
12827
+ 'a datachannel url',
12828
+ undefined
12829
+ );
12830
+ assert.equal(result, 'something');
12831
+ });
12832
+ it('disconnects if the locus url has changed', async () => {
12499
12833
  meeting.joinedWith = {state: 'JOINED'};
12834
+
12500
12835
  webex.internal.llm.isConnected.returns(true);
12501
12836
  webex.internal.llm.getLocusUrl.returns('a url');
12502
- webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
12503
12837
 
12504
- meeting.locusInfo = {url: 'a different url', info: {datachannelUrl: 'a datachannel url'}};
12838
+ meeting.locusInfo = {
12839
+ url: 'a different url',
12840
+ info: {datachannelUrl: 'a datachannel url'},
12841
+ self: {},
12842
+ };
12505
12843
 
12506
12844
  const result = await meeting.updateLLMConnection();
12507
12845
 
12508
- assert.calledWith(webex.internal.llm.disconnectLLM, {
12846
+ assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
12509
12847
  code: 3050,
12510
12848
  reason: 'done (permanent)',
12511
12849
  });
12512
- assert.calledWith(
12850
+
12851
+ assert.calledWithExactly(
12513
12852
  webex.internal.llm.registerAndConnect,
12514
12853
  'a different url',
12515
- 'a datachannel url'
12854
+ 'a datachannel url',
12855
+ undefined
12516
12856
  );
12857
+
12517
12858
  assert.equal(result, 'something');
12859
+
12518
12860
  assert.calledWithExactly(
12519
12861
  meeting.webex.internal.llm.off,
12520
12862
  'event:relay.event',
@@ -12526,6 +12868,7 @@ describe('plugin-meetings', () => {
12526
12868
  meeting.processLocusLLMEvent
12527
12869
  );
12528
12870
  assert.callCount(meeting.webex.internal.llm.off, 4);
12871
+
12529
12872
  assert.calledWithExactly(
12530
12873
  meeting.webex.internal.llm.on,
12531
12874
  'event:relay.event',
@@ -12536,28 +12879,37 @@ describe('plugin-meetings', () => {
12536
12879
  'event:locus.state_message',
12537
12880
  meeting.processLocusLLMEvent
12538
12881
  );
12882
+ assert.isFalse(
12883
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
12884
+ );
12539
12885
  });
12540
-
12541
- it('disconnects it first if the data channel url has changed', async () => {
12886
+ it('disconnects if the data channel url has changed', async () => {
12542
12887
  meeting.joinedWith = {state: 'JOINED'};
12543
12888
  webex.internal.llm.isConnected.returns(true);
12544
12889
  webex.internal.llm.getLocusUrl.returns('a url');
12545
- webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
12546
12890
 
12547
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a different datachannel url'}};
12891
+ meeting.locusInfo = {
12892
+ url: 'a url',
12893
+ info: {datachannelUrl: 'a different datachannel url'},
12894
+ self: {},
12895
+ };
12548
12896
 
12549
12897
  const result = await meeting.updateLLMConnection();
12550
12898
 
12551
- assert.calledWith(webex.internal.llm.disconnectLLM, {
12899
+ assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
12552
12900
  code: 3050,
12553
12901
  reason: 'done (permanent)',
12554
12902
  });
12555
- assert.calledWith(
12903
+
12904
+ assert.calledWithExactly(
12556
12905
  webex.internal.llm.registerAndConnect,
12557
12906
  'a url',
12558
- 'a different datachannel url'
12907
+ 'a different datachannel url',
12908
+ undefined
12559
12909
  );
12910
+
12560
12911
  assert.equal(result, 'something');
12912
+
12561
12913
  assert.calledWithExactly(
12562
12914
  meeting.webex.internal.llm.off,
12563
12915
  'event:relay.event',
@@ -12568,6 +12920,7 @@ describe('plugin-meetings', () => {
12568
12920
  'event:locus.state_message',
12569
12921
  meeting.processLocusLLMEvent
12570
12922
  );
12923
+
12571
12924
  assert.calledWithExactly(
12572
12925
  meeting.webex.internal.llm.on,
12573
12926
  'event:relay.event',
@@ -12578,8 +12931,10 @@ describe('plugin-meetings', () => {
12578
12931
  'event:locus.state_message',
12579
12932
  meeting.processLocusLLMEvent
12580
12933
  );
12934
+ assert.isFalse(
12935
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
12936
+ );
12581
12937
  });
12582
-
12583
12938
  it('disconnects when the state is not JOINED', async () => {
12584
12939
  meeting.joinedWith = {state: 'any other state'};
12585
12940
  webex.internal.llm.isConnected.returns(true);
@@ -12589,9 +12944,38 @@ describe('plugin-meetings', () => {
12589
12944
 
12590
12945
  const result = await meeting.updateLLMConnection();
12591
12946
 
12592
- assert.calledWith(webex.internal.llm.disconnectLLM, undefined);
12947
+ assert.calledWith(webex.internal.llm.disconnectLLM, {
12948
+ code: 3050,
12949
+ reason: 'done (permanent)',
12950
+ });
12593
12951
  assert.notCalled(webex.internal.llm.registerAndConnect);
12594
12952
  assert.equal(result, undefined);
12953
+ assert.isFalse(
12954
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
12955
+ );
12956
+ });
12957
+ it('rethrows disconnect errors during reconnect cleanup after removing relay listeners and timer', async () => {
12958
+ const disconnectError = new Error('disconnect failed');
12959
+
12960
+ meeting.joinedWith = {state: 'JOINED'};
12961
+ webex.internal.llm.isConnected.returns(true);
12962
+ webex.internal.llm.getLocusUrl.returns('a url');
12963
+ webex.internal.llm.disconnectLLM.rejects(disconnectError);
12964
+
12965
+ meeting.locusInfo = {
12966
+ url: 'a different url',
12967
+ info: {datachannelUrl: 'a datachannel url'},
12968
+ self: {},
12969
+ };
12970
+
12971
+ try {
12972
+ await meeting.updateLLMConnection();
12973
+ assert.fail('Expected updateLLMConnection to reject when disconnectLLM fails');
12974
+ } catch (error) {
12975
+ assert.equal(error, disconnectError);
12976
+ }
12977
+
12978
+ assert.notCalled(webex.internal.llm.registerAndConnect);
12595
12979
  assert.calledWithExactly(
12596
12980
  meeting.webex.internal.llm.off,
12597
12981
  'event:relay.event',
@@ -12602,22 +12986,149 @@ describe('plugin-meetings', () => {
12602
12986
  'event:locus.state_message',
12603
12987
  meeting.processLocusLLMEvent
12604
12988
  );
12989
+ assert.isFalse(
12990
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
12991
+ );
12992
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
12605
12993
  });
12606
-
12607
- it('connect ps data channel if ps started in webinar', async () => {
12994
+ it('still need connect main session data channel when PS started', async () => {
12608
12995
  meeting.joinedWith = {state: 'JOINED'};
12609
12996
  meeting.locusInfo = {
12610
12997
  url: 'a url',
12611
12998
  info: {
12612
12999
  datachannelUrl: 'a datachannel url',
12613
- practiceSessionDatachannelUrl: 'a ps datachannel url',
13000
+ practiceSessionDatachannelUrl: 'ps-url',
12614
13001
  },
12615
13002
  };
12616
- meeting.webinar.isJoinPracticeSessionDataChannel = sinon.stub().returns(true);
13003
+ meeting.webinar.isJoinPracticeSessionDataChannel.returns(true);
13004
+
12617
13005
  await meeting.updateLLMConnection();
12618
13006
 
12619
- assert.notCalled(webex.internal.llm.disconnectLLM);
12620
- assert.calledWith(webex.internal.llm.registerAndConnect, 'a url', 'a ps datachannel url');
13007
+ assert.calledWithExactly(
13008
+ webex.internal.llm.registerAndConnect,
13009
+ 'a url',
13010
+ 'a datachannel url',
13011
+ undefined
13012
+ );
13013
+ });
13014
+ it('passes dataChannelToken to registerAndConnect', async () => {
13015
+ meeting.joinedWith = {state: 'JOINED'};
13016
+ meeting.locusInfo = {
13017
+ url: 'a url',
13018
+ info: {datachannelUrl: 'a datachannel url'},
13019
+ self: {datachannelToken: 'token-123'},
13020
+ };
13021
+
13022
+ webex.internal.llm.getDatachannelToken.returns(undefined);
13023
+
13024
+ await meeting.updateLLMConnection();
13025
+
13026
+ assert.calledWithExactly(
13027
+ webex.internal.llm.registerAndConnect,
13028
+ 'a url',
13029
+ 'a datachannel url',
13030
+ 'token-123'
13031
+ );
13032
+ assert.calledWithExactly(webex.internal.llm.setDatachannelToken, 'token-123', 'llm-default-session');
13033
+ });
13034
+ it('prefers refreshed token over locus self token', async () => {
13035
+ meeting.joinedWith = {state: 'JOINED'};
13036
+ meeting.locusInfo = {
13037
+ url: 'a url',
13038
+ info: {datachannelUrl: 'a datachannel url'},
13039
+ self: {datachannelToken: 'locus-token'},
13040
+ };
13041
+
13042
+ webex.internal.llm.getDatachannelToken.withArgs('llm-default-session').returns('refreshed-token');
13043
+
13044
+ await meeting.updateLLMConnection();
13045
+
13046
+ assert.calledWithExactly(
13047
+ webex.internal.llm.registerAndConnect,
13048
+ 'a url',
13049
+ 'a datachannel url',
13050
+ 'refreshed-token'
13051
+ );
13052
+
13053
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
13054
+ });
13055
+
13056
+ it('does not pass token when data channel with jwt token is disabled', async () => {
13057
+ meeting.joinedWith = {state: 'JOINED'};
13058
+ meeting.locusInfo = {
13059
+ url: 'a url',
13060
+ info: {datachannelUrl: 'a datachannel url'},
13061
+ self: {datachannelToken: 'token-123'},
13062
+ };
13063
+
13064
+ webex.internal.llm.getDatachannelToken.returns(undefined);
13065
+ webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
13066
+
13067
+ await meeting.updateLLMConnection();
13068
+
13069
+ assert.calledWithExactly(
13070
+ webex.internal.llm.registerAndConnect,
13071
+ 'a url',
13072
+ 'a datachannel url',
13073
+ 'token-123'
13074
+ );
13075
+ assert.calledWithExactly(webex.internal.llm.setDatachannelToken, 'token-123', 'llm-default-session');
13076
+ });
13077
+
13078
+ describe('#clearMeetingData', () => {
13079
+ beforeEach(() => {
13080
+ webex.internal.llm.isConnected = sinon.stub().returns(true);
13081
+ webex.internal.llm.disconnectLLM = sinon.stub().resolves();
13082
+ webex.internal.llm.off = sinon.stub();
13083
+ meeting.annotation.deregisterEvents = sinon.stub();
13084
+ meeting.clearLLMHealthCheckTimer = sinon.stub();
13085
+ meeting.stopTranscription = sinon.stub();
13086
+ meeting.transcription = {};
13087
+ meeting.shareStatus = 'no-share';
13088
+ });
13089
+
13090
+ it('disconnects llm and removes online and relay listeners during meeting data cleanup', async () => {
13091
+ await meeting.clearMeetingData();
13092
+
13093
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13094
+ code: 3050,
13095
+ reason: 'done (permanent)',
13096
+ });
13097
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
13098
+ assert.calledWithExactly(
13099
+ webex.internal.llm.off,
13100
+ 'event:relay.event',
13101
+ meeting.processRelayEvent
13102
+ );
13103
+ assert.calledWithExactly(
13104
+ webex.internal.llm.off,
13105
+ 'event:locus.state_message',
13106
+ meeting.processLocusLLMEvent
13107
+ );
13108
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13109
+ assert.calledOnce(meeting.stopTranscription);
13110
+ assert.calledOnce(meeting.annotation.deregisterEvents);
13111
+ });
13112
+ it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => {
13113
+ webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
13114
+
13115
+ await meeting.clearMeetingData();
13116
+
13117
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
13118
+ assert.calledWithExactly(
13119
+ webex.internal.llm.off,
13120
+ 'event:relay.event',
13121
+ meeting.processRelayEvent
13122
+ );
13123
+ assert.calledWithExactly(
13124
+ webex.internal.llm.off,
13125
+ 'event:locus.state_message',
13126
+ meeting.processLocusLLMEvent
13127
+ );
13128
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13129
+ assert.calledOnce(meeting.stopTranscription);
13130
+ assert.calledOnce(meeting.annotation.deregisterEvents);
13131
+ });
12621
13132
  });
12622
13133
  });
12623
13134
 
@@ -12628,6 +13139,7 @@ describe('plugin-meetings', () => {
12628
13139
 
12629
13140
  it('should read the locus object, set on the meeting and return null', () => {
12630
13141
  const dataSets = {someFakeStuff: 'dataSet'};
13142
+ const metadata = {some: 'metadata'};
12631
13143
 
12632
13144
  meeting.setLocus({
12633
13145
  mediaConnections: [test1],
@@ -12637,12 +13149,14 @@ describe('plugin-meetings', () => {
12637
13149
  mediaId: uuid3,
12638
13150
  locus: {host: {id: uuid4}},
12639
13151
  dataSets,
13152
+ metadata,
12640
13153
  });
12641
13154
  assert.calledOnce(meeting.locusInfo.initialSetup);
12642
13155
  assert.calledWith(meeting.locusInfo.initialSetup, {
12643
13156
  trigger: 'join-response',
12644
13157
  locus: {host: {id: uuid4}},
12645
13158
  dataSets,
13159
+ metadata,
12646
13160
  });
12647
13161
  assert.equal(meeting.mediaConnections, test1);
12648
13162
  assert.equal(meeting.locusUrl, url1);
@@ -14184,6 +14698,69 @@ describe('plugin-meetings', () => {
14184
14698
  assert.calledOnce(meeting.meetingRequest.keepAlive);
14185
14699
  });
14186
14700
  });
14701
+ describe('#refreshDataChannelToken()', () => {
14702
+ let meeting;
14703
+
14704
+ beforeEach(() => {
14705
+ meeting = Object.create(Meeting.prototype);
14706
+ meeting.locusUrl = 'https://locus.example.com';
14707
+ meeting.meetingRequest = {
14708
+ fetchDatachannelToken: sinon.stub().resolves({
14709
+ body: {datachannelToken: 'mock-token'},
14710
+ }),
14711
+ };
14712
+ meeting.members = {
14713
+ selfId: 'self-123',
14714
+ };
14715
+ meeting.webinar = {
14716
+ isJoinPracticeSessionDataChannel: sinon.stub().returns(true),
14717
+ };
14718
+ });
14719
+
14720
+ it('calls fetchDatachannelToken with correct parameters', async () => {
14721
+ await meeting.refreshDataChannelToken();
14722
+
14723
+ sinon.assert.calledOnce(meeting.meetingRequest.fetchDatachannelToken);
14724
+
14725
+ sinon.assert.calledWith(meeting.meetingRequest.fetchDatachannelToken, {
14726
+ locusUrl: 'https://locus.example.com',
14727
+ requestingParticipantId: 'self-123',
14728
+ isPracticeSession: true,
14729
+ });
14730
+ });
14731
+
14732
+ it('returns the correct structured result', async () => {
14733
+ const result = await meeting.refreshDataChannelToken();
14734
+
14735
+ expect(result).to.deep.equal({
14736
+ body: {
14737
+ datachannelToken: 'mock-token',
14738
+ dataChannelTokenType: 'llm-practice-session',
14739
+ },
14740
+ });
14741
+ });
14742
+ });
14743
+ describe('#getDataChannelTokenType', () => {
14744
+ it('returns PracticeSession when webinar is in practice session mode', () => {
14745
+ meeting.webinar = {
14746
+ isJoinPracticeSessionDataChannel: sinon.stub().returns(true),
14747
+ };
14748
+
14749
+ const result = meeting.getDataChannelTokenType();
14750
+
14751
+ expect(result).to.equal('llm-practice-session');
14752
+ });
14753
+
14754
+ it('returns Default when not in practice session mode', () => {
14755
+ meeting.webinar = {
14756
+ isJoinPracticeSessionDataChannel: sinon.stub().returns(false),
14757
+ };
14758
+
14759
+ const result = meeting.getDataChannelTokenType();
14760
+
14761
+ expect(result).to.equal('llm-default-session');
14762
+ });
14763
+ });
14187
14764
  describe('#stopKeepAlive', () => {
14188
14765
  let clock;
14189
14766
  const defaultKeepAliveUrl = 'keep.alive.url';