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

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 (176) 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 +13 -2
  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 +869 -420
  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 +32 -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 +1304 -928
  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 +6 -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/multistream/sendSlotManager.js +116 -2
  69. package/dist/multistream/sendSlotManager.js.map +1 -1
  70. package/dist/reachability/index.js +18 -10
  71. package/dist/reachability/index.js.map +1 -1
  72. package/dist/reactions/reactions.type.js.map +1 -1
  73. package/dist/reconnection-manager/index.js +0 -1
  74. package/dist/reconnection-manager/index.js.map +1 -1
  75. package/dist/types/aiEnableRequest/index.d.ts +5 -0
  76. package/dist/types/aiEnableRequest/utils.d.ts +2 -0
  77. package/dist/types/config.d.ts +4 -0
  78. package/dist/types/constants.d.ts +23 -1
  79. package/dist/types/hashTree/constants.d.ts +2 -0
  80. package/dist/types/hashTree/hashTree.d.ts +7 -0
  81. package/dist/types/hashTree/hashTreeParser.d.ts +122 -14
  82. package/dist/types/hashTree/types.d.ts +3 -0
  83. package/dist/types/hashTree/utils.d.ts +17 -0
  84. package/dist/types/index.d.ts +1 -0
  85. package/dist/types/interceptors/constant.d.ts +5 -0
  86. package/dist/types/interceptors/dataChannelAuthToken.d.ts +43 -0
  87. package/dist/types/interceptors/index.d.ts +2 -1
  88. package/dist/types/interceptors/utils.d.ts +1 -0
  89. package/dist/types/locus-info/index.d.ts +60 -8
  90. package/dist/types/locus-info/types.d.ts +7 -0
  91. package/dist/types/media/MediaConnectionAwaiter.d.ts +10 -1
  92. package/dist/types/media/properties.d.ts +2 -1
  93. package/dist/types/meeting/in-meeting-actions.d.ts +6 -0
  94. package/dist/types/meeting/index.d.ts +72 -7
  95. package/dist/types/meeting/request.d.ts +16 -1
  96. package/dist/types/meeting/request.type.d.ts +5 -0
  97. package/dist/types/meeting/util.d.ts +31 -0
  98. package/dist/types/meetings/index.d.ts +4 -2
  99. package/dist/types/member/index.d.ts +1 -0
  100. package/dist/types/member/util.d.ts +5 -0
  101. package/dist/types/metrics/constants.d.ts +5 -0
  102. package/dist/types/multistream/mediaRequestManager.d.ts +0 -23
  103. package/dist/types/multistream/sendSlotManager.d.ts +23 -1
  104. package/dist/types/reactions/reactions.type.d.ts +1 -0
  105. package/dist/types/webinar/utils.d.ts +6 -0
  106. package/dist/webinar/index.js +438 -163
  107. package/dist/webinar/index.js.map +1 -1
  108. package/dist/webinar/utils.js +25 -0
  109. package/dist/webinar/utils.js.map +1 -0
  110. package/package.json +24 -23
  111. package/src/aiEnableRequest/README.md +84 -0
  112. package/src/aiEnableRequest/index.ts +170 -0
  113. package/src/aiEnableRequest/utils.ts +25 -0
  114. package/src/annotation/index.ts +27 -7
  115. package/src/config.ts +4 -0
  116. package/src/constants.ts +29 -1
  117. package/src/hashTree/constants.ts +10 -0
  118. package/src/hashTree/hashTree.ts +17 -0
  119. package/src/hashTree/hashTreeParser.ts +764 -264
  120. package/src/hashTree/types.ts +4 -0
  121. package/src/hashTree/utils.ts +26 -0
  122. package/src/index.ts +8 -1
  123. package/src/interceptors/constant.ts +6 -0
  124. package/src/interceptors/dataChannelAuthToken.ts +170 -0
  125. package/src/interceptors/index.ts +2 -1
  126. package/src/interceptors/utils.ts +16 -0
  127. package/src/interpretation/index.ts +2 -2
  128. package/src/locus-info/controlsUtils.ts +11 -0
  129. package/src/locus-info/index.ts +579 -113
  130. package/src/locus-info/selfUtils.ts +1 -0
  131. package/src/locus-info/types.ts +8 -0
  132. package/src/media/MediaConnectionAwaiter.ts +41 -1
  133. package/src/media/properties.ts +3 -1
  134. package/src/meeting/in-meeting-actions.ts +12 -0
  135. package/src/meeting/index.ts +389 -87
  136. package/src/meeting/request.ts +42 -0
  137. package/src/meeting/request.type.ts +6 -0
  138. package/src/meeting/util.ts +160 -2
  139. package/src/meetings/index.ts +157 -44
  140. package/src/member/index.ts +10 -0
  141. package/src/member/util.ts +12 -0
  142. package/src/metrics/constants.ts +6 -0
  143. package/src/multistream/mediaRequestManager.ts +4 -54
  144. package/src/multistream/remoteMediaManager.ts +13 -0
  145. package/src/multistream/sendSlotManager.ts +97 -3
  146. package/src/reachability/index.ts +9 -0
  147. package/src/reactions/reactions.type.ts +1 -0
  148. package/src/reconnection-manager/index.ts +0 -1
  149. package/src/webinar/index.ts +265 -6
  150. package/src/webinar/utils.ts +16 -0
  151. package/test/unit/spec/aiEnableRequest/index.ts +981 -0
  152. package/test/unit/spec/aiEnableRequest/utils.ts +130 -0
  153. package/test/unit/spec/annotation/index.ts +69 -7
  154. package/test/unit/spec/hashTree/hashTree.ts +66 -0
  155. package/test/unit/spec/hashTree/hashTreeParser.ts +2469 -195
  156. package/test/unit/spec/hashTree/utils.ts +88 -1
  157. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +210 -0
  158. package/test/unit/spec/interceptors/utils.ts +75 -0
  159. package/test/unit/spec/locus-info/controlsUtils.js +29 -0
  160. package/test/unit/spec/locus-info/index.js +1134 -55
  161. package/test/unit/spec/media/MediaConnectionAwaiter.ts +41 -1
  162. package/test/unit/spec/media/properties.ts +12 -3
  163. package/test/unit/spec/meeting/in-meeting-actions.ts +8 -2
  164. package/test/unit/spec/meeting/index.js +884 -152
  165. package/test/unit/spec/meeting/request.js +70 -0
  166. package/test/unit/spec/meeting/utils.js +438 -26
  167. package/test/unit/spec/meetings/index.js +653 -32
  168. package/test/unit/spec/member/index.js +28 -4
  169. package/test/unit/spec/member/util.js +65 -27
  170. package/test/unit/spec/multistream/mediaRequestManager.ts +2 -85
  171. package/test/unit/spec/multistream/remoteMediaManager.ts +30 -0
  172. package/test/unit/spec/multistream/sendSlotManager.ts +135 -36
  173. package/test/unit/spec/reachability/index.ts +23 -0
  174. package/test/unit/spec/reconnection-manager/index.js +4 -8
  175. package/test/unit/spec/webinar/index.ts +534 -37
  176. package/test/unit/spec/webinar/utils.ts +39 -0
