@webex/plugin-meetings 3.12.0-next.5 → 3.12.0-next.50

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 (136) hide show
  1. package/AGENTS.md +9 -0
  2. package/dist/aiEnableRequest/index.js +15 -2
  3. package/dist/aiEnableRequest/index.js.map +1 -1
  4. package/dist/breakouts/breakout.js +6 -2
  5. package/dist/breakouts/breakout.js.map +1 -1
  6. package/dist/breakouts/index.js +1 -1
  7. package/dist/config.js +1 -0
  8. package/dist/config.js.map +1 -1
  9. package/dist/constants.js +6 -3
  10. package/dist/constants.js.map +1 -1
  11. package/dist/controls-options-manager/constants.js +11 -1
  12. package/dist/controls-options-manager/constants.js.map +1 -1
  13. package/dist/controls-options-manager/index.js +38 -24
  14. package/dist/controls-options-manager/index.js.map +1 -1
  15. package/dist/controls-options-manager/util.js +91 -0
  16. package/dist/controls-options-manager/util.js.map +1 -1
  17. package/dist/hashTree/constants.js +10 -1
  18. package/dist/hashTree/constants.js.map +1 -1
  19. package/dist/hashTree/hashTreeParser.js +593 -358
  20. package/dist/hashTree/hashTreeParser.js.map +1 -1
  21. package/dist/hashTree/utils.js +22 -0
  22. package/dist/hashTree/utils.js.map +1 -1
  23. package/dist/index.js +7 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/interceptors/locusRetry.js +23 -8
  26. package/dist/interceptors/locusRetry.js.map +1 -1
  27. package/dist/interpretation/index.js +10 -1
  28. package/dist/interpretation/index.js.map +1 -1
  29. package/dist/interpretation/siLanguage.js +1 -1
  30. package/dist/locus-info/controlsUtils.js +4 -1
  31. package/dist/locus-info/controlsUtils.js.map +1 -1
  32. package/dist/locus-info/index.js +277 -86
  33. package/dist/locus-info/index.js.map +1 -1
  34. package/dist/locus-info/types.js +16 -0
  35. package/dist/locus-info/types.js.map +1 -1
  36. package/dist/media/properties.js +1 -0
  37. package/dist/media/properties.js.map +1 -1
  38. package/dist/meeting/in-meeting-actions.js +3 -1
  39. package/dist/meeting/in-meeting-actions.js.map +1 -1
  40. package/dist/meeting/index.js +842 -521
  41. package/dist/meeting/index.js.map +1 -1
  42. package/dist/meeting/util.js +19 -2
  43. package/dist/meeting/util.js.map +1 -1
  44. package/dist/meetings/index.js +199 -77
  45. package/dist/meetings/index.js.map +1 -1
  46. package/dist/meetings/meetings.types.js +6 -1
  47. package/dist/meetings/meetings.types.js.map +1 -1
  48. package/dist/meetings/request.js +39 -0
  49. package/dist/meetings/request.js.map +1 -1
  50. package/dist/meetings/util.js +67 -5
  51. package/dist/meetings/util.js.map +1 -1
  52. package/dist/member/index.js +10 -0
  53. package/dist/member/index.js.map +1 -1
  54. package/dist/member/types.js.map +1 -1
  55. package/dist/member/util.js +3 -0
  56. package/dist/member/util.js.map +1 -1
  57. package/dist/metrics/constants.js +2 -1
  58. package/dist/metrics/constants.js.map +1 -1
  59. package/dist/recording-controller/index.js +1 -3
  60. package/dist/recording-controller/index.js.map +1 -1
  61. package/dist/types/config.d.ts +1 -0
  62. package/dist/types/constants.d.ts +2 -0
  63. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  64. package/dist/types/controls-options-manager/index.d.ts +10 -0
  65. package/dist/types/hashTree/constants.d.ts +1 -0
  66. package/dist/types/hashTree/hashTreeParser.d.ts +61 -15
  67. package/dist/types/hashTree/utils.d.ts +11 -0
  68. package/dist/types/index.d.ts +2 -0
  69. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  70. package/dist/types/locus-info/index.d.ts +46 -6
  71. package/dist/types/locus-info/types.d.ts +17 -1
  72. package/dist/types/media/properties.d.ts +1 -0
  73. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  74. package/dist/types/meeting/index.d.ts +70 -1
  75. package/dist/types/meeting/util.d.ts +8 -0
  76. package/dist/types/meetings/index.d.ts +18 -1
  77. package/dist/types/meetings/meetings.types.d.ts +15 -0
  78. package/dist/types/meetings/request.d.ts +14 -0
  79. package/dist/types/member/index.d.ts +1 -0
  80. package/dist/types/member/types.d.ts +1 -0
  81. package/dist/types/member/util.d.ts +1 -0
  82. package/dist/types/metrics/constants.d.ts +1 -0
  83. package/dist/webinar/index.js +361 -235
  84. package/dist/webinar/index.js.map +1 -1
  85. package/package.json +22 -22
  86. package/src/aiEnableRequest/index.ts +16 -0
  87. package/src/breakouts/breakout.ts +2 -1
  88. package/src/config.ts +1 -0
  89. package/src/constants.ts +5 -1
  90. package/src/controls-options-manager/constants.ts +14 -1
  91. package/src/controls-options-manager/index.ts +47 -24
  92. package/src/controls-options-manager/util.ts +81 -1
  93. package/src/hashTree/constants.ts +9 -0
  94. package/src/hashTree/hashTreeParser.ts +306 -160
  95. package/src/hashTree/utils.ts +17 -0
  96. package/src/index.ts +5 -0
  97. package/src/interceptors/locusRetry.ts +25 -4
  98. package/src/interpretation/index.ts +25 -8
  99. package/src/locus-info/controlsUtils.ts +3 -1
  100. package/src/locus-info/index.ts +276 -93
  101. package/src/locus-info/types.ts +19 -1
  102. package/src/media/properties.ts +1 -0
  103. package/src/meeting/in-meeting-actions.ts +4 -0
  104. package/src/meeting/index.ts +315 -26
  105. package/src/meeting/util.ts +20 -2
  106. package/src/meetings/index.ts +104 -43
  107. package/src/meetings/meetings.types.ts +19 -0
  108. package/src/meetings/request.ts +43 -0
  109. package/src/meetings/util.ts +80 -1
  110. package/src/member/index.ts +10 -0
  111. package/src/member/types.ts +1 -0
  112. package/src/member/util.ts +3 -0
  113. package/src/metrics/constants.ts +1 -0
  114. package/src/recording-controller/index.ts +1 -2
  115. package/src/webinar/index.ts +162 -21
  116. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  117. package/test/unit/spec/breakouts/breakout.ts +7 -3
  118. package/test/unit/spec/controls-options-manager/index.js +140 -29
  119. package/test/unit/spec/controls-options-manager/util.js +165 -0
  120. package/test/unit/spec/hashTree/hashTreeParser.ts +1294 -191
  121. package/test/unit/spec/hashTree/utils.ts +88 -1
  122. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  123. package/test/unit/spec/interpretation/index.ts +26 -4
  124. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  125. package/test/unit/spec/locus-info/index.js +443 -81
  126. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  127. package/test/unit/spec/meeting/index.js +836 -41
  128. package/test/unit/spec/meeting/muteState.js +3 -0
  129. package/test/unit/spec/meeting/utils.js +33 -0
  130. package/test/unit/spec/meetings/index.js +275 -10
  131. package/test/unit/spec/meetings/request.js +141 -0
  132. package/test/unit/spec/meetings/utils.js +161 -0
  133. package/test/unit/spec/member/index.js +7 -0
  134. package/test/unit/spec/member/util.js +24 -0
  135. package/test/unit/spec/recording-controller/index.js +9 -8
  136. package/test/unit/spec/webinar/index.ts +141 -16
