You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
641 lines
21 KiB
641 lines
21 KiB
# -*- coding: utf-8 -*-
|
|
# BSD 2-Clause License
|
|
#
|
|
# Apprise - Push Notification Library.
|
|
# Copyright (c) 2023, Chris Caron <lead2gold@gmail.com>
|
|
#
|
|
# 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.
|
|
|
|
import re
|
|
import requests
|
|
from itertools import chain
|
|
|
|
from .NotifyBase import NotifyBase
|
|
from ..common import NotifyType
|
|
from ..common import NotifyFormat
|
|
from ..conversion import convert_between
|
|
from ..utils import parse_list
|
|
from ..utils import validate_regex
|
|
from ..AppriseLocale import gettext_lazy as _
|
|
from ..attachment.AttachBase import AttachBase
|
|
|
|
# Flag used as a placeholder to sending to all devices
|
|
PUSHOVER_SEND_TO_ALL = 'ALL_DEVICES'
|
|
|
|
# Used to detect a Device
|
|
VALIDATE_DEVICE = re.compile(r'^\s*(?P<device>[a-z0-9_-]{1,25})\s*$', re.I)
|
|
|
|
|
|
# Priorities
|
|
class PushoverPriority:
|
|
LOW = -2
|
|
MODERATE = -1
|
|
NORMAL = 0
|
|
HIGH = 1
|
|
EMERGENCY = 2
|
|
|
|
|
|
# Sounds
|
|
class PushoverSound:
|
|
PUSHOVER = 'pushover'
|
|
BIKE = 'bike'
|
|
BUGLE = 'bugle'
|
|
CASHREGISTER = 'cashregister'
|
|
CLASSICAL = 'classical'
|
|
COSMIC = 'cosmic'
|
|
FALLING = 'falling'
|
|
GAMELAN = 'gamelan'
|
|
INCOMING = 'incoming'
|
|
INTERMISSION = 'intermission'
|
|
MAGIC = 'magic'
|
|
MECHANICAL = 'mechanical'
|
|
PIANOBAR = 'pianobar'
|
|
SIREN = 'siren'
|
|
SPACEALARM = 'spacealarm'
|
|
TUGBOAT = 'tugboat'
|
|
ALIEN = 'alien'
|
|
CLIMB = 'climb'
|
|
PERSISTENT = 'persistent'
|
|
ECHO = 'echo'
|
|
UPDOWN = 'updown'
|
|
NONE = 'none'
|
|
|
|
|
|
PUSHOVER_SOUNDS = (
|
|
PushoverSound.PUSHOVER,
|
|
PushoverSound.BIKE,
|
|
PushoverSound.BUGLE,
|
|
PushoverSound.CASHREGISTER,
|
|
PushoverSound.CLASSICAL,
|
|
PushoverSound.COSMIC,
|
|
PushoverSound.FALLING,
|
|
PushoverSound.GAMELAN,
|
|
PushoverSound.INCOMING,
|
|
PushoverSound.INTERMISSION,
|
|
PushoverSound.MAGIC,
|
|
PushoverSound.MECHANICAL,
|
|
PushoverSound.PIANOBAR,
|
|
PushoverSound.SIREN,
|
|
PushoverSound.SPACEALARM,
|
|
PushoverSound.TUGBOAT,
|
|
PushoverSound.ALIEN,
|
|
PushoverSound.CLIMB,
|
|
PushoverSound.PERSISTENT,
|
|
PushoverSound.ECHO,
|
|
PushoverSound.UPDOWN,
|
|
PushoverSound.NONE,
|
|
)
|
|
|
|
PUSHOVER_PRIORITIES = {
|
|
# Note: This also acts as a reverse lookup mapping
|
|
PushoverPriority.LOW: 'low',
|
|
PushoverPriority.MODERATE: 'moderate',
|
|
PushoverPriority.NORMAL: 'normal',
|
|
PushoverPriority.HIGH: 'high',
|
|
PushoverPriority.EMERGENCY: 'emergency',
|
|
}
|
|
|
|
PUSHOVER_PRIORITY_MAP = {
|
|
# Maps against string 'low'
|
|
'l': PushoverPriority.LOW,
|
|
# Maps against string 'moderate'
|
|
'm': PushoverPriority.MODERATE,
|
|
# Maps against string 'normal'
|
|
'n': PushoverPriority.NORMAL,
|
|
# Maps against string 'high'
|
|
'h': PushoverPriority.HIGH,
|
|
# Maps against string 'emergency'
|
|
'e': PushoverPriority.EMERGENCY,
|
|
|
|
# Entries to additionally support (so more like Pushover's API)
|
|
'-2': PushoverPriority.LOW,
|
|
'-1': PushoverPriority.MODERATE,
|
|
'0': PushoverPriority.NORMAL,
|
|
'1': PushoverPriority.HIGH,
|
|
'2': PushoverPriority.EMERGENCY,
|
|
}
|
|
|
|
# Extend HTTP Error Messages
|
|
PUSHOVER_HTTP_ERROR_MAP = {
|
|
401: 'Unauthorized - Invalid Token.',
|
|
}
|
|
|
|
|
|
class NotifyPushover(NotifyBase):
|
|
"""
|
|
A wrapper for Pushover Notifications
|
|
"""
|
|
|
|
# The default descriptive name associated with the Notification
|
|
service_name = 'Pushover'
|
|
|
|
# The services URL
|
|
service_url = 'https://pushover.net/'
|
|
|
|
# All pushover requests are secure
|
|
secure_protocol = 'pover'
|
|
|
|
# A URL that takes you to the setup/help of the specific protocol
|
|
setup_url = 'https://github.com/caronc/apprise/wiki/Notify_pushover'
|
|
|
|
# Pushover uses the http protocol with JSON requests
|
|
notify_url = 'https://api.pushover.net/1/messages.json'
|
|
|
|
# Support attachments
|
|
attachment_support = True
|
|
|
|
# The maximum allowable characters allowed in the body per message
|
|
body_maxlen = 1024
|
|
|
|
# Default Pushover sound
|
|
default_pushover_sound = PushoverSound.PUSHOVER
|
|
|
|
# 2.5MB is the maximum supported image filesize as per documentation
|
|
# here: https://pushover.net/api#attachments (Dec 26th, 2019)
|
|
attach_max_size_bytes = 2621440
|
|
|
|
# The regular expression of the current attachment supported mime types
|
|
# At this time it is only images
|
|
attach_supported_mime_type = r'^image/.*'
|
|
|
|
# Define object templates
|
|
templates = (
|
|
'{schema}://{user_key}@{token}',
|
|
'{schema}://{user_key}@{token}/{targets}',
|
|
)
|
|
|
|
# Define our template tokens
|
|
template_tokens = dict(NotifyBase.template_tokens, **{
|
|
'user_key': {
|
|
'name': _('User Key'),
|
|
'type': 'string',
|
|
'private': True,
|
|
'required': True,
|
|
},
|
|
'token': {
|
|
'name': _('Access Token'),
|
|
'type': 'string',
|
|
'private': True,
|
|
'required': True,
|
|
},
|
|
'target_device': {
|
|
'name': _('Target Device'),
|
|
'type': 'string',
|
|
'regex': (r'^[a-z0-9_-]{1,25}$', 'i'),
|
|
'map_to': 'targets',
|
|
},
|
|
'targets': {
|
|
'name': _('Targets'),
|
|
'type': 'list:string',
|
|
},
|
|
})
|
|
|
|
# Define our template arguments
|
|
template_args = dict(NotifyBase.template_args, **{
|
|
'priority': {
|
|
'name': _('Priority'),
|
|
'type': 'choice:int',
|
|
'values': PUSHOVER_PRIORITIES,
|
|
'default': PushoverPriority.NORMAL,
|
|
},
|
|
'sound': {
|
|
'name': _('Sound'),
|
|
'type': 'string',
|
|
'regex': (r'^[a-z]{1,12}$', 'i'),
|
|
'default': PushoverSound.PUSHOVER,
|
|
},
|
|
'url': {
|
|
'name': _('URL'),
|
|
'map_to': 'supplemental_url',
|
|
'type': 'string',
|
|
},
|
|
'url_title': {
|
|
'name': _('URL Title'),
|
|
'map_to': 'supplemental_url_title',
|
|
'type': 'string'
|
|
},
|
|
'retry': {
|
|
'name': _('Retry'),
|
|
'type': 'int',
|
|
'min': 30,
|
|
'default': 900, # 15 minutes
|
|
},
|
|
'expire': {
|
|
'name': _('Expire'),
|
|
'type': 'int',
|
|
'min': 0,
|
|
'max': 10800,
|
|
'default': 3600, # 1 hour
|
|
},
|
|
'to': {
|
|
'alias_of': 'targets',
|
|
},
|
|
})
|
|
|
|
def __init__(self, user_key, token, targets=None, priority=None,
|
|
sound=None, retry=None, expire=None, supplemental_url=None,
|
|
supplemental_url_title=None, **kwargs):
|
|
"""
|
|
Initialize Pushover Object
|
|
"""
|
|
super().__init__(**kwargs)
|
|
|
|
# Access Token (associated with project)
|
|
self.token = validate_regex(token)
|
|
if not self.token:
|
|
msg = 'An invalid Pushover Access Token ' \
|
|
'({}) was specified.'.format(token)
|
|
self.logger.warning(msg)
|
|
raise TypeError(msg)
|
|
|
|
# User Key (associated with project)
|
|
self.user_key = validate_regex(user_key)
|
|
if not self.user_key:
|
|
msg = 'An invalid Pushover User Key ' \
|
|
'({}) was specified.'.format(user_key)
|
|
self.logger.warning(msg)
|
|
raise TypeError(msg)
|
|
|
|
# Track our valid devices
|
|
targets = parse_list(targets)
|
|
|
|
# Track any invalid entries
|
|
self.invalid_targets = list()
|
|
|
|
if len(targets) == 0:
|
|
self.targets = (PUSHOVER_SEND_TO_ALL, )
|
|
|
|
else:
|
|
self.targets = []
|
|
for target in targets:
|
|
result = VALIDATE_DEVICE.match(target)
|
|
if result:
|
|
# Store device information
|
|
self.targets.append(result.group('device'))
|
|
continue
|
|
|
|
self.logger.warning(
|
|
'Dropped invalid Pushover device '
|
|
'({}) specified.'.format(target),
|
|
)
|
|
self.invalid_targets.append(target)
|
|
|
|
# Setup supplemental url
|
|
self.supplemental_url = supplemental_url
|
|
self.supplemental_url_title = supplemental_url_title
|
|
|
|
# Setup our sound
|
|
self.sound = NotifyPushover.default_pushover_sound \
|
|
if not isinstance(sound, str) else sound.lower()
|
|
if self.sound and self.sound not in PUSHOVER_SOUNDS:
|
|
msg = 'Using custom sound specified ({}). '.format(sound)
|
|
self.logger.debug(msg)
|
|
|
|
# The Priority of the message
|
|
self.priority = int(
|
|
NotifyPushover.template_args['priority']['default']
|
|
if priority is None else
|
|
next((
|
|
v for k, v in PUSHOVER_PRIORITY_MAP.items()
|
|
if str(priority).lower().startswith(k)),
|
|
NotifyPushover.template_args['priority']['default']))
|
|
|
|
# The following are for emergency alerts
|
|
if self.priority == PushoverPriority.EMERGENCY:
|
|
|
|
# How often to resend notification, in seconds
|
|
self.retry = self.template_args['retry']['default']
|
|
try:
|
|
self.retry = int(retry)
|
|
except (ValueError, TypeError):
|
|
# Do nothing
|
|
pass
|
|
|
|
# How often to resend notification, in seconds
|
|
self.expire = self.template_args['expire']['default']
|
|
try:
|
|
self.expire = int(expire)
|
|
except (ValueError, TypeError):
|
|
# Do nothing
|
|
pass
|
|
|
|
if self.retry < 30:
|
|
msg = 'Pushover retry must be at least 30 seconds.'
|
|
self.logger.warning(msg)
|
|
raise TypeError(msg)
|
|
|
|
if self.expire < 0 or self.expire > 10800:
|
|
msg = 'Pushover expire must reside in the range of ' \
|
|
'0 to 10800 seconds.'
|
|
self.logger.warning(msg)
|
|
raise TypeError(msg)
|
|
return
|
|
|
|
def send(self, body, title='', notify_type=NotifyType.INFO, attach=None,
|
|
**kwargs):
|
|
"""
|
|
Perform Pushover Notification
|
|
"""
|
|
|
|
if not self.targets:
|
|
# There were no services to notify
|
|
self.logger.warning(
|
|
'There were no Pushover targets to notify.')
|
|
return False
|
|
|
|
# prepare JSON Object
|
|
payload = {
|
|
'token': self.token,
|
|
'user': self.user_key,
|
|
'priority': str(self.priority),
|
|
'title': title if title else self.app_desc,
|
|
'message': body,
|
|
'device': ','.join(self.targets),
|
|
'sound': self.sound,
|
|
}
|
|
|
|
if self.supplemental_url:
|
|
payload['url'] = self.supplemental_url
|
|
|
|
if self.supplemental_url_title:
|
|
payload['url_title'] = self.supplemental_url_title
|
|
|
|
if self.notify_format == NotifyFormat.HTML:
|
|
# https://pushover.net/api#html
|
|
payload['html'] = 1
|
|
|
|
elif self.notify_format == NotifyFormat.MARKDOWN:
|
|
payload['message'] = convert_between(
|
|
NotifyFormat.MARKDOWN, NotifyFormat.HTML, body)
|
|
payload['html'] = 1
|
|
|
|
if self.priority == PushoverPriority.EMERGENCY:
|
|
payload.update({'retry': self.retry, 'expire': self.expire})
|
|
|
|
if attach and self.attachment_support:
|
|
# Create a copy of our payload
|
|
_payload = payload.copy()
|
|
|
|
# Send with attachments
|
|
for no, attachment in enumerate(attach):
|
|
if no or not body:
|
|
# To handle multiple attachments, clean up our message
|
|
_payload['message'] = attachment.name
|
|
|
|
if not self._send(_payload, attachment):
|
|
# Mark our failure
|
|
return False
|
|
|
|
# Clear our title if previously set
|
|
_payload['title'] = ''
|
|
|
|
# No need to alarm for each consecutive attachment uploaded
|
|
# afterwards
|
|
_payload['sound'] = PushoverSound.NONE
|
|
|
|
else:
|
|
# Simple send
|
|
return self._send(payload)
|
|
|
|
return True
|
|
|
|
def _send(self, payload, attach=None):
|
|
"""
|
|
Wrapper to the requests (post) object
|
|
"""
|
|
|
|
if isinstance(attach, AttachBase):
|
|
# Perform some simple error checking
|
|
if not attach:
|
|
# We could not access the attachment
|
|
self.logger.error(
|
|
'Could not access attachment {}.'.format(
|
|
attach.url(privacy=True)))
|
|
return False
|
|
|
|
# Perform some basic checks as we want to gracefully skip
|
|
# over unsupported mime types.
|
|
if not re.match(
|
|
self.attach_supported_mime_type,
|
|
attach.mimetype,
|
|
re.I):
|
|
# No problem; we just don't support this attachment
|
|
# type; gracefully move along
|
|
self.logger.debug(
|
|
'Ignored unsupported Pushover attachment ({}): {}'
|
|
.format(
|
|
attach.mimetype,
|
|
attach.url(privacy=True)))
|
|
|
|
attach = None
|
|
|
|
else:
|
|
# If we get here, we're dealing with a supported image.
|
|
# Verify that the filesize is okay though.
|
|
file_size = len(attach)
|
|
if not (file_size > 0
|
|
and file_size <= self.attach_max_size_bytes):
|
|
|
|
# File size is no good
|
|
self.logger.warning(
|
|
'Pushover attachment size ({}B) exceeds limit: {}'
|
|
.format(file_size, attach.url(privacy=True)))
|
|
|
|
return False
|
|
|
|
self.logger.debug(
|
|
'Posting Pushover attachment {}'.format(
|
|
attach.url(privacy=True)))
|
|
|
|
# Default Header
|
|
headers = {
|
|
'User-Agent': self.app_id,
|
|
}
|
|
|
|
# Authentication
|
|
auth = (self.token, '')
|
|
|
|
# Some default values for our request object to which we'll update
|
|
# depending on what our payload is
|
|
files = None
|
|
|
|
self.logger.debug('Pushover POST URL: %s (cert_verify=%r)' % (
|
|
self.notify_url, self.verify_certificate,
|
|
))
|
|
self.logger.debug('Pushover Payload: %s' % str(payload))
|
|
|
|
# Always call throttle before any remote server i/o is made
|
|
self.throttle()
|
|
|
|
try:
|
|
# Open our attachment path if required:
|
|
if attach:
|
|
files = {'attachment': (attach.name, open(attach.path, 'rb'))}
|
|
|
|
r = requests.post(
|
|
self.notify_url,
|
|
data=payload,
|
|
headers=headers,
|
|
files=files,
|
|
auth=auth,
|
|
verify=self.verify_certificate,
|
|
timeout=self.request_timeout,
|
|
)
|
|
|
|
if r.status_code != requests.codes.ok:
|
|
# We had a problem
|
|
status_str = \
|
|
NotifyPushover.http_response_code_lookup(
|
|
r.status_code, PUSHOVER_HTTP_ERROR_MAP)
|
|
|
|
self.logger.warning(
|
|
'Failed to send Pushover notification to {}: '
|
|
'{}{}error={}.'.format(
|
|
payload['device'],
|
|
status_str,
|
|
', ' if status_str else '',
|
|
r.status_code))
|
|
|
|
self.logger.debug(
|
|
'Response Details:\r\n{}'.format(r.content))
|
|
|
|
return False
|
|
|
|
else:
|
|
self.logger.info(
|
|
'Sent Pushover notification to %s.' % payload['device'])
|
|
|
|
except requests.RequestException as e:
|
|
self.logger.warning(
|
|
'A Connection error occurred sending Pushover:%s ' % (
|
|
payload['device']) + 'notification.'
|
|
)
|
|
self.logger.debug('Socket Exception: %s' % str(e))
|
|
|
|
return False
|
|
|
|
except (OSError, IOError) as e:
|
|
self.logger.warning(
|
|
'An I/O error occurred while reading {}.'.format(
|
|
attach.name if attach else 'attachment'))
|
|
self.logger.debug('I/O Exception: %s' % str(e))
|
|
return False
|
|
|
|
finally:
|
|
# Close our file (if it's open) stored in the second element
|
|
# of our files tuple (index 1)
|
|
if files:
|
|
files['attachment'][1].close()
|
|
|
|
return True
|
|
|
|
def url(self, privacy=False, *args, **kwargs):
|
|
"""
|
|
Returns the URL built dynamically based on specified arguments.
|
|
"""
|
|
|
|
# Define any URL parameters
|
|
params = {
|
|
'priority':
|
|
PUSHOVER_PRIORITIES[self.template_args['priority']['default']]
|
|
if self.priority not in PUSHOVER_PRIORITIES
|
|
else PUSHOVER_PRIORITIES[self.priority],
|
|
}
|
|
|
|
# Only add expire and retry for emergency messages,
|
|
# pushover ignores for all other priorities
|
|
if self.priority == PushoverPriority.EMERGENCY:
|
|
params.update({'expire': self.expire, 'retry': self.retry})
|
|
|
|
# Extend our parameters
|
|
params.update(self.url_parameters(privacy=privacy, *args, **kwargs))
|
|
|
|
# Escape our devices
|
|
devices = '/'.join(
|
|
[NotifyPushover.quote(x, safe='')
|
|
for x in chain(self.targets, self.invalid_targets)])
|
|
|
|
if devices == PUSHOVER_SEND_TO_ALL:
|
|
# keyword is reserved for internal usage only; it's safe to remove
|
|
# it from the devices list
|
|
devices = ''
|
|
|
|
return '{schema}://{user_key}@{token}/{devices}/?{params}'.format(
|
|
schema=self.secure_protocol,
|
|
user_key=self.pprint(self.user_key, privacy, safe=''),
|
|
token=self.pprint(self.token, privacy, safe=''),
|
|
devices=devices,
|
|
params=NotifyPushover.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
|
|
|
|
# Set our priority
|
|
if 'priority' in results['qsd'] and len(results['qsd']['priority']):
|
|
results['priority'] = \
|
|
NotifyPushover.unquote(results['qsd']['priority'])
|
|
|
|
# Retrieve all of our targets
|
|
results['targets'] = NotifyPushover.split_path(results['fullpath'])
|
|
|
|
# User Key is retrieved from the user
|
|
results['user_key'] = NotifyPushover.unquote(results['user'])
|
|
|
|
# Get the sound
|
|
if 'sound' in results['qsd'] and len(results['qsd']['sound']):
|
|
results['sound'] = \
|
|
NotifyPushover.unquote(results['qsd']['sound'])
|
|
|
|
# Get the supplementary url
|
|
if 'url' in results['qsd'] and len(results['qsd']['url']):
|
|
results['supplemental_url'] = NotifyPushover.unquote(
|
|
results['qsd']['url']
|
|
)
|
|
if 'url_title' in results['qsd'] and len(results['qsd']['url_title']):
|
|
results['supplemental_url_title'] = results['qsd']['url_title']
|
|
|
|
# Get expire and retry
|
|
if 'expire' in results['qsd'] and len(results['qsd']['expire']):
|
|
results['expire'] = results['qsd']['expire']
|
|
if 'retry' in results['qsd'] and len(results['qsd']['retry']):
|
|
results['retry'] = results['qsd']['retry']
|
|
|
|
# The 'to' makes it easier to use yaml configuration
|
|
if 'to' in results['qsd'] and len(results['qsd']['to']):
|
|
results['targets'] += \
|
|
NotifyPushover.parse_list(results['qsd']['to'])
|
|
|
|
# Token
|
|
results['token'] = NotifyPushover.unquote(results['host'])
|
|
|
|
return results
|