Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .github/workflows/rotate-seminars.yml
Original file line number Diff line number Diff line change
@@ -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 }}
73 changes: 73 additions & 0 deletions common.py
Original file line number Diff line number Diff line change
@@ -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
102 changes: 102 additions & 0 deletions host_key_rotation.py
Original file line number Diff line number Diff line change
@@ -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()
3 changes: 3 additions & 0 deletions requrements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
requests
pytz
dateutil