@@ -38,6 +38,7 @@ import {
38
38
  import {
39
39
  ConnectionState,
40
40
  MediaConnectionEventNames,
41
+ MediaCodecMimeType,
41
42
  StatsAnalyzerEventNames,
42
43
  StatsMonitorEventNames,
43
44
  Errors,
@@ -46,6 +47,7 @@ import {
46
47
  MediaType,
47
48
  } from '@webex/internal-media-core';
48
49
  import {LocalStreamEventNames} from '@webex/media-helpers';
50
+ import {CapabilityState, WebCapabilities} from '@webex/web-capabilities';
49
51
  import EventsScope from '@webex/plugin-meetings/src/common/events/events-scope';
50
52
  import Meetings, {CONSTANTS} from '@webex/plugin-meetings';
51
53
  import Meeting from '@webex/plugin-meetings/src/meeting';
@@ -81,6 +83,7 @@ import Mercury from '@webex/internal-plugin-mercury';
81
83
  import Breakouts from '@webex/plugin-meetings/src/breakouts';
82
84
  import SimultaneousInterpretation from '@webex/plugin-meetings/src/interpretation';
83
85
  import Webinar from '@webex/plugin-meetings/src/webinar';
86
+ import AIEnableRequest from '@webex/plugin-meetings/src/aiEnableRequest';
84
87
  import {REACTION_RELAY_TYPES} from '../../../../src/reactions/constants';
85
88
  import locus from '../fixture/locus';
86
89
  import {
@@ -122,7 +125,6 @@ import {EVENT_TRIGGERS as VOICEAEVENTS} from '@webex/internal-plugin-voicea';
122
125
  import {createBrbState} from '@webex/plugin-meetings/src/meeting/brbState';
123
126
  import JoinForbiddenError from '../../../../src/common/errors/join-forbidden-error';
124
127
  import {EventEmitter} from 'stream';
125
-
126
128
  describe('plugin-meetings', () => {
127
129
  const logger = {
128
130
  info: () => {},
@@ -264,7 +266,9 @@ describe('plugin-meetings', () => {
264
266
  stopReachability: sinon.stub(),
265
267
  isSubnetReachable: sinon.stub().returns(true),
266
268
  };
269
+ webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
267
270
  webex.internal.llm.on = sinon.stub();
271
+ webex.internal.voicea.announce = sinon.stub();
268
272
  webex.internal.newMetrics.callDiagnosticLatencies = new CallDiagnosticLatencies(
269
273
  {},
270
274
  {parent: webex}
@@ -375,6 +379,7 @@ describe('plugin-meetings', () => {
375
379
  assert.instanceOf(meeting.breakouts, Breakouts);
376
380
  assert.instanceOf(meeting.simultaneousInterpretation, SimultaneousInterpretation);
377
381
  assert.instanceOf(meeting.webinar, Webinar);
382
+ assert.instanceOf(meeting.aiEnableRequest, AIEnableRequest);
378
383
  });
379
384
 
380
385
  it('should call the callback with the meeting that has id already set', () => {
@@ -734,8 +739,13 @@ describe('plugin-meetings', () => {
734
739
  let handleTurnDiscoveryHttpResponseStub;
735
740
  let abortTurnDiscoveryStub;
736
741
  let addMediaInternalStub;
742
+ let supportsRTCPeerConnectionStub;
737
743
 
738
744
  beforeEach(() => {
745
+ supportsRTCPeerConnectionStub = sinon
746
+ .stub(WebCapabilities, 'supportsRTCPeerConnection')
747
+ .returns(CapabilityState.CAPABLE);
748
+
739
749
  meeting.join = sinon.stub().callsFake((joinOptions) => {
740
750
  meeting.isMultistream = joinOptions.enableMultistream;
741
751
  return Promise.resolve(fakeJoinResult);
@@ -1007,33 +1017,53 @@ describe('plugin-meetings', () => {
1007
1017
  );
1008
1018
  });
1009
1019
 
1010
- it('should call leave() if addMediaInternal() fails ', async () => {
1020
+ it('should call leave() if addMediaInternal() fails with a browser media error (TypeError)', async () => {
1011
1021
  const addMediaError = new Error('fake addMedia error');
1012
- addMediaError.name = 'TypeError';
1022
+ addMediaError.name = 'TypeError'; // This makes it a browser media error
1013
1023
 
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();
1024
+ const leaveStub = sinon.stub(meeting, 'leave').resolves();
1025
+ meeting.addMediaInternal = sinon.stub().rejects(addMediaError);
1025
1026
 
1026
- await assert.isRejected(
1027
+ // When a browser media error occurs, it gets transformed into a special structure
1028
+ const rejectedError = await assert.isRejected(
1027
1029
  meeting.joinWithMedia({
1028
1030
  joinOptions,
1029
1031
  mediaOptions,
1030
- }),
1031
- rejectError
1032
+ })
1032
1033
  );
1033
1034
 
1035
+ // Verify the error was transformed with errorCode 2729
1036
+ assert.equal(rejectedError.error.body.errorCode, 2729);
1037
+ assert.equal(rejectedError.error.body.message, 'fake addMedia error');
1038
+ assert.equal(rejectedError.error.body.name, 'TypeError');
1039
+
1034
1040
  assert.calledOnce(meeting.join);
1035
1041
  assert.calledOnce(meeting.addMediaInternal);
1042
+ assert.calledOnce(leaveStub);
1043
+ assert.calledOnceWithExactly(leaveStub, {
1044
+ resourceId: undefined,
1045
+ reason: 'joinWithMedia failure',
1046
+ });
1047
+
1048
+ // Browser media errors don't retry, so behavioral metric is sent only once
1049
+ // NOTE: The error gets transformed, so the metric receives undefined for message/stack/name
1050
+ // because they're now nested in error.body instead of at the top level
1036
1051
  assert.calledOnce(Metrics.sendBehavioralMetric);
1052
+ assert.calledWith(
1053
+ Metrics.sendBehavioralMetric,
1054
+ BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
1055
+ {
1056
+ correlation_id: meeting.correlationId,
1057
+ locus_id: meeting.locusUrl.split('/').pop(),
1058
+ reason: undefined, // transformed error doesn't have .message at top level
1059
+ stack: undefined, // transformed error doesn't have .stack at top level
1060
+ leaveErrorReason: undefined,
1061
+ isRetry: false,
1062
+ },
1063
+ {
1064
+ type: undefined, // transformed error doesn't have .name at top level
1065
+ }
1066
+ );
1037
1067
  });
1038
1068
 
1039
1069
  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 +1274,54 @@ describe('plugin-meetings', () => {
1244
1274
  await assert.isRejected(result);
1245
1275
  });
1246
1276
 
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';
1277
+ [
1278
+ {
1279
+ errorName: 'SdpOfferCreationError',
1280
+ description: 'if we fail to create the offer on first attempt',
1281
+ },
1282
+ {
1283
+ errorName: 'WebrtcApiNotAvailableError',
1284
+ description: 'if RTCPeerConnection is not available',
1285
+ },
1286
+ ].forEach(({errorName, description}) => {
1287
+ it(`should not attempt a retry ${description}`, async () => {
1288
+ const addMediaError = new Error('fake addMedia error');
1289
+ addMediaError.name = errorName;
1250
1290
 
1251
- meeting.addMediaInternal.rejects(addMediaError);
1252
- sinon.stub(meeting, 'leave').resolves();
1291
+ meeting.addMediaInternal.rejects(addMediaError);
1292
+ sinon.stub(meeting, 'leave').resolves();
1253
1293
 
1254
- await assert.isRejected(
1255
- meeting.joinWithMedia({
1256
- joinOptions,
1257
- mediaOptions,
1258
- }),
1259
- addMediaError
1260
- );
1294
+ await assert.isRejected(
1295
+ meeting.joinWithMedia({
1296
+ joinOptions,
1297
+ mediaOptions,
1298
+ }),
1299
+ addMediaError
1300
+ );
1261
1301
 
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',
1302
+ // check that only 1 attempt was done
1303
+ assert.calledOnce(meeting.join);
1304
+ assert.calledOnce(meeting.addMediaInternal);
1305
+ assert.calledOnce(Metrics.sendBehavioralMetric);
1306
+ assert.calledWith(
1307
+ Metrics.sendBehavioralMetric.firstCall,
1308
+ BEHAVIORAL_METRICS.JOIN_WITH_MEDIA_FAILURE,
1309
+ {
1310
+ correlation_id: meeting.correlationId,
1311
+ locus_id: meeting.locusUrl.split('/').pop(),
1312
+ reason: addMediaError.message,
1313
+ stack: addMediaError.stack,
1314
+ leaveErrorReason: undefined,
1315
+ isRetry: false,
1316
+ },
1317
+ {
1318
+ type: addMediaError.name,
1319
+ }
1320
+ );
1321
+ assert.calledOnceWithExactly(meeting.leave, {
1322
+ resourceId: undefined,
1323
+ reason: 'joinWithMedia failure',
1324
+ });
1284
1325
  });
1285
1326
  });
1286
1327
 
@@ -1349,6 +1390,21 @@ describe('plugin-meetings', () => {
1349
1390
  })
1350
1391
  );
1351
1392
  });
1393
+
1394
+ it('should throw immediately if RTCPeerConnection is not available', async () => {
1395
+ supportsRTCPeerConnectionStub.returns(CapabilityState.NOT_CAPABLE);
1396
+
1397
+ await assert.isRejected(
1398
+ meeting.joinWithMedia({
1399
+ joinOptions,
1400
+ mediaOptions,
1401
+ }),
1402
+ Errors.WebrtcApiNotAvailableError
1403
+ );
1404
+
1405
+ assert.notCalled(meeting.join);
1406
+ assert.notCalled(meeting.addMediaInternal);
1407
+ });
1352
1408
  });
1353
1409
  describe('#isTranscriptionSupported', () => {
1354
1410
  it('should return false if the feature is not supported for the meeting', () => {
@@ -1497,6 +1553,22 @@ describe('plugin-meetings', () => {
1497
1553
  EVENT_TRIGGERS.MEETING_STOPPED_RECEIVING_TRANSCRIPTION
1498
1554
  );
1499
1555
  });
1556
+
1557
+ it('should stop listening to voicea events even when transcription is undefined', () => {
1558
+ meeting.transcription = undefined;
1559
+ meeting.stopTranscription();
1560
+ assert.equal(webex.internal.voicea.off.callCount, 4);
1561
+ assert.equal(meeting.areVoiceaEventsSetup, false);
1562
+ assert.calledWith(
1563
+ TriggerProxy.trigger,
1564
+ sinon.match.instanceOf(Meeting),
1565
+ {
1566
+ file: 'meeting/index',
1567
+ function: 'triggerStopReceivingTranscriptionEvent',
1568
+ },
1569
+ EVENT_TRIGGERS.MEETING_STOPPED_RECEIVING_TRANSCRIPTION
1570
+ );
1571
+ });
1500
1572
  });
1501
1573
 
1502
1574
  describe('#setCaptionLanguage', () => {
@@ -1858,16 +1930,64 @@ describe('plugin-meetings', () => {
1858
1930
  fakeProcessedReaction
1859
1931
  );
1860
1932
  });
1933
+
1934
+ it('should process if participantId does not exist in membersCollection but has displayName in Webinar', () => {
1935
+ LoggerProxy.logger.warn = sinon.stub();
1936
+ meeting.isReactionsSupported = sinon.stub().returns(true);
1937
+ meeting.config.receiveReactions = true;
1938
+ meeting.locusInfo.info = {isWebinar: true};
1939
+ const fakeSendersName = 'Fake reactors name';
1940
+ const fakeReactionPayload = {
1941
+ type: 'fake_type',
1942
+ codepoints: 'fake_codepoints',
1943
+ shortcodes: 'fake_shortcodes',
1944
+ tone: {
1945
+ type: 'fake_tone_type',
1946
+ codepoints: 'fake_tone_codepoints',
1947
+ shortcodes: 'fake_tone_shortcodes',
1948
+ },
1949
+ };
1950
+ const fakeSenderPayload = {
1951
+ displayName: 'Fake reactors name',
1952
+ participantId: 'fake_participant_id',
1953
+ };
1954
+ const fakeProcessedReaction = {
1955
+ reaction: fakeReactionPayload,
1956
+ sender: {
1957
+ id: fakeSenderPayload.participantId,
1958
+ name: fakeSendersName,
1959
+ },
1960
+ };
1961
+ const fakeRelayEvent = {
1962
+ data: {
1963
+ relayType: REACTION_RELAY_TYPES.REACTION,
1964
+ reaction: fakeReactionPayload,
1965
+ sender: fakeSenderPayload,
1966
+ },
1967
+ };
1968
+ meeting.processRelayEvent(fakeRelayEvent);
1969
+ assert.calledWith(
1970
+ TriggerProxy.trigger,
1971
+ sinon.match.instanceOf(Meeting),
1972
+ {
1973
+ file: 'meeting/index',
1974
+ function: 'join',
1975
+ },
1976
+ EVENT_TRIGGERS.MEETING_RECEIVE_REACTIONS,
1977
+ fakeProcessedReaction
1978
+ );
1979
+ });
1861
1980
  });
1862
1981
 
1863
1982
  describe('#handleLLMOnline', () => {
1864
1983
  beforeEach(() => {
1865
1984
  webex.internal.llm.off = sinon.stub();
1985
+ webex.internal.voicea.getIsCaptionBoxOn = sinon.stub().returns(false);
1986
+ webex.internal.voicea.updateSubchannelSubscriptions = sinon.stub();
1866
1987
  });
1867
1988
 
1868
- it('turns off llm online, emits transcription connected events', () => {
1989
+ it('emits transcription connected events', () => {
1869
1990
  meeting.handleLLMOnline();
1870
- assert.calledOnceWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
1871
1991
  assert.calledWith(
1872
1992
  TriggerProxy.trigger,
1873
1993
  sinon.match.instanceOf(Meeting),
@@ -1878,6 +1998,24 @@ describe('plugin-meetings', () => {
1878
1998
  EVENT_TRIGGERS.MEETING_TRANSCRIPTION_CONNECTED
1879
1999
  );
1880
2000
  });
2001
+
2002
+ it('restores transcription subscription when caption intent is enabled', () => {
2003
+ webex.internal.voicea.getIsCaptionBoxOn.returns(true);
2004
+
2005
+ meeting.handleLLMOnline();
2006
+
2007
+ assert.calledOnceWithExactly(webex.internal.voicea.updateSubchannelSubscriptions, {
2008
+ subscribe: ['transcription'],
2009
+ });
2010
+ });
2011
+
2012
+ it('does not restore transcription subscription when caption intent is disabled', () => {
2013
+ webex.internal.voicea.getIsCaptionBoxOn.returns(false);
2014
+
2015
+ meeting.handleLLMOnline();
2016
+
2017
+ assert.notCalled(webex.internal.voicea.updateSubchannelSubscriptions);
2018
+ });
1881
2019
  });
1882
2020
 
1883
2021
  describe('#join', () => {
@@ -1897,6 +2035,7 @@ describe('plugin-meetings', () => {
1897
2035
  it('should have #join', () => {
1898
2036
  assert.exists(meeting.join);
1899
2037
  });
2038
+
1900
2039
  beforeEach(() => {
1901
2040
  setCorrelationIdSpy = sinon.spy(meeting, 'setCorrelationId');
1902
2041
  meeting.setLocus = sinon.stub().returns(true);
@@ -2050,7 +2189,6 @@ describe('plugin-meetings', () => {
2050
2189
  await meeting.join().catch(() => {
2051
2190
  assert.calledOnce(MeetingUtil.joinMeeting);
2052
2191
 
2053
- // Assert that client.locus.join.response error event is not sent from this function, it is now emitted from MeetingUtil.joinMeeting
2054
2192
  assert.calledOnce(webex.internal.newMetrics.submitClientEvent);
2055
2193
  assert.calledWithMatch(webex.internal.newMetrics.submitClientEvent, {
2056
2194
  name: 'client.call.initiated',
@@ -2082,6 +2220,7 @@ describe('plugin-meetings', () => {
2082
2220
  });
2083
2221
  });
2084
2222
  });
2223
+
2085
2224
  describe('lmm, transcription & permissionTokenRefresh decoupling', () => {
2086
2225
  beforeEach(() => {
2087
2226
  sandbox.stub(MeetingUtil, 'joinMeeting').returns(Promise.resolve(joinMeetingResult));
@@ -2152,7 +2291,6 @@ describe('plugin-meetings', () => {
2152
2291
  const locusInfoParseStub = sinon.stub(meeting.locusInfo, 'parse');
2153
2292
  sinon.stub(meeting, 'isJoined').returns(true);
2154
2293
 
2155
- // Set up llm.on stub to capture the registered listener when updateLLMConnection is called
2156
2294
  let locusLLMEventListener;
2157
2295
  meeting.webex.internal.llm.on = sinon.stub().callsFake((eventName, callback) => {
2158
2296
  if (eventName === 'event:locus.state_message') {
@@ -2161,16 +2299,12 @@ describe('plugin-meetings', () => {
2161
2299
  });
2162
2300
  meeting.webex.internal.llm.off = sinon.stub();
2163
2301
 
2164
- // we need the real meeting.updateLLMConnection not the mock
2165
2302
  meeting.updateLLMConnection.restore();
2166
2303
 
2167
- // Call updateLLMConnection to register the listener
2168
2304
  await meeting.updateLLMConnection();
2169
2305
 
2170
- // Verify the listener was registered and we captured it
2171
2306
  assert.isDefined(locusLLMEventListener, 'LLM event listener should be registered');
2172
2307
 
2173
- // Now trigger the event
2174
2308
  const eventData = {
2175
2309
  eventType: 'locus.state_message',
2176
2310
  stateElementsMessage: {
@@ -2190,13 +2324,10 @@ describe('plugin-meetings', () => {
2190
2324
  sinon.stub(meeting.webex.internal.llm, 'hasEverConnected').value(true);
2191
2325
  sinon.stub(meeting.webex.internal.llm, 'registerAndConnect').resolves({});
2192
2326
 
2193
- // Restore the real updateLLMConnection
2194
2327
  meeting.updateLLMConnection.restore();
2195
2328
 
2196
- // Call updateLLMConnection to start the timer
2197
2329
  await meeting.updateLLMConnection();
2198
2330
 
2199
- // Fast forward time by 3 minutes
2200
2331
  fakeClock.tick(3 * 60 * 1000);
2201
2332
 
2202
2333
  assert.calledWith(
@@ -2221,18 +2352,14 @@ describe('plugin-meetings', () => {
2221
2352
  .stub(meeting.webex.internal.llm, 'getDatachannelUrl')
2222
2353
  .returns('https://datachannel1.example.com');
2223
2354
 
2224
- // Restore the real updateLLMConnection
2225
2355
  meeting.updateLLMConnection.restore();
2226
2356
 
2227
- // First, connect LLM and start the timer
2228
2357
  isJoinedStub.returns(true);
2229
2358
  meeting.webex.internal.llm.isConnected.returns(false);
2230
2359
  await meeting.updateLLMConnection();
2231
2360
 
2232
- // Verify timer was started
2233
2361
  assert.exists(meeting.llmHealthCheckTimer);
2234
2362
 
2235
- // Now simulate that we're no longer joined
2236
2363
  isJoinedStub.returns(false);
2237
2364
  meeting.webex.internal.llm.isConnected.returns(true);
2238
2365
 
@@ -2240,10 +2367,8 @@ describe('plugin-meetings', () => {
2240
2367
 
2241
2368
  assert.calledOnce(meeting.webex.internal.llm.disconnectLLM);
2242
2369
 
2243
- // Verify the timer was cleared (should be undefined)
2244
2370
  assert.isUndefined(meeting.llmHealthCheckTimer);
2245
2371
 
2246
- // Fast forward time to ensure no metric is sent
2247
2372
  Metrics.sendBehavioralMetric.resetHistory();
2248
2373
  fakeClock.tick(3 * 60 * 1000);
2249
2374
 
@@ -2278,7 +2403,6 @@ describe('plugin-meetings', () => {
2278
2403
  .stub()
2279
2404
  .rejects(new CaptchaError('bad captcha'));
2280
2405
  const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
2281
- const joinMeetingOptionsSpy = sinon.spy(MeetingUtil, 'joinMeetingOptions');
2282
2406
 
2283
2407
  try {
2284
2408
  await meeting.join();
@@ -2292,8 +2416,7 @@ describe('plugin-meetings', () => {
2292
2416
  );
2293
2417
  assert.instanceOf(error, CaptchaError);
2294
2418
  assert.equal(error.message, 'bad captcha');
2295
- // should not get to the end promise chain, which does do the join
2296
- assert.notCalled(joinMeetingOptionsSpy);
2419
+ assert.notCalled(MeetingUtil.joinMeeting);
2297
2420
  }
2298
2421
  });
2299
2422
 
@@ -2302,7 +2425,6 @@ describe('plugin-meetings', () => {
2302
2425
  .stub()
2303
2426
  .rejects(new PasswordError('bad password'));
2304
2427
  const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
2305
- const joinMeetingOptionsSpy = sinon.spy(MeetingUtil.joinMeetingOptions);
2306
2428
 
2307
2429
  try {
2308
2430
  await meeting.join();
@@ -2316,8 +2438,7 @@ describe('plugin-meetings', () => {
2316
2438
  );
2317
2439
  assert.instanceOf(error, PasswordError);
2318
2440
  assert.equal(error.message, 'bad password');
2319
- // should not get to the end promise chain, which does do the join
2320
- assert.notCalled(joinMeetingOptionsSpy);
2441
+ assert.notCalled(MeetingUtil.joinMeeting);
2321
2442
  }
2322
2443
  });
2323
2444
 
@@ -2326,7 +2447,6 @@ describe('plugin-meetings', () => {
2326
2447
  .stub()
2327
2448
  .rejects(new PermissionError('bad permission'));
2328
2449
  const stateMachineFailSpy = sinon.spy(meeting.meetingFiniteStateMachine, 'fail');
2329
- const joinMeetingOptionsSpy = sinon.spy(MeetingUtil.joinMeetingOptions);
2330
2450
 
2331
2451
  try {
2332
2452
  await meeting.join();
@@ -2340,14 +2460,14 @@ describe('plugin-meetings', () => {
2340
2460
  );
2341
2461
  assert.instanceOf(error, PermissionError);
2342
2462
  assert.equal(error.message, 'bad permission');
2343
- // should not get to the end promise chain, which does do the join
2344
- assert.notCalled(joinMeetingOptionsSpy);
2463
+ assert.notCalled(MeetingUtil.joinMeeting);
2345
2464
  }
2346
2465
  });
2347
2466
  });
2348
2467
  });
2349
2468
  });
2350
2469
 
2470
+
2351
2471
  describe('#addMedia', () => {
2352
2472
  const muteStateStub = {
2353
2473
  handleClientRequest: sinon.stub().returns(Promise.resolve(true)),
@@ -3004,6 +3124,111 @@ describe('plugin-meetings', () => {
3004
3124
  checkWorking({allowMediaInLobby: true});
3005
3125
  });
3006
3126
 
3127
+ const setupLobbyTest = () => {
3128
+ meeting.roap.doTurnDiscovery = sinon
3129
+ .stub()
3130
+ .resolves({turnServerInfo: undefined, turnDiscoverySkippedReason: undefined});
3131
+
3132
+ meeting.meetingState = 'ACTIVE';
3133
+ meeting.locusInfo.parsedLocus = {self: {state: 'IDLE'}};
3134
+ meeting.isUserUnadmitted = true;
3135
+
3136
+ // Mock locusMediaRequest
3137
+ meeting.locusMediaRequest = {
3138
+ send: sinon.stub().resolves(),
3139
+ isConfluenceCreated: sinon.stub().returns(false),
3140
+ };
3141
+
3142
+ sinon.stub(RemoteMediaManagerModule, 'RemoteMediaManager').returns({
3143
+ start: sinon.stub().resolves(),
3144
+ on: sinon.stub(),
3145
+ logAllReceiveSlots: sinon.stub(),
3146
+ });
3147
+
3148
+ meeting.isMultistream = true;
3149
+
3150
+ const createFakeStream = (id) => ({
3151
+ on: sinon.stub(),
3152
+ off: sinon.stub(),
3153
+ userMuted: false,
3154
+ systemMuted: false,
3155
+ get muted() {
3156
+ return this.userMuted || this.systemMuted;
3157
+ },
3158
+ setUnmuteAllowed: sinon.stub(),
3159
+ setUserMuted: sinon.stub(),
3160
+ outputStream: {
3161
+ getTracks: () => [{id}],
3162
+ },
3163
+ getSettings: sinon.stub().returns({}),
3164
+ });
3165
+
3166
+ return {
3167
+ fakeMicrophoneStream: createFakeStream('fake mic'),
3168
+ fakeCameraStream: createFakeStream('fake camera'),
3169
+ };
3170
+ };
3171
+
3172
+ it('should not publish any local streams when in the lobby and allowPublishMediaInLobby is false', async () => {
3173
+ const {fakeMicrophoneStream, fakeCameraStream} = setupLobbyTest();
3174
+
3175
+ const publishStreamStub = sinon.stub();
3176
+ fakeMediaConnection.createSendSlot = sinon.stub().returns({
3177
+ publishStream: publishStreamStub,
3178
+ unpublishStream: sinon.stub(),
3179
+ setNamedMediaGroups: sinon.stub(),
3180
+ });
3181
+
3182
+ await meeting.addMedia({
3183
+ allowMediaInLobby: true,
3184
+ allowPublishMediaInLobby: false,
3185
+ audioEnabled: true,
3186
+ videoEnabled: true,
3187
+ localStreams: {
3188
+ microphone: fakeMicrophoneStream,
3189
+ camera: fakeCameraStream,
3190
+ },
3191
+ });
3192
+
3193
+ assert.notCalled(publishStreamStub);
3194
+ });
3195
+
3196
+ it('should publish local streams when in the lobby and allowPublishMediaInLobby is true', async () => {
3197
+ const {fakeMicrophoneStream, fakeCameraStream} = setupLobbyTest();
3198
+
3199
+ const audioSlot = {
3200
+ publishStream: sinon.stub(),
3201
+ unpublishStream: sinon.stub(),
3202
+ setNamedMediaGroups: sinon.stub(),
3203
+ };
3204
+ const videoSlot = {
3205
+ publishStream: sinon.stub(),
3206
+ unpublishStream: sinon.stub(),
3207
+ setNamedMediaGroups: sinon.stub(),
3208
+ };
3209
+
3210
+ fakeMediaConnection.createSendSlot = sinon.stub().callsFake((mediaType) => {
3211
+ if (mediaType === 'AUDIO-MAIN') {
3212
+ return audioSlot;
3213
+ }
3214
+ return videoSlot;
3215
+ });
3216
+
3217
+ await meeting.addMedia({
3218
+ allowMediaInLobby: true,
3219
+ allowPublishMediaInLobby: true,
3220
+ audioEnabled: true,
3221
+ videoEnabled: true,
3222
+ localStreams: {
3223
+ microphone: fakeMicrophoneStream,
3224
+ camera: fakeCameraStream,
3225
+ },
3226
+ });
3227
+
3228
+ assert.calledOnceWithExactly(audioSlot.publishStream, fakeMicrophoneStream);
3229
+ assert.calledOnceWithExactly(videoSlot.publishStream, fakeCameraStream);
3230
+ });
3231
+
3007
3232
  it('should create rtcMetrics and pass them to Media.createMediaConnection()', async () => {
3008
3233
  const setIntervalOriginal = window.setInterval;
3009
3234
  window.setInterval = sinon.stub().returns(1);
@@ -6194,7 +6419,10 @@ describe('plugin-meetings', () => {
6194
6419
  meeting.statsAnalyzer = {stopAnalyzer: sinon.stub().resolves()};
6195
6420
  meeting.unsetPeerConnections = sinon.stub().returns(true);
6196
6421
  meeting.logger.error = sinon.stub().returns(true);
6197
- meeting.updateLLMConnection = sinon.stub().returns(Promise.resolve());
6422
+ meeting.clearMeetingData = sinon.stub().callsFake(async () => {
6423
+ meeting.audio = null;
6424
+ meeting.video = null;
6425
+ });
6198
6426
  webex.internal.voicea.off = sinon.stub().returns(true);
6199
6427
  meeting.stopTranscription = sinon.stub();
6200
6428
  meeting.transcription = {};
@@ -6221,9 +6449,7 @@ describe('plugin-meetings', () => {
6221
6449
  assert.calledOnce(meeting.closePeerConnections);
6222
6450
  assert.calledOnce(meeting.unsetRemoteStreams);
6223
6451
  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);
6452
+ assert.calledOnce(meeting.clearMeetingData);
6227
6453
  });
6228
6454
 
6229
6455
  it('should reset call diagnostic latencies correctly', async () => {
@@ -8224,7 +8450,10 @@ describe('plugin-meetings', () => {
8224
8450
  meeting.statsAnalyzer = {stopAnalyzer: sinon.stub().resolves()};
8225
8451
  meeting.unsetPeerConnections = sinon.stub().returns(true);
8226
8452
  meeting.logger.error = sinon.stub().returns(true);
8227
- meeting.updateLLMConnection = sinon.stub().returns(Promise.resolve());
8453
+ meeting.clearMeetingData = sinon.stub().callsFake(async () => {
8454
+ meeting.audio = null;
8455
+ meeting.video = null;
8456
+ });
8228
8457
  meeting.transcription = {};
8229
8458
  meeting.stopTranscription = sinon.stub();
8230
8459
 
@@ -8250,10 +8479,7 @@ describe('plugin-meetings', () => {
8250
8479
  assert.calledOnce(meeting?.closePeerConnections);
8251
8480
  assert.calledOnce(meeting?.unsetRemoteStreams);
8252
8481
  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);
8482
+ assert.calledOnce(meeting?.clearMeetingData);
8257
8483
  });
8258
8484
  });
8259
8485
 
@@ -8990,8 +9216,8 @@ describe('plugin-meetings', () => {
8990
9216
  const fakeMultistreamRoapMediaConnection = {
8991
9217
  createSendSlot: () => {
8992
9218
  return {
8993
- setCodecParameters: sinon.stub().resolves(),
8994
- deleteCodecParameters: sinon.stub().resolves(),
9219
+ setCustomCodecParameters: sinon.stub().resolves(),
9220
+ markCustomCodecParametersForDeletion: sinon.stub().resolves(),
8995
9221
  };
8996
9222
  },
8997
9223
  };
@@ -9014,27 +9240,29 @@ describe('plugin-meetings', () => {
9014
9240
  }
9015
9241
  );
9016
9242
 
9017
- it('should set the codec parameters when shouldEnableMusicMode is true', async () => {
9243
+ it('should set custom codec parameters when shouldEnableMusicMode is true', async () => {
9018
9244
  await meeting.enableMusicMode(true);
9019
9245
  assert.calledOnceWithExactly(
9020
- meeting.sendSlotManager.getSlot(MediaType.AudioMain).setCodecParameters,
9246
+ meeting.sendSlotManager.getSlot(MediaType.AudioMain).setCustomCodecParameters,
9247
+ MediaCodecMimeType.OPUS,
9021
9248
  {
9022
9249
  maxaveragebitrate: '64000',
9023
9250
  maxplaybackrate: '48000',
9024
9251
  }
9025
9252
  );
9026
9253
  assert.notCalled(
9027
- meeting.sendSlotManager.getSlot(MediaType.AudioMain).deleteCodecParameters
9254
+ meeting.sendSlotManager.getSlot(MediaType.AudioMain).markCustomCodecParametersForDeletion
9028
9255
  );
9029
9256
  });
9030
9257
 
9031
- it('should set the codec parameters when shouldEnableMusicMode is false', async () => {
9258
+ it('should mark custom codec parameters for deletion when shouldEnableMusicMode is false', async () => {
9032
9259
  await meeting.enableMusicMode(false);
9033
9260
  assert.calledOnceWithExactly(
9034
- meeting.sendSlotManager.getSlot(MediaType.AudioMain).deleteCodecParameters,
9261
+ meeting.sendSlotManager.getSlot(MediaType.AudioMain).markCustomCodecParametersForDeletion,
9262
+ MediaCodecMimeType.OPUS,
9035
9263
  ['maxaveragebitrate', 'maxplaybackrate']
9036
9264
  );
9037
- assert.notCalled(meeting.sendSlotManager.getSlot(MediaType.AudioMain).setCodecParameters);
9265
+ assert.notCalled(meeting.sendSlotManager.getSlot(MediaType.AudioMain).setCustomCodecParameters);
9038
9266
  });
9039
9267
  });
9040
9268
 
@@ -9125,7 +9353,10 @@ describe('plugin-meetings', () => {
9125
9353
 
9126
9354
  // check that the right things were called by the callback
9127
9355
  assert.calledOnceWithExactly(meeting.waitForRemoteSDPAnswer);
9128
- assert.calledOnceWithExactly(meeting.mediaProperties.waitForMediaConnectionConnected);
9356
+ assert.calledOnceWithExactly(
9357
+ meeting.mediaProperties.waitForMediaConnectionConnected,
9358
+ meeting.correlationId
9359
+ );
9129
9360
  });
9130
9361
  });
9131
9362
 
@@ -10186,14 +10417,24 @@ describe('plugin-meetings', () => {
10186
10417
  );
10187
10418
  done();
10188
10419
  });
10189
- it('listens to the self admitted guest event', (done) => {
10420
+ it('listens to the self admitted guest event without blocking on token prefetch', async () => {
10190
10421
  meeting.stopKeepAlive = sinon.stub();
10191
10422
  meeting.updateLLMConnection = sinon.stub();
10423
+ let resolvePrefetch;
10424
+
10425
+ meeting.ensureDefaultDatachannelTokenAfterAdmit = sinon
10426
+ .stub()
10427
+ .returns(new Promise((resolve) => {
10428
+ resolvePrefetch = resolve;
10429
+ }));
10192
10430
  meeting.rtcMetrics = {
10193
10431
  sendNextMetrics: sinon.stub(),
10194
10432
  };
10433
+
10195
10434
  meeting.locusInfo.emit({function: 'test', file: 'test'}, 'SELF_ADMITTED_GUEST', test1);
10435
+
10196
10436
  assert.calledOnceWithExactly(meeting.stopKeepAlive);
10437
+ assert.calledOnceWithExactly(meeting.ensureDefaultDatachannelTokenAfterAdmit);
10197
10438
  assert.calledThrice(TriggerProxy.trigger);
10198
10439
  assert.calledWith(
10199
10440
  TriggerProxy.trigger,
@@ -10212,7 +10453,11 @@ describe('plugin-meetings', () => {
10212
10453
  correlation_id: meeting.correlationId,
10213
10454
  }
10214
10455
  );
10215
- done();
10456
+
10457
+ resolvePrefetch(false);
10458
+ await Promise.resolve();
10459
+
10460
+ assert.calledOnce(meeting.updateLLMConnection);
10216
10461
  });
10217
10462
 
10218
10463
  it('listens to the breakouts changed event', () => {
@@ -10299,6 +10544,21 @@ describe('plugin-meetings', () => {
10299
10544
  EVENT_TRIGGERS.MEETING_INTERPRETATION_UPDATE
10300
10545
  );
10301
10546
  });
10547
+
10548
+ it('listens to the self id changed event and updates aiEnableRequest', () => {
10549
+ meeting.aiEnableRequest = {
10550
+ selfParticipantIdUpdate: sinon.stub(),
10551
+ };
10552
+
10553
+ const payload = {selfId: 'participant-test-123'};
10554
+
10555
+ meeting.locusInfo.emit({function: 'test', file: 'test'}, 'SELF_ID_CHANGED', payload);
10556
+
10557
+ assert.calledOnceWithExactly(
10558
+ meeting.aiEnableRequest.selfParticipantIdUpdate,
10559
+ payload.selfId
10560
+ );
10561
+ });
10302
10562
  });
10303
10563
 
10304
10564
  describe('#setUpBreakoutsListener', () => {
@@ -10546,6 +10806,24 @@ describe('plugin-meetings', () => {
10546
10806
  );
10547
10807
  });
10548
10808
 
10809
+ it('listens to MEETING_CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED', async () => {
10810
+ const aiSummaryNotification = {example: 'value'};
10811
+
10812
+ await meeting.locusInfo.emitScoped(
10813
+ {function: 'test', file: 'test'},
10814
+ LOCUSINFO.EVENTS.CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED,
10815
+ {aiSummaryNotification}
10816
+ );
10817
+
10818
+ assert.calledWith(
10819
+ TriggerProxy.trigger,
10820
+ meeting,
10821
+ {file: 'meeting/index', function: 'setupLocusControlsListener'},
10822
+ EVENT_TRIGGERS.MEETING_CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED,
10823
+ {aiSummaryNotification}
10824
+ );
10825
+ });
10826
+
10549
10827
  it('listens to MEETING_CONTROLS_MEETING_FULL_UPDATED', async () => {
10550
10828
  const state = {example: 'value'};
10551
10829
 
@@ -10818,6 +11096,9 @@ describe('plugin-meetings', () => {
10818
11096
  meeting.simultaneousInterpretation = {
10819
11097
  approvalUrlUpdate: sinon.stub().returns(undefined),
10820
11098
  };
11099
+ meeting.aiEnableRequest = {
11100
+ approvalUrlUpdate: sinon.stub().returns(undefined),
11101
+ };
10821
11102
 
10822
11103
  meeting.locusInfo.emit(
10823
11104
  {function: 'test', file: 'test'},
@@ -10837,7 +11118,11 @@ describe('plugin-meetings', () => {
10837
11118
  meeting.simultaneousInterpretation.approvalUrlUpdate,
10838
11119
  newLocusServices.services.approval.url
10839
11120
  );
10840
- assert.calledOnce(meeting.recordingController.setSessionId);
11121
+ assert.calledWith(
11122
+ meeting.aiEnableRequest.approvalUrlUpdate,
11123
+ newLocusServices.services.approval.url
11124
+ );
11125
+ assert.calledOnce(meeting.recordingController.setSessionId);
10841
11126
  done();
10842
11127
  });
10843
11128
  });
@@ -11242,6 +11527,41 @@ describe('plugin-meetings', () => {
11242
11527
  });
11243
11528
  });
11244
11529
 
11530
+ describe('localConstraintsChangeHandler', () => {
11531
+ it('calls updatePreferredBitrateKbps when not multistream', () => {
11532
+ meeting.isMultistream = false;
11533
+ meeting.mediaProperties.webrtcMediaConnection = {
11534
+ updatePreferredBitrateKbps: sinon.stub(),
11535
+ };
11536
+
11537
+ meeting.localConstraintsChangeHandler();
11538
+
11539
+ assert.calledOnce(
11540
+ meeting.mediaProperties.webrtcMediaConnection.updatePreferredBitrateKbps
11541
+ );
11542
+ });
11543
+
11544
+ it('does not call updatePreferredBitrateKbps when multistream', () => {
11545
+ meeting.isMultistream = true;
11546
+ meeting.mediaProperties.webrtcMediaConnection = {
11547
+ updatePreferredBitrateKbps: sinon.stub(),
11548
+ };
11549
+
11550
+ meeting.localConstraintsChangeHandler();
11551
+
11552
+ assert.notCalled(
11553
+ meeting.mediaProperties.webrtcMediaConnection.updatePreferredBitrateKbps
11554
+ );
11555
+ });
11556
+
11557
+ it('does not throw when webrtcMediaConnection is undefined', () => {
11558
+ meeting.isMultistream = false;
11559
+ meeting.mediaProperties.webrtcMediaConnection = undefined;
11560
+
11561
+ assert.doesNotThrow(() => meeting.localConstraintsChangeHandler());
11562
+ });
11563
+ });
11564
+
11245
11565
  describe('#parseMeetingInfo', () => {
11246
11566
  const checkParseMeetingInfo = (expectedInfoToParse) => {
11247
11567
  assert.equal(meeting.conversationUrl, expectedInfoToParse.conversationUrl);
@@ -11621,6 +11941,7 @@ describe('plugin-meetings', () => {
11621
11941
  let canUnsetDisallowUnmuteSpy;
11622
11942
  let canUserRaiseHandSpy;
11623
11943
  let bothLeaveAndEndMeetingAvailableSpy;
11944
+ let requireHostEndMeetingBeforeLeaveSpy;
11624
11945
  let canUserLowerAllHandsSpy;
11625
11946
  let canUserLowerSomeoneElsesHandSpy;
11626
11947
  let waitingForOthersToJoinSpy;
@@ -11632,6 +11953,8 @@ describe('plugin-meetings', () => {
11632
11953
  let canMoveToLobbySpy;
11633
11954
  let isSpokenLanguageAutoDetectionEnabledSpy;
11634
11955
  let showAutoEndMeetingWarningSpy;
11956
+ let canAttendeeRequestAiAssistantEnabledSpy;
11957
+ let attendeeRequestAiAssistantDeclinedAllSpy;
11635
11958
  // Due to import tree issues, hasHints must be stubed within the scope of the `it`.
11636
11959
 
11637
11960
  beforeEach(() => {
@@ -11652,6 +11975,10 @@ describe('plugin-meetings', () => {
11652
11975
  MeetingUtil,
11653
11976
  'bothLeaveAndEndMeetingAvailable'
11654
11977
  );
11978
+ requireHostEndMeetingBeforeLeaveSpy = sinon.spy(
11979
+ MeetingUtil,
11980
+ 'requireHostEndMeetingBeforeLeave'
11981
+ );
11655
11982
  canUserLowerSomeoneElsesHandSpy = sinon.spy(MeetingUtil, 'canUserLowerSomeoneElsesHand');
11656
11983
  waitingForOthersToJoinSpy = sinon.spy(MeetingUtil, 'waitingForOthersToJoin');
11657
11984
  canSendReactionsSpy = sinon.spy(MeetingUtil, 'canSendReactions');
@@ -11668,12 +11995,22 @@ describe('plugin-meetings', () => {
11668
11995
  MeetingUtil,
11669
11996
  'isSpokenLanguageAutoDetectionEnabled'
11670
11997
  );
11998
+ canAttendeeRequestAiAssistantEnabledSpy = sinon.spy(
11999
+ MeetingUtil,
12000
+ 'canAttendeeRequestAiAssistantEnabled'
12001
+ );
12002
+ attendeeRequestAiAssistantDeclinedAllSpy = sinon.spy(
12003
+ MeetingUtil,
12004
+ 'attendeeRequestAiAssistantDeclinedAll'
12005
+ );
11671
12006
  });
11672
12007
 
11673
12008
  afterEach(() => {
11674
12009
  inMeetingActionsSetSpy.restore();
11675
12010
  waitingForOthersToJoinSpy.restore();
11676
12011
  showAutoEndMeetingWarningSpy.restore();
12012
+ canAttendeeRequestAiAssistantEnabledSpy.restore();
12013
+ attendeeRequestAiAssistantDeclinedAllSpy.restore();
11677
12014
  });
11678
12015
 
11679
12016
  forEach(
@@ -12197,6 +12534,7 @@ describe('plugin-meetings', () => {
12197
12534
  const userDisplayHints = ['LOCK_CONTROL_UNLOCK'];
12198
12535
  meeting.userDisplayHints = ['LOCK_CONTROL_UNLOCK'];
12199
12536
  meeting.meetingInfo.supportVoIP = true;
12537
+ meeting.roles = [];
12200
12538
 
12201
12539
  meeting.updateMeetingActions();
12202
12540
 
@@ -12212,6 +12550,7 @@ describe('plugin-meetings', () => {
12212
12550
  assert.calledWith(canUnsetDisallowUnmuteSpy, userDisplayHints);
12213
12551
  assert.calledWith(canUserRaiseHandSpy, userDisplayHints);
12214
12552
  assert.calledWith(bothLeaveAndEndMeetingAvailableSpy, userDisplayHints);
12553
+ assert.calledWith(requireHostEndMeetingBeforeLeaveSpy, userDisplayHints);
12215
12554
  assert.calledWith(canUserLowerAllHandsSpy, userDisplayHints);
12216
12555
  assert.calledWith(canUserLowerSomeoneElsesHandSpy, userDisplayHints);
12217
12556
  assert.calledWith(waitingForOthersToJoinSpy, userDisplayHints);
@@ -12223,6 +12562,12 @@ describe('plugin-meetings', () => {
12223
12562
  assert.calledWith(canMoveToLobbySpy, userDisplayHints);
12224
12563
  assert.calledWith(showAutoEndMeetingWarningSpy, userDisplayHints);
12225
12564
  assert.calledWith(isSpokenLanguageAutoDetectionEnabledSpy, userDisplayHints);
12565
+ assert.calledWith(
12566
+ canAttendeeRequestAiAssistantEnabledSpy,
12567
+ userDisplayHints,
12568
+ meeting.roles
12569
+ );
12570
+ assert.calledWith(attendeeRequestAiAssistantDeclinedAllSpy, userDisplayHints);
12226
12571
 
12227
12572
  assert.calledWith(ControlsOptionsUtil.hasHints, {
12228
12573
  requiredHints: [DISPLAY_HINTS.MUTE_ALL],
@@ -12365,33 +12710,159 @@ describe('plugin-meetings', () => {
12365
12710
 
12366
12711
  describe('#handleDataChannelUrlChange', () => {
12367
12712
  let updateLLMConnectionSpy;
12713
+ let updatePSDataChannelSpy;
12368
12714
 
12369
12715
  beforeEach(() => {
12370
12716
  updateLLMConnectionSpy = sinon.spy(meeting, 'updateLLMConnection');
12717
+ updatePSDataChannelSpy = sinon.stub(meeting.webinar, 'updatePSDataChannel').resolves();
12718
+ meeting.webinar.isJoinPracticeSessionDataChannel = sinon.stub().returns(false);
12371
12719
  });
12372
12720
 
12373
- const check = (url, expectedCalled) => {
12374
- meeting.handleDataChannelUrlChange(url);
12721
+ const check = (
12722
+ url,
12723
+ practiceSessionDatachannelUrl,
12724
+ {expectedMainCalled, expectedPracticeCalled}
12725
+ ) => {
12726
+ meeting.handleDataChannelUrlChange(url, practiceSessionDatachannelUrl);
12375
12727
 
12376
- if (expectedCalled) {
12728
+ if (expectedMainCalled) {
12377
12729
  assert.calledWith(updateLLMConnectionSpy);
12378
12730
  } else {
12379
12731
  assert.notCalled(updateLLMConnectionSpy);
12380
12732
  }
12733
+
12734
+ if (expectedPracticeCalled) {
12735
+ assert.calledWith(updatePSDataChannelSpy);
12736
+ } else {
12737
+ assert.notCalled(updatePSDataChannelSpy);
12738
+ }
12381
12739
  };
12382
12740
 
12383
12741
  it('calls deferred updateLLMConnection if datachannelURL is set and the enableAutomaticLLM is true', () => {
12384
12742
  meeting.config.enableAutomaticLLM = true;
12385
- check('some url', true);
12743
+ check('some url', undefined, {expectedMainCalled: true, expectedPracticeCalled: false});
12386
12744
  });
12387
12745
 
12388
12746
  it('does not call updateLLMConnection if datachannelURL is undefined', () => {
12389
12747
  meeting.config.enableAutomaticLLM = true;
12390
- check(undefined, false);
12748
+ check(undefined, undefined, {
12749
+ expectedMainCalled: false,
12750
+ expectedPracticeCalled: false,
12751
+ });
12391
12752
  });
12392
12753
 
12393
12754
  it('does not call updateLLMConnection if enableAutomaticLLM is false', () => {
12394
- check('some url', false);
12755
+ check('some url', 'some practice url', {
12756
+ expectedMainCalled: false,
12757
+ expectedPracticeCalled: false,
12758
+ });
12759
+ });
12760
+
12761
+ it('calls updatePSDataChannel when practice-session routing is active', () => {
12762
+ meeting.config.enableAutomaticLLM = true;
12763
+ meeting.webinar.isJoinPracticeSessionDataChannel.returns(true);
12764
+
12765
+ check('some url', 'some practice url', {
12766
+ expectedMainCalled: true,
12767
+ expectedPracticeCalled: true,
12768
+ });
12769
+ });
12770
+
12771
+ it('does not call updatePSDataChannel when the main datachannelURL is undefined', () => {
12772
+ meeting.config.enableAutomaticLLM = true;
12773
+ meeting.webinar.isJoinPracticeSessionDataChannel.returns(true);
12774
+
12775
+ check(undefined, 'some practice url', {
12776
+ expectedMainCalled: false,
12777
+ expectedPracticeCalled: false,
12778
+ });
12779
+ });
12780
+ });
12781
+
12782
+ describe('#saveDataChannelToken', () => {
12783
+ beforeEach(() => {
12784
+ webex.internal.llm.setDatachannelToken = sinon.stub();
12785
+ });
12786
+
12787
+ it('saves datachannelToken into LLM as Default', () => {
12788
+ meeting.saveDataChannelToken({
12789
+ locus: {
12790
+ self: {datachannelToken: 'default-token'},
12791
+ },
12792
+ });
12793
+
12794
+ assert.calledWithExactly(
12795
+ webex.internal.llm.setDatachannelToken,
12796
+ 'default-token',
12797
+ 'llm-default-session'
12798
+ );
12799
+ });
12800
+
12801
+ it('saves practiceSessionDatachannelToken into LLM as PracticeSession', () => {
12802
+ meeting.saveDataChannelToken({
12803
+ locus: {
12804
+ self: {practiceSessionDatachannelToken: 'ps-token'},
12805
+ },
12806
+ });
12807
+
12808
+ assert.calledWithExactly(
12809
+ webex.internal.llm.setDatachannelToken,
12810
+ 'ps-token',
12811
+ 'llm-practice-session'
12812
+ );
12813
+ });
12814
+
12815
+ it('saves both tokens when both are present', () => {
12816
+ meeting.saveDataChannelToken({
12817
+ locus: {
12818
+ self: {
12819
+ datachannelToken: 'default-token',
12820
+ practiceSessionDatachannelToken: 'ps-token',
12821
+ },
12822
+ },
12823
+ });
12824
+
12825
+ assert.calledTwice(webex.internal.llm.setDatachannelToken);
12826
+ assert.calledWithExactly(
12827
+ webex.internal.llm.setDatachannelToken,
12828
+ 'default-token',
12829
+ 'llm-default-session'
12830
+ );
12831
+ assert.calledWithExactly(
12832
+ webex.internal.llm.setDatachannelToken,
12833
+ 'ps-token',
12834
+ 'llm-practice-session'
12835
+ );
12836
+ });
12837
+
12838
+ it('does not call setDatachannelToken when no tokens are present', () => {
12839
+ meeting.saveDataChannelToken({locus: {self: {}}});
12840
+
12841
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
12842
+ });
12843
+
12844
+ it('handles undefined join gracefully', () => {
12845
+ meeting.saveDataChannelToken(undefined);
12846
+
12847
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
12848
+ });
12849
+
12850
+ it('handles missing locus.self gracefully', () => {
12851
+ meeting.saveDataChannelToken({locus: {}});
12852
+
12853
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
12854
+ });
12855
+ });
12856
+
12857
+ describe('#clearDataChannelToken', () => {
12858
+ beforeEach(() => {
12859
+ webex.internal.llm.resetDatachannelTokens = sinon.stub();
12860
+ });
12861
+
12862
+ it('calls resetDatachannelTokens on LLM', () => {
12863
+ meeting.clearDataChannelToken();
12864
+
12865
+ assert.calledOnce(webex.internal.llm.resetDatachannelTokens);
12395
12866
  });
12396
12867
  });
12397
12868
 
@@ -12400,16 +12871,20 @@ describe('plugin-meetings', () => {
12400
12871
  webex.internal.llm.isConnected = sinon.stub().returns(false);
12401
12872
  webex.internal.llm.getLocusUrl = sinon.stub();
12402
12873
  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();
12874
+ webex.internal.llm.registerAndConnect = sinon.stub().resolves('something');
12875
+ webex.internal.llm.disconnectLLM = sinon.stub().resolves();
12876
+ webex.internal.llm.on = sinon.stub();
12877
+ webex.internal.llm.off = sinon.stub();
12878
+ webex.internal.llm.getDatachannelToken = sinon.stub().returns(undefined);
12879
+ webex.internal.llm.setDatachannelToken = sinon.stub();
12880
+
12409
12881
  meeting.processRelayEvent = sinon.stub();
12882
+ meeting.processLocusLLMEvent = sinon.stub();
12883
+ meeting.clearLLMHealthCheckTimer = sinon.stub();
12884
+ meeting.startLLMHealthCheckTimer = sinon.stub();
12885
+
12410
12886
  meeting.webinar.isJoinPracticeSessionDataChannel = sinon.stub().returns(false);
12411
12887
  });
12412
-
12413
12888
  it('does not connect if the call is not joined yet', async () => {
12414
12889
  meeting.joinedWith = {state: 'any other state'};
12415
12890
  webex.internal.llm.getLocusUrl.returns('a url');
@@ -12423,31 +12898,21 @@ describe('plugin-meetings', () => {
12423
12898
  assert.equal(result, undefined);
12424
12899
  assert.notCalled(meeting.webex.internal.llm.on);
12425
12900
  });
12426
-
12427
12901
  it('returns undefined if llm is already connected and the locus url is unchanged', async () => {
12428
12902
  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'}};
12903
+ meeting.locusInfo = {
12904
+ url: 'a url',
12905
+ info: {datachannelUrl: 'a datachannel url'},
12906
+ };
12446
12907
 
12447
12908
  const result = await meeting.updateLLMConnection();
12448
-
12449
12909
  assert.notCalled(webex.internal.llm.disconnectLLM);
12450
- assert.calledWith(webex.internal.llm.registerAndConnect, 'a url', 'a datachannel url');
12910
+ assert.calledWithExactly(
12911
+ webex.internal.llm.registerAndConnect,
12912
+ 'a url',
12913
+ 'a datachannel url',
12914
+ undefined
12915
+ );
12451
12916
  assert.equal(result, 'something');
12452
12917
  assert.calledWithExactly(
12453
12918
  meeting.webex.internal.llm.off,
@@ -12470,27 +12935,49 @@ describe('plugin-meetings', () => {
12470
12935
  meeting.processLocusLLMEvent
12471
12936
  );
12472
12937
  });
12938
+ it('connects if not already connected', async () => {
12939
+ meeting.joinedWith = {state: 'JOINED'};
12940
+ meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a datachannel url'}};
12941
+
12942
+ const result = await meeting.updateLLMConnection();
12473
12943
 
12474
- it('disconnects if first if the locus url has changed', async () => {
12944
+ assert.notCalled(webex.internal.llm.disconnectLLM);
12945
+ assert.calledWithExactly(
12946
+ webex.internal.llm.registerAndConnect,
12947
+ 'a url',
12948
+ 'a datachannel url',
12949
+ undefined
12950
+ );
12951
+ assert.equal(result, 'something');
12952
+ });
12953
+ it('disconnects if the locus url has changed', async () => {
12475
12954
  meeting.joinedWith = {state: 'JOINED'};
12955
+
12476
12956
  webex.internal.llm.isConnected.returns(true);
12477
12957
  webex.internal.llm.getLocusUrl.returns('a url');
12478
- webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
12479
12958
 
12480
- meeting.locusInfo = {url: 'a different url', info: {datachannelUrl: 'a datachannel url'}};
12959
+ meeting.locusInfo = {
12960
+ url: 'a different url',
12961
+ info: {datachannelUrl: 'a datachannel url'},
12962
+ self: {},
12963
+ };
12481
12964
 
12482
12965
  const result = await meeting.updateLLMConnection();
12483
12966
 
12484
- assert.calledWith(webex.internal.llm.disconnectLLM, {
12967
+ assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
12485
12968
  code: 3050,
12486
12969
  reason: 'done (permanent)',
12487
12970
  });
12488
- assert.calledWith(
12971
+
12972
+ assert.calledWithExactly(
12489
12973
  webex.internal.llm.registerAndConnect,
12490
12974
  'a different url',
12491
- 'a datachannel url'
12975
+ 'a datachannel url',
12976
+ undefined
12492
12977
  );
12978
+
12493
12979
  assert.equal(result, 'something');
12980
+
12494
12981
  assert.calledWithExactly(
12495
12982
  meeting.webex.internal.llm.off,
12496
12983
  'event:relay.event',
@@ -12502,6 +12989,7 @@ describe('plugin-meetings', () => {
12502
12989
  meeting.processLocusLLMEvent
12503
12990
  );
12504
12991
  assert.callCount(meeting.webex.internal.llm.off, 4);
12992
+
12505
12993
  assert.calledWithExactly(
12506
12994
  meeting.webex.internal.llm.on,
12507
12995
  'event:relay.event',
@@ -12512,28 +13000,37 @@ describe('plugin-meetings', () => {
12512
13000
  'event:locus.state_message',
12513
13001
  meeting.processLocusLLMEvent
12514
13002
  );
13003
+ assert.isFalse(
13004
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
13005
+ );
12515
13006
  });
12516
-
12517
- it('disconnects it first if the data channel url has changed', async () => {
13007
+ it('disconnects if the data channel url has changed', async () => {
12518
13008
  meeting.joinedWith = {state: 'JOINED'};
12519
13009
  webex.internal.llm.isConnected.returns(true);
12520
13010
  webex.internal.llm.getLocusUrl.returns('a url');
12521
- webex.internal.llm.getDatachannelUrl.returns('a datachannel url');
12522
13011
 
12523
- meeting.locusInfo = {url: 'a url', info: {datachannelUrl: 'a different datachannel url'}};
13012
+ meeting.locusInfo = {
13013
+ url: 'a url',
13014
+ info: {datachannelUrl: 'a different datachannel url'},
13015
+ self: {},
13016
+ };
12524
13017
 
12525
13018
  const result = await meeting.updateLLMConnection();
12526
13019
 
12527
- assert.calledWith(webex.internal.llm.disconnectLLM, {
13020
+ assert.calledWithExactly(webex.internal.llm.disconnectLLM, {
12528
13021
  code: 3050,
12529
13022
  reason: 'done (permanent)',
12530
13023
  });
12531
- assert.calledWith(
13024
+
13025
+ assert.calledWithExactly(
12532
13026
  webex.internal.llm.registerAndConnect,
12533
13027
  'a url',
12534
- 'a different datachannel url'
13028
+ 'a different datachannel url',
13029
+ undefined
12535
13030
  );
13031
+
12536
13032
  assert.equal(result, 'something');
13033
+
12537
13034
  assert.calledWithExactly(
12538
13035
  meeting.webex.internal.llm.off,
12539
13036
  'event:relay.event',
@@ -12544,6 +13041,7 @@ describe('plugin-meetings', () => {
12544
13041
  'event:locus.state_message',
12545
13042
  meeting.processLocusLLMEvent
12546
13043
  );
13044
+
12547
13045
  assert.calledWithExactly(
12548
13046
  meeting.webex.internal.llm.on,
12549
13047
  'event:relay.event',
@@ -12554,8 +13052,10 @@ describe('plugin-meetings', () => {
12554
13052
  'event:locus.state_message',
12555
13053
  meeting.processLocusLLMEvent
12556
13054
  );
13055
+ assert.isFalse(
13056
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
13057
+ );
12557
13058
  });
12558
-
12559
13059
  it('disconnects when the state is not JOINED', async () => {
12560
13060
  meeting.joinedWith = {state: 'any other state'};
12561
13061
  webex.internal.llm.isConnected.returns(true);
@@ -12565,9 +13065,38 @@ describe('plugin-meetings', () => {
12565
13065
 
12566
13066
  const result = await meeting.updateLLMConnection();
12567
13067
 
12568
- assert.calledWith(webex.internal.llm.disconnectLLM, undefined);
13068
+ assert.calledWith(webex.internal.llm.disconnectLLM, {
13069
+ code: 3050,
13070
+ reason: 'done (permanent)',
13071
+ });
12569
13072
  assert.notCalled(webex.internal.llm.registerAndConnect);
12570
13073
  assert.equal(result, undefined);
13074
+ assert.isFalse(
13075
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
13076
+ );
13077
+ });
13078
+ it('rethrows disconnect errors during reconnect cleanup after removing relay listeners and timer', async () => {
13079
+ const disconnectError = new Error('disconnect failed');
13080
+
13081
+ meeting.joinedWith = {state: 'JOINED'};
13082
+ webex.internal.llm.isConnected.returns(true);
13083
+ webex.internal.llm.getLocusUrl.returns('a url');
13084
+ webex.internal.llm.disconnectLLM.rejects(disconnectError);
13085
+
13086
+ meeting.locusInfo = {
13087
+ url: 'a different url',
13088
+ info: {datachannelUrl: 'a datachannel url'},
13089
+ self: {},
13090
+ };
13091
+
13092
+ try {
13093
+ await meeting.updateLLMConnection();
13094
+ assert.fail('Expected updateLLMConnection to reject when disconnectLLM fails');
13095
+ } catch (error) {
13096
+ assert.equal(error, disconnectError);
13097
+ }
13098
+
13099
+ assert.notCalled(webex.internal.llm.registerAndConnect);
12571
13100
  assert.calledWithExactly(
12572
13101
  meeting.webex.internal.llm.off,
12573
13102
  'event:relay.event',
@@ -12578,22 +13107,159 @@ describe('plugin-meetings', () => {
12578
13107
  'event:locus.state_message',
12579
13108
  meeting.processLocusLLMEvent
12580
13109
  );
13110
+ assert.isFalse(
13111
+ meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline)
13112
+ );
13113
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
12581
13114
  });
12582
-
12583
- it('connect ps data channel if ps started in webinar', async () => {
13115
+ it('still need connect main session data channel when PS started', async () => {
12584
13116
  meeting.joinedWith = {state: 'JOINED'};
12585
13117
  meeting.locusInfo = {
12586
13118
  url: 'a url',
12587
13119
  info: {
12588
13120
  datachannelUrl: 'a datachannel url',
12589
- practiceSessionDatachannelUrl: 'a ps datachannel url',
13121
+ practiceSessionDatachannelUrl: 'ps-url',
12590
13122
  },
12591
13123
  };
12592
- meeting.webinar.isJoinPracticeSessionDataChannel = sinon.stub().returns(true);
13124
+ meeting.webinar.isJoinPracticeSessionDataChannel.returns(true);
13125
+
12593
13126
  await meeting.updateLLMConnection();
12594
13127
 
12595
- assert.notCalled(webex.internal.llm.disconnectLLM);
12596
- assert.calledWith(webex.internal.llm.registerAndConnect, 'a url', 'a ps datachannel url');
13128
+ assert.calledWithExactly(
13129
+ webex.internal.llm.registerAndConnect,
13130
+ 'a url',
13131
+ 'a datachannel url',
13132
+ undefined
13133
+ );
13134
+ });
13135
+ it('passes dataChannelToken from LLM to registerAndConnect', async () => {
13136
+ meeting.joinedWith = {state: 'JOINED'};
13137
+ meeting.locusInfo = {
13138
+ url: 'a url',
13139
+ info: {datachannelUrl: 'a datachannel url'},
13140
+ };
13141
+
13142
+ webex.internal.llm.getDatachannelToken.withArgs('llm-default-session').returns('token-123');
13143
+
13144
+ await meeting.updateLLMConnection();
13145
+
13146
+ assert.calledWithExactly(
13147
+ webex.internal.llm.registerAndConnect,
13148
+ 'a url',
13149
+ 'a datachannel url',
13150
+ 'token-123'
13151
+ );
13152
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
13153
+ });
13154
+ it('passes undefined token when LLM has no token stored', async () => {
13155
+ meeting.joinedWith = {state: 'JOINED'};
13156
+ meeting.locusInfo = {
13157
+ url: 'a url',
13158
+ info: {datachannelUrl: 'a datachannel url'},
13159
+ };
13160
+
13161
+ webex.internal.llm.getDatachannelToken.returns(undefined);
13162
+
13163
+ await meeting.updateLLMConnection();
13164
+
13165
+ assert.calledWithExactly(
13166
+ webex.internal.llm.registerAndConnect,
13167
+ 'a url',
13168
+ 'a datachannel url',
13169
+ undefined
13170
+ );
13171
+
13172
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
13173
+ });
13174
+
13175
+ it('does not pass token when data channel with jwt token is disabled', async () => {
13176
+ meeting.joinedWith = {state: 'JOINED'};
13177
+ meeting.locusInfo = {
13178
+ url: 'a url',
13179
+ info: {datachannelUrl: 'a datachannel url'},
13180
+ };
13181
+
13182
+ webex.internal.llm.getDatachannelToken.returns(undefined);
13183
+ webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false);
13184
+
13185
+ await meeting.updateLLMConnection();
13186
+
13187
+ assert.calledWithExactly(
13188
+ webex.internal.llm.registerAndConnect,
13189
+ 'a url',
13190
+ 'a datachannel url',
13191
+ undefined
13192
+ );
13193
+ assert.notCalled(webex.internal.llm.setDatachannelToken);
13194
+ });
13195
+
13196
+ describe('#clearMeetingData', () => {
13197
+ beforeEach(() => {
13198
+ webex.internal.llm.isConnected = sinon.stub().returns(true);
13199
+ webex.internal.llm.disconnectLLM = sinon.stub().resolves();
13200
+ webex.internal.llm.off = sinon.stub();
13201
+ meeting.annotation.deregisterEvents = sinon.stub();
13202
+ meeting.clearLLMHealthCheckTimer = sinon.stub();
13203
+ meeting.stopTranscription = sinon.stub();
13204
+ meeting.clearDataChannelToken = sinon.stub();
13205
+ meeting.shareStatus = 'no-share';
13206
+ });
13207
+
13208
+ it('disconnects llm and removes online and relay listeners during meeting data cleanup', async () => {
13209
+ await meeting.clearMeetingData();
13210
+
13211
+ assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, {
13212
+ code: 3050,
13213
+ reason: 'done (permanent)',
13214
+ });
13215
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
13216
+ assert.calledWithExactly(
13217
+ webex.internal.llm.off,
13218
+ 'event:relay.event',
13219
+ meeting.processRelayEvent
13220
+ );
13221
+ assert.calledWithExactly(
13222
+ webex.internal.llm.off,
13223
+ 'event:locus.state_message',
13224
+ meeting.processLocusLLMEvent
13225
+ );
13226
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13227
+ assert.calledOnce(meeting.stopTranscription);
13228
+ assert.isUndefined(meeting.transcription);
13229
+ assert.calledOnce(meeting.clearDataChannelToken);
13230
+ assert.calledOnce(meeting.annotation.deregisterEvents);
13231
+ });
13232
+ it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => {
13233
+ webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed'));
13234
+
13235
+ await meeting.clearMeetingData();
13236
+
13237
+ assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline);
13238
+ assert.calledWithExactly(
13239
+ webex.internal.llm.off,
13240
+ 'event:relay.event',
13241
+ meeting.processRelayEvent
13242
+ );
13243
+ assert.calledWithExactly(
13244
+ webex.internal.llm.off,
13245
+ 'event:locus.state_message',
13246
+ meeting.processLocusLLMEvent
13247
+ );
13248
+ assert.calledOnce(meeting.clearLLMHealthCheckTimer);
13249
+ assert.calledOnce(meeting.stopTranscription);
13250
+ assert.isUndefined(meeting.transcription);
13251
+ assert.calledOnce(meeting.clearDataChannelToken);
13252
+ assert.calledOnce(meeting.annotation.deregisterEvents);
13253
+ });
13254
+ it('always calls stopTranscription even when transcription is undefined', async () => {
13255
+ meeting.transcription = undefined;
13256
+
13257
+ await meeting.clearMeetingData();
13258
+
13259
+ assert.calledOnce(meeting.stopTranscription);
13260
+ assert.isUndefined(meeting.transcription);
13261
+ assert.calledOnce(meeting.clearDataChannelToken);
13262
+ });
12597
13263
  });
12598
13264
  });
12599
13265
 
@@ -12604,6 +13270,7 @@ describe('plugin-meetings', () => {
12604
13270
 
12605
13271
  it('should read the locus object, set on the meeting and return null', () => {
12606
13272
  const dataSets = {someFakeStuff: 'dataSet'};
13273
+ const metadata = {some: 'metadata'};
12607
13274
 
12608
13275
  meeting.setLocus({
12609
13276
  mediaConnections: [test1],
@@ -12613,12 +13280,14 @@ describe('plugin-meetings', () => {
12613
13280
  mediaId: uuid3,
12614
13281
  locus: {host: {id: uuid4}},
12615
13282
  dataSets,
13283
+ metadata,
12616
13284
  });
12617
13285
  assert.calledOnce(meeting.locusInfo.initialSetup);
12618
13286
  assert.calledWith(meeting.locusInfo.initialSetup, {
12619
13287
  trigger: 'join-response',
12620
13288
  locus: {host: {id: uuid4}},
12621
13289
  dataSets,
13290
+ metadata,
12622
13291
  });
12623
13292
  assert.equal(meeting.mediaConnections, test1);
12624
13293
  assert.equal(meeting.locusUrl, url1);
@@ -14160,6 +14829,69 @@ describe('plugin-meetings', () => {
14160
14829
  assert.calledOnce(meeting.meetingRequest.keepAlive);
14161
14830
  });
14162
14831
  });
14832
+ describe('#refreshDataChannelToken()', () => {
14833
+ let meeting;
14834
+
14835
+ beforeEach(() => {
14836
+ meeting = Object.create(Meeting.prototype);
14837
+ meeting.locusUrl = 'https://locus.example.com';
14838
+ meeting.meetingRequest = {
14839
+ fetchDatachannelToken: sinon.stub().resolves({
14840
+ body: {datachannelToken: 'mock-token'},
14841
+ }),
14842
+ };
14843
+ meeting.members = {
14844
+ selfId: 'self-123',
14845
+ };
14846
+ meeting.webinar = {
14847
+ isJoinPracticeSessionDataChannel: sinon.stub().returns(true),
14848
+ };
14849
+ });
14850
+
14851
+ it('calls fetchDatachannelToken with correct parameters', async () => {
14852
+ await meeting.refreshDataChannelToken();
14853
+
14854
+ sinon.assert.calledOnce(meeting.meetingRequest.fetchDatachannelToken);
14855
+
14856
+ sinon.assert.calledWith(meeting.meetingRequest.fetchDatachannelToken, {
14857
+ locusUrl: 'https://locus.example.com',
14858
+ requestingParticipantId: 'self-123',
14859
+ isPracticeSession: true,
14860
+ });
14861
+ });
14862
+
14863
+ it('returns the correct structured result', async () => {
14864
+ const result = await meeting.refreshDataChannelToken();
14865
+
14866
+ expect(result).to.deep.equal({
14867
+ body: {
14868
+ datachannelToken: 'mock-token',
14869
+ dataChannelTokenType: 'llm-practice-session',
14870
+ },
14871
+ });
14872
+ });
14873
+ });
14874
+ describe('#getDataChannelTokenType', () => {
14875
+ it('returns PracticeSession when webinar is in practice session mode', () => {
14876
+ meeting.webinar = {
14877
+ isJoinPracticeSessionDataChannel: sinon.stub().returns(true),
14878
+ };
14879
+
14880
+ const result = meeting.getDataChannelTokenType();
14881
+
14882
+ expect(result).to.equal('llm-practice-session');
14883
+ });
14884
+
14885
+ it('returns Default when not in practice session mode', () => {
14886
+ meeting.webinar = {
14887
+ isJoinPracticeSessionDataChannel: sinon.stub().returns(false),
14888
+ };
14889
+
14890
+ const result = meeting.getDataChannelTokenType();
14891
+
14892
+ expect(result).to.equal('llm-default-session');
14893
+ });
14894
+ });
14163
14895
  describe('#stopKeepAlive', () => {
14164
14896
  let clock;
14165
14897
  const defaultKeepAliveUrl = 'keep.alive.url';