@webex/plugin-meetings 3.11.0 → 3.12.0-next.2

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 (170) 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 +7 -2
  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 +850 -410
  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 +522 -131
  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 +1173 -877
  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 +117 -48
  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/reachability/index.js +18 -10
  69. package/dist/reachability/index.js.map +1 -1
  70. package/dist/reactions/reactions.type.js.map +1 -1
  71. package/dist/reconnection-manager/index.js +0 -1
  72. package/dist/reconnection-manager/index.js.map +1 -1
  73. package/dist/types/aiEnableRequest/index.d.ts +5 -0
  74. package/dist/types/aiEnableRequest/utils.d.ts +2 -0
  75. package/dist/types/config.d.ts +4 -0
  76. package/dist/types/constants.d.ts +23 -1
  77. package/dist/types/hashTree/constants.d.ts +1 -0
  78. package/dist/types/hashTree/hashTree.d.ts +7 -0
  79. package/dist/types/hashTree/hashTreeParser.d.ts +122 -14
  80. package/dist/types/hashTree/types.d.ts +3 -0
  81. package/dist/types/hashTree/utils.d.ts +6 -0
  82. package/dist/types/index.d.ts +1 -0
  83. package/dist/types/interceptors/constant.d.ts +5 -0
  84. package/dist/types/interceptors/dataChannelAuthToken.d.ts +43 -0
  85. package/dist/types/interceptors/index.d.ts +2 -1
  86. package/dist/types/interceptors/utils.d.ts +1 -0
  87. package/dist/types/locus-info/index.d.ts +60 -8
  88. package/dist/types/locus-info/types.d.ts +7 -0
  89. package/dist/types/media/MediaConnectionAwaiter.d.ts +10 -1
  90. package/dist/types/media/properties.d.ts +2 -1
  91. package/dist/types/meeting/in-meeting-actions.d.ts +6 -0
  92. package/dist/types/meeting/index.d.ts +61 -7
  93. package/dist/types/meeting/request.d.ts +16 -1
  94. package/dist/types/meeting/request.type.d.ts +5 -0
  95. package/dist/types/meeting/util.d.ts +31 -0
  96. package/dist/types/meetings/index.d.ts +4 -2
  97. package/dist/types/member/index.d.ts +1 -0
  98. package/dist/types/member/util.d.ts +5 -0
  99. package/dist/types/metrics/constants.d.ts +1 -0
  100. package/dist/types/multistream/mediaRequestManager.d.ts +0 -23
  101. package/dist/types/reactions/reactions.type.d.ts +1 -0
  102. package/dist/types/webinar/utils.d.ts +6 -0
  103. package/dist/webinar/index.js +291 -91
  104. package/dist/webinar/index.js.map +1 -1
  105. package/dist/webinar/utils.js +25 -0
  106. package/dist/webinar/utils.js.map +1 -0
  107. package/package.json +24 -23
  108. package/src/aiEnableRequest/README.md +84 -0
  109. package/src/aiEnableRequest/index.ts +170 -0
  110. package/src/aiEnableRequest/utils.ts +25 -0
  111. package/src/annotation/index.ts +27 -7
  112. package/src/config.ts +4 -0
  113. package/src/constants.ts +29 -1
  114. package/src/hashTree/constants.ts +1 -0
  115. package/src/hashTree/hashTree.ts +17 -0
  116. package/src/hashTree/hashTreeParser.ts +745 -252
  117. package/src/hashTree/types.ts +4 -0
  118. package/src/hashTree/utils.ts +9 -0
  119. package/src/index.ts +8 -1
  120. package/src/interceptors/constant.ts +6 -0
  121. package/src/interceptors/dataChannelAuthToken.ts +170 -0
  122. package/src/interceptors/index.ts +2 -1
  123. package/src/interceptors/utils.ts +16 -0
  124. package/src/interpretation/index.ts +2 -2
  125. package/src/locus-info/controlsUtils.ts +11 -0
  126. package/src/locus-info/index.ts +579 -113
  127. package/src/locus-info/selfUtils.ts +1 -0
  128. package/src/locus-info/types.ts +8 -0
  129. package/src/media/MediaConnectionAwaiter.ts +41 -1
  130. package/src/media/properties.ts +3 -1
  131. package/src/meeting/in-meeting-actions.ts +12 -0
  132. package/src/meeting/index.ts +291 -76
  133. package/src/meeting/request.ts +42 -0
  134. package/src/meeting/request.type.ts +6 -0
  135. package/src/meeting/util.ts +160 -2
  136. package/src/meetings/index.ts +157 -44
  137. package/src/member/index.ts +10 -0
  138. package/src/member/util.ts +12 -0
  139. package/src/metrics/constants.ts +1 -0
  140. package/src/multistream/mediaRequestManager.ts +4 -54
  141. package/src/multistream/remoteMediaManager.ts +13 -0
  142. package/src/reachability/index.ts +9 -0
  143. package/src/reactions/reactions.type.ts +1 -0
  144. package/src/reconnection-manager/index.ts +0 -1
  145. package/src/webinar/index.ts +191 -6
  146. package/src/webinar/utils.ts +16 -0
  147. package/test/unit/spec/aiEnableRequest/index.ts +981 -0
  148. package/test/unit/spec/aiEnableRequest/utils.ts +130 -0
  149. package/test/unit/spec/annotation/index.ts +69 -7
  150. package/test/unit/spec/hashTree/hashTree.ts +66 -0
  151. package/test/unit/spec/hashTree/hashTreeParser.ts +2225 -189
  152. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +210 -0
  153. package/test/unit/spec/interceptors/utils.ts +75 -0
  154. package/test/unit/spec/locus-info/controlsUtils.js +29 -0
  155. package/test/unit/spec/locus-info/index.js +1134 -55
  156. package/test/unit/spec/media/MediaConnectionAwaiter.ts +41 -1
  157. package/test/unit/spec/media/properties.ts +12 -3
  158. package/test/unit/spec/meeting/in-meeting-actions.ts +8 -2
  159. package/test/unit/spec/meeting/index.js +829 -115
  160. package/test/unit/spec/meeting/request.js +70 -0
  161. package/test/unit/spec/meeting/utils.js +438 -26
  162. package/test/unit/spec/meetings/index.js +653 -32
  163. package/test/unit/spec/member/index.js +28 -4
  164. package/test/unit/spec/member/util.js +65 -27
  165. package/test/unit/spec/multistream/mediaRequestManager.ts +2 -85
  166. package/test/unit/spec/multistream/remoteMediaManager.ts +30 -0
  167. package/test/unit/spec/reachability/index.ts +23 -0
  168. package/test/unit/spec/reconnection-manager/index.js +4 -8
  169. package/test/unit/spec/webinar/index.ts +474 -37
  170. package/test/unit/spec/webinar/utils.ts +39 -0
@@ -46,6 +46,7 @@ import {
46
46
  MediaType,
47
47
  } from '@webex/internal-media-core';
48
48
  import {LocalStreamEventNames} from '@webex/media-helpers';
49
+ import {CapabilityState, WebCapabilities} from '@webex/web-capabilities';
49
50
  import EventsScope from '@webex/plugin-meetings/src/common/events/events-scope';
50
51
  import Meetings, {CONSTANTS} from '@webex/plugin-meetings';
51
52
  import Meeting from '@webex/plugin-meetings/src/meeting';
@@ -81,6 +82,7 @@ import Mercury from '@webex/internal-plugin-mercury';
81
82
  import Breakouts from '@webex/plugin-meetings/src/breakouts';
82
83
  import SimultaneousInterpretation from '@webex/plugin-meetings/src/interpretation';
83
84
  import Webinar from '@webex/plugin-meetings/src/webinar';
85
+ import AIEnableRequest from '@webex/plugin-meetings/src/aiEnableRequest';
84
86
  import {REACTION_RELAY_TYPES} from '../../../../src/reactions/constants';
85
87
  import locus from '../fixture/locus';
