@webex/plugin-meetings 3.12.0-next.7 → 3.12.0-next.71

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 (178) 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 +30 -7
  11. package/dist/constants.js.map +1 -1
  12. package/dist/controls-options-manager/constants.js +11 -1
  13. package/dist/controls-options-manager/constants.js.map +1 -1
  14. package/dist/controls-options-manager/index.js +38 -24
  15. package/dist/controls-options-manager/index.js.map +1 -1
  16. package/dist/controls-options-manager/util.js +91 -0
  17. package/dist/controls-options-manager/util.js.map +1 -1
  18. package/dist/hashTree/constants.js +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 +298 -87
  38. package/dist/locus-info/index.js.map +1 -1
  39. package/dist/locus-info/types.js +19 -0
  40. package/dist/locus-info/types.js.map +1 -1
  41. package/dist/media/index.js +3 -1
  42. package/dist/media/index.js.map +1 -1
  43. package/dist/media/properties.js +1 -0
  44. package/dist/media/properties.js.map +1 -1
  45. package/dist/meeting/in-meeting-actions.js +3 -1
  46. package/dist/meeting/in-meeting-actions.js.map +1 -1
  47. package/dist/meeting/index.js +1046 -689
  48. package/dist/meeting/index.js.map +1 -1
  49. package/dist/meeting/muteState.js +10 -1
  50. package/dist/meeting/muteState.js.map +1 -1
  51. package/dist/meeting/request.js +5 -2
  52. package/dist/meeting/request.js.map +1 -1
  53. package/dist/meeting/util.js +20 -2
  54. package/dist/meeting/util.js.map +1 -1
  55. package/dist/meeting-info/meeting-info-v2.js +2 -2
  56. package/dist/meeting-info/meeting-info-v2.js.map +1 -1
  57. package/dist/meetings/index.js +231 -78
  58. package/dist/meetings/index.js.map +1 -1
  59. package/dist/meetings/meetings.types.js +6 -1
  60. package/dist/meetings/meetings.types.js.map +1 -1
  61. package/dist/meetings/request.js +39 -0
  62. package/dist/meetings/request.js.map +1 -1
  63. package/dist/meetings/util.js +79 -5
  64. package/dist/meetings/util.js.map +1 -1
  65. package/dist/member/index.js +10 -0
  66. package/dist/member/index.js.map +1 -1
  67. package/dist/member/types.js.map +1 -1
  68. package/dist/member/util.js +3 -0
  69. package/dist/member/util.js.map +1 -1
  70. package/dist/metrics/constants.js +4 -1
  71. package/dist/metrics/constants.js.map +1 -1
  72. package/dist/multistream/codec/constants.js +63 -0
  73. package/dist/multistream/codec/constants.js.map +1 -0
  74. package/dist/multistream/mediaRequestManager.js +62 -15
  75. package/dist/multistream/mediaRequestManager.js.map +1 -1
  76. package/dist/multistream/receiveSlot.js +9 -0
  77. package/dist/multistream/receiveSlot.js.map +1 -1
  78. package/dist/reactions/reactions.type.js.map +1 -1
  79. package/dist/recording-controller/index.js +1 -3
  80. package/dist/recording-controller/index.js.map +1 -1
  81. package/dist/types/config.d.ts +2 -0
  82. package/dist/types/constants.d.ts +9 -1
  83. package/dist/types/controls-options-manager/constants.d.ts +6 -1
  84. package/dist/types/controls-options-manager/index.d.ts +10 -0
  85. package/dist/types/hashTree/constants.d.ts +2 -0
  86. package/dist/types/hashTree/hashTreeParser.d.ts +146 -17
  87. package/dist/types/hashTree/utils.d.ts +18 -0
  88. package/dist/types/index.d.ts +3 -0
  89. package/dist/types/interceptors/locusRetry.d.ts +4 -4
  90. package/dist/types/interpretation/interpretation.types.d.ts +10 -0
  91. package/dist/types/locus-info/index.d.ts +50 -6
  92. package/dist/types/locus-info/types.d.ts +21 -1
  93. package/dist/types/media/properties.d.ts +1 -0
  94. package/dist/types/meeting/in-meeting-actions.d.ts +2 -0
  95. package/dist/types/meeting/index.d.ts +78 -5
  96. package/dist/types/meeting/request.d.ts +1 -0
  97. package/dist/types/meeting/util.d.ts +8 -0
  98. package/dist/types/meetings/index.d.ts +30 -2
  99. package/dist/types/meetings/meetings.types.d.ts +15 -0
  100. package/dist/types/meetings/request.d.ts +14 -0
  101. package/dist/types/member/index.d.ts +1 -0
  102. package/dist/types/member/types.d.ts +1 -0
  103. package/dist/types/member/util.d.ts +1 -0
  104. package/dist/types/metrics/constants.d.ts +3 -0
  105. package/dist/types/multistream/codec/constants.d.ts +7 -0
  106. package/dist/types/multistream/mediaRequestManager.d.ts +22 -5
  107. package/dist/types/reactions/reactions.type.d.ts +3 -0
  108. package/dist/webinar/index.js +305 -159
  109. package/dist/webinar/index.js.map +1 -1
  110. package/package.json +22 -22
  111. package/src/aiEnableRequest/index.ts +16 -0
  112. package/src/breakouts/breakout.ts +3 -1
  113. package/src/breakouts/index.ts +31 -0
  114. package/src/config.ts +2 -0
  115. package/src/constants.ts +13 -2
  116. package/src/controls-options-manager/constants.ts +14 -1
  117. package/src/controls-options-manager/index.ts +47 -24
  118. package/src/controls-options-manager/util.ts +81 -1
  119. package/src/hashTree/constants.ts +16 -0
  120. package/src/hashTree/hashTreeParser.ts +580 -196
  121. package/src/hashTree/utils.ts +36 -0
  122. package/src/index.ts +6 -0
  123. package/src/interceptors/dataChannelAuthToken.ts +88 -12
  124. package/src/interceptors/locusRetry.ts +25 -4
  125. package/src/interpretation/index.ts +27 -9
  126. package/src/interpretation/interpretation.types.ts +11 -0
  127. package/src/locus-info/controlsUtils.ts +3 -1
  128. package/src/locus-info/index.ts +293 -97
  129. package/src/locus-info/types.ts +25 -1
  130. package/src/media/index.ts +3 -0
  131. package/src/media/properties.ts +1 -0
  132. package/src/meeting/in-meeting-actions.ts +4 -0
  133. package/src/meeting/index.ts +386 -48
  134. package/src/meeting/muteState.ts +10 -1
  135. package/src/meeting/request.ts +11 -0
  136. package/src/meeting/util.ts +21 -2
  137. package/src/meeting-info/meeting-info-v2.ts +4 -2
  138. package/src/meetings/index.ts +134 -44
  139. package/src/meetings/meetings.types.ts +19 -0
  140. package/src/meetings/request.ts +43 -0
  141. package/src/meetings/util.ts +97 -1
  142. package/src/member/index.ts +10 -0
  143. package/src/member/types.ts +1 -0
  144. package/src/member/util.ts +3 -0
  145. package/src/metrics/constants.ts +3 -0
  146. package/src/multistream/codec/constants.ts +58 -0
  147. package/src/multistream/mediaRequestManager.ts +119 -28
  148. package/src/multistream/receiveSlot.ts +18 -0
  149. package/src/reactions/reactions.type.ts +3 -0
  150. package/src/recording-controller/index.ts +1 -2
  151. package/src/webinar/index.ts +214 -36
  152. package/test/unit/spec/aiEnableRequest/index.ts +86 -0
  153. package/test/unit/spec/breakouts/breakout.ts +9 -3
  154. package/test/unit/spec/breakouts/index.ts +49 -0
  155. package/test/unit/spec/controls-options-manager/index.js +140 -29
  156. package/test/unit/spec/controls-options-manager/util.js +165 -0
  157. package/test/unit/spec/hashTree/hashTreeParser.ts +1838 -180
  158. package/test/unit/spec/hashTree/utils.ts +125 -1
  159. package/test/unit/spec/interceptors/dataChannelAuthToken.ts +196 -0
  160. package/test/unit/spec/interceptors/locusRetry.ts +205 -4
  161. package/test/unit/spec/interpretation/index.ts +26 -4
  162. package/test/unit/spec/locus-info/controlsUtils.js +172 -57
  163. package/test/unit/spec/locus-info/index.js +487 -81
  164. package/test/unit/spec/media/index.ts +31 -0
  165. package/test/unit/spec/meeting/in-meeting-actions.ts +2 -0
  166. package/test/unit/spec/meeting/index.js +1240 -37
  167. package/test/unit/spec/meeting/muteState.js +81 -0
  168. package/test/unit/spec/meeting/request.js +12 -0
  169. package/test/unit/spec/meeting/utils.js +33 -0
  170. package/test/unit/spec/meeting-info/meetinginfov2.js +19 -10
  171. package/test/unit/spec/meetings/index.js +360 -10
  172. package/test/unit/spec/meetings/request.js +141 -0
  173. package/test/unit/spec/meetings/utils.js +189 -0
  174. package/test/unit/spec/member/index.js +7 -0
  175. package/test/unit/spec/member/util.js +24 -0
  176. package/test/unit/spec/multistream/mediaRequestManager.ts +501 -37
  177. package/test/unit/spec/recording-controller/index.js +9 -8
  178. package/test/unit/spec/webinar/index.ts +329 -28
