diff --git a/README.md b/README.md index 438a777..56e10c5 100644 --- a/README.md +++ b/README.md @@ -1,407 +1,411 @@ # Sylk Pushserver [Home page](http://sylkserver.com) Copyright (C) 2021 AG Projects Sylk Pushserver was designed to act as a central dispatcher for mobile push notifications inside RTC provider infrastructures. Both the provider and the mobile application customer, in the case of a shared infrastructure, can easily audit problems related to the processing of push notifications. Authors: * Bibiana Rivadeneira * Tijmen de Mes ## License Sylk Pushserver is licensed under GNU General Public License version 3. [Copy of the license](http://www.fsf.org/licensing/licenses/gpl-3.0.html) ## Deployment scenarios Sylk Pushserver can be deployed together with WebRTC server applications or VoIP servers like SIP Proxies and PBXs. Its main purpose is to act as a central entity inside RTC provider infrastructures. Without such a component, the same functionality must be built inside multiple servers and as the number of mobile applications increases, the need for such central component becomes obvious. ### Integration examples * OpenSIPS: **config/opensips.cfg** * SylkServer: built-in support ## Design Sylk Pushserver can handle an arbitrary number of different combinations of push notification service and mobile applications. It and can be extended by using Python programming language to support new push notification services and applications. Sample applications are provided to handle Sylk and Linphone mobile applications for Apple and Firebase push notification services. For each configured Apple application, the server maintains a persistent connection by using HTTP/2 over TLS 1.2 and reuses that connection for sending notifications related to the application. Latest voip functionality for iOS 13 or later is also suported. Each outgoing connection can use its own set of credentials, X.509 certificates and urls. The connection failures are properly handled and incoming requests remained queued for later by using a timer dependent on the payload type. ### Logging All incoming and outgoing requests, including HTTP headers and bodies, can be logged for troubleshooting purposes in the system journal and in a separate log file. These logs can easily be correlated with the logs from the server that generated the request by using the call-id key. Remote HTTP logging of the results is possible so that one or more third-parties can receive information about the individual push requests and responses for each application. ## API Sylk Pushserver expects a json over HTTP POST requests and translates it into a correspondent outgoing push notifications request to Apple Push Notifications or Firebase FCM servers. Json object structure: ```{ 'app-id': 'com.agprojects.sylk-ios', 'platform': 'apple', 'token': '6688-71a883fe', 'device-id': 'accc8375125582aae062353', 'call-id': '4dbe8-7a53-42bd-95f3-9a7d43938', 'from': 'alice@example.com', 'from_display_name': 'Alice', 'to': 'bob@biloxi.com', 'media-type':'audio', 'event': 'incoming_session' 'silent': True 'reason': None +"badge": "number" } ``` Where: * `app-id: str`, id provided by the mobile application (e.g. mobile bundle ID) * `platform: str`, 'firebase', 'android', 'apple' or 'ios' * `token: str`, destination device token, * *iOS device tokens* are strings with 64 hexadecimal symbols * *Android device push tokens* can differ in length`. * `device-id: str`, the device that generated the token * `call-id: str`, the unique session id for each call * `from: str`, address of the caller * `from_display_name`, (mandatory)*, display name of the caller * `to`, address of the callee * `media-type: str`: 'audio', 'video', 'chat', 'sms' or 'file-transfer' * `silent: bool`: *(optional, default `True`)* True for silent notification * `reason:str`: *(optional)* Cancel reason * `event: str`, type of event: - * For *Sylk app* must be 'incoming_session', 'incoming_conference' or 'cancel' + * For *Sylk app* must be 'incoming_session', 'incoming_conference', 'cancel' or 'message' * For *Linphone app* must be 'incoming_session' +* `badge: int`: optional badge to display The response is a json with the following structure: ``` { 'code': 'a numeric code equal to the HTTP response code', 'description': 'a detailed text description', 'data' : {} } ``` *data* contains an arbitrary dictionary with a structure depending on the request type and the remote server response. ### V2 API version 2 supports storage of the push tokens in a Apache Cassandra Cluster or locally in a pickle file. The elements in the API methods are the same type and values as in API version 1. The API has the following methods: **POST** `/v2/tokens/{account}` - Stores a token for `{account}` ``` { "app-id": "string", "platform": "string", "token": "string", "device-id": "string", "silent": true, "user-agent": "string" } ``` **DELETE** `/v2/tokens/{account}` - Removes a token for `{account}` ``` { "app-id": "string", "device-id": "string" } ``` **POST** `/v2/tokens/{account}/push` - Sends a push notification(s) for `{account}` ``` { "event": "string", "call-id": "string", "from": "string", "from-display-name": "string", "to": "string", "media-type": "string", - "reason": "string" + "reason": "string", + "badge": "number" } ``` **POST** `/v2/tokens/{account}/push/{device}` - Sends a push notification for `{account}` and `{device}` ``` { "event": "string", "call-id": "string", "from": "string", "from-display-name": "string", "to": "string", "media-type": "string", - "reason": "string" + "reason": "string", + "badge": "number" } ``` ### Sample client code * See [sylk-pushclient](scripts/sylk-pushclient) * See [sylk-pushclient-v2](scripts/sylk-pushclient-v2) ### External APIs For documentation related to the API used by Apple and Firebase push notifications services you must consult their respective websites. For reference, the following APIs were used for developing the server, but these links may change: * [Sending Apple VoIp notifications](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/sending_notification_requests_to_apns) * [Firebase Cloud Messaging](https://firebase.google.com/docs/cloud-messaging) * [FCM migration from legacy HTTP to HTTP v1](https://firebase.google.com/docs/cloud-messaging/migrate-v1) ### Apple Certificate Go to Apple developer website https://developer.apple.com/account/resources/identifiers/list Go to Identifiers section Select the app id Scroll down to Push notifications Click Configure Generate a certificate. Export the certificate to pk12 format from Keychain. Convert the cartificate and private key to .pem format: openssl pkcs12 -in Certificates.p12 -nocerts -out sylk.privateKey.pem openssl pkcs12 -in Certificates.p12 -clcerts -nokeys -out sylk.pem Remove the passcode from the private key: openssl rsa -in sylk.privateKey.pem -out sylk.key Use sylk.pem and sylk.key inside applications.ini config file. ## Installation ### As a Debian package Install the AG Projects debian software signing key: wget http://download.ag-projects.com/agp-debian-gpg.key sudo apt-key add agp-debian-gpg.key Add these repository matching your distribution to /etc/apt/sources.list: https://docs-new.sipthor.net/w/debian_package_repositories/ Update the list of available packages: sudo apt-get update sudo apt-get install sylk-pushserver ### From source The source code is managed using darcs version control tool. The darcs repository can be fetched with: darcs clone http://devel.ag-projects.com/repositories/sylk-pushserver Alternatively, one can download a tar archive from: http://download.ag-projects.com/SylkPushserver/ Install Python dependencies: `pip3 install -r requirements.txt` `python3 setup.py install` ### Building Debian package Install building dependencies: ``` sudo apt install dh-virtualenv debhelper libsystemd-dev dh-python python3-dev python3-setuptools python3-pip ``` Build the package: ``` python setup.py sdist cd dist tar zxvf *.tar.gz cd sylk_pushserver-?.?.? debuild ``` To install the debian package manually: ``` sudo dpkg -i sylk-pushserver_1.0.0_all.deb sudo apt --fix-broken install ``` ## Configuration There are two configurations files. * general.ini Contains the general server settings. * applications.ini Contains the settings for each mobile application, *see config/applications.ini.sample*. Chages to this file cause the server to autamtically reload it, there is no need to restart the server. ## Remote logging Remote logging is done using a POST request over HTTP with a json containg both the original request and the final response of the push notification. ```{ 'request': push_request, 'response': push_response } ``` Where : * push_request is the original json payload received by this server * push_response is a json with the following format: ```{ 'code': code, # http response code from PNS 'description': description, # detail description of the response from the PNS 'push_url': push_url, # the final URL of the outgoing push notification 'incoming_body': {...}, # the original request body received by the server 'outgoing_headers': {...}. # the outgoing request headers sent to the PNS 'outgoing_body': {...} # the outgoing request body sent to the PNS } ``` The returned result should be a json with a consistent key. The key can be defined in the application.ini for each application. If the key is set then its value will be logged which can make troubleshooting easier. ## Custom applications Custom applications can be written in Python by subclassing existing template classes. Define the directory for custom applications in `general.ini` file: `extra_applications_dir` = `/etc/sylk-pushserver/applications` Copy config/applications/myapp.py to the extra_applications_dir and overwrite its functions. In `applications.ini` file set app_type for the custom applications: ``` `app_type` = *myapp* ``` ## Custom Push services Custom PNS can be written in Python by subclassing existing template classes. Define the directory for custom push services in `general.ini` file: `extra_pns_dir` = `/etc/sylk-pushserver/pns` Copy config/pns/mypns.py to the extra_pns dir and overwrite its classes. In `applications.ini` file set app_type for the custom applications: ``` `app_platform` = *mypns* ``` ## Running the server ### From the source code `./sylk-pushserver --config_dir ` If the config_dir directory is not specified, the following paths are searched for: * /etc/sylk-pushserver *./config For more command line options use -h. ### Debian package ``` sudo systemctl start sylk-pushserver ``` ### Testing For testing the server scripts/sylk-pushclient can be used. ## Compatibility The server is developed in Python 3 and was tested on Debian Buster 10. ## Reporting bugs You may report bugs to [SIP Beyond VoIP mailing list](http://lists.ag-projects.com/pipermail/sipbeyondvoip/) diff --git a/pushserver/applications/apple.py b/pushserver/applications/apple.py index 9f46862..3cccf77 100644 --- a/pushserver/applications/apple.py +++ b/pushserver/applications/apple.py @@ -1,137 +1,143 @@ __all__ = ['AppleHeaders', 'ApplePayload'] class AppleHeaders(object): """ Apple headers structure for a push notification """ def __init__(self, app_id: str, event: str, token: str, - call_id: str, sip_from: str, from_display_name: str, - sip_to: str, media_type: str, silent: bool, reason: str): + call_id: str, sip_from: str, from_display_name: str, + sip_to: str, media_type: str, silent: bool, reason: str, + badge: int): """ :param app_id: `str` id provided by the mobile application (bundle id) - :param event: `str` 'incoming_session', 'incoming_conference' or 'cancel' + :param event: `str` 'incoming_session', 'incoming_conference', 'cancel' or 'message' :param token: `str` destination device token. :param call_id: `str` unique sip parameter. :param sip_from: `str` SIP URI for who is calling :param from_display_name: `str` display name of the caller :param sip_to: `str` SIP URI for who is called :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' :param silent: `bool` True for silent notification :param reason: `str` Cancel reason + :param badge: `int` Number to display as badge """ self.app_id = app_id self.token = token self.call_id = call_id self.sip_from = sip_from self.sip_to = sip_to self.from_display_name = from_display_name self.media_type = media_type self.silent = silent self.event = event self.reason = reason + self.badge = badge self.apns_push_type = self.create_push_type() self.apns_expiration = self.create_expiration() self.apns_topic = self.create_topic() self.apns_priority = self.create_priority() 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'. """ return 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 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. """ return 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. """ return @property def headers(self) -> dict: """ Generate apple notification headers :return: a `dict` object with headers. """ headers = { 'apns-push-type': self.apns_push_type, 'apns-expiration': self.apns_expiration, 'apns-priority': self.apns_priority, 'apns-topic': self.apns_topic, 'authorization': f"bearer {self.token}"} if self.apns_push_type == 'background': headers['content-available'] = '1' return headers class ApplePayload(object): """ Apple payload structure for a push notification """ def __init__(self, app_id: str, event: str, token: str, - call_id: str, sip_from: str, from_display_name: str, - sip_to: str, media_type, silent: bool, reason: str): + call_id: str, sip_from: str, from_display_name: str, + sip_to: str, media_type, silent: bool, reason: str, + badge: int): """ :param app_id: `str` id provided by the mobile application (bundle id) - :param event: `str` 'incoming_session', 'incoming_conference' or 'cancel' + :param event: `str` 'incoming_session', 'incoming_conference', 'cancel' or 'message' :param token: `str` destination device token. :param call_id: `str` unique sip parameter. :param sip_from: `str` SIP URI for who is calling :param from_display_name: `str` display name of the caller :param sip_to: `str` SIP URI for who is called :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' :param silent: `bool` True for silent notification :param reason: `str` Cancel reason + :param badge: `int` Number to display as badge """ self.app_id = app_id self.token = token self.call_id = call_id self.sip_from = sip_from self.sip_to = sip_to self.from_display_name = from_display_name self.media_type = media_type self.silent = silent self.event = event self.reason = reason + self.badge = badge @property def payload(self) -> dict: """ logic to define apple payload using request parameters """ payload = {} return payload diff --git a/pushserver/applications/firebase.py b/pushserver/applications/firebase.py index 10b1424..46743d1 100644 --- a/pushserver/applications/firebase.py +++ b/pushserver/applications/firebase.py @@ -1,115 +1,122 @@ from pushserver.resources import settings from oauth2client.service_account import ServiceAccountCredentials __all__ = ['FirebaseHeaders', 'FirebasePayload'] class FirebaseHeaders(object): def __init__(self, app_id: str, event: str, token: str, call_id: str, sip_from: str, from_display_name: str, - sip_to: str, media_type: str, silent: bool, reason: str): + sip_to: str, media_type: str, silent: bool, reason: str, + badge: int): """ :param app_id: `str` id provided by the mobile application (bundle id) - :param event: `str` 'incoming_session', 'incoming_conference' or 'cancel' + :param event: `str` 'incoming_session', 'incoming_conference', 'cancel' or 'message' :param token: `str` destination device token. :param call_id: `str` unique sip parameter. :param sip_from: `str` SIP URI for who is calling :param from_display_name: `str` display name of the caller :param sip_to: `str` SIP URI for who is called :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' :param silent: `bool` True for silent notification + :param reason: `str` Cancel reason + :param badge: `int` Number to display as badge """ self.app_id = app_id self.token = token self.call_id = call_id self.sip_from = sip_from self.sip_to = sip_to self.from_display_name = from_display_name self.media_type = media_type self.silent = silent self.event = event self.reason = reason - + self.badge = badge + try: self.auth_key = settings.params.pns_register[(self.app_id, 'firebase')]['auth_key'] except KeyError: self.auth_key = None try: self.auth_file = settings.params.pns_register[(self.app_id, 'firebase')]['auth_file'] except KeyError: self.auth_file = None @property def access_token(self) -> str: # https://github.com/firebase/quickstart-python/blob/909f39e77395cb0682108184ba565150caa68a31/messaging/messaging.py#L25-L33 """ Retrieve a valid access token that can be used to authorize requests. :return: `str` Access token. """ scopes = ['https://www.googleapis.com/auth/firebase.messaging'] try: credentials = ServiceAccountCredentials.from_json_keyfile_name(self.auth_file, scopes) access_token_info = credentials.get_access_token() return access_token_info.access_token except Exception as e: self.error = f"Error: cannot generated Firebase access token: {e}" return '' @property def headers(self): """ Generate Firebase headers structure for a push notification :return: a firebase push notification header. """ if self.auth_key: headers = {'Content-Type': 'application/json', 'Authorization': f"key={self.auth_key}"} else: headers = { 'Authorization': f"Bearer {self.access_token}", 'Content-Type': 'application/json; UTF-8', } return headers class FirebasePayload(object): def __init__(self, app_id: str, event: str, token: str, call_id: str, sip_from: str, from_display_name: str, - sip_to: str, media_type: str, silent: bool, reason: str): + sip_to: str, media_type: str, silent: bool, reason: str, + badge: int): """ :param app_id: `str` id provided by the mobile application (bundle id) :param event: `str` 'incoming_session', 'incoming_conference', 'cancel' or 'message' :param token: `str` destination device token. :param call_id: `str` unique sip parameter. :param sip_from: `str` SIP URI for who is calling :param from_display_name: `str` display name of the caller :param sip_to: `str` SIP URI for who is called :param media_type: `str` 'audio', 'video', 'chat', 'sms' or 'file-transfer' :param silent: `bool` True for silent notification :param reason: `str` Cancel reason + :param badge: `int` Number to display as badge """ self.app_id = app_id self.token = token self.call_id = call_id # corresponds to session_id in the output self.event = event self.media_type = media_type self.sip_from = sip_from self.from_display_name = from_display_name self.sip_to = sip_to self.silent = silent self.reason = reason + self.badge = badge @property def payload(self) -> dict: """ Generate a Firebase payload for a push notification :return a Firebase payload: """ payload = {} return payload diff --git a/pushserver/applications/sylk.py b/pushserver/applications/sylk.py index 2d97bab..f92e71a 100644 --- a/pushserver/applications/sylk.py +++ b/pushserver/applications/sylk.py @@ -1,159 +1,208 @@ 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' + push_type = 'alert' + if self.event in ('incoming_session', 'incoming_conference_request'): + push_type = 'voip' + elif self.event == 'cancel': + push_type = '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 } + elif self.event == 'message': + payload = { + 'aps': { + 'alert': { + 'title' : 'New message', + 'body' : 'From %s' % self.sip_from, + }, + 'message_id': self.call_id, + "sound" : "default", + "badge" : self.badge, + } + } 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 } + elif self.event == 'message': + data = { + 'event': self.event, + 'from_uri': self.sip_from, + 'to_uri': self.sip_to + } 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 } http_payload = { 'message': { 'token': self.token, 'data': data, 'android': { - 'priority': 'high', - 'ttl': '60s' - } - } - } + 'priority': 'high', + 'ttl': '60s' + } + } + } + if (self.event == 'message'): + http_payload |= { + 'message': { + 'token': self.token, + 'data': data, + 'notification': { + 'title': 'New message', + 'body': 'From %s' % self.sip_from, + 'image': 'https://icanblink.com/apple-touch-icon-180x180.png' + }, + 'apns': { + 'headers': { + 'apns-priority': '5', + } + }, + 'android': { + 'priority': 'high', + 'ttl': '60s', + 'notification': { + 'channel_id': 'sylk-messages-sound', + 'sound': 'default', + 'default_sound': True, + 'notification_priority': 'PRIORITY_HIGH' + } + } + } + } - #fcm_payload = messaging.Message( + # fcm_payload = messaging.Message( # token=self.token, # data=data, # android=messaging.AndroidConfig( # ttl=datetime.timedelta(seconds=60), # priority='high' # ) - #) + # ) return http_payload diff --git a/pushserver/models/requests.py b/pushserver/models/requests.py index 32ea1b2..1614533 100644 --- a/pushserver/models/requests.py +++ b/pushserver/models/requests.py @@ -1,221 +1,223 @@ from pydantic import BaseModel, root_validator, validator from pushserver.resources import settings from pushserver.resources.utils import fix_platform_name def gen_validator_items() -> tuple: """ Generate some dicts according to minimum required parameters, and each app required paramaters, usefull for request validation. :return: two `dict` objects with common items and apps items. """ common_items = {'app-id', 'call-id', 'platform', 'from', 'token'} only_sylk_items = {'silent', 'to', 'event'} only_linphone_items = set() apps_items = {'sylk': common_items | only_sylk_items, # union 'linphone': common_items | only_linphone_items} return common_items, apps_items common_items, apps_items = gen_validator_items() def alias_rename(attribute: str) -> str: """ Rename request name attribute, replacing '_' by '_' and removing 'sip_' characters. :param attribute: `str` from request :return: a `str` corresponding to alias. """ if attribute.startswith('sip_'): return attribute.split('_', maxsplit=1)[1] return attribute.replace('_', '-') class AddRequest(BaseModel): app_id: str # id provided by the mobile application (bundle id) platform: str # 'firebase', 'android', 'apple' or 'ios' token: str # destination device token in hex device_id: str # the device-id that owns the token (used for logging purposes) silent: bool = True user_agent: str = None class Config: alias_generator = alias_rename @root_validator(pre=True) def check_required_items_for_add(cls, values): app_id, platform = values.get('app-id'), values.get('platform') if not app_id: raise ValueError("Field 'app-id' required") if not platform: raise ValueError("Field 'platform' required") platform = fix_platform_name(platform) if platform not in ('firebase', 'apple'): raise ValueError(f"The '{platform}' platform is not configured") pns_register = settings.params.pns_register if (app_id, platform) not in pns_register.keys(): raise ValueError(f"{platform.capitalize()} {app_id} app " f"is not configured") return values @validator('platform') def platform_valid_values(cls, v): if v not in ('apple', 'ios', 'android', 'firebase'): raise ValueError("platform must be 'apple', 'android' or 'firebase'") return v class AddResponse(BaseModel): app_id: str # id provided by the mobile application (bundle id) platform: str # 'firebase', 'android', 'apple' or 'ios' token: str # destination device token in hex device_id: str # the device-id that owns the token (used for logging purposes) silent: bool = True user_agent: str = None class Config: allow_population_by_field_name = True alias_generator = alias_rename class RemoveRequest(BaseModel): app_id: str # id provided by the mobile application (bundle id) device_id: str = None # the device-id that owns the token (used for logging purposes) class Config: alias_generator = alias_rename @root_validator(pre=True) def check_required_items_for_add(cls, values): app_id = values.get('app-id') if not app_id: raise ValueError("Field 'app-id' required") return values class RemoveResponse(BaseModel): app_id: str # id provided by the mobile application (bundle id) device_id: str = None # the device-id that owns the token (used for logging purposes) class Config: allow_population_by_field_name = True alias_generator = alias_rename class PushRequest(BaseModel): event: str = None # (required for sylk) 'incoming_session', 'incoming_conference' or 'cancel' call_id: str # (required for apple) unique sip parameter sip_from: str # (required for firebase) SIP URI for who is calling from_display_name: str = None # (required for sylk) display name of the caller to: str # SIP URI for who is called media_type: str = None # 'audio', 'video', 'chat', 'sms' or 'file-transfer' reason: str = None # Cancel reason + badge: int = 1 class Config: alias_generator = alias_rename class WakeUpRequest(BaseModel): # API expects a json object like: app_id: str # id provided by the mobile application (bundle id) platform: str # 'firebase', 'android', 'apple' or 'ios' - event: str = None # (required for sylk) 'incoming_session', 'incoming_conference' or 'cancel' + event: str = None # (required for sylk) 'incoming_session', 'incoming_conference', 'cancel' or 'message' token: str # destination device token in hex device_id: str = None # the device-id that owns the token (used for logging purposes) call_id: str # (required for apple) unique sip parameter sip_from: str # (required for firebase) SIP URI for who is calling from_display_name: str = None # (required for sylk) display name of the caller sip_to: str # SIP URI for who is called media_type: str = None # 'audio', 'video', 'chat', 'sms' or 'file-transfer' silent: bool = True # True for silent notification reason: str = None # Cancel reason + badge: int = 1 class Config: alias_generator = alias_rename @root_validator(pre=True) def check_required_items_by_app(cls, values): app_id, platform = values.get('app-id'), values.get('platform') if not app_id: raise ValueError("Field 'app-id' required") if not platform: raise ValueError("Field 'platform' required") platform = fix_platform_name(platform) if platform not in ('firebase', 'apple'): raise ValueError(f"'{platform}' platform is not configured") pns_register = settings.params.pns_register if (app_id, platform) not in pns_register.keys(): raise ValueError(f"{platform.capitalize()} {app_id} app " f"is not configured") try: name = pns_register[(app_id, platform)]['name'] check_items = apps_items[name] missing_items = [] for item in check_items: if values.get(item) is None: missing_items.append(item) if missing_items: missing_items_show = [] for item in missing_items: if item in ('sip_to', 'sip_from', 'device_id'): item = item.split('_')[1] else: item = item.replace('-', '_') missing_items_show.append(item) raise ValueError(f"'{' ,'.join(missing_items)}' " f"item(s) missing.") except KeyError: pass event = values.get('event') if event != 'cancel': media_type = values.get('media-type') if not media_type: raise ValueError("Field media-type required") if media_type not in ('audio', 'video', 'chat', 'sms', 'file-transfer'): raise ValueError("media-type must be 'audio', 'video', " "'chat', 'sms', 'file-transfer'") if 'linphone' in name: if event: if event != 'incoming_session': raise ValueError('event not found (must be incoming_sesion)') else: values['event'] = 'incoming_session' return values @validator('platform') def platform_valid_values(cls, v): if v not in ('apple', 'ios', 'android', 'firebase'): raise ValueError("platform must be 'apple', 'android' or 'firebase'") return v @validator('event') def event_valid_values(cls, v): - if v not in ('incoming_session', 'incoming_conference_request', 'cancel'): - raise ValueError("event must be 'incoming_session', 'incoming_conference_request' or 'cancel'") + if v not in ('incoming_session', 'incoming_conference_request', 'cancel', 'message'): + raise ValueError("event must be 'incoming_session', 'incoming_conference_request', 'cancel' or 'message'") return v diff --git a/pushserver/resources/notification.py b/pushserver/resources/notification.py index d8f6e1f..e95ab7c 100644 --- a/pushserver/resources/notification.py +++ b/pushserver/resources/notification.py @@ -1,96 +1,97 @@ import importlib import json from pushserver.models.requests import WakeUpRequest from pushserver.resources import settings def handle_request(wp_request, request_id: str) -> dict: """ Create a PushNotification object, and call methods to send the notification. :param wp_request: `WakeUpRequest', received from /push route. :param loggers: `dict` global logging instances to write messages (params.loggers) :param request_id: `str`, request ID generated on request event. :return: a `dict` with push notification results """ push_notification = PushNotification(wp_request=wp_request, request_id=request_id) results = push_notification.send_notification() return results class PushNotification(object): """ Push Notification actions from wake up request """ def __init__(self, wp_request: WakeUpRequest, request_id: str): """ :param wp_request: `WakeUpRequest`, from http request :param request_id: `str`, request ID generated on request event. """ self.wp_request = wp_request self.app_id = self.wp_request.app_id self.platform = self.wp_request.platform self.pns_register = settings.params.pns_register self.request_id = request_id self.loggers = settings.params.loggers self.log_remote = self.pns_register[(self.app_id, self.platform)].get('log_remote') self.config_dict = self.pns_register[(self.app_id, self.platform)] self.app_name = self.pns_register[(self.app_id, self.platform)]['name'] self.args = [self.app_id, self.wp_request.event, self.wp_request.token, self.wp_request.call_id, self.wp_request.sip_from, self.wp_request.from_display_name, self.wp_request.sip_to, - self.wp_request.media_type, self.wp_request.silent, self.wp_request.reason] + self.wp_request.media_type, self.wp_request.silent, + self.wp_request.reason, self.wp_request.badge] @property def custom_apps(self): apps = [self.pns_register[key]['name'] for key in self.pns_register.keys()] custom_apps = set(app for app in apps if app not in ('sylk', 'linphone')) return custom_apps def send_notification(self) -> dict: """ Send a push notification according to wakeup request params. """ error = '' headers_class = self.pns_register[(self.app_id, self.platform)]['headers_class'] headers = headers_class(*self.args).headers payload_class = self.pns_register[(self.app_id, self.platform)]['payload_class'] payload_dict = payload_class(*self.args).payload try: payload = json.dumps(payload_dict) except Exception: payload = None if not (headers and payload): error = f'{headers_class.__name__} and {payload_class.__name__} ' \ f'returned bad objects:' \ f'{headers}, {payload}' if not isinstance(headers, dict) or not isinstance(payload, str): error = f'{headers_class.__name__} and {payload_class.__name__} ' \ f'returned bad objects:' \ f'{headers}, {payload}' register = self.pns_register[(self.app_id, self.platform)] platform_module = importlib.import_module(f'pushserver.pns.{self.platform}') push_request_class = getattr(platform_module, f'{self.platform.capitalize()}PushRequest') push_request = push_request_class(error=error, app_name=self.app_name, app_id=self.app_id, request_id=self.request_id, headers=headers, payload=payload, loggers=self.loggers, log_remote=self.log_remote, wp_request=self.wp_request, register=register) return push_request.results diff --git a/scripts/sylk-pushclient-v2 b/scripts/sylk-pushclient-v2 index 3823d56..103e5e8 100755 --- a/scripts/sylk-pushclient-v2 +++ b/scripts/sylk-pushclient-v2 @@ -1,132 +1,134 @@ #!/usr/bin/python import json import logging import re import sys import requests try: import pymysql except ImportError: pass from argparse import ArgumentParser if __name__ == '__main__': parser = ArgumentParser() subparsers = parser.add_subparsers(dest='action') parser.add_argument('--url', dest='url', required=False, default='http://localhost:8400', help='Base push URL') parser.add_argument('--account', dest='account', required=True, help='Account') subparserA = subparsers.add_parser('push', help='Send push request') subparserA.add_argument('--mediatype', dest='media_type', default="audio", required=False, help='Audio, Video or Message') subparserA.add_argument('--callid', dest='call_id', required=True, help='Call ID') subparserA.add_argument('--event', dest='event', required=False, help='Event', default='incoming_session') subparserA.add_argument('--from', dest='from_uri', required=True, help='From') subparserA.add_argument('--from_name', dest='from_name', required=False, help='From name') subparserA.add_argument('--to', dest='to_uri', required=True, help='To') subparserA.add_argument('--reason', dest='reason', required=False, help='Reason') + subparserA.add_argument('--badge', dest='badge', default=None, required=False, help='Badge to display') subparserA.add_argument('--deviceid', dest='device_id', default=None, required=False, help='Device Id/Sip instance') subparserB = subparsers.add_parser('add', help='Add a push token') subparserB.add_argument('--platform', dest='platform', help='Platform') subparserB.add_argument('--appid', dest='appid', required=True, help='App ID') subparserB.add_argument('--token', dest='device_token', required=True, help='Device token') subparserB.add_argument('--deviceid', dest='device_id', required=True, help='Device Id') subparserB.add_argument('--silent', dest='silent', default="1", required=False, help='Silent') subparserB.add_argument('--user_agent', dest='user_agent', default="None", required=False, help='User Agent') subparserC = subparsers.add_parser('remove', help='Remove a push token') subparserC.add_argument('--appid', dest='appid', required=True, help='App ID') subparserC.add_argument('--deviceid', dest='device_id', required=True, help='Device Id') options = parser.parse_args() try: from_uri = re.sub(r'^"|"$', '', options.from_uri) except AttributeError: pass try: from_name = options.from_name.strip('\"') if options.from_name else None except AttributeError: pass try: media_type = options.media_type if ("video" in options.media_type): media_type = 'video' elif ("audio" in options.media_type): media_type = 'audio' except AttributeError: pass if options.url[-1] == '/': options.url=options.url[:-1] url = '{}/{}/{}'.format(options.url, 'v2/tokens', options.account) if options.action == 'add': log_params = {'platform': options.platform, 'app-id': options.appid, 'token': options.device_token, 'device-id': options.device_id, 'silent': options.silent, 'user-agent': options.user_agent} elif options.action == 'remove': log_params = {'app-id': options.appid, 'device-id': options.device_id} else: log_params = {'media-type': media_type, 'event': options.event, 'from': from_uri, 'from-display-name': from_name or from_uri, 'to': options.to_uri, 'call-id': options.call_id, + 'badge': options.badge, 'reason': options.reason} if options.device_id is None: url = '{}/{}/{}/push'.format(options.url, 'v2/tokens', options.account) else: url = '{}/{}/{}/push/{}'.format(options.url, 'v2/tokens', options.account, options.device_id) def getMethod(*args, **kwargs): if options.action == 'remove': return requests.delete(*args, **kwargs) else: return requests.post(*args, **kwargs) action = options.action.title() try: r = getMethod(url, timeout=5, json=log_params) print("%s request to %s - %s: %s" % (action, url, r.status_code, r.text)) if r.status_code >= 200 and r.status_code < 300: sys.exit(0) elif r.status_code == 410: body = r.json() try: for result in body['data']: failure = result['body']['_content']['failure'] if failure == 1: # A push client may want to act based on various response codes # https://firebase.google.com/docs/cloud-messaging/http-server-ref#error-codes reason = result['body']['_content']['results'][0]['error'] if reason == 'NotRegistered': log.info("Token %s must be purged" % token) #q = "delete from push_tokens where token = '%s'" % token #con = pymysql.connect('localhost', 'opensips', 'XYZ', 'opensips') #with con: # cur = con.cursor() # cur.execute(q) except KeyError: pass sys.exit(0) else: print("%s request to %s failed: %d: %s" % (action, url, r.status_code, r.text)) sys.exit(1) except Exception as e: print("%s request to %s failed: connection error" % (action, url)) sys.exit(1)