Why Gemfury? Push, build, and install  RubyGems npm packages Python packages Maven artifacts PHP packages Go Modules Debian packages RPM packages NuGet packages

Repository URL to install this package:

Details    
apprise / plugins / NotifyFCM / __init__.py
Size: Mime:
# -*- coding: utf-8 -*-
#
# Copyright (C) 2021 Chris Caron <lead2gold@gmail.com>
# 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 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 ...utils import dict_full_update
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:
        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, str) 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)

            # A more advanced dict.update() that recursively includes
            # sub-dictionaries as well
            dict_full_update(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