@webex/plugin-meetings 3.12.0-next.9 → 3.12.0-next.90

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 (213) 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 -2
  9. package/dist/config.js.map +1 -1
  10. package/dist/constants.js +35 -9
  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 +1233 -793
  50. package/dist/meeting/index.js.map +1 -1
  51. package/dist/meeting/locusMediaRequest.js +229 -82
  52. package/dist/meeting/locusMediaRequest.js.map +1 -1
  53. package/dist/meeting/muteState.js +10 -1
  54. package/dist/meeting/muteState.js.map +1 -1
  55. package/dist/meeting/request.js +5 -2
  56. package/dist/meeting/request.js.map +1 -1
  57. package/dist/meeting/util.js +35 -2
  58. package/dist/meeting/util.js.map +1 -1
  59. package/dist/meeting-info/meeting-info-v2.js +2 -2
  60. package/dist/meeting-info/meeting-info-v2.js.map +1 -1
  61. package/dist/meetings/index.js +231 -118
  62. package/dist/meetings/index.js.map +1 -1
  63. package/dist/meetings/meetings.types.js +6 -1
  64. package/dist/meetings/meetings.types.js.map +1 -1
  65. package/dist/meetings/request.js +39 -0
  66. package/dist/meetings/request.js.map +1 -1
  67. package/dist/meetings/util.js +79 -5
  68. package/dist/meetings/util.js.map +1 -1
  69. package/dist/member/index.js +10 -0
  70. package/dist/member/index.js.map +1 -1
  71. package/dist/member/types.js.map +1 -1
  72. package/dist/member/util.js +30 -0
  73. package/dist/member/util.js.map +1 -1
  74. package/dist/members/index.js +101 -43
  75. package/dist/members/index.js.map +1 -1
  76. package/dist/members/util.js +8 -1
  77. package/dist/members/util.js.map +1 -1
  78. package/dist/metrics/constants.js +5 -1
  79. package/dist/metrics/constants.js.map +1 -1
  80. package/dist/multistream/codec/constants.js +63 -0
  81. package/dist/multistream/codec/constants.js.map +1 -0
  82. package/dist/multistream/mediaRequestManager.js +62 -15
  83. package/dist/multistream/mediaRequestManager.js.map +1 -1
  84. package/dist/multistream/receiveSlot.js +9 -0
  85. package/dist/multistream/receiveSlot.js.map +1 -1
  86. package/dist/reachability/index.js +10 -13
  87. package/dist/reachability/index.js.map +1 -1
  88. package/dist/reachability/reachability.types.js +2 -0
  89. package/dist/reachability/reachability.types.js.map +1 -1
  90. package/dist/reachability/util.js +45 -0
  91. package/dist/reachability/util.js.map +1 -1
  92. package/dist/reactions/reactions.type.js.map +1 -1
  93. package/dist/recording-controller/index.js +1 -3
  94. package/dist/recording-controller/index.js.map +1 -1
  95. package/dist/types/config.d.ts +2 -2
  96. package/dist/types/constants.d.ts +12 -1
  97. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  98. package/dist/types/controls-options-manager/index.d.ts +16 -0
  99. package/dist/types/hashTree/constants.d.ts +2 -0
  100. package/dist/types/hashTree/hashTreeParser.d.ts +146 -17
  101. package/dist/types/hashTree/utils.d.ts +18 -0
  102. package/dist/types/index.d.ts +3 -0
  103. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  104. package/dist/types/interpretation/interpretation.types.d.ts +10 -0
  105. package/dist/types/locus-info/index.d.ts +50 -6
  106. package/dist/types/locus-info/infoUtils.d.ts +8 -0
  107. package/dist/types/locus-info/types.d.ts +21 -1
  108. package/dist/types/media/properties.d.ts +1 -0
  109. package/dist/types/meeting/in-meeting-actions.d.ts +4 -0
  110. package/dist/types/meeting/index.d.ts +94 -7
  111. package/dist/types/meeting/locusMediaRequest.d.ts +12 -0
  112. package/dist/types/meeting/request.d.ts +1 -0
  113. package/dist/types/meeting/util.d.ts +9 -0
  114. package/dist/types/meetings/index.d.ts +30 -18
  115. package/dist/types/meetings/meetings.types.d.ts +15 -0
  116. package/dist/types/meetings/request.d.ts +14 -0
  117. package/dist/types/member/index.d.ts +1 -0
  118. package/dist/types/member/types.d.ts +1 -0
  119. package/dist/types/member/util.d.ts +9 -0
  120. package/dist/types/members/index.d.ts +15 -2
  121. package/dist/types/members/util.d.ts +1 -5
  122. package/dist/types/metrics/constants.d.ts +4 -0
  123. package/dist/types/multistream/codec/constants.d.ts +7 -0
  124. package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
  125. package/dist/types/reachability/index.d.ts +1 -1
  126. package/dist/types/reachability/reachability.types.d.ts +7 -0
  127. package/dist/types/reachability/util.d.ts +17 -0
  128. package/dist/types/reactions/reactions.type.d.ts +3 -0
  129. package/dist/webinar/index.js +305 -159
  130. package/dist/webinar/index.js.map +1 -1
  131. package/package.json +22 -22
  132. package/src/aiEnableRequest/index.ts +16 -0
  133. package/src/breakouts/breakout.ts +3 -1
  134. package/src/breakouts/index.ts +31 -0
  135. package/src/config.ts +2 -2
  136. package/src/constants.ts +20 -3
  137. package/src/controls-options-manager/constants.ts +14 -1
  138. package/src/controls-options-manager/index.ts +87 -28
  139. package/src/controls-options-manager/util.ts +81 -1
  140. package/src/hashTree/constants.ts +16 -0
  141. package/src/hashTree/hashTreeParser.ts +580 -196
  142. package/src/hashTree/utils.ts +36 -0
  143. package/src/index.ts +6 -0
  144. package/src/interceptors/dataChannelAuthToken.ts +88 -12
  145. package/src/interceptors/locusRetry.ts +25 -4
  146. package/src/interpretation/index.ts +27 -9
  147. package/src/interpretation/interpretation.types.ts +11 -0
  148. package/src/locus-info/controlsUtils.ts +3 -1
  149. package/src/locus-info/index.ts +304 -100
  150. package/src/locus-info/infoUtils.ts +41 -6
  151. package/src/locus-info/types.ts +25 -1
  152. package/src/media/index.ts +3 -0
  153. package/src/media/properties.ts +1 -0
  154. package/src/meeting/in-meeting-actions.ts +8 -0
  155. package/src/meeting/index.ts +494 -67
  156. package/src/meeting/locusMediaRequest.ts +106 -6
  157. package/src/meeting/muteState.ts +10 -1
  158. package/src/meeting/request.ts +11 -0
  159. package/src/meeting/util.ts +47 -5
  160. package/src/meeting-info/meeting-info-v2.ts +4 -2
  161. package/src/meetings/index.ts +133 -78
  162. package/src/meetings/meetings.types.ts +19 -0
  163. package/src/meetings/request.ts +43 -0
  164. package/src/meetings/util.ts +97 -1
  165. package/src/member/index.ts +10 -0
  166. package/src/member/types.ts +1 -0
  167. package/src/member/util.ts +29 -0
  168. package/src/members/index.ts +70 -7
  169. package/src/members/util.ts +12 -4
  170. package/src/metrics/constants.ts +4 -0
  171. package/src/multistream/codec/constants.ts +58 -0
  172. package/src/multistream/mediaRequestManager.ts +119 -28
  173. package/src/multistream/receiveSlot.ts +18 -0
  174. package/src/reachability/index.ts +19 -15
  175. package/src/reachability/reachability.types.ts +11 -0
  176. package/src/reachability/util.ts +36 -0
  177. package/src/reactions/reactions.type.ts +3 -0
  178. package/src/recording-controller/index.ts +1 -2
  179. package/src/webinar/index.ts +214 -36
  180. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  181. package/test/unit/spec/breakouts/breakout.ts +9 -3
  182. package/test/unit/spec/breakouts/index.ts +49 -0
  183. package/test/unit/spec/controls-options-manager/index.js +244 -29
  184. package/test/unit/spec/controls-options-manager/util.js +165 -0
  185. package/test/unit/spec/hashTree/hashTreeParser.ts +1838 -180
  186. package/test/unit/spec/hashTree/utils.ts +125 -1
  187. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +196 -0
  188. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  189. package/test/unit/spec/interpretation/index.ts +26 -4
  190. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  191. package/test/unit/spec/locus-info/index.js +499 -81
  192. package/test/unit/spec/locus-info/infoUtils.js +184 -1
  193. package/test/unit/spec/media/index.ts +31 -0
  194. package/test/unit/spec/meeting/in-meeting-actions.ts +4 -0
  195. package/test/unit/spec/meeting/index.js +1615 -42
  196. package/test/unit/spec/meeting/locusMediaRequest.ts +302 -4
  197. package/test/unit/spec/meeting/muteState.js +81 -0
  198. package/test/unit/spec/meeting/request.js +12 -0
  199. package/test/unit/spec/meeting/utils.js +157 -1
  200. package/test/unit/spec/meeting-info/meetinginfov2.js +19 -10
  201. package/test/unit/spec/meetings/index.js +363 -36
  202. package/test/unit/spec/meetings/request.js +141 -0
  203. package/test/unit/spec/meetings/utils.js +189 -0
  204. package/test/unit/spec/member/index.js +7 -0
  205. package/test/unit/spec/member/util.js +89 -0
  206. package/test/unit/spec/members/index.js +216 -0
  207. package/test/unit/spec/members/request.js +37 -1
  208. package/test/unit/spec/members/utils.js +63 -0
  209. package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
  210. package/test/unit/spec/reachability/index.ts +59 -46
  211. package/test/unit/spec/reachability/util.ts +31 -0
  212. package/test/unit/spec/recording-controller/index.js +9 -8
  213. 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;
