# -*- coding: utf-8 -*- # # Copyright (C) 2021 Chris Caron # All rights reserved. # # This code is licensed under the MIT License. # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files(the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and / or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions : # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN # THE SOFTWARE. # For this plugin to work correct, the FCM server must be set up to allow # for remote connections. # Firebase Cloud Messaging # Visit your console page: https://console.firebase.google.com # 1. Create a project if you haven't already. If you did the # {project} ID will be listed as name-XXXXX. # 2. Click on your project from here to open it up. # 3. Access your Web API Key by clicking on: # - The (gear-next-to-project-name) > Project Settings > Cloud Messaging # Visit the following site to get you're Project information: # - https://console.cloud.google.com/project/_/settings/general/ # # Docs: https://firebase.google.com/docs/cloud-messaging/send-message # Legacy Docs: # https://firebase.google.com/docs/cloud-messaging/http-server-ref\ # #send-downstream # # If you Generate a new private key, it will provide a .json file # You will need this in order to send an apprise messag import six import requests from json import dumps from ..NotifyBase import NotifyBase from ...common import NotifyType from ...utils import validate_regex from ...utils import parse_list from ...utils import parse_bool from ...common import NotifyImageSize from ...AppriseAttachment import AppriseAttachment from ...AppriseLocale import gettext_lazy as _ from .common import (FCMMode, FCM_MODES) from .priority import (FCM_PRIORITIES, FCMPriorityManager) from .color import FCMColorManager # Default our global support flag NOTIFY_FCM_SUPPORT_ENABLED = False try: from .oauth import GoogleOAuth # We're good to go NOTIFY_FCM_SUPPORT_ENABLED = True except ImportError: # cryptography is the dependency of the .oauth library # Create a dummy object for init() call to work class GoogleOAuth(object): pass # Our lookup map FCM_HTTP_ERROR_MAP = { 400: 'A bad request was made to the server.', 401: 'The provided API Key was not valid.', 404: 'The token could not be registered.', } class NotifyFCM(NotifyBase): """ A wrapper for Google's Firebase Cloud Messaging Notifications """ # Set our global enabled flag enabled = NOTIFY_FCM_SUPPORT_ENABLED requirements = { # Define our required packaging in order to work 'packages_required': 'cryptography' } # The default descriptive name associated with the Notification service_name = 'Firebase Cloud Messaging' # The services URL service_url = 'https://firebase.google.com' # The default protocol secure_protocol = 'fcm' # A URL that takes you to the setup/help of the specific protocol setup_url = 'https://github.com/caronc/apprise/wiki/Notify_fcm' # Project Notification # https://firebase.google.com/docs/cloud-messaging/send-message notify_oauth2_url = \ "https://fcm.googleapis.com/v1/projects/{project}/messages:send" notify_legacy_url = "https://fcm.googleapis.com/fcm/send" # There is no reason we should exceed 5KB when reading in a JSON file. # If it is more than this, then it is not accepted. max_fcm_keyfile_size = 5000 # Allows the user to specify the NotifyImageSize object image_size = NotifyImageSize.XY_256 # The maximum length of the body body_maxlen = 1024 # Define object templates templates = ( # OAuth2 '{schema}://{project}/{targets}?keyfile={keyfile}', # Legacy Mode '{schema}://{apikey}/{targets}', ) # Define our template template_tokens = dict(NotifyBase.template_tokens, **{ 'apikey': { 'name': _('API Key'), 'type': 'string', 'private': True, }, 'keyfile': { 'name': _('OAuth2 KeyFile'), 'type': 'string', 'private': True, }, 'project': { 'name': _('Project ID'), 'type': 'string', 'required': True, }, 'target_device': { 'name': _('Target Device'), 'type': 'string', 'map_to': 'targets', }, 'target_topic': { 'name': _('Target Topic'), 'type': 'string', 'prefix': '#', 'map_to': 'targets', }, 'targets': { 'name': _('Targets'), 'type': 'list:string', }, }) template_args = dict(NotifyBase.template_args, **{ 'to': { 'alias_of': 'targets', }, 'mode': { 'name': _('Mode'), 'type': 'choice:string', 'values': FCM_MODES, 'default': FCMMode.Legacy, }, 'priority': { 'name': _('Mode'), 'type': 'choice:string', 'values': FCM_PRIORITIES, }, 'image_url': { 'name': _('Custom Image URL'), 'type': 'string', }, 'image': { 'name': _('Include Image'), 'type': 'bool', 'default': False, 'map_to': 'include_image', }, # Color can either be yes, no, or a #rrggbb ( # rrggbb without hashtag is accepted to) 'color': { 'name': _('Notification Color'), 'type': 'string', 'default': 'yes', }, }) # Define our data entry template_kwargs = { 'data_kwargs': { 'name': _('Data Entries'), 'prefix': '+', }, } def __init__(self, project, apikey, targets=None, mode=None, keyfile=None, data_kwargs=None, image_url=None, include_image=False, color=None, priority=None, **kwargs): """ Initialize Firebase Cloud Messaging """ super(NotifyFCM, self).__init__(**kwargs) if mode is None: # Detect our mode self.mode = FCMMode.OAuth2 if keyfile else FCMMode.Legacy else: # Setup our mode self.mode = NotifyFCM.template_tokens['mode']['default'] \ if not isinstance(mode, six.string_types) else mode.lower() if self.mode and self.mode not in FCM_MODES: msg = 'The FCM mode specified ({}) is invalid.'.format(mode) self.logger.warning(msg) raise TypeError(msg) # Used for Legacy Mode; this is the Web API Key retrieved from the # User Panel self.apikey = None # Path to our Keyfile self.keyfile = None # Our Project ID is required to verify against the keyfile # specified self.project = None # Initialize our Google OAuth module we can work with self.oauth = GoogleOAuth( user_agent=self.app_id, timeout=self.request_timeout, verify_certificate=self.verify_certificate) if self.mode == FCMMode.OAuth2: # The project ID associated with the account self.project = validate_regex(project) if not self.project: msg = 'An invalid FCM Project ID ' \ '({}) was specified.'.format(project) self.logger.warning(msg) raise TypeError(msg) if not keyfile: msg = 'No FCM JSON KeyFile was specified.' self.logger.warning(msg) raise TypeError(msg) # Our keyfile object is just an AppriseAttachment object self.keyfile = AppriseAttachment(asset=self.asset) # Add our definition to our template self.keyfile.add(keyfile) # Enforce maximum file size self.keyfile[0].max_file_size = self.max_fcm_keyfile_size else: # Legacy Mode # The apikey associated with the account self.apikey = validate_regex(apikey) if not self.apikey: msg = 'An invalid FCM API key ' \ '({}) was specified.'.format(apikey) self.logger.warning(msg) raise TypeError(msg) # Acquire Device IDs to notify self.targets = parse_list(targets) # Our data Keyword/Arguments to include in our outbound payload self.data_kwargs = {} if isinstance(data_kwargs, dict): self.data_kwargs.update(data_kwargs) # Include the image as part of the payload self.include_image = include_image # A Custom Image URL # FCM allows you to provide a remote https?:// URL to an image_url # located on the internet that it will download and include in the # payload. # # self.image_url() is reserved as an internal function name; so we # jsut store it into a different variable for now self.image_src = image_url # Initialize our priority self.priority = FCMPriorityManager(self.mode, priority) # Initialize our color self.color = FCMColorManager(color, asset=self.asset) return @property def access_token(self): """ Generates a access_token based on the keyfile provided """ keyfile = self.keyfile[0] if not keyfile: # We could not access the keyfile self.logger.error( 'Could not access FCM keyfile {}.'.format( keyfile.url(privacy=True))) return None if not self.oauth.load(keyfile.path): self.logger.error( 'FCM keyfile {} could not be loaded.'.format( keyfile.url(privacy=True))) return None # Verify our project id against the one provided in our keyfile if self.project != self.oauth.project_id: self.logger.error( 'FCM keyfile {} identifies itself for a different project' .format(keyfile.url(privacy=True))) return None # Return our generated key; the below returns None if a token could # not be acquired return self.oauth.access_token def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs): """ Perform FCM Notification """ if not self.targets: # There is no one to email; we're done self.logger.warning('There are no FCM devices or topics to notify') return False if self.mode == FCMMode.OAuth2: access_token = self.access_token if not access_token: # Error message is generated in access_tokengen() so no reason # to additionally write anything here return False headers = { 'User-Agent': self.app_id, 'Content-Type': 'application/json', "Authorization": "Bearer {}".format(access_token), } # Prepare our notify URL notify_url = self.notify_oauth2_url else: # FCMMode.Legacy headers = { 'User-Agent': self.app_id, 'Content-Type': 'application/json', "Authorization": "key={}".format(self.apikey), } # Prepare our notify URL notify_url = self.notify_legacy_url # Acquire image url image = self.image_url(notify_type) \ if not self.image_src else self.image_src has_error = False # Create a copy of the targets list targets = list(self.targets) while len(targets): recipient = targets.pop(0) if self.mode == FCMMode.OAuth2: payload = { 'message': { 'token': None, 'notification': { 'title': title, 'body': body, } } } if self.color: # Acquire our color payload['message']['android'] = { 'notification': {'color': self.color.get(notify_type)}} if self.include_image and image: payload['message']['notification']['image'] = image if self.data_kwargs: payload['message']['data'] = self.data_kwargs if recipient[0] == '#': payload['message']['topic'] = recipient[1:] self.logger.debug( "FCM recipient %s parsed as a topic", recipient[1:]) else: payload['message']['token'] = recipient self.logger.debug( "FCM recipient %s parsed as a device token", recipient) else: # FCMMode.Legacy payload = { 'notification': { 'notification': { 'title': title, 'body': body, } } } if self.color: # Acquire our color payload['notification']['notification']['color'] = \ self.color.get(notify_type) if self.include_image and image: payload['notification']['notification']['image'] = image if self.data_kwargs: payload['data'] = self.data_kwargs if recipient[0] == '#': payload['to'] = '/topics/{}'.format(recipient) self.logger.debug( "FCM recipient %s parsed as a topic", recipient[1:]) else: payload['to'] = recipient self.logger.debug( "FCM recipient %s parsed as a device token", recipient) # # Apply our priority configuration (if set) # def merge(d1, d2): for k in d2: if k in d1 and isinstance(d1[k], dict) \ and isinstance(d2[k], dict): merge(d1[k], d2[k]) else: d1[k] = d2[k] merge(payload, self.priority.payload()) self.logger.debug( 'FCM %s POST URL: %s (cert_verify=%r)', self.mode, notify_url, self.verify_certificate, ) self.logger.debug('FCM %s Payload: %s', self.mode, str(payload)) # Always call throttle before any remote server i/o is made self.throttle() try: r = requests.post( notify_url.format(project=self.project), data=dumps(payload), headers=headers, verify=self.verify_certificate, timeout=self.request_timeout, ) if r.status_code not in ( requests.codes.ok, requests.codes.no_content): # We had a problem status_str = \ NotifyBase.http_response_code_lookup( r.status_code, FCM_HTTP_ERROR_MAP) self.logger.warning( 'Failed to send {} FCM notification: ' '{}{}error={}.'.format( self.mode, status_str, ', ' if status_str else '', r.status_code)) self.logger.debug( 'Response Details:\r\n%s', r.content) has_error = True else: self.logger.info('Sent %s FCM notification.', self.mode) except requests.RequestException as e: self.logger.warning( 'A Connection error occurred sending FCM ' 'notification.' ) self.logger.debug('Socket Exception: %s', str(e)) has_error = True return not has_error def url(self, privacy=False, *args, **kwargs): """ Returns the URL built dynamically based on specified arguments. """ # Define any URL parameters params = { 'mode': self.mode, 'image': 'yes' if self.include_image else 'no', 'color': str(self.color), } if self.priority: # Store our priority if one was defined params['priority'] = str(self.priority) if self.keyfile: # Include our keyfile if specified params['keyfile'] = NotifyFCM.quote( self.keyfile[0].url(privacy=privacy), safe='') if self.image_src: # Include our image path as part of our URL payload params['image_url'] = self.image_src # Extend our parameters params.update(self.url_parameters(privacy=privacy, *args, **kwargs)) # Add our data keyword/args into our URL response params.update( {'+{}'.format(k): v for k, v in self.data_kwargs.items()}) reference = NotifyFCM.quote(self.project) \ if self.mode == FCMMode.OAuth2 \ else self.pprint(self.apikey, privacy, safe='') return '{schema}://{reference}/{targets}?{params}'.format( schema=self.secure_protocol, reference=reference, targets='/'.join( [NotifyFCM.quote(x) for x in self.targets]), params=NotifyFCM.urlencode(params), ) @staticmethod def parse_url(url): """ Parses the URL and returns enough arguments that can allow us to re-instantiate this object. """ results = NotifyBase.parse_url(url, verify_host=False) if not results: # We're done early as we couldn't load the results return results # The apikey/project is stored in the hostname results['apikey'] = NotifyFCM.unquote(results['host']) results['project'] = results['apikey'] # Get our Device IDs results['targets'] = NotifyFCM.split_path(results['fullpath']) # Get our mode results['mode'] = results['qsd'].get('mode') # The 'to' makes it easier to use yaml configuration if 'to' in results['qsd'] and len(results['qsd']['to']): results['targets'] += \ NotifyFCM.parse_list(results['qsd']['to']) # Our Project ID if 'project' in results['qsd'] and results['qsd']['project']: results['project'] = \ NotifyFCM.unquote(results['qsd']['project']) # Our Web API Key if 'apikey' in results['qsd'] and results['qsd']['apikey']: results['apikey'] = \ NotifyFCM.unquote(results['qsd']['apikey']) # Our Keyfile (JSON) if 'keyfile' in results['qsd'] and results['qsd']['keyfile']: results['keyfile'] = \ NotifyFCM.unquote(results['qsd']['keyfile']) # Our Priority if 'priority' in results['qsd'] and results['qsd']['priority']: results['priority'] = \ NotifyFCM.unquote(results['qsd']['priority']) # Our Color if 'color' in results['qsd'] and results['qsd']['color']: results['color'] = \ NotifyFCM.unquote(results['qsd']['color']) # Boolean to include an image or not results['include_image'] = parse_bool(results['qsd'].get( 'image', NotifyFCM.template_args['image']['default'])) # Extract image_url if it was specified if 'image_url' in results['qsd']: results['image_url'] = \ NotifyFCM.unquote(results['qsd']['image_url']) if 'image' not in results['qsd']: # Toggle default behaviour if a custom image was provided # but ONLY if the `image` boolean was not set results['include_image'] = True # Store our data keyword/args if specified results['data_kwargs'] = results['qsd+'] return results