@webex/plugin-meetings 3.12.0-next.8 → 3.12.0-next.81

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 (183) 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 +26 -2
  7. package/dist/breakouts/index.js.map +1 -1
  8. package/dist/config.js +2 -0
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.js +34 -8
  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 +67 -29
  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 +13 -1
  19. package/dist/hashTree/constants.js.map +1 -1
  20. package/dist/hashTree/hashTreeParser.js +880 -382
  21. package/dist/hashTree/hashTreeParser.js.map +1 -1
  22. package/dist/hashTree/utils.js +42 -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/dataChannelAuthToken.js +75 -15
  27. package/dist/interceptors/dataChannelAuthToken.js.map +1 -1
  28. package/dist/interceptors/locusRetry.js +23 -8
  29. package/dist/interceptors/locusRetry.js.map +1 -1
  30. package/dist/interpretation/index.js +10 -1
  31. package/dist/interpretation/index.js.map +1 -1
  32. package/dist/interpretation/interpretation.types.js +7 -0
  33. package/dist/interpretation/interpretation.types.js.map +1 -0
  34. package/dist/interpretation/siLanguage.js +1 -1
  35. package/dist/locus-info/controlsUtils.js +4 -1
  36. package/dist/locus-info/controlsUtils.js.map +1 -1
  37. package/dist/locus-info/index.js +303 -88
  38. package/dist/locus-info/index.js.map +1 -1
  39. package/dist/locus-info/infoUtils.js +36 -5
  40. package/dist/locus-info/infoUtils.js.map +1 -1
  41. package/dist/locus-info/types.js +19 -0
  42. package/dist/locus-info/types.js.map +1 -1
  43. package/dist/media/index.js +3 -1
  44. package/dist/media/index.js.map +1 -1
  45. package/dist/media/properties.js +1 -0
  46. package/dist/media/properties.js.map +1 -1
  47. package/dist/meeting/in-meeting-actions.js +5 -1
  48. package/dist/meeting/in-meeting-actions.js.map +1 -1
  49. package/dist/meeting/index.js +1111 -724
  50. package/dist/meeting/index.js.map +1 -1
  51. package/dist/meeting/muteState.js +10 -1
  52. package/dist/meeting/muteState.js.map +1 -1
  53. package/dist/meeting/request.js +5 -2
  54. package/dist/meeting/request.js.map +1 -1
  55. package/dist/meeting/util.js +29 -2
  56. package/dist/meeting/util.js.map +1 -1
  57. package/dist/meeting-info/meeting-info-v2.js +2 -2
  58. package/dist/meeting-info/meeting-info-v2.js.map +1 -1
  59. package/dist/meetings/index.js +231 -78
  60. package/dist/meetings/index.js.map +1 -1
  61. package/dist/meetings/meetings.types.js +6 -1
  62. package/dist/meetings/meetings.types.js.map +1 -1
  63. package/dist/meetings/request.js +39 -0
  64. package/dist/meetings/request.js.map +1 -1
  65. package/dist/meetings/util.js +79 -5
  66. package/dist/meetings/util.js.map +1 -1
  67. package/dist/member/index.js +10 -0
  68. package/dist/member/index.js.map +1 -1
  69. package/dist/member/types.js.map +1 -1
  70. package/dist/member/util.js +3 -0
  71. package/dist/member/util.js.map +1 -1
  72. package/dist/metrics/constants.js +4 -1
  73. package/dist/metrics/constants.js.map +1 -1
  74. package/dist/multistream/codec/constants.js +63 -0
  75. package/dist/multistream/codec/constants.js.map +1 -0
  76. package/dist/multistream/mediaRequestManager.js +62 -15
  77. package/dist/multistream/mediaRequestManager.js.map +1 -1
  78. package/dist/multistream/receiveSlot.js +9 -0
  79. package/dist/multistream/receiveSlot.js.map +1 -1
  80. package/dist/reactions/reactions.type.js.map +1 -1
  81. package/dist/recording-controller/index.js +1 -3
  82. package/dist/recording-controller/index.js.map +1 -1
  83. package/dist/types/config.d.ts +2 -0
  84. package/dist/types/constants.d.ts +12 -1
  85. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  86. package/dist/types/controls-options-manager/index.d.ts +16 -0
  87. package/dist/types/hashTree/constants.d.ts +2 -0
  88. package/dist/types/hashTree/hashTreeParser.d.ts +146 -17
  89. package/dist/types/hashTree/utils.d.ts +18 -0
  90. package/dist/types/index.d.ts +3 -0
  91. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  92. package/dist/types/interpretation/interpretation.types.d.ts +10 -0
  93. package/dist/types/locus-info/index.d.ts +50 -6
  94. package/dist/types/locus-info/infoUtils.d.ts +8 -0
  95. package/dist/types/locus-info/types.d.ts +21 -1
  96. package/dist/types/media/properties.d.ts +1 -0
  97. package/dist/types/meeting/in-meeting-actions.d.ts +4 -0
  98. package/dist/types/meeting/index.d.ts +80 -6
  99. package/dist/types/meeting/request.d.ts +1 -0
  100. package/dist/types/meeting/util.d.ts +9 -0
  101. package/dist/types/meetings/index.d.ts +30 -2
  102. package/dist/types/meetings/meetings.types.d.ts +15 -0
  103. package/dist/types/meetings/request.d.ts +14 -0
  104. package/dist/types/member/index.d.ts +1 -0
  105. package/dist/types/member/types.d.ts +1 -0
  106. package/dist/types/member/util.d.ts +1 -0
  107. package/dist/types/metrics/constants.d.ts +3 -0
  108. package/dist/types/multistream/codec/constants.d.ts +7 -0
  109. package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
  110. package/dist/types/reactions/reactions.type.d.ts +3 -0
  111. package/dist/webinar/index.js +305 -159
  112. package/dist/webinar/index.js.map +1 -1
  113. package/package.json +22 -22
  114. package/src/aiEnableRequest/index.ts +16 -0
  115. package/src/breakouts/breakout.ts +3 -1
  116. package/src/breakouts/index.ts +31 -0
  117. package/src/config.ts +2 -0
  118. package/src/constants.ts +16 -2
  119. package/src/controls-options-manager/constants.ts +14 -1
  120. package/src/controls-options-manager/index.ts +87 -28
  121. package/src/controls-options-manager/util.ts +81 -1
  122. package/src/hashTree/constants.ts +16 -0
  123. package/src/hashTree/hashTreeParser.ts +580 -196
  124. package/src/hashTree/utils.ts +36 -0
  125. package/src/index.ts +6 -0
  126. package/src/interceptors/dataChannelAuthToken.ts +88 -12
  127. package/src/interceptors/locusRetry.ts +25 -4
  128. package/src/interpretation/index.ts +27 -9
  129. package/src/interpretation/interpretation.types.ts +11 -0
  130. package/src/locus-info/controlsUtils.ts +3 -1
  131. package/src/locus-info/index.ts +304 -100
  132. package/src/locus-info/infoUtils.ts +41 -6
  133. package/src/locus-info/types.ts +25 -1
  134. package/src/media/index.ts +3 -0
  135. package/src/media/properties.ts +1 -0
  136. package/src/meeting/in-meeting-actions.ts +8 -0
  137. package/src/meeting/index.ts +441 -65
  138. package/src/meeting/muteState.ts +10 -1
  139. package/src/meeting/request.ts +11 -0
  140. package/src/meeting/util.ts +33 -2
  141. package/src/meeting-info/meeting-info-v2.ts +4 -2
  142. package/src/meetings/index.ts +134 -44
  143. package/src/meetings/meetings.types.ts +19 -0
  144. package/src/meetings/request.ts +43 -0
  145. package/src/meetings/util.ts +97 -1
  146. package/src/member/index.ts +10 -0
  147. package/src/member/types.ts +1 -0
  148. package/src/member/util.ts +3 -0
  149. package/src/metrics/constants.ts +3 -0
  150. package/src/multistream/codec/constants.ts +58 -0
  151. package/src/multistream/mediaRequestManager.ts +119 -28
  152. package/src/multistream/receiveSlot.ts +18 -0
  153. package/src/reactions/reactions.type.ts +3 -0
  154. package/src/recording-controller/index.ts +1 -2
  155. package/src/webinar/index.ts +214 -36
  156. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  157. package/test/unit/spec/breakouts/breakout.ts +9 -3
  158. package/test/unit/spec/breakouts/index.ts +49 -0
  159. package/test/unit/spec/controls-options-manager/index.js +244 -29
  160. package/test/unit/spec/controls-options-manager/util.js +165 -0
  161. package/test/unit/spec/hashTree/hashTreeParser.ts +1838 -180
  162. package/test/unit/spec/hashTree/utils.ts +125 -1
  163. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +196 -0
  164. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  165. package/test/unit/spec/interpretation/index.ts +26 -4
  166. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  167. package/test/unit/spec/locus-info/index.js +499 -81
  168. package/test/unit/spec/locus-info/infoUtils.js +87 -0
  169. package/test/unit/spec/media/index.ts +31 -0
  170. package/test/unit/spec/meeting/in-meeting-actions.ts +4 -0
  171. package/test/unit/spec/meeting/index.js +1552 -42
  172. package/test/unit/spec/meeting/muteState.js +81 -0
  173. package/test/unit/spec/meeting/request.js +12 -0
  174. package/test/unit/spec/meeting/utils.js +82 -0
  175. package/test/unit/spec/meeting-info/meetinginfov2.js +19 -10
  176. package/test/unit/spec/meetings/index.js +363 -10
  177. package/test/unit/spec/meetings/request.js +141 -0
  178. package/test/unit/spec/meetings/utils.js +189 -0
  179. package/test/unit/spec/member/index.js +7 -0
  180. package/test/unit/spec/member/util.js +24 -0
  181. package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
  182. package/test/unit/spec/recording-controller/index.js +9 -8
  183. package/test/unit/spec/webinar/index.ts +329 -28
