Skip to content

Commit 3fa929b

Browse files
authored
Merge pull request #17 from virtualscienceforum/cron-zoom-updates
Implement host key rotation and starting/stopping meetings
2 parents e373538 + 5a5bbd3 commit 3fa929b

File tree

4 files changed

+208
-0
lines changed

4 files changed

+208
-0
lines changed
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Hourly speakers' corner updates
2+
3+
on:
4+
schedule:
5+
- cron: '55 * * * *'
6+
7+
8+
jobs:
9+
rotate:
10+
runs-on: ubuntu-latest
11+
name: Deploy
12+
13+
steps:
14+
- name: checkout
15+
uses: actions/checkout@v2
16+
17+
- name: Set up Python 3.8
18+
uses: actions/setup-python@v2
19+
with:
20+
python-version: 3.8
21+
22+
- name: Install and build
23+
run: |
24+
python -m pip install --upgrade pip
25+
pip install requirements.txt
26+
python host_key_rotation.py
27+
env:
28+
ZOOM_API_KEY: ${{ secrets.ZOOM_API_KEY }}
29+
ZOOM_API_SECRET: ${{ secrets.ZOOM_API_SECRET }}
30+
HOST_KEY_SALT: ${{ secrets.HOST_KEY_SALT }}

common.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
from functools import lru_cache
2+
import os
3+
from time import time
4+
5+
import jwt
6+
import requests
7+
8+
ZOOM_API = "https://api.zoom.us/v2/"
9+
SPEAKERS_CORNER_USER_ID = "D0n5UNEHQiajWtgdWLlNSA"
10+
11+
@lru_cache()
12+
def zoom_headers(duration: int=100) -> dict:
13+
zoom_api_key = os.getenv("ZOOM_API_KEY")
14+
zoom_api_secret = os.getenv("ZOOM_API_SECRET")
15+
token = jwt.encode(
16+
# Create a payload of the token containing API Key & expiration time
17+
{"iss": zoom_api_key, "exp": time() + duration},
18+
zoom_api_secret,
19+
algorithm='HS256'
20+
).decode('utf-8')
21+
22+
return {'authorization': f'Bearer {token}', 'content-type': 'application/json'}
23+
24+
25+
def zoom_request(method: callable, *args, **kwargs):
26+
"""A minimal wrapper around requests for querying zoom API with error handling"""
27+
response = method(*args, **kwargs, headers=zoom_headers())
28+
if response.status_code > 299:
29+
raise RuntimeError(response.content.decode())
30+
31+
if response.content:
32+
return response.json()
33+
34+
35+
def speakers_corner_user_id() -> str:
36+
users = zoom_request(requests.get, ZOOM_API + "users")["users"]
37+
sc_user_id = next(
38+
u["id"] for u in users
39+
if u["first_name"] == "Speakers'" and u["last_name"] == "Corner"
40+
)
41+
return sc_user_id
42+
43+
44+
def all_meetings(user_id) -> list:
45+
"""Return all meetings by a user.
46+
47+
Handles pagination, and adds ``live: True`` to a meeting that is running (if any).
48+
"""
49+
meetings = []
50+
next_page_token = ""
51+
while True:
52+
meetings_page = zoom_request(
53+
requests.get,
54+
f"{ZOOM_API}users/{user_id}/meetings",
55+
params={"type": "scheduled", "page_size": 300, "next_page_token": next_page_token}
56+
)
57+
meetings += meetings_page["meetings"]
58+
next_page_token = meetings_page["next_page_token"]
59+
if not next_page_token:
60+
break
61+
62+
live_meetings = zoom_request(
63+
requests.get,
64+
f"{ZOOM_API}users/{user_id}/meetings",
65+
params={"type": "scheduled", "page_size": 300, "next_page_token": next_page_token}
66+
)["meetings"]
67+
68+
if live_meetings:
69+
for meeting in meetings:
70+
if meeting["id"] == live_meetings[0]["id"]:
71+
meeting["live"] = True
72+
73+
return meetings

