@webex/plugin-meetings 3.12.0-mobius-socket.1 → 3.12.0-mobius-socket.3

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 (145) 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 +8 -3
  5. package/dist/breakouts/breakout.js.map +1 -1
  6. package/dist/breakouts/index.js +3 -2
  7. package/dist/breakouts/index.js.map +1 -1
  8. package/dist/config.js +1 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.js +6 -3
  11. package/dist/constants.js.map +1 -1
  12. package/dist/controls-options-manager/constants.js +11 -1
  13. package/dist/controls-options-manager/constants.js.map +1 -1
  14. package/dist/controls-options-manager/index.js +38 -24
  15. package/dist/controls-options-manager/index.js.map +1 -1
  16. package/dist/controls-options-manager/util.js +91 -0
  17. package/dist/controls-options-manager/util.js.map +1 -1
  18. package/dist/hashTree/constants.js +10 -1
  19. package/dist/hashTree/constants.js.map +1 -1
  20. package/dist/hashTree/hashTreeParser.js +651 -382
  21. package/dist/hashTree/hashTreeParser.js.map +1 -1
  22. package/dist/hashTree/utils.js +22 -0
  23. package/dist/hashTree/utils.js.map +1 -1
  24. package/dist/index.js +7 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/interceptors/locusRetry.js +23 -8
  27. package/dist/interceptors/locusRetry.js.map +1 -1
  28. package/dist/interpretation/index.js +10 -1
  29. package/dist/interpretation/index.js.map +1 -1
  30. package/dist/interpretation/siLanguage.js +1 -1
  31. package/dist/locus-info/controlsUtils.js +4 -1
  32. package/dist/locus-info/controlsUtils.js.map +1 -1
  33. package/dist/locus-info/index.js +289 -87
  34. package/dist/locus-info/index.js.map +1 -1
  35. package/dist/locus-info/types.js +19 -0
  36. package/dist/locus-info/types.js.map +1 -1
  37. package/dist/media/properties.js +1 -0
  38. package/dist/media/properties.js.map +1 -1
  39. package/dist/meeting/in-meeting-actions.js +3 -1
  40. package/dist/meeting/in-meeting-actions.js.map +1 -1
  41. package/dist/meeting/index.js +848 -582
  42. package/dist/meeting/index.js.map +1 -1
  43. package/dist/meeting/util.js +19 -2
  44. package/dist/meeting/util.js.map +1 -1
  45. package/dist/meetings/index.js +205 -77
  46. package/dist/meetings/index.js.map +1 -1
  47. package/dist/meetings/meetings.types.js +6 -1
  48. package/dist/meetings/meetings.types.js.map +1 -1
  49. package/dist/meetings/request.js +39 -0
  50. package/dist/meetings/request.js.map +1 -1
  51. package/dist/meetings/util.js +67 -5
  52. package/dist/meetings/util.js.map +1 -1
  53. package/dist/member/index.js +10 -0
  54. package/dist/member/index.js.map +1 -1
  55. package/dist/member/types.js.map +1 -1
  56. package/dist/member/util.js +3 -0
  57. package/dist/member/util.js.map +1 -1
  58. package/dist/metrics/constants.js +4 -1
  59. package/dist/metrics/constants.js.map +1 -1
  60. package/dist/multistream/receiveSlot.js +9 -0
  61. package/dist/multistream/receiveSlot.js.map +1 -1
  62. package/dist/reactions/reactions.type.js.map +1 -1
  63. package/dist/recording-controller/index.js +1 -3
  64. package/dist/recording-controller/index.js.map +1 -1
  65. package/dist/types/config.d.ts +1 -0
  66. package/dist/types/constants.d.ts +2 -0
  67. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  68. package/dist/types/controls-options-manager/index.d.ts +10 -0
  69. package/dist/types/hashTree/constants.d.ts +1 -0
  70. package/dist/types/hashTree/hashTreeParser.d.ts +83 -16
  71. package/dist/types/hashTree/utils.d.ts +11 -0
  72. package/dist/types/index.d.ts +2 -0
  73. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  74. package/dist/types/locus-info/index.d.ts +46 -6
  75. package/dist/types/locus-info/types.d.ts +21 -1
  76. package/dist/types/media/properties.d.ts +1 -0
  77. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  78. package/dist/types/meeting/index.d.ts +65 -1
  79. package/dist/types/meeting/util.d.ts +8 -0
  80. package/dist/types/meetings/index.d.ts +20 -2
  81. package/dist/types/meetings/meetings.types.d.ts +15 -0
  82. package/dist/types/meetings/request.d.ts +14 -0
  83. package/dist/types/member/index.d.ts +1 -0
  84. package/dist/types/member/types.d.ts +1 -0
  85. package/dist/types/member/util.d.ts +1 -0
  86. package/dist/types/metrics/constants.d.ts +3 -0
  87. package/dist/types/reactions/reactions.type.d.ts +3 -0
  88. package/dist/webinar/index.js +68 -17
  89. package/dist/webinar/index.js.map +1 -1
  90. package/package.json +22 -22
  91. package/src/aiEnableRequest/index.ts +16 -0
  92. package/src/breakouts/breakout.ts +3 -1
  93. package/src/breakouts/index.ts +1 -0
  94. package/src/config.ts +1 -0
  95. package/src/constants.ts +5 -1
  96. package/src/controls-options-manager/constants.ts +14 -1
  97. package/src/controls-options-manager/index.ts +47 -24
  98. package/src/controls-options-manager/util.ts +81 -1
  99. package/src/hashTree/constants.ts +9 -0
  100. package/src/hashTree/hashTreeParser.ts +375 -197
  101. package/src/hashTree/utils.ts +17 -0
  102. package/src/index.ts +5 -0
  103. package/src/interceptors/locusRetry.ts +25 -4
  104. package/src/interpretation/index.ts +25 -8
  105. package/src/locus-info/controlsUtils.ts +3 -1
  106. package/src/locus-info/index.ts +291 -97
  107. package/src/locus-info/types.ts +25 -1
  108. package/src/media/properties.ts +1 -0
  109. package/src/meeting/in-meeting-actions.ts +4 -0
  110. package/src/meeting/index.ts +260 -23
  111. package/src/meeting/util.ts +20 -2
  112. package/src/meetings/index.ts +109 -43
  113. package/src/meetings/meetings.types.ts +19 -0
  114. package/src/meetings/request.ts +43 -0
  115. package/src/meetings/util.ts +80 -1
  116. package/src/member/index.ts +10 -0
  117. package/src/member/types.ts +1 -0
  118. package/src/member/util.ts +3 -0
  119. package/src/metrics/constants.ts +3 -0
  120. package/src/multistream/receiveSlot.ts +18 -0
  121. package/src/reactions/reactions.type.ts +3 -0
  122. package/src/recording-controller/index.ts +1 -2
  123. package/src/webinar/index.ts +88 -21
  124. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  125. package/test/unit/spec/breakouts/breakout.ts +9 -3
  126. package/test/unit/spec/breakouts/index.ts +2 -0
  127. package/test/unit/spec/controls-options-manager/index.js +140 -29
  128. package/test/unit/spec/controls-options-manager/util.js +165 -0
  129. package/test/unit/spec/hashTree/hashTreeParser.ts +1263 -157
  130. package/test/unit/spec/hashTree/utils.ts +88 -1
  131. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  132. package/test/unit/spec/interpretation/index.ts +26 -4
  133. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  134. package/test/unit/spec/locus-info/index.js +475 -81
  135. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  136. package/test/unit/spec/meeting/index.js +902 -14
  137. package/test/unit/spec/meeting/muteState.js +3 -0
  138. package/test/unit/spec/meeting/utils.js +33 -0
  139. package/test/unit/spec/meetings/index.js +309 -10
  140. package/test/unit/spec/meetings/request.js +141 -0
  141. package/test/unit/spec/meetings/utils.js +161 -0
  142. package/test/unit/spec/member/index.js +7 -0
  143. package/test/unit/spec/member/util.js +24 -0
  144. package/test/unit/spec/recording-controller/index.js +9 -8
  145. package/test/unit/spec/webinar/index.ts +81 -16