@@ -22,7 +22,6 @@ import {
22
22
  MediaConnectionEventNames,
23
23
  MediaContent,
24
24
  MediaType,
25
- MediaCodecMimeType,
26
25
  RemoteTrackType,
27
26
  RoapMessage,
28
27
  StatsAnalyzer,
@@ -31,7 +30,7 @@ import {
31
30
  NetworkQualityMonitor,
32
31
  StatsMonitor,
33
32
  StatsMonitorEventNames,
34
- InboundAudioIssueSubTypes,
33
+ MediaCodecMimeType,
35
34
  } from '@webex/internal-media-core';
36
35
 
37
36
  import {DataChannelTokenType} from '@webex/internal-plugin-llm';
@@ -137,6 +136,8 @@ import {
137
136
  STAGE_MANAGER_TYPE,
138
137
  LOCUSEVENT,
139
138
  LOCUS_LLM_EVENT,
139
+ LLM_DEFAULT_SESSION,
140
+ LLM_PRACTICE_SESSION,
140
141
  } from '../constants';
141
142
  import BEHAVIORAL_METRICS from '../metrics/constants';
142
143
  import ParameterError from '../common/errors/parameter';
@@ -190,6 +191,7 @@ import AIEnableRequest from '../aiEnableRequest';
190
191
  const DEFAULT_ICE_PHASE_CALLBACK = () => 'JOIN_MEETING_FINAL';
191
192
 
192
193
  const LLM_HEALTHCHECK_TIMER_MS = 3 * 60 * 1000;
194
+ const JOIN_WITH_MEDIA_RETRY_MAX_COUNT = 2;
193
195
 
194
196
  const logRequest = (request: any, {logText = ''}) => {
195
197
  LoggerProxy.logger.info(`${logText} - sending request`);
@@ -612,7 +614,7 @@ export default class Meeting extends StatelessWebexPlugin {
612
614
  webinar: any;
613
615
  conversationUrl: string;
614
616
  callStateForMetrics: CallStateForMetrics;
615
- destination: string;
617
+ destination: string | LocusDTO;
616
618
  destinationType: DESTINATION_TYPE;
617
619
  deviceUrl: string;
618
620
  hostId: string;
@@ -651,6 +653,8 @@ export default class Meeting extends StatelessWebexPlugin {
651
653
  floorGrantPending: boolean;
652
654
  hasJoinedOnce: boolean;
653
655
  hasWebsocketConnected: boolean;
656
+ private mercuryOnlineHandler?: () => void;
657
+ private mercuryOfflineHandler?: () => void;
654
658
  inMeetingActions: InMeetingActions;
655
659
  isLocalShareLive: boolean;
656
660
  isRoapInProgress: boolean;
@@ -662,6 +666,7 @@ export default class Meeting extends StatelessWebexPlugin {
662
666
  joinedWith?: any;
663
667
  selfId?: string;
664
668
  roles: any[];
669
+ canNotViewTheParticipantList?: boolean;
665
670
  // ... there is more ... see SelfUtils.parse()
666
671
  // end of the group
667
672
  locusMediaRequest?: LocusMediaRequest;
@@ -790,7 +795,13 @@ export default class Meeting extends StatelessWebexPlugin {
790
795
  private deferSDPAnswer?: Defer; // used for waiting for a response
791
796
  private sdpResponseTimer?: ReturnType<typeof setTimeout>;
792
797
  private hasMediaConnectionConnectedAtLeastOnce: boolean;
793
- private joinWithMediaRetryInfo?: {isRetry: boolean; prevJoinResponse?: any};
798
+ private joinWithMediaRetryInfo: {
799
+ retryCount: number;
800
+ prevJoinResponse?: any;
801
+ firstError?: Error;
802
+ prevError?: Error;
803
+ };
804
+
794
805
  private connectionStateHandler?: ConnectionStateHandler;
795
806
  private iceCandidateErrors: Map<string, number>;
796
807
  private iceCandidatesCount: number;
@@ -935,7 +946,7 @@ export default class Meeting extends StatelessWebexPlugin {
935
946
  this.simultaneousInterpretation = new SimultaneousInterpretation({}, {parent: this.webex});
936
947
 
937
948
  // @ts-ignore
938
- this.aiEnableRequest = new AIEnableRequest({}, {parent: this.webex});
949
+ this.aiEnableRequest = new AIEnableRequest({locusUrl: this.locusUrl}, {parent: this.webex});
939
950
 
940
951
  /**
941
952
  * @instance
@@ -966,6 +977,7 @@ export default class Meeting extends StatelessWebexPlugin {
966
977
  },
967
978
  (csi: CSI) => (this.members.findMemberByCsi(csi) as any)?.id
968
979
  );
980
+
969
981
  /**
970
982
  * Object containing helper classes for managing media requests for audio/video/screenshare (for multistream media connections)
971
983
  * All multistream media requests sent out for this meeting have to go through them.
@@ -985,6 +997,7 @@ export default class Meeting extends StatelessWebexPlugin {
985
997
  mediaRequests
986
998
  );
987
999
  },
1000
+ this.getIngressPayloadTypeCallback.bind(this),
988
1001
  {
989
1002
  // @ts-ignore - config coming from registerPlugin
990
1003
  degradationPreferences: this.config.degradationPreferences,
@@ -1006,6 +1019,7 @@ export default class Meeting extends StatelessWebexPlugin {
1006
1019
  mediaRequests
1007
1020
  );
1008
1021
  },
1022
+ this.getIngressPayloadTypeCallback.bind(this),
1009
1023
  {
1010
1024
  // @ts-ignore - config coming from registerPlugin
1011
1025
  degradationPreferences: this.config.degradationPreferences,
@@ -1027,6 +1041,7 @@ export default class Meeting extends StatelessWebexPlugin {
1027
1041
  mediaRequests
1028
1042
  );
1029
1043
  },
1044
+ this.getIngressPayloadTypeCallback.bind(this),
1030
1045
  {
1031
1046
  // @ts-ignore - config coming from registerPlugin
1032
1047
  degradationPreferences: this.config.degradationPreferences,
@@ -1048,11 +1063,14 @@ export default class Meeting extends StatelessWebexPlugin {
1048
1063
  mediaRequests
1049
1064
  );
1050
1065
  },
1066
+ this.getIngressPayloadTypeCallback.bind(this),
1051
1067
  {
1052
1068
  // @ts-ignore - config coming from registerPlugin
1053
1069
  degradationPreferences: this.config.degradationPreferences,
1054
1070
  kind: 'video',
1055
1071
  trimRequestsToNumOfSources: false,
1072
+ // @ts-ignore - config coming from registerPlugin
1073
+ enableAv1: this.config.enableAv1SlidesSupport,
1056
1074
  }
1057
1075
  ),
1058
1076
  };
@@ -1557,6 +1575,8 @@ export default class Meeting extends StatelessWebexPlugin {
1557
1575
  this.controlsOptionsManager = new ControlsOptionsManager(this.meetingRequest, {
1558
1576
  locusUrl: this.locusInfo?.url,
1559
1577
  displayHints: [],
1578
+ getControls: () => this.locusInfo?.controls,
1579
+ isWebinar: () => this.locusInfo?.info?.isWebinar,
1560
1580
  });
1561
1581
 
1562
1582
  this.setUpLocusInfoListeners();
@@ -1655,11 +1675,11 @@ export default class Meeting extends StatelessWebexPlugin {
1655
1675
  /**
1656
1676
  * Information needed for a retry of a call to joinWithMedia
1657
1677
  * @instance
1658
- * @type {{isRetry: boolean; prevJoinResponse?: any}}
1678
+ * @type {{retryCount: number; prevJoinResponse?: any}}
1659
1679
  * @private
1660
1680
  * @memberof Meeting
1661
1681
  */
1662
- this.joinWithMediaRetryInfo = {isRetry: false, prevJoinResponse: undefined};
1682
+ this.joinWithMediaRetryInfo = {retryCount: 0, prevJoinResponse: undefined};
1663
1683
 
1664
1684
  /**
1665
1685
  * Connection state handler
@@ -1713,6 +1733,37 @@ export default class Meeting extends StatelessWebexPlugin {
1713
1733
  this.mediaServerIp = undefined;
1714
1734
  }
1715
1735
 
1736
+ /**
1737
+ * Get the ingress payload type for a given media type and codec mime type
1738
+ * @param {MediaType} mediaType - The media type
1739
+ * @param {MediaCodecMimeType} codecMimeType - The codec mime type
1740
+ * @returns {number | undefined} - The ingress payload type
1741
+ * @private
1742
+ * @memberof Meeting
1743
+ */
1744
+ private getIngressPayloadTypeCallback(
1745
+ mediaType: MediaType,
1746
+ codecMimeType: MediaCodecMimeType
1747
+ ): number | undefined {
1748
+ if (this.isMultistream) {
1749
+ try {
1750
+ return this.mediaProperties.webrtcMediaConnection.getIngressPayloadType(
1751
+ mediaType,
1752
+ codecMimeType
1753
+ );
1754
+ } catch (error) {
1755
+ LoggerProxy.logger.info(
1756
+ `Meeting:index#mediaRequestManager --> failed to get ingress payload type for mediaType=${mediaType}, codecMimeType=${codecMimeType}`,
1757
+ error
1758
+ );
1759
+
1760
+ return undefined;
1761
+ }
1762
+ }
1763
+
1764
+ return undefined;
1765
+ }
1766
+
1716
1767
  /**
1717
1768
  * Temporary func to return webex object,
1718
1769
  * in order to access internal plugin metrics
@@ -2789,7 +2840,7 @@ export default class Meeting extends StatelessWebexPlugin {
2789
2840
  private setupLocusControlsListener() {
2790
2841
  this.locusInfo.on(
2791
2842
  LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
2792
- ({state, modifiedBy, lastModified}) => {
2843
+ ({state, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}) => {
2793
2844
  let event;
2794
2845
 
2795
2846
  switch (state) {
@@ -2815,6 +2866,8 @@ export default class Meeting extends StatelessWebexPlugin {
2815
2866
  state: state === RECORDING_STATE.RESUMED ? RECORDING_STATE.RECORDING : state,
2816
2867
  modifiedBy,
2817
2868
  lastModified,
2869
+ modifiedByServiceAppName,
2870
+ modifiedByServiceAppId,
2818
2871
  };
2819
2872
  Trigger.trigger(
2820
2873
  this,
@@ -3459,13 +3512,12 @@ export default class Meeting extends StatelessWebexPlugin {
3459
3512
  this.breakouts.locusUrlUpdate(url);
3460
3513
  this.simultaneousInterpretation.locusUrlUpdate(url);
3461
3514
  this.annotation.locusUrlUpdate(url);
3515
+ this.aiEnableRequest.locusUrlUpdate(url);
3462
3516
  this.locusUrl = url;
3463
3517
  this.locusId = this.locusUrl?.split('/').pop();
3464
3518
  this.recordingController.setLocusUrl(this.locusUrl);
3465
3519
  this.controlsOptionsManager.setLocusUrl(this.locusUrl, !!isMainLocus);
3466
3520
  this.webinar.locusUrlUpdate(url);
3467
- // @ts-ignore
3468
- this.webex.internal.llm.setRefreshHandler(() => this.refreshDataChannelToken());
3469
3521
 
3470
3522
  Trigger.trigger(
3471
3523
  this,
@@ -3734,7 +3786,7 @@ export default class Meeting extends StatelessWebexPlugin {
3734
3786
  });
3735
3787
  this.updateLLMConnection();
3736
3788
  });
3737
- this.locusInfo.on(LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, (payload) => {
3789
+ this.locusInfo.on(LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, async (payload) => {
3738
3790
  this.stopKeepAlive();
3739
3791
 
3740
3792
  if (payload) {
@@ -3761,13 +3813,15 @@ export default class Meeting extends StatelessWebexPlugin {
3761
3813
  }
3762
3814
  this.rtcMetrics?.sendNextMetrics();
3763
3815
 
3764
- this.ensureDefaultDatachannelTokenAfterAdmit().catch((error) => {
3816
+ try {
3817
+ await this.ensureDefaultDatachannelTokenAfterAdmit();
3818
+ } catch (error) {
3765
3819
  LoggerProxy.logger.warn(
3766
3820
  `Meeting:index#setUpLocusInfoSelfListener --> failed post-admit token prefetch flow: ${
3767
3821
  error?.message || String(error)
3768
3822
  }`
3769
3823
  );
3770
- });
3824
+ }
3771
3825
 
3772
3826
  this.updateLLMConnection();
3773
3827
  });
@@ -3813,6 +3867,9 @@ export default class Meeting extends StatelessWebexPlugin {
3813
3867
  });
3814
3868
 
3815
3869
  this.locusInfo.on(LOCUSINFO.EVENTS.SELF_CANNOT_VIEW_PARTICIPANT_LIST_CHANGE, (payload) => {
3870
+ // canViewTheParticipantList meeting action depends on this value, so we need to update meeting actions
3871
+ this.updateMeetingActions();
3872
+
3816
3873
  Trigger.trigger(
3817
3874
  this,
3818
3875
  {
@@ -4611,6 +4668,13 @@ export default class Meeting extends StatelessWebexPlugin {
4611
4668
  ),
4612
4669
  isAttendeeRequestAiAssistantDeclinedAll:
4613
4670
  MeetingUtil.attendeeRequestAiAssistantDeclinedAll(this.userDisplayHints),
4671
+ isAnonymizeDisplayNamesEnabled: MeetingUtil.isAnonymizeDisplayNamesEnabled(
4672
+ this.userDisplayHints
4673
+ ),
4674
+ canViewTheParticipantList: MeetingUtil.canViewTheParticipantList(
4675
+ this.userDisplayHints,
4676
+ this.canNotViewTheParticipantList ?? false
4677
+ ),
4614
4678
  }) || changed;
4615
4679
  }
4616
4680
  if (changed) {
@@ -4659,6 +4723,34 @@ export default class Meeting extends StatelessWebexPlugin {
4659
4723
  this.sipUri = sipUri;
4660
4724
  }
4661
4725
 
4726
+ /**
4727
+ * After initial locus setup, refreshes destination with synced locus data and optionally
4728
+ * performs deferred meeting info fetch when initial locus was incomplete.
4729
+ * @param {LocusDTO} locus
4730
+ * @returns {void}
4731
+ */
4732
+ public async finalizeMeetingAfterInitialLocusSetup(locus: LocusDTO): Promise<void> {
4733
+ if (locus && this?.destinationType === DESTINATION_TYPE.LOCUS_ID) {
4734
+ // destination is initialized from the initial locus snapshot in constructor,
4735
+ // so refresh it after locus sync to avoid stale partial hash-tree data.
4736
+ this.destination = locus;
4737
+ }
4738
+ if (
4739
+ (!this.meetingInfo || isEmpty(this.meetingInfo)) &&
4740
+ (this.destination as LocusDTO)?.info &&
4741
+ !this.fetchMeetingInfoTimeoutId &&
4742
+ !MeetingsUtil.isOneOnOneCall(locus)
4743
+ ) {
4744
+ try {
4745
+ await this.fetchMeetingInfo({});
4746
+ } catch (error: any) {
4747
+ LoggerProxy.logger.info(
4748
+ `Meeting:index#finalizeMeetingAfterInitialLocusSetup --> deferred fetchMeetingInfo failed: ${error.message}`
4749
+ );
4750
+ }
4751
+ }
4752
+ }
4753
+
4662
4754
  /**
4663
4755
  * Set the locus info the class instance. Should be called with the parsed locus
4664
4756
  * we got in the join response.
@@ -5130,8 +5222,7 @@ export default class Meeting extends StatelessWebexPlugin {
5130
5222
  public setMercuryListener() {
5131
5223
  // Client will have a socket manager and handle reconnecting to mercury, when we reconnect to mercury
5132
5224
  // if the meeting has active peer connections, it should try to reconnect.
5133
- // @ts-ignore
5134
- this.webex.internal.mercury.on(ONLINE, () => {
5225
+ this.mercuryOnlineHandler = () => {
5135
5226
  LoggerProxy.logger.info('Meeting:index#setMercuryListener --> Web socket online');
5136
5227
 
5137
5228
  // Only send restore event when it was disconnected before and for connected later
@@ -5141,15 +5232,47 @@ export default class Meeting extends StatelessWebexPlugin {
5141
5232
  });
5142
5233
  }
5143
5234
  this.hasWebsocketConnected = true;
5144
- });
5235
+ };
5145
5236
 
5146
- // @ts-ignore
5147
- this.webex.internal.mercury.on(OFFLINE, () => {
5237
+ this.mercuryOfflineHandler = () => {
5148
5238
  LoggerProxy.logger.error('Meeting:index#setMercuryListener --> Web socket offline');
5149
5239
  Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.MERCURY_CONNECTION_FAILURE, {
5150
5240
  correlation_id: this.correlationId,
5151
5241
  });
5152
- });
5242
+ };
5243
+
5244
+ // @ts-ignore
5245
+ this.webex.internal.mercury.on(ONLINE, this.mercuryOnlineHandler);
5246
+ // @ts-ignore
5247
+ this.webex.internal.mercury.on(OFFLINE, this.mercuryOfflineHandler);
5248
+ }
5249
+
5250
+ /**
5251
+ * Removes this meeting's Mercury ONLINE/OFFLINE event listeners registered
5252
+ * by setMercuryListener(). Must be called before Locus /leave to avoid
5253
+ * unnecessary syncs/metrics triggered by events received while leaving
5254
+ * (per Locus team recommendation).
5255
+ *
5256
+ * Mercury is a process-wide singleton shared with other plugins, so we
5257
+ * pass the bound handler refs to .off() to avoid clearing every listener
5258
+ * for ONLINE/OFFLINE on the shared emitter.
5259
+ *
5260
+ * Idempotent: subsequent calls are no-ops because the handler refs are
5261
+ * cleared after detaching.
5262
+ * @private
5263
+ * @returns {void}
5264
+ */
5265
+ private stopListeningForMercuryEvents() {
5266
+ if (this.mercuryOnlineHandler) {
5267
+ // @ts-ignore
5268
+ this.webex.internal.mercury.off(ONLINE, this.mercuryOnlineHandler);
5269
+ this.mercuryOnlineHandler = undefined;
5270
+ }
5271
+ if (this.mercuryOfflineHandler) {
5272
+ // @ts-ignore
5273
+ this.webex.internal.mercury.off(OFFLINE, this.mercuryOfflineHandler);
5274
+ this.mercuryOfflineHandler = undefined;
5275
+ }
5153
5276
  }
5154
5277
 
5155
5278
  /**
@@ -5460,7 +5583,7 @@ export default class Meeting extends StatelessWebexPlugin {
5460
5583
  } = {}
5461
5584
  ) {
5462
5585
  const {mediaOptions, joinOptions = {}} = options;
5463
- const {isRetry, prevJoinResponse} = this.joinWithMediaRetryInfo;
5586
+ const {retryCount, prevJoinResponse, prevError} = this.joinWithMediaRetryInfo;
5464
5587
 
5465
5588
  if (!mediaOptions?.allowMediaInLobby) {
5466
5589
  return Promise.reject(
@@ -5486,12 +5609,17 @@ export default class Meeting extends StatelessWebexPlugin {
5486
5609
  );
5487
5610
  }
5488
5611
 
5612
+ const shouldJoin =
5613
+ !joinResponse || // first try, when the join response is empty
5614
+ (prevError && prevError instanceof UserNotJoinedError) || // last try failed with UserNotJoinedError
5615
+ MeetingUtil.isUserInLeftState(this.locusInfo); // locus dropped the connection before we can re-try addMedia
5616
+
5489
5617
  try {
5490
5618
  let turnServerInfo;
5491
5619
  let turnDiscoverySkippedReason;
5492
5620
  let forceTurnDiscovery = false;
5493
5621
 
5494
- if (!joinResponse) {
5622
+ if (shouldJoin) {
5495
5623
  // This is the 1st attempt or a retry after join request failed -> we need to do a join with TURN discovery
5496
5624
 
5497
5625
  const turnDiscoveryRequest = await this.roap.generateTurnDiscoveryRequestMessage(
@@ -5532,14 +5660,17 @@ export default class Meeting extends StatelessWebexPlugin {
5532
5660
 
5533
5661
  const mediaResponse = await this.addMediaInternal(
5534
5662
  () => {
5535
- return this.joinWithMediaRetryInfo.isRetry ? 'JOIN_MEETING_FINAL' : 'JOIN_MEETING_RETRY';
5663
+ // callback is not called when UserNotJoinedError is thrown
5664
+ return this.joinWithMediaRetryInfo.retryCount >= 1
5665
+ ? 'JOIN_MEETING_FINAL'
5666
+ : 'JOIN_MEETING_RETRY';
5536
5667
  },
5537
5668
  turnServerInfo,
5538
5669
  forceTurnDiscovery,
5539
5670
  mediaOptions
5540
5671
  );
5541
5672
 
5542
- this.joinWithMediaRetryInfo = {isRetry: false, prevJoinResponse: undefined};
5673
+ this.joinWithMediaRetryInfo = {retryCount: 0, prevJoinResponse: undefined};
5543
5674
 
5544
5675
  return {
5545
5676
  join: joinResponse,
@@ -5553,8 +5684,10 @@ export default class Meeting extends StatelessWebexPlugin {
5553
5684
 
5554
5685
  this.roap.abortTurnDiscovery();
5555
5686
 
5556
- // if this was the first attempt, let's do a retry
5557
- let shouldRetry = !isRetry;
5687
+ // let's do a retry
5688
+ let shouldRetry =
5689
+ retryCount < 1 ||
5690
+ (error instanceof UserNotJoinedError && retryCount < JOIN_WITH_MEDIA_RETRY_MAX_COUNT);
5558
5691
 
5559
5692
  if (
5560
5693
  CallDiagnosticUtils.isSdpOfferCreationError(error) ||
@@ -5579,8 +5712,8 @@ export default class Meeting extends StatelessWebexPlugin {
5579
5712
  });
5580
5713
  }
5581
5714
 
5582
- // we only want to call leave if join was successful and this was a retry or we won't be doing any more retries
5583
- if (joined && (isRetry || !shouldRetry)) {
5715
+ // we only want to call leave if join was successful, and we won't be doing any more retries
5716
+ if (joined && !shouldRetry) {
5584
5717
  try {
5585
5718
  await this.leave({resourceId: joinOptions?.resourceId, reason: 'joinWithMedia failure'});
5586
5719
  } catch (e) {
@@ -5597,7 +5730,7 @@ export default class Meeting extends StatelessWebexPlugin {
5597
5730
  reason: error.message,
5598
5731
  stack: error.stack,
5599
5732
  leaveErrorReason: leaveError?.message,
5600
- isRetry,
5733
+ isRetry: retryCount > 0,
5601
5734
  },
5602
5735
  {
5603
5736
  type: error.name,
@@ -5606,15 +5739,26 @@ export default class Meeting extends StatelessWebexPlugin {
5606
5739
 
5607
5740
  if (shouldRetry) {
5608
5741
  LoggerProxy.logger.warn('Meeting:index#joinWithMedia --> retrying call to joinWithMedia');
5609
- this.joinWithMediaRetryInfo.isRetry = true;
5742
+ this.joinWithMediaRetryInfo.retryCount = retryCount + 1;
5610
5743
  this.joinWithMediaRetryInfo.prevJoinResponse = joinResponse;
5744
+ this.joinWithMediaRetryInfo.prevError = error;
5745
+ if (!this.joinWithMediaRetryInfo.firstError) {
5746
+ this.joinWithMediaRetryInfo.firstError = error;
5747
+ }
5611
5748
 
5612
5749
  return this.joinWithMedia(options);
5613
5750
  }
5614
5751
 
5615
- this.joinWithMediaRetryInfo = {isRetry: false, prevJoinResponse: undefined};
5752
+ const {firstError} = this.joinWithMediaRetryInfo;
5616
5753
 
5617
- throw error;
5754
+ this.joinWithMediaRetryInfo = {
5755
+ retryCount: 0,
5756
+ prevJoinResponse: undefined,
5757
+ firstError: undefined,
5758
+ prevError: undefined,
5759
+ };
5760
+
5761
+ throw firstError ?? error;
5618
5762
  }
5619
5763
  }
5620
5764
 
@@ -5865,6 +6009,31 @@ export default class Meeting extends StatelessWebexPlugin {
5865
6009
  }
5866
6010
  };
5867
6011
 
6012
+ /**
6013
+ * Verifies the relay event was delivered for the active LLM session binding.
6014
+ * @param {RelayEvent} event Event object coming from LLM Connection
6015
+ * @returns {boolean}
6016
+ */
6017
+ private isRelayEventRouteValid(event: RelayEvent): boolean {
6018
+ const route = event?.headers?.route;
6019
+
6020
+ if (!route) {
6021
+ return true;
6022
+ }
6023
+
6024
+ const {llm} = (this as any).webex.internal;
6025
+ const isPracticeSession = llm.isConnected(LLM_PRACTICE_SESSION);
6026
+ const expectedBinding = isPracticeSession
6027
+ ? llm.getBinding(LLM_PRACTICE_SESSION)
6028
+ : llm.getBinding();
6029
+
6030
+ if (!expectedBinding || route === expectedBinding) {
6031
+ return true;
6032
+ }
6033
+
6034
+ return false;
6035
+ }
6036
+
5868
6037
  /**
5869
6038
  * Callback called when a relay event is received from meeting LLM Connection
5870
6039
  * @param {RelayEvent} e Event object coming from LLM Connection
@@ -5872,6 +6041,9 @@ export default class Meeting extends StatelessWebexPlugin {
5872
6041
  * @returns {void}
5873
6042
  */
5874
6043
  private processRelayEvent = (e: RelayEvent): void => {
6044
+ if (!this.isRelayEventRouteValid(e)) {
6045
+ return;
6046
+ }
5875
6047
  switch (e.data.relayType) {
5876
6048
  case REACTION_RELAY_TYPES.REACTION:
5877
6049
  if (
@@ -6002,6 +6174,7 @@ export default class Meeting extends StatelessWebexPlugin {
6002
6174
  */
6003
6175
  private handleLLMOnline = (): void => {
6004
6176
  this.restoreLLMSubscriptionsIfNeeded();
6177
+ this.locusInfo.syncAllHashTreeDatasets({onlyLLM: true});
6005
6178
 
6006
6179
  Trigger.trigger(
6007
6180
  this,
@@ -6301,8 +6474,57 @@ export default class Meeting extends StatelessWebexPlugin {
6301
6474
  }
6302
6475
  }
6303
6476
 
6477
+ /**
6478
+ * Removes LLM event listeners and clears the health check timer.
6479
+ * Must be called before Locus /leave to avoid unnecessary syncs triggered
6480
+ * by events received while leaving (per Locus team recommendation).
6481
+ * Idempotent: safe to call multiple times; .off() is a no-op when no
6482
+ * matching listener is registered.
6483
+ * @private
6484
+ * @returns {void}
6485
+ */
6486
+ private stopListeningForLLMEvents() {
6487
+ // @ts-ignore - fix types
6488
+ this.webex.internal.llm.off('event:relay.event', this.processRelayEvent);
6489
+ // @ts-ignore - fix types
6490
+ this.webex.internal.llm.off(LOCUS_LLM_EVENT, this.processLocusLLMEvent);
6491
+ this.clearLLMHealthCheckTimer();
6492
+ }
6493
+
6494
+ /**
6495
+ * Stops listening on every event bus (LLM, Mercury, voicea/transcription,
6496
+ * annotation) that could otherwise deliver events to this meeting while
6497
+ * Locus is processing /leave or /end. Per the Locus team recommendation,
6498
+ * this must run before the Locus request is dispatched to avoid
6499
+ * unnecessary syncs triggered by in-flight events.
6500
+ *
6501
+ * Voicea (transcription) subscribes to llm 'event:relay.event' internally,
6502
+ * and the annotation plugin subscribes to both mercury and llm, so both
6503
+ * must be torn down alongside the direct LLM/Mercury listeners.
6504
+ *
6505
+ * Idempotent: safe to call multiple times; .off() is a no-op when no
6506
+ * matching listener is registered, and stopTranscription is guarded.
6507
+ * @private
6508
+ * @returns {void}
6509
+ */
6510
+ private stopListeningForMeetingEvents() {
6511
+ this.stopListeningForLLMEvents();
6512
+ this.stopListeningForMercuryEvents();
6513
+ if (this.transcription) {
6514
+ this.stopTranscription();
6515
+ this.transcription = undefined;
6516
+ }
6517
+ this.annotation.deregisterEvents();
6518
+ }
6519
+
6304
6520
  /**
6305
6521
  * Disconnects and cleans up the default LLM session listeners/timers.
6522
+ *
6523
+ * Ownership-aware: only calls `disconnectLLM` when this meeting is the
6524
+ * current owner of the default LLM session (or when no owner is recorded).
6525
+ * Event listeners belonging to this meeting instance are always detached
6526
+ * so they do not receive another meeting's relay events.
6527
+ *
6306
6528
  * @param {Object} options
6307
6529
  * @param {boolean} [options.removeOnlineListener=true] removes the one-time online listener
6308
6530
  * @param {boolean} [options.throwOnError=true] rethrows disconnect errors when true
@@ -6315,12 +6537,29 @@ export default class Meeting extends StatelessWebexPlugin {
6315
6537
  removeOnlineListener?: boolean;
6316
6538
  throwOnError?: boolean;
6317
6539
  } = {}): Promise<void> => {
6540
+ // @ts-ignore - Fix type
6541
+ // @ts-ignore - Fix type
6542
+ const {currentOwner, isOwner} = this.webex.internal.llm.resolveSessionOwnership(
6543
+ this.id,
6544
+ LLM_DEFAULT_SESSION
6545
+ );
6546
+
6318
6547
  try {
6319
- // @ts-ignore - Fix type
6320
- await this.webex.internal.llm.disconnectLLM({
6321
- code: 3050,
6322
- reason: 'done (permanent)',
6323
- });
6548
+ if (isOwner) {
6549
+ // @ts-ignore - Fix type
6550
+ await this.webex.internal.llm.disconnectLLM(
6551
+ {
6552
+ code: 3050,
6553
+ reason: 'done (permanent)',
6554
+ },
6555
+ LLM_DEFAULT_SESSION,
6556
+ this.id
6557
+ );
6558
+ } else {
6559
+ LoggerProxy.logger.info(
6560
+ `Meeting:index#cleanupLLMConneciton --> skipping disconnect; LLM owned by meeting ${currentOwner}, not ${this.id}`
6561
+ );
6562
+ }
6324
6563
  } catch (error) {
6325
6564
  LoggerProxy.logger.error(
6326
6565
  'Meeting:index#cleanupLLMConneciton --> Failed to disconnect default LLM session',
@@ -6335,23 +6574,33 @@ export default class Meeting extends StatelessWebexPlugin {
6335
6574
  // @ts-ignore - Fix type
6336
6575
  this.webex.internal.llm.off('online', this.handleLLMOnline);
6337
6576
  }
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);
6577
+ this.stopListeningForLLMEvents();
6578
+
6579
+ // Re-check ownership after awaiting disconnectLLM. If ownership changed
6580
+ // while cleanup was in flight, do not clear another meeting's owner tag.
6581
+ if (isOwner) {
6582
+ const {currentOwner: currentOwnerAfterCleanup} =
6583
+ // @ts-ignore - Fix type
6584
+ this.webex.internal.llm.resolveSessionOwnership(this.id, LLM_DEFAULT_SESSION);
6342
6585
 
6343
- this.clearLLMHealthCheckTimer();
6586
+ if (currentOwnerAfterCleanup === this.id) {
6587
+ // @ts-ignore - Fix type
6588
+ this.webex.internal.llm.setOwnerMeetingId?.(undefined);
6589
+ }
6590
+ }
6344
6591
  }
6345
6592
  };
6346
6593
 
6347
6594
  /**
6348
- * Clears all data channel tokens stored in LLM.
6349
- * Called during meeting cleanup to ensure stale tokens are not reused.
6595
+ * Clears data channel tokens associated with this meeting ownership.
6596
+ * Ownership checks are enforced in internal-plugin-llm.
6350
6597
  * @returns {void}
6351
6598
  */
6352
6599
  clearDataChannelToken(): void {
6353
6600
  // @ts-ignore
6354
- this.webex.internal.llm.resetDatachannelTokens();
6601
+ this.webex.internal.llm.clearDatachannelToken(LLM_DEFAULT_SESSION, this.id);
6602
+ // @ts-ignore
6603
+ this.webex.internal.llm.clearDatachannelToken(LLM_PRACTICE_SESSION, this.id);
6355
6604
  }
6356
6605
 
6357
6606
  /**
@@ -6366,14 +6615,15 @@ export default class Meeting extends StatelessWebexPlugin {
6366
6615
 
6367
6616
  if (datachannelToken) {
6368
6617
  // @ts-ignore
6369
- this.webex.internal.llm.setDatachannelToken(datachannelToken, DataChannelTokenType.Default);
6618
+ this.webex.internal.llm.setDatachannelToken(datachannelToken, LLM_DEFAULT_SESSION, this.id);
6370
6619
  }
6371
6620
 
6372
6621
  if (practiceSessionDatachannelToken) {
6373
6622
  // @ts-ignore
6374
6623
  this.webex.internal.llm.setDatachannelToken(
6375
6624
  practiceSessionDatachannelToken,
6376
- DataChannelTokenType.PracticeSession
6625
+ LLM_PRACTICE_SESSION,
6626
+ this.id
6377
6627
  );
6378
6628
  }
6379
6629
  }
@@ -6386,7 +6636,10 @@ export default class Meeting extends StatelessWebexPlugin {
6386
6636
  private async ensureDefaultDatachannelTokenAfterAdmit(): Promise<boolean> {
6387
6637
  try {
6388
6638
  // @ts-ignore
6389
- const datachannelToken = this.webex.internal.llm.getDatachannelToken();
6639
+ const datachannelToken = this.webex.internal.llm.getDatachannelToken(
6640
+ LLM_DEFAULT_SESSION,
6641
+ this.id
6642
+ );
6390
6643
  // @ts-ignore
6391
6644
  const isDataChannelTokenEnabled = await this.webex.internal.llm.isDataChannelTokenEnabled();
6392
6645
 
@@ -6408,7 +6661,8 @@ export default class Meeting extends StatelessWebexPlugin {
6408
6661
  // @ts-ignore
6409
6662
  this.webex.internal.llm.setDatachannelToken(
6410
6663
  fetchedDatachannelToken,
6411
- DataChannelTokenType.Default
6664
+ LLM_DEFAULT_SESSION,
6665
+ this.id
6412
6666
  );
6413
6667
 
6414
6668
  return true;
@@ -6435,15 +6689,58 @@ export default class Meeting extends StatelessWebexPlugin {
6435
6689
 
6436
6690
  const isJoined = this.isJoined();
6437
6691
 
6438
- // @ts-ignore
6439
- const datachannelToken = this.webex.internal.llm.getDatachannelToken(
6440
- DataChannelTokenType.Default
6692
+ const dataChannelUrl = datachannelUrl;
6693
+
6694
+ // Ownership guard: when the default LLM session is already connected and
6695
+ // owned by a *different* Meeting instance, do not disconnect or reconfigure
6696
+ // it. Another meeting's `updateLLMConnection` must be ignored here to
6697
+ // avoid killing the socket it relies on. We only proceed to manage the
6698
+ // connection when this meeting is the current owner, or when no owner is
6699
+ // set yet (first claim).
6700
+ // @ts-ignore - Fix type
6701
+ const {currentOwner} = this.webex.internal.llm.resolveSessionOwnership(
6702
+ this.id,
6703
+ LLM_DEFAULT_SESSION
6441
6704
  );
6442
6705
 
6443
- const dataChannelUrl = datachannelUrl;
6706
+ // Capture connectivity before any reconnect attempt. If LLM was already
6707
+ // connected, we must respect current ownership. If it was disconnected,
6708
+ // this flow may reclaim stale owner tags after a fresh connect.
6709
+ // @ts-ignore - Fix type
6710
+ const wasConnected = this.webex.internal.llm.isConnected();
6711
+
6712
+ // Prefer ownership-scoped token read. For disconnected stale-owner reclaim
6713
+ // flows, fallback to ownerless read so initial register can still carry a
6714
+ // token and recover from stale ownership without 401/403 dead-end.
6715
+ // @ts-ignore - Fix type
6716
+ let datachannelToken = this.webex.internal.llm.getDatachannelToken(
6717
+ LLM_DEFAULT_SESSION,
6718
+ this.id
6719
+ );
6720
+
6721
+ if (!datachannelToken && !wasConnected && currentOwner && currentOwner !== this.id) {
6722
+ // @ts-ignore - Fix type
6723
+ datachannelToken = this.webex.internal.llm.getDatachannelToken(LLM_DEFAULT_SESSION);
6724
+ }
6444
6725
 
6445
6726
  // @ts-ignore - Fix type
6446
- if (this.webex.internal.llm.isConnected()) {
6727
+ if (wasConnected) {
6728
+ if (currentOwner && currentOwner !== this.id) {
6729
+ // Another meeting owns the live LLM socket. We must not disconnect
6730
+ // or reconfigure it -- doing so would tear down a session the
6731
+ // owning meeting still relies on. Locus/datachannel URL mismatch is
6732
+ // expected here (each meeting has its own locus URL) and is NOT a
6733
+ // valid signal of staleness, so we never reclaim from this path.
6734
+ // The only safe reclaim mechanism is the `finally`-block owner-tag
6735
+ // release in `cleanupLLMConneciton`, which fires when this meeting
6736
+ // itself is being torn down.
6737
+ LoggerProxy.logger.info(
6738
+ `Meeting:index#updateLLMConnection --> skipping; LLM owned by meeting ${currentOwner}, not ${this.id}`
6739
+ );
6740
+
6741
+ return undefined;
6742
+ }
6743
+
6447
6744
  if (
6448
6745
  // @ts-ignore - Fix type
6449
6746
  url === this.webex.internal.llm.getLocusUrl() &&
@@ -6460,10 +6757,56 @@ export default class Meeting extends StatelessWebexPlugin {
6460
6757
  return undefined;
6461
6758
  }
6462
6759
 
6760
+ // Bind refresh handler before registration so interceptor-triggered token
6761
+ // refresh during register POST can resolve a valid handler.
6762
+ // Prefer this meeting as owner, but allow owner-less fallback when a stale
6763
+ // foreign owner tag is present on a disconnected session.
6764
+ const refreshHandlerOwnerMeetingId =
6765
+ currentOwner && currentOwner !== this.id ? undefined : this.id;
6766
+ const shouldAlignRefreshHandlerAfterOwnershipClaim = refreshHandlerOwnerMeetingId !== this.id;
6767
+ // @ts-ignore - Fix type
6768
+ this.webex.internal.llm.setRefreshHandler(
6769
+ () => this.refreshDataChannelToken(),
6770
+ LLM_DEFAULT_SESSION,
6771
+ refreshHandlerOwnerMeetingId
6772
+ );
6773
+
6463
6774
  // @ts-ignore - Fix type
6464
6775
  return this.webex.internal.llm
6465
6776
  .registerAndConnect(url, dataChannelUrl, datachannelToken)
6466
6777
  .then((registerAndConnectResult) => {
6778
+ this.locusInfo.syncAllHashTreeDatasets({onlyLLM: true});
6779
+ // Record ownership of the default LLM session for this meeting so
6780
+ // subsequent cross-meeting `updateLLMConnection` / `cleanupLLMConneciton`
6781
+ // calls can detect and skip work that doesn't belong to them.
6782
+ // @ts-ignore - Fix type
6783
+ const {isOwner} = this.webex.internal.llm.resolveSessionOwnership(
6784
+ this.id,
6785
+ LLM_DEFAULT_SESSION
6786
+ );
6787
+ const canReclaimAfterDisconnectedStart = !wasConnected;
6788
+
6789
+ // Refresh handler is pre-bound before registerAndConnect so token
6790
+ // refresh can work even during the registration request itself.
6791
+ if (isOwner || canReclaimAfterDisconnectedStart) {
6792
+ // Record ownership of the default LLM session for this meeting so
6793
+ // subsequent cross-meeting `updateLLMConnection` / `cleanupLLMConneciton`
6794
+ // calls can detect and skip work that doesn't belong to them.
6795
+ // @ts-ignore - Fix type
6796
+ this.webex.internal.llm.setOwnerMeetingId?.(this.id);
6797
+
6798
+ // If we pre-bound refresh ownerlessly (stale-owner reclaim path),
6799
+ // align the handler with the newly claimed owner immediately after
6800
+ // ownership is updated.
6801
+ if (shouldAlignRefreshHandlerAfterOwnershipClaim) {
6802
+ // @ts-ignore - Fix type
6803
+ this.webex.internal.llm.setRefreshHandler(
6804
+ () => this.refreshDataChannelToken(),
6805
+ LLM_DEFAULT_SESSION,
6806
+ this.id
6807
+ );
6808
+ }
6809
+ }
6467
6810
  // @ts-ignore - Fix type
6468
6811
  this.webex.internal.llm.off('event:relay.event', this.processRelayEvent);
6469
6812
  // @ts-ignore - Fix type
@@ -7504,6 +7847,33 @@ export default class Meeting extends StatelessWebexPlugin {
7504
7847
  }
7505
7848
  }
7506
7849
  });
7850
+ this.statsAnalyzer.on(StatsAnalyzerEventNames.STATS_UPDATE, (data) => {
7851
+ // Extract srtpCipher from transport stats
7852
+ let srtpCipher: string | undefined;
7853
+ for (const stats of data.stats.values()) {
7854
+ if (stats.type === 'transport' && stats.srtpCipher) {
7855
+ srtpCipher = stats.srtpCipher as string;
7856
+ break;
7857
+ }
7858
+ }
7859
+
7860
+ // Only emit event if srtpCipher has changed
7861
+ if (srtpCipher && srtpCipher !== this.mediaProperties.srtpCipher) {
7862
+ LoggerProxy.logger.info(
7863
+ `Meeting:index#setupStatsAnalyzerEventHandlers --> SRTP cipher changed from ${this.mediaProperties.srtpCipher} to ${srtpCipher}`
7864
+ );
7865
+ this.mediaProperties.srtpCipher = srtpCipher;
7866
+ Trigger.trigger(
7867
+ this,
7868
+ {
7869
+ file: 'meeting/index',
7870
+ function: 'setupStatsAnalyzerEventHandlers',
7871
+ },
7872
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
7873
+ {srtpCipher}
7874
+ );
7875
+ }
7876
+ });
7507
7877
  };
7508
7878
 
7509
7879
  getMediaConnectionDebugId() {
@@ -7550,6 +7920,8 @@ export default class Meeting extends StatelessWebexPlugin {
7550
7920
  disableAudioMainDtx: this.config.experimental.disableAudioMainDtx,
7551
7921
  // @ts-ignore - config coming from registerPlugin
7552
7922
  enableAudioTwcc: this.config.enableAudioTwccForMultistream,
7923
+ // @ts-ignore - config coming from registerPlugin
7924
+ enableAv1SlidesSupport: this.config.enableAv1SlidesSupport,
7553
7925
  stopIceGatheringAfterFirstRelayCandidate:
7554
7926
  // @ts-ignore - config coming from registerPlugin
7555
7927
  this.config.stopIceGatheringAfterFirstRelayCandidate,
@@ -8387,7 +8759,7 @@ export default class Meeting extends StatelessWebexPlugin {
8387
8759
  numTransports,
8388
8760
  isMultistream: this.isMultistream,
8389
8761
  retriedWithTurnServer: this.addMediaData.retriedWithTurnServer,
8390
- isJoinWithMediaRetry: this.joinWithMediaRetryInfo.isRetry,
8762
+ isJoinWithMediaRetry: this.joinWithMediaRetryInfo.retryCount > 0,
8391
8763
  ...reachabilityMetrics,
8392
8764
  ...iceCandidateErrors,
8393
8765
  iceCandidatesCount: this.iceCandidatesCount,
@@ -8432,7 +8804,7 @@ export default class Meeting extends StatelessWebexPlugin {
8432
8804
  turnServerUsed: this.turnServerUsed,
8433
8805
  retriedWithTurnServer: this.addMediaData.retriedWithTurnServer,
8434
8806
  isMultistream: this.isMultistream,
8435
- isJoinWithMediaRetry: this.joinWithMediaRetryInfo.isRetry,
8807
+ isJoinWithMediaRetry: this.joinWithMediaRetryInfo.retryCount > 0,
8436
8808
  signalingState:
8437
8809
  this.mediaProperties.webrtcMediaConnection?.multistreamConnection?.pc?.pc
8438
8810
  ?.signalingState ||
@@ -8797,6 +9169,8 @@ export default class Meeting extends StatelessWebexPlugin {
8797
9169
  });
8798
9170
  LoggerProxy.logger.log('Meeting:index#leave --> Leaving a meeting');
8799
9171
 
9172
+ this.stopListeningForMeetingEvents();
9173
+
8800
9174
  return MeetingUtil.leaveMeeting(this, options)
8801
9175
  .then(async (leave) => {
8802
9176
  // CA team recommends submitting this *after* locus /leave
@@ -9661,6 +10035,8 @@ export default class Meeting extends StatelessWebexPlugin {
9661
10035
  locus_id: this.locusId,
9662
10036
  });
9663
10037
 
10038
+ this.stopListeningForMeetingEvents();
10039
+
9664
10040
  return MeetingUtil.endMeetingForAll(this)
9665
10041
  .then(async (end) => {
9666
10042
  this.meetingFiniteStateMachine.end();
@@ -9722,12 +10098,15 @@ export default class Meeting extends StatelessWebexPlugin {
9722
10098
  }
9723
10099
  this.queuedMediaUpdates = [];
9724
10100
 
9725
- this.stopTranscription();
9726
- this.transcription = undefined;
9727
-
9728
- this.annotation.deregisterEvents();
9729
-
10101
+ // Listener teardown (transcription, annotation, llm/mercury) runs in
10102
+ // stopListeningForMeetingEvents() before /leave and /end so events
10103
+ // received mid-teardown do not trigger Locus syncs. Calling it here
10104
+ // again would double-emit MEETING_STOPPED_RECEIVING_TRANSCRIPTION
10105
+ // because stopTranscription() always fires its trigger.
10106
+ //
10107
+ // Ownership-aware token clear is encapsulated inside clearDataChannelToken().
9730
10108
  this.clearDataChannelToken();
10109
+
9731
10110
  await this.cleanupLLMConneciton({throwOnError: false});
9732
10111
  };
9733
10112
 
@@ -9804,15 +10183,12 @@ export default class Meeting extends StatelessWebexPlugin {
9804
10183
  * @public
9805
10184
  * @memberof Meeting
9806
10185
  */
9807
- public sendReaction(reactionType: ReactionServerType, skinToneType?: SkinToneType) {
10186
+ public sendReaction(reactionType: string, skinToneType?: SkinToneType) {
9808
10187
  const reactionChannelUrl = this.locusInfo?.controls?.reactions?.reactionChannelUrl as string;
9809
10188
  const participantId = this.members.selfId;
9810
10189
 
9811
- const reactionData = Reactions[reactionType];
10190
+ const reactionData = Reactions[reactionType] || {type: reactionType};
9812
10191
 
9813
- if (!reactionData) {
9814
- return Promise.reject(new Error(`${reactionType} is not a valid reaction.`));
9815
- }
9816
10192
  const skinToneData = SkinTones[skinToneType] || SkinTones.normal;
9817
10193
  const reaction: Reaction = {
9818
10194
  ...reactionData,