diff --git a/.github/workflows/rotate-seminars.yml b/.github/workflows/rotate-seminars.yml new file mode 100644 index 0000000..a57056b --- /dev/null +++ b/.github/workflows/rotate-seminars.yml @@ -0,0 +1,30 @@ +name: Hourly speakers' corner updates + +on: + schedule: + - cron: '55 * * * *' + + +jobs: + rotate: + runs-on: ubuntu-latest + name: Deploy + + steps: + - name: checkout + uses: actions/checkout@v2 + + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install and build + run: | + python -m pip install --upgrade pip + pip install requirements.txt + python host_key_rotation.py + env: + ZOOM_API_KEY: ${{ secrets.ZOOM_API_KEY }} + ZOOM_API_SECRET: ${{ secrets.ZOOM_API_SECRET }} + HOST_KEY_SALT: ${{ secrets.HOST_KEY_SALT }} diff --git a/common.py b/common.py new file mode 100644 index 0000000..2800a16 --- /dev/null +++ b/common.py @@ -0,0 +1,73 @@ +from functools import lru_cache +import os +from time import time + +import jwt +import requests + +ZOOM_API = "https://api.zoom.us/v2/" +SPEAKERS_CORNER_USER_ID = "D0n5UNEHQiajWtgdWLlNSA" + +@lru_cache() +def zoom_headers(duration: int=100) -> dict: + zoom_api_key = os.getenv("ZOOM_API_KEY") + zoom_api_secret = os.getenv("ZOOM_API_SECRET") + token = jwt.encode( + # Create a payload of the token containing API Key & expiration time + {"iss": zoom_api_key, "exp": time() + duration}, + zoom_api_secret, + algorithm='HS256' + ).decode('utf-8') + + return {'authorization': f'Bearer {token}', 'content-type': 'application/json'} + + +def zoom_request(method: callable, *args, **kwargs): + """A minimal wrapper around requests for querying zoom API with error handling""" + response = method(*args, **kwargs, headers=zoom_headers()) + if response.status_code > 299: + raise RuntimeError(response.content.decode()) + + if response.content: + return response.json() + + +def speakers_corner_user_id() -> str: + users = zoom_request(requests.get, ZOOM_API + "users")["users"] + sc_user_id = next( + u["id"] for u in users + if u["first_name"] == "Speakers'" and u["last_name"] == "Corner" + ) + return sc_user_id + + +def all_meetings(user_id) -> list: + """Return all meetings by a user. + + Handles pagination, and adds ``live: True`` to a meeting that is running (if any). + """ + meetings = [] + next_page_token = "" + while True: + meetings_page = zoom_request( + requests.get, + f"{ZOOM_API}users/{user_id}/meetings", + params={"type": "scheduled", "page_size": 300, "next_page_token": next_page_token} + ) + meetings += meetings_page["meetings"] + next_page_token = meetings_page["next_page_token"] + if not next_page_token: + break + + live_meetings = zoom_request( + requests.get, + f"{ZOOM_API}users/{user_id}/meetings", + params={"type": "scheduled", "page_size": 300, "next_page_token": next_page_token} + )["meetings"] + + if live_meetings: + for meeting in meetings: + if meeting["id"] == live_meetings[0]["id"]: + meeting["live"] = True + + return meetings diff --git a/host_key_rotation.py b/host_key_rotation.py new file mode 100644 index 0000000..913f202 --- /dev/null +++ b/host_key_rotation.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python + +import os +import hashlib +import datetime +import json + +import requests +import pytz +from dateutil.parser import parse + +import common +from common import zoom_request + + +def host_key(timeslot: datetime.datetime) -> int: + """Generate a host key for a specified time.""" + key_salt = os.getenv("HOST_KEY_SALT").encode() + timestamp = timeslot.replace(second=0, microsecond=0, minute=0).timestamp() + hashed = hashlib.sha512(int(timestamp.to_bytes(5, "big")) + key_salt) + return f"{int(hashed.hexdigest(), 16) % int(1e7):06}" + + +def update_host_key(): + """Update the host key of the speakers' corner user for the upcoming hour.""" + zoom_request( + requests.patch, + common.ZOOM_API + "users/" + common.SPEAKERS_CORNER_USER_ID, + data=json.dumps({ + "host_key": host_key(datetime.datetime.now() + datetime.timedelta(hours=1)) + }) + ) + + +def rotate_meetings(): + """Update the Speakers' corner meeting settings and statuses. + + 1. If there is an upcoming meeting in less than an hour, allow joining + before host. + 2. Stop the running meeting if there is an upcoming one or if it runs for too long. + 3. Disable joining before host on recent meetings to prevent restarting. + """ + now = datetime.datetime.now(tz=pytz.UTC) + sc_meetings = common.all_meetings(common.SPEAKERS_CORNER_USER_ID) + for m in sc_meetings: + m["start_time"] = parse(m["start_time"]) + + live = [m for m in sc_meetings if m["live"]] + + try: + upcoming = min( + (m for m in sc_meetings if m["start_time"] > now), + key=(lambda meeting: meeting["start_time"]) + ) + upcoming_start = upcoming["start_time"] + except ValueError: + upcoming = None + upcoming_start = now + datetime.timedelta(weeks=1) + + recent = [ + m for m in sc_meetings + if (now > m["start_time"] > now - datetime.timedelta(hours=2)) + and not m["live"] + ] + + starting_soon = upcoming_start - now < datetime.timedelta(hours=1) + if starting_soon: + common.zoom_request( + requests.patch, + f"{common.ZOOM_API}meetings/{upcoming['id']}", + data=json.dumps({"settings": {"join_before_host": True}}), + ) + + if ( + live + and ( + starting_soon + or live[0]["start_time"] < now - datetime.timedelta(minutes=90) + ) + ): + live_id = live[0]["id"] + common.zoom_request( + requests.put, + f"{common.ZOOM_API}meetings/{live_id}/status", + data=json.dumps({"action": "end"}), + ) + common.zoom_request( + requests.patch, + f"{common.ZOOM_API}meetings/{live_id}", + data=json.dumps({"settings": {"join_before_host": False}}), + ) + + for meeting in recent: + common.zoom_request( + requests.patch, + f"{common.ZOOM_API}meetings/{meeting['id']}", + data=json.dumps({"settings": {"join_before_host": False}}), + ) + +if __name__ == "__main__": + update_host_key() + rotate_meetings() \ No newline at end of file diff --git a/requrements.txt b/requrements.txt new file mode 100644 index 0000000..8adfe46 --- /dev/null +++ b/requrements.txt @@ -0,0 +1,3 @@ +requests +pytz +dateutil