diff --git a/bigbluebutton-config/bin/apply-lib.sh b/bigbluebutton-config/bin/apply-lib.sh index 0c82ca7a71..4be83cbfb2 100644 --- a/bigbluebutton-config/bin/apply-lib.sh +++ b/bigbluebutton-config/bin/apply-lib.sh @@ -201,7 +201,7 @@ HERE } disableMultipleKurentos() { - echo " - Configuring a single Kurento Media Server for listen only, webcam, and screeshare" + echo " - Configuring a single Kurento Media Server for listen only, webcam, and screenshare" systemctl stop kurento-media-server.service for i in `seq 8888 8890`; do @@ -269,6 +269,8 @@ source /etc/bigbluebutton/bbb-conf/apply-lib.sh #setNumberOfHTML5Processes 2 +# Shorten the FreeSWITCH "you have been muted" and "you have been unmuted" prompts +# cp -r /etc/bigbluebutton/bbb-conf/sounds /opt/freeswitch/share/freeswitch HERE chmod +x /etc/bigbluebutton/bbb-conf/apply-config.sh diff --git a/bigbluebutton-config/bin/bbb-conf b/bigbluebutton-config/bin/bbb-conf old mode 100755 new mode 100644 index e484f75539..26df4d1adb --- a/bigbluebutton-config/bin/bbb-conf +++ b/bigbluebutton-config/bin/bbb-conf @@ -650,20 +650,22 @@ check_configuration() { fi fi - if [ "$IP" != "$NGINX_IP" ] && [ "_" != "$NGINX_IP" ]; then - if [ "$IP" != "$HOSTS" ]; then - echo "# IP does not match:" - echo "# IP from ifconfig: $IP" - echo "# /etc/nginx/sites-available/bigbluebutton: $NGINX_IP" - fi - fi + # Depreciated: BigBlueButton must be installed with a valid hostname, so comparing a hostnae to IP address + # does not provide any useful information. + #if [ "$IP" != "$NGINX_IP" ]; then + # if [ "$IP" != "$HOSTS" ]; then + # echo "# IP does not match:" + # echo "# IP from ifconfig: $IP" + # echo "# /etc/nginx/sites-available/bigbluebutton: $NGINX_IP" + # fi + #fi if [ -f /var/lib/tomcat7/webapps/demo/bbb_api_conf.jsp ]; then # # Make sure the shared secret for the API matches the server # SECRET_PROPERTIES=$(cat ${SERVLET_DIR}/WEB-INF/classes/bigbluebutton.properties | grep -v '#' | tr -d '\r' | sed -n '/securitySalt/{s/.*=//;p}') - SECRET_DEMO=$(cat /var/lib/tomcat7/webapps/demo/bbb_api_conf.jsp | grep -v '^//' | tr -d '\r' | sed -n '/salt[ ]*=/{s/.*=[ ]*"//;s/".*//g;p}') + SECRET_DEMO=$(cat ${TOMCAT_DIR}/webapps/demo/bbb_api_conf.jsp | grep -v '^//' | tr -d '\r' | sed -n '/salt[ ]*=/{s/.*=[ ]*"//;s/".*//g;p}') if [ "$SECRET_PROPERTIES" != "$SECRET_DEMO" ]; then echo "#" @@ -677,12 +679,12 @@ check_configuration() { echo fi - API_IP=$(cat /var/lib/tomcat7/webapps/demo/bbb_api_conf.jsp | grep -v '^//' | sed -n '/String BigBlueButtonURL/{s/.*http[s]*:\/\///;s/\/.*//;p}' | tr -d '\015') - if [ "$IP" != "$API_IP" ]; then - echo "# Warning: API URL IPs do not match host:" + if ! grep -q https ${TOMCAT_DIR}/webapps/demo/bbb_api_conf.jsp; then + echo + echo "# Warning: Did not detect https for API demos in " + echo "#" + echo "# ${TOMCAT_DIR}/webapps/demo/bbb_api_conf.jsp" echo "#" - echo "# IP from ifconfig: $IP" - echo "# ${TOMCAT_DIR}/demo/bbb_api_conf.jsp: $API_IP" echo fi fi @@ -1006,7 +1008,8 @@ check_state() { echo fi - if [ "$SIP_NGINX_IP" != $IP ]; then + if [ "$(yq r /usr/share/meteor/bundle/programs/server/assets/app/config/settings.yml public.media.sipjsHackViaWs)" == "false" ]; then + if [ "$SIP_NGINX_IP" != $IP ]; then if [ "$SIP_NGINX_IP" != "\$freeswitch_addr" ]; then echo "# Warning: The setting of $SIP_NGINX_IP for proxy_pass in" echo "#" @@ -1016,6 +1019,7 @@ check_state() { echo "# (This is OK if you've manually changed the values)" echo fi + fi fi VARS_IP=$(cat $FREESWITCH_VARS | sed -n '/"local_ip_v4/{s/.*local_ip_v4=//;s/".*//;p}') @@ -1258,7 +1262,7 @@ if [ $CHECK ]; then echo echo "/etc/nginx/sites-available/bigbluebutton (nginx)" - echo " server name: $NGINX_IP" + echo " server_name: $NGINX_IP" PORT=$(cat /etc/nginx/sites-available/bigbluebutton | grep -v '#' | sed -n '/listen/{s/.*listen[ ]*//;s/;//;p}' | grep -v ssl | tr --delete '\n' | sed 's/\[/, \[/g' | sed 's/0$/0\n/g') echo " port: $PORT" @@ -1305,6 +1309,7 @@ if [ $CHECK ]; then echo echo "$SIP_CONFIG (sip.nginx)" echo " proxy_pass: $SIP_NGINX_IP" + echo " protocol: $(cat /etc/bigbluebutton/nginx/sip.nginx | grep -v \# | sed -n '/proxy_pass/{s/.*proxy_pass [ ]*//;s/:.*//;p}' | head -n 1)" fi if [ -f $KURENTO_CONFIG ]; then @@ -1326,6 +1331,16 @@ if [ $CHECK ]; then echo " build: $(yq r $HTML5_CONFIG public.app.html5ClientBuild)" echo " kurentoUrl: $(yq r $HTML5_CONFIG public.kurento.wsUrl)" echo " enableListenOnly: $(yq r $HTML5_CONFIG public.kurento.enableListenOnly)" + echo " sipjsHackViaWs: $(yq r $HTML5_CONFIG public.media.sipjsHackViaWs)" + fi + + TURN=/usr/share/bbb-web/WEB-INF/classes/spring/turn-stun-servers.xml + STUN="$(xmlstarlet sel -N x="http://www.springframework.org/schema/beans" -t -m '_:beans/_:bean[@class="org.bigbluebutton.web.services.turn.StunTurnService"]/_:property[@name="stunServers"]/_:set/_:ref' -v @bean $TURN)" + + if [ ! -z "$STUN" ]; then + echo + echo "$TURN (STUN Server)" + echo " $(xmlstarlet sel -N x="http://www.springframework.org/schema/beans" -t -m "_:beans/_:bean[@id=\"$STUN\"]/_:constructor-arg[@index=\"0\"]" -v @value $TURN)" fi if [ "$DISTRIB_CODENAME" == "xenial" ]; then diff --git a/bigbluebutton-config/sounds/README b/bigbluebutton-config/sounds/README new file mode 100644 index 0000000000..d2235b97e2 --- /dev/null +++ b/bigbluebutton-config/sounds/README @@ -0,0 +1,3 @@ +These wav files enable the administrator to shorten the FreeSWITCH audio prompts "you have been muted" and "you have been unmuted" to "muted" and "unmuted" versions. + +cp -r /etc/bigbluebutton/bbb-conf/sounds /opt/freeswitch/share/freeswitch diff --git a/bigbluebutton-config/sounds/en/us/callie/conference/48000/conf-muted.wav b/bigbluebutton-config/sounds/en/us/callie/conference/48000/conf-muted.wav new file mode 100644 index 0000000000..3082678aa2 Binary files /dev/null and b/bigbluebutton-config/sounds/en/us/callie/conference/48000/conf-muted.wav differ diff --git a/bigbluebutton-config/sounds/en/us/callie/conference/48000/conf-unmuted.wav b/bigbluebutton-config/sounds/en/us/callie/conference/48000/conf-unmuted.wav new file mode 100644 index 0000000000..910922b55d Binary files /dev/null and b/bigbluebutton-config/sounds/en/us/callie/conference/48000/conf-unmuted.wav differ diff --git a/bigbluebutton-html5/imports/api/annotations/server/handlers/whiteboardSend.js b/bigbluebutton-html5/imports/api/annotations/server/handlers/whiteboardSend.js index 872494677c..ae16937c17 100755 --- a/bigbluebutton-html5/imports/api/annotations/server/handlers/whiteboardSend.js +++ b/bigbluebutton-html5/imports/api/annotations/server/handlers/whiteboardSend.js @@ -1,6 +1,9 @@ import { check } from 'meteor/check'; import AnnotationsStreamer from '/imports/api/annotations/server/streamer'; import addAnnotation from '../modifiers/addAnnotation'; +import Metrics from '/imports/startup/server/metrics'; + +const { queueMetrics } = Meteor.settings.private.redis.metrics; const ANNOTATION_PROCCESS_INTERVAL = 60; @@ -15,6 +18,9 @@ const proccess = () => { annotationsRecieverIsRunning = true; Object.keys(annotationsQueue).forEach((meetingId) => { AnnotationsStreamer(meetingId).emit('added', { meetingId, annotations: annotationsQueue[meetingId] }); + if (queueMetrics) { + Metrics.setAnnotationQueueLength(meetingId, 0); + } }); annotationsQueue = {}; @@ -31,11 +37,14 @@ export default function handleWhiteboardSend({ header, body }, meetingId) { const whiteboardId = annotation.wbId; check(whiteboardId, String); - if(!annotationsQueue.hasOwnProperty(meetingId)) { + if (!annotationsQueue.hasOwnProperty(meetingId)) { annotationsQueue[meetingId] = []; } annotationsQueue[meetingId].push({ meetingId, whiteboardId, userId, annotation }); + if (queueMetrics) { + Metrics.setAnnotationQueueLength(meetingId, annotationsQueue[meetingId].length); + } if (!annotationsRecieverIsRunning) proccess(); return addAnnotation(meetingId, whiteboardId, userId, annotation); diff --git a/bigbluebutton-html5/imports/api/audio/client/bridge/kurento.js b/bigbluebutton-html5/imports/api/audio/client/bridge/kurento.js index eee5f2de9e..ddaeede658 100755 --- a/bigbluebutton-html5/imports/api/audio/client/bridge/kurento.js +++ b/bigbluebutton-html5/imports/api/audio/client/bridge/kurento.js @@ -1,258 +1,53 @@ import BaseAudioBridge from './base'; import Auth from '/imports/ui/services/auth'; -import { fetchWebRTCMappedStunTurnServers, getMappedFallbackStun } from '/imports/utils/fetchStunTurnServers'; import playAndRetry from '/imports/utils/mediaElementPlayRetry'; import logger from '/imports/startup/client/logger'; +import ListenOnlyBroker from '/imports/ui/services/bbb-webrtc-sfu/listenonly-broker'; +import loadAndPlayMediaStream from '/imports/ui/services/bbb-webrtc-sfu/load-play'; +import { + fetchWebRTCMappedStunTurnServers, + getMappedFallbackStun +} from '/imports/utils/fetchStunTurnServers'; const SFU_URL = Meteor.settings.public.kurento.wsUrl; const MEDIA = Meteor.settings.public.media; const MEDIA_TAG = MEDIA.mediaTag.replace(/#/g, ''); const GLOBAL_AUDIO_PREFIX = 'GLOBAL_AUDIO_'; const RECONNECT_TIMEOUT_MS = MEDIA.listenOnlyCallTimeout || 15000; +const RECV_ROLE = 'recv'; +const BRIDGE_NAME = 'kurento'; + +// SFU's base broker has distinct error codes so that it can be reused by different +// modules. Errors that have a valid, localized counterpart in audio manager are +// mapped so that the user gets a localized error message. +// The ones that haven't (ie SFU's servers-side errors), aren't mapped. +const errorCodeMap = { + 1301: 1001, + 1302: 1002, + 1305: 1005, + 1307: 1007, +} +const mapErrorCode = (error) => { + const { errorCode } = error; + const mappedErrorCode = errorCodeMap[errorCode]; + if (errorCode == null || mappedErrorCode == null) return error; + error.errorCode = mappedErrorCode; + return error; +} export default class KurentoAudioBridge extends BaseAudioBridge { constructor(userData) { super(); - const { - userId, - username, - voiceBridge, - meetingId, - sessionToken, - } = userData; - - this.user = { - userId, - name: username, - sessionToken, - }; - + this.internalMeetingID = userData.meetingId; + this.voiceBridge = userData.voiceBridge; + this.userId = userData.userId; + this.name = userData.username; + this.sessionToken = userData.sessionToken; this.media = { inputDevice: {}, }; - - - this.internalMeetingID = meetingId; - this.voiceBridge = voiceBridge; - this.reconnectOngoing = false; - this.hasSuccessfullyStarted = false; - } - - static normalizeError(error = {}) { - const errorMessage = error.name || error.message || error.reason || 'Unknown error'; - const errorCode = error.code || undefined; - let errorReason = error.reason || error.id || 'Undefined reason'; - - // HOPEFULLY TEMPORARY - // The errors are often just strings so replace the errorReason if that's the case - if (typeof error === 'string') { - errorReason = error; - } - // END OF HOPEFULLY TEMPORARY - - return { errorMessage, errorCode, errorReason }; - } - - - joinAudio({ isListenOnly, inputStream }, callback) { - return new Promise(async (resolve, reject) => { - this.callback = callback; - let iceServers = []; - - try { - logger.info({ - logCode: 'sfuaudiobridge_stunturn_fetch_start', - extraInfo: { iceServers }, - }, 'SFU audio bridge starting STUN/TURN fetch'); - - iceServers = await fetchWebRTCMappedStunTurnServers(this.user.sessionToken); - } catch (error) { - logger.error({ logCode: 'sfuaudiobridge_stunturn_fetch_failed' }, - 'SFU audio bridge failed to fetch STUN/TURN info, using default servers'); - iceServers = getMappedFallbackStun(); - } finally { - logger.info({ - logCode: 'sfuaudiobridge_stunturn_fetch_sucess', - extraInfo: { iceServers }, - }, 'SFU audio bridge got STUN/TURN servers'); - - const options = { - wsUrl: Auth.authenticateURL(SFU_URL), - userName: this.user.name, - caleeName: `${GLOBAL_AUDIO_PREFIX}${this.voiceBridge}`, - iceServers, - logger, - inputStream, - }; - - const audioTag = document.getElementById(MEDIA_TAG); - - const playElement = () => { - const mediaTagPlayed = () => { - logger.info({ - logCode: 'listenonly_media_play_success', - }, 'Listen only media played successfully'); - resolve(this.callback({ status: this.baseCallStates.started })); - }; - if (audioTag.paused) { - // Tag isn't playing yet. Play it. - audioTag.play() - .then(mediaTagPlayed) - .catch((error) => { - // NotAllowedError equals autoplay issues, fire autoplay handling event. - // This will be handled in audio-manager. - if (error.name === 'NotAllowedError') { - logger.error({ - logCode: 'listenonly_error_autoplay', - extraInfo: { errorName: error.name }, - }, 'Listen only media play failed due to autoplay error'); - const tagFailedEvent = new CustomEvent('audioPlayFailed', { detail: { mediaElement: audioTag } }); - window.dispatchEvent(tagFailedEvent); - resolve(this.callback({ - status: this.baseCallStates.autoplayBlocked, - })); - } else { - // Tag failed for reasons other than autoplay. Log the error and - // try playing again a few times until it works or fails for good - const played = playAndRetry(audioTag); - if (!played) { - logger.error({ - logCode: 'listenonly_error_media_play_failed', - extraInfo: { errorName: error.name }, - }, `Listen only media play failed due to ${error.name}`); - } else { - mediaTagPlayed(); - } - } - }); - } else { - // Media tag is already playing, so log a success. This is really a - // logging fallback for a case that shouldn't happen. But if it does - // (ie someone re-enables the autoPlay prop in the element), then it - // means the stream is playing properly and it'll be logged. - mediaTagPlayed(); - } - }; - - const onSuccess = () => { - const { webRtcPeer } = window.kurentoManager.kurentoAudio; - - this.hasSuccessfullyStarted = true; - if (webRtcPeer) { - logger.info({ - logCode: 'sfuaudiobridge_audio_negotiation_success', - }, 'SFU audio bridge negotiated audio with success'); - - const stream = webRtcPeer.getRemoteStream(); - - audioTag.pause(); - audioTag.srcObject = stream; - audioTag.muted = false; - logger.info({ - logCode: 'sfuaudiobridge_audio_ready_to_play', - }, 'SFU audio bridge is ready to play'); - - playElement(); - } else { - logger.info({ - logCode: 'sfuaudiobridge_audio_negotiation_failed', - }, 'SFU audio bridge failed to negotiate audio'); - - this.callback({ - status: this.baseCallStates.failed, - error: this.baseErrorCodes.CONNECTION_ERROR, - bridgeError: 'No WebRTC Peer', - }); - } - - if (this.reconnectOngoing) { - this.reconnectOngoing = false; - clearTimeout(this.reconnectTimeout); - } - }; - - const onFail = (error) => { - const { errorMessage, errorCode, errorReason } = KurentoAudioBridge.normalizeError(error); - - // Listen only connected successfully already and dropped mid-call. - // Try to reconnect ONCE (binded to reconnectOngoing flag) - if (this.hasSuccessfullyStarted && !this.reconnectOngoing) { - logger.error({ - logCode: 'listenonly_error_try_to_reconnect', - extraInfo: { errorMessage, errorCode, errorReason }, - }, `Listen only failed for an ongoing session, try to reconnect. - reason: ${errorReason}`); - window.kurentoExitAudio(); - this.callback({ status: this.baseCallStates.reconnecting }); - this.reconnectOngoing = true; - // Set up a reconnectionTimeout in case the server is unresponsive - // for some reason. If it gets triggered, end the session and stop - // trying to reconnect - this.reconnectTimeout = setTimeout(() => { - this.callback({ - status: this.baseCallStates.failed, - error: this.baseErrorCodes.CONNECTION_ERROR, - bridgeError: 'Reconnect Timeout', - }); - this.reconnectOngoing = false; - this.hasSuccessfullyStarted = false; - window.kurentoExitAudio(); - }, RECONNECT_TIMEOUT_MS); - window.kurentoJoinAudio( - MEDIA_TAG, - this.voiceBridge, - this.user.userId, - this.internalMeetingID, - onFail, - onSuccess, - options, - ); - } else { - // Already tried reconnecting once OR the user handn't succesfully - // connected firsthand. Just finish the session and reject with error - if (!this.reconnectOngoing) { - logger.error({ - logCode: 'listenonly_error_failed_to_connect', - extraInfo: { errorMessage, errorCode, errorReason }, - }, `Listen only failed when trying to start due to ${errorReason}`); - } else { - logger.error({ - logCode: 'listenonly_error_reconnect_failed', - extraInfo: { errorMessage, errorCode, errorReason }, - }, `Listen only failed when trying to reconnect due to ${errorReason}`); - } - - this.reconnectOngoing = false; - this.hasSuccessfullyStarted = false; - window.kurentoExitAudio(); - - this.callback({ - status: this.baseCallStates.failed, - error: this.baseErrorCodes.CONNECTION_ERROR, - bridgeError: errorReason, - }); - - reject(errorReason); - } - }; - - if (!isListenOnly) { - return reject(new Error('Invalid bridge option')); - } - - logger.info({ - logCode: 'sfuaudiobridge_ready_to_join_audio', - }, 'SFU audio bridge is ready to join audio'); - window.kurentoJoinAudio( - MEDIA_TAG, - this.voiceBridge, - this.user.userId, - this.internalMeetingID, - onFail, - onSuccess, - options, - ); - } - }); + this.broker; + this.reconnecting = false; } async changeOutputDevice(value) { @@ -262,8 +57,10 @@ export default class KurentoAudioBridge extends BaseAudioBridge { await audioContext.setSinkId(value); this.media.outputDeviceId = value; } catch (error) { - logger.error({ logCode: 'sfuaudiobridge_changeoutputdevice_error', extraInfo: { error } }, - 'SFU audio bridge failed to fetch STUN/TURN info, using default'); + logger.error({ + logCode: 'listenonly_changeoutputdevice_error', + extraInfo: { error, bridge: BRIDGE_NAME } + }, 'Audio bridge failed to change output device'); throw new Error(this.baseErrorCodes.MEDIA_ERROR); } } @@ -272,18 +69,175 @@ export default class KurentoAudioBridge extends BaseAudioBridge { } getPeerConnection() { - const { webRtcPeer } = window.kurentoManager.kurentoAudio; - if (webRtcPeer) { - return webRtcPeer.peerConnection; - } + const webRtcPeer = this.broker.webRtcPeer; + if (webRtcPeer) return webRtcPeer.peerConnection; return null; } - exitAudio() { - return new Promise((resolve) => { - this.hasSuccessfullyStarted = false; - window.kurentoExitAudio(); - return resolve(this.callback({ status: this.baseCallStates.ended })); + handleTermination() { + return this.callback({ status: this.baseCallStates.ended, bridge: BRIDGE_NAME }); + } + + clearReconnectionTimeout() { + this.reconnecting = false; + if (this.reconnectionTimeout) { + clearTimeout(this.reconnectionTimeout); + this.reconnectionTimeout = null; + } + } + + reconnect() { + this.broker.stop(); + this.callback({ status: this.baseCallStates.reconnecting, bridge: BRIDGE_NAME }); + this.reconnecting = true; + // Set up a reconnectionTimeout in case the server is unresponsive + // for some reason. If it gets triggered, end the session and stop + // trying to reconnect + this.reconnectionTimeout = setTimeout(() => { + this.callback({ + status: this.baseCallStates.failed, + error: 1010, + bridgeError: 'Reconnection timeout', + bridge: BRIDGE_NAME, + }); + this.broker.stop(); + this.clearReconnectionTimeout(); + }, RECONNECT_TIMEOUT_MS); + + this.joinAudio({ isListenOnly: true }, this.callback).then(() => { + this.clearReconnectionTimeout(); + }).catch(error => { + // Error handling is a no-op because it will be "handled" in handleBrokerFailure + logger.debug({ + logCode: 'listenonly_reconnect_failed', + extraInfo: { + errorMessage: error.errorMessage, + reconnecting: this.reconnecting, + bridge: BRIDGE_NAME + }, + }, 'Listen only reconnect failed'); }); } + + handleBrokerFailure(error) { + return new Promise((resolve, reject) => { + mapErrorCode(error); + const { errorMessage, errorCause, errorCode } = error; + + if (this.broker.started && !this.reconnecting) { + logger.error({ + logCode: 'listenonly_error_try_to_reconnect', + extraInfo: { errorMessage, errorCode, errorCause, bridge: BRIDGE_NAME }, + }, 'Listen only failed, try to reconnect'); + this.reconnect(); + return resolve(); + } else { + // Already tried reconnecting once OR the user handn't succesfully + // connected firsthand. Just finish the session and reject with error + logger.error({ + logCode: 'listenonly_error', + extraInfo: { + errorMessage, errorCode, errorCause, + reconnecting: this.reconnecting, + bridge: BRIDGE_NAME + }, + }, 'Listen only failed'); + this.clearReconnectionTimeout(); + this.broker.stop(); + this.callback({ + status: this.baseCallStates.failed, + error: errorCode, + bridgeError: errorMessage, + bridge: BRIDGE_NAME, + }); + return reject(error); + } + }); + } + + dispatchAutoplayHandlingEvent(mediaElement) { + const tagFailedEvent = new CustomEvent('audioPlayFailed', { + detail: { mediaElement } + }); + window.dispatchEvent(tagFailedEvent); + this.callback({ status: this.baseCallStates.autoplayBlocked, bridge: BRIDGE_NAME }); + } + + handleStart() { + const stream = this.broker.webRtcPeer.getRemoteStream(); + const mediaElement = document.getElementById(MEDIA_TAG); + + return loadAndPlayMediaStream(stream, mediaElement, false).then(() => { + return this.callback({ status: this.baseCallStates.started, bridge: BRIDGE_NAME }); + }).catch(error => { + // NotAllowedError equals autoplay issues, fire autoplay handling event. + // This will be handled in audio-manager. + if (error.name === 'NotAllowedError') { + logger.error({ + logCode: 'listenonly_error_autoplay', + extraInfo: { errorName: error.name, bridge: BRIDGE_NAME }, + }, 'Listen only media play failed due to autoplay error'); + this.dispatchAutoplayHandlingEvent(mediaElement); + } else { + const normalizedError = { + errorCode: 1004, + errorMessage: error.message || 'AUDIO_PLAY_FAILED', + }; + this.callback({ + status: this.baseCallStates.failed, + error: normalizedError.errorCode, + bridgeError: normalizedError.errorMessage, + bridge: BRIDGE_NAME, + }) + throw normalizedError; + } + }); + } + + joinAudio({ isListenOnly }, callback) { + return new Promise(async (resolve, reject) => { + if (!isListenOnly) return reject(new Error('Invalid bridge option')); + this.callback = callback; + let iceServers = []; + + try { + iceServers = await fetchWebRTCMappedStunTurnServers(this.sessionToken); + } catch (error) { + logger.error({ logCode: 'listenonly_stunturn_fetch_failed' }, + 'SFU audio bridge failed to fetch STUN/TURN info, using default servers'); + iceServers = getMappedFallbackStun(); + } finally { + const options = { + userName: this.name, + caleeName: `${GLOBAL_AUDIO_PREFIX}${this.voiceBridge}`, + iceServers, + }; + + this.broker = new ListenOnlyBroker( + Auth.authenticateURL(SFU_URL), + this.voiceBridge, + this.userId, + this.internalMeetingID, + RECV_ROLE, + options, + ); + + this.broker.onended = this.handleTermination.bind(this); + this.broker.onerror = (error) => { + this.handleBrokerFailure(error).catch(reject); + } + this.broker.onstart = () => { + this.handleStart().then(resolve).catch(reject); + }; + + this.broker.listen().catch(reject); + } + }); + } + + exitAudio() { + this.broker.stop(); + this.clearReconnectionTimeout(); + return Promise.resolve(); + } } diff --git a/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js b/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js index 8593519d72..7b81fd7606 100755 --- a/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js +++ b/bigbluebutton-html5/imports/api/audio/client/bridge/sip.js @@ -32,6 +32,10 @@ const USER_AGENT_RECONNECTION_ATTEMPTS = 3; const USER_AGENT_RECONNECTION_DELAY_MS = 5000; const USER_AGENT_CONNECTION_TIMEOUT_MS = 5000; const ICE_GATHERING_TIMEOUT = MEDIA.iceGatheringTimeout || 5000; +const BRIDGE_NAME = 'sip'; +const WEBSOCKET_KEEP_ALIVE_INTERVAL = MEDIA.websocketKeepAliveInterval || 0; +const WEBSOCKET_KEEP_ALIVE_DEBOUNCE = MEDIA.websocketKeepAliveDebounce || 10; +const TRACE_SIP = MEDIA.traceSip || false; const getAudioSessionNumber = () => { let currItem = parseInt(sessionStorage.getItem(AUDIO_SESSION_NUM_KEY), 10); @@ -172,6 +176,7 @@ class SIPSession { status: this.baseCallStates.failed, error: 1008, bridgeError: 'Timeout on call transfer', + bridge: BRIDGE_NAME, }); this.exitAudio(); @@ -293,6 +298,7 @@ class SIPSession { status: this.baseCallStates.failed, error: 1006, bridgeError: 'Timeout on call hangup', + bridge: BRIDGE_NAME, }); return reject(this.baseErrorCodes.REQUEST_TIMEOUT); } @@ -365,6 +371,9 @@ class SIPSession { transportOptions: { server: `${(protocol === 'https:' ? 'wss://' : 'ws://')}${hostname}/ws?${token}`, connectionTimeout: USER_AGENT_CONNECTION_TIMEOUT_MS, + keepAliveInterval: WEBSOCKET_KEEP_ALIVE_INTERVAL, + keepAliveDebounce: WEBSOCKET_KEEP_ALIVE_DEBOUNCE, + traceSip: TRACE_SIP, }, sessionDescriptionHandlerFactoryOptions: { peerConnectionConfiguration: { @@ -433,6 +442,7 @@ class SIPSession { status: this.baseCallStates.failed, error, bridgeError, + bridge: BRIDGE_NAME, }); reject(this.baseErrorCodes.CONNECTION_ERROR); }); @@ -472,6 +482,7 @@ class SIPSession { status: this.baseCallStates.failed, error: 1002, bridgeError: 'Websocket failed to connect', + bridge: BRIDGE_NAME, }); return reject({ type: this.baseErrorCodes.CONNECTION_ERROR, @@ -502,6 +513,7 @@ class SIPSession { status: this.baseCallStates.failed, error: 1002, bridgeError: 'Websocket failed to connect', + bridge: BRIDGE_NAME, }); reject({ @@ -660,7 +672,7 @@ class SIPSession { }, }, 'Audio call - setup remote media'); - this.callback({ status: this.baseCallStates.started }); + this.callback({ status: this.baseCallStates.started, bridge: BRIDGE_NAME }); resolve(); } }; @@ -672,6 +684,7 @@ class SIPSession { status: this.baseCallStates.failed, error: 1006, bridgeError: `Call timed out on start after ${CALL_CONNECT_TIMEOUT / 1000}s`, + bridge: BRIDGE_NAME, }); this.exitAudio(); @@ -691,6 +704,7 @@ class SIPSession { error: 1010, bridgeError: 'ICE negotiation timeout after ' + `${ICE_NEGOTIATION_TIMEOUT / 1000}s`, + bridge: BRIDGE_NAME, }); this.exitAudio(); @@ -726,6 +740,7 @@ class SIPSession { error: 1007, bridgeError: 'ICE negotiation failed. Current state ' + `- ${peer.iceConnectionState}`, + bridge: BRIDGE_NAME, }); }; @@ -744,6 +759,7 @@ class SIPSession { error: 1012, bridgeError: 'ICE connection closed. Current state -' + `${peer.iceConnectionState}`, + bridge: BRIDGE_NAME, }); }; @@ -837,6 +853,7 @@ class SIPSession { if (!message && !!this.userRequestedHangup) { return this.callback({ status: this.baseCallStates.ended, + bridge: BRIDGE_NAME, }); } @@ -864,6 +881,7 @@ class SIPSession { status: this.baseCallStates.failed, error: mappedCause, bridgeError: cause, + bridge: BRIDGE_NAME, }); }; @@ -972,7 +990,7 @@ export default class SIPBridge extends BaseAudioBridge { if (this.activeSession.webrtcConnected) { // webrtc was able to connect so just try again message.silenceNotifications = true; - callback({ status: this.baseCallStates.reconnecting }); + callback({ status: this.baseCallStates.reconnecting, bridge: BRIDGE_NAME, }); shouldTryReconnect = true; } else if (hasFallbackDomain === true && hostname !== IPV4_FALLBACK_DOMAIN) { message.silenceNotifications = true; diff --git a/bigbluebutton-html5/imports/api/group-chat-msg/server/eventHandlers.js b/bigbluebutton-html5/imports/api/group-chat-msg/server/eventHandlers.js index 829573fe49..912afcac7c 100644 --- a/bigbluebutton-html5/imports/api/group-chat-msg/server/eventHandlers.js +++ b/bigbluebutton-html5/imports/api/group-chat-msg/server/eventHandlers.js @@ -1,12 +1,12 @@ import RedisPubSub from '/imports/startup/server/redis'; -import handleGroupChatsMsgs from './handlers/groupChatsMsgs'; import handleGroupChatMsgBroadcast from './handlers/groupChatMsgBroadcast'; import handleClearPublicGroupChat from './handlers/clearPublicGroupChat'; import handleUserTyping from './handlers/userTyping'; +import handleSyncGroupChatMsg from './handlers/syncGroupsChat'; import { processForHTML5ServerOnly } from '/imports/api/common/server/helpers'; -RedisPubSub.on('GetGroupChatMsgsRespMsg', processForHTML5ServerOnly(handleGroupChatsMsgs)); +RedisPubSub.on('GetGroupChatMsgsRespMsg', processForHTML5ServerOnly(handleSyncGroupChatMsg)); RedisPubSub.on('GroupChatMessageBroadcastEvtMsg', handleGroupChatMsgBroadcast); RedisPubSub.on('ClearPublicChatHistoryEvtMsg', handleClearPublicGroupChat); -RedisPubSub.on('SyncGetGroupChatMsgsRespMsg', handleGroupChatsMsgs); +RedisPubSub.on('SyncGetGroupChatMsgsRespMsg', handleSyncGroupChatMsg); RedisPubSub.on('UserTypingEvtMsg', handleUserTyping); diff --git a/bigbluebutton-html5/imports/api/group-chat-msg/server/handlers/groupChatMsgBroadcast.js b/bigbluebutton-html5/imports/api/group-chat-msg/server/handlers/groupChatMsgBroadcast.js index 2e0340f5ea..dd116b6ecb 100644 --- a/bigbluebutton-html5/imports/api/group-chat-msg/server/handlers/groupChatMsgBroadcast.js +++ b/bigbluebutton-html5/imports/api/group-chat-msg/server/handlers/groupChatMsgBroadcast.js @@ -1,5 +1,13 @@ import { check } from 'meteor/check'; +import _ from 'lodash'; import addGroupChatMsg from '../modifiers/addGroupChatMsg'; +import addBulkGroupChatMsgs from '../modifiers/addBulkGroupChatMsgs'; + +const { bufferChatInsertsMs } = Meteor.settings.public.chat; + +const msgBuffer = []; + +const bulkFn = _.throttle(addBulkGroupChatMsgs, bufferChatInsertsMs); export default function handleGroupChatMsgBroadcast({ body }, meetingId) { const { chatId, msg } = body; @@ -8,5 +16,10 @@ export default function handleGroupChatMsgBroadcast({ body }, meetingId) { check(chatId, String); check(msg, Object); - addGroupChatMsg(meetingId, chatId, msg); + if (bufferChatInsertsMs) { + msgBuffer.push({ meetingId, chatId, msg }); + bulkFn(msgBuffer); + } else { + addGroupChatMsg(meetingId, chatId, msg); + } } diff --git a/bigbluebutton-html5/imports/api/group-chat-msg/server/handlers/groupChatsMsgs.js b/bigbluebutton-html5/imports/api/group-chat-msg/server/handlers/groupChatsMsgs.js deleted file mode 100644 index a226f28038..0000000000 --- a/bigbluebutton-html5/imports/api/group-chat-msg/server/handlers/groupChatsMsgs.js +++ /dev/null @@ -1,19 +0,0 @@ -import { Match, check } from 'meteor/check'; -import addGroupChatMsg from '../modifiers/addGroupChatMsg'; - -export default function handleGroupChatsMsgs({ body }, meetingId) { - const { chatId, msgs, msg } = body; - - check(meetingId, String); - check(chatId, String); - check(msgs, Match.Maybe(Array)); - check(msg, Match.Maybe(Array)); - - const msgsAdded = []; - - (msgs || msg).forEach((m) => { - msgsAdded.push(addGroupChatMsg(meetingId, chatId, m)); - }); - - return msgsAdded; -} diff --git a/bigbluebutton-html5/imports/api/group-chat-msg/server/handlers/syncGroupsChat.js b/bigbluebutton-html5/imports/api/group-chat-msg/server/handlers/syncGroupsChat.js new file mode 100644 index 0000000000..3db44bdecc --- /dev/null +++ b/bigbluebutton-html5/imports/api/group-chat-msg/server/handlers/syncGroupsChat.js @@ -0,0 +1,12 @@ +import { Match, check } from 'meteor/check'; +import syncMeetingChatMsgs from '../modifiers/syncMeetingChatMsgs'; + +export default function handleSyncGroupChat({ body }, meetingId) { + const { chatId, msgs } = body; + + check(meetingId, String); + check(chatId, String); + check(msgs, Match.Maybe(Array)); + + syncMeetingChatMsgs(meetingId, chatId, msgs); +} diff --git a/bigbluebutton-html5/imports/api/group-chat-msg/server/methods/stopUserTyping.js b/bigbluebutton-html5/imports/api/group-chat-msg/server/methods/stopUserTyping.js index 12cdaefa07..619ad4963a 100644 --- a/bigbluebutton-html5/imports/api/group-chat-msg/server/methods/stopUserTyping.js +++ b/bigbluebutton-html5/imports/api/group-chat-msg/server/methods/stopUserTyping.js @@ -10,7 +10,7 @@ export default function stopUserTyping() { userId: requesterUserId, }); - if (userTyping) { + if (userTyping && meetingId && requesterUserId) { stopTyping(meetingId, requesterUserId, true); } } diff --git a/bigbluebutton-html5/imports/api/group-chat-msg/server/modifiers/addBulkGroupChatMsgs.js b/bigbluebutton-html5/imports/api/group-chat-msg/server/modifiers/addBulkGroupChatMsgs.js new file mode 100644 index 0000000000..05662d5484 --- /dev/null +++ b/bigbluebutton-html5/imports/api/group-chat-msg/server/modifiers/addBulkGroupChatMsgs.js @@ -0,0 +1,30 @@ +import { GroupChatMsg } from '/imports/api/group-chat-msg'; +import Logger from '/imports/startup/server/logger'; +import flat from 'flat'; +import { parseMessage } from './addGroupChatMsg'; + +export default async function addBulkGroupChatMsgs(msgs) { + if (!msgs.length) return; + + const mappedMsgs = msgs + .map(({ chatId, meetingId, msg }) => ({ + _id: new Mongo.ObjectID()._str, + ...msg, + meetingId, + chatId, + message: parseMessage(msg.message), + sender: msg.sender.id, + })) + .map(el => flat(el, { safe: true })); + + try { + const { insertedCount } = await GroupChatMsg.rawCollection().insertMany(mappedMsgs); + msgs.length = 0; + + if (insertedCount) { + Logger.info(`Inserted ${insertedCount} messages`); + } + } catch (err) { + Logger.error(`Error on bulk insert. ${err}`); + } +} diff --git a/bigbluebutton-html5/imports/api/group-chat-msg/server/modifiers/addGroupChatMsg.js b/bigbluebutton-html5/imports/api/group-chat-msg/server/modifiers/addGroupChatMsg.js index 78db92cfa4..758cc84965 100644 --- a/bigbluebutton-html5/imports/api/group-chat-msg/server/modifiers/addGroupChatMsg.js +++ b/bigbluebutton-html5/imports/api/group-chat-msg/server/modifiers/addGroupChatMsg.js @@ -4,7 +4,7 @@ import Logger from '/imports/startup/server/logger'; import { GroupChatMsg } from '/imports/api/group-chat-msg'; import { BREAK_LINE } from '/imports/utils/lineEndings'; -const parseMessage = (message) => { +export function parseMessage(message) { let parsedMessage = message || ''; // Replace \r and \n to
@@ -15,7 +15,7 @@ const parseMessage = (message) => { parsedMessage = parsedMessage.split(' { + const options = { + mediaConstraints: { + audio: true, + video: false, + }, + onicecandidate: (candidate) => { + this.onIceCandidate(candidate, this.role); + }, + }; + + this.addIceServers(options); + + this.webRtcPeer = kurentoUtils.WebRtcPeer.WebRtcPeerRecvonly(options, (error) => { + if (error) { + // 1305: "PEER_NEGOTIATION_FAILED", + const normalizedError = BaseBroker.assembleError(1305); + logger.error({ + logCode: `${this.logCodePrefix}_peer_creation_failed`, + extraInfo: { + errorMessage: error.name || error.message || 'Unknown error', + errorCode: normalizedError.errorCode, + sfuComponent: this.sfuComponent, + started: this.started, + }, + }, `Listen only peer creation failed`); + this.onerror(normalizedError); + return reject(normalizedError); + } + + this.webRtcPeer.iceQueue = []; + this.webRtcPeer.generateOffer(this.onOfferGenerated.bind(this)); + }); + + this.webRtcPeer.peerConnection.onconnectionstatechange = this.handleConnectionStateChange.bind(this); + return resolve(); + }); + } + + listen () { + return this.openWSConnection() + .then(this.joinListenOnly.bind(this)); + } + + onWSMessage (message) { + const parsedMessage = JSON.parse(message.data); + + switch (parsedMessage.id) { + case 'startResponse': + this.processAnswer(parsedMessage); + break; + case 'iceCandidate': + this.handleIceCandidate(parsedMessage.candidate); + break; + case 'webRTCAudioSuccess': + this.onstart(parsedMessage.success); + this.started = true; + break; + case 'webRTCAudioError': + case 'error': + this.handleSFUError(parsedMessage); + break; + case 'pong': + break; + default: + logger.debug({ + logCode: `${this.logCodePrefix}_invalid_req`, + extraInfo: { messageId: parsedMessage.id || 'Unknown', sfuComponent: this.sfuComponent } + }, `Discarded invalid SFU message`); + } + } + + handleSFUError (sfuResponse) { + const { code, reason, role } = sfuResponse; + const error = BaseBroker.assembleError(code, reason); + + logger.error({ + logCode: `${this.logCodePrefix}_sfu_error`, + extraInfo: { + errorCode: code, + errorMessage: error.errorMessage, + role, + sfuComponent: this.sfuComponent, + started: this.started, + }, + }, `Listen only failed in SFU`); + this.onerror(error); + } + + onOfferGenerated (error, sdpOffer) { + if (error) { + logger.error({ + logCode: `${this.logCodePrefix}_offer_failure`, + extraInfo: { + errorMessage: error.name || error.message || 'Unknown error', + sfuComponent: this.sfuComponent + }, + }, `Listen only offer generation failed`); + // 1305: "PEER_NEGOTIATION_FAILED", + const normalizedError = BaseBroker.assembleError(1305); + return this.onerror(error); + } + + const message = { + id: 'start', + type: this.sfuComponent, + role: this.role, + internalMeetingId: this.internalMeetingId, + voiceBridge: this.voiceBridge, + caleeName: this.caleeName, + userId: this.userId, + userName: this.userName, + sdpOffer, + }; + + logger.debug({ + logCode: `${this.logCodePrefix}_offer_generated`, + extraInfo: { sfuComponent: this.sfuComponent, role: this.role }, + }, `SFU audio offer generated`); + + this.sendMessage(message); + } + + onIceCandidate (candidate, role) { + const message = { + id: ON_ICE_CANDIDATE_MSG, + role, + type: this.sfuComponent, + voiceBridge: this.voiceBridge, + candidate, + }; + + this.sendMessage(message); + } +} + +export default ListenOnlyBroker; diff --git a/bigbluebutton-html5/imports/ui/services/bbb-webrtc-sfu/load-play.js b/bigbluebutton-html5/imports/ui/services/bbb-webrtc-sfu/load-play.js new file mode 100644 index 0000000000..4be485c2bf --- /dev/null +++ b/bigbluebutton-html5/imports/ui/services/bbb-webrtc-sfu/load-play.js @@ -0,0 +1,30 @@ +const playMediaElement = (mediaElement) => { + return new Promise((resolve, reject) => { + if (mediaElement.paused) { + // Tag isn't playing yet. Play it. + mediaElement.play() + .then(resolve) + .catch((error) => { + if (error.name === 'NotAllowedError') return reject(error); + // Tag failed for reasons other than autoplay. Log the error and + // try playing again a few times until it works or fails for good + const played = playAndRetry(mediaElement); + if (!played) return reject(error); + return resolve(); + }); + } else { + // Media tag is already playing, so log a success. This is really a + // logging fallback for a case that shouldn't happen. But if it does + // (ie someone re-enables the autoPlay prop in the mediaElement), then it + // means the mediaStream is playing properly and it'll be logged. + return resolve(); + } + }); +} + +export default function loadAndPlayMediaStream (mediaStream, mediaElement, muted = true) { + mediaElement.muted = muted; + mediaElement.pause(); + mediaElement.srcObject = mediaStream; + return playMediaElement(mediaElement); +} diff --git a/bigbluebutton-html5/imports/ui/services/bbb-webrtc-sfu/sfu-base-broker.js b/bigbluebutton-html5/imports/ui/services/bbb-webrtc-sfu/sfu-base-broker.js new file mode 100644 index 0000000000..dbd9031488 --- /dev/null +++ b/bigbluebutton-html5/imports/ui/services/bbb-webrtc-sfu/sfu-base-broker.js @@ -0,0 +1,246 @@ +import logger from '/imports/startup/client/logger'; +import { notifyStreamStateChange } from '/imports/ui/services/bbb-webrtc-sfu/stream-state-service'; +import SFU_BROKER_ERRORS from '/imports/ui/services/bbb-webrtc-sfu/broker-base-errors'; + +const PING_INTERVAL_MS = 15000; + +class BaseBroker { + static assembleError(code, reason) { + const message = reason || SFU_BROKER_ERRORS[code]; + const error = new Error(message); + error.errorCode = code; + // Duplicating key-vals because we can't settle on an error pattern... - prlanzarin + error.errorCause = error.message; + error.errorMessage = error.message; + + return error; + } + + constructor(sfuComponent, wsUrl) { + this.wsUrl = wsUrl; + this.sfuComponent = sfuComponent; + this.ws = null; + this.webRtcPeer = null; + this.pingInterval = null; + this.started = false; + this.signallingTransportOpen = false; + this.logCodePrefix = `${this.sfuComponent}_broker`; + + this.onbeforeunload = this.onbeforeunload.bind(this); + window.addEventListener('beforeunload', this.onbeforeunload); + } + + set started (val) { + this._started = val; + } + + get started () { + return this._started; + } + + onbeforeunload () { + return this.stop(); + } + + onstart () { + // To be implemented by inheritors + } + + onerror (error) { + // To be implemented by inheritors + } + + onended () { + // To be implemented by inheritors + } + + openWSConnection () { + return new Promise((resolve, reject) => { + this.ws = new WebSocket(this.wsUrl); + + this.ws.onmessage = this.onWSMessage.bind(this); + + this.ws.onclose = () => { + // 1301: "WEBSOCKET_DISCONNECTED", + this.onerror(BaseBroker.assembleError(1301)); + }; + + this.ws.onerror = (error) => { + logger.error({ + logCode: `${this.logCodePrefix}_websocket_error`, + extraInfo: { + errorMessage: error.name || error.message || 'Unknown error', + sfuComponent: this.sfuComponent, + } + }, 'WebSocket connection to SFU failed'); + + if (this.signallingTransportOpen) { + // 1301: "WEBSOCKET_DISCONNECTED", transport was already open + this.onerror(BaseBroker.assembleError(1301)); + } else { + // 1302: "WEBSOCKET_CONNECTION_FAILED", transport errored before establishment + const normalized1302 = BaseBroker.assembleError(1302); + this.onerror(normalized1302); + return reject(normalized1302); + } + }; + + this.ws.onopen = () => { + this.pingInterval = setInterval(this.ping.bind(this), PING_INTERVAL_MS); + this.signallingTransportOpen = true; + return resolve(); + }; + }); + } + + sendMessage (message) { + const jsonMessage = JSON.stringify(message); + this.ws.send(jsonMessage); + } + + ping () { + this.sendMessage({ id: 'ping' }); + } + + processAnswer (message) { + const { response, sdpAnswer, role, connectionId } = message; + + if (response !== 'accepted') return this.handleSFUError(message); + + logger.debug({ + logCode: `${this.logCodePrefix}_start_success`, + extraInfo: { + sfuConnectionId: connectionId, + role, + sfuComponent: this.sfuComponent, + } + }, `Start request accepted for ${this.sfuComponent}`); + + this.webRtcPeer.processAnswer(sdpAnswer, (error) => { + if (error) { + logger.error({ + logCode: `${this.logCodePrefix}_processanswer_error`, + extraInfo: { + errorMessage: error.name || error.message || 'Unknown error', + sfuConnectionId: connectionId, + role, + sfuComponent: this.sfuComponent, + } + }, `Error processing SDP answer from SFU for ${this.sfuComponent}`); + // 1305: "PEER_NEGOTIATION_FAILED", + return this.onerror(BaseBroker.assembleError(1305)); + } + + // Mark the peer as negotiated and flush the ICE queue + this.webRtcPeer.negotiated = true; + this.processIceQueue(); + }); + } + + addIceServers (options) { + if (this.iceServers && this.iceServers.length > 0) { + options.configuration = {}; + options.configuration.iceServers = this.iceServers; + } + + return options; + } + + handleConnectionStateChange (eventIdentifier) { + if (this.webRtcPeer) { + const { peerConnection } = this.webRtcPeer; + const connectionState = peerConnection.connectionState; + if (eventIdentifier) { + notifyStreamStateChange(eventIdentifier, connectionState); + } + + if (connectionState === 'failed' || connectionState === 'closed') { + this.webRtcPeer.peerConnection.onconnectionstatechange = null; + // 1307: "ICE_STATE_FAILED", + const error = BaseBroker.assembleError(1307); + this.onerror(error); + } + } + } + + addIceCandidate (candidate) { + this.webRtcPeer.addIceCandidate(candidate, (error) => { + if (error) { + // Just log the error. We can't be sure if a candidate failure on add is + // fatal or not, so that's why we have a timeout set up for negotiations and + // listeners for ICE state transitioning to failures, so we won't act on it here + logger.error({ + logCode: `${this.logCodePrefix}_addicecandidate_error`, + extraInfo: { + errorMessage: error.name || error.message || 'Unknown error', + errorCode: error.code || 'Unknown code', + sfuComponent: this.sfuComponent, + started: this.started, + } + }, `Adding ICE candidate failed`); + } + }); + } + + processIceQueue () { + const peer = this.webRtcPeer; + while (peer.iceQueue.length) { + const candidate = peer.iceQueue.shift(); + this.addIceCandidate(candidate); + } + } + + handleIceCandidate (candidate) { + const peer = this.webRtcPeer; + + if (peer.negotiated) { + this.addIceCandidate(candidate); + } else { + // ICE candidates are queued until a SDP answer has been processed. + // This was done due to a long term iOS/Safari quirk where it'd (as of 2018) + // fail if candidates were added before the offer/answer cycle was completed. + // IT STILL HAPPENS - prlanzarin sept 2019 + // still happens - prlanzarin sept 2020 + peer.iceQueue.push(candidate); + } + } + + disposePeer () { + if (this.webRtcPeer) { + this.webRtcPeer.dispose(); + this.webRtcPeer = null; + } + } + + stop () { + this.onstart = function(){}; + this.onerror = function(){}; + window.removeEventListener('beforeunload', this.onbeforeunload); + + if (this.webRtcPeer) { + this.webRtcPeer.peerConnection.onconnectionstatechange = null; + } + + if (this.ws !== null) { + this.ws.onclose = function (){}; + this.ws.close(); + } + + if (this.pingInterval) { + clearInterval(this.pingInterval); + } + + this.disposePeer(); + this.started = false; + + logger.debug({ + logCode: `${this.logCodePrefix}_stop`, + extraInfo: { sfuComponent: this.sfuComponent }, + }, `Stopped broker session for ${this.sfuComponent}`); + + this.onended(); + this.onended = function(){}; + } +} + +export default BaseBroker; diff --git a/bigbluebutton-html5/imports/ui/services/bbb-webrtc-sfu/stream-state-service.js b/bigbluebutton-html5/imports/ui/services/bbb-webrtc-sfu/stream-state-service.js new file mode 100644 index 0000000000..b4e929c4bf --- /dev/null +++ b/bigbluebutton-html5/imports/ui/services/bbb-webrtc-sfu/stream-state-service.js @@ -0,0 +1,45 @@ +/* + * The idea behind this whole utilitary is proving a decoupled way of propagating + * peer connection states up and down the component tree without coming up with + * weird trackers, hooks and/or prop drilling. This is mainly aimed at component + * trees that aren't well organized in the first place (ie video-provider). + * The base use case for this is notifying stream state changes to correctly + * handle UI for reconnection scenarios. + */ + +const STREAM_STATE_CHANGED_EVENT_PREFIX = 'streamStateChanged'; + +/* + * The event name format for notify/subscribe/unsubscribe is + * `${STREAM_STATE_CHANGED_EVENT_PREFIX}:${eventTag}`. eventTag can be any string. + * streamState must be a valid member of either RTCIceConnectionState or + * RTCPeerConnectionState enums + */ +export const notifyStreamStateChange = (eventTag, streamState) => { + const eventName = `${STREAM_STATE_CHANGED_EVENT_PREFIX}:${eventTag}`; + const streamStateChanged = new CustomEvent( + eventName, + { detail: { eventTag, streamState } }, + ); + window.dispatchEvent(streamStateChanged); +} + +// `callback` is the method to be called when a new state is notified +// via notifyStreamStateChange +export const subscribeToStreamStateChange = (eventTag, callback) => { + const eventName = `${STREAM_STATE_CHANGED_EVENT_PREFIX}:${eventTag}`; + window.addEventListener(eventName, callback, false); +} + +export const unsubscribeFromStreamStateChange = (eventTag, callback) => { + const eventName = `${STREAM_STATE_CHANGED_EVENT_PREFIX}:${eventTag}`; + window.removeEventListener(eventName, callback); +} + +export const isStreamStateUnhealthy = (streamState) => { + return streamState === 'disconnected' || streamState === 'failed' || streamState === 'closed'; +} + +export const isStreamStateHealthy = (streamState) => { + return streamState === 'connected' || streamState === 'completed'; +} diff --git a/bigbluebutton-html5/private/config/settings.yml b/bigbluebutton-html5/private/config/settings.yml index 670a28d3a0..3c05413ede 100755 --- a/bigbluebutton-html5/private/config/settings.yml +++ b/bigbluebutton-html5/private/config/settings.yml @@ -43,7 +43,8 @@ public: # can generate excessive overhead to the server. We recommend # this value to be kept under 12. breakoutRoomLimit: 8 - customHeartbeat: false + # https://github.com/bigbluebutton/bigbluebutton/pull/10826 + customHeartbeat: true defaultSettings: application: animations: true @@ -273,6 +274,7 @@ public: time: 5000 chat: enabled: true + bufferChatInsertsMs: 0 startClosed: false min_message_length: 1 max_message_length: 5000 @@ -340,6 +342,16 @@ public: #user activates microphone. iceGatheringTimeout: 5000 sipjsHackViaWs: false + #Websocket keepAlive interval (seconds). You may set this to prevent + #websocket disconnection in some environments. When set, BBB will send + #'\r\n\r\n' string through SIP.js's websocket. If not set, default value + #is 0. + websocketKeepAliveInterval: 30 + #Debounce time (seconds) for sending SIP.js's websocket keep alive message. + #If not set, default value is 10. + websocketKeepAliveDebounce: 10 + #Trace sip/audio messages in browser. If not set, default value is false. + traceSip: false presentation: allowDownloadable: true defaultPresentationFile: default.pdf @@ -500,6 +512,11 @@ private: timeout: 5000 password: null debug: false + metrics: + queueMetrics: false + metricsDumpIntervalMs: 60000 + metricsFolderPath: METRICS_FOLDER + removeMeetingOnEnd: true channels: toAkkaApps: to-akka-apps-redis-channel toThirdParty: to-third-party-redis-channel @@ -523,6 +540,8 @@ private: enabled: false heapdump: enabled: false + heapdumpFolderPath: HEAPDUMP_FOLDER + heapdumpIntervalMs: 3600000 minBrowserVersions: - browser: chrome version: 72 diff --git a/record-and-playback/presentation/scripts/process/presentation.rb b/record-and-playback/presentation/scripts/process/presentation.rb index cad51610e5..55154fc7d8 100755 --- a/record-and-playback/presentation/scripts/process/presentation.rb +++ b/record-and-playback/presentation/scripts/process/presentation.rb @@ -237,6 +237,11 @@ if not FileTest.directory?(target_dir) BigBlueButton.process_deskshare_videos(target_dir, temp_dir, meeting_id, deskshare_width, deskshare_height, presentation_props['video_formats']) end + # Copy shared notes from raw files + if !Dir["#{raw_archive_dir}/notes/*"].empty? + FileUtils.cp_r("#{raw_archive_dir}/notes", target_dir) + end + process_done = File.new("#{recording_dir}/status/processed/#{meeting_id}-presentation.done", "w") process_done.write("Processed #{meeting_id}") process_done.close diff --git a/record-and-playback/presentation/scripts/publish/presentation.rb b/record-and-playback/presentation/scripts/publish/presentation.rb index a10abaae65..855530be61 100755 --- a/record-and-playback/presentation/scripts/publish/presentation.rb +++ b/record-and-playback/presentation/scripts/publish/presentation.rb @@ -31,7 +31,7 @@ require 'fastimage' # require fastimage to get the image size of the slides (gem # This script lives in scripts/archive/steps while properties.yaml lives in scripts/ bbb_props = BigBlueButton.read_props -$presentation_props = YAML::load(File.open('presentation.yml')) +$presentation_props = YAML::load(File.read('presentation.yml')) # There's a couple of places where stuff is mysteriously divided or multiplied # by 2. This is just here to call out how spooky that is. @@ -874,7 +874,7 @@ def processPresentation(package_dir) # Iterate through the events.xml and store the events, building the # xml files as we go last_timestamp = 0.0 - events_xml = Nokogiri::XML(File.open("#{$process_dir}/events.xml")) + events_xml = Nokogiri::XML(File.read("#{$process_dir}/events.xml")) events_xml.xpath('/recording/event').each do |event| eventname = event['eventname'] last_timestamp = timestamp = @@ -1215,9 +1215,13 @@ begin FileUtils.cp("#{$process_dir}/presentation_text.json", package_dir) end + if File.exist?("#{$process_dir}/notes/notes.html") + FileUtils.cp("#{$process_dir}/notes/notes.html", package_dir) + end + processing_time = File.read("#{$process_dir}/processing_time") - @doc = Nokogiri::XML(File.open("#{$process_dir}/events.xml")) + @doc = Nokogiri::XML(File.read("#{$process_dir}/events.xml")) # Retrieve record events and calculate total recording duration. $rec_events = BigBlueButton::Events.match_start_and_stop_rec_events( @@ -1248,7 +1252,7 @@ begin # Update state and add playback to metadata.xml ## Load metadata.xml - metadata = Nokogiri::XML(File.open("#{package_dir}/metadata.xml")) + metadata = Nokogiri::XML(File.read("#{package_dir}/metadata.xml")) ## Update state recording = metadata.root state = recording.at_xpath("state")