diff --git a/pushserver/applications/sylk.py b/pushserver/applications/sylk.py index 6d6245b..bab2824 100644 --- a/pushserver/applications/sylk.py +++ b/pushserver/applications/sylk.py @@ -1,148 +1,159 @@ +import datetime + from pushserver.applications.apple import * from pushserver.applications.firebase import * from pushserver.resources.utils import callid_to_uuid +from firebase_admin import messaging __all__ = ['AppleSylkHeaders', 'AppleSylkPayload', 'FirebaseSylkHeaders', 'FirebaseSylkPayload'] class AppleSylkHeaders(AppleHeaders): """ An Apple headers structure for a push notification """ def create_push_type(self) -> str: """ logic to define apns_push_type value using request parameters apns_push_type reflect the contents of the notification’s payload, it can be: 'alert', 'background', 'voip', 'complication', 'fileprovider' or 'mdm'. """ push_type = 'voip' if self.event in ('incoming_session', 'incoming_conference_request') else 'background' return push_type def create_expiration(self) -> int: """ logic to define apns_expiration value using request parameters apns_expiration is the date at which the notification expires, (UNIX epoch expressed in seconds UTC). """ return '120' def create_topic(self) -> str: """ logic to define apns_topic value using request parameters apns_topic is in general is the app’s bundle ID and may have a suffix based on the notification’s type. """ apns_topic = self.app_id if self.app_id.endswith('.dev') or self.app_id.endswith('.prod'): apns_topic = '.'.join(self.app_id.split('.')[:-1]) if self.event in ('incoming_session', 'incoming_conference_request'): apns_topic = f"{apns_topic}.voip" return apns_topic def create_priority(self) -> int: """ logic to define apns_priority value using request parameters Notification priority, apns_prioriy 10 o send the notification immediately, 5 to send the notification based on power considerations on the user’s device. """ apns_priority = '10' if self.event in ('incoming_session', 'incoming_conference_request') else '5' return apns_priority class FirebaseSylkHeaders(FirebaseHeaders): """ Firebase headers for a push notification """ class AppleSylkPayload(ApplePayload): """ A payload for a Apple Sylk push notification """ @property def payload(self) -> str: """ Generate an AppleSylk notification payload """ if self.event == 'cancel': payload = { 'event': self.event, 'call-id': self.call_id, 'session-id': callid_to_uuid(self.call_id), 'reason': self.reason } else: payload = { 'event': self.event, 'call-id': self.call_id, 'session-id': callid_to_uuid(self.call_id), 'media-type': self.media_type, 'from_uri': self.sip_from, 'from_display_name': self.from_display_name, 'to_uri': self.sip_to } return payload class FirebaseSylkPayload(FirebasePayload): """ A payload for a Firebase Sylk push notification """ @property def payload(self) -> str: """ Generate a Sylk payload and extra Firebase parameters """ if not self.from_display_name: from_display_name = self.sip_from else: from_display_name = self.from_display_name if self.event == 'cancel': data = { 'event': self.event, 'call-id': self.call_id, 'session-id': callid_to_uuid(self.call_id), 'reason': self.reason } else: data = { 'event': self.event, 'call-id': self.call_id, 'session-id': callid_to_uuid(self.call_id), 'media-type': self.media_type, 'from_uri': self.sip_from, 'from_display_name': from_display_name, 'to_uri': self.sip_to } - payload = { + http_payload = { 'message': { 'token': self.token, 'data': data, 'android': { 'priority': 'high', 'ttl': '60s' } } } - return payload + fcm_payload = messaging.Message( + token=self.token, + data=data, + android=messaging.AndroidConfig( + ttl=datetime.timedelta(seconds=60), + priority='high' + ) + ) + return {'http': http_payload, 'fcm': fcm_payload} diff --git a/pushserver/pns/firebase.py b/pushserver/pns/firebase.py index 12f68db..aa7773e 100644 --- a/pushserver/pns/firebase.py +++ b/pushserver/pns/firebase.py @@ -1,315 +1,381 @@ import json import os import time from datetime import datetime import oauth2client import requests from pushserver.models.requests import WakeUpRequest from requests.adapters import HTTPAdapter from urllib3 import Retry from pushserver.pns.base import PNS, PushRequest, PlatformRegister from pushserver.resources.utils import log_event, fix_non_serializable_types +import firebase_admin +from firebase_admin import messaging + +default_app = firebase_admin.initialize_app() + class FirebasePNS(PNS): """ A Firebase Push Notification service """ def __init__(self, app_id: str, app_name: str, url_push: str, voip: bool, auth_key: str = None, auth_file: str = None): """ :param app_id `str`: Application ID. :param url_push `str`: URI to push a notification. :param voip `bool`: required for apple, `True` for voip push notification type. :param auth_key `str`: A Firebase credential for push notifications. :param auth_file `str`: A Firebase credential for push notifications. """ self.app_id = app_id self.app_name = app_name self.url_push = url_push self.voip = voip self.auth_key = auth_key self.auth_file = auth_file self.error = '' class FirebaseRegister(PlatformRegister): def __init__(self, app_id: str, app_name: str, voip: bool, config_dict: dict, credentials_path: str, loggers: dict): self.app_id = app_id self.app_name = app_name self.voip = voip self.credentials_path = credentials_path self.config_dict = config_dict self.loggers = loggers self.error = '' self.auth_key, self.auth_file = self.set_auths() @property def url_push(self): try: return self.config_dict['firebase_push_url'] except KeyError: self.error = 'firebase_push_url not found in applications.ini' return None def set_auths(self): auth_key = None auth_file = None try: auth_key = self.config_dict['firebase_authorization_key'] except KeyError: try: auth_file = self.config_dict['firebase_authorization_file'] if self.credentials_path: auth_file = f"{self.credentials_path}/" \ f"{auth_file}" else: pass if not os.path.exists(auth_file): self.error = f'{auth_file} - no such file' except KeyError: self.error = 'not firebase_authorization_key or ' \ 'firebase_authorization_file found in applications.ini' return auth_key, auth_file @property def pns(self) -> FirebasePNS: pns = None if self.auth_key: auth_file = '' pns = FirebasePNS(app_id=self.app_id, app_name=self.app_name, url_push=self.url_push, voip=self.voip, auth_key=self.auth_key, auth_file=auth_file) elif self.auth_file: pns = FirebasePNS(app_id=self.app_id, app_name=self.app_name, url_push=self.url_push, voip=self.voip, auth_file=self.auth_file) self.error = pns.error if pns.error else '' return pns @property def register_entries(self): if self.error: return {} return {'pns': self.pns, 'auth_key': self.auth_key, 'auth_file': self.auth_file, 'refreshed_token': False} class FirebasePushRequest(PushRequest): """ Firebase push notification request """ def __init__(self, error: str, app_name: str, app_id: str, request_id: str, headers: str, payload: dict, loggers: dict, log_remote: dict, wp_request: WakeUpRequest, register: dict): """ :param error: `str` :param app_name: `str` 'linphone' or 'payload' :param app_id: `str` bundle id :param headers: `FirebaseHeaders` Firebase push notification headers :param payload: `FirebasePayload`Firebase push notification payload :param wp_request: `WakeUpRequest` :param loggers: `dict` global logging instances to write messages (params.loggers) """ self.error = error self.app_name = app_name self.app_id = app_id self.platform = 'firebase' self.request_id = request_id self.headers = headers self.payload = payload self.token = wp_request.token self.wp_request = wp_request self.loggers = loggers self.log_remote = log_remote self.pns = register['pns'] self.path = self.pns.url_push - self.results = self.send_notification() + self.results = self.send_http_notification() + # self.results = self.send_fcm_notification() def requests_retry_session(self, counter=0): """ Define parameters to retry a push notification according to media_type. :param counter: `int` (optional) if retries was necessary because of connection fails Following rfc3261 specification, an exponential backoff factor is used. More specifically: backoff = 0.5 T1 = 500ms max_retries_call = 7 time_to_live_call = 64 seconds max_retries_sms = 11 time_to_live_sms ~ 2 hours """ retries = self.retries_params[self.media_type] - counter backoff_factor = self.retries_params['bo_factor'] * 0.5 * 2 ** counter status_forcelist = tuple([status for status in range(500, 600)]) session = None session = session or requests.Session() retry = Retry( total=retries, read=retries, connect=retries, backoff_factor=backoff_factor, status_forcelist=status_forcelist) adapter = HTTPAdapter(max_retries=retry) session.mount('http://', adapter) session.mount('https://', adapter) return session - def send_notification(self) -> dict: + def send_http_notification(self) -> dict: """ - Send a Firebase push notification + Send a Firebase push notification over HTTP """ if self.error: self.log_error() return {'code': 500, 'body': {}, 'reason': 'Internal server error'} n_retries, backoff_factor = self.retries_params(self.wp_request.media_type) counter = 0 error = False code = 500 reason = "" body = None response = None while counter <= n_retries: self.log_request(path=self.pns.url_push) try: - response = requests.post(self.pns.url_push, - self.payload, + self.payload['http'], headers=self.headers) break except requests.exceptions.RequestException as e: error = True reason = f'connection failed: {e}' counter += 1 timer = backoff_factor * (2 ** (counter - 1)) time.sleep(timer) if counter == n_retries: reason = "maximum retries reached" elif error: try: response = self.requests_retry_session(counter). \ post(self.pns.url_push, self.payload, headers=self.headers) except Exception as x: level = 'error' msg = f"outgoing {self.platform.title()} response for " \ f"{self.request_id}, push failed: " \ f"an error occurred in {x.__class__.__name__}" log_event(loggers=self.loggers, msg=msg, level=level) try: body = response.__dict__ except (TypeError, ValueError): code = 500 reason = 'cannot parse response body' body = {} else: reason = body.get('reason') code = response.status_code for k in ('raw', 'request', 'connection', 'cookies', 'elapsed'): try: del body[k] except KeyError: pass except TypeError: break body = json.dumps(fix_non_serializable_types(body)) if isinstance(body, str): body = json.loads(body) if code == 200: try: failure = body['_content']['failure'] except KeyError: pass else: if failure == 1: reason = body['_content']['results'][0]['error'] code = 410 elif code == 404: try: payload_code = body['_content']['error']['code'] except KeyError: pass else: if payload_code == 404: code = 410 keys = list(body.keys()) for key in keys: if not body[key]: del body[key] results = {'body': body, 'code': code, 'reason': reason, 'url': self.pns.url_push, 'call_id': self.wp_request.call_id, 'token': self.token } + self.results = results + self.log_results() + return results + + def send_fcm_notification(self) -> dict: + """ + Send a native Firebase push notification + """ + + if self.error: + self.log_error() + return {'code': 500, 'body': {}, 'reason': 'Internal server error'} + + n_retries, backoff_factor = self.retries_params(self.wp_request.media_type) + + counter = 0 + error = False + code = 200 + response = None + body = None + reason = None + + while counter <= n_retries: + self.log_request(path=self.pns.url_push) + + try: + response = messaging.send(self.payload['fcm']) + break + except Exception as e: + error = True + response = f'connection failed: {e}' + counter += 1 + timer = backoff_factor * (2 ** (counter - 1)) + conde = 500 + time.sleep(timer) + + if counter == n_retries: + reason = "maximum retries reached" + + elif error: + try: + response = self.requests_retry_session(counter). \ + post(self.pns.url_push, + self.payload, + headers=self.headers) + except Exception as x: + level = 'error' + msg = f"outgoing {self.platform.title()} response for " \ + f"{self.request_id}, push failed: " \ + f"an error occurred in {x.__class__.__name__}" + log_event(loggers=self.loggers, msg=msg, level=level) + + body = {'response': response} + results = {'body': body, + 'code': code, + 'reason': reason, + 'url': self.pns.url_push, + 'call_id': self.wp_request.call_id, + 'token': self.token + } + self.results = results self.log_results() # Request is missing required authentication credential. # Expected OAuth 2 access token, login cookie or other valid authentication # credential. UNAUTHENTICATED code = results.get('code') reason = results.get('reason') if not got401 and code == 401 and reason == 'Unauthorized': if not self.pns.get('refreshed_token'): level = 'warn' msg = f"outgoing {self.platform.title()} response for request " \ f"{self.request_id} need a new access token - " \ f"server will refresh it and try again" log_event(loggers=self.loggers, msg=msg, level=level, to_file=True) # retry with a new Fireplace access token self.pns.access_token = self.pns.set_access_token() level = 'warn' msg = f"outgoing {self.platform.title()} response for request " \ f"{self.request_id} a new access token was generated - " \ f"trying again" log_event(loggers=self.loggers, msg=msg, level=level, to_file=True) self.results = self.send_notification() return results diff --git a/requirements.txt b/requirements.txt index 4e6e4c5..61db92d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,9 @@ fastapi == 0.52.0 hyper >= 0.7.0 oauth2client >= 4.1.3 pydantic >= 1.4 pyinotify >= 0.9.6 requests >= 2.23.0 systemd >= 0.16.1 -uvicorn >= 0.11.3 \ No newline at end of file +uvicorn >= 0.11.3 +firebase-admin