host_key_rotation.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
#!/usr/bin/env python
2+
3+
import os
4+
import hashlib
5+
import datetime
6+
import json
7+
8+
import requests
9+
import pytz
10+
from dateutil.parser import parse
11+
12+
import common
13+
from common import zoom_request
14+
15+
16+
def host_key(timeslot: datetime.datetime) -> int:
17+
"""Generate a host key for a specified time."""
18+
key_salt = os.getenv("HOST_KEY_SALT").encode()
19+
timestamp = timeslot.replace(second=0, microsecond=0, minute=0).timestamp()
20+
hashed = hashlib.sha512(int(timestamp.to_bytes(5, "big")) + key_salt)
21+
return f"{int(hashed.hexdigest(), 16) % int(1e7):06}"
22+
23+
24+
def update_host_key():
25+
"""Update the host key of the speakers' corner user for the upcoming hour."""
26+
zoom_request(
27+
requests.patch,
28+
common.ZOOM_API + "users/" + common.SPEAKERS_CORNER_USER_ID,
29+
data=json.dumps({
30+
"host_key": host_key(datetime.datetime.now() + datetime.timedelta(hours=1))
31+
})
32+
)
33+
34+
35+
def rotate_meetings():
36+
"""Update the Speakers' corner meeting settings and statuses.
37+
38+
1. If there is an upcoming meeting in less than an hour, allow joining
39+
before host.
40+
2. Stop the running meeting if there is an upcoming one or if it runs for too long.
41+
3. Disable joining before host on recent meetings to prevent restarting.
42+
"""
43+
now = datetime.datetime.now(tz=pytz.UTC)
44+
sc_meetings = common.all_meetings(common.SPEAKERS_CORNER_USER_ID)
45+
for m in sc_meetings:
46+
m["start_time"] = parse(m["start_time"])
47+
48+
live = [m for m in sc_meetings if m["live"]]
49+
50+
try:
51+
upcoming = min(
52+
(m for m in sc_meetings if m["start_time"] > now),
53+
key=(lambda meeting: meeting["start_time"])
54+
)
55+
upcoming_start = upcoming["start_time"]
56+
except ValueError:
57+
upcoming = None
58+
upcoming_start = now + datetime.timedelta(weeks=1)
59+
60+
recent = [
61+
m for m in sc_meetings
62+
if (now > m["start_time"] > now - datetime.timedelta(hours=2))
63+
and not m["live"]
64+
]
65+
66+
starting_soon = upcoming_start - now < datetime.timedelta(hours=1)
67+
if starting_soon:
68+
common.zoom_request(
69+
requests.patch,
70+
f"{common.ZOOM_API}meetings/{upcoming['id']}",
71+
data=json.dumps({"settings": {"join_before_host": True}}),
72+
)
73+
74+
if (
75+
live
76+
and (
77+
starting_soon
78+
or live[0]["start_time"] < now - datetime.timedelta(minutes=90)
79+
)
80+
):
81+
live_id = live[0]["id"]
82+
common.zoom_request(
83+
requests.put,
84+
f"{common.ZOOM_API}meetings/{live_id}/status",
85+
data=json.dumps({"action": "end"}),
86+
)
87+
common.zoom_request(
88+
requests.patch,
89+
f"{common.ZOOM_API}meetings/{live_id}",
90+
data=json.dumps({"settings": {"join_before_host": False}}),
91+
)
92+
93+
for meeting in recent:
94+
common.zoom_request(
95+
requests.patch,
96+
f"{common.ZOOM_API}meetings/{meeting['id']}",
97+
data=json.dumps({"settings": {"join_before_host": False}}),
98+
)
99+
100+
if __name__ == "__main__":
101+
update_host_key()
102+
rotate_meetings()

requrements.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
requests
2+
pytz
3+
dateutil

0 commit comments

Comments
 (0)