diff --git a/packages/@webex/internal-plugin-llm/src/index.ts b/packages/@webex/internal-plugin-llm/src/index.ts index 32daa193342..7293cfbc01e 100644 --- a/packages/@webex/internal-plugin-llm/src/index.ts +++ b/packages/@webex/internal-plugin-llm/src/index.ts @@ -5,7 +5,7 @@ import {DataChannelTokenType} from './llm.types'; WebexCore.registerInternalPlugin('llm', LLMPlugin, { config, onBeforeLogout() { - return this.disconnectAllLLM(); + return this.disconnectAll(); }, }); @@ -13,3 +13,4 @@ export {DataChannelTokenType}; export {LLM_DEFAULT_SESSION, LLM_PRACTICE_SESSION} from './constants'; export {default} from './llm'; export {default as LLMPlugin} from './llm-plugin'; +export type {ILLMChannel, ILLMPlugin} from './llm.types'; diff --git a/packages/@webex/internal-plugin-llm/src/llm-plugin.ts b/packages/@webex/internal-plugin-llm/src/llm-plugin.ts index 9e948a83746..41a4e2695b5 100644 --- a/packages/@webex/internal-plugin-llm/src/llm-plugin.ts +++ b/packages/@webex/internal-plugin-llm/src/llm-plugin.ts @@ -1,255 +1,88 @@ /* eslint-disable require-jsdoc */ import {WebexPlugin} from '@webex/webex-core'; import LLMChannel, {config} from './llm'; -import {DATA_CHANNEL_WITH_JWT_TOKEN, LLM_DEFAULT_SESSION} from './constants'; -import {DataChannelTokenType} from './llm.types'; +import {DATA_CHANNEL_WITH_JWT_TOKEN} from './constants'; /** * LLMPlugin — registered as `webex.internal.llm`. * - * Maintains a Map so multiple simultaneous LLM - * connections (e.g. default session + practice session) can coexist. - * All existing callers continue to work unchanged via the session-keyed API. + * Factory for creating LLMChannel instances. Each Meeting creates and owns + * its own LLMChannel(s), allowing multiple independent connections without + * the need for session IDs or ownership tracking. * - * sessionId values are the LLM_DEFAULT_SESSION / LLM_PRACTICE_SESSION constants, - * which match the DataChannelTokenType enum values so token keys and session keys - * are the same namespace. + * This is a breaking API change from the previous design where the plugin + * maintained a Map of sessions and exposed session-keyed methods. + * + * Old usage (no longer supported): + * webex.internal.llm.isConnected() + * webex.internal.llm.registerAndConnect(url, dcUrl, token, sessionId) + * + * New usage: + * const llm = webex.internal.llm.createConnection(); + * await llm.registerAndConnect(url, dcUrl, token); + * llm.isConnected(); + * // When done: + * await llm.disconnect(); */ export class LLMPlugin extends (WebexPlugin as any) { namespace = 'llm'; - private sessions = new Map(); - - private getOrCreateSession(sessionId: string): LLMChannel { - let channel = this.sessions.get(sessionId); - - if (!channel) { - // @ts-ignore — WebexPlugin children require {parent: this.webex} - channel = new LLMChannel({parent: this.webex}); - // Forward all events emitted by the channel up through the plugin so that - // callers doing llm.on('event:relay.event', ...) or llm.on('online', ...) - // receive events from whichever session channel emits them. - // Non-default sessions emit events with : suffix so that - // practice-session consumers can subscribe to session-specific names - // like `event:relay.event:llm-practice-session`. - channel.on('all', (eventName: string, ...args: any[]) => { - if (sessionId === LLM_DEFAULT_SESSION) { - this.trigger(eventName, ...args); - } else { - this.trigger(`${eventName}:${sessionId}`, ...args); - } - }); - this.sessions.set(sessionId, channel); - } - - return channel; - } - - private getSession(sessionId: string): LLMChannel | undefined { - return this.sessions.get(sessionId); - } - - // Before webex.internal.llm was LLChannel instance which extended Mercury so it was directly available - get hasEverConnected(): boolean { - for (const ch of this.sessions.values()) { - if (ch.hasEverConnected) return true; - } - - return false; - } - - public registerAndConnect( - locusUrl: string, - datachannelUrl: string, - datachannelToken?: string, - sessionId: string = LLM_DEFAULT_SESSION - ): Promise { - const channel = this.getOrCreateSession(sessionId); - - return channel.registerAndConnect(locusUrl, datachannelUrl, datachannelToken); - } - - public disconnectLLM( - options: {code: number; reason: string}, - // eslint-disable-next-line default-param-last - sessionId: string = LLM_DEFAULT_SESSION, - ownerMeetingId?: string - ): Promise { - const channel = this.getSession(sessionId); - - if (!channel) return Promise.resolve(); - - const {isOwner} = this.resolveSessionOwnership(ownerMeetingId, sessionId); - - if (!isOwner) { - this.logger.info(`llm#disconnectLLM --> skipping, not owner of session ${sessionId}`); - - return Promise.resolve(false); - } - - return channel.disconnect(options).then(() => { - this.sessions.delete(sessionId); - - return true; + /** + * Registry of active LLM channels for interceptor lookup. + * Channels are registered when created and unregistered on disconnect. + */ + private channels = new Set(); + + /** + * Creates a new LLMChannel instance. The caller owns the channel and is + * responsible for connecting, disconnecting, and cleaning it up. + * + * @example + * const llm = webex.internal.llm.createConnection(); + * llm.setRefreshHandler(() => meeting.refreshDataChannelToken()); + * await llm.registerAndConnect(locusUrl, datachannelUrl, token); + * + * // Subscribe to events directly on the channel + * llm.on('event:relay.event', handler); + * llm.on('online', onlineHandler); + * + * // When done + * await llm.disconnect(); + * + * @returns {LLMChannel} A new LLM connection instance + */ + public createConnection(): LLMChannel { + // @ts-ignore — WebexPlugin children require {parent: this.webex} + const channel = new LLMChannel({parent: this.webex}); + + this.channels.add(channel); + + // Auto-unregister when disconnected + channel.on('offline', () => { + this.channels.delete(channel); }); - } - - public disconnectAllLLM(options?: {code: number; reason: string}): Promise { - const promises = Array.from(this.sessions.entries()).map(([sessionId, channel]) => - channel.disconnect(options).then(() => this.sessions.delete(sessionId)) - ); - - return Promise.all(promises).then(() => undefined); - } - - public isConnected(sessionId: string = LLM_DEFAULT_SESSION): boolean { - const session = this.getSession(sessionId); - const connected = session?.isConnected() ?? false; - - return connected; - } - - public getBinding(sessionId: string = LLM_DEFAULT_SESSION): string | undefined { - return this.getSession(sessionId)?.getBinding(); - } - - public getSocket(sessionId: string = LLM_DEFAULT_SESSION): any { - return this.getSession(sessionId)?.socket; - } - - // Backwards-compatibility: callers that access llm.socket directly - // (e.g. voicea's getPublishTransport) get the default session socket. - get socket(): any { - return this.getSocket(LLM_DEFAULT_SESSION); - } - - public getLocusUrl(sessionId: string = LLM_DEFAULT_SESSION): string | undefined { - return this.getSession(sessionId)?.getLocusUrl(); - } - - public getDatachannelUrl(sessionId: string = LLM_DEFAULT_SESSION): string | undefined { - return this.getSession(sessionId)?.getDatachannelUrl(); - } - - // tokenKey IS the sessionId (DataChannelTokenType enum values equal LLM_*_SESSION constants) - - public getDatachannelToken( - // eslint-disable-next-line default-param-last - tokenKey: string = LLM_DEFAULT_SESSION, - ownerMeetingId?: string - ): string | undefined { - const channel = this.getSession(tokenKey); - - if (!channel) return undefined; - - const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, tokenKey); - - if (!isOwner) { - this.logger.info( - `llm#getDatachannelToken --> skip read for session ${tokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}` - ); - - return undefined; - } - - return channel.getDatachannelToken(); - } - - public setDatachannelToken( - datachannelToken: string, - ownerMeetingId?: string, - tokenKey: string = LLM_DEFAULT_SESSION - ): void { - const channel = this.getOrCreateSession(tokenKey); - const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, tokenKey); - - if (!isOwner) { - this.logger.info( - `llm#setDatachannelToken --> skip write for session ${tokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}` - ); - - return; - } - - channel.setDatachannelToken(datachannelToken); - } - - public clearDatachannelToken(tokenKey: string, ownerMeetingId: string): void { - const channel = this.getSession(tokenKey); - - if (!channel) return; - - const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, tokenKey); - - if (!isOwner) { - this.logger.info( - `llm#clearDatachannelToken --> skip clear for session ${tokenKey}; owned by ${currentOwner}, candidate ${ownerMeetingId}` - ); - - return; - } - - channel.clearDatachannelToken(); - } - - public setRefreshHandler( - handler: () => Promise<{ - body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType}; - }>, - ownerMeetingId?: string, - sessionId: string = LLM_DEFAULT_SESSION - ): void { - const channel = this.getOrCreateSession(sessionId); - const {isOwner, currentOwner} = this.resolveSessionOwnership(ownerMeetingId, sessionId); - - if (!isOwner) { - this.logger.info( - `llm#setRefreshHandler --> skip write for session ${sessionId}; owned by ${currentOwner}, candidate ${ownerMeetingId}` - ); - - return; - } - - channel.setRefreshHandler(handler); - } - - public refreshDataChannelToken(sessionId: string = LLM_DEFAULT_SESSION): Promise { - const channel = this.getSession(sessionId); - - if (!channel) { - this.logger.warn(`llm#refreshDataChannelToken --> no channel for session ${sessionId}`); - - return Promise.resolve(null); - } - return channel.refreshDataChannelToken(); - } - - public setOwnerMeetingId( - ownerMeetingId: string | undefined, - sessionId: string = LLM_DEFAULT_SESSION - ): void { - const channel = this.getSession(sessionId); - - if (channel) channel.ownerMeetingId = ownerMeetingId; - } - - public getOwnerMeetingId(sessionId: string = LLM_DEFAULT_SESSION): string | undefined { - return this.getSession(sessionId)?.ownerMeetingId; + return channel; } - public resolveSessionOwnership( - ownerMeetingId?: string, - sessionId: string = LLM_DEFAULT_SESSION - ): {currentOwner: string | undefined; isOwner: boolean} { - const currentOwner = this.getOwnerMeetingId(sessionId); - const isOwner = !currentOwner || !ownerMeetingId || currentOwner === ownerMeetingId; - - return {currentOwner, isOwner}; + /** + * Returns true if the data channel token feature flag is enabled. + * This is a global check, not per-connection. + * @returns {Promise} + */ + public isDataChannelTokenEnabled(): Promise { + // @ts-ignore + return this.webex.internal.feature.getFeature('developer', DATA_CHANNEL_WITH_JWT_TOKEN); } + /** + * Find a connection by its datachannel URL. Used by the interceptor to + * route token refresh requests to the correct channel. + * @param {string} url - The request URL to match + * @returns {LLMChannel | undefined} + */ public getConnectionByDatachannelUrl(url: string): LLMChannel | undefined { - for (const channel of this.sessions.values()) { + for (const channel of this.channels) { const datachannelUrl = channel.getDatachannelUrl(); if (datachannelUrl && LLMChannel.matchesDatachannelRequestUrl(url, datachannelUrl)) { @@ -260,37 +93,37 @@ export class LLMPlugin extends (WebexPlugin as any) { return undefined; } + /** + * Find a locus URL by matching a request URL to an active connection's + * datachannel URL. Used by the interceptor. + * @param {string} requestUrl - The request URL to match + * @returns {string | undefined} + */ public getLocusUrlByDatachannelUrl(requestUrl: string): string | undefined { - for (const channel of this.sessions.values()) { - const datachannelUrl = channel.getDatachannelUrl(); + const channel = this.getConnectionByDatachannelUrl(requestUrl); - if (datachannelUrl && LLMChannel.matchesDatachannelRequestUrl(requestUrl, datachannelUrl)) { - return channel.getLocusUrl(); - } - } - - return undefined; + return channel?.getLocusUrl(); } - public getSessionIdByDatachannelUrl(requestUrl: string): string | undefined { - for (const [sessionId, channel] of this.sessions.entries()) { - const datachannelUrl = channel.getDatachannelUrl(); - - if (datachannelUrl && LLMChannel.matchesDatachannelRequestUrl(requestUrl, datachannelUrl)) { - return sessionId; - } - } - - return undefined; + /** + * Get all active connections. Useful for diagnostics/debugging. + * @returns {Set} + */ + public getAllConnections(): Set { + return new Set(this.channels); } - public isDataChannelTokenEnabled(): Promise { - // @ts-ignore - return this.webex.internal.feature.getFeature('developer', DATA_CHANNEL_WITH_JWT_TOKEN); - } + /** + * Disconnect all active connections. Useful for cleanup on logout. + * @param {object} [options] - Disconnect options + * @param {number} [options.code] - WebSocket close code + * @param {string} [options.reason] - WebSocket close reason + * @returns {Promise} + */ + public async disconnectAll(options?: {code: number; reason: string}): Promise { + const promises = Array.from(this.channels).map((channel) => channel.disconnect(options)); - public getAllConnections(): Map { - return new Map(this.sessions); + await Promise.all(promises); } } diff --git a/packages/@webex/internal-plugin-llm/src/llm.ts b/packages/@webex/internal-plugin-llm/src/llm.ts index 44ce88949d2..a1c881ee4cf 100644 --- a/packages/@webex/internal-plugin-llm/src/llm.ts +++ b/packages/@webex/internal-plugin-llm/src/llm.ts @@ -42,8 +42,10 @@ export const config = { /** * LLMChannel — a single WebSocket connection to the LLM data channel. - * Session-level routing (multiple connections keyed by sessionId) is handled - * by LLMPlugin, which owns a Map. + * + * Created via `webex.internal.llm.createConnection()`. The caller owns the + * channel and is responsible for its lifecycle (connect, disconnect, cleanup). + * Multiple LLMChannels can exist simultaneously for different meetings. */ export default class LLMChannel extends (Mercury as any) implements ILLMChannel { namespace = LLM; @@ -57,12 +59,17 @@ export default class LLMChannel extends (Mercury as any) implements ILLMChannel body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType}; }>; - /** Owning meeting ID — set by LLMPlugin to prevent cross-meeting teardown. */ - public ownerMeetingId?: string; - /** In-flight connection promise for deduplication. */ private connectingPromise?: Promise; + /** + * Check if a connection is currently in progress. + * @returns {boolean} True if connecting + */ + public isConnecting(): boolean { + return !!this.connectingPromise; + } + /** * Register to the websocket * @param {string} llmSocketUrl @@ -149,6 +156,12 @@ export default class LLMChannel extends (Mercury as any) implements ILLMChannel */ public isConnected = (): boolean => this.connected; + /** + * Get the underlying WebSocket + * @returns {any} socket + */ + public getSocket = (): any => this.socket; + /** * Tells if LLM socket is binding * @returns {string | undefined} binding @@ -243,7 +256,6 @@ export default class LLMChannel extends (Mercury as any) implements ILLMChannel this.datachannelUrl = undefined; this.datachannelToken = undefined; this.refreshHandler = undefined; - this.ownerMeetingId = undefined; } /** diff --git a/packages/@webex/internal-plugin-llm/src/llm.types.ts b/packages/@webex/internal-plugin-llm/src/llm.types.ts index 9dd0e8afd82..4068a294756 100644 --- a/packages/@webex/internal-plugin-llm/src/llm.types.ts +++ b/packages/@webex/internal-plugin-llm/src/llm.types.ts @@ -3,63 +3,87 @@ export enum DataChannelTokenType { PracticeSession = 'llm-practice-session', } -type DataChannelTokenKey = DataChannelTokenType | string; - +/** + * ILLMChannel — interface for a single LLM WebSocket connection. + * Created via `webex.internal.llm.createConnection()`. + */ interface ILLMChannel { + /** Register with the server and connect the WebSocket. */ registerAndConnect: ( locusUrl: string, datachannelUrl: string, - datachannelToken?: string, - sessionId?: string + datachannelToken?: string ) => Promise; + + /** Returns true if the WebSocket is connected. */ isConnected: () => boolean; + + /** Returns true if a connection is currently in progress. */ + isConnecting: () => boolean; + + /** Get the underlying WebSocket. */ + getSocket: () => any; + + /** Get the binding ID for this connection. */ getBinding: () => string | undefined; + + /** Get the Locus URL associated with this connection. */ getLocusUrl: () => string | undefined; + + /** Get the datachannel URL for this connection. */ getDatachannelUrl: () => string | undefined; - disconnect: (options?: {code: number; reason: string}) => Promise; - disconnectAllLLM: (options?: {code: number; reason: string}) => Promise; - setOwnerMeetingId: (ownerMeetingId: string | undefined, sessionId?: string) => void; - getOwnerMeetingId: (sessionId?: string) => string | undefined; - resolveSessionOwnership: ( - ownerMeetingId?: string, - sessionId?: string - ) => { - currentOwner: string | undefined; - isOwner: boolean; - }; - getDatachannelToken: ( - tokenKey?: DataChannelTokenKey, - ownerMeetingId?: string - ) => string | undefined; - setDatachannelToken: ( - datachannelToken: string, - tokenKey?: DataChannelTokenKey, - ownerMeetingId?: string - ) => void; - clearDatachannelToken: (tokenKey: DataChannelTokenKey, ownerMeetingId: string) => void; + + /** Get the stored datachannel token. */ + getDatachannelToken: () => string | undefined; + + /** Store a datachannel token for this connection. */ + setDatachannelToken: (datachannelToken: string) => void; + + /** Clear the stored datachannel token. */ + clearDatachannelToken: () => void; + + /** Set the handler used to refresh the datachannel token. */ setRefreshHandler: ( handler: () => Promise<{ body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType}; - }>, - sessionId?: string, - ownerMeetingId?: string + }> ) => void; - refreshDataChannelToken: (sessionId?: string) => Promise<{ + + /** Refresh the datachannel token using the injected handler. */ + refreshDataChannelToken: () => Promise<{ body: {datachannelToken: string; datachannelTokenType: DataChannelTokenType}; } | null>; + + /** Disconnect the WebSocket and clean up state. */ + disconnect: (options?: {code: number; reason: string}) => Promise; + + /** Check if the datachannel token feature flag is enabled. */ + isDataChannelTokenEnabled: () => Promise; +} + +/** + * ILLMPlugin — interface for the LLM plugin factory. + * Accessed via `webex.internal.llm`. + */ +interface ILLMPlugin { + /** Create a new LLM connection instance. */ + createConnection: () => ILLMChannel; + + /** Check if the datachannel token feature flag is enabled globally. */ + isDataChannelTokenEnabled: () => Promise; + + /** Find a connection by matching a request URL to its datachannel URL. */ + getConnectionByDatachannelUrl: (url: string) => ILLMChannel | undefined; + + /** Find a locus URL by matching a request URL to an active connection. */ getLocusUrlByDatachannelUrl: (requestUrl: string) => string | undefined; - getSessionIdByDatachannelUrl: (requestUrl: string) => string | undefined; - getAllConnections: () => Map< - string, - { - webSocketUrl?: string; - binding?: string; - locusUrl?: string; - datachannelUrl?: string; - ownerMeetingId?: string; - } - >; + + /** Get all active connections. */ + getAllConnections: () => Set; + + /** Disconnect all active connections. */ + disconnectAll: (options?: {code: number; reason: string}) => Promise; } // eslint-disable-next-line import/prefer-default-export -export type {ILLMChannel, DataChannelTokenKey}; +export type {ILLMChannel, ILLMPlugin}; diff --git a/packages/@webex/internal-plugin-llm/test/unit/spec/llm-plugin.js b/packages/@webex/internal-plugin-llm/test/unit/spec/llm-plugin.js index 9560f04cbaf..a0d63ff69fd 100644 --- a/packages/@webex/internal-plugin-llm/test/unit/spec/llm-plugin.js +++ b/packages/@webex/internal-plugin-llm/test/unit/spec/llm-plugin.js @@ -3,31 +3,12 @@ import {assert} from '@webex/test-helper-chai'; import sinon from 'sinon'; import Mercury from '@webex/internal-plugin-mercury'; import {LLMPlugin} from '@webex/internal-plugin-llm/src/llm-plugin'; -import {LLM_DEFAULT_SESSION} from '@webex/internal-plugin-llm/src/constants'; +import LLMChannel from '@webex/internal-plugin-llm/src/llm'; describe('plugin-llm', () => { - describe('LLMPlugin', () => { + describe('LLMPlugin (Factory Pattern)', () => { let webex; let plugin; - let mockChannel; - - const createMockChannel = () => ({ - registerAndConnect: sinon.stub().resolves(), - disconnect: sinon.stub().resolves(), - isConnected: sinon.stub().returns(false), - getBinding: sinon.stub().returns('binding'), - getLocusUrl: sinon.stub().returns('locusUrl'), - getDatachannelUrl: sinon.stub().returns('datachannelUrl'), - getDatachannelToken: sinon.stub().returns('token'), - setDatachannelToken: sinon.stub(), - clearDatachannelToken: sinon.stub(), - setRefreshHandler: sinon.stub(), - refreshDataChannelToken: sinon.stub().resolves({body: {datachannelToken: 'newToken'}}), - socket: {send: sinon.stub()}, - ownerMeetingId: undefined, - hasEverConnected: false, - on: sinon.stub(), - }); beforeEach(() => { webex = new MockWebex({ @@ -41,360 +22,124 @@ describe('plugin-llm', () => { }; plugin = new LLMPlugin({}, {parent: webex}); - - mockChannel = createMockChannel(); }); afterEach(() => sinon.restore()); - describe('#disconnectLLM', () => { - it('calls disconnect and removes session from map', async () => { - // Create a session first - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - - const result = await plugin.disconnectLLM( - {code: 3000, reason: 'bye'}, - LLM_DEFAULT_SESSION, - 'meeting-1' - ); - - sinon.assert.calledOnceWithExactly(mockChannel.disconnect, {code: 3000, reason: 'bye'}); - assert.equal(plugin.sessions.has(LLM_DEFAULT_SESSION), false); - assert.equal(result, true); - }); - - it('resolves without error when session does not exist', async () => { - const result = await plugin.disconnectLLM( - {code: 1000, reason: 'test'}, - 'non-existent-session' - ); - - assert.equal(result, undefined); - }); - - it('skips disconnect if not the owner', async () => { - mockChannel.ownerMeetingId = 'meeting-1'; - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - - const result = await plugin.disconnectLLM( - {code: 1000, reason: 'test'}, - LLM_DEFAULT_SESSION, - 'meeting-2' - ); - - sinon.assert.notCalled(mockChannel.disconnect); - assert.equal(plugin.sessions.has(LLM_DEFAULT_SESSION), true); - assert.equal(result, false); - }); - - it('disconnects if owner matches', async () => { - mockChannel.ownerMeetingId = 'meeting-1'; - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - - const result = await plugin.disconnectLLM( - {code: 1000, reason: 'test'}, - LLM_DEFAULT_SESSION, - 'meeting-1' - ); - - sinon.assert.calledOnce(mockChannel.disconnect); - assert.equal(plugin.sessions.has(LLM_DEFAULT_SESSION), false); - assert.equal(result, true); - }); - - it('disconnects if no owner is set', async () => { - mockChannel.ownerMeetingId = undefined; - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - - const result = await plugin.disconnectLLM( - {code: 1000, reason: 'test'}, - LLM_DEFAULT_SESSION, - 'meeting-1' - ); - - sinon.assert.calledOnce(mockChannel.disconnect); - assert.equal(result, true); - }); - - it('supports legacy call with options only', async () => { - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - - await plugin.disconnectLLM({code: 1000, reason: 'test'}); - - sinon.assert.calledOnce(mockChannel.disconnect); - assert.equal(plugin.sessions.has(LLM_DEFAULT_SESSION), false); - }); - - it('supports legacy call with options and sessionId', async () => { - plugin.sessions.set('custom-session', mockChannel); - - await plugin.disconnectLLM({code: 1000, reason: 'test'}, 'custom-session'); - - sinon.assert.calledOnceWithExactly(mockChannel.disconnect, {code: 1000, reason: 'test'}); - assert.equal(plugin.sessions.has('custom-session'), false); - }); - }); - - describe('#disconnectAllLLM', () => { - it('disconnects and removes all sessions', async () => { - const channel1 = createMockChannel(); - const channel2 = createMockChannel(); - - plugin.sessions.set('session-1', channel1); - plugin.sessions.set('session-2', channel2); - - await plugin.disconnectAllLLM({code: 1000, reason: 'cleanup'}); - - sinon.assert.calledOnceWithExactly(channel1.disconnect, {code: 1000, reason: 'cleanup'}); - sinon.assert.calledOnceWithExactly(channel2.disconnect, {code: 1000, reason: 'cleanup'}); - assert.equal(plugin.sessions.size, 0); - }); - - it('works with no active sessions', async () => { - await plugin.disconnectAllLLM({code: 1000, reason: 'cleanup'}); - - assert.equal(plugin.sessions.size, 0); - }); - }); - - describe('#resolveSessionOwnership', () => { - it('returns isOwner true when no current owner', () => { - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - mockChannel.ownerMeetingId = undefined; - - const result = plugin.resolveSessionOwnership('meeting-1', LLM_DEFAULT_SESSION); - - assert.deepEqual(result, {currentOwner: undefined, isOwner: true}); - }); - - it('returns isOwner true when no ownerMeetingId provided', () => { - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - mockChannel.ownerMeetingId = 'meeting-1'; - - const result = plugin.resolveSessionOwnership(undefined, LLM_DEFAULT_SESSION); - - assert.deepEqual(result, {currentOwner: 'meeting-1', isOwner: true}); - }); - - it('returns isOwner true when owner matches', () => { - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - mockChannel.ownerMeetingId = 'meeting-1'; - - const result = plugin.resolveSessionOwnership('meeting-1', LLM_DEFAULT_SESSION); - - assert.deepEqual(result, {currentOwner: 'meeting-1', isOwner: true}); - }); - - it('returns isOwner false when owner does not match', () => { - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - mockChannel.ownerMeetingId = 'meeting-1'; - - const result = plugin.resolveSessionOwnership('meeting-2', LLM_DEFAULT_SESSION); - - assert.deepEqual(result, {currentOwner: 'meeting-1', isOwner: false}); - }); - }); - - describe('#registerAndConnect', () => { - it('creates session and calls registerAndConnect on channel', async () => { - // Stub the internal getOrCreateSession to return our mock - sinon.stub(plugin, 'getOrCreateSession').returns(mockChannel); - - await plugin.registerAndConnect('locusUrl', 'datachannelUrl', 'token', 'session-1'); - - sinon.assert.calledOnceWithExactly( - mockChannel.registerAndConnect, - 'locusUrl', - 'datachannelUrl', - 'token' - ); - }); - - it('uses default session when sessionId not provided', async () => { - sinon.stub(plugin, 'getOrCreateSession').returns(mockChannel); - - await plugin.registerAndConnect('locusUrl', 'datachannelUrl', 'token'); - - sinon.assert.calledOnceWithExactly(plugin.getOrCreateSession, LLM_DEFAULT_SESSION); - }); - }); - - describe('#isConnected', () => { - it('returns false when session does not exist', () => { - assert.equal(plugin.isConnected('non-existent'), false); - }); - - it('returns channel isConnected value', () => { - mockChannel.isConnected.returns(true); - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); + describe('#createConnection', () => { + it('creates a new LLMChannel instance', () => { + const channel = plugin.createConnection(); - assert.equal(plugin.isConnected(LLM_DEFAULT_SESSION), true); - }); - }); - - describe('#getBinding', () => { - it('returns undefined when session does not exist', () => { - assert.equal(plugin.getBinding('non-existent'), undefined); + assert.instanceOf(channel, LLMChannel); }); - it('returns channel binding', () => { - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); + it('adds the channel to the connections registry', () => { + const channel = plugin.createConnection(); - assert.equal(plugin.getBinding(LLM_DEFAULT_SESSION), 'binding'); - }); - }); + const connections = plugin.getAllConnections(); - describe('#getSocket', () => { - it('returns undefined when session does not exist', () => { - assert.equal(plugin.getSocket('non-existent'), undefined); + assert.equal(connections.size, 1); + assert.isTrue(connections.has(channel)); }); - it('returns channel socket', () => { - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); + it('creates independent channels for multiple calls', () => { + const channel1 = plugin.createConnection(); + const channel2 = plugin.createConnection(); - assert.equal(plugin.getSocket(LLM_DEFAULT_SESSION), mockChannel.socket); + assert.notStrictEqual(channel1, channel2); + assert.equal(plugin.getAllConnections().size, 2); }); - }); - describe('#socket (getter)', () => { - it('returns default session socket for backwards compatibility', () => { - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); + it('auto-unregisters channel when offline event fires', () => { + const channel = plugin.createConnection(); - assert.equal(plugin.socket, mockChannel.socket); - }); - }); - - describe('#setDatachannelToken', () => { - it('sets token when owner matches', () => { - mockChannel.ownerMeetingId = 'meeting-1'; - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); + assert.equal(plugin.getAllConnections().size, 1); - plugin.setDatachannelToken('newToken', 'meeting-1', LLM_DEFAULT_SESSION); + // Simulate offline event + channel.trigger('offline'); - sinon.assert.calledOnceWithExactly(mockChannel.setDatachannelToken, 'newToken'); - }); - - it('skips setting token when not owner', () => { - mockChannel.ownerMeetingId = 'meeting-1'; - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - - plugin.setDatachannelToken('newToken', 'meeting-2', LLM_DEFAULT_SESSION); - - sinon.assert.notCalled(mockChannel.setDatachannelToken); + assert.equal(plugin.getAllConnections().size, 0); }); }); - describe('#getDatachannelToken', () => { - it('returns token when owner matches', () => { - mockChannel.ownerMeetingId = 'meeting-1'; - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - - const token = plugin.getDatachannelToken(LLM_DEFAULT_SESSION, 'meeting-1'); - - assert.equal(token, 'token'); - }); - - it('returns undefined when not owner', () => { - mockChannel.ownerMeetingId = 'meeting-1'; - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - - const token = plugin.getDatachannelToken(LLM_DEFAULT_SESSION, 'meeting-2'); - - assert.equal(token, undefined); - }); - - it('returns undefined when session does not exist', () => { - const token = plugin.getDatachannelToken('non-existent'); - - assert.equal(token, undefined); - }); - }); - - describe('#clearDatachannelToken', () => { - it('clears token when owner matches', () => { - mockChannel.ownerMeetingId = 'meeting-1'; - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - - plugin.clearDatachannelToken(LLM_DEFAULT_SESSION, 'meeting-1'); + describe('#getAllConnections', () => { + it('returns an empty set when no connections exist', () => { + const connections = plugin.getAllConnections(); - sinon.assert.calledOnce(mockChannel.clearDatachannelToken); + assert.equal(connections.size, 0); }); - it('skips clearing when not owner', () => { - mockChannel.ownerMeetingId = 'meeting-1'; - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - - plugin.clearDatachannelToken(LLM_DEFAULT_SESSION, 'meeting-2'); + it('returns a copy of the connections set', () => { + const channel = plugin.createConnection(); + const connections = plugin.getAllConnections(); - sinon.assert.notCalled(mockChannel.clearDatachannelToken); + // Verify it's a copy, not the same reference + connections.delete(channel); + assert.equal(plugin.getAllConnections().size, 1); }); }); - describe('#setOwnerMeetingId / #getOwnerMeetingId', () => { - it('sets and gets owner meeting id', () => { - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - - plugin.setOwnerMeetingId('meeting-1', LLM_DEFAULT_SESSION); - - assert.equal(mockChannel.ownerMeetingId, 'meeting-1'); - assert.equal(plugin.getOwnerMeetingId(LLM_DEFAULT_SESSION), 'meeting-1'); - }); - - it('returns undefined when session does not exist', () => { - assert.equal(plugin.getOwnerMeetingId('non-existent'), undefined); - }); - }); + describe('#disconnectAll', () => { + it('disconnects all active connections', async () => { + const channel1 = plugin.createConnection(); + const channel2 = plugin.createConnection(); - describe('#hasEverConnected', () => { - it('returns false when no sessions exist', () => { - assert.equal(plugin.hasEverConnected, false); - }); + sinon.stub(channel1, 'disconnect').resolves(); + sinon.stub(channel2, 'disconnect').resolves(); - it('returns true when any session has connected', () => { - mockChannel.hasEverConnected = true; - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); + await plugin.disconnectAll({code: 1000, reason: 'cleanup'}); - assert.equal(plugin.hasEverConnected, true); + sinon.assert.calledOnceWithExactly(channel1.disconnect, {code: 1000, reason: 'cleanup'}); + sinon.assert.calledOnceWithExactly(channel2.disconnect, {code: 1000, reason: 'cleanup'}); }); - it('returns false when no session has connected', () => { - mockChannel.hasEverConnected = false; - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); + it('works with no active connections', async () => { + await plugin.disconnectAll({code: 1000, reason: 'cleanup'}); - assert.equal(plugin.hasEverConnected, false); + assert.equal(plugin.getAllConnections().size, 0); }); }); describe('#getConnectionByDatachannelUrl', () => { it('returns channel matching datachannel URL', () => { - mockChannel.getDatachannelUrl.returns( - 'https://board.wbx2.com/datachannel/api/v1/locus/123/registrations' - ); - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); + const channel = plugin.createConnection(); + + sinon + .stub(channel, 'getDatachannelUrl') + .returns('https://board.wbx2.com/datachannel/api/v1/locus/123/registrations'); const result = plugin.getConnectionByDatachannelUrl( 'https://board.wbx2.com/datachannel/api/v1/locus/123/registrations/events' ); - assert.equal(result, mockChannel); + assert.equal(result, channel); }); it('returns undefined when no match', () => { - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); + const channel = plugin.createConnection(); + + sinon.stub(channel, 'getDatachannelUrl').returns('https://board.wbx2.com/datachannel/123'); const result = plugin.getConnectionByDatachannelUrl('https://unknown.example.com'); assert.equal(result, undefined); }); + + it('returns undefined when no connections exist', () => { + const result = plugin.getConnectionByDatachannelUrl('https://example.com'); + + assert.equal(result, undefined); + }); }); describe('#getLocusUrlByDatachannelUrl', () => { it('returns locus URL for matching datachannel URL', () => { - mockChannel.getDatachannelUrl.returns( - 'https://board.wbx2.com/datachannel/api/v1/locus/123/registrations' - ); - mockChannel.getLocusUrl.returns('https://locus.example.com/123'); - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); + const channel = plugin.createConnection(); + + sinon + .stub(channel, 'getDatachannelUrl') + .returns('https://board.wbx2.com/datachannel/api/v1/locus/123/registrations'); + sinon.stub(channel, 'getLocusUrl').returns('https://locus.example.com/123'); const result = plugin.getLocusUrlByDatachannelUrl( 'https://board.wbx2.com/datachannel/api/v1/locus/123/registrations/events' @@ -402,117 +147,61 @@ describe('plugin-llm', () => { assert.equal(result, 'https://locus.example.com/123'); }); - }); - - describe('#getSessionIdByDatachannelUrl', () => { - it('returns sessionId for matching datachannel URL', () => { - mockChannel.getDatachannelUrl.returns( - 'https://board.wbx2.com/datachannel/api/v1/locus/123/registrations' - ); - plugin.sessions.set('my-session', mockChannel); - - const result = plugin.getSessionIdByDatachannelUrl( - 'https://board.wbx2.com/datachannel/api/v1/locus/123/registrations/events' - ); - - assert.equal(result, 'my-session'); - }); - }); - - describe('#getAllConnections', () => { - it('returns copy of sessions map', () => { - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - - const result = plugin.getAllConnections(); - - assert.equal(result.size, 1); - assert.equal(result.get(LLM_DEFAULT_SESSION), mockChannel); - assert.notStrictEqual(result, plugin.sessions); - }); - }); - - describe('#refreshDataChannelToken', () => { - it('calls channel refreshDataChannelToken', async () => { - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); - - const result = await plugin.refreshDataChannelToken(LLM_DEFAULT_SESSION); - - sinon.assert.calledOnce(mockChannel.refreshDataChannelToken); - assert.deepEqual(result, {body: {datachannelToken: 'newToken'}}); - }); - it('returns null when session does not exist', async () => { - const result = await plugin.refreshDataChannelToken('non-existent'); + it('returns undefined when no match', () => { + const result = plugin.getLocusUrlByDatachannelUrl('https://unknown.example.com'); - assert.equal(result, null); + assert.equal(result, undefined); }); }); - describe('#setRefreshHandler', () => { - it('sets handler when owner matches', () => { - const handler = sinon.stub(); - mockChannel.ownerMeetingId = 'meeting-1'; - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); + describe('#isDataChannelTokenEnabled', () => { + it('returns true when feature flag is enabled', async () => { + webex.internal.feature.getFeature.resolves(true); - plugin.setRefreshHandler(handler, 'meeting-1', LLM_DEFAULT_SESSION); + const result = await plugin.isDataChannelTokenEnabled(); - sinon.assert.calledOnceWithExactly(mockChannel.setRefreshHandler, handler); + assert.equal(result, true); + sinon.assert.calledOnceWithExactly( + webex.internal.feature.getFeature, + 'developer', + 'data-channel-with-jwt-token' + ); }); - it('skips setting handler when not owner', () => { - const handler = sinon.stub(); - mockChannel.ownerMeetingId = 'meeting-1'; - plugin.sessions.set(LLM_DEFAULT_SESSION, mockChannel); + it('returns false when feature flag is disabled', async () => { + webex.internal.feature.getFeature.resolves(false); - plugin.setRefreshHandler(handler, 'meeting-2', LLM_DEFAULT_SESSION); + const result = await plugin.isDataChannelTokenEnabled(); - sinon.assert.notCalled(mockChannel.setRefreshHandler); + assert.equal(result, false); }); }); - describe('event forwarding', () => { - let triggerSpy; + describe('connection lifecycle', () => { + it('channel remains in registry until offline', () => { + const channel = plugin.createConnection(); - beforeEach(() => { - triggerSpy = sinon.spy(plugin, 'trigger'); - }); - - it('forwards default session events without suffix', () => { - // Create a session via getOrCreateSession (creates real LLMChannel with event handler) - const channel = plugin.getOrCreateSession(LLM_DEFAULT_SESSION); - - // Trigger an event on the channel - the 'all' handler should forward it - channel.trigger('event:relay.event', {data: 'test'}); - - sinon.assert.calledOnceWithExactly(triggerSpy, 'event:relay.event', {data: 'test'}); - }); + // Simulate connection then disconnection + channel.trigger('online'); + assert.equal(plugin.getAllConnections().size, 1); - it('forwards non-default session events with sessionId suffix', () => { - const practiceSession = 'llm-practice-session'; - - // Create a session via getOrCreateSession (creates real LLMChannel with event handler) - const channel = plugin.getOrCreateSession(practiceSession); - - // Trigger an event on the channel - the 'all' handler should forward it with suffix - channel.trigger('event:relay.event', {data: 'test'}); - - sinon.assert.calledOnceWithExactly(triggerSpy, `event:relay.event:${practiceSession}`, { - data: 'test', - }); + channel.trigger('offline'); + assert.equal(plugin.getAllConnections().size, 0); }); - it('forwards locus events with sessionId suffix for practice session', () => { - const practiceSession = 'llm-practice-session'; + it('multiple channels can be managed independently', () => { + const channel1 = plugin.createConnection(); + const channel2 = plugin.createConnection(); - const channel = plugin.getOrCreateSession(practiceSession); + assert.equal(plugin.getAllConnections().size, 2); - channel.trigger('event:locus.state_message', {locusData: 'test'}); + // Disconnect only channel1 + channel1.trigger('offline'); - sinon.assert.calledOnceWithExactly( - triggerSpy, - `event:locus.state_message:${practiceSession}`, - {locusData: 'test'} - ); + assert.equal(plugin.getAllConnections().size, 1); + assert.isTrue(plugin.getAllConnections().has(channel2)); + assert.isFalse(plugin.getAllConnections().has(channel1)); }); }); }); diff --git a/packages/@webex/internal-plugin-llm/test/unit/spec/llm.js b/packages/@webex/internal-plugin-llm/test/unit/spec/llm.js index 13ec318ce78..2aade758fba 100644 --- a/packages/@webex/internal-plugin-llm/test/unit/spec/llm.js +++ b/packages/@webex/internal-plugin-llm/test/unit/spec/llm.js @@ -212,14 +212,12 @@ describe('plugin-llm', () => { it('calls super.disconnect and clears all connection state', async () => { await llmChannel.registerAndConnect(locusUrl, datachannelUrl); llmChannel.setDatachannelToken('token123'); - llmChannel.ownerMeetingId = 'meeting-1'; assert.equal(llmChannel.isConnected(), true); assert.equal(llmChannel.getLocusUrl(), locusUrl); assert.equal(llmChannel.getDatachannelUrl(), datachannelUrl); assert.equal(llmChannel.getBinding(), 'binding'); assert.equal(llmChannel.getDatachannelToken(), 'token123'); - assert.equal(llmChannel.ownerMeetingId, 'meeting-1'); await llmChannel.disconnect({code: 1000, reason: 'test'}); @@ -227,7 +225,6 @@ describe('plugin-llm', () => { assert.equal(llmChannel.getDatachannelUrl(), undefined); assert.equal(llmChannel.getBinding(), undefined); assert.equal(llmChannel.getDatachannelToken(), undefined); - assert.equal(llmChannel.ownerMeetingId, undefined); }); it('works without options', async () => { @@ -340,37 +337,6 @@ describe('plugin-llm', () => { }); }); - describe('#ownerMeetingId', () => { - it('stores and returns the owner meeting id', () => { - llmChannel.ownerMeetingId = 'meeting-1'; - - assert.equal(llmChannel.ownerMeetingId, 'meeting-1'); - }); - - it('returns undefined when no owner has been set', () => { - assert.equal(llmChannel.ownerMeetingId, undefined); - }); - - it('allows clearing ownership by setting undefined', () => { - llmChannel.ownerMeetingId = 'meeting-1'; - assert.equal(llmChannel.ownerMeetingId, 'meeting-1'); - - llmChannel.ownerMeetingId = undefined; - - assert.equal(llmChannel.ownerMeetingId, undefined); - }); - - it('is cleared by disconnect', async () => { - await llmChannel.registerAndConnect(locusUrl, datachannelUrl); - llmChannel.ownerMeetingId = 'meeting-1'; - assert.equal(llmChannel.ownerMeetingId, 'meeting-1'); - - await llmChannel.disconnect({code: 1000, reason: 'test'}); - - assert.equal(llmChannel.ownerMeetingId, undefined); - }); - }); - describe('.matchesDatachannelRequestUrl', () => { it('returns true when request URL starts with registration URL', () => { const registrationUrl = diff --git a/packages/@webex/internal-plugin-voicea/src/constants.ts b/packages/@webex/internal-plugin-voicea/src/constants.ts index 226b7c72aad..9916f5ab0c1 100644 --- a/packages/@webex/internal-plugin-voicea/src/constants.ts +++ b/packages/@webex/internal-plugin-voicea/src/constants.ts @@ -1,5 +1,3 @@ -export {LLM_PRACTICE_SESSION} from '@webex/internal-plugin-llm'; - export const EVENT_TRIGGERS = { VOICEA_ANNOUNCEMENT: 'voicea:announcement', CAPTION_LANGUAGE_UPDATE: 'voicea:captionLanguageUpdate', diff --git a/packages/@webex/internal-plugin-voicea/src/index.ts b/packages/@webex/internal-plugin-voicea/src/index.ts index ab6b0539c5f..9fb963594ad 100644 --- a/packages/@webex/internal-plugin-voicea/src/index.ts +++ b/packages/@webex/internal-plugin-voicea/src/index.ts @@ -1,10 +1,12 @@ import * as WebexCore from '@webex/webex-core'; -import VoiceaChannel from './voicea'; +import Voicea from './voicea-plugin'; +import {VoiceaChannel} from './voicea'; import type {MeetingTranscriptPayload} from './voicea.types'; -WebexCore.registerInternalPlugin('voicea', VoiceaChannel, {}); +WebexCore.registerInternalPlugin('voicea', Voicea, {}); -export {default} from './voicea'; +export {Voicea as VoiceaPlugin, VoiceaChannel}; +export default Voicea; export {type MeetingTranscriptPayload}; export {EVENT_TRIGGERS, TURN_ON_CAPTION_STATUS} from './constants'; diff --git a/packages/@webex/internal-plugin-voicea/src/voicea-plugin.ts b/packages/@webex/internal-plugin-voicea/src/voicea-plugin.ts new file mode 100644 index 00000000000..f2e4d2a2e9f --- /dev/null +++ b/packages/@webex/internal-plugin-voicea/src/voicea-plugin.ts @@ -0,0 +1,28 @@ +import {WebexPlugin} from '@webex/webex-core'; +import type LLMChannel from '@webex/internal-plugin-llm'; + +import {VoiceaChannel} from './voicea'; +import {VOICEA} from './constants'; + +/** + * VoiceaPlugin - factory for creating VoiceaChannel instances + * @export + * @class VoiceaPlugin + */ +export class VoiceaPlugin extends WebexPlugin { + namespace = VOICEA; + + /** + * Creates a VoiceaChannel bound to the given LLMChannel + * @param {LLMChannel} llmChannel - The LLM channel to use for voicea + * @returns {VoiceaChannel} A new VoiceaChannel instance + */ + public createChannel(llmChannel: LLMChannel): VoiceaChannel { + // @ts-ignore - webex is available on WebexPlugin + const channel = new VoiceaChannel(llmChannel, this.webex); + + return channel; + } +} + +export default VoiceaPlugin; diff --git a/packages/@webex/internal-plugin-voicea/src/voicea.ts b/packages/@webex/internal-plugin-voicea/src/voicea.ts index 2e1424dbfc5..3150c84c49a 100644 --- a/packages/@webex/internal-plugin-voicea/src/voicea.ts +++ b/packages/@webex/internal-plugin-voicea/src/voicea.ts @@ -1,19 +1,20 @@ +// @ts-ignore - events module types +import {EventEmitter} from 'events'; import uuid from 'uuid'; -import {WebexPlugin, config} from '@webex/webex-core'; +// @ts-ignore - webex-core types +import {config} from '@webex/webex-core'; +// @ts-ignore - internal-plugin-llm types +import type LLMChannel from '@webex/internal-plugin-llm'; import { EVENT_TRIGGERS, AIBRIDGE_RELAY_TYPES, TRANSCRIPTION_TYPE, - VOICEA, - LLM_PRACTICE_SESSION, ANNOUNCE_STATUS, TURN_ON_CAPTION_STATUS, TOGGLE_MANUAL_CAPTION_STATUS, DEFAULT_SPOKEN_LANGUAGE, - LANGUAGE_ASSIGNMENT, } from './constants'; -// eslint-disable-next-line no-unused-vars import { AnnouncementPayload, CaptionLanguageResponse, @@ -23,41 +24,59 @@ import { import {millisToMinutesAndSeconds} from './utils'; /** - * @description VoiceaChannel to hold single instance of LLM + * @description VoiceaChannel — handles voicea/transcription functionality for a single LLM connection. + * Created via `webex.internal.voicea.createChannel(llmChannel)`. The caller owns the + * channel and is responsible for its lifecycle. * @export * @class VoiceaChannel */ -export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { - namespace = VOICEA; +export class VoiceaChannel extends (EventEmitter as any) implements IVoiceaChannel { + private webex: any; + private llmChannel: LLMChannel; private seqNum: number; - private areCaptionsEnabled: boolean; - private hasSubscribedToEvents = false; - private captionServiceId?: string; - private announceStatus: string; - private captionStatus: string; - private isCaptionBoxOn: boolean; - private toggleManualCaptionStatus: string; - private currentSpokenLanguage?: string; - private spokenLanguages: string[] = []; - private currentCaptionLanguage?: string; /** - * @param {Object} e - * @returns {undefined} + * Creates a VoiceaChannel bound to the given LLMChannel + * @param {LLMChannel} llmChannel - The LLM channel to use + * @param {Object} webex - The webex instance for making requests */ + constructor(llmChannel: LLMChannel, webex: any) { + super(); + this.llmChannel = llmChannel; + this.webex = webex; + + this.seqNum = 1; + this.areCaptionsEnabled = false; + this.captionServiceId = undefined; + this.announceStatus = ANNOUNCE_STATUS.IDLE; + this.captionStatus = TURN_ON_CAPTION_STATUS.IDLE; + this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE; + this.currentSpokenLanguage = DEFAULT_SPOKEN_LANGUAGE; + this.currentCaptionLanguage = undefined; + this.isCaptionBoxOn = false; - private eventProcessor = (e) => { + // Subscribe to relay events from the LLM channel + this.llmChannel.on('event:relay.event', this.eventProcessor); + this.hasSubscribedToEvents = true; + } + + /** + * Process events from LLM channel + * @param {Object} e - Event data + * @returns {void} + */ + private eventProcessor = (e: any): void => { this.seqNum = e.sequenceNumber + 1; switch (e.data.relayType) { case AIBRIDGE_RELAY_TYPES.VOICEA.ANNOUNCEMENT: @@ -85,52 +104,23 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { }; /** - * Listen to websocket messages - * @returns {undefined} - */ - private listenToEvents() { - 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; - } - } - - /** - * Listen to websocket messages + * Deregister events and clean up * @returns {void} */ - public deregisterEvents() { + public deregisterEvents(): void { this.areCaptionsEnabled = false; this.isCaptionBoxOn = false; 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; - this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE; - this.currentSpokenLanguage = undefined; - this.currentCaptionLanguage = undefined; - } - /** - * Initializes Voicea plugin - * @param {any} args - */ - constructor(...args) { - super(...args); - this.seqNum = 1; - this.areCaptionsEnabled = false; - this.captionServiceId = undefined; + if (this.hasSubscribedToEvents) { + this.llmChannel.off('event:relay.event', this.eventProcessor); + this.hasSubscribedToEvents = false; + } + this.announceStatus = ANNOUNCE_STATUS.IDLE; this.captionStatus = TURN_ON_CAPTION_STATUS.IDLE; this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE; - this.currentSpokenLanguage = DEFAULT_SPOKEN_LANGUAGE; + this.currentSpokenLanguage = undefined; this.currentCaptionLanguage = undefined; } @@ -144,8 +134,7 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { transcriptPayload.type === TRANSCRIPTION_TYPE.MANUAL_CAPTION_FINAL_RESULT || transcriptPayload.type === TRANSCRIPTION_TYPE.MANUAL_CAPTION_INTERIM_RESULT ) { - // @ts-ignore - this.trigger(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, { + this.emit(EVENT_TRIGGERS.NEW_MANUAL_CAPTION, { isFinal: transcriptPayload.type === TRANSCRIPTION_TYPE.MANUAL_CAPTION_FINAL_RESULT, transcriptId: transcriptPayload.id, transcripts: transcriptPayload.transcripts, @@ -163,8 +152,7 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { private processTranscription = (voiceaPayload: TranscriptionResponse): void => { switch (voiceaPayload.type) { case TRANSCRIPTION_TYPE.TRANSCRIPT_INTERIM_RESULTS: - // @ts-ignore - this.trigger(EVENT_TRIGGERS.NEW_CAPTION, { + this.emit(EVENT_TRIGGERS.NEW_CAPTION, { isFinal: false, transcriptId: voiceaPayload.transcript_id, transcripts: voiceaPayload.transcripts, @@ -172,11 +160,10 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { break; case TRANSCRIPTION_TYPE.TRANSCRIPT_FINAL_RESULT: - // @ts-ignore - this.trigger(EVENT_TRIGGERS.NEW_CAPTION, { + this.emit(EVENT_TRIGGERS.NEW_CAPTION, { isFinal: true, transcriptId: voiceaPayload.transcript_id, - transcripts: voiceaPayload.transcripts.map((transcript) => { + transcripts: voiceaPayload.transcripts?.map((transcript) => { transcript.timestamp = millisToMinutesAndSeconds(transcript.end_millis); return transcript; @@ -185,20 +172,18 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { break; case TRANSCRIPTION_TYPE.HIGHLIGHT_CREATED: - // @ts-ignore - this.trigger(EVENT_TRIGGERS.HIGHLIGHT_CREATED, { - csis: voiceaPayload.highlight.csis, - highlightId: voiceaPayload.highlight.highlight_id, - text: voiceaPayload.highlight.transcript, - highlightLabel: voiceaPayload.highlight.highlight_label, - highlightSource: voiceaPayload.highlight.highlight_source, - timestamp: millisToMinutesAndSeconds(voiceaPayload.highlight.end_millis), + this.emit(EVENT_TRIGGERS.HIGHLIGHT_CREATED, { + csis: voiceaPayload.highlight?.csis, + highlightId: voiceaPayload.highlight?.highlight_id, + text: voiceaPayload.highlight?.transcript, + highlightLabel: voiceaPayload.highlight?.highlight_label, + highlightSource: voiceaPayload.highlight?.highlight_source, + timestamp: millisToMinutesAndSeconds(voiceaPayload.highlight?.end_millis ?? 0), }); break; case TRANSCRIPTION_TYPE.EVA_THANKS: - // @ts-ignore - this.trigger(EVENT_TRIGGERS.EVA_COMMAND, { + this.emit(EVENT_TRIGGERS.EVA_COMMAND, { isListening: false, text: voiceaPayload.command_response, }); @@ -206,22 +191,18 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { case TRANSCRIPTION_TYPE.EVA_WAKE: case TRANSCRIPTION_TYPE.EVA_CANCEL: - // @ts-ignore - this.trigger(EVENT_TRIGGERS.EVA_COMMAND, { + this.emit(EVENT_TRIGGERS.EVA_COMMAND, { isListening: voiceaPayload.type === TRANSCRIPTION_TYPE.EVA_WAKE, }); break; case TRANSCRIPTION_TYPE.LANGUAGE_DETECTED: { const isInSpokenLanguages = this.spokenLanguages.includes(voiceaPayload.language); - if (isInSpokenLanguages) { - // @ts-ignore - this.trigger(EVENT_TRIGGERS.LANGUAGE_DETECTED, { + this.emit(EVENT_TRIGGERS.LANGUAGE_DETECTED, { languageCode: voiceaPayload.language, }); } - break; } default: @@ -236,11 +217,9 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { */ private processCaptionLanguageResponse = (voiceaPayload: CaptionLanguageResponse): void => { if (voiceaPayload.statusCode === 200) { - // @ts-ignore - this.trigger(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, {statusCode: 200}); + this.emit(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, {statusCode: 200}); } else { - // @ts-ignore - this.trigger(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, { + this.emit(EVENT_TRIGGERS.CAPTION_LANGUAGE_UPDATE, { statusCode: voiceaPayload.errorCode, errorMessage: voiceaPayload.message, }); @@ -261,58 +240,32 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { }; this.spokenLanguages = voiceaPayload?.ASR?.spoken_languages ?? []; - // @ts-ignore - this.trigger(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, voiceaLanguageOptions); + this.emit(EVENT_TRIGGERS.VOICEA_ANNOUNCEMENT, voiceaLanguageOptions); }; /** - * Indicates whether the default or practice-session LLM connection is active. + * Indicates whether the LLM channel is connected. * @returns {boolean} */ - private isLLMConnected = (): boolean => - // @ts-ignore - this.webex.internal.llm.isConnected() || - // @ts-ignore - this.webex.internal.llm.isConnected(LLM_PRACTICE_SESSION); + public isLLMConnected = (): boolean => this.llmChannel.isConnected(); public getIsCaptionBoxOn = (): boolean => this.isCaptionBoxOn; - /** - * Resolves the active LLM publish transport, preferring the practice-session - * connection only when that session is fully connected. - * @returns {Object} - */ - private getPublishTransport = () => { - // @ts-ignore - const {llm} = this.webex.internal; - const isPracticeSessionConnected = llm.isConnected(LLM_PRACTICE_SESSION); - - return { - socket: (isPracticeSessionConnected && llm.getSocket(LLM_PRACTICE_SESSION)) || llm.socket, - binding: - (isPracticeSessionConnected && llm.getBinding(LLM_PRACTICE_SESSION)) || llm.getBinding(), - datachannelUrl: - (isPracticeSessionConnected && llm.getDatachannelUrl(LLM_PRACTICE_SESSION)) || - llm.getDatachannelUrl(), - }; - }; - /** * Sends Announcement to add voicea to the meeting * @returns {void} */ - private sendAnnouncement = (): void => { + public sendAnnouncement = (): void => { this.announceStatus = ANNOUNCE_STATUS.JOINING; - this.listenToEvents(); - const {socket, binding} = this.getPublishTransport(); - socket.send({ + const socket = this.llmChannel.getSocket(); + const binding = this.llmChannel.getBinding(); + + const payload = { id: `${this.seqNum}`, type: 'publishRequest', recipients: { - // @ts-ignore route: binding, }, - // If captionServiceId exists, send it as the 'to' header; otherwise keep headers empty. headers: this.captionServiceId ? {to: this.captionServiceId} : {}, data: { clientPayload: { @@ -322,7 +275,8 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { relayType: AIBRIDGE_RELAY_TYPES.VOICEA.CLIENT_ANNOUNCEMENT, }, trackingId: `${config.trackingIdPrefix}_${uuid.v4().toString()}`, - }); + }; + socket.send(payload); this.seqNum += 1; }; @@ -336,21 +290,20 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { languageCode: string, languageAssignment?: 'DEFAULT' | 'AUTO' | 'MANUAL' ): Promise => - // @ts-ignore - this.request({ - method: 'PUT', - // @ts-ignore - url: `${this.webex.internal.llm.getLocusUrl()}/controls/`, - body: { - transcribe: { - spokenLanguage: languageCode, - ...(languageAssignment && {languageAssignment}), + this.webex + .request({ + method: 'PUT', + url: `${this.llmChannel.getLocusUrl()}/controls/`, + body: { + transcribe: { + spokenLanguage: languageCode, + ...(languageAssignment && {languageAssignment}), + }, }, - }, - }).then(() => { - // @ts-ignore - this.trigger(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, {languageCode}); - }); + }) + .then(() => { + this.emit(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, {languageCode}); + }); /** * Request Language translation @@ -362,12 +315,13 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { return; } - const {socket, binding} = this.getPublishTransport(); + const socket = this.llmChannel.getSocket(); + const binding = this.llmChannel.getBinding(); + socket.send({ id: `${this.seqNum}`, type: 'publishRequest', recipients: { - // @ts-ignore route: binding, }, headers: { @@ -384,7 +338,6 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { trackingId: `${config.trackingIdPrefix}_${uuid.v4().toString()}`, }); this.currentCaptionLanguage = languageCode; - this.seqNum += 1; }; @@ -406,13 +359,13 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { return; } - const {socket, binding} = this.getPublishTransport(); + const socket = this.llmChannel.getSocket(); + const binding = this.llmChannel.getBinding(); socket?.send({ id: `${this.seqNum}`, type: 'publishRequest', recipients: { - // @ts-ignore route: binding, }, headers: {}, @@ -442,36 +395,34 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { /** * request turn on Captions - * @param {string} [languageCode] - Optional Parameter for spoken language code. Defaults to English + * @param {string} [languageCode] - Optional Parameter for spoken language code * @returns {Promise} */ - private requestTurnOnCaptions = (languageCode?): undefined | Promise => { + private requestTurnOnCaptions = (languageCode?: string): undefined | Promise => { this.captionStatus = TURN_ON_CAPTION_STATUS.SENDING; - // only set the spoken language if it is provided + const locusUrl = this.llmChannel.getLocusUrl(); + const body = { transcribe: {caption: true}, languageCode, }; - // @ts-ignore - // eslint-disable-next-line newline-before-return - return this.request({ - method: 'PUT', - // @ts-ignore - url: `${this.webex.internal.llm.getLocusUrl()}/controls/`, - body, - }) + return this.webex + .request({ + method: 'PUT', + url: `${locusUrl}/controls/`, + body, + }) .then(() => { - // @ts-ignore - this.trigger(EVENT_TRIGGERS.CAPTIONS_TURNED_ON); + this.emit(EVENT_TRIGGERS.CAPTIONS_TURNED_ON); this.areCaptionsEnabled = true; this.captionStatus = TURN_ON_CAPTION_STATUS.ENABLED; this.announce(); this.updateSubchannelSubscriptionsAndSyncCaptionState({subscribe: ['transcription']}, true); }) - .catch(() => { + .catch((error) => { this.captionStatus = TURN_ON_CAPTION_STATUS.IDLE; throw new Error('turn on captions fail'); }); @@ -481,20 +432,20 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { * is announce processing * @returns {boolean} */ - private isAnnounceProcessing = () => + public isAnnounceProcessing = (): boolean => [ANNOUNCE_STATUS.JOINING, ANNOUNCE_STATUS.JOINED].includes(this.announceStatus); /** * is announce processed * @returns {boolean} */ - private isAnnounceProcessed = () => this.announceStatus === ANNOUNCE_STATUS.JOINED; + public isAnnounceProcessed = (): boolean => this.announceStatus === ANNOUNCE_STATUS.JOINED; /** - * announce to voicea data chanel + * announce to voicea data channel * @returns {void} */ - public announce = () => { + public announce = (): void => { if (this.isAnnounceProcessed()) { return; } @@ -508,7 +459,7 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { * is turn on caption processing * @returns {boolean} */ - private isCaptionProcessing = () => + public isCaptionProcessing = (): boolean => [TURN_ON_CAPTION_STATUS.SENDING, TURN_ON_CAPTION_STATUS.ENABLED].includes(this.captionStatus); /** @@ -516,7 +467,7 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { * @param {string} [spokenLanguage] - Optional Spoken language code * @returns {Promise} */ - public turnOnCaptions = async (spokenLanguage?): undefined | Promise => { + public turnOnCaptions = async (spokenLanguage?: string): Promise => { if (this.captionStatus === TURN_ON_CAPTION_STATUS.SENDING) return undefined; if (!this.isLLMConnected()) { @@ -536,24 +487,24 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { activate: boolean, spokenLanguage?: string ): undefined | Promise => { - // @ts-ignore - return this.request({ - method: 'PUT', - // @ts-ignore - url: `${this.webex.internal.llm.getLocusUrl()}/controls/`, - body: { - transcribe: { - transcribing: activate, + return this.webex + .request({ + method: 'PUT', + url: `${this.llmChannel.getLocusUrl()}/controls/`, + body: { + transcribe: { + transcribing: activate, + }, + spokenLanguage, }, - spokenLanguage, - }, - }).then((): undefined | Promise => { - if (activate && !this.areCaptionsEnabled) { - return this.turnOnCaptions(spokenLanguage); - } + }) + .then((): undefined | Promise => { + if (activate && !this.areCaptionsEnabled) { + return this.turnOnCaptions(spokenLanguage); + } - return undefined; - }); + return undefined; + }); }; /** @@ -566,17 +517,16 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.SENDING; - // @ts-ignore - return this.request({ - method: 'PUT', - // @ts-ignore - url: `${this.webex.internal.llm.getLocusUrl()}/controls/`, - body: { - manualCaption: { - enable, + return this.webex + .request({ + method: 'PUT', + url: `${this.llmChannel.getLocusUrl()}/controls/`, + body: { + manualCaption: { + enable, + }, }, - }, - }) + }) .then((): undefined | Promise => { this.toggleManualCaptionStatus = TOGGLE_MANUAL_CAPTION_STATUS.IDLE; @@ -594,9 +544,8 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { * @param {string} meetingId * @returns {void} */ - public onSpokenLanguageUpdate = (languageCode: string, meetingId): void => { - // @ts-ignore - this.trigger(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, {languageCode, meetingId}); + public onSpokenLanguageUpdate = (languageCode: string, meetingId: string): void => { + this.emit(EVENT_TRIGGERS.SPOKEN_LANGUAGE_UPDATE, {languageCode, meetingId}); this.currentSpokenLanguage = languageCode; }; @@ -611,7 +560,6 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { } if (this.captionServiceId !== serviceId) { this.captionServiceId = serviceId; - // if service id value has changed and the translation language has been set, client needs to resend the translator language message to the LLM. if (this.currentCaptionLanguage) { this.requestLanguage(this.currentCaptionLanguage); } @@ -622,19 +570,17 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { * get caption status * @returns {string} */ - public getCaptionStatus = () => this.captionStatus; + public getCaptionStatus = (): string => this.captionStatus; /** * get announce status * @returns {string} */ - public getAnnounceStatus = () => this.announceStatus; + public getAnnounceStatus = (): string => this.announceStatus; + /** * update LLM sub‑channel subscriptions. * - * sends a single `subchannelSubscriptionRequest` to LLM, - * allowing subscribe and unsubscribe subchannel. - * * @param {string[]} options.subscribe Sub‑channels to subscribe to. * @param {string[]} options.unsubscribe Sub‑channels to unsubscribe from. * @returns {Promise} @@ -646,19 +592,18 @@ export class VoiceaChannel extends WebexPlugin implements IVoiceaChannel { subscribe?: string[]; unsubscribe?: string[]; } = {}): Promise => { - // @ts-ignore - const isDataChannelTokenEnabled = await this.webex.internal.llm.isDataChannelTokenEnabled(); - // @ts-ignore - if (!this.isLLMConnected() || !isDataChannelTokenEnabled) return; + if (!this.isLLMConnected()) return; + + const isDataChannelTokenEnabled = await this.llmChannel.isDataChannelTokenEnabled(); + if (!isDataChannelTokenEnabled) return; - const {socket, datachannelUrl} = this.getPublishTransport(); + const socket = this.llmChannel.getSocket(); + const datachannelUrl = this.llmChannel.getDatachannelUrl(); - // @ts-ignore socket.send({ id: `${this.seqNum}`, type: 'subchannelSubscriptionRequest', data: { - // @ts-ignore datachannelUri: datachannelUrl, subscribe, unsubscribe, 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 70dbc483ad0..990d53b2acb 100644 --- a/packages/@webex/internal-plugin-voicea/test/unit/spec/voicea.js +++ b/packages/@webex/internal-plugin-voicea/test/unit/spec/voicea.js @@ -7,17 +7,50 @@ import Mercury from '@webex/internal-plugin-mercury'; import LLMChannel from '@webex/internal-plugin-llm'; import VoiceaService from '../../../src/index'; -import { - EVENT_TRIGGERS, - LLM_PRACTICE_SESSION, - TOGGLE_MANUAL_CAPTION_STATUS, -} from '../../../src/constants'; +import {EVENT_TRIGGERS, TOGGLE_MANUAL_CAPTION_STATUS} from '../../../src/constants'; + +/** + * Creates a mock LLM channel for testing + * @param {Object} [options] - Options for the mock channel + * @param {boolean} [options.isConnected=true] - Whether the channel is connected + * @param {string} [options.locusUrl] - The locus URL + * @returns {Object} Mock channel + */ +function createMockChannel(options = {}) { + const mockWebSocket = new MockWebSocket(); + const {isConnected = true, locusUrl = 'locusUrl'} = options; + + return { + isConnected: sinon.stub().returns(isConnected), + getSocket: sinon.stub().returns(mockWebSocket), + getBinding: sinon.stub().returns(undefined), + getDatachannelUrl: sinon.stub().returns('datachannelUrl'), + getLocusUrl: sinon.stub().returns(locusUrl), + isDataChannelTokenEnabled: sinon.stub().resolves(true), + on: sinon.stub(), + off: sinon.stub(), + socket: mockWebSocket, + }; +} + +/** + * Emits a relay event to voiceaService by calling the registered handler + * @param {Object} voiceaService - The voicea service instance + * @param {Object} mockChannel - The mock channel that has the handler registered + * @param {Object} eventData - The event data to emit + */ +function emitRelayEvent(voiceaService, mockChannel, eventData) { + const handler = mockChannel.on.getCalls().find(call => call.args[0] === 'event:relay.event')?.args[1]; + if (handler) { + handler({sequenceNumber: 1, ...eventData}); + } +} describe('plugin-voicea', () => { const locusUrl = 'locusUrl'; describe('voicea', () => { - let webex, voiceaService; + let webex, voiceaService, mockChannel; beforeEach(() => { webex = new MockWebex({ @@ -30,10 +63,10 @@ describe('plugin-voicea', () => { voiceaService = webex.internal.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); + + // Create and register a mock channel + mockChannel = createMockChannel({locusUrl}); + voiceaService.registerChannel(mockChannel, 'default'); voiceaService.request = sinon.stub().resolves({ headers: {}, @@ -47,6 +80,10 @@ describe('plugin-voicea', () => { }); }); + afterEach(() => { + voiceaService.deregisterEvents(); + }); + describe("#constructor", () => { it('should init status', () => { assert.equal(voiceaService.announceStatus, 'idle'); @@ -56,10 +93,7 @@ describe('plugin-voicea', () => { describe('#sendAnnouncement', () => { beforeEach(async () => { - const mockWebSocket = new MockWebSocket(); - - voiceaService.webex.internal.llm.socket = mockWebSocket; - voiceaService.announceStatus = "idle"; + voiceaService.announceStatus = 'idle'; }); it("sends announcement if voicea hasn't joined", () => { @@ -69,7 +103,7 @@ describe('plugin-voicea', () => { assert.equal(voiceaService.announceStatus, 'joining'); assert.calledOnce(spy); - assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, { + assert.calledOnceWithExactly(mockChannel.socket.send, { id: '1', type: 'publishRequest', recipients: {route: undefined}, @@ -85,28 +119,27 @@ describe('plugin-voicea', () => { }); }); - it('listens to events once', () => { - const spy = sinon.spy(webex.internal.llm, 'on'); + it('listens to events once via registerChannel', () => { + // Channel events are registered when registerChannel is called + // The handler should be set up already from beforeEach + assert.calledWith(mockChannel.on, 'event:relay.event', sinon.match.func); + // Calling sendAnnouncement should not register more handlers + const callCount = mockChannel.on.callCount; voiceaService.sendAnnouncement(); - voiceaService.sendAnnouncement(); - assert.calledTwice(spy); - assert.calledWith(spy, 'event:relay.event', sinon.match.func); - assert.calledWith(spy, `event:relay.event:${LLM_PRACTICE_SESSION}`, sinon.match.func); + // No additional registrations + assert.equal(mockChannel.on.callCount, callCount); }); it('includes captionServiceId in headers when set', () => { - const mockWebSocket = new MockWebSocket(); - - voiceaService.webex.internal.llm.socket = mockWebSocket; voiceaService.announceStatus = 'idle'; voiceaService.captionServiceId = 'svc-123'; voiceaService.sendAnnouncement(); - assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, { + assert.calledOnceWithExactly(mockChannel.socket.send, { id: '1', type: 'publishRequest', recipients: {route: undefined}, @@ -125,8 +158,6 @@ describe('plugin-voicea', () => { describe('#sendManualClosedCaption', () => { beforeEach(async () => { - const mockWebSocket = new MockWebSocket(); - voiceaService.webex.internal.llm.socket = mockWebSocket; voiceaService.seqNum = 1; }); @@ -138,33 +169,30 @@ describe('plugin-voicea', () => { voiceaService.sendManualClosedCaption(text, timeStamp, csis, isFinal); - assert.calledOnceWithExactly( - voiceaService.webex.internal.llm.socket.send, - { - id: '1', - type: 'publishRequest', - recipients: {route: undefined}, - headers: {}, - data: { - eventType: 'relay.event', - relayType: 'client.manual_transcription', - transcriptPayload: { - type: 'manual_caption_interim_result', - id: sinon.match.string, - transcripts: [ - { - text: 'Test interim caption', - start_millis: 1234567890, - end_millis: 1234567890, - csis: [123456], - }, - ], - transcript_id: sinon.match.string, - }, + assert.calledOnceWithExactly(mockChannel.socket.send, { + id: '1', + type: 'publishRequest', + recipients: {route: undefined}, + headers: {}, + data: { + eventType: 'relay.event', + relayType: 'client.manual_transcription', + transcriptPayload: { + type: 'manual_caption_interim_result', + id: sinon.match.string, + transcripts: [ + { + text: 'Test interim caption', + start_millis: 1234567890, + end_millis: 1234567890, + csis: [123456], + }, + ], + transcript_id: sinon.match.string, }, - trackingId: sinon.match.string, - } - ); + }, + trackingId: sinon.match.string, + }); // seqNum should increment assert.equal(voiceaService.seqNum, 2); }); @@ -177,39 +205,39 @@ describe('plugin-voicea', () => { voiceaService.sendManualClosedCaption(text, timeStamp, csis, isFinal); - assert.calledOnceWithExactly( - voiceaService.webex.internal.llm.socket.send, - { - id: '1', - type: 'publishRequest', - recipients: {route: undefined}, - headers: {}, - data: { - eventType: 'relay.event', - relayType: 'client.manual_transcription', - transcriptPayload: { - type: 'manual_caption_final_result', - id: sinon.match.string, - transcripts: [ - { - text: 'Test final caption', - start_millis: 9876543210, - end_millis: 9876543210, - csis: [654321], - }, - ], - transcript_id: sinon.match.string, - }, + assert.calledOnceWithExactly(mockChannel.socket.send, { + id: '1', + type: 'publishRequest', + recipients: {route: undefined}, + headers: {}, + data: { + eventType: 'relay.event', + relayType: 'client.manual_transcription', + transcriptPayload: { + type: 'manual_caption_final_result', + id: sinon.match.string, + transcripts: [ + { + text: 'Test final caption', + start_millis: 9876543210, + end_millis: 9876543210, + csis: [654321], + }, + ], + transcript_id: sinon.match.string, }, - trackingId: sinon.match.string, - } - ); + }, + trackingId: sinon.match.string, + }); // seqNum should increment assert.equal(voiceaService.seqNum, 2); }); it('does not send if not connected', () => { - voiceaService.webex.internal.llm.isConnected.returns(false); + // Replace the channel with a disconnected one + const disconnectedChannel = createMockChannel({isConnected: false}); + voiceaService.unregisterChannel('default'); + voiceaService.registerChannel(disconnectedChannel, 'default'); const text = 'Should not send'; const timeStamp = 111; @@ -218,24 +246,30 @@ describe('plugin-voicea', () => { voiceaService.sendManualClosedCaption(text, timeStamp, csis, isFinal); - assert.notCalled(voiceaService.webex.internal.llm.socket.send); + assert.notCalled(disconnectedChannel.socket.send); }); }); describe('#deregisterEvents', () => { beforeEach(async () => { - const mockWebSocket = new MockWebSocket(); - voiceaService.webex.internal.llm.socket = mockWebSocket; voiceaService.isCaptionBoxOn = true; }); it('deregisters voicea service and resets caption state', async () => { - voiceaService.listenToEvents(); + // Simulate receiving an event through the registered handler + const handler = mockChannel.on + .getCalls() + .find((call) => call.args[0] === 'event:relay.event')?.args[1]; + await voiceaService.toggleTranscribing(true); - voiceaService.webex.internal.llm._emit('event:relay.event', { - headers: {from: 'ws'}, - data: {relayType: 'voicea.annc', voiceaPayload: {}}, - }); + // Call the handler directly to simulate receiving an event + if (handler) { + handler({ + sequenceNumber: 1, + headers: {from: 'ws'}, + data: {relayType: 'voicea.annc', voiceaPayload: {}}, + }); + } assert.equal(voiceaService.areCaptionsEnabled, true); assert.equal(voiceaService.captionServiceId, 'ws'); @@ -293,16 +327,10 @@ describe('plugin-voicea', () => { }); describe('#requestLanguage', () => { - beforeEach(async () => { - const mockWebSocket = new MockWebSocket(); - - voiceaService.webex.internal.llm.socket = mockWebSocket; - }); - it('requests caption language', () => { voiceaService.requestLanguage('en'); - assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, { + assert.calledOnceWithExactly(mockChannel.socket.send, { id: '1', type: 'publishRequest', recipients: {route: undefined}, @@ -324,7 +352,7 @@ describe('plugin-voicea', () => { voiceaService.requestLanguage('fr'); - assert.calledOnceWithExactly(voiceaService.webex.internal.llm.socket.send, { + assert.calledOnceWithExactly(mockChannel.socket.send, { id: '1', type: 'publishRequest', recipients: {route: undefined}, @@ -469,24 +497,27 @@ describe('plugin-voicea', () => { }); describe('#isLLMConnected', () => { - it('returns true when the default llm connection is connected', () => { - voiceaService.webex.internal.llm.isConnected.callsFake((channel) => - channel === LLM_PRACTICE_SESSION ? false : true - ); - + it('returns true when the default channel is connected', () => { + // mockChannel is already registered and connected assert.equal(voiceaService.isLLMConnected(), true); }); - it('returns true when only the practice session llm connection is connected', () => { - voiceaService.webex.internal.llm.isConnected.callsFake((channel) => - channel === LLM_PRACTICE_SESSION - ); + it('returns true when only the practice session channel is connected', () => { + // Replace default with disconnected, add connected practice session + const disconnectedDefault = createMockChannel({isConnected: false}); + const connectedPractice = createMockChannel({isConnected: true}); + + voiceaService.unregisterChannel('default'); + voiceaService.registerChannel(disconnectedDefault, 'default'); + voiceaService.registerChannel(connectedPractice, 'practice-session'); assert.equal(voiceaService.isLLMConnected(), true); }); - it('returns false when neither llm connection is connected', () => { - voiceaService.webex.internal.llm.isConnected.returns(false); + it('returns false when neither channel is connected', () => { + const disconnectedChannel = createMockChannel({isConnected: false}); + voiceaService.unregisterChannel('default'); + voiceaService.registerChannel(disconnectedChannel, 'default'); assert.equal(voiceaService.isLLMConnected(), false); }); @@ -532,15 +563,21 @@ describe('plugin-voicea', () => { }); it('announce to llm data channel before llm connected', ()=> { - voiceaService.webex.internal.llm.isConnected.returns(false); + const disconnectedChannel = createMockChannel({isConnected: false}); + voiceaService.unregisterChannel('default'); + voiceaService.registerChannel(disconnectedChannel, 'default'); + assert.throws(() => voiceaService.announce(), "voicea can not announce before llm connected"); assert.notCalled(sendAnnouncement); }); it('announce to llm data channel when only practice session is connected', ()=> { - voiceaService.webex.internal.llm.isConnected.callsFake((channel) => - channel === LLM_PRACTICE_SESSION - ); + const disconnectedDefault = createMockChannel({isConnected: false}); + const connectedPractice = createMockChannel({isConnected: true}); + + voiceaService.unregisterChannel('default'); + voiceaService.registerChannel(disconnectedDefault, 'default'); + voiceaService.registerChannel(connectedPractice, 'practice-session'); voiceaService.announce(); @@ -592,7 +629,9 @@ describe('plugin-voicea', () => { it('throws before turning on captions when llm is not connected', async () => { voiceaService.captionStatus = 'idle'; - voiceaService.webex.internal.llm.isConnected.returns(false); + const disconnectedChannel = createMockChannel({isConnected: false}); + voiceaService.unregisterChannel('default'); + voiceaService.registerChannel(disconnectedChannel, 'default'); await assert.isRejected( voiceaService.turnOnCaptions(), @@ -602,9 +641,12 @@ describe('plugin-voicea', () => { }); it('turns on captions when only the practice session llm connection is connected', () => { - voiceaService.webex.internal.llm.isConnected.callsFake((channel) => - channel === LLM_PRACTICE_SESSION - ); + const disconnectedDefault = createMockChannel({isConnected: false}); + const connectedPractice = createMockChannel({isConnected: true}); + + voiceaService.unregisterChannel('default'); + voiceaService.registerChannel(disconnectedDefault, 'default'); + voiceaService.registerChannel(connectedPractice, 'practice-session'); voiceaService.turnOnCaptions(); diff --git a/packages/@webex/plugin-meetings/src/annotation/index.ts b/packages/@webex/plugin-meetings/src/annotation/index.ts index 25d5cb77488..7f89b22f0aa 100644 --- a/packages/@webex/plugin-meetings/src/annotation/index.ts +++ b/packages/@webex/plugin-meetings/src/annotation/index.ts @@ -1,6 +1,7 @@ import uuid from 'uuid'; // eslint-disable-next-line import/no-extraneous-dependencies import {WebexPlugin, config} from '@webex/webex-core'; +import type LLMChannel from '@webex/internal-plugin-llm'; import TriggerProxy from '../common/events/trigger-proxy'; import { @@ -13,7 +14,9 @@ import { } from './constants'; import {StrokeData, RequestData, IAnnotationChannel, CommandRequestBody} from './annotation.types'; -import {HTTP_VERBS, LOCUSEVENT, LLM_PRACTICE_SESSION} from '../constants'; +import {HTTP_VERBS, LOCUSEVENT} from '../constants'; + +type ChannelType = 'default' | 'practice-session'; /** * @description Annotation to handle LLM and Mercury message and locus API @@ -24,11 +27,17 @@ class AnnotationChannel extends WebexPlugin implements IAnnotationChannel { private seqNum: number; - hasSubscribedToEvents: boolean; + hasSubscribedToEvents!: boolean; + + approvalUrl!: string; + locusUrl!: string; + deviceUrl!: string; + + /** Registered LLM channels by type */ + private channels: Map = new Map(); - approvalUrl: string; - locusUrl: string; - deviceUrl: string; + /** Event handlers bound to each channel, for cleanup */ + private channelHandlers: Map void> = new Map(); /** * Initializes annotation module @@ -38,6 +47,67 @@ class AnnotationChannel extends WebexPlugin implements IAnnotationChannel { this.seqNum = 1; } + /** + * Register an LLMChannel with annotation + * @param {LLMChannel} channel - The LLM channel to register + * @param {ChannelType} type - 'default' or 'practice-session' + * @returns {void} + */ + public registerChannel(channel: LLMChannel, type: ChannelType): void { + // Unregister existing channel of this type first + if (this.channels.has(type)) { + this.unregisterChannel(type); + } + + this.channels.set(type, channel); + + // Subscribe to relay events from this channel + const handler = this.eventDataProcessor.bind(this); + this.channelHandlers.set(type, handler); + channel.on('event:relay.event', handler); + } + + /** + * Unregister an LLMChannel from annotation + * @param {ChannelType} type - 'default' or 'practice-session' + * @returns {void} + */ + public unregisterChannel(type: ChannelType): void { + const channel = this.channels.get(type); + const handler = this.channelHandlers.get(type); + + if (channel && handler) { + channel.off('event:relay.event', handler); + } + + this.channels.delete(type); + this.channelHandlers.delete(type); + } + + /** + * Get the active LLM channel (prefers practice session if connected) + * @returns {LLMChannel | undefined} + */ + private getActiveChannel(): LLMChannel | undefined { + const practiceChannel = this.channels.get('practice-session'); + if (practiceChannel?.isConnected()) { + return practiceChannel; + } + + return this.channels.get('default'); + } + + /** + * Indicates whether any registered LLM channel is connected. + * @returns {boolean} + */ + private isLLMConnected(): boolean { + const defaultChannel = this.channels.get('default'); + const practiceChannel = this.channels.get('practice-session'); + + return defaultChannel?.isConnected() || practiceChannel?.isConnected() || false; + } + /** * Process Stroke Data * @param {object} data @@ -104,6 +174,7 @@ class AnnotationChannel extends WebexPlugin implements IAnnotationChannel { /** * Listen to websocket messages + * @deprecated LLM event subscription is now handled by registerChannel() * @returns {undefined} */ private listenToEvents() { @@ -114,14 +185,7 @@ class AnnotationChannel extends WebexPlugin implements IAnnotationChannel { this.eventCommandProcessor, this ); - // @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 - ); + // LLM event subscription is now handled by registerChannel() this.hasSubscribedToEvents = true; } } @@ -138,13 +202,11 @@ class AnnotationChannel extends WebexPlugin implements IAnnotationChannel { this.eventCommandProcessor ); - // @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 - ); + // Unregister all LLM channels + for (const type of this.channels.keys()) { + this.unregisterChannel(type); + } + this.hasSubscribedToEvents = false; } } @@ -300,8 +362,7 @@ class AnnotationChannel extends WebexPlugin implements IAnnotationChannel { * @returns {void} */ public sendStrokeData = (strokeData: StrokeData): void => { - // @ts-ignore - if (!this.webex.internal.llm.isConnected()) return; + if (!this.isLLMConnected()) return; this.encryptContent(strokeData.encryptionKeyUrl, strokeData.content).then( (encryptedContent) => { this.publishEncrypted(encryptedContent, strokeData); @@ -316,13 +377,13 @@ class AnnotationChannel extends WebexPlugin implements IAnnotationChannel { * @returns {void} */ private publishEncrypted(encryptedContent: string, strokeData: StrokeData) { - // @ts-ignore - const {llm} = this.webex.internal; - const isPracticeSessionConnected = llm.isConnected(LLM_PRACTICE_SESSION); - const socket = - (isPracticeSessionConnected && llm.getSocket(LLM_PRACTICE_SESSION)) || llm.socket; - const binding = - (isPracticeSessionConnected && llm.getBinding(LLM_PRACTICE_SESSION)) || llm.getBinding(); + const channel = this.getActiveChannel(); + if (!channel) return; + + const socket = channel.getSocket(); + const binding = channel.getBinding(); + if (!socket || !binding) return; + const data = { id: `${this.seqNum}`, type: 'publishRequest', diff --git a/packages/@webex/plugin-meetings/src/breakouts/index.ts b/packages/@webex/plugin-meetings/src/breakouts/index.ts index e1063c69da3..2b91556be94 100644 --- a/packages/@webex/plugin-meetings/src/breakouts/index.ts +++ b/packages/@webex/plugin-meetings/src/breakouts/index.ts @@ -3,6 +3,7 @@ */ import {WebexPlugin} from '@webex/webex-core'; import {debounce, forEach} from 'lodash'; +import type LLMChannel from '@webex/internal-plugin-llm'; import LoggerProxy from '../common/logs/logger-proxy'; import {BREAKOUTS, MEETINGS, HTTP_VERBS, _ID_} from '../constants'; @@ -253,16 +254,44 @@ const Breakouts = WebexPlugin.extend({ ); }, + /** + * Registers the LLM channel for broadcast messages + * @param {LLMChannel} channel - The LLM channel to use for breakout messages + * @returns {void} + */ + registerLLMChannel(channel: LLMChannel) { + // If already subscribed to a previous channel, clean it up + if (this.hasSubscribedToMessage && this._llmChannel) { + this.stopListening(this._llmChannel); + this.hasSubscribedToMessage = false; + } + + this._llmChannel = channel; + this.listenToBroadcastMessages(); + }, + + /** + * Unregisters the LLM channel + * @returns {void} + */ + unregisterLLMChannel() { + if (this._llmChannel) { + this.stopListening(this._llmChannel); + this._llmChannel = undefined; + this.hasSubscribedToMessage = false; + } + }, + /** * Sets up listener for broadcast messages sent to the breakout session * @returns {void} */ listenToBroadcastMessages() { - if (!this.webex.internal.llm.isConnected() || this.hasSubscribedToMessage) { + if (!this._llmChannel?.isConnected() || this.hasSubscribedToMessage) { return; } - this.listenTo(this.webex.internal.llm, 'event:breakout.message', (event) => { + this.listenTo(this._llmChannel, 'event:breakout.message', (event) => { const { data: {senderUserId, sentTime, message}, } = event; diff --git a/packages/@webex/plugin-meetings/src/interceptors/dataChannelAuthToken.ts b/packages/@webex/plugin-meetings/src/interceptors/dataChannelAuthToken.ts index 9f5064a5a36..dc5febe7a60 100644 --- a/packages/@webex/plugin-meetings/src/interceptors/dataChannelAuthToken.ts +++ b/packages/@webex/plugin-meetings/src/interceptors/dataChannelAuthToken.ts @@ -3,11 +3,12 @@ */ import {Interceptor} from '@webex/http-core'; + +// @ts-ignore - internal-plugin-llm types import LLMChannel from '@webex/internal-plugin-llm'; import LoggerProxy from '../common/logs/logger-proxy'; import {DATA_CHANNEL_AUTH_HEADER, MAX_RETRY, RETRY_INTERVAL, RETRY_KEY} from './constant'; import {isJwtTokenExpired} from './utils'; -import {LLM_DEFAULT_SESSION, LLM_PRACTICE_SESSION, LOCUS_URL} from '../constants'; const retryCountMap = new Map(); interface HttpLikeError extends Error { @@ -40,93 +41,64 @@ export default class DataChannelAuthTokenInterceptor extends Interceptor { return this.internal.llm.isDataChannelTokenEnabled(); }, - // Route refresh by request URL in two steps: - // 1) Match active LLM sessions (supports non-default/multiple sessions) - // 2) If no session matches, resolve locusUrl and refresh via owning meeting - // If neither route matches, fall back to the default-session refresh. + // Route refresh by request URL: + // 1) Find the LLM channel that matches the request URL + // 2) If found, use its refresh handler + // 3) If no channel matches, fall back to finding the meeting by locusUrl refreshDataChannelToken: async (requestUrl?: string) => { - let sessionId; - let meeting; - - if (typeof requestUrl === 'string') { - // @ts-ignore - sessionId = this.internal.llm.getSessionIdByDatachannelUrl?.(requestUrl); + // @ts-ignore + const channel = this.internal.llm.getConnectionByDatachannelUrl?.(requestUrl); - if (!sessionId) { - // @ts-ignore - const locusUrl = this.internal.llm.getLocusUrlByDatachannelUrl?.(requestUrl); + if (channel) { + // Channel found - use its refresh handler + const result = await channel.refreshDataChannelToken(); - if (locusUrl) { - // @ts-ignore - meeting = this.meetings?.getMeetingByType?.(LOCUS_URL, locusUrl); - } + if (!result?.body) { + throw new Error('DataChannel token refresh returned no payload'); } - if (!meeting) { - // Fallback: registerAndConnect() pre-populates datachannelUrl in - // connections before calling register(), so getSessionIdByDatachannelUrl - // above should normally resolve. This scan covers any residual gap - // where connections are not yet populated (e.g. if setRefreshHandler - // is called on a session that has no connection entry at all). - // @ts-ignore - const allMeetings = this.meetings?.getAllMeetings?.() || {}; - - meeting = Object.values(allMeetings).find((activeMeeting: any) => { - const info = activeMeeting?.locusInfo?.info || {}; - - return ( - (info.practiceSessionDatachannelUrl && - LLMChannel.matchesDatachannelRequestUrl( - requestUrl, - info.practiceSessionDatachannelUrl - )) || - (info.datachannelUrl && - LLMChannel.matchesDatachannelRequestUrl(requestUrl, info.datachannelUrl)) - ); - }); - - if (!sessionId) { - // @ts-ignore - const info = meeting?.locusInfo?.info || {}; - - if ( - info.practiceSessionDatachannelUrl && - LLMChannel.matchesDatachannelRequestUrl( - requestUrl, - info.practiceSessionDatachannelUrl - ) - ) { - sessionId = LLM_PRACTICE_SESSION; - } else if ( - info.datachannelUrl && - LLMChannel.matchesDatachannelRequestUrl(requestUrl, info.datachannelUrl) - ) { - sessionId = LLM_DEFAULT_SESSION; - } - } - } + const {datachannelToken} = result.body; + channel.setDatachannelToken(datachannelToken); + + return datachannelToken; } - let result; + // No channel found - fallback to finding meeting by matching datachannel URLs + // @ts-ignore + const allMeetings = this.meetings?.getAllMeetings?.() || {}; + + const meeting = Object.values(allMeetings).find((activeMeeting: any) => { + const info = activeMeeting?.locusInfo?.info || {}; + + return ( + (info.practiceSessionDatachannelUrl && + LLMChannel.matchesDatachannelRequestUrl( + requestUrl, + info.practiceSessionDatachannelUrl + )) || + (info.datachannelUrl && + LLMChannel.matchesDatachannelRequestUrl(requestUrl, info.datachannelUrl)) + ); + }); + if (meeting) { - result = await meeting.refreshDataChannelToken(); - } else { - // @ts-ignore - result = await this.internal.llm.refreshDataChannelToken(sessionId); - } + const result = await (meeting as any).refreshDataChannelToken(); + + if (!result?.body) { + throw new Error('DataChannel token refresh returned no payload'); + } + + const {datachannelToken} = result.body; - if (!result?.body) { - throw new Error('DataChannel token refresh returned no payload'); + // Store token on the meeting's LLM channel if available + if ((meeting as any).llmChannel) { + (meeting as any).llmChannel.setDatachannelToken(datachannelToken); + } + + return datachannelToken; } - const {datachannelToken, dataChannelTokenType} = result.body; - const tokenStoreKey = dataChannelTokenType || sessionId; - const ownerMeetingId = - // @ts-ignore - meeting?.id || this.internal.llm.getOwnerMeetingId?.(tokenStoreKey); - // @ts-ignore - this.internal.llm.setDatachannelToken(datachannelToken, ownerMeetingId, tokenStoreKey); - return datachannelToken; + throw new Error('No LLM channel or meeting found for request URL'); }, }); } diff --git a/packages/@webex/plugin-meetings/src/meeting/index.ts b/packages/@webex/plugin-meetings/src/meeting/index.ts index 52e3d003613..f263b59d1d9 100644 --- a/packages/@webex/plugin-meetings/src/meeting/index.ts +++ b/packages/@webex/plugin-meetings/src/meeting/index.ts @@ -34,6 +34,7 @@ import { } from '@webex/internal-media-core'; import {DataChannelTokenType} from '@webex/internal-plugin-llm'; +import type LLMChannel from '@webex/internal-plugin-llm'; import { LocalStream, @@ -50,6 +51,7 @@ import { EVENT_TRIGGERS as VOICEAEVENTS, TURN_ON_CAPTION_STATUS, type MeetingTranscriptPayload, + type VoiceaChannel, } from '@webex/internal-plugin-voicea'; import { @@ -136,8 +138,6 @@ import { STAGE_MANAGER_TYPE, LOCUSEVENT, LOCUS_LLM_EVENT, - LLM_DEFAULT_SESSION, - LLM_PRACTICE_SESSION, } from '../constants'; import BEHAVIORAL_METRICS from '../metrics/constants'; import ParameterError from '../common/errors/parameter'; @@ -812,6 +812,27 @@ export default class Meeting extends StatelessWebexPlugin { private mediaServerIp: string; private llmHealthCheckTimer?: ReturnType; + /** + * The LLM channel owned by this meeting for datachannel communication. + * Created in updateLLMConnection, cleaned up in cleanupLLMConneciton. + */ + private llmChannel?: LLMChannel; + + /** + * The Voicea channel for transcription/captions, bound to this meeting's LLM channel. + */ + voiceaChannel?: VoiceaChannel; + + /** + * Pending datachannel token saved from join response, used when creating the LLM channel. + */ + private _pendingDatachannelToken?: string; + + /** + * Pending practice session datachannel token, passed to webinar for its LLM channel. + */ + private _pendingPracticeSessionDatachannelToken?: string; + /** * @param {Object} attrs * @param {Object} options @@ -2536,29 +2557,28 @@ export default class Meeting extends StatelessWebexPlugin { * @memberof Meeting */ private setUpVoiceaListeners() { - // @ts-ignore - this.webex.internal.voicea.listenToEvents(); + if (!this.voiceaChannel) { + LoggerProxy.logger.warn('Meeting:index#setUpVoiceaListeners --> voiceaChannel not available'); - // @ts-ignore - this.webex.internal.voicea.on( + return; + } + + this.voiceaChannel.on( VOICEAEVENTS.VOICEA_ANNOUNCEMENT, this.voiceaListenerCallbacks[VOICEAEVENTS.VOICEA_ANNOUNCEMENT] ); - // @ts-ignore - this.webex.internal.voicea.on( + this.voiceaChannel.on( VOICEAEVENTS.CAPTIONS_TURNED_ON, this.voiceaListenerCallbacks[VOICEAEVENTS.CAPTIONS_TURNED_ON] ); - // @ts-ignore - this.webex.internal.voicea.on( + this.voiceaChannel.on( VOICEAEVENTS.EVA_COMMAND, this.voiceaListenerCallbacks[VOICEAEVENTS.EVA_COMMAND] ); - // @ts-ignore - this.webex.internal.voicea.on( + this.voiceaChannel.on( VOICEAEVENTS.NEW_CAPTION, this.voiceaListenerCallbacks[VOICEAEVENTS.NEW_CAPTION] ); @@ -2927,8 +2947,7 @@ export default class Meeting extends StatelessWebexPlugin { if (this.transcription?.languageOptions) { this.transcription.languageOptions.currentSpokenLanguage = spokenLanguage; } - // @ts-ignore - this.webex.internal.voicea.onSpokenLanguageUpdate(spokenLanguage, this.id); + this.voiceaChannel?.onSpokenLanguageUpdate(spokenLanguage, this.id); Trigger.trigger( this, @@ -2957,8 +2976,7 @@ export default class Meeting extends StatelessWebexPlugin { this.locusInfo.on(LOCUSINFO.EVENTS.CONTROLS_MEETING_HESIOD_LLM_ID_UPDATED, ({hesiodLlmId}) => { if (hesiodLlmId) { - // @ts-ignore - this.webex.internal.voicea.onCaptionServiceIdUpdate(hesiodLlmId); + this.voiceaChannel?.onCaptionServiceIdUpdate(hesiodLlmId); } }); @@ -5889,8 +5907,7 @@ export default class Meeting extends StatelessWebexPlugin { try { const voiceaListenerCaptionUpdate = (payload) => { - // @ts-ignore - this.webex.internal.voicea.off( + this.voiceaChannel?.off( VOICEAEVENTS.CAPTION_LANGUAGE_UPDATE, voiceaListenerCaptionUpdate ); @@ -5906,13 +5923,8 @@ export default class Meeting extends StatelessWebexPlugin { reject(payload); } }; - // @ts-ignore - this.webex.internal.voicea.on( - VOICEAEVENTS.CAPTION_LANGUAGE_UPDATE, - voiceaListenerCaptionUpdate - ); - // @ts-ignore - this.webex.internal.voicea.requestLanguage(language); + this.voiceaChannel?.on(VOICEAEVENTS.CAPTION_LANGUAGE_UPDATE, voiceaListenerCaptionUpdate); + this.voiceaChannel?.requestLanguage(language); } catch (error) { LoggerProxy.logger.error(`Meeting:index#setCaptionLanguage --> ${error}`); @@ -5946,8 +5958,7 @@ export default class Meeting extends StatelessWebexPlugin { try { const voiceaListenerLanguageUpdate = (payload) => { - // @ts-ignore - this.webex.internal.voicea.off( + this.voiceaChannel?.off( VOICEAEVENTS.SPOKEN_LANGUAGE_UPDATE, voiceaListenerLanguageUpdate ); @@ -5964,14 +5975,9 @@ export default class Meeting extends StatelessWebexPlugin { } }; - // @ts-ignore - this.webex.internal.voicea.on( - VOICEAEVENTS.SPOKEN_LANGUAGE_UPDATE, - voiceaListenerLanguageUpdate - ); + this.voiceaChannel?.on(VOICEAEVENTS.SPOKEN_LANGUAGE_UPDATE, voiceaListenerLanguageUpdate); - // @ts-ignore - this.webex.internal.voicea.setSpokenLanguage(language); + this.voiceaChannel?.setSpokenLanguage(language); } catch (error) { LoggerProxy.logger.error(`Meeting:index#setSpokenLanguage --> ${error}`); @@ -5993,12 +5999,26 @@ export default class Meeting extends StatelessWebexPlugin { ); try { + if (!this.voiceaChannel) { + LoggerProxy.logger.warn( + 'Meeting:index#startTranscription --> voiceaChannel not available' + ); + + return; + } + if (!this.areVoiceaEventsSetup) { this.setUpVoiceaListeners(); } + await this.voiceaChannel.turnOnCaptions(options?.spokenLanguage); - // @ts-ignore - await this.webex.internal.voicea.turnOnCaptions(options?.spokenLanguage); + // Also enable captions on practice session channel if in practice session + if (this.webinar?.practiceSessionVoiceaChannel) { + LoggerProxy.logger.info( + 'Meeting:index#startTranscription --> Also enabling captions on practice session channel' + ); + await this.webinar.practiceSessionVoiceaChannel.turnOnCaptions(options?.spokenLanguage); + } } catch (error) { LoggerProxy.logger.error(`Meeting:index#startTranscription --> ${error}`); Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.RECEIVE_TRANSCRIPTION_FAILURE, { @@ -6037,6 +6057,8 @@ export default class Meeting extends StatelessWebexPlugin { /** * Verifies the relay event was delivered for the active LLM session binding. + * With the factory pattern, each meeting owns its channel, so we just verify + * the route matches our channel's binding or the practice session binding. * @param {RelayEvent} event Event object coming from LLM Connection * @returns {boolean} */ @@ -6047,16 +6069,19 @@ export default class Meeting extends StatelessWebexPlugin { return true; } - const {llm} = (this as any).webex.internal; - const isPracticeSession = llm.isConnected(LLM_PRACTICE_SESSION); - const expectedBinding = isPracticeSession - ? llm.getBinding(LLM_PRACTICE_SESSION) - : llm.getBinding(); + const expectedBinding = this.llmChannel?.getBinding(); if (!expectedBinding || route === expectedBinding) { return true; } + // Also check practice session binding for webinars + const practiceSessionBinding = this.webinar?.practiceSessionLLMChannel?.getBinding(); + + if (practiceSessionBinding && route === practiceSessionBinding) { + return true; + } + return false; } @@ -6116,32 +6141,27 @@ export default class Meeting extends StatelessWebexPlugin { * @returns {void} */ stopTranscription() { - // @ts-ignore - this.webex.internal.voicea.off( + this.voiceaChannel?.off( VOICEAEVENTS.VOICEA_ANNOUNCEMENT, this.voiceaListenerCallbacks[VOICEAEVENTS.VOICEA_ANNOUNCEMENT] ); - // @ts-ignore - this.webex.internal.voicea.off( + this.voiceaChannel?.off( VOICEAEVENTS.CAPTIONS_TURNED_ON, this.voiceaListenerCallbacks[VOICEAEVENTS.CAPTIONS_TURNED_ON] ); - // @ts-ignore - this.webex.internal.voicea.off( + this.voiceaChannel?.off( VOICEAEVENTS.EVA_COMMAND, this.voiceaListenerCallbacks[VOICEAEVENTS.EVA_COMMAND] ); - // @ts-ignore - this.webex.internal.voicea.off( + this.voiceaChannel?.off( VOICEAEVENTS.NEW_CAPTION, this.voiceaListenerCallbacks[VOICEAEVENTS.NEW_CAPTION] ); - // @ts-ignore - this.webex.internal.voicea.deregisterEvents(); + this.voiceaChannel?.deregisterEvents(); this.areVoiceaEventsSetup = false; this.triggerStopReceivingTranscriptionEvent(); @@ -6174,15 +6194,13 @@ export default class Meeting extends StatelessWebexPlugin { */ private restoreLLMSubscriptionsIfNeeded(): void { try { - // @ts-ignore - const isCaptionBoxOn = this.webex.internal.voicea?.getIsCaptionBoxOn?.(); + const isCaptionBoxOn = this.voiceaChannel?.getIsCaptionBoxOn?.(); if (!isCaptionBoxOn) { return; } - // @ts-ignore - this.webex.internal.voicea.updateSubchannelSubscriptions({subscribe: ['transcription']}); + this.voiceaChannel?.updateSubchannelSubscriptions({subscribe: ['transcription']}); } catch (error) { const msg = error?.message || String(error); @@ -6433,10 +6451,6 @@ export default class Meeting extends StatelessWebexPlugin { this.saveDataChannelToken(join); // @ts-ignore - config coming from registerPlugin if (this.config.enableAutomaticLLM) { - // @ts-ignore - this.webex.internal.llm.off('online', this.handleLLMOnline); - // @ts-ignore - this.webex.internal.llm.on('online', this.handleLLMOnline); this.updateLLMConnection() .catch((error) => { LoggerProxy.logger.error( @@ -6471,12 +6485,10 @@ export default class Meeting extends StatelessWebexPlugin { this.clearLLMHealthCheckTimer(); this.llmHealthCheckTimer = setTimeout(() => { - // @ts-ignore - const isConnected = this.webex.internal.llm.isConnected(); + const isConnected = this.llmChannel?.isConnected() ?? false; if (!isConnected) { - // @ts-ignore - const {hasEverConnected} = this.webex.internal.llm; + const hasEverConnected = this.llmChannel?.hasEverConnected ?? false; // only send metric if not connected - to avoid too many metrics Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.LLM_HEALTHCHECK_FAILURE, { @@ -6511,10 +6523,12 @@ export default class Meeting extends StatelessWebexPlugin { * @returns {void} */ private stopListeningForLLMEvents() { - // @ts-ignore - fix types - this.webex.internal.llm.off('event:relay.event', this.processRelayEvent); - // @ts-ignore - fix types - this.webex.internal.llm.off(LOCUS_LLM_EVENT, this.processLocusLLMEvent); + this.llmChannel?.off('event:relay.event', this.processRelayEvent); + this.llmChannel?.off(LOCUS_LLM_EVENT, this.processLocusLLMEvent); + this.llmChannel?.off('online', this.handleLLMOnline); + this.breakouts?.unregisterLLMChannel(); + this.voiceaChannel?.deregisterEvents(); + this.voiceaChannel = undefined; this.clearLLMHealthCheckTimer(); } @@ -6545,51 +6559,32 @@ export default class Meeting extends StatelessWebexPlugin { } /** - * Disconnects and cleans up the default LLM session listeners/timers. - * - * Ownership-aware: only calls `disconnectLLM` when this meeting is the - * current owner of the default LLM session (or when no owner is recorded). - * Event listeners belonging to this meeting instance are always detached - * so they do not receive another meeting's relay events. + * Disconnects and cleans up the LLM channel owned by this meeting. * * @param {Object} options - * @param {boolean} [options.removeOnlineListener=true] removes the one-time online listener * @param {boolean} [options.throwOnError=true] rethrows disconnect errors when true * @returns {Promise} */ private cleanupLLMConneciton = async ({ - removeOnlineListener = true, throwOnError = true, }: { - removeOnlineListener?: boolean; throwOnError?: boolean; } = {}): Promise => { - // @ts-ignore - Fix type - // @ts-ignore - Fix type - const {currentOwner, isOwner} = this.webex.internal.llm.resolveSessionOwnership( - this.id, - LLM_DEFAULT_SESSION - ); + // Always clear the timer, even if there's no channel + this.clearLLMHealthCheckTimer(); + + if (!this.llmChannel) { + return; + } try { - if (isOwner) { - // @ts-ignore - Fix type - await this.webex.internal.llm.disconnectLLM( - { - code: 3050, - reason: 'done (permanent)', - }, - LLM_DEFAULT_SESSION, - this.id - ); - } else { - LoggerProxy.logger.info( - `Meeting:index#cleanupLLMConneciton --> skipping disconnect; LLM owned by meeting ${currentOwner}, not ${this.id}` - ); - } + await this.llmChannel.disconnect({ + code: 3050, + reason: 'done (permanent)', + }); } catch (error) { LoggerProxy.logger.error( - 'Meeting:index#cleanupLLMConneciton --> Failed to disconnect default LLM session', + 'Meeting:index#cleanupLLMConneciton --> Failed to disconnect LLM channel', error ); @@ -6597,42 +6592,15 @@ export default class Meeting extends StatelessWebexPlugin { throw error; } } finally { - if (removeOnlineListener) { - // @ts-ignore - Fix type - this.webex.internal.llm.off('online', this.handleLLMOnline); - } this.stopListeningForLLMEvents(); - - // Re-check ownership after awaiting disconnectLLM. If ownership changed - // while cleanup was in flight, do not clear another meeting's owner tag. - if (isOwner) { - const {currentOwner: currentOwnerAfterCleanup} = - // @ts-ignore - Fix type - this.webex.internal.llm.resolveSessionOwnership(this.id, LLM_DEFAULT_SESSION); - - if (currentOwnerAfterCleanup === this.id) { - // @ts-ignore - Fix type - this.webex.internal.llm.setOwnerMeetingId?.(undefined); - } - } + this.llmChannel = undefined; } }; /** - * Clears data channel tokens associated with this meeting ownership. - * Ownership checks are enforced in internal-plugin-llm. - * @returns {void} - */ - clearDataChannelToken(): void { - // @ts-ignore - this.webex.internal.llm.clearDatachannelToken(LLM_DEFAULT_SESSION, this.id); - // @ts-ignore - this.webex.internal.llm.clearDatachannelToken(LLM_PRACTICE_SESSION, this.id); - } - - /** - * Saves the data channel tokens from the join response into LLM so that - * updateLLMConnection / updatePSDataChannel don't need to fetch them from locusInfo. + * Saves the data channel tokens from the join response. + * Default session token is stored on this.llmChannel when available. + * Practice session token is stored on the webinar's practice LLM channel. * @param {Object} join - The parsed join response (from MeetingUtil.parseLocusJoin) * @returns {void} */ @@ -6640,18 +6608,19 @@ export default class Meeting extends StatelessWebexPlugin { const datachannelToken = join?.locus?.self?.datachannelToken; const practiceSessionDatachannelToken = join?.locus?.self?.practiceSessionDatachannelToken; + // Store default session token on our LLM channel if it exists + if (datachannelToken && this.llmChannel) { + this.llmChannel.setDatachannelToken(datachannelToken); + } + + // Store the token temporarily for use when channel is created if (datachannelToken) { - // @ts-ignore - this.webex.internal.llm.setDatachannelToken(datachannelToken, this.id, LLM_DEFAULT_SESSION); + this._pendingDatachannelToken = datachannelToken; } + // Practice session token is handled by webinar's practice LLM channel if (practiceSessionDatachannelToken) { - // @ts-ignore - this.webex.internal.llm.setDatachannelToken( - practiceSessionDatachannelToken, - this.id, - LLM_PRACTICE_SESSION - ); + this._pendingPracticeSessionDatachannelToken = practiceSessionDatachannelToken; } } @@ -6662,11 +6631,8 @@ export default class Meeting extends StatelessWebexPlugin { */ private async ensureDefaultDatachannelTokenAfterAdmit(): Promise { try { - // @ts-ignore - const datachannelToken = this.webex.internal.llm.getDatachannelToken( - LLM_DEFAULT_SESSION, - this.id - ); + const datachannelToken = + this.llmChannel?.getDatachannelToken() ?? this._pendingDatachannelToken; // @ts-ignore const isDataChannelTokenEnabled = await this.webex.internal.llm.isDataChannelTokenEnabled(); @@ -6685,12 +6651,12 @@ export default class Meeting extends StatelessWebexPlugin { return false; } - // @ts-ignore - this.webex.internal.llm.setDatachannelToken( - fetchedDatachannelToken, - this.id, - LLM_DEFAULT_SESSION - ); + // Store on channel if exists, otherwise save as pending + if (this.llmChannel) { + this.llmChannel.setDatachannelToken(fetchedDatachannelToken); + } else { + this._pendingDatachannelToken = fetchedDatachannelToken; + } return true; } catch (error) { @@ -6715,133 +6681,71 @@ export default class Meeting extends StatelessWebexPlugin { const {url = undefined, info: {datachannelUrl = undefined} = {}} = this.locusInfo || {}; const isJoined = this.isJoined(); - const dataChannelUrl = datachannelUrl; - // Ownership guard: when the default LLM session is already connected and - // owned by a *different* Meeting instance, do not disconnect or reconfigure - // it. Another meeting's `updateLLMConnection` must be ignored here to - // avoid killing the socket it relies on. We only proceed to manage the - // connection when this meeting is the current owner, or when no owner is - // set yet (first claim). - // @ts-ignore - Fix type - const {currentOwner} = this.webex.internal.llm.resolveSessionOwnership( - this.id, - LLM_DEFAULT_SESSION - ); - - // Capture connectivity before any reconnect attempt. If LLM was already - // connected, we must respect current ownership. If it was disconnected, - // this flow may reclaim stale owner tags after a fresh connect. - // @ts-ignore - Fix type - const wasConnected = this.webex.internal.llm.isConnected(); - - // Prefer ownership-scoped token read. For disconnected stale-owner reclaim - // flows, fallback to ownerless read so initial register can still carry a - // token and recover from stale ownership without 401/403 dead-end. - // @ts-ignore - Fix type - let datachannelToken = this.webex.internal.llm.getDatachannelToken( - LLM_DEFAULT_SESSION, - this.id - ); - - if (!datachannelToken && !wasConnected && currentOwner && currentOwner !== this.id) { - // @ts-ignore - Fix type - datachannelToken = this.webex.internal.llm.getDatachannelToken(LLM_DEFAULT_SESSION); - } - - // @ts-ignore - Fix type - if (wasConnected) { - if (currentOwner && currentOwner !== this.id) { - // Another meeting owns the live LLM socket. We must not disconnect - // or reconfigure it -- doing so would tear down a session the - // owning meeting still relies on. Locus/datachannel URL mismatch is - // expected here (each meeting has its own locus URL) and is NOT a - // valid signal of staleness, so we never reclaim from this path. - // The only safe reclaim mechanism is the `finally`-block owner-tag - // release in `cleanupLLMConneciton`, which fires when this meeting - // itself is being torn down. - LoggerProxy.logger.info( - `Meeting:index#updateLLMConnection --> skipping; LLM owned by meeting ${currentOwner}, not ${this.id}` - ); + // If we have an existing channel, check if we should reuse it or clean it up + if (this.llmChannel) { + const isSameUrls = + url === this.llmChannel.getLocusUrl() && + dataChannelUrl === this.llmChannel.getDatachannelUrl(); + // If already connected to same URLs, skip reconnect + if (this.llmChannel.isConnected() && isSameUrls && isJoined) { return undefined; } - if ( - // @ts-ignore - Fix type - url === this.webex.internal.llm.getLocusUrl() && - // @ts-ignore - Fix type - dataChannelUrl === this.webex.internal.llm.getDatachannelUrl() && - isJoined - ) { + // If currently connecting to same URLs, wait for that to complete + if (this.llmChannel.isConnecting() && isSameUrls && isJoined) { return undefined; } - await this.cleanupLLMConneciton({removeOnlineListener: false}); + + // URLs changed or not joined, disconnect existing channel + await this.cleanupLLMConneciton(); } if (!isJoined) { return undefined; } - // Bind refresh handler before registration so interceptor-triggered token - // refresh during register POST can resolve a valid handler. - // Prefer this meeting as owner, but allow owner-less fallback when a stale - // foreign owner tag is present on a disconnected session. - const refreshHandlerOwnerMeetingId = - currentOwner && currentOwner !== this.id ? undefined : this.id; - const shouldAlignRefreshHandlerAfterOwnershipClaim = refreshHandlerOwnerMeetingId !== this.id; + // Create a new LLM channel for this meeting // @ts-ignore - Fix type - this.webex.internal.llm.setRefreshHandler( - () => this.refreshDataChannelToken(), - refreshHandlerOwnerMeetingId, - LLM_DEFAULT_SESSION - ); + this.llmChannel = this.webex.internal.llm.createConnection(); - // @ts-ignore - Fix type - return this.webex.internal.llm + // Get token from pending (saved from join response) or channel + const datachannelToken = this._pendingDatachannelToken ?? this.llmChannel.getDatachannelToken(); + + // Set up refresh handler before registration so interceptor-triggered token + // refresh during register POST can resolve a valid handler. + this.llmChannel.setRefreshHandler(() => this.refreshDataChannelToken()); + + // If we have a pending token, store it on the channel + if (this._pendingDatachannelToken) { + this.llmChannel.setDatachannelToken(this._pendingDatachannelToken); + this._pendingDatachannelToken = undefined; + } + + return this.llmChannel .registerAndConnect(url, dataChannelUrl, datachannelToken) .then((registerAndConnectResult) => { this.locusInfo.syncAllHashTreeDatasets({onlyLLM: true}); - // Record ownership of the default LLM session for this meeting so - // subsequent cross-meeting `updateLLMConnection` / `cleanupLLMConneciton` - // calls can detect and skip work that doesn't belong to them. - // @ts-ignore - Fix type - const {isOwner} = this.webex.internal.llm.resolveSessionOwnership( - this.id, - LLM_DEFAULT_SESSION - ); - const canReclaimAfterDisconnectedStart = !wasConnected; - - // Refresh handler is pre-bound before registerAndConnect so token - // refresh can work even during the registration request itself. - if (isOwner || canReclaimAfterDisconnectedStart) { - // Record ownership of the default LLM session for this meeting so - // subsequent cross-meeting `updateLLMConnection` / `cleanupLLMConneciton` - // calls can detect and skip work that doesn't belong to them. - // @ts-ignore - Fix type - this.webex.internal.llm.setOwnerMeetingId?.(this.id); - - // If we pre-bound refresh ownerlessly (stale-owner reclaim path), - // align the handler with the newly claimed owner immediately after - // ownership is updated. - if (shouldAlignRefreshHandlerAfterOwnershipClaim) { - // @ts-ignore - Fix type - this.webex.internal.llm.setRefreshHandler( - () => this.refreshDataChannelToken(), - this.id, - LLM_DEFAULT_SESSION - ); - } - } - // @ts-ignore - Fix type - this.webex.internal.llm.off('event:relay.event', this.processRelayEvent); - // @ts-ignore - Fix type - this.webex.internal.llm.on('event:relay.event', this.processRelayEvent); - // @ts-ignore - Fix type - this.webex.internal.llm.off(LOCUS_LLM_EVENT, this.processLocusLLMEvent); + + // Register event listeners on the channel + this.llmChannel.off('event:relay.event', this.processRelayEvent); + this.llmChannel.on('event:relay.event', this.processRelayEvent); + this.llmChannel.off(LOCUS_LLM_EVENT, this.processLocusLLMEvent); + this.llmChannel.on(LOCUS_LLM_EVENT, this.processLocusLLMEvent); + this.llmChannel.off('online', this.handleLLMOnline); + this.llmChannel.on('online', this.handleLLMOnline); + + // Register annotation channel + this.annotation.registerChannel(this.llmChannel, 'default'); + + // Register breakouts channel + this.breakouts.registerLLMChannel(this.llmChannel); + + // Create VoiceaChannel for this meeting // @ts-ignore - Fix type - this.webex.internal.llm.on(LOCUS_LLM_EVENT, this.processLocusLLMEvent); + this.voiceaChannel = this.webex.internal.voicea.createChannel(this.llmChannel); LoggerProxy.logger.info( 'Meeting:index#updateLLMConnection --> enabled to receive relay events!' ); @@ -6850,6 +6754,8 @@ export default class Meeting extends StatelessWebexPlugin { return Promise.resolve(registerAndConnectResult); }) .catch((error) => { + // Clean up the channel on failure + this.llmChannel = undefined; throw error; }); } @@ -10153,12 +10059,8 @@ export default class Meeting extends StatelessWebexPlugin { // Listener teardown (transcription, annotation, llm/mercury) runs in // stopListeningForMeetingEvents() before /leave and /end so events - // received mid-teardown do not trigger Locus syncs. Calling it here - // again would double-emit MEETING_STOPPED_RECEIVING_TRANSCRIPTION - // because stopTranscription() always fires its trigger. - // - // Ownership-aware token clear is encapsulated inside clearDataChannelToken(). - this.clearDataChannelToken(); + // received mid-teardown do not trigger Locus syncs. + // Token cleanup happens automatically when cleanupLLMConneciton destroys the channel. await this.cleanupLLMConneciton({throwOnError: false}); }; diff --git a/packages/@webex/plugin-meetings/src/webinar/index.ts b/packages/@webex/plugin-meetings/src/webinar/index.ts index 861631d37e2..0aba80cc71d 100644 --- a/packages/@webex/plugin-meetings/src/webinar/index.ts +++ b/packages/@webex/plugin-meetings/src/webinar/index.ts @@ -4,6 +4,8 @@ import {WebexPlugin, config} from '@webex/webex-core'; import uuid from 'uuid'; import {get} from 'lodash'; +import type LLMChannel from '@webex/internal-plugin-llm'; +import type {VoiceaChannel} from '@webex/internal-plugin-voicea'; import { _ID_, HEADERS, @@ -12,7 +14,6 @@ import { SELF_ROLES, SHARE_STATUS, DEFAULT_LARGE_SCALE_WEBINAR_ATTENDEE_SEARCH_LIMIT, - LLM_PRACTICE_SESSION, LOCUS_LLM_EVENT, } from '../constants'; @@ -21,19 +22,6 @@ import LoggerProxy from '../common/logs/logger-proxy'; import MeetingUtil from '../meeting/util'; import {sanitizeParams} from './utils'; -const PS_LLM_EVENTS = [ - { - event: `event:relay.event:${LLM_PRACTICE_SESSION}`, - listenerKey: 'relay', - handlerKey: 'processRelayEvent', - }, - { - event: `${LOCUS_LLM_EVENT}:${LLM_PRACTICE_SESSION}`, - listenerKey: 'locusLLM', - handlerKey: 'processLocusLLMEvent', - }, -]; - /** * @class Webinar */ @@ -53,6 +41,18 @@ const Webinar = WebexPlugin.extend({ meetingId: 'string', }, + /** + * LLM channel for practice session, owned by this webinar instance. + * @type {LLMChannel|undefined} + */ + practiceSessionLLMChannel: undefined as LLMChannel | undefined, + + /** + * Voicea channel for practice session, bound to practiceSessionLLMChannel. + * @type {VoiceaChannel|undefined} + */ + practiceSessionVoiceaChannel: undefined as VoiceaChannel | undefined, + /** * Calls this to clean up listeners * @returns {void} @@ -177,67 +177,50 @@ const Webinar = WebexPlugin.extend({ }, /** - * Disconnects the practice session data channel and removes its relay listener. - * The listener reference removed here is the exact callback captured at subscribe - * time (see updatePSDataChannel) so that cleanup is correct even if the underlying - * meeting can no longer be resolved (e.g. locusUrl mismatch). + * Disconnects the practice session LLM channel and cleans up listeners. * @returns {Promise} */ async cleanupPSDataChannel() { - const {isOwner} = this.webex.internal.llm.resolveSessionOwnership( - this.meetingId, - LLM_PRACTICE_SESSION - ); - this.llmListeners = this.llmListeners || {}; - + // Remove pending online listener if any if (this._pendingOnlineListener) { - // @ts-ignore - Fix type - this.webex.internal.llm.off('online', this._pendingOnlineListener); + const meeting = this.getValidatedWebinarMeeting(); + meeting?.llmChannel?.off('online', this._pendingOnlineListener); this._pendingOnlineListener = null; } - try { - // @ts-ignore - Fix type - const disconnected = await this.webex.internal.llm.disconnectLLM( - { - code: 3050, - reason: 'done (permanent)', - }, - LLM_PRACTICE_SESSION, - this.meetingId - ); + // Clean up practice session voicea channel + if (this.practiceSessionVoiceaChannel) { + this.practiceSessionVoiceaChannel.deregisterEvents(); + this.practiceSessionVoiceaChannel = undefined; + } - if (!disconnected) { - LoggerProxy.logger.info( - `Webinar:index#cleanupPSDataChannel --> skipping disconnect; practice-session LLM is not owned by meeting ${this.meetingId}` - ); - } - } catch (error) { - // disconnectLLM clears ownership only on success; release a stale owner - // tag here so other meeting instances can reclaim practice-session LLM. - if (isOwner) { - // @ts-ignore - Fix type - this.webex.internal.llm.setOwnerMeetingId?.(undefined, LLM_PRACTICE_SESSION); - } + if (!this.practiceSessionLLMChannel) { + return; + } + try { + await this.practiceSessionLLMChannel.disconnect({ + code: 3050, + reason: 'done (permanent)', + }); + } catch (error) { + LoggerProxy.logger.error( + 'Webinar:index#cleanupPSDataChannel --> Failed to disconnect practice session LLM channel', + error + ); throw error; } finally { - if (this._practiceSessionRelayListener) { - // @ts-ignore - Fix type - this.webex.internal.llm.off( - `event:relay.event:${LLM_PRACTICE_SESSION}`, - this._practiceSessionRelayListener - ); - } - this._practiceSessionRelayListener = null; - - for (const {event, listenerKey} of PS_LLM_EVENTS) { - if (this.llmListeners[listenerKey]) { - // @ts-ignore - Fix type - this.webex.internal.llm.off(event, this.llmListeners[listenerKey]); - this.llmListeners[listenerKey] = null; - } + // Remove listeners from the channel + const meeting = this.getValidatedWebinarMeeting(); + if (meeting) { + this.practiceSessionLLMChannel?.off('event:relay.event', meeting.processRelayEvent); + this.practiceSessionLLMChannel?.off(LOCUS_LLM_EVENT, meeting.processLocusLLMEvent); + this.practiceSessionLLMChannel?.off('online', meeting.handleLLMOnline); + + // Unregister annotation from practice session + meeting.annotation.unregisterChannel('practice-session'); } + this.practiceSessionLLMChannel = undefined; } }, @@ -256,11 +239,10 @@ const Webinar = WebexPlugin.extend({ return undefined; } - // @ts-ignore - const cachedToken = this.webex.internal.llm.getDatachannelToken( - LLM_PRACTICE_SESSION, - this.meetingId - ); + // Check for cached token on the channel or pending token on the meeting + const cachedToken = + this.practiceSessionLLMChannel?.getDatachannelToken() ?? + meeting._pendingPracticeSessionDatachannelToken; if (cachedToken) { return cachedToken; @@ -268,18 +250,18 @@ const Webinar = WebexPlugin.extend({ try { const refreshResponse = await meeting.refreshDataChannelToken(); - const {datachannelToken, dataChannelTokenType} = refreshResponse?.body ?? {}; + const {datachannelToken} = refreshResponse?.body ?? {}; if (!datachannelToken) { return undefined; } - // @ts-ignore - this.webex.internal.llm.setDatachannelToken( - datachannelToken, - this.meetingId, - dataChannelTokenType || LLM_PRACTICE_SESSION - ); + // Store token on the channel if it exists, otherwise on meeting for later + if (this.practiceSessionLLMChannel) { + this.practiceSessionLLMChannel.setDatachannelToken(datachannelToken); + } else { + meeting._pendingPracticeSessionDatachannelToken = datachannelToken; + } return datachannelToken; } catch (error) { @@ -294,22 +276,17 @@ const Webinar = WebexPlugin.extend({ }, /** - * Connects to low latency mercury and reconnects if the address has changed - * It will also disconnect if called when the meeting has ended + * Connects practice session LLM channel. Creates a new channel if needed. + * Will disconnect if the meeting has ended or is no longer in practice session mode. + * Waits for the main LLM channel to be connected before connecting practice session. * @returns {Promise} */ async updatePSDataChannel() { - this.llmListeners = this.llmListeners || {}; - this._updatePSDataChannelSequence = (this._updatePSDataChannelSequence || 0) + 1; const invocationSequence = this._updatePSDataChannelSequence; const meeting = this.getValidatedWebinarMeeting(); const isPracticeSession = meeting?.isJoined() && this.isJoinPracticeSessionDataChannel(); - const {currentOwner, isOwner} = this.webex.internal.llm.resolveSessionOwnership( - this.meetingId, - LLM_PRACTICE_SESSION - ); if (!isPracticeSession) { await this.cleanupPSDataChannel(); @@ -317,61 +294,40 @@ const Webinar = WebexPlugin.extend({ return undefined; } - if (!isOwner) { - LoggerProxy.logger.info( - `Webinar:index#updatePSDataChannel --> skipping; practice-session LLM owned by meeting ${currentOwner}, not ${this.meetingId}` - ); - - return undefined; - } - // @ts-ignore - Fix type const {url = undefined, info: {practiceSessionDatachannelUrl = undefined} = {}} = meeting?.locusInfo || {}; - // @ts-ignore - let practiceSessionDatachannelToken = this.webex.internal.llm.getDatachannelToken( - LLM_PRACTICE_SESSION, - this.meetingId - ); - - const isCaptionBoxOn = this.webex.internal.voicea.getIsCaptionBoxOn(); - if (!practiceSessionDatachannelUrl) { return undefined; } - // @ts-ignore - Fix type - if (this.webex.internal.llm.isConnected(LLM_PRACTICE_SESSION)) { + + // If already connected to same URLs, skip reconnect + if (this.practiceSessionLLMChannel?.isConnected()) { 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) + url === this.practiceSessionLLMChannel.getLocusUrl() && + practiceSessionDatachannelUrl === this.practiceSessionLLMChannel.getDatachannelUrl() ) { return undefined; } - + // URLs changed, disconnect existing channel await this.cleanupPSDataChannel(); } - // Ensure the default session data channel is connected before connecting the practice session. + // Ensure the default session LLM channel is connected before connecting the practice session. // Subscribe before checking isConnected() to avoid a race where the 'online' event fires - // between the check and the subscription — Mercury does not replay missed events. - if (!this._pendingOnlineListener) { + // between the check and the subscription — the channel does not replay missed events. + if (!this._pendingOnlineListener && meeting?.llmChannel) { const onDefaultSessionConnected = () => { this._pendingOnlineListener = null; - // @ts-ignore - Fix type - this.webex.internal.llm.off('online', onDefaultSessionConnected); + meeting.llmChannel?.off('online', onDefaultSessionConnected); this.updatePSDataChannel(); }; this._pendingOnlineListener = onDefaultSessionConnected; - // @ts-ignore - Fix type - this.webex.internal.llm.on('online', onDefaultSessionConnected); + meeting.llmChannel.on('online', onDefaultSessionConnected); } - // @ts-ignore - Fix type - if (!this.webex.internal.llm.isConnected()) { + if (!meeting?.llmChannel?.isConnected()) { LoggerProxy.logger.info( 'Webinar:index#updatePSDataChannel --> default session not yet connected, deferring practice session connect.' ); @@ -381,12 +337,18 @@ const Webinar = WebexPlugin.extend({ // Default session is already connected — cancel the pending listener and proceed if (this._pendingOnlineListener) { - // @ts-ignore - Fix type - this.webex.internal.llm.off('online', this._pendingOnlineListener); + meeting.llmChannel.off('online', this._pendingOnlineListener); this._pendingOnlineListener = null; } - const refreshedPracticeSessionToken = await this.ensurePracticeSessionDatachannelToken(meeting); + const isCaptionBoxOn = meeting.voiceaChannel?.getIsCaptionBoxOn() ?? false; + + // Get token from pending on meeting or refresh if needed + let practiceSessionDatachannelToken = + meeting._pendingPracticeSessionDatachannelToken ?? + this.practiceSessionLLMChannel?.getDatachannelToken(); + + const refreshedToken = await this.ensurePracticeSessionDatachannelToken(meeting); const latestPracticeSessionDatachannelUrl = get( meeting, @@ -405,97 +367,85 @@ const Webinar = WebexPlugin.extend({ return undefined; } - if (refreshedPracticeSessionToken) { - practiceSessionDatachannelToken = refreshedPracticeSessionToken; + if (refreshedToken) { + practiceSessionDatachannelToken = refreshedToken; } - const {currentOwner: currentOwnerBeforeConnect, isOwner: isOwnerBeforeConnect} = - this.webex.internal.llm.resolveSessionOwnership(this.meetingId, LLM_PRACTICE_SESSION); + // Create a new practice session LLM channel + // @ts-ignore - Fix type + this.practiceSessionLLMChannel = this.webex.internal.llm.createConnection(); + + // Set up refresh handler before registration + this.practiceSessionLLMChannel.setRefreshHandler(() => meeting.refreshDataChannelToken()); - if (!isOwnerBeforeConnect) { - LoggerProxy.logger.info( - `Webinar:index#updatePSDataChannel --> skipping pre-connect owner write; practice-session LLM owned by meeting ${currentOwnerBeforeConnect}, not ${this.meetingId}` + // If we have a pending token, store it on the channel + if (meeting._pendingPracticeSessionDatachannelToken) { + this.practiceSessionLLMChannel.setDatachannelToken( + meeting._pendingPracticeSessionDatachannelToken ); - - return undefined; + meeting._pendingPracticeSessionDatachannelToken = undefined; } - // Ensure refresh for practice datachannel requests is routed to this - // meeting only when we are actually about to connect the practice session. - // This avoids claiming ownership in flows that return early (e.g. missing - // practiceSessionDatachannelUrl or waiting for default session online). - // @ts-ignore - Fix type - this.webex.internal.llm.setRefreshHandler( - () => meeting.refreshDataChannelToken(), - this.meetingId, - LLM_PRACTICE_SESSION - ); - // @ts-ignore - Fix type - this.webex.internal.llm.setOwnerMeetingId?.(this.meetingId, LLM_PRACTICE_SESSION); - - // @ts-ignore - Fix type - return this.webex.internal.llm - .registerAndConnect( - url, - practiceSessionDatachannelUrl, - practiceSessionDatachannelToken, - LLM_PRACTICE_SESSION - ) + return this.practiceSessionLLMChannel + .registerAndConnect(url, practiceSessionDatachannelUrl, practiceSessionDatachannelToken) .then((registerAndConnectResult) => { - const {currentOwner: currentOwnerAfterConnect, isOwner: isOwnerAfterConnect} = - this.webex.internal.llm.resolveSessionOwnership(this.meetingId, LLM_PRACTICE_SESSION); - - if (this.meetingId && isOwnerAfterConnect) { - // @ts-ignore - Fix type - this.webex.internal.llm.setOwnerMeetingId?.(this.meetingId, LLM_PRACTICE_SESSION); - } else { - LoggerProxy.logger.info( - `Webinar:index#updatePSDataChannel --> skipping post-connect owner write; practice-session LLM owned by meeting ${currentOwnerAfterConnect}, not ${this.meetingId}` - ); - } - - // Track the exact listener references so cleanupPSDataChannel can - // unsubscribe deterministically, even if the meeting can no longer - // be resolved at cleanup time. - for (const {event, listenerKey, handlerKey} of PS_LLM_EVENTS) { - if (this.llmListeners[listenerKey]) { - // @ts-ignore - Fix type - this.webex.internal.llm.off(event, this.llmListeners[listenerKey]); - } - this.llmListeners[listenerKey] = meeting?.[handlerKey]; - // @ts-ignore - Fix type - this.webex.internal.llm.on(event, this.llmListeners[listenerKey]); - } + // Register event listeners on the practice session channel + this.practiceSessionLLMChannel.off('event:relay.event', meeting.processRelayEvent); + this.practiceSessionLLMChannel.on('event:relay.event', meeting.processRelayEvent); + this.practiceSessionLLMChannel.off(LOCUS_LLM_EVENT, meeting.processLocusLLMEvent); + this.practiceSessionLLMChannel.on(LOCUS_LLM_EVENT, meeting.processLocusLLMEvent); + this.practiceSessionLLMChannel.off('online', meeting.handleLLMOnline); + this.practiceSessionLLMChannel.on('online', meeting.handleLLMOnline); + + // Register annotation channel for practice session + meeting.annotation.registerChannel(this.practiceSessionLLMChannel, 'practice-session'); + + // Create VoiceaChannel for practice session bound to practiceSessionLLMChannel // @ts-ignore - Fix type - this.webex.internal.voicea?.announce?.(); + this.practiceSessionVoiceaChannel = this.webex.internal.voicea.createChannel( + this.practiceSessionLLMChannel + ); + + // Set up voicea listeners for practice session channel + this.setupPracticeSessionVoiceaListeners(meeting); + + // Announce and enable captions on practice session channel + this.practiceSessionVoiceaChannel?.announce?.(); if (isCaptionBoxOn) { - this.webex.internal.voicea.updateSubchannelSubscriptions({subscribe: ['transcription']}); + this.practiceSessionVoiceaChannel?.updateSubchannelSubscriptions({ + subscribe: ['transcription'], + }); } LoggerProxy.logger.info( - `Webinar:index#updatePSDataChannel --> enabled to receive relay events for default session for ${LLM_PRACTICE_SESSION}!` + 'Webinar:index#updatePSDataChannel --> enabled to receive relay events for practice session!' ); return Promise.resolve(registerAndConnectResult); }) .catch((error) => { - const { - currentOwner: currentOwnerAfterRegisterFailure, - isOwner: isOwnerAfterRegisterFailure, - } = this.webex.internal.llm.resolveSessionOwnership(this.meetingId, LLM_PRACTICE_SESSION); - - if (isOwnerAfterRegisterFailure) { - // @ts-ignore - Fix type - this.webex.internal.llm.setOwnerMeetingId?.(undefined, LLM_PRACTICE_SESSION); - } else { - LoggerProxy.logger.info( - `Webinar:index#updatePSDataChannel --> skipping failure owner release; practice-session LLM owned by meeting ${currentOwnerAfterRegisterFailure}, not ${this.meetingId}` - ); - } - + // Clean up the channel on failure + this.practiceSessionLLMChannel = undefined; throw error; }); }, + /** + * Sets up voicea listeners for practice session channel to forward events to the meeting. + * This allows captions to work during practice session. + * @param {object} meeting - The meeting instance + * @returns {void} + */ + setupPracticeSessionVoiceaListeners(meeting) { + if (!this.practiceSessionVoiceaChannel || !meeting) { + return; + } + + // Forward voicea events from practice session channel to meeting's voicea callbacks + Object.keys(meeting.voiceaListenerCallbacks).forEach((eventName) => { + this.practiceSessionVoiceaChannel.on(eventName, meeting.voiceaListenerCallbacks[eventName]); + }); + }, + /** * start or stop practice session for webinar * @param {boolean} enabled 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 38cfd2d074a..95846577646 100644 --- a/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js +++ b/packages/@webex/plugin-meetings/test/unit/spec/meeting/index.js @@ -35,7 +35,6 @@ import { OFFLINE, ROAP_OFFER_ANSWER_EXCHANGE_TIMEOUT, LOCUS_LLM_EVENT, - LLM_PRACTICE_SESSION, RECORDING_STATE, } from '@webex/plugin-meetings/src/constants'; import { @@ -285,6 +284,20 @@ describe('plugin-meetings', () => { }); webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false); webex.internal.llm.on = sinon.stub(); + // Factory pattern: createConnection returns a mock channel + webex.internal.llm.createConnection = sinon.stub().callsFake(() => ({ + registerAndConnect: sinon.stub().resolves({}), + disconnect: sinon.stub().resolves(), + isConnected: sinon.stub().returns(false), + getLocusUrl: sinon.stub().returns(undefined), + getDatachannelUrl: sinon.stub().returns(undefined), + getDatachannelToken: sinon.stub().returns(undefined), + setDatachannelToken: sinon.stub(), + getBinding: sinon.stub().returns(undefined), + setRefreshHandler: sinon.stub(), + on: sinon.stub(), + off: sinon.stub(), + })); webex.internal.voicea.announce = sinon.stub(); webex.internal.newMetrics.callDiagnosticLatencies = new CallDiagnosticLatencies( {}, @@ -2519,66 +2532,50 @@ describe('plugin-meetings', () => { [ { - title: 'should skip a reaction when the default relay route does not match the LLM binding', - isPracticeSessionConnected: false, - route: 'wrong-default-route', - defaultBinding: 'default-route', - practiceBinding: 'practice-route', + title: 'should skip a reaction when the relay route does not match the LLM binding', + route: 'wrong-route', + channelBinding: 'correct-route', shouldProcess: false, - expectedSessionLabel: 'default session', }, { - title: 'should process a reaction when the default relay route matches the LLM binding', - isPracticeSessionConnected: false, - route: 'default-route', - defaultBinding: 'default-route', - practiceBinding: 'practice-route', + title: 'should process a reaction when the relay route matches the LLM binding', + route: 'correct-route', + channelBinding: 'correct-route', shouldProcess: true, }, { - title: - 'should process a reaction when the practice-session relay route matches the practice-session LLM binding', - isPracticeSessionConnected: true, - route: 'practice-route', - defaultBinding: 'default-route', - practiceBinding: 'practice-route', + title: 'should process a reaction when no LLM channel exists', + route: 'some-route', + channelBinding: null, // No channel shouldProcess: true, }, { - title: - 'should skip a reaction when the practice-session relay route does not match the practice-session LLM binding', - isPracticeSessionConnected: true, - route: 'default-route', - defaultBinding: 'default-route', - practiceBinding: 'practice-route', - shouldProcess: false, - expectedSessionLabel: 'practice session', + title: 'should process a reaction when LLM channel has no binding', + route: 'some-route', + channelBinding: undefined, // Channel exists but no binding + shouldProcess: true, }, ].forEach( ({ title, - isPracticeSessionConnected, route, - defaultBinding, - practiceBinding, + channelBinding, shouldProcess, - expectedSessionLabel, }) => { it(title, () => { meeting.isReactionsSupported = sinon.stub().returns(true); meeting.config.receiveReactions = true; const fakeSendersName = 'Fake reactors name'; meeting.members.membersCollection.get = sinon.stub().returns({name: fakeSendersName}); - webex.internal.llm.isConnected = sinon.stub().callsFake((llmSessionId) => { - return llmSessionId === LLM_PRACTICE_SESSION && isPracticeSessionConnected; - }); - webex.internal.llm.getBinding = sinon.stub().callsFake((llmSessionId) => { - if (llmSessionId === LLM_PRACTICE_SESSION) { - return practiceBinding; - } - - return defaultBinding; - }); + + // Mock the llmChannel on the meeting + if (channelBinding === null) { + meeting.llmChannel = undefined; + } else { + meeting.llmChannel = { + getBinding: sinon.stub().returns(channelBinding), + }; + } const fakeReactionPayload = { type: 'fake_type', codepoints: 'fake_codepoints', @@ -2722,7 +2719,10 @@ describe('plugin-meetings', () => { assert.calledOnce(MeetingUtil.joinMeeting); assert.calledOnce(webex.internal.device.meetingStarted); assert.equal(result, joinMeetingResult); - assert.calledWith(webex.internal.llm.on, 'online', meeting.handleLLMOnline); + // With factory pattern, event is registered on the channel, not the plugin + if (meeting.llmChannel) { + assert.calledWith(meeting.llmChannel.on, 'online', meeting.handleLLMOnline); + } }); [true, false].forEach((enableMultistream) => { @@ -2948,13 +2948,24 @@ describe('plugin-meetings', () => { sinon.stub(meeting, 'isJoined').returns(true); let locusLLMEventListener; - meeting.webex.internal.llm.on = sinon.stub().callsFake((eventName, callback) => { - if (eventName === 'event:locus.state_message') { - locusLLMEventListener = callback; - } - }); - meeting.webex.internal.llm.off = sinon.stub(); - sinon.stub(meeting.webex.internal.llm, 'registerAndConnect').resolves({}); + const mockChannel = { + registerAndConnect: sinon.stub().resolves({}), + disconnect: sinon.stub().resolves(), + isConnected: sinon.stub().returns(false), + getLocusUrl: sinon.stub().returns(undefined), + getDatachannelUrl: sinon.stub().returns(undefined), + getDatachannelToken: sinon.stub().returns(undefined), + setDatachannelToken: sinon.stub(), + getBinding: sinon.stub().returns(undefined), + setRefreshHandler: sinon.stub(), + on: sinon.stub().callsFake((eventName, callback) => { + if (eventName === 'event:locus.state_message') { + locusLLMEventListener = callback; + } + }), + off: sinon.stub(), + }; + webex.internal.llm.createConnection = sinon.stub().returns(mockChannel); meeting.updateLLMConnection.restore(); @@ -2977,9 +2988,22 @@ describe('plugin-meetings', () => { it('UpdateLLMConnection sends a metric if not connected after timeout', async () => { sinon.stub(meeting, 'isJoined').returns(true); - sinon.stub(meeting.webex.internal.llm, 'isConnected').returns(false); - sinon.stub(meeting.webex.internal.llm, 'hasEverConnected').value(true); - sinon.stub(meeting.webex.internal.llm, 'registerAndConnect').resolves({}); + + const mockChannel = { + registerAndConnect: sinon.stub().resolves({}), + disconnect: sinon.stub().resolves(), + isConnected: sinon.stub().returns(false), + getLocusUrl: sinon.stub().returns(undefined), + getDatachannelUrl: sinon.stub().returns(undefined), + getDatachannelToken: sinon.stub().returns(undefined), + setDatachannelToken: sinon.stub(), + getBinding: sinon.stub().returns(undefined), + setRefreshHandler: sinon.stub(), + hasEverConnected: true, + on: sinon.stub(), + off: sinon.stub(), + }; + webex.internal.llm.createConnection = sinon.stub().returns(mockChannel); meeting.updateLLMConnection.restore(); @@ -2999,32 +3023,44 @@ describe('plugin-meetings', () => { it('clears the LLM health check timer when disconnecting LLM', async () => { const isJoinedStub = sinon.stub(meeting, 'isJoined'); - sinon.stub(meeting.webex.internal.llm, 'isConnected'); - sinon.stub(meeting.webex.internal.llm, 'disconnectLLM').resolves(); - sinon.stub(meeting.webex.internal.llm, 'registerAndConnect').resolves({}); - sinon - .stub(meeting.webex.internal.llm, 'getLocusUrl') - .returns('https://locus1.example.com'); - sinon - .stub(meeting.webex.internal.llm, 'getDatachannelUrl') - .returns('https://datachannel1.example.com'); + + const mockChannel = { + registerAndConnect: sinon.stub().resolves({}), + disconnect: sinon.stub().resolves(), + isConnected: sinon.stub().returns(false), + getLocusUrl: sinon.stub().returns(undefined), + getDatachannelUrl: sinon.stub().returns(undefined), + getDatachannelToken: sinon.stub().returns(undefined), + setDatachannelToken: sinon.stub(), + getBinding: sinon.stub().returns(undefined), + setRefreshHandler: sinon.stub(), + on: sinon.stub(), + off: sinon.stub(), + }; + webex.internal.llm.createConnection = sinon.stub().returns(mockChannel); meeting.updateLLMConnection.restore(); + // First: join and connect isJoinedStub.returns(true); - meeting.webex.internal.llm.isConnected.returns(false); await meeting.updateLLMConnection(); assert.exists(meeting.llmHealthCheckTimer); + // Simulate existing connected channel that will be cleaned up + mockChannel.isConnected.returns(true); + meeting.llmChannel = mockChannel; + + // Now leave the meeting - this should trigger cleanup isJoinedStub.returns(false); - meeting.webex.internal.llm.isConnected.returns(true); + meeting.locusInfo.url = 'some-url'; + meeting.locusInfo.info = {datachannelUrl: 'some-datachannel-url'}; await meeting.updateLLMConnection(); - assert.calledOnce(meeting.webex.internal.llm.disconnectLLM); - + // After cleanup (not joined), timer should be cleared assert.isUndefined(meeting.llmHealthCheckTimer); + assert.calledOnce(mockChannel.disconnect); Metrics.sendBehavioralMetric.resetHistory(); fakeClock.tick(3 * 60 * 1000); @@ -7407,6 +7443,12 @@ describe('plugin-meetings', () => { const onlineHandler = meeting.mercuryOnlineHandler; const offlineHandler = meeting.mercuryOfflineHandler; + // Set up llmChannel with mock off function + meeting.llmChannel = { + off: sinon.stub(), + disconnect: sinon.stub().resolves(), + }; + await meeting.leave(); // All llm/mercury consumers (direct listeners, voicea transcription, @@ -7414,19 +7456,19 @@ describe('plugin-meetings', () => { // in-flight events do not trigger unnecessary Locus syncs // (per Locus team recommendation). assert.callOrder( - webex.internal.llm.off, + meeting.llmChannel.off, webex.internal.mercury.off, meeting.stopTranscription, meeting.annotation.deregisterEvents, meeting.meetingRequest.leaveMeeting ); assert.calledWithExactly( - webex.internal.llm.off, + meeting.llmChannel.off, 'event:relay.event', meeting.processRelayEvent ); assert.calledWithExactly( - webex.internal.llm.off, + meeting.llmChannel.off, LOCUS_LLM_EVENT, meeting.processLocusLLMEvent ); @@ -7446,15 +7488,21 @@ describe('plugin-meetings', () => { .stub() .returns(Promise.reject(new Error('leave failed'))); + // Set up llmChannel with mock off function + meeting.llmChannel = { + off: sinon.stub(), + disconnect: sinon.stub().resolves(), + }; + await meeting.leave().catch(() => {}); assert.calledWithExactly( - webex.internal.llm.off, + meeting.llmChannel.off, 'event:relay.event', meeting.processRelayEvent ); assert.calledWithExactly( - webex.internal.llm.off, + meeting.llmChannel.off, LOCUS_LLM_EVENT, meeting.processLocusLLMEvent ); @@ -9501,6 +9549,12 @@ describe('plugin-meetings', () => { const onlineHandler = meeting.mercuryOnlineHandler; const offlineHandler = meeting.mercuryOfflineHandler; + // Set up llmChannel with mock off function + meeting.llmChannel = { + off: sinon.stub(), + disconnect: sinon.stub().resolves(), + }; + await meeting.endMeetingForAll(); // All llm/mercury consumers (direct listeners, voicea transcription, @@ -9508,19 +9562,19 @@ describe('plugin-meetings', () => { // in-flight events do not trigger unnecessary Locus syncs // (per Locus team recommendation). assert.callOrder( - webex.internal.llm.off, + meeting.llmChannel.off, webex.internal.mercury.off, meeting.stopTranscription, meeting.annotation.deregisterEvents, meeting.meetingRequest.endMeetingForAll ); assert.calledWithExactly( - webex.internal.llm.off, + meeting.llmChannel.off, 'event:relay.event', meeting.processRelayEvent ); assert.calledWithExactly( - webex.internal.llm.off, + meeting.llmChannel.off, LOCUS_LLM_EVENT, meeting.processLocusLLMEvent ); @@ -9539,15 +9593,21 @@ describe('plugin-meetings', () => { .stub() .returns(Promise.reject(new Error('end failed'))); + // Set up llmChannel with mock off function + meeting.llmChannel = { + off: sinon.stub(), + disconnect: sinon.stub().resolves(), + }; + await meeting.endMeetingForAll().catch(() => {}); assert.calledWithExactly( - webex.internal.llm.off, + meeting.llmChannel.off, 'event:relay.event', meeting.processRelayEvent ); assert.calledWithExactly( - webex.internal.llm.off, + meeting.llmChannel.off, LOCUS_LLM_EVENT, meeting.processLocusLLMEvent ); @@ -14055,41 +14115,28 @@ describe('plugin-meetings', () => { describe('#saveDataChannelToken', () => { beforeEach(() => { - webex.internal.llm.setDatachannelToken = sinon.stub(); - webex.internal.llm.resolveSessionOwnership = sinon - .stub() - .returns({currentOwner: undefined, isOwner: true}); - webex.internal.llm.isConnected = sinon.stub().returns(false); + meeting._pendingDatachannelToken = undefined; + meeting._pendingPracticeSessionDatachannelToken = undefined; }); - it('saves datachannelToken into LLM as Default', () => { + it('saves datachannelToken to _pendingDatachannelToken', () => { meeting.saveDataChannelToken({ locus: { self: {datachannelToken: 'default-token'}, }, }); - assert.calledWithExactly( - webex.internal.llm.setDatachannelToken, - 'default-token', - meeting.id, - 'llm-default-session' - ); + assert.equal(meeting._pendingDatachannelToken, 'default-token'); }); - it('saves practiceSessionDatachannelToken into LLM as PracticeSession', () => { + it('saves practiceSessionDatachannelToken to _pendingPracticeSessionDatachannelToken', () => { meeting.saveDataChannelToken({ locus: { self: {practiceSessionDatachannelToken: 'ps-token'}, }, }); - assert.calledWithExactly( - webex.internal.llm.setDatachannelToken, - 'ps-token', - meeting.id, - 'llm-practice-session' - ); + assert.equal(meeting._pendingPracticeSessionDatachannelToken, 'ps-token'); }); it('saves both tokens when both are present', () => { @@ -14102,111 +14149,93 @@ describe('plugin-meetings', () => { }, }); - assert.calledTwice(webex.internal.llm.setDatachannelToken); - assert.calledWithExactly( - webex.internal.llm.setDatachannelToken, - 'default-token', - meeting.id, - 'llm-default-session' - ); - assert.calledWithExactly( - webex.internal.llm.setDatachannelToken, - 'ps-token', - meeting.id, - 'llm-practice-session' - ); + assert.equal(meeting._pendingDatachannelToken, 'default-token'); + assert.equal(meeting._pendingPracticeSessionDatachannelToken, 'ps-token'); }); - it('does not call setDatachannelToken when no tokens are present', () => { + it('does not set pending tokens when no tokens are present', () => { meeting.saveDataChannelToken({locus: {self: {}}}); - assert.notCalled(webex.internal.llm.setDatachannelToken); + assert.isUndefined(meeting._pendingDatachannelToken); + assert.isUndefined(meeting._pendingPracticeSessionDatachannelToken); }); it('handles undefined join gracefully', () => { meeting.saveDataChannelToken(undefined); - assert.notCalled(webex.internal.llm.setDatachannelToken); + assert.isUndefined(meeting._pendingDatachannelToken); + assert.isUndefined(meeting._pendingPracticeSessionDatachannelToken); }); it('handles missing locus.self gracefully', () => { meeting.saveDataChannelToken({locus: {}}); - assert.notCalled(webex.internal.llm.setDatachannelToken); + assert.isUndefined(meeting._pendingDatachannelToken); + assert.isUndefined(meeting._pendingPracticeSessionDatachannelToken); }); - it('writes token with meeting id as owner', () => { + it('sets token on llmChannel if it exists', () => { + const mockChannel = { + setDatachannelToken: sinon.stub(), + }; + meeting.llmChannel = mockChannel; + meeting.saveDataChannelToken({ locus: { self: {datachannelToken: 'default-token'}, }, }); - assert.calledOnceWithExactly( - webex.internal.llm.setDatachannelToken, - 'default-token', - meeting.id, - 'llm-default-session' - ); - }); - }); - - describe('#clearDataChannelToken', () => { - beforeEach(() => { - webex.internal.llm.clearDatachannelToken = sinon.stub(); - }); - - it('delegates default and practice token clears to llm with meeting ownership id', () => { - meeting.clearDataChannelToken(); - - assert.calledWithExactly( - webex.internal.llm.clearDatachannelToken, - 'llm-default-session', - meeting.id - ); - assert.calledWithExactly( - webex.internal.llm.clearDatachannelToken, - 'llm-practice-session', - meeting.id - ); - assert.callCount(webex.internal.llm.clearDatachannelToken, 2); + assert.calledOnceWithExactly(mockChannel.setDatachannelToken, 'default-token'); }); }); describe('#updateLLMConnection', () => { + let mockChannel; + beforeEach(() => { - webex.internal.llm.isConnected = sinon.stub().returns(false); - webex.internal.llm.getLocusUrl = sinon.stub(); - webex.internal.llm.getDatachannelUrl = sinon.stub(); - webex.internal.llm.registerAndConnect = sinon.stub().resolves('something'); - webex.internal.llm.setRefreshHandler = sinon.stub(); - webex.internal.llm.disconnectLLM = sinon.stub().resolves(); - webex.internal.llm.on = sinon.stub(); - webex.internal.llm.off = sinon.stub(); - webex.internal.llm.getDatachannelToken = sinon.stub().returns(undefined); - webex.internal.llm.setDatachannelToken = sinon.stub(); + // Create a mock channel that createConnection will return + mockChannel = { + registerAndConnect: sinon.stub().resolves('something'), + disconnect: sinon.stub().resolves(), + isConnected: sinon.stub().returns(false), + getLocusUrl: sinon.stub().returns(undefined), + getDatachannelUrl: sinon.stub().returns(undefined), + getDatachannelToken: sinon.stub().returns(undefined), + setDatachannelToken: sinon.stub(), + getBinding: sinon.stub().returns(undefined), + setRefreshHandler: sinon.stub(), + on: sinon.stub(), + off: sinon.stub(), + }; + + webex.internal.llm.createConnection = sinon.stub().returns(mockChannel); meeting.processRelayEvent = sinon.stub(); meeting.processLocusLLMEvent = sinon.stub(); + meeting.handleLLMOnline = sinon.stub(); meeting.clearLLMHealthCheckTimer = sinon.stub(); meeting.startLLMHealthCheckTimer = sinon.stub(); meeting.webinar.isJoinPracticeSessionDataChannel = sinon.stub().returns(false); }); + it('does not connect if the call is not joined yet', async () => { meeting.joinedWith = {state: 'any other state'}; - webex.internal.llm.getLocusUrl.returns('a url'); - meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}}; + meeting.locusInfo = { + syncAllHashTreeDatasets: sinon.stub().resolves(), + url: 'a url', + info: {datachannelUrl: 'a datachannel url'}, + }; const result = await meeting.updateLLMConnection(); - assert.notCalled(webex.internal.llm.registerAndConnect); - assert.notCalled(webex.internal.llm.disconnectLLM); + assert.notCalled(webex.internal.llm.createConnection); assert.equal(result, undefined); - assert.notCalled(meeting.webex.internal.llm.on); }); - it('returns undefined if llm is already connected and the locus url is unchanged', async () => { + + it('creates a channel and connects when joined', async () => { meeting.joinedWith = {state: 'JOINED'}; meeting.locusInfo = { syncAllHashTreeDatasets: sinon.stub().resolves(), @@ -14215,244 +14244,213 @@ describe('plugin-meetings', () => { }; const result = await meeting.updateLLMConnection(); - assert.notCalled(webex.internal.llm.disconnectLLM); + + assert.calledOnce(webex.internal.llm.createConnection); assert.calledWithExactly( - webex.internal.llm.registerAndConnect, + mockChannel.registerAndConnect, 'a url', 'a datachannel url', undefined ); assert.equal(result, 'something'); - assert.calledWithExactly( - meeting.webex.internal.llm.off, - 'event:relay.event', - meeting.processRelayEvent - ); - assert.calledWithExactly( - meeting.webex.internal.llm.off, - 'event:locus.state_message', - meeting.processLocusLLMEvent - ); - assert.calledWithExactly( - meeting.webex.internal.llm.on, - 'event:relay.event', - meeting.processRelayEvent - ); - assert.calledWithExactly( - meeting.webex.internal.llm.on, - 'event:locus.state_message', - meeting.processLocusLLMEvent - ); + assert.calledOnceWithExactly(meeting.locusInfo.syncAllHashTreeDatasets, {onlyLLM: true}); }); - it('connects if not already connected', async () => { + + it('sets up event listeners on the channel after connection', async () => { meeting.joinedWith = {state: 'JOINED'}; - meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}}; + meeting.locusInfo = { + syncAllHashTreeDatasets: sinon.stub().resolves(), + url: 'a url', + info: {datachannelUrl: 'a datachannel url'}, + }; - const result = await meeting.updateLLMConnection(); + await meeting.updateLLMConnection(); - assert.notCalled(webex.internal.llm.disconnectLLM); - assert.calledWithExactly( - webex.internal.llm.registerAndConnect, - 'a url', - 'a datachannel url', - undefined - ); - assert.calledOnceWithExactly( - webex.internal.llm.setRefreshHandler, - sinon.match.func, - meeting.id, - 'llm-default-session' - ); - assert.equal(result, 'something'); - assert.calledOnceWithExactly(meeting.locusInfo.syncAllHashTreeDatasets, {onlyLLM: true}); + assert.calledWithExactly(mockChannel.off, 'event:relay.event', meeting.processRelayEvent); + assert.calledWithExactly(mockChannel.on, 'event:relay.event', meeting.processRelayEvent); + assert.calledWithExactly(mockChannel.off, 'event:locus.state_message', meeting.processLocusLLMEvent); + assert.calledWithExactly(mockChannel.on, 'event:locus.state_message', meeting.processLocusLLMEvent); + assert.calledWithExactly(mockChannel.off, 'online', meeting.handleLLMOnline); + assert.calledWithExactly(mockChannel.on, 'online', meeting.handleLLMOnline); }); - it('disconnects if the locus url has changed', async () => { + + it('sets refresh handler before registration', async () => { meeting.joinedWith = {state: 'JOINED'}; + meeting.locusInfo = { + syncAllHashTreeDatasets: sinon.stub().resolves(), + url: 'a url', + info: {datachannelUrl: 'a datachannel url'}, + }; + + await meeting.updateLLMConnection(); - webex.internal.llm.isConnected.returns(true); - webex.internal.llm.getLocusUrl.returns('a url'); + assert.calledOnceWithExactly(mockChannel.setRefreshHandler, sinon.match.func); + // Verify setRefreshHandler was called before registerAndConnect + assert.isTrue(mockChannel.setRefreshHandler.calledBefore(mockChannel.registerAndConnect)); + }); + it('skips reconnect when already connected to same URLs', async () => { + meeting.joinedWith = {state: 'JOINED'}; meeting.locusInfo = { syncAllHashTreeDatasets: sinon.stub().resolves(), - url: 'a different url', + url: 'a url', info: {datachannelUrl: 'a datachannel url'}, - self: {}, }; + // Set up existing channel with matching URLs + const existingChannel = { + isConnected: sinon.stub().returns(true), + getLocusUrl: sinon.stub().returns('a url'), + getDatachannelUrl: sinon.stub().returns('a datachannel url'), + }; + meeting.llmChannel = existingChannel; + const result = await meeting.updateLLMConnection(); - assert.calledWithExactly(webex.internal.llm.disconnectLLM, { - code: 3050, - reason: 'done (permanent)', - }, 'llm-default-session', meeting.id); + assert.notCalled(webex.internal.llm.createConnection); + assert.equal(result, undefined); + }); - assert.calledWithExactly( - webex.internal.llm.registerAndConnect, - 'a different url', - 'a datachannel url', - undefined - ); + it('disconnects and reconnects when locus URL changes', async () => { + meeting.joinedWith = {state: 'JOINED'}; - assert.equal(result, 'something'); + // Set up existing channel with different URL + const existingChannel = { + isConnected: sinon.stub().returns(true), + getLocusUrl: sinon.stub().returns('old url'), + getDatachannelUrl: sinon.stub().returns('a datachannel url'), + disconnect: sinon.stub().resolves(), + off: sinon.stub(), + }; + meeting.llmChannel = existingChannel; - assert.calledWithExactly( - meeting.webex.internal.llm.off, - 'event:relay.event', - meeting.processRelayEvent - ); - assert.calledWithExactly( - meeting.webex.internal.llm.off, - 'event:locus.state_message', - meeting.processLocusLLMEvent - ); - assert.callCount(meeting.webex.internal.llm.off, 4); + meeting.locusInfo = { + syncAllHashTreeDatasets: sinon.stub().resolves(), + url: 'a different url', + info: {datachannelUrl: 'a datachannel url'}, + }; + await meeting.updateLLMConnection(); + + // Should disconnect old channel + assert.calledOnce(existingChannel.disconnect); + // Should create new channel + assert.calledOnce(webex.internal.llm.createConnection); + // Should connect with new URL assert.calledWithExactly( - meeting.webex.internal.llm.on, - 'event:relay.event', - meeting.processRelayEvent - ); - assert.calledWithExactly( - meeting.webex.internal.llm.on, - 'event:locus.state_message', - meeting.processLocusLLMEvent - ); - assert.isFalse( - meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline) + mockChannel.registerAndConnect, + 'a different url', + 'a datachannel url', + undefined ); }); - it('disconnects if the data channel url has changed', async () => { + + it('disconnects and reconnects when datachannel URL changes', async () => { meeting.joinedWith = {state: 'JOINED'}; - webex.internal.llm.isConnected.returns(true); - webex.internal.llm.getLocusUrl.returns('a url'); + + // Set up existing channel with different datachannel URL + const existingChannel = { + isConnected: sinon.stub().returns(true), + getLocusUrl: sinon.stub().returns('a url'), + getDatachannelUrl: sinon.stub().returns('old datachannel url'), + disconnect: sinon.stub().resolves(), + off: sinon.stub(), + }; + meeting.llmChannel = existingChannel; meeting.locusInfo = { syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a different datachannel url'}, - self: {}, }; - const result = await meeting.updateLLMConnection(); - - assert.calledWithExactly(webex.internal.llm.disconnectLLM, { - code: 3050, - reason: 'done (permanent)', - }, 'llm-default-session', meeting.id); + await meeting.updateLLMConnection(); + // Should disconnect old channel + assert.calledOnce(existingChannel.disconnect); + // Should create new channel + assert.calledOnce(webex.internal.llm.createConnection); + // Should connect with new datachannel URL assert.calledWithExactly( - webex.internal.llm.registerAndConnect, + mockChannel.registerAndConnect, 'a url', 'a different datachannel url', undefined ); - - assert.equal(result, 'something'); - - assert.calledWithExactly( - meeting.webex.internal.llm.off, - 'event:relay.event', - meeting.processRelayEvent - ); - assert.calledWithExactly( - meeting.webex.internal.llm.off, - 'event:locus.state_message', - meeting.processLocusLLMEvent - ); - - assert.calledWithExactly( - meeting.webex.internal.llm.on, - 'event:relay.event', - meeting.processRelayEvent - ); - assert.calledWithExactly( - meeting.webex.internal.llm.on, - 'event:locus.state_message', - meeting.processLocusLLMEvent - ); - assert.isFalse( - meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline) - ); }); - it('disconnects when the state is not JOINED', async () => { + + it('disconnects when state changes to not joined', async () => { meeting.joinedWith = {state: 'any other state'}; - webex.internal.llm.isConnected.returns(true); - webex.internal.llm.getLocusUrl.returns('a url'); - meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}}; + // Set up existing connected channel + const existingChannel = { + isConnected: sinon.stub().returns(true), + getLocusUrl: sinon.stub().returns('a url'), + getDatachannelUrl: sinon.stub().returns('a datachannel url'), + disconnect: sinon.stub().resolves(), + off: sinon.stub(), + }; + meeting.llmChannel = existingChannel; + + meeting.locusInfo = { + syncAllHashTreeDatasets: sinon.stub().resolves(), + url: 'a url', + info: {datachannelUrl: 'a datachannel url'}, + }; const result = await meeting.updateLLMConnection(); - assert.calledWith(webex.internal.llm.disconnectLLM, { - code: 3050, - reason: 'done (permanent)', - }, 'llm-default-session', meeting.id); - assert.notCalled(webex.internal.llm.registerAndConnect); + assert.calledOnce(existingChannel.disconnect); + assert.notCalled(webex.internal.llm.createConnection); assert.equal(result, undefined); - assert.isFalse( - meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline) - ); }); - it('rethrows disconnect errors during reconnect cleanup after removing relay listeners and timer', async () => { - const disconnectError = new Error('disconnect failed'); + it('passes pending datachannel token to registerAndConnect', async () => { meeting.joinedWith = {state: 'JOINED'}; - webex.internal.llm.isConnected.returns(true); - webex.internal.llm.getLocusUrl.returns('a url'); - webex.internal.llm.disconnectLLM.rejects(disconnectError); - + meeting._pendingDatachannelToken = 'pending-token'; meeting.locusInfo = { syncAllHashTreeDatasets: sinon.stub().resolves(), - url: 'a different url', + url: 'a url', info: {datachannelUrl: 'a datachannel url'}, - self: {}, }; - try { - await meeting.updateLLMConnection(); - assert.fail('Expected updateLLMConnection to reject when disconnectLLM fails'); - } catch (error) { - assert.equal(error, disconnectError); - } + await meeting.updateLLMConnection(); - assert.notCalled(webex.internal.llm.registerAndConnect); - assert.calledWithExactly( - meeting.webex.internal.llm.off, - 'event:relay.event', - meeting.processRelayEvent - ); assert.calledWithExactly( - meeting.webex.internal.llm.off, - 'event:locus.state_message', - meeting.processLocusLLMEvent - ); - assert.isFalse( - meeting.webex.internal.llm.off.calledWithExactly('online', meeting.handleLLMOnline) + mockChannel.registerAndConnect, + 'a url', + 'a datachannel url', + 'pending-token' ); - assert.calledOnce(meeting.clearLLMHealthCheckTimer); + // Pending token should be set on channel + assert.calledWithExactly(mockChannel.setDatachannelToken, 'pending-token'); + // Pending token should be cleared + assert.isUndefined(meeting._pendingDatachannelToken); }); - it('still need connect main session data channel when PS started', async () => { + + it('uses channel token if no pending token', async () => { meeting.joinedWith = {state: 'JOINED'}; + mockChannel.getDatachannelToken.returns('channel-token'); meeting.locusInfo = { syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', - info: { - datachannelUrl: 'a datachannel url', - practiceSessionDatachannelUrl: 'ps-url', - }, + info: {datachannelUrl: 'a datachannel url'}, }; - meeting.webinar.isJoinPracticeSessionDataChannel.returns(true); await meeting.updateLLMConnection(); assert.calledWithExactly( - webex.internal.llm.registerAndConnect, + mockChannel.registerAndConnect, 'a url', 'a datachannel url', - undefined + 'channel-token' ); }); - it('passes dataChannelToken from LLM to registerAndConnect', async () => { + + it('clears llmChannel on registration failure', async () => { + const registerError = new Error('registration failed'); + mockChannel.registerAndConnect.rejects(registerError); + meeting.joinedWith = {state: 'JOINED'}; meeting.locusInfo = { syncAllHashTreeDatasets: sinon.stub().resolves(), @@ -14460,21 +14458,17 @@ describe('plugin-meetings', () => { info: {datachannelUrl: 'a datachannel url'}, }; - webex.internal.llm.getDatachannelToken - .withArgs('llm-default-session', meeting.id) - .returns('token-123'); - - await meeting.updateLLMConnection(); + try { + await meeting.updateLLMConnection(); + assert.fail('Expected updateLLMConnection to reject'); + } catch (error) { + assert.equal(error, registerError); + } - assert.calledWithExactly( - webex.internal.llm.registerAndConnect, - 'a url', - 'a datachannel url', - 'token-123' - ); - assert.notCalled(webex.internal.llm.setDatachannelToken); + assert.isUndefined(meeting.llmChannel); }); - it('passes undefined token when LLM has no token stored', async () => { + + it('starts LLM health check timer after successful connection', async () => { meeting.joinedWith = {state: 'JOINED'}; meeting.locusInfo = { syncAllHashTreeDatasets: sinon.stub().resolves(), @@ -14482,348 +14476,88 @@ describe('plugin-meetings', () => { info: {datachannelUrl: 'a datachannel url'}, }; - webex.internal.llm.getDatachannelToken.returns(undefined); - await meeting.updateLLMConnection(); - assert.calledWithExactly( - webex.internal.llm.registerAndConnect, - 'a url', - 'a datachannel url', - undefined - ); - - assert.notCalled(webex.internal.llm.setDatachannelToken); + assert.calledOnce(meeting.startLLMHealthCheckTimer); }); - - it('does not pass token when data channel with jwt token is disabled', async () => { + it('still need connect main session data channel when PS started', async () => { meeting.joinedWith = {state: 'JOINED'}; meeting.locusInfo = { syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', - info: {datachannelUrl: 'a datachannel url'}, + info: { + datachannelUrl: 'a datachannel url', + practiceSessionDatachannelUrl: 'ps-url', + }, }; - - webex.internal.llm.getDatachannelToken.returns(undefined); - webex.internal.llm.isDataChannelTokenEnabled = sinon.stub().resolves(false); + meeting.webinar.isJoinPracticeSessionDataChannel.returns(true); await meeting.updateLLMConnection(); assert.calledWithExactly( - webex.internal.llm.registerAndConnect, + mockChannel.registerAndConnect, 'a url', 'a datachannel url', undefined ); - assert.notCalled(webex.internal.llm.setDatachannelToken); }); - describe('ownership tag', () => { - beforeEach(() => { - // Make the owner stub dynamic so setOwnerMeetingId() writes - // propagate back to getOwnerMeetingId() reads. This mirrors the - // real LLM singleton behavior so the finally-block release in - // cleanupLLMConneciton is reflected in subsequent reads. - webex.internal.llm.getOwnerMeetingId = sinon.stub().returns(undefined); - webex.internal.llm.setOwnerMeetingId = sinon.stub().callsFake((id) => { - webex.internal.llm.getOwnerMeetingId.returns(id); - }); - }); - - it('skips disconnect and reconnect when LLM is connected and owned by another meeting (regardless of URL)', async () => { - meeting.joinedWith = {state: 'JOINED'}; - webex.internal.llm.isConnected.returns(true); - webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id'); - // Locus/datachannel URL mismatch is the *normal* case when - // another meeting owns the live socket -- each meeting has its - // own locus URL. URL mismatch must NOT trigger a reclaim, - // because doing so would tear down the owning meeting's healthy - // LLM socket and break its data channel. - webex.internal.llm.getLocusUrl.returns('owner-locus-url'); - webex.internal.llm.getDatachannelUrl.returns('owner-dc-url'); - meeting.locusInfo = { - syncAllHashTreeDatasets: sinon.stub().resolves(), - url: 'a different url', - info: {datachannelUrl: 'a different datachannel url'}, - self: {}, - }; - - const result = await meeting.updateLLMConnection(); - - assert.equal(result, undefined); - assert.notCalled(webex.internal.llm.disconnectLLM); - assert.notCalled(webex.internal.llm.registerAndConnect); - assert.notCalled(webex.internal.llm.setOwnerMeetingId); - assert.notCalled(meeting.startLLMHealthCheckTimer); - }); - - - it('clears stale owner tag in cleanup finally block even when disconnectLLM rejects', async () => { - meeting.joinedWith = {state: 'JOINED'}; - webex.internal.llm.isConnected.returns(true); - webex.internal.llm.getOwnerMeetingId.returns(meeting.id); - webex.internal.llm.getLocusUrl.returns('a url'); - webex.internal.llm.getDatachannelUrl.returns('a datachannel url'); - webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed')); - meeting.locusInfo = { - syncAllHashTreeDatasets: sinon.stub().resolves(), - url: 'a different url', - info: {datachannelUrl: 'a datachannel url'}, - self: {}, - }; - - try { - await meeting.updateLLMConnection(); - } catch (e) { - /* updateLLMConnection may reject when cleanup throws */ - } - - // The owner-eligible finally branch must release the tag so a - // subsequent reconnect attempt from any meeting is not blocked. - assert.calledWith(webex.internal.llm.setOwnerMeetingId, undefined); - }); - - it('does not clear owner tag when ownership changes during cleanup disconnect await', async () => { - meeting.joinedWith = {state: 'JOINED'}; - webex.internal.llm.isConnected.returns(true); - webex.internal.llm.getOwnerMeetingId.returns(meeting.id); - webex.internal.llm.getLocusUrl.returns('a url'); - webex.internal.llm.getDatachannelUrl.returns('a datachannel url'); - webex.internal.llm.disconnectLLM.callsFake(async () => { - webex.internal.llm.getOwnerMeetingId.returns('new-owner-id'); - throw new Error('disconnect failed'); - }); - meeting.locusInfo = { - syncAllHashTreeDatasets: sinon.stub().resolves(), - url: 'a different url', - info: {datachannelUrl: 'a datachannel url'}, - self: {}, - }; - - try { - await meeting.updateLLMConnection(); - } catch (e) { - /* updateLLMConnection may reject when cleanup throws */ - } + describe('#clearMeetingData', () => { + let existingChannel; - assert.notCalled(webex.internal.llm.setOwnerMeetingId); - assert.equal(webex.internal.llm.getOwnerMeetingId(), 'new-owner-id'); - }); - - it('proceeds normally when LLM is connected and owned by this meeting with URL change', async () => { - meeting.joinedWith = {state: 'JOINED'}; - webex.internal.llm.isConnected.returns(true); - webex.internal.llm.getOwnerMeetingId.returns(meeting.id); - webex.internal.llm.getLocusUrl.returns('a url'); - webex.internal.llm.getDatachannelUrl.returns('a datachannel url'); - meeting.locusInfo = { - syncAllHashTreeDatasets: sinon.stub().resolves(), - url: 'a different url', - info: {datachannelUrl: 'a datachannel url'}, - self: {}, + beforeEach(() => { + existingChannel = { + isConnected: sinon.stub().returns(true), + disconnect: sinon.stub().resolves(), + off: sinon.stub(), }; + meeting.llmChannel = existingChannel; - await meeting.updateLLMConnection(); - - assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, { - code: 3050, - reason: 'done (permanent)', - }, 'llm-default-session', meeting.id); - assert.calledWithExactly( - webex.internal.llm.registerAndConnect, - 'a different url', - 'a datachannel url', - undefined - ); - // setOwnerMeetingId is called twice: first with undefined in - // cleanupLLMConneciton's finally block (so a failed disconnect - // cannot leave a stale owner), then with this meeting's id - // after registerAndConnect resolves. - assert.calledTwice(webex.internal.llm.setOwnerMeetingId); - assert.calledWith(webex.internal.llm.setOwnerMeetingId.firstCall, undefined); - assert.calledWith(webex.internal.llm.setOwnerMeetingId.lastCall, meeting.id); - }); - - it('claims ownership after successful registerAndConnect on initial connect', async () => { - meeting.joinedWith = {state: 'JOINED'}; - webex.internal.llm.isConnected.returns(false); - webex.internal.llm.getOwnerMeetingId.returns(undefined); - meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}}; - - await meeting.updateLLMConnection(); - - assert.calledOnce(webex.internal.llm.registerAndConnect); - assert.calledOnceWithExactly( - webex.internal.llm.setRefreshHandler, - sinon.match.func, - meeting.id, - 'llm-default-session' - ); - assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id); - }); - - it('proceeds to connect when LLM is not connected even if another ownerId lingers', async () => { - // Defensive path: if the LLM reports not-connected but an old - // ownerId is still present (e.g. race before a successful - // connections.delete), this meeting can still claim a fresh - // connection. - meeting.joinedWith = {state: 'JOINED'}; - webex.internal.llm.isConnected.returns(false); - webex.internal.llm.getOwnerMeetingId.returns('stale-owner-id'); - webex.internal.llm.getDatachannelToken.onFirstCall().returns(undefined); - webex.internal.llm.getDatachannelToken.onSecondCall().returns('recovered-token'); - meeting.locusInfo = {syncAllHashTreeDatasets: sinon.stub().resolves(), url: 'a url', info: {datachannelUrl: 'a datachannel url'}}; - - await meeting.updateLLMConnection(); - - assert.calledTwice(webex.internal.llm.getDatachannelToken); - assert.calledWithExactly( - webex.internal.llm.getDatachannelToken.firstCall, - 'llm-default-session', - meeting.id - ); - assert.calledWithExactly( - webex.internal.llm.getDatachannelToken.secondCall, - 'llm-default-session' - ); - assert.calledOnceWithExactly( - webex.internal.llm.registerAndConnect, - 'a url', - 'a datachannel url', - 'recovered-token' - ); - assert.calledWithExactly( - webex.internal.llm.setRefreshHandler.firstCall, - sinon.match.func, - undefined, - 'llm-default-session' - ); - assert.calledTwice(webex.internal.llm.setRefreshHandler); - assert.calledWithExactly( - webex.internal.llm.setRefreshHandler.secondCall, - sinon.match.func, - meeting.id, - 'llm-default-session' - ); - assert.calledOnceWithExactly(webex.internal.llm.setOwnerMeetingId, meeting.id); - }); - }); - - describe('#clearMeetingData', () => { - beforeEach(() => { - webex.internal.llm.isConnected = sinon.stub().returns(true); - webex.internal.llm.disconnectLLM = sinon.stub().resolves(); - webex.internal.llm.off = sinon.stub(); meeting.annotation.deregisterEvents = sinon.stub(); meeting.clearLLMHealthCheckTimer = sinon.stub(); meeting.stopTranscription = sinon.stub(); - meeting.clearDataChannelToken = sinon.stub(); meeting.shareStatus = 'no-share'; }); - it('disconnects llm and removes online and relay listeners during meeting data cleanup', async () => { + it('disconnects llm channel and removes listeners during meeting data cleanup', async () => { await meeting.clearMeetingData(); - assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, { - code: 3050, - reason: 'done (permanent)', - }, 'llm-default-session', meeting.id); - assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline); + assert.calledOnce(existingChannel.disconnect); + assert.calledWithExactly(existingChannel.off, 'online', meeting.handleLLMOnline); assert.calledWithExactly( - webex.internal.llm.off, + existingChannel.off, 'event:relay.event', meeting.processRelayEvent ); assert.calledWithExactly( - webex.internal.llm.off, + existingChannel.off, 'event:locus.state_message', meeting.processLocusLLMEvent ); - assert.calledOnce(meeting.clearLLMHealthCheckTimer); - assert.calledOnce(meeting.clearDataChannelToken); - // stopTranscription and annotation.deregisterEvents are not - // called here: they run in stopListeningForMeetingEvents() - // before /leave to avoid double-emitting - // MEETING_STOPPED_RECEIVING_TRANSCRIPTION. - assert.notCalled(meeting.stopTranscription); - assert.notCalled(meeting.annotation.deregisterEvents); - }); - it('continues cleanup when disconnectLLM fails during meeting data cleanup', async () => { - webex.internal.llm.disconnectLLM.rejects(new Error('disconnect failed')); + assert.called(meeting.clearLLMHealthCheckTimer); + }); + + it('continues cleanup when disconnect fails', async () => { + existingChannel.disconnect.rejects(new Error('disconnect failed')); await meeting.clearMeetingData(); - assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline); + assert.calledWithExactly(existingChannel.off, 'online', meeting.handleLLMOnline); assert.calledWithExactly( - webex.internal.llm.off, + existingChannel.off, 'event:relay.event', meeting.processRelayEvent ); - assert.calledWithExactly( - webex.internal.llm.off, - 'event:locus.state_message', - meeting.processLocusLLMEvent - ); - assert.calledOnce(meeting.clearLLMHealthCheckTimer); - assert.calledOnce(meeting.clearDataChannelToken); - assert.notCalled(meeting.stopTranscription); - assert.notCalled(meeting.annotation.deregisterEvents); + assert.called(meeting.clearLLMHealthCheckTimer); }); - describe('ownership tag', () => { - beforeEach(() => { - webex.internal.llm.getOwnerMeetingId = sinon.stub(); - }); - - it('skips disconnectLLM but still removes this meeting listeners when another meeting owns the LLM', async () => { - webex.internal.llm.getOwnerMeetingId.returns('some-other-meeting-id'); - - await meeting.clearMeetingData(); - - assert.notCalled(webex.internal.llm.disconnectLLM); - // clearDataChannelToken is always delegated; llm enforces - // ownership and no-ops for non-owners internally. - assert.calledOnce(meeting.clearDataChannelToken); - // Listeners owned by *this* Meeting instance must still be - // removed so a leaving subordinate meeting stops receiving - // relay/locus events from the shared singleton. - assert.calledWithExactly(webex.internal.llm.off, 'online', meeting.handleLLMOnline); - assert.calledWithExactly( - webex.internal.llm.off, - 'event:relay.event', - meeting.processRelayEvent - ); - assert.calledWithExactly( - webex.internal.llm.off, - 'event:locus.state_message', - meeting.processLocusLLMEvent - ); - assert.calledOnce(meeting.clearLLMHealthCheckTimer); - }); - - it('calls disconnectLLM and clears data channel token when this meeting is the owner', async () => { - webex.internal.llm.getOwnerMeetingId.returns(meeting.id); - - await meeting.clearMeetingData(); + it('handles cleanup when no llmChannel exists', async () => { + meeting.llmChannel = undefined; - assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, { - code: 3050, - reason: 'done (permanent)', - }, 'llm-default-session', meeting.id); - assert.calledOnce(meeting.clearDataChannelToken); - }); - - it('calls disconnectLLM and clears data channel token when no owner is recorded (first-claim / legacy)', async () => { - webex.internal.llm.getOwnerMeetingId.returns(undefined); - - await meeting.clearMeetingData(); + // Should not throw + await meeting.clearMeetingData(); - assert.calledOnceWithExactly(webex.internal.llm.disconnectLLM, { - code: 3050, - reason: 'done (permanent)', - }, 'llm-default-session', meeting.id); - assert.calledOnce(meeting.clearDataChannelToken); - }); + assert.called(meeting.clearLLMHealthCheckTimer); }); }); });