diff --git a/packages/@webex/internal-plugin-voicea/src/constants.ts b/packages/@webex/internal-plugin-voicea/src/constants.ts index 9916f5ab0c1..bf193169de4 100644 --- a/packages/@webex/internal-plugin-voicea/src/constants.ts +++ b/packages/@webex/internal-plugin-voicea/src/constants.ts @@ -44,6 +44,7 @@ export const TRANSCRIPTION_TYPE = { export const VOICEA = 'voicea'; export const DEFAULT_SPOKEN_LANGUAGE = 'en'; +export const LLM_PRACTICE_SESSION = 'llm-practice-session'; export const ANNOUNCE_STATUS = { IDLE: 'idle', diff --git a/packages/@webex/internal-plugin-voicea/src/voicea.ts b/packages/@webex/internal-plugin-voicea/src/voicea.ts index 77c2671f3a7..d993e497a90 100644 --- a/packages/@webex/internal-plugin-voicea/src/voicea.ts +++ b/packages/@webex/internal-plugin-voicea/src/voicea.ts @@ -6,6 +6,7 @@ import { AIBRIDGE_RELAY_TYPES, TRANSCRIPTION_TYPE, VOICEA, + LLM_PRACTICE_SESSION, ANNOUNCE_STATUS, TURN_ON_CAPTION_STATUS, TOGGLE_MANUAL_CAPTION_STATUS, @@ -89,6 +90,8 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { if (!this.hasSubscribedToEvents) { // @ts-ignore this.webex.internal.llm.on('event:relay.event', this.eventProcessor); + // @ts-ignore + this.webex.internal.llm.on(`event:relay.event:${LLM_PRACTICE_SESSION}`, this.eventProcessor); this.hasSubscribedToEvents = true; } } @@ -102,6 +105,8 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { this.captionServiceId = undefined; // @ts-ignore this.webex.internal.llm.off('event:relay.event', this.eventProcessor); + // @ts-ignore + this.webex.internal.llm.off(`event:relay.event:${LLM_PRACTICE_SESSION}`, this.eventProcessor); this.hasSubscribedToEvents = false; this.announceStatus = ANNOUNCE_STATUS.IDLE; this.captionStatus = TURN_ON_CAPTION_STATUS.IDLE; @@ -264,13 +269,20 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { private sendAnnouncement = (): void => { this.announceStatus = ANNOUNCE_STATUS.JOINING; this.listenToEvents(); - // @ts-ignore - this.webex.internal.llm.socket.send({ + const socket = + // @ts-ignore + this.webex.internal.llm.getSocket(LLM_PRACTICE_SESSION) || this.webex.internal.llm.socket; + const binding = + // @ts-ignore + this.webex.internal.llm.getBinding(LLM_PRACTICE_SESSION) || + // @ts-ignore + this.webex.internal.llm.getBinding(); + socket.send({ id: `${this.seqNum}`, type: 'publishRequest', recipients: { // @ts-ignore - route: this.webex.internal.llm.getBinding(), + route: binding, }, // If captionServiceId exists, send it as the 'to' header; otherwise keep headers empty. headers: this.captionServiceId ? {to: this.captionServiceId} : {}, @@ -320,13 +332,20 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { public requestLanguage = (languageCode: string): void => { // @ts-ignore if (!this.webex.internal.llm.isConnected()) return; - // @ts-ignore - this.webex.internal.llm.socket.send({ + const socket = + // @ts-ignore + this.webex.internal.llm.getSocket(LLM_PRACTICE_SESSION) || this.webex.internal.llm.socket; + const binding = + // @ts-ignore + this.webex.internal.llm.getBinding(LLM_PRACTICE_SESSION) || + // @ts-ignore + this.webex.internal.llm.getBinding(); + socket.send({ id: `${this.seqNum}`, type: 'publishRequest', recipients: { // @ts-ignore - route: this.webex.internal.llm.getBinding(), + route: binding, }, headers: { to: this.captionServiceId, @@ -363,8 +382,16 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { // @ts-ignore if (!this.webex.internal.llm.isConnected()) return; - // @ts-ignore - this.webex.internal.llm.socket.send({ + const socket = + // @ts-ignore + this.webex.internal.llm.getSocket(LLM_PRACTICE_SESSION) || this.webex.internal.llm.socket; + const binding = + // @ts-ignore + this.webex.internal.llm.getBinding(LLM_PRACTICE_SESSION) || + // @ts-ignore + this.webex.internal.llm.getBinding(); + + socket?.send({ id: `${this.seqNum}`, type: 'publishRequest', recipients: { diff --git a/packages/@webex/internal-plugin-voicea/test/unit/spec/voicea.js b/packages/@webex/internal-plugin-voicea/test/unit/spec/voicea.js index 852d21f9a40..b242c2c2bdd 100644 --- a/packages/@webex/internal-plugin-voicea/test/unit/spec/voicea.js +++ b/packages/@webex/internal-plugin-voicea/test/unit/spec/voicea.js @@ -7,7 +7,11 @@ import Mercury from '@webex/internal-plugin-mercury'; import LLMChannel from '@webex/internal-plugin-llm'; import VoiceaService from '../../../src/index'; -import {EVENT_TRIGGERS, TOGGLE_MANUAL_CAPTION_STATUS} from '../../../src/constants'; +import { + EVENT_TRIGGERS, + LLM_PRACTICE_SESSION, + TOGGLE_MANUAL_CAPTION_STATUS, +} from '../../../src/constants'; describe('plugin-voicea', () => { const locusUrl = 'locusUrl'; @@ -28,6 +32,7 @@ describe('plugin-voicea', () => { voiceaService.connect = sinon.stub().resolves(true); voiceaService.webex.internal.llm.isConnected = sinon.stub().returns(true); voiceaService.webex.internal.llm.getBinding = sinon.stub().returns(undefined); + voiceaService.webex.internal.llm.getSocket = sinon.stub().returns(undefined); voiceaService.webex.internal.llm.getLocusUrl = sinon.stub().returns(locusUrl); voiceaService.request = sinon.stub().resolves({ @@ -87,7 +92,9 @@ describe('plugin-voicea', () => { voiceaService.sendAnnouncement(); - assert.calledOnceWithExactly(spy, 'event:relay.event', sinon.match.func); + assert.calledTwice(spy); + assert.calledWith(spy, 'event:relay.event', sinon.match.func); + assert.calledWith(spy, `event:relay.event:${LLM_PRACTICE_SESSION}`, sinon.match.func); }); it('includes captionServiceId in headers when set', () => { @@ -1205,5 +1212,69 @@ describe('plugin-voicea', () => { }); }); + describe('#multiple llm connections', () => { + let defaultSocket; + let practiceSocket; + + beforeEach(() => { + defaultSocket = new MockWebSocket(); + practiceSocket = new MockWebSocket(); + + voiceaService.webex.internal.llm.socket = defaultSocket; + voiceaService.webex.internal.llm.getSocket.callsFake((channel) => + channel === LLM_PRACTICE_SESSION ? practiceSocket : undefined + ); + voiceaService.webex.internal.llm.getBinding.callsFake((channel) => + channel === LLM_PRACTICE_SESSION ? 'practice-binding' : 'default-binding' + ); + voiceaService.seqNum = 1; + }); + + it('sendAnnouncement uses the practice session socket and binding when available', () => { + voiceaService.announceStatus = 'idle'; + + voiceaService.sendAnnouncement(); + + assert.calledOnce(practiceSocket.send); + assert.notCalled(defaultSocket.send); + + const sent = practiceSocket.send.getCall(0).args[0]; + expect(sent).to.have.nested.property('recipients.route', 'practice-binding'); + }); + + it('requestLanguage uses the practice session socket and binding when available', () => { + voiceaService.requestLanguage('fr'); + + assert.calledOnce(practiceSocket.send); + assert.notCalled(defaultSocket.send); + + const sent = practiceSocket.send.getCall(0).args[0]; + expect(sent).to.have.nested.property('recipients.route', 'practice-binding'); + expect(sent).to.have.nested.property('data.clientPayload.translationLanguage', 'fr'); + }); + + it('processes relay events from the practice session channel', async () => { + const announcementSpy = sinon.spy(voiceaService, 'processAnnouncementMessage'); + + voiceaService.listenToEvents(); + + // eslint-disable-next-line no-underscore-dangle + await voiceaService.webex.internal.llm._emit(`event:relay.event:${LLM_PRACTICE_SESSION}`, { + headers: {from: 'svc-practice'}, + data: { + relayType: 'voicea.annc', + voiceaPayload: { + translation: {allowed_languages: ['en'], max_languages: 1}, + ASR: {spoken_languages: ['en']}, + }, + }, + sequenceNumber: 10, + }); + + assert.calledOnce(announcementSpy); + assert.equal(voiceaService.captionServiceId, 'svc-practice'); + }); + }); + }); }); diff --git a/packages/@webex/plugin-meetings/src/annotation/index.ts b/packages/@webex/plugin-meetings/src/annotation/index.ts index 1392c9c2d54..e70a2ad6514 100644 --- a/packages/@webex/plugin-meetings/src/annotation/index.ts +++ b/packages/@webex/plugin-meetings/src/annotation/index.ts @@ -13,7 +13,7 @@ import { } from './constants'; import {StrokeData, RequestData, IAnnotationChannel, CommandRequestBody} from './annotation.types'; -import {HTTP_VERBS, LOCUSEVENT} from '../constants'; +import {HTTP_VERBS, LOCUSEVENT, LLM_PRACTICE_SESSION} from '../constants'; /** * @description Annotation to handle LLM and Mercury message and locus API @@ -116,6 +116,12 @@ class AnnotationChannel extends WebexPlugin implements IAnnotationChannel { ); // @ts-ignore this.webex.internal.llm.on('event:relay.event', this.eventDataProcessor, this); + // @ts-ignore + this.webex.internal.llm.on( + `event:relay.event:${LLM_PRACTICE_SESSION}`, + this.eventDataProcessor, + this + ); this.hasSubscribedToEvents = true; } } @@ -134,7 +140,11 @@ class AnnotationChannel extends WebexPlugin implements IAnnotationChannel { // @ts-ignore this.webex.internal.llm.off('event:relay.event', this.eventDataProcessor); - + // @ts-ignore + this.webex.internal.llm.off( + `event:relay.event:${LLM_PRACTICE_SESSION}`, + this.eventDataProcessor + ); this.hasSubscribedToEvents = false; } } @@ -306,12 +316,20 @@ class AnnotationChannel extends WebexPlugin implements IAnnotationChannel { * @returns {void} */ private publishEncrypted(encryptedContent: string, strokeData: StrokeData) { + const socket = + // @ts-ignore + this.webex.internal.llm.getSocket(LLM_PRACTICE_SESSION) || this.webex.internal.llm.socket; + const binding = + // @ts-ignore + this.webex.internal.llm.getBinding(LLM_PRACTICE_SESSION) || + // @ts-ignore + this.webex.internal.llm.getBinding(); const data = { id: `${this.seqNum}`, type: 'publishRequest', recipients: { // @ts-ignore - route: this.webex.internal.llm.getBinding(), + route: binding, }, headers: { to: strokeData.toUserId, @@ -339,7 +357,7 @@ class AnnotationChannel extends WebexPlugin implements IAnnotationChannel { }; // @ts-ignore - this.webex.internal.llm.socket.send(data); + socket.send(data); this.seqNum += 1; } } diff --git a/packages/@webex/plugin-meetings/src/constants.ts b/packages/@webex/plugin-meetings/src/constants.ts index f57b5845b4e..36420b838d5 100644 --- a/packages/@webex/plugin-meetings/src/constants.ts +++ b/packages/@webex/plugin-meetings/src/constants.ts @@ -44,6 +44,7 @@ export const LOCAL = 'local'; export const LOCI = 'loci'; export const LOCUS_URL = 'locusUrl'; export const END = 'end'; +export const LLM_PRACTICE_SESSION = 'llm-practice-session'; export const MAX_RANDOM_DELAY_FOR_MEETING_INFO = 3 * 60 * 1000; export const MEETINGINFO = 'meetingInfo'; diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index 96f74fda204..4c0a68f772f 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -6228,31 +6228,24 @@ export default class Meeting extends StatelessWebexPlugin { // @ts-ignore - Fix type const { url = undefined, - info: {datachannelUrl = undefined, practiceSessionDatachannelUrl = undefined} = {}, - self: {datachannelToken = undefined, practiceSessionDatachannelToken = undefined} = {}, + info: {datachannelUrl = undefined} = {}, + self: {datachannelToken = undefined} = {}, } = this.locusInfo || {}; const isJoined = this.isJoined(); - const dataChannelTokenType = this.getDataChannelTokenType(); - const isPracticeSession = dataChannelTokenType === DataChannelTokenType.PracticeSession; // @ts-ignore - const currentToken = this.webex.internal.llm.getDatachannelToken(dataChannelTokenType); - - const locusToken = isPracticeSession ? practiceSessionDatachannelToken : datachannelToken; + const currentToken = this.webex.internal.llm.getDatachannelToken(DataChannelTokenType.Default); - const finalToken = currentToken ?? locusToken; + const finalToken = currentToken ?? datachannelToken; - if (!currentToken && locusToken) { + if (!currentToken && datachannelToken) { // @ts-ignore - this.webex.internal.llm.setDatachannelToken(locusToken, dataChannelTokenType); + this.webex.internal.llm.setDatachannelToken(datachannelToken, DataChannelTokenType.Default); } // webinar panelist should use new data channel in practice session - const dataChannelUrl = - isPracticeSession && practiceSessionDatachannelUrl - ? practiceSessionDatachannelUrl - : datachannelUrl; + const dataChannelUrl = datachannelUrl; // @ts-ignore - Fix type if (this.webex.internal.llm.isConnected()) { diff --git a/packages/@webex/plugin-meetings/src/meeting/util.ts b/packages/@webex/plugin-meetings/src/meeting/util.ts index ccab82cd06a..0877b5d9c82 100644 --- a/packages/@webex/plugin-meetings/src/meeting/util.ts +++ b/packages/@webex/plugin-meetings/src/meeting/util.ts @@ -369,6 +369,7 @@ const MeetingUtil = { meeting.stopPeriodicLogUpload(); meeting.breakouts.cleanUp(); + meeting.webinar.cleanUp(); meeting.simultaneousInterpretation.cleanUp(); meeting.locusMediaRequest = undefined; diff --git a/packages/@webex/plugin-meetings/src/webinar/index.ts b/packages/@webex/plugin-meetings/src/webinar/index.ts index 80580855609..dfcac049906 100644 --- a/packages/@webex/plugin-meetings/src/webinar/index.ts +++ b/packages/@webex/plugin-meetings/src/webinar/index.ts @@ -4,6 +4,7 @@ import {WebexPlugin, config} from '@webex/webex-core'; import uuid from 'uuid'; import {get} from 'lodash'; +import {DataChannelTokenType} from '@webex/internal-plugin-llm'; import { _ID_, HEADERS, @@ -12,12 +13,12 @@ import { SELF_ROLES, SHARE_STATUS, DEFAULT_LARGE_SCALE_WEBINAR_ATTENDEE_SEARCH_LIMIT, + LLM_PRACTICE_SESSION, } from '../constants'; import WebinarCollection from './collection'; import LoggerProxy from '../common/logs/logger-proxy'; import {sanitizeParams} from './utils'; - /** * @class Webinar */ @@ -37,6 +38,14 @@ const Webinar = WebexPlugin.extend({ meetingId: 'string', }, + /** + * Calls this to clean up listeners + * @returns {void} + */ + cleanUp() { + this.updatePSDataChannel(false); + }, + /** * Update the current locus url of the webinar * @param {string} locusUrl @@ -105,10 +114,7 @@ const Webinar = WebexPlugin.extend({ meeting?.locusInfo?.updateMediaShares(meeting?.locusInfo?.mediaShares, true); } - if (this.practiceSessionEnabled) { - // may need change data channel in practice session - meeting?.updateLLMConnection(); - } + this.updatePSDataChannel(this.isJoinPracticeSessionDataChannel()); }, /** @@ -119,6 +125,100 @@ const Webinar = WebexPlugin.extend({ return this.selfIsPanelist && this.practiceSessionEnabled; }, + /** + * Connects to low latency mercury and reconnects if the address has changed + * It will also disconnect if called when the meeting has ended + * @param {boolean} connect - whether to connect or disconnect + * @returns {Promise} + */ + async updatePSDataChannel(connect) { + const meeting = this.webex.meetings.getMeetingByType(_ID_, this.meetingId); + const isPracticeSession = this.isJoinPracticeSessionDataChannel(); + + // @ts-ignore - Fix type + const { + url = undefined, + info: {practiceSessionDatachannelUrl = undefined} = {}, + self: {practiceSessionDatachannelToken = undefined} = {}, + } = meeting?.locusInfo || {}; + + // @ts-ignore + const currentToken = this.webex.internal.llm.getDatachannelToken( + DataChannelTokenType.PracticeSession + ); + + const finalToken = currentToken ?? practiceSessionDatachannelToken; + + if (!currentToken && practiceSessionDatachannelToken) { + // @ts-ignore + this.webex.internal.llm.setDatachannelToken( + practiceSessionDatachannelToken, + DataChannelTokenType.PracticeSession + ); + } + + // webinar panelist should use new data channel in practice session + const isJoined = meeting?.isJoined() && isPracticeSession; + + if (!connect) { + // @ts-ignore - Fix type + if (this.webex.internal.llm.isConnected(LLM_PRACTICE_SESSION)) { + // @ts-ignore - Fix type + await this.webex.internal.llm.disconnectLLM( + { + code: 3050, + reason: 'done (permanent)', + }, + LLM_PRACTICE_SESSION + ); + // @ts-ignore - Fix type + this.webex.internal.llm.off( + `event:relay.event:${LLM_PRACTICE_SESSION}`, + meeting?.processRelayEvent + ); + } + + return undefined; + } + + if (!isJoined || !practiceSessionDatachannelUrl) { + return undefined; + } + // @ts-ignore - Fix type + if (this.webex.internal.llm.isConnected(LLM_PRACTICE_SESSION)) { + if ( + // @ts-ignore - Fix type + url === this.webex.internal.llm.getLocusUrl(LLM_PRACTICE_SESSION) && + // @ts-ignore - Fix type + practiceSessionDatachannelUrl === + this.webex.internal.llm.getDatachannelUrl(LLM_PRACTICE_SESSION) + ) { + return undefined; + } + } + + // @ts-ignore - Fix type + return this.webex.internal.llm + .registerAndConnect(url, practiceSessionDatachannelUrl, finalToken, LLM_PRACTICE_SESSION) + .then((registerAndConnectResult) => { + // @ts-ignore - Fix type + this.webex.internal.llm.off( + `event:relay.event:${LLM_PRACTICE_SESSION}`, + meeting?.processRelayEvent + ); + // @ts-ignore - Fix type + this.webex.internal.llm.on( + `event:relay.event:${LLM_PRACTICE_SESSION}`, + meeting?.processRelayEvent + ); + LoggerProxy.logger.info( + `Webinar:index#updatePSDataChannel --> enabled to receive relay events for default session for ${LLM_PRACTICE_SESSION}!` + ); + + return Promise.resolve(registerAndConnectResult); + }); + }, + /** * start or stop practice session for webinar * @param {boolean} enabled @@ -146,6 +246,7 @@ const Webinar = WebexPlugin.extend({ */ updatePracticeSessionStatus(payload) { this.set('practiceSessionEnabled', !!payload?.enabled); + this.updatePSDataChannel(this.isJoinPracticeSessionDataChannel()).then(() => {}); }, /** diff --git a/packages/@webex/plugin-meetings/test/unit/spec/annotation/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/annotation/index.ts index b3f1cf248f9..52ee9a30ad1 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/annotation/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/annotation/index.ts @@ -7,6 +7,7 @@ import LLMChannel from '@webex/internal-plugin-llm'; import AnnotationService from '../../../../src/annotation/index'; import {ANNOTATION_RELAY_TYPES, ANNOTATION_REQUEST_TYPE, EVENT_TRIGGERS} from '../../../../src/annotation/constants'; +import {HTTP_VERBS, LOCUSEVENT, LLM_PRACTICE_SESSION} from '../../../../src/constants'; describe('live-annotation', () => { @@ -178,12 +179,17 @@ describe('live-annotation', () => { }); it('listens to llm events once', () => { - const spy = sinon.spy(webex.internal.llm, 'on'); annotationService.listenToEvents(); - assert.calledOnceWithExactly(spy, 'event:relay.event', sinon.match.func,sinon.match.object); + assert.calledWithExactly(spy.firstCall, 'event:relay.event', sinon.match.func, sinon.match.object); + assert.calledWithExactly( + spy.secondCall, + `event:relay.event:${LLM_PRACTICE_SESSION}`, + sinon.match.func, + sinon.match.object + ); }); }); @@ -441,10 +447,21 @@ describe('live-annotation', () => { annotationService.eventDataProcessor, annotationService ); + assert.calledWith( + llmOn, + `event:relay.event:${LLM_PRACTICE_SESSION}`, + annotationService.eventDataProcessor, + annotationService + ); assert.match(annotationService.hasSubscribedToEvents, true); annotationService.deregisterEvents(); assert.calledWith(llmOff, 'event:relay.event', annotationService.eventDataProcessor); + assert.calledWith( + llmOff, + `event:relay.event:${LLM_PRACTICE_SESSION}`, + annotationService.eventDataProcessor + ); assert.calledWith( mercuryOff, 'event:locus.approval_request', diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js index 8baceb54fb8..1542784717e 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -12886,7 +12886,7 @@ describe('plugin-meetings', () => { assert.notCalled(webex.internal.llm.registerAndConnect); assert.equal(result, undefined); }); - it('connects practice session data channel when PS started', async () => { + it('still need connect main session data channel when PS started', async () => { meeting.joinedWith = {state: 'JOINED'}; meeting.locusInfo = { url: 'a url', @@ -12902,7 +12902,7 @@ describe('plugin-meetings', () => { assert.calledWithExactly( webex.internal.llm.registerAndConnect, 'a url', - 'ps-url', + 'a datachannel url', undefined ); }); diff --git a/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js b/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js index cbc54349514..a35e20f9be5 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/utils.js @@ -56,6 +56,7 @@ describe('plugin-meetings', () => { meeting.stopKeepAlive = sinon.stub(); meeting.updateLLMConnection = sinon.stub(); meeting.breakouts = {cleanUp: sinon.stub()}; + meeting.webinar = {cleanUp: sinon.stub()}; meeting.annotaion = {cleanUp: sinon.stub()}; meeting.getWebexObject = sinon.stub().returns(webex); meeting.simultaneousInterpretation = {cleanUp: sinon.stub()}; diff --git a/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts b/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts index 35d53137a32..016d82241d3 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts +++ b/packages/@webex/plugin-meetings/test/unit/spec/webinar/index.ts @@ -4,6 +4,7 @@ import Webinar from '@webex/plugin-meetings/src/webinar'; import MockWebex from '@webex/test-helper-mock-webex'; import uuid from 'uuid'; import sinon from 'sinon'; +import {LLM_PRACTICE_SESSION} from '@webex/plugin-meetings/src/constants'; describe('plugin-meetings', () => { describe('Webinar', () => { @@ -27,6 +28,17 @@ describe('plugin-meetings', () => { webex.credentials.getUserToken = getUserTokenStub; webex.meetings.getMeetingByType = sinon.stub(); + webex.internal.llm = { + getDatachannelToken: sinon.stub().returns(undefined), + setDatachannelToken: sinon.stub(), + isConnected: sinon.stub().returns(false), + disconnectLLM: sinon.stub().resolves(), + off: sinon.stub(), + on: sinon.stub(), + getLocusUrl: sinon.stub().returns('old-locus-url'), + getDatachannelUrl: sinon.stub().returns('old-dc-url'), + registerAndConnect: sinon.stub().resolves('REGISTER_AND_CONNECT_RESULT'), + }; }); afterEach(() => { @@ -147,6 +159,142 @@ describe('plugin-meetings', () => { assert.equal(result.isPromoted, false, 'should not indicate promotion'); assert.equal(result.isDemoted, false, 'should not indicate demotion'); }); + + it('handles missing role payload safely', () => { + const updateStatusByRoleStub = sinon.stub(webinar, 'updateStatusByRole'); + + const result = webinar.updateRoleChanged(undefined); + + assert.equal(webinar.selfIsPanelist, false); + assert.equal(webinar.selfIsAttendee, false); + assert.equal(webinar.canManageWebcast, false); + assert.deepEqual(result, {isPromoted: false, isDemoted: false}); + assert.calledOnceWithExactly(updateStatusByRoleStub, {isPromoted: false, isDemoted: false}); + }); + }); + + describe('#updatePSDataChannel', () => { + let meeting; + let processRelayEvent; + + beforeEach(() => { + processRelayEvent = sinon.stub(); + meeting = { + isJoined: sinon.stub().returns(true), + processRelayEvent, + locusInfo: { + url: 'locus-url', + info: {practiceSessionDatachannelUrl: 'dc-url'}, + self: {practiceSessionDatachannelToken: 'ps-token'}, + }, + }; + + webex.meetings.getMeetingByType = sinon.stub().returns(meeting); + + // Ensure connect path is eligible + webinar.selfIsPanelist = true; + webinar.practiceSessionEnabled = true; + }); + + it('disconnects when connect=false and currently connected', async () => { + webex.internal.llm.isConnected.returns(true); + + await webinar.updatePSDataChannel(false); + + assert.calledOnce(webex.internal.llm.disconnectLLM); + assert.calledWith( + webex.internal.llm.disconnectLLM, + {code: 3050, reason: 'done (permanent)'}, + LLM_PRACTICE_SESSION + ); + assert.calledOnce(webex.internal.llm.off); + assert.notCalled(webex.internal.llm.registerAndConnect); + }); + + it('no-ops when meeting is not joined', async () => { + meeting.isJoined.returns(false); + + const result = await webinar.updatePSDataChannel(true); + + assert.isUndefined(result); + assert.notCalled(webex.internal.llm.registerAndConnect); + }); + + it('no-ops when practiceSessionDatachannelUrl is missing', async () => { + meeting.locusInfo.info.practiceSessionDatachannelUrl = undefined; + + const result = await webinar.updatePSDataChannel(true); + + assert.isUndefined(result); + assert.notCalled(webex.internal.llm.registerAndConnect); + }); + + it('no-ops when already connected to the same endpoints', async () => { + webex.internal.llm.isConnected.returns(true); + webex.internal.llm.getLocusUrl.returns('locus-url'); + webex.internal.llm.getDatachannelUrl.returns('dc-url'); + + const result = await webinar.updatePSDataChannel(true); + + assert.isUndefined(result); + assert.notCalled(webex.internal.llm.registerAndConnect); + }); + + it('connects when connect=true', async () => { + const result = await webinar.updatePSDataChannel(true); + + assert.calledOnce(webex.internal.llm.setDatachannelToken); + assert.calledWith(webex.internal.llm.setDatachannelToken, 'ps-token'); + assert.calledOnce(webex.internal.llm.registerAndConnect); + assert.calledWith( + webex.internal.llm.registerAndConnect, + 'locus-url', + 'dc-url', + 'ps-token', + LLM_PRACTICE_SESSION + ); + assert.equal(result, 'REGISTER_AND_CONNECT_RESULT'); + }); + + it('uses cached token when available', async () => { + webex.internal.llm.getDatachannelToken.returns('cached-token'); + + await webinar.updatePSDataChannel(true); + + assert.notCalled(webex.internal.llm.setDatachannelToken); + assert.calledWith( + webex.internal.llm.registerAndConnect, + 'locus-url', + 'dc-url', + 'cached-token', + LLM_PRACTICE_SESSION + ); + }); + + it('no-ops disconnect when connect=false and currently disconnected', async () => { + webex.internal.llm.isConnected.returns(false); + + const result = await webinar.updatePSDataChannel(false); + + assert.isUndefined(result); + assert.notCalled(webex.internal.llm.disconnectLLM); + assert.notCalled(webex.internal.llm.off); + }); + + it('rebinds relay listener after successful connect', async () => { + await webinar.updatePSDataChannel(true); + + assert.calledWith( + webex.internal.llm.off, + `event:relay.event:${LLM_PRACTICE_SESSION}`, + processRelayEvent + ); + assert.calledWith( + webex.internal.llm.on, + `event:relay.event:${LLM_PRACTICE_SESSION}`, + processRelayEvent + ); + }); }); describe('#updateStatusByRole', () => { @@ -173,26 +321,6 @@ describe('plugin-meetings', () => { sinon.restore(); }); - it('trigger updateLLMConnection if PS started', () => { - - webinar.practiceSessionEnabled = true; - const roleChange = {isPromoted: true, isDemoted: false}; - - const result = webinar.updateStatusByRole(roleChange); - - assert.calledOnce(updateLLMConnection); - }); - - it('Not trigger updateLLMConnection if PS not started', () => { - - webinar.practiceSessionEnabled = false; - const roleChange = {isPromoted: true, isDemoted: false}; - - const result = webinar.updateStatusByRole(roleChange); - - assert.notCalled(updateLLMConnection); - }); - it('trigger updateMediaShares if promoted', () => { const roleChange = {isPromoted: true, isDemoted: false}; @@ -248,6 +376,18 @@ describe('plugin-meetings', () => { assert.notCalled(updateMediaShares); }); + + it('updates PS data channel based on join eligibility', () => { + const isJoinPracticeSessionDataChannelStub = sinon + .stub(webinar, 'isJoinPracticeSessionDataChannel') + .returns(true); + const updatePSDataChannelStub = sinon.stub(webinar, 'updatePSDataChannel').resolves(); + + webinar.updateStatusByRole({isPromoted: false, isDemoted: false}); + + assert.calledOnce(isJoinPracticeSessionDataChannelStub); + assert.calledOnceWithExactly(updatePSDataChannelStub, true); + }); }); describe("#setPracticeSessionState", () => { @@ -323,6 +463,14 @@ describe('plugin-meetings', () => { assert.equal(webinar.practiceSessionEnabled, false); }); + it('triggers PS data channel update using computed eligibility', () => { + webinar.selfIsPanelist = true; + const updatePSDataChannelStub = sinon.stub(webinar, 'updatePSDataChannel').resolves(); + + webinar.updatePracticeSessionStatus({enabled: true}); + + assert.calledOnceWithExactly(updatePSDataChannelStub, true); + }); }); describe("#startWebcast", () => {