@@ -4,7 +4,6 @@
4
4
  import {WebexPlugin, config} from '@webex/webex-core';
5
5
  import uuid from 'uuid';
6
6
  import {get} from 'lodash';
7
- import {DataChannelTokenType} from '@webex/internal-plugin-llm';
8
7
  import {
9
8
  _ID_,
10
9
  HEADERS,
@@ -14,12 +13,27 @@ import {
14
13
  SHARE_STATUS,
15
14
  DEFAULT_LARGE_SCALE_WEBINAR_ATTENDEE_SEARCH_LIMIT,
16
15
  LLM_PRACTICE_SESSION,
16
+ LOCUS_LLM_EVENT,
17
17
  } from '../constants';
18
18
 
19
19
  import WebinarCollection from './collection';
20
20
  import LoggerProxy from '../common/logs/logger-proxy';
21
+ import MeetingUtil from '../meeting/util';
21
22
  import {sanitizeParams} from './utils';
22
23
 
24
+ const PS_LLM_EVENTS = [
25
+ {
26
+ event: `event:relay.event:${LLM_PRACTICE_SESSION}`,
27
+ listenerKey: 'relay',
28
+ handlerKey: 'processRelayEvent',
29
+ },
30
+ {
31
+ event: `${LOCUS_LLM_EVENT}:${LLM_PRACTICE_SESSION}`,
32
+ listenerKey: 'locusLLM',
33
+ handlerKey: 'processLocusLLMEvent',
34
+ },
35
+ ];
36
+
23
37
  /**
24
38
  * @class Webinar
25
39
  */
@@ -98,13 +112,49 @@ const Webinar = WebexPlugin.extend({
98
112
  return {isPromoted, isDemoted};
99
113
  },
100
114
 
115
+ /**
116
+ * Resolves the meeting associated with this webinar instance, guarded against the
117
+ * meetingId pointer drifting onto an unrelated transient meeting (e.g. an inbound
118
+ * 1:1 call) that may exist in the meeting collection. Returns the meeting only when
119
+ * its locusUrl matches this webinar's tracked locusUrl. Returns undefined (with a
120
+ * warning) when the meeting cannot be resolved or when the webinar's locusUrl has
121
+ * not been initialized yet — callers must treat this as "no owned meeting" rather
122
+ * than fall through to an unvalidated lookup.
123
+ * @returns {object|undefined}
124
+ */
125
+ getValidatedWebinarMeeting() {
126
+ const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
127
+
128
+ if (!meeting) {
129
+ return undefined;
130
+ }
131
+
132
+ if (!this.locusUrl) {
133
+ LoggerProxy.logger.warn(
134
+ `Webinar:index#getValidatedWebinarMeeting --> skipping; webinar locusUrl is not yet initialized for meetingId ${this.meetingId}`
135
+ );
136
+
137
+ return undefined;
138
+ }
139
+
140
+ if (meeting.locusUrl !== this.locusUrl) {
141
+ LoggerProxy.logger.warn(
142
+ `Webinar:index#getValidatedWebinarMeeting --> skipping; meeting ${this.meetingId} locusUrl ${meeting.locusUrl} does not match webinar locusUrl ${this.locusUrl}`
143
+ );
144
+
145
+ return undefined;
146
+ }
147
+
148
+ return meeting;
149
+ },
150
+
101
151
  /**
102
152
  * should join practice session data channel or not
103
153
  * @param {Object} {isPromoted: boolean, isDemoted: boolean}} Role transition states
104
154
  * @returns {void}
105
155
  */
106
156
  updateStatusByRole({isPromoted, isDemoted}) {
107
- const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
157
+ const meeting = this.getValidatedWebinarMeeting();
108
158
 
109
159
  if (
110
160
  (isDemoted && meeting?.shareStatus === SHARE_STATUS.WHITEBOARD_SHARE_ACTIVE) ||
@@ -128,34 +178,73 @@ const Webinar = WebexPlugin.extend({
128
178
 
129
179
  /**
130
180
  * Disconnects the practice session data channel and removes its relay listener.
181
+ * The listener reference removed here is the exact callback captured at subscribe
182
+ * time (see updatePSDataChannel) so that cleanup is correct even if the underlying
183
+ * meeting can no longer be resolved (e.g. locusUrl mismatch).
131
184
  * @returns {Promise<void>}
132
185
  */
133
186
  async cleanupPSDataChannel() {
187
+ const {isOwner} = this.webex.internal.llm.resolveSessionOwnership(
188
+ this.meetingId,
189
+ LLM_PRACTICE_SESSION
190
+ );
191
+ this.llmListeners = this.llmListeners || {};
192
+
134
193
  if (this._pendingOnlineListener) {
135
194
  // @ts-ignore - Fix type
136
195
  this.webex.internal.llm.off('online', this._pendingOnlineListener);
137
196
  this._pendingOnlineListener = null;
138
197
  }
139
198
 
140
- const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
199
+ try {
200
+ // @ts-ignore - Fix type
201
+ const disconnected = await this.webex.internal.llm.disconnectLLM(
202
+ {
203
+ code: 3050,
204
+ reason: 'done (permanent)',
205
+ },
206
+ LLM_PRACTICE_SESSION,
207
+ this.meetingId
208
+ );
141
209
 
142
- // @ts-ignore - Fix type
143
- await this.webex.internal.llm.disconnectLLM(
144
- {
145
- code: 3050,
146
- reason: 'done (permanent)',
147
- },
148
- LLM_PRACTICE_SESSION
149
- );
150
- // @ts-ignore - Fix type
151
- this.webex.internal.llm.off(
152
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
153
- meeting?.processRelayEvent
154
- );
210
+ if (!disconnected) {
211
+ LoggerProxy.logger.info(
212
+ `Webinar:index#cleanupPSDataChannel --> skipping disconnect; practice-session LLM is not owned by meeting ${this.meetingId}`
213
+ );
214
+ }
215
+ } catch (error) {
216
+ // disconnectLLM clears ownership only on success; release a stale owner
217
+ // tag here so other meeting instances can reclaim practice-session LLM.
218
+ if (isOwner) {
219
+ // @ts-ignore - Fix type
220
+ this.webex.internal.llm.setOwnerMeetingId?.(undefined, LLM_PRACTICE_SESSION);
221
+ }
222
+
223
+ throw error;
224
+ } finally {
225
+ if (this._practiceSessionRelayListener) {
226
+ // @ts-ignore - Fix type
227
+ this.webex.internal.llm.off(
228
+ `event:relay.event:${LLM_PRACTICE_SESSION}`,
229
+ this._practiceSessionRelayListener
230
+ );
231
+ }
232
+ this._practiceSessionRelayListener = null;
233
+
234
+ for (const {event, listenerKey} of PS_LLM_EVENTS) {
235
+ if (this.llmListeners[listenerKey]) {
236
+ // @ts-ignore - Fix type
237
+ this.webex.internal.llm.off(event, this.llmListeners[listenerKey]);
238
+ this.llmListeners[listenerKey] = null;
239
+ }
240
+ }
241
+ }
155
242
  },
156
243
 
157
244
  /**
158
245
  * Ensures practice-session token exists before registering the practice LLM channel.
246
+ * Caller is responsible for passing a meeting that has already been resolved via
247
+ * getValidatedWebinarMeeting() — this method does not re-validate ownership.
159
248
  * @param {object} meeting
160
249
  * @returns {Promise<string|undefined>}
161
250
  */
@@ -169,7 +258,8 @@ const Webinar = WebexPlugin.extend({
169
258
 
170
259
  // @ts-ignore
171
260
  const cachedToken = this.webex.internal.llm.getDatachannelToken(
172
- DataChannelTokenType.PracticeSession
261
+ LLM_PRACTICE_SESSION,
262
+ this.meetingId
173
263
  );
174
264
 
175
265
  if (cachedToken) {
@@ -187,7 +277,8 @@ const Webinar = WebexPlugin.extend({
187
277
  // @ts-ignore
188
278
  this.webex.internal.llm.setDatachannelToken(
189
279
  datachannelToken,
190
- dataChannelTokenType || DataChannelTokenType.PracticeSession
280
+ dataChannelTokenType || LLM_PRACTICE_SESSION,
281
+ this.meetingId
191
282
  );
192
283
 
193
284
  return datachannelToken;
@@ -208,11 +299,17 @@ const Webinar = WebexPlugin.extend({
208
299
  * @returns {Promise}
209
300
  */
210
301
  async updatePSDataChannel() {
302
+ this.llmListeners = this.llmListeners || {};
303
+
211
304
  this._updatePSDataChannelSequence = (this._updatePSDataChannelSequence || 0) + 1;
212
305
  const invocationSequence = this._updatePSDataChannelSequence;
213
306
 
214
- const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
307
+ const meeting = this.getValidatedWebinarMeeting();
215
308
  const isPracticeSession = meeting?.isJoined() && this.isJoinPracticeSessionDataChannel();
309
+ const {currentOwner, isOwner} = this.webex.internal.llm.resolveSessionOwnership(
310
+ this.meetingId,
311
+ LLM_PRACTICE_SESSION
312
+ );
216
313
 
217
314
  if (!isPracticeSession) {
218
315
  await this.cleanupPSDataChannel();
@@ -220,13 +317,22 @@ const Webinar = WebexPlugin.extend({
220
317
  return undefined;
221
318
  }
222
319
 
320
+ if (!isOwner) {
321
+ LoggerProxy.logger.info(
322
+ `Webinar:index#updatePSDataChannel --> skipping; practice-session LLM owned by meeting ${currentOwner}, not ${this.meetingId}`
323
+ );
324
+
325
+ return undefined;
326
+ }
327
+
223
328
  // @ts-ignore - Fix type
224
329
  const {url = undefined, info: {practiceSessionDatachannelUrl = undefined} = {}} =
225
330
  meeting?.locusInfo || {};
226
331
 
227
332
  // @ts-ignore
228
333
  let practiceSessionDatachannelToken = this.webex.internal.llm.getDatachannelToken(
229
- DataChannelTokenType.PracticeSession
334
+ LLM_PRACTICE_SESSION,
335
+ this.meetingId
230
336
  );
231
337
 
232
338
  const isCaptionBoxOn = this.webex.internal.voicea.getIsCaptionBoxOn();
@@ -303,6 +409,30 @@ const Webinar = WebexPlugin.extend({
303
409
  practiceSessionDatachannelToken = refreshedPracticeSessionToken;
304
410
  }
305
411
 
412
+ const {currentOwner: currentOwnerBeforeConnect, isOwner: isOwnerBeforeConnect} =
413
+ this.webex.internal.llm.resolveSessionOwnership(this.meetingId, LLM_PRACTICE_SESSION);
414
+
415
+ if (!isOwnerBeforeConnect) {
416
+ LoggerProxy.logger.info(
417
+ `Webinar:index#updatePSDataChannel --> skipping pre-connect owner write; practice-session LLM owned by meeting ${currentOwnerBeforeConnect}, not ${this.meetingId}`
418
+ );
419
+
420
+ return undefined;
421
+ }
422
+
423
+ // Ensure refresh for practice datachannel requests is routed to this
424
+ // meeting only when we are actually about to connect the practice session.
425
+ // This avoids claiming ownership in flows that return early (e.g. missing
426
+ // practiceSessionDatachannelUrl or waiting for default session online).
427
+ // @ts-ignore - Fix type
428
+ this.webex.internal.llm.setRefreshHandler(
429
+ () => meeting.refreshDataChannelToken(),
430
+ LLM_PRACTICE_SESSION,
431
+ this.meetingId
432
+ );
433
+ // @ts-ignore - Fix type
434
+ this.webex.internal.llm.setOwnerMeetingId?.(this.meetingId, LLM_PRACTICE_SESSION);
435
+
306
436
  // @ts-ignore - Fix type
307
437
  return this.webex.internal.llm
308
438
  .registerAndConnect(
@@ -312,16 +442,30 @@ const Webinar = WebexPlugin.extend({
312
442
  LLM_PRACTICE_SESSION
313
443
  )
314
444
  .then((registerAndConnectResult) => {
315
- // @ts-ignore - Fix type
316
- this.webex.internal.llm.off(
317
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
318
- meeting?.processRelayEvent
319
- );
320
- // @ts-ignore - Fix type
321
- this.webex.internal.llm.on(
322
- `event:relay.event:${LLM_PRACTICE_SESSION}`,
323
- meeting?.processRelayEvent
324
- );
445
+ const {currentOwner: currentOwnerAfterConnect, isOwner: isOwnerAfterConnect} =
446
+ this.webex.internal.llm.resolveSessionOwnership(this.meetingId, LLM_PRACTICE_SESSION);
447
+
448
+ if (this.meetingId && isOwnerAfterConnect) {
449
+ // @ts-ignore - Fix type
450
+ this.webex.internal.llm.setOwnerMeetingId?.(this.meetingId, LLM_PRACTICE_SESSION);
451
+ } else {
452
+ LoggerProxy.logger.info(
453
+ `Webinar:index#updatePSDataChannel --> skipping post-connect owner write; practice-session LLM owned by meeting ${currentOwnerAfterConnect}, not ${this.meetingId}`
454
+ );
455
+ }
456
+
457
+ // Track the exact listener references so cleanupPSDataChannel can
458
+ // unsubscribe deterministically, even if the meeting can no longer
459
+ // be resolved at cleanup time.
460
+ for (const {event, listenerKey, handlerKey} of PS_LLM_EVENTS) {
461
+ if (this.llmListeners[listenerKey]) {
462
+ // @ts-ignore - Fix type
463
+ this.webex.internal.llm.off(event, this.llmListeners[listenerKey]);
464
+ }
465
+ this.llmListeners[listenerKey] = meeting?.[handlerKey];
466
+ // @ts-ignore - Fix type
467
+ this.webex.internal.llm.on(event, this.llmListeners[listenerKey]);
468
+ }
325
469
  // @ts-ignore - Fix type
326
470
  this.webex.internal.voicea?.announce?.();
327
471
  if (isCaptionBoxOn) {
@@ -332,6 +476,23 @@ const Webinar = WebexPlugin.extend({
332
476
  );
333
477
 
334
478
  return Promise.resolve(registerAndConnectResult);
479
+ })
480
+ .catch((error) => {
481
+ const {
482
+ currentOwner: currentOwnerAfterRegisterFailure,
483
+ isOwner: isOwnerAfterRegisterFailure,
484
+ } = this.webex.internal.llm.resolveSessionOwnership(this.meetingId, LLM_PRACTICE_SESSION);
485
+
486
+ if (isOwnerAfterRegisterFailure) {
487
+ // @ts-ignore - Fix type
488
+ this.webex.internal.llm.setOwnerMeetingId?.(undefined, LLM_PRACTICE_SESSION);
489
+ } else {
490
+ LoggerProxy.logger.info(
491
+ `Webinar:index#updatePSDataChannel --> skipping failure owner release; practice-session LLM owned by meeting ${currentOwnerAfterRegisterFailure}, not ${this.meetingId}`
492
+ );
493
+ }
494
+
495
+ throw error;
335
496
  });
336
497
  },
337
498
 
@@ -341,6 +502,8 @@ const Webinar = WebexPlugin.extend({
341
502
  * @returns {Promise}
342
503
  */
343
504
  setPracticeSessionState(enabled) {
505
+ const meeting = this.getValidatedWebinarMeeting();
506
+
344
507
  return this.request({
345
508
  method: HTTP_VERBS.PATCH,
346
509
  uri: `${this.locusUrl}/controls`,
@@ -349,10 +512,16 @@ const Webinar = WebexPlugin.extend({
349
512
  enabled,
350
513
  },
351
514
  },
352
- }).catch((error) => {
353
- LoggerProxy.logger.error('Meeting:webinar#setPracticeSessionState failed', error);
354
- throw error;
355
- });
515
+ })
516
+ .then((response) => {
517
+ MeetingUtil.updateLocusFromApiResponse(meeting, response);
518
+
519
+ return response;
520
+ })
521
+ .catch((error) => {
522
+ LoggerProxy.logger.error('Meeting:webinar#setPracticeSessionState failed', error);
523
+ throw error;
524
+ });
356
525
  },
357
526
 
358
527
  /**
@@ -532,7 +701,14 @@ const Webinar = WebexPlugin.extend({
532
701
  * @returns {Promise}
533
702
  */
534
703
  async searchLargeScaleWebinarAttendees(payload) {
535
- const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId);
704
+ const meeting = this.getValidatedWebinarMeeting();
705
+ if (!meeting) {
706
+ LoggerProxy.logger.error(
707
+ 'Meeting:webinar5k#searchLargeScaleWebinarAttendees failed --> webinar meeting could not be validated'
708
+ );
709
+ throw new Error('Meeting:webinar5k#Webinar meeting is not resolvable for the current locus');
710
+ }
711
+
536
712
  const rawParams = {
537
713
  search_text: payload?.queryString,
538
714
  limit: payload?.limit ?? DEFAULT_LARGE_SCALE_WEBINAR_ATTENDEE_SEARCH_LIMIT,
@@ -540,7 +716,9 @@ const Webinar = WebexPlugin.extend({
540
716
  };
541
717
  const attendeeSearchUrl = meeting?.locusInfo?.links?.resources?.attendeeSearch?.url;
542
718
  if (!attendeeSearchUrl) {
543
- LoggerProxy.logger.error(`Meeting:webinar5k#searchLargeScaleWebinarAttendees failed`);
719
+ LoggerProxy.logger.error(
720
+ 'Meeting:webinar5k#searchLargeScaleWebinarAttendees failed --> attendee search url unavailable'
721
+ );
544
722
  throw new Error('Meeting:webinar5k#Attendee search url is not available');
545
723
  }
546
724
 
@@ -55,6 +55,27 @@ describe('plugin-meetings', () => {
55
55
  });
56
56
  });
57
57
 
58
+ describe('#locusUrlUpdate', () => {
59
+ it('should update the locusUrl property', () => {
60
+ const testLocusUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/test-id';
61
+
62
+ aiEnableRequest.locusUrlUpdate(testLocusUrl);
63
+
64
+ assert.equal(aiEnableRequest.locusUrl, testLocusUrl);
65
+ });
66
+
67
+ it('should handle updating locusUrl multiple times', () => {
68
+ const firstUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/test-id-1';
69
+ const secondUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/test-id-2';
70
+
71
+ aiEnableRequest.locusUrlUpdate(firstUrl);
72
+ assert.equal(aiEnableRequest.locusUrl, firstUrl);
73
+
74
+ aiEnableRequest.locusUrlUpdate(secondUrl);
75
+ assert.equal(aiEnableRequest.locusUrl, secondUrl);
76
+ });
77
+ });
78
+
58
79
  describe('#selfParticipantIdUpdate', () => {
59
80
  it('should update the selfParticipantId property', () => {
60
81
  const testSelfParticipantId = 'participant-123';
@@ -254,6 +275,71 @@ describe('plugin-meetings', () => {
254
275
  sinon.assert.notCalled(triggerSpy);
255
276
  });
256
277
 
278
+ it('should not trigger event when locusUrl does not match', () => {
279
+ const testLocusUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/test-id';
280
+ const differentLocusUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/different-id';
281
+
282
+ aiEnableRequest.locusUrl = testLocusUrl;
283
+
284
+ // Reset the spy after setting locusUrl to avoid counting property change events
285
+ triggerSpy.resetHistory();
286
+
287
+ aiEnableRequest.listenToApprovalRequests();
288
+
289
+ const event = {
290
+ data: {
291
+ locusUrl: differentLocusUrl,
292
+ approval: {
293
+ resourceType: AI_ENABLE_REQUEST.RESOURCE_TYPE,
294
+ receivers: [{participantId: testSelfParticipantId}],
295
+ initiator: {participantId: testInitiatorId},
296
+ actionType: AI_ENABLE_REQUEST.ACTION_TYPE.REQUESTED,
297
+ url: testUrl,
298
+ },
299
+ },
300
+ };
301
+
302
+ webex.internal.mercury.emit(`event:${LOCUSEVENT.APPROVAL_REQUEST}`, event);
303
+
304
+ sinon.assert.notCalled(triggerSpy);
305
+ });
306
+
307
+ it('should trigger event when locusUrl matches', () => {
308
+ const testLocusUrl = 'https://locus-a.wbx2.com/locus/api/v1/loci/test-id';
309
+
310
+ aiEnableRequest.locusUrl = testLocusUrl;
311
+
312
+ // Reset the spy after setting locusUrl to avoid counting property change events
313
+ triggerSpy.resetHistory();
314
+
315
+ aiEnableRequest.listenToApprovalRequests();
316
+
317
+ const event = {
318
+ data: {
319
+ locusUrl: testLocusUrl,
320
+ approval: {
321
+ resourceType: AI_ENABLE_REQUEST.RESOURCE_TYPE,
322
+ receivers: [{participantId: testSelfParticipantId}],
323
+ initiator: {participantId: testInitiatorId},
324
+ actionType: AI_ENABLE_REQUEST.ACTION_TYPE.REQUESTED,
325
+ url: testUrl,
326
+ },
327
+ },
328
+ };
329
+
330
+ webex.internal.mercury.emit(`event:${LOCUSEVENT.APPROVAL_REQUEST}`, event);
331
+
332
+ sinon.assert.calledOnce(triggerSpy);
333
+ sinon.assert.calledWith(triggerSpy, AI_ENABLE_REQUEST.EVENTS.APPROVAL_REQUEST_ARRIVED, {
334
+ actionType: AI_ENABLE_REQUEST.ACTION_TYPE.REQUESTED,
335
+ isApprover: true,
336
+ isInitiator: false,
337
+ initiatorId: testInitiatorId,
338
+ approverId: testSelfParticipantId,
339
+ url: testUrl,
340
+ });
341
+ });
342
+
257
343
  it('should handle events with different action types', () => {
258
344
  aiEnableRequest.listenToApprovalRequests();
259
345
 
@@ -26,6 +26,7 @@ describe('plugin-meetings', () => {
26
26
  breakout.sessionId = 'sessionId';
27
27
  breakout.sessionType = 'BREAKOUT';
28
28
  breakout.url = 'url';
29
+ breakout.resourceLink = 'resource-link';
29
30
  breakout.collection = {
30
31
  parent: {
31
32
  meetingId: 'activeMeetingId',
@@ -45,6 +46,7 @@ describe('plugin-meetings', () => {
45
46
  describe('initialize', () => {
46
47
  it('creates the object correctly', () => {
47
48
  assert.instanceOf(breakout.breakoutRequest, BreakoutRequest);
49
+ assert.equal(breakout.resourceLink, 'resource-link');
48
50
  });
49
51
  });
50
52
 
@@ -217,10 +219,14 @@ describe('plugin-meetings', () => {
217
219
  locusParticipantsUpdate: sinon.stub(),
218
220
  };
219
221
 
220
- const locusData = {some: 'data'};
222
+ const locusData = {participants: [{id: 'participant-1'}], sequence: {entries: [123]}};
221
223
  const result = breakout.parseRoster(locusData);
222
224
 
223
- assert.calledOnceWithExactly(breakout.members.locusParticipantsUpdate, locusData);
225
+ assert.calledOnceWithExactly(breakout.members.locusParticipantsUpdate, {
226
+ participants: [{id: 'participant-1'}],
227
+ isReplace: true,
228
+ });
229
+ assert.equal(breakout.breakoutRosterLocus, locusData);
224
230
  assert.equal(result, undefined);
225
231
  });
226
232
  it('not call locusParticipantsUpdate if sequence is expired', () => {
@@ -228,7 +234,7 @@ describe('plugin-meetings', () => {
228
234
  locusParticipantsUpdate: sinon.stub(),
229
235
  };
230
236
  breakout.isNeedHandleRoster = sinon.stub().returns(false);
231
- const locusData = {some: 'data'};
237
+ const locusData = {participants: [{id: 'participant-1'}], sequence: {entries: [123]}};
232
238
  breakout.parseRoster(locusData);
233
239
 
234
240
  assert.notCalled(breakout.members.locusParticipantsUpdate);
@@ -313,6 +313,7 @@ describe('plugin-meetings', () => {
313
313
  groupId: 'groupId',
314
314
  sessionType: 'sessionType',
315
315
  url: 'url',
316
+ resourceLink: 'resource-link',
316
317
  name: 'name',
317
318
  allowBackToMain: true,
318
319
  delayCloseTime: 10,
@@ -339,6 +340,7 @@ describe('plugin-meetings', () => {
339
340
  assert.equal(breakouts.currentBreakoutSession.current, true);
340
341
  assert.equal(breakouts.currentBreakoutSession.sessionType, 'sessionType');
341
342
  assert.equal(breakouts.currentBreakoutSession.url, 'url');
343
+ assert.equal(breakouts.currentBreakoutSession.resourceLink, 'resource-link');
342
344
  assert.equal(breakouts.currentBreakoutSession.active, false);
343
345
  assert.equal(breakouts.currentBreakoutSession.allowed, false);
344
346
  assert.equal(breakouts.currentBreakoutSession.assigned, false);
@@ -1847,6 +1849,53 @@ describe('plugin-meetings', () => {
1847
1849
  });
1848
1850
  });
1849
1851
 
1852
+ describe('#removeFromBreakout', () => {
1853
+ it('should make a POST request with correct body and return the result', async () => {
1854
+ breakouts.request = sinon.stub().returns(Promise.resolve('REQUEST_RETURN_VALUE'));
1855
+ breakouts.set('url', 'url');
1856
+ breakouts.set('mainGroupId', 'mainGroupId');
1857
+ breakouts.set('mainSessionId', 'mainSessionId');
1858
+
1859
+ const participants = ['participant1', 'participant2'];
1860
+ const result = await breakouts.removeFromBreakout(participants);
1861
+
1862
+ assert.calledOnceWithExactly(breakouts.request, {
1863
+ method: 'POST',
1864
+ uri: 'url/move',
1865
+ body: {
1866
+ groups: [
1867
+ {
1868
+ id: 'mainGroupId',
1869
+ sessions: [
1870
+ {
1871
+ id: 'mainSessionId',
1872
+ participants,
1873
+ },
1874
+ ],
1875
+ },
1876
+ ],
1877
+ },
1878
+ });
1879
+ assert.equal(result, 'REQUEST_RETURN_VALUE');
1880
+ });
1881
+
1882
+ it('should throw an error if mainGroupId is missing', () => {
1883
+ breakouts.set('mainSessionId', 'mainSessionId');
1884
+ assert.throws(
1885
+ () => breakouts.removeFromBreakout(['participant1']),
1886
+ 'Main group ID and session ID must be available to remove participants from breakout'
1887
+ );
1888
+ });
1889
+
1890
+ it('should throw an error if mainSessionId is missing', () => {
1891
+ breakouts.set('mainGroupId', 'mainGroupId');
1892
+ assert.throws(
1893
+ () => breakouts.removeFromBreakout(['participant1']),
1894
+ 'Main group ID and session ID must be available to remove participants from breakout'
1895
+ );
1896
+ });
1897
+ });
1898
+
1850
1899
  describe('#triggerReturnToMainEvent', () => {
1851
1900
  const checkTrigger = ({breakout, shouldTrigger}) => {
1852
1901
  breakouts.trigger = sinon.stub();