aboutsummaryrefslogtreecommitdiffhomepage
path: root/libs/apprise/plugins/fcm
diff options
context:
space:
mode:
Diffstat (limited to 'libs/apprise/plugins/fcm')
-rw-r--r--libs/apprise/plugins/fcm/__init__.py628
-rw-r--r--libs/apprise/plugins/fcm/color.py121
-rw-r--r--libs/apprise/plugins/fcm/common.py46
-rw-r--r--libs/apprise/plugins/fcm/oauth.py319
-rw-r--r--libs/apprise/plugins/fcm/priority.py251
5 files changed, 1365 insertions, 0 deletions
diff --git a/libs/apprise/plugins/fcm/__init__.py b/libs/apprise/plugins/fcm/__init__.py
new file mode 100644
index 000000000..9dc0679f1
--- /dev/null
+++ b/libs/apprise/plugins/fcm/__init__.py
@@ -0,0 +1,628 @@
+# -*- coding: utf-8 -*-
+# BSD 2-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2024, Chris Caron <[email protected]>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+# 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 ..base 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 ...apprise_attachment import AppriseAttachment
+from ...locale 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',
+ },
+ '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',
+ 'required': True,
+ },
+ })
+
+ 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().__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),
+ )
+
+ def __len__(self):
+ """
+ Returns the number of targets associated with this notification
+ """
+ return len(self.targets)
+
+ @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
diff --git a/libs/apprise/plugins/fcm/color.py b/libs/apprise/plugins/fcm/color.py
new file mode 100644
index 000000000..20149eedd
--- /dev/null
+++ b/libs/apprise/plugins/fcm/color.py
@@ -0,0 +1,121 @@
+# -*- coding: utf-8 -*-
+# BSD 2-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2024, Chris Caron <[email protected]>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+# New priorities are defined here:
+# - https://firebase.google.com/docs/reference/fcm/rest/v1/\
+# projects.messages#NotificationPriority
+
+# Legacy color payload example here:
+# https://firebase.google.com/docs/reference/fcm/rest/v1/\
+# projects.messages#androidnotification
+import re
+from ...utils import parse_bool
+from ...common import NotifyType
+from ...asset import AppriseAsset
+
+
+class FCMColorManager:
+ """
+ A Simple object to accept either a boolean value
+ - True: Use colors provided by Apprise
+ - False: Do not use colors at all
+ - rrggbb: where you provide the rgb values (hence #333333)
+ - rgb: is also accepted as rgb values (hence #333)
+
+ For RGB colors, the hashtag is optional
+ """
+
+ __color_rgb = re.compile(
+ r'#?((?P<r1>[0-9A-F]{2})(?P<g1>[0-9A-F]{2})(?P<b1>[0-9A-F]{2})'
+ r'|(?P<r2>[0-9A-F])(?P<g2>[0-9A-F])(?P<b2>[0-9A-F]))', re.IGNORECASE)
+
+ def __init__(self, color, asset=None):
+ """
+ Parses the color object accordingly
+ """
+
+ # Initialize an asset object if one isn't otherwise defined
+ self.asset = asset \
+ if isinstance(asset, AppriseAsset) else AppriseAsset()
+
+ # Prepare our color
+ self.color = color
+ if isinstance(color, str):
+ self.color = self.__color_rgb.match(color)
+ if self.color:
+ # Store our RGB value as #rrggbb
+ self.color = '{red}{green}{blue}'.format(
+ red=self.color.group('r1'),
+ green=self.color.group('g1'),
+ blue=self.color.group('b1')).lower() \
+ if self.color.group('r1') else \
+ '{red1}{red2}{green1}{green2}{blue1}{blue2}'.format(
+ red1=self.color.group('r2'),
+ red2=self.color.group('r2'),
+ green1=self.color.group('g2'),
+ green2=self.color.group('g2'),
+ blue1=self.color.group('b2'),
+ blue2=self.color.group('b2')).lower()
+
+ if self.color is None:
+ # Color not determined, so base it on boolean parser
+ self.color = parse_bool(color)
+
+ def get(self, notify_type=NotifyType.INFO):
+ """
+ Returns color or true/false value based on configuration
+ """
+
+ if isinstance(self.color, bool) and self.color:
+ # We want to use the asset value
+ return self.asset.color(notify_type=notify_type)
+
+ elif self.color:
+ # return our color as is
+ return '#' + self.color
+
+ # No color to return
+ return None
+
+ def __str__(self):
+ """
+ our color representation
+ """
+ if isinstance(self.color, bool):
+ return 'yes' if self.color else 'no'
+
+ # otherwise return our color
+ return self.color
+
+ def __bool__(self):
+ """
+ Allows this object to be wrapped in an 'if statement'.
+ True is returned if a color was loaded
+ """
+ return True if self.color is True or \
+ isinstance(self.color, str) else False
diff --git a/libs/apprise/plugins/fcm/common.py b/libs/apprise/plugins/fcm/common.py
new file mode 100644
index 000000000..9f139226a
--- /dev/null
+++ b/libs/apprise/plugins/fcm/common.py
@@ -0,0 +1,46 @@
+# -*- coding: utf-8 -*-
+# BSD 2-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2024, Chris Caron <[email protected]>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+class FCMMode:
+ """
+ Define the Firebase Cloud Messaging Modes
+ """
+ # The legacy way of sending a message
+ Legacy = "legacy"
+
+ # The new API
+ OAuth2 = "oauth2"
+
+
+# FCM Modes
+FCM_MODES = (
+ # Legacy API
+ FCMMode.Legacy,
+ # HTTP v1 URL
+ FCMMode.OAuth2,
+)
diff --git a/libs/apprise/plugins/fcm/oauth.py b/libs/apprise/plugins/fcm/oauth.py
new file mode 100644
index 000000000..fbde3ccf7
--- /dev/null
+++ b/libs/apprise/plugins/fcm/oauth.py
@@ -0,0 +1,319 @@
+# -*- coding: utf-8 -*-
+# BSD 2-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2024, Chris Caron <[email protected]>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+#
+# To generate a private key file for your service account:
+#
+# 1. In the Firebase console, open Settings > Service Accounts.
+# 2. Click Generate New Private Key, then confirm by clicking Generate Key.
+# 3. Securely store the JSON file containing the key.
+
+import requests
+import base64
+import json
+import calendar
+from cryptography.hazmat import backends
+from cryptography.hazmat.primitives import hashes
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives import asymmetric
+from cryptography.exceptions import UnsupportedAlgorithm
+from datetime import datetime
+from datetime import timedelta
+from datetime import timezone
+from json.decoder import JSONDecodeError
+from urllib.parse import urlencode as _urlencode
+
+from ...logger import logger
+
+
+class GoogleOAuth:
+ """
+ A OAuth simplified implimentation to Google's Firebase Cloud Messaging
+
+ """
+ scopes = [
+ 'https://www.googleapis.com/auth/firebase.messaging',
+ ]
+
+ # 1 hour in seconds (the lifetime of our token)
+ access_token_lifetime_sec = timedelta(seconds=3600)
+
+ # The default URI to use if one is not found
+ default_token_uri = 'https://oauth2.googleapis.com/token'
+
+ # Taken right from google.auth.helpers:
+ clock_skew = timedelta(seconds=10)
+
+ def __init__(self, user_agent=None, timeout=(5, 4),
+ verify_certificate=True):
+ """
+ Initialize our OAuth object
+ """
+
+ # Wether or not to verify ssl
+ self.verify_certificate = verify_certificate
+
+ # Our (connect, read) timeout
+ self.request_timeout = timeout
+
+ # assign our user-agent if defined
+ self.user_agent = user_agent
+
+ # initialize our other object variables
+ self.__reset()
+
+ def __reset(self):
+ """
+ Reset object internal variables
+ """
+
+ # Google Keyfile Encoding
+ self.encoding = 'utf-8'
+
+ # Our retrieved JSON content (unmangled)
+ self.content = None
+
+ # Our generated key information we cache once loaded
+ self.private_key = None
+
+ # Our keys we build using the provided content
+ self.__refresh_token = None
+ self.__access_token = None
+ self.__access_token_expiry = datetime.now(timezone.utc)
+
+ def load(self, path):
+ """
+ Generate our SSL details
+ """
+
+ # Reset our objects
+ self.content = None
+ self.private_key = None
+ self.__access_token = None
+ self.__access_token_expiry = datetime.now(timezone.utc)
+
+ try:
+ with open(path, mode="r", encoding=self.encoding) as fp:
+ self.content = json.loads(fp.read())
+
+ except (OSError, IOError):
+ logger.debug('FCM keyfile {} could not be accessed'.format(path))
+ return False
+
+ except JSONDecodeError as e:
+ logger.debug(
+ 'FCM keyfile {} generated a JSONDecodeError: {}'.format(
+ path, e))
+ return False
+
+ if not isinstance(self.content, dict):
+ logger.debug(
+ 'FCM keyfile {} is incorrectly structured'.format(path))
+ self.__reset()
+ return False
+
+ # Verify we've got the correct tokens in our content to work with
+ is_valid = next((False for k in (
+ 'client_email', 'private_key_id', 'private_key',
+ 'type', 'project_id') if not self.content.get(k)), True)
+
+ if not is_valid:
+ logger.debug(
+ 'FCM keyfile {} is missing required information'.format(path))
+ self.__reset()
+ return False
+
+ # Verify our service_account type
+ if self.content.get('type') != 'service_account':
+ logger.debug(
+ 'FCM keyfile {} is not of type service_account'.format(path))
+ self.__reset()
+ return False
+
+ # Prepare our private key which is in PKCS8 PEM format
+ try:
+ self.private_key = serialization.load_pem_private_key(
+ self.content.get('private_key').encode(self.encoding),
+ password=None, backend=backends.default_backend())
+
+ except (TypeError, ValueError):
+ # ValueError: If the PEM data could not be decrypted or if its
+ # structure could not be decoded successfully.
+ # TypeError: If a password was given and the private key was
+ # not encrypted. Or if the key was encrypted but
+ # no password was supplied.
+ logger.error('FCM provided private key is invalid.')
+ self.__reset()
+ return False
+
+ except UnsupportedAlgorithm:
+ # If the serialized key is of a type that is not supported by
+ # the backend.
+ logger.error('FCM provided private key is not supported')
+ self.__reset()
+ return False
+
+ # We've done enough validation to move on
+ return True
+
+ @property
+ def access_token(self):
+ """
+ Returns our access token (if it hasn't expired yet)
+ - if we do not have one we'll fetch one.
+ - if it expired, we'll renew it
+ - if a key simply can't be acquired, then we return None
+ """
+
+ if not self.private_key or not self.content:
+ # invalid content (or not loaded)
+ logger.error(
+ 'No FCM JSON keyfile content loaded to generate a access '
+ 'token with.')
+ return None
+
+ if self.__access_token_expiry > datetime.now(timezone.utc):
+ # Return our no-expired key
+ return self.__access_token
+
+ # If we reach here we need to prepare our payload
+ token_uri = self.content.get('token_uri', self.default_token_uri)
+ service_email = self.content.get('client_email')
+ key_identifier = self.content.get('private_key_id')
+
+ # Generate our Assertion
+ now = datetime.now(timezone.utc)
+ expiry = now + self.access_token_lifetime_sec
+
+ payload = {
+ # The number of seconds since the UNIX epoch.
+ "iat": calendar.timegm(now.utctimetuple()),
+ "exp": calendar.timegm(expiry.utctimetuple()),
+ # The issuer must be the service account email.
+ "iss": service_email,
+ # The audience must be the auth token endpoint's URI
+ "aud": token_uri,
+ # Our token scopes
+ "scope": " ".join(self.scopes),
+ }
+
+ # JWT Details
+ header = {
+ 'typ': 'JWT',
+ 'alg': 'RS256' if isinstance(
+ self.private_key, asymmetric.rsa.RSAPrivateKey) else 'ES256',
+
+ # Key Identifier
+ 'kid': key_identifier,
+ }
+
+ # Encodes base64 strings removing any padding characters.
+ segments = [
+ base64.urlsafe_b64encode(
+ json.dumps(header).encode(self.encoding)).rstrip(b"="),
+ base64.urlsafe_b64encode(
+ json.dumps(payload).encode(self.encoding)).rstrip(b"="),
+ ]
+
+ signing_input = b".".join(segments)
+ signature = self.private_key.sign(
+ signing_input,
+ asymmetric.padding.PKCS1v15(),
+ hashes.SHA256(),
+ )
+
+ # Finally append our segment
+ segments.append(base64.urlsafe_b64encode(signature).rstrip(b"="))
+ assertion = b".".join(segments)
+
+ http_payload = _urlencode({
+ 'assertion': assertion,
+ 'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ })
+
+ http_headers = {
+ 'Content-Type': 'application/x-www-form-urlencoded',
+ }
+ if self.user_agent:
+ http_headers['User-Agent'] = self.user_agent
+
+ logger.info('Refreshing FCM Access Token')
+ try:
+ r = requests.post(
+ token_uri,
+ data=http_payload,
+ headers=http_headers,
+ verify=self.verify_certificate,
+ timeout=self.request_timeout,
+ )
+ if r.status_code != requests.codes.ok:
+ # We had a problem
+ logger.warning(
+ 'Failed to update FCM Access Token error={}.'
+ .format(r.status_code))
+
+ logger.debug(
+ 'Response Details:\r\n%s', r.content)
+ return None
+
+ except requests.RequestException as e:
+ logger.warning(
+ 'A Connection error occurred refreshing FCM '
+ 'Access Token.'
+ )
+ logger.debug('Socket Exception: %s', str(e))
+ return None
+
+ # If we get here, we made our request successfully, now we need
+ # to parse out the data
+ response = json.loads(r.content)
+ self.__access_token = response['access_token']
+ self.__refresh_token = response.get(
+ 'refresh_token', self.__refresh_token)
+
+ if 'expires_in' in response:
+ delta = timedelta(seconds=int(response['expires_in']))
+ self.__access_token_expiry = \
+ delta + datetime.now(timezone.utc) - self.clock_skew
+
+ else:
+ # Allow some grace before we expire
+ self.__access_token_expiry = expiry - self.clock_skew
+
+ logger.debug(
+ 'Access Token successfully acquired: %s', self.__access_token)
+
+ # Return our token
+ return self.__access_token
+
+ @property
+ def project_id(self):
+ """
+ Returns the project id found in the file
+ """
+ return None if not self.content \
+ else self.content.get('project_id')
diff --git a/libs/apprise/plugins/fcm/priority.py b/libs/apprise/plugins/fcm/priority.py
new file mode 100644
index 000000000..8564d3460
--- /dev/null
+++ b/libs/apprise/plugins/fcm/priority.py
@@ -0,0 +1,251 @@
+# -*- coding: utf-8 -*-
+# BSD 2-Clause License
+#
+# Apprise - Push Notification Library.
+# Copyright (c) 2024, Chris Caron <[email protected]>
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are met:
+#
+# 1. Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+#
+# 2. Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+# New priorities are defined here:
+# - https://firebase.google.com/docs/reference/fcm/rest/v1/\
+# projects.messages#NotificationPriority
+
+# Legacy priorities are defined here:
+# - https://firebase.google.com/docs/cloud-messaging/http-server-ref
+from .common import (FCMMode, FCM_MODES)
+from ...logger import logger
+
+
+class NotificationPriority:
+ """
+ Defines the Notification Priorities as described on:
+ https://firebase.google.com/docs/reference/fcm/rest/v1/\
+ projects.messages#androidmessagepriority
+
+ NORMAL:
+ Default priority for data messages. Normal priority messages won't
+ open network connections on a sleeping device, and their delivery
+ may be delayed to conserve the battery. For less time-sensitive
+ messages, such as notifications of new email or other data to sync,
+ choose normal delivery priority.
+
+ HIGH:
+ Default priority for notification messages. FCM attempts to
+ deliver high priority messages immediately, allowing the FCM
+ service to wake a sleeping device when possible and open a network
+ connection to your app server. Apps with instant messaging, chat,
+ or voice call alerts, for example, generally need to open a
+ network connection and make sure FCM delivers the message to the
+ device without delay. Set high priority if the message is
+ time-critical and requires the user's immediate interaction, but
+ beware that setting your messages to high priority contributes
+ more to battery drain compared with normal priority messages.
+ """
+
+ NORMAL = 'NORMAL'
+ HIGH = 'HIGH'
+
+
+class FCMPriority:
+ """
+ Defines our accepted priorites
+ """
+ MIN = "min"
+
+ LOW = "low"
+
+ NORMAL = "normal"
+
+ HIGH = "high"
+
+ MAX = "max"
+
+
+FCM_PRIORITIES = (
+ FCMPriority.MIN,
+ FCMPriority.LOW,
+ FCMPriority.NORMAL,
+ FCMPriority.HIGH,
+ FCMPriority.MAX,
+)
+
+
+class FCMPriorityManager:
+ """
+ A Simple object to make it easier to work with FCM set priorities
+ """
+
+ priority_map = {
+ FCMPriority.MIN: {
+ FCMMode.OAuth2: {
+ 'message': {
+ 'android': {
+ 'priority': NotificationPriority.NORMAL
+ },
+ 'apns': {
+ 'headers': {
+ 'apns-priority': "5"
+ }
+ },
+ 'webpush': {
+ 'headers': {
+ 'Urgency': 'very-low'
+ }
+ },
+ }
+ },
+ FCMMode.Legacy: {
+ 'priority': 'normal',
+ }
+ },
+ FCMPriority.LOW: {
+ FCMMode.OAuth2: {
+ 'message': {
+ 'android': {
+ 'priority': NotificationPriority.NORMAL
+ },
+ 'apns': {
+ 'headers': {
+ 'apns-priority': "5"
+ }
+ },
+ 'webpush': {
+ 'headers': {
+ 'Urgency': 'low'
+ }
+ }
+ }
+ },
+ FCMMode.Legacy: {
+ 'priority': 'normal',
+ }
+ },
+ FCMPriority.NORMAL: {
+ FCMMode.OAuth2: {
+ 'message': {
+ 'android': {
+ 'priority': NotificationPriority.NORMAL
+ },
+ 'apns': {
+ 'headers': {
+ 'apns-priority': "5"
+ }
+ },
+ 'webpush': {
+ 'headers': {
+ 'Urgency': 'normal'
+ }
+ }
+ }
+ },
+ FCMMode.Legacy: {
+ 'priority': 'normal',
+ }
+ },
+ FCMPriority.HIGH: {
+ FCMMode.OAuth2: {
+ 'message': {
+ 'android': {
+ 'priority': NotificationPriority.HIGH
+ },
+ 'apns': {
+ 'headers': {
+ 'apns-priority': "10"
+ }
+ },
+ 'webpush': {
+ 'headers': {
+ 'Urgency': 'high'
+ }
+ }
+ }
+ },
+ FCMMode.Legacy: {
+ 'priority': 'high',
+ }
+ },
+ FCMPriority.MAX: {
+ FCMMode.OAuth2: {
+ 'message': {
+ 'android': {
+ 'priority': NotificationPriority.HIGH
+ },
+ 'apns': {
+ 'headers': {
+ 'apns-priority': "10"
+ }
+ },
+ 'webpush': {
+ 'headers': {
+ 'Urgency': 'high'
+ }
+ }
+ }
+ },
+ FCMMode.Legacy: {
+ 'priority': 'high',
+ }
+ }
+ }
+
+ def __init__(self, mode, priority=None):
+ """
+ Takes a FCMMode and Priority
+ """
+
+ self.mode = mode
+ if self.mode not in FCM_MODES:
+ msg = 'The FCM mode specified ({}) is invalid.'.format(mode)
+ logger.warning(msg)
+ raise TypeError(msg)
+
+ self.priority = None
+ if priority:
+ self.priority = \
+ next((p for p in FCM_PRIORITIES
+ if p.startswith(priority[:2].lower())), None)
+ if not self.priority:
+ msg = 'An invalid FCM Priority ' \
+ '({}) was specified.'.format(priority)
+ logger.warning(msg)
+ raise TypeError(msg)
+
+ def payload(self):
+ """
+ Returns our payload depending on our mode
+ """
+ return self.priority_map[self.priority][self.mode] \
+ if self.priority else {}
+
+ def __str__(self):
+ """
+ our priority representation
+ """
+ return self.priority if self.priority else ''
+
+ def __bool__(self):
+ """
+ Allows this object to be wrapped in an 'if statement'.
+ True is returned if a priority was loaded
+ """
+ return True if self.priority else False