diff --git a/.github/workflows/schedule_zoom_talks.yml b/.github/workflows/schedule_zoom_talks.yml new file mode 100644 index 0000000..22ec87c --- /dev/null +++ b/.github/workflows/schedule_zoom_talks.yml @@ -0,0 +1,30 @@ +name: Schedule Zoom Talks +on: + repository_dispatch: + types: [schedule-zoom-talk] + +jobs: + scheduleTalks: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Schedule talks + working-directory: ./bots + run: python3 schedulezoomtalks.py + env: + VSF_BOT_TOKEN: ${{ secrets.VSF_BOT_TOKEN }} + ZOOM_API_KEY: ${{ secrets.ZOOM_API_KEY }} + ZOOM_API_SECRET: ${{ secrets.ZOOM_API_SECRET }} + MAILGUN_API_KEY: ${{ secrets.MAILGUN_API_KEY }} diff --git a/common.py b/common.py index e1f5f5b..9743451 100644 --- a/common.py +++ b/common.py @@ -1,12 +1,17 @@ from functools import lru_cache import os from time import time +import json import jwt import requests ZOOM_API = "https://api.zoom.us/v2/" SPEAKERS_CORNER_USER_ID = "D0n5UNEHQiajWtgdWLlNSA" +TALKS_FILE = "speakers_corner_talks.yml" + +MAILGUN_BASE_URL = "https://api.eu.mailgun.net/v3/" +MAILGUN_DOMAIN = "mail.virtualscienceforum.org/" @lru_cache() def zoom_headers(duration: int=100) -> dict: @@ -31,7 +36,6 @@ def zoom_request(method: callable, *args, **kwargs): 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( @@ -43,7 +47,7 @@ def speakers_corner_user_id() -> str: 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 = [] @@ -58,7 +62,7 @@ def all_meetings(user_id) -> list: 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", @@ -69,5 +73,19 @@ def all_meetings(user_id) -> list: for meeting in meetings: if meeting["id"] == live_meetings[0]["id"]: meeting["live"] = True - + return meetings + + +def decode(response): + if response.status_code > 299: # Not OK + raise RuntimeError(response.content.decode()) + return json.loads(response.content.decode()) + + +def api_query(method, endpoint, **params): + return decode(method( + MAILGUN_BASE_URL + endpoint, + auth=("api", os.getenv("MAILGUN_API_KEY")), + **params + )) diff --git a/requirements.txt b/requirements.txt index 8d5a09d..91160df 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ requests pytz python-dateutil -PyJWT \ No newline at end of file +PyJWT +PyGithub +PyYAML diff --git a/researchseminarsdotorg.py b/researchseminarsdotorg.py new file mode 100644 index 0000000..b19fa84 --- /dev/null +++ b/researchseminarsdotorg.py @@ -0,0 +1,109 @@ +import os +from requests import get, post + +SPEAKERS_CORNER_SEMINAR_SERIES = {"series_id": "speakerscorner", + "name": "Speakers\' Corner", + "is_conference": False, + "topics": [""], # TODO: Get a list of topics + "language": "en", + "institutions": [""], + "timezone": "UTC", + "homepage": "https://virtualscienceforum.org/speakerscorner.md", + "visibility": 1, # 0=private, 1=unlisted, 2=public + "access_control": 0, # 0=open, see schema for more + "slots": [""], + "organizers": [{"name": "Virtual Science Forum", + "email": "vsf@virtualscienceforum.org", + "homepage": "https://virtualscienceforum.org", + "organizer": True, + "order": 0, + "display": True}]} + + +def find_seminar_series(series_id): + url = f"https://researchseminars.org/api/0/search/series?series_id={series_id}" + r = get(url) + if r.status_code == 200: + J = r.json() + results = J["properties"]["results"] + return (len(results) != 0) + + +def create_seminar_series(payload, authorization): + url = "https://researchseminars.org/api/0/save/series/" + r = post(url, json=payload, headers={"authorization":authorization}) + J = r.json() + code = J.get("code") + + if r.status_code == 200: + if code == "warning": + return True, J["warnings"] + else: + return True, "" + else: + return False, "" + + +def edit_seminar_series(name, payload, authorization): + url = "https://researchseminars.org/api/0/save/series/" + r = post(url, json=payload, headers={"authorization":authorization}) + J = r.json() + code = J.get("code") + + if r.status_code == 200: + if code == "warning": + return True, J["warnings"] + else: + return True, "" + else: + return False, "" + + +def add_talk_to_series(series_id, payload, authorization): + url = "https://researchseminars.org/api/0/save/talk/" + r = post(url, json=payload, headers={"authorization":authorization}) + J = r.json() + code = J.get("code") + if r.status_code == 200: + if code == "warning": + return J["series_ctr"], J["warnings"] + else: + return J["series_ctr"] + else: + return "", r.status_code + + +def publish_to_researchseminars(talk): + # talk should be provided in yaml format + api_token = os.getenv("RESEARCHSEMINARS_API_TOKEN") + authorization = "vsf@virtualscienceforum.org %s" % api_token + + # Find speakers' corner series, and create it if it doesn't exist + if not find_seminar_series("speakerscorner"): + print("[ResearchSeminars.org]: The speakerscorner seminar series "\ + "does not yet exist. Creating it now") + create_seminar_series(SPEAKERS_CORNER_SEMINAR_SERIES) + + # TODO: Figure out if we need to edit the series; + # Would be annoying since edits have to be approved + + # Set up payload for talk creation + talk_payload = {"title":talk.get('title'), + "speaker":talk.get('author'), # TODO: will be 'speakerS' + "online": True, + "start_time":talk["time"], + "timezone":"UTC" + } + + # Make request to remote API + series_ctr, warnings = add_talk_to_series("speakerscorner", talk_payload, authorization) + + if series_ctr != "": + print("Talk with id {0} successfully added".format(series_ctr)) + if warnings != "": + print("Warnings: {0}".format(warnings)) + return True + else: + print("-- ERROR -- ") + print("Could not add talk to series, status code {0}".format(warnings)) + return False diff --git a/schedulezoomtalks.py b/schedulezoomtalks.py new file mode 100644 index 0000000..2cfcb84 --- /dev/null +++ b/schedulezoomtalks.py @@ -0,0 +1,269 @@ +import os +import secrets +from io import StringIO +from typing import Tuple +import datetime + +import github +import requests +from ruamel.yaml import YAML +import jinja2 +import pytz + +import common +from researchseminarsdotorg import publish_to_researchseminars +from host_key_rotation import host_key + + +ISSUE_RESPONSE_TEMPLATE = jinja2.Template( +"""I've now created a Zoom meeting for your talk, with meeting ID + {{ meeting_id }}. You'll receive a separate email with a host key. +""") + +EMAIL_TEMPLATE = jinja2.Template( +"""Hi {{ author }}, + +A Zoom meeting has now been scheduled for your Speakers' Corner talk. +Five minutes before your timeslot starts, you and your audience will be +able to join the meeting. You will then be able to claim the host role by +using the host key below. After an hour the meeting will automatically +be terminated. Once the recording finishes processing, you will get the +opportunity to cut out parts of it. + +Your meeting information: +Talk title: {{ meeting_talk_title }} +Date: {{ meeting_date }} +Time slot: {{ meeting_start }} - {{ meeting_end }} + +Zoom link: {{ meeting_zoom_link }} +Host key: {{ meeting_host_key }} + +Thank you in advance for contributing to the Speakers' Corner! +- The VSF team +""") + + +def schedule_zoom_talk(talk) -> Tuple[str, str]: + + # Form the talk registration body + request_body = { + "topic": "Speakers\' corner talk by %s"%(talk.get("name")), + "type": 2, # Scheduled meeting + "start_time": talk["time"], + "timezone": "UTC", + "duration": 60, + "schedule_for": common.SPEAKERS_CORNER_USER_ID, + + # Generate a password for the meeting. This is required since + # otherwise the meeting room will be forced. Zoom limits the + # password length to max 10 characters. + "password": secrets.token_urlsafe(10), + + # Meeting settings + "settings": { + "host_video": True, + "participant_video": False, + "cn_meeting": False, # Host the meeting in China? + "in_meeting": False, # Host the meeting in India? + + # This will be switched to True shortly before the meeting starts + # by the VSF bot. It will also be switched back to False afterwards + "join_before_host": False, + "mute_upon_entry": True, + "watermark": False, # Don't add a watermark when screensharing + "use_pmi": False, # Don't use Personal Meeting ID, but generate one + "approval_type": 0, # Automatically approve + "close_registration" : True, # Close registration after event date + "waiting_room" : False, # No waiting room + "audio": "both", + "auto_recording": "cloud", + "enforce_login": False, + "alternative_hosts": "", + + # Email notifications are turned off when created, so that we can + # register the speaker without them receiving an invitation to + # their own talk. They will receive a separate email with info. + # This will be turned on with a PATCH once the speaker is registered. + "registrants_email_notification": False, + "contact_email": "vsf@virtualscienceforum.org", + }, + } + + # Create the meeting + response = common.zoom_request( + requests.post, + f"{common.ZOOM_API}users/{common.SPEAKERS_CORNER_USER_ID}/meetings", + params={"body":request_body} + ) + + meeting_id = response["id"] + + register_speaker(meeting_id, talk) + patch_registration_questions(meeting_id) + patch_registration_notification(meeting_id) + + return meeting_id, response["registration_url"] + + +def register_speaker(meeting_id, talk) -> int: + request_payload = { + "email": talk["email"], + "first_name": talk["speaker_name"], + } + + # Send request + response = common.zoom_request( + requests.post, + f"{common.ZOOM_API}users/{common.SPEAKERS_CORNER_USER_ID}/meetings/{meeting_id}/registrants", + params={"body": request_payload} + ) + + return response.status + + +def patch_registration_questions(meeting_id) -> int: + + request_body = { + "questions": [ + {"field_name": "First Name", "required": True}, + {"field_name": "Last Name", "required": True}, + {"field_name": "Email Address", "required": True}, + {"field_name": "Confirm Email Address", "required": True}, + {"field_name": "Organization", "required": True}, + ], + + "custom_questions": [ + { + "title": "May we contact you about future Virtual Science Forum events?", + "type": "single", # short or single + "answers": ["Yes", "No"], # only single + "required": True + }, + { + "title": "How did you hear about the Virtual Science Forum?", + "type": "single", # short or single + "answers": ["Email list", + "One of the organizers", + "A colleague (not an organizer)", + "Other"], + "required": True + }, + { + "title": "Please confirm you have read the participant instructions: \ + http://virtualscienceforum.org/#/attendeeguide*", + "type": "short", # short or single + "required": True + }, + ] + } + + response = common.zoom_request( + requests.patch, + f"{common.ZOOM_API}users/{common.SPEAKERS_CORNER_USER_ID}/meetings/{meeting_id}/registrants/questions", + params={"body":request_body} + ) + + return response.status + +def patch_registration_notification(meeting_id) -> int: + + # Form the talk registration body + request_body = { + "settings": { + "registrants_email_notification": True, + }, + } + + # Create the meeting + response = common.zoom_request( + requests.patch, + f"{common.ZOOM_API}users/{common.SPEAKERS_CORNER_USER_ID}/meetings/{meeting_id}", + params={"body":request_body} + ) + + return response.status + +def notify_issue_about_zoom_meeting(repo, talk): + issue_comment = ISSUE_RESPONSE_TEMPLATE.render(meeting_id=talk["meeting_id"]) + + issue = repo.get_issue(number=talk["workflow_issue"]) + issue.create_comment(issue_comment) + + +def notify_author(talk, join_url) -> str: + # Get the host key + meeting_host_key = host_key(talk["time"]) + + # Format the email body + meeting_start = datetime.datetime(talk["time"], tz=pytz.UTC) + meeting_end = meeting_start + datetime.timedelta(hours=1) + email_text = EMAIL_TEMPLATE.render(author=talk["speaker_name"], + meeting_zoom_link=join_url, + meeting_host_key=meeting_host_key, + meeting_talk_title=talk["title"], + meeting_date=meeting_start.date, + meeting_start=meeting_start.time, + meeting_end=meeting_end.time) + + data = { + "from": "Speakers' Corner ", + "to": "{0} <{1}>".format(talk["speaker_name"], talk["email"]), + "subject": "Speakers' Corner talk", + "text": email_text, + "html": email_text, + } + + return common.api_query( + requests.post, + f"{common.MAILGUN_DOMAIN}messages", + data=data + ) + +def schedule_talks(repo, talks) -> int: + num_updated = 0 + for talk in talks: + # If we are not processing a speakers corner talk, or if the + # zoom meeting id has already been set, there's nothing left to do + if "zoom_meeting_id" in talk or talk["event_type"] != "speakers_corner": + continue + + meeting_id, join_url = schedule_zoom_talk(talk) + if meeting_id: + talk["zoom_meeting_id"] = meeting_id + # Add this talk to researchseminars.org + # publish_to_researchseminars(talk) + # Create comment in issue + notify_issue_about_zoom_meeting(repo, talk) + # Email the author + notify_author(talk, join_url) + + num_updated += 1 + + return num_updated + +if __name__ == "__main__": + # Get a handle on the repository + gh = github.Github(os.getenv("VSF_BOT_TOKEN")) + repo = gh.get_repo("virtualscienceforum/virtualscienceforum") + + # Read the talks file + yaml = YAML() + try: + talks_data = repo.get_contents(common.TALKS_FILE, ref="test_zoom_meeting_registering_workflow") + talks = yaml.load(StringIO(talks_data.decoded_content.decode())) + except github.UnknownObjectException: + talks_data = None + talks = [] + + # If we added Zoom links, we should update the file in the repo + if (num_updated := schedule_talks(repo, talks) ): + serialized = StringIO() + yaml.dump(talks, serialized) + + repo.update_file( + common.TALKS_FILE, f"Added Zoom link{1} for {0} scheduled speakers\'"\ + "corner talk{1}".format(num_updated,'' if num_updated == 1 else 's'), + serialized.getvalue(), + sha=talks_data.sha, + branch='test_zoom_meeting_registering_workflow' + )