@@ -43,6 +43,7 @@ export default class MediaProperties {
43
43
  shareAudioStream?: LocalSystemAudioStream;
44
44
  videoDeviceId: any;
45
45
  videoStream?: LocalCameraStream;
46
+ srtpCipher: string | undefined;
46
47
  namespace = MEETINGS;
47
48
  mediaIssueCounters: {[key: string]: number} = {};
48
49
  throttledSendMediaIssueMetric: ReturnType<typeof throttle>;
@@ -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
  /**
@@ -137,6 +137,7 @@ import {
137
137
  STAGE_MANAGER_TYPE,
138
138
  LOCUSEVENT,
139
139
  LOCUS_LLM_EVENT,
140
+ LLM_PRACTICE_SESSION,
140
141
  } from '../constants';
141
142
  import BEHAVIORAL_METRICS from '../metrics/constants';
142
143
  import ParameterError from '../common/errors/parameter';
@@ -612,7 +613,7 @@ export default class Meeting extends StatelessWebexPlugin {
612
613
  webinar: any;
613
614
  conversationUrl: string;
614
615
  callStateForMetrics: CallStateForMetrics;
615
- destination: string;
616
+ destination: string | LocusDTO;
616
617
  destinationType: DESTINATION_TYPE;
617
618
  deviceUrl: string;
618
619
  hostId: string;
@@ -651,6 +652,8 @@ export default class Meeting extends StatelessWebexPlugin {
651
652
  floorGrantPending: boolean;
652
653
  hasJoinedOnce: boolean;
653
654
  hasWebsocketConnected: boolean;
655
+ private mercuryOnlineHandler?: () => void;
656
+ private mercuryOfflineHandler?: () => void;
654
657
  inMeetingActions: InMeetingActions;
655
658
  isLocalShareLive: boolean;
656
659
  isRoapInProgress: boolean;
@@ -935,7 +938,7 @@ export default class Meeting extends StatelessWebexPlugin {
935
938
  this.simultaneousInterpretation = new SimultaneousInterpretation({}, {parent: this.webex});
936
939
 
937
940
  // @ts-ignore
938
- this.aiEnableRequest = new AIEnableRequest({}, {parent: this.webex});
941
+ this.aiEnableRequest = new AIEnableRequest({locusUrl: this.locusUrl}, {parent: this.webex});
939
942
 
940
943
  /**
941
944
  * @instance
@@ -2789,7 +2792,7 @@ export default class Meeting extends StatelessWebexPlugin {
2789
2792
  private setupLocusControlsListener() {
2790
2793
  this.locusInfo.on(
2791
2794
  LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
2792
- ({state, modifiedBy, lastModified}) => {
2795
+ ({state, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}) => {
2793
2796
  let event;
2794
2797
 
2795
2798
  switch (state) {
@@ -2815,6 +2818,8 @@ export default class Meeting extends StatelessWebexPlugin {
2815
2818
  state: state === RECORDING_STATE.RESUMED ? RECORDING_STATE.RECORDING : state,
2816
2819
  modifiedBy,
2817
2820
  lastModified,
2821
+ modifiedByServiceAppName,
2822
+ modifiedByServiceAppId,
2818
2823
  };
2819
2824
  Trigger.trigger(
2820
2825
  this,
@@ -3459,6 +3464,7 @@ export default class Meeting extends StatelessWebexPlugin {
3459
3464
  this.breakouts.locusUrlUpdate(url);
3460
3465
  this.simultaneousInterpretation.locusUrlUpdate(url);
3461
3466
  this.annotation.locusUrlUpdate(url);
3467
+ this.aiEnableRequest.locusUrlUpdate(url);
3462
3468
  this.locusUrl = url;
3463
3469
  this.locusId = this.locusUrl?.split('/').pop();
3464
3470
  this.recordingController.setLocusUrl(this.locusUrl);
@@ -4611,6 +4617,9 @@ export default class Meeting extends StatelessWebexPlugin {
4611
4617
  ),
4612
4618
  isAttendeeRequestAiAssistantDeclinedAll:
4613
4619
  MeetingUtil.attendeeRequestAiAssistantDeclinedAll(this.userDisplayHints),
4620
+ isAnonymizeDisplayNamesEnabled: MeetingUtil.isAnonymizeDisplayNamesEnabled(
4621
+ this.userDisplayHints
4622
+ ),
4614
4623
  }) || changed;
4615
4624
  }
4616
4625
  if (changed) {
@@ -4659,6 +4668,33 @@ export default class Meeting extends StatelessWebexPlugin {
4659
4668
  this.sipUri = sipUri;
4660
4669
  }
4661
4670
 
4671
+ /**
4672
+ * After initial locus setup, refreshes destination with synced locus data and optionally
4673
+ * performs deferred meeting info fetch when initial locus was incomplete.
4674
+ * @param {LocusDTO} locus
4675
+ * @returns {void}
4676
+ */
4677
+ public async finalizeMeetingAfterInitialLocusSetup(locus: LocusDTO): Promise<void> {
4678
+ if (locus && this?.destinationType === DESTINATION_TYPE.LOCUS_ID) {
4679
+ // destination is initialized from the initial locus snapshot in constructor,
4680
+ // so refresh it after locus sync to avoid stale partial hash-tree data.
4681
+ this.destination = locus;
4682
+ }
4683
+ if (
4684
+ (!this.meetingInfo || isEmpty(this.meetingInfo)) &&
4685
+ (this.destination as LocusDTO)?.info &&
4686
+ !this.fetchMeetingInfoTimeoutId
4687
+ ) {
4688
+ try {
4689
+ await this.fetchMeetingInfo({});
4690
+ } catch (error: any) {
4691
+ LoggerProxy.logger.info(
4692
+ `Meeting:index#finalizeMeetingAfterInitialLocusSetup --> deferred fetchMeetingInfo failed: ${error.message}`
4693
+ );
4694
+ }
4695
+ }
4696
+ }
4697
+
4662
4698
  /**
4663
4699
  * Set the locus info the class instance. Should be called with the parsed locus
4664
4700
  * we got in the join response.
@@ -5130,8 +5166,7 @@ export default class Meeting extends StatelessWebexPlugin {
5130
5166
  public setMercuryListener() {
5131
5167
  // Client will have a socket manager and handle reconnecting to mercury, when we reconnect to mercury
5132
5168
  // if the meeting has active peer connections, it should try to reconnect.
5133
- // @ts-ignore
5134
- this.webex.internal.mercury.on(ONLINE, () => {
5169
+ this.mercuryOnlineHandler = () => {
5135
5170
  LoggerProxy.logger.info('Meeting:index#setMercuryListener --> Web socket online');
5136
5171
 
5137
5172
  // Only send restore event when it was disconnected before and for connected later
@@ -5141,15 +5176,47 @@ export default class Meeting extends StatelessWebexPlugin {
5141
5176
  });
5142
5177
  }
5143
5178
  this.hasWebsocketConnected = true;
5144
- });
5179
+ };
5145
5180
 
5146
- // @ts-ignore
5147
- this.webex.internal.mercury.on(OFFLINE, () => {
5181
+ this.mercuryOfflineHandler = () => {
5148
5182
  LoggerProxy.logger.error('Meeting:index#setMercuryListener --> Web socket offline');
5149
5183
  Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.MERCURY_CONNECTION_FAILURE, {
5150
5184
  correlation_id: this.correlationId,
5151
5185
  });
5152
- });
5186
+ };
5187
+
5188
+ // @ts-ignore
5189
+ this.webex.internal.mercury.on(ONLINE, this.mercuryOnlineHandler);
5190
+ // @ts-ignore
5191
+ this.webex.internal.mercury.on(OFFLINE, this.mercuryOfflineHandler);
5192
+ }
5193
+
5194
+ /**
5195
+ * Removes this meeting's Mercury ONLINE/OFFLINE event listeners registered
5196
+ * by setMercuryListener(). Must be called before Locus /leave to avoid
5197
+ * unnecessary syncs/metrics triggered by events received while leaving
5198
+ * (per Locus team recommendation).
5199
+ *
5200
+ * Mercury is a process-wide singleton shared with other plugins, so we
5201
+ * pass the bound handler refs to .off() to avoid clearing every listener
5202
+ * for ONLINE/OFFLINE on the shared emitter.
5203
+ *
5204
+ * Idempotent: subsequent calls are no-ops because the handler refs are
5205
+ * cleared after detaching.
5206
+ * @private
5207
+ * @returns {void}
5208
+ */
5209
+ private stopListeningForMercuryEvents() {
5210
+ if (this.mercuryOnlineHandler) {
5211
+ // @ts-ignore
5212
+ this.webex.internal.mercury.off(ONLINE, this.mercuryOnlineHandler);
5213
+ this.mercuryOnlineHandler = undefined;
5214
+ }
5215
+ if (this.mercuryOfflineHandler) {
5216
+ // @ts-ignore
5217
+ this.webex.internal.mercury.off(OFFLINE, this.mercuryOfflineHandler);
5218
+ this.mercuryOfflineHandler = undefined;
5219
+ }
5153
5220
  }
5154
5221
 
5155
5222
  /**
@@ -5865,6 +5932,31 @@ export default class Meeting extends StatelessWebexPlugin {
5865
5932
  }
5866
5933
  };
5867
5934
 
5935
+ /**
5936
+ * Verifies the relay event was delivered for the active LLM session binding.
5937
+ * @param {RelayEvent} event Event object coming from LLM Connection
5938
+ * @returns {boolean}
5939
+ */
5940
+ private isRelayEventRouteValid(event: RelayEvent): boolean {
5941
+ const route = event?.headers?.route;
5942
+
5943
+ if (!route) {
5944
+ return true;
5945
+ }
5946
+
5947
+ const {llm} = (this as any).webex.internal;
5948
+ const isPracticeSession = llm.isConnected(LLM_PRACTICE_SESSION);
5949
+ const expectedBinding = isPracticeSession
5950
+ ? llm.getBinding(LLM_PRACTICE_SESSION)
5951
+ : llm.getBinding();
5952
+
5953
+ if (!expectedBinding || route === expectedBinding) {
5954
+ return true;
5955
+ }
5956
+
5957
+ return false;
5958
+ }
5959
+
5868
5960
  /**
5869
5961
  * Callback called when a relay event is received from meeting LLM Connection
5870
5962
  * @param {RelayEvent} e Event object coming from LLM Connection
@@ -5872,6 +5964,9 @@ export default class Meeting extends StatelessWebexPlugin {
5872
5964
  * @returns {void}
5873
5965
  */
5874
5966
  private processRelayEvent = (e: RelayEvent): void => {
5967
+ if (!this.isRelayEventRouteValid(e)) {
5968
+ return;
5969
+ }
5875
5970
  switch (e.data.relayType) {
5876
5971
  case REACTION_RELAY_TYPES.REACTION:
5877
5972
  if (
@@ -6301,8 +6396,57 @@ export default class Meeting extends StatelessWebexPlugin {
6301
6396
  }
6302
6397
  }
6303
6398
 
6399
+ /**
6400
+ * Removes LLM event listeners and clears the health check timer.
6401
+ * Must be called before Locus /leave to avoid unnecessary syncs triggered
6402
+ * by events received while leaving (per Locus team recommendation).
6403
+ * Idempotent: safe to call multiple times; .off() is a no-op when no
6404
+ * matching listener is registered.
6405
+ * @private
6406
+ * @returns {void}
6407
+ */
6408
+ private stopListeningForLLMEvents() {
6409
+ // @ts-ignore - fix types
6410
+ this.webex.internal.llm.off('event:relay.event', this.processRelayEvent);
6411
+ // @ts-ignore - fix types
6412
+ this.webex.internal.llm.off(LOCUS_LLM_EVENT, this.processLocusLLMEvent);
6413
+ this.clearLLMHealthCheckTimer();
6414
+ }
6415
+
6416
+ /**
6417
+ * Stops listening on every event bus (LLM, Mercury, voicea/transcription,
6418
+ * annotation) that could otherwise deliver events to this meeting while
6419
+ * Locus is processing /leave or /end. Per the Locus team recommendation,
6420
+ * this must run before the Locus request is dispatched to avoid
6421
+ * unnecessary syncs triggered by in-flight events.
6422
+ *
6423
+ * Voicea (transcription) subscribes to llm 'event:relay.event' internally,
6424
+ * and the annotation plugin subscribes to both mercury and llm, so both
6425
+ * must be torn down alongside the direct LLM/Mercury listeners.
6426
+ *
6427
+ * Idempotent: safe to call multiple times; .off() is a no-op when no
6428
+ * matching listener is registered, and stopTranscription is guarded.
6429
+ * @private
6430
+ * @returns {void}
6431
+ */
6432
+ private stopListeningForMeetingEvents() {
6433
+ this.stopListeningForLLMEvents();
6434
+ this.stopListeningForMercuryEvents();
6435
+ if (this.transcription) {
6436
+ this.stopTranscription();
6437
+ this.transcription = undefined;
6438
+ }
6439
+ this.annotation.deregisterEvents();
6440
+ }
6441
+
6304
6442
  /**
6305
6443
  * Disconnects and cleans up the default LLM session listeners/timers.
6444
+ *
6445
+ * Ownership-aware: only calls `disconnectLLM` when this meeting is the
6446
+ * current owner of the default LLM session (or when no owner is recorded).
6447
+ * Event listeners belonging to this meeting instance are always detached
6448
+ * so they do not receive another meeting's relay events.
6449
+ *
6306
6450
  * @param {Object} options
6307
6451
  * @param {boolean} [options.removeOnlineListener=true] removes the one-time online listener
6308
6452
  * @param {boolean} [options.throwOnError=true] rethrows disconnect errors when true
@@ -6315,12 +6459,22 @@ export default class Meeting extends StatelessWebexPlugin {
6315
6459
  removeOnlineListener?: boolean;
6316
6460
  throwOnError?: boolean;
6317
6461
  } = {}): Promise<void> => {
6462
+ // @ts-ignore - Fix type
6463
+ const currentOwner = this.webex.internal.llm.getOwnerMeetingId();
6464
+ const isOwner = !currentOwner || currentOwner === this.id;
6465
+
6318
6466
  try {
6319
- // @ts-ignore - Fix type
6320
- await this.webex.internal.llm.disconnectLLM({
6321
- code: 3050,
6322
- reason: 'done (permanent)',
6323
- });
6467
+ if (isOwner) {
6468
+ // @ts-ignore - Fix type
6469
+ await this.webex.internal.llm.disconnectLLM({
6470
+ code: 3050,
6471
+ reason: 'done (permanent)',
6472
+ });
6473
+ } else {
6474
+ LoggerProxy.logger.info(
6475
+ `Meeting:index#cleanupLLMConneciton --> skipping disconnect; LLM owned by meeting ${currentOwner}, not ${this.id}`
6476
+ );
6477
+ }
6324
6478
  } catch (error) {
6325
6479
  LoggerProxy.logger.error(
6326
6480
  'Meeting:index#cleanupLLMConneciton --> Failed to disconnect default LLM session',
@@ -6335,12 +6489,18 @@ export default class Meeting extends StatelessWebexPlugin {
6335
6489
  // @ts-ignore - Fix type
6336
6490
  this.webex.internal.llm.off('online', this.handleLLMOnline);
6337
6491
  }
6338
- // @ts-ignore - fix types
6339
- this.webex.internal.llm.off('event:relay.event', this.processRelayEvent);
6340
- // @ts-ignore - Fix type
6341
- this.webex.internal.llm.off(LOCUS_LLM_EVENT, this.processLocusLLMEvent);
6492
+ this.stopListeningForLLMEvents();
6342
6493
 
6343
- this.clearLLMHealthCheckTimer();
6494
+ // If this meeting owned (or could have owned) the default LLM session,
6495
+ // always release the owner tag here regardless of whether disconnectLLM
6496
+ // resolved. `disconnectLLM` only clears the owner on its success path,
6497
+ // so a failed disconnect would otherwise leave a stale owner pointing
6498
+ // at a torn-down meeting and permanently block other meetings'
6499
+ // `updateLLMConnection` calls via the ownership guard.
6500
+ if (isOwner) {
6501
+ // @ts-ignore - Fix type
6502
+ this.webex.internal.llm.setOwnerMeetingId?.(undefined);
6503
+ }
6344
6504
  }
6345
6505
  };
6346
6506
 
@@ -6442,8 +6602,33 @@ export default class Meeting extends StatelessWebexPlugin {
6442
6602
 
6443
6603
  const dataChannelUrl = datachannelUrl;
6444
6604
 
6605
+ // Ownership guard: when the default LLM session is already connected and
6606
+ // owned by a *different* Meeting instance, do not disconnect or reconfigure
6607
+ // it. Another meeting's `updateLLMConnection` must be ignored here to
6608
+ // avoid killing the socket it relies on. We only proceed to manage the
6609
+ // connection when this meeting is the current owner, or when no owner is
6610
+ // set yet (first claim).
6611
+ // @ts-ignore - Fix type
6612
+ const currentOwner = this.webex.internal.llm.getOwnerMeetingId();
6613
+
6445
6614
  // @ts-ignore - Fix type
6446
6615
  if (this.webex.internal.llm.isConnected()) {
6616
+ if (currentOwner && currentOwner !== this.id) {
6617
+ // Another meeting owns the live LLM socket. We must not disconnect
6618
+ // or reconfigure it -- doing so would tear down a session the
6619
+ // owning meeting still relies on. Locus/datachannel URL mismatch is
6620
+ // expected here (each meeting has its own locus URL) and is NOT a
6621
+ // valid signal of staleness, so we never reclaim from this path.
6622
+ // The only safe reclaim mechanism is the `finally`-block owner-tag
6623
+ // release in `cleanupLLMConneciton`, which fires when this meeting
6624
+ // itself is being torn down.
6625
+ LoggerProxy.logger.info(
6626
+ `Meeting:index#updateLLMConnection --> skipping; LLM owned by meeting ${currentOwner}, not ${this.id}`
6627
+ );
6628
+
6629
+ return undefined;
6630
+ }
6631
+
6447
6632
  if (
6448
6633
  // @ts-ignore - Fix type
6449
6634
  url === this.webex.internal.llm.getLocusUrl() &&
@@ -6464,6 +6649,11 @@ export default class Meeting extends StatelessWebexPlugin {
6464
6649
  return this.webex.internal.llm
6465
6650
  .registerAndConnect(url, dataChannelUrl, datachannelToken)
6466
6651
  .then((registerAndConnectResult) => {
6652
+ // Record ownership of the default LLM session for this meeting so
6653
+ // subsequent cross-meeting `updateLLMConnection` / `cleanupLLMConneciton`
6654
+ // calls can detect and skip work that doesn't belong to them.
6655
+ // @ts-ignore - Fix type
6656
+ this.webex.internal.llm.setOwnerMeetingId?.(this.id);
6467
6657
  // @ts-ignore - Fix type
6468
6658
  this.webex.internal.llm.off('event:relay.event', this.processRelayEvent);
6469
6659
  // @ts-ignore - Fix type
@@ -7504,6 +7694,33 @@ export default class Meeting extends StatelessWebexPlugin {
7504
7694
  }
7505
7695
  }
7506
7696
  });
7697
+ this.statsAnalyzer.on(StatsAnalyzerEventNames.STATS_UPDATE, (data) => {
7698
+ // Extract srtpCipher from transport stats
7699
+ let srtpCipher: string | undefined;
7700
+ for (const stats of data.stats.values()) {
7701
+ if (stats.type === 'transport' && stats.srtpCipher) {
7702
+ srtpCipher = stats.srtpCipher as string;
7703
+ break;
7704
+ }
7705
+ }
7706
+
7707
+ // Only emit event if srtpCipher has changed
7708
+ if (srtpCipher && srtpCipher !== this.mediaProperties.srtpCipher) {
7709
+ LoggerProxy.logger.info(
7710
+ `Meeting:index#setupStatsAnalyzerEventHandlers --> SRTP cipher changed from ${this.mediaProperties.srtpCipher} to ${srtpCipher}`
7711
+ );
7712
+ this.mediaProperties.srtpCipher = srtpCipher;
7713
+ Trigger.trigger(
7714
+ this,
7715
+ {
7716
+ file: 'meeting/index',
7717
+ function: 'setupStatsAnalyzerEventHandlers',
7718
+ },
7719
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
7720
+ {srtpCipher}
7721
+ );
7722
+ }
7723
+ });
7507
7724
  };
7508
7725
 
7509
7726
  getMediaConnectionDebugId() {
@@ -8797,6 +9014,8 @@ export default class Meeting extends StatelessWebexPlugin {
8797
9014
  });
8798
9015
  LoggerProxy.logger.log('Meeting:index#leave --> Leaving a meeting');
8799
9016
 
9017
+ this.stopListeningForMeetingEvents();
9018
+
8800
9019
  return MeetingUtil.leaveMeeting(this, options)
8801
9020
  .then(async (leave) => {
8802
9021
  // CA team recommends submitting this *after* locus /leave
@@ -9661,6 +9880,8 @@ export default class Meeting extends StatelessWebexPlugin {
9661
9880
  locus_id: this.locusId,
9662
9881
  });
9663
9882
 
9883
+ this.stopListeningForMeetingEvents();
9884
+
9664
9885
  return MeetingUtil.endMeetingForAll(this)
9665
9886
  .then(async (end) => {
9666
9887
  this.meetingFiniteStateMachine.end();
@@ -9722,12 +9943,28 @@ export default class Meeting extends StatelessWebexPlugin {
9722
9943
  }
9723
9944
  this.queuedMediaUpdates = [];
9724
9945
 
9725
- this.stopTranscription();
9726
- this.transcription = undefined;
9946
+ // Listener teardown (transcription, annotation, llm/mercury) runs in
9947
+ // stopListeningForMeetingEvents() before /leave and /end so events
9948
+ // received mid-teardown do not trigger Locus syncs. Calling it here
9949
+ // again would double-emit MEETING_STOPPED_RECEIVING_TRANSCRIPTION
9950
+ // because stopTranscription() always fires its trigger.
9951
+ //
9952
+ // Ownership-aware token clear: only clear the shared LLM data channel
9953
+ // tokens when this meeting owns (or no meeting owns) the default LLM
9954
+ // session. Otherwise we would wipe tokens still in use by another
9955
+ // meeting's active LLM connection.
9956
+ // @ts-ignore - Fix type
9957
+ const currentOwner = this.webex.internal.llm.getOwnerMeetingId();
9958
+ const isOwner = !currentOwner || currentOwner === this.id;
9727
9959
 
9728
- this.annotation.deregisterEvents();
9960
+ if (isOwner) {
9961
+ this.clearDataChannelToken();
9962
+ } else {
9963
+ LoggerProxy.logger.info(
9964
+ `Meeting:index#clearMeetingData --> skipping clearDataChannelToken; LLM owned by meeting ${currentOwner}, not ${this.id}`
9965
+ );
9966
+ }
9729
9967
 
9730
- this.clearDataChannelToken();
9731
9968
  await this.cleanupLLMConneciton({throwOnError: false});
9732
9969
  };
9733
9970
 
@@ -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;