@@ -735,6 +740,7 @@ export default class Meeting extends StatelessWebexPlugin {
735
740
  isMoveToInProgress = false;
736
741
  registrationIdStatus: string;
737
742
  brbState: BrbState;
743
+ private promisesWaitingForPropUpdate: Record<string, Defer> = {};
738
744
 
739
745
  voiceaListenerCallbacks: object = {
740
746
  [VOICEAEVENTS.VOICEA_ANNOUNCEMENT]: (payload: Transcription['languageOptions']) => {
@@ -790,7 +796,13 @@ export default class Meeting extends StatelessWebexPlugin {
790
796
  private deferSDPAnswer?: Defer; // used for waiting for a response
791
797
  private sdpResponseTimer?: ReturnType<typeof setTimeout>;
792
798
  private hasMediaConnectionConnectedAtLeastOnce: boolean;
793
- private joinWithMediaRetryInfo?: {isRetry: boolean; prevJoinResponse?: any};
799
+ private joinWithMediaRetryInfo: {
800
+ retryCount: number;
801
+ prevJoinResponse?: any;
802
+ firstError?: Error;
803
+ prevError?: Error;
804
+ };
805
+
794
806
  private connectionStateHandler?: ConnectionStateHandler;
795
807
  private iceCandidateErrors: Map<string, number>;
796
808
  private iceCandidatesCount: number;
@@ -935,7 +947,7 @@ export default class Meeting extends StatelessWebexPlugin {
935
947
  this.simultaneousInterpretation = new SimultaneousInterpretation({}, {parent: this.webex});
936
948
 
937
949
  // @ts-ignore
938
- this.aiEnableRequest = new AIEnableRequest({}, {parent: this.webex});
950
+ this.aiEnableRequest = new AIEnableRequest({locusUrl: this.locusUrl}, {parent: this.webex});
939
951
 
940
952
  /**
941
953
  * @instance
@@ -966,6 +978,7 @@ export default class Meeting extends StatelessWebexPlugin {
966
978
  },
967
979
  (csi: CSI) => (this.members.findMemberByCsi(csi) as any)?.id
968
980
  );
981
+
969
982
  /**
970
983
  * Object containing helper classes for managing media requests for audio/video/screenshare (for multistream media connections)
971
984
  * All multistream media requests sent out for this meeting have to go through them.
@@ -985,6 +998,7 @@ export default class Meeting extends StatelessWebexPlugin {
985
998
  mediaRequests
986
999
  );
987
1000
  },
1001
+ this.getIngressPayloadTypeCallback.bind(this),
988
1002
  {
989
1003
  // @ts-ignore - config coming from registerPlugin
990
1004
  degradationPreferences: this.config.degradationPreferences,
@@ -1006,6 +1020,7 @@ export default class Meeting extends StatelessWebexPlugin {
1006
1020
  mediaRequests
1007
1021
  );
1008
1022
  },
1023
+ this.getIngressPayloadTypeCallback.bind(this),
1009
1024
  {
1010
1025
  // @ts-ignore - config coming from registerPlugin
1011
1026
  degradationPreferences: this.config.degradationPreferences,
@@ -1027,6 +1042,7 @@ export default class Meeting extends StatelessWebexPlugin {
1027
1042
  mediaRequests
1028
1043
  );
1029
1044
  },
1045
+ this.getIngressPayloadTypeCallback.bind(this),
1030
1046
  {
1031
1047
  // @ts-ignore - config coming from registerPlugin
1032
1048
  degradationPreferences: this.config.degradationPreferences,
@@ -1048,11 +1064,14 @@ export default class Meeting extends StatelessWebexPlugin {
1048
1064
  mediaRequests
1049
1065
  );
1050
1066
  },
1067
+ this.getIngressPayloadTypeCallback.bind(this),
1051
1068
  {
1052
1069
  // @ts-ignore - config coming from registerPlugin
1053
1070
  degradationPreferences: this.config.degradationPreferences,
1054
1071
  kind: 'video',
1055
1072
  trimRequestsToNumOfSources: false,
1073
+ // @ts-ignore - config coming from registerPlugin
1074
+ enableAv1: this.config.enableAv1SlidesSupport,
1056
1075
  }
1057
1076
  ),
1058
1077
  };
@@ -1557,6 +1576,8 @@ export default class Meeting extends StatelessWebexPlugin {
1557
1576
  this.controlsOptionsManager = new ControlsOptionsManager(this.meetingRequest, {
1558
1577
  locusUrl: this.locusInfo?.url,
1559
1578
  displayHints: [],
1579
+ getControls: () => this.locusInfo?.controls,
1580
+ isWebinar: () => this.locusInfo?.info?.isWebinar,
1560
1581
  });
1561
1582
 
1562
1583
  this.setUpLocusInfoListeners();
@@ -1655,11 +1676,11 @@ export default class Meeting extends StatelessWebexPlugin {
1655
1676
  /**
1656
1677
  * Information needed for a retry of a call to joinWithMedia
1657
1678
  * @instance
1658
- * @type {{isRetry: boolean; prevJoinResponse?: any}}
1679
+ * @type {{retryCount: number; prevJoinResponse?: any}}
1659
1680
  * @private
1660
1681
  * @memberof Meeting
1661
1682
  */
1662
- this.joinWithMediaRetryInfo = {isRetry: false, prevJoinResponse: undefined};
1683
+ this.joinWithMediaRetryInfo = {retryCount: 0, prevJoinResponse: undefined};
1663
1684
 
1664
1685
  /**
1665
1686
  * Connection state handler
@@ -1713,6 +1734,37 @@ export default class Meeting extends StatelessWebexPlugin {
1713
1734
  this.mediaServerIp = undefined;
1714
1735
  }
1715
1736
 
1737
+ /**
1738
+ * Get the ingress payload type for a given media type and codec mime type
1739
+ * @param {MediaType} mediaType - The media type
1740
+ * @param {MediaCodecMimeType} codecMimeType - The codec mime type
1741
+ * @returns {number | undefined} - The ingress payload type
1742
+ * @private
1743
+ * @memberof Meeting
1744
+ */
1745
+ private getIngressPayloadTypeCallback(
1746
+ mediaType: MediaType,
1747
+ codecMimeType: MediaCodecMimeType
1748
+ ): number | undefined {
1749
+ if (this.isMultistream) {
1750
+ try {
1751
+ return this.mediaProperties.webrtcMediaConnection.getIngressPayloadType(
1752
+ mediaType,
1753
+ codecMimeType
1754
+ );
1755
+ } catch (error) {
1756
+ LoggerProxy.logger.info(
1757
+ `Meeting:index#mediaRequestManager --> failed to get ingress payload type for mediaType=${mediaType}, codecMimeType=${codecMimeType}`,
1758
+ error
1759
+ );
1760
+
1761
+ return undefined;
1762
+ }
1763
+ }
1764
+
1765
+ return undefined;
1766
+ }
1767
+
1716
1768
  /**
1717
1769
  * Temporary func to return webex object,
1718
1770
  * in order to access internal plugin metrics
@@ -2789,7 +2841,7 @@ export default class Meeting extends StatelessWebexPlugin {
2789
2841
  private setupLocusControlsListener() {
2790
2842
  this.locusInfo.on(
2791
2843
  LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED,
2792
- ({state, modifiedBy, lastModified}) => {
2844
+ ({state, modifiedBy, lastModified, modifiedByServiceAppName, modifiedByServiceAppId}) => {
2793
2845
  let event;
2794
2846
 
2795
2847
  switch (state) {
@@ -2815,6 +2867,8 @@ export default class Meeting extends StatelessWebexPlugin {
2815
2867
  state: state === RECORDING_STATE.RESUMED ? RECORDING_STATE.RECORDING : state,
2816
2868
  modifiedBy,
2817
2869
  lastModified,
2870
+ modifiedByServiceAppName,
2871
+ modifiedByServiceAppId,
2818
2872
  };
2819
2873
  Trigger.trigger(
2820
2874
  this,
@@ -3459,13 +3513,12 @@ export default class Meeting extends StatelessWebexPlugin {
3459
3513
  this.breakouts.locusUrlUpdate(url);
3460
3514
  this.simultaneousInterpretation.locusUrlUpdate(url);
3461
3515
  this.annotation.locusUrlUpdate(url);
3516
+ this.aiEnableRequest.locusUrlUpdate(url);
3462
3517
  this.locusUrl = url;
3463
3518
  this.locusId = this.locusUrl?.split('/').pop();
3464
3519
  this.recordingController.setLocusUrl(this.locusUrl);
3465
3520
  this.controlsOptionsManager.setLocusUrl(this.locusUrl, !!isMainLocus);
3466
3521
  this.webinar.locusUrlUpdate(url);
3467
- // @ts-ignore
3468
- this.webex.internal.llm.setRefreshHandler(() => this.refreshDataChannelToken());
3469
3522
 
3470
3523
  Trigger.trigger(
3471
3524
  this,
@@ -3734,7 +3787,7 @@ export default class Meeting extends StatelessWebexPlugin {
3734
3787
  });
3735
3788
  this.updateLLMConnection();
3736
3789
  });
3737
- this.locusInfo.on(LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, (payload) => {
3790
+ this.locusInfo.on(LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, async (payload) => {
3738
3791
  this.stopKeepAlive();
3739
3792
 
3740
3793
  if (payload) {
@@ -3761,13 +3814,15 @@ export default class Meeting extends StatelessWebexPlugin {
3761
3814
  }
3762
3815
  this.rtcMetrics?.sendNextMetrics();
3763
3816
 
3764
- this.ensureDefaultDatachannelTokenAfterAdmit().catch((error) => {
3817
+ try {
3818
+ await this.ensureDefaultDatachannelTokenAfterAdmit();
3819
+ } catch (error) {
3765
3820
  LoggerProxy.logger.warn(
3766
3821
  `Meeting:index#setUpLocusInfoSelfListener --> failed post-admit token prefetch flow: ${
3767
3822
  error?.message || String(error)
3768
3823
  }`
3769
3824
  );
3770
- });
3825
+ }
3771
3826
 
3772
3827
  this.updateLLMConnection();
3773
3828
  });
@@ -3813,6 +3868,9 @@ export default class Meeting extends StatelessWebexPlugin {
3813
3868
  });
3814
3869
 
3815
3870
  this.locusInfo.on(LOCUSINFO.EVENTS.SELF_CANNOT_VIEW_PARTICIPANT_LIST_CHANGE, (payload) => {
3871
+ // canViewTheParticipantList meeting action depends on this value, so we need to update meeting actions
3872
+ this.updateMeetingActions();
3873
+
3816
3874
  Trigger.trigger(
3817
3875
  this,
3818
3876
  {
@@ -4007,7 +4065,14 @@ export default class Meeting extends StatelessWebexPlugin {
4007
4065
  // is not changed by any delta event
4008
4066
  if (object && Object.keys(object).length) {
4009
4067
  Object.keys(object).forEach((key) => {
4068
+ const previousValue = this[key];
4069
+
4010
4070
  this[key] = object[key];
4071
+
4072
+ if (this.promisesWaitingForPropUpdate[key] && previousValue !== object[key]) {
4073
+ this.promisesWaitingForPropUpdate[key].resolve();
4074
+ delete this.promisesWaitingForPropUpdate[key];
4075
+ }
4011
4076
  });
4012
4077
  }
4013
4078
  }
@@ -4145,7 +4210,12 @@ export default class Meeting extends StatelessWebexPlugin {
4145
4210
  }
4146
4211
 
4147
4212
  /**
4148
- * Transfer the moderator role to another eligible member
4213
+ * Transfer the moderator role to another eligible member.
4214
+ *
4215
+ * If the target member is currently joined in the current session, the request is
4216
+ * sent against the current locus. Otherwise, we search the breakout sessions and,
4217
+ * when the member is found in one, we send the request against that breakout
4218
+ * session's locus url instead.
4149
4219
  * @param {String} memberId
4150
4220
  * @param {Boolean} moderator
4151
4221
  * @returns {Promise} see #members.transferHostToMember
@@ -4153,7 +4223,21 @@ export default class Meeting extends StatelessWebexPlugin {
4153
4223
  * @memberof Meeting
4154
4224
  */
4155
4225
  public transfer(memberId: string, moderator = true) {
4156
- return this.members.transferHostToMember(memberId, moderator);
4226
+ const targetMember = this.members.membersCollection.get(memberId);
4227
+
4228
+ if (targetMember?.isInMeeting) {
4229
+ return this.members.transferHostToMember(memberId, moderator);
4230
+ }
4231
+
4232
+ const breakout = this.breakouts?.breakouts?.models?.find((bo: any) => {
4233
+ const breakoutMember = bo.members?.membersCollection?.get(memberId);
4234
+
4235
+ return breakoutMember?.isInMeeting;
4236
+ });
4237
+
4238
+ const breakoutLocusUrl = breakout?.breakoutRosterLocus?.url;
4239
+
4240
+ return this.members.transferHostToMember(memberId, moderator, breakoutLocusUrl);
4157
4241
  }
4158
4242
 
4159
4243
  /**
@@ -4611,6 +4695,13 @@ export default class Meeting extends StatelessWebexPlugin {
4611
4695
  ),
4612
4696
  isAttendeeRequestAiAssistantDeclinedAll:
4613
4697
  MeetingUtil.attendeeRequestAiAssistantDeclinedAll(this.userDisplayHints),
4698
+ isAnonymizeDisplayNamesEnabled: MeetingUtil.isAnonymizeDisplayNamesEnabled(
4699
+ this.userDisplayHints
4700
+ ),
4701
+ canViewTheParticipantList: MeetingUtil.canViewTheParticipantList(
4702
+ this.userDisplayHints,
4703
+ this.canNotViewTheParticipantList ?? false
4704
+ ),
4614
4705
  }) || changed;
4615
4706
  }
4616
4707
  if (changed) {
@@ -4659,6 +4750,34 @@ export default class Meeting extends StatelessWebexPlugin {
4659
4750
  this.sipUri = sipUri;
4660
4751
  }
4661
4752
 
4753
+ /**
4754
+ * After initial locus setup, refreshes destination with synced locus data and optionally
4755
+ * performs deferred meeting info fetch when initial locus was incomplete.
4756
+ * @param {LocusDTO} locus
4757
+ * @returns {void}
4758
+ */
4759
+ public async finalizeMeetingAfterInitialLocusSetup(locus: LocusDTO): Promise<void> {
4760
+ if (locus && this?.destinationType === DESTINATION_TYPE.LOCUS_ID) {
4761
+ // destination is initialized from the initial locus snapshot in constructor,
4762
+ // so refresh it after locus sync to avoid stale partial hash-tree data.
4763
+ this.destination = locus;
4764
+ }
4765
+ if (
4766
+ (!this.meetingInfo || isEmpty(this.meetingInfo)) &&
4767
+ (this.destination as LocusDTO)?.info &&
4768
+ !this.fetchMeetingInfoTimeoutId &&
4769
+ !MeetingsUtil.isOneOnOneCall(locus)
4770
+ ) {
4771
+ try {
4772
+ await this.fetchMeetingInfo({});
4773
+ } catch (error: any) {
4774
+ LoggerProxy.logger.info(
4775
+ `Meeting:index#finalizeMeetingAfterInitialLocusSetup --> deferred fetchMeetingInfo failed: ${error.message}`
4776
+ );
4777
+ }
4778
+ }
4779
+ }
4780
+
4662
4781
  /**
4663
4782
  * Set the locus info the class instance. Should be called with the parsed locus
4664
4783
  * we got in the join response.
@@ -5130,8 +5249,7 @@ export default class Meeting extends StatelessWebexPlugin {
5130
5249
  public setMercuryListener() {
5131
5250
  // Client will have a socket manager and handle reconnecting to mercury, when we reconnect to mercury
5132
5251
  // if the meeting has active peer connections, it should try to reconnect.
5133
- // @ts-ignore
5134
- this.webex.internal.mercury.on(ONLINE, () => {
5252
+ this.mercuryOnlineHandler = () => {
5135
5253
  LoggerProxy.logger.info('Meeting:index#setMercuryListener --> Web socket online');
5136
5254
 
5137
5255
  // Only send restore event when it was disconnected before and for connected later
@@ -5141,15 +5259,47 @@ export default class Meeting extends StatelessWebexPlugin {
5141
5259
  });
5142
5260
  }
5143
5261
  this.hasWebsocketConnected = true;
5144
- });
5262
+ };
5145
5263
 
5146
- // @ts-ignore
5147
- this.webex.internal.mercury.on(OFFLINE, () => {
5264
+ this.mercuryOfflineHandler = () => {
5148
5265
  LoggerProxy.logger.error('Meeting:index#setMercuryListener --> Web socket offline');
5149
5266
  Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.MERCURY_CONNECTION_FAILURE, {
5150
5267
  correlation_id: this.correlationId,
5151
5268
  });
5152
- });
5269
+ };
5270
+
5271
+ // @ts-ignore
5272
+ this.webex.internal.mercury.on(ONLINE, this.mercuryOnlineHandler);
5273
+ // @ts-ignore
5274
+ this.webex.internal.mercury.on(OFFLINE, this.mercuryOfflineHandler);
5275
+ }
5276
+
5277
+ /**
5278
+ * Removes this meeting's Mercury ONLINE/OFFLINE event listeners registered
5279
+ * by setMercuryListener(). Must be called before Locus /leave to avoid
5280
+ * unnecessary syncs/metrics triggered by events received while leaving
5281
+ * (per Locus team recommendation).
5282
+ *
5283
+ * Mercury is a process-wide singleton shared with other plugins, so we
5284
+ * pass the bound handler refs to .off() to avoid clearing every listener
5285
+ * for ONLINE/OFFLINE on the shared emitter.
5286
+ *
5287
+ * Idempotent: subsequent calls are no-ops because the handler refs are
5288
+ * cleared after detaching.
5289
+ * @private
5290
+ * @returns {void}
5291
+ */
5292
+ private stopListeningForMercuryEvents() {
5293
+ if (this.mercuryOnlineHandler) {
5294
+ // @ts-ignore
5295
+ this.webex.internal.mercury.off(ONLINE, this.mercuryOnlineHandler);
5296
+ this.mercuryOnlineHandler = undefined;
5297
+ }
5298
+ if (this.mercuryOfflineHandler) {
5299
+ // @ts-ignore
5300
+ this.webex.internal.mercury.off(OFFLINE, this.mercuryOfflineHandler);
5301
+ this.mercuryOfflineHandler = undefined;
5302
+ }
5153
5303
  }
5154
5304
 
5155
5305
  /**
@@ -5460,7 +5610,7 @@ export default class Meeting extends StatelessWebexPlugin {
5460
5610
  } = {}
5461
5611
  ) {
5462
5612
  const {mediaOptions, joinOptions = {}} = options;
5463
- const {isRetry, prevJoinResponse} = this.joinWithMediaRetryInfo;
5613
+ const {retryCount, prevJoinResponse, prevError} = this.joinWithMediaRetryInfo;
5464
5614
 
5465
5615
  if (!mediaOptions?.allowMediaInLobby) {
5466
5616
  return Promise.reject(
@@ -5486,12 +5636,17 @@ export default class Meeting extends StatelessWebexPlugin {
5486
5636
  );
5487
5637
  }
5488
5638
 
5639
+ const shouldJoin =
5640
+ !joinResponse || // first try, when the join response is empty
5641
+ (prevError && prevError instanceof UserNotJoinedError) || // last try failed with UserNotJoinedError
5642
+ MeetingUtil.isUserInLeftState(this.locusInfo); // locus dropped the connection before we can re-try addMedia
5643
+
5489
5644
  try {
5490
5645
  let turnServerInfo;
5491
5646
  let turnDiscoverySkippedReason;
5492
5647
  let forceTurnDiscovery = false;
5493
5648
 
5494
- if (!joinResponse) {
5649
+ if (shouldJoin) {
5495
5650
  // This is the 1st attempt or a retry after join request failed -> we need to do a join with TURN discovery
5496
5651
 
5497
5652
  const turnDiscoveryRequest = await this.roap.generateTurnDiscoveryRequestMessage(
@@ -5532,14 +5687,17 @@ export default class Meeting extends StatelessWebexPlugin {
5532
5687
 
5533
5688
  const mediaResponse = await this.addMediaInternal(
5534
5689
  () => {
5535
- return this.joinWithMediaRetryInfo.isRetry ? 'JOIN_MEETING_FINAL' : 'JOIN_MEETING_RETRY';
5690
+ // callback is not called when UserNotJoinedError is thrown
5691
+ return this.joinWithMediaRetryInfo.retryCount >= 1
5692
+ ? 'JOIN_MEETING_FINAL'
5693
+ : 'JOIN_MEETING_RETRY';
5536
5694
  },
5537
5695
  turnServerInfo,
5538
5696
  forceTurnDiscovery,
5539
5697
  mediaOptions
5540
5698
  );
5541
5699
 
5542
- this.joinWithMediaRetryInfo = {isRetry: false, prevJoinResponse: undefined};
5700
+ this.joinWithMediaRetryInfo = {retryCount: 0, prevJoinResponse: undefined};
5543
5701
 
5544
5702
  return {
5545
5703
  join: joinResponse,
@@ -5553,8 +5711,10 @@ export default class Meeting extends StatelessWebexPlugin {
5553
5711
 
5554
5712
  this.roap.abortTurnDiscovery();
5555
5713
 
5556
- // if this was the first attempt, let's do a retry
5557
- let shouldRetry = !isRetry;
5714
+ // let's do a retry
5715
+ let shouldRetry =
5716
+ retryCount < 1 ||
5717
+ (error instanceof UserNotJoinedError && retryCount < JOIN_WITH_MEDIA_RETRY_MAX_COUNT);
5558
5718
 
5559
5719
  if (
5560
5720
  CallDiagnosticUtils.isSdpOfferCreationError(error) ||
@@ -5579,8 +5739,8 @@ export default class Meeting extends StatelessWebexPlugin {
5579
5739
  });
5580
5740
  }
5581
5741
 
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)) {
5742
+ // we only want to call leave if join was successful, and we won't be doing any more retries
5743
+ if (joined && !shouldRetry) {
5584
5744
  try {
5585
5745
  await this.leave({resourceId: joinOptions?.resourceId, reason: 'joinWithMedia failure'});
5586
5746
  } catch (e) {
@@ -5597,7 +5757,7 @@ export default class Meeting extends StatelessWebexPlugin {
5597
5757
  reason: error.message,
5598
5758
  stack: error.stack,
5599
5759
  leaveErrorReason: leaveError?.message,
5600
- isRetry,
5760
+ isRetry: retryCount > 0,
5601
5761
  },
5602
5762
  {
5603
5763
  type: error.name,
@@ -5606,15 +5766,26 @@ export default class Meeting extends StatelessWebexPlugin {
5606
5766
 
5607
5767
  if (shouldRetry) {
5608
5768
  LoggerProxy.logger.warn('Meeting:index#joinWithMedia --> retrying call to joinWithMedia');
5609
- this.joinWithMediaRetryInfo.isRetry = true;
5769
+ this.joinWithMediaRetryInfo.retryCount = retryCount + 1;
5610
5770
  this.joinWithMediaRetryInfo.prevJoinResponse = joinResponse;
5771
+ this.joinWithMediaRetryInfo.prevError = error;
5772
+ if (!this.joinWithMediaRetryInfo.firstError) {
5773
+ this.joinWithMediaRetryInfo.firstError = error;
5774
+ }
5611
5775
 
5612
5776
  return this.joinWithMedia(options);
5613
5777
  }
5614
5778
 
5615
- this.joinWithMediaRetryInfo = {isRetry: false, prevJoinResponse: undefined};
5779
+ const {firstError} = this.joinWithMediaRetryInfo;
5616
5780
 
5617
- throw error;
5781
+ this.joinWithMediaRetryInfo = {
5782
+ retryCount: 0,
5783
+ prevJoinResponse: undefined,
5784
+ firstError: undefined,
5785
+ prevError: undefined,
5786
+ };
5787
+
5788
+ throw firstError ?? error;
5618
5789
  }
5619
5790
  }
5620
5791
 
@@ -5865,6 +6036,31 @@ export default class Meeting extends StatelessWebexPlugin {
5865
6036
  }
5866
6037
  };
5867
6038
 
6039
+ /**
6040
+ * Verifies the relay event was delivered for the active LLM session binding.
6041
+ * @param {RelayEvent} event Event object coming from LLM Connection
6042
+ * @returns {boolean}
6043
+ */
6044
+ private isRelayEventRouteValid(event: RelayEvent): boolean {
6045
+ const route = event?.headers?.route;
6046
+
6047
+ if (!route) {
6048
+ return true;
6049
+ }
6050
+
6051
+ const {llm} = (this as any).webex.internal;
6052
+ const isPracticeSession = llm.isConnected(LLM_PRACTICE_SESSION);
6053
+ const expectedBinding = isPracticeSession
6054
+ ? llm.getBinding(LLM_PRACTICE_SESSION)
6055
+ : llm.getBinding();
6056
+
6057
+ if (!expectedBinding || route === expectedBinding) {
6058
+ return true;
6059
+ }
6060
+
6061
+ return false;
6062
+ }
6063
+
5868
6064
  /**
5869
6065
  * Callback called when a relay event is received from meeting LLM Connection
5870
6066
  * @param {RelayEvent} e Event object coming from LLM Connection
@@ -5872,6 +6068,9 @@ export default class Meeting extends StatelessWebexPlugin {
5872
6068
  * @returns {void}
5873
6069
  */
5874
6070
  private processRelayEvent = (e: RelayEvent): void => {
6071
+ if (!this.isRelayEventRouteValid(e)) {
6072
+ return;
6073
+ }
5875
6074
  switch (e.data.relayType) {
5876
6075
  case REACTION_RELAY_TYPES.REACTION:
5877
6076
  if (
@@ -6002,6 +6201,7 @@ export default class Meeting extends StatelessWebexPlugin {
6002
6201
  */
6003
6202
  private handleLLMOnline = (): void => {
6004
6203
  this.restoreLLMSubscriptionsIfNeeded();
6204
+ this.locusInfo.syncAllHashTreeDatasets({onlyLLM: true});
6005
6205
 
6006
6206
  Trigger.trigger(
6007
6207
  this,
@@ -6301,8 +6501,57 @@ export default class Meeting extends StatelessWebexPlugin {
6301
6501
  }
6302
6502
  }
6303
6503
 
6504
+ /**
6505
+ * Removes LLM event listeners and clears the health check timer.
6506
+ * Must be called before Locus /leave to avoid unnecessary syncs triggered
6507
+ * by events received while leaving (per Locus team recommendation).
6508
+ * Idempotent: safe to call multiple times; .off() is a no-op when no
6509
+ * matching listener is registered.
6510
+ * @private
6511
+ * @returns {void}
6512
+ */
6513
+ private stopListeningForLLMEvents() {
6514
+ // @ts-ignore - fix types
6515
+ this.webex.internal.llm.off('event:relay.event', this.processRelayEvent);
6516
+ // @ts-ignore - fix types
6517
+ this.webex.internal.llm.off(LOCUS_LLM_EVENT, this.processLocusLLMEvent);
6518
+ this.clearLLMHealthCheckTimer();
6519
+ }
6520
+
6521
+ /**
6522
+ * Stops listening on every event bus (LLM, Mercury, voicea/transcription,
6523
+ * annotation) that could otherwise deliver events to this meeting while
6524
+ * Locus is processing /leave or /end. Per the Locus team recommendation,
6525
+ * this must run before the Locus request is dispatched to avoid
6526
+ * unnecessary syncs triggered by in-flight events.
6527
+ *
6528
+ * Voicea (transcription) subscribes to llm 'event:relay.event' internally,
6529
+ * and the annotation plugin subscribes to both mercury and llm, so both
6530
+ * must be torn down alongside the direct LLM/Mercury listeners.
6531
+ *
6532
+ * Idempotent: safe to call multiple times; .off() is a no-op when no
6533
+ * matching listener is registered, and stopTranscription is guarded.
6534
+ * @private
6535
+ * @returns {void}
6536
+ */
6537
+ private stopListeningForMeetingEvents() {
6538
+ this.stopListeningForLLMEvents();
6539
+ this.stopListeningForMercuryEvents();
6540
+ if (this.transcription) {
6541
+ this.stopTranscription();
6542
+ this.transcription = undefined;
6543
+ }
6544
+ this.annotation.deregisterEvents();
6545
+ }
6546
+
6304
6547
  /**
6305
6548
  * Disconnects and cleans up the default LLM session listeners/timers.
6549
+ *
6550
+ * Ownership-aware: only calls `disconnectLLM` when this meeting is the
6551
+ * current owner of the default LLM session (or when no owner is recorded).
6552
+ * Event listeners belonging to this meeting instance are always detached
6553
+ * so they do not receive another meeting's relay events.
6554
+ *
6306
6555
  * @param {Object} options
6307
6556
  * @param {boolean} [options.removeOnlineListener=true] removes the one-time online listener
6308
6557
  * @param {boolean} [options.throwOnError=true] rethrows disconnect errors when true
@@ -6315,12 +6564,29 @@ export default class Meeting extends StatelessWebexPlugin {
6315
6564
  removeOnlineListener?: boolean;
6316
6565
  throwOnError?: boolean;
6317
6566
  } = {}): Promise<void> => {
6567
+ // @ts-ignore - Fix type
6568
+ // @ts-ignore - Fix type
6569
+ const {currentOwner, isOwner} = this.webex.internal.llm.resolveSessionOwnership(
6570
+ this.id,
6571
+ LLM_DEFAULT_SESSION
6572
+ );
6573
+
6318
6574
  try {
6319
- // @ts-ignore - Fix type
6320
- await this.webex.internal.llm.disconnectLLM({
6321
- code: 3050,
6322
- reason: 'done (permanent)',
6323
- });
6575
+ if (isOwner) {
6576
+ // @ts-ignore - Fix type
6577
+ await this.webex.internal.llm.disconnectLLM(
6578
+ {
6579
+ code: 3050,
6580
+ reason: 'done (permanent)',
6581
+ },
6582
+ LLM_DEFAULT_SESSION,
6583
+ this.id
6584
+ );
6585
+ } else {
6586
+ LoggerProxy.logger.info(
6587
+ `Meeting:index#cleanupLLMConneciton --> skipping disconnect; LLM owned by meeting ${currentOwner}, not ${this.id}`
6588
+ );
6589
+ }
6324
6590
  } catch (error) {
6325
6591
  LoggerProxy.logger.error(
6326
6592
  'Meeting:index#cleanupLLMConneciton --> Failed to disconnect default LLM session',
@@ -6335,23 +6601,33 @@ export default class Meeting extends StatelessWebexPlugin {
6335
6601
  // @ts-ignore - Fix type
6336
6602
  this.webex.internal.llm.off('online', this.handleLLMOnline);
6337
6603
  }
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);
6604
+ this.stopListeningForLLMEvents();
6342
6605
 
6343
- this.clearLLMHealthCheckTimer();
6606
+ // Re-check ownership after awaiting disconnectLLM. If ownership changed
6607
+ // while cleanup was in flight, do not clear another meeting's owner tag.
6608
+ if (isOwner) {
6609
+ const {currentOwner: currentOwnerAfterCleanup} =
6610
+ // @ts-ignore - Fix type
6611
+ this.webex.internal.llm.resolveSessionOwnership(this.id, LLM_DEFAULT_SESSION);
6612
+
6613
+ if (currentOwnerAfterCleanup === this.id) {
6614
+ // @ts-ignore - Fix type
6615
+ this.webex.internal.llm.setOwnerMeetingId?.(undefined);
6616
+ }
6617
+ }
6344
6618
  }
6345
6619
  };
6346
6620
 
6347
6621
  /**
6348
- * Clears all data channel tokens stored in LLM.
6349
- * Called during meeting cleanup to ensure stale tokens are not reused.
6622
+ * Clears data channel tokens associated with this meeting ownership.
6623
+ * Ownership checks are enforced in internal-plugin-llm.
6350
6624
  * @returns {void}
6351
6625
  */
6352
6626
  clearDataChannelToken(): void {
6353
6627
  // @ts-ignore
6354
- this.webex.internal.llm.resetDatachannelTokens();
6628
+ this.webex.internal.llm.clearDatachannelToken(LLM_DEFAULT_SESSION, this.id);
6629
+ // @ts-ignore
6630
+ this.webex.internal.llm.clearDatachannelToken(LLM_PRACTICE_SESSION, this.id);
6355
6631
  }
6356
6632
 
6357
6633
  /**
@@ -6366,14 +6642,15 @@ export default class Meeting extends StatelessWebexPlugin {
6366
6642
 
6367
6643
  if (datachannelToken) {
6368
6644
  // @ts-ignore
6369
- this.webex.internal.llm.setDatachannelToken(datachannelToken, DataChannelTokenType.Default);
6645
+ this.webex.internal.llm.setDatachannelToken(datachannelToken, LLM_DEFAULT_SESSION, this.id);
6370
6646
  }
6371
6647
 
6372
6648
  if (practiceSessionDatachannelToken) {
6373
6649
  // @ts-ignore
6374
6650
  this.webex.internal.llm.setDatachannelToken(
6375
6651
  practiceSessionDatachannelToken,
6376
- DataChannelTokenType.PracticeSession
6652
+ LLM_PRACTICE_SESSION,
6653
+ this.id
6377
6654
  );
6378
6655
  }
6379
6656
  }
@@ -6386,7 +6663,10 @@ export default class Meeting extends StatelessWebexPlugin {
6386
6663
  private async ensureDefaultDatachannelTokenAfterAdmit(): Promise<boolean> {
6387
6664
  try {
6388
6665
  // @ts-ignore
6389
- const datachannelToken = this.webex.internal.llm.getDatachannelToken();
6666
+ const datachannelToken = this.webex.internal.llm.getDatachannelToken(
6667
+ LLM_DEFAULT_SESSION,
6668
+ this.id
6669
+ );
6390
6670
  // @ts-ignore
6391
6671
  const isDataChannelTokenEnabled = await this.webex.internal.llm.isDataChannelTokenEnabled();
6392
6672
 
@@ -6408,7 +6688,8 @@ export default class Meeting extends StatelessWebexPlugin {
6408
6688
  // @ts-ignore
6409
6689
  this.webex.internal.llm.setDatachannelToken(
6410
6690
  fetchedDatachannelToken,
6411
- DataChannelTokenType.Default
6691
+ LLM_DEFAULT_SESSION,
6692
+ this.id
6412
6693
  );
6413
6694
 
6414
6695
  return true;
@@ -6435,15 +6716,58 @@ export default class Meeting extends StatelessWebexPlugin {
6435
6716
 
6436
6717
  const isJoined = this.isJoined();
6437
6718
 
6438
- // @ts-ignore
6439
- const datachannelToken = this.webex.internal.llm.getDatachannelToken(
6440
- DataChannelTokenType.Default
6719
+ const dataChannelUrl = datachannelUrl;
6720
+
6721
+ // Ownership guard: when the default LLM session is already connected and
6722
+ // owned by a *different* Meeting instance, do not disconnect or reconfigure
6723
+ // it. Another meeting's `updateLLMConnection` must be ignored here to
6724
+ // avoid killing the socket it relies on. We only proceed to manage the
6725
+ // connection when this meeting is the current owner, or when no owner is
6726
+ // set yet (first claim).
6727
+ // @ts-ignore - Fix type
6728
+ const {currentOwner} = this.webex.internal.llm.resolveSessionOwnership(
6729
+ this.id,
6730
+ LLM_DEFAULT_SESSION
6441
6731
  );
6442
6732
 
6443
- const dataChannelUrl = datachannelUrl;
6733
+ // Capture connectivity before any reconnect attempt. If LLM was already
6734
+ // connected, we must respect current ownership. If it was disconnected,
6735
+ // this flow may reclaim stale owner tags after a fresh connect.
6736
+ // @ts-ignore - Fix type
6737
+ const wasConnected = this.webex.internal.llm.isConnected();
6444
6738
 
6739
+ // Prefer ownership-scoped token read. For disconnected stale-owner reclaim
6740
+ // flows, fallback to ownerless read so initial register can still carry a
6741
+ // token and recover from stale ownership without 401/403 dead-end.
6445
6742
  // @ts-ignore - Fix type
6446
- if (this.webex.internal.llm.isConnected()) {
6743
+ let datachannelToken = this.webex.internal.llm.getDatachannelToken(
6744
+ LLM_DEFAULT_SESSION,
6745
+ this.id
6746
+ );
6747
+
6748
+ if (!datachannelToken && !wasConnected && currentOwner && currentOwner !== this.id) {
6749
+ // @ts-ignore - Fix type
6750
+ datachannelToken = this.webex.internal.llm.getDatachannelToken(LLM_DEFAULT_SESSION);
6751
+ }
6752
+
6753
+ // @ts-ignore - Fix type
6754
+ if (wasConnected) {
6755
+ if (currentOwner && currentOwner !== this.id) {
6756
+ // Another meeting owns the live LLM socket. We must not disconnect
6757
+ // or reconfigure it -- doing so would tear down a session the
6758
+ // owning meeting still relies on. Locus/datachannel URL mismatch is
6759
+ // expected here (each meeting has its own locus URL) and is NOT a
6760
+ // valid signal of staleness, so we never reclaim from this path.
6761
+ // The only safe reclaim mechanism is the `finally`-block owner-tag
6762
+ // release in `cleanupLLMConneciton`, which fires when this meeting
6763
+ // itself is being torn down.
6764
+ LoggerProxy.logger.info(
6765
+ `Meeting:index#updateLLMConnection --> skipping; LLM owned by meeting ${currentOwner}, not ${this.id}`
6766
+ );
6767
+
6768
+ return undefined;
6769
+ }
6770
+
6447
6771
  if (
6448
6772
  // @ts-ignore - Fix type
6449
6773
  url === this.webex.internal.llm.getLocusUrl() &&
@@ -6460,10 +6784,56 @@ export default class Meeting extends StatelessWebexPlugin {
6460
6784
  return undefined;
6461
6785
  }
6462
6786
 
6787
+ // Bind refresh handler before registration so interceptor-triggered token
6788
+ // refresh during register POST can resolve a valid handler.
6789
+ // Prefer this meeting as owner, but allow owner-less fallback when a stale
6790
+ // foreign owner tag is present on a disconnected session.
6791
+ const refreshHandlerOwnerMeetingId =
6792
+ currentOwner && currentOwner !== this.id ? undefined : this.id;
6793
+ const shouldAlignRefreshHandlerAfterOwnershipClaim = refreshHandlerOwnerMeetingId !== this.id;
6794
+ // @ts-ignore - Fix type
6795
+ this.webex.internal.llm.setRefreshHandler(
6796
+ () => this.refreshDataChannelToken(),
6797
+ LLM_DEFAULT_SESSION,
6798
+ refreshHandlerOwnerMeetingId
6799
+ );
6800
+
6463
6801
  // @ts-ignore - Fix type
6464
6802
  return this.webex.internal.llm
6465
6803
  .registerAndConnect(url, dataChannelUrl, datachannelToken)
6466
6804
  .then((registerAndConnectResult) => {
6805
+ this.locusInfo.syncAllHashTreeDatasets({onlyLLM: true});
6806
+ // Record ownership of the default LLM session for this meeting so
6807
+ // subsequent cross-meeting `updateLLMConnection` / `cleanupLLMConneciton`
6808
+ // calls can detect and skip work that doesn't belong to them.
6809
+ // @ts-ignore - Fix type
6810
+ const {isOwner} = this.webex.internal.llm.resolveSessionOwnership(
6811
+ this.id,
6812
+ LLM_DEFAULT_SESSION
6813
+ );
6814
+ const canReclaimAfterDisconnectedStart = !wasConnected;
6815
+
6816
+ // Refresh handler is pre-bound before registerAndConnect so token
6817
+ // refresh can work even during the registration request itself.
6818
+ if (isOwner || canReclaimAfterDisconnectedStart) {
6819
+ // Record ownership of the default LLM session for this meeting so
6820
+ // subsequent cross-meeting `updateLLMConnection` / `cleanupLLMConneciton`
6821
+ // calls can detect and skip work that doesn't belong to them.
6822
+ // @ts-ignore - Fix type
6823
+ this.webex.internal.llm.setOwnerMeetingId?.(this.id);
6824
+
6825
+ // If we pre-bound refresh ownerlessly (stale-owner reclaim path),
6826
+ // align the handler with the newly claimed owner immediately after
6827
+ // ownership is updated.
6828
+ if (shouldAlignRefreshHandlerAfterOwnershipClaim) {
6829
+ // @ts-ignore - Fix type
6830
+ this.webex.internal.llm.setRefreshHandler(
6831
+ () => this.refreshDataChannelToken(),
6832
+ LLM_DEFAULT_SESSION,
6833
+ this.id
6834
+ );
6835
+ }
6836
+ }
6467
6837
  // @ts-ignore - Fix type
6468
6838
  this.webex.internal.llm.off('event:relay.event', this.processRelayEvent);
6469
6839
  // @ts-ignore - Fix type
@@ -7504,6 +7874,33 @@ export default class Meeting extends StatelessWebexPlugin {
7504
7874
  }
7505
7875
  }
7506
7876
  });
7877
+ this.statsAnalyzer.on(StatsAnalyzerEventNames.STATS_UPDATE, (data) => {
7878
+ // Extract srtpCipher from transport stats
7879
+ let srtpCipher: string | undefined;
7880
+ for (const stats of data.stats.values()) {
7881
+ if (stats.type === 'transport' && stats.srtpCipher) {
7882
+ srtpCipher = stats.srtpCipher as string;
7883
+ break;
7884
+ }
7885
+ }
7886
+
7887
+ // Only emit event if srtpCipher has changed
7888
+ if (srtpCipher && srtpCipher !== this.mediaProperties.srtpCipher) {
7889
+ LoggerProxy.logger.info(
7890
+ `Meeting:index#setupStatsAnalyzerEventHandlers --> SRTP cipher changed from ${this.mediaProperties.srtpCipher} to ${srtpCipher}`
7891
+ );
7892
+ this.mediaProperties.srtpCipher = srtpCipher;
7893
+ Trigger.trigger(
7894
+ this,
7895
+ {
7896
+ file: 'meeting/index',
7897
+ function: 'setupStatsAnalyzerEventHandlers',
7898
+ },
7899
+ EVENT_TRIGGERS.MEETING_SRTP_CIPHER_UPDATED,
7900
+ {srtpCipher}
7901
+ );
7902
+ }
7903
+ });
7507
7904
  };
7508
7905
 
7509
7906
  getMediaConnectionDebugId() {
@@ -7550,6 +7947,8 @@ export default class Meeting extends StatelessWebexPlugin {
7550
7947
  disableAudioMainDtx: this.config.experimental.disableAudioMainDtx,
7551
7948
  // @ts-ignore - config coming from registerPlugin
7552
7949
  enableAudioTwcc: this.config.enableAudioTwccForMultistream,
7950
+ // @ts-ignore - config coming from registerPlugin
7951
+ enableAv1SlidesSupport: this.config.enableAv1SlidesSupport,
7553
7952
  stopIceGatheringAfterFirstRelayCandidate:
7554
7953
  // @ts-ignore - config coming from registerPlugin
7555
7954
  this.config.stopIceGatheringAfterFirstRelayCandidate,
@@ -8187,6 +8586,8 @@ export default class Meeting extends StatelessWebexPlugin {
8187
8586
  regionCode: this.webex.meetings.geoHintInfo?.regionCode,
8188
8587
  },
8189
8588
  preferTranscoding: !this.isMultistream,
8589
+ getCurrentSelfUrl: () => this.selfUrl,
8590
+ waitForSelfUrlChange: () => this.waitForSelfUrlChange(),
8190
8591
  },
8191
8592
  {
8192
8593
  // @ts-ignore
@@ -8195,6 +8596,29 @@ export default class Meeting extends StatelessWebexPlugin {
8195
8596
  );
8196
8597
  }
8197
8598
 
8599
+ /**
8600
+ * Waits for LocusInfo to update meeting.selfUrl, with a timeout fallback.
8601
+ * @returns {Promise<void>}
8602
+ * @private
8603
+ * @memberof Meeting
8604
+ */
8605
+ private waitForSelfUrlChange(): Promise<void> {
8606
+ if (!this.promisesWaitingForPropUpdate.selfUrl) {
8607
+ const pendingSelfUrlUpdate = new Defer();
8608
+
8609
+ this.promisesWaitingForPropUpdate.selfUrl = pendingSelfUrlUpdate;
8610
+
8611
+ setTimeout(() => {
8612
+ if (this.promisesWaitingForPropUpdate.selfUrl === pendingSelfUrlUpdate) {
8613
+ delete this.promisesWaitingForPropUpdate.selfUrl;
8614
+ pendingSelfUrlUpdate.resolve();
8615
+ }
8616
+ }, 5000);
8617
+ }
8618
+
8619
+ return this.promisesWaitingForPropUpdate.selfUrl.promise;
8620
+ }
8621
+
8198
8622
  /**
8199
8623
  * Creates a media connection to the server. Media connection is required for sending or receiving any audio/video.
8200
8624
  *
@@ -8387,7 +8811,7 @@ export default class Meeting extends StatelessWebexPlugin {
8387
8811
  numTransports,
8388
8812
  isMultistream: this.isMultistream,
8389
8813
  retriedWithTurnServer: this.addMediaData.retriedWithTurnServer,
8390
- isJoinWithMediaRetry: this.joinWithMediaRetryInfo.isRetry,
8814
+ isJoinWithMediaRetry: this.joinWithMediaRetryInfo.retryCount > 0,
8391
8815
  ...reachabilityMetrics,
8392
8816
  ...iceCandidateErrors,
8393
8817
  iceCandidatesCount: this.iceCandidatesCount,
@@ -8432,7 +8856,7 @@ export default class Meeting extends StatelessWebexPlugin {
8432
8856
  turnServerUsed: this.turnServerUsed,
8433
8857
  retriedWithTurnServer: this.addMediaData.retriedWithTurnServer,
8434
8858
  isMultistream: this.isMultistream,
8435
- isJoinWithMediaRetry: this.joinWithMediaRetryInfo.isRetry,
8859
+ isJoinWithMediaRetry: this.joinWithMediaRetryInfo.retryCount > 0,
8436
8860
  signalingState:
8437
8861
  this.mediaProperties.webrtcMediaConnection?.multistreamConnection?.pc?.pc
8438
8862
  ?.signalingState ||
@@ -8796,6 +9220,7 @@ export default class Meeting extends StatelessWebexPlugin {
8796
9220
  options: {meetingId: this.id},
8797
9221
  });
8798
9222
  LoggerProxy.logger.log('Meeting:index#leave --> Leaving a meeting');
9223
+ this.stopListeningForMeetingEvents();
8799
9224
 
8800
9225
  return MeetingUtil.leaveMeeting(this, options)
8801
9226
  .then(async (leave) => {
@@ -9661,6 +10086,8 @@ export default class Meeting extends StatelessWebexPlugin {
9661
10086
  locus_id: this.locusId,
9662
10087
  });
9663
10088
 
10089
+ this.stopListeningForMeetingEvents();
10090
+
9664
10091
  return MeetingUtil.endMeetingForAll(this)
9665
10092
  .then(async (end) => {
9666
10093
  this.meetingFiniteStateMachine.end();
@@ -9722,12 +10149,15 @@ export default class Meeting extends StatelessWebexPlugin {
9722
10149
  }
9723
10150
  this.queuedMediaUpdates = [];
9724
10151
 
9725
- this.stopTranscription();
9726
- this.transcription = undefined;
9727
-
9728
- this.annotation.deregisterEvents();
9729
-
10152
+ // Listener teardown (transcription, annotation, llm/mercury) runs in
10153
+ // stopListeningForMeetingEvents() before /leave and /end so events
10154
+ // received mid-teardown do not trigger Locus syncs. Calling it here
10155
+ // again would double-emit MEETING_STOPPED_RECEIVING_TRANSCRIPTION
10156
+ // because stopTranscription() always fires its trigger.
10157
+ //
10158
+ // Ownership-aware token clear is encapsulated inside clearDataChannelToken().
9730
10159
  this.clearDataChannelToken();
10160
+
9731
10161
  await this.cleanupLLMConneciton({throwOnError: false});
9732
10162
  };
9733
10163
 
@@ -9804,15 +10234,12 @@ export default class Meeting extends StatelessWebexPlugin {
9804
10234
  * @public
9805
10235
  * @memberof Meeting
9806
10236
  */
9807
- public sendReaction(reactionType: ReactionServerType, skinToneType?: SkinToneType) {
10237
+ public sendReaction(reactionType: string, skinToneType?: SkinToneType) {
9808
10238
  const reactionChannelUrl = this.locusInfo?.controls?.reactions?.reactionChannelUrl as string;
9809
10239
  const participantId = this.members.selfId;
9810
10240
 
9811
- const reactionData = Reactions[reactionType];
10241
+ const reactionData = Reactions[reactionType] || {type: reactionType};
9812
10242
 
9813
- if (!reactionData) {
9814
- return Promise.reject(new Error(`${reactionType} is not a valid reaction.`));
9815
- }
9816
10243
  const skinToneData = SkinTones[skinToneType] || SkinTones.normal;
9817
10244
  const reaction: Reaction = {
9818
10245
  ...reactionData,