@@ -120,6 +120,7 @@ interface IInMeetingActions {
120
120
  canDisablePollingQA?: boolean;
121
121
  canAttendeeRequestAiAssistantEnabled?: boolean;
122
122
  isAttendeeRequestAiAssistantDeclinedAll?: boolean;
123
+ isAnonymizeDisplayNamesEnabled?: boolean;
123
124
  }
124
125
 
125
126
  /**
@@ -346,6 +347,8 @@ export default class InMeetingActions implements IInMeetingActions {
346
347
 
347
348
  isAttendeeRequestAiAssistantDeclinedAll = null;
348
349
 
350
+ isAnonymizeDisplayNamesEnabled = null;
351
+
349
352
  /**
350
353
  * Returns all meeting action options
351
354
  * @returns {Object}
@@ -460,6 +463,7 @@ export default class InMeetingActions implements IInMeetingActions {
460
463
  canDisablePollingQA: this.canDisablePollingQA,
461
464
  canAttendeeRequestAiAssistantEnabled: this.canAttendeeRequestAiAssistantEnabled,
462
465
  isAttendeeRequestAiAssistantDeclinedAll: this.isAttendeeRequestAiAssistantDeclinedAll,
466
+ isAnonymizeDisplayNamesEnabled: this.isAnonymizeDisplayNamesEnabled,
463
467
  });
464
468
 
465
469
  /**
@@ -612,7 +612,7 @@ export default class Meeting extends StatelessWebexPlugin {
612
612
  webinar: any;
613
613
  conversationUrl: string;
614
614
  callStateForMetrics: CallStateForMetrics;
615
- destination: string;
615
+ destination: string | LocusDTO;
616
616
  destinationType: DESTINATION_TYPE;
617
617
  deviceUrl: string;
618
618
  hostId: string;
@@ -651,6 +651,8 @@ export default class Meeting extends StatelessWebexPlugin {
651
651
  floorGrantPending: boolean;
652
652
  hasJoinedOnce: boolean;
653
653
  hasWebsocketConnected: boolean;
654
+ private mercuryOnlineHandler?: () => void;
655
+ private mercuryOfflineHandler?: () => void;
654
656
  inMeetingActions: InMeetingActions;
655
657
  isLocalShareLive: boolean;
656
658
  isRoapInProgress: boolean;
@@ -935,7 +937,7 @@ export default class Meeting extends StatelessWebexPlugin {
935
937
  this.simultaneousInterpretation = new SimultaneousInterpretation({}, {parent: this.webex});
936
938
 
937
939
  // @ts-ignore
938
- this.aiEnableRequest = new AIEnableRequest({}, {parent: this.webex});
940
+ this.aiEnableRequest = new AIEnableRequest({locusUrl: this.locusUrl}, {parent: this.webex});
939
941
 
940
942
  /**
941
943
  * @instance
@@ -2789,7 +2791,7 @@ export default class Meeting extends StatelessWebexPlugin {
2789
2791
  private setupLocusControlsListener() {
2790
2792
  this.locusInfo.on(
2791
2793
  LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
2792
- ({state, modifiedBy, lastModified}) => {
2794
+ ({state, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}) => {
2793
2795
  let event;
2794
2796
 
2795
2797
  switch (state) {
@@ -2815,6 +2817,8 @@ export default class Meeting extends StatelessWebexPlugin {
2815
2817
  state: state === RECORDING_STATE.RESUMED ? RECORDING_STATE.RECORDING : state,
2816
2818
  modifiedBy,
2817
2819
  lastModified,
2820
+ modifiedByServiceAppName,
2821
+ modifiedByServiceAppId,
2818
2822
  };
2819
2823
  Trigger.trigger(
2820
2824
  this,
@@ -3459,6 +3463,7 @@ export default class Meeting extends StatelessWebexPlugin {
3459
3463
  this.breakouts.locusUrlUpdate(url);
3460
3464
  this.simultaneousInterpretation.locusUrlUpdate(url);
3461
3465
  this.annotation.locusUrlUpdate(url);
3466
+ this.aiEnableRequest.locusUrlUpdate(url);
3462
3467
  this.locusUrl = url;
3463
3468
  this.locusId = this.locusUrl?.split('/').pop();
3464
3469
  this.recordingController.setLocusUrl(this.locusUrl);
@@ -3734,7 +3739,7 @@ export default class Meeting extends StatelessWebexPlugin {
3734
3739
  });
3735
3740
  this.updateLLMConnection();
3736
3741
  });
3737
- this.locusInfo.on(LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, async (payload) => {
3742
+ this.locusInfo.on(LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, (payload) => {
3738
3743
  this.stopKeepAlive();
3739
3744
 
3740
3745
  if (payload) {
@@ -3760,6 +3765,15 @@ export default class Meeting extends StatelessWebexPlugin {
3760
3765
  });
3761
3766
  }
3762
3767
  this.rtcMetrics?.sendNextMetrics();
3768
+
3769
+ this.ensureDefaultDatachannelTokenAfterAdmit().catch((error) => {
3770
+ LoggerProxy.logger.warn(
3771
+ `Meeting:index#setUpLocusInfoSelfListener --> failed post-admit token prefetch flow: ${
3772
+ error?.message || String(error)
3773
+ }`
3774
+ );
3775
+ });
3776
+
3763
3777
  this.updateLLMConnection();
3764
3778
  });
3765
3779
 
@@ -4602,6 +4616,9 @@ export default class Meeting extends StatelessWebexPlugin {
4602
4616
  ),
4603
4617
  isAttendeeRequestAiAssistantDeclinedAll:
4604
4618
  MeetingUtil.attendeeRequestAiAssistantDeclinedAll(this.userDisplayHints),
4619
+ isAnonymizeDisplayNamesEnabled: MeetingUtil.isAnonymizeDisplayNamesEnabled(
4620
+ this.userDisplayHints
4621
+ ),
4605
4622
  }) || changed;
4606
4623
  }
4607
4624
  if (changed) {
@@ -4650,6 +4667,33 @@ export default class Meeting extends StatelessWebexPlugin {
4650
4667
  this.sipUri = sipUri;
4651
4668
  }
4652
4669
 
4670
+ /**
4671
+ * After initial locus setup, refreshes destination with synced locus data and optionally
4672
+ * performs deferred meeting info fetch when initial locus was incomplete.
4673
+ * @param {LocusDTO} locus
4674
+ * @returns {void}
4675
+ */
4676
+ public async finalizeMeetingAfterInitialLocusSetup(locus: LocusDTO): Promise<void> {
4677
+ if (locus && this?.destinationType === DESTINATION_TYPE.LOCUS_ID) {
4678
+ // destination is initialized from the initial locus snapshot in constructor,
4679
+ // so refresh it after locus sync to avoid stale partial hash-tree data.
4680
+ this.destination = locus;
4681
+ }
4682
+ if (
4683
+ (!this.meetingInfo || isEmpty(this.meetingInfo)) &&
4684
+ (this.destination as LocusDTO)?.info &&
4685
+ !this.fetchMeetingInfoTimeoutId
4686
+ ) {
4687
+ try {
4688
+ await this.fetchMeetingInfo({});
4689
+ } catch (error: any) {
4690
+ LoggerProxy.logger.info(
4691
+ `Meeting:index#finalizeMeetingAfterInitialLocusSetup --> deferred fetchMeetingInfo failed: ${error.message}`
4692
+ );
4693
+ }
4694
+ }
4695
+ }
4696
+
4653
4697
  /**
4654
4698
  * Set the locus info the class instance. Should be called with the parsed locus
4655
4699
  * we got in the join response.
@@ -5121,8 +5165,7 @@ export default class Meeting extends StatelessWebexPlugin {
5121
5165
  public setMercuryListener() {
5122
5166
  // Client will have a socket manager and handle reconnecting to mercury, when we reconnect to mercury
5123
5167
  // if the meeting has active peer connections, it should try to reconnect.
5124
- // @ts-ignore
5125
- this.webex.internal.mercury.on(ONLINE, () => {
5168
+ this.mercuryOnlineHandler = () => {
5126
5169
  LoggerProxy.logger.info('Meeting:index#setMercuryListener --> Web socket online');
5127
5170
 
5128
5171
  // Only send restore event when it was disconnected before and for connected later
@@ -5132,15 +5175,47 @@ export default class Meeting extends StatelessWebexPlugin {
5132
5175
  });
5133
5176
  }
5134
5177
  this.hasWebsocketConnected = true;
5135
- });
5178
+ };
5136
5179
 
5137
- // @ts-ignore
5138
- this.webex.internal.mercury.on(OFFLINE, () => {
5180
+ this.mercuryOfflineHandler = () => {
5139
5181
  LoggerProxy.logger.error('Meeting:index#setMercuryListener --> Web socket offline');
5140
5182
  Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.MERCURY_CONNECTION_FAILURE, {
5141
5183
  correlation_id: this.correlationId,
5142
5184
  });
5143
- });
5185
+ };
5186
+
5187
+ // @ts-ignore
5188
+ this.webex.internal.mercury.on(ONLINE, this.mercuryOnlineHandler);
5189
+ // @ts-ignore
5190
+ this.webex.internal.mercury.on(OFFLINE, this.mercuryOfflineHandler);
5191
+ }
5192
+
5193
+ /**
5194
+ * Removes this meeting's Mercury ONLINE/OFFLINE event listeners registered
5195
+ * by setMercuryListener(). Must be called before Locus /leave to avoid
5196
+ * unnecessary syncs/metrics triggered by events received while leaving
5197
+ * (per Locus team recommendation).
5198
+ *
5199
+ * Mercury is a process-wide singleton shared with other plugins, so we
5200
+ * pass the bound handler refs to .off() to avoid clearing every listener
5201
+ * for ONLINE/OFFLINE on the shared emitter.
5202
+ *
5203
+ * Idempotent: subsequent calls are no-ops because the handler refs are
5204
+ * cleared after detaching.
5205
+ * @private
5206
+ * @returns {void}
5207
+ */
5208
+ private stopListeningForMercuryEvents() {
5209
+ if (this.mercuryOnlineHandler) {
5210
+ // @ts-ignore
5211
+ this.webex.internal.mercury.off(ONLINE, this.mercuryOnlineHandler);
5212
+ this.mercuryOnlineHandler = undefined;
5213
+ }
5214
+ if (this.mercuryOfflineHandler) {
5215
+ // @ts-ignore
5216
+ this.webex.internal.mercury.off(OFFLINE, this.mercuryOfflineHandler);
5217
+ this.mercuryOfflineHandler = undefined;
5218
+ }
5144
5219
  }
