diff --git a/config.example.py b/config.example.py index fb4f81a..68766bf 100644 --- a/config.example.py +++ b/config.example.py @@ -15,6 +15,9 @@ SPARKPOST_KEY = "" +class FIREBASE: + CREDENTIALS = {} # Firebase serviceAccountKey.json + TOPIC = "announcements" class GOOGLE_CAL: CAL_ID = "" diff --git a/config.travis.py b/config.travis.py index 09c4005..d443647 100644 --- a/config.travis.py +++ b/config.travis.py @@ -1,4 +1,5 @@ import os +import json from datetime import datetime, timezone, timedelta # uri should contain auth and default database @@ -19,6 +20,9 @@ 'channel': os.getenv("TRAVIS_SLACK_CHANNEL_ID") } +class FIREBASE: + CREDENTIALS = json.loads(os.getenv("FIREBASE_CREDENTIALS", "{}")) # Firebase serviceAccountKey.json + TOPIC = "announcements" class GOOGLE_CAL: CAL_ID = os.getenv("TRAVIS_GOOGLE_CAL_ID", "") diff --git a/requirements.txt b/requirements.txt index 788eb5d..b2ff348 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,18 +5,29 @@ bcrypt==3.1.5 beautifulsoup4==4.6.3 boto3==1.9.200 botocore==1.12.253 +CacheControl==0.12.6 cachetools==3.0.0 certifi==2018.11.29 cffi==1.11.5 chardet==3.0.4 +colorama==0.4.4 coverage==4.5.2 dnspython==1.16.0 docutils==0.15.2 +firebase-admin==4.4.0 +google-api-core==1.23.0 google-api-python-client==1.7.6 google-auth==1.6.2 google-auth-httplib2==0.0.3 google-auth-oauthlib==0.4.1 +google-cloud-core==1.4.3 +google-cloud-firestore==1.9.0 +google-cloud-storage==1.32.0 +google-crc32c==1.0.0 +google-resumable-media==1.1.0 +googleapis-common-protos==1.52.0 googlemaps==3.0.2 +grpcio==1.33.2 httplib2==0.12.0 idna==2.8 isort==4.3.4 @@ -26,9 +37,11 @@ lazy-object-proxy==1.3.1 mccabe==0.6.1 mock==4.0.2 more-itertools==5.0.0 +msgpack==1.0.0 oauth2client==4.1.3 oauthlib==3.1.0 pluggy==0.8.1 +protobuf==3.13.0 py==1.7.0 pyasn1==0.4.5 pyasn1-modules==0.2.3 @@ -40,6 +53,7 @@ pytest==4.0.2 pytest-cov==2.6.0 pytest-ordering==0.6 python-dateutil==2.7.5 +pytz==2020.4 requests==2.21.0 requests-oauthlib==1.3.0 rsa==4.0 diff --git a/serverless.yml b/serverless.yml index 2586db7..17b05c6 100644 --- a/serverless.yml +++ b/serverless.yml @@ -255,3 +255,7 @@ functions: request: template: application/json: '$input.body' + notifications: + handler: src/notifications.update_notification_status + events: + - schedule: rate(10 minutes) diff --git a/src/notifications.py b/src/notifications.py new file mode 100644 index 0000000..fbca2dd --- /dev/null +++ b/src/notifications.py @@ -0,0 +1,114 @@ +import datetime +import logging + +import firebase_admin +from firebase_admin import credentials +from firebase_admin import messaging + +from src import cal_announce +from config import FIREBASE + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +firebase_admin.initialize_app(credentials.Certificate(FIREBASE.CREDENTIALS)) + +def send_notification(message_title, message_body, message_topic): + """Sends a notification to the appropriate topic in Firebase. + + Sends a message consisting of a title and a body in the form of a push + notification to the Flutter mobile app. + + Documentation: + https://pub.dev/packages/firebase_messaging + """ + message = messaging.Message( + notification=messaging.Notification( + title=message_title, + body=message_body, + ), + topic=message_topic, + data={ + "title": message_title, + "body": message_body, + "click_action": "FLUTTER_NOTIFICATION_CLICK" + } + ) + return messaging.send(message) + +def check_slack(n=10): + """Retrieves latest message from slack. If the message timestamp is within n minutes of the + current time, returns the message body. + """ + slack_resp = cal_announce.slack_announce({"num_messages": 1}, None) + if slack_resp["statusCode"] != 200: + return {"error": f'Error retriving slack messages: {slack_resp["statusCode"]} {slack_resp["body"]}'} + + if not slack_resp["body"]: + return {"body": None} + + latest_msg = slack_resp["body"][0] + msg_time = datetime.datetime.utcfromtimestamp(float(latest_msg["ts"])) + body = None + if msg_time + datetime.timedelta(minutes=n) > datetime.datetime.utcnow(): + body = latest_msg["text"] + return {"body": body} + +def slack_notifications(): + """Cron job to send Firebase notifications whenever a new Slack message appears in #announcements.""" + slack_msg = check_slack() + + if "error" in slack_msg: + logging.error(slack_msg["error"]) + return + + if not slack_msg["body"]: + logging.info("No new slack messages") + return + + try: + response = send_notification("New Slack Announcement!", slack_msg["body"], FIREBASE.TOPIC) + logging.info(f'Firebase message for Slack ({slack_msg["body"]}) sent successfully! Response: {response}') + except Exception as e: + logging.error(f"Firebase messaging error: {e}") + +def check_google_calendar(n=10): + """Retrieves latest event from Google Calendar. If the event timestamp is within n minutes of the + current time, returns the event body. + """ + cal_resp = cal_announce.google_cal({"num_events": 1}, None) + if cal_resp["statusCode"] != 200: + return {"error": f'Error retriving Google Calendar events: {cal_resp["statusCode"]} {cal_resp["body"]}'} + + if not cal_resp["body"]: + return {"body": None} + + latest_cal_event = cal_resp["body"][0] + event_time = datetime.datetime.strptime(latest_cal_event["start"]["dateTime"], "%Y-%m-%dT%H:%M:%S%z") + body = None + if datetime.datetime.now(event_time.tzinfo) + datetime.timedelta(minutes=n) > event_time: + body = latest_cal_event["summary"] + return {"body": body} + +def google_calendar_notifications(): + """Cron job to send Firebase notifications whenever a new Google calendar event is approaching.""" + google_cal_event = check_google_calendar() + + if "error" in google_cal_event: + logging.error(google_cal_event["error"]) + return + + if not google_cal_event["body"]: + logging.info("No new Google Calendar events") + return + + try: + response = send_notification("Calender Event Approaching!", google_cal_event["body"], FIREBASE.TOPIC) + logging.info(f'Firebase message for Google Calendar ({google_cal_event["body"]}) sent successfully! Response: {response}') + except Exception as e: + logging.error(f"Firebase messaging error: {e}") + +def update_notification_status(): + """This function should be run as a cron job.""" + slack_notifications() + google_calendar_notifications() diff --git a/tests/test_notifications.py b/tests/test_notifications.py new file mode 100644 index 0000000..0d8bd2a --- /dev/null +++ b/tests/test_notifications.py @@ -0,0 +1,10 @@ +# This is different from regular lambda functions because it does not have a response to a user +# In the event that this function fails, the only side effect is that Firebase notifications will not be made + +from src import notifications + +def test_notifications(): + try: + notifications.update_notification_status() + except Exception as e: + print(f"Error: {e}")