86
88
  import {
@@ -122,7 +124,6 @@ import {EVENT_TRIGGERS as VOICEAEVENTS} from '@webex/internal-plugin-voicea';
122
124
  import {createBrbState} from '@webex/plugin-meetings/src/meeting/brbState';
123
125
  import JoinForbiddenError from '../../../../src/common/errors/join-forbidden-error';
124
126
  import {EventEmitter} from 'stream';
125
-
126
127
  describe('plugin-meetings', () => {
127
128
  const logger = {
128
129
  info: () => {},
@@ -264,7 +265,9 @@ describe('plugin-meetings', () => {
264
265
  stopReachability: sinon.stub(),
265
266
  isSubnetReachable: sinon.stub().returns(true),
266
267
  };
268
+ webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
267
269
  webex.internal.llm.on = sinon.stub();
270
+ webex.internal.voicea.announce = sinon.stub();
268
271
  webex.internal.newMetrics.callDiagnosticLatencies = new CallDiagnosticLatencies(
269
272
  {},
270
273
  {parent: webex}
@@ -375,6 +378,7 @@ describe('plugin-meetings', () => {
375
378
  assert.instanceOf(meeting.breakouts, Breakouts);
376
379
  assert.instanceOf(meeting.simultaneousInterpretation, SimultaneousInterpretation);
377
380
  assert.instanceOf(meeting.webinar, Webinar);
381
+ assert.instanceOf(meeting.aiEnableRequest, AIEnableRequest);
378
382
  });
379
383
 
380
384
  it('should call the callback with the meeting that has id already set', () => {
@@ -734,8 +738,13 @@ describe('plugin-meetings', () => {
734
738
  let handleTurnDiscoveryHttpResponseStub;
735
739
  let abortTurnDiscoveryStub;
736
740
  let addMediaInternalStub;
741
+ let supportsRTCPeerConnectionStub;
737
742
 
738
743
  beforeEach(() => {
744
+ supportsRTCPeerConnectionStub = sinon
745
+ .stub(WebCapabilities, 'supportsRTCPeerConnection')
746
+ .returns(CapabilityState.CAPABLE);
747
+
739
748
  meeting.join = sinon.stub().callsFake((joinOptions) => {
740
749
  meeting.isMultistream = joinOptions.enableMultistream;
741
750
  return Promise.resolve(fakeJoinResult);
@@ -1007,33 +1016,53 @@ describe('plugin-meetings', () => {
1007
1016
  );
1008
1017
  });
1009
1018
 
1010
- it('should call leave() if addMediaInternal() fails ', async () => {
1019
+ it('should call leave() if addMediaInternal() fails with a browser media error (TypeError)', async () => {
1011
1020
  const addMediaError = new Error('fake addMedia error');
1012
- addMediaError.name = 'TypeError';
1021
+ addMediaError.name = 'TypeError'; // This makes it a browser media error
1013
1022
 
1014
- const rejectError = {
1015
- error: {
1016
- body: {
1017
- errorCode: 2729,
1018
- message: 'fake addMedia error',
1019
- name: 'TypeError'
1020
- }
1021
- }
1022
- };
1023
- meeting.addMediaInternal.rejects(addMediaError);
1024
- sinon.stub(meeting, 'leave').resolves();
1023
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1024
+ meeting.addMediaInternal = sinon.stub().rejects(addMediaError);
1025
1025
 
1026
- await assert.isRejected(
1026
+ // When a browser media error occurs, it gets transformed into a special structure
1027
+ const rejectedError = await assert.isRejected(
1027
1028
  meeting.joinWithMedia({
1028
1029
  joinOptions,
1029
1030
  mediaOptions,
1030
- }),
1031
- rejectError
1031
+ })
1032
1032
  );
1033
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
+
1034
1039
  assert.calledOnce(meeting.join);
1035
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
1036
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
+ );
1037
1066
  });
1038
1067
 
1039
1068
  it('should not call leave() if addMediaInternal() fails the first time and succeeds the second time and should only call join() once', async () => {
@@ -1244,43 +1273,54 @@ describe('plugin-meetings', () => {
1244
1273
  await assert.isRejected(result);
1245
1274
  });
1246
1275
 
1247
- it('should not attempt a retry if we fail to create the offer on first atttempt', async () => {
1248
- const addMediaError = new Error('fake addMedia error');
1249
- addMediaError.name = 'SdpOfferCreationError';
1276
+ [
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
+ },
1285
+ ].forEach(({errorName, description}) => {
1286
+ it(`should not attempt a retry ${description}`, async () => {
1287
+ const addMediaError = new Error('fake addMedia error');
1288
+ addMediaError.name = errorName;
1250
1289
 
1251
- meeting.addMediaInternal.rejects(addMediaError);
1252
- sinon.stub(meeting, 'leave').resolves();
1290
+ meeting.addMediaInternal.rejects(addMediaError);
1291
+ sinon.stub(meeting, 'leave').resolves();
1253
1292
 
1254
- await assert.isRejected(
1255
- meeting.joinWithMedia({
1256
- joinOptions,
1257
- mediaOptions,
1258
- }),
1259
- addMediaError
1260
- );
1293
+ await assert.isRejected(
1294
+ meeting.joinWithMedia({
1295
+ joinOptions,
1296
+ mediaOptions,
1297
+ }),
1298
+ addMediaError
1299
+ );
1261
1300
 
1262
- // check that only 1 attempt was done
1263
- assert.calledOnce(meeting.join);
1264
- assert.calledOnce(meeting.addMediaInternal);
1265
- assert.calledOnce(Metrics.sendBehavioralMetric);
1266
- assert.calledWith(
1267
- Metrics.sendBehavioralMetric.firstCall,
1268
- BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
1269
- {
1270
- correlation_id: meeting.correlationId,
1271
- locus_id: meeting.locusUrl.split('/').pop(),
1272
- reason: addMediaError.message,
1273
- stack: addMediaError.stack,
1274
- leaveErrorReason: undefined,
1275
- isRetry: false,
1276
- },
1277
- {
1278
- type: addMediaError.name,
1279
- }
1280
- );
1281
- assert.calledOnceWithExactly(meeting.leave, {
1282
- resourceId: undefined,
1283
- reason: 'joinWithMedia failure',
1301
+ // check that only 1 attempt was done
1302
+ assert.calledOnce(meeting.join);
1303
+ assert.calledOnce(meeting.addMediaInternal);
1304
+ assert.calledOnce(Metrics.sendBehavioralMetric);
1305
+ assert.calledWith(
1306
+ Metrics.sendBehavioralMetric.firstCall,
1307
+ BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
1308
+ {
1309
+ correlation_id: meeting.correlationId,
1310
+ locus_id: meeting.locusUrl.split('/').pop(),
1311
+ reason: addMediaError.message,
1312
+ stack: addMediaError.stack,
1313
+ leaveErrorReason: undefined,
1314
+ isRetry: false,
1315
+ },
1316
+ {
1317
+ type: addMediaError.name,
1318
+ }
1319
+ );
1320
+ assert.calledOnceWithExactly(meeting.leave, {
1321
+ resourceId: undefined,
1322
+ reason: 'joinWithMedia failure',
1323
+ });
1284
1324
  });
1285
1325
  });
1286
1326
 
@@ -1349,6 +1389,21 @@ describe('plugin-meetings', () => {
1349
1389
  })
1350
1390
  );
1351
1391
  });
1392
+
1393
+ it('should throw immediately if RTCPeerConnection is not available', async () => {
1394
+ supportsRTCPeerConnectionStub.returns(CapabilityState.NOT_CAPABLE);
1395
+
1396
+ await assert.isRejected(
1397
+ meeting.joinWithMedia({
1398
+ joinOptions,
1399
+ mediaOptions,
1400
+ }),
1401
+ Errors.WebrtcApiNotAvailableError
1402
+ );
1403
+
1404
+ assert.notCalled(meeting.join);
1405
+ assert.notCalled(meeting.addMediaInternal);
1406
+ });
1352
1407
  });
1353
1408
  describe('#isTranscriptionSupported', () => {
1354
1409
  it('should return false if the feature is not supported for the meeting', () => {
@@ -1497,6 +1552,22 @@ describe('plugin-meetings', () => {
1497
1552
  EVENT_TRIGGERS.MEETING_STOPPED_RECEIVING_TRANSCRIPTION
1498
1553
  );
1499
1554
  });
1555
+
1556
+ it('should stop listening to voicea events even when transcription is undefined', () => {
1557
+ meeting.transcription = undefined;
1558
+ meeting.stopTranscription();
1559
+ assert.equal(webex.internal.voicea.off.callCount, 4);
1560
+ assert.equal(meeting.areVoiceaEventsSetup, false);
1561
+ assert.calledWith(
1562
+ TriggerProxy.trigger,
1563
+ sinon.match.instanceOf(Meeting),
1564
+ {
1565
+ file: 'meeting/index',
1566
+ function: 'triggerStopReceivingTranscriptionEvent',
1567
+ },
1568
+ EVENT_TRIGGERS.MEETING_STOPPED_RECEIVING_TRANSCRIPTION
1569
+ );
1570
+ });
1500
1571
  });
1501
1572
 
1502
1573
  describe('#setCaptionLanguage', () => {
@@ -1858,6 +1929,53 @@ describe('plugin-meetings', () => {
1858
1929
  fakeProcessedReaction
1859
1930
  );
1860
1931
  });
1932
+
1933
+ it('should process if participantId does not exist in membersCollection but has displayName in Webinar', () => {
1934
+ LoggerProxy.logger.warn = sinon.stub();
1935
+ meeting.isReactionsSupported = sinon.stub().returns(true);
1936
+ meeting.config.receiveReactions = true;
1937
+ meeting.locusInfo.info = {isWebinar: true};
1938
+ const fakeSendersName = 'Fake reactors name';
1939
+ const fakeReactionPayload = {
1940
+ type: 'fake_type',
1941
+ codepoints: 'fake_codepoints',
1942
+ shortcodes: 'fake_shortcodes',
1943
+ tone: {
1944
+ type: 'fake_tone_type',
1945
+ codepoints: 'fake_tone_codepoints',
1946
+ shortcodes: 'fake_tone_shortcodes',
1947
+ },
1948
+ };
1949
+ const fakeSenderPayload = {
1950
+ displayName: 'Fake reactors name',
1951
+ participantId: 'fake_participant_id',
1952
+ };
1953
+ const fakeProcessedReaction = {
1954
+ reaction: fakeReactionPayload,
1955
+ sender: {
1956
+ id: fakeSenderPayload.participantId,
1957
+ name: fakeSendersName,
1958
+ },
1959
+ };
1960
+ const fakeRelayEvent = {
1961
+ data: {
1962
+ relayType: REACTION_RELAY_TYPES.REACTION,
1963
+ reaction: fakeReactionPayload,
1964
+ sender: fakeSenderPayload,
1965
+ },
1966
+ };
1967
+ meeting.processRelayEvent(fakeRelayEvent);
1968
+ assert.calledWith(
1969
+ TriggerProxy.trigger,
1970
+ sinon.match.instanceOf(Meeting),
1971
+ {
1972
+ file: 'meeting/index',
1973
+ function: 'join',
1974
+ },
1975
+ EVENT_TRIGGERS.MEETING_RECEIVE_REACTIONS,
1976
+ fakeProcessedReaction
1977
+ );
1978
+ });
1861
1979
  });
1862
1980
 
1863
1981
  describe('#handleLLMOnline', () => {
@@ -3004,6 +3122,111 @@ describe('plugin-meetings', () => {
3004
3122
  checkWorking({allowMediaInLobby: true});
3005
3123
  });
3006
3124
 
3125
+ const setupLobbyTest = () => {
3126
+ meeting.roap.doTurnDiscovery = sinon
3127
+ .stub()
3128
+ .resolves({turnServerInfo: undefined, turnDiscoverySkippedReason: undefined});
3129
+
3130
+ meeting.meetingState = 'ACTIVE';
3131
+ meeting.locusInfo.parsedLocus = {self: {state: 'IDLE'}};
3132
+ meeting.isUserUnadmitted = true;
3133
+
3134
+ // Mock locusMediaRequest
3135
+ meeting.locusMediaRequest = {
3136
+ send: sinon.stub().resolves(),
3137
+ isConfluenceCreated: sinon.stub().returns(false),
3138
+ };
3139
+
3140
+ sinon.stub(RemoteMediaManagerModule, 'RemoteMediaManager').returns({
3141
+ start: sinon.stub().resolves(),
3142
+ on: sinon.stub(),
3143
+ logAllReceiveSlots: sinon.stub(),
3144
+ });
3145
+
3146
+ meeting.isMultistream = true;
3147
+
3148
+ const createFakeStream = (id) => ({
3149
+ on: sinon.stub(),
3150
+ off: sinon.stub(),
3151
+ userMuted: false,
3152
+ systemMuted: false,
3153
+ get muted() {
3154
+ return this.userMuted || this.systemMuted;
3155
+ },
3156
+ setUnmuteAllowed: sinon.stub(),
3157
+ setUserMuted: sinon.stub(),
3158
+ outputStream: {
3159
+ getTracks: () => [{id}],
3160
+ },
3161
+ getSettings: sinon.stub().returns({}),
3162
+ });
3163
+
3164
+ return {
3165
+ fakeMicrophoneStream: createFakeStream('fake mic'),
3166
+ fakeCameraStream: createFakeStream('fake camera'),
3167
+ };
3168
+ };
3169
+
3170
+ it('should not publish any local streams when in the lobby and allowPublishMediaInLobby is false', async () => {
3171
+ const {fakeMicrophoneStream, fakeCameraStream} = setupLobbyTest();
3172
+
3173
+ const publishStreamStub = sinon.stub();
3174
+ fakeMediaConnection.createSendSlot = sinon.stub().returns({
3175
+ publishStream: publishStreamStub,
3176
+ unpublishStream: sinon.stub(),
3177
+ setNamedMediaGroups: sinon.stub(),
3178
+ });
3179
+
3180
+ await meeting.addMedia({
3181
+ allowMediaInLobby: true,
3182
+ allowPublishMediaInLobby: false,
3183
+ audioEnabled: true,
3184
+ videoEnabled: true,
3185
+ localStreams: {
3186
+ microphone: fakeMicrophoneStream,
3187
+ camera: fakeCameraStream,
3188
+ },
3189
+ });
3190
+
3191
+ assert.notCalled(publishStreamStub);
3192
+ });
3193
+
3194
+ it('should publish local streams when in the lobby and allowPublishMediaInLobby is true', async () => {
3195
+ const {fakeMicrophoneStream, fakeCameraStream} = setupLobbyTest();
3196
+
3197
+ const audioSlot = {
3198
+ publishStream: sinon.stub(),
3199
+ unpublishStream: sinon.stub(),
3200
+ setNamedMediaGroups: sinon.stub(),
3201
+ };
3202
+ const videoSlot = {
3203
+ publishStream: sinon.stub(),
3204
+ unpublishStream: sinon.stub(),
3205
+ setNamedMediaGroups: sinon.stub(),
3206
+ };
3207
+
3208
+ fakeMediaConnection.createSendSlot = sinon.stub().callsFake((mediaType) => {
3209
+ if (mediaType === 'AUDIO-MAIN') {
3210
+ return audioSlot;
3211
+ }
3212
+ return videoSlot;
3213
+ });
3214
+
3215
+ await meeting.addMedia({
3216
+ allowMediaInLobby: true,
3217
+ allowPublishMediaInLobby: true,
3218
+ audioEnabled: true,
3219
+ videoEnabled: true,
3220
+ localStreams: {
3221
+ microphone: fakeMicrophoneStream,
3222
+ camera: fakeCameraStream,
3223
+ },
3224
+ });
3225
+
3226
+ assert.calledOnceWithExactly(audioSlot.publishStream, fakeMicrophoneStream);
3227
+ assert.calledOnceWithExactly(videoSlot.publishStream, fakeCameraStream);
3228
+ });
3229
+
3007
3230
  it('should create rtcMetrics and pass them to Media.createMediaConnection()', async () => {
3008
3231
  const setIntervalOriginal = window.setInterval;
3009
3232
  window.setInterval = sinon.stub().returns(1);
@@ -6194,7 +6417,10 @@ describe('plugin-meetings', () => {
6194
6417
  meeting.statsAnalyzer = {stopAnalyzer: sinon.stub().resolves()};
6195
6418
  meeting.unsetPeerConnections = sinon.stub().returns(true);
6196
6419
  meeting.logger.error = sinon.stub().returns(true);
6197
- meeting.updateLLMConnection = sinon.stub().returns(Promise.resolve());
6420
+ meeting.clearMeetingData = sinon.stub().callsFake(async () => {
6421
+ meeting.audio = null;
6422
+ meeting.video = null;
6423
+ });
6198
6424
  webex.internal.voicea.off = sinon.stub().returns(true);
6199
6425
  meeting.stopTranscription = sinon.stub();
6200
6426
  meeting.transcription = {};
@@ -6221,9 +6447,7 @@ describe('plugin-meetings', () => {
6221
6447
  assert.calledOnce(meeting.closePeerConnections);
6222
6448
  assert.calledOnce(meeting.unsetRemoteStreams);
6223
6449
  assert.calledOnce(meeting.unsetPeerConnections);
6224
- assert.calledOnce(meeting.stopTranscription);
6225
- assert.calledOnce(meeting.annotation.deregisterEvents);
6226
- assert.calledWith(webex.internal.llm.off, 'event:relay.event', meeting.processRelayEvent);
6450
+ assert.calledOnce(meeting.clearMeetingData);
6227
6451
  });
6228
6452
 
6229
6453
  it('should reset call diagnostic latencies correctly', async () => {
@@ -8224,7 +8448,10 @@ describe('plugin-meetings', () => {
8224
8448
  meeting.statsAnalyzer = {stopAnalyzer: sinon.stub().resolves()};
8225
8449
  meeting.unsetPeerConnections = sinon.stub().returns(true);
8226
8450
  meeting.logger.error = sinon.stub().returns(true);
8227
- meeting.updateLLMConnection = sinon.stub().returns(Promise.resolve());
8451
+ meeting.clearMeetingData = sinon.stub().callsFake(async () => {
8452
+ meeting.audio = null;
8453
+ meeting.video = null;
8454
+ });
8228
8455
  meeting.transcription = {};
8229
8456
  meeting.stopTranscription = sinon.stub();
8230
8457
 
@@ -8250,10 +8477,7 @@ describe('plugin-meetings', () => {
8250
8477
  assert.calledOnce(meeting?.closePeerConnections);
8251
8478
  assert.calledOnce(meeting?.unsetRemoteStreams);
8252
8479
  assert.calledOnce(meeting?.unsetPeerConnections);
8253
- assert.calledOnce(meeting?.stopTranscription);
8254
-
8255
- assert.called(meeting.annotation.deregisterEvents);
8256
- assert.calledWith(webex.internal.llm.off, 'event:relay.event', meeting.processRelayEvent);
8480
+ assert.calledOnce(meeting?.clearMeetingData);
8257
8481
  });
8258
8482
  });
8259
8483
 
@@ -9125,7 +9349,10 @@ describe('plugin-meetings', () => {
9125
9349
 
9126
9350
  // check that the right things were called by the callback
9127
9351
  assert.calledOnceWithExactly(meeting.waitForRemoteSDPAnswer);
9128
- assert.calledOnceWithExactly(meeting.mediaProperties.waitForMediaConnectionConnected);
9352
+ assert.calledOnceWithExactly(
9353
+ meeting.mediaProperties.waitForMediaConnectionConnected,
9354
+ meeting.correlationId
9355
+ );
9129
9356
  });
9130
9357
  });
9131
9358
 
@@ -10299,6 +10526,21 @@ describe('plugin-meetings', () => {
10299
10526
  EVENT_TRIGGERS.MEETING_INTERPRETATION_UPDATE
10300
10527
  );
10301
10528
  });
10529
+
10530
+ it('listens to the self id changed event and updates aiEnableRequest', () => {
10531
+ meeting.aiEnableRequest = {
10532
+ selfParticipantIdUpdate: sinon.stub(),
10533
+ };
10534
+
10535
+ const payload = {selfId: 'participant-test-123'};
10536
+
10537
+ meeting.locusInfo.emit({function: 'test', file: 'test'}, 'SELF_ID_CHANGED', payload);
10538
+
10539
+ assert.calledOnceWithExactly(
10540
+ meeting.aiEnableRequest.selfParticipantIdUpdate,
10541
+ payload.selfId
10542
+ );
10543
+ });
10302
10544
  });
10303
10545
 
10304
10546
  describe('#setUpBreakoutsListener', () => {
@@ -10546,6 +10788,24 @@ describe('plugin-meetings', () => {
10546
10788
  );
10547
10789
  });
10548
10790
 
10791
+ it('listens to MEETING_CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED', async () => {
10792
+ const aiSummaryNotification = {example: 'value'};
10793
+
10794
+ await meeting.locusInfo.emitScoped(
10795
+ {function: 'test', file: 'test'},
10796
+ LOCUSINFO.EVENTS.CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED,
10797
+ {aiSummaryNotification}
10798
+ );
10799
+
10800
+ assert.calledWith(
10801
+ TriggerProxy.trigger,
10802
+ meeting,
10803
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
10804
+ EVENT_TRIGGERS.MEETING_CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED,
10805
+ {aiSummaryNotification}
10806
+ );
10807
+ });
10808
+
10549
10809
  it('listens to MEETING_CONTROLS_MEETING_FULL_UPDATED', async () => {
10550
10810
  const state = {example: 'value'};
10551
10811
 
@@ -10818,6 +11078,9 @@ describe('plugin-meetings', () => {
10818
11078
  meeting.simultaneousInterpretation = {
10819
11079
  approvalUrlUpdate: sinon.stub().returns(undefined),
10820
11080
  };
11081
+ meeting.aiEnableRequest = {
11082
+ approvalUrlUpdate: sinon.stub().returns(undefined),
11083
+ };
10821
11084
 
10822
11085
  meeting.locusInfo.emit(
10823
11086
  {function: 'test', file: 'test'},
@@ -10837,6 +11100,10 @@ describe('plugin-meetings', () => {
10837
11100
  meeting.simultaneousInterpretation.approvalUrlUpdate,
10838
11101
  newLocusServices.services.approval.url
10839
11102
  );
11103
+ assert.calledWith(
11104
+ meeting.aiEnableRequest.approvalUrlUpdate,
11105
+ newLocusServices.services.approval.url
11106
+ );
10840
11107
  assert.calledOnce(meeting.recordingController.setSessionId);
10841
11108
  done();
10842
11109
  });
@@ -11242,6 +11509,41 @@ describe('plugin-meetings', () => {
11242
11509
  });
11243
11510
  });
11244
11511
 
11512
+ describe('localConstraintsChangeHandler', () => {
11513
+ it('calls updatePreferredBitrateKbps when not multistream', () => {
11514
+ meeting.isMultistream = false;
11515
+ meeting.mediaProperties.webrtcMediaConnection = {
11516
+ updatePreferredBitrateKbps: sinon.stub(),
11517
+ };
11518
+
11519
+ meeting.localConstraintsChangeHandler();
11520
+
11521
+ assert.calledOnce(
11522
+ meeting.mediaProperties.webrtcMediaConnection.updatePreferredBitrateKbps
11523
+ );
11524
+ });
11525
+
11526
+ it('does not call updatePreferredBitrateKbps when multistream', () => {
11527
+ meeting.isMultistream = true;
11528
+ meeting.mediaProperties.webrtcMediaConnection = {
11529
+ updatePreferredBitrateKbps: sinon.stub(),
11530
+ };
11531
+
11532
+ meeting.localConstraintsChangeHandler();
11533
+
11534
+ assert.notCalled(
11535
+ meeting.mediaProperties.webrtcMediaConnection.updatePreferredBitrateKbps
11536
+ );
11537
+ });
11538
+
11539
+ it('does not throw when webrtcMediaConnection is undefined', () => {
11540
+ meeting.isMultistream = false;
11541
+ meeting.mediaProperties.webrtcMediaConnection = undefined;
11542
+
11543
+ assert.doesNotThrow(() => meeting.localConstraintsChangeHandler());
11544
+ });
11545
+ });
11546
+
11245
11547
  describe('#parseMeetingInfo', () => {
11246
11548
  const checkParseMeetingInfo = (expectedInfoToParse) => {
11247
11549
  assert.equal(meeting.conversationUrl, expectedInfoToParse.conversationUrl);
@@ -11621,6 +11923,7 @@ describe('plugin-meetings', () => {
11621
11923
  let canUnsetDisallowUnmuteSpy;
11622
11924
  let canUserRaiseHandSpy;
11623
11925
  let bothLeaveAndEndMeetingAvailableSpy;
11926
+ let requireHostEndMeetingBeforeLeaveSpy;
11624
11927
  let canUserLowerAllHandsSpy;
11625
11928
  let canUserLowerSomeoneElsesHandSpy;
11626
11929
  let waitingForOthersToJoinSpy;
@@ -11632,6 +11935,8 @@ describe('plugin-meetings', () => {
11632
11935
  let canMoveToLobbySpy;
11633
11936
  let isSpokenLanguageAutoDetectionEnabledSpy;
11634
11937
  let showAutoEndMeetingWarningSpy;
11938
+ let canAttendeeRequestAiAssistantEnabledSpy;
11939
+ let attendeeRequestAiAssistantDeclinedAllSpy;
11635
11940
  // Due to import tree issues, hasHints must be stubed within the scope of the `it`.
11636
11941
 
11637
11942
  beforeEach(() => {
@@ -11652,6 +11957,10 @@ describe('plugin-meetings', () => {
11652
11957
  MeetingUtil,
11653
11958
  'bothLeaveAndEndMeetingAvailable'
11654
11959
  );
11960
+ requireHostEndMeetingBeforeLeaveSpy = sinon.spy(
11961
+ MeetingUtil,
11962
+ 'requireHostEndMeetingBeforeLeave'
11963
+ );
11655
11964
  canUserLowerSomeoneElsesHandSpy = sinon.spy(MeetingUtil, 'canUserLowerSomeoneElsesHand');
11656
11965
  waitingForOthersToJoinSpy = sinon.spy(MeetingUtil, 'waitingForOthersToJoin');
11657
11966
  canSendReactionsSpy = sinon.spy(MeetingUtil, 'canSendReactions');
@@ -11668,12 +11977,22 @@ describe('plugin-meetings', () => {
11668
11977
  MeetingUtil,
11669
11978
  'isSpokenLanguageAutoDetectionEnabled'
11670
11979
  );
11980
+ canAttendeeRequestAiAssistantEnabledSpy = sinon.spy(
11981
+ MeetingUtil,
11982
+ 'canAttendeeRequestAiAssistantEnabled'
11983
+ );
11984
+ attendeeRequestAiAssistantDeclinedAllSpy = sinon.spy(
11985
+ MeetingUtil,
11986
+ 'attendeeRequestAiAssistantDeclinedAll'
11987
+ );
11671
11988
  });
11672
11989
 
11673
11990
  afterEach(() => {
11674
11991
  inMeetingActionsSetSpy.restore();
11675
11992
  waitingForOthersToJoinSpy.restore();
11676
11993
  showAutoEndMeetingWarningSpy.restore();
11994
+ canAttendeeRequestAiAssistantEnabledSpy.restore();
11995
+ attendeeRequestAiAssistantDeclinedAllSpy.restore();
11677
11996
  });
11678
11997
 
11679
11998
  forEach(
@@ -12197,6 +12516,7 @@ describe('plugin-meetings', () => {
12197
12516
  const userDisplayHints = ['LOCK_CONTROL_UNLOCK'];
12198
12517
  meeting.userDisplayHints = ['LOCK_CONTROL_UNLOCK'];
12199
12518
  meeting.meetingInfo.supportVoIP = true;
12519
+ meeting.roles = [];
12200
12520
 
12201
12521
  meeting.updateMeetingActions();
12202
12522
 
@@ -12212,6 +12532,7 @@ describe('plugin-meetings', () => {
12212
12532
  assert.calledWith(canUnsetDisallowUnmuteSpy, userDisplayHints);
12213
12533
  assert.calledWith(canUserRaiseHandSpy, userDisplayHints);
12214
12534
  assert.calledWith(bothLeaveAndEndMeetingAvailableSpy, userDisplayHints);
12535
+ assert.calledWith(requireHostEndMeetingBeforeLeaveSpy, userDisplayHints);
12215
12536
  assert.calledWith(canUserLowerAllHandsSpy, userDisplayHints);
12216
12537
  assert.calledWith(canUserLowerSomeoneElsesHandSpy, userDisplayHints);
12217
12538
  assert.calledWith(waitingForOthersToJoinSpy, userDisplayHints);
@@ -12223,6 +12544,12 @@ describe('plugin-meetings', () => {
12223
12544
  assert.calledWith(canMoveToLobbySpy, userDisplayHints);
12224
12545
  assert.calledWith(showAutoEndMeetingWarningSpy, userDisplayHints);
12225
12546
  assert.calledWith(isSpokenLanguageAutoDetectionEnabledSpy, userDisplayHints);
12547
+ assert.calledWith(
12548
+ canAttendeeRequestAiAssistantEnabledSpy,
12549
+ userDisplayHints,
12550
+ meeting.roles
12551
+ );
12552
+ assert.calledWith(attendeeRequestAiAssistantDeclinedAllSpy, userDisplayHints);
12226
12553
 
12227
12554
  assert.calledWith(ControlsOptionsUtil.hasHints, {
12228
12555
  requiredHints: [DISPLAY_HINTS.MUTE_ALL],
@@ -12365,33 +12692,159 @@ describe('plugin-meetings', () => {
12365
12692
 
12366
12693
  describe('#handleDataChannelUrlChange', () => {
12367
12694
  let updateLLMConnectionSpy;
12695
+ let updatePSDataChannelSpy;
12368
12696
 
12369
12697
  beforeEach(() => {
12370
12698
  updateLLMConnectionSpy = sinon.spy(meeting, 'updateLLMConnection');
12699
+ updatePSDataChannelSpy = sinon.stub(meeting.webinar, 'updatePSDataChannel').resolves();
12700
+ meeting.webinar.isJoinPracticeSessionDataChannel = sinon.stub().returns(false);
12371
12701
  });
12372
12702
 
12373
- const check = (url, expectedCalled) => {
12374
- meeting.handleDataChannelUrlChange(url);
12703
+ const check = (
12704
+ url,
12705
+ practiceSessionDatachannelUrl,
12706
+ {expectedMainCalled, expectedPracticeCalled}
12707
+ ) => {
12708
+ meeting.handleDataChannelUrlChange(url, practiceSessionDatachannelUrl);
12375
12709
 
12376
- if (expectedCalled) {
12710
+ if (expectedMainCalled) {
12377
12711
  assert.calledWith(updateLLMConnectionSpy);
12378
12712
  } else {
12379
12713
  assert.notCalled(updateLLMConnectionSpy);
12380
12714
  }
12715
+
12716
+ if (expectedPracticeCalled) {
12717
+ assert.calledWith(updatePSDataChannelSpy);
12718
+ } else {
12719
+ assert.notCalled(updatePSDataChannelSpy);
12720
+ }
12381
12721
  };
12382
12722
 
12383
12723
  it('calls deferred updateLLMConnection if datachannelURL is set and the enableAutomaticLLM is true', () => {
12384
12724
  meeting.config.enableAutomaticLLM = true;
12385
- check('some url', true);
12725
+ check('some url', undefined, {expectedMainCalled: true, expectedPracticeCalled: false});
12386
12726
  });
12387
12727
 
12388
12728
  it('does not call updateLLMConnection if datachannelURL is undefined', () => {
12389
12729
  meeting.config.enableAutomaticLLM = true;
12390
- check(undefined, false);
12730
+ check(undefined, undefined, {
12731
+ expectedMainCalled: false,
12732
+ expectedPracticeCalled: false,
12733
+ });
12391
12734
  });
12392
12735
 
12393
12736
  it('does not call updateLLMConnection if enableAutomaticLLM is false', () => {
12394
- check('some url', false);
12737
+ check('some url', 'some practice url', {
12738
+ expectedMainCalled: false,
12739
+ expectedPracticeCalled: false,
12740
+ });
12741
+ });
12742
+
12743
+ it('calls updatePSDataChannel when practice-session routing is active', () => {
12744
+ meeting.config.enableAutomaticLLM = true;
12745
+ meeting.webinar.isJoinPracticeSessionDataChannel.returns(true);
12746
+
12747
+ check('some url', 'some practice url', {
12748
+ expectedMainCalled: true,
12749
+ expectedPracticeCalled: true,
12750
+ });
12751
+ });
12752
+
12753
+ it('does not call updatePSDataChannel when the main datachannelURL is undefined', () => {
12754
+ meeting.config.enableAutomaticLLM = true;
12755
+ meeting.webinar.isJoinPracticeSessionDataChannel.returns(true);
12756
+
12757
+ check(undefined, 'some practice url', {
12758
+ expectedMainCalled: false,
12759
+ expectedPracticeCalled: false,
12760
+ });
12761
+ });
12762
+ });
12763
+
12764
+ describe('#saveDataChannelToken', () => {
12765
+ beforeEach(() => {
12766
+ webex.internal.llm.setDatachannelToken = sinon.stub();
12767
+ });
12768
+
12769
+ it('saves datachannelToken into LLM as Default', () => {
12770
+ meeting.saveDataChannelToken({
12771
+ locus: {
12772
+ self: {datachannelToken: 'default-token'},
12773
+ },
12774
+ });
12775
+
12776
+ assert.calledWithExactly(
12777
+ webex.internal.llm.setDatachannelToken,
12778
+ 'default-token',
12779
+ 'llm-default-session'
12780
+ );
12781
+ });
12782
+
12783
+ it('saves practiceSessionDatachannelToken into LLM as PracticeSession', () => {
12784
+ meeting.saveDataChannelToken({
12785
+ locus: {
12786
+ self: {practiceSessionDatachannelToken: 'ps-token'},
12787
+ },
12788
+ });
12789
+
12790
+ assert.calledWithExactly(
12791
+ webex.internal.llm.setDatachannelToken,
12792
+ 'ps-token',
12793
+ 'llm-practice-session'
12794
+ );
12795
+ });
12796
+
12797
+ it('saves both tokens when both are present', () => {
12798
+ meeting.saveDataChannelToken({
12799
+ locus: {
12800
+ self: {
12801
+ datachannelToken: 'default-token',
12802
+ practiceSessionDatachannelToken: 'ps-token',
12803
+ },
12804
+ },
12805
+ });
12806
+
12807
+ assert.calledTwice(webex.internal.llm.setDatachannelToken);
12808
+ assert.calledWithExactly(
12809
+ webex.internal.llm.setDatachannelToken,
12810
+ 'default-token',
12811
+ 'llm-default-session'
12812
+ );
12813
+ assert.calledWithExactly(
12814
+ webex.internal.llm.setDatachannelToken,
12815
+ 'ps-token',
12816
+ 'llm-practice-session'
12817
+ );
12818
+ });
12819
+
12820
+ it('does not call setDatachannelToken when no tokens are present', () => {
12821
+ meeting.saveDataChannelToken({locus: {self: {}}});
12822
+
12823
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
12824
+ });
12825
+
12826
+ it('handles undefined join gracefully', () => {
12827
+ meeting.saveDataChannelToken(undefined);
12828
+
12829
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
12830
+ });
12831
+
12832
+ it('handles missing locus.self gracefully', () => {
12833
+ meeting.saveDataChannelToken({locus: {}});
12834
+
12835
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
12836
+ });
12837
+ });
12838
+
12839
+ describe('#clearDataChannelToken', () => {
12840
+ beforeEach(() => {
12841
+ webex.internal.llm.resetDatachannelTokens = sinon.stub();
12842
+ });
12843
+
12844
+ it('calls resetDatachannelTokens on LLM', () => {
12845
+ meeting.clearDataChannelToken();
12846
+
12847
+ assert.calledOnce(webex.internal.llm.resetDatachannelTokens);
12395
12848
  });
12396
12849
  });
12397
12850
 
@@ -12400,16 +12853,20 @@ describe('plugin-meetings', () => {
12400
12853
  webex.internal.llm.isConnected = sinon.stub().returns(false);
12401
12854
  webex.internal.llm.getLocusUrl = sinon.stub();
12402
12855
  webex.internal.llm.getDatachannelUrl = sinon.stub();
12403
- webex.internal.llm.registerAndConnect = sinon
12404
- .stub()
12405
- .returns(Promise.resolve('something'));
12406
- webex.internal.llm.disconnectLLM = sinon.stub().returns(Promise.resolve());
12407
- meeting.webex.internal.llm.on = sinon.stub();
12408
- meeting.webex.internal.llm.off = sinon.stub();
12856
+ webex.internal.llm.registerAndConnect = sinon.stub().resolves('something');
12857
+ webex.internal.llm.disconnectLLM = sinon.stub().resolves();
12858
+ webex.internal.llm.on = sinon.stub();
12859
+ webex.internal.llm.off = sinon.stub();
12860
+ webex.internal.llm.getDatachannelToken = sinon.stub().returns(undefined);
12861
+ webex.internal.llm.setDatachannelToken = sinon.stub();
12862
+
12409
12863
  meeting.processRelayEvent = sinon.stub();
12864
+ meeting.processLocusLLMEvent = sinon.stub();
12865
+ meeting.clearLLMHealthCheckTimer = sinon.stub();
12866
+ meeting.startLLMHealthCheckTimer = sinon.stub();
12867
+
12410
12868
  meeting.webinar.isJoinPracticeSessionDataChannel = sinon.stub().returns(false);
12411
12869
  });
12412
-
12413
12870
  it('does not connect if the call is not joined yet', async () => {
12414
12871
  meeting.joinedWith = {state: 'any other state'};
12415
12872
  webex.internal.llm.getLocusUrl.returns('a url');
@@ -12423,31 +12880,21 @@ describe('plugin-meetings', () => {
12423
12880
  assert.equal(result, undefined);
12424
12881
  assert.notCalled(meeting.webex.internal.llm.on);
12425
12882
  });
12426
-
12427
12883
  it('returns undefined if llm is already connected and the locus url is unchanged', async () => {
12428
12884
  meeting.joinedWith = {state: 'JOINED'};
12429
- webex.internal.llm.isConnected.returns(true);
12430
- webex.internal.llm.getLocusUrl.returns('a url');
12431
- webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
12432
-
12433
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12434
-
12435
- const result = await meeting.updateLLMConnection();
12436
-
12437
- assert.notCalled(webex.internal.llm.registerAndConnect);
12438
- assert.notCalled(webex.internal.llm.disconnectLLM);
12439
- assert.equal(result, undefined);
12440
- assert.notCalled(meeting.webex.internal.llm.on);
12441
- });
12442
-
12443
- it('connects if not already connected', async () => {
12444
- meeting.joinedWith = {state: 'JOINED'};
12445
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12885
+ meeting.locusInfo = {
12886
+ url: 'a url',
12887
+ info: {datachannelUrl: 'a datachannel url'},
12888
+ };
12446
12889
 
12447
12890
  const result = await meeting.updateLLMConnection();
12448
-
12449
12891
  assert.notCalled(webex.internal.llm.disconnectLLM);
12450
- assert.calledWith(webex.internal.llm.registerAndConnect, 'a url', 'a datachannel url');
12892
+ assert.calledWithExactly(
12893
+ webex.internal.llm.registerAndConnect,
12894
+ 'a url',
12895
+ 'a datachannel url',
12896
+ undefined
12897
+ );
12451
12898
  assert.equal(result, 'something');
12452
12899
  assert.calledWithExactly(
12453
12900
  meeting.webex.internal.llm.off,
@@ -12470,27 +12917,49 @@ describe('plugin-meetings', () => {
12470
12917
  meeting.processLocusLLMEvent
12471
12918
  );
12472
12919
  });
12920
+ it('connects if not already connected', async () => {
12921
+ meeting.joinedWith = {state: 'JOINED'};
12922
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12923
+
12924
+ const result = await meeting.updateLLMConnection();
12473
12925
 
12474
- it('disconnects if first if the locus url has changed', async () => {
12926
+ assert.notCalled(webex.internal.llm.disconnectLLM);
12927
+ assert.calledWithExactly(
12928
+ webex.internal.llm.registerAndConnect,
12929
+ 'a url',
12930
+ 'a datachannel url',
12931
+ undefined
12932
+ );
12933
+ assert.equal(result, 'something');
12934
+ });
12935
+ it('disconnects if the locus url has changed', async () => {
12475
12936
  meeting.joinedWith = {state: 'JOINED'};
12937
+
12476
12938
  webex.internal.llm.isConnected.returns(true);
12477
12939
  webex.internal.llm.getLocusUrl.returns('a url');
12478
- webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
12479
12940
 
12480
- meeting.locusInfo = {url: 'a different url', info: {datachannelUrl: 'a datachannel url'}};
12941
+ meeting.locusInfo = {
12942
+ url: 'a different url',
12943
+ info: {datachannelUrl: 'a datachannel url'},
12944
+ self: {},
12945
+ };
12481
12946
 
12482
12947
  const result = await meeting.updateLLMConnection();
12483
12948
 
12484
- assert.calledWith(webex.internal.llm.disconnectLLM, {
12949
+ assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
12485
12950
  code: 3050,
12486
12951
  reason: 'done (permanent)',
12487
12952
  });
12488
- assert.calledWith(
12953
+
12954
+ assert.calledWithExactly(
12489
12955
  webex.internal.llm.registerAndConnect,
12490
12956
  'a different url',
12491
- 'a datachannel url'
12957
+ 'a datachannel url',
12958
+ undefined
12492
12959
  );
12960
+
12493
12961
  assert.equal(result, 'something');
12962
+
12494
12963
  assert.calledWithExactly(
12495
12964
  meeting.webex.internal.llm.off,
12496
12965
  'event:relay.event',
@@ -12502,6 +12971,7 @@ describe('plugin-meetings', () => {
12502
12971
  meeting.processLocusLLMEvent
12503
12972
  );
12504
12973
  assert.callCount(meeting.webex.internal.llm.off, 4);
12974
+
12505
12975
  assert.calledWithExactly(
12506
12976
  meeting.webex.internal.llm.on,
12507
12977
  'event:relay.event',
@@ -12512,28 +12982,37 @@ describe('plugin-meetings', () => {
12512
12982
  'event:locus.state_message',
12513
12983
  meeting.processLocusLLMEvent
12514
12984
  );
12985
+ assert.isFalse(
12986
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
12987
+ );
12515
12988
  });
12516
-
12517
- it('disconnects it first if the data channel url has changed', async () => {
12989
+ it('disconnects if the data channel url has changed', async () => {
12518
12990
  meeting.joinedWith = {state: 'JOINED'};
12519
12991
  webex.internal.llm.isConnected.returns(true);
12520
12992
  webex.internal.llm.getLocusUrl.returns('a url');
12521
- webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
12522
12993
 
12523
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a different datachannel url'}};
12994
+ meeting.locusInfo = {
12995
+ url: 'a url',
12996
+ info: {datachannelUrl: 'a different datachannel url'},
12997
+ self: {},
12998
+ };
12524
12999
 
12525
13000
  const result = await meeting.updateLLMConnection();
12526
13001
 
12527
- assert.calledWith(webex.internal.llm.disconnectLLM, {
13002
+ assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
12528
13003
  code: 3050,
12529
13004
  reason: 'done (permanent)',
12530
13005
  });
12531
- assert.calledWith(
13006
+
13007
+ assert.calledWithExactly(
12532
13008
  webex.internal.llm.registerAndConnect,
12533
13009
  'a url',
12534
- 'a different datachannel url'
13010
+ 'a different datachannel url',
13011
+ undefined
12535
13012
  );
13013
+
12536
13014
  assert.equal(result, 'something');
13015
+
12537
13016
  assert.calledWithExactly(
12538
13017
  meeting.webex.internal.llm.off,
12539
13018
  'event:relay.event',
@@ -12544,6 +13023,7 @@ describe('plugin-meetings', () => {
12544
13023
  'event:locus.state_message',
12545
13024
  meeting.processLocusLLMEvent
12546
13025
  );
13026
+
12547
13027
  assert.calledWithExactly(
12548
13028
  meeting.webex.internal.llm.on,
12549
13029
  'event:relay.event',
@@ -12554,8 +13034,10 @@ describe('plugin-meetings', () => {
12554
13034
  'event:locus.state_message',
12555
13035
  meeting.processLocusLLMEvent
12556
13036
  );
13037
+ assert.isFalse(
13038
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
13039
+ );
12557
13040
  });
12558
-
12559
13041
  it('disconnects when the state is not JOINED', async () => {
12560
13042
  meeting.joinedWith = {state: 'any other state'};
12561
13043
  webex.internal.llm.isConnected.returns(true);
@@ -12565,9 +13047,38 @@ describe('plugin-meetings', () => {
12565
13047
 
12566
13048
  const result = await meeting.updateLLMConnection();
12567
13049
 
12568
- assert.calledWith(webex.internal.llm.disconnectLLM, undefined);
13050
+ assert.calledWith(webex.internal.llm.disconnectLLM, {
13051
+ code: 3050,
13052
+ reason: 'done (permanent)',
13053
+ });
12569
13054
  assert.notCalled(webex.internal.llm.registerAndConnect);
12570
13055
  assert.equal(result, undefined);
13056
+ assert.isFalse(
13057
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
13058
+ );
13059
+ });
13060
+ it('rethrows disconnect errors during reconnect cleanup after removing relay listeners and timer', async () => {
13061
+ const disconnectError = new Error('disconnect failed');
13062
+
13063
+ meeting.joinedWith = {state: 'JOINED'};
13064
+ webex.internal.llm.isConnected.returns(true);
13065
+ webex.internal.llm.getLocusUrl.returns('a url');
13066
+ webex.internal.llm.disconnectLLM.rejects(disconnectError);
13067
+
13068
+ meeting.locusInfo = {
13069
+ url: 'a different url',
13070
+ info: {datachannelUrl: 'a datachannel url'},
13071
+ self: {},
13072
+ };
13073
+
13074
+ try {
13075
+ await meeting.updateLLMConnection();
13076
+ assert.fail('Expected updateLLMConnection to reject when disconnectLLM fails');
13077
+ } catch (error) {
13078
+ assert.equal(error, disconnectError);
13079
+ }
13080
+
13081
+ assert.notCalled(webex.internal.llm.registerAndConnect);
12571
13082
  assert.calledWithExactly(
12572
13083
  meeting.webex.internal.llm.off,
12573
13084
  'event:relay.event',
@@ -12578,22 +13089,159 @@ describe('plugin-meetings', () => {
12578
13089
  'event:locus.state_message',
12579
13090
  meeting.processLocusLLMEvent
12580
13091
  );
13092
+ assert.isFalse(
13093
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
13094
+ );
13095
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
12581
13096
  });
12582
-
12583
- it('connect ps data channel if ps started in webinar', async () => {
13097
+ it('still need connect main session data channel when PS started', async () => {
12584
13098
  meeting.joinedWith = {state: 'JOINED'};
12585
13099
  meeting.locusInfo = {
12586
13100
  url: 'a url',
12587
13101
  info: {
12588
13102
  datachannelUrl: 'a datachannel url',
12589
- practiceSessionDatachannelUrl: 'a ps datachannel url',
13103
+ practiceSessionDatachannelUrl: 'ps-url',
12590
13104
  },
12591
13105
  };
12592
- meeting.webinar.isJoinPracticeSessionDataChannel = sinon.stub().returns(true);
13106
+ meeting.webinar.isJoinPracticeSessionDataChannel.returns(true);
13107
+
12593
13108
  await meeting.updateLLMConnection();
12594
13109
 
12595
- assert.notCalled(webex.internal.llm.disconnectLLM);
12596
- assert.calledWith(webex.internal.llm.registerAndConnect, 'a url', 'a ps datachannel url');
13110
+ assert.calledWithExactly(
13111
+ webex.internal.llm.registerAndConnect,
13112
+ 'a url',
13113
+ 'a datachannel url',
13114
+ undefined
13115
+ );
13116
+ });
13117
+ it('passes dataChannelToken from LLM to registerAndConnect', async () => {
13118
+ meeting.joinedWith = {state: 'JOINED'};
13119
+ meeting.locusInfo = {
13120
+ url: 'a url',
13121
+ info: {datachannelUrl: 'a datachannel url'},
13122
+ };
13123
+
13124
+ webex.internal.llm.getDatachannelToken.withArgs('llm-default-session').returns('token-123');
13125
+
13126
+ await meeting.updateLLMConnection();
13127
+
13128
+ assert.calledWithExactly(
13129
+ webex.internal.llm.registerAndConnect,
13130
+ 'a url',
13131
+ 'a datachannel url',
13132
+ 'token-123'
13133
+ );
13134
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
13135
+ });
13136
+ it('passes undefined token when LLM has no token stored', async () => {
13137
+ meeting.joinedWith = {state: 'JOINED'};
13138
+ meeting.locusInfo = {
13139
+ url: 'a url',
13140
+ info: {datachannelUrl: 'a datachannel url'},
13141
+ };
13142
+
13143
+ webex.internal.llm.getDatachannelToken.returns(undefined);
13144
+
13145
+ await meeting.updateLLMConnection();
13146
+
13147
+ assert.calledWithExactly(
13148
+ webex.internal.llm.registerAndConnect,
13149
+ 'a url',
13150
+ 'a datachannel url',
13151
+ undefined
13152
+ );
13153
+
13154
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
13155
+ });
13156
+
13157
+ it('does not pass token when data channel with jwt token is disabled', async () => {
13158
+ meeting.joinedWith = {state: 'JOINED'};
13159
+ meeting.locusInfo = {
13160
+ url: 'a url',
13161
+ info: {datachannelUrl: 'a datachannel url'},
13162
+ };
13163
+
13164
+ webex.internal.llm.getDatachannelToken.returns(undefined);
13165
+ webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
13166
+
13167
+ await meeting.updateLLMConnection();
13168
+
13169
+ assert.calledWithExactly(
13170
+ webex.internal.llm.registerAndConnect,
13171
+ 'a url',
13172
+ 'a datachannel url',
13173
+ undefined
13174
+ );
13175
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
13176
+ });
13177
+
13178
+ describe('#clearMeetingData', () => {
13179
+ beforeEach(() => {
13180
+ webex.internal.llm.isConnected = sinon.stub().returns(true);
13181
+ webex.internal.llm.disconnectLLM = sinon.stub().resolves();
13182
+ webex.internal.llm.off = sinon.stub();
13183
+ meeting.annotation.deregisterEvents = sinon.stub();
13184
+ meeting.clearLLMHealthCheckTimer = sinon.stub();
13185
+ meeting.stopTranscription = sinon.stub();
13186
+ meeting.clearDataChannelToken = sinon.stub();
13187
+ meeting.shareStatus = 'no-share';
13188
+ });
13189
+
13190
+ it('disconnects llm and removes online and relay listeners during meeting data cleanup', async () => {
13191
+ await meeting.clearMeetingData();
13192
+
13193
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13194
+ code: 3050,
13195
+ reason: 'done (permanent)',
13196
+ });
13197
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
13198
+ assert.calledWithExactly(
13199
+ webex.internal.llm.off,
13200
+ 'event:relay.event',
13201
+ meeting.processRelayEvent
13202
+ );
13203
+ assert.calledWithExactly(
13204
+ webex.internal.llm.off,
13205
+ 'event:locus.state_message',
13206
+ meeting.processLocusLLMEvent
13207
+ );
13208
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13209
+ assert.calledOnce(meeting.stopTranscription);
13210
+ assert.isUndefined(meeting.transcription);
13211
+ assert.calledOnce(meeting.clearDataChannelToken);
13212
+ assert.calledOnce(meeting.annotation.deregisterEvents);
13213
+ });
13214
+ it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => {
13215
+ webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
13216
+
13217
+ await meeting.clearMeetingData();
13218
+
13219
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
13220
+ assert.calledWithExactly(
13221
+ webex.internal.llm.off,
13222
+ 'event:relay.event',
13223
+ meeting.processRelayEvent
13224
+ );
13225
+ assert.calledWithExactly(
13226
+ webex.internal.llm.off,
13227
+ 'event:locus.state_message',
13228
+ meeting.processLocusLLMEvent
13229
+ );
13230
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13231
+ assert.calledOnce(meeting.stopTranscription);
13232
+ assert.isUndefined(meeting.transcription);
13233
+ assert.calledOnce(meeting.clearDataChannelToken);
13234
+ assert.calledOnce(meeting.annotation.deregisterEvents);
13235
+ });
13236
+ it('always calls stopTranscription even when transcription is undefined', async () => {
13237
+ meeting.transcription = undefined;
13238
+
13239
+ await meeting.clearMeetingData();
13240
+
13241
+ assert.calledOnce(meeting.stopTranscription);
13242
+ assert.isUndefined(meeting.transcription);
13243
+ assert.calledOnce(meeting.clearDataChannelToken);
13244
+ });
12597
13245
  });
12598
13246
  });
12599
13247
 
@@ -12604,6 +13252,7 @@ describe('plugin-meetings', () => {
12604
13252
 
12605
13253
  it('should read the locus object, set on the meeting and return null', () => {
12606
13254
  const dataSets = {someFakeStuff: 'dataSet'};
13255
+ const metadata = {some: 'metadata'};
12607
13256
 
12608
13257
  meeting.setLocus({
12609
13258
  mediaConnections: [test1],
@@ -12613,12 +13262,14 @@ describe('plugin-meetings', () => {
12613
13262
  mediaId: uuid3,
12614
13263
  locus: {host: {id: uuid4}},
12615
13264
  dataSets,
13265
+ metadata,
12616
13266
  });
12617
13267
  assert.calledOnce(meeting.locusInfo.initialSetup);
12618
13268
  assert.calledWith(meeting.locusInfo.initialSetup, {
12619
13269
  trigger: 'join-response',
12620
13270
  locus: {host: {id: uuid4}},
12621
13271
  dataSets,
13272
+ metadata,
12622
13273
  });
12623
13274
  assert.equal(meeting.mediaConnections, test1);
12624
13275
  assert.equal(meeting.locusUrl, url1);
@@ -14160,6 +14811,69 @@ describe('plugin-meetings', () => {
14160
14811
  assert.calledOnce(meeting.meetingRequest.keepAlive);
14161
14812
  });
14162
14813
  });
14814
+ describe('#refreshDataChannelToken()', () => {
14815
+ let meeting;
14816
+
14817
+ beforeEach(() => {
14818
+ meeting = Object.create(Meeting.prototype);
14819
+ meeting.locusUrl = 'https://locus.example.com';
14820
+ meeting.meetingRequest = {
14821
+ fetchDatachannelToken: sinon.stub().resolves({
14822
+ body: {datachannelToken: 'mock-token'},
14823
+ }),
14824
+ };
14825
+ meeting.members = {
14826
+ selfId: 'self-123',
14827
+ };
14828
+ meeting.webinar = {
14829
+ isJoinPracticeSessionDataChannel: sinon.stub().returns(true),
14830
+ };
14831
+ });
14832
+
14833
+ it('calls fetchDatachannelToken with correct parameters', async () => {
14834
+ await meeting.refreshDataChannelToken();
14835
+
14836
+ sinon.assert.calledOnce(meeting.meetingRequest.fetchDatachannelToken);
14837
+
14838
+ sinon.assert.calledWith(meeting.meetingRequest.fetchDatachannelToken, {
14839
+ locusUrl: 'https://locus.example.com',
14840
+ requestingParticipantId: 'self-123',
14841
+ isPracticeSession: true,
14842
+ });
14843
+ });
14844
+
14845
+ it('returns the correct structured result', async () => {
14846
+ const result = await meeting.refreshDataChannelToken();
14847
+
14848
+ expect(result).to.deep.equal({
14849
+ body: {
14850
+ datachannelToken: 'mock-token',
14851
+ dataChannelTokenType: 'llm-practice-session',
14852
+ },
14853
+ });
14854
+ });
14855
+ });
14856
+ describe('#getDataChannelTokenType', () => {
14857
+ it('returns PracticeSession when webinar is in practice session mode', () => {
14858
+ meeting.webinar = {
14859
+ isJoinPracticeSessionDataChannel: sinon.stub().returns(true),
14860
+ };
14861
+
14862
+ const result = meeting.getDataChannelTokenType();
14863
+
14864
+ expect(result).to.equal('llm-practice-session');
14865
+ });
14866
+
14867
+ it('returns Default when not in practice session mode', () => {
14868
+ meeting.webinar = {
14869
+ isJoinPracticeSessionDataChannel: sinon.stub().returns(false),
14870
+ };
14871
+
14872
+ const result = meeting.getDataChannelTokenType();
14873
+
14874
+ expect(result).to.equal('llm-default-session');
14875
+ });
14876
+ });
14163
14877
  describe('#stopKeepAlive', () => {
14164
14878
  let clock;
14165
14879
  const defaultKeepAliveUrl = 'keep.alive.url';