import {LocalCameraStream, LocalMicrophoneStream} from '@webex/media-helpers'; import url from 'url'; import {cloneDeep} from 'lodash'; import {MeetingNotActiveError, UserNotJoinedError} from '../common/errors/webex-errors'; import LoggerProxy from '../common/logs/logger-proxy'; import { INTENT_TO_JOIN, _LEFT_, _IDLE_, _JOINED_, PASSWORD_STATUS, DISPLAY_HINTS, FULL_STATE, SELF_POLICY, EVENT_TRIGGERS, LOCAL_SHARE_ERRORS, IP_VERSION, } from '../constants'; import BrowserDetection from '../common/browser-detection'; import IntentToJoinError from '../common/errors/intent-to-join'; import JoinMeetingError from '../common/errors/join-meeting'; import ParameterError from '../common/errors/parameter'; import PermissionError from '../common/errors/permission'; import PasswordError from '../common/errors/password-error'; import CaptchaError from '../common/errors/captcha-error'; import Trigger from '../common/events/trigger-proxy'; import {ServerRoles} from '../member/types'; const MeetingUtil = { parseLocusJoin: (response) => { const parsed: any = {}; // First todo: add check for existance parsed.locus = response.body.locus; parsed.dataSets = response.body.dataSets; parsed.metadata = response.body.metaData; parsed.mediaConnections = response.body.mediaConnections; parsed.locusUrl = parsed.locus.url; parsed.locusId = parsed.locus.url.split('/').pop(); parsed.selfId = parsed.locus.self.id; // we need mediaId before making roap calls parsed.mediaConnections.forEach((mediaConnection) => { if (mediaConnection.mediaId) { parsed.mediaId = mediaConnection.mediaId; } }); return parsed; }, /** * Sanitizes a WebSocket URL by extracting only protocol, host, and pathname * Returns concatenated protocol + host + pathname for safe logging * Note: This is used for logging only; URL matching uses partial matching via _urlsPartiallyMatch * @param {string} urlString - The URL to sanitize * @returns {string} Sanitized URL or empty string if parsing fails */ sanitizeWebSocketUrl: (urlString: string): string => { if (!urlString || typeof urlString !== 'string') { return ''; } try { const parsedUrl = url.parse(urlString); const protocol = parsedUrl.protocol || ''; const host = parsedUrl.host || ''; // If we don't have at least protocol and host, it's not a valid URL if (!protocol || !host) { return ''; } const pathname = parsedUrl.pathname || ''; // Strip trailing slash if pathname is just '/' const normalizedPathname = pathname === '/' ? '' : pathname; return `${protocol}//${host}${normalizedPathname}`; } catch (error) { LoggerProxy.logger.warn( `Meeting:util#sanitizeWebSocketUrl --> unable to parse URL: ${error}` ); return ''; } }, /** * Checks if two URLs partially match using an endsWith approach * Combines host and pathname, then checks if one ends with the other * This handles cases where one URL goes through a proxy (e.g., /webproxy/) while the other is direct * @param {string} url1 - First URL to compare * @param {string} url2 - Second URL to compare * @returns {boolean} True if one URL path ends with the other (partial match), false otherwise */ _urlsPartiallyMatch: (url1: string, url2: string): boolean => { if (!url1 || !url2) { return false; } try { const parsedUrl1 = url.parse(url1); const parsedUrl2 = url.parse(url2); const host1 = parsedUrl1.host || ''; const host2 = parsedUrl2.host || ''; const pathname1 = parsedUrl1.pathname || ''; const pathname2 = parsedUrl2.pathname || ''; // If either failed to parse, they don't match if (!host1 || !host2 || !pathname1 || !pathname2) { return false; } // Combine host and pathname for comparison const combined1 = host1 + pathname1; const combined2 = host2 + pathname2; // Check if one combined path ends with the other (handles proxy URLs) return combined1.endsWith(combined2) || combined2.endsWith(combined1); } catch (e) { LoggerProxy.logger.warn('Meeting:util#_urlsPartiallyMatch --> error comparing URLs', e); return false; } }, /** * Gets socket URL information for metrics, including whether the socket URLs match * Uses partial matching to handle proxy URLs (e.g., URLs with /webproxy/ prefix) * @param {Object} webex - The webex instance * @returns {Object} Object with hasMismatchedSocket, mercurySocketUrl, and deviceSocketUrl properties */ getSocketUrlInfo: ( webex: any ): {hasMismatchedSocket: boolean; mercurySocketUrl: string; deviceSocketUrl: string} => { try { const mercuryUrl = webex?.internal?.mercury?.socket?.url; const deviceUrl = webex?.internal?.device?.webSocketUrl; const sanitizedMercuryUrl = MeetingUtil.sanitizeWebSocketUrl(mercuryUrl); const sanitizedDeviceUrl = MeetingUtil.sanitizeWebSocketUrl(deviceUrl); // Only report a mismatch if both URLs are present and they don't match // If either URL is missing, we can't determine if there's a mismatch, so return false let hasMismatchedSocket = false; if (sanitizedMercuryUrl && sanitizedDeviceUrl) { hasMismatchedSocket = !MeetingUtil._urlsPartiallyMatch(mercuryUrl, deviceUrl); } return { hasMismatchedSocket, mercurySocketUrl: sanitizedMercuryUrl, deviceSocketUrl: sanitizedDeviceUrl, }; } catch (error) { LoggerProxy.logger.warn( `Meeting:util#getSocketUrlInfo --> error getting socket URL info: ${error}` ); return { hasMismatchedSocket: false, mercurySocketUrl: '', deviceSocketUrl: '', }; } }, remoteUpdateAudioVideo: (meeting, audioMuted?: boolean, videoMuted?: boolean) => { if (!meeting) { return Promise.reject(new ParameterError('You need a meeting object.')); } if (!meeting.locusMediaRequest) { return Promise.reject( new ParameterError( 'You need a meeting with a media connection, call Meeting.addMedia() first.' ) ); } return meeting.locusMediaRequest.send({ type: 'LocalMute', selfUrl: meeting.selfUrl, mediaId: meeting.mediaId, sequence: meeting.locusInfo.sequence, muteOptions: { audioMuted, videoMuted, }, }); }, hasOwner: (info) => info && info.owner, isOwnerSelf: (owner, selfId) => owner === selfId, isPinOrGuest: (err) => err?.body?.errorCode && INTENT_TO_JOIN.includes(err.body.errorCode), /** * Returns the current state of knowledge about whether we are on an ipv4-only or ipv6-only or mixed (ipv4 and ipv6) network. * The return value matches the possible values of "ipver" parameter used by the backend APIs. * * @param {Object} webex webex instance * @returns {IP_VERSION|undefined} ipver value to be passed to the backend APIs or undefined if we should not pass any value to the backend */ getIpVersion(webex: any): IP_VERSION | undefined { const {supportsIpV4, supportsIpV6} = webex.internal.device.ipNetworkDetector; if ( !webex.config.meetings.backendIpv6NativeSupport && BrowserDetection().isBrowser('firefox') ) { // when backend doesn't support native ipv6, // then our NAT64/DNS64 based solution relies on FQDN ICE candidates, but Firefox doesn't support them, // see https://bugzilla.mozilla.org/show_bug.cgi?id=1713128 // so for Firefox we don't want the backend to activate the "ipv6 feature" return undefined; } if (supportsIpV4 && supportsIpV6) { return IP_VERSION.ipv4_and_ipv6; } if (supportsIpV4) { return IP_VERSION.only_ipv4; } if (supportsIpV6) { return IP_VERSION.only_ipv6; } return IP_VERSION.unknown; }, /** * Returns CA event labels related to Orpheus ipver parameter that can be sent to CA with any CA event * @param {any} webex instance * @returns {Array|undefined} array of CA event labels or undefined if no labels should be sent */ getCaEventLabelsForIpVersion(webex: any): Array | undefined { const ipver = MeetingUtil.getIpVersion(webex); switch (ipver) { case IP_VERSION.unknown: return undefined; case IP_VERSION.only_ipv4: return ['hasIpv4_true']; case IP_VERSION.only_ipv6: return ['hasIpv6_true']; case IP_VERSION.ipv4_and_ipv6: return ['hasIpv4_true', 'hasIpv6_true']; default: return undefined; } }, joinMeeting: async (meeting, options) => { if (!meeting) { return Promise.reject(new ParameterError('You need a meeting object.')); } const webex = meeting.getWebexObject(); // @ts-ignore webex.internal.newMetrics.submitClientEvent({ name: 'client.locus.join.request', options: {meetingId: meeting.id}, }); let reachability; let clientMediaPreferences = { // bare minimum fallback value that should allow us to join ipver: IP_VERSION.unknown, joinCookie: undefined, preferTranscoding: !meeting.isMultistream, }; try { clientMediaPreferences = await webex.meetings.reachability.getClientMediaPreferences( meeting.isMultistream, MeetingUtil.getIpVersion(webex) ); if (options.roapMessage) { // we only need to attach reachability if we are sending a roap message // sending reachability on its own will cause Locus to reject our join request reachability = await webex.meetings.reachability.getReachabilityReportToAttachToRoap(); } } catch (e) { LoggerProxy.logger.error( 'Meeting:util#joinMeeting --> Error getting reachability or clientMediaPreferences:', e ); } // eslint-disable-next-line no-warning-comments // TODO: check if the meeting is in JOINING state // if Joining state termintate the request as user might click multiple times return meeting.meetingRequest .joinMeeting({ inviteeAddress: meeting.meetingJoinUrl || meeting.sipUri, meetingNumber: meeting.meetingNumber, deviceUrl: meeting.deviceUrl, locusUrl: meeting.locusUrl, locusClusterUrl: meeting.meetingInfo?.locusClusterUrl, correlationId: meeting.correlationId, reachability, roapMessage: options.roapMessage, permissionToken: meeting.permissionToken, resourceId: options.resourceId || null, moderator: options.moderator, pin: options.pin, moveToResource: options.moveToResource, asResourceOccupant: options.asResourceOccupant, breakoutsSupported: options.breakoutsSupported, locale: options.locale, deviceCapabilities: options.deviceCapabilities, liveAnnotationSupported: options.liveAnnotationSupported, clientMediaPreferences, alias: options.alias, }) .then((res) => { const parsed = MeetingUtil.parseLocusJoin(res); meeting.setLocus(parsed); meeting.isoLocalClientMeetingJoinTime = res?.headers?.date; // read from header if exist, else fall back to system clock : https://jira-eng-gpk2.cisco.com/jira/browse/SPARK-555657 const socketUrlInfo = MeetingUtil.getSocketUrlInfo(webex); webex.internal.newMetrics.submitClientEvent({ name: 'client.locus.join.response', payload: { trigger: 'loci-update', identifiers: { trackingId: res.headers.trackingid, }, eventData: { ...socketUrlInfo, }, }, options: { meetingId: meeting.id, mediaConnections: parsed.mediaConnections, }, }); return parsed; }) .catch((err) => { const socketUrlInfo = MeetingUtil.getSocketUrlInfo(webex); webex.internal.newMetrics.submitClientEvent({ name: 'client.locus.join.response', payload: { identifiers: {meetingLookupUrl: meeting.meetingInfo?.meetingLookupUrl}, eventData: { ...socketUrlInfo, }, }, options: { meetingId: meeting.id, rawError: err, }, }); throw err; }); }, cleanUp: (meeting) => { meeting.getWebexObject().internal.device.meetingEnded(); meeting.stopPeriodicLogUpload(); meeting.breakouts.cleanUp(); meeting.webinar.cleanUp(); meeting.simultaneousInterpretation.cleanUp(); meeting.locusMediaRequest = undefined; meeting.webex?.internal?.newMetrics?.callDiagnosticMetrics?.clearEventLimitsForCorrelationId( meeting.correlationId ); // make sure we send last metrics before we close the peerconnection const stopStatsAnalyzer = meeting.statsAnalyzer ? meeting.statsAnalyzer.stopAnalyzer() : Promise.resolve(); return stopStatsAnalyzer .then(() => meeting.closeRemoteStreams()) .then(() => meeting.closePeerConnections()) .then(() => { meeting.cleanupLocalStreams(); meeting.unsetRemoteStreams(); meeting.unsetPeerConnections(); meeting.reconnectionManager.cleanUp(); }) .then(() => meeting.stopKeepAlive()) .then(() => { if (meeting.config?.enableAutomaticLLM) { return meeting.cleanupLLMConneciton({throwOnError: false}); } return undefined; }); }, disconnectPhoneAudio: (meeting, phoneUrl) => { if (meeting.meetingState === FULL_STATE.INACTIVE) { return Promise.reject(new MeetingNotActiveError()); } const options = { locusUrl: meeting.locusUrl, selfId: meeting.selfId, correlationId: meeting.correlationId, phoneUrl, }; return meeting.meetingRequest.disconnectPhoneAudio(options).catch((err) => { LoggerProxy.logger.error( `Meeting:util#disconnectPhoneAudio --> An error occured while disconnecting phone audio in meeting ${meeting.id}, error: ${err}` ); return Promise.reject(err); }); }, /** * Returns options for leaving a meeting. * @param {any} meeting * @param {any} options * @returns {any} leave options */ prepareLeaveMeetingOptions: (meeting, options: any = {}) => { const defaultOptions = { locusUrl: meeting.locusUrl, selfId: meeting.selfId, correlationId: meeting.correlationId, resourceId: meeting.resourceId, deviceUrl: meeting.deviceUrl, }; return {...defaultOptions, ...options}; }, // by default will leave on meeting's resourceId // if you explicity want it not to leave on resource id, pass // {resourceId: null} // TODO: chris, you can modify this however you want leaveMeeting: (meeting, options: any = {}) => { if (meeting.meetingState === FULL_STATE.INACTIVE) { // TODO: clean up if the meeting is already inactive return Promise.reject(new MeetingNotActiveError()); } if (MeetingUtil.isUserInLeftState(meeting.locusInfo)) { return Promise.reject(new UserNotJoinedError()); } const leaveOptions = MeetingUtil.prepareLeaveMeetingOptions(meeting, options); return meeting.meetingRequest .leaveMeeting(leaveOptions) .then(() => { if (options.moveMeeting) { return Promise.resolve(); } return MeetingUtil.cleanUp(meeting); }) .catch((err) => { // TODO: If the meeting state comes as LEFT or INACTIVE as response then // 1) on leave clean up the meeting or simply do a sync on the meeting // 2) If the error says meeting is inactive then destroy the meeting object LoggerProxy.logger.error( `Meeting:util#leaveMeeting --> An error occured while trying to leave meeting with an id of ${meeting.id}, error: ${err}` ); return Promise.reject(err); }); }, declineMeeting: (meeting, reason) => meeting.meetingRequest.declineMeeting({ locusUrl: meeting.locusUrl, deviceUrl: meeting.deviceUrl, reason, }), isUserInLeftState: (locusInfo) => locusInfo.parsedLocus?.self?.state === _LEFT_, isUserInIdleState: (locusInfo) => locusInfo.parsedLocus?.self?.state === _IDLE_, isUserInJoinedState: (locusInfo) => locusInfo.parsedLocus?.self?.state === _JOINED_, isMediaEstablished: (currentMediaStatus) => currentMediaStatus && (currentMediaStatus.audio || currentMediaStatus.video || currentMediaStatus.share), joinMeetingOptions: (meeting, options: any = {}) => { const webex = meeting.getWebexObject(); meeting.resourceId = meeting.resourceId || options.resourceId; if (meeting.requiredCaptcha) { const errorToThrow = new CaptchaError(); // @ts-ignore webex.internal.newMetrics.submitClientEvent({ name: 'client.meetinginfo.response', options: { meetingId: meeting.id, }, payload: { errors: [ { fatal: false, category: 'expected', name: 'other', shownToUser: false, errorCode: errorToThrow.code, errorDescription: errorToThrow.name, rawErrorMessage: errorToThrow.sdkMessage, }, ], }, }); return Promise.reject(errorToThrow); } if (meeting.passwordStatus === PASSWORD_STATUS.REQUIRED) { const errorToThrow = new PasswordError(); // @ts-ignore webex.internal.newMetrics.submitClientEvent({ name: 'client.meetinginfo.response', options: { meetingId: meeting.id, }, payload: { errors: [ { fatal: false, category: 'expected', name: 'other', shownToUser: false, errorCode: errorToThrow.code, errorDescription: errorToThrow.name, rawErrorMessage: errorToThrow.sdkMessage, }, ], }, }); return Promise.reject(errorToThrow); } if (options.pin) { // @ts-ignore webex.internal.newMetrics.submitClientEvent({ name: 'client.pin.collected', options: { meetingId: meeting.id, }, }); } // normal join meeting, scenario A, D return MeetingUtil.joinMeeting(meeting, options).catch((err) => { // joining a claimed PMR that is not my own, scenario B if (MeetingUtil.isPinOrGuest(err)) { webex.internal.newMetrics.submitClientEvent({ name: 'client.pin.prompt', options: { meetingId: meeting.id, }, }); // request host pin or non host for unclaimed PMR, start of Scenario C // see https://sqbu-github.cisco.com/WebExSquared/locus/wiki/Locus-Lobby-and--IVR-Feature return Promise.reject(new IntentToJoinError('Error Joining Meeting', err)); } LoggerProxy.logger.error('Meeting:util#joinMeetingOptions --> Error joining the call, ', err); return Promise.reject(new JoinMeetingError(options, 'Error Joining Meeting', err)); }); }, /** * Returns request options for leaving a meeting. * @param {any} meeting * @param {any} options * @returns {any} request options */ buildLeaveFetchRequestOptions: (meeting, options: any = {}) => { const leaveOptions = MeetingUtil.prepareLeaveMeetingOptions(meeting, options); return meeting.meetingRequest.buildLeaveMeetingRequestOptions(leaveOptions); }, getTrack: (stream) => { let audioTrack = null; let videoTrack = null; let audioTracks = null; let videoTracks = null; if (!stream) { return {audioTrack: null, videoTrack: null}; } if (stream.getAudioTracks) { audioTracks = stream.getAudioTracks(); } if (stream.getVideoTracks) { videoTracks = stream.getVideoTracks(); } if (audioTracks && audioTracks.length > 0) { [audioTrack] = audioTracks; } if (videoTracks && videoTracks.length > 0) { [videoTrack] = videoTracks; } return {audioTrack, videoTrack}; }, getModeratorFromLocusInfo: (locusInfo) => locusInfo && locusInfo.parsedLocus && locusInfo.parsedLocus.info && locusInfo.parsedLocus.info && locusInfo.parsedLocus.info.moderator, getPolicyFromLocusInfo: (locusInfo) => locusInfo && locusInfo.parsedLocus && locusInfo.parsedLocus.info && locusInfo.parsedLocus.info && locusInfo.parsedLocus.info.policy, getUserDisplayHintsFromLocusInfo: (locusInfo) => locusInfo?.parsedLocus?.info?.userDisplayHints || [], canInviteNewParticipants: (displayHints) => displayHints.includes(DISPLAY_HINTS.ADD_GUEST), canAdmitParticipant: (displayHints) => displayHints.includes(DISPLAY_HINTS.ROSTER_WAITING_TO_JOIN), canUserLock: (displayHints) => displayHints.includes(DISPLAY_HINTS.LOCK_CONTROL_LOCK) && displayHints.includes(DISPLAY_HINTS.LOCK_STATUS_UNLOCKED), canUserUnlock: (displayHints) => displayHints.includes(DISPLAY_HINTS.LOCK_CONTROL_UNLOCK) && displayHints.includes(DISPLAY_HINTS.LOCK_STATUS_LOCKED), canUserRaiseHand: (displayHints) => displayHints.includes(DISPLAY_HINTS.RAISE_HAND), canUserLowerAllHands: (displayHints) => displayHints.includes(DISPLAY_HINTS.LOWER_ALL_HANDS), canUserLowerSomeoneElsesHand: (displayHints) => displayHints.includes(DISPLAY_HINTS.LOWER_SOMEONE_ELSES_HAND), bothLeaveAndEndMeetingAvailable: (displayHints) => displayHints.includes(DISPLAY_HINTS.LEAVE_TRANSFER_HOST_END_MEETING) || displayHints.includes(DISPLAY_HINTS.LEAVE_END_MEETING), requireHostEndMeetingBeforeLeave: (displayHints) => displayHints.includes(DISPLAY_HINTS.REQUIRE_HOST_END_MEETING_BEFORE_LEAVE) || (!displayHints.includes(DISPLAY_HINTS.LEAVE_TRANSFER_HOST_END_MEETING) && displayHints.includes(DISPLAY_HINTS.END_MEETING)), canManageBreakout: (displayHints) => displayHints.includes(DISPLAY_HINTS.BREAKOUT_MANAGEMENT), canStartBreakout: (displayHints) => !displayHints.includes(DISPLAY_HINTS.DISABLE_BREAKOUT_START), canBroadcastMessageToBreakout: (displayHints, policies = {}) => displayHints.includes(DISPLAY_HINTS.BROADCAST_MESSAGE_TO_BREAKOUT) && !!policies[SELF_POLICY.SUPPORT_BROADCAST_MESSAGE], isSuppressBreakoutSupport: (displayHints) => displayHints.includes(DISPLAY_HINTS.UCF_SUPPRESS_BREAKOUTS_SUPPORT), canAdmitLobbyToBreakout: (displayHints) => !displayHints.includes(DISPLAY_HINTS.DISABLE_LOBBY_TO_BREAKOUT), isBreakoutPreassignmentsEnabled: (displayHints) => !displayHints.includes(DISPLAY_HINTS.DISABLE_BREAKOUT_PREASSIGNMENTS), canUserAskForHelp: (displayHints) => !displayHints.includes(DISPLAY_HINTS.DISABLE_ASK_FOR_HELP), lockMeeting: (actions, request, locusUrl) => { if (actions && actions.canLock) { return request.lockMeeting({locusUrl, lock: true}); } return Promise.reject(new PermissionError('Lock not allowed, due to joined property.')); }, unlockMeeting: (actions, request, locusUrl) => { if (actions && actions.canUnlock) { return request.lockMeeting({locusUrl, lock: false}); } return Promise.reject(new PermissionError('Unlock not allowed, due to joined property.')); }, handleAudioLogging: (audioStream?: LocalMicrophoneStream) => { const LOG_HEADER = 'MeetingUtil#handleAudioLogging -->'; if (audioStream) { const settings = audioStream.getSettings(); const {deviceId} = settings; LoggerProxy.logger.log(LOG_HEADER, `deviceId = ${deviceId}`); LoggerProxy.logger.log(LOG_HEADER, 'settings =', JSON.stringify(settings)); } }, handleVideoLogging: (videoStream?: LocalCameraStream) => { const LOG_HEADER = 'MeetingUtil#handleVideoLogging -->'; if (videoStream) { const settings = videoStream.getSettings(); const {deviceId} = settings; LoggerProxy.logger.log(LOG_HEADER, `deviceId = ${deviceId}`); LoggerProxy.logger.log(LOG_HEADER, 'settings =', JSON.stringify(settings)); } }, endMeetingForAll: (meeting) => { if (meeting.meetingState === FULL_STATE.INACTIVE) { return Promise.reject(new MeetingNotActiveError()); } const endOptions = { locusUrl: meeting.locusUrl, }; return meeting.meetingRequest .endMeetingForAll(endOptions) .then(() => MeetingUtil.cleanUp(meeting)) .catch((err) => { LoggerProxy.logger.error( `Meeting:util#endMeetingForAll An error occured while trying to end meeting for all with an id of ${meeting.id}, error: ${err}` ); return Promise.reject(err); }); }, canEnableClosedCaption: (displayHints) => displayHints.includes(DISPLAY_HINTS.CAPTION_START), isSaveTranscriptsEnabled: (displayHints) => displayHints.includes(DISPLAY_HINTS.SAVE_TRANSCRIPTS_ENABLED), canStartTranscribing: (displayHints) => displayHints.includes(DISPLAY_HINTS.TRANSCRIPTION_CONTROL_START), canStopTranscribing: (displayHints) => displayHints.includes(DISPLAY_HINTS.TRANSCRIPTION_CONTROL_STOP), isClosedCaptionActive: (displayHints) => displayHints.includes(DISPLAY_HINTS.CAPTION_STATUS_ACTIVE), canStartManualCaption: (displayHints) => displayHints.includes(DISPLAY_HINTS.MANUAL_CAPTION_START), isLocalRecordingStarted: (displayHints) => displayHints.includes(DISPLAY_HINTS.LOCAL_RECORDING_STATUS_STARTED), isLocalRecordingStopped: (displayHints) => displayHints.includes(DISPLAY_HINTS.LOCAL_RECORDING_STATUS_STOPPED), isLocalRecordingPaused: (displayHints) => displayHints.includes(DISPLAY_HINTS.LOCAL_RECORDING_STATUS_PAUSED), isLocalStreamingStarted: (displayHints) => displayHints.includes(DISPLAY_HINTS.STREAMING_STATUS_STARTED), isLocalStreamingStopped: (displayHints) => displayHints.includes(DISPLAY_HINTS.STREAMING_STATUS_STOPPED), canStopManualCaption: (displayHints) => displayHints.includes(DISPLAY_HINTS.MANUAL_CAPTION_STOP), isManualCaptionActive: (displayHints) => displayHints.includes(DISPLAY_HINTS.MANUAL_CAPTION_STATUS_ACTIVE), isSpokenLanguageAutoDetectionEnabled: (displayHints) => displayHints.includes(DISPLAY_HINTS.SPOKEN_LANGUAGE_AUTO_DETECTION_ENABLED), isWebexAssistantActive: (displayHints) => displayHints.includes(DISPLAY_HINTS.WEBEX_ASSISTANT_STATUS_ACTIVE), canViewCaptionPanel: (displayHints) => displayHints.includes(DISPLAY_HINTS.ENABLE_CAPTION_PANEL), isRealTimeTranslationEnabled: (displayHints) => displayHints.includes(DISPLAY_HINTS.DISPLAY_REAL_TIME_TRANSLATION), canSelectSpokenLanguages: (displayHints) => displayHints.includes(DISPLAY_HINTS.DISPLAY_NON_ENGLISH_ASR), waitingForOthersToJoin: (displayHints) => displayHints.includes(DISPLAY_HINTS.WAITING_FOR_OTHERS), showAutoEndMeetingWarning: (displayHints) => displayHints.includes(DISPLAY_HINTS.SHOW_AUTO_END_MEETING_WARNING), canSendReactions: (originalValue, displayHints) => { if (displayHints.includes(DISPLAY_HINTS.REACTIONS_ACTIVE)) { return true; } if (displayHints.includes(DISPLAY_HINTS.REACTIONS_INACTIVE)) { return false; } return originalValue; }, canUserRenameSelfAndObserved: (displayHints) => displayHints.includes(DISPLAY_HINTS.CAN_RENAME_SELF_AND_OBSERVED), requiresPostMeetingDataConsentPrompt: (displayHints) => displayHints.includes(DISPLAY_HINTS.SHOW_POST_MEETING_DATA_CONSENT_PROMPT), canUserRenameOthers: (displayHints) => displayHints.includes(DISPLAY_HINTS.CAN_RENAME_OTHERS), // Default empty value for policies if we get an undefined value (ie permissionToken is not available) canShareWhiteBoard: (displayHints, policies = {}) => displayHints.includes(DISPLAY_HINTS.SHARE_WHITEBOARD) && !!policies[SELF_POLICY.SUPPORT_WHITEBOARD], canMoveToLobby: (displayHints) => displayHints.includes(DISPLAY_HINTS.MOVE_TO_LOBBY), /** * Adds the current locus sequence information to a request body * @param {Object} meeting The meeting object * @param {Object} requestBody The body of a request to locus * @returns {void} */ addSequence: (meeting, requestBody) => { const sequence = meeting?.locusInfo?.sequence; if (!sequence) { return; } requestBody.sequence = sequence; }, /** * Updates the locus info for the meeting with the locus * information returned from API requests made to Locus * Returns the original response object * @param {Object} meeting The meeting object * @param {Object} response The response of the http request * @returns {Object} */ updateLocusFromApiResponse: (meeting, response) => { if (!meeting) { return response; } if (response?.body?.locus) { meeting.locusInfo.handleLocusAPIResponse(meeting, response.body); } return response; }, generateBuildLocusDeltaRequestOptions: (originalMeeting) => { const meetingRef = new WeakRef(originalMeeting); const buildLocusDeltaRequestOptions = (originalOptions) => { const meeting = meetingRef.deref(); if (!meeting) { return originalOptions; } const options = cloneDeep(originalOptions); if (!options.body) { options.body = {}; } MeetingUtil.addSequence(meeting, options.body); return options; }; return buildLocusDeltaRequestOptions; }, generateLocusDeltaRequest: (originalMeeting) => { const meetingRef = new WeakRef(originalMeeting); const buildLocusDeltaRequestOptions = MeetingUtil.generateBuildLocusDeltaRequestOptions(originalMeeting); const locusDeltaRequest = (originalOptions) => { const meeting = meetingRef.deref(); if (!meeting) { return Promise.resolve(); } const options = buildLocusDeltaRequestOptions(originalOptions); return meeting .request(options) .then((response) => MeetingUtil.updateLocusFromApiResponse(meeting, response)); }; return locusDeltaRequest; }, canAttendeeRequestAiAssistantEnabled: (displayHints = [], roles: any[] = []) => { const isHostOrCoHost = roles.includes(ServerRoles.Cohost) || roles.includes(ServerRoles.Moderator); if (isHostOrCoHost) { return false; } if (displayHints.includes(DISPLAY_HINTS.ATTENDEE_REQUEST_AI_ASSISTANT_ENABLED)) { return true; } return false; }, attendeeRequestAiAssistantDeclinedAll: (displayHints = []) => displayHints.includes(DISPLAY_HINTS.ATTENDEE_REQUEST_AI_ASSISTANT_DECLINED_ALL), selfSupportsFeature: (feature: SELF_POLICY, userPolicies: Record) => { if (!userPolicies) { return true; } return userPolicies[feature]; }, parseInterpretationInfo: (meeting, meetingInfo) => { if (!meeting || !meetingInfo) { return; } const siInfo = meetingInfo.simultaneousInterpretation; meeting.simultaneousInterpretation.updateMeetingSIEnabled( !!meetingInfo.turnOnSimultaneousInterpretation, !!siInfo?.currentSIInterpreter ); const hostSIEnabled = !!( meetingInfo.turnOnSimultaneousInterpretation && meetingInfo?.meetingSiteSetting?.enableHostInterpreterControlSI ); meeting.simultaneousInterpretation.updateHostSIEnabled(hostSIEnabled); function renameKey(obj, oldKey, newKey) { if (oldKey in obj) { obj[newKey] = obj[oldKey]; delete obj[oldKey]; } } if (siInfo) { const lanuagesInfo = cloneDeep(siInfo.siLanguages); for (const language of lanuagesInfo) { renameKey(language, 'languageCode', 'languageName'); renameKey(language, 'languageGroupId', 'languageCode'); } if (!meeting.simultaneousInterpretation?.siLanguages?.length) { meeting.simultaneousInterpretation.updateInterpretation({siLanguages: lanuagesInfo}); } } Trigger.trigger( meeting, { file: 'meeting/util', function: 'parseInterpretationInfo', }, EVENT_TRIGGERS.MEETING_INTERPRETATION_UPDATE ); }, /** * Returns a CA-recognized error payload for the specified raw error message/reason. * * New errors can be added to this function for handling in the future * * @param {String} reason the raw error message * @returns {Array} an array of payload objects */ getChangeMeetingFloorErrorPayload: (reason: string) => { const errorPayload = { errorDescription: reason, name: 'locus.response', shownToUser: false, }; if (reason.includes(LOCAL_SHARE_ERRORS.UNDEFINED)) { return [ { ...errorPayload, fatal: true, category: 'signaling', errorCode: 1100, }, ]; } if (reason.includes(LOCAL_SHARE_ERRORS.DEVICE_NOT_JOINED)) { return [ { ...errorPayload, fatal: true, category: 'signaling', errorCode: 4050, }, ]; } if (reason.includes(LOCAL_SHARE_ERRORS.NO_MEDIA_FOR_DEVICE)) { return [ { ...errorPayload, fatal: true, category: 'media', errorCode: 2048, }, ]; } if (reason.includes(LOCAL_SHARE_ERRORS.NO_CONFLUENCE_ID)) { return [ { ...errorPayload, fatal: true, category: 'signaling', errorCode: 4064, }, ]; } if (reason.includes(LOCAL_SHARE_ERRORS.CONTENT_SHARING_DISABLED)) { return [ { ...errorPayload, fatal: true, category: 'expected', errorCode: 4065, }, ]; } if (reason.includes(LOCAL_SHARE_ERRORS.LOCUS_PARTICIPANT_DNE)) { return [ { ...errorPayload, fatal: true, category: 'signaling', errorCode: 4066, }, ]; } if (reason.includes(LOCAL_SHARE_ERRORS.CONTENT_REQUEST_WHILE_PENDING_WHITEBOARD)) { return [ { ...errorPayload, fatal: true, category: 'expected', errorCode: 4067, }, ]; } // return unknown error return [ { ...errorPayload, fatal: true, category: 'signaling', errorCode: 1100, }, ]; }, }; export default MeetingUtil;