├── zoom_drive_connector ├── __init__.py ├── slack │ ├── __init__.py │ └── slack_api.py ├── drive │ ├── __init__.py │ ├── drive_api_exception.py │ └── drive_api.py ├── zoom │ ├── __init__.py │ ├── zoom_api_exception.py │ └── zoom_api.py ├── configuration │ ├── __init__.py │ └── configuration_interfaces.py └── __main__.py ├── MANIFEST.in ├── pyproject.toml ├── tox.ini ├── .deepsource.toml ├── .editorconfig ├── .funcignore ├── host.json ├── requirements.txt ├── environment.yml ├── .pycodestyle ├── .dockerignore ├── tests ├── test_drive_exception.py ├── test_slack.py ├── unittest_settings.py ├── test_zoom_exception.py ├── test_zoom.py └── test_configuration.py ├── setup.py ├── Dockerfile ├── Makefile ├── .gitignore ├── style_check.sh ├── function_app.py ├── README.md ├── LICENSE └── .pylintrc /zoom_drive_connector/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = python3.5, python3.6, python3.7 3 | 4 | [testenv] 5 | deps = pytest 6 | extras = test 7 | commands = 8 | python setup.py check -m -s 9 | python -m pytest tests -------------------------------------------------------------------------------- /.deepsource.toml: -------------------------------------------------------------------------------- 1 | version = 1 2 | 3 | test_patterns = ["tests/*"] 4 | 5 | [[analyzers]] 6 | name = "python" 7 | enabled = true 8 | 9 | [analyzers.meta] 10 | runtime_version = "3.x.x" 11 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.py] 2 | indent_style = space 3 | indent_size = 2 4 | charset = utf-8 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | 8 | [*.sh] 9 | indent_style = space 10 | indent_size = 2 11 | 12 | [Makefile] 13 | indent_style = tab -------------------------------------------------------------------------------- /.funcignore: -------------------------------------------------------------------------------- 1 | .git* 2 | .vscode 3 | __azurite_db*__.json 4 | __blobstorage__ 5 | __queuestorage__ 6 | local.settings.json 7 | test 8 | .venv 9 | 10 | # Custom additions 11 | old 12 | __pycache__ 13 | config.yaml 14 | .ruff_cache 15 | credentials.json 16 | client_secrets.json 17 | -------------------------------------------------------------------------------- /host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | }, 11 | "extensionBundle": { 12 | "id": "Microsoft.Azure.Functions.ExtensionBundle", 13 | "version": "[4.*, 5.0.0)" 14 | } 15 | } -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | # DO NOT include azure-functions-worker in this file 2 | # The Python Worker is managed by Azure Functions platform 3 | # Manually managing azure-functions-worker may cause unexpected issues 4 | 5 | azure-functions==1.21.3 6 | PyYAML==6.0.2 7 | 8 | slackclient==2.9.4 9 | google-api-python-client==2.156.0 10 | google-auth-httplib2==0.2.0 11 | google-auth-oauthlib==1.2.1 12 | 13 | schedule==0.5.0 14 | -------------------------------------------------------------------------------- /environment.yml: -------------------------------------------------------------------------------- 1 | name: zoom-drive-connector 2 | channels: 3 | - conda-forge 4 | dependencies: 5 | - python=3.6 6 | - google-api-python-client=1.6.7 7 | - oauth2client=4.1.2 8 | - slackclient=1.2.1 9 | - pyjwt==1.5.3 10 | - pytest=3.10.0 # Dev dependency. 11 | - mypy=0.641 # Dev dependency. 12 | - pycodestyle=2.4.0 # Dev dependency. 13 | - pylint=1.9.2 # Dev dependency. 14 | - pip 15 | - pip: 16 | - pyyaml>=5.1 17 | - responses==0.10.2 # Dev dependency. 18 | - schedule==0.5.0 19 | - httplib2shim==0.0.3 20 | -------------------------------------------------------------------------------- /.pycodestyle: -------------------------------------------------------------------------------- 1 | [pycodestyle] 2 | 3 | # Skip errors and warnings 4 | # E111 indentation is not a multiple of four 5 | # E114 indentation is not a multiple of four (comment) 6 | # E121 continuation line under--indented for hanging indent 7 | # E128 continuation line under-indented for visual indent 8 | # E129 visually indented line with same indent as next logical line 9 | ignore=E114,E111,E121,E128,E129 10 | 11 | # Set maximum allowed line length (default: 79) 12 | max-line-length=100 13 | 14 | # Set the error format [default|pylint|] 15 | format=pylint 16 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | # Ignore app configuration files 2 | *.yaml* 3 | 4 | # Do not copy configuration files 5 | conf/ 6 | 7 | # Ignore git specific files 8 | .git/ 9 | .gitignore 10 | 11 | # Ignore linting and codestyle configs 12 | .pylintrc 13 | .pycodestyle 14 | 15 | # Ignore shell scripts 16 | *.sh 17 | 18 | # Ignore environment files for Conda 19 | environment.yml 20 | 21 | # Ignore Docker-related files 22 | .dockerignore 23 | Dockerfile 24 | Makefile 25 | 26 | # Other files that should not be bundled (keep README, however) 27 | **/*.md 28 | !README.md 29 | 30 | # Test directory 31 | tests/ 32 | .tox/ 33 | .mypy_cache/ 34 | 35 | # Local build directories 36 | build/ 37 | dist/ 38 | *.egg_info -------------------------------------------------------------------------------- /zoom_drive_connector/slack/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | from .slack_api import SlackAPI 17 | -------------------------------------------------------------------------------- /zoom_drive_connector/drive/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | from .drive_api import DriveAPI 17 | from .drive_api_exception import DriveAPIException 18 | -------------------------------------------------------------------------------- /zoom_drive_connector/zoom/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | from .zoom_api import ZoomAPI, ZoomWebhook 17 | from .zoom_api_exception import ZoomAPIException 18 | 19 | __all__ = ['ZoomAPI','ZoomAPIException', 'ZoomWebhook'] 20 | -------------------------------------------------------------------------------- /zoom_drive_connector/configuration/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | from .configuration_interfaces import ( 17 | APIConfigBase, 18 | SlackConfig, 19 | ZoomConfig, 20 | DriveConfig, 21 | SystemConfig, 22 | ConfigInterface 23 | ) 24 | -------------------------------------------------------------------------------- /tests/test_drive_exception.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | import unittest 17 | 18 | from zoom_drive_connector.drive import DriveAPIException 19 | 20 | 21 | class TestDriveAPIException(unittest.TestCase): 22 | def test_throw_exception(self): 23 | with self.assertRaises(DriveAPIException): 24 | raise DriveAPIException('Test', 'Test reason') 25 | 26 | def test_str_method(self): 27 | self.assertEqual(str(DriveAPIException('Test', 'Test reason.')), 28 | 'DRIVE_API_FAILURE: Test, Test reason.') 29 | 30 | def test_repr_method(self): 31 | self.assertEqual(repr(DriveAPIException('Test', 'Test reason')), 'DriveAPIException()') 32 | -------------------------------------------------------------------------------- /tests/test_slack.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | import unittest 17 | from zoom_drive_connector import slack 18 | 19 | from zoom_drive_connector.configuration import SlackConfig 20 | 21 | 22 | class TestSlack(unittest.TestCase): 23 | def setUp(self): 24 | self.config_dict = {'channel_name': 'some_channel', 'key': 'random_key'} 25 | self.conf = SlackConfig(self.config_dict) 26 | self.api = slack.SlackAPI(self.conf) 27 | 28 | def test_logger(self): 29 | with self.assertLogs(logger='app', level='INFO') as logger: 30 | self.api.post_message('Test message!', 'fake-channel') 31 | 32 | self.assertRegex(logger.output[0], '.*Slack notification sent.$') 33 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from os import path 3 | 4 | # Read the contents of the README into the long_description field. 5 | current_directory = path.abspath(path.dirname(__file__)) 6 | with open(path.join(current_directory, 'README.md'), encoding='utf-8') as f: 7 | long_description = f.read() 8 | 9 | setup( 10 | name='zoom-drive-connector', 11 | version='1.7', 12 | packages=find_packages(exclude=['tests']), 13 | url='https://github.com/minds-ai/zoom-drive-connector', 14 | license='Apache 2.0', 15 | author='Nick Pleatsikas, Jeroen Bédorf', 16 | author_email='nick@minds.ai, jeroen@minds.ai', 17 | # Package description. 18 | description='Automatically copies recordings from Zoom to Google Drive.', 19 | long_description=long_description, 20 | long_description_content_type='text/markdown', 21 | # Package requirements. 22 | install_requires=[ 23 | 'pyyaml>=5.1', 24 | 'slackclient==1.2.1', 25 | 'schedule==0.5.0', 26 | 'google-api-python-client==2.10.0', 27 | 'google-auth-httplib2==0.1.0', 28 | 'google-auth-oauthlib==0.4.4', 29 | ], 30 | python_requires='>=3.5, <4', 31 | # Development requirements. 32 | extras_require={ 33 | 'test': ['tox', 'mypy', 'pylint', 'pycodestyle', 'responses'] 34 | }, 35 | # Application entrypoint. 36 | entry_points={ 37 | 'console_scripts': [ 38 | 'zoom-drive-connector=zoom_drive_connector:__main__.main' 39 | ] 40 | } 41 | ) 42 | -------------------------------------------------------------------------------- /zoom_drive_connector/drive/drive_api_exception.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | 17 | class DriveAPIException(Exception): 18 | def __init__(self, name: str, reason: str): 19 | """Initializes object for containing information about an exception or error with the Google 20 | Drive API or its defined interface. 21 | 22 | :param name: Name of the error. 23 | :param reason: Reason for error. 24 | """ 25 | super(DriveAPIException, self).__init__() 26 | self.name = name 27 | self.reason = reason 28 | 29 | def __str__(self) -> str: 30 | """Returns formatted message containing information about the exception. This should be human 31 | readable. 32 | 33 | :return: String with exception contents. 34 | """ 35 | return f'DRIVE_API_FAILURE: {self.name}, {self.reason}' 36 | 37 | def __repr__(self) -> str: 38 | """Returns name of exception class. 39 | 40 | :return: name of exception class. 41 | """ 42 | return 'DriveAPIException()' 43 | -------------------------------------------------------------------------------- /tests/unittest_settings.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | import unittest 17 | 18 | 19 | class TestSettingsBase(unittest.TestCase): 20 | def setUp(self): 21 | self.slack_config = {'key': 'random_key'} 22 | self.zoom_config = {'key': 'some_key', 23 | 'secret': 'some_secret', 24 | 'username': 'some@email.com', 25 | 'delete': True, 26 | 'meetings': [ 27 | {'id': 'first_id', 'name': 'meeting1', 28 | 'folder_id': 'folder1', 'slack_channel': 'channel-1'}, 29 | {'id': 'second_id', 'name': 'meeting2', 30 | 'folder_id': 'folder2', 'slack_channel': 'channel-2'} 31 | ]} 32 | self.drive_config = {'credentials_json': '/tmp/credentials.json', 33 | 'client_secret_json': '/tmp/client_secrets.json'} 34 | self.internal_config = {'target_folder': '/tmp'} 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright 2019 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | FROM python:3.7.4-slim-buster AS base 15 | FROM base AS build 16 | 17 | LABEL MAINTAINER.1="Jeroen Bedorf " \ 18 | MAINTAINER.2="Nick Pleatsikas " 19 | 20 | # Copy project files into build container. 21 | WORKDIR /build 22 | COPY . /build 23 | 24 | # Create python wheel. 25 | RUN python setup.py bdist_wheel 26 | 27 | # Main container. This is where the application will be installed and run. 28 | FROM base 29 | 30 | # Create required volumes. 31 | VOLUME /conf 32 | 33 | # Copy wheel from build container into current container and install. 34 | WORKDIR /dist 35 | COPY --from=build /build/dist /dist 36 | RUN python -m pip install zoom_drive_connector-*-py3-none-any.whl 37 | 38 | # Create runuser and switch to it. 39 | RUN useradd -ms /bin/false runuser 40 | USER runuser 41 | 42 | WORKDIR /home/runuser 43 | 44 | # Set required environment variables. 45 | ENV CONFIG="/conf/config.yaml" 46 | 47 | # Run application. 48 | ENTRYPOINT ["python"] 49 | CMD ["-u", "-m", "zoom_drive_connector", "--noauth_local_webserver"] 50 | -------------------------------------------------------------------------------- /tests/test_zoom_exception.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | import unittest 17 | 18 | from requests import PreparedRequest 19 | from zoom_drive_connector.zoom import ZoomAPIException 20 | 21 | 22 | class TestZoomAPIException(unittest.TestCase): 23 | def test_throw_exception(self): 24 | with self.assertRaises(ZoomAPIException): 25 | raise ZoomAPIException(0, 'Test', None, 'Test message') 26 | 27 | def test_str_method(self): 28 | self.assertEqual(str(ZoomAPIException(404, 'Not found', None, 'File not found.')), 29 | 'HTTP_STATUS: 404-Not found, File not found.') 30 | 31 | def test_repr(self): 32 | self.assertEqual(repr(ZoomAPIException(0, 'Test', None, 'Test message')), 'ZoomAPIException()') 33 | 34 | def test_http_method(self): 35 | unknown_method = ZoomAPIException(0, 'Test', None, 'Test message') 36 | self.assertEqual(unknown_method.http_method, None) 37 | 38 | req = PreparedRequest() 39 | req.prepare_method('GET') 40 | known_method = ZoomAPIException(0, 'Test', req, 'Message') 41 | 42 | self.assertEqual(known_method.http_method, 'GET') 43 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # 15 | # Makefile for building new Docker images that are properly versioned. 16 | # Inspired by https://container-solutions.com/tagging-docker-images-the-right-way/ 17 | GIT_URL = $(shell git config --local --get remote.origin.url) 18 | 19 | ifneq (,$(findstring https, $(GIT_URL))) 20 | # Cloned via HTTPS 21 | ORG = $(shell echo ${GIT_URL} | awk -F '[/.]' '{print $$5}') 22 | NAME = $(shell echo ${GIT_URL} | awk -F '[/.]' '{print $$6}') 23 | else 24 | # Cloned via SSH 25 | ORG = $(shell echo ${GIT_URL} | awk -F '[:/.]' '{print $$3}') 26 | NAME = $(shell echo ${GIT_URL} | awk -F '[:/.]' '{print $$4}') 27 | endif 28 | 29 | # Default variables. 30 | TAG = $(shell git --no-pager log -1 --pretty=%H) 31 | IMG = ${NAME}:${TAG} 32 | 33 | # Allow end-user to set version number for image. Default to "latest". 34 | VERSION?=latest 35 | 36 | .PHONY : build push-github 37 | 38 | build: 39 | ifeq ($(VERSION),latest) 40 | @echo "Warning: Image version set to 'latest'" 41 | endif 42 | 43 | @docker build -t ${IMG} . 44 | @docker tag ${IMG} ${NAME}:${VERSION} 45 | 46 | push-github: build 47 | @docker tag ${IMG} docker.pkg.github.com/${ORG}/${NAME}/${NAME}:${VERSION} 48 | @docker push docker.pkg.github.com/${ORG}/${NAME}/${NAME}:${VERSION} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | 28 | # PyInstaller 29 | # Usually these files are written by a python script from a template 30 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 31 | *.manifest 32 | *.spec 33 | 34 | # Installer logs 35 | pip-log.txt 36 | pip-delete-this-directory.txt 37 | 38 | # Unit test / coverage reports 39 | htmlcov/ 40 | .tox/ 41 | .coverage 42 | .coverage.* 43 | .cache 44 | nosetests.xml 45 | coverage.xml 46 | *.cover 47 | .hypothesis/ 48 | 49 | # Translations 50 | *.mo 51 | *.pot 52 | 53 | # Django stuff: 54 | *.log 55 | local_settings.py 56 | 57 | # Flask stuff: 58 | instance/ 59 | .webassets-cache 60 | 61 | # Scrapy stuff: 62 | .scrapy 63 | 64 | # Sphinx documentation 65 | docs/_build/ 66 | 67 | # PyBuilder 68 | target/ 69 | 70 | # Jupyter Notebook 71 | .ipynb_checkpoints 72 | 73 | # pyenv 74 | .python-version 75 | 76 | # celery beat schedule file 77 | celerybeat-schedule 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # dotenv 83 | .env 84 | 85 | # virtualenv 86 | .venv 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # Ignore Jetbrains files 104 | .idea/* 105 | 106 | # Ignore .DS_Store files. 107 | .DS_Store 108 | 109 | # Google Drive Secrets. 110 | credentials.json 111 | client_secret.json 112 | 113 | # Application configuration. 114 | config.yaml -------------------------------------------------------------------------------- /zoom_drive_connector/zoom/zoom_api_exception.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | from typing import Optional 17 | from requests import PreparedRequest 18 | 19 | 20 | class ZoomAPIException(Exception): 21 | def __init__(self, 22 | status_code: int, 23 | name: str, 24 | method: Optional[PreparedRequest], 25 | message: str): 26 | """Initializes container for holding HTTP status information. 27 | 28 | :param status_code: HTTP status code. 29 | :param name: HTTP status name. 30 | :param method: HTTP method used. 31 | :param message: Exception message/reason. 32 | """ 33 | super(ZoomAPIException, self).__init__() 34 | self.status_code = status_code 35 | self.name = name 36 | self.method = method 37 | self.message = message 38 | 39 | def __str__(self) -> str: 40 | """Returns printable string with formatted exception message. 41 | Usage: print(ZoomAPIException) 42 | 43 | :return: formatted message containing exception information. 44 | """ 45 | return f'HTTP_STATUS: {self.status_code}-{self.name}, {self.message}' 46 | 47 | def __repr__(self) -> str: 48 | """Returns class type when repr method called. 49 | 50 | :return: string containing exception class name. 51 | """ 52 | return 'ZoomAPIException()' 53 | 54 | @property 55 | def http_method(self): 56 | """Returns request method. 57 | 58 | :return: String containing HTTP request method (GET, DELETE, etc.). 59 | """ 60 | return self.method.method if self.method else None 61 | -------------------------------------------------------------------------------- /style_check.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # ============================================================================== 17 | 18 | ####################################### 19 | # Runs pycodestyle and pylint against a 20 | # specified file. 21 | # Globals: 22 | # None 23 | # Arguments: 24 | # file_name: name of file to run checks 25 | # on. 26 | # Returns: 27 | # None 28 | ####################################### 29 | check_file () { 30 | # Function arguments. 31 | local file_name="$1" 32 | 33 | echo "File: $file_name" 34 | echo "PYCODESTYLE:" 35 | pycodestyle --config=.pycodestyle "$file_name" 36 | 37 | echo "PYLINT:" 38 | pylint --rcfile=".pylintrc" --output-format=parseable "$file_name" 39 | 40 | echo "MYPY:" 41 | mypy --ignore-missing-imports "$file_name" 42 | } 43 | 44 | ####################################### 45 | # Gets an array of files in the given 46 | # directory and in all subdirectories 47 | # and runs check_file against them. 48 | # Globals: 49 | # None 50 | # Arguments: 51 | # folder: name of folder to check files 52 | # in. 53 | # Returns: 54 | # None 55 | ####################################### 56 | check_files_in_dir () { 57 | # Function arguments. 58 | local folder="$1" 59 | 60 | # Get array of files. 61 | IFS=" " read -ra PY_FILES <<< "$(find "$folder" -type f -iname "*.py" -exec echo {} +)" 62 | 63 | # Run checks against all files in array. 64 | for file in "${PY_FILES[@]}"; do 65 | check_file "$file" 66 | done 67 | } 68 | 69 | if [[ $# -lt 1 ]]; then 70 | # If no argument is provided then check all files in all directories. 71 | check_files_in_dir . 72 | else 73 | if [[ -f $1 ]]; then 74 | # Check specific file. 75 | check_file "$1" 76 | else 77 | # Check specific folder. 78 | check_files_in_dir "$1" 79 | fi 80 | fi 81 | -------------------------------------------------------------------------------- /zoom_drive_connector/drive/drive_api.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | import base64 17 | import json 18 | import os 19 | import logging 20 | from typing import TypeVar, cast 21 | 22 | from google.oauth2.credentials import Credentials 23 | from google.auth.transport.requests import Request 24 | from google_auth_oauthlib.flow import InstalledAppFlow 25 | from googleapiclient.discovery import build 26 | from googleapiclient.http import MediaFileUpload 27 | 28 | from zoom_drive_connector.configuration import DriveConfig, SystemConfig, APIConfigBase 29 | 30 | from .drive_api_exception import DriveAPIException 31 | 32 | log = logging.getLogger('app') 33 | S = TypeVar("S", bound=APIConfigBase) 34 | 35 | 36 | class DriveAPI: 37 | def __init__(self, drive_config: S, sys_config: S): 38 | """Initializes instance of DriveAPI class. 39 | 40 | :param drive_config: configuration class containing all parameters needed for Google Drive. 41 | :param sys_config: configuration class containing all system related parameters. 42 | """ 43 | self.drive_config = cast(DriveConfig, drive_config) 44 | self.sys_config = cast(SystemConfig, sys_config) 45 | 46 | self._scopes = ['https://www.googleapis.com/auth/drive.file'] 47 | self._service = None 48 | 49 | self.setup() 50 | 51 | def setup(self): 52 | """Triggers the OAuth2 setup flow for Google API endpoints. Requires the ability to open 53 | a link within a web browser in order to work. 54 | """ 55 | creds = None 56 | 57 | if (env_data :=os.environ.get('GOOGLE_APPLICATION_CREDENTIALS')): 58 | decoded_data = base64.b64decode(env_data).decode("utf-8") 59 | creds_info = json.loads(decoded_data) 60 | creds = Credentials.from_authorized_user_info(creds_info, self._scopes) 61 | log.log(logging.INFO, 'Using Google Application Credentials from environment') 62 | elif os.path.exists(self.drive_config.credentials_json): 63 | creds = Credentials.from_authorized_user_file( 64 | self.drive_config.credentials_json, self._scopes 65 | ) 66 | 67 | if not creds or not creds.valid: 68 | if creds and creds.expired and creds.refresh_token: 69 | creds.refresh(Request()) 70 | else: 71 | flow = InstalledAppFlow.from_client_secrets_file( 72 | self.drive_config.client_secret_json, self._scopes 73 | ) 74 | creds = flow.run_local_server(port=0) 75 | 76 | # Don't write the credentials if we are using the environment variable. Azure functions 77 | # uses a read-only file system 78 | if "GOOGLE_APPLICATION_CREDENTIALS" not in os.environ: 79 | with open(self.drive_config.credentials_json, 'w') as token: 80 | token.write(creds.to_json()) 81 | 82 | self._service = build('drive', 'v3', credentials=creds) 83 | 84 | log.log(logging.INFO, 'Drive connection established.') 85 | 86 | def upload_file(self, file_path: str, name: str, folder_id: str) -> str: 87 | """Uploads the given file to the specified folder id in Google Drive. 88 | 89 | :param file_path: Path to file to upload to Google Drive. 90 | :param name: Final name of the file 91 | :param folder_id: The Google Drive folder to upload the file to 92 | :return: The url of the file in Google Drive. 93 | """ 94 | 95 | if self._service is None: 96 | # Raise an exception if setup() hasn't been run. 97 | raise DriveAPIException(name='Service error', reason='setup() method not called.') 98 | 99 | if not file_path or not os.path.exists(file_path): 100 | # Raise an exception if the specified file doesn't exist. 101 | raise DriveAPIException( 102 | name='File error', reason=f'{file_path} could not be found.') 103 | 104 | # Google Drive file metadata 105 | metadata = {'name': name, 'parents': [folder_id]} 106 | 107 | # Create a new upload of the recording and execute it. 108 | media = MediaFileUpload(file_path, 109 | mimetype='video/mp4', 110 | chunksize=1024*1024, 111 | resumable=True 112 | ) 113 | 114 | # pylint: disable=no-member 115 | request = self._service.files().create(body=metadata, 116 | media_body=media, 117 | fields='webViewLink', 118 | supportsTeamDrives=True 119 | ) 120 | response = None 121 | while response is None: 122 | status, response = request.next_chunk() 123 | if status: 124 | print(f"Uploaded {int(status.progress() * 100)}%") 125 | uploaded_file = request.execute() 126 | 127 | log.log(logging.INFO, f'File {file_path} uploaded to Google Drive') 128 | 129 | # Return the url to the file that was just uploaded. 130 | return uploaded_file.get('webViewLink') 131 | -------------------------------------------------------------------------------- /zoom_drive_connector/__main__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | import datetime 17 | import logging 18 | import os 19 | import time 20 | from typing import TypeVar, cast, Dict, List 21 | 22 | import schedule 23 | 24 | from zoom_drive_connector import ( 25 | configuration as config, 26 | drive, 27 | slack, 28 | zoom 29 | ) 30 | 31 | S = TypeVar("S", bound=config.APIConfigBase) 32 | 33 | 34 | def download(zoom_conn: zoom.ZoomAPI, zoom_conf: config.ZoomConfig) -> List[Dict[str, str]]: 35 | """Downloads all available recordings from Zoom and returns a list of dicts with all relevant 36 | information about the recording. 37 | 38 | :param zoom_conn: API object instance for Zoom. 39 | :param zoom_conf: configuration instance containing all Zoom API settings. 40 | :return: list of dictionaries containing meeting recording information. 41 | """ 42 | result = [] 43 | 44 | # Note, need type: ignore here as the return Union contains items without iterator 45 | meeting = {} # type: Dict 46 | for meeting in zoom_conf.meetings: # type: ignore 47 | meeting = cast(Dict, meeting) 48 | res = zoom_conn.pull_file_from_zoom(meeting['id'], rm=bool(zoom_conf.delete)) 49 | if (res['success']) and (res['filename']): 50 | name = f'{res["date"].strftime("%Y%m%d")}-{meeting["name"]}.mp4' 51 | 52 | result.append({'meeting': meeting['name'], 53 | 'file': res['filename'], 54 | 'name': name, 55 | 'folder_id': meeting['folder_id'], 56 | 'slack_channel': meeting['slack_channel'], 57 | 'date': res['date'].strftime('%B %d, %Y at %H:%M'), 58 | 'unix': int(res['date'].replace(tzinfo=datetime.timezone.utc).timestamp())}) 59 | 60 | return result 61 | 62 | 63 | def upload_and_notify(files: List, drive_conn: drive.DriveAPI, slack_conn: slack.SlackAPI): 64 | """Uploads a list of files from the local filesystem to Google Drive. 65 | 66 | :param files: list of dictionaries containing file information. 67 | :param drive_conn: API instance for Google Drive. 68 | :param slack_conn: API instance for Slack. 69 | """ 70 | for file in files: 71 | try: 72 | # Get url from upload function. 73 | file_url = drive_conn.upload_file(file['file'], file['name'], file['folder_id']) 74 | 75 | # The formatted date/time string to be used for older Slack clients 76 | fall_back = f"{file['date']} UTC" 77 | 78 | # Only post message if the upload worked. 79 | message = (f'The recording of _{file["meeting"]}_ on ' 80 | "__" 81 | f' is <{file_url}| now available>.') 82 | 83 | slack_conn.post_message(message, file['slack_channel']) 84 | except drive.DriveAPIException as e: 85 | raise e 86 | # Remove the file after uploading so we do not run out of disk space in our container. 87 | os.remove(file['file']) 88 | 89 | 90 | def all_steps(zoom_conn: zoom.ZoomAPI, 91 | slack_conn: slack.SlackAPI, 92 | drive_conn: drive.DriveAPI, 93 | zoom_config: S): 94 | """Primary function dispatcher that calls functions which download files and then upload them and 95 | notifies people in Slack that they are on Google Drive. 96 | 97 | :param zoom_conn: API object instance for Zoom. 98 | :param slack_conn: API object instance for Slack. 99 | :param drive_conn: API object instance for Google Drive. 100 | :param zoom_config: configuration instance containing all Zoom API settings. 101 | """ 102 | downloaded_files = download(zoom_conn, cast(config.ZoomConfig, zoom_config)) 103 | upload_and_notify(downloaded_files, drive_conn, slack_conn) 104 | 105 | 106 | def main(): 107 | """Application entrypoint function. Configures logging, parses configuration file, and sets up 108 | proper container classes. 109 | """ 110 | # App configuration. 111 | app_config = config.ConfigInterface(os.getenv('CONFIG', '/conf/config.yaml')) 112 | 113 | # Configure the logger interface to print to console with level INFO 114 | log = logging.getLogger('app') 115 | log.setLevel(logging.INFO) 116 | ch = logging.StreamHandler() 117 | ch.setFormatter(logging.Formatter('%(asctime)s %(module)s:%(levelname)s %(message)s')) 118 | log.addHandler(ch) 119 | 120 | log.info('Application starting up.') 121 | 122 | # Configure each API service module. 123 | zoom_api = zoom.ZoomAPI(app_config.zoom, app_config.internals) 124 | slack_api = slack.SlackAPI(app_config.slack) 125 | drive_api = drive.DriveAPI(app_config.drive, app_config.internals) # This should open a prompt. 126 | 127 | # Run the application on a 10 minute schedule. 128 | all_steps(zoom_api, slack_api, drive_api, app_config.zoom) 129 | schedule.every(10).minutes.do(all_steps, zoom_api, slack_api, drive_api, app_config.zoom) 130 | while True: 131 | schedule.run_pending() 132 | time.sleep(1) 133 | 134 | 135 | if __name__ == '__main__': 136 | main() 137 | -------------------------------------------------------------------------------- /zoom_drive_connector/configuration/configuration_interfaces.py: -------------------------------------------------------------------------------- 1 | # type: ignore # pylint: disable=no-member 2 | 3 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | # ============================================================================== 17 | from typing import Dict, Union, Any 18 | 19 | import base64 20 | import logging 21 | import os 22 | import yaml 23 | 24 | log = logging.getLogger('app') 25 | 26 | 27 | class APIConfigBase: 28 | def __init__(self, settings_dict: Dict): 29 | """Initializes key and secret values. 30 | 31 | :param settings_dict: dictionary of settings corresponding to specific service. 32 | """ 33 | self.settings_dict = settings_dict 34 | 35 | def validate(self) -> bool: 36 | """Dummy validation method. 37 | 38 | :return: Always returns true. 39 | """ 40 | return True 41 | 42 | def __getattr__(self, item) -> Union[str, int]: 43 | """Allows for dot operator access to anything in `settings_dict`. 44 | 45 | :param item: name of attribute to return from `settings_dict`. 46 | :return: value of attribute in dictionary. Either string or integer. 47 | """ 48 | return self.settings_dict[item] 49 | 50 | @classmethod 51 | def factory_registrar(cls, name): 52 | """Returns true if the current class is the proper registrar for the corresponding config class. 53 | 54 | :param name: name of class that should be registered. 55 | :return: if __class__ matched the passed in class name. 56 | """ 57 | return name == cls._classname 58 | 59 | 60 | class SlackConfig(APIConfigBase): 61 | _classname = 'slack' 62 | 63 | 64 | class ZoomConfig(APIConfigBase): 65 | _classname = 'zoom' 66 | 67 | def validate(self) -> bool: 68 | """Checks to see if all Zoom configuration parameters are valid. 69 | This includes checking the 4 items that should be configured per meeting 70 | 71 | :return: Checks to make sure that the meetings have all required properties 72 | """ 73 | if not all( 74 | k in self.settings_dict for k in ( 75 | 'account_id', 'client_id', 'client_secret', 'delete', 'meetings') 76 | ): 77 | return False 78 | 79 | for meeting in self.settings_dict['meetings']: 80 | if not all(k in meeting for k in ('id', 'folder_id', 'name', 'slack_channel')): 81 | return False 82 | 83 | return True 84 | 85 | 86 | class DriveConfig(APIConfigBase): 87 | _classname = 'drive' 88 | 89 | def validate(self) -> bool: 90 | """Checks to see if all parameters are valid. 91 | 92 | :return: Checks to make sure that the secret file exists. 93 | """ 94 | if "GOOGLE_APPLICATION_CREDENTIALS" in os.environ: 95 | return True 96 | files_exist = os.path.exists(self.settings_dict['client_secret_json']) 97 | return files_exist 98 | 99 | 100 | class SystemConfig(APIConfigBase): 101 | _classname = 'internals' 102 | 103 | def validate(self) -> bool: 104 | """Returns true if the target folder exists. 105 | 106 | :return: True if the condition listed about evaluates to true. 107 | """ 108 | return os.path.isdir(self.settings_dict['target_folder']) 109 | 110 | 111 | class ConfigInterface: 112 | def __init__(self, file: str): 113 | """Initializes and loads configuration file to Python object. 114 | """ 115 | self.file = file 116 | self.configuration_dict: Dict = dict() 117 | 118 | # Load configuration 119 | self.__interface_factory() 120 | 121 | def __load_config(self) -> Dict[str, Any]: 122 | """Loads YAML configuration file to Python dictionary. Does some basic error checking to help 123 | with debugging bad configuration files. 124 | """ 125 | try: 126 | with open(self.file, 'r') as f: 127 | return yaml.safe_load(f) 128 | except yaml.YAMLError as ye: 129 | log.log(logging.ERROR, f'Error in YAML file {self.file}') 130 | 131 | # If the error can be identified, print it to the console. 132 | if hasattr(ye, 'problem_mark'): 133 | log.log(logging.INFO, f'Error at position ({ye.problem_mark.line + 1}, ' 134 | f'{ye.problem_mark.column + 1})') 135 | 136 | raise SystemExit # Crash program 137 | 138 | def __interface_factory(self): 139 | """Loads configuration file using `self.__load_config` and iterates through each top-level key 140 | and instantiates the corresponding configuration class depending on the name of the key. Each 141 | class then has its validation method run to check for any errors. 142 | """ 143 | if self.file == "": 144 | data = os.environ.get('ZOOM_DRIVE_SLACK_CONFIG') 145 | decoded_yaml = base64.b64decode(data).decode("utf-8") 146 | dict_from_yaml = yaml.safe_load(decoded_yaml) 147 | log.log(logging.INFO, 'Loaded YAML config from environment variable') 148 | else: 149 | dict_from_yaml = self.__load_config() 150 | 151 | # Iterate through all keys and their corresponding values. 152 | for key, value in dict_from_yaml.items(): 153 | # Iterator for subclasses of `APIConfigBase`. 154 | for cls in APIConfigBase.__subclasses__(): 155 | if cls.factory_registrar(key): 156 | self.configuration_dict[key] = cls(value) 157 | 158 | # Run validation for each item in the dictionary. 159 | for value in self.configuration_dict.values(): 160 | if not value.validate(): 161 | raise RuntimeError(f'Configuration for section {value.__class__} failed validation step.') 162 | 163 | def __getattr__(self, item) -> APIConfigBase: 164 | """Returns the configuration class corresponding to given name. Allows "dot" access to items 165 | in the configuration_dict. 166 | 167 | :param item: name of the key in `configuration_dict`. 168 | :return: the value corresponding to the key specified by the parameter `item`. 169 | """ 170 | return self.configuration_dict[item] 171 | -------------------------------------------------------------------------------- /function_app.py: -------------------------------------------------------------------------------- 1 | # Copyright 2024 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | """Azure Function App to handle ad process Zoom Webhooks events. 17 | 18 | Supported Events: 19 | - recording.completed: Triggered when the recording of a meeting is completed and available. 20 | The event body contains a download link. 21 | - meeting.summary_completed: Triggered when the summary of a meeting is completed and available. 22 | The event body contains the summary data. 23 | """ 24 | 25 | import json 26 | import logging 27 | import os 28 | 29 | import azure.functions as func 30 | from azure.functions.decorators import FunctionApp 31 | 32 | from zoom_drive_connector import configuration as config 33 | from zoom_drive_connector import drive, slack, zoom 34 | 35 | app = FunctionApp() 36 | 37 | 38 | def get_apis(use_zoom: bool = True, use_slack: bool = True, use_drive: bool = True) -> tuple: 39 | """Initialize all APIs and return the objects. 40 | 41 | Args: 42 | use_zoom: Whether to use the Zoom API. 43 | use_slack: Whether to use the Slack API. 44 | use_drive: Whether to use the Google Drive API. 45 | 46 | Returns: 47 | Tuple of ZoomAPI, SlackAPI, and DriveAPI objects 48 | """ 49 | app_config = config.ConfigInterface("") 50 | zoom_api = zoom.ZoomAPI(app_config.zoom, app_config.internals) if use_zoom else None 51 | slack_api = slack.SlackAPI(app_config.slack) if use_slack else None 52 | drive_api = drive.DriveAPI(app_config.drive, app_config.internals) if use_drive else None 53 | return zoom_api, slack_api, drive_api 54 | 55 | 56 | @app.route(route="zoom", methods=["POST"]) 57 | @app.queue_output(arg_name="queue", queue_name="myqueue", connection="AzureWebJobsStorage") 58 | def zoom_handle(req: func.HttpRequest, queue: func.Out[str]) -> func.HttpResponse: 59 | """Handle Zoom Webhook events as received from the Zoom systems.""" 60 | logging.info("HTTP trigger function processed a request.") 61 | 62 | headers = req.headers 63 | text = req.get_body().decode("utf-8") 64 | zhook = zoom.ZoomWebhook(os.environ["ZOOM_WEBHOOK_TOKEN"]) 65 | 66 | try: 67 | if not zhook.verify_headers(headers, text): 68 | return func.HttpResponse("Invalid Headers", status_code=400) 69 | 70 | logging.info("Zoom Headers Verified") 71 | 72 | body = req.get_json() 73 | print(body) 74 | 75 | response = {} 76 | event_name = body.get("event", "UNKNOWN") 77 | 78 | supported_events = [ 79 | "recording.completed", 80 | "meeting.summary_completed", 81 | ] 82 | 83 | logging.info(f"Parsing Event: {event_name}") 84 | if event_name == "endpoint.url_validation": 85 | response = zhook.handle_validation_event(body) 86 | elif event_name in supported_events: 87 | logging.info(f"Supported Event '{event_name}', added to Queue") 88 | response = {"message": "Received Supported Event"} 89 | queue.set(body) 90 | else: 91 | response = {"message": "Unsupported Event"} 92 | logging.warning(f"Unsupported Event: {event_name}") 93 | 94 | return func.HttpResponse( 95 | json.dumps(response).encode("utf-8"), 96 | status_code=200, 97 | headers={"Content-Type": "application/json"}, 98 | ) 99 | except Exception: 100 | error_msg = "An error occurred while processing the request." 101 | logging.exception(error_msg) 102 | return func.HttpResponse(error_msg, status_code=500) 103 | 104 | 105 | 106 | @app.queue_trigger(arg_name="msg", queue_name="myqueue", connection="AzureWebJobsStorage") 107 | def process_queue(msg: func.QueueMessage) -> None: 108 | """Process the message that was added to the queue by the webhook handler.""" 109 | logging.info(f"Queue trigger function processed a message: {msg.get_body().decode('utf-8')}") 110 | 111 | zoom_body = json.loads(msg.get_body().decode("utf-8")) 112 | event_name = zoom_body["event"] 113 | 114 | if event_name == "recording.completed": 115 | return process_recording_complete_event(zoom_body) 116 | if event_name == "meeting.summary_completed": 117 | return process_meeting_summary_event(zoom_body) 118 | 119 | logging.warning(f"Unsupported Event: {event_name}") 120 | 121 | 122 | def process_meeting_summary_event(zoom_body: dict) -> None: 123 | """Process the meeting summary event. 124 | 125 | Args: 126 | zoom_body: The body of the Zoom event. 127 | """ 128 | logging.info("Processing meeting summary event") 129 | try: 130 | zoom_api, slack_api, _ = get_apis(use_drive=False) 131 | result = zoom_api.webhook_summary_message(zoom_body) 132 | 133 | if result["success"]: 134 | slack_api.post_summary(result, result["slack_channel"]) 135 | logging.info("Meeting summary event processed successfully") 136 | except Exception: 137 | logging.exception("Error processing meeting summary event.") 138 | 139 | 140 | 141 | def process_recording_complete_event(zoom_body: dict) -> None: 142 | """Process the recording completed event. 143 | 144 | Args: 145 | zoom_body: The body of the Zoom event. 146 | """ 147 | logging.info("Processing meeting recording event") 148 | try: 149 | zoom_api, slack_api, drive_api = get_apis() 150 | result = zoom_api.webhook_recording_complete(zoom_body, ["MP4"]) 151 | 152 | if (result["success"]) and (result["filename"]): 153 | logging.info(f"Successfully downloaded {result['filename']}") 154 | else: 155 | logging.error(f"Failed to download files: {result}") 156 | return 157 | 158 | for idx, fname in enumerate(result["filename"]): 159 | try: 160 | name = f'{result["date"][idx].strftime("%Y%m%d")}-{result["meeting"]}.mp4' 161 | slack_api.post_recording_message( 162 | result["meeting"], 163 | result["date"][idx], 164 | drive_api.upload_file(fname, name, result["folder_id"]), 165 | result["meeting_uuid"], 166 | result["slack_channel"], 167 | ) 168 | except drive.DriveAPIException as e: 169 | raise e 170 | # Remove the file after uploading so we do not run out of disk space in our container. 171 | os.remove(fname) 172 | except Exception: 173 | logging.exception("Error processing queue message.") 174 | 175 | 176 | -------------------------------------------------------------------------------- /tests/test_zoom.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | import datetime 17 | import time 18 | 19 | from unittest.mock import MagicMock 20 | 21 | import os 22 | import responses 23 | import jwt 24 | from zoom_drive_connector import zoom 25 | 26 | from zoom_drive_connector.configuration import ZoomConfig, SystemConfig 27 | 28 | # pylint: disable=no-member 29 | # pylint: disable=relative-beyond-top-level 30 | from unittest_settings import TestSettingsBase 31 | 32 | 33 | # pylint: disable=too-many-instance-attributes 34 | class TestZoom(TestSettingsBase): 35 | # pylint: disable=invalid-name 36 | def setUp(self): 37 | super(TestZoom, self).setUp() 38 | 39 | self.zoom_object = ZoomConfig(self.zoom_config) 40 | self.sys_object = SystemConfig(self.internal_config) 41 | 42 | self.api = zoom.ZoomAPI(self.zoom_object, self.sys_object) 43 | # URLs 44 | self.single_recording_url = 'https://api.zoom.us/v2/meetings/some-meeting-id/recordings/rid' 45 | self.single_meeting_recording_info_url = 'https://api.zoom.us/v2/meetings/some-meeting-id/' \ 46 | 'recordings' 47 | self.single_recording_download = 'https://mindsai.zoom.us/recording/share/random-uid' 48 | self.zak_token_url = f'https://api.zoom.us/v2/users/{self.zoom_object.username}/token' 49 | 50 | # Test JSON payload to be returned from querying specific meeting ID for recordings. 51 | self.recording_return_payload = { 52 | 'recording_files': [{ 53 | 'file_type': 'MP4', 54 | 'recording_start': '2018-01-01T01:01:01Z', 55 | 'download_url': self.single_recording_download, 56 | 'id': 'some-recording-id' 57 | }] 58 | } 59 | # Test JSON payload as returned by the zak token request 60 | self.zak_token_return_payload = {'token': 'token'} 61 | 62 | def test_generate_jwt_valid_token(self): 63 | token = jwt.encode( 64 | { 65 | 'iss': self.zoom_object.key, 66 | 'exp': int(time.time() + 1800) 67 | }, 68 | str(self.zoom_object.secret), 69 | algorithm='HS256') 70 | 71 | self.assertEqual(self.api.generate_jwt(), token) 72 | 73 | def test_generate_jwt_invalid_token(self): 74 | token = jwt.encode( 75 | { 76 | 'iss': 'fake', 77 | 'exp': int(time.time()) 78 | }, str(self.zoom_object.secret), algorithm='HS256') 79 | 80 | self.assertNotEqual(self.api.generate_jwt(), token) 81 | 82 | @responses.activate 83 | def test_delete_recording_errors(self): 84 | responses.add(responses.DELETE, self.single_recording_url, status=404) 85 | 86 | with self.assertRaises(zoom.ZoomAPIException): 87 | self.api.delete_recording('some-meeting-id', 'rid', b'token') 88 | 89 | @responses.activate 90 | def test_get_url_success(self): 91 | responses.add( 92 | responses.GET, 93 | self.single_meeting_recording_info_url, 94 | status=200, 95 | json=self.recording_return_payload) 96 | 97 | self.assertEqual( 98 | self.api.get_recording_url('some-meeting-id', b'token'), { 99 | 'date': datetime.datetime(2018, 1, 1, 1, 1, 1), 100 | 'id': 'some-recording-id', 101 | 'url': self.single_recording_download 102 | }) 103 | 104 | @responses.activate 105 | def test_get_recording_url_fail(self): 106 | responses.add(responses.GET, self.single_meeting_recording_info_url, status=404) 107 | 108 | with self.assertRaises(zoom.ZoomAPIException): 109 | self.api.get_recording_url('some-meeting-id', b'token') 110 | 111 | responses.add(responses.GET, self.single_meeting_recording_info_url, status=300) 112 | 113 | with self.assertRaises(zoom.ZoomAPIException): 114 | self.api.get_recording_url('some-meeting-id', b'token') 115 | 116 | responses.add(responses.GET, self.single_meeting_recording_info_url, status=500) 117 | 118 | with self.assertRaises(zoom.ZoomAPIException): 119 | self.api.get_recording_url('some-meeting-id', b'token') 120 | 121 | @responses.activate 122 | def test_downloading_file(self): 123 | responses.add(responses.GET, self.zak_token_url, status=404, json=self.zak_token_return_payload) 124 | responses.add(responses.GET, self.single_recording_download, status=200, stream=True) 125 | 126 | with self.assertRaises(zoom.ZoomAPIException): 127 | self.api.download_recording(self.single_recording_download, b'token') 128 | 129 | responses.replace( 130 | responses.GET, self.zak_token_url, status=200, json=self.zak_token_return_payload) 131 | self.assertEqual( 132 | self.api.download_recording(self.single_recording_download, b'token'), 133 | '/tmp/random-uid.mp4') 134 | 135 | os.remove('/tmp/random-uid.mp4') 136 | 137 | @responses.activate 138 | def test_delete_meeting_recording(self): 139 | # For downloading recording. 140 | responses.add(responses.GET, self.single_recording_download, status=200, stream=True) 141 | # For sending DELETE http request for 142 | responses.add( 143 | responses.DELETE, 144 | self.single_meeting_recording_info_url, 145 | status=200, 146 | json=self.recording_return_payload) 147 | 148 | self.assertEqual( 149 | self.api.pull_file_from_zoom('some-meeting-id', True), 150 | {'success': False, 151 | 'date': None, 152 | 'filename': None}) 153 | 154 | @responses.activate 155 | def test_delete_chat_transcript(self): 156 | responses.add( 157 | responses.DELETE, 158 | self.single_meeting_recording_info_url, 159 | status=200, 160 | json={'recording_files': [{ 161 | 'file_type': 'CHAT' 162 | }]}) 163 | 164 | self.assertEqual( 165 | self.api.pull_file_from_zoom('some-meeting-id', True), 166 | {'success': False, 167 | 'date': None, 168 | 'filename': None}) 169 | 170 | @responses.activate 171 | def test_handling_zoom_errors_file_pull(self): 172 | responses.add(responses.GET, self.single_meeting_recording_info_url, status=404) 173 | 174 | self.assertEqual( 175 | self.api.pull_file_from_zoom('some-meeting-id', True), 176 | {'success': False, 177 | 'date': None, 178 | 'filename': None}) 179 | 180 | @responses.activate 181 | def test_filesystem_errors_file_pull(self): 182 | responses.add( 183 | responses.GET, 184 | self.single_meeting_recording_info_url, 185 | status=200, 186 | json=self.recording_return_payload) 187 | 188 | self.api.download_recording = MagicMock(side_effect=OSError('Could not write file!')) 189 | self.assertEqual( 190 | self.api.pull_file_from_zoom('some-meeting-id', True), 191 | {'success': False, 192 | 'date': None, 193 | 'filename': None}) 194 | -------------------------------------------------------------------------------- /tests/test_configuration.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | import unittest 17 | import tempfile 18 | import os 19 | from zoom_drive_connector import configuration 20 | 21 | # pylint: disable=relative-beyond-top-level 22 | from unittest_settings import TestSettingsBase 23 | 24 | 25 | class TestBaseClass(unittest.TestCase): 26 | # pylint: disable=invalid-name 27 | def setUp(self): 28 | self.config = {'hello': 'world', 'inner': {'value': 2}, 'exlist': [1, 2], 'exbool': True} 29 | self.base = configuration.APIConfigBase(self.config) 30 | 31 | def test_validation(self): 32 | self.assertTrue(self.base.validate()) 33 | 34 | def test_getattr(self): 35 | self.assertEqual(self.base.hello, 'world') 36 | self.assertEqual(self.base.inner, {'value': 2}) 37 | self.assertEqual(self.base.exlist, [1, 2]) 38 | self.assertTrue(self.base.exbool) 39 | 40 | with self.assertRaises(KeyError): 41 | # pylint: disable=unused-variable 42 | test_value = self.base.test 43 | 44 | with self.assertRaises(KeyError): 45 | # pylint: disable=unused-variable 46 | test_value = self.base.test.inner 47 | 48 | 49 | class TestSlackConfig(TestSettingsBase): 50 | # pylint: disable=invalid-name 51 | def setUp(self): 52 | super(TestSlackConfig, self).setUp() 53 | self.slack = configuration.SlackConfig(self.slack_config) 54 | 55 | def test_validation(self): 56 | self.assertTrue(self.slack.validate()) 57 | 58 | def test_getattr(self): 59 | self.assertEqual(self.slack.key, 'random_key') 60 | with self.assertRaises(KeyError): 61 | # pylint: disable=unused-variable 62 | test_value = self.slack.random_value 63 | 64 | def test_registrar(self): 65 | self.assertTrue(configuration.SlackConfig.factory_registrar('slack')) 66 | self.assertFalse(configuration.SlackConfig.factory_registrar('zoom')) 67 | 68 | 69 | class TestZoomConfig(TestSettingsBase): 70 | # pylint: disable=invalid-name 71 | def setUp(self): 72 | super(TestZoomConfig, self).setUp() 73 | self.zoom = configuration.ZoomConfig(self.zoom_config) 74 | 75 | def test_validation(self): 76 | self.assertTrue(self.zoom.validate()) 77 | 78 | def test_getattr(self): 79 | self.assertEqual(self.zoom.key, 'some_key') 80 | self.assertEqual(self.zoom.username, 'some@email.com') 81 | self.assertTrue(self.zoom.delete) 82 | self.assertEqual(self.zoom.meetings, 83 | [{'id': 'first_id', 'name': 'meeting1', 84 | 'folder_id': 'folder1', 'slack_channel': 'channel-1'}, 85 | {'id': 'second_id', 'name': 'meeting2', 86 | 'folder_id': 'folder2', 'slack_channel': 'channel-2'}] 87 | ) 88 | 89 | with self.assertRaises(KeyError): 90 | # pylint: disable=unused-variable 91 | test_value = self.zoom.random_value 92 | 93 | def test_registrar(self): 94 | self.assertTrue(configuration.ZoomConfig.factory_registrar('zoom')) 95 | self.assertFalse(configuration.ZoomConfig.factory_registrar('drive')) 96 | 97 | 98 | class TestDriveConfig(TestSettingsBase): 99 | # pylint: disable=invalid-name 100 | def setUp(self): 101 | super(TestDriveConfig, self).setUp() 102 | self.drive = configuration.DriveConfig(self.drive_config) 103 | 104 | # Create temporary configuration file for validation testing. 105 | with tempfile.NamedTemporaryFile(delete=False, suffix='.json') as f: 106 | self.secrets_file_name = f.name 107 | 108 | self.drive_config['client_secret_json'] = self.secrets_file_name 109 | 110 | def tearDown(self): 111 | # Remove temporary configuration file. 112 | os.remove(self.secrets_file_name) 113 | 114 | def test_validation(self): 115 | self.assertTrue(self.drive.validate()) 116 | 117 | def test_getattr(self): 118 | self.assertEqual(self.drive.credentials_json, '/tmp/credentials.json') 119 | 120 | with self.assertRaises(KeyError): 121 | # pylint: disable=unused-variable 122 | test_value = self.drive.not_here 123 | 124 | def test_registrar(self): 125 | self.assertTrue(configuration.DriveConfig.factory_registrar('drive')) 126 | self.assertFalse(configuration.DriveConfig.factory_registrar('random')) 127 | 128 | 129 | class TestInternalConfig(TestSettingsBase): 130 | # pylint: disable=invalid-name 131 | def setUp(self): 132 | super(TestInternalConfig, self).setUp() 133 | self.internal = configuration.SystemConfig(self.internal_config) 134 | 135 | def test_validation(self): 136 | self.assertTrue(self.internal.validate()) 137 | 138 | def test_getattr(self): 139 | self.assertEqual(self.internal.target_folder, '/tmp') 140 | 141 | with self.assertRaises(KeyError): 142 | # pylint: disable=unused-variable 143 | test_value = self.internal.random_value 144 | 145 | def test_registrar(self): 146 | self.assertTrue(configuration.SystemConfig.factory_registrar('internals')) 147 | self.assertFalse(configuration.SystemConfig.factory_registrar('not_working')) 148 | 149 | 150 | class TestConfigInterface(unittest.TestCase): 151 | # pylint: disable=invalid-name 152 | def setUp(self): 153 | with tempfile.NamedTemporaryFile(delete=False, suffix='.json') as f: 154 | self.secrets_file_name = f.name 155 | 156 | config_document = ( 157 | 'zoom:\n' 158 | ' key: "zoom_api_key"\n' 159 | ' secret: "zoom_api_secret"\n' 160 | ' username: "email@example.com"\n' 161 | ' delete: true\n' 162 | ' meetings:\n' 163 | ' - {id: "meeting_id" , name: "Meeting Name", folder_id: "folder-id",\ 164 | slack_channel: channel_name"}\n' 165 | ' - {id: "meeting_id2" , name: "Second Meeting Name", folder_id: "folder-id2",\ 166 | slack_channel: channel_name2"}\n' 167 | 'drive:\n' 168 | ' credentials_json: "/tmp/credentials.json"\n' 169 | f' client_secret_json: "{self.secrets_file_name}"\n' 170 | 'slack:\n' 171 | ' key: "slack_api_key"\n' 172 | 'internals:\n' 173 | ' target_folder: /tmp' 174 | ) 175 | 176 | with tempfile.NamedTemporaryFile(delete=False, suffix='.yaml') as f: 177 | enc_doc = config_document.encode('utf-8') 178 | f.write(bytes(enc_doc)) 179 | 180 | self.config_file_name = f.name 181 | 182 | self.valid_interface = configuration.ConfigInterface(self.config_file_name) 183 | 184 | def tearDown(self): 185 | # Remove temporary configuration file. 186 | os.remove(self.config_file_name) 187 | os.remove(self.secrets_file_name) 188 | 189 | def test_nested_getattr(self): 190 | zoom = self.valid_interface.zoom 191 | self.assertEqual(zoom.username, 'email@example.com') 192 | 193 | with self.assertRaises(KeyError): 194 | # pylint: disable=unused-variable 195 | test_value = zoom.email 196 | 197 | def test_getattr(self): 198 | self.assertIsInstance(self.valid_interface.drive, configuration.DriveConfig) 199 | self.assertIsInstance(self.valid_interface.zoom, configuration.ZoomConfig) 200 | self.assertIsInstance(self.valid_interface.slack, configuration.SlackConfig) 201 | self.assertIsInstance(self.valid_interface.internals, configuration.SystemConfig) 202 | 203 | def test_empty_config_file(self): 204 | with tempfile.NamedTemporaryFile(suffix='.yaml') as f: 205 | with self.assertRaises(AttributeError): 206 | configuration.ConfigInterface(f.name) 207 | 208 | def test_bad_config(self): 209 | bad_config_document = """ 210 | zoom: 211 | key: "zoom_api_key" 212 | secret: "zoom_api_secret" 213 | username: "email@example.com" 214 | delete: true 215 | meetings: 216 | - {id: "meeting_id" , name: "Meeting Name", folder_id: "FolderID", slack_channel: "name"} 217 | - {id: "meeting_id2" , name: "Second Meeting Name"} 218 | drive: 219 | credentials_json: "/tmp/credentials.json" 220 | client_secret_json: "/tmp/client_secrets.json" 221 | slack: 222 | key: "slack_api_key" 223 | internals: 224 | target_folder: /tmp 225 | """ 226 | 227 | with tempfile.NamedTemporaryFile(suffix='.yaml') as f: 228 | enc_doc = bad_config_document.encode('utf-8') 229 | f.write(bytes(enc_doc)) 230 | 231 | f.read() # Not sure why this is necessary for this test to pass. Test fails otherwise. 232 | 233 | with self.assertRaises(RuntimeError): 234 | configuration.ConfigInterface(f.name) 235 | -------------------------------------------------------------------------------- /zoom_drive_connector/slack/slack_api.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | import datetime 17 | import enum 18 | import logging 19 | from typing import Any, Dict, TypeVar, cast 20 | 21 | from slack import WebClient 22 | from zoom_drive_connector.configuration import SlackConfig, APIConfigBase 23 | 24 | log = logging.getLogger('app') 25 | S = TypeVar("S", bound=APIConfigBase) 26 | 27 | class SlackMessages(enum.Enum): 28 | RECORDING_AVAILABLE = "The recording is available " 29 | RECORDING_UNAVAILABLE = "The recording is not available." 30 | SUMMARY_AVAILABLE = "The summary is available in the thread." 31 | SUMMARY_UNAVAILABLE = "The summary is not (yet) available." 32 | 33 | 34 | class SlackAPI: 35 | def __init__(self, config: S): 36 | """Class initialization. Stores link config and initializes client with supplied key. 37 | 38 | :param config: Slack configuration object. 39 | """ 40 | self.config = cast(SlackConfig, config) 41 | self.sc = WebClient(self.config.key) 42 | 43 | 44 | def post_message(self, text: str, channel: str): 45 | """Sends message to specific Slack channel with given payload. 46 | 47 | :param text: message to sent to Slack channel. 48 | :param channel: channel name or ID to send `text` to. 49 | :return: None. 50 | """ 51 | self.sc.chat_postMessage(channel=channel, text=text) 52 | log.log(logging.INFO, 'Slack notification sent.') 53 | 54 | 55 | def _create_meeting_message(self, meeting_name, date, file_url, meeting_uuid, summary_set: bool = False): 56 | """Creates a message for a meeting recording. 57 | 58 | :param meeting_name: name of the meeting. 59 | :param date: date of the meeting. 60 | :param file_url: link to the recording. 61 | :return: formatted message. 62 | """ 63 | if file_url: 64 | recording_block = [ 65 | { "type": "text", "text": SlackMessages.RECORDING_AVAILABLE.value }, 66 | { "type": "link", "text": "here.", "url": file_url }, 67 | ] 68 | else: 69 | recording_block = [{"type": "text", "text": SlackMessages.RECORDING_UNAVAILABLE.value}] 70 | 71 | date_str = self._get_date_str(date) 72 | summary = SlackMessages.SUMMARY_AVAILABLE.value if summary_set else SlackMessages.SUMMARY_UNAVAILABLE.value 73 | text = f"The \"{meeting_name}\" meeting, held on {date_str}, has concluded." 74 | return [ 75 | {"type": "section", "block_id": meeting_uuid, "text": { "type": "mrkdwn", "text": text}}, 76 | { 77 | "type": "rich_text", 78 | "elements": [ 79 | { 80 | "type": "rich_text_list", 81 | "style": "bullet", 82 | "elements": [ 83 | { "type": "rich_text_section", "elements": recording_block}, 84 | { "type": "rich_text_section", "elements": [{"type": "text", "text": summary}] } 85 | ] 86 | } 87 | ] 88 | } 89 | ] 90 | 91 | 92 | def _find_previous_top_post(self, channel: str, meeting_uuid: str) -> Dict[str, Any]: 93 | """Finds if there is a previous post in the Slack channel related to this meeting. 94 | 95 | :param channel: channel name or ID to search in. 96 | :param meeting_uuid: unique identifier for the meeting. 97 | :return: dictionary containing post information. 98 | """ 99 | # Only search for messages in the last 4 hours, anything older should not be relevant. 100 | oldest = (datetime.datetime.now() - datetime.timedelta(hours=4)).timestamp() 101 | 102 | for message in self.sc.conversations_history(channel=channel, oldest=oldest).data["messages"]: 103 | if "blocks" not in message or message["blocks"][0].get("block_id") != meeting_uuid: 104 | continue 105 | 106 | # Found an existing block, check if we have a URL and summary 107 | items = message.get("blocks")[1]["elements"][0]["elements"][0]["elements"] 108 | url = items[1]["url"] if len(items) > 1 else None 109 | 110 | items = message.get("blocks")[1]["elements"][0]["elements"][1]["elements"] 111 | has_summary = items[0]["text"] == SlackMessages.SUMMARY_AVAILABLE.value 112 | 113 | return message, url, has_summary 114 | return None, "", False 115 | 116 | 117 | def post_top_message( 118 | self, 119 | channel: str, 120 | name: str, 121 | meeting_date: datetime.datetime, 122 | meeting_uuid: str, 123 | url: str, 124 | has_summary: bool, 125 | ): 126 | """Posts a message related to this meeting. 127 | 128 | :param channel: channel name or ID to send a message to. 129 | :param name: name of the meeting. 130 | :param meeting_date: date of the meeting. 131 | :param meeting_uuid: unique identifier for the meeting. 132 | :param url: URL to the recording. 133 | :param has_summary: if a summary is available. 134 | :return: None. 135 | """ 136 | # Check if there is an existing post for this meeting and see if it has a recording/summary. 137 | existing_post, url2, has_summary2 = self._find_previous_top_post(channel, meeting_uuid) 138 | url = url if url else url2 139 | has_summary = has_summary or has_summary2 140 | 141 | top_msg = self._create_meeting_message(name, meeting_date, url, meeting_uuid, has_summary) 142 | 143 | thread_ts = None 144 | if existing_post: 145 | self.sc.chat_update(channel=channel, ts=existing_post["ts"], blocks=top_msg) 146 | thread_ts = existing_post["ts"] 147 | else: 148 | result = self.sc.chat_postMessage(channel=channel, blocks=top_msg) 149 | thread_ts=result.data["ts"] 150 | 151 | log.log(logging.INFO, f'Slack top-message sent to {channel}.') 152 | return thread_ts 153 | 154 | 155 | def post_recording_message(self, name: str, date: str, url: str, meeting_uuid: str, channel: str): 156 | """Sends message to specific Slack channel with given payload. 157 | 158 | :param text: message to sent to Slack channel. 159 | :param channel: channel name or ID to send `text` to. 160 | :return: None. 161 | """ 162 | self.post_top_message(channel, name, date,meeting_uuid, url, False) 163 | log.log(logging.INFO, 'Slack notification sent for available recording.') 164 | 165 | 166 | def post_summary(self, summary_data: Dict[str, Any], channel: str): 167 | """Posts a summary message to a Slack channel. 168 | 169 | :param summary_data: dictionary containing summary information. 170 | :param channel: channel name or ID to send the message to. 171 | """ 172 | summary = summary_data["summary"] 173 | name = summary_data["meeting"] 174 | blocks = [] 175 | blocks.extend(self._get_summary_header(summary, name)) 176 | blocks.append(self._get_richtext_section("Overview", summary['overview'])) 177 | blocks.extend(self._get_summary_next_steps(summary)) 178 | blocks.append({"type": "divider"}) 179 | blocks.append(self._get_richtext_section("Summary", "")) 180 | blocks.extend(self._get_summary_details(summary)) 181 | 182 | thread_ts = self.post_top_message( 183 | channel, name, summary['date'], summary_data["meeting_uuid"], "", True 184 | ) 185 | self.sc.chat_postMessage(channel=channel, blocks=blocks, thread_ts=thread_ts) 186 | log.log(logging.INFO, f'Slack summary sent to {channel}.') 187 | 188 | 189 | def _get_date_str(self, date: datetime.datetime) -> str: 190 | """Returns a formatted date string. 191 | 192 | :param date: datetime object to format. 193 | :return: formatted date string. 194 | """ 195 | date_time_str = date.strftime('%B %d, %Y at %H:%M') 196 | unix = int(date.replace(tzinfo=datetime.timezone.utc).timestamp()) 197 | return "" 198 | 199 | def _get_summary_header(self, summary: Dict[str, Any], name: str) -> Dict[str, Any]: 200 | """Returns a header block for a Slack message. 201 | 202 | The date is formatted as a Unix timestamp for Slack to convert to the user's local time. 203 | 204 | :param summary: dictionary containing summary information. 205 | :return: dictionary containing header block. 206 | """ 207 | return [ 208 | { 209 | "type": "header", 210 | "text": { 211 | "type": "plain_text", 212 | "text": f"Summary for '{name}' - {summary['date'].strftime('%B %d, %Y')}", 213 | } 214 | }, 215 | { 216 | "type": "section", 217 | "text": { 218 | "type": "mrkdwn", 219 | "text":f"(*{self._get_date_str(summary['date'])}*)" 220 | } 221 | } 222 | ] 223 | 224 | 225 | def _get_summary_details(self, summary: Dict[str, Any]) -> Dict[str, Any]: 226 | """Returns a block for the summary overview. 227 | 228 | :param summary: dictionary containing summary information. 229 | :return: List of summary sections. 230 | """ 231 | return [ 232 | self._get_richtext_section(section["label"], section["summary"]) 233 | for section in summary["details"] 234 | ] 235 | 236 | 237 | def _get_richtext_section(self, title, content) -> Dict[str, Any]: 238 | return { 239 | "type": "section", 240 | "text": { "type": "mrkdwn", "text": f"*{title}*\n{content}" } 241 | } 242 | 243 | def _get_summary_next_steps(self, summary: Dict[str, Any]) -> Dict[str, Any]: 244 | """Returns a block for the next steps to be taken. 245 | 246 | :param summary: dictionary containing summary information. 247 | :return: dictionary containing next steps block. 248 | """ 249 | 250 | next_steps=summary["next_steps"] 251 | if not next_steps: 252 | return [] 253 | 254 | list_items = [ 255 | {"type": "rich_text_section", "elements": [{"type": "text", "text": item}]} 256 | for item in next_steps 257 | ] 258 | elements = [ 259 | { 260 | "type": "rich_text_section", "elements": [ 261 | { "type": "text", "text": "Next Steps", "style": { "bold": True }} 262 | ] 263 | }, 264 | {"type": "rich_text_list", "style": "bullet", "elements": list_items} 265 | ] 266 | 267 | return [ 268 | {"type": "section", "text": { "type": "mrkdwn", "text": "\n" }}, 269 | {"type": "rich_text", "elements": elements } 270 | ] 271 | 272 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zoom-Drive-Connector 2 | 3 | This program downloads meeting recordings from Zoom and uploads them to a 4 | specific folder on Google Drive. 5 | 6 | This software is particularly helpful for archiving recorded meetings and 7 | webinars on Zoom. It can also be used to distribute VODs (videos on demand) to 8 | public folder in Google Drive. This software aims to fill a missing piece of 9 | functionality in Zoom (post-meeting recording sharing). 10 | 11 | 12 | ## December 2024 update 13 | 14 | Support added for Zoom Webhooks. This allows the program to be notified when a new recording is 15 | available. This is a more efficient way to handle new recordings, as the program does not need to 16 | poll the Zoom API every few minutes. The program will now listen for new recordings and upload 17 | them to Google Drive as soon as they are available. 18 | 19 | To use this feature, you need to set up a webhook in Zoom. This can be done by following the 20 | instructions in the Zoom API documentation. You will need to provide the URL of the server where 21 | the program is running, and a secret key that will be used to verify the authenticity of the 22 | webhook requests. The secret key should be added to the `config.yaml` file under the `zoom` section. 23 | 24 | In addition to the download and upload functionality of recordings, the program can also post 25 | the AI generated summary of the meeting to a Slack channel. Note, that the message will be posted 26 | to the main channel and the summary to the thread of the main message. 27 | 28 | To enable this the Slack bot needs the following permissions: 29 | - chat:write 30 | - channels:history 31 | - groups:history 32 | 33 | 34 | 35 | ## Setup 36 | Create a file called `config.yaml` with the following contents: 37 | ```yaml 38 | zoom: 39 | account_id: "Zoom account ID" 40 | client_id: "OAuth client ID" 41 | client_secret: "OAuth client secret" 42 | webhook_secret: "Secret when using the webhook, can be ignored if using the polling method" 43 | delete: true 44 | meetings: 45 | - {id: "meeting_id" , name: "Meeting Name", folder_id: "Some Google Drive Folder ID", slack_channel: "channel_ID"} 46 | - {id: "meeting_id2" , name: "Second Meeting Name", folder_id: "Some Google Drive Folder ID2", slack_channel: "channel_ID2"} 47 | drive: 48 | credentials_json: "conf/credentials.json" 49 | client_secret_json: "conf/client_secrets.json" 50 | slack: 51 | key: "slack_api_key" 52 | internals: 53 | target_folder: "/tmp" 54 | ``` 55 | *Note:* It is advised to place this file in the `conf` folder (together with the json credentials) 56 | this folder needs to be referenced when you launch the Docker container (see below). 57 | 58 | You will need to fill in the example values in the file above. In order to 59 | fill in some of these values you will need to create developer credentials on 60 | several services. Short guides on each service can be found below. 61 | 62 | ### Zoom 63 | https://developers.zoom.us/docs/internal-apps/create/#steps-to-create-a-server-to-server-oauth-app 64 | 65 | For Zoom the Server-to-Server OAuth app is used. You will need to use the following 66 | [guide](https://developer.zoom.us/docs/windows/introduction-and-pre-requisite/). on the Zoom's 67 | developer site. You need to copy the 'account_id', 'client_id' and 'client_secret' variables into 68 | the `config.yaml` file under the `zoom` section. 69 | 70 | ### Google Drive 71 | To upload files to Google Drive you have to login to your developer console, create a new project, 72 | set the required permissions and then download the access key. This can be done via the following 73 | steps: 74 | 75 | 1. Go to the [Google API Console](https://console.developers.google.com/) 76 | 2. Click on "Create new project". 77 | 3. Give it a name and enter any other required info. 78 | 4. Once back on the dashboard click on "Enable APIs and Services" (make sure your newly 79 | created project is selected). 80 | 5. Search for and enable: "Google Drive API". 81 | 6. Go back to the dashboard and click on "Credentials" in the left side bar. 82 | 7. On the credentials screen click on the "Create credentials" button and select "OAuth client ID". 83 | 8. Follow the instructions to set a product name, on the next screen only the `Application name` 84 | is required. Enter it and click on "save". 85 | 9. As application type, select "Other" and fill in the name of your client. 86 | 10. Now under "OAuth 2.0 client IDs" download the JSON file for the newly create client ID 87 | 11. Save this file as `client_secrets.json` in the `conf` directory. 88 | 89 | The `credentials` file will be created during the first start (see below). 90 | 91 | ### Slack 92 | 1. Register a new app using [this link](https://api.slack.com/apps/new). 93 | 2. Under "Add features and functionality" select "Permissions". 94 | 3. Under 'Scopes' select `chat:write:bot` . When using the Webhook & Summary feature you also 95 | need to select `channels:history` and `groups:history`. 96 | 4. On the same page copy the "OAuth Access Token". 97 | Paste that value into the configuration file under the `slack` section. 98 | 5. Put the ID of the Slack channel to post statuses/summaries in the config file. 99 | Note, make sure to use the ID and not the name. You can get the ID by looking at the channel details. 100 | 101 | ## Running the Program in webhook mode 102 | 103 | Note for this we assume Azure Functions. In theory any other (serverless) function can be used. 104 | 105 | To enable the Zoom webhook functionality, you need to set up a webhook in Zoom. This can be done by 106 | following the instructions in the Zoom API documentation. You will need to provide the URL of the 107 | server where the program is running, and a secret key that will be used to verify the authenticity 108 | of the webhook requests. The secret key should be added to the `config.yaml` file under the `zoom` 109 | section. 110 | 111 | Zoom documentation: https://marketplace.zoom.us/docs/api-reference/webhook-reference/ 112 | 113 | To run the program in Azure Functions, you need to create a new function app and add a new function 114 | to it. You can do this by following the instructions in the Azure Functions documentation. 115 | 116 | Azure Functions documentation: https://learn.microsoft.com/en-us/azure/azure-functions/ 117 | 118 | **In short:** 119 | - Create a new function app in the Azure portal. 120 | - Select the 'Flex Consumption' plan. 121 | - Set the app name/region/resource group to your preference. 122 | - Set the runtime stack to 'Python', Version to 3.11 and instance size to 2048 MB. 123 | - Create a new storage account using the default settings. 124 | - Everything else can be left as default. 125 | - After deployment add the `Secrets & Configuration` as described below. 126 | - Deploy the app using your favorite method (e.g. VSCode extension, Azure CLI, etc.) 127 | 128 | 129 | 130 | **Secrets & Configuration:** 131 | - If you don't have a 'credentials.json' and 'client_secrets.json' file, you can create them by 132 | following the instructions in the Google Drive section above. 133 | 134 | - The `config.yaml` file should be stored in the Environment Variables of the function app. 135 | - Environment variable: `ZOOM_DRIVE_SLACK_CONFIG` 136 | - The content of the 'credentials.json' should be stored in the 137 | Environment Variables of the function app. 138 | - Environment variable: `GOOGLE_APPLICATION_CREDENTIALS` 139 | - The Zoom Webhook token should be stored in the Environment Variables of the function app. 140 | - Environment variable: `ZOOM_WEBHOOK_TOKEN` 141 | - The content of the files should be encoded as base64 strings. You can use the following command 142 | to encode the content of a file as a base64 string: 143 | ```bash 144 | base64 -w 0 /path/to/file 145 | ``` 146 | 147 | 148 | 149 | ## Running the Program in polling mode 150 | The first time we run the program we have to authenticate it with Google and accept the required 151 | permissions. For this we run the docker container in the interactive mode such that we 152 | can enter the generated token. Instructions on how to pull Docker images from the Github 153 | registry can be found 154 | [here](https://help.github.com/en/articles/configuring-docker-for-use-with-github-package-registry#authenticating-to-github-package-registry). 155 | 156 | ```bash 157 | $ docker pull docker.pkg.github.com/minds-ai/zoom-drive-connector/zoom-drive-connector:1.2.0 158 | $ docker run -i -v /path/to/conf/directory:/conf \ 159 | docker.pkg.github.com/minds-ai/zoom-drive-connector/zoom-drive-connector:1.2.0 160 | ``` 161 | 162 | Alternatively, you can clone the repository and run `make build VERSION=1.2.0` to build 163 | the container locally. In this case, you will need to exchange the long image name 164 | with the short-form one (`zoom-drive-connector:1.2.0`). 165 | 166 | This will print an URL, this URL should be copied in the browser. After accepting the 167 | permissions you will be presented with a token. This token should be pasted in the 168 | terminal. After the token has been accepted a `credentials.json` file will have been 169 | created in your configuration folder. You can now kill (`ctrl-C`) the Docker container 170 | and follow the steps below to run it in the background. 171 | 172 | Run the following command to start the container after finishing the setup process. 173 | ```bash 174 | $ docker run -d -v /path/to/conf/directory:/conf \ 175 | docker.pkg.github.com/minds-ai/zoom-drive-connector/zoom-drive-connector:1.2.0 176 | ``` 177 | 178 | ## Making Changes to Source 179 | If you wish to make changes to the program source, you can quickly create a 180 | Conda environment using the provided `environment.yml` file. Use the following 181 | commands to get started. 182 | ```bash 183 | $ conda env create -f environment.yml 184 | $ source activate zoom-drive-connector 185 | ``` 186 | 187 | Any changes to dependencies should be recorded in **both** `environment.yml` and 188 | `requirements.txt` with the exception of development dependencies, which 189 | only have to be placed in `environment.yml`. Make sure to record the version of the 190 | package you are adding using the double-equal operator. 191 | 192 | To run the program in the conda environment you can use the following command line: 193 | ```bash 194 | CONFIG=conf/config.yaml python -u main.py --noauth_local_webserver 195 | ``` 196 | 197 | ### Running Tests and Style Checks 198 | All new functionality should have accompanying unit tests. Look at the `tests/` 199 | folder for examples. All tests should be written using the `unittests` framework. 200 | Additionally, ensure that your code conforms with the given style configurations 201 | presented in this repository. 202 | 203 | To run tests and style checks, run the following commands: 204 | ```bash 205 | $ tox -p all --recreate 206 | $ ./style_checks.sh # Run this in the root of the repository. 207 | $ # You can also check style for a single file by passing the name of the file. 208 | ``` 209 | 210 | ## Contributing 211 | This project is open to public contributions. Fork the project, make any changes you 212 | want, and submit a pull request. Your changes will be reviewed before they are merged 213 | into master. 214 | 215 | ## Authors 216 | - [Nick Pleatsikas](https://github.com/MrFlynn) 217 | - [Jeroen Bédorf](https://github.com/jbedorf) 218 | 219 | ## License 220 | This project is licensed under [Apache 2.0](LICENSE). 221 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Zoom-drive-connector 2 | Copyright 2018 Minds.ai, Inc. All Rights Reserved. 3 | 4 | Apache License 5 | Version 2.0, January 2004 6 | http://www.apache.org/licenses/ 7 | 8 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 9 | 10 | 1. Definitions. 11 | 12 | "License" shall mean the terms and conditions for use, reproduction, 13 | and distribution as defined by Sections 1 through 9 of this document. 14 | 15 | "Licensor" shall mean the copyright owner or entity authorized by 16 | the copyright owner that is granting the License. 17 | 18 | "Legal Entity" shall mean the union of the acting entity and all 19 | other entities that control, are controlled by, or are under common 20 | control with that entity. For the purposes of this definition, 21 | "control" means (i) the power, direct or indirect, to cause the 22 | direction or management of such entity, whether by contract or 23 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 24 | outstanding shares, or (iii) beneficial ownership of such entity. 25 | 26 | "You" (or "Your") shall mean an individual or Legal Entity 27 | exercising permissions granted by this License. 28 | 29 | "Source" form shall mean the preferred form for making modifications, 30 | including but not limited to software source code, documentation 31 | source, and configuration files. 32 | 33 | "Object" form shall mean any form resulting from mechanical 34 | transformation or translation of a Source form, including but 35 | not limited to compiled object code, generated documentation, 36 | and conversions to other media types. 37 | 38 | "Work" shall mean the work of authorship, whether in Source or 39 | Object form, made available under the License, as indicated by a 40 | copyright notice that is included in or attached to the work 41 | (an example is provided in the Appendix below). 42 | 43 | "Derivative Works" shall mean any work, whether in Source or Object 44 | form, that is based on (or derived from) the Work and for which the 45 | editorial revisions, annotations, elaborations, or other modifications 46 | represent, as a whole, an original work of authorship. For the purposes 47 | of this License, Derivative Works shall not include works that remain 48 | separable from, or merely link (or bind by name) to the interfaces of, 49 | the Work and Derivative Works thereof. 50 | 51 | "Contribution" shall mean any work of authorship, including 52 | the original version of the Work and any modifications or additions 53 | to that Work or Derivative Works thereof, that is intentionally 54 | submitted to Licensor for inclusion in the Work by the copyright owner 55 | or by an individual or Legal Entity authorized to submit on behalf of 56 | the copyright owner. For the purposes of this definition, "submitted" 57 | means any form of electronic, verbal, or written communication sent 58 | to the Licensor or its representatives, including but not limited to 59 | communication on electronic mailing lists, source code control systems, 60 | and issue tracking systems that are managed by, or on behalf of, the 61 | Licensor for the purpose of discussing and improving the Work, but 62 | excluding communication that is conspicuously marked or otherwise 63 | designated in writing by the copyright owner as "Not a Contribution." 64 | 65 | "Contributor" shall mean Licensor and any individual or Legal Entity 66 | on behalf of whom a Contribution has been received by Licensor and 67 | subsequently incorporated within the Work. 68 | 69 | 2. Grant of Copyright License. Subject to the terms and conditions of 70 | this License, each Contributor hereby grants to You a perpetual, 71 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 72 | copyright license to reproduce, prepare Derivative Works of, 73 | publicly display, publicly perform, sublicense, and distribute the 74 | Work and such Derivative Works in Source or Object form. 75 | 76 | 3. Grant of Patent License. Subject to the terms and conditions of 77 | this License, each Contributor hereby grants to You a perpetual, 78 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 79 | (except as stated in this section) patent license to make, have made, 80 | use, offer to sell, sell, import, and otherwise transfer the Work, 81 | where such license applies only to those patent claims licensable 82 | by such Contributor that are necessarily infringed by their 83 | Contribution(s) alone or by combination of their Contribution(s) 84 | with the Work to which such Contribution(s) was submitted. If You 85 | institute patent litigation against any entity (including a 86 | cross-claim or counterclaim in a lawsuit) alleging that the Work 87 | or a Contribution incorporated within the Work constitutes direct 88 | or contributory patent infringement, then any patent licenses 89 | granted to You under this License for that Work shall terminate 90 | as of the date such litigation is filed. 91 | 92 | 4. Redistribution. You may reproduce and distribute copies of the 93 | Work or Derivative Works thereof in any medium, with or without 94 | modifications, and in Source or Object form, provided that You 95 | meet the following conditions: 96 | 97 | (a) You must give any other recipients of the Work or 98 | Derivative Works a copy of this License; and 99 | 100 | (b) You must cause any modified files to carry prominent notices 101 | stating that You changed the files; and 102 | 103 | (c) You must retain, in the Source form of any Derivative Works 104 | that You distribute, all copyright, patent, trademark, and 105 | attribution notices from the Source form of the Work, 106 | excluding those notices that do not pertain to any part of 107 | the Derivative Works; and 108 | 109 | (d) If the Work includes a "NOTICE" text file as part of its 110 | distribution, then any Derivative Works that You distribute must 111 | include a readable copy of the attribution notices contained 112 | within such NOTICE file, excluding those notices that do not 113 | pertain to any part of the Derivative Works, in at least one 114 | of the following places: within a NOTICE text file distributed 115 | as part of the Derivative Works; within the Source form or 116 | documentation, if provided along with the Derivative Works; or, 117 | within a display generated by the Derivative Works, if and 118 | wherever such third-party notices normally appear. The contents 119 | of the NOTICE file are for informational purposes only and 120 | do not modify the License. You may add Your own attribution 121 | notices within Derivative Works that You distribute, alongside 122 | or as an addendum to the NOTICE text from the Work, provided 123 | that such additional attribution notices cannot be construed 124 | as modifying the License. 125 | 126 | You may add Your own copyright statement to Your modifications and 127 | may provide additional or different license terms and conditions 128 | for use, reproduction, or distribution of Your modifications, or 129 | for any such Derivative Works as a whole, provided Your use, 130 | reproduction, and distribution of the Work otherwise complies with 131 | the conditions stated in this License. 132 | 133 | 5. Submission of Contributions. Unless You explicitly state otherwise, 134 | any Contribution intentionally submitted for inclusion in the Work 135 | by You to the Licensor shall be under the terms and conditions of 136 | this License, without any additional terms or conditions. 137 | Notwithstanding the above, nothing herein shall supersede or modify 138 | the terms of any separate license agreement you may have executed 139 | with Licensor regarding such Contributions. 140 | 141 | 6. Trademarks. This License does not grant permission to use the trade 142 | names, trademarks, service marks, or product names of the Licensor, 143 | except as required for reasonable and customary use in describing the 144 | origin of the Work and reproducing the content of the NOTICE file. 145 | 146 | 7. Disclaimer of Warranty. Unless required by applicable law or 147 | agreed to in writing, Licensor provides the Work (and each 148 | Contributor provides its Contributions) on an "AS IS" BASIS, 149 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 150 | implied, including, without limitation, any warranties or conditions 151 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 152 | PARTICULAR PURPOSE. You are solely responsible for determining the 153 | appropriateness of using or redistributing the Work and assume any 154 | risks associated with Your exercise of permissions under this License. 155 | 156 | 8. Limitation of Liability. In no event and under no legal theory, 157 | whether in tort (including negligence), contract, or otherwise, 158 | unless required by applicable law (such as deliberate and grossly 159 | negligent acts) or agreed to in writing, shall any Contributor be 160 | liable to You for damages, including any direct, indirect, special, 161 | incidental, or consequential damages of any character arising as a 162 | result of this License or out of the use or inability to use the 163 | Work (including but not limited to damages for loss of goodwill, 164 | work stoppage, computer failure or malfunction, or any and all 165 | other commercial damages or losses), even if such Contributor 166 | has been advised of the possibility of such damages. 167 | 168 | 9. Accepting Warranty or Additional Liability. While redistributing 169 | the Work or Derivative Works thereof, You may choose to offer, 170 | and charge a fee for, acceptance of support, warranty, indemnity, 171 | or other liability obligations and/or rights consistent with this 172 | License. However, in accepting such obligations, You may act only 173 | on Your own behalf and on Your sole responsibility, not on behalf 174 | of any other Contributor, and only if You agree to indemnify, 175 | defend, and hold each Contributor harmless for any liability 176 | incurred by, or claims asserted against, such Contributor by reason 177 | of your accepting any such warranty or additional liability. 178 | 179 | END OF TERMS AND CONDITIONS 180 | 181 | APPENDIX: How to apply the Apache License to your work. 182 | 183 | To apply the Apache License to your work, attach the following 184 | boilerplate notice, with the fields enclosed by brackets "[]" 185 | replaced with your own identifying information. (Don't include 186 | the brackets!) The text should be enclosed in the appropriate 187 | comment syntax for the file format. We also recommend that a 188 | file or class name and description of purpose be included on the 189 | same "printed page" as the copyright notice for easier 190 | identification within third-party archives. 191 | 192 | Copyright 2018 Minds.ai, Inc. 193 | 194 | Licensed under the Apache License, Version 2.0 (the "License"); 195 | you may not use this file except in compliance with the License. 196 | You may obtain a copy of the License at 197 | 198 | http://www.apache.org/licenses/LICENSE-2.0 199 | 200 | Unless required by applicable law or agreed to in writing, software 201 | distributed under the License is distributed on an "AS IS" BASIS, 202 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 203 | See the License for the specific language governing permissions and 204 | limitations under the License. 205 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Profiled execution. 11 | profile=no 12 | 13 | # Add files or directories to the blacklist. They should be base names, not 14 | # paths. 15 | ignore=CVS 16 | 17 | # Pickle collected data for later comparisons. 18 | persistent=yes 19 | 20 | # List of plugins (as comma separated values of python modules names) to load, 21 | # usually to register additional checkers. 22 | load-plugins= 23 | 24 | 25 | [MESSAGES CONTROL] 26 | 27 | # Enable the message, report, category or checker with the given id(s). You can 28 | # either give multiple identifier separated by comma (,) or put this option 29 | # multiple time. See also the "--disable" option for examples. 30 | enable=indexing-exception,old-raise-syntax 31 | 32 | # Disable the message, report, category or checker with the given id(s). You 33 | # can either give multiple identifiers separated by comma (,) or put this 34 | # option multiple times (only on the command line, not in the configuration 35 | # file where it should appear only once).You can also use "--disable=all" to 36 | # disable everything first and then reenable specific checks. For example, if 37 | # you want to run only the similarities checker, you can use "--disable=all 38 | # --enable=similarities". If you want to run only the classes checker, but have 39 | # no Warning level messages displayed, use"--disable=all --enable=classes 40 | # --disable=W" 41 | # 42 | #disable=design,similarities,no-self-use,attribute-defined-outside-init,locally-disabled,star-args,pointless-except,bad-option-value,global-statement,fixme,suppressed-message,useless-suppression,locally-enabled,no-member,no-name-in-module,import-error,unsubscriptable-object,unbalanced-tuple-unpacking,undefined-variable,not-context-manager 43 | disable=bad-continuation,missing-docstring,no-self-use,attribute-defined-outside-init,locally-disabled,no-name-in-module,redefined-outer-name,too-few-public-methods,not-context-manager,import-error,logging-format-interpolation,logging-fstring-interpolation 44 | 45 | 46 | # Set the cache size for astng objects. 47 | cache-size=500 48 | 49 | 50 | [REPORTS] 51 | 52 | # Set the output format. Available formats are text, parseable, colorized, msvs 53 | # (visual studio) and html. You can also give a reporter class, eg 54 | # mypackage.mymodule.MyReporterClass. 55 | output-format=text 56 | 57 | # Put messages in a separate file for each module / package specified on the 58 | # command line instead of printing them on stdout. Reports (if any) will be 59 | # written in a file name "pylint_global.[txt|html]". 60 | files-output=no 61 | 62 | # Tells whether to display a full report or only the messages 63 | reports=no 64 | 65 | # Python expression which should return a note less than 10 (10 is the highest 66 | # note). You have access to the variables errors warning, statement which 67 | # respectively contain the number of errors / warnings messages and the total 68 | # number of statements analyzed. This is used by the global evaluation report 69 | # (RP0004). 70 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 71 | 72 | # Add a comment according to your evaluation note. This is used by the global 73 | # evaluation report (RP0004). 74 | comment=no 75 | 76 | # Template used to display messages. This is a python new-style format string 77 | # used to format the message information. See doc for all details 78 | #msg-template= 79 | 80 | 81 | [TYPECHECK] 82 | 83 | # Tells whether missing members accessed in mixin class should be ignored. A 84 | # mixin class is detected if its name ends with "mixin" (case insensitive). 85 | ignore-mixin-members=yes 86 | 87 | # List of classes names for which member attributes should not be checked 88 | # (useful for classes with attributes dynamically set). 89 | ignored-classes=SQLObject,scoped_session,numpy.random,bokeh.palettes,Example,Network,PurePath,ConfigProto,ExceptionInfo 90 | 91 | 92 | # List of module names for which member attributes should not be checked 93 | # (useful for modules/projects where namespaces are manipulated during runtime 94 | # and thus existing member attributes cannot be deduced by static analysis. It 95 | # supports qualified module names, as well as Unix pattern matching. 96 | ignored-modules=flask_sqlalchemy,numpy,ibm_db,bokeh,tensorflow 97 | 98 | # When zope mode is activated, add a predefined set of Zope acquired attributes 99 | # to generated-members. 100 | zope=no 101 | 102 | 103 | # List of members which are set dynamically and missed by pylint inference 104 | # system, and so shouldn't trigger E0201 when accessed. Python regular 105 | # expressions are accepted. 106 | generated-members=REQUEST,acl_users,aq_parent,gpu_options,FULL_TRACE,add_summary,step_stats,name,sh 107 | 108 | # List of decorators that create context managers from functions, such as 109 | # contextlib.contextmanager. 110 | contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager 111 | 112 | 113 | [VARIABLES] 114 | 115 | # Tells whether we should check for unused import in __init__ files. 116 | init-import=no 117 | 118 | # A regular expression matching the beginning of the name of dummy variables 119 | # (i.e. not used). 120 | dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) 121 | 122 | # List of additional names supposed to be defined in builtins. Remember that 123 | # you should avoid to define new builtins when possible. 124 | additional-builtins= 125 | 126 | 127 | [BASIC] 128 | 129 | # Required attributes for module, separated by a comma 130 | required-attributes= 131 | 132 | # List of builtins function names that should not be used, separated by a comma 133 | bad-functions=apply,input,reduce 134 | 135 | 136 | # Disable the report(s) with the given id(s). 137 | # All non-Google reports are disabled by default. 138 | disable-report=R0001,R0002,R0003,R0004,R0101,R0102,R0201,R0202,R0220,R0401,R0402,R0701,R0801,R0901,R0902,R0903,R0904,R0911,R0912,R0913,R0914,R0915,R0921,R0922,R0923 139 | 140 | # Regular expression which should only match correct module names 141 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 142 | 143 | # Regular expression which should only match correct module level names 144 | const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ 145 | 146 | # Regular expression which should only match correct class names 147 | class-rgx=^_?[A-Z][a-zA-Z0-9]*$ 148 | 149 | # Regular expression which should only match correct function names 150 | function-rgx=^(?:(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$ 151 | 152 | # Regular expression which should only match correct method names 153 | method-rgx=^(?:(?P__[a-z0-9_]+__|next)|(?P_{0,2}[A-Z][a-zA-Z0-9]*)|(?P_{0,2}[a-z][a-z0-9_]*))$ 154 | 155 | # Regular expression which should only match correct instance attribute names 156 | attr-rgx=^_{0,2}[a-z][a-z0-9_]*$ 157 | 158 | # Regular expression which should only match correct argument names 159 | argument-rgx=^[a-z][a-z0-9_]*$ 160 | 161 | # Regular expression which should only match correct variable names 162 | variable-rgx=^[a-z][a-z0-9_]*$ 163 | 164 | # Regular expression which should only match correct attribute names in class 165 | # bodies 166 | class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ 167 | 168 | # Regular expression which should only match correct list comprehension / 169 | # generator expression variable names 170 | inlinevar-rgx=^[a-z][a-z0-9_]*$ 171 | 172 | # Good variable names which should always be accepted, separated by a comma 173 | good-names=main,_ 174 | 175 | # Bad variable names which should always be refused, separated by a comma 176 | bad-names= 177 | 178 | # Regular expression which should only match function or class names that do 179 | # not require a docstring. 180 | no-docstring-rgx=(__.*__|main) 181 | 182 | # Minimum line length for functions/classes that require docstrings, shorter 183 | # ones are exempt. 184 | docstring-min-length=10 185 | 186 | 187 | [FORMAT] 188 | 189 | # Maximum number of characters on a single line. 190 | max-line-length=100 191 | 192 | # Regexp for a line that is allowed to be longer than the limit. 193 | ignore-long-lines=^\s*(# )??$ 194 | 195 | # Allow the body of an if to be on the same line as the test if there is no 196 | # else. 197 | single-line-if-stmt=n 198 | 199 | # List of optional constructs for which whitespace checking is disabled 200 | no-space-check= 201 | 202 | # Maximum number of lines in a module 203 | max-module-lines=99999 204 | 205 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 206 | # tab). 207 | indent-string=' ' 208 | 209 | 210 | [SIMILARITIES] 211 | 212 | # Minimum lines number of a similarity. 213 | min-similarity-lines=40 214 | 215 | # Ignore comments when computing similarities. 216 | ignore-comments=yes 217 | 218 | # Ignore docstrings when computing similarities. 219 | ignore-docstrings=yes 220 | 221 | # Ignore imports when computing similarities. 222 | ignore-imports=no 223 | 224 | 225 | [MISCELLANEOUS] 226 | 227 | # List of note tags to take in consideration, separated by a comma. 228 | notes=TODO 229 | 230 | 231 | [IMPORTS] 232 | 233 | # Deprecated modules which should not be used, separated by a comma 234 | deprecated-modules=regsub,TERMIOS,Bastion,rexec,sets 235 | 236 | # Create a graph of every (i.e. internal and external) dependencies in the 237 | # given file (report RP0402 must not be disabled) 238 | import-graph= 239 | 240 | # Create a graph of external dependencies in the given file (report RP0402 must 241 | # not be disabled) 242 | ext-import-graph= 243 | 244 | # Create a graph of internal dependencies in the given file (report RP0402 must 245 | # not be disabled) 246 | int-import-graph= 247 | 248 | 249 | [CLASSES] 250 | 251 | # List of interface methods to ignore, separated by a comma. This is used for 252 | # instance to not check methods defines in Zope's Interface base class. 253 | ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by 254 | 255 | # List of method names used to declare (i.e. assign) instance attributes. 256 | defining-attr-methods=__init__,__new__,setUp 257 | 258 | # List of valid names for the first argument in a class method. 259 | valid-classmethod-first-arg=cls,class_ 260 | 261 | # List of valid names for the first argument in a metaclass class method. 262 | valid-metaclass-classmethod-first-arg=mcs 263 | 264 | 265 | [DESIGN] 266 | 267 | # Maximum number of arguments for function / method 268 | max-args=12 269 | 270 | # Argument names that match this expression will be ignored. Default to name 271 | # with leading underscore 272 | ignored-argument-names=_.* 273 | 274 | # Maximum number of locals for function / method body 275 | max-locals=15 276 | 277 | # Maximum number of return / yield for function / method body 278 | max-returns=6 279 | 280 | # Maximum number of branch for function / method body 281 | max-branches=12 282 | 283 | # Maximum number of statements in function / method body 284 | max-statements=50 285 | 286 | # Maximum number of parents for a class (see R0901). 287 | max-parents=7 288 | 289 | # Maximum number of attributes for a class (see R0902). 290 | max-attributes=7 291 | 292 | # Minimum number of public methods for a class (see R0903). 293 | min-public-methods=2 294 | 295 | # Maximum number of public methods for a class (see R0904). 296 | max-public-methods=20 297 | 298 | 299 | [EXCEPTIONS] 300 | 301 | # Exceptions that will emit a warning when being caught. Defaults to 302 | # "Exception" 303 | overgeneral-exceptions=Exception,StandardError,BaseException,Error 304 | 305 | 306 | [AST] 307 | 308 | # Maximum line length for lambdas 309 | short-func-length=1 310 | 311 | # List of module members that should be marked as deprecated. 312 | # All of the string functions are listed in 4.1.4 Deprecated string functions 313 | # in the Python 2.4 docs. 314 | deprecated-members=string.atof,string.atoi,string.atol,string.capitalize,string.expandtabs,string.find,string.rfind,string.index,string.rindex,string.count,string.lower,string.split,string.rsplit,string.splitfields,string.join,string.joinfields,string.lstrip,string.rstrip,string.strip,string.swapcase,string.translate,string.upper,string.ljust,string.rjust,string.center,string.zfill,string.replace,sys.exitfunc 315 | 316 | 317 | [DOCSTRING] 318 | 319 | # List of exceptions that do not need to be mentioned in the Raises section of 320 | # a docstring. 321 | ignore-exceptions=AssertionError,NotImplementedError,StopIteration,TypeError 322 | 323 | 324 | 325 | [TOKENS] 326 | 327 | # Number of spaces of indent required when the last token on the preceding line 328 | # is an open (, [, or {. 329 | indent-after-paren=4 330 | 331 | 332 | -------------------------------------------------------------------------------- /zoom_drive_connector/zoom/zoom_api.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Minds.ai, Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | # ============================================================================== 15 | 16 | import datetime 17 | from enum import Enum 18 | import os 19 | import hmac 20 | import hashlib 21 | import shutil 22 | import logging 23 | from typing import List, TypeVar, cast, Dict, Any 24 | 25 | import requests 26 | from requests.auth import HTTPBasicAuth 27 | 28 | from zoom_drive_connector.configuration import APIConfigBase, ZoomConfig, SystemConfig 29 | 30 | from .zoom_api_exception import ZoomAPIException 31 | 32 | log = logging.getLogger('app') 33 | S = TypeVar("S", bound=APIConfigBase) 34 | 35 | 36 | class ZoomURLS(Enum): 37 | recordings = 'https://api.zoom.us/v2/meetings/{id}/recordings' 38 | zak_token = 'https://api.zoom.us/v2/users/{user}/token?type=zak' 39 | delete_recordings = 'https://api.zoom.us/v2/meetings/{id}/recordings/{rid}' 40 | signin = 'https://api.zoom.us/signin' 41 | oauth_token = 'https://zoom.us/oauth/token' 42 | 43 | 44 | 45 | 46 | # Create a class to handle the Zoom Webhook 47 | class ZoomWebhook: 48 | def __init__(self, token: str): 49 | self.secret_token = token 50 | 51 | def verify_headers(self, headers: Dict, body: str) -> bool: 52 | """Verify the headers of the request, to validate the request is from Zoom.""" 53 | if "x-zm-signature" not in headers or "x-zm-request-timestamp" not in headers: 54 | print("Invalid Zoom Headers, missing items. Content: ") 55 | for key, value in headers.items(): 56 | print(f" {key} = {value}") 57 | return False 58 | 59 | timestamp = headers["x-zm-request-timestamp"] 60 | 61 | 62 | hash_to_verify = hmac.new( 63 | self.secret_token.encode("utf-8"), 64 | f"v0:{timestamp}:{body}".encode("utf-8"), 65 | hashlib.sha256, 66 | ).hexdigest() 67 | signature = f"v0={hash_to_verify}" 68 | return hmac.compare_digest(signature, headers["x-zm-signature"]) 69 | 70 | def handle_validation_event(self, body: dict): 71 | """Handle the validation of the Zoom Webhook URL.""" 72 | secret_token = self.secret_token.encode("utf-8") 73 | plain_token = body["payload"]["plainToken"] 74 | message = plain_token.encode("utf-8") 75 | hashed = hmac.new(secret_token, message, hashlib.sha256).digest() 76 | return {"plainToken": plain_token, "encryptedToken": hashed.hex()} 77 | 78 | 79 | 80 | 81 | class ZoomAPI: 82 | def __init__(self, zoom_config: S, sys_config: S): 83 | """Class initialization; sets client key, secret, and download folder path. 84 | 85 | :param zoom_config: configuration class containing all relevant parameters for Zoom API. 86 | :param sys_config: configuration class containing target folder where to download contains. 87 | """ 88 | self.zoom_config = cast(ZoomConfig, zoom_config) 89 | self.sys_config = cast(SystemConfig, sys_config) 90 | 91 | self.timeout = 1800 # Default expiration time is 30 minutes. 92 | 93 | # Clarified HTTP status messages 94 | self.message = { 95 | 401: 'Not authenticated.', 96 | 404: 'File not found or no recordings', 97 | 409: 'File deleted already.' 98 | } 99 | 100 | def generate_server_to_server_oath_token(self) -> bytes: 101 | """Generates the OATH token used for authenticating with Zoom. 102 | 103 | Sends OATH information and receives a token to use for the next hour. 104 | """ 105 | data = { 106 | "grant_type" : "account_credentials", 107 | "account_id" : self.zoom_config.account_id 108 | } 109 | headers = { 110 | 'content-type': 'application/x-www-form-urlencoded' 111 | } 112 | res = requests.post( 113 | ZoomURLS.oauth_token.value, 114 | headers=headers, 115 | params=data, 116 | auth=HTTPBasicAuth(self.zoom_config.client_id, self.zoom_config.client_secret) 117 | ) 118 | if res.status_code != 200: 119 | raise ValueError("Failed to authenticate, error: ", res.json()) 120 | return res.json()["access_token"] 121 | 122 | def delete_recording(self, meeting_id: str, recording_id: str, auth: bytes): 123 | """Given a specific meeting room ID and recording ID, this function moves the recording to the 124 | trash in Zoom's cloud. 125 | 126 | :param meeting_id: UUID associated with a meeting room. 127 | :param recording_id: The ID of the recording to trash. 128 | :param auth: OAUTH token. 129 | """ 130 | zoom_url = str(ZoomURLS.delete_recordings.value).format(id=meeting_id, rid=recording_id) 131 | headers = { 132 | 'authorization': 'Bearer ' + auth, 133 | 'content-type': 'application/json' 134 | } 135 | # trash, not delete 136 | res = requests.delete(zoom_url, headers=headers, params={'action': 'trash'}) 137 | log.log(logging.INFO, f'Deleting recording {recording_id} for meeting {meeting_id}...') 138 | status_code = res.status_code 139 | if 400 <= status_code <= 499: 140 | raise ZoomAPIException(status_code, res.reason, res.request, self.message.get( 141 | status_code, '')) 142 | 143 | def get_recording_url(self, meeting_id: str, auth: str) -> Dict[str, Any]: 144 | """Given a specific meeting room ID and auth token, this function gets the download url 145 | for most recent recording in the given meeting room. 146 | 147 | :param meeting_id: UUID associated with a meeting room. 148 | :param auth: Authorization token 149 | :return: dict containing the date of the recording, the ID of the recording, and the video url. 150 | """ 151 | zoom_url = str(ZoomURLS.recordings.value).format(id=meeting_id) 152 | 153 | try: 154 | headers = { 155 | 'authorization': 'Bearer ' + auth, 156 | 'content-type': 'application/json' 157 | } 158 | zoom_request = requests.get(zoom_url, headers=headers) 159 | except requests.exceptions.RequestException as e: 160 | # Failed to make a connection so let's just return a 404, as there is no file 161 | # but print an additional warning in case it was a configuration error 162 | log.log(logging.ERROR, e) 163 | raise ZoomAPIException(404, 'File Not Found', None, 'Could not connect') 164 | 165 | status_code = zoom_request.status_code 166 | if 200 <= status_code <= 299: 167 | log.log(logging.DEBUG, zoom_request.json()) 168 | for req in zoom_request.json()['recording_files']: 169 | # TODO(jbedorf): For now just delete the chat messages and continue processing other files. 170 | if req['file_type'] == 'CHAT': 171 | self.delete_recording(req['meeting_id'], req['id'], auth) 172 | elif req['file_type'] == 'TRANSCRIPT': 173 | self.delete_recording(req['meeting_id'], req['id'], auth) 174 | elif req['file_type'] == 'MP4': 175 | date = datetime.datetime.strptime(req['recording_start'], '%Y-%m-%dT%H:%M:%SZ') 176 | return { 177 | 'date': date, 178 | 'id': req['id'], 179 | 'url': req['download_url'], 180 | 'meeting_id': req['meeting_id'], 181 | } 182 | # Raise 404 when we do not recognize the file type. 183 | raise ZoomAPIException(404, 'File Not Found', zoom_request.request, # pylint: no-else-raise 184 | 'File not found or no recordings') 185 | elif 300 <= status_code <= 599: 186 | raise ZoomAPIException(status_code, zoom_request.reason, zoom_request.request, 187 | self.message.get(status_code, '')) 188 | else: 189 | raise ZoomAPIException(status_code, zoom_request.reason, zoom_request.request, '') 190 | 191 | def download_recording(self, url: str, auth: str, filename: str="") -> str: 192 | """Downloads video file from Zoom to local folder. 193 | 194 | :param url: Download URL for meeting recording. 195 | :param auth: Authorization token. 196 | :return: Path to the recording 197 | """ 198 | headers = { 199 | 'authorization': 'Bearer ' + auth, 200 | 'content-type': 'application/json' 201 | } 202 | zoom_request = requests.get(url, stream=True, headers=headers) 203 | 204 | filename = filename or url.split('/')[-1] 205 | outfile = os.path.join(str(self.sys_config.target_folder), filename + '.mp4') 206 | with open(outfile, 'wb') as source: 207 | shutil.copyfileobj(zoom_request.raw, source) # Copy raw file data to local file. 208 | 209 | return outfile 210 | 211 | def download_webhook_recording(self, url: str, download_token: str, filename: str="") -> str: 212 | """Downloads video file from Zoom to local folder. 213 | 214 | :param url: Download URL for meeting recording. 215 | :param auth: Authorization token. 216 | :return: Path to the recording 217 | """ 218 | url = f"{url}?access_token={download_token}" 219 | zoom_request = requests.get(url, stream=True) 220 | 221 | outfile = os.path.join(str(self.sys_config.target_folder), filename) 222 | with open(outfile, 'wb') as source: 223 | shutil.copyfileobj(zoom_request.raw, source) # Copy raw file data to local file. 224 | 225 | return outfile 226 | 227 | 228 | def pull_file_from_zoom(self, meeting_id: str, rm: bool = True) -> Dict[str, Any]: 229 | """Interface for downloading recordings from Zoom. Optionally trashes recorded file on Zoom. 230 | Returns a dictionary containing success state and/or recording information. 231 | 232 | :param meeting_id: UUID for meeting room where recording was just completed. 233 | :param rm: If true is passed (default) then file is trashed on Zoom. 234 | :return: dict containing if the operation was successful. If downloading and (optionally) 235 | deleting the recording on Zoom completed successfully, include the recording date and the 236 | recording filename. 237 | """ 238 | result = {'success': False, 'date': None, 'filename': None} 239 | try: 240 | log.log(logging.INFO, f'Found recording for meeting {meeting_id} starting download...') 241 | # Generate token and Authorization header. 242 | zoom_token = self.generate_server_to_server_oath_token() 243 | 244 | # Get URL and download the file. 245 | res = self.get_recording_url(meeting_id, zoom_token) 246 | filename = self.download_recording(res['url'], zoom_token) 247 | 248 | if rm: 249 | self.delete_recording(res['meeting_id'], res['id'], zoom_token) 250 | log.log(logging.INFO, f'File {filename} downloaded for meeting {meeting_id}.') 251 | return {'success': True, 'date': res['date'], 'filename': filename} 252 | except ZoomAPIException as ze: 253 | if ze.http_method and ze.http_method == 'DELETE': 254 | log.log(logging.INFO, ze) 255 | # Allow other systems to proceed if delete fails. 256 | result['success'] = True 257 | return result 258 | log.log(logging.ERROR, ze) 259 | return result 260 | except OSError as fe: 261 | # Catches general filesystem errors. If download could not be written to disk, stop. 262 | log.log(logging.ERROR, fe) 263 | return result 264 | 265 | 266 | def webhook_summary_message(self, zoom_body: Dict) -> Dict[str, Any]: 267 | log.info("Processing webhook for completed summary.") 268 | 269 | result = {'success': False, 'summary': None, 'slack_channel': None, 'meeting': None} 270 | 271 | meeting_id = zoom_body["payload"]["object"]["meeting_id"] 272 | 273 | 274 | meeting_config = next( 275 | (meeting for meeting in self.zoom_config.meetings if meeting['id'] == str(meeting_id)), None 276 | ) 277 | if not meeting_config: 278 | log.log(logging.ERROR, f"Meeting {meeting_id} not found in configuration.") 279 | return result 280 | 281 | result["slack_channel"] = meeting_config["slack_channel"] 282 | result["meeting"] = meeting_config["name"] 283 | result["meeting_uuid"] = zoom_body["payload"]["object"]["meeting_uuid"] 284 | result["success"] = True 285 | 286 | info = zoom_body["payload"]["object"] 287 | 288 | result["summary"] = { 289 | "date": datetime.datetime.strptime(info["meeting_start_time"], '%Y-%m-%dT%H:%M:%SZ'), 290 | "title": info.get("summary_title", "Summary"), 291 | "overview": info.get("summary_overview", "No overview provided."), 292 | "details": info.get("summary_details", []), 293 | "next_steps": info.get("next_steps", []), 294 | } 295 | log.log(logging.INFO, f"Meeting {meeting_id} summary processed and returned.") 296 | return result 297 | 298 | 299 | def webhook_recording_complete(self, zoom_body: Dict, types_to_download: List) -> Dict[str, Any]: 300 | 301 | log.info("Processing webhook for completed recording.") 302 | 303 | result = {'success': False, 'date': [], 'filename': []} 304 | 305 | meeting_id = zoom_body["payload"]["object"]["id"] 306 | recording_files = zoom_body["payload"]["object"]["recording_files"] 307 | 308 | log.info(f"Meeting ID: {meeting_id}") 309 | 310 | meeting_config = next( 311 | (meeting for meeting in self.zoom_config.meetings if meeting['id'] == str(meeting_id)), None 312 | ) 313 | if not meeting_config: 314 | log.log(logging.ERROR, f"Meeting {meeting_id} not found in configuration.") 315 | return result 316 | 317 | result["folder_id"] = meeting_config["folder_id"] 318 | result["slack_channel"] = meeting_config["slack_channel"] 319 | result["meeting"] = meeting_config["name"] 320 | result['meeting_uuid'] = zoom_body["payload"]["object"]["uuid"] 321 | 322 | meeting_config: Dict = None 323 | for meeting in self.zoom_config.meetings: 324 | if meeting['id'] == str(meeting_id): 325 | meeting_config = meeting 326 | if not meeting_config: 327 | log.log(logging.ERROR, f"Meeting {meeting_id} not found in configuration.") 328 | return result 329 | 330 | 331 | try: 332 | log.log(logging.INFO, f'Recording triggered for meeting {meeting_id} starting download...') 333 | # Generate token and Authorization header. 334 | zoom_token = self.generate_server_to_server_oath_token() 335 | 336 | for file_info in recording_files: 337 | if file_info["file_type"].upper() in types_to_download: 338 | date = datetime.datetime.strptime(file_info['recording_start'], '%Y-%m-%dT%H:%M:%SZ') 339 | tmp_name = f"{meeting_config['name']}-{date}.{file_info['file_extension'].lower()}" 340 | 341 | 342 | filename = self.download_webhook_recording( 343 | file_info["download_url"], 344 | zoom_body["download_token"], 345 | tmp_name, 346 | ) 347 | log.log(logging.INFO, f'File {filename} downloaded for meeting {meeting_id}.') 348 | result['filename'].append(filename) 349 | result['date'].append(date) 350 | else: 351 | log.log(logging.INFO, f'Skipping file-type: {file_info["file_type"]}') 352 | 353 | if self.zoom_config.delete: 354 | self.delete_recording(meeting_id, file_info['id'], zoom_token) 355 | result['success'] = True 356 | return result 357 | 358 | except ZoomAPIException as ze: 359 | if ze.http_method and ze.http_method == 'DELETE': 360 | log.log(logging.INFO, ze) 361 | # Allow other systems to proceed if delete fails. 362 | result['success'] = True 363 | return result 364 | log.log(logging.ERROR, ze) 365 | return result 366 | except OSError as fe: 367 | # Catches general filesystem errors. If download could not be written to disk, stop. 368 | log.log(logging.ERROR, fe) 369 | return result 370 | --------------------------------------------------------------------------------