import {isEqual, assignWith, cloneDeep, isEmpty} from 'lodash'; import LoggerProxy from '../common/logs/logger-proxy'; import EventsScope from '../common/events/events-scope'; import { EVENTS, LOCUSEVENT, _USER_, _CALL_, _SIP_BRIDGE_, MEETING_STATE, _MEETING_, _SPACE_SHARE_, LOCUSINFO, LOCUS, _LEFT_, MEETING_REMOVED_REASON, CALL_REMOVED_REASON, RECORDING_STATE, Enum, SELF_ROLES, } from '../constants'; import InfoUtils from './infoUtils'; import FullState from './fullState'; import SelfUtils from './selfUtils'; import HostUtils from './hostUtils'; import ControlsUtils from './controlsUtils'; import EmbeddedAppsUtils from './embeddedAppsUtils'; import MediaSharesUtils from './mediaSharesUtils'; import LocusDeltaParser from './parser'; import Metrics from '../metrics'; import BEHAVIORAL_METRICS from '../metrics/constants'; import HashTreeParser, { DataSet, HashTreeMessage, LocusInfoUpdateType, Metadata, } from '../hashTree/hashTreeParser'; import {HashTreeObject, ObjectType, ObjectTypeToLocusKeyMap} from '../hashTree/types'; import {isMetadata} from '../hashTree/utils'; import {Links, LocusDTO} from './types'; export type LocusLLMEvent = { data: { eventType: typeof LOCUSEVENT.HASH_TREE_DATA_UPDATED; stateElementsMessage: HashTreeMessage; }; }; // list of top level keys in Locus DTO relevant for Hash Tree DTOs processing // it does not contain fields specific to classic Locus DTOs like sequence or baseSequence const LocusDtoTopLevelKeys = [ 'controls', 'fullState', 'embeddedApps', 'host', 'info', 'links', 'mediaShares', 'meetings', 'participants', 'replaces', 'self', 'sequence', 'syncUrl', 'url', 'htMeta', // only exists when hash trees are used ]; export type LocusApiResponseBody = { dataSets?: DataSet[]; locus: LocusDTO; // this LocusDTO here might not be the full one (for example it won't have all the participants, but it should have self) metadata?: Metadata; }; const LocusObjectStateAfterUpdates = { unchanged: 'unchanged', removed: 'removed', updated: 'updated', } as const; type LocusObjectStateAfterUpdates = Enum; /** * Creates a locus object from the objects received in a hash tree message. It usually will be * incomplete, because hash tree messages only contain the parts of locus that have changed, * and some updates come separately over Mercury or LLM in separate messages. * * @param {HashTreeMessage} message hash tree message to created the locus from * @returns {Object} the created locus object and metadata if present */ export function createLocusFromHashTreeMessage(message: HashTreeMessage): { locus: LocusDTO; metadata?: Metadata; } { const locus: LocusDTO = { participants: [], url: message.locusUrl, }; let metadata: Metadata | undefined; if (!message.locusStateElements) { return {locus, metadata}; } for (const element of message.locusStateElements) { if (!element.data) { // eslint-disable-next-line no-continue continue; } const type = element.htMeta.elementId.type.toLowerCase(); switch (type) { case ObjectType.locus: { // spread locus object data onto the top level, but remove keys managed by other ObjectTypes const locusObjectData = {...element.data}; Object.values(ObjectTypeToLocusKeyMap).forEach((locusDtoKey) => { delete locusObjectData[locusDtoKey]; }); Object.assign(locus, locusObjectData); break; } case ObjectType.participant: locus.participants.push(element.data); break; case ObjectType.mediaShare: if (!locus.mediaShares) { locus.mediaShares = []; } locus.mediaShares.push(element.data); break; case ObjectType.embeddedApp: if (!locus.embeddedApps) { locus.embeddedApps = []; } locus.embeddedApps.push(element.data); break; case ObjectType.control: if (!locus.controls) { locus.controls = {}; } Object.assign(locus.controls, element.data); break; case ObjectType.links: case ObjectType.info: case ObjectType.fullState: case ObjectType.self: { const locusDtoKey = ObjectTypeToLocusKeyMap[type]; locus[locusDtoKey] = element.data; break; } case ObjectType.metadata: // metadata is not part of Locus DTO metadata = {...element.data, htMeta: element.htMeta} as Metadata; break; default: break; } } return {locus, metadata}; } /** * @description LocusInfo extends ChildEmitter to convert locusInfo info a private emitter to parent object * @export * @private * @class LocusInfo */ export default class LocusInfo extends EventsScope { compareAndUpdateFlags: any; emitChange: any; locusParser: any; meetingId: any; parsedLocus: any; updateMeeting: any; webex: any; aclUrl: any; baseSequence: any; created: any; participants: any; replaces: any; scheduledMeeting: any; sequence: any; controls: any; conversationUrl: any; embeddedApps: any; fullState: any; host: any; info: any; roles: any; mediaShares: any; url: any; links?: Links; mainSessionLocusCache: any; self: any; hashTreeParser?: HashTreeParser; hashTreeObjectId2ParticipantId: Map; // mapping of hash tree object ids to participant ids classicVsHashTreeMismatchMetricCounter = 0; /** * Constructor * @param {function} updateMeeting callback to update the meeting object from an object * @param {object} webex * @param {string} meetingId * @returns {undefined} */ constructor(updateMeeting, webex, meetingId) { super(); this.parsedLocus = { states: [], }; this.webex = webex; this.emitChange = false; this.compareAndUpdateFlags = {}; this.meetingId = meetingId; this.updateMeeting = updateMeeting; this.locusParser = new LocusDeltaParser(); this.hashTreeObjectId2ParticipantId = new Map(); } /** * Does a Locus sync. It tries to get the latest delta DTO or if it can't, it falls back to getting the full Locus DTO. * WARNING: This function must not be used for hash tree based Locus meetings. * * @param {Meeting} meeting * @param {boolean} isLocusUrlChanged * @param {Locus} locus * @returns {undefined} */ private doLocusSync(meeting: any, isLocusUrlChanged: boolean, locus: any) { let url; let isDelta = false; let meetingDestroyed = false; if (isLocusUrlChanged) { // for the locus url changed case from breakout to main session, we should always do a full sync, in this case, the url from locus is always on main session, // so use the main session locus url to get the full locus(full participants list in the response). // for the locus url changed case from main session to breakout, we don't need to care about it here, // because it is a USE_INCOMING case, it will not be executed here. url = locus.url; } else if (this.locusParser.workingCopy?.syncUrl) { url = this.locusParser.workingCopy.syncUrl; isDelta = true; } else { url = meeting.locusUrl; } LoggerProxy.logger.info( `Locus-info:index#doLocusSync --> doing Locus sync (getting ${ isDelta ? 'delta' : 'full' } DTO)` ); // return value ignored on purpose meeting.meetingRequest .getLocusDTO({url}) .catch((e) => { if (isDelta) { LoggerProxy.logger.info( 'Locus-info:index#doLocusSync --> delta sync failed, falling back to full sync' ); Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.LOCUS_DELTA_SYNC_FAILED, { correlationId: meeting.correlationId, url, reason: e.message, errorName: e.name, stack: e.stack, code: e.code, }); isDelta = false; // Locus sometimes returns 403, for example if meeting has ended, no point trying the fallback to full sync in that case if (e.statusCode !== 403) { return meeting.meetingRequest.getLocusDTO({url: meeting.locusUrl}).catch((err) => { LoggerProxy.logger.info( 'Locus-info:index#doLocusSync --> fallback full sync failed, destroying the meeting' ); this.webex.meetings.destroy(meeting, MEETING_REMOVED_REASON.LOCUS_DTO_SYNC_FAILED); meetingDestroyed = true; throw err; }); } LoggerProxy.logger.info( 'Locus-info:index#doLocusSync --> got 403 from Locus, skipping fallback to full sync, destroying the meeting' ); } else { LoggerProxy.logger.info( 'Locus-info:index#doLocusSync --> fallback full sync failed, destroying the meeting' ); } this.webex.meetings.destroy(meeting, MEETING_REMOVED_REASON.LOCUS_DTO_SYNC_FAILED); meetingDestroyed = true; throw e; }) .then((res) => { if (isEmpty(res.body)) { if (isDelta) { LoggerProxy.logger.info( 'Locus-info:index#doLocusSync --> received empty body from syncUrl, so we already have latest Locus DTO' ); } else { LoggerProxy.logger.info( 'Locus-info:index#doLocusSync --> received empty body from full DTO sync request' ); } return; } if (isDelta) { if (res.body.baseSequence) { meeting.locusInfo.handleLocusDelta(res.body, meeting); return; } // in some cases Locus might return us full DTO even when we asked for a delta LoggerProxy.logger.info( 'Locus-info:index#doLocusSync --> got full DTO when we asked for delta' ); } meeting.locusInfo.onFullLocus('classic Locus sync', res.body); }) .catch((e) => { LoggerProxy.logger.info( `Locus-info:index#doLocusSync --> getLocusDTO succeeded but failed to handle result, locus parser will resume but not all data may be synced (${e.toString()})` ); Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.LOCUS_SYNC_HANDLING_FAILED, { correlationId: meeting.correlationId, url, reason: e.message, errorName: e.name, stack: e.stack, code: e.code, }); }) .finally(() => { if (!meetingDestroyed) { // Notify parser to resume processing delta events. // Any deltas in the queue that have now been superseded by this sync will simply be ignored this.locusParser.resume(); } }); } /** * Apply locus delta data to meeting * @param {string} action Locus delta action * @param {Locus} locus * @param {Meeting} meeting * @returns {undefined} */ applyLocusDeltaData(action: string, locus: any, meeting: any) { const {DESYNC, USE_CURRENT, USE_INCOMING, WAIT, LOCUS_URL_CHANGED} = LocusDeltaParser.loci; const isLocusUrlChanged = action === LOCUS_URL_CHANGED; switch (action) { case USE_INCOMING: meeting.locusInfo.onDeltaLocus(locus); break; case USE_CURRENT: case WAIT: // do nothing break; case DESYNC: case LOCUS_URL_CHANGED: this.doLocusSync(meeting, isLocusUrlChanged, locus); break; default: LoggerProxy.logger.info( `Locus-info:index#applyLocusDeltaData --> Unknown locus delta action: ${action}` ); } } /** * Adds locus delta to parser's queue * and registers a function handler * to recieve parsed actions from queue. * @param {Locus} locus * @param {Meeting} meeting * @returns {undefined} */ handleLocusDelta(locus: any, meeting: any) { // register a function to process delta actions if (!this.locusParser.onDeltaAction) { // delta action, along with associated loci // is passed into the function. this.locusParser.onDeltaAction = (action, parsedLoci) => { this.applyLocusDeltaData(action, parsedLoci, meeting); }; } // queue delta event with parser this.locusParser.onDeltaEvent(locus); } /** * @param {Locus} locus * @returns {undefined} * @memberof LocusInfo */ init(locus: any = {}) { this.created = locus.created || null; this.scheduledMeeting = locus.meeting || null; this.replaces = locus.replaces || null; this.aclUrl = locus.aclUrl || null; this.baseSequence = locus.baseSequence || null; this.sequence = locus.sequence || null; this.participants = locus.participants || null; /** * Stores the delta values for a changed participant. * * @typedef {Object} DeltaParticipant * @property {Record} delta - Contains changed streams. * @property {Object} person - Contains person data. */ this.updateLocusCache(locus); // above section only updates the locusInfo object // The below section makes sure it updates the locusInfo as well as updates the meeting object this.updateParticipants(locus.participants, []); // For 1:1 space meeting the conversation Url does not exist in locus.conversation this.updateConversationUrl(locus.conversationUrl, locus.info); this.updateControls(locus.controls, locus.self); this.updateLocusUrl(locus.url, ControlsUtils.isMainSessionDTO(locus)); this.updateFullState(locus.fullState); this.updateMeetingInfo(locus.info); this.updateEmbeddedApps(locus.embeddedApps); // self and participants generate sipUrl for 1:1 meeting this.updateSelf(locus.self); this.updateHostInfo(locus.host); this.updateMediaShares(locus.mediaShares); this.updateLinks(locus.links); } /** * Creates the HashTreeParser instance. * @param {Object} initial locus data * @returns {void} */ private createHashTreeParser({ initialLocus, metadata, }: { initialLocus: { dataSets: Array; locus: any; }; metadata: Metadata; }) { return new HashTreeParser({ initialLocus, metadata, webexRequest: this.webex.request.bind(this.webex), locusInfoUpdateCallback: this.updateFromHashTree.bind(this), debugId: `HT-${this.meetingId.substring(0, 4)}`, excludedDataSets: this.webex.config.meetings.locus?.excludedDataSets, }); } /** * @param {Object} data - data to initialize locus info with. It may be from a join or GET /loci response or from a Mercury event that triggers a creation of meeting object * @returns {undefined} * @memberof LocusInfo */ async initialSetup( data: | { trigger: 'join-response'; locus: LocusDTO; dataSets?: DataSet[]; metadata?: Metadata; } | { trigger: 'locus-message'; locus?: LocusDTO; hashTreeMessage?: HashTreeMessage; } | { trigger: 'get-loci-response'; locus?: LocusDTO; } ) { switch (data.trigger) { case 'locus-message': if (data.hashTreeMessage) { // we need the Metadata object to be in the received message, because it contains visibleDataSets // and these are needed to initialize all the hash trees const metadataObject = data.hashTreeMessage.locusStateElements?.find((el) => isMetadata(el) ); if (!metadataObject?.data?.visibleDataSets) { // this is a common case (not an error) // it happens for example after we leave the meeting and still get some heartbeats or delayed messages LoggerProxy.logger.info( `Locus-info:index#initialSetup --> cannot initialize HashTreeParser, Metadata object with visibleDataSets is missing in the message` ); // throw so that handleLocusEvent() catches it and destroys the partially created meeting object throw new Error('Metadata object with visibleDataSets is missing in the message'); } LoggerProxy.logger.info( 'Locus-info:index#initialSetup --> creating HashTreeParser from message' ); // first create the HashTreeParser, but don't initialize it with any data yet this.hashTreeParser = this.createHashTreeParser({ initialLocus: { locus: null, dataSets: [], // empty, because they will be populated in initializeFromMessage() call // dataSets: data.hashTreeMessage.dataSets, }, metadata: { htMeta: metadataObject.htMeta, visibleDataSets: metadataObject.data.visibleDataSets, }, }); // now handle the message - that should populate all the visible datasets await this.hashTreeParser.initializeFromMessage(data.hashTreeMessage); } else { // "classic" Locus case, no hash trees involved this.updateLocusCache(data.locus); this.onFullLocus('classic locus message', data.locus, undefined); } break; case 'join-response': this.updateLocusCache(data.locus); this.onFullLocus('join response', data.locus, undefined, data.dataSets, data.metadata); break; case 'get-loci-response': if (data.locus?.links?.resources?.visibleDataSets?.url) { LoggerProxy.logger.info( 'Locus-info:index#initialSetup --> creating HashTreeParser from get-loci-response' ); // first create the HashTreeParser, but don't initialize it with any data yet this.hashTreeParser = this.createHashTreeParser({ initialLocus: { locus: null, dataSets: [], // empty, because we don't have them yet }, metadata: null, // get-loci-response doesn't contain Metadata object }); // now initialize all the data await this.hashTreeParser.initializeFromGetLociResponse(data.locus); } else { // "classic" Locus case, no hash trees involved this.updateLocusCache(data.locus); this.onFullLocus('classic get-loci-response', data.locus, undefined); } } // Change it to true after it receives it first locus object this.emitChange = true; } /** * Handles HTTP response from Locus API call. * @param {Meeting} meeting meeting object * @param {LocusApiResponseBody} responseBody body of the http response from Locus API call * @returns {void} */ handleLocusAPIResponse(meeting, responseBody: LocusApiResponseBody): void { if (this.hashTreeParser) { if (!responseBody.dataSets) { this.sendClassicVsHashTreeMismatchMetric( meeting, `expected hash tree dataSets in API response but they are missing` ); // continuing as we can still manage without responseBody.dataSets, but this is very suspicious } LoggerProxy.logger.info( 'Locus-info:index#handleLocusAPIResponse --> passing Locus API response to HashTreeParser: ', responseBody ); // update the data in our hash trees this.hashTreeParser.handleLocusUpdate(responseBody); } else { if (responseBody.dataSets) { this.sendClassicVsHashTreeMismatchMetric( meeting, `unexpected hash tree dataSets in API response` ); } // classic Locus delta this.handleLocusDelta(responseBody.locus, meeting); } } /** * * @param {HashTreeObject} object data set object * @param {any} locus * @returns {void} */ updateLocusFromHashTreeObject(object: HashTreeObject, locus: LocusDTO): LocusDTO { const type = object.htMeta.elementId.type.toLowerCase(); const addParticipantObject = (obj: HashTreeObject) => { if (!locus.participants) { locus.participants = []; } locus.participants.push(obj.data); this.hashTreeObjectId2ParticipantId.set(obj.htMeta.elementId.id, obj.data.id); }; switch (type) { case ObjectType.locus: { if (!object.data) { // not doing anything here, as we need Locus to always be there (at least some fields) // and that's already taken care of in updateFromHashTree() LoggerProxy.logger.info( `Locus-info:index#updateLocusFromHashTreeObject --> LOCUS object removed, version=${object.htMeta.elementId.version}` ); return locus; } // replace the main locus // The Locus object we receive from backend has empty participants array, // and may have (although it shouldn't) other fields that are managed by other ObjectTypes // like "fullState" or "info", so we're making sure to delete them here const locusObjectFromData = object.data; Object.values(ObjectTypeToLocusKeyMap).forEach((locusDtoKey) => { delete locusObjectFromData[locusDtoKey]; }); locus = {...locus, ...locusObjectFromData}; LoggerProxy.logger.info( `Locus-info:index#updateLocusFromHashTreeObject --> LOCUS object updated to version=${object.htMeta.elementId.version}` ); break; } case ObjectType.mediaShare: if (object.data) { LoggerProxy.logger.info( `Locus-info:index#updateLocusFromHashTreeObject --> mediaShare id=${ object.htMeta.elementId.id } name='${object.data.name}' updated ${ object.data.name === 'content' ? `floor=${object.data.floor?.disposition}, ${object.data.floor?.beneficiary?.id}` : '' } version=${object.htMeta.elementId.version}` ); const existingMediaShare = locus.mediaShares?.find( (ms) => ms.htMeta.elementId.id === object.htMeta.elementId.id ); if (existingMediaShare) { Object.assign(existingMediaShare, object.data); } else { locus.mediaShares = locus.mediaShares || []; locus.mediaShares.push(object.data); } } else { LoggerProxy.logger.info( `Locus-info:index#updateLocusFromHashTreeObject --> mediaShare id=${object.htMeta.elementId.id} removed, version=${object.htMeta.elementId.version}` ); locus.mediaShares = locus.mediaShares?.filter( (ms) => ms.htMeta.elementId.id !== object.htMeta.elementId.id ); } break; case ObjectType.embeddedApp: if (object.data) { LoggerProxy.logger.info( `Locus-info:index#updateLocusFromHashTreeObject --> embeddedApp id=${object.htMeta.elementId.id} url='${object.data.url}' updated version=${object.htMeta.elementId.version}:`, object.data ); const existingEmbeddedApp = locus.embeddedApps?.find( (ms) => ms.htMeta.elementId.id === object.htMeta.elementId.id ); if (existingEmbeddedApp) { Object.assign(existingEmbeddedApp, object.data); } else { locus.embeddedApps = locus.embeddedApps || []; locus.embeddedApps.push(object.data); } } else { LoggerProxy.logger.info( `Locus-info:index#updateLocusFromHashTreeObject --> embeddedApp id=${object.htMeta.elementId.id} removed, version=${object.htMeta.elementId.version}` ); locus.embeddedApps = locus.embeddedApps?.filter( (ms) => ms.htMeta.elementId.id !== object.htMeta.elementId.id ); } break; case ObjectType.participant: LoggerProxy.logger.info( `Locus-info:index#updateLocusFromHashTreeObject --> participant id=${ object.htMeta.elementId.id } ${object.data ? 'updated' : 'removed'} version=${object.htMeta.elementId.version}` ); if (object.data) { addParticipantObject(object); } else { const participantId = this.hashTreeObjectId2ParticipantId.get(object.htMeta.elementId.id); if (!locus.jsSdkMeta) { locus.jsSdkMeta = {removedParticipantIds: []}; } locus.jsSdkMeta.removedParticipantIds.push(participantId); this.hashTreeObjectId2ParticipantId.delete(object.htMeta.elementId.id); } break; case ObjectType.control: if (object.data) { Object.keys(object.data).forEach((controlKey) => { LoggerProxy.logger.info( `Locus-info:index#updateLocusFromHashTreeObject --> control ${controlKey} updated:`, object.data[controlKey] ); if (!locus.controls) { locus.controls = {}; } locus.controls[controlKey] = object.data[controlKey]; }); } else { LoggerProxy.logger.warn( `Locus-info:index#updateLocusFromHashTreeObject --> control object update without data - this is not expected!` ); } break; case ObjectType.links: case ObjectType.info: case ObjectType.fullState: case ObjectType.self: if (!object.data) { // self without data is handled inside HashTreeParser and results in LocusInfoUpdateType.MEETING_ENDED, so we should never get here // all other types info, fullstate, etc - Locus should never send them without data LoggerProxy.logger.warn( `Locus-info:index#updateLocusFromHashTreeObject --> received ${type} object without data, this is not expected! version=${object.htMeta.elementId.version}` ); } else { LoggerProxy.logger.info( `Locus-info:index#updateLocusFromHashTreeObject --> ${type} object updated to version ${object.htMeta.elementId.version}` ); const locusDtoKey = ObjectTypeToLocusKeyMap[type]; locus[locusDtoKey] = object.data; /* Hash tree based webinar attendees don't receive a Participant object for themselves from Locus, but a lot of existing code in SDK and web app expects a member object for self to exist, so whenever SELF changes for a webinar attendee, we copy it into a participant object. We can do it, because SELF has always all the same properties as a participant object. */ if ( type === ObjectType.self && locus.info?.isWebinar && object.data.controls?.role?.roles?.find( (r) => r.type === SELF_ROLES.ATTENDEE && r.hasRole ) ) { LoggerProxy.logger.info( `Locus-info:index#updateLocusFromHashTreeObject --> webinar attendee: creating participant object from self` ); addParticipantObject(object); } } break; case ObjectType.metadata: LoggerProxy.logger.info( `Locus-info:index#updateLocusFromHashTreeObject --> metadata object updated to version ${object.htMeta.elementId.version}` ); // we don't use hash tree metadata right now for anything, it's mainly used internally by HashTreeParser break; default: LoggerProxy.logger.warn( `Locus-info:index#updateLocusFromHashTreeObject --> received unsupported object type ${type}` ); break; } return locus; } /** * Sends a metric when we receive something from Locus that uses hash trees while we * expect classic deltas or the other way around. * @param {Meeting} meeting * @param {string} message * @returns {void} */ sendClassicVsHashTreeMismatchMetric(meeting: any, message: string) { LoggerProxy.logger.warn( `Locus-info:index#sendClassicVsHashTreeMismatchMetric --> classic vs hash tree mismatch! ${message}` ); // we don't want to flood the metrics system if (this.classicVsHashTreeMismatchMetricCounter < 5) { Metrics.sendBehavioralMetric(BEHAVIORAL_METRICS.LOCUS_CLASSIC_VS_HASH_TREE_MISMATCH, { correlationId: meeting.correlationId, message, }); this.classicVsHashTreeMismatchMetricCounter += 1; } } /** * Handles a hash tree message received from Locus. * * @param {Meeting} meeting - The meeting object * @param {eventType} eventType - The event type * @param {HashTreeMessage} message incoming hash tree message * @returns {void} */ private handleHashTreeMessage(meeting: any, eventType: LOCUSEVENT, message: HashTreeMessage) { if (eventType !== LOCUSEVENT.HASH_TREE_DATA_UPDATED) { this.sendClassicVsHashTreeMismatchMetric( meeting, `got ${eventType}, expected ${LOCUSEVENT.HASH_TREE_DATA_UPDATED}` ); return; } this.hashTreeParser.handleMessage(message); } /** * Callback registered with HashTreeParser to receive locus info updates. * Updates our locus info based on the data parsed by the hash tree parser. * * @param {LocusInfoUpdateType} updateType - The type of update received. * @param {Object} [data] - Additional data for the update, if applicable. * @returns {void} */ private updateFromHashTree( updateType: LocusInfoUpdateType, data?: {updatedObjects: HashTreeObject[]} ) { switch (updateType) { case LocusInfoUpdateType.OBJECTS_UPDATED: { // initialize our new locus let locus: LocusDTO = { participants: [], jsSdkMeta: {removedParticipantIds: []}, }; // first go over all the updates and check what happens with the main locus object let locusObjectStateAfterUpdates: LocusObjectStateAfterUpdates = LocusObjectStateAfterUpdates.unchanged; data.updatedObjects.forEach((object) => { if (object.htMeta.elementId.type.toLowerCase() === ObjectType.locus) { if (locusObjectStateAfterUpdates === LocusObjectStateAfterUpdates.updated) { // this code doesn't supported it right now, // cases for "updated" followed by "removed", or multiple "updated" would need more handling // but these should never happen LoggerProxy.logger.warn( `Locus-info:index#updateFromHashTree --> received multiple LOCUS objects in one update, this is unexpected!` ); Metrics.sendBehavioralMetric( BEHAVIORAL_METRICS.LOCUS_HASH_TREE_UNSUPPORTED_OPERATION, { locusUrl: object.data?.url || this.url, message: object.data ? 'multiple LOCUS object updates' : 'LOCUS object update followed by removal', } ); } if (object.data) { locusObjectStateAfterUpdates = LocusObjectStateAfterUpdates.updated; } else { locusObjectStateAfterUpdates = LocusObjectStateAfterUpdates.removed; } } }); // if Locus object is unchanged or removed, we need to keep using the existing locus // because the rest of the locusInfo code expects locus to always be present (with at least some of the fields) // if it gets updated, we only need to have the fields that are not part of "locus" object (like "info" or "mediaShares") // so that when Locus object gets updated, if the new one is missing some field, that field will // be removed from our locusInfo if ( locusObjectStateAfterUpdates === LocusObjectStateAfterUpdates.unchanged || locusObjectStateAfterUpdates === LocusObjectStateAfterUpdates.removed ) { // copy over all of existing locus except participants LocusDtoTopLevelKeys.forEach((key) => { if (key !== 'participants') { locus[key] = cloneDeep(this[key]); } }); } else { // initialize only the fields that are not part of main "Locus" object // (except participants, which need to stay empty - that means "no participant changes") Object.values(ObjectTypeToLocusKeyMap).forEach((locusDtoKey) => { if (locusDtoKey !== 'participants') { locus[locusDtoKey] = cloneDeep(this[locusDtoKey]); } }); } LoggerProxy.logger.info( `Locus-info:index#updateFromHashTree --> LOCUS object is ${locusObjectStateAfterUpdates}, all updates: ${JSON.stringify( data.updatedObjects.map((o) => ({ type: o.htMeta.elementId.type, id: o.htMeta.elementId.id, hasData: !!o.data, })) )}` ); // now apply all the updates from the hash tree onto the locus data.updatedObjects.forEach((object) => { locus = this.updateLocusFromHashTreeObject(object, locus); }); // update our locus info with the new locus this.onDeltaLocus(locus); break; } case LocusInfoUpdateType.MEETING_ENDED: { const meeting = this.webex.meetings.meetingCollection.get(this.meetingId); if (meeting) { LoggerProxy.logger.info( `Locus-info:index#updateFromHashTree --> received signal that meeting ended, destroying meeting ${this.meetingId}` ); this.webex.meetings.destroy(meeting, MEETING_REMOVED_REASON.SELF_REMOVED); } } } } /** * @param {Meeting} meeting * @param {Object} data * @returns {undefined} * @memberof LocusInfo */ parse(meeting: any, data: any) { if (this.hashTreeParser) { this.handleHashTreeMessage( meeting, data.eventType, data.stateElementsMessage as HashTreeMessage ); } else { const {eventType} = data; if (eventType === LOCUSEVENT.HASH_TREE_DATA_UPDATED) { // this can happen when we get an event before join http response // it's OK to just ignore it LoggerProxy.logger.info( `Locus-info:index#parse --> received locus hash tree event before hashTreeParser is created` ); return; } const locus = this.getTheLocusToUpdate(data.locus); LoggerProxy.logger.info(`Locus-info:index#parse --> received locus data: ${eventType}`); locus.jsSdkMeta = {removedParticipantIds: []}; switch (eventType) { case LOCUSEVENT.PARTICIPANT_JOIN: case LOCUSEVENT.PARTICIPANT_LEFT: case LOCUSEVENT.CONTROLS_UPDATED: case LOCUSEVENT.PARTICIPANT_AUDIO_MUTED: case LOCUSEVENT.PARTICIPANT_AUDIO_UNMUTED: case LOCUSEVENT.PARTICIPANT_VIDEO_MUTED: case LOCUSEVENT.PARTICIPANT_VIDEO_UNMUTED: case LOCUSEVENT.SELF_CHANGED: case LOCUSEVENT.PARTICIPANT_UPDATED: case LOCUSEVENT.PARTICIPANT_CONTROLS_UPDATED: case LOCUSEVENT.PARTICIPANT_ROLES_UPDATED: case LOCUSEVENT.PARTICIPANT_DECLINED: case LOCUSEVENT.FLOOR_GRANTED: case LOCUSEVENT.FLOOR_RELEASED: this.onFullLocus(`classic locus event ${eventType}`, locus, eventType); break; case LOCUSEVENT.DIFFERENCE: this.handleLocusDelta(locus, meeting); break; default: // Why will there be a event with no eventType ???? // we may not need this, we can get full locus this.handleLocusDelta(locus, meeting); } } } /** * @param {String} scope * @param {String} eventName * @param {Array} args * @returns {undefined} * @memberof LocusInfo */ emitScoped(scope?: any, eventName?: string, args?: any) { return this.emit(scope, eventName, args); } /** * Function for handling full locus when it's using hash trees (so not the "classic" one). * * @param {string} debugText string explaining the trigger for this call, added to logs for debugging purposes * @param {object} locus locus object * @param {object} metadata locus hash trees metadata * @param {string} eventType locus event * @param {DataSet[]} dataSets * @returns {void} */ private onFullLocusWithHashTrees( debugText: string, locus: any, metadata: Metadata, eventType?: string, dataSets?: Array ) { if (!this.hashTreeParser) { LoggerProxy.logger.info( `Locus-info:index#onFullLocus (${debugText}) --> creating hash tree parser` ); LoggerProxy.logger.info( `Locus-info:index#onFullLocus (${debugText}) --> dataSets:`, dataSets, ' and locus:', locus, ' and metadata:', metadata ); this.hashTreeParser = this.createHashTreeParser({ initialLocus: {locus, dataSets}, metadata, }); this.onFullLocusCommon(locus, eventType); } else { // in this case the Locus we're getting is not necessarily the full one // so treat it like if we just got it in any api response LoggerProxy.logger.info( `Locus-info:index#onFullLocus (${debugText}) --> hash tree parser already exists, handling it like a normal API response` ); this.handleLocusAPIResponse(undefined, {dataSets, locus, metadata}); } } /** * Function for handling full locus when it's the "classic" one (not hash trees) * * @param {string} debugText string explaining the trigger for this call, added to logs for debugging purposes * @param {object} locus locus object * @param {string} eventType locus event * @returns {void} */ private onFullLocusClassic(debugText: string, locus: any, eventType?: string) { if (!this.locusParser.isNewFullLocus(locus)) { LoggerProxy.logger.info( `Locus-info:index#onFullLocus (${debugText}) --> ignoring old full locus DTO, eventType=${eventType}` ); return; } this.onFullLocusCommon(locus, eventType); } /** * updates the locus with full locus object * @param {string} debugText string explaining the trigger for this call, added to logs for debugging purposes * @param {object} locus locus object * @param {string} eventType locus event * @param {DataSet[]} dataSets * @param {object} metadata locus hash trees metadata * @returns {object} null * @memberof LocusInfo */ onFullLocus( debugText: string, locus: any, eventType?: string, dataSets?: Array, metadata?: Metadata ) { if (!locus) { LoggerProxy.logger.error( `Locus-info:index#onFullLocus (${debugText}) --> object passed as argument was invalid, continuing.` ); } if (dataSets) { if (!metadata) { throw new Error( `Locus-info:index#onFullLocus (${debugText}) --> hash tree metadata is missing with full Locus` ); } // this is the new hashmap Locus DTO format (only applicable to webinars for now) this.onFullLocusWithHashTrees(debugText, locus, metadata, eventType, dataSets); } else { this.onFullLocusClassic(debugText, locus, eventType); } } /** * Common part of handling full locus, used by both classic and hash tree based locus handling * @param {object} locus locus object * @param {string} eventType locus event * @returns {void} */ private onFullLocusCommon(locus: any, eventType?: string) { this.scheduledMeeting = locus.meeting || null; this.participants = locus.participants; this.participants?.forEach((participant) => { // participant.htMeta is set only for hash tree based locus if (participant.htMeta?.elementId.id) { this.hashTreeObjectId2ParticipantId.set(participant.htMeta.elementId.id, participant.id); } }); const isReplaceMembers = ControlsUtils.isNeedReplaceMembers(this.controls, locus.controls); this.updateLocusInfo(locus); this.updateParticipants( locus.participants, locus.jsSdkMeta?.removedParticipantIds, isReplaceMembers ); this.isMeetingActive(); this.handleOneOnOneEvent(eventType); this.updateEmbeddedApps(locus.embeddedApps); // set current (working copy) for parser this.locusParser.workingCopy = locus; } // used for ringing stops on one on one /** * @param {String} eventType * @returns {undefined} * @memberof LocusInfo */ // eslint-disable-next-line @typescript-eslint/no-shadow handleOneOnOneEvent(eventType: string) { if ( this.parsedLocus.fullState?.type === _CALL_ || this.parsedLocus.fullState?.type === _SIP_BRIDGE_ ) { // for 1:1 bob calls alice and alice declines, notify the meeting state if (eventType === LOCUSEVENT.PARTICIPANT_DECLINED) { // trigger the event for stop ringing this.emitScoped( { file: 'locus-info', function: 'handleOneonOneEvent', }, EVENTS.REMOTE_RESPONSE, { remoteDeclined: true, remoteAnswered: false, } ); } // for 1:1 bob calls alice and alice answers, notify the meeting state if (eventType === LOCUSEVENT.PARTICIPANT_JOIN) { // trigger the event for stop ringing this.emitScoped( { file: 'locus-info', function: 'handleOneonOneEvent', }, EVENTS.REMOTE_RESPONSE, { remoteDeclined: false, remoteAnswered: true, } ); } } } /** * @param {Object} locus * @returns {undefined} * @memberof LocusInfo */ onDeltaLocus(locus: any) { const isReplaceMembers = ControlsUtils.isNeedReplaceMembers(this.controls, locus.controls); this.mergeParticipants(this.participants, locus.participants); this.updateLocusInfo(locus); this.updateParticipants( locus.participants, locus.jsSdkMeta?.removedParticipantIds, isReplaceMembers ); this.isMeetingActive(); } /** * @param {Object} locus * @returns {undefined} * @memberof LocusInfo */ updateLocusInfo(locus) { if (locus.self?.reason === 'MOVED' && locus.self?.state === 'LEFT') { // When moved to a breakout session locus sends a message for the previous locus // indicating that we have been moved. It isn't helpful to continue parsing this // as it gets interpreted as if we have left the call return; } this.updateControls(locus.controls, locus.self); this.updateConversationUrl(locus.conversationUrl, locus.info); this.updateCreated(locus.created); this.updateFullState(locus.fullState); this.updateHostInfo(locus.host); this.updateLocusUrl(locus.url, ControlsUtils.isMainSessionDTO(locus)); this.updateMeetingInfo(locus.info, locus.self); this.updateMediaShares(locus.mediaShares); this.updateReplaces(locus.replaces); this.updateSelf(locus.self); this.updateAclUrl(locus.aclUrl); this.updateBasequence(locus.baseSequence); this.updateSequence(locus.sequence); this.updateEmbeddedApps(locus.embeddedApps); this.updateLinks(locus.links); this.compareAndUpdate(); // update which required to compare different objects from locus } /** * @param {Array} participants * @param {Object} self * @returns {Array} * @memberof LocusInfo */ getLocusPartner(participants: Array, self: any) { if (!participants || participants.length === 0) { return null; } return ( participants.find( (participant) => self && participant.identity !== self.identity && (participants.length <= 2 || (participant.type === _USER_ && !participant.removed)) // @ts-ignore ) || this.partner ); } // TODO: all the leave states need to be checked /** * @returns {undefined} * @memberof LocusInfo */ isMeetingActive() { if ( this.parsedLocus.fullState?.type === _CALL_ || this.parsedLocus.fullState?.type === _SIP_BRIDGE_ || this.parsedLocus.fullState?.type === _SPACE_SHARE_ ) { // @ts-ignore const partner = this.getLocusPartner(this.participants, this.self); this.updateMeeting({partner}); // Check if guest user needs to be checked here // 1) when bob declines call from bob, (bob='DECLINED') // 2) When alice rejects call to bob , (bob='NOTIFIED') // When we dont add MEDIA for condition 2. The state of bob='IDLE' if (this.fullState && this.fullState.state === LOCUS.STATE.INACTIVE) { // TODO: update the meeting state LoggerProxy.logger.warn( 'Locus-info:index#isMeetingActive --> Call Ended, locus state is inactive.' ); // @ts-ignore this.webex.internal.newMetrics.submitClientEvent({ name: 'client.call.remote-ended', options: { meetingId: this.meetingId, }, }); this.emitScoped( { file: 'locus-info', function: 'isMeetingActive', }, EVENTS.DESTROY_MEETING, { reason: CALL_REMOVED_REASON.CALL_INACTIVE, shouldLeave: false, } ); } else if ( partner.state === MEETING_STATE.STATES.LEFT && this.parsedLocus.self && (this.parsedLocus.self.state === MEETING_STATE.STATES.DECLINED || this.parsedLocus.self.state === MEETING_STATE.STATES.NOTIFIED || this.parsedLocus.self.state === MEETING_STATE.STATES.JOINED) ) { // @ts-ignore this.webex.internal.newMetrics.submitClientEvent({ name: 'client.call.remote-ended', options: { meetingId: this.meetingId, }, }); this.emitScoped( { file: 'locus-info', function: 'isMeetingActive', }, EVENTS.DESTROY_MEETING, { reason: CALL_REMOVED_REASON.PARTNER_LEFT, shouldLeave: this.parsedLocus.self.joinedWith && this.parsedLocus.self.joinedWith.state !== _LEFT_, } ); } else if ( this.parsedLocus.self && this.parsedLocus.self.state === MEETING_STATE.STATES.LEFT && (partner.state === MEETING_STATE.STATES.LEFT || partner.state === MEETING_STATE.STATES.DECLINED || partner.state === MEETING_STATE.STATES.NOTIFIED || partner.state === MEETING_STATE.STATES.IDLE) // Happens when user just joins and adds no Media ) { // @ts-ignore this.webex.internal.newMetrics.submitClientEvent({ name: 'client.call.remote-ended', options: { meetingId: this.meetingId, }, }); this.emitScoped( { file: 'locus-info', function: 'isMeetingActive', }, EVENTS.DESTROY_MEETING, { reason: CALL_REMOVED_REASON.SELF_LEFT, shouldLeave: false, } ); } } else if (this.parsedLocus.fullState?.type === _MEETING_) { if ( this.fullState && (this.fullState.state === LOCUS.STATE.INACTIVE || // @ts-ignore this.fullState.state === LOCUS.STATE.TERMINATING) ) { LoggerProxy.logger.warn( 'Locus-info:index#isMeetingActive --> Meeting is ending due to inactive or terminating' ); // @ts-ignore this.webex.internal.newMetrics.submitClientEvent({ name: 'client.call.remote-ended', options: { meetingId: this.meetingId, }, }); this.emitScoped( { file: 'locus-info', function: 'isMeetingActive', }, EVENTS.DESTROY_MEETING, { reason: MEETING_REMOVED_REASON.MEETING_INACTIVE_TERMINATING, shouldLeave: false, } ); } // If you are guest and you are removed from the meeting // You wont get any further events else if (this.parsedLocus.self && this.parsedLocus.self.removed) { // Check if we need to send an event this.emitScoped( { file: 'locus-info', function: 'isMeetingActive', }, EVENTS.DESTROY_MEETING, { reason: MEETING_REMOVED_REASON.SELF_REMOVED, shouldLeave: false, } ); } } else { LoggerProxy.logger.warn('Locus-info:index#isMeetingActive --> Meeting Type is unknown.'); } } /** * checks if the host permissions have changed while in the meeting * This would be the case if your role as host or moderator has been updated * @returns {undefined} * @memberof LocusInfo */ compareAndUpdate() { // TODO: check with locus team on host and moderator doc // use host as a validator if needed if ( this.compareAndUpdateFlags.compareSelfAndHost || this.compareAndUpdateFlags.compareHostAndSelf ) { this.compareSelfAndHost(); } } /** * compared the self object to check if the user has host permissions * @returns {undefined} * @memberof LocusInfo */ compareSelfAndHost() { // In some cases the host info is not present but the moderator values changes from null to false so it triggers an update if ( this.parsedLocus.self && this.parsedLocus.self.selfIdentity === this.parsedLocus.host?.hostId && this.parsedLocus.self.moderator ) { this.emitScoped( { file: 'locus-info', function: 'compareSelfAndHost', }, EVENTS.LOCUS_INFO_CAN_ASSIGN_HOST, { canAssignHost: true, } ); } else { this.emitScoped( { file: 'locus-info', function: 'compareSelfAndHost', }, EVENTS.LOCUS_INFO_CAN_ASSIGN_HOST, { canAssignHost: false, } ); } } /** * update meeting's members * @param {Object} participants new participants object * @param {Array} removedParticipantIds list of removed participants * @param {Boolean} isReplace is replace the whole members * @returns {Array} updatedParticipants * @memberof LocusInfo */ updateParticipants(participants: object, removedParticipantIds?: string[], isReplace?: boolean) { this.emitScoped( { file: 'locus-info', function: 'updateParticipants', }, EVENTS.LOCUS_INFO_UPDATE_PARTICIPANTS, { participants, removedParticipantIds, recordingId: this.parsedLocus.controls && this.parsedLocus.controls.record?.modifiedBy, selfIdentity: this.parsedLocus.self && this.parsedLocus.self.selfIdentity, selfId: this.parsedLocus.self && this.parsedLocus.self.selfId, hostId: this.parsedLocus.host && this.parsedLocus.host.hostId, isReplace, } ); if (participants && Array.isArray(participants) && participants.length > 0) { for (const participant of participants) { if (participant && participant?.reason === 'FAILURE') { this.emitScoped( { file: 'locus-info', function: 'updateParticipants', }, LOCUSINFO.EVENTS.PARTICIPANT_REASON_CHANGED, { displayName: participant?.person?.primaryDisplayString, } ); } } } } /** * @param {Object} controls * @param {Object} self * @returns {undefined} * @memberof LocusInfo */ updateControls(controls: object, self: object) { if (controls && !isEqual(this.controls, controls)) { this.parsedLocus.controls = ControlsUtils.parse(controls); const { updates: { hasRecordingChanged, hasRecordingPausedChanged, hasMeetingContainerChanged, hasTranscribeChanged, hasHesiodLLMIdChanged, hasAiSummaryNotificationChanged, hasTranscribeSpokenLanguageChanged, hasManualCaptionChanged, hasEntryExitToneChanged, hasBreakoutChanged, hasVideoEnabledChanged, hasMuteOnEntryChanged, hasShareControlChanged, hasDisallowUnmuteChanged, hasReactionsChanged, hasReactionDisplayNamesChanged, hasViewTheParticipantListChanged, hasRaiseHandChanged, hasVideoChanged, hasInterpretationChanged, hasWebcastChanged, hasMeetingFullChanged, hasPracticeSessionEnabledChanged, hasStageViewChanged, hasAnnotationControlChanged, hasRemoteDesktopControlChanged, hasPollingQAControlChanged, hasAutoEndMeetingChanged, }, current, } = ControlsUtils.getControls(this.controls, controls); if (hasMuteOnEntryChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_MUTE_ON_ENTRY_CHANGED, {state: current.muteOnEntry} ); } if (hasShareControlChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_SHARE_CONTROL_CHANGED, {state: current.shareControl} ); } if (hasDisallowUnmuteChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_DISALLOW_UNMUTE_CHANGED, {state: current.disallowUnmute} ); } if (hasReactionsChanged || hasReactionDisplayNamesChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_REACTIONS_CHANGED, {state: current.reactions} ); } if (hasViewTheParticipantListChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_VIEW_THE_PARTICIPANTS_LIST_CHANGED, {state: current.viewTheParticipantList} ); } if (hasRaiseHandChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_RAISE_HAND_CHANGED, {state: current.raiseHand} ); } if (hasVideoChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_VIDEO_CHANGED, {state: current.video} ); } if (hasRecordingChanged || hasRecordingPausedChanged) { let state = null; if (hasRecordingPausedChanged) { if (current.record.paused) { state = RECORDING_STATE.PAUSED; } else { // state will be `IDLE` if the recording is not active, even when there is a `pause` status change. state = current.record.recording ? RECORDING_STATE.RESUMED : RECORDING_STATE.IDLE; } } else if (hasRecordingChanged) { state = current.record.recording ? RECORDING_STATE.RECORDING : RECORDING_STATE.IDLE; } this.emitScoped( { file: 'locus-info', function: 'updateControls', }, LOCUSINFO.EVENTS.CONTROLS_RECORDING_UPDATED, { state, modifiedBy: current.record.modifiedBy, lastModified: current.record.lastModified, } ); } if (hasMeetingContainerChanged) { const {meetingContainerUrl} = current.meetingContainer; this.emitScoped( { file: 'locus-info', function: 'updateControls', }, LOCUSINFO.EVENTS.CONTROLS_MEETING_CONTAINER_UPDATED, { meetingContainerUrl, } ); } if (hasTranscribeChanged) { const {transcribing, caption} = current.transcribe; this.emitScoped( { file: 'locus-info', function: 'updateControls', }, LOCUSINFO.EVENTS.CONTROLS_MEETING_TRANSCRIBE_UPDATED, { transcribing, caption, } ); } if (hasHesiodLLMIdChanged) { const {hesiodLlmId} = current.transcribe; this.emitScoped( { file: 'locus-info', function: 'updateControls', }, LOCUSINFO.EVENTS.CONTROLS_MEETING_HESIOD_LLM_ID_UPDATED, { hesiodLlmId, } ); } if (hasAiSummaryNotificationChanged) { this.emitScoped( { file: 'locus-info', function: 'updateControls', }, LOCUSINFO.EVENTS.CONTROLS_AI_SUMMARY_NOTIFICATION_UPDATED, { aiSummaryNotification: current.transcribe.aiSummaryNotification, } ); } if (hasTranscribeSpokenLanguageChanged) { const {spokenLanguage} = current.transcribe; this.emitScoped( { file: 'locus-info', function: 'updateControls', }, LOCUSINFO.EVENTS.CONTROLS_MEETING_TRANSCRIPTION_SPOKEN_LANGUAGE_UPDATED, { spokenLanguage, } ); } if (hasManualCaptionChanged) { const {enabled} = current.manualCaptionControl; this.emitScoped( { file: 'locus-info', function: 'updateControls', }, LOCUSINFO.EVENTS.CONTROLS_MEETING_MANUAL_CAPTION_UPDATED, { enabled, } ); } if (hasBreakoutChanged) { const {breakout} = current; breakout.breakoutMoveId = SelfUtils.getReplacedBreakoutMoveId( self, this.webex.internal.device.url ); this.emitScoped( { file: 'locus-info', function: 'updateControls', }, LOCUSINFO.EVENTS.CONTROLS_MEETING_BREAKOUT_UPDATED, { breakout, } ); } if (hasInterpretationChanged) { const {interpretation} = current; this.emitScoped( { file: 'locus-info', function: 'updateControls', }, LOCUSINFO.EVENTS.CONTROLS_MEETING_INTERPRETATION_UPDATED, { interpretation, } ); } if (hasEntryExitToneChanged) { const {entryExitTone} = current; this.updateMeeting({entryExitTone}); this.emitScoped( { file: 'locus-info', function: 'updateControls', }, LOCUSINFO.EVENTS.CONTROLS_ENTRY_EXIT_TONE_UPDATED, { entryExitTone, } ); } // videoEnabled is handled differently than other controls, // to fit with audio mute status logic if (hasVideoEnabledChanged) { const {videoEnabled} = current; this.updateMeeting({unmuteVideoAllowed: videoEnabled}); this.emitScoped( { file: 'locus-info', function: 'updateControls', }, LOCUSINFO.EVENTS.SELF_REMOTE_VIDEO_MUTE_STATUS_UPDATED, { // muted: not part of locus.controls unmuteAllowed: videoEnabled, } ); } if (hasWebcastChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_WEBCAST_CHANGED, {state: current.webcastControl} ); } if (hasMeetingFullChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_MEETING_FULL_CHANGED, {state: current.meetingFull} ); } if (hasPracticeSessionEnabledChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_PRACTICE_SESSION_STATUS_UPDATED, {state: current.practiceSession} ); } if (hasStageViewChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_STAGE_VIEW_UPDATED, {state: current.videoLayout} ); } if (hasAnnotationControlChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_ANNOTATION_CHANGED, {state: current.annotationControl} ); } if (hasRemoteDesktopControlChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_REMOTE_DESKTOP_CONTROL_CHANGED, {state: current.rdcControl} ); } if (hasPollingQAControlChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_POLLING_QA_CHANGED, {state: current.pollingQAControl} ); } if (hasAutoEndMeetingChanged) { this.emitScoped( {file: 'locus-info', function: 'updateControls'}, LOCUSINFO.EVENTS.CONTROLS_AUTO_END_MEETING_WARNING_CHANGED, {state: current.autoEndMeetingWarning} ); } this.controls = controls; } } /** * @param {String} conversationUrl * @param {Object} info * @returns {undefined} * @memberof LocusInfo */ updateConversationUrl(conversationUrl: string, info: any) { if (conversationUrl && !isEqual(this.conversationUrl, conversationUrl)) { this.conversationUrl = conversationUrl; this.updateMeeting({conversationUrl}); } else if ( info && info.conversationUrl && !isEqual(this.conversationUrl, info.conversationUrl) ) { this.conversationUrl = info.conversationUrl; this.updateMeeting({conversationUrl: info.conversationUrl}); } } /** * @param {Object} created * @returns {undefined} * @memberof LocusInfo */ updateCreated(created: object) { if (created && !isEqual(this.created, created)) { this.created = created; } } /** * Updates links and emits appropriate events if services or resources have changed * @param {Object} links * @returns {undefined} * @memberof LocusInfo */ updateLinks(links?: Links) { const {services, resources} = links || {}; if (services && !isEqual(this.links?.services, services)) { this.emitScoped( { file: 'locus-info', function: 'updateLinks', }, LOCUSINFO.EVENTS.LINKS_SERVICES, { services, } ); } if (resources && !isEqual(this.links?.resources, resources)) { this.emitScoped( { file: 'locus-info', function: 'updateLinks', }, LOCUSINFO.EVENTS.LINKS_RESOURCES, { resources, } ); } this.links = links; } /** * @param {Object} fullState * @returns {undefined} * @memberof LocusInfo */ updateFullState(fullState: object) { if (fullState && !isEqual(this.fullState, fullState)) { const result = FullState.getFullState(this.fullState, fullState); this.updateMeeting(result.current); if (result.updates.meetingStateChangedTo) { this.emitScoped( { file: 'locus-info', function: 'updateFullState', }, LOCUSINFO.EVENTS.FULL_STATE_MEETING_STATE_CHANGE, { previousState: result.previous && result.previous.meetingState, currentState: result.current.meetingState, } ); } if (result.updates.meetingTypeChangedTo) { this.emitScoped( { file: 'locus-info', function: 'updateFullState', }, LOCUSINFO.EVENTS.FULL_STATE_TYPE_UPDATE, { type: result.current.type, } ); } this.parsedLocus.fullState = result.current; this.fullState = fullState; } } /** * handles when the locus.host is updated * @param {Object} host the locus.host property * @returns {undefined} * @memberof LocusInfo * emits internal event locus_info_update_host */ updateHostInfo(host: object) { if (host && !isEqual(this.host, host)) { const parsedHosts = HostUtils.getHosts(this.host, host); this.updateMeeting(parsedHosts.current); this.parsedLocus.host = parsedHosts.current; if (parsedHosts.updates.isNewHost) { this.compareAndUpdateFlags.compareSelfAndHost = true; this.emitScoped( { file: 'locus-info', function: 'updateHostInfo', }, EVENTS.LOCUS_INFO_UPDATE_HOST, { newHost: parsedHosts.current, oldHost: parsedHosts.previous, } ); } this.host = host; } else { this.compareAndUpdateFlags.compareSelfAndHost = false; } } /** * @param {Object} info * @param {Object} self * @returns {undefined} * @memberof LocusInfo */ updateMeetingInfo(info: object, self?: object) { const roles = self ? SelfUtils.getRoles(self) : this.parsedLocus.self?.roles || []; if ((info && !isEqual(this.info, info)) || (!isEqual(this.roles, roles) && info)) { const isJoined = SelfUtils.isJoined(self || this.parsedLocus.self); const parsedInfo = InfoUtils.getInfos(this.parsedLocus.info, info, roles, isJoined); if (parsedInfo.updates.isLocked) { this.emitScoped( { file: 'locus-info', function: 'updateMeetingInfo', }, LOCUSINFO.EVENTS.MEETING_LOCKED, info ); } if (parsedInfo.updates.isUnlocked) { this.emitScoped( { file: 'locus-info', function: 'updateMeetingInfo', }, LOCUSINFO.EVENTS.MEETING_UNLOCKED, info ); } this.info = info; this.parsedLocus.info = parsedInfo.current; // Parses the info and adds necessary values this.updateMeeting(parsedInfo.current); this.emitScoped( { file: 'locus-info', function: 'updateMeetingInfo', }, LOCUSINFO.EVENTS.MEETING_INFO_UPDATED, { isInitializing: !self, // if self is undefined, then the update is caused by locus init } ); } this.roles = roles; } /** * @param {Object} embeddedApps * @returns {undefined} * @memberof LocusInfo */ updateEmbeddedApps(embeddedApps: object) { // don't do anything if the arrays of apps haven't changed significantly if (EmbeddedAppsUtils.areSimilar(this.embeddedApps, embeddedApps)) { return; } const parsedEmbeddedApps = EmbeddedAppsUtils.parse(embeddedApps); this.updateMeeting({embeddedApps: parsedEmbeddedApps}); this.emitScoped( { file: 'locus-info', function: 'updateEmbeddedApps', }, LOCUSINFO.EVENTS.EMBEDDED_APPS_UPDATED, parsedEmbeddedApps ); this.embeddedApps = embeddedApps; } /** * handles when the locus.mediaShares is updated * @param {Object} mediaShares the locus.mediaShares property * @param {boolean} forceUpdate force to update the mediaShares * @returns {undefined} * @memberof LocusInfo * emits internal event locus_info_update_media_shares */ updateMediaShares(mediaShares: object, forceUpdate = false) { if (mediaShares && (!isEqual(this.mediaShares, mediaShares) || forceUpdate)) { const parsedMediaShares = MediaSharesUtils.getMediaShares(this.mediaShares, mediaShares); this.updateMeeting(parsedMediaShares.current); this.parsedLocus.mediaShares = parsedMediaShares.current; this.mediaShares = mediaShares; this.emitScoped( { file: 'locus-info', function: 'updateMediaShares', }, EVENTS.LOCUS_INFO_UPDATE_MEDIA_SHARES, { current: parsedMediaShares.current, previous: parsedMediaShares.previous, forceUpdate, } ); } } /** * @param {Object} replaces * @returns {undefined} * @memberof LocusInfo */ updateReplaces(replaces: object) { if (replaces && !isEqual(this.replaces, replaces)) { this.replaces = replaces; } } /** * handles when the locus.self is updated * @param {Object} self the new locus.self * @returns {undefined} * @memberof LocusInfo * emits internal events self_admitted_guest, self_unadmitted_guest, locus_info_update_self */ updateSelf(self: any) { if (self) { // @ts-ignore const parsedSelves = SelfUtils.getSelves( this.parsedLocus.self, self, this.webex.internal.device.url, this.participants // using this.participants instead of locus.participants here, because with delta DTOs locus.participants will only contain a small subset of participants ); this.updateMeeting(parsedSelves.current); this.parsedLocus.self = parsedSelves.current; const element = this.parsedLocus.states[this.parsedLocus.states.length - 1]; if (element !== parsedSelves.current.state) { this.parsedLocus.states.push(parsedSelves.current.state); } // TODO: check if we need to save the sipUri here as well // this.emit(LOCUSINFO.EVENTS.MEETING_UPDATE, SelfUtils.getSipUrl(this.getLocusPartner(participants, self), this.parsedLocus.fullState?.type, this.parsedLocus.info?.sipUri)); const result = SelfUtils.getSipUrl( this.getLocusPartner(this.participants, self), this.parsedLocus.fullState?.type, this.parsedLocus.info?.sipUri ); if (result?.sipUri) { this.updateMeeting(result); } if (parsedSelves.updates.moderatorChanged) { this.compareAndUpdateFlags.compareHostAndSelf = true; } else { this.compareAndUpdateFlags.compareHostAndSelf = false; } if (parsedSelves.updates.layoutChanged) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.CONTROLS_MEETING_LAYOUT_UPDATED, {layout: parsedSelves.current.layout} ); } if (parsedSelves.updates.breakoutsChanged) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.SELF_MEETING_BREAKOUTS_CHANGED, {breakoutSessions: parsedSelves.current.breakoutSessions} ); } if (parsedSelves.updates.brbChanged) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.SELF_MEETING_BRB_CHANGED, { brb: parsedSelves.current.brb, } ); } if (parsedSelves.updates.selfIdChanged) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.SELF_ID_CHANGED, { selfId: parsedSelves.current.selfId, } ); } if (parsedSelves.updates.interpretationChanged) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.SELF_MEETING_INTERPRETATION_CHANGED, { interpretation: parsedSelves.current.interpretation, selfParticipantId: parsedSelves.current.selfId, } ); } if (parsedSelves.updates.isMediaInactiveOrReleased) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.DISCONNECT_DUE_TO_INACTIVITY, {reason: self.reason} ); } if (parsedSelves.updates.moderatorChanged) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.SELF_MODERATOR_CHANGED, self ); } if (parsedSelves.updates.isRolesChanged) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.SELF_ROLES_CHANGED, {oldRoles: parsedSelves.previous?.roles, newRoles: parsedSelves.current?.roles} ); } if (parsedSelves.updates.isVideoMutedByOthersChanged) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.SELF_REMOTE_VIDEO_MUTE_STATUS_UPDATED, { muted: parsedSelves.current.remoteVideoMuted, // unmuteAllowed: not part of .self } ); } if (parsedSelves.updates.localAudioUnmuteRequiredByServer) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.LOCAL_UNMUTE_REQUIRED, { muted: parsedSelves.current.remoteMuted, unmuteAllowed: parsedSelves.current.unmuteAllowed, } ); } if (parsedSelves.updates.isMutedByOthersChanged) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.SELF_REMOTE_MUTE_STATUS_UPDATED, { muted: parsedSelves.current.remoteMuted, unmuteAllowed: parsedSelves.current.unmuteAllowed, } ); } if (parsedSelves.updates.localAudioUnmuteRequestedByServer) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.LOCAL_UNMUTE_REQUESTED, {} ); } if (parsedSelves.updates.hasUserEnteredLobby) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.SELF_UNADMITTED_GUEST, self ); } if (parsedSelves.updates.hasUserBeenAdmitted) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.SELF_ADMITTED_GUEST, self ); } if (parsedSelves.updates.isMediaInactive) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, // @ts-ignore LOCUSINFO.EVENTS.MEDIA_INACTIVITY, SelfUtils.getMediaStatus(self.mediaSessions) ); } if ( parsedSelves.updates.audioStateChange || parsedSelves.updates.videoStateChange || parsedSelves.updates.shareStateChange ) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.MEDIA_STATUS_CHANGE, { audioStatus: parsedSelves.current.currentMediaStatus?.audio, videoStatus: parsedSelves.current.currentMediaStatus?.video, shareStatus: parsedSelves.current.currentMediaStatus?.share, } ); } if (parsedSelves.updates.isUserObserving) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.SELF_OBSERVING ); } if (parsedSelves.updates.canNotViewTheParticipantListChanged) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.SELF_CANNOT_VIEW_PARTICIPANT_LIST_CHANGE, {canNotViewTheParticipantList: parsedSelves.current.canNotViewTheParticipantList} ); } if (parsedSelves.updates.isSharingBlockedChanged) { this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, LOCUSINFO.EVENTS.SELF_IS_SHARING_BLOCKED_CHANGE, {isSharingBlocked: parsedSelves.current.isSharingBlocked} ); } this.emitScoped( { file: 'locus-info', function: 'updateSelf', }, EVENTS.LOCUS_INFO_UPDATE_SELF, { oldSelf: parsedSelves.previous, newSelf: parsedSelves.current, } ); this.parsedLocus.self = parsedSelves.current; // @ts-ignore this.self = self; } else { this.compareAndUpdateFlags.compareHostAndSelf = false; } } /** * handles when the locus.url is updated * @param {String} url * @param {Boolean} isMainLocus * @returns {undefined} * emits internal event locus_info_update_url */ updateLocusUrl(url: string, isMainLocus = true) { if (url && this.url !== url) { this.url = url; this.updateMeeting({locusUrl: url}); this.emitScoped( { file: 'locus-info', function: 'updateLocusUrl', }, EVENTS.LOCUS_INFO_UPDATE_URL, {url, isMainLocus} ); } } /** * @param {String} aclUrl * @returns {undefined} * @memberof LocusInfo */ updateAclUrl(aclUrl: string) { if (aclUrl && !isEqual(this.aclUrl, aclUrl)) { this.aclUrl = aclUrl; } } /** * @param {Number} baseSequence * @returns {undefined} * @memberof LocusInfo */ updateBasequence(baseSequence: number) { if (baseSequence && !isEqual(this.baseSequence, baseSequence)) { this.baseSequence = baseSequence; } } /** * @param {Number} sequence * @returns {undefined} * @memberof LocusInfo */ updateSequence(sequence: number) { if (sequence && !isEqual(this.sequence, sequence)) { this.sequence = sequence; } } /** * check the locus is main session's one or not, if is main session's, update main session cache * @param {Object} locus * @returns {undefined} * @memberof LocusInfo */ updateLocusCache(locus: any) { const isMainSessionDTO = ControlsUtils.isMainSessionDTO(locus); if (isMainSessionDTO) { this.updateMainSessionLocusCache(locus); } } /** * if return from breakout to main session, need to use cached main session DTO since locus won't send the full locus (participants) * if join breakout from main session, main session is not active for the attendee and remove main session locus cache * @param {Object} newLocus * @returns {Object} * @memberof LocusInfo */ getTheLocusToUpdate(newLocus: any) { const switchStatus = ControlsUtils.getSessionSwitchStatus(this, newLocus); if (switchStatus.isReturnToMain && this.mainSessionLocusCache) { return cloneDeep(this.mainSessionLocusCache); } const isMainSessionDTO = this.mainSessionLocusCache && ControlsUtils.isMainSessionDTO(this.mainSessionLocusCache); if (isMainSessionDTO) { const isActive = [LOCUS.STATE.ACTIVE, LOCUS.STATE.INITIALIZING, LOCUS.STATE.TERMINATING].includes( this.fullState?.state ) && !this.mainSessionLocusCache?.self?.removed; if (!isActive) { this.clearMainSessionLocusCache(); } } return newLocus; } /** * merge participants by participant id * @param {Array} participants * @param {Array} sourceParticipants * @returns {Array} merged participants * @memberof LocusInfo */ // eslint-disable-next-line class-methods-use-this mergeParticipants(participants, sourceParticipants) { if (!sourceParticipants || !sourceParticipants.length) return participants; if (!participants || !participants.length) { return sourceParticipants; } sourceParticipants.forEach((participant) => { const existIndex = participants.findIndex((p) => p.id === participant.id); if (existIndex > -1) { participants.splice(existIndex, 1, participant); } else { participants.push(participant); } }); return participants; } /** * need cache main sessions' participants since locus will not send the full list when cohost/host leave breakout * @param {Object} mainLocus * @returns {undefined} * @memberof LocusInfo */ updateMainSessionLocusCache(mainLocus: any) { if (!mainLocus) { return; } const locusClone = cloneDeep(mainLocus); if (this.mainSessionLocusCache) { // shallow merge and do special merge for participants assignWith(this.mainSessionLocusCache, locusClone, (objValue, srcValue, key) => { if (key === 'participants') { return this.mergeParticipants(objValue, srcValue); } return srcValue || objValue; }); } else { this.mainSessionLocusCache = locusClone; } } /** * clear main session cache * @returns {undefined} * @memberof LocusInfo */ clearMainSessionLocusCache() { this.mainSessionLocusCache = null; } }