5145
5220
 
5146
5221
  /**
@@ -5960,6 +6035,30 @@ export default class Meeting extends StatelessWebexPlugin {
5960
6035
  );
5961
6036
  }
5962
6037
 
6038
+ /**
6039
+ * Restores LLM subchannel subscriptions after reconnect when captions are active.
6040
+ * @returns {void}
6041
+ */
6042
+ private restoreLLMSubscriptionsIfNeeded(): void {
6043
+ try {
6044
+ // @ts-ignore
6045
+ const isCaptionBoxOn = this.webex.internal.voicea?.getIsCaptionBoxOn?.();
6046
+
6047
+ if (!isCaptionBoxOn) {
6048
+ return;
6049
+ }
6050
+
6051
+ // @ts-ignore
6052
+ this.webex.internal.voicea.updateSubchannelSubscriptions({subscribe: ['transcription']});
6053
+ } catch (error) {
6054
+ const msg = error?.message || String(error);
6055
+
6056
+ LoggerProxy.logger.warn(
6057
+ `Meeting:index#restoreLLMSubscriptionsIfNeeded --> failed to restore subscriptions after LLM online: ${msg}`
6058
+ );
6059
+ }
6060
+ }
6061
+
5963
6062
  /**
5964
6063
  * This is a callback for the LLM event that is triggered when it comes online
5965
6064
  * This method in turn will trigger an event to the developers that the LLM is connected
@@ -5968,8 +6067,8 @@ export default class Meeting extends StatelessWebexPlugin {
5968
6067
  * @returns {null}
5969
6068
  */
