#!/usr/bin/python3 # A mini-app for accepting Zoom webhooks import configparser import json import psycopg2 import psycopg2.extras import traceback import dateutil.parser ### Configuration ### config = configparser.ConfigParser() config.read('zoom.ini') ### Database connection ### db_connection = None db = None def db_connect(): global db_connection, db db_connection = psycopg2.connect( host='localhost', user=config['db']['user'], password=config['db']['passwd'], dbname=config['db']['name'], ) db = db_connection.cursor(cursor_factory=psycopg2.extras.NamedTupleCursor) def db_query(query, args=()): if db is None: db_connect() try: db.execute(query, args) except psycopg2.DatabaseError: # Reconnect if the connection died (timeout, server restart etc.) db_connect() db.execute(query, args) ### Utilities ### # FIXME: Move to shared code? def parse_time(iso_time): return dateutil.parser.isoparse(iso_time) ### Application ### class HookApp: def __init__(self, env, start_response): self.env = env self.wsgi_start = start_response def log(self, msg): print(msg, file=self.env['wsgi.errors'], flush=True) def http_error(self, code, msg, extra_headers=[]): self.wsgi_start("{} {}".format(code, msg), extra_headers + [ ('Content-Type', 'text/plain') ]) return ["{} {}".format(code, msg)] def create_regular_meeting(self, uid, meeting): meeting_id = meeting["id"] self.log(f"Meeting {meeting_id}: Planning") db_query(""" INSERT INTO zoom_meetings (meeting_id, uuid, host_id, topic, type, start_time, duration) VALUES (%s, %s, %s, %s, %s, %s, %s) """, ( meeting_id, meeting['uuid'], uid, meeting['topic'], meeting['type'], parse_time(meeting['start_time']), meeting['duration'], )) def create_recurring_meeting(self, uid, meeting): meeting_id = meeting["id"] for occ in meeting["occurrences"]: occ_id = occ["occurrence_id"] self.log(f"Meeting {meeting_id}: Planning occurrence {occ_id}") db_query(""" INSERT INTO zoom_meetings (meeting_id, uuid, occurrence_id, host_id, topic, type, start_time, duration) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, ( meeting_id, meeting['uuid'], occ_id, uid, meeting['topic'], meeting['type'], parse_time(occ['start_time']), occ['duration'], )) def create_meeting(self, js): payload = js["payload"] meeting = payload["object"] meeting_id = meeting["id"] host_user_id = meeting["host_id"] db_query("SELECT * FROM zoom_users WHERE user_id=%s", (host_user_id,)) user = db.fetchone() if user is None: self.log(f"Meeting {meeting_id}: Host {host_user_id} not found in zoom_users") return type = meeting["type"] if type == 2: self.create_regular_meeting(user.id, meeting) elif type == 8: self.create_recurring_meeting(user.id, meeting) else: self.log(f"Meeting {meeting_id}: Unknown type {type}") return def delete_regular_meeting(self, meeting): meeting_id = meeting["id"] self.log(f"Meeting {meeting_id}: Deleting") db_query(""" DELETE FROM zoom_meetings WHERE meeting_id=%s """, ( meeting_id, )) def delete_recurring_meeting(self, meeting): meeting_id = meeting["id"] if "occurrences" not in meeting: return self.delete_regular_meeting(meeting) for occ in meeting["occurrences"]: occ_id = occ["occurrence_id"] self.log(f"Meeting {meeting_id}: Deleting occurrence {occ_id}") db_query(""" DELETE FROM zoom_meetings WHERE meeting_id=%s AND occurrence_id=%s """, ( meeting_id, occ_id, )) def delete_meeting(self, js): payload = js["payload"] meeting = payload["object"] meeting_id = meeting["id"] type = meeting["type"] if type == 2: self.delete_regular_meeting(meeting) elif type == 8: self.delete_recurring_meeting(meeting) else: self.log(f"Meeting {meeting_id}: Unknown type {type}") return def update_common_attrs(self, meeting_id, occurrence_id, new): if "topic" in new: self.log(f"Meeting {meeting_id}.{occurrence_id}: Updating topic") db_query("UPDATE zoom_meetings SET topic=%s WHERE meeting_id=%s AND (occurrence_id=%s OR %s = '0')", (new["topic"], meeting_id, occurrence_id, occurrence_id)) for a in ['uuid', 'host_id']: if a in new: self.log(f"Meeting {meeting_id}.{occurrence_id}: Change of {a} not supported") def update_schedule(self, meeting_id, occurrence_id, new): if "start_time" in new: self.log(f"Meeting {meeting_id}.{occurrence_id}: Updating start time") db_query("UPDATE zoom_meetings SET start_time=%s WHERE meeting_id=%s AND (occurrence_id=%s OR %s = '0')", (parse_time(new['start_time']), meeting_id, occurrence_id, occurrence_id)) if "duration" in new: self.log(f"Meeting {meeting_id}.{occurrence_id}: Updating duration") db_query("UPDATE zoom_meetings SET duration=%s WHERE meeting_id=%s AND (occurrence_id=%s OR %s = '0')", (new['duration'], meeting_id, occurrence_id, occurrence_id)) def update_regular_meeting(self, meeting_id, old, new): self.log(f"Meeting {meeting_id}: Updating regular meeting") self.update_common_attrs(meeting_id, 0, new) self.update_schedule(meeting_id, 0, new) def update_recurring_meeting_single(self, meeting_id, old, new): self.log(f"Meeting {meeting_id}: Updating single occurrence") for occ in new["occurrences"]: self.update_common_attrs(meeting_id, occ["occurrence_id"], new) self.update_schedule(meeting_id, occ["occurrence_id"], new) # e.g., duration can be set here self.update_schedule(meeting_id, occ["occurrence_id"], occ) def update_recurring_meeting_all(self, meeting_id, old, new): meeting_id = new["id"] self.log(f"Meeting {meeting_id}: Updating all occurrences") if "occurrences" not in new: self.update_common_attrs(meeting_id, 0, new) return db_query("SELECT * FROM zoom_meetings WHERE meeting_id=%s", (meeting_id,)) orig = db.fetchone() if orig is None: self.log(f"Meeting {meeting_id}: Update did not find previous version") return db_query("DELETE FROM zoom_meetings WHERE meeting_id=%s", (meeting_id,)) for occ in new["occurrences"]: occurrence_id = occ["occurrence_id"] self.log(f"Meeting {meeting_id}.{occurrence_id}: Re-creating") db_query(""" INSERT INTO zoom_meetings (meeting_id, uuid, occurrence_id, host_id, topic, type, start_time, duration) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, ( meeting_id, new.get('uuid', orig.uuid), occurrence_id, orig.host_id, new.get('topic', orig.topic), new.get('type', orig.type), parse_time(occ['start_time']), occ['duration'], )) def update_regular_to_recurring(self, meeting_id, old, new): self.log(f"Meeting {meeting_id}: Type change to recurring") self.update_recurring_meeting_all(meeting_id, old, new) def update_recurring_to_regular(self, meeting_id, old, new): self.log(f"Meeting {meeting_id}: Type change to regular") db_query("SELECT * FROM zoom_meetings WHERE meeting_id=%s", (meeting_id,)) orig = db.fetchone() if orig is None: self.log(f"Meeting {meeting_id}: Update did not find previous version") return db_query("DELETE FROM zoom_meetings WHERE meeting_id=%s", (meeting_id,)) db_query(""" INSERT INTO zoom_meetings (meeting_id, uuid, host_id, topic, type, start_time, duration) VALUES (%s, %s, %s, %s, %s, %s, %s) """, ( meeting_id, new.get('uuid', orig.uuid), orig.host_id, new.get('topic', orig.topic), new['type'], parse_time(new['start_time']), new['duration'], )) def update_meeting(self, js): payload = js["payload"] new = payload["object"] old = payload["old_object"] meeting_id = new["id"] new_type = new.get("type", -1) old_type = old.get("type", -1) if old_type != new_type: if old_type == 2 and new_type == 8: self.update_regular_to_recurring(meeting_id, old, new) elif old_type == 8 and new_type == 2: self.update_recurring_to_regular(meeting_id, old, new) else: self.log(f"Meeting {meeting_id}: Unsupported type change from {old_type} to {new_type}") return scope = payload.get("scope", "") if scope == "": self.update_regular_meeting(meeting_id, old, new) elif scope == "all": self.update_recurring_meeting_all(meeting_id, old, new) elif scope == "single": self.update_recurring_meeting_single(meeting_id, old, new) else: self.log(f"Meeting {meeting_id}: Unsupported update scope {scope}") def run(self): method = self.env['REQUEST_METHOD'] if method != 'POST': return self.http_error(405, 'Method not allowed', [('Allow', 'POST')]) if self.env.get('HTTP_AUTHORIZATION', '') != config['hooks']['verification_token']: self.log('Verification token does not match!') return self.http_error(401, 'Authorization failed') body = self.env['wsgi.input'].read() js = json.loads(body) self.log(js) event = js["event"] if event == "meeting.created": self.create_meeting(js) elif event == "meeting.deleted": self.delete_meeting(js) elif event == "meeting.updated": self.update_meeting(js) else: self.log(f"Unknown event: {event}") db_connection.commit() self.wsgi_start("204 No Content", []) return b"" def application(env, start_response): app = HookApp(env, start_response) try: return app.run() except Exception as exc: app.log(traceback.print_exception(etype=None, value=exc, tb=exc.__traceback__)) return app.http_error(500, "Internal server error")