bazarr/libs/apprise/plugins/NotifyFCM/__init__.py

628 lines
21 KiB
Python

# -*- 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 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