5970
6069
  private handleLLMOnline = (): void => {
5971
- // @ts-ignore
5972
- this.webex.internal.llm.off('online', this.handleLLMOnline);
6070
+ this.restoreLLMSubscriptionsIfNeeded();
6071
+
5973
6072
  Trigger.trigger(
5974
6073
  this,
5975
6074
  {
@@ -6200,6 +6299,8 @@ export default class Meeting extends StatelessWebexPlugin {
6200
6299
  this.saveDataChannelToken(join);
6201
6300
  // @ts-ignore - config coming from registerPlugin
6202
6301
  if (this.config.enableAutomaticLLM) {
6302
+ // @ts-ignore
6303
+ this.webex.internal.llm.off('online', this.handleLLMOnline);
6203
6304
  // @ts-ignore
6204
6305
  this.webex.internal.llm.on('online', this.handleLLMOnline);
6205
6306
  this.updateLLMConnection()
@@ -6266,8 +6367,57 @@ export default class Meeting extends StatelessWebexPlugin {
6266
6367
  }
6267
6368
  }
6268
6369
 
6370
+ /**
6371
+ * Removes LLM event listeners and clears the health check timer.
6372
+ * Must be called before Locus /leave to avoid unnecessary syncs triggered
6373
+ * by events received while leaving (per Locus team recommendation).
6374
+ * Idempotent: safe to call multiple times; .off() is a no-op when no
6375
+ * matching listener is registered.
6376
+ * @private
6377
+ * @returns {void}
6378
+ */
6379
+ private stopListeningForLLMEvents() {
6380
+ // @ts-ignore - fix types
6381
+ this.webex.internal.llm.off('event:relay.event', this.processRelayEvent);
6382
+ // @ts-ignore - fix types
6383
+ this.webex.internal.llm.off(LOCUS_LLM_EVENT, this.processLocusLLMEvent);
6384
+ this.clearLLMHealthCheckTimer();
6385
+ }
6386
+
6387
+ /**
6388
+ * Stops listening on every event bus (LLM, Mercury, voicea/transcription,
6389
+ * annotation) that could otherwise deliver events to this meeting while
6390
+ * Locus is processing /leave or /end. Per the Locus team recommendation,
6391
+ * this must run before the Locus request is dispatched to avoid
6392
+ * unnecessary syncs triggered by in-flight events.
6393
+ *
6394
+ * Voicea (transcription) subscribes to llm 'event:relay.event' internally,
6395
+ * and the annotation plugin subscribes to both mercury and llm, so both
6396
+ * must be torn down alongside the direct LLM/Mercury listeners.
6397
+ *
6398
+ * Idempotent: safe to call multiple times; .off() is a no-op when no
6399
+ * matching listener is registered, and stopTranscription is guarded.
6400
+ * @private
6401
+ * @returns {void}
6402
+ */
6403
+ private stopListeningForMeetingEvents() {
6404
+ this.stopListeningForLLMEvents();
6405
+ this.stopListeningForMercuryEvents();
6406
+ if (this.transcription) {
6407
+ this.stopTranscription();
6408
+ this.transcription = undefined;
6409
+ }
6410
+ this.annotation.deregisterEvents();
6411
+ }
6412
+
6269
6413
  /**
6270
6414
  * Disconnects and cleans up the default LLM session listeners/timers.
6415
+ *
6416
+ * Ownership-aware: only calls `disconnectLLM` when this meeting is the
6417
+ * current owner of the default LLM session (or when no owner is recorded).
6418
+ * Event listeners belonging to this meeting instance are always detached
6419
+ * so they do not receive another meeting's relay events.
6420
+ *
6271
6421
  * @param {Object} options
6272
6422
  * @param {boolean} [options.removeOnlineListener=true] removes the one-time online listener
6273
6423
  * @param {boolean} [options.throwOnError=true] rethrows disconnect errors when true
@@ -6280,12 +6430,22 @@ export default class Meeting extends StatelessWebexPlugin {
6280
6430
  removeOnlineListener?: boolean;
6281
6431
  throwOnError?: boolean;
6282
6432
  } = {}): Promise<void> => {
6433
+ // @ts-ignore - Fix type
6434
+ const currentOwner = this.webex.internal.llm.getOwnerMeetingId();
6435
+ const isOwner = !currentOwner || currentOwner === this.id;
6436
+
6283
6437
  try {
6284
- // @ts-ignore - Fix type
6285
- await this.webex.internal.llm.disconnectLLM({
6286
- code: 3050,
6287
- reason: 'done (permanent)',
6288
- });
6438
+ if (isOwner) {
6439
+ // @ts-ignore - Fix type
6440
+ await this.webex.internal.llm.disconnectLLM({
6441
+ code: 3050,
6442
+ reason: 'done (permanent)',
6443
+ });
6444
+ } else {
6445
+ LoggerProxy.logger.info(
6446
+ `Meeting:index#cleanupLLMConneciton --> skipping disconnect; LLM owned by meeting ${currentOwner}, not ${this.id}`
6447
+ );
6448
+ }
6289
6449
  } catch (error) {
6290
6450
  LoggerProxy.logger.error(
6291
6451
  'Meeting:index#cleanupLLMConneciton --> Failed to disconnect default LLM session',
@@ -6300,12 +6460,18 @@ export default class Meeting extends StatelessWebexPlugin {
6300
6460
  // @ts-ignore - Fix type
6301
6461
  this.webex.internal.llm.off('online', this.handleLLMOnline);
6302
6462
  }
6303
- // @ts-ignore - fix types
6304
- this.webex.internal.llm.off('event:relay.event', this.processRelayEvent);
6305
- // @ts-ignore - Fix type
6306
- this.webex.internal.llm.off(LOCUS_LLM_EVENT, this.processLocusLLMEvent);
6463
+ this.stopListeningForLLMEvents();
6307
6464
 
6308
- this.clearLLMHealthCheckTimer();
6465
+ // If this meeting owned (or could have owned) the default LLM session,
6466
+ // always release the owner tag here regardless of whether disconnectLLM
6467
+ // resolved. `disconnectLLM` only clears the owner on its success path,
6468
+ // so a failed disconnect would otherwise leave a stale owner pointing
6469
+ // at a torn-down meeting and permanently block other meetings'
6470
+ // `updateLLMConnection` calls via the ownership guard.
6471
+ if (isOwner) {
6472
+ // @ts-ignore - Fix type
6473
+ this.webex.internal.llm.setOwnerMeetingId?.(undefined);
6474
+ }
6309
6475
  }
6310
6476
  };
6311
6477
 
@@ -6343,6 +6509,52 @@ export default class Meeting extends StatelessWebexPlugin {
6343
6509
  }
6344
6510
  }
6345
6511
 
6512
+ /**
6513
+ * Ensures default-session data channel token exists after lobby admission.
6514
+ * Some lobby users do not receive a token until they are admitted.
6515
+ * @returns {Promise<boolean>} true when a new token is fetched and cached
6516
+ */
6517
+ private async ensureDefaultDatachannelTokenAfterAdmit(): Promise<boolean> {
6518
+ try {
6519
+ // @ts-ignore
6520
+ const datachannelToken = this.webex.internal.llm.getDatachannelToken();
6521
+ // @ts-ignore
6522
+ const isDataChannelTokenEnabled = await this.webex.internal.llm.isDataChannelTokenEnabled();
6523
+
6524
+ if (!isDataChannelTokenEnabled || datachannelToken) {
6525
+ return false;
6526
+ }
6527
+
6528
+ const response = await this.meetingRequest.fetchDatachannelToken({
6529
+ locusUrl: this.locusUrl,
6530
+ requestingParticipantId: this.members.selfId,
6531
+ isPracticeSession: false,
6532
+ });
6533
+ const fetchedDatachannelToken = response?.body?.datachannelToken;
6534
+
6535
+ if (!fetchedDatachannelToken) {
6536
+ return false;
6537
+ }
6538
+
6539
+ // @ts-ignore
6540
+ this.webex.internal.llm.setDatachannelToken(
6541
+ fetchedDatachannelToken,
6542
+ DataChannelTokenType.Default
6543
+ );
6544
+
6545
+ return true;
6546
+ } catch (error) {
6547
+ const msg = error?.message || String(error);
6548
+
6549
+ LoggerProxy.logger.warn(
6550
+ `Meeting:index#ensureDefaultDatachannelTokenAfterAdmit --> failed to proactively fetch default data channel token after admit: ${msg}`,
6551
+ {statusCode: error?.statusCode}
6552
+ );
6553
+
6554
+ return false;
6555
+ }
6556
+ }
6557
+
6346
6558
  /**
6347
6559
  * Connects to low latency mercury and reconnects if the address has changed
6348
6560
  * It will also disconnect if called when the meeting has ended
@@ -6361,8 +6573,33 @@ export default class Meeting extends StatelessWebexPlugin {
6361
6573
 
6362
6574
  const dataChannelUrl = datachannelUrl;
6363
6575
 
6576
+ // Ownership guard: when the default LLM session is already connected and
6577
+ // owned by a *different* Meeting instance, do not disconnect or reconfigure
6578
+ // it. Another meeting's `updateLLMConnection` must be ignored here to
6579
+ // avoid killing the socket it relies on. We only proceed to manage the
6580
+ // connection when this meeting is the current owner, or when no owner is
6581
+ // set yet (first claim).
6582
+ // @ts-ignore - Fix type
6583
+ const currentOwner = this.webex.internal.llm.getOwnerMeetingId();
6584
+
6364
6585
  // @ts-ignore - Fix type
6365
6586
  if (this.webex.internal.llm.isConnected()) {
6587
+ if (currentOwner && currentOwner !== this.id) {
6588
+ // Another meeting owns the live LLM socket. We must not disconnect
6589
+ // or reconfigure it -- doing so would tear down a session the
6590
+ // owning meeting still relies on. Locus/datachannel URL mismatch is
6591
+ // expected here (each meeting has its own locus URL) and is NOT a
6592
+ // valid signal of staleness, so we never reclaim from this path.
6593
+ // The only safe reclaim mechanism is the `finally`-block owner-tag
6594
+ // release in `cleanupLLMConneciton`, which fires when this meeting
6595
+ // itself is being torn down.
6596
+ LoggerProxy.logger.info(
6597
+ `Meeting:index#updateLLMConnection --> skipping; LLM owned by meeting ${currentOwner}, not ${this.id}`
6598
+ );
6599
+
6600
+ return undefined;
6601
+ }
6602
+
6366
6603
  if (
6367
6604
  // @ts-ignore - Fix type
6368
6605
  url === this.webex.internal.llm.getLocusUrl() &&
@@ -6383,6 +6620,11 @@ export default class Meeting extends StatelessWebexPlugin {
6383
6620
  return this.webex.internal.llm
6384
6621
  .registerAndConnect(url, dataChannelUrl, datachannelToken)
6385
6622
  .then((registerAndConnectResult) => {
6623
+ // Record ownership of the default LLM session for this meeting so
6624
+ // subsequent cross-meeting `updateLLMConnection` / `cleanupLLMConneciton`
6625
+ // calls can detect and skip work that doesn't belong to them.
6626
+ // @ts-ignore - Fix type
6627
+ this.webex.internal.llm.setOwnerMeetingId?.(this.id);
6386
6628
  // @ts-ignore - Fix type
6387
6629
  this.webex.internal.llm.off('event:relay.event', this.processRelayEvent);
6388
6630
  // @ts-ignore - Fix type
@@ -7423,6 +7665,33 @@ export default class Meeting extends StatelessWebexPlugin {
7423
7665
  }
7424
7666
  }
7425
7667
  });
7668
+ this.statsAnalyzer.on(StatsAnalyzerEventNames.STATS_UPDATE, (data) => {
7669
+ // Extract srtpCipher from transport stats
7670
+ let srtpCipher: string | undefined;
7671
+ for (const stats of data.stats.values()) {
7672
+ if (stats.type === 'transport' && stats.srtpCipher) {
7673
+ srtpCipher = stats.srtpCipher as string;
7674
+ break;
7675
+ }
7676
+ }
7677
+
7678
+ // Only emit event if srtpCipher has changed
7679
+ if (srtpCipher && srtpCipher !== this.mediaProperties.srtpCipher) {
7680
+ LoggerProxy.logger.info(
7681
+ `Meeting:index#setupStatsAnalyzerEventHandlers --> SRTP cipher changed from ${this.mediaProperties.srtpCipher} to ${srtpCipher}`
7682
+ );
7683
+ this.mediaProperties.srtpCipher = srtpCipher;
7684
+ Trigger.trigger(
7685
+ this,
7686
+ {
7687
+ file: 'meeting/index',
7688
+ function: 'setupStatsAnalyzerEventHandlers',
7689
+ },
7690
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
7691
+ {srtpCipher}
7692
+ );
7693
+ }
7694
+ });
7426
7695
  };
7427
7696
 
7428
7697
  getMediaConnectionDebugId() {
@@ -8716,6 +8985,8 @@ export default class Meeting extends StatelessWebexPlugin {
8716
8985
  });
8717
8986
  LoggerProxy.logger.log('Meeting:index#leave --> Leaving a meeting');
8718
8987
 
8988
+ this.stopListeningForMeetingEvents();
8989
+
8719
8990
  return MeetingUtil.leaveMeeting(this, options)
8720
8991
  .then(async (leave) => {
8721
8992
  // CA team recommends submitting this *after* locus /leave
@@ -9580,6 +9851,8 @@ export default class Meeting extends StatelessWebexPlugin {
9580
9851
  locus_id: this.locusId,
9581
9852
  });
9582
9853
 
9854
+ this.stopListeningForMeetingEvents();
9855
+
9583
9856
  return MeetingUtil.endMeetingForAll(this)
9584
9857
  .then(async (end) => {
9585
9858
  this.meetingFiniteStateMachine.end();
@@ -9641,12 +9914,28 @@ export default class Meeting extends StatelessWebexPlugin {
9641
9914
  }
9642
9915
  this.queuedMediaUpdates = [];
9643
9916
 
9644
- this.stopTranscription();
9645
- this.transcription = undefined;
9917
+ // Listener teardown (transcription, annotation, llm/mercury) runs in
9918
+ // stopListeningForMeetingEvents() before /leave and /end so events
9919
+ // received mid-teardown do not trigger Locus syncs. Calling it here
9920
+ // again would double-emit MEETING_STOPPED_RECEIVING_TRANSCRIPTION
9921
+ // because stopTranscription() always fires its trigger.
9922
+ //
9923
+ // Ownership-aware token clear: only clear the shared LLM data channel
9924
+ // tokens when this meeting owns (or no meeting owns) the default LLM
9925
+ // session. Otherwise we would wipe tokens still in use by another
9926
+ // meeting's active LLM connection.
9927
+ // @ts-ignore - Fix type
9928
+ const currentOwner = this.webex.internal.llm.getOwnerMeetingId();
9929
+ const isOwner = !currentOwner || currentOwner === this.id;
9646
9930
 
9647
- this.annotation.deregisterEvents();
9931
+ if (isOwner) {
9932
+ this.clearDataChannelToken();
9933
+ } else {
9934
+ LoggerProxy.logger.info(
9935
+ `Meeting:index#clearMeetingData --> skipping clearDataChannelToken; LLM owned by meeting ${currentOwner}, not ${this.id}`
9936
+ );
9937
+ }
9648
9938
 
9649
- this.clearDataChannelToken();
9650
9939
  await this.cleanupLLMConneciton({throwOnError: false});
9651
9940
  };
9652
9941
 
@@ -371,6 +371,7 @@ const MeetingUtil = {
371
371
  meeting.breakouts.cleanUp();
372
372
  meeting.webinar.cleanUp();
373
373
  meeting.simultaneousInterpretation.cleanUp();
374
+ meeting.locusInfo.cleanUp();
374
375
  meeting.locusMediaRequest = undefined;
375
376
 
376
377
  meeting.webex?.internal?.newMetrics?.callDiagnosticMetrics?.clearEventLimitsForCorrelationId(
@@ -845,6 +846,19 @@ const MeetingUtil = {
845
846
  requestBody.sequence = sequence;
846
847
  },
847
848
 
849
+ /**
850
+ * Checks if Locus API response contains a Locus DTO
851
+ *
852
+ * @param {any} response http response from Locus API call
853
+ * @returns {boolean} true if response contains a Locus DTO
854
+ */
855
+ isLocusDtoInAPIResponse(response: any) {
856
+ return (
857
+ response?.body?.locus || // for APIs called on our participant - locus is one of props in the response body
858
+ response?.body?.url // for APIs that act on locus itself (like mute all), the body is the locus
859
+ );
860
+ },
861
+
848
862
  /**
849
863
  * Updates the locus info for the meeting with the locus
850
864
  * information returned from API requests made to Locus
@@ -853,12 +867,13 @@ const MeetingUtil = {
853
867
  * @param {Object} response The response of the http request
854
868
  * @returns {Object}
855
869
  */
856
- updateLocusFromApiResponse: (meeting, response) => {
870
+ updateLocusFromApiResponse: (meeting: any, response: any) => {
857
871
  if (!meeting) {
858
872
  return response;
859
873
  }
860
874
 
861
- if (response?.body?.locus) {
875
+ // locus API responses can come in different shapes:
876
+ if (MeetingUtil.isLocusDtoInAPIResponse(response)) {
862
877
  meeting.locusInfo.handleLocusAPIResponse(meeting, response.body);
863
878
  }
864
879
 
@@ -930,6 +945,9 @@ const MeetingUtil = {
930
945
  attendeeRequestAiAssistantDeclinedAll: (displayHints = []) =>
931
946
  displayHints.includes(DISPLAY_HINTS.ATTENDEE_REQUEST_AI_ASSISTANT_DECLINED_ALL),
932
947
 
948
+ isAnonymizeDisplayNamesEnabled: (displayHints) =>
949
+ displayHints.includes(DISPLAY_HINTS.ANONYMOUS_DISPLAY_NAMES_ENABLED),
950
+
933
951
  selfSupportsFeature: (feature: SELF_POLICY, userPolicies: Record<SELF_POLICY, boolean>) => {
934
952
  if (!userPolicies) {
935
953
  return true;