summaryrefslogtreecommitdiffstats
path: root/recording
diff options
context:
space:
mode:
authorDaniel Calviño Sánchez <danxuliu@gmail.com>2023-02-05 05:08:28 +0100
committerDaniel Calviño Sánchez <danxuliu@gmail.com>2023-02-14 10:37:18 +0100
commitca2195396b2064206f85609763fbe0fd35156187 (patch)
tree4dd48482d0832290c956592c38b3d720610d06aa /recording
parentff1fcdc60e8968ee770b8720b4f3f9667af69127 (diff)
Add module for real participant
Based on the real participant class (and related) from "Talkbuchet-cli.py", but with some adjustments, like using kiosk mode, loading the browser with specific environment variables (which will make possible to load the browser in a specific display and to send the audio to a specific device) or preventing any microphone or camera from being used. Signed-off-by: Daniel Calviño Sánchez <danxuliu@gmail.com>
Diffstat (limited to 'recording')
-rw-r--r--recording/pyproject.toml4
-rw-r--r--recording/src/nextcloud/talk/recording/Participant.py424
2 files changed, 428 insertions, 0 deletions
diff --git a/recording/pyproject.toml b/recording/pyproject.toml
index 91a62fc53..dc8f1f0fa 100644
--- a/recording/pyproject.toml
+++ b/recording/pyproject.toml
@@ -5,6 +5,10 @@ license = {text = "GNU AGPLv3+"}
classifiers = [
"License :: OSI Approved :: GNU Affero General Public License v3 or later (AGPLv3+)",
]
+dependencies = [
+ "selenium>=4.6.0",
+ "websocket-client",
+]
dynamic = ["version"]
[tool.setuptools.dynamic]
diff --git a/recording/src/nextcloud/talk/recording/Participant.py b/recording/src/nextcloud/talk/recording/Participant.py
new file mode 100644
index 000000000..c5887bff2
--- /dev/null
+++ b/recording/src/nextcloud/talk/recording/Participant.py
@@ -0,0 +1,424 @@
+#
+# @copyright Copyright (c) 2023, Daniel Calviño Sánchez (danxuliu@gmail.com)
+#
+# @license GNU AGPL version 3 or any later version
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see <http://www.gnu.org/licenses/>.
+#
+
+"""
+Module to join a call with a browser.
+"""
+
+import json
+import logging
+import threading
+import websocket
+
+from datetime import datetime
+from selenium import webdriver
+from selenium.webdriver.common.by import By
+from selenium.webdriver.firefox.service import Service as FirefoxService
+from selenium.webdriver.support.wait import WebDriverWait
+from shutil import disk_usage
+from time import sleep
+
+class BiDiLogsHelper:
+ """
+ Helper class to get browser logs using the BiDi protocol.
+
+ A new thread is started by each object to receive the logs, so they can be
+ printed in real time even if the main thread is waiting for some script to
+ finish.
+ """
+
+ def __init__(self, driver, parentLogger):
+ if not 'webSocketUrl' in driver.capabilities:
+ raise Exception('webSocketUrl not found in capabilities')
+
+ self._logger = parentLogger.getChild('BiDiLogsHelper')
+
+ self.realtimeLogsEnabled = False
+ self.pendingLogs = []
+ self.logsLock = threading.Lock()
+
+ # Web socket connection is rejected by Firefox with "Bad request" if
+ # "Origin" header is present; logs show:
+ # "The handshake request has incorrect Origin header".
+ self.websocket = websocket.create_connection(driver.capabilities['webSocketUrl'], suppress_origin=True)
+
+ self.websocket.send(json.dumps({
+ 'id': 1,
+ 'method': 'session.subscribe',
+ 'params': {
+ 'events': ['log.entryAdded'],
+ },
+ }))
+
+ self.initialLogsLock = threading.Lock()
+ self.initialLogsLock.acquire()
+
+ self.loggingThread = threading.Thread(target=self.__processLogEvents, daemon=True)
+ self.loggingThread.start()
+
+ # Do not return until the existing logs were fetched, except if it is
+ # taking too long.
+ self.initialLogsLock.acquire(timeout=10)
+
+ def __del__(self):
+ if self.websocket:
+ self.websocket.close()
+
+ if self.loggingThread:
+ self.loggingThread.join()
+
+ def __messageFromEvent(self, event):
+ if not 'params' in event:
+ return '???'
+
+ method = ''
+ if 'method' in event['params']:
+ method = event['params']['method']
+ elif 'level' in event['params']:
+ method = event['params']['level'] if event['params']['level'] != 'warning' else 'warn'
+
+ text = ''
+ if 'text' in event['params']:
+ text = event['params']['text']
+
+ time = '??:??:??'
+ if 'timestamp' in event['params']:
+ timestamp = event['params']['timestamp']
+
+ # JavaScript timestamps are millisecond based, Python timestamps
+ # are second based.
+ time = datetime.fromtimestamp(timestamp / 1000).strftime('%H:%M:%S')
+
+ methodShort = '?'
+ if method == 'error':
+ methodShort = 'E'
+ elif method == 'warn':
+ methodShort = 'W'
+ elif method == 'log':
+ methodShort = 'L'
+ elif method == 'info':
+ methodShort = 'I'
+ elif method == 'debug':
+ methodShort = 'D'
+
+ return time + ' ' + methodShort + ' ' + text
+
+ def __processLogEvents(self):
+ while True:
+ try:
+ event = json.loads(self.websocket.recv())
+ except:
+ self._logger.debug('BiDi WebSocket closed')
+ return
+
+ if 'id' in event and event['id'] == 1:
+ self.initialLogsLock.release()
+ continue
+
+ if not 'method' in event or event['method'] != 'log.entryAdded':
+ continue
+
+ message = self.__messageFromEvent(event)
+
+ with self.logsLock:
+ if self.realtimeLogsEnabled:
+ self._logger.debug(message)
+ else:
+ self.pendingLogs.append(message)
+
+ def clearLogs(self):
+ """
+ Clears, without printing, the logs received while realtime logs were not
+ enabled.
+ """
+
+ with self.logsLock:
+ self.pendingLogs = []
+
+ def printLogs(self):
+ """
+ Prints the logs received while realtime logs were not enabled.
+
+ The logs are cleared after printing them.
+ """
+
+ with self.logsLock:
+ for log in self.pendingLogs:
+ self._logger.debug(log)
+
+ self.pendingLogs = []
+
+ def setRealtimeLogsEnabled(self, realtimeLogsEnabled):
+ """
+ Enable or disable realtime logs.
+
+ If logs are received while realtime logs are not enabled they can be
+ printed using "printLogs()".
+ """
+
+ with self.logsLock:
+ self.realtimeLogsEnabled = realtimeLogsEnabled
+
+
+class SeleniumHelper:
+ """
+ Helper class to start a browser and execute scripts in it using WebDriver.
+
+ The browser is expected to be available in the local system.
+ """
+
+ def __init__(self, parentLogger):
+ self._parentLogger = parentLogger
+ self._logger = parentLogger.getChild('SeleniumHelper')
+
+ self.driver = None
+ self.bidiLogsHelper = None
+
+ def __del__(self):
+ if self.driver:
+ # The session must be explicitly quit to remove the temporary files
+ # created in "/tmp".
+ self.driver.quit()
+
+ def startFirefox(self, width, height, env):
+ """
+ Starts a Firefox instance.
+
+ :param width: the width of the browser window.
+ :param height: the height of the browser window.
+ :param env: the environment variables, including the display to start
+ the browser in.
+ """
+
+ options = webdriver.FirefoxOptions()
+
+ # "webSocketUrl" is needed for BiDi; this should be set already by
+ # default, but just in case.
+ options.set_capability('webSocketUrl', True)
+ # In Firefox < 101 BiDi protocol was not enabled by default, although it
+ # works fine for getting the logs with Firefox 99, so it is explicitly
+ # enabled.
+ # https://bugzilla.mozilla.org/show_bug.cgi?id=1753997
+ options.set_preference('remote.active-protocols', 3)
+
+ options.set_preference('media.navigator.permission.disabled', True)
+
+ options.add_argument('--kiosk')
+ options.add_argument(f'--width={width}')
+ options.add_argument(f'--height={height}')
+
+ if disk_usage('/tmp').free < 134217728:
+ self._logger.warning('Less than 128 MiB available in "/tmp", strange failures may occur')
+
+ service = FirefoxService(
+ env=env,
+ )
+
+ self.driver = webdriver.Firefox(
+ options=options,
+ service=service,
+ )
+
+ self.bidiLogsHelper = BiDiLogsHelper(self.driver, self._parentLogger)
+
+ def clearLogs(self):
+ """
+ Clears browser logs not printed yet.
+
+ This does not affect the logs in the browser itself, only the ones
+ received by the SeleniumHelper.
+ """
+
+ if self.bidiLogsHelper:
+ self.bidiLogsHelper.clearLogs()
+ return
+
+ self.driver.get_log('browser')
+
+ def printLogs(self):
+ """
+ Prints browser logs received since last print.
+
+ These logs do not include realtime logs, as they are printed as soon as
+ they are received.
+ """
+
+ if self.bidiLogsHelper:
+ self.bidiLogsHelper.printLogs()
+ return
+
+ for log in self.driver.get_log('browser'):
+ self._logger.debug(log['message'])
+
+ def execute(self, script):
+ """
+ Executes the given script.
+
+ If the script contains asynchronous code "executeAsync()" should be used
+ instead to properly wait until the asynchronous code finished before
+ returning.
+
+ Technically Chrome (unlike Firefox) works as expected with something
+ like "execute('await someFunctionCall(); await anotherFunctionCall()'",
+ but "executeAsync" has to be used instead for something like
+ "someFunctionReturningAPromise().then(() => { more code })").
+
+ If realtime logs are available logs are printed as soon as they are
+ received. Otherwise they will be printed once the script has finished.
+ """
+
+ # Real time logs are enabled while the command is being executed.
+ if self.bidiLogsHelper:
+ self.printLogs()
+ self.bidiLogsHelper.setRealtimeLogsEnabled(True)
+
+ self.driver.execute_script(script)
+
+ if self.bidiLogsHelper:
+ # Give it some time to receive the last real time logs before
+ # disabling them again.
+ sleep(0.5)
+
+ self.bidiLogsHelper.setRealtimeLogsEnabled(False)
+
+ self.printLogs()
+
+ def executeAsync(self, script):
+ """
+ Executes the given script asynchronously.
+
+ This function should be used to execute JavaScript code that needs to
+ wait for a promise to be fulfilled, either explicitly or through "await"
+ calls.
+
+ The script needs to explicitly signal that the execution has finished by
+ including the special text "{RETURN}" (without quotes). If "{RETURN}" is
+ not included the function will automatically return once all the root
+ statements of the script were executed (which works as expected if using
+ "await" calls, but not if the script includes something like
+ "someFunctionReturningAPromise().then(() => { more code })"; in that
+ case the script should be written as
+ "someFunctionReturningAPromise().then(() => { more code {RETURN} })").
+
+ If realtime logs are available logs are printed as soon as they are
+ received. Otherwise they will be printed once the script has finished.
+ """
+
+ # Real time logs are enabled while the command is being executed.
+ if self.bidiLogsHelper:
+ self.printLogs()
+ self.bidiLogsHelper.setRealtimeLogsEnabled(True)
+
+ # Add an explicit return point at the end of the script if none is
+ # given.
+ if script.find('{RETURN}') == -1:
+ script += '{RETURN}'
+
+ # await is not valid in the root context in Firefox, so the script to be
+ # executed needs to be wrapped in an async function.
+ script = '(async() => { ' + script + ' })().catch(error => { console.error(error) {RETURN} })'
+
+ # Asynchronous scripts need to explicitly signal that they are finished
+ # by invoking the callback injected as the last argument.
+ # https://www.selenium.dev/documentation/legacy/json_wire_protocol/#sessionsessionidexecute_async
+ script = script.replace('{RETURN}', '; arguments[arguments.length - 1]()')
+
+ self.driver.execute_async_script(script)
+
+ if self.bidiLogsHelper:
+ # Give it some time to receive the last real time logs before
+ # disabling them again.
+ sleep(0.5)
+
+ self.bidiLogsHelper.setRealtimeLogsEnabled(False)
+
+ self.printLogs()
+
+
+class Participant():
+ """
+ Wrapper for a real participant in Talk.
+
+ This wrapper exposes functions to use a real participant in a browser.
+ """
+
+ def __init__(self, browser, nextcloudUrl, width, height, env, parentLogger):
+ """
+ Starts a real participant in the given Nextcloud URL using the given
+ browser.
+
+ :param browser: currently only "firefox" is supported.
+ :param nextcloudUrl: the URL of the Nextcloud instance to start the real
+ participant in.
+ :param width: the width of the browser window.
+ :param height: the height of the browser window.
+ :param env: the environment variables, including the display to start
+ the browser in.
+ :param parentLogger: the parent logger to get a child from.
+ """
+
+ # URL should not contain a trailing '/', as that could lead to a double
+ # '/' which may prevent Talk UI from loading as expected.
+ self.nextcloudUrl = nextcloudUrl.rstrip('/')
+
+ self.seleniumHelper = SeleniumHelper(parentLogger)
+
+ if browser == 'firefox':
+ self.seleniumHelper.startFirefox(width, height, env)
+ else:
+ raise Exception('Invalid browser: ' + browser)
+
+ self.seleniumHelper.driver.get(nextcloudUrl)
+
+ def joinCall(self, token):
+ """
+ Joins (or starts) the call in the room with the given token.
+
+ The participant will join as a guest.
+
+ :param token: the token of the room to join.
+ """
+
+ self.seleniumHelper.driver.get(self.nextcloudUrl + '/index.php/call/' + token)
+
+ # Hack to prevent the participant from using any device that might be
+ # available, including PulseAudio's "Monitor of Dummy Output".
+ self.seleniumHelper.execute('navigator.mediaDevices.getUserMedia = async function() { throw new Error() }')
+
+ WebDriverWait(self.seleniumHelper.driver, timeout=30).until(lambda driver: driver.find_element(By.CSS_SELECTOR, '.top-bar #call_button:not(:disabled)'))
+ self.seleniumHelper.driver.find_element(By.CSS_SELECTOR, '.top-bar #call_button').click()
+
+ try:
+ # If the device selector is shown click on the "Join call" button
+ # in the dialog to actually join the call.
+ WebDriverWait(self.seleniumHelper.driver, timeout=5).until(lambda driver: driver.find_element(By.CSS_SELECTOR, '.device-checker #call_button'))
+ self.seleniumHelper.driver.find_element(By.CSS_SELECTOR, '.device-checker #call_button').click()
+ except:
+ pass
+
+ def leaveCall(self):
+ """
+ Leaves the current call.
+
+ The call must have been joined first.
+ """
+
+ self.seleniumHelper.executeAsync('''
+ await OCA.Talk.SimpleWebRTC.connection.leaveCurrentCall()
+ ''')